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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user