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

185 lines
6.2 KiB
JavaScript

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");
}
}