feat(landing): add map teaser section linking to /map#5649
Merged
Conversation
Two-column section between SpecsSection and LibrariesSection: short description on the left, decorative SVG cluster preview on the right. The preview is purely static (no data fetch, no force simulation) and mirrors the real map's three-cluster look using the same Okabe-Ito palette, so it tells visitors what /map is without paying for it on the landing page. https://claude.ai/code/session_011dPWC8Z43EGZdj6rrE9gav
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
Contributor
There was a problem hiding this comment.
Pull request overview
Adds a new “Map” teaser section to the landing page to drive navigation to the interactive /map experience, positioned between the existing specs showcase and the libraries section.
Changes:
- Inserts a new
MapSectionintoLandingPagebetweenSpecsSectionandLibrariesSection. - Implements a static SVG “cluster preview” card linking to
/map, withnav_clickanalytics tracking.
Comment on lines
+110
to
+115
| {specCount ? `all ${specCount} specs` : 'every spec'} on a single canvas —{' '} | ||
| <Box component="span" sx={{ color: 'var(--ink)' }}> | ||
| clustered by tag similarity, coloured by plot type, searchable. | ||
| </Box>{' '} | ||
| zoom in for thumbnails, hover for details, click to open the spec. | ||
| </Box> |
Comment on lines
+216
to
+217
| role="img" | ||
| aria-label="Three clusters of circles connected by hairlines, mirroring the map's force-directed layout" |
Comment on lines
+117
to
+126
| <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 3 }}> | ||
| <MethodLink to="/map" subject="map" verb="open" source="map_teaser_link" /> | ||
| </Box> | ||
| </Box> | ||
|
|
||
| <Box | ||
| component={RouterLink} | ||
| to="/map" | ||
| onClick={() => trackEvent('nav_click', { source: 'map_teaser_preview', target: '/map' })} | ||
| sx={{ |
- specCount: use null-check instead of truthiness so a future 0-spec catalog still renders "all 0 specs" rather than the loading copy - decorative SVG: drop role="img" + aria-label on the cluster preview and add aria-hidden, since the wrapping link already carries an aria-label and the duplicate announcement is noisy - tests: assert nav_click fires for map_teaser_preview and map_teaser_link with the expected source https://claude.ai/code/session_011dPWC8Z43EGZdj6rrE9gav
The map teaser had `map.explore()` in the section header and `map.open()` as a MethodLink in the body — both pointing to /map. Drop the MethodLink to match the palette/specs section pattern (header-only entry link), and flip the grid ratio to 1.1fr / 1fr so the SVG preview is narrower and proportionally less tall, sitting closer to the text height. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
UX - Add a "settling" overlay that swallows pointer input from canvas mount through engine cool-down. Previously the canvas accepted clicks during the simulation and the post-settle zoom-fit, which felt like clicks were resetting because the camera animation overrode them. - Drop the post-settle two-step zoomToFit (zoom out, wait 700ms, zoom back in to MIN_ZOOM). The simulation's centering force already lands the graph in the viewport, so the corrective zoom changed almost nothing visually but added 1.7s of camera movement that could clobber input. - Belt-and-braces: onNodeClick / search flyTo early-return until settled. Layout quality - Seed initial node positions per colorBucket on a circle around the origin (CLUSTER_SEED_RADIUS=600, hash-stable jitter ±150). With a warm start the simulation produces visibly cleaner cluster separation in the same number of cooldown ticks. - Warm-restart from previous x/y/vx/vy when the cache hits, so filter or weight tweaks refine the existing layout instead of re-converging. Defaults - Add a 0.1 weight floor on every previously-zero category (techniques, patterns, dataprep, dependencies, domain, styling) so subtle cross-type cohesion contributes instead of being ignored. - Bump domain 0.1 → 0.3, techniques 0.1 → 0.2, data_type 0.5 → 0.6: domain in particular is a strong semantic axis worth surfacing. - KNN_K 5 → 8: denser within-cluster connectivity tightens the cluster shapes without creating cross-cluster bridges. - COOLDOWN_TICKS 400 → 450, d3AlphaDecay 0.0228 → 0.018: a touch more effective settling time, hidden behind the overlay. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment on lines
+133
to
+137
| * 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. | ||
| * features and data_type (0.5 each), plus a small 0.1 floor on every | ||
| * remaining category so subtle cross-type cohesion (techniques, patterns, | ||
| * dataprep, etc.) still nudges the layout instead of being ignored | ||
| * entirely. Users can slide any of these up via the weights panel. |
Comment on lines
+77
to
+114
| /** | ||
| * Map section — teases the interactive force-directed map at /map. Mirrors | ||
| * SpecsSection / PaletteSection two-column layout: short description on the | ||
| * left, decorative SVG cluster preview on the right. The preview is purely | ||
| * static (no data fetch, no force simulation) so it stays cheap on the | ||
| * landing page; it only hints at the real map's clustering aesthetic using | ||
| * the same Okabe-Ito palette. | ||
| */ | ||
| function MapSection({ specCount }: { specCount?: number }) { | ||
| const { trackEvent } = useAnalytics(); | ||
| return ( | ||
| <Box sx={{ py: { xs: 2, md: 3 } }}> | ||
| <SectionHeader prompt="❯" title={<em>map</em>} linkText="map.explore()" linkTo="/map" /> | ||
|
|
||
| <Box | ||
| sx={{ | ||
| display: 'grid', | ||
| gridTemplateColumns: { xs: 'minmax(0, 1fr)', md: 'minmax(0, 1.1fr) minmax(0, 1fr)' }, | ||
| gap: { xs: 4, md: 8, lg: 12 }, | ||
| alignItems: 'center', | ||
| }} | ||
| > | ||
| <Box | ||
| sx={{ | ||
| fontFamily: typography.serif, | ||
| fontSize: { xs: '1rem', md: '1.25rem' }, | ||
| lineHeight: 1.55, | ||
| color: 'var(--ink-soft)', | ||
| fontWeight: 300, | ||
| maxWidth: '52ch', | ||
| }} | ||
| > | ||
| {specCount != null ? `all ${specCount} specs` : 'every spec'} on a single canvas —{' '} | ||
| <Box component="span" sx={{ color: 'var(--ink)' }}> | ||
| clustered by tag similarity, coloured by plot type, searchable. | ||
| </Box>{' '} | ||
| zoom in for thumbnails, hover for details, click to open the spec. | ||
| </Box> |
Comment on lines
+1312
to
+1315
| hasn't finished cooling and the camera hasn't completed its initial | ||
| fit-to-view. 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. */} |
Comment on lines
+531
to
+534
| // Belt-and-braces guard: the overlay already swallows pointer events | ||
| // while the camera is animating, but ForceGraph2D may have buffered a | ||
| // tap that landed just before the gate appeared. | ||
| if (!settled) return; |
Comment on lines
555
to
556
| if (!settled) return; | ||
| const fg = fgRef.current; |
Comment on lines
+1266
to
+1271
| // The simulation's centering force already lands the graph near | ||
| // the viewport, so an explicit zoom-to-fit changes very little | ||
| // and just adds a wait. Drop the gate as soon as the engine | ||
| // stops — the user keeps whatever camera state the simulation | ||
| // produced and can pan/zoom freely. | ||
| onEngineStop={() => setSettled(true)} |
Comment on lines
+168
to
+171
| // settled = true once the post-mount camera animation has finished. Until | ||
| // then, the canvas is overlaid by a subtle gate that swallows pointer | ||
| // input so a click during simulation/zoom can't be clobbered when the | ||
| // camera then animates away from where the user tapped. |
- Update DEFAULT_CATEGORY_WEIGHT docstring to reflect actual values (data_type 0.6 / features 0.5 / domain 0.3 / techniques 0.2 / 0.1 floor on the rest) instead of the stale "0.1 floor everywhere" line. - Drop the `if (!settled) return` guards in onNodeClick and flyTo. The settling overlay (pointerEvents: auto on a full-canvas Box) is enough to prevent canvas pointer input during cooling; the early-returns also blocked the search dropdown's flyTo even though seeded nodes have valid coordinates from t=0. - Restore a conditional fit-to-view at engine stop: only fires when the settled bbox would actually overflow the viewport (typical case on mobile), uses a single centerAt + zoom animation covered by the overlay, and clamps via MIN_ZOOM_FIT so far-flung outliers don't shrink the dense cluster into pixels. - Refresh stale comments around the `settled` state and the gate overlay so they match the actual cooling-only behaviour. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Restoring even a guarded zoomToFit ended up firing on typical desktop viewports (graph extent slightly exceeds visible area), animating the camera with no perceptible visual change. User feedback was the same as on the original two-step fit: the motion feels unmotivated. Trust the simulation's centering force to land the graph reasonably, let users pan/zoom on small screens where it overflows. Drop the MIN_ZOOM_FIT constant since nothing references it anymore. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment on lines
+1261
to
+1267
| // 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 |
Comment on lines
1312
to
1321
| // 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) => |
Comment on lines
+109
to
+114
| {specCount != null ? `all ${specCount} specs` : 'every spec'} on a single canvas —{' '} | ||
| <Box component="span" sx={{ color: 'var(--ink)' }}> | ||
| clustered by tag similarity, coloured by plot type, searchable. | ||
| </Box>{' '} | ||
| zoom in for thumbnails, hover for details, click to open the spec. | ||
| </Box> |
Trim the bbox to its 5th–95th percentile in each axis before fitting, so a couple of far-flung outliers can't shrink the readable center. The fit fires instantly (0 ms) at engine stop and is hidden behind the gate overlay, so the user never sees an animation — just lands on a cleanly framed graph regardless of viewport size. On desktop this is a small inward trim. On mobile the full structure becomes visible at the cost of smaller thumbnails — exactly the "overview" the user described. Outliers stay reachable via pan. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously the corner preview panel switched from compact (160 px, no tags) to full (280 px, tags) at the `sm` breakpoint (≥600 px), so tablets in portrait already got the desktop-sized panel that covers the focused node and its KNN edges after fly-to. Move every breakpoint one step up (`sm` → `md`): tablets now share the phone layout (160 px, no tags), full 280 px panel kicks in at `md` (≥900 px) where there's actually room beside the dense cluster. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two issues from the latest Copilot review: 1. The settling overlay sat at z-index 5 with pointerEvents:auto, and the search pill / legend / weights panel / corner panel sit at z-indices 2–4. So during simulation cooling the gate blocked all UI, not just the canvas. Drop the gate to z-index 1 — above the canvas (implicit 0), below every interactive control. Pointer input on the controls now reaches them; only the canvas itself is gated. 2. `settled` was a one-way latch (false → true on engineStop, never reset). Filter / weight / category changes re-derive `graphData`, FG2D reheats the simulation, but the gate stayed gone. Add a useEffect that resets `settled = false` whenever `graphData` changes, so the gate also covers subsequent re-layouts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Extend the ForceGraph2D mock with forwardRef + a shared mock instance so callbacks that read `fgRef.current` (centerAt / zoom / d3Force, …) can be exercised in jsdom. - New test: cluster-seeded x/y/vx/vy on every node before FG2D ticks, and same-bucket nodes seeded closer than cross-bucket ones. - New test: settling overlay is visible while the engine is cooling and disappears the moment onEngineStop fires. - New test: onEngineStop calls centerAt + zoom (instant duration 0, hidden behind the gate) — covers the percentile-trimmed fit path. - New test: touch-device flow (`useMediaQuery` returns false) — first tap pins + emits `map_node_pin`, second tap navigates + emits `map_node_click`. Each tap wrapped in act() so React flushes the pinnedId state between calls. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment on lines
+1297
to
+1298
| const lo = Math.floor(xs.length * trim); | ||
| const hi = Math.floor(xs.length * (1 - trim)); |
Comment on lines
+109
to
+114
| {specCount != null ? `all ${specCount} specs` : 'every spec'} on a single canvas —{' '} | ||
| <Box component="span" sx={{ color: 'var(--ink)' }}> | ||
| clustered by tag similarity, coloured by plot type, searchable. | ||
| </Box>{' '} | ||
| zoom in for thumbnails, hover for details, click to open the spec. | ||
| </Box> |
| > | ||
| {specCount != null ? `all ${specCount} specs` : 'every spec'} on a single canvas —{' '} | ||
| <Box component="span" sx={{ color: 'var(--ink)' }}> | ||
| clustered by tag similarity, coloured by plot type, searchable. |
Comment on lines
+1273
to
1314
| // 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<MapNode & { x?: number; y?: number }>) { | ||
| 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); | ||
| }} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a new section to the landing page that teases the recently introduced
/mappage and links to it. Sits betweenSpecsSectionandLibrariesSection— the natural follow-up to the curated featured grid is "and here's the visual way to browse all of them".SpecsSection/PaletteSection: short description on the left, decorative preview on the right.all 327 specs on a single canvas — clustered by tag similarity, coloured by plot type, searchable.).MapPage'sCLUSTER_COLORS. Purely static — no data fetch, no force simulation — so the landing page stays cheap.RouterLinkto/mapwith the same hover treatment as the featured thumbs (lift + green accent bar). A secondarymap.open()MethodLinklives under the description for keyboard users.nav_clickevents withsource: 'map_teaser_link'(text link) andsource: 'map_teaser_preview'(visual). TheSectionHeaderlink already tracks itself.Design proposal
I weighed three options for the right column:
ForceGraph2Dat small size. Rejected: ~70 KB gzip on the landing page, force simulation cost, layout flicker.Test plan
yarn type-check— cleanyarn test src/pages/LandingPage.test.tsx— 5/5 pass (existing tests still hit the right elements)yarn build— landing page bundle 24.51 kB → 7.23 kB gzipyarn lint src/pages/LandingPage.tsx— clean (pre-existing warnings in other files unaffected)/, scroll to map section, verify hover lift + click navigates to/maphttps://claude.ai/code/session_011dPWC8Z43EGZdj6rrE9gav
Generated by Claude Code