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