@graph-render/core 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.eslintrc.json +6 -0
- package/CHANGELOG.md +45 -0
- package/dist/edges/collision.d.ts +8 -0
- package/dist/edges/collision.d.ts.map +1 -0
- package/dist/edges/collision.js +26 -0
- package/dist/edges/collision.js.map +1 -0
- package/dist/edges/geometry.d.ts +22 -0
- package/dist/edges/geometry.d.ts.map +1 -0
- package/dist/edges/geometry.js +75 -0
- package/dist/edges/geometry.js.map +1 -0
- package/dist/edges/index.d.ts +4 -0
- package/dist/edges/index.d.ts.map +1 -0
- package/dist/edges/index.js +4 -0
- package/dist/edges/index.js.map +1 -0
- package/dist/edges/pathBuilder.d.ts +11 -0
- package/dist/edges/pathBuilder.d.ts.map +1 -0
- package/dist/edges/pathBuilder.js +114 -0
- package/dist/edges/pathBuilder.js.map +1 -0
- package/dist/edges/pathCalculation.d.ts +14 -0
- package/dist/edges/pathCalculation.d.ts.map +1 -0
- package/dist/edges/pathCalculation.js +47 -0
- package/dist/edges/pathCalculation.js.map +1 -0
- package/dist/edges/routing.d.ts +6 -0
- package/dist/edges/routing.d.ts.map +1 -0
- package/dist/edges/routing.js +243 -0
- package/dist/edges/routing.js.map +1 -0
- package/dist/edges/sideSelection.d.ts +17 -0
- package/dist/edges/sideSelection.d.ts.map +1 -0
- package/dist/edges/sideSelection.js +45 -0
- package/dist/edges/sideSelection.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/layouts/centered.d.ts +6 -0
- package/dist/layouts/centered.d.ts.map +1 -0
- package/dist/layouts/centered.js +69 -0
- package/dist/layouts/centered.js.map +1 -0
- package/dist/layouts/compactBracket.d.ts +3 -0
- package/dist/layouts/compactBracket.d.ts.map +1 -0
- package/dist/layouts/compactBracket.js +7 -0
- package/dist/layouts/compactBracket.js.map +1 -0
- package/dist/layouts/dag.d.ts +3 -0
- package/dist/layouts/dag.d.ts.map +1 -0
- package/dist/layouts/dag.js +52 -0
- package/dist/layouts/dag.js.map +1 -0
- package/dist/layouts/forceDirected.d.ts +3 -0
- package/dist/layouts/forceDirected.d.ts.map +1 -0
- package/dist/layouts/forceDirected.js +176 -0
- package/dist/layouts/forceDirected.js.map +1 -0
- package/dist/layouts/grid.d.ts +6 -0
- package/dist/layouts/grid.d.ts.map +1 -0
- package/dist/layouts/grid.js +34 -0
- package/dist/layouts/grid.js.map +1 -0
- package/dist/layouts/index.d.ts +12 -0
- package/dist/layouts/index.d.ts.map +1 -0
- package/dist/layouts/index.js +88 -0
- package/dist/layouts/index.js.map +1 -0
- package/dist/layouts/orthogonalFlow.d.ts +3 -0
- package/dist/layouts/orthogonalFlow.d.ts.map +1 -0
- package/dist/layouts/orthogonalFlow.js +81 -0
- package/dist/layouts/orthogonalFlow.js.map +1 -0
- package/dist/layouts/radialTree.d.ts +3 -0
- package/dist/layouts/radialTree.d.ts.map +1 -0
- package/dist/layouts/radialTree.js +45 -0
- package/dist/layouts/radialTree.js.map +1 -0
- package/dist/layouts/tree.d.ts +6 -0
- package/dist/layouts/tree.d.ts.map +1 -0
- package/dist/layouts/tree.js +19 -0
- package/dist/layouts/tree.js.map +1 -0
- package/dist/layouts/treeAlignment.d.ts +10 -0
- package/dist/layouts/treeAlignment.d.ts.map +1 -0
- package/dist/layouts/treeAlignment.js +69 -0
- package/dist/layouts/treeAlignment.js.map +1 -0
- package/dist/layouts/treePositioning.d.ts +14 -0
- package/dist/layouts/treePositioning.d.ts.map +1 -0
- package/dist/layouts/treePositioning.js +30 -0
- package/dist/layouts/treePositioning.js.map +1 -0
- package/dist/layouts/treeTopology.d.ts +29 -0
- package/dist/layouts/treeTopology.d.ts.map +1 -0
- package/dist/layouts/treeTopology.js +137 -0
- package/dist/layouts/treeTopology.js.map +1 -0
- package/dist/rendering/defaultRenderers.d.ts +10 -0
- package/dist/rendering/defaultRenderers.d.ts.map +1 -0
- package/dist/rendering/defaultRenderers.js +85 -0
- package/dist/rendering/defaultRenderers.js.map +1 -0
- package/dist/rendering/index.d.ts +4 -0
- package/dist/rendering/index.d.ts.map +1 -0
- package/dist/rendering/index.js +4 -0
- package/dist/rendering/index.js.map +1 -0
- package/dist/rendering/svg.d.ts +7 -0
- package/dist/rendering/svg.d.ts.map +1 -0
- package/dist/rendering/svg.js +256 -0
- package/dist/rendering/svg.js.map +1 -0
- package/dist/rendering/utils.d.ts +5 -0
- package/dist/rendering/utils.d.ts.map +1 -0
- package/dist/rendering/utils.js +33 -0
- package/dist/rendering/utils.js.map +1 -0
- package/dist/utils/config.d.ts +36 -0
- package/dist/utils/config.d.ts.map +1 -0
- package/dist/utils/config.js +115 -0
- package/dist/utils/config.js.map +1 -0
- package/dist/utils/constants.d.ts +15 -0
- package/dist/utils/constants.d.ts.map +1 -0
- package/dist/utils/constants.js +19 -0
- package/dist/utils/constants.js.map +1 -0
- package/dist/utils/graphParser.d.ts +16 -0
- package/dist/utils/graphParser.d.ts.map +1 -0
- package/dist/utils/graphParser.js +277 -0
- package/dist/utils/graphParser.js.map +1 -0
- package/dist/utils/graphTraversal.d.ts +13 -0
- package/dist/utils/graphTraversal.d.ts.map +1 -0
- package/dist/utils/graphTraversal.js +28 -0
- package/dist/utils/graphTraversal.js.map +1 -0
- package/dist/utils/index.d.ts +8 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/index.js +7 -0
- package/dist/utils/index.js.map +1 -0
- package/dist/utils/nodeMetrics.d.ts +8 -0
- package/dist/utils/nodeMetrics.d.ts.map +1 -0
- package/dist/utils/nodeMetrics.js +12 -0
- package/dist/utils/nodeMetrics.js.map +1 -0
- package/dist/utils/nodeSizing.d.ts +3 -0
- package/dist/utils/nodeSizing.d.ts.map +1 -0
- package/dist/utils/nodeSizing.js +77 -0
- package/dist/utils/nodeSizing.js.map +1 -0
- package/package.json +29 -0
- package/project.json +32 -0
- package/src/edges/collision.ts +31 -0
- package/src/edges/geometry.ts +85 -0
- package/src/edges/index.ts +3 -0
- package/src/edges/pathBuilder.ts +136 -0
- package/src/edges/pathCalculation.ts +69 -0
- package/src/edges/routing.ts +459 -0
- package/src/edges/sideSelection.ts +67 -0
- package/src/index.ts +50 -0
- package/src/layouts/centered.ts +114 -0
- package/src/layouts/compactBracket.ts +14 -0
- package/src/layouts/dag.ts +76 -0
- package/src/layouts/forceDirected.ts +224 -0
- package/src/layouts/grid.ts +50 -0
- package/src/layouts/index.ts +148 -0
- package/src/layouts/orthogonalFlow.ts +112 -0
- package/src/layouts/radialTree.ts +77 -0
- package/src/layouts/tree.ts +35 -0
- package/src/layouts/treeAlignment.ts +107 -0
- package/src/layouts/treePositioning.ts +55 -0
- package/src/layouts/treeTopology.ts +184 -0
- package/src/rendering/defaultRenderers.ts +110 -0
- package/src/rendering/index.ts +3 -0
- package/src/rendering/svg.ts +346 -0
- package/src/rendering/utils.ts +41 -0
- package/src/utils/config.ts +198 -0
- package/src/utils/constants.ts +24 -0
- package/src/utils/graphParser.ts +495 -0
- package/src/utils/graphTraversal.ts +32 -0
- package/src/utils/index.ts +19 -0
- package/src/utils/nodeMetrics.ts +23 -0
- package/src/utils/nodeSizing.ts +97 -0
- package/tsconfig.json +11 -0
- package/tsconfig.tsbuildinfo +1 -0
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
import {
|
|
2
|
+
NxGraphInput,
|
|
3
|
+
NodeData,
|
|
4
|
+
PositionedNode,
|
|
5
|
+
PositionedEdge,
|
|
6
|
+
EdgeData,
|
|
7
|
+
EdgeType,
|
|
8
|
+
LayoutType,
|
|
9
|
+
LayoutDirection,
|
|
10
|
+
Point,
|
|
11
|
+
} from '@graph-render/types';
|
|
12
|
+
import type { NodeRenderer, EdgeRenderer } from '@graph-render/types';
|
|
13
|
+
import type {
|
|
14
|
+
RenderGraphToSvgOptions,
|
|
15
|
+
RenderGraphToSvgResult,
|
|
16
|
+
RenderConfig,
|
|
17
|
+
RenderTheme,
|
|
18
|
+
} from '@graph-render/types';
|
|
19
|
+
import { DEFAULT_THEME, fromNxGraph, normalizeGraphConfig } from '../utils';
|
|
20
|
+
import { layoutNodes } from '../layouts';
|
|
21
|
+
import { routeEdges, buildEdgePath } from '../edges';
|
|
22
|
+
import { defaultNodeRenderer, defaultEdgeRenderer } from './defaultRenderers';
|
|
23
|
+
import { escapeXml, sanitizeCssColor, sanitizeFontFamily, sanitizeSvgId } from './utils';
|
|
24
|
+
|
|
25
|
+
const isFinitePoint = (point: Point | undefined): point is Point => {
|
|
26
|
+
return Boolean(point && Number.isFinite(point.x) && Number.isFinite(point.y));
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const buildFallbackPositionedEdges = (
|
|
30
|
+
positionedNodes: PositionedNode[],
|
|
31
|
+
edges: EdgeData[]
|
|
32
|
+
): PositionedEdge[] => {
|
|
33
|
+
const nodeMap = new Map(positionedNodes.map((node) => [node.id, node]));
|
|
34
|
+
|
|
35
|
+
return edges.flatMap((edge) => {
|
|
36
|
+
const source = nodeMap.get(edge.source);
|
|
37
|
+
const target = nodeMap.get(edge.target);
|
|
38
|
+
|
|
39
|
+
if (!source || !target) {
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const sourceWidth = source.size?.width ?? 0;
|
|
44
|
+
const sourceHeight = source.size?.height ?? 0;
|
|
45
|
+
const targetWidth = target.size?.width ?? 0;
|
|
46
|
+
const targetHeight = target.size?.height ?? 0;
|
|
47
|
+
|
|
48
|
+
if (source.id === target.id) {
|
|
49
|
+
const right = source.position.x + sourceWidth;
|
|
50
|
+
const top = source.position.y;
|
|
51
|
+
|
|
52
|
+
return [
|
|
53
|
+
{
|
|
54
|
+
...edge,
|
|
55
|
+
type: edge.type ?? EdgeType.Directed,
|
|
56
|
+
points: [
|
|
57
|
+
{ x: right - Math.min(sourceWidth * 0.25, 18), y: top + Math.min(sourceHeight * 0.35, 18) },
|
|
58
|
+
{ x: right + 28, y: top - 20 },
|
|
59
|
+
{ x: right + 36, y: top + sourceHeight / 2 },
|
|
60
|
+
{ x: right - Math.min(sourceWidth * 0.25, 18), y: top + sourceHeight * 0.8 },
|
|
61
|
+
],
|
|
62
|
+
},
|
|
63
|
+
];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return [
|
|
67
|
+
{
|
|
68
|
+
...edge,
|
|
69
|
+
type: edge.type ?? EdgeType.Directed,
|
|
70
|
+
points: [
|
|
71
|
+
{
|
|
72
|
+
x: source.position.x + sourceWidth / 2,
|
|
73
|
+
y: source.position.y + sourceHeight / 2,
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
x: target.position.x + targetWidth / 2,
|
|
77
|
+
y: target.position.y + targetHeight / 2,
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
},
|
|
81
|
+
];
|
|
82
|
+
});
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const getPositionedNodesWithFallback = (
|
|
86
|
+
sourceNodes: NodeData[],
|
|
87
|
+
normalizedEdges: EdgeData[],
|
|
88
|
+
config: RenderConfig
|
|
89
|
+
): PositionedNode[] => {
|
|
90
|
+
try {
|
|
91
|
+
return layoutNodes({
|
|
92
|
+
nodes: sourceNodes,
|
|
93
|
+
edges: normalizedEdges,
|
|
94
|
+
theme: config.mergedTheme,
|
|
95
|
+
padding: config.padding,
|
|
96
|
+
layout: config.layout,
|
|
97
|
+
width: config.width,
|
|
98
|
+
height: config.height,
|
|
99
|
+
layoutDirection: config.layoutDirection,
|
|
100
|
+
nodeSizing: config.nodeSizing,
|
|
101
|
+
fixedNodeSize: config.fixedNodeSize,
|
|
102
|
+
labelMeasurementPaddingX: config.labelMeasurementPaddingX,
|
|
103
|
+
labelMeasurementPaddingY: config.labelMeasurementPaddingY,
|
|
104
|
+
labelMeasurementCharWidth: config.labelMeasurementCharWidth,
|
|
105
|
+
labelMeasurementLineHeight: config.labelMeasurementLineHeight,
|
|
106
|
+
});
|
|
107
|
+
} catch {
|
|
108
|
+
return layoutNodes({
|
|
109
|
+
nodes: sourceNodes,
|
|
110
|
+
edges: normalizedEdges,
|
|
111
|
+
theme: config.mergedTheme,
|
|
112
|
+
padding: config.padding,
|
|
113
|
+
layout: LayoutType.Centered,
|
|
114
|
+
width: config.width,
|
|
115
|
+
height: config.height,
|
|
116
|
+
layoutDirection: LayoutDirection.LTR,
|
|
117
|
+
nodeSizing: config.nodeSizing,
|
|
118
|
+
fixedNodeSize: config.fixedNodeSize,
|
|
119
|
+
labelMeasurementPaddingX: config.labelMeasurementPaddingX,
|
|
120
|
+
labelMeasurementPaddingY: config.labelMeasurementPaddingY,
|
|
121
|
+
labelMeasurementCharWidth: config.labelMeasurementCharWidth,
|
|
122
|
+
labelMeasurementLineHeight: config.labelMeasurementLineHeight,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const getPositionedEdgesWithFallback = (
|
|
128
|
+
positionedNodes: PositionedNode[],
|
|
129
|
+
normalizedEdges: EdgeData[],
|
|
130
|
+
config: RenderConfig
|
|
131
|
+
): PositionedEdge[] => {
|
|
132
|
+
try {
|
|
133
|
+
const routedEdges = routeEdges(positionedNodes, normalizedEdges, {
|
|
134
|
+
arrowPadding: config.arrowPadding,
|
|
135
|
+
straight: !config.curveEdges || config.routingStyle === 'orthogonal',
|
|
136
|
+
layoutDirection: config.layoutDirection,
|
|
137
|
+
forceRightToLeft: config.forceRightToLeft ?? false,
|
|
138
|
+
routingStyle: config.routingStyle,
|
|
139
|
+
edgeSeparation: config.edgeSeparation,
|
|
140
|
+
selfLoopRadius: config.selfLoopRadius,
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
return routedEdges.filter(
|
|
144
|
+
(edge) => edge.points.length >= 2 && edge.points.every((point) => isFinitePoint(point))
|
|
145
|
+
);
|
|
146
|
+
} catch {
|
|
147
|
+
return buildFallbackPositionedEdges(positionedNodes, normalizedEdges);
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Extract and normalize configuration from options
|
|
153
|
+
*/
|
|
154
|
+
const extractRenderConfig = (options?: RenderGraphToSvgOptions): RenderConfig => {
|
|
155
|
+
const cfg = normalizeGraphConfig(options?.config);
|
|
156
|
+
const mergedTheme = { ...DEFAULT_THEME, ...(cfg.theme ?? {}) };
|
|
157
|
+
const safeFontFamily = escapeXml(
|
|
158
|
+
sanitizeFontFamily(mergedTheme.fontFamily, DEFAULT_THEME.fontFamily)
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
width: cfg.width,
|
|
163
|
+
height: cfg.height,
|
|
164
|
+
padding: cfg.padding,
|
|
165
|
+
defaultEdgeType: cfg.defaultEdgeType,
|
|
166
|
+
curveEdges: cfg.curveEdges,
|
|
167
|
+
curveStrength: cfg.curveStrength,
|
|
168
|
+
arrowPadding: cfg.arrowPadding,
|
|
169
|
+
showArrows: cfg.showArrows,
|
|
170
|
+
nodeSizing: cfg.nodeSizing,
|
|
171
|
+
fixedNodeSize: cfg.fixedNodeSize,
|
|
172
|
+
labelMeasurementPaddingX: cfg.labelMeasurementPaddingX,
|
|
173
|
+
labelMeasurementPaddingY: cfg.labelMeasurementPaddingY,
|
|
174
|
+
labelMeasurementCharWidth: cfg.labelMeasurementCharWidth,
|
|
175
|
+
labelMeasurementLineHeight: cfg.labelMeasurementLineHeight,
|
|
176
|
+
routingStyle: cfg.routingStyle,
|
|
177
|
+
edgeSeparation: cfg.edgeSeparation,
|
|
178
|
+
selfLoopRadius: cfg.selfLoopRadius,
|
|
179
|
+
layout: cfg.layout,
|
|
180
|
+
layoutDirection: cfg.layoutDirection,
|
|
181
|
+
forceRightToLeft: cfg.forceRightToLeft,
|
|
182
|
+
markerId: sanitizeSvgId(options?.markerId ?? 'arrow', 'arrow'),
|
|
183
|
+
edgeLabelColor: cfg.edgeLabelColor,
|
|
184
|
+
mergedTheme,
|
|
185
|
+
safeFontFamily,
|
|
186
|
+
};
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Normalize edges with default type
|
|
191
|
+
*/
|
|
192
|
+
const normalizeEdges = (edges: EdgeData[], defaultType: EdgeType): EdgeData[] => {
|
|
193
|
+
return edges.map((edge) => ({ ...edge, type: edge.type ?? defaultType }));
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Extract theme properties for rendering
|
|
198
|
+
*/
|
|
199
|
+
const extractRenderTheme = (config: RenderConfig): RenderTheme => {
|
|
200
|
+
return {
|
|
201
|
+
edgeColor: sanitizeCssColor(config.mergedTheme.edgeColor, DEFAULT_THEME.edgeColor),
|
|
202
|
+
edgeWidth: config.mergedTheme.edgeWidth ?? DEFAULT_THEME.edgeWidth,
|
|
203
|
+
edgeLabelColor: sanitizeCssColor(config.edgeLabelColor, '#334155'),
|
|
204
|
+
background: sanitizeCssColor(config.mergedTheme.background, DEFAULT_THEME.background),
|
|
205
|
+
};
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Create SVG marker definition for arrow heads
|
|
210
|
+
*/
|
|
211
|
+
const createArrowMarkerDef = (markerId: string, edgeColor: string): string => {
|
|
212
|
+
return [
|
|
213
|
+
`<marker id="${escapeXml(markerId)}" viewBox="0 0 10 10" refX="6" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">`,
|
|
214
|
+
`<path d="M 0 0 L 10 5 L 0 10 z" fill="${escapeXml(edgeColor)}" />`,
|
|
215
|
+
'</marker>',
|
|
216
|
+
].join('');
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Render all edges to SVG markup
|
|
221
|
+
*/
|
|
222
|
+
const renderEdgesToSvg = (
|
|
223
|
+
edges: PositionedEdge[],
|
|
224
|
+
curveEdges: boolean,
|
|
225
|
+
curveStrength: number,
|
|
226
|
+
edgeRenderer: EdgeRenderer,
|
|
227
|
+
theme: RenderTheme,
|
|
228
|
+
markerId: string
|
|
229
|
+
): string => {
|
|
230
|
+
return edges
|
|
231
|
+
.map((edge) => {
|
|
232
|
+
const pathD = buildEdgePath(edge, curveEdges, curveStrength);
|
|
233
|
+
if (!pathD) return '';
|
|
234
|
+
return edgeRenderer(edge, pathD, {
|
|
235
|
+
edgeColor: theme.edgeColor,
|
|
236
|
+
edgeWidth: theme.edgeWidth,
|
|
237
|
+
edgeLabelColor: theme.edgeLabelColor,
|
|
238
|
+
markerId,
|
|
239
|
+
});
|
|
240
|
+
})
|
|
241
|
+
.join('');
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Render all nodes to SVG markup
|
|
246
|
+
*/
|
|
247
|
+
const renderNodesToSvg = (nodes: PositionedNode[], nodeRenderer: NodeRenderer): string => {
|
|
248
|
+
return nodes
|
|
249
|
+
.map((node) => {
|
|
250
|
+
const body = nodeRenderer(node);
|
|
251
|
+
return `<g transform="translate(${node.position.x}, ${node.position.y})">${body}</g>`;
|
|
252
|
+
})
|
|
253
|
+
.join('');
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Create SVG metadata elements (title and description)
|
|
258
|
+
*/
|
|
259
|
+
const createMetadataElements = (title?: string, desc?: string): string => {
|
|
260
|
+
const titleElement = title ? `<title>${escapeXml(title)}</title>` : '';
|
|
261
|
+
const descElement = desc ? `<desc>${escapeXml(desc)}</desc>` : '';
|
|
262
|
+
return titleElement + descElement;
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Assemble complete SVG document
|
|
267
|
+
*/
|
|
268
|
+
const assembleSvgDocument = (
|
|
269
|
+
width: number,
|
|
270
|
+
height: number,
|
|
271
|
+
background: string,
|
|
272
|
+
fontFamily: string,
|
|
273
|
+
metadata: string,
|
|
274
|
+
defs: string,
|
|
275
|
+
edgesMarkup: string,
|
|
276
|
+
nodesMarkup: string
|
|
277
|
+
): string => {
|
|
278
|
+
return [
|
|
279
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" style="background:${escapeXml(background)};font-family:${fontFamily};">`,
|
|
280
|
+
metadata,
|
|
281
|
+
'<defs>',
|
|
282
|
+
defs,
|
|
283
|
+
'</defs>',
|
|
284
|
+
'<g>',
|
|
285
|
+
edgesMarkup,
|
|
286
|
+
nodesMarkup,
|
|
287
|
+
'</g>',
|
|
288
|
+
'</svg>',
|
|
289
|
+
].join('');
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Render a graph to SVG format
|
|
294
|
+
*/
|
|
295
|
+
export const renderGraphToSvg = (
|
|
296
|
+
graph: NxGraphInput,
|
|
297
|
+
options?: RenderGraphToSvgOptions
|
|
298
|
+
): RenderGraphToSvgResult => {
|
|
299
|
+
const config = extractRenderConfig(options);
|
|
300
|
+
|
|
301
|
+
// Parse and normalize graph data
|
|
302
|
+
const { nodes: sourceNodes, edges: sourceEdges } = fromNxGraph(graph, config.defaultEdgeType);
|
|
303
|
+
const normalizedEdges = normalizeEdges(sourceEdges, config.defaultEdgeType);
|
|
304
|
+
|
|
305
|
+
// Layout nodes and route edges
|
|
306
|
+
const positionedNodes = getPositionedNodesWithFallback(sourceNodes, normalizedEdges, config);
|
|
307
|
+
const positionedEdges = getPositionedEdgesWithFallback(positionedNodes, normalizedEdges, config);
|
|
308
|
+
|
|
309
|
+
// Extract rendering components
|
|
310
|
+
const theme = extractRenderTheme(config);
|
|
311
|
+
const nodeRenderer = options?.vertexRenderer ?? defaultNodeRenderer;
|
|
312
|
+
const edgeRenderer = options?.edgeRenderer ?? defaultEdgeRenderer;
|
|
313
|
+
|
|
314
|
+
// Generate SVG elements
|
|
315
|
+
const defs = config.showArrows ? createArrowMarkerDef(config.markerId, theme.edgeColor) : '';
|
|
316
|
+
const edgesMarkup = renderEdgesToSvg(
|
|
317
|
+
positionedEdges,
|
|
318
|
+
config.curveEdges,
|
|
319
|
+
config.curveStrength,
|
|
320
|
+
edgeRenderer,
|
|
321
|
+
theme,
|
|
322
|
+
config.showArrows ? config.markerId : ''
|
|
323
|
+
);
|
|
324
|
+
const nodesMarkup = renderNodesToSvg(positionedNodes, nodeRenderer);
|
|
325
|
+
const metadata = createMetadataElements(options?.title, options?.desc);
|
|
326
|
+
|
|
327
|
+
// Assemble final SVG
|
|
328
|
+
const svg = assembleSvgDocument(
|
|
329
|
+
config.width,
|
|
330
|
+
config.height,
|
|
331
|
+
theme.background,
|
|
332
|
+
config.safeFontFamily,
|
|
333
|
+
metadata,
|
|
334
|
+
defs,
|
|
335
|
+
edgesMarkup,
|
|
336
|
+
nodesMarkup
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
return {
|
|
340
|
+
svg,
|
|
341
|
+
width: config.width,
|
|
342
|
+
height: config.height,
|
|
343
|
+
nodes: positionedNodes,
|
|
344
|
+
edges: positionedEdges,
|
|
345
|
+
};
|
|
346
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export const escapeXml = (input: unknown): string => {
|
|
2
|
+
const str = String(input ?? '');
|
|
3
|
+
return str
|
|
4
|
+
.replace(/&/g, '&')
|
|
5
|
+
.replace(/</g, '<')
|
|
6
|
+
.replace(/>/g, '>')
|
|
7
|
+
.replace(/"/g, '"')
|
|
8
|
+
.replace(/'/g, ''');
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const CSS_COLOR_PATTERN =
|
|
12
|
+
/^(#[0-9a-fA-F]{3,8}|(?:rgb|hsl)a?\([0-9\s.,%+-]+\)|[a-zA-Z][a-zA-Z0-9-]*|var\(--[a-zA-Z0-9-_]+\))$/;
|
|
13
|
+
|
|
14
|
+
const FONT_FAMILY_PATTERN = /^[a-zA-Z0-9\s,'"-]+$/;
|
|
15
|
+
|
|
16
|
+
export const sanitizeSvgId = (input: unknown, fallback: string): string => {
|
|
17
|
+
const normalized = String(input ?? '')
|
|
18
|
+
.trim()
|
|
19
|
+
.replace(/[^a-zA-Z0-9:_-]+/g, '-')
|
|
20
|
+
.replace(/^-+|-+$/g, '');
|
|
21
|
+
|
|
22
|
+
if (!normalized) {
|
|
23
|
+
return fallback;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return /^[a-zA-Z_]/.test(normalized) ? normalized : `${fallback}-${normalized}`;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const sanitizeCssColor = (input: unknown, fallback: string): string => {
|
|
30
|
+
const value = String(input ?? '').trim();
|
|
31
|
+
return CSS_COLOR_PATTERN.test(value) ? value : fallback;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const sanitizeFontFamily = (input: unknown, fallback: string): string => {
|
|
35
|
+
const value = String(input ?? '').trim();
|
|
36
|
+
if (!value || !FONT_FAMILY_PATTERN.test(value)) {
|
|
37
|
+
return fallback;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return value;
|
|
41
|
+
};
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import {
|
|
2
|
+
EdgeType,
|
|
3
|
+
GraphConfig,
|
|
4
|
+
GraphTheme,
|
|
5
|
+
LayoutDirection,
|
|
6
|
+
LayoutType,
|
|
7
|
+
} from '@graph-render/types';
|
|
8
|
+
import { DEFAULT_THEME, DEFAULT_NODE_SIZE } from './constants';
|
|
9
|
+
|
|
10
|
+
const DEFAULT_WIDTH = 960;
|
|
11
|
+
const DEFAULT_HEIGHT = 720;
|
|
12
|
+
const MAX_DIMENSION = 32768;
|
|
13
|
+
const DEFAULT_CURVE_STRENGTH = 0.3;
|
|
14
|
+
const DEFAULT_ARROW_PADDING = 6;
|
|
15
|
+
const DEFAULT_EDGE_SEPARATION = 18;
|
|
16
|
+
const DEFAULT_SELF_LOOP_RADIUS = 32;
|
|
17
|
+
const DEFAULT_LABEL_OFFSET = 32;
|
|
18
|
+
const DEFAULT_LABEL_PADDING_X = 18;
|
|
19
|
+
const DEFAULT_LABEL_PADDING_Y = 12;
|
|
20
|
+
const DEFAULT_LABEL_CHAR_WIDTH = 8;
|
|
21
|
+
const DEFAULT_LABEL_LINE_HEIGHT = 18;
|
|
22
|
+
const DEFAULT_HOVER_EDGE_COLOR = '#4da3ff';
|
|
23
|
+
const DEFAULT_HOVER_NODE_IN_COLOR = '#2ecc71';
|
|
24
|
+
const DEFAULT_HOVER_NODE_OUT_COLOR = '#ff5b5b';
|
|
25
|
+
const DEFAULT_EDGE_LABEL_COLOR = '#334155';
|
|
26
|
+
const DEFAULT_LABEL_PILL_BACKGROUND = '#eef1f6';
|
|
27
|
+
const DEFAULT_LABEL_PILL_BORDER_COLOR = '#d7dbe3';
|
|
28
|
+
const DEFAULT_LABEL_PILL_TEXT_COLOR = '#3f434b';
|
|
29
|
+
const CSS_COLOR_PATTERN =
|
|
30
|
+
/^(#[0-9a-fA-F]{3,8}|(?:rgb|hsl)a?\([0-9\s.,%+-]+\)|[a-zA-Z][a-zA-Z0-9-]*|var\(--[a-zA-Z0-9-_]+\))$/;
|
|
31
|
+
|
|
32
|
+
const getFinitePositive = (value: unknown, fallback: number): number => {
|
|
33
|
+
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const getFiniteNonNegative = (value: unknown, fallback: number): number => {
|
|
37
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : fallback;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const getFiniteBounded = (value: unknown, min: number, max: number, fallback: number): number => {
|
|
41
|
+
return typeof value === 'number' && Number.isFinite(value)
|
|
42
|
+
? Math.min(Math.max(value, min), max)
|
|
43
|
+
: fallback;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const sanitizeCssColor = (value: unknown, fallback: string): string => {
|
|
47
|
+
const normalized = String(value ?? '').trim();
|
|
48
|
+
return CSS_COLOR_PATTERN.test(normalized) ? normalized : fallback;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const getEdgeType = (value: unknown, fallback: EdgeType): EdgeType => {
|
|
52
|
+
return value === EdgeType.Directed || value === EdgeType.Undirected ? value : fallback;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const getLayoutType = (value: unknown, fallback: LayoutType): LayoutType => {
|
|
56
|
+
return Object.values(LayoutType).includes(value as LayoutType) ? (value as LayoutType) : fallback;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const getLayoutDirection = (value: unknown, fallback: LayoutDirection): LayoutDirection => {
|
|
60
|
+
return value === LayoutDirection.LTR || value === LayoutDirection.RTL ? value : fallback;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const getNodeSizing = (value: unknown, fallback: NonNullable<GraphConfig['nodeSizing']>) => {
|
|
64
|
+
return value === 'fixed' || value === 'label' || value === 'measured' ? value : fallback;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const getRoutingStyle = (
|
|
68
|
+
value: unknown,
|
|
69
|
+
fallback: NonNullable<GraphConfig['routingStyle']>
|
|
70
|
+
): NonNullable<GraphConfig['routingStyle']> => {
|
|
71
|
+
return value === 'smart' || value === 'orthogonal' || value === 'bundled' ? value : fallback;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const normalizeFixedNodeSize = (value: unknown): NonNullable<GraphConfig['fixedNodeSize']> => {
|
|
75
|
+
if (!value || typeof value !== 'object') {
|
|
76
|
+
return DEFAULT_NODE_SIZE;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const width = getFinitePositive(
|
|
80
|
+
(value as GraphConfig['fixedNodeSize'])?.width,
|
|
81
|
+
DEFAULT_NODE_SIZE.width
|
|
82
|
+
);
|
|
83
|
+
const height = getFinitePositive(
|
|
84
|
+
(value as GraphConfig['fixedNodeSize'])?.height,
|
|
85
|
+
DEFAULT_NODE_SIZE.height
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
return { width, height };
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const normalizeTheme = (theme?: GraphTheme): GraphTheme => ({
|
|
92
|
+
...DEFAULT_THEME,
|
|
93
|
+
...theme,
|
|
94
|
+
edgeWidth: getFinitePositive(theme?.edgeWidth, DEFAULT_THEME.edgeWidth),
|
|
95
|
+
nodeGap: getFinitePositive(theme?.nodeGap, DEFAULT_THEME.nodeGap),
|
|
96
|
+
nodeBorderWidth:
|
|
97
|
+
typeof theme?.nodeBorderWidth === 'number' &&
|
|
98
|
+
Number.isFinite(theme.nodeBorderWidth) &&
|
|
99
|
+
theme.nodeBorderWidth >= 0
|
|
100
|
+
? theme.nodeBorderWidth
|
|
101
|
+
// FIX: was `theme?.nodeBorderWidth`, which passed invalid values (e.g.,
|
|
102
|
+
// the string "2px") straight through to the SVG stroke-width attribute.
|
|
103
|
+
// All other config fields fall back to a safe default; this now does too.
|
|
104
|
+
: undefined,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
export interface NormalizedGraphConfig extends Omit<GraphConfig, 'theme' | 'fixedNodeSize'> {
|
|
108
|
+
width: number;
|
|
109
|
+
height: number;
|
|
110
|
+
padding: number;
|
|
111
|
+
defaultEdgeType: EdgeType;
|
|
112
|
+
showArrows: boolean;
|
|
113
|
+
nodeSizing: NonNullable<GraphConfig['nodeSizing']>;
|
|
114
|
+
fixedNodeSize: NonNullable<GraphConfig['fixedNodeSize']>;
|
|
115
|
+
theme: GraphTheme;
|
|
116
|
+
curveEdges: boolean;
|
|
117
|
+
curveStrength: number;
|
|
118
|
+
arrowPadding: number;
|
|
119
|
+
routingStyle: NonNullable<GraphConfig['routingStyle']>;
|
|
120
|
+
edgeSeparation: number;
|
|
121
|
+
selfLoopRadius: number;
|
|
122
|
+
edgeLabelColor: string;
|
|
123
|
+
layout: LayoutType;
|
|
124
|
+
layoutDirection: LayoutDirection;
|
|
125
|
+
hoverHighlight: boolean;
|
|
126
|
+
hoverEdgeColor: string;
|
|
127
|
+
hoverNodeInColor: string;
|
|
128
|
+
hoverNodeOutColor: string;
|
|
129
|
+
hoverNodeHighlight: boolean;
|
|
130
|
+
autoLabels: boolean;
|
|
131
|
+
labelOffset: number;
|
|
132
|
+
labelPillBackground: string;
|
|
133
|
+
labelPillBorderColor: string;
|
|
134
|
+
labelPillTextColor: string;
|
|
135
|
+
labelMeasurementPaddingX: number;
|
|
136
|
+
labelMeasurementPaddingY: number;
|
|
137
|
+
labelMeasurementCharWidth: number;
|
|
138
|
+
labelMeasurementLineHeight: number;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export const normalizeGraphConfig = (config?: GraphConfig): NormalizedGraphConfig => {
|
|
142
|
+
return {
|
|
143
|
+
...config,
|
|
144
|
+
width: getFiniteBounded(config?.width, 1, MAX_DIMENSION, DEFAULT_WIDTH),
|
|
145
|
+
height: getFiniteBounded(config?.height, 1, MAX_DIMENSION, DEFAULT_HEIGHT),
|
|
146
|
+
padding: getFiniteNonNegative(config?.padding, 24),
|
|
147
|
+
defaultEdgeType: getEdgeType(config?.defaultEdgeType, EdgeType.Directed),
|
|
148
|
+
showArrows: config?.showArrows ?? true,
|
|
149
|
+
nodeSizing: getNodeSizing(config?.nodeSizing, 'fixed'),
|
|
150
|
+
fixedNodeSize: normalizeFixedNodeSize(config?.fixedNodeSize),
|
|
151
|
+
labelMeasurementPaddingX: getFiniteNonNegative(
|
|
152
|
+
config?.labelMeasurementPaddingX,
|
|
153
|
+
DEFAULT_LABEL_PADDING_X
|
|
154
|
+
),
|
|
155
|
+
labelMeasurementPaddingY: getFiniteNonNegative(
|
|
156
|
+
config?.labelMeasurementPaddingY,
|
|
157
|
+
DEFAULT_LABEL_PADDING_Y
|
|
158
|
+
),
|
|
159
|
+
labelMeasurementCharWidth: getFinitePositive(
|
|
160
|
+
config?.labelMeasurementCharWidth,
|
|
161
|
+
DEFAULT_LABEL_CHAR_WIDTH
|
|
162
|
+
),
|
|
163
|
+
labelMeasurementLineHeight: getFinitePositive(
|
|
164
|
+
config?.labelMeasurementLineHeight,
|
|
165
|
+
DEFAULT_LABEL_LINE_HEIGHT
|
|
166
|
+
),
|
|
167
|
+
theme: normalizeTheme(config?.theme),
|
|
168
|
+
curveEdges: config?.curveEdges ?? true,
|
|
169
|
+
curveStrength: getFiniteBounded(config?.curveStrength, 0, 1, DEFAULT_CURVE_STRENGTH),
|
|
170
|
+
arrowPadding: getFiniteNonNegative(config?.arrowPadding, DEFAULT_ARROW_PADDING),
|
|
171
|
+
routingStyle: getRoutingStyle(config?.routingStyle, 'smart'),
|
|
172
|
+
edgeSeparation: getFinitePositive(config?.edgeSeparation, DEFAULT_EDGE_SEPARATION),
|
|
173
|
+
selfLoopRadius: getFinitePositive(config?.selfLoopRadius, DEFAULT_SELF_LOOP_RADIUS),
|
|
174
|
+
edgeLabelColor: config?.edgeLabelColor ?? DEFAULT_EDGE_LABEL_COLOR,
|
|
175
|
+
layout: getLayoutType(config?.layout, LayoutType.Centered),
|
|
176
|
+
layoutDirection: getLayoutDirection(config?.layoutDirection, LayoutDirection.LTR),
|
|
177
|
+
hoverHighlight: config?.hoverHighlight ?? true,
|
|
178
|
+
hoverEdgeColor: config?.hoverEdgeColor ?? DEFAULT_HOVER_EDGE_COLOR,
|
|
179
|
+
hoverNodeBorderColor: config?.hoverNodeBorderColor,
|
|
180
|
+
hoverNodeInColor: config?.hoverNodeInColor ?? DEFAULT_HOVER_NODE_IN_COLOR,
|
|
181
|
+
hoverNodeOutColor: config?.hoverNodeOutColor ?? DEFAULT_HOVER_NODE_OUT_COLOR,
|
|
182
|
+
hoverNodeBothColor: config?.hoverNodeBothColor,
|
|
183
|
+
hoverNodeHighlight: config?.hoverNodeHighlight ?? true,
|
|
184
|
+
labels: config?.labels,
|
|
185
|
+
autoLabels: config?.autoLabels ?? false,
|
|
186
|
+
labelOffset: getFiniteNonNegative(config?.labelOffset, DEFAULT_LABEL_OFFSET),
|
|
187
|
+
labelPillBackground: sanitizeCssColor(
|
|
188
|
+
config?.labelPillBackground,
|
|
189
|
+
DEFAULT_LABEL_PILL_BACKGROUND
|
|
190
|
+
),
|
|
191
|
+
labelPillBorderColor: sanitizeCssColor(
|
|
192
|
+
config?.labelPillBorderColor,
|
|
193
|
+
DEFAULT_LABEL_PILL_BORDER_COLOR
|
|
194
|
+
),
|
|
195
|
+
labelPillTextColor: sanitizeCssColor(config?.labelPillTextColor, DEFAULT_LABEL_PILL_TEXT_COLOR),
|
|
196
|
+
forceRightToLeft: config?.forceRightToLeft ?? false,
|
|
197
|
+
};
|
|
198
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Size, GraphTheme } from '@graph-render/types';
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_NODE_GAP = 96;
|
|
4
|
+
export const DEFAULT_PADDING = 24;
|
|
5
|
+
export const DEFAULT_NODE_SIZE: Size = { width: 180, height: 72 };
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_THEME: Required<
|
|
8
|
+
Pick<GraphTheme, 'background' | 'edgeColor' | 'edgeWidth' | 'fontFamily'>
|
|
9
|
+
> & { nodeGap: number } = {
|
|
10
|
+
background: '#0c0c10',
|
|
11
|
+
edgeColor: '#8b9dbf',
|
|
12
|
+
edgeWidth: 2,
|
|
13
|
+
nodeGap: 96,
|
|
14
|
+
fontFamily: 'system-ui, -apple-system, Segoe UI, sans-serif',
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// Default rendering constants
|
|
18
|
+
export const DEFAULT_NODE_WIDTH = 180;
|
|
19
|
+
export const DEFAULT_NODE_HEIGHT = 72;
|
|
20
|
+
export const DEFAULT_NODE_RADIUS = 8;
|
|
21
|
+
export const DEFAULT_NODE_FILL = 'white';
|
|
22
|
+
export const DEFAULT_NODE_STROKE = '#d7dbe3';
|
|
23
|
+
export const DEFAULT_TEXT_FILL = '#111827';
|
|
24
|
+
export const DEFAULT_TEXT_SIZE = 14;
|