Session metrics over time
X axis:
@@ -148,32 +371,63 @@ export function SubjectAnalysisCharts({ statuses, dailyTemplate }) {
{/* Chart 1 — counts */}
-
Attempts per session
-
-
+ countsS.setExpanded((e) => !e)} />
+ {countsS.expanded && (
+ 0 && (
+
+ Y lines:
+ {countsData.cats.map((cat, i) => {
+ const on = visibleCats === null || visibleCats.has(cat);
+ return (
+
+ );
+ })}
+
+ )
+ }
+ />
+ )}
+
+
-
-
+
+
} />
- {cats.map((cat, i) => (
-
- ))}
-
+ {countsData.cats
+ .filter((cat) => visibleCats === null || visibleCats.has(cat))
+ .map((cat, i) => (
+
+ ))}
+
{/* Chart 2 — success rate */}
-
Success rate (%)
-
-
+ rateS.setExpanded((e) => !e)} />
+ {rateS.expanded && (
+
+ )}
+
+
-
- `${v}%`} />
+
+ `${v}%`} />
} />
-
+
@@ -190,17 +444,20 @@ const METRIC_OPTIONS = [
];
export function ExperimentAnalysisCharts({ animals, experimentStatuses, subjectTemplate, dailyTemplate }) {
- const groupableFields = (subjectTemplate ?? []).filter((f) => f.active);
- const xAxisFields = (dailyTemplate ?? []).filter((f) => f.active);
+ const groupableFields = useMemo(() => (subjectTemplate ?? []).filter((f) => f.active), [subjectTemplate]);
+ const xAxisFields = useMemo(() => (dailyTemplate ?? []).filter((f) => f.active), [dailyTemplate]);
const defaultGroup = (groupableFields.find((f) => /^group$/i.test(f.label)) ?? groupableFields[0])?.fieldId ?? '';
- const [groupBy, setGroupBy] = useState(defaultGroup);
- const [metric, setMetric] = useState('total');
- const [xField, setXField] = useState(() => defaultXField(xAxisFields));
- const [tickStep, setTickStep] = useState(1);
+ const [groupBy, setGroupBy] = useState(defaultGroup);
+ const [metric, setMetric] = useState('total');
+ const [xField, setXField] = useState(() => defaultXField(xAxisFields));
+ const [tickStep, setTickStep] = useState(1);
- const { countData, rateData, lines, xLabel } = useMemo(() => {
- // animalId → group label
+ const countsS = useChartSettings(200);
+ const rateS = useChartSettings(160);
+
+ // Step 1: animalRows — does NOT depend on xField; shared by both charts
+ const { animalRows, lines } = useMemo(() => {
const animalGroup = {};
for (const animal of animals) {
const raw = groupBy ? animal.subject_info?.[groupBy] : null;
@@ -208,13 +465,12 @@ export function ExperimentAnalysisCharts({ animals, experimentStatuses, subjectT
}
const groups = [...new Set(Object.values(animalGroup))].sort();
- // animalId → sorted statuses that have saved metrics (full status rows)
const statusesByAnimal = {};
for (const s of experimentStatuses) {
if (!statusesByAnimal[s.animal_id]) statusesByAnimal[s.animal_id] = [];
statusesByAnimal[s.animal_id].push(s);
}
- const animalRows = {}; // full status rows, sorted by date
+ const animalRows = {};
for (const animal of animals) {
animalRows[animal.id] = (statusesByAnimal[animal.id] ?? [])
.filter((s) => s.analysis_summary?.total != null)
@@ -222,7 +478,7 @@ export function ExperimentAnalysisCharts({ animals, experimentStatuses, subjectT
}
const lines = animals
- .filter((a) => animalRows[a.id].length > 0)
+ .filter((a) => animalRows[a.id]?.length > 0)
.map((a) => ({
id: a.id,
name: a.animal_name,
@@ -230,85 +486,40 @@ export function ExperimentAnalysisCharts({ animals, experimentStatuses, subjectT
color: GROUP_COLORS[groups.indexOf(animalGroup[a.id]) % GROUP_COLORS.length],
}));
- if (lines.length === 0) return { countData: [], rateData: [], lines: [], xLabel: '' };
+ return { animalRows, lines };
+ }, [animals, experimentStatuses, groupBy]);
- const xAxisField = xAxisFields.find((f) => f.fieldId === xField);
- const xLabel = xAxisField ? xAxisField.label : '# Days Reach';
+ // Step 2: count chart data — depends on xField for counts + metric
+ const { data: countData, xLabel: countsXLabel } = useMemo(() => {
+ if (lines.length === 0) return { data: [], xLabel: '' };
+ const ef = countsS.xFieldOverride ?? xField;
+ return computeExpChartData(ef, 'count', metric, animalRows, lines, xAxisFields);
+ }, [animalRows, lines, countsS.xFieldOverride, xField, metric, xAxisFields]);
- function getX(row, dayIndex) {
- if (xField === '__days__') return dayIndex + 1;
- return numericFieldVal(row, xAxisField);
- }
+ // Step 3: rate chart data — depends on xField for rate
+ const { data: rateData, xLabel: rateXLabel } = useMemo(() => {
+ if (lines.length === 0) return { data: [], xLabel: '' };
+ const ef = rateS.xFieldOverride ?? xField;
+ return computeExpChartData(ef, 'rate', null, animalRows, lines, xAxisFields);
+ }, [animalRows, lines, rateS.xFieldOverride, xField, xAxisFields]);
- function getCountVal(summary) {
- return metric === 'total'
- ? summary.total
- : (summary.counts?.[metric] ?? null);
- }
-
- if (xField === '__days__') {
- // Ordinal alignment: position by day index
- const maxDay = Math.max(...lines.map((l) => animalRows[l.id].length));
- const countData = Array.from({ length: maxDay }, (_, i) => ({ x: i + 1 }));
- const rateData = Array.from({ length: maxDay }, (_, i) => ({ x: i + 1 }));
- for (const line of lines) {
- animalRows[line.id].forEach((row, i) => {
- const s = row.analysis_summary;
- countData[i][line.id] = getCountVal(s);
- rateData[i][line.id] = s.success_rate != null ? +(s.success_rate * 100).toFixed(1) : null;
- });
- }
- return { countData, rateData, lines, xLabel };
- }
-
- // Field-based x axis: merge all unique x values across animals
- const allPts = [];
- for (const line of lines) {
- animalRows[line.id].forEach((row, i) => {
- const x = getX(row, i);
- if (x !== null) allPts.push({ x, animalId: line.id, summary: row.analysis_summary });
- });
- }
- const sortedX = [...new Set(allPts.map((p) => p.x))].sort((a, b) => a - b);
- if (sortedX.length === 0) return { countData: [], rateData: [], lines, xLabel };
-
- const countData = sortedX.map((x) => {
- const pt = { x };
- for (const p of allPts.filter((p) => p.x === x)) {
- pt[p.animalId] = getCountVal(p.summary);
- }
- return pt;
- });
- const rateData = sortedX.map((x) => {
- const pt = { x };
- for (const p of allPts.filter((p) => p.x === x)) {
- const s = p.summary;
- pt[p.animalId] = s.success_rate != null ? +(s.success_rate * 100).toFixed(1) : null;
- }
- return pt;
- });
-
- return { countData, rateData, lines, xLabel };
- }, [animals, experimentStatuses, subjectTemplate, dailyTemplate, groupBy, metric, xField]);
+ const countsTicks = useMemo(() => buildTicks(countData, tickStep), [countData, tickStep]);
+ const rateTicks = useMemo(() => buildTicks(rateData, tickStep), [rateData, tickStep]);
const hasData = lines.length > 0;
- const countTicks = useMemo(() => buildTicks(countData, tickStep), [countData, tickStep]);
- const rateTicks = useMemo(() => buildTicks(rateData, tickStep), [rateData, tickStep]);
- function xAxisProps(ticks) {
- return {
- dataKey: 'x', type: 'number', tick: { fontSize: 10 }, allowDecimals: false,
- ticks,
- domain: ticks?.length ? [ticks[0], ticks[ticks.length - 1]] : ['auto', 'auto'],
- label: { value: xLabel, position: 'insideBottomRight', offset: -4, fontSize: 10 },
- };
+ function xAxisProps(data, ticks, s, fallbackLabel) {
+ const domain = s.xDomain ?? (ticks?.length ? [ticks[0], ticks[ticks.length - 1]] : ['auto', 'auto']);
+ const label = (s.xFieldOverride ? xAxisFields.find((f) => f.fieldId === s.xFieldOverride)?.label : null) ?? fallbackLabel;
+ return { dataKey: 'x', type: 'number', tick: { fontSize: 10 }, ticks, domain, allowDecimals: false,
+ label: { value: label, position: 'insideBottomRight', offset: -4, fontSize: 10 } };
}
function ExpTooltip({ active, payload, label, unit = '' }) {
if (!active || !payload?.length) return null;
return (
-
{xLabel}: {label}
+
{label}
{payload.map((p) => {
const line = lines.find((l) => l.id === p.dataKey);
return p.value != null ? (
@@ -323,29 +534,22 @@ export function ExperimentAnalysisCharts({ animals, experimentStatuses, subjectT
return (
+ {/* Global controls */}
Cross-subject metrics
-
- {/* X-axis field selector */}
X axis:
-
- {/* Color-by subject info field */}
{groupableFields.length > 0 && (
Color by:
)}
@@ -353,36 +557,54 @@ export function ExperimentAnalysisCharts({ animals, experimentStatuses, subjectT
{!hasData && (
-
No sessions with saved metrics yet. Upload a CSV on a daily status page and click "Save metrics to daily status".
+
No sessions with saved metrics yet.
)}
{hasData && (
<>
{/* Chart 1 — counts */}
-
-
Count per session — each line is one subject
-
- {METRIC_OPTIONS.map((opt) => (
-
- ))}
+
+
+
Count per session
+ {/* Y-metric selector stays at chart level — primary control */}
+
+ {METRIC_OPTIONS.map((opt) => (
+
+ ))}
+
+
-
+ {countsS.expanded && (
+
+ )}
+
-
-
+
+
} />
@@ -390,18 +612,24 @@ export function ExperimentAnalysisCharts({ animals, experimentStatuses, subjectT
{/* Chart 2 — success rate */}
-
Success rate (%) — each line is one subject
-
+ rateS.setExpanded((e) => !e)} />
+ {rateS.expanded && (
+
+ )}
+
-
- `${v}%`} />
+
+ `${v}%`} />
} />