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 02251f4..0000000 Binary files a/src/assets/hero.png and /dev/null differ 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,