feat: add spectrum monitoring performance prototype
This commit is contained in:
+12
@@ -0,0 +1,12 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
artifacts/
|
||||||
|
*.tsbuildinfo
|
||||||
|
.playwright-cli/
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# vue-tsc 曾生成的重复编译文件;源码以 TypeScript/Vue SFC 为准
|
||||||
|
src/*.js
|
||||||
|
src/components/*.vue.js
|
||||||
|
vite.config.js
|
||||||
|
vite.config.d.ts
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
# 频谱监测 WebGL2 原型性能结果
|
||||||
|
|
||||||
|
_第一轮可运行基线 · 2026-08-04_
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 测试环境
|
||||||
|
|
||||||
|
本轮测试用于验证实现正确性、指标链路和不同负载之间的相对变化,不代表真实显卡最终性能。
|
||||||
|
|
||||||
|
| 项目 | 环境 |
|
||||||
|
| --- | --- |
|
||||||
|
| 操作系统 | macOS |
|
||||||
|
| 浏览器 | Playwright Chromium Headless |
|
||||||
|
| WebGL 后端 | ANGLE SwiftShader 软件渲染 |
|
||||||
|
| 桌面视口 | 1440 × 900 |
|
||||||
|
| 移动视口 | 390 × 844 |
|
||||||
|
| 数据源 | Dedicated Worker 模拟 Uint8 强度帧 |
|
||||||
|
| 渲染器 | 单 WebGL2 Context + R8 texture2DArray |
|
||||||
|
|
||||||
|
> ⚠️ **环境限制:** Playwright 默认无头 Chromium 未提供 WebGL2。测试通过 `--use-angle=swiftshader` 启用软件 WebGL2,因此 GPU 时间和 FPS 只能作为保守基线。真实硬件 GPU 需要在本地 Chrome、Edge 或 Safari 中重新记录。
|
||||||
|
|
||||||
|
## 🔬 模拟负载模型
|
||||||
|
|
||||||
|
模拟器定位为“接收端已经解码、校准后的频谱强度帧”,而不是原始 IQ 采样或浏览器内 FFT。这样能独立测量数据传递、纹理上传、曲线和瀑布渲染能力,不会把 DSP 计算混入前端展示指标。
|
||||||
|
|
||||||
|
### 已模拟内容
|
||||||
|
|
||||||
|
| 特征 | 实现 |
|
||||||
|
| --- | --- |
|
||||||
|
| 噪声统计 | 近似 FFT 单频点功率分布,不使用均匀随机抖动 |
|
||||||
|
| 接收通带 | 边缘衰减、固定纹波、频段校准偏差 |
|
||||||
|
| 时间特征 | 噪声逐帧变化、噪声底慢漂移、信号慢衰落 |
|
||||||
|
| 窄带信号 | 固定载波和近似高斯频谱裙边 |
|
||||||
|
| 宽带信号 | 平顶占用带宽、滚降边沿和带内功率纹理 |
|
||||||
|
| 动态信号 | 突发、跳频和线性扫频 |
|
||||||
|
| 可重复性 | 固定随机种子,时间由帧序号和刷新率确定 |
|
||||||
|
| 场景配置 | 安静频段、混合业务、密集信号、突发/跳频 |
|
||||||
|
|
||||||
|
信号数量按总频宽(GHz)和场景密度计算,信号占用带宽、跳频步进与扫频范围先以 MHz 定义,再映射到 FFT bin。因而把同一总频宽切成 1、32 或 64 个显示窗口不会改变物理信号集合;窗口数与每窗口 FFT 点数只改变总数据点数和渲染负载。
|
||||||
|
|
||||||
|
噪声底参数表示完成 RBW、窗函数、接收机噪声系数和校准后的“每 FFT bin dBm”,不是 `-174 dBm/Hz` 热噪声密度。正式接入时应直接采用设备上报值。
|
||||||
|
|
||||||
|
### 未模拟内容
|
||||||
|
|
||||||
|
- 原始 IQ 采样率、ADC 量化和 IQ 不平衡
|
||||||
|
- FFT 窗函数旁瓣的精确数学响应
|
||||||
|
- RBW/VBW、检波器模式和平均算法
|
||||||
|
- AGC、前端压缩、互调、镜像和杂散
|
||||||
|
- WebSocket/TCP 协议开销及服务端排队
|
||||||
|
|
||||||
|
因此,当前“强度流”指标是 `窗口数 × FFT 点数 × 更新频率 × 8 bit`,代表 Worker 输出到渲染器的数据量。若真实协议使用 `Int16` 或 `Float32`,网络带宽分别约为该值的 2 倍或 4 倍,但在 Worker 归一化为 `Uint8` 后,GPU 上传负载仍与当前测试一致。
|
||||||
|
|
||||||
|
## 📊 测试结果
|
||||||
|
|
||||||
|
| 场景 | FPS | 接收 | 吞吐 | CPU 上传 | CPU 提交 | GPU | 延迟 | 纹理 |
|
||||||
|
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
|
||||||
|
| 移动轻载:4 × 1024 × 30 Hz,96 行 | 60 | 29.5 Hz | 0.97 Mb/s | 0.00 ms | 0.10 ms | 1.80 ms | 2 ms | 0.4 MiB |
|
||||||
|
| 桌面基准:32 × 1024 × 30 Hz,96 行 | 25 | 31.0 Hz | 8.12 Mb/s | 0.10 ms | 0.10 ms | 13.77 ms | 17 ms | 3.0 MiB |
|
||||||
|
| 桌面高载:64 × 4096 × 20 Hz,128 行 | 5 | 20.3 Hz | 42.62 Mb/s | 0.30 ms | 0.30 ms | 59.82 ms | 19 ms | 32.3 MiB |
|
||||||
|
|
||||||
|
改进信号模型后补充复测:
|
||||||
|
|
||||||
|
| 场景 | FPS | 接收 | 强度流 | CPU 上传 | CPU 提交 | GPU | 延迟 | 纹理 |
|
||||||
|
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
|
||||||
|
| 混合业务:32 × 1024 × 30 Hz,96 行 | 24 | 31.0 Hz | 8.13 Mb/s | 0.00 ms | 0.20 ms | 7.78 ms | 11 ms | 3.0 MiB |
|
||||||
|
| 密集信号:64 × 4096 × 20 Hz,128 行 | 10 | 19.4 Hz | 40.59 Mb/s | 0.20 ms | 0.20 ms | 39.53 ms | 49 ms | 32.3 MiB |
|
||||||
|
|
||||||
|
丢弃帧统计为应用负载以来的累计值,包含页面启动和配置切换阶段,不能直接比较不同测试时长。它的主要用途是判断最新帧策略是否正在主动控制延迟。
|
||||||
|
|
||||||
|
## 💡 结果分析
|
||||||
|
|
||||||
|
### 数据通路达到目标频率
|
||||||
|
|
||||||
|
三个场景的数据接收率均接近配置值。64 路场景约 42.6 Mb/s,与 `64 × 4096 × 20 × 8 bit` 的理论强度数据量一致,说明 Worker、可转移 ArrayBuffer 和主线程接收链路可以承载该测试规模。
|
||||||
|
|
||||||
|
### GPU 填充成为高负载瓶颈
|
||||||
|
|
||||||
|
CPU 上传和绘制命令提交均低于 0.5 ms,但软件 GPU 时间从轻载的 1.8 ms 增长到高载的约 59.8 ms。瓶颈主要来自 64 个窗口、2936 像素画布高度和瀑布区域填充,而不是 JavaScript 绘制循环。
|
||||||
|
|
||||||
|
### 最新帧策略保持低延迟
|
||||||
|
|
||||||
|
高载时 FPS 低于数据频率,原型会覆盖待处理旧帧而不是形成队列。端到端延迟仍保持约 19 ms,证明主动丢弃中间帧可以避免延迟持续增长。
|
||||||
|
|
||||||
|
### 响应式布局通过基础验证
|
||||||
|
|
||||||
|
390 像素移动视口下页面水平溢出为 0,控制项自动换行,四个窗口使用单列布局。桌面 32 路使用四列布局,曲线、阈值、瀑布、标签和框选区域未出现重叠。
|
||||||
|
|
||||||
|
## ✅ 已验证功能
|
||||||
|
|
||||||
|
- Worker 按窗口数、FFT 点数和刷新率生成数据
|
||||||
|
- ArrayBuffer 回收池与最新帧覆盖策略
|
||||||
|
- 运行时重建 R8 `texture2DArray`
|
||||||
|
- 单 Canvas 绘制 1 至 64 个频率窗口
|
||||||
|
- 当前曲线、GPU 环形瀑布和阈值超限着色
|
||||||
|
- 矩形框选与频率/dBm 结果换算
|
||||||
|
- FPS、接收率、吞吐、CPU、GPU、延迟、内存和丢帧指标
|
||||||
|
- 配置切换版本隔离,旧尺寸帧不会进入新纹理
|
||||||
|
- WebGL2 不可用时显示明确错误状态
|
||||||
|
|
||||||
|
## 🔍 下一轮建议
|
||||||
|
|
||||||
|
1. 在目标部署设备的硬件 Chrome/Edge 上记录同一测试矩阵
|
||||||
|
2. 增加固定画布高度模式,区分“同时绘制所有窗口”和“只绘制可见窗口”
|
||||||
|
3. 为 4096/8192 点曲线增加屏幕空间 `min/max` LOD 对照测试
|
||||||
|
4. 连续运行 30 分钟记录 JS Heap、GPU 纹理和丢帧变化
|
||||||
|
5. 若硬件 GPU 在 32 路基准下仍无法稳定 60 FPS,再评估按脏帧绘制、降低 DPR 或可见区域裁剪
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_最后更新:2026-08-04_
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
# 无线电频谱与瀑布图前端渲染方案调研
|
||||||
|
|
||||||
|
_技术选型与性能验证方案 · Vue Web 前端 · 2026-08-04_
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 摘要
|
||||||
|
|
||||||
|
本文面向全频或多频率窗口的实时监测页面,比较商业图表库、开源信号图表库和专用 WebGL2 渲染器。目标页面需要同时显示实时频谱曲线与瀑布图,支持阈值、框选、缩放和游标,并允许在一个页面中显示 1 个全频窗口或 16/32/64 个频率窗口。
|
||||||
|
|
||||||
|
调研结论是:允许商业授权时,SciChart.js 是功能完整度和开发效率较均衡的方案;必须采用宽松开源许可证时,没有现成组件能同时满足多窗口、高刷新率、瀑布图和完整交互。开源路线应采用 Vue 管理控制面、Web Worker 处理数据、单 WebGL2 上下文绘制全部窗口,并以 GPU 环形纹理保存瀑布历史。
|
||||||
|
|
||||||
|
当前性能原型选择开源专用方案,以验证浏览器真实容量上限,同时保留 SciChart.js 作为后续商业方案对照组。
|
||||||
|
|
||||||
|
## 🎯 需求与边界
|
||||||
|
|
||||||
|
### 核心需求
|
||||||
|
|
||||||
|
- 支持 1 个连续全频视图,也支持 4/8/16/32/64 个频率窗口
|
||||||
|
- 每个窗口同时显示当前频谱曲线与历史瀑布图
|
||||||
|
- 支持阈值线、超阈值着色、矩形框选、缩放和频率游标
|
||||||
|
- 支持可配置 FFT 点数、数据刷新率、瀑布深度和频率范围
|
||||||
|
- 高频数据不能进入 Vue 深层响应式系统
|
||||||
|
- 数据到达速度超过渲染速度时保持低延迟,不积压旧帧
|
||||||
|
|
||||||
|
### 非目标
|
||||||
|
|
||||||
|
- 不定义正式后端协议和鉴权方案
|
||||||
|
- 不处理设备控制、解调和信号识别算法
|
||||||
|
- 不追求在移动设备上同时展示 64 个完整窗口
|
||||||
|
- 不把百万 FFT 点逐点映射到不足数千像素的屏幕
|
||||||
|
|
||||||
|
> 📌 **关键定义:** “全频段”描述连续频率范围,不等于必须将全部原始 FFT 点直接提交给 GPU。缩放前必须根据视口进行保峰值的多级降采样。
|
||||||
|
|
||||||
|
## 🔬 调研方法
|
||||||
|
|
||||||
|
本次调研核对了 npm 最新版本元数据、项目许可证、公开 API、官方示例和实际开源实现。候选方案按五个维度评估:曲线、瀑布、多图资源模型、交互能力和授权风险。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
accTitle: 频谱渲染技术选型流程
|
||||||
|
accDescr: 从功能筛选、渲染模型检查、许可证审查到性能原型验证的完整选型流程
|
||||||
|
|
||||||
|
requirements[📋 明确负载模型] --> candidates[🔍 筛选候选库]
|
||||||
|
candidates --> rendering{⚡ GPU 增量渲染?}
|
||||||
|
rendering -->|是| licensing{🔐 许可可接受?}
|
||||||
|
rendering -->|否| baseline[📊 仅作功能基线]
|
||||||
|
licensing -->|是| benchmark[🧪 进入性能测试]
|
||||||
|
licensing -->|否| custom[🔧 专用 WebGL2]
|
||||||
|
custom --> benchmark
|
||||||
|
benchmark --> decision([✅ 根据实测定型])
|
||||||
|
|
||||||
|
classDef process fill:#dbeafe,stroke:#2563eb,stroke-width:2px,color:#1e3a5f
|
||||||
|
classDef decision fill:#fef9c3,stroke:#ca8a04,stroke-width:2px,color:#713f12
|
||||||
|
classDef success fill:#dcfce7,stroke:#16a34a,stroke-width:2px,color:#14532d
|
||||||
|
class requirements,candidates,baseline,custom,benchmark process
|
||||||
|
class rendering,licensing decision
|
||||||
|
class decision success
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📊 方案对比
|
||||||
|
|
||||||
|
### 综合比较
|
||||||
|
|
||||||
|
| 方案 | 渲染内核 | 瀑布能力 | 多窗口模型 | 交互 | 许可证 | 结论 |
|
||||||
|
| --- | --- | --- | --- | --- | --- | --- |
|
||||||
|
| SciChart.js 5.2.62 | WebGL2 + WASM | Uniform Heatmap | 单 Surface + SubCharts | 完整 | 商业 | 商业首选 |
|
||||||
|
| LightningChart JS 9.0.0 | WebGL | Scrolling Heatmap | Dashboard | 完整 | 商业 | 性能强,许可复杂 |
|
||||||
|
| ECharts 6.1.0 | Canvas/SVG | Heatmap | 多 Grid/实例 | 完整 | Apache-2.0 | 低频功能基线 |
|
||||||
|
| Highcharts 13.0.0 | SVG + Boost | Heatmap | 多 Chart | 完整 | 商业 | 通用性强,针对性弱 |
|
||||||
|
| SigPlot 3.1.7 | Canvas2D | 流式 2D Layer | 多实例 | 较完整 | Apache-2.0 | SDR 开源基线 |
|
||||||
|
| uPlot 1.6.32 | Canvas2D | 无内置 | 多实例 | 插件 | MIT | 只适合曲线 |
|
||||||
|
| webgl-plot 1.1.2 | WebGL | 无内置 | 需自研 | 需自研 | MIT | 曲线底层组件 |
|
||||||
|
| gl-spectrogram 1.1.8 | WebGL | 内置 | 多实例 | 很弱 | MIT | 仅作代码参考 |
|
||||||
|
| OpenWebRX | Canvas2D | 内置 | 单接收视图 | SDR 交互 | AGPL-3.0 | 架构参考 |
|
||||||
|
| 专用 WebGL2 | WebGL2 | 环形纹理 | 单上下文分视口 | 按需实现 | 项目自有 | 开源性能首选 |
|
||||||
|
|
||||||
|
### 商业组件
|
||||||
|
|
||||||
|
SciChart.js 提供 WebGL2/WASM 渲染、Uniform Heatmap、Box/Line Annotation、RubberBand 选择及 SubCharts API。其公开类型说明明确建议:需要大量图表同时更新时,应使用单 Surface 配合 SubCharts,而不是创建大量独立 WebGL 上下文。[^1]
|
||||||
|
|
||||||
|
LightningChart JS 提供 `HeatmapScrollingGridSeries`、Spectrogram 示例、多通道实时曲线和 Dashboard。官方给出的性能目标非常激进,但许可包含开发与部署授权、联网校验和特定用途约束,采购前需要单独确认。[^2]
|
||||||
|
|
||||||
|
### 通用开源图表库
|
||||||
|
|
||||||
|
ECharts 的 `brush`、`markLine`、`dataZoom` 和 Heatmap 能快速完成交互原型,但它的主渲染器基于 Canvas/SVG 场景图。大量热力图单元和高频 `setOption` 更新会增加主线程和对象管理压力,因此不作为 32/64 窗口高刷新率主视图。[^3]
|
||||||
|
|
||||||
|
uPlot 是高效的 Canvas2D 时序曲线库,但项目文档明确建议在海量 60 FPS 流式信号场景使用 WebGL;同时没有内置瀑布图。[^4] webgl-plot 能以 WebGL 绘制实时波形,并支持 OffscreenCanvas,但缺少瀑布、坐标轴和标注系统。[^5]
|
||||||
|
|
||||||
|
### SDR 与瀑布开源实现
|
||||||
|
|
||||||
|
SigPlot 面向软件无线电,具有一维、二维流式 Layer 和 SDR 交互,采用 Apache-2.0 许可证。其二维实现主要更新 Canvas 图像缓冲,因此适合功能基线,不应在未经测试时假设能承载 64 个窗口。[^6]
|
||||||
|
|
||||||
|
gl-spectrogram 使用 WebGL 双纹理和 Framebuffer 实现瀑布,但每次 `push` 会将已有纹理整体平移到另一纹理。该方式适合单个音频频谱图,不适合大量窗口;更合理的做法是保持纹理不动,仅循环更新一行。[^7]
|
||||||
|
|
||||||
|
OpenWebRX 是成熟的 SDR 应用参考。其前端逐行创建 `ImageData`、执行 JavaScript 颜色映射,再调用 `putImageData` 写入多个 Canvas;许可证为 AGPL-3.0。它适合研究交互和协议,不适合作为宽松授权的高性能组件依赖。[^8]
|
||||||
|
|
||||||
|
## 💡 推荐架构
|
||||||
|
|
||||||
|
### 数据与渲染通路
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
accTitle: 实时频谱前端数据通路
|
||||||
|
accDescr: 模拟器或 WebSocket 数据经过 Worker 解码和降采样后,以最新帧策略进入单 WebGL2 渲染器
|
||||||
|
|
||||||
|
source[🌐 模拟器或 WebSocket] --> worker[⚙️ Worker 解码与 LOD]
|
||||||
|
worker --> latest[(💾 最新帧缓冲)]
|
||||||
|
latest --> upload[⚡ 上传当前纹理行]
|
||||||
|
upload --> waterfall[📊 GPU 环形瀑布]
|
||||||
|
upload --> curve[📈 当前频谱曲线]
|
||||||
|
controls[🔧 Vue 控制与交互] --> renderer[⚙️ 单 WebGL2 Surface]
|
||||||
|
waterfall --> renderer
|
||||||
|
curve --> renderer
|
||||||
|
renderer --> display([✅ 多频率窗口])
|
||||||
|
|
||||||
|
classDef process fill:#dbeafe,stroke:#2563eb,stroke-width:2px,color:#1e3a5f
|
||||||
|
classDef data fill:#ede9fe,stroke:#7c3aed,stroke-width:2px,color:#3b0764
|
||||||
|
classDef success fill:#dcfce7,stroke:#16a34a,stroke-width:2px,color:#14532d
|
||||||
|
class source,worker,upload,waterfall,curve,controls,renderer process
|
||||||
|
class latest data
|
||||||
|
class display success
|
||||||
|
```
|
||||||
|
|
||||||
|
### GPU 环形纹理
|
||||||
|
|
||||||
|
瀑布纹理使用 `R8` 或 `R16F` 保存归一化强度。每个 FFT 帧只执行一行 `texSubImage2D`,写入位置按以下方式循环:
|
||||||
|
|
||||||
|
```text
|
||||||
|
writeRow = (writeRow + 1) % historyRows
|
||||||
|
sampleRow = (writeRow - screenRow + historyRows) % historyRows
|
||||||
|
```
|
||||||
|
|
||||||
|
Fragment Shader 通过 `sampleRow` 改变显示顺序,不移动历史纹理。颜色使用一维 LUT 纹理完成映射。当前曲线可以从最新一行采样,避免复制第二份强度数据。
|
||||||
|
|
||||||
|
多窗口共享一个 WebGL2 Context,通过 `viewport` 和 `scissor` 划分绘制区域。这样不会触发浏览器 WebGL Context 数量限制,也降低重复纹理、着色器和状态切换成本。
|
||||||
|
|
||||||
|
### Vue 集成原则
|
||||||
|
|
||||||
|
- Vue 只保存配置、布局、阈值、选区和统计值
|
||||||
|
- FFT 数组保持为非响应式 TypedArray
|
||||||
|
- Web Worker 使用可转移 ArrayBuffer 池,避免持续分配
|
||||||
|
- 主线程只保留最新帧,旧帧直接回收
|
||||||
|
- `requestAnimationFrame` 决定绘制节奏,数据接收不直接触发 Vue Render
|
||||||
|
- 阈值和框选使用轻量覆盖层,指针移动时才更新
|
||||||
|
|
||||||
|
## ⚡ 容量模型
|
||||||
|
|
||||||
|
### 数据带宽
|
||||||
|
|
||||||
|
使用 16 位强度值时:
|
||||||
|
|
||||||
|
```text
|
||||||
|
带宽 = 窗口数 × 每窗口 FFT 点数 × 2 字节 × 更新频率
|
||||||
|
```
|
||||||
|
|
||||||
|
| 场景 | 计算规模 | 约合带宽 |
|
||||||
|
| --- | ---: | ---: |
|
||||||
|
| 32 个窗口 | 32 × 2048 × 30 Hz | 3.75 MiB/s |
|
||||||
|
| 64 个窗口 | 64 × 4096 × 20 Hz | 10 MiB/s |
|
||||||
|
| 单个全频 | 1,048,576 × 20 Hz | 40 MiB/s |
|
||||||
|
|
||||||
|
全频缩放前的有效水平分辨率通常只有 1,000 至 4,000 像素。曲线应按像素保留 `min/max` 包络,瀑布按像素保留 `max` 或可配置聚合;否则既浪费带宽,也可能因平均降采样丢失窄带峰值。
|
||||||
|
|
||||||
|
### GPU 内存
|
||||||
|
|
||||||
|
采用单字节强度纹理时:
|
||||||
|
|
||||||
|
```text
|
||||||
|
瀑布内存 = 窗口数 × FFT 点数 × 历史行数
|
||||||
|
```
|
||||||
|
|
||||||
|
| 场景 | 纹理规模 | 强度纹理内存 |
|
||||||
|
| --- | ---: | ---: |
|
||||||
|
| 32 × 2048 × 256 | 16,777,216 texels | 16 MiB |
|
||||||
|
| 64 × 4096 × 256 | 67,108,864 texels | 64 MiB |
|
||||||
|
| 1M × 512 | 536,870,912 texels | 512 MiB |
|
||||||
|
|
||||||
|
最后一种布局不合理,必须在进入瀑布纹理前完成 LOD。
|
||||||
|
|
||||||
|
## 🧪 性能验证计划
|
||||||
|
|
||||||
|
### 测试矩阵
|
||||||
|
|
||||||
|
| 维度 | 测试值 |
|
||||||
|
| --- | --- |
|
||||||
|
| 窗口数 | 1、8、16、32、64 |
|
||||||
|
| FFT 点数 | 512、1024、2048、4096、8192 |
|
||||||
|
| 数据频率 | 10、20、30、60 Hz |
|
||||||
|
| 瀑布深度 | 64、128、256、512 行 |
|
||||||
|
| 页面布局 | 全频单图、多列窗口 |
|
||||||
|
| 交互 | 阈值拖动、框选、缩放、游标 |
|
||||||
|
|
||||||
|
### 验收指标
|
||||||
|
|
||||||
|
| 指标 | 目标 |
|
||||||
|
| --- | ---: |
|
||||||
|
| P95 帧耗时 | 小于 16.7 ms |
|
||||||
|
| 交互反馈延迟 | 小于 50 ms |
|
||||||
|
| 数据到显示延迟 | 小于 100 ms |
|
||||||
|
| 主线程长任务 | 10 分钟内无持续出现 |
|
||||||
|
| 内存 | 30 分钟运行无持续增长 |
|
||||||
|
| 数据积压 | 不积压,允许合并中间帧 |
|
||||||
|
|
||||||
|
性能页面必须显示实际 FPS、数据接收率、上传耗时、绘制耗时、丢弃帧数、估算吞吐和纹理内存。测试结果只能代表指定浏览器、GPU、分辨率和设备像素比,不能直接外推到全部终端。
|
||||||
|
|
||||||
|
## ✅ 选型结论
|
||||||
|
|
||||||
|
当前采用以下决策:
|
||||||
|
|
||||||
|
1. 使用专用 WebGL2 GPU 环形纹理实现开源性能基线
|
||||||
|
2. 使用 Vue 构建控制面,不让 Vue 管理实时 FFT 数组
|
||||||
|
3. 使用 Worker 模拟后端并生成二进制 TypedArray 数据
|
||||||
|
4. 使用单 WebGL2 Context 绘制全部频率窗口
|
||||||
|
5. 将 SigPlot 保留为开源功能对照,不作为最终内核
|
||||||
|
6. 将 SciChart.js 保留为商业对照,待开源基线结果出来后再决定是否评估试用版
|
||||||
|
|
||||||
|
选择专用实现并非预先认定其一定优于商业库,而是为了先获得不受许可约束的真实容量数据,并明确浏览器、GPU、数据带宽和布局的性能边界。
|
||||||
|
|
||||||
|
## 🔗 参考资料
|
||||||
|
|
||||||
|
[^1]: SciChart. (2026). "SciChart.js npm package and SubCharts APIs." _npm / SciChart Documentation_. <https://www.npmjs.com/package/scichart>
|
||||||
|
|
||||||
|
[^2]: LightningChart. (2026). "LightningChart JS interactive examples and heatmap features." _LightningChart_. <https://lightningchart.com/lightningchart-js-interactive-examples/>
|
||||||
|
|
||||||
|
[^3]: Apache ECharts. (2026). "Canvas vs. SVG." _Apache ECharts Handbook_. <https://echarts.apache.org/handbook/en/best-practices/canvas-vs-svg/>
|
||||||
|
|
||||||
|
[^4]: uPlot Contributors. (2025). "uPlot performance and streaming guidance." _GitHub_. <https://github.com/leeoniya/uPlot>
|
||||||
|
|
||||||
|
[^5]: Chitnis, D. (2026). "webgl-plot: high-performance 2D plotting." _GitHub_. <https://github.com/danchitnis/webgl-plot>
|
||||||
|
|
||||||
|
[^6]: Spectric Labs. (2025). "SigPlot: interactive plotting for SDR applications." _GitHub_. <https://github.com/spectriclabs/sigplot>
|
||||||
|
|
||||||
|
[^7]: audio-lab. (2022). "gl-spectrogram WebGL renderer." _GitHub_. <https://github.com/audio-lab/gl-spectrogram>
|
||||||
|
|
||||||
|
[^8]: OpenWebRX Contributors. (2026). "OpenWebRX source and AGPL license." _GitHub_. <https://github.com/jketterl/openwebrx>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_最后更新:2026-08-04_
|
||||||
+142
@@ -0,0 +1,142 @@
|
|||||||
|
# 频谱监测性能实验台 UI Spec
|
||||||
|
|
||||||
|
_实现约束与验收基准 · Web / Vue · 2026-08-04_
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 需求拆解
|
||||||
|
|
||||||
|
| 项目 | 定义 |
|
||||||
|
| --- | --- |
|
||||||
|
| 页面类型 | 高密度工程监测台 |
|
||||||
|
| 目标用户 | 频谱监测、无线电和前端性能工程师 |
|
||||||
|
| 核心任务 | 调整负载参数并观察曲线、瀑布及性能指标 |
|
||||||
|
| 首屏主信息 | 运行状态、FPS、数据率、渲染耗时、频谱视图 |
|
||||||
|
| 次要信息 | GPU 内存估算、丢帧、端到端延迟、选区结果 |
|
||||||
|
| 平台 | 桌面 Web 优先,平板可查看,移动端仅用于诊断 |
|
||||||
|
| 数据源 | Web Worker 模拟二进制强度帧 |
|
||||||
|
|
||||||
|
必须包含正常、暂停、配置应用中、WebGL 不支持和 Worker 异常状态。页面不包含营销内容,不使用装饰型卡片;控制条和性能条保持紧凑,主区域用于频谱数据。
|
||||||
|
|
||||||
|
## 🎯 页面规格
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
screen:
|
||||||
|
name: 频谱监测性能实验台
|
||||||
|
surface: web
|
||||||
|
type: dashboard
|
||||||
|
goal: 测量全频或多窗口曲线与瀑布图的浏览器性能边界
|
||||||
|
target_users:
|
||||||
|
- 频谱监测工程师
|
||||||
|
- 前端性能工程师
|
||||||
|
primary_task: 配置数据规模并在交互过程中判断是否满足性能目标
|
||||||
|
information_density: high
|
||||||
|
visual_style: 安静、专业、偏仪器界面
|
||||||
|
|
||||||
|
platform:
|
||||||
|
stack: Vue 3 + TypeScript + Web Worker + WebGL2
|
||||||
|
existing_components:
|
||||||
|
- lucide-vue-next
|
||||||
|
state_management: Vue Composition API local state
|
||||||
|
routing_or_navigation: 单页面
|
||||||
|
design_system: 项目内紧凑变量与原生表单控件
|
||||||
|
|
||||||
|
layout:
|
||||||
|
shell: 固定标题与控制区、性能状态条、可滚动频谱区域
|
||||||
|
max_width_or_device_rule: 桌面占满可用宽度,最小支持 768px
|
||||||
|
background: 中性深灰仪器背景
|
||||||
|
content_container: 全宽无装饰外卡片
|
||||||
|
regions:
|
||||||
|
- id: control_bar
|
||||||
|
name: 测试控制
|
||||||
|
priority: high
|
||||||
|
position: 顶部
|
||||||
|
adaptive_behavior: 窄屏换行
|
||||||
|
- id: metrics_bar
|
||||||
|
name: 性能指标
|
||||||
|
priority: high
|
||||||
|
position: 控制区下方
|
||||||
|
adaptive_behavior: 自动换列
|
||||||
|
- id: spectrum_surface
|
||||||
|
name: 频谱与瀑布
|
||||||
|
priority: high
|
||||||
|
position: 主内容区
|
||||||
|
adaptive_behavior: 1 至 4 列响应式网格
|
||||||
|
- id: selection_feedback
|
||||||
|
name: 选区结果
|
||||||
|
priority: medium
|
||||||
|
position: 性能条右侧
|
||||||
|
adaptive_behavior: 窄屏独占一行
|
||||||
|
|
||||||
|
interactions:
|
||||||
|
- name: 应用负载配置
|
||||||
|
trigger: 点击应用按钮
|
||||||
|
result: 重建 Worker 数据规模和 GPU 纹理
|
||||||
|
feedback: 指标归零并显示运行状态
|
||||||
|
risk: none
|
||||||
|
- name: 暂停数据流
|
||||||
|
trigger: 点击暂停按钮
|
||||||
|
result: Worker 停止生成帧
|
||||||
|
feedback: 状态显示已暂停
|
||||||
|
risk: none
|
||||||
|
- name: 调整阈值
|
||||||
|
trigger: 拖动阈值滑块
|
||||||
|
result: 所有曲线更新阈值线和超限颜色
|
||||||
|
feedback: 实时显示 dBm 数值
|
||||||
|
risk: none
|
||||||
|
- name: 框选频率区域
|
||||||
|
trigger: 在任一窗口按下并拖动
|
||||||
|
result: 显示矩形和频率范围
|
||||||
|
feedback: 性能条显示窗口编号与起止频率
|
||||||
|
risk: none
|
||||||
|
|
||||||
|
tokens:
|
||||||
|
color_style: 中性深色背景,青绿数据,黄色阈值,红色告警
|
||||||
|
primary: "#2dd4bf"
|
||||||
|
success: "#55d187"
|
||||||
|
warning: "#f5c451"
|
||||||
|
danger: "#ff735f"
|
||||||
|
background: "#101416"
|
||||||
|
typography: 系统无衬线字体与等宽数字
|
||||||
|
spacing: 4/8/12/16/24
|
||||||
|
radius: 4px
|
||||||
|
elevation_or_shadow: 无大面积阴影,仅使用边框分层
|
||||||
|
density: compact
|
||||||
|
|
||||||
|
adaptive:
|
||||||
|
desktop: 1440px 以上显示四列频率窗口
|
||||||
|
tablet: 768 至 1199px 显示两列
|
||||||
|
mobile: 低于 768px 显示单列并允许控制条换行
|
||||||
|
text_scale: 支持浏览器 125% 缩放且不重叠
|
||||||
|
orientation: 桌面横向优先
|
||||||
|
|
||||||
|
accessibility:
|
||||||
|
semantics: Canvas 具有描述文本,每个频率窗口具有可访问名称
|
||||||
|
focus_order: 配置控件、运行控制、频谱窗口
|
||||||
|
labels: 图标按钮必须提供 aria-label 和 title
|
||||||
|
contrast: 文本和状态色满足深色背景可读性
|
||||||
|
motion: prefers-reduced-motion 下关闭非必要过渡
|
||||||
|
touch_targets: 交互控件最小高度 32px
|
||||||
|
```
|
||||||
|
|
||||||
|
## ✅ 验收标准
|
||||||
|
|
||||||
|
1. 支持 1、8、16、32、64 个窗口以及 512 至 8192 FFT 点
|
||||||
|
2. 每个窗口同时出现当前曲线、阈值线和瀑布历史
|
||||||
|
3. 参数应用后不刷新页面,指标和纹理正确重置
|
||||||
|
4. 框选后显示频率范围,选择层不改变 Canvas 尺寸
|
||||||
|
5. 实时显示 FPS、接收频率、吞吐、上传、绘制、延迟、丢帧和纹理内存
|
||||||
|
6. FFT TypedArray 不进入 Vue 深层响应式状态
|
||||||
|
7. 桌面、平板和移动宽度无文本重叠或水平溢出
|
||||||
|
8. WebGL2 初始化失败时显示明确错误,而不是空白画布
|
||||||
|
|
||||||
|
## 🔍 待实测问题
|
||||||
|
|
||||||
|
- 目标设备在 64 × 4096 × 20 Hz 下的 Worker 生成能力
|
||||||
|
- 不同 GPU 对 `R8 texture2DArray` 连续行上传的耗时差异
|
||||||
|
- 画布高度、设备像素比和多窗口布局对填充率的影响
|
||||||
|
- 8192 点曲线使用原始 `LINE_STRIP` 时是否需要屏幕空间 LOD
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_最后更新:2026-08-04_
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#101316" />
|
||||||
|
<title>频谱监测性能实验台</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+1568
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "spectrum-monitor-lab",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host 0.0.0.0",
|
||||||
|
"build": "vue-tsc -b && vite build",
|
||||||
|
"preview": "vite preview --host 0.0.0.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@lucide/vue": "^1.28.0",
|
||||||
|
"vue": "^3.5.13"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.2.1",
|
||||||
|
"typescript": "~5.7.2",
|
||||||
|
"vite": "^6.0.5",
|
||||||
|
"vue-tsc": "^2.2.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
+87
@@ -0,0 +1,87 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { AlertTriangle, RadioTower } from '@lucide/vue'
|
||||||
|
import ControlPanel from './components/ControlPanel.vue'
|
||||||
|
import SpectrumSurface from './components/SpectrumSurface.vue'
|
||||||
|
import StatusBar from './components/StatusBar.vue'
|
||||||
|
import type { PerformanceMetrics, SelectionResult, SpectrumConfig } from './types'
|
||||||
|
|
||||||
|
const baseline: SpectrumConfig = {
|
||||||
|
bandCount: 32,
|
||||||
|
bins: 1024,
|
||||||
|
updateHz: 30,
|
||||||
|
waterfallRows: 96,
|
||||||
|
startMHz: 20,
|
||||||
|
endMHz: 6020,
|
||||||
|
minDbm: -120,
|
||||||
|
maxDbm: -20,
|
||||||
|
simulationProfile: 'mixed',
|
||||||
|
noiseFloorDbm: -104,
|
||||||
|
simulationSeed: 20260804,
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = ref<SpectrumConfig>({ ...baseline })
|
||||||
|
const paused = ref(false)
|
||||||
|
const threshold = ref(-72)
|
||||||
|
const error = ref('')
|
||||||
|
const selection = ref<SelectionResult | null>(null)
|
||||||
|
const metrics = ref<PerformanceMetrics>({
|
||||||
|
fps: 0,
|
||||||
|
receivedHz: 0,
|
||||||
|
throughputMbps: 0,
|
||||||
|
uploadMs: 0,
|
||||||
|
renderMs: 0,
|
||||||
|
gpuMs: 0,
|
||||||
|
latencyMs: 0,
|
||||||
|
droppedFrames: 0,
|
||||||
|
textureMemoryMb: baseline.bandCount * baseline.bins * (baseline.waterfallRows + 1) / 1024 / 1024,
|
||||||
|
sequence: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
function applyConfig(value: SpectrumConfig): void {
|
||||||
|
error.value = ''
|
||||||
|
selection.value = null
|
||||||
|
config.value = { ...value }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main class="app-shell">
|
||||||
|
<header class="app-header">
|
||||||
|
<div class="product-title">
|
||||||
|
<RadioTower :size="22" aria-hidden="true" />
|
||||||
|
<div>
|
||||||
|
<h1>频谱监测性能实验台</h1>
|
||||||
|
<p>单 WebGL2 Surface / GPU 环形瀑布 / Worker 数据源</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href="/docs/spectrum-rendering-options.md" target="_blank" rel="noreferrer">方案文档</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<ControlPanel
|
||||||
|
:config="config"
|
||||||
|
:paused="paused"
|
||||||
|
:threshold="threshold"
|
||||||
|
@apply="applyConfig"
|
||||||
|
@update:paused="paused = $event"
|
||||||
|
@update:threshold="threshold = $event"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<StatusBar :metrics="metrics" :paused="paused" :selection="selection" />
|
||||||
|
|
||||||
|
<div v-if="error" class="error-banner" role="alert">
|
||||||
|
<AlertTriangle :size="17" aria-hidden="true" />
|
||||||
|
<span>{{ error }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SpectrumSurface
|
||||||
|
v-else
|
||||||
|
:config="config"
|
||||||
|
:threshold="threshold"
|
||||||
|
:paused="paused"
|
||||||
|
@metrics="metrics = $event"
|
||||||
|
@selection="selection = $event"
|
||||||
|
@error="error = $event"
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,374 @@
|
|||||||
|
import type { BandRect, SpectrumConfig } from './types'
|
||||||
|
|
||||||
|
const QUAD_VERTEX = `#version 300 es
|
||||||
|
in vec2 a_position;
|
||||||
|
uniform vec2 u_resolution;
|
||||||
|
uniform vec4 u_rect;
|
||||||
|
out vec2 v_uv;
|
||||||
|
void main() {
|
||||||
|
v_uv = a_position * 0.5 + 0.5;
|
||||||
|
vec2 pixel = u_rect.xy + v_uv * u_rect.zw;
|
||||||
|
vec2 clip = pixel / u_resolution * 2.0 - 1.0;
|
||||||
|
gl_Position = vec4(clip.x, -clip.y, 0.0, 1.0);
|
||||||
|
}`
|
||||||
|
|
||||||
|
const WATERFALL_FRAGMENT = `#version 300 es
|
||||||
|
precision highp float;
|
||||||
|
uniform highp sampler2DArray u_waterfall;
|
||||||
|
uniform float u_band;
|
||||||
|
uniform float u_rows;
|
||||||
|
uniform float u_head;
|
||||||
|
in vec2 v_uv;
|
||||||
|
out vec4 out_color;
|
||||||
|
|
||||||
|
vec3 palette(float x) {
|
||||||
|
vec3 c0 = vec3(0.018, 0.035, 0.055);
|
||||||
|
vec3 c1 = vec3(0.0, 0.34, 0.48);
|
||||||
|
vec3 c2 = vec3(0.12, 0.83, 0.63);
|
||||||
|
vec3 c3 = vec3(0.98, 0.78, 0.20);
|
||||||
|
vec3 c4 = vec3(0.94, 0.20, 0.16);
|
||||||
|
if (x < 0.25) return mix(c0, c1, x * 4.0);
|
||||||
|
if (x < 0.55) return mix(c1, c2, (x - 0.25) / 0.30);
|
||||||
|
if (x < 0.78) return mix(c2, c3, (x - 0.55) / 0.23);
|
||||||
|
return mix(c3, c4, (x - 0.78) / 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
float row = mod(u_head - floor((1.0 - v_uv.y) * u_rows) + u_rows, u_rows);
|
||||||
|
float rowY = (row + 0.5) / u_rows;
|
||||||
|
float level = texture(u_waterfall, vec3(v_uv.x, rowY, u_band)).r;
|
||||||
|
vec3 color = palette(level);
|
||||||
|
float grid = step(0.985, fract(v_uv.x * 8.0)) + step(0.985, fract(v_uv.y * 4.0));
|
||||||
|
out_color = vec4(color + min(grid, 1.0) * 0.055, 1.0);
|
||||||
|
}`
|
||||||
|
|
||||||
|
const CURVE_VERTEX = `#version 300 es
|
||||||
|
precision highp float;
|
||||||
|
uniform sampler2D u_latest;
|
||||||
|
uniform vec2 u_resolution;
|
||||||
|
uniform vec4 u_rect;
|
||||||
|
uniform int u_bins;
|
||||||
|
uniform int u_band;
|
||||||
|
out float v_level;
|
||||||
|
void main() {
|
||||||
|
float x = float(gl_VertexID) / float(u_bins - 1);
|
||||||
|
float level = texelFetch(u_latest, ivec2(gl_VertexID, u_band), 0).r;
|
||||||
|
v_level = level;
|
||||||
|
vec2 pixel = u_rect.xy + vec2(x, 1.0 - level) * u_rect.zw;
|
||||||
|
vec2 clip = pixel / u_resolution * 2.0 - 1.0;
|
||||||
|
gl_Position = vec4(clip.x, -clip.y, 0.0, 1.0);
|
||||||
|
}`
|
||||||
|
|
||||||
|
const CURVE_FRAGMENT = `#version 300 es
|
||||||
|
precision highp float;
|
||||||
|
uniform float u_threshold;
|
||||||
|
in float v_level;
|
||||||
|
out vec4 out_color;
|
||||||
|
void main() {
|
||||||
|
vec3 normal = vec3(0.25, 0.89, 0.83);
|
||||||
|
vec3 alarm = vec3(1.0, 0.47, 0.25);
|
||||||
|
out_color = vec4(mix(normal, alarm, step(u_threshold, v_level)), 1.0);
|
||||||
|
}`
|
||||||
|
|
||||||
|
const GRID_FRAGMENT = `#version 300 es
|
||||||
|
precision highp float;
|
||||||
|
in vec2 v_uv;
|
||||||
|
out vec4 out_color;
|
||||||
|
void main() {
|
||||||
|
float major = step(0.988, fract(v_uv.x * 8.0)) + step(0.988, fract(v_uv.y * 4.0));
|
||||||
|
float edge = step(v_uv.x, 0.004) + step(0.996, v_uv.x) + step(v_uv.y, 0.008) + step(0.992, v_uv.y);
|
||||||
|
float line = min(1.0, major * 0.36 + edge * 0.55);
|
||||||
|
vec3 base = vec3(0.035, 0.055, 0.067);
|
||||||
|
out_color = vec4(base + line * vec3(0.09, 0.12, 0.13), 1.0);
|
||||||
|
}`
|
||||||
|
|
||||||
|
interface Programs {
|
||||||
|
waterfall: WebGLProgram
|
||||||
|
curve: WebGLProgram
|
||||||
|
grid: WebGLProgram
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TimerQueryExtension {
|
||||||
|
readonly TIME_ELAPSED_EXT: number
|
||||||
|
readonly GPU_DISJOINT_EXT: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SpectrumRenderer {
|
||||||
|
private readonly gl: WebGL2RenderingContext
|
||||||
|
private readonly programs: Programs
|
||||||
|
private readonly quad: WebGLBuffer
|
||||||
|
private latestTexture: WebGLTexture
|
||||||
|
private waterfallTexture: WebGLTexture
|
||||||
|
private config: SpectrumConfig
|
||||||
|
private head = 0
|
||||||
|
private hasFrame = false
|
||||||
|
private threshold = -72
|
||||||
|
private rects: BandRect[] = []
|
||||||
|
private dpr = 1
|
||||||
|
private readonly timerExtension: TimerQueryExtension | null
|
||||||
|
private pendingTimerQuery: WebGLQuery | null = null
|
||||||
|
private gpuMs = 0
|
||||||
|
|
||||||
|
constructor(private readonly canvas: HTMLCanvasElement, config: SpectrumConfig) {
|
||||||
|
const gl = canvas.getContext('webgl2', { antialias: false, alpha: false, desynchronized: true })
|
||||||
|
if (!gl) throw new Error('当前浏览器或显卡不支持 WebGL2')
|
||||||
|
this.gl = gl
|
||||||
|
this.timerExtension = gl.getExtension('EXT_disjoint_timer_query_webgl2') as TimerQueryExtension | null
|
||||||
|
this.config = config
|
||||||
|
this.programs = {
|
||||||
|
waterfall: this.createProgram(QUAD_VERTEX, WATERFALL_FRAGMENT),
|
||||||
|
curve: this.createProgram(CURVE_VERTEX, CURVE_FRAGMENT),
|
||||||
|
grid: this.createProgram(QUAD_VERTEX, GRID_FRAGMENT),
|
||||||
|
}
|
||||||
|
this.quad = this.createQuad()
|
||||||
|
this.latestTexture = this.createTexture()
|
||||||
|
this.waterfallTexture = this.createTexture()
|
||||||
|
this.allocateTextures()
|
||||||
|
}
|
||||||
|
|
||||||
|
configure(config: SpectrumConfig): void {
|
||||||
|
this.config = config
|
||||||
|
this.head = 0
|
||||||
|
this.hasFrame = false
|
||||||
|
this.allocateTextures()
|
||||||
|
}
|
||||||
|
|
||||||
|
setThreshold(value: number): void {
|
||||||
|
this.threshold = value
|
||||||
|
}
|
||||||
|
|
||||||
|
setLayout(rects: BandRect[], cssWidth: number, cssHeight: number): void {
|
||||||
|
const maxDimension = this.gl.getParameter(this.gl.MAX_RENDERBUFFER_SIZE) as number
|
||||||
|
const maxCssDimension = Math.max(cssWidth, cssHeight, 1)
|
||||||
|
this.dpr = Math.min(window.devicePixelRatio || 1, 1.5, maxDimension / maxCssDimension)
|
||||||
|
const width = Math.max(1, Math.floor(cssWidth * this.dpr))
|
||||||
|
const height = Math.max(1, Math.floor(cssHeight * this.dpr))
|
||||||
|
if (this.canvas.width !== width || this.canvas.height !== height) {
|
||||||
|
this.canvas.width = width
|
||||||
|
this.canvas.height = height
|
||||||
|
}
|
||||||
|
this.rects = rects.map((rect) => ({
|
||||||
|
x: rect.x * this.dpr,
|
||||||
|
y: rect.y * this.dpr,
|
||||||
|
width: rect.width * this.dpr,
|
||||||
|
height: rect.height * this.dpr,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
upload(buffer: ArrayBuffer): number {
|
||||||
|
const started = performance.now()
|
||||||
|
const gl = this.gl
|
||||||
|
const data = new Uint8Array(buffer)
|
||||||
|
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1)
|
||||||
|
gl.bindTexture(gl.TEXTURE_2D, this.latestTexture)
|
||||||
|
gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, this.config.bins, this.config.bandCount, gl.RED, gl.UNSIGNED_BYTE, data)
|
||||||
|
|
||||||
|
this.head = (this.head + 1) % this.config.waterfallRows
|
||||||
|
gl.bindTexture(gl.TEXTURE_2D_ARRAY, this.waterfallTexture)
|
||||||
|
for (let band = 0; band < this.config.bandCount; band += 1) {
|
||||||
|
const row = new Uint8Array(buffer, band * this.config.bins, this.config.bins)
|
||||||
|
gl.texSubImage3D(
|
||||||
|
gl.TEXTURE_2D_ARRAY,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
this.head,
|
||||||
|
band,
|
||||||
|
this.config.bins,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
gl.RED,
|
||||||
|
gl.UNSIGNED_BYTE,
|
||||||
|
row,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
this.hasFrame = true
|
||||||
|
return performance.now() - started
|
||||||
|
}
|
||||||
|
|
||||||
|
render(): number {
|
||||||
|
const started = performance.now()
|
||||||
|
const gl = this.gl
|
||||||
|
this.pollGpuTimer()
|
||||||
|
gl.viewport(0, 0, this.canvas.width, this.canvas.height)
|
||||||
|
gl.disable(gl.DEPTH_TEST)
|
||||||
|
gl.disable(gl.BLEND)
|
||||||
|
gl.clearColor(0.039, 0.051, 0.059, 1)
|
||||||
|
gl.clear(gl.COLOR_BUFFER_BIT)
|
||||||
|
if (!this.hasFrame || this.rects.length === 0) return performance.now() - started
|
||||||
|
|
||||||
|
const timerQuery = this.timerExtension && !this.pendingTimerQuery ? gl.createQuery() : null
|
||||||
|
if (timerQuery && this.timerExtension) gl.beginQuery(this.timerExtension.TIME_ELAPSED_EXT, timerQuery)
|
||||||
|
|
||||||
|
for (let band = 0; band < Math.min(this.rects.length, this.config.bandCount); band += 1) {
|
||||||
|
const panel = this.rects[band]
|
||||||
|
const header = 28 * this.dpr
|
||||||
|
const gap = 5 * this.dpr
|
||||||
|
const usable = Math.max(20, panel.height - header)
|
||||||
|
const curveHeight = usable * 0.39
|
||||||
|
const curve = { x: panel.x, y: panel.y + header, width: panel.width, height: curveHeight }
|
||||||
|
const waterfall = { x: panel.x, y: curve.y + curve.height + gap, width: panel.width, height: usable - curveHeight - gap }
|
||||||
|
this.drawQuad(this.programs.grid, curve)
|
||||||
|
this.drawWaterfall(waterfall, band)
|
||||||
|
this.drawCurve(curve, band)
|
||||||
|
}
|
||||||
|
if (timerQuery && this.timerExtension) {
|
||||||
|
gl.endQuery(this.timerExtension.TIME_ELAPSED_EXT)
|
||||||
|
this.pendingTimerQuery = timerQuery
|
||||||
|
}
|
||||||
|
return performance.now() - started
|
||||||
|
}
|
||||||
|
|
||||||
|
getGpuMs(): number {
|
||||||
|
return this.gpuMs
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
const gl = this.gl
|
||||||
|
gl.deleteTexture(this.latestTexture)
|
||||||
|
gl.deleteTexture(this.waterfallTexture)
|
||||||
|
gl.deleteBuffer(this.quad)
|
||||||
|
if (this.pendingTimerQuery) gl.deleteQuery(this.pendingTimerQuery)
|
||||||
|
Object.values(this.programs).forEach((program) => gl.deleteProgram(program))
|
||||||
|
}
|
||||||
|
|
||||||
|
private allocateTextures(): void {
|
||||||
|
const gl = this.gl
|
||||||
|
const maxTextureSize = gl.getParameter(gl.MAX_TEXTURE_SIZE) as number
|
||||||
|
const maxLayers = gl.getParameter(gl.MAX_ARRAY_TEXTURE_LAYERS) as number
|
||||||
|
if (this.config.bins > maxTextureSize || this.config.waterfallRows > maxTextureSize || this.config.bandCount > maxLayers) {
|
||||||
|
throw new Error(`纹理规模超过 GPU 限制:最大尺寸 ${maxTextureSize},最大层数 ${maxLayers}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
gl.bindTexture(gl.TEXTURE_2D, this.latestTexture)
|
||||||
|
this.setTextureParameters(gl.TEXTURE_2D)
|
||||||
|
gl.texImage2D(gl.TEXTURE_2D, 0, gl.R8, this.config.bins, this.config.bandCount, 0, gl.RED, gl.UNSIGNED_BYTE, null)
|
||||||
|
gl.bindTexture(gl.TEXTURE_2D_ARRAY, this.waterfallTexture)
|
||||||
|
this.setTextureParameters(gl.TEXTURE_2D_ARRAY)
|
||||||
|
gl.texImage3D(
|
||||||
|
gl.TEXTURE_2D_ARRAY,
|
||||||
|
0,
|
||||||
|
gl.R8,
|
||||||
|
this.config.bins,
|
||||||
|
this.config.waterfallRows,
|
||||||
|
this.config.bandCount,
|
||||||
|
0,
|
||||||
|
gl.RED,
|
||||||
|
gl.UNSIGNED_BYTE,
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private setTextureParameters(target: number): void {
|
||||||
|
const gl = this.gl
|
||||||
|
gl.texParameteri(target, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
|
||||||
|
gl.texParameteri(target, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
|
||||||
|
gl.texParameteri(target, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
|
||||||
|
gl.texParameteri(target, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
|
||||||
|
}
|
||||||
|
|
||||||
|
private drawQuad(program: WebGLProgram, rect: BandRect): void {
|
||||||
|
const gl = this.gl
|
||||||
|
gl.useProgram(program)
|
||||||
|
this.bindQuad(program)
|
||||||
|
gl.uniform2f(gl.getUniformLocation(program, 'u_resolution'), this.canvas.width, this.canvas.height)
|
||||||
|
gl.uniform4f(gl.getUniformLocation(program, 'u_rect'), rect.x, rect.y, rect.width, rect.height)
|
||||||
|
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
private drawWaterfall(rect: BandRect, band: number): void {
|
||||||
|
const gl = this.gl
|
||||||
|
const program = this.programs.waterfall
|
||||||
|
gl.useProgram(program)
|
||||||
|
this.bindQuad(program)
|
||||||
|
gl.activeTexture(gl.TEXTURE0)
|
||||||
|
gl.bindTexture(gl.TEXTURE_2D_ARRAY, this.waterfallTexture)
|
||||||
|
gl.uniform1i(gl.getUniformLocation(program, 'u_waterfall'), 0)
|
||||||
|
gl.uniform1f(gl.getUniformLocation(program, 'u_band'), band)
|
||||||
|
gl.uniform1f(gl.getUniformLocation(program, 'u_rows'), this.config.waterfallRows)
|
||||||
|
gl.uniform1f(gl.getUniformLocation(program, 'u_head'), this.head)
|
||||||
|
gl.uniform2f(gl.getUniformLocation(program, 'u_resolution'), this.canvas.width, this.canvas.height)
|
||||||
|
gl.uniform4f(gl.getUniformLocation(program, 'u_rect'), rect.x, rect.y, rect.width, rect.height)
|
||||||
|
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
private drawCurve(rect: BandRect, band: number): void {
|
||||||
|
const gl = this.gl
|
||||||
|
const program = this.programs.curve
|
||||||
|
gl.useProgram(program)
|
||||||
|
gl.activeTexture(gl.TEXTURE0)
|
||||||
|
gl.bindTexture(gl.TEXTURE_2D, this.latestTexture)
|
||||||
|
gl.uniform1i(gl.getUniformLocation(program, 'u_latest'), 0)
|
||||||
|
gl.uniform2f(gl.getUniformLocation(program, 'u_resolution'), this.canvas.width, this.canvas.height)
|
||||||
|
gl.uniform4f(gl.getUniformLocation(program, 'u_rect'), rect.x, rect.y, rect.width, rect.height)
|
||||||
|
gl.uniform1i(gl.getUniformLocation(program, 'u_bins'), this.config.bins)
|
||||||
|
gl.uniform1i(gl.getUniformLocation(program, 'u_band'), band)
|
||||||
|
gl.uniform1f(gl.getUniformLocation(program, 'u_threshold'), (this.threshold - this.config.minDbm) / (this.config.maxDbm - this.config.minDbm))
|
||||||
|
gl.drawArrays(gl.LINE_STRIP, 0, this.config.bins)
|
||||||
|
}
|
||||||
|
|
||||||
|
private bindQuad(program: WebGLProgram): void {
|
||||||
|
const gl = this.gl
|
||||||
|
const location = gl.getAttribLocation(program, 'a_position')
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, this.quad)
|
||||||
|
gl.enableVertexAttribArray(location)
|
||||||
|
gl.vertexAttribPointer(location, 2, gl.FLOAT, false, 0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
private pollGpuTimer(): void {
|
||||||
|
if (!this.timerExtension || !this.pendingTimerQuery) return
|
||||||
|
const gl = this.gl
|
||||||
|
const available = gl.getQueryParameter(this.pendingTimerQuery, gl.QUERY_RESULT_AVAILABLE) as boolean
|
||||||
|
const disjoint = gl.getParameter(this.timerExtension.GPU_DISJOINT_EXT) as boolean
|
||||||
|
if (!available && !disjoint) return
|
||||||
|
if (available && !disjoint) {
|
||||||
|
const elapsedNanoseconds = gl.getQueryParameter(this.pendingTimerQuery, gl.QUERY_RESULT) as number
|
||||||
|
this.gpuMs = elapsedNanoseconds / 1_000_000
|
||||||
|
}
|
||||||
|
gl.deleteQuery(this.pendingTimerQuery)
|
||||||
|
this.pendingTimerQuery = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private createQuad(): WebGLBuffer {
|
||||||
|
const gl = this.gl
|
||||||
|
const buffer = gl.createBuffer()
|
||||||
|
if (!buffer) throw new Error('无法创建 WebGL 顶点缓冲区')
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, buffer)
|
||||||
|
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW)
|
||||||
|
return buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
private createTexture(): WebGLTexture {
|
||||||
|
const texture = this.gl.createTexture()
|
||||||
|
if (!texture) throw new Error('无法创建 WebGL 纹理')
|
||||||
|
return texture
|
||||||
|
}
|
||||||
|
|
||||||
|
private createProgram(vertexSource: string, fragmentSource: string): WebGLProgram {
|
||||||
|
const gl = this.gl
|
||||||
|
const vertex = this.compileShader(gl.VERTEX_SHADER, vertexSource)
|
||||||
|
const fragment = this.compileShader(gl.FRAGMENT_SHADER, fragmentSource)
|
||||||
|
const program = gl.createProgram()
|
||||||
|
if (!program) throw new Error('无法创建 WebGL 程序')
|
||||||
|
gl.attachShader(program, vertex)
|
||||||
|
gl.attachShader(program, fragment)
|
||||||
|
gl.linkProgram(program)
|
||||||
|
gl.deleteShader(vertex)
|
||||||
|
gl.deleteShader(fragment)
|
||||||
|
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
||||||
|
throw new Error(gl.getProgramInfoLog(program) ?? 'WebGL 程序链接失败')
|
||||||
|
}
|
||||||
|
return program
|
||||||
|
}
|
||||||
|
|
||||||
|
private compileShader(type: number, source: string): WebGLShader {
|
||||||
|
const gl = this.gl
|
||||||
|
const shader = gl.createShader(type)
|
||||||
|
if (!shader) throw new Error('无法创建 WebGL 着色器')
|
||||||
|
gl.shaderSource(shader, source)
|
||||||
|
gl.compileShader(shader)
|
||||||
|
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
||||||
|
throw new Error(gl.getShaderInfoLog(shader) ?? 'WebGL 着色器编译失败')
|
||||||
|
}
|
||||||
|
return shader
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, watch } from 'vue'
|
||||||
|
import { Pause, Play, RotateCcw, SlidersHorizontal } from '@lucide/vue'
|
||||||
|
import type { SpectrumConfig } from '../types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
config: SpectrumConfig
|
||||||
|
paused: boolean
|
||||||
|
threshold: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
apply: [config: SpectrumConfig]
|
||||||
|
'update:paused': [value: boolean]
|
||||||
|
'update:threshold': [value: number]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const draft = reactive<SpectrumConfig>({ ...props.config })
|
||||||
|
watch(() => props.config, (value) => Object.assign(draft, value), { deep: true })
|
||||||
|
|
||||||
|
const loadLabel = computed(() => {
|
||||||
|
const pointsPerSecond = draft.bandCount * draft.bins * draft.updateHz
|
||||||
|
return pointsPerSecond >= 1_000_000
|
||||||
|
? `${(pointsPerSecond / 1_000_000).toFixed(1)} M点/s`
|
||||||
|
: `${Math.round(pointsPerSecond / 1000)} K点/s`
|
||||||
|
})
|
||||||
|
|
||||||
|
function reset(): void {
|
||||||
|
Object.assign(draft, {
|
||||||
|
bandCount: 32,
|
||||||
|
bins: 1024,
|
||||||
|
updateHz: 30,
|
||||||
|
waterfallRows: 96,
|
||||||
|
startMHz: 20,
|
||||||
|
endMHz: 6020,
|
||||||
|
minDbm: -120,
|
||||||
|
maxDbm: -20,
|
||||||
|
simulationProfile: 'mixed',
|
||||||
|
noiseFloorDbm: -104,
|
||||||
|
simulationSeed: 20260804,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="control-panel" aria-label="性能测试参数">
|
||||||
|
<div class="control-title">
|
||||||
|
<SlidersHorizontal :size="17" aria-hidden="true" />
|
||||||
|
<span>负载配置</span>
|
||||||
|
<output>{{ loadLabel }}</output>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<span>信号场景</span>
|
||||||
|
<select v-model="draft.simulationProfile">
|
||||||
|
<option value="mixed">混合业务</option>
|
||||||
|
<option value="quiet">安静频段</option>
|
||||||
|
<option value="dense">密集信号</option>
|
||||||
|
<option value="burst">突发/跳频</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="frequency-input">
|
||||||
|
<span>噪声底 dBm</span>
|
||||||
|
<input v-model.number="draft.noiseFloorDbm" type="number" :min="draft.minDbm + 5" :max="draft.maxDbm - 20" step="1" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<span>窗口</span>
|
||||||
|
<select v-model.number="draft.bandCount">
|
||||||
|
<option v-for="value in [1, 4, 8, 16, 32, 64]" :key="value" :value="value">
|
||||||
|
{{ value === 1 ? '全频 1' : value }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<span>FFT 点数</span>
|
||||||
|
<select v-model.number="draft.bins">
|
||||||
|
<option v-for="value in [512, 1024, 2048, 4096, 8192]" :key="value" :value="value">{{ value }}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<span>刷新率</span>
|
||||||
|
<select v-model.number="draft.updateHz">
|
||||||
|
<option v-for="value in [10, 20, 30, 60]" :key="value" :value="value">{{ value }} Hz</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<span>瀑布深度</span>
|
||||||
|
<select v-model.number="draft.waterfallRows">
|
||||||
|
<option v-for="value in [64, 96, 128, 256, 512]" :key="value" :value="value">{{ value }} 行</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="frequency-input">
|
||||||
|
<span>起始 MHz</span>
|
||||||
|
<input v-model.number="draft.startMHz" type="number" min="0" step="1" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="frequency-input">
|
||||||
|
<span>结束 MHz</span>
|
||||||
|
<input v-model.number="draft.endMHz" type="number" :min="draft.startMHz + 1" step="1" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="threshold-control">
|
||||||
|
<span>阈值 <output>{{ threshold }} dBm</output></span>
|
||||||
|
<input
|
||||||
|
:value="threshold"
|
||||||
|
type="range"
|
||||||
|
:min="config.minDbm"
|
||||||
|
:max="config.maxDbm"
|
||||||
|
step="1"
|
||||||
|
@input="emit('update:threshold', Number(($event.target as HTMLInputElement).value))"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="control-actions">
|
||||||
|
<button class="icon-button" type="button" title="恢复基准参数" aria-label="恢复基准参数" @click="reset">
|
||||||
|
<RotateCcw :size="16" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<button class="run-button" type="button" @click="emit('update:paused', !paused)">
|
||||||
|
<Play v-if="paused" :size="16" aria-hidden="true" />
|
||||||
|
<Pause v-else :size="16" aria-hidden="true" />
|
||||||
|
{{ paused ? '继续' : '暂停' }}
|
||||||
|
</button>
|
||||||
|
<button class="apply-button" type="button" :disabled="draft.endMHz <= draft.startMHz" @click="emit('apply', { ...draft })">
|
||||||
|
应用负载
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||||
|
import { SpectrumRenderer } from '../SpectrumRenderer.ts'
|
||||||
|
import type { BandRect, PerformanceMetrics, Selection, SelectionResult, SpectrumConfig, SpectrumFrame } from '../types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
config: SpectrumConfig
|
||||||
|
threshold: number
|
||||||
|
paused: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
metrics: [value: PerformanceMetrics]
|
||||||
|
selection: [value: SelectionResult | null]
|
||||||
|
error: [message: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const canvas = ref<HTMLCanvasElement | null>(null)
|
||||||
|
const surface = ref<HTMLElement | null>(null)
|
||||||
|
const panelElements: HTMLElement[] = []
|
||||||
|
const selection = reactive<Selection>({ band: -1, x0: 0, x1: 0, y0: 0, y1: 0 })
|
||||||
|
const dragging = ref(false)
|
||||||
|
|
||||||
|
let renderer: SpectrumRenderer | null = null
|
||||||
|
let worker: Worker | null = null
|
||||||
|
let pendingFrame: SpectrumFrame | null = null
|
||||||
|
let resizeObserver: ResizeObserver | null = null
|
||||||
|
let animationId = 0
|
||||||
|
let frameCounter = 0
|
||||||
|
let receivedCounter = 0
|
||||||
|
let receivedBytes = 0
|
||||||
|
let droppedFrames = 0
|
||||||
|
let lastMetricsAt = performance.now()
|
||||||
|
let lastUploadMs = 0
|
||||||
|
let lastRenderMs = 0
|
||||||
|
let lastLatencyMs = 0
|
||||||
|
let lastSequence = 0
|
||||||
|
let configId = 0
|
||||||
|
|
||||||
|
const bands = computed(() => Array.from({ length: props.config.bandCount }, (_, index) => index))
|
||||||
|
const columns = computed(() => props.config.bandCount === 1 ? 1 : props.config.bandCount <= 4 ? 2 : 4)
|
||||||
|
const panelHeight = computed(() => props.config.bandCount === 1 ? 560 : props.config.bandCount <= 8 ? 260 : 176)
|
||||||
|
const thresholdRatio = computed(() => 1 - (props.threshold - props.config.minDbm) / (props.config.maxDbm - props.config.minDbm))
|
||||||
|
|
||||||
|
function setPanelRef(element: Element | null, index: number): void {
|
||||||
|
if (element instanceof HTMLElement) panelElements[index] = element
|
||||||
|
}
|
||||||
|
|
||||||
|
function frequencyRange(band: number): [number, number] {
|
||||||
|
const width = (props.config.endMHz - props.config.startMHz) / props.config.bandCount
|
||||||
|
return [props.config.startMHz + band * width, props.config.startMHz + (band + 1) * width]
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFrequency(value: number): string {
|
||||||
|
return value >= 1000 ? `${(value / 1000).toFixed(3)} GHz` : `${value.toFixed(value < 100 ? 2 : 1)} MHz`
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLayout(): void {
|
||||||
|
if (!surface.value || !canvas.value || !renderer) return
|
||||||
|
const canvasRect = canvas.value.getBoundingClientRect()
|
||||||
|
const rects: BandRect[] = panelElements.slice(0, props.config.bandCount).map((element) => {
|
||||||
|
const rect = element.getBoundingClientRect()
|
||||||
|
return { x: rect.left - canvasRect.left, y: rect.top - canvasRect.top, width: rect.width, height: rect.height }
|
||||||
|
})
|
||||||
|
renderer.setLayout(rects, canvas.value.clientWidth, canvas.value.clientHeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
function recycle(buffer: ArrayBuffer): void {
|
||||||
|
worker?.postMessage({ type: 'recycle', buffer }, [buffer])
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFrame(frame: SpectrumFrame): void {
|
||||||
|
if (frame.configId !== configId) {
|
||||||
|
recycle(frame.buffer)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
receivedCounter += 1
|
||||||
|
receivedBytes += frame.buffer.byteLength
|
||||||
|
if (pendingFrame) {
|
||||||
|
recycle(pendingFrame.buffer)
|
||||||
|
droppedFrames += 1
|
||||||
|
}
|
||||||
|
pendingFrame = frame
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLoop(now: number): void {
|
||||||
|
animationId = requestAnimationFrame(renderLoop)
|
||||||
|
if (!renderer) return
|
||||||
|
|
||||||
|
if (pendingFrame) {
|
||||||
|
const frame = pendingFrame
|
||||||
|
pendingFrame = null
|
||||||
|
lastUploadMs = renderer.upload(frame.buffer)
|
||||||
|
lastLatencyMs = Math.max(0, Date.now() - frame.generatedAt)
|
||||||
|
lastSequence = frame.sequence
|
||||||
|
recycle(frame.buffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
lastRenderMs = renderer.render()
|
||||||
|
frameCounter += 1
|
||||||
|
|
||||||
|
const elapsed = now - lastMetricsAt
|
||||||
|
if (elapsed >= 1000) {
|
||||||
|
emit('metrics', {
|
||||||
|
fps: frameCounter * 1000 / elapsed,
|
||||||
|
receivedHz: receivedCounter * 1000 / elapsed,
|
||||||
|
throughputMbps: receivedBytes * 8 / elapsed / 1000,
|
||||||
|
uploadMs: lastUploadMs,
|
||||||
|
renderMs: lastRenderMs,
|
||||||
|
gpuMs: renderer.getGpuMs(),
|
||||||
|
latencyMs: lastLatencyMs,
|
||||||
|
droppedFrames,
|
||||||
|
textureMemoryMb: props.config.bandCount * props.config.bins * (props.config.waterfallRows + 1) / 1024 / 1024,
|
||||||
|
sequence: lastSequence,
|
||||||
|
})
|
||||||
|
frameCounter = 0
|
||||||
|
receivedCounter = 0
|
||||||
|
receivedBytes = 0
|
||||||
|
lastMetricsAt = now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function localPoint(event: PointerEvent): { x: number; y: number } {
|
||||||
|
const target = event.currentTarget as HTMLElement
|
||||||
|
const rect = target.getBoundingClientRect()
|
||||||
|
return {
|
||||||
|
x: Math.max(0, Math.min(rect.width, event.clientX - rect.left)),
|
||||||
|
y: Math.max(0, Math.min(rect.height, event.clientY - rect.top)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startSelection(event: PointerEvent, band: number): void {
|
||||||
|
const point = localPoint(event)
|
||||||
|
selection.band = band
|
||||||
|
selection.x0 = selection.x1 = point.x
|
||||||
|
selection.y0 = selection.y1 = point.y
|
||||||
|
dragging.value = true
|
||||||
|
;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveSelection(event: PointerEvent, band: number): void {
|
||||||
|
if (!dragging.value || selection.band !== band) return
|
||||||
|
const point = localPoint(event)
|
||||||
|
selection.x1 = point.x
|
||||||
|
selection.y1 = point.y
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishSelection(event: PointerEvent, band: number): void {
|
||||||
|
if (!dragging.value || selection.band !== band) return
|
||||||
|
moveSelection(event, band)
|
||||||
|
dragging.value = false
|
||||||
|
const panel = event.currentTarget as HTMLElement
|
||||||
|
const width = Math.max(1, panel.clientWidth)
|
||||||
|
const [bandStart, bandEnd] = frequencyRange(band)
|
||||||
|
const xMin = Math.min(selection.x0, selection.x1) / width
|
||||||
|
const xMax = Math.max(selection.x0, selection.x1) / width
|
||||||
|
const result: SelectionResult = {
|
||||||
|
band,
|
||||||
|
startMHz: bandStart + (bandEnd - bandStart) * xMin,
|
||||||
|
endMHz: bandStart + (bandEnd - bandStart) * xMax,
|
||||||
|
}
|
||||||
|
const curveTop = 28
|
||||||
|
const curveHeight = (panel.clientHeight - curveTop) * 0.39
|
||||||
|
const yMin = Math.min(selection.y0, selection.y1)
|
||||||
|
const yMax = Math.max(selection.y0, selection.y1)
|
||||||
|
if (yMin <= curveTop + curveHeight && yMax >= curveTop) {
|
||||||
|
const toDbm = (y: number) => props.config.maxDbm - Math.max(0, Math.min(1, (y - curveTop) / curveHeight)) * (props.config.maxDbm - props.config.minDbm)
|
||||||
|
result.highDbm = toDbm(Math.max(curveTop, yMin))
|
||||||
|
result.lowDbm = toDbm(Math.min(curveTop + curveHeight, yMax))
|
||||||
|
}
|
||||||
|
emit('selection', result)
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectionStyle(index: number): Record<string, string> {
|
||||||
|
if (selection.band !== index) return { display: 'none' }
|
||||||
|
return {
|
||||||
|
left: `${Math.min(selection.x0, selection.x1)}px`,
|
||||||
|
top: `${Math.min(selection.y0, selection.y1)}px`,
|
||||||
|
width: `${Math.abs(selection.x1 - selection.x0)}px`,
|
||||||
|
height: `${Math.abs(selection.y1 - selection.y0)}px`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetCounters(): void {
|
||||||
|
frameCounter = 0
|
||||||
|
receivedCounter = 0
|
||||||
|
receivedBytes = 0
|
||||||
|
droppedFrames = 0
|
||||||
|
lastMetricsAt = performance.now()
|
||||||
|
emit('selection', null)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
if (!canvas.value) return
|
||||||
|
renderer = new SpectrumRenderer(canvas.value, props.config)
|
||||||
|
renderer.setThreshold(props.threshold)
|
||||||
|
worker = new Worker(new URL('../spectrum.worker.ts', import.meta.url), { type: 'module' })
|
||||||
|
worker.onmessage = (event: MessageEvent<SpectrumFrame>) => {
|
||||||
|
if (event.data.type === 'frame') handleFrame(event.data)
|
||||||
|
}
|
||||||
|
worker.onerror = (event) => emit('error', event.message || '模拟数据 Worker 运行失败')
|
||||||
|
configId += 1
|
||||||
|
worker.postMessage({ type: 'configure', config: { ...props.config }, configId })
|
||||||
|
worker.postMessage({ type: 'pause', paused: props.paused })
|
||||||
|
await nextTick()
|
||||||
|
updateLayout()
|
||||||
|
resizeObserver = new ResizeObserver(updateLayout)
|
||||||
|
if (surface.value) resizeObserver.observe(surface.value)
|
||||||
|
animationId = requestAnimationFrame(renderLoop)
|
||||||
|
} catch (error) {
|
||||||
|
emit('error', error instanceof Error ? error.message : String(error))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.threshold, (value) => renderer?.setThreshold(value))
|
||||||
|
watch(() => props.paused, (value) => worker?.postMessage({ type: 'pause', paused: value }))
|
||||||
|
watch(() => props.config, async (value) => {
|
||||||
|
try {
|
||||||
|
if (pendingFrame) {
|
||||||
|
recycle(pendingFrame.buffer)
|
||||||
|
pendingFrame = null
|
||||||
|
}
|
||||||
|
renderer?.configure(value)
|
||||||
|
configId += 1
|
||||||
|
worker?.postMessage({ type: 'configure', config: { ...value }, configId })
|
||||||
|
panelElements.length = 0
|
||||||
|
resetCounters()
|
||||||
|
await nextTick()
|
||||||
|
updateLayout()
|
||||||
|
} catch (error) {
|
||||||
|
emit('error', error instanceof Error ? error.message : String(error))
|
||||||
|
}
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
cancelAnimationFrame(animationId)
|
||||||
|
resizeObserver?.disconnect()
|
||||||
|
if (pendingFrame) recycle(pendingFrame.buffer)
|
||||||
|
worker?.terminate()
|
||||||
|
renderer?.destroy()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section
|
||||||
|
ref="surface"
|
||||||
|
class="spectrum-surface"
|
||||||
|
:style="{ '--columns': columns, '--panel-height': `${panelHeight}px` }"
|
||||||
|
aria-label="实时频谱曲线与瀑布图"
|
||||||
|
>
|
||||||
|
<canvas ref="canvas" class="spectrum-canvas" aria-label="WebGL2 频谱渲染画布" />
|
||||||
|
<div class="band-grid">
|
||||||
|
<article
|
||||||
|
v-for="band in bands"
|
||||||
|
:key="band"
|
||||||
|
:ref="(element) => setPanelRef(element as Element | null, band)"
|
||||||
|
class="band-panel"
|
||||||
|
:aria-label="`频率窗口 ${band + 1}`"
|
||||||
|
@pointerdown="startSelection($event, band)"
|
||||||
|
@pointermove="moveSelection($event, band)"
|
||||||
|
@pointerup="finishSelection($event, band)"
|
||||||
|
@pointercancel="dragging = false"
|
||||||
|
>
|
||||||
|
<header>
|
||||||
|
<strong>{{ config.bandCount === 1 ? '全频段' : `频段 ${String(band + 1).padStart(2, '0')}` }}</strong>
|
||||||
|
<span>{{ formatFrequency(frequencyRange(band)[0]) }} - {{ formatFrequency(frequencyRange(band)[1]) }}</span>
|
||||||
|
<small>{{ config.bins }} FFT</small>
|
||||||
|
</header>
|
||||||
|
<div class="curve-interaction" aria-hidden="true">
|
||||||
|
<span class="threshold-line" :style="{ top: `${thresholdRatio * 100}%` }">
|
||||||
|
<i>{{ threshold }}</i>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span class="chart-divider" aria-hidden="true">瀑布</span>
|
||||||
|
<div class="selection-box" :style="selectionStyle(band)" aria-hidden="true" />
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Activity, Cpu, Crosshair, Database, Gauge, Timer, Upload } from '@lucide/vue'
|
||||||
|
import type { PerformanceMetrics, SelectionResult } from '../types'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
metrics: PerformanceMetrics
|
||||||
|
paused: boolean
|
||||||
|
selection: SelectionResult | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function formatSelection(selection: SelectionResult): string {
|
||||||
|
const level = selection.highDbm === undefined
|
||||||
|
? ''
|
||||||
|
: ` / ${selection.lowDbm?.toFixed(1)} 至 ${selection.highDbm.toFixed(1)} dBm`
|
||||||
|
return `窗口 ${selection.band + 1} / ${selection.startMHz.toFixed(3)} 至 ${selection.endMHz.toFixed(3)} MHz${level}`
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="status-bar" aria-label="实时性能指标">
|
||||||
|
<div class="run-state" :class="{ paused }">
|
||||||
|
<span class="state-dot" />
|
||||||
|
{{ paused ? '数据已暂停' : '实时运行' }}
|
||||||
|
</div>
|
||||||
|
<dl>
|
||||||
|
<div><Gauge :size="14" /><dt>FPS</dt><dd>{{ metrics.fps.toFixed(0) }}</dd></div>
|
||||||
|
<div><Activity :size="14" /><dt>接收</dt><dd>{{ metrics.receivedHz.toFixed(1) }} Hz</dd></div>
|
||||||
|
<div title="Worker 输出的解码后 8 位强度帧"><Database :size="14" /><dt>强度流</dt><dd>{{ metrics.throughputMbps.toFixed(2) }} Mb/s</dd></div>
|
||||||
|
<div><Upload :size="14" /><dt>上传</dt><dd>{{ metrics.uploadMs.toFixed(2) }} ms</dd></div>
|
||||||
|
<div><Cpu :size="14" /><dt>绘制</dt><dd>{{ metrics.renderMs.toFixed(2) }} ms</dd></div>
|
||||||
|
<div><Cpu :size="14" /><dt>GPU</dt><dd>{{ metrics.gpuMs.toFixed(2) }} ms</dd></div>
|
||||||
|
<div><Timer :size="14" /><dt>延迟</dt><dd>{{ metrics.latencyMs.toFixed(0) }} ms</dd></div>
|
||||||
|
<div><Database :size="14" /><dt>纹理</dt><dd>{{ metrics.textureMemoryMb.toFixed(1) }} MiB</dd></div>
|
||||||
|
<div><Activity :size="14" /><dt>丢弃</dt><dd>{{ metrics.droppedFrames }}</dd></div>
|
||||||
|
</dl>
|
||||||
|
<div class="selection-readout" :class="{ empty: !selection }">
|
||||||
|
<Crosshair :size="14" aria-hidden="true" />
|
||||||
|
{{ selection ? formatSelection(selection) : '拖动任一图表进行框选' }}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
import './styles.css'
|
||||||
|
|
||||||
|
createApp(App).mount('#app')
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
/// <reference lib="webworker" />
|
||||||
|
|
||||||
|
import type { SimulationProfile, SpectrumConfig } from './types'
|
||||||
|
|
||||||
|
type WorkerCommand =
|
||||||
|
| { type: 'configure'; config: SpectrumConfig; configId: number }
|
||||||
|
| { type: 'recycle'; buffer: ArrayBuffer }
|
||||||
|
| { type: 'pause'; paused: boolean }
|
||||||
|
|
||||||
|
type SignalKind = 'carrier' | 'wideband' | 'burst' | 'hopper' | 'chirp'
|
||||||
|
|
||||||
|
interface SimulatedSignal {
|
||||||
|
kind: SignalKind
|
||||||
|
centerBin: number
|
||||||
|
widthBins: number
|
||||||
|
levelDbm: number
|
||||||
|
phase: number
|
||||||
|
rate: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaults: SpectrumConfig = {
|
||||||
|
bandCount: 32,
|
||||||
|
bins: 1024,
|
||||||
|
updateHz: 30,
|
||||||
|
waterfallRows: 96,
|
||||||
|
startMHz: 20,
|
||||||
|
endMHz: 6020,
|
||||||
|
minDbm: -120,
|
||||||
|
maxDbm: -20,
|
||||||
|
simulationProfile: 'mixed',
|
||||||
|
noiseFloorDbm: -104,
|
||||||
|
simulationSeed: 20260804,
|
||||||
|
}
|
||||||
|
|
||||||
|
let config = defaults
|
||||||
|
let timer = 0
|
||||||
|
let sequence = 0
|
||||||
|
let paused = false
|
||||||
|
let pool: ArrayBuffer[] = []
|
||||||
|
let configId = 0
|
||||||
|
let randomState = defaults.simulationSeed >>> 0
|
||||||
|
let noiseLut = new Int8Array(4096)
|
||||||
|
let binCalibration = new Int8Array(defaults.bandCount * defaults.bins)
|
||||||
|
let bandCalibration = new Float32Array(defaults.bandCount)
|
||||||
|
let signals: SimulatedSignal[] = []
|
||||||
|
|
||||||
|
function nextRandom(): number {
|
||||||
|
randomState ^= randomState << 13
|
||||||
|
randomState ^= randomState >>> 17
|
||||||
|
randomState ^= randomState << 5
|
||||||
|
return randomState >>> 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomUnit(): number {
|
||||||
|
return nextRandom() / 0x1_0000_0000
|
||||||
|
}
|
||||||
|
|
||||||
|
function toByte(dbm: number): number {
|
||||||
|
return Math.max(0, Math.min(255, Math.round(((dbm - config.minDbm) / (config.maxDbm - config.minDbm)) * 255)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildNoiseModel(): void {
|
||||||
|
randomState = config.simulationSeed >>> 0 || 1
|
||||||
|
noiseLut = new Int8Array(4096)
|
||||||
|
for (let index = 0; index < noiseLut.length; index += 1) {
|
||||||
|
const unit = Math.max(1e-6, (index + 0.5) / noiseLut.length)
|
||||||
|
const fftPowerDb = 10 * Math.log10(-Math.log(unit)) + 2.5
|
||||||
|
noiseLut[index] = Math.max(-13, Math.min(8, Math.round(fftPowerDb)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const length = config.bandCount * config.bins
|
||||||
|
binCalibration = new Int8Array(length)
|
||||||
|
bandCalibration = new Float32Array(config.bandCount)
|
||||||
|
for (let band = 0; band < config.bandCount; band += 1) {
|
||||||
|
bandCalibration[band] = (randomUnit() - 0.5) * 4
|
||||||
|
for (let bin = 0; bin < config.bins; bin += 1) {
|
||||||
|
const normalized = bin / Math.max(1, config.bins - 1)
|
||||||
|
const passbandShape = -1.8 * Math.pow(Math.abs(normalized - 0.5) * 2, 6)
|
||||||
|
const ripple = Math.sin(normalized * Math.PI * 7 + band * 0.83) * 0.7
|
||||||
|
binCalibration[band * config.bins + bin] = Math.round(passbandShape + ripple)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function signalsPerGhz(profile: SimulationProfile): number {
|
||||||
|
if (profile === 'quiet') return 1
|
||||||
|
if (profile === 'dense') return 15
|
||||||
|
if (profile === 'burst') return 7
|
||||||
|
return 7
|
||||||
|
}
|
||||||
|
|
||||||
|
function chooseSignalKind(profile: SimulationProfile, index: number): SignalKind {
|
||||||
|
if (profile === 'quiet') return index % 5 === 0 ? 'wideband' : 'carrier'
|
||||||
|
if (profile === 'burst') return (['burst', 'hopper', 'chirp', 'carrier'] as SignalKind[])[index % 4]
|
||||||
|
if (profile === 'dense') return (['carrier', 'wideband', 'carrier', 'burst', 'hopper'] as SignalKind[])[index % 5]
|
||||||
|
return (['carrier', 'wideband', 'carrier', 'burst', 'hopper', 'chirp'] as SignalKind[])[index % 6]
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSignals(): void {
|
||||||
|
signals = []
|
||||||
|
randomState = (config.simulationSeed ^ 0x9e3779b9) >>> 0 || 1
|
||||||
|
const spanMHz = config.endMHz - config.startMHz
|
||||||
|
const totalBins = config.bandCount * config.bins
|
||||||
|
const binWidthMHz = spanMHz / totalBins
|
||||||
|
const minimumCount = config.simulationProfile === 'quiet' ? 2 : 4
|
||||||
|
const count = Math.max(minimumCount, Math.min(192, Math.round((spanMHz / 1000) * signalsPerGhz(config.simulationProfile))))
|
||||||
|
for (let index = 0; index < count; index += 1) {
|
||||||
|
const kind = chooseSignalKind(config.simulationProfile, index)
|
||||||
|
const normalizedCenter = (index + 0.15 + randomUnit() * 0.7) / count
|
||||||
|
const centerBin = Math.min(totalBins - 1, Math.round(normalizedCenter * totalBins))
|
||||||
|
const widthMHz = kind === 'wideband' ? 8 + randomUnit() * 72 : 0.05 + randomUnit() * 0.95
|
||||||
|
const baseLevel = config.simulationProfile === 'dense' ? -72 : -64
|
||||||
|
signals.push({
|
||||||
|
kind,
|
||||||
|
centerBin,
|
||||||
|
widthBins: Math.max(kind === 'wideband' ? 2 : 1, Math.round(widthMHz / binWidthMHz)),
|
||||||
|
levelDbm: baseLevel + randomUnit() * 25,
|
||||||
|
phase: randomUnit() * Math.PI * 2,
|
||||||
|
rate: 0.15 + randomUnit() * 1.8,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function signalCenter(signal: SimulatedSignal, time: number): number {
|
||||||
|
const totalBins = config.bandCount * config.bins
|
||||||
|
const binWidthMHz = (config.endMHz - config.startMHz) / totalBins
|
||||||
|
if (signal.kind === 'hopper') {
|
||||||
|
const slot = (Math.floor(time * (2.5 + signal.rate)) + Math.floor(signal.phase * 10)) % 7
|
||||||
|
const hopStepBins = Math.max(1, Math.round(12.5 / binWidthMHz))
|
||||||
|
return Math.max(0, Math.min(totalBins - 1, signal.centerBin + (slot - 3) * hopStepBins))
|
||||||
|
}
|
||||||
|
if (signal.kind === 'chirp') {
|
||||||
|
const progress = (time * signal.rate * 0.08 + signal.phase / (Math.PI * 2)) % 1
|
||||||
|
const sweepBins = Math.max(4, Math.round(100 / binWidthMHz))
|
||||||
|
return Math.max(0, Math.min(totalBins - 1, signal.centerBin + Math.round((progress - 0.5) * sweepBins)))
|
||||||
|
}
|
||||||
|
return signal.centerBin
|
||||||
|
}
|
||||||
|
|
||||||
|
function signalActive(signal: SimulatedSignal, time: number): boolean {
|
||||||
|
if (signal.kind === 'burst') return (time * signal.rate + signal.phase) % 1 < 0.28
|
||||||
|
if (signal.kind === 'hopper') return (time * 3 + signal.phase) % 1 < 0.72
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawSignal(output: Uint8Array, signal: SimulatedSignal, time: number): void {
|
||||||
|
if (!signalActive(signal, time)) return
|
||||||
|
const center = signalCenter(signal, time)
|
||||||
|
const fading = Math.sin(time * signal.rate + signal.phase) * 1.8
|
||||||
|
const halfWidth = signal.kind === 'wideband' ? signal.widthBins : Math.max(3, signal.widthBins * 4)
|
||||||
|
const start = Math.max(0, Math.floor(center - halfWidth))
|
||||||
|
const end = Math.min(output.length - 1, Math.ceil(center + halfWidth))
|
||||||
|
|
||||||
|
for (let index = start; index <= end; index += 1) {
|
||||||
|
const distance = Math.abs(index - center)
|
||||||
|
let level = signal.levelDbm + fading
|
||||||
|
if (signal.kind === 'wideband') {
|
||||||
|
const flatHalf = signal.widthBins * 0.72
|
||||||
|
const shoulder = Math.max(1, signal.widthBins - flatHalf)
|
||||||
|
const rolloff = Math.max(0, distance - flatHalf) / shoulder
|
||||||
|
const texture = ((index * 1103515245 + sequence * 12345) >>> 27) - 15.5
|
||||||
|
level += texture * 0.16 - Math.pow(rolloff, 2) * 26
|
||||||
|
} else {
|
||||||
|
const normalized = distance / Math.max(1, signal.widthBins)
|
||||||
|
level -= normalized * normalized * 18
|
||||||
|
}
|
||||||
|
output[index] = Math.max(output[index], toByte(level))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function generate(): void {
|
||||||
|
if (paused) return
|
||||||
|
const length = config.bandCount * config.bins
|
||||||
|
const reusable = pool.pop()
|
||||||
|
const buffer = reusable?.byteLength === length ? reusable : new ArrayBuffer(length)
|
||||||
|
const output = new Uint8Array(buffer)
|
||||||
|
const time = sequence / config.updateHz
|
||||||
|
|
||||||
|
for (let band = 0; band < config.bandCount; band += 1) {
|
||||||
|
const bandDrift = bandCalibration[band] + Math.sin(time * 0.12 + band * 0.47) * 1.2
|
||||||
|
const offset = band * config.bins
|
||||||
|
for (let bin = 0; bin < config.bins; bin += 1) {
|
||||||
|
const index = offset + bin
|
||||||
|
const noiseDbm = config.noiseFloorDbm + bandDrift + binCalibration[index] + noiseLut[nextRandom() & 4095]
|
||||||
|
output[index] = toByte(noiseDbm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const signal of signals) drawSignal(output, signal, time)
|
||||||
|
self.postMessage({ type: 'frame', buffer, sequence: sequence++, generatedAt: Date.now(), configId }, [buffer])
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedule(): void {
|
||||||
|
clearInterval(timer)
|
||||||
|
timer = self.setInterval(generate, 1000 / config.updateHz)
|
||||||
|
}
|
||||||
|
|
||||||
|
function configure(nextConfig: SpectrumConfig, nextConfigId: number): void {
|
||||||
|
config = nextConfig
|
||||||
|
configId = nextConfigId
|
||||||
|
pool = []
|
||||||
|
sequence = 0
|
||||||
|
buildNoiseModel()
|
||||||
|
buildSignals()
|
||||||
|
schedule()
|
||||||
|
}
|
||||||
|
|
||||||
|
self.onmessage = (event: MessageEvent<WorkerCommand>) => {
|
||||||
|
const message = event.data
|
||||||
|
if (message.type === 'configure') {
|
||||||
|
configure(message.config, message.configId)
|
||||||
|
} else if (message.type === 'recycle') {
|
||||||
|
if (pool.length < 3) pool.push(message.buffer)
|
||||||
|
} else if (message.type === 'pause') {
|
||||||
|
paused = message.paused
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildNoiseModel()
|
||||||
|
buildSignals()
|
||||||
|
schedule()
|
||||||
+530
@@ -0,0 +1,530 @@
|
|||||||
|
:root {
|
||||||
|
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
color: #d8e0e3;
|
||||||
|
background: #101416;
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
letter-spacing: 0;
|
||||||
|
--bg: #101416;
|
||||||
|
--surface: #171d20;
|
||||||
|
--surface-raised: #1d2528;
|
||||||
|
--border: #344047;
|
||||||
|
--border-soft: #273237;
|
||||||
|
--muted: #8b9a9f;
|
||||||
|
--text: #d8e0e3;
|
||||||
|
--primary: #2dd4bf;
|
||||||
|
--success: #55d187;
|
||||||
|
--warning: #f5c451;
|
||||||
|
--danger: #ff735f;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#app {
|
||||||
|
min-width: 320px;
|
||||||
|
min-height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
select {
|
||||||
|
font: inherit;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
select,
|
||||||
|
input[type="number"] {
|
||||||
|
min-height: 32px;
|
||||||
|
color: var(--text);
|
||||||
|
background: #111719;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 0 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover,
|
||||||
|
select:hover,
|
||||||
|
input[type="number"]:hover {
|
||||||
|
border-color: #526268;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:focus-visible,
|
||||||
|
select:focus-visible,
|
||||||
|
input:focus-visible {
|
||||||
|
outline: 2px solid var(--primary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header {
|
||||||
|
min-height: 62px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 10px 18px;
|
||||||
|
background: #12181a;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-title {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-title h1 {
|
||||||
|
margin: 0;
|
||||||
|
color: #ecf3f4;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.25;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-title p {
|
||||||
|
margin: 3px 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header a {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: #9fb0b5;
|
||||||
|
font-size: 12px;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header a:hover {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-panel {
|
||||||
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 18px;
|
||||||
|
background: var(--surface);
|
||||||
|
border-bottom: 1px solid var(--border-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-title {
|
||||||
|
align-self: center;
|
||||||
|
min-width: 126px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px 7px;
|
||||||
|
color: #cbd5d8;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-title output {
|
||||||
|
grid-column: 2;
|
||||||
|
color: var(--primary);
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-panel label {
|
||||||
|
min-width: 92px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-panel select,
|
||||||
|
.control-panel input[type="number"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0 8px;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frequency-input {
|
||||||
|
max-width: 98px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.threshold-control {
|
||||||
|
flex: 1 1 160px;
|
||||||
|
min-width: 140px !important;
|
||||||
|
max-width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.threshold-control span {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.threshold-control output {
|
||||||
|
color: var(--warning);
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.threshold-control input {
|
||||||
|
width: 100%;
|
||||||
|
height: 32px;
|
||||||
|
accent-color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button {
|
||||||
|
width: 34px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-button {
|
||||||
|
min-width: 72px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apply-button {
|
||||||
|
min-width: 86px;
|
||||||
|
color: #082a26;
|
||||||
|
background: var(--primary);
|
||||||
|
border-color: var(--primary);
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apply-button:hover {
|
||||||
|
background: #55decf;
|
||||||
|
border-color: #55decf;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar {
|
||||||
|
min-height: 42px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 6px 18px;
|
||||||
|
background: #111719;
|
||||||
|
border-bottom: 1px solid var(--border-soft);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-state {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
color: var(--success);
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-state.paused {
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-dot {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in srgb, currentColor 18%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar dl {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar dl > div {
|
||||||
|
height: 28px;
|
||||||
|
min-width: 88px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto auto;
|
||||||
|
grid-template-rows: auto auto;
|
||||||
|
align-items: center;
|
||||||
|
column-gap: 5px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-left: 1px solid var(--border-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar svg {
|
||||||
|
grid-row: 1 / 3;
|
||||||
|
color: #73858b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar dt {
|
||||||
|
color: #718187;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar dd {
|
||||||
|
margin: 0;
|
||||||
|
color: #dfe8ea;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-readout {
|
||||||
|
min-width: 0;
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: #d5e1e3;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-readout.empty {
|
||||||
|
color: #687a80;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-banner {
|
||||||
|
margin: 18px;
|
||||||
|
min-height: 48px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
color: #ffd4cf;
|
||||||
|
background: #321d1c;
|
||||||
|
border: 1px solid #7b3c36;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spectrum-surface {
|
||||||
|
position: relative;
|
||||||
|
min-height: 560px;
|
||||||
|
padding: 10px;
|
||||||
|
background: #0d1113;
|
||||||
|
isolation: isolate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spectrum-canvas {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 0;
|
||||||
|
inset: 10px;
|
||||||
|
width: calc(100% - 20px);
|
||||||
|
height: calc(100% - 20px);
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.band-grid {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(var(--columns), minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.band-panel {
|
||||||
|
position: relative;
|
||||||
|
height: var(--panel-height);
|
||||||
|
min-width: 0;
|
||||||
|
border: 1px solid #344148;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: crosshair;
|
||||||
|
touch-action: none;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.band-panel header {
|
||||||
|
height: 28px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
padding: 0 8px;
|
||||||
|
color: #9aabb0;
|
||||||
|
background: rgba(21, 28, 31, 0.94);
|
||||||
|
border-bottom: 1px solid #303c42;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 9px;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.band-panel header strong {
|
||||||
|
color: #dbe4e6;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.band-panel header span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.band-panel header small {
|
||||||
|
margin-left: auto;
|
||||||
|
color: #65777d;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.curve-interaction {
|
||||||
|
position: absolute;
|
||||||
|
top: 28px;
|
||||||
|
right: 0;
|
||||||
|
left: 0;
|
||||||
|
height: calc((100% - 28px) * 0.39);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.threshold-line {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
left: 0;
|
||||||
|
height: 1px;
|
||||||
|
background: rgba(245, 196, 81, 0.8);
|
||||||
|
border-top: 1px dashed rgba(245, 196, 81, 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
.threshold-line i {
|
||||||
|
position: absolute;
|
||||||
|
top: -7px;
|
||||||
|
right: 4px;
|
||||||
|
padding: 1px 3px;
|
||||||
|
color: #f7d77f;
|
||||||
|
background: rgba(22, 27, 28, 0.88);
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 8px;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-divider {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(28px + (100% - 28px) * 0.39 + 2px);
|
||||||
|
left: 5px;
|
||||||
|
z-index: 2;
|
||||||
|
color: rgba(188, 203, 207, 0.58);
|
||||||
|
font-size: 8px;
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-box {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 3;
|
||||||
|
background: rgba(45, 212, 191, 0.13);
|
||||||
|
border: 1px solid rgba(94, 234, 212, 0.95);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1199px) {
|
||||||
|
.control-panel {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-title {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar dl {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-readout {
|
||||||
|
width: 100%;
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.band-grid {
|
||||||
|
grid-template-columns: repeat(min(var(--columns), 2), minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.app-header {
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-title p {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-panel,
|
||||||
|
.status-bar {
|
||||||
|
padding-right: 12px;
|
||||||
|
padding-left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-panel label {
|
||||||
|
flex: 1 1 92px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.threshold-control {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-actions {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar dl > div {
|
||||||
|
min-width: 82px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.band-grid {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spectrum-surface {
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spectrum-canvas {
|
||||||
|
inset: 6px;
|
||||||
|
width: calc(100% - 12px);
|
||||||
|
height: calc(100% - 12px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
transition: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
export type SimulationProfile = 'mixed' | 'quiet' | 'dense' | 'burst'
|
||||||
|
|
||||||
|
export interface SpectrumConfig {
|
||||||
|
bandCount: number
|
||||||
|
bins: number
|
||||||
|
updateHz: number
|
||||||
|
waterfallRows: number
|
||||||
|
startMHz: number
|
||||||
|
endMHz: number
|
||||||
|
minDbm: number
|
||||||
|
maxDbm: number
|
||||||
|
simulationProfile: SimulationProfile
|
||||||
|
noiseFloorDbm: number
|
||||||
|
simulationSeed: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpectrumFrame {
|
||||||
|
type: 'frame'
|
||||||
|
buffer: ArrayBuffer
|
||||||
|
sequence: number
|
||||||
|
generatedAt: number
|
||||||
|
configId: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BandRect {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Selection {
|
||||||
|
band: number
|
||||||
|
x0: number
|
||||||
|
x1: number
|
||||||
|
y0: number
|
||||||
|
y1: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelectionResult {
|
||||||
|
band: number
|
||||||
|
startMHz: number
|
||||||
|
endMHz: number
|
||||||
|
highDbm?: number
|
||||||
|
lowDbm?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PerformanceMetrics {
|
||||||
|
fps: number
|
||||||
|
receivedHz: number
|
||||||
|
throughputMbps: number
|
||||||
|
uploadMs: number
|
||||||
|
renderMs: number
|
||||||
|
gpuMs: number
|
||||||
|
latencyMs: number
|
||||||
|
droppedFrames: number
|
||||||
|
textureMemoryMb: number
|
||||||
|
sequence: number
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable", "WebWorker"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user