@jmbarzee/force-graph 0.0.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jacob Barzee
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,424 @@
1
+ # @jmbarzee/force-graph
2
+
3
+ A force-directed graph view with composable axes, as a React component.
4
+
5
+ A host declares what its graph is made of — the **axes** its nodes carry values on,
6
+ the **values** on each, and for the style axis a colour per value — through
7
+ `createTaxonomy(spec)`. It builds a `Graph` of nodes and edges carrying
8
+ their values on those axes, and mounts `GraphView`. The view lays the graph out (push and
9
+ pull on any axis the nodes carry, two layout axes keyed on an axis or a
10
+ computed scalar), filters it on any declared axis, highlights it, and lets the
11
+ user tune the forces — without knowing what any node or edge means. The host's
12
+ vocabulary never enters this package.
13
+
14
+ ## Install
15
+
16
+ ```bash
17
+ npm install @jmbarzee/force-graph
18
+ ```
19
+
20
+ `react` and `react-dom` are peer dependencies (`^18 || ^19`). The package is
21
+ ESM only. Import the stylesheet once — it carries the design tokens and every
22
+ component's rules.
23
+
24
+ ## Quick start
25
+
26
+ The smallest host: a taxonomy of two values and two edge types, a graph of
27
+ three nodes, the view. The block below is the whole of it; a full host is
28
+ [`examples/course-catalog/`](examples/course-catalog/).
29
+
30
+ ```tsx
31
+ import { createTaxonomy, GraphView } from '@jmbarzee/force-graph'
32
+ import type { Graph, GraphNode } from '@jmbarzee/force-graph'
33
+ import '@jmbarzee/force-graph/styles.css'
34
+
35
+ const taxonomy = createTaxonomy({
36
+ styleAxis: 'kind',
37
+ styles: [
38
+ { id: 'team', label: 'Team', icon: '⌂', summaryKind: 'children',
39
+ color: { light: { fill: '#475569', border: '#1E293B' }, dark: { fill: '#94A3B8', border: '#475569' } }, size: { r: 18 } },
40
+ { id: 'service', label: 'Service', icon: '◆',
41
+ color: { light: { fill: '#0F766E', border: '#134E4A' }, dark: { fill: '#2DD4BF', border: '#0F766E' } } },
42
+ ],
43
+ edgeTypes: [
44
+ { id: 'owns', sourceValue: 'service', targetValue: 'team', relation: 'containment' },
45
+ { id: 'calls', sourceValue: 'service', targetValue: 'service', relation: 'dependency',
46
+ style: { color: { light: '#0F766E', dark: '#5EEAD4' }, width: 1.5 } },
47
+ ],
48
+ })
49
+
50
+ const nodes: GraphNode[] = [
51
+ { id: 'payments', name: 'Payments', values: { kind: 'team' } },
52
+ { id: 'checkout', name: 'Checkout', values: { kind: 'service' }, parentId: 'payments' },
53
+ { id: 'ledger', name: 'Ledger', values: { kind: 'service' }, parentId: 'payments' },
54
+ ]
55
+ const graph: Graph = {
56
+ nodes,
57
+ edges: [{ id: 'checkout→ledger', sourceId: 'checkout', targetId: 'ledger' }],
58
+ }
59
+
60
+ export function App() {
61
+ return (
62
+ <div style={{ display: 'flex', flexDirection: 'column', height: '100vh' }}>
63
+ <GraphView graph={graph} taxonomy={taxonomy} />
64
+ </div>
65
+ )
66
+ }
67
+ ```
68
+
69
+ The containment edges from each service to its team are derived from
70
+ `parentId`; the edge between the services is a dependency unless it says
71
+ otherwise, and is classified as `calls` because that is the one declared type
72
+ between two services; the filter axis is the style axis unless others are
73
+ declared; the filter, the locks, the axes shown, the search, the theme, the
74
+ selection and the layout are the view's own until the host asks for them.
75
+
76
+ ## The taxonomy
77
+
78
+ `createTaxonomy(spec: TaxonomySpec): Taxonomy`. An incoherent spec — a value
79
+ declared twice, an edge type between values nobody declares, a colour no reader
80
+ can parse, a band whose ends cross — is refused at construction, loudly. To
81
+ derive a variant, build one from the spec (`createTaxonomy({ ...spec, … })`),
82
+ never by spreading the built object.
83
+
84
+ - **The style axis and its values.** `styleAxis` names the axis every
85
+ node is styled on; `styles` lists its values' styles, top of the hierarchy
86
+ first.
87
+ A `NodeStyle` needs its `id`, `label` and `color` (fill and border, per
88
+ theme: hex, `rgb()` or `hsl()`); everything else has a default, and a
89
+ value that restates one says nothing:
90
+
91
+ | field | default |
92
+ |---|---|
93
+ | `abbreviation` | the label's first two characters |
94
+ | `icon` | none |
95
+ | `size.r` / `size.iconSize` | 12 / nine tenths of `r`, rounded, at least 9 |
96
+ | `physics.charge` / `physics.coreRadius` | −40 r / 4 r |
97
+ | `physics.band.y` | the value's slice of the stack, by its position in the order |
98
+ | `physics.band.x` | 0 to 380 (`BAND_FRAME.x`) |
99
+ | `whenHidden` | `ascend`: a dependency edge touching a hidden node re-points to its nearest visible ancestor; `splice` follows the node's own edges through |
100
+ | `summaryKind` | `degree` (`→3 ←2` in the hover box); `children` counts what the node contains; `none` shows neither |
101
+
102
+ `fallbackStyle` draws a value nobody declared; `styleLabel` names the axis
103
+ in the controls.
104
+ - **Edge types and classification.** Each `EdgeType` has an `id`, the
105
+ `sourceValue` and `targetValue` it runs between — a style value, or several
106
+ at either end: the type runs between every source value and every target
107
+ value — and its `relation` (`containment` or `dependency`); `label`
108
+ defaults to the id, `style` (colour per theme, alpha, dash, width) field by
109
+ field to the relation's stroke (`NEUTRAL_STROKE`, a quiet solid grey;
110
+ `CONTAINMENT_STROKE`, the same grey faint and dashed, for a containment
111
+ type), `physics` (a spring's strength and rest length) to a middling
112
+ spring, `tooltip` to the label. An edge is classified once, on the style
113
+ values of its original endpoints: the declared type of its relation whose
114
+ endpoints match in either orientation and whose `match` — values the edge
115
+ itself must carry on the host's edge axes — its `values` satisfy. Two
116
+ types on one pair are told apart by `match` (a call and a signal between
117
+ two services, by the edge's `kind`), with the type that declares none as
118
+ the fallback, or by their declared orientation: a satisfied `match` beats
119
+ the pair alone, the declared orientation beats the reverse, and declaration
120
+ order settles the rest. Two types nothing tells apart are refused at
121
+ construction unless the host gives `classifyEdge(edge, sourceValue,
122
+ targetValue, declared)`, which sees what the declarations answered and may
123
+ keep it, replace it, or supply a type where they answered none — the
124
+ exceptions declarations cannot express. An edge keeps its type when
125
+ graduation re-points it (graduation: a dependency edge whose end is hidden
126
+ is re-pointed to a visible node — see Collapse rules); an edge no type
127
+ claims draws in its relation's stroke, pulls nothing while push and pull
128
+ key on the style axis (on another axis every edge is sprung by its
129
+ endpoints' buckets), and `validateGraph` says so.
130
+ - **Filter axes.** `filterAxes` defaults to the style axis as an
131
+ allow-list. Each `AxisDescriptor` is an `id`, a `label` (default the id)
132
+ and what an empty selection means: `none` (an allow-list: nothing selected
133
+ shows nothing) or `all` (a narrowing axis: nothing selected shows
134
+ everything). The policies follow — a node with no value on the axis
135
+ (`absentMeans`), how a focus widens it (`widens`: `always`, or `whenActive`
136
+ — only while a selection is active), whether a change keeps the camera
137
+ (`keepCamera`: a narrowing axis does, an allow-list refits) — and may be
138
+ overridden. `chipsFor(values)` groups values into chips (default one per
139
+ value; on the style axis they wear the value's icon and tint; a chip none
140
+ of whose values the data presents is not offered), `labelFor`,
141
+ `abbreviationFor`, `iconFor` and `tooltipFor` name and adorn values
142
+ (defaults: the style's names and icon on the style axis, the value itself
143
+ and no icon elsewhere, the abbreviation from the label, no tooltip),
144
+ `initialSelection(values)` is the first open's selection (default every
145
+ value on an allow-list, none on a narrowing axis), `presentation` is
146
+ `chip` or `tag`.
147
+ - **Scalars and hooks.** `scalars` adds computed axes (`id`, `label` and
148
+ `tooltip` — defaults the id and the label — and `compute(graph)` answering a
149
+ number per node; `normalize` maps
150
+ the answers onto [0, 1]: `max` divides by the largest and refuses a
151
+ negative, `range` puts the smallest at 0 and the largest at 1, `none`
152
+ trusts them) to the built-in
153
+ reach, degree and depth. `contextLine(node, parent)` is the hover box's
154
+ line or lines under the name, text or elements (default the parent's
155
+ name); `searchText(node)` is
156
+ what the search matches on (default the name); `highlight(active, base,
157
+ graph, direction)` may widen or replace the transitive walk; `collapse(kept,
158
+ candidate)` picks which of two graduated edges on one visible pair survives.
159
+ Every hook is handed the host's own node objects, never the view's copies,
160
+ so a host may key them by identity.
161
+
162
+ ## The graph
163
+
164
+ `Graph` is `nodes` (an array, in layout order; the view indexes it, and
165
+ `nodesById` does the same for a host) and `edges`. Both carry `values`, a map
166
+ from axis to value, and an opaque `payload` the view never reads
167
+ (`payloadString` and `payloadNumber` read it for you). Ids are opaque: the
168
+ engine compares them and never parses one.
169
+
170
+ - **Containment is a tree.** A node's `parentId` names its container; the view
171
+ derives the containment edge from it. An entity that sits in two containers
172
+ is two nodes sharing a `copyKey`: the library indexes the copies
173
+ (`copyGroups`), draws their halo and counts them in the hover box. A host may
174
+ list containment edges of its own between any two nodes.
175
+ - **An edge's `relation`** is `containment` or `dependency` (the default;
176
+ `isContainment(edge)` reads it). A containment edge from a node to its
177
+ `parentId` is derived; one a host lists itself, to any node, is drawn in
178
+ the containment stroke and sprung as its type says, but never walked as
179
+ the parent, and
180
+ `validateGraph` names one that contradicts `parentId`. A host adds axes of
181
+ its own to edges and nodes alike through `values`; any axis the nodes
182
+ carry can be pushed, pulled and laid out on.
183
+ - **Adornments.** A node may carry `badges` (short texts drawn as chips above
184
+ it) and a `ring` (`dashed`, for a node set apart).
185
+ - **Identity.** Keep one `graph` object until something in it changes: a new
186
+ object equal to the last rebuilds the layout for nothing, and the view says
187
+ so once. The same for the taxonomy — build it once, outside render; the
188
+ view warns when its identity churns. The filter, locks and axes are
189
+ compared by content, so a host may rebuild those each render. A changed
190
+ graph keeps the positions of the nodes it still has.
191
+ - **Validation.** `validateGraph(graph, taxonomy)` names every defect — a
192
+ dangling endpoint, an unknown relation, a type the rule returns that the
193
+ table lacks, a duplicate node or edge id, a parent not in the graph. The
194
+ view runs it, lists the diagnostics under the bar, hands them to
195
+ `onDiagnostics` and draws what remains — adding one warning of its own,
196
+ `containment-untyped`, when a containment edge it derived from `parentId`
197
+ has no declared type; a host that wants to refuse a bad graph calls it
198
+ first.
199
+
200
+ ## The view
201
+
202
+ `GraphView` needs the `graph`, a taxonomy (the `taxonomy` prop, or a
203
+ `TaxonomyContext.Provider` above; `useTaxonomy()` reads it in a host's own
204
+ component inside the pane), and a parent with a height: a flex column (the
205
+ pane takes the remaining space) or a block with a height (the pane fills it).
206
+ A parent with no height gives the pane none, and the pane says so once on
207
+ the console. Everything else is optional.
208
+
209
+ - **State.** The filter, the locks, the axes shown, the search control
210
+ (`{ query, active }`), the theme choice, the selection and the layout each
211
+ follow the React convention: pass the value and its callback to control it,
212
+ neither to let the view keep it, the callback alone to be told; and each
213
+ has a `default*` prop for where the view's own starts (`defaultFilter`,
214
+ `defaultLocks`, `defaultAxes`, `defaultSearch`, `defaultTheme`,
215
+ `defaultSelectedId`, `defaultLayout`), read once. A controlled value is the
216
+ host's: the view never rewrites it, and warns once if the prop flips
217
+ between defined and undefined. `initialFilter(taxonomy, graph)` is the
218
+ filter the view starts from — an `initialSelection` that splits a chip is
219
+ snapped to the whole chip, with a warning. `reseedFilter(filter, taxonomy,
220
+ graph, touched)` is what the view does to its own filter when the graph
221
+ changes — every axis the user has not touched takes the new graph's
222
+ opening selection — for a host that controls the filter to call from its
223
+ own data effect; `onFilterChange(next, touched)` names the axes the user
224
+ changed in each write, so the host can keep that set.
225
+ `filterToJSON` / `filterFromJSON` and `locksToJSON` / `locksFromJSON` carry
226
+ a filter and its locks to a URL or a store and back.
227
+ - **Selection and focus.** `selectedId` controls the selection: while it is
228
+ defined the view shows it and nothing else, `onSelectionChange` reports the
229
+ selections the user makes (never one the host made), and the host answers
230
+ by setting the prop, or not. Absent, the view keeps the selection and still
231
+ reports. `focus={{ id, key }}` reveals, centres and selects a node — the
232
+ declarative form of the handle's `focusNode`, for a pane that mounts lazily
233
+ or whose data arrives late. It is compared by value, so a new `id` or `key`
234
+ is a new request; it waits until its node is in the graph; and
235
+ `onFocusResult(request, shown)` says whether the filter could show the
236
+ node. Null, or a newer request, withdraws a waiting one unreported.
237
+ `useFocusRequest()` gives a host the request and a `requestFocus(id)` that
238
+ makes each ask new.
239
+ - **Layout.** `layout` is the force tuning and the node scale as the user
240
+ set them: which axis push and pull key on and the buckets they changed,
241
+ the two layout axes (an axis's bands, or a computed scalar's span), the
242
+ multipliers, gravities and dynamics. `onLayoutChange` hears every change —
243
+ the control panel's and the view's own retune for a new graph or taxonomy
244
+ (a tuning on an axis the graph no longer carries returns to the style
245
+ axis). `layoutToJSON(layout)` carries it to a store and `layoutFromJSON(json,
246
+ taxonomy)` back; the JSON form (`LayoutJSON`) fills whatever it leaves out
247
+ from the taxonomy's defaults, so it is also how a host writes a start:
248
+ `defaultLayout={layoutFromJSON({ forces: { bandY: { scalar: 'credits' } } },
249
+ taxonomy)}` opens the view laid out on a scalar. The `LayoutState` itself
250
+ is opaque: keep it and hand it back.
251
+ - **Inputs.** `counts` (chip badges; the graph's entity counts unless given,
252
+ the copies of one `copyKey` once),
253
+ `findings` under the bar, `groups` to glow (each with a `color` or the
254
+ palette's), `groupsControl` for a legend inside the pane (anchor it under
255
+ `--gv-toolbar-height`, and handed the groups as drawn and `emphasize`;
256
+ `GroupLegend` is one — items, the active groups, a toggle and the
257
+ emphasis), `markedEdges` (edge ids drawn with a dashed band behind their
258
+ stroke, a graduated edge when the edge it stands for is), a `nodeAction`
259
+ for the hover box (`{ label, onSelect }`, or a function of the node
260
+ answering one or null), `chrome` to turn parts of
261
+ the pane off — `controls` (the control panel), `themeToggle` (the theme
262
+ button), `search`, `locks` (the lock toggles; off until the host controls
263
+ `locks` with `onLocksChange`), `transport` (the Fit / theme / Play / count
264
+ cluster, which takes the theme button with it), `diagnostics`,
265
+ `shortcuts`, `directionLine` (the hover box's) and `activateZoom` (the
266
+ double-click zoom) — `searchTitle` and `searchPlaceholder` for the host's
267
+ own words, `systemTheme` (what `system` means, for a host that knows its
268
+ own theme), `className`, `style`, and `defaultPositions` (kept positions,
269
+ read whenever the simulation is built; a node the live layout already
270
+ holds keeps its place).
271
+ - **Outputs.** `onSelectionChange(node | null)`, `onNodeActivate(node)` on a
272
+ double-click, `onHover(node | null)`, `onThemeResolved(theme)`,
273
+ `onDiagnostics(diagnostics)` whenever the graph's defects are recomputed
274
+ (the first graph's included), `onLayoutChange(layout)`. Every node handed
275
+ out is the host's own object. The resolved theme is for the host's own
276
+ chrome beside the pane; a host that writes it as a `data-theme` on an
277
+ ancestor of the pane passes `systemTheme` too, so `system` follows the
278
+ host's answer and not the pane's own echo.
279
+ - **The handle.** Through a ref, `focusNode(id)` reveals, centres and selects
280
+ a node in one call — it widens the filter on the axes shown, flashes a lock
281
+ it overrides, and centres once the layout has settled — and answers
282
+ whether the node ended visible (a value absent on a hiding axis cannot be
283
+ revealed); `select(id | null)` sets or clears the selection; `fit()` frames
284
+ the visible set; `getPositions()` and `setPositions()` carry a layout out
285
+ and back in.
286
+ - **What to know.** The view's own filter re-seeds on a graph change for
287
+ every axis still at the selection it opened with — a controlled filter
288
+ never does, and a host that gave `defaultFilter` chose its start — and
289
+ `onFilterChange` hears it. `onHover(null)` arrives 150 ms after the pointer
290
+ leaves, so a tooltip can be reached. Removing an axis in the bar resets its
291
+ selection to "match everything" and fires `onFilterChange` before
292
+ `onAxesChange`; an axis the taxonomy does not declare renders as a header
293
+ with its remove control and filters nothing.
294
+ Two declared types on one pair with no `match` to tell them apart are
295
+ refused; with `classifyEdge` given, its answer stands. Derived containment
296
+ edges are `in:<id>` (numbered on a collision) and reach `highlight` and
297
+ `collapse`. An error diagnostic drops the edge, or the later node of a
298
+ duplicated id, or clears the `parentId`, it names. The search dropdown lists visible matches; hidden ones badge the
299
+ chip hiding them. The pane's theme observer watches every `data-theme` in
300
+ the document unless `systemTheme` is given.
301
+ - **Keyboard.** `keyboard` scopes the shortcuts: `pane` (the default — the
302
+ focusable root handles the keys that reach it, so a page keeps Tab and
303
+ Space for its own controls), `global` (the window — a page that is the
304
+ pane, or a tab shell's active tab) or `none` (a hidden tab). Tab cycles
305
+ nodes, Enter selects, Escape closes, arrows pan, `+`/`-` zoom, F fits, `/`
306
+ opens search (while the search is offered), Space pauses, `?` lists them.
307
+
308
+ ## Two views sharing a bar
309
+
310
+ A host with a second view gives both views one state through `useLinkedViews`
311
+ and renders `FilterBar` in the view that is not a `GraphView`. The hook owns
312
+ each view's filter, locks and axes, the view shown and the search they share,
313
+ hands each view its props, reconciles on a switch, records the locks a focus
314
+ overrode so the bar flashes them, and asks the graph view to centre on a node
315
+ a focus names:
316
+
317
+ ```tsx
318
+ import React from 'react'
319
+ import { FilterBar, GraphView, passesSelection, useLinkedViews } from '@jmbarzee/force-graph'
320
+ import type { Graph, Taxonomy } from '@jmbarzee/force-graph'
321
+
322
+ export function TwoViews({ taxonomy, graph }: { taxonomy: Taxonomy; graph: Graph }) {
323
+ const linked = useLinkedViews({ taxonomy, views: ['list', 'graph'], graph })
324
+ const list = linked.of('list')
325
+ const shown = graph.nodes.filter(node => passesSelection({ filter: list.filter, taxonomy, axes: list.axes, node }))
326
+ // `linked.focus` switches to the view, widens its filter to show the node
327
+ // and, for the graph view, asks it to centre on and select the node.
328
+ return linked.active === 'graph'
329
+ ? <GraphView graph={graph} taxonomy={taxonomy} {...linked.of('graph')} nodeAction={{ label: 'Show in list', onSelect: node => linked.focus('list', node) }} />
330
+ : (
331
+ <div>
332
+ <FilterBar taxonomy={taxonomy} graph={graph} {...list} />
333
+ <ul>{shown.map(n => <li key={n.id} onDoubleClick={() => linked.focus('graph', n)}>{n.name}</li>)}</ul>
334
+ </div>
335
+ )
336
+ }
337
+ ```
338
+
339
+ `linked.of(view)` is the view's `filter`, `locks`, `axes`, their callbacks,
340
+ `overriddenLocks`, the shared `search` with its callback, and the `focus` the
341
+ last `linked.focus` asked of it with `onFocusResult` (a `FilterBar` ignores
342
+ those two); it throws for a view the hook was not given (`views` is read
343
+ once). `linked.active` is the view shown.
344
+ `linked.switchTo(to, transition?)` switches from the active view and makes
345
+ `to` active: a manual switch (the default) adopts the active view's
346
+ selections except where locked, a focus widens the destination to show its
347
+ target. `linked.focus(view, node)` is the focus switch and the request in one.
348
+ `linked.updateFilter(view, fn)` rewrites one view's filter and
349
+ `linked.updateFilters(fn)` every view's (a stale value pruned when the data
350
+ changes — `reseedFilter` is the rule the hook itself applies to every view
351
+ when `graph` changes, on the axes the user has not touched); `linked.state`,
352
+ `linked.active` and `linked.search` are everything,
353
+ for a host that persists them. The bar carries its own theme and scheme
354
+ (`theme`, default `system`; `systemTheme` says what that means), so it needs
355
+ no wrapper; give it the `graph` (or the `values` per axis — `valuesOn(records,
356
+ axis)` reads them off any subjects carrying `values`) and, if the host reads
357
+ its own data, the `counts`. The lock toggles appear only when `locks` is
358
+ controlled with its callback — `useLinkedViews` does — or `chrome.locks` asks.
359
+ The bar tracks the values that just turned on for its chip flash
360
+ (`RecentlyChanged`, a set per axis); a host with its own rule passes
361
+ `recentlyChanged`.
362
+
363
+ What the pane computes for its own bar is exported for the second view:
364
+ `passesSelection({ filter, taxonomy, axes, node })` decides visibility on the
365
+ axes the view shows (`valueOn(node, axis)` for a record that carries its
366
+ values under other names), `countsOn(nodes, axes)` the badges,
367
+ `hiddenMatchesOf({ nodes, filter, taxonomy, axes, visibleIds, matches })` the
368
+ search's hidden-match badges. `reconcileFilter({ destination, source, locks,
369
+ transition, taxonomy, axes, graph })` is the switch the hook makes; `axes`
370
+ are ids, and with the `graph` a focus widens to the whole chip holding its
371
+ target's value. `selectionFor`, `withSelection`, `withLock`, `toggleGroup` and
372
+ `filterStatesEqual` are the filter operations the bar itself uses;
373
+ `toggleValue` flips one value for a host's own control. A focus into the graph
374
+ is `focusNode(id)` or the `focus` prop; a focus into the other view is the
375
+ host's (`matchesFocus(node, target)` finds the node a `FocusTarget`'s values
376
+ name).
377
+
378
+ ## Theming
379
+
380
+ The pane resolves its theme from its own choice (Auto / Light / Dark) or, for
381
+ `system`, from `systemTheme` when the host gives it, else the nearest
382
+ ancestor's `data-theme` and then the OS — `useHostTheme(ref)` is that same
383
+ read, for a host's own chrome, and `themeChoiceOf(value)` reads a stored
384
+ choice back (`system` for anything else). Every
385
+ token the components read is a `--gv-*` custom property declared in the
386
+ stylesheet per theme (`--gv-color-bg`, `--gv-color-text`, `--gv-accent`,
387
+ `--gv-font-sans`, …), and every class is `gv-*`; a host overrides a token by
388
+ redeclaring it on an ancestor. Each style value's fill and border are
389
+ published on the pane under the name `taxonomy.colorVar(value)` answers —
390
+ `--gv-node-` and the value id in kebab case (`nexusEndpoint` →
391
+ `--gv-node-nexus-endpoint`), or a numbered name when the id kebab-cases to
392
+ nothing or to another value's — and `-border` for the border. A host's own
393
+ chrome beside the pane reads the same tints through `schemeVars(taxonomy,
394
+ theme)` inline, or `schemeStylesheet(taxonomy, darkSelector?)` mounted once
395
+ (its dark block under `[data-theme="dark"]`, or the host's own selector), with
396
+ the theme `onThemeResolved` hands it. Default rest
397
+ bands are laid in `BAND_FRAME` (world units: the hierarchy axis from −300 to
398
+ 400, x from 0 to 380); a band a host declares is read in the same units.
399
+
400
+ ## Collapse rules
401
+
402
+ A dependency edge whose end is hidden is **graduated**: re-pointed to the
403
+ nearest visible ancestor (`ascend`) or followed through the hidden node's own
404
+ edges (`splice`). Two graduated edges landing on one visible pair collapse to
405
+ one, the host's `collapse` hook choosing which original it stands for; an
406
+ original edge between two visible nodes stands for every projection landing
407
+ on its pair; two original edges between visible nodes never collapse, and
408
+ draw in lanes.
409
+
410
+ ## Limits
411
+
412
+ The charge force is O(n²) over the visible nodes: a few hundred draw
413
+ smoothly, a few thousand do not. The push/pull and layout controls bound their
414
+ tokens by a ceiling (an axis with more values than the ceiling is offered but
415
+ cannot be tuned on, and a tuning that outgrows it returns to the style axis).
416
+ What is not built is filed as issues: colour by another axis
417
+ ([#9](https://github.com/jmbarzee/force-graph/issues/9)), an opaque `Taxonomy`
418
+ ([#10](https://github.com/jmbarzee/force-graph/issues/10)), undirected edges
419
+ ([#11](https://github.com/jmbarzee/force-graph/issues/11)), per-edge weight
420
+ ([#12](https://github.com/jmbarzee/force-graph/issues/12)), the pane's strings
421
+ in other languages ([#13](https://github.com/jmbarzee/force-graph/issues/13)),
422
+ the React 19 types gate ([#14](https://github.com/jmbarzee/force-graph/issues/14)).
423
+
424
+ `src/index.ts` names the whole surface.
@@ -0,0 +1,90 @@
1
+ import { AxisId } from '../graph/axis';
2
+ import { Graph } from '../graph/model';
3
+ import { Taxonomy } from '../graph/taxonomy';
4
+ import { ThemeChoice } from './graph-view/useTheme';
5
+ import { Theme } from '../graph/scheme';
6
+ import { RecentlyChanged } from './graph-view/useRecentlyChanged';
7
+ import { default as React } from 'react';
8
+ import { FilterState, AxisLocks } from '../filter/types';
9
+ /** The search control's state: the query, and whether the control is open. */
10
+ export interface SearchState {
11
+ query: string;
12
+ active: boolean;
13
+ }
14
+ /** Which of the bar's own controls are offered: the search on by default, the lock toggles when the host controls `locks` or asks here. */
15
+ export interface FilterBarChrome {
16
+ /** The search control. */
17
+ search?: boolean;
18
+ /** The lock toggle on each axis — meaningful only to a host with a second view to lock against. */
19
+ locks?: boolean;
20
+ }
21
+ /**
22
+ * The bar's inputs. The filter and its callback are the one requirement; the
23
+ * values each axis offers come from `values` or from `graph` — one of the
24
+ * two — and everything else the pane supplies for its own bar has a default
25
+ * here, so a host's second view renders the same bar without rebuilding the
26
+ * pane's internals.
27
+ */
28
+ export type FilterBarProps = FilterBarBaseProps & ({
29
+ /** Values available per axis — the one source every axis's chips are built from; an axis with no entry renders an empty, disabled section rather than vanishing. */
30
+ values: ReadonlyMap<string, readonly string[]>;
31
+ graph?: undefined;
32
+ } | {
33
+ /** The graph the bar filters: the values each axis offers are read off its nodes. */
34
+ graph: Graph;
35
+ values?: undefined;
36
+ });
37
+ interface FilterBarBaseProps {
38
+ /** The taxonomy, when not supplied through `TaxonomyContext`. */
39
+ taxonomy?: Taxonomy;
40
+ /** Which controls are offered. Default: the search; the lock toggles when the host controls `locks` with `onLocksChange`. */
41
+ chrome?: FilterBarChrome;
42
+ /**
43
+ * The bar's theme: a choice, or the resolved theme a pane hands it.
44
+ * Default `system` — the nearest ancestor's `data-theme`, else the OS. The
45
+ * bar carries the scheme's variables and its `data-theme` on its own root,
46
+ * so its chips read their tints without a wrapper.
47
+ */
48
+ theme?: ThemeChoice;
49
+ /** What `system` resolves to, for a host that knows its own theme. Given, the bar reads no ancestor's `data-theme` and no OS preference for it. */
50
+ systemTheme?: Theme;
51
+ /** Chip badge counts per axis and value, read by the host off its own data. Absent: the graph's entity counts (the copies of one `copyKey` once) when `graph` is given, else no badges. */
52
+ counts?: ReadonlyMap<string, ReadonlyMap<string, number>>;
53
+ filter: FilterState;
54
+ /** Every change the user makes, with the axis they changed — for a host that keeps which axes are the user's (`reseedFilter`). */
55
+ onFilterChange: (next: FilterState, touched: readonly AxisId[]) => void;
56
+ /** Which axes are locked. Controlling it with `onLocksChange` shows the lock toggles; a value without its callback is read-only and shows none unless `chrome.locks` asks. */
57
+ locks?: AxisLocks;
58
+ onLocksChange?: (next: AxisLocks) => void;
59
+ /** Axes a focus just overrode the lock of. The bar flashes them once, for 600 ms, whenever the value changes; the host need not clear it. */
60
+ overriddenLocks?: ReadonlySet<AxisId>;
61
+ /** The values that just turned on, per axis — drives the chip flash. Default: tracked here from the filter's changes. */
62
+ recentlyChanged?: RecentlyChanged;
63
+ /** The search control. Default: the bar's own, closed and empty. */
64
+ search?: SearchState;
65
+ onSearchChange?: (next: SearchState) => void;
66
+ /** The search input, for a host that focuses it from its own shortcut. */
67
+ searchInputRef?: React.RefObject<HTMLInputElement>;
68
+ searchTitle?: string;
69
+ searchPlaceholder?: string;
70
+ /** View-specific search adornment rendered inside the search control. */
71
+ searchExtra?: React.ReactNode;
72
+ /** Search matches hidden by a chip, per axis and value — the badge the chip hiding them wears. Optional. */
73
+ hiddenMatches?: ReadonlyMap<string, ReadonlyMap<string, number>>;
74
+ /** The axes shown, in order; may be empty. Default: every declared axis, editable in place. */
75
+ axes?: readonly string[];
76
+ /** Omitted by a host that controls the axes and does not want it edited in place. */
77
+ onAxesChange?: (next: readonly string[]) => void;
78
+ /** The host's findings bars, rendered under the chips. */
79
+ findings?: React.ReactNode;
80
+ /** Flash the whole bar once whenever this value changes to something truthy — a counter a host bumps when its data reloads. */
81
+ refreshFlash?: boolean | number;
82
+ }
83
+ /**
84
+ * The shared filter bar: a section of chips per axis shown with its lock and
85
+ * remove controls, the add-axis control, the search control, and the host's
86
+ * findings under them. The pane renders it for itself; a host renders it in
87
+ * a second view with the same props, through `useLinkedViews`.
88
+ */
89
+ export declare function FilterBar({ taxonomy: taxonomyProp, chrome, theme: themeChoice, systemTheme, counts: hostCounts, values: hostValues, graph, filter: filterProp, onFilterChange, locks: locksProp, onLocksChange, overriddenLocks, recentlyChanged: hostRecentlyChanged, search: searchProp, onSearchChange, searchInputRef: hostInputRef, searchTitle, searchPlaceholder, searchExtra, hiddenMatches, findings, refreshFlash, axes: hostAxes, onAxesChange, }: FilterBarProps): import("react/jsx-runtime").JSX.Element;
90
+ export {};