@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.
Files changed (161) hide show
  1. package/.eslintrc.json +6 -0
  2. package/CHANGELOG.md +45 -0
  3. package/dist/edges/collision.d.ts +8 -0
  4. package/dist/edges/collision.d.ts.map +1 -0
  5. package/dist/edges/collision.js +26 -0
  6. package/dist/edges/collision.js.map +1 -0
  7. package/dist/edges/geometry.d.ts +22 -0
  8. package/dist/edges/geometry.d.ts.map +1 -0
  9. package/dist/edges/geometry.js +75 -0
  10. package/dist/edges/geometry.js.map +1 -0
  11. package/dist/edges/index.d.ts +4 -0
  12. package/dist/edges/index.d.ts.map +1 -0
  13. package/dist/edges/index.js +4 -0
  14. package/dist/edges/index.js.map +1 -0
  15. package/dist/edges/pathBuilder.d.ts +11 -0
  16. package/dist/edges/pathBuilder.d.ts.map +1 -0
  17. package/dist/edges/pathBuilder.js +114 -0
  18. package/dist/edges/pathBuilder.js.map +1 -0
  19. package/dist/edges/pathCalculation.d.ts +14 -0
  20. package/dist/edges/pathCalculation.d.ts.map +1 -0
  21. package/dist/edges/pathCalculation.js +47 -0
  22. package/dist/edges/pathCalculation.js.map +1 -0
  23. package/dist/edges/routing.d.ts +6 -0
  24. package/dist/edges/routing.d.ts.map +1 -0
  25. package/dist/edges/routing.js +243 -0
  26. package/dist/edges/routing.js.map +1 -0
  27. package/dist/edges/sideSelection.d.ts +17 -0
  28. package/dist/edges/sideSelection.d.ts.map +1 -0
  29. package/dist/edges/sideSelection.js +45 -0
  30. package/dist/edges/sideSelection.js.map +1 -0
  31. package/dist/index.d.ts +7 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +6 -0
  34. package/dist/index.js.map +1 -0
  35. package/dist/layouts/centered.d.ts +6 -0
  36. package/dist/layouts/centered.d.ts.map +1 -0
  37. package/dist/layouts/centered.js +69 -0
  38. package/dist/layouts/centered.js.map +1 -0
  39. package/dist/layouts/compactBracket.d.ts +3 -0
  40. package/dist/layouts/compactBracket.d.ts.map +1 -0
  41. package/dist/layouts/compactBracket.js +7 -0
  42. package/dist/layouts/compactBracket.js.map +1 -0
  43. package/dist/layouts/dag.d.ts +3 -0
  44. package/dist/layouts/dag.d.ts.map +1 -0
  45. package/dist/layouts/dag.js +52 -0
  46. package/dist/layouts/dag.js.map +1 -0
  47. package/dist/layouts/forceDirected.d.ts +3 -0
  48. package/dist/layouts/forceDirected.d.ts.map +1 -0
  49. package/dist/layouts/forceDirected.js +176 -0
  50. package/dist/layouts/forceDirected.js.map +1 -0
  51. package/dist/layouts/grid.d.ts +6 -0
  52. package/dist/layouts/grid.d.ts.map +1 -0
  53. package/dist/layouts/grid.js +34 -0
  54. package/dist/layouts/grid.js.map +1 -0
  55. package/dist/layouts/index.d.ts +12 -0
  56. package/dist/layouts/index.d.ts.map +1 -0
  57. package/dist/layouts/index.js +88 -0
  58. package/dist/layouts/index.js.map +1 -0
  59. package/dist/layouts/orthogonalFlow.d.ts +3 -0
  60. package/dist/layouts/orthogonalFlow.d.ts.map +1 -0
  61. package/dist/layouts/orthogonalFlow.js +81 -0
  62. package/dist/layouts/orthogonalFlow.js.map +1 -0
  63. package/dist/layouts/radialTree.d.ts +3 -0
  64. package/dist/layouts/radialTree.d.ts.map +1 -0
  65. package/dist/layouts/radialTree.js +45 -0
  66. package/dist/layouts/radialTree.js.map +1 -0
  67. package/dist/layouts/tree.d.ts +6 -0
  68. package/dist/layouts/tree.d.ts.map +1 -0
  69. package/dist/layouts/tree.js +19 -0
  70. package/dist/layouts/tree.js.map +1 -0
  71. package/dist/layouts/treeAlignment.d.ts +10 -0
  72. package/dist/layouts/treeAlignment.d.ts.map +1 -0
  73. package/dist/layouts/treeAlignment.js +69 -0
  74. package/dist/layouts/treeAlignment.js.map +1 -0
  75. package/dist/layouts/treePositioning.d.ts +14 -0
  76. package/dist/layouts/treePositioning.d.ts.map +1 -0
  77. package/dist/layouts/treePositioning.js +30 -0
  78. package/dist/layouts/treePositioning.js.map +1 -0
  79. package/dist/layouts/treeTopology.d.ts +29 -0
  80. package/dist/layouts/treeTopology.d.ts.map +1 -0
  81. package/dist/layouts/treeTopology.js +137 -0
  82. package/dist/layouts/treeTopology.js.map +1 -0
  83. package/dist/rendering/defaultRenderers.d.ts +10 -0
  84. package/dist/rendering/defaultRenderers.d.ts.map +1 -0
  85. package/dist/rendering/defaultRenderers.js +85 -0
  86. package/dist/rendering/defaultRenderers.js.map +1 -0
  87. package/dist/rendering/index.d.ts +4 -0
  88. package/dist/rendering/index.d.ts.map +1 -0
  89. package/dist/rendering/index.js +4 -0
  90. package/dist/rendering/index.js.map +1 -0
  91. package/dist/rendering/svg.d.ts +7 -0
  92. package/dist/rendering/svg.d.ts.map +1 -0
  93. package/dist/rendering/svg.js +256 -0
  94. package/dist/rendering/svg.js.map +1 -0
  95. package/dist/rendering/utils.d.ts +5 -0
  96. package/dist/rendering/utils.d.ts.map +1 -0
  97. package/dist/rendering/utils.js +33 -0
  98. package/dist/rendering/utils.js.map +1 -0
  99. package/dist/utils/config.d.ts +36 -0
  100. package/dist/utils/config.d.ts.map +1 -0
  101. package/dist/utils/config.js +115 -0
  102. package/dist/utils/config.js.map +1 -0
  103. package/dist/utils/constants.d.ts +15 -0
  104. package/dist/utils/constants.d.ts.map +1 -0
  105. package/dist/utils/constants.js +19 -0
  106. package/dist/utils/constants.js.map +1 -0
  107. package/dist/utils/graphParser.d.ts +16 -0
  108. package/dist/utils/graphParser.d.ts.map +1 -0
  109. package/dist/utils/graphParser.js +277 -0
  110. package/dist/utils/graphParser.js.map +1 -0
  111. package/dist/utils/graphTraversal.d.ts +13 -0
  112. package/dist/utils/graphTraversal.d.ts.map +1 -0
  113. package/dist/utils/graphTraversal.js +28 -0
  114. package/dist/utils/graphTraversal.js.map +1 -0
  115. package/dist/utils/index.d.ts +8 -0
  116. package/dist/utils/index.d.ts.map +1 -0
  117. package/dist/utils/index.js +7 -0
  118. package/dist/utils/index.js.map +1 -0
  119. package/dist/utils/nodeMetrics.d.ts +8 -0
  120. package/dist/utils/nodeMetrics.d.ts.map +1 -0
  121. package/dist/utils/nodeMetrics.js +12 -0
  122. package/dist/utils/nodeMetrics.js.map +1 -0
  123. package/dist/utils/nodeSizing.d.ts +3 -0
  124. package/dist/utils/nodeSizing.d.ts.map +1 -0
  125. package/dist/utils/nodeSizing.js +77 -0
  126. package/dist/utils/nodeSizing.js.map +1 -0
  127. package/package.json +29 -0
  128. package/project.json +32 -0
  129. package/src/edges/collision.ts +31 -0
  130. package/src/edges/geometry.ts +85 -0
  131. package/src/edges/index.ts +3 -0
  132. package/src/edges/pathBuilder.ts +136 -0
  133. package/src/edges/pathCalculation.ts +69 -0
  134. package/src/edges/routing.ts +459 -0
  135. package/src/edges/sideSelection.ts +67 -0
  136. package/src/index.ts +50 -0
  137. package/src/layouts/centered.ts +114 -0
  138. package/src/layouts/compactBracket.ts +14 -0
  139. package/src/layouts/dag.ts +76 -0
  140. package/src/layouts/forceDirected.ts +224 -0
  141. package/src/layouts/grid.ts +50 -0
  142. package/src/layouts/index.ts +148 -0
  143. package/src/layouts/orthogonalFlow.ts +112 -0
  144. package/src/layouts/radialTree.ts +77 -0
  145. package/src/layouts/tree.ts +35 -0
  146. package/src/layouts/treeAlignment.ts +107 -0
  147. package/src/layouts/treePositioning.ts +55 -0
  148. package/src/layouts/treeTopology.ts +184 -0
  149. package/src/rendering/defaultRenderers.ts +110 -0
  150. package/src/rendering/index.ts +3 -0
  151. package/src/rendering/svg.ts +346 -0
  152. package/src/rendering/utils.ts +41 -0
  153. package/src/utils/config.ts +198 -0
  154. package/src/utils/constants.ts +24 -0
  155. package/src/utils/graphParser.ts +495 -0
  156. package/src/utils/graphTraversal.ts +32 -0
  157. package/src/utils/index.ts +19 -0
  158. package/src/utils/nodeMetrics.ts +23 -0
  159. package/src/utils/nodeSizing.ts +97 -0
  160. package/tsconfig.json +11 -0
  161. 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, '&amp;')
5
+ .replace(/</g, '&lt;')
6
+ .replace(/>/g, '&gt;')
7
+ .replace(/"/g, '&quot;')
8
+ .replace(/'/g, '&apos;');
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;