@john-guerra/fisheye-nav 0.1.0

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/package.json ADDED
@@ -0,0 +1,98 @@
1
+ {
2
+ "name": "@john-guerra/fisheye-nav",
3
+ "version": "0.1.0",
4
+ "description": "A focus+context navigator for hierarchies: an icicle whose leaf axis is fisheyed, or a flat list indented by level. Vanilla reactive widget, with Svelte and React wrappers.",
5
+ "type": "module",
6
+ "main": "dist/fisheye-nav.min.js",
7
+ "module": "dist/fisheye-nav.esm.js",
8
+ "svelte": "./src/svelte/FisheyeNav.svelte",
9
+ "browser": "dist/fisheye-nav.min.js",
10
+ "unpkg": "dist/fisheye-nav.min.js",
11
+ "jsdelivr": "dist/fisheye-nav.min.js",
12
+ "exports": {
13
+ ".": {
14
+ "import": "./dist/fisheye-nav.esm.js",
15
+ "default": "./dist/fisheye-nav.min.js"
16
+ },
17
+ "./core": "./src/index.js",
18
+ "./svelte": "./src/svelte/FisheyeNav.svelte",
19
+ "./react": "./dist/react.esm.js"
20
+ },
21
+ "files": [
22
+ "dist",
23
+ "src"
24
+ ],
25
+ "sideEffects": false,
26
+ "scripts": {
27
+ "build": "rollup -c",
28
+ "dev": "concurrently \"rollup -c -w\" \"vite example\"",
29
+ "test": "vitest run",
30
+ "test:watch": "vitest",
31
+ "lint": "eslint src test example",
32
+ "format": "prettier --write \"{src,test,example,site}/**/*.{js,jsx,svelte,html,css}\" \"*.{js,json,md}\"",
33
+ "prepublishOnly": "npm run build && npm test",
34
+ "format:check": "prettier --check \"{src,test,example,site}/**/*.{js,jsx,svelte,html,css}\" \"*.{js,json,md}\"",
35
+ "site": "npm run build && mkdir -p _site && cp site/index.html _site/ && cp example/demo.js _site/ && cp dist/fisheye-nav.esm.js _site/ && npx http-server _site -p 5191"
36
+ },
37
+ "keywords": [
38
+ "fisheye",
39
+ "focus+context",
40
+ "icicle",
41
+ "hierarchy",
42
+ "tree",
43
+ "navigator",
44
+ "d3",
45
+ "reactive-widget",
46
+ "degree-of-interest",
47
+ "visualization"
48
+ ],
49
+ "dependencies": {
50
+ "d3-hierarchy": "^3.1.2",
51
+ "d3-scale": "^4.0.2",
52
+ "d3-selection": "^3.0.0",
53
+ "reactive-widget-helper": "^0.0.3"
54
+ },
55
+ "peerDependencies": {
56
+ "react": ">=17",
57
+ "svelte": ">=4"
58
+ },
59
+ "peerDependenciesMeta": {
60
+ "react": {
61
+ "optional": true
62
+ },
63
+ "svelte": {
64
+ "optional": true
65
+ }
66
+ },
67
+ "devDependencies": {
68
+ "@rollup/plugin-node-resolve": "^15.2.3",
69
+ "@rollup/plugin-terser": "^0.4.4",
70
+ "concurrently": "^9.1.0",
71
+ "eslint": "^9.8.0",
72
+ "eslint-config-prettier": "^9.1.0",
73
+ "fast-check": "^3.23.1",
74
+ "jsdom": "^25.0.1",
75
+ "prettier": "^3.3.3",
76
+ "prettier-plugin-svelte": "^4.1.1",
77
+ "rollup": "^4.20.0",
78
+ "rollup-plugin-postcss": "^4.0.2",
79
+ "vite": "^5.4.11",
80
+ "vitest": "^2.1.8"
81
+ },
82
+ "author": {
83
+ "name": "John Alexis Guerra Gómez",
84
+ "url": "https://johnguerra.co"
85
+ },
86
+ "license": "MIT",
87
+ "repository": {
88
+ "type": "git",
89
+ "url": "git+https://github.com/john-guerra/fisheye-nav.git"
90
+ },
91
+ "bugs": {
92
+ "url": "https://github.com/john-guerra/fisheye-nav/issues"
93
+ },
94
+ "homepage": "https://john-guerra.github.io/fisheye-nav/",
95
+ "publishConfig": {
96
+ "access": "public"
97
+ }
98
+ }
@@ -0,0 +1,305 @@
1
+ /**
2
+ * The widget: a reactivewidgets-style DOM node.
3
+ *
4
+ * const nav = fisheyeNav({ data, keys: ["year","month","day"] });
5
+ * nav.addEventListener("input", () => console.log(nav.value)); // the path
6
+ * nav.value = [{key:"year", value:"2024"}]; // set silently
7
+ *
8
+ * `value` is the path to whatever the user clicked, truncated at that depth:
9
+ * click a day and you get the full 3-level path; click the "2024" band and you
10
+ * get just [{key:"year", value:"2024"}]. Hover drives the LENS and never fires
11
+ * `input` — otherwise every mousemove would look like a selection.
12
+ */
13
+
14
+ // Subpath, not the bare specifier: reactive-widget-helper@0.0.3 declares
15
+ // `"type": "module"` but points `main` at a UMD bundle, so Node's ESM resolver
16
+ // loads it, finds no exports, and hands back `undefined`. Browsers and bundlers
17
+ // dodge this by preferring the `module` field; Node (vitest, SSR) does not.
18
+ // Remove this once upstream ships an `exports` map.
19
+ import ReactiveWidget from "reactive-widget-helper/dist/ReactiveWidget.es.js";
20
+ import { pointer } from "d3-selection";
21
+ import { normalize, leavesOf, idOfPath } from "./hierarchy.js";
22
+ import { layout, LAYOUT_DEFAULTS } from "./layout/index.js";
23
+ import { render, RENDER_DEFAULTS, STYLES } from "./render/index.js";
24
+ import { injectStyle } from "./render/style.js";
25
+ import {
26
+ buildControls,
27
+ readStore,
28
+ writeStore,
29
+ sanitize,
30
+ PERSIST_KEYS,
31
+ } from "./render/controls.js";
32
+
33
+ export const NAV_DEFAULTS = {
34
+ ...LAYOUT_DEFAULTS,
35
+ ...RENDER_DEFAULTS,
36
+ style: "flat",
37
+ /** A ⚙ that live-tunes the lens. Pass false to drive the options yourself. */
38
+ controls: true,
39
+ /** localStorage key, so a tuned lens survives a reload. Omit and listen for
40
+ * the "settings" event to use your own store instead. */
41
+ persistKey: null,
42
+ // `hybrid`, not `fisheye`, is the default: pure fisheye draws every leaf, which
43
+ // on a real library (thousands of days) means thousands of sub-pixel bands —
44
+ // honest about density, unreadable as a navigator. Hybrid bounds the row count
45
+ // to what actually fits and stays readable at any library size. Pick `fisheye`
46
+ // deliberately when you want the undecimated silhouette.
47
+ layout: "hybrid",
48
+ width: null, // null = measure the container
49
+ height: null,
50
+ };
51
+
52
+ export function fisheyeNav(options = {}) {
53
+ const doc = options.document ?? globalThis.document;
54
+ const el = doc.createElement("div");
55
+ el.className = "fisheye-nav";
56
+ injectStyle(doc);
57
+
58
+ const svg = doc.createElementNS("http://www.w3.org/2000/svg", "svg");
59
+ svg.setAttribute("preserveAspectRatio", "none");
60
+ svg.setAttribute("role", "listbox");
61
+ el.appendChild(svg);
62
+
63
+ // A persisted lens wins over the defaults but NOT over what the caller passed
64
+ // explicitly — a host pinning `style: "icicle"` in code means it, and a stale
65
+ // localStorage entry must not silently override it.
66
+ const stored = sanitize(readStore(options.persistKey), {});
67
+ let opts = { ...NAV_DEFAULTS, ...stored, ...options };
68
+ let root = null; // the normalized hierarchy
69
+ let model = null; // the last layout result
70
+ let hoverPx = null; // the pinned cursor pixel, or null when not hovering
71
+ // Where DOI opens its window. Moves ONLY on a click (see the click handler) —
72
+ // never on hover, or the rows reshuffle under the cursor. Null = follow the
73
+ // selection.
74
+ let anchorLeaf = null;
75
+ let size = { width: 0, height: 0 };
76
+ let controls = null;
77
+
78
+ // --- state derivation -----------------------------------------------------
79
+
80
+ const rebuild = () => {
81
+ root = normalize({
82
+ data: opts.data,
83
+ keys: opts.keys,
84
+ root: opts.root,
85
+ children: opts.children,
86
+ value: opts.value,
87
+ count: opts.count,
88
+ label: opts.label,
89
+ // Flat mode needs a real row per internal node to have something to indent;
90
+ // an icicle gives parents a column instead, so headers would be redundant.
91
+ headers: opts.style === "flat",
92
+ });
93
+ };
94
+
95
+ /** The leaf the current selection sits on — where the lens rests when not hovering. */
96
+ const selectedLeaf = () => {
97
+ const path = el.value;
98
+ if (!path?.length || !root) return 0;
99
+ let node = root;
100
+ for (const step of path) {
101
+ const next = (node.children ?? []).find(
102
+ (c) =>
103
+ !c.data.header &&
104
+ c.data.key === step.key &&
105
+ c.data.value === step.value,
106
+ );
107
+ if (!next) break;
108
+ node = next;
109
+ }
110
+ return node.leafStart ?? 0;
111
+ };
112
+
113
+ // `idOfPath`, not a second copy of the same join. This built the id with a
114
+ // SPACE while the rows built it with PATH_SEP, so any selection deeper than one
115
+ // level failed to match its row: selecting a day never highlighted it (and,
116
+ // once the label floor learned about the selection, never labelled it either).
117
+ const selectedId = () => {
118
+ const path = el.value;
119
+ return path?.length ? idOfPath(path) : null;
120
+ };
121
+
122
+ const measure = () => {
123
+ const w = opts.width ?? el.clientWidth;
124
+ const h = opts.height ?? el.clientHeight;
125
+ size = { width: Math.max(0, w), height: Math.max(0, h) };
126
+ };
127
+
128
+ const draw = () => {
129
+ if (!root) rebuild();
130
+ measure();
131
+ if (!size.width || !size.height) return;
132
+
133
+ model = layout(root, {
134
+ ...opts,
135
+ height: size.height,
136
+ // Two focuses, and keeping them apart is the whole trick: the PIXEL steers
137
+ // the lens, continuously; the LEAF decides which rows DOI opens, and it
138
+ // moves only on a click. See the pointermove and click handlers.
139
+ focus: { px: hoverPx ?? undefined, leaf: anchorLeaf ?? selectedLeaf() },
140
+ });
141
+
142
+ render(svg, model, { ...opts, ...size, selectedId: selectedId() });
143
+ };
144
+
145
+ // --- interaction ----------------------------------------------------------
146
+
147
+ /**
148
+ * HOVER MOVES THE LENS. IT DOES NOT RE-SELECT. This is load-bearing.
149
+ *
150
+ * A lens is only stable over a FIXED set of rows. DOI selection is not fixed —
151
+ * it is a function of the focus — so re-running it on every mousemove reshuffles
152
+ * the rows under the cursor and the leaf you are pointing at changes
153
+ * discontinuously: hovering down through January you get 27 Jan, Feb, 23 Feb,
154
+ * 27 Feb, Mar, and whole weeks are simply unreachable. (Plain `fisheye` selects
155
+ * every leaf, so its set is fixed, and it never does this.)
156
+ *
157
+ * Re-anchoring DOI on whatever the cursor touches does not fix it either: the
158
+ * row budget is fixed, so opening one region collapses another, and the tail
159
+ * oscillates — 9 more, 8 more, ... 2 more, 9 more, forever.
160
+ *
161
+ * So which rows are open changes only when the user SAYS so, by clicking. Hover
162
+ * magnifies what is there; a click drills in and re-opens the window around it
163
+ * (via `selectedLeaf`). That is the DOI-browser tradition, and it is what the
164
+ * original AutoGallery sidebar did — its decimated set was fixed too, which is
165
+ * precisely why hovering it never jumped.
166
+ */
167
+ svg.addEventListener("pointermove", (e) => {
168
+ hoverPx = pointer(e, svg)[1];
169
+ draw();
170
+ });
171
+
172
+ svg.addEventListener("pointerleave", () => {
173
+ hoverPx = null; // the lens snaps back to the selection
174
+ draw();
175
+ });
176
+
177
+ /**
178
+ * EVERY ITEM MUST BE SELECTABLE. The column shows ~40 rows of a library that
179
+ * may hold thousands of leaves, so most of them are, at any moment, folded into
180
+ * a closed subtree or an elision. Reaching them therefore has to be a matter of
181
+ * DRILLING IN — and every click must make progress toward that, or some leaves
182
+ * are unreachable forever.
183
+ *
184
+ * Which is why a click moves the DOI anchor INTO what was clicked, rather than
185
+ * relying on the selection to do it:
186
+ *
187
+ * - a leaf, a header, a closed subtree -> select it; the window opens there.
188
+ * - an ELISION (a run of closed siblings, "7 more") stands for no single node,
189
+ * so there is no path to select. It used to select the elision's PARENT —
190
+ * which, if the parent was already selected, changed nothing at all: click
191
+ * it forever and the days inside it never open. A dead end, and a whole
192
+ * region of the library you could see but never reach.
193
+ *
194
+ * So the anchor goes to the first leaf the clicked row stands for. That run
195
+ * always scores highest next time, so it always opens. Drill enough and any
196
+ * leaf gets its own row.
197
+ */
198
+ svg.addEventListener("click", (e) => {
199
+ const g = e.target.closest?.("g.fn-row");
200
+ if (!g) return;
201
+ const row = model?.rows.find((r) => r.id === g.getAttribute("data-id"));
202
+ if (!row) return;
203
+
204
+ anchorLeaf = row.leafStart; // always progress: open where you clicked
205
+
206
+ // `row.path` — NOT `row.parentNode.path`. An elision row already carries its
207
+ // parent's path (bands.js), and `parentNode` is a d3 hierarchy node, whose
208
+ // `.path` is d3's own path(target) METHOD. Reading it got a function, whose
209
+ // `.length` is 1 (its arity), so the emptiness guard passed and `.map` threw —
210
+ // killing the handler before it could redraw. The anchor moved, the screen did
211
+ // not, and clicking a "2 more" row did nothing at all, forever: the two years
212
+ // it stood for could be seen and never reached.
213
+ const path = row.path;
214
+ if (!path?.length) {
215
+ draw(); // an elision at the root: nothing to select, but still drill in
216
+ return;
217
+ }
218
+ el.setValue(path.map((p) => ({ ...p })));
219
+ });
220
+
221
+ // --- reactive-widget contract --------------------------------------------
222
+
223
+ const widget = ReactiveWidget(el, {
224
+ value: options.selected ?? null,
225
+ // Called on `input` AND on a silent `.value = x` set — so a host pushing the
226
+ // selection in gets the highlight without an event echoing back out.
227
+ showValue: () => draw(),
228
+ });
229
+
230
+ /** Merge new options and redraw. Re-normalizes only when it has to. */
231
+ el.update = (next = {}) => {
232
+ const restructure =
233
+ ("data" in next && next.data !== opts.data) ||
234
+ ("root" in next && next.root !== opts.root) ||
235
+ ("keys" in next && String(next.keys) !== String(opts.keys)) ||
236
+ ("style" in next && next.style !== opts.style); // headers on/off
237
+ opts = { ...opts, ...next };
238
+ if (!STYLES.includes(opts.style)) opts.style = "flat";
239
+ if (restructure) rebuild();
240
+ controls?.sync();
241
+ draw();
242
+ };
243
+
244
+ // --- settings -------------------------------------------------------------
245
+
246
+ /** Apply a settings change: persist it, tell the host, redraw. */
247
+ const applySettings = (patch) => {
248
+ el.update(patch);
249
+ writeStore(opts.persistKey, opts);
250
+ // A host that wants its own store (or wants to mirror the lens elsewhere)
251
+ // listens for this instead of passing a persistKey.
252
+ el.dispatchEvent(
253
+ new CustomEvent("settings", { detail: el.getSettings(), bubbles: true }),
254
+ );
255
+ };
256
+
257
+ /** The tunable lens settings — the subset that is persisted. */
258
+ el.getSettings = () => {
259
+ const out = {};
260
+ for (const k of PERSIST_KEYS) out[k] = opts[k];
261
+ return out;
262
+ };
263
+
264
+ el.setSettings = (patch) =>
265
+ applySettings(sanitize({ ...el.getSettings(), ...patch }, {}));
266
+
267
+ el.resetSettings = () => {
268
+ const defaults = {};
269
+ for (const k of PERSIST_KEYS) defaults[k] = NAV_DEFAULTS[k];
270
+ applySettings(defaults);
271
+ };
272
+
273
+ if (opts.controls !== false) {
274
+ controls = buildControls(el, {
275
+ get: () => opts,
276
+ set: applySettings,
277
+ reset: () => el.resetSettings(),
278
+ });
279
+ }
280
+
281
+ el.getRows = () => model?.rows ?? [];
282
+ el.getRoot = () => root;
283
+ // Real leaves only. The synthetic header cells are an internal device for the
284
+ // flat view's outline; leaking them here would make the count depend on the
285
+ // current style, which is nobody's idea of a leaf count.
286
+ el.getLeafCount = () =>
287
+ root ? leavesOf(root).filter((n) => !n.data.header).length : 0;
288
+
289
+ if (typeof ResizeObserver !== "undefined") {
290
+ const ro = new ResizeObserver(() => draw());
291
+ ro.observe(el);
292
+ el.destroy = () => {
293
+ ro.disconnect();
294
+ controls?.destroy();
295
+ };
296
+ } else {
297
+ el.destroy = () => controls?.destroy();
298
+ }
299
+
300
+ rebuild();
301
+ draw();
302
+ return widget;
303
+ }
304
+
305
+ export default fisheyeNav;
@@ -0,0 +1,259 @@
1
+ /**
2
+ * Input normalization: both accepted input shapes collapse into ONE decorated
3
+ * `d3.hierarchy`, so every layout strategy and every renderer downstream sees
4
+ * exactly one data structure.
5
+ *
6
+ * A) flat rows + keys { data: [...], keys: ["year","month","day"] }
7
+ * B) nested tree { root: {...}, children: d => d.children }
8
+ *
9
+ * (A) is the "tidy long table" form — a sorted list of value-tuples, which is
10
+ * what a single SQL `GROUP BY` hands you. (B) is for ragged/precomputed trees.
11
+ */
12
+
13
+ import { hierarchy } from "d3-hierarchy";
14
+
15
+ /** Field on every node.data. `value` is the node's own key-value (e.g. "2024"),
16
+ * NOT a count — counts live on `node.count`.
17
+ * @typedef {{key: string|null, value: any, label: string, row: any}} NodeDatum */
18
+
19
+ // A separator that cannot occur inside a key or a value. Written as an ESCAPE,
20
+ // never as a literal NUL byte: a raw \0 in the source makes the whole file look
21
+ // like binary data, and grep silently reports no matches in it. That cost an hour.
22
+ const PATH_SEP = "\u0000";
23
+
24
+ /**
25
+ * The id of a path — the ONE place it is spelled.
26
+ *
27
+ * `fisheyeNav` used to build the selected id by joining with a space while the
28
+ * rows joined with PATH_SEP, so a selection deeper than one level could never
29
+ * match a row: selecting a DAY never highlighted it, and never labelled it. It
30
+ * hid because the only test selected a depth-1 path, where the two joins produce
31
+ * the same string.
32
+ */
33
+ export const idOfPath = (path) =>
34
+ path.map((p) => p.key + "=" + p.value).join(PATH_SEP);
35
+
36
+ /**
37
+ * Roll a flat, pre-sorted row list up into a nested tree, preserving the row
38
+ * order as the child order at every level. Order matters: the host (AutoGallery)
39
+ * hands us rows already sorted the way its feed is sorted, and the navigator
40
+ * must mirror that exactly — so we never re-sort, we just group.
41
+ */
42
+ function rollup(rows, keys, valueOf, countOf) {
43
+ const root = { key: null, value: null, children: [], row: null };
44
+ const byPath = new Map([["", root]]);
45
+
46
+ for (const row of rows) {
47
+ let node = root;
48
+ let path = "";
49
+ for (const key of keys) {
50
+ const value = valueOf(row, key);
51
+ path += PATH_SEP + key + "=" + value;
52
+ let child = byPath.get(path);
53
+ if (!child) {
54
+ child = { key, value, children: [], row: null };
55
+ byPath.set(path, child);
56
+ node.children.push(child);
57
+ }
58
+ node = child;
59
+ }
60
+ // `node` is now the leaf for this row's key-tuple. Several rows can land on
61
+ // the same leaf; their counts add up and the last row wins as the exemplar.
62
+ node.row = row;
63
+ node.ownCount = (node.ownCount ?? 0) + countOf(row);
64
+ }
65
+ return root;
66
+ }
67
+
68
+ /**
69
+ * Give every internal node a synthetic HEADER pseudo-leaf as its first child.
70
+ *
71
+ * This is what makes the flat (indented outline) view possible without a second
72
+ * layout engine. The layout distributes pixels over the LEAF axis, and a
73
+ * parent's band is exactly the span of its children — so there is no vertical
74
+ * space anywhere for a parent's own label to live. In an icicle that's fine (the
75
+ * parent owns a column); in a flat list it means an internal node has no row,
76
+ * and with nothing to indent relative to, "indent by level" says nothing.
77
+ *
78
+ * A header cell costs one slot on the leaf axis and carries `count: 0`, so mass
79
+ * conservation is untouched, and it sits INSIDE its parent's interval, so
80
+ * containment is untouched. Every downstream stage needs zero changes.
81
+ */
82
+ function withHeaders(raw, childrenOf, isRoot = true) {
83
+ const kids = childrenOf(raw);
84
+ if (!kids?.length) return raw;
85
+ const nested = kids.map((k) => withHeaders(k, childrenOf, false));
86
+ // The root is never drawn (depth 0), so a header for it would surface as a
87
+ // stray depth-1 row labelled with nothing.
88
+ if (isRoot) return { ...raw, children: nested };
89
+ return {
90
+ ...raw,
91
+ children: [{ __header: true, __of: raw, children: null }, ...nested],
92
+ };
93
+ }
94
+
95
+ /** Depth-first, in child order. */
96
+ function walk(node, visit) {
97
+ visit(node);
98
+ if (node.children) for (const c of node.children) walk(c, visit);
99
+ }
100
+
101
+ /**
102
+ * Decorate a d3.hierarchy in place with everything the layouts and renderers
103
+ * need, so neither has to re-derive it:
104
+ * node.count photo mass (leaf: from the data; internal: sum of children)
105
+ * node.leafStart index of its first descendant leaf } the interval the
106
+ * node.leafEnd index past its last descendant leaf } fisheye distorts
107
+ * node.path [{key, value}, …] from the root down to this node
108
+ * node.id stable string id (survives relayout; keys the DOM)
109
+ * node.label display string
110
+ *
111
+ * `leafStart`/`leafEnd` are the load-bearing bit of the redesign: once every
112
+ * node owns a LEAF INTERVAL, a parent's band is just the union of its children's
113
+ * bands, and "parents contain their children" stops being something a renderer
114
+ * has to be careful about and becomes true by construction.
115
+ */
116
+ function decorate(root, labelOf) {
117
+ const leaves = [];
118
+ walk(root, (n) => {
119
+ if (!n.children?.length) leaves.push(n);
120
+ });
121
+
122
+ leaves.forEach((leaf, i) => {
123
+ leaf.leafStart = i;
124
+ leaf.leafEnd = i + 1;
125
+ });
126
+
127
+ // Pre-order: a node's path extends its PARENT's, so parents go first.
128
+ root.eachBefore((n) => {
129
+ const d = n.data;
130
+ // Coerce here, once. A host's `label` accessor is arbitrary user code and
131
+ // may well return undefined for a key it doesn't know about (an unknown
132
+ // dimension, the synthetic root's null key). The renderer must never be the
133
+ // place that discovers this — a widget that throws because a consumer's
134
+ // accessor came back empty is a broken widget.
135
+ n.label = String(labelOf(d.value, d.key, n) ?? d.value ?? "");
136
+ if (d.header) {
137
+ // A header stands in for its parent: same path (so clicking it selects the
138
+ // parent, which is what you mean when you click "2024"), distinct id.
139
+ n.keyPath = n.parent.keyPath;
140
+ n.id = n.parent.id + "#header";
141
+ return;
142
+ }
143
+ // `keyPath`, NOT `path` — d3's HierarchyNode already has a `.path(target)`
144
+ // method, and an own property would silently shadow it.
145
+ n.keyPath =
146
+ n.depth === 0
147
+ ? []
148
+ : [...n.parent.keyPath, { key: d.key, value: d.value }];
149
+ n.id = idOfPath(n.keyPath);
150
+ });
151
+
152
+ // Post-order: a node's count and leaf interval aggregate its CHILDREN's, so
153
+ // children go first.
154
+ root.eachAfter((n) => {
155
+ if (!n.children?.length) {
156
+ n.count = n.data.ownCount ?? n.data.count ?? 0;
157
+ return;
158
+ }
159
+ n.count = 0;
160
+ n.leafStart = Infinity;
161
+ n.leafEnd = -Infinity;
162
+ for (const c of n.children) {
163
+ n.count += c.count;
164
+ if (c.leafStart < n.leafStart) n.leafStart = c.leafStart;
165
+ if (c.leafEnd > n.leafEnd) n.leafEnd = c.leafEnd;
166
+ }
167
+ });
168
+
169
+ root.leaves_ = leaves;
170
+ return root;
171
+ }
172
+
173
+ const defaultLabel = (value) => (value == null ? "" : String(value));
174
+
175
+ /**
176
+ * Normalize either input shape into one decorated hierarchy.
177
+ *
178
+ * @param {object} spec
179
+ * @param {any[]} [spec.data] flat, pre-sorted rows (shape A)
180
+ * @param {string[]} [spec.keys] the levels, outermost first (shape A)
181
+ * @param {any} [spec.root] a nested tree (shape B)
182
+ * @param {(d:any)=>any[]} [spec.children] child accessor (shape B)
183
+ * @param {(row:any, key:string)=>any} [spec.value] how to read a key off a row
184
+ * @param {(d:any)=>number} [spec.count] leaf mass; defaults to `d.count ?? 1`
185
+ * @param {(value:any, key:string, node:any)=>string} [spec.label]
186
+ * @returns {import("d3-hierarchy").HierarchyNode<NodeDatum> & {leaves_: any[]}}
187
+ */
188
+ export function normalize(spec = {}) {
189
+ const {
190
+ data,
191
+ keys,
192
+ root: treeRoot,
193
+ children = (d) => d.children,
194
+ value = (row, key) => row[key],
195
+ count = (d) => d.count ?? 1,
196
+ label = defaultLabel,
197
+ headers = false,
198
+ } = spec;
199
+
200
+ const maybeHeaders = (raw, childrenOf) =>
201
+ headers ? withHeaders(raw, childrenOf) : raw;
202
+
203
+ let root;
204
+ if (treeRoot != null) {
205
+ root = hierarchy(maybeHeaders(treeRoot, children), (d) =>
206
+ headers ? d.children : children(d),
207
+ );
208
+ // Shape B nodes carry their own key/value/count; mirror them onto the
209
+ // NodeDatum contract so `decorate` and the renderers stay shape-agnostic.
210
+ root.each((n) => {
211
+ const d = n.data ?? {};
212
+ const of = d.__header ? d.__of : d;
213
+ n.data = {
214
+ key: of.key ?? null,
215
+ value: of.value ?? of.name ?? of.id ?? null,
216
+ row: of,
217
+ header: !!d.__header,
218
+ ownCount: d.__header ? 0 : n.children?.length ? undefined : count(of),
219
+ };
220
+ });
221
+ } else if (Array.isArray(data) && Array.isArray(keys)) {
222
+ root = hierarchy(
223
+ maybeHeaders(rollup(data, keys, value, count), (d) => d.children),
224
+ (d) => d.children,
225
+ );
226
+ if (headers) {
227
+ root.each((n) => {
228
+ if (!n.data.__header) return;
229
+ const of = n.data.__of;
230
+ n.data = {
231
+ key: of.key,
232
+ value: of.value,
233
+ row: of.row,
234
+ header: true,
235
+ ownCount: 0,
236
+ };
237
+ });
238
+ }
239
+ } else {
240
+ // An empty widget is a normal state (no library loaded yet), not an error.
241
+ root = hierarchy({ key: null, value: null, children: [], ownCount: 0 });
242
+ }
243
+
244
+ return decorate(root, label);
245
+ }
246
+
247
+ /** The ordered leaf list — the sequence the fisheye lens distorts. */
248
+ export function leavesOf(root) {
249
+ return root.leaves_ ?? root.leaves();
250
+ }
251
+
252
+ /** Every node except the synthetic root, in depth-first (visual) order. */
253
+ export function nodesOf(root) {
254
+ const out = [];
255
+ walk(root, (n) => {
256
+ if (n.depth > 0) out.push(n);
257
+ });
258
+ return out;
259
+ }
package/src/index.js ADDED
@@ -0,0 +1,18 @@
1
+ export { fisheyeNav, fisheyeNav as default } from "./fisheyeNav.js";
2
+ export { normalize, leavesOf, nodesOf } from "./hierarchy.js";
3
+ export {
4
+ layout,
5
+ LAYOUTS,
6
+ LAYOUT_DEFAULTS,
7
+ selectAll,
8
+ selectDOI,
9
+ doiScore,
10
+ leafDistance,
11
+ fisheyePosition,
12
+ basePositions,
13
+ distort,
14
+ slotAtPixel,
15
+ buildRows,
16
+ makeBarScale,
17
+ } from "./layout/index.js";
18
+ export { STYLES } from "./render/index.js";