diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js
index b4b7c33..6679ed2 100644
--- a/frontend/src/api/client.js
+++ b/frontend/src/api/client.js
@@ -33,6 +33,7 @@ export const experimentsApi = {
updateTemplate: (id, template) => api.put(`/experiments/${id}/template`, template).then((r) => r.data),
getSubjectTemplate: (id) => api.get(`/experiments/${id}/subject-template`).then((r) => r.data),
updateSubjectTemplate: (id, template) => api.put(`/experiments/${id}/subject-template`, template).then((r) => r.data),
+ savePlotConfig: (id, config) => api.put(`/experiments/${id}/plot-config`, config).then((r) => r.data),
// opts: { date?: string, analysisOnly?: boolean }
getDailyStatuses: (id, opts) => {
const params = typeof opts === 'string' ? { date: opts } : (opts ?? {});
diff --git a/frontend/src/components/AnalysisCharts.jsx b/frontend/src/components/AnalysisCharts.jsx
index 536da13..58ed95c 100644
--- a/frontend/src/components/AnalysisCharts.jsx
+++ b/frontend/src/components/AnalysisCharts.jsx
@@ -1,11 +1,11 @@
-import React, { useState, useMemo, useEffect } from 'react';
+import React, { useState, useMemo, useEffect, useRef } from 'react';
import { format } from 'date-fns';
import {
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip,
Legend, ResponsiveContainer,
} from 'recharts';
import { SubjectSidebar } from './SubjectSidebar';
-import { sortPayloadByValueDesc, toggleInSet, setIdsHidden } from '../lib/crossSubjectChart';
+import { sortPayloadByValueDesc, toggleInSet, setIdsHidden, serializePlotConfig, readPlotConfig } from '../lib/crossSubjectChart';
// ── Color palettes ─────────────────────────────────────────────────────────────
@@ -89,14 +89,14 @@ const HEIGHT_OPTIONS = [
{ label: 'L', value: 340 },
];
-function useChartSettings(defaultHeight) {
+function useChartSettings(defaultHeight, initial = {}) {
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 [height, setHeight] = useState(initial.height ?? defaultHeight);
+ const [yMin, setYMin] = useState(initial.yMin ?? '');
+ const [yMax, setYMax] = useState(initial.yMax ?? '');
+ const [xZoomMin, setXZoomMin] = useState(initial.xZoomMin ?? '');
+ const [xZoomMax, setXZoomMax] = useState(initial.xZoomMax ?? '');
+ const [xFieldOverride, setXFieldOverride] = useState(initial.xFieldOverride ?? null); // null → use parent's xField
const yDomain = useMemo(() => [
yMin !== '' ? parseFloat(yMin) : 'auto',
@@ -113,6 +113,11 @@ function useChartSettings(defaultHeight) {
const isDirty = yMin !== '' || yMax !== '' || xZoomMin !== '' || xZoomMax !== ''
|| xFieldOverride !== null || height !== defaultHeight;
+ const snapshot = useMemo(
+ () => ({ height, yMin, yMax, xZoomMin, xZoomMax, xFieldOverride }),
+ [height, yMin, yMax, xZoomMin, xZoomMax, xFieldOverride],
+ );
+
function reset() {
setHeight(defaultHeight);
setYMin(''); setYMax('');
@@ -126,7 +131,7 @@ function useChartSettings(defaultHeight) {
yMin, setYMin, yMax, setYMax,
xZoomMin, setXZoomMin, xZoomMax, setXZoomMax,
xFieldOverride, setXFieldOverride,
- yDomain, xDomain, isDirty, reset,
+ yDomain, xDomain, isDirty, reset, snapshot,
};
}
@@ -445,27 +450,63 @@ const METRIC_OPTIONS = [
{ value: 'Failure', label: 'Failure count' },
];
-export function ExperimentAnalysisCharts({ animals, experimentStatuses, subjectTemplate, dailyTemplate }) {
+export function ExperimentAnalysisCharts({ animals, experimentStatuses, subjectTemplate, dailyTemplate, plotConfig, onPersistConfig }) {
const groupableFields = useMemo(() => (subjectTemplate ?? []).filter((f) => f.active), [subjectTemplate]);
const xAxisFields = useMemo(() => (dailyTemplate ?? []).filter((f) => f.active), [dailyTemplate]);
const defaultGroup = (groupableFields.find((f) => /^group$/i.test(f.label)) ?? groupableFields[0])?.fieldId ?? '';
- const [groupBy, setGroupBy] = useState(defaultGroup);
- const [metric, setMetric] = useState('total');
- const [xField, setXField] = useState(() => defaultXField(xAxisFields));
- const [tickStep, setTickStep] = useState(1);
+ // Read the saved config exactly once (component only mounts after the experiment loads).
+ const initialConfigRef = useRef(null);
+ if (initialConfigRef.current === null) initialConfigRef.current = readPlotConfig(plotConfig);
+ const initialConfig = initialConfigRef.current;
+ const [groupBy, setGroupBy] = useState(initialConfig.groupBy ?? defaultGroup);
+ const [metric, setMetric] = useState(initialConfig.metric ?? 'total');
+ const [xField, setXField] = useState(() => initialConfig.xField ?? defaultXField(xAxisFields));
+ const [tickStep, setTickStep] = useState(initialConfig.tickStep ?? 1);
// Cross-subject interaction state (client-only; global across both charts)
- const [sidebarOpen, setSidebarOpen] = useState(false);
- const [hiddenSubjects, setHiddenSubjects] = useState(() => new Set());
+ const [sidebarOpen, setSidebarOpen] = useState(initialConfig.sidebarOpen);
+ const [hiddenSubjects, setHiddenSubjects] = useState(() => new Set(initialConfig.hiddenSubjects));
const [highlightedSubject, setHighlightedSubject] = useState(null);
const toggleHidden = (id) => setHiddenSubjects((prev) => toggleInSet(prev, id));
const toggleGroup = (ids, hidden) => setHiddenSubjects((prev) => setIdsHidden(prev, ids, hidden));
const showAll = () => setHiddenSubjects(new Set());
- const countsS = useChartSettings(200);
- const rateS = useChartSettings(160);
+ const countsS = useChartSettings(200, initialConfig.counts);
+ const rateS = useChartSettings(160, initialConfig.rate);
+
+ // ── Persist display settings (debounced auto-save) ────────────────────────────
+ const [saveState, setSaveState] = useState('idle'); // 'idle' | 'saving' | 'saved' | 'error'
+
+ const currentConfig = useMemo(
+ () => serializePlotConfig({
+ xField, groupBy, tickStep, metric, hiddenSubjects, sidebarOpen,
+ counts: countsS.snapshot, rate: rateS.snapshot,
+ }),
+ [xField, groupBy, tickStep, metric, hiddenSubjects, sidebarOpen, countsS.snapshot, rateS.snapshot],
+ );
+
+ // Keep the latest persist callback in a ref so the save effect does not re-run
+ // when the parent passes a new inline function each render.
+ const persistRef = useRef(onPersistConfig);
+ persistRef.current = onPersistConfig;
+
+ const lastSavedRef = useRef(null);
+ useEffect(() => {
+ const serialized = JSON.stringify(currentConfig);
+ if (lastSavedRef.current === null) { lastSavedRef.current = serialized; return; } // skip initial (hydration)
+ // Reverted to the last-saved value before the debounce fired → nothing pending.
+ if (serialized === lastSavedRef.current) { setSaveState('idle'); return; }
+ if (!persistRef.current) { lastSavedRef.current = serialized; setSaveState('idle'); return; }
+ setSaveState('saving');
+ const t = setTimeout(() => {
+ Promise.resolve(persistRef.current(currentConfig))
+ .then(() => { lastSavedRef.current = serialized; setSaveState('saved'); })
+ .catch(() => setSaveState('error'));
+ }, 1000);
+ return () => clearTimeout(t);
+ }, [currentConfig]);
// Step 1: animalRows — does NOT depend on xField; shared by both charts
const { animalRows, lines } = useMemo(() => {
@@ -567,15 +608,20 @@ export function ExperimentAnalysisCharts({ animals, experimentStatuses, subjectT
)}