Files
racket-wiki/static/cmap/cmap-racket-wiki.js
T
2026-09-03 08:40:15 +02:00

1629 lines
68 KiB
JavaScript

import {
CmapModel,
ConceptMapConcept,
ConceptMapConnector,
ConceptMapPhrase,
PLACEMENT_FIELDS
} from "./model/concept-map.js";
import { CONCEPT_FIELDS } from "./model/concept-repository.js";
import { CmapHistory } from "./controller/cmap-history.js";
import { CmapSubmapController } from "./controller/cmap-submap-controller.js";
import { CmapSelectionController } from "./controller/cmap-selection-controller.js";
import { CmapInteractionController } from "./controller/cmap-interaction-controller.js";
import { CmapItemDecorator } from "./view/cmap-item-decorator.js";
import { CmapView } from "./cmap-view.js";
import {
debug,
debugPrefix,
elementDescription,
selectionStyle,
escapeHtml,
numberOr,
conceptDescriptionReference,
newConceptId,
normalizeConceptTags
} from "./cmap-utils.js";
/*
* Racket Wiki editor layer for the bundled racket-wiki CMap component.
*
* cmap.js owns hit testing, dragging and render lifecycle callbacks. This file
* adds the wiki-specific editor model and CMapTools-like controls.
*/
(() => {
"use strict";
debug("cmap-racket-wiki.js loaded", {
script: document.currentScript ? document.currentScript.src : null,
cmapAvailable: true,
stylesheets: Array.from(document.styleSheets || [])
.map((sheet) => sheet.href)
.filter((href) => href && href.includes("cmap.css"))
});
//////////////////////////////////////////////////////////////////////////////
// Editor
//////////////////////////////////////////////////////////////////////////////
/**
* goal : Add the wiki editor model to the bundled CMap drawing component.
* pre : canvas is a DOM element.
* post : Concepts and linking phrases can be selected and connected by
* direct manipulation.
* result : A CmapEditor instance.
*/
class CmapEditor {
constructor(canvas, options = {}) {
this.canvas = canvas;
this.renderItem = options.renderItem || null;
this.onOpenPage = options.onOpenPage || null;
this.onOpenSubMap = options.onOpenSubMap || null;
this.onPopulateSubMap = options.onPopulateSubMap || null;
this.onSubMapPromoted = options.onSubMapPromoted || null;
this.onMapChange = options.onMapChange || null;
this.onOpenCmap = options.onOpenCmap || null;
this.onOpenParentCmap = options.onOpenParentCmap || null;
this.onOpenExternalUrl = options.onOpenExternalUrl || null;
this.onOpenStoredSubMap = options.onOpenStoredSubMap || null;
this.onOpenBoundaryReference = options.onOpenBoundaryReference || null;
this.onVisibilityChange = options.onVisibilityChange || null;
this.onConfirmDetachFromSubmap = options.onConfirmDetachFromSubmap || null;
this.onEditItem = options.onEditItem || null;
this.onCreateConnectedItem = options.onCreateConnectedItem || null;
this.onSelectionChange = options.onSelectionChange || null;
this.onHistoryChange = options.onHistoryChange || null;
this.onAutomaticLayoutChange = options.onAutomaticLayoutChange || null;
this.labels = {
createRelation: options.createRelationLabel || "Create relation",
editConcept: options.editConceptLabel || "Edit concept",
resizeConcept: options.resizeConceptLabel || "Resize concept",
relation: options.relationLabel || "Relation"
};
this.view = new CmapView(
canvas,
(component, event) => this.interaction.handleMapSelection(component, event),
(component, event) => this.interaction.handleMapActivation(component, event),
options.createDiagramEngine || null);
this.map = this.view.map;
this.items = [];
this.connectors = [];
this.unresolvedConnectors = [];
this.model = CmapModel.fromDocument({});
this.conceptMaps = this.model.conceptMap.conceptMapsById;
this.documentMetadata = this.model.conceptMap.metadata;
this.activeMapRoot = null;
this.mapHistory = [];
this.selectedItem = null;
this.selectedItems = new Set();
this.selectedConnector = null;
this.destroyed = false;
this.nextId = 1;
this.nextConnectorId = 1;
this.nextGroupId = 1;
this.dragRelation = null;
this.zoomFactor = 1;
this.marqueeMouseDownHandler = null;
this.activeMarqueeCleanup = null;
this.canvasPanPointerDownHandler = null;
this.activeCanvasPanCleanup = null;
this.history = new CmapHistory({
snapshot: () => this.historySnapshot(),
restore: (snapshot) => this.restoreHistoryDocument(snapshot),
onChange: (change) => {
if (this.onHistoryChange) this.onHistoryChange(change);
}
});
this.submaps = new CmapSubmapController(this);
this.selection = new CmapSelectionController(this);
this.interaction = new CmapInteractionController(this);
this.decorator = new CmapItemDecorator(this);
this.boundaryLayer = null;
this.boundaryScrollHandler = () => this.refreshBoundaryReferences();
this.canvas.addEventListener("scroll", this.boundaryScrollHandler, { passive: true });
this.interaction.installCanvasPanning();
this.interaction.installMarqueeSelection();
debug("editor created", {
canvas: elementDescription(canvas),
drawingEngine: this.map.constructor.name
});
}
/** 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());
}
resetHistory() {
this.history.reset();
}
scheduleHistoryCommit() {
this.history.scheduleCommit();
}
refreshHistorySnapshot() {
this.history.refreshSnapshot();
}
commitHistory() {
return this.history.commit();
}
canUndo() {
return this.history.canUndo();
}
canRedo() {
return this.history.canRedo();
}
clearDocument() {
this.clearSelection(false);
for (const connector of this.connectors) connector.link.remove();
for (const record of this.items) {
if (record.submapAnchorLineElement) record.submapAnchorLineElement.remove();
record.node.remove();
}
this.items = [];
this.connectors = [];
this.unresolvedConnectors = [];
this.model = CmapModel.fromDocument({});
this.conceptMaps = this.model.conceptMap.conceptMapsById;
this.documentMetadata = this.model.conceptMap.metadata;
this.activeMapRoot = null;
this.mapHistory = [];
this.nextId = 1;
this.nextConnectorId = 1;
if (this.boundaryLayer) {
this.boundaryLayer.remove();
this.boundaryLayer = null;
}
this.nextGroupId = 1;
}
restoreHistoryDocument(snapshot) {
const activeMapRootId = this.activeMapRoot ? this.activeMapRoot.id : null;
const mapHistoryIds = this.mapHistory.map((record) => record.id);
this.clearDocument();
this.loadDocument(JSON.parse(snapshot));
this.activeMapRoot = this.items.find((item) => item.id === activeMapRootId) || null;
this.mapHistory = mapHistoryIds
.map((id) => this.items.find((item) => item.id === id))
.filter(Boolean);
this.applyCurrentContextLayout();
const reference = this.activeMapRoot ? this.activeMapRoot.mapReference : null;
if (this.onMapChange) this.onMapChange(reference, this.activeMapRoot);
this.notifySelection();
}
undo() {
const restored = this.history.undo();
if (!restored) return false;
debug("undo applied", {
undoCount: this.history.undoCount,
redoCount: this.history.redoCount
});
return true;
}
redo() {
const restored = this.history.redo();
if (!restored) return false;
debug("redo applied", {
undoCount: this.history.undoCount,
redoCount: this.history.redoCount
});
return true;
}
/**
* goal : Add a draggable concept or linking-phrase node.
* pre : options may contain the normal ionstage/cmap node attributes.
* post : The item is drawn and receives selection/relation/resize UI.
* result : The item record used by the editor.
*/
addItem(options = {}) {
// Begin the transaction before map.node can synchronously render and fit
// the item. A render callback may refresh the current layout snapshot;
// while this timer is pending it must not replace the pre-add snapshot.
this.scheduleHistoryCommit();
const requestedId = Number(options.id);
const id = Number.isInteger(requestedId) && requestedId > 0 ? requestedId : this.nextId;
this.nextId = Math.max(this.nextId, id + 1);
const kind = options.kind || "concept";
const autoWidth = options.width === undefined || options.width === null;
const autoHeight = options.height === undefined || options.height === null;
const record = {
id,
conceptId: options.conceptId || (kind === "phrase" ? null : newConceptId()),
kind,
label: options.label || "Concept",
synopsis: options.synopsis || "",
aspects: Array.isArray(options.aspects) ? options.aspects.map(String) : [],
tags: normalizeConceptTags(options.tags),
descriptionPageSlug: kind === "phrase" ? null :
(options.descriptionPageSlug || conceptDescriptionReference(options.label || "Concept", id)),
pageSlug: options.pageSlug || null,
cmapSlug: options.cmapSlug || null,
externalUrl: options.externalUrl || null,
parentCmapLink: Boolean(options.parentCmapLink),
groupId: options.groupId || null,
childMap: options.childMap || null,
parentSubmap: options.parentSubmap || null,
submapDepth: numberOr(options.submapDepth, 0),
expanded: false,
submapInitialized: false,
separateMap: Boolean(options.separateMap),
mapReference: options.mapReference || null,
hiddenContexts: new Set(Array.isArray(options.hiddenContexts) ?
options.hiddenContexts.map(String) : []),
layouts: options.layouts && typeof options.layouts === "object" ?
Object.fromEntries(Object.entries(options.layouts).map(([context, layout]) => [context, {
x: numberOr(Number(layout.x), numberOr(options.x, 0)),
y: numberOr(Number(layout.y), numberOr(options.y, 0)),
width: numberOr(Number(layout.width), numberOr(options.width, 220)),
height: numberOr(Number(layout.height), numberOr(options.height, 70)),
backgroundColor: layout.backgroundColor || options.backgroundColor || "#f3f6f8",
borderColor: layout.borderColor || options.borderColor || "#5d6d7e",
textColor: layout.textColor || options.textColor || "#222222",
fontFamily: layout.fontFamily || options.fontFamily || "Arial, Helvetica, sans-serif",
fontSize: layout.fontSize || options.fontSize || "11pt",
fontWeight: layout.fontWeight || options.fontWeight || "700",
fontStyle: layout.fontStyle || options.fontStyle || "normal",
synopsisTextColor: layout.synopsisTextColor || layout.textColor ||
options.synopsisTextColor || options.textColor || "#222222",
synopsisFontFamily: layout.synopsisFontFamily || layout.fontFamily ||
options.synopsisFontFamily || options.fontFamily || "Arial, Helvetica, sans-serif",
synopsisFontSize: layout.synopsisFontSize ||
options.synopsisFontSize || "0.84em",
synopsisFontWeight: layout.synopsisFontWeight || layout.fontWeight ||
options.synopsisFontWeight || options.fontWeight || "700",
synopsisFontStyle: layout.synopsisFontStyle || layout.fontStyle ||
options.synopsisFontStyle || options.fontStyle || "normal"
}])) : {},
submapAnchorLineElement: null,
imageSource: options.imageSource || "",
backgroundColor: options.backgroundColor || "#f3f6f8",
borderColor: options.borderColor || "#5d6d7e",
submapBackgroundColor: kind === "submap" ?
(options.submapBackgroundColor || "#edf7e8") : null,
submapBorderColor: kind === "submap" ?
(options.submapBorderColor || "#57834a") : null,
textColor: options.textColor || "#222222",
fontFamily: options.fontFamily || "Arial, Helvetica, sans-serif",
fontSize: options.fontSize || "11pt",
fontWeight: options.fontWeight || "700",
fontStyle: options.fontStyle || "normal",
synopsisTextColor: options.synopsisTextColor || options.textColor || "#222222",
synopsisFontFamily: options.synopsisFontFamily || options.fontFamily ||
"Arial, Helvetica, sans-serif",
synopsisFontSize: options.synopsisFontSize || "0.84em",
synopsisFontWeight: options.synopsisFontWeight || options.fontWeight || "700",
synopsisFontStyle: options.synopsisFontStyle || options.fontStyle || "normal",
width: options.width || (kind === "phrase" ? 145 : 220),
height: options.height || (kind === "phrase" ? 36 : (options.synopsis ? 105 : 70)),
autoWidth,
autoHeight,
fitContentPending: autoWidth || autoHeight,
node: null
};
record.usageCount = record.conceptId && kind !== "phrase" ?
this.items.filter((item) => item.conceptId === record.conceptId &&
item.kind !== "phrase").length + 1 : null;
const node = this.view.createNode({
content: this.itemHtml(record),
contentType: "html",
x: numberOr(options.x, 80 + ((id * 37) % 420)),
y: numberOr(options.y, 80 + ((id * 83) % 360)),
width: record.width,
height: record.height,
backgroundColor: record.backgroundColor,
borderColor: record.borderColor,
borderWidth: kind === "phrase" ? 0 : 2,
textColor: record.textColor
});
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));
node.onMove((_movedNode, x, y) => this.handleItemMove(record, x, y));
node.onMoveEnd(() => this.handleItemMoveEnd(record));
debug("item registered; waiting for cmap render callback", {
id: record.id,
kind: record.kind,
label: record.label
});
return record;
}
refreshConceptUsageIndicators(conceptIds = null) {
const ids = conceptIds ? new Set(conceptIds.filter(Boolean)) : new Set(this.items
.map((item) => item.conceptId).filter(Boolean));
for (const conceptId of ids) {
const peers = this.items.filter((item) => item.conceptId === conceptId &&
item.kind !== "phrase");
for (const peer of peers) {
peer.usageCount = peers.length;
if (!peer.node) continue;
peer.node.attr({ content: this.itemHtml(peer) });
peer.node.redraw();
}
}
}
addSubmapItem(parentSubmap, options = {}) {
if (!parentSubmap || parentSubmap.kind !== "submap") {
throw TypeError("A submap parent is required");
}
const index = this.items.filter((item) => item.parentSubmap === parentSubmap).length;
return this.addItem({
...options,
parentSubmap,
submapDepth: parentSubmap.submapDepth + 1,
x: options.x === undefined ? Number(parentSubmap.node.attr("x")) + 55 + ((index % 2) * 245) : options.x,
y: options.y === undefined ? Number(parentSubmap.node.attr("y")) + 105 + (Math.floor(index / 2) * 125) : options.y
});
}
refreshConceptMapReferences() {
for (const submap of this.items.filter((item) => item.separateMap && item.mapReference)) {
submap.mapReference.itemIds = this.items
.filter((item) => this.isDescendantOf(item, submap))
.map((item) => item.id);
}
}
mapContextKey(root = this.activeMapRoot) {
return this.submaps.mapContextKey(root);
}
itemLayout(record) {
return {
x: Number(record.node.attr("x")),
y: Number(record.node.attr("y")),
width: Number(record.node.attr("width")),
height: Number(record.node.attr("height")),
backgroundColor: record.backgroundColor,
borderColor: record.borderColor,
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
};
}
saveCurrentContextLayout() {
const context = this.mapContextKey();
for (const record of this.items.filter((item) => this.isEffectiveItemVisible(item))) {
record.layouts[context] = this.itemLayout(record);
}
}
applyCurrentContextLayout() {
const context = this.mapContextKey();
const visible = this.items.filter((item) => this.isEffectiveItemVisible(item));
let offset = { x: 0, y: 0 };
if (this.activeMapRoot && !this.activeMapRoot.layouts[context]) {
const rootLayout = this.itemLayout(this.activeMapRoot);
offset = { x: 80 - rootLayout.x, y: 80 - rootLayout.y };
}
for (const record of visible) {
if (!record.layouts[context]) {
const current = this.itemLayout(record);
record.layouts[context] = {
...current,
x: current.x + offset.x,
y: current.y + offset.y
};
}
const layout = record.layouts[context];
record.width = layout.width;
record.height = layout.height;
record.backgroundColor = layout.backgroundColor || record.backgroundColor;
record.borderColor = layout.borderColor || record.borderColor;
record.textColor = layout.textColor || record.textColor;
record.fontFamily = layout.fontFamily || record.fontFamily;
record.fontSize = layout.fontSize || record.fontSize;
record.fontWeight = layout.fontWeight || record.fontWeight;
record.fontStyle = layout.fontStyle || record.fontStyle;
record.synopsisTextColor = layout.synopsisTextColor || layout.textColor ||
record.synopsisTextColor;
record.synopsisFontFamily = layout.synopsisFontFamily || layout.fontFamily ||
record.synopsisFontFamily;
record.synopsisFontSize = layout.synopsisFontSize || record.synopsisFontSize;
record.synopsisFontWeight = layout.synopsisFontWeight || layout.fontWeight ||
record.synopsisFontWeight;
record.synopsisFontStyle = layout.synopsisFontStyle || layout.fontStyle ||
record.synopsisFontStyle;
record.node.attr({
x: layout.x,
y: layout.y,
width: layout.width,
height: layout.height,
content: this.itemHtml(record),
backgroundColor: record.backgroundColor,
borderColor: record.borderColor,
textColor: record.textColor
});
record.node.redraw();
}
this.refreshSubmapVisibility();
this.refreshConnectorGeometry();
window.requestAnimationFrame(() => {
if (!this.destroyed && this.canvas.isConnected) {
// DrawingSurface creates and attaches its DOM surface asynchronously.
// Reapply context visibility after that first render so descendants
// of collapsed submaps cannot briefly become the rendered baseline.
this.refreshSubmapVisibility();
this.refreshConnectorGeometry();
}
});
}
refreshConnectorGeometry() {
for (const connector of this.connectors) {
const source = this.connectorEndpoint(connector.source);
const target = this.connectorEndpoint(connector.target);
this.applyConnectorVisualEndpoints(connector, source, target);
if (source && target && source !== target) {
if (connector.link.sourceNode() !== source.node) connector.link.sourceNode(source.node);
if (connector.link.targetNode() !== target.node) connector.link.targetNode(target.node);
connector.visualSource = source;
connector.visualTarget = target;
connector.link.straighten();
connector.link.redraw();
}
}
this.refreshBoundaryReferences();
}
itemInsideActiveMap(record) {
return this.submaps.itemInsideActiveMap(record);
}
boundaryConceptFor(record, crossedConnector, inside) {
if (!record || record.kind !== "phrase") return record;
for (const connector of this.connectors) {
if (connector === crossedConnector) continue;
if (connector.source !== record && connector.target !== record) continue;
const neighbour = connector.source === record ? connector.target : connector.source;
if (neighbour.kind === "phrase") continue;
if (this.itemInsideActiveMap(neighbour) === inside) return neighbour;
}
return record;
}
refreshBoundaryReferences() {
if (this.boundaryLayer) {
this.boundaryLayer.remove();
this.boundaryLayer = null;
}
if (!this.activeMapRoot) return;
const surface = this.surfaceElement();
if (!surface) return;
const crossings = [];
for (const connector of this.connectors) {
const sourceInside = this.itemInsideActiveMap(connector.source);
const targetInside = this.itemInsideActiveMap(connector.target);
if (sourceInside === targetInside) continue;
const insideRecord = sourceInside ? connector.source : connector.target;
const outsideRecord = sourceInside ? connector.target : connector.source;
const insideConcept = this.boundaryConceptFor(insideRecord, connector, true);
const outsideConcept = this.boundaryConceptFor(outsideRecord, connector, false);
if (!insideConcept || !outsideConcept || !this.isItemVisible(insideConcept)) continue;
crossings.push({ connector, sourceInside, insideConcept, outsideConcept });
}
if (!crossings.length) return;
const layer = document.createElement("div");
layer.className = "rw-cmap-boundary-layer";
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.classList.add("rw-cmap-boundary-lines");
const definitions = document.createElementNS("http://www.w3.org/2000/svg", "defs");
const marker = document.createElementNS("http://www.w3.org/2000/svg", "marker");
marker.setAttribute("id", "rw-cmap-boundary-arrow");
marker.setAttribute("viewBox", "0 0 10 10");
marker.setAttribute("refX", "9");
marker.setAttribute("refY", "5");
marker.setAttribute("markerWidth", "7");
marker.setAttribute("markerHeight", "7");
marker.setAttribute("orient", "auto-start-reverse");
const arrow = document.createElementNS("http://www.w3.org/2000/svg", "path");
arrow.setAttribute("d", "M 0 0 L 10 5 L 0 10 z");
arrow.setAttribute("fill", "#4a5560");
marker.append(arrow);
definitions.append(marker);
svg.append(definitions);
layer.append(svg);
surface.append(layer);
this.boundaryLayer = layer;
const viewLeft = this.canvas.scrollLeft / this.zoomFactor;
const viewTop = this.canvas.scrollTop / this.zoomFactor;
const viewWidth = this.canvas.clientWidth / this.zoomFactor;
const viewHeight = this.canvas.clientHeight / this.zoomFactor;
const buttonWidth = 190;
const occupied = { left: [], right: [] };
const reserveY = (side, desired) => {
let y = Math.max(viewTop + 12, Math.min(desired, viewTop + viewHeight - 40));
while (occupied[side].some((used) => Math.abs(used - y) < 34)) y += 34;
if (y > viewTop + viewHeight - 40) y = viewTop + 12;
occupied[side].push(y);
return y;
};
for (const crossing of crossings) {
const insideX = Number(crossing.insideConcept.node.attr("x")) +
(Number(crossing.insideConcept.node.attr("width")) / 2);
const insideY = Number(crossing.insideConcept.node.attr("y")) +
(Number(crossing.insideConcept.node.attr("height")) / 2);
const outsideX = Number(crossing.outsideConcept.node.attr("x")) +
(Number(crossing.outsideConcept.node.attr("width")) / 2);
const side = outsideX < insideX ? "left" : "right";
const x = side === "left" ? viewLeft + 12 : viewLeft + viewWidth - buttonWidth - 12;
const y = reserveY(side, insideY - 15);
const button = document.createElement("button");
button.type = "button";
button.className = `rw-cmap-boundary-reference rw-cmap-boundary-reference-${side}`;
button.style.transform = `translate(${x}px, ${y}px)`;
button.style.width = `${buttonWidth}px`;
button.textContent = crossing.outsideConcept.label || "External concept";
button.title = "Open the concept map containing this connection";
button.addEventListener("click", () => {
if (this.onOpenBoundaryReference) {
this.onOpenBoundaryReference(crossing.outsideConcept, crossing.connector);
}
});
layer.append(button);
const boundaryX = side === "left" ? x + buttonWidth : x;
const boundaryY = y + 15;
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
const startX = crossing.sourceInside ? insideX : boundaryX;
const startY = crossing.sourceInside ? insideY : boundaryY;
const endX = crossing.sourceInside ? boundaryX : insideX;
const endY = crossing.sourceInside ? boundaryY : insideY;
path.setAttribute("d", `M ${startX} ${startY} L ${endX} ${endY}`);
path.setAttribute("fill", "none");
path.setAttribute("stroke", crossing.connector.lineColor || "#4a5560");
path.setAttribute("stroke-width", String(crossing.connector.lineWidth || 2));
if (crossing.connector.hasArrow) path.setAttribute("marker-end", "url(#rw-cmap-boundary-arrow)");
svg.append(path);
}
}
isDescendantOf(record, submap) {
return this.submaps.isDescendantOf(record, submap);
}
isItemVisible(record) {
return this.submaps.isItemVisible(record);
}
hiddenItemsInCurrentContext() {
return this.submaps.hiddenItemsInCurrentContext();
}
canHideSelectionInCurrentContext() {
return this.submaps.canHideSelectionInCurrentContext();
}
hideSelectionInCurrentContext() {
return this.submaps.hideSelectionInCurrentContext();
}
showItemInCurrentContext(record) {
return this.submaps.showItemInCurrentContext(record);
}
ensureSubmapContents(record) {
return this.submaps.ensureSubmapContents(record);
}
toggleSubmap(record, expanded = !record.expanded) {
return this.submaps.toggleSubmap(record, expanded);
}
openSubmapMap(record) {
return this.submaps.openSubmapMap(record);
}
openRootMap() {
return this.submaps.openRootMap();
}
canStepBackWithinMap() {
return this.submaps.canStepBackWithinMap();
}
openParentMap() {
return this.submaps.openParentMap();
}
promoteSubmap(record, name) {
return this.submaps.promoteSubmap(record, name);
}
prepareStoredSubmapExtraction(record, targetSlug, childMetadata = null) {
return this.submaps.prepareStoredSubmapExtraction(record, targetSlug, childMetadata);
}
replaceDocument(document) {
if (!document || typeof document !== "object") return false;
this.history.replace(() => {
this.clearDocument();
this.loadDocument(document);
});
this.notifySelection();
return true;
}
/** Replace the editor contents with a public CMap domain model. */
replaceModel(model) {
if (!(model instanceof CmapModel)) throw new TypeError("A CmapModel is required");
return this.replaceDocument(model.toDocument());
}
setZoom(percent) {
const next = Math.max(25, Math.min(300, Number(percent) || 100));
this.zoomFactor = next / 100;
this.view.setZoom(this.zoomFactor);
this.ensureCanvasExtent(0, 0);
debug("zoom changed", { percent: next, factor: this.zoomFactor });
return next;
}
zoomPercentage() {
return Math.round(this.zoomFactor * 100);
}
/**
* Install a record-level visibility filter on the generic diagram engine.
*
* The callback receives a wiki item or connector record and its public
* diagram handle. It may return a boolean or a `{ visible }` decision.
* The editor's own visibility rules remain the base visibility and are
* combined with the supplied policy by DiagramEngine.
*/
setFilter(filter) {
if (filter !== null && filter !== undefined && typeof filter !== "function") {
throw new TypeError("A CMap filter must be a function");
}
this.view.map.setFilter(filter ? (handle) => {
const item = this.items.find((record) => record.node === handle) || null;
const connector = this.connectors.find((record) => record.link === handle) || null;
return filter(item || connector, handle);
} : null);
return this;
}
surfaceElement() {
return this.view.surfaceElement();
}
ensureCanvasExtent(x, y, padding = 180) {
const surface = this.surfaceElement();
if (!surface) return;
const viewportWidth = this.canvas.clientWidth / this.zoomFactor;
const viewportHeight = this.canvas.clientHeight / this.zoomFactor;
const width = Math.max(viewportWidth, Number(x) + padding,
Number.parseFloat(surface.style.minWidth) || 0);
const height = Math.max(viewportHeight, Number(y) + padding,
Number.parseFloat(surface.style.minHeight) || 0);
surface.style.minWidth = `${Math.ceil(width)}px`;
surface.style.minHeight = `${Math.ceil(height)}px`;
}
/**
* goal : Change presentation/content of an existing item.
* pre : record belongs to this editor.
* post : The ionstage node and interaction handles are redrawn.
*/
updateItem(record, changes = {}) {
// node.redraw may synchronously run fitItemToContent. Mark the mutation
// first, so that automatic sizing cannot turn the edited state into the
// history baseline before it has been committed as its own Undo step.
this.scheduleHistoryCommit();
if (changes.tags !== undefined) changes.tags = normalizeConceptTags(changes.tags);
for (const [key, value] of Object.entries(changes)) {
if (value !== undefined) record[key] = value;
}
const identityKeys = ["label", "synopsis", "aspects", "tags",
"descriptionPageSlug",
"pageSlug", "cmapSlug", "externalUrl",
"imageSource"];
if (record.conceptId && record.kind !== "phrase") {
for (const peer of this.items) {
if (peer === record || peer.conceptId !== record.conceptId || peer.kind === "phrase") continue;
for (const key of identityKeys) {
if (changes[key] !== undefined) peer[key] = changes[key];
}
peer.node.attr({ content: this.itemHtml(peer) });
peer.node.redraw();
}
}
if (record.autoWidth || record.autoHeight) record.fitContentPending = true;
record.width = numberOr(Number(record.width), record.node.attr("width"));
record.height = numberOr(Number(record.height), record.node.attr("height"));
record.node.attr({
content: this.itemHtml(record),
width: record.width,
height: record.height,
backgroundColor: record.backgroundColor,
borderColor: record.borderColor,
textColor: record.textColor
});
record.node.redraw();
if (record.kind === "submap") this.submaps.updateGroupAppearance(record);
this.redrawConnectorsFor(record);
this.refreshSubmapVisibility();
}
/**
* goal : Connect source to target with a separate linking phrase.
* pre : source and target are items in this editor.
* post : source -> phrase -> target is visible; the phrase can branch.
* result : The newly created linking-phrase item.
*/
connectWithPhrase(source, target, label = "?????", editImmediately = true) {
const a = this.itemCenter(source);
const b = this.itemCenter(target);
const parentSubmap = this.commonSubmapParent([source, target]);
const phrase = this.addItem({
kind: "phrase",
label,
parentSubmap,
submapDepth: parentSubmap ? parentSubmap.submapDepth + 1 : 0,
x: ((a.x + b.x) / 2) - 72,
y: ((a.y + b.y) / 2) - 18,
backgroundColor: "#fbfbf8",
borderColor: "transparent"
});
this.addConnector(source, phrase, false);
this.addConnector(phrase, target, true);
this.reconcilePhraseMembership(phrase);
this.selectItem(phrase);
if (editImmediately) this.editPhraseInline(phrase);
return phrase;
}
submapChain(record) {
const result = [];
if (record.kind === "submap") result.push(record);
let parent = record.parentSubmap;
while (parent) {
result.push(parent);
parent = parent.parentSubmap;
}
return result;
}
commonSubmapParent(records) {
if (!records.length) return null;
const chains = records.map((record) => this.submapChain(record));
return chains[0]
.filter((candidate) => chains.every((chain) => chain.includes(candidate)))
.sort((a, b) => b.submapDepth - a.submapDepth)[0] || null;
}
reconcilePhraseMembership(phrase = null) {
const phrases = phrase ? [phrase] : this.items.filter((item) => item.kind === "phrase");
for (const item of phrases) {
const endpoints = this.connectors
.filter((connector) => connector.source === item || connector.target === item)
.map((connector) => connector.source === item ? connector.target : connector.source)
.filter((endpoint) => endpoint.kind !== "phrase");
if (!endpoints.length) continue;
const parent = this.commonSubmapParent(endpoints);
item.parentSubmap = parent;
item.submapDepth = parent ? parent.submapDepth + 1 : 0;
}
}
/**
* goal : Add one directed connector between two existing map items.
* pre : source and target are items in this editor.
* post : A selectable ionstage/cmap link joins them.
* result : Connector record.
*/
addConnector(source, target, hasArrow = true, options = {}) {
const sourceCenter = this.itemCenter(source);
const targetCenter = this.itemCenter(target);
const link = this.view.createConnector({
content: "",
width: 1,
height: 1,
backgroundColor: "transparent",
borderColor: "transparent",
borderWidth: 0,
lineColor: options.lineColor || "#333",
lineWidth: numberOr(Number(options.lineWidth), 2),
hasArrow,
cx: (sourceCenter.x + targetCenter.x) / 2,
cy: (sourceCenter.y + targetCenter.y) / 2,
sourceX: sourceCenter.x,
sourceY: sourceCenter.y,
targetX: targetCenter.x,
targetY: targetCenter.y
});
link.sourceNode(source.node).targetNode(target.node);
link.straighten();
link.draggable(true);
const record = {
id: Number.isInteger(Number(options.id)) && Number(options.id) > 0 ?
Number(options.id) : this.nextConnectorId,
link,
source,
target,
visualSource: source,
visualTarget: target,
hasArrow,
lineColor: options.lineColor || "#333",
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) =>
this.handleConnectorConnectionChange(record, type, node));
link.visible(this.isItemVisible(source) && this.isItemVisible(target));
this.scheduleHistoryCommit();
return record;
}
/**
* Keep a dragged ionstage/cmap link endpoint and the persisted wiki
* connector model in lockstep. The drawing library owns the endpoint
* handles; the wiki model owns sourceId/targetId and submap projection.
*/
handleConnectorConnectionChange(connector, type, node) {
if (!connector || (type !== "source" && type !== "target")) return false;
const endpoint = this.items.find((item) => item.node === node) || null;
const otherType = type === "source" ? "target" : "source";
// A connector is only a storable relation while both ends are attached
// to different items. Restore the previous logical projection when an
// endpoint is dropped on empty space or on its opposite endpoint.
if (!endpoint || endpoint === connector[otherType]) {
connector[`visual${type === "source" ? "Source" : "Target"}`] = null;
this.applyConnectorVisualEndpoints(connector,
this.connectorEndpoint(connector.source),
this.connectorEndpoint(connector.target));
return false;
}
if (connector[type] === endpoint) {
connector[`visual${type === "source" ? "Source" : "Target"}`] = endpoint;
return false;
}
const previous = connector[type];
connector[type] = endpoint;
connector[`visual${type === "source" ? "Source" : "Target"}`] = endpoint;
this.reconcilePhraseMembership();
this.refreshSubmapVisibility();
this.refreshConnectorGeometry();
debug("connector endpoint changed", {
connectorId: connector.id,
type,
previousItemId: previous ? previous.id : null,
itemId: endpoint.id
});
this.scheduleHistoryCommit();
return true;
}
// Selection Delegation
selectItem(record, options) { return this.selection.selectItem(record, options); }
selectConnector(record) { return this.selection.selectConnector(record); }
clearSelection(notify) { return this.selection.clearSelection(notify); }
selected() { return this.selection.selected(); }
selectedAll() { return this.selection.selectedAll(); }
refreshSelectionDecoration() { return this.selection.refreshSelectionDecoration(); }
storeConceptReferences(records) { return this.selection.storeConceptReferences(records); }
copySelectionReferences() { return this.selection.copySelectionReferences(); }
canCutSelectionReferences() { return this.selection.canCutSelectionReferences(); }
cutSelectionReferences() { return this.selection.cutSelectionReferences(); }
canPasteConceptReferences() { return this.selection.canPasteConceptReferences(); }
pasteConceptReferences() { return this.selection.pasteConceptReferences(); }
selectAll() { return this.selection.selectAll(); }
layoutSelectionRecords() { return this.selection.layoutSelectionRecords(); }
canLayoutSelection(command) { return this.selection.canLayoutSelection(command); }
applySelectionLayout(command) { return this.selection.applySelectionLayout(command); }
canGroupSelection() { return this.selection.canGroupSelection(); }
groupSelection(options) { return this.selection.groupSelection(options); }
canUngroupSelection() { return this.selection.canUngroupSelection(); }
ungroupSelection() { return this.selection.ungroupSelection(); }
deleteSelection() { return this.selection.deleteSelection(); }
notifySelection() { return this.selection.notifySelection(); }
getDocumentMetadata() {
return {
tags: [...this.documentMetadata.tags],
summary: this.documentMetadata.summary,
explanationPageSlug: this.documentMetadata.explanationPageSlug
};
}
setDocumentMetadata(metadata = {}) {
this.scheduleHistoryCommit();
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()
};
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();
return this.synchronizeModel().toDocument();
}
/** Return the synchronized domain model currently edited by this view. */
currentModel() {
this.saveCurrentContextLayout();
this.refreshConceptMapReferences();
return this.synchronizeModel();
}
/** Load a public CMap domain model into an empty editor. */
loadModel(model) {
if (!(model instanceof CmapModel)) throw new TypeError("A CmapModel is required");
this.loadDocument(model.toDocument());
}
loadDocument(document = {}) {
if (this.items.length || this.connectors.length || this.unresolvedConnectors.length) {
throw new Error("A concept map document can only be loaded into an empty editor");
}
this.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();
for (const itemDocument of itemDocuments) {
const concept = this.model.repository.concept(itemDocument.conceptId)?.toDocument() || {};
const record = this.addItem({
...itemDocument,
...concept,
id: itemDocument.id,
conceptId: itemDocument.conceptId,
kind: itemDocument.kind || concept.kind || "concept",
parentSubmap: null
});
record.autoWidth = Boolean(itemDocument.autoWidth);
record.autoHeight = Boolean(itemDocument.autoHeight);
record.fitContentPending = false;
record.expanded = Boolean(itemDocument.expanded);
record.submapInitialized = Boolean(itemDocument.submapInitialized);
records.set(record.id, record);
}
const groupNumbers = itemDocuments
.map((item) => /^group-(\d+)$/.exec(item.groupId || ""))
.filter(Boolean)
.map((match) => Number(match[1]));
this.nextGroupId = groupNumbers.length ? Math.max(...groupNumbers) + 1 : 1;
for (const itemDocument of itemDocuments) {
const record = records.get(Number(itemDocument.id));
const parent = records.get(Number(itemDocument.parentSubmapId)) || null;
if (record) record.parentSubmap = parent;
}
for (const connectorDocument of connectorDocuments) {
const source = records.get(Number(connectorDocument.sourceId));
const target = records.get(Number(connectorDocument.targetId));
if (!source || !target) {
this.unresolvedConnectors.push({ ...connectorDocument });
const unresolvedId = Number(connectorDocument.id);
if (Number.isInteger(unresolvedId) && unresolvedId > 0) {
this.nextConnectorId = Math.max(this.nextConnectorId, unresolvedId + 1);
}
console.warn(`${debugPrefix} relation retained without rendering`, {
relationId: connectorDocument.id || null,
sourceId: connectorDocument.sourceId,
targetId: connectorDocument.targetId,
missingSource: !source,
missingTarget: !target
});
continue;
}
this.addConnector(source, target, connectorDocument.hasArrow !== false, connectorDocument);
}
this.reconcilePhraseMembership();
this.refreshConceptMapReferences();
this.applyCurrentContextLayout();
this.clearSelection();
if (!this.history.isRestoring) this.resetHistory();
return this;
}
// Interaction Delegation
startRelationDrag(event, source) { return this.interaction.startRelationDrag(event, source); }
finishRelation(source, target, direct) { return this.interaction.finishRelation(source, target, direct); }
createDraftLine(start) { return this.interaction.createDraftLine(start); }
startResize(event, record) { return this.interaction.startResize(event, record); }
handleItemMove(record, x, y) { return this.interaction.handleItemMove(record, x, y); }
beginItemMove(record, includeDescendants) { return this.interaction.beginItemMove(record, includeDescendants); }
moveSubmapGroup(record, x, y, moveMembership) { return this.interaction.moveSubmapGroup(record, x, y, moveMembership); }
handleItemMoveEnd(record) { return this.interaction.handleItemMoveEnd(record); }
startSubmapFrameDrag(event, record) { return this.interaction.startSubmapFrameDrag(event, record); }
installMarqueeSelection() { return this.interaction.installMarqueeSelection(); }
installCanvasPanning() { return this.interaction.installCanvasPanning(); }
handleMapSelection(component, event) { return this.interaction.handleMapSelection(component, event); }
handleMapActivation(component, event) { return this.interaction.handleMapActivation(component, event); }
// Decorator Delegation
itemHtml(record) { return this.decorator.itemHtml(record); }
editSelected() {
const record = this.selectedItem;
if (!record) return false;
if (record.kind === "phrase") {
this.editPhraseInline(record);
} else if (this.onEditItem) {
this.onEditItem(record);
}
return true;
}
editPhraseInline(record) { return this.decorator.editPhraseInline(record); }
applyItemTypography(record, element) { return this.decorator.applyItemTypography(record, element); }
decorateItem(record, renderedElement) { return this.decorator.decorateItem(record, renderedElement); }
fitItemToContent(record, element) { return this.decorator.fitItemToContent(record, element); }
decorateConnector(record, renderedElement) { return this.decorator.decorateConnector(record, renderedElement); }
ensureSubmapToggle(record, element) { return this.decorator.ensureSubmapToggle(record, element); }
ensureHandles(record, element) { return this.decorator.ensureHandles(record, element); }
removeHandles(element) { return this.decorator.removeHandles(element); }
refreshBoundaryReferences() { return this.decorator.refreshBoundaryReferences(); }
boundaryConceptFor(record, crossedConnector, inside) { return this.decorator.boundaryConceptFor(record, crossedConnector, inside); }
updateSubmapAnchorLine(record, bounds, surface) { return this.decorator.updateSubmapAnchorLine(record, bounds, surface); }
updateSubmapDepth(record, depth) {
record.submapDepth = depth;
for (const child of this.items.filter((item) => item.parentSubmap === record)) {
this.updateSubmapDepth(child, depth + 1);
}
}
pointInBounds(point, bounds) {
return Boolean(bounds && point.x >= bounds.left && point.x <= bounds.right &&
point.y >= bounds.top && point.y <= bounds.bottom);
}
pointNearConnector(point, tolerance = 8 / this.zoomFactor) {
const distanceToSegment = (start, end) => {
const segmentX = end.x - start.x;
const segmentY = end.y - start.y;
const segmentLengthSquared = (segmentX * segmentX) + (segmentY * segmentY);
if (segmentLengthSquared === 0) {
return Math.hypot(point.x - start.x, point.y - start.y);
}
const projection = Math.max(0, Math.min(1,
(((point.x - start.x) * segmentX) + ((point.y - start.y) * segmentY)) /
segmentLengthSquared));
const nearestX = start.x + (projection * segmentX);
const nearestY = start.y + (projection * segmentY);
return Math.hypot(point.x - nearestX, point.y - nearestY);
};
return this.connectors.some((connector) => {
const source = connector.visualSource || this.connectorEndpoint(connector.source);
const target = connector.visualTarget || this.connectorEndpoint(connector.target);
if (!source || !target || source === target) return false;
return distanceToSegment(this.itemCenter(source), this.itemCenter(target)) <= tolerance;
});
}
submapAtPoint(point, excludedRecord = null, excludedRecords = []) {
const exclusions = new Set(excludedRecords);
const candidates = this.items
.filter((item) => item.kind === "submap" && item.expanded &&
item !== excludedRecord &&
!exclusions.has(item) &&
(!excludedRecord || !this.isDescendantOf(item, excludedRecord)) &&
!excludedRecords.some((record) => this.isDescendantOf(item, record)) &&
this.isItemVisible(item))
.sort((a, b) => b.submapDepth - a.submapDepth);
const match = candidates.find((item) => this.pointInBounds(point, this.submapBounds(item, excludedRecord)));
return match || (this.activeMapRoot && this.activeMapRoot !== excludedRecord &&
!exclusions.has(this.activeMapRoot) ? this.activeMapRoot : null);
}
submapBounds(record, excludedRecord = null) {
const visibleItems = this.items.filter((item) =>
this.isDescendantOf(item, record) &&
item !== excludedRecord &&
(!excludedRecord || !this.isDescendantOf(item, excludedRecord)) &&
this.isItemVisible(item));
if (visibleItems.length === 0) return null;
return {
left: Math.min(...visibleItems.map((item) => Number(item.node.attr("x")))) - 34,
top: Math.min(...visibleItems.map((item) => Number(item.node.attr("y")))) - 42,
right: Math.max(...visibleItems.map((item) =>
Number(item.node.attr("x")) + Number(item.node.attr("width")))) + 34,
bottom: Math.max(...visibleItems.map((item) =>
Number(item.node.attr("y")) + Number(item.node.attr("height")))) + 34
};
}
visualEndpointFor(record) {
if (this.isItemVisible(record)) return record;
if (record.kind === "phrase" || this.activeMapRoot) return null;
let parent = record.parentSubmap;
while (parent) {
if (this.isItemVisible(parent)) return parent;
parent = parent.parentSubmap;
}
return null;
}
isEffectiveItemVisible(record) {
if (!this.isItemVisible(record)) return false;
if (record.kind !== "phrase") return true;
const neighbours = this.connectors
.filter((connector) => connector.source === record || connector.target === record)
.map((connector) => connector.source === record ? connector.target : connector.source)
.filter((item) => item.kind !== "phrase");
return neighbours.every((item) => Boolean(this.visualEndpointFor(item)));
}
connectorEndpoint(record) {
if (record.kind === "phrase") {
return this.isEffectiveItemVisible(record) ? record : null;
}
return this.visualEndpointFor(record);
}
applyConnectorVisualEndpoints(connector, source, target) {
if (!source || !target || source === target) {
connector.link.visible(false);
return;
}
if (connector.visualSource !== source) {
connector.link.sourceNode(source.node);
connector.visualSource = source;
}
if (connector.visualTarget !== target) {
connector.link.targetNode(target.node);
connector.visualTarget = target;
}
connector.link.visible(true);
connector.link.straighten();
connector.link.redraw();
}
refreshSubmapVisibility() {
this.submaps.refreshVisibility();
}
updateSubmapFrame(record) {
this.submaps.refreshGroups();
return this.submaps.diagramGroups.get(record)?.redraw() || null;
}
redrawConnectorsFor(record) {
for (const connector of this.connectors) {
if (connector.source === record || connector.target === record ||
connector.visualSource === record || connector.visualTarget === record) {
connector.link.straighten();
connector.link.redraw();
}
}
}
destroy() {
this.destroyed = true;
this.history.destroy();
if (this.boundaryScrollHandler) {
this.canvas.removeEventListener("scroll", this.boundaryScrollHandler);
this.boundaryScrollHandler = null;
}
this.interaction.destroy();
for (const item of this.items) {
if (item.submapAnchorLineElement) {
item.submapAnchorLineElement.remove();
item.submapAnchorLineElement = null;
}
}
this.view.destroy();
debug("editor destroyed");
}
deleteSelection() {
const records = new Set(this.selectedAll().filter((item) => item !== this.activeMapRoot));
for (const record of Array.from(records)) {
if (record.kind === "submap") {
for (const item of this.items) {
if (this.isDescendantOf(item, record)) records.add(item);
}
}
}
const connectors = new Set(this.connectors.filter((connector) =>
connector === this.selectedConnector || records.has(connector.source) || records.has(connector.target)));
const affectedPhrases = new Set();
for (const connector of connectors) {
if (connector.source.kind === "phrase" && !records.has(connector.source)) {
affectedPhrases.add(connector.source);
}
if (connector.target.kind === "phrase" && !records.has(connector.target)) {
affectedPhrases.add(connector.target);
}
}
let foundOrphan = true;
while (foundOrphan) {
foundOrphan = false;
const remainingConnectors = this.connectors.filter((connector) =>
!connectors.has(connector) && !records.has(connector.source) && !records.has(connector.target));
for (const phrase of Array.from(affectedPhrases).filter((item) => !records.has(item))) {
const hasSource = remainingConnectors.some((connector) => connector.target === phrase);
const hasTarget = remainingConnectors.some((connector) => connector.source === phrase);
if (hasSource && hasTarget) continue;
records.add(phrase);
for (const connector of this.connectors) {
if (connector.source !== phrase && connector.target !== phrase) continue;
connectors.add(connector);
if (connector.source.kind === "phrase" && !records.has(connector.source)) {
affectedPhrases.add(connector.source);
}
if (connector.target.kind === "phrase" && !records.has(connector.target)) {
affectedPhrases.add(connector.target);
}
}
foundOrphan = true;
}
}
if (!records.size && !connectors.size) return false;
this.clearSelection(false);
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.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)));
this.unresolvedConnectors = this.unresolvedConnectors.filter((connector) =>
!deletedIds.has(Number(connector.sourceId)) && !deletedIds.has(Number(connector.targetId)));
}
this.items = this.items.filter((item) => !records.has(item));
this.refreshConceptUsageIndicators(Array.from(records).map((record) => record.conceptId));
this.reconcilePhraseMembership();
this.refreshConceptMapReferences();
this.refreshSubmapVisibility();
this.notifySelection();
debug("selection deleted", {
itemIds: Array.from(records).map((item) => item.id),
connectorIds: Array.from(connectors).map((connector) => connector.id)
});
this.scheduleHistoryCommit();
return true;
}
logicalCanvasWidth() {
const surface = this.surfaceElement();
return Math.max(this.canvas.clientWidth / this.zoomFactor,
surface ? surface.scrollWidth : 0);
}
logicalCanvasHeight() {
const surface = this.surfaceElement();
return Math.max(this.canvas.clientHeight / this.zoomFactor,
surface ? surface.scrollHeight : 0);
}
canvasPoint(event) {
const rect = this.canvas.getBoundingClientRect();
return {
x: (event.clientX - rect.left + this.canvas.scrollLeft) / this.zoomFactor,
y: (event.clientY - rect.top + this.canvas.scrollTop) / this.zoomFactor
};
}
itemAt(clientX, clientY) {
const element = document.elementFromPoint(clientX, clientY);
const itemElement = element ? element.closest("[data-rw-cmap-item-id]") : null;
if (!itemElement) return null;
const id = Number(itemElement.dataset.rwCmapItemId);
return this.items.find((item) => item.id === id) || null;
}
itemCenter(record) {
return {
x: Number(record.node.attr("x")) + (Number(record.node.attr("width")) / 2),
y: Number(record.node.attr("y")) + (Number(record.node.attr("height")) / 2)
};
}
itemHtml(record) {
if (record.kind === "phrase") {
return `<div class="rw-cmap-phrase-label">${escapeHtml(record.label || "?????")}</div>`;
}
if (this.renderItem) return this.renderItem(record);
return `<div>${escapeHtml(record.label)}</div>`;
}
notifySelection() {
if (this.onSelectionChange) {
this.onSelectionChange(this.selectedItem, this.selectedConnector, this.selectedAll());
}
}
}
let lastEditor = null;
window.RacketWikiCmap = {
version: "0.2.122",
createEditor(canvas, options) {
lastEditor = new CmapEditor(canvas, options);
return lastEditor;
},
debugSelection() {
if (!lastEditor) {
debug("debugSelection: no editor has been created");
return null;
}
const record = lastEditor.selected();
const element = record ? record.node.element() : null;
const result = {
selectedId: record ? record.id : null,
selectedIds: lastEditor.selectedAll().map((item) => item.id),
selectedKind: record ? record.kind : null,
selectedLabel: record ? record.label : null,
element: elementDescription(element),
selectedClassPresent: Boolean(element && element.classList.contains("rw-cmap-selected")),
handleCount: element ? element.querySelectorAll(":scope > .rw-cmap-handle").length : 0,
computedStyle: selectionStyle(element)
};
debug("manual selection inspection", result);
return result;
}
};
})();