feat: per-chart customization — size, Y range, X zoom, independent X/Y data

Each chart card now has a "▾ customize" button. Clicking it reveals a
settings bar (only rendered when active — zero cost when collapsed):

  Size      — S / M / L height selector
  Y range   — min / max inputs to set the YAxis domain
  X zoom    — min / max inputs to clip the displayed X domain
  X data    — per-chart X field override, independent from the shared
              global X selector (each chart can use a different field)
  Y lines   — (counts chart only) toggle individual category lines on/off
  ↺ Reset   — shown only when any setting has been changed from default

The global X-axis selector and tick-step control remain at the component
level as the persistent defaults. Per-chart overrides are session-only
(non-persistent), resetting on page load.

Performance: animalRows computation in ExperimentAnalysisCharts is now
split from the per-chart data step, so a metric/X change only recomputes
the affected chart. Per-chart data only recomputes when that chart's
xFieldOverride changes; shared data is reused otherwise.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Experiments DB Dev
2026-04-23 14:36:19 -04:00
parent d2c8d69718
commit 5c0703c271
+369 -141
View File
@@ -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,
@@ -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,14 +79,144 @@ function TickStepControl({ value, onChange }) {
);
}
// ── Subject charts ─────────────────────────────────────────────────────────────
// ── Per-chart customisation hook & UI ──────────────────────────────────────────
export function SubjectAnalysisCharts({ statuses, dailyTemplate }) {
const xAxisFields = (dailyTemplate ?? []).filter((f) => f.active);
const [xField, setXField] = useState(() => defaultXField(xAxisFields));
const [tickStep, setTickStep] = useState(1);
const HEIGHT_OPTIONS = [
{ label: 'S', value: 140 },
{ label: 'M', value: 220 },
{ label: 'L', value: 340 },
];
const { data, cats, xLabel } = useMemo(() => {
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 (
<div className="flex items-center justify-between mb-2">
<p className="text-xs text-gray-500 font-medium">{title}</p>
<button type="button" onClick={onToggle}
className={[
'text-xs px-2 py-0.5 rounded border transition-colors',
expanded
? 'bg-gray-100 border-gray-300 text-gray-700'
: isDirty
? 'bg-indigo-50 border-indigo-200 text-indigo-600'
: 'bg-white border-gray-200 text-gray-400 hover:text-gray-700 hover:border-gray-300',
].join(' ')}
>
{expanded ? '▲ collapse' : '▾ customize'}
</button>
</div>
);
}
// Shown only when the chart card is expanded — renders per-chart controls.
function ChartSettingsBar({ s, xAxisFields, globalXField, extra }) {
return (
<div className="mb-3 p-3 bg-gray-50 border border-gray-100 rounded-lg flex flex-wrap gap-x-5 gap-y-2 items-center text-xs">
{/* Size */}
<div className="flex items-center gap-1.5">
<span className="text-gray-400 shrink-0">Size:</span>
<div className="flex rounded border border-gray-200 overflow-hidden">
{HEIGHT_OPTIONS.map((opt) => (
<button key={opt.value} type="button" onClick={() => s.setHeight(opt.value)}
className={`px-2 py-0.5 transition-colors border-l border-gray-200 first:border-l-0
${s.height === opt.value ? 'bg-gray-700 text-white' : 'bg-white text-gray-500 hover:bg-gray-50'}`}>
{opt.label}
</button>
))}
</div>
</div>
{/* Y range */}
<div className="flex items-center gap-1.5">
<span className="text-gray-400 shrink-0">Y range:</span>
<input type="number" placeholder="min" value={s.yMin} onChange={(e) => s.setYMin(e.target.value)} className={INPUT_CLS} />
<span className="text-gray-300"></span>
<input type="number" placeholder="max" value={s.yMax} onChange={(e) => s.setYMax(e.target.value)} className={INPUT_CLS} />
</div>
{/* X zoom */}
<div className="flex items-center gap-1.5">
<span className="text-gray-400 shrink-0">X zoom:</span>
<input type="number" placeholder="min" value={s.xZoomMin} onChange={(e) => s.setXZoomMin(e.target.value)} className={INPUT_CLS} />
<span className="text-gray-300"></span>
<input type="number" placeholder="max" value={s.xZoomMax} onChange={(e) => s.setXZoomMax(e.target.value)} className={INPUT_CLS} />
</div>
{/* Per-chart X axis field override */}
{xAxisFields.length > 0 && (
<div className="flex items-center gap-1.5">
<span className="text-gray-400 shrink-0">X data:</span>
<select
value={s.xFieldOverride ?? globalXField}
onChange={(e) => {
const v = e.target.value;
s.setXFieldOverride(v === globalXField ? null : v);
}}
className={SELECT_CLS}
>
<option value="__days__"># Days Reach (ordinal)</option>
{xAxisFields.map((f) => <option key={f.fieldId} value={f.fieldId}>{f.label}</option>)}
</select>
</div>
)}
{/* Slot for chart-specific controls (e.g. Y line visibility) */}
{extra}
{s.isDirty && (
<button type="button" onClick={s.reset}
className="text-xs text-red-400 hover:text-red-600 transition-colors ml-auto">
Reset
</button>
)}
</div>
);
}
// ── Pure data-computation helpers ──────────────────────────────────────────────
function computeSubjectChartData(xField, statuses, xAxisFields) {
const xAxisField = xAxisFields.find((f) => f.fieldId === xField);
const xLabel = xAxisField ? xAxisField.label : '# Days Reach';
@@ -99,9 +225,7 @@ export function SubjectAnalysisCharts({ statuses, dailyTemplate }) {
.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 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 };
@@ -116,31 +240,130 @@ export function SubjectAnalysisCharts({ statuses, dailyTemplate }) {
}
}
return { data, cats: [...catSet], xLabel };
}, [statuses, xField, dailyTemplate]);
}
const ticks = useMemo(() => buildTicks(data, tickStep), [data, tickStep]);
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';
if (data.length === 0) return null;
function getX(row, dayIndex) {
if (xField === '__days__') return dayIndex + 1;
return numericFieldVal(row, xAxisField);
}
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,
};
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 = useMemo(
() => (dailyTemplate ?? []).filter((f) => f.active),
[dailyTemplate],
);
const [xField, setXField] = useState(() => defaultXField(xAxisFields));
const [tickStep, setTickStep] = useState(1);
const countsS = useChartSettings(200);
const rateS = useChartSettings(160);
// Shared base computation — used by both charts when no override is set
const sharedData = useMemo(
() => computeSubjectChartData(xField, statuses, xAxisFields),
[xField, statuses, xAxisFields],
);
// 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 rateData = useMemo(() => {
const ef = rateS.xFieldOverride;
if (!ef || ef === xField) return sharedData;
return computeSubjectChartData(ef, statuses, xAxisFields);
}, [rateS.xFieldOverride, xField, sharedData, statuses, xAxisFields]);
const countsTicks = useMemo(() => buildTicks(countsData.data, tickStep), [countsData.data, tickStep]);
const rateTicks = useMemo(() => buildTicks(rateData.data, tickStep), [rateData.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 (sharedData.data.length === 0) return null;
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 (
<div className="bg-white border border-gray-200 rounded-xl p-5 mb-6 space-y-5">
{/* Global controls */}
<div className="flex items-center flex-wrap gap-3">
<h2 className="text-sm font-semibold text-gray-700 uppercase tracking-wide">Session metrics over time</h2>
<div className="flex items-center gap-1.5 text-xs">
<span className="text-gray-400">X axis:</span>
<select value={xField} onChange={(e) => setXField(e.target.value)} className={SELECT_CLS}>
<option value="__days__"># Days Reach (ordinal)</option>
{xAxisFields.map((f) => (
<option key={f.fieldId} value={f.fieldId}>{f.label}</option>
))}
{xAxisFields.map((f) => <option key={f.fieldId} value={f.fieldId}>{f.label}</option>)}
</select>
</div>
<TickStepControl value={tickStep} onChange={setTickStep} />
@@ -148,32 +371,63 @@ export function SubjectAnalysisCharts({ statuses, dailyTemplate }) {
{/* Chart 1 — counts */}
<div>
<p className="text-xs text-gray-500 mb-2">Attempts per session</p>
<ResponsiveContainer width="100%" height={200}>
<LineChart data={data} margin={{ top: 4, right: 16, left: 0, bottom: 4 }}>
<ChartHeader title="Attempts per session" isDirty={countsS.isDirty} expanded={countsS.expanded} onToggle={() => countsS.setExpanded((e) => !e)} />
{countsS.expanded && (
<ChartSettingsBar s={countsS} xAxisFields={xAxisFields} globalXField={xField}
extra={
countsData.cats.length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-gray-400 shrink-0">Y lines:</span>
{countsData.cats.map((cat, i) => {
const on = visibleCats === null || visibleCats.has(cat);
return (
<button key={cat} type="button" onClick={() => toggleCat(cat)}
style={{ color: catColor(cat, i), borderColor: on ? catColor(cat, i) : '#e5e7eb' }}
className={`px-2 py-0.5 rounded border text-xs transition-opacity ${on ? 'opacity-100' : 'opacity-35'}`}>
{cat}
</button>
);
})}
</div>
)
}
/>
)}
<ResponsiveContainer width="100%" height={countsS.height}>
<LineChart data={countsData.data} margin={{ top: 4, right: 16, left: 0, bottom: 4 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#f3f4f6" />
<XAxis {...xAxisProps} />
<YAxis tick={{ fontSize: 10 }} width={30} allowDecimals={false} />
<XAxis {...xAxisProps(countsData.data, countsTicks, countsS)} />
<YAxis tick={{ fontSize: 10 }} width={30} allowDecimals={false} domain={countsS.yDomain} />
<Tooltip content={<ChartTooltip labelKey="dateStr" />} />
<Legend wrapperStyle={{ fontSize: 11 }} />
{cats.map((cat, i) => (
<Line key={cat} type="monotone" dataKey={cat} stroke={catColor(cat, i)} strokeWidth={2} dot={{ r: 3 }} activeDot={{ r: 4 }} connectNulls isAnimationActive={false} />
{countsData.cats
.filter((cat) => visibleCats === null || visibleCats.has(cat))
.map((cat, i) => (
<Line key={cat} type="monotone" dataKey={cat} stroke={catColor(cat, i)} strokeWidth={2}
dot={{ r: 3 }} activeDot={{ r: 4 }} connectNulls isAnimationActive={false} />
))}
<Line type="monotone" dataKey="total" name="Total" stroke="#3b82f6" strokeWidth={1.5} strokeDasharray="5 3" dot={{ r: 3 }} connectNulls isAnimationActive={false} />
<Line type="monotone" dataKey="total" name="Total" stroke="#3b82f6" strokeWidth={1.5}
strokeDasharray="5 3" dot={{ r: 3 }} connectNulls isAnimationActive={false} />
</LineChart>
</ResponsiveContainer>
</div>
{/* Chart 2 — success rate */}
<div>
<p className="text-xs text-gray-500 mb-2">Success rate (%)</p>
<ResponsiveContainer width="100%" height={160}>
<LineChart data={data} margin={{ top: 4, right: 16, left: 0, bottom: 4 }}>
<ChartHeader title="Success rate (%)" isDirty={rateS.isDirty} expanded={rateS.expanded} onToggle={() => rateS.setExpanded((e) => !e)} />
{rateS.expanded && (
<ChartSettingsBar s={rateS} xAxisFields={xAxisFields} globalXField={xField} />
)}
<ResponsiveContainer width="100%" height={rateS.height}>
<LineChart data={rateData.data} margin={{ top: 4, right: 16, left: 0, bottom: 4 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#f3f4f6" />
<XAxis {...xAxisProps} />
<YAxis tick={{ fontSize: 10 }} width={36} domain={[0, 100]} tickFormatter={(v) => `${v}%`} />
<XAxis {...xAxisProps(rateData.data, rateTicks, rateS)} />
<YAxis tick={{ fontSize: 10 }} width={36}
domain={rateS.yDomain[0] !== 'auto' || rateS.yDomain[1] !== 'auto' ? rateS.yDomain : [0, 100]}
tickFormatter={(v) => `${v}%`} />
<Tooltip content={<ChartTooltip unit="%" labelKey="dateStr" />} />
<Line type="monotone" dataKey="rate" name="Success rate" stroke="#6366f1" strokeWidth={2} dot={{ r: 3 }} connectNulls isAnimationActive={false} />
<Line type="monotone" dataKey="rate" name="Success rate" stroke="#6366f1" strokeWidth={2}
dot={{ r: 3 }} connectNulls isAnimationActive={false} />
</LineChart>
</ResponsiveContainer>
</div>
@@ -190,8 +444,8 @@ 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);
@@ -199,8 +453,11 @@ export function ExperimentAnalysisCharts({ animals, experimentStatuses, subjectT
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 hasData = lines.length > 0;
const countTicks = useMemo(() => buildTicks(countData, tickStep), [countData, tickStep]);
const countsTicks = 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 },
};
const hasData = lines.length > 0;
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 (
<div className="bg-white border border-gray-200 rounded shadow-sm px-3 py-2 text-xs space-y-0.5 max-h-48 overflow-y-auto">
<p className="font-semibold text-gray-700 mb-1">{xLabel}: {label}</p>
<p className="font-semibold text-gray-700 mb-1">{label}</p>
{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 (
<div className="bg-white border border-gray-200 rounded-xl p-5 mb-6 space-y-5">
{/* Global controls */}
<div className="flex items-center flex-wrap gap-3">
<h2 className="text-sm font-semibold text-gray-700 uppercase tracking-wide">Cross-subject metrics</h2>
{/* X-axis field selector */}
<div className="flex items-center gap-1.5 text-xs">
<span className="text-gray-400">X axis:</span>
<select value={xField} onChange={(e) => setXField(e.target.value)} className={SELECT_CLS}>
<option value="__days__"># Days Reach (ordinal)</option>
{xAxisFields.map((f) => (
<option key={f.fieldId} value={f.fieldId}>{f.label}</option>
))}
{xAxisFields.map((f) => <option key={f.fieldId} value={f.fieldId}>{f.label}</option>)}
</select>
</div>
{/* Color-by subject info field */}
{groupableFields.length > 0 && (
<div className="flex items-center gap-1.5 text-xs">
<span className="text-gray-400">Color by:</span>
<select value={groupBy} onChange={(e) => setGroupBy(e.target.value)} className={SELECT_CLS}>
<option value=""> none </option>
{groupableFields.map((f) => (
<option key={f.fieldId} value={f.fieldId}>{f.label}</option>
))}
{groupableFields.map((f) => <option key={f.fieldId} value={f.fieldId}>{f.label}</option>)}
</select>
</div>
)}
@@ -353,15 +557,17 @@ export function ExperimentAnalysisCharts({ animals, experimentStatuses, subjectT
</div>
{!hasData && (
<p className="text-sm text-gray-400 italic">No sessions with saved metrics yet. Upload a CSV on a daily status page and click "Save metrics to daily status".</p>
<p className="text-sm text-gray-400 italic">No sessions with saved metrics yet.</p>
)}
{hasData && (
<>
{/* Chart 1 — counts */}
<div>
<div className="flex items-center gap-3 mb-2">
<p className="text-xs text-gray-500">Count per session each line is one subject</p>
<div className="flex items-center justify-between mb-2 flex-wrap gap-2">
<div className="flex items-center gap-3">
<p className="text-xs text-gray-500 font-medium">Count per session</p>
{/* Y-metric selector stays at chart level — primary control */}
<div className="flex rounded border border-gray-200 overflow-hidden text-[10px]">
{METRIC_OPTIONS.map((opt) => (
<button key={opt.value} type="button" onClick={() => setMetric(opt.value)}
@@ -372,17 +578,33 @@ export function ExperimentAnalysisCharts({ animals, experimentStatuses, subjectT
))}
</div>
</div>
<ResponsiveContainer width="100%" height={200}>
<button type="button" onClick={() => countsS.setExpanded((e) => !e)}
className={[
'text-xs px-2 py-0.5 rounded border transition-colors',
countsS.expanded
? 'bg-gray-100 border-gray-300 text-gray-700'
: countsS.isDirty
? 'bg-indigo-50 border-indigo-200 text-indigo-600'
: 'bg-white border-gray-200 text-gray-400 hover:text-gray-700 hover:border-gray-300',
].join(' ')}>
{countsS.expanded ? '▲ collapse' : '▾ customize'}
</button>
</div>
{countsS.expanded && (
<ChartSettingsBar s={countsS} xAxisFields={xAxisFields} globalXField={xField} />
)}
<ResponsiveContainer width="100%" height={countsS.height}>
<LineChart data={countData} margin={{ top: 4, right: 16, left: 0, bottom: 4 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#f3f4f6" />
<XAxis {...xAxisProps(countTicks)} />
<YAxis tick={{ fontSize: 10 }} width={30} allowDecimals={false} />
<XAxis {...xAxisProps(countData, countsTicks, countsS, countsXLabel)} />
<YAxis tick={{ fontSize: 10 }} width={30} allowDecimals={false} domain={countsS.yDomain} />
<Tooltip content={<ExpTooltip />} />
<Legend wrapperStyle={{ fontSize: 11 }}
formatter={(value) => lines.find((l) => l.id === value)?.name ?? value} />
{lines.map((l) => (
<Line key={l.id} type="monotone" dataKey={l.id} name={l.id}
stroke={l.color} strokeWidth={2} dot={{ r: 3 }} activeDot={{ r: 5 }} connectNulls isAnimationActive={false} />
stroke={l.color} strokeWidth={2} dot={{ r: 3 }} activeDot={{ r: 5 }}
connectNulls isAnimationActive={false} />
))}
</LineChart>
</ResponsiveContainer>
@@ -390,18 +612,24 @@ export function ExperimentAnalysisCharts({ animals, experimentStatuses, subjectT
{/* Chart 2 — success rate */}
<div>
<p className="text-xs text-gray-500 mb-2">Success rate (%) each line is one subject</p>
<ResponsiveContainer width="100%" height={160}>
<ChartHeader title="Success rate (%) — each line is one subject" isDirty={rateS.isDirty} expanded={rateS.expanded} onToggle={() => rateS.setExpanded((e) => !e)} />
{rateS.expanded && (
<ChartSettingsBar s={rateS} xAxisFields={xAxisFields} globalXField={xField} />
)}
<ResponsiveContainer width="100%" height={rateS.height}>
<LineChart data={rateData} margin={{ top: 4, right: 16, left: 0, bottom: 4 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#f3f4f6" />
<XAxis {...xAxisProps(rateTicks)} />
<YAxis tick={{ fontSize: 10 }} width={36} domain={[0, 100]} tickFormatter={(v) => `${v}%`} />
<XAxis {...xAxisProps(rateData, rateTicks, rateS, rateXLabel)} />
<YAxis tick={{ fontSize: 10 }} width={36}
domain={rateS.yDomain[0] !== 'auto' || rateS.yDomain[1] !== 'auto' ? rateS.yDomain : [0, 100]}
tickFormatter={(v) => `${v}%`} />
<Tooltip content={<ExpTooltip unit="%" />} />
<Legend wrapperStyle={{ fontSize: 11 }}
formatter={(value) => lines.find((l) => l.id === value)?.name ?? value} />
{lines.map((l) => (
<Line key={l.id} type="monotone" dataKey={l.id} name={l.id}
stroke={l.color} strokeWidth={2} dot={{ r: 3 }} activeDot={{ r: 5 }} connectNulls isAnimationActive={false} />
stroke={l.color} strokeWidth={2} dot={{ r: 3 }} activeDot={{ r: 5 }}
connectNulls isAnimationActive={false} />
))}
</LineChart>
</ResponsiveContainer>