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:
Experiments DB Dev
2026-06-01 09:37:31 -04:00
parent 7b34c2fa52
commit 839d05f449
2 changed files with 117 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>
);