2025-11-16 18:37:43 +08:00

664 lines
26 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 zoomPlugin from 'chartjs-plugin-zoom'
import { Line, Bar, Scatter } from 'react-chartjs-2'
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, BarElement, Tooltip, Legend, zoomPlugin)
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 [filterType, setFilterType] = useState<'none' | 'ema' | 'sg' | 'hampel_sg' | 'gauss'>('hampel_sg')
const [emaAlpha, setEmaAlpha] = useState(0.01)
const [sgWindow, setSgWindow] = useState(21)
const [sgOrder, setSgOrder] = useState<2 | 3>(3)
const [hampelWindow, setHampelWindow] = useState(11)
const [hampelK, setHampelK] = useState(2)
const [gaussSigma, setGaussSigma] = useState(5)
const [showOriginal, setShowOriginal] = useState(false)
const [showFiltered, setShowFiltered] = useState(true)
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])
// 若存在拟合,默认不显示滤波码值(仅在记录加载时初始化一次)
useEffect(() => {
if (rec?.fit) setShowFiltered(false)
}, [rec?.fit])
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 filteredCode = useMemo(() => {
if (!rec) return [] as number[]
const x = rec.code
switch (filterType) {
case 'none':
return x
case 'ema':
return applyEMA(x, emaAlpha)
case 'sg': {
const w = makeOddSafe(sgWindow, x.length)
return savitzkyGolay(x, w, sgOrder)
}
case 'hampel_sg': {
const hw = makeOddSafe(hampelWindow, x.length)
const w = makeOddSafe(sgWindow, x.length)
const y = hampelFilter(x, hw, hampelK)
return savitzkyGolay(y, w, sgOrder)
}
case 'gauss':
return gaussianSmooth(x, gaussSigma)
default:
return x
}
}, [rec, filterType, emaAlpha, sgWindow, sgOrder, hampelWindow, hampelK, gaussSigma])
const forceSeries = useMemo(() => {
if (!rec?.fit) return undefined
const { a, b } = rec.fit
const dataToUse = filterType === 'none' ? (rec.code) : filteredCode
return dataToUse.map((c: number) => a * c + b)
}, [rec, filterType, filteredCode])
const codeHistogram = useMemo(() => {
const dataToUse = filterType === 'none' ? (rec?.code || []) : filteredCode
return buildHistogram(dataToUse, 30)
}, [rec, filterType, filteredCode])
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 flex-wrap items-center gap-4">
<label className="flex items-center gap-2 text-sm">
<span>:</span>
<select
className="rounded border bg-transparent px-2 py-1 dark:border-zinc-700"
value={filterType}
onChange={(e) => setFilterType(e.target.value as any)}
>
<option value="none"></option>
<option value="ema">EMA</option>
<option value="sg">SavitzkyGolay</option>
<option value="hampel_sg">Hampel + SG</option>
<option value="gauss">Gaussian</option>
</select>
</label>
{filterType === 'ema' && (
<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-28"
/>
<span className="w-12 text-right">{emaAlpha.toFixed(2)}</span>
</label>
)}
{filterType === 'sg' && (
<div className="flex items-center gap-4 text-sm">
<label className="flex items-center gap-2">
<span>:</span>
<input
type="number"
className="w-16 rounded border bg-transparent px-2 py-1 dark:border-zinc-700"
value={sgWindow}
min={5}
step={2}
onChange={(e) => setSgWindow(Number(e.target.value))}
/>
</label>
<label className="flex items-center gap-2">
<span>:</span>
<select
className="rounded border bg-transparent px-2 py-1 dark:border-zinc-700"
value={sgOrder}
onChange={(e) => setSgOrder(Number(e.target.value) as 2 | 3)}
>
<option value={2}>2</option>
<option value={3}>3</option>
</select>
</label>
</div>
)}
{filterType === 'hampel_sg' && (
<div className="flex items-center gap-4 text-sm">
<label className="flex items-center gap-2">
<span>Hampel窗口:</span>
<input
type="number"
className="w-16 rounded border bg-transparent px-2 py-1 dark:border-zinc-700"
value={hampelWindow}
min={5}
step={2}
onChange={(e) => setHampelWindow(Number(e.target.value))}
/>
</label>
<label className="flex items-center gap-2">
<span>k:</span>
<input
type="number"
className="w-16 rounded border bg-transparent px-2 py-1 dark:border-zinc-700"
value={hampelK}
min={1}
step={0.5}
onChange={(e) => setHampelK(Number(e.target.value))}
/>
</label>
<label className="flex items-center gap-2">
<span>SG窗口:</span>
<input
type="number"
className="w-16 rounded border bg-transparent px-2 py-1 dark:border-zinc-700"
value={sgWindow}
min={5}
step={2}
onChange={(e) => setSgWindow(Number(e.target.value))}
/>
</label>
<label className="flex items-center gap-2">
<span>SG阶数:</span>
<select
className="rounded border bg-transparent px-2 py-1 dark:border-zinc-700"
value={sgOrder}
onChange={(e) => setSgOrder(Number(e.target.value) as 2 | 3)}
>
<option value={2}>2</option>
<option value={3}>3</option>
</select>
</label>
</div>
)}
{filterType === 'gauss' && (
<label className="flex items-center gap-2 text-sm">
<span>σ():</span>
<input
type="range"
min={0.5}
max={10}
step={0.5}
value={gaussSigma}
onChange={(e) => setGaussSigma(Number(e.target.value))}
className="w-40"
/>
<span className="w-12 text-right">{gaussSigma.toFixed(1)}</span>
</label>
)}
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" className="rounded" checked={showFiltered} onChange={(e) => setShowFiltered(e.target.checked)} />
<span></span>
</label>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" className="rounded" checked={showOriginal} onChange={(e) => setShowOriginal(e.target.checked)} />
<span></span>
</label>
<span className="hidden text-xs text-zinc-500 sm:inline"> Ctrl+ </span>
</div>
</div>
<Line
data={{
labels: timeAxis,
datasets: [
...(showOriginal && rec
? [
{
type: 'line' as const,
label: '原始码值',
data: rec.code,
borderColor: '#a1a1aa',
backgroundColor: 'rgba(161,161,170,0.2)',
borderDash: [4, 4] as any,
borderWidth: 1,
yAxisID: 'yCode',
pointRadius: 0,
},
]
: []),
...(showFiltered
? [
{
type: 'line' as const,
label: '滤波码值',
data: filterType === 'none' ? rec.code : filteredCode,
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 },
zoom: {
pan: { enabled: true, mode: 'x' as const },
zoom: {
wheel: { enabled: true, modifierKey: 'ctrl' as const },
pinch: { enabled: true },
mode: 'x' 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={{
plugins: {
zoom: {
pan: { enabled: true, mode: 'x' as const },
zoom: {
wheel: { enabled: true, modifierKey: 'ctrl' as const },
pinch: { enabled: true },
mode: 'x' as const,
},
},
},
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: (filterType === 'none' ? rec.code : filteredCode).map((c: number, i: number) => ({ x: c, y: forceSeries[i] })),
backgroundColor: '#22c55e',
},
],
}}
options={{
scales: {
x: { title: { display: true, text: '码值' } },
y: { title: { display: true, text: '力值 (mN)' } },
},
plugins: {
legend: { display: false },
zoom: {
pan: { enabled: true, mode: 'x' as const },
zoom: {
wheel: { enabled: true, modifierKey: 'ctrl' as const },
pinch: { enabled: true },
mode: 'x' as const,
},
},
},
}}
/>
) : (
<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 makeOddSafe(w: number, len: number) {
let ww = Math.max(3, Math.min(Math.floor(w), len))
if (ww % 2 === 0) ww = ww - 1
if (ww < 3) ww = Math.min(len, 3)
if (ww % 2 === 0 && ww > 1) ww = ww - 1
return Math.max(1, ww)
}
function reflectIndex(i: number, n: number): number {
if (n <= 1) return 0
while (i < 0 || i >= n) {
if (i < 0) i = -i - 1
if (i >= n) i = 2 * n - i - 1
}
return i
}
function convolveReflect(data: number[], kernel: number[]): number[] {
const n = data.length
const m = kernel.length
const half = Math.floor(m / 2)
const out = new Array<number>(n)
for (let i = 0; i < n; i++) {
let acc = 0
for (let k = 0; k < m; k++) {
const idx = reflectIndex(i + k - half, n)
acc += data[idx] * kernel[k]
}
out[i] = acc
}
return out
}
function gaussianSmooth(data: number[], sigma: number): number[] {
if (data.length === 0) return []
if (sigma <= 0) return data.slice()
const radius = Math.max(1, Math.round(3 * sigma))
const size = radius * 2 + 1
const kernel = new Array<number>(size)
let sum = 0
for (let i = -radius; i <= radius; i++) {
const v = Math.exp(-0.5 * (i * i) / (sigma * sigma))
kernel[i + radius] = v
sum += v
}
for (let i = 0; i < size; i++) kernel[i] /= sum
return convolveReflect(data, kernel)
}
// SavitzkyGolay 实现(中心点系数 + 反射卷积)
function transpose(A: number[][]): number[][] {
const r = A.length, c = A[0].length
const T = Array.from({ length: c }, () => new Array<number>(r).fill(0))
for (let i = 0; i < r; i++) for (let j = 0; j < c; j++) T[j][i] = A[i][j]
return T
}
function matMul(A: number[][], B: number[][]): number[][] {
const r = A.length, klen = A[0].length, c = B[0].length
const out = Array.from({ length: r }, () => new Array<number>(c).fill(0))
for (let i = 0; i < r; i++) {
for (let k = 0; k < klen; k++) {
const aik = A[i][k]
for (let j = 0; j < c; j++) out[i][j] += aik * B[k][j]
}
}
return out
}
function invertMatrix(M: number[][]): number[][] {
const n = M.length
const A = M.map((row) => row.slice())
const I = Array.from({ length: n }, (_, i) => {
const r = new Array<number>(n).fill(0)
r[i] = 1
return r
})
// Gauss-Jordan
for (let i = 0; i < n; i++) {
// pivot
let pivot = A[i][i]
if (Math.abs(pivot) < 1e-12) {
// 找到下面非零行交换
let swap = i + 1
while (swap < n && Math.abs(A[swap][i]) < 1e-12) swap++
if (swap === n) throw new Error('Matrix not invertible')
const tmpA = A[i]; A[i] = A[swap]; A[swap] = tmpA
const tmpI = I[i]; I[i] = I[swap]; I[swap] = tmpI
pivot = A[i][i]
}
const invPivot = 1 / pivot
for (let j = 0; j < n; j++) { A[i][j] *= invPivot; I[i][j] *= invPivot }
for (let r = 0; r < n; r++) {
if (r === i) continue
const factor = A[r][i]
if (factor === 0) continue
for (let j = 0; j < n; j++) {
A[r][j] -= factor * A[i][j]
I[r][j] -= factor * I[i][j]
}
}
}
return I
}
function sgCoefficients(windowSize: number, polyOrder: number): number[] {
const m = Math.floor(windowSize / 2)
const rows = windowSize
const cols = polyOrder + 1
// 构造范德蒙德矩阵 Ax ∈ [-m, m]
const A = Array.from({ length: rows }, (_, r) => {
const x = r - m
const row = new Array<number>(cols)
let xp = 1
for (let c = 0; c < cols; c++) { row[c] = xp; xp *= x }
return row
})
const AT = transpose(A)
const ATA = matMul(AT, A)
const ATAi = invertMatrix(ATA)
const pinv = matMul(ATAi, AT) // (cols x rows)
const c = pinv[0] // 第一行对应在 x=0 处的估计系数
return c
}
function savitzkyGolay(data: number[], windowSize: number, polyOrder: 2 | 3): number[] {
if (data.length === 0) return []
const w = makeOddSafe(windowSize, data.length)
const coeff = sgCoefficients(w, polyOrder)
return convolveReflect(data, coeff)
}
// Hampel 过滤,先去异常值,再可串联平滑
function median(arr: number[]): number {
if (arr.length === 0) return 0
const a = arr.slice().sort((x, y) => x - y)
const mid = Math.floor(a.length / 2)
return a.length % 2 === 0 ? (a[mid - 1] + a[mid]) / 2 : a[mid]
}
function hampelFilter(data: number[], windowSize: number, k: number): number[] {
if (data.length === 0) return []
const n = data.length
const w = makeOddSafe(windowSize, n)
const half = Math.floor(w / 2)
const out = data.slice()
for (let i = 0; i < n; i++) {
const win: number[] = []
for (let t = -half; t <= half; t++) win.push(data[reflectIndex(i + t, n)])
const med = median(win)
const absDev = win.map((v) => Math.abs(v - med))
const mad = median(absDev)
const s0 = (mad || 1e-12) * 1.4826
if (Math.abs(data[i] - med) > k * s0) out[i] = med
}
return out
}
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 }
}