feat(editor): JointJS integration — shapes, paper controller, edge styles, color schemes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Ilya Ashikhmin 2026-07-02 23:12:20 +02:00
parent 3040dc9d20
commit 7aa1f9517b
4 changed files with 1198 additions and 0 deletions

View File

@ -0,0 +1,91 @@
/**
* Color schemes map port relations to colors used for ports, edges and the
* canvas chrome. Selectable in Settings.
*/
import type { PortRelation } from '../model/validation';
export interface ColorScheme {
id: string;
label: string;
relations: Record<PortRelation, string>;
canvasBackground: string;
gridColor: string;
nodeStroke: string;
selectionColor: string;
}
export const COLOR_SCHEMES: ColorScheme[] = [
{
id: 'classic',
label: 'Classic',
relations: {
water: '#2563eb',
heat: '#dc2626',
gas: '#ca8a04',
sewage: '#78716c',
power: '#9333ea',
signal: '#059669',
},
canvasBackground: '#f8fafc',
gridColor: '#dbe2ea',
nodeStroke: '#1e293b',
selectionColor: '#0ea5e9',
},
{
id: 'dark',
label: 'Dark',
relations: {
water: '#60a5fa',
heat: '#f87171',
gas: '#facc15',
sewage: '#a8a29e',
power: '#c084fc',
signal: '#34d399',
},
canvasBackground: '#0f172a',
gridColor: '#243046',
nodeStroke: '#e2e8f0',
selectionColor: '#38bdf8',
},
{
id: 'highContrast',
label: 'High contrast',
relations: {
water: '#0000ff',
heat: '#ff0000',
gas: '#b45309',
sewage: '#000000',
power: '#ff00ff',
signal: '#008000',
},
canvasBackground: '#ffffff',
gridColor: '#c9c9c9',
nodeStroke: '#000000',
selectionColor: '#ff6600',
},
{
id: 'mono',
label: 'Monochrome',
relations: {
water: '#334155',
heat: '#334155',
gas: '#334155',
sewage: '#334155',
power: '#334155',
signal: '#334155',
},
canvasBackground: '#fbfbfb',
gridColor: '#e2e2e2',
nodeStroke: '#111111',
selectionColor: '#2563eb',
},
];
export function getColorScheme(id: string): ColorScheme {
return COLOR_SCHEMES.find((s) => s.id === id) ?? COLOR_SCHEMES[0];
}
export function relationColor(schemeId: string, relation: PortRelation): string {
return getColorScheme(schemeId).relations[relation];
}

77
src/editor/edgeStyles.ts Normal file
View File

@ -0,0 +1,77 @@
/**
* Edge (link) visual style computation: dash patterns and SVG path markers
* (arrow / circle / diamond / bar) for head and tail, derived from edge
* presentation properties and the relation color of the active scheme.
*/
import type { PropertyValue } from '../model/properties';
export type MarkerKind = 'none' | 'arrow' | 'circle' | 'diamond' | 'bar';
export type LineStyleKind = 'solid' | 'dashed' | 'dotted' | 'dashDot';
export function dashArray(style: LineStyleKind, width: number): string | null {
switch (style) {
case 'solid':
return null;
case 'dashed':
return `${width * 4},${width * 2.5}`;
case 'dotted':
return `${width},${width * 2}`;
case 'dashDot':
return `${width * 4},${width * 2},${width},${width * 2}`;
}
}
/**
* JointJS marker definition (attrs of line/sourceMarker or targetMarker).
* Markers are auto-rotated by JointJS; coordinates assume the line comes
* from the left and the endpoint is at (0, 0).
*/
export function markerDef(kind: MarkerKind, color: string): Record<string, unknown> | null {
switch (kind) {
case 'none':
return { d: 'M 0 0', fill: 'none', stroke: 'none' };
case 'arrow':
return { type: 'path', d: 'M 12 -6 L 0 0 L 12 6 Z', fill: color, stroke: color };
case 'circle':
return { type: 'circle', r: 5, cx: 5, fill: '#fff', stroke: color, 'stroke-width': 2 };
case 'diamond':
return { type: 'path', d: 'M 0 0 L 8 -6 L 16 0 L 8 6 Z', fill: '#fff', stroke: color, 'stroke-width': 2 };
case 'bar':
return { type: 'path', d: 'M 0 -7 L 0 7', fill: 'none', stroke: color, 'stroke-width': 3 };
default:
return null;
}
}
export interface EdgeStyleProps {
lineStyle: LineStyleKind;
lineWidth: number;
lineColor: string;
sourceMarker: MarkerKind;
targetMarker: MarkerKind;
}
export function readEdgeStyle(props: Record<string, PropertyValue>): EdgeStyleProps {
return {
lineStyle: (props.lineStyle as LineStyleKind) ?? 'solid',
lineWidth: typeof props.lineWidth === 'number' ? props.lineWidth : 2,
lineColor: typeof props.lineColor === 'string' ? props.lineColor : '',
sourceMarker: (props.sourceMarker as MarkerKind) ?? 'none',
targetMarker: (props.targetMarker as MarkerKind) ?? 'arrow',
};
}
/** Build JointJS `attrs/line` object for a link from its style + relation color. */
export function buildLineAttrs(style: EdgeStyleProps, relationColor: string): Record<string, unknown> {
const color = style.lineColor || relationColor;
const attrs: Record<string, unknown> = {
stroke: color,
strokeWidth: style.lineWidth,
sourceMarker: markerDef(style.sourceMarker, color),
targetMarker: markerDef(style.targetMarker, color),
};
const dash = dashArray(style.lineStyle, style.lineWidth);
attrs.strokeDasharray = dash ?? 'none';
return attrs;
}

869
src/editor/paper.ts Normal file
View File

@ -0,0 +1,869 @@
/**
* EditorController owns the JointJS graph + paper and implements all
* canvas interactions: selection, port-validated linking, grid routing with
* optional jumpover arcs, pan/zoom, rubber-band, clipboard, undo/redo,
* simulation animation and (de)serialization.
*/
import { dia, linkTools, highlighters, V } from '@joint/core';
import {
cellNamespace,
createNode,
applyNodePresentation,
portSpec,
PipelineLink,
} from './shapes';
import { getColorScheme } from './colorSchemes';
import { buildLineAttrs, readEdgeStyle } from './edgeStyles';
import type { PropertyValue } from '../model/properties';
import { checkConnection } from '../model/validation';
import type { PortRelation } from '../model/validation';
import { getNodeType } from '../model/nodeTypes';
import { PipelineGraph, defaultEdgeProps, genId } from '../model/graph';
import { History } from '../state/history';
import { solveFlow } from '../sim/flow';
import type { FlowResult } from '../sim/flow';
export interface EditorConfig {
gridSize: number;
showGrid: boolean;
snapToGrid: boolean;
router: 'manhattan' | 'orthogonal' | 'rightAngle' | 'normal';
/** Draw an arc where lines intersect (JointJS `jumpover` connector). */
arcOnIntersections: boolean;
colorScheme: string;
animateFlow: boolean;
}
export const DEFAULT_CONFIG: EditorConfig = {
gridSize: 20,
showGrid: true,
snapToGrid: true,
router: 'manhattan',
arcOnIntersections: true,
colorScheme: 'classic',
animateFlow: true,
};
export interface EditorEvents {
onSelectionChange(cellIds: string[]): void;
onGraphChange(): void;
onHistoryChange(canUndo: boolean, canRedo: boolean): void;
onSimulationChange(running: boolean, result: FlowResult | null): void;
}
interface ClipboardEntry {
cells: unknown[];
}
const FLOW_EPS = 1e-6;
export class EditorController {
readonly graph: dia.Graph;
readonly paper: dia.Paper;
config: EditorConfig;
private events: EditorEvents;
private selection = new Set<string>();
private history: History<string>;
private silent = 0;
private historyTimer: ReturnType<typeof setTimeout> | null = null;
private clipboard: ClipboardEntry | null = null;
private container: HTMLElement;
private simRunning = false;
private simResult: FlowResult | null = null;
private animFrame: number | null = null;
private animOffsets = new Map<string, number>();
private lastTick = 0;
private groupDragOrigins: Map<string, { x: number; y: number }> | null = null;
private groupDragId: string | null = null;
private groupDragStart: { x: number; y: number } | null = null;
constructor(container: HTMLElement, config: EditorConfig, events: EditorEvents) {
this.container = container;
this.config = { ...config };
this.events = events;
const scheme = getColorScheme(config.colorScheme);
this.graph = new dia.Graph({}, { cellNamespace });
this.paper = new dia.Paper({
el: container,
model: this.graph,
width: '100%',
height: '100%',
gridSize: config.snapToGrid ? config.gridSize : 1,
drawGrid: config.showGrid ? { name: 'mesh', args: { color: scheme.gridColor } } : false,
background: { color: scheme.canvasBackground },
cellViewNamespace: cellNamespace,
linkPinning: false,
snapLinks: { radius: 40 },
markAvailable: true,
clickThreshold: 5,
moveThreshold: 3,
defaultRouter: this.routerDef(),
defaultConnector: this.connectorDef(),
defaultLink: () => this.makeLink(),
validateMagnet: (_view, magnet) => magnet.getAttribute('magnet') === 'true',
validateConnection: (svS, magnetS, svT, magnetT) => this.validateConnection(svS, magnetS, svT, magnetT),
highlighting: {
magnetAvailability: { name: 'addClass', options: { className: 'pf-magnet-available' } },
elementAvailability: { name: 'addClass', options: { className: 'pf-element-available' } },
},
});
this.history = new History<string>(this.snapshot());
this.bindPaperEvents();
this.bindGraphEvents();
}
// -------------------------------------------------------------- routing --
private routerDef(): dia.Paper.Options['defaultRouter'] {
const step = this.config.gridSize;
switch (this.config.router) {
case 'manhattan':
return { name: 'manhattan', args: { step, padding: step } };
case 'orthogonal':
return { name: 'orthogonal', args: { padding: step } };
case 'rightAngle':
return { name: 'rightAngle', args: { margin: step } };
case 'normal':
return { name: 'normal' };
}
}
private connectorDef(): dia.Paper.Options['defaultConnector'] {
return this.config.arcOnIntersections
? { name: 'jumpover', args: { size: 8, jump: 'arc', radius: 4 } }
: { name: 'rounded', args: { radius: 4 } };
}
private makeLink(): dia.Link {
const link = new PipelineLink({
id: genId('edge'),
props: defaultEdgeProps(),
});
link.router(this.routerDef() as never);
link.connector(this.connectorDef() as never);
return link;
}
private validateConnection(
sourceView: dia.CellView,
sourceMagnet: SVGElement | null,
targetView: dia.CellView,
targetMagnet: SVGElement | null,
): boolean {
if (!sourceMagnet || !targetMagnet) return false;
const sourcePortId = sourceMagnet.getAttribute('port');
const targetPortId = targetMagnet.getAttribute('port');
if (!sourcePortId || !targetPortId) return false;
const source = portSpec(sourceView.model, sourcePortId);
const target = portSpec(targetView.model, targetPortId);
if (!source || !target) return false;
if (sourceView === targetView && sourcePortId === targetPortId) return false;
return checkConnection(source, target).ok;
}
// --------------------------------------------------------------- events --
private bindPaperEvents(): void {
const paper = this.paper;
paper.on('element:pointerclick', (view, evt) => {
this.select(String(view.model.id), evt.shiftKey);
});
paper.on('link:pointerclick', (view, evt) => {
this.select(String(view.model.id), evt.shiftKey);
});
paper.on('blank:pointerclick', () => this.clearSelection());
// Group dragging: move the rest of the selection along with the dragged cell.
paper.on('element:pointerdown', (view) => {
const id = String(view.model.id);
if (this.selection.has(id) && this.selection.size > 1) {
this.groupDragOrigins = new Map();
this.groupDragId = id;
this.groupDragStart = (view.model as dia.Element).position();
for (const selId of this.selection) {
const cell = this.graph.getCell(selId);
if (cell && cell.isElement() && selId !== id) {
this.groupDragOrigins.set(selId, (cell as dia.Element).position());
}
}
}
});
paper.on('element:pointermove', (view) => {
if (!this.groupDragOrigins || !this.groupDragStart) return;
if (this.groupDragId !== String(view.model.id)) return;
const pos = (view.model as dia.Element).position();
const dx = pos.x - this.groupDragStart.x;
const dy = pos.y - this.groupDragStart.y;
for (const [selId, selOrigin] of this.groupDragOrigins) {
const cell = this.graph.getCell(selId);
if (cell && cell.isElement()) {
(cell as dia.Element).position(selOrigin.x + dx, selOrigin.y + dy);
}
}
});
paper.on('element:pointerup link:pointerup', () => {
this.groupDragOrigins = null;
this.groupDragId = null;
this.groupDragStart = null;
});
// Sync dragged position back into the x/y presentation props.
paper.on('element:pointerup', (view) => {
const el = view.model as dia.Element;
const props = { ...(el.get('props') ?? {}) } as Record<string, PropertyValue>;
const pos = el.position();
if (props.x !== pos.x || props.y !== pos.y) {
props.x = pos.x;
props.y = pos.y;
el.set('props', props);
}
});
// Panning and rubber-band selection on blank space.
paper.on('blank:pointerdown', (evt) => {
if (evt.shiftKey) {
this.startRubberBand(evt as unknown as MouseEvent);
} else {
this.startPan(evt as unknown as MouseEvent);
}
});
// Style newly connected links according to relation + edge props.
paper.on('link:connect', (linkView) => {
const link = linkView.model as dia.Link;
this.applyLinkStyle(link);
this.events.onGraphChange();
});
// Zoom at pointer with the mouse wheel.
this.container.addEventListener('wheel', this.onWheel, { passive: false });
}
private onWheel = (evt: WheelEvent): void => {
evt.preventDefault();
const factor = evt.deltaY < 0 ? 1.1 : 1 / 1.1;
const local = this.paper.clientToLocalPoint(evt.clientX, evt.clientY);
this.zoomAt(local, factor);
};
private startPan(evt: MouseEvent): void {
const start = { x: evt.clientX, y: evt.clientY };
const origin = this.paper.translate();
const onMove = (e: MouseEvent) => {
this.paper.translate(origin.tx + (e.clientX - start.x), origin.ty + (e.clientY - start.y));
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}
private startRubberBand(evt: MouseEvent): void {
const containerRect = this.container.getBoundingClientRect();
const band = document.createElement('div');
band.className = 'pf-rubber-band';
const start = { x: evt.clientX - containerRect.left, y: evt.clientY - containerRect.top };
Object.assign(band.style, { left: `${start.x}px`, top: `${start.y}px`, width: '0px', height: '0px' });
this.container.appendChild(band);
const client0 = { x: evt.clientX, y: evt.clientY };
let client1 = client0;
const onMove = (e: MouseEvent) => {
client1 = { x: e.clientX, y: e.clientY };
const x = Math.min(client0.x, client1.x) - containerRect.left;
const y = Math.min(client0.y, client1.y) - containerRect.top;
Object.assign(band.style, {
left: `${x}px`,
top: `${y}px`,
width: `${Math.abs(client1.x - client0.x)}px`,
height: `${Math.abs(client1.y - client0.y)}px`,
});
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
band.remove();
const p0 = this.paper.clientToLocalPoint(client0.x, client0.y);
const p1 = this.paper.clientToLocalPoint(client1.x, client1.y);
const rect = {
x: Math.min(p0.x, p1.x),
y: Math.min(p0.y, p1.y),
width: Math.abs(p1.x - p0.x),
height: Math.abs(p1.y - p0.y),
};
const models = this.graph.findModelsInArea(rect);
this.setSelection(models.map((m) => String(m.id)));
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}
private bindGraphEvents(): void {
this.graph.on('add remove change', () => {
if (this.silent > 0) return;
this.scheduleHistoryPush();
});
this.graph.on('remove', (cell: dia.Cell) => {
if (this.selection.delete(String(cell.id))) {
this.emitSelection();
}
});
}
private scheduleHistoryPush(): void {
if (this.historyTimer) clearTimeout(this.historyTimer);
this.historyTimer = setTimeout(() => {
this.historyTimer = null;
const snap = this.snapshot();
if (snap !== this.history.peek()) {
this.history.push(snap);
this.events.onHistoryChange(this.history.canUndo, this.history.canRedo);
}
this.events.onGraphChange();
}, 250);
}
private snapshot(): string {
return JSON.stringify(this.graph.toJSON());
}
private applySnapshot(snap: string): void {
this.runSilent(() => {
this.stopSimulation();
this.graph.fromJSON(JSON.parse(snap));
this.restyleAll();
});
this.setSelection([]);
this.events.onGraphChange();
}
private runSilent(fn: () => void): void {
this.silent += 1;
try {
fn();
} finally {
this.silent -= 1;
}
}
// ------------------------------------------------------------ selection --
getSelection(): string[] {
return [...this.selection];
}
select(cellId: string, additive = false): void {
if (additive) {
if (this.selection.has(cellId)) this.selection.delete(cellId);
else this.selection.add(cellId);
} else {
this.selection.clear();
this.selection.add(cellId);
}
this.emitSelection();
}
setSelection(cellIds: string[]): void {
this.selection = new Set(cellIds);
this.emitSelection();
}
clearSelection(): void {
if (this.selection.size === 0) return;
this.selection.clear();
this.emitSelection();
}
selectAll(): void {
this.setSelection(this.graph.getCells().map((c) => String(c.id)));
}
private emitSelection(): void {
this.updateSelectionHighlight();
this.events.onSelectionChange([...this.selection]);
}
private updateSelectionHighlight(): void {
const scheme = getColorScheme(this.config.colorScheme);
for (const cell of this.graph.getCells()) {
const view = this.paper.findViewByModel(cell);
if (!view) continue;
const selected = this.selection.has(String(cell.id));
highlighters.mask.remove(view, 'pf-selection');
view.el.classList.toggle('pf-focused', selected);
if (selected) {
highlighters.mask.add(view, cell.isLink() ? { selector: 'line' } : { selector: 'root' }, 'pf-selection', {
padding: 4,
attrs: { stroke: scheme.selectionColor, 'stroke-width': 2, 'stroke-linejoin': 'round' },
});
}
if (cell.isLink()) {
const linkView = view as dia.LinkView;
if (selected) {
linkView.addTools(
new dia.ToolsView({
tools: [new linkTools.Vertices({ snapRadius: this.config.gridSize })],
}),
);
} else {
linkView.removeTools();
}
}
}
}
// -------------------------------------------------------------- editing --
addNode(typeId: string, x: number, y: number): dia.Element {
const grid = this.config.snapToGrid ? this.config.gridSize : 1;
const snappedX = Math.round(x / grid) * grid;
const snappedY = Math.round(y / grid) * grid;
const el = createNode(typeId, snappedX, snappedY, this.config.colorScheme);
this.graph.addCell(el);
this.select(String(el.id));
return el;
}
clientToLocal(clientX: number, clientY: number): { x: number; y: number } {
return this.paper.clientToLocalPoint(clientX, clientY);
}
deleteSelection(): void {
const cells = [...this.selection]
.map((id) => this.graph.getCell(id))
.filter((c): c is dia.Cell => !!c);
if (cells.length === 0) return;
this.graph.removeCells(cells);
this.clearSelection();
}
nudgeSelection(dx: number, dy: number): void {
for (const id of this.selection) {
const cell = this.graph.getCell(id);
if (cell && cell.isElement()) {
(cell as dia.Element).translate(dx, dy);
}
}
}
copySelection(): void {
const ids = this.selection;
const elements = [...ids]
.map((id) => this.graph.getCell(id))
.filter((c): c is dia.Element => !!c && c.isElement());
const elementIds = new Set(elements.map((e) => String(e.id)));
const links = this.graph.getLinks().filter((l) => {
const s = l.source().id;
const t = l.target().id;
return !!s && !!t && elementIds.has(String(s)) && elementIds.has(String(t));
});
if (elements.length === 0) return;
this.clipboard = { cells: [...elements, ...links].map((c) => c.toJSON()) };
}
paste(offset = 20): void {
if (!this.clipboard) return;
const idMap = new Map<string, string>();
const clones: dia.Cell[] = [];
for (const json of this.clipboard.cells as Record<string, unknown>[]) {
const oldId = String(json.id);
idMap.set(oldId, genId(String(json.type).includes('Link') ? 'edge' : 'node'));
}
for (const json of this.clipboard.cells as Record<string, any>[]) {
const copy = JSON.parse(JSON.stringify(json));
copy.id = idMap.get(String(json.id));
if (copy.position) {
copy.position.x += offset;
copy.position.y += offset;
if (copy.props) {
copy.props.x = copy.position.x;
copy.props.y = copy.position.y;
}
}
if (copy.source?.id) copy.source.id = idMap.get(String(copy.source.id)) ?? copy.source.id;
if (copy.target?.id) copy.target.id = idMap.get(String(copy.target.id)) ?? copy.target.id;
if (copy.vertices) {
copy.vertices = copy.vertices.map((v: { x: number; y: number }) => ({ x: v.x + offset, y: v.y + offset }));
}
clones.push(this.cellFromJSON(copy));
}
this.graph.addCells(clones);
this.restyleAll();
this.setSelection(clones.map((c) => String(c.id)));
}
private cellFromJSON(json: Record<string, any>): dia.Cell {
const type = String(json.type);
if (type === 'pipeline.Node') return new (cellNamespace.pipeline.Node as typeof dia.Element)(json);
return new (cellNamespace.pipeline.Link as typeof dia.Link)(json);
}
duplicateSelection(): void {
this.copySelection();
this.paste();
}
undo(): void {
this.flushHistory();
const snap = this.history.undo();
if (snap !== undefined) this.applySnapshot(snap);
this.events.onHistoryChange(this.history.canUndo, this.history.canRedo);
}
redo(): void {
this.flushHistory();
const snap = this.history.redo();
if (snap !== undefined) this.applySnapshot(snap);
this.events.onHistoryChange(this.history.canUndo, this.history.canRedo);
}
private flushHistory(): void {
if (this.historyTimer) {
clearTimeout(this.historyTimer);
this.historyTimer = null;
const snap = this.snapshot();
if (snap !== this.history.peek()) this.history.push(snap);
}
}
// ----------------------------------------------------------------- zoom --
zoomAt(localPoint: { x: number; y: number }, factor: number): void {
const scale = this.paper.scale().sx;
const next = Math.min(4, Math.max(0.2, scale * factor));
if (next === scale) return;
const translate = this.paper.translate();
// Keep localPoint stationary on screen while scaling.
const tx = translate.tx - localPoint.x * (next - scale);
const ty = translate.ty - localPoint.y * (next - scale);
this.paper.scale(next, next);
this.paper.translate(tx, ty);
}
zoom(factor: number): void {
const rect = this.container.getBoundingClientRect();
const center = this.paper.clientToLocalPoint(rect.left + rect.width / 2, rect.top + rect.height / 2);
this.zoomAt(center, factor);
}
zoomToFit(): void {
this.paper.transformToFitContent({
padding: 40,
minScale: 0.2,
maxScale: 1.5,
useModelGeometry: true,
});
}
resetZoom(): void {
this.paper.scale(1, 1);
this.paper.translate(0, 0);
}
// ------------------------------------------------------------ properties --
getCellProps(cellId: string): Record<string, PropertyValue> | null {
const cell = this.graph.getCell(cellId);
if (!cell) return null;
return { ...(cell.get('props') ?? {}) } as Record<string, PropertyValue>;
}
cellKind(cellId: string): 'node' | 'edge' | null {
const cell = this.graph.getCell(cellId);
if (!cell) return null;
return cell.isLink() ? 'edge' : 'node';
}
cellTypeId(cellId: string): string | null {
const cell = this.graph.getCell(cellId);
return cell ? ((cell.get('nodeTypeId') as string) ?? null) : null;
}
setCellProp(cellId: string, key: string, value: PropertyValue): void {
const cell = this.graph.getCell(cellId);
if (!cell) return;
const props = { ...(cell.get('props') ?? {}), [key]: value } as Record<string, PropertyValue>;
cell.set('props', props);
if (cell.isElement()) {
const el = cell as dia.Element;
if (key === 'x' || key === 'y') {
el.position(Number(props.x ?? 0), Number(props.y ?? 0));
}
applyNodePresentation(el, this.config.colorScheme);
} else {
this.applyLinkStyle(cell as dia.Link);
}
}
// ----------------------------------------------------------------- style --
linkRelation(link: dia.Link): PortRelation {
const sourceId = link.source().id;
const sourcePort = link.source().port;
if (sourceId && sourcePort) {
const sourceCell = this.graph.getCell(sourceId);
if (sourceCell) {
const spec = portSpec(sourceCell, String(sourcePort));
if (spec) return spec.relation;
}
}
return 'water';
}
applyLinkStyle(link: dia.Link): void {
const scheme = getColorScheme(this.config.colorScheme);
const props = (link.get('props') ?? {}) as Record<string, PropertyValue>;
const style = readEdgeStyle(props);
const relation = this.linkRelation(link);
link.attr('line', buildLineAttrs(style, scheme.relations[relation]) as never);
const title = typeof props.title === 'string' ? props.title : '';
const baseLabels = title
? [{ position: 0.35, attrs: { text: { text: title, fontSize: 11, fill: scheme.nodeStroke } } }]
: [];
// Preserve the simulation label (index ≥ 1) while editing the title.
const simLabel = (link.labels() ?? []).find((l) => (l as { simLabel?: boolean }).simLabel);
link.labels(simLabel ? [...baseLabels, simLabel] : baseLabels);
}
private restyleAll(): void {
for (const el of this.graph.getElements()) {
applyNodePresentation(el, this.config.colorScheme);
}
for (const link of this.graph.getLinks()) {
link.router(this.routerDef() as never);
link.connector(this.connectorDef() as never);
this.applyLinkStyle(link);
}
}
// ---------------------------------------------------------------- config --
applyConfig(config: EditorConfig): void {
const wasAnimating = this.simRunning;
this.config = { ...config };
const scheme = getColorScheme(config.colorScheme);
this.paper.setGridSize(config.snapToGrid ? config.gridSize : 1);
if (config.showGrid) {
this.paper.setGrid({ name: 'mesh', args: { color: scheme.gridColor } });
} else {
this.paper.setGrid(false as never);
}
this.paper.drawBackground({ color: scheme.canvasBackground });
this.runSilent(() => this.restyleAll());
this.updateSelectionHighlight();
if (wasAnimating) {
// Re-derive animation styling for the new scheme/config.
this.stopSimulation();
this.runSimulation();
}
}
// ------------------------------------------------------------- documents --
toPipelineGraph(): PipelineGraph {
const pg = new PipelineGraph();
for (const el of this.graph.getElements()) {
const typeId = el.get('nodeTypeId') as string;
if (!getNodeType(typeId)) continue;
const pos = el.position();
pg.addNode({
id: String(el.id),
typeId,
x: pos.x,
y: pos.y,
angle: el.angle(),
props: { ...(el.get('props') ?? {}) },
});
}
for (const link of this.graph.getLinks()) {
const s = link.source();
const t = link.target();
if (!s.id || !t.id || !s.port || !t.port) continue;
try {
pg.addEdge({
id: String(link.id),
sourceNodeId: String(s.id),
sourcePortId: String(s.port),
targetNodeId: String(t.id),
targetPortId: String(t.port),
props: { ...(link.get('props') ?? {}) },
vertices: (link.vertices() ?? []).map((v) => ({ x: v.x, y: v.y })),
});
} catch {
// Skip edges that no longer validate (e.g. after type edits).
}
}
return pg;
}
serialize(): string {
const doc = this.toPipelineGraph().toDocument();
return JSON.stringify(doc, null, 2);
}
loadDocumentJSON(json: string): void {
const pg = PipelineGraph.fromDocument(JSON.parse(json));
this.stopSimulation();
this.runSilent(() => {
this.graph.clear();
for (const node of pg.nodes.values()) {
const el = createNode(node.typeId, node.x, node.y, this.config.colorScheme, node.props, node.id);
this.graph.addCell(el);
}
for (const edge of pg.edges.values()) {
const link = this.makeLink();
link.set('id', edge.id);
link.set('props', edge.props);
link.source({ id: edge.sourceNodeId, port: edge.sourcePortId });
link.target({ id: edge.targetNodeId, port: edge.targetPortId });
if (edge.vertices.length > 0) link.vertices(edge.vertices);
this.graph.addCell(link);
this.applyLinkStyle(link);
}
});
this.history.reset(this.snapshot());
this.setSelection([]);
this.events.onHistoryChange(false, false);
this.events.onGraphChange();
}
clearDocument(): void {
this.stopSimulation();
this.graph.clear();
this.clearSelection();
}
exportSVG(): string {
const svg = this.paper.svg.cloneNode(true) as SVGSVGElement;
const bbox = this.paper.getContentBBox();
const local = this.paper.paperToLocalRect(bbox);
svg.setAttribute('viewBox', `${local.x - 20} ${local.y - 20} ${local.width + 40} ${local.height + 40}`);
svg.setAttribute('width', String(local.width + 40));
svg.setAttribute('height', String(local.height + 40));
// Undo the interactive pan/zoom transform on the exported copy.
const viewport = svg.querySelector('.joint-cells-layer, [class*="cells"]');
if (viewport) viewport.removeAttribute('transform');
return new XMLSerializer().serializeToString(svg);
}
// ------------------------------------------------------------ simulation --
get simulationRunning(): boolean {
return this.simRunning;
}
runSimulation(): FlowResult {
this.stopSimulation();
const pg = this.toPipelineGraph();
const result = solveFlow(pg);
this.simResult = result;
this.simRunning = true;
this.runSilent(() => {
for (const link of this.graph.getLinks()) {
const flow = result.flows.get(String(link.id)) ?? 0;
const props = { ...(link.get('props') ?? {}) } as Record<string, PropertyValue>;
props.simFlow = Math.round(Math.abs(flow) * 100) / 100;
props.simDirection =
Math.abs(flow) < FLOW_EPS ? 'no flow' : flow > 0 ? 'along drawing' : 'reversed';
link.set('props', props);
this.applySimLabel(link, flow);
}
});
if (this.config.animateFlow) this.startAnimation();
this.events.onSimulationChange(true, result);
return result;
}
stopSimulation(): void {
if (this.animFrame !== null) {
cancelAnimationFrame(this.animFrame);
this.animFrame = null;
}
if (!this.simRunning) return;
this.simRunning = false;
this.simResult = null;
this.runSilent(() => {
for (const link of this.graph.getLinks()) {
const labels = (link.labels() ?? []).filter((l) => !(l as { simLabel?: boolean }).simLabel);
link.labels(labels);
this.applyLinkStyle(link);
}
});
this.events.onSimulationChange(false, null);
}
private applySimLabel(link: dia.Link, flow: number): void {
const scheme = getColorScheme(this.config.colorScheme);
const labels = (link.labels() ?? []).filter((l) => !(l as { simLabel?: boolean }).simLabel);
if (Math.abs(flow) >= FLOW_EPS) {
labels.push({
simLabel: true,
position: 0.5,
attrs: {
text: {
text: `${Math.abs(flow).toFixed(1)} m³/h`,
fontSize: 10,
fill: scheme.selectionColor,
fontFamily: 'monospace',
},
rect: { fill: scheme.canvasBackground, opacity: 0.85 },
},
} as never);
}
link.labels(labels);
}
private startAnimation(): void {
if (!this.simResult) return;
const result = this.simResult;
const maxFlow = result.maxAbsFlow || 1;
this.lastTick = performance.now();
const tick = (now: number) => {
const dt = (now - this.lastTick) / 1000;
this.lastTick = now;
for (const link of this.graph.getLinks()) {
const flow = result.flows.get(String(link.id)) ?? 0;
if (Math.abs(flow) < FLOW_EPS) continue;
const view = this.paper.findViewByModel(link) as dia.LinkView | null;
if (!view) continue;
const node = view.findNode ? (view.findNode('line') as SVGPathElement | null) : null;
const path = node ?? (view.el.querySelector('path[joint-selector="line"]') as SVGPathElement | null);
if (!path) continue;
const norm = Math.abs(flow) / maxFlow;
const width = 2 + norm * 6;
const speed = 20 + norm * 80; // px/s
const offset = (this.animOffsets.get(String(link.id)) ?? 0) - Math.sign(flow) * speed * dt;
this.animOffsets.set(String(link.id), offset);
path.setAttribute('stroke-dasharray', `${width * 3} ${width * 2}`);
path.setAttribute('stroke-dashoffset', String(offset));
path.setAttribute('stroke-width', String(width));
}
this.animFrame = requestAnimationFrame(tick);
};
this.animFrame = requestAnimationFrame(tick);
}
// ---------------------------------------------------------------- misc --
dispose(): void {
this.stopSimulation();
this.container.removeEventListener('wheel', this.onWheel);
this.paper.remove();
}
}
// V import is used to guarantee the Vectorizer is bundled for SVG export helpers.
void V;

161
src/editor/shapes.ts Normal file
View File

@ -0,0 +1,161 @@
/**
* JointJS cell factories: pipeline nodes (SVG-symbol elements with ports)
* and pipeline links. Shapes are registered under the `pipeline` namespace
* so graph.fromJSON can rebuild them.
*/
import { dia, shapes } from '@joint/core';
import type { NodeTypeDef } from '../model/nodeTypes';
import { getNodeType, defaultNodeProps } from '../model/nodeTypes';
import type { PropertyValue } from '../model/properties';
import type { PortSpec } from '../model/validation';
import { getColorScheme } from './colorSchemes';
import { genId } from '../model/graph';
export const PipelineNode = dia.Element.define(
'pipeline.Node',
{
attrs: {
root: { magnet: false },
image: { x: 0, y: 0 },
label: {
textAnchor: 'middle',
fontSize: 12,
fontFamily: 'sans-serif',
fill: '#334155',
},
},
},
{
markup: [
{ tagName: 'image', selector: 'image' },
{ tagName: 'text', selector: 'label' },
],
},
);
export const PipelineLink = shapes.standard.Link.define('pipeline.Link', {
attrs: {
line: { stroke: '#2563eb', strokeWidth: 2 },
wrapper: { strokeWidth: 12 },
},
});
/** Namespace for dia.Graph cell rebuilding. */
export const cellNamespace = {
...shapes,
pipeline: { Node: PipelineNode, Link: PipelineLink },
};
/** Render a node type's SVG symbol to a data URI with a concrete color. */
export function svgDataUri(type: NodeTypeDef, color: string): string {
const svg =
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${type.width} ${type.height}" ` +
`width="${type.width}" height="${type.height}" color="${color}">${type.svg}</svg>`;
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
}
export function portAbsolutePosition(type: NodeTypeDef, port: PortSpec): { x: number; y: number } {
return { x: port.x * type.width, y: port.y * type.height };
}
/** Create a JointJS element for a node type at the given position. */
export function createNode(
typeId: string,
x: number,
y: number,
schemeId: string,
props?: Record<string, PropertyValue>,
id?: string,
): dia.Element {
const type = getNodeType(typeId);
if (!type) throw new Error(`Unknown node type: ${typeId}`);
const scheme = getColorScheme(schemeId);
const bag = { ...defaultNodeProps(typeId), ...props, x, y };
const element = new PipelineNode({
id: id ?? genId('node'),
position: { x, y },
size: { width: type.width, height: type.height },
nodeTypeId: typeId,
props: bag,
ports: {
groups: {
default: {
position: 'absolute',
attrs: {
portBody: {
r: 7,
magnet: true,
stroke: '#ffffff',
strokeWidth: 1.5,
cursor: 'crosshair',
class: 'pf-port-body',
},
},
markup: [{ tagName: 'circle', selector: 'portBody' }],
label: {
position: { name: 'radial', args: { offset: 12 } },
markup: [{ tagName: 'text', selector: 'text', className: 'pf-port-label' }],
},
},
},
items: type.ports.map((port) => ({
id: port.id,
group: 'default',
args: portAbsolutePosition(type, port),
attrs: {
portBody: { fill: scheme.relations[port.relation] },
text: { text: port.label ?? port.id, fontSize: 10, fill: scheme.nodeStroke },
},
})),
},
});
applyNodePresentation(element, schemeId);
return element;
}
/** Sync presentation-group props (position, angle, color, label) onto the cell. */
export function applyNodePresentation(element: dia.Element, schemeId: string): void {
const typeId = element.get('nodeTypeId') as string;
const type = getNodeType(typeId);
if (!type) return;
const scheme = getColorScheme(schemeId);
const props = (element.get('props') ?? {}) as Record<string, PropertyValue>;
const color = typeof props.symbolColor === 'string' && props.symbolColor ? props.symbolColor : scheme.nodeStroke;
const title = typeof props.title === 'string' ? props.title : type.label;
const labelVisible = props.labelVisible !== 'no';
element.attr({
image: {
href: svgDataUri(type, color),
width: type.width,
height: type.height,
},
label: {
text: labelVisible ? title : '',
x: type.width / 2,
y: type.height + 14,
fill: scheme.nodeStroke,
},
});
// Refresh port colors for the active scheme.
for (const port of type.ports) {
element.portProp(port.id, 'attrs/portBody/fill', scheme.relations[port.relation]);
element.portProp(port.id, 'attrs/text/fill', scheme.nodeStroke);
}
const angle = Number(props.angle ?? 0);
if (element.angle() !== angle) {
element.rotate(angle - element.angle());
}
}
/** Look up the model PortSpec behind a JointJS element port. */
export function portSpec(element: dia.Cell, portId: string): PortSpec | undefined {
const typeId = element.get('nodeTypeId') as string | undefined;
if (!typeId) return undefined;
return getNodeType(typeId)?.ports.find((p) => p.id === portId);
}