🐟 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. For lists too long to scroll.

Try it GitHub npm
npm i @john-guerra/fisheye-nav

Live demo

A synthetic photo library, year β†’ month β†’ day. Hover the big panel to move the lens; click a band to select it. The βš™ in its corner is the widget's own settings β€” every consumer gets it for free.

click a band…

Two independent axes

style is how it's drawn. layout is how vertical space is allocated. They're orthogonal β€” the renderers never learn which algorithm ran, which is why every combination above is valid.

fisheye

No decimation. Every leaf gets a band, magnified near the focus and compressed away from it. Honest about density β€” use it when the silhouette is the point.

doi

Furnas degree-of-interest. Leaves are scored by importance minus distance-from-focus; the best keep a full row, the rest collapse into their ancestors or an β‹― 4 more row. Nothing sub-pixel.

hybrid (default)

DOI picks which leaves survive; the fisheye positions them. Bounded row count and continuous magnification. The one you want for a real library.

sizeBy

slots makes a band's height encode interest. count makes it encode mass, so a 40,000-photo year visibly dwarfs a 400-photo one β€” a true value-encoded partition diagram.

Use it

A vanilla reactivewidgets-style DOM node: a .value getter/setter and an input event. Svelte and React wrappers ship in the same package.

import 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)
});
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` fires

Why it can't draw a broken tree

Every node owns a leaf interval, and the layout's slots partition that same 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, and what lets the test suite state its invariants once and sweep them across every strategy:

Containment every band is inside its parent's
Tiling rows tile the column with no gaps and no overlaps
Order leaf order is preserved
Mass counts are conserved through every elision
Focus the focused row's band contains the focus pixel β€” the row you click is the row you pointed at
Budget the focused leaf is never elided away, whatever the row budget