((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 && (
+
+
+
+ arranging…
+
+
+ )}
+
{/* a11y fallback: visually-hidden list so screen readers + keyboard users
can still reach every spec from this page. */}