@john-guerra/fisheye-nav
v0.1.2
Published
A focus+context navigator for hierarchies: an icicle whose leaf axis is fisheyed, or a flat list indented by level. Vanilla reactive widget, with Svelte and React wrappers.
Maintainers
Readme
@john-guerra/fisheye-nav
A focus+context navigator for hierarchies: an icicle whose leaf axis is fisheyed, or a flat outline indented by level. Hover to magnify; click any band to select at that depth.
Built for lists too long to scroll — a photo library with 3,000 days in it, a log tree, a file system — where you need to see where you are and what's around you at the same time.
Vanilla reactivewidgets-style DOM widget. Svelte and React wrappers included.
npm install @john-guerra/fisheye-navimport fisheyeNav from "@john-guerra/fisheye-nav";
const nav = fisheyeNav({
data: rows, // flat, pre-sorted rows
keys: ["year", "month", "day"], // the levels
count: (d) => d.count, // mass (drives the histogram)
});
document.querySelector("#sidebar").appendChild(nav);
nav.addEventListener("input", () => {
console.log(nav.value);
// [{key:"year", value:"2024"}, {key:"month", value:"06"}, {key:"day", value:"2024-06-13"}]
});
nav.value = [{ key: "year", value: "2024" }]; // set silently — no `input` firesThe two axes
style is how it's drawn. layout is how vertical space is allocated. They are
independent — all eight combinations are valid.
| | uniform | fisheye |
| ---------------- | -------------------------------------------------------------- | ------------------------------------------------------ |
| all leaves | plain icicle | fisheye — every leaf, distorted around the focus |
| DOI-selected | doi — full-height rows, uninteresting subtrees collapsed | hybrid — default |
fisheye— no decimation. Every leaf gets a band, magnified near the focus and compressed away from it. Honest about density; on thousands of leaves the compressed tail is sub-pixel, so use it when the silhouette is the point.doi— Furnas degree of interest. Leaves are scored by importance minus distance-from-focus; the best ones keep a full, readable row and everything else collapses into its ancestors (2019 · 1,204) or into an elision row (⋯ 4 more). No magnification, nothing sub-pixel.hybrid(default) — DOI picks which leaves survive, the fisheye positions them. Bounded row count and continuous magnification. This is the one you want for a real library.
style: "flat" gives internal nodes their own row (indented by depth), so you can always
see what level you're on. style: "icicle" gives each depth a column instead.
Options
| | |
| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| data, keys | flat pre-sorted rows + the level names. Order is preserved — the widget groups, it never re-sorts. |
| root, children | …or a nested tree instead. |
| count | leaf mass. Default d => d.count ?? 1. |
| label | (value, key, node) => string. |
| style | "flat" | "icicle" |
| layout | "hybrid" | "fisheye" | "doi" | "uniform" |
| sizeBy | what a band's height encodes: "slots" (default — interest, every row equal) or "count" (mass, a true value-encoded partition). |
| sizeFloor | under sizeBy: "count", the share of the column split equally regardless of mass, so a zero-count group is still visible and clickable. Default 0.25; 1 collapses back to "slots". |
| distortion | lens strength. Higher = gentler. Default 4. |
| minRowPx | target minimum row height; sets the DOI row budget. Default 16. |
| apiWeight, distanceWeight, treeWeight, countWeight | DOI scoring knobs. |
| bars | draw the count histogram inside each band (length = photo mass). Default true. |
| barScale | "log" (default) | "sqrt" | "linear". Log, because real hierarchies have pathological count distributions — one bucket holding 99% of the mass flattens every other bar to the minimum under linear or sqrt. |
| controls | show the ⚙ settings popover. Default true. |
| persistKey | localStorage key to remember the user's settings under. Default null (don't persist). |
| width, height | default: measured from the container (with a ResizeObserver). |
Methods: nav.update(opts), nav.getRows(), nav.getRoot(), nav.getLeafCount(),
nav.getSettings(), nav.setSettings(s), nav.resetSettings(), nav.destroy().
Styling: override the --fn-* custom properties on .fisheye-nav.
Settings, built in
The widget ships its own ⚙ popover — view, lens, band size, bar scale, distortion, row
size, and the DOI weights. A consumer should never hand-roll a gear. Give it a
persistKey and the user's choices survive a reload; listen for settings if you want to
react to them.
const nav = fisheyeNav({ data, keys, persistKey: "myapp.fisheye" });
nav.addEventListener("settings", (e) => console.log(e.detail)); // {style, layout, …}An explicit option always beats a persisted one, so fisheyeNav({style: "icicle", …})
still opens as an icicle even if the user last left it flat. Pass controls: false to hide
the gear entirely.
Svelte
<script>
import FisheyeNav from "@john-guerra/fisheye-nav/svelte";
</script>
<FisheyeNav
{data}
keys={["year", "month", "day"]}
style="icicle"
bind:selected
on:select={(e) => jumpTo(e.detail)}
/>React
import FisheyeNav from "@john-guerra/fisheye-nav/react";
<FisheyeNav
data={rows}
keys={["year", "month", "day"]}
selected={path}
onSelect={setPath}
/>;How it holds together
Every node owns a leaf interval, and the slots the layout produces partition that same leaf axis. So a node's band is the pixel span of the slots its interval covers — and because a child's interval is a subset of its parent's, its band is necessarily inside its parent's. Parents contain their children is arithmetic, not diligence.
That one idea is what lets selection and positioning vary independently, lets both
renderers stay ignorant of which algorithm ran, and lets the test suite state its
invariants once and sweep them across every strategy:
- every band is contained in its parent's;
- rows tile the column with no gaps and no overlaps;
- leaf order is preserved;
- photo mass is conserved through every elision;
- the focused row's band contains the focus pixel — so the row you click is the row you pointed at;
- the focused leaf is never elided away, whatever the budget.
npm test # property tests (fast-check) + DOM tests (jsdom)
npm run dev # the demo: the full style x layout matrixThe demo comes two ways. npm run dev serves example/index.html straight off
src/ through Vite — use this while hacking on the widget. example/standalone.html
runs off the bundled dist/, so it works under any plain static server (a bare
http-server cannot load example/index.html, because src/ imports d3 by bare
specifier and only a bundler can resolve that):
npm run build && npx http-server . -p 5190
# -> http://localhost:5190/example/standalone.htmlLicense
MIT © John Alexis Guerra Gómez
