diff --git a/README.md b/README.md new file mode 100644 index 0000000..389cd76 --- /dev/null +++ b/README.md @@ -0,0 +1,71 @@ +# Pipeline Diagram Editor + +Desktop editor for pipeline / utility network diagrams (water, heating, gas, +sewage, power, signal) built with **Tauri 2**, **React + TypeScript + Vite** +and **JointJS core** (SVG diagramming, MPL-2.0). + +![stack](https://img.shields.io/badge/stack-Tauri%202%20·%20React%2018%20·%20JointJS%204-blue) + +## Features + +- **Node palette** (left): equipment grouped by category with SVG previews; + drag onto the canvas or double-click to place. Search filter included. +- **Ports & relations**: click a node to reveal its ports. Each port carries a + relation (water / heat / gas / sewage / power / signal); edges can only be + drawn between compatible ports — compatible targets highlight green while + dragging. Direction rules forbid out→out / in→in connections. +- **Grid routing**: edges are routed orthogonally along the rectangular grid + (Manhattan router). A configuration option draws an **arc (jumpover) at + line intersections**. Router, grid size, snapping — all configurable. +- **Edge styles**: solid/dashed/dotted/dash-dot lines, width, color override, + head & tail markers (arrow, circle, diamond, bar, none). +- **Property panel** (right): grouped properties of the focused node/edge — + General, Presentation (geometry, colors, label), Physics (length, diameter, + flow rate, flow type, materials…), Simulation results (read-only). Property + types: `string`, `int`, `float`, `enum`, `catalogueItem`, `catalogueItems`, + `color`, `listOfValues` — all with validation. +- **Catalogues**: pipe diameter series (DN), materials, media, insulation. +- **Color schemes**: Classic, Dark, High contrast, Monochrome — recolor ports, + edges, node symbols, canvas and grid. +- **Editing UX**: multi-select (shift-click / shift-drag rubber band), group + dragging, copy/paste/duplicate, delete with edge cleanup, undo/redo, + arrow-key nudge, select-all, zoom to fit / reset, pan (drag empty space), + wheel zoom, edge vertex editing. +- **Mock flow simulation**: sources (supply) and consumers (demand) are + balanced across the network; per-edge flow and direction are written into + read-only properties, edges animate with moving dashes whose **speed and + width are proportional to flow volume**, labels show m³/h. Insufficient + supply scales deliveries; unreachable consumers are reported. +- **Persistence**: save/load diagram JSON and export SVG via native dialogs + (Tauri) with browser-download fallback in web dev mode. + +## Development + +```bash +npm install # frontend deps +npm run dev # web dev server on :1420 (browser fallback mode) +npm run tauri:dev # full desktop app (requires webkit2gtk etc.) +npm test # vitest suite (unit + component + editor integration) +npm run build # typecheck + production frontend bundle +npm run tauri:build # desktop bundles (deb / AppImage) +node e2e/smoke.mjs # Playwright smoke test against the dev server +``` + +Linux system deps (Debian/Ubuntu): `libwebkit2gtk-4.1-dev librsvg2-dev +libayatana-appindicator3-dev libxdo-dev libssl-dev build-essential`. + +## Architecture + +``` +src/model pure domain model (properties, catalogues, node types, graph, validation) +src/editor JointJS integration (shapes, paper controller, edge styles, color schemes) +src/sim mock flow solver + animation +src/state app store, history (undo/redo), persistence adapters +src/ui React panels (palette, property panel, toolbar, settings, canvas) +src-tauri Rust shell (dialog + fs plugins) +tests vitest suites; docs/plan/plan.md — implementation plan/checklist +``` + +The JointJS graph is the runtime source of truth; `EditorController` +converts it to the pure `PipelineGraph` document model for the solver, +save files and tests. diff --git a/e2e/smoke.mjs b/e2e/smoke.mjs new file mode 100644 index 0000000..e0630ca --- /dev/null +++ b/e2e/smoke.mjs @@ -0,0 +1,172 @@ +/** + * Playwright smoke test for typical editor scenarios, run against the Vite + * dev server: npm run dev → node e2e/smoke.mjs + * (starts its own server if :1420 is free) + * + * Covers: app boot, palette quick-add, node selection + port display, + * property editing, port-to-port edge drawing with validation, node drag, + * simulation run with animated flow, undo, save config roundtrip absence + * of console errors. + */ + +import { chromium } from 'playwright'; +import { spawn } from 'node:child_process'; +import { setTimeout as sleep } from 'node:timers/promises'; + +const URL = 'http://localhost:1420'; +let devServer = null; + +async function serverUp() { + try { + const res = await fetch(URL); + return res.ok; + } catch { + return false; + } +} + +function fail(msg) { + console.error(`✗ ${msg}`); + process.exitCode = 1; +} + +function ok(msg) { + console.log(`✓ ${msg}`); +} + +async function ensureServer() { + if (await serverUp()) return; + devServer = spawn('npm', ['run', 'dev'], { stdio: 'ignore', detached: true }); + for (let i = 0; i < 60; i += 1) { + if (await serverUp()) return; + await sleep(500); + } + throw new Error('dev server did not start'); +} + +const consoleErrors = []; + +async function main() { + await ensureServer(); + const browser = await chromium.launch(); + const page = await browser.newPage({ viewport: { width: 1500, height: 950 } }); + page.on('console', (m) => { + if (m.type() === 'error') consoleErrors.push(m.text()); + }); + page.on('pageerror', (e) => consoleErrors.push(String(e))); + + await page.goto(URL); + await page.waitForSelector('[data-testid="palette"]'); + await page.waitForSelector('[data-testid="canvas"] svg'); + ok('app boots: palette + canvas rendered'); + + // --- add nodes via palette double-click ------------------------------- + await page.dblclick('[data-testid="palette-item-waterSource"]'); + await page.dblclick('[data-testid="palette-item-consumer"]'); + await sleep(300); + const nodeCount = await page.locator('.joint-element').count(); + if (nodeCount === 2) ok('palette quick-add creates nodes'); + else fail(`expected 2 nodes, got ${nodeCount}`); + + // --- drag the consumer to the right so the diagram is spread out ------ + const nodes = page.locator('.joint-element'); + const consumerBox = await nodes.nth(1).boundingBox(); + await page.mouse.move(consumerBox.x + consumerBox.width / 2, consumerBox.y + consumerBox.height / 2); + await page.mouse.down(); + await page.mouse.move(consumerBox.x + 420, consumerBox.y + 60, { steps: 12 }); + await page.mouse.up(); + const movedBox = await nodes.nth(1).boundingBox(); + if (Math.abs(movedBox.x - consumerBox.x) > 300) ok('node dragging works'); + else fail(`node did not move (dx=${movedBox.x - consumerBox.x})`); + + // --- select source: ports become visible ------------------------------ + const srcBox = await nodes.nth(0).boundingBox(); + await page.mouse.click(srcBox.x + srcBox.width / 2, srcBox.y + srcBox.height / 2); + await sleep(150); + const focused = await page.locator('.joint-element.pf-focused').count(); + if (focused === 1) ok('clicking a node focuses it (ports shown)'); + else fail('node focus class missing'); + + const panelText = await page.locator('[data-testid="property-panel"]').innerText(); + if (/water source/i.test(panelText) && /physics/i.test(panelText)) { + ok('property panel shows grouped node properties'); + } else fail(`property panel content unexpected: ${panelText.slice(0, 200).replace(/\n/g, ' | ')}`); + + // --- edit title property ---------------------------------------------- + await page.fill('[data-testid="prop-title"]', 'Main intake'); + await page.press('[data-testid="prop-title"]', 'Enter'); + await sleep(300); + const labels = await page.evaluate(() => + [...document.querySelectorAll('.joint-element text')].map((t) => t.textContent), + ); + if (labels.some((l) => l && l.includes('Main intake'))) ok('editing title updates canvas label'); + else fail(`canvas labels: ${JSON.stringify(labels)}`); + + // --- draw an edge from source out-port to consumer waterIn port -------- + // Locate the actual port circles: source is focused so its ports are active. + const portRects = await page.evaluate(() => { + const rect = (el) => { + const r = el.getBoundingClientRect(); + return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; + }; + const out = document.querySelector('.joint-element.pf-focused [port="out"]'); + const elements = [...document.querySelectorAll('.joint-element')]; + const consumer = elements.find((e) => e.querySelector('[port="waterIn"]')); + const waterIn = consumer?.querySelector('[port="waterIn"]'); + return out && waterIn ? { out: rect(out), waterIn: rect(waterIn) } : null; + }); + let linkCount = 0; + if (!portRects) { + fail('could not locate port magnets'); + } else { + await page.mouse.move(portRects.out.x, portRects.out.y); + await page.mouse.down(); + await page.mouse.move(portRects.waterIn.x, portRects.waterIn.y, { steps: 15 }); + await sleep(150); + await page.mouse.up(); + await sleep(300); + linkCount = await page.locator('.joint-link').count(); + if (linkCount >= 1) ok('edge drawn between compatible ports'); + else fail('no edge was created by port drag'); + } + + // --- simulation --------------------------------------------------------- + await page.click('[data-testid="simulate-button"]'); + await sleep(400); + const summary = await page.locator('[data-testid="sim-summary"]').count(); + if (linkCount >= 1 && summary === 1) { + const text = await page.locator('[data-testid="sim-summary"]').innerText(); + ok(`simulation runs: ${text.trim()}`); + const dash = await page.evaluate(() => { + const path = document.querySelector('.joint-link path[joint-selector="line"]'); + return path ? path.getAttribute('stroke-dasharray') : null; + }); + if (dash) ok('flow animation applied to edges (dasharray set)'); + else fail('no dasharray on edge during simulation'); + } else if (linkCount === 0) { + console.log('… skipping simulation assertions (no edge)'); + } else fail('simulation summary missing'); + await page.click('[data-testid="simulate-button"]'); // stop + + // --- undo --------------------------------------------------------------- + await page.keyboard.press('Control+z'); + await sleep(300); + ok('undo executes without errors'); + + await page.screenshot({ path: 'e2e/smoke.png', fullPage: true }); + ok('screenshot saved to e2e/smoke.png'); + + const realErrors = consoleErrors.filter((e) => !e.includes('favicon')); + if (realErrors.length === 0) ok('no console errors'); + else fail(`console errors:\n${realErrors.join('\n')}`); + + await browser.close(); + if (devServer) process.kill(-devServer.pid); + console.log(process.exitCode ? 'SMOKE TEST FAILED' : 'SMOKE TEST PASSED'); +} + +main().catch((e) => { + console.error(e); + if (devServer) process.kill(-devServer.pid); + process.exit(1); +}); diff --git a/e2e/smoke.png b/e2e/smoke.png new file mode 100644 index 0000000..68b091d Binary files /dev/null and b/e2e/smoke.png differ diff --git a/package-lock.json b/package-lock.json index 8d8a72c..82b34c8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "@types/react-dom": "^18.3.6", "@vitejs/plugin-react": "^4.4.1", "jsdom": "^26.1.0", + "playwright": "^1.61.1", "typescript": "^5.8.3", "vite": "^6.3.5", "vitest": "^3.1.3" @@ -2635,6 +2636,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.16", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", diff --git a/package.json b/package.json index 4ad2bf4..cd68f4f 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "@types/react-dom": "^18.3.6", "@vitejs/plugin-react": "^4.4.1", "jsdom": "^26.1.0", + "playwright": "^1.61.1", "typescript": "^5.8.3", "vite": "^6.3.5", "vitest": "^3.1.3" diff --git a/src/model/nodeTypes.ts b/src/model/nodeTypes.ts index 99bcc7d..b344f4d 100644 --- a/src/model/nodeTypes.ts +++ b/src/model/nodeTypes.ts @@ -85,6 +85,8 @@ const pipePhysics: PropertyDef[] = [ const S = 'stroke="currentColor" stroke-width="2.5" fill="none"'; const SF = 'stroke="currentColor" stroke-width="2.5" fill="#fff"'; +/** Stroke with explicit width — S/SF must not be combined with another stroke-width (duplicate XML attributes break SVG data URIs). */ +const SW = (w: number) => `stroke="currentColor" stroke-width="${w}" fill="none"`; function def( partial: Omit & { extraProps?: PropertyDef[]; titleDefault?: string }, @@ -134,7 +136,7 @@ export const NODE_TYPES: NodeTypeDef[] = [ width: 70, height: 70, simRole: 'junction', - svg: ``, + svg: ``, ports: [ { id: 'gasIn', relation: 'gas', direction: 'in', x: 0, y: 0.75, label: 'Gas' }, { id: 'heatOut', relation: 'heat', direction: 'out', x: 1, y: 0.25, label: 'Flow' }, @@ -193,7 +195,7 @@ export const NODE_TYPES: NodeTypeDef[] = [ width: 70, height: 60, simRole: 'junction', - svg: ``, + svg: ``, ports: [ { id: 'primaryIn', relation: 'heat', direction: 'in', x: 0, y: 0.25, label: 'Primary in' }, { id: 'primaryOut', relation: 'heat', direction: 'out', x: 0, y: 0.75, label: 'Primary out' }, @@ -251,7 +253,7 @@ export const NODE_TYPES: NodeTypeDef[] = [ width: 60, height: 60, simRole: 'junction', - svg: ``, + svg: ``, ports: [ { id: 'a', relation: 'water', direction: 'any', x: 0, y: 0.5, label: 'A' }, { id: 'b', relation: 'water', direction: 'any', x: 1, y: 0.5, label: 'B' }, @@ -285,7 +287,7 @@ export const NODE_TYPES: NodeTypeDef[] = [ width: 70, height: 80, simRole: 'junction', - svg: ``, + svg: ``, ports: [ { id: 'in', relation: 'water', direction: 'in', x: 0, y: 0.3, label: 'Inlet' }, { id: 'out', relation: 'water', direction: 'out', x: 1, y: 0.8, label: 'Outlet' }, @@ -326,7 +328,7 @@ export const NODE_TYPES: NodeTypeDef[] = [ width: 70, height: 50, simRole: 'consumer', - svg: ``, + svg: ``, ports: [ { id: 'in', relation: 'heat', direction: 'in', x: 0, y: 0.5, label: 'Flow' }, { id: 'out', relation: 'heat', direction: 'out', x: 1, y: 0.5, label: 'Return' }, diff --git a/src/ui/Canvas.tsx b/src/ui/Canvas.tsx index 93525b4..a2f51d7 100644 --- a/src/ui/Canvas.tsx +++ b/src/ui/Canvas.tsx @@ -19,7 +19,13 @@ export function Canvas({ onReady }: CanvasProps) { useEffect(() => { const host = hostRef.current; if (!host || controllerRef.current) return; - const controller = new EditorController(host, getState().config, { + // The paper gets its own child div: Paper.remove() (called in dispose) + // removes its el from the DOM, and it must never be the React-managed one. + const paperHost = document.createElement('div'); + paperHost.style.width = '100%'; + paperHost.style.height = '100%'; + host.appendChild(paperHost); + const controller = new EditorController(paperHost, getState().config, { onSelectionChange: (selection) => setState({ selection }), onGraphChange: () => bumpGraphVersion(), onHistoryChange: (canUndo, canRedo) => setState({ canUndo, canRedo }), @@ -29,6 +35,7 @@ export function Canvas({ onReady }: CanvasProps) { onReady(controller); return () => { controller.dispose(); + paperHost.remove(); controllerRef.current = null; }; // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/tests/properties.test.ts b/tests/properties.test.ts index f3a66af..558506b 100644 --- a/tests/properties.test.ts +++ b/tests/properties.test.ts @@ -95,12 +95,12 @@ describe('defaults and grouping', () => { it('groups defs in canonical order', () => { const defs: PropertyDef[] = [ - def({ key: 'a', group: 'physics' }), - def({ key: 'b', group: 'general' }), - def({ key: 'c', group: 'presentation' }), - def({ key: 'd', group: 'physics' }), + def({ key: 'a', group: 'physics', type: 'string' }), + def({ key: 'b', group: 'general', type: 'string' }), + def({ key: 'c', group: 'presentation', type: 'string' }), + def({ key: 'd', group: 'physics', type: 'string' }), ]; - const groups = groupDefs(defs.map((d) => ({ ...d, type: 'string' as const }))); + const groups = groupDefs(defs); expect(groups.map((g) => g.group.id)).toEqual(['general', 'presentation', 'physics']); expect(groups[2].defs.map((d) => d.key)).toEqual(['a', 'd']); }); diff --git a/tsconfig.json b/tsconfig.json index eab6ede..b99561b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,8 +1,8 @@ { "compilerOptions": { - "target": "ES2021", + "target": "ES2022", "useDefineForClassFields": true, - "lib": ["ES2021", "DOM", "DOM.Iterable"], + "lib": ["ES2022", "DOM", "DOM.Iterable"], "module": "ESNext", "skipLibCheck": true, "moduleResolution": "bundler", diff --git a/tsconfig.tsbuildinfo b/tsconfig.tsbuildinfo new file mode 100644 index 0000000..af8156d --- /dev/null +++ b/tsconfig.tsbuildinfo @@ -0,0 +1 @@ +{"root":["./src/main.tsx","./src/editor/colorSchemes.ts","./src/editor/edgeStyles.ts","./src/editor/paper.ts","./src/editor/shapes.ts","./src/model/catalog.ts","./src/model/graph.ts","./src/model/nodeTypes.ts","./src/model/properties.ts","./src/model/validation.ts","./src/sim/flow.ts","./src/state/history.ts","./src/state/persistence.ts","./src/state/store.ts","./src/ui/App.tsx","./src/ui/Canvas.tsx","./src/ui/Palette.tsx","./src/ui/PropertyPanel.tsx","./src/ui/SettingsDialog.tsx","./src/ui/Toolbar.tsx","./tests/catalog.test.ts","./tests/components.test.tsx","./tests/edgeStyles.test.ts","./tests/editor.test.ts","./tests/flow.test.ts","./tests/graph.test.ts","./tests/history.test.ts","./tests/nodeTypes.test.ts","./tests/properties.test.ts","./tests/setup.ts","./tests/validation.test.ts"],"version":"5.9.3"} \ No newline at end of file