feat(frontend): subject sidebar, legend toggle/highlight, ordered tooltip on cross-subject plot
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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,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();
|
||||
});
|
||||
Reference in New Issue
Block a user