@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.
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Stage 3: slots + pixel boundaries -> rows.
3
+ *
4
+ * This is where "parents contain their children" stops being something a
5
+ * renderer has to be careful about. Every node already owns a leaf interval
6
+ * (`leafStart`..`leafEnd`, from hierarchy.js), and the slots partition that same
7
+ * leaf axis. So a node's band is just the pixel span of the slots its leaf
8
+ * interval covers — and since a child's leaf interval is a SUBSET of its
9
+ * parent's, its band is necessarily inside its parent's. Containment is
10
+ * arithmetic, not diligence.
11
+ *
12
+ * A node is drawn iff its leaf interval is a whole number of slots AND no closed
13
+ * ancestor is standing in for it.
14
+ */
15
+
16
+ /** @typedef {{node: any|null, parent?: any, nodes?: any[], leafStart: number,
17
+ * leafEnd: number, count?: number}} Slot */
18
+
19
+ /**
20
+ * @param {any} root decorated hierarchy (see hierarchy.js)
21
+ * @param {Slot[]} slots contiguous partition of the leaf axis
22
+ * @param {number[]} pos slots.length + 1 pixel boundaries
23
+ * @param {{focusSlot?: number}} [opts]
24
+ * @returns {{rows: any[], maxCount: number}}
25
+ */
26
+ export function buildRows(root, slots, pos, opts = {}) {
27
+ const S = slots.length;
28
+ if (!S) return { rows: [], maxCount: 0 };
29
+
30
+ const nLeaves = slots[S - 1].leafEnd;
31
+ const slotOfLeaf = new Int32Array(nLeaves);
32
+ for (let j = 0; j < S; j++) {
33
+ for (let i = slots[j].leafStart; i < slots[j].leafEnd; i++)
34
+ slotOfLeaf[i] = j;
35
+ }
36
+
37
+ // Nodes that a closed slot is standing in for. Without this, a chain like
38
+ // 2019 > 12 > 31 that is closed at "2019" would draw all three bands stacked
39
+ // on the same pixels — they all happen to span exactly one slot.
40
+ const hidden = new Set();
41
+ const hide = (n) => n.each?.((d) => d !== n && hidden.add(d));
42
+ for (const slot of slots) {
43
+ if (slot.node?.children?.length) hide(slot.node);
44
+ if (slot.nodes) for (const n of slot.nodes) n.each((d) => hidden.add(d));
45
+ }
46
+
47
+ const focusSlot = opts.focusSlot;
48
+ const rows = [];
49
+ let maxCount = 0;
50
+
51
+ const push = (row) => {
52
+ rows.push(row);
53
+ if (row.terminal && row.count > maxCount) maxCount = row.count;
54
+ };
55
+
56
+ const band = (sStart, sEnd) => {
57
+ const y0 = pos[sStart];
58
+ const y1 = pos[sEnd];
59
+ return {
60
+ slotStart: sStart,
61
+ slotEnd: sEnd,
62
+ leafStart: slots[sStart].leafStart,
63
+ leafEnd: slots[sEnd - 1].leafEnd,
64
+ focused: focusSlot != null && focusSlot >= sStart && focusSlot < sEnd,
65
+ y0,
66
+ y1,
67
+ y: (y0 + y1) / 2,
68
+ thickness: y1 - y0,
69
+ };
70
+ };
71
+
72
+ root.each((node) => {
73
+ if (node.depth === 0) return; // the synthetic root is not a row
74
+ if (hidden.has(node)) return;
75
+ if (node.leafStart >= nLeaves) return;
76
+
77
+ const sStart = slotOfLeaf[node.leafStart];
78
+ const sEnd = slotOfLeaf[node.leafEnd - 1] + 1;
79
+ const aligned =
80
+ slots[sStart].leafStart === node.leafStart &&
81
+ slots[sEnd - 1].leafEnd === node.leafEnd;
82
+ if (!aligned) return;
83
+
84
+ const slot = slots[sStart];
85
+ const collapsed =
86
+ sEnd - sStart === 1 && slot.node === node && !!node.children?.length;
87
+ // Terminal rows carry the count histogram: real leaves and closed subtrees.
88
+ // Everything else is structure.
89
+ const terminal = collapsed || !node.children?.length;
90
+
91
+ push({
92
+ node,
93
+ parentNode: node.parent,
94
+ id: node.id,
95
+ depth: node.depth,
96
+ label: node.label,
97
+ path: node.keyPath,
98
+ count: node.count,
99
+ kind: node.data?.header
100
+ ? "header"
101
+ : collapsed
102
+ ? "collapsed"
103
+ : terminal
104
+ ? "leaf"
105
+ : "branch",
106
+ terminal,
107
+ elided: collapsed ? node.leafEnd - node.leafStart : 0,
108
+ ...band(sStart, sEnd),
109
+ });
110
+ });
111
+
112
+ // Elision slots have no node of their own, so they are not reachable from the
113
+ // walk above — emit them here.
114
+ slots.forEach((slot, j) => {
115
+ if (slot.node || !slot.nodes) return;
116
+ const first = slot.nodes[0];
117
+ push({
118
+ node: null,
119
+ parentNode: slot.parent,
120
+ nodes: slot.nodes,
121
+ id: `elision:${first.id}+${slot.nodes.length}`,
122
+ depth: first.depth,
123
+ label: `${slot.nodes.length} more`,
124
+ path: slot.parent.keyPath,
125
+ count: slot.count,
126
+ kind: "elision",
127
+ terminal: true,
128
+ elided: slot.leafEnd - slot.leafStart,
129
+ ...band(j, j + 1),
130
+ });
131
+ });
132
+
133
+ rows.sort((a, b) => a.y0 - b.y0 || a.depth - b.depth);
134
+ return { rows, maxCount };
135
+ }
@@ -0,0 +1,202 @@
1
+ /**
2
+ * The layout: one signature, three named strategies, ONE row contract.
3
+ *
4
+ * The strategies are not three algorithms — they are three cells of a 2x2 over
5
+ * two independent stages, `selection` x `positioning`:
6
+ *
7
+ * | uniform positions | fisheye positions
8
+ * -------------+-------------------+-------------------
9
+ * all leaves | "uniform" | "fisheye"
10
+ * DOI-selected | "doi" | "hybrid"
11
+ *
12
+ * Everything downstream (bands, renderers, tests) is shared, so adding a fourth
13
+ * strategy means adding a selector or a positioner, never a new pipeline.
14
+ *
15
+ * Note what this dissolves: AutoGallery's old `positioning: "rank" | "proportional"`
16
+ * knob existed only because decimation squashed the near-focus leaves into a
17
+ * sub-pixel sliver. Here, "fisheye" never decimates (so rank IS proportional),
18
+ * and "hybrid" magnifies over DOI-selected slots (so rank is simply correct).
19
+ * The knob has no reason to exist.
20
+ */
21
+
22
+ import { selectAll, selectDOI } from "./select.js";
23
+ import {
24
+ basePositions,
25
+ resolveFocus,
26
+ slotAtPixel,
27
+ distort,
28
+ clamp,
29
+ SIZE_BY,
30
+ } from "./position.js";
31
+ import { buildRows } from "./bands.js";
32
+ import { leavesOf } from "../hierarchy.js";
33
+
34
+ export const LAYOUTS = ["fisheye", "doi", "hybrid", "uniform"];
35
+ export { SIZE_BY };
36
+
37
+ export const LAYOUT_DEFAULTS = {
38
+ /** Fisheye strength. Higher = gentler. (Ported knob; same meaning as before.) */
39
+ distortion: 4,
40
+ /** Top/bottom inset, px. */
41
+ pad: 6,
42
+ /** Target minimum row height — sets the DOI row budget. */
43
+ minRowPx: 16,
44
+ /** What a band's HEIGHT encodes.
45
+ * "slots" — every visible group gets equal room, so height reads as INTEREST.
46
+ * "count" — height is proportional to photo mass, so a 40k-photo year visibly
47
+ * dwarfs a 400-photo one. A true value-encoded partition diagram. */
48
+ sizeBy: "slots",
49
+ /** Under `sizeBy: "count"`, the fraction of the column split EQUALLY before the
50
+ * rest is divided by mass. Without it, a zero-count group gets a zero-height
51
+ * band: invisible and impossible to click. 1 collapses back to "slots", so the
52
+ * two modes are the ends of one continuum. */
53
+ sizeFloor: 0.25,
54
+ /** DOI: penalty for sitting at a different level than the focus. */
55
+ apiWeight: 0.5,
56
+ /** DOI: how fast interest falls off with distance from the focus. */
57
+ distanceWeight: 1,
58
+ /** DOI: 0 = distance measured purely along the leaf axis (a clean readable
59
+ * window, blind to structure); 1 = purely tree distance (keeps the focus's
60
+ * siblings and cousins, but scatters survivors down the column). */
61
+ treeWeight: 0.6,
62
+ /** DOI: how much photo mass earns a row. A weak tiebreaker by design — turn it
63
+ * up and the lens becomes a popularity chart instead of a "where am I". */
64
+ countWeight: 0.25,
65
+ };
66
+
67
+ /**
68
+ * @param {any} root a decorated hierarchy (see hierarchy.js `normalize`)
69
+ * @param {object} options
70
+ * @param {number} options.height
71
+ * @param {"fisheye"|"doi"|"hybrid"|"uniform"} [options.layout="fisheye"]
72
+ * @param {{leaf?: number, px?: number}} [options.focus] focus by leaf index, or
73
+ * pinned to a cursor pixel. Pixel wins when both are given — that is what keeps
74
+ * the magnified row exactly under the pointer.
75
+ * @returns {{rows: any[], maxCount: number, focusLeaf: number, focusPx: number,
76
+ * slots: number, leaves: number}}
77
+ */
78
+ export function layout(root, options = {}) {
79
+ const o = { ...LAYOUT_DEFAULTS, ...options };
80
+ const height = o.height ?? 0;
81
+ const leaves = leavesOf(root);
82
+ const n = leaves.length;
83
+
84
+ const min = o.pad;
85
+ const max = height - o.pad;
86
+ if (!n || max <= min) {
87
+ return {
88
+ rows: [],
89
+ maxCount: 0,
90
+ focusLeaf: 0,
91
+ focusPx: min,
92
+ slots: 0,
93
+ leaves: n,
94
+ };
95
+ }
96
+
97
+ const strategy = LAYOUTS.includes(o.layout) ? o.layout : "fisheye";
98
+ const usesDOI = strategy === "doi" || strategy === "hybrid";
99
+ const usesFisheye = strategy === "fisheye" || strategy === "hybrid";
100
+
101
+ const sizeBy = SIZE_BY.includes(o.sizeBy) ? o.sizeBy : "slots";
102
+ const sizing = { min, max, sizeBy, sizeFloor: o.sizeFloor };
103
+
104
+ const budget = Math.max(4, Math.floor((max - min) / o.minRowPx));
105
+
106
+ // Which leaf is the focus? Under DOI this looks circular — the slot axis exists
107
+ // only once DOI has run, DOI needs a focus leaf, and the focus leaf is whatever
108
+ // sits under the cursor — but it isn't, as long as the caller says which ROW it
109
+ // is pointing at. The widget reads that off the frame it just drew (only it can:
110
+ // a pixel means whatever was last painted there, and this function is pure), and
111
+ // one pass resolves everything:
112
+ //
113
+ // seed leaf -> DOI selects the slots -> the slot at the cursor is the focus.
114
+ //
115
+ // The old code had no seed, so it mapped the cursor pixel onto the WHOLE-LEAF
116
+ // axis instead. On a real library that is about one leaf per pixel, while the
117
+ // rows on screen are the ~35 DOI slots at ~16px apart — so one row of mouse
118
+ // travel moved the focus by ~30 leaves. You could not hover from 06 Feb to
119
+ // 07 Feb; it jumped straight to 06 Mar.
120
+ //
121
+ // Resist the urge to iterate this to a fixed point (focus the slot under the
122
+ // cursor, re-select, repeat). It converges, and it is worse: pointing just past
123
+ // the last open day lands on the collapsed month below, and the chase then
124
+ // re-centres INSIDE it — 27 Jan jumps to 27 Feb. One pass steps into the month
125
+ // and lets the next hover open it.
126
+ const px = o.focus?.px != null ? clamp(o.focus.px, min, max) : null;
127
+ const seed =
128
+ o.focus?.leaf != null
129
+ ? clamp(o.focus.leaf, 0, n - 1)
130
+ : px != null
131
+ ? // No seed (the first frame, before anything has been drawn). The leaf
132
+ // axis is the only axis there is; it is a rough guess, and the very next
133
+ // pointermove replaces it with the real row.
134
+ resolveFocus({ px }, basePositions(leaves, sizing)).slot
135
+ : 0;
136
+
137
+ const slots = usesDOI
138
+ ? selectDOI(root, { ...o, budget, focusLeaf: seed })
139
+ : selectAll(root);
140
+ const S = slots.length;
141
+ const basePos = basePositions(slots, sizing);
142
+
143
+ // The row under the cursor. Safe to read off the BASE axis: the fisheye pins
144
+ // `px` as a fixed point, so the slot whose base band contains it is exactly the
145
+ // slot whose DRAWN band contains it — which is what makes the row you click the
146
+ // row you pointed at.
147
+ const focusSlot =
148
+ px != null ? slotAtPixel(basePos, px) : slotOfLeaf(slots, seed);
149
+ const focusLeaf = px != null ? slots[focusSlot].leafStart : seed;
150
+ const focus = {
151
+ px: px ?? (basePos[focusSlot] + basePos[focusSlot + 1]) / 2,
152
+ slot: focusSlot,
153
+ };
154
+
155
+ // The lens distorts whatever base it is handed — equal spans or mass-weighted.
156
+ // It only needs monotonicity, which both give it.
157
+ const pos = usesFisheye
158
+ ? distort(basePos, { focusPx: focus.px, distortion: o.distortion })
159
+ : basePos;
160
+
161
+ const { rows, maxCount } = buildRows(root, slots, pos, {
162
+ focusSlot: focus.slot,
163
+ });
164
+
165
+ return {
166
+ rows,
167
+ maxCount,
168
+ focusLeaf,
169
+ focusPx: focus.px,
170
+ focusSlot: focus.slot,
171
+ slots: S,
172
+ leaves: n,
173
+ };
174
+ }
175
+
176
+ /** Which slot holds a given leaf. Slots are ordered, so binary search. */
177
+ function slotOfLeaf(slots, leaf) {
178
+ let lo = 0;
179
+ let hi = slots.length - 1;
180
+ while (lo < hi) {
181
+ const mid = (lo + hi) >> 1;
182
+ if (slots[mid].leafEnd <= leaf) lo = mid + 1;
183
+ else hi = mid;
184
+ }
185
+ return lo;
186
+ }
187
+
188
+ export {
189
+ selectAll,
190
+ selectDOI,
191
+ doiScore,
192
+ leafDistance,
193
+ treeDistance,
194
+ } from "./select.js";
195
+ export {
196
+ fisheyePosition,
197
+ basePositions,
198
+ distort,
199
+ slotAtPixel,
200
+ } from "./position.js";
201
+ export { buildRows } from "./bands.js";
202
+ export { makeBarScale } from "./scales.js";
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Stage 2 of the layout: turn a slot sequence into pixel boundaries.
3
+ *
4
+ * Two steps, deliberately separate:
5
+ *
6
+ * basePositions() how much column each slot gets BEFORE any lens. Either
7
+ * equal spans (`sizeBy: "slots"` — size encodes interest) or
8
+ * spans proportional to mass (`sizeBy: "count"` — size encodes
9
+ * photo count, a true value-encoded partition diagram).
10
+ * distort() the fisheye, applied on top of whatever base it is given.
11
+ *
12
+ * The fisheye only needs its input to be monotonic, so it composes with either
13
+ * base for free. `x === a` stays a fixed point, which is what keeps the row under
14
+ * the cursor the row you click — mass-weighted or not.
15
+ *
16
+ * A slot sequence of length S gets S+1 boundaries spanning [min, max]. Slot j
17
+ * owns [pos[j], pos[j+1]], so the last slot gets a real band instead of
18
+ * collapsing onto the bottom edge.
19
+ */
20
+
21
+ export const SIZE_BY = ["slots", "count"];
22
+
23
+ /**
24
+ * The fisheye scale, ported verbatim from AutoGallery's `fisheye.js`, which
25
+ * ported it from PhotoRing's `d3_fisheye_scale` (d3 v3). Maps a base position
26
+ * `x` in [min, max] to a distorted position that magnifies around focus pixel
27
+ * `a` and compresses away from it. Monotonic in `x`; `x === a` is a fixed point;
28
+ * the endpoints map to themselves. Larger `d` = gentler distortion.
29
+ *
30
+ * This math is correct and well covered — it is the one piece carried over
31
+ * untouched.
32
+ *
33
+ * @returns {number}
34
+ */
35
+ export function fisheyePosition(x, a, min, max, d) {
36
+ const left = x < a;
37
+ let m = left ? a - min : max - a;
38
+ if (m === 0) m = max - min;
39
+ const dx = Math.abs(x - a);
40
+ if (dx < 1e-9) return a;
41
+ return ((left ? -1 : 1) * (m * (d + 1))) / (d + m / dx) + a;
42
+ }
43
+
44
+ export const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
45
+
46
+ /**
47
+ * Base (undistorted) boundaries for a sequence of weighted items.
48
+ *
49
+ * `sizeBy: "count"` is the honest icicle: a 40,000-photo year visibly dwarfs a
50
+ * 400-photo one. But a pure mass split gives a zero-count group a zero-height
51
+ * band — invisible, and impossible to click. So every item is guaranteed
52
+ * `sizeFloor` of an equal share, and only the remainder is divided by mass:
53
+ *
54
+ * span(j) = (1 - sizeFloor) * mass(j)/total + sizeFloor * 1/S
55
+ *
56
+ * sizeFloor = 1 collapses back to equal spans, so the two modes are the ends of
57
+ * one continuum rather than two code paths.
58
+ *
59
+ * @param {Array<{count?: number}>} items
60
+ * @param {{min:number, max:number, sizeBy?:string, sizeFloor?:number}} o
61
+ * @returns {number[]} S+1 boundaries
62
+ */
63
+ export function basePositions(
64
+ items,
65
+ { min, max, sizeBy = "slots", sizeFloor = 0.25 },
66
+ ) {
67
+ const S = items.length;
68
+ const span = max - min;
69
+ const pos = new Array(S + 1);
70
+ pos[0] = min;
71
+
72
+ if (sizeBy !== "count") {
73
+ for (let j = 1; j <= S; j++) pos[j] = min + (span * j) / S;
74
+ pos[S] = max;
75
+ return pos;
76
+ }
77
+
78
+ const total = items.reduce((s, it) => s + Math.max(0, it.count ?? 0), 0);
79
+ const f = total > 0 ? clamp(sizeFloor, 0, 1) : 1; // no mass anywhere -> equal
80
+ let acc = min;
81
+ for (let j = 0; j < S; j++) {
82
+ const mass = total > 0 ? Math.max(0, items[j].count ?? 0) / total : 0;
83
+ acc += span * ((1 - f) * mass + f / S);
84
+ pos[j + 1] = acc;
85
+ }
86
+ pos[S] = max; // absorb float drift into the endpoint, which is pinned anyway
87
+ return pos;
88
+ }
89
+
90
+ /** The slot whose base interval contains pixel `px`. Works for ANY spacing —
91
+ * which is why this is a search and not a `scale.invert()`. */
92
+ export function slotAtPixel(basePos, px) {
93
+ const S = basePos.length - 1;
94
+ if (S <= 0) return 0;
95
+ let lo = 0;
96
+ let hi = S - 1;
97
+ while (lo < hi) {
98
+ const mid = (lo + hi + 1) >> 1;
99
+ if (basePos[mid] <= px) lo = mid;
100
+ else hi = mid - 1;
101
+ }
102
+ return lo;
103
+ }
104
+
105
+ /**
106
+ * Resolve the focus into BOTH representations:
107
+ * `px` the focus pixel (what the lens magnifies around)
108
+ * `slot` the slot sitting under that pixel (what gets highlighted)
109
+ *
110
+ * When the host pins the focus to the cursor (`focus.px`), the pixel is
111
+ * authoritative and the slot is derived — that is what keeps the magnified row
112
+ * exactly under the pointer, so a click never lands on a row that slid away.
113
+ * When the host drives the focus from its own state (`focus.slot`), the slot is
114
+ * authoritative and the pixel is derived (the middle of its band).
115
+ */
116
+ export function resolveFocus(focus, basePos) {
117
+ const S = basePos.length - 1;
118
+ const min = basePos[0];
119
+ const max = basePos[S];
120
+ if (S <= 0) return { px: min, slot: 0 };
121
+
122
+ if (focus?.px != null) {
123
+ const px = clamp(focus.px, min, max);
124
+ return { px, slot: slotAtPixel(basePos, px) };
125
+ }
126
+ const slot = clamp(focus?.slot ?? 0, 0, S - 1);
127
+ return { px: (basePos[slot] + basePos[slot + 1]) / 2, slot };
128
+ }
129
+
130
+ /** Apply the fisheye to a base boundary array. */
131
+ export function distort(basePos, { focusPx, distortion }) {
132
+ const S = basePos.length - 1;
133
+ const min = basePos[0];
134
+ const max = basePos[S];
135
+ const a = clamp(focusPx, min, max);
136
+ const pos = basePos.map((x) => fisheyePosition(x, a, min, max, distortion));
137
+ // The scale pins the endpoints analytically; clamp anyway so floating-point
138
+ // drift can never push a band a hair outside the padded column.
139
+ pos[0] = min;
140
+ pos[S] = max;
141
+ return pos;
142
+ }
@@ -0,0 +1,41 @@
1
+ import { scaleLinear, scaleSqrt } from "d3-scale";
2
+
3
+ export const BAR_SCALES = ["log", "sqrt", "linear"];
4
+
5
+ /**
6
+ * Bar-length scale for the count histogram: mass -> pixel length.
7
+ *
8
+ * The default is LOG, and that is not a stylistic choice. Real hierarchies of
9
+ * this kind have pathological count distributions — a photo library where one
10
+ * "Unknown date" bucket holds 113,656 of 114,125 photos is a true story. Under a
11
+ * linear scale every other bar is the 4px minimum; under sqrt, a busy 300-photo
12
+ * day still renders at 5% of the track. Both turn the histogram into a row of
13
+ * identical stubs that encode nothing. `log1p(300)/log1p(113656) = 49%` — the
14
+ * silhouette comes back.
15
+ *
16
+ * Use `linear` when the counts are known to be well-behaved and you want the
17
+ * bars to be honestly proportional.
18
+ *
19
+ * @param {number} maxCount
20
+ * @param {number} maxLenPx
21
+ * @param {{minLenPx?: number, type?: "log"|"sqrt"|"linear"}} [opts]
22
+ * @returns {(count: number) => number}
23
+ */
24
+ export function makeBarScale(maxCount, maxLenPx, opts = {}) {
25
+ const { minLenPx = 4, type = "log" } = opts;
26
+ const hi = Math.max(1, maxCount);
27
+
28
+ if (type === "log") {
29
+ // log1p, not scaleLog: the domain starts at 0 (an empty group is legal) and
30
+ // log(0) is -Infinity.
31
+ const denom = Math.log1p(hi);
32
+ return (count) => {
33
+ const c = Math.max(0, Math.min(count, hi));
34
+ const t = denom > 0 ? Math.log1p(c) / denom : 0;
35
+ return minLenPx + (maxLenPx - minLenPx) * t;
36
+ };
37
+ }
38
+
39
+ const scale = type === "linear" ? scaleLinear() : scaleSqrt();
40
+ return scale.domain([0, hi]).range([minLenPx, maxLenPx]).clamp(true);
41
+ }