# 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(); 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( {}} onToggleGroup={() => {}} onShowAll={() => {}} onHighlight={() => {}} />, ); expect(screen.queryByText('Show all')).toBeNull(); rerender( {}} 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 `` text inside its `