675 lines
25 KiB
TypeScript
675 lines
25 KiB
TypeScript
import React from 'react';
|
||
import { useEffect, useRef, useState, useCallback, useImperativeHandle, forwardRef } from 'react';
|
||
|
||
// 直接声明全局 lcjs 变量(已确认依赖已引入,无需再关联 arctionJS)
|
||
declare const lcjs: any;
|
||
|
||
// 定义组件 Props 类型
|
||
export interface WaterFallChartProps {
|
||
texts?: {
|
||
title?: string;
|
||
unit?: string;
|
||
};
|
||
btns?: string[];
|
||
className?: string;
|
||
style?: React.CSSProperties;
|
||
defaultPeriod?: number;
|
||
defaultResolution?: number;
|
||
fftPointNumber?: number;
|
||
xAxisRange: { start: number; end: number };
|
||
powerMin?: number;
|
||
powerMax?: number;
|
||
colorConfig: {
|
||
gain: number;
|
||
};
|
||
}
|
||
|
||
// 定义组件暴露的实例方法类型(核心:明确对外暴露的方法)
|
||
export interface WaterFallChartInstance {
|
||
addData: (data: any[]) => void;
|
||
togglePause: () => void;
|
||
reset: () => void;
|
||
}
|
||
|
||
// 核心:移除 RenderWorker 类(不再需要定时轮询渲染)
|
||
|
||
// 核心:使用 forwardRef 包裹组件,支持暴露实例方法
|
||
const WaterFallChart = forwardRef<WaterFallChartInstance, WaterFallChartProps>((props, ref) => {
|
||
// 解构 props
|
||
const {
|
||
texts = {},
|
||
btns = [],
|
||
className = '',
|
||
style = {},
|
||
defaultPeriod = 100,
|
||
fftPointNumber = 1024, // 默认 FFT 点数
|
||
xAxisRange = { start: 0, end: 30000000 },
|
||
powerMin=-160,
|
||
powerMax=20,
|
||
colorConfig= { gain: 20 }
|
||
} = props;
|
||
|
||
// 容器 Ref(用于挂载 LightningChart)
|
||
const containerRef = useRef<HTMLDivElement>(null);
|
||
// 图表实例 Ref(保存全局上下文,防止重复创建)
|
||
const chartRef = useRef<any>(null);
|
||
// 暂停状态(React 状态管理)
|
||
const [paused, setPaused] = useState(false);
|
||
const powerExtremesRef = useRef({
|
||
min: -95, // 初始默认最小值(匹配原有 setColorStyle 默认值,无数据时兜底)
|
||
max: 0, // 初始默认最大值(匹配原有 setColorStyle 默认值,无数据时兜底)
|
||
hasValidData: false, // 标记是否已有有效数据(区分默认值和实际数据值)
|
||
});
|
||
|
||
// 常量定义
|
||
const RANGE_TIME_MS = 10 * 1000;
|
||
const DefaultAxisYThickness = 45;
|
||
const MAX_CACHE_LENGTH = 500; // 缓存最大长度限制
|
||
const BUFFER_LENGTH = 450;
|
||
|
||
// 颜色数组
|
||
const defaultColorsArray = (() => {
|
||
const colors = "#000000,#000008,#000010,#000018,#000020,#000029,#000031,#000039,#000041,#00004a,#000052,#00005a,#000062,#00006a,#000073,#00007b,#000083,#00008b,#000094,#00009c,#0000a4,#0000ac,#0000b4,#0000bd,#0000c5,#0000cd,#0000d5,#0000de,#0000e6,#0000ee,#0000f6,#0000ff,#0000ff,#0006ff,#000dff,#0013ff,#001aff,#0020ff,#0027ff,#002dff,#0034ff,#003aff,#0041ff,#0047ff,#004eff,#0055ff,#005bff,#0062ff,#0068ff,#006fff,#0075ff,#007cff,#0082ff,#0089ff,#008fff,#0096ff,#009cff,#00a3ff,#00aaff,#00b0ff,#00b7ff,#00bdff,#00c4ff,#00caff,#00d1ff,#00d7ff,#00deff,#00e4ff,#00ebff,#00f1ff,#00f8ff,#00ffff,#00ffff,#00fff3,#00ffe8,#00ffdd,#00ffd2,#00ffc7,#00ffbc,#00ffb1,#00ffa6,#00ff9b,#00ff90,#00ff85,#00ff79,#00ff6e,#00ff63,#00ff58,#00ff4d,#00ff42,#00ff37,#00ff2c,#00ff21,#00ff16,#00ff0b,#00ff00,#00ff00,#0dff00,#1aff00,#28ff00,#35ff00,#43ff00,#50ff00,#5dff00,#6bff00,#78ff00,#86ff00,#93ff00,#a1ff00,#aeff00,#bbff00,#c9ff00,#d6ff00,#e4ff00,#f1ff00,#ffff00,#ffff00,#fffb00,#fff700,#fff300,#ffef00,#ffeb00,#ffe800,#ffe400,#ffe000,#ffdc00,#ffd800,#ffd500,#ffd100,#ffcd00,#ffc900,#ffc500,#ffc200,#ffbe00,#ffba00,#ffb600,#ffb200,#ffaf00,#ffab00,#ffa700,#ffa300,#ff9f00,#ff9c00,#ff9800,#ff9400,#ff9000,#ff8c00,#ff8900,#ff8500,#ff8100,#ff7d00,#ff7900,#ff7500,#ff7200,#ff6e00,#ff6a00,#ff6600,#ff6200,#ff5f00,#ff5b00,#ff5700,#ff5300,#ff4f00,#ff4c00,#ff4800,#ff4400,#ff4000,#ff3c00,#ff3900,#ff3500,#ff3100,#ff2d00,#ff2900,#ff2600,#ff2200,#ff1e00,#ff1a00,#ff1600,#ff1300,#ff0f00,#ff0b00,#ff0700,#ff0300,#ff0000,#ff0000,#ff0001,#ff0003,#ff0005,#ff0007,#ff0009,#ff000a,#ff000c,#ff000e,#ff0010,#ff0012,#ff0014,#ff0015,#ff0017,#ff0019,#ff001b,#ff001d,#ff001f,#ff0020,#ff0022,#ff0024,#ff0026,#ff0028,#ff002a,#ff002b,#ff002d,#ff002f,#ff0031,#ff0033,#ff0035,#ff0036,#ff0038,#ff003a,#ff003c,#ff003e,#ff0040,#ff0041,#ff0043,#ff0045,#ff0047,#ff0049,#ff004a,#ff004c,#ff004e,#ff0050,#ff0052,#ff0054,#ff0055,#ff0057,#ff0059,#ff005b,#ff005d,#ff005f,#ff0060,#ff0062,#ff0064,#ff0066,#ff0068,#ff006a,#ff006b,#ff006d,#ff006f,#ff0071,#ff0073,#ff0075,#ff0076,#ff0078,#ff007a,#ff007c,#ff007e,#ff0080,#ff0081"
|
||
.split(",")
|
||
.filter(color => color && /^#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})$/.test(color))
|
||
.filter((value, index, self) => self.indexOf(value) === index);
|
||
|
||
if (colors.length < 256) {
|
||
const fillCount = 256 - colors.length;
|
||
const lastColor = colors[colors.length - 1] || "#ff0000";
|
||
for (let i = 0; i < fillCount; i++) {
|
||
colors.push(lastColor);
|
||
}
|
||
}
|
||
return colors;
|
||
})();
|
||
const DEFAULT_COLORS = useRef<string[]>(defaultColorsArray).current;
|
||
|
||
// 图表上下文(移除 updater 定时相关,保留必要缓存)
|
||
const chartContext = useRef({
|
||
period: defaultPeriod,
|
||
last: [] as any[],
|
||
cache: [] as any[][],
|
||
resolution: fftPointNumber || 1920,
|
||
series: null as any, // 热力图系列实例
|
||
updater: { last: 0, timeout: 250 } as { last: number; timeout: number } | null, // 添加 updater 属性
|
||
});
|
||
|
||
// 辅助函数:提取单个数据批次中的有效功率极值
|
||
const extractBatchPowerExtremes = useCallback((dataBatch: any[]) => {
|
||
const validPowerPoints = dataBatch.flat().filter((item: any) => {
|
||
return typeof item === 'number' && !isNaN(item) && isFinite(item);
|
||
});
|
||
if (validPowerPoints.length === 0) {
|
||
return null;
|
||
}
|
||
const batchMin = Math.min(...validPowerPoints);
|
||
const batchMax = Math.max(...validPowerPoints);
|
||
return { min: batchMin, max: batchMax };
|
||
}, []);
|
||
|
||
// 数据平铺适配函数
|
||
const spreadData = useCallback((source: any[]) => {
|
||
const targetLength = fftPointNumber;
|
||
const { min: powerMin, max: powerMax } = powerExtremesRef.current;
|
||
if (source.length === 0) {
|
||
return [Array(targetLength).fill(powerMin)];
|
||
}
|
||
const validSource = source[0].map(item => Math.max(powerMin, Math.min(powerMax, item ?? powerMin)));
|
||
const targetRow: number[] = [];
|
||
const freqInterval = 30000000;
|
||
|
||
for (let i = 0; i < targetLength; i++) {
|
||
const freq = 0 + (i / targetLength) * freqInterval;
|
||
const freqRatio = (freq - 0) / freqInterval;
|
||
const sourceIndex = Math.floor(freqRatio * validSource.length);
|
||
const safeSourceIndex = Math.min(Math.max(sourceIndex, 0), validSource.length - 1);
|
||
targetRow.push(validSource[safeSourceIndex]);
|
||
}
|
||
return [targetRow];
|
||
}, [fftPointNumber]);
|
||
|
||
// 颜色样式配置函数
|
||
const setColorStyle1 = useCallback(() => {
|
||
const ctx = chartContext.current;
|
||
const globalExtremes = powerExtremesRef.current;
|
||
if (!ctx.series || !lcjs) {
|
||
console.error("热力图系列未初始化,无法配置颜色样式");
|
||
return;
|
||
}
|
||
const { ColorCSS, LUT, PalettedFill } = lcjs;
|
||
const powerMin = globalExtremes.hasValidData ? globalExtremes.min : -95;
|
||
const powerMax = globalExtremes.hasValidData ? globalExtremes.max : 0;
|
||
const valueRange = powerMax - powerMin;
|
||
|
||
if (valueRange <= 0) {
|
||
console.warn("功率极值范围无效(max <= min),使用默认颜色范围");
|
||
return;
|
||
}
|
||
|
||
const valueColors: { value: number; color: string }[] = [];
|
||
for (let i = 0; i < valueRange; i++) {
|
||
const colorIndex = Math.floor((i / valueRange) * 256);
|
||
const safeColorIndex = Math.min(colorIndex, DEFAULT_COLORS.length - 1);
|
||
const currentValue = i + powerMin;
|
||
const currentColor = DEFAULT_COLORS[safeColorIndex];
|
||
valueColors.push({
|
||
value: currentValue,
|
||
color: currentColor
|
||
});
|
||
}
|
||
|
||
const steps = valueColors.map(({ value, color }) => ({
|
||
value: value,
|
||
label: value,
|
||
color: ColorCSS(color),
|
||
}));
|
||
|
||
const lut = new LUT({
|
||
steps: steps,
|
||
units: "dB",
|
||
interpolate: false,
|
||
});
|
||
|
||
const paletteFill = new PalettedFill({ lut });
|
||
ctx.series.setFillStyle(paletteFill);
|
||
}, []);
|
||
|
||
const setColorStyle2 = useCallback(() => {
|
||
const ctx = chartContext.current;
|
||
const globalExtremes = powerExtremesRef.current;
|
||
if (!ctx.series || !lcjs) {
|
||
console.error("热力图系列未初始化,无法配置颜色样式");
|
||
return;
|
||
}
|
||
const { ColorCSS, LUT, PalettedFill } = lcjs;
|
||
|
||
// 步骤1:使用实际数据极值(贴合真实信号范围,避免色阶稀释)
|
||
// const powerMin = globalExtremes.hasValidData ? globalExtremes.min : -95;
|
||
// const powerMax = globalExtremes.hasValidData ? globalExtremes.max : 0;
|
||
const powerMin = -160;
|
||
const powerMax = 20;
|
||
const valueRange = powerMax - powerMin;
|
||
|
||
if (valueRange <= 0) {
|
||
console.warn("功率极值范围无效(max <= min),使用默认颜色范围");
|
||
return;
|
||
}
|
||
|
||
// 步骤2:新增信号阈值配置(核心:区分有效/无效信号)
|
||
// 阈值规则:取实际功率最小值+10dB(可根据你的信号强度调整该偏移量)
|
||
// 低于此阈值判定为「无信号」,统一设为纯黑/深黑
|
||
const signalThreshold = Math.min(powerMin, powerMax); // 防止阈值超过最大值,避免逻辑错误
|
||
const validSignalRange = powerMax - signalThreshold; // 有效信号的实际范围
|
||
|
||
// 步骤3:重构颜色映射数组(先填充无信号区域,再填充有效信号高对比度颜色)
|
||
const valueColors: { value: number; color: string }[] = [];
|
||
|
||
// 3.1 无信号区域:功率从min到threshold,统一映射为纯黑(低调不抢眼)
|
||
valueColors.push(
|
||
{ value: powerMin, color: "#000000" }, // 起始点
|
||
{ value: signalThreshold, color: "#000000" } // 阈值点(以下全黑)
|
||
);
|
||
|
||
// 3.2 有效信号区域:分配高对比度颜色,避免相近色
|
||
if (validSignalRange > 0) {
|
||
// 可选:替换为精简的高对比度颜色数组(比原有256色更易区分信号强弱)
|
||
const highContrastColors = [
|
||
"#0000ff", // 暗蓝(弱信号)
|
||
"#001A33", // 蓝
|
||
"#0088ff", // 浅蓝
|
||
"#33FFFF", // 绿
|
||
"#88ff00", // 浅绿
|
||
"#ffff00", // 亮黄
|
||
"#ff8800", // 橙
|
||
"#ff0000" // 红(极强信号)
|
||
];
|
||
|
||
for (let i = 0; i <= validSignalRange; i++) {
|
||
const currentValue = signalThreshold + i;
|
||
// 映射逻辑:将有效信号范围均匀分配到高对比度颜色数组(避免颜色过于密集)
|
||
const colorIndex = Math.floor((i / validSignalRange) * (highContrastColors.length - 1));
|
||
const currentColor = highContrastColors[colorIndex];
|
||
|
||
valueColors.push({
|
||
value: currentValue,
|
||
color: currentColor
|
||
});
|
||
}
|
||
} else {
|
||
// 无有效信号范围时,补充一个默认颜色(防止数组为空)
|
||
valueColors.push({ value: powerMax, color: "#0000ff" });
|
||
}
|
||
|
||
// 步骤4:生成LUT所需的steps(保持原有逻辑,映射颜色和值)
|
||
const steps = valueColors.map(({ value, color }) => ({
|
||
value: value,
|
||
label: value.toFixed(1), // 优化标签显示,保留1位小数更整洁
|
||
color: ColorCSS(color),
|
||
}));
|
||
|
||
// 步骤5:创建颜色查找表(关闭插值,确保信号边界清晰,不模糊)
|
||
const lut = new LUT({
|
||
steps: steps,
|
||
units: "dB",
|
||
interpolate: false, // 关键:关闭插值,避免有效/无效信号之间出现过渡杂色
|
||
});
|
||
|
||
const paletteFill = new PalettedFill({ lut });
|
||
ctx.series.setFillStyle(paletteFill);
|
||
}, []); // 补充依赖项,避免闭包导致的颜色数组更新异常
|
||
|
||
const setColorStyle = useCallback(() => {
|
||
const ctx = chartContext.current;
|
||
const globalExtremes = powerExtremesRef.current;
|
||
if (!ctx.series || !lcjs) {
|
||
console.error("热力图系列未初始化,无法配置颜色样式");
|
||
return;
|
||
}
|
||
const { ColorCSS, LUT, PalettedFill } = lcjs;
|
||
|
||
// 步骤1:保留固定功率范围,优化颜色在该区间的分布(黑色背景下更醒目)
|
||
const valueRange = powerMax - powerMin;
|
||
|
||
if (valueRange <= 0) {
|
||
console.warn("功率极值范围无效(max <= min),使用默认颜色范围");
|
||
return;
|
||
}
|
||
|
||
// 步骤2:优化信号阈值逻辑(真正区分无信号/有效信号,提升对比核心)
|
||
// 阈值规则:取 powerMin + 20dB(可调整,建议15-25dB),低于此值判定为「无信号(纯黑)」
|
||
// 解决原有阈值等于powerMin,无信号和弱信号混淆的问题
|
||
const signalOffset = colorConfig.gain; // 关键优化:增大阈值偏移,突出有效信号
|
||
const signalThreshold = Math.min(powerMin + signalOffset, powerMax - 5); // 防止阈值溢出有效区间
|
||
const validSignalRange = powerMax - signalThreshold; // 有效信号的实际可展示范围
|
||
|
||
// 步骤4:重构颜色映射数组(强化有效信号区间的颜色密度)
|
||
const valueColors: { value: number; color: string }[] = [];
|
||
|
||
// 4.1 无信号区域:powerMin → signalThreshold,纯黑(硬边界,无过渡)
|
||
// 解决原有弱信号被黑色淹没的问题,明确区分「无信号」和「有信号」
|
||
valueColors.push(
|
||
{ value: powerMin, color: "#000000" },
|
||
{ value: signalThreshold, color: "#000000" }
|
||
);
|
||
|
||
// 4.2 有效信号区域:分配高对比度颜色,且「分段加权」(中间信号区间颜色更密集)
|
||
if (validSignalRange > 0) {
|
||
// 细分有效信号区间,提升中间信号的对比差异
|
||
const colorStep = validSignalRange / (DEFAULT_COLORS.length * 2); // 颜色步长减半,提升密度
|
||
for (let i = 0; i <= validSignalRange; i += colorStep) {
|
||
const currentValue = signalThreshold + i;
|
||
if (currentValue > powerMax) break;
|
||
|
||
// 映射逻辑:优先分配高对比度颜色,避免均匀过渡导致的对比不足
|
||
const colorIndex = Math.floor(
|
||
(i / validSignalRange) * (DEFAULT_COLORS.length - 1)
|
||
);
|
||
const currentColor = DEFAULT_COLORS[Math.min(colorIndex, DEFAULT_COLORS.length - 1)];
|
||
|
||
valueColors.push({
|
||
value: currentValue,
|
||
color: currentColor,
|
||
});
|
||
}
|
||
} else {
|
||
// 无有效信号范围时,补充鲜艳颜色,避免视觉平淡
|
||
valueColors.push({ value: powerMax, color: "#0099FF" });
|
||
}
|
||
|
||
// 步骤5:优化LUT步骤配置(提升标签可读性,强化颜色边界)
|
||
const steps = valueColors.map(({ value, color }) => ({
|
||
value: value,
|
||
label: value.toFixed(0), // 优化:整数显示,更整洁,减少视觉干扰
|
||
color: ColorCSS(color),
|
||
}));
|
||
|
||
// 步骤6:微调颜色查找表(可选开启「轻度插值」,提升颜色过渡的层次感,不模糊边界)
|
||
// 关键:仅在有效信号区间内插值,无信号区域保持纯黑硬边界
|
||
const lut = new LUT({
|
||
steps: steps,
|
||
units: "dB",
|
||
interpolate: true, // 优化:开启轻度插值,让有效信号颜色过渡更自然,对比更鲜明
|
||
// 新增:设置颜色范围兜底,避免信号溢出时显示异常
|
||
min: powerMin,
|
||
max: powerMax,
|
||
});
|
||
|
||
const paletteFill = new PalettedFill({ lut });
|
||
ctx.series.setFillStyle(paletteFill);
|
||
}, [colorConfig.gain]);
|
||
// 重置图表系列函数
|
||
const resetChartSeries = useCallback(() => {
|
||
const ctx = chartContext.current;
|
||
const chart = chartRef.current;
|
||
if (!chart || !lcjs) {
|
||
console.warn("图表或坐标轴未初始化,无法重置系列");
|
||
return;
|
||
}
|
||
if (ctx.series) {
|
||
try {
|
||
chart.getSeries().forEach(series => series.dispose());
|
||
} catch (error) {
|
||
console.error("销毁旧系列失败", error);
|
||
}
|
||
}
|
||
const { emptyLine } = lcjs;
|
||
if (!emptyLine) {
|
||
console.error("lcjs 缺少 emptyLine API,无法配置系列样式");
|
||
return;
|
||
};
|
||
|
||
const targetResolution = fftPointNumber;
|
||
const freqInterval = 30000000;
|
||
const xStep = freqInterval > 0 ? (freqInterval / targetResolution) : 1;
|
||
|
||
try {
|
||
ctx.series = chart
|
||
.addHeatmapScrollingGridSeries({
|
||
scrollDimension: "rows",
|
||
resolution: targetResolution,
|
||
start: { x: 0, y: 0 },
|
||
step: { x: xStep, y: ctx.period },
|
||
})
|
||
.setWireframeStyle(emptyLine)
|
||
.setMouseInteractions(false)
|
||
.setDataCleaning({ minDataPointCount: 0 });
|
||
} catch (error) {
|
||
console.error("创建热力图系列失败", error);
|
||
return;
|
||
}
|
||
setColorStyle();
|
||
}, [setColorStyle, fftPointNumber]);
|
||
|
||
const addData = useCallback((data: any[]) => {
|
||
// 1. 暂停状态下不执行任何操作
|
||
if (paused) return;
|
||
// 2. 校验输入数据有效性
|
||
if (!Array.isArray(data) || data.length === 0) {
|
||
console.warn("传入的数据无效,跳过更新");
|
||
return;
|
||
}
|
||
const ctx = chartContext.current;
|
||
const now = window.performance.now();
|
||
|
||
// 3. 提取功率极值并更新全局极值
|
||
const batchExtremes = extractBatchPowerExtremes(data);
|
||
if (batchExtremes) {
|
||
const globalExtremes = powerExtremesRef.current;
|
||
if (!globalExtremes.hasValidData) {
|
||
globalExtremes.min = batchExtremes.min;
|
||
globalExtremes.max = batchExtremes.max;
|
||
globalExtremes.hasValidData = true;
|
||
// 首次获取有效数据,更新颜色样式
|
||
setColorStyle();
|
||
} else {
|
||
globalExtremes.min = Math.min(globalExtremes.min, batchExtremes.min);
|
||
globalExtremes.max = Math.max(globalExtremes.max, batchExtremes.max);
|
||
}
|
||
}
|
||
|
||
// 4. 数据缓存处理(限制缓存长度,防止内存溢出)
|
||
ctx.cache.push(data);
|
||
if (ctx.cache.length > MAX_CACHE_LENGTH) {
|
||
ctx.cache = ctx.cache.slice(-BUFFER_LENGTH);
|
||
}
|
||
|
||
// 5. 【核心】直接处理数据并渲染(单次add对应单次渲染)
|
||
let values = [data]; // 取出当前传入的单批数据
|
||
ctx.last = data; // 记录最后一批数据,用于兜底
|
||
|
||
// 6. 数据平铺适配(匹配FFT点数分辨率)
|
||
if (values.length < 1) {
|
||
values = spreadData(values);
|
||
}
|
||
|
||
// 7. 渲染到热力图系列(完成单次更新)
|
||
if (ctx.series) {
|
||
// 检查时间间隔,避免过于频繁的更新
|
||
const lastUpdateTime = ctx.updater?.last || 0;
|
||
const updateTimeout = ctx.updater?.timeout || 250;
|
||
|
||
if (now - lastUpdateTime >= updateTimeout) {
|
||
ctx.series.addIntensityValues(values);
|
||
// 更新最后渲染时间
|
||
if (!ctx.updater) {
|
||
ctx.updater = { last: now, timeout: 250 };
|
||
} else {
|
||
ctx.updater.last = now;
|
||
}
|
||
}
|
||
}
|
||
|
||
}, [paused, extractBatchPowerExtremes, setColorStyle, spreadData]);
|
||
|
||
// 暂停/继续切换方法
|
||
const togglePause = useCallback(() => {
|
||
setPaused(prev => !prev);
|
||
}, []);
|
||
|
||
// 监听X轴范围变化,更新瀑布图X轴(联动功能保留)
|
||
useEffect(() => {
|
||
if (!chartRef.current || !xAxisRange) return;
|
||
|
||
const chart = chartRef.current;
|
||
const axisX = chart.getDefaultAxisX();
|
||
const currentXInterval = axisX.getInterval();
|
||
if (
|
||
currentXInterval.start === xAxisRange.start &&
|
||
currentXInterval.end === xAxisRange.end
|
||
) {
|
||
return;
|
||
}
|
||
|
||
axisX.setInterval(
|
||
xAxisRange.start,
|
||
xAxisRange.end,
|
||
false,
|
||
true
|
||
);
|
||
}, [xAxisRange]);
|
||
|
||
// 暴露实例方法给父组件
|
||
useImperativeHandle(ref, () => ({
|
||
addData,
|
||
togglePause,
|
||
reset: resetChartSeries,
|
||
}), [addData, togglePause, resetChartSeries]);
|
||
|
||
// 初始化图表(移除 RenderWorker 相关逻辑,仅初始化图表和系列)
|
||
useEffect(() => {
|
||
if (!lcjs) {
|
||
console.error("LightningChart 未加载,请确保已引入相关依赖");
|
||
return;
|
||
}
|
||
const requiredAPIs = [
|
||
"AxisScrollStrategies",
|
||
"AxisTickStrategies",
|
||
"TimeFormattingFunctions",
|
||
"emptyLine",
|
||
"emptyFill",
|
||
"lightningChart",
|
||
"ColorCSS",
|
||
"LUT",
|
||
"PalettedFill"
|
||
];
|
||
const missingAPIs = requiredAPIs.filter(api => !lcjs[api]);
|
||
if (missingAPIs.length > 0) {
|
||
console.error("lcjs 缺少必要 API,无法初始化瀑布图", { missingAPIs });
|
||
return;
|
||
}
|
||
|
||
const {
|
||
AxisScrollStrategies,
|
||
AxisTickStrategies,
|
||
TimeFormattingFunctions,
|
||
emptyLine,
|
||
emptyFill,
|
||
lightningChart,
|
||
SolidFill,
|
||
ColorRGBA,
|
||
AutoCursorModes,
|
||
} = lcjs;
|
||
|
||
if (!containerRef.current) {
|
||
console.error("图表容器未找到,无法初始化 LightningChart");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const chart = lightningChart({
|
||
overrideInteractionMouseButtons: {
|
||
axisXYZoomMouseButton: 2,
|
||
chartXYPanMouseButton: 2,
|
||
chartXYRectangleZoomFitMouseButton: 0,
|
||
axisXYPanMouseButton: 0,
|
||
},
|
||
}).ChartXY({ container: containerRef.current });
|
||
|
||
chart
|
||
.setSeriesBackgroundFillStyle(emptyFill)
|
||
.setPadding(5)
|
||
.setPadding({ top: 0 })
|
||
.disableAnimations()
|
||
.setPadding({ left: 0, top: 0, right: 0, bottom: 0 })
|
||
.setMouseInteractionPan(false)
|
||
.setMouseInteractionWheelZoom(false)
|
||
.setMouseInteractionRectangleZoom(false)
|
||
.setMouseInteractionRectangleFit(false)
|
||
.setZoomingRectangleFillStyle(new SolidFill({ color: ColorRGBA(0, 255, 10, 100) }))
|
||
.setTitle("")
|
||
.setAutoCursorMode(AutoCursorModes.disabled);
|
||
|
||
chartRef.current = chart;
|
||
|
||
const axisX = chart.getDefaultAxisX();
|
||
const axisY = chart.getDefaultAxisY();
|
||
axisX
|
||
.setInterval(0, 30000000, false, true)
|
||
.setMouseInteractions(false)
|
||
.setAnimationScroll(false);
|
||
|
||
axisY.setMouseInteractions(false)
|
||
.setAnimationScroll(false);
|
||
|
||
|
||
|
||
// 轴刻度样式配置
|
||
function SetTickStyle(tick: any) {
|
||
return tick.setLabelFont((font: any) => font.setWeight('normal').setSize(12).setStyle('normal').setFamily('Arial').setVariant(true))
|
||
}
|
||
|
||
axisX.setTickStrategy(AxisTickStrategies.Numeric, (strategy: any) => {
|
||
return strategy
|
||
.setMajorTickStyle(SetTickStyle)
|
||
.setMinorTickStyle(SetTickStyle)
|
||
.setFormattingFunction((value: number) => {
|
||
let v = value;
|
||
const { start, end } = axisX.getInterval();
|
||
const span = Math.abs(end - start);
|
||
let unit = "Hz";
|
||
let factor = 1;
|
||
|
||
if (span >= 1e9) { unit = "GHz"; factor = 1e9; }
|
||
else if (span >= 1e6) { unit = "MHz"; factor = 1e6; }
|
||
else if (span >= 1e3) { unit = "KHz"; factor = 1e3; }
|
||
|
||
let num = (v / factor).toFixed(2);
|
||
if (num.endsWith(".00")) num = num.slice(0, -3);
|
||
return `${num}${unit}`;
|
||
});
|
||
});
|
||
|
||
axisY
|
||
.setScrollStrategy(AxisScrollStrategies.progressive)
|
||
.setTickStrategy(AxisTickStrategies.Numeric, (strategy: any) =>
|
||
strategy
|
||
.setMajorFormattingFunction((val: number) =>
|
||
TimeFormattingFunctions.hhmmss(val)
|
||
)
|
||
.setMinorFormattingFunction((val: number) =>
|
||
TimeFormattingFunctions.hhmmss(val)
|
||
)
|
||
.setMajorTickStyle((style: any) => strategy.getMinorTickStyle())
|
||
)
|
||
.setInterval(0, -RANGE_TIME_MS)
|
||
.setThickness(DefaultAxisYThickness)
|
||
.setTickStrategy(AxisTickStrategies.Numeric, (strategy: any) => {
|
||
return strategy
|
||
.setMajorTickStyle(SetTickStyle)
|
||
.setMinorTickStyle(SetTickStyle)
|
||
});
|
||
|
||
// 初始化图表系列(无定时Worker,仅初始化系列)
|
||
const ctx = chartContext.current;
|
||
requestAnimationFrame(() => {
|
||
resetChartSeries();
|
||
});
|
||
|
||
// 销毁回调
|
||
return () => {
|
||
chart?.dispose();
|
||
chartRef.current = null;
|
||
chartContext.current.series = null;
|
||
};
|
||
} catch (error) {
|
||
console.error("瀑布图初始化失败:", error);
|
||
}
|
||
}, [resetChartSeries, fftPointNumber]);
|
||
|
||
// 渲染 UI 组件
|
||
const renderUIElements = () => {
|
||
return (
|
||
<div className="waterfall-chart-ui" style={{ position: "absolute", top: 0, left: 0, right: 0, zIndex: 10 }}>
|
||
{texts.title && (
|
||
<div style={{ position: "absolute", top: 10, left: 10, color: "#fff" }}>
|
||
{texts.title}
|
||
</div>
|
||
)}
|
||
|
||
{texts.unit && (
|
||
<div style={{ position: "absolute", top: 10, right: 10, color: "#fff" }}>
|
||
{texts.unit}
|
||
</div>
|
||
)}
|
||
|
||
{btns.includes("pause") && (
|
||
<button
|
||
onClick={togglePause}
|
||
style={{
|
||
position: "absolute",
|
||
top: 10,
|
||
right: 20,
|
||
padding: "4px 12px",
|
||
backgroundColor: paused ? "#409eff" : "#113665",
|
||
color: "#fff",
|
||
border: "none",
|
||
borderRadius: "4px",
|
||
cursor: "pointer",
|
||
}}
|
||
>
|
||
{paused ? "继续" : "暂停"}
|
||
</button>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
return (
|
||
<div
|
||
ref={containerRef}
|
||
className={`waterfall-chart-container ${className}`}
|
||
style={{
|
||
position: "relative",
|
||
width: "100%",
|
||
height: "400px",
|
||
backgroundColor: "#000",
|
||
...style,
|
||
}}
|
||
>
|
||
{renderUIElements()}
|
||
</div>
|
||
);
|
||
});
|
||
|
||
export default WaterFallChart; |