719 lines
28 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, useRef, 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'
// 仅导入类型以启用插件的类型增强,不触发运行时代码
import type {} from 'chartjs-plugin-zoom'
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 [filterType, setFilterType] = useState<'none' | 'ema' | 'sg' | 'hampel_sg' | 'gauss'>('ema')
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)
// 图例控制显示,不再使用自定义 checkbox默认隐藏在数据集上配置
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])
// 显示策略改由 Chart.js 图例控制,默认隐藏在数据集 hidden 字段中设置
// 侦测小屏(手机)以调整 Chart 布局
const [isSmall, setIsSmall] = useState(false)
useEffect(() => {
if (typeof window === 'undefined') return
const mql = window.matchMedia('(max-width: 640px)')
const apply = () => setIsSmall(mql.matches)
apply()
if ((mql as any).addEventListener) {
mql.addEventListener('change', apply)
return () => mql.removeEventListener('change', apply)
}
}, [])
// 动态加载并注册缩放插件(仅在客户端)
const zoomRef = useRef<any>(null)
useEffect(() => {
let cancelled = false
;(async () => {
try {
const mod: any = await import('chartjs-plugin-zoom')
const plugin = mod?.default ?? mod
if (!cancelled) {
zoomRef.current = plugin
ChartJS.register(plugin)
}
} catch (err) {
console.error('Failed to load chartjs-plugin-zoom', err)
}
})()
return () => {
cancelled = true
if (zoomRef.current) {
try { ChartJS.unregister(zoomRef.current) } catch {}
zoomRef.current = null
}
}
}, [])
const timeAxis = useMemo(() => {
if (!rec) return [] as number[]
// 使用秒为单位保留3位小数若采样溢出固定为 16.384s
const overflow = rec.sampleCount >= 16384
const effectiveDuration = overflow ? 16.384 : rec.duration
const step = rec.sampleCount > 0 ? effectiveDuration / 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-2 sm:p-6">
<div className="mb-3 sm: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-4 sm:gap-6">
<section className="grid grid-cols-1 gap-3 sm:gap-4 sm:grid-cols-3">
<div className="rounded-lg border p-3 sm: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-3 sm:p-4 dark:border-zinc-800">
<div className="text-sm text-zinc-500"></div>
{(() => {
const overflow = rec.sampleCount >= 16384
const effectiveDuration = overflow ? 16.384 : rec.duration
const hz = effectiveDuration > 0 ? (rec.sampleCount / effectiveDuration) : 0
return (
<div className="mt-1 text-lg">
{rec.sampleCount} ·{' '}
<span
className={overflow ? 'text-red-600 dark:text-red-400' : ''}
title={overflow ? '采样溢出,时间将不准确' : undefined}
>
{effectiveDuration.toFixed(3)} s
</span>
{' '}· {hz.toFixed(2)} Hz
</div>
)
})()}
</div>
<div className="rounded-lg border p-3 sm: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-3 sm:gap-4 sm:grid-cols-3">
<div className="rounded-lg border p-3 sm: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-3 sm: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-3 sm: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-2 sm:p-4 dark:border-zinc-800">
<div className="mb-2 sm: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>
)}
{/* 由 Chart.js 图例控制数据集显示,不再使用本地 checkbox */}
<span className="hidden text-xs text-zinc-500 sm:inline"> Ctrl+ </span>
</div>
</div>
<div className="relative h-64 sm:h-96">
<Line
data={{
labels: timeAxis,
datasets: [
{
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,
hidden: true, // 默认不显示原始
},
{
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,
hidden: Boolean(rec?.fit), // 存在拟合时默认不显示滤波码值
},
...(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,
maintainAspectRatio: false,
layout: { padding: isSmall ? 0 : { left: 4, right: 12, top: 8, bottom: 4 } as any },
interaction: { mode: 'index', intersect: false },
plugins: {
legend: { position: (isSmall ? 'bottom' : 'top') as 'top' | 'left' | 'bottom' | 'right', labels: { font: { size: isSmall ? 10 : 12 }, boxWidth: isSmall ? 10 : 12, padding: isSmall ? 8 : 12 } },
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: !isSmall, text: '时间 (s)' }, ticks: { maxRotation: 0, font: { size: isSmall ? 10 : 12 } } },
yCode: { type: 'linear' as const, position: 'left' as const, title: { display: !isSmall, text: '码值' }, ticks: { font: { size: isSmall ? 10 : 12 } } },
yForce: { type: 'linear' as const, position: 'right' as const, title: { display: !isSmall, text: '力值 (mN)' }, grid: { drawOnChartArea: false }, ticks: { font: { size: isSmall ? 10 : 12 } } },
},
}}
/>
</div>
</section>
<section className="grid grid-cols-1 gap-3 sm:gap-4 sm:grid-cols-2">
<div className="rounded-lg border p-2 sm:p-4 dark:border-zinc-800">
<div className="mb-2 sm:mb-3 text-sm text-zinc-500"></div>
<div className="relative h-56 sm:h-80">
<Bar
data={{
labels: codeHistogram.labels,
datasets: [
{
label: '频数',
data: codeHistogram.counts,
backgroundColor: 'rgba(14,165,233,0.3)',
borderColor: '#0ea5e9',
},
],
}}
options={{
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,
},
},
},
responsive: true,
maintainAspectRatio: false,
layout: { padding: isSmall ? 0 : { left: 4, right: 8, top: 4, bottom: 0 } as any },
scales: { x: { title: { display: !isSmall, text: '码值' }, ticks: { font: { size: isSmall ? 10 : 12 } } }, y: { title: { display: !isSmall, text: '数量' }, ticks: { font: { size: isSmall ? 10 : 12 } } } },
}}
/>
</div>
</div>
<div className="rounded-lg border p-2 sm:p-4 dark:border-zinc-800">
<div className="mb-2 sm:mb-3 text-sm text-zinc-500">线</div>
{rec.fit && forceSeries ? (
<div className="relative h-56 sm:h-80">
<Scatter
data={{
datasets: [
{
label: '码值 vs 力值',
data: (filterType === 'none' ? rec.code : filteredCode).map((c: number, i: number) => ({ x: c, y: forceSeries[i] })),
backgroundColor: '#22c55e',
},
],
}}
options={{
responsive: true,
maintainAspectRatio: false,
layout: { padding: isSmall ? 0 : { left: 4, right: 8, top: 4, bottom: 0 } as any },
scales: {
x: { title: { display: !isSmall, text: '码值' }, ticks: { font: { size: isSmall ? 10 : 12 } } },
y: { title: { display: !isSmall, text: '力值 (mN)' }, ticks: { font: { size: isSmall ? 10 : 12 } } },
},
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>
) : (
<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 }
}