@simplex-viz/react 0.1.1 → 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 +263 -20
  2. package/package.json +3 -3
package/README.md CHANGED
@@ -4,6 +4,20 @@ End-to-end type-safe React bindings, declarative JSX node renderers, and imperat
4
4
 
5
5
  - **Live Demo:** <https://hackenberg.tech/simplex-viz/>
6
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
+ ---
7
21
 
8
22
  ## Installation
9
23
 
@@ -11,33 +25,262 @@ End-to-end type-safe React bindings, declarative JSX node renderers, and imperat
11
25
  npm install @simplex-viz/core @simplex-viz/svg @simplex-viz/react
12
26
  ```
13
27
 
14
- ## Quickstart
28
+ Peer dependencies: `react >= 18.0.0` and `react-dom >= 18.0.0`.
29
+
30
+ ---
31
+
32
+ ## Quickstart: Declarative JSX Node Renderers
15
33
 
16
34
  ```tsx
17
- import { SimplexTopicGraph, useSimplexGraphController } from '@simplex-viz/react';
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
+ }
18
67
 
19
- export function MyGraph({ items }: { items: readonly MyItem[] }) {
20
- const { controllerRef, handle } = useSimplexGraphController<MyItem, MyContentVisual, MyClusterVisual>();
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
+ );
21
100
 
22
101
  return (
23
- <SimplexTopicGraph
24
- controllerRef={controllerRef}
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`:
192
+
193
+ ```tsx
194
+ import { SimplexTopicGraph } from '@simplex-viz/react';
195
+ import {
196
+ GeometricShapeSvgView,
197
+ ClusterAnchorSvgView,
198
+ type GeometricShapeVisual,
199
+ type ClusterAnchorVisual,
200
+ } from '@simplex-viz/svg';
201
+
202
+ const contentView = new GeometricShapeSvgView();
203
+ const clusterView = new ClusterAnchorSvgView();
204
+
205
+ export function FastGraph({ items, visualResolver }) {
206
+ return (
207
+ <SimplexTopicGraph<MyItem, GeometricShapeVisual, ClusterAnchorVisual>
25
208
  items={items}
26
- getId={(item) => item.slug}
27
- getTags={(item) => item.hashtags}
28
- visualResolver={resolver}
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
- )}
209
+ visualResolver={visualResolver}
210
+ contentView={contentView}
211
+ clusterView={clusterView}
40
212
  />
41
213
  );
42
214
  }
43
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.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/core": "^0.1.1",
24
- "@simplex-viz/svg": "^0.1.1"
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",