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
+18 -4
View File
@@ -9,9 +9,16 @@ racket-wiki. Its public additions include `onSelection` on a map and
same hit-test and drag lifecycle as selection, so it does not depend on a DOM
`dblclick` event that may be suppressed by dragging.
`cmap-racket-wiki.js` contains the wiki-specific editor model, selection state,
content-based initial sizing, resize and relation controls, page concepts and
submap concepts. Automatic sizing remains active while text is edited and is
`model/concept-repository.js` owns shared concepts, semantic concept relations
and concept ownership. `model/concept-map.js` owns one map's concept
placements, linking phrases, connectors, metadata and presentation values.
Neither model module contains DOM or drawing-engine objects.
`cmap-view.js` owns the canvas and the concrete `cmap.js` drawing instance.
`cmap-racket-wiki.js` contains the wiki-specific editor controller, selection
state, content-based initial sizing, resize and relation controls. Its view
records connect pure model ids to drawing nodes without making those nodes part
of the model. Automatic sizing remains active while text is edited and is
disabled when the user resizes a concept manually.
Selected concepts expose an edit handle. The host opens a modal editor for
@@ -42,6 +49,13 @@ Multiple selected items can be moved or deleted together. Grouping is a
structural operation: the editor creates an expanded sub-CMap with a named main
concept and adopts the selection as its contents. Ungrouping dissolves a
selected inline sub-CMap or moves selected child items one level outward.
**Make separate CMap** extracts the descendants into a complete stored CMap.
The former sub-CMap head remains in the parent as an ordinary concept whose
shared `cmapSlug` links to the new map. Ownership from that concept to the
extracted concepts is removed; internal child connectors move with the child,
while relations crossing the boundary terminate on the retained owner concept.
The same action converts an older `derivedView` in place: its slug, title and
metadata remain intact, but its document no longer depends on the parent CMap.
`toDocument` and `loadDocument` round-trip the complete editor model: items,
formatting, positions, connectors, recursive submap membership and promoted
@@ -49,7 +63,7 @@ map references. The wiki host persists this JSON document through its CMap API.
## JSON interchange
`/js/cmap-interchange.js` implements the versioned `racket-wiki-cmap-bundle`
`/js/wiki/cmap/interchange.js` implements the versioned `racket-wiki-cmap-bundle`
format. A bundle mirrors the normalized database model: `cmaps[]` contains the
complete placement and presentation document, `concepts[]` contains shared
content once per UUID, and `pages[]` contains the current Markdown, tags and
+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)));
+37
View File
@@ -0,0 +1,37 @@
/**
* Present a CMap through the bundled drawing engine.
*
* CmapView owns the canvas and concrete cmap.js instance. It creates and
* destroys drawing objects but contains no concept, placement or persistence
* rules; CmapEditor remains responsible for interpreting user interaction.
*/
export class CmapView {
constructor(canvas, CmapFactory, onSelection, onActivation) {
if (!(canvas instanceof Object)) throw new TypeError("A CMap canvas is required");
if (typeof CmapFactory !== "function") throw new TypeError("A CMap factory is required");
this.canvas = canvas;
this.map = CmapFactory(canvas);
this.map.onSelection(onSelection);
this.map.onActivation(onActivation);
}
createNode(attributes) {
return this.map.node(attributes);
}
createConnector(attributes) {
return this.map.link(attributes);
}
setZoom(factor) {
this.map.zoom(factor);
}
surfaceElement() {
return this.canvas.querySelector(":scope > .rw-cmap-surface");
}
destroy() {
if (typeof this.map.destroy === "function") this.map.destroy();
}
}
+9
View File
@@ -52,6 +52,11 @@ body.cmap-mode #main {
min-width: 16px;
}
#cmap-extract-selected {
flex: 0 0 auto;
white-space: nowrap;
}
.cmap-hidden-items {
position: relative;
flex: 0 0 auto;
@@ -466,6 +471,10 @@ body.cmap-mode #main {
z-index: 100000;
display: grid;
min-width: 210px;
max-height: calc(100vh - 16px);
max-height: calc(100dvh - 16px);
overflow-y: auto;
overscroll-behavior: contain;
padding: 5px;
border: 1px solid #8c95a3;
border-radius: 6px;
+422
View File
@@ -0,0 +1,422 @@
import { ConceptRepository } from "./concept-repository.js";
const PLACEMENT_FIELDS = [
"parentCmapLink", "groupId", "childMap", "parentSubmapId", "submapDepth",
"expanded", "submapInitialized", "separateMap", "mapReference",
"hiddenContexts", "layouts", "backgroundColor", "borderColor",
"submapBackgroundColor", "submapBorderColor", "textColor", "fontFamily",
"fontSize", "fontWeight", "fontStyle", "synopsisTextColor",
"synopsisFontFamily", "synopsisFontSize", "synopsisFontWeight",
"synopsisFontStyle", "width", "height", "autoWidth", "autoHeight", "x", "y"
];
function copy(value) {
return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
}
/** Join one repository concept to its placement and appearance on a CMap. */
export class ConceptMapConcept {
constructor(id, conceptId, values = {}) {
if (!Number.isInteger(Number(id)) || !conceptId) {
throw new TypeError("A concept placement requires an integer id and concept id");
}
this.id = Number(id);
this.conceptId = String(conceptId);
this.kind = values.kind || "concept";
this.update(values);
}
update(values = {}) {
for (const field of PLACEMENT_FIELDS) {
if (values[field] !== undefined) this[field] = copy(values[field]);
}
this.parentCmapLink = Boolean(this.parentCmapLink);
this.parentSubmapId = this.parentSubmapId === null || this.parentSubmapId === undefined ?
null : Number(this.parentSubmapId);
this.submapDepth = Number(this.submapDepth) || 0;
this.expanded = Boolean(this.expanded);
this.submapInitialized = Boolean(this.submapInitialized);
this.separateMap = Boolean(this.separateMap);
this.hiddenContexts = this.hiddenContexts instanceof Set ? this.hiddenContexts :
new Set(Array.isArray(this.hiddenContexts) ? this.hiddenContexts.map(String) : []);
this.layouts = this.layouts && typeof this.layouts === "object" ? this.layouts : {};
this.autoWidth = Boolean(this.autoWidth);
this.autoHeight = Boolean(this.autoHeight);
return this;
}
toDocument() {
const document = { id: this.id, conceptId: this.conceptId, kind: this.kind };
for (const field of PLACEMENT_FIELDS) {
document[field] = field === "hiddenContexts" ? [...this.hiddenContexts] : copy(this[field]);
}
return document;
}
}
/** Represent a map-local linking phrase without creating a repository concept. */
export class ConceptMapPhrase {
constructor(id, values = {}) {
if (!Number.isInteger(Number(id))) throw new TypeError("A phrase requires an integer id");
this.id = Number(id);
this.kind = "phrase";
this.label = String(values.label || "?????");
this.synopsis = String(values.synopsis || "");
this.update(values);
}
update(values = {}) {
if (values.label !== undefined) this.label = String(values.label);
if (values.synopsis !== undefined) this.synopsis = String(values.synopsis);
for (const field of PLACEMENT_FIELDS) {
if (values[field] !== undefined) this[field] = copy(values[field]);
}
this.parentSubmapId = this.parentSubmapId === null || this.parentSubmapId === undefined ?
null : Number(this.parentSubmapId);
this.hiddenContexts = this.hiddenContexts instanceof Set ? this.hiddenContexts :
new Set(Array.isArray(this.hiddenContexts) ? this.hiddenContexts.map(String) : []);
this.layouts = this.layouts && typeof this.layouts === "object" ? this.layouts : {};
return this;
}
toDocument() {
const document = {
id: this.id,
conceptId: null,
kind: "phrase",
label: this.label,
synopsis: this.synopsis
};
for (const field of PLACEMENT_FIELDS) {
document[field] = field === "hiddenContexts" ? [...this.hiddenContexts] : copy(this[field]);
}
return document;
}
}
/** Store one rendered connection between two map-local item placements. */
export class ConceptMapConnector {
constructor(id, sourceId, targetId, values = {}) {
if (!Number.isInteger(Number(id)) || !Number.isInteger(Number(sourceId)) ||
!Number.isInteger(Number(targetId))) {
throw new TypeError("A connector requires integer ids");
}
this.id = Number(id);
this.sourceId = Number(sourceId);
this.targetId = Number(targetId);
this.relationId = values.relationId || null;
this.hasArrow = values.hasArrow !== false;
this.lineColor = values.lineColor || "#333";
this.lineWidth = Number(values.lineWidth) || 2;
}
toDocument() {
return {
id: this.id,
sourceId: this.sourceId,
targetId: this.targetId,
...(this.relationId ? { relationId: this.relationId } : {}),
hasArrow: this.hasArrow,
lineColor: this.lineColor,
lineWidth: this.lineWidth
};
}
}
/** Own the placements, phrases, connectors and metadata of one CMap. */
export class ConceptMap {
constructor(values = {}) {
this.schemaVersion = Number(values.schemaVersion) || 2;
this.metadata = {
tags: Array.isArray(values.metadata?.tags) ? values.metadata.tags.map(String) : [],
summary: String(values.metadata?.summary || ""),
explanationPageSlug: String(values.metadata?.explanationPageSlug || "")
};
this.derivedView = values.derivedView ? copy(values.derivedView) : null;
this.itemsById = new Map();
this.connectorsById = new Map();
this.conceptMapsById = new Map();
}
addItem(value) {
const item = value instanceof ConceptMapConcept || value instanceof ConceptMapPhrase ? value :
(value.kind === "phrase" ? new ConceptMapPhrase(value.id, value) :
new ConceptMapConcept(value.id, value.conceptId, value));
if (this.itemsById.has(item.id)) throw new Error(`Duplicate CMap item id: ${item.id}`);
this.itemsById.set(item.id, item);
return item;
}
item(id) {
return this.itemsById.get(Number(id)) || null;
}
items() {
return [...this.itemsById.values()];
}
removeItem(id) {
const itemId = Number(id);
this.itemsById.delete(itemId);
for (const [connectorId, connector] of this.connectorsById) {
if (connector.sourceId === itemId || connector.targetId === itemId) {
this.connectorsById.delete(connectorId);
}
}
}
addConnector(value) {
const connector = value instanceof ConceptMapConnector ? value : new ConceptMapConnector(
value.id, value.sourceId, value.targetId, value);
if (this.connectorsById.has(connector.id)) {
throw new Error(`Duplicate CMap connector id: ${connector.id}`);
}
this.connectorsById.set(connector.id, connector);
return connector;
}
connector(id) {
return this.connectorsById.get(Number(id)) || null;
}
connectors() {
return [...this.connectorsById.values()];
}
removeConnector(id) {
this.connectorsById.delete(Number(id));
}
setConceptMapReferences(references = []) {
this.conceptMapsById = new Map(references
.filter((reference) => reference && reference.id)
.map((reference) => [reference.id, copy(reference)]));
}
toDocument() {
const document = {
schemaVersion: this.schemaVersion,
metadata: copy(this.metadata),
items: this.items().map((item) => item.toDocument()),
connectors: this.connectors().map((connector) => connector.toDocument()),
conceptMaps: [...this.conceptMapsById.values()].map(copy)
};
if (this.derivedView) document.derivedView = copy(this.derivedView);
return document;
}
}
/** Combine a concept repository with one concrete concept-map document. */
export class CmapModel {
constructor(repository = new ConceptRepository(), conceptMap = new ConceptMap()) {
this.repository = repository;
this.conceptMap = conceptMap;
}
static fromDocument(document = {}) {
const repository = new ConceptRepository(
Array.isArray(document.concepts) ? document.concepts : [],
Array.isArray(document.conceptRelations) ? document.conceptRelations : [],
Array.isArray(document.conceptOwnerships) ? document.conceptOwnerships : []);
const conceptMap = new ConceptMap(document);
for (const item of (Array.isArray(document.items) ? document.items : [])) {
if (item.kind !== "phrase" && !repository.concept(item.conceptId)) {
repository.add({ id: item.conceptId, label: item.label || "Concept" });
}
conceptMap.addItem(item);
}
for (const connector of (Array.isArray(document.connectors) ? document.connectors : [])) {
conceptMap.addConnector(connector);
}
conceptMap.setConceptMapReferences(document.conceptMaps);
return new CmapModel(repository, conceptMap);
}
/**
* Split an embedded submap into a standalone map and its remaining parent.
* The owner stays in the parent as an ordinary concept and links to targetSlug.
* Descendant placements become the complete contents of the child map.
*/
extractSubmap(rootItemId, targetSlug = null, childMetadata = null) {
const sourceDocument = this.toDocument();
const rootId = Number(rootItemId);
const rootItem = sourceDocument.items.find((item) => Number(item.id) === rootId);
if (!rootItem || rootItem.kind !== "submap") {
throw new TypeError("A submap item is required for extraction");
}
const descendantIds = new Set();
let foundDescendant = true;
while (foundDescendant) {
foundDescendant = false;
for (const item of sourceDocument.items) {
const parentId = Number(item.parentSubmapId);
if (parentId !== rootId && !descendantIds.has(parentId)) continue;
if (descendantIds.has(Number(item.id))) continue;
descendantIds.add(Number(item.id));
foundDescendant = true;
}
}
const descendantItems = sourceDocument.items
.filter((item) => descendantIds.has(Number(item.id)));
if (!descendantItems.some((item) => item.kind !== "phrase")) {
throw new Error("The submap has no concepts to extract");
}
const left = Math.min(...descendantItems.map((item) => Number(item.x) || 0));
const top = Math.min(...descendantItems.map((item) => Number(item.y) || 0));
const childItems = descendantItems.map((item) => {
const x = (Number(item.x) || 0) - left + 80;
const y = (Number(item.y) || 0) - top + 80;
const parentSubmapId = Number(item.parentSubmapId) === rootId ? null : item.parentSubmapId;
const submapDepth = Math.max(
0, Number(item.submapDepth || 0) - Number(rootItem.submapDepth || 0) - 1);
return {
...item,
parentSubmapId,
submapDepth,
x,
y,
layouts: {
...(item.layouts || {}),
root: {
...(item.layouts?.root || {}),
x,
y,
width: Number(item.width),
height: Number(item.height)
}
}
};
});
const parentItems = sourceDocument.items
.filter((item) => !descendantIds.has(Number(item.id)))
.map((item) => Number(item.id) === rootId ? {
...item,
kind: "concept",
childMap: null,
expanded: false,
submapInitialized: false,
separateMap: false,
mapReference: null
} : item);
const allItemIds = new Set(sourceDocument.items.map((item) => Number(item.id)));
const parentConnectors = [];
const parentConnectorKeys = new Set();
const childConnectors = [];
for (const connector of sourceDocument.connectors) {
const sourceId = Number(connector.sourceId);
const targetId = Number(connector.targetId);
if (!allItemIds.has(sourceId) || !allItemIds.has(targetId)) continue;
const sourceInside = descendantIds.has(sourceId);
const targetInside = descendantIds.has(targetId);
if (sourceInside && targetInside) {
childConnectors.push({ ...connector });
continue;
}
if (sourceInside || targetInside) {
const rewired = {
...connector,
sourceId: sourceInside ? rootId : sourceId,
targetId: targetInside ? rootId : targetId
};
if (rewired.sourceId === rewired.targetId) continue;
const key = `${rewired.sourceId}\u0000${rewired.targetId}\u0000${rewired.hasArrow !== false}`;
if (!parentConnectorKeys.has(key)) {
parentConnectorKeys.add(key);
parentConnectors.push(rewired);
}
continue;
}
const key = `${sourceId}\u0000${targetId}\u0000${connector.hasArrow !== false}`;
if (!parentConnectorKeys.has(key)) {
parentConnectorKeys.add(key);
parentConnectors.push({ ...connector });
}
}
const conceptIdsFor = (items) => new Set(items
.filter((item) => item.kind !== "phrase" && item.conceptId)
.map((item) => String(item.conceptId)));
const childConceptIds = conceptIdsFor(childItems);
const parentConceptIds = conceptIdsFor(parentItems);
const ownerConceptId = String(rootItem.conceptId);
const conceptsFor = (ids) => sourceDocument.concepts
.filter((concept) => ids.has(String(concept.id)))
.map((concept) => ({ ...concept }));
const parentConcepts = conceptsFor(parentConceptIds);
if (targetSlug) {
const ownerConcept = parentConcepts.find((concept) => concept.id === ownerConceptId);
if (ownerConcept) ownerConcept.cmapSlug = String(targetSlug);
}
const relationsFor = (ids) => (sourceDocument.conceptRelations || [])
.filter((relation) => ids.has(String(relation.sourceConceptId)) &&
ids.has(String(relation.targetConceptId)))
.map((relation) => ({ ...relation }));
const ownershipsFor = (ids) => (sourceDocument.conceptOwnerships || [])
.filter((ownership) => {
const parentId = String(ownership.parentConceptId);
const childId = String(ownership.childConceptId);
const extractedOwnership = parentId === ownerConceptId && childConceptIds.has(childId);
return !extractedOwnership && ids.has(parentId) && ids.has(childId);
})
.map((ownership) => ({ ...ownership }));
const ownerConcept = sourceDocument.concepts
.find((concept) => String(concept.id) === ownerConceptId) || {};
const extractedMetadata = childMetadata ? copy(childMetadata) : {
tags: Array.isArray(ownerConcept.aspects) ? [...ownerConcept.aspects] : [],
summary: String(ownerConcept.synopsis || ""),
explanationPageSlug: String(ownerConcept.descriptionPageSlug || "")
};
const childDocument = {
schemaVersion: sourceDocument.schemaVersion,
metadata: extractedMetadata,
concepts: conceptsFor(childConceptIds),
items: childItems,
connectors: childConnectors,
conceptMaps: sourceDocument.conceptMaps
.filter((reference) => descendantIds.has(Number(reference.rootItemId)))
};
const childRelations = relationsFor(childConceptIds);
const childOwnerships = ownershipsFor(childConceptIds);
if (childRelations.length) childDocument.conceptRelations = childRelations;
if (childOwnerships.length) childDocument.conceptOwnerships = childOwnerships;
const parentDocument = {
...sourceDocument,
concepts: parentConcepts,
items: parentItems,
connectors: parentConnectors,
conceptMaps: sourceDocument.conceptMaps.filter((reference) =>
Number(reference.rootItemId) !== rootId &&
!descendantIds.has(Number(reference.rootItemId)))
};
const parentRelations = relationsFor(parentConceptIds);
const parentOwnerships = ownershipsFor(parentConceptIds);
if (parentRelations.length) parentDocument.conceptRelations = parentRelations;
else delete parentDocument.conceptRelations;
if (parentOwnerships.length) parentDocument.conceptOwnerships = parentOwnerships;
else delete parentDocument.conceptOwnerships;
return { parentDocument, childDocument };
}
toDocument() {
const document = this.conceptMap.toDocument();
const usedConceptIds = new Set(this.conceptMap.items()
.filter((item) => item instanceof ConceptMapConcept)
.map((item) => item.conceptId));
document.concepts = this.repository.toDocument()
.filter((concept) => usedConceptIds.has(concept.id));
const relations = this.repository.relationDocuments();
const ownerships = this.repository.ownershipDocuments();
if (relations.length) document.conceptRelations = relations;
if (ownerships.length) document.conceptOwnerships = ownerships;
return document;
}
}
export { PLACEMENT_FIELDS };
+217
View File
@@ -0,0 +1,217 @@
const CONCEPT_FIELDS = [
"label", "synopsis", "aspects", "tags", "descriptionPageSlug",
"pageSlug", "cmapSlug", "externalUrl", "imageSource"
];
function copy(value) {
return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
}
function normalizeTags(tags) {
if (!Array.isArray(tags)) return [];
return tags.map((tag) => {
if (typeof tag === "string") return { type: "label", value: tag.trim() };
if (!tag || typeof tag !== "object") return null;
const type = String(tag.type || "label").trim() || "label";
const value = String(tag.value || tag.name || "").trim();
return value ? { type, value } : null;
}).filter(Boolean);
}
/**
* Store the shared content and external references of one concept.
* A Concept never contains coordinates, styling or browser objects.
*/
export class Concept {
constructor(id, values = {}) {
if (!id) throw new TypeError("A concept id is required");
this.id = String(id);
this.label = String(values.label || "Concept");
this.synopsis = String(values.synopsis || "");
this.aspects = Array.isArray(values.aspects) ? values.aspects.map(String) : [];
this.tags = normalizeTags(values.tags);
this.descriptionPageSlug = values.descriptionPageSlug || null;
this.pageSlug = values.pageSlug || null;
this.cmapSlug = values.cmapSlug || null;
this.externalUrl = values.externalUrl || null;
this.imageSource = values.imageSource || "";
}
/** Change shared content without accepting placement or presentation fields. */
update(changes = {}) {
for (const field of CONCEPT_FIELDS) {
if (changes[field] === undefined) continue;
if (field === "aspects") {
this.aspects = Array.isArray(changes.aspects) ? changes.aspects.map(String) : [];
} else if (field === "tags") {
this.tags = normalizeTags(changes.tags);
} else {
this[field] = changes[field];
}
}
return this;
}
/** Return the stable JSON representation used at the wiki API boundary. */
toDocument() {
return {
id: this.id,
label: this.label,
synopsis: this.synopsis,
aspects: [...this.aspects],
tags: copy(this.tags),
descriptionPageSlug: this.descriptionPageSlug,
pageSlug: this.pageSlug,
cmapSlug: this.cmapSlug,
externalUrl: this.externalUrl,
imageSource: this.imageSource
};
}
}
/** Describe a semantic relation that exists independently of a diagram. */
export class ConceptRelation {
constructor(id, sourceConceptId, targetConceptId, values = {}) {
if (!id || !sourceConceptId || !targetConceptId) {
throw new TypeError("A concept relation requires an id and two concepts");
}
this.id = String(id);
this.sourceConceptId = String(sourceConceptId);
this.targetConceptId = String(targetConceptId);
this.label = String(values.label || "");
this.tags = normalizeTags(values.tags);
}
toDocument() {
return {
id: this.id,
sourceConceptId: this.sourceConceptId,
targetConceptId: this.targetConceptId,
label: this.label,
tags: copy(this.tags)
};
}
}
/** Describe semantic parent/child ownership between repository concepts. */
export class ConceptOwnership {
constructor(parentConceptId, childConceptId) {
if (!parentConceptId || !childConceptId || parentConceptId === childConceptId) {
throw new TypeError("Concept ownership requires two different concepts");
}
this.parentConceptId = String(parentConceptId);
this.childConceptId = String(childConceptId);
}
toDocument() {
return {
parentConceptId: this.parentConceptId,
childConceptId: this.childConceptId
};
}
}
/**
* Own all shared concepts and their semantic relationships.
* Placements refer to this repository by concept id.
*/
export class ConceptRepository {
constructor(concepts = [], relations = [], ownerships = []) {
this.conceptsById = new Map();
this.relationsById = new Map();
this.ownershipsByChildId = new Map();
for (const value of concepts) this.add(value);
for (const value of relations) this.addRelation(value);
for (const value of ownerships) this.addOwnership(value);
}
add(value) {
const concept = value instanceof Concept ? value : new Concept(value.id, value);
this.conceptsById.set(concept.id, concept);
return concept;
}
ensure(id, values = {}) {
const key = String(id);
const existing = this.conceptsById.get(key);
if (existing) {
existing.update(values);
return existing;
}
return this.add(new Concept(key, values));
}
concept(id) {
return this.conceptsById.get(String(id)) || null;
}
concepts() {
return [...this.conceptsById.values()];
}
remove(id) {
const key = String(id);
this.conceptsById.delete(key);
for (const [relationId, relation] of this.relationsById) {
if (relation.sourceConceptId === key || relation.targetConceptId === key) {
this.relationsById.delete(relationId);
}
}
this.ownershipsByChildId.delete(key);
for (const [childId, ownership] of this.ownershipsByChildId) {
if (ownership.parentConceptId === key) this.ownershipsByChildId.delete(childId);
}
}
addRelation(value) {
const relation = value instanceof ConceptRelation ? value : new ConceptRelation(
value.id, value.sourceConceptId, value.targetConceptId, value);
this.requireConcept(relation.sourceConceptId);
this.requireConcept(relation.targetConceptId);
this.relationsById.set(relation.id, relation);
return relation;
}
addOwnership(value) {
const ownership = value instanceof ConceptOwnership ? value : new ConceptOwnership(
value.parentConceptId, value.childConceptId);
this.requireConcept(ownership.parentConceptId);
this.requireConcept(ownership.childConceptId);
if (this.isOwnedBy(ownership.parentConceptId, ownership.childConceptId)) {
throw new Error("Concept ownership would create a cycle");
}
this.ownershipsByChildId.set(ownership.childConceptId, ownership);
return ownership;
}
isOwnedBy(conceptId, possibleAncestorId) {
let current = this.ownershipsByChildId.get(String(conceptId));
const seen = new Set();
while (current && !seen.has(current.childConceptId)) {
if (current.parentConceptId === String(possibleAncestorId)) return true;
seen.add(current.childConceptId);
current = this.ownershipsByChildId.get(current.parentConceptId);
}
return false;
}
requireConcept(id) {
const concept = this.concept(id);
if (!concept) throw new Error(`Unknown concept: ${id}`);
return concept;
}
toDocument() {
return this.concepts().map((concept) => concept.toDocument());
}
relationDocuments() {
return [...this.relationsById.values()].map((relation) => relation.toDocument());
}
ownershipDocuments() {
return [...this.ownershipsByChildId.values()].map((ownership) => ownership.toDocument());
}
}
export { CONCEPT_FIELDS, normalizeTags };
+3
View File
@@ -0,0 +1,3 @@
{
"type": "module"
}
+3
View File
@@ -0,0 +1,3 @@
{
"type": "module"
}