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

205 lines
7.9 KiB
JavaScript

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