"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; } }