diff --git a/app/src/pages/DebugPage.tsx b/app/src/pages/DebugPage.tsx index 3d2a8c57fed..cb5b874521c 100644 --- a/app/src/pages/DebugPage.tsx +++ b/app/src/pages/DebugPage.tsx @@ -1,24 +1,28 @@ import { useEffect, useMemo, useState } from 'react'; -import { Link as RouterLink } from 'react-router-dom'; - -import CheckIcon from '@mui/icons-material/Check'; -import ContentCopyIcon from '@mui/icons-material/ContentCopy'; import Box from '@mui/material/Box'; -import IconButton from '@mui/material/IconButton'; -import Link from '@mui/material/Link'; -import Tooltip from '@mui/material/Tooltip'; import Typography from '@mui/material/Typography'; import { SectionHeader } from 'src/components/SectionHeader'; -import { DEBUG_API_URL, LIB_ABBREV, LIB_TO_LANG, LIBRARIES } from 'src/constants'; -import { useAnalytics, useCopyCode } from 'src/hooks'; +import { DEBUG_API_URL, LIBRARIES } from 'src/constants'; +import { useAnalytics } from 'src/hooks'; import { fetchWithAuth } from 'src/lib/api'; -import { specPath } from 'src/routes/paths'; import { AgentCasesSection } from 'src/sections/debug/AgentCasesSection'; import { AgentUsageSection } from 'src/sections/debug/AgentUsageSection'; -import { timeAgo } from 'src/sections/debug/format'; +import { CommonWeaknesses } from 'src/sections/debug/CommonWeaknesses'; +import type { FeedbackMessageFilter, FeedbackStatus } from 'src/sections/debug/constants'; +import { DailyActivityChart, type DailyImplPoint } from 'src/sections/debug/DailyActivityChart'; +import { countImpls } from 'src/sections/debug/debugFormat'; +import { FeedbackSection } from 'src/sections/debug/FeedbackSection'; +import type { FeedbackTopPage } from 'src/sections/debug/FeedbackTopList'; +import { KPIGrid } from 'src/sections/debug/KPIGrid'; +import { LibraryCoverage, type LibraryStats } from 'src/sections/debug/LibraryCoverage'; +import { ProblemAreas, type ProblemSpec } from 'src/sections/debug/ProblemAreas'; +import { RecentActivity, type RecentActivityData } from 'src/sections/debug/RecentActivity'; +import type { SortDir } from 'src/sections/debug/SortableHeader'; +import { SpecMatrix, SpecStatus } from 'src/sections/debug/SpecMatrix'; import { debugControlSx } from 'src/sections/debug/styles'; +import { SystemHealthRow } from 'src/sections/debug/SystemHealthRow'; import { colors, fontSize, semanticColors, typography } from 'src/theme'; import { clearAccessReloadGuard, @@ -28,53 +32,11 @@ import { setAdminHint, writeAdminToken, } from 'src/utils/adminAuth'; -import { buildClaudePrompt } from 'src/utils/claudePrompt'; // ============================================================================ // Types // ============================================================================ -interface SpecStatus { - id: string; - title: string; - updated: string | null; - avg_score: number | null; - // One score field per library in LIBRARIES — keep in sync with the registry - // (the matrix renders a column for every LIBRARIES entry and indexes this by - // id, so a missing field would read as undefined and miscount). - altair: number | null; - bokeh: number | null; - chartjs: number | null; - d3: number | null; - echarts: number | null; - ggplot2: number | null; - highcharts: number | null; - letsplot: number | null; - makie: number | null; - matplotlib: number | null; - muix: number | null; - plotly: number | null; - plotnine: number | null; - pygal: number | null; - seaborn: number | null; -} - -interface LibraryStats { - id: string; - name: string; - impl_count: number; - avg_score: number | null; - min_score: number | null; - max_score: number | null; -} - -interface ProblemSpec { - id: string; - title: string; - issue: string; - value: string | null; -} - interface SystemHealth { database_connected: boolean; api_response_time_ms: number; @@ -83,38 +45,13 @@ interface SystemHealth { total_impls_in_db: number; } -interface DailyImplPoint { - date: string; - impls_updated: number; -} - -interface RecentActivity { - spec_id: string; - spec_title: string; - library_id: string; - language_id: string; - quality_score: number | null; - generated_by: string | null; - updated: string; -} - interface WeaknessCount { text: string; count: number; } -interface FeedbackTopPage { - path: string; - count: number; - last_seen: string | null; -} - -type FeedbackStatus = 'new' | 'in_progress' | 'done' | 'wont_solve'; -const FEEDBACK_STATUS_OPTIONS: FeedbackStatus[] = ['new', 'in_progress', 'done', 'wont_solve']; - // "" → all statuses, "open" → new + in_progress (server-side pseudo-filter). // Default view shows the open queue so resolved entries don't drown out triage. -type FeedbackMessageFilter = '' | 'open' | FeedbackStatus; const DEFAULT_MESSAGE_FILTER: FeedbackMessageFilter = 'open'; interface FeedbackMessageItem { @@ -139,14 +76,13 @@ interface DebugStatus { missing_preview_specs: ProblemSpec[]; missing_tags_specs: ProblemSpec[]; daily_impls: DailyImplPoint[]; - recent_activity: RecentActivity[]; + recent_activity: RecentActivityData[]; common_weaknesses: WeaknessCount[]; system: SystemHealth; specs: SpecStatus[]; } -type SortKey = 'updated' | 'id' | 'title' | 'avg_score'; -type SortDir = 'asc' | 'desc'; +type SortKey = keyof SpecStatus | 'coverage'; // ============================================================================ // Helpers @@ -154,25 +90,6 @@ type SortDir = 'asc' | 'desc'; const LOW_SCORE_THRESHOLD = 90; -function scoreColor(score: number | null): string { - if (score === null) return 'var(--ink-muted)'; - if (score >= 90) return colors.success; - if (score >= 75) return colors.warning; - return colors.error; -} - -function formatNum(n: number): string { - if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; - if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; - return n.toLocaleString(); -} - -function formatDateShort(iso: string): string { - const d = new Date(iso); - if (Number.isNaN(d.getTime())) return iso; - return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); -} - // ============================================================================ // Component // ============================================================================ @@ -180,12 +97,6 @@ function formatDateShort(iso: string): string { const PING_INTERVAL_MS = 2000; const PING_HISTORY = 30; -function pingColor(ms: number): string { - if (ms < 75) return colors.success; - if (ms < 200) return colors.warning; - return colors.error; -} - // Admin auth for /debug endpoints (require_admin in api/routers/debug.py). // Two paths: // - Cloudflare Access cookie set on .anyplot.ai → forwarded as Cf-Access-Jwt-Assertion @@ -338,10 +249,12 @@ export function DebugPage() { }; }, [adminToken, authRequired, reloadCounter, feedbackStatusFilter]); - const updateFeedbackStatus = async (id: string, newStatus: FeedbackStatus) => { + const updateFeedbackStatus = async (id: string, newStatus: string) => { // Optimistic update — revert on failure so the dropdown stays truthful. const prev = feedbackMessages; - setFeedbackMessages(prev.map(m => (m.id === id ? { ...m, status: newStatus } : m))); + setFeedbackMessages( + prev.map(m => (m.id === id ? { ...m, status: newStatus as FeedbackStatus } : m)) + ); try { const r = await fetchWithAuth(`${DEBUG_API_URL}/debug/feedback/${id}`, adminToken, { method: 'PATCH', @@ -368,9 +281,6 @@ export function DebugPage() { setReloadCounter(c => c + 1); }; - const countImpls = (spec: SpecStatus): number => - LIBRARIES.filter(lib => spec[lib as keyof SpecStatus] !== null).length; - const hasLowScore = (spec: SpecStatus): boolean => LIBRARIES.some(lib => { const s = spec[lib as keyof SpecStatus] as number | null; @@ -526,10 +436,6 @@ export function DebugPage() { ); const avgQuality = totalImplsWithScore > 0 ? weightedScoreSum / totalImplsWithScore : null; - const maxDaily = Math.max(...data.daily_impls.map(p => p.impls_updated), 1); - const maxLibCount = Math.max(...data.library_stats.map(l => l.impl_count), 1); - const maxWeakness = Math.max(...data.common_weaknesses.map(w => w.count), 1); - const kpis: Array<{ label: string; value: number | null; suffix?: string }> = [ { label: 'specs', value: data.total_specs }, { label: 'impls', value: data.total_implementations }, @@ -539,763 +445,33 @@ export function DebugPage() { { label: 'stale specs', value: data.oldest_specs.length }, ]; - const firstDate = data.daily_impls[0]?.date ?? ''; - const lastDate = data.daily_impls[data.daily_impls.length - 1]?.date ?? ''; - return ( debug | anyplot.ai {/* Header */} debug} /> - {/* System health row */} - {(() => { - const latest = pings[pings.length - 1]; - const okPings = pings.filter(p => p.ok); - const currentOk = latest ? latest.ok : data.system.database_connected; - const currentMs = latest ? latest.ms : data.system.api_response_time_ms; - const avgMs = - okPings.length > 0 ? okPings.reduce((a, p) => a + p.ms, 0) / okPings.length : null; - const maxMs = Math.max(...pings.map(p => p.ms), 1); - return ( - - - - - database {currentOk ? 'connected' : 'down'} - - - - - - {Array.from({ length: PING_HISTORY }).map((_, i) => { - const idx = pings.length - PING_HISTORY + i; - const p = idx >= 0 ? pings[idx] : null; - if (!p) return ; - return ( - - ); - })} - - - - {currentMs.toFixed(0)}ms - - {avgMs !== null && ( - - avg {avgMs.toFixed(0)} - - )} - - - updated {new Date(data.system.timestamp).toLocaleTimeString()} - - - ); - })()} - - {/* KPI Grid */} - - {kpis.map(item => ( - - - {typeof item.value === 'number' - ? `${formatNum(item.value)}${item.suffix ?? ''}` - : '—'} - - - {item.label} - - - ))} - - - {/* Daily Activity Bar Chart */} - daily activity} /> - - implementation updates · last 30 days - - - {data.daily_impls.map(point => ( - - 0 ? 3 : 0)}%`, - bgcolor: colors.primaryDark, - opacity: 0.5, - borderRadius: '2px 2px 0 0', - minHeight: point.impls_updated > 0 ? 2 : 0, - '&:hover': { opacity: 0.8 }, - transition: 'opacity 0.15s ease', - }} - /> - - ))} - - - - {firstDate} - - - {lastDate} - - - - {/* Library Coverage */} - - libraries} /> - - - impl count · avg · min–max - - - {[...data.library_stats] - .sort((a, b) => b.impl_count - a.impl_count) - .map(lib => ( - - - {lib.name} - - - - - - {lib.impl_count} - - - {lib.avg_score ?? '—'} - - - {lib.min_score?.toFixed(0) ?? '—'}–{lib.max_score?.toFixed(0) ?? '—'} - - - ))} - - - - - - count - - - avg - - - min–max - - - - {/* Recent Activity */} - {data.recent_activity.length > 0 && ( - <> - - recent activity} /> - - - {data.recent_activity.map((act, idx) => ( - - - {timeAgo(act.updated)} - - - {act.library_id} - - - - {act.library_id} · - - {act.spec_title} - - - {act.quality_score !== null ? Math.round(act.quality_score) : '—'} - - - {act.generated_by ?? '—'} - - - ))} - - - )} - - {/* Common Weaknesses */} - {data.common_weaknesses.length > 0 && ( - <> - - common weaknesses} /> - - - {data.common_weaknesses.map(w => { - const ratio = w.count / maxWeakness; - const size = - ratio >= 0.8 - ? fontSize.md - : ratio >= 0.5 - ? fontSize.sm - : ratio >= 0.25 - ? fontSize.xs - : fontSize.xxs; - const weight = ratio >= 0.5 ? 600 : 400; - const opacity = ratio >= 0.8 ? 1 : ratio >= 0.5 ? 0.85 : ratio >= 0.25 ? 0.7 : 0.55; - return ( - - {w.text} - - {w.count} - - - ); - })} - - - )} - - {/* Problem Areas */} - - problem areas} /> - - - {[ - { title: `low scores (avg < ${LOW_SCORE_THRESHOLD})`, items: data.low_score_specs }, - { title: 'missing previews', items: data.missing_preview_specs }, - { title: 'missing tags', items: data.missing_tags_specs }, - { title: 'oldest specs', items: data.oldest_specs }, - ].map(section => ( - summary': { cursor: 'pointer', listStyle: 'none' }, - '& > summary::-webkit-details-marker': { display: 'none' }, - }} - > - - - ▸ - - - {section.title} - - - {section.items.length} - - - {section.items.length > 0 && ( - - {section.items.map((item, idx) => ( - 0 ? '1px solid var(--rule)' : 'none', - '&:hover': { bgcolor: 'var(--bg-surface)' }, - }} - > - - {item.id} - - - {item.issue} - - {item.value && ( - - {item.value} - - )} - - ))} - - )} - - ))} - - - {/* Feedback: top pages + messages with triage (issue #5662) */} - - feedback · top pages} /> - - - grouped by URL — clickable - - - - - - - - feedback · messages} /> - ) => - setFeedbackStatusFilter(e.target.value as FeedbackMessageFilter) - } - sx={{ ...debugControlSx, cursor: 'pointer', ml: 'auto' }} - > - - - {FEEDBACK_STATUS_OPTIONS.map(s => ( - - ))} - - - {feedbackMessages.length === 0 ? ( - - no messages{feedbackStatusFilter ? ` with status "${feedbackStatusFilter}"` : ''} - - ) : ( - - {feedbackMessages.map(m => ( - - - - - {m.reaction === 'thumbs_up' - ? '👍' - : m.reaction === 'thumbs_down' - ? '👎' - : m.reaction === 'idea' - ? '💡' - : m.reaction === 'bug' - ? '🪲' - : '—'} - - - {timeAgo(m.created_at)} - - - {m.path && ( - - {m.path} - - )} - - - - {m.message} - - {m.contact && ( - - contact: {m.contact} - - )} - - - {m.message && ( - - )} - ) => - updateFeedbackStatus(m.id, e.target.value as FeedbackStatus) - } - sx={{ ...debugControlSx, cursor: 'pointer', alignSelf: 'start' }} - > - {FEEDBACK_STATUS_OPTIONS.map(s => ( - - ))} - - - - ))} - - )} + + + + + + + + {/* Agent usage: the agent chat's content-free telemetry, 7 or 30 days */} @@ -1303,551 +479,23 @@ export function DebugPage() { {/* Agent cases: the triage of the agent chat's quick feedback */} - {/* Spec Matrix */} - - specs} /> - - - {/* Filter Bar */} - - ) => setSearchText(e.target.value)} - sx={{ ...debugControlSx, flex: { xs: '0 0 180px', sm: '1 1 220px' } }} - /> - setShowIncomplete(v => !v)} - sx={{ - ...debugControlSx, - cursor: 'pointer', - flexShrink: 0, - bgcolor: showIncomplete ? colors.highlight.bg : 'var(--bg-surface)', - color: showIncomplete ? colors.highlight.text : 'var(--ink-soft)', - borderColor: showIncomplete ? colors.primary : 'var(--rule)', - }} - > - incomplete {'<'} - {LIBRARIES.length} - - setShowLowScores(v => !v)} - sx={{ - ...debugControlSx, - cursor: 'pointer', - flexShrink: 0, - bgcolor: showLowScores ? colors.highlight.bg : 'var(--bg-surface)', - color: showLowScores ? colors.highlight.text : 'var(--ink-soft)', - borderColor: showLowScores ? colors.primary : 'var(--rule)', - }} - > - low scores {'<'}90 - - ) => setMissingLibrary(e.target.value)} - sx={{ ...debugControlSx, cursor: 'pointer', flexShrink: 0 }} - > - - {LIBRARIES.map(lib => ( - - ))} - - - {filteredSpecs.length} / {data.total_specs} - - - - {/* Desktop: grid matrix */} - - - {/* Header row */} - - handleSort('id')} - /> - handleSort('title')} - /> - # - handleSort('avg_score')} - center - /> - {LIBRARIES.map(lib => ( - - {LIB_ABBREV[lib] ?? lib.slice(0, 3)} - - ))} - handleSort('updated')} - /> - - - {/* Body rows */} - {filteredSpecs.map(spec => { - const implCount = countImpls(spec); - return ( - - - {spec.id} - - - {spec.title} - - 0 - ? semanticColors.mutedText - : 'var(--ink-muted)', - textAlign: 'center', - }} - > - {implCount}/{LIBRARIES.length} - - - {spec.avg_score !== null ? spec.avg_score.toFixed(1) : '—'} - - {LIBRARIES.map(lib => { - const score = spec[lib as keyof SpecStatus] as number | null; - if (score === null) { - return ( - - — - - ); - } - return ( - - {Math.round(score)} - - ); - })} - - {spec.updated ? formatDateShort(spec.updated) : '—'} - - - ); - })} - - - - {/* Mobile: card list */} - - {filteredSpecs.map(spec => ( - - - - {spec.title} - - - avg {spec.avg_score !== null ? spec.avg_score.toFixed(0) : '—'} - - - - {LIBRARIES.map(lib => { - const score = spec[lib as keyof SpecStatus] as number | null; - const missing = score === null; - const abbrev = LIB_ABBREV[lib] ?? lib.slice(0, 3); - const commonSx = { - display: 'inline-flex', - alignItems: 'center', - gap: 0.5, - px: 0.75, - py: 0.25, - borderRadius: 0.5, - fontFamily: typography.fontFamily, - fontSize: fontSize.xxs, - fontWeight: 600, - textDecoration: 'none', - bgcolor: missing ? 'var(--bg-elevated)' : `${scoreColor(score)}22`, - color: missing ? 'var(--ink-muted)' : scoreColor(score), - }; - if (missing) { - return ( - - {abbrev} - — - - ); - } - return ( - - {abbrev} - {Math.round(score)} - - ); - })} - - - - {spec.id} - - · - {spec.updated ? timeAgo(spec.updated) : 'no activity'} - - - ))} - + ); } // ============================================================================ -// Sortable header helpers -// ============================================================================ - -function FeedbackTopList({ - title, - items, - accent, -}: { - title: string; - items: FeedbackTopPage[]; - accent: string; -}) { - return ( - - - {title} - - {items.length === 0 ? ( - - no entries yet - - ) : ( - - {items.map(it => ( - - - {it.count} - - - {it.path} - - - {it.last_seen ? timeAgo(it.last_seen) : ''} - - - ))} - - )} - - ); -} - -function CopyClaudePromptButton({ - message, - path, - reaction, -}: { - message: string; - path: string | null; - reaction: string | null; -}) { - const { copied, copyToClipboard } = useCopyCode(); - return ( - - copyToClipboard(buildClaudePrompt(message, path, reaction))} - aria-label="Copy Claude Code prompt" - size="small" - sx={{ - alignSelf: 'start', - color: copied ? colors.success : 'var(--ink-muted)', - border: '1px solid var(--rule)', - borderRadius: 1, - '&:hover': { bgcolor: 'var(--bg-surface)', color: colors.primary }, - }} - > - {copied ? : } - - - ); -} - -function HeaderCell({ children, center }: { children: React.ReactNode; center?: boolean }) { - return ( - - {children} - - ); -} - -function SortableHeader({ - label, - active, - dir, - onClick, - center, -}: { - label: string; - active: boolean; - dir: SortDir; - onClick: () => void; - center?: boolean; -}) { - return ( - - {label} - {active && ( - - {dir === 'asc' ? '▲' : '▼'} - - )} - - ); -} diff --git a/app/src/pages/MapPage.helpers.ts b/app/src/pages/MapPage.helpers.ts index 1959e85df36..c619f72bdc7 100644 --- a/app/src/pages/MapPage.helpers.ts +++ b/app/src/pages/MapPage.helpers.ts @@ -14,6 +14,14 @@ import { selectPreviewUrl } from 'src/utils/themedPreview'; +/** + * ForceGraph2D's imperative instance (zoom, centerAt, d3Force, refresh, ...). + * react-force-graph-2d types it only loosely, so the map's ref stays untyped + * in this one place and every consumer shares the alias. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type ForceGraphHandle = any; + /** Backend response shape from GET /api/specs/map. Mirrors api/schemas.py::SpecMapItem. */ export interface SpecMapItem { id: string; diff --git a/app/src/pages/MapPage.tsx b/app/src/pages/MapPage.tsx index 2d4eedd6234..71845f22856 100644 --- a/app/src/pages/MapPage.tsx +++ b/app/src/pages/MapPage.tsx @@ -1,38 +1,27 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useRef } 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'; 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 { useForceGraphSimulation } from 'src/hooks/useForceGraphSimulation'; +import { type ForceGraphHandle, type MapNode } from 'src/pages/MapPage.helpers'; import { specPath } from 'src/routes/paths'; -import { colors, fontSize, typography } from 'src/theme'; +import { + MapCanvas, + MapHoverPanel, + MapLegend, + MapSearchPill, + MapWeights, + useMapResize, + useMapSearch, + useMapSelection, +} from 'src/sections/map'; +import { fontSize, typography } from 'src/theme'; // visually-hidden style — keeps the spec list readable for screen readers // even though the canvas is the primary interface. @@ -48,42 +37,23 @@ const visuallyHiddenSx = { 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(); const navigate = useNavigate(); // 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 containerRef = useRef(null); + const fgRef = useRef(null); + + // 1. page view + useEffect(() => { + trackPageview('/map'); + }, [trackPageview]); + + const { containerRef, size } = useMapResize(); // Simulation/data orchestration: spec loading, weights, the KNN graph // derivation, settling gate, search matching, and force tuning all live - // in the hook. This component keeps the canvas, the paint callbacks, and - // every handler that needs the imperative graph ref. + // in the hook. const { specs, error, @@ -106,207 +76,37 @@ export function MapPage() { forceConfig, } = useForceGraphSimulation({ onRepaint: () => fgRef.current?.refresh?.() }); - const [size, setSize] = useState<{ w: number; h: number }>({ 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 [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 - useEffect(() => { - trackPageview('/map'); - }, [trackPageview]); - - // 2. resize observer - useEffect(() => { - const el = containerRef.current; - if (!el) return; - const obs = new ResizeObserver(entries => { - const r = entries[0]?.contentRect; - if (r) setSize({ w: r.width, h: r.height }); + const { + hoverId, + setHoverId, + pinnedId, + setPinnedId, + pinScreen, + hoverType, + setHoverType, + handleNodeHover, + panelData, + pinColor, + } = useMapSelection({ + graphData, + nodeById, + fgRef, + }); + + const { searchOpen, setSearchOpen, searchIdx, setSearchIdx, searchInputRef, selectMatch } = + useMapSearch({ + nodeById, + fgRef, + setHoverId, + setPinnedId, + setSearchQuery, + trackEvent, }); - obs.observe(el); - return () => obs.disconnect(); - }, []); - - // Hover preview: when a new node becomes active, swap the panel content - // immediately. When hover ends, fall back to the pinned spec (so the - // searched node's details stay visible while the user looks around). - // If neither hover nor pin is set, the previous content lingers in the - // DOM but fades out via the opacity transition. - // - // Derived during render rather than via a useState+useEffect pair: the - // value is purely a function of (hoverId, pinnedId, last-non-null) and - // never needs to react to external systems. The ref write below is - // permitted by the project's eslint config (`react-hooks/refs: off`) — - // it's idempotent (writes the same value on identical renders) and - // happens *before* React commits, so subsequent reads see the update. - const panelNodeId = hoverId ?? pinnedId ?? lastPanelNodeIdRef.current; - if (panelNodeId !== null) lastPanelNodeIdRef.current = panelNodeId; - - // Clean up any pending hover-debounce timer on unmount. - useEffect(() => { - return () => { - if (hoverTimerRef.current != null) { - window.clearTimeout(hoverTimerRef.current); - } - }; - }, []); - - // Track the pinned node's on-screen rect so the DOM-overlay pulse marker - // stays glued to it while the user pans/zooms. Cheap (one RAF tick = a - // graph→screen coord transform + a setState that no-ops on sub-pixel - // diffs), no canvas repaint involved. - // - // When pinnedId becomes null we just stop the RAF loop — the marker - // visibility is gated on `pinnedId` at the JSX site, so there's no need - // to setState(null) inside this effect (which would trip the - // set-state-in-effect rule). pinScreen lingers at its last value but is - // simply not rendered, then gets overwritten the next time a pin lands. - useEffect(() => { - 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 - ) { - return prev; - } - return next; - }); - } - } - raf = requestAnimationFrame(tick); - }; - raf = requestAnimationFrame(tick); - return () => cancelAnimationFrame(raf); - }, [pinnedId, graphData, nodeById]); - - // 3. derive everything the corner hover-panel needs from the (lagged) - // panelNodeId, so the panel can fade out without losing its content. - const panelData = useMemo(() => { - if (!panelNodeId) return null; - const node = nodeById.get(panelNodeId); - if (!node) return null; - const ptTag = node.tags.find(t => t.startsWith('plot_type:')); - const plotType = ptTag ? ptTag.slice('plot_type:'.length) : null; - // Top 4 most distinctive tags by IDF, excluding plot_type (rendered as - // chip) and zero-IDF noise (corpus-common tags zeroed out by computeIDF). - const tags = node.tags - .filter(t => !t.startsWith('plot_type:')) - .map(t => ({ tag: t, w: graphData.idf.get(t) ?? 0 })) - .filter(x => x.w > 0) - .sort((a, b) => b.w - a.w) - .slice(0, 4) - .map(x => x.tag); - return { - title: node.title, - plotType, - chipColor: plotType ? colorFor(plotType, graphData.topTypes) : null, - tags, - // Prefer a higher-res variant for the panel — request 800-tier so the - // preview looks crisp. The browser caches per-URL, so subsequent hovers - // of the same node are instant. - previewUrl: node.thumbUrl ? buildVariantUrl(node.thumbUrl, 800) : null, - }; - }, [panelNodeId, graphData, nodeById]); - - // Pulse colour: match the pinned node's natural cluster border so the - // ring isn't a foreign green halo when the node itself is e.g. orange. - // Top-N nodes (with a colorBucket) get their cluster color; specs that - // fall outside the top-N (no colorBucket) fall back to the brand primary - // for legibility against the neutral hairline border. - const pinColor = useMemo(() => { - if (!pinnedId) return colors.primary; - const node = nodeById.get(pinnedId); - if (!node) return colors.primary; - return colorFor(node.colorBucket, graphData.topTypes) ?? colors.primary; - }, [pinnedId, nodeById, graphData.topTypes]); - // (searchIdx is reset inline in the input's onChange handler — moved out - // 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 - // preventDefault so the browser's own ⌘K (Chrome address bar) doesn't fire. - useEffect(() => { - const onKey = (e: KeyboardEvent) => { - if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { - e.preventDefault(); - searchInputRef.current?.focus(); - searchInputRef.current?.select(); - } - }; - window.addEventListener('keydown', onKey); - return () => window.removeEventListener('keydown', onKey); - }, []); - - // 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 - // check tapping a node would jump straight to the spec page — losing - // the preview-panel + pin UX entirely. On touch, we delay navigation - // until the *second* tap on the same node. const hasHover = useMediaQuery('(hover: hover)', { noSsr: true }); const onNodeClick = (node: MapNode) => { if (!hasHover && pinnedId !== node.id) { - // Touch device, first tap on a fresh node: pin + open panel — same - // 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. setPinnedId(node.id); setHoverId(node.id); trackEvent('map_node_pin', { spec: node.id }); @@ -316,29 +116,6 @@ export function MapPage() { navigate(specPath(node.id)); }; - // Fly the camera to a node and open its hover panel. Used by the search - // dropdown when the user picks a result. Does nothing until the simulation - // has placed the node (x/y populated) — by the time the user has typed a - // query, the cooldown has long since finished. - const flyTo = (id: string) => { - const fg = fgRef.current; - if (!fg) return; - const node = nodeById.get(id) as (MapNode & { x?: number; y?: number }) | undefined; - if (!node || node.x == null || node.y == null) return; - fg.centerAt?.(node.x, node.y, 800); - fg.zoom?.(2.0, 800); - setHoverId(id); - }; - - const selectMatch = (spec: SpecMapItem) => { - flyTo(spec.id); - setPinnedId(spec.id); - setSearchQuery(''); - setSearchOpen(false); - searchInputRef.current?.blur(); - trackEvent('map_search_select', { spec: spec.id }); - }; - const ready = graphData.nodes.length > 0 && size.w > 0 && size.h > 0; return ( @@ -352,15 +129,8 @@ export function MapPage() { - {/* 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. */} - {/* 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 && ( + {pinScreen && pinnedId && ( )} - {/* 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 && ( @@ -973,311 +246,26 @@ 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); - }} - 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 => (
  • diff --git a/app/src/sections/debug/CommonWeaknesses.tsx b/app/src/sections/debug/CommonWeaknesses.tsx new file mode 100644 index 00000000000..17c158ec452 --- /dev/null +++ b/app/src/sections/debug/CommonWeaknesses.tsx @@ -0,0 +1,62 @@ +import Box from '@mui/material/Box'; + +import { SectionHeader } from 'src/components/SectionHeader'; +import { fontSize, semanticColors, typography } from 'src/theme'; + +export interface WeaknessData { + text: string; + count: number; +} + +export function CommonWeaknesses({ weaknesses }: { weaknesses: WeaknessData[] }) { + if (weaknesses.length === 0) return null; + const maxWeakness = Math.max(...weaknesses.map(w => w.count), 1); + + return ( + <> + + common weaknesses} /> + + + {weaknesses.map(w => { + const ratio = w.count / maxWeakness; + const size = + ratio >= 0.8 + ? fontSize.md + : ratio >= 0.5 + ? fontSize.sm + : ratio >= 0.25 + ? fontSize.xs + : fontSize.xxs; + const weight = ratio >= 0.5 ? 600 : 400; + const opacity = ratio >= 0.8 ? 1 : ratio >= 0.5 ? 0.85 : ratio >= 0.25 ? 0.7 : 0.55; + return ( + + {w.text} + + {w.count} + + + ); + })} + + + ); +} diff --git a/app/src/sections/debug/CopyClaudePromptButton.tsx b/app/src/sections/debug/CopyClaudePromptButton.tsx new file mode 100644 index 00000000000..5c9dd155861 --- /dev/null +++ b/app/src/sections/debug/CopyClaudePromptButton.tsx @@ -0,0 +1,38 @@ +import CheckIcon from '@mui/icons-material/Check'; +import ContentCopyIcon from '@mui/icons-material/ContentCopy'; +import IconButton from '@mui/material/IconButton'; +import Tooltip from '@mui/material/Tooltip'; + +import { useCopyCode } from 'src/hooks'; +import { colors } from 'src/theme'; +import { buildClaudePrompt } from 'src/utils/claudePrompt'; + +export function CopyClaudePromptButton({ + message, + path, + reaction, +}: { + message: string; + path: string | null; + reaction: string | null; +}) { + const { copied, copyToClipboard } = useCopyCode(); + return ( + + copyToClipboard(buildClaudePrompt(message, path, reaction))} + aria-label="Copy Claude Code prompt" + size="small" + sx={{ + alignSelf: 'start', + color: copied ? colors.success : 'var(--ink-muted)', + border: '1px solid var(--rule)', + borderRadius: 1, + '&:hover': { bgcolor: 'var(--bg-surface)', color: colors.primary }, + }} + > + {copied ? : } + + + ); +} diff --git a/app/src/sections/debug/DailyActivityChart.tsx b/app/src/sections/debug/DailyActivityChart.tsx new file mode 100644 index 00000000000..8dce305a94d --- /dev/null +++ b/app/src/sections/debug/DailyActivityChart.tsx @@ -0,0 +1,73 @@ +import Box from '@mui/material/Box'; +import Tooltip from '@mui/material/Tooltip'; +import Typography from '@mui/material/Typography'; + +import { SectionHeader } from 'src/components/SectionHeader'; +import { colors, fontSize, semanticColors, typography } from 'src/theme'; + +export interface DailyImplPoint { + date: string; + impls_updated: number; +} + +export function DailyActivityChart({ data }: { data: DailyImplPoint[] }) { + const maxDaily = Math.max(...data.map(p => p.impls_updated), 1); + const firstDate = data[0]?.date ?? ''; + const lastDate = data[data.length - 1]?.date ?? ''; + + return ( + <> + daily activity} /> + + implementation updates · last 30 days + + + {data.map(point => ( + + 0 ? 3 : 0)}%`, + bgcolor: colors.primaryDark, + opacity: 0.5, + borderRadius: '2px 2px 0 0', + minHeight: point.impls_updated > 0 ? 2 : 0, + '&:hover': { opacity: 0.8 }, + transition: 'opacity 0.15s ease', + }} + /> + + ))} + + + + {firstDate} + + + {lastDate} + + + + ); +} diff --git a/app/src/sections/debug/FeedbackSection.tsx b/app/src/sections/debug/FeedbackSection.tsx new file mode 100644 index 00000000000..5b3e61558e3 --- /dev/null +++ b/app/src/sections/debug/FeedbackSection.tsx @@ -0,0 +1,205 @@ +import React from 'react'; + +import { Link as RouterLink } from 'react-router-dom'; + +import Box from '@mui/material/Box'; +import Link from '@mui/material/Link'; +import Typography from '@mui/material/Typography'; + +import { SectionHeader } from 'src/components/SectionHeader'; +import { FEEDBACK_STATUS_OPTIONS, FeedbackMessageFilter } from 'src/sections/debug/constants'; +import { CopyClaudePromptButton } from 'src/sections/debug/CopyClaudePromptButton'; +import { FeedbackTopList, FeedbackTopPage } from 'src/sections/debug/FeedbackTopList'; +import { timeAgo } from 'src/sections/debug/format'; +import { debugControlSx } from 'src/sections/debug/styles'; +import { colors, fontSize, semanticColors, typography } from 'src/theme'; + +export interface FeedbackMessage { + contact: string | null; + id: string; + reaction: string | null; + message: string | null; + path: string | null; + status: string; + created_at: string; +} + +export function FeedbackSection({ + topThumbsUp, + topThumbsDown, + feedbackStatusFilter, + setFeedbackStatusFilter, + feedbackMessages, + handleStatusChange, +}: { + topThumbsUp: FeedbackTopPage[]; + topThumbsDown: FeedbackTopPage[]; + feedbackStatusFilter: FeedbackMessageFilter; + setFeedbackStatusFilter: (val: FeedbackMessageFilter) => void; + feedbackMessages: FeedbackMessage[]; + handleStatusChange: (id: string, newStatus: string) => void; +}) { + return ( + <> + {/* Feedback: top pages + messages with triage (issue #5662) */} + + feedback · top pages} /> + + + grouped by URL — clickable + + + + + + + + feedback · messages} /> + ) => + setFeedbackStatusFilter(e.target.value as FeedbackMessageFilter) + } + sx={{ ...debugControlSx, cursor: 'pointer', ml: 'auto' }} + > + + + {FEEDBACK_STATUS_OPTIONS.map(s => ( + + ))} + + + {feedbackMessages.length === 0 ? ( + + no messages{feedbackStatusFilter ? ` with status "${feedbackStatusFilter}"` : ''} + + ) : ( + + {feedbackMessages.map(m => ( + + + + + {m.reaction === 'thumbs_up' + ? '👍' + : m.reaction === 'thumbs_down' + ? '👎' + : m.reaction === 'idea' + ? '💡' + : m.reaction === 'bug' + ? '🪲' + : '—'} + + + {timeAgo(m.created_at)} + + + {m.path && ( + + {m.path} + + )} + + + + {m.message} + + {m.contact && ( + + contact: {m.contact} + + )} + + + {m.message && ( + + )} + ) => + handleStatusChange( + m.id, + e.target.value as import('src/sections/debug/constants').FeedbackStatus + ) + } + sx={{ ...debugControlSx, cursor: 'pointer', alignSelf: 'start' }} + > + {FEEDBACK_STATUS_OPTIONS.map(s => ( + + ))} + + + + ))} + + )} + + ); +} diff --git a/app/src/sections/debug/FeedbackTopList.tsx b/app/src/sections/debug/FeedbackTopList.tsx new file mode 100644 index 00000000000..abf5c66ea05 --- /dev/null +++ b/app/src/sections/debug/FeedbackTopList.tsx @@ -0,0 +1,105 @@ +import { Link as RouterLink } from 'react-router-dom'; + +import Box from '@mui/material/Box'; +import Link from '@mui/material/Link'; +import Typography from '@mui/material/Typography'; + +import { timeAgo } from 'src/sections/debug/format'; +import { colors, fontSize, semanticColors, typography } from 'src/theme'; + +export interface FeedbackTopPage { + path: string; + count: number; + last_seen: string | null; +} + +export function FeedbackTopList({ + title, + items, + accent, +}: { + title: string; + items: FeedbackTopPage[]; + accent: string; +}) { + return ( + + + {title} + + {items.length === 0 ? ( + + no entries yet + + ) : ( + + {items.map(it => ( + + + {it.count} + + + {it.path} + + + {it.last_seen ? timeAgo(it.last_seen) : ''} + + + ))} + + )} + + ); +} diff --git a/app/src/sections/debug/HeaderCell.tsx b/app/src/sections/debug/HeaderCell.tsx new file mode 100644 index 00000000000..99f162721cf --- /dev/null +++ b/app/src/sections/debug/HeaderCell.tsx @@ -0,0 +1,19 @@ +import Typography from '@mui/material/Typography'; + +import { fontSize, typography } from 'src/theme'; + +export function HeaderCell({ children, center }: { children: React.ReactNode; center?: boolean }) { + return ( + + {children} + + ); +} diff --git a/app/src/sections/debug/KPIGrid.tsx b/app/src/sections/debug/KPIGrid.tsx new file mode 100644 index 00000000000..380a6e90eed --- /dev/null +++ b/app/src/sections/debug/KPIGrid.tsx @@ -0,0 +1,53 @@ +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; + +import { formatNum } from 'src/sections/debug/debugFormat'; +import { fontSize, semanticColors, typography } from 'src/theme'; + +export interface KPIItem { + label: string; + value: number | null; + suffix?: string; +} + +export function KPIGrid({ kpis }: { kpis: KPIItem[] }) { + return ( + + {kpis.map(item => ( + + + {typeof item.value === 'number' ? `${formatNum(item.value)}${item.suffix ?? ''}` : '—'} + + + {item.label} + + + ))} + + ); +} diff --git a/app/src/sections/debug/LibraryCoverage.tsx b/app/src/sections/debug/LibraryCoverage.tsx new file mode 100644 index 00000000000..aabd877ba98 --- /dev/null +++ b/app/src/sections/debug/LibraryCoverage.tsx @@ -0,0 +1,138 @@ +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; + +import { SectionHeader } from 'src/components/SectionHeader'; +import { scoreColor } from 'src/sections/debug/debugFormat'; +import { colors, fontSize, semanticColors, typography } from 'src/theme'; + +export interface LibraryStats { + id: string; + name: string; + impl_count: number; + avg_score: number | null; + min_score: number | null; + max_score: number | null; +} + +export function LibraryCoverage({ stats }: { stats: LibraryStats[] }) { + const maxLibCount = Math.max(...stats.map(lib => lib.impl_count), 1); + + return ( + <> + {/* Library Coverage */} + + libraries} /> + + + impl count · avg · min–max + + + {[...stats] + .sort((a, b) => b.impl_count - a.impl_count) + .map(lib => ( + + + {lib.name} + + + + + + {lib.impl_count} + + + {lib.avg_score ?? '—'} + + + {lib.min_score?.toFixed(0) ?? '—'}–{lib.max_score?.toFixed(0) ?? '—'} + + + ))} + + + + + + count + + + avg + + + min–max + + + + ); +} diff --git a/app/src/sections/debug/ProblemAreas.tsx b/app/src/sections/debug/ProblemAreas.tsx new file mode 100644 index 00000000000..7ac552eca5f --- /dev/null +++ b/app/src/sections/debug/ProblemAreas.tsx @@ -0,0 +1,144 @@ +import { Link as RouterLink } from 'react-router-dom'; + +import Box from '@mui/material/Box'; +import Link from '@mui/material/Link'; + +import { SectionHeader } from 'src/components/SectionHeader'; +import { specPath } from 'src/routes/paths'; +import { colors, fontSize, semanticColors, typography } from 'src/theme'; + +export interface ProblemSpec { + id: string; + title: string; + issue: string; + value: string | null; +} + +export function ProblemAreas({ + lowScoreThreshold, + lowScoreSpecs, + missingPreviewSpecs, + missingTagsSpecs, + oldestSpecs, +}: { + lowScoreThreshold: number; + lowScoreSpecs: ProblemSpec[]; + missingPreviewSpecs: ProblemSpec[]; + missingTagsSpecs: ProblemSpec[]; + oldestSpecs: ProblemSpec[]; +}) { + return ( + <> + + problem areas} /> + + + {[ + { title: `low scores (avg < ${lowScoreThreshold})`, items: lowScoreSpecs }, + { title: 'missing previews', items: missingPreviewSpecs }, + { title: 'missing tags', items: missingTagsSpecs }, + { title: 'oldest specs', items: oldestSpecs }, + ].map(section => ( + summary': { cursor: 'pointer', listStyle: 'none' }, + '& > summary::-webkit-details-marker': { display: 'none' }, + }} + > + + + ▸ + + + {section.title} + + + {section.items.length} + + + {section.items.length > 0 && ( + + {section.items.map((item, idx) => ( + 0 ? '1px solid var(--rule)' : 'none', + '&:hover': { bgcolor: 'var(--bg-surface)' }, + }} + > + + {item.id} + + + {item.issue} + + {item.value && ( + + {item.value} + + )} + + ))} + + )} + + ))} + + + ); +} diff --git a/app/src/sections/debug/RecentActivity.tsx b/app/src/sections/debug/RecentActivity.tsx new file mode 100644 index 00000000000..eee6ded03f4 --- /dev/null +++ b/app/src/sections/debug/RecentActivity.tsx @@ -0,0 +1,126 @@ +import { Link as RouterLink } from 'react-router-dom'; + +import Box from '@mui/material/Box'; +import Link from '@mui/material/Link'; +import Typography from '@mui/material/Typography'; + +import { SectionHeader } from 'src/components/SectionHeader'; +import { specPath } from 'src/routes/paths'; +import { scoreColor } from 'src/sections/debug/debugFormat'; +import { timeAgo } from 'src/sections/debug/format'; +import { fontSize, semanticColors, typography } from 'src/theme'; + +export interface RecentActivityData { + spec_id: string; + spec_title: string; + library_id: string; + language_id: string; + quality_score: number | null; + generated_by: string | null; + updated: string; +} + +export function RecentActivity({ activity }: { activity: RecentActivityData[] }) { + if (activity.length === 0) return null; + return ( + <> + + recent activity} /> + + + {activity.map((act, idx) => ( + + + {timeAgo(act.updated)} + + + {act.library_id} + + + + {act.library_id} · + + {act.spec_title} + + + {act.quality_score !== null ? Math.round(act.quality_score) : '—'} + + + {act.generated_by ?? '—'} + + + ))} + + + ); +} diff --git a/app/src/sections/debug/SortableHeader.tsx b/app/src/sections/debug/SortableHeader.tsx new file mode 100644 index 00000000000..3ccda8b34da --- /dev/null +++ b/app/src/sections/debug/SortableHeader.tsx @@ -0,0 +1,50 @@ +import Box from '@mui/material/Box'; + +import { colors, fontSize, typography } from 'src/theme'; + +export type SortDir = 'asc' | 'desc'; + +export function SortableHeader({ + label, + active, + dir, + onClick, + center, +}: { + label: string; + active: boolean; + dir: SortDir; + onClick: () => void; + center?: boolean; +}) { + return ( + + {label} + {active && ( + + {dir === 'asc' ? '▲' : '▼'} + + )} + + ); +} diff --git a/app/src/sections/debug/SpecMatrix.tsx b/app/src/sections/debug/SpecMatrix.tsx new file mode 100644 index 00000000000..8deaab83774 --- /dev/null +++ b/app/src/sections/debug/SpecMatrix.tsx @@ -0,0 +1,433 @@ +import React from 'react'; + +import { Link as RouterLink } from 'react-router-dom'; + +import Box from '@mui/material/Box'; +import Link from '@mui/material/Link'; +import Typography from '@mui/material/Typography'; + +import { SectionHeader } from 'src/components/SectionHeader'; +import { LIB_ABBREV, LIB_TO_LANG, LIBRARIES } from 'src/constants'; +import { specPath } from 'src/routes/paths'; +import { countImpls, formatDateShort, scoreColor } from 'src/sections/debug/debugFormat'; +import { timeAgo } from 'src/sections/debug/format'; +import { HeaderCell } from 'src/sections/debug/HeaderCell'; +import { SortableHeader, SortDir } from 'src/sections/debug/SortableHeader'; +import { debugControlSx } from 'src/sections/debug/styles'; +import { colors, fontSize, semanticColors, typography } from 'src/theme'; + +export interface SpecStatus { + id: string; + title: string; + updated: string | null; + avg_score: number | null; + altair: number | null; + bokeh: number | null; + chartjs: number | null; + d3: number | null; + echarts: number | null; + ggplot2: number | null; + highcharts: number | null; + letsplot: number | null; + makie: number | null; + matplotlib: number | null; + muix: number | null; + plotly: number | null; + plotnine: number | null; + pygal: number | null; + seaborn: number | null; +} + +export function SpecMatrix({ + searchText, + setSearchText, + showIncomplete, + setShowIncomplete, + showLowScores, + setShowLowScores, + missingLibrary, + setMissingLibrary, + filteredSpecs, + totalSpecs, + sortCol, + sortDir, + handleSort, +}: { + searchText: string; + setSearchText: (val: string) => void; + showIncomplete: boolean; + setShowIncomplete: (val: boolean | ((prev: boolean) => boolean)) => void; + showLowScores: boolean; + setShowLowScores: (val: boolean | ((prev: boolean) => boolean)) => void; + missingLibrary: string; + setMissingLibrary: (val: string) => void; + filteredSpecs: SpecStatus[]; + totalSpecs: number; + sortCol: keyof SpecStatus | 'coverage'; + sortDir: SortDir; + handleSort: (col: keyof SpecStatus | 'coverage') => void; +}) { + return ( + <> + {/* Spec Matrix */} + + specs} /> + + + {/* Filter Bar */} + + ) => setSearchText(e.target.value)} + sx={{ ...debugControlSx, flex: { xs: '0 0 180px', sm: '1 1 220px' } }} + /> + setShowIncomplete(v => !v)} + sx={{ + ...debugControlSx, + cursor: 'pointer', + flexShrink: 0, + bgcolor: showIncomplete ? colors.highlight.bg : 'var(--bg-surface)', + color: showIncomplete ? colors.highlight.text : 'var(--ink-soft)', + borderColor: showIncomplete ? colors.primary : 'var(--rule)', + }} + > + incomplete {'<'} + {LIBRARIES.length} + + setShowLowScores(v => !v)} + sx={{ + ...debugControlSx, + cursor: 'pointer', + flexShrink: 0, + bgcolor: showLowScores ? colors.highlight.bg : 'var(--bg-surface)', + color: showLowScores ? colors.highlight.text : 'var(--ink-soft)', + borderColor: showLowScores ? colors.primary : 'var(--rule)', + }} + > + low scores {'<'}90 + + ) => setMissingLibrary(e.target.value)} + sx={{ ...debugControlSx, cursor: 'pointer', flexShrink: 0 }} + > + + {LIBRARIES.map(lib => ( + + ))} + + + {filteredSpecs.length} / {totalSpecs} + + + + {/* Desktop: grid matrix */} + + + {/* Header row */} + + handleSort('id')} + /> + handleSort('title')} + /> + # + handleSort('avg_score')} + center + /> + {LIBRARIES.map(lib => ( + + {LIB_ABBREV[lib] ?? lib.slice(0, 3)} + + ))} + handleSort('updated')} + /> + + + {/* Body rows */} + {filteredSpecs.map(spec => { + const implCount = countImpls(spec); + return ( + + + {spec.id} + + + {spec.title} + + 0 + ? semanticColors.mutedText + : 'var(--ink-muted)', + textAlign: 'center', + }} + > + {implCount}/{LIBRARIES.length} + + + {spec.avg_score !== null ? spec.avg_score.toFixed(1) : '—'} + + {LIBRARIES.map(lib => { + const score = spec[lib as keyof SpecStatus] as number | null; + if (score === null) { + return ( + + — + + ); + } + return ( + + {Math.round(score)} + + ); + })} + + {spec.updated ? formatDateShort(spec.updated) : '—'} + + + ); + })} + + + + {/* Mobile: card list */} + + {filteredSpecs.map(spec => ( + + + + {spec.title} + + + avg {spec.avg_score !== null ? spec.avg_score.toFixed(0) : '—'} + + + + {LIBRARIES.map(lib => { + const score = spec[lib as keyof SpecStatus] as number | null; + const missing = score === null; + const abbrev = LIB_ABBREV[lib] ?? lib.slice(0, 3); + const commonSx = { + display: 'inline-flex', + alignItems: 'center', + gap: 0.5, + px: 0.75, + py: 0.25, + borderRadius: 0.5, + fontFamily: typography.fontFamily, + fontSize: fontSize.xxs, + fontWeight: 600, + textDecoration: 'none', + bgcolor: missing ? 'var(--bg-elevated)' : `${scoreColor(score)}22`, + color: missing ? 'var(--ink-muted)' : scoreColor(score), + }; + if (missing) { + return ( + + {abbrev} + — + + ); + } + return ( + + {abbrev} + {Math.round(score)} + + ); + })} + + + + {spec.id} + + · + {spec.updated ? timeAgo(spec.updated) : 'no activity'} + + + ))} + + + ); +} diff --git a/app/src/sections/debug/SystemHealthRow.tsx b/app/src/sections/debug/SystemHealthRow.tsx new file mode 100644 index 00000000000..36e02405090 --- /dev/null +++ b/app/src/sections/debug/SystemHealthRow.tsx @@ -0,0 +1,137 @@ +import Box from '@mui/material/Box'; +import Tooltip from '@mui/material/Tooltip'; +import Typography from '@mui/material/Typography'; + +import { pingColor } from 'src/sections/debug/debugFormat'; +import { colors, fontSize, semanticColors, typography } from 'src/theme'; + +export interface PingData { + ms: number; + ok: boolean; +} + +export function SystemHealthRow({ + pings, + systemData, + pingHistory, +}: { + pings: PingData[]; + systemData: { database_connected: boolean; api_response_time_ms: number; timestamp: string }; + pingHistory: number; +}) { + const latest = pings[pings.length - 1]; + const okPings = pings.filter(p => p.ok); + const currentOk = latest ? latest.ok : systemData.database_connected; + const currentMs = latest ? latest.ms : systemData.api_response_time_ms; + const avgMs = okPings.length > 0 ? okPings.reduce((a, p) => a + p.ms, 0) / okPings.length : null; + const maxMs = Math.max(...pings.map(p => p.ms), 1); + + return ( + + + + + database {currentOk ? 'connected' : 'down'} + + + + + + {Array.from({ length: pingHistory }).map((_, i) => { + const idx = pings.length - pingHistory + i; + const p = idx >= 0 ? pings[idx] : null; + if (!p) return ; + return ( + + ); + })} + + + + {currentMs.toFixed(0)}ms + + {avgMs !== null && ( + + avg {avgMs.toFixed(0)} + + )} + + + updated {new Date(systemData.timestamp).toLocaleTimeString()} + + + ); +} diff --git a/app/src/sections/debug/constants.ts b/app/src/sections/debug/constants.ts new file mode 100644 index 00000000000..45ad6b9509e --- /dev/null +++ b/app/src/sections/debug/constants.ts @@ -0,0 +1,3 @@ +export type FeedbackStatus = 'new' | 'in_progress' | 'done' | 'wont_solve'; +export type FeedbackMessageFilter = '' | 'open' | 'new' | 'in_progress' | 'done' | 'wont_solve'; +export const FEEDBACK_STATUS_OPTIONS = ['new', 'in_progress', 'done', 'wont_solve'] as const; diff --git a/app/src/sections/debug/debugFormat.ts b/app/src/sections/debug/debugFormat.ts new file mode 100644 index 00000000000..5be679dc1bc --- /dev/null +++ b/app/src/sections/debug/debugFormat.ts @@ -0,0 +1,31 @@ +import { LIBRARIES } from 'src/constants'; +import type { SpecStatus } from 'src/sections/debug/SpecMatrix'; +import { colors } from 'src/theme'; + +export const countImpls = (spec: SpecStatus): number => + LIBRARIES.filter(lib => spec[lib as keyof SpecStatus] !== null).length; + +export function scoreColor(score: number | null): string { + if (score === null) return 'var(--ink-muted)'; + if (score >= 90) return colors.success; + if (score >= 75) return colors.warning; + return colors.error; +} + +export function formatNum(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; + return n.toLocaleString(); +} + +export function formatDateShort(iso: string): string { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return iso; + return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); +} + +export function pingColor(ms: number): string { + if (ms < 75) return colors.success; + if (ms < 200) return colors.warning; + return colors.error; +} diff --git a/app/src/sections/map/MapCanvas.tsx b/app/src/sections/map/MapCanvas.tsx new file mode 100644 index 00000000000..68093785b2a --- /dev/null +++ b/app/src/sections/map/MapCanvas.tsx @@ -0,0 +1,380 @@ +import ForceGraph2D from 'react-force-graph-2d'; + +import Box from '@mui/material/Box'; +import CircularProgress from '@mui/material/CircularProgress'; + +import { + colorFor, + type MapForceConfig, + type MapGraphData, + NODE_SIZE, +} from 'src/hooks/useForceGraphSimulation'; +import { useTheme } from 'src/hooks/useLayoutContext'; +import { + ensureNodeTier, + fitToBox, + type ForceGraphHandle, + type MapLink, + type MapNode, + nodeAspectRatio, + pickBestLoadedTier, + pickTier, + type ResolutionTier, +} from 'src/pages/MapPage.helpers'; +import { colors, fontSize, typography } from 'src/theme'; + +interface MapCanvasProps { + ready: boolean; + fgRef: React.RefObject; + graphData: MapGraphData; + size: { w: number; h: number }; + forceConfig: MapForceConfig; + hoverId: string | null; + hoverType: string | null; + nodeById: Map; + neighbors: Map>; + onNodeClick: (node: MapNode) => void; + setPinnedId: (id: string | null) => void; + setHoverId: (id: string | null) => void; + onNodeHover: (n: MapNode | null) => void; + markSettled: () => void; + handleEngineTick: () => void; + settled: boolean; + tickProgress: 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)'; +} + +type WithCoords = MapNode & { x?: number; y?: number }; + +export function MapCanvas({ + ready, + fgRef, + graphData, + size, + forceConfig, + hoverId, + hoverType, + nodeById, + neighbors, + onNodeClick, + setPinnedId, + setHoverId, + onNodeHover, + markSettled, + handleEngineTick, + settled, + tickProgress, +}: MapCanvasProps) { + const { isDark } = useTheme(); + + return ( + <> + {/* 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={onNodeHover} + 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. */} + + + + + + )} + + ); +} diff --git a/app/src/sections/map/MapHoverPanel.tsx b/app/src/sections/map/MapHoverPanel.tsx new file mode 100644 index 00000000000..6b9d63c461c --- /dev/null +++ b/app/src/sections/map/MapHoverPanel.tsx @@ -0,0 +1,121 @@ +import Box from '@mui/material/Box'; + +import { useTheme } from 'src/hooks/useLayoutContext'; +import type { MapPanelData } from 'src/sections/map/useMapSelection'; +import { fontSize, typography } from 'src/theme'; + +interface MapHoverPanelProps { + hoverId: string | null; + pinnedId: string | null; + panelData: MapPanelData | null; +} + +export function MapHoverPanel({ hoverId, pinnedId, panelData }: MapHoverPanelProps) { + const { isDark } = useTheme(); + + return ( + + {panelData && ( + <> + {panelData.previewUrl ? ( + + ) : ( + + )} + + + {panelData.title} + + {panelData.plotType && ( + + + + {panelData.plotType} + + + )} + {panelData.tags.length > 0 && ( + + {panelData.tags.map((t: string) => ( + + {t} + + ))} + + )} + + + )} + + ); +} diff --git a/app/src/sections/map/MapLegend.tsx b/app/src/sections/map/MapLegend.tsx new file mode 100644 index 00000000000..52527aac4bf --- /dev/null +++ b/app/src/sections/map/MapLegend.tsx @@ -0,0 +1,111 @@ +import { useState } from 'react'; + +import Box from '@mui/material/Box'; + +import { CLUSTER_COLORS, type MapGraphData } from 'src/hooks/useForceGraphSimulation'; +import { fontSize, typography } from 'src/theme'; + +interface MapLegendProps { + graphData: MapGraphData; + activeCategory: string; + hoverType: string | null; + setHoverType: (type: string | null) => void; +} + +export function MapLegend({ graphData, activeCategory, hoverType, setHoverType }: MapLegendProps) { + const [legendOpen, setLegendOpen] = useState(false); + + if (graphData.topTypes.length === 0) { + return null; + } + + return ( + <> + {/* 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. */} + 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 ▸'} + + + {activeCategory} + {graphData.topTypes.map((t: string, i: number) => { + 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/MapSearchPill.tsx b/app/src/sections/map/MapSearchPill.tsx new file mode 100644 index 00000000000..b63fb88bdfd --- /dev/null +++ b/app/src/sections/map/MapSearchPill.tsx @@ -0,0 +1,154 @@ +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: React.Dispatch>; + searchInputRef: React.RefObject; + searchMatches: SpecMapItem[]; + selectMatch: (spec: SpecMapItem) => void; +} + +export function MapSearchPill({ + searchQuery, + setSearchQuery, + searchOpen, + setSearchOpen, + searchIdx, + setSearchIdx, + searchInputRef, + searchMatches, + 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 => 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/MapWeights.tsx b/app/src/sections/map/MapWeights.tsx new file mode 100644 index 00000000000..a20ed9b9dc8 --- /dev/null +++ b/app/src/sections/map/MapWeights.tsx @@ -0,0 +1,196 @@ +import { useState } 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/pages/MapPage.helpers'; +import { colors, fontSize, typography } from 'src/theme'; + +interface MapWeightsProps { + weights: Record; + setWeights: React.Dispatch>>; + minSim: number; + setMinSim: (val: number) => void; + resetWeights: () => void; +} + +export function MapWeights({ + weights, + setWeights, + minSim, + setMinSim, + resetWeights, +}: MapWeightsProps) { + const [weightsOpen, setWeightsOpen] = useState(false); + + return ( + + 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 + + + + )} + + ); +} diff --git a/app/src/sections/map/index.ts b/app/src/sections/map/index.ts new file mode 100644 index 00000000000..6da6f835bb7 --- /dev/null +++ b/app/src/sections/map/index.ts @@ -0,0 +1,8 @@ +export { useMapResize } from './useMapResize'; +export { useMapSelection } from './useMapSelection'; +export { useMapSearch } from './useMapSearch'; +export { MapSearchPill } from './MapSearchPill'; +export { MapLegend } from './MapLegend'; +export { MapWeights } from './MapWeights'; +export { MapHoverPanel } from './MapHoverPanel'; +export { MapCanvas } from './MapCanvas'; diff --git a/app/src/sections/map/useMapResize.test.tsx b/app/src/sections/map/useMapResize.test.tsx new file mode 100644 index 00000000000..f090a5f2ea1 --- /dev/null +++ b/app/src/sections/map/useMapResize.test.tsx @@ -0,0 +1,68 @@ +import { act, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useMapResize } from './useMapResize'; + +// jsdom doesn't ship ResizeObserver; this stub records the observed element and +// lets the test fire the callback with chosen dimensions. +type ResizeCb = (entries: { contentRect: { width: number; height: number } }[]) => void; +const observers: MockResizeObserver[] = []; +class MockResizeObserver { + cb: ResizeCb; + observed: Element | null = null; + disconnect = vi.fn(); + constructor(cb: ResizeCb) { + this.cb = cb; + observers.push(this); + } + observe(target: Element) { + this.observed = target; + } + unobserve() {} +} + +function Probe() { + const { containerRef, size } = useMapResize(); + return
    ; +} + +function renderedSize() { + const el = screen.getByTestId('map-container'); + return { w: Number(el.dataset.w), h: Number(el.dataset.h) }; +} + +describe('useMapResize', () => { + beforeEach(() => { + vi.stubGlobal('ResizeObserver', MockResizeObserver); + }); + + afterEach(() => { + observers.length = 0; + vi.unstubAllGlobals(); + }); + + it('starts at 0 by 0 and follows the observed container size', () => { + render(); + + expect(renderedSize()).toEqual({ w: 0, h: 0 }); + expect(observers).toHaveLength(1); + expect(observers[0].observed).toBe(screen.getByTestId('map-container')); + + act(() => observers[0].cb([{ contentRect: { width: 800, height: 600 } }])); + expect(renderedSize()).toEqual({ w: 800, h: 600 }); + }); + + it('ignores an empty entry list', () => { + render(); + + act(() => observers[0].cb([])); + expect(renderedSize()).toEqual({ w: 0, h: 0 }); + }); + + it('disconnects the observer on unmount', () => { + const { unmount } = render(); + + unmount(); + expect(observers[0].disconnect).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/src/sections/map/useMapResize.ts b/app/src/sections/map/useMapResize.ts new file mode 100644 index 00000000000..295aaa58d4f --- /dev/null +++ b/app/src/sections/map/useMapResize.ts @@ -0,0 +1,20 @@ +import { useEffect, useRef, useState } from 'react'; + +export function useMapResize() { + const containerRef = useRef(null); + const [size, setSize] = useState<{ w: number; h: number }>({ w: 0, h: 0 }); + + // 2. resize observer + useEffect(() => { + const el = containerRef.current; + if (!el) return; + const obs = new ResizeObserver(entries => { + const r = entries[0]?.contentRect; + if (r) setSize({ w: r.width, h: r.height }); + }); + obs.observe(el); + return () => obs.disconnect(); + }, []); + + return { containerRef, size }; +} diff --git a/app/src/sections/map/useMapSearch.test.tsx b/app/src/sections/map/useMapSearch.test.tsx new file mode 100644 index 00000000000..e32c34ffaa9 --- /dev/null +++ b/app/src/sections/map/useMapSearch.test.tsx @@ -0,0 +1,103 @@ +import { act, renderHook } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import type { MapNode, SpecMapItem } from 'src/pages/MapPage.helpers'; + +import { useMapSearch } from './useMapSearch'; + +function node(id: string, x?: number, y?: number): MapNode & { x?: number; y?: number } { + return { + id, + title: id, + tags: [], + thumbUrl: null, + imgs: new Map(), + pendingTiers: new Set(), + colorBucket: null, + x, + y, + }; +} + +function setup(nodes: MapNode[] = [], fg: object | null = { centerAt: vi.fn(), zoom: vi.fn() }) { + const deps = { + nodeById: new Map(nodes.map(n => [n.id, n])), + fgRef: { current: fg }, + setHoverId: vi.fn(), + setPinnedId: vi.fn(), + setSearchQuery: vi.fn(), + trackEvent: vi.fn(), + }; + const hook = renderHook(() => useMapSearch(deps)); + return { ...hook, deps, fg }; +} + +describe('useMapSearch', () => { + it('starts closed, on the first match, with no input attached', () => { + const { result } = setup(); + + expect(result.current.searchOpen).toBe(false); + expect(result.current.searchIdx).toBe(0); + expect(result.current.searchInputRef.current).toBeNull(); + }); + + it('focuses and selects the search input on Cmd+K and Ctrl+K', () => { + const { result } = setup(); + const input = document.createElement('input'); + const select = vi.spyOn(input, 'select'); + document.body.appendChild(input); + result.current.searchInputRef.current = input; + + for (const mod of [{ metaKey: true }, { ctrlKey: true }]) { + input.blur(); + const event = new KeyboardEvent('keydown', { key: 'K', cancelable: true, ...mod }); + window.dispatchEvent(event); + expect(document.activeElement).toBe(input); + expect(event.defaultPrevented).toBe(true); + } + expect(select).toHaveBeenCalledTimes(2); + + // A plain K is typing, not the shortcut. + input.blur(); + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k' })); + expect(document.activeElement).not.toBe(input); + input.remove(); + }); + + it('stops listening for the shortcut on unmount', () => { + const remove = vi.spyOn(window, 'removeEventListener'); + const { unmount } = setup(); + + unmount(); + expect(remove).toHaveBeenCalledWith('keydown', expect.any(Function)); + remove.mockRestore(); + }); + + it('selectMatch flies to the node, pins it, clears the query, closes the popup, and tracks it', () => { + const { result, deps, fg } = setup([node('scatter-basic', 10, 20)]); + act(() => result.current.setSearchOpen(true)); + + act(() => result.current.selectMatch({ id: 'scatter-basic' } as SpecMapItem)); + + const graph = fg as { centerAt: ReturnType; zoom: ReturnType }; + expect(graph.centerAt).toHaveBeenCalledWith(10, 20, 800); + expect(graph.zoom).toHaveBeenCalledWith(2.0, 800); + expect(deps.setHoverId).toHaveBeenCalledWith('scatter-basic'); + expect(deps.setPinnedId).toHaveBeenCalledWith('scatter-basic'); + expect(deps.setSearchQuery).toHaveBeenCalledWith(''); + expect(result.current.searchOpen).toBe(false); + expect(deps.trackEvent).toHaveBeenCalledWith('map_search_select', { spec: 'scatter-basic' }); + }); + + it('flyTo does nothing before the graph or the node position exists', () => { + const noGraph = setup([node('a', 1, 2)], null); + noGraph.result.current.flyTo('a'); + expect(noGraph.deps.setHoverId).not.toHaveBeenCalled(); + + const unplaced = setup([node('b')]); + unplaced.result.current.flyTo('b'); + const graph = unplaced.fg as { centerAt: ReturnType }; + expect(graph.centerAt).not.toHaveBeenCalled(); + expect(unplaced.deps.setHoverId).not.toHaveBeenCalled(); + }); +}); diff --git a/app/src/sections/map/useMapSearch.ts b/app/src/sections/map/useMapSearch.ts new file mode 100644 index 00000000000..d74c999a232 --- /dev/null +++ b/app/src/sections/map/useMapSearch.ts @@ -0,0 +1,65 @@ +import { useEffect, useRef, useState } from 'react'; + +import type { useAnalytics } from 'src/hooks'; +import { type ForceGraphHandle, type MapNode, type SpecMapItem } from 'src/pages/MapPage.helpers'; + +export function useMapSearch({ + nodeById, + fgRef, + setHoverId, + setPinnedId, + setSearchQuery, + trackEvent, +}: { + nodeById: Map; + fgRef: React.RefObject; + setHoverId: (id: string | null) => void; + setPinnedId: (id: string | null) => void; + setSearchQuery: (q: string) => void; + trackEvent: ReturnType['trackEvent']; +}) { + const [searchOpen, setSearchOpen] = useState(false); + const [searchIdx, setSearchIdx] = useState(0); + const searchInputRef = useRef(null); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { + e.preventDefault(); + searchInputRef.current?.focus(); + searchInputRef.current?.select(); + } + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, []); + + const flyTo = (id: string) => { + const fg = fgRef.current; + if (!fg) return; + const node = nodeById.get(id) as (MapNode & { x?: number; y?: number }) | undefined; + if (!node || node.x == null || node.y == null) return; + fg.centerAt?.(node.x, node.y, 800); + fg.zoom?.(2.0, 800); + setHoverId(id); + }; + + const selectMatch = (spec: SpecMapItem) => { + flyTo(spec.id); + setPinnedId(spec.id); + setSearchQuery(''); + setSearchOpen(false); + searchInputRef.current?.blur(); + trackEvent('map_search_select', { spec: spec.id }); + }; + + return { + searchOpen, + setSearchOpen, + searchIdx, + setSearchIdx, + searchInputRef, + flyTo, + selectMatch, + }; +} diff --git a/app/src/sections/map/useMapSelection.test.tsx b/app/src/sections/map/useMapSelection.test.tsx new file mode 100644 index 00000000000..5a86850f151 --- /dev/null +++ b/app/src/sections/map/useMapSelection.test.tsx @@ -0,0 +1,181 @@ +import { act, renderHook } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { CLUSTER_COLORS, type MapGraphData, NODE_SIZE } from 'src/hooks/useForceGraphSimulation'; +import { fitToBox, type MapNode, nodeAspectRatio } from 'src/pages/MapPage.helpers'; +import { colors } from 'src/theme'; + +import { useMapSelection } from './useMapSelection'; + +function node(overrides: Partial & { id: string }): MapNode { + return { + title: overrides.id, + tags: [], + thumbUrl: null, + imgs: new Map(), + pendingTiers: new Set(), + colorBucket: null, + ...overrides, + }; +} + +function graph(overrides: Partial = {}): MapGraphData { + return { + nodes: [], + links: [], + topTypes: [], + typeCounts: new Map(), + idf: new Map(), + ...overrides, + }; +} + +function setup(nodes: MapNode[] = [], graphData: MapGraphData = graph(), fg: object | null = null) { + const nodeById = new Map(nodes.map(n => [n.id, n])); + return renderHook(() => useMapSelection({ graphData, nodeById, fgRef: { current: fg } })); +} + +describe('useMapSelection', () => { + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it('starts with nothing hovered, pinned, or shown', () => { + const { result } = setup(); + + expect(result.current.hoverId).toBeNull(); + expect(result.current.pinnedId).toBeNull(); + expect(result.current.pinScreen).toBeNull(); + expect(result.current.hoverType).toBeNull(); + expect(result.current.panelNodeId).toBeNull(); + expect(result.current.panelData).toBeNull(); + expect(result.current.pinColor).toBe(colors.primary); + }); + + it('debounces a node hover by 100 ms and clears it at once on leave', () => { + vi.useFakeTimers(); + const a = node({ id: 'a' }); + const { result } = setup([a]); + + act(() => result.current.handleNodeHover(a)); + act(() => vi.advanceTimersByTime(99)); + expect(result.current.hoverId).toBeNull(); + act(() => vi.advanceTimersByTime(1)); + expect(result.current.hoverId).toBe('a'); + + act(() => result.current.handleNodeHover(null)); + expect(result.current.hoverId).toBeNull(); + }); + + it('a newer hover cancels the pending one', () => { + vi.useFakeTimers(); + const a = node({ id: 'a' }); + const b = node({ id: 'b' }); + const { result } = setup([a, b]); + + act(() => result.current.handleNodeHover(a)); + act(() => vi.advanceTimersByTime(50)); + act(() => result.current.handleNodeHover(b)); + act(() => vi.advanceTimersByTime(100)); + expect(result.current.hoverId).toBe('b'); + }); + + it('clears a pending hover timer on unmount', () => { + vi.useFakeTimers(); + const clear = vi.spyOn(window, 'clearTimeout'); + const a = node({ id: 'a' }); + const { result, unmount } = setup([a]); + + act(() => result.current.handleNodeHover(a)); + clear.mockClear(); + unmount(); + expect(clear).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(0); + clear.mockRestore(); + }); + + it('derives the panel from the hovered node: plot type, top tags by IDF, preview tier', () => { + const a = node({ + id: 'a', + title: 'Basic Scatter', + thumbUrl: 'https://cdn.example/plot-light.png', + tags: ['plot_type:scatter', 'domain:finance', 'feature:hover', 'style:dark', 'rare', 'zero'], + }); + const idf = new Map([ + ['domain:finance', 0.5], + ['feature:hover', 0.9], + ['style:dark', 0.2], + ['rare', 1.5], + ['zero', 0], + ]); + const { result } = setup([a], graph({ idf, topTypes: ['scatter'] })); + + act(() => result.current.setHoverId('a')); + + expect(result.current.panelNodeId).toBe('a'); + expect(result.current.panelData).toEqual({ + title: 'Basic Scatter', + plotType: 'scatter', + chipColor: expect.any(String), + tags: ['rare', 'feature:hover', 'domain:finance', 'style:dark'], + previewUrl: 'https://cdn.example/plot-light_800.webp', + }); + }); + + it('keeps showing the last node after the hover ends, and prefers the pin', () => { + const a = node({ id: 'a' }); + const b = node({ id: 'b' }); + const { result } = setup([a, b]); + + act(() => result.current.setHoverId('a')); + act(() => result.current.setHoverId(null)); + expect(result.current.panelNodeId).toBe('a'); + + act(() => result.current.setPinnedId('b')); + expect(result.current.panelNodeId).toBe('b'); + }); + + it('colours the pin by its cluster, with the primary colour as fallback', () => { + // Slot 1, because slot 0 of CLUSTER_COLORS is the brand green itself. + const inCluster = node({ id: 'a', colorBucket: 'bar' }); + const outside = node({ id: 'b', colorBucket: null }); + const { result } = setup([inCluster, outside], graph({ topTypes: ['scatter', 'bar'] })); + + act(() => result.current.setPinnedId('a')); + expect(result.current.pinColor).toBe(CLUSTER_COLORS[1]); + + act(() => result.current.setPinnedId('b')); + expect(result.current.pinColor).toBe(colors.primary); + }); + + it('tracks the pinned node on screen each frame and stops when the pin goes', () => { + const frames: FrameRequestCallback[] = []; + vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => frames.push(cb)); + const cancel = vi.fn(); + vi.stubGlobal('cancelAnimationFrame', cancel); + const a = Object.assign(node({ id: 'a' }), { x: 100, y: 50 }); + const screenPos = { x: 400, y: 300 }; + const fg = { graph2ScreenCoords: vi.fn(() => screenPos), zoom: vi.fn(() => 2) }; + const { result } = setup([a], graph(), fg); + + act(() => result.current.setPinnedId('a')); + act(() => frames[frames.length - 1](0)); + + const { w, h } = fitToBox(NODE_SIZE, nodeAspectRatio(a)); + expect(fg.graph2ScreenCoords).toHaveBeenCalledWith(100, 50); + expect(result.current.pinScreen).toEqual({ x: 400 - w, y: 300 - h, w: 2 * w, h: 2 * h }); + + // An unchanged position keeps the same object, so nothing re-renders. + const first = result.current.pinScreen; + act(() => frames[frames.length - 1](16)); + expect(result.current.pinScreen).toBe(first); + + screenPos.x = 420; + act(() => frames[frames.length - 1](32)); + expect(result.current.pinScreen?.x).toBe(420 - w); + + act(() => result.current.setPinnedId(null)); + expect(cancel).toHaveBeenCalled(); + }); +}); diff --git a/app/src/sections/map/useMapSelection.ts b/app/src/sections/map/useMapSelection.ts new file mode 100644 index 00000000000..e1f920ef29b --- /dev/null +++ b/app/src/sections/map/useMapSelection.ts @@ -0,0 +1,145 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; + +import { colorFor, type MapGraphData, NODE_SIZE } from 'src/hooks/useForceGraphSimulation'; +import { + buildVariantUrl, + fitToBox, + type ForceGraphHandle, + type MapNode, + nodeAspectRatio, +} from 'src/pages/MapPage.helpers'; +import { colors } from 'src/theme'; + +/** What the hover panel shows for the hovered or pinned node. */ +export interface MapPanelData { + title: string; + plotType: string | null; + chipColor: string | null; + tags: string[]; + previewUrl: string | null; +} + +export function useMapSelection({ + graphData, + nodeById, + fgRef, +}: { + graphData: MapGraphData; + nodeById: Map; + fgRef: React.RefObject; +}) { + const [hoverId, setHoverId] = useState(null); + const lastPanelNodeIdRef = useRef(null); + const [pinnedId, setPinnedId] = useState(null); + const [pinScreen, setPinScreen] = useState<{ x: number; y: number; w: number; h: number } | null>( + null + ); + const hoverTimerRef = useRef(null); + const [hoverType, setHoverType] = useState(null); + + const panelNodeId = hoverId ?? pinnedId ?? lastPanelNodeIdRef.current; + if (panelNodeId !== null) lastPanelNodeIdRef.current = panelNodeId; + + // ForceGraph2D's onNodeHover, debounced by 100 ms. Lives here, next to the + // timer it owns, so the unmount cleanup below covers every timer it sets. + const handleNodeHover = (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); + } + }; + + // Clean up any pending hover-debounce timer on unmount. + useEffect(() => { + return () => { + if (hoverTimerRef.current != null) { + window.clearTimeout(hoverTimerRef.current); + } + }; + }, []); + + useEffect(() => { + 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 + ) { + return prev; + } + return next; + }); + } + } + raf = requestAnimationFrame(tick); + }; + raf = requestAnimationFrame(tick); + return () => cancelAnimationFrame(raf); + }, [pinnedId, graphData, nodeById, fgRef]); + + const panelData = useMemo((): MapPanelData | null => { + if (!panelNodeId) return null; + const node = nodeById.get(panelNodeId); + if (!node) return null; + const ptTag = node.tags.find((t: string) => t.startsWith('plot_type:')); + const plotType = ptTag ? ptTag.slice('plot_type:'.length) : null; + const tags = node.tags + .filter((t: string) => !t.startsWith('plot_type:')) + .map((t: string) => ({ tag: t, w: graphData.idf.get(t) ?? 0 })) + .filter((x: { w: number }) => x.w > 0) + .sort((a: { w: number }, b: { w: number }) => b.w - a.w) + .slice(0, 4) + .map((x: { tag: string }) => x.tag); + return { + title: node.title, + plotType, + chipColor: plotType ? colorFor(plotType, graphData.topTypes) : null, + tags, + previewUrl: node.thumbUrl ? buildVariantUrl(node.thumbUrl, 800) : null, + }; + }, [panelNodeId, graphData, nodeById]); + + const pinColor = useMemo(() => { + if (!pinnedId) return colors.primary; + const node = nodeById.get(pinnedId); + if (!node) return colors.primary; + return colorFor(node.colorBucket, graphData.topTypes) ?? colors.primary; + }, [pinnedId, nodeById, graphData.topTypes]); + + return { + hoverId, + setHoverId, + pinnedId, + setPinnedId, + pinScreen, + hoverType, + setHoverType, + handleNodeHover, + panelNodeId, + panelData, + pinColor, + }; +} diff --git a/changelog.d/split-debug-map-pages.md b/changelog.d/split-debug-map-pages.md new file mode 100644 index 00000000000..18a10cd4388 --- /dev/null +++ b/changelog.d/split-debug-map-pages.md @@ -0,0 +1,9 @@ +### Changed + +- **DebugPage and MapPage split into sections.** `DebugPage.tsx` shrinks from + 1,853 to 541 lines: its KPI grid, charts, tables, and feedback blocks move + into `app/src/sections/debug/`. `MapPage.tsx` shrinks from 1,291 to 265 + lines: its selection, search, and resize logic moves into hooks with tests, + and the canvas, legend, hover panel, search pill, and weights panel into + `app/src/sections/map/`. Both pages render the same markup as before. + PalettePage stays as it is for now. (#12457)