/** * 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 = `${type.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, 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; 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); }