From 4f3427b44fb0ff03926192ff16835ea5e8b738d4 Mon Sep 17 00:00:00 2001 From: eafonyang Date: Tue, 14 Jul 2026 14:30:55 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=AC=E5=8A=9B=E8=A1=A5=E5=81=BF=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E5=B7=A6=E5=8F=B3=E8=80=B3=E6=9B=B2=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/HearingCompensationChart.tsx | 179 ++++++++++++++++++ client/src/lib/hearingCurve.ts | 112 +++++++++++ client/src/locales/data-de.json | 4 +- client/src/locales/data-en.json | 4 +- client/src/locales/data-es.json | 4 +- client/src/locales/data-fr.json | 4 +- client/src/locales/data-zh-HK.json | 4 +- client/src/locales/data-zh.json | 4 +- client/src/pages/EffectsPage.tsx | 10 + 9 files changed, 319 insertions(+), 6 deletions(-) create mode 100644 client/src/components/HearingCompensationChart.tsx create mode 100644 client/src/lib/hearingCurve.ts diff --git a/client/src/components/HearingCompensationChart.tsx b/client/src/components/HearingCompensationChart.tsx new file mode 100644 index 0000000..0cd46d7 --- /dev/null +++ b/client/src/components/HearingCompensationChart.tsx @@ -0,0 +1,179 @@ +import { useEffect, useMemo, useRef } from "react"; +import * as echarts from "echarts"; +import { + HEARING_FREQ_LABELS, + buildHearingSplineSeries, +} from "@/lib/hearingCurve"; + +const LEFT_COLOR = "rgb(0, 255, 246)"; +const RIGHT_COLOR = "#FF5A36"; + +type HearingCompensationChartProps = { + left: number[]; + right: number[]; + leftLabel?: string; + rightLabel?: string; + className?: string; +}; + +export default function HearingCompensationChart({ + left, + right, + leftLabel = "Left", + rightLabel = "Right", + className, +}: HearingCompensationChartProps) { + const containerRef = useRef(null); + const chartRef = useRef(null); + + const leftSeries = useMemo(() => buildHearingSplineSeries(left), [left]); + const rightSeries = useMemo(() => buildHearingSplineSeries(right), [right]); + + useEffect(() => { + const el = containerRef.current; + if (!el) return; + + const option: echarts.EChartsOption = { + animation: false, + legend: { + show: true, + top: 8, + data: [ + { + name: leftLabel, + icon: "circle", + textStyle: { color: LEFT_COLOR }, + itemStyle: { color: LEFT_COLOR, borderColor: LEFT_COLOR }, + }, + { + name: rightLabel, + icon: "circle", + textStyle: { color: RIGHT_COLOR }, + itemStyle: { color: RIGHT_COLOR, borderColor: RIGHT_COLOR }, + }, + ], + textStyle: { fontSize: 13 }, + }, + grid: { + left: 48, + right: 36, + top: 48, + bottom: 28, + }, + graphic: [ + { + type: "text", + right: 10, + bottom: 6, + style: { + text: "Hz", + fill: "#999", + fontSize: 14, + }, + z: 100, + }, + ], + xAxis: { + type: "value", + min: 0, + max: HEARING_FREQ_LABELS.length - 1, + interval: 1, + axisLabel: { + formatter: (value: number) => { + const idx = Math.round(value); + if (Math.abs(value - idx) > 1e-6) return ""; + return HEARING_FREQ_LABELS[idx] ?? ""; + }, + fontSize: 14, + color: "#999", + }, + splitLine: { + show: true, + lineStyle: { type: "solid", color: "#3A4348" }, + }, + axisLine: { + lineStyle: { color: "#3A4348" }, + }, + }, + yAxis: { + type: "value", + min: -80, + max: 0, + interval: 10, + name: "dB", + nameLocation: "end", + nameGap: 8, + nameTextStyle: { + color: "#999", + fontSize: 14, + align: "right", + padding: [0, 8, 0, 0], + }, + axisLabel: { + fontSize: 14, + color: "#999", + }, + splitLine: { + show: true, + lineStyle: { type: "solid", color: "#3A4348" }, + }, + axisLine: { + lineStyle: { color: "#3A4348" }, + }, + }, + series: [ + { + name: leftLabel, + type: "line", + data: leftSeries, + showSymbol: false, + smooth: false, + lineStyle: { color: LEFT_COLOR, width: 2 }, + itemStyle: { color: LEFT_COLOR }, + }, + { + name: rightLabel, + type: "line", + data: rightSeries, + showSymbol: false, + smooth: false, + lineStyle: { color: RIGHT_COLOR, width: 2 }, + itemStyle: { color: RIGHT_COLOR }, + }, + ], + }; + + if (!chartRef.current) { + chartRef.current = echarts.init(el, undefined, { renderer: "canvas" }); + } + chartRef.current.setOption(option, true); + + const onResize = () => chartRef.current?.resize(); + const ro = new ResizeObserver(onResize); + ro.observe(el); + window.addEventListener("resize", onResize); + + return () => { + ro.disconnect(); + window.removeEventListener("resize", onResize); + }; + }, [leftSeries, rightSeries, leftLabel, rightLabel]); + + useEffect(() => { + return () => { + chartRef.current?.dispose(); + chartRef.current = null; + }; + }, []); + + return ( +
+ ); +} diff --git a/client/src/lib/hearingCurve.ts b/client/src/lib/hearingCurve.ts new file mode 100644 index 0000000..44b6747 --- /dev/null +++ b/client/src/lib/hearingCurve.ts @@ -0,0 +1,112 @@ +/** Hearing compensation curve helpers: map levels → dB and cubic-spline smooth. */ + +export const HEARING_FREQ_LABELS = ["125", "250", "500", "1k", "2k", "4k", "8k"] as const; + +/** Y = n + (-70). Values outside [-80, 0] are kept as-is (axis still -80…0). */ +export function mapHearingLevelToDb(n: number): number { + return n - 70; +} + +/** + * Natural cubic spline for monotonically spaced `xs`. + * Returns an interpolator defined on [xs[0], xs[n-1]] (extrapolates flat at ends). + */ +export function createNaturalCubicSpline( + xs: number[], + ys: number[], +): (x: number) => number { + const n = xs.length; + if (n === 0) return () => 0; + if (n === 1) return () => ys[0]!; + if (n === 2) { + const x0 = xs[0]!; + const x1 = xs[1]!; + const y0 = ys[0]!; + const y1 = ys[1]!; + return (x: number) => { + if (x1 === x0) return y0; + const t = (x - x0) / (x1 - x0); + return y0 + t * (y1 - y0); + }; + } + + const h = new Array(n - 1); + for (let i = 0; i < n - 1; i++) h[i] = xs[i + 1]! - xs[i]!; + + const alpha = new Array(n).fill(0); + for (let i = 1; i < n - 1; i++) { + alpha[i] = + (3 / h[i]!) * (ys[i + 1]! - ys[i]!) - + (3 / h[i - 1]!) * (ys[i]! - ys[i - 1]!); + } + + const l = new Array(n).fill(1); + const mu = new Array(n).fill(0); + const z = new Array(n).fill(0); + + for (let i = 1; i < n - 1; i++) { + l[i] = 2 * (xs[i + 1]! - xs[i - 1]!) - h[i - 1]! * mu[i - 1]!; + mu[i] = h[i]! / l[i]!; + z[i] = (alpha[i]! - h[i - 1]! * z[i - 1]!) / l[i]!; + } + + const c = new Array(n).fill(0); + const b = new Array(n - 1).fill(0); + const d = new Array(n - 1).fill(0); + + for (let j = n - 2; j >= 0; j--) { + c[j] = z[j]! - mu[j]! * c[j + 1]!; + b[j] = + (ys[j + 1]! - ys[j]!) / h[j]! - + (h[j]! * (c[j + 1]! + 2 * c[j]!)) / 3; + d[j] = (c[j + 1]! - c[j]!) / (3 * h[j]!); + } + + return (x: number) => { + let i = 0; + if (x <= xs[0]!) { + i = 0; + } else if (x >= xs[n - 1]!) { + i = n - 2; + } else { + let lo = 0; + let hi = n - 2; + while (lo <= hi) { + const mid = (lo + hi) >> 1; + if (x < xs[mid]!) { + hi = mid - 1; + } else if (x > xs[mid + 1]!) { + lo = mid + 1; + } else { + i = mid; + break; + } + } + if (x > xs[i + 1]!) i = Math.min(n - 2, lo); + } + + const dx = x - xs[i]!; + return ys[i]! + b[i]! * dx + c[i]! * dx * dx + d[i]! * dx * dx * dx; + }; +} + +/** + * Build densely sampled [xIndex, dB] pairs for a smooth ECharts line. + * X uses equal spacing 0…6 matching HEARING_FREQ_LABELS. + */ +export function buildHearingSplineSeries( + levels: number[], + samplesPerSegment = 16, +): [number, number][] { + const xs = levels.map((_, i) => i); + const ys = levels.map(mapHearingLevelToDb); + const spline = createNaturalCubicSpline(xs, ys); + const maxX = xs[xs.length - 1] ?? 0; + const steps = Math.max(1, maxX * samplesPerSegment); + const points: [number, number][] = []; + for (let s = 0; s <= steps; s++) { + const x = (s / steps) * maxX; + points.push([x, spline(x)]); + } + return points; +} diff --git a/client/src/locales/data-de.json b/client/src/locales/data-de.json index f5f3080..06ee382 100644 --- a/client/src/locales/data-de.json +++ b/client/src/locales/data-de.json @@ -113,7 +113,9 @@ }, "hearing": { "label": "Hörkompensation", - "emptyProfile": "Kein Profil verfügbar" + "emptyProfile": "Kein Profil verfügbar", + "left": "Links", + "right": "Rechts" }, "subwoofer": { "label": "Subwoofer", diff --git a/client/src/locales/data-en.json b/client/src/locales/data-en.json index d8bd479..8dacc45 100644 --- a/client/src/locales/data-en.json +++ b/client/src/locales/data-en.json @@ -113,7 +113,9 @@ }, "hearing": { "label": "Hearing compensation", - "emptyProfile": "No profile available" + "emptyProfile": "No profile available", + "left": "Left", + "right": "Right" }, "subwoofer": { "label": "Subwoofer", diff --git a/client/src/locales/data-es.json b/client/src/locales/data-es.json index 693eb18..d66072a 100644 --- a/client/src/locales/data-es.json +++ b/client/src/locales/data-es.json @@ -113,7 +113,9 @@ }, "hearing": { "label": "Compensación auditiva", - "emptyProfile": "No hay perfil disponible" + "emptyProfile": "No hay perfil disponible", + "left": "Izquierda", + "right": "Derecha" }, "subwoofer": { "label": "Subwoofer", diff --git a/client/src/locales/data-fr.json b/client/src/locales/data-fr.json index b1f3e70..512a0df 100644 --- a/client/src/locales/data-fr.json +++ b/client/src/locales/data-fr.json @@ -113,7 +113,9 @@ }, "hearing": { "label": "Compensation auditive", - "emptyProfile": "Aucun profil disponible" + "emptyProfile": "Aucun profil disponible", + "left": "Gauche", + "right": "Droite" }, "subwoofer": { "label": "Caisson de basses", diff --git a/client/src/locales/data-zh-HK.json b/client/src/locales/data-zh-HK.json index fc48ae5..eec8edc 100644 --- a/client/src/locales/data-zh-HK.json +++ b/client/src/locales/data-zh-HK.json @@ -106,7 +106,9 @@ }, "hearing": { "label": "聽力補償", - "emptyProfile": "無可用配置" + "emptyProfile": "無可用配置", + "left": "左耳", + "right": "右耳" }, "subwoofer": { "label": "低音炮輸出", diff --git a/client/src/locales/data-zh.json b/client/src/locales/data-zh.json index b2690c0..e1e81b7 100644 --- a/client/src/locales/data-zh.json +++ b/client/src/locales/data-zh.json @@ -106,7 +106,9 @@ }, "hearing": { "label": "听力补偿", - "emptyProfile": "无可用配置" + "emptyProfile": "无可用配置", + "left": "左耳", + "right": "右耳" }, "subwoofer": { "label": "低音炮输出", diff --git a/client/src/pages/EffectsPage.tsx b/client/src/pages/EffectsPage.tsx index cc5d203..f26a4d0 100644 --- a/client/src/pages/EffectsPage.tsx +++ b/client/src/pages/EffectsPage.tsx @@ -12,6 +12,7 @@ import { useLocation } from "wouter"; import { useState, useEffect, useRef, useMemo } from "react"; import BottomNav from "@/components/BottomNav"; import SubwooferLpfChart from "@/components/SubwooferLpfChart"; +import HearingCompensationChart from "@/components/HearingCompensationChart"; import { FeatureGate } from "@/components/FeatureGate"; import { navigateToSelect } from "./SelectPage"; import { parseFirmwareVersion, parseHearingData } from "@/lib/luxsinApi"; @@ -914,6 +915,15 @@ export default function EffectsPage() { {hearingDisplay} + {hearingOn && hearingProfiles[hearingIdx] && ( + + )}
)}