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:
@@ -1,5 +1,8 @@
|
|||||||
import React, { useState, useRef, useCallback } from 'react';
|
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 { analysesApi, dailyStatusesApi } from '../api/client';
|
||||||
import Button from './ui/Button';
|
import Button from './ui/Button';
|
||||||
import RunSequenceView from './RunSequenceView';
|
import RunSequenceView from './RunSequenceView';
|
||||||
@@ -246,8 +249,20 @@ export default function CsvAnalysis({ dailyStatusId, animal, onSaved, onSummaryP
|
|||||||
// ── Run analysis ─────────────────────────────────────────────────────────────
|
// ── Run analysis ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function compute() {
|
async function compute() {
|
||||||
const res = runAnalysis(rows, timestampCol, noteCol, classifications);
|
// Note-only classifications so numerical buckets don't pollute counts/runs
|
||||||
setResults(res);
|
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');
|
setPhase('results');
|
||||||
|
|
||||||
if (!dailyStatusId) return;
|
if (!dailyStatusId) return;
|
||||||
@@ -258,7 +273,7 @@ export default function CsvAnalysis({ dailyStatusId, animal, onSaved, onSummaryP
|
|||||||
file_name: fileName || null,
|
file_name: fileName || null,
|
||||||
timestamp_col: timestampCol,
|
timestamp_col: timestampCol,
|
||||||
note_col: noteCol,
|
note_col: noteCol,
|
||||||
classifications,
|
classifications: noteClassifications,
|
||||||
total_note_rows: res.totalNoteRows,
|
total_note_rows: res.totalNoteRows,
|
||||||
session_end_ts: res.sessionEndTs,
|
session_end_ts: res.sessionEndTs,
|
||||||
sequence: res.sequence,
|
sequence: res.sequence,
|
||||||
@@ -643,6 +658,49 @@ export default function CsvAnalysis({ dailyStatusId, animal, onSaved, onSummaryP
|
|||||||
sessionEndTs={results.sessionEndTs}
|
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 */}
|
{/* Save metrics to daily status */}
|
||||||
{dailyStatusId && (
|
{dailyStatusId && (
|
||||||
<div className="pt-3 border-t border-gray-100">
|
<div className="pt-3 border-t border-gray-100">
|
||||||
|
|||||||
Reference in New Issue
Block a user