diff --git a/frontend/src/components/AnalysisCharts.jsx b/frontend/src/components/AnalysisCharts.jsx index 7bbc4b6..c1a2738 100644 --- a/frontend/src/components/AnalysisCharts.jsx +++ b/frontend/src/components/AnalysisCharts.jsx @@ -1,4 +1,4 @@ -import React, { useState, useMemo } from 'react'; +import React, { useState, useMemo, useEffect } from 'react'; import { format } from 'date-fns'; import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, @@ -7,8 +7,8 @@ import { // ── Color palettes ───────────────────────────────────────────────────────────── -const CAT_COLORS = { Success: '#22c55e', Failure: '#ef4444', Other: '#9ca3af' }; -const EXTRA_CAT = ['#f59e0b', '#a855f7', '#ec4899', '#14b8a6', '#f97316']; +const CAT_COLORS = { Success: '#22c55e', Failure: '#ef4444', Other: '#9ca3af' }; +const EXTRA_CAT = ['#f59e0b', '#a855f7', '#ec4899', '#14b8a6', '#f97316']; const GROUP_COLORS = ['#6366f1', '#f59e0b', '#22c55e', '#ef4444', '#a855f7', '#14b8a6', '#ec4899', '#f97316', '#84cc16', '#06b6d4']; function catColor(cat, idx = 0) { @@ -35,13 +35,14 @@ function ChartTooltip({ active, payload, label, labelKey = 'dateStr', unit = '' // ── Shared helpers ───────────────────────────────────────────────────────────── const DAYS_REACH_RE = /#?\s*days?\s*reach/i; +const SELECT_CLS = 'border border-gray-200 rounded px-1.5 py-0.5 text-xs bg-white focus:outline-none focus:ring-1 focus:ring-indigo-400'; +const INPUT_CLS = 'w-16 border border-gray-200 rounded px-1.5 py-0.5 text-xs bg-white focus:outline-none focus:ring-1 focus:ring-indigo-400'; +const STEP_OPTIONS = [1, 2, 5, 10]; -/** Pick the default xField from a list of active daily-template fields. */ function defaultXField(fields) { return fields.find((f) => DAYS_REACH_RE.test(f.label))?.fieldId ?? '__days__'; } -/** Read a template field's value from a status row (numeric, or null). */ function numericFieldVal(status, field) { if (!field) return null; const raw = field.builtin ? status[field.key] : status.custom_fields?.[field.fieldId]; @@ -49,11 +50,6 @@ function numericFieldVal(status, field) { return isNaN(n) ? null : n; } -const SELECT_CLS = 'border border-gray-200 rounded px-1.5 py-0.5 text-xs bg-white focus:outline-none focus:ring-1 focus:ring-indigo-400'; - -const STEP_OPTIONS = [1, 2, 5, 10]; - -/** Build explicit integer ticks for a numeric x-axis so every step value is shown. */ function buildTicks(data, step) { const xs = data.map((d) => d.x).filter((x) => x != null && isFinite(x)); if (!xs.length) return undefined; @@ -83,64 +79,291 @@ function TickStepControl({ value, onChange }) { ); } +// ── Per-chart customisation hook & UI ────────────────────────────────────────── + +const HEIGHT_OPTIONS = [ + { label: 'S', value: 140 }, + { label: 'M', value: 220 }, + { label: 'L', value: 340 }, +]; + +function useChartSettings(defaultHeight) { + const [expanded, setExpanded] = useState(false); + const [height, setHeight] = useState(defaultHeight); + const [yMin, setYMin] = useState(''); + const [yMax, setYMax] = useState(''); + const [xZoomMin, setXZoomMin] = useState(''); + const [xZoomMax, setXZoomMax] = useState(''); + const [xFieldOverride, setXFieldOverride] = useState(null); // null → use parent's xField + + const yDomain = useMemo(() => [ + yMin !== '' ? parseFloat(yMin) : 'auto', + yMax !== '' ? parseFloat(yMax) : 'auto', + ], [yMin, yMax]); + + const xDomain = useMemo(() => { + const hasMin = xZoomMin !== ''; + const hasMax = xZoomMax !== ''; + if (!hasMin && !hasMax) return null; + return [hasMin ? parseFloat(xZoomMin) : 'dataMin', hasMax ? parseFloat(xZoomMax) : 'dataMax']; + }, [xZoomMin, xZoomMax]); + + const isDirty = yMin !== '' || yMax !== '' || xZoomMin !== '' || xZoomMax !== '' + || xFieldOverride !== null || height !== defaultHeight; + + function reset() { + setHeight(defaultHeight); + setYMin(''); setYMax(''); + setXZoomMin(''); setXZoomMax(''); + setXFieldOverride(null); + } + + return { + expanded, setExpanded, + height, setHeight, + yMin, setYMin, yMax, setYMax, + xZoomMin, setXZoomMin, xZoomMax, setXZoomMax, + xFieldOverride, setXFieldOverride, + yDomain, xDomain, isDirty, reset, + }; +} + +function ChartHeader({ title, isDirty, expanded, onToggle }) { + return ( +
+

{title}

+ +
+ ); +} + +// Shown only when the chart card is expanded — renders per-chart controls. +function ChartSettingsBar({ s, xAxisFields, globalXField, extra }) { + return ( +
+ + {/* Size */} +
+ Size: +
+ {HEIGHT_OPTIONS.map((opt) => ( + + ))} +
+
+ + {/* Y range */} +
+ Y range: + s.setYMin(e.target.value)} className={INPUT_CLS} /> + + s.setYMax(e.target.value)} className={INPUT_CLS} /> +
+ + {/* X zoom */} +
+ X zoom: + s.setXZoomMin(e.target.value)} className={INPUT_CLS} /> + + s.setXZoomMax(e.target.value)} className={INPUT_CLS} /> +
+ + {/* Per-chart X axis field override */} + {xAxisFields.length > 0 && ( +
+ X data: + +
+ )} + + {/* Slot for chart-specific controls (e.g. Y line visibility) */} + {extra} + + {s.isDirty && ( + + )} +
+ ); +} + +// ── Pure data-computation helpers ────────────────────────────────────────────── + +function computeSubjectChartData(xField, statuses, xAxisFields) { + const xAxisField = xAxisFields.find((f) => f.fieldId === xField); + const xLabel = xAxisField ? xAxisField.label : '# Days Reach'; + + const withMetrics = statuses + .filter((s) => s.analysis_summary?.total != null) + .sort((a, b) => String(a.date).localeCompare(String(b.date))); + + const rows = withMetrics.map((s, i) => { + const xVal = xField === '__days__' || !xAxisField ? i + 1 : numericFieldVal(s, xAxisField); + const dateStr = format(new Date(String(s.date).slice(0, 10) + 'T12:00:00'), 'MMM d'); + const { counts = {}, total, success_rate } = s.analysis_summary; + return { _x: xVal, dateStr, total, rate: success_rate != null ? +(success_rate * 100).toFixed(1) : null, ...counts }; + }).filter((r) => r._x != null).sort((a, b) => a._x - b._x); + + const data = rows.map((r) => ({ ...r, x: r._x })); + + const catSet = new Set(); + for (const d of data) { + for (const k of Object.keys(d)) { + if (!['x', '_x', 'dateStr', 'total', 'rate'].includes(k)) catSet.add(k); + } + } + return { data, cats: [...catSet], xLabel }; +} + +function computeExpChartData(xField, chartType, metric, animalRows, lines, xAxisFields) { + // chartType: 'count' | 'rate' + const xAxisField = xAxisFields.find((f) => f.fieldId === xField); + const xLabel = xAxisField ? xAxisField.label : '# Days Reach'; + + function getX(row, dayIndex) { + if (xField === '__days__') return dayIndex + 1; + return numericFieldVal(row, xAxisField); + } + + if (xField === '__days__') { + const maxDay = Math.max(0, ...lines.map((l) => animalRows[l.id].length)); + if (maxDay === 0) return { data: [], xLabel }; + const data = Array.from({ length: maxDay }, (_, i) => ({ x: i + 1 })); + for (const line of lines) { + animalRows[line.id].forEach((row, i) => { + const s = row.analysis_summary; + if (chartType === 'count') { + data[i][line.id] = metric === 'total' ? s.total : (s.counts?.[metric] ?? null); + } else { + data[i][line.id] = s.success_rate != null ? +(s.success_rate * 100).toFixed(1) : null; + } + }); + } + return { data, xLabel }; + } + + // Field-based x axis + 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 { data: [], xLabel }; + + const data = sortedX.map((x) => { + const pt = { x }; + for (const p of allPts.filter((pp) => pp.x === x)) { + const s = p.summary; + if (chartType === 'count') { + pt[p.animalId] = metric === 'total' ? s.total : (s.counts?.[metric] ?? null); + } else { + pt[p.animalId] = s.success_rate != null ? +(s.success_rate * 100).toFixed(1) : null; + } + } + return pt; + }); + return { data, xLabel }; +} + // ── Subject charts ───────────────────────────────────────────────────────────── export function SubjectAnalysisCharts({ statuses, dailyTemplate }) { - const xAxisFields = (dailyTemplate ?? []).filter((f) => f.active); - const [xField, setXField] = useState(() => defaultXField(xAxisFields)); - const [tickStep, setTickStep] = useState(1); + const xAxisFields = useMemo( + () => (dailyTemplate ?? []).filter((f) => f.active), + [dailyTemplate], + ); + const [xField, setXField] = useState(() => defaultXField(xAxisFields)); + const [tickStep, setTickStep] = useState(1); - const { data, cats, xLabel } = useMemo(() => { - const xAxisField = xAxisFields.find((f) => f.fieldId === xField); - const xLabel = xAxisField ? xAxisField.label : '# Days Reach'; + const countsS = useChartSettings(200); + const rateS = useChartSettings(160); - const withMetrics = statuses - .filter((s) => s.analysis_summary?.total != null) - .sort((a, b) => String(a.date).localeCompare(String(b.date))); + // Shared base computation — used by both charts when no override is set + const sharedData = useMemo( + () => computeSubjectChartData(xField, statuses, xAxisFields), + [xField, statuses, xAxisFields], + ); - const rows = withMetrics.map((s, i) => { - const xVal = xField === '__days__' || !xAxisField - ? i + 1 - : numericFieldVal(s, xAxisField); - const dateStr = format(new Date(String(s.date).slice(0, 10) + 'T12:00:00'), 'MMM d'); - const { counts = {}, total, success_rate } = s.analysis_summary; - return { _x: xVal, dateStr, total, rate: success_rate != null ? +(success_rate * 100).toFixed(1) : null, ...counts }; - }).filter((r) => r._x != null).sort((a, b) => a._x - b._x); + // Per-chart overrides: only recompute when the override differs from the shared field + const countsData = useMemo(() => { + const ef = countsS.xFieldOverride; + if (!ef || ef === xField) return sharedData; + return computeSubjectChartData(ef, statuses, xAxisFields); + }, [countsS.xFieldOverride, xField, sharedData, statuses, xAxisFields]); - const data = rows.map((r) => ({ ...r, x: r._x })); + const rateData = useMemo(() => { + const ef = rateS.xFieldOverride; + if (!ef || ef === xField) return sharedData; + return computeSubjectChartData(ef, statuses, xAxisFields); + }, [rateS.xFieldOverride, xField, sharedData, statuses, xAxisFields]); - const catSet = new Set(); - for (const d of data) { - for (const k of Object.keys(d)) { - if (!['x', '_x', 'dateStr', 'total', 'rate'].includes(k)) catSet.add(k); - } - } - return { data, cats: [...catSet], xLabel }; - }, [statuses, xField, dailyTemplate]); + const countsTicks = useMemo(() => buildTicks(countsData.data, tickStep), [countsData.data, tickStep]); + const rateTicks = useMemo(() => buildTicks(rateData.data, tickStep), [rateData.data, tickStep]); - const ticks = useMemo(() => buildTicks(data, tickStep), [data, tickStep]); + // Y-line visibility for the counts chart (null = show all) + const [visibleCats, setVisibleCats] = useState(null); + useEffect(() => { setVisibleCats(null); }, [countsData.cats.join(',')]); // reset when cats change - if (data.length === 0) return null; + if (sharedData.data.length === 0) return null; - const xAxisProps = { - dataKey: 'x', type: 'number', tick: { fontSize: 10 }, - ticks, - domain: ticks?.length ? [ticks[0], ticks[ticks.length - 1]] : ['auto', 'auto'], - label: { value: xLabel, position: 'insideBottomRight', offset: -4, fontSize: 10 }, - allowDecimals: false, - }; + function toggleCat(cat) { + setVisibleCats((prev) => { + if (prev === null) return new Set([cat]); + const next = new Set(prev); + next.has(cat) ? next.delete(cat) : next.add(cat); + return next.size === 0 ? null : next; + }); + } + + function xAxisProps(data, ticks, s) { + const ef = s.xFieldOverride ?? xField; + const xAxisField = xAxisFields.find((f) => f.fieldId === ef); + const xLabel = xAxisField ? xAxisField.label : '# Days Reach'; + const domain = s.xDomain ?? (ticks?.length ? [ticks[0], ticks[ticks.length - 1]] : ['auto', 'auto']); + return { dataKey: 'x', type: 'number', tick: { fontSize: 10 }, ticks, domain, allowDecimals: false, + label: { value: xLabel, position: 'insideBottomRight', offset: -4, fontSize: 10 } }; + } return (
+ {/* Global controls */}

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 && ( + + )} + - - + + } /> lines.find((l) => l.id === value)?.name ?? value} /> {lines.map((l) => ( + stroke={l.color} strokeWidth={2} dot={{ r: 3 }} activeDot={{ r: 5 }} + connectNulls isAnimationActive={false} /> ))} @@ -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}%`} /> } /> lines.find((l) => l.id === value)?.name ?? value} /> {lines.map((l) => ( + stroke={l.color} strokeWidth={2} dot={{ r: 3 }} activeDot={{ r: 5 }} + connectNulls isAnimationActive={false} /> ))}