@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 +21 -0
- package/README.md +424 -0
- package/dist-lib/components/FilterBar.d.ts +90 -0
- package/dist-lib/components/GraphView.d.ts +213 -0
- package/dist-lib/components/GroupLegend.d.ts +25 -0
- package/dist-lib/components/graph-view/focus.d.ts +16 -0
- package/dist-lib/components/graph-view/useFocusRequest.d.ts +9 -0
- package/dist-lib/components/graph-view/useLinkedViews.d.ts +85 -0
- package/dist-lib/components/graph-view/useRecentlyChanged.d.ts +5 -0
- package/dist-lib/components/graph-view/useTaxonomy.d.ts +11 -0
- package/dist-lib/components/graph-view/useTheme.d.ts +26 -0
- package/dist-lib/filter/linked.d.ts +26 -0
- package/dist-lib/filter/reconcile.d.ts +38 -0
- package/dist-lib/filter/toggle.d.ts +12 -0
- package/dist-lib/filter/types.d.ts +162 -0
- package/dist-lib/graph/axis.d.ts +290 -0
- package/dist-lib/graph/groups.d.ts +31 -0
- package/dist-lib/graph/layout.d.ts +104 -0
- package/dist-lib/graph/model.d.ts +149 -0
- package/dist-lib/graph/scalar.d.ts +88 -0
- package/dist-lib/graph/scheme.d.ts +68 -0
- package/dist-lib/graph/shapes.d.ts +227 -0
- package/dist-lib/graph/taxonomy.d.ts +207 -0
- package/dist-lib/graph/validate.d.ts +47 -0
- package/dist-lib/index.d.ts +35 -0
- package/dist-lib/index.js +4895 -0
- package/dist-lib/index.js.map +1 -0
- package/dist-lib/styles.css +1 -0
- package/package.json +63 -0
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 {};
|