Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
cfb73c4
feat(api): add GET /specs/map endpoint
MarkusNeusinger Apr 30, 2026
47cf1bc
feat(app): add MapPage with force-directed similarity graph
MarkusNeusinger Apr 30, 2026
ac8019a
feat(app): wire /map route + nav link
MarkusNeusinger Apr 30, 2026
004b447
test(app): cover MapPage canvas/click/hover callbacks
MarkusNeusinger Apr 30, 2026
e88ad99
fix(app): drop crossOrigin on map thumbnails to avoid GCS CORS preflight
MarkusNeusinger Apr 30, 2026
c2d72d1
perf(app): use _400.webp variants for map thumbnails
MarkusNeusinger Apr 30, 2026
c68f153
perf(app): switch map thumbnails to _800.webp variant
MarkusNeusinger Apr 30, 2026
ca8bad5
perf(app): progressive thumbnail loading (400→800→1200) on zoom
MarkusNeusinger Apr 30, 2026
5405c88
fix(app): respect plot aspect ratio (16:9) when drawing map nodes
MarkusNeusinger Apr 30, 2026
7be2269
feat(app): cluster gravity + hover preview for the spec map
MarkusNeusinger Apr 30, 2026
010707d
feat(app): bigger map nodes + cluster colors + subdued links
MarkusNeusinger Apr 30, 2026
66e92e3
feat(app): plot_type-weighted similarity + drop ring layout
MarkusNeusinger Apr 30, 2026
93750a6
feat(app): legend with cluster filter + restore link visibility
MarkusNeusinger Apr 30, 2026
3c46a64
feat(app): per-category weight sliders for live similarity tuning
MarkusNeusinger Apr 30, 2026
3ffdd86
feat(app): plot_type-only default + slider max bumped to 5
MarkusNeusinger Apr 30, 2026
db24af7
fix(app): zero out IDF for tags in >67% of corpus + raise KNN_MIN_SIM
MarkusNeusinger Apr 30, 2026
17659e8
feat(app): legend + colors follow the highest-weighted category
MarkusNeusinger Apr 30, 2026
5325c7c
feat(app): /map UX overhaul — corner panel, search, pin, mobile
MarkusNeusinger May 1, 2026
b03d347
docs(plausible): document map_node_click / map_node_pin / map_search_…
MarkusNeusinger May 1, 2026
58ffc9e
chore(map): apply Copilot review nits
MarkusNeusinger May 1, 2026
45471fd
fix(map): apply 4 Copilot follow-up findings
MarkusNeusinger May 1, 2026
97f012a
fix(map): preserve loaded thumbnails across graphData rebuilds
MarkusNeusinger May 1, 2026
17ecdd1
feat(seo): add /map to the static sitemap
MarkusNeusinger May 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions api/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ def clear_spec_cache(spec_id: str) -> int:
count += clear_cache_by_pattern(f"spec:{spec_id}")
count += clear_cache_by_pattern(f"spec_images:{spec_id}")
count += clear_cache_by_pattern("specs_list") # List might have changed
count += clear_cache_by_pattern("specs_map") # Map page payload might have changed
count += clear_cache_by_pattern("filter:") # Filters might be affected
count += clear_cache_by_pattern("stats") # Stats might have changed
count += clear_cache_by_pattern("sitemap") # Sitemap includes spec URLs
Expand Down
48 changes: 47 additions & 1 deletion api/routers/specs.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from api.cache import cache_key, get_or_set_cache
from api.dependencies import require_db
from api.exceptions import raise_not_found
from api.schemas import ImplementationResponse, SpecDetailResponse, SpecListItem
from api.schemas import ImplementationResponse, SpecDetailResponse, SpecListItem, SpecMapItem
from core.config import settings
from core.database import ImplRepository, SpecRepository
from core.database.connection import get_db_context
Expand All @@ -28,6 +28,33 @@ async def _build_specs_list(db: AsyncSession) -> list[SpecListItem]:
]


async def _build_specs_map(db: AsyncSession) -> list[SpecMapItem]:
"""One row per spec with its best-rated impl image + spec/impl tag bag for the /map page.

Best-impl tiebreak: highest quality_score, then lexicographic library_id for determinism.
Specs without any implementations are skipped (mirrors _build_specs_list).
"""
repo = SpecRepository(db)
specs = await repo.get_all()
items: list[SpecMapItem] = []
for spec in specs:
if not spec.impls:
continue
best = max(spec.impls, key=lambda i: ((i.quality_score or 0.0), i.library_id))
items.append(
SpecMapItem(
id=spec.id,
title=spec.title,
preview_url_light=best.preview_url_light,
preview_url_dark=best.preview_url_dark,
quality_score=best.quality_score,
Comment on lines +44 to +57
tags=spec.tags,
impl_tags=best.impl_tags,
)
Comment on lines +41 to +60
)
return items


async def _build_spec_detail(db: AsyncSession, spec_id: str) -> SpecDetailResponse:
repo = SpecRepository(db)
spec = await repo.get_by_id(spec_id)
Expand Down Expand Up @@ -125,6 +152,25 @@ async def _refresh() -> list[SpecListItem]:
)


@router.get("/specs/map", response_model=list[SpecMapItem])
async def get_specs_map(db: AsyncSession = Depends(require_db)):
"""Get one row per spec (best-impl image + tag bag) for the /map clustering page.

NOTE: must stay declared before /specs/{spec_id} so the path-parameter route doesn't capture "map".
"""

async def _fetch() -> list[SpecMapItem]:
return await _build_specs_map(db)

async def _refresh() -> list[SpecMapItem]:
async with get_db_context() as fresh_db:
return await _build_specs_map(fresh_db)

return await get_or_set_cache(
cache_key("specs_map"), _fetch, refresh_after=settings.cache_refresh_after, refresh_factory=_refresh
)


@router.get("/specs/{spec_id}", response_model=SpecDetailResponse)
async def get_spec(spec_id: str, db: AsyncSession = Depends(require_db)):
"""Get detailed spec information including all implementations."""
Expand Down
12 changes: 12 additions & 0 deletions api/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,18 @@ class SpecListItem(BaseModel):
library_count: int = 0


class SpecMapItem(BaseModel):
"""One row per spec for the /map page: best-impl preview + full tag bag for client-side similarity clustering."""

id: str
title: str
preview_url_light: str | None = None
preview_url_dark: str | None = None
quality_score: float | None = None
tags: dict[str, Any] | None = None
impl_tags: dict[str, Any] | None = None


class ImageResponse(BaseModel):
"""Image/plot response for grid display."""

Expand Down
2 changes: 2 additions & 0 deletions app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,11 @@
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^9.0.0",
"@mui/material": "^9.0.0",
"force-graph": "^1.51.4",
"fuse.js": "^7.3.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-force-graph-2d": "^1.29.1",
"react-helmet-async": "^3.0.0",
"react-router-dom": "^7.14.2",
"react-syntax-highlighter": "^16.1.1",
Expand Down
1 change: 1 addition & 0 deletions app/src/components/NavBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const DEBUG_CLICK_WINDOW_MS = 800;
const NAV_LINKS: { label: string; to: string; short?: string }[] = [
{ label: 'specs', to: '/specs' },
{ label: 'plots', to: '/plots' },
{ label: 'map', to: '/map' },
{ label: 'libraries', to: '/libraries', short: 'libs' },
{ label: 'stats', to: '/stats' },
{ label: 'palette', to: '/palette', short: 'pal' },
Expand Down
178 changes: 178 additions & 0 deletions app/src/pages/MapPage.helpers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import { describe, it, expect } from 'vitest';

import {
flattenTags,
computeIDF,
weightedJaccard,
buildKNNLinks,
selectMapThumbUrl,
type SpecMapItem,
} from './MapPage.helpers';


function spec(id: string, tags: SpecMapItem['tags'], implTags: SpecMapItem['impl_tags'] = null): SpecMapItem {
return {
id,
title: id,
preview_url_light: `https://example.com/${id}-light.png`,
preview_url_dark: `https://example.com/${id}-dark.png`,
quality_score: 90,
tags,
impl_tags: implTags,
};
}


describe('flattenTags', () => {
it('prefixes values with their category', () => {
const s = spec('a', { plot_type: ['scatter'], features: ['basic', '2d'] });
expect(flattenTags(s).sort()).toEqual(['features:2d', 'features:basic', 'plot_type:scatter']);
});

it('merges spec.tags with impl_tags by default', () => {
const s = spec('a', { plot_type: ['scatter'] }, { dependencies: ['scipy'] });
expect(flattenTags(s).sort()).toEqual(['dependencies:scipy', 'plot_type:scatter']);
});

it('skips impl_tags when includeImpl=false', () => {
const s = spec('a', { plot_type: ['scatter'] }, { dependencies: ['scipy'] });
expect(flattenTags(s, false)).toEqual(['plot_type:scatter']);
});

it('handles missing dicts and empty arrays', () => {
expect(flattenTags(spec('a', null, null))).toEqual([]);
expect(flattenTags(spec('a', { plot_type: [] }, null))).toEqual([]);
});

it('deduplicates identical category:value pairs', () => {
const s = spec('a', { plot_type: ['scatter', 'scatter'] }, { plot_type: ['scatter'] });
expect(flattenTags(s)).toEqual(['plot_type:scatter']);
});
});


describe('computeIDF', () => {
it('assigns log(N / df) to every tag', () => {
const specs = [
spec('a', { plot_type: ['scatter'] }),
spec('b', { plot_type: ['scatter'] }),
spec('c', { plot_type: ['line'] }),
];
const idf = computeIDF(specs);
expect(idf.get('plot_type:scatter')).toBeCloseTo(Math.log(3 / 2));
expect(idf.get('plot_type:line')).toBeCloseTo(Math.log(3 / 1));
});

it('gives ubiquitous tags weight ~0', () => {
const specs = [
spec('a', { data_type: ['numeric'] }),
spec('b', { data_type: ['numeric'] }),
];
expect(computeIDF(specs).get('data_type:numeric')).toBeCloseTo(0);
});

it('survives empty input without dividing by zero', () => {
expect(computeIDF([]).size).toBe(0);
});
});


describe('weightedJaccard', () => {
const idf = new Map([
['plot_type:scatter', 1.0],
['plot_type:line', 1.0],
['features:basic', 0.5],
]);

it('returns 1 when sets are identical', () => {
expect(weightedJaccard(['plot_type:scatter'], ['plot_type:scatter'], idf)).toBeCloseTo(1);
});

it('returns 0 when sets are disjoint', () => {
expect(weightedJaccard(['plot_type:scatter'], ['plot_type:line'], idf)).toBe(0);
});

it('weights overlap by IDF (rare overlap > common overlap)', () => {
const rareIdf = new Map([['plot_type:scatter', 2], ['features:basic', 0.1]]);
const sharedRare = weightedJaccard(['plot_type:scatter'], ['plot_type:scatter', 'features:basic'], rareIdf);
const sharedCommon = weightedJaccard(['features:basic'], ['features:basic', 'plot_type:scatter'], rareIdf);
expect(sharedRare).toBeGreaterThan(sharedCommon);
});

it('returns 0 when either set is empty', () => {
expect(weightedJaccard([], ['plot_type:scatter'], idf)).toBe(0);
expect(weightedJaccard(['plot_type:scatter'], [], idf)).toBe(0);
});
});


describe('buildKNNLinks', () => {
it('keeps top-K neighbors above the similarity threshold', () => {
const specs = [
spec('scatter1', { plot_type: ['scatter'], features: ['basic'] }),
spec('scatter2', { plot_type: ['scatter'], features: ['basic'] }),
spec('line1', { plot_type: ['line'], features: ['basic'] }),
spec('bar1', { plot_type: ['bar'] }),
];
const idf = computeIDF(specs);
const links = buildKNNLinks(specs, idf, 2, 0.0);
// scatter1 ↔ scatter2 should be linked (most similar pair)
const ids = links.map(l => `${l.source}-${l.target}`).sort();
expect(ids).toContain('scatter1-scatter2');
});

it('produces undirected links (no A→B and B→A duplicate)', () => {
// Need a 3-spec corpus so IDF gives non-zero weight to scatter (otherwise
// a universal tag has weight 0 and no link is emitted — correct behavior).
const specs = [
spec('a', { plot_type: ['scatter'] }),
spec('b', { plot_type: ['scatter'] }),
spec('c', { plot_type: ['line'] }),
];
const links = buildKNNLinks(specs, computeIDF(specs), 5, 0.0);
const keys = links.map(l => `${l.source}|${l.target}`);
// a-b should appear exactly once, not twice
expect(keys.filter(k => k === 'a|b' || k === 'b|a').length).toBe(1);
});

it('drops links below minSim', () => {
const specs = [
spec('a', { plot_type: ['scatter'] }),
spec('b', { plot_type: ['line'] }),
];
const links = buildKNNLinks(specs, computeIDF(specs), 5, 0.5);
expect(links).toHaveLength(0);
});

it('every link weight is in (0, 1]', () => {
const specs = [
spec('a', { plot_type: ['scatter'], features: ['basic'] }),
spec('b', { plot_type: ['scatter'], features: ['regression'] }),
spec('c', { plot_type: ['line'], features: ['basic'] }),
];
const links = buildKNNLinks(specs, computeIDF(specs), 3, 0.0);
for (const l of links) {
expect(l.weight).toBeGreaterThan(0);
expect(l.weight).toBeLessThanOrEqual(1);
}
});
});


describe('selectMapThumbUrl', () => {
it('returns dark URL in dark mode, light in light mode', () => {
const s = spec('a', null);
expect(selectMapThumbUrl(s, true)).toBe('https://example.com/a-dark.png');
expect(selectMapThumbUrl(s, false)).toBe('https://example.com/a-light.png');
});

it('falls back to the other theme when the preferred URL is missing', () => {
const s: SpecMapItem = { ...spec('a', null), preview_url_dark: null };
expect(selectMapThumbUrl(s, true)).toBe('https://example.com/a-light.png');
});

it('returns null when no preview URLs at all', () => {
const s: SpecMapItem = { ...spec('a', null), preview_url_light: null, preview_url_dark: null };
expect(selectMapThumbUrl(s, false)).toBeNull();
});
});
Loading
Loading