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:
parent
372c2c25c4
commit
4769412070
464
src/renderer/diagram/controller.ts
Normal file
464
src/renderer/diagram/controller.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
74
src/renderer/diagram/edges.ts
Normal file
74
src/renderer/diagram/edges.ts
Normal 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: '' }
|
||||
}
|
||||
}
|
||||
94
src/renderer/diagram/flowAnimation.ts
Normal file
94
src/renderer/diagram/flowAnimation.ts
Normal 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
|
||||
}
|
||||
}
|
||||
45
src/renderer/diagram/history.ts
Normal file
45
src/renderer/diagram/history.ts
Normal 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]
|
||||
}
|
||||
}
|
||||
48
src/renderer/diagram/settings.ts
Normal file
48
src/renderer/diagram/settings.ts
Normal 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
|
||||
}
|
||||
209
src/renderer/diagram/shapes.ts
Normal file
209
src/renderer/diagram/shapes.ts
Normal 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()
|
||||
}
|
||||
@ -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 `<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 {
|
||||
const svg = `<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">${symbol.svg}</svg>`
|
||||
/**
|
||||
* data: URI encoding of a symbol icon, used as the node's <image> 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 = `<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">${body}</svg>`
|
||||
return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user