From d495401bc125ff9435618515b4ba28270abdac39 Mon Sep 17 00:00:00 2001 From: Ilya Ashikhmin Date: Thu, 2 Jul 2026 23:32:27 +0200 Subject: [PATCH] feat(ui): full React editor UI, IO, demo seed, and tests - graphOps: paper-independent graph logic (add/connect/network/flow/serialize) so core is unit-testable in jsdom; CanvasController delegates to it - App shell: toolbar, node palette (grouped/searchable/draggable), tabbed properties + configuration panels, status bar with calc summary - PropertyField editors for all 8 property types with validation - Keyboard shortcuts (undo/redo/copy/cut/paste/dup/select-all/delete/zoom) - Document IO (Tauri dialog+fs with browser download/upload fallback), SVG/PNG - Demo network seeded on first launch - Tests: 5 graphOps integration + 9 UI component (47 total) Co-Authored-By: Claude Opus 4.8 (1M context) --- src/App.css | 184 --------------- src/App.tsx | 142 +++--------- src/assets/hero.png | Bin 13057 -> 0 bytes src/assets/react.svg | 1 - src/assets/vite.svg | 1 - src/canvas/CanvasController.ts | 154 ++++++------- src/canvas/canvas.css | 47 ++++ src/canvas/demo.ts | 31 +++ src/canvas/graphOps.test.ts | 98 ++++++++ src/canvas/graphOps.ts | 160 +++++++++++++ src/index.css | 127 +++-------- src/io/document.ts | 111 +++++++++ src/main.tsx | 12 +- src/ui/CanvasView.tsx | 73 ++++++ src/ui/ConfigPanel.tsx | 97 ++++++++ src/ui/EditorContext.tsx | 9 + src/ui/NodePalette.tsx | 92 ++++++++ src/ui/PropertiesPanel.tsx | 121 ++++++++++ src/ui/RightPanel.tsx | 21 ++ src/ui/StatusBar.tsx | 31 +++ src/ui/Toolbar.tsx | 112 ++++++++++ src/ui/components.test.tsx | 125 +++++++++++ src/ui/editor.css | 373 +++++++++++++++++++++++++++++++ src/ui/editors/PropertyField.tsx | 210 +++++++++++++++++ src/ui/useKeyboardShortcuts.ts | 50 +++++ vite.config.ts | 8 +- 26 files changed, 1897 insertions(+), 493 deletions(-) delete mode 100644 src/App.css delete mode 100644 src/assets/hero.png delete mode 100644 src/assets/react.svg delete mode 100644 src/assets/vite.svg create mode 100644 src/canvas/canvas.css create mode 100644 src/canvas/demo.ts create mode 100644 src/canvas/graphOps.test.ts create mode 100644 src/canvas/graphOps.ts create mode 100644 src/io/document.ts create mode 100644 src/ui/CanvasView.tsx create mode 100644 src/ui/ConfigPanel.tsx create mode 100644 src/ui/EditorContext.tsx create mode 100644 src/ui/NodePalette.tsx create mode 100644 src/ui/PropertiesPanel.tsx create mode 100644 src/ui/RightPanel.tsx create mode 100644 src/ui/StatusBar.tsx create mode 100644 src/ui/Toolbar.tsx create mode 100644 src/ui/components.test.tsx create mode 100644 src/ui/editor.css create mode 100644 src/ui/editors/PropertyField.tsx create mode 100644 src/ui/useKeyboardShortcuts.ts diff --git a/src/App.css b/src/App.css deleted file mode 100644 index f90339d..0000000 --- a/src/App.css +++ /dev/null @@ -1,184 +0,0 @@ -.counter { - font-size: 16px; - padding: 5px 10px; - border-radius: 5px; - color: var(--accent); - background: var(--accent-bg); - border: 2px solid transparent; - transition: border-color 0.3s; - margin-bottom: 24px; - - &:hover { - border-color: var(--accent-border); - } - &:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; - } -} - -.hero { - position: relative; - - .base, - .framework, - .vite { - inset-inline: 0; - margin: 0 auto; - } - - .base { - width: 170px; - position: relative; - z-index: 0; - } - - .framework, - .vite { - position: absolute; - } - - .framework { - z-index: 1; - top: 34px; - height: 28px; - transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) - scale(1.4); - } - - .vite { - z-index: 0; - top: 107px; - height: 26px; - width: auto; - transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) - scale(0.8); - } -} - -#center { - display: flex; - flex-direction: column; - gap: 25px; - place-content: center; - place-items: center; - flex-grow: 1; - - @media (max-width: 1024px) { - padding: 32px 20px 24px; - gap: 18px; - } -} - -#next-steps { - display: flex; - border-top: 1px solid var(--border); - text-align: left; - - & > div { - flex: 1 1 0; - padding: 32px; - @media (max-width: 1024px) { - padding: 24px 20px; - } - } - - .icon { - margin-bottom: 16px; - width: 22px; - height: 22px; - } - - @media (max-width: 1024px) { - flex-direction: column; - text-align: center; - } -} - -#docs { - border-right: 1px solid var(--border); - - @media (max-width: 1024px) { - border-right: none; - border-bottom: 1px solid var(--border); - } -} - -#next-steps ul { - list-style: none; - padding: 0; - display: flex; - gap: 8px; - margin: 32px 0 0; - - .logo { - height: 18px; - } - - a { - color: var(--text-h); - font-size: 16px; - border-radius: 6px; - background: var(--social-bg); - display: flex; - padding: 6px 12px; - align-items: center; - gap: 8px; - text-decoration: none; - transition: box-shadow 0.3s; - - &:hover { - box-shadow: var(--shadow); - } - .button-icon { - height: 18px; - width: 18px; - } - } - - @media (max-width: 1024px) { - margin-top: 20px; - flex-wrap: wrap; - justify-content: center; - - li { - flex: 1 1 calc(50% - 8px); - } - - a { - width: 100%; - justify-content: center; - box-sizing: border-box; - } - } -} - -#spacer { - height: 88px; - border-top: 1px solid var(--border); - @media (max-width: 1024px) { - height: 48px; - } -} - -.ticks { - position: relative; - width: 100%; - - &::before, - &::after { - content: ''; - position: absolute; - top: -4.5px; - border: 5px solid transparent; - } - - &::before { - left: 0; - border-left-color: var(--border); - } - &::after { - right: 0; - border-right-color: var(--border); - } -} diff --git a/src/App.tsx b/src/App.tsx index a66b5ef..22d136e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,122 +1,30 @@ -import { useState } from 'react' -import reactLogo from './assets/react.svg' -import viteLogo from './assets/vite.svg' -import heroImg from './assets/hero.png' -import './App.css' +import { useState } from "react"; +import type { CanvasController } from "./canvas/CanvasController"; +import { EditorContext } from "./ui/EditorContext"; +import { CanvasView } from "./ui/CanvasView"; +import { NodePalette } from "./ui/NodePalette"; +import { RightPanel } from "./ui/RightPanel"; +import { Toolbar } from "./ui/Toolbar"; +import { StatusBar } from "./ui/StatusBar"; +import "./ui/editor.css"; +import "./canvas/canvas.css"; -function App() { - const [count, setCount] = useState(0) +export default function App() { + const [controller, setController] = useState(null); return ( - <> -
-
- - React logo - Vite logo + +
+ +
+ +
+ +
+
-
-

Get started

-

- Edit src/App.tsx and save to test HMR -

-
- -
- -
- -
-
- -

Documentation

-

Your questions, answered

- -
-
- -

Connect with us

-

Join the Vite community

- -
-
- -
-
- - ) + + + + ); } - -export default App diff --git a/src/assets/hero.png b/src/assets/hero.png deleted file mode 100644 index 02251f4b956c55af2d76fd0788124d7eee2b45eb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13057 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 P00000NkvXXu0mjft=yBf diff --git a/src/assets/react.svg b/src/assets/react.svg deleted file mode 100644 index 6c87de9..0000000 --- a/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/assets/vite.svg b/src/assets/vite.svg deleted file mode 100644 index 5101b67..0000000 --- a/src/assets/vite.svg +++ /dev/null @@ -1 +0,0 @@ -Vite diff --git a/src/canvas/CanvasController.ts b/src/canvas/CanvasController.ts index bd04906..0e31801 100644 --- a/src/canvas/CanvasController.ts +++ b/src/canvas/CanvasController.ts @@ -9,29 +9,28 @@ */ import { dia, shapes, elementTools, linkTools } from "@joint/core"; -import { - PipelineNode, - createNode, - createLink, - restyleLink, - recolorPorts, -} from "./shapes"; +import { PipelineNode, createLink, restyleLink, recolorPorts } from "./shapes"; import { nodeType } from "../model/nodeTypes"; +import { + CELL_NAMESPACE, + routerConfig, + connectorConfig, + addNodeToGraph, + connectInGraph, + buildNetwork, + computeAndAnnotateFlow, + clearFlow, +} from "./graphOps"; import { getColorScheme, type ColorScheme } from "../model/colorSchemes"; import { RelationRegistry, canConnect, - type PortDirection, type PortRef, + type PortDirection, } from "../model/mediums"; -import { solveFlow, type FlowNetwork } from "../calc/flow"; +import 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; @@ -87,8 +86,8 @@ export class CanvasController { linkPinning: false, snapLinks: { radius: 20 }, markAvailable: true, - defaultRouter: this.routerConfig(config.router), - defaultConnector: this.connectorConfig(config.arcOnIntersection), + defaultRouter: this.routerCfg(config.router), + defaultConnector: this.connectorCfg(config.arcOnIntersection), defaultLink: () => createLink({ source: { id: "", port: "" }, @@ -106,21 +105,12 @@ export class CanvasController { // ---- 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 routerCfg(router: RouterType) { + return routerConfig(router, this.config.gridSize); } - private connectorConfig(arc: boolean) { - return arc - ? { name: "jumpover", args: { size: 8, jump: "arc" } } - : { name: "rounded", args: { radius: 10 } }; + private connectorCfg(arc: boolean) { + return connectorConfig(arc); } applyConfig(config: EditorConfig): void { @@ -128,16 +118,16 @@ export class CanvasController { 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.options.defaultRouter = this.routerCfg(config.router); + this.paper.options.defaultConnector = this.connectorCfg(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)); + l.router(this.routerCfg(config.router)); + l.connector(this.connectorCfg(config.arcOnIntersection)); restyleLink(l, this.scheme); }); if (prevScheme !== config.colorScheme) { @@ -193,7 +183,21 @@ export class CanvasController { p.on("link:pointerclick", (view, evt) => { this.selectCell(view.model, evt.shiftKey === true); }); - p.on("link:connect", () => { + p.on("link:connect", (linkView) => { + // Adopt the source port's medium so the new link colors correctly. + const link = linkView.model as dia.Link; + const src = link.getSourceElement(); + const portId = link.source().port; + if (src && portId) { + const port = src.getPort(portId) as { medium?: string } | undefined; + if (port?.medium) { + const props = { ...((link.prop("props") ?? {}) as Record) }; + props.medium = port.medium; + link.prop("props", props); + restyleLink(link, this.scheme); + } + } + this.selectCell(link, false); this.commit(); }); p.on("blank:pointerdown", () => { @@ -309,6 +313,35 @@ export class CanvasController { // ---- creation ------------------------------------------------------------ + /** Add a node at local (paper) coordinates. Used by the demo seed and tests. */ + addNode( + typeId: string, + x: number, + y: number, + props?: Record, + ): dia.Element | null { + return addNodeToGraph(this.graph, this.scheme, typeId, x, y, props); + } + + /** Connect two ports programmatically; returns the link or null if invalid. */ + connect( + sourceId: string, + sourcePort: string, + targetId: string, + targetPort: string, + props?: Record, + ): dia.Link | null { + return connectInGraph(this.graph, this.scheme, this.relations, { + sourceId, + sourcePort, + targetId, + targetPort, + router: this.routerCfg(this.config.router), + connector: this.connectorCfg(this.config.arcOnIntersection), + props, + }); + } + addNodeAt(typeId: string, clientX: number, clientY: number): dia.Element | null { const type = nodeType(typeId); if (!type) return null; @@ -320,8 +353,8 @@ export class CanvasController { 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); + const node = addNodeToGraph(this.graph, this.scheme, typeId, x, y); + if (!node) return null; this.commit(); this.selectCell(node, false); return node; @@ -560,49 +593,11 @@ export class CanvasController { // ---- flow calculation & animation --------------------------------------- buildNetwork(): FlowNetwork { - const nodes = this.graph.getElements().map((el) => { - const props = (el.prop("props") ?? {}) as Record; - return { - id: String(el.id), - supply: typeof props.supply === "number" ? props.supply : undefined, - demand: typeof props.demand === "number" ? props.demand : undefined, - }; - }); - const edges = this.graph - .getLinks() - .filter((l) => l.getSourceElement() && l.getTargetElement()) - .map((l) => ({ - id: String(l.id), - source: String(l.getSourceElement()!.id), - target: String(l.getTargetElement()!.id), - })); - return { nodes, edges }; + return buildNetwork(this.graph); } runFlow(): void { - const net = this.buildNetwork(); - const result = solveFlow(net); - const max = result.maxFlow || 1; - - for (const link of this.graph.getLinks()) { - const flow = result.edgeFlow[String(link.id)] ?? 0; - const norm = flow / max; - link.prop("flow", flow); - link.prop("flowNorm", norm); - const props = { ...((link.prop("props") ?? {}) as Record) }; - props.flowRate = Math.round(Math.abs(flow) * 100) / 100; - link.prop("props", props); - } - // Annotate meter readings. - for (const el of this.graph.getElements()) { - if (el.prop("nodeType") === "meter") { - const inflow = result.nodeInflow[String(el.id)] ?? 0; - const props = { ...((el.prop("props") ?? {}) as Record) }; - props.reading = Math.round(inflow * 100) / 100; - el.prop("props", props); - } - } - + const result = computeAndAnnotateFlow(this.graph); this.hooks.onCalc({ totalSupply: result.totalSupply, totalDemand: result.totalDemand, @@ -661,11 +656,8 @@ export class CanvasController { 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); - } + clearFlow(this.graph); + for (const link of this.graph.getLinks()) restyleLink(link, this.scheme); this.hooks.onCalc(null); } diff --git a/src/canvas/canvas.css b/src/canvas/canvas.css new file mode 100644 index 0000000..ff803ce --- /dev/null +++ b/src/canvas/canvas.css @@ -0,0 +1,47 @@ +/* JointJS canvas: port reveal-on-focus, selection glow, cursors. */ + +.canvas-host { + width: 100%; + height: 100%; +} + +.canvas-host .joint-paper { + width: 100% !important; + height: 100% !important; +} + +/* Ports are hidden until the node is hovered or focused/selected. */ +.joint-element .joint-port { + opacity: 0; + transition: opacity 0.12s ease; +} +.joint-element:hover .joint-port, +.joint-element.is-focused .joint-port, +.joint-element.is-selected .joint-port { + opacity: 1; +} +.joint-port-body { + cursor: crosshair; +} + +/* Selection emphasis (works for both nodes and links). */ +.joint-cell.is-selected { + filter: drop-shadow(0 0 3px rgba(47, 109, 246, 0.9)); +} +.joint-element.is-selected [joint-selector="card"] { + stroke: var(--accent); + stroke-width: 2.5; +} + +/* Highlight available magnets while drawing a link. */ +.joint-port-body.available-magnet { + stroke: #1f9d55; + stroke-width: 3; +} +.joint-highlight-stroke { + stroke: var(--accent); +} + +.joint-link .connection-wrap { + cursor: pointer; +} diff --git a/src/canvas/demo.ts b/src/canvas/demo.ts new file mode 100644 index 0000000..e163dd8 --- /dev/null +++ b/src/canvas/demo.ts @@ -0,0 +1,31 @@ +/** + * Seed a demonstration pipeline network so the editor opens with something + * meaningful: a source feeding a pump and meter into a junction that splits to + * a storage tank and two demand outlets. Supplies/demands are set so the mock + * solver produces visible, balanced flow. + */ + +import type { CanvasController } from "./CanvasController"; + +export function seedDemoNetwork(controller: CanvasController): void { + const source = controller.addNode("water-source", 60, 240, { title: "Reservoir", supply: 12 }); + const pump = controller.addNode("pump", 220, 240, { title: "Booster pump", model: "p-medium" }); + const meter = controller.addNode("meter", 380, 250, { title: "FT-01" }); + const junction = controller.addNode("junction", 520, 252, { title: "J1" }); + const tank = controller.addNode("tank", 500, 80, { title: "Buffer tank" }); + const valve = controller.addNode("valve", 700, 120, { title: "V-01", state: "open" }); + const sinkA = controller.addNode("water-sink", 860, 120, { title: "Zone A", demand: 5 }); + const sinkB = controller.addNode("water-sink", 700, 300, { title: "Zone B", demand: 7 }); + + if (!source || !pump || !meter || !junction || !tank || !valve || !sinkA || !sinkB) return; + + controller.connect(source.id as string, "out", pump.id as string, "in", { title: "Suction main" }); + controller.connect(pump.id as string, "out", meter.id as string, "in"); + controller.connect(meter.id as string, "out", junction.id as string, "p-left"); + controller.connect(junction.id as string, "p-top", tank.id as string, "in"); + controller.connect(tank.id as string, "out", valve.id as string, "in", { style: "bidirectional" }); + controller.connect(valve.id as string, "out", sinkA.id as string, "in"); + controller.connect(junction.id as string, "p-bottom", sinkB.id as string, "in", { style: "signal" }); + + controller.commit(); +} diff --git a/src/canvas/graphOps.test.ts b/src/canvas/graphOps.test.ts new file mode 100644 index 0000000..23ed6f0 --- /dev/null +++ b/src/canvas/graphOps.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect } from "vitest"; +import { + makeGraph, + addNodeToGraph, + connectInGraph, + buildNetwork, + computeAndAnnotateFlow, + routerConfig, + connectorConfig, +} from "./graphOps"; +import { RelationRegistry } from "../model/mediums"; +import { getColorScheme } from "../model/colorSchemes"; + +const scheme = getColorScheme("default"); +const relations = new RelationRegistry(); + +function connect(graph: ReturnType, s: string, sp: string, t: string, tp: string) { + return connectInGraph(graph, scheme, relations, { + sourceId: s, + sourcePort: sp, + targetId: t, + targetPort: tp, + router: routerConfig("manhattan", 20), + connector: connectorConfig(true), + }); +} + +describe("graphOps", () => { + it("adds nodes with the right ports and props", () => { + const g = makeGraph(); + const pump = addNodeToGraph(g, scheme, "pump", 0, 0, { title: "P1", power: 5 }); + expect(pump).toBeTruthy(); + expect(pump!.prop("nodeType")).toBe("pump"); + expect(pump!.getPorts().map((p) => p.id).sort()).toEqual(["in", "out"]); + expect((pump!.prop("props") as Record).title).toBe("P1"); + expect(g.getElements()).toHaveLength(1); + }); + + it("returns null for an unknown node type", () => { + const g = makeGraph(); + expect(addNodeToGraph(g, scheme, "does-not-exist", 0, 0)).toBeNull(); + }); + + it("connects compatible ports and rejects incompatible ones", () => { + const g = makeGraph(); + const src = addNodeToGraph(g, scheme, "water-source", 0, 0)!; + const sink = addNodeToGraph(g, scheme, "water-sink", 200, 0)!; + const gas = addNodeToGraph(g, scheme, "compressor", 0, 200)!; + + // valid: source.out(water) -> sink.in(water) + expect(connect(g, src.id as string, "out", sink.id as string, "in")).toBeTruthy(); + // invalid medium: source.out(water) -> compressor.in(gas) + expect(connect(g, src.id as string, "out", gas.id as string, "in")).toBeNull(); + // invalid direction: source.out -> source.out (same node anyway) + expect(connect(g, src.id as string, "out", src.id as string, "out")).toBeNull(); + expect(g.getLinks()).toHaveLength(1); + }); + + it("extracts a flow network respecting drawn direction and runs the solver", () => { + const g = makeGraph(); + const src = addNodeToGraph(g, scheme, "water-source", 0, 0, { supply: 10 })!; + const meter = addNodeToGraph(g, scheme, "meter", 200, 0)!; + const sink = addNodeToGraph(g, scheme, "water-sink", 400, 0, { demand: 10 })!; + connect(g, src.id as string, "out", meter.id as string, "in"); + connect(g, meter.id as string, "out", sink.id as string, "in"); + + const net = buildNetwork(g); + expect(net.nodes).toHaveLength(3); + expect(net.edges).toHaveLength(2); + + const result = computeAndAnnotateFlow(g); + expect(result.balanced).toBe(true); + // Links carry flow + flowRate; meter carries a reading. + for (const link of g.getLinks()) { + expect(Math.abs((link.prop("flow") as number) - 10)).toBeLessThan(1e-6); + expect((link.prop("props") as Record).flowRate).toBe(10); + } + expect((meter.prop("props") as Record).reading).toBe(10); + }); + + it("survives a JSON round-trip preserving nodes, links and props", () => { + const g = makeGraph(); + const src = addNodeToGraph(g, scheme, "water-source", 40, 40, { supply: 7, title: "R" })!; + const sink = addNodeToGraph(g, scheme, "water-sink", 300, 40, { demand: 7 })!; + connect(g, src.id as string, "out", sink.id as string, "in"); + + const json = g.toJSON(); + const g2 = makeGraph(); + g2.fromJSON(json); + expect(g2.getElements()).toHaveLength(2); + expect(g2.getLinks()).toHaveLength(1); + const src2 = g2.getCell(src.id) ; + expect((src2.prop("props") as Record).title).toBe("R"); + // Ports (with custom medium/direction) survive serialization. + const port = (src2 as never as { getPort: (id: string) => { medium?: string } }).getPort("out"); + expect(port.medium).toBe("water"); + }); +}); diff --git a/src/canvas/graphOps.ts b/src/canvas/graphOps.ts new file mode 100644 index 0000000..47fc8fc --- /dev/null +++ b/src/canvas/graphOps.ts @@ -0,0 +1,160 @@ +/** + * Paper-independent graph operations. These work on a bare `dia.Graph` and so + * are unit-testable in jsdom (where `dia.Paper` cannot mount because jsdom has + * no SVG layout). The CanvasController delegates node/link creation, network + * extraction, flow annotation and (de)serialization here. + */ + +import { dia, shapes } from "@joint/core"; +import { PipelineNode, createNode, createLink } from "./shapes"; +import { nodeType } from "../model/nodeTypes"; +import type { ColorScheme } from "../model/colorSchemes"; +import { + RelationRegistry, + canConnect, + type PortDirection, + type PortRef, +} from "../model/mediums"; +import { solveFlow, type FlowNetwork, type FlowResult } from "../calc/flow"; +import type { RouterType } from "../store/editorStore"; + +export const CELL_NAMESPACE = { + ...shapes, + pipeline: { Node: PipelineNode }, +}; + +export function makeGraph(): dia.Graph { + return new dia.Graph({}, { cellNamespace: CELL_NAMESPACE }); +} + +export function routerConfig(router: RouterType, step: number) { + switch (router) { + case "orthogonal": + return { name: "orthogonal" }; + case "normal": + return { name: "normal" }; + default: + return { name: "manhattan", args: { padding: 12, step } }; + } +} + +export function connectorConfig(arc: boolean) { + return arc + ? { name: "jumpover", args: { size: 8, jump: "arc" } } + : { name: "rounded", args: { radius: 10 } }; +} + +export function addNodeToGraph( + graph: dia.Graph, + scheme: ColorScheme, + typeId: string, + x: number, + y: number, + props?: Record, +): dia.Element | null { + const type = nodeType(typeId); + if (!type) return null; + const node = createNode({ type, position: { x, y }, scheme, props }); + graph.addCell(node); + return node; +} + +function portRefOf(el: dia.Element | undefined, portId: string): PortRef | null { + if (!el) return null; + const port = el.getPort(portId) as { medium?: string; direction?: PortDirection } | undefined; + return { + nodeId: String(el.id), + portId, + medium: port?.medium ?? "water", + direction: port?.direction ?? "inout", + }; +} + +export function connectInGraph( + graph: dia.Graph, + scheme: ColorScheme, + relations: RelationRegistry, + opts: { + sourceId: string; + sourcePort: string; + targetId: string; + targetPort: string; + router: ReturnType; + connector: ReturnType; + props?: Record; + }, +): dia.Link | null { + const s = graph.getCell(opts.sourceId) as dia.Element | undefined; + const t = graph.getCell(opts.targetId) as dia.Element | undefined; + const source = portRefOf(s, opts.sourcePort); + const target = portRefOf(t, opts.targetPort); + if (!source || !target) return null; + if (!canConnect(source, target, relations).ok) return null; + + const link = createLink({ + source: { id: opts.sourceId, port: opts.sourcePort }, + target: { id: opts.targetId, port: opts.targetPort }, + scheme, + props: opts.props, + }); + link.router(opts.router); + link.connector(opts.connector); + graph.addCell(link); + return link; +} + +/** Extract the flow network (respecting drawn edge direction) from the graph. */ +export function buildNetwork(graph: dia.Graph): FlowNetwork { + const nodes = graph.getElements().map((el) => { + const props = (el.prop("props") ?? {}) as Record; + return { + id: String(el.id), + supply: typeof props.supply === "number" ? props.supply : undefined, + demand: typeof props.demand === "number" ? props.demand : undefined, + }; + }); + const edges = 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 }; +} + +/** + * Solve the network and write results back onto the graph: each link gets + * `flow`/`flowNorm` and a `flowRate` prop; meters get their `reading` prop. + * Returns the raw {@link FlowResult}. + */ +export function computeAndAnnotateFlow(graph: dia.Graph): FlowResult { + const result = solveFlow(buildNetwork(graph)); + const max = result.maxFlow || 1; + + for (const link of graph.getLinks()) { + const flow = result.edgeFlow[String(link.id)] ?? 0; + link.prop("flow", flow); + link.prop("flowNorm", flow / max); + const props = { ...((link.prop("props") ?? {}) as Record) }; + props.flowRate = Math.round(Math.abs(flow) * 100) / 100; + link.prop("props", props); + } + for (const el of graph.getElements()) { + if (el.prop("nodeType") === "meter") { + const inflow = result.nodeInflow[String(el.id)] ?? 0; + const props = { ...((el.prop("props") ?? {}) as Record) }; + props.reading = Math.round(inflow * 100) / 100; + el.prop("props", props); + } + } + return result; +} + +export function clearFlow(graph: dia.Graph): void { + for (const link of graph.getLinks()) { + link.prop("flow", 0); + link.prop("flowNorm", 0); + } +} diff --git a/src/index.css b/src/index.css index 5fb3313..c4f9700 100644 --- a/src/index.css +++ b/src/index.css @@ -1,111 +1,38 @@ :root { - --text: #6b6375; - --text-h: #08060d; - --bg: #fff; - --border: #e5e4e7; - --code-bg: #f4f3ec; - --accent: #aa3bff; - --accent-bg: rgba(170, 59, 255, 0.1); - --accent-border: rgba(170, 59, 255, 0.5); - --social-bg: rgba(244, 243, 236, 0.5); - --shadow: - rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px; - - --sans: system-ui, 'Segoe UI', Roboto, sans-serif; - --heading: system-ui, 'Segoe UI', Roboto, sans-serif; - --mono: ui-monospace, Consolas, monospace; - - font: 18px/145% var(--sans); - letter-spacing: 0.18px; - color-scheme: light dark; + --bg: #eef1f5; + --panel: #ffffff; + --panel-2: #f4f6f9; + --border: #d7dce3; + --text: #23272f; + --text-muted: #6b7280; + --accent: #2f6df6; + --accent-weak: #e6efff; + --danger: #e0402c; + --ok: #1f9d55; + --warn: #c9820a; + --toolbar-h: 48px; + --status-h: 28px; + font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; color: var(--text); - background: var(--bg); - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - - @media (max-width: 1024px) { - font-size: 16px; - } } -@media (prefers-color-scheme: dark) { - :root { - --text: #9ca3af; - --text-h: #f3f4f6; - --bg: #16171d; - --border: #2e303a; - --code-bg: #1f2028; - --accent: #c084fc; - --accent-bg: rgba(192, 132, 252, 0.15); - --accent-border: rgba(192, 132, 252, 0.5); - --social-bg: rgba(47, 48, 58, 0.5); - --shadow: - rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px; - } - - #social .button-icon { - filter: invert(1) brightness(2); - } -} - -#root { - width: 1126px; - max-width: 100%; - margin: 0 auto; - text-align: center; - border-inline: 1px solid var(--border); - min-height: 100svh; - display: flex; - flex-direction: column; +* { box-sizing: border-box; } +html, +body, +#root { + margin: 0; + height: 100%; + width: 100%; +} + body { - margin: 0; + background: var(--bg); + overflow: hidden; } -h1, -h2 { - font-family: var(--heading); - font-weight: 500; - color: var(--text-h); -} - -h1 { - font-size: 56px; - letter-spacing: -1.68px; - margin: 32px 0; - @media (max-width: 1024px) { - font-size: 36px; - margin: 20px 0; - } -} -h2 { - font-size: 24px; - line-height: 118%; - letter-spacing: -0.24px; - margin: 0 0 8px; - @media (max-width: 1024px) { - font-size: 20px; - } -} -p { - margin: 0; -} - -code, -.counter { - font-family: var(--mono); - display: inline-flex; - border-radius: 4px; - color: var(--text-h); -} - -code { - font-size: 15px; - line-height: 135%; - padding: 4px 8px; - background: var(--code-bg); +button { + font-family: inherit; } diff --git a/src/io/document.ts b/src/io/document.ts new file mode 100644 index 0000000..6e8cb7e --- /dev/null +++ b/src/io/document.ts @@ -0,0 +1,111 @@ +/** + * Document IO: save/open the diagram JSON and export SVG/PNG. Runs natively + * through Tauri's dialog + fs plugins when available, and degrades gracefully to + * browser download / file-input when running in a plain browser (e.g. dev, + * tests). All Tauri modules are imported lazily so the browser build never + * touches them. + */ + +export function isTauri(): boolean { + return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window; +} + +export interface DiagramDocument { + version: number; + config: unknown; + graph: unknown; +} + +function triggerDownload(filename: string, content: Blob): void { + const url = URL.createObjectURL(content); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); +} + +/** Save the document. Returns the chosen path (Tauri) or null (browser). */ +export async function saveDocument( + doc: DiagramDocument, + suggestedName = "network.pipeline.json", +): Promise { + const json = JSON.stringify(doc, null, 2); + if (isTauri()) { + const { save } = await import("@tauri-apps/plugin-dialog"); + const { writeTextFile } = await import("@tauri-apps/plugin-fs"); + const path = await save({ + defaultPath: suggestedName, + filters: [{ name: "Pipeline network", extensions: ["json"] }], + }); + if (!path) return null; + await writeTextFile(path, json); + return path; + } + triggerDownload(suggestedName, new Blob([json], { type: "application/json" })); + return null; +} + +/** Open a document. Returns the parsed doc or null if cancelled. */ +export async function openDocument(): Promise { + if (isTauri()) { + const { open } = await import("@tauri-apps/plugin-dialog"); + const { readTextFile } = await import("@tauri-apps/plugin-fs"); + const path = await open({ + multiple: false, + filters: [{ name: "Pipeline network", extensions: ["json"] }], + }); + if (!path || typeof path !== "string") return null; + const text = await readTextFile(path); + return JSON.parse(text) as DiagramDocument; + } + return new Promise((resolve) => { + const input = document.createElement("input"); + input.type = "file"; + input.accept = "application/json,.json"; + input.onchange = () => { + const file = input.files?.[0]; + if (!file) return resolve(null); + const reader = new FileReader(); + reader.onload = () => { + try { + resolve(JSON.parse(String(reader.result)) as DiagramDocument); + } catch { + resolve(null); + } + }; + reader.readAsText(file); + }; + input.click(); + }); +} + +export async function exportSvgFile(svg: string, name = "network.svg"): Promise { + if (isTauri()) { + const { save } = await import("@tauri-apps/plugin-dialog"); + const { writeTextFile } = await import("@tauri-apps/plugin-fs"); + const path = await save({ defaultPath: name, filters: [{ name: "SVG", extensions: ["svg"] }] }); + if (path) await writeTextFile(path, svg); + return; + } + triggerDownload(name, new Blob([svg], { type: "image/svg+xml" })); +} + +export async function exportPngFile(dataUri: string, name = "network.png"): Promise { + if (isTauri()) { + const { save } = await import("@tauri-apps/plugin-dialog"); + const { writeFile } = await import("@tauri-apps/plugin-fs"); + const path = await save({ defaultPath: name, filters: [{ name: "PNG", extensions: ["png"] }] }); + if (path) { + const base64 = dataUri.split(",")[1] ?? ""; + const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0)); + await writeFile(path, bytes); + } + return; + } + const base64 = dataUri.split(",")[1] ?? ""; + const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0)); + triggerDownload(name, new Blob([bytes], { type: "image/png" })); +} diff --git a/src/main.tsx b/src/main.tsx index bef5202..eff7ccc 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,10 +1,10 @@ -import { StrictMode } from 'react' -import { createRoot } from 'react-dom/client' -import './index.css' -import App from './App.tsx' +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import "./index.css"; +import App from "./App.tsx"; -createRoot(document.getElementById('root')!).render( +createRoot(document.getElementById("root")!).render( , -) +); diff --git a/src/ui/CanvasView.tsx b/src/ui/CanvasView.tsx new file mode 100644 index 0000000..3864d54 --- /dev/null +++ b/src/ui/CanvasView.tsx @@ -0,0 +1,73 @@ +import { useEffect, useRef } from "react"; +import { CanvasController } from "../canvas/CanvasController"; +import { seedDemoNetwork } from "../canvas/demo"; +import { useEditorStore } from "../store/editorStore"; +import { useKeyboardShortcuts } from "./useKeyboardShortcuts"; + +interface Props { + onReady: (controller: CanvasController) => void; +} + +/** + * Hosts the JointJS paper and owns the CanvasController lifecycle. Bridges the + * imperative controller to the reactive store via hooks and keeps the canvas + * config in sync. Also handles drop-from-palette. + */ +export function CanvasView({ onReady }: Props) { + const hostRef = useRef(null); + const controllerRef = useRef(null); + const store = useEditorStore; + + useKeyboardShortcuts(controllerRef); + + useEffect(() => { + if (!hostRef.current) return; + const s = store.getState(); + const controller = new CanvasController(hostRef.current, s.config, { + onSelect: (sel) => { + store.getState().setSelection(sel); + store.getState().bumpSelection(); + }, + onSelectionChanged: () => store.getState().bumpSelection(), + onDirty: () => store.getState().setDirty(true), + onCalc: (calc) => store.getState().setCalc(calc), + }); + controllerRef.current = controller; + + if (controller.isEmpty()) { + seedDemoNetwork(controller); + controller.zoomToFit(); + store.getState().setDirty(false); + } + + onReady(controller); + + return () => { + controller.destroy(); + controllerRef.current = null; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Keep canvas config in sync with the store. + const config = useEditorStore((st) => st.config); + useEffect(() => { + controllerRef.current?.applyConfig(config); + }, [config]); + + return ( +
{ + e.preventDefault(); + e.dataTransfer.dropEffect = "copy"; + }} + onDrop={(e) => { + e.preventDefault(); + const typeId = e.dataTransfer.getData("application/x-node-type"); + if (typeId) controllerRef.current?.addNodeAt(typeId, e.clientX, e.clientY); + }} + /> + ); +} diff --git a/src/ui/ConfigPanel.tsx b/src/ui/ConfigPanel.tsx new file mode 100644 index 0000000..6b646ae --- /dev/null +++ b/src/ui/ConfigPanel.tsx @@ -0,0 +1,97 @@ +import { useEditorStore } from "../store/editorStore"; +import { COLOR_SCHEMES } from "../model/colorSchemes"; + +/** Editor configuration: color scheme, routing, grid/snap, animation. */ +export function ConfigPanel() { + const config = useEditorStore((s) => s.config); + const setConfig = useEditorStore((s) => s.setConfig); + + return ( +
+
Configuration
+
+
+ Appearance + +
+ +
+ Routing + + +
+ +
+ Grid + + + +
+ +
+ Flow animation + +
+
+
+ ); +} diff --git a/src/ui/EditorContext.tsx b/src/ui/EditorContext.tsx new file mode 100644 index 0000000..e1f3249 --- /dev/null +++ b/src/ui/EditorContext.tsx @@ -0,0 +1,9 @@ +import { createContext, useContext } from "react"; +import type { CanvasController } from "../canvas/CanvasController"; + +export const EditorContext = createContext(null); + +/** Access the CanvasController; null until the canvas has mounted. */ +export function useController(): CanvasController | null { + return useContext(EditorContext); +} diff --git a/src/ui/NodePalette.tsx b/src/ui/NodePalette.tsx new file mode 100644 index 0000000..ac0d825 --- /dev/null +++ b/src/ui/NodePalette.tsx @@ -0,0 +1,92 @@ +import { useMemo, useState } from "react"; +import { nodeTypesByGroup, type NodeTypeDef } from "../model/nodeTypes"; +import { iconDataUri } from "../model/svgIcons"; +import { useEditorStore } from "../store/editorStore"; +import { useController } from "./EditorContext"; + +/** + * Left palette: node types grouped and collapsible, with a search filter. + * Items are draggable onto the canvas (HTML5 DnD) and also add-on-click as a + * fallback (dropped at the viewport center). + */ +export function NodePalette() { + const query = useEditorStore((s) => s.paletteQuery); + const setQuery = useEditorStore((s) => s.setPaletteQuery); + const controller = useController(); + const [collapsed, setCollapsed] = useState>({}); + + const groups = useMemo(() => { + const q = query.trim().toLowerCase(); + return nodeTypesByGroup() + .map((g) => ({ + group: g.group, + types: q + ? g.types.filter( + (t) => t.label.toLowerCase().includes(q) || t.group.toLowerCase().includes(q), + ) + : g.types, + })) + .filter((g) => g.types.length > 0); + }, [query]); + + const addToCenter = (t: NodeTypeDef) => { + const host = document.querySelector(".canvas-host") as HTMLElement | null; + if (!host || !controller) return; + const r = host.getBoundingClientRect(); + controller.addNodeAt(t.id, r.left + r.width / 2, r.top + r.height / 2); + }; + + return ( + + ); +} diff --git a/src/ui/PropertiesPanel.tsx b/src/ui/PropertiesPanel.tsx new file mode 100644 index 0000000..26d9e0b --- /dev/null +++ b/src/ui/PropertiesPanel.tsx @@ -0,0 +1,121 @@ +import { useMemo } from "react"; +import { useEditorStore } from "../store/editorStore"; +import { useController } from "./EditorContext"; +import { PropertyField } from "./editors/PropertyField"; +import { + PROPERTY_GROUPS, + sortByGroup, + type PropertyDef, + type PropertyValue, +} from "../model/properties"; +import { nodeType } from "../model/nodeTypes"; +import { edgePropertyDefs } from "../model/edgeProperties"; +import { cataloguesById } from "../model/catalogues"; +import type { dia } from "@joint/core"; + +/** + * Right panel showing grouped, editable properties for the current selection + * (node or edge). Reflects geometry live and writes edits back through the + * controller, which updates the canvas presentation. + */ +export function PropertiesPanel() { + const selection = useEditorStore((s) => s.selection); + const rev = useEditorStore((s) => s.selectionRev); + const catalogues = useEditorStore((s) => s.catalogues); + const controller = useController(); + + const cataloguesMap = useMemo(() => cataloguesById(catalogues), [catalogues]); + + const cell = selection && controller ? controller.getPrimary() : null; + + const model = useMemo(() => { + if (!cell || !selection) return null; + const props = (cell.prop("props") ?? {}) as Record; + if (selection.kind === "node") { + const el = cell as dia.Element; + const type = nodeType(el.prop("nodeType")); + const geom: PropertyDef[] = [ + { key: "__x", label: "X", type: "int", group: "geometry", unit: "px" }, + { key: "__y", label: "Y", type: "int", group: "geometry", unit: "px" }, + { key: "__w", label: "Width", type: "int", group: "geometry", unit: "px", min: 24 }, + { key: "__h", label: "Height", type: "int", group: "geometry", unit: "px", min: 24 }, + { key: "__angle", label: "Rotation", type: "int", group: "geometry", unit: "°" }, + ]; + const pos = el.position(); + const size = el.size(); + const geomValues: Record = { + __x: Math.round(pos.x), + __y: Math.round(pos.y), + __w: Math.round(size.width), + __h: Math.round(size.height), + __angle: Math.round(el.angle()), + }; + return { + title: type?.label ?? "Node", + defs: [...(type?.properties ?? []), ...geom], + values: { ...props, ...geomValues }, + }; + } + return { + title: "Connector", + defs: edgePropertyDefs(), + values: props, + }; + }, [cell, selection, rev]); // rev forces refresh when props change externally + + if (!selection || !cell || !model) { + return ( +
+
Properties
+
Select a node or connector to edit its properties.
+
+ ); + } + + const commit = (def: PropertyDef, value: PropertyValue) => { + if (!controller) return; + if (def.group === "geometry") { + const map: Record = { + __x: "x", + __y: "y", + __w: "width", + __h: "height", + __angle: "angle", + }; + controller.setGeometry(String(cell.id), { [map[def.key]]: Number(value) }); + useEditorStore.getState().bumpSelection(); + return; + } + controller.setProp(String(cell.id), def.key, value); + useEditorStore.getState().bumpSelection(); + }; + + const grouped = PROPERTY_GROUPS.map((g) => ({ + group: g, + defs: sortByGroup(model.defs).filter((d) => d.group === g.id), + })).filter((x) => x.defs.length > 0); + + return ( +
+
+ Properties{model.title} +
+
+ {grouped.map(({ group, defs }) => ( +
+ {group.label} + {defs.map((def) => ( + commit(def, v)} + /> + ))} +
+ ))} +
+
+ ); +} diff --git a/src/ui/RightPanel.tsx b/src/ui/RightPanel.tsx new file mode 100644 index 0000000..275eb75 --- /dev/null +++ b/src/ui/RightPanel.tsx @@ -0,0 +1,21 @@ +import { useState } from "react"; +import { PropertiesPanel } from "./PropertiesPanel"; +import { ConfigPanel } from "./ConfigPanel"; + +/** Right dock: switch between element Properties and editor Configuration. */ +export function RightPanel() { + const [tab, setTab] = useState<"properties" | "config">("properties"); + return ( + + ); +} diff --git a/src/ui/StatusBar.tsx b/src/ui/StatusBar.tsx new file mode 100644 index 0000000..270e10f --- /dev/null +++ b/src/ui/StatusBar.tsx @@ -0,0 +1,31 @@ +import { useEditorStore } from "../store/editorStore"; + +/** Bottom status bar: selection info + flow simulation summary/warnings. */ +export function StatusBar() { + const selection = useEditorStore((s) => s.selection); + const calc = useEditorStore((s) => s.calc); + const running = useEditorStore((s) => s.running); + + return ( +
+ + {selection ? `Selected: ${selection.kind}` : "No selection"} + + {running && calc && ( + <> + + Supply {calc.totalSupply.toFixed(1)} · Demand {calc.totalDemand.toFixed(1)} ·{" "} + {calc.balanced ? "balanced" : "unbalanced"} + + {calc.warnings.length > 0 && ( + + ⚠ {calc.warnings.length} warning{calc.warnings.length > 1 ? "s" : ""} + + )} + + )} + + Drag from the palette · click a node to reveal ports · drag a port to connect +
+ ); +} diff --git a/src/ui/Toolbar.tsx b/src/ui/Toolbar.tsx new file mode 100644 index 0000000..80b151c --- /dev/null +++ b/src/ui/Toolbar.tsx @@ -0,0 +1,112 @@ +import { useEditorStore } from "../store/editorStore"; +import { useController } from "./EditorContext"; +import { + saveDocument, + openDocument, + exportSvgFile, + exportPngFile, + type DiagramDocument, +} from "../io/document"; + +/** Top toolbar: document actions, history, zoom, and the flow simulation. */ +export function Toolbar() { + const controller = useController(); + const running = useEditorStore((s) => s.running); + const setRunning = useEditorStore((s) => s.setRunning); + const dirty = useEditorStore((s) => s.dirty); + const documentName = useEditorStore((s) => s.documentName); + const setDocumentName = useEditorStore((s) => s.setDocumentName); + const setDirty = useEditorStore((s) => s.setDirty); + + const guard = (fn: (c: NonNullable) => T) => () => { + if (controller) fn(controller); + }; + + const onNew = () => { + if (!controller) return; + if (dirty && !confirm("Discard unsaved changes and start a new network?")) return; + controller.clear(); + setDocumentName("Untitled network"); + setDirty(false); + setRunning(false); + }; + + const onOpen = async () => { + const doc = await openDocument(); + if (doc && controller) { + controller.loadDocument(doc as unknown as { config?: never; graph: unknown }); + setDirty(false); + setRunning(false); + controller.zoomToFit(); + } + }; + + const onSave = async () => { + if (!controller) return; + await saveDocument(controller.toDocument() as unknown as DiagramDocument, `${documentName}.json`); + setDirty(false); + }; + + const onExportSvg = async () => { + if (!controller) return; + await exportSvgFile(controller.exportSVG(), `${documentName}.svg`); + }; + + const onExportPng = async () => { + if (!controller) return; + await exportPngFile(await controller.exportPNG(), `${documentName}.png`); + }; + + const onToggleRun = () => { + if (!controller) return; + if (running) { + controller.stopFlow(); + setRunning(false); + } else { + controller.runFlow(); + setRunning(true); + } + }; + + return ( +
+
◆ Pipeline Editor
+ +
+ + + +
+ +
+ + +
+ +
+ + + + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ ); +} diff --git a/src/ui/components.test.tsx b/src/ui/components.test.tsx new file mode 100644 index 0000000..8d2892e --- /dev/null +++ b/src/ui/components.test.tsx @@ -0,0 +1,125 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { NodePalette } from "./NodePalette"; +import { ConfigPanel } from "./ConfigPanel"; +import { PropertiesPanel } from "./PropertiesPanel"; +import { EditorContext } from "./EditorContext"; +import { useEditorStore, DEFAULT_CONFIG } from "../store/editorStore"; +import type { CanvasController } from "../canvas/CanvasController"; + +function resetStore() { + useEditorStore.setState({ + config: { ...DEFAULT_CONFIG }, + selection: null, + selectionRev: 0, + paletteQuery: "", + running: false, + calc: null, + }); +} + +function renderWithController(ui: React.ReactElement, controller: CanvasController | null = null) { + return render({ui}); +} + +beforeEach(resetStore); + +describe("NodePalette", () => { + it("renders component groups and items", () => { + renderWithController(); + expect(screen.getByText("Sources & sinks")).toBeInTheDocument(); + expect(screen.getByText("Active equipment")).toBeInTheDocument(); + expect(screen.getByText("Pump")).toBeInTheDocument(); + }); + + it("filters items by search query", async () => { + const user = userEvent.setup(); + renderWithController(); + await user.type(screen.getByLabelText("Search components"), "pump"); + expect(screen.getByText("Pump")).toBeInTheDocument(); + expect(screen.queryByText("Storage tank")).not.toBeInTheDocument(); + }); + + it("shows an empty message when nothing matches", async () => { + const user = userEvent.setup(); + renderWithController(); + await user.type(screen.getByLabelText("Search components"), "zzzzz"); + expect(screen.getByText(/No components match/)).toBeInTheDocument(); + }); + + it("carries the node type id on drag start", () => { + renderWithController(); + const item = screen.getByTitle(/Pump — drag onto canvas/); + const data: Record = {}; + const dataTransfer = { + setData: (k: string, v: string) => (data[k] = v), + effectAllowed: "", + }; + item.dispatchEvent( + Object.assign(new Event("dragstart", { bubbles: true }), { dataTransfer }), + ); + expect(data["application/x-node-type"]).toBe("pump"); + }); +}); + +describe("ConfigPanel", () => { + it("toggles arc-on-intersection into the store", async () => { + const user = userEvent.setup(); + renderWithController(); + expect(useEditorStore.getState().config.arcOnIntersection).toBe(true); + await user.click(screen.getByLabelText(/Arc on line intersection/i)); + expect(useEditorStore.getState().config.arcOnIntersection).toBe(false); + }); + + it("changes the color scheme", async () => { + const user = userEvent.setup(); + renderWithController(); + await user.selectOptions(screen.getByDisplayValue("Default"), "dark"); + expect(useEditorStore.getState().config.colorScheme).toBe("dark"); + }); + + it("updates the router", async () => { + const user = userEvent.setup(); + renderWithController(); + await user.selectOptions(screen.getByDisplayValue("Manhattan (grid)"), "normal"); + expect(useEditorStore.getState().config.router).toBe("normal"); + }); +}); + +describe("PropertiesPanel", () => { + it("shows an empty hint with no selection", () => { + renderWithController(); + expect(screen.getByText(/Select a node or connector/i)).toBeInTheDocument(); + }); + + it("renders grouped fields for a selected node via a stub controller", () => { + // Minimal controller stub exposing getPrimary + setProp/setGeometry. + const props: Record = { title: "Booster", power: 7.5, color: "#2f80ed", showLabel: "yes" }; + const fakeCell = { + id: "n1", + prop: (k: string) => (k === "nodeType" ? "pump" : k === "props" ? props : undefined), + position: () => ({ x: 20, y: 40 }), + size: () => ({ width: 72, height: 72 }), + angle: () => 0, + }; + const controller = { + getPrimary: () => fakeCell, + setProp: () => {}, + setGeometry: () => {}, + } as unknown as CanvasController; + + useEditorStore.setState({ selection: { id: "n1", kind: "node" }, selectionRev: 1 }); + renderWithController(, controller); + + // Group legends present. + expect(screen.getByText("Identity")).toBeInTheDocument(); + expect(screen.getByText("Physics")).toBeInTheDocument(); + expect(screen.getByText("Geometry")).toBeInTheDocument(); + // A physics field with its value. + const physics = screen.getByText("Physics").closest("fieldset")!; + expect(within(physics).getByText(/Power/)).toBeInTheDocument(); + // Identity title value bound. + expect(screen.getByDisplayValue("Booster")).toBeInTheDocument(); + }); +}); diff --git a/src/ui/editor.css b/src/ui/editor.css new file mode 100644 index 0000000..760a65c --- /dev/null +++ b/src/ui/editor.css @@ -0,0 +1,373 @@ +.editor-shell { + display: flex; + flex-direction: column; + height: 100vh; + width: 100vw; +} + +/* ---------- Toolbar ---------- */ +.toolbar { + height: var(--toolbar-h); + min-height: var(--toolbar-h); + display: flex; + align-items: center; + gap: 10px; + padding: 0 12px; + background: var(--panel); + border-bottom: 1px solid var(--border); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); + z-index: 5; +} +.toolbar-brand { + font-weight: 700; + color: var(--accent); + margin-right: 6px; + white-space: nowrap; +} +.toolbar-group { + display: flex; + gap: 2px; + padding: 2px; + border-radius: 8px; + background: var(--panel-2); +} +.toolbar-group button { + border: none; + background: transparent; + padding: 6px 10px; + border-radius: 6px; + cursor: pointer; + color: var(--text); + font-size: 13px; +} +.toolbar-group button:hover { + background: #e4e9f1; +} +.toolbar-spacer { + flex: 1; +} +.run-btn { + border: 1px solid var(--accent); + background: var(--accent); + color: #fff; + padding: 7px 14px; + border-radius: 8px; + cursor: pointer; + font-weight: 600; +} +.run-btn.running { + background: var(--danger); + border-color: var(--danger); +} + +/* ---------- Body layout ---------- */ +.editor-body { + flex: 1; + display: flex; + min-height: 0; +} +.canvas-area { + flex: 1; + position: relative; + min-width: 0; + background: var(--panel-2); +} + +.panel { + background: var(--panel); + display: flex; + flex-direction: column; + min-height: 0; +} +.palette { + width: 230px; + border-right: 1px solid var(--border); +} +.right-panel { + width: 320px; + border-left: 1px solid var(--border); +} +.panel-header { + padding: 10px 12px; + font-weight: 600; + font-size: 13px; + border-bottom: 1px solid var(--border); + display: flex; + justify-content: space-between; + align-items: baseline; +} +.panel-sub { + font-weight: 400; + color: var(--text-muted); + font-size: 12px; +} + +/* ---------- Palette ---------- */ +.palette-search { + padding: 8px; + border-bottom: 1px solid var(--border); +} +.palette-search input { + width: 100%; + padding: 6px 8px; + border: 1px solid var(--border); + border-radius: 6px; + font-size: 13px; +} +.palette-scroll { + flex: 1; + overflow-y: auto; + padding: 6px; +} +.palette-empty { + color: var(--text-muted); + font-size: 13px; + padding: 12px; +} +.palette-group { + margin-bottom: 6px; +} +.palette-group-header { + width: 100%; + text-align: left; + border: none; + background: transparent; + font-weight: 600; + font-size: 12px; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.03em; + padding: 6px 4px; + cursor: pointer; + display: flex; + align-items: center; + gap: 6px; +} +.chevron { + transition: transform 0.15s; +} +.chevron.collapsed { + transform: rotate(-90deg); +} +.palette-items { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 6px; +} +.palette-item { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 8px 4px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--panel-2); + cursor: grab; + text-align: center; + transition: border-color 0.12s, transform 0.06s; +} +.palette-item:hover { + border-color: var(--accent); + background: var(--accent-weak); +} +.palette-item:active { + transform: scale(0.97); +} +.palette-icon { + width: 40px; + height: 40px; + pointer-events: none; +} +.palette-label { + font-size: 11px; + line-height: 1.2; +} +.palette-hint { + padding: 8px 10px; + font-size: 11px; + color: var(--text-muted); + border-top: 1px solid var(--border); +} + +/* ---------- Tabs / right panel ---------- */ +.tabs { + display: flex; + border-bottom: 1px solid var(--border); +} +.tabs button { + flex: 1; + padding: 10px; + border: none; + background: var(--panel-2); + cursor: pointer; + font-size: 13px; + color: var(--text-muted); + border-bottom: 2px solid transparent; +} +.tabs button.active { + background: var(--panel); + color: var(--text); + border-bottom-color: var(--accent); + font-weight: 600; +} +.tab-body { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} +.properties, +.config { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} +.properties-scroll, +.config-scroll { + flex: 1; + overflow-y: auto; + padding: 8px; +} +.empty-hint { + padding: 20px 14px; + color: var(--text-muted); + font-size: 13px; +} + +/* ---------- Property groups & fields ---------- */ +.prop-group { + border: 1px solid var(--border); + border-radius: 8px; + margin: 0 0 10px; + padding: 6px 10px 10px; +} +.prop-group legend { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--accent); + padding: 0 4px; +} +.prop-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 4px 0; +} +.prop-row.toggle { + cursor: pointer; +} +.prop-label { + font-size: 12px; + color: var(--text); + flex: 0 0 44%; +} +.prop-unit { + color: var(--text-muted); +} +.prop-input { + flex: 1; + display: flex; + flex-direction: column; + align-items: stretch; + min-width: 0; +} +.prop-input input, +.prop-input select { + width: 100%; + padding: 5px 7px; + border: 1px solid var(--border); + border-radius: 6px; + font-size: 13px; + background: #fff; +} +.prop-error { + color: var(--danger); + font-size: 11px; + margin-top: 3px; +} +.color-input { + display: flex; + gap: 6px; +} +.color-input input[type="color"] { + width: 34px; + padding: 2px; + flex: 0 0 auto; +} +.checkbox-list { + display: flex; + flex-direction: column; + gap: 3px; + max-height: 130px; + overflow-y: auto; + border: 1px solid var(--border); + border-radius: 6px; + padding: 6px; +} +.checkbox-row { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; +} +.checkbox-row input { + width: auto; +} +.list-editor { + display: flex; + flex-direction: column; + gap: 4px; +} +.list-row { + display: flex; + gap: 4px; +} +.icon-btn { + border: 1px solid var(--border); + background: var(--panel-2); + border-radius: 6px; + cursor: pointer; + padding: 0 8px; +} +.add-btn { + border: 1px dashed var(--border); + background: transparent; + border-radius: 6px; + cursor: pointer; + padding: 5px; + color: var(--accent); + font-size: 12px; +} + +/* ---------- Status bar ---------- */ +.statusbar { + height: var(--status-h); + min-height: var(--status-h); + display: flex; + align-items: center; + gap: 16px; + padding: 0 12px; + background: var(--panel); + border-top: 1px solid var(--border); + font-size: 12px; + color: var(--text-muted); +} +.status-item.ok { + color: var(--ok); +} +.status-item.warn { + color: var(--warn); + font-weight: 600; +} +.status-spacer { + flex: 1; +} +.status-item.muted { + color: var(--text-muted); + opacity: 0.8; +} diff --git a/src/ui/editors/PropertyField.tsx b/src/ui/editors/PropertyField.tsx new file mode 100644 index 0000000..686009e --- /dev/null +++ b/src/ui/editors/PropertyField.tsx @@ -0,0 +1,210 @@ +import { useEffect, useState } from "react"; +import { + coerceValue, + type PropertyDef, + type PropertyValue, +} from "../../model/properties"; +import type { Catalogue } from "../../model/catalogues"; + +interface Props { + def: PropertyDef; + value: PropertyValue; + catalogues: Record; + onCommit: (value: PropertyValue) => void; +} + +/** Renders and validates a single property, dispatching per-type editors. */ +export function PropertyField({ def, value, catalogues, onCommit }: Props) { + switch (def.type) { + case "string": + return ; + case "int": + case "float": + return ; + case "enum": + return ; + case "color": + return ; + case "catalogueItem": + return ; + case "catalogueItems": + return ; + case "list": + return ; + default: + return null; + } +} + +function Row({ def, children, error }: { def: PropertyDef; children: React.ReactNode; error?: string }) { + return ( + + ); +} + +function TextField({ def, value, onCommit }: Omit) { + const [text, setText] = useState(String(value ?? "")); + useEffect(() => setText(String(value ?? "")), [value]); + return ( + + setText(e.target.value)} + onBlur={() => onCommit(text)} + onKeyDown={(e) => e.key === "Enter" && (e.target as HTMLInputElement).blur()} + /> + + ); +} + +function NumberField({ def, value, onCommit }: Omit) { + const [text, setText] = useState(String(value ?? 0)); + const [error, setError] = useState(); + useEffect(() => setText(String(value ?? 0)), [value]); + const commit = () => { + const res = coerceValue(def, text); + if (res.ok) { + setError(undefined); + setText(String(res.value)); + onCommit(res.value!); + } else { + setError(res.error); + } + }; + return ( + + setText(e.target.value)} + onBlur={commit} + onKeyDown={(e) => e.key === "Enter" && (e.target as HTMLInputElement).blur()} + /> + + ); +} + +function EnumField({ def, value, onCommit }: Omit) { + return ( + + + + ); +} + +function ColorField({ def, value, onCommit }: Omit) { + const v = String(value ?? "#888888"); + return ( + + + onCommit(e.target.value)} /> + { + const res = coerceValue(def, e.target.value); + if (res.ok) onCommit(res.value!); + else onCommit(e.target.value); // allow typing; canvas ignores invalid + }} + /> + + + ); +} + +function CatalogueItemField({ def, value, catalogues, onCommit }: Props) { + const cat = def.catalogue ? catalogues[def.catalogue] : undefined; + return ( + + + + ); +} + +function CatalogueItemsField({ def, value, catalogues, onCommit }: Props) { + const cat = def.catalogue ? catalogues[def.catalogue] : undefined; + const selected = new Set(Array.isArray(value) ? value.map(String) : []); + const toggle = (id: string) => { + const next = new Set(selected); + if (next.has(id)) next.delete(id); + else next.add(id); + onCommit([...next]); + }; + return ( + +
+ {cat?.items.map((it) => ( + + ))} +
+
+ ); +} + +function ListField({ def, value, onCommit }: Omit) { + const items = Array.isArray(value) ? value.map(String) : []; + const update = (idx: number, v: string) => { + const next = [...items]; + next[idx] = v; + onCommit(coerceList(def, next)); + }; + const add = () => onCommit(coerceList(def, [...items, def.itemType === "string" ? "" : "0"])); + const remove = (idx: number) => onCommit(coerceList(def, items.filter((_, i) => i !== idx))); + return ( + +
+ {items.map((it, i) => ( +
+ update(i, e.target.value)} + /> + +
+ ))} + +
+
+ ); +} + +function coerceList(def: PropertyDef, raw: string[]): PropertyValue { + const res = coerceValue(def, raw); + return res.ok ? res.value! : raw; +} diff --git a/src/ui/useKeyboardShortcuts.ts b/src/ui/useKeyboardShortcuts.ts new file mode 100644 index 0000000..131dec1 --- /dev/null +++ b/src/ui/useKeyboardShortcuts.ts @@ -0,0 +1,50 @@ +import { useEffect, type RefObject } from "react"; +import type { CanvasController } from "../canvas/CanvasController"; + +/** Wire global editing shortcuts to controller actions. */ +export function useKeyboardShortcuts(ref: RefObject) { + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + const c = ref.current; + if (!c) return; + const target = e.target as HTMLElement | null; + // Don't hijack typing in form fields. + if (target && ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName)) return; + + const mod = e.ctrlKey || e.metaKey; + if (mod && e.key.toLowerCase() === "z" && !e.shiftKey) { + e.preventDefault(); + c.undo(); + } else if (mod && (e.key.toLowerCase() === "y" || (e.key.toLowerCase() === "z" && e.shiftKey))) { + e.preventDefault(); + c.redo(); + } else if (mod && e.key.toLowerCase() === "c") { + c.copySelection(); + } else if (mod && e.key.toLowerCase() === "x") { + c.copySelection(); + c.deleteSelection(); + } else if (mod && e.key.toLowerCase() === "v") { + c.paste(); + } else if (mod && e.key.toLowerCase() === "d") { + e.preventDefault(); + c.duplicateSelection(); + } else if (mod && e.key.toLowerCase() === "a") { + e.preventDefault(); + c.selectAll(); + } else if (e.key === "Delete" || e.key === "Backspace") { + e.preventDefault(); + c.deleteSelection(); + } else if (e.key === "Escape") { + c.clearSelection(); + } else if (mod && e.key === "=") { + e.preventDefault(); + c.zoomIn(); + } else if (mod && e.key === "-") { + e.preventDefault(); + c.zoomOut(); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [ref]); +} diff --git a/vite.config.ts b/vite.config.ts index 4cd84b9..29702cb 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,10 +1,12 @@ -/// -import { defineConfig } from "vite"; +import { defineConfig } from "vitest/config"; import react from "@vitejs/plugin-react"; // Tauri expects a fixed port; fail if unavailable. +// `react()` is typed against the top-level Vite while `vitest/config` bundles +// its own Vite copy; cast past that harmless version skew. export default defineConfig({ - plugins: [react()], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + plugins: [react() as any], clearScreen: false, server: { port: 1420,