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((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(null); // 图表实例 Ref(保存全局上下文,防止重复创建) const chartRef = useRef(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(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 (
{texts.title && (
{texts.title}
)} {texts.unit && (
{texts.unit}
)} {btns.includes("pause") && ( )}
); }; return (
{renderUIElements()}
); }); export default WaterFallChart;