@graph-render/react 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 (52) hide show
  1. package/.eslintrc.json +6 -0
  2. package/CHANGELOG.md +58 -0
  3. package/README.md +0 -0
  4. package/dist/index.js +2509 -0
  5. package/dist/src/components/EdgePath.d.ts +25 -0
  6. package/dist/src/components/EdgePath.d.ts.map +1 -0
  7. package/dist/src/components/Graph.d.ts +6 -0
  8. package/dist/src/components/Graph.d.ts.map +1 -0
  9. package/dist/src/components/GraphLabels.d.ts +30 -0
  10. package/dist/src/components/GraphLabels.d.ts.map +1 -0
  11. package/dist/src/components/GraphNode.d.ts +35 -0
  12. package/dist/src/components/GraphNode.d.ts.map +1 -0
  13. package/dist/src/constants/defaults.d.ts +3 -0
  14. package/dist/src/constants/defaults.d.ts.map +1 -0
  15. package/dist/src/hooks/useGraphCollapse.d.ts +15 -0
  16. package/dist/src/hooks/useGraphCollapse.d.ts.map +1 -0
  17. package/dist/src/hooks/useGraphHover.d.ts +24 -0
  18. package/dist/src/hooks/useGraphHover.d.ts.map +1 -0
  19. package/dist/src/hooks/useGraphModel.d.ts +51 -0
  20. package/dist/src/hooks/useGraphModel.d.ts.map +1 -0
  21. package/dist/src/hooks/useGraphSearchState.d.ts +29 -0
  22. package/dist/src/hooks/useGraphSearchState.d.ts.map +1 -0
  23. package/dist/src/hooks/useStableConfig.d.ts +25 -0
  24. package/dist/src/hooks/useStableConfig.d.ts.map +1 -0
  25. package/dist/src/index.d.ts +7 -0
  26. package/dist/src/index.d.ts.map +1 -0
  27. package/dist/src/utils/columns.d.ts +7 -0
  28. package/dist/src/utils/columns.d.ts.map +1 -0
  29. package/dist/src/utils/pathHighlight.d.ts +29 -0
  30. package/dist/src/utils/pathHighlight.d.ts.map +1 -0
  31. package/dist/src/utils/viewport.d.ts +14 -0
  32. package/dist/src/utils/viewport.d.ts.map +1 -0
  33. package/dist/tsconfig.tsbuildinfo +1 -0
  34. package/package.json +52 -0
  35. package/project.json +60 -0
  36. package/src/components/EdgePath.tsx +103 -0
  37. package/src/components/Graph.tsx +1545 -0
  38. package/src/components/GraphLabels.tsx +141 -0
  39. package/src/components/GraphNode.tsx +214 -0
  40. package/src/constants/defaults.ts +62 -0
  41. package/src/hooks/useGraphCollapse.ts +53 -0
  42. package/src/hooks/useGraphHover.ts +206 -0
  43. package/src/hooks/useGraphModel.ts +445 -0
  44. package/src/hooks/useGraphSearchState.ts +215 -0
  45. package/src/hooks/useStableConfig.ts +108 -0
  46. package/src/index.ts +57 -0
  47. package/src/utils/columns.ts +51 -0
  48. package/src/utils/pathHighlight.ts +160 -0
  49. package/src/utils/viewport.ts +100 -0
  50. package/tsconfig.json +12 -0
  51. package/tsconfig.node.json +11 -0
  52. package/vite.config.ts +17 -0
@@ -0,0 +1,108 @@
1
+ import { useRef } from 'react';
2
+
3
+ const isPlainObject = (value: unknown): value is Record<string, unknown> => {
4
+ return Object.prototype.toString.call(value) === '[object Object]';
5
+ };
6
+
7
+ const areValuesEqual = (
8
+ left: unknown,
9
+ right: unknown,
10
+ seen: WeakMap<object, WeakSet<object>>
11
+ ): boolean => {
12
+ if (Object.is(left, right)) {
13
+ return true;
14
+ }
15
+
16
+ if (typeof left === 'function' || typeof right === 'function') {
17
+ return false;
18
+ }
19
+
20
+ if (!left || !right || typeof left !== 'object' || typeof right !== 'object') {
21
+ return false;
22
+ }
23
+
24
+ const existing = seen.get(left);
25
+ if (existing?.has(right)) {
26
+ return true;
27
+ }
28
+
29
+ if (Array.isArray(left) || Array.isArray(right)) {
30
+ if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) {
31
+ return false;
32
+ }
33
+
34
+ if (!existing) {
35
+ seen.set(left, new WeakSet([right]));
36
+ } else {
37
+ existing.add(right);
38
+ }
39
+
40
+ for (let index = 0; index < left.length; index += 1) {
41
+ if (!areValuesEqual(left[index], right[index], seen)) {
42
+ return false;
43
+ }
44
+ }
45
+
46
+ return true;
47
+ }
48
+
49
+ if (!isPlainObject(left) || !isPlainObject(right)) {
50
+ return false;
51
+ }
52
+
53
+ if (!existing) {
54
+ seen.set(left, new WeakSet([right]));
55
+ } else {
56
+ existing.add(right);
57
+ }
58
+
59
+ const leftKeys = Object.keys(left);
60
+ const rightKeys = Object.keys(right);
61
+ if (leftKeys.length !== rightKeys.length) {
62
+ return false;
63
+ }
64
+
65
+ for (const key of leftKeys) {
66
+ if (!(key in right) || !areValuesEqual(left[key], right[key], seen)) {
67
+ return false;
68
+ }
69
+ }
70
+
71
+ return true;
72
+ };
73
+
74
+ /**
75
+ * Returns the same reference for a config-like object as long as its
76
+ * serialized form has not changed.
77
+ *
78
+ * This prevents a common foot-gun where consumers pass an inline object
79
+ * literal as the `config` prop:
80
+ *
81
+ * <Graph config={{ layout: 'dag' }} ... />
82
+ *
83
+ * Without stabilization, every parent render creates a new object reference,
84
+ * which invalidates the normalizeGraphConfig memo and cascades through the
85
+ * entire graph model pipeline (layout, edge routing, etc.), causing full
86
+ * recomputes on every keystroke / scroll / hover in the parent tree.
87
+ *
88
+ * `GraphConfig` only contains JSON-serializable values (strings, numbers,
89
+ * booleans, string arrays, and theme objects with the same), so JSON.stringify
90
+ * is a reliable, cheap equality check here.
91
+ *
92
+ * Note: if `config` contains function-typed fields, those are intentionally
93
+ * excluded from the comparison (JSON.stringify omits them). Functions should
94
+ * be passed as separate, memoized props rather than embedded in the config
95
+ * object.
96
+ */
97
+ export function useStableConfig<T>(config: T): T {
98
+ const ref = useRef<T | null>(null);
99
+
100
+ if (ref.current === null || !areValuesEqual(ref.current, config, new WeakMap())) {
101
+ // Writing to a ref during render is safe when the write is idempotent
102
+ // and deterministic for the same inputs (standard React ref-as-cache
103
+ // pattern).
104
+ ref.current = config;
105
+ }
106
+
107
+ return ref.current;
108
+ }
package/src/index.ts ADDED
@@ -0,0 +1,57 @@
1
+ export { EdgeType, LayoutType, LayoutDirection, NodeSide } from '@graph-render/types';
2
+ export type {
3
+ DragState,
4
+ EdgeComponent,
5
+ EdgeData,
6
+ EdgeId,
7
+ EdgePathProps,
8
+ EdgeRenderer,
9
+ EdgeRoutingContext,
10
+ GraphConfig,
11
+ GraphControlsPosition,
12
+ GraphHandle,
13
+ GraphHoverMeta,
14
+ GraphProps,
15
+ GraphRenderContext,
16
+ GraphSearchResults,
17
+ GraphSelection,
18
+ GraphTheme,
19
+ GraphTopology,
20
+ GraphViewport,
21
+ LayoutOptions,
22
+ NodeData,
23
+ NodeId,
24
+ NodeMeasurementHints,
25
+ NodeRenderer,
26
+ NodeSizingMode,
27
+ NxEdgeAttrs,
28
+ NxGraphInput,
29
+ NxNodeAttrs,
30
+ PathHoverOptions,
31
+ PathTraversalOptions,
32
+ PathTraversalResult,
33
+ Point,
34
+ PositionedEdge,
35
+ PositionedNode,
36
+ RenderConfig,
37
+ RenderGraphToSvgOptions,
38
+ RenderGraphToSvgResult,
39
+ RenderTheme,
40
+ RouteEdgesOptions,
41
+ Size,
42
+ TreeMetrics,
43
+ VertexComponent,
44
+ VertexComponentProps,
45
+ } from '@graph-render/types';
46
+ export { Graph } from './components/Graph';
47
+ export { EdgePath } from './components/EdgePath';
48
+ export { groupPositionedNodesByColumn } from './utils/columns';
49
+
50
+ // Re-export core functionality
51
+ export {
52
+ renderGraphToSvg,
53
+ layoutNodes,
54
+ routeEdges,
55
+ fromNxGraph,
56
+ buildEdgePath,
57
+ } from '@graph-render/core';
@@ -0,0 +1,51 @@
1
+ import { PositionedNode } from '@graph-render/types';
2
+
3
+ const DEFAULT_NODE_WIDTH = 180;
4
+ const DEFAULT_COLUMN_TOLERANCE = 24;
5
+
6
+ export interface NodeColumn<TNode extends PositionedNode = PositionedNode> {
7
+ centerX: number;
8
+ nodes: TNode[];
9
+ }
10
+
11
+ const getNodeWidth = (node: PositionedNode): number => node.size?.width ?? DEFAULT_NODE_WIDTH;
12
+
13
+ const getNodeCenterX = (node: PositionedNode): number => node.position.x + getNodeWidth(node) / 2;
14
+
15
+ export const groupPositionedNodesByColumn = <TNode extends PositionedNode = PositionedNode>(
16
+ nodes: TNode[],
17
+ tolerance: number = DEFAULT_COLUMN_TOLERANCE
18
+ ): NodeColumn<TNode>[] => {
19
+ const sortedNodes = [...nodes].sort((left, right) => getNodeCenterX(left) - getNodeCenterX(right));
20
+ const columns: Array<{ centerX: number; avgWidth: number; nodes: TNode[] }> = [];
21
+
22
+ sortedNodes.forEach((node) => {
23
+ const nodeCenterX = getNodeCenterX(node);
24
+ const nodeWidth = getNodeWidth(node);
25
+ const current = columns[columns.length - 1];
26
+
27
+ if (!current) {
28
+ columns.push({ centerX: nodeCenterX, avgWidth: nodeWidth, nodes: [node] });
29
+ return;
30
+ }
31
+
32
+ const threshold = Math.max(tolerance, Math.min(current.avgWidth, nodeWidth) * 0.35);
33
+ if (Math.abs(nodeCenterX - current.centerX) > threshold) {
34
+ columns.push({ centerX: nodeCenterX, avgWidth: nodeWidth, nodes: [node] });
35
+ return;
36
+ }
37
+
38
+ current.nodes.push(node);
39
+ // FIX: replaced O(k) reduce-based recompute with an O(1) incremental
40
+ // running average. The previous approach re-summed the entire column on
41
+ // every push, making the full grouping O(n×k) for large graphs.
42
+ const newCount = current.nodes.length; // length after push
43
+ current.centerX = (current.centerX * (newCount - 1) + nodeCenterX) / newCount;
44
+ current.avgWidth = (current.avgWidth * (newCount - 1) + nodeWidth) / newCount;
45
+ });
46
+
47
+ return columns.map((column) => ({
48
+ centerX: column.centerX,
49
+ nodes: [...column.nodes].sort((left, right) => left.position.y - right.position.y),
50
+ }));
51
+ };
@@ -0,0 +1,160 @@
1
+ import { EdgeId, PathTraversalResult, PositionedEdge } from '@graph-render/types';
2
+
3
+ export interface FocusedPath {
4
+ nodeId: string;
5
+ sourceIndex: number | null;
6
+ pathKey?: string;
7
+ }
8
+
9
+ type NodeWithPathMeta = {
10
+ id: string;
11
+ meta?: Record<string, unknown>;
12
+ };
13
+
14
+ function normalizePathKey(value: string): string {
15
+ return value.trim().toLowerCase();
16
+ }
17
+
18
+ export function extractPathKeysFromNodes(nodes: NodeWithPathMeta[]): Map<string, string[]> {
19
+ const map = new Map<string, string[]>();
20
+
21
+ nodes.forEach((node) => {
22
+ const meta = node.meta;
23
+ if (!meta) {
24
+ return;
25
+ }
26
+
27
+ const rawPathKeys = meta.pathKeys;
28
+
29
+ // Only read the generic `pathKeys` field. Domain-specific metadata (e.g.
30
+ // tournament `players`) must be mapped to `pathKeys` by the consuming
31
+ // package before the graph is passed to Graph — see react-tournament-tree
32
+ // TournamentBracket for an example.
33
+ const pathKeys = Array.isArray(rawPathKeys) ? rawPathKeys : [];
34
+
35
+ const normalized = pathKeys
36
+ .map((entry) => {
37
+ if (typeof entry === 'string') {
38
+ return entry;
39
+ }
40
+
41
+ if (
42
+ typeof entry === 'object' &&
43
+ entry !== null &&
44
+ 'name' in entry &&
45
+ typeof entry.name === 'string'
46
+ ) {
47
+ return entry.name;
48
+ }
49
+
50
+ return null;
51
+ })
52
+ .filter((value): value is string => typeof value === 'string')
53
+ .map((value) => value.trim())
54
+ .filter((value) => value.length > 0);
55
+
56
+ if (normalized.length) {
57
+ map.set(node.id, normalized);
58
+ }
59
+ });
60
+
61
+ return map;
62
+ }
63
+
64
+ export function traverseHighlightedPath(options: {
65
+ startNodeId: string;
66
+ sourceIndex?: number | null;
67
+ pathKey?: string;
68
+ incomingEdgesByTarget: Map<string, PositionedEdge[]>;
69
+ pathKeysByNode?: Map<string, string[]>;
70
+ /**
71
+ * Hard upper bound on the number of nodes visited. Prevents the traversal
72
+ * from freezing the UI on dense graphs when neither a pathKey nor a valid
73
+ * sourceIndex is supplied and the algorithm falls back to following all
74
+ * incoming edges.
75
+ *
76
+ * Defaults to 500.
77
+ */
78
+ maxNodes?: number;
79
+ }): PathTraversalResult {
80
+ const {
81
+ startNodeId,
82
+ sourceIndex,
83
+ pathKey,
84
+ incomingEdgesByTarget,
85
+ pathKeysByNode,
86
+ maxNodes = 500,
87
+ } = options;
88
+
89
+ const nodes = new Set<string>([startNodeId]);
90
+ const edges = new Set<EdgeId>();
91
+ // Track which (nodeId, sourceIndex, pathKey) combinations have been pushed
92
+ // onto the stack so that a node reached via multiple paths is not processed
93
+ // more than once — preventing exponential fan-out on dense DAGs.
94
+ const visitedKeys = new Set<string>([`${startNodeId}|${sourceIndex ?? ''}|${pathKey ?? ''}`]);
95
+ const stack: Array<{ nodeId: string; sourceIndex: number | null; pathKey?: string }> = [
96
+ { nodeId: startNodeId, sourceIndex: sourceIndex ?? null, pathKey },
97
+ ];
98
+
99
+ while (stack.length) {
100
+ // Hard cap to prevent blocking the main thread on dense graphs where the
101
+ // fallback "follow all incoming edges" path would visit the entire graph.
102
+ if (nodes.size >= maxNodes) {
103
+ break;
104
+ }
105
+
106
+ const current = stack.pop();
107
+ if (!current) {
108
+ continue;
109
+ }
110
+
111
+ const incoming = incomingEdgesByTarget.get(current.nodeId) ?? [];
112
+ const normalizedKey = current.pathKey ? normalizePathKey(current.pathKey) : null;
113
+ let chosen: PositionedEdge[] = [];
114
+
115
+ if (normalizedKey && pathKeysByNode) {
116
+ chosen = incoming.filter((edge) => {
117
+ const keys = pathKeysByNode.get(edge.source) ?? [];
118
+ return keys.some((candidate) => normalizePathKey(candidate) === normalizedKey);
119
+ });
120
+ }
121
+
122
+ if (!chosen.length) {
123
+ if (current.sourceIndex == null) {
124
+ chosen = incoming;
125
+ } else if (incoming[current.sourceIndex]) {
126
+ chosen = [incoming[current.sourceIndex]];
127
+ }
128
+ }
129
+
130
+ chosen.forEach((edge) => {
131
+ if (edges.has(edge.id)) {
132
+ return;
133
+ }
134
+
135
+ edges.add(edge.id);
136
+ nodes.add(edge.source);
137
+
138
+ const sourceKeys = pathKeysByNode?.get(edge.source) ?? [];
139
+ const nextSourceIndex = normalizedKey
140
+ ? sourceKeys.findIndex((candidate) => normalizePathKey(candidate) === normalizedKey)
141
+ : current.sourceIndex;
142
+ const resolvedSourceIndex =
143
+ typeof nextSourceIndex === 'number' && nextSourceIndex >= 0
144
+ ? nextSourceIndex
145
+ : current.sourceIndex;
146
+
147
+ const visitKey = `${edge.source}|${resolvedSourceIndex ?? ''}|${current.pathKey ?? ''}`;
148
+ if (!visitedKeys.has(visitKey)) {
149
+ visitedKeys.add(visitKey);
150
+ stack.push({
151
+ nodeId: edge.source,
152
+ sourceIndex: resolvedSourceIndex,
153
+ pathKey: current.pathKey,
154
+ });
155
+ }
156
+ });
157
+ }
158
+
159
+ return { nodes, edges };
160
+ }
@@ -0,0 +1,100 @@
1
+ import { GraphViewport, PositionedNode } from '@graph-render/types';
2
+
3
+ export interface GraphBounds {
4
+ minX: number;
5
+ minY: number;
6
+ maxX: number;
7
+ maxY: number;
8
+ width: number;
9
+ height: number;
10
+ }
11
+
12
+ const DEFAULT_NODE_WIDTH = 180;
13
+ const DEFAULT_NODE_HEIGHT = 72;
14
+
15
+ export const clampZoom = (zoom: number, minZoom: number, maxZoom: number): number => {
16
+ return Math.min(Math.max(zoom, minZoom), maxZoom);
17
+ };
18
+
19
+ export const getGraphBounds = (nodes: PositionedNode[]): GraphBounds | null => {
20
+ if (!nodes.length) {
21
+ return null;
22
+ }
23
+
24
+ const bounds = nodes.reduce<GraphBounds>(
25
+ (acc, node) => {
26
+ const width = node.size?.width ?? DEFAULT_NODE_WIDTH;
27
+ const height = node.size?.height ?? DEFAULT_NODE_HEIGHT;
28
+ const minX = Math.min(acc.minX, node.position.x);
29
+ const minY = Math.min(acc.minY, node.position.y);
30
+ const maxX = Math.max(acc.maxX, node.position.x + width);
31
+ const maxY = Math.max(acc.maxY, node.position.y + height);
32
+
33
+ return {
34
+ minX,
35
+ minY,
36
+ maxX,
37
+ maxY,
38
+ width: maxX - minX,
39
+ height: maxY - minY,
40
+ };
41
+ },
42
+ {
43
+ minX: Number.POSITIVE_INFINITY,
44
+ minY: Number.POSITIVE_INFINITY,
45
+ maxX: Number.NEGATIVE_INFINITY,
46
+ maxY: Number.NEGATIVE_INFINITY,
47
+ width: 0,
48
+ height: 0,
49
+ }
50
+ );
51
+
52
+ return bounds;
53
+ };
54
+
55
+ export const getFitViewport = (
56
+ bounds: GraphBounds | null,
57
+ width: number,
58
+ height: number,
59
+ padding: number,
60
+ minZoom: number,
61
+ maxZoom: number
62
+ ): GraphViewport => {
63
+ if (!bounds || width <= 0 || height <= 0) {
64
+ return { x: 0, y: 0, zoom: 1 };
65
+ }
66
+
67
+ const safeWidth = Math.max(bounds.width, 1);
68
+ const safeHeight = Math.max(bounds.height, 1);
69
+ const availableWidth = Math.max(width - padding * 2, 1);
70
+ const availableHeight = Math.max(height - padding * 2, 1);
71
+ const zoom = clampZoom(
72
+ Math.min(availableWidth / safeWidth, availableHeight / safeHeight),
73
+ minZoom,
74
+ maxZoom
75
+ );
76
+
77
+ return {
78
+ x: padding + (availableWidth - safeWidth * zoom) / 2 - bounds.minX * zoom,
79
+ y: padding + (availableHeight - safeHeight * zoom) / 2 - bounds.minY * zoom,
80
+ zoom,
81
+ };
82
+ };
83
+
84
+ export const centerViewportOnNode = (
85
+ node: PositionedNode,
86
+ width: number,
87
+ height: number,
88
+ currentZoom: number
89
+ ): GraphViewport => {
90
+ const nodeWidth = node.size?.width ?? DEFAULT_NODE_WIDTH;
91
+ const nodeHeight = node.size?.height ?? DEFAULT_NODE_HEIGHT;
92
+ const centerX = node.position.x + nodeWidth / 2;
93
+ const centerY = node.position.y + nodeHeight / 2;
94
+
95
+ return {
96
+ x: width / 2 - centerX * currentZoom,
97
+ y: height / 2 - centerY * currentZoom,
98
+ zoom: currentZoom,
99
+ };
100
+ };
package/tsconfig.json ADDED
@@ -0,0 +1,12 @@
1
+ {
2
+ "extends": "../../tsconfig.react.json",
3
+ "compilerOptions": {
4
+ "composite": true,
5
+ "outDir": "./dist",
6
+ "emitDeclarationOnly": false,
7
+ "allowImportingTsExtensions": false
8
+ },
9
+ "include": ["src"],
10
+ "exclude": ["dist", "node_modules"],
11
+ "references": [{ "path": "./tsconfig.node.json" }, { "path": "../types" }, { "path": "../core-graph-render" }]
12
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "composite": true,
7
+ "allowSyntheticDefaultImports": true,
8
+ "types": []
9
+ },
10
+ "include": ["vite.config.ts"]
11
+ }
package/vite.config.ts ADDED
@@ -0,0 +1,17 @@
1
+ import { defineConfig } from 'vite';
2
+ import react from '@vitejs/plugin-react';
3
+
4
+ export default defineConfig({
5
+ plugins: [react()],
6
+ build: {
7
+ lib: {
8
+ entry: './src/index.ts',
9
+ name: 'ReactGraphRender',
10
+ formats: ['es'],
11
+ fileName: () => 'index.js',
12
+ },
13
+ rollupOptions: {
14
+ external: ['react', 'react-dom', '@graph-render/core', '@graph-render/types'],
15
+ },
16
+ },
17
+ });