diff --git a/api/cache.py b/api/cache.py index ccad8423c5..c7a67fa9c9 100644 --- a/api/cache.py +++ b/api/cache.py @@ -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 diff --git a/api/routers/specs.py b/api/routers/specs.py index a741e9b691..c3c868e006 100644 --- a/api/routers/specs.py +++ b/api/routers/specs.py @@ -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 @@ -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, + tags=spec.tags, + impl_tags=best.impl_tags, + ) + ) + return items + + async def _build_spec_detail(db: AsyncSession, spec_id: str) -> SpecDetailResponse: repo = SpecRepository(db) spec = await repo.get_by_id(spec_id) @@ -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.""" diff --git a/api/schemas.py b/api/schemas.py index d6c2d582cc..115fd85a6d 100644 --- a/api/schemas.py +++ b/api/schemas.py @@ -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.""" diff --git a/app/package.json b/app/package.json index c53784bd80..18958c1fd7 100644 --- a/app/package.json +++ b/app/package.json @@ -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", diff --git a/app/src/components/NavBar.tsx b/app/src/components/NavBar.tsx index 9e9458fe04..df0df25a8e 100644 --- a/app/src/components/NavBar.tsx +++ b/app/src/components/NavBar.tsx @@ -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' }, diff --git a/app/src/pages/MapPage.helpers.test.ts b/app/src/pages/MapPage.helpers.test.ts new file mode 100644 index 0000000000..f0bc999ff3 --- /dev/null +++ b/app/src/pages/MapPage.helpers.test.ts @@ -0,0 +1,349 @@ +import { describe, it, expect } from 'vitest'; + +import { + flattenTags, + computeIDF, + weightedJaccard, + buildKNNLinks, + selectMapThumbUrl, + buildVariantUrl, + pickTier, + pickBestLoadedTier, + fitToBox, + primaryPlotType, + topPlotTypes, + 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')).toBe(0); + }); + + it('survives empty input without dividing by zero', () => { + expect(computeIDF([]).size).toBe(0); + }); + + it('zeroes out tags above the maxDfRatio cutoff (default 0.67)', () => { + // 4 specs, "dependencies:selenium" appears in 3 (75%) → above default 0.67 cutoff + const specs = [ + spec('a', { plot_type: ['scatter'] }, { dependencies: ['selenium'] }), + spec('b', { plot_type: ['scatter'] }, { dependencies: ['selenium'] }), + spec('c', { plot_type: ['line'] }, { dependencies: ['selenium'] }), + spec('d', { plot_type: ['bar'] }, { dependencies: ['matplotlib'] }), + ]; + const idf = computeIDF(specs); + expect(idf.get('dependencies:selenium')).toBe(0); + // The rare one stays meaningful + expect(idf.get('dependencies:matplotlib')).toBeGreaterThan(0); + }); + + it('honors a custom maxDfRatio', () => { + const specs = [ + spec('a', { features: ['basic'] }), + spec('b', { features: ['basic'] }), + spec('c', { features: ['rare'] }), + ]; + // basic in 2/3 = 67 % — below default 0.67 cutoff, kept + expect(computeIDF(specs).get('features:basic')).toBeGreaterThan(0); + // tighten cutoff to 0.5 → basic now noise + expect(computeIDF(specs, 0.5).get('features:basic')).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 the dark URL in dark mode and light URL 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(); + }); +}); + + +describe('buildVariantUrl', () => { + it('rewrites .png to _{tier}.webp', () => { + expect(buildVariantUrl('https://example.com/plot.png', 400)).toBe('https://example.com/plot_400.webp'); + expect(buildVariantUrl('https://example.com/plot-light.png', 800)).toBe('https://example.com/plot-light_800.webp'); + expect(buildVariantUrl('https://example.com/plot-dark.png', 1200)).toBe('https://example.com/plot-dark_1200.webp'); + }); + + it('passes through URLs that do not end in .png', () => { + expect(buildVariantUrl('https://example.com/plot.svg', 400)).toBe('https://example.com/plot.svg'); + }); +}); + + +describe('pickTier', () => { + it('returns 400 when device pixel size fits in 400 with headroom', () => { + expect(pickTier(100)).toBe(400); + expect(pickTier(300)).toBe(400); + }); + + it('returns 800 when 400 would require upscaling', () => { + expect(pickTier(500)).toBe(800); + expect(pickTier(600)).toBe(800); + }); + + it('returns 1200 for very large device sizes', () => { + expect(pickTier(1000)).toBe(1200); + expect(pickTier(2000)).toBe(1200); + }); +}); + + +describe('primaryPlotType', () => { + it('returns the first plot_type entry', () => { + expect(primaryPlotType(spec('a', { plot_type: ['scatter', 'point'] }))).toBe('scatter'); + }); + + it('returns "other" when plot_type is missing', () => { + expect(primaryPlotType(spec('a', null))).toBe('other'); + expect(primaryPlotType(spec('a', { domain: ['statistics'] }))).toBe('other'); + }); +}); + + +describe('topPlotTypes', () => { + it('returns the N most frequent primary types in descending order', () => { + const specs = [ + spec('s1', { plot_type: ['line'] }), + spec('s2', { plot_type: ['line'] }), + spec('s3', { plot_type: ['line'] }), + spec('s4', { plot_type: ['scatter'] }), + spec('s5', { plot_type: ['scatter'] }), + spec('s6', { plot_type: ['bar'] }), + ]; + expect(topPlotTypes(specs, 3)).toEqual(['line', 'scatter', 'bar']); + }); + + it('truncates to the requested length', () => { + const specs = [ + spec('s1', { plot_type: ['a'] }), + spec('s2', { plot_type: ['b'] }), + spec('s3', { plot_type: ['c'] }), + ]; + expect(topPlotTypes(specs, 2)).toHaveLength(2); + }); + + it('breaks ties alphabetically for determinism', () => { + const specs = [ + spec('s1', { plot_type: ['zebra'] }), + spec('s2', { plot_type: ['apple'] }), + spec('s3', { plot_type: ['mango'] }), + ]; + // All have count=1, alphabetic order: apple, mango, zebra + expect(topPlotTypes(specs, 3)).toEqual(['apple', 'mango', 'zebra']); + }); + + it('excludes the synthetic "other" bucket so it does not waste a color slot', () => { + const specs = [ + spec('s1', null), // no plot_type → primaryPlotType returns 'other' + spec('s2', { plot_type: ['line'] }), + ]; + expect(topPlotTypes(specs, 5)).toEqual(['line']); + }); +}); + + +describe('fitToBox', () => { + it('returns a square for 1:1 aspect ratio', () => { + expect(fitToBox(22, 1)).toEqual({ w: 22, h: 22 }); + }); + + it('keeps width = box and shrinks height for 16:9', () => { + const r = fitToBox(22, 16 / 9); + expect(r.w).toBe(22); + expect(r.h).toBeCloseTo(22 * 9 / 16); + }); + + it('keeps height = box and shrinks width for portrait (9:16)', () => { + const r = fitToBox(22, 9 / 16); + expect(r.h).toBe(22); + expect(r.w).toBeCloseTo(22 * 9 / 16); + }); + + it('falls back to a square for invalid aspect ratios', () => { + expect(fitToBox(22, 0)).toEqual({ w: 22, h: 22 }); + expect(fitToBox(22, NaN)).toEqual({ w: 22, h: 22 }); + expect(fitToBox(22, Infinity)).toEqual({ w: 22, h: 22 }); + }); +}); + + +describe('pickBestLoadedTier', () => { + function img(): HTMLImageElement { + return document.createElement('img'); + } + + it('returns the desired tier when loaded', () => { + const a = img(); + const imgs = new Map([[400 as const, a]]); + expect(pickBestLoadedTier(imgs, 400)).toBe(a); + }); + + it('returns a higher-resolution variant when desired is not loaded', () => { + const a = img(); + const imgs = new Map([[800 as const, a]]); + expect(pickBestLoadedTier(imgs, 400)).toBe(a); + }); + + it('falls back to a smaller tier when nothing larger is loaded', () => { + const a = img(); + const imgs = new Map([[400 as const, a]]); + expect(pickBestLoadedTier(imgs, 800)).toBe(a); + }); + + it('returns null when nothing is loaded', () => { + expect(pickBestLoadedTier(new Map(), 400)).toBeNull(); + }); +}); diff --git a/app/src/pages/MapPage.helpers.ts b/app/src/pages/MapPage.helpers.ts new file mode 100644 index 0000000000..b8f0c1fd84 --- /dev/null +++ b/app/src/pages/MapPage.helpers.ts @@ -0,0 +1,450 @@ +/** + * Pure helpers for the /map page: tag flattening, IDF weighting, + * weighted Jaccard similarity, and sparse KNN edge construction. + * + * Kept side-effect-free so the math is exhaustively unit-testable + * in MapPage.helpers.test.ts. The page component imports these and + * feeds the result into react-force-graph-2d. + */ + +import { selectPreviewUrl } from '../utils/themedPreview'; + + +/** Backend response shape from GET /api/specs/map. Mirrors api/schemas.py::SpecMapItem. */ +export interface SpecMapItem { + id: string; + title: string; + preview_url_light: string | null; + preview_url_dark: string | null; + quality_score: number | null; + tags: Record | null; + impl_tags: Record | null; +} + +/** Resolution tiers baked by the responsive-image pipeline (responsiveImage.ts). */ +export const RESOLUTION_TIERS = [400, 800, 1200] as const; +export type ResolutionTier = (typeof RESOLUTION_TIERS)[number]; + +/** + * Node shape passed to ForceGraph2D. Holds a lazy collection of image variants + * keyed by resolution tier (400/800/1200). The page populates the 400 tier + * eagerly on load and progressively upgrades on zoom-in. + */ +export interface MapNode { + id: string; + title: string; + tags: string[]; + thumbUrl: string | null; // base theme-aware .png URL + imgs: Map; // loaded variants + pendingTiers: Set; // tiers with an in-flight fetch + // colorBucket = primary plot_type for nodes that fall into the top-N most + // frequent plot types; null otherwise. Drives the per-cluster border color + // without imposing any spatial bias on the layout. + colorBucket: string | null; +} + +/** Link shape passed to ForceGraph2D. `weight` = weighted-Jaccard sim ∈ (0, 1]. */ +export interface MapLink { + source: string; + target: string; + weight: number; +} + +/** + * Flatten a spec's nested tag dicts to a single `category:value` string set. + * Prefixing prevents collisions like `numeric` appearing in both `data_type` + * and `dataprep` and gives the IDF/Jaccard math distinct tokens to weigh. + */ +export function flattenTags(spec: SpecMapItem, includeImpl = true): string[] { + const out: string[] = []; + const push = (dict: Record | null | undefined) => { + if (!dict) return; + for (const [category, values] of Object.entries(dict)) { + if (!Array.isArray(values)) continue; + for (const v of values) { + if (typeof v === 'string' && v.length > 0) out.push(`${category}:${v}`); + } + } + }; + push(spec.tags); + if (includeImpl) push(spec.impl_tags); + return Array.from(new Set(out)); +} + +/** + * Inverse-document-frequency weights: w_t = log(N / df_t). + * Down-weights ubiquitous tags (`data_type:numeric` is in nearly every spec) + * and amplifies rare ones. Returns weight ≥ 0; tags absent from the corpus + * default to 0 when looked up. + * + * `maxDfRatio` zeroes out tags that appear in more than that fraction of the + * corpus. Plain log-IDF still gives those tags a small positive weight, which + * compounds across many shared common tags into spurious cross-cluster + * bridges — `dependencies:selenium` in ~98 % of specs, `features:basic` in + * ~50 %, etc. Setting them to exactly zero kills the noise without affecting + * tags that are merely common-but-informative. + */ +export function computeIDF(specs: SpecMapItem[], maxDfRatio = 0.67): Map { + const N = specs.length || 1; + const df = new Map(); + for (const spec of specs) { + for (const tag of flattenTags(spec)) { + df.set(tag, (df.get(tag) ?? 0) + 1); + } + } + const idf = new Map(); + for (const [tag, count] of df) { + if (count / N > maxDfRatio) { + idf.set(tag, 0); + continue; + } + idf.set(tag, Math.log(N / count)); + } + return idf; +} + +/** + * The 9 known tag categories the catalog uses. The first four come from + * specification.yaml (spec-level), the last five from impl metadata yaml. + */ +export const TAG_CATEGORIES = [ + 'plot_type', + 'features', + 'data_type', + 'domain', + 'dependencies', + 'techniques', + 'patterns', + 'dataprep', + 'styling', +] as const; + +export type TagCategory = (typeof TAG_CATEGORIES)[number]; + +/** + * Default per-category multipliers applied on top of IDF weighting in the + * Jaccard similarity calculation. Users can override these live via the + * weights panel; passing a custom `weights` map to {@link weightedJaccard} + * or {@link buildKNNLinks} replaces the defaults entirely. + * + * The defaults strongly privilege plot_type (3.0) over everything else (0). + * That produces the cleanest "scatter-galaxy / bar-galaxy / line-galaxy" + * map. Users can slide secondary categories up via the weights panel to mix + * in features/techniques/etc. for richer cross-type clustering. + */ +export const DEFAULT_CATEGORY_WEIGHT: Record = { + plot_type: 3.0, + features: 0, + techniques: 0, + patterns: 0, + dataprep: 0, + dependencies: 0, + domain: 0, + data_type: 0, + styling: 0, +}; + +function categoryOf(prefixedTag: string): string { + const idx = prefixedTag.indexOf(':'); + return idx >= 0 ? prefixedTag.slice(0, idx) : ''; +} + +function tagWeight( + tag: string, + idf: Map, + weights: Record +): number { + return (idf.get(tag) ?? 0) * (weights[categoryOf(tag)] ?? 1); +} + +/** + * Weighted Jaccard similarity over two tag sets. + * sim = Σ w_t for t∈a∩b / Σ w_t for t∈a∪b + * Per-tag weight = IDF × weights[category prefix], so the contribution of a + * shared tag depends both on its rarity in the corpus and on which category + * it belongs to. Returns 0 when either set is empty or the denominator + * collapses to zero. `weights` defaults to {@link DEFAULT_CATEGORY_WEIGHT}. + */ +export function weightedJaccard( + a: string[], + b: string[], + idf: Map, + weights: Record = DEFAULT_CATEGORY_WEIGHT +): number { + if (a.length === 0 || b.length === 0) return 0; + const setA = new Set(a); + const setB = new Set(b); + let num = 0; + let denom = 0; + const seen = new Set(); + for (const t of setA) { + seen.add(t); + const w = tagWeight(t, idf, weights); + denom += w; + if (setB.has(t)) num += w; + } + for (const t of setB) { + if (seen.has(t)) continue; + denom += tagWeight(t, idf, weights); + } + return denom > 0 ? num / denom : 0; +} + +/** + * Build a sparse KNN link list: each spec keeps its top-K most similar + * neighbors above `minSim`. Output is deduplicated (no A→B + B→A pair) and + * symmetric — the link with the higher weight wins on tie. + * + * With ~327 specs × K=5 the result is ~1.6k edges: dense enough for + * cohesive clustering, sparse enough to avoid hairball rendering. + */ +export function buildKNNLinks( + specs: SpecMapItem[], + idf: Map, + k = 5, + minSim = 0.05, + weights: Record = DEFAULT_CATEGORY_WEIGHT +): MapLink[] { + const tagsByIdx = specs.map(s => flattenTags(s)); + const linkSet = new Map(); + for (let i = 0; i < specs.length; i++) { + const sims: { j: number; sim: number }[] = []; + for (let j = 0; j < specs.length; j++) { + if (i === j) continue; + const sim = weightedJaccard(tagsByIdx[i], tagsByIdx[j], idf, weights); + // sim > 0 drops zero-weight links (no shared tags or all-zero IDF) — pure visual noise. + if (sim > 0 && sim >= minSim) sims.push({ j, sim }); + } + sims.sort((x, y) => y.sim - x.sim); + for (const { j, sim } of sims.slice(0, k)) { + const a = specs[i].id; + const b = specs[j].id; + const key = a < b ? `${a}|${b}` : `${b}|${a}`; + const existing = linkSet.get(key); + if (!existing || sim > existing.weight) { + linkSet.set(key, { source: a < b ? a : b, target: a < b ? b : a, weight: sim }); + } + } + } + return Array.from(linkSet.values()); +} + +/** + * Pick the theme-aware base preview URL (the original `.png`). Variant + * selection happens at draw time via {@link buildVariantUrl} + {@link pickTier} + * so we only fetch higher-resolution thumbnails for nodes the user actually + * zooms into. + */ +export function selectMapThumbUrl(spec: SpecMapItem, isDark: boolean): string | null { + return selectPreviewUrl(spec, isDark); +} + +/** + * Derive the URL of a specific resolution variant from the base `.png` URL. + * `.../plot-light.png` + 800 → `.../plot-light_800.webp`. Returns the original + * URL unchanged if it doesn't end in `.png` (no variants available). + */ +export function buildVariantUrl(baseUrl: string, tier: ResolutionTier): string { + if (!baseUrl.endsWith('.png')) return baseUrl; + return baseUrl.replace(/\.png$/, `_${tier}.webp`); +} + +/** + * Pick the smallest pipeline tier whose source resolution comfortably covers + * the requested device-pixel size. Source needs to be ≥ device pixels for + * crisp rendering — we add a small headroom factor so a tiny zoom-in nudge + * doesn't immediately re-fetch the next tier. + */ +export function pickTier(devicePxSize: number): ResolutionTier { + const HEADROOM = 1.25; + const target = devicePxSize * HEADROOM; + if (target <= 400) return 400; + if (target <= 800) return 800; + return 1200; +} + +/** + * Return the highest-resolution tier that's already loaded and at least as + * big as `desired`. Falls back to a smaller tier if nothing larger is loaded + * yet (better than blank during the lazy upgrade). + */ +export function pickBestLoadedTier( + imgs: Map, + desired: ResolutionTier +): HTMLImageElement | null { + for (const t of RESOLUTION_TIERS) { + if (t >= desired && imgs.has(t)) return imgs.get(t)!; + } + for (let i = RESOLUTION_TIERS.length - 1; i >= 0; i--) { + const t = RESOLUTION_TIERS[i]; + if (imgs.has(t)) return imgs.get(t)!; + } + return null; +} + +/** Tag categories that come from specification.yaml (vs. impl-level metadata). */ +export const SPEC_LEVEL_CATEGORIES: readonly TagCategory[] = [ + 'plot_type', + 'features', + 'data_type', + 'domain', +] as const; + +/** + * Pick a spec's primary value for a given tag category — the first entry of + * the relevant list (spec.tags[category] for spec-level categories, + * spec.impl_tags[category] for impl-level). Falls back to "other" when the + * spec has no tag in that category at all. + */ +export function primaryCategoryValue(spec: SpecMapItem, category: TagCategory): string { + const dict = (SPEC_LEVEL_CATEGORIES as readonly string[]).includes(category) + ? spec.tags + : spec.impl_tags; + return dict?.[category]?.[0] ?? 'other'; +} + +/** Convenience wrapper: a spec's primary plot_type. */ +export function primaryPlotType(spec: SpecMapItem): string { + return primaryCategoryValue(spec, 'plot_type'); +} + +/** + * Count specs by their primary value for a given tag category (excluding + * the synthetic `other` bucket). Used by the legend to display per-cluster + * member counts. + */ +export function categoryValueCounts( + specs: SpecMapItem[], + category: TagCategory +): Map { + const counts = new Map(); + for (const s of specs) { + const v = primaryCategoryValue(s, category); + if (v === 'other') continue; + counts.set(v, (counts.get(v) ?? 0) + 1); + } + return counts; +} + +/** Convenience wrapper: per-plot_type spec counts. */ +export function plotTypeCounts(specs: SpecMapItem[]): Map { + return categoryValueCounts(specs, 'plot_type'); +} + +/** + * Return the top-N most frequent primary values in the given category, sorted + * by count descending (alphabetic name as tiebreaker for determinism). Used + * to decide which buckets earn a distinct color border in the map. + * + * Excludes the synthetic `other` bucket (specs missing the category entirely) + * so it never wastes a color slot. + */ +export function topCategoryValues( + specs: SpecMapItem[], + category: TagCategory, + n: number +): string[] { + return Array.from(categoryValueCounts(specs, category).entries()) + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) + .slice(0, n) + .map(([v]) => v); +} + +/** Convenience wrapper: top-N plot_types by spec count. */ +export function topPlotTypes(specs: SpecMapItem[], n: number): string[] { + return topCategoryValues(specs, 'plot_type', n); +} + +/** + * Read a node's intrinsic aspect ratio (width/height) from any already-loaded + * thumbnail variant. Defaults to 1 when nothing is loaded yet (and the page + * draws a square fallback rect anyway). Most plots are 16:9 (figsize=(16,9)), + * so the typical return value is ~1.78. + */ +export function nodeAspectRatio(node: MapNode): number { + for (const t of RESOLUTION_TIERS) { + const img = node.imgs.get(t); + if (img && img.naturalWidth > 0 && img.naturalHeight > 0) { + return img.naturalWidth / img.naturalHeight; + } + } + return 1; +} + +/** + * Given a target box size and an aspect ratio, return the (width, height) that + * fits inside the box without distortion (longer side = boxSize). Used for both + * canvas drawing and hit-area painting so they always agree. + */ +export function fitToBox(boxSize: number, aspectRatio: number): { w: number; h: number } { + if (!isFinite(aspectRatio) || aspectRatio <= 0) return { w: boxSize, h: boxSize }; + if (aspectRatio >= 1) return { w: boxSize, h: boxSize / aspectRatio }; + return { w: boxSize * aspectRatio, h: boxSize }; +} + +/** + * Lazily fetch the requested tier for a node and call `onLoad` when it lands. + * Idempotent — safe to call repeatedly from `nodeCanvasObject` on every paint. + * force-graph only invokes that callback for visible nodes, so off-screen + * specs never trigger a higher-tier fetch. + */ +export function ensureNodeTier( + node: MapNode, + tier: ResolutionTier, + onLoad: () => void +): void { + if (!node.thumbUrl) return; + if (node.imgs.has(tier) || node.pendingTiers.has(tier)) return; + node.pendingTiers.add(tier); + const img = document.createElement('img'); + img.onload = () => { + node.imgs.set(tier, img); + node.pendingTiers.delete(tier); + onLoad(); + }; + img.onerror = () => { + node.pendingTiers.delete(tier); + }; + img.src = buildVariantUrl(node.thumbUrl, tier); +} + +/** + * Eager-preload every node's thumbnail at the smallest tier (400 px wide ≈ 6 KB + * webp). Resolves once all images either loaded or errored — failures are + * swallowed (the node renders as a plain dot in the fallback path). + * + * `onLoad` fires per-image so the page can call fgRef.refresh() to re-paint + * without re-running the simulation, producing the "thumbnails pop in + * organically" UX rather than a blocking wait. Higher-resolution tiers are + * lazy-loaded on demand by {@link ensureNodeTier} from `nodeCanvasObject` + * when the user zooms in. + */ +export async function preloadImages( + items: { id: string; thumbUrl: string | null }[], + onLoad?: (id: string, tier: ResolutionTier, img: HTMLImageElement) => void +): Promise> { + const out = new Map(); + const tier: ResolutionTier = 400; + await Promise.all( + items.map(({ id, thumbUrl }) => { + if (!thumbUrl) return Promise.resolve(); + return new Promise(resolve => { + // document.createElement is preferred over `new Image()` here only because + // some lint configs don't surface browser globals on plain .ts files. + const img = document.createElement('img'); + // Intentionally NOT setting img.crossOrigin: the GCS bucket has no CORS + // headers, and adding crossOrigin='anonymous' triggers a preflight that + // fails. We only ever drawImage() these onto the canvas (the canvas + // becomes "tainted", which is fine — we never read it back). + img.onload = () => { + out.set(id, img); + onLoad?.(id, tier, img); + resolve(); + }; + img.onerror = () => resolve(); + img.src = buildVariantUrl(thumbUrl, tier); + }); + }) + ); + return out; +} diff --git a/app/src/pages/MapPage.test.tsx b/app/src/pages/MapPage.test.tsx new file mode 100644 index 0000000000..38f978be79 --- /dev/null +++ b/app/src/pages/MapPage.test.tsx @@ -0,0 +1,258 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { render, screen, waitFor } from '../test-utils'; +import { MapPage } from './MapPage'; + + +vi.mock('react-helmet-async', () => ({ + Helmet: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +const mockNavigate = vi.fn(); +const mockTrackEvent = vi.fn(); + +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { ...actual, useNavigate: () => mockNavigate }; +}); + +vi.mock('../hooks', () => ({ + useAnalytics: () => ({ + trackPageview: vi.fn(), + trackEvent: mockTrackEvent, + }), +})); + +vi.mock('../hooks/useLayoutContext', () => ({ + useTheme: () => ({ isDark: false }), +})); + +// Capture the props passed to ForceGraph2D so individual callbacks can be exercised +// from outside React. A live canvas can't run in jsdom, but the callbacks (drawNode, +// onNodeClick, linkColor, …) are pure-ish JS and worth testing in isolation. +type FgProps = Record; +const lastFgProps: { current: FgProps | null } = { current: null }; + +vi.mock('react-force-graph-2d', () => ({ + default: (props: FgProps) => { + lastFgProps.current = props; + const data = props.graphData as { nodes: unknown[]; links: unknown[] }; + return ( +
+ ); + }, +})); + + +function makeCtxStub() { + // Minimal mock of CanvasRenderingContext2D — just enough surface for drawNode/paintHitbox. + return { + save: vi.fn(), + restore: vi.fn(), + drawImage: vi.fn(), + fillRect: vi.fn(), + strokeRect: vi.fn(), + fillStyle: '', + strokeStyle: '', + lineWidth: 0, + globalAlpha: 1, + }; +} + + +const mockSpecs = [ + { + id: 'scatter-basic', + title: 'Basic Scatter Plot', + preview_url_light: 'https://example.com/scatter-basic-light.png', + preview_url_dark: 'https://example.com/scatter-basic-dark.png', + quality_score: 90, + tags: { plot_type: ['scatter'], data_type: ['numeric'], features: ['basic'] }, + impl_tags: { dependencies: ['scipy'] }, + }, + { + id: 'scatter-color-mapped', + title: 'Scatter with Color Mapping', + preview_url_light: 'https://example.com/scatter-color-light.png', + preview_url_dark: 'https://example.com/scatter-color-dark.png', + quality_score: 88, + tags: { plot_type: ['scatter'], data_type: ['numeric'], features: ['color-mapped'] }, + impl_tags: { dependencies: ['scipy'] }, + }, + { + id: 'line-basic', + title: 'Basic Line Chart', + preview_url_light: 'https://example.com/line-basic-light.png', + preview_url_dark: 'https://example.com/line-basic-dark.png', + quality_score: 92, + tags: { plot_type: ['line'], data_type: ['numeric'], features: ['basic'] }, + impl_tags: null, + }, +]; + + +function mockFetchSuccess() { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockSpecs), + }), + ); +} + + +// jsdom doesn't ship ResizeObserver; stub it so the page's useEffect doesn't crash +// AND fire the callback once with non-zero dimensions so the `size.w > 0` gate that +// guards mounting is satisfied. +type ResizeCb = (entries: { contentRect: { width: number; height: number } }[]) => void; +class MockResizeObserver { + cb: ResizeCb; + constructor(cb: ResizeCb) { + this.cb = cb; + } + observe(_target: Element) { + setTimeout(() => { + this.cb([{ contentRect: { width: 800, height: 600 } }]); + }, 0); + } + unobserve() {} + disconnect() {} +} + + +describe('MapPage', () => { + beforeEach(() => { + vi.restoreAllMocks(); + mockNavigate.mockReset(); + mockTrackEvent.mockReset(); + lastFgProps.current = null; + vi.stubGlobal('ResizeObserver', MockResizeObserver); + }); + + it('renders the spec/edge count meta after fetch', async () => { + mockFetchSuccess(); + render(); + await waitFor(() => { + expect(screen.getByText(/3 specs/)).toBeInTheDocument(); + }); + }); + + it('renders an a11y fallback list of every spec as anchor links', async () => { + mockFetchSuccess(); + render(); + await waitFor(() => { + expect(screen.getByRole('link', { name: 'Basic Scatter Plot' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Basic Line Chart' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Scatter with Color Mapping' })).toBeInTheDocument(); + }); + }); + + it('shows an error message when the fetch fails', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 500 })); + render(); + await waitFor(() => { + expect(screen.getByText(/Failed to load map/)).toBeInTheDocument(); + }); + }); + + it('passes graph data with the expected node count to ForceGraph2D', async () => { + mockFetchSuccess(); + render(); + await waitFor(() => { + expect(screen.getByTestId('force-graph-2d')).toBeInTheDocument(); + }); + expect(screen.getByTestId('force-graph-2d').getAttribute('data-node-count')).toBe('3'); + }); + + it('navigates to the spec page and emits an analytics event on node click', async () => { + mockFetchSuccess(); + render(); + await waitFor(() => expect(lastFgProps.current).not.toBeNull()); + + const onNodeClick = lastFgProps.current!.onNodeClick as (n: { id: string }) => void; + onNodeClick({ id: 'scatter-basic' }); + + expect(mockNavigate).toHaveBeenCalledWith('/scatter-basic'); + expect(mockTrackEvent).toHaveBeenCalledWith('map_node_click', { spec_id: 'scatter-basic' }); + }); + + it('drawNode paints a fallback rect when a node has no preloaded image', async () => { + mockFetchSuccess(); + render(); + await waitFor(() => expect(lastFgProps.current).not.toBeNull()); + + const drawNode = lastFgProps.current!.nodeCanvasObject as (n: unknown, c: unknown, gs?: number) => void; + const ctx = makeCtxStub(); + drawNode({ id: 'scatter-basic', x: 100, y: 100, imgs: new Map(), pendingTiers: new Set(), colorBucket: null }, ctx, 1); + + // Without an attached image, the fallback rect path runs. + expect(ctx.fillRect).toHaveBeenCalled(); + expect(ctx.strokeRect).toHaveBeenCalled(); + expect(ctx.drawImage).not.toHaveBeenCalled(); + }); + + it('drawNode paints the thumbnail when a node has a preloaded image', async () => { + mockFetchSuccess(); + render(); + await waitFor(() => expect(lastFgProps.current).not.toBeNull()); + + const drawNode = lastFgProps.current!.nodeCanvasObject as (n: unknown, c: unknown, gs?: number) => void; + const ctx = makeCtxStub(); + const fakeImg = { src: 'x' } as unknown as HTMLImageElement; + drawNode( + { id: 'scatter-basic', x: 50, y: 50, imgs: new Map([[400, fakeImg]]), pendingTiers: new Set(), colorBucket: null }, + ctx, + 1, + ); + + expect(ctx.drawImage).toHaveBeenCalledWith(fakeImg, expect.any(Number), expect.any(Number), expect.any(Number), expect.any(Number)); + expect(ctx.strokeRect).toHaveBeenCalled(); + }); + + it('paintHitbox draws a sprite-sized hit rectangle', async () => { + mockFetchSuccess(); + render(); + await waitFor(() => expect(lastFgProps.current).not.toBeNull()); + + const paintHitbox = lastFgProps.current!.nodePointerAreaPaint as (n: unknown, c: string, ctx: unknown) => void; + const ctx = makeCtxStub(); + paintHitbox({ id: 'scatter-basic', x: 80, y: 60, imgs: new Map(), pendingTiers: new Set(), colorBucket: null }, '#ff00ff', ctx); + + expect(ctx.fillStyle).toBe('#ff00ff'); + expect(ctx.fillRect).toHaveBeenCalled(); + }); + + it('linkColor returns the brand green for links touching the hovered node', async () => { + mockFetchSuccess(); + render(); + await waitFor(() => expect(lastFgProps.current).not.toBeNull()); + + // Hover a node, then ask the link-color callback for its incident link. + const onNodeHover = lastFgProps.current!.onNodeHover as (n: { id: string } | null) => void; + onNodeHover({ id: 'scatter-basic' }); + await waitFor(() => { + const linkColor = lastFgProps.current!.linkColor as (l: unknown) => string; + const colorInvolved = linkColor({ source: 'scatter-basic', target: 'line-basic', weight: 0.5 }); + const colorOther = linkColor({ source: 'line-basic', target: 'scatter-color-mapped', weight: 0.5 }); + expect(colorInvolved).toMatch(/^#/); // brand color (hex) + expect(colorInvolved).not.toBe(colorOther); + }); + }); + + it('linkWidth scales with link weight', async () => { + mockFetchSuccess(); + render(); + await waitFor(() => expect(lastFgProps.current).not.toBeNull()); + + const linkWidth = lastFgProps.current!.linkWidth as (l: unknown) => number; + const small = linkWidth({ weight: 0.1 }); + const large = linkWidth({ weight: 0.9 }); + expect(large).toBeGreaterThan(small); + expect(small).toBeGreaterThan(0); + }); +}); diff --git a/app/src/pages/MapPage.tsx b/app/src/pages/MapPage.tsx new file mode 100644 index 0000000000..bf6b9753d5 --- /dev/null +++ b/app/src/pages/MapPage.tsx @@ -0,0 +1,637 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Helmet } from 'react-helmet-async'; +import Box from '@mui/material/Box'; +import CircularProgress from '@mui/material/CircularProgress'; +import Slider from '@mui/material/Slider'; +import Typography from '@mui/material/Typography'; +import ForceGraph2D from 'react-force-graph-2d'; +import { forceCollide } from 'd3-force-3d'; + +import { API_URL } from '../constants'; +import { useAnalytics } from '../hooks'; +import { useTheme } from '../hooks/useLayoutContext'; +import { specPath } from '../utils/paths'; +import { colors, fontSize, typography } from '../theme'; +import { + buildKNNLinks, + categoryValueCounts, + computeIDF, + DEFAULT_CATEGORY_WEIGHT, + ensureNodeTier, + fitToBox, + flattenTags, + nodeAspectRatio, + pickBestLoadedTier, + pickTier, + preloadImages, + primaryCategoryValue, + selectMapThumbUrl, + TAG_CATEGORIES, + topCategoryValues, + type MapLink, + type MapNode, + type ResolutionTier, + type SpecMapItem, + type TagCategory, +} from './MapPage.helpers'; + + +const NODE_SIZE = 60; // graph-space size of a node — large enough to read the thumbnail without hovering +const HOVER_PREVIEW_SIZE = NODE_SIZE * 5; // graph-space size of the hover preview overlay +const MIN_ZOOM = 0.5; // floor for zoomToFit so outliers can't shrink the dense cluster into pixels +const COOLDOWN_TICKS = 400; // longer settling for cleaner final positions +const KNN_K = 5; // edges per node in the sparse KNN graph +// Threshold tuned for the plot_type-dominant default. Bumped up from 0.05 +// because once secondary categories (features, techniques, …) have non-zero +// weight, common tags like `features:basic` create weak cross-cluster +// bridges in the 0.05–0.12 range that collapse the graph into one blob. At +// 0.15, those bridges drop out and clusters stay distinct. +const KNN_MIN_SIM = 0.15; +// Forces: tuned so KNN edges + collision shape the layout while many-body +// repulsion stays GENTLE — collision already enforces minimum spacing, and +// strong repulsion would just blow the graph wide enough that zoomToFit +// zooms out too far for thumbnails to be readable. Goal: graph extent stays +// small enough that zoomToFit displays nodes at a generous CSS-pixel size. +const REPULSION = -50; // forceManyBody strength +const LINK_DISTANCE_MIN = NODE_SIZE * 1.1; // shortest link (highest sim) +const LINK_DISTANCE_MAX = NODE_SIZE * 3.5; // longest link (lowest sim above threshold) +const LINK_STRENGTH_CAP = 0.4; // max pull from a single link +const COLLIDE_PADDING = 3; // px padding on top of the bounding-box radius + +// visually-hidden style — keeps the spec list readable for screen readers +// even though the canvas is the primary interface. +const visuallyHiddenSx = { + position: 'absolute' as const, + width: '1px', + height: '1px', + padding: 0, + margin: '-1px', + overflow: 'hidden', + clip: 'rect(0, 0, 0, 0)', + whiteSpace: 'nowrap' as const, + border: 0, +}; + +// Top-N most frequent plot_types each get a distinct Okabe-Ito border color +// so the catalog's biggest categories (line, scatter, bar, …) stand out at +// a glance. Specs that don't fall into the top-N keep a neutral border. +// The palette has 7 categorical colors + an adaptive neutral as the 8th — +// here we use the 7 categorical ones; everything else stays uncolored. +const CLUSTER_COLORS = [ + '#009E73', // brand green + '#D55E00', // vermillion + '#0072B2', // blue + '#CC79A7', // reddish purple + '#E69F00', // orange + '#56B4E9', // sky blue + '#F0E442', // yellow +] as const; + +function colorFor(bucket: string | null, topTypes: string[]): string | null { + if (!bucket) return null; + const idx = topTypes.indexOf(bucket); + if (idx < 0) return null; + return CLUSTER_COLORS[idx % CLUSTER_COLORS.length]; +} + +// Hairline border around a thumbnail node, theme-aware. Top-N plot types +// paint with a brand color; the rest fall back to a neutral hairline. +function strokeFor(isDark: boolean, isHover: boolean, color: string | null): string { + if (isHover) return colors.primary; + if (color) return color; + return isDark ? 'rgba(240,239,232,0.18)' : 'rgba(26,26,23,0.18)'; +} + + +export function MapPage() { + const { trackPageview, trackEvent } = useAnalytics(); + const { isDark } = useTheme(); + const navigate = useNavigate(); + + // refs + // ForceGraph2D's TypeScript surface for the imperative ref is non-trivial; the + // generated types live in dist and aren't worth re-typing here. Treat as opaque. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const fgRef = useRef(null); + const containerRef = useRef(null); + + // data state + const [specs, setSpecs] = useState(null); + const [error, setError] = useState(null); + const [size, setSize] = useState<{ w: number; h: number }>({ w: 0, h: 0 }); + const [hoverId, setHoverId] = useState(null); + // hoverType = a plot_type the user is hovering in the legend; everything + // not in that cluster dims so the cluster shape is obvious. + const [hoverType, setHoverType] = useState(null); + // Per-category weight overrides for the similarity calculation. Bound to + // the weights panel sliders. Live-updates KNN edges + simulation on change. + const [weights, setWeights] = useState>(DEFAULT_CATEGORY_WEIGHT); + const [weightsOpen, setWeightsOpen] = useState(false); + + // 1. fetch + page view + useEffect(() => { + trackPageview('/map'); + }, [trackPageview]); + + useEffect(() => { + const ctrl = new AbortController(); + fetch(`${API_URL}/specs/map`, { signal: ctrl.signal }) + .then(r => { + if (!r.ok) throw new Error(`HTTP ${r.status}`); + return r.json() as Promise; + }) + .then(setSpecs) + .catch(err => { + if (err.name !== 'AbortError') setError(err.message ?? 'Failed to load map data'); + }); + return () => ctrl.abort(); + }, []); + + // 2. resize observer + useEffect(() => { + const el = containerRef.current; + if (!el) return; + const obs = new ResizeObserver(entries => { + const r = entries[0]?.contentRect; + if (r) setSize({ w: r.width, h: r.height }); + }); + obs.observe(el); + return () => obs.disconnect(); + }, []); + + // The category that drives the legend + node border colors: whichever + // currently has the highest weight (plot_type wins on ties because it's + // the first entry of TAG_CATEGORIES and we use strictly-greater compare). + // Falls back to plot_type when all weights are 0. + const activeCategory: TagCategory = useMemo(() => { + let maxWeight = -Infinity; + let active: TagCategory = 'plot_type'; + for (const c of TAG_CATEGORIES) { + if (weights[c] > maxWeight) { + maxWeight = weights[c]; + active = c; + } + } + return maxWeight > 0 ? active : 'plot_type'; + }, [weights]); + + // 3. derive graph data from specs/theme (pure — no setState in effect) + const graphData = useMemo<{ + nodes: MapNode[]; + links: MapLink[]; + topTypes: string[]; + typeCounts: Map; + }>(() => { + if (!specs) return { nodes: [], links: [], topTypes: [], typeCounts: new Map() }; + const idf = computeIDF(specs); + const topTypes = topCategoryValues(specs, activeCategory, CLUSTER_COLORS.length); + const typeCounts = categoryValueCounts(specs, activeCategory); + const nodes: MapNode[] = specs.map(s => { + const v = primaryCategoryValue(s, activeCategory); + return { + id: s.id, + title: s.title, + tags: flattenTags(s), + colorBucket: topTypes.includes(v) ? v : null, + thumbUrl: selectMapThumbUrl(s, isDark), + imgs: new Map(), + pendingTiers: new Set(), + }; + }); + const links = buildKNNLinks(specs, idf, KNN_K, KNN_MIN_SIM, weights); + return { nodes, links, topTypes, typeCounts }; + }, [specs, isDark, weights, activeCategory]); + + // Eager-load the 400-tier thumbnails so something paints fast. Higher tiers + // are fetched lazily from nodeCanvasObject when the user zooms in. + useEffect(() => { + if (graphData.nodes.length === 0) return; + const nodeById = new Map(graphData.nodes.map(n => [n.id, n])); + let cancelled = false; + preloadImages( + graphData.nodes.map(n => ({ id: n.id, thumbUrl: n.thumbUrl })), + (id, tier, img) => { + if (cancelled) return; + const n = nodeById.get(id); + if (n) n.imgs.set(tier, img); + fgRef.current?.refresh?.(); + } + ); + return () => { + cancelled = true; + }; + }, [graphData]); + + // 4. neighbor lookup for hover highlight (built once per links change) + const neighbors = useMemo(() => { + const map = new Map>(); + for (const l of graphData.links) { + if (!map.has(l.source)) map.set(l.source, new Set()); + if (!map.has(l.target)) map.set(l.target, new Set()); + map.get(l.source)!.add(l.target); + map.get(l.target)!.add(l.source); + } + return map; + }, [graphData.links]); + + + // 5. ForceGraph2D callbacks. Types for ctx come from the wrapper's prop signature + // when these are passed inline below — extracting them out would force us to spell + // CanvasRenderingContext2D explicitly, which our eslint config doesn't recognize. + type WithCoords = MapNode & { x?: number; y?: number }; + + const onNodeClick = (node: MapNode) => { + trackEvent('map_node_click', { spec_id: node.id }); + navigate(specPath(node.id)); + }; + + const ready = graphData.nodes.length > 0 && size.w > 0 && size.h > 0; + + return ( + <> + + map() — anyplot + + + + {/* Header overlay with tiny meta. left values mirror RootLayout's + container px in raw pixels (sx `left` is NOT spacing-aware, unlike + `px`/`mx`) so the text aligns with the anyplot logo / nav links. */} + + {specs ? `${specs.length} specs · ${graphData.links.length} edges` : ' '} + {hoverId && ( + + ❯ {graphData.nodes.find(n => n.id === hoverId)?.title} + + )} + + + {/* Legend: one row per top-N value of the highest-weighted tag + category. Caption shows which category is active so it's obvious + why the buckets just changed when a slider moves. Hovering a row + highlights that cluster on the canvas (matching nodes stay opaque, + others dim) so the spatial shape of the cluster pops out even + when nodes are scattered. */} + {graphData.topTypes.length > 0 && ( + + {activeCategory} + {graphData.topTypes.map((t, i) => { + const color = CLUSTER_COLORS[i % CLUSTER_COLORS.length]; + const count = graphData.typeCounts.get(t) ?? 0; + const dimmed = hoverType != null && hoverType !== t; + return ( + setHoverType(t)} + onMouseLeave={() => setHoverType(null)} + sx={{ + display: 'flex', + alignItems: 'center', + gap: 1, + cursor: 'pointer', + opacity: dimmed ? 0.35 : 1, + transition: 'opacity 0.15s', + color: hoverType === t ? 'var(--ink)' : 'inherit', + userSelect: 'none', + }} + > + + {t} + {count} + + ); + })} + + )} + + {/* Weights panel: collapsible bottom-left control for per-category + similarity weights. Live-updates KNN + simulation on every drag. */} + + setWeightsOpen(o => !o)} + sx={{ + all: 'unset', + cursor: 'pointer', + fontFamily: typography.mono, + fontSize: fontSize.xs, + color: weightsOpen ? 'var(--ink)' : 'var(--ink-soft)', + '&:hover': { color: colors.primary }, + userSelect: 'none', + }} + > + {weightsOpen ? 'weights ▾' : 'weights ▸'} + + {weightsOpen && ( + + {TAG_CATEGORIES.map(cat => ( + + + {cat} + + setWeights(w => ({ ...w, [cat]: v as number }))} + min={0} + max={5} + step={0.1} + size="small" + sx={{ + flex: 1, + color: colors.primary, + '& .MuiSlider-rail': { opacity: 0.25 }, + }} + /> + + {weights[cat].toFixed(1)} + + + ))} + + setWeights(DEFAULT_CATEGORY_WEIGHT)} + sx={{ + all: 'unset', + cursor: 'pointer', + fontFamily: typography.mono, + fontSize: fontSize.xs, + color: 'var(--ink-soft)', + '&:hover': { color: colors.primary }, + }} + > + reset + + + + )} + + + {/* Loading / error states */} + {!specs && !error && ( + + + + )} + {error && ( + + + Failed to load map: {error} + + + )} + + {/* Canvas */} + {ready && ( + n.title} + // Boost global repulsion so nodes aren't crammed into a blob. + d3VelocityDecay={0.35} + d3AlphaDecay={0.0228} + nodeCanvasObject={(node, ctx, globalScale) => { + const n = node as WithCoords; + if (n.x == null || n.y == null) return; + const isHover = hoverId === n.id; + const isNeighbor = !isHover && hoverId != null && neighbors.get(hoverId)?.has(n.id); + // hoverType is set when the user hovers a legend entry — match + // any node in that cluster, dim the rest. + const matchesType = hoverType == null || n.colorBucket === hoverType; + const dim = + (hoverId != null && !isHover && !isNeighbor) || + (hoverType != null && !matchesType); + // The hovered node itself doesn't grow here — the much larger + // preview is drawn on top by onRenderFramePost. We DO bump + // direct neighbors slightly so the relationship is legible. + const baseSize = NODE_SIZE * (isNeighbor ? 1.2 : 1); + + // Pick the smallest variant whose source resolution comfortably + // covers the on-screen size, then lazy-load it if not yet present. + // force-graph only invokes nodeCanvasObject for visible nodes, so + // off-screen specs never trigger a higher-tier fetch. + const screenPx = baseSize * (globalScale ?? 1); + const dpr = typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1; + const desired: ResolutionTier = pickTier(screenPx * dpr); + if (n.imgs && !n.imgs.has(desired) && !n.pendingTiers?.has(desired)) { + ensureNodeTier(n, desired, () => fgRef.current?.refresh?.()); + } + const img = n.imgs ? pickBestLoadedTier(n.imgs, desired) : null; + + // Match draw size to the source aspect ratio (most plots are 16:9 + // from figsize=(16,9)) — keep the longer side at baseSize so nodes + // share a consistent bounding-box scale. + const { w, h } = fitToBox(baseSize, nodeAspectRatio(n)); + const x = n.x - w / 2; + const y = n.y - h / 2; + + ctx.save(); + if (dim) ctx.globalAlpha = 0.18; + if (img) { + ctx.drawImage(img, x, y, w, h); + } else { + ctx.fillStyle = isDark ? '#242420' : '#FFFDF6'; + ctx.fillRect(x, y, w, h); + } + ctx.lineWidth = isHover ? 2 : n.colorBucket ? 1.5 : 1; + ctx.strokeStyle = strokeFor(isDark, !!isHover, colorFor(n.colorBucket, graphData.topTypes)); + ctx.strokeRect(x, y, w, h); + ctx.restore(); + }} + nodePointerAreaPaint={(node, color, ctx) => { + const n = node as WithCoords; + if (n.x == null || n.y == null) return; + const { w, h } = fitToBox(NODE_SIZE, nodeAspectRatio(n)); + ctx.fillStyle = color; + ctx.fillRect(n.x - w / 2, n.y - h / 2, w, h); + }} + // Links are intentionally very subtle by default so the thumbnails + // dominate. Hovered-node connections light up bright green; when + // a legend entry is hovered, links between same-cluster nodes + // stay visible while everything else fades. + linkColor={(l: MapLink) => { + const involved = hoverId && (l.source === hoverId || l.target === hoverId); + if (involved) return colors.primary; + if (hoverType) { + const sId = typeof l.source === 'string' ? l.source : (l.source as { id?: string })?.id; + const tId = typeof l.target === 'string' ? l.target : (l.target as { id?: string })?.id; + const sBucket = graphData.nodes.find(n => n.id === sId)?.colorBucket; + const tBucket = graphData.nodes.find(n => n.id === tId)?.colorBucket; + const intra = sBucket === hoverType && tBucket === hoverType; + if (intra) return isDark ? 'rgba(255,255,255,0.18)' : 'rgba(0,0,0,0.22)'; + return isDark ? 'rgba(255,255,255,0.012)' : 'rgba(0,0,0,0.015)'; + } + if (hoverId) return isDark ? 'rgba(255,255,255,0.02)' : 'rgba(0,0,0,0.025)'; + return isDark ? 'rgba(255,255,255,0.10)' : 'rgba(0,0,0,0.13)'; + }} + linkWidth={(l: MapLink) => { + const involved = hoverId && (l.source === hoverId || l.target === hoverId); + if (involved) return Math.max(1, (l.weight ?? 0.3) * 2.5); + return Math.max(0.4, (l.weight ?? 0.3) * 1.5); + }} + onNodeClick={onNodeClick} + onNodeHover={(n: MapNode | null) => setHoverId(n?.id ?? null)} + cooldownTicks={COOLDOWN_TICKS} + // Fit the whole graph into the viewport once the engine settles, + // but enforce a minimum zoom afterwards — without the floor, a + // few far-flung outliers force zoomToFit to shrink the dense + // central cluster down to illegible pixels. + onEngineStop={() => { + const fg = fgRef.current; + if (!fg) return; + fg.zoomToFit?.(600, 80); + setTimeout(() => { + if (typeof fg.zoom === 'function' && fg.zoom() < MIN_ZOOM) { + fg.zoom(MIN_ZOOM, 400); + } + }, 700); + }} + // Wire up the custom forces once the imperative ref is available. + // onRenderFramePre fires every frame; the __forcesWired guard makes + // it idempotent and the cost on subsequent frames is one property read. + onRenderFramePre={() => { + const fg = fgRef.current; + if (!fg || fg.__forcesWired) return; + // Stronger many-body repulsion than the default ~-30. + fg.d3Force('charge')?.strength(REPULSION); + // Link distance/strength scale with weighted-Jaccard similarity: + // tighter clusters for highly related specs, looser otherwise. + // The d3-force-3d ambient types are minimal; cast for the chained calls. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const linkForce = fg.d3Force('link') as any; + if (linkForce) { + linkForce.distance((l: MapLink) => { + const w = l.weight ?? 0.3; + return LINK_DISTANCE_MIN + (1 - Math.min(1, w)) * (LINK_DISTANCE_MAX - LINK_DISTANCE_MIN); + }); + linkForce.strength((l: MapLink) => + Math.max(0.02, Math.min(LINK_STRENGTH_CAP, (l.weight ?? 0.3) * 0.4)) + ); + } + // Per-node collision: prevents thumbnail overlap. Radius = half + // the longer side of the bounding box plus a small padding. + fg.d3Force( + 'collide', + forceCollide(() => NODE_SIZE / 2 + COLLIDE_PADDING).iterations(2) + ); + fg.__forcesWired = true; + fg.d3ReheatSimulation?.(); + }} + // Hover preview: paint a much larger version of the hovered node + // AFTER all the regular nodes have been drawn, so it always sits + // on top regardless of the node-paint order. Uses the same canvas + // transform (graph coords) so positioning is just (n.x, n.y). + onRenderFramePost={(ctx) => { + if (!hoverId) return; + const n = graphData.nodes.find(x => x.id === hoverId) as + | (MapNode & { x?: number; y?: number }) + | undefined; + if (!n || n.x == null || n.y == null) return; + + // Upgrade to the highest tier we'd want for a preview this large. + const dpr = typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1; + const desiredTier = pickTier(HOVER_PREVIEW_SIZE * dpr); + if (n.imgs && !n.imgs.has(desiredTier) && !n.pendingTiers?.has(desiredTier)) { + ensureNodeTier(n, desiredTier, () => fgRef.current?.refresh?.()); + } + const img = n.imgs ? pickBestLoadedTier(n.imgs, desiredTier) : null; + + const { w, h } = fitToBox(HOVER_PREVIEW_SIZE, nodeAspectRatio(n)); + const x = n.x - w / 2; + const y = n.y - h / 2; + + ctx.save(); + // Soft drop-shadow halo to lift the preview off the canvas. + ctx.shadowColor = colors.primary; + ctx.shadowBlur = 16; + ctx.fillStyle = isDark ? '#0a0a08' : '#FFFDF6'; + ctx.fillRect(x, y, w, h); + ctx.shadowBlur = 0; + if (img) ctx.drawImage(img, x, y, w, h); + ctx.lineWidth = 1.5; + ctx.strokeStyle = colors.primary; + ctx.strokeRect(x, y, w, h); + ctx.restore(); + }} + /> + )} + + {/* a11y fallback: visually-hidden list so screen readers + keyboard users + can still reach every spec from this page. */} + + {(specs ?? []).map(s => ( +
  • + {s.title} +
  • + ))} +
    +
    + + ); +} diff --git a/app/src/router.tsx b/app/src/router.tsx index 13ab23cf1a..1ceda39e65 100644 --- a/app/src/router.tsx +++ b/app/src/router.tsx @@ -36,6 +36,7 @@ const router = createBrowserRouter([ { path: 'plots', lazy: () => import('./pages/PlotsPage').then(m => ({ Component: m.PlotsPage })) }, { path: 'specs', lazy: () => import('./pages/SpecsListPage').then(m => ({ Component: m.SpecsListPage })) }, { path: 'libraries', lazy: () => import('./pages/LibrariesPage').then(m => ({ Component: m.LibrariesPage })) }, + { path: 'map', lazy: () => import('./pages/MapPage').then(m => ({ Component: m.MapPage })) }, { path: 'palette', lazy: () => import('./pages/PalettePage').then(m => ({ Component: m.PalettePage })) }, { path: 'about', lazy: () => import('./pages/AboutPage').then(m => ({ Component: m.AboutPage })) }, { path: 'legal', lazy: () => import('./pages/LegalPage').then(m => ({ Component: m.LegalPage })) }, diff --git a/app/src/types/d3-force-3d.d.ts b/app/src/types/d3-force-3d.d.ts new file mode 100644 index 0000000000..d4a8ed4a89 --- /dev/null +++ b/app/src/types/d3-force-3d.d.ts @@ -0,0 +1,25 @@ +/** + * Minimal ambient declarations for d3-force-3d (no @types package published). + * We only use forceCollide; the rest of the simulation lives inside force-graph. + */ +declare module 'd3-force-3d' { + export interface Force { + initialize: (nodes: N[]) => void; + radius: (r: number | ((node: N, i: number, nodes: N[]) => number)) => Force; + iterations: (n: number) => Force; + strength: (s: number | ((node: N) => number)) => Force; + distance: (d: number | ((link: unknown) => number)) => Force; + } + + export function forceCollide( + radius?: number | ((node: N, i: number, nodes: N[]) => number) + ): Force; + + export function forceX( + x?: number | ((node: N, i: number, nodes: N[]) => number) + ): Force; + + export function forceY( + y?: number | ((node: N, i: number, nodes: N[]) => number) + ): Force; +} diff --git a/app/yarn.lock b/app/yarn.lock index ca4d14a689..817cc74191 100644 --- a/app/yarn.lock +++ b/app/yarn.lock @@ -807,6 +807,11 @@ resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-14.6.1.tgz#13e09a32d7a8b7060fe38304788ebf4197cd2149" integrity sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw== +"@tweenjs/tween.js@18 - 25": + version "25.0.0" + resolved "https://registry.yarnpkg.com/@tweenjs/tween.js/-/tween.js-25.0.0.tgz#7266baebcc3affe62a3a54318a3ea82d904cd0b9" + integrity sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A== + "@tybys/wasm-util@^0.10.1": version "0.10.1" resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.10.1.tgz#ecddd3205cf1e2d5274649ff0eedd2991ed7f414" @@ -1083,6 +1088,11 @@ convert-source-map "^2.0.0" tinyrainbow "^3.1.0" +accessor-fn@1: + version "1.5.3" + resolved "https://registry.yarnpkg.com/accessor-fn/-/accessor-fn-1.5.3.tgz#5e2549d291d4ac022f532da9a554358dc525b0f7" + integrity sha512-rkAofCwe/FvYFUlMB0v0gWmhqtfAtV1IUkdPbfhTUyYniu5LrC0A0UJkTH0Jv3S8SvwkmfuAlY+mQIJATdocMA== + acorn-jsx@^5.3.2: version "5.3.2" resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" @@ -1158,6 +1168,11 @@ baseline-browser-mapping@^2.10.12: resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.13.tgz#5a154cc4589193015a274e3d18319b0d76b9224e" integrity sha512-BL2sTuHOdy0YT1lYieUxTw/QMtPBC3pmlJC6xk8BBYVv6vcw3SGdKemQ+Xsx9ik2F/lYDO9tqsFQH1r9PFuHKw== +"bezier-js@3 - 6": + version "6.1.4" + resolved "https://registry.yarnpkg.com/bezier-js/-/bezier-js-6.1.4.tgz#c7828f6c8900562b69d5040afb881bcbdad82001" + integrity sha512-PA0FW9ZpcHbojUCMu28z9Vg/fNkwTj5YhusSAjHHDfHDGLxJ6YUKrAN2vk1fP2MMOxVw4Oko16FMlRGVBGqLKg== + bidi-js@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/bidi-js/-/bidi-js-1.0.3.tgz#6f8bcf3c877c4d9220ddf49b9bb6930c88f877d2" @@ -1193,6 +1208,13 @@ caniuse-lite@^1.0.30001782: resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001784.tgz#bdf9733a0813ccfb5ab4d02f2127e62ee4c6b718" integrity sha512-WU346nBTklUV9YfUl60fqRbU5ZqyXlqvo1SgigE1OAXK5bFL8LL9q1K7aap3N739l4BvNqnkm3YrGHiY9sfUQw== +canvas-color-tracker@^1.3: + version "1.3.2" + resolved "https://registry.yarnpkg.com/canvas-color-tracker/-/canvas-color-tracker-1.3.2.tgz#b924cf94b33441b82692938fca5b936be971a46d" + integrity sha512-ryQkDX26yJ3CXzb3hxUVNlg1NKE4REc5crLBq661Nxzr8TNd236SaEf2ffYLXyI5tSABSeguHLqcVq4vf9L3Zg== + dependencies: + tinycolor2 "^1.6.0" + chai@^6.2.2: version "6.2.2" resolved "https://registry.yarnpkg.com/chai/-/chai-6.2.2.tgz#ae41b52c9aca87734505362717f3255facda360e" @@ -1276,6 +1298,139 @@ csstype@^3.0.2, csstype@^3.2.2, csstype@^3.2.3: resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.2.3.tgz#ec48c0f3e993e50648c86da559e2610995cf989a" integrity sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ== +"d3-array@1 - 3", "d3-array@2 - 3", "d3-array@2.10.0 - 3": + version "3.2.4" + resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-3.2.4.tgz#15fec33b237f97ac5d7c986dc77da273a8ed0bb5" + integrity sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg== + dependencies: + internmap "1 - 2" + +d3-binarytree@1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/d3-binarytree/-/d3-binarytree-1.0.2.tgz#ed43ebc13c70fbabfdd62df17480bc5a425753cc" + integrity sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw== + +"d3-color@1 - 3": + version "3.1.0" + resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-3.1.0.tgz#395b2833dfac71507f12ac2f7af23bf819de24e2" + integrity sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA== + +"d3-dispatch@1 - 3": + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-dispatch/-/d3-dispatch-3.0.1.tgz#5fc75284e9c2375c36c839411a0cf550cbfc4d5e" + integrity sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg== + +"d3-drag@2 - 3": + version "3.0.0" + resolved "https://registry.yarnpkg.com/d3-drag/-/d3-drag-3.0.0.tgz#994aae9cd23c719f53b5e10e3a0a6108c69607ba" + integrity sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg== + dependencies: + d3-dispatch "1 - 3" + d3-selection "3" + +"d3-ease@1 - 3": + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-ease/-/d3-ease-3.0.1.tgz#9658ac38a2140d59d346160f1f6c30fda0bd12f4" + integrity sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w== + +"d3-force-3d@2 - 3": + version "3.0.6" + resolved "https://registry.yarnpkg.com/d3-force-3d/-/d3-force-3d-3.0.6.tgz#7ea4c26d7937b82993bd9444f570ed52f661d4aa" + integrity sha512-4tsKHUPLOVkyfEffZo1v6sFHvGFwAIIjt/W8IThbp08DYAsXZck+2pSHEG5W1+gQgEvFLdZkYvmJAbRM2EzMnA== + dependencies: + d3-binarytree "1" + d3-dispatch "1 - 3" + d3-octree "1" + d3-quadtree "1 - 3" + d3-timer "1 - 3" + +"d3-format@1 - 3": + version "3.1.2" + resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-3.1.2.tgz#01fdb46b58beb1f55b10b42ad70b6e344d5eb2ae" + integrity sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg== + +"d3-interpolate@1 - 3", "d3-interpolate@1.2.0 - 3": + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz#3c47aa5b32c5b3dfb56ef3fd4342078a632b400d" + integrity sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g== + dependencies: + d3-color "1 - 3" + +d3-octree@1: + version "1.1.0" + resolved "https://registry.yarnpkg.com/d3-octree/-/d3-octree-1.1.0.tgz#f07e353b76df872644e7130ab1a74c5ef2f4287e" + integrity sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A== + +"d3-quadtree@1 - 3": + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-quadtree/-/d3-quadtree-3.0.1.tgz#6dca3e8be2b393c9a9d514dabbd80a92deef1a4f" + integrity sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw== + +"d3-scale-chromatic@1 - 3": + version "3.1.0" + resolved "https://registry.yarnpkg.com/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz#34c39da298b23c20e02f1a4b239bd0f22e7f1314" + integrity sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ== + dependencies: + d3-color "1 - 3" + d3-interpolate "1 - 3" + +"d3-scale@1 - 4": + version "4.0.2" + resolved "https://registry.yarnpkg.com/d3-scale/-/d3-scale-4.0.2.tgz#82b38e8e8ff7080764f8dcec77bd4be393689396" + integrity sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ== + dependencies: + d3-array "2.10.0 - 3" + d3-format "1 - 3" + d3-interpolate "1.2.0 - 3" + d3-time "2.1.1 - 3" + d3-time-format "2 - 4" + +"d3-selection@2 - 3", d3-selection@3: + version "3.0.0" + resolved "https://registry.yarnpkg.com/d3-selection/-/d3-selection-3.0.0.tgz#c25338207efa72cc5b9bd1458a1a41901f1e1b31" + integrity sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ== + +"d3-time-format@2 - 4": + version "4.1.0" + resolved "https://registry.yarnpkg.com/d3-time-format/-/d3-time-format-4.1.0.tgz#7ab5257a5041d11ecb4fe70a5c7d16a195bb408a" + integrity sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg== + dependencies: + d3-time "1 - 3" + +"d3-time@1 - 3", "d3-time@2.1.1 - 3": + version "3.1.0" + resolved "https://registry.yarnpkg.com/d3-time/-/d3-time-3.1.0.tgz#9310db56e992e3c0175e1ef385e545e48a9bb5c7" + integrity sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q== + dependencies: + d3-array "2 - 3" + +"d3-timer@1 - 3": + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-timer/-/d3-timer-3.0.1.tgz#6284d2a2708285b1abb7e201eda4380af35e63b0" + integrity sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA== + +"d3-transition@2 - 3": + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-transition/-/d3-transition-3.0.1.tgz#6869fdde1448868077fdd5989200cb61b2a1645f" + integrity sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w== + dependencies: + d3-color "1 - 3" + d3-dispatch "1 - 3" + d3-ease "1 - 3" + d3-interpolate "1 - 3" + d3-timer "1 - 3" + +"d3-zoom@2 - 3": + version "3.0.0" + resolved "https://registry.yarnpkg.com/d3-zoom/-/d3-zoom-3.0.0.tgz#d13f4165c73217ffeaa54295cd6969b3e7aee8f3" + integrity sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw== + dependencies: + d3-dispatch "1 - 3" + d3-drag "2 - 3" + d3-interpolate "1 - 3" + d3-selection "2 - 3" + d3-transition "2 - 3" + data-urls@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/data-urls/-/data-urls-7.0.0.tgz#6dce8b63226a1ecfdd907ce18a8ccfb1eee506d3" @@ -1550,6 +1705,36 @@ flatted@^3.2.9: resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.4.2.tgz#f5c23c107f0f37de8dbdf24f13722b3b98d52726" integrity sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA== +float-tooltip@^1.7: + version "1.7.5" + resolved "https://registry.yarnpkg.com/float-tooltip/-/float-tooltip-1.7.5.tgz#7083bf78f0de5a97f9c2d6aa8e90d2139f34047f" + integrity sha512-/kXzuDnnBqyyWyhDMH7+PfP8J/oXiAavGzcRxASOMRHFuReDtofizLLJsf7nnDLAfEaMW4pVWaXrAjtnglpEkg== + dependencies: + d3-selection "2 - 3" + kapsule "^1.16" + preact "10" + +force-graph@^1.51, force-graph@^1.51.4: + version "1.51.4" + resolved "https://registry.yarnpkg.com/force-graph/-/force-graph-1.51.4.tgz#bd4b5b0d046f2c9e7c737988c821104a100a368d" + integrity sha512-TdJ2KbkoiDQ7NIRx8IPGD0mAXXpLhamS7c+b7W98b0MHG7lphnda1VOQX/98UDTsttIAdH4TcP0l0MauSnLK8w== + dependencies: + "@tweenjs/tween.js" "18 - 25" + accessor-fn "1" + bezier-js "3 - 6" + canvas-color-tracker "^1.3" + d3-array "1 - 3" + d3-drag "2 - 3" + d3-force-3d "2 - 3" + d3-scale "1 - 4" + d3-scale-chromatic "1 - 3" + d3-selection "2 - 3" + d3-zoom "2 - 3" + float-tooltip "^1.7" + index-array-by "1" + kapsule "^1.16" + lodash-es "4" + format@^0.2.0: version "0.2.2" resolved "https://registry.yarnpkg.com/format/-/format-0.2.2.tgz#d6170107e9efdc4ed30c9dc39016df942b5cb58b" @@ -1681,6 +1866,16 @@ indent-string@^4.0.0: resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-4.0.0.tgz#624f8f4497d619b2d9768531d58f4122854d7251" integrity sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg== +index-array-by@1: + version "1.4.2" + resolved "https://registry.yarnpkg.com/index-array-by/-/index-array-by-1.4.2.tgz#d6f82e9fbff3201c4dab64ba415d4d2923242fea" + integrity sha512-SP23P27OUKzXWEC/TOyWlwLviofQkCSCKONnc62eItjp69yCZZPqDQtr3Pw5gJDnPeUMqExmKydNZaJO0FU9pw== + +"internmap@1 - 2": + version "2.0.3" + resolved "https://registry.yarnpkg.com/internmap/-/internmap-2.0.3.tgz#6685f23755e43c524e251d29cbc97248e3061009" + integrity sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg== + invariant@^2.2.4: version "2.2.4" resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6" @@ -1767,6 +1962,11 @@ istanbul-reports@^3.2.0: html-escaper "^2.0.0" istanbul-lib-report "^3.0.0" +jerrypick@^1.1.1: + version "1.1.2" + resolved "https://registry.yarnpkg.com/jerrypick/-/jerrypick-1.1.2.tgz#eb5016304aeb9ac9b7dea6714aa5fe85b24cb8ad" + integrity sha512-YKnxXEekXKzhpf7CLYA0A+oDP8V0OhICNCr5lv96FvSsDEmrb0GKM776JgQvHTMjr7DTTPEVv/1Ciaw0uEWzBA== + js-tokens@^10.0.0: version "10.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-10.0.0.tgz#dffe7599b4a8bb7fe30aff8d0235234dffb79831" @@ -1834,6 +2034,13 @@ json5@^2.2.3: resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283" integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== +kapsule@^1.16: + version "1.16.3" + resolved "https://registry.yarnpkg.com/kapsule/-/kapsule-1.16.3.tgz#5684ed89838b6658b30d0f2cc056dffc3ba68c30" + integrity sha512-4+5mNNf4vZDSwPhKprKwz3330iisPrb08JyMgbsdFrimBCKNHecua/WBwvVg3n7vwx0C1ARjfhwIpbrbd9n5wg== + dependencies: + lodash-es "4" + keyv@^4.5.4: version "4.5.4" resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93" @@ -1935,6 +2142,11 @@ locate-path@^6.0.0: dependencies: p-locate "^5.0.0" +lodash-es@4: + version "4.18.1" + resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.18.1.tgz#b962eeb80d9d983a900bf342961fb7418ca10b1d" + integrity sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A== + loose-envify@^1.0.0, loose-envify@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" @@ -2144,6 +2356,11 @@ postcss@^8.5.10: picocolors "^1.1.1" source-map-js "^1.2.1" +preact@10: + version "10.29.1" + resolved "https://registry.yarnpkg.com/preact/-/preact-10.29.1.tgz#2a5b936efe91cfe1e773cdb55dceb55d148d1d4b" + integrity sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg== + prelude-ls@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" @@ -2163,7 +2380,7 @@ prismjs@^1.30.0: resolved "https://registry.yarnpkg.com/prismjs/-/prismjs-1.30.0.tgz#d9709969d9d4e16403f6f348c63553b19f0975a9" integrity sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw== -prop-types@^15.6.2, prop-types@^15.8.1: +prop-types@15, prop-types@^15.6.2, prop-types@^15.8.1: version "15.8.1" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5" integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg== @@ -2194,6 +2411,15 @@ react-fast-compare@^3.2.2: resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.2.tgz#929a97a532304ce9fee4bcae44234f1ce2c21d49" integrity sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ== +react-force-graph-2d@^1.29.1: + version "1.29.1" + resolved "https://registry.yarnpkg.com/react-force-graph-2d/-/react-force-graph-2d-1.29.1.tgz#a0784d4387b12b28e2b552058ec09d092b4e8cda" + integrity sha512-1Rl/1Z3xy2iTHKj6a0jRXGyiI86xUti81K+jBQZ+Oe46csaMikp47L5AjrzA9hY9fNGD63X8ffrqnvaORukCuQ== + dependencies: + force-graph "^1.51" + prop-types "15" + react-kapsule "^2.5" + react-helmet-async@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/react-helmet-async/-/react-helmet-async-3.0.0.tgz#16f31779ea4e4e01827c071b2f15301d074dd570" @@ -2218,6 +2444,13 @@ react-is@^19.2.4: resolved "https://registry.yarnpkg.com/react-is/-/react-is-19.2.5.tgz#7e7b54143e9313fed787b23fd4295d5a23872ad9" integrity sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ== +react-kapsule@^2.5: + version "2.5.7" + resolved "https://registry.yarnpkg.com/react-kapsule/-/react-kapsule-2.5.7.tgz#dcd957ae8e897ff48055fc8ff48ed04ebe3c5bd2" + integrity sha512-kifAF4ZPD77qZKc4CKLmozq6GY1sBzPEJTIJb0wWFK6HsePJatK3jXplZn2eeAt3x67CDozgi7/rO8fNQ/AL7A== + dependencies: + jerrypick "^1.1.1" + react-router-dom@^7.14.2: version "7.14.2" resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-7.14.2.tgz#0b043c1534fe58596771b82a318a7e4c2e5f1279" @@ -2434,6 +2667,11 @@ tinybench@^2.9.0: resolved "https://registry.yarnpkg.com/tinybench/-/tinybench-2.9.0.tgz#103c9f8ba6d7237a47ab6dd1dcff77251863426b" integrity sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg== +tinycolor2@^1.6.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/tinycolor2/-/tinycolor2-1.6.0.tgz#f98007460169b0263b97072c5ae92484ce02d09e" + integrity sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw== + tinyexec@^1.0.2: version "1.0.4" resolved "https://registry.yarnpkg.com/tinyexec/-/tinyexec-1.0.4.tgz#6c60864fe1d01331b2f17c6890f535d7e5385408" diff --git a/tests/unit/api/test_routers.py b/tests/unit/api/test_routers.py index b34a94f092..0286956505 100644 --- a/tests/unit/api/test_routers.py +++ b/tests/unit/api/test_routers.py @@ -347,6 +347,101 @@ def test_spec_detail_not_found(self, client: TestClient) -> None: response = client.get("/specs/nonexistent") assert response.status_code == 404 + def test_specs_map_without_db(self, client: TestClient) -> None: + """Specs map should return 503 when DB not configured.""" + with patch(DB_CONFIG_PATCH, return_value=False): + response = client.get("/specs/map") + assert response.status_code == 503 + + def test_specs_map_returns_list(self, client: TestClient, mock_spec) -> None: + """Specs map returns one row per spec with best-impl preview + tag bag.""" + mock_spec_repo = MagicMock() + mock_spec_repo.get_all = AsyncMock(return_value=[mock_spec]) + + with ( + patch(DB_CONFIG_PATCH, return_value=True), + patch("api.routers.specs.get_or_set_cache", side_effect=_passthrough_cache), + patch("api.routers.specs.SpecRepository", return_value=mock_spec_repo), + ): + response = client.get("/specs/map") + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + assert len(data) == 1 + row = data[0] + assert row["id"] == "scatter-basic" + assert row["title"] == "Basic Scatter Plot" + assert row["preview_url_light"] == TEST_IMAGE_URL + assert row["quality_score"] == 92.5 + assert row["tags"] == { + "plot_type": ["scatter"], + "domain": ["statistics"], + "data_type": ["numeric"], + "features": ["basic"], + } + assert row["impl_tags"] == {"patterns": ["data-generation"], "styling": ["alpha-blending"]} + + def test_specs_map_picks_best_impl(self, client: TestClient, mock_spec) -> None: + """Specs map picks the impl with the highest quality_score per spec.""" + # Append a second, lower-rated impl with a distinct preview URL + worse_impl = MagicMock() + worse_impl.library_id = "seaborn" + worse_impl.preview_url_light = "https://example.com/worse-light.png" + worse_impl.preview_url_dark = None + worse_impl.quality_score = 60.0 + worse_impl.impl_tags = {"patterns": ["should-not-appear"]} + mock_spec.impls = [worse_impl, mock_spec.impls[0]] # quality 60 then quality 92.5 + + mock_spec_repo = MagicMock() + mock_spec_repo.get_all = AsyncMock(return_value=[mock_spec]) + + with ( + patch(DB_CONFIG_PATCH, return_value=True), + patch("api.routers.specs.get_or_set_cache", side_effect=_passthrough_cache), + patch("api.routers.specs.SpecRepository", return_value=mock_spec_repo), + ): + response = client.get("/specs/map") + assert response.status_code == 200 + data = response.json() + assert len(data) == 1 + assert data[0]["preview_url_light"] == TEST_IMAGE_URL # higher-rated matplotlib impl + assert data[0]["quality_score"] == 92.5 + assert data[0]["impl_tags"] == {"patterns": ["data-generation"], "styling": ["alpha-blending"]} + + def test_specs_map_skips_specs_without_impls(self, client: TestClient, mock_spec) -> None: + """Specs map omits specs with zero implementations (matches /specs behavior).""" + empty_spec = MagicMock() + empty_spec.id = "no-impls" + empty_spec.title = "No Implementations Yet" + empty_spec.impls = [] + + mock_spec_repo = MagicMock() + mock_spec_repo.get_all = AsyncMock(return_value=[mock_spec, empty_spec]) + + with ( + patch(DB_CONFIG_PATCH, return_value=True), + patch("api.routers.specs.get_or_set_cache", side_effect=_passthrough_cache), + patch("api.routers.specs.SpecRepository", return_value=mock_spec_repo), + ): + response = client.get("/specs/map") + assert response.status_code == 200 + data = response.json() + assert [row["id"] for row in data] == ["scatter-basic"] + + def test_specs_map_empty_db(self, client: TestClient) -> None: + """Specs map returns [] (not 404) when there are no specs.""" + mock_spec_repo = MagicMock() + mock_spec_repo.get_all = AsyncMock(return_value=[]) + + with ( + patch(DB_CONFIG_PATCH, return_value=True), + patch("api.routers.specs.get_or_set_cache", side_effect=_passthrough_cache), + patch("api.routers.specs.SpecRepository", return_value=mock_spec_repo), + ): + response = client.get("/specs/map") + assert response.status_code == 200 + assert response.json() == [] + class TestDownloadRouter: """Tests for download router."""