refactoring

This commit is contained in:
2026-09-02 08:38:03 +02:00
parent 649ff0d7c5
commit 38f255c1a4
48 changed files with 6998 additions and 5577 deletions
+270 -269
View File
@@ -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)));