@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.
Files changed (2) hide show
  1. package/README.md +266 -20
  2. 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
- ## Quickstart
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, useSimplexGraphController } from '@simplex-viz/react';
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
- export function MyGraph({ items }: { items: readonly MyItem[] }) {
17
- const { controllerRef, handle } = useSimplexGraphController<MyItem, MyContentVisual, MyClusterVisual>();
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
- getId={(item) => item.slug}
24
- getTags={(item) => item.hashtags}
25
- visualResolver={resolver}
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.0",
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/svg": "^0.1.0",
24
- "@simplex-viz/core": "^0.1.0"
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",