feat(canvas): JointJS shapes + CanvasController engine

- PipelineNode custom element (card + SVG icon + medium-colored ports) and
  standard.Link with style-driven markers/dash
- CanvasController: paper/graph setup, relation-based validateConnection,
  manhattan routing + jumpover arc connector toggle, grid/snap, color scheme
- Selection (single/multi/shift), element & link tools, drop-to-add node
- Undo/redo via graph snapshots, copy/cut/paste/duplicate, zoom/pan/fit
- Flow calc integration + dash animation (direction=sign, speed=volume)
- SVG/PNG export from paper SVG root; document (de)serialization
- Zustand editor store (config, selection, catalogues, calc summary)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ilya Ashikhmin 2026-07-02 23:21:09 +02:00
parent 22ad8b3e0c
commit ab5a5e1a15
5 changed files with 1159 additions and 0 deletions

View File

@ -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<string>();
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<string, number>();
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<string, string>();
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<string, unknown>) };
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<typeof setTimeout> | 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<string, unknown>;
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<string, unknown>) };
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<string, unknown>) };
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<string> {
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();
}
}

264
src/canvas/shapes.ts Normal file
View File

@ -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<string, unknown>;
}
/** 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<string, ReturnType<typeof portGroup>> = {};
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<string, unknown>,
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<string, unknown> {
const attrs: Record<string, unknown> = {
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<ReturnType<typeof markerSpec>>) {
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<string, unknown>;
id?: string;
}
/** Build the props bag for a new link (edge). */
export function defaultLinkProps(): Record<string, unknown> {
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<string, unknown>): 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<string, unknown>;
const style = edgeStyle(props.style as string);
const color = linkColor(props, scheme);
link.attr("line", buildLinkLineAttrs(style, color));
applyLinkLabel(link, props);
}

View File

@ -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 },
];
}

View File

@ -5,3 +5,4 @@ export * from "./catalogues";
export * from "./svgIcons"; export * from "./svgIcons";
export * from "./nodeTypes"; export * from "./nodeTypes";
export * from "./edgeStyles"; export * from "./edgeStyles";
export * from "./edgeProperties";

94
src/store/editorStore.ts Normal file
View File

@ -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<EditorConfig>) => 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<EditorState>((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 }),
}));