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 "./nodeTypes";
|
||||
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