docs: implementation plan for cross-subject plot interactions
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,628 @@
|
|||||||
|
# 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 ~446–641; 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 (~518–533) 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 ... />` (~602–603) 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 (~604–608) 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 ... />` (~627–628) and its `lines.map(...)` `<Line>` block (~629–633) — 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/`.
|
||||||
Reference in New Issue
Block a user