"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(null) const [rec, setRec] = useState(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 (
← 返回

记录 {rec?.id || id}

{loading &&
加载中...
} {error &&
{error}
} {rec && (
上传时间
{new Date(rec.timestamp).toLocaleString()}
采样
{rec.sampleCount} 点 · {rec.duration.toFixed(3)} s · {(rec.sampleCount / rec.duration).toFixed(2)} Hz
拟合
{rec.fit ? `y = ${rec.fit.a.toFixed(4)}x + ${rec.fit.b.toFixed(3)}` : '未拟合'}
码值统计
[{rec.stats.codeMin} ~ {rec.stats.codeMax}] · 均值 {toFixed(rec.stats.codeAvg, 2)}
力值统计
{rec.fit ? `[${toFixed(rec.stats.forceMin, 2)} ~ ${toFixed(rec.stats.forceMax, 2)}] mN · 均值 ${toFixed(rec.stats.forceAvg, 2)}` : '—'}
峰峰值
码值 {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` : ''}
时序曲线
{filterType === 'ema' && ( )} {filterType === 'sg' && (
)} {filterType === 'hampel_sg' && (
)} {filterType === 'gauss' && ( )} 按 Ctrl+滚轮 缩放,拖拽平移
码值分布直方图
线性度散点图
{rec.fit && forceSeries ? ( ({ 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, }, }, }, }} /> ) : (
无拟合参数,无法绘制
)}
)}
) } 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(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(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) } // Savitzky–Golay 实现(中心点系数 + 反射卷积) function transpose(A: number[][]): number[][] { const r = A.length, c = A[0].length const T = Array.from({ length: c }, () => new Array(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(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(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 // 构造范德蒙德矩阵 A,x ∈ [-m, m] const A = Array.from({ length: rows }, (_, r) => { const x = r - m const row = new Array(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 } }