refactoring
This commit is contained in:
+270
-269
@@ -1,3 +1,13 @@
|
||||
import {
|
||||
CmapModel,
|
||||
ConceptMapConcept,
|
||||
ConceptMapConnector,
|
||||
ConceptMapPhrase,
|
||||
PLACEMENT_FIELDS
|
||||
} from "./model/concept-map.js";
|
||||
import { CONCEPT_FIELDS } from "./model/concept-repository.js";
|
||||
import { CmapView } from "./cmap-view.js";
|
||||
|
||||
/*
|
||||
* Racket Wiki editor layer for the bundled racket-wiki CMap component.
|
||||
*
|
||||
@@ -152,14 +162,18 @@
|
||||
resizeConcept: options.resizeConceptLabel || "Resize concept",
|
||||
relation: options.relationLabel || "Relation"
|
||||
};
|
||||
this.map = this.CmapFactory(canvas);
|
||||
this.map.onSelection((component, event) => this.handleMapSelection(component, event));
|
||||
this.map.onActivation((component, event) => this.handleMapActivation(component, event));
|
||||
this.view = new CmapView(
|
||||
canvas,
|
||||
this.CmapFactory,
|
||||
(component, event) => this.handleMapSelection(component, event),
|
||||
(component, event) => this.handleMapActivation(component, event));
|
||||
this.map = this.view.map;
|
||||
this.items = [];
|
||||
this.connectors = [];
|
||||
this.unresolvedConnectors = [];
|
||||
this.conceptMaps = new Map();
|
||||
this.documentMetadata = { tags: [], summary: "", explanationPageSlug: "" };
|
||||
this.model = CmapModel.fromDocument({});
|
||||
this.conceptMaps = this.model.conceptMap.conceptMapsById;
|
||||
this.documentMetadata = this.model.conceptMap.metadata;
|
||||
this.activeMapRoot = null;
|
||||
this.mapHistory = [];
|
||||
this.selectedItem = null;
|
||||
@@ -190,6 +204,229 @@
|
||||
});
|
||||
}
|
||||
|
||||
/** Return the view record that renders one model item id. */
|
||||
itemRecord(id) {
|
||||
return this.items.find((record) => Number(record.id) === Number(id)) || null;
|
||||
}
|
||||
|
||||
/** Return a copy of the records currently rendered by this editor. */
|
||||
itemRecords() {
|
||||
return [...this.items];
|
||||
}
|
||||
|
||||
/** Return rendered records that are visible in the active map context. */
|
||||
visibleItemRecords() {
|
||||
return this.items.filter((record) => this.isEffectiveItemVisible(record));
|
||||
}
|
||||
|
||||
containsItemRecord(record) {
|
||||
return this.items.includes(record);
|
||||
}
|
||||
|
||||
descendantItemRecords(record) {
|
||||
return this.items.filter((candidate) => this.isDescendantOf(candidate, record));
|
||||
}
|
||||
|
||||
setConceptMapReference(reference) {
|
||||
if (!reference?.id) throw new TypeError("A concept-map reference id is required");
|
||||
this.conceptMaps.set(reference.id, reference);
|
||||
return reference;
|
||||
}
|
||||
|
||||
itemCount() {
|
||||
return this.items.length;
|
||||
}
|
||||
|
||||
connectorCount() {
|
||||
return this.connectors.length + this.unresolvedConnectors.length;
|
||||
}
|
||||
|
||||
conceptRepository() {
|
||||
return this.model.repository;
|
||||
}
|
||||
|
||||
conceptMapModel() {
|
||||
return this.model.conceptMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the domain data behind a view record.
|
||||
* The model receives only serializable concept and placement fields.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an existing editor record a view onto its pure model item.
|
||||
* DOM handles stay on the record; all serializable fields live in the model.
|
||||
*/
|
||||
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.itemRecord(modelItem.parentSubmapId),
|
||||
set: (value) => { modelItem.parentSubmapId = value ? Number(value.id) : null; }
|
||||
});
|
||||
return record;
|
||||
}
|
||||
|
||||
/** Register and bind a record that was inserted through the editor view. */
|
||||
attachRecordToModel(record) {
|
||||
return this.bindRecordToModel(record, this.registerModelItem(record));
|
||||
}
|
||||
|
||||
/** Bind a rendered connector to its map-local connector model. */
|
||||
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.itemRecord(modelConnector[idField]),
|
||||
set: (value) => { modelConnector[idField] = Number(value.id); }
|
||||
});
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
/** Keep the pure model synchronized with geometry owned by the drawing engine. */
|
||||
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.documentMetadata;
|
||||
this.model.conceptMap.setConceptMapReferences([...this.conceptMaps.values()]);
|
||||
this.conceptMaps = this.model.conceptMap.conceptMapsById;
|
||||
return this.model;
|
||||
}
|
||||
|
||||
historySnapshot() {
|
||||
return JSON.stringify(this.toDocument());
|
||||
}
|
||||
@@ -266,8 +503,9 @@
|
||||
this.items = [];
|
||||
this.connectors = [];
|
||||
this.unresolvedConnectors = [];
|
||||
this.conceptMaps = new Map();
|
||||
this.documentMetadata = { tags: [], summary: "", explanationPageSlug: "" };
|
||||
this.model = CmapModel.fromDocument({});
|
||||
this.conceptMaps = this.model.conceptMap.conceptMapsById;
|
||||
this.documentMetadata = this.model.conceptMap.metadata;
|
||||
this.activeMapRoot = null;
|
||||
this.mapHistory = [];
|
||||
this.nextId = 1;
|
||||
@@ -423,7 +661,7 @@
|
||||
this.items.filter((item) => item.conceptId === record.conceptId &&
|
||||
item.kind !== "phrase").length + 1 : null;
|
||||
|
||||
const node = this.map.node({
|
||||
const node = this.view.createNode({
|
||||
content: this.itemHtml(record),
|
||||
contentType: "html",
|
||||
x: numberOr(options.x, 80 + ((id * 37) % 420)),
|
||||
@@ -437,6 +675,7 @@
|
||||
});
|
||||
record.node = node;
|
||||
this.items.push(record);
|
||||
this.attachRecordToModel(record);
|
||||
this.refreshConceptUsageIndicators(record.conceptId ? [record.conceptId] : []);
|
||||
this.refreshConceptMapReferences();
|
||||
node.onRendered((_renderedNode, element) => this.decorateItem(record, element));
|
||||
@@ -885,177 +1124,10 @@
|
||||
return record.mapReference;
|
||||
}
|
||||
|
||||
prepareStoredSubmapExtraction(record, targetSlug) {
|
||||
console.warn(`${debugPrefix} destructive submap extraction is disabled; use a shared derived view`, {
|
||||
itemId: record ? record.id : null,
|
||||
targetSlug
|
||||
});
|
||||
return null;
|
||||
/* istanbul ignore next -- retained only to read historical documents */
|
||||
if (!record || record.kind !== "submap" || !targetSlug) return null;
|
||||
prepareStoredSubmapExtraction(record, targetSlug, childMetadata = null) {
|
||||
if (!record || record.kind !== "submap") return null;
|
||||
this.ensureSubmapContents(record);
|
||||
const sourceDocument = this.toDocument();
|
||||
const descendants = this.items.filter((item) => this.isDescendantOf(item, record));
|
||||
const descendantIds = new Set(descendants.map((item) => item.id));
|
||||
const allItemIds = new Set(this.items.map((item) => item.id));
|
||||
if (!descendantIds.size) return null;
|
||||
|
||||
const descendantDocuments = sourceDocument.items.filter((item) => descendantIds.has(item.id));
|
||||
const headSource = sourceDocument.items.find((item) => Number(item.id) === Number(record.id));
|
||||
if (!headSource) return null;
|
||||
const scopedDocuments = [headSource, ...descendantDocuments];
|
||||
const left = Math.min(...scopedDocuments.map((item) => Number(item.x) || 0));
|
||||
const top = Math.min(...scopedDocuments.map((item) => Number(item.y) || 0));
|
||||
const placeInChild = (item, parentSubmapId, submapDepth) => {
|
||||
const x = (Number(item.x) || 0) - left + 80;
|
||||
const y = (Number(item.y) || 0) - top + 80;
|
||||
const rootLayout = item.layouts && item.layouts.root ? item.layouts.root : {};
|
||||
return {
|
||||
...item,
|
||||
parentSubmapId,
|
||||
submapDepth,
|
||||
x,
|
||||
y,
|
||||
layouts: {
|
||||
...(item.layouts || {}),
|
||||
root: {
|
||||
...rootLayout,
|
||||
x,
|
||||
y,
|
||||
width: Number(item.width),
|
||||
height: Number(item.height)
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
const childHead = {
|
||||
...placeInChild(headSource, null, 0),
|
||||
kind: "concept",
|
||||
childMap: null,
|
||||
expanded: false,
|
||||
submapInitialized: false,
|
||||
separateMap: false,
|
||||
mapReference: null,
|
||||
pageSlug: null,
|
||||
// The placement in the parent links to this map. The head on the
|
||||
// child itself must not be a link back to the same map.
|
||||
cmapSlug: null,
|
||||
parentCmapLink: false
|
||||
};
|
||||
const childItems = [childHead, ...descendantDocuments.map((item) =>
|
||||
placeInChild(
|
||||
item,
|
||||
Number(item.parentSubmapId) === Number(record.id) ? null : item.parentSubmapId,
|
||||
Math.max(0, Number(item.submapDepth || 0) - record.submapDepth - 1)
|
||||
))];
|
||||
|
||||
const parentConnectors = [];
|
||||
const childConnectors = [];
|
||||
const parentConnectorKeys = new Set();
|
||||
for (const connector of sourceDocument.connectors) {
|
||||
const sourceInside = descendantIds.has(Number(connector.sourceId));
|
||||
const targetInside = descendantIds.has(Number(connector.targetId));
|
||||
const sourceIsHead = Number(connector.sourceId) === Number(record.id);
|
||||
const targetIsHead = Number(connector.targetId) === Number(record.id);
|
||||
if ((sourceInside && targetInside) ||
|
||||
(sourceIsHead && targetInside) ||
|
||||
(sourceInside && targetIsHead)) {
|
||||
childConnectors.push({ ...connector });
|
||||
continue;
|
||||
}
|
||||
if (sourceInside || targetInside) {
|
||||
const outsideId = Number(sourceInside ? connector.targetId : connector.sourceId);
|
||||
if (!allItemIds.has(outsideId)) {
|
||||
childConnectors.push({ ...connector });
|
||||
continue;
|
||||
}
|
||||
const rewired = {
|
||||
...connector,
|
||||
sourceId: sourceInside ? record.id : connector.sourceId,
|
||||
targetId: targetInside ? record.id : connector.targetId
|
||||
};
|
||||
if (Number(rewired.sourceId) === Number(rewired.targetId)) continue;
|
||||
const key = `${rewired.sourceId}\u0000${rewired.targetId}\u0000${rewired.hasArrow !== false}`;
|
||||
if (parentConnectorKeys.has(key)) continue;
|
||||
parentConnectorKeys.add(key);
|
||||
parentConnectors.push(rewired);
|
||||
continue;
|
||||
}
|
||||
const key = `${connector.sourceId}\u0000${connector.targetId}\u0000${connector.hasArrow !== false}`;
|
||||
if (!parentConnectorKeys.has(key)) {
|
||||
parentConnectorKeys.add(key);
|
||||
parentConnectors.push({ ...connector });
|
||||
}
|
||||
}
|
||||
|
||||
const childConceptIds = new Set(childItems.map((item) => item.conceptId).filter(Boolean));
|
||||
const parentItems = sourceDocument.items
|
||||
.filter((item) => !descendantIds.has(item.id))
|
||||
.map((item) => item.id === record.id ? {
|
||||
...item,
|
||||
kind: "concept",
|
||||
childMap: null,
|
||||
expanded: false,
|
||||
submapInitialized: false,
|
||||
separateMap: false,
|
||||
mapReference: null,
|
||||
cmapSlug: targetSlug,
|
||||
parentCmapLink: false,
|
||||
borderColor: "#57834a"
|
||||
} : item);
|
||||
const parentConceptIds = new Set(parentItems.map((item) => item.conceptId).filter(Boolean));
|
||||
const conceptById = new Map(sourceDocument.concepts.map((concept) => [concept.id, concept]));
|
||||
const childConceptById = new Map(conceptById);
|
||||
const parentConceptById = new Map(conceptById);
|
||||
const headDocument = parentItems.find((item) => item.id === record.id);
|
||||
if (headDocument && headDocument.conceptId) {
|
||||
parentConceptById.set(headDocument.conceptId, {
|
||||
id: headDocument.conceptId,
|
||||
kind: "concept",
|
||||
label: headDocument.label,
|
||||
synopsis: headDocument.synopsis,
|
||||
aspects: headDocument.aspects || [],
|
||||
descriptionPageSlug: headDocument.descriptionPageSlug,
|
||||
pageSlug: null,
|
||||
cmapSlug: targetSlug,
|
||||
externalUrl: headDocument.externalUrl || null,
|
||||
parentCmapLink: false,
|
||||
imageSource: headDocument.imageSource || ""
|
||||
});
|
||||
childConceptById.set(headDocument.conceptId, {
|
||||
id: headDocument.conceptId,
|
||||
kind: "concept",
|
||||
label: childHead.label,
|
||||
synopsis: childHead.synopsis,
|
||||
aspects: childHead.aspects || [],
|
||||
descriptionPageSlug: childHead.descriptionPageSlug,
|
||||
pageSlug: null,
|
||||
cmapSlug: null,
|
||||
externalUrl: childHead.externalUrl || null,
|
||||
parentCmapLink: false,
|
||||
imageSource: childHead.imageSource || ""
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
childDocument: {
|
||||
schemaVersion: 2,
|
||||
concepts: Array.from(childConceptById.values()).filter((concept) => childConceptIds.has(concept.id)),
|
||||
items: childItems,
|
||||
connectors: childConnectors,
|
||||
conceptMaps: sourceDocument.conceptMaps.filter((reference) =>
|
||||
descendantIds.has(Number(reference.rootItemId)))
|
||||
},
|
||||
parentDocument: {
|
||||
...sourceDocument,
|
||||
concepts: Array.from(parentConceptById.values()).filter((concept) => parentConceptIds.has(concept.id)),
|
||||
items: parentItems,
|
||||
connectors: parentConnectors,
|
||||
conceptMaps: sourceDocument.conceptMaps.filter((reference) =>
|
||||
Number(reference.rootItemId) !== record.id &&
|
||||
!descendantIds.has(Number(reference.rootItemId)))
|
||||
}
|
||||
};
|
||||
return this.synchronizeModel().extractSubmap(record.id, targetSlug, childMetadata);
|
||||
}
|
||||
|
||||
replaceDocument(document) {
|
||||
@@ -1084,7 +1156,7 @@
|
||||
setZoom(percent) {
|
||||
const next = Math.max(25, Math.min(300, Number(percent) || 100));
|
||||
this.zoomFactor = next / 100;
|
||||
this.map.zoom(this.zoomFactor);
|
||||
this.view.setZoom(this.zoomFactor);
|
||||
this.ensureCanvasExtent(0, 0);
|
||||
debug("zoom changed", { percent: next, factor: this.zoomFactor });
|
||||
return next;
|
||||
@@ -1095,7 +1167,7 @@
|
||||
}
|
||||
|
||||
surfaceElement() {
|
||||
return this.canvas.querySelector(":scope > .rw-cmap-surface");
|
||||
return this.view.surfaceElement();
|
||||
}
|
||||
|
||||
ensureCanvasExtent(x, y, padding = 180) {
|
||||
@@ -1228,7 +1300,7 @@
|
||||
addConnector(source, target, hasArrow = true, options = {}) {
|
||||
const sourceCenter = this.itemCenter(source);
|
||||
const targetCenter = this.itemCenter(target);
|
||||
const link = this.map.link({
|
||||
const link = this.view.createConnector({
|
||||
content: "",
|
||||
width: 1,
|
||||
height: 1,
|
||||
@@ -1262,6 +1334,7 @@
|
||||
lineWidth: numberOr(Number(options.lineWidth), 2)
|
||||
};
|
||||
this.nextConnectorId = Math.max(this.nextConnectorId, record.id + 1);
|
||||
this.attachConnectorToModel(record);
|
||||
this.connectors.push(record);
|
||||
link.onRendered((_renderedLink, element) => this.decorateConnector(record, element));
|
||||
link.onConnectionChange((_changedLink, type, node) =>
|
||||
@@ -1782,103 +1855,31 @@
|
||||
summary: String(metadata.summary || "").trim(),
|
||||
explanationPageSlug: String(metadata.explanationPageSlug || "").trim()
|
||||
};
|
||||
this.model.conceptMap.metadata = this.documentMetadata;
|
||||
return this.getDocumentMetadata();
|
||||
}
|
||||
|
||||
/** Serialize the pure repository and map model at the API boundary. */
|
||||
toDocument() {
|
||||
this.saveCurrentContextLayout();
|
||||
this.refreshConceptMapReferences();
|
||||
const concepts = Array.from(new Map(this.items
|
||||
.filter((record) => record.conceptId && record.kind !== "phrase")
|
||||
.map((record) => [record.conceptId, {
|
||||
id: record.conceptId,
|
||||
label: record.label,
|
||||
synopsis: record.synopsis,
|
||||
aspects: record.aspects,
|
||||
tags: normalizeConceptTags(record.tags).filter((tag) => tag.type === "person"),
|
||||
descriptionPageSlug: record.descriptionPageSlug,
|
||||
pageSlug: record.pageSlug,
|
||||
cmapSlug: record.cmapSlug,
|
||||
externalUrl: record.externalUrl,
|
||||
imageSource: record.imageSource
|
||||
}])).values());
|
||||
return {
|
||||
schemaVersion: 2,
|
||||
metadata: this.getDocumentMetadata(),
|
||||
concepts,
|
||||
items: this.items.map((record) => ({
|
||||
id: record.id,
|
||||
conceptId: record.conceptId,
|
||||
kind: record.kind,
|
||||
...(record.kind === "phrase" ? {
|
||||
label: record.label,
|
||||
synopsis: record.synopsis
|
||||
} : {}),
|
||||
parentCmapLink: record.parentCmapLink,
|
||||
groupId: record.groupId,
|
||||
childMap: record.childMap,
|
||||
parentSubmapId: record.parentSubmap ? record.parentSubmap.id : null,
|
||||
submapDepth: record.submapDepth,
|
||||
expanded: record.expanded,
|
||||
submapInitialized: record.submapInitialized,
|
||||
separateMap: record.separateMap,
|
||||
mapReference: record.mapReference,
|
||||
hiddenContexts: Array.from(record.hiddenContexts),
|
||||
layouts: record.layouts,
|
||||
backgroundColor: record.backgroundColor,
|
||||
borderColor: record.borderColor,
|
||||
submapBackgroundColor: record.submapBackgroundColor,
|
||||
submapBorderColor: record.submapBorderColor,
|
||||
textColor: record.textColor,
|
||||
fontFamily: record.fontFamily,
|
||||
fontSize: record.fontSize,
|
||||
fontWeight: record.fontWeight,
|
||||
fontStyle: record.fontStyle,
|
||||
synopsisTextColor: record.synopsisTextColor,
|
||||
synopsisFontFamily: record.synopsisFontFamily,
|
||||
synopsisFontSize: record.synopsisFontSize,
|
||||
synopsisFontWeight: record.synopsisFontWeight,
|
||||
synopsisFontStyle: record.synopsisFontStyle,
|
||||
width: Number(record.node.attr("width")),
|
||||
height: Number(record.node.attr("height")),
|
||||
autoWidth: record.autoWidth,
|
||||
autoHeight: record.autoHeight,
|
||||
x: Number(record.node.attr("x")),
|
||||
y: Number(record.node.attr("y"))
|
||||
})),
|
||||
connectors: this.connectors.map((connector) => ({
|
||||
id: connector.id,
|
||||
sourceId: connector.source.id,
|
||||
targetId: connector.target.id,
|
||||
hasArrow: connector.hasArrow,
|
||||
lineColor: connector.lineColor,
|
||||
lineWidth: connector.lineWidth
|
||||
})).concat(this.unresolvedConnectors.map((connector) => ({ ...connector }))),
|
||||
conceptMaps: Array.from(this.conceptMaps.values())
|
||||
};
|
||||
return this.synchronizeModel().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");
|
||||
}
|
||||
const itemDocuments = Array.isArray(document.items) ? document.items : [];
|
||||
const connectorDocuments = Array.isArray(document.connectors) ? document.connectors : [];
|
||||
const concepts = new Map((Array.isArray(document.concepts) ? document.concepts : [])
|
||||
.filter((concept) => concept && concept.id)
|
||||
.map((concept) => [concept.id, concept]));
|
||||
this.model = CmapModel.fromDocument(document);
|
||||
this.conceptMaps = this.model.conceptMap.conceptMapsById;
|
||||
this.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();
|
||||
const metadata = document.metadata && typeof document.metadata === "object" ?
|
||||
document.metadata : {};
|
||||
this.documentMetadata = {
|
||||
tags: Array.isArray(metadata.tags) ? metadata.tags.map(String)
|
||||
.map((tag) => tag.trim()).filter(Boolean) : [],
|
||||
summary: String(metadata.summary || "").trim(),
|
||||
explanationPageSlug: String(metadata.explanationPageSlug || "").trim()
|
||||
};
|
||||
|
||||
for (const itemDocument of itemDocuments) {
|
||||
const concept = concepts.get(itemDocument.conceptId) || {};
|
||||
const concept = this.model.repository.concept(itemDocument.conceptId)?.toDocument() || {};
|
||||
const record = this.addItem({
|
||||
...itemDocument,
|
||||
...concept,
|
||||
@@ -1924,10 +1925,6 @@
|
||||
}
|
||||
this.addConnector(source, target, connectorDocument.hasArrow !== false, connectorDocument);
|
||||
}
|
||||
this.conceptMaps = new Map(
|
||||
(Array.isArray(document.conceptMaps) ? document.conceptMaps : [])
|
||||
.filter((reference) => reference && reference.id)
|
||||
.map((reference) => [reference.id, reference]));
|
||||
this.reconcilePhraseMembership();
|
||||
this.refreshConceptMapReferences();
|
||||
this.applyCurrentContextLayout();
|
||||
@@ -2812,7 +2809,7 @@
|
||||
item.submapAnchorLineElement = null;
|
||||
}
|
||||
}
|
||||
if (this.map && typeof this.map.destroy === "function") this.map.destroy();
|
||||
this.view.destroy();
|
||||
debug("editor destroyed");
|
||||
}
|
||||
|
||||
@@ -2862,12 +2859,16 @@
|
||||
if (!records.size && !connectors.size) return false;
|
||||
|
||||
this.clearSelection(false);
|
||||
for (const connector of connectors) connector.link.remove();
|
||||
for (const connector of connectors) {
|
||||
connector.link.remove();
|
||||
this.model.conceptMap.removeConnector(connector.id);
|
||||
}
|
||||
this.connectors = this.connectors.filter((connector) => !connectors.has(connector));
|
||||
for (const record of records) {
|
||||
if (record.submapFrameElement) record.submapFrameElement.remove();
|
||||
if (record.mapReference && record.mapReference.id) this.conceptMaps.delete(record.mapReference.id);
|
||||
record.node.remove();
|
||||
this.model.conceptMap.removeItem(record.id);
|
||||
}
|
||||
if (records.size && this.unresolvedConnectors.length) {
|
||||
const deletedIds = new Set(Array.from(records).map((record) => Number(record.id)));
|
||||
|
||||
Reference in New Issue
Block a user