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 && (
+ setSidebarOpen((o) => !o)}
+ className={[
+ 'text-xs px-2 py-0.5 rounded border transition-colors ml-auto',
+ sidebarOpen
+ ? 'bg-gray-100 border-gray-300 text-gray-700'
+ : 'bg-white border-gray-200 text-gray-500 hover:text-gray-700 hover:border-gray-300',
+ ].join(' ')}>
+ {sidebarOpen ? '◂ Subjects' : '▸ Subjects'}
+
+ )}
{!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();
+});