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/src/components/SubjectSidebar.jsx b/frontend/src/components/SubjectSidebar.jsx new file mode 100644 index 0000000..2d836c5 --- /dev/null +++ b/frontend/src/components/SubjectSidebar.jsx @@ -0,0 +1,59 @@ +import React from 'react'; +import { groupLines, groupVisibilityState } from '../lib/crossSubjectChart'; + +// Presentational panel listing subjects grouped by their group, with per-subject +// and per-group visibility checkboxes. All state lives in the parent; this component +// only renders and calls handlers. +export function SubjectSidebar({ + lines, hiddenSubjects, onToggleSubject, onToggleGroup, onShowAll, onHighlight, +}) { + const groups = groupLines(lines); + const anyHidden = lines.some((l) => hiddenSubjects.has(l.id)); + + return ( +
+
+ Subjects + {anyHidden && ( + + )} +
+
+ {groups.map(({ group, subjects }) => { + const ids = subjects.map((s) => s.id); + const state = groupVisibilityState(ids, hiddenSubjects); + return ( +
+ +
+ {subjects.map((s) => ( + + ))} +
+
+ ); + })} +
+
+ ); +} diff --git a/frontend/src/lib/crossSubjectChart.js b/frontend/src/lib/crossSubjectChart.js new file mode 100644 index 0000000..8443231 --- /dev/null +++ b/frontend/src/lib/crossSubjectChart.js @@ -0,0 +1,52 @@ +// Pure helpers for the cross-subject metrics plot interactions. No React, no I/O. + +// Sort a Recharts tooltip payload by numeric value descending, dropping entries +// whose value is null/undefined. Returns a NEW array (does not mutate input). +export function sortPayloadByValueDesc(payload) { + if (!Array.isArray(payload)) return []; + return payload + .filter((p) => p && p.value != null) + .sort((a, b) => b.value - a.value); +} + +// Toggle an id's membership in a Set, returning a NEW Set. +export function toggleInSet(set, id) { + const next = new Set(set); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; +} + +// Add (hidden=true) or remove (hidden=false) a list of ids from a hidden-set. +// Returns a NEW Set. +export function setIdsHidden(set, ids, hidden) { + const next = new Set(set); + for (const id of ids) { + if (hidden) next.add(id); + else next.delete(id); + } + return next; +} + +// Tri-state for a group checkbox given which subject ids are hidden. +// 'all' = all visible, 'none' = all hidden, 'some' = mixed. +export function groupVisibilityState(subjectIds, hiddenSet) { + if (subjectIds.length === 0) return 'none'; + const hiddenCount = subjectIds.filter((id) => hiddenSet.has(id)).length; + if (hiddenCount === 0) return 'all'; + if (hiddenCount === subjectIds.length) return 'none'; + return 'some'; +} + +// Group chart "lines" by their .group field, preserving first-seen group order +// and line order within each group. Returns [{ group, subjects: [line...] }]. +export function groupLines(lines) { + const order = []; + const byGroup = new Map(); + for (const line of lines) { + const g = line.group ?? '—'; + if (!byGroup.has(g)) { byGroup.set(g, []); order.push(g); } + byGroup.get(g).push(line); + } + return order.map((group) => ({ group, subjects: byGroup.get(group) })); +} 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(); +}); diff --git a/frontend/tests/SubjectSidebar.test.jsx b/frontend/tests/SubjectSidebar.test.jsx new file mode 100644 index 0000000..069e123 --- /dev/null +++ b/frontend/tests/SubjectSidebar.test.jsx @@ -0,0 +1,104 @@ +import React from 'react'; +import { render, screen, fireEvent, within } from '@testing-library/react'; +import { SubjectSidebar } from '../src/components/SubjectSidebar'; + +const LINES = [ + { id: 'a1', name: 'Mouse-A1', group: 'Group A', color: '#111' }, + { id: 'a2', name: 'Mouse-A2', group: 'Group A', color: '#222' }, + { id: 'b1', name: 'Mouse-B1', group: 'Group B', color: '#333' }, +]; + +function setup(overrides = {}) { + const props = { + lines: LINES, + hiddenSubjects: new Set(), + onToggleSubject: jest.fn(), + onToggleGroup: jest.fn(), + onShowAll: jest.fn(), + onHighlight: jest.fn(), + ...overrides, + }; + render(); + return props; +} + +it('renders each group header with its subject count', () => { + setup(); + expect(screen.getByText('Group A')).toBeInTheDocument(); + expect(screen.getByText('Group B')).toBeInTheDocument(); + expect(screen.getByText('(2)')).toBeInTheDocument(); + expect(screen.getByText('(1)')).toBeInTheDocument(); +}); + +it('renders a subject checkbox per subject, checked when visible', () => { + setup({ hiddenSubjects: new Set(['a2']) }); + expect(screen.getByLabelText('Mouse-A1').checked).toBe(true); + expect(screen.getByLabelText('Mouse-A2').checked).toBe(false); +}); + +it('calls onToggleSubject with the id when a subject checkbox is clicked', () => { + const { onToggleSubject } = setup(); + fireEvent.click(screen.getByLabelText('Mouse-A1')); + expect(onToggleSubject).toHaveBeenCalledWith('a1'); +}); + +it('shows the group checkbox as indeterminate when the group is mixed', () => { + setup({ hiddenSubjects: new Set(['a1']) }); + const groupCheckbox = screen.getByLabelText(/Group A/); + expect(groupCheckbox.indeterminate).toBe(true); + expect(groupCheckbox.checked).toBe(false); +}); + +it('checks the group checkbox when all members are visible', () => { + setup(); + expect(screen.getByLabelText(/Group A/).checked).toBe(true); +}); + +it('calls onToggleGroup with the group ids and hidden=true when an all-visible group is clicked', () => { + const { onToggleGroup } = setup(); + fireEvent.click(screen.getByLabelText(/Group A/)); + expect(onToggleGroup).toHaveBeenCalledWith(['a1', 'a2'], true); +}); + +it('shows the group checkbox unchecked and not indeterminate when all members are hidden', () => { + setup({ hiddenSubjects: new Set(['a1', 'a2']) }); + const groupCheckbox = screen.getByLabelText(/Group A/); + expect(groupCheckbox.checked).toBe(false); + expect(groupCheckbox.indeterminate).toBe(false); +}); + +it('calls onToggleGroup with hidden=false when a fully-hidden group is clicked', () => { + const { onToggleGroup } = setup({ hiddenSubjects: new Set(['a1', 'a2']) }); + fireEvent.click(screen.getByLabelText(/Group A/)); + expect(onToggleGroup).toHaveBeenCalledWith(['a1', 'a2'], false); +}); + +it('hides the Show all link when nothing is hidden, shows it otherwise', () => { + const { rerender } = render( + {}} onToggleGroup={() => {}} + onShowAll={() => {}} onHighlight={() => {}} />, + ); + expect(screen.queryByText('Show all')).toBeNull(); + rerender( + {}} onToggleGroup={() => {}} + onShowAll={() => {}} onHighlight={() => {}} />, + ); + expect(screen.getByText('Show all')).toBeInTheDocument(); +}); + +it('calls onShowAll when the Show all link is clicked', () => { + const { onShowAll } = setup({ hiddenSubjects: new Set(['a1']) }); + fireEvent.click(screen.getByText('Show all')); + expect(onShowAll).toHaveBeenCalled(); +}); + +it('calls onHighlight on subject row hover enter/leave', () => { + const { onHighlight } = setup(); + const row = screen.getByLabelText('Mouse-A1').closest('label'); + fireEvent.mouseEnter(row); + expect(onHighlight).toHaveBeenCalledWith('a1'); + fireEvent.mouseLeave(row); + expect(onHighlight).toHaveBeenCalledWith(null); +}); diff --git a/frontend/tests/crossSubjectChart.test.js b/frontend/tests/crossSubjectChart.test.js new file mode 100644 index 0000000..39f1e60 --- /dev/null +++ b/frontend/tests/crossSubjectChart.test.js @@ -0,0 +1,82 @@ +import { + sortPayloadByValueDesc, + toggleInSet, + setIdsHidden, + groupVisibilityState, + groupLines, +} from '../src/lib/crossSubjectChart'; + +describe('sortPayloadByValueDesc', () => { + it('sorts by value descending and drops null/undefined values', () => { + const payload = [ + { dataKey: 'a', value: 40 }, + { dataKey: 'b', value: null }, + { dataKey: 'c', value: 92 }, + { dataKey: 'd', value: 78 }, + ]; + expect(sortPayloadByValueDesc(payload).map((p) => p.dataKey)).toEqual(['c', 'd', 'a']); + }); + it('returns [] for non-array input', () => { + expect(sortPayloadByValueDesc(undefined)).toEqual([]); + }); + it('does not mutate the input array', () => { + const payload = [{ dataKey: 'a', value: 1 }, { dataKey: 'b', value: 2 }]; + sortPayloadByValueDesc(payload); + expect(payload.map((p) => p.dataKey)).toEqual(['a', 'b']); + }); +}); + +describe('toggleInSet', () => { + it('adds a missing id and removes a present id, returning a new Set', () => { + const a = new Set(['x']); + const added = toggleInSet(a, 'y'); + expect([...added].sort()).toEqual(['x', 'y']); + expect(added).not.toBe(a); + const removed = toggleInSet(added, 'x'); + expect([...removed]).toEqual(['y']); + }); +}); + +describe('setIdsHidden', () => { + it('adds all ids when hidden=true', () => { + expect([...setIdsHidden(new Set(['a']), ['b', 'c'], true)].sort()).toEqual(['a', 'b', 'c']); + }); + it('removes all ids when hidden=false', () => { + expect([...setIdsHidden(new Set(['a', 'b', 'c']), ['b', 'c'], false)]).toEqual(['a']); + }); + it('returns a new Set', () => { + const s = new Set(); + expect(setIdsHidden(s, ['a'], true)).not.toBe(s); + }); +}); + +describe('groupVisibilityState', () => { + it("returns 'all' when none of the ids are hidden", () => { + expect(groupVisibilityState(['a', 'b'], new Set())).toBe('all'); + }); + it("returns 'none' when all ids are hidden", () => { + expect(groupVisibilityState(['a', 'b'], new Set(['a', 'b']))).toBe('none'); + }); + it("returns 'some' when a subset is hidden", () => { + expect(groupVisibilityState(['a', 'b'], new Set(['a']))).toBe('some'); + }); + it("returns 'none' for an empty group", () => { + expect(groupVisibilityState([], new Set())).toBe('none'); + }); +}); + +describe('groupLines', () => { + it('groups by .group preserving first-seen group order and within-group order', () => { + const lines = [ + { id: '1', group: 'B' }, + { id: '2', group: 'A' }, + { id: '3', group: 'B' }, + ]; + const out = groupLines(lines); + expect(out.map((g) => g.group)).toEqual(['B', 'A']); + expect(out[0].subjects.map((s) => s.id)).toEqual(['1', '3']); + }); + it("uses '—' for lines with no group", () => { + expect(groupLines([{ id: '1' }])[0].group).toBe('—'); + }); +});