From 47694120701eff2f13d36885f1549e7ad54356d3 Mon Sep 17 00:00:00 2001 From: Ilya Ashikhmin Date: Thu, 2 Jul 2026 23:18:36 +0200 Subject: [PATCH] feat(engine): JointJS canvas controller with ports, routing and flow Add custom SVG node element and styled links, grid-aligned manhattan router with optional jumpover arcs, medium-based connection validation, selection/highlighting, undo-redo history, copy/paste, settings, and a marching-ants flow animator driven by the mock solver. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/renderer/diagram/controller.ts | 464 ++++++++++++++++++++++++++ src/renderer/diagram/edges.ts | 74 ++++ src/renderer/diagram/flowAnimation.ts | 94 ++++++ src/renderer/diagram/history.ts | 45 +++ src/renderer/diagram/settings.ts | 48 +++ src/renderer/diagram/shapes.ts | 209 ++++++++++++ src/renderer/diagram/symbols.ts | 13 +- 7 files changed, 943 insertions(+), 4 deletions(-) create mode 100644 src/renderer/diagram/controller.ts create mode 100644 src/renderer/diagram/edges.ts create mode 100644 src/renderer/diagram/flowAnimation.ts create mode 100644 src/renderer/diagram/history.ts create mode 100644 src/renderer/diagram/settings.ts create mode 100644 src/renderer/diagram/shapes.ts diff --git a/src/renderer/diagram/controller.ts b/src/renderer/diagram/controller.ts new file mode 100644 index 0000000..834f591 --- /dev/null +++ b/src/renderer/diagram/controller.ts @@ -0,0 +1,464 @@ +/** + * DiagramController owns the JointJS Graph + Paper and exposes a small, + * framework-agnostic API for the React shell: add nodes, edit properties, + * select/delete/copy, undo/redo, configure, save/load and run the mock flow. + */ +import { dia, shapes, highlighters, g } from '@joint/core' +import { + PipelineNode, + createNode, + createLink, + applyNodeValues, + applyEdgeValues, + recolorPorts, + portMedium, + cellValues, + type NodeValues +} from './shapes' +import { canConnectMedia, type MediumId } from './relations' +import { DEFAULT_SETTINGS, routerConfig, connectorConfig, snap, type DiagramSettings } from './settings' +import { defaultsFor } from './properties' +import { edgeSchema } from './schemas' +import { getSymbol } from './symbols' +import { History } from './history' +import { solveFlow, type FlowNodeInput, type FlowEdgeInput, type FlowSolution } from './flow' +import { FlowAnimator } from './flowAnimation' + +const cellNamespace = { ...shapes, pipeline: { Node: PipelineNode } } + +type Emit = 'selection' | 'change' | 'settings' | 'flow' + +export class DiagramController { + readonly graph: dia.Graph + readonly paper: dia.Paper + settings: DiagramSettings + private history = new History() + private selection = new Set() + private clipboard: unknown[] = [] + private animator: FlowAnimator + private lastSolution: FlowSolution | null = null + private restoring = false + private listeners: Record void>> = { + selection: new Set(), + change: new Set(), + settings: new Set(), + flow: new Set() + } + + constructor(el: HTMLElement, settings: DiagramSettings = DEFAULT_SETTINGS) { + this.settings = { ...settings } + this.graph = new dia.Graph({}, { cellNamespace }) + this.paper = new dia.Paper({ + el, + model: this.graph, + width: '100%', + height: '100%', + gridSize: this.settings.gridSize, + drawGrid: this.settings.showGrid ? { name: 'mesh', args: { color: '#2a2c36' } } : false, + background: { color: this.settings.theme === 'dark' ? '#181920' : '#f4f5f7' }, + async: true, + cellViewNamespace: cellNamespace, + defaultRouter: routerConfig(this.settings), + defaultConnector: connectorConfig(this.settings), + snapLinks: { radius: 20 }, + linkPinning: false, + markAvailable: true, + defaultLink: () => { + const link = createLink({ id: '' }, { id: '' }) + return link + }, + validateConnection: this.validateConnection, + validateMagnet: (_view, magnet) => magnet.getAttribute('magnet') !== 'passive' + }) + this.animator = new FlowAnimator(this.graph) + this.bindEvents() + this.snapshot() + } + + // ---- Events ------------------------------------------------------------- + + on(evt: Emit, cb: () => void): () => void { + this.listeners[evt].add(cb) + return () => this.listeners[evt].delete(cb) + } + private fire(evt: Emit): void { + for (const cb of this.listeners[evt]) cb() + } + + private bindEvents(): void { + this.paper.on('element:pointerclick', (view, evt) => { + this.select(view.model.id as string, evt.shiftKey || evt.ctrlKey || evt.metaKey) + }) + this.paper.on('link:pointerclick', (view, evt) => { + this.select(view.model.id as string, evt.shiftKey || evt.ctrlKey || evt.metaKey) + }) + this.paper.on('blank:pointerclick', () => this.clearSelection()) + + // Snap nodes to the grid on drop and record history. + this.paper.on('element:pointerup', (view) => { + const el = view.model + const p = el.position() + el.position(snap(p.x, this.settings), snap(p.y, this.settings)) + this.snapshot() + }) + this.paper.on('link:connect', () => this.snapshot()) + + // Reveal ports of hovered node. + this.paper.on('element:mouseenter', (view) => this.setPortsVisible(view.model as dia.Element, true)) + this.paper.on('element:mouseleave', (view) => { + if (!this.selection.has(view.model.id as string)) { + this.setPortsVisible(view.model as dia.Element, false) + } + }) + + this.graph.on('remove', () => !this.restoring && this.fire('change')) + this.graph.on('add', () => !this.restoring && this.fire('change')) + } + + private validateConnection = ( + sourceView: dia.CellView, + sourceMagnet: SVGElement | null, + targetView: dia.CellView, + targetMagnet: SVGElement | null + ): boolean => { + if (!targetMagnet) return false // must land on a port + const targetPort = targetMagnet.getAttribute('port') + const sourcePort = sourceMagnet?.getAttribute('port') + if (!targetPort) return false + // Disallow linking a port back to the very same port. + if (sourceView === targetView && sourcePort === targetPort) return false + const sMed = portMedium(sourceView.model as dia.Element, sourcePort ?? '') + const tMed = portMedium(targetView.model as dia.Element, targetPort) + if (!sMed || !tMed) return false + return canConnectMedia(sMed as MediumId, tMed as MediumId) + } + + // ---- Node / edge creation ---------------------------------------------- + + addNodeAtClient(symbolId: string, clientX: number, clientY: number): dia.Element { + const local = this.paper.clientToLocalPoint(new g.Point(clientX, clientY)) + return this.addNode(symbolId, local.x, local.y) + } + + addNode(symbolId: string, x: number, y: number): dia.Element { + const node = createNode( + symbolId, + snap(x, this.settings), + snap(y, this.settings), + this.settings.colorScheme + ) + node.addTo(this.graph) + this.setPortsVisible(node, false) + this.snapshot() + this.select(node.id as string, false) + return node + } + + // ---- Selection ---------------------------------------------------------- + + select(id: string, additive: boolean): void { + if (!additive) this.clearHighlights() + if (!additive) this.selection.clear() + if (this.selection.has(id) && additive) this.selection.delete(id) + else this.selection.add(id) + this.refreshHighlights() + this.fire('selection') + } + + selectAll(): void { + this.clearHighlights() + this.selection = new Set(this.graph.getCells().map((c) => c.id as string)) + this.refreshHighlights() + this.fire('selection') + } + + clearSelection(): void { + this.clearHighlights() + this.selection.clear() + this.fire('selection') + } + + getSelection(): dia.Cell[] { + return [...this.selection].map((id) => this.graph.getCell(id)).filter(Boolean) as dia.Cell[] + } + + private refreshHighlights(): void { + for (const id of this.selection) { + const cell = this.graph.getCell(id) + const view = cell && this.paper.findViewByModel(cell) + if (!view) continue + highlighters.mask.add(view, cell.isLink() ? 'line' : 'body', 'sel', { + deep: false, + padding: 4, + attrs: { stroke: '#f0c000', 'stroke-width': 2 } + }) + if (!cell.isLink()) this.setPortsVisible(cell as dia.Element, true) + } + } + + private clearHighlights(): void { + for (const id of this.selection) { + const cell = this.graph.getCell(id) + const view = cell && this.paper.findViewByModel(cell) + if (view) highlighters.mask.remove(view, 'sel') + if (cell && !cell.isLink()) this.setPortsVisible(cell as dia.Element, false) + } + } + + private setPortsVisible(node: dia.Element, visible: boolean): void { + for (const port of node.getPorts()) { + node.portProp(port.id as string, 'attrs/portBody/opacity', visible ? 1 : 0.28) + } + } + + // ---- Property editing --------------------------------------------------- + + applyValues(cellId: string, values: NodeValues): void { + const cell = this.graph.getCell(cellId) + if (!cell) return + if (cell.isLink()) applyEdgeValues(cell as dia.Link, values) + else applyNodeValues(cell as dia.Element, values, this.settings.colorScheme) + this.snapshot() + this.fire('change') + } + + // ---- Delete / copy / paste --------------------------------------------- + + deleteSelected(): void { + const cells = this.getSelection() + if (cells.length === 0) return + this.clearHighlights() + this.selection.clear() + this.graph.removeCells(cells) + this.snapshot() + this.fire('selection') + } + + copySelected(): void { + const cells = this.getSelection() + if (cells.length === 0) return + const ids = new Set(cells.map((c) => c.id)) + // Include links whose both ends are selected. + const withLinks = this.graph.getCells().filter((c) => { + if (ids.has(c.id)) return true + if (!c.isLink()) return false + const s = (c as dia.Link).getSourceElement()?.id + const t = (c as dia.Link).getTargetElement()?.id + return s && t && ids.has(s) && ids.has(t) + }) + this.clipboard = withLinks.map((c) => c.toJSON()) + } + + paste(): void { + if (this.clipboard.length === 0) return + const cells = JSON.parse(JSON.stringify(this.clipboard)) as Array> + const idMap = new Map() + const created: dia.Cell[] = [] + const offset = this.settings.gridSize * 2 + // First pass: elements. + for (const raw of cells) { + if (raw.type === 'pipeline.Node') { + const sym = getSymbol(String(raw.symbolId)) + if (!sym) continue + const pos = raw.position as { x: number; y: number } + const node = createNode( + String(raw.symbolId), + pos.x + offset, + pos.y + offset, + this.settings.colorScheme, + raw.values as NodeValues + ) + idMap.set(String(raw.id), node.id as string) + node.addTo(this.graph) + created.push(node) + } + } + // Second pass: links between copied elements. + for (const raw of cells) { + if (raw.type !== 'pipeline.Node') { + const source = raw.source as { id?: string; port?: string } + const target = raw.target as { id?: string; port?: string } + const ns = source.id && idMap.get(source.id) + const nt = target.id && idMap.get(target.id) + if (!ns || !nt) continue + const link = createLink( + { id: ns, port: source.port }, + { id: nt, port: target.port }, + raw.values as NodeValues + ) + link.addTo(this.graph) + created.push(link) + } + } + this.selection = new Set(created.map((c) => c.id as string)) + this.refreshHighlights() + this.snapshot() + this.fire('selection') + } + + duplicateSelected(): void { + this.copySelected() + this.paste() + } + + // ---- History ------------------------------------------------------------ + + private snapshot(): void { + if (this.restoring) return + this.history.push(JSON.stringify(this.graph.toJSON())) + this.fire('change') + } + + private restore(json: string | null): void { + if (!json) return + this.restoring = true + this.clearHighlights() + this.selection.clear() + this.graph.fromJSON(JSON.parse(json)) + for (const node of this.graph.getElements()) this.setPortsVisible(node, false) + this.restoring = false + this.fire('selection') + this.fire('change') + } + + undo(): void { + this.restore(this.history.undo()) + } + redo(): void { + this.restore(this.history.redo()) + } + canUndo(): boolean { + return this.history.canUndo() + } + canRedo(): boolean { + return this.history.canRedo() + } + + // ---- Settings ----------------------------------------------------------- + + setSettings(patch: Partial): void { + this.settings = { ...this.settings, ...patch } + const s = this.settings + this.paper.options.gridSize = s.gridSize + this.paper.setGridSize(s.gridSize) + this.paper.drawGrid(s.showGrid ? { name: 'mesh', args: { color: '#2a2c36' } } : false) + if (!s.showGrid) this.paper.clearGrid() + this.paper.drawBackground({ color: s.theme === 'dark' ? '#181920' : '#f4f5f7' }) + this.paper.options.defaultRouter = routerConfig(s) as dia.Path.Segment[] | never + this.paper.options.defaultConnector = connectorConfig(s) as never + for (const link of this.graph.getLinks()) { + link.router(routerConfig(s).name, routerConfig(s).args) + link.connector(connectorConfig(s).name, connectorConfig(s).args) + } + for (const node of this.graph.getElements()) recolorPorts(node, s.colorScheme) + this.fire('settings') + } + + // ---- Zoom / fit --------------------------------------------------------- + + zoom(factor: number): void { + const next = Math.min(3, Math.max(0.2, this.paper.scale().sx * factor)) + this.paper.scale(next, next) + } + resetZoom(): void { + this.paper.scale(1, 1) + this.paper.translate(0, 0) + } + fitContent(): void { + this.paper.transformToFitContent({ padding: 40, minScale: 0.2, maxScale: 2 }) + } + + // ---- Flow --------------------------------------------------------------- + + private nodeSupply(node: dia.Element): number { + const v = cellValues(node) + const num = (x: unknown): number => Number(x) || 0 + switch (node.get('symbolId')) { + case 'well': + return num(v.supplyFlow) + case 'reservoir': + return 50 + case 'grid-supply': + return 100 + case 'outlet': + return -num(v.demand) + case 'load': + return -num(v.power) + default: + return 0 + } + } + + runFlow(): FlowSolution { + const nodes: FlowNodeInput[] = this.graph + .getElements() + .map((n) => ({ id: n.id as string, supply: this.nodeSupply(n) })) + const edges: FlowEdgeInput[] = this.graph + .getLinks() + .map((l) => { + const s = l.getSourceElement()?.id as string | undefined + const t = l.getTargetElement()?.id as string | undefined + return s && t ? { id: l.id as string, source: s, target: t } : null + }) + .filter(Boolean) as FlowEdgeInput[] + const solution = solveFlow(nodes, edges) + this.lastSolution = solution + this.animator.start(solution, { colorByMagnitude: true }) + this.fire('flow') + return solution + } + + stopFlow(): void { + this.animator.reset((link) => applyEdgeValues(link as dia.Link, cellValues(link))) + this.lastSolution = null + this.fire('flow') + } + + isFlowing(): boolean { + return this.animator.isRunning() + } + getSolution(): FlowSolution | null { + return this.lastSolution + } + + // ---- Serialization ------------------------------------------------------ + + toJSON(): string { + return JSON.stringify({ settings: this.settings, graph: this.graph.toJSON() }, null, 2) + } + + loadJSON(json: string): void { + const data = JSON.parse(json) + this.restoring = true + this.selection.clear() + if (data.settings) this.settings = { ...DEFAULT_SETTINGS, ...data.settings } + this.graph.fromJSON(data.graph ?? data) + for (const node of this.graph.getElements()) this.setPortsVisible(node, false) + this.restoring = false + this.history.reset(JSON.stringify(this.graph.toJSON())) + this.setSettings({}) + this.fire('change') + this.fire('selection') + } + + newDiagram(): void { + this.stopFlow() + this.restoring = true + this.selection.clear() + this.graph.clear() + this.restoring = false + this.history.reset(JSON.stringify(this.graph.toJSON())) + this.fire('change') + this.fire('selection') + } + + // Convenience for tests / edge default values. + edgeDefaults(): NodeValues { + return defaultsFor(edgeSchema()) + } + + dispose(): void { + this.animator.stop() + this.paper.remove() + } +} diff --git a/src/renderer/diagram/edges.ts b/src/renderer/diagram/edges.ts new file mode 100644 index 0000000..cdafda7 --- /dev/null +++ b/src/renderer/diagram/edges.ts @@ -0,0 +1,74 @@ +/** + * Pure helpers that translate edge property values into SVG marker / stroke + * descriptors. Kept free of JointJS so they can be unit-tested and reused by + * the property panel preview. + */ + +export type MarkerKind = 'none' | 'arrow' | 'openArrow' | 'circle' | 'diamond' | 'bar' +export type LineStyle = 'solid' | 'dashed' | 'dotted' + +export interface MarkerDescriptor { + type: 'path' | 'circle' + d?: string + r?: number + fill: string + stroke: string + 'stroke-width'?: number +} + +/** + * Build a JointJS-compatible marker descriptor. Markers are drawn in a local + * frame where +x points along the line away from the endpoint; JointJS rotates + * them to the correct angle. Returns null for 'none'. + */ +export function marker(kind: MarkerKind, color: string): MarkerDescriptor | null { + switch (kind) { + case 'arrow': + return { type: 'path', d: 'M 10 -5 0 0 10 5 Z', fill: color, stroke: color } + case 'openArrow': + return { type: 'path', d: 'M 10 -5 0 0 10 5', fill: 'none', stroke: color, 'stroke-width': 2 } + case 'circle': + return { type: 'circle', r: 5, fill: color, stroke: color } + case 'diamond': + return { type: 'path', d: 'M 10 0 5 -5 0 0 5 5 Z', fill: color, stroke: color } + case 'bar': + return { type: 'path', d: 'M 0 -6 0 6', fill: 'none', stroke: color, 'stroke-width': 3 } + case 'none': + default: + return null + } +} + +/** Dash pattern for a given line style, scaled by stroke width. */ +export function dashArray(style: LineStyle, strokeWidth: number): string { + switch (style) { + case 'dashed': + return `${strokeWidth * 3} ${strokeWidth * 2}` + case 'dotted': + return `${strokeWidth} ${strokeWidth * 1.5}` + case 'solid': + default: + return 'none' + } +} + +export interface EdgeStyleValues { + color?: string + lineStyle?: LineStyle + strokeWidth?: number + headMarker?: MarkerKind + tailMarker?: MarkerKind +} + +/** Compute the `attrs.line` object for a JointJS standard.Link. */ +export function lineAttrs(v: EdgeStyleValues): Record { + const color = v.color ?? '#2f7fe0' + const strokeWidth = v.strokeWidth ?? 3 + return { + stroke: color, + strokeWidth, + strokeDasharray: dashArray(v.lineStyle ?? 'solid', strokeWidth), + sourceMarker: marker(v.tailMarker ?? 'none', color) ?? { d: '' }, + targetMarker: marker(v.headMarker ?? 'arrow', color) ?? { d: '' } + } +} diff --git a/src/renderer/diagram/flowAnimation.ts b/src/renderer/diagram/flowAnimation.ts new file mode 100644 index 0000000..96ab082 --- /dev/null +++ b/src/renderer/diagram/flowAnimation.ts @@ -0,0 +1,94 @@ +/** + * Animates flow along links using a marching-ants stroke-dashoffset loop. + * Dash density and animation speed encode flow volume; sign of movement + * encodes direction. Edge color is shifted toward the magnitude legend. + */ +import type { dia } from '@joint/core' +import type { FlowSolution } from './flow' + +/** Blue → cyan → yellow → red ramp for flow magnitude. */ +export function magnitudeColor(magnitude: number, max: number): string { + if (max <= 0) return '#3a6ea5' + const t = Math.min(1, magnitude / max) + const stops = [ + [58, 110, 165], // low #3a6ea5 + [63, 182, 192], // #3fb6c0 + [224, 182, 47], // #e0b62f + [224, 86, 47] // high #e0562f + ] + const seg = t * (stops.length - 1) + const i = Math.min(stops.length - 2, Math.floor(seg)) + const f = seg - i + const [r, g, b] = stops[i].map((c, k) => Math.round(c + (stops[i + 1][k] - c) * f)) + return `rgb(${r}, ${g}, ${b})` +} + +export interface FlowAnimatorOptions { + colorByMagnitude: boolean +} + +export class FlowAnimator { + private raf = 0 + private running = false + private offset = 0 + private solution: FlowSolution | null = null + + constructor( + private graph: dia.Graph, + private nowFn: () => number = () => performance.now() + ) {} + + start(solution: FlowSolution, opts: FlowAnimatorOptions = { colorByMagnitude: true }): void { + this.stop() + this.solution = solution + this.running = true + // Prepare dash pattern & optional color per animated link. + for (const link of this.graph.getLinks()) { + const flow = solution.edges.get(link.id as string) + if (!flow || flow.magnitude === 0) continue + const width = Number((link.get('values') as Record)?.strokeWidth) || 3 + link.attr('line/strokeDasharray', `${width * 2.5} ${width * 2}`) + if (opts.colorByMagnitude) { + link.attr('line/stroke', magnitudeColor(flow.magnitude, solution.maxMagnitude)) + } + } + const last = { t: this.nowFn() } + const tick = (): void => { + if (!this.running || !this.solution) return + const now = this.nowFn() + const dt = Math.min(64, now - last.t) + last.t = now + this.offset += dt * 0.06 + const max = this.solution.maxMagnitude || 1 + for (const link of this.graph.getLinks()) { + const flow = this.solution.edges.get(link.id as string) + if (!flow || flow.magnitude === 0) continue + const speed = 0.3 + (flow.magnitude / max) * 1.7 + const dir = flow.forward ? -1 : 1 + link.attr('line/strokeDashoffset', dir * this.offset * speed, { silent: true }) + } + this.raf = requestAnimationFrame(tick) + } + this.raf = requestAnimationFrame(tick) + } + + stop(): void { + this.running = false + if (this.raf) cancelAnimationFrame(this.raf) + this.raf = 0 + } + + /** Restore links to their static styling. */ + reset(applyStatic: (link: dia.Link) => void): void { + this.stop() + this.solution = null + for (const link of this.graph.getLinks()) { + link.attr('line/strokeDashoffset', 0, { silent: true }) + applyStatic(link) + } + } + + isRunning(): boolean { + return this.running + } +} diff --git a/src/renderer/diagram/history.ts b/src/renderer/diagram/history.ts new file mode 100644 index 0000000..9b553c2 --- /dev/null +++ b/src/renderer/diagram/history.ts @@ -0,0 +1,45 @@ +/** Snapshot-based undo/redo stack (stores serialized graph states). */ +export class History { + private stack: string[] = [] + private index = -1 + private readonly limit: number + + constructor(limit = 100) { + this.limit = limit + } + + /** Seed the stack with the initial state. */ + reset(snapshot: string): void { + this.stack = [snapshot] + this.index = 0 + } + + /** Record a new state, discarding any redo history. */ + push(snapshot: string): void { + if (this.index >= 0 && this.stack[this.index] === snapshot) return + this.stack = this.stack.slice(0, this.index + 1) + this.stack.push(snapshot) + if (this.stack.length > this.limit) this.stack.shift() + this.index = this.stack.length - 1 + } + + canUndo(): boolean { + return this.index > 0 + } + + canRedo(): boolean { + return this.index < this.stack.length - 1 + } + + undo(): string | null { + if (!this.canUndo()) return null + this.index-- + return this.stack[this.index] + } + + redo(): string | null { + if (!this.canRedo()) return null + this.index++ + return this.stack[this.index] + } +} diff --git a/src/renderer/diagram/settings.ts b/src/renderer/diagram/settings.ts new file mode 100644 index 0000000..aa6e6b8 --- /dev/null +++ b/src/renderer/diagram/settings.ts @@ -0,0 +1,48 @@ +/** Editor-wide configuration and the JointJS router/connector it produces. */ + +export interface DiagramSettings { + gridSize: number + snapToGrid: boolean + /** Draw an arc where links cross (JointJS `jumpover` connector). */ + jumpover: boolean + colorScheme: string + theme: 'dark' | 'light' + showGrid: boolean +} + +export const DEFAULT_SETTINGS: DiagramSettings = { + gridSize: 16, + snapToGrid: true, + jumpover: true, + colorScheme: 'default', + theme: 'dark', + showGrid: true +} + +/** Orthogonal router aligned to the grid so links follow the rectangular grid. */ +export function routerConfig(s: DiagramSettings) { + return { + name: 'manhattan', + args: { + step: s.gridSize, + padding: s.gridSize, + startDirections: ['left', 'right', 'top', 'bottom'], + endDirections: ['left', 'right', 'top', 'bottom'] + } + } +} + +/** + * Connector controls how routed segments are drawn. With `jumpover` on, JointJS + * inserts an arc (gap) at every crossing of two links — requirement #3. + */ +export function connectorConfig(s: DiagramSettings) { + return s.jumpover + ? { name: 'jumpover', args: { size: Math.max(6, s.gridSize / 2), jump: 'arc' } } + : { name: 'rounded', args: { radius: 4 } } +} + +/** Snap a coordinate to the grid when snapping is enabled. */ +export function snap(value: number, s: DiagramSettings): number { + return s.snapToGrid ? Math.round(value / s.gridSize) * s.gridSize : value +} diff --git a/src/renderer/diagram/shapes.ts b/src/renderer/diagram/shapes.ts new file mode 100644 index 0000000..e783f59 --- /dev/null +++ b/src/renderer/diagram/shapes.ts @@ -0,0 +1,209 @@ +/** + * JointJS element/link construction from symbols and property values. This is + * the bridge between the framework-free domain model and the JointJS graph. + */ +import { dia, shapes } from '@joint/core' +import { getSymbol, symbolDataUri, type SymbolDef, type PortSpec } from './symbols' +import { defaultsFor, type PropertyValue } from './properties' +import { edgeSchema } from './schemas' +import { mediumColor, type MediumId } from './relations' +import { lineAttrs, type EdgeStyleValues, type MarkerKind, type LineStyle } from './edges' + +export type NodeValues = Record + +// ---- Custom element ------------------------------------------------------- + +export const PipelineNode = dia.Element.define( + 'pipeline.Node', + { + size: { width: 72, height: 72 }, + attrs: { + body: { + refWidth: '100%', + refHeight: '100%', + fill: 'transparent', + stroke: 'transparent', + strokeWidth: 2, + rx: 6, + ry: 6 + }, + icon: { + refWidth: '100%', + refHeight: '100%', + x: 0, + y: 0, + preserveAspectRatio: 'xMidYMid meet' + }, + label: { + text: '', + refX: '50%', + refY: '100%', + refY2: 6, + textAnchor: 'middle', + textVerticalAnchor: 'top', + fill: '#c9cdd6', + fontSize: 11, + fontFamily: 'Inter, system-ui, sans-serif', + pointerEvents: 'none' + } + } + }, + { + markup: [ + { tagName: 'rect', selector: 'body' }, + { tagName: 'image', selector: 'icon' }, + { tagName: 'text', selector: 'label' } + ] + } +) + +const PORT_GROUP = 'pipe' + +function portItem(p: PortSpec, schemeId: string) { + const color = mediumColor(p.medium, schemeId) + return { + id: p.id, + group: PORT_GROUP, + medium: p.medium, + args: { x: `${p.x * 100}%`, y: `${p.y * 100}%` }, + attrs: { + portBody: { + r: 6, + fill: color, + stroke: '#15171d', + strokeWidth: 1.5, + magnet: 'active', + cursor: 'crosshair' + }, + portLabel: { text: p.label ?? '', fill: '#8a8f99', fontSize: 9 } + } + } +} + +function portGroupsDef() { + return { + [PORT_GROUP]: { + position: { name: 'absolute' }, + label: { position: { name: 'outside', args: { offset: 8 } } }, + markup: [{ tagName: 'circle', selector: 'portBody' }] + } + } +} + +/** Create a node element from a symbol id at a position. */ +export function createNode( + symbolId: string, + x: number, + y: number, + schemeId = 'default', + values?: NodeValues +): dia.Element { + const sym = getSymbol(symbolId) + if (!sym) throw new Error(`Unknown symbol: ${symbolId}`) + + const vals: NodeValues = values ?? { + ...defaultsFor(sym.schema), + width: sym.width, + height: sym.height + } + + const node = new PipelineNode({ + position: { x, y }, + size: { width: Number(vals.width) || sym.width, height: Number(vals.height) || sym.height }, + ports: { groups: portGroupsDef(), items: sym.ports.map((p) => portItem(p, schemeId)) } + }) + node.set('symbolId', symbolId) + node.set('kind', 'node') + node.set('values', vals) + applyNodeValues(node, vals, schemeId) + return node +} + +/** Apply property values to an existing node (size, rotation, label, tint). */ +export function applyNodeValues(node: dia.Element, values: NodeValues, schemeId = 'default'): void { + const sym = getSymbol(String(node.get('symbolId'))) + if (!sym) return + node.set('values', values) + const w = Number(values.width) || sym.width + const h = Number(values.height) || sym.height + node.resize(w, h) + node.attr('icon/xlink:href', symbolDataUri(sym, String(values.color ?? ''))) + const showLabel = values.showLabel !== 'no' + node.attr('label/text', showLabel ? String(values.title ?? sym.label) : '') + const angle = Number(values.rotation) || 0 + node.rotate(angle, true) + // Recolor ports to the active scheme. + for (const p of sym.ports) { + node.portProp(p.id, 'attrs/portBody/fill', mediumColor(p.medium, schemeId)) + } +} + +export function recolorPorts(node: dia.Element, schemeId: string): void { + const sym = getSymbol(String(node.get('symbolId'))) + if (!sym) return + for (const p of sym.ports) { + node.portProp(p.id, 'attrs/portBody/fill', mediumColor(p.medium, schemeId)) + } +} + +/** Return the medium carried by a node's port. */ +export function portMedium(node: dia.Element, portId: string): MediumId | undefined { + const port = node.getPort(portId) as { medium?: MediumId } | undefined + return port?.medium +} + +// ---- Custom link ---------------------------------------------------------- + +export function createLink( + source: { id: string; port?: string }, + target: { id: string; port?: string }, + values?: NodeValues +): dia.Link { + const vals: NodeValues = values ?? defaultsFor(edgeSchema()) + const link = new shapes.standard.Link({ + source: source.port ? { id: source.id, port: source.port } : { id: source.id }, + target: target.port ? { id: target.id, port: target.port } : { id: target.id } + }) + link.set('kind', 'edge') + link.set('values', vals) + applyEdgeValues(link, vals) + return link +} + +export function applyEdgeValues(link: dia.Link, values: NodeValues): void { + link.set('values', values) + const style: EdgeStyleValues = { + color: String(values.color ?? '#2f7fe0'), + lineStyle: (values.lineStyle as LineStyle) ?? 'solid', + strokeWidth: Number(values.strokeWidth) || 3, + headMarker: (values.headMarker as MarkerKind) ?? 'arrow', + tailMarker: (values.tailMarker as MarkerKind) ?? 'none' + } + link.attr('line', lineAttrs(style)) + const title = String(values.title ?? '') + link.labels( + title + ? [ + { + position: 0.5, + attrs: { + text: { text: title, fill: '#c9cdd6', fontSize: 10 }, + rect: { fill: '#1e1f26', stroke: 'none' } + } + } + ] + : [] + ) +} + +/** Read the stored property values off any cell. */ +export function cellValues(cell: dia.Cell): NodeValues { + return (cell.get('values') as NodeValues) ?? {} +} + +/** Get the schema-appropriate default values for a cell kind. */ +export function schemaForCell(cell: dia.Cell): SymbolDef['schema'] { + if (cell.get('kind') === 'edge') return edgeSchema() + const sym = getSymbol(String(cell.get('symbolId'))) + return sym ? sym.schema : edgeSchema() +} diff --git a/src/renderer/diagram/symbols.ts b/src/renderer/diagram/symbols.ts index 27bd024..b4d435b 100644 --- a/src/renderer/diagram/symbols.ts +++ b/src/renderer/diagram/symbols.ts @@ -28,7 +28,8 @@ export interface SymbolDef { schema: PropertySchema } -const stroke = '#d7dae0' +export const DEFAULT_STROKE = '#d7dae0' +const stroke = DEFAULT_STROKE // Common physics fragments ------------------------------------------------- const elevation: PropertyDef = { key: 'elevation', label: 'Elevation', type: 'float', group: 'physics', unit: 'm', default: 0, step: 0.1 } @@ -285,8 +286,12 @@ export function symbolSvgMarkup(symbol: SymbolDef, size = symbol.width): string return `${symbol.svg}` } -/** data: URI encoding of a symbol icon, used as the node's href. */ -export function symbolDataUri(symbol: SymbolDef): string { - const svg = `${symbol.svg}` +/** + * data: URI encoding of a symbol icon, used as the node's href. + * When `color` is given the default stroke is recolored to tint the symbol. + */ +export function symbolDataUri(symbol: SymbolDef, color?: string): string { + const body = color ? symbol.svg.split(DEFAULT_STROKE).join(color) : symbol.svg + const svg = `${body}` return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}` }