Merge: cross-subject plot interactions (sidebar, legend toggle/highlight, ordered tooltip)

This commit is contained in:
Experiments DB Dev
2026-06-01 09:56:41 -04:00
6 changed files with 414 additions and 9 deletions
+56 -9
View File
@@ -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 (
<div className="bg-white border border-gray-200 rounded shadow-sm px-3 py-2 text-xs space-y-0.5 max-h-48 overflow-y-auto">
<p className="font-semibold text-gray-700 mb-1">{label}</p>
{payload.map((p) => {
{rows.map((p) => {
const line = lines.find((l) => l.id === p.dataKey);
return p.value != null ? (
return (
<p key={p.dataKey} style={{ color: p.stroke }}>
{line?.name ?? p.dataKey}{line?.group && line.group !== '—' ? ` (${line.group})` : ''}: {p.value}{unit}
</p>
) : null;
);
})}
</div>
);
@@ -554,6 +566,17 @@ export function ExperimentAnalysisCharts({ animals, experimentStatuses, subjectT
</div>
)}
<TickStepControl value={tickStep} onChange={setTickStep} />
{hasData && (
<button type="button" onClick={() => 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'}
</button>
)}
</div>
{!hasData && (
@@ -561,7 +584,8 @@ export function ExperimentAnalysisCharts({ animals, experimentStatuses, subjectT
)}
{hasData && (
<>
<div className="flex gap-3 items-start">
<div className="flex-1 min-w-0 space-y-5">
{/* Chart 1 — counts */}
<div>
<div className="flex items-center justify-between mb-2 flex-wrap gap-2">
@@ -600,10 +624,16 @@ export function ExperimentAnalysisCharts({ animals, experimentStatuses, subjectT
<YAxis tick={{ fontSize: 10 }} width={30} allowDecimals={false} domain={countsS.yDomain} />
<Tooltip content={<ExpTooltip />} />
<Legend wrapperStyle={{ fontSize: 11 }}
formatter={(value) => 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) => (
<Line key={l.id} type="monotone" dataKey={l.id} name={l.id}
stroke={l.color} strokeWidth={2} dot={{ r: 3 }} activeDot={{ r: 5 }}
stroke={l.color}
strokeWidth={highlightedSubject === l.id ? 3.5 : 2}
hide={hiddenSubjects.has(l.id)}
dot={{ r: 3 }} activeDot={{ r: 5 }}
connectNulls isAnimationActive={false} />
))}
</LineChart>
@@ -625,16 +655,33 @@ export function ExperimentAnalysisCharts({ animals, experimentStatuses, subjectT
tickFormatter={(v) => `${v}%`} />
<Tooltip content={<ExpTooltip unit="%" />} />
<Legend wrapperStyle={{ fontSize: 11 }}
formatter={(value) => 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) => (
<Line key={l.id} type="monotone" dataKey={l.id} name={l.id}
stroke={l.color} strokeWidth={2} dot={{ r: 3 }} activeDot={{ r: 5 }}
stroke={l.color}
strokeWidth={highlightedSubject === l.id ? 3.5 : 2}
hide={hiddenSubjects.has(l.id)}
dot={{ r: 3 }} activeDot={{ r: 5 }}
connectNulls isAnimationActive={false} />
))}
</LineChart>
</ResponsiveContainer>
</div>
</>
</div>
{sidebarOpen && (
<SubjectSidebar
lines={lines}
hiddenSubjects={hiddenSubjects}
onToggleSubject={toggleHidden}
onToggleGroup={toggleGroup}
onShowAll={showAll}
onHighlight={setHighlightedSubject}
/>
)}
</div>
)}
</div>
);
@@ -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 (
<div className="w-[210px] shrink-0 border-l border-gray-100 pl-3">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Subjects</span>
{anyHidden && (
<button type="button" onClick={onShowAll} aria-label="Show all subjects"
className="text-xs text-indigo-500 hover:text-indigo-700">Show all</button>
)}
</div>
<div className="space-y-3 max-h-[320px] overflow-y-auto pr-1">
{groups.map(({ group, subjects }) => {
const ids = subjects.map((s) => s.id);
const state = groupVisibilityState(ids, hiddenSubjects);
return (
<div key={group}>
<label className="flex items-center gap-1.5 text-xs font-medium text-gray-600 cursor-pointer">
<input type="checkbox"
checked={state === 'all'}
ref={(el) => { if (el) el.indeterminate = state === 'some'; }}
onChange={() => onToggleGroup(ids, state === 'all')}
className="accent-indigo-500" />
<span className="truncate">{group}</span>
<span className="text-gray-400">({subjects.length})</span>
</label>
<div className="mt-1 space-y-0.5 pl-1">
{subjects.map((s) => (
<label key={s.id}
onMouseEnter={() => onHighlight(s.id)}
onMouseLeave={() => onHighlight(null)}
className="flex items-center gap-1.5 text-xs text-gray-600 cursor-pointer hover:bg-gray-50 rounded px-1 py-0.5">
<input type="checkbox"
checked={!hiddenSubjects.has(s.id)}
onChange={() => onToggleSubject(s.id)}
className="accent-indigo-500" />
<span className="inline-block w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: s.color }} />
<span className="truncate">{s.name}</span>
</label>
))}
</div>
</div>
);
})}
</div>
</div>
);
}
+52
View File
@@ -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) }));
}
@@ -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 }) => <div>{children}</div>;
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(
<ExperimentAnalysisCharts
animals={ANIMALS}
experimentStatuses={STATUSES}
subjectTemplate={SUBJECT_TEMPLATE}
dailyTemplate={DAILY_TEMPLATE}
/>,
);
}
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();
});
+104
View File
@@ -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(<SubjectSidebar {...props} />);
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(
<SubjectSidebar lines={LINES} hiddenSubjects={new Set()}
onToggleSubject={() => {}} onToggleGroup={() => {}}
onShowAll={() => {}} onHighlight={() => {}} />,
);
expect(screen.queryByText('Show all')).toBeNull();
rerender(
<SubjectSidebar lines={LINES} hiddenSubjects={new Set(['a1'])}
onToggleSubject={() => {}} 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);
});
+82
View File
@@ -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('—');
});
});