Skip to content

Commit 6160622

Browse files
feat: /map page — force-directed spec map clustered by tag similarity (#5647)
## Summary - Backend: new `GET /api/specs/map` endpoint — one row per spec with the highest-rated implementation's preview URLs, quality score, and full tag bag (spec + impl). - Frontend: new `/map` page powered by `react-force-graph-2d` — image-thumbnail nodes positioned by client-side weighted-Jaccard similarity over tags (IDF-weighted, sparse KNN edges). - Wired into `NavBar` as a top-level link and registered as a lazy-loaded route. Closes #5646. ## Approach in one paragraph Tag similarity, IDF weighting, and KNN edge construction all happen client-side as pure helpers in `app/src/pages/MapPage.helpers.ts` (19 unit tests). 327 specs × pairwise weighted-Jaccard runs in ~30 ms in the browser, so there's no need to precompute positions on the server. The wrapper's built-in d3-force physics handles the layout from a sparse link list (K=5, min-sim 0.05 → ~1.6k edges). Best-impl thumbnails are eager-preloaded and attached to nodes as they resolve, so the canvas re-paints organically without restarting physics. Hover highlights neighbors, click navigates to the spec page, and a visually-hidden anchor list mirrors the canvas for screen readers + keyboard users. ## Test plan - [x] `uv run ruff check api/ tests/` — clean - [x] `uv run ruff format --check api/ tests/` — clean - [x] `uv run pytest tests/unit/api/ tests/unit/core/` — 739 passed (5 new SpecsRouter tests for the endpoint) - [x] `cd app && yarn lint` — no new errors in MapPage files (pre-existing baseline drift in unrelated files unchanged) - [x] `cd app && yarn tsc --noEmit` — clean - [x] `cd app && yarn test` — 421 passed (19 new helper tests + 3 new page smoke tests) - [x] `cd app && yarn build` — succeeds; `/map` chunk is 193 KB raw / 63 KB gz, lazy-loaded only when visiting `/map` - [ ] Manual visual QA in dev server: nav link appears, ~327 thumbnails render, scatter/bar/line clusters form distinct neighborhoods, hover highlights neighbors + dims the rest, click → spec page, theme toggle switches link colors and thumbnails, mobile breakpoint usable ## Notes / known follow-ups 1. **Optional edge tag labels** (the user's "optional" feature) — explicitly out of scope here, deferred to a follow-up issue. Idea: on hover, render shared-tag chips at link midpoints as DOM overlays. 2. **Layout opt-out via negative margins** in `MapPage.tsx` cancels `RootLayout`'s container padding so the canvas goes full-bleed. TODO comment left in place; cleaner long-term answer is a router-level layout switch analog to the existing `mastheadSticks` flag. 3. **Thumbnail size**: full-size GCS PNGs at 327 × ~40 KB ≈ 13 MB. Acceptable on warm CDN, slightly heavy on cold. If perf review pushes back, the follow-up is to add a smaller variant in the responsive-image pipeline. 4. **K=5, min-sim=0.05** are gut-feel values; helpers are pure so tuning is a one-line PR after live observation. 5. **Pre-existing eslint baseline drift**: 35 `react-hooks/set-state-in-effect` and `no-undef` errors in unrelated files were already present before this branch. None added; one previously-introduced one in `MapPage.tsx` was refactored away during review. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent bb0fc63 commit 6160622

17 files changed

Lines changed: 2816 additions & 3 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,3 +242,6 @@ docker-compose.override.yml
242242
secrets/
243243
/.playwright-mcp/
244244
/screenshots/
245+
246+
# Map page tuning screenshots (root-level only — must not match plots/*/preview.png etc.)
247+
/map-*.png

api/cache.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ def clear_spec_cache(spec_id: str) -> int:
178178
count += clear_cache_by_pattern(f"spec:{spec_id}")
179179
count += clear_cache_by_pattern(f"spec_images:{spec_id}")
180180
count += clear_cache_by_pattern("specs_list") # List might have changed
181+
count += clear_cache_by_pattern("specs_map") # Map page payload might have changed
181182
count += clear_cache_by_pattern("filter:") # Filters might be affected
182183
count += clear_cache_by_pattern("stats") # Stats might have changed
183184
count += clear_cache_by_pattern("sitemap") # Sitemap includes spec URLs

api/routers/seo.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ def _build_sitemap_xml(specs: list) -> str:
4747
" <url><loc>https://anyplot.ai/plots</loc></url>",
4848
" <url><loc>https://anyplot.ai/specs</loc></url>",
4949
" <url><loc>https://anyplot.ai/libraries</loc></url>",
50+
" <url><loc>https://anyplot.ai/map</loc></url>",
5051
" <url><loc>https://anyplot.ai/palette</loc></url>",
5152
" <url><loc>https://anyplot.ai/about</loc></url>",
5253
" <url><loc>https://anyplot.ai/mcp</loc></url>",

api/routers/specs.py

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from api.cache import cache_key, get_or_set_cache
77
from api.dependencies import require_db
88
from api.exceptions import raise_not_found
9-
from api.schemas import ImplementationResponse, SpecDetailResponse, SpecListItem
9+
from api.schemas import ImplementationResponse, SpecDetailResponse, SpecListItem, SpecMapItem
1010
from core.config import settings
1111
from core.database import ImplRepository, SpecRepository
1212
from core.database.connection import get_db_context
@@ -28,6 +28,40 @@ async def _build_specs_list(db: AsyncSession) -> list[SpecListItem]:
2828
]
2929

3030

31+
async def _build_specs_map(db: AsyncSession) -> list[SpecMapItem]:
32+
"""One row per spec with its best-rated impl image + spec/impl tag bag for the /map page.
33+
34+
Best-impl tiebreak: highest quality_score, then lexicographically *greatest* library_id
35+
(since `max()` picks the largest tuple — e.g. seaborn over matplotlib on a tie).
36+
Specs without any implementations are skipped (mirrors _build_specs_list).
37+
"""
38+
repo = SpecRepository(db)
39+
specs = await repo.get_all()
40+
items: list[SpecMapItem] = []
41+
for spec in specs:
42+
if not spec.impls:
43+
continue
44+
# Prefer impls that actually have a preview URL — otherwise the map
45+
# would render blank-bordered nodes for specs whose top-quality impl
46+
# happens to have no thumbnail. Fall back to the full impl list only
47+
# when *no* impl has a preview (very rare, but keeps the spec on the map).
48+
with_preview = [i for i in spec.impls if i.preview_url_light or i.preview_url_dark]
49+
candidates = with_preview or list(spec.impls)
50+
best = max(candidates, key=lambda i: ((i.quality_score or 0.0), i.library_id))
51+
items.append(
52+
SpecMapItem(
53+
id=spec.id,
54+
title=spec.title,
55+
preview_url_light=best.preview_url_light,
56+
preview_url_dark=best.preview_url_dark,
57+
quality_score=best.quality_score,
58+
tags=spec.tags,
59+
impl_tags=best.impl_tags,
60+
)
61+
)
62+
return items
63+
64+
3165
async def _build_spec_detail(db: AsyncSession, spec_id: str) -> SpecDetailResponse:
3266
repo = SpecRepository(db)
3367
spec = await repo.get_by_id(spec_id)
@@ -125,6 +159,25 @@ async def _refresh() -> list[SpecListItem]:
125159
)
126160

127161

162+
@router.get("/specs/map", response_model=list[SpecMapItem])
163+
async def get_specs_map(db: AsyncSession = Depends(require_db)):
164+
"""Get one row per spec (best-impl image + tag bag) for the /map clustering page.
165+
166+
NOTE: must stay declared before /specs/{spec_id} so the path-parameter route doesn't capture "map".
167+
"""
168+
169+
async def _fetch() -> list[SpecMapItem]:
170+
return await _build_specs_map(db)
171+
172+
async def _refresh() -> list[SpecMapItem]:
173+
async with get_db_context() as fresh_db:
174+
return await _build_specs_map(fresh_db)
175+
176+
return await get_or_set_cache(
177+
cache_key("specs_map"), _fetch, refresh_after=settings.cache_refresh_after, refresh_factory=_refresh
178+
)
179+
180+
128181
@router.get("/specs/{spec_id}", response_model=SpecDetailResponse)
129182
async def get_spec(spec_id: str, db: AsyncSession = Depends(require_db)):
130183
"""Get detailed spec information including all implementations."""

api/schemas.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,22 @@ class SpecListItem(BaseModel):
6969
library_count: int = 0
7070

7171

72+
class SpecMapItem(BaseModel):
73+
"""One row per spec for the /map page: best-impl preview + full tag bag for client-side similarity clustering."""
74+
75+
id: str
76+
title: str
77+
preview_url_light: str | None = None
78+
preview_url_dark: str | None = None
79+
quality_score: float | None = None
80+
# Tag bags: each category maps to a list of strings. Tightened from
81+
# dict[str, Any] so the OpenAPI contract matches what the /map frontend
82+
# expects (Record<string, string[]>) and so unexpected shapes get
83+
# caught at validation time instead of breaking client-side similarity.
84+
tags: dict[str, list[str]] | None = None
85+
impl_tags: dict[str, list[str]] | None = None
86+
87+
7288
class ImageResponse(BaseModel):
7389
"""Image/plot response for grid display."""
7490

app/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,11 @@
2222
"@emotion/styled": "^11.14.1",
2323
"@mui/icons-material": "^9.0.0",
2424
"@mui/material": "^9.0.0",
25+
"force-graph": "^1.51.4",
2526
"fuse.js": "^7.3.0",
2627
"react": "^19.2.5",
2728
"react-dom": "^19.2.5",
29+
"react-force-graph-2d": "^1.29.1",
2830
"react-helmet-async": "^3.0.0",
2931
"react-router-dom": "^7.14.2",
3032
"react-syntax-highlighter": "^16.1.1",

app/src/components/NavBar.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const DEBUG_CLICK_WINDOW_MS = 800;
1010
const NAV_LINKS: { label: string; to: string; short?: string }[] = [
1111
{ label: 'specs', to: '/specs' },
1212
{ label: 'plots', to: '/plots' },
13+
{ label: 'map', to: '/map' },
1314
{ label: 'libraries', to: '/libraries', short: 'libs' },
1415
{ label: 'stats', to: '/stats' },
1516
{ label: 'palette', to: '/palette', short: 'pal' },

0 commit comments

Comments
 (0)