From 4186840fa441c3b03a2e83752516c8617c946bb5 Mon Sep 17 00:00:00 2001 From: aper <1295355980@qq.com> Date: Wed, 5 Aug 2026 00:46:52 +0800 Subject: [PATCH] feat: add spectrum monitoring performance prototype --- .gitignore | 12 + docs/performance-results.md | 111 ++ docs/spectrum-rendering-options.md | 240 +++++ docs/ui-spec.md | 142 +++ index.html | 13 + package-lock.json | 1568 ++++++++++++++++++++++++++++ package.json | 21 + src/App.vue | 87 ++ src/SpectrumRenderer.ts | 374 +++++++ src/components/ControlPanel.vue | 134 +++ src/components/SpectrumSurface.vue | 280 +++++ src/components/StatusBar.vue | 41 + src/main.ts | 5 + src/spectrum.worker.ts | 221 ++++ src/styles.css | 530 ++++++++++ src/types.ts | 59 ++ tsconfig.app.json | 20 + tsconfig.json | 7 + tsconfig.node.json | 11 + vite.config.ts | 6 + 20 files changed, 3882 insertions(+) create mode 100644 .gitignore create mode 100644 docs/performance-results.md create mode 100644 docs/spectrum-rendering-options.md create mode 100644 docs/ui-spec.md create mode 100644 index.html create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/App.vue create mode 100644 src/SpectrumRenderer.ts create mode 100644 src/components/ControlPanel.vue create mode 100644 src/components/SpectrumSurface.vue create mode 100644 src/components/StatusBar.vue create mode 100644 src/main.ts create mode 100644 src/spectrum.worker.ts create mode 100644 src/styles.css create mode 100644 src/types.ts create mode 100644 tsconfig.app.json create mode 100644 tsconfig.json create mode 100644 tsconfig.node.json create mode 100644 vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b38ffce --- /dev/null +++ b/.gitignore @@ -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 diff --git a/docs/performance-results.md b/docs/performance-results.md new file mode 100644 index 0000000..8690e11 --- /dev/null +++ b/docs/performance-results.md @@ -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_ diff --git a/docs/spectrum-rendering-options.md b/docs/spectrum-rendering-options.md new file mode 100644 index 0000000..33d90e4 --- /dev/null +++ b/docs/spectrum-rendering-options.md @@ -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_. + +[^2]: LightningChart. (2026). "LightningChart JS interactive examples and heatmap features." _LightningChart_. + +[^3]: Apache ECharts. (2026). "Canvas vs. SVG." _Apache ECharts Handbook_. + +[^4]: uPlot Contributors. (2025). "uPlot performance and streaming guidance." _GitHub_. + +[^5]: Chitnis, D. (2026). "webgl-plot: high-performance 2D plotting." _GitHub_. + +[^6]: Spectric Labs. (2025). "SigPlot: interactive plotting for SDR applications." _GitHub_. + +[^7]: audio-lab. (2022). "gl-spectrogram WebGL renderer." _GitHub_. + +[^8]: OpenWebRX Contributors. (2026). "OpenWebRX source and AGPL license." _GitHub_. + +--- + +_最后更新:2026-08-04_ diff --git a/docs/ui-spec.md b/docs/ui-spec.md new file mode 100644 index 0000000..ede66b6 --- /dev/null +++ b/docs/ui-spec.md @@ -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_ diff --git a/index.html b/index.html new file mode 100644 index 0000000..c02c918 --- /dev/null +++ b/index.html @@ -0,0 +1,13 @@ + + + + + + + 频谱监测性能实验台 + + +
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..5813106 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1568 @@ +{ + "name": "spectrum-monitor-lab", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "spectrum-monitor-lab", + "version": "0.1.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" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://repo.huaweicloud.com/repository/npm/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://repo.huaweicloud.com/repository/npm/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://repo.huaweicloud.com/repository/npm/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://repo.huaweicloud.com/repository/npm/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://repo.huaweicloud.com/repository/npm/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://repo.huaweicloud.com/repository/npm/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://repo.huaweicloud.com/repository/npm/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://repo.huaweicloud.com/repository/npm/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://repo.huaweicloud.com/repository/npm/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://repo.huaweicloud.com/repository/npm/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://repo.huaweicloud.com/repository/npm/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://repo.huaweicloud.com/repository/npm/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://repo.huaweicloud.com/repository/npm/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://repo.huaweicloud.com/repository/npm/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://repo.huaweicloud.com/repository/npm/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://repo.huaweicloud.com/repository/npm/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://repo.huaweicloud.com/repository/npm/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://repo.huaweicloud.com/repository/npm/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://repo.huaweicloud.com/repository/npm/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://repo.huaweicloud.com/repository/npm/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://repo.huaweicloud.com/repository/npm/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://repo.huaweicloud.com/repository/npm/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://repo.huaweicloud.com/repository/npm/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://repo.huaweicloud.com/repository/npm/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://repo.huaweicloud.com/repository/npm/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://repo.huaweicloud.com/repository/npm/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://repo.huaweicloud.com/repository/npm/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://repo.huaweicloud.com/repository/npm/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://repo.huaweicloud.com/repository/npm/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://repo.huaweicloud.com/repository/npm/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://repo.huaweicloud.com/repository/npm/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@lucide/vue": { + "version": "1.28.0", + "resolved": "https://repo.huaweicloud.com/repository/npm/@lucide/vue/-/vue-1.28.0.tgz", + "integrity": "sha512-cO89UyNM/0i2Srdi04nFWeEOQkqzDZlM2ehtWyBhcMpRTJDKpMZPCGAVlmVBgBgr5HaeL5L6FsU8RHsOdwXtKw==", + "license": "ISC", + "peerDependencies": { + "vue": ">=3.0.1" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://repo.huaweicloud.com/repository/npm/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.4", + "resolved": "https://repo.huaweicloud.com/repository/npm/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.4", + "resolved": "https://repo.huaweicloud.com/repository/npm/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.4", + "resolved": "https://repo.huaweicloud.com/repository/npm/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.4", + "resolved": "https://repo.huaweicloud.com/repository/npm/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.4", + "resolved": "https://repo.huaweicloud.com/repository/npm/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.4", + "resolved": "https://repo.huaweicloud.com/repository/npm/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.4", + "resolved": "https://repo.huaweicloud.com/repository/npm/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.4", + "resolved": "https://repo.huaweicloud.com/repository/npm/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.4", + "resolved": "https://repo.huaweicloud.com/repository/npm/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.4", + "resolved": "https://repo.huaweicloud.com/repository/npm/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.4", + "resolved": "https://repo.huaweicloud.com/repository/npm/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.4", + "resolved": "https://repo.huaweicloud.com/repository/npm/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.4", + "resolved": "https://repo.huaweicloud.com/repository/npm/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.4", + "resolved": "https://repo.huaweicloud.com/repository/npm/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.4", + "resolved": "https://repo.huaweicloud.com/repository/npm/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.4", + "resolved": "https://repo.huaweicloud.com/repository/npm/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.4", + "resolved": "https://repo.huaweicloud.com/repository/npm/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.4", + "resolved": "https://repo.huaweicloud.com/repository/npm/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.4", + "resolved": "https://repo.huaweicloud.com/repository/npm/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.4", + "resolved": "https://repo.huaweicloud.com/repository/npm/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.4", + "resolved": "https://repo.huaweicloud.com/repository/npm/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.4", + "resolved": "https://repo.huaweicloud.com/repository/npm/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.4", + "resolved": "https://repo.huaweicloud.com/repository/npm/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.4", + "resolved": "https://repo.huaweicloud.com/repository/npm/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.4", + "resolved": "https://repo.huaweicloud.com/repository/npm/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://repo.huaweicloud.com/repository/npm/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://repo.huaweicloud.com/repository/npm/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.15", + "resolved": "https://repo.huaweicloud.com/repository/npm/@volar/language-core/-/language-core-2.4.15.tgz", + "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.15" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.15", + "resolved": "https://repo.huaweicloud.com/repository/npm/@volar/source-map/-/source-map-2.4.15.tgz", + "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.15", + "resolved": "https://repo.huaweicloud.com/repository/npm/@volar/typescript/-/typescript-2.4.15.tgz", + "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.40", + "resolved": "https://repo.huaweicloud.com/repository/npm/@vue/compiler-core/-/compiler-core-3.5.40.tgz", + "integrity": "sha512-39E8IgOhTbVDnoJFMKc2DvYnypcZwUqgUhQkccva/0m6FUwtIKSGV7n1hpVmYcFaoRAwf9pBcwnKlCEsN63ZEQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/shared": "3.5.40", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.40", + "resolved": "https://repo.huaweicloud.com/repository/npm/@vue/compiler-dom/-/compiler-dom-3.5.40.tgz", + "integrity": "sha512-pwkx4vqlqOspFstrcmzwkKLePVMD3PT65imRzLhanU2V1Fj4K13g6OXjanOyzw3aTAuRk84BOmY8f3rEHqPaVA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.40", + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.40", + "resolved": "https://repo.huaweicloud.com/repository/npm/@vue/compiler-sfc/-/compiler-sfc-3.5.40.tgz", + "integrity": "sha512-gIf497P4kpuALcvs5n3AEg1Vdn0pSY4XbjASIfHNYF1/MP3T2Mf2STERTubysBxCRxzJGJYtF/O7vwJrxFB3Vw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/compiler-core": "3.5.40", + "@vue/compiler-dom": "3.5.40", + "@vue/compiler-ssr": "3.5.40", + "@vue/shared": "3.5.40", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.19", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.40", + "resolved": "https://repo.huaweicloud.com/repository/npm/@vue/compiler-ssr/-/compiler-ssr-3.5.40.tgz", + "integrity": "sha512-rrE5xiXG663+vHCHa3J9p2z5OcBRjXmoqenprJxAFQxg5pSshzeBiCE6pu46axapRJ2Adk0YDA2BRZVjiHXnhg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.40", + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/compiler-vue2": { + "version": "2.7.16", + "resolved": "https://repo.huaweicloud.com/repository/npm/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz", + "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==", + "dev": true, + "license": "MIT", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/@vue/language-core": { + "version": "2.2.12", + "resolved": "https://repo.huaweicloud.com/repository/npm/@vue/language-core/-/language-core-2.2.12.tgz", + "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "@vue/compiler-dom": "^3.5.0", + "@vue/compiler-vue2": "^2.7.16", + "@vue/shared": "^3.5.0", + "alien-signals": "^1.0.3", + "minimatch": "^9.0.3", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.40", + "resolved": "https://repo.huaweicloud.com/repository/npm/@vue/reactivity/-/reactivity-3.5.40.tgz", + "integrity": "sha512-B7ot9UlUZOi1zbq61/LvE88ZLTV8IlajTdiZTAEiDQgrnIMIZoPr9kGw0Zw46ObW62O9+H/Be3kMbfb7kYPQZA==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.40", + "resolved": "https://repo.huaweicloud.com/repository/npm/@vue/runtime-core/-/runtime-core-3.5.40.tgz", + "integrity": "sha512-KAZLweuZ6uUJPK1PMSQPgBU5gCjgrrfjUhSglmU9NhH+Zjepa8cnwSydPWDWHDwOgY4g3VcZ+PljbiHlURNCbw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.40", + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.40", + "resolved": "https://repo.huaweicloud.com/repository/npm/@vue/runtime-dom/-/runtime-dom-3.5.40.tgz", + "integrity": "sha512-ZfrX8ssZQds900L9pr8AuK05ddnMsR4MPMZr8cPN9GoqoPWcXLhjvvbIA2SMv+7a97sJ1vv9pj/zxK0Cq/eEFQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.40", + "@vue/runtime-core": "3.5.40", + "@vue/shared": "3.5.40", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.40", + "resolved": "https://repo.huaweicloud.com/repository/npm/@vue/server-renderer/-/server-renderer-3.5.40.tgz", + "integrity": "sha512-XNJym9WpevhTVt1HuwOrCRJ5Q+9z4BjTMrDtjTrvx74SmUll8spNTw6whWJa9mEkO4PKn5TihI/bm/8ds2QVJw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.40", + "@vue/runtime-dom": "3.5.40", + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.40", + "resolved": "https://repo.huaweicloud.com/repository/npm/@vue/shared/-/shared-3.5.40.tgz", + "integrity": "sha512-WxnBtruIqOoV3rA4jeKDWzrYI5h7Cp4+pjwDi8kWGHz+IslhiN+wguLVVhtv2l8VoU02rzDCVfDjgCl1lNpZVg==", + "license": "MIT" + }, + "node_modules/alien-signals": { + "version": "1.0.13", + "resolved": "https://repo.huaweicloud.com/repository/npm/alien-signals/-/alien-signals-1.0.13.tgz", + "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://repo.huaweicloud.com/repository/npm/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://repo.huaweicloud.com/repository/npm/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://repo.huaweicloud.com/repository/npm/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://repo.huaweicloud.com/repository/npm/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://repo.huaweicloud.com/repository/npm/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://repo.huaweicloud.com/repository/npm/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://repo.huaweicloud.com/repository/npm/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://repo.huaweicloud.com/repository/npm/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://repo.huaweicloud.com/repository/npm/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://repo.huaweicloud.com/repository/npm/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://repo.huaweicloud.com/repository/npm/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://repo.huaweicloud.com/repository/npm/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://repo.huaweicloud.com/repository/npm/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.17", + "resolved": "https://repo.huaweicloud.com/repository/npm/nanoid/-/nanoid-3.3.17.tgz", + "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://repo.huaweicloud.com/repository/npm/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://repo.huaweicloud.com/repository/npm/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://repo.huaweicloud.com/repository/npm/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.25", + "resolved": "https://repo.huaweicloud.com/repository/npm/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.62.4", + "resolved": "https://repo.huaweicloud.com/repository/npm/rollup/-/rollup-4.62.4.tgz", + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://repo.huaweicloud.com/repository/npm/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://repo.huaweicloud.com/repository/npm/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.7.3", + "resolved": "https://repo.huaweicloud.com/repository/npm/typescript/-/typescript-5.7.3.tgz", + "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://repo.huaweicloud.com/repository/npm/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://repo.huaweicloud.com/repository/npm/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.40", + "resolved": "https://repo.huaweicloud.com/repository/npm/vue/-/vue-3.5.40.tgz", + "integrity": "sha512-+8PJ4SJXdn/cHGImF4CKdxlWHIN5Dkt7DoufRREM6h6uVCx2m7QxgcEQmmzyOK8A9mcafg7sFbJFYsdFVubTig==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.40", + "@vue/compiler-sfc": "3.5.40", + "@vue/runtime-dom": "3.5.40", + "@vue/server-renderer": "3.5.40", + "@vue/shared": "3.5.40" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-tsc": { + "version": "2.2.12", + "resolved": "https://repo.huaweicloud.com/repository/npm/vue-tsc/-/vue-tsc-2.2.12.tgz", + "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.15", + "@vue/language-core": "2.2.12" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..50630d9 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/App.vue b/src/App.vue new file mode 100644 index 0000000..b289b59 --- /dev/null +++ b/src/App.vue @@ -0,0 +1,87 @@ + + + diff --git a/src/SpectrumRenderer.ts b/src/SpectrumRenderer.ts new file mode 100644 index 0000000..4c16984 --- /dev/null +++ b/src/SpectrumRenderer.ts @@ -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 + } +} diff --git a/src/components/ControlPanel.vue b/src/components/ControlPanel.vue new file mode 100644 index 0000000..f4e53f4 --- /dev/null +++ b/src/components/ControlPanel.vue @@ -0,0 +1,134 @@ + + + diff --git a/src/components/SpectrumSurface.vue b/src/components/SpectrumSurface.vue new file mode 100644 index 0000000..35d8302 --- /dev/null +++ b/src/components/SpectrumSurface.vue @@ -0,0 +1,280 @@ + + + diff --git a/src/components/StatusBar.vue b/src/components/StatusBar.vue new file mode 100644 index 0000000..8591d04 --- /dev/null +++ b/src/components/StatusBar.vue @@ -0,0 +1,41 @@ + + + diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..fdbdce5 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,5 @@ +import { createApp } from 'vue' +import App from './App.vue' +import './styles.css' + +createApp(App).mount('#app') diff --git a/src/spectrum.worker.ts b/src/spectrum.worker.ts new file mode 100644 index 0000000..2f71c0b --- /dev/null +++ b/src/spectrum.worker.ts @@ -0,0 +1,221 @@ +/// + +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) => { + 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() diff --git a/src/styles.css b/src/styles.css new file mode 100644 index 0000000..556801f --- /dev/null +++ b/src/styles.css @@ -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; + } +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..3750e85 --- /dev/null +++ b/src/types.ts @@ -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 +} diff --git a/tsconfig.app.json b/tsconfig.app.json new file mode 100644 index 0000000..f4a4a3e --- /dev/null +++ b/tsconfig.app.json @@ -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"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..24b3d43 --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "noEmit": true + }, + "include": ["vite.config.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..c40aa3c --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +export default defineConfig({ + plugins: [vue()], +})