162 lines
5.0 KiB
TypeScript
162 lines
5.0 KiB
TypeScript
/**
|
|
* 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);
|
|
}
|