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) <noreply@anthropic.com>
This commit is contained in:
Ilya Ashikhmin 2026-07-02 23:18:36 +02:00
parent 372c2c25c4
commit 4769412070
7 changed files with 943 additions and 4 deletions

View File

@ -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<string>()
private clipboard: unknown[] = []
private animator: FlowAnimator
private lastSolution: FlowSolution | null = null
private restoring = false
private listeners: Record<Emit, Set<() => 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<Record<string, unknown>>
const idMap = new Map<string, string>()
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<DiagramSettings>): 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()
}
}

View File

@ -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<string, unknown> {
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: '' }
}
}

View File

@ -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<string, unknown>)?.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
}
}

View File

@ -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]
}
}

View File

@ -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
}

View File

@ -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<string, PropertyValue>
// ---- 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()
}

View File

@ -28,7 +28,8 @@ export interface SymbolDef {
schema: PropertySchema schema: PropertySchema
} }
const stroke = '#d7dae0' export const DEFAULT_STROKE = '#d7dae0'
const stroke = DEFAULT_STROKE
// Common physics fragments ------------------------------------------------- // Common physics fragments -------------------------------------------------
const elevation: PropertyDef = { key: 'elevation', label: 'Elevation', type: 'float', group: 'physics', unit: 'm', default: 0, step: 0.1 } 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 `<svg viewBox="0 0 100 100" width="${size}" height="${(size * symbol.height) / symbol.width}" xmlns="http://www.w3.org/2000/svg">${symbol.svg}</svg>` return `<svg viewBox="0 0 100 100" width="${size}" height="${(size * symbol.height) / symbol.width}" xmlns="http://www.w3.org/2000/svg">${symbol.svg}</svg>`
} }
/** data: URI encoding of a symbol icon, used as the node's <image> href. */ /**
export function symbolDataUri(symbol: SymbolDef): string { * data: URI encoding of a symbol icon, used as the node's <image> href.
const svg = `<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">${symbol.svg}</svg>` * 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 = `<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">${body}</svg>`
return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}` return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`
} }