@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,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stage 1: choose the SLOTS — the contiguous partition of the leaf axis that
|
|
3
|
+
* the positioner lays out. A slot is one of:
|
|
4
|
+
*
|
|
5
|
+
* leaf one real leaf
|
|
6
|
+
* collapsed one closed subtree, standing in for all its leaves ("2019 · 1,204")
|
|
7
|
+
* elision a RUN of adjacent closed siblings, merged ("4 more groups")
|
|
8
|
+
*
|
|
9
|
+
* Two selectors, same output shape, so the positioner and renderers never learn
|
|
10
|
+
* which one ran:
|
|
11
|
+
*
|
|
12
|
+
* selectAll every leaf is its own slot. Honest, proportional, no elision.
|
|
13
|
+
* selectDOI Furnas degree-of-interest: score the leaves, keep the best ones
|
|
14
|
+
* that fit the row budget, close everything else.
|
|
15
|
+
*
|
|
16
|
+
* Why score LEAVES and not internal nodes: a selector that can only choose which
|
|
17
|
+
* subtrees to *open* cannot be bounded. To show the focused leaf you must open
|
|
18
|
+
* its whole ancestor chain, and each open node drags in its siblings — on a
|
|
19
|
+
* 10-year library that is already 48 rows before you have chosen anything. So
|
|
20
|
+
* the budget has to bite at the leaf level, and structure falls out of it:
|
|
21
|
+
* a node is open iff it still holds a kept leaf.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { leavesOf } from "../hierarchy.js";
|
|
25
|
+
|
|
26
|
+
/** @typedef {{node: any|null, parent?: any, nodes?: any[], leafStart: number,
|
|
27
|
+
* leafEnd: number, count?: number}} Slot */
|
|
28
|
+
|
|
29
|
+
/** One slot per leaf. Carries `count` like every other slot — `sizeBy: "count"`
|
|
30
|
+
* weights the column by slot mass, and a slot without it silently weighs zero. */
|
|
31
|
+
export function selectAll(root) {
|
|
32
|
+
return leavesOf(root).map((node) => ({
|
|
33
|
+
node,
|
|
34
|
+
leafStart: node.leafStart,
|
|
35
|
+
leafEnd: node.leafEnd,
|
|
36
|
+
count: node.count,
|
|
37
|
+
}));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Distance from a node's leaf interval to the focused leaf; 0 if it contains it. */
|
|
41
|
+
export function leafDistance(node, focusLeaf) {
|
|
42
|
+
if (focusLeaf >= node.leafStart && focusLeaf < node.leafEnd) return 0;
|
|
43
|
+
return focusLeaf < node.leafStart
|
|
44
|
+
? node.leafStart - focusLeaf
|
|
45
|
+
: focusLeaf - (node.leafEnd - 1);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Hops from `a` up to the lowest common ancestor and back down to `b`. This is
|
|
50
|
+
* Furnas's tree distance, and it is the thing a purely positional distance
|
|
51
|
+
* cannot see: the day before the focused day might be three rows up but a whole
|
|
52
|
+
* YEAR away in the tree, while the focused day's own siblings are structurally
|
|
53
|
+
* adjacent no matter where they land on the axis.
|
|
54
|
+
*/
|
|
55
|
+
export function treeDistance(a, b) {
|
|
56
|
+
let x = a;
|
|
57
|
+
let y = b;
|
|
58
|
+
let d = 0;
|
|
59
|
+
while (x.depth > y.depth) {
|
|
60
|
+
x = x.parent;
|
|
61
|
+
d++;
|
|
62
|
+
}
|
|
63
|
+
while (y.depth > x.depth) {
|
|
64
|
+
y = y.parent;
|
|
65
|
+
d++;
|
|
66
|
+
}
|
|
67
|
+
while (x !== y && x.parent && y.parent) {
|
|
68
|
+
x = x.parent;
|
|
69
|
+
y = y.parent;
|
|
70
|
+
d += 2;
|
|
71
|
+
}
|
|
72
|
+
return d;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Furnas's degree of interest: how badly does the user want to see this leaf,
|
|
77
|
+
* given where they are looking?
|
|
78
|
+
*
|
|
79
|
+
* DOI(leaf) = importance(leaf) - distance(leaf, focus) + mass(leaf)
|
|
80
|
+
*
|
|
81
|
+
* Leaves are ranked by this and the best ones that fit the budget keep a full
|
|
82
|
+
* row; the rest close into their ancestors. This function alone decides what a
|
|
83
|
+
* DOI tree FEELS like, so each term is deliberate:
|
|
84
|
+
*
|
|
85
|
+
* - **importance** is depth measured RELATIVE to the focus, not absolutely. The
|
|
86
|
+
* textbook `-depth` says a deep leaf matters less — true for the *root*'s
|
|
87
|
+
* importance, false for leaves. In a ragged tree a depth-5 leaf isn't less
|
|
88
|
+
* interesting than a depth-2 one, it's just more nested; what actually reads
|
|
89
|
+
* badly is jumping between levels, so we penalise the level CHANGE.
|
|
90
|
+
*
|
|
91
|
+
* - **distance** blends the two notions of "far", because each alone fails:
|
|
92
|
+
* the leaf axis alone gives a clean readable window but is blind to
|
|
93
|
+
* structure; the tree alone keeps the focus's siblings but scatters the
|
|
94
|
+
* survivors across the column. `treeWeight` slides between them.
|
|
95
|
+
* Axis distance goes through `log1p` so the falloff is smooth — a linear
|
|
96
|
+
* penalty makes rank flip sharply as the focus moves, which is exactly what
|
|
97
|
+
* makes rows pop in and out while you drag the cursor.
|
|
98
|
+
*
|
|
99
|
+
* - **mass** is a weak tiebreaker, never a driver. Let it dominate and the lens
|
|
100
|
+
* stops being about where you are and becomes a popularity chart.
|
|
101
|
+
*
|
|
102
|
+
* @param {any} leaf hierarchy node (.depth, .count, .leafStart, .leafEnd, .parent)
|
|
103
|
+
* @param {{leaf: number, node: any}} focus focused leaf index and its node
|
|
104
|
+
* @param {{apiWeight, distanceWeight, countWeight, treeWeight}} opts
|
|
105
|
+
* @returns {number} higher = more interesting = more likely to keep its own row
|
|
106
|
+
*/
|
|
107
|
+
export function doiScore(leaf, focus, opts) {
|
|
108
|
+
const importance = -Math.abs(leaf.depth - focus.node.depth) * opts.apiWeight;
|
|
109
|
+
|
|
110
|
+
const axis = Math.log1p(leafDistance(leaf, focus.leaf));
|
|
111
|
+
const tree = treeDistance(leaf, focus.node);
|
|
112
|
+
const t = opts.treeWeight;
|
|
113
|
+
const distance = ((1 - t) * axis + t * tree) * opts.distanceWeight;
|
|
114
|
+
|
|
115
|
+
const mass = Math.log1p(leaf.count) * opts.countWeight;
|
|
116
|
+
|
|
117
|
+
return importance - distance + mass;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Keep the highest-DOI leaves that fit the row budget; close the rest.
|
|
122
|
+
*
|
|
123
|
+
* Guarantees the property tests lean on:
|
|
124
|
+
* - slots partition [0, nLeaves) contiguously, so mass is always conserved;
|
|
125
|
+
* - the focused leaf ALWAYS keeps its own slot — you can never lose the row you
|
|
126
|
+
* are pointing at, whatever the budget;
|
|
127
|
+
* - slot count <= budget (that is what the K search below is for).
|
|
128
|
+
*
|
|
129
|
+
* @param {any} root
|
|
130
|
+
* @param {{budget: number, focusLeaf: number} & Record<string, number>} opts
|
|
131
|
+
* @returns {Slot[]}
|
|
132
|
+
*/
|
|
133
|
+
export function selectDOI(root, opts) {
|
|
134
|
+
const leaves = leavesOf(root);
|
|
135
|
+
const n = leaves.length;
|
|
136
|
+
if (!n) return [];
|
|
137
|
+
|
|
138
|
+
const budget = Math.max(1, Math.floor(opts.budget ?? n));
|
|
139
|
+
if (n <= budget) return selectAll(root);
|
|
140
|
+
|
|
141
|
+
const focusLeaf = Math.max(0, Math.min(opts.focusLeaf ?? 0, n - 1));
|
|
142
|
+
const focus = { leaf: focusLeaf, node: leaves[focusLeaf] };
|
|
143
|
+
|
|
144
|
+
// Rank once. Header cells are excluded: they are force-kept whenever their
|
|
145
|
+
// node is open, so ranking them would just burn budget entries on rows we were
|
|
146
|
+
// going to draw anyway. Ties break toward the focus, then by leaf order, so the
|
|
147
|
+
// result is deterministic (a property test asserts it).
|
|
148
|
+
const ranked = leaves
|
|
149
|
+
.map((leaf, i) => ({ leaf, i, score: doiScore(leaf, focus, opts) }))
|
|
150
|
+
.filter(({ leaf }) => !leaf.data?.header)
|
|
151
|
+
.sort(
|
|
152
|
+
(a, b) =>
|
|
153
|
+
b.score - a.score ||
|
|
154
|
+
Math.abs(a.i - focusLeaf) - Math.abs(b.i - focusLeaf) ||
|
|
155
|
+
a.i - b.i,
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
// Closing leaves ADDS rows back (a closed subtree is still one row), so K is
|
|
159
|
+
// not simply the budget. Walk K down until the realized slot count fits. It
|
|
160
|
+
// converges fast — closed rows only ever shrink as K shrinks.
|
|
161
|
+
for (let K = Math.min(budget, n); K >= 1; K--) {
|
|
162
|
+
const keep = new Set(ranked.slice(0, K).map((r) => r.leaf));
|
|
163
|
+
keep.add(focus.node);
|
|
164
|
+
const slots = buildSlots(root, keep);
|
|
165
|
+
if (slots.length <= budget) return slots;
|
|
166
|
+
}
|
|
167
|
+
return buildSlots(root, new Set([focus.node]));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Turn a set of kept leaves into the slot partition.
|
|
172
|
+
*
|
|
173
|
+
* A node is OPEN iff it still contains a kept leaf. Under an open node, each
|
|
174
|
+
* child is either open (recurse) or closed (one slot). Adjacent closed children
|
|
175
|
+
* merge into a single elision slot — without that merge, a wide node whose
|
|
176
|
+
* children are all closed would spend one row per child and blow the budget it
|
|
177
|
+
* was supposed to be saving.
|
|
178
|
+
*/
|
|
179
|
+
function buildSlots(root, keep) {
|
|
180
|
+
const holdsKept = new Map();
|
|
181
|
+
root.eachAfter((n) => {
|
|
182
|
+
if (!n.children?.length) return holdsKept.set(n, keep.has(n));
|
|
183
|
+
holdsKept.set(
|
|
184
|
+
n,
|
|
185
|
+
n.children.some((c) => holdsKept.get(c)),
|
|
186
|
+
);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
const slots = [];
|
|
190
|
+
let run = null; // the closed run currently being accumulated
|
|
191
|
+
|
|
192
|
+
const flush = () => {
|
|
193
|
+
if (!run) return;
|
|
194
|
+
slots.push(
|
|
195
|
+
run.nodes.length === 1
|
|
196
|
+
? {
|
|
197
|
+
node: run.nodes[0],
|
|
198
|
+
leafStart: run.leafStart,
|
|
199
|
+
leafEnd: run.leafEnd,
|
|
200
|
+
count: run.count,
|
|
201
|
+
}
|
|
202
|
+
: {
|
|
203
|
+
node: null,
|
|
204
|
+
parent: run.parent,
|
|
205
|
+
nodes: run.nodes,
|
|
206
|
+
leafStart: run.leafStart,
|
|
207
|
+
leafEnd: run.leafEnd,
|
|
208
|
+
count: run.count,
|
|
209
|
+
},
|
|
210
|
+
);
|
|
211
|
+
run = null;
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
(function descend(node) {
|
|
215
|
+
for (const child of node.children ?? []) {
|
|
216
|
+
// A header always survives while its parent is open — a node that shows
|
|
217
|
+
// its children but not its own label is worse than useless.
|
|
218
|
+
const isHeader = child.data?.header;
|
|
219
|
+
const open = holdsKept.get(child) && child.children?.length;
|
|
220
|
+
const kept =
|
|
221
|
+
isHeader || (holdsKept.get(child) && !child.children?.length);
|
|
222
|
+
|
|
223
|
+
if (open || kept) {
|
|
224
|
+
flush(); // a visible child breaks the closed run
|
|
225
|
+
if (open) descend(child);
|
|
226
|
+
else
|
|
227
|
+
slots.push({
|
|
228
|
+
node: child,
|
|
229
|
+
leafStart: child.leafStart,
|
|
230
|
+
leafEnd: child.leafEnd,
|
|
231
|
+
count: child.count,
|
|
232
|
+
});
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Closed: accumulate into the current run of adjacent closed siblings.
|
|
237
|
+
if (run && run.parent === node) {
|
|
238
|
+
run.nodes.push(child);
|
|
239
|
+
run.leafEnd = child.leafEnd;
|
|
240
|
+
run.count += child.count;
|
|
241
|
+
} else {
|
|
242
|
+
flush();
|
|
243
|
+
run = {
|
|
244
|
+
parent: node,
|
|
245
|
+
nodes: [child],
|
|
246
|
+
leafStart: child.leafStart,
|
|
247
|
+
leafEnd: child.leafEnd,
|
|
248
|
+
count: child.count,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
flush(); // a run never spans out of its parent — that would break containment
|
|
253
|
+
})(root);
|
|
254
|
+
|
|
255
|
+
return slots;
|
|
256
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React wrapper. Written with createElement rather than JSX so the package needs
|
|
3
|
+
* no Babel step — the whole point of the vanilla core is that the wrappers stay
|
|
4
|
+
* this thin.
|
|
5
|
+
*
|
|
6
|
+
* <FisheyeNav data={rows} keys={["year","month","day"]}
|
|
7
|
+
* selected={path} onSelect={setPath} />
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createElement, useEffect, useRef } from "react";
|
|
11
|
+
import { fisheyeNav } from "../fisheyeNav.js";
|
|
12
|
+
|
|
13
|
+
export function FisheyeNav({
|
|
14
|
+
data,
|
|
15
|
+
keys,
|
|
16
|
+
root,
|
|
17
|
+
children,
|
|
18
|
+
style = "flat",
|
|
19
|
+
layout = "hybrid",
|
|
20
|
+
selected = null,
|
|
21
|
+
onSelect,
|
|
22
|
+
className,
|
|
23
|
+
...options
|
|
24
|
+
}) {
|
|
25
|
+
const hostRef = useRef(null);
|
|
26
|
+
const navRef = useRef(null);
|
|
27
|
+
// The handler is read at event time, so a re-render with a new closure does not
|
|
28
|
+
// require tearing the widget down and rebuilding it.
|
|
29
|
+
const onSelectRef = useRef(onSelect);
|
|
30
|
+
onSelectRef.current = onSelect;
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
const nav = fisheyeNav({
|
|
34
|
+
data,
|
|
35
|
+
keys,
|
|
36
|
+
root,
|
|
37
|
+
children,
|
|
38
|
+
style,
|
|
39
|
+
layout,
|
|
40
|
+
selected,
|
|
41
|
+
...options,
|
|
42
|
+
});
|
|
43
|
+
nav.addEventListener("input", () => onSelectRef.current?.(nav.value));
|
|
44
|
+
hostRef.current.appendChild(nav);
|
|
45
|
+
navRef.current = nav;
|
|
46
|
+
return () => {
|
|
47
|
+
nav.destroy?.();
|
|
48
|
+
nav.remove();
|
|
49
|
+
navRef.current = null;
|
|
50
|
+
};
|
|
51
|
+
// Mount once; every prop change flows through the update effects below.
|
|
52
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
53
|
+
}, []);
|
|
54
|
+
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
navRef.current?.update({
|
|
57
|
+
data,
|
|
58
|
+
keys,
|
|
59
|
+
root,
|
|
60
|
+
children,
|
|
61
|
+
style,
|
|
62
|
+
layout,
|
|
63
|
+
...options,
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
const nav = navRef.current;
|
|
69
|
+
// `.value =` is the silent setter: it repaints the highlight without firing
|
|
70
|
+
// `input`, so a controlled parent cannot loop.
|
|
71
|
+
if (nav && nav.value !== selected) nav.value = selected;
|
|
72
|
+
}, [selected]);
|
|
73
|
+
|
|
74
|
+
return createElement("div", {
|
|
75
|
+
ref: hostRef,
|
|
76
|
+
className,
|
|
77
|
+
style: { width: "100%", height: "100%" },
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export default FisheyeNav;
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The settings gear + popover, owned by the widget.
|
|
3
|
+
*
|
|
4
|
+
* Same contract as @john-guerra/d3-zoomable-axis's scent controls, deliberately:
|
|
5
|
+
* controls a ⚙ that live-tunes the lens. On by default; pass false to
|
|
6
|
+
* suppress it and drive the options from your own UI.
|
|
7
|
+
* persistKey a localStorage key, so a user's tuned lens survives a reload.
|
|
8
|
+
* Pass none and listen for the "settings" event to use your own
|
|
9
|
+
* store instead.
|
|
10
|
+
*
|
|
11
|
+
* A consumer should not have to hand-roll this — AutoGallery did, and every
|
|
12
|
+
* other consumer would have too.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { LAYOUTS } from "../layout/index.js";
|
|
16
|
+
import { SIZE_BY } from "../layout/position.js";
|
|
17
|
+
import { BAR_SCALES } from "../layout/scales.js";
|
|
18
|
+
import { STYLES } from "./index.js";
|
|
19
|
+
|
|
20
|
+
/** What the panel tunes AND persists. Never data, accessors, or sizes. */
|
|
21
|
+
export const PERSIST_KEYS = [
|
|
22
|
+
"style",
|
|
23
|
+
"layout",
|
|
24
|
+
"sizeBy",
|
|
25
|
+
"distortion",
|
|
26
|
+
"minRowPx",
|
|
27
|
+
"barScale",
|
|
28
|
+
"apiWeight",
|
|
29
|
+
"distanceWeight",
|
|
30
|
+
"treeWeight",
|
|
31
|
+
"countWeight",
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
export function readStore(key) {
|
|
35
|
+
if (!key || typeof localStorage === "undefined") return null;
|
|
36
|
+
try {
|
|
37
|
+
const s = JSON.parse(localStorage.getItem(key) ?? "null");
|
|
38
|
+
return s && typeof s === "object" ? s : null;
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function writeStore(key, obj) {
|
|
45
|
+
if (!key || typeof localStorage === "undefined") return;
|
|
46
|
+
try {
|
|
47
|
+
const picked = {};
|
|
48
|
+
for (const k of PERSIST_KEYS) if (k in obj) picked[k] = obj[k];
|
|
49
|
+
localStorage.setItem(key, JSON.stringify(picked));
|
|
50
|
+
} catch {
|
|
51
|
+
/* quota / disabled storage — persistence is best-effort */
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Keep only known keys with sane values: a stale or hand-edited store must not
|
|
56
|
+
* be able to wedge the widget into an unrenderable state. */
|
|
57
|
+
export function sanitize(raw, defaults) {
|
|
58
|
+
const out = {};
|
|
59
|
+
if (!raw) return out;
|
|
60
|
+
const num = (v, lo, hi) => {
|
|
61
|
+
const n = Number(v);
|
|
62
|
+
return Number.isFinite(n) ? Math.min(hi, Math.max(lo, n)) : undefined;
|
|
63
|
+
};
|
|
64
|
+
const oneOf = (v, list) => (list.includes(v) ? v : undefined);
|
|
65
|
+
const put = (k, v) => {
|
|
66
|
+
if (v !== undefined) out[k] = v;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
put("style", oneOf(raw.style, STYLES));
|
|
70
|
+
put("layout", oneOf(raw.layout, LAYOUTS));
|
|
71
|
+
put("sizeBy", oneOf(raw.sizeBy, SIZE_BY));
|
|
72
|
+
put("barScale", oneOf(raw.barScale, BAR_SCALES));
|
|
73
|
+
put("distortion", num(raw.distortion, 1, 12));
|
|
74
|
+
put("minRowPx", num(raw.minRowPx, 8, 48));
|
|
75
|
+
put("apiWeight", num(raw.apiWeight, 0, 3));
|
|
76
|
+
put("distanceWeight", num(raw.distanceWeight, 0, 3));
|
|
77
|
+
put("treeWeight", num(raw.treeWeight, 0, 1));
|
|
78
|
+
put("countWeight", num(raw.countWeight, 0, 2));
|
|
79
|
+
return { ...defaults, ...out };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const SELECTS = [
|
|
83
|
+
{
|
|
84
|
+
key: "style",
|
|
85
|
+
label: "View",
|
|
86
|
+
options: [
|
|
87
|
+
["flat", "List (indented)"],
|
|
88
|
+
["icicle", "Icicle (columns)"],
|
|
89
|
+
],
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
key: "layout",
|
|
93
|
+
label: "Lens",
|
|
94
|
+
options: [
|
|
95
|
+
["hybrid", "Hybrid — magnify + fold"],
|
|
96
|
+
["fisheye", "Fisheye — every group"],
|
|
97
|
+
["doi", "Interest — full rows"],
|
|
98
|
+
["uniform", "Uniform — no lens"],
|
|
99
|
+
],
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
key: "sizeBy",
|
|
103
|
+
label: "Band size",
|
|
104
|
+
options: [
|
|
105
|
+
["slots", "Interest (equal)"],
|
|
106
|
+
["count", "Photos (mass)"],
|
|
107
|
+
],
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
key: "barScale",
|
|
111
|
+
label: "Bars",
|
|
112
|
+
options: [
|
|
113
|
+
["log", "Log"],
|
|
114
|
+
["sqrt", "Square root"],
|
|
115
|
+
["linear", "Linear"],
|
|
116
|
+
],
|
|
117
|
+
},
|
|
118
|
+
];
|
|
119
|
+
|
|
120
|
+
const SLIDERS = [
|
|
121
|
+
{ key: "distortion", label: "Distortion", min: 1, max: 12, step: 0.5 },
|
|
122
|
+
{ key: "minRowPx", label: "Row size", min: 8, max: 48, step: 1, unit: "px" },
|
|
123
|
+
];
|
|
124
|
+
|
|
125
|
+
// The DOI weights live behind a disclosure: they are where the lens's FEEL comes
|
|
126
|
+
// from, but four extra sliders up front would bury the two knobs most people
|
|
127
|
+
// actually want.
|
|
128
|
+
const WEIGHTS = [
|
|
129
|
+
{ key: "distanceWeight", label: "Focus falloff", min: 0, max: 3, step: 0.1 },
|
|
130
|
+
{
|
|
131
|
+
key: "treeWeight",
|
|
132
|
+
label: "Structure vs. list",
|
|
133
|
+
min: 0,
|
|
134
|
+
max: 1,
|
|
135
|
+
step: 0.05,
|
|
136
|
+
},
|
|
137
|
+
{ key: "countWeight", label: "Busy groups", min: 0, max: 2, step: 0.05 },
|
|
138
|
+
{ key: "apiWeight", label: "Same-level bias", min: 0, max: 3, step: 0.1 },
|
|
139
|
+
];
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* @param {HTMLElement} host the widget root
|
|
143
|
+
* @param {{get:()=>object, set:(patch:object)=>void, reset:()=>void}} io
|
|
144
|
+
* @returns {{sync:()=>void, destroy:()=>void}}
|
|
145
|
+
*/
|
|
146
|
+
export function buildControls(host, io) {
|
|
147
|
+
const doc = host.ownerDocument;
|
|
148
|
+
const el = (tag, cls, props) =>
|
|
149
|
+
Object.assign(doc.createElement(tag), { className: cls, ...props });
|
|
150
|
+
|
|
151
|
+
const gear = el("button", "fn-gear", {
|
|
152
|
+
type: "button",
|
|
153
|
+
innerHTML: "⚙",
|
|
154
|
+
title: "Lens settings",
|
|
155
|
+
});
|
|
156
|
+
gear.setAttribute("aria-label", "Lens settings");
|
|
157
|
+
gear.setAttribute("aria-expanded", "false");
|
|
158
|
+
|
|
159
|
+
const panel = el("div", "fn-panel");
|
|
160
|
+
panel.hidden = true;
|
|
161
|
+
panel.setAttribute("role", "group");
|
|
162
|
+
panel.setAttribute("aria-label", "Lens settings");
|
|
163
|
+
|
|
164
|
+
const inputs = new Map();
|
|
165
|
+
|
|
166
|
+
const row = (label, control) => {
|
|
167
|
+
const r = el("label", "fn-row-ctl");
|
|
168
|
+
r.appendChild(el("span", "fn-ctl-label", { textContent: label }));
|
|
169
|
+
r.appendChild(control);
|
|
170
|
+
return r;
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
for (const s of SELECTS) {
|
|
174
|
+
const sel = el("select", "fn-ctl-select");
|
|
175
|
+
for (const [value, text] of s.options) {
|
|
176
|
+
sel.appendChild(el("option", "", { value, textContent: text }));
|
|
177
|
+
}
|
|
178
|
+
sel.addEventListener("change", () => io.set({ [s.key]: sel.value }));
|
|
179
|
+
inputs.set(s.key, sel);
|
|
180
|
+
panel.appendChild(row(s.label, sel));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const slider = (s) => {
|
|
184
|
+
const wrap = el("span", "fn-ctl-slider");
|
|
185
|
+
const input = el("input", "", {
|
|
186
|
+
type: "range",
|
|
187
|
+
min: String(s.min),
|
|
188
|
+
max: String(s.max),
|
|
189
|
+
step: String(s.step),
|
|
190
|
+
});
|
|
191
|
+
const out = el("span", "fn-ctl-val");
|
|
192
|
+
input.addEventListener("input", () => {
|
|
193
|
+
out.textContent = input.value + (s.unit ?? "");
|
|
194
|
+
io.set({ [s.key]: Number(input.value) });
|
|
195
|
+
});
|
|
196
|
+
wrap.append(input, out);
|
|
197
|
+
inputs.set(s.key, input);
|
|
198
|
+
return { wrap, input, out, spec: s };
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const sliders = [];
|
|
202
|
+
for (const s of SLIDERS) {
|
|
203
|
+
const made = slider(s);
|
|
204
|
+
sliders.push(made);
|
|
205
|
+
panel.appendChild(row(s.label, made.wrap));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const details = el("details", "fn-ctl-more");
|
|
209
|
+
details.appendChild(el("summary", "", { textContent: "Interest weights" }));
|
|
210
|
+
for (const s of WEIGHTS) {
|
|
211
|
+
const made = slider(s);
|
|
212
|
+
sliders.push(made);
|
|
213
|
+
details.appendChild(row(s.label, made.wrap));
|
|
214
|
+
}
|
|
215
|
+
panel.appendChild(details);
|
|
216
|
+
|
|
217
|
+
const reset = el("button", "fn-ctl-reset", {
|
|
218
|
+
type: "button",
|
|
219
|
+
textContent: "Reset",
|
|
220
|
+
});
|
|
221
|
+
reset.addEventListener("click", () => io.reset());
|
|
222
|
+
panel.appendChild(reset);
|
|
223
|
+
|
|
224
|
+
// `.fn-open` is what actually shows the panel — see style.js. `hidden` is kept
|
|
225
|
+
// in sync for assistive tech, but it cannot be trusted to hide anything on its
|
|
226
|
+
// own once the stylesheet sets `display`.
|
|
227
|
+
const toggle = (open = panel.hidden) => {
|
|
228
|
+
panel.hidden = !open;
|
|
229
|
+
panel.classList.toggle("fn-open", open);
|
|
230
|
+
gear.classList.toggle("on", open);
|
|
231
|
+
gear.setAttribute("aria-expanded", String(open));
|
|
232
|
+
};
|
|
233
|
+
gear.addEventListener("click", () => toggle());
|
|
234
|
+
|
|
235
|
+
// Esc closes; a click outside closes. Both are what a popover is expected to
|
|
236
|
+
// do, and neither is worth making every consumer reimplement.
|
|
237
|
+
const onKey = (e) => {
|
|
238
|
+
if (e.key === "Escape" && !panel.hidden) toggle(false);
|
|
239
|
+
};
|
|
240
|
+
const onDocClick = (e) => {
|
|
241
|
+
if (panel.hidden) return;
|
|
242
|
+
if (!panel.contains(e.target) && e.target !== gear) toggle(false);
|
|
243
|
+
};
|
|
244
|
+
host.addEventListener("keydown", onKey);
|
|
245
|
+
doc.addEventListener("click", onDocClick, true);
|
|
246
|
+
|
|
247
|
+
host.append(gear, panel);
|
|
248
|
+
|
|
249
|
+
const sync = () => {
|
|
250
|
+
const o = io.get();
|
|
251
|
+
for (const [key, input] of inputs) {
|
|
252
|
+
const v = o[key];
|
|
253
|
+
if (input.tagName === "SELECT") input.value = String(v);
|
|
254
|
+
else input.value = String(v);
|
|
255
|
+
}
|
|
256
|
+
for (const s of sliders) {
|
|
257
|
+
s.out.textContent = String(o[s.spec.key]) + (s.spec.unit ?? "");
|
|
258
|
+
}
|
|
259
|
+
// Distortion means nothing without a lens; say so rather than letting the
|
|
260
|
+
// user drag a slider that does nothing.
|
|
261
|
+
const lensed = o.layout === "fisheye" || o.layout === "hybrid";
|
|
262
|
+
const dist = inputs.get("distortion");
|
|
263
|
+
dist.disabled = !lensed;
|
|
264
|
+
dist.closest(".fn-row-ctl").classList.toggle("fn-disabled", !lensed);
|
|
265
|
+
// Likewise the interest weights only matter when DOI is doing the selecting.
|
|
266
|
+
const doi = o.layout === "doi" || o.layout === "hybrid";
|
|
267
|
+
details.classList.toggle("fn-disabled", !doi);
|
|
268
|
+
for (const w of WEIGHTS) inputs.get(w.key).disabled = !doi;
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
sync();
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
sync,
|
|
275
|
+
destroy() {
|
|
276
|
+
host.removeEventListener("keydown", onKey);
|
|
277
|
+
doc.removeEventListener("click", onDocClick, true);
|
|
278
|
+
gear.remove();
|
|
279
|
+
panel.remove();
|
|
280
|
+
},
|
|
281
|
+
};
|
|
282
|
+
}
|