// 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) })); } // ── Plot config (de)serialization ─────────────────────────────────────────────── // Translate between ExperimentAnalysisCharts React state and the stored JSONB blob. // readPlotConfig is defensive: it never throws and fills safe defaults for missing // or malformed input (unknown keys are ignored). // Symmetric with readChart so a serialize→read round-trip is lossless. function snapshotChart(s = {}) { const str = (v) => (typeof v === 'string' ? v : ''); return { height: Number.isFinite(s.height) ? s.height : undefined, yMin: str(s.yMin), yMax: str(s.yMax), xZoomMin: str(s.xZoomMin), xZoomMax: str(s.xZoomMax), xFieldOverride: typeof s.xFieldOverride === 'string' ? s.xFieldOverride : null, }; } export function serializePlotConfig(state = {}) { const { xField, groupBy, tickStep, metric, hiddenSubjects, sidebarOpen, counts, rate } = state; return { xField, groupBy, tickStep, metric, hiddenSubjects: [...(hiddenSubjects ?? [])].sort(), sidebarOpen: !!sidebarOpen, counts: snapshotChart(counts), rate: snapshotChart(rate), }; } function readChart(c) { const o = c && typeof c === 'object' && !Array.isArray(c) ? c : {}; const str = (v) => (typeof v === 'string' ? v : ''); return { height: Number.isFinite(o.height) ? o.height : undefined, yMin: str(o.yMin), yMax: str(o.yMax), xZoomMin: str(o.xZoomMin), xZoomMax: str(o.xZoomMax), xFieldOverride: typeof o.xFieldOverride === 'string' ? o.xFieldOverride : null, }; } export function readPlotConfig(raw) { const c = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {}; return { xField: typeof c.xField === 'string' ? c.xField : undefined, groupBy: typeof c.groupBy === 'string' ? c.groupBy : undefined, tickStep: Number.isFinite(c.tickStep) ? c.tickStep : undefined, metric: typeof c.metric === 'string' ? c.metric : undefined, hiddenSubjects: Array.isArray(c.hiddenSubjects) ? c.hiddenSubjects.filter((x) => typeof x === 'string') : [], sidebarOpen: c.sidebarOpen === true, counts: readChart(c.counts), rate: readChart(c.rate), }; }