Summary
Add a new top-level page /map that renders all ~327 plot specs as image-thumbnail nodes in a force-directed graph, positioned by tag overlap. Specs that share many tags pull together; specs that share few drift apart. Acts as a visual discovery surface complementing the existing list-based /specs, /plots, and library pages.
Reference visual target: vasturiano/force-graph image-nodes example.
Motivation
Today there is no spatial way to discover related specs — to see at a glance that "annotated scatter" lives next door to "regression scatter" and a few jumps away from "bar with error bars." Tag-based clustering on a force-directed map exposes the implicit topology of the catalog, surfaces unexpected neighborhoods, and gives the gallery a novel browsing affordance.
Approach
Backend — one new lean endpoint:
GET /api/specs/map returns one row per spec containing id, title, preview_url_light, preview_url_dark, quality_score, tags (spec-level), impl_tags (best-impl). Image is the highest-quality_score implementation. Cached via the existing pattern.
- New schema
SpecMapItem in api/schemas.py. Route declared before /specs/{spec_id} so the path-parameter route doesn't capture it.
Frontend — new page powered by react-force-graph-2d:
app/src/pages/MapPage.tsx — full-bleed canvas, theme-aware thumbnails, click-to-spec navigation, screen-reader fallback list.
app/src/pages/MapPage.helpers.ts (+ tests) — pure functions for tag flattening with category:value prefixes, IDF weighting (down-weights ubiquitous tags like data_type:numeric), weighted Jaccard similarity, sparse KNN edge construction (K=5, min-sim 0.05).
- Layout computed by
force-graph's built-in d3-force engine; no UMAP/MST/A* on the server. With ~1.6k edges over 327 nodes the cluster structure forms naturally.
- Wire-up:
app/src/router.tsx lazy route + app/src/components/NavBar.tsx nav link.
Why client-side similarity: 327×327 pairwise weighted-Jaccard runs in ~30 ms in the browser; a precomputed-positions endpoint would block future filter-driven re-layouts and add numpy/scipy dep creep on the API.
Out of scope (follow-up)
- Edge labels showing which shared tags connect two nodes — explicitly deferred.
- Filter UI on the map (e.g., toggle
dependencies:scipy, re-cluster live).
- Smaller thumbnail variants in the responsive-image pipeline if perf review pushes back on the ~13 MB warm cache load.
- Cleaner full-bleed layout via a router-level switch (analog to the existing
mastheadSticks flag) instead of negative-margin Container opt-out.
File map
Created:
app/src/pages/MapPage.tsx
app/src/pages/MapPage.helpers.ts + .test.ts
app/src/pages/MapPage.test.tsx
Modified:
api/schemas.py, api/routers/specs.py, api/cache.py
tests/unit/api/test_routers.py
app/src/router.tsx, app/src/components/NavBar.tsx
app/package.json + app/yarn.lock (new deps: force-graph, react-force-graph-2d)
Test plan
Summary
Add a new top-level page
/mapthat renders all ~327 plot specs as image-thumbnail nodes in a force-directed graph, positioned by tag overlap. Specs that share many tags pull together; specs that share few drift apart. Acts as a visual discovery surface complementing the existing list-based/specs,/plots, and library pages.Reference visual target: vasturiano/force-graph image-nodes example.
Motivation
Today there is no spatial way to discover related specs — to see at a glance that "annotated scatter" lives next door to "regression scatter" and a few jumps away from "bar with error bars." Tag-based clustering on a force-directed map exposes the implicit topology of the catalog, surfaces unexpected neighborhoods, and gives the gallery a novel browsing affordance.
Approach
Backend — one new lean endpoint:
GET /api/specs/mapreturns one row per spec containingid,title,preview_url_light,preview_url_dark,quality_score,tags(spec-level),impl_tags(best-impl). Image is the highest-quality_scoreimplementation. Cached via the existing pattern.SpecMapIteminapi/schemas.py. Route declared before/specs/{spec_id}so the path-parameter route doesn't capture it.Frontend — new page powered by
react-force-graph-2d:app/src/pages/MapPage.tsx— full-bleed canvas, theme-aware thumbnails, click-to-spec navigation, screen-reader fallback list.app/src/pages/MapPage.helpers.ts(+ tests) — pure functions for tag flattening withcategory:valueprefixes, IDF weighting (down-weights ubiquitous tags likedata_type:numeric), weighted Jaccard similarity, sparse KNN edge construction (K=5, min-sim 0.05).force-graph's built-in d3-force engine; no UMAP/MST/A* on the server. With ~1.6k edges over 327 nodes the cluster structure forms naturally.app/src/router.tsxlazy route +app/src/components/NavBar.tsxnav link.Why client-side similarity: 327×327 pairwise weighted-Jaccard runs in ~30 ms in the browser; a precomputed-positions endpoint would block future filter-driven re-layouts and add numpy/scipy dep creep on the API.
Out of scope (follow-up)
dependencies:scipy, re-cluster live).mastheadSticksflag) instead of negative-margin Container opt-out.File map
Created:
app/src/pages/MapPage.tsxapp/src/pages/MapPage.helpers.ts+.test.tsapp/src/pages/MapPage.test.tsxModified:
api/schemas.py,api/routers/specs.py,api/cache.pytests/unit/api/test_routers.pyapp/src/router.tsx,app/src/components/NavBar.tsxapp/package.json+app/yarn.lock(new deps:force-graph,react-force-graph-2d)Test plan
uv run ruff check api/ tests/ && uv run ruff format api/ tests/uv run pytest tests/unit/api/test_routers.py -v— 4 new tests: list, picks-best-impl, skips-impl-less, empty-dbcd app && yarn lint && yarn tsc --noEmit && yarn test && yarn build/mapchunk in ~40–60 KB gz range