@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,215 @@
1
+ /**
2
+ * The renderers. Both consume the SAME rows, so neither knows (or can know)
3
+ * which layout strategy produced them — that is what keeps `style` and `layout`
4
+ * genuinely orthogonal instead of a 6-way switch.
5
+ *
6
+ * flat one column, indented by depth. Internal nodes get a real row (their
7
+ * synthetic header cell), so "what level am I on" is answerable at a
8
+ * glance. This is an outline.
9
+ * icicle one column per depth; a node's band is its children's span. This is
10
+ * a partition diagram whose vertical axis happens to be fisheyed.
11
+ */
12
+
13
+ import { select } from "d3-selection";
14
+ import { makeBarScale } from "../layout/scales.js";
15
+ import { injectStyle } from "./style.js";
16
+
17
+ export const STYLES = ["flat", "icicle"];
18
+
19
+ export const RENDER_DEFAULTS = {
20
+ /** px of indent per level (flat). */
21
+ indent: 11,
22
+ /** Draw the count histogram INSIDE each band (the row is the bar's track, the
23
+ * label sits on top). False hides it. */
24
+ bars: true,
25
+ /** px of clear space kept at the right edge. */
26
+ gutter: 6,
27
+ /** don't draw a label in a band thinner than this — it would just be noise. */
28
+ minLabelPx: 9,
29
+ /** count-histogram scale. Log by default — see scales.js for why. */
30
+ barScale: "log",
31
+ /** count formatter for the histogram tooltip/label. */
32
+ format: (n) => n.toLocaleString(),
33
+ };
34
+
35
+ /**
36
+ * @param {SVGElement} svg
37
+ * @param {{rows: any[], maxCount: number}} model a `layout()` result
38
+ * @param {object} opts width, height, style, selectedId, + RENDER_DEFAULTS
39
+ */
40
+ export function render(svg, model, opts) {
41
+ const o = { ...RENDER_DEFAULTS, ...opts };
42
+ injectStyle(svg.ownerDocument);
43
+
44
+ const root = select(svg);
45
+ const { rows, maxCount } = model;
46
+ const { width, height, style } = o;
47
+
48
+ root.attr("viewBox", `0 0 ${width} ${height}`);
49
+
50
+ if (!rows.length) {
51
+ root.selectAll(".fn-row").remove();
52
+ root
53
+ .selectAll(".fn-empty")
54
+ .data([0])
55
+ .join("text")
56
+ .attr("class", "fn-empty")
57
+ .attr("x", width / 2)
58
+ .attr("y", height / 2)
59
+ .text(o.emptyLabel ?? "Nothing to show");
60
+ return;
61
+ }
62
+ root.selectAll(".fn-empty").remove();
63
+
64
+ // The bar's track is the ROW, not a column of its own: length encodes photo
65
+ // mass while the band's height encodes the lens, so the column reads as a
66
+ // histogram silhouette (the original AutoGallery design). The label is drawn
67
+ // over it.
68
+ const trackW = width - o.gutter;
69
+ const maxDepth = rows.reduce((m, r) => Math.max(m, r.depth), 1);
70
+ const geom =
71
+ style === "icicle"
72
+ ? icicleGeom(maxDepth, trackW)
73
+ : flatGeom(o.indent, trackW);
74
+
75
+ // Which rows get a bar. In an icicle the inner nodes own a column of their own,
76
+ // so they show their AGGREGATE — the count of the whole subtree they stand for.
77
+ // In flat mode they don't: a branch's band spans all of its children, so a bar
78
+ // across it would just be a block sitting behind them.
79
+ const barred = (d) =>
80
+ o.bars &&
81
+ d.count > 0 &&
82
+ (d.terminal || (style === "icicle" && d.kind === "branch"));
83
+
84
+ // Flat: ONE scale, absolute lengths, because every row shares one track — a
85
+ // per-row scale would draw equal counts at different lengths depending on how
86
+ // deep the row sits, which is a lie. A deeper row starts further right, so its
87
+ // bar is clipped to its own band.
88
+ const flatScale = makeBarScale(maxCount, Math.max(1, trackW), {
89
+ type: o.barScale,
90
+ });
91
+
92
+ // Icicle: each depth is a column, so each depth is its OWN histogram. A year
93
+ // (40,000 photos) and a day (300) do not belong on one scale — shared, every
94
+ // year's bar pegs at full width and the column encodes nothing. So the bar is a
95
+ // fraction of the row's own width, taken from a scale over that depth's max.
96
+ const depthScale = new Map();
97
+ if (style === "icicle") {
98
+ const maxAt = new Map();
99
+ for (const r of rows)
100
+ if (barred(r))
101
+ maxAt.set(r.depth, Math.max(maxAt.get(r.depth) ?? 0, r.count));
102
+ for (const [depth, m] of maxAt)
103
+ depthScale.set(
104
+ depth,
105
+ makeBarScale(m, 1, { type: o.barScale, minLenPx: 0.05 }),
106
+ );
107
+ }
108
+
109
+ const barLen = (d) => {
110
+ if (!barred(d)) return 0;
111
+ const w = Math.max(0, geom.w(d));
112
+ return style === "icicle"
113
+ ? (depthScale.get(d.depth)?.(d.count) ?? 0) * w
114
+ : Math.min(flatScale(d.count), w);
115
+ };
116
+
117
+ const g = root
118
+ .selectAll("g.fn-row")
119
+ .data(rows, (d) => d.id)
120
+ .join((enter) => {
121
+ const e = enter.append("g").attr("class", "fn-row");
122
+ e.append("rect").attr("class", "fn-band");
123
+ e.append("rect").attr("class", "fn-bar");
124
+ e.append("text").attr("class", "fn-label");
125
+ e.append("title");
126
+ return e;
127
+ });
128
+
129
+ g.attr("data-kind", (d) => d.kind)
130
+ .attr("data-depth", (d) => d.depth)
131
+ .attr("data-id", (d) => d.id)
132
+ .attr("data-focused", (d) => (d.focused ? "true" : null))
133
+ .classed("fn-selected", (d) => d.id === o.selectedId);
134
+
135
+ g.select("title").text(
136
+ (d) =>
137
+ `${d.label}\n${o.format(d.count)}` +
138
+ (d.elided ? ` · ${d.elided} groups collapsed` : ""),
139
+ );
140
+
141
+ g.select("rect.fn-band")
142
+ .attr("x", (d) => geom.x(d))
143
+ .attr("y", (d) => d.y0)
144
+ .attr("width", (d) => Math.max(0, geom.w(d)))
145
+ // Hairline bands still need to be clickable; 1px floor keeps the hit target.
146
+ .attr("height", (d) => Math.max(1, d.thickness))
147
+ // Drop the border on sub-pixel bands. In the compressed tail of an
148
+ // undecimated fisheye, hundreds of hairline bands stack their 0.5px strokes
149
+ // into a solid wall that swallows both the fill and the labels — the
150
+ // silhouette turns into a white smear. Inline style, because a stylesheet
151
+ // rule would beat a presentation attribute.
152
+ .style("stroke-width", (d) => (d.thickness >= 2 ? 0.5 : 0));
153
+
154
+ // Same x and same height as the band it fills — so the lens magnifies the bar
155
+ // along with its row, and the label (drawn after) sits over it.
156
+ g.select("rect.fn-bar")
157
+ .attr("x", (d) => geom.x(d))
158
+ .attr("y", (d) => d.y0)
159
+ .attr("width", barLen)
160
+ .attr("height", (d) => Math.max(1, d.thickness));
161
+
162
+ g.select("text.fn-label")
163
+ .attr("x", (d) => geom.x(d) + 4)
164
+ .attr("y", (d) => d.y)
165
+ .attr("font-weight", (d) =>
166
+ d.kind === "header" || d.kind === "branch" ? 600 : 400,
167
+ )
168
+ .attr("opacity", (d) => (labelled(d, style, o) ? 1 : 0))
169
+ .text((d) => (labelled(d, style, o) ? labelFor(d, geom.w(d)) : ""));
170
+ }
171
+
172
+ /** flat: indent by depth, span the rest of the track. */
173
+ const flatGeom = (indent, barX) => ({
174
+ x: (d) => (d.depth - 1) * indent,
175
+ w: (d) => barX - (d.depth - 1) * indent,
176
+ });
177
+
178
+ /**
179
+ * icicle: one column per level. A terminal row spans from its own column to the
180
+ * end of the track, so a leaf that stops short in a ragged tree still reads as a
181
+ * leaf rather than leaving a hole (same convention as d3's icicle).
182
+ */
183
+ const icicleGeom = (maxDepth, barX) => {
184
+ const colW = barX / maxDepth;
185
+ return {
186
+ x: (d) => (d.depth - 1) * colW,
187
+ w: (d) => (d.terminal ? barX - (d.depth - 1) * colW : colW),
188
+ };
189
+ };
190
+
191
+ /**
192
+ * A branch's band spans its whole subtree, so a label centred in it would land
193
+ * ON TOP of its children. In flat mode the branch's synthetic header row already
194
+ * carries the name, so the branch itself stays silent — shading only. In icicle
195
+ * mode the branch owns a column of its own, so it must be labelled.
196
+ */
197
+ function labelled(d, style, o) {
198
+ if (style !== "icicle" && d.kind === "branch") return false;
199
+ // The row you are ON and the row you have CHOSEN always say what they are, at
200
+ // any thickness. The floor exists to stop hundreds of unreadable labels piling
201
+ // up — it must never silence the two rows the user is actually asking about.
202
+ // Without this, `fisheye` (which draws every leaf, so every band is a couple of
203
+ // pixels) is a lens onto a column of anonymous stripes: you hover, and nothing
204
+ // tells you where you are.
205
+ if (d.focused || d.id === o.selectedId) return true;
206
+ return d.thickness >= o.minLabelPx;
207
+ }
208
+
209
+ function labelFor(d, w) {
210
+ const text = d.kind === "elision" ? `⋯ ${d.label}` : (d.label ?? "");
211
+ // ~6px per char at 11px system-ui; cheap and good enough to avoid measuring
212
+ // text in a hot path that runs on every mousemove.
213
+ const max = Math.max(0, Math.floor((w - 8) / 6));
214
+ return text.length > max ? text.slice(0, Math.max(1, max - 1)) + "…" : text;
215
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * One stylesheet, injected once. Everything is expressed as a CSS custom
3
+ * property on `.fisheye-nav`, so a host restyles the widget without a build step
4
+ * and without the widget knowing anything about the host's design system.
5
+ *
6
+ * Colors are derived from a single hue + the host's text color, and the widget
7
+ * inherits `color-scheme`, so it lands correctly in a light or a dark app
8
+ * without being told which it is in.
9
+ */
10
+ const CSS = `
11
+ .fisheye-nav {
12
+ --fn-accent: #3b82f6;
13
+ --fn-fg: currentColor;
14
+ --fn-muted: color-mix(in oklab, currentColor 55%, transparent);
15
+ --fn-band: color-mix(in oklab, currentColor 8%, transparent);
16
+ --fn-band-hover: color-mix(in oklab, currentColor 16%, transparent);
17
+ --fn-rule: color-mix(in oklab, currentColor 18%, transparent);
18
+ --fn-bar: color-mix(in oklab, var(--fn-accent) 45%, transparent);
19
+ --fn-font: system-ui, -apple-system, "Segoe UI", sans-serif;
20
+ --fn-size: 11px;
21
+
22
+ position: relative;
23
+ display: block;
24
+ width: 100%;
25
+ height: 100%;
26
+ font-family: var(--fn-font);
27
+ font-size: var(--fn-size);
28
+ color: var(--fn-fg);
29
+ user-select: none;
30
+ }
31
+ .fisheye-nav svg { display: block; width: 100%; height: 100%; overflow: hidden; }
32
+ .fisheye-nav .fn-row { cursor: pointer; }
33
+ .fisheye-nav .fn-band {
34
+ fill: var(--fn-band);
35
+ stroke: var(--fn-rule);
36
+ stroke-width: 0.5;
37
+ shape-rendering: crispEdges;
38
+ }
39
+ .fisheye-nav .fn-row:hover .fn-band { fill: var(--fn-band-hover); }
40
+ .fisheye-nav .fn-row[data-kind="branch"] .fn-band { fill: color-mix(in oklab, currentColor 4%, transparent); }
41
+ .fisheye-nav .fn-row[data-kind="elision"] .fn-band { fill: none; stroke-dasharray: 2 2; }
42
+ .fisheye-nav .fn-row[data-focused="true"] .fn-band {
43
+ fill: color-mix(in oklab, var(--fn-accent) 22%, transparent);
44
+ stroke: var(--fn-accent);
45
+ stroke-width: 1;
46
+ }
47
+ .fisheye-nav .fn-selected .fn-band {
48
+ stroke: var(--fn-accent);
49
+ stroke-width: 1.5;
50
+ }
51
+ .fisheye-nav .fn-bar { fill: var(--fn-bar); }
52
+ .fisheye-nav .fn-label {
53
+ fill: var(--fn-fg);
54
+ dominant-baseline: middle;
55
+ pointer-events: none;
56
+ white-space: pre;
57
+ }
58
+ .fisheye-nav .fn-row[data-kind="branch"] .fn-label,
59
+ .fisheye-nav .fn-row[data-kind="elision"] .fn-label { fill: var(--fn-muted); }
60
+ .fisheye-nav .fn-count { fill: var(--fn-muted); text-anchor: end; dominant-baseline: middle; pointer-events: none; }
61
+ .fisheye-nav .fn-spine { fill: var(--fn-rule); }
62
+ .fisheye-nav .fn-empty { fill: var(--fn-muted); text-anchor: middle; }
63
+
64
+ /* ── Settings gear + popover (opt out with controls: false) ──────────────────
65
+ Neutral defaults driven by the same --fn-* variables as the chart, so a host
66
+ restyles the panel without patching the library. */
67
+ .fisheye-nav .fn-gear {
68
+ position: absolute; top: 2px; right: 4px; z-index: 6;
69
+ width: 18px; height: 18px; padding: 0; border: none; border-radius: 4px;
70
+ background: transparent; color: var(--fn-muted);
71
+ font-size: 12px; line-height: 18px; cursor: pointer;
72
+ }
73
+ .fisheye-nav .fn-gear:hover,
74
+ .fisheye-nav .fn-gear.on { color: var(--fn-fg); background: var(--fn-band-hover); }
75
+ /* Visibility is driven by the .fn-open CLASS, not by the hidden attribute: the
76
+ UA's "[hidden] { display: none }" is a single-attribute selector, so any rule
77
+ here that sets display outranks it and setting .hidden silently does nothing.
78
+ (It did exactly that — every popover in the demo rendered open.) The hidden
79
+ attribute is still kept in sync, for assistive tech.
80
+ NB: no backticks in this comment — the whole stylesheet is a JS template
81
+ literal, and a backtick here would terminate it. */
82
+ /* The popover is the ONE place the widget paints its own background, so it is
83
+ the one place "inherit the host's color" stops working: an inherited
84
+ foreground over a system background is a coin toss (a dark app's near-white
85
+ text landed on Canvas-white and vanished). Background and foreground must
86
+ come from the same place — so they are a system PAIR by default, and a host
87
+ overriding one is expected to override the other. color-scheme makes the
88
+ pair, and the native selects and sliders inside, follow the host's theme. */
89
+ .fisheye-nav .fn-panel {
90
+ position: absolute; top: 22px; right: 4px; z-index: 7;
91
+ width: 220px; padding: 8px; display: none; flex-direction: column; gap: 6px;
92
+ color-scheme: var(--fn-scheme, light dark);
93
+ background: var(--fn-panel, Canvas);
94
+ color: var(--fn-panel-fg, CanvasText);
95
+ border: 1px solid var(--fn-rule); border-radius: 6px;
96
+ box-shadow: 0 6px 20px rgb(0 0 0 / 0.28);
97
+ }
98
+ .fisheye-nav .fn-panel.fn-open { display: flex; }
99
+ .fisheye-nav .fn-row-ctl { display: flex; align-items: center; gap: 6px; }
100
+ .fisheye-nav .fn-ctl-label { flex: 0 0 76px; opacity: 0.75; }
101
+ .fisheye-nav .fn-ctl-select {
102
+ flex: 1; min-width: 0; font: inherit; padding: 2px 4px;
103
+ color: inherit; background: transparent;
104
+ border: 1px solid var(--fn-rule); border-radius: 4px;
105
+ }
106
+ .fisheye-nav .fn-ctl-slider { flex: 1; display: flex; align-items: center; gap: 6px; min-width: 0; }
107
+ .fisheye-nav .fn-ctl-slider input { flex: 1; min-width: 0; accent-color: var(--fn-accent); }
108
+ .fisheye-nav .fn-ctl-val {
109
+ flex: 0 0 30px; text-align: right; opacity: 0.6;
110
+ font-variant-numeric: tabular-nums;
111
+ }
112
+ .fisheye-nav .fn-ctl-more summary { cursor: pointer; opacity: 0.75; margin-bottom: 4px; }
113
+ .fisheye-nav .fn-ctl-more[open] { border-top: 1px solid var(--fn-rule); padding-top: 6px; }
114
+ .fisheye-nav .fn-ctl-reset {
115
+ align-self: flex-start; font: inherit; padding: 2px 8px; cursor: pointer;
116
+ color: inherit; background: transparent;
117
+ border: 1px solid var(--fn-rule); border-radius: 4px;
118
+ }
119
+ /* A control that cannot affect the current lens says so, rather than silently
120
+ doing nothing when dragged. */
121
+ .fisheye-nav .fn-disabled { opacity: 0.4; }
122
+ `;
123
+
124
+ let injected = false;
125
+
126
+ export function injectStyle(doc = globalThis.document) {
127
+ if (injected || !doc?.head) return;
128
+ const el = doc.createElement("style");
129
+ el.setAttribute("data-fisheye-nav", "");
130
+ el.textContent = CSS;
131
+ doc.head.appendChild(el);
132
+ injected = true;
133
+ }
134
+
135
+ export { CSS };
@@ -0,0 +1,72 @@
1
+ <script>
2
+ /**
3
+ * Svelte 4 wrapper. Owns the mount/teardown and the two-way `selected` binding,
4
+ * nothing else — all behaviour lives in the vanilla widget.
5
+ *
6
+ * <FisheyeNav {data} keys={["year","month","day"]} bind:selected
7
+ * on:select={(e) => jumpToPath(e.detail)} />
8
+ */
9
+ import { createEventDispatcher } from "svelte";
10
+ import { fisheyeNav } from "../fisheyeNav.js";
11
+
12
+ /** Flat rows (shape A) + the levels. */
13
+ export let data = undefined;
14
+ export let keys = undefined;
15
+ /** Or a nested tree (shape B). */
16
+ export let root = undefined;
17
+ export let children = undefined;
18
+
19
+ /**
20
+ * These are UNSET by default on purpose. Every prop we pass is an *explicit*
21
+ * option, and an explicit option beats the user's persisted choice — so a
22
+ * wrapper that always passed `style: "flat"` would both ignore `persistKey`
23
+ * and stomp the gear's selection on the next reactive update. Pass them only
24
+ * to pin the lens in code.
25
+ */
26
+ export let style = undefined; // "flat" | "icicle"
27
+ export let layout = undefined; // "fisheye" | "doi" | "hybrid" | "uniform"
28
+ /** The selected path, [{key, value}, …]. Bindable. */
29
+ export let selected = null;
30
+ /** Any other widget knob: distortion, minRowPx, label, count, persistKey, … */
31
+ export let options = {};
32
+
33
+ const dispatch = createEventDispatcher();
34
+ let nav = null;
35
+
36
+ const defined = (o) =>
37
+ Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined));
38
+
39
+ $: opts = defined({ data, keys, root, children, style, layout, ...options });
40
+
41
+ function mount(node) {
42
+ nav = fisheyeNav({ ...opts, selected });
43
+ nav.addEventListener("input", () => {
44
+ // Write the binding BEFORE dispatching, so a parent reading `selected` in
45
+ // the handler sees the new value rather than the previous one.
46
+ selected = nav.value;
47
+ dispatch("select", nav.value);
48
+ });
49
+ node.appendChild(nav);
50
+ return {
51
+ destroy() {
52
+ nav?.destroy?.();
53
+ nav?.remove();
54
+ nav = null;
55
+ },
56
+ };
57
+ }
58
+
59
+ $: if (nav) nav.update(opts);
60
+ // Assigning `.value` is the SILENT path — it repaints the highlight without
61
+ // firing `input`, so a parent pushing the selection in cannot echo back out.
62
+ $: if (nav && selected !== nav.value) nav.value = selected;
63
+ </script>
64
+
65
+ <div class="fisheye-nav-host" use:mount></div>
66
+
67
+ <style>
68
+ .fisheye-nav-host {
69
+ width: 100%;
70
+ height: 100%;
71
+ }
72
+ </style>