Files
experiments-database/docs/superpowers/plans/2026-06-01-cross-subject-plot-interactions.md
2026-06-01 09:26:00 -04:00

629 lines
24 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Cross-subject Plot Interactions Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a subject-visibility sidebar, legend click-to-toggle, value-ordered tooltips, and legend/sidebar hover highlight to the "Cross-subject metrics" plot.
**Architecture:** Extract pure interaction logic into `src/lib/crossSubjectChart.js` (unit-tested), build a standalone presentational `SubjectSidebar` component (unit-tested with RTL), then wire shared client-side state (`hiddenSubjects`, `highlightedSubject`, `sidebarOpen`) into `ExperimentAnalysisCharts` and thread it through both Recharts `LineChart`s. Visibility is global per-subject (affects both charts).
**Tech Stack:** React 18, Recharts 2.15, Jest + React Testing Library, Tailwind.
**Constraints:** Frontend only — no backend/API/DB changes, nothing persisted. Do not alter the sibling `SubjectAnalysisCharts` or the existing controls (X axis, Color by, tick step, per-chart customize bars, Y-metric selector).
---
### Task 1: Pure interaction helpers
**Files:**
- Create: `frontend/src/lib/crossSubjectChart.js`
- Test: `frontend/tests/crossSubjectChart.test.js`
- [ ] **Step 1: Write the failing tests**
```js
// frontend/tests/crossSubjectChart.test.js
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('—');
});
});
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `cd frontend && npx jest tests/crossSubjectChart.test.js`
Expected: FAIL — cannot find module `../src/lib/crossSubjectChart`.
- [ ] **Step 3: Write the implementation**
```js
// frontend/src/lib/crossSubjectChart.js
// 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)
.slice()
.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) }));
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `cd frontend && npx jest tests/crossSubjectChart.test.js`
Expected: PASS — all describe blocks green.
- [ ] **Step 5: Commit**
```bash
git add frontend/src/lib/crossSubjectChart.js frontend/tests/crossSubjectChart.test.js
git commit -m "feat(frontend): pure helpers for cross-subject plot interactions"
```
---
### Task 2: SubjectSidebar component
**Files:**
- Create: `frontend/src/components/SubjectSidebar.jsx`
- Test: `frontend/tests/SubjectSidebar.test.jsx`
- [ ] **Step 1: Write the failing tests**
```jsx
// frontend/tests/SubjectSidebar.test.jsx
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('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);
});
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `cd frontend && npx jest tests/SubjectSidebar.test.jsx`
Expected: FAIL — cannot find module `../src/components/SubjectSidebar`.
- [ ] **Step 3: Write the implementation**
Note: the group checkbox's accessible name comes from the group `<span>` text inside its `<label>`, so `getByLabelText(/Group A/)` matches it; subject checkboxes are matched by their name text. The group `<label>` text includes the count, so its accessible name is e.g. "Group A (2)" — `/Group A/` regex still matches.
```jsx
// frontend/src/components/SubjectSidebar.jsx
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}
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>
);
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `cd frontend && npx jest tests/SubjectSidebar.test.jsx`
Expected: PASS — all tests green.
- [ ] **Step 5: Commit**
```bash
git add frontend/src/components/SubjectSidebar.jsx frontend/tests/SubjectSidebar.test.jsx
git commit -m "feat(frontend): add SubjectSidebar visibility panel"
```
---
### Task 3: Wire state, sidebar, and behaviors into ExperimentAnalysisCharts
**Files:**
- Modify: `frontend/src/components/AnalysisCharts.jsx` (`ExperimentAnalysisCharts`, lines ~446641; imports at top)
- Test: `frontend/tests/ExperimentAnalysisCharts.test.jsx` (create)
- [ ] **Step 1: Add imports**
At the top of `frontend/src/components/AnalysisCharts.jsx`, add after the existing `date-fns` import:
```jsx
import { SubjectSidebar } from './SubjectSidebar';
import { sortPayloadByValueDesc, toggleInSet, setIdsHidden } from '../lib/crossSubjectChart';
```
- [ ] **Step 2: Add shared state and handlers**
In `ExperimentAnalysisCharts`, immediately after the existing `const [tickStep, setTickStep] = useState(1);` line (~454), add:
```jsx
// 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());
```
- [ ] **Step 3: Update the `ExpTooltip` to order rows by value descending**
Replace the existing `ExpTooltip` function (~518533) with:
```jsx
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>
{rows.map((p) => {
const line = lines.find((l) => l.id === p.dataKey);
return (
<p key={p.dataKey} style={{ color: p.stroke }}>
{line?.name ?? p.dataKey}{line?.group && line.group !== '—' ? ` (${line.group})` : ''}: {p.value}{unit}
</p>
);
})}
</div>
);
}
```
- [ ] **Step 4: Add the sidebar toggle button to the global controls row**
In the global-controls `<div className="flex items-center flex-wrap gap-3">` block, immediately after the `<TickStepControl ... />` line (~556), add:
```jsx
{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>
)}
```
Note: `hasData` is declared at ~509 (`const hasData = lines.length > 0;`), before the `return`, so it is in scope here.
- [ ] **Step 5: Wrap the charts in a flex row with the sidebar**
Replace the `{hasData && (` fragment opener and its closing for the charts block. The current structure is:
```jsx
{hasData && (
<>
{/* Chart 1 — counts */}
<div> ... </div>
{/* Chart 2 — success rate */}
<div> ... </div>
</>
)}
```
Change ONLY the wrapper (keep both chart `<div>` blocks exactly as they are between the markers):
```jsx
{hasData && (
<div className="flex gap-3 items-start">
<div className="flex-1 min-w-0 space-y-5">
{/* Chart 1 — counts */}
{/* ...unchanged chart 1 <div> ... */}
{/* Chart 2 — success rate */}
{/* ...unchanged chart 2 <div> ... */}
</div>
{sidebarOpen && (
<SubjectSidebar
lines={lines}
hiddenSubjects={hiddenSubjects}
onToggleSubject={toggleHidden}
onToggleGroup={toggleGroup}
onShowAll={showAll}
onHighlight={setHighlightedSubject}
/>
)}
</div>
)}
```
`min-w-0` on the chart column lets the flex child shrink so `ResponsiveContainer` re-flows when the sidebar is open.
- [ ] **Step 6: Add legend handlers and per-line hide/highlight to BOTH charts**
In the **counts** chart, replace the `<Legend ... />` (~602603) with:
```jsx
<Legend wrapperStyle={{ fontSize: 11 }}
formatter={(value) => lines.find((l) => l.id === value)?.name ?? value}
onClick={(o) => toggleHidden(o.dataKey)}
onMouseEnter={(o) => setHighlightedSubject(o.dataKey)}
onMouseLeave={() => setHighlightedSubject(null)} />
```
and replace its `lines.map(...)` `<Line>` block (~604608) with:
```jsx
{lines.map((l) => (
<Line key={l.id} type="monotone" dataKey={l.id} name={l.id}
stroke={l.color}
strokeWidth={highlightedSubject === l.id ? 3.5 : 2}
hide={hiddenSubjects.has(l.id)}
dot={{ r: 3 }} activeDot={{ r: 5 }}
connectNulls isAnimationActive={false} />
))}
```
In the **success-rate** chart, make the identical replacements for its `<Legend ... />` (~627628) and its `lines.map(...)` `<Line>` block (~629633) — same two snippets above.
- [ ] **Step 7: Write a smoke test for the wiring**
```jsx
// frontend/tests/ExperimentAnalysisCharts.test.jsx
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
// recharts uses ResizeObserver (absent in jsdom); stub all used pieces as passthroughs.
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(); // closed: panel header not rendered
fireEvent.click(screen.getByText('▸ Subjects'));
// panel header "Subjects" + both subjects now visible
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);
// hiding one subject reveals the Show all link
expect(screen.getByText('Show all')).toBeInTheDocument();
});
```
Note: the controls header `<h2>` text is "Cross-subject metrics"; the sidebar panel header text is "Subjects". They are distinct, so `queryByText('Subjects')` is null until the panel opens.
- [ ] **Step 8: Run the smoke test to verify it passes**
Run: `cd frontend && npx jest tests/ExperimentAnalysisCharts.test.jsx`
Expected: PASS — both tests green.
- [ ] **Step 9: Run the full frontend test suite to confirm no regressions**
Run: `cd frontend && npm test`
Expected: PASS — all existing suites plus the three new files green.
- [ ] **Step 10: Build to confirm no syntax/compile errors**
Run: `cd frontend && npm run build`
Expected: Vite build succeeds with no errors.
- [ ] **Step 11: Commit**
```bash
git add frontend/src/components/AnalysisCharts.jsx frontend/tests/ExperimentAnalysisCharts.test.jsx
git commit -m "feat(frontend): subject sidebar, legend toggle/highlight, ordered tooltip on cross-subject plot"
```
---
## Manual Verification (after Task 3)
On the `ExperimentDetail` page for an experiment with animals and saved analysis metrics:
- [ ] `▸ Subjects` button toggles the sidebar; charts re-flow narrower while open.
- [ ] Unchecking a subject removes its line from BOTH charts; its legend entry greys out and stays clickable.
- [ ] Re-checking (sidebar) or clicking the greyed legend entry restores the line in both charts.
- [ ] Group checkbox toggles all members; shows indeterminate () when the group is mixed.
- [ ] "Show all" appears only when something is hidden and clears all hidden state.
- [ ] Clicking a legend entry in either chart toggles that subject in both.
- [ ] Hover tooltip lists subjects highest-value-first, matching their vertical order.
- [ ] Hovering a legend entry OR a sidebar row thickens that subject's line in both charts; leaving restores it.
- [ ] Existing controls still work: X axis, Color by, tick step, per-chart customize (size/Y range/X zoom/X-field), Y-metric selector. Sibling "Session metrics over time" chart is unchanged.
## Self-Review Notes
- **Spec coverage:** sidebar + groups + per-subject/per-group checkboxes (Task 2, Task 3 wiring); global visibility via `hiddenSubjects` (Task 1 `toggleInSet`/`setIdsHidden`, Task 3); legend click toggle (Task 3 Step 6); value-ordered tooltip (Task 1 `sortPayloadByValueDesc`, Task 3 Step 3); legend + sidebar hover highlight via `highlightedSubject` + `strokeWidth` (Task 2 onHighlight, Task 3 Steps 2/6). All four behaviors covered.
- **Naming consistency:** `toggleHidden`, `toggleGroup`, `showAll`, `setHighlightedSubject`, `hiddenSubjects`, `highlightedSubject`, `sidebarOpen` used identically across Task 2 props and Task 3 wiring.
- **No DB/API:** all state is `useState` in the component; no imports from `api/`.