@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,495 @@
|
|
|
1
|
+
import { NxGraphInput, NodeData, EdgeData, NxEdgeAttrs, EdgeType } from '@graph-render/types';
|
|
2
|
+
|
|
3
|
+
type GraphNodeTuple<TNodeData, TNodeMeta extends Record<string, unknown>, TNodeLabel> = NodeData<
|
|
4
|
+
TNodeData,
|
|
5
|
+
TNodeMeta,
|
|
6
|
+
TNodeLabel
|
|
7
|
+
>;
|
|
8
|
+
|
|
9
|
+
type GraphEdgeTuple<TEdgeMeta extends Record<string, unknown>, TEdgeLabel> = EdgeData<
|
|
10
|
+
TEdgeMeta,
|
|
11
|
+
TEdgeLabel
|
|
12
|
+
>;
|
|
13
|
+
|
|
14
|
+
const isPlainObject = (value: unknown): value is Record<string, unknown> => {
|
|
15
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const isFiniteNumber = (value: unknown): value is number =>
|
|
19
|
+
typeof value === 'number' && Number.isFinite(value);
|
|
20
|
+
|
|
21
|
+
const sanitizeNodeId = (value: string, kind: 'node' | 'edge-endpoint'): string => {
|
|
22
|
+
const normalized = value.trim();
|
|
23
|
+
if (!normalized) {
|
|
24
|
+
throw new TypeError(`Graph ${kind} identifiers must be non-empty strings.`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return normalized;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const sanitizePoint = (value: unknown): { x: number; y: number } | undefined => {
|
|
31
|
+
if (!isPlainObject(value) || !isFiniteNumber(value.x) || !isFiniteNumber(value.y)) {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return { x: value.x, y: value.y };
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const sanitizeSize = (value: unknown): { width: number; height: number } | undefined => {
|
|
39
|
+
if (!isPlainObject(value) || !isFiniteNumber(value.width) || !isFiniteNumber(value.height)) {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return value.width > 0 && value.height > 0
|
|
44
|
+
? { width: value.width, height: value.height }
|
|
45
|
+
: undefined;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const sanitizeRecord = <T extends Record<string, unknown>>(value: unknown): T | undefined => {
|
|
49
|
+
return isPlainObject(value) ? (value as T) : undefined;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const sanitizeMeasurementHints = (value: unknown): NodeData['measurementHints'] | undefined => {
|
|
53
|
+
if (!isPlainObject(value)) return undefined;
|
|
54
|
+
// FIX: was a bare cast `(value as NodeData['measurementHints'])` that let
|
|
55
|
+
// non-numeric values (e.g., paddingX: "8px") flow into layout arithmetic and
|
|
56
|
+
// produce NaN node sizes. Each field is now validated individually.
|
|
57
|
+
return {
|
|
58
|
+
label: typeof value.label === 'string' ? value.label : undefined,
|
|
59
|
+
paddingX:
|
|
60
|
+
isFiniteNumber(value.paddingX) && (value.paddingX as number) >= 0
|
|
61
|
+
? (value.paddingX as number)
|
|
62
|
+
: undefined,
|
|
63
|
+
paddingY:
|
|
64
|
+
isFiniteNumber(value.paddingY) && (value.paddingY as number) >= 0
|
|
65
|
+
? (value.paddingY as number)
|
|
66
|
+
: undefined,
|
|
67
|
+
estimatedCharWidth:
|
|
68
|
+
isFiniteNumber(value.estimatedCharWidth) && (value.estimatedCharWidth as number) > 0
|
|
69
|
+
? (value.estimatedCharWidth as number)
|
|
70
|
+
: undefined,
|
|
71
|
+
lineHeight:
|
|
72
|
+
isFiniteNumber(value.lineHeight) && (value.lineHeight as number) > 0
|
|
73
|
+
? (value.lineHeight as number)
|
|
74
|
+
: undefined,
|
|
75
|
+
};
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const sanitizeNodeData = <TNodeData, TNodeMeta extends Record<string, unknown>, TNodeLabel>(
|
|
79
|
+
id: string,
|
|
80
|
+
attrs: Record<string, unknown>
|
|
81
|
+
): GraphNodeTuple<TNodeData, TNodeMeta, TNodeLabel> => {
|
|
82
|
+
const position = sanitizePoint(attrs.position);
|
|
83
|
+
const size = sanitizeSize(attrs.size);
|
|
84
|
+
const measuredSize = sanitizeSize(attrs.measuredSize);
|
|
85
|
+
const measurementHints = sanitizeMeasurementHints(attrs.measurementHints);
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
id,
|
|
89
|
+
label: attrs.label,
|
|
90
|
+
position,
|
|
91
|
+
size,
|
|
92
|
+
measuredSize,
|
|
93
|
+
sizeMode:
|
|
94
|
+
attrs.sizeMode === 'fixed' || attrs.sizeMode === 'label' || attrs.sizeMode === 'measured'
|
|
95
|
+
? attrs.sizeMode
|
|
96
|
+
: undefined,
|
|
97
|
+
measurementHints,
|
|
98
|
+
data: attrs.data,
|
|
99
|
+
meta: sanitizeRecord<TNodeMeta>(attrs.meta),
|
|
100
|
+
} as GraphNodeTuple<TNodeData, TNodeMeta, TNodeLabel>;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const sanitizeEdgePoints = (value: unknown): EdgeData['points'] | undefined => {
|
|
104
|
+
if (!Array.isArray(value)) {
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const points = value
|
|
109
|
+
.map((point) => sanitizePoint(point))
|
|
110
|
+
.filter((point): point is NonNullable<EdgeData['points']>[number] => point !== undefined);
|
|
111
|
+
return points.length >= 2 ? points : undefined;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const assertUniqueEdgeId = (candidate: string, usedEdgeIds: Set<string>): string => {
|
|
115
|
+
if (usedEdgeIds.has(candidate)) {
|
|
116
|
+
throw new TypeError(
|
|
117
|
+
`Graph edge identifiers must be unique. Duplicate edge id "${candidate}" was provided.`
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
usedEdgeIds.add(candidate);
|
|
122
|
+
return candidate;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const assertValidGraphInput = (graph: NxGraphInput): void => {
|
|
126
|
+
if (!isPlainObject(graph)) {
|
|
127
|
+
throw new TypeError('Graph input must be a plain object.');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (!isPlainObject(graph.adj)) {
|
|
131
|
+
throw new TypeError('Graph input must include an adjacency map in `adj`.');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (graph.nodes != null && !isPlainObject(graph.nodes)) {
|
|
135
|
+
throw new TypeError('Graph `nodes` must be a record of node attributes when provided.');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
for (const [source, neighbors] of Object.entries(graph.adj)) {
|
|
139
|
+
if (!isPlainObject(neighbors)) {
|
|
140
|
+
throw new TypeError(`Adjacency entry for node "${source}" must be an object.`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
for (const [target, rawAttrs] of Object.entries(neighbors)) {
|
|
144
|
+
const attrsList = Array.isArray(rawAttrs) ? rawAttrs : [rawAttrs];
|
|
145
|
+
if (!attrsList.length) {
|
|
146
|
+
throw new TypeError(
|
|
147
|
+
`Adjacency entry for edge "${source}" -> "${target}" must not be an empty array.`
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
attrsList.forEach((attrs, index) => {
|
|
152
|
+
if (attrs != null && !isPlainObject(attrs)) {
|
|
153
|
+
throw new TypeError(
|
|
154
|
+
`Edge attributes for "${source}" -> "${target}" at index ${index} must be an object.`
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Build node map from graph node definitions
|
|
164
|
+
*/
|
|
165
|
+
const buildNodeMap = <
|
|
166
|
+
TNodeData,
|
|
167
|
+
TNodeMeta extends Record<string, unknown>,
|
|
168
|
+
TNodeLabel,
|
|
169
|
+
TEdgeMeta extends Record<string, unknown>,
|
|
170
|
+
TEdgeLabel,
|
|
171
|
+
>(
|
|
172
|
+
graph: NxGraphInput<TNodeData, TNodeMeta, TNodeLabel, TEdgeMeta, TEdgeLabel>
|
|
173
|
+
): Map<string, GraphNodeTuple<TNodeData, TNodeMeta, TNodeLabel>> => {
|
|
174
|
+
const nodeMap = new Map<string, GraphNodeTuple<TNodeData, TNodeMeta, TNodeLabel>>();
|
|
175
|
+
|
|
176
|
+
if (graph.nodes) {
|
|
177
|
+
for (const [id, attrs] of Object.entries(graph.nodes)) {
|
|
178
|
+
if (attrs != null && !isPlainObject(attrs)) {
|
|
179
|
+
throw new TypeError(`Node attributes for "${id}" must be an object when provided.`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const sanitizedId = sanitizeNodeId(id, 'node');
|
|
183
|
+
nodeMap.set(
|
|
184
|
+
sanitizedId,
|
|
185
|
+
sanitizeNodeData<TNodeData, TNodeMeta, TNodeLabel>(sanitizedId, attrs ?? {})
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return nodeMap;
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const hasExplicitNodeDefinitions = (graph: NxGraphInput): boolean => {
|
|
194
|
+
return Boolean(graph.nodes && Object.keys(graph.nodes).length > 0);
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Ensure a node exists in the map, creating it if necessary
|
|
199
|
+
*/
|
|
200
|
+
const ensureNodeExists = <TNodeData, TNodeMeta extends Record<string, unknown>, TNodeLabel>(
|
|
201
|
+
nodeMap: Map<string, NodeData<TNodeData, TNodeMeta, TNodeLabel>>,
|
|
202
|
+
nodeId: string
|
|
203
|
+
): void => {
|
|
204
|
+
const sanitizedNodeId = sanitizeNodeId(nodeId, 'edge-endpoint');
|
|
205
|
+
if (!nodeMap.has(sanitizedNodeId)) {
|
|
206
|
+
nodeMap.set(sanitizedNodeId, { id: sanitizedNodeId } as NodeData<
|
|
207
|
+
TNodeData,
|
|
208
|
+
TNodeMeta,
|
|
209
|
+
TNodeLabel
|
|
210
|
+
>);
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const assertNodeExists = <TNodeData, TNodeMeta extends Record<string, unknown>, TNodeLabel>(
|
|
215
|
+
nodeMap: Map<string, NodeData<TNodeData, TNodeMeta, TNodeLabel>>,
|
|
216
|
+
nodeId: string,
|
|
217
|
+
graph: NxGraphInput,
|
|
218
|
+
kind: 'source' | 'target'
|
|
219
|
+
): void => {
|
|
220
|
+
const sanitizedNodeId = sanitizeNodeId(nodeId, 'edge-endpoint');
|
|
221
|
+
|
|
222
|
+
if (!hasExplicitNodeDefinitions(graph)) {
|
|
223
|
+
ensureNodeExists(nodeMap, sanitizedNodeId);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (!nodeMap.has(sanitizedNodeId)) {
|
|
228
|
+
throw new TypeError(
|
|
229
|
+
`Graph edge ${kind} "${sanitizedNodeId}" must exist in graph.nodes when explicit node definitions are provided.`
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Normalize edge attributes to array format
|
|
236
|
+
*/
|
|
237
|
+
const normalizeEdgeAttributes = <TEdgeMeta extends Record<string, unknown>, TEdgeLabel>(
|
|
238
|
+
rawAttrs: NxEdgeAttrs<TEdgeMeta, TEdgeLabel> | NxEdgeAttrs<TEdgeMeta, TEdgeLabel>[]
|
|
239
|
+
): NxEdgeAttrs<TEdgeMeta, TEdgeLabel>[] => {
|
|
240
|
+
return Array.isArray(rawAttrs) ? rawAttrs : [rawAttrs];
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Generate unique key for undirected edge deduplication
|
|
245
|
+
*/
|
|
246
|
+
const createUndirectedEdgeKey = (source: string, target: string, index: number): string => {
|
|
247
|
+
return `${[source, target].sort().join('|')}|${index}`;
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Generate default edge ID
|
|
252
|
+
*/
|
|
253
|
+
const generateEdgeId = (source: string, target: string, index: number): string => {
|
|
254
|
+
return `${source}-${target}-${index}`;
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Check if undirected edge was already processed
|
|
259
|
+
*/
|
|
260
|
+
const isUndirectedEdgeSeen = (
|
|
261
|
+
edgeType: EdgeType,
|
|
262
|
+
source: string,
|
|
263
|
+
target: string,
|
|
264
|
+
index: number,
|
|
265
|
+
seenSet: Set<string>
|
|
266
|
+
): boolean => {
|
|
267
|
+
if (edgeType !== EdgeType.Undirected) return false;
|
|
268
|
+
|
|
269
|
+
const key = createUndirectedEdgeKey(source, target, index);
|
|
270
|
+
if (seenSet.has(key)) return true;
|
|
271
|
+
|
|
272
|
+
seenSet.add(key);
|
|
273
|
+
return false;
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Create edge data object from attributes
|
|
278
|
+
*/
|
|
279
|
+
const createEdgeData = (
|
|
280
|
+
source: string,
|
|
281
|
+
target: string,
|
|
282
|
+
index: number,
|
|
283
|
+
attrs: NxEdgeAttrs | undefined,
|
|
284
|
+
defaultEdgeType: EdgeType,
|
|
285
|
+
usedEdgeIds: Set<string>
|
|
286
|
+
): EdgeData => {
|
|
287
|
+
const { id, type, points, meta, ...rest } = attrs ?? {};
|
|
288
|
+
const edgeType = (type as EdgeType | undefined) ?? defaultEdgeType;
|
|
289
|
+
const baseId = sanitizeNodeId(String(id ?? generateEdgeId(source, target, index)), 'node');
|
|
290
|
+
|
|
291
|
+
return {
|
|
292
|
+
id: assertUniqueEdgeId(baseId, usedEdgeIds),
|
|
293
|
+
source,
|
|
294
|
+
target,
|
|
295
|
+
type:
|
|
296
|
+
edgeType === EdgeType.Directed || edgeType === EdgeType.Undirected
|
|
297
|
+
? edgeType
|
|
298
|
+
: defaultEdgeType,
|
|
299
|
+
points: sanitizeEdgePoints(points),
|
|
300
|
+
meta: sanitizeRecord<NonNullable<EdgeData['meta']>>(meta),
|
|
301
|
+
...rest,
|
|
302
|
+
};
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
const createTypedEdgeData = <TEdgeMeta extends Record<string, unknown>, TEdgeLabel>(
|
|
306
|
+
source: string,
|
|
307
|
+
target: string,
|
|
308
|
+
index: number,
|
|
309
|
+
attrs: NxEdgeAttrs<TEdgeMeta, TEdgeLabel> | undefined,
|
|
310
|
+
defaultEdgeType: EdgeType,
|
|
311
|
+
usedEdgeIds: Set<string>
|
|
312
|
+
): GraphEdgeTuple<TEdgeMeta, TEdgeLabel> => {
|
|
313
|
+
const edgeData = createEdgeData(
|
|
314
|
+
source,
|
|
315
|
+
target,
|
|
316
|
+
index,
|
|
317
|
+
attrs as NxEdgeAttrs | undefined,
|
|
318
|
+
defaultEdgeType,
|
|
319
|
+
usedEdgeIds
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
return edgeData as GraphEdgeTuple<TEdgeMeta, TEdgeLabel>;
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Process edges from adjacency list for a source node
|
|
327
|
+
*/
|
|
328
|
+
const processNodeEdges = (
|
|
329
|
+
source: string,
|
|
330
|
+
neighbors: Record<string, NxEdgeAttrs | NxEdgeAttrs[]>,
|
|
331
|
+
defaultEdgeType: 'directed' | 'undirected',
|
|
332
|
+
graph: NxGraphInput,
|
|
333
|
+
nodeMap: Map<string, NodeData>,
|
|
334
|
+
undirectedSeen: Set<string>,
|
|
335
|
+
usedEdgeIds: Set<string>
|
|
336
|
+
): EdgeData[] => {
|
|
337
|
+
const edges: EdgeData[] = [];
|
|
338
|
+
|
|
339
|
+
for (const [target, rawAttrs] of Object.entries(neighbors)) {
|
|
340
|
+
const sanitizedTarget = sanitizeNodeId(target, 'edge-endpoint');
|
|
341
|
+
assertNodeExists(nodeMap, sanitizedTarget, graph, 'target');
|
|
342
|
+
|
|
343
|
+
const attrsList = normalizeEdgeAttributes(rawAttrs);
|
|
344
|
+
|
|
345
|
+
attrsList.forEach((attrs, idx) => {
|
|
346
|
+
const edgeData = createEdgeData(
|
|
347
|
+
source,
|
|
348
|
+
sanitizedTarget,
|
|
349
|
+
idx,
|
|
350
|
+
attrs,
|
|
351
|
+
defaultEdgeType as EdgeType,
|
|
352
|
+
usedEdgeIds
|
|
353
|
+
);
|
|
354
|
+
|
|
355
|
+
// Skip if undirected edge already seen from other direction
|
|
356
|
+
if (!isUndirectedEdgeSeen(edgeData.type!, source, sanitizedTarget, idx, undirectedSeen)) {
|
|
357
|
+
edges.push(edgeData);
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
return edges;
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
const processTypedNodeEdges = <
|
|
366
|
+
TNodeData,
|
|
367
|
+
TNodeMeta extends Record<string, unknown>,
|
|
368
|
+
TNodeLabel,
|
|
369
|
+
TEdgeMeta extends Record<string, unknown>,
|
|
370
|
+
TEdgeLabel,
|
|
371
|
+
>(
|
|
372
|
+
source: string,
|
|
373
|
+
neighbors: Record<
|
|
374
|
+
string,
|
|
375
|
+
NxEdgeAttrs<TEdgeMeta, TEdgeLabel> | NxEdgeAttrs<TEdgeMeta, TEdgeLabel>[]
|
|
376
|
+
>,
|
|
377
|
+
defaultEdgeType: 'directed' | 'undirected',
|
|
378
|
+
graph: NxGraphInput<TNodeData, TNodeMeta, TNodeLabel, TEdgeMeta, TEdgeLabel>,
|
|
379
|
+
nodeMap: Map<string, GraphNodeTuple<TNodeData, TNodeMeta, TNodeLabel>>,
|
|
380
|
+
undirectedSeen: Set<string>,
|
|
381
|
+
usedEdgeIds: Set<string>
|
|
382
|
+
): GraphEdgeTuple<TEdgeMeta, TEdgeLabel>[] => {
|
|
383
|
+
const edges: GraphEdgeTuple<TEdgeMeta, TEdgeLabel>[] = [];
|
|
384
|
+
|
|
385
|
+
for (const [target, rawAttrs] of Object.entries(neighbors)) {
|
|
386
|
+
const sanitizedTarget = sanitizeNodeId(target, 'edge-endpoint');
|
|
387
|
+
assertNodeExists(nodeMap, sanitizedTarget, graph, 'target');
|
|
388
|
+
|
|
389
|
+
const attrsList = normalizeEdgeAttributes<TEdgeMeta, TEdgeLabel>(rawAttrs);
|
|
390
|
+
|
|
391
|
+
attrsList.forEach((attrs, idx) => {
|
|
392
|
+
const edgeData = createTypedEdgeData<TEdgeMeta, TEdgeLabel>(
|
|
393
|
+
source,
|
|
394
|
+
sanitizedTarget,
|
|
395
|
+
idx,
|
|
396
|
+
attrs,
|
|
397
|
+
defaultEdgeType as EdgeType,
|
|
398
|
+
usedEdgeIds
|
|
399
|
+
);
|
|
400
|
+
|
|
401
|
+
// Skip if undirected edge already seen from other direction
|
|
402
|
+
if (!isUndirectedEdgeSeen(edgeData.type!, source, sanitizedTarget, idx, undirectedSeen)) {
|
|
403
|
+
edges.push(edgeData);
|
|
404
|
+
}
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return edges;
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Parse NetworkX-style graph input to internal format
|
|
413
|
+
*/
|
|
414
|
+
export const fromNxGraph = (
|
|
415
|
+
graph: NxGraphInput,
|
|
416
|
+
defaultEdgeType: EdgeType = EdgeType.Undirected
|
|
417
|
+
): { nodes: NodeData[]; edges: EdgeData[] } => {
|
|
418
|
+
assertValidGraphInput(graph);
|
|
419
|
+
|
|
420
|
+
const nodeMap = buildNodeMap(graph);
|
|
421
|
+
const undirectedSeen = new Set<string>();
|
|
422
|
+
const usedEdgeIds = new Set<string>();
|
|
423
|
+
const edges: EdgeData[] = [];
|
|
424
|
+
|
|
425
|
+
for (const [source, neighbors] of Object.entries(graph.adj)) {
|
|
426
|
+
const sanitizedSource = sanitizeNodeId(source, 'edge-endpoint');
|
|
427
|
+
assertNodeExists(nodeMap, sanitizedSource, graph, 'source');
|
|
428
|
+
|
|
429
|
+
const nodeEdges = processNodeEdges(
|
|
430
|
+
sanitizedSource,
|
|
431
|
+
neighbors,
|
|
432
|
+
defaultEdgeType,
|
|
433
|
+
graph,
|
|
434
|
+
nodeMap,
|
|
435
|
+
undirectedSeen,
|
|
436
|
+
usedEdgeIds
|
|
437
|
+
);
|
|
438
|
+
|
|
439
|
+
edges.push(...nodeEdges);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
return {
|
|
443
|
+
nodes: Array.from(nodeMap.values()),
|
|
444
|
+
edges,
|
|
445
|
+
};
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
export const fromTypedNxGraph = <
|
|
449
|
+
TNodeData = unknown,
|
|
450
|
+
TNodeMeta extends Record<string, unknown> = Record<string, unknown>,
|
|
451
|
+
TNodeLabel = unknown,
|
|
452
|
+
TEdgeMeta extends Record<string, unknown> = Record<string, unknown>,
|
|
453
|
+
TEdgeLabel = unknown,
|
|
454
|
+
>(
|
|
455
|
+
graph: NxGraphInput<TNodeData, TNodeMeta, TNodeLabel, TEdgeMeta, TEdgeLabel>,
|
|
456
|
+
defaultEdgeType: EdgeType = EdgeType.Undirected
|
|
457
|
+
): {
|
|
458
|
+
nodes: GraphNodeTuple<TNodeData, TNodeMeta, TNodeLabel>[];
|
|
459
|
+
edges: GraphEdgeTuple<TEdgeMeta, TEdgeLabel>[];
|
|
460
|
+
} => {
|
|
461
|
+
assertValidGraphInput(graph as NxGraphInput);
|
|
462
|
+
|
|
463
|
+
const nodeMap = buildNodeMap<TNodeData, TNodeMeta, TNodeLabel, TEdgeMeta, TEdgeLabel>(graph);
|
|
464
|
+
const undirectedSeen = new Set<string>();
|
|
465
|
+
const usedEdgeIds = new Set<string>();
|
|
466
|
+
const edges: GraphEdgeTuple<TEdgeMeta, TEdgeLabel>[] = [];
|
|
467
|
+
|
|
468
|
+
for (const [source, neighbors] of Object.entries(graph.adj)) {
|
|
469
|
+
const sanitizedSource = sanitizeNodeId(source, 'edge-endpoint');
|
|
470
|
+
assertNodeExists(nodeMap, sanitizedSource, graph, 'source');
|
|
471
|
+
|
|
472
|
+
const nodeEdges = processTypedNodeEdges<
|
|
473
|
+
TNodeData,
|
|
474
|
+
TNodeMeta,
|
|
475
|
+
TNodeLabel,
|
|
476
|
+
TEdgeMeta,
|
|
477
|
+
TEdgeLabel
|
|
478
|
+
>(
|
|
479
|
+
sanitizedSource,
|
|
480
|
+
neighbors,
|
|
481
|
+
defaultEdgeType,
|
|
482
|
+
graph,
|
|
483
|
+
nodeMap,
|
|
484
|
+
undirectedSeen,
|
|
485
|
+
usedEdgeIds
|
|
486
|
+
);
|
|
487
|
+
|
|
488
|
+
edges.push(...nodeEdges);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
return {
|
|
492
|
+
nodes: Array.from(nodeMap.values()),
|
|
493
|
+
edges,
|
|
494
|
+
};
|
|
495
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { PositionedEdge } from '@graph-render/types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Group edges by their target node
|
|
5
|
+
*/
|
|
6
|
+
export const groupEdgesByTarget = (edges: PositionedEdge[]): Map<string, PositionedEdge[]> => {
|
|
7
|
+
const map = new Map<string, PositionedEdge[]>();
|
|
8
|
+
edges.forEach((edge) => {
|
|
9
|
+
const arr = map.get(edge.target) ?? [];
|
|
10
|
+
arr.push(edge);
|
|
11
|
+
map.set(edge.target, arr);
|
|
12
|
+
});
|
|
13
|
+
return map;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Sort edges by their source node position (y first, then x)
|
|
18
|
+
*/
|
|
19
|
+
export const sortEdgesBySourcePosition = (
|
|
20
|
+
edges: PositionedEdge[],
|
|
21
|
+
nodePositions: Map<string, { x: number; y: number }>
|
|
22
|
+
): PositionedEdge[] => {
|
|
23
|
+
return [...edges].sort((a, b) => {
|
|
24
|
+
const pa = nodePositions.get(a.source);
|
|
25
|
+
const pb = nodePositions.get(b.source);
|
|
26
|
+
if (pa && pb) {
|
|
27
|
+
if (pa.y !== pb.y) return pa.y - pb.y;
|
|
28
|
+
return pa.x - pb.x;
|
|
29
|
+
}
|
|
30
|
+
return 0;
|
|
31
|
+
});
|
|
32
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export { fromNxGraph, fromTypedNxGraph } from './graphParser';
|
|
2
|
+
export {
|
|
3
|
+
DEFAULT_THEME,
|
|
4
|
+
DEFAULT_NODE_SIZE,
|
|
5
|
+
DEFAULT_NODE_GAP,
|
|
6
|
+
DEFAULT_PADDING,
|
|
7
|
+
DEFAULT_NODE_WIDTH,
|
|
8
|
+
DEFAULT_NODE_HEIGHT,
|
|
9
|
+
DEFAULT_NODE_RADIUS,
|
|
10
|
+
DEFAULT_NODE_FILL,
|
|
11
|
+
DEFAULT_NODE_STROKE,
|
|
12
|
+
DEFAULT_TEXT_FILL,
|
|
13
|
+
DEFAULT_TEXT_SIZE,
|
|
14
|
+
} from './constants';
|
|
15
|
+
export { groupEdgesByTarget, sortEdgesBySourcePosition } from './graphTraversal';
|
|
16
|
+
export { applyNodeSizing } from './nodeSizing';
|
|
17
|
+
export { getMaxNodeDimensions, getMaxNodeHeight, getMaxNodeWidth } from './nodeMetrics';
|
|
18
|
+
export { normalizeGraphConfig } from './config';
|
|
19
|
+
export type { NormalizedGraphConfig } from './config';
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { NodeData } from '@graph-render/types';
|
|
2
|
+
import { DEFAULT_NODE_SIZE } from './constants';
|
|
3
|
+
|
|
4
|
+
export const getMaxNodeWidth = (nodes: NodeData[]): number => {
|
|
5
|
+
return nodes.reduce(
|
|
6
|
+
(max, node) => Math.max(max, node.size?.width ?? DEFAULT_NODE_SIZE.width),
|
|
7
|
+
0
|
|
8
|
+
);
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const getMaxNodeHeight = (nodes: NodeData[]): number => {
|
|
12
|
+
return nodes.reduce(
|
|
13
|
+
(max, node) => Math.max(max, node.size?.height ?? DEFAULT_NODE_SIZE.height),
|
|
14
|
+
0
|
|
15
|
+
);
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const getMaxNodeDimensions = (
|
|
19
|
+
nodes: NodeData[]
|
|
20
|
+
): { maxWidth: number; maxHeight: number } => ({
|
|
21
|
+
maxWidth: getMaxNodeWidth(nodes),
|
|
22
|
+
maxHeight: getMaxNodeHeight(nodes),
|
|
23
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { LayoutOptions, NodeData, Size } from '@graph-render/types';
|
|
2
|
+
import { DEFAULT_NODE_SIZE } from './constants';
|
|
3
|
+
|
|
4
|
+
const DEFAULT_PADDING_X = 18;
|
|
5
|
+
const DEFAULT_PADDING_Y = 12;
|
|
6
|
+
const DEFAULT_CHAR_WIDTH = 8;
|
|
7
|
+
const DEFAULT_LINE_HEIGHT = 18;
|
|
8
|
+
const MAX_MEASUREMENT_TEXT_LENGTH = 4_000;
|
|
9
|
+
const MAX_MEASUREMENT_LINES = 200;
|
|
10
|
+
const MAX_MEASUREMENT_CHARS_PER_LINE = 400;
|
|
11
|
+
|
|
12
|
+
const clampSize = (value: Size, fallback: Size): Size => ({
|
|
13
|
+
width: Number.isFinite(value.width) && value.width > 0 ? value.width : fallback.width,
|
|
14
|
+
height: Number.isFinite(value.height) && value.height > 0 ? value.height : fallback.height,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const getNodeLabel = (node: NodeData): string => {
|
|
18
|
+
if (typeof node.measurementHints?.label === 'string') {
|
|
19
|
+
return node.measurementHints.label;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (typeof node.label === 'string' || typeof node.label === 'number') {
|
|
23
|
+
return String(node.label);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return node.id;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const getMeasuredLines = (label: string): string[] => {
|
|
30
|
+
const truncatedLabel = label.slice(0, MAX_MEASUREMENT_TEXT_LENGTH);
|
|
31
|
+
const rawLines = truncatedLabel.split(/\r?\n/);
|
|
32
|
+
const measuredLines: string[] = [];
|
|
33
|
+
|
|
34
|
+
for (let index = 0; index < rawLines.length && measuredLines.length < MAX_MEASUREMENT_LINES; index += 1) {
|
|
35
|
+
const codePoints = Array.from(rawLines[index]);
|
|
36
|
+
if (!codePoints.length) {
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
measuredLines.push(codePoints.slice(0, MAX_MEASUREMENT_CHARS_PER_LINE).join(''));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return measuredLines.length ? measuredLines : [''];
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const estimateLabelSize = (node: NodeData, options: LayoutOptions): Size => {
|
|
47
|
+
const label = getNodeLabel(node);
|
|
48
|
+
const lines = getMeasuredLines(label);
|
|
49
|
+
const paddingX =
|
|
50
|
+
node.measurementHints?.paddingX ?? options.labelMeasurementPaddingX ?? DEFAULT_PADDING_X;
|
|
51
|
+
const paddingY =
|
|
52
|
+
node.measurementHints?.paddingY ?? options.labelMeasurementPaddingY ?? DEFAULT_PADDING_Y;
|
|
53
|
+
const charWidth =
|
|
54
|
+
node.measurementHints?.estimatedCharWidth ??
|
|
55
|
+
options.labelMeasurementCharWidth ??
|
|
56
|
+
DEFAULT_CHAR_WIDTH;
|
|
57
|
+
const lineHeight =
|
|
58
|
+
node.measurementHints?.lineHeight ?? options.labelMeasurementLineHeight ?? DEFAULT_LINE_HEIGHT;
|
|
59
|
+
let maxChars = 1;
|
|
60
|
+
for (const line of lines) {
|
|
61
|
+
const lineLength = Array.from(line).length;
|
|
62
|
+
if (lineLength > maxChars) {
|
|
63
|
+
maxChars = lineLength;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const lineCount = Math.max(1, lines.length);
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
width: Math.max(DEFAULT_NODE_SIZE.width, Math.ceil(maxChars * charWidth + paddingX * 2)),
|
|
70
|
+
height: Math.max(DEFAULT_NODE_SIZE.height, Math.ceil(lineCount * lineHeight + paddingY * 2)),
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const getResolvedSize = (node: NodeData, options: LayoutOptions): Size => {
|
|
75
|
+
const mode = node.sizeMode ?? options.nodeSizing ?? 'fixed';
|
|
76
|
+
const fixedSize = options.fixedNodeSize ?? DEFAULT_NODE_SIZE;
|
|
77
|
+
const explicitSize = node.size ? clampSize(node.size, fixedSize) : null;
|
|
78
|
+
const measuredSize = node.measuredSize ? clampSize(node.measuredSize, fixedSize) : null;
|
|
79
|
+
const estimatedSize = estimateLabelSize(node, options);
|
|
80
|
+
|
|
81
|
+
if (mode === 'measured') {
|
|
82
|
+
return measuredSize ?? explicitSize ?? estimatedSize;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (mode === 'label') {
|
|
86
|
+
return explicitSize ?? estimatedSize;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return explicitSize ?? fixedSize;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export const applyNodeSizing = (nodes: NodeData[], options: LayoutOptions): NodeData[] => {
|
|
93
|
+
return nodes.map((node) => ({
|
|
94
|
+
...node,
|
|
95
|
+
size: getResolvedSize(node, options),
|
|
96
|
+
}));
|
|
97
|
+
};
|
package/tsconfig.json
ADDED