feat(frontend): pure helpers for cross-subject plot interactions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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) }));
|
||||||
|
}
|
||||||
@@ -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('—');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user