@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.
- package/.eslintrc.json +6 -0
- package/CHANGELOG.md +58 -0
- package/README.md +0 -0
- package/dist/index.js +2509 -0
- package/dist/src/components/EdgePath.d.ts +25 -0
- package/dist/src/components/EdgePath.d.ts.map +1 -0
- package/dist/src/components/Graph.d.ts +6 -0
- package/dist/src/components/Graph.d.ts.map +1 -0
- package/dist/src/components/GraphLabels.d.ts +30 -0
- package/dist/src/components/GraphLabels.d.ts.map +1 -0
- package/dist/src/components/GraphNode.d.ts +35 -0
- package/dist/src/components/GraphNode.d.ts.map +1 -0
- package/dist/src/constants/defaults.d.ts +3 -0
- package/dist/src/constants/defaults.d.ts.map +1 -0
- package/dist/src/hooks/useGraphCollapse.d.ts +15 -0
- package/dist/src/hooks/useGraphCollapse.d.ts.map +1 -0
- package/dist/src/hooks/useGraphHover.d.ts +24 -0
- package/dist/src/hooks/useGraphHover.d.ts.map +1 -0
- package/dist/src/hooks/useGraphModel.d.ts +51 -0
- package/dist/src/hooks/useGraphModel.d.ts.map +1 -0
- package/dist/src/hooks/useGraphSearchState.d.ts +29 -0
- package/dist/src/hooks/useGraphSearchState.d.ts.map +1 -0
- package/dist/src/hooks/useStableConfig.d.ts +25 -0
- package/dist/src/hooks/useStableConfig.d.ts.map +1 -0
- package/dist/src/index.d.ts +7 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/utils/columns.d.ts +7 -0
- package/dist/src/utils/columns.d.ts.map +1 -0
- package/dist/src/utils/pathHighlight.d.ts +29 -0
- package/dist/src/utils/pathHighlight.d.ts.map +1 -0
- package/dist/src/utils/viewport.d.ts +14 -0
- package/dist/src/utils/viewport.d.ts.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/package.json +52 -0
- package/project.json +60 -0
- package/src/components/EdgePath.tsx +103 -0
- package/src/components/Graph.tsx +1545 -0
- package/src/components/GraphLabels.tsx +141 -0
- package/src/components/GraphNode.tsx +214 -0
- package/src/constants/defaults.ts +62 -0
- package/src/hooks/useGraphCollapse.ts +53 -0
- package/src/hooks/useGraphHover.ts +206 -0
- package/src/hooks/useGraphModel.ts +445 -0
- package/src/hooks/useGraphSearchState.ts +215 -0
- package/src/hooks/useStableConfig.ts +108 -0
- package/src/index.ts +57 -0
- package/src/utils/columns.ts +51 -0
- package/src/utils/pathHighlight.ts +160 -0
- package/src/utils/viewport.ts +100 -0
- package/tsconfig.json +12 -0
- package/tsconfig.node.json +11 -0
- 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
|
+
}
|
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
|
+
});
|