diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..f20aaf6 --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { App } from './ui/App'; +import './ui/styles.css'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + , +); diff --git a/src/state/persistence.ts b/src/state/persistence.ts new file mode 100644 index 0000000..3f1d818 --- /dev/null +++ b/src/state/persistence.ts @@ -0,0 +1,66 @@ +/** + * Save/load/export. Uses the Tauri dialog + fs plugins when running inside + * Tauri; falls back to browser download / file-input in plain web dev mode. + */ + +function inTauri(): boolean { + return typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window; +} + +export async function saveTextFile( + contents: string, + defaultName: string, + filterName: string, + extensions: string[], +): Promise { + if (inTauri()) { + const { save } = await import('@tauri-apps/plugin-dialog'); + const { writeTextFile } = await import('@tauri-apps/plugin-fs'); + const path = await save({ + defaultPath: defaultName, + filters: [{ name: filterName, extensions }], + }); + if (!path) return null; + await writeTextFile(path, contents); + return path; + } + // Browser fallback: trigger a download. + const blob = new Blob([contents], { type: 'application/octet-stream' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = defaultName; + a.click(); + URL.revokeObjectURL(url); + return defaultName; +} + +export async function openTextFile( + filterName: string, + extensions: string[], +): Promise<{ name: string; contents: string } | null> { + if (inTauri()) { + const { open } = await import('@tauri-apps/plugin-dialog'); + const { readTextFile } = await import('@tauri-apps/plugin-fs'); + const path = await open({ + multiple: false, + filters: [{ name: filterName, extensions }], + }); + if (typeof path !== 'string') return null; + const contents = await readTextFile(path); + return { name: path, contents }; + } + // Browser fallback: hidden file input. + return new Promise((resolve) => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = extensions.map((e) => `.${e}`).join(','); + input.onchange = async () => { + const file = input.files?.[0]; + if (!file) return resolve(null); + resolve({ name: file.name, contents: await file.text() }); + }; + input.oncancel = () => resolve(null); + input.click(); + }); +} diff --git a/src/state/store.ts b/src/state/store.ts new file mode 100644 index 0000000..90b5fb3 --- /dev/null +++ b/src/state/store.ts @@ -0,0 +1,97 @@ +/** + * Minimal external store bridging the imperative EditorController and React + * (via useSyncExternalStore). Holds editor config, selection, history and + * simulation status. + */ + +import { useSyncExternalStore } from 'react'; +import type { EditorConfig } from '../editor/paper'; +import { DEFAULT_CONFIG } from '../editor/paper'; +import type { FlowResult } from '../sim/flow'; + +export interface AppState { + config: EditorConfig; + selection: string[]; + canUndo: boolean; + canRedo: boolean; + simulationRunning: boolean; + simulationSummary: { + totalSupplied: number; + totalDemand: number; + unreachedConsumers: number; + } | null; + /** Bumped whenever the graph changes, so panels re-read cell props. */ + graphVersion: number; + statusMessage: string; +} + +const CONFIG_KEY = 'pipeline-editor-config'; + +function loadConfig(): EditorConfig { + try { + const raw = localStorage.getItem(CONFIG_KEY); + if (raw) return { ...DEFAULT_CONFIG, ...JSON.parse(raw) }; + } catch { + // Fall through to defaults. + } + return { ...DEFAULT_CONFIG }; +} + +let state: AppState = { + config: loadConfig(), + selection: [], + canUndo: false, + canRedo: false, + simulationRunning: false, + simulationSummary: null, + graphVersion: 0, + statusMessage: 'Ready', +}; + +const listeners = new Set<() => void>(); + +export function getState(): AppState { + return state; +} + +export function setState(patch: Partial): void { + state = { ...state, ...patch }; + if (patch.config) { + try { + localStorage.setItem(CONFIG_KEY, JSON.stringify(patch.config)); + } catch { + // Persisting config is best-effort. + } + } + for (const listener of listeners) listener(); +} + +export function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +export function useAppState(): AppState { + return useSyncExternalStore(subscribe, getState); +} + +export function bumpGraphVersion(): void { + setState({ graphVersion: state.graphVersion + 1 }); +} + +export function setStatus(statusMessage: string): void { + setState({ statusMessage }); +} + +export function applySimulationResult(running: boolean, result: FlowResult | null): void { + setState({ + simulationRunning: running, + simulationSummary: result + ? { + totalSupplied: result.totalSupplied, + totalDemand: result.totalDemand, + unreachedConsumers: result.unreachedConsumers.length, + } + : null, + }); +} diff --git a/src/ui/App.tsx b/src/ui/App.tsx new file mode 100644 index 0000000..854da98 --- /dev/null +++ b/src/ui/App.tsx @@ -0,0 +1,181 @@ +/** + * Application shell: toolbar on top, palette left, canvas center, + * property panel right. Wires keyboard shortcuts and file operations. + */ + +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { EditorController } from '../editor/paper'; +import { Canvas } from './Canvas'; +import { Palette } from './Palette'; +import { PropertyPanel } from './PropertyPanel'; +import { Toolbar } from './Toolbar'; +import { SettingsDialog } from './SettingsDialog'; +import { useAppState, setState, setStatus } from '../state/store'; +import { saveTextFile, openTextFile } from '../state/persistence'; + +function isEditableTarget(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) return false; + return ( + target.isContentEditable || + ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName) + ); +} + +export function App() { + const state = useAppState(); + const [controller, setController] = useState(null); + const [settingsOpen, setSettingsOpen] = useState(false); + const controllerRef = useRef(null); + controllerRef.current = controller; + + const onReady = useCallback((c: EditorController) => setController(c), []); + + // Keyboard shortcuts. + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + const c = controllerRef.current; + if (!c || isEditableTarget(e.target)) return; + const mod = e.ctrlKey || e.metaKey; + const grid = c.config.snapToGrid ? c.config.gridSize : 5; + + if (mod && e.key.toLowerCase() === 'z' && !e.shiftKey) { + e.preventDefault(); + c.undo(); + } else if ((mod && e.key.toLowerCase() === 'z' && e.shiftKey) || (mod && e.key.toLowerCase() === 'y')) { + e.preventDefault(); + c.redo(); + } else if (mod && e.key.toLowerCase() === 'c') { + c.copySelection(); + setStatus('Copied'); + } else if (mod && e.key.toLowerCase() === 'v') { + c.paste(); + setStatus('Pasted'); + } 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 (e.key.startsWith('Arrow')) { + e.preventDefault(); + const d = { + ArrowLeft: [-grid, 0], + ArrowRight: [grid, 0], + ArrowUp: [0, -grid], + ArrowDown: [0, grid], + }[e.key as 'ArrowLeft' | 'ArrowRight' | 'ArrowUp' | 'ArrowDown']; + if (d) c.nudgeSelection(d[0], d[1]); + } + }; + document.addEventListener('keydown', onKeyDown); + return () => document.removeEventListener('keydown', onKeyDown); + }, []); + + const handleNew = () => { + if (!controller) return; + if (controller.graph.getCells().length > 0 && !window.confirm('Clear the current diagram?')) return; + controller.clearDocument(); + setStatus('New diagram'); + }; + + const handleSave = async () => { + if (!controller) return; + try { + const path = await saveTextFile(controller.serialize(), 'diagram.pipeline.json', 'Pipeline diagram', ['json']); + if (path) setStatus(`Saved: ${path}`); + } catch (err) { + setStatus(`Save failed: ${String(err)}`); + } + }; + + const handleOpen = async () => { + if (!controller) return; + try { + const file = await openTextFile('Pipeline diagram', ['json']); + if (!file) return; + controller.loadDocumentJSON(file.contents); + setStatus(`Loaded: ${file.name}`); + } catch (err) { + setStatus(`Open failed: ${String(err)}`); + } + }; + + const handleExportSvg = async () => { + if (!controller) return; + try { + const path = await saveTextFile(controller.exportSVG(), 'diagram.svg', 'SVG image', ['svg']); + if (path) setStatus(`Exported: ${path}`); + } catch (err) { + setStatus(`Export failed: ${String(err)}`); + } + }; + + const handleToggleSimulation = () => { + if (!controller) return; + if (controller.simulationRunning) { + controller.stopSimulation(); + setStatus('Simulation stopped'); + } else { + const result = controller.runSimulation(); + setStatus( + result.totalDemand === 0 + ? 'Simulation: no consumers with demand found' + : `Simulation: ${result.totalSupplied.toFixed(1)} of ${result.totalDemand.toFixed(1)} m³/h delivered`, + ); + } + }; + + const handleQuickAdd = (typeId: string) => { + if (!controller) return; + const host = document.querySelector('.pf-canvas'); + const rect = host?.getBoundingClientRect(); + const p = rect + ? controller.clientToLocal(rect.left + rect.width / 2, rect.top + rect.height / 2) + : { x: 200, y: 200 }; + controller.addNode(typeId, p.x, p.y); + }; + + return ( +
+ controller?.undo()} + onRedo={() => controller?.redo()} + onDelete={() => controller?.deleteSelection()} + onZoomIn={() => controller?.zoom(1.2)} + onZoomOut={() => controller?.zoom(1 / 1.2)} + onZoomFit={() => controller?.zoomToFit()} + onZoomReset={() => controller?.resetZoom()} + onToggleSimulation={handleToggleSimulation} + onOpenSettings={() => setSettingsOpen(true)} + /> +
+ + + +
+ { + setState({ config }); + controller?.applyConfig(config); + }} + onClose={() => setSettingsOpen(false)} + /> +
+ ); +} diff --git a/src/ui/Canvas.tsx b/src/ui/Canvas.tsx new file mode 100644 index 0000000..93525b4 --- /dev/null +++ b/src/ui/Canvas.tsx @@ -0,0 +1,58 @@ +/** + * Canvas host: mounts the JointJS paper via EditorController and accepts + * palette drops. + */ + +import { useEffect, useRef } from 'react'; +import { EditorController } from '../editor/paper'; +import { PALETTE_MIME } from './Palette'; +import { getState, setState, bumpGraphVersion, applySimulationResult } from '../state/store'; + +interface CanvasProps { + onReady(controller: EditorController): void; +} + +export function Canvas({ onReady }: CanvasProps) { + const hostRef = useRef(null); + const controllerRef = useRef(null); + + useEffect(() => { + const host = hostRef.current; + if (!host || controllerRef.current) return; + const controller = new EditorController(host, getState().config, { + onSelectionChange: (selection) => setState({ selection }), + onGraphChange: () => bumpGraphVersion(), + onHistoryChange: (canUndo, canRedo) => setState({ canUndo, canRedo }), + onSimulationChange: (running, result) => applySimulationResult(running, result), + }); + controllerRef.current = controller; + onReady(controller); + return () => { + controller.dispose(); + controllerRef.current = null; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( +
{ + if (e.dataTransfer.types.includes(PALETTE_MIME)) { + e.preventDefault(); + e.dataTransfer.dropEffect = 'copy'; + } + }} + onDrop={(e) => { + const typeId = e.dataTransfer.getData(PALETTE_MIME); + const controller = controllerRef.current; + if (!typeId || !controller) return; + e.preventDefault(); + const p = controller.clientToLocal(e.clientX, e.clientY); + controller.addNode(typeId, p.x, p.y); + }} + /> + ); +} diff --git a/src/ui/Palette.tsx b/src/ui/Palette.tsx new file mode 100644 index 0000000..0f68942 --- /dev/null +++ b/src/ui/Palette.tsx @@ -0,0 +1,82 @@ +/** + * Left panel: node types grouped by category. Items are dragged onto the + * canvas (HTML5 drag & drop carrying the type id) or double-clicked to drop + * at the viewport center. + */ + +import { useMemo, useState } from 'react'; +import { nodeTypesByCategory, type NodeTypeDef } from '../model/nodeTypes'; +import { svgDataUri } from '../editor/shapes'; +import { getColorScheme } from '../editor/colorSchemes'; + +export const PALETTE_MIME = 'application/x-pipeline-node-type'; + +interface PaletteProps { + schemeId: string; + onQuickAdd(typeId: string): void; +} + +function PaletteItem({ type, schemeId, onQuickAdd }: { + type: NodeTypeDef; + schemeId: string; + onQuickAdd(typeId: string): void; +}) { + const scheme = getColorScheme(schemeId); + return ( +
{ + e.dataTransfer.setData(PALETTE_MIME, type.id); + e.dataTransfer.effectAllowed = 'copy'; + }} + onDoubleClick={() => onQuickAdd(type.id)} + > + {type.label} + {type.label} +
+ ); +} + +export function Palette({ schemeId, onQuickAdd }: PaletteProps) { + const [filter, setFilter] = useState(''); + const [collapsed, setCollapsed] = useState>({}); + const groups = useMemo(() => nodeTypesByCategory(), []); + + const query = filter.trim().toLowerCase(); + return ( + + ); +} diff --git a/src/ui/PropertyPanel.tsx b/src/ui/PropertyPanel.tsx new file mode 100644 index 0000000..9a55fa4 --- /dev/null +++ b/src/ui/PropertyPanel.tsx @@ -0,0 +1,230 @@ +/** + * Right panel: grouped, typed property editors for the focused node or edge. + * Renders one editor per property type; commits values through + * EditorController.setCellProp after validation. + */ + +import { useMemo, useState } from 'react'; +import type { PropertyDef, PropertyValue } from '../model/properties'; +import { groupDefs, validateValue } from '../model/properties'; +import { getCatalogue } from '../model/catalog'; +import { getNodeType } from '../model/nodeTypes'; +import { EDGE_PROPERTIES } from '../model/graph'; +import type { EditorController } from '../editor/paper'; + +interface PropertyPanelProps { + controller: EditorController | null; + selection: string[]; + /** Changes on every graph mutation so the panel re-reads values. */ + graphVersion: number; +} + +function EditorRow({ def, value, onCommit }: { + def: PropertyDef; + value: PropertyValue; + onCommit(value: PropertyValue): void; +}) { + const [error, setError] = useState(null); + const [draft, setDraft] = useState(null); + + const commit = (raw: PropertyValue) => { + const result = validateValue(def, raw); + if (!result.ok) { + setError(result.error ?? 'Invalid value'); + return; + } + setError(null); + setDraft(null); + onCommit(result.value); + }; + + const control = (() => { + if (def.readOnly) { + const text = Array.isArray(value) ? value.join(', ') : String(value ?? '—'); + return {text}; + } + switch (def.type) { + case 'string': + return ( + setDraft(e.target.value)} + onBlur={() => draft !== null && commit(draft)} + onKeyDown={(e) => e.key === 'Enter' && draft !== null && commit(draft)} + /> + ); + case 'int': + case 'float': + return ( + setDraft(e.target.value)} + onBlur={() => draft !== null && commit(draft)} + onKeyDown={(e) => e.key === 'Enter' && draft !== null && commit(draft)} + /> + ); + case 'enum': + return ( + + ); + case 'color': + return ( + commit(e.target.value)} + /> + ); + case 'catalogueItem': { + const cat = def.catalogueId ? getCatalogue(def.catalogueId) : undefined; + return ( + + ); + } + case 'catalogueItems': { + const cat = def.catalogueId ? getCatalogue(def.catalogueId) : undefined; + const selected = Array.isArray(value) ? value.map(String) : []; + return ( +
+ {(cat?.items ?? []).map((item) => ( + + ))} +
+ ); + } + case 'listOfValues': { + const list = Array.isArray(value) ? value : []; + const text = draft ?? list.join(', '); + return ( + setDraft(e.target.value)} + onBlur={() => { + if (draft === null) return; + const items = draft.split(',').map((s) => s.trim()).filter((s) => s !== ''); + commit(items as PropertyValue); + }} + onKeyDown={(e) => { + if (e.key === 'Enter' && draft !== null) { + const items = draft.split(',').map((s) => s.trim()).filter((s) => s !== ''); + commit(items as PropertyValue); + } + }} + /> + ); + } + } + })(); + + return ( +
+ +
+ {control} + {error &&
{error}
} +
+
+ ); +} + +export function PropertyPanel({ controller, selection, graphVersion }: PropertyPanelProps) { + const [collapsed, setCollapsed] = useState>({}); + void graphVersion; // dependency to re-read props after graph changes + + const target = useMemo(() => { + if (!controller || selection.length !== 1) return null; + const cellId = selection[0]; + const kind = controller.cellKind(cellId); + if (!kind) return null; + const props = controller.getCellProps(cellId); + if (!props) return null; + if (kind === 'node') { + const typeId = controller.cellTypeId(cellId); + const type = typeId ? getNodeType(typeId) : undefined; + if (!type) return null; + return { cellId, kind, defs: type.properties, heading: type.label, props }; + } + return { cellId, kind, defs: EDGE_PROPERTIES, heading: 'Pipe / line', props }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [controller, selection, graphVersion]); + + if (!controller || selection.length === 0) { + return ( + + ); + } + if (selection.length > 1) { + return ( + + ); + } + if (!target) return