@opendata-ai/openchart-react 2.0.0
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/README.md +95 -0
- package/dist/index.d.ts +292 -0
- package/dist/index.js +574 -0
- package/dist/index.js.map +1 -0
- package/package.json +63 -0
- package/src/Chart.tsx +191 -0
- package/src/DataTable.tsx +165 -0
- package/src/Graph.tsx +186 -0
- package/src/ThemeContext.tsx +40 -0
- package/src/Visualization.tsx +54 -0
- package/src/__tests__/Chart.test.tsx +134 -0
- package/src/__tests__/DataTable.test.tsx +126 -0
- package/src/__tests__/Graph.test.tsx +130 -0
- package/src/__tests__/ThemeContext.test.tsx +238 -0
- package/src/__tests__/hooks.test.tsx +160 -0
- package/src/__tests__/update-vs-remount.test.tsx +133 -0
- package/src/__tests__/useTableState.test.tsx +218 -0
- package/src/hooks/useGraph.ts +85 -0
- package/src/hooks/useTable.ts +98 -0
- package/src/hooks/useTableState.ts +76 -0
- package/src/hooks.ts +145 -0
- package/src/index.ts +37 -0
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@opendata-ai/openchart-react",
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "React components for openchart: <Chart />, <DataTable />, <VizThemeProvider />",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"author": "Riley Hilliard",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/tryopendata/openchart.git",
|
|
10
|
+
"directory": "packages/react"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/tryopendata/openchart#readme",
|
|
13
|
+
"bugs": "https://github.com/tryopendata/openchart/issues",
|
|
14
|
+
"publishConfig": {
|
|
15
|
+
"access": "public"
|
|
16
|
+
},
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=18"
|
|
19
|
+
},
|
|
20
|
+
"type": "module",
|
|
21
|
+
"main": "dist/index.js",
|
|
22
|
+
"types": "dist/index.d.ts",
|
|
23
|
+
"exports": {
|
|
24
|
+
".": {
|
|
25
|
+
"types": "./dist/index.d.ts",
|
|
26
|
+
"import": "./dist/index.js"
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"dist",
|
|
31
|
+
"src"
|
|
32
|
+
],
|
|
33
|
+
"sideEffects": false,
|
|
34
|
+
"keywords": [
|
|
35
|
+
"chart",
|
|
36
|
+
"visualization",
|
|
37
|
+
"react",
|
|
38
|
+
"data-table",
|
|
39
|
+
"svg",
|
|
40
|
+
"d3",
|
|
41
|
+
"declarative"
|
|
42
|
+
],
|
|
43
|
+
"scripts": {
|
|
44
|
+
"build": "tsup",
|
|
45
|
+
"test": "vitest run",
|
|
46
|
+
"typecheck": "tsc --noEmit"
|
|
47
|
+
},
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"@opendata-ai/openchart-core": "2.0.0",
|
|
50
|
+
"@opendata-ai/openchart-engine": "2.0.0",
|
|
51
|
+
"@opendata-ai/openchart-vanilla": "2.0.0"
|
|
52
|
+
},
|
|
53
|
+
"peerDependencies": {
|
|
54
|
+
"react": ">=18.0.0",
|
|
55
|
+
"react-dom": ">=18.0.0"
|
|
56
|
+
},
|
|
57
|
+
"devDependencies": {
|
|
58
|
+
"@types/react": "^19.0.0",
|
|
59
|
+
"@types/react-dom": "^19.0.0",
|
|
60
|
+
"react": "^19.0.0",
|
|
61
|
+
"react-dom": "^19.0.0"
|
|
62
|
+
}
|
|
63
|
+
}
|
package/src/Chart.tsx
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React Chart component: thin wrapper around the vanilla adapter.
|
|
3
|
+
*
|
|
4
|
+
* Mounts a chart instance on render, updates when spec changes,
|
|
5
|
+
* and cleans up on unmount. All heavy lifting is done by the vanilla
|
|
6
|
+
* createChart() function.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type {
|
|
10
|
+
ChartEventHandlers,
|
|
11
|
+
ChartSpec,
|
|
12
|
+
DarkMode,
|
|
13
|
+
GraphSpec,
|
|
14
|
+
ThemeConfig,
|
|
15
|
+
} from '@opendata-ai/openchart-core';
|
|
16
|
+
import { type ChartInstance, createChart, type MountOptions } from '@opendata-ai/openchart-vanilla';
|
|
17
|
+
import { type CSSProperties, useCallback, useEffect, useRef } from 'react';
|
|
18
|
+
import { useVizDarkMode, useVizTheme } from './ThemeContext';
|
|
19
|
+
|
|
20
|
+
export interface ChartProps extends ChartEventHandlers {
|
|
21
|
+
/** The visualization spec to render. */
|
|
22
|
+
spec: ChartSpec | GraphSpec;
|
|
23
|
+
/** Theme overrides. */
|
|
24
|
+
theme?: ThemeConfig;
|
|
25
|
+
/** Dark mode: "auto", "force", or "off". */
|
|
26
|
+
darkMode?: DarkMode;
|
|
27
|
+
/** Callback when a data point is clicked. @deprecated Use onMarkClick instead. */
|
|
28
|
+
onDataPointClick?: (data: Record<string, unknown>) => void;
|
|
29
|
+
/** CSS class name for the wrapper div. */
|
|
30
|
+
className?: string;
|
|
31
|
+
/** Inline styles for the wrapper div. */
|
|
32
|
+
style?: CSSProperties;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* React component that renders a visualization from a spec.
|
|
37
|
+
*
|
|
38
|
+
* Uses the vanilla adapter internally. The spec is compiled and rendered
|
|
39
|
+
* as SVG inside a wrapper div. Spec changes trigger re-renders via the
|
|
40
|
+
* vanilla adapter's update() method.
|
|
41
|
+
*/
|
|
42
|
+
export function Chart({
|
|
43
|
+
spec,
|
|
44
|
+
theme: themeProp,
|
|
45
|
+
darkMode,
|
|
46
|
+
onDataPointClick,
|
|
47
|
+
onMarkClick,
|
|
48
|
+
onMarkHover,
|
|
49
|
+
onMarkLeave,
|
|
50
|
+
onLegendToggle,
|
|
51
|
+
onAnnotationClick,
|
|
52
|
+
onAnnotationEdit,
|
|
53
|
+
onEdit,
|
|
54
|
+
className,
|
|
55
|
+
style,
|
|
56
|
+
}: ChartProps) {
|
|
57
|
+
const contextTheme = useVizTheme();
|
|
58
|
+
const contextDarkMode = useVizDarkMode();
|
|
59
|
+
const theme = themeProp ?? contextTheme;
|
|
60
|
+
const resolvedDarkMode = darkMode ?? contextDarkMode;
|
|
61
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
62
|
+
const chartRef = useRef<ChartInstance | null>(null);
|
|
63
|
+
const specRef = useRef<string>('');
|
|
64
|
+
|
|
65
|
+
// Store event handlers in refs so they don't trigger chart recreation.
|
|
66
|
+
// Inline arrow functions create new references every render, which would
|
|
67
|
+
// destroy and recreate the entire chart instance without this pattern.
|
|
68
|
+
const handlersRef = useRef<{
|
|
69
|
+
onDataPointClick?: ChartProps['onDataPointClick'];
|
|
70
|
+
onMarkClick?: ChartProps['onMarkClick'];
|
|
71
|
+
onMarkHover?: ChartProps['onMarkHover'];
|
|
72
|
+
onMarkLeave?: ChartProps['onMarkLeave'];
|
|
73
|
+
onLegendToggle?: ChartProps['onLegendToggle'];
|
|
74
|
+
onAnnotationClick?: ChartProps['onAnnotationClick'];
|
|
75
|
+
onAnnotationEdit?: ChartProps['onAnnotationEdit'];
|
|
76
|
+
onEdit?: ChartProps['onEdit'];
|
|
77
|
+
}>({});
|
|
78
|
+
handlersRef.current = {
|
|
79
|
+
onDataPointClick,
|
|
80
|
+
onMarkClick,
|
|
81
|
+
onMarkHover,
|
|
82
|
+
onMarkLeave,
|
|
83
|
+
onLegendToggle,
|
|
84
|
+
onAnnotationClick,
|
|
85
|
+
onAnnotationEdit,
|
|
86
|
+
onEdit,
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// Stable callback wrappers that read from refs
|
|
90
|
+
const stableOnDataPointClick = useCallback(
|
|
91
|
+
(data: Record<string, unknown>) => handlersRef.current.onDataPointClick?.(data),
|
|
92
|
+
[],
|
|
93
|
+
);
|
|
94
|
+
const stableOnMarkClick = useCallback(
|
|
95
|
+
(event: import('@opendata-ai/openchart-core').MarkEvent) =>
|
|
96
|
+
handlersRef.current.onMarkClick?.(event),
|
|
97
|
+
[],
|
|
98
|
+
);
|
|
99
|
+
const stableOnMarkHover = useCallback(
|
|
100
|
+
(event: import('@opendata-ai/openchart-core').MarkEvent) =>
|
|
101
|
+
handlersRef.current.onMarkHover?.(event),
|
|
102
|
+
[],
|
|
103
|
+
);
|
|
104
|
+
const stableOnMarkLeave = useCallback(() => handlersRef.current.onMarkLeave?.(), []);
|
|
105
|
+
const stableOnLegendToggle = useCallback(
|
|
106
|
+
(series: string, visible: boolean) => handlersRef.current.onLegendToggle?.(series, visible),
|
|
107
|
+
[],
|
|
108
|
+
);
|
|
109
|
+
const stableOnAnnotationClick = useCallback(
|
|
110
|
+
(annotation: import('@opendata-ai/openchart-core').Annotation, event: MouseEvent) =>
|
|
111
|
+
handlersRef.current.onAnnotationClick?.(annotation, event),
|
|
112
|
+
[],
|
|
113
|
+
);
|
|
114
|
+
const stableOnAnnotationEdit = useCallback(
|
|
115
|
+
(
|
|
116
|
+
annotation: import('@opendata-ai/openchart-core').TextAnnotation,
|
|
117
|
+
updatedOffset: import('@opendata-ai/openchart-core').AnnotationOffset,
|
|
118
|
+
) => handlersRef.current.onAnnotationEdit?.(annotation, updatedOffset),
|
|
119
|
+
[],
|
|
120
|
+
);
|
|
121
|
+
const stableOnEdit = useCallback(
|
|
122
|
+
(edit: import('@opendata-ai/openchart-core').ElementEdit) => handlersRef.current.onEdit?.(edit),
|
|
123
|
+
[],
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
// Mount chart and recreate when theme/darkMode change.
|
|
127
|
+
// Event handlers use stable refs so they don't trigger recreation.
|
|
128
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: spec intentionally excluded - spec changes handled via update() in Effect 2
|
|
129
|
+
useEffect(() => {
|
|
130
|
+
const container = containerRef.current;
|
|
131
|
+
if (!container) return;
|
|
132
|
+
|
|
133
|
+
const options: MountOptions = {
|
|
134
|
+
theme,
|
|
135
|
+
darkMode: resolvedDarkMode,
|
|
136
|
+
onDataPointClick: stableOnDataPointClick,
|
|
137
|
+
onMarkClick: stableOnMarkClick,
|
|
138
|
+
onMarkHover: stableOnMarkHover,
|
|
139
|
+
onMarkLeave: stableOnMarkLeave,
|
|
140
|
+
onLegendToggle: stableOnLegendToggle,
|
|
141
|
+
onAnnotationClick: stableOnAnnotationClick,
|
|
142
|
+
// Only include editing callbacks when the consumer provides them.
|
|
143
|
+
// The stable wrappers are always truthy, so gating on handlersRef
|
|
144
|
+
// avoids adding unstable prop references to the effect deps.
|
|
145
|
+
...(handlersRef.current.onAnnotationEdit ? { onAnnotationEdit: stableOnAnnotationEdit } : {}),
|
|
146
|
+
...(handlersRef.current.onEdit ? { onEdit: stableOnEdit } : {}),
|
|
147
|
+
responsive: true,
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
chartRef.current = createChart(container, spec, options);
|
|
151
|
+
specRef.current = JSON.stringify(spec);
|
|
152
|
+
|
|
153
|
+
return () => {
|
|
154
|
+
chartRef.current?.destroy();
|
|
155
|
+
chartRef.current = null;
|
|
156
|
+
};
|
|
157
|
+
// Only recreate when theme or darkMode change. Event handlers use stable refs.
|
|
158
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
159
|
+
}, [
|
|
160
|
+
theme,
|
|
161
|
+
resolvedDarkMode,
|
|
162
|
+
stableOnAnnotationClick,
|
|
163
|
+
stableOnDataPointClick,
|
|
164
|
+
stableOnEdit,
|
|
165
|
+
stableOnLegendToggle,
|
|
166
|
+
stableOnMarkClick,
|
|
167
|
+
stableOnMarkHover,
|
|
168
|
+
stableOnMarkLeave,
|
|
169
|
+
stableOnAnnotationEdit,
|
|
170
|
+
]);
|
|
171
|
+
|
|
172
|
+
// Update chart when spec changes
|
|
173
|
+
useEffect(() => {
|
|
174
|
+
const chart = chartRef.current;
|
|
175
|
+
if (!chart) return;
|
|
176
|
+
|
|
177
|
+
const specString = JSON.stringify(spec);
|
|
178
|
+
if (specString !== specRef.current) {
|
|
179
|
+
specRef.current = specString;
|
|
180
|
+
chart.update(spec);
|
|
181
|
+
}
|
|
182
|
+
}, [spec]);
|
|
183
|
+
|
|
184
|
+
return (
|
|
185
|
+
<div
|
|
186
|
+
ref={containerRef}
|
|
187
|
+
className={className ? `viz-chart-root ${className}` : 'viz-chart-root'}
|
|
188
|
+
style={style}
|
|
189
|
+
/>
|
|
190
|
+
);
|
|
191
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DataTable component: React wrapper around the vanilla table adapter.
|
|
3
|
+
*
|
|
4
|
+
* Mounts a table instance on render, updates when spec changes,
|
|
5
|
+
* and cleans up on unmount. Supports both controlled and uncontrolled modes
|
|
6
|
+
* for sort, search, and pagination state.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { DarkMode, SortState, TableSpec, ThemeConfig } from '@opendata-ai/openchart-core';
|
|
10
|
+
import {
|
|
11
|
+
createTable,
|
|
12
|
+
type TableInstance,
|
|
13
|
+
type TableMountOptions,
|
|
14
|
+
} from '@opendata-ai/openchart-vanilla';
|
|
15
|
+
import { type CSSProperties, useCallback, useEffect, useRef } from 'react';
|
|
16
|
+
import { useVizDarkMode, useVizTheme } from './ThemeContext';
|
|
17
|
+
|
|
18
|
+
export interface DataTableProps {
|
|
19
|
+
/** The table spec to render. */
|
|
20
|
+
spec: TableSpec;
|
|
21
|
+
/** Theme overrides. */
|
|
22
|
+
theme?: ThemeConfig;
|
|
23
|
+
/** Dark mode: "auto", "force", or "off". */
|
|
24
|
+
darkMode?: DarkMode;
|
|
25
|
+
/** Row click handler. */
|
|
26
|
+
onRowClick?: (row: Record<string, unknown>) => void;
|
|
27
|
+
/** Callback when sort changes. */
|
|
28
|
+
onSortChange?: (sort: SortState | null) => void;
|
|
29
|
+
/** Callback when search changes. */
|
|
30
|
+
onSearchChange?: (query: string) => void;
|
|
31
|
+
/** Callback when page changes. */
|
|
32
|
+
onPageChange?: (page: number) => void;
|
|
33
|
+
/** CSS class name for the wrapper div. */
|
|
34
|
+
className?: string;
|
|
35
|
+
/** Inline styles for the wrapper div. */
|
|
36
|
+
style?: CSSProperties;
|
|
37
|
+
/** Controlled sort state. */
|
|
38
|
+
sort?: SortState | null;
|
|
39
|
+
/** Controlled search query. */
|
|
40
|
+
search?: string;
|
|
41
|
+
/** Controlled page number. */
|
|
42
|
+
page?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* React component that renders a data table from a TableSpec.
|
|
47
|
+
*
|
|
48
|
+
* Uses the vanilla adapter internally. Supports controlled and uncontrolled
|
|
49
|
+
* modes for sort, search, and pagination state.
|
|
50
|
+
*/
|
|
51
|
+
export function DataTable({
|
|
52
|
+
spec,
|
|
53
|
+
theme: themeProp,
|
|
54
|
+
darkMode,
|
|
55
|
+
onRowClick,
|
|
56
|
+
onSortChange,
|
|
57
|
+
onSearchChange,
|
|
58
|
+
onPageChange,
|
|
59
|
+
className,
|
|
60
|
+
style,
|
|
61
|
+
sort,
|
|
62
|
+
search,
|
|
63
|
+
page,
|
|
64
|
+
}: DataTableProps) {
|
|
65
|
+
const contextTheme = useVizTheme();
|
|
66
|
+
const contextDarkMode = useVizDarkMode();
|
|
67
|
+
const theme = themeProp ?? contextTheme;
|
|
68
|
+
const resolvedDarkMode = darkMode ?? contextDarkMode;
|
|
69
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
70
|
+
const tableRef = useRef<TableInstance | null>(null);
|
|
71
|
+
|
|
72
|
+
// Store event handlers in refs so they don't trigger table recreation.
|
|
73
|
+
const handlersRef = useRef<{
|
|
74
|
+
onRowClick?: DataTableProps['onRowClick'];
|
|
75
|
+
onSortChange?: DataTableProps['onSortChange'];
|
|
76
|
+
onSearchChange?: DataTableProps['onSearchChange'];
|
|
77
|
+
onPageChange?: DataTableProps['onPageChange'];
|
|
78
|
+
}>({});
|
|
79
|
+
handlersRef.current = { onRowClick, onSortChange, onSearchChange, onPageChange };
|
|
80
|
+
|
|
81
|
+
// Stable callback wrappers that read from refs
|
|
82
|
+
const stableOnRowClick = useCallback(
|
|
83
|
+
(row: Record<string, unknown>) => handlersRef.current.onRowClick?.(row),
|
|
84
|
+
[],
|
|
85
|
+
);
|
|
86
|
+
const stableOnStateChange = useCallback(
|
|
87
|
+
(state: { sort?: SortState | null; search?: string; page?: number }) => {
|
|
88
|
+
if (state.sort !== undefined) handlersRef.current.onSortChange?.(state.sort);
|
|
89
|
+
if (state.search !== undefined) handlersRef.current.onSearchChange?.(state.search);
|
|
90
|
+
if (state.page !== undefined) handlersRef.current.onPageChange?.(state.page);
|
|
91
|
+
},
|
|
92
|
+
[],
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
const prevSpecRef = useRef<string>('');
|
|
96
|
+
|
|
97
|
+
// Determine if we're in controlled mode
|
|
98
|
+
const isControlled = sort !== undefined || search !== undefined || page !== undefined;
|
|
99
|
+
|
|
100
|
+
// Effect 1: Mount/unmount. Only recreate when structural options change.
|
|
101
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: spec, sort, search, page intentionally excluded - handled via update()/setState() in Effects 2-3
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
const container = containerRef.current;
|
|
104
|
+
if (!container) return;
|
|
105
|
+
|
|
106
|
+
const mountOptions: TableMountOptions = {
|
|
107
|
+
theme,
|
|
108
|
+
darkMode: resolvedDarkMode,
|
|
109
|
+
onRowClick: stableOnRowClick,
|
|
110
|
+
responsive: true,
|
|
111
|
+
onStateChange: stableOnStateChange,
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
if (isControlled) {
|
|
115
|
+
mountOptions.externalState = {
|
|
116
|
+
sort: sort ?? null,
|
|
117
|
+
search: search ?? '',
|
|
118
|
+
page: page ?? 0,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
tableRef.current = createTable(container, spec, mountOptions);
|
|
123
|
+
prevSpecRef.current = JSON.stringify(spec);
|
|
124
|
+
|
|
125
|
+
return () => {
|
|
126
|
+
tableRef.current?.destroy();
|
|
127
|
+
tableRef.current = null;
|
|
128
|
+
};
|
|
129
|
+
// Only recreate on structural option changes (theme, darkMode, onRowClick).
|
|
130
|
+
// Controlled state updates are handled in Effect 2.
|
|
131
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
132
|
+
}, [theme, resolvedDarkMode, isControlled, stableOnRowClick, stableOnStateChange]);
|
|
133
|
+
|
|
134
|
+
// Effect 2: Sync controlled state without remounting.
|
|
135
|
+
useEffect(() => {
|
|
136
|
+
const table = tableRef.current;
|
|
137
|
+
if (!table || !isControlled) return;
|
|
138
|
+
|
|
139
|
+
table.setState({
|
|
140
|
+
sort: sort ?? null,
|
|
141
|
+
search: search ?? '',
|
|
142
|
+
page: page ?? 0,
|
|
143
|
+
});
|
|
144
|
+
}, [sort, search, page, isControlled]);
|
|
145
|
+
|
|
146
|
+
// Effect 3: Sync spec changes via update().
|
|
147
|
+
useEffect(() => {
|
|
148
|
+
const table = tableRef.current;
|
|
149
|
+
if (!table) return;
|
|
150
|
+
|
|
151
|
+
const specString = JSON.stringify(spec);
|
|
152
|
+
if (specString !== prevSpecRef.current) {
|
|
153
|
+
prevSpecRef.current = specString;
|
|
154
|
+
table.update(spec);
|
|
155
|
+
}
|
|
156
|
+
}, [spec]);
|
|
157
|
+
|
|
158
|
+
return (
|
|
159
|
+
<div
|
|
160
|
+
ref={containerRef}
|
|
161
|
+
className={className ? `viz-table-root ${className}` : 'viz-table-root'}
|
|
162
|
+
style={style}
|
|
163
|
+
/>
|
|
164
|
+
);
|
|
165
|
+
}
|
package/src/Graph.tsx
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React Graph component: thin wrapper around the vanilla adapter.
|
|
3
|
+
*
|
|
4
|
+
* Mounts a graph instance on render, updates when spec changes,
|
|
5
|
+
* and cleans up on unmount. All heavy lifting is done by the vanilla
|
|
6
|
+
* createGraph() function.
|
|
7
|
+
*
|
|
8
|
+
* Supports forwardRef for imperative control via useGraph() hook.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { DarkMode, GraphSpec, ThemeConfig } from '@opendata-ai/openchart-core';
|
|
12
|
+
import {
|
|
13
|
+
createGraph,
|
|
14
|
+
type GraphInstance,
|
|
15
|
+
type GraphMountOptions,
|
|
16
|
+
} from '@opendata-ai/openchart-vanilla';
|
|
17
|
+
import {
|
|
18
|
+
type CSSProperties,
|
|
19
|
+
forwardRef,
|
|
20
|
+
useCallback,
|
|
21
|
+
useEffect,
|
|
22
|
+
useImperativeHandle,
|
|
23
|
+
useRef,
|
|
24
|
+
} from 'react';
|
|
25
|
+
import type { GraphHandle } from './hooks/useGraph';
|
|
26
|
+
import { useVizDarkMode, useVizTheme } from './ThemeContext';
|
|
27
|
+
|
|
28
|
+
export interface GraphProps {
|
|
29
|
+
/** The graph spec to render. */
|
|
30
|
+
spec: GraphSpec;
|
|
31
|
+
/** Theme overrides. */
|
|
32
|
+
theme?: ThemeConfig;
|
|
33
|
+
/** Dark mode: "auto", "force", or "off". */
|
|
34
|
+
darkMode?: DarkMode;
|
|
35
|
+
/** Callback when a node is clicked. */
|
|
36
|
+
onNodeClick?: (node: Record<string, unknown>) => void;
|
|
37
|
+
/** Callback when a node is double-clicked. */
|
|
38
|
+
onNodeDoubleClick?: (node: Record<string, unknown>) => void;
|
|
39
|
+
/** Callback when selection changes. */
|
|
40
|
+
onSelectionChange?: (nodeIds: string[]) => void;
|
|
41
|
+
/** CSS class name for the wrapper div. */
|
|
42
|
+
className?: string;
|
|
43
|
+
/** Inline styles for the wrapper div. */
|
|
44
|
+
style?: CSSProperties;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* React component that renders a force-directed graph from a GraphSpec.
|
|
49
|
+
*
|
|
50
|
+
* Uses the vanilla adapter internally. The spec is compiled and rendered
|
|
51
|
+
* on a canvas inside a wrapper div. Spec changes trigger re-renders via the
|
|
52
|
+
* vanilla adapter's update() method.
|
|
53
|
+
*
|
|
54
|
+
* Supports ref for imperative control via useGraph() hook:
|
|
55
|
+
* ```tsx
|
|
56
|
+
* const { ref, search, zoomToFit } = useGraph();
|
|
57
|
+
* return <Graph ref={ref} spec={spec} />;
|
|
58
|
+
* ```
|
|
59
|
+
*/
|
|
60
|
+
export const Graph = forwardRef<GraphHandle, GraphProps>(function Graph(
|
|
61
|
+
{
|
|
62
|
+
spec,
|
|
63
|
+
theme: themeProp,
|
|
64
|
+
darkMode,
|
|
65
|
+
onNodeClick,
|
|
66
|
+
onNodeDoubleClick,
|
|
67
|
+
onSelectionChange,
|
|
68
|
+
className,
|
|
69
|
+
style,
|
|
70
|
+
},
|
|
71
|
+
ref,
|
|
72
|
+
) {
|
|
73
|
+
const contextTheme = useVizTheme();
|
|
74
|
+
const contextDarkMode = useVizDarkMode();
|
|
75
|
+
const theme = themeProp ?? contextTheme;
|
|
76
|
+
const resolvedDarkMode = darkMode ?? contextDarkMode;
|
|
77
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
78
|
+
const graphRef = useRef<GraphInstance | null>(null);
|
|
79
|
+
const specRef = useRef<string>('');
|
|
80
|
+
|
|
81
|
+
// Store event handlers in refs so they don't trigger graph recreation.
|
|
82
|
+
// Inline arrow functions create new references every render, which would
|
|
83
|
+
// destroy and recreate the entire graph instance without this pattern.
|
|
84
|
+
const handlersRef = useRef<{
|
|
85
|
+
onNodeClick?: GraphProps['onNodeClick'];
|
|
86
|
+
onNodeDoubleClick?: GraphProps['onNodeDoubleClick'];
|
|
87
|
+
onSelectionChange?: GraphProps['onSelectionChange'];
|
|
88
|
+
}>({});
|
|
89
|
+
handlersRef.current = { onNodeClick, onNodeDoubleClick, onSelectionChange };
|
|
90
|
+
|
|
91
|
+
// Stable callback wrappers that read from refs
|
|
92
|
+
const stableOnNodeClick = useCallback(
|
|
93
|
+
(node: Record<string, unknown>) => handlersRef.current.onNodeClick?.(node),
|
|
94
|
+
[],
|
|
95
|
+
);
|
|
96
|
+
const stableOnNodeDoubleClick = useCallback(
|
|
97
|
+
(node: Record<string, unknown>) => handlersRef.current.onNodeDoubleClick?.(node),
|
|
98
|
+
[],
|
|
99
|
+
);
|
|
100
|
+
const stableOnSelectionChange = useCallback(
|
|
101
|
+
(nodeIds: string[]) => handlersRef.current.onSelectionChange?.(nodeIds),
|
|
102
|
+
[],
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
// Expose imperative handle for useGraph() hook
|
|
106
|
+
useImperativeHandle(
|
|
107
|
+
ref,
|
|
108
|
+
() => ({
|
|
109
|
+
search(query: string) {
|
|
110
|
+
graphRef.current?.search(query);
|
|
111
|
+
},
|
|
112
|
+
clearSearch() {
|
|
113
|
+
graphRef.current?.clearSearch();
|
|
114
|
+
},
|
|
115
|
+
zoomToFit() {
|
|
116
|
+
graphRef.current?.zoomToFit();
|
|
117
|
+
},
|
|
118
|
+
zoomToNode(nodeId: string) {
|
|
119
|
+
graphRef.current?.zoomToNode(nodeId);
|
|
120
|
+
},
|
|
121
|
+
selectNode(nodeId: string) {
|
|
122
|
+
graphRef.current?.selectNode(nodeId);
|
|
123
|
+
},
|
|
124
|
+
getSelectedNodes() {
|
|
125
|
+
return graphRef.current?.getSelectedNodes() ?? [];
|
|
126
|
+
},
|
|
127
|
+
get instance() {
|
|
128
|
+
return graphRef.current;
|
|
129
|
+
},
|
|
130
|
+
}),
|
|
131
|
+
[],
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
// Mount graph and recreate when theme/darkMode change.
|
|
135
|
+
// Event handlers use stable refs so they don't trigger recreation.
|
|
136
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: spec intentionally excluded - spec changes handled via update() in Effect 2
|
|
137
|
+
useEffect(() => {
|
|
138
|
+
const container = containerRef.current;
|
|
139
|
+
if (!container) return;
|
|
140
|
+
|
|
141
|
+
const options: GraphMountOptions = {
|
|
142
|
+
theme,
|
|
143
|
+
darkMode: resolvedDarkMode,
|
|
144
|
+
onNodeClick: stableOnNodeClick,
|
|
145
|
+
onNodeDoubleClick: stableOnNodeDoubleClick,
|
|
146
|
+
onSelectionChange: stableOnSelectionChange,
|
|
147
|
+
responsive: true,
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
graphRef.current = createGraph(container, spec, options);
|
|
151
|
+
specRef.current = JSON.stringify(spec);
|
|
152
|
+
|
|
153
|
+
return () => {
|
|
154
|
+
graphRef.current?.destroy();
|
|
155
|
+
graphRef.current = null;
|
|
156
|
+
};
|
|
157
|
+
// Only recreate when theme or darkMode change. Event handlers use stable refs.
|
|
158
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
159
|
+
}, [
|
|
160
|
+
theme,
|
|
161
|
+
resolvedDarkMode,
|
|
162
|
+
stableOnNodeClick,
|
|
163
|
+
stableOnNodeDoubleClick,
|
|
164
|
+
stableOnSelectionChange,
|
|
165
|
+
]);
|
|
166
|
+
|
|
167
|
+
// Update graph when spec changes
|
|
168
|
+
useEffect(() => {
|
|
169
|
+
const graph = graphRef.current;
|
|
170
|
+
if (!graph) return;
|
|
171
|
+
|
|
172
|
+
const specString = JSON.stringify(spec);
|
|
173
|
+
if (specString !== specRef.current) {
|
|
174
|
+
specRef.current = specString;
|
|
175
|
+
graph.update(spec);
|
|
176
|
+
}
|
|
177
|
+
}, [spec]);
|
|
178
|
+
|
|
179
|
+
return (
|
|
180
|
+
<div
|
|
181
|
+
ref={containerRef}
|
|
182
|
+
className={className ? `viz-graph-root ${className}` : 'viz-graph-root'}
|
|
183
|
+
style={style}
|
|
184
|
+
/>
|
|
185
|
+
);
|
|
186
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Theme context: provides a theme and dark mode preference to all
|
|
3
|
+
* descendant Chart, DataTable, and Graph components without prop drilling.
|
|
4
|
+
*
|
|
5
|
+
* Components use the context values as fallbacks when no explicit
|
|
6
|
+
* `theme` or `darkMode` prop is passed.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { DarkMode, ThemeConfig } from '@opendata-ai/openchart-core';
|
|
10
|
+
import { createContext, type ReactNode, useContext } from 'react';
|
|
11
|
+
|
|
12
|
+
const VizThemeContext = createContext<ThemeConfig | undefined>(undefined);
|
|
13
|
+
const VizDarkModeContext = createContext<DarkMode | undefined>(undefined);
|
|
14
|
+
|
|
15
|
+
/** Read the current theme from the nearest VizThemeProvider. */
|
|
16
|
+
export function useVizTheme(): ThemeConfig | undefined {
|
|
17
|
+
return useContext(VizThemeContext);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Read the current dark mode preference from the nearest VizThemeProvider. */
|
|
21
|
+
export function useVizDarkMode(): DarkMode | undefined {
|
|
22
|
+
return useContext(VizDarkModeContext);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface VizThemeProviderProps {
|
|
26
|
+
/** Theme config to provide to descendant viz components. */
|
|
27
|
+
theme: ThemeConfig | undefined;
|
|
28
|
+
/** Dark mode preference to provide to descendant viz components. */
|
|
29
|
+
darkMode?: DarkMode;
|
|
30
|
+
children: ReactNode;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Provides a theme and dark mode preference to all nested Chart, DataTable, and Graph components. */
|
|
34
|
+
export function VizThemeProvider({ theme, darkMode, children }: VizThemeProviderProps) {
|
|
35
|
+
return (
|
|
36
|
+
<VizThemeContext.Provider value={theme}>
|
|
37
|
+
<VizDarkModeContext.Provider value={darkMode}>{children}</VizDarkModeContext.Provider>
|
|
38
|
+
</VizThemeContext.Provider>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Visualization routing component: renders Chart, DataTable, or Graph
|
|
3
|
+
* based on the spec type. Use this when rendering arbitrary VizSpec values.
|
|
4
|
+
*
|
|
5
|
+
* For event handlers, use the specific component (Chart, DataTable, Graph) directly.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { DarkMode, ThemeConfig, VizSpec } from '@opendata-ai/openchart-core';
|
|
9
|
+
import { isGraphSpec, isTableSpec } from '@opendata-ai/openchart-core';
|
|
10
|
+
import type { CSSProperties } from 'react';
|
|
11
|
+
import { Chart } from './Chart';
|
|
12
|
+
import { DataTable } from './DataTable';
|
|
13
|
+
import { Graph } from './Graph';
|
|
14
|
+
|
|
15
|
+
export interface VisualizationProps {
|
|
16
|
+
/** The visualization spec to render. */
|
|
17
|
+
spec: VizSpec;
|
|
18
|
+
/** Theme overrides. */
|
|
19
|
+
theme?: ThemeConfig;
|
|
20
|
+
/** Dark mode: "auto", "force", or "off". */
|
|
21
|
+
darkMode?: DarkMode;
|
|
22
|
+
/** CSS class name for the wrapper div. */
|
|
23
|
+
className?: string;
|
|
24
|
+
/** Inline styles for the wrapper div. */
|
|
25
|
+
style?: CSSProperties;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Routes a VizSpec to the appropriate rendering component.
|
|
30
|
+
*
|
|
31
|
+
* Accepts any VizSpec and renders it with the correct component based on the
|
|
32
|
+
* spec's type field. For event handlers, use Chart, DataTable, or Graph directly.
|
|
33
|
+
*/
|
|
34
|
+
export function Visualization({ spec, theme, darkMode, className, style }: VisualizationProps) {
|
|
35
|
+
if (isTableSpec(spec)) {
|
|
36
|
+
return (
|
|
37
|
+
<DataTable
|
|
38
|
+
spec={spec}
|
|
39
|
+
theme={theme}
|
|
40
|
+
darkMode={darkMode}
|
|
41
|
+
className={className}
|
|
42
|
+
style={style}
|
|
43
|
+
/>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
if (isGraphSpec(spec)) {
|
|
47
|
+
return (
|
|
48
|
+
<Graph spec={spec} theme={theme} darkMode={darkMode} className={className} style={style} />
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
return (
|
|
52
|
+
<Chart spec={spec} theme={theme} darkMode={darkMode} className={className} style={style} />
|
|
53
|
+
);
|
|
54
|
+
}
|