@simplex-viz/react 0.1.1 → 0.1.3
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 +297 -20
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -1,43 +1,320 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://raw.githubusercontent.com/ghackenberg/simplex-viz/main/docs/assets/logo-react.png" width="640" alt="SimplexViz / react — @simplex-viz/react" />
|
|
3
|
+
</p>
|
|
4
|
+
|
|
1
5
|
# @simplex-viz/react
|
|
2
6
|
|
|
3
7
|
End-to-end type-safe React bindings, declarative JSX node renderers, and imperative controller hooks for `@simplex-viz/core` and `@simplex-viz/svg`.
|
|
4
8
|
|
|
5
9
|
- **Live Demo:** <https://hackenberg.tech/simplex-viz/>
|
|
6
10
|
- **Source & Monorepo:** <https://github.com/ghackenberg/simplex-viz/tree/main/packages/react>
|
|
11
|
+
- **Companion Packages:** [`@simplex-viz/core`](https://www.npmjs.com/package/@simplex-viz/core) · [`@simplex-viz/svg`](https://www.npmjs.com/package/@simplex-viz/svg)
|
|
12
|
+
|
|
13
|
+
## Declarative JSX Slot Architecture
|
|
14
|
+
|
|
15
|
+
In React, `<SimplexTopicGraph />` lets you drop custom JSX directly into cluster and content slots instead of hand-writing imperative SVG DOM code. The wireframe preview below illustrates how those two component slots populate a single visualization:
|
|
16
|
+
|
|
17
|
+
<p align="center">
|
|
18
|
+
<img src="https://raw.githubusercontent.com/ghackenberg/simplex-viz/main/docs/assets/preview-react.png" width="100%" alt="@simplex-viz/react preview showing minimalist renderClusterNode and renderContentNode JSX slot placeholders with automatic getBBox() collision sizing" />
|
|
19
|
+
</p>
|
|
20
|
+
|
|
21
|
+
*Figure: Architectural slot layout rendered with `@simplex-viz/react`. Dashed topic cards illustrate the `renderClusterNode` JSX slot positioned on a subtle 60° simplex mesh, while minimalist skeleton content cards represent `renderContentNode` slots complete with color accents, type/time metadata, and interactive selection highlights (amber for pinned, cyan for hovered). Every JSX element is measured automatically via offscreen `getBBox()` so custom React SVG cards remain strictly overlap-free.*
|
|
22
|
+
|
|
23
|
+
## Features
|
|
24
|
+
|
|
25
|
+
- **`<SimplexTopicGraph />`**: Responsive, zero-overlap triangulation graph component that automatically observes container resizing, computes cluster layouts, and drives 60 fps spring animations.
|
|
26
|
+
- **Two rendering modes** (mix and match or swap live with smooth crossfading):
|
|
27
|
+
1. **Declarative JSX Renderers** (`renderContentNode`, `renderClusterNode`): Write standard React SVG JSX (`<g>`, `<rect>`, `<text>`, `<circle>`). Nodes are automatically measured via offscreen `getBBox()` for collision-free placement.
|
|
28
|
+
2. **Portable `SvgNodeView` Plugins** (`contentView`, `clusterView` from `@simplex-viz/svg`): Direct SVG DOM updates with zero React reconciliation overhead per animation frame.
|
|
29
|
+
- **Full generic type inference**: Infers `<TItem, TContentVisual, TClusterVisual, TTagMeta, TClusterMeta>` end-to-end across `items`, `getId`, `getTags`, `deriveClusterMeta`, `visualResolver`, `renderContentNode`, `renderClusterNode`, hover events, and click targets.
|
|
30
|
+
- **`useSimplexGraphController`**: Imperative hook for live item mutations (`addItems`, `removeItems`, `setItems`), camera controls (`zoomBy`, `resetCamera`), and telemetry getters.
|
|
7
31
|
|
|
8
32
|
## Installation
|
|
9
33
|
|
|
34
|
+
Install the React wrapper together with its core engine and SVG scene-graph peer packages (`react >= 18.0.0` and `react-dom >= 18.0.0`):
|
|
35
|
+
|
|
10
36
|
```bash
|
|
11
37
|
npm install @simplex-viz/core @simplex-viz/svg @simplex-viz/react
|
|
12
38
|
```
|
|
13
39
|
|
|
14
|
-
## Quickstart
|
|
40
|
+
## Quickstart: Declarative JSX Node Renderers
|
|
41
|
+
|
|
42
|
+
### Step 1 — Define Domain, Visual, and Cluster Metadata Types
|
|
43
|
+
|
|
44
|
+
Describe your domain item (`Article`), visual cache descriptors (`ArticleVisual`, `ClusterVisual`), and aggregate cluster stats (`ClusterStats`). TypeScript preserves these types across every callback prop on `<SimplexTopicGraph />`:
|
|
45
|
+
|
|
46
|
+
```tsx
|
|
47
|
+
interface Article {
|
|
48
|
+
readonly slug: string;
|
|
49
|
+
readonly title: string;
|
|
50
|
+
readonly hashtags: readonly string[];
|
|
51
|
+
readonly readingMinutes: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface ArticleVisual {
|
|
55
|
+
readonly key: string;
|
|
56
|
+
readonly title: string;
|
|
57
|
+
readonly colorHex: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface ClusterVisual {
|
|
61
|
+
readonly key: string;
|
|
62
|
+
readonly code: string;
|
|
63
|
+
readonly label: string;
|
|
64
|
+
readonly colorHex: string;
|
|
65
|
+
readonly count: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface ClusterStats {
|
|
69
|
+
readonly count: number;
|
|
70
|
+
readonly avgMinutes: number;
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### Step 2 — Define Declarative JSX Node Renderers
|
|
75
|
+
|
|
76
|
+
Write standard SVG JSX centered around `(0, 0)` for individual items and topic clusters. `@simplex-viz/react` measures the resulting `<g>` subtree automatically via offscreen `getBBox()` whenever `getVisualCacheKey` changes, guaranteeing collision-free card placement:
|
|
77
|
+
|
|
78
|
+
```tsx
|
|
79
|
+
import type {
|
|
80
|
+
ClusterDescriptor,
|
|
81
|
+
ContentLayoutContext,
|
|
82
|
+
NodeRenderState,
|
|
83
|
+
} from '@simplex-viz/core';
|
|
84
|
+
|
|
85
|
+
const renderArticleNode = (
|
|
86
|
+
article: Article,
|
|
87
|
+
ctx: ContentLayoutContext<Article, undefined, ClusterStats>,
|
|
88
|
+
state: NodeRenderState<ArticleVisual>,
|
|
89
|
+
) => (
|
|
90
|
+
<g>
|
|
91
|
+
<rect
|
|
92
|
+
x={-54}
|
|
93
|
+
y={-15}
|
|
94
|
+
width={108}
|
|
95
|
+
height={30}
|
|
96
|
+
rx={6}
|
|
97
|
+
fill="#0f172a"
|
|
98
|
+
stroke={state.interaction.isPinned ? '#f59e0b' : ctx.mixedColorHex}
|
|
99
|
+
strokeWidth={state.interaction.isHovered ? 2 : 1.2}
|
|
100
|
+
/>
|
|
101
|
+
<text textAnchor="middle" y={4} fill="#f8fafc" fontSize={9}>
|
|
102
|
+
{article.title.slice(0, 16)}
|
|
103
|
+
</text>
|
|
104
|
+
</g>
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
const renderTopicCluster = (
|
|
108
|
+
cluster: ClusterDescriptor<Article, undefined, ClusterStats>,
|
|
109
|
+
state: NodeRenderState<ClusterVisual>,
|
|
110
|
+
) => (
|
|
111
|
+
<g>
|
|
112
|
+
<rect
|
|
113
|
+
x={-68}
|
|
114
|
+
y={-20}
|
|
115
|
+
width={136}
|
|
116
|
+
height={40}
|
|
117
|
+
rx={8}
|
|
118
|
+
fill="#090d16"
|
|
119
|
+
stroke={state.interaction.isHovered ? '#f8fafc' : cluster.colorHex}
|
|
120
|
+
strokeWidth={1.6}
|
|
121
|
+
/>
|
|
122
|
+
<text textAnchor="middle" y={-2} fill="#f8fafc" fontSize={10} fontWeight={600}>
|
|
123
|
+
{`${cluster.code} · ${cluster.defaultLabel}`}
|
|
124
|
+
</text>
|
|
125
|
+
<text textAnchor="middle" y={12} fill="#94a3b8" fontSize={8.5}>
|
|
126
|
+
{`${cluster.meta.count} items · avg ${cluster.meta.avgMinutes.toFixed(0)}m`}
|
|
127
|
+
</text>
|
|
128
|
+
</g>
|
|
129
|
+
);
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
### Step 3 — Initialize State, Controller Hook & Memoized `VisualResolver`
|
|
133
|
+
|
|
134
|
+
Inside your React component, set up selection state, create a typed controller handle with `useSimplexGraphController`, and memoize `visualResolver` with `useMemo` so re-renders never trigger unintended crossfade transitions:
|
|
135
|
+
|
|
136
|
+
```tsx
|
|
137
|
+
import { useMemo, useState } from 'react';
|
|
138
|
+
import {
|
|
139
|
+
SimplexTopicGraph,
|
|
140
|
+
useSimplexGraphController,
|
|
141
|
+
} from '@simplex-viz/react';
|
|
142
|
+
import type { VisualResolver } from '@simplex-viz/core';
|
|
143
|
+
|
|
144
|
+
export function KnowledgeAtlas({ articles }: { articles: readonly Article[] }) {
|
|
145
|
+
const [focusedClusterUid, setFocusedClusterUid] = useState<string | null>(null);
|
|
146
|
+
const [pinnedIds, setPinnedIds] = useState<Set<string>>(() => new Set());
|
|
147
|
+
|
|
148
|
+
const { controllerRef, handle } = useSimplexGraphController<
|
|
149
|
+
Article,
|
|
150
|
+
ArticleVisual,
|
|
151
|
+
ClusterVisual,
|
|
152
|
+
undefined,
|
|
153
|
+
ClusterStats
|
|
154
|
+
>();
|
|
155
|
+
|
|
156
|
+
const visualResolver = useMemo<
|
|
157
|
+
VisualResolver<Article, ArticleVisual, ClusterVisual, undefined, ClusterStats>
|
|
158
|
+
>(
|
|
159
|
+
() => ({
|
|
160
|
+
resolveContentVisual: (article, ctx) => ({
|
|
161
|
+
key: `${article.slug}:${ctx.mixedColorHex}`,
|
|
162
|
+
title: article.title,
|
|
163
|
+
colorHex: ctx.mixedColorHex,
|
|
164
|
+
}),
|
|
165
|
+
resolveClusterVisual: (cluster) => ({
|
|
166
|
+
key: `${cluster.clusterUid}:${cluster.meta.count}`,
|
|
167
|
+
code: cluster.code,
|
|
168
|
+
label: cluster.defaultLabel,
|
|
169
|
+
colorHex: cluster.colorHex,
|
|
170
|
+
count: cluster.meta.count,
|
|
171
|
+
}),
|
|
172
|
+
getVisualCacheKey: (visual) => visual.key,
|
|
173
|
+
}),
|
|
174
|
+
[],
|
|
175
|
+
);
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
### Step 4 — Render `<SimplexTopicGraph />` with Camera Toolbar & Click Handling
|
|
179
|
+
|
|
180
|
+
Finally, wire the camera toolbar (`handle.zoomBy`, `handle.resetCamera`), pass the JSX renderers from Step 2, and handle left-clicks to toggle pinned items or isolate clicked topic clusters:
|
|
181
|
+
|
|
182
|
+
```tsx
|
|
183
|
+
return (
|
|
184
|
+
<div style={{ width: '100%', height: '640px', position: 'relative' }}>
|
|
185
|
+
<div style={{ position: 'absolute', top: 12, left: 12, zIndex: 10 }}>
|
|
186
|
+
<button onClick={() => handle.zoomBy(1.2)}>Zoom In</button>
|
|
187
|
+
<button onClick={() => handle.zoomBy(0.8)}>Zoom Out</button>
|
|
188
|
+
<button onClick={() => handle.resetCamera()}>Reset Camera</button>
|
|
189
|
+
</div>
|
|
190
|
+
|
|
191
|
+
<SimplexTopicGraph
|
|
192
|
+
controllerRef={controllerRef}
|
|
193
|
+
items={articles}
|
|
194
|
+
getId={(a) => a.slug}
|
|
195
|
+
getTags={(a) => a.hashtags}
|
|
196
|
+
deriveClusterMeta={(ctx) => ({
|
|
197
|
+
count: ctx.members.length,
|
|
198
|
+
avgMinutes:
|
|
199
|
+
ctx.members.reduce((sum, m) => sum + m.item.readingMinutes, 0) /
|
|
200
|
+
Math.max(1, ctx.members.length),
|
|
201
|
+
})}
|
|
202
|
+
visualResolver={visualResolver}
|
|
203
|
+
pinnedIds={pinnedIds}
|
|
204
|
+
focusedClusterUid={focusedClusterUid}
|
|
205
|
+
renderContentNode={renderArticleNode}
|
|
206
|
+
renderClusterNode={renderTopicCluster}
|
|
207
|
+
onLeftClick={(target) => {
|
|
208
|
+
if (target.kind === 'content') {
|
|
209
|
+
setPinnedIds((prev) => {
|
|
210
|
+
const next = new Set(prev);
|
|
211
|
+
if (next.has(target.item.slug)) next.delete(target.item.slug);
|
|
212
|
+
else next.add(target.item.slug);
|
|
213
|
+
return next;
|
|
214
|
+
});
|
|
215
|
+
} else if (target.kind === 'cluster') {
|
|
216
|
+
setFocusedClusterUid((cur) =>
|
|
217
|
+
cur === target.cluster.clusterUid ? null : target.cluster.clusterUid,
|
|
218
|
+
);
|
|
219
|
+
} else {
|
|
220
|
+
setFocusedClusterUid(null);
|
|
221
|
+
}
|
|
222
|
+
}}
|
|
223
|
+
/>
|
|
224
|
+
</div>
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
## Using Portable `SvgNodeView` Plugins (60 fps Without JSX Re-Renders)
|
|
230
|
+
|
|
231
|
+
For larger graphs or built-in shape morphing without per-frame JSX overhead, instantiate `GeometricShapeSvgView` and `ClusterAnchorSvgView` once outside the component and pass them via `contentView` and `clusterView` instead of `renderContentNode` / `renderClusterNode`:
|
|
15
232
|
|
|
16
233
|
```tsx
|
|
17
|
-
import { SimplexTopicGraph
|
|
234
|
+
import { SimplexTopicGraph } from '@simplex-viz/react';
|
|
235
|
+
import {
|
|
236
|
+
GeometricShapeSvgView,
|
|
237
|
+
ClusterAnchorSvgView,
|
|
238
|
+
type GeometricShapeVisual,
|
|
239
|
+
type ClusterAnchorVisual,
|
|
240
|
+
} from '@simplex-viz/svg';
|
|
18
241
|
|
|
19
|
-
|
|
20
|
-
|
|
242
|
+
const contentView = new GeometricShapeSvgView();
|
|
243
|
+
const clusterView = new ClusterAnchorSvgView();
|
|
21
244
|
|
|
245
|
+
export function FastGraph({ items, visualResolver }) {
|
|
22
246
|
return (
|
|
23
|
-
<SimplexTopicGraph
|
|
24
|
-
controllerRef={controllerRef}
|
|
247
|
+
<SimplexTopicGraph<MyItem, GeometricShapeVisual, ClusterAnchorVisual>
|
|
25
248
|
items={items}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
renderContentNode={(item, ctx) => (
|
|
30
|
-
<g>
|
|
31
|
-
<circle r={10} fill={ctx.mixedColorHex} />
|
|
32
|
-
</g>
|
|
33
|
-
)}
|
|
34
|
-
renderClusterNode={(cluster) => (
|
|
35
|
-
<g>
|
|
36
|
-
<rect x={-50} y={-16} width={100} height={32} rx={6} fill="#0f172a" stroke={cluster.colorHex} />
|
|
37
|
-
<text textAnchor="middle" y={4} fill="#f8fafc">{cluster.code}</text>
|
|
38
|
-
</g>
|
|
39
|
-
)}
|
|
249
|
+
visualResolver={visualResolver}
|
|
250
|
+
contentView={contentView}
|
|
251
|
+
clusterView={clusterView}
|
|
40
252
|
/>
|
|
41
253
|
);
|
|
42
254
|
}
|
|
43
255
|
```
|
|
256
|
+
|
|
257
|
+
> **Tip:** Memoize `visualResolver` (e.g., via `useMemo`) and keep `contentView` / `clusterView` instances stable outside render to avoid unnecessary crossfades. Changing `visualResolver` or switching view props intentionally triggers a smooth crossfade/morph transition.
|
|
258
|
+
|
|
259
|
+
## `<SimplexTopicGraph />` Props Reference
|
|
260
|
+
|
|
261
|
+
### Data, Typing & Metadata
|
|
262
|
+
| Prop | Required | Description |
|
|
263
|
+
| --- | --- | --- |
|
|
264
|
+
| `items` | Yes | `readonly TItem[]` — Domain items to cluster and lay out. |
|
|
265
|
+
| `getId` | If `TItem` lacks `id` | `(item: TItem) => string` — Extracts a unique ID per item. |
|
|
266
|
+
| `getTags` | If `TItem` lacks `tags` | `(item: TItem) => readonly string[]` — Extracts raw tag strings per item. |
|
|
267
|
+
| `normalizeTag` | No | `(rawTag: string) => string` — Custom tag normalizer (defaults to lowercase trim + `#` strip). |
|
|
268
|
+
| `resolveTagMeta` | No | `(normalizedTag: string) => TTagMeta \| undefined` — Attaches domain metadata to each tag. |
|
|
269
|
+
| `deriveClusterMeta` | If `TClusterMeta` is defined | `(ctx) => TClusterMeta` — Derives aggregate metadata once per cluster update. |
|
|
270
|
+
|
|
271
|
+
### Visuals & Renderers
|
|
272
|
+
| Prop | Description |
|
|
273
|
+
| --- | --- |
|
|
274
|
+
| `visualResolver` | Required `VisualResolver` mapping domain items and clusters to pure visual descriptors and cache keys. |
|
|
275
|
+
| `renderContentNode` | Declarative React JSX renderer `(item, ctx, state) => ReactNode` (auto-measured via `getBBox()`). |
|
|
276
|
+
| `renderClusterNode` | Declarative React JSX renderer `(cluster, state) => ReactNode` (auto-measured via `getBBox()`). |
|
|
277
|
+
| `contentView` | Portable `SvgNodeView<TContentVisual>` plugin from `@simplex-viz/svg` (used when `renderContentNode` is omitted). |
|
|
278
|
+
| `clusterView` | Portable `SvgNodeView<TClusterVisual>` plugin from `@simplex-viz/svg` (used when `renderClusterNode` is omitted). |
|
|
279
|
+
|
|
280
|
+
### Mesh, Solver & Visual Emphasis
|
|
281
|
+
| Prop | Default | Description |
|
|
282
|
+
| --- | --- | --- |
|
|
283
|
+
| `meshOptions` | — | `{ innerPadding, minSideLength, maxSideLength, maxClusters }`. |
|
|
284
|
+
| `layoutOptions` | — | `{ clusterGapPx, itemGapPx, minContentScale, relaxationIterations, seed }`. |
|
|
285
|
+
| `sizeMultiplier` | `1.0` | Uniform node size multiplier (`0.35` – `3.0`). |
|
|
286
|
+
| `showHulls` | `false` | Renders convex bounding hull wireframes over nodes. |
|
|
287
|
+
| `pinnedIds` | — | `ReadonlySet<string> \| readonly string[]` of pinned item IDs. |
|
|
288
|
+
| `highlightedIds` | — | `ReadonlySet<string> \| readonly string[]` of highlighted item IDs (dims others). |
|
|
289
|
+
| `focusedClusterUid` | `null` | Isolates a cluster and its members, dimming unrelated nodes. |
|
|
290
|
+
| `className` / `style` | — | Styling forwarded to the outer container `<div>` (defaults to `width: 100%; height: 100%`). |
|
|
291
|
+
|
|
292
|
+
### Interactions, Telemetry & Controller
|
|
293
|
+
| Prop | Description |
|
|
294
|
+
| --- | --- |
|
|
295
|
+
| `onContentHover` | `(event: ContentHoverEvent \| null) => void` |
|
|
296
|
+
| `onClusterHover` | `(event: ClusterHoverEvent \| null) => void` |
|
|
297
|
+
| `onLeftClick` | `(target: HitTarget, rawEvent: NormalizedPointerEvent) => void` |
|
|
298
|
+
| `onRightClick` | `(target: HitTarget, rawEvent: NormalizedPointerEvent) => boolean \| void` (return `true` to suppress browser menu) |
|
|
299
|
+
| `onWheel` | `'zoom-to-cursor' \| 'pan-vertical' \| 'none' \| ((event, camera) => void)` |
|
|
300
|
+
| `onMetricsChange` | `(metrics: LayoutMetrics) => void` — Reports cluster count, scales `α`, `overlapCount`, `meshSideLength`, `areaEfficiency`. |
|
|
301
|
+
| `onClustersChange` | `(clusters: readonly ClusterDescriptor[]) => void` |
|
|
302
|
+
| `onFpsUpdate` | `(fps: number) => void` |
|
|
303
|
+
| `controllerRef` | Ref populated with the underlying `SimplexSvgController` instance. |
|
|
304
|
+
|
|
305
|
+
## `useSimplexGraphController()` Hook
|
|
306
|
+
|
|
307
|
+
Returns `{ controllerRef, handle }`:
|
|
308
|
+
|
|
309
|
+
- `handle.addItems(items)` — Appends items without resetting existing camera or springs.
|
|
310
|
+
- `handle.removeItems(ids)` — Removes items by ID with smooth fade-out.
|
|
311
|
+
- `handle.setItems(items)` — Replaces the active dataset.
|
|
312
|
+
- `handle.zoomBy(factor)` — Zooms relative to viewport center.
|
|
313
|
+
- `handle.resetCamera()` — Resets zoom & pan smoothly to `(1, 0, 0)`.
|
|
314
|
+
- `handle.getMetrics()` — Returns the latest `LayoutMetrics | null`.
|
|
315
|
+
- `handle.getClusters()` — Returns current `ClusterDescriptor[]`.
|
|
316
|
+
- `handle.getRawController()` — Returns the underlying `SimplexSvgController | null`.
|
|
317
|
+
|
|
318
|
+
## License
|
|
319
|
+
|
|
320
|
+
MIT — see [LICENSE](https://github.com/ghackenberg/simplex-viz/blob/main/LICENSE).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@simplex-viz/react",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "End-to-end type-safe React bindings, declarative JSX node renderers, and hooks for @simplex-viz/core and @simplex-viz/svg.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -20,8 +20,8 @@
|
|
|
20
20
|
"LICENSE"
|
|
21
21
|
],
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@simplex-viz/core": "^0.1.
|
|
24
|
-
"@simplex-viz/svg": "^0.1.
|
|
23
|
+
"@simplex-viz/core": "^0.1.3",
|
|
24
|
+
"@simplex-viz/svg": "^0.1.3"
|
|
25
25
|
},
|
|
26
26
|
"peerDependencies": {
|
|
27
27
|
"react": ">=18.0.0",
|