187 lines
4.7 KiB
TypeScript
187 lines
4.7 KiB
TypeScript
import { useEffect, useMemo, useRef } from "react";
|
|
import * as echarts from "echarts";
|
|
import {
|
|
buildHearingSplineSeries,
|
|
resolveHearingFreqLabels,
|
|
} 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<HTMLDivElement>(null);
|
|
const chartRef = useRef<echarts.ECharts | null>(null);
|
|
|
|
const leftSeries = useMemo(() => buildHearingSplineSeries(left), [left]);
|
|
const rightSeries = useMemo(() => buildHearingSplineSeries(right), [right]);
|
|
|
|
// X axis adapts to the control-point count: 7-band (legacy) or 10-band (adds 3k/5k/6k).
|
|
const pointCount = Math.max(left.length, right.length);
|
|
const freqLabels = useMemo(() => resolveHearingFreqLabels(pointCount), [pointCount]);
|
|
const xMax = Math.max(0, pointCount - 1);
|
|
|
|
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: xMax,
|
|
interval: 1,
|
|
axisLabel: {
|
|
formatter: (value: number) => {
|
|
const idx = Math.round(value);
|
|
if (Math.abs(value - idx) > 1e-6) return "";
|
|
return freqLabels[idx] ?? "";
|
|
},
|
|
fontSize: 14,
|
|
color: "#999",
|
|
},
|
|
splitLine: {
|
|
show: true,
|
|
lineStyle: { type: "solid", color: "#3A4348" },
|
|
},
|
|
axisLine: {
|
|
lineStyle: { color: "#3A4348" },
|
|
},
|
|
},
|
|
yAxis: {
|
|
type: "value",
|
|
min: -80,
|
|
// Top raised to +20 dB: levels above 70 map to positive dB and were
|
|
// clipped at the old max of 0, leaving gaps at the top of the curve.
|
|
max: 20,
|
|
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, freqLabels, xMax]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
chartRef.current?.dispose();
|
|
chartRef.current = null;
|
|
};
|
|
}, []);
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
className={
|
|
className
|
|
?? "w-full h-[min(52vw,240px)] min-h-[180px] md:h-[320px] lg:h-[380px] rounded-lg bg-black/30"
|
|
}
|
|
aria-hidden
|
|
/>
|
|
);
|
|
}
|