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