diff --git a/app/src/pages/LandingPage.test.tsx b/app/src/pages/LandingPage.test.tsx index eff7c2d443..062e35a5c7 100644 --- a/app/src/pages/LandingPage.test.tsx +++ b/app/src/pages/LandingPage.test.tsx @@ -114,4 +114,12 @@ describe('LandingPage', () => { await user.click(screen.getByText(/Okabe/)); expect(trackEvent).toHaveBeenCalledWith('nav_click', expect.objectContaining({ source: 'palette_okabe_ito' })); }); + + it('tracks the map teaser visual click', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByLabelText(/Open the interactive specifications map/)); + expect(trackEvent).toHaveBeenCalledWith('nav_click', { source: 'map_teaser_preview', target: '/map' }); + }); }); diff --git a/app/src/pages/LandingPage.tsx b/app/src/pages/LandingPage.tsx index 78d4b28540..b64f211c7b 100644 --- a/app/src/pages/LandingPage.tsx +++ b/app/src/pages/LandingPage.tsx @@ -61,6 +61,8 @@ export function LandingPage() { + + + map} linkText="map.explore()" linkTo="/map" /> + + + + {specCount != null ? `all ${specCount} specs` : 'every spec'} on a single canvas —{' '} + + clustered by tag similarity, coloured by plot type, searchable. + {' '} + zoom in for thumbnails, hover for details, click to open the spec. + + + trackEvent('nav_click', { source: 'map_teaser_preview', target: '/map' })} + sx={{ + display: 'block', + textDecoration: 'none', + color: 'inherit', + border: '1px solid var(--rule)', + borderRadius: 2, + bgcolor: 'var(--bg-surface)', + overflow: 'hidden', + position: 'relative', + transition: 'transform 0.25s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.25s, border-color 0.25s', + '&::before': { + content: '""', + position: 'absolute', + top: 0, + left: 0, + right: 0, + height: '2px', + background: colors.primary, + transform: 'scaleX(0)', + transformOrigin: 'left', + transition: 'transform 0.35s cubic-bezier(0.4, 0, 0.2, 1)', + zIndex: 1, + }, + '&:hover': { + transform: 'translateY(-3px)', + boxShadow: '0 16px 32px -12px rgba(0,0,0,0.08)', + borderColor: 'rgba(0, 158, 115, 0.2)', + '&::before': { transform: 'scaleX(1)' }, + }, + }} + aria-label="Open the interactive specifications map" + > + + + + + ); +} + +/** + * Decorative SVG mini-cluster — three loose groups of circles in Okabe-Ito + * cluster colours, connected by hairline edges. Static (no force simulation, + * no data fetch) so it's cheap to render; the aspect ratio matches the + * featured-thumb cards (16:10) for visual rhythm. Positions are hand-picked + * to read as "three blobs gently bridged" — like the real map at low zoom. + */ +const MAP_PREVIEW_NODES: Array<{ x: number; y: number; r: number; cluster: 0 | 1 | 2 | 3 }> = [ + // Cluster A — left, brand green + { x: 90, y: 95, r: 9, cluster: 0 }, + { x: 70, y: 130, r: 7, cluster: 0 }, + { x: 115, y: 125, r: 8, cluster: 0 }, + { x: 95, y: 160, r: 6, cluster: 0 }, + { x: 135, y: 95, r: 6, cluster: 0 }, + { x: 60, y: 100, r: 5, cluster: 0 }, + // Cluster B — top-right, vermillion + { x: 320, y: 70, r: 9, cluster: 1 }, + { x: 350, y: 100, r: 7, cluster: 1 }, + { x: 295, y: 105, r: 6, cluster: 1 }, + { x: 365, y: 60, r: 5, cluster: 1 }, + { x: 330, y: 130, r: 7, cluster: 1 }, + // Cluster C — bottom-right, blue + { x: 290, y: 200, r: 8, cluster: 2 }, + { x: 325, y: 220, r: 9, cluster: 2 }, + { x: 360, y: 195, r: 6, cluster: 2 }, + { x: 305, y: 235, r: 6, cluster: 2 }, + { x: 350, y: 240, r: 5, cluster: 2 }, + // Bridges — neutral nodes + { x: 200, y: 130, r: 6, cluster: 3 }, + { x: 225, y: 175, r: 5, cluster: 3 }, + { x: 175, y: 165, r: 4, cluster: 3 }, +]; + +const MAP_PREVIEW_LINKS: Array<[number, number]> = [ + // Cluster A internal + [0, 1], [0, 2], [1, 2], [2, 3], [0, 4], [1, 5], [0, 5], + // Cluster B internal + [6, 7], [6, 8], [7, 9], [6, 10], [7, 10], + // Cluster C internal + [11, 12], [12, 13], [11, 14], [13, 14], [14, 15], [12, 15], + // Bridges via neutrals + [2, 16], [16, 8], [3, 17], [17, 11], [16, 17], [17, 18], [18, 3], +]; + +const CLUSTER_PALETTE = ['#009E73', '#D55E00', '#0072B2'] as const; + +function MapClusterPreview() { + return ( + + ); +} + /** * Palette section — mirrors SpecsSection's two-column layout: description on * the left, labelled palette strip on the right. diff --git a/app/src/pages/MapPage.helpers.ts b/app/src/pages/MapPage.helpers.ts index 7bc770b103..172dd7d873 100644 --- a/app/src/pages/MapPage.helpers.ts +++ b/app/src/pages/MapPage.helpers.ts @@ -130,22 +130,23 @@ export type TagCategory = (typeof TAG_CATEGORIES)[number]; * 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. + * The defaults privilege plot_type (2.0) so it drives the main clusters. + * Secondary semantic axes get non-zero contributions: data_type (0.6), + * features (0.5), domain (0.3), techniques (0.2). Remaining categories + * sit on a 0.1 floor so they still nudge the layout instead of being + * ignored entirely. Users can slide any of these up via the weights + * panel. */ 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, + techniques: 0.2, + patterns: 0.1, + dataprep: 0.1, + dependencies: 0.1, + domain: 0.3, + data_type: 0.6, + styling: 0.1, }; function categoryOf(prefixedTag: string): string { diff --git a/app/src/pages/MapPage.test.tsx b/app/src/pages/MapPage.test.tsx index 7210d6c3bb..7543806303 100644 --- a/app/src/pages/MapPage.test.tsx +++ b/app/src/pages/MapPage.test.tsx @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { forwardRef, useImperativeHandle } from 'react'; -import { render, screen, waitFor } from '../test-utils'; +import { act, render, screen, waitFor } from '../test-utils'; import { MapPage } from './MapPage'; @@ -28,9 +29,10 @@ vi.mock('../hooks/useLayoutContext', () => ({ })); // Default to "(hover: hover)" matching → desktop behaviour. Touch-specific -// branches (e.g. tap-to-pin) need a per-test override. +// branches (e.g. tap-to-pin) need a per-test override via mockHasHover. +const mockHasHover = { current: true }; vi.mock('@mui/material/useMediaQuery', () => ({ - default: () => true, + default: () => mockHasHover.current, })); // Capture the props passed to ForceGraph2D so individual callbacks can be exercised @@ -39,9 +41,31 @@ vi.mock('@mui/material/useMediaQuery', () => ({ type FgProps = Record; const lastFgProps: { current: FgProps | null } = { current: null }; +// Mock instance returned via ref. The page calls imperative methods like +// `fgRef.current?.centerAt(...)` from `onEngineStop`; without forwardRef the +// ref would be null and those branches would silently early-return. +type FgInstance = { + centerAt: ReturnType; + zoom: ReturnType; + zoomToFit: ReturnType; + d3Force: ReturnType; + d3ReheatSimulation: ReturnType; + refresh: ReturnType; + __forcesWired?: boolean; +}; +const fgInstance: FgInstance = { + centerAt: vi.fn(), + zoom: vi.fn().mockReturnValue(1), + zoomToFit: vi.fn(), + d3Force: vi.fn().mockReturnValue({ strength: vi.fn().mockReturnThis(), distance: vi.fn().mockReturnThis() }), + d3ReheatSimulation: vi.fn(), + refresh: vi.fn(), +}; + vi.mock('react-force-graph-2d', () => ({ - default: (props: FgProps) => { + default: forwardRef((props, ref) => { lastFgProps.current = props; + useImperativeHandle(ref, () => fgInstance, []); const data = props.graphData as { nodes: unknown[]; links: unknown[] }; return (
({ data-link-count={data.links.length} /> ); - }, + }), })); @@ -137,6 +161,14 @@ describe('MapPage', () => { mockNavigate.mockReset(); mockTrackEvent.mockReset(); lastFgProps.current = null; + mockHasHover.current = true; + fgInstance.centerAt.mockReset(); + fgInstance.zoom.mockReset().mockReturnValue(1); + fgInstance.zoomToFit.mockReset(); + fgInstance.d3Force.mockReset().mockReturnValue({ strength: vi.fn().mockReturnThis(), distance: vi.fn().mockReturnThis() }); + fgInstance.d3ReheatSimulation.mockReset(); + fgInstance.refresh.mockReset(); + fgInstance.__forcesWired = undefined; vi.stubGlobal('ResizeObserver', MockResizeObserver); }); @@ -269,4 +301,84 @@ describe('MapPage', () => { expect(large).toBeGreaterThan(small); expect(small).toBeGreaterThan(0); }); + + it('seeds initial node positions per cluster (warm start for the simulation)', async () => { + mockFetchSuccess(); + render(); + await waitFor(() => expect(lastFgProps.current).not.toBeNull()); + + const nodes = (lastFgProps.current!.graphData as { nodes: Array<{ id: string; x?: number; y?: number; vx?: number; vy?: number }> }).nodes; + // Every node should have a numeric seed position before FG2D ever ticks the simulation — + // without seeding, FG2D's random initialiser would leave x/y undefined here. + for (const n of nodes) { + expect(typeof n.x).toBe('number'); + expect(typeof n.y).toBe('number'); + expect(Number.isFinite(n.x as number)).toBe(true); + expect(Number.isFinite(n.y as number)).toBe(true); + } + // Same plot_type (= colorBucket) should land near the same centroid; nodes from + // different buckets should land further apart on average. Take the two scatters + // (bucketed together) vs. line-basic and compare distances. + const scatterA = nodes.find(n => n.id === 'scatter-basic')!; + const scatterB = nodes.find(n => n.id === 'scatter-color-mapped')!; + const line = nodes.find(n => n.id === 'line-basic')!; + const dist = (a: typeof scatterA, b: typeof scatterA) => + Math.hypot((a.x ?? 0) - (b.x ?? 0), (a.y ?? 0) - (b.y ?? 0)); + expect(dist(scatterA, scatterB)).toBeLessThan(dist(scatterA, line)); + }); + + it('shows the settling overlay until the simulation cools, then hides it', async () => { + mockFetchSuccess(); + render(); + await waitFor(() => expect(screen.getByTestId('force-graph-2d')).toBeInTheDocument()); + + // Gate is visible while the engine is still cooling. + expect(screen.getByText(/arranging/i)).toBeInTheDocument(); + + // Engine stops → settled flips → overlay disappears. + const onEngineStop = lastFgProps.current!.onEngineStop as () => void; + act(() => onEngineStop()); + await waitFor(() => expect(screen.queryByText(/arranging/i)).not.toBeInTheDocument()); + }); + + it('frames the bbox via centerAt + zoom on engine stop', async () => { + mockFetchSuccess(); + render(); + await waitFor(() => expect(lastFgProps.current).not.toBeNull()); + + // The percentile-trimmed fit reads node x/y; seed positions guarantee these are set. + const onEngineStop = lastFgProps.current!.onEngineStop as () => void; + act(() => onEngineStop()); + + expect(fgInstance.centerAt).toHaveBeenCalledTimes(1); + expect(fgInstance.zoom).toHaveBeenCalled(); + // Animation duration is 0 → instant, hidden behind the gate. + const centerCall = fgInstance.centerAt.mock.calls[0]; + expect(centerCall[2]).toBe(0); + }); + + it('first tap pins on touch devices, second tap navigates', async () => { + mockHasHover.current = false; + mockFetchSuccess(); + render(); + await waitFor(() => expect(lastFgProps.current).not.toBeNull()); + + // First tap: pin (no navigation, analytics fires map_node_pin). + act(() => { + const onNodeClick = lastFgProps.current!.onNodeClick as (n: { id: string }) => void; + onNodeClick({ id: 'scatter-basic' }); + }); + expect(mockNavigate).not.toHaveBeenCalled(); + expect(mockTrackEvent).toHaveBeenCalledWith('map_node_pin', { spec: 'scatter-basic' }); + + // Second tap on the same node: navigate. After the first tap React + // re-rendered MapPage with the new pinnedId, so lastFgProps.current + // now holds a fresh onNodeClick closure that reads the updated state. + act(() => { + 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' }); + }); }); diff --git a/app/src/pages/MapPage.tsx b/app/src/pages/MapPage.tsx index e0b45abefc..71d663f394 100644 --- a/app/src/pages/MapPage.tsx +++ b/app/src/pages/MapPage.tsx @@ -40,9 +40,10 @@ import { 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 +const COOLDOWN_TICKS = 450; // a touch over the original 400 — just enough to let the slower alpha decay finish its work before the cap kicks in +const CLUSTER_SEED_RADIUS = 600; // distance from origin where each colorBucket cluster's centroid is initially placed +const CLUSTER_SEED_JITTER = 150; // per-node random offset around the cluster centroid — small enough to keep clusters identifiable, large enough that collision can settle them +const KNN_K = 8; // 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 @@ -164,6 +165,13 @@ export function MapPage() { // 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); + // settled = true once the force simulation has finished cooling. Until + // then, the canvas is overlaid by a subtle gate that swallows pointer + // input — a click on a still-moving node would otherwise pin the wrong + // spec by the time the simulation settles around it. Resets to false + // whenever graphData re-derives (filter / weight / category change), so + // the gate also covers subsequent re-layouts. + const [settled, setSettled] = useState(false); // Search-pill state. searchOpen controls dropdown visibility (separate // from focus so we can keep showing matches briefly while a click is in @@ -269,24 +277,54 @@ export function MapPage() { const typeCounts = categoryValueCounts(specs, activeCategory); const cache = nodeCacheRef.current; const nextCache = new Map(); - const nodes: MapNode[] = specs.map(s => { + // Pre-compute one centroid per colorBucket on a circle around the origin. + // Seeding each node near its cluster centroid (instead of the FG2D + // default of random positions everywhere) gives the simulation a warm + // start: clusters don't have to first separate from a uniform soup, and + // the same number of cooldown ticks now produces visibly cleaner + // separation. Null-bucket nodes sit at the origin and let the link force + // pull them toward whatever clusters they connect to. + const clusterCentroids = new Map(); + topTypes.forEach((t, i) => { + const angle = (i / topTypes.length) * Math.PI * 2; + clusterCentroids.set(t, { + x: Math.cos(angle) * CLUSTER_SEED_RADIUS, + y: Math.sin(angle) * CLUSTER_SEED_RADIUS, + }); + }); + // Hash-based jitter so seed positions are stable across re-renders for + // the same spec id — avoids reshuffling on filter changes. + const jitter = (id: string, salt: number) => { + let h = salt; + for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) | 0; + return ((h & 0xffff) / 0xffff - 0.5) * 2 * CLUSTER_SEED_JITTER; + }; + const nodes: (MapNode & { x?: number; y?: number; vx?: number; vy?: number })[] = 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 cached = cache.get(s.id) as + | (MapNode & { x?: number; y?: number; vx?: number; vy?: number }) + | undefined; const reuse = cached && cached.thumbUrl === thumbUrl; - const node: MapNode = { + // Warm-start preference: keep the simulation's last x/y if we have it + // (filter / weight tweaks reuse positions and refine in place). Cold + // start: seed from the cluster centroid + stable per-id jitter. + const seedCenter = colorBucket ? clusterCentroids.get(colorBucket) : null; + const x = cached?.x ?? (seedCenter ? seedCenter.x + jitter(s.id, 1) : jitter(s.id, 3)); + const y = cached?.y ?? (seedCenter ? seedCenter.y + jitter(s.id, 2) : jitter(s.id, 5)); + const node: MapNode & { x: number; y: number; vx: number; vy: number } = { id: s.id, title: s.title, tags: flattenTags(s), colorBucket, thumbUrl, - imgs: reuse ? cached.imgs : new Map(), - pendingTiers: reuse ? cached.pendingTiers : new Set(), + imgs: reuse ? cached!.imgs : new Map(), + pendingTiers: reuse ? cached!.pendingTiers : new Set(), + x, + y, + vx: cached?.vx ?? 0, + vy: cached?.vy ?? 0, }; nextCache.set(s.id, node); return node; @@ -296,6 +334,16 @@ export function MapPage() { return { nodes, links, topTypes, typeCounts, idf }; }, [specs, isDark, weights, minSim, activeCategory]); + // Re-arm the settling gate whenever graphData re-derives — FG2D reheats + // the simulation in response, and we want the gate to cover the new + // cooling phase the same way it covers the initial one. No-op on the + // very first render (settled is already false) and while specs are + // still loading. + useEffect(() => { + if (graphData.nodes.length === 0) return; + setSettled(false); + }, [graphData]); + // Eager-load the 400-tier thumbnails so something paints fast. Higher tiers // are fetched lazily from nodeCanvasObject when the user zooms in. useEffect(() => { @@ -982,11 +1030,11 @@ export function MapPage() { bottom: { xs: 8, sm: 16 }, right: { xs: 16, sm: 32, md: 64, lg: 96 }, zIndex: 3, - // Phones: ~half the width, no tags. Otherwise the panel covers - // the searched node + its connection lines after fly-to. Tablet+: - // full 280 px panel as before. - width: { xs: 160, sm: 280 }, - maxWidth: { xs: 'calc(60vw - 32px)', sm: 'calc(100vw - 64px)' }, + // Phones + tablets: ~half the width, no tags. Otherwise the + // panel covers the searched node + its connection lines after + // fly-to. Desktop only: full 280 px panel with tag chips. + width: { xs: 160, md: 280 }, + maxWidth: { xs: 'calc(60vw - 32px)', md: 'calc(100vw - 64px)' }, border: '1px solid var(--rule)', borderRadius: '4px', bgcolor: 'var(--bg-surface)', @@ -1009,16 +1057,16 @@ export function MapPage() { display: 'block', width: '100%', height: 'auto', - maxHeight: { xs: 110, sm: 200 }, + maxHeight: { xs: 110, md: 200 }, objectFit: 'contain', bgcolor: isDark ? '#0a0a08' : '#FFFDF6', }} /> ) : ( - + )} 0 && ( n.title} // Boost global repulsion so nodes aren't crammed into a blob. d3VelocityDecay={0.35} - d3AlphaDecay={0.0228} + d3AlphaDecay={0.018} nodeCanvasObject={(node, ctx, globalScale) => { const n = node as WithCoords; if (n.x == null || n.y == null) return; @@ -1222,19 +1270,47 @@ export function MapPage() { } }} 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. + // Frame the dense cluster to ~80% of the viewport — instantly + // (0 ms), so the camera move happens behind the still-active + // gate overlay and the user just sees the final framing when + // `settled` flips. The trick is bounding the *5th–95th + // percentile* of node coordinates instead of the full bbox: + // a couple of far-flung outliers (typically null-bucket specs + // with no strong KNN edges) would otherwise dominate the bbox + // and shrink the readable center to half its size. Outliers + // remain reachable via pan. 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); + if (fg) { + const xs: number[] = []; + const ys: number[] = []; + for (const n of graphData.nodes as Array) { + if (n.x != null && n.y != null) { + xs.push(n.x); + ys.push(n.y); + } + } + if (xs.length > 0) { + xs.sort((a, b) => a - b); + ys.sort((a, b) => a - b); + const trim = 0.05; + const lo = Math.floor(xs.length * trim); + const hi = Math.floor(xs.length * (1 - trim)); + const minX = xs[lo], maxX = xs[hi], minY = ys[lo], maxY = ys[hi]; + const cx = (minX + maxX) / 2; + const cy = (minY + maxY) / 2; + const bboxW = Math.max(1, maxX - minX); + const bboxH = Math.max(1, maxY - minY); + const padding = Math.round(Math.min(size.w, size.h) * 0.1); + const fitZoom = Math.min( + (size.w - 2 * padding) / bboxW, + (size.h - 2 * padding) / bboxH, + ); + fg.centerAt?.(cx, cy, 0); + fg.zoom?.(fitZoom, 0); } - }, 700); + } + setSettled(true); }} // Wire up the custom forces once the imperative ref is available. // onRenderFramePre fires every frame; the __forcesWired guard makes @@ -1275,6 +1351,52 @@ export function MapPage() { /> )} + {/* Settling gate: visible while specs are loaded but the simulation + hasn't finished cooling. Sits on top of the canvas, swallows + pointer events, and shows a small spinner in the corner so the + user sees that the layout is still resolving. Drops as soon as + `settled` flips on engine stop. */} + {ready && !settled && ( + + )} + {/* a11y fallback: visually-hidden list so screen readers + keyboard users can still reach every spec from this page. */}