334 lines
13 KiB
JavaScript
334 lines
13 KiB
JavaScript
"use strict";
|
|
|
|
import {
|
|
CmapModel,
|
|
ConceptMapConcept,
|
|
ConceptMapConnector,
|
|
ConceptMapPhrase,
|
|
PLACEMENT_FIELDS
|
|
} from "../model/concept-map.js";
|
|
import { CONCEPT_FIELDS } from "../model/concept-repository.js";
|
|
import { debugPrefix } from "../cmap-utils.js";
|
|
|
|
/**
|
|
* Keeps the editor's rendered records synchronized with its domain model.
|
|
*
|
|
* The editor remains the owner of drawing records and delegates document
|
|
* operations here. This controller does not store a second copy of items.
|
|
*/
|
|
export class CmapDocumentController {
|
|
constructor(editor) {
|
|
this.editor = editor;
|
|
}
|
|
|
|
get model() { return this.editor.model; }
|
|
get items() { return this.editor.items; }
|
|
get connectors() { return this.editor.connectors; }
|
|
get unresolvedConnectors() { return this.editor.unresolvedConnectors; }
|
|
|
|
registerModelItem(record) {
|
|
const existing = this.model.conceptMap.item(record.id);
|
|
if (existing) return existing;
|
|
const values = { kind: record.kind };
|
|
for (const field of PLACEMENT_FIELDS) {
|
|
if (field === "parentSubmapId") {
|
|
values.parentSubmapId = record.parentSubmap ? record.parentSubmap.id : null;
|
|
} else if (field === "hiddenContexts") {
|
|
values.hiddenContexts = Array.from(record.hiddenContexts || []);
|
|
} else if (field === "x" || field === "y" || field === "width" || field === "height") {
|
|
values[field] = record.node ? Number(record.node.attr(field)) : Number(record[field]);
|
|
} else {
|
|
values[field] = record[field];
|
|
}
|
|
}
|
|
let modelItem;
|
|
if (record.kind === "phrase") {
|
|
modelItem = new ConceptMapPhrase(record.id, {
|
|
...values,
|
|
label: record.label,
|
|
synopsis: record.synopsis
|
|
});
|
|
} else {
|
|
const conceptValues = {};
|
|
for (const field of CONCEPT_FIELDS) conceptValues[field] = record[field];
|
|
this.model.repository.ensure(record.conceptId, conceptValues);
|
|
modelItem = new ConceptMapConcept(record.id, record.conceptId, values);
|
|
}
|
|
this.model.conceptMap.addItem(modelItem);
|
|
return modelItem;
|
|
}
|
|
|
|
bindRecordToModel(record, modelItem) {
|
|
if (record.modelItem === modelItem) return record;
|
|
Object.defineProperty(record, "modelItem", { value: modelItem, configurable: true });
|
|
Object.defineProperty(record, "kind", {
|
|
configurable: true,
|
|
get: () => modelItem.kind,
|
|
set: (value) => { modelItem.kind = value; }
|
|
});
|
|
Object.defineProperty(record, "conceptId", {
|
|
configurable: true,
|
|
get: () => modelItem.conceptId || null,
|
|
set: (conceptId) => {
|
|
if (modelItem instanceof ConceptMapPhrase || !conceptId) return;
|
|
const current = this.model.repository.concept(modelItem.conceptId);
|
|
const target = this.model.repository.concept(conceptId) ||
|
|
this.model.repository.ensure(conceptId, current ? current.toDocument() : {});
|
|
modelItem.conceptId = target.id;
|
|
}
|
|
});
|
|
for (const field of ["label", "synopsis"]) {
|
|
Object.defineProperty(record, field, {
|
|
configurable: true,
|
|
get: () => modelItem instanceof ConceptMapPhrase ? modelItem[field] :
|
|
this.model.repository.requireConcept(modelItem.conceptId)[field],
|
|
set: (value) => {
|
|
if (modelItem instanceof ConceptMapPhrase) modelItem[field] = String(value || "");
|
|
else this.model.repository.requireConcept(modelItem.conceptId).update({ [field]: value });
|
|
}
|
|
});
|
|
}
|
|
for (const field of CONCEPT_FIELDS.filter((name) => name !== "label" && name !== "synopsis")) {
|
|
Object.defineProperty(record, field, {
|
|
configurable: true,
|
|
get: () => modelItem instanceof ConceptMapPhrase ? null :
|
|
this.model.repository.requireConcept(modelItem.conceptId)[field],
|
|
set: (value) => {
|
|
if (!(modelItem instanceof ConceptMapPhrase)) {
|
|
this.model.repository.requireConcept(modelItem.conceptId).update({ [field]: value });
|
|
}
|
|
}
|
|
});
|
|
}
|
|
for (const field of PLACEMENT_FIELDS.filter((name) =>
|
|
name !== "parentSubmapId" && name !== "x" && name !== "y")) {
|
|
Object.defineProperty(record, field, {
|
|
configurable: true,
|
|
get: () => modelItem[field],
|
|
set: (value) => { modelItem[field] = value; }
|
|
});
|
|
}
|
|
Object.defineProperty(record, "parentSubmap", {
|
|
configurable: true,
|
|
get: () => modelItem.parentSubmapId === null ? null :
|
|
this.editor.itemRecord(modelItem.parentSubmapId),
|
|
set: (value) => { modelItem.parentSubmapId = value ? Number(value.id) : null; }
|
|
});
|
|
return record;
|
|
}
|
|
|
|
attachRecordToModel(record) {
|
|
return this.bindRecordToModel(record, this.registerModelItem(record));
|
|
}
|
|
|
|
attachConnectorToModel(record) {
|
|
let modelConnector = this.model.conceptMap.connector(record.id);
|
|
if (!modelConnector) {
|
|
modelConnector = this.model.conceptMap.addConnector(new ConceptMapConnector(
|
|
record.id, record.source.id, record.target.id, record));
|
|
}
|
|
Object.defineProperty(record, "modelConnector", {
|
|
value: modelConnector,
|
|
configurable: true
|
|
});
|
|
for (const field of ["hasArrow", "lineColor", "lineWidth"]) {
|
|
Object.defineProperty(record, field, {
|
|
configurable: true,
|
|
get: () => modelConnector[field],
|
|
set: (value) => { modelConnector[field] = value; }
|
|
});
|
|
}
|
|
for (const [field, idField] of [["source", "sourceId"], ["target", "targetId"]]) {
|
|
Object.defineProperty(record, field, {
|
|
configurable: true,
|
|
get: () => this.editor.itemRecord(modelConnector[idField]),
|
|
set: (value) => { modelConnector[idField] = Number(value.id); }
|
|
});
|
|
}
|
|
return record;
|
|
}
|
|
|
|
synchronizeModel() {
|
|
const itemIds = new Set(this.items.map((record) => Number(record.id)));
|
|
for (const modelItem of this.model.conceptMap.items()) {
|
|
if (!itemIds.has(modelItem.id)) this.model.conceptMap.removeItem(modelItem.id);
|
|
}
|
|
for (const record of this.items) {
|
|
const modelItem = this.registerModelItem(record);
|
|
this.bindRecordToModel(record, modelItem);
|
|
if (record.node) {
|
|
modelItem.x = Number(record.node.attr("x"));
|
|
modelItem.y = Number(record.node.attr("y"));
|
|
modelItem.width = Number(record.node.attr("width"));
|
|
modelItem.height = Number(record.node.attr("height"));
|
|
}
|
|
}
|
|
const connectorIds = new Set();
|
|
for (const connector of this.connectors) {
|
|
connectorIds.add(Number(connector.id));
|
|
let modelConnector = this.model.conceptMap.connector(connector.id);
|
|
if (!modelConnector) {
|
|
modelConnector = this.model.conceptMap.addConnector(new ConceptMapConnector(
|
|
connector.id, connector.source.id, connector.target.id, connector));
|
|
}
|
|
modelConnector.sourceId = Number(connector.source.id);
|
|
modelConnector.targetId = Number(connector.target.id);
|
|
modelConnector.hasArrow = connector.hasArrow;
|
|
modelConnector.lineColor = connector.lineColor;
|
|
modelConnector.lineWidth = connector.lineWidth;
|
|
}
|
|
for (const connector of this.unresolvedConnectors) {
|
|
connectorIds.add(Number(connector.id));
|
|
if (!this.model.conceptMap.connector(connector.id)) {
|
|
this.model.conceptMap.addConnector(new ConceptMapConnector(
|
|
connector.id, connector.sourceId, connector.targetId, connector));
|
|
}
|
|
}
|
|
for (const connector of this.model.conceptMap.connectors()) {
|
|
if (!connectorIds.has(connector.id)) this.model.conceptMap.removeConnector(connector.id);
|
|
}
|
|
this.model.conceptMap.metadata = this.editor.documentMetadata;
|
|
this.model.conceptMap.setConceptMapReferences([...this.editor.conceptMaps.values()]);
|
|
this.editor.conceptMaps = this.model.conceptMap.conceptMapsById;
|
|
return this.model;
|
|
}
|
|
|
|
historySnapshot() {
|
|
return JSON.stringify(this.toDocument());
|
|
}
|
|
|
|
clearDocument() {
|
|
this.editor.clearSelection(false);
|
|
for (const connector of this.connectors) connector.link.remove();
|
|
for (const record of this.items) {
|
|
if (record.submapAnchorLineElement) record.submapAnchorLineElement.remove();
|
|
record.node.remove();
|
|
}
|
|
this.editor.items = [];
|
|
this.editor.connectors = [];
|
|
this.editor.unresolvedConnectors = [];
|
|
this.editor.model = CmapModel.fromDocument({});
|
|
this.editor.conceptMaps = this.model.conceptMap.conceptMapsById;
|
|
this.editor.documentMetadata = this.model.conceptMap.metadata;
|
|
this.editor.activeMapRoot = null;
|
|
this.editor.mapHistory = [];
|
|
this.editor.nextId = 1;
|
|
this.editor.nextConnectorId = 1;
|
|
if (this.editor.boundaryReferenceView) this.editor.boundaryReferenceView.clear();
|
|
this.editor.nextGroupId = 1;
|
|
}
|
|
|
|
restoreHistoryDocument(snapshot) {
|
|
const activeMapRootId = this.editor.activeMapRoot ? this.editor.activeMapRoot.id : null;
|
|
const mapHistoryIds = this.editor.mapHistory.map((record) => record.id);
|
|
this.clearDocument();
|
|
this.editor.loadDocument(JSON.parse(snapshot));
|
|
this.editor.activeMapRoot = this.items.find((item) => item.id === activeMapRootId) || null;
|
|
this.editor.mapHistory = mapHistoryIds
|
|
.map((id) => this.items.find((item) => item.id === id))
|
|
.filter(Boolean);
|
|
this.editor.applyCurrentContextLayout();
|
|
const reference = this.editor.activeMapRoot ? this.editor.activeMapRoot.mapReference : null;
|
|
if (this.editor.onMapChange) this.editor.onMapChange(reference, this.editor.activeMapRoot);
|
|
this.editor.notifySelection();
|
|
}
|
|
|
|
replaceDocument(document) {
|
|
if (!document || typeof document !== "object") return false;
|
|
this.editor.history.replace(() => {
|
|
this.clearDocument();
|
|
this.loadDocument(document);
|
|
});
|
|
this.editor.notifySelection();
|
|
return true;
|
|
}
|
|
|
|
replaceModel(model) {
|
|
if (!(model instanceof CmapModel)) throw new TypeError("A CmapModel is required");
|
|
return this.replaceDocument(model.toDocument());
|
|
}
|
|
|
|
toDocument() {
|
|
this.editor.saveCurrentContextLayout();
|
|
this.editor.refreshConceptMapReferences();
|
|
return this.synchronizeModel().toDocument();
|
|
}
|
|
|
|
currentModel() {
|
|
this.editor.saveCurrentContextLayout();
|
|
this.editor.refreshConceptMapReferences();
|
|
return this.synchronizeModel();
|
|
}
|
|
|
|
loadModel(model) {
|
|
if (!(model instanceof CmapModel)) throw new TypeError("A CmapModel is required");
|
|
this.loadDocument(model.toDocument());
|
|
}
|
|
|
|
loadDocument(document = {}) {
|
|
if (this.items.length || this.connectors.length || this.unresolvedConnectors.length) {
|
|
throw new Error("A concept map document can only be loaded into an empty editor");
|
|
}
|
|
this.editor.model = CmapModel.fromDocument(document);
|
|
this.editor.conceptMaps = this.model.conceptMap.conceptMapsById;
|
|
this.editor.documentMetadata = this.model.conceptMap.metadata;
|
|
const itemDocuments = this.model.conceptMap.items().map((item) => item.toDocument());
|
|
const connectorDocuments = this.model.conceptMap.connectors().map((connector) => connector.toDocument());
|
|
const records = new Map();
|
|
|
|
for (const itemDocument of itemDocuments) {
|
|
const concept = this.model.repository.concept(itemDocument.conceptId)?.toDocument() || {};
|
|
const record = this.editor.addItem({
|
|
...itemDocument,
|
|
...concept,
|
|
id: itemDocument.id,
|
|
conceptId: itemDocument.conceptId,
|
|
kind: itemDocument.kind || concept.kind || "concept",
|
|
parentSubmap: null
|
|
});
|
|
record.autoWidth = Boolean(itemDocument.autoWidth);
|
|
record.autoHeight = Boolean(itemDocument.autoHeight);
|
|
record.fitContentPending = false;
|
|
record.expanded = Boolean(itemDocument.expanded);
|
|
record.submapInitialized = Boolean(itemDocument.submapInitialized);
|
|
records.set(record.id, record);
|
|
}
|
|
const groupNumbers = itemDocuments
|
|
.map((item) => /^(?:group)-(\d+)$/.exec(item.groupId || ""))
|
|
.filter(Boolean)
|
|
.map((match) => Number(match[1]));
|
|
this.editor.nextGroupId = groupNumbers.length ? Math.max(...groupNumbers) + 1 : 1;
|
|
for (const itemDocument of itemDocuments) {
|
|
const record = records.get(Number(itemDocument.id));
|
|
const parent = records.get(Number(itemDocument.parentSubmapId)) || null;
|
|
if (record) record.parentSubmap = parent;
|
|
}
|
|
for (const connectorDocument of connectorDocuments) {
|
|
const source = records.get(Number(connectorDocument.sourceId));
|
|
const target = records.get(Number(connectorDocument.targetId));
|
|
if (!source || !target) {
|
|
this.editor.unresolvedConnectors.push({ ...connectorDocument });
|
|
const unresolvedId = Number(connectorDocument.id);
|
|
if (Number.isInteger(unresolvedId) && unresolvedId > 0) {
|
|
this.editor.nextConnectorId = Math.max(this.editor.nextConnectorId, unresolvedId + 1);
|
|
}
|
|
console.warn(`${debugPrefix} relation retained without rendering`, {
|
|
relationId: connectorDocument.id || null,
|
|
sourceId: connectorDocument.sourceId,
|
|
targetId: connectorDocument.targetId,
|
|
missingSource: !source,
|
|
missingTarget: !target
|
|
});
|
|
continue;
|
|
}
|
|
this.editor.addConnector(source, target, connectorDocument.hasArrow !== false, connectorDocument);
|
|
}
|
|
this.editor.reconcilePhraseMembership();
|
|
this.editor.refreshConceptMapReferences();
|
|
this.editor.applyCurrentContextLayout();
|
|
this.editor.clearSelection();
|
|
if (!this.editor.history.isRestoring) this.editor.resetHistory();
|
|
return this.editor;
|
|
}
|
|
}
|