diff --git a/src/editor/colorSchemes.ts b/src/editor/colorSchemes.ts new file mode 100644 index 0000000..3577f86 --- /dev/null +++ b/src/editor/colorSchemes.ts @@ -0,0 +1,91 @@ +/** + * Color schemes map port relations to colors used for ports, edges and the + * canvas chrome. Selectable in Settings. + */ + +import type { PortRelation } from '../model/validation'; + +export interface ColorScheme { + id: string; + label: string; + relations: Record; + canvasBackground: string; + gridColor: string; + nodeStroke: string; + selectionColor: string; +} + +export const COLOR_SCHEMES: ColorScheme[] = [ + { + id: 'classic', + label: 'Classic', + relations: { + water: '#2563eb', + heat: '#dc2626', + gas: '#ca8a04', + sewage: '#78716c', + power: '#9333ea', + signal: '#059669', + }, + canvasBackground: '#f8fafc', + gridColor: '#dbe2ea', + nodeStroke: '#1e293b', + selectionColor: '#0ea5e9', + }, + { + id: 'dark', + label: 'Dark', + relations: { + water: '#60a5fa', + heat: '#f87171', + gas: '#facc15', + sewage: '#a8a29e', + power: '#c084fc', + signal: '#34d399', + }, + canvasBackground: '#0f172a', + gridColor: '#243046', + nodeStroke: '#e2e8f0', + selectionColor: '#38bdf8', + }, + { + id: 'highContrast', + label: 'High contrast', + relations: { + water: '#0000ff', + heat: '#ff0000', + gas: '#b45309', + sewage: '#000000', + power: '#ff00ff', + signal: '#008000', + }, + canvasBackground: '#ffffff', + gridColor: '#c9c9c9', + nodeStroke: '#000000', + selectionColor: '#ff6600', + }, + { + id: 'mono', + label: 'Monochrome', + relations: { + water: '#334155', + heat: '#334155', + gas: '#334155', + sewage: '#334155', + power: '#334155', + signal: '#334155', + }, + canvasBackground: '#fbfbfb', + gridColor: '#e2e2e2', + nodeStroke: '#111111', + selectionColor: '#2563eb', + }, +]; + +export function getColorScheme(id: string): ColorScheme { + return COLOR_SCHEMES.find((s) => s.id === id) ?? COLOR_SCHEMES[0]; +} + +export function relationColor(schemeId: string, relation: PortRelation): string { + return getColorScheme(schemeId).relations[relation]; +} diff --git a/src/editor/edgeStyles.ts b/src/editor/edgeStyles.ts new file mode 100644 index 0000000..bfba6b9 --- /dev/null +++ b/src/editor/edgeStyles.ts @@ -0,0 +1,77 @@ +/** + * Edge (link) visual style computation: dash patterns and SVG path markers + * (arrow / circle / diamond / bar) for head and tail, derived from edge + * presentation properties and the relation color of the active scheme. + */ + +import type { PropertyValue } from '../model/properties'; + +export type MarkerKind = 'none' | 'arrow' | 'circle' | 'diamond' | 'bar'; +export type LineStyleKind = 'solid' | 'dashed' | 'dotted' | 'dashDot'; + +export function dashArray(style: LineStyleKind, width: number): string | null { + switch (style) { + case 'solid': + return null; + case 'dashed': + return `${width * 4},${width * 2.5}`; + case 'dotted': + return `${width},${width * 2}`; + case 'dashDot': + return `${width * 4},${width * 2},${width},${width * 2}`; + } +} + +/** + * JointJS marker definition (attrs of line/sourceMarker or targetMarker). + * Markers are auto-rotated by JointJS; coordinates assume the line comes + * from the left and the endpoint is at (0, 0). + */ +export function markerDef(kind: MarkerKind, color: string): Record | null { + switch (kind) { + case 'none': + return { d: 'M 0 0', fill: 'none', stroke: 'none' }; + case 'arrow': + return { type: 'path', d: 'M 12 -6 L 0 0 L 12 6 Z', fill: color, stroke: color }; + case 'circle': + return { type: 'circle', r: 5, cx: 5, fill: '#fff', stroke: color, 'stroke-width': 2 }; + case 'diamond': + return { type: 'path', d: 'M 0 0 L 8 -6 L 16 0 L 8 6 Z', fill: '#fff', stroke: color, 'stroke-width': 2 }; + case 'bar': + return { type: 'path', d: 'M 0 -7 L 0 7', fill: 'none', stroke: color, 'stroke-width': 3 }; + default: + return null; + } +} + +export interface EdgeStyleProps { + lineStyle: LineStyleKind; + lineWidth: number; + lineColor: string; + sourceMarker: MarkerKind; + targetMarker: MarkerKind; +} + +export function readEdgeStyle(props: Record): EdgeStyleProps { + return { + lineStyle: (props.lineStyle as LineStyleKind) ?? 'solid', + lineWidth: typeof props.lineWidth === 'number' ? props.lineWidth : 2, + lineColor: typeof props.lineColor === 'string' ? props.lineColor : '', + sourceMarker: (props.sourceMarker as MarkerKind) ?? 'none', + targetMarker: (props.targetMarker as MarkerKind) ?? 'arrow', + }; +} + +/** Build JointJS `attrs/line` object for a link from its style + relation color. */ +export function buildLineAttrs(style: EdgeStyleProps, relationColor: string): Record { + const color = style.lineColor || relationColor; + const attrs: Record = { + stroke: color, + strokeWidth: style.lineWidth, + sourceMarker: markerDef(style.sourceMarker, color), + targetMarker: markerDef(style.targetMarker, color), + }; + const dash = dashArray(style.lineStyle, style.lineWidth); + attrs.strokeDasharray = dash ?? 'none'; + return attrs; +} diff --git a/src/editor/paper.ts b/src/editor/paper.ts new file mode 100644 index 0000000..ee5f798 --- /dev/null +++ b/src/editor/paper.ts @@ -0,0 +1,869 @@ +/** + * EditorController — owns the JointJS graph + paper and implements all + * canvas interactions: selection, port-validated linking, grid routing with + * optional jumpover arcs, pan/zoom, rubber-band, clipboard, undo/redo, + * simulation animation and (de)serialization. + */ + +import { dia, linkTools, highlighters, V } from '@joint/core'; +import { + cellNamespace, + createNode, + applyNodePresentation, + portSpec, + PipelineLink, +} from './shapes'; +import { getColorScheme } from './colorSchemes'; +import { buildLineAttrs, readEdgeStyle } from './edgeStyles'; +import type { PropertyValue } from '../model/properties'; +import { checkConnection } from '../model/validation'; +import type { PortRelation } from '../model/validation'; +import { getNodeType } from '../model/nodeTypes'; +import { PipelineGraph, defaultEdgeProps, genId } from '../model/graph'; +import { History } from '../state/history'; +import { solveFlow } from '../sim/flow'; +import type { FlowResult } from '../sim/flow'; + +export interface EditorConfig { + gridSize: number; + showGrid: boolean; + snapToGrid: boolean; + router: 'manhattan' | 'orthogonal' | 'rightAngle' | 'normal'; + /** Draw an arc where lines intersect (JointJS `jumpover` connector). */ + arcOnIntersections: boolean; + colorScheme: string; + animateFlow: boolean; +} + +export const DEFAULT_CONFIG: EditorConfig = { + gridSize: 20, + showGrid: true, + snapToGrid: true, + router: 'manhattan', + arcOnIntersections: true, + colorScheme: 'classic', + animateFlow: true, +}; + +export interface EditorEvents { + onSelectionChange(cellIds: string[]): void; + onGraphChange(): void; + onHistoryChange(canUndo: boolean, canRedo: boolean): void; + onSimulationChange(running: boolean, result: FlowResult | null): void; +} + +interface ClipboardEntry { + cells: unknown[]; +} + +const FLOW_EPS = 1e-6; + +export class EditorController { + readonly graph: dia.Graph; + readonly paper: dia.Paper; + config: EditorConfig; + + private events: EditorEvents; + private selection = new Set(); + private history: History; + private silent = 0; + private historyTimer: ReturnType | null = null; + private clipboard: ClipboardEntry | null = null; + private container: HTMLElement; + + private simRunning = false; + private simResult: FlowResult | null = null; + private animFrame: number | null = null; + private animOffsets = new Map(); + private lastTick = 0; + + private groupDragOrigins: Map | null = null; + private groupDragId: string | null = null; + private groupDragStart: { x: number; y: number } | null = null; + + constructor(container: HTMLElement, config: EditorConfig, events: EditorEvents) { + this.container = container; + this.config = { ...config }; + this.events = events; + const scheme = getColorScheme(config.colorScheme); + + this.graph = new dia.Graph({}, { cellNamespace }); + + this.paper = new dia.Paper({ + el: container, + model: this.graph, + width: '100%', + height: '100%', + gridSize: config.snapToGrid ? config.gridSize : 1, + drawGrid: config.showGrid ? { name: 'mesh', args: { color: scheme.gridColor } } : false, + background: { color: scheme.canvasBackground }, + cellViewNamespace: cellNamespace, + linkPinning: false, + snapLinks: { radius: 40 }, + markAvailable: true, + clickThreshold: 5, + moveThreshold: 3, + defaultRouter: this.routerDef(), + defaultConnector: this.connectorDef(), + defaultLink: () => this.makeLink(), + validateMagnet: (_view, magnet) => magnet.getAttribute('magnet') === 'true', + validateConnection: (svS, magnetS, svT, magnetT) => this.validateConnection(svS, magnetS, svT, magnetT), + highlighting: { + magnetAvailability: { name: 'addClass', options: { className: 'pf-magnet-available' } }, + elementAvailability: { name: 'addClass', options: { className: 'pf-element-available' } }, + }, + }); + + this.history = new History(this.snapshot()); + this.bindPaperEvents(); + this.bindGraphEvents(); + } + + // -------------------------------------------------------------- routing -- + + private routerDef(): dia.Paper.Options['defaultRouter'] { + const step = this.config.gridSize; + switch (this.config.router) { + case 'manhattan': + return { name: 'manhattan', args: { step, padding: step } }; + case 'orthogonal': + return { name: 'orthogonal', args: { padding: step } }; + case 'rightAngle': + return { name: 'rightAngle', args: { margin: step } }; + case 'normal': + return { name: 'normal' }; + } + } + + private connectorDef(): dia.Paper.Options['defaultConnector'] { + return this.config.arcOnIntersections + ? { name: 'jumpover', args: { size: 8, jump: 'arc', radius: 4 } } + : { name: 'rounded', args: { radius: 4 } }; + } + + private makeLink(): dia.Link { + const link = new PipelineLink({ + id: genId('edge'), + props: defaultEdgeProps(), + }); + link.router(this.routerDef() as never); + link.connector(this.connectorDef() as never); + return link; + } + + private validateConnection( + sourceView: dia.CellView, + sourceMagnet: SVGElement | null, + targetView: dia.CellView, + targetMagnet: SVGElement | null, + ): boolean { + if (!sourceMagnet || !targetMagnet) return false; + const sourcePortId = sourceMagnet.getAttribute('port'); + const targetPortId = targetMagnet.getAttribute('port'); + if (!sourcePortId || !targetPortId) return false; + const source = portSpec(sourceView.model, sourcePortId); + const target = portSpec(targetView.model, targetPortId); + if (!source || !target) return false; + if (sourceView === targetView && sourcePortId === targetPortId) return false; + return checkConnection(source, target).ok; + } + + // --------------------------------------------------------------- events -- + + private bindPaperEvents(): void { + const paper = this.paper; + + paper.on('element:pointerclick', (view, evt) => { + this.select(String(view.model.id), evt.shiftKey); + }); + paper.on('link:pointerclick', (view, evt) => { + this.select(String(view.model.id), evt.shiftKey); + }); + paper.on('blank:pointerclick', () => this.clearSelection()); + + // Group dragging: move the rest of the selection along with the dragged cell. + paper.on('element:pointerdown', (view) => { + const id = String(view.model.id); + if (this.selection.has(id) && this.selection.size > 1) { + this.groupDragOrigins = new Map(); + this.groupDragId = id; + this.groupDragStart = (view.model as dia.Element).position(); + for (const selId of this.selection) { + const cell = this.graph.getCell(selId); + if (cell && cell.isElement() && selId !== id) { + this.groupDragOrigins.set(selId, (cell as dia.Element).position()); + } + } + } + }); + paper.on('element:pointermove', (view) => { + if (!this.groupDragOrigins || !this.groupDragStart) return; + if (this.groupDragId !== String(view.model.id)) return; + const pos = (view.model as dia.Element).position(); + const dx = pos.x - this.groupDragStart.x; + const dy = pos.y - this.groupDragStart.y; + for (const [selId, selOrigin] of this.groupDragOrigins) { + const cell = this.graph.getCell(selId); + if (cell && cell.isElement()) { + (cell as dia.Element).position(selOrigin.x + dx, selOrigin.y + dy); + } + } + }); + paper.on('element:pointerup link:pointerup', () => { + this.groupDragOrigins = null; + this.groupDragId = null; + this.groupDragStart = null; + }); + + // Sync dragged position back into the x/y presentation props. + paper.on('element:pointerup', (view) => { + const el = view.model as dia.Element; + const props = { ...(el.get('props') ?? {}) } as Record; + const pos = el.position(); + if (props.x !== pos.x || props.y !== pos.y) { + props.x = pos.x; + props.y = pos.y; + el.set('props', props); + } + }); + + // Panning and rubber-band selection on blank space. + paper.on('blank:pointerdown', (evt) => { + if (evt.shiftKey) { + this.startRubberBand(evt as unknown as MouseEvent); + } else { + this.startPan(evt as unknown as MouseEvent); + } + }); + + // Style newly connected links according to relation + edge props. + paper.on('link:connect', (linkView) => { + const link = linkView.model as dia.Link; + this.applyLinkStyle(link); + this.events.onGraphChange(); + }); + + // Zoom at pointer with the mouse wheel. + this.container.addEventListener('wheel', this.onWheel, { passive: false }); + } + + private onWheel = (evt: WheelEvent): void => { + evt.preventDefault(); + const factor = evt.deltaY < 0 ? 1.1 : 1 / 1.1; + const local = this.paper.clientToLocalPoint(evt.clientX, evt.clientY); + this.zoomAt(local, factor); + }; + + private startPan(evt: MouseEvent): void { + const start = { x: evt.clientX, y: evt.clientY }; + const origin = this.paper.translate(); + const onMove = (e: MouseEvent) => { + this.paper.translate(origin.tx + (e.clientX - start.x), origin.ty + (e.clientY - start.y)); + }; + const onUp = () => { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + }; + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + } + + private startRubberBand(evt: MouseEvent): void { + const containerRect = this.container.getBoundingClientRect(); + const band = document.createElement('div'); + band.className = 'pf-rubber-band'; + const start = { x: evt.clientX - containerRect.left, y: evt.clientY - containerRect.top }; + Object.assign(band.style, { left: `${start.x}px`, top: `${start.y}px`, width: '0px', height: '0px' }); + this.container.appendChild(band); + const client0 = { x: evt.clientX, y: evt.clientY }; + let client1 = client0; + + const onMove = (e: MouseEvent) => { + client1 = { x: e.clientX, y: e.clientY }; + const x = Math.min(client0.x, client1.x) - containerRect.left; + const y = Math.min(client0.y, client1.y) - containerRect.top; + Object.assign(band.style, { + left: `${x}px`, + top: `${y}px`, + width: `${Math.abs(client1.x - client0.x)}px`, + height: `${Math.abs(client1.y - client0.y)}px`, + }); + }; + const onUp = () => { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + band.remove(); + const p0 = this.paper.clientToLocalPoint(client0.x, client0.y); + const p1 = this.paper.clientToLocalPoint(client1.x, client1.y); + const rect = { + x: Math.min(p0.x, p1.x), + y: Math.min(p0.y, p1.y), + width: Math.abs(p1.x - p0.x), + height: Math.abs(p1.y - p0.y), + }; + const models = this.graph.findModelsInArea(rect); + this.setSelection(models.map((m) => String(m.id))); + }; + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + } + + private bindGraphEvents(): void { + this.graph.on('add remove change', () => { + if (this.silent > 0) return; + this.scheduleHistoryPush(); + }); + this.graph.on('remove', (cell: dia.Cell) => { + if (this.selection.delete(String(cell.id))) { + this.emitSelection(); + } + }); + } + + private scheduleHistoryPush(): void { + if (this.historyTimer) clearTimeout(this.historyTimer); + this.historyTimer = setTimeout(() => { + this.historyTimer = null; + const snap = this.snapshot(); + if (snap !== this.history.peek()) { + this.history.push(snap); + this.events.onHistoryChange(this.history.canUndo, this.history.canRedo); + } + this.events.onGraphChange(); + }, 250); + } + + private snapshot(): string { + return JSON.stringify(this.graph.toJSON()); + } + + private applySnapshot(snap: string): void { + this.runSilent(() => { + this.stopSimulation(); + this.graph.fromJSON(JSON.parse(snap)); + this.restyleAll(); + }); + this.setSelection([]); + this.events.onGraphChange(); + } + + private runSilent(fn: () => void): void { + this.silent += 1; + try { + fn(); + } finally { + this.silent -= 1; + } + } + + // ------------------------------------------------------------ selection -- + + getSelection(): string[] { + return [...this.selection]; + } + + select(cellId: string, additive = false): void { + if (additive) { + if (this.selection.has(cellId)) this.selection.delete(cellId); + else this.selection.add(cellId); + } else { + this.selection.clear(); + this.selection.add(cellId); + } + this.emitSelection(); + } + + setSelection(cellIds: string[]): void { + this.selection = new Set(cellIds); + this.emitSelection(); + } + + clearSelection(): void { + if (this.selection.size === 0) return; + this.selection.clear(); + this.emitSelection(); + } + + selectAll(): void { + this.setSelection(this.graph.getCells().map((c) => String(c.id))); + } + + private emitSelection(): void { + this.updateSelectionHighlight(); + this.events.onSelectionChange([...this.selection]); + } + + private updateSelectionHighlight(): void { + const scheme = getColorScheme(this.config.colorScheme); + for (const cell of this.graph.getCells()) { + const view = this.paper.findViewByModel(cell); + if (!view) continue; + const selected = this.selection.has(String(cell.id)); + highlighters.mask.remove(view, 'pf-selection'); + view.el.classList.toggle('pf-focused', selected); + if (selected) { + highlighters.mask.add(view, cell.isLink() ? { selector: 'line' } : { selector: 'root' }, 'pf-selection', { + padding: 4, + attrs: { stroke: scheme.selectionColor, 'stroke-width': 2, 'stroke-linejoin': 'round' }, + }); + } + if (cell.isLink()) { + const linkView = view as dia.LinkView; + if (selected) { + linkView.addTools( + new dia.ToolsView({ + tools: [new linkTools.Vertices({ snapRadius: this.config.gridSize })], + }), + ); + } else { + linkView.removeTools(); + } + } + } + } + + // -------------------------------------------------------------- editing -- + + addNode(typeId: string, x: number, y: number): dia.Element { + const grid = this.config.snapToGrid ? this.config.gridSize : 1; + const snappedX = Math.round(x / grid) * grid; + const snappedY = Math.round(y / grid) * grid; + const el = createNode(typeId, snappedX, snappedY, this.config.colorScheme); + this.graph.addCell(el); + this.select(String(el.id)); + return el; + } + + clientToLocal(clientX: number, clientY: number): { x: number; y: number } { + return this.paper.clientToLocalPoint(clientX, clientY); + } + + deleteSelection(): void { + const cells = [...this.selection] + .map((id) => this.graph.getCell(id)) + .filter((c): c is dia.Cell => !!c); + if (cells.length === 0) return; + this.graph.removeCells(cells); + this.clearSelection(); + } + + nudgeSelection(dx: number, dy: number): void { + for (const id of this.selection) { + const cell = this.graph.getCell(id); + if (cell && cell.isElement()) { + (cell as dia.Element).translate(dx, dy); + } + } + } + + copySelection(): void { + const ids = this.selection; + const elements = [...ids] + .map((id) => this.graph.getCell(id)) + .filter((c): c is dia.Element => !!c && c.isElement()); + const elementIds = new Set(elements.map((e) => String(e.id))); + const links = this.graph.getLinks().filter((l) => { + const s = l.source().id; + const t = l.target().id; + return !!s && !!t && elementIds.has(String(s)) && elementIds.has(String(t)); + }); + if (elements.length === 0) return; + this.clipboard = { cells: [...elements, ...links].map((c) => c.toJSON()) }; + } + + paste(offset = 20): void { + if (!this.clipboard) return; + const idMap = new Map(); + const clones: dia.Cell[] = []; + for (const json of this.clipboard.cells as Record[]) { + const oldId = String(json.id); + idMap.set(oldId, genId(String(json.type).includes('Link') ? 'edge' : 'node')); + } + for (const json of this.clipboard.cells as Record[]) { + const copy = JSON.parse(JSON.stringify(json)); + copy.id = idMap.get(String(json.id)); + if (copy.position) { + copy.position.x += offset; + copy.position.y += offset; + if (copy.props) { + copy.props.x = copy.position.x; + copy.props.y = copy.position.y; + } + } + if (copy.source?.id) copy.source.id = idMap.get(String(copy.source.id)) ?? copy.source.id; + if (copy.target?.id) copy.target.id = idMap.get(String(copy.target.id)) ?? copy.target.id; + if (copy.vertices) { + copy.vertices = copy.vertices.map((v: { x: number; y: number }) => ({ x: v.x + offset, y: v.y + offset })); + } + clones.push(this.cellFromJSON(copy)); + } + this.graph.addCells(clones); + this.restyleAll(); + this.setSelection(clones.map((c) => String(c.id))); + } + + private cellFromJSON(json: Record): dia.Cell { + const type = String(json.type); + if (type === 'pipeline.Node') return new (cellNamespace.pipeline.Node as typeof dia.Element)(json); + return new (cellNamespace.pipeline.Link as typeof dia.Link)(json); + } + + duplicateSelection(): void { + this.copySelection(); + this.paste(); + } + + undo(): void { + this.flushHistory(); + const snap = this.history.undo(); + if (snap !== undefined) this.applySnapshot(snap); + this.events.onHistoryChange(this.history.canUndo, this.history.canRedo); + } + + redo(): void { + this.flushHistory(); + const snap = this.history.redo(); + if (snap !== undefined) this.applySnapshot(snap); + this.events.onHistoryChange(this.history.canUndo, this.history.canRedo); + } + + private flushHistory(): void { + if (this.historyTimer) { + clearTimeout(this.historyTimer); + this.historyTimer = null; + const snap = this.snapshot(); + if (snap !== this.history.peek()) this.history.push(snap); + } + } + + // ----------------------------------------------------------------- zoom -- + + zoomAt(localPoint: { x: number; y: number }, factor: number): void { + const scale = this.paper.scale().sx; + const next = Math.min(4, Math.max(0.2, scale * factor)); + if (next === scale) return; + const translate = this.paper.translate(); + // Keep localPoint stationary on screen while scaling. + const tx = translate.tx - localPoint.x * (next - scale); + const ty = translate.ty - localPoint.y * (next - scale); + this.paper.scale(next, next); + this.paper.translate(tx, ty); + } + + zoom(factor: number): void { + const rect = this.container.getBoundingClientRect(); + const center = this.paper.clientToLocalPoint(rect.left + rect.width / 2, rect.top + rect.height / 2); + this.zoomAt(center, factor); + } + + zoomToFit(): void { + this.paper.transformToFitContent({ + padding: 40, + minScale: 0.2, + maxScale: 1.5, + useModelGeometry: true, + }); + } + + resetZoom(): void { + this.paper.scale(1, 1); + this.paper.translate(0, 0); + } + + // ------------------------------------------------------------ properties -- + + getCellProps(cellId: string): Record | null { + const cell = this.graph.getCell(cellId); + if (!cell) return null; + return { ...(cell.get('props') ?? {}) } as Record; + } + + cellKind(cellId: string): 'node' | 'edge' | null { + const cell = this.graph.getCell(cellId); + if (!cell) return null; + return cell.isLink() ? 'edge' : 'node'; + } + + cellTypeId(cellId: string): string | null { + const cell = this.graph.getCell(cellId); + return cell ? ((cell.get('nodeTypeId') as string) ?? null) : null; + } + + setCellProp(cellId: string, key: string, value: PropertyValue): void { + const cell = this.graph.getCell(cellId); + if (!cell) return; + const props = { ...(cell.get('props') ?? {}), [key]: value } as Record; + cell.set('props', props); + if (cell.isElement()) { + const el = cell as dia.Element; + if (key === 'x' || key === 'y') { + el.position(Number(props.x ?? 0), Number(props.y ?? 0)); + } + applyNodePresentation(el, this.config.colorScheme); + } else { + this.applyLinkStyle(cell as dia.Link); + } + } + + // ----------------------------------------------------------------- style -- + + linkRelation(link: dia.Link): PortRelation { + const sourceId = link.source().id; + const sourcePort = link.source().port; + if (sourceId && sourcePort) { + const sourceCell = this.graph.getCell(sourceId); + if (sourceCell) { + const spec = portSpec(sourceCell, String(sourcePort)); + if (spec) return spec.relation; + } + } + return 'water'; + } + + applyLinkStyle(link: dia.Link): void { + const scheme = getColorScheme(this.config.colorScheme); + const props = (link.get('props') ?? {}) as Record; + const style = readEdgeStyle(props); + const relation = this.linkRelation(link); + link.attr('line', buildLineAttrs(style, scheme.relations[relation]) as never); + const title = typeof props.title === 'string' ? props.title : ''; + const baseLabels = title + ? [{ position: 0.35, attrs: { text: { text: title, fontSize: 11, fill: scheme.nodeStroke } } }] + : []; + // Preserve the simulation label (index ≥ 1) while editing the title. + const simLabel = (link.labels() ?? []).find((l) => (l as { simLabel?: boolean }).simLabel); + link.labels(simLabel ? [...baseLabels, simLabel] : baseLabels); + } + + private restyleAll(): void { + for (const el of this.graph.getElements()) { + applyNodePresentation(el, this.config.colorScheme); + } + for (const link of this.graph.getLinks()) { + link.router(this.routerDef() as never); + link.connector(this.connectorDef() as never); + this.applyLinkStyle(link); + } + } + + // ---------------------------------------------------------------- config -- + + applyConfig(config: EditorConfig): void { + const wasAnimating = this.simRunning; + this.config = { ...config }; + const scheme = getColorScheme(config.colorScheme); + this.paper.setGridSize(config.snapToGrid ? config.gridSize : 1); + if (config.showGrid) { + this.paper.setGrid({ name: 'mesh', args: { color: scheme.gridColor } }); + } else { + this.paper.setGrid(false as never); + } + this.paper.drawBackground({ color: scheme.canvasBackground }); + this.runSilent(() => this.restyleAll()); + this.updateSelectionHighlight(); + if (wasAnimating) { + // Re-derive animation styling for the new scheme/config. + this.stopSimulation(); + this.runSimulation(); + } + } + + // ------------------------------------------------------------- documents -- + + toPipelineGraph(): PipelineGraph { + const pg = new PipelineGraph(); + for (const el of this.graph.getElements()) { + const typeId = el.get('nodeTypeId') as string; + if (!getNodeType(typeId)) continue; + const pos = el.position(); + pg.addNode({ + id: String(el.id), + typeId, + x: pos.x, + y: pos.y, + angle: el.angle(), + props: { ...(el.get('props') ?? {}) }, + }); + } + for (const link of this.graph.getLinks()) { + const s = link.source(); + const t = link.target(); + if (!s.id || !t.id || !s.port || !t.port) continue; + try { + pg.addEdge({ + id: String(link.id), + sourceNodeId: String(s.id), + sourcePortId: String(s.port), + targetNodeId: String(t.id), + targetPortId: String(t.port), + props: { ...(link.get('props') ?? {}) }, + vertices: (link.vertices() ?? []).map((v) => ({ x: v.x, y: v.y })), + }); + } catch { + // Skip edges that no longer validate (e.g. after type edits). + } + } + return pg; + } + + serialize(): string { + const doc = this.toPipelineGraph().toDocument(); + return JSON.stringify(doc, null, 2); + } + + loadDocumentJSON(json: string): void { + const pg = PipelineGraph.fromDocument(JSON.parse(json)); + this.stopSimulation(); + this.runSilent(() => { + this.graph.clear(); + for (const node of pg.nodes.values()) { + const el = createNode(node.typeId, node.x, node.y, this.config.colorScheme, node.props, node.id); + this.graph.addCell(el); + } + for (const edge of pg.edges.values()) { + const link = this.makeLink(); + link.set('id', edge.id); + link.set('props', edge.props); + link.source({ id: edge.sourceNodeId, port: edge.sourcePortId }); + link.target({ id: edge.targetNodeId, port: edge.targetPortId }); + if (edge.vertices.length > 0) link.vertices(edge.vertices); + this.graph.addCell(link); + this.applyLinkStyle(link); + } + }); + this.history.reset(this.snapshot()); + this.setSelection([]); + this.events.onHistoryChange(false, false); + this.events.onGraphChange(); + } + + clearDocument(): void { + this.stopSimulation(); + this.graph.clear(); + this.clearSelection(); + } + + exportSVG(): string { + const svg = this.paper.svg.cloneNode(true) as SVGSVGElement; + const bbox = this.paper.getContentBBox(); + const local = this.paper.paperToLocalRect(bbox); + svg.setAttribute('viewBox', `${local.x - 20} ${local.y - 20} ${local.width + 40} ${local.height + 40}`); + svg.setAttribute('width', String(local.width + 40)); + svg.setAttribute('height', String(local.height + 40)); + // Undo the interactive pan/zoom transform on the exported copy. + const viewport = svg.querySelector('.joint-cells-layer, [class*="cells"]'); + if (viewport) viewport.removeAttribute('transform'); + return new XMLSerializer().serializeToString(svg); + } + + // ------------------------------------------------------------ simulation -- + + get simulationRunning(): boolean { + return this.simRunning; + } + + runSimulation(): FlowResult { + this.stopSimulation(); + const pg = this.toPipelineGraph(); + const result = solveFlow(pg); + this.simResult = result; + this.simRunning = true; + + this.runSilent(() => { + for (const link of this.graph.getLinks()) { + const flow = result.flows.get(String(link.id)) ?? 0; + const props = { ...(link.get('props') ?? {}) } as Record; + props.simFlow = Math.round(Math.abs(flow) * 100) / 100; + props.simDirection = + Math.abs(flow) < FLOW_EPS ? 'no flow' : flow > 0 ? 'along drawing' : 'reversed'; + link.set('props', props); + this.applySimLabel(link, flow); + } + }); + + if (this.config.animateFlow) this.startAnimation(); + this.events.onSimulationChange(true, result); + return result; + } + + stopSimulation(): void { + if (this.animFrame !== null) { + cancelAnimationFrame(this.animFrame); + this.animFrame = null; + } + if (!this.simRunning) return; + this.simRunning = false; + this.simResult = null; + this.runSilent(() => { + for (const link of this.graph.getLinks()) { + const labels = (link.labels() ?? []).filter((l) => !(l as { simLabel?: boolean }).simLabel); + link.labels(labels); + this.applyLinkStyle(link); + } + }); + this.events.onSimulationChange(false, null); + } + + private applySimLabel(link: dia.Link, flow: number): void { + const scheme = getColorScheme(this.config.colorScheme); + const labels = (link.labels() ?? []).filter((l) => !(l as { simLabel?: boolean }).simLabel); + if (Math.abs(flow) >= FLOW_EPS) { + labels.push({ + simLabel: true, + position: 0.5, + attrs: { + text: { + text: `${Math.abs(flow).toFixed(1)} m³/h`, + fontSize: 10, + fill: scheme.selectionColor, + fontFamily: 'monospace', + }, + rect: { fill: scheme.canvasBackground, opacity: 0.85 }, + }, + } as never); + } + link.labels(labels); + } + + private startAnimation(): void { + if (!this.simResult) return; + const result = this.simResult; + const maxFlow = result.maxAbsFlow || 1; + this.lastTick = performance.now(); + + const tick = (now: number) => { + const dt = (now - this.lastTick) / 1000; + this.lastTick = now; + for (const link of this.graph.getLinks()) { + const flow = result.flows.get(String(link.id)) ?? 0; + if (Math.abs(flow) < FLOW_EPS) continue; + const view = this.paper.findViewByModel(link) as dia.LinkView | null; + if (!view) continue; + const node = view.findNode ? (view.findNode('line') as SVGPathElement | null) : null; + const path = node ?? (view.el.querySelector('path[joint-selector="line"]') as SVGPathElement | null); + if (!path) continue; + const norm = Math.abs(flow) / maxFlow; + const width = 2 + norm * 6; + const speed = 20 + norm * 80; // px/s + const offset = (this.animOffsets.get(String(link.id)) ?? 0) - Math.sign(flow) * speed * dt; + this.animOffsets.set(String(link.id), offset); + path.setAttribute('stroke-dasharray', `${width * 3} ${width * 2}`); + path.setAttribute('stroke-dashoffset', String(offset)); + path.setAttribute('stroke-width', String(width)); + } + this.animFrame = requestAnimationFrame(tick); + }; + this.animFrame = requestAnimationFrame(tick); + } + + // ---------------------------------------------------------------- misc -- + + dispose(): void { + this.stopSimulation(); + this.container.removeEventListener('wheel', this.onWheel); + this.paper.remove(); + } +} + +// V import is used to guarantee the Vectorizer is bundled for SVG export helpers. +void V; diff --git a/src/editor/shapes.ts b/src/editor/shapes.ts new file mode 100644 index 0000000..a1ea1fa --- /dev/null +++ b/src/editor/shapes.ts @@ -0,0 +1,161 @@ +/** + * JointJS cell factories: pipeline nodes (SVG-symbol elements with ports) + * and pipeline links. Shapes are registered under the `pipeline` namespace + * so graph.fromJSON can rebuild them. + */ + +import { dia, shapes } from '@joint/core'; +import type { NodeTypeDef } from '../model/nodeTypes'; +import { getNodeType, defaultNodeProps } from '../model/nodeTypes'; +import type { PropertyValue } from '../model/properties'; +import type { PortSpec } from '../model/validation'; +import { getColorScheme } from './colorSchemes'; +import { genId } from '../model/graph'; + +export const PipelineNode = dia.Element.define( + 'pipeline.Node', + { + attrs: { + root: { magnet: false }, + image: { x: 0, y: 0 }, + label: { + textAnchor: 'middle', + fontSize: 12, + fontFamily: 'sans-serif', + fill: '#334155', + }, + }, + }, + { + markup: [ + { tagName: 'image', selector: 'image' }, + { tagName: 'text', selector: 'label' }, + ], + }, +); + +export const PipelineLink = shapes.standard.Link.define('pipeline.Link', { + attrs: { + line: { stroke: '#2563eb', strokeWidth: 2 }, + wrapper: { strokeWidth: 12 }, + }, +}); + +/** Namespace for dia.Graph cell rebuilding. */ +export const cellNamespace = { + ...shapes, + pipeline: { Node: PipelineNode, Link: PipelineLink }, +}; + +/** Render a node type's SVG symbol to a data URI with a concrete color. */ +export function svgDataUri(type: NodeTypeDef, color: string): string { + const svg = + `${type.svg}`; + return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; +} + +export function portAbsolutePosition(type: NodeTypeDef, port: PortSpec): { x: number; y: number } { + return { x: port.x * type.width, y: port.y * type.height }; +} + +/** Create a JointJS element for a node type at the given position. */ +export function createNode( + typeId: string, + x: number, + y: number, + schemeId: string, + props?: Record, + id?: string, +): dia.Element { + const type = getNodeType(typeId); + if (!type) throw new Error(`Unknown node type: ${typeId}`); + const scheme = getColorScheme(schemeId); + const bag = { ...defaultNodeProps(typeId), ...props, x, y }; + + const element = new PipelineNode({ + id: id ?? genId('node'), + position: { x, y }, + size: { width: type.width, height: type.height }, + nodeTypeId: typeId, + props: bag, + ports: { + groups: { + default: { + position: 'absolute', + attrs: { + portBody: { + r: 7, + magnet: true, + stroke: '#ffffff', + strokeWidth: 1.5, + cursor: 'crosshair', + class: 'pf-port-body', + }, + }, + markup: [{ tagName: 'circle', selector: 'portBody' }], + label: { + position: { name: 'radial', args: { offset: 12 } }, + markup: [{ tagName: 'text', selector: 'text', className: 'pf-port-label' }], + }, + }, + }, + items: type.ports.map((port) => ({ + id: port.id, + group: 'default', + args: portAbsolutePosition(type, port), + attrs: { + portBody: { fill: scheme.relations[port.relation] }, + text: { text: port.label ?? port.id, fontSize: 10, fill: scheme.nodeStroke }, + }, + })), + }, + }); + applyNodePresentation(element, schemeId); + return element; +} + +/** Sync presentation-group props (position, angle, color, label) onto the cell. */ +export function applyNodePresentation(element: dia.Element, schemeId: string): void { + const typeId = element.get('nodeTypeId') as string; + const type = getNodeType(typeId); + if (!type) return; + const scheme = getColorScheme(schemeId); + const props = (element.get('props') ?? {}) as Record; + + const color = typeof props.symbolColor === 'string' && props.symbolColor ? props.symbolColor : scheme.nodeStroke; + const title = typeof props.title === 'string' ? props.title : type.label; + const labelVisible = props.labelVisible !== 'no'; + + element.attr({ + image: { + href: svgDataUri(type, color), + width: type.width, + height: type.height, + }, + label: { + text: labelVisible ? title : '', + x: type.width / 2, + y: type.height + 14, + fill: scheme.nodeStroke, + }, + }); + + // Refresh port colors for the active scheme. + for (const port of type.ports) { + element.portProp(port.id, 'attrs/portBody/fill', scheme.relations[port.relation]); + element.portProp(port.id, 'attrs/text/fill', scheme.nodeStroke); + } + + const angle = Number(props.angle ?? 0); + if (element.angle() !== angle) { + element.rotate(angle - element.angle()); + } +} + +/** Look up the model PortSpec behind a JointJS element port. */ +export function portSpec(element: dia.Cell, portId: string): PortSpec | undefined { + const typeId = element.get('nodeTypeId') as string | undefined; + if (!typeId) return undefined; + return getNodeType(typeId)?.ports.find((p) => p.id === portId); +}