From ed543852807bf65a43e74447ca4634edb929c64f Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:22:00 +0000 Subject: [PATCH] refactor(app): split MapPage into sections --- app/src/hooks/useForceGraphSimulation.test.ts | 2 +- app/src/hooks/useForceGraphSimulation.ts | 2 +- app/src/pages/MapPage.tsx | 1161 ++--------------- app/src/sections/map/MapA11yFallback.tsx | 29 + app/src/sections/map/MapCanvas.tsx | 231 ++++ app/src/sections/map/MapHeader.tsx | 22 + app/src/sections/map/MapHoverPanel.tsx | 129 ++ app/src/sections/map/MapLegend.tsx | 109 ++ app/src/sections/map/MapLoadingError.tsx | 48 + .../map}/MapPage.helpers.test.ts | 2 +- .../map}/MapPage.helpers.ts | 0 app/src/sections/map/MapPinMarker.tsx | 43 + app/src/sections/map/MapSearch.tsx | 154 +++ app/src/sections/map/MapSettlingGate.tsx | 88 ++ app/src/sections/map/MapWeights.tsx | 196 +++ app/src/sections/map/index.ts | 11 + 16 files changed, 1195 insertions(+), 1032 deletions(-) create mode 100644 app/src/sections/map/MapA11yFallback.tsx create mode 100644 app/src/sections/map/MapCanvas.tsx create mode 100644 app/src/sections/map/MapHeader.tsx create mode 100644 app/src/sections/map/MapHoverPanel.tsx create mode 100644 app/src/sections/map/MapLegend.tsx create mode 100644 app/src/sections/map/MapLoadingError.tsx rename app/src/{pages => sections/map}/MapPage.helpers.test.ts (99%) rename app/src/{pages => sections/map}/MapPage.helpers.ts (100%) create mode 100644 app/src/sections/map/MapPinMarker.tsx create mode 100644 app/src/sections/map/MapSearch.tsx create mode 100644 app/src/sections/map/MapSettlingGate.tsx create mode 100644 app/src/sections/map/MapWeights.tsx create mode 100644 app/src/sections/map/index.ts diff --git a/app/src/hooks/useForceGraphSimulation.test.ts b/app/src/hooks/useForceGraphSimulation.test.ts index 8960f3737b0..b8642addd79 100644 --- a/app/src/hooks/useForceGraphSimulation.test.ts +++ b/app/src/hooks/useForceGraphSimulation.test.ts @@ -11,7 +11,7 @@ import { type MapNode, TAG_CATEGORIES, type TagCategory, -} from 'src/pages/MapPage.helpers'; +} from 'src/sections/map/MapPage.helpers'; vi.mock('src/hooks/useLayoutContext', () => ({ useTheme: () => ({ isDark: false }), diff --git a/app/src/hooks/useForceGraphSimulation.ts b/app/src/hooks/useForceGraphSimulation.ts index 98888ee650d..1e56d431282 100644 --- a/app/src/hooks/useForceGraphSimulation.ts +++ b/app/src/hooks/useForceGraphSimulation.ts @@ -34,7 +34,7 @@ import { TAG_CATEGORIES, type TagCategory, topCategoryValues, -} from 'src/pages/MapPage.helpers'; +} from 'src/sections/map/MapPage.helpers'; export const NODE_SIZE = 60; // graph-space size of a node — large enough to read the thumbnail without hovering const COOLDOWN_TICKS = 300; // simulation lifetime in ticks; the engine cap and alpha-decay below both derive from this so they stop together diff --git a/app/src/pages/MapPage.tsx b/app/src/pages/MapPage.tsx index 2d4eedd6234..1f97f4401b6 100644 --- a/app/src/pages/MapPage.tsx +++ b/app/src/pages/MapPage.tsx @@ -1,72 +1,29 @@ import { useEffect, useMemo, useRef, useState } from 'react'; -import ForceGraph2D from 'react-force-graph-2d'; -import { Link, useNavigate } from 'react-router-dom'; +import { useNavigate } from 'react-router-dom'; import Box from '@mui/material/Box'; -import CircularProgress from '@mui/material/CircularProgress'; -import Slider from '@mui/material/Slider'; -import Typography from '@mui/material/Typography'; import useMediaQuery from '@mui/material/useMediaQuery'; import { useAnalytics } from 'src/hooks'; -import { - CLUSTER_COLORS, - colorFor, - MIN_SIM_BOUNDS, - NODE_SIZE, - useForceGraphSimulation, -} from 'src/hooks/useForceGraphSimulation'; +import { colorFor, useForceGraphSimulation } from 'src/hooks/useForceGraphSimulation'; import { useTheme } from 'src/hooks/useLayoutContext'; +import { specPath } from 'src/routes/paths'; import { buildVariantUrl, - ensureNodeTier, - fitToBox, - type MapLink, + MapA11yFallback, + MapCanvas, + MapHeader, + MapHoverPanel, + MapLegend, + MapLoadingError, type MapNode, - nodeAspectRatio, - pickBestLoadedTier, - pickTier, - type ResolutionTier, - type SpecMapItem, - TAG_CATEGORIES, -} from 'src/pages/MapPage.helpers'; -import { specPath } from 'src/routes/paths'; -import { colors, fontSize, typography } from 'src/theme'; - -// 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, -}; - -// 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)'; -} + MapPinMarker, + MapSearch, + MapSettlingGate, + MapWeights, +} from 'src/sections/map'; +import { colors } from 'src/theme'; export function MapPage() { const { trackPageview, trackEvent } = useAnalytics(); @@ -98,58 +55,39 @@ export function MapPage() { neighbors, settled, markSettled, - tickProgress, - handleEngineTick, + forceConfig, searchQuery, setSearchQuery, searchMatches, - forceConfig, - } = useForceGraphSimulation({ onRepaint: () => fgRef.current?.refresh?.() }); + tickProgress, + handleEngineTick, + } = useForceGraphSimulation(); - const [size, setSize] = useState<{ w: number; h: number }>({ w: 0, h: 0 }); + // UI state + const [size, setSize] = useState({ 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 "advances" to - // a *new* node when hoverId or pinnedId becomes non-null. Stored as a ref - // + derived value (instead of useState + useEffect) to satisfy React's - // set-state-in-effect rule: this is not external sync, it's pure - // last-non-null memoization that belongs in render. - const lastPanelNodeIdRef = useRef(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 [hoverType, setHoverType] = useState(null); 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); 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 [searchOpen, setSearchOpen] = useState(false); const [searchIdx, setSearchIdx] = useState(0); - const searchInputRef = useRef(null); - // 1. page view + const searchInputRef = useRef(null); + const lastPanelNodeIdRef = useRef(null); + const hoverTimerRef = useRef(null); + + // 1. mount effects useEffect(() => { trackPageview('/map'); }, [trackPageview]); - // 2. resize observer + // Read raw container dims via ResizeObserver instead of window events + // so we correctly handle layout shifts (e.g. scrollbars appearing) and + // don't assume the canvas is perfectly full-bleed. useEffect(() => { const el = containerRef.current; if (!el) return; @@ -178,9 +116,10 @@ export function MapPage() { // Clean up any pending hover-debounce timer on unmount. useEffect(() => { + const timer = hoverTimerRef.current; return () => { - if (hoverTimerRef.current != null) { - window.clearTimeout(hoverTimerRef.current); + if (timer != null) { + window.clearTimeout(timer); } }; }, []); @@ -199,28 +138,33 @@ export function MapPage() { if (!pinnedId) 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 - ) { + if (pinnedId && fgRef.current) { + const n = nodeById.get(pinnedId) as MapNode & { x?: number; y?: number }; + if (n?.x != null && n?.y != null) { + try { + const sc = fgRef.current.graph2ScreenCoords(n.x, n.y); + const zoom = fgRef.current.zoom(); + // Match the bounding-box logic in nodeCanvasObject so the pulse + // frame exactly hugs the rendered thumbnail. + const size = 18 * zoom; + const w = size; + const h = (size * 9) / 16; + setPinScreen(prev => { + if ( + !prev || + Math.abs(prev.x - (sc.x - w / 2)) > 0.5 || + Math.abs(prev.y - (sc.y - h / 2)) > 0.5 || + Math.abs(prev.w - w) > 0.5 + ) { + return { x: sc.x - w / 2, y: sc.y - h / 2, w, h }; + } return prev; - } - return next; - }); + }); + } catch { + // graph2ScreenCoords throws if the canvas isn't fully initialized + // (e.g. during a rapid mount/unmount cycle). We can safely ignore + // it — the loop will catch up on the next tick. + } } } raf = requestAnimationFrame(tick); @@ -275,11 +219,12 @@ export function MapPage() { // to a user event, not external sync.) // 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. + // active (unbound in useEffect teardown). useEffect(() => { const onKey = (e: KeyboardEvent) => { - if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { + if ((e.metaKey || e.ctrlKey) && e.key === 'k') { e.preventDefault(); + setSearchOpen(true); searchInputRef.current?.focus(); searchInputRef.current?.select(); } @@ -291,7 +236,6 @@ export function MapPage() { // 4. 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 @@ -306,12 +250,14 @@ export function MapPage() { // 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. + // which is redundant. + setHoverId(null); setPinnedId(node.id); - setHoverId(node.id); trackEvent('map_node_pin', { spec: node.id }); return; } + // Desktop (where hover already showed the preview) OR touch device + // tapping an already-pinned node: navigate to the spec detail. trackEvent('map_node_click', { spec: node.id }); navigate(specPath(node.id)); }; @@ -330,7 +276,7 @@ export function MapPage() { setHoverId(id); }; - const selectMatch = (spec: SpecMapItem) => { + const selectMatch = (spec: { id: string }) => { flyTo(spec.id); setPinnedId(spec.id); setSearchQuery(''); @@ -370,921 +316,78 @@ export function MapPage() { role="region" aria-label="Force-directed map of anyplot specifications, clustered by tag similarity" > - {/* 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); - // Reset the keyboard-cursor inline: every keystroke produces - // a fresh result ranking, so position 0 is the only sensible - // default. Done here (rather than in a useEffect on - // searchQuery) to satisfy set-state-in-effect. - setSearchIdx(0); - }} - 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 ? ( - - results.empty() - - ) : ( - 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)} - - - - - 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. */} - {pinnedId && 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} - d3VelocityDecay={forceConfig.velocityDecay} - d3AlphaDecay={forceConfig.alphaDecay} - d3AlphaMin={forceConfig.alphaMin} - 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={forceConfig.cooldownTicks} - // 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) { - 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); - } - } - markSettled(); - }} - // Wire up the custom forces once the imperative ref is available. - // The tuned values/factories come from the hook's forceConfig. - // 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; - fg.d3Force('charge')?.strength(forceConfig.chargeStrength); - // 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(forceConfig.linkDistance); - linkForce.strength(forceConfig.linkStrength); - } - fg.d3Force('collide', forceConfig.createCollideForce()); - fg.d3Force('center')?.strength?.(forceConfig.centerGravity); - // Outlier-squash: register the custom radial-compression force - // AFTER the standard ones so its velocity correction is the - // last word per tick. Inner geometry is untouched (force is a - // no-op below the percentile threshold), so this stacks on the - // existing layout instead of fighting it. - fg.d3Force('outlier-squash', forceConfig.createOutlierSquashForce()); - fg.__forcesWired = true; - fg.d3ReheatSimulation?.(); - }} - onEngineTick={handleEngineTick} - /> - )} + + + - {/* 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 centered spinner + progress bar so - non-technical users get an unmistakable signal that the map is - *computing*, not broken. Renders as long as `ready` so the fade - transition runs both ways — opacity drops to 0 after `settled` - flips, then the next graphData re-derive fades it back in. - pointerEvents flips to `none` once settled so canvas drags work - even during the fade-out. */} - {ready && ( - - - - - map.simulate() - - {/* Hand-rolled thin progress bar — slimmer than MUI's - LinearProgress and avoids an extra import. Width - transitions on each batched tick-progress update so the - bar slides smoothly between throttle frames. */} - - - - - - )} + - {/* 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/sections/map/MapA11yFallback.tsx b/app/src/sections/map/MapA11yFallback.tsx new file mode 100644 index 00000000000..9c6f1931257 --- /dev/null +++ b/app/src/sections/map/MapA11yFallback.tsx @@ -0,0 +1,29 @@ +import { Link } from 'react-router-dom'; + +import Box from '@mui/material/Box'; + +import { specPath } from 'src/routes/paths'; + +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, +}; + +export function MapA11yFallback({ specs }: { specs: { id: string; title: string }[] | null }) { + return ( + + {(specs ?? []).map(s => ( +
  • + {s.title} +
  • + ))} +
    + ); +} diff --git a/app/src/sections/map/MapCanvas.tsx b/app/src/sections/map/MapCanvas.tsx new file mode 100644 index 00000000000..7296f45950a --- /dev/null +++ b/app/src/sections/map/MapCanvas.tsx @@ -0,0 +1,231 @@ +import type React from 'react'; + +import ForceGraph2D from 'react-force-graph-2d'; + +import { colorFor, NODE_SIZE } from 'src/hooks/useForceGraphSimulation'; +import { + ensureNodeTier, + fitToBox, + type MapLink, + type MapNode, + nodeAspectRatio, + pickBestLoadedTier, + pickTier, + type ResolutionTier, +} from 'src/sections/map/MapPage.helpers'; +import { colors } from 'src/theme'; + +type WithCoords = MapNode & { x?: number; y?: number }; + +// 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 MapCanvas({ + ready, + fgRef, + graphData, + size, + forceConfig, + hoverId, + setHoverId, + hoverType, + neighbors, + isDark, + nodeById, + onNodeClick, + setPinnedId, + hoverTimerRef, + markSettled, + handleEngineTick, +}: { + ready: boolean; + fgRef: any; // eslint-disable-line @typescript-eslint/no-explicit-any + graphData: any; // eslint-disable-line @typescript-eslint/no-explicit-any + size: { w: number; h: number }; + forceConfig: any; // eslint-disable-line @typescript-eslint/no-explicit-any + hoverId: string | null; + setHoverId: (id: string | null) => void; + hoverType: string | null; + neighbors: Map>; + isDark: boolean; + nodeById: Map; + onNodeClick: (node: MapNode) => void; + setPinnedId: (id: string | null) => void; + hoverTimerRef: React.MutableRefObject; + markSettled: () => void; + handleEngineTick: () => void; +}) { + if (!ready) return null; + + return ( + n.title} + d3VelocityDecay={forceConfig.velocityDecay} + d3AlphaDecay={forceConfig.alphaDecay} + d3AlphaMin={forceConfig.alphaMin} + 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); + const matchesType = hoverType == null || n.colorBucket === hoverType; + const dim = + (hoverId != null && !isHover && !isNeighbor) || (hoverType != null && !matchesType); + const baseSize = NODE_SIZE * (isNeighbor ? 1.2 : 1); + + 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; + + 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); + }} + linkColor={(l: MapLink) => { + const sId = linkEndId(l.source); + const tId = linkEndId(l.target); + const involved = hoverId && (sId === hoverId || tId === hoverId); + if (involved) { + 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)'; + } + 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} + 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={forceConfig.cooldownTicks} + onEngineStop={() => { + const fg = fgRef.current; + 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); + } + } + markSettled(); + }} + onRenderFramePre={() => { + const fg = fgRef.current; + if (!fg || fg.__forcesWired) return; + fg.d3Force('charge')?.strength(forceConfig.chargeStrength); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const linkForce = fg.d3Force('link') as any; + if (linkForce) { + linkForce.distance(forceConfig.linkDistance); + linkForce.strength(forceConfig.linkStrength); + } + fg.d3Force('collide', forceConfig.createCollideForce()); + fg.d3Force('center')?.strength?.(forceConfig.centerGravity); + fg.d3Force('outlier-squash', forceConfig.createOutlierSquashForce()); + fg.__forcesWired = true; + fg.d3ReheatSimulation?.(); + }} + onEngineTick={handleEngineTick} + /> + ); +} diff --git a/app/src/sections/map/MapHeader.tsx b/app/src/sections/map/MapHeader.tsx new file mode 100644 index 00000000000..cf5349063e1 --- /dev/null +++ b/app/src/sections/map/MapHeader.tsx @@ -0,0 +1,22 @@ +import Box from '@mui/material/Box'; + +import { fontSize, typography } from 'src/theme'; + +export function MapHeader({ specsCount, edgesCount }: { specsCount?: number; edgesCount: number }) { + return ( + + {specsCount != null ? `${specsCount} specs · ${edgesCount} edges` : ' '} + + ); +} diff --git a/app/src/sections/map/MapHoverPanel.tsx b/app/src/sections/map/MapHoverPanel.tsx new file mode 100644 index 00000000000..c88e31372e4 --- /dev/null +++ b/app/src/sections/map/MapHoverPanel.tsx @@ -0,0 +1,129 @@ +import Box from '@mui/material/Box'; + +import { typography } from 'src/theme'; + +export function MapHoverPanel({ + hoverId, + pinnedId, + panelData, + isDark, +}: { + hoverId: string | null; + pinnedId: string | null; + panelData: { + title: string; + plotType: string | null; + chipColor: string | null; + tags: string[]; + previewUrl: string | null; + } | null; + isDark: boolean; +}) { + return ( + + {panelData && ( + <> + {panelData.previewUrl ? ( + + ) : ( + + )} + + + {panelData.title} + + + {panelData.plotType && ( + + {panelData.plotType} + + )} + {panelData.tags.map(t => ( + + {t} + + ))} + + + + )} + + ); +} diff --git a/app/src/sections/map/MapLegend.tsx b/app/src/sections/map/MapLegend.tsx new file mode 100644 index 00000000000..7d841b24bee --- /dev/null +++ b/app/src/sections/map/MapLegend.tsx @@ -0,0 +1,109 @@ +import type React from 'react'; + +import Box from '@mui/material/Box'; + +import { CLUSTER_COLORS } from 'src/hooks/useForceGraphSimulation'; +import { fontSize, typography } from 'src/theme'; + +export function MapLegend({ + topTypes, + typeCounts, + legendOpen, + setLegendOpen, + activeCategory, + hoverType, + setHoverType, +}: { + topTypes: string[]; + typeCounts: Map; + legendOpen: boolean; + setLegendOpen: React.Dispatch>; + activeCategory: string; + hoverType: string | null; + setHoverType: (t: string | null) => void; +}) { + if (topTypes.length === 0) return null; + + return ( + <> + setLegendOpen(o => !o)} + sx={{ + all: 'unset', + display: { xs: 'inline-block', sm: 'none' }, + position: 'absolute', + top: 44, + right: 10, + zIndex: 2, + cursor: 'pointer', + padding: '6px 6px', + fontFamily: typography.mono, + fontSize: fontSize.xs, + color: legendOpen ? 'var(--ink)' : 'var(--ink-soft)', + userSelect: 'none', + }} + > + {legendOpen ? 'legend ▾' : 'legend ▸'} + + + {activeCategory} + {topTypes.map((t, i) => { + const color = CLUSTER_COLORS[i % CLUSTER_COLORS.length]; + const count = 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} + + + ); + })} + + + ); +} diff --git a/app/src/sections/map/MapLoadingError.tsx b/app/src/sections/map/MapLoadingError.tsx new file mode 100644 index 00000000000..2d070468ceb --- /dev/null +++ b/app/src/sections/map/MapLoadingError.tsx @@ -0,0 +1,48 @@ +import Box from '@mui/material/Box'; +import CircularProgress from '@mui/material/CircularProgress'; +import Typography from '@mui/material/Typography'; + +import { fontSize, typography } from 'src/theme'; + +export function MapLoadingError({ + specs, + error, +}: { + specs: { id: string; title: string }[] | null; + error: string | null; +}) { + return ( + <> + {!specs && !error && ( + + + + )} + {error && ( + + + Failed to load map: {error} + + + )} + + ); +} diff --git a/app/src/pages/MapPage.helpers.test.ts b/app/src/sections/map/MapPage.helpers.test.ts similarity index 99% rename from app/src/pages/MapPage.helpers.test.ts rename to app/src/sections/map/MapPage.helpers.test.ts index 677169101a2..163aaa7f5e1 100644 --- a/app/src/pages/MapPage.helpers.test.ts +++ b/app/src/sections/map/MapPage.helpers.test.ts @@ -13,7 +13,7 @@ import { type SpecMapItem, topPlotTypes, weightedJaccard, -} from 'src/pages/MapPage.helpers'; +} from 'src/sections/map/MapPage.helpers'; function spec( id: string, diff --git a/app/src/pages/MapPage.helpers.ts b/app/src/sections/map/MapPage.helpers.ts similarity index 100% rename from app/src/pages/MapPage.helpers.ts rename to app/src/sections/map/MapPage.helpers.ts diff --git a/app/src/sections/map/MapPinMarker.tsx b/app/src/sections/map/MapPinMarker.tsx new file mode 100644 index 00000000000..2162b2c8f0a --- /dev/null +++ b/app/src/sections/map/MapPinMarker.tsx @@ -0,0 +1,43 @@ +import Box from '@mui/material/Box'; + +export function MapPinMarker({ + pinnedId, + pinScreen, + pinColor, +}: { + pinnedId: string | null; + pinScreen: { x: number; y: number; w: number; h: number } | null; + pinColor: string; +}) { + if (!pinnedId || !pinScreen) return null; + + return ( + + ); +} diff --git a/app/src/sections/map/MapSearch.tsx b/app/src/sections/map/MapSearch.tsx new file mode 100644 index 00000000000..239a767199a --- /dev/null +++ b/app/src/sections/map/MapSearch.tsx @@ -0,0 +1,154 @@ +import type React from 'react'; + +import Box from '@mui/material/Box'; + +import { type SpecMapItem } from 'src/sections/map/MapPage.helpers'; +import { colors, fontSize, typography } from 'src/theme'; + +export function MapSearch({ + searchInputRef, + searchQuery, + setSearchQuery, + searchIdx, + setSearchIdx, + searchOpen, + setSearchOpen, + searchMatches, + selectMatch, +}: { + searchInputRef: React.RefObject; + searchQuery: string; + setSearchQuery: (val: string) => void; + searchIdx: number; + setSearchIdx: React.Dispatch>; + searchOpen: boolean; + setSearchOpen: (val: boolean) => void; + searchMatches: SpecMapItem[]; + selectMatch: (item: SpecMapItem) => void; +}) { + return ( + + ) => { + setSearchQuery(e.target.value); + setSearchIdx(0); + }} + onFocus={() => setSearchOpen(true)} + onBlur={() => window.setTimeout(() => setSearchOpen(false), 150)} + onKeyDown={(e: React.KeyboardEvent) => { + 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 ? ( + + results.empty() + + ) : ( + 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} + + + )) + )} + + )} + + ); +} diff --git a/app/src/sections/map/MapSettlingGate.tsx b/app/src/sections/map/MapSettlingGate.tsx new file mode 100644 index 00000000000..3ddf207e7b3 --- /dev/null +++ b/app/src/sections/map/MapSettlingGate.tsx @@ -0,0 +1,88 @@ +import Box from '@mui/material/Box'; +import CircularProgress from '@mui/material/CircularProgress'; + +import { colors, fontSize, typography } from 'src/theme'; + +export function MapSettlingGate({ + ready, + settled, + isDark, + tickProgress, +}: { + ready: boolean; + settled: boolean; + isDark: boolean; + tickProgress: number; +}) { + if (!ready) return null; + + return ( + + + + + map.simulate() + + + + + + + ); +} diff --git a/app/src/sections/map/MapWeights.tsx b/app/src/sections/map/MapWeights.tsx new file mode 100644 index 00000000000..b47795ea42c --- /dev/null +++ b/app/src/sections/map/MapWeights.tsx @@ -0,0 +1,196 @@ +import type React from 'react'; + +import Box from '@mui/material/Box'; +import Slider from '@mui/material/Slider'; + +import { MIN_SIM_BOUNDS } from 'src/hooks/useForceGraphSimulation'; +import { TAG_CATEGORIES } from 'src/sections/map/MapPage.helpers'; +import { colors, fontSize, typography } from 'src/theme'; + +export function MapWeights({ + weightsOpen, + setWeightsOpen, + weights, + setWeights, + minSim, + setMinSim, + resetWeights, +}: { + weightsOpen: boolean; + setWeightsOpen: React.Dispatch>; + weights: Record; + setWeights: React.Dispatch>>; + minSim: number; + setMinSim: (val: number) => void; + resetWeights: () => void; +}) { + return ( + + setWeightsOpen(o => !o)} + sx={{ + all: 'unset', + cursor: 'pointer', + 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 }))} + sx={{ + flex: 1, + color: 'var(--ink-soft)', + py: { xs: 1, sm: 1.5 }, + '& .MuiSlider-thumb': { + width: { xs: 12, sm: 14 }, + height: { xs: 12, sm: 14 }, + backgroundColor: 'var(--bg-surface)', + border: '2px solid currentColor', + }, + }} + /> + + {(weights[cat] ?? 1).toFixed(1)} + + + ))} + + + min_sim + + setMinSim(v as number)} + sx={{ + flex: 1, + color: 'var(--ink-soft)', + py: { xs: 1, sm: 1.5 }, + '& .MuiSlider-thumb': { + width: { xs: 12, sm: 14 }, + height: { xs: 12, sm: 14 }, + backgroundColor: 'var(--bg-surface)', + border: '2px solid currentColor', + }, + }} + /> + + {minSim.toFixed(2)} + + + + + reset + + + + )} + + ); +} diff --git a/app/src/sections/map/index.ts b/app/src/sections/map/index.ts new file mode 100644 index 00000000000..ecd4c7386c8 --- /dev/null +++ b/app/src/sections/map/index.ts @@ -0,0 +1,11 @@ +export { MapHeader } from './MapHeader'; +export { MapSearch } from './MapSearch'; +export { MapLegend } from './MapLegend'; +export { MapWeights } from './MapWeights'; +export { MapPinMarker } from './MapPinMarker'; +export { MapHoverPanel } from './MapHoverPanel'; +export { MapLoadingError } from './MapLoadingError'; +export { MapCanvas } from './MapCanvas'; +export { MapSettlingGate } from './MapSettlingGate'; +export { MapA11yFallback } from './MapA11yFallback'; +export * from './MapPage.helpers';