@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,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>
|