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:
parent
22ad8b3e0c
commit
ab5a5e1a15
733
src/canvas/CanvasController.ts
Normal file
733
src/canvas/CanvasController.ts
Normal 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
264
src/canvas/shapes.ts
Normal 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);
|
||||||
|
}
|
||||||
67
src/model/edgeProperties.ts
Normal file
67
src/model/edgeProperties.ts
Normal 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 },
|
||||||
|
];
|
||||||
|
}
|
||||||
@ -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
94
src/store/editorStore.ts
Normal 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 }),
|
||||||
|
}));
|
||||||
Loading…
x
Reference in New Issue
Block a user