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 { format } from 'date-fns';
|
||||||
import {
|
import {
|
||||||
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip,
|
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip,
|
||||||
@@ -35,13 +35,14 @@ function ChartTooltip({ active, payload, label, labelKey = 'dateStr', unit = ''
|
|||||||
// ── Shared helpers ─────────────────────────────────────────────────────────────
|
// ── Shared helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const DAYS_REACH_RE = /#?\s*days?\s*reach/i;
|
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) {
|
function defaultXField(fields) {
|
||||||
return fields.find((f) => DAYS_REACH_RE.test(f.label))?.fieldId ?? '__days__';
|
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) {
|
function numericFieldVal(status, field) {
|
||||||
if (!field) return null;
|
if (!field) return null;
|
||||||
const raw = field.builtin ? status[field.key] : status.custom_fields?.[field.fieldId];
|
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;
|
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) {
|
function buildTicks(data, step) {
|
||||||
const xs = data.map((d) => d.x).filter((x) => x != null && isFinite(x));
|
const xs = data.map((d) => d.x).filter((x) => x != null && isFinite(x));
|
||||||
if (!xs.length) return undefined;
|
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 HEIGHT_OPTIONS = [
|
||||||
const xAxisFields = (dailyTemplate ?? []).filter((f) => f.active);
|
{ label: 'S', value: 140 },
|
||||||
const [xField, setXField] = useState(() => defaultXField(xAxisFields));
|
{ label: 'M', value: 220 },
|
||||||
const [tickStep, setTickStep] = useState(1);
|
{ 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 xAxisField = xAxisFields.find((f) => f.fieldId === xField);
|
||||||
const xLabel = xAxisField ? xAxisField.label : '# Days Reach';
|
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)));
|
.sort((a, b) => String(a.date).localeCompare(String(b.date)));
|
||||||
|
|
||||||
const rows = withMetrics.map((s, i) => {
|
const rows = withMetrics.map((s, i) => {
|
||||||
const xVal = xField === '__days__' || !xAxisField
|
const xVal = xField === '__days__' || !xAxisField ? i + 1 : numericFieldVal(s, xAxisField);
|
||||||
? i + 1
|
|
||||||
: numericFieldVal(s, xAxisField);
|
|
||||||
const dateStr = format(new Date(String(s.date).slice(0, 10) + 'T12:00:00'), 'MMM d');
|
const dateStr = format(new Date(String(s.date).slice(0, 10) + 'T12:00:00'), 'MMM d');
|
||||||
const { counts = {}, total, success_rate } = s.analysis_summary;
|
const { counts = {}, total, success_rate } = s.analysis_summary;
|
||||||
return { _x: xVal, dateStr, total, rate: success_rate != null ? +(success_rate * 100).toFixed(1) : null, ...counts };
|
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 };
|
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 = {
|
if (xField === '__days__') {
|
||||||
dataKey: 'x', type: 'number', tick: { fontSize: 10 },
|
const maxDay = Math.max(0, ...lines.map((l) => animalRows[l.id].length));
|
||||||
ticks,
|
if (maxDay === 0) return { data: [], xLabel };
|
||||||
domain: ticks?.length ? [ticks[0], ticks[ticks.length - 1]] : ['auto', 'auto'],
|
const data = Array.from({ length: maxDay }, (_, i) => ({ x: i + 1 }));
|
||||||
label: { value: xLabel, position: 'insideBottomRight', offset: -4, fontSize: 10 },
|
for (const line of lines) {
|
||||||
allowDecimals: false,
|
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 (
|
return (
|
||||||
<div className="bg-white border border-gray-200 rounded-xl p-5 mb-6 space-y-5">
|
<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">
|
<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>
|
<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">
|
<div className="flex items-center gap-1.5 text-xs">
|
||||||
<span className="text-gray-400">X axis:</span>
|
<span className="text-gray-400">X axis:</span>
|
||||||
<select value={xField} onChange={(e) => setXField(e.target.value)} className={SELECT_CLS}>
|
<select value={xField} onChange={(e) => setXField(e.target.value)} className={SELECT_CLS}>
|
||||||
<option value="__days__"># Days Reach (ordinal)</option>
|
<option value="__days__"># Days Reach (ordinal)</option>
|
||||||
{xAxisFields.map((f) => (
|
{xAxisFields.map((f) => <option key={f.fieldId} value={f.fieldId}>{f.label}</option>)}
|
||||||
<option key={f.fieldId} value={f.fieldId}>{f.label}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<TickStepControl value={tickStep} onChange={setTickStep} />
|
<TickStepControl value={tickStep} onChange={setTickStep} />
|
||||||
@@ -148,32 +371,63 @@ export function SubjectAnalysisCharts({ statuses, dailyTemplate }) {
|
|||||||
|
|
||||||
{/* Chart 1 — counts */}
|
{/* Chart 1 — counts */}
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-gray-500 mb-2">Attempts per session</p>
|
<ChartHeader title="Attempts per session" isDirty={countsS.isDirty} expanded={countsS.expanded} onToggle={() => countsS.setExpanded((e) => !e)} />
|
||||||
<ResponsiveContainer width="100%" height={200}>
|
{countsS.expanded && (
|
||||||
<LineChart data={data} margin={{ top: 4, right: 16, left: 0, bottom: 4 }}>
|
<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" />
|
<CartesianGrid strokeDasharray="3 3" stroke="#f3f4f6" />
|
||||||
<XAxis {...xAxisProps} />
|
<XAxis {...xAxisProps(countsData.data, countsTicks, countsS)} />
|
||||||
<YAxis tick={{ fontSize: 10 }} width={30} allowDecimals={false} />
|
<YAxis tick={{ fontSize: 10 }} width={30} allowDecimals={false} domain={countsS.yDomain} />
|
||||||
<Tooltip content={<ChartTooltip labelKey="dateStr" />} />
|
<Tooltip content={<ChartTooltip labelKey="dateStr" />} />
|
||||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||||
{cats.map((cat, i) => (
|
{countsData.cats
|
||||||
<Line key={cat} type="monotone" dataKey={cat} stroke={catColor(cat, i)} strokeWidth={2} dot={{ r: 3 }} activeDot={{ r: 4 }} connectNulls isAnimationActive={false} />
|
.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>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Chart 2 — success rate */}
|
{/* Chart 2 — success rate */}
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-gray-500 mb-2">Success rate (%)</p>
|
<ChartHeader title="Success rate (%)" isDirty={rateS.isDirty} expanded={rateS.expanded} onToggle={() => rateS.setExpanded((e) => !e)} />
|
||||||
<ResponsiveContainer width="100%" height={160}>
|
{rateS.expanded && (
|
||||||
<LineChart data={data} margin={{ top: 4, right: 16, left: 0, bottom: 4 }}>
|
<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" />
|
<CartesianGrid strokeDasharray="3 3" stroke="#f3f4f6" />
|
||||||
<XAxis {...xAxisProps} />
|
<XAxis {...xAxisProps(rateData.data, rateTicks, rateS)} />
|
||||||
<YAxis tick={{ fontSize: 10 }} width={36} domain={[0, 100]} tickFormatter={(v) => `${v}%`} />
|
<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" />} />
|
<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>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
@@ -190,8 +444,8 @@ const METRIC_OPTIONS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export function ExperimentAnalysisCharts({ animals, experimentStatuses, subjectTemplate, dailyTemplate }) {
|
export function ExperimentAnalysisCharts({ animals, experimentStatuses, subjectTemplate, dailyTemplate }) {
|
||||||
const groupableFields = (subjectTemplate ?? []).filter((f) => f.active);
|
const groupableFields = useMemo(() => (subjectTemplate ?? []).filter((f) => f.active), [subjectTemplate]);
|
||||||
const xAxisFields = (dailyTemplate ?? []).filter((f) => f.active);
|
const xAxisFields = useMemo(() => (dailyTemplate ?? []).filter((f) => f.active), [dailyTemplate]);
|
||||||
|
|
||||||
const defaultGroup = (groupableFields.find((f) => /^group$/i.test(f.label)) ?? groupableFields[0])?.fieldId ?? '';
|
const defaultGroup = (groupableFields.find((f) => /^group$/i.test(f.label)) ?? groupableFields[0])?.fieldId ?? '';
|
||||||
const [groupBy, setGroupBy] = useState(defaultGroup);
|
const [groupBy, setGroupBy] = useState(defaultGroup);
|
||||||
@@ -199,8 +453,11 @@ export function ExperimentAnalysisCharts({ animals, experimentStatuses, subjectT
|
|||||||
const [xField, setXField] = useState(() => defaultXField(xAxisFields));
|
const [xField, setXField] = useState(() => defaultXField(xAxisFields));
|
||||||
const [tickStep, setTickStep] = useState(1);
|
const [tickStep, setTickStep] = useState(1);
|
||||||
|
|
||||||
const { countData, rateData, lines, xLabel } = useMemo(() => {
|
const countsS = useChartSettings(200);
|
||||||
// animalId → group label
|
const rateS = useChartSettings(160);
|
||||||
|
|
||||||
|
// Step 1: animalRows — does NOT depend on xField; shared by both charts
|
||||||
|
const { animalRows, lines } = useMemo(() => {
|
||||||
const animalGroup = {};
|
const animalGroup = {};
|
||||||
for (const animal of animals) {
|
for (const animal of animals) {
|
||||||
const raw = groupBy ? animal.subject_info?.[groupBy] : null;
|
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();
|
const groups = [...new Set(Object.values(animalGroup))].sort();
|
||||||
|
|
||||||
// animalId → sorted statuses that have saved metrics (full status rows)
|
|
||||||
const statusesByAnimal = {};
|
const statusesByAnimal = {};
|
||||||
for (const s of experimentStatuses) {
|
for (const s of experimentStatuses) {
|
||||||
if (!statusesByAnimal[s.animal_id]) statusesByAnimal[s.animal_id] = [];
|
if (!statusesByAnimal[s.animal_id]) statusesByAnimal[s.animal_id] = [];
|
||||||
statusesByAnimal[s.animal_id].push(s);
|
statusesByAnimal[s.animal_id].push(s);
|
||||||
}
|
}
|
||||||
const animalRows = {}; // full status rows, sorted by date
|
const animalRows = {};
|
||||||
for (const animal of animals) {
|
for (const animal of animals) {
|
||||||
animalRows[animal.id] = (statusesByAnimal[animal.id] ?? [])
|
animalRows[animal.id] = (statusesByAnimal[animal.id] ?? [])
|
||||||
.filter((s) => s.analysis_summary?.total != null)
|
.filter((s) => s.analysis_summary?.total != null)
|
||||||
@@ -222,7 +478,7 @@ export function ExperimentAnalysisCharts({ animals, experimentStatuses, subjectT
|
|||||||
}
|
}
|
||||||
|
|
||||||
const lines = animals
|
const lines = animals
|
||||||
.filter((a) => animalRows[a.id].length > 0)
|
.filter((a) => animalRows[a.id]?.length > 0)
|
||||||
.map((a) => ({
|
.map((a) => ({
|
||||||
id: a.id,
|
id: a.id,
|
||||||
name: a.animal_name,
|
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],
|
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);
|
// Step 2: count chart data — depends on xField for counts + metric
|
||||||
const xLabel = xAxisField ? xAxisField.label : '# Days Reach';
|
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) {
|
// Step 3: rate chart data — depends on xField for rate
|
||||||
if (xField === '__days__') return dayIndex + 1;
|
const { data: rateData, xLabel: rateXLabel } = useMemo(() => {
|
||||||
return numericFieldVal(row, xAxisField);
|
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) {
|
const countsTicks = useMemo(() => buildTicks(countData, tickStep), [countData, tickStep]);
|
||||||
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 rateTicks = useMemo(() => buildTicks(rateData, tickStep), [rateData, tickStep]);
|
const rateTicks = useMemo(() => buildTicks(rateData, tickStep), [rateData, tickStep]);
|
||||||
|
|
||||||
function xAxisProps(ticks) {
|
const hasData = lines.length > 0;
|
||||||
return {
|
|
||||||
dataKey: 'x', type: 'number', tick: { fontSize: 10 }, allowDecimals: false,
|
function xAxisProps(data, ticks, s, fallbackLabel) {
|
||||||
ticks,
|
const domain = s.xDomain ?? (ticks?.length ? [ticks[0], ticks[ticks.length - 1]] : ['auto', 'auto']);
|
||||||
domain: ticks?.length ? [ticks[0], ticks[ticks.length - 1]] : ['auto', 'auto'],
|
const label = (s.xFieldOverride ? xAxisFields.find((f) => f.fieldId === s.xFieldOverride)?.label : null) ?? fallbackLabel;
|
||||||
label: { value: xLabel, position: 'insideBottomRight', offset: -4, fontSize: 10 },
|
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 = '' }) {
|
function ExpTooltip({ active, payload, label, unit = '' }) {
|
||||||
if (!active || !payload?.length) return null;
|
if (!active || !payload?.length) return null;
|
||||||
return (
|
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">
|
<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) => {
|
{payload.map((p) => {
|
||||||
const line = lines.find((l) => l.id === p.dataKey);
|
const line = lines.find((l) => l.id === p.dataKey);
|
||||||
return p.value != null ? (
|
return p.value != null ? (
|
||||||
@@ -323,29 +534,22 @@ export function ExperimentAnalysisCharts({ animals, experimentStatuses, subjectT
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white border border-gray-200 rounded-xl p-5 mb-6 space-y-5">
|
<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">
|
<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>
|
<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">
|
<div className="flex items-center gap-1.5 text-xs">
|
||||||
<span className="text-gray-400">X axis:</span>
|
<span className="text-gray-400">X axis:</span>
|
||||||
<select value={xField} onChange={(e) => setXField(e.target.value)} className={SELECT_CLS}>
|
<select value={xField} onChange={(e) => setXField(e.target.value)} className={SELECT_CLS}>
|
||||||
<option value="__days__"># Days Reach (ordinal)</option>
|
<option value="__days__"># Days Reach (ordinal)</option>
|
||||||
{xAxisFields.map((f) => (
|
{xAxisFields.map((f) => <option key={f.fieldId} value={f.fieldId}>{f.label}</option>)}
|
||||||
<option key={f.fieldId} value={f.fieldId}>{f.label}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Color-by subject info field */}
|
|
||||||
{groupableFields.length > 0 && (
|
{groupableFields.length > 0 && (
|
||||||
<div className="flex items-center gap-1.5 text-xs">
|
<div className="flex items-center gap-1.5 text-xs">
|
||||||
<span className="text-gray-400">Color by:</span>
|
<span className="text-gray-400">Color by:</span>
|
||||||
<select value={groupBy} onChange={(e) => setGroupBy(e.target.value)} className={SELECT_CLS}>
|
<select value={groupBy} onChange={(e) => setGroupBy(e.target.value)} className={SELECT_CLS}>
|
||||||
<option value="">— none —</option>
|
<option value="">— none —</option>
|
||||||
{groupableFields.map((f) => (
|
{groupableFields.map((f) => <option key={f.fieldId} value={f.fieldId}>{f.label}</option>)}
|
||||||
<option key={f.fieldId} value={f.fieldId}>{f.label}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -353,15 +557,17 @@ export function ExperimentAnalysisCharts({ animals, experimentStatuses, subjectT
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!hasData && (
|
{!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 && (
|
{hasData && (
|
||||||
<>
|
<>
|
||||||
{/* Chart 1 — counts */}
|
{/* Chart 1 — counts */}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="flex items-center justify-between mb-2 flex-wrap gap-2">
|
||||||
<p className="text-xs text-gray-500">Count per session — each line is one subject</p>
|
<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]">
|
<div className="flex rounded border border-gray-200 overflow-hidden text-[10px]">
|
||||||
{METRIC_OPTIONS.map((opt) => (
|
{METRIC_OPTIONS.map((opt) => (
|
||||||
<button key={opt.value} type="button" onClick={() => setMetric(opt.value)}
|
<button key={opt.value} type="button" onClick={() => setMetric(opt.value)}
|
||||||
@@ -372,17 +578,33 @@ export function ExperimentAnalysisCharts({ animals, experimentStatuses, subjectT
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</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 }}>
|
<LineChart data={countData} margin={{ top: 4, right: 16, left: 0, bottom: 4 }}>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#f3f4f6" />
|
<CartesianGrid strokeDasharray="3 3" stroke="#f3f4f6" />
|
||||||
<XAxis {...xAxisProps(countTicks)} />
|
<XAxis {...xAxisProps(countData, countsTicks, countsS, countsXLabel)} />
|
||||||
<YAxis tick={{ fontSize: 10 }} width={30} allowDecimals={false} />
|
<YAxis tick={{ fontSize: 10 }} width={30} allowDecimals={false} domain={countsS.yDomain} />
|
||||||
<Tooltip content={<ExpTooltip />} />
|
<Tooltip content={<ExpTooltip />} />
|
||||||
<Legend wrapperStyle={{ fontSize: 11 }}
|
<Legend wrapperStyle={{ fontSize: 11 }}
|
||||||
formatter={(value) => lines.find((l) => l.id === value)?.name ?? value} />
|
formatter={(value) => lines.find((l) => l.id === value)?.name ?? value} />
|
||||||
{lines.map((l) => (
|
{lines.map((l) => (
|
||||||
<Line key={l.id} type="monotone" dataKey={l.id} name={l.id}
|
<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>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
@@ -390,18 +612,24 @@ export function ExperimentAnalysisCharts({ animals, experimentStatuses, subjectT
|
|||||||
|
|
||||||
{/* Chart 2 — success rate */}
|
{/* Chart 2 — success rate */}
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-gray-500 mb-2">Success rate (%) — each line is one subject</p>
|
<ChartHeader title="Success rate (%) — each line is one subject" isDirty={rateS.isDirty} expanded={rateS.expanded} onToggle={() => rateS.setExpanded((e) => !e)} />
|
||||||
<ResponsiveContainer width="100%" height={160}>
|
{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 }}>
|
<LineChart data={rateData} margin={{ top: 4, right: 16, left: 0, bottom: 4 }}>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#f3f4f6" />
|
<CartesianGrid strokeDasharray="3 3" stroke="#f3f4f6" />
|
||||||
<XAxis {...xAxisProps(rateTicks)} />
|
<XAxis {...xAxisProps(rateData, rateTicks, rateS, rateXLabel)} />
|
||||||
<YAxis tick={{ fontSize: 10 }} width={36} domain={[0, 100]} tickFormatter={(v) => `${v}%`} />
|
<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="%" />} />
|
<Tooltip content={<ExpTooltip unit="%" />} />
|
||||||
<Legend wrapperStyle={{ fontSize: 11 }}
|
<Legend wrapperStyle={{ fontSize: 11 }}
|
||||||
formatter={(value) => lines.find((l) => l.id === value)?.name ?? value} />
|
formatter={(value) => lines.find((l) => l.id === value)?.name ?? value} />
|
||||||
{lines.map((l) => (
|
{lines.map((l) => (
|
||||||
<Line key={l.id} type="monotone" dataKey={l.id} name={l.id}
|
<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>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
|
|||||||
Reference in New Issue
Block a user