From 839d05f4496a924803998f6088d0717f88572f1c Mon Sep 17 00:00:00 2001 From: Experiments DB Dev Date: Mon, 1 Jun 2026 09:37:31 -0400 Subject: [PATCH] feat(frontend): subject sidebar, legend toggle/highlight, ordered tooltip on cross-subject plot Co-Authored-By: Claude Opus 4.8 --- frontend/src/components/AnalysisCharts.jsx | 65 ++++++++++++++++--- .../tests/ExperimentAnalysisCharts.test.jsx | 61 +++++++++++++++++ 2 files changed, 117 insertions(+), 9 deletions(-) create mode 100644 frontend/tests/ExperimentAnalysisCharts.test.jsx diff --git a/frontend/src/components/AnalysisCharts.jsx b/frontend/src/components/AnalysisCharts.jsx index c1a2738..536da13 100644 --- a/frontend/src/components/AnalysisCharts.jsx +++ b/frontend/src/components/AnalysisCharts.jsx @@ -4,6 +4,8 @@ import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, } from 'recharts'; +import { SubjectSidebar } from './SubjectSidebar'; +import { sortPayloadByValueDesc, toggleInSet, setIdsHidden } from '../lib/crossSubjectChart'; // ── Color palettes ───────────────────────────────────────────────────────────── @@ -453,6 +455,15 @@ export function ExperimentAnalysisCharts({ animals, experimentStatuses, subjectT const [xField, setXField] = useState(() => defaultXField(xAxisFields)); const [tickStep, setTickStep] = useState(1); + // Cross-subject interaction state (client-only; global across both charts) + const [sidebarOpen, setSidebarOpen] = useState(false); + const [hiddenSubjects, setHiddenSubjects] = useState(() => new Set()); + const [highlightedSubject, setHighlightedSubject] = useState(null); + + const toggleHidden = (id) => setHiddenSubjects((prev) => toggleInSet(prev, id)); + const toggleGroup = (ids, hidden) => setHiddenSubjects((prev) => setIdsHidden(prev, ids, hidden)); + const showAll = () => setHiddenSubjects(new Set()); + const countsS = useChartSettings(200); const rateS = useChartSettings(160); @@ -517,16 +528,17 @@ export function ExperimentAnalysisCharts({ animals, experimentStatuses, subjectT function ExpTooltip({ active, payload, label, unit = '' }) { if (!active || !payload?.length) return null; + const rows = sortPayloadByValueDesc(payload); return (

{label}

- {payload.map((p) => { + {rows.map((p) => { const line = lines.find((l) => l.id === p.dataKey); - return p.value != null ? ( + return (

{line?.name ?? p.dataKey}{line?.group && line.group !== '—' ? ` (${line.group})` : ''}: {p.value}{unit}

- ) : null; + ); })}
); @@ -554,6 +566,17 @@ export function ExperimentAnalysisCharts({ animals, experimentStatuses, subjectT )} + {hasData && ( + + )} {!hasData && ( @@ -561,7 +584,8 @@ export function ExperimentAnalysisCharts({ animals, experimentStatuses, subjectT )} {hasData && ( - <> +
+
{/* Chart 1 — counts */}
@@ -600,10 +624,16 @@ export function ExperimentAnalysisCharts({ animals, experimentStatuses, subjectT } /> lines.find((l) => l.id === value)?.name ?? value} /> + formatter={(value) => lines.find((l) => l.id === value)?.name ?? value} + onClick={(o) => toggleHidden(o.dataKey)} + onMouseEnter={(o) => setHighlightedSubject(o.dataKey)} + onMouseLeave={() => setHighlightedSubject(null)} /> {lines.map((l) => ( ))} @@ -625,16 +655,33 @@ export function ExperimentAnalysisCharts({ animals, experimentStatuses, subjectT tickFormatter={(v) => `${v}%`} /> } /> lines.find((l) => l.id === value)?.name ?? value} /> + formatter={(value) => lines.find((l) => l.id === value)?.name ?? value} + onClick={(o) => toggleHidden(o.dataKey)} + onMouseEnter={(o) => setHighlightedSubject(o.dataKey)} + onMouseLeave={() => setHighlightedSubject(null)} /> {lines.map((l) => ( ))}
- +
+ {sidebarOpen && ( + + )} +
)}
); diff --git a/frontend/tests/ExperimentAnalysisCharts.test.jsx b/frontend/tests/ExperimentAnalysisCharts.test.jsx new file mode 100644 index 0000000..17c2021 --- /dev/null +++ b/frontend/tests/ExperimentAnalysisCharts.test.jsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; + +// recharts uses ResizeObserver (absent in jsdom); stub all used pieces as passthroughs. +// Coverage boundary: with recharts mocked out, these tests cover only the sidebar +// integration path (toggle, checkbox state, persistence). The legend onClick/hover +// wiring, line hide/strokeWidth, and tooltip ordering are exercised by the pure-helper +// unit tests (crossSubjectChart.test.js) and SubjectSidebar.test.jsx. +jest.mock('recharts', () => { + const Pass = ({ children }) =>
{children}
; + return { + ResponsiveContainer: Pass, LineChart: Pass, Line: () => null, + XAxis: () => null, YAxis: () => null, CartesianGrid: () => null, + Tooltip: () => null, Legend: () => null, + }; +}); + +import { ExperimentAnalysisCharts } from '../src/components/AnalysisCharts'; + +const ANIMALS = [ + { id: 'a1', animal_name: 'Mouse-A1', subject_info: { grp: 'Group A' } }, + { id: 'b1', animal_name: 'Mouse-B1', subject_info: { grp: 'Group B' } }, +]; +const STATUSES = [ + { animal_id: 'a1', date: '2026-01-01', analysis_summary: { total: 10, success_rate: 0.5, counts: { Success: 5 } } }, + { animal_id: 'b1', date: '2026-01-01', analysis_summary: { total: 8, success_rate: 0.25, counts: { Success: 2 } } }, +]; +const SUBJECT_TEMPLATE = [{ fieldId: 'grp', label: 'Group', active: true }]; +const DAILY_TEMPLATE = []; + +function renderChart() { + render( + , + ); +} + +it('toggles the subject sidebar open and closed', () => { + renderChart(); + expect(screen.queryByText('Subjects')).toBeNull(); + fireEvent.click(screen.getByText('▸ Subjects')); + expect(screen.getByText('Subjects')).toBeInTheDocument(); + expect(screen.getByLabelText('Mouse-A1')).toBeInTheDocument(); + expect(screen.getByLabelText('Mouse-B1')).toBeInTheDocument(); + fireEvent.click(screen.getByText('◂ Subjects')); + expect(screen.queryByText('Subjects')).toBeNull(); +}); + +it('unchecking a subject in the sidebar persists across re-render', () => { + renderChart(); + fireEvent.click(screen.getByText('▸ Subjects')); + const cb = screen.getByLabelText('Mouse-A1'); + expect(cb.checked).toBe(true); + fireEvent.click(cb); + expect(screen.getByLabelText('Mouse-A1').checked).toBe(false); + expect(screen.getByText('Show all')).toBeInTheDocument(); +});