feat(frontend): render numerical-bucket plots in CSV-analysis results

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Experiments DB Dev
2026-05-01 08:03:48 -04:00
parent 39c01c567c
commit 75a343d617
+62 -4
View File
@@ -1,5 +1,8 @@
import React, { useState, useRef, useCallback } from 'react';
import { parseCSVHeaders, parseCSV, getUniqueNoteValues, runAnalysis, checkFrameConsecutive } from '../lib/csvAnalysis';
import { parseCSVHeaders, parseCSV, getUniqueNoteValues, runAnalysis, checkFrameConsecutive, computeNumericSeries } from '../lib/csvAnalysis';
import {
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
} from 'recharts';
import { analysesApi, dailyStatusesApi } from '../api/client';
import Button from './ui/Button';
import RunSequenceView from './RunSequenceView';
@@ -246,8 +249,20 @@ export default function CsvAnalysis({ dailyStatusId, animal, onSaved, onSummaryP
// ── Run analysis ─────────────────────────────────────────────────────────────
async function compute() {
const res = runAnalysis(rows, timestampCol, noteCol, classifications);
setResults(res);
// Note-only classifications so numerical buckets don't pollute counts/runs
const noteClassifications = Object.fromEntries(
noteBuckets.flatMap((b) => b.notes.map((n) => [n, b.name]))
);
const res = runAnalysis(rows, timestampCol, noteCol, noteClassifications);
// Numerical series — one per numerical bucket. xCol = frame if set, else timestamp.
const xCol = frameCol || timestampCol;
const numericSeries = numericalBuckets.map((b) => ({
bucket: b,
...computeNumericSeries(rows, xCol, noteCol, b),
}));
setResults({ ...res, numericSeries });
setPhase('results');
if (!dailyStatusId) return;
@@ -258,7 +273,7 @@ export default function CsvAnalysis({ dailyStatusId, animal, onSaved, onSummaryP
file_name: fileName || null,
timestamp_col: timestampCol,
note_col: noteCol,
classifications,
classifications: noteClassifications,
total_note_rows: res.totalNoteRows,
session_end_ts: res.sessionEndTs,
sequence: res.sequence,
@@ -643,6 +658,49 @@ export default function CsvAnalysis({ dailyStatusId, animal, onSaved, onSummaryP
sessionEndTs={results.sessionEndTs}
/>
{/* Numerical plots — one panel per numerical bucket */}
{results.numericSeries && results.numericSeries.length > 0 && (
<div>
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">Numerical Plots</h3>
<div className="space-y-4">
{results.numericSeries.map(({ bucket, points, avg, skipped, total, xLabel }) => {
const colors = getCategoryColors(bucket.name, customCategories);
return (
<div key={bucket.name} className="border border-gray-200 rounded-lg p-3">
<div className="flex items-baseline justify-between mb-2">
<p className={`text-sm font-semibold ${colors.text}`}>
{bucket.name} <span className="text-xs text-gray-400 font-normal">vs {xLabel}</span>
</p>
<p className="text-xs text-gray-500">
{avg != null ? <>Avg: <strong className="text-gray-800">{avg.toFixed(2)}</strong> · </> : null}
{points.length}/{total} rows plottable
{skipped > 0 && <> · {skipped} skipped</>}
</p>
</div>
{points.length > 0 ? (
<ResponsiveContainer width="100%" height={220}>
<LineChart data={points} margin={{ top: 4, right: 16, left: 0, bottom: 4 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#eee" />
<XAxis dataKey="x" type="number" domain={['dataMin', 'dataMax']}
tick={{ fontSize: 10 }} label={{ value: xLabel, position: 'insideBottom', offset: -2, fontSize: 10 }} />
<YAxis tick={{ fontSize: 10 }} />
<Tooltip />
<Line type="monotone" dataKey="value" stroke="currentColor" className={colors.text}
dot={{ r: 2 }} strokeWidth={2} />
</LineChart>
</ResponsiveContainer>
) : (
<p className="text-xs text-gray-400 italic">
No plottable values {total} of {total} note rows had no extractable number.
</p>
)}
</div>
);
})}
</div>
</div>
)}
{/* Save metrics to daily status */}
{dailyStatusId && (
<div className="pt-3 border-t border-gray-100">