From 67047e970589294d773749fd7cbfdb2103659942 Mon Sep 17 00:00:00 2001 From: feie9456 Date: Sun, 16 Nov 2025 18:37:43 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=B9=E8=BF=9B=E6=BB=A4=E6=B3=A2=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/records/[id]/page.tsx | 382 ++++++++++++++++++++++++++++++++++---- 1 file changed, 351 insertions(+), 31 deletions(-) diff --git a/app/records/[id]/page.tsx b/app/records/[id]/page.tsx index 76b6008..f06ac2e 100644 --- a/app/records/[id]/page.tsx +++ b/app/records/[id]/page.tsx @@ -43,8 +43,16 @@ export default function Page({ params }: { params: Promise<{ id: string }> }) { 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 [enableEma, setEnableEma] = useState(true) + 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() @@ -67,28 +75,53 @@ export default function Page({ params }: { params: Promise<{ id: string }> }) { 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 smoothedCode = useMemo(() => { - if (!rec || !enableEma) return rec?.code || [] - return applyEMA(rec.code, emaAlpha) - }, [rec, enableEma, emaAlpha]) + 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 = enableEma ? smoothedCode : rec.code - return dataToUse.map((c) => a * c + b) - }, [rec, enableEma, smoothedCode]) + const dataToUse = filterType === 'none' ? (rec.code) : filteredCode + return dataToUse.map((c: number) => a * c + b) + }, [rec, filterType, filteredCode]) const codeHistogram = useMemo(() => { - const dataToUse = enableEma ? smoothedCode : (rec?.code || []) + const dataToUse = filterType === 'none' ? (rec?.code || []) : filteredCode return buildHistogram(dataToUse, 30) - }, [rec, enableEma, smoothedCode]) + }, [rec, filterType, filteredCode]) return (
@@ -155,17 +188,23 @@ export default function Page({ params }: { params: Promise<{ id: string }> }) {
时序曲线
-
+
- {enableEma && ( + + {filterType === 'ema' && ( )} + + {filterType === 'sg' && ( +
+ + +
+ )} + + {filterType === 'hampel_sg' && ( +
+ + + + +
+ )} + + {filterType === 'gauss' && ( + + )} + + + + + 按 Ctrl+滚轮 缩放,拖拽平移
@@ -187,16 +329,35 @@ export default function Page({ params }: { params: Promise<{ id: string }> }) { 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, - }, + ...(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 ? [ { @@ -274,7 +435,7 @@ export default function Page({ params }: { params: Promise<{ id: string }> }) { datasets: [ { label: '码值 vs 力值', - data: (enableEma ? smoothedCode : rec.code).map((c, i) => ({ x: c, y: forceSeries[i] })), + data: (filterType === 'none' ? rec.code : filteredCode).map((c: number, i: number) => ({ x: c, y: forceSeries[i] })), backgroundColor: '#22c55e', }, ], @@ -318,6 +479,165 @@ function applyEMA(data: number[], alpha: number): number[] { 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]