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:
parent
3040dc9d20
commit
7aa1f9517b
91
src/editor/colorSchemes.ts
Normal file
91
src/editor/colorSchemes.ts
Normal 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
77
src/editor/edgeStyles.ts
Normal 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
869
src/editor/paper.ts
Normal 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
161
src/editor/shapes.ts
Normal 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);
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user