diff --git a/src/canvas/CanvasController.ts b/src/canvas/CanvasController.ts new file mode 100644 index 0000000..bd04906 --- /dev/null +++ b/src/canvas/CanvasController.ts @@ -0,0 +1,733 @@ +/** + * CanvasController — imperative owner of the JointJS graph & paper. + * + * React panels stay declarative and talk to this controller for everything that + * touches the diagram: creating nodes/links, selection, tools, routing/grid + * config, undo/redo, clipboard, zoom/pan, (de)serialization and the flow + * animation. It bridges back to the Zustand store for reactive UI state + * (selection + calc summary) via injected callbacks. + */ + +import { dia, shapes, elementTools, linkTools } from "@joint/core"; +import { + PipelineNode, + createNode, + createLink, + restyleLink, + recolorPorts, +} from "./shapes"; +import { nodeType } from "../model/nodeTypes"; +import { getColorScheme, type ColorScheme } from "../model/colorSchemes"; +import { + RelationRegistry, + canConnect, + type PortDirection, + type PortRef, +} from "../model/mediums"; +import { solveFlow, type FlowNetwork } from "../calc/flow"; +import type { EditorConfig, RouterType, Selection } from "../store/editorStore"; + +const CELL_NAMESPACE = { + ...shapes, + pipeline: { Node: PipelineNode }, +}; + +export interface ControllerHooks { + onSelect: (sel: Selection | null) => void; + onSelectionChanged: () => void; + onDirty: () => void; + onCalc: (summary: { + totalSupply: number; + totalDemand: number; + balanced: boolean; + warnings: string[]; + } | null) => void; +} + +export class CanvasController { + readonly graph: dia.Graph; + readonly paper: dia.Paper; + private relations = new RelationRegistry(); + private config: EditorConfig; + private scheme: ColorScheme; + private hooks: ControllerHooks; + + /** Multi-selection of cell ids; `primary` drives the properties panel. */ + private selected = new Set(); + private primary: string | null = null; + + private history: string[] = []; + private historyIndex = -1; + private restoring = false; + + private clipboard: unknown[] = []; + + private rafId: number | null = null; + private animating = false; + private lastTs = 0; + private dashOffsets = new Map(); + + constructor(el: HTMLElement, config: EditorConfig, hooks: ControllerHooks) { + this.config = config; + this.scheme = getColorScheme(config.colorScheme); + this.hooks = hooks; + + this.graph = new dia.Graph({}, { cellNamespace: CELL_NAMESPACE }); + this.paper = new dia.Paper({ + el, + model: this.graph, + width: "100%", + height: "100%", + gridSize: config.gridSize, + drawGrid: config.showGrid ? { name: "mesh", args: { color: this.scheme.grid } } : false, + background: { color: this.scheme.background }, + cellViewNamespace: CELL_NAMESPACE, + async: true, + sorting: dia.Paper.sorting.APPROX, + linkPinning: false, + snapLinks: { radius: 20 }, + markAvailable: true, + defaultRouter: this.routerConfig(config.router), + defaultConnector: this.connectorConfig(config.arcOnIntersection), + defaultLink: () => + createLink({ + source: { id: "", port: "" }, + target: { id: "", port: "" }, + scheme: this.scheme, + }) as unknown as dia.Link, + validateConnection: (cellViewS, magnetS, cellViewT, magnetT) => + this.validateConnection(cellViewS, magnetS, cellViewT, magnetT), + validateMagnet: (_cellView, magnet) => magnet.getAttribute("magnet") !== "passive", + }); + + this.wireEvents(); + this.snapshot(); // initial empty state + } + + // ---- configuration ------------------------------------------------------- + + private routerConfig(router: RouterType) { + switch (router) { + case "orthogonal": + return { name: "orthogonal" }; + case "normal": + return { name: "normal" }; + default: + return { name: "manhattan", args: { padding: 12, step: this.config.gridSize } }; + } + } + + private connectorConfig(arc: boolean) { + return arc + ? { name: "jumpover", args: { size: 8, jump: "arc" } } + : { name: "rounded", args: { radius: 10 } }; + } + + applyConfig(config: EditorConfig): void { + const prevScheme = this.config.colorScheme; + this.config = config; + this.scheme = getColorScheme(config.colorScheme); + + this.paper.options.defaultRouter = this.routerConfig(config.router); + this.paper.options.defaultConnector = this.connectorConfig(config.arcOnIntersection); + this.paper.setGridSize(config.gridSize); + this.paper.setGrid(config.showGrid ? { name: "mesh", args: { color: this.scheme.grid } } : false); + this.paper.drawBackground({ color: this.scheme.background }); + + // Re-route + re-style every link, recolor ports/scheme if changed. + this.graph.getLinks().forEach((l) => { + l.router(this.routerConfig(config.router)); + l.connector(this.connectorConfig(config.arcOnIntersection)); + restyleLink(l, this.scheme); + }); + if (prevScheme !== config.colorScheme) { + this.graph.getElements().forEach((e) => recolorPorts(e, this.scheme)); + } + if (this.animating) this.applyAnimationStyles(); + } + + // ---- connection validation ---------------------------------------------- + + private portRef(view: dia.CellView, magnet: SVGElement | null): PortRef | null { + const el = view.model; + if (!el.isElement() || !magnet) return null; + const portId = magnet.getAttribute("port"); + if (!portId) return null; + const port = (el as dia.Element).getPort(portId) as + | { medium?: string; direction?: PortDirection } + | undefined; + return { + nodeId: String(el.id), + portId, + medium: port?.medium ?? "water", + direction: port?.direction ?? "inout", + }; + } + + private validateConnection( + cellViewS: dia.CellView, + magnetS: SVGElement | null, + cellViewT: dia.CellView, + magnetT: SVGElement | null, + ): boolean { + if (!cellViewT.model.isElement()) return false; + const source = this.portRef(cellViewS, magnetS); + const target = this.portRef(cellViewT, magnetT); + if (!source || !target) return false; + return canConnect(source, target, this.relations).ok; + } + + // ---- events -------------------------------------------------------------- + + private wireEvents(): void { + const p = this.paper; + + p.on("element:pointerup", (view) => { + if (this.config.snapToGrid) this.snapElement(view.model as dia.Element); + this.selectCell(view.model, false); + this.commit(); + }); + p.on("element:pointerclick", (view, evt) => { + this.selectCell(view.model, evt.shiftKey === true); + }); + p.on("link:pointerclick", (view, evt) => { + this.selectCell(view.model, evt.shiftKey === true); + }); + p.on("link:connect", () => { + this.commit(); + }); + p.on("blank:pointerdown", () => { + this.clearSelection(); + }); + p.on("cell:pointerdblclick", (view) => { + // double-click focuses without toggling multi-select + this.selectCell(view.model, false); + }); + } + + private snapElement(el: dia.Element): void { + const g = this.config.gridSize; + const { x, y } = el.position(); + el.position(Math.round(x / g) * g, Math.round(y / g) * g); + } + + // ---- selection ----------------------------------------------------------- + + private selectCell(cell: dia.Cell, additive: boolean): void { + if (!additive) this.clearSelectionVisual(); + if (additive && this.selected.has(String(cell.id))) { + this.selected.delete(String(cell.id)); + this.setViewSelected(cell, false); + this.primary = this.selected.size ? [...this.selected][0] : null; + } else { + if (!additive) this.selected.clear(); + this.selected.add(String(cell.id)); + this.setViewSelected(cell, true); + this.primary = String(cell.id); + } + this.updateTools(); + this.emitSelection(); + } + + private setViewSelected(cell: dia.Cell, on: boolean): void { + const view = cell.findView(this.paper); + if (!view) return; + view.el.classList.toggle("is-selected", on); + view.el.classList.toggle("is-focused", on); + } + + private clearSelectionVisual(): void { + for (const id of this.selected) { + const cell = this.graph.getCell(id); + if (cell) this.setViewSelected(cell, false); + } + } + + clearSelection(): void { + this.clearSelectionVisual(); + this.selected.clear(); + this.primary = null; + this.removeTools(); + this.emitSelection(); + } + + private emitSelection(): void { + if (!this.primary) { + this.hooks.onSelect(null); + return; + } + const cell = this.graph.getCell(this.primary); + if (!cell) { + this.hooks.onSelect(null); + return; + } + this.hooks.onSelect({ id: this.primary, kind: cell.isLink() ? "link" : "node" }); + } + + private removeTools(): void { + this.graph.getCells().forEach((c) => c.findView(this.paper)?.removeTools()); + } + + private updateTools(): void { + this.removeTools(); + if (!this.primary) return; + const cell = this.graph.getCell(this.primary); + const view = cell?.findView(this.paper); + if (!view) return; + if (cell!.isLink()) { + view.addTools( + new dia.ToolsView({ + tools: [ + new linkTools.Vertices(), + new linkTools.SourceArrowhead(), + new linkTools.TargetArrowhead(), + new linkTools.Segments(), + new linkTools.Remove({ distance: -20 }), + ], + }), + ); + } else { + view.addTools( + new dia.ToolsView({ + tools: [ + new elementTools.Boundary({ padding: 6 }), + new elementTools.Remove({ x: "100%", y: 0, offset: { x: 8, y: -8 } }), + ], + }), + ); + } + } + + getPrimary(): dia.Cell | null { + return this.primary ? this.graph.getCell(this.primary) ?? null : null; + } + + selectById(id: string): void { + const cell = this.graph.getCell(id); + if (cell) this.selectCell(cell, false); + } + + // ---- creation ------------------------------------------------------------ + + addNodeAt(typeId: string, clientX: number, clientY: number): dia.Element | null { + const type = nodeType(typeId); + if (!type) return null; + const local = this.paper.clientToLocalPoint({ x: clientX, y: clientY }); + let x = local.x - type.size.width / 2; + let y = local.y - type.size.height / 2; + if (this.config.snapToGrid) { + const g = this.config.gridSize; + x = Math.round(x / g) * g; + y = Math.round(y / g) * g; + } + const node = createNode({ type, position: { x, y }, scheme: this.scheme }); + this.graph.addCell(node); + this.commit(); + this.selectCell(node, false); + return node; + } + + // ---- editing actions ----------------------------------------------------- + + deleteSelection(): void { + if (!this.selected.size) return; + const cells = [...this.selected].map((id) => this.graph.getCell(id)).filter(Boolean) as dia.Cell[]; + this.clearSelection(); + this.graph.removeCells(cells); + this.commit(); + } + + copySelection(): void { + const cells = [...this.selected] + .map((id) => this.graph.getCell(id)) + .filter(Boolean) as dia.Cell[]; + this.clipboard = cells.map((c) => c.toJSON()); + } + + paste(offset = 24): void { + if (!this.clipboard.length) return; + const idMap = new Map(); + const clones: dia.Cell[] = []; + + type CellJSON = { + id: string; + type?: string; + position?: { x: number; y: number }; + source?: { id?: string; port?: string }; + target?: { id?: string; port?: string }; + [k: string]: unknown; + }; + + // First pass: rebuild elements from JSON with fresh ids (robust even if the + // originals were since deleted). + for (const json of this.clipboard as CellJSON[]) { + if (json.type === "standard.Link") continue; + const { id, ...rest } = json; + const el = new PipelineNode(rest as never) as dia.Element; + idMap.set(String(id), String(el.id)); + const pos = json.position; + if (pos) el.position(pos.x + offset, pos.y + offset); + clones.push(el); + } + // Second pass: links whose both ends were copied. + for (const json of this.clipboard as CellJSON[]) { + if (json.type !== "standard.Link") continue; + const s = idMap.get(String(json.source?.id)); + const t = idMap.get(String(json.target?.id)); + if (!s || !t) continue; + const { id, source, target, ...rest } = json; + void id; + const link = new shapes.standard.Link(rest as never) as dia.Link; + link.source({ id: s, port: source?.port }); + link.target({ id: t, port: target?.port }); + clones.push(link); + } + this.graph.addCells(clones); + this.commit(); + // Select the newly pasted cells. + this.clearSelection(); + clones.forEach((c, i) => this.selectCell(c, i > 0)); + } + + duplicateSelection(): void { + this.copySelection(); + this.paste(); + } + + selectAll(): void { + this.clearSelection(); + const cells = this.graph.getCells(); + cells.forEach((c, i) => this.selectCell(c, i > 0)); + } + + /** Update a node/link property and refresh its presentation. */ + setProp(id: string, key: string, value: unknown): void { + const cell = this.graph.getCell(id); + if (!cell) return; + const props = { ...((cell.prop("props") ?? {}) as Record) }; + props[key] = value as never; + cell.prop("props", props); + + if (cell.isElement()) { + const el = cell as dia.Element; + if (key === "title") el.attr("label/text", (value as string) || nodeType(el.prop("nodeType"))?.label || ""); + if (key === "showLabel") el.attr("label/display", value === "no" ? "none" : ""); + if (key === "color") el.attr("accent/fill", value as string); + } else { + restyleLink(cell as dia.Link, this.scheme); + } + this.hooks.onDirty(); + this.commitDebounced(); + } + + /** Update geometry (position/size/rotation) of the primary element. */ + setGeometry(id: string, patch: { x?: number; y?: number; width?: number; height?: number; angle?: number }): void { + const cell = this.graph.getCell(id); + if (!cell?.isElement()) return; + const el = cell as dia.Element; + if (patch.x != null || patch.y != null) { + const pos = el.position(); + el.position(patch.x ?? pos.x, patch.y ?? pos.y); + } + if (patch.width != null || patch.height != null) { + const size = el.size(); + el.resize(patch.width ?? size.width, patch.height ?? size.height); + } + if (patch.angle != null) el.rotate(patch.angle, true); + this.commitDebounced(); + } + + // ---- zoom / pan ---------------------------------------------------------- + + zoom(factor: number, center?: { x: number; y: number }): void { + const current = this.paper.scale().sx; + const next = Math.min(3, Math.max(0.2, current * factor)); + if (center) { + const local = this.paper.clientToLocalPoint(center); + this.paper.scale(next, next); + const after = this.paper.localToClientPoint(local); + this.paper.translate( + this.paper.translate().tx + (center.x - after.x), + this.paper.translate().ty + (center.y - after.y), + ); + } else { + this.paper.scale(next, next); + } + } + + zoomIn(): void { + this.zoom(1.2); + } + zoomOut(): void { + this.zoom(1 / 1.2); + } + resetZoom(): void { + this.paper.scale(1, 1); + this.paper.translate(0, 0); + } + zoomToFit(): void { + this.paper.transformToFitContent({ padding: 60, maxScale: 1.5, minScale: 0.2, useModelGeometry: true }); + } + + panBy(dx: number, dy: number): void { + const t = this.paper.translate(); + this.paper.translate(t.tx + dx, t.ty + dy); + } + + // ---- undo / redo --------------------------------------------------------- + + private snapshot(): void { + const json = JSON.stringify(this.graph.toJSON()); + this.history = this.history.slice(0, this.historyIndex + 1); + this.history.push(json); + this.historyIndex = this.history.length - 1; + } + + private commitTimer: ReturnType | null = null; + private commitDebounced(): void { + if (this.commitTimer) clearTimeout(this.commitTimer); + this.commitTimer = setTimeout(() => this.commit(), 400); + } + + commit(): void { + if (this.restoring) return; + if (this.commitTimer) { + clearTimeout(this.commitTimer); + this.commitTimer = null; + } + this.snapshot(); + this.hooks.onDirty(); + } + + canUndo(): boolean { + return this.historyIndex > 0; + } + canRedo(): boolean { + return this.historyIndex < this.history.length - 1; + } + + undo(): void { + if (!this.canUndo()) return; + this.historyIndex--; + this.restoreSnapshot(this.history[this.historyIndex]); + } + redo(): void { + if (!this.canRedo()) return; + this.historyIndex++; + this.restoreSnapshot(this.history[this.historyIndex]); + } + + private restoreSnapshot(json: string): void { + this.restoring = true; + this.selected.clear(); + this.primary = null; + this.graph.fromJSON(JSON.parse(json)); + this.restoring = false; + // Re-apply routing/connectors/styles from config. + this.applyConfig(this.config); + this.emitSelection(); + } + + // ---- (de)serialization --------------------------------------------------- + + toDocument(): { version: number; config: EditorConfig; graph: unknown } { + return { version: 1, config: this.config, graph: this.graph.toJSON() }; + } + + loadDocument(doc: { config?: EditorConfig; graph: unknown }): void { + this.restoring = true; + this.selected.clear(); + this.primary = null; + this.graph.fromJSON(doc.graph as dia.Graph.JSON); + this.restoring = false; + if (doc.config) this.applyConfig(doc.config); + else this.applyConfig(this.config); + this.history = []; + this.historyIndex = -1; + this.snapshot(); + this.emitSelection(); + } + + clear(): void { + this.stopFlow(); + this.graph.clear(); + this.clearSelection(); + this.history = []; + this.historyIndex = -1; + this.snapshot(); + } + + // ---- flow calculation & animation --------------------------------------- + + buildNetwork(): FlowNetwork { + const nodes = this.graph.getElements().map((el) => { + const props = (el.prop("props") ?? {}) as Record; + return { + id: String(el.id), + supply: typeof props.supply === "number" ? props.supply : undefined, + demand: typeof props.demand === "number" ? props.demand : undefined, + }; + }); + const edges = this.graph + .getLinks() + .filter((l) => l.getSourceElement() && l.getTargetElement()) + .map((l) => ({ + id: String(l.id), + source: String(l.getSourceElement()!.id), + target: String(l.getTargetElement()!.id), + })); + return { nodes, edges }; + } + + runFlow(): void { + const net = this.buildNetwork(); + const result = solveFlow(net); + const max = result.maxFlow || 1; + + for (const link of this.graph.getLinks()) { + const flow = result.edgeFlow[String(link.id)] ?? 0; + const norm = flow / max; + link.prop("flow", flow); + link.prop("flowNorm", norm); + const props = { ...((link.prop("props") ?? {}) as Record) }; + props.flowRate = Math.round(Math.abs(flow) * 100) / 100; + link.prop("props", props); + } + // Annotate meter readings. + for (const el of this.graph.getElements()) { + if (el.prop("nodeType") === "meter") { + const inflow = result.nodeInflow[String(el.id)] ?? 0; + const props = { ...((el.prop("props") ?? {}) as Record) }; + props.reading = Math.round(inflow * 100) / 100; + el.prop("props", props); + } + } + + this.hooks.onCalc({ + totalSupply: result.totalSupply, + totalDemand: result.totalDemand, + balanced: result.balanced, + warnings: result.warnings, + }); + this.hooks.onSelectionChanged(); + this.startFlow(); + } + + private applyAnimationStyles(): void { + for (const link of this.graph.getLinks()) { + const flow = (link.prop("flow") as number) ?? 0; + if (Math.abs(flow) < 1e-6) { + restyleLink(link, this.scheme); + continue; + } + const norm = Math.abs((link.prop("flowNorm") as number) ?? 0); + const width = 3 + norm * 8; + link.attr("line/strokeWidth", width); + link.attr("line/strokeDasharray", "12 8"); + } + } + + startFlow(): void { + this.animating = true; + this.applyAnimationStyles(); + if (this.rafId != null) return; + this.lastTs = 0; + const tick = (ts: number) => { + if (!this.animating) { + this.rafId = null; + return; + } + const dt = this.lastTs ? (ts - this.lastTs) / 1000 : 0; + this.lastTs = ts; + const speed = this.config.animationSpeed; + for (const link of this.graph.getLinks()) { + const norm = (link.prop("flowNorm") as number) ?? 0; + if (Math.abs(norm) < 1e-6) continue; + const prev = this.dashOffsets.get(String(link.id)) ?? 0; + // Negative offset moves dashes toward the target (flow direction). + const next = prev - Math.sign(norm) * (30 + Math.abs(norm) * 120) * speed * dt; + this.dashOffsets.set(String(link.id), next); + link.attr("line/strokeDashoffset", next, { silent: false }); + } + this.rafId = requestAnimationFrame(tick); + }; + this.rafId = requestAnimationFrame(tick); + } + + stopFlow(): void { + this.animating = false; + if (this.rafId != null) { + cancelAnimationFrame(this.rafId); + this.rafId = null; + } + this.dashOffsets.clear(); + for (const link of this.graph.getLinks()) { + link.prop("flow", 0); + link.prop("flowNorm", 0); + restyleLink(link, this.scheme); + } + this.hooks.onCalc(null); + } + + // ---- export -------------------------------------------------------------- + + /** + * Serialize the diagram to a standalone SVG string. Built from the paper's + * live SVG root (the `@joint/core` build doesn't ship the format plugin), with + * the viewBox tightened to the content bounding box. + */ + exportSVG(): string { + const area = this.graph.getBBox(); + const pad = 20; + const svg = this.paper.svg.cloneNode(true) as SVGSVGElement; + // Drop tool/decoration layers from the export. + svg.querySelectorAll(".joint-tools, .joint-highlight").forEach((n) => n.remove()); + if (area) { + svg.setAttribute( + "viewBox", + `${area.x - pad} ${area.y - pad} ${area.width + pad * 2} ${area.height + pad * 2}`, + ); + svg.setAttribute("width", String(area.width + pad * 2)); + svg.setAttribute("height", String(area.height + pad * 2)); + } + svg.setAttribute("xmlns", "http://www.w3.org/2000/svg"); + return new XMLSerializer().serializeToString(svg); + } + + /** Rasterize the exported SVG to a PNG data URI (browser only). */ + async exportPNG(scale = 2): Promise { + const svg = this.exportSVG(); + const area = this.graph.getBBox(); + const pad = 20; + const w = (area ? area.width + pad * 2 : 800) * scale; + const h = (area ? area.height + pad * 2 : 600) * scale; + const url = "data:image/svg+xml;charset=utf-8," + encodeURIComponent(svg); + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => { + const canvas = document.createElement("canvas"); + canvas.width = w; + canvas.height = h; + const ctx = canvas.getContext("2d"); + if (!ctx) return reject(new Error("2D context unavailable")); + ctx.fillStyle = this.scheme.background; + ctx.fillRect(0, 0, w, h); + ctx.drawImage(img, 0, 0, w, h); + resolve(canvas.toDataURL("image/png")); + }; + img.onerror = () => reject(new Error("Failed to rasterize SVG")); + img.src = url; + }); + } + + // ---- lifecycle ----------------------------------------------------------- + + isEmpty(): boolean { + return this.graph.getCells().length === 0; + } + + destroy(): void { + this.stopFlow(); + this.paper.remove(); + } +} diff --git a/src/canvas/shapes.ts b/src/canvas/shapes.ts new file mode 100644 index 0000000..2c5b43e --- /dev/null +++ b/src/canvas/shapes.ts @@ -0,0 +1,264 @@ +/** + * JointJS shape definitions and factories for pipeline nodes and links. + * + * A `PipelineNode` renders a rounded card, the node type's SVG icon and a label, + * plus one magnet port per {@link PortDef} laid out on the four sides. Ports are + * colored by medium and only become visible when the node is focused/hovered + * (via CSS in canvas.css). Links are `standard.Link`s whose markers (arrow, + * circle, diamond, bar) and dash pattern come from the selected edge style. + */ + +import { dia, shapes } from "@joint/core"; +import type { NodeTypeDef, PortDef } from "../model/nodeTypes"; +import { iconDataUri } from "../model/svgIcons"; +import { buildDefaults } from "../model/properties"; +import { edgePropertyDefs } from "../model/edgeProperties"; +import { edgeStyle, dashArray, markerSpec, type EdgeStyleDef } from "../model/edgeStyles"; +import { mediumColor, type ColorScheme } from "../model/colorSchemes"; + +export const NODE_TYPE_NAME = "pipeline.Node"; + +/** Custom element: card + icon + label, ports supplied per instance. */ +export const PipelineNode = dia.Element.define( + NODE_TYPE_NAME, + { + size: { width: 72, height: 72 }, + attrs: { + card: { + x: 0, + y: 0, + width: "calc(w)", + height: "calc(h)", + rx: 10, + ry: 10, + fill: "#ffffff", + stroke: "#c7ced8", + strokeWidth: 1.5, + cursor: "move", + }, + icon: { + x: "calc(w/2 - 26)", + y: "calc(h/2 - 26)", + width: 52, + height: 52, + pointerEvents: "none", + }, + accent: { + x: 0, + y: 0, + width: "calc(w)", + height: 5, + rx: 3, + ry: 3, + fill: "#2f80ed", + }, + label: { + x: "calc(w/2)", + y: "calc(h + 15)", + textAnchor: "middle", + textVerticalAnchor: "middle", + fontSize: 12, + fontFamily: "system-ui, sans-serif", + fill: "#2b2f36", + pointerEvents: "none", + text: "", + }, + }, + }, + { + markup: [ + { tagName: "rect", selector: "card" }, + { tagName: "rect", selector: "accent" }, + { tagName: "image", selector: "icon" }, + { tagName: "text", selector: "label" }, + ], + }, +); + +function portGroup(side: string) { + return { + position: { name: side }, + label: { + position: { name: side === "left" || side === "right" ? side : side, args: { y: 0 } }, + markup: [{ tagName: "text", selector: "portLabel" }], + }, + attrs: { + portBody: { + magnet: "active", + r: 6, + fill: "#2f80ed", + stroke: "#ffffff", + strokeWidth: 2, + }, + portLabel: { + fontSize: 9, + fill: "#5b6472", + fontFamily: "system-ui, sans-serif", + }, + }, + markup: [{ tagName: "circle", selector: "portBody" }], + }; +} + +export interface CreateNodeOptions { + type: NodeTypeDef; + position: { x: number; y: number }; + scheme: ColorScheme; + id?: string; + props?: Record; +} + +/** Instantiate a placed node from a node type definition. */ +export function createNode(opts: CreateNodeOptions): dia.Element { + const { type, position, scheme } = opts; + const node = new PipelineNode({ + ...(opts.id ? { id: opts.id } : {}), + position, + size: { ...type.size }, + }); + + node.attr("icon/xlinkHref", iconDataUri(type.icon)); + const props = { ...buildDefaults(type.properties), ...(opts.props ?? {}) }; + node.attr("label/text", (props.title as string) || type.label); + node.attr("accent/fill", (props.color as string) || "#2f80ed"); + + // Port groups (unique sides used by this type). + const sides = [...new Set(type.ports.map((p) => p.side))]; + const groups: Record> = {}; + for (const side of sides) groups[side] = portGroup(side); + node.set("ports", { groups, items: [] }); + + for (const port of type.ports) addPortItem(node, port, scheme); + + node.prop("nodeType", type.id); + node.prop("props", props); + return node; +} + +export function addPortItem(node: dia.Element, port: PortDef, scheme: ColorScheme): void { + // Custom `medium`/`direction` keys are preserved by JointJS on the port item + // and used by validateConnection & recoloring; cast past the strict Port type. + node.addPort({ + id: port.id, + group: port.side, + attrs: { + portBody: { + fill: mediumColor(scheme, port.medium), + magnet: "active", + }, + portLabel: { text: port.label ?? "" }, + }, + medium: port.medium, + direction: port.direction, + } as unknown as dia.Element.Port); +} + +/** Re-apply port colors after a color scheme change. */ +export function recolorPorts(node: dia.Element, scheme: ColorScheme): void { + const ports = node.getPorts(); + for (const p of ports) { + const medium = (p as { medium?: string }).medium ?? "water"; + node.portProp(p.id!, "attrs/portBody/fill", mediumColor(scheme, medium)); + } +} + +/** Resolve an edge's effective color from its props + scheme. */ +export function linkColor( + props: Record, + scheme: ColorScheme, +): string { + if (props.useMediumColor === "no" && typeof props.colorOverride === "string") { + return props.colorOverride; + } + return mediumColor(scheme, (props.medium as string) || "water"); +} + +/** Build the JointJS `line` attrs (stroke, dash, markers) for an edge style. */ +export function buildLinkLineAttrs( + style: EdgeStyleDef, + color: string, +): Record { + const attrs: Record = { + connection: true, + stroke: color, + strokeWidth: style.width, + strokeLinejoin: "round", + fill: "none", + strokeDasharray: dashArray(style.line, style.width), + }; + const src = markerSpec(style.sourceMarker, color); + const tgt = markerSpec(style.targetMarker, color); + attrs.sourceMarker = src ? markerToJoint(src) : { type: "path", d: "M 0 0" }; + attrs.targetMarker = tgt ? markerToJoint(tgt) : { type: "path", d: "M 0 0" }; + return attrs; +} + +function markerToJoint(spec: NonNullable>) { + if (spec.type === "circle") { + return { type: "circle", r: spec.r, fill: spec.fill, stroke: spec.fill }; + } + return { + type: "path", + d: spec.d, + fill: spec.fill, + stroke: spec.fill === "none" ? undefined : spec.fill, + "stroke-width": spec.fill === "none" ? 2 : 0, + }; +} + +export interface CreateLinkOptions { + source: { id: string; port: string }; + target: { id: string; port: string }; + scheme: ColorScheme; + props?: Record; + id?: string; +} + +/** Build the props bag for a new link (edge). */ +export function defaultLinkProps(): Record { + return buildDefaults(edgePropertyDefs()); +} + +/** Create a link (edge) between two ports. */ +export function createLink(opts: CreateLinkOptions): dia.Link { + const props = { ...defaultLinkProps(), ...(opts.props ?? {}) }; + const style = edgeStyle(props.style as string); + const color = linkColor(props, opts.scheme); + + const link = new shapes.standard.Link({ + ...(opts.id ? { id: opts.id } : {}), + source: { id: opts.source.id, port: opts.source.port }, + target: { id: opts.target.id, port: opts.target.port }, + z: -1, + }); + link.attr("line", buildLinkLineAttrs(style, color)); + applyLinkLabel(link, props); + link.prop("props", props); + return link; +} + +export function applyLinkLabel(link: dia.Link, props: Record): void { + const title = (props.title as string) || ""; + link.labels( + title + ? [ + { + position: 0.5, + attrs: { + text: { text: title, fontSize: 11, fill: "#2b2f36", fontFamily: "system-ui, sans-serif" }, + rect: { fill: "#ffffff", stroke: "#c7ced8", strokeWidth: 1, rx: 3, ry: 3 }, + }, + }, + ] + : [], + ); +} + +/** Re-apply an edge's visual style from its current props + scheme. */ +export function restyleLink(link: dia.Link, scheme: ColorScheme): void { + const props = (link.prop("props") ?? {}) as Record; + const style = edgeStyle(props.style as string); + const color = linkColor(props, scheme); + link.attr("line", buildLinkLineAttrs(style, color)); + applyLinkLabel(link, props); +} diff --git a/src/model/edgeProperties.ts b/src/model/edgeProperties.ts new file mode 100644 index 0000000..8428006 --- /dev/null +++ b/src/model/edgeProperties.ts @@ -0,0 +1,67 @@ +/** + * Property schema for edges (connectors), grouped like node properties. + * Presentation controls the visual style/medium/color; physics feeds the mock + * solver (length, diameter, flow type). `flowRate` is filled in by the solver + * and shown read-only. + */ + +import { EDGE_STYLES } from "./edgeStyles"; +import { DEFAULT_MEDIUMS } from "./mediums"; +import type { PropertyDef } from "./properties"; + +export function edgePropertyDefs(): PropertyDef[] { + return [ + { key: "title", label: "Title", type: "string", group: "identity", default: "" }, + { key: "tag", label: "Tag / ID", type: "string", group: "identity", default: "" }, + { + key: "style", + label: "Style", + type: "enum", + group: "presentation", + default: "pipe", + options: EDGE_STYLES.map((s) => ({ value: s.id, label: s.label })), + }, + { + key: "medium", + label: "Medium", + type: "enum", + group: "presentation", + default: "water", + options: DEFAULT_MEDIUMS.map((m) => ({ value: m.id, label: m.label })), + }, + { + key: "colorOverride", + label: "Color override", + type: "color", + group: "presentation", + default: "#2f80ed", + description: "Used only when 'Use medium color' is off.", + }, + { + key: "useMediumColor", + label: "Use medium color", + type: "enum", + group: "presentation", + default: "yes", + options: [ + { value: "yes", label: "Yes" }, + { value: "no", label: "No" }, + ], + }, + { key: "spec", label: "Pipe spec", type: "catalogueItem", group: "physics", catalogue: "pipes" }, + { key: "length", label: "Length", type: "float", group: "physics", unit: "m", default: 10, min: 0 }, + { key: "diameter", label: "Diameter", type: "float", group: "physics", unit: "mm", default: 100, min: 0 }, + { + key: "flowType", + label: "Flow type", + type: "enum", + group: "physics", + default: "pressurized", + options: [ + { value: "pressurized", label: "Pressurized" }, + { value: "gravity", label: "Gravity" }, + ], + }, + { key: "flowRate", label: "Flow rate (calc)", type: "float", group: "physics", unit: "L/s", default: 0, readonly: true }, + ]; +} diff --git a/src/model/index.ts b/src/model/index.ts index 7b0574b..f1f59df 100644 --- a/src/model/index.ts +++ b/src/model/index.ts @@ -5,3 +5,4 @@ export * from "./catalogues"; export * from "./svgIcons"; export * from "./nodeTypes"; export * from "./edgeStyles"; +export * from "./edgeProperties"; diff --git a/src/store/editorStore.ts b/src/store/editorStore.ts new file mode 100644 index 0000000..0341459 --- /dev/null +++ b/src/store/editorStore.ts @@ -0,0 +1,94 @@ +/** + * Global editor state (config, selection, catalogues, solver status). The + * JointJS graph/paper themselves live in the CanvasController (imperative, + * non-reactive); this store holds the reactive UI state React panels bind to. + */ + +import { create } from "zustand"; +import { DEFAULT_CATALOGUES, type Catalogue } from "../model/catalogues"; +import { DEFAULT_MEDIUMS, type MediumDef } from "../model/mediums"; + +export type RouterType = "manhattan" | "orthogonal" | "normal"; + +export interface EditorConfig { + colorScheme: string; + /** Draw an arc where crossing links intersect (JointJS jumpover connector). */ + arcOnIntersection: boolean; + router: RouterType; + gridSize: number; + showGrid: boolean; + snapToGrid: boolean; + /** Flow animation speed multiplier (0 = paused). */ + animationSpeed: number; +} + +export const DEFAULT_CONFIG: EditorConfig = { + colorScheme: "default", + arcOnIntersection: true, + router: "manhattan", + gridSize: 20, + showGrid: true, + snapToGrid: true, + animationSpeed: 1, +}; + +export type SelectionKind = "node" | "link"; + +export interface Selection { + id: string; + kind: SelectionKind; +} + +export interface CalcSummary { + totalSupply: number; + totalDemand: number; + balanced: boolean; + warnings: string[]; +} + +interface EditorState { + config: EditorConfig; + catalogues: Catalogue[]; + mediums: MediumDef[]; + selection: Selection | null; + /** Bumped whenever the selected element's props change, to refresh panels. */ + selectionRev: number; + paletteQuery: string; + running: boolean; + calc: CalcSummary | null; + documentName: string; + dirty: boolean; + + setConfig: (patch: Partial) => void; + setSelection: (sel: Selection | null) => void; + bumpSelection: () => void; + setPaletteQuery: (q: string) => void; + setRunning: (running: boolean) => void; + setCalc: (calc: CalcSummary | null) => void; + setDocumentName: (name: string) => void; + setDirty: (dirty: boolean) => void; + setCatalogues: (catalogues: Catalogue[]) => void; +} + +export const useEditorStore = create((set) => ({ + config: DEFAULT_CONFIG, + catalogues: DEFAULT_CATALOGUES, + mediums: DEFAULT_MEDIUMS, + selection: null, + selectionRev: 0, + paletteQuery: "", + running: false, + calc: null, + documentName: "Untitled network", + dirty: false, + + setConfig: (patch) => set((s) => ({ config: { ...s.config, ...patch } })), + setSelection: (selection) => set({ selection }), + bumpSelection: () => set((s) => ({ selectionRev: s.selectionRev + 1 })), + setPaletteQuery: (paletteQuery) => set({ paletteQuery }), + setRunning: (running) => set({ running }), + setCalc: (calc) => set({ calc }), + setDocumentName: (documentName) => set({ documentName }), + setDirty: (dirty) => set({ dirty }), + setCatalogues: (catalogues) => set({ catalogues }), +}));