diff --git a/static/cmap/README.md b/static/cmap/README.md
index 282d3fe..0eb07de 100644
--- a/static/cmap/README.md
+++ b/static/cmap/README.md
@@ -2,12 +2,32 @@
This directory contains the complete browser-side CMap component.
-`cmap.js` is the drawing and hit-testing engine. It is based on the
-MIT-licensed ionstage/cmap 0.1.3 source and is now maintained as part of
-racket-wiki. Its public additions include `onSelection` on a map and
-`onActivation` and `onRendered` callbacks. Activation is recognized inside the
-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.js` is the ES-module entry point of the diagram engine. Its public
+`DiagramEngine`, `DiagramNode` and `DiagramLink` classes expose only the API
+used by the wiki editor: creating nodes and links, changing their presentation,
+connecting endpoints, zooming and receiving render, selection, activation and
+move events. The engine does not install a `window.Cmap` global.
+
+The modules under `engine/` separate that API from the renderer internals.
+`diagram-engine.js` owns the surface and component lifetime;
+`diagram-node.js`, `diagram-link.js` and `diagram-component.js` define the
+public handles. The `drawing-*` modules contain DOM rendering, geometry,
+relations, hit testing and pointer interaction. That rendering core is based on
+the MIT-licensed ionstage/cmap 0.1.3 source and is maintained as part of
+racket-wiki. Activation is recognized inside the same hit-test and drag
+lifecycle as selection, so it does not depend on a DOM `dblclick` event that
+may be suppressed by dragging.
+
+`diagram-group.js` adds the generic view-level `DiagramGroup` abstraction. A
+group contains nodes or nested groups, calculates a frame from their geometry,
+and can be expanded or collapsed. It deliberately knows nothing about CMaps,
+wiki pages or aspects. `DiagramEngine.setFilter` accepts an application policy
+for component visibility; the engine combines that policy with each component's
+own visibility and hides links whose endpoints are hidden. A wiki adapter can
+therefore translate submap membership or aspect matching into groups and
+filters without putting domain rules into the drawing engine. Proxy endpoints
+and boundary navigation remain application concerns until a generic endpoint
+projection API is introduced.
`model/concept-repository.js` owns shared concepts, semantic concept relations
and concept ownership. `model/concept-map.js` owns one map's concept
@@ -25,7 +45,7 @@ The repository objects keep only an in-memory session copy; the backend remains
authoritative. None of these model modules contains DOM or drawing-engine
objects, and CMap state is not persisted in browser storage.
-`cmap-view.js` owns the canvas and the concrete `cmap.js` drawing instance.
+`cmap-view.js` owns the canvas and its concrete `DiagramEngine` instance.
`view/appearance-editor.js` presents the appearance model in the concept dialog
and coordinates explicit style and palette changes with its repository.
The concrete dialog controllers live in `../js/wiki/cmap/dialogs/`. They own
diff --git a/static/cmap/cmap-racket-wiki.js b/static/cmap/cmap-racket-wiki.js
index c26e1ae..c1195df 100644
--- a/static/cmap/cmap-racket-wiki.js
+++ b/static/cmap/cmap-racket-wiki.js
@@ -6,7 +6,23 @@ import {
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.
@@ -17,120 +33,21 @@ import { CmapView } from "./cmap-view.js";
(() => {
"use strict";
- const debugPrefix = "[racket-wiki:cmap 0.2.122]";
- // The editor is rebuilt when navigating between stored CMaps. Clipboard
- // entries therefore belong to the shared module, not to one editor instance.
- let copiedConceptReferences = [];
-
- function debug(message, details) {
- if (details === undefined) {
- console.info(debugPrefix, message);
- return;
- }
- console.info(debugPrefix, message, details);
- }
-
- function elementDescription(element) {
- if (!(element instanceof Element)) return String(element);
- return {
- tag: element.tagName,
- id: element.id || null,
- classes: Array.from(element.classList),
- itemId: element.dataset.rwCmapItemId || null
- };
- }
-
- function selectionStyle(element) {
- if (!(element instanceof Element) || typeof window.getComputedStyle !== "function") return null;
- const style = window.getComputedStyle(element);
- return {
- pointerEvents: style.pointerEvents,
- outline: style.outline,
- outlineOffset: style.outlineOffset,
- boxShadow: style.boxShadow,
- overflow: style.overflow,
- zIndex: style.zIndex
- };
- }
-
debug("cmap-racket-wiki.js loaded", {
script: document.currentScript ? document.currentScript.src : null,
- cmapAvailable: typeof window.Cmap === "function",
+ cmapAvailable: true,
stylesheets: Array.from(document.styleSheets || [])
.map((sheet) => sheet.href)
.filter((href) => href && href.includes("cmap.css"))
});
- //////////////////////////////////////////////////////////////////////////////
- // Small helpers
- //////////////////////////////////////////////////////////////////////////////
-
- function escapeHtml(value) {
- return String(value || "")
- .replaceAll("&", "&")
- .replaceAll("<", "<")
- .replaceAll(">", ">")
- .replaceAll('"', """)
- .replaceAll("'", "'");
- }
-
- function numberOr(value, fallback) {
- return Number.isFinite(value) ? value : fallback;
- }
-
- function conceptDescriptionReference(label, id) {
- const slug = String(label || "")
- .normalize("NFKD")
- .toLocaleLowerCase()
- .replace(/[\u0300-\u036f]/g, "")
- .replace(/[^\p{L}\p{N}]+/gu, "-")
- .replace(/^-+|-+$/g, "")
- .slice(0, 120)
- .replace(/-+$/g, "");
- return `cmap:${slug || `concept-${id}`}`;
- }
-
- function newConceptId() {
- if (window.crypto && typeof window.crypto.randomUUID === "function") {
- try {
- return window.crypto.randomUUID().toLowerCase();
- } catch (_error) {
- // randomUUID can be exposed but forbidden in an insecure/file context.
- }
- }
- const bytes = new Uint8Array(16);
- if (window.crypto && typeof window.crypto.getRandomValues === "function") {
- window.crypto.getRandomValues(bytes);
- } else {
- for (let index = 0; index < bytes.length; index += 1) {
- bytes[index] = Math.floor(Math.random() * 256);
- }
- }
- bytes[6] = (bytes[6] & 0x0f) | 0x40;
- bytes[8] = (bytes[8] & 0x3f) | 0x80;
- const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0"));
- return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex.slice(6, 8).join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10).join("")}`;
- }
-
- function normalizeConceptTags(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;
- return {
- type: String(tag.type || "label").trim() || "label",
- value: String(tag.value || tag.name || "").trim()
- };
- }).filter((tag) => tag && tag.value);
- }
-
//////////////////////////////////////////////////////////////////////////////
// Editor
//////////////////////////////////////////////////////////////////////////////
/**
* goal : Add the wiki editor model to the bundled CMap drawing component.
- * pre : canvas is a DOM element and window.Cmap is available.
+ * pre : canvas is a DOM element.
* post : Concepts and linking phrases can be selected and connected by
* direct manipulation.
* result : A CmapEditor instance.
@@ -138,7 +55,6 @@ import { CmapView } from "./cmap-view.js";
class CmapEditor {
constructor(canvas, options = {}) {
this.canvas = canvas;
- this.CmapFactory = options.Cmap || window.Cmap;
this.renderItem = options.renderItem || null;
this.onOpenPage = options.onOpenPage || null;
this.onOpenSubMap = options.onOpenSubMap || null;
@@ -146,6 +62,7 @@ import { CmapView } from "./cmap-view.js";
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;
@@ -164,9 +81,9 @@ import { CmapView } from "./cmap-view.js";
};
this.view = new CmapView(
canvas,
- this.CmapFactory,
- (component, event) => this.handleMapSelection(component, event),
- (component, event) => this.handleMapActivation(component, event));
+ (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 = [];
@@ -187,20 +104,27 @@ import { CmapView } from "./cmap-view.js";
this.zoomFactor = 1;
this.marqueeMouseDownHandler = null;
this.activeMarqueeCleanup = null;
- this.undoStack = [];
- this.redoStack = [];
- this.historySnapshotValue = null;
- this.historyTimer = null;
- this.historyReady = false;
- this.historyRestoring = false;
- this.historyLimit = 100;
+ 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.installMarqueeSelection();
+ this.interaction.installCanvasPanning();
+ this.interaction.installMarqueeSelection();
debug("editor created", {
canvas: elementDescription(canvas),
- cmapFactoryAvailable: typeof this.CmapFactory === "function"
+ drawingEngine: this.map.constructor.name
});
}
@@ -431,72 +355,34 @@ import { CmapView } from "./cmap-view.js";
return JSON.stringify(this.toDocument());
}
- notifyHistory() {
- if (this.onHistoryChange) {
- this.onHistoryChange({
- canUndo: this.canUndo(),
- canRedo: this.canRedo()
- });
- }
- }
-
resetHistory() {
- if (this.historyTimer !== null) {
- window.clearTimeout(this.historyTimer);
- this.historyTimer = null;
- }
- this.undoStack = [];
- this.redoStack = [];
- this.historyReady = true;
- this.historySnapshotValue = this.historySnapshot();
- this.notifyHistory();
+ this.history.reset();
}
scheduleHistoryCommit() {
- if (!this.historyReady || this.historyRestoring) return;
- if (this.historyTimer !== null) window.clearTimeout(this.historyTimer);
- this.historyTimer = window.setTimeout(() => {
- this.historyTimer = null;
- this.commitHistory();
- }, 0);
+ this.history.scheduleCommit();
}
refreshHistorySnapshot() {
- if (!this.historyReady || this.historyRestoring || this.historyTimer !== null) return;
- this.historySnapshotValue = this.historySnapshot();
+ this.history.refreshSnapshot();
}
commitHistory() {
- if (!this.historyReady || this.historyRestoring) return false;
- if (this.historyTimer !== null) {
- window.clearTimeout(this.historyTimer);
- this.historyTimer = null;
- }
- const nextSnapshot = this.historySnapshot();
- if (nextSnapshot === this.historySnapshotValue) return false;
- if (this.historySnapshotValue !== null) {
- this.undoStack.push(this.historySnapshotValue);
- if (this.undoStack.length > this.historyLimit) this.undoStack.shift();
- }
- this.historySnapshotValue = nextSnapshot;
- this.redoStack = [];
- this.notifyHistory();
- return true;
+ return this.history.commit();
}
canUndo() {
- return this.undoStack.length > 0;
+ return this.history.canUndo();
}
canRedo() {
- return this.redoStack.length > 0;
+ 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.submapFrameElement) record.submapFrameElement.remove();
if (record.submapAnchorLineElement) record.submapAnchorLineElement.remove();
record.node.remove();
}
@@ -517,50 +403,37 @@ import { CmapView } from "./cmap-view.js";
this.nextGroupId = 1;
}
- restoreHistorySnapshot(snapshot) {
+ restoreHistoryDocument(snapshot) {
const activeMapRootId = this.activeMapRoot ? this.activeMapRoot.id : null;
const mapHistoryIds = this.mapHistory.map((record) => record.id);
- this.historyRestoring = true;
- try {
- 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);
- } finally {
- this.historyRestoring = false;
- }
- this.historySnapshotValue = snapshot;
+ 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();
- this.notifyHistory();
}
undo() {
- this.commitHistory();
- if (!this.canUndo()) return false;
- this.redoStack.push(this.historySnapshotValue);
- const snapshot = this.undoStack.pop();
- this.restoreHistorySnapshot(snapshot);
+ const restored = this.history.undo();
+ if (!restored) return false;
debug("undo applied", {
- undoCount: this.undoStack.length,
- redoCount: this.redoStack.length
+ undoCount: this.history.undoCount,
+ redoCount: this.history.redoCount
});
return true;
}
redo() {
- this.commitHistory();
- if (!this.canRedo()) return false;
- this.undoStack.push(this.historySnapshotValue);
- const snapshot = this.redoStack.pop();
- this.restoreHistorySnapshot(snapshot);
+ const restored = this.history.redo();
+ if (!restored) return false;
debug("redo applied", {
- undoCount: this.undoStack.length,
- redoCount: this.redoStack.length
+ undoCount: this.history.undoCount,
+ redoCount: this.history.redoCount
});
return true;
}
@@ -630,7 +503,6 @@ import { CmapView } from "./cmap-view.js";
synopsisFontStyle: layout.synopsisFontStyle || layout.fontStyle ||
options.synopsisFontStyle || options.fontStyle || "normal"
}])) : {},
- submapFrameElement: null,
submapAnchorLineElement: null,
imageSource: options.imageSource || "",
backgroundColor: options.backgroundColor || "#f3f6f8",
@@ -727,7 +599,7 @@ import { CmapView } from "./cmap-view.js";
}
mapContextKey(root = this.activeMapRoot) {
- return root && root.mapReference && root.mapReference.id ? root.mapReference.id : "root";
+ return this.submaps.mapContextKey(root);
}
itemLayout(record) {
@@ -810,7 +682,13 @@ import { CmapView } from "./cmap-view.js";
this.refreshSubmapVisibility();
this.refreshConnectorGeometry();
window.requestAnimationFrame(() => {
- if (!this.destroyed && this.canvas.isConnected) this.refreshConnectorGeometry();
+ 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();
+ }
});
}
@@ -832,8 +710,7 @@ import { CmapView } from "./cmap-view.js";
}
itemInsideActiveMap(record) {
- return Boolean(this.activeMapRoot &&
- (record === this.activeMapRoot || this.isDescendantOf(record, this.activeMapRoot)));
+ return this.submaps.itemInsideActiveMap(record);
}
boundaryConceptFor(record, crossedConnector, inside) {
@@ -948,208 +825,68 @@ import { CmapView } from "./cmap-view.js";
}
isDescendantOf(record, submap) {
- let parent = record.parentSubmap;
- while (parent) {
- if (parent === submap) return true;
- parent = parent.parentSubmap;
- }
- return false;
+ return this.submaps.isDescendantOf(record, submap);
}
isItemVisible(record) {
- const context = this.mapContextKey();
- if (record !== this.activeMapRoot && record.hiddenContexts.has(context)) return false;
- if (this.activeMapRoot) {
- if (record === this.activeMapRoot) return true;
- if (!this.isDescendantOf(record, this.activeMapRoot)) return false;
- let parent = record.parentSubmap;
- while (parent && parent !== this.activeMapRoot) {
- if (!parent.expanded) return false;
- parent = parent.parentSubmap;
- }
- return parent === this.activeMapRoot;
- }
-
- let parent = record.parentSubmap;
- while (parent) {
- if (!parent.expanded) return false;
- parent = parent.parentSubmap;
- }
- return true;
+ return this.submaps.isItemVisible(record);
}
hiddenItemsInCurrentContext() {
- const context = this.mapContextKey();
- return this.items
- .filter((item) => item !== this.activeMapRoot && item.kind !== "phrase" &&
- item.hiddenContexts.has(context))
- .sort((left, right) => left.label.localeCompare(right.label));
+ return this.submaps.hiddenItemsInCurrentContext();
}
canHideSelectionInCurrentContext() {
- return !this.activeMapRoot && this.selectedAll().some((item) => item.parentSubmap &&
- item.kind !== "phrase" && this.isItemVisible(item));
+ return this.submaps.canHideSelectionInCurrentContext();
}
hideSelectionInCurrentContext() {
- const context = this.mapContextKey();
- if (this.activeMapRoot) return false;
- const selected = this.selectedAll().filter((item) => item.parentSubmap &&
- item.kind !== "phrase" && this.isItemVisible(item));
- if (!selected.length) return false;
- this.scheduleHistoryCommit();
- for (const item of selected) item.hiddenContexts.add(context);
- this.clearSelection();
- this.refreshSubmapVisibility();
- if (this.onVisibilityChange) this.onVisibilityChange(this.hiddenItemsInCurrentContext());
- return true;
+ return this.submaps.hideSelectionInCurrentContext();
}
showItemInCurrentContext(record) {
- if (!record) return false;
- const context = this.mapContextKey();
- if (!record.hiddenContexts.has(context)) return false;
- this.scheduleHistoryCommit();
- record.hiddenContexts.delete(context);
- this.refreshSubmapVisibility();
- if (this.onVisibilityChange) this.onVisibilityChange(this.hiddenItemsInCurrentContext());
- return true;
+ return this.submaps.showItemInCurrentContext(record);
}
ensureSubmapContents(record) {
- if (record.submapInitialized) return;
- record.submapInitialized = true;
- if (this.onPopulateSubMap) this.onPopulateSubMap(record, this);
+ return this.submaps.ensureSubmapContents(record);
}
toggleSubmap(record, expanded = !record.expanded) {
- if (!record || record.kind !== "submap") return false;
- // Legacy embedded pages had only an "open" state. New shared sub-CMaps
- // keep their expand/collapse state in the parent and use a separate
- // button for the standalone route.
- if (record.separateMap && !record.cmapSlug) {
- record.expanded = false;
- if (record === this.activeMapRoot) {
- this.refreshSubmapVisibility();
- return false;
- }
- return this.openSubmapMap(record);
- }
- // Persist the complete placement before hiding descendants. Reapplying
- // the same document-local context on expansion guarantees that a
- // submap opens at the same coordinates and dimensions every time.
- this.saveCurrentContextLayout();
- if (expanded) this.ensureSubmapContents(record);
- record.expanded = Boolean(expanded);
- if (record.expanded) this.applyCurrentContextLayout();
- else this.refreshSubmapVisibility();
- const element = record.node.element();
- if (element) this.ensureSubmapToggle(record, element);
- debug("submap toggled", {
- id: record.id,
- expanded: record.expanded,
- childCount: this.items.filter((item) => item.parentSubmap === record).length
- });
- if (this.onOpenSubMap) this.onOpenSubMap(record, record.expanded);
- this.scheduleHistoryCommit();
- return record.expanded;
+ return this.submaps.toggleSubmap(record, expanded);
}
openSubmapMap(record) {
- if (!record || record.kind !== "submap" || !record.separateMap) return false;
- if (record === this.activeMapRoot) return true;
- this.ensureSubmapContents(record);
- this.clearSelection();
- this.saveCurrentContextLayout();
- if (this.activeMapRoot) this.mapHistory.push(this.activeMapRoot);
- this.activeMapRoot = record;
- this.applyCurrentContextLayout();
- if (this.onMapChange) this.onMapChange(record.mapReference, record);
- debug("separate concept map opened", { id: record.id, mapReference: record.mapReference });
- return true;
+ return this.submaps.openSubmapMap(record);
}
openRootMap() {
- if (!this.activeMapRoot) return false;
- this.clearSelection();
- this.saveCurrentContextLayout();
- this.activeMapRoot = null;
- this.mapHistory = [];
- this.applyCurrentContextLayout();
- if (this.onMapChange) this.onMapChange(null, null);
- debug("root concept map opened");
- return true;
+ return this.submaps.openRootMap();
}
canStepBackWithinMap() {
- return this.mapHistory.length > 0;
+ return this.submaps.canStepBackWithinMap();
}
openParentMap() {
- if (!this.activeMapRoot) return false;
- this.clearSelection();
- this.saveCurrentContextLayout();
- this.activeMapRoot = this.mapHistory.pop() || null;
- this.applyCurrentContextLayout();
- const reference = this.activeMapRoot ? this.activeMapRoot.mapReference : null;
- if (this.onMapChange) this.onMapChange(reference, this.activeMapRoot);
- debug("parent concept map opened", {
- id: this.activeMapRoot ? this.activeMapRoot.id : null,
- mapReference: reference
- });
- return true;
+ return this.submaps.openParentMap();
}
promoteSubmap(record, name) {
- if (!record || record.kind !== "submap") return null;
- this.ensureSubmapContents(record);
- record.childMap = String(name || record.label).trim() || record.label;
- record.separateMap = true;
- record.mapReference = {
- id: `cmap-${record.id}`,
- title: record.childMap,
- rootItemId: record.id,
- itemIds: this.items
- .filter((item) => this.isDescendantOf(item, record))
- .map((item) => item.id)
- };
- this.conceptMaps.set(record.mapReference.id, record.mapReference);
- record.expanded = false;
- this.updateItem(record, {
- synopsis: `Concept map: ${record.childMap}`
- });
- if (this.onSubMapPromoted) this.onSubMapPromoted(record);
- this.refreshSubmapVisibility();
- debug("submap promoted to separate map", { id: record.id, childMap: record.childMap });
- return record.mapReference;
+ return this.submaps.promoteSubmap(record, name);
}
prepareStoredSubmapExtraction(record, targetSlug, childMetadata = null) {
- if (!record || record.kind !== "submap") return null;
- this.ensureSubmapContents(record);
- return this.synchronizeModel().extractSubmap(record.id, targetSlug, childMetadata);
+ return this.submaps.prepareStoredSubmapExtraction(record, targetSlug, childMetadata);
}
replaceDocument(document) {
if (!document || typeof document !== "object") return false;
- this.commitHistory();
- const previousSnapshot = this.historySnapshotValue || this.historySnapshot();
- this.historyRestoring = true;
- try {
+ this.history.replace(() => {
this.clearDocument();
this.loadDocument(document);
- } finally {
- this.historyRestoring = false;
- }
- const nextSnapshot = this.historySnapshot();
- if (previousSnapshot !== nextSnapshot) {
- this.undoStack.push(previousSnapshot);
- if (this.undoStack.length > this.historyLimit) this.undoStack.shift();
- }
- this.historySnapshotValue = nextSnapshot;
- this.redoStack = [];
+ });
this.notifySelection();
- this.notifyHistory();
return true;
}
@@ -1172,6 +909,26 @@ import { CmapView } from "./cmap-view.js";
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();
}
@@ -1232,6 +989,7 @@ import { CmapView } from "./cmap-view.js";
textColor: record.textColor
});
record.node.redraw();
+ if (record.kind === "submap") this.submaps.updateGroupAppearance(record);
this.redrawConnectorsFor(record);
this.refreshSubmapVisibility();
}
@@ -1392,458 +1150,29 @@ import { CmapView } from "./cmap-view.js";
return true;
}
- /**
- * goal : Select a concept/linking phrase, optionally beside the current selection.
- * pre : record belongs to this editor.
- * post : Its logical group is selected as one unit; the primary item exposes handles.
- */
- selectItem(record, options = {}) {
- if (!record) {
- this.clearSelection();
- return;
- }
- const additive = Boolean(options.additive);
- const toggle = Boolean(options.toggle);
- const groupRecords = record.groupId && options.expandGroup !== false ?
- this.items.filter((item) => item.groupId === record.groupId && this.isItemVisible(item)) :
- [record];
- debug("selectItem called", {
- requestedId: record.id,
- requestedKind: record.kind,
- additive,
- groupId: record.groupId,
- previousIds: this.selectedAll().map((item) => item.id)
- });
-
- if (!additive) this.clearSelection(false);
- const remove = toggle && groupRecords.every((item) => this.selectedItems.has(item));
- for (const item of groupRecords) {
- if (remove) {
- this.selectedItems.delete(item);
- } else {
- this.selectedItems.add(item);
- }
- }
-
- this.selectedItem = remove ? (this.selectedAll().at(-1) || null) : record;
- this.selectedConnector = null;
- this.refreshSelectionDecoration();
- debug("selection applied", {
- selectedId: this.selectedItem ? this.selectedItem.id : null,
- selectedIds: this.selectedAll().map((item) => item.id),
- selectionCount: this.selectedItems.size
- });
- this.notifySelection();
- }
-
- refreshSelectionDecoration() {
- for (const item of this.items) {
- const element = item.node.element();
- const selected = this.selectedItems.has(item);
- if (element) {
- element.classList.toggle("rw-cmap-selected", selected);
- element.classList.toggle(
- "rw-cmap-selected-primary", selected && item === this.selectedItem);
- if (selected) {
- element.setAttribute("aria-selected", "true");
- item.node.toFront();
- } else {
- element.removeAttribute("aria-selected");
- }
- this.removeHandles(element);
- if (selected && item === this.selectedItem) this.ensureHandles(item, element);
- }
- if (item.kind === "submap") this.updateSubmapFrame(item);
- }
- }
-
- /**
- * goal : Select a connector so its line becomes clearly visible.
- * pre : record belongs to this editor.
- * post : Previous selection is cleared and the connector is highlighted.
- */
- selectConnector(record) {
- this.clearSelection();
- this.selectedConnector = record;
- record.link.attr({ lineColor: "#4f5ee8", lineWidth: 4 });
- record.link.redraw();
- this.notifySelection();
- }
-
- /**
- * goal : Remove the current item/connector selection.
- * post : No item handles or connector highlight remain.
- */
- clearSelection(notify = true) {
- const clearedItemIds = this.selectedAll().map((item) => item.id);
- const clearedConnectorId = this.selectedConnector ? this.selectedConnector.id : null;
- for (const item of this.selectedItems) {
- if (item.submapFrameElement) {
- item.submapFrameElement.classList.remove("rw-cmap-submap-frame-selected");
- item.submapFrameElement.classList.remove("rw-cmap-submap-frame-selected-primary");
- }
- const element = item.node.element();
- if (element) {
- element.classList.remove("rw-cmap-selected");
- element.classList.remove("rw-cmap-selected-primary");
- element.removeAttribute("aria-selected");
- this.removeHandles(element);
- }
- }
- if (this.selectedConnector) {
- const connector = this.selectedConnector;
- connector.link.attr({ lineColor: connector.lineColor, lineWidth: connector.lineWidth });
- connector.link.redraw();
- }
- this.selectedItem = null;
- this.selectedItems.clear();
- this.selectedConnector = null;
- if (clearedItemIds.length || clearedConnectorId) {
- debug("selection cleared", { itemIds: clearedItemIds, connectorId: clearedConnectorId });
- }
- if (notify) this.notifySelection();
- }
-
- selected() {
- return this.selectedItem;
- }
-
- selectedAll() {
- return Array.from(this.selectedItems);
- }
-
- storeConceptReferences(records) {
- if (!records.length) return 0;
- const referencesById = new Map(records.map((source) =>
- [source.conceptId, {
- conceptId: source.conceptId,
- kind: source.kind === "submap" ? "concept" : source.kind,
- label: source.label,
- synopsis: source.synopsis,
- aspects: Array.isArray(source.aspects) ? [...source.aspects] : [],
- tags: normalizeConceptTags(source.tags).map((tag) => ({ ...tag })),
- descriptionPageSlug: source.descriptionPageSlug,
- pageSlug: source.pageSlug,
- cmapSlug: source.cmapSlug,
- externalUrl: source.externalUrl,
- imageSource: source.imageSource,
- width: Number(source.node.attr("width")),
- height: Number(source.node.attr("height")),
- backgroundColor: source.backgroundColor,
- borderColor: source.borderColor,
- textColor: source.textColor,
- fontFamily: source.fontFamily,
- fontSize: source.fontSize,
- fontWeight: source.fontWeight,
- fontStyle: source.fontStyle,
- synopsisTextColor: source.synopsisTextColor,
- synopsisFontFamily: source.synopsisFontFamily,
- synopsisFontSize: source.synopsisFontSize,
- synopsisFontWeight: source.synopsisFontWeight,
- synopsisFontStyle: source.synopsisFontStyle
- }]));
- copiedConceptReferences = Array.from(referencesById.values());
- return copiedConceptReferences.length;
- }
-
- copySelectionReferences() {
- const selected = this.selectedAll()
- .filter((item) => item.conceptId && item.kind !== "phrase");
- const copied = this.storeConceptReferences(selected);
- if (!copied) return 0;
- this.notifySelection();
- return copied;
- }
-
- canCutSelectionReferences() {
- return this.selectedAll().some((item) =>
- item !== this.activeMapRoot && item.conceptId && item.kind !== "phrase");
- }
-
- cutSelectionReferences() {
- const cuttable = this.selectedAll().filter((item) =>
- item !== this.activeMapRoot && item.conceptId && item.kind !== "phrase");
- if (!cuttable.length) return 0;
- const copied = this.storeConceptReferences(cuttable);
- if (!copied) return 0;
- this.clearSelection(false);
- for (const record of cuttable) this.selectedItems.add(record);
- this.selectedItem = cuttable.at(-1) || null;
- this.deleteSelection();
- return copied;
- }
-
- canPasteConceptReferences() {
- return copiedConceptReferences.length > 0;
- }
-
- pasteConceptReferences() {
- const sources = copiedConceptReferences;
- if (!sources.length) return [];
- this.clearSelection(false);
- const parentSubmap = this.activeMapRoot || null;
- const pasted = sources.map((source, index) => this.addItem({
- conceptId: source.conceptId,
- kind: source.kind === "submap" ? "concept" : source.kind,
- label: source.label,
- synopsis: source.synopsis,
- aspects: source.aspects,
- tags: source.tags,
- descriptionPageSlug: source.descriptionPageSlug,
- pageSlug: source.pageSlug,
- cmapSlug: source.cmapSlug,
- externalUrl: source.externalUrl,
- parentCmapLink: false,
- imageSource: source.imageSource,
- parentSubmap,
- submapDepth: parentSubmap ? parentSubmap.submapDepth + 1 : 0,
- x: 120 + (index * 36),
- y: 120 + (index * 36),
- width: source.width,
- height: source.height,
- backgroundColor: source.backgroundColor,
- borderColor: source.borderColor,
- textColor: source.textColor,
- fontFamily: source.fontFamily,
- fontSize: source.fontSize,
- fontWeight: source.fontWeight,
- fontStyle: source.fontStyle,
- synopsisTextColor: source.synopsisTextColor,
- synopsisFontFamily: source.synopsisFontFamily,
- synopsisFontSize: source.synopsisFontSize,
- synopsisFontWeight: source.synopsisFontWeight,
- synopsisFontStyle: source.synopsisFontStyle
- }));
- for (const record of pasted) this.selectedItems.add(record);
- this.selectedItem = pasted.at(-1) || null;
- this.refreshSelectionDecoration();
- this.notifySelection();
- return pasted;
- }
-
- selectAll() {
- this.clearSelection(false);
- for (const item of this.items) {
- if (this.isEffectiveItemVisible(item)) this.selectedItems.add(item);
- }
- this.selectedItem = this.selectedAll().at(-1) || null;
- this.refreshSelectionDecoration();
- this.notifySelection();
- return this.selectedAll();
- }
-
- layoutSelectionRecords() {
- return this.selectedAll().filter((item) => this.isEffectiveItemVisible(item));
- }
-
- canLayoutSelection(command) {
- const minimum = ["distribute-horizontal", "distribute-vertical"].includes(command) ? 3 : 2;
- return this.layoutSelectionRecords().length >= minimum;
- }
-
- applySelectionLayout(command) {
- const records = this.layoutSelectionRecords();
- if (!this.canLayoutSelection(command)) return false;
- const boxes = records.map((record) => ({
- record,
- x: Number(record.node.attr("x")),
- y: Number(record.node.attr("y")),
- width: Number(record.node.attr("width")),
- height: Number(record.node.attr("height"))
- }));
- const reference = boxes.find((box) => box.record === this.selectedItem) || boxes.at(-1);
- const updates = new Map(boxes.map(({ record }) => [record, {}]));
- const referenceRight = reference.x + reference.width;
- const referenceCenter = reference.x + (reference.width / 2);
- const referenceBottom = reference.y + reference.height;
- const referenceMiddle = reference.y + (reference.height / 2);
-
- if (["same-width", "same-size"].includes(command)) {
- for (const box of boxes) updates.get(box.record).width = reference.width;
- }
- if (["same-height", "same-size"].includes(command)) {
- for (const box of boxes) updates.get(box.record).height = reference.height;
- }
- if (command === "align-left") {
- for (const box of boxes) updates.get(box.record).x = reference.x;
- }
- if (command === "align-right") {
- for (const box of boxes) updates.get(box.record).x = referenceRight - box.width;
- }
- if (command === "align-center") {
- for (const box of boxes) updates.get(box.record).x = referenceCenter - (box.width / 2);
- }
- if (command === "align-top") {
- for (const box of boxes) updates.get(box.record).y = reference.y;
- }
- if (command === "align-bottom") {
- for (const box of boxes) updates.get(box.record).y = referenceBottom - box.height;
- }
- if (command === "align-middle") {
- for (const box of boxes) updates.get(box.record).y = referenceMiddle - (box.height / 2);
- }
- if (command === "distribute-horizontal") {
- const ordered = [...boxes].sort((a, b) =>
- (a.x + (a.width / 2)) - (b.x + (b.width / 2)) ||
- String(a.record.id).localeCompare(String(b.record.id)));
- const distributionLeft = ordered[0].x;
- const distributionRight = ordered.at(-1).x + ordered.at(-1).width;
- const occupiedWidth = ordered.reduce((sum, box) => sum + box.width, 0);
- const gap = (distributionRight - distributionLeft - occupiedWidth) / (ordered.length - 1);
- let cursor = distributionLeft;
- for (const box of ordered) {
- updates.get(box.record).x = cursor;
- cursor += box.width + gap;
- }
- }
- if (command === "distribute-vertical") {
- const ordered = [...boxes].sort((a, b) =>
- (a.y + (a.height / 2)) - (b.y + (b.height / 2)) ||
- String(a.record.id).localeCompare(String(b.record.id)));
- const distributionTop = ordered[0].y;
- const distributionBottom = ordered.at(-1).y + ordered.at(-1).height;
- const occupiedHeight = ordered.reduce((sum, box) => sum + box.height, 0);
- const gap = (distributionBottom - distributionTop - occupiedHeight) / (ordered.length - 1);
- let cursor = distributionTop;
- for (const box of ordered) {
- updates.get(box.record).y = cursor;
- cursor += box.height + gap;
- }
- }
-
- if (!["same-width", "same-height", "same-size", "align-left", "align-right",
- "align-center", "align-top", "align-bottom", "align-middle",
- "distribute-horizontal", "distribute-vertical"].includes(command)) return false;
-
- this.scheduleHistoryCommit();
- for (const record of records) {
- const attributes = updates.get(record);
- if (attributes.width !== undefined) {
- record.width = attributes.width;
- record.autoWidth = false;
- }
- if (attributes.height !== undefined) {
- record.height = attributes.height;
- record.autoHeight = false;
- }
- record.node.attr(attributes);
- record.node.redraw();
- }
- this.saveCurrentContextLayout();
- this.refreshConnectorGeometry();
- for (const submap of this.items
- .filter((item) => item.kind === "submap")
- .sort((a, b) => b.submapDepth - a.submapDepth)) {
- this.updateSubmapFrame(submap);
- }
- this.refreshSelectionDecoration();
- debug("selection layout applied", {
- command,
- referenceItemId: reference.record.id,
- itemIds: records.map((record) => record.id)
- });
- return true;
- }
-
- canGroupSelection() {
- const selected = this.selectedAll();
- return selected.length >= 2 &&
- selected.every((item) => item.parentSubmap === selected[0].parentSubmap);
- }
-
- groupSelection(options = {}) {
- const selected = this.selectedAll();
- if (!this.canGroupSelection()) return false;
- const parentSubmap = selected[0].parentSubmap;
- const left = Math.min(...selected.map((item) => Number(item.node.attr("x"))));
- const top = Math.min(...selected.map((item) => Number(item.node.attr("y"))));
- const label = String(options.label || "Sub-conceptmap").trim() || "Sub-conceptmap";
- const submap = this.addItem({
- ...options,
- kind: "submap",
- label,
- childMap: options.childMap || label,
- synopsis: options.synopsis || "Grouped sub-concept map.",
- parentSubmap,
- submapDepth: parentSubmap ? parentSubmap.submapDepth + 1 : 0,
- x: numberOr(Number(options.x), left),
- y: numberOr(Number(options.y), Math.max(20, top - 105)),
- backgroundColor: options.backgroundColor || "#edf7e8",
- borderColor: options.borderColor || "#57834a"
- });
-
- submap.expanded = true;
- submap.submapInitialized = true;
- for (const item of selected) {
- item.groupId = null;
- item.parentSubmap = submap;
- this.updateSubmapDepth(item, submap.submapDepth + 1);
- }
- this.reconcilePhraseMembership();
- this.refreshConceptMapReferences();
- this.applyCurrentContextLayout();
- this.selectItem(submap);
- debug("selection grouped as submap", {
- submapId: submap.id,
- itemIds: selected.map((item) => item.id)
- });
- return submap;
- }
-
- canUngroupSelection() {
- return this.selectedAll().some((item) =>
- Boolean(item.groupId) ||
- (item.kind === "submap" && !item.separateMap) ||
- Boolean(item.parentSubmap && item.parentSubmap !== this.activeMapRoot));
- }
-
- ungroupSelection() {
- const groupIds = new Set(this.selectedAll().map((item) => item.groupId).filter(Boolean));
- const affected = this.items.filter((item) => groupIds.has(item.groupId));
- for (const item of affected) item.groupId = null;
-
- const selected = this.selectedAll();
- const selectedSubmaps = new Set(selected.filter((item) =>
- item.kind === "submap" && !item.separateMap));
- const liftedChildren = new Set();
- for (const submap of selectedSubmaps) {
- const parent = submap.parentSubmap;
- const children = this.items.filter((item) => item.parentSubmap === submap);
- for (const child of children) {
- liftedChildren.add(child);
- child.parentSubmap = parent;
- this.updateSubmapDepth(child, parent ? parent.submapDepth + 1 : 0);
- }
- if (submap.submapFrameElement) {
- submap.submapFrameElement.remove();
- submap.submapFrameElement = null;
- }
- submap.expanded = false;
- submap.submapInitialized = false;
- submap.childMap = null;
- this.updateItem(submap, { kind: "concept" });
- affected.push(submap, ...children);
- }
-
- for (const item of selected) {
- if (selectedSubmaps.has(item) || liftedChildren.has(item) || !item.parentSubmap ||
- item.parentSubmap === this.activeMapRoot) continue;
- const parent = item.parentSubmap.parentSubmap;
- item.parentSubmap = parent;
- this.updateSubmapDepth(item, parent ? parent.submapDepth + 1 : 0);
- affected.push(item);
- }
-
- if (!affected.length) return false;
- this.reconcilePhraseMembership();
- this.refreshConceptMapReferences();
- this.refreshSubmapVisibility();
- this.refreshSelectionDecoration();
- debug("items ungrouped", { itemIds: Array.from(new Set(affected)).map((item) => item.id) });
- this.notifySelection();
- 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 {
@@ -1948,15 +1277,27 @@ import { CmapView } from "./cmap-view.js";
this.refreshConceptMapReferences();
this.applyCurrentContextLayout();
this.clearSelection();
- if (!this.historyRestoring) this.resetHistory();
+ if (!this.history.isRestoring) this.resetHistory();
return this;
}
- /**
- * goal : Start editing the currently selected item.
- * post : A phrase is edited inline; another item uses the host editor.
- * result : True when an item was available for editing.
- */
+ // 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;
@@ -1967,463 +1308,17 @@ import { CmapView } from "./cmap-view.js";
}
return true;
}
-
- /**
- * goal : Start direct editing of a linking phrase.
- * pre : record.kind is "phrase".
- * post : An input appears in the relation-name node and receives focus.
- */
- editPhraseInline(record) {
- if (!record || record.kind !== "phrase") return;
- const value = record.label || "?????";
- record.node.attr("content",
- ``);
- record.node.redraw();
- const element = record.node.element();
- const input = element ? element.querySelector(".rw-cmap-phrase-input") : null;
- if (!input) {
- record.editWhenRendered = true;
- return;
- }
-
- const commit = () => {
- // Redrawing a phrase can synchronously fit it to its new label. Keep
- // that layout work inside this edit transaction without losing the
- // snapshot from before the text change.
- this.scheduleHistoryCommit();
- const text = input.value.trim() || "?????";
- record.label = text;
- record.node.attr("content", this.itemHtml(record));
- record.node.redraw();
- this.selectItem(record);
- };
-
- input.addEventListener("pointerdown", (event) => event.stopPropagation());
- input.addEventListener("keydown", (event) => {
- if (event.key === "Enter") {
- event.preventDefault();
- input.blur();
- }
- if (event.key === "Escape") {
- event.preventDefault();
- input.value = value;
- input.blur();
- }
- });
- input.addEventListener("blur", commit, { once: true });
- input.focus();
- input.select();
- }
-
- /**
- * Apply the independently stored heading and synopsis typography.
- * Legacy records have already inherited their old shared typography in
- * addItem, so merely opening an existing CMap does not restyle it.
- */
- applyItemTypography(record, element) {
- const title = element.querySelector(".cmap-card-title");
- if (title) {
- title.style.color = record.textColor;
- title.style.fontFamily = record.fontFamily;
- title.style.fontSize = record.fontSize;
- title.style.fontWeight = record.fontWeight;
- title.style.fontStyle = record.fontStyle;
- }
- const synopsis = element.querySelector(".cmap-card-synopsis");
- if (synopsis) {
- synopsis.style.color = record.synopsisTextColor;
- synopsis.style.fontFamily = record.synopsisFontFamily;
- synopsis.style.fontSize = record.synopsisFontSize;
- synopsis.style.fontWeight = record.synopsisFontWeight;
- synopsis.style.fontStyle = record.synopsisFontStyle;
- }
- }
-
- /**
- * goal : Redraw selection controls after ionstage/cmap updates a node.
- * pre : record.node.redraw() has made a DOM element available.
- * post : Selection, drag-to-link and resize interactions are attached.
- */
- decorateItem(record, renderedElement = null) {
- const element = renderedElement || record.node.element();
- if (!element) return;
- element.classList.remove("rw-cmap-item-concept", "rw-cmap-item-page", "rw-cmap-item-submap", "rw-cmap-item-phrase");
- element.classList.add("cmap-prototype-node", "rw-cmap-item", `rw-cmap-item-${record.kind}`);
- element.dataset.rwCmapItemId = String(record.id);
- element.style.fontFamily = record.fontFamily;
- element.style.fontSize = record.fontSize;
- element.style.fontWeight = record.fontWeight;
- element.style.fontStyle = record.fontStyle;
- element.style.overflow = "visible";
- this.applyItemTypography(record, element);
-
- // Concept content is never presentation-optional. A context may retain a
- // different width or a larger manual height, but it may not keep a
- // height that clips part of the shared content.
- if (record.fitContentPending || record.kind !== "phrase") {
- this.fitItemToContent(record, element);
- }
-
- const image = element.querySelector(".cmap-card-image");
- if (image && image.dataset.rwCmapFitBound !== "1") {
- image.dataset.rwCmapFitBound = "1";
- image.addEventListener("load", () => {
- if (record.kind === "phrase" && !record.autoWidth && !record.autoHeight) return;
- record.fitContentPending = true;
- this.fitItemToContent(record, element);
- }, { once: true });
- }
-
- const descriptionButton = element.querySelector(".rw-cmap-view-description");
- if (descriptionButton && descriptionButton.dataset.rwCmapBound !== "1") {
- descriptionButton.dataset.rwCmapBound = "1";
- descriptionButton.addEventListener("pointerdown", (event) => {
- event.preventDefault();
- event.stopPropagation();
- });
- descriptionButton.addEventListener("click", (event) => {
- event.preventDefault();
- event.stopPropagation();
- if (record.descriptionPageSlug && this.onOpenPage) {
- this.onOpenPage({ ...record, pageSlug: record.descriptionPageSlug });
- }
- });
- }
-
- const linkedButton = element.querySelector(".rw-cmap-open-linked");
- if (linkedButton && linkedButton.dataset.rwCmapBound !== "1") {
- linkedButton.dataset.rwCmapBound = "1";
- linkedButton.addEventListener("pointerdown", (event) => {
- event.preventDefault();
- event.stopPropagation();
- });
- linkedButton.addEventListener("click", (event) => {
- event.preventDefault();
- event.stopPropagation();
- if (record.parentCmapLink) {
- this.openParentMap();
- } else if (record.cmapSlug && this.onOpenCmap) {
- this.onOpenCmap(record);
- } else if (record.pageSlug && this.onOpenPage) {
- this.onOpenPage(record);
- }
- });
- }
-
- const externalButton = element.querySelector(".rw-cmap-open-external");
- if (externalButton && externalButton.dataset.rwCmapBound !== "1") {
- externalButton.dataset.rwCmapBound = "1";
- externalButton.addEventListener("pointerdown", (event) => {
- event.preventDefault();
- event.stopPropagation();
- });
- externalButton.addEventListener("click", (event) => {
- event.preventDefault();
- event.stopPropagation();
- if (record.externalUrl && this.onOpenExternalUrl) this.onOpenExternalUrl(record);
- });
- }
-
- if (element.dataset.rwCmapBound !== "1") {
- element.dataset.rwCmapBound = "1";
- debug("item pointer handlers attached", {
- id: record.id,
- kind: record.kind,
- element: elementDescription(element),
- style: selectionStyle(element)
- });
- }
-
- if (this.selectedItems.has(record)) {
- element.classList.add("rw-cmap-selected");
- element.classList.toggle("rw-cmap-selected-primary", this.selectedItem === record);
- element.setAttribute("aria-selected", "true");
- if (this.selectedItem === record) this.ensureHandles(record, element);
- }
- this.ensureSubmapToggle(record, element);
- debug("cmap node rendered and decorated", {
- id: record.id,
- kind: record.kind,
- selected: this.selectedItems.has(record),
- element: elementDescription(element),
- style: selectionStyle(element)
- });
- if (record.editWhenRendered) {
- record.editWhenRendered = false;
- queueMicrotask(() => this.editPhraseInline(record));
- }
- this.ensureCanvasExtent(
- Number(record.node.attr("x")) + Number(record.node.attr("width")),
- Number(record.node.attr("y")) + Number(record.node.attr("height"))
- );
- if (!record.moveMembership) {
- let parent = record.parentSubmap;
- while (parent) {
- this.updateSubmapFrame(parent);
- parent = parent.parentSubmap;
- }
- }
- }
-
- /**
- * goal : Size automatic items and prevent fixed concept cards clipping content.
- * pre : element has been rendered and contains the current item HTML.
- * post : Automatic dimensions closely surround the text, with long text
- * wrapping at a practical maximum width.
- */
- fitItemToContent(record, element) {
- record.fitContentPending = false;
- if (record.kind === "phrase" && !record.autoWidth && !record.autoHeight) return;
-
- const fixedWidth = !record.autoWidth && record.kind !== "phrase";
-
- const probe = document.createElement("div");
- probe.className = element.className;
- probe.innerHTML = this.itemHtml(record);
- Object.assign(probe.style, {
- position: "fixed",
- left: "-10000px",
- top: "0",
- width: fixedWidth ? `${record.width}px` : "max-content",
- height: "auto",
- maxWidth: fixedWidth ? "none" : (record.kind === "phrase" ? "280px" : "380px"),
- boxSizing: "border-box",
- fontFamily: record.fontFamily,
- fontSize: record.fontSize,
- fontWeight: record.fontWeight,
- fontStyle: record.fontStyle,
- lineHeight: "1.25",
- overflow: "visible",
- pointerEvents: "none",
- transform: "none",
- visibility: "hidden",
- whiteSpace: "normal"
- });
- this.applyItemTypography(record, probe);
-
- const content = probe.firstElementChild;
- if (content) {
- Object.assign(content.style, {
- width: fixedWidth ? "100%" : "max-content",
- height: "auto",
- maxWidth: fixedWidth ? "none" : (record.kind === "phrase" ? "276px" : "376px"),
- overflow: "visible",
- whiteSpace: "normal"
- });
- }
-
- document.body.append(probe);
- const bounds = probe.getBoundingClientRect();
- probe.remove();
-
- const minimumWidth = record.kind === "phrase" ? 50 : 100;
- const minimumHeight = record.kind === "phrase" ? 24 : 40;
- const measuredWidth = Math.ceil(bounds.width) + 4;
- const measuredHeight = Math.ceil(bounds.height) + 4;
- const nextWidth = record.autoWidth ? Math.max(minimumWidth, measuredWidth) : record.width;
- const nextHeight = record.autoHeight ? Math.max(minimumHeight, measuredHeight) :
- (record.kind === "phrase" ? record.height : Math.max(record.height, measuredHeight));
-
- if (nextWidth === record.width && nextHeight === record.height) return;
- // Rendering may finish after the host has recorded its saved baseline.
- // Give the host both exact states so it can advance that baseline only
- // when no user edit occurred in between.
- const beforeAutomaticLayout = this.onAutomaticLayoutChange ? this.historySnapshot() : null;
- const previousWidth = record.width;
- const previousHeight = record.height;
- const attributes = { width: nextWidth, height: nextHeight };
- if (record.kind === "phrase") {
- attributes.x = Number(record.node.attr("x")) + ((previousWidth - nextWidth) / 2);
- attributes.y = Number(record.node.attr("y")) + ((previousHeight - nextHeight) / 2);
- }
- record.width = nextWidth;
- record.height = nextHeight;
- record.node.attr(attributes);
- record.node.redraw();
- this.redrawConnectorsFor(record);
- debug("automatic item size applied", {
- id: record.id,
- kind: record.kind,
- width: nextWidth,
- height: nextHeight
- });
- this.refreshHistorySnapshot();
- if (this.onAutomaticLayoutChange) {
- const afterAutomaticLayout = this.historySnapshot();
- if (beforeAutomaticLayout !== afterAutomaticLayout) {
- this.onAutomaticLayoutChange({
- beforeSnapshot: beforeAutomaticLayout,
- afterSnapshot: afterAutomaticLayout,
- itemId: record.id
- });
- }
- }
- }
-
- decorateConnector(record, renderedElement = null) {
- const element = renderedElement || record.link.element();
- if (!element) return;
- element.classList.add("rw-cmap-connector");
- element.dataset.rwCmapConnectorId = String(record.id);
- }
-
- handleItemMove(record, x, y) {
- // A submap node is an anchor placement. Moving the anchor never moves
- // the separately positioned expanded group; the frame has its own drag.
- const movesCompleteSubmap = false;
- const movesSelection = this.selectedItems.has(record) && this.selectedItems.size > 1;
- const moveMembership = this.beginItemMove(record, movesCompleteSubmap || movesSelection);
- if (movesCompleteSubmap || movesSelection) {
- this.moveSubmapGroup(record, x, y, moveMembership);
- }
- return { x, y };
- }
-
- beginItemMove(record, includeDescendants = false) {
- if (record.moveMembership) return record.moveMembership;
- let groupItems = [record];
- if (includeDescendants) {
- const selected = this.selectedItems.has(record) && this.selectedItems.size > 1 ?
- this.selectedAll() : [record];
- const expanded = [];
- for (const item of selected) {
- expanded.push(item);
- if (item.kind === "submap" && item !== this.activeMapRoot) {
- expanded.push(...this.items.filter((candidate) => this.isDescendantOf(candidate, item)));
- }
- }
- groupItems = Array.from(new Set(expanded));
- }
- record.moveMembership = {
- parent: record.parentSubmap,
- parentBounds: record.parentSubmap ? this.submapBounds(record.parentSubmap) : null,
- startX: Number(record.node.attr("x")),
- startY: Number(record.node.attr("y")),
- groupPositions: groupItems.map((item) => ({
- item,
- x: Number(item.node.attr("x")),
- y: Number(item.node.attr("y"))
- }))
- };
- return record.moveMembership;
- }
-
- moveSubmapGroup(record, x, y, moveMembership = this.beginItemMove(record, true)) {
- const deltaX = x - moveMembership.startX;
- const deltaY = y - moveMembership.startY;
- for (const position of moveMembership.groupPositions) {
- position.item.node.attr({
- x: position.x + deltaX,
- y: position.y + deltaY
- });
- position.item.node.redraw();
- }
- for (const connector of this.connectors) {
- connector.link.straighten();
- connector.link.redraw();
- }
- for (const submap of this.items
- .filter((item) => item.kind === "submap")
- .sort((a, b) => b.submapDepth - a.submapDepth)) {
- this.updateSubmapFrame(submap);
- }
- }
-
- handleItemMoveEnd(record) {
- const moveMembership = record.moveMembership;
- record.moveMembership = null;
- if (!moveMembership) return;
-
- const movedItems = this.selectedAll().filter((item) =>
- moveMembership.groupPositions.some((position) => position.item === item));
- const movedParents = new Set(movedItems.map((item) => item.parentSubmap));
- if (movedItems.length > 1 && movedParents.size === 1) {
- const previousParent = movedItems[0].parentSubmap;
- const centers = movedItems.map((item) => this.itemCenter(item));
- const center = {
- x: centers.reduce((sum, point) => sum + point.x, 0) / centers.length,
- y: centers.reduce((sum, point) => sum + point.y, 0) / centers.length
- };
- let parent = null;
- if (previousParent && this.pointInBounds(center, moveMembership.parentBounds)) {
- parent = previousParent;
- } else {
- parent = this.submapAtPoint(center, null, movedItems);
- }
- if (!parent && this.activeMapRoot && !movedItems.includes(this.activeMapRoot)) {
- parent = this.activeMapRoot;
- }
- if (previousParent && parent !== previousParent && this.onConfirmDetachFromSubmap &&
- !this.onConfirmDetachFromSubmap(record, previousParent, parent)) {
- parent = previousParent;
- }
- if (parent !== previousParent) {
- for (const item of movedItems) {
- if (item === this.activeMapRoot) continue;
- item.parentSubmap = parent;
- this.updateSubmapDepth(item, parent ? parent.submapDepth + 1 : 0);
- }
- this.reconcilePhraseMembership();
- this.refreshConceptMapReferences();
- debug("selection submap membership changed", {
- itemIds: movedItems.map((item) => item.id),
- previousParentId: previousParent ? previousParent.id : null,
- parentId: parent ? parent.id : null
- });
- }
- this.refreshSubmapVisibility();
- this.scheduleHistoryCommit();
- return;
- }
-
- if (record.kind === "phrase") {
- this.scheduleHistoryCommit();
- return;
- }
-
- // A separately opened map keeps its head linked to the parent map. Moving
- // that head edits its position inside the current view; it must not be
- // interpreted as dragging the complete map out of its parent submap.
- if (record === this.activeMapRoot) {
- this.refreshSubmapVisibility();
- debug("active map head moved without changing parent membership", {
- id: record.id,
- parentId: record.parentSubmap ? record.parentSubmap.id : null
- });
- this.scheduleHistoryCommit();
- return;
- }
-
- const center = this.itemCenter(record);
- let parent = null;
- if (moveMembership.parent && this.pointInBounds(center, moveMembership.parentBounds)) {
- parent = moveMembership.parent;
- } else {
- parent = this.submapAtPoint(center, record);
- }
- if (!parent && this.activeMapRoot && record !== this.activeMapRoot) parent = this.activeMapRoot;
-
- if (moveMembership.parent && parent !== moveMembership.parent &&
- this.onConfirmDetachFromSubmap &&
- !this.onConfirmDetachFromSubmap(record, moveMembership.parent, parent)) {
- parent = moveMembership.parent;
- }
-
- if (parent !== record.parentSubmap) {
- const previousParent = record.parentSubmap;
- record.parentSubmap = parent;
- this.updateSubmapDepth(record, parent ? parent.submapDepth + 1 : 0);
- debug("item submap membership changed", {
- id: record.id,
- previousParentId: previousParent ? previousParent.id : null,
- parentId: parent ? parent.id : null
- });
- this.reconcilePhraseMembership();
- this.refreshConceptMapReferences();
- }
- this.refreshSubmapVisibility();
- this.scheduleHistoryCommit();
- }
+ 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;
@@ -2540,175 +1435,12 @@ import { CmapView } from "./cmap-view.js";
}
refreshSubmapVisibility() {
- for (const item of this.items) item.node.visible(this.isEffectiveItemVisible(item));
- for (const connector of this.connectors) {
- this.applyConnectorVisualEndpoints(connector,
- this.connectorEndpoint(connector.source),
- this.connectorEndpoint(connector.target));
- }
- const submaps = this.items
- .filter((item) => item.kind === "submap")
- .sort((a, b) => b.submapDepth - a.submapDepth);
- for (const submap of submaps) {
- this.updateSubmapFrame(submap);
- const element = submap.node.element();
- if (element) this.ensureSubmapToggle(submap, element);
- }
+ this.submaps.refreshVisibility();
}
updateSubmapFrame(record) {
- const surface = this.surfaceElement();
- const shouldShow = record !== this.activeMapRoot &&
- record.expanded && this.isItemVisible(record);
- if (!shouldShow || !surface) {
- if (record.submapFrameElement) {
- record.submapFrameElement.remove();
- record.submapFrameElement = null;
- }
- if (record.submapAnchorLineElement) {
- record.submapAnchorLineElement.remove();
- record.submapAnchorLineElement = null;
- }
- return;
- }
-
- if (!record.submapFrameElement) {
- const frame = document.createElement("div");
- frame.className = "rw-cmap-submap-frame";
- frame.dataset.rwCmapSubmapId = String(record.id);
- frame.tabIndex = 0;
- frame.setAttribute("role", "group");
- frame.setAttribute("aria-label", record.label);
-
- frame.addEventListener("pointerdown", (event) => {
- if (event.button !== 0) return;
- if (this.pointNearConnector(this.canvasPoint(event))) return;
- this.startSubmapFrameDrag(event, record);
- });
- frame.addEventListener("dblclick", (event) => {
- event.preventDefault();
- event.stopPropagation();
- this.selectItem(record);
- if (this.onEditItem) this.onEditItem(record);
- });
-
- const collapse = document.createElement("button");
- collapse.type = "button";
- collapse.className = "rw-cmap-submap-frame-toggle";
- collapse.textContent = "«";
- collapse.title = "Collapse submap";
- collapse.setAttribute("aria-label", collapse.title);
- collapse.setAttribute("aria-expanded", "true");
- collapse.addEventListener("pointerdown", (event) => {
- event.preventDefault();
- event.stopPropagation();
- });
- collapse.addEventListener("click", (event) => {
- event.preventDefault();
- event.stopPropagation();
- this.selectItem(record);
- this.toggleSubmap(record, false);
- });
- frame.append(collapse);
- surface.prepend(frame);
- record.submapFrameElement = frame;
- }
-
- const bounds = this.submapBounds(record);
- if (!bounds) return;
- const { left, top, right, bottom } = bounds;
- Object.assign(record.submapFrameElement.style, {
- left: `${left}px`,
- top: `${top}px`,
- width: `${right - left}px`,
- height: `${bottom - top}px`
- });
- record.submapFrameElement.style.setProperty(
- "--rw-cmap-submap-background", record.submapBackgroundColor || "#edf7e8");
- record.submapFrameElement.style.setProperty(
- "--rw-cmap-submap-border", record.submapBorderColor || "#57834a");
- record.submapFrameElement.classList.toggle(
- "rw-cmap-submap-frame-selected", this.selectedItems.has(record));
- record.submapFrameElement.classList.toggle(
- "rw-cmap-submap-frame-selected-primary",
- this.selectedItems.has(record) && this.selectedItem === record);
- record.submapFrameElement.setAttribute("aria-label", record.label);
- this.updateSubmapAnchorLine(record, bounds, surface);
- this.ensureCanvasExtent(right, bottom);
- }
-
- updateSubmapAnchorLine(record, bounds, surface = this.surfaceElement()) {
- if (!surface || !bounds || record === this.activeMapRoot || !record.expanded) return;
- if (!record.submapAnchorLineElement) {
- const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
- svg.classList.add("rw-cmap-submap-anchor-line");
- const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
- svg.append(path);
- surface.prepend(svg);
- record.submapAnchorLineElement = svg;
- }
- const anchor = this.itemCenter(record);
- const target = {
- x: Math.max(bounds.left, Math.min(anchor.x, bounds.right)),
- y: Math.max(bounds.top, Math.min(anchor.y, bounds.bottom))
- };
- record.submapAnchorLineElement.querySelector("path")
- .setAttribute("d", `M ${anchor.x} ${anchor.y} L ${target.x} ${target.y}`);
- }
-
- startSubmapFrameDrag(event, record) {
- event.preventDefault();
- event.stopPropagation();
- const additive = event.ctrlKey || event.metaKey || event.shiftKey;
- if (!additive && this.selectedItems.size > 1 && this.selectedItems.has(record)) {
- this.selectedItem = record;
- this.refreshSelectionDecoration();
- this.notifySelection();
- } else {
- this.selectItem(record, { additive });
- }
- const pointerId = event.pointerId;
- const startClientX = event.clientX;
- const startClientY = event.clientY;
- const bounds = this.submapBounds(record);
- if (!bounds) return;
- const moveMembership = {
- parent: record.parentSubmap,
- parentBounds: null,
- startX: bounds.left,
- startY: bounds.top,
- groupPositions: this.items
- .filter((item) => this.isDescendantOf(item, record))
- .map((item) => ({
- item,
- x: Number(item.node.attr("x")),
- y: Number(item.node.attr("y"))
- }))
- };
- record.moveMembership = moveMembership;
-
- const move = (moveEvent) => {
- if (moveEvent.pointerId !== pointerId) return;
- moveEvent.preventDefault();
- const x = moveMembership.startX +
- ((moveEvent.clientX - startClientX) / this.zoomFactor);
- const y = moveMembership.startY +
- ((moveEvent.clientY - startClientY) / this.zoomFactor);
- this.moveSubmapGroup(record, x, y, moveMembership);
- };
-
- const up = (upEvent) => {
- if (upEvent.pointerId !== pointerId) return;
- window.removeEventListener("pointermove", move);
- window.removeEventListener("pointerup", up);
- record.moveMembership = null;
- this.saveCurrentContextLayout();
- this.refreshSubmapVisibility();
- this.scheduleHistoryCommit();
- };
-
- window.addEventListener("pointermove", move);
- window.addEventListener("pointerup", up);
+ this.submaps.refreshGroups();
+ return this.submaps.diagramGroups.get(record)?.redraw() || null;
}
redrawConnectorsFor(record) {
@@ -2721,108 +1453,15 @@ import { CmapView } from "./cmap-view.js";
}
}
- installMarqueeSelection() {
- this.marqueeMouseDownHandler = (event) => {
- if (event.button !== 0) return;
- if (!(event.target instanceof Element)) return;
- if (event.target.closest(
- "[data-rw-cmap-item-id], [data-rw-cmap-connector-id], .rw-cmap-submap-frame, .rw-cmap-handle")) return;
-
- const start = this.canvasPoint(event);
- if (this.pointNearConnector(start)) return;
- if (this.activeMarqueeCleanup) this.activeMarqueeCleanup();
- const additive = event.ctrlKey || event.metaKey || event.shiftKey;
- const surface = this.surfaceElement() || this.canvas;
- const marquee = document.createElement("div");
- marquee.className = "rw-cmap-marquee";
- Object.assign(marquee.style, { left: `${start.x}px`, top: `${start.y}px`, width: "0", height: "0" });
- surface.append(marquee);
-
- const cleanup = () => {
- window.removeEventListener("mousemove", move);
- window.removeEventListener("mouseup", up);
- marquee.remove();
- if (this.activeMarqueeCleanup === cleanup) this.activeMarqueeCleanup = null;
- };
-
- const move = (moveEvent) => {
- const point = this.canvasPoint(moveEvent);
- const left = Math.min(start.x, point.x);
- const top = Math.min(start.y, point.y);
- Object.assign(marquee.style, {
- left: `${left}px`,
- top: `${top}px`,
- width: `${Math.abs(point.x - start.x)}px`,
- height: `${Math.abs(point.y - start.y)}px`
- });
- };
-
- const up = (upEvent) => {
- const point = this.canvasPoint(upEvent);
- cleanup();
- const bounds = {
- left: Math.min(start.x, point.x),
- top: Math.min(start.y, point.y),
- right: Math.max(start.x, point.x),
- bottom: Math.max(start.y, point.y)
- };
- if (bounds.right - bounds.left < 4 && bounds.bottom - bounds.top < 4) {
- if (!additive) this.clearSelection();
- return;
- }
- if (!additive) this.clearSelection(false);
- const matches = this.items.filter((item) => {
- if (!this.isEffectiveItemVisible(item)) return false;
- const left = Number(item.node.attr("x"));
- const top = Number(item.node.attr("y"));
- const right = left + Number(item.node.attr("width"));
- const bottom = top + Number(item.node.attr("height"));
- return right >= bounds.left && left <= bounds.right &&
- bottom >= bounds.top && top <= bounds.bottom;
- });
- const expanded = new Set(matches);
- for (const item of matches) {
- if (!item.groupId) continue;
- for (const member of this.items.filter((candidate) =>
- candidate.groupId === item.groupId && this.isItemVisible(candidate))) expanded.add(member);
- }
- for (const item of expanded) this.selectedItems.add(item);
- this.selectedItem = matches.at(-1) || this.selectedItem;
- this.selectedConnector = null;
- this.refreshSelectionDecoration();
- this.notifySelection();
- debug("marquee selection applied", {
- selectedIds: this.selectedAll().map((item) => item.id)
- });
- };
-
- window.addEventListener("mousemove", move);
- window.addEventListener("mouseup", up);
- this.activeMarqueeCleanup = cleanup;
- };
- this.canvas.addEventListener("mousedown", this.marqueeMouseDownHandler);
- }
-
destroy() {
this.destroyed = true;
- if (this.historyTimer !== null) {
- window.clearTimeout(this.historyTimer);
- this.historyTimer = null;
- }
- if (this.marqueeMouseDownHandler) {
- this.canvas.removeEventListener("mousedown", this.marqueeMouseDownHandler);
- this.marqueeMouseDownHandler = null;
- }
+ this.history.destroy();
if (this.boundaryScrollHandler) {
this.canvas.removeEventListener("scroll", this.boundaryScrollHandler);
this.boundaryScrollHandler = null;
}
- if (this.activeMarqueeCleanup) this.activeMarqueeCleanup();
+ this.interaction.destroy();
for (const item of this.items) {
- if (item.submapFrameElement) {
- item.submapFrameElement.remove();
- item.submapFrameElement = null;
- }
if (item.submapAnchorLineElement) {
item.submapAnchorLineElement.remove();
item.submapAnchorLineElement = null;
@@ -2884,7 +1523,6 @@ import { CmapView } from "./cmap-view.js";
}
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);
@@ -2908,330 +1546,6 @@ import { CmapView } from "./cmap-view.js";
return true;
}
- /**
- * goal : Follow the component selected by cmap's coordinate hit test.
- * pre : component is a public node/link wrapper or null.
- * post : The corresponding wiki item or connector is selected; empty
- * canvas space clears the selection.
- */
- handleMapSelection(component, event) {
- if (event.target instanceof Element &&
- event.target.closest(".rw-cmap-handle, .rw-cmap-phrase-input")) {
- debug("cmap selection belongs to an editor control", elementDescription(event.target));
- return;
- }
-
- const item = this.items.find((candidate) => candidate.node === component) || null;
- const connector = this.connectors.find((candidate) => candidate.link === component) || null;
- debug("selection callback received from cmap hit test", {
- componentFound: Boolean(component),
- itemId: item ? item.id : null,
- connectorId: connector ? connector.id : null,
- target: elementDescription(event.target)
- });
-
- if (item) {
- const additive = Boolean(event && (event.ctrlKey || event.metaKey || event.shiftKey));
- if (!additive && this.selectedItems.size > 1 && this.selectedItems.has(item)) {
- this.selectedItem = item;
- this.refreshSelectionDecoration();
- this.notifySelection();
- return;
- }
- this.selectItem(item, { additive, toggle: additive });
- return;
- }
- if (connector) {
- this.selectConnector(connector);
- return;
- }
- if (!(event && (event.ctrlKey || event.metaKey || event.shiftKey))) this.clearSelection();
- }
-
- /**
- * goal : Activate an item after cmap recognizes two stationary clicks.
- * pre : component is the public wrapper returned by cmap's hit test.
- * post : Page concepts navigate, submaps open and phrases enter editing.
- */
- handleMapActivation(component, event) {
- const item = this.items.find((candidate) => candidate.node === component) || null;
- debug("activation callback received from cmap", {
- itemId: item ? item.id : null,
- kind: item ? item.kind : null,
- pageSlug: item ? item.pageSlug : null
- });
- if (!item) return;
- if (event && event.preventDefault) event.preventDefault();
- if (item.kind === "phrase") {
- this.editPhraseInline(item);
- return;
- }
- if (item.kind === "submap") {
- this.toggleSubmap(item);
- return;
- }
- if (this.onEditItem) {
- this.selectItem(item);
- this.onEditItem(item);
- return;
- }
- if (item.parentCmapLink) {
- this.openParentMap();
- return;
- }
- if (item.cmapSlug && this.onOpenCmap) {
- this.onOpenCmap(item);
- return;
- }
- if (item.pageSlug && this.onOpenPage) {
- this.onOpenPage(item);
- return;
- }
- }
-
- ensureSubmapToggle(record, element) {
- let toggle = element.querySelector(":scope > .rw-cmap-submap-toggle");
- let open = element.querySelector(":scope > .rw-cmap-submap-open");
- if (record.kind !== "submap") {
- if (toggle) toggle.remove();
- if (open) open.remove();
- return;
- }
- if (record === this.activeMapRoot) {
- if (toggle) toggle.remove();
- if (open) open.remove();
- return;
- }
- const legacySeparateMap = record.separateMap && !record.cmapSlug;
- if (record.expanded && !legacySeparateMap) {
- if (toggle) toggle.remove();
- toggle = null;
- }
- if (!toggle) {
- if (!record.expanded || legacySeparateMap) {
- toggle = document.createElement("button");
- toggle.type = "button";
- toggle.className = "rw-cmap-submap-toggle";
- toggle.addEventListener("pointerdown", (event) => {
- event.preventDefault();
- event.stopPropagation();
- });
- toggle.addEventListener("click", (event) => {
- event.preventDefault();
- event.stopPropagation();
- this.selectItem(record);
- this.toggleSubmap(record);
- });
- element.append(toggle);
- }
- }
- if (toggle) {
- toggle.textContent = legacySeparateMap ? "↗" : "+";
- toggle.title = legacySeparateMap ? "Open concept map" : "Expand submap";
- toggle.setAttribute("aria-label", toggle.title);
- toggle.setAttribute("aria-expanded", String(record.expanded));
- }
- if (record.cmapSlug && this.onOpenStoredSubMap) {
- if (!open) {
- open = document.createElement("button");
- open.type = "button";
- open.className = "rw-cmap-submap-open";
- open.textContent = "↗";
- open.title = "Open as separate concept map";
- open.setAttribute("aria-label", open.title);
- open.addEventListener("pointerdown", (event) => {
- event.preventDefault();
- event.stopPropagation();
- });
- open.addEventListener("click", (event) => {
- event.preventDefault();
- event.stopPropagation();
- this.selectItem(record);
- this.onOpenStoredSubMap(record);
- });
- element.append(open);
- }
- } else if (open) {
- open.remove();
- }
- }
-
- ensureHandles(record, element) {
- if (!element.querySelector(":scope > .rw-cmap-relation-handle")) {
- const relation = document.createElement("button");
- relation.type = "button";
- relation.className = "rw-cmap-handle rw-cmap-relation-handle";
- relation.title = this.labels.createRelation;
- relation.setAttribute("aria-label", this.labels.createRelation);
- relation.setAttribute("aria-hidden", "false");
- relation.addEventListener("pointerdown", (event) => this.startRelationDrag(event, record));
- element.append(relation);
- }
-
- if (record.kind !== "phrase" &&
- !element.querySelector(":scope > .rw-cmap-edit-handle")) {
- const edit = document.createElement("button");
- edit.type = "button";
- edit.className = "rw-cmap-handle rw-cmap-edit-handle";
- edit.title = this.labels.editConcept;
- edit.setAttribute("aria-label", this.labels.editConcept);
- edit.setAttribute("aria-hidden", "false");
- edit.addEventListener("pointerdown", (event) => {
- event.preventDefault();
- event.stopPropagation();
- });
- edit.addEventListener("mousedown", (event) => event.stopPropagation());
- edit.addEventListener("click", (event) => {
- event.preventDefault();
- event.stopPropagation();
- this.selectItem(record);
- if (this.onEditItem) this.onEditItem(record);
- });
- element.append(edit);
- }
-
- if (record.kind !== "phrase" &&
- !element.querySelector(":scope > .rw-cmap-resize-handle")) {
- const resize = document.createElement("button");
- resize.type = "button";
- resize.className = "rw-cmap-handle rw-cmap-resize-handle";
- resize.title = this.labels.resizeConcept;
- resize.setAttribute("aria-label", this.labels.resizeConcept);
- resize.setAttribute("aria-hidden", "false");
- resize.addEventListener("pointerdown", (event) => this.startResize(event, record));
- element.append(resize);
- }
- }
-
- removeHandles(element) {
- for (const handle of element.querySelectorAll(":scope > .rw-cmap-handle")) handle.remove();
- }
-
- startResize(event, record) {
- event.preventDefault();
- event.stopPropagation();
- record.autoWidth = false;
- record.autoHeight = false;
- record.fitContentPending = false;
- const startX = event.clientX;
- const startY = event.clientY;
- const startWidth = Number(record.node.attr("width"));
- const startHeight = Number(record.node.attr("height"));
- const pointerId = event.pointerId;
- event.currentTarget.setPointerCapture(pointerId);
-
- const move = (moveEvent) => {
- if (moveEvent.pointerId !== pointerId) return;
- record.width = Math.max(100, startWidth + ((moveEvent.clientX - startX) / this.zoomFactor));
- record.height = Math.max(42, startHeight + ((moveEvent.clientY - startY) / this.zoomFactor));
- record.node.attr({ width: record.width, height: record.height });
- record.node.redraw();
- this.decorateItem(record);
- this.redrawConnectorsFor(record);
- this.ensureCanvasExtent(Number(record.node.attr("x")) + record.width,
- Number(record.node.attr("y")) + record.height);
- };
-
- const up = (upEvent) => {
- if (upEvent.pointerId !== pointerId) return;
- window.removeEventListener("pointermove", move);
- window.removeEventListener("pointerup", up);
- this.decorateItem(record);
- this.scheduleHistoryCommit();
- };
-
- window.addEventListener("pointermove", move);
- window.addEventListener("pointerup", up);
- }
-
- startRelationDrag(event, source) {
- event.preventDefault();
- event.stopPropagation();
- const pointerId = event.pointerId;
- const start = this.itemCenter(source);
- const draft = this.createDraftLine(start);
- this.dragRelation = { source, draft };
- event.currentTarget.setPointerCapture(pointerId);
-
- const move = (moveEvent) => {
- if (moveEvent.pointerId !== pointerId) return;
- const point = this.canvasPoint(moveEvent);
- this.ensureCanvasExtent(point.x, point.y);
- draft.line.setAttribute("x2", String(point.x));
- draft.line.setAttribute("y2", String(point.y));
- draft.svg.setAttribute("width", String(Math.max(this.logicalCanvasWidth(), point.x + 180)));
- draft.svg.setAttribute("height", String(Math.max(this.logicalCanvasHeight(), point.y + 180)));
- };
-
- const up = (upEvent) => {
- if (upEvent.pointerId !== pointerId) return;
- window.removeEventListener("pointermove", move);
- window.removeEventListener("pointerup", up);
- const target = this.itemAt(upEvent.clientX, upEvent.clientY);
- const point = this.canvasPoint(upEvent);
- draft.svg.remove();
- this.dragRelation = null;
- if (!target) {
- this.ensureCanvasExtent(point.x, point.y);
- const parentSubmap = this.submapAtPoint(point);
- debug("relation dropped on empty canvas", {
- sourceId: source.id,
- point,
- parentSubmapId: parentSubmap ? parentSubmap.id : null
- });
- if (this.onCreateConnectedItem) this.onCreateConnectedItem({ source, point, parentSubmap });
- return;
- }
- if (target === source) return;
- this.finishRelation(source, target, event.altKey || upEvent.altKey);
- };
-
- window.addEventListener("pointermove", move);
- window.addEventListener("pointerup", up);
- }
-
- finishRelation(source, target, direct = false) {
- if (source.kind === "phrase" && target.kind !== "phrase") {
- this.addConnector(source, target, true);
- this.reconcilePhraseMembership(source);
- this.refreshSubmapVisibility();
- this.selectItem(source);
- return;
- }
- if (source.kind !== "phrase" && target.kind === "phrase") {
- this.addConnector(source, target, false);
- this.reconcilePhraseMembership(target);
- this.refreshSubmapVisibility();
- this.selectItem(target);
- return;
- }
- if (source.kind === "phrase" && target.kind === "phrase") return;
- if (direct) {
- const connector = this.addConnector(source, target, true);
- this.refreshSubmapVisibility();
- this.selectConnector(connector);
- return;
- }
- this.connectWithPhrase(source, target, "?????", true);
- }
-
- createDraftLine(start) {
- const ns = "http://www.w3.org/2000/svg";
- const svg = document.createElementNS(ns, "svg");
- svg.classList.add("rw-cmap-draft-layer");
- svg.setAttribute("width", String(this.logicalCanvasWidth()));
- svg.setAttribute("height", String(this.logicalCanvasHeight()));
- const line = document.createElementNS(ns, "line");
- line.setAttribute("x1", String(start.x));
- line.setAttribute("y1", String(start.y));
- line.setAttribute("x2", String(start.x));
- line.setAttribute("y2", String(start.y));
- line.setAttribute("class", "rw-cmap-draft-line");
- svg.append(line);
- (this.surfaceElement() || this.canvas).append(svg);
- return { svg, line };
- }
-
logicalCanvasWidth() {
const surface = this.surfaceElement();
return Math.max(this.canvas.clientWidth / this.zoomFactor,
diff --git a/static/cmap/cmap-utils.js b/static/cmap/cmap-utils.js
new file mode 100644
index 0000000..b4d672b
--- /dev/null
+++ b/static/cmap/cmap-utils.js
@@ -0,0 +1,93 @@
+"use strict";
+
+export const debugPrefix = "[racket-wiki:cmap 0.2.122]";
+
+export function debug(message, details) {
+ if (details === undefined) {
+ console.info(debugPrefix, message);
+ return;
+ }
+ console.info(debugPrefix, message, details);
+}
+
+export function elementDescription(element) {
+ if (!(element instanceof Element)) return String(element);
+ return {
+ tag: element.tagName,
+ id: element.id || null,
+ classes: Array.from(element.classList),
+ itemId: element.dataset.rwCmapItemId || null
+ };
+}
+
+export function selectionStyle(element) {
+ if (!(element instanceof Element) || typeof window.getComputedStyle !== "function") return null;
+ const style = window.getComputedStyle(element);
+ return {
+ pointerEvents: style.pointerEvents,
+ outline: style.outline,
+ outlineOffset: style.outlineOffset,
+ boxShadow: style.boxShadow,
+ overflow: style.overflow,
+ zIndex: style.zIndex
+ };
+}
+
+export function escapeHtml(value) {
+ return String(value || "")
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll('"', """)
+ .replaceAll("'", "'");
+}
+
+export function numberOr(value, fallback) {
+ return Number.isFinite(value) ? value : fallback;
+}
+
+export function conceptDescriptionReference(label, id) {
+ const slug = String(label || "")
+ .normalize("NFKD")
+ .toLocaleLowerCase()
+ .replace(/[\u0300-\u036f]/g, "")
+ .replace(/[^\p{L}\p{N}]+/gu, "-")
+ .replace(/^-+|-+$/g, "")
+ .slice(0, 120)
+ .replace(/-+$/g, "");
+ return `cmap:${slug || `concept-${id}`}`;
+}
+
+export function newConceptId() {
+ if (window.crypto && typeof window.crypto.randomUUID === "function") {
+ try {
+ return window.crypto.randomUUID().toLowerCase();
+ } catch (_error) {
+ // randomUUID can be exposed but forbidden in an insecure/file context.
+ }
+ }
+ const bytes = new Uint8Array(16);
+ if (window.crypto && typeof window.crypto.getRandomValues === "function") {
+ window.crypto.getRandomValues(bytes);
+ } else {
+ for (let index = 0; index < bytes.length; index += 1) {
+ bytes[index] = Math.floor(Math.random() * 256);
+ }
+ }
+ bytes[6] = (bytes[6] & 0x0f) | 0x40;
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
+ const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0"));
+ return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex.slice(6, 8).join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10).join("")}`;
+}
+
+export function normalizeConceptTags(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;
+ return {
+ type: String(tag.type || "label").trim() || "label",
+ value: String(tag.value || tag.name || "").trim()
+ };
+ }).filter((tag) => tag && tag.value);
+}
diff --git a/static/cmap/cmap-view.js b/static/cmap/cmap-view.js
index 97d4459..d5cc8c8 100644
--- a/static/cmap/cmap-view.js
+++ b/static/cmap/cmap-view.js
@@ -1,3 +1,5 @@
+import { DiagramEngine } from "./cmap.js";
+
/**
* Present a CMap through the bundled drawing engine.
*
@@ -6,11 +8,13 @@
* rules; CmapEditor remains responsible for interpreting user interaction.
*/
export class CmapView {
- constructor(canvas, CmapFactory, onSelection, onActivation) {
+ constructor(canvas, onSelection, onActivation, createEngine = null) {
if (!(canvas instanceof Object)) throw new TypeError("A CMap canvas is required");
- if (typeof CmapFactory !== "function") throw new TypeError("A CMap factory is required");
+ if (createEngine !== null && typeof createEngine !== "function") {
+ throw new TypeError("A diagram-engine factory must be a function");
+ }
this.canvas = canvas;
- this.map = CmapFactory(canvas);
+ this.map = createEngine ? createEngine(canvas) : new DiagramEngine(canvas);
this.map.onSelection(onSelection);
this.map.onActivation(onActivation);
}
diff --git a/static/cmap/cmap.css b/static/cmap/cmap.css
index 4ce8538..436aa15 100644
--- a/static/cmap/cmap.css
+++ b/static/cmap/cmap.css
@@ -362,11 +362,12 @@ body.cmap-mode #main {
--cmap-a4-width: 1123px;
--cmap-a4-height: 794px;
position: relative;
- flex: 1 0 auto;
+ z-index: 1;
+ flex: 1 1 0;
width: max-content;
min-width: max(760px, calc(100% - 90px));
min-height: 794px;
- overflow: visible;
+ overflow: auto;
border: 0;
background-color: #fff;
background-image:
@@ -376,6 +377,12 @@ body.cmap-mode #main {
box-shadow: inset 1px 1px #d9dde2;
}
+/* Keep the editor viewport bounded so vertical canvas scrolling is available. */
+.cmap-workspace > .cmap-canvas {
+ height: calc(100vh - 180px);
+ min-height: 0;
+}
+
.cmap-canvas.cmap-page-guides-hidden {
background-image: none;
box-shadow: none;
@@ -388,6 +395,15 @@ body.cmap-mode #main {
transform-origin: 0 0;
}
+.cmap-canvas:has(.rw-cmap-surface) {
+ cursor: grab;
+}
+
+.cmap-canvas.rw-cmap-canvas-panning {
+ cursor: grabbing;
+ user-select: none;
+}
+
.rw-cmap-embed {
display: block;
margin: 1.25rem 0;
@@ -773,7 +789,7 @@ body.cmap-mode #main {
);
box-shadow: 0 1px 3px rgb(35 55 30 / 18%);
cursor: move;
- pointer-events: auto;
+ pointer-events: none;
user-select: none;
}
@@ -822,6 +838,33 @@ body.cmap-mode #main {
pointer-events: auto;
}
+.rw-cmap-submap-frame-drag-edge {
+ position: absolute;
+ z-index: 2;
+ cursor: move;
+ pointer-events: auto;
+}
+
+.rw-cmap-submap-frame-drag-edge-top,
+.rw-cmap-submap-frame-drag-edge-bottom {
+ right: 0;
+ left: 0;
+ height: 10px;
+}
+
+.rw-cmap-submap-frame-drag-edge-top { top: 0; }
+.rw-cmap-submap-frame-drag-edge-bottom { bottom: 0; }
+
+.rw-cmap-submap-frame-drag-edge-right,
+.rw-cmap-submap-frame-drag-edge-left {
+ top: 0;
+ bottom: 0;
+ width: 10px;
+}
+
+.rw-cmap-submap-frame-drag-edge-right { right: 0; }
+.rw-cmap-submap-frame-drag-edge-left { left: 0; }
+
.rw-cmap-boundary-layer,
.rw-cmap-boundary-lines {
position: absolute;
diff --git a/static/cmap/cmap.js b/static/cmap/cmap.js
index 2cfde8c..c1e25e9 100644
--- a/static/cmap/cmap.js
+++ b/static/cmap/cmap.js
@@ -1,2305 +1,9 @@
/**
- * racket-wiki cmap component 0.2.122
- * Based on cmap v0.1.3.
- * (c) 2015 iOnStage
- * Released under the MIT License.
+ * Public entry point for the racket-wiki diagram engine.
+ *
+ * CMap application code imports DiagramEngine explicitly. The Cmap alias is
+ * retained as an exported name for code that describes the visual surface as
+ * a CMap, without installing another global on window.
*/
-
-(function() {
- 'use strict';
-
- var helper = {};
-
- helper.toNumber = function(value, defaultValue) {
- return !isNaN(value) ? +value : defaultValue;
- };
-
- helper.toString = function(value, defaultValue) {
- return (typeof value !== 'undefined') ? '' + value : defaultValue;
- };
-
- helper.toBoolean = function(value, defaultValue) {
- return (typeof value !== 'undefined') ? !!value : defaultValue;
- };
-
- helper.toContentType = function(value, defaultValue) {
- if (value === helper.CONTENT_TYPE_TEXT || value === helper.CONTENT_TYPE_HTML)
- return value;
-
- return defaultValue;
- };
-
- helper.isPlainObject = function(obj) {
- return (typeof obj === 'object' && obj !== null &&
- Object.prototype.toString.call(obj) === '[object Object]');
- };
-
- helper.inherits = function(ctor, superCtor) {
- ctor.super_ = superCtor;
- ctor.prototype = Object.create(superCtor.prototype, {
- constructor: {
- value: ctor,
- enumerable: false,
- writable: true,
- configurable: true
- }
- });
-
- return ctor;
- };
-
- helper.wrap = (function() {
- var Wrapper = function(obj, key) {
- this.obj = obj;
- this.key = key;
-
- var wrapper = unwrap.bind(this);
- var proto = Object.getPrototypeOf(obj);
-
- for (var key in proto) {
- wrapper[key] = chain(proto[key], obj);
- }
-
- return wrapper;
- };
-
- var unwrap = function(key) {
- if (this.key === key)
- return this.obj;
- };
-
- var chain = function(func, ctx) {
- return function() {
- var ret = func.apply(ctx, arguments);
-
- if (typeof ret === 'undefined')
- return this;
-
- return ret;
- };
- };
-
- return function(obj, key) {
- return new Wrapper(obj, key);
- };
- })();
-
- helper.deactivate = function(obj) {
- for (var key in obj) {
- delete obj[key];
- }
- };
-
- helper.eachInstance = function(array, ctor, callback) {
- array.filter(function(obj) {
- return obj instanceof ctor;
- }).forEach(callback);
- };
-
- helper.firstInstance = function(array, ctor) {
- return array.filter(function(obj) {
- return obj instanceof ctor;
- })[0];
- };
-
- helper.diffObj = function(newObj, oldObj) {
- var diff = {};
-
- for (var key in newObj) {
- if (!oldObj || newObj[key] !== oldObj[key])
- diff[key] = newObj[key];
- }
-
- return diff;
- };
-
- helper.pick = function(obj, keys) {
- var ret = {};
-
- if (!obj)
- return ret;
-
- keys.forEach(function(key) {
- if (key in obj)
- ret[key] = obj[key];
- });
-
- return ret;
- };
-
- helper.identity = function(value) {
- return value;
- };
-
- helper.List = (function() {
- var List = function() {
- this.data = [];
- };
-
- List.prototype.add = function(item) {
- if (!this.contains(item))
- this.data.push(item);
- };
-
- List.prototype.remove = function(item) {
- var data = this.data;
-
- for (var i = data.length - 1; i >= 0; i--) {
- if (this.equal(data[i], item)) {
- data.splice(i, 1);
- break;
- }
- }
- };
-
- List.prototype.contains = function(item) {
- return this.data.some(function(dataItem) {
- return this.equal(dataItem, item);
- }.bind(this));
- };
-
- List.prototype.equal = function(a, b) {
- return a === b;
- };
-
- List.prototype.toArray = function() {
- return this.data.slice();
- };
-
- return List;
- })();
-
- helper.CONTENT_TYPE_TEXT = 'text';
- helper.CONTENT_TYPE_HTML = 'html';
-
- var dom = {};
-
- dom.disabled = function() {
- return (typeof document === 'undefined');
- };
-
- dom.el = function(selector) {
- if (selector.charAt(0) === '<') {
- selector = selector.match(/<(.+)>/)[1];
- return document.createElement(selector);
- }
- };
-
- dom.body = function() {
- return document.body;
- };
-
- dom.attr = function(el, props) {
- for (var key in props) {
- el.setAttribute(key, props[key]);
- }
- };
-
- dom.css = function(el, props) {
- var style = el.style;
-
- for (var key in props) {
- style[key] = props[key];
- }
- };
-
- dom.rect = function(el) {
- return el.getBoundingClientRect();
- };
-
- dom.clientWidth = function(el) {
- return el.clientWidth;
- };
-
- dom.clientHeight = function(el) {
- return el.clientHeight;
- };
-
- dom.scrollLeft = function(el) {
- return el.scrollLeft;
- };
-
- dom.scrollTop = function(el) {
- return el.scrollTop;
- };
-
- dom.scrollWidth = function(el) {
- return el.scrollWidth;
- };
-
- dom.scrollHeight = function(el) {
- return el.scrollHeight;
- };
-
- dom.text = function(el, s) {
- el.textContent = s;
- };
-
- dom.html = function(el, s) {
- el.innerHTML = s;
- };
-
- dom.append = function(parent, el) {
- parent.appendChild(el);
- };
-
- dom.remove = function(el) {
- el.parentNode.removeChild(el);
- };
-
- dom.child = function(el, index) {
- return el.childNodes[index];
- };
-
- dom.animate = function(callback) {
- return window.requestAnimationFrame(callback);
- };
-
- dom.supportsTouch = function() {
- return ('ontouchstart' in window || (typeof DocumentTouch !== 'undefined' && document instanceof DocumentTouch));
- };
-
- dom.on = function(el, type, listener) {
- el.addEventListener(type, listener);
- };
-
- dom.off = function(el, type, listener) {
- el.removeEventListener(type, listener);
- };
-
- dom.pagePoint = function(event, offset) {
- if (dom.supportsTouch())
- event = event.changedTouches[0];
-
- return {
- x: event.pageX - (offset ? offset.x : 0),
- y: event.pageY - (offset ? offset.y : 0)
- };
- };
-
- dom.clientPoint = function(event, offset) {
- if (dom.supportsTouch())
- event = event.changedTouches[0];
-
- return {
- x: event.clientX - (offset ? offset.x : 0),
- y: event.clientY - (offset ? offset.y : 0)
- };
- };
-
- dom.cancel = function(event) {
- event.preventDefault();
- };
-
- dom.draggable = (function() {
- if (dom.disabled())
- return function() {};
-
- var supportsTouch = dom.supportsTouch();
- var EVENT_TYPE_START = supportsTouch ? 'touchstart' : 'mousedown';
- var EVENT_TYPE_MOVE = supportsTouch ? 'touchmove' : 'mousemove';
- var EVENT_TYPE_END = supportsTouch ? 'touchend' : 'mouseup';
-
- var Draggable = function(props) {
- this.el = props.el;
- this.onstart = props.onstart;
- this.onmove = props.onmove;
- this.onend = props.onend;
- this.start = start.bind(this);
- this.move = move.bind(this);
- this.end = end.bind(this);
- this.lock = false;
- this.startingPoint = null;
-
- dom.on(this.el, EVENT_TYPE_START, this.start);
- };
-
- var start = function(event) {
- if (this.lock)
- return;
-
- this.lock = true;
- this.startingPoint = dom.pagePoint(event);
-
- var el = this.el;
- var onstart = this.onstart;
-
- var rect = dom.rect(el);
- var p = dom.clientPoint(event, {
- x: rect.left - dom.scrollLeft(el),
- y: rect.top - dom.scrollTop(el)
- });
-
- if (typeof onstart === 'function')
- onstart(p.x, p.y, event);
-
- dom.on(document, EVENT_TYPE_MOVE, this.move);
- dom.on(document, EVENT_TYPE_END, this.end);
- };
-
- var move = function(event) {
- var onmove = this.onmove;
- var d = dom.pagePoint(event, this.startingPoint);
-
- if (typeof onmove === 'function')
- onmove(d.x, d.y, event);
- };
-
- var end = function(event) {
- dom.off(document, EVENT_TYPE_MOVE, this.move);
- dom.off(document, EVENT_TYPE_END, this.end);
-
- var onend = this.onend;
- var d = dom.pagePoint(event, this.startingPoint);
-
- if (typeof onend === 'function')
- onend(d.x, d.y, event);
-
- this.lock = false;
- };
-
- return function(el, onstart, onmove, onend) {
- new Draggable({
- el: el,
- onstart: onstart,
- onmove: onmove,
- onend: onend
- });
- };
- })();
-
- var Component = function() {};
-
- Component.prototype.disposed = false;
-
- Component.prototype.dispose = function() {
- this.disposed = true;
- };
-
- Component.prototype.prop = function(initialValue, defaultValue, converter) {
- if (typeof converter !== 'function')
- converter = helper.identity;
-
- var cache = converter(initialValue, defaultValue);
-
- return function(value) {
- if (typeof value === 'undefined')
- return cache;
-
- if (value === cache)
- return;
-
- cache = converter(value, cache);
-
- this.markDirty();
- };
- };
-
- Component.prototype.relations = function() {
- return [];
- };
-
- Component.prototype.redraw = function() {};
-
- Component.prototype.notifyRendered = function() {
- if (typeof this.renderedHandler === 'function' && this.element())
- this.renderedHandler(this.element());
- };
-
- Component.prototype.markDirty = (function() {
- var dirtyComponents = [];
- var requestId = null;
-
- var updateRelations = function(index) {
- for (var i = index, len = dirtyComponents.length; i < len; i++) {
- var component = dirtyComponents[i];
- if (component.disposed)
- continue;
- component.relations().forEach(function(relation) {
- if (!relation.disposed)
- relation.update(component);
- });
- }
-
- // may be inserted other dirty components by updating relations
- if (dirtyComponents.length > len)
- updateRelations(len);
- };
-
- var callback = function() {
- updateRelations(0);
-
- dirtyComponents.forEach(function(component) {
- if (!component.disposed)
- component.redraw();
- });
-
- dirtyComponents = [];
- requestId = null;
- };
-
- return function() {
- if (dom.disabled() || this.disposed)
- return;
-
- if (dirtyComponents.indexOf(this) === -1)
- dirtyComponents.push(this);
-
- if (requestId !== null)
- return;
-
- requestId = dom.animate(callback);
- };
- })();
-
- var Node = helper.inherits(function(props) {
- this.visible = true;
- this.content = this.prop(props.content, '', helper.toString);
- this.contentType = this.prop(props.contentType, helper.CONTENT_TYPE_TEXT, helper.toContentType);
- this.x = this.prop(props.x, 0, helper.toNumber);
- this.y = this.prop(props.y, 0, helper.toNumber);
- this.width = this.prop(props.width, 75, helper.toNumber);
- this.height = this.prop(props.height, 30, helper.toNumber);
- this.backgroundColor = this.prop(props.backgroundColor, '#a7cbe6', helper.toString);
- this.borderColor = this.prop(props.borderColor, '#333', helper.toString);
- this.borderWidth = this.prop(props.borderWidth, 2, helper.toNumber);
- this.textColor = this.prop(props.textColor, '#333', helper.toString);
- this.zIndex = this.prop('auto');
- this.element = this.prop(null);
- this.parentElement = this.prop(null);
- this.cache = this.prop({});
- this.relations = this.prop([]);
- this.moveHandler = null;
- }, Component);
-
- Node.prototype.cx = function() {
- return this.x() + this.width() / 2;
- };
-
- Node.prototype.cy = function() {
- return this.y() + this.height() / 2;
- };
-
- Node.prototype.borderRadius = function() {
- return 4;
- };
-
- Node.prototype.contains = function(x, y, tolerance) {
- var nx = this.x();
- var ny = this.y();
- var nwidth = this.width();
- var nheight = this.height();
-
- return (nx - tolerance <= x && x <= nx + nwidth + tolerance &&
- ny - tolerance <= y && y <= ny + nheight + tolerance);
- };
-
- Node.prototype.style = function() {
- var contentType = this.contentType();
- var lineHeight = (contentType === helper.CONTENT_TYPE_TEXT) ? this.height() : 14;
- var textAlign = (contentType === helper.CONTENT_TYPE_TEXT) ? 'center' : 'left';
- var translate = 'translate(' + this.x() + 'px, ' + this.y() + 'px)';
- var borderWidthOffset = this.borderWidth() * 2;
-
- return {
- backgroundColor: this.backgroundColor(),
- border: this.borderWidth() + 'px solid ' + this.borderColor(),
- borderRadius: this.borderRadius() + 'px',
- color: this.textColor(),
- display: this.visible ? '' : 'none',
- height: (this.height() - borderWidthOffset) + 'px',
- lineHeight: (lineHeight - borderWidthOffset) + 'px',
- msTransform: translate,
- overflow: 'hidden',
- pointerEvents: 'auto',
- position: 'absolute',
- textAlign: textAlign,
- textOverflow: 'ellipsis',
- transform: translate,
- webkitTransform: translate,
- whiteSpace: 'nowrap',
- width: (this.width() - borderWidthOffset) + 'px',
- zIndex: this.zIndex()
- };
- };
-
- Node.prototype.redraw = function() {
- var element = this.element();
- var parentElement = this.parentElement();
-
- if (!parentElement && !element)
- return;
-
- // add element
- if (parentElement && !element) {
- element = dom.el('
');
- this.element(element);
- dom.append(parentElement, element);
- this.redraw();
-
- return;
- }
-
- // remove element
- if (!parentElement && element) {
- dom.remove(element);
- this.element(null);
- this.cache({});
-
- return;
- }
-
- var cache = this.cache();
-
- // update element
- var content = this.content();
-
- if (content !== cache.content) {
- var contentType = this.contentType();
-
- if (contentType === helper.CONTENT_TYPE_TEXT)
- dom.text(element, content);
- else if (contentType === helper.CONTENT_TYPE_HTML)
- dom.html(element, content);
-
- cache.content = content;
- }
-
- var style = this.style();
-
- dom.css(element, helper.diffObj(style, cache.style));
- cache.style = style;
- this.notifyRendered();
- };
-
- var Link = helper.inherits(function(props) {
- this.visible = true;
- this.content = this.prop(props.content, '', helper.toString);
- this.contentType = this.prop(props.contentType, helper.CONTENT_TYPE_TEXT, helper.toContentType);
- this.cx = this.prop(props.cx, 100, helper.toNumber);
- this.cy = this.prop(props.cy, 40, helper.toNumber);
- this.width = this.prop(props.width, 50, helper.toNumber);
- this.height = this.prop(props.height, 20, helper.toNumber);
- this.backgroundColor = this.prop(props.backgroundColor, 'white', helper.toString);
- this.borderColor = this.prop(props.borderColor, '#333', helper.toString);
- this.borderWidth = this.prop(props.borderWidth, 2, helper.toNumber);
- this.textColor = this.prop(props.textColor, '#333', helper.toString);
- this.sourceX = this.prop(props.sourceX, this.cx() - 70, helper.toNumber);
- this.sourceY = this.prop(props.sourceY, this.cy(), helper.toNumber);
- this.targetX = this.prop(props.targetX, this.cx() + 70, helper.toNumber);
- this.targetY = this.prop(props.targetY, this.cy(), helper.toNumber);
- this.lineColor = this.prop(props.lineColor, '#333', helper.toString);
- this.lineWidth = this.prop(props.lineWidth, 2, helper.toNumber);
- this.hasArrow = this.prop(props.hasArrow, true, helper.toBoolean);
- this.zIndex = this.prop('auto');
- this.element = this.prop(null);
- this.parentElement = this.prop(null);
- this.cache = this.prop({});
- this.relations = this.prop([]);
- this.connectionChangeHandler = null;
- }, Component);
-
- Link.prototype.straighten = function(sx, sy, tx, ty) {
- if (arguments.length === 0) {
- this.cx((this.sourceX() + this.targetX()) / 2);
- this.cy((this.sourceY() + this.targetY()) / 2);
-
- return;
- }
-
- this.cx((sx + tx) / 2);
- this.cy((sy + ty) / 2);
- this.sourceX(sx);
- this.sourceY(sy);
- this.targetX(tx);
- this.targetY(ty);
- };
-
- Link.prototype.contains = function(x, y, tolerance) {
- var content = this.content();
- var lcx = this.cx();
- var lcy = this.cy();
-
- // content area
- if (content) {
- var lwidth = this.width();
- var lheight = this.height();
-
- var lx = lcx - lwidth / 2;
- var ly = lcy - lheight / 2;
-
- if (lx - tolerance <= x && x <= lx + lwidth + tolerance &&
- ly - tolerance <= y && y <= ly + lheight + tolerance) {
- return true;
- }
- }
-
- var lineWidth = this.lineWidth();
-
- // source path
- if (this.containsPath(this.sourceX(), this.sourceY(), lcx, lcy, x, y, lineWidth / 2 + tolerance))
- return true;
-
- // target path
- if (this.containsPath(this.targetX(), this.targetY(), lcx, lcy, x, y, lineWidth / 2 + tolerance))
- return true;
-
- return false;
- };
-
- Link.prototype.containsPath = function(x0, y0, x1, y1, x, y, d) {
- var ax = x1 - x0;
- var ay = y1 - y0;
-
- var bx = x - x0;
- var by = y - y0;
-
- var r = (ax * bx + ay * by) / (ax * ax + ay * ay);
-
- if (0 <= r && r <= 1) {
- var px = x0 + r * ax;
- var py = y0 + r * ay;
-
- var dx = px - x;
- var dy = py - y;
-
- if (dx * dx + dy * dy <= d * d)
- return true;
- }
-
- return false;
- };
-
- Link.prototype.style = function() {
- return {
- display: this.visible ? '' : 'none',
- pointerEvents: 'none',
- position: 'absolute',
- zIndex: this.zIndex()
- };
- };
-
- Link.prototype.pathContainerStyle = function() {
- var width = Math.max(this.cx(), this.sourceX(), this.targetX());
- var height = Math.max(this.cy(), this.sourceY(), this.targetY());
-
- return {
- height: height + 'px',
- overflow: 'visible',
- position: 'absolute',
- width: width + 'px'
- };
- };
-
- Link.prototype.lineAttributes = function() {
- var d = [
- 'M', this.sourceX(), this.sourceY(),
- 'L', this.cx(), this.cy(),
- 'L', this.targetX(), this.targetY()
- ].join(' ');
-
- return {
- d: d,
- fill: 'none',
- stroke: this.lineColor(),
- 'stroke-linecap': 'round',
- 'stroke-width': this.lineWidth()
- };
- };
-
- Link.prototype.arrowAttributes = function() {
- var cx = this.cx();
- var cy = this.cy();
- var tx = this.targetX();
- var ty = this.targetY();
-
- var radians = Math.atan2(ty - cy, tx - cx);
-
- var p0 = {
- x: 15 * Math.cos(radians - 26 * Math.PI / 180),
- y: 15 * Math.sin(radians - 26 * Math.PI / 180)
- };
-
- var p1 = {
- x: 15 * Math.cos(radians + 26 * Math.PI / 180),
- y: 15 * Math.sin(radians + 26 * Math.PI / 180)
- };
-
- var p2 = {
- x: 7 * Math.cos(radians),
- y: 7 * Math.sin(radians)
- };
-
- var d = [
- 'M', tx - p0.x, ty - p0.y,
- 'L', tx, ty,
- 'L', tx - p1.x, ty - p1.y,
- 'Q', tx - p2.x, ty - p2.y, tx - p0.x, ty - p0.y,
- 'Z'
- ].join(' ');
-
- return {
- d: d,
- fill: this.lineColor(),
- stroke: this.lineColor(),
- 'stroke-linejoin': 'round',
- 'stroke-width': this.lineWidth(),
- visibility: this.hasArrow() ? 'visible' : 'hidden'
- };
- };
-
- Link.prototype.contentStyle = function() {
- var contentType = this.contentType();
- var lineHeight = (contentType === helper.CONTENT_TYPE_TEXT) ? this.height() : 14;
- var textAlign = (contentType === helper.CONTENT_TYPE_TEXT) ? 'center' : 'left';
- var x = this.cx() - this.width() / 2;
- var y = this.cy() - this.height() / 2;
- var translate = 'translate(' + x + 'px, ' + y + 'px)';
- var borderWidthOffset = this.borderWidth() * 2;
-
- return {
- backgroundColor: this.backgroundColor(),
- border: this.borderWidth() + 'px solid ' + this.borderColor(),
- borderRadius: '4px',
- color: this.textColor(),
- height: (this.height() - borderWidthOffset) + 'px',
- lineHeight: (lineHeight - borderWidthOffset) + 'px',
- msTransform: translate,
- overflow: 'hidden',
- position: 'absolute',
- textAlign: textAlign,
- textOverflow: 'ellipsis',
- transform: translate,
- visibility: this.content() ? 'visible' : 'hidden',
- webkitTransform: translate,
- whiteSpace: 'nowrap',
- width: (this.width() - borderWidthOffset) + 'px'
- };
- };
-
- Link.prototype.redraw = function() {
- var element = this.element();
- var parentElement = this.parentElement();
-
- if (!parentElement && !element)
- return;
-
- // add element
- if (parentElement && !element) {
- element = dom.el('
');
- dom.html(element, '
');
- this.element(element);
- dom.append(parentElement, element);
- this.redraw();
-
- return;
- }
-
- // remove element
- if (!parentElement && element) {
- dom.remove(element);
- this.element(null);
- this.cache({});
-
- return;
- }
-
- var cache = this.cache();
-
- // update path container element
- var pathContainerStyle = this.pathContainerStyle();
- var pathContainerElement = dom.child(element, 0);
-
- dom.css(pathContainerElement, helper.diffObj(pathContainerStyle, cache.pathContainerElementStyle));
- cache.pathContainerElementStyle = contentStyle;
-
- // update line element
- var lineAttributes = this.lineAttributes();
- var lineElement = dom.child(pathContainerElement, 0);
-
- dom.attr(lineElement, helper.diffObj(lineAttributes, cache.lineAttributes));
- cache.lineAttributes = lineAttributes;
-
- // update arrow element
- var arrowAttributes = this.arrowAttributes();
- var arrowElement = dom.child(pathContainerElement, 1);
-
- dom.attr(arrowElement, helper.diffObj(arrowAttributes, cache.arrowAttributes));
- cache.arrowAttributes = arrowAttributes;
-
- // update content element
- var content = this.content();
- var contentStyle = this.contentStyle();
- var contentElement = dom.child(element, 1);
-
- if (content !== cache.content) {
- var contentType = this.contentType();
-
- if (contentType === helper.CONTENT_TYPE_TEXT)
- dom.text(contentElement, content);
- else if (contentType === helper.CONTENT_TYPE_HTML)
- dom.html(contentElement, content);
-
- cache.content = content;
- }
-
- dom.css(contentElement, helper.diffObj(contentStyle, cache.contentStyle));
- cache.contentStyle = contentStyle;
-
- // update container element
- var style = this.style();
-
- dom.css(element, helper.diffObj(style, cache.style));
- cache.style = style;
- this.notifyRendered();
- };
-
- var Connector = helper.inherits(function(props) {
- this.x = this.prop(props.x, 0, helper.toNumber);
- this.y = this.prop(props.y, 0, helper.toNumber);
- this.color = this.prop(Connector.COLOR_UNCONNECTED);
- this.zIndex = this.prop('auto');
- this.element = this.prop(null);
- this.parentElement = this.prop(null);
- this.cache = this.prop({});
- this.relations = this.prop([]);
- }, Component);
-
- Connector.prototype.r = function() {
- return 16;
- };
-
- Connector.prototype.contains = function(x, y, tolerance) {
- var dx = x - this.x();
- var dy = y - this.y();
- var r = this.r() + tolerance;
-
- return (dx * dx + dy * dy <= r * r);
- };
-
- Connector.prototype.style = function() {
- var r = this.r();
- var x = this.x() - r;
- var y = this.y() - r;
- var translate = 'translate(' + x + 'px, ' + y + 'px)';
-
- return {
- backgroundColor: this.color(),
- border: '2px solid lightgray',
- borderRadius: '50%',
- boxSizing: 'border-box',
- height: r * 2 + 'px',
- msTransform: translate,
- opacity: 0.6,
- pointerEvents: 'none',
- position: 'absolute',
- transform: translate,
- webkitTransform: translate,
- width: r * 2 + 'px',
- zIndex: this.zIndex()
- };
- };
-
- Connector.prototype.redraw = function() {
- var element = this.element();
- var parentElement = this.parentElement();
-
- if (!parentElement && !element)
- return;
-
- // add element
- if (parentElement && !element) {
- element = dom.el('
');
- this.element(element);
- dom.append(parentElement, element);
- this.redraw();
-
- return;
- }
-
- // remove element
- if (!parentElement && element) {
- dom.remove(element);
- this.element(null);
-
- return;
- }
-
- var cache = this.cache();
-
- // update element
- var style = this.style();
-
- dom.css(element, helper.diffObj(style, cache.style));
- cache.style = style;
- this.notifyRendered();
- };
-
- Connector.COLOR_CONNECTED = 'lightgreen';
- Connector.COLOR_UNCONNECTED = 'pink';
-
- var Relation = function() {};
-
- Relation.prototype.prop = function(initialValue) {
- var cache = initialValue;
-
- return function(value) {
- if (typeof value === 'undefined')
- return cache;
-
- cache = value;
- };
- };
-
- Relation.prototype.update = function() {};
-
- var Triple = helper.inherits(function(props) {
- this.link = this.prop(props.link);
- this.sourceNode = this.prop(props.sourceNode || null);
- this.targetNode = this.prop(props.targetNode || null);
- this.skipNextUpdate = this.prop(false);
- this.nodePositionsCache = this.prop({});
- }, Relation);
-
- Triple.prototype.update = function(changedComponent) {
- if (this.skipNextUpdate()) {
- this.skipNextUpdate(false);
- return;
- }
-
- var link = this.link();
- var sourceNode = this.sourceNode();
- var targetNode = this.targetNode();
-
- if (changedComponent instanceof Node)
- this.updateNode(link, sourceNode, targetNode, changedComponent);
- else if (changedComponent instanceof Link)
- this.updateLink(link, sourceNode, targetNode);
- };
-
- Triple.prototype.updateNode = function(link, sourceNode, targetNode, changedNode) {
- if (sourceNode && targetNode)
- this.rotateLink(link, sourceNode, targetNode, changedNode);
- else
- this.shiftLink(link, sourceNode, targetNode, changedNode);
-
- this.updateNodePositionsCache();
- };
-
- Triple.prototype.rotateLink = function(link, sourceNode, targetNode, changedNode) {
- var cache = this.nodePositionsCache();
-
- var sncx = cache.sncx;
- var sncy = cache.sncy;
- var tncx = cache.tncx;
- var tncy = cache.tncy;
-
- var lcx = link.cx();
- var lcy = link.cy();
-
- var ts_dx = tncx - sncx;
- var ts_dy = tncy - sncy;
- var cs_dx = lcx - sncx;
- var cs_dy = lcy - sncy;
-
- var ts_rad0 = Math.atan2(ts_dy, ts_dx);
- var cs_rad0 = Math.atan2(cs_dy, cs_dx);
-
- // changed node position
- if (changedNode === sourceNode) {
- sncx = sourceNode.cx();
- sncy = sourceNode.cy();
- } else if (changedNode === targetNode) {
- tncx = targetNode.cx();
- tncy = targetNode.cy();
- }
-
- // center positions of two nodes are equal
- if (cs_rad0 === 0) {
- link.cx((sncx + tncx) / 2);
- link.cy((sncy + tncy) / 2);
-
- return;
- }
-
- var ts_d0 = Math.sqrt(ts_dx * ts_dx + ts_dy * ts_dy);
- var cs_d0 = Math.sqrt(cs_dx * cs_dx + cs_dy * cs_dy);
-
- var ts_cs_rad = ts_rad0 - cs_rad0;
-
- ts_dx = tncx - sncx;
- ts_dy = tncy - sncy;
-
- var ts_rad1 = Math.atan2(ts_dy, ts_dx);
- var cs_rad1 = ts_rad1 - ts_cs_rad;
-
- var ts_d1 = Math.sqrt(ts_dx * ts_dx + ts_dy * ts_dy);
- var d_rate = (ts_d0 !== 0) ? ts_d1 / ts_d0 : 1;
- var cs_d1 = cs_d0 * d_rate;
-
- lcx = sncx + cs_d1 * Math.cos(cs_rad1);
- lcy = sncy + cs_d1 * Math.sin(cs_rad1);
-
- link.cx(lcx);
- link.cy(lcy);
- };
-
- Triple.prototype.shiftLink = function(link, sourceNode, targetNode, changedNode) {
- var cache = this.nodePositionsCache();
-
- var ncx = changedNode.cx();
- var ncy = changedNode.cy();
-
- if (changedNode === sourceNode) {
- link.targetX(link.targetX() + (ncx - cache.sncx));
- link.targetY(link.targetY() + (ncy - cache.sncy));
- } else if (changedNode === targetNode) {
- link.sourceX(link.sourceX() + (ncx - cache.tncx));
- link.sourceY(link.sourceY() + (ncy - cache.tncy));
- }
- };
-
- Triple.prototype.updateLink = function(link, sourceNode, targetNode) {
- var lx, ly, p;
-
- if (sourceNode) {
- // connect link to source node
- lx = targetNode ? link.cx() : link.targetX();
- ly = targetNode ? link.cy() : link.targetY();
- p = this.connectedPoint(sourceNode, lx, ly);
- link.sourceX(p.x);
- link.sourceY(p.y);
- }
-
- if (targetNode) {
- // connect link to target node
- lx = sourceNode ? link.cx() : link.sourceX();
- ly = sourceNode ? link.cy() : link.sourceY();
- p = this.connectedPoint(targetNode, lx, ly);
- link.targetX(p.x);
- link.targetY(p.y);
- }
-
- if (!sourceNode || !targetNode) {
- // link content moves to midpoint
- link.cx((link.sourceX() + link.targetX()) / 2);
- link.cy((link.sourceY() + link.targetY()) / 2);
- }
- };
-
- Triple.prototype.updateLinkAngle = function(radians) {
- var link = this.link();
- var sourceNode = this.sourceNode();
- var targetNode = this.targetNode();
-
- var ldx = link.targetX() - link.sourceX();
- var ldy = link.targetY() - link.sourceY();
- var d = Math.sqrt(ldx * ldx + ldy * ldy);
-
- var connectedNode = sourceNode || targetNode;
- var cx = connectedNode.cx();
- var cy = connectedNode.cy();
- var lx = cx + d * Math.cos(radians);
- var ly = cy + d * Math.sin(radians);
- var p = this.connectedPoint(connectedNode, lx, ly);
-
- if (connectedNode === sourceNode)
- link.straighten(p.x, p.y, lx + p.x - cx, ly + p.y - cy);
- else if (connectedNode === targetNode)
- link.straighten(lx + p.x - cx, ly + p.y - cy, p.x, p.y);
- };
-
- Triple.prototype.updateNodePositionsCache = function() {
- var sourceNode = this.sourceNode();
- var targetNode = this.targetNode();
- var cache = this.nodePositionsCache();
-
- if (sourceNode) {
- cache.sncx = sourceNode.cx();
- cache.sncy = sourceNode.cy();
- }
-
- if (targetNode) {
- cache.tncx = targetNode.cx();
- cache.tncy = targetNode.cy();
- }
- };
-
- Triple.prototype.connectedPoint = function(node, lx, ly) {
- var nx = node.x();
- var ny = node.y();
- var nwidth = node.width();
- var nheight = node.height();
- var ncx = node.cx();
- var ncy = node.cy();
-
- var alpha = Math.atan2(ly - ncy, lx - ncx);
- var beta = Math.PI / 2 - alpha;
- var t = Math.atan2(nheight, nwidth);
-
- var x, y;
-
- // left edge
- if (alpha < t - Math.PI || alpha > Math.PI - t) {
- x = nx;
- y = ncy - nwidth * Math.tan(alpha) / 2;
- }
- // top edge
- else if (alpha < -t) {
- x = ncx - nheight * Math.tan(beta) / 2;
- y = ny;
- }
- // right edge
- else if (alpha < t) {
- x = nx + nwidth;
- y = ncy + nwidth * Math.tan(alpha) / 2;
- }
- // bottom edge
- else {
- x = ncx + nheight * Math.tan(beta) / 2;
- y = ny + nheight;
- }
-
- var x0, y0, l, ex, ey;
- var r = node.borderRadius();
- var atCorner = false;
-
- // top-left corner
- if (x < nx + r && y < ny + r) {
- x0 = nx + r;
- y0 = ny + r;
- atCorner = true;
- }
- // top-right corner
- else if (x > nx + nwidth - r && y < ny + r) {
- x0 = nx + nwidth - r;
- y0 = ny + r;
- atCorner = true;
- }
- // bottom-left corner
- else if (x < nx + r && y > ny + nheight - r) {
- x0 = nx + r;
- y0 = ny + nheight - r;
- atCorner = true;
- }
- // bottom-right corner
- else if (x > nx + nwidth - r && y > ny + nheight - r) {
- x0 = nx + nwidth - r;
- y0 = ny + nheight - r;
- atCorner = true;
- }
-
- if (atCorner) {
- l = Math.sqrt((x0 - x) * (x0 - x) + (y0 - y) * (y0 - y));
- ex = (x0 - x) / l;
- ey = (y0 - y) / l;
- x = x0 - r * ex;
- y = y0 - r * ey;
- }
-
- return {
- x: x,
- y: y
- };
- };
-
- var LinkConnectorRelation = helper.inherits(function(props) {
- this.type = this.prop(props.type);
- this.link = this.prop(props.link);
- this.connector = this.prop(props.connector);
- }, Relation);
-
- LinkConnectorRelation.prototype.isConnected = function(isConnected) {
- var color = isConnected ? Connector.COLOR_CONNECTED : Connector.COLOR_UNCONNECTED;
- this.connector().color(color);
- };
-
- LinkConnectorRelation.prototype.update = function(changedComponent) {
- var type = this.type();
- var link = this.link();
- var connector = this.connector();
-
- if (changedComponent === link) {
- connector.x(link[type + 'X']());
- connector.y(link[type + 'Y']());
- }
- };
-
- var ComponentList = helper.inherits(function() {
- ComponentList.super_.call(this);
- }, helper.List);
-
- ComponentList.prototype.toFront = function(component) {
- var data = this.data;
- var index = data.indexOf(component);
-
- if (index === -1)
- return;
-
- data.splice(index, 1);
- data.push(component);
- };
-
- ComponentList.prototype.fromPoint = function(ctor, x, y) {
- var data = this.data;
- // The visual stack is connector controls, concepts and finally relations.
- // Use that same priority for coordinate hit testing so a relation that is
- // hidden behind a concept can never steal the concept's click.
- var types = (ctor === Component) ? [Connector, Node, Link] : [ctor];
-
- for (var toleranceIndex = 0; toleranceIndex < 2; toleranceIndex++) {
- var tolerance = toleranceIndex === 0 ? 0 : 8;
- for (var typeIndex = 0; typeIndex < types.length; typeIndex++) {
- for (var i = data.length - 1; i >= 0; i--) {
- var component = data[i];
-
- if (!(component instanceof types[typeIndex]))
- continue;
-
- if (component.visible === false)
- continue;
-
- if (component.contains(x, y, tolerance))
- return component;
- }
- }
- }
-
- return null;
- };
-
- var DisabledConnectorList = helper.inherits(function() {
- DisabledConnectorList.super_.call(this);
- }, helper.List);
-
- DisabledConnectorList.prototype.add = function(type, link) {
- DisabledConnectorList.super_.prototype.add.call(this, {
- type: type,
- link: link
- });
- };
-
- DisabledConnectorList.prototype.remove = function(type, link) {
- DisabledConnectorList.super_.prototype.remove.call(this, {
- type: type,
- link: link
- });
- };
-
- DisabledConnectorList.prototype.contains = function(type, link) {
- return DisabledConnectorList.super_.prototype.contains.call(this, {
- type: type,
- link: link
- });
- };
-
- DisabledConnectorList.prototype.equal = function(a, b) {
- return a.type === b.type && a.link === b.link;
- };
-
- var Cmap = helper.inherits(function(rootElement) {
- this.componentList = this.prop(new ComponentList());
- this.disabledConnectorList = this.prop(new DisabledConnectorList());
- this.dragDisabledComponentList = this.prop(new ComponentList());
- this.element = this.prop(null);
- this.rootElement = this.prop(rootElement || null);
- this.retainerElement = this.prop(null);
- this.dragContext = this.prop({});
- this.selectionHandler = null;
- this.activationHandler = null;
- this.lastClickComponent = null;
- this.lastClickTime = 0;
- this.zoomFactor = 1;
-
- this.markDirty();
- }, Component);
-
- Cmap.LINK_Z_INDEX_BASE = 100;
- Cmap.NODE_Z_INDEX_BASE = 100000;
- Cmap.CONNECTOR_Z_INDEX_BASE = 200000;
-
- Cmap.prototype.add = function(component) {
- component.parentElement(this.element());
- this.componentList().add(component);
- this.updateZIndex();
- };
-
- Cmap.prototype.remove = function(component) {
- component.parentElement(null);
-
- if (component instanceof Link)
- this.hideConnectors(component);
-
- this.disconnect(component);
- this.componentList().remove(component);
- this.updateZIndex();
- };
-
- Cmap.prototype.toFront = function(component) {
- this.componentList().toFront(component);
- this.updateZIndex();
- };
-
- Cmap.prototype.updateZIndex = function() {
- var linkIndex = 0;
- var nodeIndex = 0;
- this.componentList().toArray().forEach(function(component) {
- if (component instanceof Connector)
- return;
-
- // Relations always occupy a lower band than concept nodes. Reordering a
- // selected component therefore only changes its order inside that band.
- var zIndex = component instanceof Link ?
- Cmap.LINK_Z_INDEX_BASE + linkIndex++ :
- Cmap.NODE_Z_INDEX_BASE + nodeIndex++;
- component.zIndex(zIndex);
-
- if (!(component instanceof Link))
- return;
-
- // update connector z-index of link
- helper.eachInstance(component.relations(), LinkConnectorRelation, function(relation, index) {
- relation.connector().zIndex(Cmap.CONNECTOR_Z_INDEX_BASE + index);
- });
- });
- };
-
- Cmap.prototype.connect = function(type, node, link) {
- var linkRelations = link.relations();
- var triple = helper.firstInstance(linkRelations, Triple);
- var nodeKey = type + 'Node';
-
- if (triple && triple[nodeKey]())
- throw new Error('Already connected');
-
- var anotherType = Cmap.anotherConnectionType(type);
- var anotherSideNode = triple ? triple[anotherType + 'Node']() : null;
-
- if (anotherSideNode === node)
- throw new Error('Already connected to the ' + anotherType + ' of the link');
-
- if (triple) {
- triple[nodeKey](node);
- } else {
- var tripleProps = {};
- tripleProps.link = link;
- tripleProps[nodeKey] = node;
- triple = new Triple(tripleProps);
-
- // add triple to the beginning of link relations to be ahead of link-connector relation
- // connector position won't be updated before triple update
- linkRelations.unshift(triple);
- }
-
- // add triple to node
- node.relations().push(triple);
- triple.updateNodePositionsCache();
-
- // update connectors of link
- helper.eachInstance(linkRelations, LinkConnectorRelation, function(relation) {
- if (relation.type() === type)
- relation.isConnected(true);
- });
-
- // link content moves to midpoint of connected nodes
- if (anotherSideNode) {
- link.cx((node.cx() + anotherSideNode.cx()) / 2);
- link.cy((node.cy() + anotherSideNode.cy()) / 2);
- }
-
- // do not need to mark node dirty (stay unchanged)
- link.markDirty();
- };
-
- Cmap.prototype.disconnect = function(type, node, link) {
- if (type instanceof Component) {
- var component = type;
- var relations = component.relations().slice();
-
- // disconnect all connections of component
- helper.eachInstance(relations, Triple, function(triple) {
- var link = triple.link();
- var sourceNode = triple.sourceNode();
- var targetNode = triple.targetNode();
-
- if (sourceNode && (component === link || component === sourceNode))
- this.disconnect(Cmap.CONNECTION_TYPE_SOURCE, sourceNode, link);
-
- if (targetNode && (component === link || component === targetNode))
- this.disconnect(Cmap.CONNECTION_TYPE_TARGET, targetNode, link);
- }.bind(this));
-
- return;
- }
-
- var linkRelations = link.relations();
- var triple = helper.firstInstance(linkRelations, Triple);
- var nodeKey = type + 'Node';
-
- if (!triple || triple[nodeKey]() !== node)
- throw new Error('Not connected');
-
- triple[nodeKey](null);
-
- // remove triple from node
- var nodeRelations = node.relations();
- nodeRelations.splice(nodeRelations.indexOf(triple), 1);
-
- // remove triple from link
- if (!triple.sourceNode() && !triple.targetNode())
- linkRelations.splice(linkRelations.indexOf(triple), 1);
-
- // update connectors of link
- helper.eachInstance(linkRelations, LinkConnectorRelation, function(relation) {
- if (relation.type() === type)
- relation.isConnected(false);
- });
-
- // do not need to mark node dirty (stay unchanged)
- link.markDirty();
- };
-
- Cmap.prototype.connectedNode = function(type, link) {
- var triple = helper.firstInstance(link.relations(), Triple);
-
- if (!triple)
- return null;
-
- return triple[type + 'Node']();
- };
-
- Cmap.prototype.showConnector = function(type, link) {
- if (this.connectorVisible(type, link))
- return;
-
- var disabledConnectorList = this.disabledConnectorList();
- var connectorDisabled = disabledConnectorList.contains(type, link);
-
- if (!connectorDisabled)
- this.addConnector(type, link);
- };
-
- Cmap.prototype.connectorVisible = function(type, link) {
- return link.relations().some(function(relation) {
- return relation instanceof LinkConnectorRelation && relation.type() === type;
- });
- };
-
- Cmap.prototype.addConnector = function(type, link) {
- var connector = new Connector({
- x: link[type + 'X'](),
- y: link[type + 'Y']()
- });
-
- var linkConnectorRelation = new LinkConnectorRelation({
- type: type,
- link: link,
- connector: connector
- });
-
- var linkRelations = link.relations();
- var triple = helper.firstInstance(linkRelations, Triple);
- var isConnected = (triple && !!triple[type + 'Node']());
-
- linkConnectorRelation.isConnected(isConnected);
- linkRelations.push(linkConnectorRelation);
- connector.relations().push(linkConnectorRelation);
-
- this.add(connector);
- };
-
- Cmap.prototype.hideConnector = function(type, link) {
- var linkRelations = link.relations();
-
- for (var i = linkRelations.length - 1; i >= 0; i--) {
- var relation = linkRelations[i];
-
- if (!(relation instanceof LinkConnectorRelation) || relation.type() !== type)
- continue;
-
- // remove connector component
- this.remove(relation.connector());
-
- // remove link-connector relation from link
- linkRelations.splice(i, 1);
-
- break;
- }
- };
-
- Cmap.prototype.showConnectors = function(link) {
- this.showConnector(Cmap.CONNECTION_TYPE_SOURCE, link);
- this.showConnector(Cmap.CONNECTION_TYPE_TARGET, link);
- };
-
- Cmap.prototype.hideConnectors = function(link) {
- this.hideConnector(Cmap.CONNECTION_TYPE_SOURCE, link);
- this.hideConnector(Cmap.CONNECTION_TYPE_TARGET, link);
- };
-
- Cmap.prototype.hideAllConnectors = function() {
- this.componentList().toArray().forEach(function(component) {
- if (component instanceof Link)
- this.hideConnectors(component);
- }.bind(this));
- };
-
- Cmap.prototype.enableConnector = function(type, link) {
- this.disabledConnectorList().remove(type, link);
- };
-
- Cmap.prototype.disableConnector = function(type, link) {
- // remove showing connector
- this.hideConnector(type, link);
-
- this.disabledConnectorList().add(type, link);
- };
-
- Cmap.prototype.connectorEnabled = function(type, link) {
- return !this.disabledConnectorList().contains(type, link);
- };
-
- Cmap.prototype.enableDrag = function(component) {
- this.dragDisabledComponentList().remove(component);
- };
-
- Cmap.prototype.disableDrag = function(component) {
- this.dragDisabledComponentList().add(component);
- };
-
- Cmap.prototype.dragEnabled = function(component) {
- return !this.dragDisabledComponentList().contains(component);
- };
-
- Cmap.prototype.onstart = function(x, y, event) {
- var context = this.dragContext();
-
- var component = this.componentList().fromPoint(Component, x, y);
- context.component = component;
-
- if (typeof this.selectionHandler === 'function')
- this.selectionHandler(component, event);
-
- if (!(component instanceof Connector))
- this.hideAllConnectors();
-
- if (!component)
- return;
-
- var draggable = !this.dragDisabledComponentList().contains(component);
- context.draggable = draggable;
-
- if (!draggable)
- return;
-
- dom.cancel(event);
-
- this.toFront(component);
-
- if (component instanceof Node) {
- context.x = component.x();
- context.y = component.y();
- } else if (component instanceof Link) {
- context.cx = component.cx();
- context.cy = component.cy();
- context.sourceX = component.sourceX();
- context.sourceY = component.sourceY();
- context.targetX = component.targetX();
- context.targetY = component.targetY();
- context.triple = helper.firstInstance(component.relations(), Triple);
-
- this.showConnectors(component);
- } else if (component instanceof Connector) {
- var linkConnectorRelation = helper.firstInstance(component.relations(), LinkConnectorRelation);
-
- context.x = x;
- context.y = y;
- context.type = linkConnectorRelation.type();
- context.link = linkConnectorRelation.link();
- }
-
- this.fixScrollSize();
- };
-
- Cmap.prototype.onmove = function(dx, dy, event) {
- var context = this.dragContext();
-
- var component = context.component;
-
- if (!component)
- return;
-
- if (!context.draggable)
- return;
-
- if (component instanceof Node) {
- var nodeX = context.x + dx;
- var nodeY = context.y + dy;
-
- if (typeof component.moveHandler === 'function') {
- var constrainedPosition = component.moveHandler(nodeX, nodeY);
-
- if (constrainedPosition && isFinite(constrainedPosition.x) && isFinite(constrainedPosition.y)) {
- nodeX = constrainedPosition.x;
- nodeY = constrainedPosition.y;
- }
- }
-
- component.x(nodeX);
- component.y(nodeY);
- } else if (component instanceof Link) {
- var cx = context.cx + dx;
- var cy = context.cy + dy;
- var triple = context.triple;
- var connectedNode = null;
-
- if (triple) {
- var sourceNode = triple.sourceNode();
- var targetNode = triple.targetNode();
-
- if (sourceNode && !targetNode)
- connectedNode = sourceNode;
- else if (!sourceNode && targetNode)
- connectedNode = targetNode;
- }
-
- if (connectedNode) {
- // only one node connected
- var x = cx - connectedNode.cx();
- var y = cy - connectedNode.cy();
- triple.updateLinkAngle(Math.atan2(y, x));
- triple.skipNextUpdate(true);
- } else if (!triple || component.content()) {
- // not connected or link has content
- // (except two nodes connected but link has no content)
- component.cx(cx);
- component.cy(cy);
- component.sourceX(context.sourceX + dx);
- component.sourceY(context.sourceY + dy);
- component.targetX(context.targetX + dx);
- component.targetY(context.targetY + dy);
- }
- } else if (component instanceof Connector) {
- var x = context.x + dx;
- var y = context.y + dy;
- var type = context.type;
- var link = context.link;
-
- var triple = helper.firstInstance(link.relations(), Triple);
- var connectedNode = triple ? triple[type + 'Node']() : null;
- var node = this.componentList().fromPoint(Node, x, y);
-
- if (connectedNode && connectedNode === node) {
- // already connected (do nothing)
- return;
- }
-
- var anotherType = Cmap.anotherConnectionType(type);
- var anotherSideNode = triple ? triple[anotherType + 'Node']() : null;
-
- if (connectedNode && connectedNode !== node) {
- this.disconnect(type, connectedNode, link);
- connectedNode = null;
- }
-
- var needsConnect = !connectedNode && node && anotherSideNode !== node;
-
- if (needsConnect) {
- if (anotherSideNode) {
- var p = triple.connectedPoint(node, anotherSideNode.cx(), anotherSideNode.cy());
-
- link[type + 'X'](p.x);
- link[type + 'Y'](p.y);
-
- triple.update(link);
- triple.skipNextUpdate(true);
- }
-
- this.connect(type, node, link);
- } else {
- link[type + 'X'](x);
- link[type + 'Y'](y);
-
- if (!anotherSideNode)
- link.straighten();
- }
- }
- };
-
- Cmap.prototype.onend = function(dx, dy, event) {
- var context = this.dragContext();
-
- var component = context.component;
-
- if (!component) {
- this.lastClickComponent = null;
- this.lastClickTime = 0;
- return;
- }
-
- if (!context.draggable) {
- this.lastClickComponent = null;
- this.lastClickTime = 0;
- return;
- }
-
- // dx/dy are logical map coordinates; keep the click tolerance at four
- // physical screen pixels at every zoom level.
- var clickTolerance = 4 / this.zoomFactor;
- var isClick = Math.abs(dx) <= clickTolerance && Math.abs(dy) <= clickTolerance;
-
- if (isClick) {
- var now = Date.now();
- var isDoubleClick = (component === this.lastClickComponent &&
- now - this.lastClickTime <= 500);
-
- if (isDoubleClick) {
- this.lastClickComponent = null;
- this.lastClickTime = 0;
-
- if (typeof this.activationHandler === 'function')
- this.activationHandler(component, event);
- } else {
- this.lastClickComponent = component;
- this.lastClickTime = now;
- }
- } else {
- this.lastClickComponent = null;
- this.lastClickTime = 0;
- }
-
- if (component instanceof Node && typeof component.moveEndHandler === 'function')
- component.moveEndHandler(component.x(), component.y(), event);
-
- if (component instanceof Connector) {
- var link = context.link;
- var triple = helper.firstInstance(link.relations(), Triple);
- var connectedNode = triple ? triple[context.type + 'Node']() : null;
-
- if (typeof link.connectionChangeHandler === 'function')
- link.connectionChangeHandler(context.type, connectedNode, event);
- }
-
- this.unfixScrollSize();
- };
-
- Cmap.prototype.fixScrollSize = function() {
- var element = this.element();
-
- var clientWidth = dom.clientWidth(element);
- var clientHeight = dom.clientHeight(element);
- var scrollWidth = dom.scrollWidth(element);
- var scrollHeight = dom.scrollHeight(element);
-
- // check if scrolled
- if (clientWidth === scrollWidth && clientHeight === scrollHeight)
- return;
-
- var translate = 'translate(' + (scrollWidth - 1) + 'px, ' + (scrollHeight - 1) + 'px)';
-
- dom.css(this.retainerElement(), {
- msTransform: translate,
- transform: translate,
- webkitTransform: translate
- });
- };
-
- Cmap.prototype.unfixScrollSize = function() {
- var translate = 'translate(-1px, -1px)';
-
- dom.css(this.retainerElement(), {
- msTransform: translate,
- transform: translate,
- webkitTransform: translate
- });
- };
-
- Cmap.prototype.style = function() {
- return {
- color: '#333',
- cursor: 'default',
- fontFamily: 'sans-serif',
- fontSize: '14px',
- height: '100%',
- MozUserSelect: 'none',
- msUserSelect: 'none',
- overflow: 'visible',
- position: 'relative',
- userSelect: 'none',
- webkitUserSelect: 'none',
- width: '100%',
- zoom: this.zoomFactor
- };
- };
-
- Cmap.prototype.retainerStyle = function() {
- return {
- height: '1px',
- pointerEvents: 'none',
- position: 'absolute',
- width: '1px'
- };
- };
-
- Cmap.prototype.redraw = function() {
- if (this.disposed)
- return;
-
- var rootElement = this.rootElement();
-
- if (!rootElement) {
- rootElement = dom.body();
- dom.css(rootElement, {
- height: '100vh',
- margin: '0',
- width: '100vw'
- });
- this.rootElement(rootElement);
- }
-
- var previousElement = this.element();
- var element = dom.el('
');
- element.className = 'rw-cmap-surface';
- dom.draggable(element, function(x, y, event) {
- this.onstart(x / this.zoomFactor, y / this.zoomFactor, event);
- }.bind(this), function(dx, dy, event) {
- this.onmove(dx / this.zoomFactor, dy / this.zoomFactor, event);
- }.bind(this), function(dx, dy, event) {
- this.onend(dx / this.zoomFactor, dy / this.zoomFactor, event);
- }.bind(this));
- this.element(element);
-
- this.componentList().toArray().forEach(function(component) {
- component.parentElement(element);
- });
-
- var retainerElement = dom.el('
');
- dom.css(retainerElement, this.retainerStyle());
- dom.append(element, retainerElement);
- this.retainerElement(retainerElement);
-
- // set initial position of retainer
- this.unfixScrollSize();
-
- dom.css(element, this.style());
- if (previousElement && previousElement.parentNode)
- previousElement.parentNode.removeChild(previousElement);
- dom.append(rootElement, element);
- };
-
- Cmap.anotherConnectionType = function(type) {
- if (type === Cmap.CONNECTION_TYPE_SOURCE)
- return Cmap.CONNECTION_TYPE_TARGET;
- else if (type === Cmap.CONNECTION_TYPE_TARGET)
- return Cmap.CONNECTION_TYPE_SOURCE;
- };
-
- Cmap.CONNECTION_TYPE_SOURCE = 'source';
- Cmap.CONNECTION_TYPE_TARGET = 'target';
-
- var ComponentModule = function(component, cmap) {
- this.component = component;
- this.cmap = cmap;
- this.wrapper = helper.wrap(this, cmap.component);
- component.module = this;
- };
-
- ComponentModule.prototype.attr = function(key, value) {
- var component = this.component;
- var attributeKeys = this.constructor.attributeKeys();
-
- if (typeof key === 'undefined') {
- var props = {};
-
- attributeKeys.forEach(function(key) {
- props[key] = component[key]();
- });
-
- return props;
- }
-
- if (helper.isPlainObject(key)) {
- var props = key;
-
- for (key in props) {
- this.attr(key, props[key]);
- }
-
- return;
- }
-
- if (attributeKeys.indexOf(key) === -1)
- return;
-
- if (typeof value === 'undefined')
- return component[key]();
-
- component[key](value);
- };
-
- ComponentModule.prototype.remove = function() {
- this.cmap.component.remove(this.component);
- helper.deactivate(this.wrapper);
-
- this.component = null;
- this.cmap = null;
- this.wrapper = null;
- };
-
- ComponentModule.prototype.toFront = function() {
- this.cmap.component.toFront(this.component);
- };
-
- ComponentModule.prototype.element = function() {
- return this.component.element();
- };
-
- ComponentModule.prototype.redraw = function() {
- this.component.redraw();
- };
-
- ComponentModule.prototype.visible = function(value) {
- if (typeof value === 'undefined')
- return this.component.visible !== false;
-
- this.component.visible = !!value;
- this.component.redraw();
- return this.component.visible;
- };
-
- ComponentModule.prototype.onRendered = function(handler) {
- var module = this;
- var component = this.component;
-
- if (handler !== null && typeof handler !== 'function')
- throw TypeError('Invalid render handler');
-
- component.renderedHandler = handler ? function(element) {
- handler(module.wrapper, element);
- } : null;
-
- if (handler && component.element())
- handler(module.wrapper, component.element());
- };
-
- ComponentModule.prototype.draggable = function(enabled) {
- var component = this.component;
- var cmap = this.cmap;
-
- if (typeof enabled === 'undefined')
- return cmap.component.dragEnabled(component);
-
- if (enabled)
- cmap.component.enableDrag(component);
- else
- cmap.component.disableDrag(component);
- };
-
- ComponentModule.attributeKeys = function() {
- return [];
- };
-
- var NodeModule = helper.inherits(function(props, cmap) {
- var component = new Node(helper.pick(props, NodeModule.attributeKeys()));
-
- NodeModule.super_.call(this, component, cmap);
- }, ComponentModule);
-
- NodeModule.prototype.remove = function() {
- this.cmap.nodeModuleList.remove(this);
- NodeModule.super_.prototype.remove.call(this);
- };
-
- NodeModule.prototype.onMove = function(handler) {
- var module = this;
-
- if (handler !== null && typeof handler !== 'function')
- throw TypeError('Invalid move handler');
-
- this.component.moveHandler = handler ? function(x, y) {
- return handler(module.wrapper, x, y);
- } : null;
- };
-
- NodeModule.prototype.onMoveEnd = function(handler) {
- var module = this;
-
- if (handler !== null && typeof handler !== 'function')
- throw TypeError('Invalid move-end handler');
-
- this.component.moveEndHandler = handler ? function(x, y, event) {
- handler(module.wrapper, x, y, event);
- } : null;
- };
-
- NodeModule.attributeKeys = function() {
- return [
- 'content',
- 'contentType',
- 'x',
- 'y',
- 'width',
- 'height',
- 'backgroundColor',
- 'borderColor',
- 'borderWidth',
- 'textColor'
- ];
- };
-
- var LinkModule = helper.inherits(function(props, cmap) {
- var component = new Link(helper.pick(props, LinkModule.attributeKeys()));
-
- LinkModule.super_.call(this, component, cmap);
- }, ComponentModule);
-
- LinkModule.prototype.sourceNode = function(node) {
- return LinkModule.connectNode(this, Cmap.CONNECTION_TYPE_SOURCE, node);
- };
-
- LinkModule.prototype.targetNode = function(node) {
- return LinkModule.connectNode(this, Cmap.CONNECTION_TYPE_TARGET, node);
- };
-
- LinkModule.prototype.onConnectionChange = function(handler) {
- var module = this;
-
- if (handler !== null && typeof handler !== 'function')
- throw TypeError('Invalid connection-change handler');
-
- this.component.connectionChangeHandler = handler ? function(type, node, event) {
- var nodeModule = node ? module.cmap.nodeModuleList.fromComponent(node) : null;
- handler(module.wrapper, type, nodeModule ? nodeModule.wrapper : null, event);
- } : null;
- };
-
- LinkModule.prototype.straighten = function() {
- var link = this.component;
- var triple = helper.firstInstance(link.relations(), Triple);
- var sourceNode = triple ? triple.sourceNode() : null;
- var targetNode = triple ? triple.targetNode() : null;
-
- if (!sourceNode || !targetNode) {
- link.straighten();
- return;
- }
-
- var sourcePoint = triple.connectedPoint(sourceNode, targetNode.cx(), targetNode.cy());
- var targetPoint = triple.connectedPoint(targetNode, sourceNode.cx(), sourceNode.cy());
- link.straighten(sourcePoint.x, sourcePoint.y, targetPoint.x, targetPoint.y);
- };
-
- LinkModule.prototype.sourceConnectorEnabled = function(enabled) {
- return LinkModule.connectorEnabled(this, Cmap.CONNECTION_TYPE_SOURCE, enabled);
- };
-
- LinkModule.prototype.targetConnectorEnabled = function(enabled) {
- return LinkModule.connectorEnabled(this, Cmap.CONNECTION_TYPE_TARGET, enabled);
- };
-
- LinkModule.attributeKeys = function() {
- return [
- 'content',
- 'contentType',
- 'cx',
- 'cy',
- 'width',
- 'height',
- 'backgroundColor',
- 'borderColor',
- 'borderWidth',
- 'textColor',
- 'sourceX',
- 'sourceY',
- 'targetX',
- 'targetY',
- 'lineColor',
- 'lineWidth',
- 'hasArrow'
- ];
- };
-
- LinkModule.connectNode = function(module, type, node) {
- var component = module.component;
- var cmap = module.cmap;
-
- var cmapComponent = cmap.component;
- var connectedNodeComponent = cmapComponent.connectedNode(type, component);
-
- if (typeof node === 'undefined') {
- if (!connectedNodeComponent)
- return null;
-
- var connectedNode = cmap.nodeModuleList.fromComponent(connectedNodeComponent);
- return connectedNode.wrapper;
- }
-
- if (node !== null) {
- if (typeof node !== 'function')
- throw TypeError('Invalid node');
-
- // unwrap node module
- node = node(cmap.component);
-
- if (!(node instanceof NodeModule))
- throw TypeError('Invalid node');
-
- // cannot connect the same node to the source and the target of the link
- var anotherType = Cmap.anotherConnectionType(type);
- var anotherNodeComponent = cmapComponent.connectedNode(anotherType, component);
-
- if (anotherNodeComponent === node.component)
- return;
- }
-
- if (connectedNodeComponent)
- cmapComponent.disconnect(type, connectedNodeComponent, component);
-
- if (node === null)
- return;
-
- cmapComponent.connect(type, node.component, component);
- };
-
- LinkModule.connectorEnabled = function(module, type, enabled) {
- var component = module.component;
- var cmap = module.cmap;
-
- var cmapComponent = cmap.component;
-
- if (typeof enabled === 'undefined')
- return cmapComponent.connectorEnabled(type, component);
-
- if (enabled) {
- cmapComponent.enableConnector(type, component);
-
- // show the connector if another connector is showing
- var anotherType = Cmap.anotherConnectionType(type);
-
- if (cmapComponent.connectorVisible(anotherType, component))
- cmapComponent.showConnector(type, component);
- } else {
- cmapComponent.disableConnector(type, component);
- }
- };
-
- var NodeModuleList = helper.inherits(function() {
- NodeModuleList.super_.call(this);
- }, helper.List);
-
- NodeModuleList.prototype.fromComponent = function(component) {
- var data = this.data;
-
- for (var i = 0, len = data.length; i < len; i++) {
- var node = data[i];
-
- if (node.component === component)
- return node;
- }
-
- return null;
- };
-
- var CmapModule = function(element) {
- if (!(this instanceof CmapModule))
- return new CmapModule(element);
-
- this.component = new Cmap(element);
- this.nodeModuleList = new NodeModuleList();
- this.selectionHandler = null;
- this.activationHandler = null;
-
- this.component.selectionHandler = function(component, event) {
- if (typeof this.selectionHandler !== 'function')
- return;
-
- var module = component ? component.module : null;
- this.selectionHandler(module ? module.wrapper : null, event);
- }.bind(this);
-
- this.component.activationHandler = function(component, event) {
- if (typeof this.activationHandler !== 'function')
- return;
-
- var module = component ? component.module : null;
- this.activationHandler(module ? module.wrapper : null, event);
- }.bind(this);
-
- return helper.wrap(this, this.component);
- };
-
- CmapModule.prototype.onSelection = function(handler) {
- if (handler !== null && typeof handler !== 'function')
- throw TypeError('Invalid selection handler');
-
- this.selectionHandler = handler;
- };
-
- CmapModule.prototype.onActivation = function(handler) {
- if (handler !== null && typeof handler !== 'function')
- throw TypeError('Invalid activation handler');
-
- this.activationHandler = handler;
- };
-
- CmapModule.prototype.destroy = function() {
- var component = this.component;
- var element = component.element();
-
- this.selectionHandler = null;
- this.activationHandler = null;
- component.selectionHandler = null;
- component.activationHandler = null;
- component.componentList().toArray().forEach(function(child) {
- child.dispose();
- child.parentElement(null);
- });
- component.dispose();
-
- if (element && element.parentNode)
- element.parentNode.removeChild(element);
- };
-
- CmapModule.prototype.zoom = function(value) {
- if (typeof value === 'undefined')
- return this.component.zoomFactor;
-
- value = Number(value);
- if (!isFinite(value) || value <= 0)
- throw TypeError('Invalid zoom factor');
-
- this.component.zoomFactor = value;
- var element = this.component.element();
- if (element)
- element.style.zoom = String(value);
- return value;
- };
-
- CmapModule.prototype.node = function(props) {
- if (typeof props !== 'undefined' && !helper.isPlainObject(props))
- throw TypeError('Type error');
-
- var node = new NodeModule(props, this);
-
- this.component.add(node.component);
- this.nodeModuleList.add(node);
-
- return node.wrapper;
- };
-
- CmapModule.prototype.link = function(props) {
- if (typeof props !== 'undefined' && !helper.isPlainObject(props))
- throw TypeError('Type error');
-
- var link = new LinkModule(props, this);
-
- this.component.add(link.component);
-
- return link.wrapper;
- };
-
- if (typeof module !== 'undefined' && module.exports)
- module.exports = CmapModule;
- else
- window.Cmap = CmapModule;
-})();
+export { DiagramEngine, DiagramEngine as Cmap } from "./engine/diagram-engine.js";
+export { DiagramGroup } from "./engine/diagram-group.js";
diff --git a/static/cmap/controller/cmap-history.js b/static/cmap/controller/cmap-history.js
new file mode 100644
index 0000000..8a201b1
--- /dev/null
+++ b/static/cmap/controller/cmap-history.js
@@ -0,0 +1,194 @@
+/**
+ * Undo/redo history for an editor whose state can be represented as JSON.
+ *
+ * The history owns snapshots, stack limits and the asynchronous commit boundary.
+ * It does not know how a document is rendered or restored: those concerns are
+ * supplied by the editor through the constructor callbacks.
+ */
+export class CmapHistory {
+ /**
+ * goal : Create a history coordinator for one editor state.
+ * pre : snapshot and restore are functions for the same serialized state.
+ * post : The coordinator is ready to track state after reset() is called.
+ * result : A CmapHistory instance with empty undo and redo stacks.
+ * internals : The callbacks keep this controller independent of the editor's
+ * model and view; the stacks contain only serialized snapshots.
+ *
+ * @param {object} options History callbacks and configuration.
+ * @param {Function} options.snapshot Returns the current serialized state.
+ * @param {Function} options.restore Restores one serialized state.
+ * @param {Function} [options.onChange] Receives undo/redo availability changes.
+ * @param {number} [options.limit=100] Maximum number of undo snapshots.
+ */
+ constructor({ snapshot, restore, onChange = null, limit = 100 }) {
+ if (typeof snapshot !== "function") throw new TypeError("A snapshot function is required");
+ if (typeof restore !== "function") throw new TypeError("A restore function is required");
+ this.snapshot = snapshot;
+ this.restoreDocument = restore;
+ this.onChange = onChange;
+ this.limit = Math.max(1, Number(limit) || 100);
+ this.undoStack = [];
+ this.redoStack = [];
+ this.currentSnapshot = null;
+ this.timer = null;
+ this.ready = false;
+ this.isRestoring = false;
+ }
+
+ /** Notify the host about the current availability of undo and redo. */
+ notify() {
+ if (this.onChange) {
+ this.onChange({
+ canUndo: this.canUndo(),
+ canRedo: this.canRedo()
+ });
+ }
+ }
+
+ /**
+ * goal : Start a new history session at the current editor state.
+ * pre : The snapshot callback returns the current serialized state.
+ * post : Both stacks are empty and the current state is the history baseline.
+ * result : Undefined; the host is notified of the empty stacks.
+ * internals : A pending timer is cancelled before the baseline is captured.
+ */
+ reset() {
+ this.cancelScheduledCommit();
+ this.undoStack = [];
+ this.redoStack = [];
+ this.ready = true;
+ this.currentSnapshot = this.snapshot();
+ this.notify();
+ }
+
+ /** Cancel a pending asynchronous history commit. */
+ cancelScheduledCommit() {
+ if (this.timer !== null) {
+ window.clearTimeout(this.timer);
+ this.timer = null;
+ }
+ }
+
+ /**
+ * Schedule one commit for the current mutation transaction.
+ * The zero-delay timer groups synchronous editor changes into one undo step.
+ */
+ scheduleCommit() {
+ if (!this.ready || this.isRestoring) return;
+ this.cancelScheduledCommit();
+ this.timer = window.setTimeout(() => {
+ this.timer = null;
+ this.commit();
+ }, 0);
+ }
+
+ /** Update the baseline after renderer-only normalization. */
+ refreshSnapshot() {
+ if (!this.ready || this.isRestoring || this.timer !== null) return;
+ this.currentSnapshot = this.snapshot();
+ }
+
+ /**
+ * Commit the current state when it differs from the baseline.
+ * @returns {boolean} Whether a new undo step was recorded.
+ */
+ commit() {
+ if (!this.ready || this.isRestoring) return false;
+ this.cancelScheduledCommit();
+ const nextSnapshot = this.snapshot();
+ if (nextSnapshot === this.currentSnapshot) return false;
+ if (this.currentSnapshot !== null) {
+ this.undoStack.push(this.currentSnapshot);
+ if (this.undoStack.length > this.limit) this.undoStack.shift();
+ }
+ this.currentSnapshot = nextSnapshot;
+ this.redoStack = [];
+ this.notify();
+ return true;
+ }
+
+ /** Return whether an undo operation is available. */
+ canUndo() {
+ return this.undoStack.length > 0;
+ }
+
+ /** Return whether a redo operation is available. */
+ canRedo() {
+ return this.redoStack.length > 0;
+ }
+
+ get undoCount() {
+ return this.undoStack.length;
+ }
+
+ get redoCount() {
+ return this.redoStack.length;
+ }
+
+ /**
+ * Restore one snapshot while suppressing history commits caused by loading.
+ * The editor callback performs the actual model and view reconstruction.
+ */
+ restoreSnapshot(snapshot) {
+ this.isRestoring = true;
+ try {
+ this.restoreDocument(snapshot);
+ } finally {
+ this.isRestoring = false;
+ }
+ this.currentSnapshot = snapshot;
+ this.notify();
+ }
+
+ /** Restore the previous committed state, if one exists. */
+ undo() {
+ this.commit();
+ if (!this.canUndo()) return false;
+ this.redoStack.push(this.currentSnapshot);
+ const snapshot = this.undoStack.pop();
+ this.restoreSnapshot(snapshot);
+ return true;
+ }
+
+ /** Restore the most recently undone state, if one exists. */
+ redo() {
+ this.commit();
+ if (!this.canRedo()) return false;
+ this.undoStack.push(this.currentSnapshot);
+ const snapshot = this.redoStack.pop();
+ this.restoreSnapshot(snapshot);
+ return true;
+ }
+
+ /**
+ * goal : Replace the current document as one undoable operation.
+ * pre : replaceDocument performs the complete document replacement.
+ * post : The replacement is current and redo history has been discarded.
+ * result : Undefined; the host receives the new undo/redo availability.
+ * internals : The old baseline is pushed before the callback runs, while
+ * isRestoring prevents loading callbacks from creating nested history steps.
+ */
+ replace(replaceDocument) {
+ this.commit();
+ const previousSnapshot = this.currentSnapshot || this.snapshot();
+ this.isRestoring = true;
+ try {
+ replaceDocument();
+ } finally {
+ this.isRestoring = false;
+ }
+ const nextSnapshot = this.snapshot();
+ if (previousSnapshot !== nextSnapshot) {
+ this.undoStack.push(previousSnapshot);
+ if (this.undoStack.length > this.limit) this.undoStack.shift();
+ }
+ this.currentSnapshot = nextSnapshot;
+ this.redoStack = [];
+ this.notify();
+ }
+
+ /** Release the timer when the owning editor is destroyed. */
+ destroy() {
+ this.cancelScheduledCommit();
+ }
+}
diff --git a/static/cmap/controller/cmap-interaction-controller.js b/static/cmap/controller/cmap-interaction-controller.js
new file mode 100644
index 0000000..43e5edc
--- /dev/null
+++ b/static/cmap/controller/cmap-interaction-controller.js
@@ -0,0 +1,568 @@
+"use strict";
+
+import { debug, elementDescription } from "../cmap-utils.js";
+
+export class CmapInteractionController {
+ constructor(editor) {
+ this.editor = editor;
+ this.marqueeMouseDownHandler = null;
+ this.activeMarqueeCleanup = null;
+ this.canvasPanPointerDownHandler = null;
+ this.activeCanvasPanCleanup = null;
+ }
+
+ get items() { return this.editor.items; }
+ get connectors() { return this.editor.connectors; }
+ get canvas() { return this.editor.canvas; }
+ get zoomFactor() { return this.editor.zoomFactor; }
+
+ handleItemMove(record, x, y) {
+ const movesCompleteSubmap = false;
+ const movesSelection = this.editor.selectedItems.has(record) && this.editor.selectedItems.size > 1;
+ const moveMembership = this.beginItemMove(record, movesCompleteSubmap || movesSelection);
+ if (moveMembership.groupPositions.length > 1) {
+ this.moveSubmapGroup(record, x, y, moveMembership);
+ queueMicrotask(() => this.redrawAllConnectors());
+ }
+ return { x, y };
+ }
+
+ beginItemMove(record, includeDescendants = false) {
+ if (record.moveMembership) return record.moveMembership;
+ let groupItems = [record];
+ if (includeDescendants) {
+ const selected = this.editor.selectedItems.has(record) && this.editor.selectedItems.size > 1 ?
+ this.editor.selectedAll() : [record];
+ const expanded = [];
+ for (const item of selected) {
+ expanded.push(item);
+ if (item.kind === "submap" && item !== this.editor.activeMapRoot) {
+ expanded.push(...this.items.filter((candidate) => this.editor.isDescendantOf(candidate, item)));
+ }
+ }
+ groupItems = Array.from(new Set(expanded));
+ }
+ groupItems = this.includeLinkedMapItems(groupItems);
+ record.moveMembership = {
+ parent: record.parentSubmap,
+ parentBounds: record.parentSubmap ? this.editor.submapBounds(record.parentSubmap) : null,
+ startX: Number(record.node.attr("x")),
+ startY: Number(record.node.attr("y")),
+ groupPositions: groupItems.map((item) => ({
+ item,
+ x: Number(item.node.attr("x")),
+ y: Number(item.node.attr("y"))
+ }))
+ };
+ return record.moveMembership;
+ }
+
+ includeLinkedMapItems(groupItems) {
+ const group = new Set(groupItems);
+ const pending = [...groupItems];
+ while (pending.length) {
+ const current = pending.pop();
+ for (const connector of this.connectors) {
+ if (connector.source !== current && connector.target !== current) continue;
+ const other = connector.source === current ? connector.target : connector.source;
+ const include = other.kind === "phrase" || Boolean(other.cmapSlug);
+ if (!include || group.has(other)) continue;
+ group.add(other);
+ pending.push(other);
+ }
+ }
+ return Array.from(group);
+ }
+
+ moveSubmapGroup(record, x, y, moveMembership = this.beginItemMove(record, true)) {
+ const deltaX = x - moveMembership.startX;
+ const deltaY = y - moveMembership.startY;
+ for (const position of moveMembership.groupPositions) {
+ position.item.node.attr({
+ x: position.x + deltaX,
+ y: position.y + deltaY
+ });
+ position.item.node.redraw();
+ }
+ for (const connector of this.connectors) {
+ connector.link.straighten();
+ connector.link.redraw();
+ }
+ for (const submap of this.items
+ .filter((item) => item.kind === "submap")
+ .sort((a, b) => b.submapDepth - a.submapDepth)) {
+ this.editor.updateSubmapFrame(submap);
+ }
+ }
+
+ redrawAllConnectors() {
+ for (const connector of this.connectors) {
+ connector.link.straighten();
+ connector.link.redraw();
+ }
+ }
+
+ handleItemMoveEnd(record) {
+ const moveMembership = record.moveMembership;
+ record.moveMembership = null;
+ if (!moveMembership) return;
+ this.redrawAllConnectors();
+
+ const movedItems = this.editor.selectedAll().filter((item) =>
+ moveMembership.groupPositions.some((position) => position.item === item));
+ const movedParents = new Set(movedItems.map((item) => item.parentSubmap));
+ if (movedItems.length > 1 && movedParents.size === 1) {
+ const previousParent = movedItems[0].parentSubmap;
+ const centers = movedItems.map((item) => this.editor.itemCenter(item));
+ const center = {
+ x: centers.reduce((sum, point) => sum + point.x, 0) / centers.length,
+ y: centers.reduce((sum, point) => sum + point.y, 0) / centers.length
+ };
+ let parent = null;
+ if (previousParent && this.editor.pointInBounds(center, moveMembership.parentBounds)) {
+ parent = previousParent;
+ } else {
+ parent = this.editor.submapAtPoint(center, null, movedItems);
+ }
+ if (!parent && this.editor.activeMapRoot && !movedItems.includes(this.editor.activeMapRoot)) {
+ parent = this.editor.activeMapRoot;
+ }
+ if (previousParent && parent !== previousParent && this.editor.onConfirmDetachFromSubmap &&
+ !this.editor.onConfirmDetachFromSubmap(record, previousParent, parent)) {
+ parent = previousParent;
+ }
+ if (parent !== previousParent) {
+ for (const item of movedItems) {
+ if (item === this.editor.activeMapRoot) continue;
+ item.parentSubmap = parent;
+ this.editor.updateSubmapDepth(item, parent ? parent.submapDepth + 1 : 0);
+ }
+ this.editor.reconcilePhraseMembership();
+ this.editor.refreshConceptMapReferences();
+ debug("selection submap membership changed", {
+ itemIds: movedItems.map((item) => item.id),
+ previousParentId: previousParent ? previousParent.id : null,
+ parentId: parent ? parent.id : null
+ });
+ }
+ this.editor.refreshSubmapVisibility();
+ this.editor.scheduleHistoryCommit();
+ return;
+ }
+
+ if (record.kind === "phrase") {
+ this.editor.scheduleHistoryCommit();
+ return;
+ }
+
+ if (record === this.editor.activeMapRoot) {
+ this.editor.refreshSubmapVisibility();
+ debug("active map head moved without changing parent membership", {
+ id: record.id,
+ parentId: record.parentSubmap ? record.parentSubmap.id : null
+ });
+ this.editor.scheduleHistoryCommit();
+ return;
+ }
+
+ const center = this.editor.itemCenter(record);
+ let parent = null;
+ if (moveMembership.parent && this.editor.pointInBounds(center, moveMembership.parentBounds)) {
+ parent = moveMembership.parent;
+ } else {
+ parent = this.editor.submapAtPoint(center, record);
+ }
+ if (!parent && this.editor.activeMapRoot && record !== this.editor.activeMapRoot) parent = this.editor.activeMapRoot;
+
+ if (moveMembership.parent && parent !== moveMembership.parent &&
+ this.editor.onConfirmDetachFromSubmap &&
+ !this.editor.onConfirmDetachFromSubmap(record, moveMembership.parent, parent)) {
+ parent = moveMembership.parent;
+ }
+
+ if (parent !== record.parentSubmap) {
+ const previousParent = record.parentSubmap;
+ record.parentSubmap = parent;
+ this.editor.updateSubmapDepth(record, parent ? parent.submapDepth + 1 : 0);
+ debug("item submap membership changed", {
+ id: record.id,
+ previousParentId: previousParent ? previousParent.id : null,
+ parentId: parent ? parent.id : null
+ });
+ this.editor.reconcilePhraseMembership();
+ this.editor.refreshConceptMapReferences();
+ }
+ this.editor.refreshSubmapVisibility();
+ this.editor.scheduleHistoryCommit();
+ }
+
+ startSubmapFrameDrag(event, record) {
+ event.preventDefault();
+ event.stopPropagation();
+ const additive = event.ctrlKey || event.metaKey || event.shiftKey;
+ if (!additive && this.editor.selectedItems.size > 1 && this.editor.selectedItems.has(record)) {
+ this.editor.selectedItem = record;
+ this.editor.refreshSelectionDecoration();
+ this.editor.notifySelection();
+ } else {
+ this.editor.selectItem(record, { additive });
+ }
+ const pointerId = event.pointerId;
+ const startClientX = event.clientX;
+ const startClientY = event.clientY;
+ const bounds = this.editor.submapBounds(record);
+ if (!bounds) return;
+ const moveMembership = {
+ parent: record.parentSubmap,
+ parentBounds: null,
+ startX: bounds.left,
+ startY: bounds.top,
+ groupPositions: this.items
+ .filter((item) => this.editor.isDescendantOf(item, record))
+ .map((item) => ({
+ item,
+ x: Number(item.node.attr("x")),
+ y: Number(item.node.attr("y"))
+ }))
+ };
+ record.moveMembership = moveMembership;
+
+ const move = (moveEvent) => {
+ if (moveEvent.pointerId !== pointerId) return;
+ moveEvent.preventDefault();
+ const x = moveMembership.startX +
+ ((moveEvent.clientX - startClientX) / this.zoomFactor);
+ const y = moveMembership.startY +
+ ((moveEvent.clientY - startClientY) / this.zoomFactor);
+ this.moveSubmapGroup(record, x, y, moveMembership);
+ };
+
+ const up = (upEvent) => {
+ if (upEvent.pointerId !== pointerId) return;
+ window.removeEventListener("pointermove", move);
+ window.removeEventListener("pointerup", up);
+ record.moveMembership = null;
+ this.editor.saveCurrentContextLayout();
+ this.editor.refreshSubmapVisibility();
+ this.editor.scheduleHistoryCommit();
+ };
+
+ window.addEventListener("pointermove", move);
+ window.addEventListener("pointerup", up);
+ }
+
+ installMarqueeSelection() {
+ this.marqueeMouseDownHandler = (event) => {
+ if (event.button !== 0) return;
+ if (!(event.target instanceof Element)) return;
+ if (event.target.closest(
+ "[data-rw-cmap-item-id], [data-rw-cmap-connector-id], .rw-cmap-submap-frame, .rw-cmap-handle")) return;
+
+ const start = this.editor.canvasPoint(event);
+ if (this.editor.pointNearConnector(start)) return;
+ if (this.activeMarqueeCleanup) this.activeMarqueeCleanup();
+ const additive = event.ctrlKey || event.metaKey || event.shiftKey;
+ const surface = this.editor.surfaceElement() || this.canvas;
+ const marquee = document.createElement("div");
+ marquee.className = "rw-cmap-marquee";
+ Object.assign(marquee.style, { left: `${start.x}px`, top: `${start.y}px`, width: "0", height: "0" });
+ surface.append(marquee);
+
+ const cleanup = () => {
+ window.removeEventListener("mousemove", move);
+ window.removeEventListener("mouseup", up);
+ marquee.remove();
+ if (this.activeMarqueeCleanup === cleanup) this.activeMarqueeCleanup = null;
+ };
+
+ const move = (moveEvent) => {
+ const point = this.editor.canvasPoint(moveEvent);
+ const left = Math.min(start.x, point.x);
+ const top = Math.min(start.y, point.y);
+ Object.assign(marquee.style, {
+ left: `${left}px`,
+ top: `${top}px`,
+ width: `${Math.abs(point.x - start.x)}px`,
+ height: `${Math.abs(point.y - start.y)}px`
+ });
+ };
+
+ const up = (upEvent) => {
+ const point = this.editor.canvasPoint(upEvent);
+ cleanup();
+ const bounds = {
+ left: Math.min(start.x, point.x),
+ top: Math.min(start.y, point.y),
+ right: Math.max(start.x, point.x),
+ bottom: Math.max(start.y, point.y)
+ };
+ if (bounds.right - bounds.left < 4 && bounds.bottom - bounds.top < 4) {
+ if (!additive) this.editor.clearSelection();
+ return;
+ }
+ if (!additive) this.editor.clearSelection(false);
+ const matches = this.items.filter((item) => {
+ if (!this.editor.isEffectiveItemVisible(item)) return false;
+ const left = Number(item.node.attr("x"));
+ const top = Number(item.node.attr("y"));
+ const right = left + Number(item.node.attr("width"));
+ const bottom = top + Number(item.node.attr("height"));
+ return right >= bounds.left && left <= bounds.right &&
+ bottom >= bounds.top && top <= bounds.bottom;
+ });
+ const expanded = new Set(matches);
+ for (const item of matches) {
+ if (!item.groupId) continue;
+ for (const member of this.items.filter((candidate) =>
+ candidate.groupId === item.groupId && this.editor.isItemVisible(candidate))) expanded.add(member);
+ }
+ for (const item of expanded) this.editor.selectedItems.add(item);
+ this.editor.selectedItem = matches.at(-1) || this.editor.selectedItem;
+ this.editor.selectedConnector = null;
+ this.editor.refreshSelectionDecoration();
+ this.editor.notifySelection();
+ debug("marquee selection applied", {
+ selectedIds: this.editor.selectedAll().map((item) => item.id)
+ });
+ };
+
+ window.addEventListener("mousemove", move);
+ window.addEventListener("mouseup", up);
+ this.activeMarqueeCleanup = cleanup;
+ };
+ this.canvas.addEventListener("mousedown", this.marqueeMouseDownHandler);
+ }
+
+ installCanvasPanning() {
+ this.canvasPanPointerDownHandler = (event) => {
+ const target = event.target instanceof Element ? event.target : null;
+ if (target && target.closest(
+ "[data-rw-cmap-item-id], [data-rw-cmap-connector-id], .rw-cmap-submap-frame, .rw-cmap-handle")) return;
+ if (event.button !== 1 && !(event.button === 0 && event.altKey)) return;
+ event.preventDefault();
+ if (this.activeCanvasPanCleanup) this.activeCanvasPanCleanup();
+ const startX = event.clientX;
+ const startY = event.clientY;
+ const startScrollLeft = this.canvas.scrollLeft;
+ const startScrollTop = this.canvas.scrollTop;
+ const pointerId = event.pointerId;
+ this.canvas.classList.add("rw-cmap-canvas-panning");
+
+ const move = (moveEvent) => {
+ if (moveEvent.pointerId !== pointerId) return;
+ moveEvent.preventDefault();
+ this.canvas.scrollLeft = startScrollLeft - (moveEvent.clientX - startX);
+ this.canvas.scrollTop = startScrollTop - (moveEvent.clientY - startY);
+ };
+ const up = (upEvent) => {
+ if (upEvent.pointerId !== pointerId) return;
+ window.removeEventListener("pointermove", move);
+ window.removeEventListener("pointerup", up);
+ this.canvas.classList.remove("rw-cmap-canvas-panning");
+ if (this.activeCanvasPanCleanup === cleanup) this.activeCanvasPanCleanup = null;
+ };
+ const cleanup = () => {
+ window.removeEventListener("pointermove", move);
+ window.removeEventListener("pointerup", up);
+ this.canvas.classList.remove("rw-cmap-canvas-panning");
+ if (this.activeCanvasPanCleanup === cleanup) this.activeCanvasPanCleanup = null;
+ };
+
+ window.addEventListener("pointermove", move);
+ window.addEventListener("pointerup", up);
+ this.activeCanvasPanCleanup = cleanup;
+ };
+ this.canvas.addEventListener("pointerdown", this.canvasPanPointerDownHandler);
+ }
+
+ handleMapSelection(component, event) {
+ if (event.target instanceof Element &&
+ event.target.closest(".rw-cmap-handle, .rw-cmap-phrase-input")) {
+ debug("cmap selection belongs to an editor control", elementDescription(event.target));
+ return;
+ }
+
+ const item = this.items.find((candidate) => candidate.node === component) || null;
+ const connector = this.connectors.find((candidate) => candidate.link === component) || null;
+ debug("selection callback received from cmap hit test", {
+ componentFound: Boolean(component),
+ itemId: item ? item.id : null,
+ connectorId: connector ? connector.id : null,
+ target: elementDescription(event.target)
+ });
+
+ if (item) {
+ const additive = Boolean(event && (event.ctrlKey || event.metaKey || event.shiftKey));
+ if (!additive && this.editor.selectedItems.size > 1 && this.editor.selectedItems.has(item)) {
+ this.editor.selectedItem = item;
+ this.editor.refreshSelectionDecoration();
+ this.editor.notifySelection();
+ return;
+ }
+ this.editor.selectItem(item, { additive, toggle: additive });
+ return;
+ }
+ if (connector) {
+ this.editor.selectConnector(connector);
+ return;
+ }
+ if (!(event && (event.ctrlKey || event.metaKey || event.shiftKey))) this.editor.clearSelection();
+ }
+
+ handleMapActivation(component, event) {
+ const item = this.items.find((candidate) => candidate.node === component) || null;
+ debug("activation callback received from cmap", {
+ itemId: item ? item.id : null,
+ kind: item ? item.kind : null,
+ pageSlug: item ? item.pageSlug : null
+ });
+ if (!item) return;
+ if (event && event.preventDefault) event.preventDefault();
+ if (item.kind === "phrase") {
+ this.editor.editPhraseInline(item);
+ return;
+ }
+ if (this.editor.onEditItem) {
+ this.editor.selectItem(item);
+ this.editor.onEditItem(item);
+ }
+ }
+
+ startResize(event, record) {
+ event.preventDefault();
+ event.stopPropagation();
+ record.autoWidth = false;
+ record.autoHeight = false;
+ record.fitContentPending = false;
+ const startX = event.clientX;
+ const startY = event.clientY;
+ const startWidth = Number(record.node.attr("width"));
+ const startHeight = Number(record.node.attr("height"));
+ const pointerId = event.pointerId;
+ event.currentTarget.setPointerCapture(pointerId);
+
+ const move = (moveEvent) => {
+ if (moveEvent.pointerId !== pointerId) return;
+ record.width = Math.max(100, startWidth + ((moveEvent.clientX - startX) / this.zoomFactor));
+ record.height = Math.max(42, startHeight + ((moveEvent.clientY - startY) / this.zoomFactor));
+ record.node.attr({ width: record.width, height: record.height });
+ record.node.redraw();
+ this.editor.decorateItem(record);
+ this.editor.redrawConnectorsFor(record);
+ this.editor.ensureCanvasExtent(Number(record.node.attr("x")) + record.width,
+ Number(record.node.attr("y")) + record.height);
+ };
+
+ const up = (upEvent) => {
+ if (upEvent.pointerId !== pointerId) return;
+ window.removeEventListener("pointermove", move);
+ window.removeEventListener("pointerup", up);
+ this.editor.decorateItem(record);
+ this.editor.scheduleHistoryCommit();
+ };
+
+ window.addEventListener("pointermove", move);
+ window.addEventListener("pointerup", up);
+ }
+
+ startRelationDrag(event, source) {
+ event.preventDefault();
+ event.stopPropagation();
+ const pointerId = event.pointerId;
+ const start = this.editor.itemCenter(source);
+ const draft = this.createDraftLine(start);
+ this.editor.dragRelation = { source, draft };
+ event.currentTarget.setPointerCapture(pointerId);
+
+ const move = (moveEvent) => {
+ if (moveEvent.pointerId !== pointerId) return;
+ const point = this.editor.canvasPoint(moveEvent);
+ this.editor.ensureCanvasExtent(point.x, point.y);
+ draft.line.setAttribute("x2", String(point.x));
+ draft.line.setAttribute("y2", String(point.y));
+ draft.svg.setAttribute("width", String(Math.max(this.editor.logicalCanvasWidth(), point.x + 180)));
+ draft.svg.setAttribute("height", String(Math.max(this.editor.logicalCanvasHeight(), point.y + 180)));
+ };
+
+ const up = (upEvent) => {
+ if (upEvent.pointerId !== pointerId) return;
+ window.removeEventListener("pointermove", move);
+ window.removeEventListener("pointerup", up);
+ const target = this.editor.itemAt(upEvent.clientX, upEvent.clientY);
+ const point = this.editor.canvasPoint(upEvent);
+ draft.svg.remove();
+ this.editor.dragRelation = null;
+ if (!target) {
+ this.editor.ensureCanvasExtent(point.x, point.y);
+ const parentSubmap = this.editor.submapAtPoint(point);
+ debug("relation dropped on empty canvas", {
+ sourceId: source.id,
+ point,
+ parentSubmapId: parentSubmap ? parentSubmap.id : null
+ });
+ if (this.editor.onCreateConnectedItem) this.editor.onCreateConnectedItem({ source, point, parentSubmap });
+ return;
+ }
+ if (target === source) return;
+ this.finishRelation(source, target, event.altKey || upEvent.altKey);
+ };
+
+ window.addEventListener("pointermove", move);
+ window.addEventListener("pointerup", up);
+ }
+
+ finishRelation(source, target, direct = false) {
+ if (source.kind === "phrase" && target.kind !== "phrase") {
+ this.editor.addConnector(source, target, true);
+ this.editor.reconcilePhraseMembership(source);
+ this.editor.refreshSubmapVisibility();
+ this.editor.selectItem(source);
+ return;
+ }
+ if (source.kind !== "phrase" && target.kind === "phrase") {
+ this.editor.addConnector(source, target, false);
+ this.editor.reconcilePhraseMembership(target);
+ this.editor.refreshSubmapVisibility();
+ this.editor.selectItem(target);
+ return;
+ }
+ if (source.kind === "phrase" && target.kind === "phrase") return;
+ if (direct) {
+ const connector = this.editor.addConnector(source, target, true);
+ this.editor.refreshSubmapVisibility();
+ this.editor.selectConnector(connector);
+ return;
+ }
+ this.editor.connectWithPhrase(source, target, "?????", true);
+ }
+
+ createDraftLine(start) {
+ const ns = "http://www.w3.org/2000/svg";
+ const svg = document.createElementNS(ns, "svg");
+ svg.classList.add("rw-cmap-draft-layer");
+ svg.setAttribute("width", String(this.editor.logicalCanvasWidth()));
+ svg.setAttribute("height", String(this.editor.logicalCanvasHeight()));
+ const line = document.createElementNS(ns, "line");
+ line.setAttribute("x1", String(start.x));
+ line.setAttribute("y1", String(start.y));
+ line.setAttribute("x2", String(start.x));
+ line.setAttribute("y2", String(start.y));
+ line.setAttribute("class", "rw-cmap-draft-line");
+ svg.append(line);
+ (this.editor.surfaceElement() || this.canvas).append(svg);
+ return { svg, line };
+ }
+
+ destroy() {
+ if (this.marqueeMouseDownHandler) {
+ this.canvas.removeEventListener("mousedown", this.marqueeMouseDownHandler);
+ this.marqueeMouseDownHandler = null;
+ }
+ if (this.activeMarqueeCleanup) this.activeMarqueeCleanup();
+ if (this.canvasPanPointerDownHandler) {
+ this.canvas.removeEventListener("pointerdown", this.canvasPanPointerDownHandler);
+ this.canvasPanPointerDownHandler = null;
+ }
+ if (this.activeCanvasPanCleanup) this.activeCanvasPanCleanup();
+ }
+}
diff --git a/static/cmap/controller/cmap-selection-controller.js b/static/cmap/controller/cmap-selection-controller.js
new file mode 100644
index 0000000..4692fce
--- /dev/null
+++ b/static/cmap/controller/cmap-selection-controller.js
@@ -0,0 +1,532 @@
+"use strict";
+
+import { debug, normalizeConceptTags } from "../cmap-utils.js";
+
+let copiedConceptReferences = [];
+
+export class CmapSelectionController {
+ constructor(editor) {
+ this.editor = editor;
+ }
+
+ get items() { return this.editor.items; }
+ get connectors() { return this.editor.connectors; }
+ get selectedItem() { return this.editor.selectedItem; }
+ set selectedItem(val) { this.editor.selectedItem = val; }
+ get selectedItems() { return this.editor.selectedItems; }
+ get selectedConnector() { return this.editor.selectedConnector; }
+ set selectedConnector(val) { this.editor.selectedConnector = val; }
+
+ selectItem(record, options = {}) {
+ if (!record) {
+ this.clearSelection();
+ return;
+ }
+ const additive = Boolean(options.additive);
+ const toggle = Boolean(options.toggle);
+ const groupRecords = record.groupId && options.expandGroup !== false ?
+ this.items.filter((item) => item.groupId === record.groupId && this.editor.isItemVisible(item)) :
+ [record];
+ debug("selectItem called", {
+ requestedId: record.id,
+ requestedKind: record.kind,
+ additive,
+ groupId: record.groupId,
+ previousIds: this.selectedAll().map((item) => item.id)
+ });
+
+ if (!additive) this.clearSelection(false);
+ const remove = toggle && groupRecords.every((item) => this.selectedItems.has(item));
+ for (const item of groupRecords) {
+ if (remove) {
+ this.selectedItems.delete(item);
+ } else {
+ this.selectedItems.add(item);
+ }
+ }
+
+ this.selectedItem = remove ? (this.selectedAll().at(-1) || null) : record;
+ this.selectedConnector = null;
+ this.refreshSelectionDecoration();
+ debug("selection applied", {
+ selectedId: this.selectedItem ? this.selectedItem.id : null,
+ selectedIds: this.selectedAll().map((item) => item.id),
+ selectionCount: this.selectedItems.size
+ });
+ this.notifySelection();
+ }
+
+ refreshSelectionDecoration() {
+ for (const item of this.items) {
+ const element = item.node.element();
+ const selected = this.selectedItems.has(item);
+ if (element) {
+ element.classList.toggle("rw-cmap-selected", selected);
+ element.classList.toggle(
+ "rw-cmap-selected-primary", selected && item === this.selectedItem);
+ if (selected) {
+ element.setAttribute("aria-selected", "true");
+ item.node.toFront();
+ } else {
+ element.removeAttribute("aria-selected");
+ }
+ this.editor.removeHandles(element);
+ if (selected && item === this.selectedItem) this.editor.ensureHandles(item, element);
+ }
+ if (item.kind === "submap") this.editor.updateSubmapFrame(item);
+ }
+ this.editor.submaps.refreshGroupSelection();
+ }
+
+ selectConnector(record) {
+ this.clearSelection();
+ this.selectedConnector = record;
+ record.link.attr({ lineColor: "#4f5ee8", lineWidth: 4 });
+ record.link.redraw();
+ this.notifySelection();
+ }
+
+ clearSelection(notify = true) {
+ const clearedItemIds = this.selectedAll().map((item) => item.id);
+ const clearedConnectorId = this.selectedConnector ? this.selectedConnector.id : null;
+ for (const item of this.selectedItems) {
+ const element = item.node.element();
+ if (element) {
+ element.classList.remove("rw-cmap-selected");
+ element.classList.remove("rw-cmap-selected-primary");
+ element.removeAttribute("aria-selected");
+ this.editor.removeHandles(element);
+ }
+ }
+ if (this.selectedConnector) {
+ const connector = this.selectedConnector;
+ connector.link.attr({ lineColor: connector.lineColor, lineWidth: connector.lineWidth });
+ connector.link.redraw();
+ }
+ this.selectedItem = null;
+ this.selectedItems.clear();
+ this.selectedConnector = null;
+ if (clearedItemIds.length || clearedConnectorId) {
+ debug("selection cleared", { itemIds: clearedItemIds, connectorId: clearedConnectorId });
+ }
+ if (notify) this.notifySelection();
+ }
+
+ selected() {
+ return this.selectedItem;
+ }
+
+ selectedAll() {
+ return Array.from(this.selectedItems);
+ }
+
+ storeConceptReferences(records) {
+ if (!records.length) return 0;
+ const referencesById = new Map(records.map((source) =>
+ [source.conceptId, {
+ conceptId: source.conceptId,
+ kind: source.kind === "submap" ? "concept" : source.kind,
+ label: source.label,
+ synopsis: source.synopsis,
+ aspects: Array.isArray(source.aspects) ? [...source.aspects] : [],
+ tags: normalizeConceptTags(source.tags).map((tag) => ({ ...tag })),
+ descriptionPageSlug: source.descriptionPageSlug,
+ pageSlug: source.pageSlug,
+ cmapSlug: source.cmapSlug,
+ externalUrl: source.externalUrl,
+ imageSource: source.imageSource,
+ width: Number(source.node.attr("width")),
+ height: Number(source.node.attr("height")),
+ backgroundColor: source.backgroundColor,
+ borderColor: source.borderColor,
+ textColor: source.textColor,
+ fontFamily: source.fontFamily,
+ fontSize: source.fontSize,
+ fontWeight: source.fontWeight,
+ fontStyle: source.fontStyle,
+ synopsisTextColor: source.synopsisTextColor,
+ synopsisFontFamily: source.synopsisFontFamily,
+ synopsisFontSize: source.synopsisFontSize,
+ synopsisFontWeight: source.synopsisFontWeight,
+ synopsisFontStyle: source.synopsisFontStyle
+ }]));
+ copiedConceptReferences = Array.from(referencesById.values());
+ return copiedConceptReferences.length;
+ }
+
+ copySelectionReferences() {
+ const selected = this.selectedAll()
+ .filter((item) => item.conceptId && item.kind !== "phrase");
+ const copied = this.storeConceptReferences(selected);
+ if (!copied) return 0;
+ this.notifySelection();
+ return copied;
+ }
+
+ canCutSelectionReferences() {
+ return this.selectedAll().some((item) =>
+ item !== this.editor.activeMapRoot && item.conceptId && item.kind !== "phrase");
+ }
+
+ cutSelectionReferences() {
+ const cuttable = this.selectedAll().filter((item) =>
+ item !== this.editor.activeMapRoot && item.conceptId && item.kind !== "phrase");
+ if (!cuttable.length) return 0;
+ const copied = this.storeConceptReferences(cuttable);
+ if (!copied) return 0;
+ this.clearSelection(false);
+ for (const record of cuttable) this.selectedItems.add(record);
+ this.selectedItem = cuttable.at(-1) || null;
+ this.deleteSelection();
+ return copied;
+ }
+
+ canPasteConceptReferences() {
+ return copiedConceptReferences.length > 0;
+ }
+
+ pasteConceptReferences() {
+ const sources = copiedConceptReferences;
+ if (!sources.length) return [];
+ this.clearSelection(false);
+ const parentSubmap = this.editor.activeMapRoot || null;
+ const pasted = sources.map((source, index) => this.editor.addItem({
+ conceptId: source.conceptId,
+ kind: source.kind === "submap" ? "concept" : source.kind,
+ label: source.label,
+ synopsis: source.synopsis,
+ aspects: source.aspects,
+ tags: source.tags,
+ descriptionPageSlug: source.descriptionPageSlug,
+ pageSlug: source.pageSlug,
+ cmapSlug: source.cmapSlug,
+ externalUrl: source.externalUrl,
+ parentCmapLink: false,
+ imageSource: source.imageSource,
+ parentSubmap,
+ submapDepth: parentSubmap ? parentSubmap.submapDepth + 1 : 0,
+ x: 120 + (index * 36),
+ y: 120 + (index * 36),
+ width: source.width,
+ height: source.height,
+ backgroundColor: source.backgroundColor,
+ borderColor: source.borderColor,
+ textColor: source.textColor,
+ fontFamily: source.fontFamily,
+ fontSize: source.fontSize,
+ fontWeight: source.fontWeight,
+ fontStyle: source.fontStyle,
+ synopsisTextColor: source.synopsisTextColor,
+ synopsisFontFamily: source.synopsisFontFamily,
+ synopsisFontSize: source.synopsisFontSize,
+ synopsisFontWeight: source.synopsisFontWeight,
+ synopsisFontStyle: source.synopsisFontStyle
+ }));
+ for (const record of pasted) this.selectedItems.add(record);
+ this.selectedItem = pasted.at(-1) || null;
+ this.refreshSelectionDecoration();
+ this.notifySelection();
+ return pasted;
+ }
+
+ selectAll() {
+ this.clearSelection(false);
+ for (const item of this.items) {
+ if (this.editor.isEffectiveItemVisible(item)) this.selectedItems.add(item);
+ }
+ this.selectedItem = this.selectedAll().at(-1) || null;
+ this.refreshSelectionDecoration();
+ this.notifySelection();
+ return this.selectedAll();
+ }
+
+ layoutSelectionRecords() {
+ return this.selectedAll().filter((item) => this.editor.isEffectiveItemVisible(item));
+ }
+
+ canLayoutSelection(command) {
+ const minimum = ["distribute-horizontal", "distribute-vertical"].includes(command) ? 3 : 2;
+ return this.layoutSelectionRecords().length >= minimum;
+ }
+
+ applySelectionLayout(command) {
+ const records = this.layoutSelectionRecords();
+ if (!this.canLayoutSelection(command)) return false;
+ const boxes = records.map((record) => ({
+ record,
+ x: Number(record.node.attr("x")),
+ y: Number(record.node.attr("y")),
+ width: Number(record.node.attr("width")),
+ height: Number(record.node.attr("height"))
+ }));
+ const reference = boxes.find((box) => box.record === this.selectedItem) || boxes.at(-1);
+ const updates = new Map(boxes.map(({ record }) => [record, {}]));
+ const referenceRight = reference.x + reference.width;
+ const referenceCenter = reference.x + (reference.width / 2);
+ const referenceBottom = reference.y + reference.height;
+ const referenceMiddle = reference.y + (reference.height / 2);
+
+ if (["same-width", "same-size"].includes(command)) {
+ for (const box of boxes) updates.get(box.record).width = reference.width;
+ }
+ if (["same-height", "same-size"].includes(command)) {
+ for (const box of boxes) updates.get(box.record).height = reference.height;
+ }
+ if (command === "align-left") {
+ for (const box of boxes) updates.get(box.record).x = reference.x;
+ }
+ if (command === "align-right") {
+ for (const box of boxes) updates.get(box.record).x = referenceRight - box.width;
+ }
+ if (command === "align-center") {
+ for (const box of boxes) updates.get(box.record).x = referenceCenter - (box.width / 2);
+ }
+ if (command === "align-top") {
+ for (const box of boxes) updates.get(box.record).y = reference.y;
+ }
+ if (command === "align-bottom") {
+ for (const box of boxes) updates.get(box.record).y = referenceBottom - box.height;
+ }
+ if (command === "align-middle") {
+ for (const box of boxes) updates.get(box.record).y = referenceMiddle - (box.height / 2);
+ }
+ if (command === "distribute-horizontal") {
+ const ordered = [...boxes].sort((a, b) =>
+ (a.x + (a.width / 2)) - (b.x + (b.width / 2)) ||
+ String(a.record.id).localeCompare(String(b.record.id)));
+ const distributionLeft = ordered[0].x;
+ const distributionRight = ordered.at(-1).x + ordered.at(-1).width;
+ const occupiedWidth = ordered.reduce((sum, box) => sum + box.width, 0);
+ const gap = (distributionRight - distributionLeft - occupiedWidth) / (ordered.length - 1);
+ let cursor = distributionLeft;
+ for (const box of ordered) {
+ updates.get(box.record).x = cursor;
+ cursor += box.width + gap;
+ }
+ }
+ if (command === "distribute-vertical") {
+ const ordered = [...boxes].sort((a, b) =>
+ (a.y + (a.height / 2)) - (b.y + (b.height / 2)) ||
+ String(a.record.id).localeCompare(String(b.record.id)));
+ const distributionTop = ordered[0].y;
+ const distributionBottom = ordered.at(-1).y + ordered.at(-1).height;
+ const occupiedHeight = ordered.reduce((sum, box) => sum + box.height, 0);
+ const gap = (distributionBottom - distributionTop - occupiedHeight) / (ordered.length - 1);
+ let cursor = distributionTop;
+ for (const box of ordered) {
+ updates.get(box.record).y = cursor;
+ cursor += box.height + gap;
+ }
+ }
+
+ if (!["same-width", "same-height", "same-size", "align-left", "align-right",
+ "align-center", "align-top", "align-bottom", "align-middle",
+ "distribute-horizontal", "distribute-vertical"].includes(command)) return false;
+
+ this.editor.scheduleHistoryCommit();
+ for (const record of records) {
+ const attributes = updates.get(record);
+ if (attributes.width !== undefined) {
+ record.width = attributes.width;
+ record.autoWidth = false;
+ }
+ if (attributes.height !== undefined) {
+ record.height = attributes.height;
+ record.autoHeight = false;
+ }
+ record.node.attr(attributes);
+ record.node.redraw();
+ }
+ this.editor.saveCurrentContextLayout();
+ this.editor.refreshConnectorGeometry();
+ for (const submap of this.items
+ .filter((item) => item.kind === "submap")
+ .sort((a, b) => b.submapDepth - a.submapDepth)) {
+ this.editor.updateSubmapFrame(submap);
+ }
+ this.refreshSelectionDecoration();
+ debug("selection layout applied", {
+ command,
+ referenceItemId: reference.record.id,
+ itemIds: records.map((record) => record.id)
+ });
+ return true;
+ }
+
+ canGroupSelection() {
+ const selected = this.selectedAll();
+ return selected.length >= 2 &&
+ selected.every((item) => item.parentSubmap === selected[0].parentSubmap);
+ }
+
+ groupSelection(options = {}) {
+ const selected = this.selectedAll();
+ if (!this.canGroupSelection()) return false;
+ const parentSubmap = selected[0].parentSubmap;
+ const left = Math.min(...selected.map((item) => Number(item.node.attr("x"))));
+ const top = Math.min(...selected.map((item) => Number(item.node.attr("y"))));
+ const label = String(options.label || "Sub-conceptmap").trim() || "Sub-conceptmap";
+ const submap = this.editor.addItem({
+ ...options,
+ kind: "submap",
+ label,
+ childMap: options.childMap || label,
+ synopsis: options.synopsis || "Grouped sub-concept map.",
+ parentSubmap,
+ submapDepth: parentSubmap ? parentSubmap.submapDepth + 1 : 0,
+ x: (options.x === undefined || options.x === null) ? left : Number(options.x),
+ y: (options.y === undefined || options.y === null) ? Math.max(20, top - 105) : Number(options.y),
+ backgroundColor: options.backgroundColor || "#edf7e8",
+ borderColor: options.borderColor || "#57834a"
+ });
+
+ submap.expanded = true;
+ submap.submapInitialized = true;
+ for (const item of selected) {
+ item.groupId = null;
+ item.parentSubmap = submap;
+ this.editor.updateSubmapDepth(item, submap.submapDepth + 1);
+ }
+ this.editor.reconcilePhraseMembership();
+ this.editor.refreshConceptMapReferences();
+ this.editor.applyCurrentContextLayout();
+ this.selectItem(submap);
+ debug("selection grouped as submap", {
+ submapId: submap.id,
+ itemIds: selected.map((item) => item.id)
+ });
+ return submap;
+ }
+
+ canUngroupSelection() {
+ return this.selectedAll().some((item) =>
+ Boolean(item.groupId) ||
+ (item.kind === "submap" && !item.separateMap) ||
+ Boolean(item.parentSubmap && item.parentSubmap !== this.editor.activeMapRoot));
+ }
+
+ ungroupSelection() {
+ const groupIds = new Set(this.selectedAll().map((item) => item.groupId).filter(Boolean));
+ const affected = this.items.filter((item) => groupIds.has(item.groupId));
+ for (const item of affected) item.groupId = null;
+
+ const selected = this.selectedAll();
+ const selectedSubmaps = new Set(selected.filter((item) =>
+ item.kind === "submap" && !item.separateMap));
+ const liftedChildren = new Set();
+ for (const submap of selectedSubmaps) {
+ const parent = submap.parentSubmap;
+ const children = this.items.filter((item) => item.parentSubmap === submap);
+ for (const child of children) {
+ liftedChildren.add(child);
+ child.parentSubmap = parent;
+ this.editor.updateSubmapDepth(child, parent ? parent.submapDepth + 1 : 0);
+ }
+ submap.expanded = false;
+ submap.submapInitialized = false;
+ submap.childMap = null;
+ this.editor.updateItem(submap, { kind: "concept" });
+ affected.push(submap, ...children);
+ }
+
+ for (const item of selected) {
+ if (selectedSubmaps.has(item) || liftedChildren.has(item) || !item.parentSubmap ||
+ item.parentSubmap === this.editor.activeMapRoot) continue;
+ const parent = item.parentSubmap.parentSubmap;
+ item.parentSubmap = parent;
+ this.editor.updateSubmapDepth(item, parent ? parent.submapDepth + 1 : 0);
+ affected.push(item);
+ }
+
+ if (!affected.length) return false;
+ this.editor.reconcilePhraseMembership();
+ this.editor.refreshConceptMapReferences();
+ this.editor.refreshSubmapVisibility();
+ this.refreshSelectionDecoration();
+ debug("items ungrouped", { itemIds: Array.from(new Set(affected)).map((item) => item.id) });
+ this.notifySelection();
+ this.editor.scheduleHistoryCommit();
+ return true;
+ }
+
+ deleteSelection() {
+ const records = new Set(this.selectedAll().filter((item) => item !== this.editor.activeMapRoot));
+ for (const record of Array.from(records)) {
+ if (record.kind === "submap") {
+ for (const item of this.items) {
+ if (this.editor.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.editor.model.conceptMap.removeConnector(connector.id);
+ }
+ this.editor.connectors = this.connectors.filter((connector) => !connectors.has(connector));
+ for (const record of records) {
+ if (record.mapReference && record.mapReference.id) this.editor.conceptMaps.delete(record.mapReference.id);
+ record.node.remove();
+ this.editor.model.conceptMap.removeItem(record.id);
+ }
+ if (records.size && this.editor.unresolvedConnectors.length) {
+ const deletedIds = new Set(Array.from(records).map((record) => Number(record.id)));
+ this.editor.unresolvedConnectors = this.editor.unresolvedConnectors.filter((connector) =>
+ !deletedIds.has(Number(connector.sourceId)) && !deletedIds.has(Number(connector.targetId)));
+ }
+ this.editor.items = this.items.filter((item) => !records.has(item));
+ this.editor.refreshConceptUsageIndicators(Array.from(records).map((record) => record.conceptId));
+ this.editor.reconcilePhraseMembership();
+ this.editor.refreshConceptMapReferences();
+ this.editor.refreshSubmapVisibility();
+ this.notifySelection();
+ debug("selection deleted", {
+ itemIds: Array.from(records).map((item) => item.id),
+ connectorIds: Array.from(connectors).map((connector) => connector.id)
+ });
+ this.editor.scheduleHistoryCommit();
+ return true;
+ }
+
+ notifySelection() {
+ if (this.editor.onSelectionChange) {
+ this.editor.onSelectionChange(this.selectedItem, this.selectedConnector, this.selectedAll());
+ }
+ }
+}
diff --git a/static/cmap/controller/cmap-submap-controller.js b/static/cmap/controller/cmap-submap-controller.js
new file mode 100644
index 0000000..eb069c1
--- /dev/null
+++ b/static/cmap/controller/cmap-submap-controller.js
@@ -0,0 +1,340 @@
+/**
+ * Coordinates submap membership, navigation and visibility for the wiki editor.
+ *
+ * The editor remains responsible for item storage and drawing. This controller
+ * owns the rules for the active map context and delegates rendering and model
+ * synchronization through the supplied editor instance.
+ */
+export class CmapSubmapController {
+ /**
+ * goal : Create the controller for one wiki CMap editor.
+ * pre : editor owns the items, view, model and editor callbacks.
+ * post : Submap operations can delegate rendering and model work to editor.
+ * result : A CmapSubmapController instance.
+ * internals : The controller keeps no duplicate item state; its accessors
+ * read the editor's active context and map history when an operation runs.
+ *
+ * @param {object} editor The editor facade that owns items and rendering.
+ */
+ constructor(editor) {
+ this.editor = editor;
+ this.diagramGroups = new Map();
+ }
+
+ get items() { return this.editor.items; }
+ get activeMapRoot() { return this.editor.activeMapRoot; }
+ set activeMapRoot(value) { this.editor.activeMapRoot = value; }
+ get mapHistory() { return this.editor.mapHistory; }
+ get onMapChange() { return this.editor.onMapChange; }
+
+ /** Return whether record is nested below submap. */
+ isDescendantOf(record, submap) {
+ let parent = record.parentSubmap;
+ while (parent) {
+ if (parent === submap) return true;
+ parent = parent.parentSubmap;
+ }
+ return false;
+ }
+
+ /** Return whether record belongs to the currently opened map context. */
+ itemInsideActiveMap(record) {
+ return Boolean(this.activeMapRoot &&
+ (record === this.activeMapRoot || this.isDescendantOf(record, this.activeMapRoot)));
+ }
+
+ /** Return the persistence key for the active map context. */
+ mapContextKey(root = this.activeMapRoot) {
+ return root && root.mapReference && root.mapReference.id ? root.mapReference.id : "root";
+ }
+
+ /**
+ * Determine visibility from hidden contexts, active root and expanded parents.
+ * The result controls both item rendering and connector endpoint projection.
+ */
+ isItemVisible(record) {
+ const context = this.mapContextKey();
+ if (record !== this.activeMapRoot && record.hiddenContexts.has(context)) return false;
+ if (this.activeMapRoot) {
+ if (record === this.activeMapRoot) return true;
+ if (!this.isDescendantOf(record, this.activeMapRoot)) return false;
+ let parent = record.parentSubmap;
+ while (parent && parent !== this.activeMapRoot) {
+ if (!parent.expanded) return false;
+ parent = parent.parentSubmap;
+ }
+ return parent === this.activeMapRoot;
+ }
+
+ let parent = record.parentSubmap;
+ while (parent) {
+ if (!parent.expanded) return false;
+ parent = parent.parentSubmap;
+ }
+ return true;
+ }
+
+ /** Return hidden non-phrase items ordered for the visibility picker. */
+ hiddenItemsInCurrentContext() {
+ const context = this.mapContextKey();
+ return this.items
+ .filter((item) => item !== this.activeMapRoot && item.kind !== "phrase" &&
+ item.hiddenContexts.has(context))
+ .sort((left, right) => left.label.localeCompare(right.label));
+ }
+
+ /** Return whether the current selection contains an item that can be hidden. */
+ canHideSelectionInCurrentContext() {
+ return !this.activeMapRoot && this.editor.selectedAll().some((item) => item.parentSubmap &&
+ item.kind !== "phrase" && this.isItemVisible(item));
+ }
+
+ /** Hide selected child concepts in the current root context. */
+ hideSelectionInCurrentContext() {
+ const context = this.mapContextKey();
+ if (this.activeMapRoot) return false;
+ const selected = this.editor.selectedAll().filter((item) => item.parentSubmap &&
+ item.kind !== "phrase" && this.isItemVisible(item));
+ if (!selected.length) return false;
+ this.editor.scheduleHistoryCommit();
+ for (const item of selected) item.hiddenContexts.add(context);
+ this.editor.clearSelection();
+ this.refreshVisibility();
+ if (this.editor.onVisibilityChange) {
+ this.editor.onVisibilityChange(this.hiddenItemsInCurrentContext());
+ }
+ return true;
+ }
+
+ /** Show one item again in the current root context. */
+ showItemInCurrentContext(record) {
+ if (!record) return false;
+ const context = this.mapContextKey();
+ if (!record.hiddenContexts.has(context)) return false;
+ this.editor.scheduleHistoryCommit();
+ record.hiddenContexts.delete(context);
+ this.refreshVisibility();
+ if (this.editor.onVisibilityChange) {
+ this.editor.onVisibilityChange(this.hiddenItemsInCurrentContext());
+ }
+ return true;
+ }
+
+ /** Populate a lazy submap once, then reuse its editor records. */
+ ensureSubmapContents(record) {
+ if (record.submapInitialized) return;
+ record.submapInitialized = true;
+ if (this.editor.onPopulateSubMap) this.editor.onPopulateSubMap(record, this.editor);
+ }
+
+ /** Expand/collapse a submap or open its separate map representation. */
+ toggleSubmap(record, expanded = !record.expanded) {
+ if (!record || record.kind !== "submap") return false;
+ if (record.separateMap && !record.cmapSlug) {
+ record.expanded = false;
+ if (record === this.activeMapRoot) {
+ this.refreshVisibility();
+ return false;
+ }
+ return this.openSubmapMap(record);
+ }
+ this.editor.saveCurrentContextLayout();
+ if (expanded) this.ensureSubmapContents(record);
+ record.expanded = Boolean(expanded);
+ if (record.expanded) this.editor.applyCurrentContextLayout();
+ else this.refreshVisibility();
+ const element = record.node.element();
+ if (element) this.editor.ensureSubmapToggle(record, element);
+ if (this.editor.onOpenSubMap) this.editor.onOpenSubMap(record, record.expanded);
+ this.editor.scheduleHistoryCommit();
+ return record.expanded;
+ }
+
+ /** Open a separate submap and preserve the previous map on the navigation stack. */
+ openSubmapMap(record) {
+ if (!record || record.kind !== "submap" || !record.separateMap) return false;
+ if (record === this.activeMapRoot) return true;
+ this.ensureSubmapContents(record);
+ this.editor.clearSelection();
+ this.editor.saveCurrentContextLayout();
+ if (this.activeMapRoot) this.mapHistory.push(this.activeMapRoot);
+ this.activeMapRoot = record;
+ this.editor.applyCurrentContextLayout();
+ if (this.onMapChange) this.onMapChange(record.mapReference, record);
+ return true;
+ }
+
+ /** Return from a child context to the root map. */
+ openRootMap() {
+ if (!this.activeMapRoot) return false;
+ this.editor.clearSelection();
+ this.editor.saveCurrentContextLayout();
+ this.activeMapRoot = null;
+ this.editor.mapHistory = [];
+ this.editor.applyCurrentContextLayout();
+ if (this.onMapChange) this.onMapChange(null, null);
+ return true;
+ }
+
+ /** Return whether a parent context is available. */
+ canStepBackWithinMap() {
+ return this.mapHistory.length > 0;
+ }
+
+ /** Open exactly one parent context from the map navigation stack. */
+ openParentMap() {
+ if (!this.activeMapRoot) return false;
+ this.editor.clearSelection();
+ this.editor.saveCurrentContextLayout();
+ this.activeMapRoot = this.editor.mapHistory.pop() || null;
+ this.editor.applyCurrentContextLayout();
+ const reference = this.activeMapRoot ? this.activeMapRoot.mapReference : null;
+ if (this.onMapChange) this.onMapChange(reference, this.activeMapRoot);
+ return true;
+ }
+
+ /** Promote an embedded submap into a separately addressable CMap reference. */
+ promoteSubmap(record, name) {
+ if (!record || record.kind !== "submap") return null;
+ this.ensureSubmapContents(record);
+ record.childMap = String(name || record.label).trim() || record.label;
+ record.separateMap = true;
+ record.mapReference = {
+ id: `cmap-${record.id}`,
+ title: record.childMap,
+ rootItemId: record.id,
+ itemIds: this.items
+ .filter((item) => this.isDescendantOf(item, record))
+ .map((item) => item.id)
+ };
+ this.editor.conceptMaps.set(record.mapReference.id, record.mapReference);
+ record.expanded = false;
+ this.editor.updateItem(record, { synopsis: `Concept map: ${record.childMap}` });
+ if (this.editor.onSubMapPromoted) this.editor.onSubMapPromoted(record);
+ this.refreshVisibility();
+ return record.mapReference;
+ }
+
+ /** Prepare a submap model for storage as a separate CMap. */
+ prepareStoredSubmapExtraction(record, targetSlug, childMetadata = null) {
+ if (!record || record.kind !== "submap") return null;
+ this.ensureSubmapContents(record);
+ return this.editor.synchronizeModel().extractSubmap(record.id, targetSlug, childMetadata);
+ }
+
+ /** Apply one submap record's current frame appearance immediately. */
+ updateGroupAppearance(record) {
+ const group = this.diagramGroups.get(record);
+ if (!group) return false;
+ group.setAppearance({
+ label: record.label,
+ backgroundColor: record.submapBackgroundColor || "#edf7e8",
+ borderColor: record.submapBorderColor || "#57834a"
+ });
+ return true;
+ }
+
+ /**
+ * Synchronize wiki submap membership with generic engine groups.
+ * Groups own the frame DOM; the editor remains responsible for wiki actions.
+ */
+ refreshGroups() {
+ if (!this.editor.map || typeof this.editor.map.group !== "function") return;
+ const submaps = this.items
+ .filter((item) => item.kind === "submap")
+ .sort((left, right) => right.submapDepth - left.submapDepth);
+ const current = new Set(submaps);
+ for (const [record, group] of this.diagramGroups) {
+ if (!current.has(record)) {
+ group.destroy();
+ this.diagramGroups.delete(record);
+ }
+ }
+ for (const record of submaps) {
+ if (this.diagramGroups.has(record)) continue;
+ const group = this.editor.map.group({
+ label: record.label,
+ className: "rw-cmap-submap-frame",
+ backgroundColor: record.submapBackgroundColor || "#edf7e8",
+ borderColor: record.submapBorderColor || "#57834a",
+ padding: 34,
+ depth: record.submapDepth,
+ expanded: record.expanded,
+ manageVisibility: false,
+ onPointerDown: (event) => {
+ if (event.target?.closest?.(".rw-cmap-submap-frame-toggle")) return;
+ this.editor.startSubmapFrameDrag(event, record);
+ },
+ onDoubleClick: (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ this.editor.selectItem(record);
+ if (this.editor.onEditItem) this.editor.onEditItem(record);
+ }
+ });
+ group.onToggle((_group, expanded) => this.toggleSubmap(record, expanded));
+ this.diagramGroups.set(record, group);
+ }
+ for (const record of submaps) {
+ const group = this.diagramGroups.get(record);
+ for (const member of [...group.members]) group.remove(member);
+ group.expanded = Boolean(record.expanded && record !== this.activeMapRoot &&
+ this.isItemVisible(record));
+ for (const child of this.items.filter((item) => item.parentSubmap === record)) {
+ if (child.kind === "submap") {
+ const childGroup = this.diagramGroups.get(child);
+ if (childGroup) group.add(childGroup);
+ }
+ // A nested submap's anchor is part of this group's layout. Its child
+ // group is added separately so that the nested contents get their own frame.
+ if (child.node) group.add(child.node);
+ }
+ group.setAppearance({
+ label: record.label,
+ backgroundColor: record.submapBackgroundColor || "#edf7e8",
+ borderColor: record.submapBorderColor || "#57834a"
+ });
+ group.depth = record.submapDepth;
+ group.redraw();
+ const element = group.element();
+ if (element) {
+ element.classList.toggle("rw-cmap-submap-frame-selected",
+ this.editor.selectedItems.has(record));
+ element.classList.toggle("rw-cmap-submap-frame-selected-primary",
+ this.editor.selectedItems.has(record) && this.editor.selectedItem === record);
+ }
+ this.editor.updateSubmapAnchorLine(record, group.bounds());
+ }
+ }
+
+ /** Update selection styling on already rendered group frames. */
+ refreshGroupSelection() {
+ for (const [record, group] of this.diagramGroups) {
+ const element = group.element();
+ if (!element) continue;
+ element.classList.toggle("rw-cmap-submap-frame-selected",
+ this.editor.selectedItems.has(record));
+ element.classList.toggle("rw-cmap-submap-frame-selected-primary",
+ this.editor.selectedItems.has(record) && this.editor.selectedItem === record);
+ }
+ }
+
+ /**
+ * Reconcile item visibility, projected connector endpoints and submap frames.
+ * The editor still owns the drawing operations; this method coordinates their
+ * order after a context or membership change.
+ */
+ refreshVisibility() {
+ for (const item of this.items) item.node.visible(this.editor.isEffectiveItemVisible(item));
+ for (const connector of this.editor.connectors) {
+ this.editor.applyConnectorVisualEndpoints(connector,
+ this.editor.connectorEndpoint(connector.source),
+ this.editor.connectorEndpoint(connector.target));
+ }
+ this.refreshGroups();
+ for (const submap of this.items.filter((item) => item.kind === "submap")) {
+ const element = submap.node.element();
+ if (element) this.editor.ensureSubmapToggle(submap, element);
+ }
+ }
+}
diff --git a/static/cmap/engine/diagram-component.js b/static/cmap/engine/diagram-component.js
new file mode 100644
index 0000000..b02b8f6
--- /dev/null
+++ b/static/cmap/engine/diagram-component.js
@@ -0,0 +1,116 @@
+/**
+ * Public handle for one component rendered by a DiagramEngine.
+ *
+ * A handle exposes only the operations used by the CMap editor. The drawing
+ * object remains private to the engine so application code cannot depend on
+ * the representation inherited from the original renderer.
+ */
+export class DiagramComponent {
+ constructor(engine, component, attributeNames) {
+ this.engine = engine;
+ this.component = component;
+ this.attributeNames = attributeNames;
+ this.baseVisible = true;
+ }
+
+ /** Read or update the supported rendering attributes. */
+ attr(name, value) {
+ if (name === undefined) {
+ const attributes = {};
+ for (const attributeName of this.attributeNames) {
+ attributes[attributeName] = this.component[attributeName]();
+ }
+ return attributes;
+ }
+
+ if (isPlainObject(name)) {
+ for (const [attributeName, attributeValue] of Object.entries(name)) {
+ this.attr(attributeName, attributeValue);
+ }
+ return this;
+ }
+
+ if (!this.attributeNames.includes(name)) return this;
+ if (value === undefined) return this.component[name]();
+
+ this.component[name](value);
+ return this;
+ }
+
+ /** Remove this component from its diagram. */
+ remove() {
+ this.engine.removeComponent(this);
+ }
+
+ /** Move this component to the front of its own rendering band. */
+ toFront() {
+ this.engine.drawingSurface.toFront(this.component);
+ return this;
+ }
+
+ /** Return the concrete element currently rendering this component. */
+ element() {
+ return this.component.element();
+ }
+
+ /** Immediately render the current component state. */
+ redraw() {
+ this.component.redraw();
+ return this;
+ }
+
+ /** Read or change whether the component participates in the presentation. */
+ visible(value) {
+ if (value === undefined) return this.component.visible !== false;
+
+ this.baseVisible = Boolean(value);
+ this.engine.applyFilter(this);
+ return this.component.visible;
+ }
+
+ /** Register a callback invoked after this component has been rendered. */
+ onRendered(handler) {
+ validateOptionalHandler(handler, "render");
+ this.component.renderedHandler = handler ?
+ (element) => handler(this, element) : null;
+
+ if (handler && this.component.element()) handler(this, this.component.element());
+ return this;
+ }
+
+ /** Read or change whether the component can be dragged. */
+ draggable(enabled) {
+ if (enabled === undefined) {
+ return this.engine.drawingSurface.dragEnabled(this.component);
+ }
+
+ if (enabled) this.engine.drawingSurface.enableDrag(this.component);
+ else this.engine.drawingSurface.disableDrag(this.component);
+ return this;
+ }
+}
+
+/** Return a new object containing only supported input attributes. */
+export function pickAttributes(attributes, names) {
+ if (attributes === undefined) return {};
+ if (!isPlainObject(attributes)) throw new TypeError("Invalid component attributes");
+
+ const selected = {};
+ for (const name of names) {
+ if (name in attributes) selected[name] = attributes[name];
+ }
+ return selected;
+}
+
+/** Validate an optional event handler at the public engine boundary. */
+export function validateOptionalHandler(handler, meaning) {
+ if (handler !== null && handler !== undefined && typeof handler !== "function") {
+ throw new TypeError(`Invalid ${meaning} handler`);
+ }
+}
+
+/** Determine whether a value is a plain attributes object. */
+function isPlainObject(value) {
+ return typeof value === "object" && value !== null &&
+ Object.prototype.toString.call(value) === "[object Object]";
+}
diff --git a/static/cmap/engine/diagram-engine.js b/static/cmap/engine/diagram-engine.js
new file mode 100644
index 0000000..75af8f7
--- /dev/null
+++ b/static/cmap/engine/diagram-engine.js
@@ -0,0 +1,184 @@
+import { DrawingSurface } from "./drawing-core.js";
+import { validateOptionalHandler } from "./diagram-component.js";
+import { DiagramLink } from "./diagram-link.js";
+import { DiagramNode } from "./diagram-node.js";
+import { DiagramGroup } from "./diagram-group.js";
+
+/**
+ * Render and interact with a diagram of nodes and links.
+ *
+ * DiagramEngine is the complete public boundary of the drawing engine. It
+ * translates low-level hit-test results to DiagramNode and DiagramLink
+ * handles and owns every rendering object's lifetime.
+ */
+export class DiagramEngine {
+ constructor(element) {
+ this.drawingSurface = new DrawingSurface(element);
+ this.handles = new Map();
+ this.selectionHandler = null;
+ this.activationHandler = null;
+ this.filter = null;
+ this.groups = new Set();
+ this.destroyed = false;
+
+ this.drawingSurface.selectionHandler = (component, event) => {
+ if (this.selectionHandler) {
+ this.selectionHandler(component ? this.handleFor(component) : null, event);
+ }
+ };
+ this.drawingSurface.activationHandler = (component, event) => {
+ if (this.activationHandler) {
+ this.activationHandler(component ? this.handleFor(component) : null, event);
+ }
+ };
+ }
+
+ /** Register a callback for a single hit-tested component selection. */
+ onSelection(handler) {
+ validateOptionalHandler(handler, "selection");
+ this.selectionHandler = handler || null;
+ return this;
+ }
+
+ /** Register a callback for activation of one hit-tested component. */
+ onActivation(handler) {
+ validateOptionalHandler(handler, "activation");
+ this.activationHandler = handler || null;
+ return this;
+ }
+
+ /** Create and render a node owned by this engine. */
+ node(attributes) {
+ this.ensureActive();
+ const node = new DiagramNode(this, attributes);
+ this.addComponent(node);
+ return node;
+ }
+
+ /** Create and render a link owned by this engine. */
+ link(attributes) {
+ this.ensureActive();
+ const link = new DiagramLink(this, attributes);
+ this.addComponent(link);
+ return link;
+ }
+
+ /**
+ * Create a generic group frame for nodes and nested groups.
+ * @returns {DiagramGroup} A group owned by this engine.
+ */
+ group(options) {
+ this.ensureActive();
+ const group = new DiagramGroup(this, options);
+ this.groups.add(group);
+ return group;
+ }
+
+ /**
+ * goal : Install a policy that controls component visibility.
+ * pre : filter is null or a function receiving a public component handle.
+ * post : All existing components use the new policy immediately.
+ * result : This engine, for fluent setup.
+ * internals : A policy may return a boolean or `{ visible }`; the component's
+ * own visibility remains the base value and is combined with the policy.
+ */
+ setFilter(filter) {
+ if (filter !== null && filter !== undefined && typeof filter !== "function") {
+ throw new TypeError("A diagram filter must be a function");
+ }
+ this.filter = filter || null;
+ for (const handle of this.handles.values()) this.applyFilter(handle);
+ for (const group of this.groups) group.redraw();
+ return this;
+ }
+
+ /** Read or update the diagram zoom factor. */
+ zoom(value) {
+ if (value === undefined) return this.drawingSurface.zoomFactor;
+
+ const factor = Number(value);
+ if (!Number.isFinite(factor) || factor <= 0) {
+ throw new TypeError("Invalid zoom factor");
+ }
+
+ this.drawingSurface.zoomFactor = factor;
+ const element = this.drawingSurface.element();
+ if (element) element.style.zoom = String(factor);
+ return factor;
+ }
+
+ /** Destroy the surface and all nodes and links created through this engine. */
+ destroy() {
+ if (this.destroyed) return;
+
+ const element = this.drawingSurface.element();
+ this.selectionHandler = null;
+ this.activationHandler = null;
+ this.drawingSurface.selectionHandler = null;
+ this.drawingSurface.activationHandler = null;
+ for (const group of this.groups) group.destroy();
+ this.groups.clear();
+ for (const component of this.drawingSurface.componentList().toArray()) {
+ component.dispose();
+ component.parentElement(null);
+ }
+ this.drawingSurface.dispose();
+ this.handles.clear();
+ this.destroyed = true;
+
+ if (element && element.parentNode) element.parentNode.removeChild(element);
+ }
+
+ /** Return the public handle associated with a low-level drawing object. */
+ handleFor(component) {
+ return this.handles.get(component) || null;
+ }
+
+ /** Add a newly constructed public component to the drawing surface. */
+ addComponent(handle) {
+ this.handles.set(handle.component, handle);
+ this.drawingSurface.add(handle.component);
+ this.applyFilter(handle);
+ }
+
+ /** Remove a public component and forget its low-level drawing object. */
+ removeComponent(handle) {
+ if (!handle || handle.engine !== this || !this.handles.has(handle.component)) return;
+ this.drawingSurface.remove(handle.component);
+ this.handles.delete(handle.component);
+ }
+
+ /** Return the DOM surface on which components and group frames are drawn. */
+ surfaceElement() {
+ return this.drawingSurface.element();
+ }
+
+ /** Apply the current base visibility and optional external filter to a handle. */
+ applyFilter(handle) {
+ const decision = this.filter ? this.filter(handle) : true;
+ let visible = typeof decision === "boolean" ? decision : decision?.visible !== false;
+ if (handle instanceof DiagramLink) {
+ const source = handle.sourceNode();
+ const target = handle.targetNode();
+ visible = visible && (!source || source.visible()) && (!target || target.visible());
+ }
+ handle.component.visible = handle.baseVisible && visible;
+ handle.component.redraw();
+ if (handle instanceof DiagramNode) {
+ for (const candidate of this.handles.values()) {
+ if (!(candidate instanceof DiagramLink)) continue;
+ if (candidate.sourceNode() === handle || candidate.targetNode() === handle) {
+ this.applyFilter(candidate);
+ }
+ }
+ }
+ for (const group of this.groups) {
+ if (group.members.has(handle)) group.redraw();
+ }
+ }
+
+ /** Reject operations after the engine and its DOM surface were destroyed. */
+ ensureActive() {
+ if (this.destroyed) throw new Error("The diagram engine has been destroyed");
+ }
+}
diff --git a/static/cmap/engine/diagram-group.js b/static/cmap/engine/diagram-group.js
new file mode 100644
index 0000000..69bab93
--- /dev/null
+++ b/static/cmap/engine/diagram-group.js
@@ -0,0 +1,204 @@
+import { DiagramComponent } from "./diagram-component.js";
+
+/**
+ * Render a nested group frame around diagram components.
+ *
+ * A group is a view-level container, not a domain model. It owns membership,
+ * expanded state and frame geometry while DiagramEngine continues to own the
+ * lifetime of nodes and links. Application code can map a wiki submap or any
+ * other grouping concept onto this generic abstraction.
+ */
+export class DiagramGroup {
+ /**
+ * goal : Create an empty diagram group attached to one engine.
+ * pre : engine is a DiagramEngine and options contains only presentation data.
+ * post : The group has no members and has not yet rendered a frame.
+ * result : A DiagramGroup instance.
+ * internals : Membership is kept as a Set so nested groups and repeated add
+ * operations remain deterministic; bounds are calculated from member handles.
+ */
+ constructor(engine, options = {}) {
+ if (!engine) throw new TypeError("A diagram engine is required");
+ this.engine = engine;
+ this.label = String(options.label || "");
+ this.className = String(options.className || "cmap-group-frame");
+ this.backgroundColor = options.backgroundColor || "transparent";
+ this.borderColor = options.borderColor || "#5d6d7e";
+ this.padding = Number.isFinite(Number(options.padding)) ? Number(options.padding) : 24;
+ this.depth = Number.isFinite(Number(options.depth)) ? Number(options.depth) : 0;
+ this.expanded = options.expanded !== false;
+ this.manageVisibility = options.manageVisibility !== false;
+ this.members = new Set();
+ this.elementValue = null;
+ this.onToggleHandler = null;
+ this.onPointerDownHandler = options.onPointerDown || null;
+ this.onDoubleClickHandler = options.onDoubleClick || null;
+ }
+
+ /**
+ * goal : Add one node or nested group to this group.
+ * pre : member belongs to the same DiagramEngine.
+ * post : The member contributes to group bounds and rendering.
+ * result : This group, for fluent setup.
+ */
+ add(member) {
+ if (!(member instanceof DiagramComponent) && !(member instanceof DiagramGroup)) {
+ throw new TypeError("A diagram group member is required");
+ }
+ if (member.engine !== this.engine) throw new TypeError("Group member belongs to another engine");
+ this.members.add(member);
+ this.redraw();
+ return this;
+ }
+
+ /** Remove a member and update the frame. */
+ remove(member) {
+ this.members.delete(member);
+ this.redraw();
+ return this;
+ }
+
+ /** Register the callback invoked when the generic frame toggle is clicked. */
+ onToggle(handler) {
+ if (handler !== null && handler !== undefined && typeof handler !== "function") {
+ throw new TypeError("Invalid group toggle handler");
+ }
+ this.onToggleHandler = handler || null;
+ return this;
+ }
+
+ /**
+ * goal : Set the visual appearance of the group frame.
+ * pre : values contains optional CSS colour values.
+ * post : The next redraw uses the supplied background and border colours.
+ * result : This group, for fluent setup.
+ * internals : Values are kept on the group and applied as CSS custom
+ * properties in redraw(), allowing application-specific frame styles.
+ */
+ setAppearance(values = {}) {
+ if (values.backgroundColor !== undefined) this.backgroundColor = String(values.backgroundColor);
+ if (values.borderColor !== undefined) this.borderColor = String(values.borderColor);
+ if (values.label !== undefined) this.label = String(values.label);
+ this.redraw();
+ return this;
+ }
+
+ /**
+ * goal : Change whether group contents are shown.
+ * pre : expanded is boolean-coercible.
+ * post : The frame and all member components reflect the new state.
+ * result : The resulting expanded state.
+ * internals : A collapsed group hides direct members; nested groups redraw
+ * themselves so their own frames disappear with the enclosing group.
+ */
+ setExpanded(expanded) {
+ this.expanded = Boolean(expanded);
+ if (this.manageVisibility) {
+ for (const member of this.members) {
+ if (member instanceof DiagramGroup) member.setExpanded(this.expanded && member.expanded);
+ else member.visible(this.expanded);
+ }
+ }
+ this.redraw();
+ return this.expanded;
+ }
+
+ /** Return the union bounds of visible member nodes and nested groups. */
+ bounds() {
+ const members = [...this.members]
+ .map((member) => member instanceof DiagramGroup ? member.bounds() :
+ (member.visible() ? this.componentBounds(member) : null))
+ .filter(Boolean);
+ if (!members.length) return null;
+ return {
+ left: Math.min(...members.map((bounds) => bounds.left)) - this.padding,
+ top: Math.min(...members.map((bounds) => bounds.top)) - this.padding,
+ right: Math.max(...members.map((bounds) => bounds.right)) + this.padding,
+ bottom: Math.max(...members.map((bounds) => bounds.bottom)) + this.padding
+ };
+ }
+
+ /** Return the DOM frame, if the group currently has one. */
+ element() {
+ return this.elementValue;
+ }
+
+ /**
+ * Render or remove the generic frame according to membership and state.
+ * The engine calls this after component redraws; applications may call it
+ * directly after changing group presentation or membership.
+ */
+ redraw() {
+ const surface = this.engine.surfaceElement();
+ const bounds = this.expanded && this.bounds();
+ if (!surface || !bounds) {
+ this.removeElement();
+ return this;
+ }
+ if (!this.elementValue) {
+ const frame = document.createElement("div");
+ frame.className = this.className;
+ frame.setAttribute("role", "group");
+ for (const side of ["top", "right", "bottom", "left"]) {
+ const dragEdge = document.createElement("div");
+ dragEdge.className = `${this.className}-drag-edge ${this.className}-drag-edge-${side}`;
+ if (this.onPointerDownHandler) dragEdge.addEventListener("pointerdown", this.onPointerDownHandler);
+ if (this.onDoubleClickHandler) dragEdge.addEventListener("dblclick", this.onDoubleClickHandler);
+ frame.append(dragEdge);
+ }
+ const toggle = document.createElement("button");
+ toggle.type = "button";
+ toggle.className = `${this.className}-toggle`;
+ toggle.addEventListener("click", () => {
+ this.setExpanded(!this.expanded);
+ if (this.onToggleHandler) this.onToggleHandler(this, this.expanded);
+ });
+ frame.append(toggle);
+ surface.prepend(frame);
+ this.elementValue = frame;
+ }
+ const toggle = this.elementValue.querySelector(`.${this.className}-toggle`);
+ if (toggle) {
+ toggle.textContent = this.expanded ? "-" : "+";
+ toggle.setAttribute("aria-expanded", String(this.expanded));
+ toggle.setAttribute("aria-label", this.expanded ? "Collapse group" : "Expand group");
+ }
+ this.elementValue.setAttribute("aria-label", this.label);
+ Object.assign(this.elementValue.style, {
+ left: `${bounds.left}px`,
+ top: `${bounds.top}px`,
+ width: `${bounds.right - bounds.left}px`,
+ height: `${bounds.bottom - bounds.top}px`,
+ zIndex: String(this.depth)
+ });
+ this.elementValue.style.setProperty("--rw-cmap-submap-background", this.backgroundColor);
+ this.elementValue.style.setProperty("--rw-cmap-submap-border", this.borderColor);
+ return this;
+ }
+
+ /** Remove the frame and all references owned by this group. */
+ destroy() {
+ for (const member of this.members) {
+ if (member instanceof DiagramGroup) member.destroy();
+ }
+ this.members.clear();
+ this.removeElement();
+ }
+
+ componentBounds(component) {
+ const attributes = component.attr();
+ const x = Number(attributes.x);
+ const y = Number(attributes.y);
+ const width = Number(attributes.width);
+ const height = Number(attributes.height);
+ if (![x, y, width, height].every(Number.isFinite)) return null;
+ return { left: x, top: y, right: x + width, bottom: y + height };
+ }
+
+ removeElement() {
+ if (this.elementValue) {
+ this.elementValue.remove();
+ this.elementValue = null;
+ }
+ }
+}
diff --git a/static/cmap/engine/diagram-link.js b/static/cmap/engine/diagram-link.js
new file mode 100644
index 0000000..be7d7ef
--- /dev/null
+++ b/static/cmap/engine/diagram-link.js
@@ -0,0 +1,113 @@
+import {
+ DiagramComponent,
+ pickAttributes,
+ validateOptionalHandler
+} from "./diagram-component.js";
+import { DrawingLink, DrawingSurface, DrawingTriple } from "./drawing-core.js";
+import { DiagramNode } from "./diagram-node.js";
+
+const LINK_ATTRIBUTES = [
+ "content",
+ "contentType",
+ "cx",
+ "cy",
+ "width",
+ "height",
+ "backgroundColor",
+ "borderColor",
+ "borderWidth",
+ "textColor",
+ "sourceX",
+ "sourceY",
+ "targetX",
+ "targetY",
+ "lineColor",
+ "lineWidth",
+ "hasArrow"
+];
+
+/**
+ * Public rendering handle for one diagram link.
+ *
+ * A link owns only visual and interaction state. Its source and target are
+ * DiagramNode instances from the same DiagramEngine.
+ */
+export class DiagramLink extends DiagramComponent {
+ constructor(engine, attributes) {
+ const component = new DrawingLink(pickAttributes(attributes, LINK_ATTRIBUTES));
+ super(engine, component, LINK_ATTRIBUTES);
+ }
+
+ /** Read or replace the source node. Pass null to disconnect it. */
+ sourceNode(node) {
+ return this.connectNode(DrawingSurface.CONNECTION_TYPE_SOURCE, node);
+ }
+
+ /** Read or replace the target node. Pass null to disconnect it. */
+ targetNode(node) {
+ return this.connectNode(DrawingSurface.CONNECTION_TYPE_TARGET, node);
+ }
+
+ /** Register a callback for a source or target connection change. */
+ onConnectionChange(handler) {
+ validateOptionalHandler(handler, "connection-change");
+ this.component.connectionChangeHandler = handler ? (type, node, event) => {
+ handler(this, type, node ? this.engine.handleFor(node) : null, event);
+ } : null;
+ return this;
+ }
+
+ /** Straighten both link segments between their current endpoints. */
+ straighten() {
+ const relation = this.component.relations()
+ .find((candidate) => candidate instanceof DrawingTriple);
+ const sourceNode = relation ? relation.sourceNode() : null;
+ const targetNode = relation ? relation.targetNode() : null;
+
+ if (!sourceNode || !targetNode) {
+ this.component.straighten();
+ return this;
+ }
+
+ const sourcePoint = relation.connectedPoint(
+ sourceNode, targetNode.cx(), targetNode.cy());
+ const targetPoint = relation.connectedPoint(
+ targetNode, sourceNode.cx(), sourceNode.cy());
+ this.component.straighten(
+ sourcePoint.x, sourcePoint.y, targetPoint.x, targetPoint.y);
+ return this;
+ }
+
+ /** Read or update one connected endpoint. */
+ connectNode(type, node) {
+ const connectedComponent = this.engine.drawingSurface
+ .connectedNode(type, this.component);
+
+ if (node === undefined) {
+ return connectedComponent ? this.engine.handleFor(connectedComponent) : null;
+ }
+
+ if (node !== null && !this.validateNode(type, node)) return this;
+ if (connectedComponent) {
+ this.engine.drawingSurface.disconnect(type, connectedComponent, this.component);
+ }
+ if (node !== null) {
+ this.engine.drawingSurface.connect(type, node.component, this.component);
+ }
+ return this;
+ }
+
+ /** Ensure an endpoint belongs to this engine and is not used at both ends. */
+ validateNode(type, node) {
+ if (!(node instanceof DiagramNode) || node.engine !== this.engine) {
+ throw new TypeError("Invalid diagram node");
+ }
+
+ const otherType = DrawingSurface.anotherConnectionType(type);
+ const otherNode = this.engine.drawingSurface.connectedNode(otherType, this.component);
+ if (otherNode === node.component) {
+ return false;
+ }
+ return true;
+ }
+}
diff --git a/static/cmap/engine/diagram-node.js b/static/cmap/engine/diagram-node.js
new file mode 100644
index 0000000..1452079
--- /dev/null
+++ b/static/cmap/engine/diagram-node.js
@@ -0,0 +1,48 @@
+import {
+ DiagramComponent,
+ pickAttributes,
+ validateOptionalHandler
+} from "./diagram-component.js";
+import { DrawingNode } from "./drawing-core.js";
+
+const NODE_ATTRIBUTES = [
+ "content",
+ "contentType",
+ "x",
+ "y",
+ "width",
+ "height",
+ "backgroundColor",
+ "borderColor",
+ "borderWidth",
+ "textColor"
+];
+
+/**
+ * Public rendering handle for one diagram node.
+ *
+ * DiagramEngine creates nodes and owns their lifetime. The editor uses this
+ * handle to update presentation attributes and receive completed moves.
+ */
+export class DiagramNode extends DiagramComponent {
+ constructor(engine, attributes) {
+ const component = new DrawingNode(pickAttributes(attributes, NODE_ATTRIBUTES));
+ super(engine, component, NODE_ATTRIBUTES);
+ }
+
+ /** Constrain or observe the node while it is being dragged. */
+ onMove(handler) {
+ validateOptionalHandler(handler, "move");
+ this.component.moveHandler = handler ?
+ (x, y) => handler(this, x, y) : null;
+ return this;
+ }
+
+ /** Register a callback for the end of a node drag gesture. */
+ onMoveEnd(handler) {
+ validateOptionalHandler(handler, "move-end");
+ this.component.moveEndHandler = handler ?
+ (x, y, event) => handler(this, x, y, event) : null;
+ return this;
+ }
+}
diff --git a/static/cmap/engine/drawing-collections.js b/static/cmap/engine/drawing-collections.js
new file mode 100644
index 0000000..0daf660
--- /dev/null
+++ b/static/cmap/engine/drawing-collections.js
@@ -0,0 +1,86 @@
+/**
+ * Maintain renderer component order and coordinate hit-test priority.
+ * Derived from ionstage/cmap 0.1.3, (c) 2015 iOnStage, MIT License.
+ */
+import { Connector, DrawingLink as Link, DrawingNode as Node } from "./drawing-components.js";
+import { Component, helper } from "./drawing-support.js";
+
+class ComponentList extends helper.List {
+ constructor() {
+ super();
+ }
+
+ toFront(component) {
+ var data = this.data;
+ var index = data.indexOf(component);
+
+ if (index === -1)
+ return;
+
+ data.splice(index, 1);
+ data.push(component);
+ }
+
+ fromPoint(ctor, x, y) {
+ var data = this.data;
+ // The visual stack is connector controls, concepts and finally relations.
+ // Use that same priority for coordinate hit testing so a relation that is
+ // hidden behind a concept can never steal the concept's click.
+ var types = (ctor === Component) ? [Connector, Node, Link] : [ctor];
+
+ for (var toleranceIndex = 0; toleranceIndex < 2; toleranceIndex++) {
+ var tolerance = toleranceIndex === 0 ? 0 : 8;
+ for (var typeIndex = 0; typeIndex < types.length; typeIndex++) {
+ for (var i = data.length - 1; i >= 0; i--) {
+ var component = data[i];
+
+ if (!(component instanceof types[typeIndex]))
+ continue;
+
+ if (component.visible === false)
+ continue;
+
+ if (component.contains(x, y, tolerance))
+ return component;
+ }
+ }
+ }
+
+ return null;
+ }
+}
+
+
+
+class DisabledConnectorList extends helper.List {
+ constructor() {
+ super();
+ }
+
+ add(type, link) {
+ super.add( {
+ type: type,
+ link: link
+ });
+ }
+
+ remove(type, link) {
+ super.remove( {
+ type: type,
+ link: link
+ });
+ }
+
+ contains(type, link) {
+ return super.contains( {
+ type: type,
+ link: link
+ });
+ }
+
+ equal(a, b) {
+ return a.type === b.type && a.link === b.link;
+ }
+}
+
+export { ComponentList, DisabledConnectorList };
diff --git a/static/cmap/engine/drawing-components.js b/static/cmap/engine/drawing-components.js
new file mode 100644
index 0000000..36e6a81
--- /dev/null
+++ b/static/cmap/engine/drawing-components.js
@@ -0,0 +1,524 @@
+/**
+ * Render the node, link and connector primitives of a diagram.
+ *
+ * These classes contain geometry and DOM presentation only. Application code
+ * reaches them through DiagramNode and DiagramLink handles.
+ * Derived from ionstage/cmap 0.1.3, (c) 2015 iOnStage, MIT License.
+ */
+import { Component, dom, helper } from "./drawing-support.js";
+
+class Node extends Component {
+ constructor(props) {
+ super();
+ this.visible = true;
+ this.content = this.prop(props.content, '', helper.toString);
+ this.contentType = this.prop(props.contentType, helper.CONTENT_TYPE_TEXT, helper.toContentType);
+ this.x = this.prop(props.x, 0, helper.toNumber);
+ this.y = this.prop(props.y, 0, helper.toNumber);
+ this.width = this.prop(props.width, 75, helper.toNumber);
+ this.height = this.prop(props.height, 30, helper.toNumber);
+ this.backgroundColor = this.prop(props.backgroundColor, '#a7cbe6', helper.toString);
+ this.borderColor = this.prop(props.borderColor, '#333', helper.toString);
+ this.borderWidth = this.prop(props.borderWidth, 2, helper.toNumber);
+ this.textColor = this.prop(props.textColor, '#333', helper.toString);
+ this.zIndex = this.prop('auto');
+ this.element = this.prop(null);
+ this.parentElement = this.prop(null);
+ this.cache = this.prop({});
+ this.relations = this.prop([]);
+ this.moveHandler = null;
+ }
+
+ cx() {
+ return this.x() + this.width() / 2;
+ }
+
+ cy() {
+ return this.y() + this.height() / 2;
+ }
+
+ borderRadius() {
+ return 4;
+ }
+
+ contains(x, y, tolerance) {
+ var nx = this.x();
+ var ny = this.y();
+ var nwidth = this.width();
+ var nheight = this.height();
+
+ return (nx - tolerance <= x && x <= nx + nwidth + tolerance &&
+ ny - tolerance <= y && y <= ny + nheight + tolerance);
+ }
+
+ style() {
+ var contentType = this.contentType();
+ var lineHeight = (contentType === helper.CONTENT_TYPE_TEXT) ? this.height() : 14;
+ var textAlign = (contentType === helper.CONTENT_TYPE_TEXT) ? 'center' : 'left';
+ var translate = 'translate(' + this.x() + 'px, ' + this.y() + 'px)';
+ var borderWidthOffset = this.borderWidth() * 2;
+
+ return {
+ backgroundColor: this.backgroundColor(),
+ border: this.borderWidth() + 'px solid ' + this.borderColor(),
+ borderRadius: this.borderRadius() + 'px',
+ color: this.textColor(),
+ display: this.visible ? '' : 'none',
+ height: (this.height() - borderWidthOffset) + 'px',
+ lineHeight: (lineHeight - borderWidthOffset) + 'px',
+ msTransform: translate,
+ overflow: 'hidden',
+ pointerEvents: 'auto',
+ position: 'absolute',
+ textAlign: textAlign,
+ textOverflow: 'ellipsis',
+ transform: translate,
+ webkitTransform: translate,
+ whiteSpace: 'nowrap',
+ width: (this.width() - borderWidthOffset) + 'px',
+ zIndex: this.zIndex()
+ };
+ }
+
+ redraw() {
+ var element = this.element();
+ var parentElement = this.parentElement();
+
+ if (!parentElement && !element)
+ return;
+
+ // add element
+ if (parentElement && !element) {
+ element = dom.el('
');
+ this.element(element);
+ dom.append(parentElement, element);
+ this.redraw();
+
+ return;
+ }
+
+ // remove element
+ if (!parentElement && element) {
+ dom.remove(element);
+ this.element(null);
+ this.cache({});
+
+ return;
+ }
+
+ var cache = this.cache();
+
+ // update element
+ var content = this.content();
+
+ if (content !== cache.content) {
+ var contentType = this.contentType();
+
+ if (contentType === helper.CONTENT_TYPE_TEXT)
+ dom.text(element, content);
+ else if (contentType === helper.CONTENT_TYPE_HTML)
+ dom.html(element, content);
+
+ cache.content = content;
+ }
+
+ var style = this.style();
+
+ dom.css(element, helper.diffObj(style, cache.style));
+ cache.style = style;
+ this.notifyRendered();
+ }
+}
+
+
+
+
+
+
+
+class Link extends Component {
+ constructor(props) {
+ super();
+ this.visible = true;
+ this.content = this.prop(props.content, '', helper.toString);
+ this.contentType = this.prop(props.contentType, helper.CONTENT_TYPE_TEXT, helper.toContentType);
+ this.cx = this.prop(props.cx, 100, helper.toNumber);
+ this.cy = this.prop(props.cy, 40, helper.toNumber);
+ this.width = this.prop(props.width, 50, helper.toNumber);
+ this.height = this.prop(props.height, 20, helper.toNumber);
+ this.backgroundColor = this.prop(props.backgroundColor, 'white', helper.toString);
+ this.borderColor = this.prop(props.borderColor, '#333', helper.toString);
+ this.borderWidth = this.prop(props.borderWidth, 2, helper.toNumber);
+ this.textColor = this.prop(props.textColor, '#333', helper.toString);
+ this.sourceX = this.prop(props.sourceX, this.cx() - 70, helper.toNumber);
+ this.sourceY = this.prop(props.sourceY, this.cy(), helper.toNumber);
+ this.targetX = this.prop(props.targetX, this.cx() + 70, helper.toNumber);
+ this.targetY = this.prop(props.targetY, this.cy(), helper.toNumber);
+ this.lineColor = this.prop(props.lineColor, '#333', helper.toString);
+ this.lineWidth = this.prop(props.lineWidth, 2, helper.toNumber);
+ this.hasArrow = this.prop(props.hasArrow, true, helper.toBoolean);
+ this.zIndex = this.prop('auto');
+ this.element = this.prop(null);
+ this.parentElement = this.prop(null);
+ this.cache = this.prop({});
+ this.relations = this.prop([]);
+ this.connectionChangeHandler = null;
+ }
+
+ straighten(sx, sy, tx, ty) {
+ if (arguments.length === 0) {
+ this.cx((this.sourceX() + this.targetX()) / 2);
+ this.cy((this.sourceY() + this.targetY()) / 2);
+
+ return;
+ }
+
+ this.cx((sx + tx) / 2);
+ this.cy((sy + ty) / 2);
+ this.sourceX(sx);
+ this.sourceY(sy);
+ this.targetX(tx);
+ this.targetY(ty);
+ }
+
+ contains(x, y, tolerance) {
+ var content = this.content();
+ var lcx = this.cx();
+ var lcy = this.cy();
+
+ // content area
+ if (content) {
+ var lwidth = this.width();
+ var lheight = this.height();
+
+ var lx = lcx - lwidth / 2;
+ var ly = lcy - lheight / 2;
+
+ if (lx - tolerance <= x && x <= lx + lwidth + tolerance &&
+ ly - tolerance <= y && y <= ly + lheight + tolerance) {
+ return true;
+ }
+ }
+
+ var lineWidth = this.lineWidth();
+
+ // source path
+ if (this.containsPath(this.sourceX(), this.sourceY(), lcx, lcy, x, y, lineWidth / 2 + tolerance))
+ return true;
+
+ // target path
+ if (this.containsPath(this.targetX(), this.targetY(), lcx, lcy, x, y, lineWidth / 2 + tolerance))
+ return true;
+
+ return false;
+ }
+
+ containsPath(x0, y0, x1, y1, x, y, d) {
+ var ax = x1 - x0;
+ var ay = y1 - y0;
+
+ var bx = x - x0;
+ var by = y - y0;
+
+ var r = (ax * bx + ay * by) / (ax * ax + ay * ay);
+
+ if (0 <= r && r <= 1) {
+ var px = x0 + r * ax;
+ var py = y0 + r * ay;
+
+ var dx = px - x;
+ var dy = py - y;
+
+ if (dx * dx + dy * dy <= d * d)
+ return true;
+ }
+
+ return false;
+ }
+
+ style() {
+ return {
+ display: this.visible ? '' : 'none',
+ pointerEvents: 'none',
+ position: 'absolute',
+ zIndex: this.zIndex()
+ };
+ }
+
+ pathContainerStyle() {
+ var width = Math.max(this.cx(), this.sourceX(), this.targetX());
+ var height = Math.max(this.cy(), this.sourceY(), this.targetY());
+
+ return {
+ height: height + 'px',
+ overflow: 'visible',
+ position: 'absolute',
+ width: width + 'px'
+ };
+ }
+
+ lineAttributes() {
+ var d = [
+ 'M', this.sourceX(), this.sourceY(),
+ 'L', this.cx(), this.cy(),
+ 'L', this.targetX(), this.targetY()
+ ].join(' ');
+
+ return {
+ d: d,
+ fill: 'none',
+ stroke: this.lineColor(),
+ 'stroke-linecap': 'round',
+ 'stroke-width': this.lineWidth()
+ };
+ }
+
+ arrowAttributes() {
+ var cx = this.cx();
+ var cy = this.cy();
+ var tx = this.targetX();
+ var ty = this.targetY();
+
+ var radians = Math.atan2(ty - cy, tx - cx);
+
+ var p0 = {
+ x: 15 * Math.cos(radians - 26 * Math.PI / 180),
+ y: 15 * Math.sin(radians - 26 * Math.PI / 180)
+ };
+
+ var p1 = {
+ x: 15 * Math.cos(radians + 26 * Math.PI / 180),
+ y: 15 * Math.sin(radians + 26 * Math.PI / 180)
+ };
+
+ var p2 = {
+ x: 7 * Math.cos(radians),
+ y: 7 * Math.sin(radians)
+ };
+
+ var d = [
+ 'M', tx - p0.x, ty - p0.y,
+ 'L', tx, ty,
+ 'L', tx - p1.x, ty - p1.y,
+ 'Q', tx - p2.x, ty - p2.y, tx - p0.x, ty - p0.y,
+ 'Z'
+ ].join(' ');
+
+ return {
+ d: d,
+ fill: this.lineColor(),
+ stroke: this.lineColor(),
+ 'stroke-linejoin': 'round',
+ 'stroke-width': this.lineWidth(),
+ visibility: this.hasArrow() ? 'visible' : 'hidden'
+ };
+ }
+
+ contentStyle() {
+ var contentType = this.contentType();
+ var lineHeight = (contentType === helper.CONTENT_TYPE_TEXT) ? this.height() : 14;
+ var textAlign = (contentType === helper.CONTENT_TYPE_TEXT) ? 'center' : 'left';
+ var x = this.cx() - this.width() / 2;
+ var y = this.cy() - this.height() / 2;
+ var translate = 'translate(' + x + 'px, ' + y + 'px)';
+ var borderWidthOffset = this.borderWidth() * 2;
+
+ return {
+ backgroundColor: this.backgroundColor(),
+ border: this.borderWidth() + 'px solid ' + this.borderColor(),
+ borderRadius: '4px',
+ color: this.textColor(),
+ height: (this.height() - borderWidthOffset) + 'px',
+ lineHeight: (lineHeight - borderWidthOffset) + 'px',
+ msTransform: translate,
+ overflow: 'hidden',
+ position: 'absolute',
+ textAlign: textAlign,
+ textOverflow: 'ellipsis',
+ transform: translate,
+ visibility: this.content() ? 'visible' : 'hidden',
+ webkitTransform: translate,
+ whiteSpace: 'nowrap',
+ width: (this.width() - borderWidthOffset) + 'px'
+ };
+ }
+
+ redraw() {
+ var element = this.element();
+ var parentElement = this.parentElement();
+
+ if (!parentElement && !element)
+ return;
+
+ // add element
+ if (parentElement && !element) {
+ element = dom.el('
');
+ dom.html(element, '
');
+ this.element(element);
+ dom.append(parentElement, element);
+ this.redraw();
+
+ return;
+ }
+
+ // remove element
+ if (!parentElement && element) {
+ dom.remove(element);
+ this.element(null);
+ this.cache({});
+
+ return;
+ }
+
+ var cache = this.cache();
+
+ // update path container element
+ var pathContainerStyle = this.pathContainerStyle();
+ var pathContainerElement = dom.child(element, 0);
+
+ dom.css(pathContainerElement, helper.diffObj(pathContainerStyle, cache.pathContainerElementStyle));
+ cache.pathContainerElementStyle = contentStyle;
+
+ // update line element
+ var lineAttributes = this.lineAttributes();
+ var lineElement = dom.child(pathContainerElement, 0);
+
+ dom.attr(lineElement, helper.diffObj(lineAttributes, cache.lineAttributes));
+ cache.lineAttributes = lineAttributes;
+
+ // update arrow element
+ var arrowAttributes = this.arrowAttributes();
+ var arrowElement = dom.child(pathContainerElement, 1);
+
+ dom.attr(arrowElement, helper.diffObj(arrowAttributes, cache.arrowAttributes));
+ cache.arrowAttributes = arrowAttributes;
+
+ // update content element
+ var content = this.content();
+ var contentStyle = this.contentStyle();
+ var contentElement = dom.child(element, 1);
+
+ if (content !== cache.content) {
+ var contentType = this.contentType();
+
+ if (contentType === helper.CONTENT_TYPE_TEXT)
+ dom.text(contentElement, content);
+ else if (contentType === helper.CONTENT_TYPE_HTML)
+ dom.html(contentElement, content);
+
+ cache.content = content;
+ }
+
+ dom.css(contentElement, helper.diffObj(contentStyle, cache.contentStyle));
+ cache.contentStyle = contentStyle;
+
+ // update container element
+ var style = this.style();
+
+ dom.css(element, helper.diffObj(style, cache.style));
+ cache.style = style;
+ this.notifyRendered();
+ }
+}
+
+
+
+
+
+
+
+
+
+
+class Connector extends Component {
+ constructor(props) {
+ super();
+ this.x = this.prop(props.x, 0, helper.toNumber);
+ this.y = this.prop(props.y, 0, helper.toNumber);
+ this.color = this.prop(Connector.COLOR_UNCONNECTED);
+ this.zIndex = this.prop('auto');
+ this.element = this.prop(null);
+ this.parentElement = this.prop(null);
+ this.cache = this.prop({});
+ this.relations = this.prop([]);
+ }
+
+ r() {
+ return 16;
+ }
+
+ contains(x, y, tolerance) {
+ var dx = x - this.x();
+ var dy = y - this.y();
+ var r = this.r() + tolerance;
+
+ return (dx * dx + dy * dy <= r * r);
+ }
+
+ style() {
+ var r = this.r();
+ var x = this.x() - r;
+ var y = this.y() - r;
+ var translate = 'translate(' + x + 'px, ' + y + 'px)';
+
+ return {
+ backgroundColor: this.color(),
+ border: '2px solid lightgray',
+ borderRadius: '50%',
+ boxSizing: 'border-box',
+ height: r * 2 + 'px',
+ msTransform: translate,
+ opacity: 0.6,
+ pointerEvents: 'none',
+ position: 'absolute',
+ transform: translate,
+ webkitTransform: translate,
+ width: r * 2 + 'px',
+ zIndex: this.zIndex()
+ };
+ }
+
+ redraw() {
+ var element = this.element();
+ var parentElement = this.parentElement();
+
+ if (!parentElement && !element)
+ return;
+
+ // add element
+ if (parentElement && !element) {
+ element = dom.el('
');
+ this.element(element);
+ dom.append(parentElement, element);
+ this.redraw();
+
+ return;
+ }
+
+ // remove element
+ if (!parentElement && element) {
+ dom.remove(element);
+ this.element(null);
+
+ return;
+ }
+
+ var cache = this.cache();
+
+ // update element
+ var style = this.style();
+
+ dom.css(element, helper.diffObj(style, cache.style));
+ cache.style = style;
+ this.notifyRendered();
+ }
+}
+
+
+
+
+
+Connector.COLOR_CONNECTED = 'lightgreen';
+Connector.COLOR_UNCONNECTED = 'pink';
+
+export { Connector, Link as DrawingLink, Node as DrawingNode };
diff --git a/static/cmap/engine/drawing-core.js b/static/cmap/engine/drawing-core.js
new file mode 100644
index 0000000..d3516b3
--- /dev/null
+++ b/static/cmap/engine/drawing-core.js
@@ -0,0 +1,9 @@
+/**
+ * Internal exports of the modular diagram rendering core.
+ *
+ * The implementation is derived from the MIT-licensed ionstage/cmap 0.1.3
+ * renderer and is maintained as part of racket-wiki.
+ */
+export { DrawingLink, DrawingNode } from "./drawing-components.js";
+export { DrawingTriple } from "./drawing-relations.js";
+export { DrawingSurface } from "./drawing-surface.js";
diff --git a/static/cmap/engine/drawing-relations.js b/static/cmap/engine/drawing-relations.js
new file mode 100644
index 0000000..964b84e
--- /dev/null
+++ b/static/cmap/engine/drawing-relations.js
@@ -0,0 +1,308 @@
+/**
+ * Keep diagram endpoints and connector controls geometrically related.
+ * Derived from ionstage/cmap 0.1.3, (c) 2015 iOnStage, MIT License.
+ */
+import { Connector, DrawingLink as Link, DrawingNode as Node } from "./drawing-components.js";
+
+class Relation {
+ constructor() {
+ }
+
+ prop(initialValue) {
+ var cache = initialValue;
+
+ return function(value) {
+ if (typeof value === 'undefined')
+ return cache;
+
+ cache = value;
+ };
+ }
+
+ update() {
+ }
+}
+
+
+
+class Triple extends Relation {
+ constructor(props) {
+ super();
+ this.link = this.prop(props.link);
+ this.sourceNode = this.prop(props.sourceNode || null);
+ this.targetNode = this.prop(props.targetNode || null);
+ this.skipNextUpdate = this.prop(false);
+ this.nodePositionsCache = this.prop({});
+ }
+
+ update(changedComponent) {
+ if (this.skipNextUpdate()) {
+ this.skipNextUpdate(false);
+ return;
+ }
+
+ var link = this.link();
+ var sourceNode = this.sourceNode();
+ var targetNode = this.targetNode();
+
+ if (changedComponent instanceof Node)
+ this.updateNode(link, sourceNode, targetNode, changedComponent);
+ else if (changedComponent instanceof Link)
+ this.updateLink(link, sourceNode, targetNode);
+ }
+
+ updateNode(link, sourceNode, targetNode, changedNode) {
+ if (sourceNode && targetNode)
+ this.rotateLink(link, sourceNode, targetNode, changedNode);
+ else
+ this.shiftLink(link, sourceNode, targetNode, changedNode);
+
+ this.updateNodePositionsCache();
+ }
+
+ rotateLink(link, sourceNode, targetNode, changedNode) {
+ var cache = this.nodePositionsCache();
+
+ var sncx = cache.sncx;
+ var sncy = cache.sncy;
+ var tncx = cache.tncx;
+ var tncy = cache.tncy;
+
+ var lcx = link.cx();
+ var lcy = link.cy();
+
+ var ts_dx = tncx - sncx;
+ var ts_dy = tncy - sncy;
+ var cs_dx = lcx - sncx;
+ var cs_dy = lcy - sncy;
+
+ var ts_rad0 = Math.atan2(ts_dy, ts_dx);
+ var cs_rad0 = Math.atan2(cs_dy, cs_dx);
+
+ // changed node position
+ if (changedNode === sourceNode) {
+ sncx = sourceNode.cx();
+ sncy = sourceNode.cy();
+ } else if (changedNode === targetNode) {
+ tncx = targetNode.cx();
+ tncy = targetNode.cy();
+ }
+
+ // center positions of two nodes are equal
+ if (cs_rad0 === 0) {
+ link.cx((sncx + tncx) / 2);
+ link.cy((sncy + tncy) / 2);
+
+ return;
+ }
+
+ var ts_d0 = Math.sqrt(ts_dx * ts_dx + ts_dy * ts_dy);
+ var cs_d0 = Math.sqrt(cs_dx * cs_dx + cs_dy * cs_dy);
+
+ var ts_cs_rad = ts_rad0 - cs_rad0;
+
+ ts_dx = tncx - sncx;
+ ts_dy = tncy - sncy;
+
+ var ts_rad1 = Math.atan2(ts_dy, ts_dx);
+ var cs_rad1 = ts_rad1 - ts_cs_rad;
+
+ var ts_d1 = Math.sqrt(ts_dx * ts_dx + ts_dy * ts_dy);
+ var d_rate = (ts_d0 !== 0) ? ts_d1 / ts_d0 : 1;
+ var cs_d1 = cs_d0 * d_rate;
+
+ lcx = sncx + cs_d1 * Math.cos(cs_rad1);
+ lcy = sncy + cs_d1 * Math.sin(cs_rad1);
+
+ link.cx(lcx);
+ link.cy(lcy);
+ }
+
+ shiftLink(link, sourceNode, targetNode, changedNode) {
+ var cache = this.nodePositionsCache();
+
+ var ncx = changedNode.cx();
+ var ncy = changedNode.cy();
+
+ if (changedNode === sourceNode) {
+ link.targetX(link.targetX() + (ncx - cache.sncx));
+ link.targetY(link.targetY() + (ncy - cache.sncy));
+ } else if (changedNode === targetNode) {
+ link.sourceX(link.sourceX() + (ncx - cache.tncx));
+ link.sourceY(link.sourceY() + (ncy - cache.tncy));
+ }
+ }
+
+ updateLink(link, sourceNode, targetNode) {
+ var lx, ly, p;
+
+ if (sourceNode) {
+ // connect link to source node
+ lx = targetNode ? link.cx() : link.targetX();
+ ly = targetNode ? link.cy() : link.targetY();
+ p = this.connectedPoint(sourceNode, lx, ly);
+ link.sourceX(p.x);
+ link.sourceY(p.y);
+ }
+
+ if (targetNode) {
+ // connect link to target node
+ lx = sourceNode ? link.cx() : link.sourceX();
+ ly = sourceNode ? link.cy() : link.sourceY();
+ p = this.connectedPoint(targetNode, lx, ly);
+ link.targetX(p.x);
+ link.targetY(p.y);
+ }
+
+ if (!sourceNode || !targetNode) {
+ // link content moves to midpoint
+ link.cx((link.sourceX() + link.targetX()) / 2);
+ link.cy((link.sourceY() + link.targetY()) / 2);
+ }
+ }
+
+ updateLinkAngle(radians) {
+ var link = this.link();
+ var sourceNode = this.sourceNode();
+ var targetNode = this.targetNode();
+
+ var ldx = link.targetX() - link.sourceX();
+ var ldy = link.targetY() - link.sourceY();
+ var d = Math.sqrt(ldx * ldx + ldy * ldy);
+
+ var connectedNode = sourceNode || targetNode;
+ var cx = connectedNode.cx();
+ var cy = connectedNode.cy();
+ var lx = cx + d * Math.cos(radians);
+ var ly = cy + d * Math.sin(radians);
+ var p = this.connectedPoint(connectedNode, lx, ly);
+
+ if (connectedNode === sourceNode)
+ link.straighten(p.x, p.y, lx + p.x - cx, ly + p.y - cy);
+ else if (connectedNode === targetNode)
+ link.straighten(lx + p.x - cx, ly + p.y - cy, p.x, p.y);
+ }
+
+ updateNodePositionsCache() {
+ var sourceNode = this.sourceNode();
+ var targetNode = this.targetNode();
+ var cache = this.nodePositionsCache();
+
+ if (sourceNode) {
+ cache.sncx = sourceNode.cx();
+ cache.sncy = sourceNode.cy();
+ }
+
+ if (targetNode) {
+ cache.tncx = targetNode.cx();
+ cache.tncy = targetNode.cy();
+ }
+ }
+
+ connectedPoint(node, lx, ly) {
+ var nx = node.x();
+ var ny = node.y();
+ var nwidth = node.width();
+ var nheight = node.height();
+ var ncx = node.cx();
+ var ncy = node.cy();
+
+ var alpha = Math.atan2(ly - ncy, lx - ncx);
+ var beta = Math.PI / 2 - alpha;
+ var t = Math.atan2(nheight, nwidth);
+
+ var x, y;
+
+ // left edge
+ if (alpha < t - Math.PI || alpha > Math.PI - t) {
+ x = nx;
+ y = ncy - nwidth * Math.tan(alpha) / 2;
+ }
+ // top edge
+ else if (alpha < -t) {
+ x = ncx - nheight * Math.tan(beta) / 2;
+ y = ny;
+ }
+ // right edge
+ else if (alpha < t) {
+ x = nx + nwidth;
+ y = ncy + nwidth * Math.tan(alpha) / 2;
+ }
+ // bottom edge
+ else {
+ x = ncx + nheight * Math.tan(beta) / 2;
+ y = ny + nheight;
+ }
+
+ var x0, y0, l, ex, ey;
+ var r = node.borderRadius();
+ var atCorner = false;
+
+ // top-left corner
+ if (x < nx + r && y < ny + r) {
+ x0 = nx + r;
+ y0 = ny + r;
+ atCorner = true;
+ }
+ // top-right corner
+ else if (x > nx + nwidth - r && y < ny + r) {
+ x0 = nx + nwidth - r;
+ y0 = ny + r;
+ atCorner = true;
+ }
+ // bottom-left corner
+ else if (x < nx + r && y > ny + nheight - r) {
+ x0 = nx + r;
+ y0 = ny + nheight - r;
+ atCorner = true;
+ }
+ // bottom-right corner
+ else if (x > nx + nwidth - r && y > ny + nheight - r) {
+ x0 = nx + nwidth - r;
+ y0 = ny + nheight - r;
+ atCorner = true;
+ }
+
+ if (atCorner) {
+ l = Math.sqrt((x0 - x) * (x0 - x) + (y0 - y) * (y0 - y));
+ ex = (x0 - x) / l;
+ ey = (y0 - y) / l;
+ x = x0 - r * ex;
+ y = y0 - r * ey;
+ }
+
+ return {
+ x: x,
+ y: y
+ };
+ }
+}
+
+
+
+class LinkConnectorRelation extends Relation {
+ constructor(props) {
+ super();
+ this.type = this.prop(props.type);
+ this.link = this.prop(props.link);
+ this.connector = this.prop(props.connector);
+ }
+
+ isConnected(isConnected) {
+ var color = isConnected ? Connector.COLOR_CONNECTED : Connector.COLOR_UNCONNECTED;
+ this.connector().color(color);
+ }
+
+ update(changedComponent) {
+ var type = this.type();
+ var link = this.link();
+ var connector = this.connector();
+
+ if (changedComponent === link) {
+ connector.x(link[type + 'X']());
+ connector.y(link[type + 'Y']());
+ }
+ }
+}
+
+export { LinkConnectorRelation, Triple as DrawingTriple };
diff --git a/static/cmap/engine/drawing-support.js b/static/cmap/engine/drawing-support.js
new file mode 100644
index 0000000..43456ba
--- /dev/null
+++ b/static/cmap/engine/drawing-support.js
@@ -0,0 +1,340 @@
+/**
+ * Shared DOM and state support for the low-level diagram renderer.
+ *
+ * Derived from ionstage/cmap 0.1.3, (c) 2015 iOnStage, MIT License.
+ */
+const CONTENT_TYPE_TEXT = 'text';
+const CONTENT_TYPE_HTML = 'html';
+
+class ItemList {
+ constructor() {
+ this.data = [];
+ }
+
+ add(item) {
+ if (!this.contains(item)) this.data.push(item);
+ }
+
+ remove(item) {
+ for (let index = this.data.length - 1; index >= 0; index -= 1) {
+ if (this.equal(this.data[index], item)) {
+ this.data.splice(index, 1);
+ break;
+ }
+ }
+ }
+
+ contains(item) {
+ return this.data.some((candidate) => this.equal(candidate, item));
+ }
+
+ equal(first, second) {
+ return first === second;
+ }
+
+ toArray() {
+ return this.data.slice();
+ }
+}
+
+const helper = {
+ CONTENT_TYPE_HTML,
+ CONTENT_TYPE_TEXT,
+ List: ItemList,
+
+ toNumber(value, defaultValue) {
+ return !isNaN(value) ? Number(value) : defaultValue;
+ },
+
+ toString(value, defaultValue) {
+ return value !== undefined ? String(value) : defaultValue;
+ },
+
+ toBoolean(value, defaultValue) {
+ return value !== undefined ? Boolean(value) : defaultValue;
+ },
+
+ toContentType(value, defaultValue) {
+ if (value === CONTENT_TYPE_TEXT || value === CONTENT_TYPE_HTML) return value;
+ return defaultValue;
+ },
+
+ eachInstance(values, constructor, callback) {
+ values.filter((value) => value instanceof constructor).forEach(callback);
+ },
+
+ firstInstance(values, constructor) {
+ return values.find((value) => value instanceof constructor);
+ },
+
+ diffObj(newObject, oldObject) {
+ const difference = {};
+ for (const key in newObject) {
+ if (!oldObject || newObject[key] !== oldObject[key]) {
+ difference[key] = newObject[key];
+ }
+ }
+ return difference;
+ },
+
+ identity(value) {
+ return value;
+ }
+};
+
+var dom = {};
+
+dom.disabled = function() {
+ return (typeof document === 'undefined');
+};
+
+dom.el = function(selector) {
+ if (selector.charAt(0) === '<') {
+ selector = selector.match(/<(.+)>/)[1];
+ return document.createElement(selector);
+ }
+};
+
+dom.body = function() {
+ return document.body;
+};
+
+dom.attr = function(el, props) {
+ for (var key in props) {
+ el.setAttribute(key, props[key]);
+ }
+};
+
+dom.css = function(el, props) {
+ var style = el.style;
+
+ for (var key in props) {
+ style[key] = props[key];
+ }
+};
+
+dom.rect = function(el) {
+ return el.getBoundingClientRect();
+};
+
+dom.clientWidth = function(el) {
+ return el.clientWidth;
+};
+
+dom.clientHeight = function(el) {
+ return el.clientHeight;
+};
+
+dom.scrollLeft = function(el) {
+ return el.scrollLeft;
+};
+
+dom.scrollTop = function(el) {
+ return el.scrollTop;
+};
+
+dom.scrollWidth = function(el) {
+ return el.scrollWidth;
+};
+
+dom.scrollHeight = function(el) {
+ return el.scrollHeight;
+};
+
+dom.text = function(el, s) {
+ el.textContent = s;
+};
+
+dom.html = function(el, s) {
+ el.innerHTML = s;
+};
+
+dom.append = function(parent, el) {
+ parent.appendChild(el);
+};
+
+dom.remove = function(el) {
+ el.parentNode.removeChild(el);
+};
+
+dom.child = function(el, index) {
+ return el.childNodes[index];
+};
+
+dom.animate = function(callback) {
+ return window.requestAnimationFrame(callback);
+};
+
+dom.supportsTouch = function() {
+ return ('ontouchstart' in window || (typeof DocumentTouch !== 'undefined' && document instanceof DocumentTouch));
+};
+
+dom.on = function(el, type, listener) {
+ el.addEventListener(type, listener);
+};
+
+dom.off = function(el, type, listener) {
+ el.removeEventListener(type, listener);
+};
+
+dom.pagePoint = function(event, offset) {
+ if (dom.supportsTouch())
+ event = event.changedTouches[0];
+
+ return {
+ x: event.pageX - (offset ? offset.x : 0),
+ y: event.pageY - (offset ? offset.y : 0)
+ };
+};
+
+dom.clientPoint = function(event, offset) {
+ if (dom.supportsTouch())
+ event = event.changedTouches[0];
+
+ return {
+ x: event.clientX - (offset ? offset.x : 0),
+ y: event.clientY - (offset ? offset.y : 0)
+ };
+};
+
+dom.cancel = function(event) {
+ event.preventDefault();
+};
+
+class Draggable {
+ constructor(element, onStart, onMove, onEnd) {
+ this.element = element;
+ this.onStart = onStart;
+ this.onMove = onMove;
+ this.onEnd = onEnd;
+ this.start = this.start.bind(this);
+ this.move = this.move.bind(this);
+ this.end = this.end.bind(this);
+ this.locked = false;
+ this.startingPoint = null;
+ this.startEvent = dom.supportsTouch() ? 'touchstart' : 'mousedown';
+ this.moveEvent = dom.supportsTouch() ? 'touchmove' : 'mousemove';
+ this.endEvent = dom.supportsTouch() ? 'touchend' : 'mouseup';
+
+ dom.on(this.element, this.startEvent, this.start);
+ }
+
+ start(event) {
+ if (this.locked)
+ return;
+
+ this.locked = true;
+ this.startingPoint = dom.pagePoint(event);
+ const rectangle = dom.rect(this.element);
+ const point = dom.clientPoint(event, {
+ x: rectangle.left - dom.scrollLeft(this.element),
+ y: rectangle.top - dom.scrollTop(this.element)
+ });
+
+ if (typeof this.onStart === 'function') this.onStart(point.x, point.y, event);
+
+ dom.on(document, this.moveEvent, this.move);
+ dom.on(document, this.endEvent, this.end);
+ }
+
+ move(event) {
+ const distance = dom.pagePoint(event, this.startingPoint);
+ if (typeof this.onMove === 'function') this.onMove(distance.x, distance.y, event);
+ }
+
+ end(event) {
+ dom.off(document, this.moveEvent, this.move);
+ dom.off(document, this.endEvent, this.end);
+
+ const distance = dom.pagePoint(event, this.startingPoint);
+ if (typeof this.onEnd === 'function') this.onEnd(distance.x, distance.y, event);
+
+ this.locked = false;
+ }
+}
+
+dom.draggable = function(element, onStart, onMove, onEnd) {
+ if (dom.disabled()) return null;
+ return new Draggable(element, onStart, onMove, onEnd);
+};
+
+const dirtyComponents = [];
+let renderRequestId = null;
+
+class Component {
+ constructor() {
+ this.disposed = false;
+ }
+
+ dispose() {
+ this.disposed = true;
+ }
+
+ prop(initialValue, defaultValue, converter) {
+ const convert = typeof converter === 'function' ? converter : helper.identity;
+ let cache = convert(initialValue, defaultValue);
+
+ return (value) => {
+ if (typeof value === 'undefined')
+ return cache;
+
+ if (value === cache)
+ return;
+
+ cache = convert(value, cache);
+ this.markDirty();
+ };
+ }
+
+ relations() {
+ return [];
+ }
+
+ redraw() {}
+
+ notifyRendered() {
+ if (typeof this.renderedHandler === 'function' && this.element())
+ this.renderedHandler(this.element());
+ }
+
+ markDirty() {
+ if (dom.disabled() || this.disposed)
+ return;
+
+ if (!dirtyComponents.includes(this))
+ dirtyComponents.push(this);
+
+ if (renderRequestId !== null)
+ return;
+
+ renderRequestId = dom.animate(redrawDirtyComponents);
+ }
+}
+
+function updateDirtyRelations(index) {
+ const initialLength = dirtyComponents.length;
+ for (let position = index; position < initialLength; position += 1) {
+ const component = dirtyComponents[position];
+ if (component.disposed)
+ continue;
+ component.relations().forEach((relation) => {
+ if (!relation.disposed)
+ relation.update(component);
+ });
+ }
+
+ if (dirtyComponents.length > initialLength)
+ updateDirtyRelations(initialLength);
+}
+
+function redrawDirtyComponents() {
+ updateDirtyRelations(0);
+ dirtyComponents.forEach((component) => {
+ if (!component.disposed)
+ component.redraw();
+ });
+ dirtyComponents.length = 0;
+ renderRequestId = null;
+}
+
+export { Component, dom, helper };
diff --git a/static/cmap/engine/drawing-surface.js b/static/cmap/engine/drawing-surface.js
new file mode 100644
index 0000000..ae1cd0a
--- /dev/null
+++ b/static/cmap/engine/drawing-surface.js
@@ -0,0 +1,615 @@
+/**
+ * Own the diagram surface, component relations and pointer interaction.
+ *
+ * DrawingSurface is internal to DiagramEngine. It coordinates the primitive
+ * renderers, performs hit testing and translates drag gestures to geometry.
+ * Derived from ionstage/cmap 0.1.3, (c) 2015 iOnStage, MIT License.
+ */
+import { ComponentList, DisabledConnectorList } from "./drawing-collections.js";
+import { Connector, DrawingLink as Link, DrawingNode as Node } from "./drawing-components.js";
+import { DrawingTriple as Triple, LinkConnectorRelation } from "./drawing-relations.js";
+import { Component, dom, helper } from "./drawing-support.js";
+
+class Cmap extends Component {
+ constructor(rootElement) {
+ super();
+ this.componentList = this.prop(new ComponentList());
+ this.disabledConnectorList = this.prop(new DisabledConnectorList());
+ this.dragDisabledComponentList = this.prop(new ComponentList());
+ this.element = this.prop(null);
+ this.rootElement = this.prop(rootElement || null);
+ this.retainerElement = this.prop(null);
+ this.dragContext = this.prop({});
+ this.selectionHandler = null;
+ this.activationHandler = null;
+ this.lastClickComponent = null;
+ this.lastClickTime = 0;
+ this.zoomFactor = 1;
+
+ this.markDirty();
+ }
+
+ add(component) {
+ component.parentElement(this.element());
+ this.componentList().add(component);
+ this.updateZIndex();
+ }
+
+ static anotherConnectionType(type) {
+ if (type === Cmap.CONNECTION_TYPE_SOURCE)
+ return Cmap.CONNECTION_TYPE_TARGET;
+ else if (type === Cmap.CONNECTION_TYPE_TARGET)
+ return Cmap.CONNECTION_TYPE_SOURCE;
+ }
+
+ remove(component) {
+ component.parentElement(null);
+
+ if (component instanceof Link)
+ this.hideConnectors(component);
+
+ this.disconnect(component);
+ this.componentList().remove(component);
+ this.updateZIndex();
+ }
+
+ toFront(component) {
+ this.componentList().toFront(component);
+ this.updateZIndex();
+ }
+
+ updateZIndex() {
+ var linkIndex = 0;
+ var nodeIndex = 0;
+ this.componentList().toArray().forEach(function(component) {
+ if (component instanceof Connector)
+ return;
+
+ // Relations always occupy a lower band than concept nodes. Reordering a
+ // selected component therefore only changes its order inside that band.
+ var zIndex = component instanceof Link ?
+ Cmap.LINK_Z_INDEX_BASE + linkIndex++ :
+ Cmap.NODE_Z_INDEX_BASE + nodeIndex++;
+ component.zIndex(zIndex);
+
+ if (!(component instanceof Link))
+ return;
+
+ // update connector z-index of link
+ helper.eachInstance(component.relations(), LinkConnectorRelation, function(relation, index) {
+ relation.connector().zIndex(Cmap.CONNECTOR_Z_INDEX_BASE + index);
+ });
+ });
+ }
+
+ connect(type, node, link) {
+ var linkRelations = link.relations();
+ var triple = helper.firstInstance(linkRelations, Triple);
+ var nodeKey = type + 'Node';
+
+ if (triple && triple[nodeKey]())
+ throw new Error('Already connected');
+
+ var anotherType = Cmap.anotherConnectionType(type);
+ var anotherSideNode = triple ? triple[anotherType + 'Node']() : null;
+
+ if (anotherSideNode === node)
+ throw new Error('Already connected to the ' + anotherType + ' of the link');
+
+ if (triple) {
+ triple[nodeKey](node);
+ } else {
+ var tripleProps = {};
+ tripleProps.link = link;
+ tripleProps[nodeKey] = node;
+ triple = new Triple(tripleProps);
+
+ // add triple to the beginning of link relations to be ahead of link-connector relation
+ // connector position won't be updated before triple update
+ linkRelations.unshift(triple);
+ }
+
+ // add triple to node
+ node.relations().push(triple);
+ triple.updateNodePositionsCache();
+
+ // update connectors of link
+ helper.eachInstance(linkRelations, LinkConnectorRelation, function(relation) {
+ if (relation.type() === type)
+ relation.isConnected(true);
+ });
+
+ // link content moves to midpoint of connected nodes
+ if (anotherSideNode) {
+ link.cx((node.cx() + anotherSideNode.cx()) / 2);
+ link.cy((node.cy() + anotherSideNode.cy()) / 2);
+ }
+
+ // do not need to mark node dirty (stay unchanged)
+ link.markDirty();
+ }
+
+ disconnect(type, node, link) {
+ if (type instanceof Component) {
+ var component = type;
+ var relations = component.relations().slice();
+
+ // disconnect all connections of component
+ helper.eachInstance(relations, Triple, function(triple) {
+ var link = triple.link();
+ var sourceNode = triple.sourceNode();
+ var targetNode = triple.targetNode();
+
+ if (sourceNode && (component === link || component === sourceNode))
+ this.disconnect(Cmap.CONNECTION_TYPE_SOURCE, sourceNode, link);
+
+ if (targetNode && (component === link || component === targetNode))
+ this.disconnect(Cmap.CONNECTION_TYPE_TARGET, targetNode, link);
+ }.bind(this));
+
+ return;
+ }
+
+ var linkRelations = link.relations();
+ var triple = helper.firstInstance(linkRelations, Triple);
+ var nodeKey = type + 'Node';
+
+ if (!triple || triple[nodeKey]() !== node)
+ throw new Error('Not connected');
+
+ triple[nodeKey](null);
+
+ // remove triple from node
+ var nodeRelations = node.relations();
+ nodeRelations.splice(nodeRelations.indexOf(triple), 1);
+
+ // remove triple from link
+ if (!triple.sourceNode() && !triple.targetNode())
+ linkRelations.splice(linkRelations.indexOf(triple), 1);
+
+ // update connectors of link
+ helper.eachInstance(linkRelations, LinkConnectorRelation, function(relation) {
+ if (relation.type() === type)
+ relation.isConnected(false);
+ });
+
+ // do not need to mark node dirty (stay unchanged)
+ link.markDirty();
+ }
+
+ connectedNode(type, link) {
+ var triple = helper.firstInstance(link.relations(), Triple);
+
+ if (!triple)
+ return null;
+
+ return triple[type + 'Node']();
+ }
+
+ showConnector(type, link) {
+ if (this.connectorVisible(type, link))
+ return;
+
+ var disabledConnectorList = this.disabledConnectorList();
+ var connectorDisabled = disabledConnectorList.contains(type, link);
+
+ if (!connectorDisabled)
+ this.addConnector(type, link);
+ }
+
+ connectorVisible(type, link) {
+ return link.relations().some(function(relation) {
+ return relation instanceof LinkConnectorRelation && relation.type() === type;
+ });
+ }
+
+ addConnector(type, link) {
+ var connector = new Connector({
+ x: link[type + 'X'](),
+ y: link[type + 'Y']()
+ });
+
+ var linkConnectorRelation = new LinkConnectorRelation({
+ type: type,
+ link: link,
+ connector: connector
+ });
+
+ var linkRelations = link.relations();
+ var triple = helper.firstInstance(linkRelations, Triple);
+ var isConnected = (triple && !!triple[type + 'Node']());
+
+ linkConnectorRelation.isConnected(isConnected);
+ linkRelations.push(linkConnectorRelation);
+ connector.relations().push(linkConnectorRelation);
+
+ this.add(connector);
+ }
+
+ hideConnector(type, link) {
+ var linkRelations = link.relations();
+
+ for (var i = linkRelations.length - 1; i >= 0; i--) {
+ var relation = linkRelations[i];
+
+ if (!(relation instanceof LinkConnectorRelation) || relation.type() !== type)
+ continue;
+
+ // remove connector component
+ this.remove(relation.connector());
+
+ // remove link-connector relation from link
+ linkRelations.splice(i, 1);
+
+ break;
+ }
+ }
+
+ showConnectors(link) {
+ this.showConnector(Cmap.CONNECTION_TYPE_SOURCE, link);
+ this.showConnector(Cmap.CONNECTION_TYPE_TARGET, link);
+ }
+
+ hideConnectors(link) {
+ this.hideConnector(Cmap.CONNECTION_TYPE_SOURCE, link);
+ this.hideConnector(Cmap.CONNECTION_TYPE_TARGET, link);
+ }
+
+ hideAllConnectors() {
+ this.componentList().toArray().forEach(function(component) {
+ if (component instanceof Link)
+ this.hideConnectors(component);
+ }.bind(this));
+ }
+
+ enableConnector(type, link) {
+ this.disabledConnectorList().remove(type, link);
+ }
+
+ disableConnector(type, link) {
+ // remove showing connector
+ this.hideConnector(type, link);
+
+ this.disabledConnectorList().add(type, link);
+ }
+
+ connectorEnabled(type, link) {
+ return !this.disabledConnectorList().contains(type, link);
+ }
+
+ enableDrag(component) {
+ this.dragDisabledComponentList().remove(component);
+ }
+
+ disableDrag(component) {
+ this.dragDisabledComponentList().add(component);
+ }
+
+ dragEnabled(component) {
+ return !this.dragDisabledComponentList().contains(component);
+ }
+
+ onstart(x, y, event) {
+ var context = this.dragContext();
+
+ var component = this.componentList().fromPoint(Component, x, y);
+ context.component = component;
+
+ if (typeof this.selectionHandler === 'function')
+ this.selectionHandler(component, event);
+
+ if (!(component instanceof Connector))
+ this.hideAllConnectors();
+
+ if (!component)
+ return;
+
+ var draggable = !this.dragDisabledComponentList().contains(component);
+ context.draggable = draggable;
+
+ if (!draggable)
+ return;
+
+ dom.cancel(event);
+
+ this.toFront(component);
+
+ if (component instanceof Node) {
+ context.x = component.x();
+ context.y = component.y();
+ } else if (component instanceof Link) {
+ context.cx = component.cx();
+ context.cy = component.cy();
+ context.sourceX = component.sourceX();
+ context.sourceY = component.sourceY();
+ context.targetX = component.targetX();
+ context.targetY = component.targetY();
+ context.triple = helper.firstInstance(component.relations(), Triple);
+
+ this.showConnectors(component);
+ } else if (component instanceof Connector) {
+ var linkConnectorRelation = helper.firstInstance(component.relations(), LinkConnectorRelation);
+
+ context.x = x;
+ context.y = y;
+ context.type = linkConnectorRelation.type();
+ context.link = linkConnectorRelation.link();
+ }
+
+ this.fixScrollSize();
+ }
+
+ onmove(dx, dy, event) {
+ var context = this.dragContext();
+
+ var component = context.component;
+
+ if (!component)
+ return;
+
+ if (!context.draggable)
+ return;
+
+ if (component instanceof Node) {
+ var nodeX = context.x + dx;
+ var nodeY = context.y + dy;
+
+ if (typeof component.moveHandler === 'function') {
+ var constrainedPosition = component.moveHandler(nodeX, nodeY);
+
+ if (constrainedPosition && isFinite(constrainedPosition.x) && isFinite(constrainedPosition.y)) {
+ nodeX = constrainedPosition.x;
+ nodeY = constrainedPosition.y;
+ }
+ }
+
+ component.x(nodeX);
+ component.y(nodeY);
+ } else if (component instanceof Link) {
+ var cx = context.cx + dx;
+ var cy = context.cy + dy;
+ var triple = context.triple;
+ var connectedNode = null;
+
+ if (triple) {
+ var sourceNode = triple.sourceNode();
+ var targetNode = triple.targetNode();
+
+ if (sourceNode && !targetNode)
+ connectedNode = sourceNode;
+ else if (!sourceNode && targetNode)
+ connectedNode = targetNode;
+ }
+
+ if (connectedNode) {
+ // only one node connected
+ var x = cx - connectedNode.cx();
+ var y = cy - connectedNode.cy();
+ triple.updateLinkAngle(Math.atan2(y, x));
+ triple.skipNextUpdate(true);
+ } else if (!triple || component.content()) {
+ // not connected or link has content
+ // (except two nodes connected but link has no content)
+ component.cx(cx);
+ component.cy(cy);
+ component.sourceX(context.sourceX + dx);
+ component.sourceY(context.sourceY + dy);
+ component.targetX(context.targetX + dx);
+ component.targetY(context.targetY + dy);
+ }
+ } else if (component instanceof Connector) {
+ var x = context.x + dx;
+ var y = context.y + dy;
+ var type = context.type;
+ var link = context.link;
+
+ var triple = helper.firstInstance(link.relations(), Triple);
+ var connectedNode = triple ? triple[type + 'Node']() : null;
+ var node = this.componentList().fromPoint(Node, x, y);
+
+ if (connectedNode && connectedNode === node) {
+ // already connected (do nothing)
+ return;
+ }
+
+ var anotherType = Cmap.anotherConnectionType(type);
+ var anotherSideNode = triple ? triple[anotherType + 'Node']() : null;
+
+ if (connectedNode && connectedNode !== node) {
+ this.disconnect(type, connectedNode, link);
+ connectedNode = null;
+ }
+
+ var needsConnect = !connectedNode && node && anotherSideNode !== node;
+
+ if (needsConnect) {
+ if (anotherSideNode) {
+ var p = triple.connectedPoint(node, anotherSideNode.cx(), anotherSideNode.cy());
+
+ link[type + 'X'](p.x);
+ link[type + 'Y'](p.y);
+
+ triple.update(link);
+ triple.skipNextUpdate(true);
+ }
+
+ this.connect(type, node, link);
+ } else {
+ link[type + 'X'](x);
+ link[type + 'Y'](y);
+
+ if (!anotherSideNode)
+ link.straighten();
+ }
+ }
+ }
+
+ onend(dx, dy, event) {
+ var context = this.dragContext();
+
+ var component = context.component;
+
+ if (!component) {
+ this.lastClickComponent = null;
+ this.lastClickTime = 0;
+ return;
+ }
+
+ if (!context.draggable) {
+ this.lastClickComponent = null;
+ this.lastClickTime = 0;
+ return;
+ }
+
+ // dx/dy are logical map coordinates; keep the click tolerance at four
+ // physical screen pixels at every zoom level.
+ var clickTolerance = 4 / this.zoomFactor;
+ var isClick = Math.abs(dx) <= clickTolerance && Math.abs(dy) <= clickTolerance;
+
+ if (isClick) {
+ var now = Date.now();
+ var isDoubleClick = (component === this.lastClickComponent &&
+ now - this.lastClickTime <= 500);
+
+ if (isDoubleClick) {
+ this.lastClickComponent = null;
+ this.lastClickTime = 0;
+
+ if (typeof this.activationHandler === 'function')
+ this.activationHandler(component, event);
+ } else {
+ this.lastClickComponent = component;
+ this.lastClickTime = now;
+ }
+ } else {
+ this.lastClickComponent = null;
+ this.lastClickTime = 0;
+ }
+
+ if (component instanceof Node && typeof component.moveEndHandler === 'function')
+ component.moveEndHandler(component.x(), component.y(), event);
+
+ if (component instanceof Connector) {
+ var link = context.link;
+ var triple = helper.firstInstance(link.relations(), Triple);
+ var connectedNode = triple ? triple[context.type + 'Node']() : null;
+
+ if (typeof link.connectionChangeHandler === 'function')
+ link.connectionChangeHandler(context.type, connectedNode, event);
+ }
+
+ this.unfixScrollSize();
+ }
+
+ fixScrollSize() {
+ var element = this.element();
+
+ var clientWidth = dom.clientWidth(element);
+ var clientHeight = dom.clientHeight(element);
+ var scrollWidth = dom.scrollWidth(element);
+ var scrollHeight = dom.scrollHeight(element);
+
+ // check if scrolled
+ if (clientWidth === scrollWidth && clientHeight === scrollHeight)
+ return;
+
+ var translate = 'translate(' + (scrollWidth - 1) + 'px, ' + (scrollHeight - 1) + 'px)';
+
+ dom.css(this.retainerElement(), {
+ msTransform: translate,
+ transform: translate,
+ webkitTransform: translate
+ });
+ }
+
+ unfixScrollSize() {
+ var translate = 'translate(-1px, -1px)';
+
+ dom.css(this.retainerElement(), {
+ msTransform: translate,
+ transform: translate,
+ webkitTransform: translate
+ });
+ }
+
+ style() {
+ return {
+ color: '#333',
+ cursor: 'default',
+ fontFamily: 'sans-serif',
+ fontSize: '14px',
+ height: '100%',
+ MozUserSelect: 'none',
+ msUserSelect: 'none',
+ overflow: 'visible',
+ position: 'relative',
+ userSelect: 'none',
+ webkitUserSelect: 'none',
+ width: '100%',
+ zoom: this.zoomFactor
+ };
+ }
+
+ retainerStyle() {
+ return {
+ height: '1px',
+ pointerEvents: 'none',
+ position: 'absolute',
+ width: '1px'
+ };
+ }
+
+ redraw() {
+ if (this.disposed)
+ return;
+
+ var rootElement = this.rootElement();
+
+ if (!rootElement) {
+ rootElement = dom.body();
+ dom.css(rootElement, {
+ height: '100vh',
+ margin: '0',
+ width: '100vw'
+ });
+ this.rootElement(rootElement);
+ }
+
+ var previousElement = this.element();
+ var element = dom.el('
');
+ element.className = 'rw-cmap-surface';
+ dom.draggable(element, function(x, y, event) {
+ this.onstart(x / this.zoomFactor, y / this.zoomFactor, event);
+ }.bind(this), function(dx, dy, event) {
+ this.onmove(dx / this.zoomFactor, dy / this.zoomFactor, event);
+ }.bind(this), function(dx, dy, event) {
+ this.onend(dx / this.zoomFactor, dy / this.zoomFactor, event);
+ }.bind(this));
+ this.element(element);
+
+ this.componentList().toArray().forEach(function(component) {
+ component.parentElement(element);
+ });
+
+ var retainerElement = dom.el('
');
+ dom.css(retainerElement, this.retainerStyle());
+ dom.append(element, retainerElement);
+ this.retainerElement(retainerElement);
+
+ // set initial position of retainer
+ this.unfixScrollSize();
+
+ dom.css(element, this.style());
+ if (previousElement && previousElement.parentNode)
+ previousElement.parentNode.removeChild(previousElement);
+ dom.append(rootElement, element);
+ }
+}
+
+Cmap.LINK_Z_INDEX_BASE = 100;
+Cmap.NODE_Z_INDEX_BASE = 100000;
+Cmap.CONNECTOR_Z_INDEX_BASE = 200000;
+Cmap.CONNECTION_TYPE_SOURCE = 'source';
+Cmap.CONNECTION_TYPE_TARGET = 'target';
+
+export { Cmap as DrawingSurface };
diff --git a/static/cmap/model/interchange.js b/static/cmap/model/interchange.js
index 9e72837..cbcabed 100644
--- a/static/cmap/model/interchange.js
+++ b/static/cmap/model/interchange.js
@@ -1,4 +1,13 @@
-/* Versioned JSON interchange for the Racket Wiki CMap model. */
+/**
+ * Versioned JSON interchange for the Racket Wiki CMap model.
+ *
+ * The bundle separates shared concept content from map-local placement data.
+ * `buildBundle` follows map and page references, embeds the referenced page
+ * attachments, and validates the resulting object. Import code calls
+ * `validateBundle` first and then uses `preparedMapDocument` to join shared
+ * concepts back into a map document. The helpers in this module deliberately
+ * return detached JSON values so callers cannot mutate an input map or bundle.
+ */
const FORMAT = "racket-wiki-cmap-bundle";
const FORMAT_VERSION = 1;
@@ -12,10 +21,15 @@
];
const PLACEMENT_CONTENT_KEYS = new Set(CONCEPT_KEYS.filter((key) => key !== "id"));
+ /** Return a detached JSON-compatible copy while preserving undefined. */
function clone(value) {
return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
}
+ /**
+ * Check a wiki page reference in either `slug` or `namespace:slug` form.
+ * The check is shared by concept links, map metadata and exported pages.
+ */
function validPageReference(value) {
if (typeof value !== "string") return false;
const separator = value.indexOf(":");
@@ -25,6 +39,7 @@
return namespace.length <= 80 && SLUG.test(namespace) && SLUG.test(slug);
}
+ /** Accept only complete HTTP(S) URLs for external concept links. */
function validExternalUrl(value) {
try {
const url = new URL(String(value));
@@ -34,6 +49,11 @@
}
}
+ /**
+ * Decode the one or two historical JSON string wrappers used by storage.
+ * Invalid or non-object values become an empty document; bundle validation
+ * remains responsible for rejecting malformed imported data.
+ */
function decodedDocument(value) {
let documentValue = value;
for (let attempt = 0; attempt < 2 && typeof documentValue === "string"; attempt += 1) {
@@ -43,6 +63,7 @@
documentValue : {};
}
+ /** Copy only shared concept fields into the bundle-level concept table. */
function conceptContent(value) {
const result = {};
for (const key of CONCEPT_KEYS) {
@@ -51,18 +72,21 @@
return result;
}
+ /** Index valid document concepts by their stable identity. */
function conceptsById(documentValue) {
return new Map((Array.isArray(documentValue.concepts) ? documentValue.concepts : [])
.filter((concept) => concept && typeof concept.id === "string" && concept.id)
.map((concept) => [concept.id, concept]));
}
+ /** Return unique concept ids used by non-phrase placements. */
function itemConceptIds(documentValue) {
return [...new Set((Array.isArray(documentValue.items) ? documentValue.items : [])
.filter((item) => item && item.kind !== "phrase" && typeof item.conceptId === "string" && item.conceptId)
.map((item) => item.conceptId))];
}
+ /** Find linked CMap slugs in the concepts actually placed on a document. */
function linkedMapSlugs(documentValue) {
const concepts = conceptsById(documentValue);
return [...new Set(itemConceptIds(documentValue)
@@ -70,6 +94,11 @@
.filter(Boolean))];
}
+ /**
+ * Collect page references from map metadata and placed concepts.
+ * Unplaced concept records are intentionally ignored because they are not
+ * part of the visible map dependency graph.
+ */
function linkedPageReferences(documentValue) {
const references = new Set();
const metadata = documentValue.metadata && typeof documentValue.metadata === "object" ?
@@ -87,6 +116,11 @@
return references;
}
+ /**
+ * Extract local upload URLs from Markdown and HTML-like markup.
+ * A Set removes duplicates; the final prefix check removes a shorter URL
+ * accidentally captured from a URL containing a space.
+ */
function attachmentUrls(markdown) {
const source = String(markdown || "");
const urls = new Set();
@@ -102,6 +136,7 @@
other !== url && other.startsWith(`${url} `)));
}
+ /** Derive a readable fallback filename from an upload URL. */
function attachmentName(url) {
const encoded = String(url || "").split("/").at(-1) || "attachment.bin";
try {
@@ -111,6 +146,7 @@
}
}
+ /** Replace upload URLs after imported attachments receive new server URLs. */
function replaceAttachmentUrls(markdown, replacements) {
let result = String(markdown || "");
const entries = replacements instanceof Map ? [...replacements.entries()] :
@@ -123,6 +159,11 @@
return result;
}
+ /**
+ * Strip shared concept fields from placements while preserving layout data.
+ * It also keeps only connectors whose two local item endpoints still exist,
+ * because export must not emit dangling layout relations.
+ */
function placementDocument(documentValue) {
const documentCopy = clone(decodedDocument(documentValue));
const ids = itemConceptIds(documentCopy);
@@ -149,6 +190,10 @@
return documentCopy;
}
+ /**
+ * Convert one wiki page and all uploads referenced by its Markdown into a
+ * bundle page record. The attachment loader is called once per unique URL.
+ */
async function pageRecord(page, requestedReference, loadAttachment) {
const markdown = String(page.markdown || "");
const attachments = [];
@@ -176,6 +221,24 @@
};
}
+ /**
+ * Build and validate a complete export bundle.
+ *
+ * @param {object} options Export options and asynchronous repository loaders.
+ * @param {object} options.rootMap Root map with `slug`, `title` and `document`.
+ * @param {Function} options.loadConceptMap Loads a linked map by slug.
+ * @param {Function} options.loadWikiPage Loads a linked wiki page by reference.
+ * @param {Function} [options.loadAttachment] Loads base64 upload content.
+ * @param {number} [options.maxDepth=0] Maximum depth for linked CMaps.
+ * @returns {Promise