From 0b20a6e7bcef81e0a45e3f51f246911822724738 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 11:28:50 +0100 Subject: [PATCH 1/7] fix(app): strip the shell's head defaults when the app boots (#12440) Fixes duplicate metadata elements (title, description, and OG/Twitter properties) that are present in the shell `index.html` by default and redundantly appended when React routes boot up and hydrate. We now mark the 6 shell tags with `data-shell-default` and aggressively strip them immediately before creating the React root. Additionally, `DebugPage` now consistently sets its own explicit title. Tests added for both the DOM utility and integration into the specific pages. Verification steps run successfully: ``` yarn lint yarn fm:check yarn type-check yarn test src/pages src/utils yarn build ``` Refs #9629 --- *PR created automatically by Jules for task [16201668932719198013](https://jules.google.com/task/16201668932719198013) started by @MarkusNeusinger* --------- Co-authored-by: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> --- app/index.html | 12 +- app/src/main.tsx | 3 + app/src/pages/DebugPage.tsx | 4 + app/src/pages/headMetadata.test.tsx | 240 ++++++++++++++++++++++++++++ app/src/utils/shellMetadata.test.ts | 54 +++++++ app/src/utils/shellMetadata.ts | 9 ++ 6 files changed, 316 insertions(+), 6 deletions(-) create mode 100644 app/src/pages/headMetadata.test.tsx create mode 100644 app/src/utils/shellMetadata.test.ts create mode 100644 app/src/utils/shellMetadata.ts diff --git a/app/index.html b/app/index.html index 175fe23c0ea..77ce1bdfb6c 100644 --- a/app/index.html +++ b/app/index.html @@ -9,11 +9,11 @@ - + - any.plot() — any library. + any.plot() — any library. @@ -62,16 +62,16 @@ - - + + - - + + diff --git a/app/src/main.tsx b/app/src/main.tsx index 2876d90789f..ee6c8e03088 100644 --- a/app/src/main.tsx +++ b/app/src/main.tsx @@ -8,6 +8,9 @@ import ReactDOM from 'react-dom/client'; import { reportWebVitals } from 'src/analytics/reportWebVitals'; import { App } from 'src/app'; +import { removeShellDefaults } from 'src/utils/shellMetadata'; + +removeShellDefaults(); ReactDOM.createRoot(document.getElementById('root')!).render( diff --git a/app/src/pages/DebugPage.tsx b/app/src/pages/DebugPage.tsx index 8addb3d5ccc..3d2a8c57fed 100644 --- a/app/src/pages/DebugPage.tsx +++ b/app/src/pages/DebugPage.tsx @@ -426,6 +426,7 @@ export function DebugPage() { if (authRequired) { return ( + debug | anyplot.ai debug · admin auth} /> + debug | anyplot.ai loading debug data... @@ -505,6 +507,7 @@ export function DebugPage() { if (error || !data) { return ( + debug | anyplot.ai failed to load{error ? `: ${error}` : ''} @@ -541,6 +544,7 @@ export function DebugPage() { return ( + debug | anyplot.ai {/* Header */} debug} /> diff --git a/app/src/pages/headMetadata.test.tsx b/app/src/pages/headMetadata.test.tsx new file mode 100644 index 00000000000..8ee0267c35d --- /dev/null +++ b/app/src/pages/headMetadata.test.tsx @@ -0,0 +1,240 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { AboutPage } from 'src/pages/AboutPage'; +import { DebugPage } from 'src/pages/DebugPage'; +import { LandingPage } from 'src/pages/LandingPage'; +import { LegalPage } from 'src/pages/LegalPage'; +import { PlotsPage } from 'src/pages/PlotsPage'; +import { render } from 'src/test-utils'; +import { removeShellDefaults } from 'src/utils/shellMetadata'; + +vi.mock('src/hooks/useLayoutContext', async () => { + const actual = await vi.importActual( + 'src/hooks/useLayoutContext' + ); + return { + ...actual, + useTheme: () => ({ isDark: false, toggle: vi.fn() }), + useAppData: () => ({ + specs: [], + specTitles: {}, + filterCounts: null, + globalCounts: null, + fetchError: null, + hasData: true, + lastUpdated: '2023-01-01', + }), + useHomeState: () => ({ + specsData: [], + specTitles: {}, + allImages: [], + displayedImages: [], + activeFilters: [], + filterCounts: null, + globalCounts: null, + orCounts: [], + hasMore: false, + loading: false, + error: '', + setDisplayedImages: vi.fn(), + handleFilterClick: vi.fn(), + handleSortModeChange: vi.fn(), + loadMore: vi.fn(), + homeStateRef: { current: { scrollY: 0 } }, + saveScrollPosition: vi.fn(), + }), + }; +}); + +vi.mock('src/hooks/useFeaturedSpecs', () => ({ + useFeaturedSpecs: () => [], +})); + +vi.mock('src/hooks/usePlotOfTheDay', () => ({ + usePlotOfTheDay: () => null, +})); + +vi.mock('src/hooks', async () => { + const actual = await vi.importActual('src/hooks'); + return { + ...actual, + useAnalytics: () => ({ trackPageview: vi.fn(), trackEvent: vi.fn() }), + useAppData: () => ({ + specs: [], + specTitles: {}, + filterCounts: null, + globalCounts: null, + fetchError: null, + hasData: true, + lastUpdated: '2023-01-01', + }), + useHomeState: () => ({ + specsData: [], + specTitles: {}, + allImages: [], + displayedImages: [], + activeFilters: [], + filterCounts: null, + globalCounts: null, + orCounts: [], + hasMore: false, + loading: false, + error: '', + setDisplayedImages: vi.fn(), + handleFilterClick: vi.fn(), + handleSortModeChange: vi.fn(), + loadMore: vi.fn(), + homeStateRef: { current: { scrollY: 0 } }, + saveScrollPosition: vi.fn(), + }), + }; +}); + +// For PlotsPage filters and infinite scroll context +vi.mock('src/hooks/useFilterState', async () => { + const actual = await vi.importActual( + 'src/hooks/useFilterState' + ); + return { + ...actual, + useFilterState: () => ({ + activeFilters: [], + filterCounts: null, + globalCounts: null, + orCounts: [], + specTitles: {}, + allImages: [], + displayedImages: [], + hasMore: false, + loading: false, + error: '', + setDisplayedImages: vi.fn(), + handleFilterClick: vi.fn(), + handleSortModeChange: vi.fn(), + loadMore: vi.fn(), + }), + }; +}); + +vi.mock('src/hooks/useInfiniteScroll', () => ({ + useInfiniteScroll: () => ({ loadMoreRef: { current: null } }), +})); + +vi.mock('src/sections/landing/LibrariesSection', () => ({ + LibrariesSection: () =>
, +})); +vi.mock('src/sections/landing/HeroSection', () => ({ + HeroSection: () =>
, +})); +vi.mock('src/sections/landing/NumbersStrip', () => ({ + NumbersStrip: () =>
, +})); +vi.mock('src/sections/plots-gallery/FilterBar', () => ({ + FilterBar: () =>
FilterBar
, +})); +vi.mock('src/sections/plots-gallery/ImagesGrid', () => ({ + ImagesGrid: () =>
ImagesGrid
, +})); +vi.mock('src/layouts/Footer', () => ({ + Footer: () =>
Footer
, +})); +vi.mock('src/utils/filters', async () => { + const actual = await vi.importActual('src/utils/filters'); + return { ...actual }; +}); + +describe('head metadata deduplication', () => { + beforeEach(() => { + // Clear head + document.head.innerHTML = ''; + + // Insert the 6 marked shell tags + const tags = [ + { tag: 'title', attrs: {} }, + { tag: 'meta', attrs: { name: 'description', content: 'desc' } }, + { tag: 'meta', attrs: { property: 'og:title', content: 'og title' } }, + { tag: 'meta', attrs: { property: 'og:description', content: 'og desc' } }, + { tag: 'meta', attrs: { name: 'twitter:title', content: 'tw title' } }, + { tag: 'meta', attrs: { name: 'twitter:description', content: 'tw desc' } }, + ]; + + tags.forEach(({ tag, attrs }) => { + const el = document.createElement(tag); + el.setAttribute('data-shell-default', ''); + Object.entries(attrs).forEach(([key, val]) => el.setAttribute(key, val)); + if (tag === 'title') { + el.textContent = 'shell title'; + } + document.head.appendChild(el); + }); + }); + + afterEach(() => { + document.head.innerHTML = ''; + }); + + it('LandingPage deduplicates head tags', () => { + removeShellDefaults(); + const { unmount } = render(); + + const titles = document.head.querySelectorAll('title'); + expect(titles.length).toBe(1); + expect(titles[0].textContent).toBe('any.plot() — any library.'); + + const descs = document.head.querySelectorAll('meta[name="description"]'); + expect(descs.length).toBeLessThanOrEqual(1); + unmount(); + }); + + it('PlotsPage deduplicates head tags', () => { + removeShellDefaults(); + const { unmount } = render(); + + const titles = document.head.querySelectorAll('title'); + expect(titles.length).toBe(1); + expect(titles[0].textContent).toBe('plots | anyplot.ai'); + + const descs = document.head.querySelectorAll('meta[name="description"]'); + expect(descs.length).toBeLessThanOrEqual(1); + unmount(); + }); + + it('AboutPage deduplicates head tags', () => { + removeShellDefaults(); + const { unmount } = render(); + + const titles = document.head.querySelectorAll('title'); + expect(titles.length).toBe(1); + expect(titles[0].textContent).toBe('about | anyplot.ai'); + + const descs = document.head.querySelectorAll('meta[name="description"]'); + expect(descs.length).toBeLessThanOrEqual(1); + unmount(); + }); + + it('LegalPage deduplicates head tags', () => { + removeShellDefaults(); + const { unmount } = render(); + + const titles = document.head.querySelectorAll('title'); + expect(titles.length).toBe(1); + expect(titles[0].textContent).toBe('legal | anyplot.ai'); + + const descs = document.head.querySelectorAll('meta[name="description"]'); + expect(descs.length).toBeLessThanOrEqual(1); + unmount(); + }); + + it('DebugPage deduplicates head tags', () => { + removeShellDefaults(); + const { unmount } = render(); + + const titles = document.head.querySelectorAll('title'); + expect(titles.length).toBe(1); + expect(titles[0].textContent).toBe('debug | anyplot.ai'); + + const descs = document.head.querySelectorAll('meta[name="description"]'); + expect(descs.length).toBeLessThanOrEqual(1); + unmount(); + }); +}); diff --git a/app/src/utils/shellMetadata.test.ts b/app/src/utils/shellMetadata.test.ts new file mode 100644 index 00000000000..a280386c380 --- /dev/null +++ b/app/src/utils/shellMetadata.test.ts @@ -0,0 +1,54 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { removeShellDefaults } from 'src/utils/shellMetadata'; + +describe('removeShellDefaults', () => { + afterEach(() => { + document.head.innerHTML = ''; + }); + + it('removes only tags with data-shell-default attribute and returns correct count', () => { + // Add marked shell tags + const title = document.createElement('title'); + title.setAttribute('data-shell-default', ''); + title.textContent = 'shell title'; + document.head.appendChild(title); + + const desc = document.createElement('meta'); + desc.setAttribute('data-shell-default', ''); + desc.name = 'description'; + document.head.appendChild(desc); + + // Add unmarked tags that should stay + const otherTitle = document.createElement('title'); + otherTitle.textContent = 'page title'; + document.head.appendChild(otherTitle); + + const otherMeta = document.createElement('meta'); + otherMeta.name = 'keywords'; + document.head.appendChild(otherMeta); + + expect(document.head.children.length).toBe(4); + + const count = removeShellDefaults(); + + expect(count).toBe(2); + expect(document.head.children.length).toBe(2); + + // Verify only the unmarked ones are left + const titles = document.head.querySelectorAll('title'); + expect(titles.length).toBe(1); + expect(titles[0].textContent).toBe('page title'); + + const metas = document.head.querySelectorAll('meta'); + expect(metas.length).toBe(1); + expect(metas[0].name).toBe('keywords'); + }); + + it('handles empty head', () => { + expect(document.head.children.length).toBe(0); + const count = removeShellDefaults(); + expect(count).toBe(0); + expect(document.head.children.length).toBe(0); + }); +}); diff --git a/app/src/utils/shellMetadata.ts b/app/src/utils/shellMetadata.ts new file mode 100644 index 00000000000..7164b6b9dab --- /dev/null +++ b/app/src/utils/shellMetadata.ts @@ -0,0 +1,9 @@ +export function removeShellDefaults(head: HTMLHeadElement = document.head): number { + const elements = head.querySelectorAll('[data-shell-default]'); + let count = 0; + elements.forEach(el => { + el.remove(); + count++; + }); + return count; +} From 8bb69cbec06615486259731facdd837ce999e03d Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 14:05:42 +0100 Subject: [PATCH 2/7] refactor(app): split DebugPage into sections (#12446) Refactors `DebugPage.tsx` by splitting its massive layout into smaller, presentational sections. - **Line count of DebugPage.tsx before:** 1,853 lines - **Line count of DebugPage.tsx after:** 541 lines - **Number of new files:** 15 (9 section components, 4 helpers, 2 util modules) - **Largest new file:** `SpecMatrix.tsx` (437 lines) **Verification Results:** - `yarn lint`: Pass (0 errors) - `yarn fm:check`: Pass (Prettier format OK) - `yarn type-check`: Pass - `yarn test src/pages src/sections`: Pass (61 test files, 662 tests passed) - `yarn build`: Pass Refs #9631 --- *PR created automatically by Jules for task [11163445064419037951](https://jules.google.com/task/11163445064419037951) started by @MarkusNeusinger* Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> --- app/src/pages/DebugPage.tsx | 1424 +---------------- app/src/sections/debug/CommonWeaknesses.tsx | 62 + .../sections/debug/CopyClaudePromptButton.tsx | 38 + app/src/sections/debug/DailyActivityChart.tsx | 73 + app/src/sections/debug/FeedbackSection.tsx | 205 +++ app/src/sections/debug/FeedbackTopList.tsx | 105 ++ app/src/sections/debug/HeaderCell.tsx | 19 + app/src/sections/debug/KPIGrid.tsx | 53 + app/src/sections/debug/LibraryCoverage.tsx | 138 ++ app/src/sections/debug/ProblemAreas.tsx | 144 ++ app/src/sections/debug/RecentActivity.tsx | 126 ++ app/src/sections/debug/SortableHeader.tsx | 50 + app/src/sections/debug/SpecMatrix.tsx | 437 +++++ app/src/sections/debug/SystemHealthRow.tsx | 137 ++ app/src/sections/debug/constants.ts | 3 + app/src/sections/debug/debugFormat.ts | 26 + 16 files changed, 1672 insertions(+), 1368 deletions(-) create mode 100644 app/src/sections/debug/CommonWeaknesses.tsx create mode 100644 app/src/sections/debug/CopyClaudePromptButton.tsx create mode 100644 app/src/sections/debug/DailyActivityChart.tsx create mode 100644 app/src/sections/debug/FeedbackSection.tsx create mode 100644 app/src/sections/debug/FeedbackTopList.tsx create mode 100644 app/src/sections/debug/HeaderCell.tsx create mode 100644 app/src/sections/debug/KPIGrid.tsx create mode 100644 app/src/sections/debug/LibraryCoverage.tsx create mode 100644 app/src/sections/debug/ProblemAreas.tsx create mode 100644 app/src/sections/debug/RecentActivity.tsx create mode 100644 app/src/sections/debug/SortableHeader.tsx create mode 100644 app/src/sections/debug/SpecMatrix.tsx create mode 100644 app/src/sections/debug/SystemHealthRow.tsx create mode 100644 app/src/sections/debug/constants.ts create mode 100644 app/src/sections/debug/debugFormat.ts diff --git a/app/src/pages/DebugPage.tsx b/app/src/pages/DebugPage.tsx index 3d2a8c57fed..1bdf53b5764 100644 --- a/app/src/pages/DebugPage.tsx +++ b/app/src/pages/DebugPage.tsx @@ -1,24 +1,25 @@ 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 } from 'src/sections/debug/constants'; +import { DailyActivityChart } from 'src/sections/debug/DailyActivityChart'; +import { FeedbackSection } from 'src/sections/debug/FeedbackSection'; +import { KPIGrid } from 'src/sections/debug/KPIGrid'; +import { LibraryCoverage } from 'src/sections/debug/LibraryCoverage'; +import { ProblemAreas } from 'src/sections/debug/ProblemAreas'; +import { RecentActivity } from 'src/sections/debug/RecentActivity'; +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,37 +29,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; @@ -88,7 +63,7 @@ interface DailyImplPoint { impls_updated: number; } -interface RecentActivity { +interface RecentActivityData { spec_id: string; spec_title: string; library_id: string; @@ -109,12 +84,10 @@ interface FeedbackTopPage { last_seen: string | null; } -type FeedbackStatus = 'new' | 'in_progress' | 'done' | 'wont_solve'; -const FEEDBACK_STATUS_OPTIONS: FeedbackStatus[] = ['new', 'in_progress', 'done', 'wont_solve']; +export type 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,13 +112,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 SortKey = keyof SpecStatus | 'coverage'; type SortDir = 'asc' | 'desc'; // ============================================================================ @@ -154,25 +127,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 +134,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 +286,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', @@ -526,10 +476,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 +485,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 +519,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/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..3ecef017ef8 --- /dev/null +++ b/app/src/sections/debug/SpecMatrix.tsx @@ -0,0 +1,437 @@ +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 { 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 = ((spec: SpecStatus) => { + let c = 0; + for (const lib of LIBRARIES) if (spec[lib as keyof SpecStatus] !== null) c++; + return c; + })(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..6822ed266cc --- /dev/null +++ b/app/src/sections/debug/debugFormat.ts @@ -0,0 +1,26 @@ +import { colors } from 'src/theme'; + +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; +} From 3dacaf132e7ba9d3196984cfee11e883b9c367f4 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:45:24 +0100 Subject: [PATCH 3/7] refactor(app): split MapPage into hooks and sections (#12450) This PR extracts all of the logic and markup from `MapPage.tsx` into a modular `map` section, keeping exactly identical functionality, network requests, and DOM output. Hooks logic has been preserved across extracted functions, and the canvas rendering function matches byte-for-byte outside of where the parameters are sourced from. Tests for all 3 hooks have been added. `MapPage.test.tsx` stays entirely green. `MapPage.helpers.ts` was deliberately left inside `app/src/pages/` so as not to break other shared hooks. * `MapPage.tsx` original line count: 1291 * `MapPage.tsx` new line count: 265 * 12 new files added. * Largest new file is `MapCanvas.tsx` at 391 lines. Verification commands execution logs show successful outputs across all requirements: * `yarn lint`: (0 warnings, 0 errors) * `yarn fm:check`: All matched files use Prettier code style! * `yarn type-check`: (0 errors) * `yarn test src/pages src/sections`: 64 passed / 665 tests passed * `yarn build`: Finished with 0 errors in chunks rendering. Refs #9631 --- *PR created automatically by Jules for task [9247391219185265114](https://jules.google.com/task/9247391219185265114) started by @MarkusNeusinger* Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> --- app/src/pages/MapPage.tsx | 1210 ++--------------- app/src/sections/map/MapCanvas.tsx | 391 ++++++ app/src/sections/map/MapHoverPanel.tsx | 121 ++ app/src/sections/map/MapLegend.tsx | 112 ++ app/src/sections/map/MapSearchPill.tsx | 154 +++ app/src/sections/map/MapWeights.tsx | 201 +++ app/src/sections/map/index.ts | 8 + app/src/sections/map/useMapResize.test.tsx | 12 + app/src/sections/map/useMapResize.ts | 20 + app/src/sections/map/useMapSearch.test.tsx | 23 + app/src/sections/map/useMapSearch.ts | 67 + app/src/sections/map/useMapSelection.test.tsx | 23 + app/src/sections/map/useMapSelection.ts | 121 ++ 13 files changed, 1345 insertions(+), 1118 deletions(-) create mode 100644 app/src/sections/map/MapCanvas.tsx create mode 100644 app/src/sections/map/MapHoverPanel.tsx create mode 100644 app/src/sections/map/MapLegend.tsx create mode 100644 app/src/sections/map/MapSearchPill.tsx create mode 100644 app/src/sections/map/MapWeights.tsx create mode 100644 app/src/sections/map/index.ts create mode 100644 app/src/sections/map/useMapResize.test.tsx create mode 100644 app/src/sections/map/useMapResize.ts create mode 100644 app/src/sections/map/useMapSearch.test.tsx create mode 100644 app/src/sections/map/useMapSearch.ts create mode 100644 app/src/sections/map/useMapSelection.test.tsx create mode 100644 app/src/sections/map/useMapSelection.ts diff --git a/app/src/pages/MapPage.tsx b/app/src/pages/MapPage.tsx index 2d4eedd6234..7bfa1a53668 100644 --- a/app/src/pages/MapPage.tsx +++ b/app/src/pages/MapPage.tsx @@ -1,38 +1,26 @@ -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 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 +36,24 @@ 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); + + // 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, + hoverTimerRef, + 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 && ( - - - - )} {error && ( )} - {/* Canvas */} - {ready && ( - n.title} - d3VelocityDecay={forceConfig.velocityDecay} - d3AlphaDecay={forceConfig.alphaDecay} - d3AlphaMin={forceConfig.alphaMin} - nodeCanvasObject={(node, ctx, globalScale) => { - const n = node as WithCoords; - if (n.x == null || n.y == null) return; - const isHover = hoverId === n.id; - const isNeighbor = !isHover && hoverId != null && neighbors.get(hoverId)?.has(n.id); - // hoverType is set when the user hovers a legend entry — match - // any node in that cluster, dim the rest. - const matchesType = hoverType == null || n.colorBucket === hoverType; - const dim = - (hoverId != null && !isHover && !isNeighbor) || (hoverType != null && !matchesType); - // Hovered node itself doesn't grow — the rich preview lives in a - // DOM corner panel. Direct neighbors get a small bump so the - // relationship is still legible on the canvas. - const baseSize = NODE_SIZE * (isNeighbor ? 1.2 : 1); - - // Pick the smallest variant whose source resolution comfortably - // covers the on-screen size, then lazy-load it if not yet present. - // force-graph only invokes nodeCanvasObject for visible nodes, so - // off-screen specs never trigger a higher-tier fetch. - const screenPx = baseSize * (globalScale ?? 1); - const dpr = typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1; - const desired: ResolutionTier = pickTier(screenPx * dpr); - if (n.imgs && !n.imgs.has(desired) && !n.pendingTiers?.has(desired)) { - ensureNodeTier(n, desired, () => fgRef.current?.refresh?.()); - } - const img = n.imgs ? pickBestLoadedTier(n.imgs, desired) : null; - - // Match draw size to the source aspect ratio (most plots are 16:9 - // from figsize=(16,9)) — keep the longer side at baseSize so nodes - // share a consistent bounding-box scale. - const { w, h } = fitToBox(baseSize, nodeAspectRatio(n)); - const x = n.x - w / 2; - const y = n.y - h / 2; - - ctx.save(); - if (dim) ctx.globalAlpha = 0.18; - if (img) { - ctx.drawImage(img, x, y, w, h); - } else { - ctx.fillStyle = isDark ? '#242420' : '#FFFDF6'; - ctx.fillRect(x, y, w, h); - } - ctx.lineWidth = isHover ? 2 : n.colorBucket ? 1.5 : 1; - ctx.strokeStyle = strokeFor( - isDark, - !!isHover, - colorFor(n.colorBucket, graphData.topTypes) - ); - ctx.strokeRect(x, y, w, h); - - ctx.restore(); - }} - nodePointerAreaPaint={(node, color, ctx) => { - const n = node as WithCoords; - if (n.x == null || n.y == null) return; - const { w, h } = fitToBox(NODE_SIZE, nodeAspectRatio(n)); - ctx.fillStyle = color; - ctx.fillRect(n.x - w / 2, n.y - h / 2, w, h); - }} - // Links are intentionally very subtle by default so the thumbnails - // dominate. Hovered-node connections light up; when a legend - // entry is hovered, links between same-cluster nodes stay - // visible while everything else fades. - // - // NOTE: ForceGraph2D mutates link.source / link.target from - // string IDs to actual node objects after the first simulation - // tick. So `l.source === hoverId` would only ever match before - // the first tick. linkEndId() reads through the object. - linkColor={(l: MapLink) => { - const sId = linkEndId(l.source); - const tId = linkEndId(l.target); - const involved = hoverId && (sId === hoverId || tId === hoverId); - if (involved) { - // Match the cluster color of the hovered node so the burst - // of highlighted edges feels coherent with the frame. - const hoverNode = nodeById.get(hoverId); - return ( - colorFor(hoverNode?.colorBucket ?? null, graphData.topTypes) ?? colors.primary - ); - } - if (hoverType) { - const sBucket = sId ? nodeById.get(sId)?.colorBucket : undefined; - const tBucket = tId ? nodeById.get(tId)?.colorBucket : undefined; - const intra = sBucket === hoverType && tBucket === hoverType; - if (intra) return isDark ? 'rgba(255,255,255,0.18)' : 'rgba(0,0,0,0.22)'; - return isDark ? 'rgba(255,255,255,0.012)' : 'rgba(0,0,0,0.015)'; - } - // When a node is hovered, keep non-involved edges faintly - // visible so the user can still read the surrounding cluster - // structure — too much fade-out makes the map feel broken. - if (hoverId) return isDark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.06)'; - return isDark ? 'rgba(255,255,255,0.10)' : 'rgba(0,0,0,0.13)'; - }} - linkWidth={(l: MapLink) => { - const sId = linkEndId(l.source); - const tId = linkEndId(l.target); - const involved = hoverId && (sId === hoverId || tId === hoverId); - if (involved) return Math.max(1.5, (l.weight ?? 0.3) * 3); - return Math.max(0.4, (l.weight ?? 0.3) * 1.5); - }} - onNodeClick={onNodeClick} - // Background tap clears the pin AND closes the preview panel. - // On desktop ForceGraph2D fires onNodeHover(null) when the - // pointer leaves a node, but on touch there's no hover event, - // so we need to clear hoverId explicitly here. Doing it on - // both is a harmless no-op for the desktop path. - onBackgroundClick={() => { - setPinnedId(null); - setHoverId(null); - }} - onNodeHover={(n: MapNode | null) => { - if (hoverTimerRef.current != null) { - window.clearTimeout(hoverTimerRef.current); - hoverTimerRef.current = null; - } - if (n) { - hoverTimerRef.current = window.setTimeout(() => { - setHoverId(n.id); - hoverTimerRef.current = null; - }, 100); - } else { - setHoverId(null); - } - }} - cooldownTicks={forceConfig.cooldownTicks} - // Frame the dense cluster to ~80% of the viewport — instantly - // (0 ms), so the camera move happens behind the still-active - // gate overlay and the user just sees the final framing when - // `settled` flips. The trick is bounding the *5th–95th - // percentile* of node coordinates instead of the full bbox: - // a couple of far-flung outliers (typically null-bucket specs - // with no strong KNN edges) would otherwise dominate the bbox - // and shrink the readable center to half its size. Outliers - // remain reachable via pan. - onEngineStop={() => { - const fg = fgRef.current; - if (fg) { - const xs: number[] = []; - const ys: number[] = []; - for (const n of graphData.nodes as Array) { - if (n.x != null && n.y != null) { - xs.push(n.x); - ys.push(n.y); - } - } - if (xs.length > 0) { - xs.sort((a, b) => a - b); - ys.sort((a, b) => a - b); - const trim = 0.05; - const lo = Math.floor(xs.length * trim); - const hi = Math.floor(xs.length * (1 - trim)); - const minX = xs[lo], - maxX = xs[hi], - minY = ys[lo], - maxY = ys[hi]; - const cx = (minX + maxX) / 2; - const cy = (minY + maxY) / 2; - const bboxW = Math.max(1, maxX - minX); - const bboxH = Math.max(1, maxY - minY); - const padding = Math.round(Math.min(size.w, size.h) * 0.1); - const fitZoom = Math.min( - (size.w - 2 * padding) / bboxW, - (size.h - 2 * padding) / bboxH - ); - fg.centerAt?.(cx, cy, 0); - fg.zoom?.(fitZoom, 0); - } - } - markSettled(); - }} - // Wire up the custom forces once the imperative ref is available. - // The tuned values/factories come from the hook's forceConfig. - // onRenderFramePre fires every frame; the __forcesWired guard makes - // it idempotent and the cost on subsequent frames is one property read. - onRenderFramePre={() => { - const fg = fgRef.current; - if (!fg || fg.__forcesWired) return; - fg.d3Force('charge')?.strength(forceConfig.chargeStrength); - // The d3-force-3d ambient types are minimal; cast for the chained calls. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const linkForce = fg.d3Force('link') as any; - if (linkForce) { - linkForce.distance(forceConfig.linkDistance); - linkForce.strength(forceConfig.linkStrength); - } - fg.d3Force('collide', forceConfig.createCollideForce()); - fg.d3Force('center')?.strength?.(forceConfig.centerGravity); - // Outlier-squash: register the custom radial-compression force - // AFTER the standard ones so its velocity correction is the - // last word per tick. Inner geometry is untouched (force is a - // no-op below the percentile threshold), so this stacks on the - // existing layout instead of fighting it. - fg.d3Force('outlier-squash', forceConfig.createOutlierSquashForce()); - fg.__forcesWired = true; - fg.d3ReheatSimulation?.(); - }} - onEngineTick={handleEngineTick} - /> - )} - - {/* Settling gate: visible while specs are loaded but the simulation - hasn't finished cooling. Sits on top of the canvas, swallows - pointer events, and shows a centered spinner + progress bar so - non-technical users get an unmistakable signal that the map is - *computing*, not broken. Renders as long as `ready` so the fade - transition runs both ways — opacity drops to 0 after `settled` - flips, then the next graphData re-derive fades it back in. - pointerEvents flips to `none` once settled so canvas drags work - even during the fade-out. */} - {ready && ( - - - - - map.simulate() - - {/* Hand-rolled thin progress bar — slimmer than MUI's - LinearProgress and avoids an extra import. Width - transitions on each batched tick-progress update so the - bar slides smoothly between throttle frames. */} - - - - - - )} + - {/* a11y fallback: visually-hidden list so screen readers + keyboard users - can still reach every spec from this page. */} {(specs ?? []).map(s => (
  • diff --git a/app/src/sections/map/MapCanvas.tsx b/app/src/sections/map/MapCanvas.tsx new file mode 100644 index 00000000000..e921b292f8e --- /dev/null +++ b/app/src/sections/map/MapCanvas.tsx @@ -0,0 +1,391 @@ +import ForceGraph2D from 'react-force-graph-2d'; + +import Box from '@mui/material/Box'; +import CircularProgress from '@mui/material/CircularProgress'; + +import { colorFor, NODE_SIZE } from 'src/hooks/useForceGraphSimulation'; +import { useTheme } from 'src/hooks/useLayoutContext'; +import { + ensureNodeTier, + fitToBox, + 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; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + fgRef: React.RefObject; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + graphData: any; + size: { w: number; h: number }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + forceConfig: any; + hoverId: string | null; + hoverType: string | null; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + nodeById: Map; + neighbors: Map>; + onNodeClick: (node: MapNode) => void; + setPinnedId: (id: string | null) => void; + setHoverId: (id: string | null) => void; + hoverTimerRef: React.MutableRefObject; + 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, + hoverTimerRef, + 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={(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. */} + + + + + + )} + + ); +} diff --git a/app/src/sections/map/MapHoverPanel.tsx b/app/src/sections/map/MapHoverPanel.tsx new file mode 100644 index 00000000000..6c6af568032 --- /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 { fontSize, typography } from 'src/theme'; + +interface MapHoverPanelProps { + hoverId: string | null; + pinnedId: string | null; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + panelData: any; +} + +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..9ca8943c182 --- /dev/null +++ b/app/src/sections/map/MapLegend.tsx @@ -0,0 +1,112 @@ +import { useState } from 'react'; + +import Box from '@mui/material/Box'; + +import { CLUSTER_COLORS } from 'src/hooks/useForceGraphSimulation'; +import { fontSize, typography } from 'src/theme'; + +interface MapLegendProps { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + graphData: any; + 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..a46a3793ff7 --- /dev/null +++ b/app/src/sections/map/MapWeights.tsx @@ -0,0 +1,201 @@ +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)} + + + ))} + + {/* Separation line for threshold control */} + + + + + 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..312f5f1b064 --- /dev/null +++ b/app/src/sections/map/useMapResize.test.tsx @@ -0,0 +1,12 @@ +import { renderHook } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { useMapResize } from './useMapResize'; + +describe('useMapResize', () => { + it('initializes with 0 size', () => { + const { result } = renderHook(() => useMapResize()); + expect(result.current.size).toEqual({ w: 0, h: 0 }); + expect(result.current.containerRef.current).toBeNull(); + }); +}); 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..9d93346402e --- /dev/null +++ b/app/src/sections/map/useMapSearch.test.tsx @@ -0,0 +1,23 @@ +import { renderHook } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { useMapSearch } from './useMapSearch'; + +describe('useMapSearch', () => { + it('initializes state correctly', () => { + const { result } = renderHook(() => + useMapSearch({ + nodeById: new Map(), + fgRef: { current: null }, + setHoverId: vi.fn(), + setPinnedId: vi.fn(), + setSearchQuery: vi.fn(), + trackEvent: vi.fn(), + }) + ); + + expect(result.current.searchOpen).toBe(false); + expect(result.current.searchIdx).toBe(0); + expect(result.current.searchInputRef.current).toBeNull(); + }); +}); diff --git a/app/src/sections/map/useMapSearch.ts b/app/src/sections/map/useMapSearch.ts new file mode 100644 index 00000000000..9bd375aa875 --- /dev/null +++ b/app/src/sections/map/useMapSearch.ts @@ -0,0 +1,67 @@ +import { useEffect, useRef, useState } from 'react'; + +import { type MapNode, type SpecMapItem } from 'src/pages/MapPage.helpers'; + +export function useMapSearch({ + nodeById, + fgRef, + setHoverId, + setPinnedId, + setSearchQuery, + trackEvent, +}: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + nodeById: Map; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + fgRef: React.RefObject; + setHoverId: (id: string | null) => void; + setPinnedId: (id: string | null) => void; + setSearchQuery: (q: string) => void; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + trackEvent: (event: string, properties?: any) => void; +}) { + 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..ded36575f80 --- /dev/null +++ b/app/src/sections/map/useMapSelection.test.tsx @@ -0,0 +1,23 @@ +import { renderHook } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { useMapSelection } from './useMapSelection'; + +describe('useMapSelection', () => { + it('initializes state correctly', () => { + const { result } = renderHook(() => + useMapSelection({ + graphData: { idf: new Map(), topTypes: [] }, + nodeById: new Map(), + fgRef: { current: null }, + }) + ); + + 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(); + }); +}); diff --git a/app/src/sections/map/useMapSelection.ts b/app/src/sections/map/useMapSelection.ts new file mode 100644 index 00000000000..45b50296b2d --- /dev/null +++ b/app/src/sections/map/useMapSelection.ts @@ -0,0 +1,121 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; + +import { colorFor, NODE_SIZE } from 'src/hooks/useForceGraphSimulation'; +import { + buildVariantUrl, + fitToBox, + type MapNode, + nodeAspectRatio, +} from 'src/pages/MapPage.helpers'; +import { colors } from 'src/theme'; + +export function useMapSelection({ + graphData, + nodeById, + fgRef, +}: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + graphData: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + nodeById: Map; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + 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; + + useEffect(() => { + const timer = hoverTimerRef.current; + return () => { + if (timer != null) { + window.clearTimeout(timer); + } + }; + }, []); + + 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(() => { + 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, + hoverTimerRef, + panelNodeId, + panelData, + pinColor, + }; +} From 2d8fdac6415401c37a6512087c670ab3317dbdff Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:11:38 +0100 Subject: [PATCH 4/7] docs(changelog): split DebugPage and MapPage Co-Authored-By: Claude Opus 5.5 --- changelog.d/split-debug-map-pages.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 changelog.d/split-debug-map-pages.md diff --git a/changelog.d/split-debug-map-pages.md b/changelog.d/split-debug-map-pages.md new file mode 100644 index 00000000000..f327b116dc7 --- /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. From e8342c3510a3df3d748101304c7a5d82fbc31860 Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:46:32 +0100 Subject: [PATCH 5/7] fix(app): restore what the page split dropped The Gemini Flash pre-PR check found behaviour the Jules split changed: - MapPage lost its loading spinner while the specs fetch; restored. - useMapSelection's unmount cleanup read the hover timer at mount, so it never cleared a pending timer. The debounced hover handler now lives in the hook next to the timer it owns, and the cleanup reads the ref when it runs; MapCanvas gets the handler instead of the ref. - MapWeights drew a solid separator instead of the dashed top border of the threshold row, and lost its comment; restored. - countImpls was duplicated inline in SpecMatrix and five API types were declared twice; both now have one home (debugFormat.ts, the section files). - The split typed props as `any` behind twelve new eslint-disable comments. They now use MapGraphData, MapNode, MapForceConfig, a new MapPanelData, and one ForceGraphHandle alias that keeps base's single untyped ref, so the map has two disables, as before the split. Co-Authored-By: Claude Opus 5.5 --- app/src/pages/DebugPage.tsx | 56 +++---------------- app/src/pages/MapPage.helpers.ts | 8 +++ app/src/pages/MapPage.tsx | 24 ++++++-- app/src/sections/debug/SpecMatrix.tsx | 8 +-- app/src/sections/debug/debugFormat.ts | 5 ++ app/src/sections/map/MapCanvas.tsx | 39 +++++-------- app/src/sections/map/MapHoverPanel.tsx | 4 +- app/src/sections/map/MapLegend.tsx | 5 +- app/src/sections/map/MapWeights.tsx | 17 ++---- app/src/sections/map/useMapSearch.ts | 12 ++-- app/src/sections/map/useMapSelection.test.tsx | 2 +- app/src/sections/map/useMapSelection.ts | 48 ++++++++++++---- 12 files changed, 108 insertions(+), 120 deletions(-) diff --git a/app/src/pages/DebugPage.tsx b/app/src/pages/DebugPage.tsx index 1bdf53b5764..cb5b874521c 100644 --- a/app/src/pages/DebugPage.tsx +++ b/app/src/pages/DebugPage.tsx @@ -10,13 +10,16 @@ import { fetchWithAuth } from 'src/lib/api'; import { AgentCasesSection } from 'src/sections/debug/AgentCasesSection'; import { AgentUsageSection } from 'src/sections/debug/AgentUsageSection'; import { CommonWeaknesses } from 'src/sections/debug/CommonWeaknesses'; -import type { FeedbackMessageFilter } from 'src/sections/debug/constants'; -import { DailyActivityChart } from 'src/sections/debug/DailyActivityChart'; +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 } from 'src/sections/debug/LibraryCoverage'; -import { ProblemAreas } from 'src/sections/debug/ProblemAreas'; -import { RecentActivity } from 'src/sections/debug/RecentActivity'; +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'; @@ -34,22 +37,6 @@ import { // Types // ============================================================================ -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; @@ -58,34 +45,11 @@ interface SystemHealth { total_impls_in_db: number; } -interface DailyImplPoint { - date: string; - impls_updated: number; -} - -interface RecentActivityData { - 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; -} - -export type 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. const DEFAULT_MESSAGE_FILTER: FeedbackMessageFilter = 'open'; @@ -119,7 +83,6 @@ interface DebugStatus { } type SortKey = keyof SpecStatus | 'coverage'; -type SortDir = 'asc' | 'desc'; // ============================================================================ // Helpers @@ -318,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; 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 7bfa1a53668..71845f22856 100644 --- a/app/src/pages/MapPage.tsx +++ b/app/src/pages/MapPage.tsx @@ -3,12 +3,13 @@ import { useEffect, useRef } from 'react'; import { Link, useNavigate } from 'react-router-dom'; import Box from '@mui/material/Box'; +import CircularProgress from '@mui/material/CircularProgress'; import Typography from '@mui/material/Typography'; import useMediaQuery from '@mui/material/useMediaQuery'; import { useAnalytics } from 'src/hooks'; import { useForceGraphSimulation } from 'src/hooks/useForceGraphSimulation'; -import { type MapNode } from 'src/pages/MapPage.helpers'; +import { type ForceGraphHandle, type MapNode } from 'src/pages/MapPage.helpers'; import { specPath } from 'src/routes/paths'; import { MapCanvas, @@ -41,8 +42,7 @@ export function MapPage() { const navigate = useNavigate(); // refs - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const fgRef = useRef(null); + const fgRef = useRef(null); // 1. page view useEffect(() => { @@ -84,7 +84,7 @@ export function MapPage() { pinScreen, hoverType, setHoverType, - hoverTimerRef, + handleNodeHover, panelData, pinColor, } = useMapSelection({ @@ -214,6 +214,20 @@ export function MapPage() { + {/* Loading / error states */} + {!specs && !error && ( + + + + )} {error && ( { - const implCount = ((spec: SpecStatus) => { - let c = 0; - for (const lib of LIBRARIES) if (spec[lib as keyof SpecStatus] !== null) c++; - return c; - })(spec); + const implCount = countImpls(spec); return ( + 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; diff --git a/app/src/sections/map/MapCanvas.tsx b/app/src/sections/map/MapCanvas.tsx index e921b292f8e..68093785b2a 100644 --- a/app/src/sections/map/MapCanvas.tsx +++ b/app/src/sections/map/MapCanvas.tsx @@ -3,11 +3,17 @@ import ForceGraph2D from 'react-force-graph-2d'; import Box from '@mui/material/Box'; import CircularProgress from '@mui/material/CircularProgress'; -import { colorFor, NODE_SIZE } from 'src/hooks/useForceGraphSimulation'; +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, @@ -19,22 +25,18 @@ import { colors, fontSize, typography } from 'src/theme'; interface MapCanvasProps { ready: boolean; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - fgRef: React.RefObject; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - graphData: any; + fgRef: React.RefObject; + graphData: MapGraphData; size: { w: number; h: number }; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - forceConfig: any; + forceConfig: MapForceConfig; hoverId: string | null; hoverType: string | null; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - nodeById: Map; + nodeById: Map; neighbors: Map>; onNodeClick: (node: MapNode) => void; setPinnedId: (id: string | null) => void; setHoverId: (id: string | null) => void; - hoverTimerRef: React.MutableRefObject; + onNodeHover: (n: MapNode | null) => void; markSettled: () => void; handleEngineTick: () => void; settled: boolean; @@ -76,7 +78,7 @@ export function MapCanvas({ onNodeClick, setPinnedId, setHoverId, - hoverTimerRef, + onNodeHover, markSettled, handleEngineTick, settled, @@ -206,20 +208,7 @@ export function MapCanvas({ 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); - } - }} + 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 diff --git a/app/src/sections/map/MapHoverPanel.tsx b/app/src/sections/map/MapHoverPanel.tsx index 6c6af568032..6b9d63c461c 100644 --- a/app/src/sections/map/MapHoverPanel.tsx +++ b/app/src/sections/map/MapHoverPanel.tsx @@ -1,13 +1,13 @@ 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; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - panelData: any; + panelData: MapPanelData | null; } export function MapHoverPanel({ hoverId, pinnedId, panelData }: MapHoverPanelProps) { diff --git a/app/src/sections/map/MapLegend.tsx b/app/src/sections/map/MapLegend.tsx index 9ca8943c182..52527aac4bf 100644 --- a/app/src/sections/map/MapLegend.tsx +++ b/app/src/sections/map/MapLegend.tsx @@ -2,12 +2,11 @@ import { useState } from 'react'; import Box from '@mui/material/Box'; -import { CLUSTER_COLORS } from 'src/hooks/useForceGraphSimulation'; +import { CLUSTER_COLORS, type MapGraphData } from 'src/hooks/useForceGraphSimulation'; import { fontSize, typography } from 'src/theme'; interface MapLegendProps { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - graphData: any; + graphData: MapGraphData; activeCategory: string; hoverType: string | null; setHoverType: (type: string | null) => void; diff --git a/app/src/sections/map/MapWeights.tsx b/app/src/sections/map/MapWeights.tsx index a46a3793ff7..a20ed9b9dc8 100644 --- a/app/src/sections/map/MapWeights.tsx +++ b/app/src/sections/map/MapWeights.tsx @@ -122,22 +122,17 @@ export function MapWeights({ ))} - - {/* Separation line for threshold control */} - - + {/* 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). */} ; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - fgRef: React.RefObject; + nodeById: Map; + fgRef: React.RefObject; setHoverId: (id: string | null) => void; setPinnedId: (id: string | null) => void; setSearchQuery: (q: string) => void; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - trackEvent: (event: string, properties?: any) => void; + trackEvent: ReturnType['trackEvent']; }) { const [searchOpen, setSearchOpen] = useState(false); const [searchIdx, setSearchIdx] = useState(0); diff --git a/app/src/sections/map/useMapSelection.test.tsx b/app/src/sections/map/useMapSelection.test.tsx index ded36575f80..463a34a9b74 100644 --- a/app/src/sections/map/useMapSelection.test.tsx +++ b/app/src/sections/map/useMapSelection.test.tsx @@ -7,7 +7,7 @@ describe('useMapSelection', () => { it('initializes state correctly', () => { const { result } = renderHook(() => useMapSelection({ - graphData: { idf: new Map(), topTypes: [] }, + graphData: { nodes: [], links: [], topTypes: [], typeCounts: new Map(), idf: new Map() }, nodeById: new Map(), fgRef: { current: null }, }) diff --git a/app/src/sections/map/useMapSelection.ts b/app/src/sections/map/useMapSelection.ts index 45b50296b2d..e1f920ef29b 100644 --- a/app/src/sections/map/useMapSelection.ts +++ b/app/src/sections/map/useMapSelection.ts @@ -1,25 +1,32 @@ import { useEffect, useMemo, useRef, useState } from 'react'; -import { colorFor, NODE_SIZE } from 'src/hooks/useForceGraphSimulation'; +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, }: { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - graphData: any; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - nodeById: Map; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - fgRef: React.RefObject; + graphData: MapGraphData; + nodeById: Map; + fgRef: React.RefObject; }) { const [hoverId, setHoverId] = useState(null); const lastPanelNodeIdRef = useRef(null); @@ -33,11 +40,28 @@ export function useMapSelection({ 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(() => { - const timer = hoverTimerRef.current; return () => { - if (timer != null) { - window.clearTimeout(timer); + if (hoverTimerRef.current != null) { + window.clearTimeout(hoverTimerRef.current); } }; }, []); @@ -76,7 +100,7 @@ export function useMapSelection({ return () => cancelAnimationFrame(raf); }, [pinnedId, graphData, nodeById, fgRef]); - const panelData = useMemo(() => { + const panelData = useMemo((): MapPanelData | null => { if (!panelNodeId) return null; const node = nodeById.get(panelNodeId); if (!node) return null; @@ -113,7 +137,7 @@ export function useMapSelection({ pinScreen, hoverType, setHoverType, - hoverTimerRef, + handleNodeHover, panelNodeId, panelData, pinColor, From 6a814f29fe29388d6882d2d4e8610718a939bf98 Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:46:59 +0100 Subject: [PATCH 6/7] docs(changelog): add the PR reference Co-Authored-By: Claude Opus 5.5 --- changelog.d/split-debug-map-pages.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/split-debug-map-pages.md b/changelog.d/split-debug-map-pages.md index f327b116dc7..18a10cd4388 100644 --- a/changelog.d/split-debug-map-pages.md +++ b/changelog.d/split-debug-map-pages.md @@ -6,4 +6,4 @@ 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. + PalettePage stays as it is for now. (#12457) From 740f3918804a674d215feaadfe0d6ad0858dc410 Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Thu, 29 Oct 2026 23:06:44 +0100 Subject: [PATCH 7/7] test(app): cover the map hooks the split extracted The hook tests from the split only checked initial state (Copilot review, codecov patch check). They now cover what each hook owns: - useMapResize: the observed element, size updates, an empty entry list, and the disconnect on unmount. - useMapSearch: Cmd/Ctrl+K focus and select, the listener removal, and selectMatch (camera, pin, query, popup, map_search_select event), plus flyTo before the graph or the node position exists. - useMapSelection: the 100 ms hover debounce, a newer hover cancelling the pending one, the timer cleared on unmount, panel data (plot type, top tags by IDF, preview tier), the panel keeping the last node, the pin colour, and the per-frame pin tracking with its cancel. Co-Authored-By: Claude Opus 5.5 --- app/src/sections/map/useMapResize.test.tsx | 68 ++++++- app/src/sections/map/useMapSearch.test.tsx | 104 ++++++++-- app/src/sections/map/useMapSelection.test.tsx | 178 +++++++++++++++++- 3 files changed, 322 insertions(+), 28 deletions(-) diff --git a/app/src/sections/map/useMapResize.test.tsx b/app/src/sections/map/useMapResize.test.tsx index 312f5f1b064..f090a5f2ea1 100644 --- a/app/src/sections/map/useMapResize.test.tsx +++ b/app/src/sections/map/useMapResize.test.tsx @@ -1,12 +1,68 @@ -import { renderHook } from '@testing-library/react'; -import { describe, expect, it } from 'vitest'; +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', () => { - it('initializes with 0 size', () => { - const { result } = renderHook(() => useMapResize()); - expect(result.current.size).toEqual({ w: 0, h: 0 }); - expect(result.current.containerRef.current).toBeNull(); + 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/useMapSearch.test.tsx b/app/src/sections/map/useMapSearch.test.tsx index 9d93346402e..e32c34ffaa9 100644 --- a/app/src/sections/map/useMapSearch.test.tsx +++ b/app/src/sections/map/useMapSearch.test.tsx @@ -1,23 +1,103 @@ -import { renderHook } from '@testing-library/react'; +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('initializes state correctly', () => { - const { result } = renderHook(() => - useMapSearch({ - nodeById: new Map(), - fgRef: { current: null }, - setHoverId: vi.fn(), - setPinnedId: vi.fn(), - setSearchQuery: vi.fn(), - trackEvent: vi.fn(), - }) - ); + 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/useMapSelection.test.tsx b/app/src/sections/map/useMapSelection.test.tsx index 463a34a9b74..5a86850f151 100644 --- a/app/src/sections/map/useMapSelection.test.tsx +++ b/app/src/sections/map/useMapSelection.test.tsx @@ -1,17 +1,48 @@ -import { renderHook } from '@testing-library/react'; -import { describe, expect, it } from 'vitest'; +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', () => { - it('initializes state correctly', () => { - const { result } = renderHook(() => - useMapSelection({ - graphData: { nodes: [], links: [], topTypes: [], typeCounts: new Map(), idf: new Map() }, - nodeById: new Map(), - fgRef: { current: null }, - }) - ); + 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(); @@ -19,5 +50,132 @@ describe('useMapSelection', () => { 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(); }); });