refactoring van de cmap structuren bijna compleet
This commit is contained in:
@@ -0,0 +1,333 @@
|
||||
"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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user