2025-11-15 15:47:11 +08:00

311 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client"
import { use, useEffect, useMemo, useState } from 'react'
import Link from 'next/link'
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
BarElement,
Tooltip,
Legend,
} from 'chart.js'
import { Line, Bar, Scatter } from 'react-chartjs-2'
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, BarElement, Tooltip, Legend)
type Detail = {
id: string
timestamp: string
code: number[]
fit?: { a: number; b: number }
sampleCount: number
duration: number
stats: {
codeMin: number
codeMax: number
codeAvg: number
forceMin?: number
forceMax?: number
forceAvg?: number
}
}
function toFixed(n: number | undefined | null, d = 3) {
if (n == null || Number.isNaN(n)) return '-'
return n.toFixed(d)
}
export default function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [rec, setRec] = useState<Detail | null>(null)
const [emaAlpha, setEmaAlpha] = useState(0.2)
const [enableEma, setEnableEma] = useState(false)
useEffect(() => {
const controller = new AbortController()
async function run() {
setLoading(true)
setError(null)
try {
const res = await fetch(`/api/records/${id}`, { signal: controller.signal })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const j = await res.json()
setRec(j)
} catch (e) {
// @ts-expect-error any
if (e.name !== 'AbortError') setError(e.message || '加载失败')
} finally {
setLoading(false)
}
}
run()
return () => controller.abort()
}, [id])
const timeAxis = useMemo(() => {
if (!rec) return [] as number[]
const step = rec.sampleCount > 0 ? (rec.duration * 1000) / rec.sampleCount : 0
return Array.from({ length: rec.sampleCount }, (_, i) => +(i * step).toFixed(3))
}, [rec])
const smoothedCode = useMemo(() => {
if (!rec || !enableEma) return rec?.code || []
return applyEMA(rec.code, emaAlpha)
}, [rec, enableEma, emaAlpha])
const forceSeries = useMemo(() => {
if (!rec?.fit) return undefined
const { a, b } = rec.fit
const dataToUse = enableEma ? smoothedCode : rec.code
return dataToUse.map((c) => a * c + b)
}, [rec, enableEma, smoothedCode])
const codeHistogram = useMemo(() => {
const dataToUse = enableEma ? smoothedCode : (rec?.code || [])
return buildHistogram(dataToUse, 30)
}, [rec, enableEma, smoothedCode])
const forceHistogram = useMemo(() => buildHistogram(forceSeries || [], 30), [forceSeries])
return (
<div className="min-h-screen bg-zinc-50 text-zinc-900 dark:bg-black dark:text-zinc-100">
<div className="mx-auto max-w-6xl p-4 sm:p-6">
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href="/" className="rounded border px-3 py-1.5"> </Link>
<h1 className="text-xl font-semibold"> {rec?.id || id}</h1>
</div>
<div className="flex gap-2">
<a
href={`/api/export`}
onClick={async (e) => {
e.preventDefault()
const body = JSON.stringify({ ids: [id], format: 'csv' as const })
const res = await fetch('/api/export', { method: 'POST', headers: { 'content-type': 'application/json' }, body })
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `record-${id}.csv`
a.click()
URL.revokeObjectURL(url)
}}
className="rounded bg-zinc-900 px-3 py-1.5 text-white dark:bg-zinc-100 dark:text-black"
> CSV</a>
</div>
</div>
{loading && <div className="rounded bg-zinc-200 p-4 dark:bg-zinc-800">...</div>}
{error && <div className="rounded bg-red-100 p-4 text-red-800">{error}</div>}
{rec && (
<div className="flex flex-col gap-6">
<section className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="rounded-lg border p-4 dark:border-zinc-800">
<div className="text-sm text-zinc-500"></div>
<div className="mt-1 text-lg">{new Date(rec.timestamp).toLocaleString()}</div>
</div>
<div className="rounded-lg border p-4 dark:border-zinc-800">
<div className="text-sm text-zinc-500"></div>
<div className="mt-1 text-lg">{rec.sampleCount} · {rec.duration.toFixed(3)} s · {(rec.sampleCount / rec.duration).toFixed(2)} Hz</div>
</div>
<div className="rounded-lg border p-4 dark:border-zinc-800">
<div className="text-sm text-zinc-500"></div>
<div className="mt-1 text-lg">{rec.fit ? `y = ${rec.fit.a.toFixed(4)}x + ${rec.fit.b.toFixed(3)}` : '未拟合'}</div>
</div>
</section>
<section className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="rounded-lg border p-4 dark:border-zinc-800">
<div className="text-sm text-zinc-500"></div>
<div className="mt-1 text-lg">[{rec.stats.codeMin} ~ {rec.stats.codeMax}] · {toFixed(rec.stats.codeAvg, 2)}</div>
</div>
<div className="rounded-lg border p-4 dark:border-zinc-800">
<div className="text-sm text-zinc-500"></div>
<div className="mt-1 text-lg">{rec.fit ? `[${toFixed(rec.stats.forceMin, 2)} ~ ${toFixed(rec.stats.forceMax, 2)}] mN · 均值 ${toFixed(rec.stats.forceAvg, 2)}` : '—'}</div>
</div>
<div className="rounded-lg border p-4 dark:border-zinc-800">
<div className="text-sm text-zinc-500"></div>
<div className="mt-1 text-lg"> {rec.stats.codeMax - rec.stats.codeMin}{rec.fit && rec.stats.forceMax != null && rec.stats.forceMin != null ? ` · 力值 ${(rec.stats.forceMax - rec.stats.forceMin).toFixed(2)} mN` : ''}</div>
</div>
</section>
<section className="rounded-lg border p-4 dark:border-zinc-800">
<div className="mb-3 flex items-center justify-between">
<div className="text-sm text-zinc-500">线</div>
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={enableEma}
onChange={(e) => setEnableEma(e.target.checked)}
className="rounded"
/>
<span>EMA平滑</span>
</label>
{enableEma && (
<label className="flex items-center gap-2 text-sm">
<span>α:</span>
<input
type="range"
min="0.01"
max="1"
step="0.01"
value={emaAlpha}
onChange={(e) => setEmaAlpha(Number(e.target.value))}
className="w-24"
/>
<span className="w-12 text-right">{emaAlpha.toFixed(2)}</span>
</label>
)}
</div>
</div>
<Line
data={{
labels: timeAxis,
datasets: [
{
type: 'line' as const,
label: '码值',
data: enableEma ? smoothedCode : rec.code,
borderColor: '#0ea5e9',
backgroundColor: 'rgba(14,165,233,0.2)',
borderWidth: 1.5,
yAxisID: 'yCode',
pointRadius: 0,
},
...(rec.fit && forceSeries
? [
{
type: 'line' as const,
label: '力值 (mN)',
data: forceSeries,
borderColor: '#22c55e',
backgroundColor: 'rgba(34,197,94,0.2)',
borderWidth: 1.5,
yAxisID: 'yForce',
pointRadius: 0,
},
]
: []),
],
}}
options={{
responsive: true,
interaction: { mode: 'index', intersect: false },
plugins: { legend: { position: 'top' as const } },
scales: {
x: { title: { display: true, text: '时间 (ms)' } },
yCode: { type: 'linear' as const, position: 'left' as const, title: { display: true, text: '码值' } },
yForce: { type: 'linear' as const, position: 'right' as const, title: { display: true, text: '力值 (mN)' }, grid: { drawOnChartArea: false } },
},
}}
/>
</section>
<section className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="rounded-lg border p-4 dark:border-zinc-800">
<div className="mb-3 text-sm text-zinc-500"></div>
<Bar
data={{
labels: codeHistogram.labels,
datasets: [
{
label: '频数',
data: codeHistogram.counts,
backgroundColor: 'rgba(14,165,233,0.3)',
borderColor: '#0ea5e9',
},
],
}}
options={{ scales: { x: { title: { display: true, text: '码值' } }, y: { title: { display: true, text: '数量' } } } }}
/>
</div>
<div className="rounded-lg border p-4 dark:border-zinc-800">
<div className="mb-3 text-sm text-zinc-500">线</div>
{rec.fit && forceSeries ? (
<Scatter
data={{
datasets: [
{
label: '码值 vs 力值',
data: (enableEma ? smoothedCode : rec.code).map((c, i) => ({ x: c, y: forceSeries[i] })),
backgroundColor: '#22c55e',
},
],
}}
options={{
scales: {
x: { title: { display: true, text: '码值' } },
y: { title: { display: true, text: '力值 (mN)' } },
},
plugins: { legend: { display: false } },
}}
/>
) : (
<div className="text-sm text-zinc-500"></div>
)}
</div>
</section>
</div>
)}
</div>
</div>
)
}
function applyEMA(data: number[], alpha: number): number[] {
if (data.length === 0) return []
const result: number[] = [data[0]]
for (let i = 1; i < data.length; i++) {
result.push(alpha * data[i] + (1 - alpha) * result[i - 1])
}
return result
}
function buildHistogram(values: number[], bins: number) {
if (!values.length || bins <= 0) return { labels: [] as string[], counts: [] as number[] }
let min = values[0]
let max = values[0]
for (const v of values) {
if (v < min) min = v
if (v > max) max = v
}
if (min === max) {
return { labels: [min.toFixed(2)], counts: [values.length] }
}
const step = (max - min) / bins
const edges = Array.from({ length: bins + 1 }, (_, i) => min + i * step)
const counts = Array.from({ length: bins }, () => 0)
for (const v of values) {
let idx = Math.floor((v - min) / step)
if (idx >= bins) idx = bins - 1
if (idx < 0) idx = 0
counts[idx]++
}
const labels = Array.from({ length: bins }, (_, i) => `${edges[i].toFixed(1)}~${edges[i + 1].toFixed(1)}`)
return { labels, counts }
}