@simplex-viz/react 0.1.0 → 0.1.2
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 +266 -20
- package/package.json +7 -3
package/README.md
CHANGED
|
@@ -2,39 +2,285 @@
|
|
|
2
2
|
|
|
3
3
|
End-to-end type-safe React bindings, declarative JSX node renderers, and imperative controller hooks for `@simplex-viz/core` and `@simplex-viz/svg`.
|
|
4
4
|
|
|
5
|
+
- **Live Demo:** <https://hackenberg.tech/simplex-viz/>
|
|
6
|
+
- **Source & Monorepo:** <https://github.com/ghackenberg/simplex-viz/tree/main/packages/react>
|
|
7
|
+
- **Companion Packages:** [`@simplex-viz/core`](https://www.npmjs.com/package/@simplex-viz/core) · [`@simplex-viz/svg`](https://www.npmjs.com/package/@simplex-viz/svg)
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Features
|
|
12
|
+
|
|
13
|
+
- **`<SimplexTopicGraph />`**: Responsive, zero-overlap triangulation graph component that automatically observes container resizing, computes cluster layouts, and drives 60 fps spring animations.
|
|
14
|
+
- **Two rendering modes** (mix and match or swap live with smooth crossfading):
|
|
15
|
+
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.
|
|
16
|
+
2. **Portable `SvgNodeView` Plugins** (`contentView`, `clusterView` from `@simplex-viz/svg`): Direct SVG DOM updates with zero React reconciliation overhead per animation frame.
|
|
17
|
+
- **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.
|
|
18
|
+
- **`useSimplexGraphController`**: Imperative hook for live item mutations (`addItems`, `removeItems`, `setItems`), camera controls (`zoomBy`, `resetCamera`), and telemetry getters.
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
5
22
|
## Installation
|
|
6
23
|
|
|
7
24
|
```bash
|
|
8
25
|
npm install @simplex-viz/core @simplex-viz/svg @simplex-viz/react
|
|
9
26
|
```
|
|
10
27
|
|
|
11
|
-
|
|
28
|
+
Peer dependencies: `react >= 18.0.0` and `react-dom >= 18.0.0`.
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## Quickstart: Declarative JSX Node Renderers
|
|
33
|
+
|
|
34
|
+
```tsx
|
|
35
|
+
import { useMemo, useState } from 'react';
|
|
36
|
+
import {
|
|
37
|
+
SimplexTopicGraph,
|
|
38
|
+
useSimplexGraphController,
|
|
39
|
+
} from '@simplex-viz/react';
|
|
40
|
+
import type { VisualResolver } from '@simplex-viz/core';
|
|
41
|
+
|
|
42
|
+
interface Article {
|
|
43
|
+
readonly slug: string;
|
|
44
|
+
readonly title: string;
|
|
45
|
+
readonly hashtags: readonly string[];
|
|
46
|
+
readonly readingMinutes: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface ArticleVisual {
|
|
50
|
+
readonly key: string;
|
|
51
|
+
readonly title: string;
|
|
52
|
+
readonly colorHex: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface ClusterVisual {
|
|
56
|
+
readonly key: string;
|
|
57
|
+
readonly code: string;
|
|
58
|
+
readonly label: string;
|
|
59
|
+
readonly colorHex: string;
|
|
60
|
+
readonly count: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface ClusterStats {
|
|
64
|
+
readonly count: number;
|
|
65
|
+
readonly avgMinutes: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function KnowledgeAtlas({ articles }: { articles: readonly Article[] }) {
|
|
69
|
+
const [focusedClusterUid, setFocusedClusterUid] = useState<string | null>(null);
|
|
70
|
+
const [pinnedIds, setPinnedIds] = useState<Set<string>>(() => new Set());
|
|
71
|
+
|
|
72
|
+
const { controllerRef, handle } = useSimplexGraphController<
|
|
73
|
+
Article,
|
|
74
|
+
ArticleVisual,
|
|
75
|
+
ClusterVisual,
|
|
76
|
+
undefined,
|
|
77
|
+
ClusterStats
|
|
78
|
+
>();
|
|
79
|
+
|
|
80
|
+
const visualResolver = useMemo<
|
|
81
|
+
VisualResolver<Article, ArticleVisual, ClusterVisual, undefined, ClusterStats>
|
|
82
|
+
>(
|
|
83
|
+
() => ({
|
|
84
|
+
resolveContentVisual: (article, ctx) => ({
|
|
85
|
+
key: `${article.slug}:${ctx.mixedColorHex}`,
|
|
86
|
+
title: article.title,
|
|
87
|
+
colorHex: ctx.mixedColorHex,
|
|
88
|
+
}),
|
|
89
|
+
resolveClusterVisual: (cluster) => ({
|
|
90
|
+
key: `${cluster.clusterUid}:${cluster.meta.count}`,
|
|
91
|
+
code: cluster.code,
|
|
92
|
+
label: cluster.defaultLabel,
|
|
93
|
+
colorHex: cluster.colorHex,
|
|
94
|
+
count: cluster.meta.count,
|
|
95
|
+
}),
|
|
96
|
+
getVisualCacheKey: (visual) => visual.key,
|
|
97
|
+
}),
|
|
98
|
+
[],
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<div style={{ width: '100%', height: '640px', position: 'relative' }}>
|
|
103
|
+
<div style={{ position: 'absolute', top: 12, left: 12, zIndex: 10 }}>
|
|
104
|
+
<button onClick={() => handle.zoomBy(1.2)}>Zoom In</button>
|
|
105
|
+
<button onClick={() => handle.zoomBy(0.8)}>Zoom Out</button>
|
|
106
|
+
<button onClick={() => handle.resetCamera()}>Reset Camera</button>
|
|
107
|
+
</div>
|
|
108
|
+
|
|
109
|
+
<SimplexTopicGraph
|
|
110
|
+
controllerRef={controllerRef}
|
|
111
|
+
items={articles}
|
|
112
|
+
getId={(a) => a.slug}
|
|
113
|
+
getTags={(a) => a.hashtags}
|
|
114
|
+
deriveClusterMeta={(ctx) => ({
|
|
115
|
+
count: ctx.members.length,
|
|
116
|
+
avgMinutes:
|
|
117
|
+
ctx.members.reduce((sum, m) => sum + m.item.readingMinutes, 0) /
|
|
118
|
+
Math.max(1, ctx.members.length),
|
|
119
|
+
})}
|
|
120
|
+
visualResolver={visualResolver}
|
|
121
|
+
pinnedIds={pinnedIds}
|
|
122
|
+
focusedClusterUid={focusedClusterUid}
|
|
123
|
+
renderContentNode={(article, ctx, state) => (
|
|
124
|
+
<g>
|
|
125
|
+
<rect
|
|
126
|
+
x={-54}
|
|
127
|
+
y={-15}
|
|
128
|
+
width={108}
|
|
129
|
+
height={30}
|
|
130
|
+
rx={6}
|
|
131
|
+
fill="#0f172a"
|
|
132
|
+
stroke={state.interaction.isPinned ? '#f59e0b' : ctx.mixedColorHex}
|
|
133
|
+
strokeWidth={state.interaction.isHovered ? 2 : 1.2}
|
|
134
|
+
/>
|
|
135
|
+
<text
|
|
136
|
+
textAnchor="middle"
|
|
137
|
+
y={4}
|
|
138
|
+
fill="#f8fafc"
|
|
139
|
+
fontSize={9}
|
|
140
|
+
>
|
|
141
|
+
{article.title.slice(0, 16)}
|
|
142
|
+
</text>
|
|
143
|
+
</g>
|
|
144
|
+
)}
|
|
145
|
+
renderClusterNode={(cluster, state) => (
|
|
146
|
+
<g>
|
|
147
|
+
<rect
|
|
148
|
+
x={-68}
|
|
149
|
+
y={-20}
|
|
150
|
+
width={136}
|
|
151
|
+
height={40}
|
|
152
|
+
rx={8}
|
|
153
|
+
fill="#090d16"
|
|
154
|
+
stroke={state.interaction.isHovered ? '#f8fafc' : cluster.colorHex}
|
|
155
|
+
strokeWidth={1.6}
|
|
156
|
+
/>
|
|
157
|
+
<text textAnchor="middle" y={-2} fill="#f8fafc" fontSize={10} fontWeight={600}>
|
|
158
|
+
{`${cluster.code} · ${cluster.defaultLabel}`}
|
|
159
|
+
</text>
|
|
160
|
+
<text textAnchor="middle" y={12} fill="#94a3b8" fontSize={8.5}>
|
|
161
|
+
{`${cluster.meta.count} items · avg ${cluster.meta.avgMinutes.toFixed(0)}m`}
|
|
162
|
+
</text>
|
|
163
|
+
</g>
|
|
164
|
+
)}
|
|
165
|
+
onLeftClick={(target) => {
|
|
166
|
+
if (target.kind === 'content') {
|
|
167
|
+
setPinnedIds((prev) => {
|
|
168
|
+
const next = new Set(prev);
|
|
169
|
+
if (next.has(target.item.slug)) next.delete(target.item.slug);
|
|
170
|
+
else next.add(target.item.slug);
|
|
171
|
+
return next;
|
|
172
|
+
});
|
|
173
|
+
} else if (target.kind === 'cluster') {
|
|
174
|
+
setFocusedClusterUid((cur) =>
|
|
175
|
+
cur === target.cluster.clusterUid ? null : target.cluster.clusterUid,
|
|
176
|
+
);
|
|
177
|
+
} else {
|
|
178
|
+
setFocusedClusterUid(null);
|
|
179
|
+
}
|
|
180
|
+
}}
|
|
181
|
+
/>
|
|
182
|
+
</div>
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
---
|
|
188
|
+
|
|
189
|
+
## Using Portable `SvgNodeView` Plugins (60 fps Without JSX Re-Renders)
|
|
190
|
+
|
|
191
|
+
For larger graphs or built-in shape morphing, pass `contentView` and `clusterView` from `@simplex-viz/svg` instead of `renderContentNode` / `renderClusterNode`:
|
|
12
192
|
|
|
13
193
|
```tsx
|
|
14
|
-
import { SimplexTopicGraph
|
|
194
|
+
import { SimplexTopicGraph } from '@simplex-viz/react';
|
|
195
|
+
import {
|
|
196
|
+
GeometricShapeSvgView,
|
|
197
|
+
ClusterAnchorSvgView,
|
|
198
|
+
type GeometricShapeVisual,
|
|
199
|
+
type ClusterAnchorVisual,
|
|
200
|
+
} from '@simplex-viz/svg';
|
|
15
201
|
|
|
16
|
-
|
|
17
|
-
|
|
202
|
+
const contentView = new GeometricShapeSvgView();
|
|
203
|
+
const clusterView = new ClusterAnchorSvgView();
|
|
18
204
|
|
|
205
|
+
export function FastGraph({ items, visualResolver }) {
|
|
19
206
|
return (
|
|
20
|
-
<SimplexTopicGraph
|
|
21
|
-
controllerRef={controllerRef}
|
|
207
|
+
<SimplexTopicGraph<MyItem, GeometricShapeVisual, ClusterAnchorVisual>
|
|
22
208
|
items={items}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
renderContentNode={(item, ctx) => (
|
|
27
|
-
<g>
|
|
28
|
-
<circle r={10} fill={ctx.mixedColorHex} />
|
|
29
|
-
</g>
|
|
30
|
-
)}
|
|
31
|
-
renderClusterNode={(cluster) => (
|
|
32
|
-
<g>
|
|
33
|
-
<rect x={-50} y={-16} width={100} height={32} rx={6} fill="#0f172a" stroke={cluster.colorHex} />
|
|
34
|
-
<text textAnchor="middle" y={4} fill="#f8fafc">{cluster.code}</text>
|
|
35
|
-
</g>
|
|
36
|
-
)}
|
|
209
|
+
visualResolver={visualResolver}
|
|
210
|
+
contentView={contentView}
|
|
211
|
+
clusterView={clusterView}
|
|
37
212
|
/>
|
|
38
213
|
);
|
|
39
214
|
}
|
|
40
215
|
```
|
|
216
|
+
|
|
217
|
+
> **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.
|
|
218
|
+
|
|
219
|
+
---
|
|
220
|
+
|
|
221
|
+
## `<SimplexTopicGraph />` Props Reference
|
|
222
|
+
|
|
223
|
+
### Data, Typing & Metadata
|
|
224
|
+
| Prop | Required | Description |
|
|
225
|
+
| --- | --- | --- |
|
|
226
|
+
| `items` | Yes | `readonly TItem[]` — Domain items to cluster and lay out. |
|
|
227
|
+
| `getId` | If `TItem` lacks `id` | `(item: TItem) => string` — Extracts a unique ID per item. |
|
|
228
|
+
| `getTags` | If `TItem` lacks `tags` | `(item: TItem) => readonly string[]` — Extracts raw tag strings per item. |
|
|
229
|
+
| `normalizeTag` | No | `(rawTag: string) => string` — Custom tag normalizer (defaults to lowercase trim + `#` strip). |
|
|
230
|
+
| `resolveTagMeta` | No | `(normalizedTag: string) => TTagMeta \| undefined` — Attaches domain metadata to each tag. |
|
|
231
|
+
| `deriveClusterMeta` | If `TClusterMeta` is defined | `(ctx) => TClusterMeta` — Derives aggregate metadata once per cluster update. |
|
|
232
|
+
|
|
233
|
+
### Visuals & Renderers
|
|
234
|
+
| Prop | Description |
|
|
235
|
+
| --- | --- |
|
|
236
|
+
| `visualResolver` | Required `VisualResolver` mapping domain items and clusters to pure visual descriptors and cache keys. |
|
|
237
|
+
| `renderContentNode` | Declarative React JSX renderer `(item, ctx, state) => ReactNode` (auto-measured via `getBBox()`). |
|
|
238
|
+
| `renderClusterNode` | Declarative React JSX renderer `(cluster, state) => ReactNode` (auto-measured via `getBBox()`). |
|
|
239
|
+
| `contentView` | Portable `SvgNodeView<TContentVisual>` plugin from `@simplex-viz/svg` (used when `renderContentNode` is omitted). |
|
|
240
|
+
| `clusterView` | Portable `SvgNodeView<TClusterVisual>` plugin from `@simplex-viz/svg` (used when `renderClusterNode` is omitted). |
|
|
241
|
+
|
|
242
|
+
### Mesh, Solver & Visual Emphasis
|
|
243
|
+
| Prop | Default | Description |
|
|
244
|
+
| --- | --- | --- |
|
|
245
|
+
| `meshOptions` | — | `{ innerPadding, minSideLength, maxSideLength, maxClusters }`. |
|
|
246
|
+
| `layoutOptions` | — | `{ clusterGapPx, itemGapPx, minContentScale, relaxationIterations, seed }`. |
|
|
247
|
+
| `sizeMultiplier` | `1.0` | Uniform node size multiplier (`0.35` – `3.0`). |
|
|
248
|
+
| `showHulls` | `false` | Renders convex bounding hull wireframes over nodes. |
|
|
249
|
+
| `pinnedIds` | — | `ReadonlySet<string> \| readonly string[]` of pinned item IDs. |
|
|
250
|
+
| `highlightedIds` | — | `ReadonlySet<string> \| readonly string[]` of highlighted item IDs (dims others). |
|
|
251
|
+
| `focusedClusterUid` | `null` | Isolates a cluster and its members, dimming unrelated nodes. |
|
|
252
|
+
| `className` / `style` | — | Styling forwarded to the outer container `<div>` (defaults to `width: 100%; height: 100%`). |
|
|
253
|
+
|
|
254
|
+
### Interactions, Telemetry & Controller
|
|
255
|
+
| Prop | Description |
|
|
256
|
+
| --- | --- |
|
|
257
|
+
| `onContentHover` | `(event: ContentHoverEvent \| null) => void` |
|
|
258
|
+
| `onClusterHover` | `(event: ClusterHoverEvent \| null) => void` |
|
|
259
|
+
| `onLeftClick` | `(target: HitTarget, rawEvent: NormalizedPointerEvent) => void` |
|
|
260
|
+
| `onRightClick` | `(target: HitTarget, rawEvent: NormalizedPointerEvent) => boolean \| void` (return `true` to suppress browser menu) |
|
|
261
|
+
| `onWheel` | `'zoom-to-cursor' \| 'pan-vertical' \| 'none' \| ((event, camera) => void)` |
|
|
262
|
+
| `onMetricsChange` | `(metrics: LayoutMetrics) => void` — Reports cluster count, scales `α`, `overlapCount`, `meshSideLength`, `areaEfficiency`. |
|
|
263
|
+
| `onClustersChange` | `(clusters: readonly ClusterDescriptor[]) => void` |
|
|
264
|
+
| `onFpsUpdate` | `(fps: number) => void` |
|
|
265
|
+
| `controllerRef` | Ref populated with the underlying `SimplexSvgController` instance. |
|
|
266
|
+
|
|
267
|
+
---
|
|
268
|
+
|
|
269
|
+
## `useSimplexGraphController()` Hook
|
|
270
|
+
|
|
271
|
+
Returns `{ controllerRef, handle }`:
|
|
272
|
+
|
|
273
|
+
- `handle.addItems(items)` — Appends items without resetting existing camera or springs.
|
|
274
|
+
- `handle.removeItems(ids)` — Removes items by ID with smooth fade-out.
|
|
275
|
+
- `handle.setItems(items)` — Replaces the active dataset.
|
|
276
|
+
- `handle.zoomBy(factor)` — Zooms relative to viewport center.
|
|
277
|
+
- `handle.resetCamera()` — Resets zoom & pan smoothly to `(1, 0, 0)`.
|
|
278
|
+
- `handle.getMetrics()` — Returns the latest `LayoutMetrics | null`.
|
|
279
|
+
- `handle.getClusters()` — Returns current `ClusterDescriptor[]`.
|
|
280
|
+
- `handle.getRawController()` — Returns the underlying `SimplexSvgController | null`.
|
|
281
|
+
|
|
282
|
+
---
|
|
283
|
+
|
|
284
|
+
## License
|
|
285
|
+
|
|
286
|
+
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.2",
|
|
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/
|
|
24
|
-
"@simplex-viz/
|
|
23
|
+
"@simplex-viz/core": "^0.1.2",
|
|
24
|
+
"@simplex-viz/svg": "^0.1.2"
|
|
25
25
|
},
|
|
26
26
|
"peerDependencies": {
|
|
27
27
|
"react": ">=18.0.0",
|
|
@@ -34,6 +34,10 @@
|
|
|
34
34
|
"visualization",
|
|
35
35
|
"typescript"
|
|
36
36
|
],
|
|
37
|
+
"homepage": "https://hackenberg.tech/simplex-viz/",
|
|
38
|
+
"bugs": {
|
|
39
|
+
"url": "https://github.com/ghackenberg/simplex-viz/issues"
|
|
40
|
+
},
|
|
37
41
|
"repository": {
|
|
38
42
|
"type": "git",
|
|
39
43
|
"url": "git+https://github.com/ghackenberg/simplex-viz.git",
|