diff --git a/.gitignore b/.gitignore index 734d171fc8..bf3fd48238 100644 --- a/.gitignore +++ b/.gitignore @@ -242,3 +242,6 @@ docker-compose.override.yml secrets/ /.playwright-mcp/ /screenshots/ + +# Map page tuning screenshots (root-level only — must not match plots/*/preview.png etc.) +/map-*.png 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/seo.py b/api/routers/seo.py index 4dd6b9f741..c331884e39 100644 --- a/api/routers/seo.py +++ b/api/routers/seo.py @@ -47,6 +47,7 @@ def _build_sitemap_xml(specs: list) -> str: " https://anyplot.ai/plots", " https://anyplot.ai/specs", " https://anyplot.ai/libraries", + " https://anyplot.ai/map", " https://anyplot.ai/palette", " https://anyplot.ai/about", " https://anyplot.ai/mcp", diff --git a/api/routers/specs.py b/api/routers/specs.py index a741e9b691..7d92bb13b8 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,40 @@ 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 lexicographically *greatest* library_id + (since `max()` picks the largest tuple — e.g. seaborn over matplotlib on a tie). + 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 + # Prefer impls that actually have a preview URL — otherwise the map + # would render blank-bordered nodes for specs whose top-quality impl + # happens to have no thumbnail. Fall back to the full impl list only + # when *no* impl has a preview (very rare, but keeps the spec on the map). + with_preview = [i for i in spec.impls if i.preview_url_light or i.preview_url_dark] + candidates = with_preview or list(spec.impls) + best = max(candidates, 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 +159,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..0d47a317e6 100644 --- a/api/schemas.py +++ b/api/schemas.py @@ -69,6 +69,22 @@ 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 + # Tag bags: each category maps to a list of strings. Tightened from + # dict[str, Any] so the OpenAPI contract matches what the /map frontend + # expects (Record) and so unexpected shapes get + # caught at validation time instead of breaking client-side similarity. + tags: dict[str, list[str]] | None = None + impl_tags: dict[str, list[str]] | 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..7bc770b103 --- /dev/null +++ b/app/src/pages/MapPage.helpers.ts @@ -0,0 +1,456 @@ +/** + * Helpers for the /map page: tag flattening, IDF weighting, weighted + * Jaccard similarity, KNN edge construction, plus thumbnail-tier + * selection and image preloading. + * + * Most helpers are pure (math + selection logic) so they can be unit + * tested in MapPage.helpers.test.ts. The two exceptions — preloadImages + * and ensureNodeTier — create DOM HTMLImageElements and trigger network + * fetches; their callbacks let the caller hook in cache state and a + * canvas refresh. + */ + +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 privilege plot_type (2.0) with light contributions from + * features and data_type (0.5 each). That gives a plot_type-dominant map + * with subtle cross-type cohesion. Users can slide secondary categories up + * via the weights panel to mix in techniques/patterns/etc. for richer + * clustering. + */ +export const DEFAULT_CATEGORY_WEIGHT: Record = { + plot_type: 2.0, + features: 0.5, + techniques: 0, + patterns: 0, + dataprep: 0, + dependencies: 0, + domain: 0, + data_type: 0.5, + 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 smallest already-loaded tier that's at least as big as + * `desired` (we don't waste pixels rendering a 1200 px image at the + * 400 px tier). Falls back to the largest loaded tier smaller than + * `desired` if no sufficient tier has loaded yet — better than a blank + * thumbnail 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..7210d6c3bb --- /dev/null +++ b/app/src/pages/MapPage.test.tsx @@ -0,0 +1,272 @@ +import { describe, it, expect, vi, beforeEach, afterEach } 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 }), +})); + +// Default to "(hover: hover)" matching → desktop behaviour. Touch-specific +// branches (e.g. tap-to-pin) need a per-test override. +vi.mock('@mui/material/useMediaQuery', () => ({ + default: () => true, +})); + +// 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); + }); + + // Restore stubbed globals (fetch, ResizeObserver, …) after every test so + // they don't leak into subsequent suites — other frontend tests rely on a + // clean global surface and silently break otherwise. + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + 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: '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..e0b45abefc --- /dev/null +++ b/app/src/pages/MapPage.tsx @@ -0,0 +1,1290 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import { Helmet } from 'react-helmet-async'; +import Box from '@mui/material/Box'; +import CircularProgress from '@mui/material/CircularProgress'; +import useMediaQuery from '@mui/material/useMediaQuery'; +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, + buildVariantUrl, + 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 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 +// Default 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. +// Exposed as a live slider in the weights panel for power users. +const DEFAULT_MIN_SIM = 0.15; +const MIN_SIM_BOUNDS = { min: 0.05, max: 0.4, step: 0.01 } as const; +// 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 = 6; // px padding on top of the bounding-box radius — visible breathing room between thumbnails +const CENTER_GRAVITY = 0.04; // gentle pull toward the viewport center; ~25× weaker than d3-force-3d's default to corral outliers without flattening clusters + +// 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]; +} + +// Read a link endpoint's spec id regardless of whether ForceGraph2D has +// already mutated the field from a string into the resolved node object +// (it does so after the first simulation tick). All link-side comparisons +// must go through this helper or they silently break post-tick. +function linkEndId(end: MapLink['source']): string | undefined { + if (typeof end === 'string') return end; + return (end as { id?: string })?.id; +} + +// 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. +// On hover we keep the cluster color (or fall back to brand primary for +// non-bucketed nodes) so the frame doesn't suddenly turn green and clash +// with whatever color family the node belongs to. +function strokeFor(isDark: boolean, isHover: boolean, color: string | null): string { + if (isHover) return color ?? 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); + // panelNodeId trails hoverId on mouse-out so the corner preview can fade + // out while still showing the last node's content. It only updates to a + // *new* node when hoverId becomes non-null. + const [panelNodeId, setPanelNodeId] = useState(null); + // pinnedId persists a visual marker on the searched node so the user + // doesn't lose track of it when the mouse drifts onto a different node + // (which would otherwise overwrite hoverId and replace the panel content). + // Cleared by clicking empty canvas or by triggering another search. + const [pinnedId, setPinnedId] = useState(null); + // Screen-space rect of the pinned node, recomputed every frame while a + // pin is active. Drives the DOM-overlay pulse marker — separate from the + // canvas so a CSS @keyframes animation can drive the pulse independently + // of FG2D's render loop (which pauses once the simulation cools down). + const [pinScreen, setPinScreen] = useState<{ x: number; y: number; w: number; h: number } | null>(null); + // ~100ms debounce so quick mouse-overs don't strobe the preview. + const hoverTimerRef = useRef(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 [minSim, setMinSim] = useState(DEFAULT_MIN_SIM); + const [weightsOpen, setWeightsOpen] = useState(false); + // Mobile-only: legend collapses behind a `legend ▸` toggle to leave + // canvas room. Tablet/desktop renders the legend list always-visible. + const [legendOpen, setLegendOpen] = useState(false); + + // Search-pill state. searchOpen controls dropdown visibility (separate + // from focus so we can keep showing matches briefly while a click is in + // flight via the input's onBlur grace period). + const [searchQuery, setSearchQuery] = useState(''); + const [searchOpen, setSearchOpen] = useState(false); + const [searchIdx, setSearchIdx] = useState(0); + const searchInputRef = useRef(null); + + // 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(); + }, []); + + // Hover preview: when a new node becomes active, swap the panel content + // immediately. When hover ends, fall back to the pinned spec (so the + // searched node's details stay visible while the user looks around). + // If neither hover nor pin is set, the previous content lingers in the + // DOM but fades out via the opacity transition. + useEffect(() => { + if (hoverId) setPanelNodeId(hoverId); + else if (pinnedId) setPanelNodeId(pinnedId); + }, [hoverId, pinnedId]); + + // Clean up any pending hover-debounce timer on unmount. + useEffect(() => { + return () => { + if (hoverTimerRef.current != null) { + window.clearTimeout(hoverTimerRef.current); + } + }; + }, []); + + + + + + // 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]); + + // graphData rebuilds whenever weights/minSim/activeCategory change + // (because links + colorBucket depend on them). Without this cache, every + // slider-drag tick would recreate every MapNode with empty imgs/pendingTiers + // Maps, dropping the loaded HTMLImageElements — the canvas would then paint + // fallback rects until each re-fires onload, producing a visible + // flicker across all 327 thumbnails on every onChange tick. We keep a + // stable id → MapNode cache here and reuse imgs/pendingTiers as long as + // thumbUrl is unchanged (theme toggle invalidates). + const nodeCacheRef = useRef>(new Map()); + + // 3. derive graph data from specs/theme (pure — no setState in effect) + const graphData = useMemo<{ + nodes: MapNode[]; + links: MapLink[]; + topTypes: string[]; + typeCounts: Map; + idf: Map; + }>(() => { + if (!specs) { + return { nodes: [], links: [], topTypes: [], typeCounts: new Map(), idf: new Map() }; + } + const idf = computeIDF(specs); + const topTypes = topCategoryValues(specs, activeCategory, CLUSTER_COLORS.length); + const typeCounts = categoryValueCounts(specs, activeCategory); + const cache = nodeCacheRef.current; + const nextCache = new Map(); + const nodes: MapNode[] = specs.map(s => { + const v = primaryCategoryValue(s, activeCategory); + const colorBucket = topTypes.includes(v) ? v : null; + const thumbUrl = selectMapThumbUrl(s, isDark); + const cached = cache.get(s.id); + // Reuse the loaded image cache and pending-fetch tracker when the + // thumbnail URL hasn't changed. weights / minSim / activeCategory + // never affect the thumbnail; only specs (re-fetch) and isDark + // (theme switch -> different preview URL) do. + const reuse = cached && cached.thumbUrl === thumbUrl; + const node: MapNode = { + id: s.id, + title: s.title, + tags: flattenTags(s), + colorBucket, + thumbUrl, + imgs: reuse ? cached.imgs : new Map(), + pendingTiers: reuse ? cached.pendingTiers : new Set(), + }; + nextCache.set(s.id, node); + return node; + }); + nodeCacheRef.current = nextCache; + const links = buildKNNLinks(specs, idf, KNN_K, minSim, weights); + return { nodes, links, topTypes, typeCounts, idf }; + }, [specs, isDark, weights, minSim, 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) + // Precomputed id → node lookup. linkColor/linkWidth fire once per link + // per frame (~1k links), and a graphData.nodes.find() inside each call + // would be O(N²) total per frame; the Map keeps it O(1). + const nodeById = useMemo(() => { + const map = new Map(); + for (const n of graphData.nodes) map.set(n.id, n); + return map; + }, [graphData.nodes]); + + 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]); + + // Track the pinned node's on-screen rect so the DOM-overlay pulse marker + // stays glued to it while the user pans/zooms. Cheap (one RAF tick = a + // graph→screen coord transform + a setState that no-ops on sub-pixel + // diffs), no canvas repaint involved. + useEffect(() => { + if (!pinnedId) { + setPinScreen(null); + return; + } + let raf = 0; + const tick = () => { + const fg = fgRef.current; + const node = nodeById.get(pinnedId) as + | (MapNode & { x?: number; y?: number }) + | undefined; + if (fg && node && node.x != null && node.y != null) { + const sc = fg.graph2ScreenCoords?.(node.x, node.y); + const z = typeof fg.zoom === 'function' ? fg.zoom() : 1; + if (sc) { + const { w: gw, h: gh } = fitToBox(NODE_SIZE, nodeAspectRatio(node)); + const w = gw * z; + const h = gh * z; + setPinScreen(prev => { + const next = { x: sc.x - w / 2, y: sc.y - h / 2, w, h }; + if ( + prev && + Math.abs(prev.x - next.x) < 0.5 && + Math.abs(prev.y - next.y) < 0.5 && + Math.abs(prev.w - next.w) < 0.5 && + Math.abs(prev.h - next.h) < 0.5 + ) { + return prev; + } + return next; + }); + } + } + raf = requestAnimationFrame(tick); + }; + raf = requestAnimationFrame(tick); + return () => cancelAnimationFrame(raf); + }, [pinnedId, graphData]); + + // 5. derive everything the corner hover-panel needs from the (lagged) + // panelNodeId, so the panel can fade out without losing its content. + const panelData = useMemo(() => { + if (!panelNodeId) return null; + const node = nodeById.get(panelNodeId); + if (!node) return null; + const ptTag = node.tags.find(t => t.startsWith('plot_type:')); + const plotType = ptTag ? ptTag.slice('plot_type:'.length) : null; + // Top 4 most distinctive tags by IDF, excluding plot_type (rendered as + // chip) and zero-IDF noise (corpus-common tags zeroed out by computeIDF). + const tags = node.tags + .filter(t => !t.startsWith('plot_type:')) + .map(t => ({ tag: t, w: graphData.idf.get(t) ?? 0 })) + .filter(x => x.w > 0) + .sort((a, b) => b.w - a.w) + .slice(0, 4) + .map(x => x.tag); + return { + title: node.title, + plotType, + chipColor: plotType ? colorFor(plotType, graphData.topTypes) : null, + tags, + // Prefer a higher-res variant for the panel — request 800-tier so the + // preview looks crisp. The browser caches per-URL, so subsequent hovers + // of the same node are instant. + previewUrl: node.thumbUrl ? buildVariantUrl(node.thumbUrl, 800) : null, + }; + }, [panelNodeId, graphData]); + + // Pulse colour: match the pinned node's natural cluster border so the + // ring isn't a foreign green halo when the node itself is e.g. orange. + // Top-N nodes (with a colorBucket) get their cluster color; specs that + // fall outside the top-N (no colorBucket) fall back to the brand primary + // for legibility against the neutral hairline border. + const pinColor = useMemo(() => { + if (!pinnedId) return colors.primary; + const node = nodeById.get(pinnedId); + if (!node) return colors.primary; + return colorFor(node.colorBucket, graphData.topTypes) ?? colors.primary; + }, [pinnedId, nodeById, graphData.topTypes]); + + // 6. Precompute lowercased searchable fields per spec so each keystroke + // only does .includes() checks, not a fresh tag-flatten + lowercase. + const searchHaystacks = useMemo(() => { + if (!specs) return []; + return specs.map(s => ({ + spec: s, + titleL: s.title.toLowerCase(), + idL: s.id.toLowerCase(), + tagsL: flattenTags(s).map(t => t.toLowerCase()), + })); + }, [specs]); + + // 7. Match the search query: every whitespace-separated token must appear + // somewhere (title / id / tag), score weighted by where it hit. Top 8. + const searchMatches = useMemo(() => { + const q = searchQuery.trim().toLowerCase(); + if (!q) return []; + const tokens = q.split(/\s+/).filter(Boolean); + const scored: { spec: SpecMapItem; score: number }[] = []; + for (const h of searchHaystacks) { + let score = 0; + let allMatch = true; + for (const tok of tokens) { + const inTitle = h.titleL.includes(tok); + const inId = h.idL.includes(tok); + const inTags = h.tagsL.some(t => t.includes(tok)); + if (!(inTitle || inId || inTags)) { + allMatch = false; + break; + } + score += inTitle ? 3 : inId ? 2 : 1; + } + if (allMatch) scored.push({ spec: h.spec, score }); + } + scored.sort((a, b) => b.score - a.score || a.spec.title.localeCompare(b.spec.title)); + return scored.slice(0, 8).map(x => x.spec); + }, [searchQuery, searchHaystacks]); + + // Reset the keyboard-cursor whenever the result list shrinks/reshuffles. + useEffect(() => { + setSearchIdx(0); + }, [searchQuery]); + + // Cmd/Ctrl+K focuses the search pill from anywhere on the page. Always + // preventDefault so the browser's own ⌘K (Chrome address bar) doesn't fire. + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { + e.preventDefault(); + searchInputRef.current?.focus(); + searchInputRef.current?.select(); + } + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, []); + + + // 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 }; + + // Detect whether the device has a real hover-capable pointer (mouse, + // trackpad). Touch-only devices never fire onNodeHover, so without this + // check tapping a node would jump straight to the spec page — losing + // the preview-panel + pin UX entirely. On touch, we delay navigation + // until the *second* tap on the same node. + const hasHover = useMediaQuery('(hover: hover)', { noSsr: true }); + + const onNodeClick = (node: MapNode) => { + if (!hasHover && pinnedId !== node.id) { + // Touch device, first tap on a fresh node: pin + open panel — same + // semantics as desktop hover. We deliberately don't fly/zoom: the + // user already sees where they tapped, and zooming in would make + // the on-canvas thumbnail roughly the same size as the panel preview, + // defeating the panel's purpose. Background tap clears the pin. + setPinnedId(node.id); + setHoverId(node.id); + trackEvent('map_node_pin', { spec: node.id }); + return; + } + trackEvent('map_node_click', { spec: node.id }); + navigate(specPath(node.id)); + }; + + // Fly the camera to a node and open its hover panel. Used by the search + // dropdown when the user picks a result. Does nothing until the simulation + // has placed the node (x/y populated) — by the time the user has typed a + // query, the cooldown has long since finished. + const flyTo = (id: string) => { + const fg = fgRef.current; + if (!fg) return; + const node = nodeById.get(id) as + | (MapNode & { x?: number; y?: number }) + | undefined; + if (!node || node.x == null || node.y == null) return; + fg.centerAt?.(node.x, node.y, 800); + fg.zoom?.(2.0, 800); + setHoverId(id); + }; + + const selectMatch = (spec: SpecMapItem) => { + flyTo(spec.id); + setPinnedId(spec.id); + setSearchQuery(''); + setSearchOpen(false); + searchInputRef.current?.blur(); + trackEvent('map_search_select', { spec: spec.id }); + }; + + const ready = graphData.nodes.length > 0 && size.w > 0 && size.h > 0; + + return ( + <> + + map() — anyplot + + + + {/* Header overlay with tiny meta. On phones the search pill takes + the top row so the meta drops to a second row. 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` : ' '} + + + {/* Search pill: top-center mono input with dropdown of top-8 matches. + Cmd/Ctrl+K focuses from anywhere; ArrowUp/Down + Enter selects; + Escape clears. Selecting a match flies the camera to the node and + opens the hover panel. Higher z-index than the other overlays so + the dropdown sits above legend + weights + hover panel. */} + + setSearchQuery((e.target as HTMLInputElement).value)} + onFocus={() => setSearchOpen(true)} + onBlur={() => window.setTimeout(() => setSearchOpen(false), 150)} + onKeyDown={e => { + if (e.key === 'ArrowDown') { + e.preventDefault(); + setSearchIdx(i => Math.min(i + 1, Math.max(0, searchMatches.length - 1))); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setSearchIdx(i => Math.max(i - 1, 0)); + } else if (e.key === 'Enter') { + e.preventDefault(); + const pick = searchMatches[searchIdx]; + if (pick) selectMatch(pick); + } else if (e.key === 'Escape') { + setSearchQuery(''); + searchInputRef.current?.blur(); + } + }} + sx={{ + width: '100%', + boxSizing: 'border-box', + px: 1.25, + py: 0.75, + bgcolor: 'var(--bg-surface)', + border: '1px solid var(--rule)', + borderRadius: '4px', + fontFamily: typography.mono, + fontSize: fontSize.xs, + color: 'var(--ink)', + outline: 'none', + '&::placeholder': { color: 'var(--ink-soft)', opacity: 0.65 }, + '&:focus': { borderColor: colors.primary }, + }} + /> + {searchOpen && searchQuery.trim() && ( + + {searchMatches.length === 0 ? ( + + no matches + + ) : ( + searchMatches.map((s, i) => ( + { + e.preventDefault(); + selectMatch(s); + }} + onMouseEnter={() => setSearchIdx(i)} + sx={{ + px: 1.25, py: 0.75, + cursor: 'pointer', + fontSize: fontSize.xs, + color: 'var(--ink)', + bgcolor: i === searchIdx ? 'var(--bg-page)' : 'transparent', + borderBottom: i < searchMatches.length - 1 ? '1px solid var(--rule)' : 'none', + display: 'flex', + alignItems: 'baseline', + gap: 1, + }} + > + + {s.title} + + + {s.id} + + + )) + )} + + )} + + + {/* 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. */} + {/* Mobile-only legend toggle. Sits in the same row as the meta + line (top: 50) so the top row stays clear for the search pill. */} + {graphData.topTypes.length > 0 && ( + setLegendOpen(o => !o)} + sx={{ + all: 'unset', + display: { xs: 'inline-block', sm: 'none' }, + position: 'absolute', + top: 44, + right: 10, + zIndex: 2, + cursor: 'pointer', + // Bigger touch target since `all: 'unset'` strips the + // default button padding. + padding: '6px 6px', + fontFamily: typography.mono, + fontSize: fontSize.xs, + color: legendOpen ? 'var(--ink)' : 'var(--ink-soft)', + userSelect: 'none', + }} + > + {legendOpen ? 'legend ▾' : 'legend ▸'} + + )} + {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. + column-reverse keeps the toggle pinned at the bottom while the + panel grows upward — without it, opening the panel pushes the + toggle up by ~300 px on mobile, where it can land off-screen. */} + + setWeightsOpen(o => !o)} + sx={{ + all: 'unset', + cursor: 'pointer', + // Bigger touch target on phones — `all: 'unset'` strips the + // default button padding, so the bare text is only ~16 px + // tall by default which is below the recommended 40 px. + padding: { xs: '6px 4px', sm: '0' }, + 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, + // Compact knob & rail height so the rows can pack + // tighter on phones without the slider feeling cramped. + py: { xs: 0.25, sm: 0.5 }, + '& .MuiSlider-rail': { opacity: 0.25 }, + }} + /> + + {weights[cat].toFixed(1)} + + + ))} + {/* Edge-threshold slider — controls KNN_MIN_SIM, the cutoff + below which a candidate KNN edge is dropped. Lower = denser + graph (more cross-cluster bridges); higher = sparser graph + (cleaner clusters but more isolated outliers). */} + + + threshold + + setMinSim(v as number)} + min={MIN_SIM_BOUNDS.min} + max={MIN_SIM_BOUNDS.max} + step={MIN_SIM_BOUNDS.step} + size="small" + sx={{ + flex: 1, + color: colors.primary, + py: { xs: 0.25, sm: 0.5 }, + '& .MuiSlider-rail': { opacity: 0.25 }, + }} + /> + + {minSim.toFixed(2)} + + + + { + setWeights(DEFAULT_CATEGORY_WEIGHT); + setMinSim(DEFAULT_MIN_SIM); + }} + sx={{ + all: 'unset', + cursor: 'pointer', + fontFamily: typography.mono, + fontSize: fontSize.xs, + color: 'var(--ink-soft)', + '&:hover': { color: colors.primary }, + }} + > + reset + + + + )} + + + {/* Pin marker: a CSS-pulsing outline overlaid on the searched node. + DOM-driven so the pulse animation runs independently of FG2D's + render loop (which pauses once the simulation cools down). The + position is recomputed every frame from graph2ScreenCoords so it + tracks pan/zoom. pointerEvents:none keeps it from intercepting + clicks/hovers on the node underneath. */} + {pinScreen && ( + + )} + + {/* Hover panel: fixed bottom-right, fades in/out on hover. Lives in + the DOM so its size is independent of zoom level and never clips + at the viewport edge. Pointer-transparent so it never steals + hover detection from canvas nodes underneath. */} + + {panelData && ( + <> + {panelData.previewUrl ? ( + + ) : ( + + )} + + + {panelData.title} + + {panelData.plotType && ( + + + + {panelData.plotType} + + + )} + {panelData.tags.length > 0 && ( + + {panelData.tags.map(t => ( + + {t} + + ))} + + )} + + + )} + + + {/* 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); + // Hovered node itself doesn't grow — the rich preview lives in a + // DOM corner panel. Direct neighbors get a small bump so the + // relationship is still legible on the canvas. + 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; when a legend + // entry is hovered, links between same-cluster nodes stay + // visible while everything else fades. + // + // NOTE: ForceGraph2D mutates link.source / link.target from + // string IDs to actual node objects after the first simulation + // tick. So `l.source === hoverId` would only ever match before + // the first tick. linkEndId() reads through the object. + linkColor={(l: MapLink) => { + const sId = linkEndId(l.source); + const tId = linkEndId(l.target); + const involved = hoverId && (sId === hoverId || tId === hoverId); + if (involved) { + // Match the cluster color of the hovered node so the burst + // of highlighted edges feels coherent with the frame. + const hoverNode = nodeById.get(hoverId); + return colorFor(hoverNode?.colorBucket ?? null, graphData.topTypes) ?? colors.primary; + } + if (hoverType) { + const sBucket = sId ? nodeById.get(sId)?.colorBucket : undefined; + const tBucket = tId ? nodeById.get(tId)?.colorBucket : undefined; + 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)'; + } + // When a node is hovered, keep non-involved edges faintly + // visible so the user can still read the surrounding cluster + // structure — too much fade-out makes the map feel broken. + if (hoverId) return isDark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.06)'; + return isDark ? 'rgba(255,255,255,0.10)' : 'rgba(0,0,0,0.13)'; + }} + linkWidth={(l: MapLink) => { + const sId = linkEndId(l.source); + const tId = linkEndId(l.target); + const involved = hoverId && (sId === hoverId || tId === hoverId); + if (involved) return Math.max(1.5, (l.weight ?? 0.3) * 3); + return Math.max(0.4, (l.weight ?? 0.3) * 1.5); + }} + onNodeClick={onNodeClick} + // Background tap clears the pin AND closes the preview panel. + // On desktop ForceGraph2D fires onNodeHover(null) when the + // pointer leaves a node, but on touch there's no hover event, + // so we need to clear hoverId explicitly here. Doing it on + // both is a harmless no-op for the desktop path. + onBackgroundClick={() => { + setPinnedId(null); + setHoverId(null); + }} + onNodeHover={(n: MapNode | null) => { + if (hoverTimerRef.current != null) { + window.clearTimeout(hoverTimerRef.current); + hoverTimerRef.current = null; + } + if (n) { + hoverTimerRef.current = window.setTimeout(() => { + setHoverId(n.id); + hoverTimerRef.current = null; + }, 100); + } else { + setHoverId(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) + ); + // Mild centering force so disconnected outliers (no KNN edges + // because all sims < threshold) drift back toward the cluster + // mass instead of vanishing to the corners. Strength is well + // below the default 1.0 so cluster shapes stay intact. + fg.d3Force('center')?.strength?.(CENTER_GRAVITY); + fg.__forcesWired = true; + fg.d3ReheatSimulation?.(); + }} + /> + )} + + {/* 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/docs/reference/plausible.md b/docs/reference/plausible.md index 2064595f18..979612ba74 100644 --- a/docs/reference/plausible.md +++ b/docs/reference/plausible.md @@ -135,6 +135,9 @@ https://anyplot.ai/{spec_id}/{language}/{library}/{category}/{value}/... | `view_mode_change` | `mode`, `library` | SpecDetailView.tsx | User toggles preview ↔ interactive view inside a spec detail. `mode` ∈ `preview`, `interactive`. Fires on every toggle in either direction (cf. `open_interactive`, which only fires when the interactive HTML is opened in a new tab). | | `library_click` | `source`, `library` | LibrariesPage.tsx | User clicks a library card on `/libraries` to navigate to its filtered plots view. `source` is `libraries_page` from this entry point. | | `stats_top_impl_click` | `spec`, `library` | StatsPage.tsx | User clicks a "top implementation" thumbnail on `/stats` to jump into its spec detail. | +| `map_node_click` | `spec` | MapPage.tsx | User clicks a node on `/map` (or, on touch, second tap on an already-pinned node) to navigate to its spec detail. | +| `map_node_pin` | `spec` | MapPage.tsx | Touch device only: first tap on a node opens the preview panel + pin marker without navigating. A second tap on the same node fires `map_node_click` and navigates. | +| `map_search_select` | `spec` | MapPage.tsx | User picks a result from the `/map` search dropdown (`⌘K` / `Ctrl+K` opens it). The camera flies to the node and the preview panel opens. | ### Landing Page Navigation (`nav_click`) @@ -355,6 +358,9 @@ To see event properties in Plausible dashboard, you **MUST** register them as cu | `view_mode_change` | Custom Event | Track preview ↔ interactive toggles in spec detail | | `library_click` | Custom Event | Track library-card clicks on the libraries page | | `stats_top_impl_click` | Custom Event | Track clicks on top-quality implementation thumbnails on /stats | +| `map_node_click` | Custom Event | Track navigation clicks from `/map` into a spec detail | +| `map_node_pin` | Custom Event | Track touch users opening the preview panel on `/map` (first tap) | +| `map_search_select` | Custom Event | Track use of the `/map` search-and-fly-to feature | | `og_image_view` | Custom Event | Track og:image requests from social media bots | | `LCP` | Custom Event | Largest Contentful Paint (Core Web Vital) | | `CLS` | Custom Event | Cumulative Layout Shift (Core Web Vital) | @@ -448,12 +454,15 @@ User lands on anyplot.ai | `view_mode_change` | `mode`, `library` | SpecDetailView.tsx | | `library_click` | `source`, `library` | LibrariesPage.tsx | | `stats_top_impl_click` | `spec`, `library` | StatsPage.tsx | +| `map_node_click` | `spec` | MapPage.tsx | +| `map_node_pin` | `spec` | MapPage.tsx | +| `map_search_select` | `spec` | MapPage.tsx | | `LCP` | `value`, `rating` | reportWebVitals.ts | | `CLS` | `value`, `rating` | reportWebVitals.ts | | `INP` | `value`, `rating` | reportWebVitals.ts | | `og_image_view` | `page`, `platform`, `spec`?, `language`?, `library`?, `filter_*`? | api/analytics.py (server-side) | -**Total: 25 client-side + 1 server-side = 26 events** +**Total: 28 client-side + 1 server-side = 29 events** > Every pageview and event additionally carries a `theme` ambient prop (`dark` / > `light`). Set in `RootLayout` via `setAnalyticsAmbientProps` whenever the user 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.""" diff --git a/tests/unit/api/test_seo_helpers.py b/tests/unit/api/test_seo_helpers.py index 4f343a0132..1caffc5255 100644 --- a/tests/unit/api/test_seo_helpers.py +++ b/tests/unit/api/test_seo_helpers.py @@ -38,6 +38,7 @@ def test_empty_specs(self) -> None: assert "https://anyplot.ai/plots" in result assert "https://anyplot.ai/specs" in result assert "https://anyplot.ai/libraries" in result + assert "https://anyplot.ai/map" in result assert "https://anyplot.ai/palette" in result assert "https://anyplot.ai/about" in result assert "https://anyplot.ai/mcp" in result