@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/LICENSE +21 -0
- package/README.md +172 -0
- package/dist/fisheye-nav.esm.js +4477 -0
- package/dist/fisheye-nav.js +4505 -0
- package/dist/fisheye-nav.min.js +1 -0
- package/dist/react.esm.js +4548 -0
- package/package.json +98 -0
- package/src/fisheyeNav.js +305 -0
- package/src/hierarchy.js +259 -0
- package/src/index.js +18 -0
- package/src/layout/bands.js +135 -0
- package/src/layout/index.js +202 -0
- package/src/layout/position.js +142 -0
- package/src/layout/scales.js +41 -0
- package/src/layout/select.js +256 -0
- package/src/react/index.js +81 -0
- package/src/render/controls.js +282 -0
- package/src/render/index.js +215 -0
- package/src/render/style.js +135 -0
- package/src/svelte/FisheyeNav.svelte +72 -0
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;
|
package/src/hierarchy.js
ADDED
|
@@ -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";
|