Skip to content

Commit 47c210f

Browse files
feat(landing): add map teaser section linking to /map (#5649)
## Summary Adds a new section to the landing page that teases the recently introduced `/map` page and links to it. Sits between `SpecsSection` and `LibrariesSection` — the natural follow-up to the curated featured grid is "and here's the visual way to browse all of them". - Two-column layout that mirrors `SpecsSection` / `PaletteSection`: short description on the left, decorative preview on the right. - Description copy is dynamic — uses the live spec count when available (`all 327 specs on a single canvas — clustered by tag similarity, coloured by plot type, searchable.`). - Preview is a hand-positioned SVG of three loose clusters connected by hairlines, in the same Okabe-Ito palette as `MapPage`'s `CLUSTER_COLORS`. Purely static — no data fetch, no force simulation — so the landing page stays cheap. - Whole right column is a `RouterLink` to `/map` with the same hover treatment as the featured thumbs (lift + green accent bar). A secondary `map.open()` `MethodLink` lives under the description for keyboard users. - Analytics: `nav_click` events with `source: 'map_teaser_link'` (text link) and `source: 'map_teaser_preview'` (visual). The `SectionHeader` link already tracks itself. ## Design proposal I weighed three options for the right column: 1. **Live mini-graph** — re-render `ForceGraph2D` at small size. Rejected: ~70 KB gzip on the landing page, force simulation cost, layout flicker. 2. **Real screenshot** — would need a curated PNG asset that goes stale every time the catalog grows. 3. **Static SVG cluster** *(picked)* — light, on-brand, ages gracefully, communicates the clustering aesthetic without pretending to be the real thing. ## Test plan - [x] `yarn type-check` — clean - [x] `yarn test src/pages/LandingPage.test.tsx` — 5/5 pass (existing tests still hit the right elements) - [x] `yarn build` — landing page bundle 24.51 kB → 7.23 kB gzip - [x] `yarn lint src/pages/LandingPage.tsx` — clean (pre-existing warnings in other files unaffected) - [ ] Manual smoke: open `/`, scroll to map section, verify hover lift + click navigates to `/map` - [ ] Manual smoke: light + dark theme — palette, surfaces, rule borders all read correctly https://claude.ai/code/session_011dPWC8Z43EGZdj6rrE9gav --- _Generated by [Claude Code](https://claude.ai/code/session_011dPWC8Z43EGZdj6rrE9gav)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6160622 commit 47c210f

5 files changed

Lines changed: 466 additions & 52 deletions

File tree

app/src/pages/LandingPage.test.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,4 +114,12 @@ describe('LandingPage', () => {
114114
await user.click(screen.getByText(/Okabe/));
115115
expect(trackEvent).toHaveBeenCalledWith('nav_click', expect.objectContaining({ source: 'palette_okabe_ito' }));
116116
});
117+
118+
it('tracks the map teaser visual click', async () => {
119+
const user = userEvent.setup();
120+
render(<LandingPage />);
121+
122+
await user.click(screen.getByLabelText(/Open the interactive specifications map/));
123+
expect(trackEvent).toHaveBeenCalledWith('nav_click', { source: 'map_teaser_preview', target: '/map' });
124+
});
117125
});

app/src/pages/LandingPage.tsx

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ export function LandingPage() {
6161

6262
<SpecsSection specCount={stats?.specs} featured={featured} />
6363

64+
<MapSection specCount={stats?.specs} />
65+
6466
<LibrariesSection
6567
libraries={librariesData}
6668
onLibraryClick={handleLibraryClick}
@@ -72,6 +74,175 @@ export function LandingPage() {
7274
);
7375
}
7476

77+
/**
78+
* Map section — teases the interactive force-directed map at /map. Mirrors
79+
* SpecsSection / PaletteSection two-column layout: short description on the
80+
* left, decorative SVG cluster preview on the right. The preview is purely
81+
* static (no data fetch, no force simulation) so it stays cheap on the
82+
* landing page; it only hints at the real map's clustering aesthetic using
83+
* the same Okabe-Ito palette.
84+
*/
85+
function MapSection({ specCount }: { specCount?: number }) {
86+
const { trackEvent } = useAnalytics();
87+
return (
88+
<Box sx={{ py: { xs: 2, md: 3 } }}>
89+
<SectionHeader prompt="❯" title={<em>map</em>} linkText="map.explore()" linkTo="/map" />
90+
91+
<Box
92+
sx={{
93+
display: 'grid',
94+
gridTemplateColumns: { xs: 'minmax(0, 1fr)', md: 'minmax(0, 1.1fr) minmax(0, 1fr)' },
95+
gap: { xs: 4, md: 8, lg: 12 },
96+
alignItems: 'center',
97+
}}
98+
>
99+
<Box
100+
sx={{
101+
fontFamily: typography.serif,
102+
fontSize: { xs: '1rem', md: '1.25rem' },
103+
lineHeight: 1.55,
104+
color: 'var(--ink-soft)',
105+
fontWeight: 300,
106+
maxWidth: '52ch',
107+
}}
108+
>
109+
{specCount != null ? `all ${specCount} specs` : 'every spec'} on a single canvas —{' '}
110+
<Box component="span" sx={{ color: 'var(--ink)' }}>
111+
clustered by tag similarity, coloured by plot type, searchable.
112+
</Box>{' '}
113+
zoom in for thumbnails, hover for details, click to open the spec.
114+
</Box>
115+
116+
<Box
117+
component={RouterLink}
118+
to="/map"
119+
onClick={() => trackEvent('nav_click', { source: 'map_teaser_preview', target: '/map' })}
120+
sx={{
121+
display: 'block',
122+
textDecoration: 'none',
123+
color: 'inherit',
124+
border: '1px solid var(--rule)',
125+
borderRadius: 2,
126+
bgcolor: 'var(--bg-surface)',
127+
overflow: 'hidden',
128+
position: 'relative',
129+
transition: 'transform 0.25s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.25s, border-color 0.25s',
130+
'&::before': {
131+
content: '""',
132+
position: 'absolute',
133+
top: 0,
134+
left: 0,
135+
right: 0,
136+
height: '2px',
137+
background: colors.primary,
138+
transform: 'scaleX(0)',
139+
transformOrigin: 'left',
140+
transition: 'transform 0.35s cubic-bezier(0.4, 0, 0.2, 1)',
141+
zIndex: 1,
142+
},
143+
'&:hover': {
144+
transform: 'translateY(-3px)',
145+
boxShadow: '0 16px 32px -12px rgba(0,0,0,0.08)',
146+
borderColor: 'rgba(0, 158, 115, 0.2)',
147+
'&::before': { transform: 'scaleX(1)' },
148+
},
149+
}}
150+
aria-label="Open the interactive specifications map"
151+
>
152+
<MapClusterPreview />
153+
</Box>
154+
</Box>
155+
</Box>
156+
);
157+
}
158+
159+
/**
160+
* Decorative SVG mini-cluster — three loose groups of circles in Okabe-Ito
161+
* cluster colours, connected by hairline edges. Static (no force simulation,
162+
* no data fetch) so it's cheap to render; the aspect ratio matches the
163+
* featured-thumb cards (16:10) for visual rhythm. Positions are hand-picked
164+
* to read as "three blobs gently bridged" — like the real map at low zoom.
165+
*/
166+
const MAP_PREVIEW_NODES: Array<{ x: number; y: number; r: number; cluster: 0 | 1 | 2 | 3 }> = [
167+
// Cluster A — left, brand green
168+
{ x: 90, y: 95, r: 9, cluster: 0 },
169+
{ x: 70, y: 130, r: 7, cluster: 0 },
170+
{ x: 115, y: 125, r: 8, cluster: 0 },
171+
{ x: 95, y: 160, r: 6, cluster: 0 },
172+
{ x: 135, y: 95, r: 6, cluster: 0 },
173+
{ x: 60, y: 100, r: 5, cluster: 0 },
174+
// Cluster B — top-right, vermillion
175+
{ x: 320, y: 70, r: 9, cluster: 1 },
176+
{ x: 350, y: 100, r: 7, cluster: 1 },
177+
{ x: 295, y: 105, r: 6, cluster: 1 },
178+
{ x: 365, y: 60, r: 5, cluster: 1 },
179+
{ x: 330, y: 130, r: 7, cluster: 1 },
180+
// Cluster C — bottom-right, blue
181+
{ x: 290, y: 200, r: 8, cluster: 2 },
182+
{ x: 325, y: 220, r: 9, cluster: 2 },
183+
{ x: 360, y: 195, r: 6, cluster: 2 },
184+
{ x: 305, y: 235, r: 6, cluster: 2 },
185+
{ x: 350, y: 240, r: 5, cluster: 2 },
186+
// Bridges — neutral nodes
187+
{ x: 200, y: 130, r: 6, cluster: 3 },
188+
{ x: 225, y: 175, r: 5, cluster: 3 },
189+
{ x: 175, y: 165, r: 4, cluster: 3 },
190+
];
191+
192+
const MAP_PREVIEW_LINKS: Array<[number, number]> = [
193+
// Cluster A internal
194+
[0, 1], [0, 2], [1, 2], [2, 3], [0, 4], [1, 5], [0, 5],
195+
// Cluster B internal
196+
[6, 7], [6, 8], [7, 9], [6, 10], [7, 10],
197+
// Cluster C internal
198+
[11, 12], [12, 13], [11, 14], [13, 14], [14, 15], [12, 15],
199+
// Bridges via neutrals
200+
[2, 16], [16, 8], [3, 17], [17, 11], [16, 17], [17, 18], [18, 3],
201+
];
202+
203+
const CLUSTER_PALETTE = ['#009E73', '#D55E00', '#0072B2'] as const;
204+
205+
function MapClusterPreview() {
206+
return (
207+
<Box
208+
component="svg"
209+
viewBox="0 0 420 280"
210+
aria-hidden="true"
211+
sx={{
212+
display: 'block',
213+
width: '100%',
214+
height: 'auto',
215+
aspectRatio: '16 / 10',
216+
bgcolor: 'var(--bg-elevated)',
217+
}}
218+
>
219+
<g stroke="var(--rule)" strokeWidth={0.75} fill="none" opacity={0.85}>
220+
{MAP_PREVIEW_LINKS.map(([a, b], i) => {
221+
const na = MAP_PREVIEW_NODES[a];
222+
const nb = MAP_PREVIEW_NODES[b];
223+
return <line key={i} x1={na.x} y1={na.y} x2={nb.x} y2={nb.y} />;
224+
})}
225+
</g>
226+
{MAP_PREVIEW_NODES.map((n, i) => {
227+
const fill = n.cluster === 3 ? 'var(--ink-soft)' : CLUSTER_PALETTE[n.cluster];
228+
const opacity = n.cluster === 3 ? 0.45 : 0.92;
229+
return (
230+
<circle
231+
key={i}
232+
cx={n.x}
233+
cy={n.y}
234+
r={n.r}
235+
fill={fill}
236+
opacity={opacity}
237+
stroke="var(--bg-surface)"
238+
strokeWidth={1.5}
239+
/>
240+
);
241+
})}
242+
</Box>
243+
);
244+
}
245+
75246
/**
76247
* Palette section — mirrors SpecsSection's two-column layout: description on
77248
* the left, labelled palette strip on the right.

app/src/pages/MapPage.helpers.ts

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -130,22 +130,23 @@ export type TagCategory = (typeof TAG_CATEGORIES)[number];
130130
* weights panel; passing a custom `weights` map to {@link weightedJaccard}
131131
* or {@link buildKNNLinks} replaces the defaults entirely.
132132
*
133-
* The defaults privilege plot_type (2.0) with light contributions from
134-
* features and data_type (0.5 each). That gives a plot_type-dominant map
135-
* with subtle cross-type cohesion. Users can slide secondary categories up
136-
* via the weights panel to mix in techniques/patterns/etc. for richer
137-
* clustering.
133+
* The defaults privilege plot_type (2.0) so it drives the main clusters.
134+
* Secondary semantic axes get non-zero contributions: data_type (0.6),
135+
* features (0.5), domain (0.3), techniques (0.2). Remaining categories
136+
* sit on a 0.1 floor so they still nudge the layout instead of being
137+
* ignored entirely. Users can slide any of these up via the weights
138+
* panel.
138139
*/
139140
export const DEFAULT_CATEGORY_WEIGHT: Record<TagCategory, number> = {
140141
plot_type: 2.0,
141142
features: 0.5,
142-
techniques: 0,
143-
patterns: 0,
144-
dataprep: 0,
145-
dependencies: 0,
146-
domain: 0,
147-
data_type: 0.5,
148-
styling: 0,
143+
techniques: 0.2,
144+
patterns: 0.1,
145+
dataprep: 0.1,
146+
dependencies: 0.1,
147+
domain: 0.3,
148+
data_type: 0.6,
149+
styling: 0.1,
149150
};
150151

151152
function categoryOf(prefixedTag: string): string {

app/src/pages/MapPage.test.tsx

Lines changed: 117 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2+
import { forwardRef, useImperativeHandle } from 'react';
23

3-
import { render, screen, waitFor } from '../test-utils';
4+
import { act, render, screen, waitFor } from '../test-utils';
45
import { MapPage } from './MapPage';
56

67

@@ -28,9 +29,10 @@ vi.mock('../hooks/useLayoutContext', () => ({
2829
}));
2930

3031
// Default to "(hover: hover)" matching → desktop behaviour. Touch-specific
31-
// branches (e.g. tap-to-pin) need a per-test override.
32+
// branches (e.g. tap-to-pin) need a per-test override via mockHasHover.
33+
const mockHasHover = { current: true };
3234
vi.mock('@mui/material/useMediaQuery', () => ({
33-
default: () => true,
35+
default: () => mockHasHover.current,
3436
}));
3537

3638
// Capture the props passed to ForceGraph2D so individual callbacks can be exercised
@@ -39,9 +41,31 @@ vi.mock('@mui/material/useMediaQuery', () => ({
3941
type FgProps = Record<string, unknown>;
4042
const lastFgProps: { current: FgProps | null } = { current: null };
4143

44+
// Mock instance returned via ref. The page calls imperative methods like
45+
// `fgRef.current?.centerAt(...)` from `onEngineStop`; without forwardRef the
46+
// ref would be null and those branches would silently early-return.
47+
type FgInstance = {
48+
centerAt: ReturnType<typeof vi.fn>;
49+
zoom: ReturnType<typeof vi.fn>;
50+
zoomToFit: ReturnType<typeof vi.fn>;
51+
d3Force: ReturnType<typeof vi.fn>;
52+
d3ReheatSimulation: ReturnType<typeof vi.fn>;
53+
refresh: ReturnType<typeof vi.fn>;
54+
__forcesWired?: boolean;
55+
};
56+
const fgInstance: FgInstance = {
57+
centerAt: vi.fn(),
58+
zoom: vi.fn().mockReturnValue(1),
59+
zoomToFit: vi.fn(),
60+
d3Force: vi.fn().mockReturnValue({ strength: vi.fn().mockReturnThis(), distance: vi.fn().mockReturnThis() }),
61+
d3ReheatSimulation: vi.fn(),
62+
refresh: vi.fn(),
63+
};
64+
4265
vi.mock('react-force-graph-2d', () => ({
43-
default: (props: FgProps) => {
66+
default: forwardRef<FgInstance, FgProps>((props, ref) => {
4467
lastFgProps.current = props;
68+
useImperativeHandle(ref, () => fgInstance, []);
4569
const data = props.graphData as { nodes: unknown[]; links: unknown[] };
4670
return (
4771
<div
@@ -50,7 +74,7 @@ vi.mock('react-force-graph-2d', () => ({
5074
data-link-count={data.links.length}
5175
/>
5276
);
53-
},
77+
}),
5478
}));
5579

5680

@@ -137,6 +161,14 @@ describe('MapPage', () => {
137161
mockNavigate.mockReset();
138162
mockTrackEvent.mockReset();
139163
lastFgProps.current = null;
164+
mockHasHover.current = true;
165+
fgInstance.centerAt.mockReset();
166+
fgInstance.zoom.mockReset().mockReturnValue(1);
167+
fgInstance.zoomToFit.mockReset();
168+
fgInstance.d3Force.mockReset().mockReturnValue({ strength: vi.fn().mockReturnThis(), distance: vi.fn().mockReturnThis() });
169+
fgInstance.d3ReheatSimulation.mockReset();
170+
fgInstance.refresh.mockReset();
171+
fgInstance.__forcesWired = undefined;
140172
vi.stubGlobal('ResizeObserver', MockResizeObserver);
141173
});
142174

@@ -269,4 +301,84 @@ describe('MapPage', () => {
269301
expect(large).toBeGreaterThan(small);
270302
expect(small).toBeGreaterThan(0);
271303
});
304+
305+
it('seeds initial node positions per cluster (warm start for the simulation)', async () => {
306+
mockFetchSuccess();
307+
render(<MapPage />);
308+
await waitFor(() => expect(lastFgProps.current).not.toBeNull());
309+
310+
const nodes = (lastFgProps.current!.graphData as { nodes: Array<{ id: string; x?: number; y?: number; vx?: number; vy?: number }> }).nodes;
311+
// Every node should have a numeric seed position before FG2D ever ticks the simulation —
312+
// without seeding, FG2D's random initialiser would leave x/y undefined here.
313+
for (const n of nodes) {
314+
expect(typeof n.x).toBe('number');
315+
expect(typeof n.y).toBe('number');
316+
expect(Number.isFinite(n.x as number)).toBe(true);
317+
expect(Number.isFinite(n.y as number)).toBe(true);
318+
}
319+
// Same plot_type (= colorBucket) should land near the same centroid; nodes from
320+
// different buckets should land further apart on average. Take the two scatters
321+
// (bucketed together) vs. line-basic and compare distances.
322+
const scatterA = nodes.find(n => n.id === 'scatter-basic')!;
323+
const scatterB = nodes.find(n => n.id === 'scatter-color-mapped')!;
324+
const line = nodes.find(n => n.id === 'line-basic')!;
325+
const dist = (a: typeof scatterA, b: typeof scatterA) =>
326+
Math.hypot((a.x ?? 0) - (b.x ?? 0), (a.y ?? 0) - (b.y ?? 0));
327+
expect(dist(scatterA, scatterB)).toBeLessThan(dist(scatterA, line));
328+
});
329+
330+
it('shows the settling overlay until the simulation cools, then hides it', async () => {
331+
mockFetchSuccess();
332+
render(<MapPage />);
333+
await waitFor(() => expect(screen.getByTestId('force-graph-2d')).toBeInTheDocument());
334+
335+
// Gate is visible while the engine is still cooling.
336+
expect(screen.getByText(/arranging/i)).toBeInTheDocument();
337+
338+
// Engine stops → settled flips → overlay disappears.
339+
const onEngineStop = lastFgProps.current!.onEngineStop as () => void;
340+
act(() => onEngineStop());
341+
await waitFor(() => expect(screen.queryByText(/arranging/i)).not.toBeInTheDocument());
342+
});
343+
344+
it('frames the bbox via centerAt + zoom on engine stop', async () => {
345+
mockFetchSuccess();
346+
render(<MapPage />);
347+
await waitFor(() => expect(lastFgProps.current).not.toBeNull());
348+
349+
// The percentile-trimmed fit reads node x/y; seed positions guarantee these are set.
350+
const onEngineStop = lastFgProps.current!.onEngineStop as () => void;
351+
act(() => onEngineStop());
352+
353+
expect(fgInstance.centerAt).toHaveBeenCalledTimes(1);
354+
expect(fgInstance.zoom).toHaveBeenCalled();
355+
// Animation duration is 0 → instant, hidden behind the gate.
356+
const centerCall = fgInstance.centerAt.mock.calls[0];
357+
expect(centerCall[2]).toBe(0);
358+
});
359+
360+
it('first tap pins on touch devices, second tap navigates', async () => {
361+
mockHasHover.current = false;
362+
mockFetchSuccess();
363+
render(<MapPage />);
364+
await waitFor(() => expect(lastFgProps.current).not.toBeNull());
365+
366+
// First tap: pin (no navigation, analytics fires map_node_pin).
367+
act(() => {
368+
const onNodeClick = lastFgProps.current!.onNodeClick as (n: { id: string }) => void;
369+
onNodeClick({ id: 'scatter-basic' });
370+
});
371+
expect(mockNavigate).not.toHaveBeenCalled();
372+
expect(mockTrackEvent).toHaveBeenCalledWith('map_node_pin', { spec: 'scatter-basic' });
373+
374+
// Second tap on the same node: navigate. After the first tap React
375+
// re-rendered MapPage with the new pinnedId, so lastFgProps.current
376+
// now holds a fresh onNodeClick closure that reads the updated state.
377+
act(() => {
378+
const onNodeClick = lastFgProps.current!.onNodeClick as (n: { id: string }) => void;
379+
onNodeClick({ id: 'scatter-basic' });
380+
});
381+
expect(mockNavigate).toHaveBeenCalledWith('/scatter-basic');
382+
expect(mockTrackEvent).toHaveBeenCalledWith('map_node_click', { spec: 'scatter-basic' });
383+
});
272384
});

0 commit comments

Comments
 (0)