diff --git a/app/src/pages/MapPage.tsx b/app/src/pages/MapPage.tsx index 2d4eedd6234..0109736b856 100644 --- a/app/src/pages/MapPage.tsx +++ b/app/src/pages/MapPage.tsx @@ -1,11 +1,9 @@ import { useEffect, useMemo, useRef, useState } from 'react'; -import ForceGraph2D from 'react-force-graph-2d'; import { Link, 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'; @@ -13,61 +11,29 @@ import { useAnalytics } from 'src/hooks'; import { CLUSTER_COLORS, colorFor, - MIN_SIM_BOUNDS, NODE_SIZE, useForceGraphSimulation, } from 'src/hooks/useForceGraphSimulation'; import { useTheme } from 'src/hooks/useLayoutContext'; import { buildVariantUrl, - ensureNodeTier, fitToBox, - type MapLink, type MapNode, nodeAspectRatio, - pickBestLoadedTier, - pickTier, - type ResolutionTier, type SpecMapItem, - TAG_CATEGORIES, } from 'src/pages/MapPage.helpers'; import { specPath } from 'src/routes/paths'; +import type { ForceGraphInstance } from 'src/sections/map/MapForceGraph'; +import { MapForceGraph } from 'src/sections/map/MapForceGraph'; +import { MapHoverPanel } from 'src/sections/map/MapHoverPanel'; +import { MapLegend } from 'src/sections/map/MapLegend'; +import { visuallyHiddenSx } from 'src/sections/map/MapPage.utils'; +import { MapPinMarker } from 'src/sections/map/MapPinMarker'; +import { MapSearchPill } from 'src/sections/map/MapSearchPill'; +import { MapSettlingGate } from 'src/sections/map/MapSettlingGate'; +import { MapWeightsPanel } from 'src/sections/map/MapWeightsPanel'; 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)'; -} - export function MapPage() { const { trackPageview, trackEvent } = useAnalytics(); const { isDark } = useTheme(); @@ -76,8 +42,7 @@ export function MapPage() { // refs // ForceGraph2D's TypeScript surface for the imperative ref is non-trivial; the // generated types live in dist and aren't worth re-typing here. Treat as opaque. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const fgRef = useRef(null); + const fgRef = useRef(null); const containerRef = useRef(null); // Simulation/data orchestration: spec loading, weights, the KNN graph @@ -176,11 +141,13 @@ export function MapPage() { const panelNodeId = hoverId ?? pinnedId ?? lastPanelNodeIdRef.current; if (panelNodeId !== null) lastPanelNodeIdRef.current = panelNodeId; - // Clean up any pending hover-debounce timer on unmount. + // Clean up unknown pending hover-debounce timer on unmount. useEffect(() => { + // capture the ref for cleanup + const timerRef = hoverTimerRef; return () => { - if (hoverTimerRef.current != null) { - window.clearTimeout(hoverTimerRef.current); + if (timerRef.current != null) { + window.clearTimeout(timerRef.current); } }; }, []); @@ -274,7 +241,7 @@ export function MapPage() { // of useEffect to avoid set-state-in-effect: it's a one-line followup // to a user event, not external sync.) - // Cmd/Ctrl+K focuses the search pill from anywhere on the page. Always + // Cmd/Ctrl+K focuses the search pill from unknownwhere on the page. Always // preventDefault so the browser's own ⌘K (Chrome address bar) doesn't fire. useEffect(() => { const onKey = (e: KeyboardEvent) => { @@ -291,7 +258,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 @@ -344,10 +310,10 @@ export function MapPage() { return ( <> <> - map() — anyplot + map() — unknownplot {/* 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. */} + the unknownplot logo / nav links. */} - {/* 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 && ( )} - {/* 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 && ( @@ -975,306 +497,31 @@ export function MapPage() { {/* 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); - }} + size={size} + forceConfig={forceConfig} + hoverId={hoverId} + hoverType={hoverType} + neighbors={neighbors} + nodeById={nodeById} + isDark={isDark} 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} + setPinnedId={setPinnedId} + setHoverId={setHoverId} + hoverTimerRef={hoverTimerRef} + markSettled={markSettled} + handleEngineTick={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. */} diff --git a/app/src/sections/map/MapForceGraph.tsx b/app/src/sections/map/MapForceGraph.tsx new file mode 100644 index 00000000000..74e8adc7a94 --- /dev/null +++ b/app/src/sections/map/MapForceGraph.tsx @@ -0,0 +1,231 @@ +import { type MutableRefObject, type RefObject } from 'react'; + +import ForceGraph2D from 'react-force-graph-2d'; + +export interface ForceGraphInstance { + refresh?: () => void; + centerAt?: (x: number, y: number, z: number) => void; + zoom?: ((z: number, ms: number) => void) & (() => number); + graph2ScreenCoords?: (x: number, y: number) => { x: number; y: number }; + d3Force: (force: string, args?: unknown) => unknown; + d3ReheatSimulation?: () => void; + __forcesWired?: boolean; +} + +import { + colorFor, + type MapForceConfig, + type MapGraphData, + NODE_SIZE, +} from 'src/hooks/useForceGraphSimulation'; +import { + ensureNodeTier, + fitToBox, + type MapLink, + type MapNode, + nodeAspectRatio, + pickBestLoadedTier, + pickTier, + type ResolutionTier, +} from 'src/pages/MapPage.helpers'; +import { linkEndId, strokeFor } from 'src/sections/map/MapPage.utils'; +import { colors } from 'src/theme'; + +type WithCoords = MapNode & { x?: number; y?: number }; + +interface MapForceGraphProps { + fgRef: RefObject; + graphData: MapGraphData; + size: { w: number; h: number }; + forceConfig: MapForceConfig; + hoverId: string | null; + hoverType: string | null; + neighbors: Map>; + nodeById: Map; + isDark: boolean; + onNodeClick: (node: MapNode) => void; + setPinnedId: (id: string | null) => void; + setHoverId: (id: string | null) => void; + hoverTimerRef: MutableRefObject; + markSettled: () => void; + handleEngineTick: () => void; +} + +export function MapForceGraph({ + fgRef, + graphData, + size, + forceConfig, + hoverId, + hoverType, + neighbors, + nodeById, + isDark, + onNodeClick, + setPinnedId, + setHoverId, + hoverTimerRef, + markSettled, + handleEngineTick, +}: MapForceGraphProps) { + return ( + (n as MapNode).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 as (node: object) => void} + onBackgroundClick={() => { + setPinnedId(null); + setHoverId(null); + }} + onNodeHover={(n: object | null) => { + const node = n as MapNode | null; + if (hoverTimerRef.current != null) { + window.clearTimeout(hoverTimerRef.current); + hoverTimerRef.current = null; + } + if (node) { + hoverTimerRef.current = window.setTimeout(() => { + setHoverId(node.id); + hoverTimerRef.current = null; + }, 100) as unknown as number; + } 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; + // @ts-expect-error D3 force types are missing + fg.d3Force('charge')?.strength(forceConfig.chargeStrength); + const linkForce = fg.d3Force('link') as { + distance: (d: unknown) => void; + strength: (s: unknown) => void; + }; + if (linkForce) { + linkForce.distance(forceConfig.linkDistance); + linkForce.strength(forceConfig.linkStrength); + } + fg.d3Force('collide', forceConfig.createCollideForce()); + const centerForce = fg.d3Force('center') as { strength?: (s: unknown) => void } | undefined; + centerForce?.strength?.(forceConfig.centerGravity); + fg.d3Force('outlier-squash', forceConfig.createOutlierSquashForce()); + fg.__forcesWired = true; + fg.d3ReheatSimulation?.(); + }} + onEngineTick={handleEngineTick} + /> + ); +} diff --git a/app/src/sections/map/MapHoverPanel.tsx b/app/src/sections/map/MapHoverPanel.tsx new file mode 100644 index 00000000000..3286213c0f0 --- /dev/null +++ b/app/src/sections/map/MapHoverPanel.tsx @@ -0,0 +1,113 @@ +import Box from '@mui/material/Box'; + +import { fontSize, typography } from 'src/theme'; + +interface MapHoverPanelProps { + hoverId: string | null; + pinnedId: string | null; + panelData: { + title: string; + previewUrl: string | null; + plotType: string | null; + tags: string[]; + chipColor?: string | null; + } | null; + isDark: boolean; +} + +export function MapHoverPanel({ hoverId, pinnedId, panelData, isDark }: MapHoverPanelProps) { + return ( + + {panelData && ( + <> + {panelData.previewUrl ? ( + + ) : ( + + )} + + + {panelData.title} + + {panelData.plotType && ( + + + + {panelData.plotType} + + + )} + {panelData.tags.length > 0 && ( + + {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..cbd15d0300a --- /dev/null +++ b/app/src/sections/map/MapLegend.tsx @@ -0,0 +1,104 @@ +import Box from '@mui/material/Box'; + +import { CLUSTER_COLORS } from 'src/hooks/useForceGraphSimulation'; +import { fontSize, typography } from 'src/theme'; + +interface MapLegendProps { + graphData: { + topTypes: string[]; + typeCounts: Map; + }; + legendOpen: boolean; + setLegendOpen: (open: boolean | ((prev: boolean) => boolean)) => void; + activeCategory: string; + hoverType: string | null; + setHoverType: (type: string | null) => void; +} + +export function MapLegend({ + graphData, + legendOpen, + setLegendOpen, + activeCategory, + hoverType, + setHoverType, +}: MapLegendProps) { + if (graphData.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} + {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} + + + ); + })} + + + ); +} diff --git a/app/src/sections/map/MapPage.utils.test.ts b/app/src/sections/map/MapPage.utils.test.ts new file mode 100644 index 00000000000..787231212f3 --- /dev/null +++ b/app/src/sections/map/MapPage.utils.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; + +import type { MapLink } from 'src/pages/MapPage.helpers'; +import { colors } from 'src/theme'; + +import { linkEndId, strokeFor } from './MapPage.utils'; + +describe('linkEndId', () => { + it('returns string if end is string', () => { + expect(linkEndId('node-1')).toBe('node-1'); + }); + + it('returns id if end is an object with id', () => { + expect(linkEndId({ id: 'node-2' } as unknown as MapLink['source'])).toBe('node-2'); + }); + + it('returns undefined if end is an object without id', () => { + expect(linkEndId({} as unknown as MapLink['source'])).toBeUndefined(); + }); +}); + +describe('strokeFor', () => { + it('returns hover color (or primary) when hovered', () => { + expect(strokeFor(false, true, '#ff0000')).toBe('#ff0000'); + expect(strokeFor(false, true, null)).toBe(colors.primary); + }); + + it('returns node color when not hovered but color is provided', () => { + expect(strokeFor(false, false, '#00ff00')).toBe('#00ff00'); + }); + + it('returns neutral hairline for dark mode when no color provided', () => { + expect(strokeFor(true, false, null)).toBe('rgba(240,239,232,0.18)'); + }); + + it('returns neutral hairline for light mode when no color provided', () => { + expect(strokeFor(false, false, null)).toBe('rgba(26,26,23,0.18)'); + }); +}); diff --git a/app/src/sections/map/MapPage.utils.ts b/app/src/sections/map/MapPage.utils.ts new file mode 100644 index 00000000000..4de0e8911e9 --- /dev/null +++ b/app/src/sections/map/MapPage.utils.ts @@ -0,0 +1,25 @@ +import type { MapLink } from 'src/pages/MapPage.helpers'; +import { colors } from 'src/theme'; + +export 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 linkEndId(end: MapLink['source']): string | undefined { + if (typeof end === 'string') return end; + return (end as { id?: string })?.id; +} + +export 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)'; +} diff --git a/app/src/sections/map/MapPinMarker.tsx b/app/src/sections/map/MapPinMarker.tsx new file mode 100644 index 00000000000..cc8814ebe65 --- /dev/null +++ b/app/src/sections/map/MapPinMarker.tsx @@ -0,0 +1,41 @@ +import Box from '@mui/material/Box'; + +interface MapPinMarkerProps { + pinnedId: string | null; + pinScreen: { x: number; y: number; w: number; h: number } | null; + pinColor: string; +} + +export function MapPinMarker({ pinnedId, pinScreen, pinColor }: MapPinMarkerProps) { + if (!pinnedId || !pinScreen) return null; + + return ( + + ); +} diff --git a/app/src/sections/map/MapSearchPill.tsx b/app/src/sections/map/MapSearchPill.tsx new file mode 100644 index 00000000000..863c94cb98a --- /dev/null +++ b/app/src/sections/map/MapSearchPill.tsx @@ -0,0 +1,157 @@ +import { type RefObject } from 'react'; + +import Box from '@mui/material/Box'; + +import { type SpecMapItem } from 'src/pages/MapPage.helpers'; +import { colors, fontSize, typography } from 'src/theme'; + +interface MapSearchPillProps { + searchQuery: string; + setSearchQuery: (q: string) => void; + searchOpen: boolean; + setSearchOpen: (open: boolean) => void; + searchIdx: number; + setSearchIdx: (idx: number | ((prev: number) => number)) => void; + searchMatches: SpecMapItem[]; + searchInputRef: RefObject; + selectMatch: (spec: SpecMapItem) => void; +} + +export function MapSearchPill({ + searchQuery, + setSearchQuery, + searchOpen, + setSearchOpen, + searchIdx, + setSearchIdx, + searchMatches, + searchInputRef, + selectMatch, +}: MapSearchPillProps) { + return ( + + { + setSearchQuery((e.target as HTMLInputElement).value); + setSearchIdx(0); + }} + onFocus={() => setSearchOpen(true)} + onBlur={() => window.setTimeout(() => setSearchOpen(false), 150)} + onKeyDown={e => { + if (e.key === 'ArrowDown') { + e.preventDefault(); + setSearchIdx((i: number) => Math.min(i + 1, Math.max(0, searchMatches.length - 1))); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setSearchIdx((i: number) => 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', + fontFamily: typography.mono, + 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..b9dcd544a65 --- /dev/null +++ b/app/src/sections/map/MapSettlingGate.tsx @@ -0,0 +1,79 @@ +import Box from '@mui/material/Box'; +import CircularProgress from '@mui/material/CircularProgress'; + +import { colors, fontSize, typography } from 'src/theme'; + +interface MapSettlingGateProps { + ready: boolean; + settled: boolean; + isDark: boolean; + tickProgress: number; +} + +export function MapSettlingGate({ ready, settled, isDark, tickProgress }: MapSettlingGateProps) { + if (!ready) return null; + + return ( + + + + + map.simulate() + + + + + + + ); +} diff --git a/app/src/sections/map/MapWeightsPanel.tsx b/app/src/sections/map/MapWeightsPanel.tsx new file mode 100644 index 00000000000..315055797a8 --- /dev/null +++ b/app/src/sections/map/MapWeightsPanel.tsx @@ -0,0 +1,180 @@ +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/pages/MapPage.helpers'; +import { colors, fontSize, typography } from 'src/theme'; + +interface MapWeightsPanelProps { + weights: Record; + setWeights: (w: (prev: Record) => Record) => void; + minSim: number; + setMinSim: (v: number) => void; + resetWeights: () => void; + weightsOpen: boolean; + setWeightsOpen: (open: boolean | ((prev: boolean) => boolean)) => void; +} + +export function MapWeightsPanel({ + weights, + setWeights, + minSim, + setMinSim, + resetWeights, + weightsOpen, + setWeightsOpen, +}: MapWeightsPanelProps) { + 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 }))} + min={0} + max={5} + step={0.1} + size="small" + sx={{ + flex: 1, + color: colors.primary, + py: { xs: 0.25, sm: 0.5 }, + '& .MuiSlider-rail': { opacity: 0.25 }, + }} + /> + + {weights[cat].toFixed(1)} + + + ))} + + + 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 + + + + )} + + ); +}