@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
|
@@ -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
|
+
}
|