diff --git a/frontend/src/lib/crossSubjectChart.js b/frontend/src/lib/crossSubjectChart.js new file mode 100644 index 0000000..8443231 --- /dev/null +++ b/frontend/src/lib/crossSubjectChart.js @@ -0,0 +1,52 @@ +// 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) + .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) })); +} diff --git a/frontend/tests/crossSubjectChart.test.js b/frontend/tests/crossSubjectChart.test.js new file mode 100644 index 0000000..39f1e60 --- /dev/null +++ b/frontend/tests/crossSubjectChart.test.js @@ -0,0 +1,82 @@ +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('—'); + }); +});