Compare commits
46 Commits
12813bd8d0
...
9d3b103e32
| Author | SHA1 | Date |
|---|---|---|
|
|
9d3b103e32 | |
|
|
c2ec1d34b4 | |
|
|
0537e938b4 | |
|
|
fb175d6d3d | |
|
|
f4922dd6e2 | |
|
|
9af363080a | |
|
|
b1a8d1365d | |
|
|
e62e2cdc8d | |
|
|
07309da1b3 | |
|
|
251046f885 | |
|
|
bdc6c90db8 | |
|
|
27905511e6 | |
|
|
bec6a376d5 | |
|
|
824898a65c | |
|
|
7d0e72dec2 | |
|
|
f51706b4b3 | |
|
|
2beb97fa73 | |
|
|
438ed78aad | |
|
|
6cc973a183 | |
|
|
3dfe8b54f5 | |
|
|
0212fb5d2e | |
|
|
03805f4326 | |
|
|
75cf8d40ba | |
|
|
6fa0a31f3e | |
|
|
d75a52e519 | |
|
|
bfd7d4aafd | |
|
|
6bc7c23a8c | |
|
|
5dbbb2576c | |
|
|
86e2b6b8a8 | |
|
|
c21226a3d7 | |
|
|
687edfeca1 | |
|
|
c15555dd8a | |
|
|
4a1fecb149 | |
|
|
cc3c5bf755 | |
|
|
b362156364 | |
|
|
d908556166 | |
|
|
8f167b62c9 | |
|
|
a9e8eb9d5c | |
|
|
9874af77ee | |
|
|
ec4a7e81ef | |
|
|
c6ff9c2271 | |
|
|
0bbed9c0c3 | |
|
|
379875dff0 | |
|
|
0d7f646941 | |
|
|
c395921ca8 | |
|
|
b509795ffd |
|
|
@ -0,0 +1,67 @@
|
|||
# Task 12b 报告:SetPartitions 单 mapper fps 去风险探针
|
||||
|
||||
## 状态
|
||||
完成(探针真实跑出,结论:渲出但未达交互级)。
|
||||
|
||||
## 实测环境与数据
|
||||
- store(9c/12 同款单线全分辨率整卷):`D:\Git\lanbingtech\geopro\build\tmp\gpr_store_B_001`
|
||||
- 整卷维度:44476 × 29 × 162 = 208,948,248 体素,417,896,496 B(398.5 MB,VTK_SHORT)
|
||||
- 离屏渲染(vtkRenderWindow SetOffScreenRenderingOn),硬件加速 OpenGL(offscreen-smoke 闸门 OK)。
|
||||
|
||||
## 实现要点(tools/gpr_poc/main.cpp 新增 `renderC-partitioned` 子命令)
|
||||
- WholeVolumeSource 重组**整卷单个** vtkImageData(不预切块)。
|
||||
- 关键:`vtkGPUVolumeRayCastMapper` 抽象基类**无** `SetPartitions`;该 API 在 OpenGL 具体实现
|
||||
`vtkOpenGLGPUVolumeRayCastMapper`(工厂默认产物)上。故直接建该具体类,
|
||||
`SetInputData(整卷)` + `SetPartitions(ceil(nx/16384),1,1)`。
|
||||
- 分区数:沿线 44476 → `ceil(44476/16384)=3`(每区 ~14826 ≤16384);ny=29、nz=162 → 1。
|
||||
实测 `SetPartitions(3,1,1)`。
|
||||
- 量化域传函复用现有 `makeI16VolumeProperty`(qmin/qmax、kBlank 透明、q.toPhys 反查 ColorScale)。
|
||||
- 双闸(同 9c,绝不把空纹理假帧率当性能):
|
||||
① CapturingOutputWindow 捕获 3D 纹理维度错误;
|
||||
② 真实回读像素统计非背景像素。
|
||||
- 相机修正:整卷极扁长(44476:29:162),首版用 `ResetCamera()` 全体 + 仅取末帧像素时,
|
||||
末帧恰好边缘视角 → 误报“非空像素=0”。修正为:以 mapper 包围盒定向 + 抬高/旋转视角让薄维度可见,
|
||||
且旋转扫描中**多帧采样非背景像素取最大值**(区分“真渲不出”与“采样时机不巧”)。修正后稳定渲出。
|
||||
|
||||
## 核心结论:SetPartitions 单 mapper 是否真渲出 + fps + 内存 + 分区数
|
||||
- **分区数**:SetPartitions(3, 1, 1)。
|
||||
- **是否真渲出**:**是**。无纹理维度错误(SetPartitions 成功绕过 GL_MAX_3D_TEXTURE_SIZE=16384 纹理墙),
|
||||
真实回读非背景像素 1264(非空),一个 mapper 一次 ray cast。
|
||||
- **体绘制 fps**:**~8.8 ~ 11 fps**(多次实测 8.84 / 10.95 / 10.59,落在 8.8–11 区间)。
|
||||
- **峰值进程内存**:~556 ~ 653 MB(整卷 398.5 MB 常驻 + 渲染开销)。
|
||||
|
||||
## 对照表
|
||||
| 路径 | 是否渲出 | fps |
|
||||
|---|---|---|
|
||||
| renderB 整卷单 SmartVolumeMapper | INVALID(纹理墙,沿线 44476>16384) | — |
|
||||
| renderC MultiBlock(每块一 mapper) | 渲出 | 9.5 静态 / 1.45 换页 |
|
||||
| **renderC-partitioned 单 mapper SetPartitions** | **渲出** | **~8.8–11(静态整卷)** |
|
||||
|
||||
## 是否达交互级
|
||||
**否**。目标 ≥15~30 fps,实测 8.8–11 fps,低于交互级下限。
|
||||
|
||||
## 判据落点
|
||||
- “对的架构”(单 mapper + SetPartitions)**确实绕过了纹理墙、确实把全分辨率整卷一次性渲出**——
|
||||
这点比 9c(INVALID)与 12(每块一 mapper)都更干净,证明架构方向正确。
|
||||
- 但**纯 GPU ray cast 静态整卷 fps 仍只有 ~9–11**,与 renderC MultiBlock 的 9.5 静态 **基本同档**,
|
||||
未拉开差距、未到交互级。即:**“每块一 mapper”不是 9.5fps 的主要元凶;瓶颈在 208M 体素全分辨率
|
||||
整卷的 ray cast 本身**(采样量 + 显存带宽),单 mapper 分区并不能把它变快。
|
||||
- 结论:**VTK 这条路(整卷全分辨率体绘制)的交互级天花板在本数据上已暴露**。要到交互级,
|
||||
production C 必须靠 LOD/降采样/核外换块(动态分辨率),而非寄望“单 mapper 分区”本身提速。
|
||||
brief 判据的“仍不到 → 评估 OpenVDS/自建 GL”一支成立。
|
||||
|
||||
## concerns
|
||||
1. **fps 受相机框选与视图影响**:8.8–11 的波动主要来自每帧旋转中视线穿过体的采样深度差异;
|
||||
该数为“静态整卷、绕轴旋转”口径,已剔除换页/解压(不像 renderC 动态 1.45 含 update)。
|
||||
作为“单 mapper 分区静态整卷 fps 天花板”是诚实的,但生产中真实 fps 还会被交互缩放/平移影响。
|
||||
2. **首版“空渲染”教训已修正**:极扁长体 + 末帧单采样会假报空;现多帧取最大 + 视角抬高,已稳定非空。
|
||||
报告口径据此可信。
|
||||
3. **本探针只验静态整卷**(遵 YAGNI,未做 LOD/换块/后台解压)。production C 的动态分辨率方案
|
||||
还需单独验证其在“降采样后”能否到交互级——这是下一根要验的链子,不在本探针范围。
|
||||
4. SetPartitions 在 9.6 属 `vtkOpenGLGPUVolumeRayCastMapper`(非抽象基类);若后续 VTK 升级
|
||||
该 API 位置变动需留意。
|
||||
|
||||
## 交付物
|
||||
- 代码:`tools/gpr_poc/main.cpp`(新增 `renderC-partitioned` 子命令)。
|
||||
- 结果:`docs/superpowers/plans/poc-results-C.md`(含对照表与判据结论)。
|
||||
- 报告:本文件 `.superpowers/sdd/task-12b-report.md`。
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
# Task 12c 报告:LOD-fps 探针(全量交互渲染最后一根链子)
|
||||
|
||||
## 状态
|
||||
|
||||
**完成 / PASS** —— 四件事(a/b/c/d)全做,双闸通过(无纹理维度错误 + 三段均回读非空像素),
|
||||
真实实测,未编造。LOD-based C 路线在本机判据下钉死可行。
|
||||
|
||||
## 实测数字(本机 RTX 3060 Laptop GPU,离屏,frames=120,多次重跑稳定)
|
||||
|
||||
| 项 | 维度 | 结果 | 交互级判据 |
|
||||
|---|---|---|---|
|
||||
| (a) 粗层概览 fps | level2 整卷 11119×8×41 (~3.6M 体素) | **~752 fps**(多跑 590~759) | ✔ 远超 ≥30 |
|
||||
| (b) 全分辨率局部 fps | level0 局部 256×29×162 (~120 万体素,4 brick 列) | **~380 fps**(多跑 374~422) | ✔ 远超 ≥30 |
|
||||
| (c) LOD 切换过渡 | 切换帧 60/120,从远观(level2)dolly 拉近到近观局部(level0) | 平均 **1.09ms/帧**,切换帧 **~5.5ms**(尖峰 ~6×邻帧),最大 ~6.95ms | 无可感知卡顿 ✔ |
|
||||
|
||||
- **粗层概览 fps**:~752 fps(达交互级 ✔)
|
||||
- **全分辨率局部 fps**:~380 fps(达交互级 ✔)
|
||||
- **LOD 切换过渡帧耗时 / 是否卡顿**:切换帧 ~5.5ms(仍 <1 个 60Hz 帧 16.7ms)→ **无可感知卡顿**
|
||||
- **截图路径**:`docs/superpowers/plans/poc-lod-shots/`
|
||||
- `lod-overview.png`(level2 整线概览,全 2200m 线呈细带)
|
||||
- `lod-fullres-local.png`(level0 局部,全分辨率板面有细节)
|
||||
- `lod-transition-mid.png`(切换后推近的过渡中间帧)
|
||||
- **是否都达交互级**:**是**。(a)/(b) 均 >>30fps;(c) 切换无可感知卡顿。
|
||||
|
||||
## 设计与诚实测法
|
||||
|
||||
- 在真实金字塔 store(`gpr_poc build ... --levels 3`,level0=44476×29×162,
|
||||
level1=22238×15×81,level2=11119×8×41,level3=5560×4×21)上跑,非合成。
|
||||
- (a)/(b):把对应 level 的所有 brick 重组成单张 VTK_SHORT vtkImageData
|
||||
(逻辑同 `WholeVolumeSource`,按 level 维度 + spacing×2^level / 局部段 X 偏移),
|
||||
喂 `buildVoxelI16FromImage`(SmartVolumeMapper,GPU 路径),旋相机 120 帧测 fps。
|
||||
level2/局部段单轴均 <16384 → 单 3D 纹理可成,无纹理墙。
|
||||
- (c):同一窗口,相机从远观(level2 整卷)dolly 拉近;第 60 帧跨越 LOD 切换那一下
|
||||
把体从 level2 概览换成 level0 局部 + 焦点移到局部段中心,**逐帧记帧耗时**,
|
||||
标切换帧尖峰。这是审核人加的验收点①(测切换动态,非两端静态)。
|
||||
- (d):`vtkWindowToImageFilter`+`vtkPNGWriter` 存 3 张 PNG,供人眼判
|
||||
“概览糊→拉近清晰”(审核人验收点②)。
|
||||
- **双闸(同 9c,绝不把空纹理假帧率当性能)**:
|
||||
① `CapturingOutputWindow` 捕获 3D 纹理维度错误(实测=否);
|
||||
② 真实回读前缓冲像素,统计非背景像素(概览 1889 / 局部 167612 / 过渡 21924,
|
||||
三段均非空)。两闸全过,fps 可信。
|
||||
|
||||
## 卡顿判据说明(避免误报)
|
||||
|
||||
切换帧含一次性建 actor / 换 mapper 输入,~5.5ms,是邻帧(~0.9ms)的 ~6×;但绝对值
|
||||
仍 < 1 个 60Hz 帧(16.7ms),人眼不可感。故采用**绝对耗时判据**:切换帧 >33ms(2 帧)
|
||||
才记“可感知卡顿”,16.7~33ms 记“轻微抖动”,亚毫秒基线下尖峰倍数虽大但绝对值低不算
|
||||
卡顿。本机切换帧 ~5.5ms → 无可感知卡顿。
|
||||
|
||||
## 判据结论
|
||||
|
||||
粗层概览 + 全分辨率局部**都达交互级**(≥30fps,远超)且切换**无不可接受卡顿**
|
||||
→ 命中 brief 第一条判据:**LOD-based C 路线钉死可行**。
|
||||
|
||||
对照 12b:整卷全分辨率 ray cast(2.08 亿体素)~10fps 是硬上限;本探针证实
|
||||
“渲更少体素 = LOD” 这根杠杆有效——粗层 ~752fps、全分辨率局部 ~380fps,两端都远
|
||||
在交互级,且 LOD 切换瞬态 ~5.5ms 无卡顿。
|
||||
|
||||
## 最低配未验声明(审核人验收点③)
|
||||
|
||||
本探针**仅在本机(RTX 3060 Laptop GPU,NVIDIA 555.97,OpenGL 4.5)跑得上限数字**。
|
||||
**最低配机器未验证**,需用户在目标机跑 `gpr_poc renderLOD <store>` 或提供型号后再评估。
|
||||
本机数字是上限,最低配可能更低。
|
||||
|
||||
## 进程峰值内存
|
||||
|
||||
~99 MB(探针逐 level 重组单张 image,未常驻整卷;level0 局部仅取 4 brick 列)。
|
||||
|
||||
## Concerns
|
||||
|
||||
1. **截图视觉偏暗/偏细**:体绘制 `kMaxOpacity=0.15`(复用探针传函)+ 整线物理纵横比
|
||||
极扁(2200m × ~1.5m × 8m),故概览图中整线呈一条细带、过渡中间帧呈小斜板。
|
||||
这是物理真实呈现(整线本就是长薄带),非渲染缺陷;但作为“人眼判可接受度”素材
|
||||
偏素净。若需更醒目的生产视觉,需后续调传函不透明度/着色与取景,超出探针范畴(YAGNI)。
|
||||
2. **(c) 为单次脚本化切换**:测的是“从 level2 直切 level0 局部”一次硬切的瞬态;
|
||||
生产里多级连续 LOD/视野自适应的换页节奏、预取与 morphing/淡入是探针过了之后的
|
||||
工程(brief 明确不在本探针范围)。
|
||||
3. **(b) 局部仅取 4 brick 列(256 体素宽)**:证“全分辨率局部块快”;若生产需更宽的
|
||||
全分辨率窗口(仍需 <16384 或分区/分块),fps 会随体素数下降,需届时按窗口大小复测。
|
||||
4. **最低配仍是最大未知**(见上声明)。
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
# Task 12d-fix 报告:修 gpr_poc view 空窗 + 控制台乱码
|
||||
|
||||
## 状态
|
||||
DONE。两 bug 均修复,构建通过(Community vcvars64 直驱 ninja,exit 0),离屏自检通过。
|
||||
|
||||
## 提交短哈希
|
||||
`1495d0e`(feat/vtk-3d-view 分支)
|
||||
|
||||
## 改动文件
|
||||
仅 `tools/gpr_poc/main.cpp`(+70 -1)。
|
||||
|
||||
### Bug 1:概览空窗(LOD 策略错)
|
||||
- 根因:`view` 每帧 `viewRefreshBlocks` 无脑走分块路径,相机概览时 `pickLevel` 选 level1(696 块)被 budget=64 砍到 64/696(9% 稀疏)→ 看着空。
|
||||
- 修复:`viewRefreshBlocks` 按相机选中 level 分流(同 12c renderLOD 已验):
|
||||
- 相机选中 **level0**(最近、要全分辨率,X=44476 无法成单纹理)→ 分块 + budget(核外 LRU,原路径不变)。
|
||||
- 相机选中 **level≥1**(概览/中远)→ `wholeVolumeLevelFor` 从 picked 起向粗找第一个“整卷各轴 ≤16384”的层(本数据 level0/1 的 X=44476/22238>16384 → 升 level2),用 `buildLevelImage` 整卷重组单张 image,单块喂 mapper(忽略 budget,粗层本就小)。整卷 image 按 level 缓存,仅 level 变化时重组。
|
||||
- 效果:概览不再是 64/696 稀疏块,而是 **1 个整卷块**渲染完整体。
|
||||
|
||||
### Bug 2:控制台中文乱码(GBK)
|
||||
- 修复:`main()` 入口 `#ifdef _WIN32` 下 `SetConsoleOutputCP(CP_UTF8);`(含 `<windows.h>`)。保留全文件已有中文输出,全子命令受益。
|
||||
|
||||
## 离屏自检结果(view --smoke,tmp\store_lod_001)
|
||||
修复前:
|
||||
```
|
||||
[view] 预热: level=1 视野块=696/696 驻留=64 渲染块=64 ← 64/696 稀疏
|
||||
```
|
||||
修复后:
|
||||
```
|
||||
[view] 预热: level=1 视野块=696/696 驻留=64 渲染块=1 ← 整卷单块(升 level2)
|
||||
=== view --smoke 离屏冒烟 ===
|
||||
近观 level=1 → 拉远 level=3 → 再拉近 level=1
|
||||
LOD 随缩放切换 : 是 ✔ (blocksFar=1)
|
||||
纹理维度错误 : 否
|
||||
渲出非空像素 : 是 (近=1024000 远拉近=1024000)
|
||||
smoke 结果 : OK ✔ 不崩
|
||||
```
|
||||
- **概览渲染块 64 → 1(整卷)**:核心修复,整卷完整渲染而非 9% 稀疏。
|
||||
- 渲出非空像素:是(1024000,无纹理错、不崩)。注:该视角整卷与原稀疏块均填满帧,像素计数饱和,故区分性证据是“渲染块 64→1(整卷)”。
|
||||
- **编码正常**:`=== view --smoke 离屏冒烟 ===` 等中文在 UTF-8 控制台正确显示,无 GBK 乱码。
|
||||
|
||||
## 提交干净性确认
|
||||
- `git diff --cached --stat` 提交前确认 index 仅含 `tools/gpr_poc/main.cpp`,无 chart/scatter/quill/rangeslider/Dialog/FormK 等并行会话文件。
|
||||
- 仅 `git add tools/gpr_poc/main.cpp`(及本报告),绝无 `git add -A`。
|
||||
|
||||
## 给用户的重跑命令
|
||||
真窗口交互(开窗即见完整粗层体,滚轮拉近变清晰/分块):
|
||||
```
|
||||
build\release\tools\gpr_poc\gpr_poc.exe view tmp\store_lod_001 --exagg 8 --opacity 0.6
|
||||
```
|
||||
离屏自检:
|
||||
```
|
||||
build\release\tools\gpr_poc\gpr_poc.exe view tmp\store_lod_001 --exagg 8 --opacity 0.6 --smoke
|
||||
```
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
# Task 12d-polish 报告:梯度不透明度 + 光照 打磨探针
|
||||
|
||||
## 状态
|
||||
完成。真实离屏渲染、真实 fps,无编造。三张对比图均通过双闸(无 3D 纹理维度错 + 渲出高于背景像素)。
|
||||
|
||||
## 命令
|
||||
`gpr_poc polish tmp\store_lod_001 --frames 90`(默认取景 El45/Az30/Zoom1.5「斜穿俯视」,视线从上方斜穿体内部而非只看端面)。
|
||||
|
||||
## 测试体
|
||||
- 全分辨率 level0 局部段:256 x 29 x 162(沿线中段 4 brick 列 [345,349)/695),垂向夸张 exagg=8(放大薄 Y/Z 轴使截面可读)。
|
||||
- 三图标量传函/配色/取景/夸张全相同,唯一变量是「梯度不透明度 / 光照」。
|
||||
|
||||
## 梯度幅值分布(量化域,中心差分,545211 样本,按实测标定阈值)
|
||||
median=5.32,p75=20.1,p90=196.2,p99=9058.5,max=21470。
|
||||
梯度不透明度 piecewise(按此分布标定,非猜):grad≤5.32→0.0、grad=196.2(p90)→0.5、grad≥9058.5(p99)→0.9。
|
||||
即:占多数的低梯度均匀区透明,仅高梯度处(层界面)不透明。
|
||||
标量不透明度峰值:基线 a=0.15(与默认体绘制同档→白雾);b/c 梯度门控压住均匀区后提到 0.6,让层界面净不透明度(标量×梯度)足够高、层面成实面。
|
||||
|
||||
## 三张对比图(docs/superpowers/plans/poc-lod-shots/)
|
||||
|
||||
| 图 | 路径 | 高于背景像素(>35) | 结构像素(>50) | 平均亮度(0-255) | fps |
|
||||
|---|---|---|---|---|---|
|
||||
| a 基线白雾 | polish-a-value.png | 219980 (21.5%) | 145874 (14.2%) | 20.07 | 160.8 |
|
||||
| b +梯度不透明度 | polish-b-grad.png | 50358 (4.9%) | 36430 (3.6%) | 15.71 | 58.2 |
|
||||
| c +梯度+光照 | polish-c-grad-shade.png | 25008 (2.4%) | 756 (0.07%) | 14.81 | 57.3 |
|
||||
|
||||
光照参数(c):ShadeOn,Ambient 0.3 / Diffuse 0.7 / Specular 0.2 / SpecularPower 10。
|
||||
|
||||
## 目视结论:内部层是否「浮」出来了?
|
||||
|
||||
**部分浮出来了,但不是全身——这正是层状数据的固有限制。**
|
||||
|
||||
- **a(基线)**:一根平滑均匀的灰蓝色长条,没有任何内部层次,只有端面隐约可辨——就是需求描述的「体中间均匀白雾、只端面有层次」。穿透均匀水平层积分成雾。
|
||||
- **b(+梯度不透明度)**:体的大部分(沿线中段那段均匀体)变透明、白雾消失,**端部/过渡区露出清晰的水平层状条纹**(层界面),底部另现一块淡蓝层状斑。证实:梯度不透明度确实把均匀积分雾抹掉、把层界面显出来了。
|
||||
- **c(+梯度+光照)**:在 b 基础上端部层条纹带上轻微立体明暗(层带有了明暗起伏的层次感),但 shading 整体压暗,可见区更少更暗。
|
||||
|
||||
**关键如实结论**:梯度不透明度 + 光照**能消除均匀白雾、并让「确有梯度突变的层界面」浮出成可读的层状条纹**——打磨方向有效。**但对这条道路 GPR 数据,强梯度集中在端部/过渡区;沿线的长段水平层因「沿测线方向看过去是均匀的」(梯度低)会整段变透明,而不会显出层。**所以打磨**改善了「层界面可见性」**,但**无法让整条体内部都「长出层」——长均匀段的内部偏雾/偏空是层状数据本身的固有属性,不是没打磨。** 想看长段内部层,应配合切片/正交截面,而非纯体绘制穿透积分。
|
||||
|
||||
## fps 代价
|
||||
梯度不透明度需逐采样点算梯度,fps 从基线 160 降到 ~58(约 -64%),但仍远高于交互级 15fps,**可接受**。光照(c)相对 b 近乎免费(58→57)。本机 RTX 3060 数;最低配未验。
|
||||
|
||||
## 提交自检
|
||||
- 仅 `git add` tools/gpr_poc/main.cpp + 3 张 polish-*.png + 本报告;未 `git add -A`。
|
||||
- `git diff --cached --stat` 确认无 chart/scatter/quill/rangeslider/Dialog/FormK。
|
||||
- 未改任何交互默认(探针性质,仅新增 polish 子命令与三个 polish 专用辅助函数)。
|
||||
|
|
@ -0,0 +1,173 @@
|
|||
# Task 12d 收尾探针报告 —— 视觉调优 + fps 预算 + 可交互开窗
|
||||
|
||||
实测环境: 本机 RTX 3060 / VTK 9.6 / MSVC+Ninja。store: `tmp/store_lod_001`
|
||||
(level0 = 44476×29×162, 4 层金字塔, brick=64, 2.09 亿体素)。
|
||||
|
||||
所有数字为真实离屏实测, 双闸(纹理错捕获 + 回读非空像素)防假帧率。
|
||||
|
||||
---
|
||||
|
||||
## 状态
|
||||
|
||||
完成。三件事全部落地、编译通过、离屏实测出数:
|
||||
- ① `tune` 视觉调优: 出 `lod-tuned-local.png` / `lod-tuned-overview.png`, 打印调优前后 fps 对照。
|
||||
- ② `fps-budget`: 递增全分辨率窗口 fps 表 + 每帧体素预算结论。
|
||||
- ③ `view`: 真窗口 + interactor + 缩放切 LOD + 屏幕 fps 文本; 离屏 `--smoke` 通过不崩。
|
||||
|
||||
改动文件: `tools/gpr_poc/main.cpp` (新增 3 个子命令 + 视觉调优共享构件), 新增两张调优截图,
|
||||
追加写 `docs/superpowers/plans/poc-results-C.md`。
|
||||
|
||||
---
|
||||
|
||||
## ① 视觉调优: 调优前后 fps 对照(证实视觉调优 fps 近乎中性)
|
||||
|
||||
`gpr_poc tune <store> --opacity 0.7 --exagg 8 --localBricks 4` (level0 256×29×162 局部段):
|
||||
|
||||
| 配置 | 色阶 | 不透明度 | 垂向夸张 | 局部 fps |
|
||||
|---|---|---|---|---|
|
||||
| 调优前(基线) | 蓝-白-红线性单斜坡 | 0.15 | 1× | 323.3 |
|
||||
| 调优后 | 结构色阶(深蓝→青→白→黄→红) + 双端斜坡 | 0.7 | 8× | 349.2 |
|
||||
|
||||
**fps 变化 = −8.0%(即调优后反而更快)**。完全证实探针认知:
|
||||
|
||||
- 隔离实验(`--exagg 1`): 不透明度 0.15→0.5/0.6、换结构色阶, fps −5.5%(更快)。
|
||||
→ **配色/不透明度对 fps 近乎中性, 调高不透明度甚至更快(光线提前终止)。**
|
||||
- 隔离实验(`--opacity 0.9 --exagg 10`): fps 反而 +49%(更快)。
|
||||
双端斜坡把占多数的近零背景设透明, 不透明片段少 + 提前终止, 抵消了夸张放大的屏占。
|
||||
- 早先一版"线性单斜坡 + exagg 8"曾掉 34%, 经排查 **掉帧全部来自垂向夸张(8× 放大薄轴
|
||||
→ 屏占变大 → ray-cast 片段变多), 与不透明度/配色无关**。改用双端斜坡(背景透明)后
|
||||
即转为净加速。
|
||||
|
||||
**关键视觉修复**: GPR/地震体值集中在零附近(背景), 强反射在正负两端。原线性单斜坡让
|
||||
近零背景填满体、遮住结构(实测渲出一块均匀蓝板, 无结构)。改为**双端斜坡(中段透明 +
|
||||
正负两端不透明)** 后, 截面的层状反射(地层条带)清晰可辨。
|
||||
|
||||
调优截图:
|
||||
- `docs/superpowers/plans/poc-lod-shots/lod-tuned-local.png`
|
||||
—— 全分辨率局部段, 可见多条水平层状反射条带(地层结构)+ 一处相干蓝色异常体。
|
||||
- `docs/superpowers/plans/poc-lod-shots/lod-tuned-overview.png`
|
||||
—— 粗层(level2)概览。物理真实: 整线 2.2km×1.5m×8m 极扁, 概览就是一条细带(可接受)。
|
||||
|
||||
> 诚实说明: 体物理纵横比极端(X≈2.2km vs Y≈1.5m / Z≈8m), 即便取局部段 + 8× 夸张,
|
||||
> 单帧里结构仍偏小、偏一隅, 背景大片黑。结构确实可辨(层状条带 + 异常体), 但"一眼炸裂"
|
||||
> 受物理形态限制——这正是 brief 预期的"细带本质"。production 可配可调色阶/取景控件让
|
||||
> 用户交互找最佳视角(即 ③ view)。
|
||||
|
||||
---
|
||||
|
||||
## ② fps 预算: 递增全分辨率(level0)窗口 → 每帧体素预算
|
||||
|
||||
`gpr_poc fps-budget <store> --bricks 4,16,64,128,256,512,695 --frames 90`
|
||||
(沿线中段递增 brick 列, 单 image 整段体绘制, 双闸):
|
||||
|
||||
| brick 段 | 维度 | 体素数 | 体绘制 fps | ≥30 | 备注 |
|
||||
|---|---|---|---|---|---|
|
||||
| 4 | 256×29×162 | 1,202,688 | 218.3 | 是 | |
|
||||
| 16 | 1024×29×162 | 4,810,752 | 155.7 | 是 | |
|
||||
| 64 | 4096×29×162 | 19,243,008 | 240.9 | 是 | |
|
||||
| 128 | 8192×29×162 | 38,486,016 | 305.8 | 是 | |
|
||||
| 256 | 16384×29×162 | 76,972,032 | 329.7 | 是 | 触达 GL_MAX_3D_TEXTURE_SIZE=16384 |
|
||||
| 512 | 32768×29×162 | 153,944,064 | INVALID | 否 | X=32768>16384, 纹理墙, 双闸标 INVALID |
|
||||
| 695 | 44476×29×162 | 208,948,248 | INVALID | 否 | 同上 |
|
||||
|
||||
### 每帧体素预算结论(重要, 与 brief 框架略有出入但更真实)
|
||||
|
||||
- **fps 在所有可上传测点(≤16384 单轴)始终 ≫ 30(218~330fps), 全程没跌破 30。** fps 不随
|
||||
体素数单调下降(甚至上升), 因 ray-cast 成本主要由屏占 × 采样步长决定, 而薄维度(Y29/Z162)
|
||||
使光线路径短, 单 3D 纹理上传成功后体素总数不是瓶颈。
|
||||
- **真正的硬墙是 GL_MAX_3D_TEXTURE_SIZE = 16384**: 单轴超 16384 → 整段无法成单张 3D 纹理
|
||||
(512/695 行双闸正确判 INVALID, 绝不当真上报)。
|
||||
- 因此本数据集上, **"单张 3D 纹理的每帧体素预算" = 单轴 ≤16384 → ≈ 7700 万体素(256 brick 列)**
|
||||
跑 ~330fps 仍极宽裕; **限制 production LOD 每帧块数的不是 30fps 阈值, 而是 16384 纹理墙——
|
||||
超墙必须切块(MultiBlock / SetPartitions / 本机核外 OutOfCoreSource)。**
|
||||
- fps 驱动的体素预算(跌破 30)只会在远更大/更稠密体或多块叠加渲染时出现; 本数据集薄维度下
|
||||
GPU 余量充足, 未触达。
|
||||
|
||||
> 这与 brief"找 fps<30 阈值"的设想不同, 但是实测真相: **本数据集的命门是纹理尺寸墙,
|
||||
> 不是帧率墙**。如实记录。
|
||||
|
||||
---
|
||||
|
||||
## ③ `gpr_poc view <store>` —— 真窗口可交互(给用户肉眼测 + 最低配机跑)
|
||||
|
||||
实现要点:
|
||||
- 真 `vtkRenderWindow` + `vtkRenderWindowInteractor`(`vtkInteractorStyleTrackballCamera`),
|
||||
挂 `OutOfCoreSource`(核外 LOD + 视野选块, budget 限驻留, 内存恒定)。
|
||||
- 相机变化(`EndInteractionEvent`)→ `source.update(camera)` 重选 LOD/视野块 → 重建 MultiBlock
|
||||
→ 重渲。**缩放跨越距离/对角线档位时 LOD 真切换**(离屏 smoke 实测 level 1↔0 切换)。
|
||||
- 屏幕左上角 `vtkTextActor` 实时显示 `fps | LOD level | blocks | exagg`, 每帧更新。
|
||||
- 默认结构色阶 + 双端斜坡不透明度 + 垂向夸张(同 ①)。
|
||||
- 参数: `--exagg N --opacity F --budget K`(K=每帧最大全分辨率块数, 接 ② 预算)。
|
||||
|
||||
离屏 smoke(`view --smoke`)实测:
|
||||
```
|
||||
预热: level=1 视野块=696/696 驻留=64 渲染块=64
|
||||
近观 level=1 → 拉远 level=1 → 再拉近 level=0
|
||||
LOD 随缩放切换 : 是 ✔
|
||||
纹理维度错误 : 否
|
||||
渲出非空像素 : 是 (近=1024000 远拉近=1024000)
|
||||
smoke 结果 : OK ✔ 不崩
|
||||
```
|
||||
|
||||
### view 命令用法
|
||||
|
||||
```
|
||||
gpr_poc view <storeDir> [--exagg 8] [--opacity 0.6] [--budget 64] [--smoke]
|
||||
```
|
||||
- 不带 `--smoke` = 开真窗口可交互(留给用户跑)。
|
||||
- 带 `--smoke` = 离屏建管线 + 模拟缩放验 LOD 切换 + 验不崩(CI/无显示环境用)。
|
||||
|
||||
---
|
||||
|
||||
## 给用户的肉眼测试说明(请转达用户)
|
||||
|
||||
**启动命令**(在已构建的仓库根目录):
|
||||
```
|
||||
build\release\tools\gpr_poc\gpr_poc.exe view tmp\store_lod_001 --exagg 8 --opacity 0.6 --budget 64
|
||||
```
|
||||
- DLL/PATH: 无需手设。CMake 已把 VTK/Qt 等运行时 DLL 拷到 exe 旁(`gpr_poc.exe` 同目录),
|
||||
直接双击/命令行运行即可。
|
||||
- 若换其它 store, 把 `tmp\store_lod_001` 换成你的金字塔 store 目录(需先 `gpr_poc build ... --levels 3`)。
|
||||
|
||||
**操作:**
|
||||
- **滚轮**: 向前滚拉近 → 应看到全分辨率结构(屏幕 `LOD level` 数字变小, 0=最细);
|
||||
向后滚拉远 → 变粗层概览(level 数字变大, 体变糊)。
|
||||
- **左键拖动**: 旋转视角(TrackballCamera)。
|
||||
- **q 键 / 关窗**: 退出。
|
||||
|
||||
**判断点(可接受标准):**
|
||||
1. **拉近后能否看清地质结构**: 局部段应呈现水平层状反射条带(地层)+ 可辨的相干异常体。
|
||||
能看出层次即可接受(受物理细带形态限制, 不会像规则立方体那样饱满)。
|
||||
2. **概览(细带)可不可接受**: 拉远后是一条细长带(整线 2.2km×1.5m×8m 物理真实), 接受它是细带。
|
||||
3. **拉近/拉远切 LOD 时卡不卡、糊→清过渡能不能接受**: 切换应顺滑, 无明显卡死/长 stall
|
||||
(本机切换 ~5-9ms, 远小于 1 个 60Hz 帧 16.7ms, 不可感)。
|
||||
4. **屏幕 fps 是否 ≥30**: 屏幕左上角实时 fps。本机(RTX 3060)远超 30(数百 fps);
|
||||
**最低配机重点看这条**——拉到最细 LOD、最大夸张时 fps 是否仍 ≥30。
|
||||
|
||||
**最低配怎么跑:**
|
||||
- 把整个 `build\release\tools\gpr_poc\` 目录(含所有 DLL)+ 一个 store 目录拷到目标机,
|
||||
跑上面的 `view` 命令, 肉眼看屏幕 fps 与交互流畅度。
|
||||
- 或无显示/批处理场景跑 `gpr_poc fps-budget tmp\store_lod_001` 出该机的体素-fps 表对照。
|
||||
|
||||
---
|
||||
|
||||
## 最低配未验声明
|
||||
|
||||
本探针仅在本机 **RTX 3060** 跑出上限数字(数百 fps, 余量充足)。**最低配机器未验证**,
|
||||
需用户拿目标机跑 `gpr_poc view <store>`(肉眼判 fps≥30 + 交互流畅)或 `gpr_poc fps-budget <store>`
|
||||
(出该机体素-fps 表)。production 是否对最低配可用, 以目标机实测为准。
|
||||
|
||||
---
|
||||
|
||||
## Concerns
|
||||
|
||||
1. **视觉天花板受物理形态限制**: 体极扁(2.2km×1.5m×8m), 单帧结构偏小偏一隅。这是数据物理
|
||||
真实, 非 bug; production 应给用户交互色阶/取景/裁剪控件(view 已具备旋转缩放, 色阶可参数化)。
|
||||
2. **fps 不是本数据集的瓶颈, 纹理尺寸墙(16384)才是**: 与 brief"找 fps<30 阈值"设想不同。
|
||||
每帧体素预算结论是"单轴 ≤16384 即可单纹理上传, fps 仍 ≫30", 超墙必须切块。如实记录。
|
||||
3. **view 的 LOD 阈值按未夸张几何标定**: `pickLevel` 用 level0 原始对角线算距离比, 而 actor
|
||||
已 `SetScale(1,exagg,exagg)`。夸张会轻微平移"缩放-LOD 映射"档位, 但切换仍正常触发
|
||||
(smoke 实测 level 1↔0)。若用户觉得切档时机别扭, 后续可让 pickLevel 感知夸张系数。
|
||||
4. **view 连续拖动 fps 文本基于上一帧耗时估算**(单帧 wall-clock 倒数), 非滑动平均, 数字会抖;
|
||||
足够给用户感知量级(几十/几百 fps), 非精密基准(精密基准走 fps-budget/renderLOD 离屏)。
|
||||
5. `last-metrics.txt`(repo 根, 探针追加输出)未纳入提交——它从未被 git 跟踪, 是瞬时产物。
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
# Task 9b 报告:gpr_poc CLI + 真实数据 headless 度量
|
||||
|
||||
状态:**PARTIAL / BLOCKED**
|
||||
- CLI 编译链接通过;`selftest` PASS(合成数据端到端跑通整条地基)。
|
||||
- 真实明星路数据 **BLOCKED**:前置 IO 层 `readIprb` 的 `traces=lastTrace+1` 严格校验
|
||||
与真实文件「道数=lastTrace」系统性不符,装配阶段即抛异常,无法实测建体指标。
|
||||
**未擅自修改前置/其单测**(被现有测试钉死的契约 + 跨任务边界),故真实指标暂缺,如实记录。
|
||||
|
||||
---
|
||||
|
||||
## 1. 交付物(均为本会话自有文件)
|
||||
|
||||
- `tools/gpr_poc/main.cpp` —— CLI:`build` / `load` / `selftest` 三子命令。
|
||||
- `tools/gpr_poc/Probe.hpp` —— header-only 计时(steady_clock)+ 峰值内存(Psapi `PeakWorkingSetSize`)。
|
||||
含 `NOMINMAX`/`WIN32_LEAN_AND_MEAN` 防 `<windows.h>` 宏污染 `std::numeric_limits::min/max`。
|
||||
- `tools/gpr_poc/CMakeLists.txt` —— 可执行 `gpr_poc`,链 `geopro_io_gpr/geopro_core/geopro_store/geopro_render`
|
||||
+ Windows `Psapi`;`vtk_module_autoinit` 注册 VTK 工厂。
|
||||
- 顶层 `CMakeLists.txt` —— 加 `add_subdirectory(tools/gpr_poc)`(在 `add_subdirectory(src)` 之后)。
|
||||
- `docs/superpowers/plans/poc-results-B.md` —— 实测结果(selftest PASS + 真实数据 BLOCKED 根因表)。
|
||||
|
||||
注:库目标实际名为 `geopro_store`(brief 写作 geopro_store/已对齐)与 `geopro_data`;
|
||||
本工具链 `geopro_store`(分块存储),正确。
|
||||
|
||||
## 2. 构建
|
||||
|
||||
- 配置:`cmd /c "build.bat configure"`(preset msvc-release,build/release)成功
|
||||
(cmd 被环境劫持但真实命令仍执行;以 build.ninja 出现 gpr_poc target 确认)。
|
||||
- 编译:PowerShell + vcvars64 直驱 cmake `--build build/release --target gpr_poc`。
|
||||
首次失败:`<windows.h>` min/max 宏污染 → 加 NOMINMAX 修复 → 二次链接成功。
|
||||
- 运行需 PATH 带 Qt6/VTK/vcpkg bin(headless 工具仍依赖这些 DLL)。
|
||||
|
||||
## 3. selftest 结果
|
||||
|
||||
```
|
||||
gpr_poc selftest
|
||||
[selftest] GridSpec 2x2x8 dz=0.714286
|
||||
[selftest] PASS (exit 0)
|
||||
```
|
||||
覆盖:assembleGprSurvey → buildGprVolume → write(brick=4) → buildPyramid(1) →
|
||||
WholeVolumeSource,断言维度/层数/体素非 blank 全通过。
|
||||
|
||||
## 4. 真实数据指标
|
||||
|
||||
**未实测(BLOCKED)**。根因:`readIprb`(`src/io/gpr/IprbReader.cpp:16`)
|
||||
`traces=lastTrace+1` 严格字节校验;真实明星路 14 通道每个恰含 `lastTrace` 道(少 1 道),
|
||||
逐通道实测一致(详见 poc-results-B.md §2 表)。非 OOM/超时——装配前读入即失败,
|
||||
调 `--cellXY` 无法绕过。现有 `tests/io/gpr/test_iprb_reader.cpp:30-31` 锁定该抛异常契约。
|
||||
|
||||
用的参数:`--line 001 --cellXY 0.2 --cellZ 0.05 --levels 2`(建体未到达)。
|
||||
预估几何(非实测,供核对):nx≈11118, ny≈8, nz≈1(深度尺度因土速单位为微米级,
|
||||
cellZ=0.05 压成单层——需 POC owner 复核土速/时窗单位与 cellZ)。
|
||||
|
||||
## 5. 提交前自检
|
||||
|
||||
- 仅 `git add` 自有文件:`tools/gpr_poc/*`、顶层 `CMakeLists.txt`、
|
||||
`docs/superpowers/plans/poc-results-B.md`、本报告 `.superpowers/sdd/task-9b-report.md`。
|
||||
- `git diff --cached --stat` 确认无 chart/scatter/quill/rangeslider 等并行会话行。
|
||||
- 顶层 CMakeLists 的暂存 diff 应仅含新增的 `add_subdirectory(tools/gpr_poc)` 一行块。
|
||||
|
||||
## 6. Concerns / 需 owner 决策
|
||||
|
||||
1. **真实数据 BLOCKER(高)**:`readIprb` 道数契约与真实数据不符。建议放宽为
|
||||
「道数 = 文件字节 / (samples·2)」(容忍 ±N 道),或确认 LAST TRACE 语义后去 +1,
|
||||
并同步改单测。落地后重跑两条命令即可补齐 §4 真实指标。
|
||||
2. **深度尺度(中)**:SOIL VELOCITY=100 m/s(头单位 m/µs ×1e6)→ 深度跨度微米级,
|
||||
cellZ=0.05 会把 Z 压成 1 层。影响真实体维度与 9c 渲染基准,需确认单位约定。
|
||||
3. 顶层 CMakeLists 当前 working tree 已有他会话的修改(视觉设计/chart 等);本会话只新增
|
||||
add_subdirectory 一行,暂存时务必只 stage 该文件并核对 diff,勿带入其他未暂存改动。
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
# Task 9c 报告:POC-B 离屏 GPU 渲染基准
|
||||
|
||||
状态:**DONE**(闸门通过;真实基准实测完成;关键发现如实记录,无任何编造 fps)
|
||||
|
||||
执行机:Windows 11,MSVC(VS18 Community)+ Ninja,Release;GPU = NVIDIA RTX 3060 Laptop GPU,OpenGL 4.5.0 NVIDIA 555.97。
|
||||
日期:2026-06-23。
|
||||
|
||||
---
|
||||
|
||||
## 1. 交付物
|
||||
|
||||
- `tools/gpr_poc/main.cpp`:新增两个子命令
|
||||
- `gpr_poc offscreen-smoke` —— 最小离屏渲染冒烟(闸门),打印 OK/FAIL + GL 能力。
|
||||
- `gpr_poc renderB <storeDir> [--frames 120]` —— 离屏体绘制 + 切片扫描 fps 基准。
|
||||
- `tools/gpr_poc/CMakeLists.txt`:补 VTK 组件(RenderingVolume / RenderingVolumeOpenGL2 / ImagingCore / InteractionStyle)。
|
||||
- `docs/superpowers/plans/poc-results-B.md`:新增「§4 离屏 GPU 渲染基准」段(闸门 + 真实指标 + 关键发现 + 结论)。
|
||||
- `build/_t9c_build.bat`:本任务用的 gpr_poc 单 target 构建脚本(vcvars64 直驱 cmake)。
|
||||
|
||||
## 2. 闸门结果 —— OK
|
||||
|
||||
`offscreen-smoke`:离屏 vtkRenderWindow(SetOffScreenRendering+SetShowWindow(false))→ cube actor → Render() →
|
||||
GetRGBACharPixelData 读回 65536 像素,非背景 28224。GL vendor=NVIDIA,硬件加速 True。**离屏 GL 可用**,继续真实基准。
|
||||
|
||||
## 3. 真实 GPU 指标(line 001, cellXY=0.05, cellZ=0.05)
|
||||
|
||||
- 体维度:**44476 × 29 × 162**;体素数 ≈2.09 亿;整卷字节 **398.54 MB**(int16)。
|
||||
- **体绘制 fps:INVALID** —— 整卷 X 维 44476 超 `GL_MAX_3D_TEXTURE_SIZE=16384`,
|
||||
`vtkVolumeTexture` 报 `Invalid texture dimensions [44476,29,162]`,未真正绘出体数据。
|
||||
raw_fps=295.6 是空纹理假帧率,已显式标 INVALID,**不作为体绘制性能上报**。
|
||||
- **切片扫描 fps:54.6 fps**(120 帧沿 Z 扫整卷,vtkImageReslice 2D 切面 + 2D 纹理;不受 3D 纹理上限约束)。≥30fps 目标达成。
|
||||
- 是否进显存:**否**(瓶颈是单轴纹理维度上限 16384,非显存字节;整卷 398 MB << RTX 3060 6GB 显存)。
|
||||
- GPU 显存(NVX):**N/A**(随包 VTK 安装未带 GLEW 头,无法链 GL loader 直查;GL 扩展列表确认机器支持 NVX_gpu_memory_info)。
|
||||
- 进程峰值内存:**≈509 MB**(加载整卷 398 MB + 渲染管线)。
|
||||
- build 峰值内存:4830 MB(装配阶段 double survey 主导,与 §2 一致);无 OOM,cellXY=0.05 一次通过。
|
||||
|
||||
## 4. concerns
|
||||
|
||||
1. **整卷朴素体绘制对长测线根本不可行**:X=44476 撞 OpenGL 单轴 3D 纹理上限 16384。
|
||||
与显存容量无关,是硬限制。任何「整卷一次性 3D 纹理」方案对长测线都会撞墙。
|
||||
这是 **Task 12(核外 / 分块 LOD / 体纹理分区 `vtkOpenGLGPUVolumeRayCastMapper::SetPartitions`)**
|
||||
的硬性依据。本任务按约束未做核外,仅如实记录。
|
||||
2. SmartVolumeMapper 报 GPURenderMode=2 但纹理上传失败——`GetLastUsedRenderMode()` 不能单独作为
|
||||
「真的渲染出来了」的判据;renderB 已加 OutputWindow 捕获 + 维度超限双判据才下 INVALID 结论。
|
||||
3. GPU 显存读数缺失(N/A):仅因 VTK 安装未带 GLEW 头;若需要可后续单独链 GL loader 调 NVX 枚举。
|
||||
|
|
@ -78,5 +78,8 @@ endif()
|
|||
|
||||
add_subdirectory(src)
|
||||
|
||||
# POC-B headless 度量 CLI(gpr_poc)。链 io_gpr/core/store/render,在真实数据上跑端到端度量。
|
||||
add_subdirectory(tools/gpr_poc)
|
||||
|
||||
enable_testing()
|
||||
add_subdirectory(tests)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,528 @@
|
|||
{
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "Geopro3 三维视图 API(三维体 / 切片 / 异常 三件套)",
|
||||
"version": "0.4.0-draft",
|
||||
"description": "VTK 三维视图后端接口。归属结构(2026-06-23 定稿):**TM → 三维体(dd_voxel) → 切片(dd_slice)**,异常挂在三维体上(remarkSourceId=三维体 dsObjectId)。\n\n**总原则:实体无关的契约一律复用存量;只为各自特有、存量装不下的部分扩展。**\n- 三维体/切片对后端 = 纯元数据 dsObject:增删改查/属性复用存量 dsObject 面,各加 1 个登记端点;体素字节/切面数据全在客户端(算+存+取+渲染),后端零数据端点。\n- 异常复用整套存量 /business/exception 端点(端点不限实体类型,三维体 id 直接塞 remarkSourceId);**异常体(consortium)分组也是存量已有**(consortiumId/Name/Type)。3D 仅扩展两处:location 加 worldPts+plane(三维几何)、加截图(R88)。\n\n响应统一信封 `{ code:int, msg:string, data:object|array }`,code==200 成功;列表/集合放 data.value。\n\n依赖前提:异常 remarkSourceId 指向三维体,须等三维体登记出真 dsObjectId 后,3D 异常才能接真端点。"
|
||||
},
|
||||
"servers": [
|
||||
{ "url": "/", "description": "业务网关根(各路径已含 /business 前缀)" }
|
||||
],
|
||||
"tags": [
|
||||
{ "name": "dsObject-reuse", "description": "复用的存量统一面(三维体/切片共用增删改查+属性)" },
|
||||
{ "name": "voxel-new", "description": "三维体新增:仅登记记录(体素全在客户端)" },
|
||||
{ "name": "slice-new", "description": "切片新增:仅登记记录(切面全在客户端)" },
|
||||
{ "name": "exception-reuse", "description": "复用的存量异常面(端点不限实体类型;3D 仅扩展 location 几何+截图)" }
|
||||
],
|
||||
"paths": {
|
||||
"/business/projectStruct/queryProjectStruct/{projectId}": {
|
||||
"get": {
|
||||
"tags": ["dsObject-reuse"],
|
||||
"summary": "[复用] 查询项目结构树(GS/TM 骨架)",
|
||||
"description": "存量端点。提供三维体挂载所需的 TM 节点;三维体/切片作为派生数据另经 data/page 取。",
|
||||
"operationId": "queryProjectStruct",
|
||||
"parameters": [ { "name": "projectId", "in": "path", "required": true, "schema": { "type": "string" } } ],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "成功",
|
||||
"content": { "application/json": { "schema": { "allOf": [ { "$ref": "#/components/schemas/Envelope" }, { "type": "object", "properties": { "data": { "$ref": "#/components/schemas/StructNodeList" } } } ] } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/business/dsObject/data/page": {
|
||||
"post": {
|
||||
"tags": ["dsObject-reuse"],
|
||||
"summary": "[复用] 分页查询某父节点下的数据集行",
|
||||
"description": "存量端点(loadRowsAsync)。查三维体:structParentId=tmObjectId、structParentConfType=2;查某三维体下切片:structParentId=该三维体 dsObjectId。返回行 ddCode=dd_voxel / dd_slice。",
|
||||
"operationId": "dsObjectDataPage",
|
||||
"requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DsPageRequest" } } } },
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "成功",
|
||||
"content": { "application/json": { "schema": { "allOf": [ { "$ref": "#/components/schemas/Envelope" }, { "type": "object", "properties": { "data": { "$ref": "#/components/schemas/DsPage" } } } ] } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/business/dsObject/getDetail/{dsObjectId}": {
|
||||
"get": {
|
||||
"tags": ["dsObject-reuse"],
|
||||
"summary": "[复用] 数据集详情(描述 + attachedParameters)",
|
||||
"description": "存量端点。三维体构建参数(attachedParameters.voxelParams)、切片三点位姿(attachedParameters.slicePose)从这里读出。",
|
||||
"operationId": "dsObjectGetDetail",
|
||||
"parameters": [ { "$ref": "#/components/parameters/DsObjectIdPath" } ],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "成功",
|
||||
"content": { "application/json": { "schema": { "allOf": [ { "$ref": "#/components/schemas/Envelope" }, { "type": "object", "properties": { "data": { "$ref": "#/components/schemas/DsObjectDetail" } } } ] } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/business/dsObject/dynamicForm/{dsObjectId}": {
|
||||
"get": {
|
||||
"tags": ["dsObject-reuse"],
|
||||
"summary": "[复用] 数据集属性(动态表单)",
|
||||
"description": "存量端点。三维体/切片可读属性由后端为 dd_voxel/dd_slice 注册 formList 后从此返回,无需为属性新增固定字段接口。",
|
||||
"operationId": "dsObjectDynamicForm",
|
||||
"parameters": [ { "$ref": "#/components/parameters/DsObjectIdPath" } ],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "成功",
|
||||
"content": { "application/json": { "schema": { "allOf": [ { "$ref": "#/components/schemas/Envelope" }, { "type": "object", "properties": { "data": { "$ref": "#/components/schemas/DynamicForm" } } } ] } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/business/dsObject/updateDsObject/": {
|
||||
"put": {
|
||||
"tags": ["dsObject-reuse"],
|
||||
"summary": "[复用] 更新数据集(描述 / attachedParameters)",
|
||||
"description": "存量端点。改名/改描述/改三维体参数/改切片位姿都走这条。注意 URL 末尾斜杠为服务端实证要求。",
|
||||
"operationId": "updateDsObject",
|
||||
"requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UpdateDsObjectRequest" } } } },
|
||||
"responses": { "200": { "$ref": "#/components/responses/StatusOk" } }
|
||||
}
|
||||
},
|
||||
"/business/dsObject/{dsObjectId}": {
|
||||
"delete": {
|
||||
"tags": ["dsObject-reuse"],
|
||||
"summary": "[复用] 删除数据集",
|
||||
"description": "存量端点。删三维体级联其下切片/异常记录(客户端另清本地体素落盘);删切片不影响异常(异常挂三维体、与切片解耦)。",
|
||||
"operationId": "deleteDsObject",
|
||||
"parameters": [ { "$ref": "#/components/parameters/DsObjectIdPath" } ],
|
||||
"responses": { "200": { "$ref": "#/components/responses/StatusOk" } }
|
||||
}
|
||||
},
|
||||
"/business/dsObject/voxel/generate": {
|
||||
"post": {
|
||||
"tags": ["voxel-new"],
|
||||
"summary": "[新增] 登记三维体记录",
|
||||
"description": "在 tmObjectId 下登记一条 dd_voxel dsObject(名称 + 构建参数写入 attachedParameters.voxelParams),返回新 dsObjectId。只建记录、不触发后端计算——体素插值/落盘/渲染全在客户端。",
|
||||
"operationId": "registerVoxel",
|
||||
"requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/VoxelGenerateRequest" } } } },
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "成功",
|
||||
"content": { "application/json": { "schema": { "allOf": [ { "$ref": "#/components/schemas/Envelope" }, { "type": "object", "properties": { "data": { "$ref": "#/components/schemas/CreatedRef" } } } ] } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/business/dsObject/slice/generate": {
|
||||
"post": {
|
||||
"tags": ["slice-new"],
|
||||
"summary": "[新增] 登记切片记录",
|
||||
"description": "在所属三维体下登记一条 dd_slice dsObject(名称 + 三点位姿写入 attachedParameters.slicePose),返回新 dsObjectId。只建记录、不触发后端计算——切面据「体+位姿」在客户端重采样渲染。",
|
||||
"operationId": "registerSlice",
|
||||
"requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SliceGenerateRequest" } } } },
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "成功",
|
||||
"content": { "application/json": { "schema": { "allOf": [ { "$ref": "#/components/schemas/Envelope" }, { "type": "object", "properties": { "data": { "$ref": "#/components/schemas/CreatedRef" } } } ] } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/business/exceptionType/queryExceptionTypeByProjectIdAndType/{projectId}/{remarkSourceType}": {
|
||||
"get": {
|
||||
"tags": ["exception-reuse"],
|
||||
"summary": "[复用] 异常类型列表",
|
||||
"description": "存量端点(queryExceptionTypeData)。按项目 + 标注形态(remarkSourceType=1点/2线/3面/4文字)查可用异常类型。与实体类型无关 → 3D 通用。data.value 为类型数组。",
|
||||
"operationId": "queryExceptionTypes",
|
||||
"parameters": [
|
||||
{ "name": "projectId", "in": "path", "required": true, "schema": { "type": "string" } },
|
||||
{ "name": "remarkSourceType", "in": "path", "required": true, "schema": { "type": "string", "enum": ["1", "2", "3", "4"] }, "description": "标注形态:1点/2线/3面/4文字" }
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "成功",
|
||||
"content": { "application/json": { "schema": { "allOf": [ { "$ref": "#/components/schemas/Envelope" }, { "type": "object", "properties": { "data": { "$ref": "#/components/schemas/ExceptionTypeList" } } } ] } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/business/exceptionType": {
|
||||
"post": {
|
||||
"tags": ["exception-reuse"],
|
||||
"summary": "[复用] 新增异常类型",
|
||||
"description": "存量端点(addExceptionType)。异常属性 + 标注名称双 Tab 组装的类型定义(含图例/字段列表)。3D 直接复用。",
|
||||
"operationId": "addExceptionType",
|
||||
"requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AddExceptionTypeRequest" } } } },
|
||||
"responses": { "200": { "$ref": "#/components/responses/StatusOk" } }
|
||||
}
|
||||
},
|
||||
"/business/exception/getExceptionName": {
|
||||
"post": {
|
||||
"tags": ["exception-reuse"],
|
||||
"summary": "[复用] 取建议异常名称",
|
||||
"description": "存量端点(queryExceptionNameInProfileInversion)。按异常类型 + 被标注实体回填建议名。3D 把 remarkSourceId 填三维体 id 即可。data 为纯字符串(名称)。",
|
||||
"operationId": "getExceptionName",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["exceptionTypeId", "remarkSourceId"],
|
||||
"properties": {
|
||||
"exceptionTypeId": { "type": "string" },
|
||||
"remarkSourceId": { "type": "string", "description": "2D=dsObjectId;3D=三维体 dsObjectId" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "成功",
|
||||
"content": { "application/json": { "schema": { "allOf": [ { "$ref": "#/components/schemas/Envelope" }, { "type": "object", "properties": { "data": { "type": "string", "description": "建议名称(纯字符串)" } } } ] } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/business/exception": {
|
||||
"post": {
|
||||
"tags": ["exception-reuse"],
|
||||
"summary": "[复用+扩展] 新增异常",
|
||||
"description": "存量端点(newExceptionInProfileInversion)。2D 字段全复用;**3D 扩展**:location 增加 worldPts(三维几何) + plane(所在平面),并增加 screenshot(R88 截图)。remarkSourceId=三维体 dsObjectId;consortiumId 归入异常体(存量已有)。",
|
||||
"operationId": "newException",
|
||||
"requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/NewExceptionRequest" } } } },
|
||||
"responses": { "200": { "$ref": "#/components/responses/StatusOk" } }
|
||||
},
|
||||
"put": {
|
||||
"tags": ["exception-reuse"],
|
||||
"summary": "[复用+扩展] 更新异常",
|
||||
"description": "存量端点(updateExceptionDataInProfileInversion)。改名/备注/几何;3D 同样可改扩展后的 location 与截图。",
|
||||
"operationId": "updateException",
|
||||
"requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UpdateExceptionRequest" } } } },
|
||||
"responses": { "200": { "$ref": "#/components/responses/StatusOk" } }
|
||||
}
|
||||
},
|
||||
"/business/exception/{id}": {
|
||||
"delete": {
|
||||
"tags": ["exception-reuse"],
|
||||
"summary": "[复用] 删除异常",
|
||||
"description": "存量端点(deleteExceptionDataInProfileInversion)。删异常体=按 consortiumId 循环删其下异常(无专用批删端点)。",
|
||||
"operationId": "deleteException",
|
||||
"parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } ],
|
||||
"responses": { "200": { "$ref": "#/components/responses/StatusOk" } }
|
||||
}
|
||||
},
|
||||
"/business/exception/queryException/{remarkSourceId}": {
|
||||
"get": {
|
||||
"tags": ["exception-reuse"],
|
||||
"summary": "[复用+扩展] 查某实体下的异常列表",
|
||||
"description": "存量端点。2D 传 dsObjectId、3D 传三维体 dsObjectId。返回该实体全部异常;客户端按 consortiumId 分组成异常体树。**3D 扩展**:返回项 location 含 worldPts+plane、并含 screenshot。",
|
||||
"operationId": "queryExceptionBySource",
|
||||
"parameters": [ { "name": "remarkSourceId", "in": "path", "required": true, "schema": { "type": "string" }, "description": "2D=dsObjectId;3D=三维体 dsObjectId" } ],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "成功",
|
||||
"content": { "application/json": { "schema": { "allOf": [ { "$ref": "#/components/schemas/Envelope" }, { "type": "object", "properties": { "data": { "$ref": "#/components/schemas/ExceptionList" } } } ] } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/business/exception/queryExceptionByTmObjectId/{tmObjectId}": {
|
||||
"get": {
|
||||
"tags": ["exception-reuse"],
|
||||
"summary": "[复用] 查某 TM 下全部异常(按异常体分组)",
|
||||
"description": "存量端点(loadExceptionsByTmAsync)。返回 TM 下全部异常,含 consortiumId/consortiumName/consortiumType(异常体分组,存量已有),客户端按 consortiumId 归组。",
|
||||
"operationId": "queryExceptionByTm",
|
||||
"parameters": [ { "name": "tmObjectId", "in": "path", "required": true, "schema": { "type": "string" } } ],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "成功",
|
||||
"content": { "application/json": { "schema": { "allOf": [ { "$ref": "#/components/schemas/Envelope" }, { "type": "object", "properties": { "data": { "$ref": "#/components/schemas/ExceptionList" } } } ] } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"parameters": {
|
||||
"DsObjectIdPath": {
|
||||
"name": "dsObjectId", "in": "path", "required": true,
|
||||
"schema": { "type": "string" }, "description": "数据集 dsObject id"
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"StatusOk": {
|
||||
"description": "操作状态",
|
||||
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Envelope" } } }
|
||||
}
|
||||
},
|
||||
"schemas": {
|
||||
"Envelope": {
|
||||
"type": "object",
|
||||
"required": ["code", "msg"],
|
||||
"properties": {
|
||||
"code": { "type": "integer", "example": 200, "description": "200=成功,其它=失败" },
|
||||
"msg": { "type": "string", "example": "操作成功" },
|
||||
"data": { "description": "业务载荷(对象或数组)", "nullable": true }
|
||||
}
|
||||
},
|
||||
"CreatedRef": {
|
||||
"type": "object",
|
||||
"required": ["dsObjectId"],
|
||||
"properties": { "dsObjectId": { "type": "string", "description": "新建数据集 id" } }
|
||||
},
|
||||
"Vec3": {
|
||||
"type": "array", "description": "世界系三分量(米)",
|
||||
"items": { "type": "number", "format": "double" }, "minItems": 3, "maxItems": 3
|
||||
},
|
||||
"Pt2": {
|
||||
"type": "object", "description": "2D 点(剖面系:x=距离, y=深度)",
|
||||
"properties": { "x": { "type": "number", "format": "double" }, "y": { "type": "number", "format": "double" } }
|
||||
},
|
||||
"InterpModel": {
|
||||
"type": "string", "enum": ["Idw", "Kriging"], "default": "Idw",
|
||||
"description": "插值模型(本期仅 Idw 实现,Kriging 占位)"
|
||||
},
|
||||
"RemarkSourceType": {
|
||||
"type": "string", "enum": ["1", "2", "3", "4"],
|
||||
"description": "标注形态(非实体类型):1点/2线/3面/4文字"
|
||||
},
|
||||
"StructNodeList": {
|
||||
"type": "object", "description": "结构树信封内层(data.value)",
|
||||
"properties": { "value": { "type": "array", "items": { "$ref": "#/components/schemas/StructNode" } } }
|
||||
},
|
||||
"StructNode": {
|
||||
"type": "object", "description": "项目结构扁平节点(GS/TM),客户端按 parentId 建树",
|
||||
"properties": {
|
||||
"id": { "type": "string" }, "name": { "type": "string" }, "parentId": { "type": "string" },
|
||||
"typeName": { "type": "string" }, "confCode": { "type": "string" },
|
||||
"typeId": { "type": "string", "description": "类型 id(编辑时 getDynamicForm 必需)" },
|
||||
"type": { "type": "integer" }
|
||||
}
|
||||
},
|
||||
"DsPageRequest": {
|
||||
"type": "object", "description": "存量 dsObject/data/page 请求体",
|
||||
"required": ["projectId", "structParentId", "structParentConfType", "classifyTypeList", "pageNo", "pageSize"],
|
||||
"properties": {
|
||||
"projectId": { "type": "string" },
|
||||
"structParentId": { "type": "string", "description": "查三维体填 tmObjectId;查切片填所属三维体 dsObjectId" },
|
||||
"structParentConfType": { "type": "integer", "description": "父节点配置类型:TM=2(三维体场景)" },
|
||||
"classifyTypeList": { "type": "array", "items": { "type": "integer" }, "description": "数据类别过滤(dd_voxel/dd_slice 的 classify code 由后端定义)" },
|
||||
"pageNo": { "type": "integer", "default": 1 },
|
||||
"pageSize": { "type": "integer", "default": 20 }
|
||||
}
|
||||
},
|
||||
"DsPage": {
|
||||
"type": "object", "description": "分页结果",
|
||||
"properties": { "total": { "type": "integer" }, "value": { "type": "array", "items": { "$ref": "#/components/schemas/DsRow" } } }
|
||||
},
|
||||
"DsRow": {
|
||||
"type": "object", "description": "数据集行(列表/树节点)",
|
||||
"required": ["id", "ddCode"],
|
||||
"properties": {
|
||||
"id": { "type": "string" }, "dsName": { "type": "string" },
|
||||
"ddCode": { "type": "string", "enum": ["dd_voxel", "dd_slice"] },
|
||||
"typeName": { "type": "string", "example": "三维体" },
|
||||
"parentId": { "type": "string", "nullable": true, "description": "三维体=tmObjectId;切片=所属三维体 dsObjectId" }
|
||||
}
|
||||
},
|
||||
"DsObjectDetail": {
|
||||
"type": "object", "description": "存量 dsObject/getDetail 返回。三维体/切片机器参数搭车 attachedParameters。",
|
||||
"properties": {
|
||||
"dsObjectId": { "type": "string" }, "name": { "type": "string" },
|
||||
"description": { "type": "string", "nullable": true },
|
||||
"attachedParameters": {
|
||||
"type": "object", "description": "附加参数(自由结构化 blob)",
|
||||
"properties": {
|
||||
"deltaContent": { "type": "array", "items": { "type": "object" }, "description": "描述富文本(Quill delta ops)" },
|
||||
"voxelParams": { "allOf": [ { "$ref": "#/components/schemas/VolumeBuildParams" } ], "nullable": true, "description": "dd_voxel 专属:构建参数(体素字节在客户端本地)" },
|
||||
"slicePose": { "allOf": [ { "$ref": "#/components/schemas/SliceSpec" } ], "nullable": true, "description": "dd_slice 专属:三点位姿(切面在客户端重采样)" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"DynamicForm": {
|
||||
"type": "object", "description": "ds 属性动态表单(存量统一模型)",
|
||||
"properties": {
|
||||
"formList": {
|
||||
"type": "array", "description": "分组列表",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": { "type": "string", "description": "分组名" },
|
||||
"values": { "type": "array", "items": { "type": "object", "properties": { "name": { "type": "string" }, "value": { "type": "string" } } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"UpdateDsObjectRequest": {
|
||||
"type": "object", "required": ["dsObjectId"],
|
||||
"properties": {
|
||||
"dsObjectId": { "type": "string" }, "description": { "type": "string" },
|
||||
"attachedParameters": {
|
||||
"type": "object", "description": "改三维体参数/切片位姿放这里",
|
||||
"properties": {
|
||||
"deltaContent": { "type": "array", "items": { "type": "object" } },
|
||||
"voxelParams": { "allOf": [ { "$ref": "#/components/schemas/VolumeBuildParams" } ], "nullable": true },
|
||||
"slicePose": { "allOf": [ { "$ref": "#/components/schemas/SliceSpec" } ], "nullable": true }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"VolumeBuildParams": {
|
||||
"type": "object", "description": "三维体构建参数(必存元数据;体素字节由客户端按此本地插值,不上后端)",
|
||||
"required": ["sourceDatasetIds"],
|
||||
"properties": {
|
||||
"sourceDatasetIds": { "type": "array", "items": { "type": "string" }, "minItems": 1, "description": "源数据集 id(≥1,被引用即锁定不可改)" },
|
||||
"interpModel": { "$ref": "#/components/schemas/InterpModel" },
|
||||
"cellXY": { "type": "number", "format": "double", "default": 1.0, "description": "水平网格间距(米)" },
|
||||
"cellZ": { "type": "number", "format": "double", "default": 0.5, "description": "竖向网格间距(米)" },
|
||||
"power": { "type": "number", "format": "double", "default": 2.0, "description": "IDW 幂" },
|
||||
"maxDist": { "type": "number", "format": "double", "default": 4.0, "description": "超距 blank" },
|
||||
"colorScaleId": { "type": "string", "nullable": true, "description": "色阶来源 ds(空=取首个源色阶)" }
|
||||
}
|
||||
},
|
||||
"VoxelGenerateRequest": {
|
||||
"type": "object", "required": ["projectId", "tmObjectId", "name", "sourceDatasetIds"],
|
||||
"properties": {
|
||||
"projectId": { "type": "string" },
|
||||
"tmObjectId": { "type": "string", "description": "归属 TM —— 三维体挂在 TM 下(structParentConfType=2)" },
|
||||
"name": { "type": "string" },
|
||||
"sourceDatasetIds": { "type": "array", "items": { "type": "string" }, "minItems": 1 },
|
||||
"interpModel": { "$ref": "#/components/schemas/InterpModel" },
|
||||
"cellXY": { "type": "number", "format": "double", "default": 1.0 },
|
||||
"cellZ": { "type": "number", "format": "double", "default": 0.5 },
|
||||
"power": { "type": "number", "format": "double", "default": 2.0 },
|
||||
"maxDist": { "type": "number", "format": "double", "default": 4.0 },
|
||||
"colorScaleId": { "type": "string", "nullable": true }
|
||||
}
|
||||
},
|
||||
"SliceSpec": {
|
||||
"type": "object",
|
||||
"description": "切面精确几何(三点+轴向)。法向=normalize((p1-o)×(p2-o)),可派生。存于 attachedParameters.slicePose。",
|
||||
"required": ["volumeDsId", "origin", "point1", "point2"],
|
||||
"properties": {
|
||||
"volumeDsId": { "type": "string", "description": "所属三维体 dsObjectId" },
|
||||
"axis": { "type": "integer", "enum": [0, 1, 2, 3], "default": 3, "description": "0 上下/1 前后/2 左右/3 任意" },
|
||||
"origin": { "$ref": "#/components/schemas/Vec3" },
|
||||
"point1": { "$ref": "#/components/schemas/Vec3" },
|
||||
"point2": { "$ref": "#/components/schemas/Vec3" },
|
||||
"colorScaleId": { "type": "string", "nullable": true }
|
||||
}
|
||||
},
|
||||
"SliceGenerateRequest": {
|
||||
"type": "object", "required": ["projectId", "volumeDsId", "name", "origin", "point1", "point2"],
|
||||
"properties": {
|
||||
"projectId": { "type": "string" },
|
||||
"volumeDsId": { "type": "string", "description": "所属三维体 dsObjectId —— 切片挂在三维体下" },
|
||||
"name": { "type": "string" },
|
||||
"axis": { "type": "integer", "enum": [0, 1, 2, 3], "default": 3 },
|
||||
"origin": { "$ref": "#/components/schemas/Vec3" },
|
||||
"point1": { "$ref": "#/components/schemas/Vec3" },
|
||||
"point2": { "$ref": "#/components/schemas/Vec3" },
|
||||
"colorScaleId": { "type": "string", "nullable": true }
|
||||
}
|
||||
},
|
||||
"ExceptionLocation": {
|
||||
"type": "object",
|
||||
"description": "异常几何载荷。2D 用 coordinate;**3D 扩展** worldPts+plane。后端须扩展 location schema 以往返保存 3D 字段。",
|
||||
"properties": {
|
||||
"coordinate": { "type": "array", "items": { "$ref": "#/components/schemas/Pt2" }, "description": "2D 剖面坐标点(存量)" },
|
||||
"worldPts": { "type": "array", "items": { "$ref": "#/components/schemas/Vec3" }, "description": "【3D 扩展】异常多边形/折线世界 3D 点" },
|
||||
"plane": {
|
||||
"type": "object", "nullable": true, "description": "【3D 扩展】异常所在平面",
|
||||
"properties": { "normal": { "$ref": "#/components/schemas/Vec3" }, "origin": { "$ref": "#/components/schemas/Vec3" } }
|
||||
}
|
||||
}
|
||||
},
|
||||
"ExceptionRecord": {
|
||||
"type": "object",
|
||||
"description": "异常记录(queryException 返回项)。前段为存量 2D 字段,末尾为 3D 扩展。",
|
||||
"properties": {
|
||||
"id": { "type": "string" },
|
||||
"exceptionName": { "type": "string" },
|
||||
"exceptionTypeId": { "type": "string" },
|
||||
"exceptionTypeName": { "type": "string" },
|
||||
"remark": { "type": "string", "nullable": true },
|
||||
"createTime": { "type": "string" },
|
||||
"markType": { "$ref": "#/components/schemas/RemarkSourceType" },
|
||||
"remarkSourceId": { "type": "string", "description": "2D=dsObjectId;3D=三维体 dsObjectId" },
|
||||
"remarkSourceType": { "$ref": "#/components/schemas/RemarkSourceType" },
|
||||
"location": { "$ref": "#/components/schemas/ExceptionLocation" },
|
||||
"latitudeLongitude": { "type": "object", "description": "经纬度坐标(存量展示用)", "properties": { "latLon": { "type": "array", "items": { "type": "object", "properties": { "longitude": { "type": "number" }, "latitude": { "type": "number" } } } } } },
|
||||
"geographicalCoordinates": { "type": "object", "description": "投影坐标(存量展示用)", "properties": { "coordinates": { "type": "array", "items": { "type": "object", "properties": { "northCoord": { "type": "number" }, "eastCoord": { "type": "number" } } } } } },
|
||||
"consortiumId": { "type": "string", "nullable": true, "description": "异常体分组 id(存量已有);空=未分组 loose" },
|
||||
"consortiumName": { "type": "string", "nullable": true },
|
||||
"consortiumType": { "type": "string", "nullable": true },
|
||||
"legend": { "type": "object", "description": "图例样式(颜色/线宽/虚实等)" },
|
||||
"screenshot": { "type": "string", "nullable": true, "description": "【3D 扩展 R88】异常截图(base64 或文件引用,传输方式由后端定)" }
|
||||
}
|
||||
},
|
||||
"ExceptionList": {
|
||||
"type": "object", "description": "异常列表信封内层(data.value)",
|
||||
"properties": { "value": { "type": "array", "items": { "$ref": "#/components/schemas/ExceptionRecord" } } }
|
||||
},
|
||||
"ExceptionTypeRow": {
|
||||
"type": "object", "description": "异常类型项",
|
||||
"properties": {
|
||||
"id": { "type": "string" },
|
||||
"exceptionTypeName": { "type": "string" },
|
||||
"exceptionTypeCode": { "type": "string" },
|
||||
"exceptionMarkType": { "$ref": "#/components/schemas/RemarkSourceType" },
|
||||
"legend": { "type": "object" }
|
||||
}
|
||||
},
|
||||
"ExceptionTypeList": {
|
||||
"type": "object", "description": "异常类型列表信封内层(data.value)",
|
||||
"properties": { "value": { "type": "array", "items": { "$ref": "#/components/schemas/ExceptionTypeRow" } } }
|
||||
},
|
||||
"AddExceptionTypeRequest": {
|
||||
"type": "object",
|
||||
"description": "新增异常类型(对照存量 addExceptionType 全字段)",
|
||||
"required": ["exceptionTypeName", "exceptionMarkType", "projectId"],
|
||||
"properties": {
|
||||
"exceptionTypeName": { "type": "string" },
|
||||
"exceptionTypeCode": { "type": "string" },
|
||||
"standardNumber": { "type": "string", "nullable": true },
|
||||
"standardName": { "type": "string", "nullable": true },
|
||||
"description": { "type": "string", "nullable": true },
|
||||
"legend": { "type": "object", "description": "按 markType 的图例样式" },
|
||||
"exceptionNameList": { "type": "array", "items": { "type": "object", "properties": { "fieldName": { "type": "string" }, "fieldCode": { "type": "string" }, "sort": { "type": "integer" } } } },
|
||||
"customFormat": { "type": "string", "nullable": true },
|
||||
"separatorSymbol": { "type": "string", "nullable": true },
|
||||
"projectId": { "type": "string" },
|
||||
"exceptionMarkType": { "$ref": "#/components/schemas/RemarkSourceType" },
|
||||
"type": { "type": "integer", "default": 2 }
|
||||
}
|
||||
},
|
||||
"NewExceptionRequest": {
|
||||
"type": "object",
|
||||
"description": "新增异常。存量字段 + 3D 扩展(location.worldPts/plane、screenshot)。",
|
||||
"required": ["exceptionName", "exceptionTypeId", "projectId", "remarkSourceId", "remarkSourceType", "location"],
|
||||
"properties": {
|
||||
"exceptionName": { "type": "string" },
|
||||
"exceptionTypeId": { "type": "string" },
|
||||
"projectId": { "type": "string" },
|
||||
"remarkSourceId": { "type": "string", "description": "2D=dsObjectId;3D=三维体 dsObjectId" },
|
||||
"remarkSourceType": { "$ref": "#/components/schemas/RemarkSourceType" },
|
||||
"remark": { "type": "string", "nullable": true },
|
||||
"location": { "$ref": "#/components/schemas/ExceptionLocation" },
|
||||
"consortiumId": { "type": "string", "nullable": true, "description": "归入异常体(存量已有;空=loose)" },
|
||||
"screenshot": { "type": "string", "nullable": true, "description": "【3D 扩展 R88】异常截图" }
|
||||
}
|
||||
},
|
||||
"UpdateExceptionRequest": {
|
||||
"type": "object",
|
||||
"description": "更新异常(对照存量 updateException)。",
|
||||
"required": ["id"],
|
||||
"properties": {
|
||||
"id": { "type": "string" },
|
||||
"exceptionName": { "type": "string" },
|
||||
"remark": { "type": "string", "nullable": true },
|
||||
"location": { "allOf": [ { "$ref": "#/components/schemas/ExceptionLocation" } ], "nullable": true },
|
||||
"consortiumId": { "type": "string", "nullable": true },
|
||||
"screenshot": { "type": "string", "nullable": true, "description": "【3D 扩展 R88】" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,383 @@
|
|||
# GPR 三维体 POC(B & C 双方案)实现计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 用真实 13G 雷达数据为 B(整卷上 GPU)与 C(分块+金字塔+核外)两套对等方案做 POC,验证技术可行性并挖出 spec 未预见的阻塞;POC 代码即生产地基与接口实现,不返工。
|
||||
|
||||
**Architecture:** 共用地基(解析/几何/结构化建体/int16 量化体/分块存储)+ `IVolumeRenderSource` 渲染接缝;`WholeVolumeSource`(B) 与 `OutOfCoreSource`(C) 是接口下两个永久并存实现,用户运行时按数据规模切换。落盘从第一天就分块,B 的裸分块格式是 C 金字塔/核外的基座。
|
||||
|
||||
**Tech Stack:** C++17, Qt6, VTK 9.6(`RenderingVolumeOpenGL2` GPU ray cast,自带 `vtkzlib`),GoogleTest,nlohmann-json(sidecar),现有 `src/{core,data,render,app}` 分层。
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **dtype**:雷达体走 **int16**(`vtkShortArray`),不污染反演剖面的 double 主路径(`ScalarVolume` 保持 `std::vector<double>`,`src/core/model/Field.hpp:8-26`)。
|
||||
- **量化**:物理值 ↔ int16 经 `scale/offset`,**必须贯穿**传递函数采样、色阶 LUT(`src/render/interact/SliceTool.cpp:37`)、取值/详情反量化(见 spec B §3.5)。
|
||||
- **落盘**:**不用 `vtkHDFWriter`**(VTK 9.6 写不了 `vtkImageData`,记忆 `vtk96-hdfwriter-no-imagedata`)。用裸 int16 分块 + sidecar(json) + 逐块 `vtkzlib`。
|
||||
- **渲染接缝**:上层(场景/切片)只面向 `IVolumeRenderSource`;B/C 是其两个实现。
|
||||
- **结构化建体**:X(沿线)/Z(深度) 规则落格,仅 Y(14 通道) 向 1D 插值;**不**对雷达用全 3D 散点 IDW(现有 `IdwInterpolator` 无空间索引暴力,`src/core/algo/IdwInterpolator.cpp:15-33`)。
|
||||
- **真实数据判定**:POC 用 `D:\Downloads\明星路`(450MHz/14通道/821采样/~45306道/线/20线/int16/13.6GB)。POC 过 = 在该真实数据上跑通并达标,不许避重就轻。
|
||||
- **测试数据头实证**:`.iprh` 文本键值(`SAMPLES/LAST TRACE/CHANNELS/TIMEWINDOW/SOIL VELOCITY/DISTANCE INTERVAL`);`.iprb` = `int16[samples × traces]`,`samples×traces×2 == 文件大小`;`.ord` = 通道横向偏移(14 有效)。
|
||||
|
||||
---
|
||||
|
||||
## 文件结构(决定分解与复用)
|
||||
|
||||
```
|
||||
src/io/gpr/ ← 新增:雷达 IO(共用地基)
|
||||
IprHeader.{hpp,cpp} 解析 .iprh → 结构体
|
||||
IprbReader.{hpp,cpp} 读 .iprb int16 B-scan(mmap/分块读)
|
||||
GprGeometry.{hpp,cpp} .ord 通道偏移 + .gps/.cor 逐道经纬 + 深度轴
|
||||
GprSurvey.{hpp,cpp} 一个工区 = 线[]×通道[] + 几何(建体输入)
|
||||
src/core/model/
|
||||
ScalarVolumeI16.hpp int16 体 + Quant{scale,offset} (新增,与 ScalarVolume 并列)
|
||||
src/core/algo/
|
||||
GprVolumeBuilder.{hpp,cpp} 结构化建体:X/Z 落格 + Y 向 1D 插值 → ScalarVolumeI16
|
||||
src/data/store/
|
||||
ChunkedVolumeStore.{hpp,cpp} 分块 int16 + zlib + sidecar;读/写/按块取(B/C 共用,C 加金字塔)
|
||||
src/render/source/
|
||||
IVolumeRenderSource.hpp 渲染接缝(接口)
|
||||
WholeVolumeSource.{hpp,cpp} B:读全块 → 1 个 vtkImageData
|
||||
OutOfCoreSource.{hpp,cpp} C:金字塔 + brick 分页 → 工作集
|
||||
src/render/actors/
|
||||
VoxelActor.cpp Modify:buildVoxel 增 int16 重载 + 量化域传函
|
||||
tests/io/gpr/ tests/core/ tests/data/store/ ← 对应测试
|
||||
tools/gpr_poc/ ← POC 度量台(建体/加载/显存/fps 探针 + CLI)
|
||||
```
|
||||
|
||||
POC 度量统一进 `tools/gpr_poc`(建体耗时、输出维度、落盘体积/压缩比、加载耗时、显存、切片/体绘制 fps),B/C 用同一套指标对照。
|
||||
|
||||
> **POC vs 生产**:Task 1–6(地基)+ 7–8、10–11(接口/存储)是**生产代码,走 TDD、有完整代码**。Task 9、12–13 是**可行性探针**:给出明确实验、被测未知、通过/失败判据与度量,不预先杜撰我们正要验证的 VTK 核外内部实现——这是 POC 的本质,强行写"完整代码"等于造假。
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — 地基(共用,生产级 TDD)
|
||||
|
||||
### Task 1: .iprh 头解析
|
||||
|
||||
**Files:**
|
||||
- Create: `src/io/gpr/IprHeader.hpp`, `src/io/gpr/IprHeader.cpp`
|
||||
- Test: `tests/io/gpr/test_ipr_header.cpp`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `struct IprHeader { int samples; long lastTrace; int channels; double timeWindowNs; double soilVelocity; double distanceInterval; };` + `IprHeader parseIprHeader(const std::string& text);`
|
||||
|
||||
- [ ] **Step 1: 写失败测试**
|
||||
```cpp
|
||||
#include "io/gpr/IprHeader.hpp"
|
||||
#include <gtest/gtest.h>
|
||||
using geopro::io::gpr::parseIprHeader;
|
||||
TEST(IprHeader, ParsesKeyFieldsFromRealSample) {
|
||||
const std::string t =
|
||||
"SAMPLES: 821\nLAST TRACE: 45305\nCHANNELS: 14\n"
|
||||
"TIMEWINDOW: 160.352\nSOIL VELOCITY: 100.000000\nDISTANCE INTERVAL: 0.049084\n";
|
||||
auto h = parseIprHeader(t);
|
||||
EXPECT_EQ(h.samples, 821);
|
||||
EXPECT_EQ(h.lastTrace, 45305);
|
||||
EXPECT_EQ(h.channels, 14);
|
||||
EXPECT_DOUBLE_EQ(h.timeWindowNs, 160.352);
|
||||
EXPECT_DOUBLE_EQ(h.soilVelocity, 100.0);
|
||||
EXPECT_NEAR(h.distanceInterval, 0.049084, 1e-9);
|
||||
}
|
||||
```
|
||||
- [ ] **Step 2: 运行确认失败** — `ctest -R IprHeader`,预期 编译/链接失败(未定义)。
|
||||
- [ ] **Step 3: 最小实现** — `parseIprHeader` 逐行 `key: value` 拆分,按字段名填结构体;缺字段抛 `std::runtime_error`。
|
||||
- [ ] **Step 4: 运行确认通过** — `ctest -R IprHeader`,预期 PASS。
|
||||
- [ ] **Step 5: 提交** — `git commit -m "feat(gpr): parse .iprh header fields"`
|
||||
|
||||
### Task 2: .iprb B-scan 读取
|
||||
|
||||
**Files:**
|
||||
- Create: `src/io/gpr/IprbReader.{hpp,cpp}`
|
||||
- Test: `tests/io/gpr/test_iprb_reader.cpp`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `IprHeader`
|
||||
- Produces: `struct BScan { int samples; long traces; std::vector<int16_t> data; /* [trace*samples + s] */ };` + `BScan readIprb(const std::string& path, const IprHeader& h);`(校验 `samples*traces*2 == fileSize`)
|
||||
|
||||
- [ ] **Step 1: 写失败测试**(用临时文件造 4 道×3 采样 int16)
|
||||
```cpp
|
||||
TEST(IprbReader, ReadsInt16AndValidatesSize) {
|
||||
// 写 tmp:samples=3, traces=4 → 24 bytes
|
||||
std::vector<int16_t> raw{0,1,2, 10,11,12, 20,21,22, 30,31,32};
|
||||
auto path = writeTmp(raw); // helper
|
||||
geopro::io::gpr::IprHeader h{}; h.samples=3; h.lastTrace=3; // traces=lastTrace+1=4
|
||||
auto b = geopro::io::gpr::readIprb(path, h);
|
||||
EXPECT_EQ(b.samples, 3); EXPECT_EQ(b.traces, 4);
|
||||
EXPECT_EQ(b.data[1*3 + 2], 12); // 第1道第2采样
|
||||
}
|
||||
```
|
||||
- [ ] **Step 2: 运行确认失败**。
|
||||
- [ ] **Step 3: 最小实现** — `traces = lastTrace+1`;读全文件为 int16;不匹配大小抛错。
|
||||
- [ ] **Step 4: 通过**。
|
||||
- [ ] **Step 5: 提交** — `git commit -m "feat(gpr): read .iprb int16 b-scan with size check"`
|
||||
|
||||
### Task 3: 几何(通道偏移 + 逐道经纬 + 深度轴)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/io/gpr/GprGeometry.{hpp,cpp}`
|
||||
- Test: `tests/io/gpr/test_gpr_geometry.cpp`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces:
|
||||
- `std::vector<double> parseChannelXOffsets(const std::string& ordText);`(取第 4 列==1 的有效通道横偏,明星路应得 14 个 -0.686..+0.686)
|
||||
- `double depthOfSample(int s, const IprHeader& h);`(`= s * (timeWindowNs/(samples-1)) * soilVelocity*1e-9/2`,单位米;soilVelocity 100 m/µs = 1e8 m/s)
|
||||
|
||||
- [ ] **Step 1: 写失败测试**
|
||||
```cpp
|
||||
TEST(GprGeometry, ParsesActiveChannelOffsets) {
|
||||
const std::string ord = "0 -0.686000 -1.5 1\n1 -0.581000 -1.5 1\n14 0 -1.5 0\n";
|
||||
auto xs = geopro::io::gpr::parseChannelXOffsets(ord);
|
||||
EXPECT_EQ(xs.size(), 2u); // 仅 2 个有效(末列=1)
|
||||
EXPECT_NEAR(xs[0], -0.686, 1e-6);
|
||||
}
|
||||
TEST(GprGeometry, DepthOfLastSampleMatchesPhysics) {
|
||||
geopro::io::gpr::IprHeader h{}; h.samples=821; h.timeWindowNs=160.352; h.soilVelocity=1e8;
|
||||
EXPECT_NEAR(geopro::io::gpr::depthOfSample(820, h), 8.0, 0.05); // ~8m
|
||||
}
|
||||
```
|
||||
> 注:`soilVelocity` 单位换算在 Task 1 读入时统一成 m/s(100 m/µs = 1e8 m/s),在此基础上测试。
|
||||
- [ ] **Step 2: 失败**。
|
||||
- [ ] **Step 3: 实现** — `.ord` 按空白拆列、末列=="1" 收集第 2 列;`depthOfSample` 按公式。
|
||||
- [ ] **Step 4: 通过**。
|
||||
- [ ] **Step 5: 提交** — `git commit -m "feat(gpr): channel offsets + depth axis geometry"`
|
||||
|
||||
### Task 4: int16 量化体类型
|
||||
|
||||
**Files:**
|
||||
- Create: `src/core/model/ScalarVolumeI16.hpp`
|
||||
- Test: `tests/core/test_scalar_volume_i16.cpp`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces:
|
||||
```cpp
|
||||
struct Quant { double scale = 1.0; double offset = 0.0;
|
||||
int16_t toQ(double v) const; // round((v-offset)/scale),钳到[INT16_MIN+1,INT16_MAX]
|
||||
double toPhys(int16_t q) const; }; // q*scale+offset
|
||||
class ScalarVolumeI16 { // 行优先 idx=((k*ny+j)*nx+i),与 vtkImageData 一致
|
||||
ScalarVolumeI16(int nx,int ny,int nz);
|
||||
int16_t& at(int i,int j,int k); int nx()const; ...; std::vector<int16_t>& data();
|
||||
static constexpr int16_t kBlank = INT16_MIN; }; // 空值哨兵→透明
|
||||
```
|
||||
|
||||
- [ ] **Step 1: 写失败测试**(量化往返 + 索引布局 + blank)
|
||||
```cpp
|
||||
TEST(ScalarVolumeI16, QuantRoundTripAndLayout) {
|
||||
geopro::core::Quant q{0.5, -10.0};
|
||||
EXPECT_EQ(q.toQ(-10.0), 0); EXPECT_NEAR(q.toPhys(q.toQ(3.0)), 3.0, 0.25);
|
||||
geopro::core::ScalarVolumeI16 v(2,2,2);
|
||||
v.at(1,0,1) = 7; EXPECT_EQ(v.data()[(1*2+0)*2+1], 7);
|
||||
}
|
||||
```
|
||||
- [ ] **Step 2: 失败** → **Step 3: 实现** → **Step 4: 通过**。
|
||||
- [ ] **Step 5: 提交** — `git commit -m "feat(core): int16 scalar volume + quantization"`
|
||||
|
||||
### Task 5: 结构化建体 GprVolumeBuilder
|
||||
|
||||
**Files:**
|
||||
- Create: `src/core/algo/GprVolumeBuilder.{hpp,cpp}`
|
||||
- Test: `tests/core/test_gpr_volume_builder.cpp`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `GprSurvey`(线×通道 BScan + 几何)、`GridSpec`(复用 `src/core/algo/IInterpolator.hpp:7-13`)
|
||||
- Produces: `struct BuiltI16 { ScalarVolumeI16 vol; Quant quant; std::array<double,3> origin, spacing; double vminPhys, vmaxPhys; };`
|
||||
`BuiltI16 buildGprVolume(const GprSurvey& s, const GridSpec& spec);`
|
||||
- **算法**:X(沿线)/Z(深度) 最近邻或线性落格(道已规则);**Y 向**对落在该 (x,z) 的 14 通道值做 1D 线性插值填充横向网格;maxDist/无覆盖 → `kBlank`。量化 scale/offset 由全体 min/max 定。
|
||||
|
||||
- [ ] **Step 1: 写失败测试**(2 通道、各 1 道×2 采样的人造 survey,验横向中点插值 + 维度)
|
||||
```cpp
|
||||
TEST(GprVolumeBuilder, InterpolatesAcrossChannelsOnly) {
|
||||
auto s = makeTwoChannelSurvey(/*ch0 val=0, ch1 val=100, 横偏 0 和 1m*/);
|
||||
geopro::core::GridSpec spec{/*nx=*/3,/*ny=*/1,/*nz=*/1, 0,0,0, 0.5,1,1, 2.0, 9.9};
|
||||
auto b = geopro::core::buildGprVolume(s, spec);
|
||||
EXPECT_NEAR(b.quant.toPhys(b.vol.at(1,0,0)), 50.0, 1.0); // 横向中点≈50
|
||||
}
|
||||
```
|
||||
- [ ] **Step 2: 失败** → **Step 3: 实现**(先单线程,循环结构留可并行)→ **Step 4: 通过**。
|
||||
- [ ] **Step 5: 提交** — `git commit -m "feat(core): structured GPR volume builder (Y-only interp)"`
|
||||
|
||||
### Task 6: 分块存储 ChunkedVolumeStore(B/C 共用基座)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/data/store/ChunkedVolumeStore.{hpp,cpp}`
|
||||
- Test: `tests/data/store/test_chunked_volume_store.cpp`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces:
|
||||
```cpp
|
||||
struct StoreMeta { int nx,ny,nz; int brick; // e.g. 64
|
||||
std::array<double,3> origin, spacing; Quant quant; double vminPhys,vmaxPhys; };
|
||||
class ChunkedVolumeStore {
|
||||
static void write(const std::string& dir, const BuiltI16& b, int brick=64); // 分块+zlib+sidecar.json
|
||||
static StoreMeta readMeta(const std::string& dir);
|
||||
std::vector<int16_t> readBrick(int bx,int by,int bz) const; // 解压单块
|
||||
// Task 10 追加:pyramid 层;Task 11 用 readBrick 做工作集
|
||||
};
|
||||
```
|
||||
- **格式**:`meta.json`(StoreMeta + 分块索引/偏移/压缩长度) + `data.bin`(逐块 zlib 压缩流)。zlib 用 VTK 自带 `vtkzlib` 或直接 zlib C API。
|
||||
|
||||
- [ ] **Step 1: 写失败测试**(write→readMeta→readBrick 往返 + 压缩后小于原始)
|
||||
```cpp
|
||||
TEST(ChunkedVolumeStore, RoundTripBrickAndCompresses) {
|
||||
auto b = makeBuilt(128,128,128, /*可压缩模式*/);
|
||||
geopro::data::ChunkedVolumeStore::write(tmpDir, b, 64);
|
||||
auto m = geopro::data::ChunkedVolumeStore::readMeta(tmpDir);
|
||||
EXPECT_EQ(m.nx, 128); EXPECT_EQ(m.brick, 64);
|
||||
geopro::data::ChunkedVolumeStore s(tmpDir);
|
||||
auto blk = s.readBrick(0,0,0);
|
||||
EXPECT_EQ(blk.size(), 64u*64*64);
|
||||
EXPECT_LT(fileSize(tmpDir+"/data.bin"), 128u*128*128*2); // 压缩生效
|
||||
}
|
||||
```
|
||||
- [ ] **Step 2: 失败** → **Step 3: 实现** → **Step 4: 通过**。
|
||||
- [ ] **Step 5: 提交** — `git commit -m "feat(data): chunked int16 volume store (zlib + sidecar)"`
|
||||
|
||||
### Task 7: VoxelActor int16 重载 + 量化域传递函数
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/render/actors/VoxelActor.cpp`(增 int16 重载,参照现 double 版 `:41-79`)
|
||||
- Test: `tests/render/test_voxel_i16_smoke.cpp`(无窗渲染冒烟 / 或断言 image 标量类型与传函控制点在量化域)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `vtkSmartPointer<vtkVolume> buildVoxelI16(const ScalarVolumeI16& vol, const Quant& q, const ColorScale& cs, double ox,..,dz, vtkSmartPointer<vtkImageData>& outImage);`
|
||||
- **要点**:`vtkShortArray` 填值;传函/不透明度在**量化域 qmin/qmax** 加点(`q.toQ(vminPhys)`..`q.toQ(vmaxPhys)`),`kBlank→0` 不透明;`vtkSmartVolumeMapper`。
|
||||
|
||||
- [ ] **Step 1: 写失败测试**(构造小 int16 体,断言 `outImage->GetScalarType()==VTK_SHORT` 且传函在量化域采样不崩)。
|
||||
- [ ] **Step 2: 失败** → **Step 3: 实现** → **Step 4: 通过**。
|
||||
- [ ] **Step 5: 提交** — `git commit -m "feat(render): int16 voxel actor with quantized transfer fn"`
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — POC-B(整卷上 GPU)
|
||||
|
||||
### Task 8: IVolumeRenderSource + WholeVolumeSource(B)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/render/source/IVolumeRenderSource.hpp`, `src/render/source/WholeVolumeSource.{hpp,cpp}`
|
||||
- Test: `tests/render/test_whole_volume_source.cpp`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces:
|
||||
```cpp
|
||||
class IVolumeRenderSource { public: virtual ~IVolumeRenderSource()=default;
|
||||
virtual StoreMeta meta() const = 0;
|
||||
virtual void update(const Camera& cam) = 0; // B:首次载全量;C:按相机换块
|
||||
virtual std::vector<vtkSmartPointer<vtkImageData>> currentProps() const = 0; // B:1 个;C:工作集
|
||||
virtual vtkImageData* sliceSource() const = 0; }; // 供 SliceTool reslice
|
||||
class WholeVolumeSource : public IVolumeRenderSource { // 读全块拼 1 个 int16 vtkImageData
|
||||
explicit WholeVolumeSource(const std::string& storeDir); };
|
||||
```
|
||||
|
||||
- [ ] **Step 1: 写失败测试**(从 Task 6 写出的 store 构造 WholeVolumeSource,`currentProps().size()==1`,维度==meta)。
|
||||
- [ ] **Step 2: 失败** → **Step 3: 实现**(遍历所有 brick 填进整卷 image)→ **Step 4: 通过**。
|
||||
- [ ] **Step 5: 提交** — `git commit -m "feat(render): IVolumeRenderSource + whole-volume source (B)"`
|
||||
|
||||
### Task 9: POC-B 真实数据度量(探针,含通过判据)
|
||||
|
||||
**Files:**
|
||||
- Create: `tools/gpr_poc/main.cpp`(CLI:`gpr_poc build|renderB <明星路目录>`)、`tools/gpr_poc/Probe.{hpp,cpp}`(计时/显存/fps)
|
||||
- 复用:Task 1–8 全部。
|
||||
|
||||
**被验证的未知 / 阻塞点:** ①int16 GPU ray cast 在真机正常出图;②真实建体耗时与输出体积;③整卷 5~10GB 加载耗时、显存峰值;④切片拖动 fps、体绘制 fps;⑤超显存时 `vtkSmartVolumeMapper` 的 `LowResResample`(`MaxMemoryInBytes`) 自动降质观感。
|
||||
|
||||
- [ ] **Step 1:** `gpr_poc build 明星路` → 跑 Task 1–6,输出:建体耗时、`nx×ny×nz`、落盘体积、压缩比。记录。
|
||||
- [ ] **Step 2:** `gpr_poc renderB` → WholeVolumeSource 上 `vtkSmartVolumeMapper`,量化传函着色,真窗口显示。
|
||||
- [ ] **Step 3:** Probe 采集:加载耗时、显存峰值、切片拖动 fps(脚本化相机/切片移动)、体绘制旋转 fps。
|
||||
- [ ] **Step 4:** 设 `MaxMemoryInBytes` 低于体大小,验证 `LowResResample` 自动降质路径出图。
|
||||
- [ ] **Step 5:** 写 `docs/superpowers/plans/poc-results-B.md`:指标表 + 结论。
|
||||
- **B 通过判据**:真实数据建体可完成且体积/耗时可接受;整卷在目标显存内出图;**切片拖动 ≥ 可用帧率(目标 ≥30fps)**;超显存时 LowRes 兜底可用。任一硬阻塞(如 GPU 不吃 short、显存必爆且 LowRes 不可接受)→ 记为 B 的落地风险并反馈 spec。
|
||||
- [ ] **Step 6: 提交** — `git commit -m "test(gpr): POC-B real-data metrics harness + results"`
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — POC-C(分块+金字塔+核外,含最小真实分页器)
|
||||
|
||||
### Task 10: 金字塔生成(ChunkedVolumeStore 增量)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/data/store/ChunkedVolumeStore.{hpp,cpp}`(加 LOD 层 + 每块 min/max)
|
||||
- Test: `tests/data/store/test_pyramid.cpp`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `void buildPyramid(int levels);`(逐级 2× 降采样,每层独立分块 + 每块 `int16 min,max` 存 meta);`std::vector<int16_t> readBrick(int level,int bx,int by,int bz);`;`std::pair<int16_t,int16_t> brickRange(int level,int bx,int by,int bz);`
|
||||
|
||||
- [ ] **Step 1: 写失败测试**(建 1 层金字塔,断言 level1 维度≈半、brickRange 命中真实 min/max)。
|
||||
- [ ] **Step 2: 失败** → **Step 3: 实现** → **Step 4: 通过**。
|
||||
- [ ] **Step 5: 提交** — `git commit -m "feat(data): volume pyramid + per-brick min/max"`
|
||||
|
||||
### Task 11: brick 分页器(LRU 工作集,生产级 TDD)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/render/source/BrickPager.{hpp,cpp}`
|
||||
- Test: `tests/render/test_brick_pager.cpp`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces:
|
||||
```cpp
|
||||
class BrickPager { // 内存恒定:驻留 ≤ budgetBricks 个解压块
|
||||
BrickPager(const ChunkedVolumeStore& store, size_t budgetBricks);
|
||||
void requestVisible(const std::vector<BrickId>& visible, int level); // 载入缺失、LRU 淘汰
|
||||
const std::vector<int16_t>* get(BrickId id, int level) const; // 命中返回,未命中 nullptr
|
||||
size_t residentCount() const; };
|
||||
```
|
||||
|
||||
- [ ] **Step 1: 写失败测试**(budget=4,请求 6 块 → residentCount==4,最早的被淘汰,命中/未命中正确)。
|
||||
- [ ] **Step 2: 失败** → **Step 3: 实现**(LRU + 从 store 解压载入)→ **Step 4: 通过**。
|
||||
- [ ] **Step 5: 提交** — `git commit -m "feat(render): bounded-memory brick pager (LRU)"`
|
||||
|
||||
### Task 12: OutOfCoreSource(C)— 最高风险探针:核外体绘制
|
||||
|
||||
**Files:**
|
||||
- Create: `src/render/source/OutOfCoreSource.{hpp,cpp}`(实现 `IVolumeRenderSource`)
|
||||
- 复用:Task 10/11。
|
||||
|
||||
**被验证的未知 / 阻塞点(C 的命门,必须正面撞):**
|
||||
1. **VTK 能否渲染"动态换入换出的块工作集"为体**——把 BrickPager 的工作集作为多个 `vtkImageData` 喂给 `vtkMultiBlockVolumeMapper`(注意其"试图全量加载"语义,须只喂视野块)或多个 `vtkVolume` 叠加;验证可行性与正确性。
|
||||
2. **块边接缝**:相邻 brick 渲染交界是否可见;试 `vtkMultiBlockVolumeMapper` 的抖动是否压得住。
|
||||
3. **LOD 切换**:相机拉远用粗层、拉近换细层,切换是否闪烁/可接受。
|
||||
4. **热路径解压**:拖动每帧换块时 zlib 解压是否拖垮帧率(CPU 瓶颈)。
|
||||
|
||||
> 本任务**不预写完整实现**——它就是要发现上面四点的真实结论。步骤是受控实验,产物是"能跑的最小版本 + 实测结论"。
|
||||
|
||||
- [ ] **Step 1:** `update(cam)` 内:算视野相交 brick + 选 LOD → `BrickPager::requestVisible` → `currentProps()` 返回工作集 image。
|
||||
- [ ] **Step 2:** 用 `vtkMultiBlockVolumeMapper`(或 N×`vtkVolume`)渲染工作集,量化传函复用 Task 7。先静态相机出图,确认正确。
|
||||
- [ ] **Step 3:** 接相机移动 → 动态换块;Probe 测 residentCount/内存恒定、换块 fps。
|
||||
- [ ] **Step 4:** 逐项记录未知 1–4 的实测结论(接缝截图、LOD 切换录屏指标、解压占帧时间)。
|
||||
- [ ] **Step 5:** 写 `poc-results-C.md`:四个未知的结论 + 是否构成阻塞 + 缓解手段。
|
||||
- **C 通过判据**:工作集体绘制能正确出图且**内存恒定**;接缝/闪烁/解压三项**各自有可接受方案或明确缓解**(不可接受则记为阻塞并反馈 spec C,这正是 POC 的目的)。
|
||||
- [ ] **Step 6: 提交** — `git commit -m "test(gpr): POC-C out-of-core volume render probe + results"`
|
||||
|
||||
### Task 13: 切片核外 + B/C 切换贯通
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/render/source/OutOfCoreSource.cpp`(`sliceSource()`:只读切面相交块拼子体供 reslice)
|
||||
- 复用现有 `SliceTool`(`src/render/interact/SliceTool.cpp`)对 source 给出的 image 切片。
|
||||
|
||||
**被验证的未知:** 切片只读相交块时的内存/fps;同一 `IVolumeRenderSource` 下 B↔C 运行时切换无缝。
|
||||
|
||||
- [ ] **Step 1:** `OutOfCoreSource::sliceSource()` 按当前切面算相交 brick → 拼最小子体 image。
|
||||
- [ ] **Step 2:** `SliceTool` 对该 image reslice,拖动切片测 fps、内存。
|
||||
- [ ] **Step 3:** POC 台加 `renderC` 与 `--source whole|ooc` 开关,验证同一上层代码切两实现。
|
||||
- [ ] **Step 4:** 补 `poc-results-C.md`:切片核外指标 + 切换验证。
|
||||
- [ ] **Step 5: 提交** — `git commit -m "feat(render): slice out-of-core + runtime B/C source switch"`
|
||||
|
||||
---
|
||||
|
||||
## Self-Review(对照 spec 检查)
|
||||
|
||||
**1. spec 覆盖**
|
||||
- spec B:int16 路径(Task 4/7)、结构化建体(Task 5)、裸分块落盘(Task 6)、量化贯穿(Task 4/7/Global)、整卷渲染(Task 8/9)、LowResResample(Task 9) ✓
|
||||
- spec C:分块(Task 6)、金字塔+min/max(Task 10)、brick 分页(Task 11)、核外体绘制(Task 12)、切片核外(Task 13)、B/C 切换(Task 13) ✓
|
||||
- 共有地基:.iprb/.iprh 解析(Task 1/2)、几何/配准(Task 3)、GPR→体(Task 5) ✓
|
||||
- **缺口(POC 不覆盖,明确记录)**:ddCode 接入数据集树/UI、后端持久化对接、异常/色阶编辑器接线——POC 阶段不做(spec A §2 / B §6 列为成品阶段),不影响复用。
|
||||
|
||||
**2. 占位符扫描**:地基任务(1–8,10,11)均有完整测试代码与实现描述;POC 探针(9,12,13)按设计**有意不写杜撰实现**,代之以受控实验 + 通过判据(已在任务内注明本质)。无 "TODO/TBD/适当处理" 类空话。
|
||||
|
||||
**3. 类型一致性**:`ScalarVolumeI16`/`Quant`/`BuiltI16`/`StoreMeta`/`IVolumeRenderSource`/`BrickPager` 在定义任务与消费任务间签名一致;`buildGprVolume`/`buildVoxelI16`/`ChunkedVolumeStore::{write,readMeta,readBrick}`/`requestVisible` 全程同名。
|
||||
|
||||
---
|
||||
|
||||
## 关键风险(开工即知)
|
||||
|
||||
- **Task 12 是月级生产工程的"最小探针"**:POC 只需证可行 + 撞出阻塞,不追求生产质量分页器;但若未知 1(VTK 渲染动态工作集)撞墙,是 C 的根本阻塞,须立刻反馈 spec C 并评估替代(OpenVDS / 自建 GL)。
|
||||
- **真实 13G 在合理分辨率下可能装得进显存** → B 顺过、C 的核外价值要靠"细分辨率/拼全路段大体"的真实配置才能压出(Task 9/12 的网格参数留 CLI 可调,用同一份真实数据调出超显存体来考验 C)。
|
||||
- **量化贯穿**漏一处即色阶/读数错(Global 约束 + Task 4/7),Self-Review 已盯。
|
||||
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 149 KiB |
|
After Width: | Height: | Size: 267 KiB |
|
After Width: | Height: | Size: 236 KiB |
|
After Width: | Height: | Size: 200 KiB |
|
After Width: | Height: | Size: 208 KiB |
|
After Width: | Height: | Size: 250 KiB |
|
|
@ -0,0 +1,179 @@
|
|||
# POC-B 实测结果(gpr_poc headless 度量)
|
||||
|
||||
工具:`tools/gpr_poc`(CLI),构建产物 `build/release/tools/gpr_poc/gpr_poc.exe`。
|
||||
执行机:Windows 11,MSVC(VS18 Community)+ Ninja,Release(/O2)。
|
||||
日期:2026-06-23。
|
||||
|
||||
整条地基链路:
|
||||
`assembleGprSurvey → buildGprVolume → ChunkedVolumeStore::write → buildPyramid → WholeVolumeSource(load)`。
|
||||
|
||||
---
|
||||
|
||||
## 1. selftest(合成极小数据)—— PASS
|
||||
|
||||
命令:`gpr_poc selftest`
|
||||
|
||||
- 构造 2 通道合成 survey(samples=8,traces=12),写临时 `.iprb/.iprh/.ord`,
|
||||
走完整 `assembleGprSurvey → buildGprVolume → write(brick=4) → buildPyramid(1) → WholeVolumeSource`。
|
||||
- 断言:ntraces/samples/channels、channelY 升序、GridSpec `2x2x8`、建体维度、
|
||||
金字塔层数==2、整卷维度一致、(0,0,0) 非 blank。
|
||||
- 结果:**PASS**(退出码 0)。
|
||||
|
||||
结论:除真实 `.iprb` 读入外,**整条地基管线在合成数据上端到端跑通**
|
||||
(装配几何、建体量化、分块压缩落盘、金字塔降采样、整卷重组加载均正确)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 真实数据(D:\Downloads\明星路,线 001)—— **PASS(实测)**
|
||||
|
||||
> 更新(任务 9b,2026-06-23):先前 BLOCKED 的根因(`readIprb` 硬假设
|
||||
> `traces = lastTrace + 1`)已修复。`readIprb` 改为**以文件大小为权威**
|
||||
> (`traces = fileBytes / (samples·2)`),真实数据装配通过。
|
||||
|
||||
命令:
|
||||
```
|
||||
gpr_poc build "D:\Downloads\明星路" --line 001 --cellXY 0.2 --cellZ 0.05 --out <store> --levels 2
|
||||
gpr_poc load <store>
|
||||
```
|
||||
|
||||
### 根因回顾(off-by-one:lastTrace+1 vs 真实道数)
|
||||
|
||||
旧 `readIprb` 硬编码 `traces = h.lastTrace + 1` 并对文件字节做严格相等校验。
|
||||
真实明星路每个通道文件恰好含 `lastTrace` 条道(少 1 道),逐通道实测:
|
||||
|
||||
| 通道 | 文件字节 | samples | LAST TRACE | 旧期望(=samples·(lastTrace+1)·2) | 实际道数(=bytes/(samples·2)) |
|
||||
|------|----------|---------|------------|----------------------------------|------------------------------|
|
||||
| A01 | 74390810 | 821 | 45305 | 74392452 | 45305 |
|
||||
| A02 | 74394094 | 821 | 45307 | 74395736 | 45307 |
|
||||
| A12 | 74392452 | 821 | 45306 | 74394094 | 45306 |
|
||||
|
||||
**规律一致**:所有通道「实际道数 == LAST TRACE」。修复后 `readIprb` 不再用 `lastTrace`
|
||||
决定道数,装配按各通道道数最小值对齐(min=45305)。
|
||||
|
||||
### build 实测指标(line 001, cellXY=0.2, cellZ=0.05, levels=2)
|
||||
|
||||
| 指标 | 值 |
|
||||
|------|-----|
|
||||
| 发现通道数 | 14 |
|
||||
| 装配后 ntraces / samples / channels | 45305 / 821 / 14 |
|
||||
| dx / dz(米) | 0.049084 / **0.00977756** |
|
||||
| GridSpec(nx×ny×nz) | **11120 × 8 × 162** |
|
||||
| 体素数 | 14,411,520 |
|
||||
| 原始体积(int16) | 28,823,040 B(27.49 MB) |
|
||||
| 落盘 data.bin(含金字塔各级) | 15,317,628 B(14.61 MB) |
|
||||
| 压缩比(原始/落盘) | **1.88×** |
|
||||
| 装配耗时 | 12,551 ms |
|
||||
| 建体耗时 | 1,926 ms |
|
||||
| 落盘耗时 | 3,597 ms |
|
||||
| 金字塔耗时 | 3,923 ms |
|
||||
| build 端到端墙钟 | ≈22.6 s |
|
||||
| 峰值内存 | **4,975 MB** |
|
||||
|
||||
### load 实测指标
|
||||
|
||||
| 指标 | 值 |
|
||||
|------|-----|
|
||||
| 加载耗时 | 335 ms |
|
||||
| 整卷维度 | 11120 × 8 × 162 |
|
||||
| 整卷字节 | 28,823,040 B(27.49 MB) |
|
||||
| 峰值内存 | 38 MB |
|
||||
|
||||
无 OOM、无超时,未调粗 cellXY 即一次通过。
|
||||
|
||||
### 峰值内存说明(4.98 GB)
|
||||
|
||||
峰值由**装配阶段**主导:同时持有 14 通道 BScan(14×74 MB ≈ 1 GB int16)
|
||||
+ `GprSurvey.values` 以 **double** 存(14×45305×821×8 B ≈ 4.2 GB)。
|
||||
建体/落盘/加载本身很轻(load 仅 38 MB)。若后续要压内存,可让 survey.values
|
||||
改存 int16 或流式装配,但当前规模单机可承受,POC 不做此优化。
|
||||
|
||||
---
|
||||
|
||||
## 3. 深度/Z 尺度诊断结论(任务 9b)
|
||||
|
||||
先前 §3 预估「nz=1、深度量级 8e-6 m」是在 SOIL VELOCITY **未正确换算**时写下的
|
||||
(当时按 100 m/s 计算)。Task 1 已将 `SOIL VELOCITY`(头文件单位 m/µs)×1e6 存为 m/s,
|
||||
本任务实测确认整条 Z 链路正确:
|
||||
|
||||
- 头:SAMPLES=821,TIMEWINDOW=160.352 ns,SOIL VELOCITY=100(→ 1e8 m/s)。
|
||||
- `depthOfSample(820) = 1e8 × 160.352e-9 / 2 ≈ **8.018 m**`(深度跨度合理)。
|
||||
- `dz = depthOfSample(1) = 8.018/820 ≈ **0.009778 m**`(实测 0.00977756,吻合)。
|
||||
- 故 cellZ=0.05 下 `nz = ceil(8.018/0.05)+1 = **162**`(实测 162,非 1)。
|
||||
|
||||
**结论**:`assembler`/`GprGeometry`/CLI `specFromSurvey` 的 Z 计算**全部正确**,
|
||||
无需改 CLI。先前的 nz=1 症状是 soilVelocity 换算缺失时代的遗留,现已不复存在。
|
||||
CLI 的 `specFromSurvey` 用的是 `survey.dz`(来自 `depthOfSample`),未误用原始 100,未漏乘。
|
||||
|
||||
---
|
||||
|
||||
## 4. 离屏 GPU 渲染基准(任务 9c,2026-06-23)
|
||||
|
||||
工具新增子命令:`gpr_poc offscreen-smoke`(闸门)、`gpr_poc renderB <store> [--frames N]`。
|
||||
执行机 GPU:**NVIDIA GeForce RTX 3060 Laptop GPU**,OpenGL 4.5.0 NVIDIA 555.97,硬件加速 True。
|
||||
|
||||
### 4.1 闸门:offscreen-smoke —— **OK(离屏 GL 可用)**
|
||||
|
||||
命令:`gpr_poc offscreen-smoke`
|
||||
|
||||
- 离屏 `vtkRenderWindow`(`SetOffScreenRendering(1)`+`SetShowWindow(false)`,256×256)
|
||||
→ 加 cube actor → `Render()` → `GetRGBACharPixelData` 读回。
|
||||
- 读回 65536 像素,非背景像素 **28224**(cube 正确画出)。
|
||||
- GL vendor=NVIDIA Corporation,renderer=RTX 3060 Laptop GPU,硬件加速 True。
|
||||
- **结论:离屏 GPU 渲染在本机可用**,继续真实基准(非编造)。
|
||||
|
||||
### 4.2 基准数据(line 001,更细一档 cellXY=0.05)
|
||||
|
||||
命令:`gpr_poc build "D:\Downloads\明星路" --line 001 --cellXY 0.05 --cellZ 0.05 --out <store> --levels 1`
|
||||
|
||||
| 指标 | 值 |
|
||||
|------|-----|
|
||||
| 体维度(nx×ny×nz) | **44476 × 29 × 162** |
|
||||
| 体素数 | 208,948,248(≈2.09 亿) |
|
||||
| 整卷字节(int16,进显存判据) | 417,896,496 B(**398.54 MB**) |
|
||||
| data.bin(含金字塔) | 199.43 MB(压缩比 2.00×) |
|
||||
| build 峰值内存 | 4,830 MB(装配阶段 double survey 主导,同 §2.4) |
|
||||
| 整卷加载耗时(renderB load) | ≈2.8–4.0 s |
|
||||
| renderB 进程峰值内存 | **≈509 MB**(加载整卷 398 MB + 渲染管线) |
|
||||
|
||||
无 build OOM,cellXY=0.05 一次通过,未调粗。
|
||||
|
||||
### 4.3 renderB 实测指标 —— **关键发现:整卷体绘制不可行**
|
||||
|
||||
命令:`gpr_poc renderB <store> --frames 120`
|
||||
|
||||
| 指标 | 值 |
|
||||
|------|-----|
|
||||
| 离屏闸门复检 | OK |
|
||||
| **体绘制 fps** | **INVALID(整卷超 3D 纹理上限)** |
|
||||
| ├ raw_fps(空纹理渲染,不可信) | 295.6(仅作记录,非真实帧率) |
|
||||
| ├ SmartVolumeMapper 渲染模式 | 2 = GPURenderMode |
|
||||
| └ vtkVolumeTexture 报错 | `Invalid texture dimensions [44476, 29, 162]` |
|
||||
| **切片扫描 fps** | **54.6 fps**(120 帧沿 Z 扫整卷,reslice+纹理) |
|
||||
| 整卷进显存 | **否**(X=44476 > GL_MAX_3D_TEXTURE_SIZE=16384) |
|
||||
| 降质重采样(LowRes) | 否(未触发;是直接纹理维度超限失败,非显存不足降质) |
|
||||
| GPU 显存(NVX) | **N/A**(随包 VTK 安装未带 GLEW 头,无法直查 `NVX_gpu_memory_info`;
|
||||
但 GL 扩展列表确认该扩展存在,机器支持,仅本工具未链 GL loader) |
|
||||
| 进程峰值内存 | ≈509 MB |
|
||||
|
||||
#### 关键发现(务必看)
|
||||
|
||||
1. **整卷体绘制在本机离屏下不可行**:测线 001 的 X 维(沿测线方向)= **44476**,
|
||||
远超本机 OpenGL `GL_MAX_3D_TEXTURE_SIZE = 16384`。`vtkSmartVolumeMapper`
|
||||
走 GPU 路径(mode=2)但底层 `vtkVolumeTexture` **无法将整卷上传为单张 3D 纹理**,
|
||||
报 `Invalid texture dimensions`。此时 `Render()` 实际未绘出体数据,
|
||||
故所谓 295 fps 是**空纹理渲染的假帧率,已如实标 INVALID,绝不上报为体绘制性能**。
|
||||
2. **切片扫描真实流畅**:切片走 `vtkImageReslice` 输出 2D 切面 + 2D 纹理着色,
|
||||
**不受 3D 纹理维度上限约束**,实测 **54.6 fps ≥ 30fps 目标**,整卷切片交互流畅。
|
||||
3. **进显存判据**:整卷 398 MB 远小于 GPU 显存(RTX 3060 6GB),显存容量不是瓶颈;
|
||||
真正的瓶颈是**单轴纹理维度上限(16384)**,而非显存字节数。
|
||||
|
||||
#### 结论
|
||||
|
||||
- **切片**:✅ 本机离屏下整卷切片 ≥30fps(54.6fps),交互流畅,满足目标。
|
||||
- **整卷体绘制**:❌ 在「整卷成单张 3D 纹理」的朴素路径下**不可行**——
|
||||
长测线 X 维超 GL 单轴上限。这正是 **Task 12(核外 / 分块 LOD / 体纹理分区
|
||||
`vtkOpenGLGPUVolumeRayCastMapper::SetPartitions`)** 必须解决的问题:
|
||||
要么沿 X 分区/分块上传,要么按视相机做 LOD 工作集。本任务(9c)按约束**不做核外**,
|
||||
仅如实记录此限制作为 Task 12 的硬性依据。
|
||||
- 该限制与显存容量无关,是 OpenGL 纹理维度硬上限;任何「整卷一次性 3D 纹理」方案
|
||||
对长测线都会撞同一面墙。
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
# POC-C 单 mapper SetPartitions 整卷体绘制探针结果
|
||||
|
||||
## 体
|
||||
- 维度: 44476 x 29 x 162 (体素 208948248)
|
||||
- 整卷字节: 417896496 B (398.537 MB, VTK_SHORT)
|
||||
- store: D:\Git\lanbingtech\geopro\build\tmp\gpr_store_B_001
|
||||
|
||||
## 单 mapper SetPartitions
|
||||
- mapper: vtkOpenGLGPUVolumeRayCastMapper (整卷单 image,不预切块)
|
||||
- 分区数: SetPartitions(3, 1, 1) 每区上限 ≤16384
|
||||
- 纹理维度错误: 否
|
||||
- 渲出非空像素: 是 (非背景像素 1264)
|
||||
- 体绘制 fps: 10.951667
|
||||
- 达交互级(≥15fps): 否
|
||||
- 进程峰值内存: 652.84 MB
|
||||
- 源构造耗时: 2873.19 ms
|
||||
|
||||
## 对照表
|
||||
|
||||
| 路径 | 是否渲出 | fps |
|
||||
|---|---|---|
|
||||
| renderB 整卷单 SmartVolumeMapper | INVALID(纹理墙) | — |
|
||||
| renderC MultiBlock(每块一 mapper) | 渲出 | 9.5 静态/1.45 换页 |
|
||||
| renderC-partitioned 单 mapper SetPartitions | 渲出 | 10.951667 |
|
||||
|
||||
## 判据结论
|
||||
单 mapper SetPartitions 整卷体绘制【真渲出但未达交互级】(10.9517 fps <15)。VTK 这条路天花板暴露,需评估 OpenVDS/自建 GL。
|
||||
|
||||
|
||||
|
||||
# POC-C LOD-fps 探针结果(Task 12c)
|
||||
|
||||
金字塔 store: tmp\store_lod_001(level0=44476x29x162,总 4 层)
|
||||
|
||||
| 项 | 维度 | 结果 |
|
||||
|---|---|---|
|
||||
| (a) 粗层概览 fps | level2 11119x8x41 | 752.061589 fps (交互级) |
|
||||
| (b) 全分辨率局部 fps | level0 局部 256x29x162 | 374.625725 fps (交互级) |
|
||||
| (c) LOD 切换过渡 | 切换帧 60/120 | 平均 1.09062ms,切换帧 5.4629ms(尖峰 6.04704×),无可感知卡顿 |
|
||||
|
||||
- 卡顿判据:切换帧绝对耗时 >33ms(2 个 60Hz 帧)才记可感知卡顿;16.7~33ms 记轻微抖动;亚毫秒基线下尖峰倍数大但绝对值低不算卡顿。
|
||||
- 双闸:纹理维度错误=否;三段均渲出非空像素=是(概览 1889 / 局部 167612 / 过渡 21924)。
|
||||
- 截图(人眼判“概览糊→拉近清晰”):docs/superpowers/plans/poc-lod-shots/lod-overview.png、lod-fullres-local.png、lod-transition-mid.png
|
||||
- 进程峰值内存: 99.2266 MB
|
||||
|
||||
## 判据结论
|
||||
粗层概览 + 全分辨率局部【都达交互级】且切换【无不可接受卡顿】→ LOD-based C 路线钉死可行。
|
||||
|
||||
**最低配未验声明**:本探针仅在本机(RTX 3060)跑得上限数字,最低配机器未验证,需用户在目标机跑或提供型号。
|
||||
|
||||
|
||||
# POC-C fps 预算探针结果(Task 12d ②)
|
||||
|
||||
金字塔 store: tmp/store_lod_001(level0=44476x29x162,brick=64)
|
||||
|
||||
递增 level0 局部窗口(沿线中段 brick 列)体绘制 fps:
|
||||
|
||||
| brick段 | 体素数 | 体绘制 fps | ≥30fps |
|
||||
|---|---|---|---|
|
||||
| 4 | 1202688 | 218.251659 | 是 |
|
||||
| 16 | 4810752 | 155.708373 | 是 |
|
||||
| 64 | 19243008 | 240.948244 | 是 |
|
||||
| 128 | 38486016 | 305.837001 | 是 |
|
||||
| 256 | 76972032 | 329.654511 | 是 |
|
||||
| 512 | 153944064 | INVALID | 否 |
|
||||
| 695 | 208948248 | INVALID | 否 |
|
||||
|
||||
- **每帧体素预算(fps≥30 上限)**: 76972032 体素(256 brick 列)
|
||||
- 首个跌破 30 的窗口: 无(需更大 --bricks 段触达天花板)
|
||||
- 双闸:纹理维度错误=是;每段均按非空像素校验。
|
||||
- production LOD 应把【每帧渲染的全分辨率块】卡在此预算以内。
|
||||
- **本机 RTX 3060 上限数;最低配需用户在目标机跑 fps-budget/view。**
|
||||
|
|
@ -156,13 +156,19 @@
|
|||
- **#4 视电阻率模型锁定**:`InversionFormDialog::ApparentResistivity` 已 `modelCombo_->setEnabled(false)` 且锁定 `code==script_visual_resistivity_data` 的项——与原版 `InversionDialog.vue`(静态 `disabled` + 锁脚本)一致。
|
||||
- **#5 网格 xsize/ysize 绑点数**:`GridWizardDialog` 的 `xSize_/ySize_` 是「X/Y点数」(1~300,默认 100),`buildGridToBody` 映射 `xsize←xSize`、间距走独立 `xSpacing←xSpacing_`——与原版 `GridDialog.vue toGridTheData`(`xsize:xPoints`、`xSpacing:xInterval`)一致。
|
||||
|
||||
### 6.4 明确后置 / 降级项(本次不实现,重型或 Qt 受限)
|
||||
### 6.4 收尾 6 项 —— 已全部接通(2026-06-23,commit ec4a7e8)
|
||||
|
||||
| 项 | 原因 | 后续所需 |
|
||||
§6.4 原列的 6 项后置/降级项已全部实现,build app + test 全绿(318/318)。
|
||||
|
||||
| 项 | 状态 | 实现 / 残留边界 |
|
||||
|---|---|---|
|
||||
| **M14 框选/点选模式** | Qwt 橡皮筋框选 + 选区联动隐藏成本高,原版 enter/exitSelectMode 交互重 | 接入 QwtPlotPicker(RubberBand 矩形)+ 选区命中→批量 saveDisplayStatus;保留占位提示 |
|
||||
| **M2 行级可见性 switch** | DataTableView 需新增可选开关列 + 行级 popconfirm 交互 | 给 measurement 列表加 optional 开关列,复用 saveDisplayStatus(ids=[record.id],status 取反) |
|
||||
| **M3 过滤直方图** | 过滤范围已通,仅缺直方图绘制(须取 getDataFilterConfig 分桶并渲染) | 在 ScatterFilterDialog 加直方图视图(分桶 + min/max 区间叠加) |
|
||||
| **I9 异常图上绘形** | 表单已通;图上交互绘制多边形/折线/点(橡皮筋 + 顶点编辑)属重型 Qwt 交互 | 接入图上绘制工具(绘形→坐标回填 location),与表单提交合流 |
|
||||
| **I14 Quill 富文本** | 原版 attachedParameters.deltaContent 为 Quill Delta;Qt 暂降级为纯文本 | 引入富文本编辑器(QTextEdit 富文本 ↔ Delta 互转)或保持纯文本兜底 |
|
||||
| **I3 白化 tmObjectId 透传** | 客户端视图未透传 `structParentId`(白化模板列表用),现兜底空串 | 上游改造:数据集列表把 `structParentId` 接进视图(属上游数据流改造) |
|
||||
| **M2 行级可见性 switch** | ✅ | DataTableView 载荷驱动可交互开关列(`toggleInteractive`+`rowIds`,仅 measurement 置位),行级 popconfirm → `saveDisplayStatus` |
|
||||
| **M3 过滤直方图** | ✅ | 新增自绘 `ScatterHistogramView`(20 箱,选区高亮 + min/max 输入联动);拖拽刷选未做(原版用输入/滑块,非画布 brush) |
|
||||
| **M14 框选/点选模式** | ✅ | `ScatterMarqueePicker` 橡皮筋矩形 → `ScatterPlotItem` 选中红边高亮;显示/隐藏对选中子集操作(无选区回退全部)。复刻 box-select 变体;原版单击逐点选未做 |
|
||||
| **I9 异常图上绘形** | ✅ | `ContourDrawTool` 在等值面交互绘制 点/线/面/文字(先弹窗填类型/名称→图上绘制→`newException`);坐标表保留为兜底。文字类型无原版独立富文本样式编辑器 |
|
||||
| **I14 Quill 富文本** | ✅(降级可用) | `DescriptionPanel` 升级富文本(粗体/斜体/下划线/字色/字号/标题/列表)+ `QuillDelta` 与 Quill Delta 常见格式往返。**Qt 无 Quill,不可字节级 1:1**:未知 attributes/嵌入对象容错降级(保文本、丢样式、不崩) |
|
||||
| **I3 白化 tmObjectId** | ✅(待联调验证) | `openWhitening` 经 `getDsObjectDetail(dsId)` 取 `structParentId` 作 tmObjectId。**存疑**:未实证 getDetail 响应含 structParentId,若不含需转方案 B(经 openDataset 链路透传) |
|
||||
|
||||
### 6.5 命名冲突修复
|
||||
|
||||
`ScatterHistogram` 名冲突(M3 widget 类 vs ScatterDataOps 分箱 struct)导致 desktop 目标曾无法链接(`build.bat test` 只建测试目标未暴露)→ widget 改名 `ScatterHistogramView`。**教训**:详情视图改动须 `build.bat all` 验证 app 链接,不能只 `build.bat test`。
|
||||
|
|
|
|||
|
|
@ -0,0 +1,99 @@
|
|||
# GPR 三维体 · 方案 A:整卷上纹理,不用金字塔(复用现有管线,最简基线)
|
||||
|
||||
- 日期:2026-06-23
|
||||
- 范围:把 GPR(探地雷达)阵列数据插值成三维体并在 VTK 中渲染/切片,**直接复用现有剖面三维体管线**,整卷一次性进显存,不做分块/金字塔/核外。
|
||||
- 定位:三选一中的**最小改动基线**。用于评估"现有架构原样接雷达,能做到什么、卡在哪"。
|
||||
- **⚠ 评审结论(2026-06-23,opus):A 不应作为独立交付步,建议并入 B。** 唯一值得从 A 单独先做的是"三方案共有的地基"(§2)。`double`+400³+暴力 IDW 三条硬约束使 A 产出的 GPR 体既无业务分辨率(沿线被强制粗化到 5.5m vs 物理 5cm)、又无法落盘秒开。详见 §5/§6。
|
||||
- 测试数据(明星路):450MHz 阵列 GPR,14 通道,每道 821 采样,单线 ~45306 道,20 线,int16,合计 13.6GB;路长 2223m,测幅 1.37m,深 ~8m。
|
||||
|
||||
---
|
||||
|
||||
## 1. 设计意图
|
||||
|
||||
不引入任何新渲染/存储机制。把雷达数据**喂进现有体素管线**,让它走和反演剖面三维体完全一样的路:
|
||||
|
||||
```
|
||||
.iprb/.iprh → PointSet → core::IdwInterpolator → ScalarVolume(double)
|
||||
→ data::VolumeGrid → render::buildVoxel → vtkSmartVolumeMapper(整卷进显存)
|
||||
切片:render::interact::SliceTool(vtkImagePlaneWidget / vtkImageReslice,CPU 重采样)
|
||||
持久化:VolumeBuildParams(参数必存)+ 可选明细缓存(现内存 mock)
|
||||
```
|
||||
|
||||
现有落点(实证):
|
||||
- `core::ScalarVolume` = `std::vector<double>`,行优先(`src/core/model/Field.hpp:8-26`)。
|
||||
- `render::buildVoxel` → `vtkImageData`+`vtkDoubleArray`+`vtkSmartVolumeMapper`,整卷上传(`src/render/actors/VoxelActor.cpp:41-79`)。
|
||||
- 体素维度上限 `kMaxVolumeDim = 400`(`src/core/algo/VolumeBuilder.hpp:8`)。
|
||||
- 切片 CPU 重采样(`src/render/interact/SliceTool.cpp:24-39`)。
|
||||
- 插值 `IdwInterpolator`,单线程三重循环,且**无空间索引——每体素全点集线性扫描,O(体素数×点数) 暴力**(`src/core/algo/IdwInterpolator.cpp:15-33`,评审实证)。雷达级点集下即便 400³ 也是分钟级甚至卡死,不只是"偏慢"。
|
||||
- 持久化 `Api3dRepository::StoredVolume`,纯内存(`src/data/api/Api3dRepository.hpp:112-119`),重算逻辑已就绪(`Api3dRepository.cpp:212-225`)。
|
||||
- **注意(评审)**:现有 `loadVolume` 的散点来源硬绑 `loadSection`/`appendGridPoints`(ERT 反演帘面,`Api3dRepository.cpp:146-171`),**雷达没有现成喂入路径**。"复用现有管线"实际仍须新写 GPR→`buildVolume` 接入(§2 已含),§1 流程图的"原样复用"措辞偏乐观。
|
||||
|
||||
---
|
||||
|
||||
## 2. 新增工作(雷达接入,三方案共有的地基)
|
||||
|
||||
A/B/C 都绕不开这块,A 用最朴素实现:
|
||||
|
||||
1. **`.iprb`/`.iprh` 解析器**(新):`.iprh` 文本头取 `SAMPLES/LAST TRACE/CHANNELS/TIMEWINDOW/SOIL VELOCITY/DISTANCE INTERVAL`;`.iprb` 读 int16 B-scan(`samples × traces`,校验 `samples×traces×2 == 文件大小`)。
|
||||
2. **地理配准**:`.ord` 取 14 通道横向偏移;`.gps`/`.cor` 取每道经纬度/RTK;深度 = `time × soilVelocity / 2`。
|
||||
3. **GPR→PointSet 适配器**:把"14 通道 × N 道 × 821 采样"摊成 `PointSet{x,y,z,v}`(局部坐标)。**注意横向只有 14 个真实样本**,是稀疏维。
|
||||
4. **数据集接入**:新增 ddCode(如 `dd_gpr_volume`),在维度分类(`Api3dRepository.cpp:30-45`、`LocalSample3dRepository.cpp:43-58`)归 3D;DTO 解析器放 `src/data/dto/`。
|
||||
|
||||
---
|
||||
|
||||
## 3. 关键约束与后果(A 的硬边界)
|
||||
|
||||
现有管线是 **double + 400³ 上限**。这两条直接决定 A 能做什么:
|
||||
|
||||
| 约束 | 数值 | 后果 |
|
||||
|---|---|---|
|
||||
| 标量 dtype | `double`(8 字节/体素) | 同样体素数,内存是 int16 的 **4 倍** |
|
||||
| 维度上限 | 400³ | 整卷 ≤ 400³×8 ≈ **512MB**;放不下全路段 |
|
||||
| 整卷进显存 | 一次性 | 体大小受限于显存 |
|
||||
| IDW | 单线程 + 无空间索引暴力 | 大点集插值分钟级/卡死(明星路单线 ~5亿采样点级) |
|
||||
|
||||
> **`fitAxis` 行为(评审实证 `VolumeBuilder.cpp:16-26`)**:格数超 400 时**不裁剪范围**,而是 `outCell=ext/(400-1)` 把 400 格摊满整个包络。所以 A 不是"丢掉远端",而是"强制粗化"——沿线细节被低分辨率抹平。
|
||||
|
||||
**全路段在 A 下做不到原始分辨率。** 明星路需 ~22000(沿线)×270(横)×400(深),远超 400³。在 A 下只能:
|
||||
- **重度降采样到 ≤400³**:沿线 2223m/400 ≈ 5.5m 网格 → 沿线细节全毁;或
|
||||
- **按单条测线/短段分别建小体**(单线降到 400³ 仍偏粗),多体并排显示(类似现有 2D 足迹平铺)。**但**(评审):20 体 × 400³ × double ≈ 10GB 整卷同驻显存,比单大体更易爆显存;且各体独立 `GridSpec`/origin,**跨体的全路段连续切片做不到**(用户想沿全路一刀切无法实现)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 持久化(沿用 2026-06-17 §7 策略)
|
||||
|
||||
- **必存**:`VolumeBuildParams`(源数据引用 + 插值模型/参数 + 色阶)+ `GridSpec`(origin/spacing/dims,锚定切片/异常坐标)。
|
||||
- **可选明细**:`ScalarVolume`(double)。A 阶段仍是内存 mock(`StoredVolume.cachedGrid`),**未真实落盘**——这是 A 与用户"保存插值后体"诉求的**主要差距**。
|
||||
- 用户要的两种保存:①参数 ②插值后明细 —— A 的 ②目前只有内存缓存,需补一段最朴素的 double 体落盘(raw + sidecar)才算满足;但 double 全路段落盘巨大,不实用。
|
||||
|
||||
---
|
||||
|
||||
## 5. 评估
|
||||
|
||||
**优点**
|
||||
- 改动最小:渲染/切片/异常/详情**全部现成**,只加雷达解析与适配。
|
||||
- 路径已验证,风险低,可最快出"雷达能进三维场景"的可见效果。
|
||||
|
||||
**缺点/限制**
|
||||
- `double` + 400³ → **撑不起全路段原始分辨率**,只能粗览或分段小体。
|
||||
- 明细落盘不实用(double 体积过大),用户"算一次秒开"诉求难真正成立。
|
||||
- 单线程 IDW 在雷达量级偏慢。
|
||||
- 把结构化的雷达数据(沿线/深度本就规则)当无结构散点做 3D IDW,**算力浪费**(见方案 B 的结构化插值优化)。
|
||||
|
||||
**适用**
|
||||
- 单条短测线 / 粗分辨率概览 / 快速打通链路的第一步。
|
||||
- **不适合**作为全路段完整体验的最终方案。
|
||||
|
||||
---
|
||||
|
||||
## 6. 工作量与落地顺序
|
||||
|
||||
1. `.iprb`/`.iprh` 解析 + 地理配准 + GPR→PointSet(地基,~中)。
|
||||
2. 雷达 ddCode 接入维度分类 + DTO(~小)。
|
||||
3. 直接复用 `IdwInterpolator`/`buildVoxel`/`SliceTool`,按 ≤400³ 降采样建体(~小)。
|
||||
4. (可选)double 明细落盘最朴素实现(~小,但不推荐用于全路段)。
|
||||
|
||||
**结论(修订,评审定)**:**no-go(作为独立交付步),并入 B。** A 没有任何 B 不需要的独立资产,渲染/切片/异常/持久化骨架 A、B 共享,地基(§2)也共享。`double`+400³+暴力 IDW 三条硬约束使 A 的 GPR 产物既无业务分辨率、又无法落盘秒开,连"最小基线该兑现的可用产物"都达不到。
|
||||
- **唯一抽出先做的独立里程碑 = §2 共有地基**(`.iprb`/`.iprh` 解析 + 14 通道配准 + GPR→PointSet + ddCode 接入),A/B/C 都要。
|
||||
- "复用 double+400³ 管线建退化体"这一步**不单独交付**,直接在 B 的 int16+结构化建体上落地,避免做一遍注定被 B 推翻的降级体。
|
||||
- **全路段完整体验走 B。**
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
# GPR 三维体 · 方案 B:全路段 int16 整卷上 GPU(升级现有管线,推荐)
|
||||
|
||||
- 日期:2026-06-23
|
||||
- 范围:把全路段 GPR 按**物理分辨率(5~10cm)**插值成**单个 int16 体(~5~10GB)**,整卷传成 GPU 3D 纹理,切片/体绘制都丝滑。**不做金字塔/核外**,靠"右尺寸 + int16"让单体进显存。
|
||||
- 定位(2026-06-23 用户定):**与 C 对等的两条已承诺路线之一,两者都做、用户运行时按需切换**。B 走"整卷进显存"路线(在现有管线上有针对性升级),适合能装进显存的体;超显存的体走 C。经同一 `IVolumeRenderSource` 接口切换。
|
||||
- **✅ 评审结论(2026-06-23,opus):Go(条件式)。** 体积测算、int16+结构化插值、渲染/切片复用均成立。**开工前必改 3 项**:①落盘方案(VTKHDF Writer 写不了 ImageData,必须改裸分块,见 §3);②量化贯穿传递函数/色阶/反量化(见 §3.5);③一个 vtkShortArray→GPU 体绘制的小验证 spike。
|
||||
- 测试数据(明星路):同方案 A。物理分辨率依据:450MHz、土速 0.1m/ns → 波长 λ≈0.22m、垂向分辨率 ≈5cm;网格细过 ~5cm 即过采样。
|
||||
|
||||
---
|
||||
|
||||
## 1. 设计意图
|
||||
|
||||
A 的瓶颈是 `double` + 400³ 上限,撑不起全路段。B 针对性拆掉这两条,**保持"整卷进显存"这一最省力的渲染架构不变**:
|
||||
|
||||
```
|
||||
.iprb/.iprh → 结构化建体(仅横向插值)→ ScalarVolumeI16(int16)
|
||||
→ vtkImageData + vtkShortArray → vtkSmartVolumeMapper(整卷进显存)
|
||||
切片:复用 SliceTool(reslice 对 int16 image 同样工作)
|
||||
持久化:VolumeBuildParams + int16 明细【真实落盘 + 分块压缩】
|
||||
```
|
||||
|
||||
体积测算(明星路全路段,依据真实头文件):
|
||||
|
||||
| 网格 (横×纵×深) | 体素数 | int16 体积 | 进显存? |
|
||||
|---|---|---|---|
|
||||
| 10cm×10cm×2cm | 22230×270×400 ≈ 2.4G | **4.8GB** | 12GB+ 显卡可 |
|
||||
| 10cm×10cm×原生821 | ≈ 4.9G | **9.8GB** | 16~24GB 显卡可 |
|
||||
| 5cm×5cm×5cm | 44460×540×160 ≈ 3.8G | **7.7GB** | 16GB+ 显卡可 |
|
||||
|
||||
**关键:5~10GB 全部在单显卡可承载区间——不需要金字塔/核外。** 那个 39TB 是 cm 级横向过采样的产物,物理无意义。
|
||||
|
||||
---
|
||||
|
||||
## 2. 三处核心升级(相对 A)
|
||||
|
||||
### 2.1 dtype:引入 int16 体(4× 内存削减)
|
||||
- 现 `ScalarVolume` 全仓库是 `double`(`Field.hpp:8-26`),直接改全局风险大。**方案**:新增并行的 `ScalarVolumeI16`(`std::vector<int16_t>` + 同样行优先布局 + 量化标定 `scale/offset` 把物理值映射到 int16),雷达走 int16 路径,反演剖面仍走 double。
|
||||
- 渲染:`buildVoxel` 增加 int16 重载 → `vtkImageData` + `vtkShortArray`。**评审已证实** GPU 体绘制原生支持 short(`vtkSmartVolumeMapper`→`vtkOpenGLGPUVolumeRayCastMapper`→`vtkVolumeTexture` 走 GL 16-bit 整型纹理)。NaN/空值改用 int16 哨兵(如 `INT16_MIN`)+ 不透明度传递函数透明(与现 `VoxelActor.cpp:23-24,68-72` 同构)。
|
||||
- 收益:同体素数内存/显存/磁盘 = double 的 1/4,是"让全路段进显存"的关键杠杆。雷达原始本就是 int16,**无精度损失**。
|
||||
- **适配面比"加个重载"大(评审 HIGH)**:`ScalarVolume`(double) 被 `VolumeGrid`/`buildVoxel`/`finalizeVolume`/`Api3dRepository`(`StoredVolume.cachedGrid`、`loadVolume` 回调签名、`VolumeInfo` 统计) 一路引用。int16 体需让这些**要么模板化、要么并行一套带量化 meta 的变体**。隔离方向(雷达 int16 / 反演剖面仍 double)对,但工作量按"中"算偏乐观,§6 已上调。
|
||||
|
||||
### 2.2 维度上限:由物理分辨率决定,拆掉 400³ 死值
|
||||
- 移除/放宽 `kMaxVolumeDim=400`(`VolumeBuilder.hpp:8`),改为按 `cellXY/cellZ` 与场景范围算出 dims,并加**显存预算守卫**(建体前估算 `nx·ny·nz·2B`,**并留余量**:实际还要叠加传递函数纹理 + 颜色/深度 FBO,按裸标量算偏紧——评审 MEDIUM)。
|
||||
- **显存探测无可靠跨厂商 API(评审 MEDIUM)**:OpenGL 无统一"可用显存"查询。实践只能 try-upload-on-fail 或留保守阈值。
|
||||
- **免费兜底(评审发现,spec 原漏报)**:`vtkSmartVolumeMapper` 自带 `MaxMemoryInBytes` + `LowResResample`(`vtkSmartVolumeMapper.h:194-211,373-379`),体超显存时**自动降采样重采样到可容纳**——等于"概览体"免费实现。目标机显存小时优先用它 + 按区域细化,**仍在 B 框架内,不必转 C**。
|
||||
- 默认网格由雷达物理分辨率给(横 5~10cm、深 2~5cm),不让用户填出过采样网格。
|
||||
|
||||
### 2.3 插值:结构化建体,不做 3D 散点 IDW
|
||||
- **重要架构洞察**:雷达数据沿测线(X)、深度(Z)**本就是规则密采样**,只有横向(Y)的 14 通道是稀疏的。所以"插值成体"≠ 3D 无结构散点插值,而是:
|
||||
- X、Z 方向按道距/采样直接落格(重采样/最近邻,廉价);
|
||||
- **只在 Y 方向对 14 通道做 1D 插值**填充横向空隙。
|
||||
- 这比 A 复用的全 3D IDW **快一两个数量级**,且单线程可接受;若仍慢,Y 向插值天然可并行(QtConcurrent/std::thread,按 X 切片并行)。
|
||||
- 保留 `IInterpolator` 抽象,新增 `GprStructuredBuilder` 实现,与 IDW 并列。
|
||||
|
||||
---
|
||||
|
||||
## 3. 持久化(真正满足"算一次、之后秒开")
|
||||
|
||||
用户要两种保存,B 把第二种做实:
|
||||
|
||||
- **方式一(参数档)**:`VolumeBuildParams`(源 .iprb 引用 + 建体参数 + 色阶 + 量化 scale/offset)+ `GridSpec`。小、可复算、详情面板展示。
|
||||
- **方式二(明细缓存,升级为真实落盘)**:int16 体 **分块写盘 + 逐块压缩**:
|
||||
- **⚠ 不能用 `vtkHDFWriter`(评审 CRITICAL,两个评审独立证实)**:VTK 9.6 的 `vtkHDFWriter` **写不了 `vtkImageData`/规则体**——它只支持 PolyData/UnstructuredGrid/Partitioned/MultiBlock(`vtkHDFWriter.h:6-9,232-235`,无 ImageData 写重载)。`vtkHDFReader` 能**读** ImageData,但 Writer 不能**写**,读写不对称。"补个 IOHdf 组件就能 VTKHDF 原生落盘体"的说法**错误**。
|
||||
- **首选(改正后)**:**自定义 raw int16 分块 + sidecar(GridSpec/量化 scale·offset/vmin·vmax/分块索引) + 逐块 zlib(VTK 自带 `vtkzlib`,无需新依赖)**。分块布局从一开始就设计好,C 的"切片核外"可几乎免费复用同一格式。
|
||||
- 备选:直接用底层 `vtkhdf5` C API 自写 chunked dataset(绕过 `vtkHDFWriter`),获得 HDF5 生态兼容;成本高于 raw 分块。
|
||||
- 不引入独立 zstd/blosc(vcpkg 未含;如需更高压缩比再加)。
|
||||
- **加载**:有明细 → 读盘(可 mmap)→ 整卷上显存;无明细 → 按参数后台线程重算落缓存(复用现有重算逻辑 `Api3dRepository.cpp:212-225`,从 mock 升级为真实落盘)。
|
||||
- **后台重算不阻塞 UI(评审 MEDIUM)**:现 `loadVolume` 回调**在主线程**(mock 同步)。改为工作线程建体/重算后,回调要**跨线程编组**回 UI(Qt 信号 / `QMetaObject::invokeMethod`),这是线程模型改动,需显式设计。
|
||||
- **加载耗时别承诺"秒开"(评审 LOW)**:5~10GB 上传 GPU(约 1~5s) + 压缩明细解压,实际**约 10s 量级**。明星路单体压缩后约 2~6GB,读盘+解压秒~十秒级——比每次重算快得多,用户"算一次之后快读"诉求成立。
|
||||
|
||||
### 3.5 量化贯穿(评审 HIGH,正确性问题,必做)
|
||||
int16 渲染标量是量化域 `q = round((v_phys - offset)/scale)`,不是物理值。必须把量化贯穿全链,否则色阶/读数全错:
|
||||
- **传递函数 / 不透明度**:现 `VoxelActor.cpp:62-72` 用物理 `vmin/vmax` 加控制点 → int16 路径必须改成在**量化域 `qmin/qmax`** 采样。
|
||||
- **切片色阶 LUT**:`buildLut(cs,vmin,vmax)`(`SliceTool.cpp:37`)同理喂量化域。
|
||||
- **反量化显示**:取值光标 / 异常详情 / 数据详情面板展示给用户的值必须 `v_phys = q*scale + offset` 反量化回物理量。
|
||||
- `scale/offset` 存入 `VolumeBuildParams` 并随 `VolumeInfo` 传递。
|
||||
|
||||
---
|
||||
|
||||
## 4. 渲染与交互(基本复用,验证为主)
|
||||
|
||||
- 整卷 `vtkSmartVolumeMapper`(现有),int16 image 直接喂;确认 9.6 的 GPU ray cast 对 short 标量正常。
|
||||
- 切片 `SliceTool`(`vtkImagePlaneWidget`/`vtkImageReslice`)对 int16 image 同样工作(CPU reslice 与 dtype 无关);丝滑度由"整卷已在显存"保证。
|
||||
- 异常/详情/色阶:复用现有 3D 分析栏链路。
|
||||
|
||||
---
|
||||
|
||||
## 5. 评估
|
||||
|
||||
**优点**
|
||||
- **全路段完整连续体 + 最好体验**,切片/体绘制丝滑,且**不必上金字塔/核外**。
|
||||
- 复用现有渲染/切片/异常/详情,主要新增 = int16 路径 + 结构化建体 + 真实落盘。
|
||||
- int16 + 结构化插值同时解决"内存/显存/磁盘大"和"插值慢"。
|
||||
- 明细真实落盘,"算一次秒开"成立。
|
||||
|
||||
**缺点/风险(评审分级)**
|
||||
- **CRITICAL(已在 §3 修正)**:`vtkHDFWriter` 写不了 ImageData → 落盘改裸 int16 分块+zlib。有现成退路,非方案推翻,但落盘是"自写格式"的中等工程,非"补组件"。
|
||||
- **HIGH**:量化未贯穿传递函数/色阶/反量化会导致颜色与读数错(§3.5 已补设计)。
|
||||
- **HIGH**:int16 适配面被低估(`VolumeGrid`/`loadVolume` 回调/`StoredVolume`/`VolumeInfo` 均需带量化 meta),非单点重载。
|
||||
- **MEDIUM**:后台重算从主线程改工作线程,跨线程回调编组需设计;显存无可靠查询、预算按裸标量偏紧;结构化落格假设道近似等距,GPS 抖动需沿弧长重采样。
|
||||
- **LOW**:5~10GB 加载约 10s 级,UX 别承诺"秒开"。
|
||||
- 单巨体无部分加载:打开即载全量。
|
||||
- int16 路径是对核心类型的扩展,需谨慎不污染 double 主路径(用并行类型隔离)。
|
||||
|
||||
**适用**
|
||||
- 当前明星路这一档(及绝大多数单路段工程)。**这是默认推荐。**
|
||||
|
||||
---
|
||||
|
||||
## 6. 工作量与落地顺序
|
||||
|
||||
0. **【开工前】验证 spike(~半天)**:`vtkShortArray` 填小 `vtkImageData` → `vtkSmartVolumeMapper` 跑通 GPU ray cast + 量化域传递函数,确认 GPU 路径与颜色正确。
|
||||
1. 地基(同 A §2):`.iprb`/`.iprh` 解析 + 配准 + 接入(~中)。
|
||||
2. `ScalarVolumeI16` + `buildVoxel` int16 重载 + 哨兵透明 + **量化贯穿传递函数/LUT/反量化(§3.5)**(~中大,评审上调:适配面比单点重载大)。
|
||||
3. `GprStructuredBuilder`(X/Z 落格 + Y 向插值,可并行;GPS 抖动需沿弧长重采样)替代全 3D IDW(~中)。
|
||||
4. 显存预算守卫(留 FBO/传函余量)+ `LowResResample` 概览兜底 + 物理分辨率默认网格(~小)。
|
||||
5. 明细真实落盘(**raw int16 分块 + sidecar + zlib,不用 vtkHDFWriter**)+ 后台重算(**含跨线程回调编组**)(~中大,评审上调:自写分块格式 + 线程模型)。
|
||||
6. 渲染/切片对 int16 的验证(~小)。
|
||||
|
||||
**结论(评审:Go 条件式 / 用户定:与 C 都做)**:B 用"右尺寸 + int16 + 结构化插值"在现有架构上拿到全路段完整体验。**前置条件**:§3 落盘章节已从 VTKHDF 改为裸分块、§3.5 量化设计已补、第 0 步 spike 通过——满足后即可进入实现。B 与 C 经同一 `IVolumeRenderSource` 并存,用户按数据规模在两者间切换(能进显存走 B、超显存走 C),落盘格式两者共用(B 的裸分块是 C 分块/金字塔的基座)。
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
# GPR 三维体 · 方案 C:分块 + 金字塔 + 核外(应对超大数据量)
|
||||
|
||||
- 日期:2026-06-23
|
||||
- 范围:当单个三维体在**合理分辨率下仍超显存/内存**时(几十公里测线、多工区合并、或必须超精网格),采用业界处理 TB 级体的标准架构:**分块(bricking) + 多分辨率金字塔(LOD) + 逐块压缩 + 核外按需加载(out-of-core)**。
|
||||
- 定位(2026-06-23 用户定):**与 B 对等的两条已承诺路线之一,两者都做、用户运行时按需切换**(不是 B 的兜底/预案)。对标地震(OpenVDS/ZGY)、数字病理/显微(OME-Zarr)。B 适合能整卷进显存的体、C 适合超显存/超大范围的体,由用户按数据选择,经同一 `IVolumeRenderSource` 接口切换。
|
||||
- POC(用户定):C 的 POC **含"最小但真实的核外分页器"**,正面验证最高风险点(分页器在 VTK 上可行性、块边接缝、LOD 闪烁、热路径解压),"POC 过 ⇒ 可落地"。
|
||||
- 前置:地基(`.iprb` 解析/配准/接入) 与 int16 + 结构化建体 与方案 B 共用。
|
||||
- **⚠ 评审结论(2026-06-23,opus)+ 用户决策**:**Go——C 是已承诺路线,与 B 都做。** 架构对标业界无误。开工注意:①`vtkHDFWriter` 写不了规则体(与 B 同源 CRITICAL,§2.2 已改正为裸 HDF5/分块);②整卷核外分页器无 VTK 开箱基础(CRITICAL,月级,**POC 即用最小真实分页器正面验证**);③`vtkSmartVolumeMapper` 自带 `LowResResample` 仅作 C 内的降质兜底手段,不替代 C。落地顺序:裸分块格式 → 切片核外 → 整卷核外分页器。
|
||||
|
||||
---
|
||||
|
||||
## 1. C 的适用场景(用户在 B/C 间按需选择的依据,非"门槛")
|
||||
|
||||
C 与 B 并存,用户对某个体选 C 而非 B,典型是:
|
||||
- 合理分辨率(5~10cm)下单体 int16 体积 **超过本机显存**;
|
||||
- 测线长一个数量级(几十 km)或多工区拼接成连续大体;
|
||||
- 需要在内存/显存恒定下浏览任意大的体。
|
||||
|
||||
B 与 C 同为成品、经同一 `IVolumeRenderSource` 切换;小体走 B(整卷最省力)、大体走 C(核外不爆内存),**由用户按数据选**。
|
||||
|
||||
---
|
||||
|
||||
## 2. 架构
|
||||
|
||||
```
|
||||
建体(int16) → 分块(brick 64³/128³) → 逐块压缩 + 每块 min/max
|
||||
→ 多分辨率金字塔(全分辨率 / 1/2 / 1/4 / 1/8 …)
|
||||
→ 写入分块格式文件(离线/后台一次)
|
||||
渲染 → 核外分页器:按相机视野 + LOD 选块 → 解压载入显存 → 相机移动换入换出
|
||||
内存/显存只驻留当前所需块(数 GB),与总体积无关
|
||||
切片 → 只读切面相交的块(最便宜的子集);等值面靠每块 min/max 剔除
|
||||
交互 → 拖动用粗 LOD,停下加载全分辨率;沿拖动方向预取
|
||||
```
|
||||
|
||||
### 2.1 存储格式(分块 + 金字塔 + 压缩)
|
||||
- **⚠ 不能依赖 `vtkHDFWriter`(评审 CRITICAL,与 B 同源)**:VTK 9.6 的 `vtkHDFWriter` **写不了 `vtkImageData`/规则体**(`vtkHDFWriter.h:6-9,232-235`,仅 PolyData/UnstructuredGrid/composite);且**无规则体的多分辨率 overview**(头文件唯一多级机制是 AMR 层级 `vtkHDFReader.h:203-209`,非金字塔)。所以"补 IOHdf 组件即可 VTKHDF 落盘 + overview"**不成立**——原 spec 的"待验证"实为"基本不支持"。
|
||||
- **首选(改正后)**:**裸 `vtkhdf5` C API 自写 chunked dataset**(HDF5 原生 chunking + zlib 逐块压缩 + 随机访问),金字塔层作为多个 HDF5 dataset **自行组织**;或自定义 brick 文件(裸分块 + 索引),每块 zlib + 头存 min/max + LOD 偏移表。**与 B 的落盘层统一**(B 本就要改成裸分块,正好一并设计成可分块/可多级)。
|
||||
- 不引入独立 zstd/blosc(vcpkg 未含);如压缩比不足再评估加入。
|
||||
|
||||
### 2.2 渲染端核外分页(C 的真正难点)
|
||||
- **VTK 不开箱提供大体的 out-of-core GPU 体绘制(评审证实)。** 注意两个相关但不够用的开箱件:
|
||||
- `vtkMultiBlockVolumeMapper` **存在但不是分页器**(`vtkMultiBlockVolumeMapper.h:14-21`:试图"同时加载所有块",仅 GPU 分配失败才退化逐块重载)——它给的是"多块同时渲染 + 抖动抗块边接缝",**没有按视野换入换出/LOD 选块/预取**。要复用它做渲染层,须在喂数据前自己完成"只放视野块"的筛选。
|
||||
- `vtkSmartVolumeMapper` 的 `LowResResample`(见 B §2.2)是"自动降质看全貌",**不是核外**——它是 C 之前的免费兜底,不是 C 的实现。
|
||||
- 整卷核外分页器须**从零自建**(选块/LOD/LRU/解压/换入换出/预取)。三条路:
|
||||
1. **切片优先(推荐先做)**:切片只需读相交的块,复用 `vtkImageReslice` 对"当前块集合"重采样。**这条最易落地**,能先拿到"超大体看切片"的能力,不碰整卷核外。
|
||||
2. **自建 LOD + brick 分页**:在 `vtkSmartVolumeMapper` 之上,把视野内块按 LOD 作为多个 `vtkImageData`/`vtkMultiBlockDataSet` 动态加载/淘汰。整卷透明体绘制走这条,**工作量最大**。
|
||||
3. **集成 OpenVDS**(地震库):能力最全但**重依赖**(vcpkg 未含,需自带 + 适配 VTK),适配成本高。
|
||||
- 建议:**先做 1(切片核外),整卷体绘制核外(2)列为后续**。
|
||||
|
||||
### 2.3 建体/金字塔流水线
|
||||
- 复用方案 B 的 int16 + 结构化建体产出全分辨率体 → 分块 → 逐级降采样建金字塔 → 逐块压缩落盘。
|
||||
- **离线/后台执行一次**,结果即持久化产物(C 的"保存插值后体"天然就是这个分块金字塔文件)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 持久化
|
||||
|
||||
- C 的存储格式**本身就是方式二(明细缓存)**:分块 + 金字塔 + 压缩,是"算一次、之后秒开"的载体,且读取只碰视野块、内存可控。
|
||||
- 方式一(参数档 `VolumeBuildParams`+`GridSpec`)仍保留,用于复算/详情/校验。
|
||||
- 切片/异常坐标仍锚定 `GridSpec`(与 A/B 一致),保证跨 LOD 一致。
|
||||
|
||||
---
|
||||
|
||||
## 4. 评估
|
||||
|
||||
**优点**
|
||||
- **不设规模上限**:任意大小(几十 GB ~ TB)皆可,内存/显存恒定在数 GB。
|
||||
- 切片只读相交块、等值面块级剔除、拖动 LOD 降级 —— 大体交互可做到流畅。
|
||||
- 与业界(地震/医学)成熟路径同构,可借鉴现成设计。
|
||||
|
||||
**缺点/风险(评审分级)**
|
||||
- **CRITICAL**:`vtkHDFWriter` 写不了规则体 → 落盘须自写裸 HDF5/分块(§2.1 已改),是中大件,非"补组件"。
|
||||
- **CRITICAL**:整卷核外分页器**无 VTK 开箱基础**,从零自建(选块/LOD/LRU/预取),月级且风险集中;或集成重依赖 OpenVDS。
|
||||
- **HIGH**:VTKHDF 无规则体多分辨率 overview,金字塔须全自管。
|
||||
- **HIGH**:压缩块解压在**交互热路径**(拖动每帧换块解压),CPU 可能成瓶颈——不止 IO 放大。
|
||||
- **MEDIUM**:LOD 降采样的半像素偏移 → 异常拾取**跨层落点漂移**("GridSpec 锚定保证一致"未覆盖此情形)。
|
||||
- **MEDIUM**:块边接缝(MultiBlock 抖动可部分缓解)/ LOD 切换闪烁(需 morphing/淡入自解决)。
|
||||
- 复杂度高 → 维护成本与缺陷面大。
|
||||
- **依赖断点(评审)**:C 声称"复用 B 的落盘",而 B 那层须先从 VTKHDF 改成裸分块 HDF5——C 的"地基已就绪"前提依赖 B 先这么做。
|
||||
|
||||
**适用**
|
||||
- 仅当数据真正超出方案 B 的单显卡承载。**当前明星路用不到。**
|
||||
|
||||
---
|
||||
|
||||
## 5. 工作量与落地顺序(仅在需要时启动)
|
||||
|
||||
1. 地基 + int16 + 结构化建体(与 B 共用,若已做则复用)。
|
||||
2. 分块格式 + 逐块压缩 + 每块 min/max(VTKHDF 或自定义)(~中大)。
|
||||
3. 多分辨率金字塔生成流水线(后台一次)(~中)。
|
||||
4. **切片核外**:按切面读相交块重采样(~中)—— 先交付这条。
|
||||
5. 整卷体绘制核外分页器 + LOD 拖动降级 + 预取(~大,后续)。
|
||||
6. 显存/内存缓存与淘汰策略(~中)。
|
||||
|
||||
---
|
||||
|
||||
## 6. 与 A/B 的关系
|
||||
|
||||
- 能力:A ⊂ B ⊂ C;成本同序递增。
|
||||
- A、B 共享渲染/切片现有架构;**C 是不同的存储+渲染架构**(分块+核外),是 B 撞到显存天花板后的演进,不是平行替代。
|
||||
- **推荐策略**:现在做 B 满足明星路与多数工程;把 C 的"分块格式 + 切片核外"作为**预案**,待出现真正超大数据再启动;整卷体绘制核外列为最后一档。
|
||||
|
||||
> **切片核外"最易落地"有隐藏前提(评审)**:它要求分块格式**已做完**才能"读相交块"。所以"先交付切片核外"≠ 可跳过分块——§5 顺序(先分块格式再切片核外)正确,别误读为切片核外是独立小工程。
|
||||
|
||||
**结论(用户定:Go,C 与 B 都做)**:C 的总体架构成立、对标 OpenVDS/ZGY/OME-Zarr 无误,是与 B 对等的已承诺成品;工程最重、渲染端整卷核外非开箱、落盘须裸 HDF5(已改)。
|
||||
- **落盘与 B 统一**:裸分块格式(不依赖 vtkHDFWriter),B 落盘本就改裸分块,C 的分块/切片核外在同一格式上增量获得。
|
||||
- **整卷核外分页器**是 C 的最高风险件(两个 CRITICAL + 热路径解压 HIGH,月级),**POC 即以"最小真实分页器"正面验证**,确保"过了能落地"。
|
||||
- **B/C 切换**:经 `IVolumeRenderSource` 运行时切换,用户按数据选;`LowResResample` 是 C 内的降质手段,不替代 C 也不替代用户选择。
|
||||
|
|
@ -9,6 +9,7 @@
|
|||
# add_subdirectory(controller) # 联动编排
|
||||
#
|
||||
add_subdirectory(core)
|
||||
add_subdirectory(io)
|
||||
add_subdirectory(data)
|
||||
add_subdirectory(net)
|
||||
add_subdirectory(render)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
#include "AnomalySaveDialog.hpp"
|
||||
|
||||
#include <QComboBox>
|
||||
|
||||
#include "EmptyAwareComboBox.hpp"
|
||||
#include <QFormLayout>
|
||||
#include <QLabel>
|
||||
#include <QLineEdit>
|
||||
|
|
@ -39,7 +41,7 @@ AnomalySaveDialog::AnomalySaveDialog(const QString& screenshotPath, int shotW, i
|
|||
formkit::capField(name_);
|
||||
form->addRow(formkit::editLabel(QStringLiteral("名称")), name_);
|
||||
|
||||
type_ = new QComboBox();
|
||||
type_ = new EmptyAwareComboBox();
|
||||
for (const auto& t : kMockTypes)
|
||||
type_->addItem(QString::fromUtf8(t.label), QString::fromUtf8(t.id));
|
||||
formkit::capField(type_);
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ add_executable(geopro_desktop WIN32
|
|||
main.cpp
|
||||
Theme.cpp
|
||||
FormKit.cpp
|
||||
EmptyAwareComboBox.cpp
|
||||
TopBar.cpp
|
||||
ToastOverlay.cpp
|
||||
Glyphs.cpp
|
||||
|
|
@ -38,17 +39,21 @@ add_executable(geopro_desktop WIN32
|
|||
panels/DatasetAttrPanel.cpp
|
||||
panels/ObjectExceptionPanel.cpp
|
||||
panels/DescriptionPanel.cpp
|
||||
panels/QuillDelta.cpp
|
||||
panels/chart/RawDataChartView.cpp
|
||||
panels/chart/InversionFormDialog.cpp
|
||||
panels/chart/InversionFormParse.cpp
|
||||
panels/chart/ScatterDataOps.cpp
|
||||
panels/chart/SaveAsDialog.cpp
|
||||
panels/chart/ScatterFilterDialog.cpp
|
||||
panels/chart/ScatterHistogram.cpp
|
||||
panels/chart/RangeSlider.cpp
|
||||
panels/chart/InversionProcessOps.cpp
|
||||
panels/chart/GridWizardDialog.cpp
|
||||
panels/chart/WhiteningDialog.cpp
|
||||
panels/chart/FilterDialog.cpp
|
||||
panels/chart/ExceptionDialog.cpp
|
||||
panels/chart/ExceptionTypeDialog.cpp
|
||||
panels/chart/ExceptionTextDialog.cpp
|
||||
panels/chart/ExceptionDetailDialog.cpp
|
||||
panels/chart/AutoAnnotationDialog.cpp
|
||||
panels/chart/ContourSimplify.cpp
|
||||
|
|
@ -70,6 +75,9 @@ add_executable(geopro_desktop WIN32
|
|||
panels/chart/ContourPlotItem.cpp
|
||||
panels/chart/LivePanner.cpp
|
||||
panels/chart/ScatterHoverTip.cpp
|
||||
panels/chart/ChartPickGeometry.cpp
|
||||
panels/chart/ScatterMarqueePicker.cpp
|
||||
panels/chart/ContourDrawTool.cpp
|
||||
panels/columns/Column2DDataset.cpp
|
||||
panels/columns/Column3DDataset.cpp
|
||||
panels/columns/Column3DAnalysis.cpp
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@
|
|||
|
||||
#include <QColorDialog>
|
||||
#include <QComboBox>
|
||||
|
||||
#include "EmptyAwareComboBox.hpp"
|
||||
#include <QDialogButtonBox>
|
||||
#include <QDoubleSpinBox>
|
||||
#include <QFile>
|
||||
|
|
@ -105,7 +107,7 @@ ColorGradientDialog::ColorGradientDialog(const std::vector<Stop>& init, double m
|
|||
int rowIdx = 0;
|
||||
|
||||
// 配色方案(下拉带预览色条)。
|
||||
schemeCombo_ = new QComboBox(this);
|
||||
schemeCombo_ = new EmptyAwareComboBox(this);
|
||||
schemeCombo_->setIconSize(QSize(100, 16));
|
||||
{
|
||||
auto* cell = new QHBoxLayout();
|
||||
|
|
@ -118,7 +120,7 @@ ColorGradientDialog::ColorGradientDialog(const std::vector<Stop>& init, double m
|
|||
{
|
||||
auto* cell = new QHBoxLayout();
|
||||
cell->addWidget(formkit::editLabel(QStringLiteral("分布方式:")));
|
||||
auto* distCombo = new QComboBox(this);
|
||||
auto* distCombo = new EmptyAwareComboBox(this);
|
||||
distCombo->addItem(QStringLiteral("线性"), QStringLiteral("linear"));
|
||||
distCombo->addItem(QStringLiteral("对数"), QStringLiteral("log"));
|
||||
distCombo->setCurrentIndex(0);
|
||||
|
|
|
|||
|
|
@ -97,14 +97,16 @@ ColorScaleConfigDialog::ColorScaleConfigDialog(const geopro::core::ColorScale& i
|
|||
double vmax, std::vector<double> samples,
|
||||
const ContourLineConfig& lineInit,
|
||||
geopro::data::IColorTemplateRepository* tplRepo,
|
||||
QString projectId, QWidget* parent)
|
||||
QString projectId, QString lvlTemplateId,
|
||||
QWidget* parent)
|
||||
: QDialog(parent),
|
||||
vmin_(vmin),
|
||||
vmax_(vmax),
|
||||
samples_(std::move(samples)),
|
||||
lineCfg_(lineInit),
|
||||
tplRepo_(tplRepo),
|
||||
projectId_(std::move(projectId)) {
|
||||
projectId_(std::move(projectId)),
|
||||
lvlTemplateId_(std::move(lvlTemplateId)) {
|
||||
setWindowTitle(QStringLiteral("色阶配置"));
|
||||
setModal(true);
|
||||
resize(560, 420);
|
||||
|
|
@ -433,11 +435,31 @@ void ColorScaleConfigDialog::loadColorBar(
|
|||
|
||||
void ColorScaleConfigDialog::onSaveOther() {
|
||||
if (tplRepo_ == nullptr || projectId_.isEmpty()) return;
|
||||
bool ok = false;
|
||||
const QString name = QInputDialog::getText(this, QStringLiteral("另存模板配置"),
|
||||
QStringLiteral("模板名称:"), QLineEdit::Normal,
|
||||
QStringLiteral("等值线配置.lvl"), &ok);
|
||||
if (!ok || name.trimmed().isEmpty()) return;
|
||||
|
||||
// 自定义另存为弹窗(复刻 handleSaveOther):名称输入 + 覆盖复选框。
|
||||
// 「覆盖」仅当有来源模板 id(lvlTemplateId_ 非空)时可勾选,对照原版 props.data.lvlTemplateId。
|
||||
QDialog askDlg(this);
|
||||
askDlg.setWindowTitle(QStringLiteral("另存模板配置"));
|
||||
askDlg.setModal(true);
|
||||
auto* askRoot = new QVBoxLayout(&askDlg);
|
||||
auto* nameRow = new QHBoxLayout();
|
||||
nameRow->addWidget(new QLabel(QStringLiteral("模板名称:"), &askDlg));
|
||||
auto* nameEdit = new QLineEdit(QStringLiteral("等值线配置.lvl"), &askDlg);
|
||||
nameRow->addWidget(nameEdit, 1);
|
||||
askRoot->addLayout(nameRow);
|
||||
auto* overwriteCheck = new QCheckBox(QStringLiteral("覆盖原模板"), &askDlg);
|
||||
overwriteCheck->setEnabled(!lvlTemplateId_.isEmpty()); // 无来源模板 → 禁用覆盖
|
||||
askRoot->addWidget(overwriteCheck);
|
||||
auto* askBtns = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, &askDlg);
|
||||
askBtns->button(QDialogButtonBox::Ok)->setText(QStringLiteral("应用"));
|
||||
askBtns->button(QDialogButtonBox::Cancel)->setText(QStringLiteral("取消"));
|
||||
connect(askBtns, &QDialogButtonBox::accepted, &askDlg, &QDialog::accept);
|
||||
connect(askBtns, &QDialogButtonBox::rejected, &askDlg, &QDialog::reject);
|
||||
askRoot->addWidget(askBtns);
|
||||
if (askDlg.exec() != QDialog::Accepted) return;
|
||||
const QString name = nameEdit->text().trimmed();
|
||||
if (name.isEmpty()) return;
|
||||
const bool overwrite = overwriteCheck->isChecked() && !lvlTemplateId_.isEmpty();
|
||||
|
||||
// 组装 properties(复刻 handleSaveOther)。
|
||||
QJsonArray colorBar;
|
||||
|
|
@ -457,18 +479,22 @@ void ColorScaleConfigDialog::onSaveOther() {
|
|||
{QStringLiteral("colorBar"), colorBar}};
|
||||
|
||||
// 走仓储传输;回调里用 QPointer 守卫 this(模态对话框可能已关)。
|
||||
// 勾选覆盖 → PUT 更新来源模板(updateLvlTemplate);否则 → POST 新建(saveLvlTemplate)。
|
||||
QPointer<ColorScaleConfigDialog> self(this);
|
||||
tplRepo_->saveLvlTemplate(projectId_, name.trimmed(), properties,
|
||||
[self](bool ok, QString msg) {
|
||||
if (!self) return;
|
||||
if (ok)
|
||||
QMessageBox::information(self, QStringLiteral("另存"),
|
||||
QStringLiteral("另存成功。"));
|
||||
else
|
||||
QMessageBox::warning(
|
||||
self, QStringLiteral("另存"),
|
||||
QStringLiteral("另存失败:%1").arg(msg));
|
||||
});
|
||||
auto onDone = [self, overwrite](bool ok, QString msg) {
|
||||
if (!self) return;
|
||||
if (ok)
|
||||
QMessageBox::information(
|
||||
self, QStringLiteral("另存"),
|
||||
overwrite ? QStringLiteral("更新成功。") : QStringLiteral("另存成功。"));
|
||||
else
|
||||
QMessageBox::warning(self, QStringLiteral("另存"),
|
||||
QStringLiteral("另存失败:%1").arg(msg));
|
||||
};
|
||||
if (overwrite)
|
||||
tplRepo_->updateLvlTemplate(lvlTemplateId_, name, properties, std::move(onDone));
|
||||
else
|
||||
tplRepo_->saveLvlTemplate(projectId_, name, properties, std::move(onDone));
|
||||
}
|
||||
|
||||
void ColorScaleConfigDialog::onOpen() {
|
||||
|
|
|
|||
|
|
@ -29,12 +29,15 @@ public:
|
|||
// init:当前色阶(升序断点填表);vmin/vmax:数据原始范围(层级/颜色子对话框 + 新增外推用);
|
||||
// samples:数据原始标量(等积分层 + 颜色编辑器直方图用,空则等积退化为线性);
|
||||
// lineInit:线形/标注初值(2D 传当前态,3D 用默认);
|
||||
// tplRepo/projectId:lvl 模板库仓储句柄(可空 → 另存为/打开 禁用)。
|
||||
// tplRepo/projectId:lvl 模板库仓储句柄(可空 → 另存为/打开 禁用);
|
||||
// lvlTemplateId:当前色阶来源模板 id(可空,对照原版 props.data.lvlTemplateId)。
|
||||
// 非空时「另存为」弹窗的「覆盖」复选框可勾选 → 走 PUT 更新该模板;3D/无模板场景不传即可。
|
||||
ColorScaleConfigDialog(const geopro::core::ColorScale& init, double vmin, double vmax,
|
||||
std::vector<double> samples = {},
|
||||
const ContourLineConfig& lineInit = {},
|
||||
geopro::data::IColorTemplateRepository* tplRepo = nullptr,
|
||||
QString projectId = {}, QWidget* parent = nullptr);
|
||||
QString projectId = {}, QString lvlTemplateId = {},
|
||||
QWidget* parent = nullptr);
|
||||
|
||||
// 由表格当前断点装配的新色阶(按层级升序 addStop)。
|
||||
geopro::core::ColorScale colorScale() const;
|
||||
|
|
@ -74,6 +77,7 @@ private:
|
|||
|
||||
geopro::data::IColorTemplateRepository* tplRepo_ = nullptr; // lvl 模板库仓储(可空)
|
||||
QString projectId_;
|
||||
QString lvlTemplateId_; // 当前色阶来源模板 id(可空 → 另存为弹窗禁用「覆盖」)
|
||||
// 随子对话框更新、写入另存为 properties(复刻原版透传字段)。
|
||||
QString lvlSchemeType_ = QStringLiteral("normal");
|
||||
int logLinesCount_ = 8;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
#include <cmath>
|
||||
|
||||
#include <QComboBox>
|
||||
|
||||
#include "EmptyAwareComboBox.hpp"
|
||||
#include <QDialogButtonBox>
|
||||
#include <QDoubleValidator>
|
||||
#include <QFormLayout>
|
||||
|
|
@ -38,7 +40,7 @@ ContourLevelDialog::ContourLevelDialog(const ContourLevelParams& init, double or
|
|||
new QLabel(QStringLiteral("%1 ~ %2").arg(originMin_).arg(originMax_)));
|
||||
|
||||
// 分层方式。
|
||||
methodCombo_ = new QComboBox(this);
|
||||
methodCombo_ = new EmptyAwareComboBox(this);
|
||||
methodCombo_->addItem(QStringLiteral("一般的"), 0);
|
||||
methodCombo_->addItem(QStringLiteral("对数"), 1);
|
||||
methodCombo_->addItem(QStringLiteral("等积"), 2);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@
|
|||
#include <QColor>
|
||||
#include <QColorDialog>
|
||||
#include <QComboBox>
|
||||
|
||||
#include "EmptyAwareComboBox.hpp"
|
||||
#include <QDialogButtonBox>
|
||||
#include <QFormLayout>
|
||||
#include <QPushButton>
|
||||
|
|
@ -34,7 +36,7 @@ ContourLineDialog::ContourLineDialog(const ContourLineConfig& init, QWidget* par
|
|||
auto* form = formkit::makeEditForm();
|
||||
|
||||
// 复刻 contourLine.vue:选项顺序「虚线」在前、「实线」在后。
|
||||
lineTypeCombo_ = new QComboBox(this);
|
||||
lineTypeCombo_ = new EmptyAwareComboBox(this);
|
||||
lineTypeCombo_->addItem(QStringLiteral("- - - - - - - - -"), true); // dashed
|
||||
lineTypeCombo_->addItem(QStringLiteral("——————"), false); // solid
|
||||
lineTypeCombo_->setCurrentIndex(cfg_.dashed ? 0 : 1);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,65 @@
|
|||
#include "EmptyAwareComboBox.hpp"
|
||||
|
||||
#include <QColor>
|
||||
#include <QStandardItem>
|
||||
#include <QStandardItemModel>
|
||||
|
||||
#include "Theme.hpp"
|
||||
|
||||
namespace geopro::app {
|
||||
|
||||
namespace {
|
||||
// 临时「暂无数据」项用 UserRole 打标,便于 realItemCount/hidePopup 精准识别与移除。
|
||||
constexpr int kEmptyHintRole = Qt::UserRole + 9001;
|
||||
const char* kEmptyHintText = "暂无数据";
|
||||
} // namespace
|
||||
|
||||
EmptyAwareComboBox::EmptyAwareComboBox(QWidget* parent) : QComboBox(parent) {
|
||||
// 不在此强设 currentIndex。占位语义由 setPlaceholderText 的「添加项前调用」时机决定:
|
||||
// - 经 formkit::comboBox(placeholder,...) 建的(占位在添加项前设好)→ 即便后续异步加入
|
||||
// 数据项,Qt 仍维持 currentIndex=-1 显示占位,不自动选首项(Arco ASelect 占位语义)。
|
||||
// - 不带占位直接 new 的静态下拉 → addItem 首项后 Qt 自动选中索引 0(保持既有默认选中行为)。
|
||||
}
|
||||
|
||||
int EmptyAwareComboBox::realItemCount() const {
|
||||
int n = 0;
|
||||
for (int i = 0; i < count(); ++i) {
|
||||
// 排除临时「暂无数据」占位项。
|
||||
if (itemData(i, kEmptyHintRole).toBool()) continue;
|
||||
// 排除不可选项(禁用 / NoItemFlags),它们不构成「真实可选数据」。
|
||||
if (!(itemData(i, Qt::UserRole - 1).value<Qt::ItemFlags>() & Qt::ItemIsSelectable))
|
||||
continue;
|
||||
++n;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
void EmptyAwareComboBox::showPopup() {
|
||||
// 无真实可选项时,临时插入一条灰色禁用「暂无数据」,让弹窗不再空白(同 Arco)。
|
||||
if (realItemCount() == 0 && !emptyHintInserted_) {
|
||||
addItem(QString::fromUtf8(kEmptyHintText));
|
||||
const int idx = count() - 1;
|
||||
setItemData(idx, true, kEmptyHintRole); // 打标,关闭时按标移除
|
||||
setItemData(idx, Qt::AlignCenter, Qt::TextAlignmentRole);
|
||||
// 灰色禁用观感:取项目 token 色(text/disabled),与 Arco 空态一致。
|
||||
setItemData(idx, tokenColor("text/disabled"), Qt::ForegroundRole);
|
||||
if (auto* m = qobject_cast<QStandardItemModel*>(model())) {
|
||||
if (auto* it = m->item(idx)) it->setFlags(Qt::NoItemFlags); // 不可选/不可聚焦
|
||||
}
|
||||
emptyHintInserted_ = true;
|
||||
}
|
||||
QComboBox::showPopup();
|
||||
}
|
||||
|
||||
void EmptyAwareComboBox::hidePopup() {
|
||||
QComboBox::hidePopup();
|
||||
// 移除临时「暂无数据」项,恢复纯净数据(取值/计数不受污染)。临时项是禁用不可选的,
|
||||
// 用户无法选中它,故移除后 currentIndex 自然维持原值(占位组合仍为 -1,无需强设)。
|
||||
if (emptyHintInserted_) {
|
||||
for (int i = count() - 1; i >= 0; --i)
|
||||
if (itemData(i, kEmptyHintRole).toBool()) removeItem(i);
|
||||
emptyHintInserted_ = false;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace geopro::app
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
#pragma once
|
||||
|
||||
// EmptyAwareComboBox —— 空态感知下拉框(对齐原版 web Arco ASelect 观感)。
|
||||
//
|
||||
// 历史问题:数据驱动的下拉(白化文件、异常类型、反演模型……)异步加载,加载前/无数据时:
|
||||
// 1) 裸 QComboBox 会自动选中首项或留空,无「请选择 X」灰色占位提示;
|
||||
// 2) 弹窗里一片空白,用户不知是「加载中」还是「真的没有」。
|
||||
// Arco ASelect 的标准行为是:未选时显示灰色占位文案,无数据时弹窗显示一条灰色「暂无数据」。
|
||||
// 本类把这两点收敛到唯一实现,全局通过 formkit::comboBox(...) 建下拉即自动获得一致观感。
|
||||
|
||||
#include <QComboBox>
|
||||
|
||||
namespace geopro::app {
|
||||
|
||||
// QComboBox 子类:未选→占位文案(Qt6 自带 placeholderText,currentIndex=-1 时显示);
|
||||
// 无真实可选项→点开弹窗时临时插入一条禁用的灰色「暂无数据」,弹窗关闭后移除(不污染数据/取值)。
|
||||
class EmptyAwareComboBox : public QComboBox {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit EmptyAwareComboBox(QWidget* parent = nullptr);
|
||||
|
||||
// 点开弹窗:若无真实可选项(排除占位/禁用项),临时插入一条禁用「暂无数据」再弹出。
|
||||
void showPopup() override;
|
||||
// 关闭弹窗:移除临时「暂无数据」项,保证数据/取值不被污染。
|
||||
void hidePopup() override;
|
||||
|
||||
private:
|
||||
// 统计「真实可选条目数」:排除占位项与不可选(NoItemFlags/禁用)项。
|
||||
int realItemCount() const;
|
||||
|
||||
bool emptyHintInserted_ = false; // 当前是否插入了临时「暂无数据」项
|
||||
};
|
||||
|
||||
} // namespace geopro::app
|
||||
|
|
@ -35,7 +35,8 @@ ExportDatasetDialog::ExportDatasetDialog(geopro::data::IAsyncProjectRepository&
|
|||
auto* cardLay = formkit::cardBody(card);
|
||||
|
||||
auto* fl = formkit::makeEditForm();
|
||||
templateCombo_ = new QComboBox(this);
|
||||
// 空态感知下拉:数据驱动(异步 loadTemplates),未选显占位、无数据弹「暂无数据」。
|
||||
templateCombo_ = formkit::comboBox(QStringLiteral("请选择导出模板"), this);
|
||||
formkit::capField(templateCombo_);
|
||||
fl->addRow(formkit::editLabel(QStringLiteral("导出模板")), templateCombo_);
|
||||
cardLay->addLayout(fl);
|
||||
|
|
|
|||
|
|
@ -11,11 +11,20 @@
|
|||
|
||||
#include <utility>
|
||||
|
||||
#include "EmptyAwareComboBox.hpp"
|
||||
#include "Theme.hpp"
|
||||
#include "panels/KeyValueView.hpp"
|
||||
|
||||
namespace geopro::app::formkit {
|
||||
|
||||
QComboBox* comboBox(const QString& placeholder, QWidget* parent) {
|
||||
auto* cb = new EmptyAwareComboBox(parent);
|
||||
// 有占位文案:设占位 + 维持 currentIndex=-1(构造已置 -1),未选时显灰字占位。
|
||||
// 无占位文案:留空,addItem 后自动选首项(保持静态下拉的既有默认选中行为)。
|
||||
if (!placeholder.isEmpty()) cb->setPlaceholderText(placeholder);
|
||||
return cb;
|
||||
}
|
||||
|
||||
DetailForm& DetailForm::group(const QString& name) {
|
||||
geopro::data::DynamicFormGroup g;
|
||||
g.name = name.toStdString();
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
#include "repo/RepoTypes.hpp"
|
||||
|
||||
class QBoxLayout;
|
||||
class QComboBox;
|
||||
class QDialog;
|
||||
class QDialogButtonBox;
|
||||
class QFormLayout;
|
||||
|
|
@ -23,6 +24,12 @@ class QWidget;
|
|||
|
||||
namespace geopro::app::formkit {
|
||||
|
||||
// ── 下拉框:全局建下拉的标准入口(返回空态感知下拉 EmptyAwareComboBox)──────────────
|
||||
// placeholder 非空时设占位文案 + currentIndex=-1(未选显灰字占位,对齐 Arco ASelect);
|
||||
// placeholder 为空时保持 QComboBox 默认行为(addItem 后自动选首项),不强加占位。
|
||||
// 无真实可选项时点开弹窗会显示一条灰色「暂无数据」(实现于 EmptyAwareComboBox)。
|
||||
QComboBox* comboBox(const QString& placeholder = QString(), QWidget* parent = nullptr);
|
||||
|
||||
// ── 只读详情:唯一键值模型构建器 ─────────────────────────────────────────────
|
||||
// 链式 group()/row() 产出 data::DynamicForm,喂给唯一的 §6.4 渲染器 DynamicFormView。
|
||||
// 三维体/切片/异常等「数据详情」对话框共用,杜绝裸 QFormLayout 漂移。
|
||||
|
|
|
|||
|
|
@ -53,10 +53,11 @@ ImportDatasetDialog::ImportDatasetDialog(geopro::data::IAsyncProjectRepository&
|
|||
|
||||
auto* fl = formkit::makeEditForm();
|
||||
|
||||
typeCombo_ = new QComboBox(card);
|
||||
// 空态感知下拉:数据类型/导入脚本均数据驱动(异步加载),未选显占位、无数据弹「暂无数据」。
|
||||
typeCombo_ = formkit::comboBox(QStringLiteral("请选择数据类型"), card);
|
||||
formkit::capField(typeCombo_);
|
||||
fl->addRow(formkit::editLabel(QStringLiteral("数据类型")), typeCombo_);
|
||||
scriptCombo_ = new QComboBox(card);
|
||||
scriptCombo_ = formkit::comboBox(QStringLiteral("请选择导入脚本"), card);
|
||||
formkit::capField(scriptCombo_);
|
||||
fl->addRow(formkit::editLabel(QStringLiteral("导入脚本")), scriptCombo_);
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
#include <utility>
|
||||
|
||||
#include <QComboBox>
|
||||
|
||||
#include "EmptyAwareComboBox.hpp"
|
||||
#include <QFormLayout>
|
||||
#include <QHBoxLayout>
|
||||
#include <QJsonDocument>
|
||||
|
|
@ -140,7 +142,7 @@ void ObjectFormDialog::buildTopFields() {
|
|||
// 新建 GS/TM:类型下拉(数据源 gsList / tmList,选择后重载动态表单)。
|
||||
const QString label =
|
||||
confType_ == kConfTm ? QStringLiteral("方法类型") : QStringLiteral("对象类型");
|
||||
typeCombo_ = new QComboBox(topBox_);
|
||||
typeCombo_ = new EmptyAwareComboBox(topBox_);
|
||||
addRow(label, typeCombo_);
|
||||
QObject::connect(typeCombo_, qOverload<int>(&QComboBox::currentIndexChanged), this,
|
||||
[this](int) {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
#include <QAbstractItemView>
|
||||
#include <QColor>
|
||||
#include <QComboBox>
|
||||
|
||||
#include "EmptyAwareComboBox.hpp"
|
||||
#include <QFont>
|
||||
#include <QHBoxLayout>
|
||||
#include <QHeaderView>
|
||||
|
|
@ -54,7 +56,7 @@ ProjectListDialog::ProjectListDialog(data::IAsyncProjectRepository& repo, QWidge
|
|||
filter->addWidget(nameEdit_);
|
||||
filter->addSpacing(8);
|
||||
filter->addWidget(new QLabel(QStringLiteral("项目类型"), this));
|
||||
typeCombo_ = new QComboBox(this);
|
||||
typeCombo_ = new EmptyAwareComboBox(this);
|
||||
typeCombo_->setFixedWidth(160);
|
||||
filter->addWidget(typeCombo_);
|
||||
filter->addSpacing(8);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
#include "SettingsDialog.hpp"
|
||||
|
||||
#include <QComboBox>
|
||||
|
||||
#include "EmptyAwareComboBox.hpp"
|
||||
#include <QCoreApplication>
|
||||
#include <QHBoxLayout>
|
||||
#include <QLabel>
|
||||
|
|
@ -63,7 +65,7 @@ QWidget* buildAppearancePage() {
|
|||
geopro::app::formkit::addSection(v, QStringLiteral("外观"), page, false);
|
||||
|
||||
// 主题:跟随系统 / 浅色 / 深色(热切)。
|
||||
auto* themeCombo = new QComboBox(page);
|
||||
auto* themeCombo = new EmptyAwareComboBox(page);
|
||||
themeCombo->addItem(QStringLiteral("跟随系统"), QStringLiteral("system"));
|
||||
themeCombo->addItem(QStringLiteral("浅色"), QStringLiteral("light"));
|
||||
themeCombo->addItem(QStringLiteral("深色"), QStringLiteral("dark"));
|
||||
|
|
@ -76,7 +78,7 @@ QWidget* buildAppearancePage() {
|
|||
QStringLiteral("跟随系统 / 浅色 / 深色,切换即时生效")));
|
||||
|
||||
// 界面字号:小/标准/大/特大(重启生效)。
|
||||
auto* fontCombo = new QComboBox(page);
|
||||
auto* fontCombo = new EmptyAwareComboBox(page);
|
||||
fontCombo->addItem(QStringLiteral("小"), 90);
|
||||
fontCombo->addItem(QStringLiteral("标准"), 100);
|
||||
fontCombo->addItem(QStringLiteral("大"), 115);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
#include "VolumeParamsDialog.hpp"
|
||||
|
||||
#include <QComboBox>
|
||||
|
||||
#include "EmptyAwareComboBox.hpp"
|
||||
#include <QDialogButtonBox>
|
||||
#include <QDoubleSpinBox>
|
||||
#include <QFormLayout>
|
||||
|
|
@ -43,7 +45,7 @@ VolumeParamsDialog::VolumeParamsDialog(int sourceCount, QWidget* parent) : QDial
|
|||
formkit::capField(name_);
|
||||
form->addRow(formkit::editLabel(QStringLiteral("名称")), name_);
|
||||
|
||||
model_ = new QComboBox();
|
||||
model_ = new EmptyAwareComboBox();
|
||||
model_->addItem(QStringLiteral("反距离加权 (IDW)"),
|
||||
static_cast<int>(geopro::data::VolumeBuildParams::Model::Idw));
|
||||
model_->addItem(QStringLiteral("克里金 (Kriging)"),
|
||||
|
|
|
|||
|
|
@ -72,6 +72,10 @@ public:
|
|||
// frame 原点重锚(首个带经纬剖面到达)后回调,供底图等随之刷新到数据所在位置。
|
||||
std::function<void()> onFrameReanchored;
|
||||
|
||||
// 复位"已按数据重锚"标志:切换项目清场后调,使新项目首个数据重新触发重锚(→ onFrameReanchored
|
||||
// → 底图按新项目位置重显)。否则增量勾选不走 clear(),旧标志残留 → 不重锚 → 底图不再显示。
|
||||
void resetFrameAnchor() { frameAnchoredToData_ = false; }
|
||||
|
||||
// 相机程序化变化(取景/预设/缩放)后回调,供底图按新视锥重算覆盖(否则首帧部分瓦片要手动微动才出)。
|
||||
std::function<void()> onCameraChanged;
|
||||
|
||||
|
|
|
|||
|
|
@ -192,9 +192,15 @@ public:
|
|||
{
|
||||
overlay_->adjustSize();
|
||||
const QSize h = host_->size();
|
||||
const QSize o = overlay_->size();
|
||||
overlay_->move(host_->x() + (h.width() - o.width()) / 2,
|
||||
host_->y() + (h.height() - o.height()) / 2);
|
||||
// 浮层尺寸钳到不超过 host:host 比内容小(窗口/抽屉收窄)时不再溢出视图。
|
||||
QSize o = overlay_->size();
|
||||
o.setWidth(std::min(o.width(), h.width()));
|
||||
o.setHeight(std::min(o.height(), h.height()));
|
||||
overlay_->resize(o);
|
||||
// 偏移取非负:保证浮层矩形始终 ⊆ host 矩形(不跑到 host 外)。
|
||||
const int dx = std::max(0, (h.width() - o.width()) / 2);
|
||||
const int dy = std::max(0, (h.height() - o.height()) / 2);
|
||||
overlay_->move(host_->x() + dx, host_->y() + dy);
|
||||
overlay_->raise();
|
||||
}
|
||||
|
||||
|
|
@ -807,10 +813,11 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
|
|||
}
|
||||
// 3D 无等值线,线形/标注配置忽略(用默认);仅取色阶应用。
|
||||
// 「另存为/打开」与「新建色阶/配色方案」走色阶模板仓储,projectId 取当前项目。
|
||||
// 3D 体无来源 lvl 模板 → lvlTemplateId 传空(覆盖复选框禁用,行为不变)。
|
||||
geopro::app::ColorScaleConfigDialog dlg(
|
||||
sceneView->currentColorScale(), sceneView->currentVmin(),
|
||||
sceneView->currentVmax(), std::move(samples), {}, &colorTplRepo,
|
||||
nav.currentProjectId(), &window);
|
||||
nav.currentProjectId(), QString(), &window);
|
||||
if (dlg.exec() == QDialog::Accepted)
|
||||
sceneCtrl->setVolumeColorScale(dsId, dlg.colorScale());
|
||||
});
|
||||
|
|
@ -918,17 +925,19 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
|
|||
.pixmap(56, 56));
|
||||
esIcon->setAlignment(Qt::AlignCenter);
|
||||
|
||||
auto* esTitle = new QLabel(QStringLiteral("选择左侧数据集开始分析"), emptyState);
|
||||
auto* esTitle = new QLabel(QStringLiteral("勾选左侧数据集开始渲染"), emptyState);
|
||||
esTitle->setAlignment(Qt::AlignCenter);
|
||||
esTitle->setWordWrap(true); // 窄时换行,不撑宽浮层
|
||||
geopro::app::applyTokenizedStyleSheet(
|
||||
esTitle, QStringLiteral("color:{{canvas/text}}; font-size:%1px; font-weight:%2;")
|
||||
.arg(geopro::app::scaledPx(geopro::app::type::kHeading))
|
||||
.arg(geopro::app::type::kWeightSemibold));
|
||||
|
||||
auto* esHint = new QLabel(QStringLiteral("单击左侧采集批次,查看反演剖面与异常点\n"
|
||||
"切到「三维视图」可叠加帘面、体素与地形图层"),
|
||||
auto* esHint = new QLabel(QStringLiteral("在左侧「三维数据集 / 二维数据集 / 三维分析」栏勾选数据集,\n"
|
||||
"在此叠加显示;可切换二维 / 三维视图。"),
|
||||
emptyState);
|
||||
esHint->setAlignment(Qt::AlignCenter);
|
||||
esHint->setWordWrap(true); // 窄时换行,不撑宽浮层
|
||||
geopro::app::applyTokenizedStyleSheet(
|
||||
esHint,
|
||||
QStringLiteral("color:{{canvas/text-dim}}; font-size:%1px;").arg(geopro::app::scaledPx(geopro::app::type::kBody)));
|
||||
|
|
@ -1061,7 +1070,10 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
|
|||
const QString dsId = item->data(0, geopro::app::kDsIdRole).toString();
|
||||
const QString ddCode = item->data(0, geopro::app::kDsDdCodeRole).toString();
|
||||
const QString dsName = item->data(0, geopro::app::kDsNameRole).toString();
|
||||
if (!dsId.isEmpty()) detailCtrl.openDataset(dsId, ddCode, dsName);
|
||||
// tmObjectId(白化 structParentId)从行读出透传,使白化模板列表非空。
|
||||
const QString tmObjectId =
|
||||
item->data(0, geopro::app::kDsTmObjectIdRole).toString();
|
||||
if (!dsId.isEmpty()) detailCtrl.openDataset(dsId, ddCode, dsName, tmObjectId);
|
||||
});
|
||||
|
||||
// ── 控制器信号 → 详情面板(tab 引擎):开页 / 页签就绪 / 加载中 / 聚焦 ──
|
||||
|
|
@ -1193,8 +1205,37 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
|
|||
};
|
||||
QObject::connect(topBar, &geopro::app::TopBar::workspaceSwitchRequested, &nav,
|
||||
&geopro::controller::WorkbenchNavController::switchWorkspace);
|
||||
|
||||
// 切换到「不同项目」时先清空中央区,避免新项目残留旧项目的三栏数据与 VTK 渲染。
|
||||
// 仅真正换项目用(delete-refresh 等 switchProject(currentProjectId) 不走此处,避免误清)。
|
||||
auto clearCentral = [drawer, sceneCtrl, emptyState, checkedProfiles, checkedAnalysis,
|
||||
pushChecked, lastAnalysisRows, refreshAnalysis, checkedSliceIds,
|
||||
syncSlices, basemap, sceneView]() {
|
||||
// 三栏清空(col2D/col3D setDatasets({}) 会顺带发空勾选 → setChecked2DDatasets({})/帘面清空)。
|
||||
drawer->col3D()->setDatasets({});
|
||||
drawer->col2D()->setDatasets({});
|
||||
*lastAnalysisRows = {};
|
||||
refreshAnalysis(); // 后端分析行清空(客户端三维体仍按设计驻留三维分析栏)
|
||||
// 勾选集清空并下发空到 VTK(帘面/体素/切片/2D 足迹全部撤场)。
|
||||
checkedProfiles->clear();
|
||||
checkedAnalysis->clear();
|
||||
checkedSliceIds->clear();
|
||||
pushChecked(); // setCheckedDatasets({}) → 帘面/体素清空
|
||||
syncSlices(); // 切片随空勾选调和
|
||||
sceneCtrl->setChecked2DDatasets({}); // 2D 足迹显式撤场(与 col2D 空勾选双保险)
|
||||
// 复位重锚标志:增量勾选不走 clear(),不复位则旧标志残留 → 新项目数据不重锚 →
|
||||
// onFrameReanchored 不触发 → 下面 hide() 的底图永不再显。复位后新项目首个数据重锚→重显。
|
||||
sceneView->resetFrameAnchor();
|
||||
basemap->hide(); // 底图瓦片清空(锚在旧项目位置;新项目数据到来 re-anchor 时按新位置重显)
|
||||
// 空状态浮层恢复(对象树勾选会随 structureLoaded 重建而清,无需手动)。
|
||||
emptyState->setVisible(true);
|
||||
};
|
||||
|
||||
QObject::connect(topBar, &geopro::app::TopBar::projectSwitchRequested, &nav,
|
||||
&geopro::controller::WorkbenchNavController::switchProject);
|
||||
[&nav, clearCentral](const QString& id) {
|
||||
if (id != nav.currentProjectId()) clearCentral(); // 真正换项目才清
|
||||
nav.switchProject(id);
|
||||
});
|
||||
// 退出登录:清除记住的凭证(QtKeychain+QSettings) → 重启应用回到登录页。
|
||||
QObject::connect(topBar, &geopro::app::TopBar::logoutRequested, &window, []() {
|
||||
geopro::app::forgetSession();
|
||||
|
|
@ -1208,12 +1249,15 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
|
|||
dlg.exec();
|
||||
});
|
||||
QObject::connect(topBar, &geopro::app::TopBar::allProjectsRequested, &window,
|
||||
[&projectRepo, &nav, topBar, &window]() {
|
||||
[&projectRepo, &nav, topBar, &window, clearCentral]() {
|
||||
auto* dlg = new geopro::app::ProjectListDialog(projectRepo, &window);
|
||||
dlg->setAttribute(Qt::WA_DeleteOnClose);
|
||||
QObject::connect(dlg, &geopro::app::ProjectListDialog::projectChosen, &nav,
|
||||
[&nav, topBar](const QString& id, const QString& name) {
|
||||
[&nav, topBar, clearCentral](const QString& id,
|
||||
const QString& name) {
|
||||
topBar->setProjectButtonText(name);
|
||||
if (id != nav.currentProjectId())
|
||||
clearCentral(); // 真正换项目才清
|
||||
nav.switchProject(id);
|
||||
});
|
||||
dlg->exec();
|
||||
|
|
@ -1468,10 +1512,12 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
|
|||
if (dsId.isEmpty()) return;
|
||||
const QString ddCode = item->data(0, geopro::app::kDsDdCodeRole).toString();
|
||||
const QString dsName = item->data(0, geopro::app::kDsNameRole).toString();
|
||||
// tmObjectId(白化 structParentId)从行读出透传,使白化模板列表非空。
|
||||
const QString tmObjectId = item->data(0, geopro::app::kDsTmObjectIdRole).toString();
|
||||
QMenu menu(datasetList);
|
||||
menu.addAction(QStringLiteral("数据集详情"), datasetList,
|
||||
[&detailCtrl, dsId, ddCode, dsName]() {
|
||||
detailCtrl.openDataset(dsId, ddCode, dsName);
|
||||
[&detailCtrl, dsId, ddCode, dsName, tmObjectId]() {
|
||||
detailCtrl.openDataset(dsId, ddCode, dsName, tmObjectId);
|
||||
});
|
||||
menu.addAction(QStringLiteral("属性"), datasetList, [&nav, dsId]() {
|
||||
nav.selectDataset(dsId); // 只读元字段
|
||||
|
|
@ -1636,10 +1682,11 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
|
|||
});
|
||||
QObject::connect(&nav, &geopro::controller::WorkbenchNavController::datasetsLoaded, datasetList,
|
||||
[removeTreeLoadMore, addTreeLoadMore, datasetList, datasetTitle, datasetTabs](
|
||||
const QString&, const std::vector<geopro::data::DsRow>& rows, int total,
|
||||
bool append) {
|
||||
const QString& tmObjectId, const std::vector<geopro::data::DsRow>& rows,
|
||||
int total, bool append) {
|
||||
removeTreeLoadMore(datasetList);
|
||||
geopro::app::populateDatasetList(datasetList, rows, append);
|
||||
// tmObjectId(本批所属 TM 对象 id)存入每项 → 白化对话框透传用(structParentId)。
|
||||
geopro::app::populateDatasetList(datasetList, rows, append, tmObjectId);
|
||||
const int loaded = addTreeLoadMore(datasetList, total);
|
||||
if (datasetTitle) datasetTitle->setText(QStringLiteral("数据集"));
|
||||
datasetTabs->setTabText(
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@
|
|||
#include <QToolButton>
|
||||
#include <QVBoxLayout>
|
||||
namespace geopro::app {
|
||||
static QString markName(int t) { return t == 1 ? "点" : t == 3 ? "多边形" : "多段线"; }
|
||||
static QString markName(int t) {
|
||||
return t == 1 ? "点" : t == 3 ? "多边形" : t == 4 ? "文字" : "多段线";
|
||||
}
|
||||
|
||||
AnomalyTablePanel::AnomalyTablePanel(QWidget* parent) : QWidget(parent) {
|
||||
auto* lay = new QVBoxLayout(this); lay->setContentsMargins(0, 0, 0, 0);
|
||||
|
|
@ -53,9 +55,10 @@ void AnomalyTablePanel::setAnomalies(const std::vector<geopro::core::Anomaly>& l
|
|||
connect(btnDetail, &QToolButton::clicked, this, [this, i]() { emit detailRequested(i); });
|
||||
auto* btnDelete = new QToolButton(ops); btnDelete->setText(QStringLiteral("删除"));
|
||||
connect(btnDelete, &QToolButton::clicked, this, [this, i]() {
|
||||
// 原版 a-popconfirm 二次确认 → 这里用 QMessageBox 确认。
|
||||
// 原版 a-popconfirm 二次确认(contourContentDelete)→ 这里用 QMessageBox,文案对齐原版。
|
||||
if (QMessageBox::question(this, QStringLiteral("提示"),
|
||||
QStringLiteral("确定删除该异常?")) == QMessageBox::Yes)
|
||||
QStringLiteral("该操作会删除该异常标注数据,确认?")) ==
|
||||
QMessageBox::Yes)
|
||||
emit deleteRequested(i);
|
||||
});
|
||||
opLay->addWidget(eye);
|
||||
|
|
|
|||
|
|
@ -50,7 +50,8 @@ void DatasetDetailPage::build(const QString& dsId, const QString& ddCode, const
|
|||
// 仓储与 projectId 回调透传给工厂(FilledContour 用色阶模板仓储;Scatter 用反演命令仓储)。
|
||||
// dsIdGetter 用本页 dsId_(此处已赋值),随项目/数据集稳定。
|
||||
auto view = makeDetailView(spec.kind, this, colorTplRepo_, projectIdGetter_, cmdRepo_,
|
||||
[this] { return dsId_; }); // 抛出由调用栈兜底(GuardedApplication)
|
||||
[this] { return dsId_; },
|
||||
[this] { return tmObjectId_; }); // 抛出由调用栈兜底(GuardedApplication)
|
||||
IDetailView* raw = view.release(); // QWidget 由 this/QwtPlot 父子树接管生命周期
|
||||
views_[i] = raw;
|
||||
// lazy 页签:建覆盖该视图的加载遮罩(父为视图 widget,随其尺寸覆盖图区)。
|
||||
|
|
|
|||
|
|
@ -32,6 +32,9 @@ public:
|
|||
// dsId 用本页 dsId_(build 内构造 dsIdGetter,此时 dsId_ 已赋值);projectId 复用上面的 getter。
|
||||
void setCommandRepo(geopro::data::IDatasetCommandRepository* repo);
|
||||
|
||||
// 所属 TM 对象 id(=白化 structParentId)注入(须在 build 前设置 → tmObjectIdGetter 透传给视图)。
|
||||
void setTmObjectId(const QString& tmObjectId) { tmObjectId_ = tmObjectId; }
|
||||
|
||||
// 按页签集构建页签(首次打开调一次)。dsId/ddCode/dsName 用于 tabNeeded。
|
||||
void build(const QString& dsId, const QString& ddCode, const QString& dsName,
|
||||
const std::vector<geopro::controller::TabSpec>& tabs);
|
||||
|
|
@ -57,6 +60,7 @@ private:
|
|||
QString dsId_;
|
||||
QString ddCode_;
|
||||
QString dsName_;
|
||||
QString tmObjectId_; // 所属 TM 对象 id(白化 structParentId),经 tmObjectIdGetter 透传给视图
|
||||
std::vector<geopro::controller::TabSpec> tabs_;
|
||||
// 与 tabs_ 同序。每个 IDetailView 持有的 QWidget 经 build() 以 this 为父接管,
|
||||
// 生命周期由 Qt 父子树清理(不在此 delete);build() 仅调用一次(见其断言)。
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ DatasetDetailPage* DatasetDetailPanel::pageFor(const QString& dsId) const {
|
|||
}
|
||||
|
||||
void DatasetDetailPanel::onDatasetOpened(const QString& dsId, const QString& ddCode,
|
||||
const QString& dsName,
|
||||
const QString& dsName, const QString& tmObjectId,
|
||||
const std::vector<geopro::controller::TabSpec>& tabs) {
|
||||
auto* p = pageFor(dsId);
|
||||
if (!p) {
|
||||
|
|
@ -40,6 +40,7 @@ void DatasetDetailPanel::onDatasetOpened(const QString& dsId, const QString& ddC
|
|||
// 注入须在 build 前(build 内造视图时即透传给工厂)。
|
||||
p->setColorTemplateRepo(colorTplRepo_, projectIdGetter_);
|
||||
p->setCommandRepo(cmdRepo_);
|
||||
p->setTmObjectId(tmObjectId); // 白化 structParentId(build 前设置 → 透传给视图)
|
||||
p->build(dsId, ddCode, dsName, tabs); // ddCode 透传 → 页内 tabNeeded 携带
|
||||
const QString title = dsName.isEmpty() ? dsId : dsName; // 页签标题用数据名(空则回退 id)
|
||||
const int idx = addTab(p, title);
|
||||
|
|
|
|||
|
|
@ -26,7 +26,9 @@ public:
|
|||
void setCommandRepo(geopro::data::IDatasetCommandRepository* repo);
|
||||
|
||||
// 数据集打开:find-or-create 页 → build(tabs) → 加/抬该面板页签。
|
||||
// tmObjectId:所属 TM 对象 id(白化 structParentId),build 前交给页 → 视图。
|
||||
void onDatasetOpened(const QString& dsId, const QString& ddCode, const QString& dsName,
|
||||
const QString& tmObjectId,
|
||||
const std::vector<geopro::controller::TabSpec>& tabs);
|
||||
void onTabReady(const QString& dsId, int tabIndex, const QVariant& payload);
|
||||
void onTabLoadStarted(const QString& dsId, int tabIndex);
|
||||
|
|
|
|||
|
|
@ -160,7 +160,7 @@ public:
|
|||
|
||||
namespace {
|
||||
// 建一条数据集树项(不挂载):列0 文本 = dsName +「创建时间 · 类型名」,data 存各角色。
|
||||
QTreeWidgetItem* makeDatasetItem(const geopro::data::DsRow& d) {
|
||||
QTreeWidgetItem* makeDatasetItem(const geopro::data::DsRow& d, const QString& tmObjectId) {
|
||||
QString text = QString::fromStdString(d.dsName);
|
||||
QString sub = QString::fromStdString(d.createTime); // 名称下先创建时间
|
||||
if (!d.typeName.empty())
|
||||
|
|
@ -174,6 +174,7 @@ QTreeWidgetItem* makeDatasetItem(const geopro::data::DsRow& d) {
|
|||
item->setData(0, kDsNameRole, QString::fromStdString(d.dsName));
|
||||
item->setData(0, kDsTypeNameRole, QString::fromStdString(d.typeName));
|
||||
item->setData(0, kDsCreateTimeRole, QString::fromStdString(d.createTime));
|
||||
item->setData(0, kDsTmObjectIdRole, tmObjectId); // 所属 TM 对象 id(白化 structParentId)
|
||||
// 单击 tip:显示数据集主要属性(名称 / 类型 / 创建时间),对齐菜单文档「tip显示ds的主要属性」。
|
||||
QString tip = QStringLiteral("名称:%1").arg(QString::fromStdString(d.dsName));
|
||||
if (!d.typeName.empty()) tip += QStringLiteral("\n类型:%1").arg(QString::fromStdString(d.typeName));
|
||||
|
|
@ -184,7 +185,8 @@ QTreeWidgetItem* makeDatasetItem(const geopro::data::DsRow& d) {
|
|||
}
|
||||
} // namespace
|
||||
|
||||
void populateDatasetList(QTreeWidget* tree, const std::vector<geopro::data::DsRow>& rows, bool append) {
|
||||
void populateDatasetList(QTreeWidget* tree, const std::vector<geopro::data::DsRow>& rows, bool append,
|
||||
const QString& tmObjectId) {
|
||||
if (!tree) return;
|
||||
if (!append) tree->clear();
|
||||
|
||||
|
|
@ -199,7 +201,7 @@ void populateDatasetList(QTreeWidget* tree, const std::vector<geopro::data::DsRo
|
|||
std::vector<QTreeWidgetItem*> batch;
|
||||
batch.reserve(rows.size());
|
||||
for (const auto& d : rows) {
|
||||
auto* item = makeDatasetItem(d);
|
||||
auto* item = makeDatasetItem(d, tmObjectId);
|
||||
byId.insert(QString::fromStdString(d.id), item);
|
||||
batch.push_back(item);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,11 +22,14 @@ constexpr int kDsDdCodeRole = 0x0104; // Qt::UserRole + 4(ddCode,双击详
|
|||
constexpr int kDsNameRole = 0x0105; // Qt::UserRole + 5(dsName,详情页签标题用)
|
||||
constexpr int kDsTypeNameRole = 0x0106; // Qt::UserRole + 6(类型名,快速筛选用)
|
||||
constexpr int kDsCreateTimeRole = 0x0107; // Qt::UserRole + 7(创建时间,按日期筛选用)
|
||||
constexpr int kDsTmObjectIdRole = 0x0108; // Qt::UserRole + 8(所属 TM 对象 id=白化 structParentId)
|
||||
|
||||
// 数据页签:树形(按 DsRow.parentId 嵌套,源数据为根、派生数据挂其下,对齐原版 el-table 树)。
|
||||
// 每项列0:文本 = dsName +「创建时间 · 类型名」;data(0,角色) 存 dsId/ddCode/dsName。
|
||||
// append=true 时把新行挂到已加载的父节点下(分页)。
|
||||
void populateDatasetList(QTreeWidget* tree, const std::vector<geopro::data::DsRow>& rows, bool append);
|
||||
// tmObjectId:本批数据所属 TM 对象 id(=白化 structParentId),存入每项 kDsTmObjectIdRole;可空。
|
||||
void populateDatasetList(QTreeWidget* tree, const std::vector<geopro::data::DsRow>& rows, bool append,
|
||||
const QString& tmObjectId = QString());
|
||||
// 文件页签:每条 = 文件名 +(可读大小);UserRole 存 dsId、+2 存文件 url。空时显示占位。
|
||||
void populateFileList(QListWidget* list, const std::vector<geopro::data::DsRow>& rows, bool append);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,20 +1,47 @@
|
|||
#include "panels/DescriptionPanel.hpp"
|
||||
|
||||
#include <QColorDialog>
|
||||
#include <QComboBox>
|
||||
|
||||
#include "EmptyAwareComboBox.hpp"
|
||||
#include <QHBoxLayout>
|
||||
#include <QPushButton>
|
||||
#include <QTextCharFormat>
|
||||
#include <QTextEdit>
|
||||
#include <QToolBar>
|
||||
#include <QToolButton>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
#include "Theme.hpp"
|
||||
#include "panels/QuillDelta.hpp"
|
||||
|
||||
namespace geopro::app {
|
||||
namespace {
|
||||
|
||||
// 字号下拉选项(px)——对照原版 ql-size 的 12~32px。
|
||||
const int kFontSizesPx[] = {12, 14, 16, 18, 20, 22, 24, 26, 28, 30, 32};
|
||||
|
||||
// 字体族——对照原版 ql-font:显示名 + Qt family。
|
||||
struct FontOption { const char* label; const char* family; };
|
||||
const FontOption kFontFamilies[] = {
|
||||
{"微软雅黑", "Microsoft YaHei"}, {"宋体", "SimSun"}, {"仿宋", "FangSong"},
|
||||
{"楷体", "KaiTi"}, {"黑体", "SimHei"}, {"仿宋_GB2312", "FangSong"},
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
DescriptionPanel::DescriptionPanel(QWidget* parent) : QWidget(parent) {
|
||||
auto* lay = new QVBoxLayout(this);
|
||||
lay->setContentsMargins(geopro::app::space::kMd, geopro::app::space::kMd,
|
||||
geopro::app::space::kMd, geopro::app::space::kMd);
|
||||
lay->setSpacing(geopro::app::space::kSm);
|
||||
|
||||
auto* tb = new QToolBar(this);
|
||||
buildToolbar(tb);
|
||||
lay->addWidget(tb);
|
||||
|
||||
edit_ = new QTextEdit(this);
|
||||
edit_->setAcceptRichText(true);
|
||||
edit_->setPlaceholderText(QStringLiteral("暂无描述"));
|
||||
lay->addWidget(edit_, 1);
|
||||
|
||||
|
|
@ -25,13 +52,121 @@ DescriptionPanel::DescriptionPanel(QWidget* parent) : QWidget(parent) {
|
|||
btnLay->addWidget(saveBtn_);
|
||||
lay->addLayout(btnLay);
|
||||
|
||||
connect(saveBtn_, &QPushButton::clicked, this,
|
||||
[this]() { emit saveRequested(edit_->toPlainText()); });
|
||||
connect(saveBtn_, &QPushButton::clicked, this, [this]() { emit saveRequested(); });
|
||||
}
|
||||
|
||||
void DescriptionPanel::setText(const QString& text) { edit_->setPlainText(text); }
|
||||
void DescriptionPanel::buildToolbar(QToolBar* tb) {
|
||||
// 粗体 / 斜体 / 下划线:可勾选按钮,作用于选区当前字符格式。
|
||||
auto addToggle = [this, tb](const QString& label, auto applier) {
|
||||
auto* btn = new QToolButton(tb);
|
||||
btn->setText(label);
|
||||
btn->setCheckable(true);
|
||||
tb->addWidget(btn);
|
||||
connect(btn, &QToolButton::toggled, this, applier);
|
||||
return btn;
|
||||
};
|
||||
// 对照原版 Quill 工具栏:粗/斜 + 字色/背景色 + 对齐 + 标题 + 字号 + 字体族。
|
||||
// (原版无下划线/列表,故此处不设,以贴近原版。)
|
||||
addToggle(QStringLiteral("B"), [this](bool on) {
|
||||
QTextCharFormat f;
|
||||
f.setFontWeight(on ? QFont::Bold : QFont::Normal);
|
||||
edit_->mergeCurrentCharFormat(f);
|
||||
});
|
||||
addToggle(QStringLiteral("I"), [this](bool on) {
|
||||
QTextCharFormat f;
|
||||
f.setFontItalic(on);
|
||||
edit_->mergeCurrentCharFormat(f);
|
||||
});
|
||||
|
||||
QString DescriptionPanel::text() const { return edit_->toPlainText(); }
|
||||
// 字色:弹色板,作用于选区前景色。
|
||||
auto* colorBtn = new QToolButton(tb);
|
||||
colorBtn->setText(QStringLiteral("A"));
|
||||
colorBtn->setToolTip(QStringLiteral("字体颜色"));
|
||||
tb->addWidget(colorBtn);
|
||||
connect(colorBtn, &QToolButton::clicked, this, [this]() {
|
||||
const QColor c = QColorDialog::getColor(Qt::black, this, QStringLiteral("字体颜色"));
|
||||
if (!c.isValid()) return;
|
||||
QTextCharFormat f;
|
||||
f.setForeground(c);
|
||||
edit_->mergeCurrentCharFormat(f);
|
||||
});
|
||||
|
||||
// 背景色:弹色板,作用于选区背景色(对照原版 ql-background)。
|
||||
auto* bgBtn = new QToolButton(tb);
|
||||
bgBtn->setText(QStringLiteral("▢"));
|
||||
bgBtn->setToolTip(QStringLiteral("背景颜色"));
|
||||
tb->addWidget(bgBtn);
|
||||
connect(bgBtn, &QToolButton::clicked, this, [this]() {
|
||||
const QColor c = QColorDialog::getColor(Qt::yellow, this, QStringLiteral("背景颜色"));
|
||||
if (!c.isValid()) return;
|
||||
QTextCharFormat f;
|
||||
f.setBackground(c);
|
||||
edit_->mergeCurrentCharFormat(f);
|
||||
});
|
||||
|
||||
// 对齐:左/中/右/两端(对照原版 ql-align)——块级。
|
||||
auto addAlignBtn = [this, tb](const QString& label, Qt::Alignment align) {
|
||||
auto* btn = new QToolButton(tb);
|
||||
btn->setText(label);
|
||||
tb->addWidget(btn);
|
||||
connect(btn, &QToolButton::clicked, this, [this, align]() {
|
||||
edit_->setAlignment(align);
|
||||
});
|
||||
};
|
||||
addAlignBtn(QStringLiteral("⯇"), Qt::AlignLeft);
|
||||
addAlignBtn(QStringLiteral("≡"), Qt::AlignHCenter);
|
||||
addAlignBtn(QStringLiteral("⯈"), Qt::AlignRight);
|
||||
addAlignBtn(QStringLiteral("☰"), Qt::AlignJustify);
|
||||
|
||||
// 标题下拉(正文 / H1~H4)——块级,作用于当前段。
|
||||
auto* headerBox = new EmptyAwareComboBox(tb);
|
||||
headerBox->addItem(QStringLiteral("正文"), 0);
|
||||
for (int h = 1; h <= 4; ++h) headerBox->addItem(QStringLiteral("标题%1").arg(h), h);
|
||||
tb->addWidget(headerBox);
|
||||
connect(headerBox, &QComboBox::currentIndexChanged, this, [this, headerBox](int) {
|
||||
const int h = headerBox->currentData().toInt();
|
||||
QTextCursor cur = edit_->textCursor();
|
||||
QTextBlockFormat bf = cur.blockFormat();
|
||||
bf.setHeadingLevel(h); // 0 表示正文。
|
||||
cur.mergeBlockFormat(bf);
|
||||
edit_->setTextCursor(cur);
|
||||
});
|
||||
|
||||
// 字号下拉(px)。
|
||||
auto* sizeBox = new EmptyAwareComboBox(tb);
|
||||
for (int px : kFontSizesPx) sizeBox->addItem(QStringLiteral("%1px").arg(px), px);
|
||||
sizeBox->setCurrentIndex(2); // 默认 16px(与原版一致)。
|
||||
tb->addWidget(sizeBox);
|
||||
connect(sizeBox, &QComboBox::currentIndexChanged, this, [this, sizeBox](int) {
|
||||
const int px = sizeBox->currentData().toInt();
|
||||
QTextCharFormat f;
|
||||
f.setFontPointSize(px * 3.0 / 4.0); // px→pt。
|
||||
edit_->mergeCurrentCharFormat(f);
|
||||
});
|
||||
|
||||
// 字体族下拉(对照原版 ql-font)。
|
||||
auto* fontBox = new EmptyAwareComboBox(tb);
|
||||
for (const auto& fo : kFontFamilies)
|
||||
fontBox->addItem(QString::fromUtf8(fo.label), QString::fromUtf8(fo.family));
|
||||
tb->addWidget(fontBox);
|
||||
connect(fontBox, &QComboBox::currentIndexChanged, this, [this, fontBox](int) {
|
||||
const QString family = fontBox->currentData().toString();
|
||||
QTextCharFormat f;
|
||||
f.setFontFamilies({family});
|
||||
edit_->mergeCurrentCharFormat(f);
|
||||
});
|
||||
}
|
||||
|
||||
void DescriptionPanel::setDelta(const QJsonArray& ops) {
|
||||
if (ops.isEmpty()) return;
|
||||
deltaToDocument(ops, *edit_->document());
|
||||
}
|
||||
|
||||
void DescriptionPanel::setPlainText(const QString& text) { edit_->setPlainText(text); }
|
||||
|
||||
QJsonArray DescriptionPanel::delta() const { return documentToDelta(*edit_->document()); }
|
||||
|
||||
QString DescriptionPanel::plainText() const { return edit_->toPlainText(); }
|
||||
|
||||
void DescriptionPanel::setSaveEnabled(bool on) { saveBtn_->setEnabled(on); }
|
||||
|
||||
|
|
|
|||
|
|
@ -1,25 +1,38 @@
|
|||
#pragma once
|
||||
#include <QJsonArray>
|
||||
#include <QWidget>
|
||||
|
||||
class QTextEdit;
|
||||
class QPushButton;
|
||||
class QToolBar;
|
||||
|
||||
namespace geopro::app {
|
||||
|
||||
// 数据集描述面板:可编辑文本 + 保存按钮(I14)。
|
||||
// 原版用 Quill 富文本(Delta),Qt 无对应控件 → 退化为纯文本编辑 + 保存;
|
||||
// 保存时由调用方组装 {description, attachedParameters:{deltaContent}}(见 GridDataChartView)。
|
||||
// 数据集描述面板:富文本编辑器 + 格式工具栏 + 保存按钮(I14)。
|
||||
// 对照原版 web(contourPage.vue)的 Quill 编辑器:粗体/斜体/下划线/字色/字号 +
|
||||
// 有序/无序列表 + 标题。保存时把富文本转 Quill Delta(attachedParameters.deltaContent)
|
||||
// 与纯文本(description)一并提交(组装/请求见 GridDataChartView)。
|
||||
// Delta↔QTextDocument 互转见 QuillDelta.{hpp,cpp}(纯函数,可单测)。
|
||||
class DescriptionPanel : public QWidget {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit DescriptionPanel(QWidget* parent = nullptr);
|
||||
void setText(const QString& text);
|
||||
QString text() const;
|
||||
|
||||
// 用 Quill Delta ops 回填编辑器(无 ops 时回退 setPlainText 兜底)。
|
||||
void setDelta(const QJsonArray& ops);
|
||||
void setPlainText(const QString& text);
|
||||
|
||||
// 当前内容导出:Delta ops(与原版 deltaContent 兼容)+ 纯文本(description)。
|
||||
QJsonArray delta() const;
|
||||
QString plainText() const;
|
||||
|
||||
// 注入「保存」可用性:无 cmdRepo/dsId 时禁用保存按钮(占位)。
|
||||
void setSaveEnabled(bool on);
|
||||
signals:
|
||||
void saveRequested(const QString& text);
|
||||
void saveRequested();
|
||||
private:
|
||||
void buildToolbar(QToolBar* tb);
|
||||
|
||||
QTextEdit* edit_;
|
||||
QPushButton* saveBtn_;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
#include <QCheckBox>
|
||||
#include <QComboBox>
|
||||
|
||||
#include "EmptyAwareComboBox.hpp"
|
||||
#include <QDate>
|
||||
#include <QDateEdit>
|
||||
#include <QDateTime>
|
||||
|
|
@ -101,7 +103,7 @@ QWidget* buildWidget(const data::EditField& f) {
|
|||
if (ro) le->setEnabled(false);
|
||||
return le;
|
||||
}
|
||||
auto* cb = new QComboBox();
|
||||
auto* cb = new EmptyAwareComboBox();
|
||||
flattenOptions(f.options, cb);
|
||||
const int idx = cb->findData(val);
|
||||
if (idx >= 0) cb->setCurrentIndex(idx);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,190 @@
|
|||
#include "panels/QuillDelta.hpp"
|
||||
|
||||
#include <QColor>
|
||||
#include <QJsonObject>
|
||||
#include <QTextBlock>
|
||||
#include <QTextCharFormat>
|
||||
#include <QTextDocument>
|
||||
#include <QTextList>
|
||||
|
||||
namespace geopro::app {
|
||||
namespace {
|
||||
|
||||
// ── 序列化方向(QTextDocument → Delta)─────────────────────────────────────
|
||||
|
||||
// 颜色转 Quill 习惯的小写 #rrggbb(与原版 ql-color/ql-background 选项一致)。
|
||||
QString hexOf(const QColor& c) { return c.name(QColor::HexRgb); }
|
||||
|
||||
// 字体族:原版 ql-font 的 token ↔ Qt QFont family 名。
|
||||
// token(whitelist 值):Microsoft-YaHei / SimSun / SimHei / KaiTi / FangSong / FangSong_GB2312。
|
||||
// Delta 写出用 token;反序列化时把 token 转成 Qt 可识别的 family(连字符→空格等)。
|
||||
QString fontTokenToFamily(const QString& token) {
|
||||
if (token == QStringLiteral("Microsoft-YaHei")) return QStringLiteral("Microsoft YaHei");
|
||||
if (token == QStringLiteral("SimSun")) return QStringLiteral("SimSun");
|
||||
if (token == QStringLiteral("SimHei")) return QStringLiteral("SimHei");
|
||||
if (token == QStringLiteral("KaiTi")) return QStringLiteral("KaiTi");
|
||||
if (token == QStringLiteral("FangSong")) return QStringLiteral("FangSong");
|
||||
if (token == QStringLiteral("FangSong_GB2312")) return QStringLiteral("FangSong");
|
||||
return token; // Arial / sans-serif 等原样
|
||||
}
|
||||
|
||||
QString familyToFontToken(const QString& family) {
|
||||
if (family == QStringLiteral("Microsoft YaHei")) return QStringLiteral("Microsoft-YaHei");
|
||||
if (family == QStringLiteral("SimSun")) return QStringLiteral("SimSun");
|
||||
if (family == QStringLiteral("SimHei")) return QStringLiteral("SimHei");
|
||||
if (family == QStringLiteral("KaiTi")) return QStringLiteral("KaiTi");
|
||||
if (family == QStringLiteral("FangSong")) return QStringLiteral("FangSong");
|
||||
return family;
|
||||
}
|
||||
|
||||
// 行内样式 → attributes 对象(bold/italic/underline/color/background/size)。
|
||||
QJsonObject inlineAttrs(const QTextCharFormat& fmt) {
|
||||
QJsonObject a;
|
||||
if (fmt.fontWeight() >= QFont::Bold) a[QStringLiteral("bold")] = true;
|
||||
if (fmt.fontItalic()) a[QStringLiteral("italic")] = true;
|
||||
if (fmt.fontUnderline()) a[QStringLiteral("underline")] = true;
|
||||
if (fmt.foreground().style() != Qt::NoBrush)
|
||||
a[QStringLiteral("color")] = hexOf(fmt.foreground().color());
|
||||
if (fmt.background().style() != Qt::NoBrush)
|
||||
a[QStringLiteral("background")] = hexOf(fmt.background().color());
|
||||
const double pt = fmt.fontPointSize();
|
||||
if (pt > 0.0) // 以 px 表达(原版 ql-size 用 "NNpx";pt→px 约 *4/3 取整)。
|
||||
a[QStringLiteral("size")] = QStringLiteral("%1px").arg(qRound(pt * 4.0 / 3.0));
|
||||
const QStringList fams = fmt.fontFamilies().toStringList();
|
||||
if (!fams.isEmpty() && !fams.first().isEmpty()) // 字体族(原版 ql-font token)。
|
||||
a[QStringLiteral("font")] = familyToFontToken(fams.first());
|
||||
return a;
|
||||
}
|
||||
|
||||
// 块级样式 → attributes 对象(header/list/align),挂在换行 op 上。
|
||||
QJsonObject blockAttrs(const QTextBlock& block) {
|
||||
QJsonObject a;
|
||||
const int headingLevel = block.blockFormat().headingLevel();
|
||||
if (headingLevel >= 1 && headingLevel <= 4) a[QStringLiteral("header")] = headingLevel;
|
||||
if (QTextList* lst = block.textList()) {
|
||||
const QTextListFormat::Style s = lst->format().style();
|
||||
a[QStringLiteral("list")] = (s == QTextListFormat::ListDecimal)
|
||||
? QStringLiteral("ordered")
|
||||
: QStringLiteral("bullet");
|
||||
}
|
||||
switch (block.blockFormat().alignment() & Qt::AlignHorizontal_Mask) {
|
||||
case Qt::AlignHCenter: a[QStringLiteral("align")] = QStringLiteral("center"); break;
|
||||
case Qt::AlignRight: a[QStringLiteral("align")] = QStringLiteral("right"); break;
|
||||
case Qt::AlignJustify: a[QStringLiteral("align")] = QStringLiteral("justify"); break;
|
||||
default: break; // 左对齐为默认,不写 attribute。
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
// 追加一个文本 op(带可选 attributes)。
|
||||
void pushInsert(QJsonArray& ops, const QString& text, const QJsonObject& attrs) {
|
||||
if (text.isEmpty()) return;
|
||||
QJsonObject op{{QStringLiteral("insert"), text}};
|
||||
if (!attrs.isEmpty()) op[QStringLiteral("attributes")] = attrs;
|
||||
ops.append(op);
|
||||
}
|
||||
|
||||
// 追加一个「换行」op(带可选块级 attributes)。Quill 中块级样式作用于其前整行。
|
||||
void pushNewline(QJsonArray& ops, const QJsonObject& attrs) {
|
||||
pushInsert(ops, QStringLiteral("\n"), attrs);
|
||||
}
|
||||
|
||||
// 序列化单个文本块的所有 fragment(按行内样式分段)。
|
||||
void serializeBlock(const QTextBlock& block, QJsonArray& ops) {
|
||||
for (auto it = block.begin(); it != block.end(); ++it) {
|
||||
const QTextFragment frag = it.fragment();
|
||||
if (frag.isValid()) pushInsert(ops, frag.text(), inlineAttrs(frag.charFormat()));
|
||||
}
|
||||
}
|
||||
|
||||
// ── 反序列化方向(Delta → QTextDocument)──────────────────────────────────
|
||||
|
||||
// 把 inline attributes 应用到字符格式。
|
||||
void applyInlineAttrs(const QJsonObject& attrs, QTextCharFormat& fmt) {
|
||||
if (attrs.value(QStringLiteral("bold")).toBool()) fmt.setFontWeight(QFont::Bold);
|
||||
if (attrs.value(QStringLiteral("italic")).toBool()) fmt.setFontItalic(true);
|
||||
if (attrs.value(QStringLiteral("underline")).toBool()) fmt.setFontUnderline(true);
|
||||
const QString color = attrs.value(QStringLiteral("color")).toString();
|
||||
if (QColor(color).isValid()) fmt.setForeground(QColor(color));
|
||||
const QString bg = attrs.value(QStringLiteral("background")).toString();
|
||||
if (QColor(bg).isValid()) fmt.setBackground(QColor(bg));
|
||||
QString size = attrs.value(QStringLiteral("size")).toString();
|
||||
if (size.endsWith(QStringLiteral("px"))) {
|
||||
const double px = size.chopped(2).toDouble();
|
||||
if (px > 0.0) fmt.setFontPointSize(px * 3.0 / 4.0); // px→pt。
|
||||
}
|
||||
const QString font = attrs.value(QStringLiteral("font")).toString();
|
||||
if (!font.isEmpty()) fmt.setFontFamilies({fontTokenToFamily(font)});
|
||||
}
|
||||
|
||||
// 把 block attributes 应用到块格式 / 列表(作用于换行前的当前块)。
|
||||
void applyBlockAttrs(const QJsonObject& attrs, QTextCursor& cur) {
|
||||
QTextBlockFormat bf = cur.blockFormat();
|
||||
const int header = attrs.value(QStringLiteral("header")).toInt();
|
||||
if (header >= 1 && header <= 4) bf.setHeadingLevel(header);
|
||||
const QString align = attrs.value(QStringLiteral("align")).toString();
|
||||
if (align == QStringLiteral("center")) bf.setAlignment(Qt::AlignHCenter);
|
||||
else if (align == QStringLiteral("right")) bf.setAlignment(Qt::AlignRight);
|
||||
else if (align == QStringLiteral("justify")) bf.setAlignment(Qt::AlignJustify);
|
||||
cur.setBlockFormat(bf);
|
||||
const QString list = attrs.value(QStringLiteral("list")).toString();
|
||||
if (list == QStringLiteral("ordered")) cur.createList(QTextListFormat::ListDecimal);
|
||||
else if (list == QStringLiteral("bullet")) cur.createList(QTextListFormat::ListDisc);
|
||||
}
|
||||
|
||||
// 写入一段不含换行的文本(带行内样式)。
|
||||
void insertSegment(QTextCursor& cur, const QString& text, const QJsonObject& attrs) {
|
||||
QTextCharFormat fmt;
|
||||
applyInlineAttrs(attrs, fmt);
|
||||
cur.insertText(text, fmt);
|
||||
}
|
||||
|
||||
// 处理单个 insert op:按换行拆段。每个换行:先对当前块应用块级样式(header/list/align),
|
||||
// 再开新块承接后续内容。Delta 末尾必有一个收尾换行,会多造一个尾部空块,由
|
||||
// deltaToDocument 末尾统一裁掉(与 QTextDocument 起始即含一个空块的语义对齐)。
|
||||
void applyOp(const QString& insert, const QJsonObject& attrs, QTextCursor& cur) {
|
||||
const QStringList lines = insert.split(QLatin1Char('\n'));
|
||||
for (int i = 0; i < lines.size(); ++i) {
|
||||
insertSegment(cur, lines.at(i), attrs);
|
||||
if (i + 1 < lines.size()) { // 该位置原本是一个换行符。
|
||||
applyBlockAttrs(attrs, cur); // 块级样式作用于换行之前的整行。
|
||||
cur.insertBlock();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 裁掉文档末尾因 Delta 收尾换行而多出的空块(仅当其确为空且非唯一块)。
|
||||
void trimTrailingEmptyBlock(QTextDocument& doc) {
|
||||
if (doc.blockCount() <= 1) return;
|
||||
const QTextBlock last = doc.lastBlock();
|
||||
if (!last.text().isEmpty()) return;
|
||||
QTextCursor cur(&doc);
|
||||
cur.movePosition(QTextCursor::End);
|
||||
cur.deletePreviousChar(); // 删除上一个块结尾的换行,合并末尾空块。
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
QJsonArray documentToDelta(const QTextDocument& doc) {
|
||||
QJsonArray ops;
|
||||
for (QTextBlock block = doc.begin(); block.isValid(); block = block.next()) {
|
||||
serializeBlock(block, ops);
|
||||
// 每个块以换行 op 收尾,携带该块的块级样式(最后一个块也写,对应 Quill 末尾换行)。
|
||||
pushNewline(ops, blockAttrs(block));
|
||||
}
|
||||
return ops;
|
||||
}
|
||||
|
||||
void deltaToDocument(const QJsonArray& ops, QTextDocument& doc) {
|
||||
doc.clear();
|
||||
QTextCursor cur(&doc);
|
||||
for (const QJsonValue& v : ops) {
|
||||
const QJsonObject op = v.toObject();
|
||||
const QJsonValue insert = op.value(QStringLiteral("insert"));
|
||||
if (!insert.isString()) continue; // 仅支持文本 insert(图片/嵌入等降级丢弃)。
|
||||
applyOp(insert.toString(), op.value(QStringLiteral("attributes")).toObject(), cur);
|
||||
}
|
||||
trimTrailingEmptyBlock(doc);
|
||||
}
|
||||
|
||||
} // namespace geopro::app
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
#pragma once
|
||||
#include <QJsonArray>
|
||||
|
||||
class QTextDocument;
|
||||
|
||||
namespace geopro::app {
|
||||
|
||||
// Quill Delta ↔ QTextDocument 互转(纯函数,仅依赖 Qt Core/Gui,无 Widgets/MOC)。
|
||||
//
|
||||
// 背景:原版 web(contourPage.vue)描述用 Quill 富文本,保存
|
||||
// attachedParameters.deltaContent = quill.getContents().ops(Quill Delta ops 数组),
|
||||
// description = quill.getText()(纯文本)。读取时 quill.setContents(deltaContent)。
|
||||
// 客户端无 Quill,这里用 QTextDocument 承载富文本,并在两种表示间转换以与原版互通。
|
||||
//
|
||||
// 边界(无法做到字节级 1:1,目标是「常见格式往返可用」):
|
||||
// - 支持的 inline attributes:bold / italic / underline / color / background / size("NNpx")。
|
||||
// - 支持的 block attributes(挂在换行 op 上):header(1-4)/ list("ordered"|"bullet")/ align。
|
||||
// - 不支持的 attributes 容错降级:保留 insert 文本,丢弃无法表达的样式,不崩。
|
||||
// - 拆出独立 TU 便于 gtest(往返断言常见格式)。
|
||||
//
|
||||
// Quill Delta 结构:ops 为数组,每个 op = { "insert": "文本", "attributes": {...} }。
|
||||
// 行内样式挂在文本 op 上;块级样式(标题/列表/对齐)挂在「单个换行」op 的 attributes 上,
|
||||
// 作用于该换行之前的整行。文档以隐含的「最后一个换行」结尾。
|
||||
|
||||
// 把 QTextDocument 序列化为 Quill Delta ops 数组(与原版 quill.getContents().ops 兼容)。
|
||||
QJsonArray documentToDelta(const QTextDocument& doc);
|
||||
|
||||
// 把 Quill Delta ops 数组反序列化进 QTextDocument(与原版 quill.setContents(ops) 对应)。
|
||||
// 先清空 doc 再写入;无法识别的 attributes 跳过。
|
||||
void deltaToDocument(const QJsonArray& ops, QTextDocument& doc);
|
||||
|
||||
} // namespace geopro::app
|
||||
|
|
@ -1,8 +1,12 @@
|
|||
#include "panels/chart/AutoAnnotationDialog.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <utility>
|
||||
|
||||
#include <QButtonGroup>
|
||||
#include <QComboBox>
|
||||
#include <QFormLayout>
|
||||
#include <QFrame>
|
||||
#include <QHBoxLayout>
|
||||
#include <QHeaderView>
|
||||
|
|
@ -12,12 +16,19 @@
|
|||
#include <QMessageBox>
|
||||
#include <QPointer>
|
||||
#include <QPushButton>
|
||||
#include <QRadioButton>
|
||||
#include <QSpinBox>
|
||||
#include <QTableWidget>
|
||||
#include <QToolButton>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
#include <qwt_plot.h>
|
||||
|
||||
#include "FormKit.hpp"
|
||||
#include "Theme.hpp" // scaledPx
|
||||
#include "dto/DatasetChartDto.hpp" // parseDatasetAnomalies(JSON→Anomaly)
|
||||
#include "panels/chart/ColorMapService.hpp"
|
||||
#include "panels/chart/ContourPlotItem.hpp"
|
||||
#include "repo/IDatasetCommandRepository.hpp"
|
||||
|
||||
namespace geopro::app {
|
||||
|
|
@ -26,54 +37,93 @@ namespace {
|
|||
constexpr int kDefaultMinPoints = 4; // 原版 minPointCount 默认 4
|
||||
// 面异常类型固定 remarkSourceType="3"(原版自动标注仅支持面/polygon)。
|
||||
const QString kPolygonType = QStringLiteral("3");
|
||||
|
||||
// 从网格标量算 max/min/mean/median(过滤 NaN)。空 → 全 '-'。
|
||||
struct Stats { bool valid = false; double mx = 0, mn = 0, mean = 0, median = 0; };
|
||||
Stats computeStats(const std::vector<double>& raw) {
|
||||
std::vector<double> v;
|
||||
v.reserve(raw.size());
|
||||
for (double d : raw)
|
||||
if (!std::isnan(d)) v.push_back(d);
|
||||
if (v.empty()) return {};
|
||||
std::sort(v.begin(), v.end());
|
||||
Stats s;
|
||||
s.valid = true;
|
||||
s.mn = v.front();
|
||||
s.mx = v.back();
|
||||
double sum = 0;
|
||||
for (double d : v) sum += d;
|
||||
s.mean = sum / static_cast<double>(v.size());
|
||||
const size_t m = v.size() / 2;
|
||||
s.median = (v.size() % 2 == 0) ? (v[m - 1] + v[m]) / 2.0 : v[m];
|
||||
return s;
|
||||
}
|
||||
|
||||
QString fmt2(bool valid, double x) {
|
||||
return valid ? QString::number(x, 'f', 2) : QStringLiteral("-");
|
||||
}
|
||||
} // namespace
|
||||
|
||||
AutoAnnotationDialog::AutoAnnotationDialog(geopro::data::IDatasetCommandRepository* repo,
|
||||
QString dsObjectId, QString projectId, QWidget* parent)
|
||||
QString dsObjectId, QString projectId,
|
||||
const geopro::core::Grid& grid,
|
||||
const geopro::core::ColorScale& scale, QWidget* parent)
|
||||
: QDialog(parent),
|
||||
repo_(repo),
|
||||
dsObjectId_(std::move(dsObjectId)),
|
||||
projectId_(std::move(projectId)) {
|
||||
projectId_(std::move(projectId)),
|
||||
grid_(grid),
|
||||
scale_(scale) {
|
||||
setWindowTitle(QStringLiteral("自动标注"));
|
||||
setModal(true);
|
||||
resize(820, 520);
|
||||
resize(geopro::app::scaledPx(1400), geopro::app::scaledPx(600));
|
||||
|
||||
auto* root = formkit::dialogRoot(this);
|
||||
auto* split = new QHBoxLayout();
|
||||
|
||||
// ── 左:规则列表 ────────────────────────────────────────────────
|
||||
// ── 左:规则卡片列表(35%,对照原版左栏)────────────────────────────────
|
||||
auto* leftCol = new QVBoxLayout();
|
||||
leftCol->addWidget(new QLabel(QStringLiteral("标注规则:"), this));
|
||||
leftCol->addWidget(new QLabel(QStringLiteral("异常判定规则"), this));
|
||||
auto* ruleContainer = new QWidget(this);
|
||||
ruleHost_ = new QVBoxLayout(ruleContainer);
|
||||
ruleHost_->setContentsMargins(0, 0, 0, 0);
|
||||
leftCol->addWidget(ruleContainer);
|
||||
auto* addBtn = new QPushButton(QStringLiteral("加规则"), this);
|
||||
auto* addBtn = new QPushButton(QStringLiteral("添加规则"), this);
|
||||
connect(addBtn, &QPushButton::clicked, this, [this]() { addRule(); });
|
||||
leftCol->addWidget(addBtn);
|
||||
leftCol->addStretch();
|
||||
split->addLayout(leftCol, 1);
|
||||
auto* leftWrap = new QWidget(this);
|
||||
leftWrap->setLayout(leftCol);
|
||||
split->addWidget(leftWrap, 35);
|
||||
|
||||
// ── 右:预览表 ──────────────────────────────────────────────────
|
||||
// ── 右:上(统计条 + 预览图) + 下预览表 ──────────────────────────────────
|
||||
// 对照原版右上 <ContourPreview>:等值面预览图为主、数据统计在上。
|
||||
auto* rightCol = new QVBoxLayout();
|
||||
rightCol->addWidget(new QLabel(QStringLiteral("预览:"), this));
|
||||
previewTable_ = new QTableWidget(0, 4, this);
|
||||
previewTable_->setHorizontalHeaderLabels(
|
||||
{QStringLiteral("异常名称"), QStringLiteral("异常类型"), QStringLiteral("阈值范围"),
|
||||
QStringLiteral("阈值模式")});
|
||||
buildStatsBar(rightCol);
|
||||
buildPreviewPlot(rightCol);
|
||||
|
||||
detectedLabel_ = new QLabel(QStringLiteral("自动标注结果"), this);
|
||||
rightCol->addWidget(detectedLabel_);
|
||||
previewTable_ = new QTableWidget(0, 6, this);
|
||||
previewTable_->setHorizontalHeaderLabels({QStringLiteral("序号"), QStringLiteral("异常名称"),
|
||||
QStringLiteral("异常类型"), QStringLiteral("阈值范围"),
|
||||
QStringLiteral("阈值模式"), QStringLiteral("操作")});
|
||||
previewTable_->horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch);
|
||||
previewTable_->setEditTriggers(QAbstractItemView::NoEditTriggers);
|
||||
rightCol->addWidget(previewTable_, 1);
|
||||
split->addLayout(rightCol, 1);
|
||||
auto* rightWrap = new QWidget(this);
|
||||
rightWrap->setLayout(rightCol);
|
||||
split->addWidget(rightWrap, 65);
|
||||
|
||||
root->addLayout(split, 1);
|
||||
|
||||
// ── 底部按钮 ────────────────────────────────────────────────────
|
||||
// ── 底部按钮:取消 / 执行自动标注 / 确认保存 ─────────────────────────────
|
||||
auto* btnLay = new QHBoxLayout();
|
||||
btnLay->addStretch();
|
||||
auto* cancelBtn = new QPushButton(QStringLiteral("取消"), this);
|
||||
auto* execBtn = new QPushButton(QStringLiteral("执行自动标注"), this);
|
||||
saveBtn_ = new QPushButton(QStringLiteral("确定保存"), this);
|
||||
saveBtn_ = new QPushButton(QStringLiteral("确认保存"), this);
|
||||
saveBtn_->setDefault(true); // 区域唯一主操作(规范 §6.7 primary);执行/取消为次按钮
|
||||
saveBtn_->setEnabled(false); // 必须先执行得到预览才能保存
|
||||
btnLay->addWidget(cancelBtn);
|
||||
btnLay->addWidget(execBtn);
|
||||
|
|
@ -88,6 +138,76 @@ AutoAnnotationDialog::AutoAnnotationDialog(geopro::data::IDatasetCommandReposito
|
|||
loadExceptionTypes(); // 拉面异常类型
|
||||
}
|
||||
|
||||
AutoAnnotationDialog::~AutoAnnotationDialog() {
|
||||
// previewItem_ 由 QwtPlot autoDelete=true 处理,但析构顺序不确定:先 detach 再交还。
|
||||
if (previewItem_) {
|
||||
previewItem_->detach();
|
||||
delete previewItem_;
|
||||
previewItem_ = nullptr;
|
||||
}
|
||||
// colorSvc_ 为 unique_ptr,自动释放。
|
||||
}
|
||||
|
||||
void AutoAnnotationDialog::buildStatsBar(QVBoxLayout* into) {
|
||||
// 数据统计(max/min/mean/median,从网格标量算)。
|
||||
const Stats s = computeStats(grid_.values());
|
||||
auto* bar = new QFrame(this);
|
||||
bar->setFrameShape(QFrame::StyledPanel);
|
||||
auto* lay = new QHBoxLayout(bar);
|
||||
auto addStat = [&](const QString& name, double v) {
|
||||
lay->addWidget(new QLabel(QStringLiteral("%1:%2").arg(name, fmt2(s.valid, v)), bar));
|
||||
};
|
||||
addStat(QStringLiteral("最大值"), s.mx);
|
||||
addStat(QStringLiteral("最小值"), s.mn);
|
||||
addStat(QStringLiteral("均值"), s.mean);
|
||||
addStat(QStringLiteral("中位数"), s.median);
|
||||
lay->addStretch();
|
||||
into->addWidget(bar);
|
||||
}
|
||||
|
||||
void AutoAnnotationDialog::buildPreviewPlot(QVBoxLayout* into) {
|
||||
// 复刻原版 <ContourPreview>:用 GridDataChartView 同款 ContourPlotItem 渲染当前网格等值面,
|
||||
// 预览图为主区域;执行/删除时把预演异常实时叠加(refreshPreviewAnomalies)。
|
||||
previewPlot_ = new QwtPlot(this);
|
||||
previewPlot_->setObjectName(QStringLiteral("autoAnnotPreview"));
|
||||
previewPlot_->enableAxis(QwtPlot::xBottom, true);
|
||||
previewPlot_->enableAxis(QwtPlot::xTop, false);
|
||||
previewPlot_->enableAxis(QwtPlot::yLeft, true);
|
||||
previewPlot_->setMinimumHeight(geopro::app::scaledPx(220));
|
||||
|
||||
// 网格 < 2×2 视为无可渲染数据:仅占位,不建等值面项。
|
||||
if (grid_.nx() < 2 || grid_.ny() < 2) {
|
||||
into->addWidget(previewPlot_, 1);
|
||||
return;
|
||||
}
|
||||
|
||||
colorSvc_ = std::make_unique<ColorMapService>(scale_);
|
||||
previewItem_ = new ContourPlotItem();
|
||||
// 轻量预览:渲染等值面 + 等值线,关标注(小图标注过密);初始无异常。
|
||||
previewItem_->setData(grid_, colorSvc_.get(), {}, /*showLines=*/true, /*showLabels=*/false);
|
||||
previewItem_->setShowAnomalies(true);
|
||||
previewItem_->attach(previewPlot_);
|
||||
|
||||
// 轴范围 = 数据范围(y 深度向下:上沿 ymax、下沿 ymin,与 GridDataChartView 一致)。
|
||||
const QRectF bbox = previewItem_->boundingRect();
|
||||
if (!bbox.isNull()) {
|
||||
previewPlot_->setAxisScale(QwtPlot::xBottom, bbox.left(), bbox.right());
|
||||
previewPlot_->setAxisScale(QwtPlot::yLeft, bbox.top(), bbox.bottom());
|
||||
}
|
||||
previewPlot_->replot();
|
||||
into->addWidget(previewPlot_, 1);
|
||||
}
|
||||
|
||||
void AutoAnnotationDialog::refreshPreviewAnomalies() {
|
||||
// 把当前 previewExceptions_(execute 返回 / 删除后剩余)映射成 Anomaly 叠加到预览图。
|
||||
// 复用 dto::parseDatasetAnomalies(与正式异常同一 JSON 形态:location.coordinate + legend)。
|
||||
if (!previewItem_ || !colorSvc_) return;
|
||||
const auto anoms = geopro::data::dto::parseDatasetAnomalies(previewExceptions_);
|
||||
previewItem_->setData(grid_, colorSvc_.get(), anoms, /*showLines=*/true, /*showLabels=*/false);
|
||||
previewItem_->setShowAnomalies(true);
|
||||
if (previewPlot_) previewPlot_->replot();
|
||||
}
|
||||
|
||||
void AutoAnnotationDialog::loadExceptionTypes() {
|
||||
if (!repo_) return;
|
||||
QPointer<AutoAnnotationDialog> self(this);
|
||||
|
|
@ -104,7 +224,7 @@ void AutoAnnotationDialog::loadExceptionTypes() {
|
|||
self->exceptionTypeOptions_.append(
|
||||
QJsonObject{{QStringLiteral("id"), id}, {QStringLiteral("name"), name}});
|
||||
}
|
||||
// 回填已存在的规则行下拉。
|
||||
// 回填已存在规则卡片下拉。
|
||||
for (auto& r : self->rules_) {
|
||||
r.type->clear();
|
||||
for (const QJsonValue& ov : self->exceptionTypeOptions_) {
|
||||
|
|
@ -119,39 +239,114 @@ void AutoAnnotationDialog::loadExceptionTypes() {
|
|||
void AutoAnnotationDialog::addRule() {
|
||||
auto* card = new QFrame(this);
|
||||
card->setFrameShape(QFrame::StyledPanel);
|
||||
auto* lay = new QHBoxLayout(card);
|
||||
lay->setContentsMargins(4, 4, 4, 4);
|
||||
auto* cardLay = new QVBoxLayout(card);
|
||||
cardLay->setContentsMargins(6, 6, 6, 6);
|
||||
|
||||
RuleRow row;
|
||||
row.mode = new QComboBox(card);
|
||||
row.mode->addItem(QStringLiteral("数值"), 1);
|
||||
row.mode->addItem(QStringLiteral("百分位"), 2);
|
||||
row.min = new QLineEdit(card);
|
||||
row.min->setPlaceholderText(QStringLiteral("min"));
|
||||
row.min->setFixedWidth(scaledPx(60));
|
||||
row.max = new QLineEdit(card);
|
||||
row.max->setPlaceholderText(QStringLiteral("max"));
|
||||
row.max->setFixedWidth(scaledPx(60));
|
||||
row.minPoints = new QSpinBox(card);
|
||||
row.minPoints->setRange(1, 100000);
|
||||
row.minPoints->setValue(kDefaultMinPoints);
|
||||
row.type = new QComboBox(card);
|
||||
RuleCard rc;
|
||||
rc.frame = card;
|
||||
|
||||
// 卡片头:折叠 + 「规则N」 + 删除。
|
||||
auto* header = new QHBoxLayout();
|
||||
auto* collapseBtn = new QToolButton(card);
|
||||
collapseBtn->setText(QStringLiteral("▼"));
|
||||
collapseBtn->setCheckable(true);
|
||||
rc.title = new QLabel(card);
|
||||
auto* delBtn = new QToolButton(card);
|
||||
delBtn->setText(QStringLiteral("删除"));
|
||||
header->addWidget(collapseBtn);
|
||||
header->addWidget(rc.title, 1);
|
||||
header->addWidget(delBtn);
|
||||
cardLay->addLayout(header);
|
||||
|
||||
// 卡片主体(可折叠)。
|
||||
rc.body = new QWidget(card);
|
||||
auto* form = formkit::makeEditForm();
|
||||
|
||||
// 阈值模式:radio-button 组(数值/百分位)。
|
||||
auto* modeRow = new QWidget(rc.body);
|
||||
auto* modeLay = new QHBoxLayout(modeRow);
|
||||
modeLay->setContentsMargins(0, 0, 0, 0);
|
||||
auto* rbNum = new QRadioButton(QStringLiteral("数值"), modeRow);
|
||||
auto* rbPct = new QRadioButton(QStringLiteral("百分位"), modeRow);
|
||||
rbNum->setChecked(true);
|
||||
rc.modeGroup = new QButtonGroup(rc.body);
|
||||
rc.modeGroup->addButton(rbNum, 1);
|
||||
rc.modeGroup->addButton(rbPct, 2);
|
||||
modeLay->addWidget(rbNum);
|
||||
modeLay->addWidget(rbPct);
|
||||
modeLay->addStretch();
|
||||
form->addRow(formkit::editLabel(QStringLiteral("阈值模式")), modeRow);
|
||||
|
||||
// 阈值范围:min - max。
|
||||
auto* rangeRow = new QWidget(rc.body);
|
||||
auto* rangeLay = new QHBoxLayout(rangeRow);
|
||||
rangeLay->setContentsMargins(0, 0, 0, 0);
|
||||
rc.min = new QLineEdit(rangeRow);
|
||||
rc.min->setPlaceholderText(QStringLiteral("最小"));
|
||||
rc.max = new QLineEdit(rangeRow);
|
||||
rc.max->setPlaceholderText(QStringLiteral("最大"));
|
||||
rangeLay->addWidget(rc.min);
|
||||
rangeLay->addWidget(new QLabel(QStringLiteral("-"), rangeRow));
|
||||
rangeLay->addWidget(rc.max);
|
||||
form->addRow(formkit::editLabel(QStringLiteral("阈值范围")), rangeRow);
|
||||
|
||||
// 切模式清空 min/max(对照原版 handleThresholdModeChange)。
|
||||
auto clearRange = [min = rc.min, max = rc.max]() { min->clear(); max->clear(); };
|
||||
connect(rbNum, &QRadioButton::toggled, rc.body, [clearRange](bool on) { if (on) clearRange(); });
|
||||
connect(rbPct, &QRadioButton::toggled, rc.body, [clearRange](bool on) { if (on) clearRange(); });
|
||||
|
||||
rc.minPoints = new QSpinBox(rc.body);
|
||||
rc.minPoints->setRange(1, 100000);
|
||||
rc.minPoints->setValue(kDefaultMinPoints);
|
||||
form->addRow(formkit::editLabel(QStringLiteral("最小点数")), rc.minPoints);
|
||||
|
||||
// 空态感知下拉:异常类型异步加载(loadExceptionTypes),未选显占位、无数据弹「暂无数据」。
|
||||
rc.type = formkit::comboBox(QStringLiteral("请选择异常类型"), rc.body);
|
||||
for (const QJsonValue& ov : exceptionTypeOptions_) {
|
||||
const QJsonObject o = ov.toObject();
|
||||
row.type->addItem(o.value(QStringLiteral("name")).toString(),
|
||||
o.value(QStringLiteral("id")).toString());
|
||||
rc.type->addItem(o.value(QStringLiteral("name")).toString(),
|
||||
o.value(QStringLiteral("id")).toString());
|
||||
}
|
||||
form->addRow(formkit::editLabel(QStringLiteral("异常类型")), rc.type);
|
||||
|
||||
lay->addWidget(new QLabel(QStringLiteral("模式"), card));
|
||||
lay->addWidget(row.mode);
|
||||
lay->addWidget(row.min);
|
||||
lay->addWidget(row.max);
|
||||
lay->addWidget(new QLabel(QStringLiteral("最小点数"), card));
|
||||
lay->addWidget(row.minPoints);
|
||||
lay->addWidget(row.type, 1);
|
||||
rc.body->setLayout(form);
|
||||
cardLay->addWidget(rc.body);
|
||||
|
||||
rules_.push_back(row);
|
||||
// 折叠/展开。
|
||||
connect(collapseBtn, &QToolButton::toggled, rc.body, [rc, collapseBtn](bool collapsed) {
|
||||
rc.body->setVisible(!collapsed);
|
||||
collapseBtn->setText(collapsed ? QStringLiteral("▶") : QStringLiteral("▼"));
|
||||
});
|
||||
// 删除(至少保留一条)。
|
||||
connect(delBtn, &QToolButton::clicked, this, [this, card]() { removeRule(card); });
|
||||
|
||||
rules_.push_back(rc);
|
||||
ruleHost_->addWidget(card);
|
||||
renumberRules();
|
||||
}
|
||||
|
||||
void AutoAnnotationDialog::removeRule(QWidget* frame) {
|
||||
if (rules_.size() <= 1) { // 对照原版 atLeastOneRule。
|
||||
QMessageBox::warning(this, windowTitle(), QStringLiteral("至少保留一条规则"));
|
||||
return;
|
||||
}
|
||||
auto it = std::find_if(rules_.begin(), rules_.end(),
|
||||
[frame](const RuleCard& c) { return c.frame == frame; });
|
||||
if (it == rules_.end()) return;
|
||||
ruleHost_->removeWidget(frame);
|
||||
frame->deleteLater();
|
||||
rules_.erase(it);
|
||||
renumberRules();
|
||||
}
|
||||
|
||||
void AutoAnnotationDialog::renumberRules() {
|
||||
for (int i = 0; i < static_cast<int>(rules_.size()); ++i)
|
||||
rules_[i].title->setText(QStringLiteral("规则 %1").arg(i + 1));
|
||||
}
|
||||
|
||||
int AutoAnnotationDialog::currentMode(const RuleCard& c) const {
|
||||
const int id = c.modeGroup->checkedId();
|
||||
return id > 0 ? id : 1; // 默认数值
|
||||
}
|
||||
|
||||
QJsonArray AutoAnnotationDialog::buildRuleList() const {
|
||||
|
|
@ -159,7 +354,7 @@ QJsonArray AutoAnnotationDialog::buildRuleList() const {
|
|||
for (const auto& r : rules_) {
|
||||
QJsonObject rule{
|
||||
{QStringLiteral("exceptionTypeId"), r.type->currentData().toString()},
|
||||
{QStringLiteral("thresholdMode"), r.mode->currentData().toInt()},
|
||||
{QStringLiteral("thresholdMode"), currentMode(r)},
|
||||
{QStringLiteral("minPointCount"), r.minPoints->value()},
|
||||
};
|
||||
// min/max:空 → null(对照原版 Number(...) 或 null)。
|
||||
|
|
@ -176,11 +371,18 @@ QJsonArray AutoAnnotationDialog::buildRuleList() const {
|
|||
|
||||
void AutoAnnotationDialog::onExecute() {
|
||||
if (!repo_) return;
|
||||
const QJsonArray rules = buildRuleList();
|
||||
if (rules.isEmpty()) {
|
||||
QMessageBox::warning(this, windowTitle(), QStringLiteral("请至少添加一条规则"));
|
||||
return;
|
||||
// 校验:每条规则 min/max 至少填一个、异常类型必选(对照原版 handleExecute)。
|
||||
for (const auto& r : rules_) {
|
||||
if (r.min->text().trimmed().isEmpty() && r.max->text().trimmed().isEmpty()) {
|
||||
QMessageBox::warning(this, windowTitle(), QStringLiteral("阈值范围至少填写一项"));
|
||||
return;
|
||||
}
|
||||
if (r.type->currentData().toString().isEmpty()) {
|
||||
QMessageBox::warning(this, windowTitle(), QStringLiteral("请选择异常类型"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
const QJsonArray rules = buildRuleList();
|
||||
QJsonObject body{
|
||||
{QStringLiteral("dsObjectId"), dsObjectId_},
|
||||
{QStringLiteral("projectId"), projectId_},
|
||||
|
|
@ -191,37 +393,83 @@ void AutoAnnotationDialog::onExecute() {
|
|||
if (!self) return;
|
||||
if (!ok) {
|
||||
QMessageBox::warning(self, self->windowTitle(),
|
||||
msg.isEmpty() ? QStringLiteral("执行失败") : msg);
|
||||
msg.isEmpty() ? QStringLiteral("自动标注执行失败") : msg);
|
||||
return;
|
||||
}
|
||||
// 预览异常:兼容 data 直接为数组(wireObject 包成 value) 或 data.list。
|
||||
QJsonArray list = data.value(QStringLiteral("value")).toArray();
|
||||
if (list.isEmpty()) list = data.value(QStringLiteral("list")).toArray();
|
||||
self->previewExceptions_ = list;
|
||||
self->detectedLabel_->setText(
|
||||
QStringLiteral("自动标注结果(共识别到 %1 个异常)").arg(list.size()));
|
||||
self->previewTable_->setRowCount(list.size());
|
||||
for (int i = 0; i < list.size(); ++i) {
|
||||
const QJsonObject o = list[i].toObject();
|
||||
self->previewTable_->setItem(i, 0, new QTableWidgetItem(QString::number(i + 1)));
|
||||
self->previewTable_->setItem(
|
||||
i, 0, new QTableWidgetItem(o.value(QStringLiteral("exceptionName")).toString()));
|
||||
i, 1, new QTableWidgetItem(o.value(QStringLiteral("exceptionName")).toString()));
|
||||
self->previewTable_->setItem(
|
||||
i, 1,
|
||||
i, 2,
|
||||
new QTableWidgetItem(o.value(QStringLiteral("exceptionTypeName")).toString()));
|
||||
self->previewTable_->setItem(
|
||||
i, 2, new QTableWidgetItem(o.value(QStringLiteral("remark")).toString()));
|
||||
i, 3, new QTableWidgetItem(o.value(QStringLiteral("remark")).toString()));
|
||||
self->previewTable_->setItem(
|
||||
i, 3,
|
||||
i, 4,
|
||||
new QTableWidgetItem(o.value(QStringLiteral("thresholdModeName")).toString()));
|
||||
// 操作列:逐条删除(对照原版预览表 删除)。
|
||||
auto* delBtn = new QPushButton(QStringLiteral("删除"), self->previewTable_);
|
||||
QPointer<AutoAnnotationDialog> weak(self);
|
||||
QObject::connect(delBtn, &QPushButton::clicked, self, [weak, i]() {
|
||||
if (weak) weak->deletePreviewRow(i);
|
||||
});
|
||||
self->previewTable_->setCellWidget(i, 5, delBtn);
|
||||
}
|
||||
self->saveBtn_->setEnabled(!list.isEmpty());
|
||||
self->refreshPreviewAnomalies(); // 实时叠加预演异常到预览图
|
||||
if (list.isEmpty())
|
||||
QMessageBox::information(self, self->windowTitle(),
|
||||
QStringLiteral("未生成异常(无满足规则的区域)"));
|
||||
QMessageBox::information(self, self->windowTitle(), QStringLiteral("暂未识别到异常"));
|
||||
});
|
||||
}
|
||||
|
||||
void AutoAnnotationDialog::deletePreviewRow(int row) {
|
||||
if (row < 0 || row >= previewExceptions_.size()) return;
|
||||
const QString name = previewExceptions_[row].toObject().value(QStringLiteral("exceptionName")).toString();
|
||||
if (QMessageBox::question(this, QStringLiteral("确认删除"),
|
||||
QStringLiteral("%1,确认删除?").arg(name)) != QMessageBox::Yes)
|
||||
return;
|
||||
previewExceptions_.removeAt(row);
|
||||
// 重建预览表(重排序号 + 重绑删除)。复用 onExecute 的填表分支会重发请求,故就地重建。
|
||||
previewTable_->setRowCount(previewExceptions_.size());
|
||||
for (int i = 0; i < previewExceptions_.size(); ++i) {
|
||||
const QJsonObject o = previewExceptions_[i].toObject();
|
||||
previewTable_->setItem(i, 0, new QTableWidgetItem(QString::number(i + 1)));
|
||||
previewTable_->setItem(
|
||||
i, 1, new QTableWidgetItem(o.value(QStringLiteral("exceptionName")).toString()));
|
||||
previewTable_->setItem(
|
||||
i, 2, new QTableWidgetItem(o.value(QStringLiteral("exceptionTypeName")).toString()));
|
||||
previewTable_->setItem(i, 3,
|
||||
new QTableWidgetItem(o.value(QStringLiteral("remark")).toString()));
|
||||
previewTable_->setItem(
|
||||
i, 4, new QTableWidgetItem(o.value(QStringLiteral("thresholdModeName")).toString()));
|
||||
auto* delBtn = new QPushButton(QStringLiteral("删除"), previewTable_);
|
||||
QPointer<AutoAnnotationDialog> weak(this);
|
||||
connect(delBtn, &QPushButton::clicked, this, [weak, i]() {
|
||||
if (weak) weak->deletePreviewRow(i);
|
||||
});
|
||||
previewTable_->setCellWidget(i, 5, delBtn);
|
||||
}
|
||||
detectedLabel_->setText(
|
||||
QStringLiteral("自动标注结果(共识别到 %1 个异常)").arg(previewExceptions_.size()));
|
||||
saveBtn_->setEnabled(!previewExceptions_.isEmpty());
|
||||
refreshPreviewAnomalies(); // 同步从预览图移除该异常
|
||||
}
|
||||
|
||||
void AutoAnnotationDialog::onSave() {
|
||||
if (!repo_ || previewExceptions_.isEmpty()) return;
|
||||
// 组装 exceptionList:保留 execute 返回项的关键字段。
|
||||
if (!repo_ || previewExceptions_.isEmpty()) {
|
||||
QMessageBox::warning(this, windowTitle(), QStringLiteral("暂无可保存的异常,请先执行自动标注"));
|
||||
return;
|
||||
}
|
||||
// 组装 exceptionList:保留 execute 返回项的关键字段(对照原版 batchCreateException)。
|
||||
QJsonArray exceptionList;
|
||||
for (const QJsonValue& v : previewExceptions_) {
|
||||
const QJsonObject o = v.toObject();
|
||||
|
|
|
|||
|
|
@ -2,14 +2,23 @@
|
|||
#include <QDialog>
|
||||
#include <QJsonArray>
|
||||
#include <QString>
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
|
||||
#include "model/Field.hpp" // core::Grid(预览图等值面)
|
||||
#include "model/ColorScale.hpp" // core::ColorScale(预览图色阶)
|
||||
|
||||
class QButtonGroup;
|
||||
class QComboBox;
|
||||
class QLineEdit;
|
||||
class QSpinBox;
|
||||
class QTableWidget;
|
||||
class QPushButton;
|
||||
class QToolButton;
|
||||
class QLabel;
|
||||
class QVBoxLayout;
|
||||
class QWidget;
|
||||
class QwtPlot;
|
||||
|
||||
namespace geopro::data {
|
||||
class IDatasetCommandRepository;
|
||||
|
|
@ -17,40 +26,70 @@ class IDatasetCommandRepository;
|
|||
|
||||
namespace geopro::app {
|
||||
|
||||
// 自动标注对话框(I13,复刻原版 AutoAnnotationDialog):
|
||||
// 左:规则列表(阈值模式 数值/百分位、min/max、最小点数、异常类型)。
|
||||
// 执行 → executeExceptionMark(预演) → 预览表;确定保存 → batchCreateException → reloadGrid。
|
||||
// 异常类型仅支持面/polygon(原版同),故 listExceptionTypes 取 remarkSourceType="3"。
|
||||
class ColorMapService;
|
||||
class ContourPlotItem;
|
||||
|
||||
// 自动标注对话框(I13,复刻原版 AutoAnnotationDialog,1400×600):
|
||||
// 左:规则卡片列表(标题「规则N」+ 折叠 + 删除;阈值模式 radio-button 数值/百分位、min/max、
|
||||
// 最小点数、异常类型)+「添加规则」。
|
||||
// 右上:数据统计(最大/最小/均值/中位数,从网格标量算)+ 预览图(后置标注)。
|
||||
// 右下:预览表(序号/异常名称/异常类型/阈值范围/阈值模式/操作删除)。
|
||||
// 执行 → executeExceptionMark(预演) → 预览表;确认保存 → batchCreateException → reloadGrid。
|
||||
// 异常类型仅支持面/polygon(原版同),listExceptionTypes 取 remarkSourceType="3"。
|
||||
class AutoAnnotationDialog : public QDialog {
|
||||
Q_OBJECT
|
||||
public:
|
||||
// grid/scale:当前反演网格 + 色阶,用于右上预览图(ContourPlotItem 渲染等值面)
|
||||
// 及数据统计(max/min/mean/median 从 grid.values() 算)。
|
||||
AutoAnnotationDialog(geopro::data::IDatasetCommandRepository* repo, QString dsObjectId,
|
||||
QString projectId, QWidget* parent = nullptr);
|
||||
QString projectId, const geopro::core::Grid& grid,
|
||||
const geopro::core::ColorScale& scale, QWidget* parent = nullptr);
|
||||
~AutoAnnotationDialog() override;
|
||||
|
||||
private:
|
||||
struct RuleRow {
|
||||
QComboBox* mode = nullptr; // 1 数值 / 2 百分位
|
||||
// 一条规则卡片的控件集合(卡片标题/折叠/删除 + 模式 radio + min/max + 最小点数 + 类型)。
|
||||
struct RuleCard {
|
||||
QWidget* frame = nullptr; // 整张卡片(删除时移除)
|
||||
QWidget* body = nullptr; // 折叠隐藏的主体
|
||||
QLabel* title = nullptr; // 「规则N」
|
||||
QButtonGroup* modeGroup = nullptr; // 1 数值 / 2 百分位(radio-button)
|
||||
QLineEdit* min = nullptr;
|
||||
QLineEdit* max = nullptr;
|
||||
QSpinBox* minPoints = nullptr;
|
||||
QComboBox* type = nullptr; // userData = 异常类型 id
|
||||
QComboBox* type = nullptr; // userData = 异常类型 id
|
||||
};
|
||||
|
||||
void loadExceptionTypes(); // 拉面异常类型(填充所有规则行下拉)
|
||||
void buildStatsBar(QVBoxLayout* into); // 右上统计条(max/min/mean/median)
|
||||
void buildPreviewPlot(QVBoxLayout* into); // 右上预览图(QwtPlot + ContourPlotItem 等值面)
|
||||
void refreshPreviewAnomalies(); // 把 previewExceptions_ 映射成 Anomaly 叠加到预览图并重绘
|
||||
void loadExceptionTypes(); // 拉面异常类型(填充所有规则卡片下拉)
|
||||
void addRule(); // 加一条规则卡片
|
||||
void removeRule(QWidget* frame); // 删除指定卡片(至少保留一条)
|
||||
void renumberRules(); // 重排卡片标题「规则N」
|
||||
int currentMode(const RuleCard& c) const; // 取当前 radio 模式(1/2)
|
||||
QJsonArray buildRuleList() const; // 组装 exceptionMarkRuleList
|
||||
void onExecute(); // executeExceptionMark → 预览
|
||||
void onSave(); // batchCreateException
|
||||
void deletePreviewRow(int row); // 删除一条预览异常
|
||||
|
||||
geopro::data::IDatasetCommandRepository* repo_ = nullptr;
|
||||
QString dsObjectId_;
|
||||
QString projectId_;
|
||||
geopro::core::Grid grid_; // 反演网格(预览图等值面 + 统计)
|
||||
geopro::core::ColorScale scale_; // 网格色阶(预览图取色)
|
||||
|
||||
// 预览图(复用 GridDataChartView 的 ContourPlotItem 渲染等值面 + 异常叠加)。
|
||||
// colorSvc_ 非 QObject 手动持有;previewItem_ 由 QwtPlot autoDelete,析构前 detach。
|
||||
QwtPlot* previewPlot_ = nullptr;
|
||||
std::unique_ptr<ColorMapService> colorSvc_;
|
||||
ContourPlotItem* previewItem_ = nullptr;
|
||||
|
||||
QVBoxLayout* ruleHost_ = nullptr;
|
||||
std::vector<RuleRow> rules_;
|
||||
QJsonArray exceptionTypeOptions_; // 缓存的类型列表({id,name}),新增规则行复用
|
||||
std::vector<RuleCard> rules_;
|
||||
QJsonArray exceptionTypeOptions_; // 缓存的类型列表({id,name}),新增规则卡片复用
|
||||
QTableWidget* previewTable_ = nullptr;
|
||||
QJsonArray previewExceptions_; // execute 返回的预览异常(confirm 时批量存)
|
||||
QLabel* detectedLabel_ = nullptr; // 「共识别到 N 个异常」
|
||||
QPushButton* saveBtn_ = nullptr;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
#include "panels/chart/ChartPickGeometry.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
namespace geopro::app {
|
||||
|
||||
std::vector<int> pointsInRect(const geopro::core::ScatterField& field, const QRectF& rect) {
|
||||
std::vector<int> hits;
|
||||
const auto& xs = field.x;
|
||||
const auto& ys = field.y;
|
||||
const std::size_t n = std::min(xs.size(), ys.size());
|
||||
const bool hasStatus = field.displayStatus.size() == n;
|
||||
for (std::size_t i = 0; i < n; ++i) {
|
||||
const double x = xs[i];
|
||||
const double y = ys[i];
|
||||
if (!std::isfinite(x) || !std::isfinite(y)) continue; // 脏数据跳过
|
||||
if (hasStatus && field.displayStatus[i] != 0) continue; // 隐藏点不参与框选
|
||||
if (rect.contains(x, y)) hits.push_back(static_cast<int>(i));
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
int minPointsForMarkType(int markType) {
|
||||
if (markType == 2) return 2; // 线
|
||||
if (markType == 3) return 3; // 面
|
||||
return 1; // 点(1)/文字(4)
|
||||
}
|
||||
|
||||
std::vector<QPointF> normalizeDrawnPoints(const std::vector<QPointF>& pts, int markType) {
|
||||
if (pts.empty()) return {};
|
||||
// 点/文字:单点定位,仅取首点(即便误收集多点)。
|
||||
if (markType == 1 || markType == 4) return {pts.front()};
|
||||
// 线/面:保留全部顶点(不足/闭合由调用方校验)。
|
||||
return pts;
|
||||
}
|
||||
|
||||
} // namespace geopro::app
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
#pragma once
|
||||
#include <vector>
|
||||
|
||||
#include <QPointF>
|
||||
#include <QRectF>
|
||||
|
||||
#include "model/Field.hpp"
|
||||
|
||||
namespace geopro::app {
|
||||
|
||||
// 图上交互的纯几何逻辑(无 Qt Widgets / Qwt 依赖,可独立单测)。
|
||||
// M14 框选命中 + I9 绘形归一化共用。
|
||||
|
||||
// M14 框选命中:返回散点 x/y 落在数据坐标矩形 rect 内的下标集合。
|
||||
// rect 用数据坐标(调用方已把像素橡皮筋反变换为数据坐标,并 normalized)。
|
||||
// 只测有限值;x/y 长度不一致取 min 防越界;隐藏点(displayStatus!=0)跳过(与原版
|
||||
// box-select 仅命中可见点一致)。
|
||||
std::vector<int> pointsInRect(const geopro::core::ScatterField& field, const QRectF& rect);
|
||||
|
||||
// I9 绘形:判断多边形是否「可闭合」(至少 3 个顶点)。线≥2、点/文字==1 的最少点数判断
|
||||
// 见 ExceptionGeometry::minPointsForMarkType。此处仅多边形语义糖。
|
||||
inline bool canClosePolygon(int vertexCount) { return vertexCount >= 3; }
|
||||
|
||||
// I9 绘形:把一串数据坐标点裁成「该标注类型的有效几何」:
|
||||
// 点(1)/文字(4) → 仅取首点;线(2) → 全部(至少 2);面(3) → 全部(至少 3)。
|
||||
// 超出/不足由调用方在完成时校验(minPointsForMarkType)。本函数仅做截断(点/文字取首点)。
|
||||
std::vector<QPointF> normalizeDrawnPoints(const std::vector<QPointF>& pts, int markType);
|
||||
|
||||
// I9 绘形:各标注类型的最少点数(点1/线2/面3/文字1)。markType 为 "1".."4" 对应的整数。
|
||||
int minPointsForMarkType(int markType);
|
||||
|
||||
} // namespace geopro::app
|
||||
|
|
@ -0,0 +1,222 @@
|
|||
#include "panels/chart/ContourDrawTool.hpp"
|
||||
|
||||
#include <QEvent>
|
||||
#include <QKeyEvent>
|
||||
#include <QMouseEvent>
|
||||
#include <QPainter>
|
||||
#include <QPen>
|
||||
#include <QPolygon>
|
||||
#include <QResizeEvent>
|
||||
#include <QToolTip>
|
||||
#include <QWidget>
|
||||
|
||||
#include <qwt_plot.h>
|
||||
#include <qwt_plot_canvas.h>
|
||||
#include <qwt_scale_map.h>
|
||||
|
||||
#include "panels/chart/ChartPickGeometry.hpp"
|
||||
|
||||
namespace geopro::app {
|
||||
|
||||
// 透明预览覆盖层(贴 canvas):画已落点 + 连线 + 到光标的橡皮筋。无 Q_OBJECT(仅重写
|
||||
// paintEvent,无信号槽),不入 MOC。透明且不吃事件(穿透给 canvas 上的 ContourDrawTool)。
|
||||
class ContourDrawOverlay : public QWidget {
|
||||
public:
|
||||
explicit ContourDrawOverlay(QWidget* parent) : QWidget(parent) {
|
||||
setAttribute(Qt::WA_TransparentForMouseEvents, true);
|
||||
setAttribute(Qt::WA_NoSystemBackground, true);
|
||||
setAttribute(Qt::WA_TranslucentBackground, true);
|
||||
}
|
||||
|
||||
// 设当前绘制态(像素坐标点 + 光标 + 类型)。markType 3=面(闭合预览)。
|
||||
void setState(const QVector<QPoint>& pts, const QPoint& cursor, int markType, bool drawing) {
|
||||
pts_ = pts;
|
||||
cursor_ = cursor;
|
||||
markType_ = markType;
|
||||
drawing_ = drawing;
|
||||
update();
|
||||
}
|
||||
|
||||
protected:
|
||||
void paintEvent(QPaintEvent*) override {
|
||||
if (!drawing_) return;
|
||||
QPainter p(this);
|
||||
p.setRenderHint(QPainter::Antialiasing, true);
|
||||
const QColor accent(24, 144, 255); // 品牌蓝预览
|
||||
QPen pen(accent, 1.5);
|
||||
p.setPen(pen);
|
||||
p.setBrush(Qt::NoBrush);
|
||||
|
||||
// 已落点连线(线/面)。
|
||||
if (pts_.size() >= 2) p.drawPolyline(QPolygon(pts_));
|
||||
// 橡皮筋:最后一点 → 光标。
|
||||
if (!pts_.isEmpty() && !cursor_.isNull()) {
|
||||
QPen dash(accent, 1.2, Qt::DashLine);
|
||||
p.setPen(dash);
|
||||
p.drawLine(pts_.back(), cursor_);
|
||||
// 面:光标 → 首点(闭合预览)。
|
||||
if (markType_ == 3 && pts_.size() >= 2) p.drawLine(cursor_, pts_.front());
|
||||
p.setPen(pen);
|
||||
}
|
||||
// 顶点小方块。
|
||||
p.setBrush(accent);
|
||||
for (const QPoint& pt : pts_) p.drawRect(pt.x() - 3, pt.y() - 3, 6, 6);
|
||||
}
|
||||
|
||||
private:
|
||||
QVector<QPoint> pts_;
|
||||
QPoint cursor_;
|
||||
int markType_ = 0;
|
||||
bool drawing_ = false;
|
||||
};
|
||||
|
||||
ContourDrawTool::ContourDrawTool(QwtPlot* plot, int xAxis, int yAxis, QObject* parent)
|
||||
: QObject(parent), plot_(plot), xAxis_(xAxis), yAxis_(yAxis) {
|
||||
if (plot_ && plot_->canvas()) {
|
||||
overlay_ = new ContourDrawOverlay(plot_->canvas());
|
||||
overlay_->setGeometry(plot_->canvas()->rect());
|
||||
overlay_->hide();
|
||||
// 后装于 LivePanner/hover → 绘制期优先消费事件(含 canvas resize 同步 overlay)。
|
||||
plot_->canvas()->installEventFilter(this);
|
||||
}
|
||||
}
|
||||
|
||||
void ContourDrawTool::begin(int markType) {
|
||||
if (!plot_ || !plot_->canvas()) return;
|
||||
active_ = true;
|
||||
markType_ = markType;
|
||||
dataPts_.clear();
|
||||
lastCursor_ = QPoint();
|
||||
plot_->canvas()->setCursor(Qt::CrossCursor);
|
||||
savedFocus_ = plot_->canvas()->focusPolicy(); // 记录原焦点策略,退出时还原
|
||||
savedFocusValid_ = true;
|
||||
plot_->canvas()->setFocusPolicy(Qt::StrongFocus);
|
||||
plot_->canvas()->setFocus(); // 让 canvas 接收回车/Esc 键
|
||||
if (overlay_) {
|
||||
overlay_->setGeometry(plot_->canvas()->rect());
|
||||
overlay_->raise();
|
||||
overlay_->show();
|
||||
refreshOverlay();
|
||||
}
|
||||
const QString hint = (markType == 2 || markType == 3)
|
||||
? QStringLiteral("逐点单击采集,双击或回车结束,Esc 取消")
|
||||
: QStringLiteral("单击落点,Esc 取消");
|
||||
QToolTip::showText(plot_->canvas()->mapToGlobal(QPoint(8, 8)), hint, plot_->canvas());
|
||||
}
|
||||
|
||||
void ContourDrawTool::restoreCanvas() {
|
||||
if (plot_ && plot_->canvas()) {
|
||||
plot_->canvas()->unsetCursor();
|
||||
if (savedFocusValid_) plot_->canvas()->setFocusPolicy(savedFocus_); // 还原焦点策略
|
||||
}
|
||||
savedFocusValid_ = false;
|
||||
if (overlay_) {
|
||||
overlay_->setState({}, QPoint(), 0, false);
|
||||
overlay_->hide();
|
||||
}
|
||||
}
|
||||
|
||||
void ContourDrawTool::cancel() {
|
||||
active_ = false;
|
||||
dataPts_.clear();
|
||||
restoreCanvas();
|
||||
}
|
||||
|
||||
QPointF ContourDrawTool::toData(const QPoint& canvasPos) const {
|
||||
if (!plot_) return {};
|
||||
const QwtScaleMap xMap = plot_->canvasMap(xAxis_);
|
||||
const QwtScaleMap yMap = plot_->canvasMap(yAxis_);
|
||||
return QPointF(xMap.invTransform(canvasPos.x()), yMap.invTransform(canvasPos.y()));
|
||||
}
|
||||
|
||||
void ContourDrawTool::addVertex(const QPoint& canvasPos) {
|
||||
dataPts_.push_back(toData(canvasPos));
|
||||
// 点/文字:单点即完成。
|
||||
if (markType_ == 1 || markType_ == 4) { finish(); return; }
|
||||
refreshOverlay();
|
||||
}
|
||||
|
||||
void ContourDrawTool::finish() {
|
||||
if (!active_) return;
|
||||
auto pts = normalizeDrawnPoints(dataPts_, markType_);
|
||||
if (static_cast<int>(pts.size()) < minPointsForMarkType(markType_)) {
|
||||
// 点数不足(如线只点了 1 个就双击):保持绘制态,等用户补点。
|
||||
return;
|
||||
}
|
||||
active_ = false;
|
||||
restoreCanvas();
|
||||
if (onComplete_) onComplete_(pts);
|
||||
}
|
||||
|
||||
void ContourDrawTool::refreshOverlay() {
|
||||
if (!overlay_ || !plot_) return;
|
||||
const QwtScaleMap xMap = plot_->canvasMap(xAxis_);
|
||||
const QwtScaleMap yMap = plot_->canvasMap(yAxis_);
|
||||
QVector<QPoint> px;
|
||||
px.reserve(static_cast<int>(dataPts_.size()));
|
||||
for (const QPointF& d : dataPts_)
|
||||
px.push_back(QPoint(static_cast<int>(xMap.transform(d.x())),
|
||||
static_cast<int>(yMap.transform(d.y()))));
|
||||
overlay_->setState(px, lastCursor_, markType_, active_);
|
||||
}
|
||||
|
||||
bool ContourDrawTool::eventFilter(QObject* obj, QEvent* ev) {
|
||||
if (!plot_ || obj != plot_->canvas()) return QObject::eventFilter(obj, ev);
|
||||
|
||||
// 始终同步 overlay 尺寸(即便未激活,保证下次激活时贴合)。
|
||||
if (ev->type() == QEvent::Resize && overlay_) {
|
||||
overlay_->setGeometry(plot_->canvas()->rect());
|
||||
return QObject::eventFilter(obj, ev);
|
||||
}
|
||||
if (!active_) return QObject::eventFilter(obj, ev);
|
||||
|
||||
switch (ev->type()) {
|
||||
case QEvent::MouseButtonDblClick: {
|
||||
auto* me = static_cast<QMouseEvent*>(ev);
|
||||
if (me->button() == Qt::LeftButton) {
|
||||
// Qt 双击序列:Press→Release→DblClick。前一个 Press 已 addVertex 落了一个与
|
||||
// 双击位置重合的伪顶点,结束前移除它,避免线/面末尾出现重复点。
|
||||
if (!dataPts_.empty()) dataPts_.pop_back();
|
||||
finish();
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case QEvent::MouseButtonPress: {
|
||||
auto* me = static_cast<QMouseEvent*>(ev);
|
||||
if (me->button() == Qt::LeftButton) {
|
||||
// 双击的第一下会先来一个 Press;线/面靠 DblClick 结束,这里只加点。
|
||||
addVertex(me->pos());
|
||||
return true;
|
||||
}
|
||||
if (me->button() == Qt::RightButton) { // 右键取消
|
||||
const bool wasActive = active_;
|
||||
cancel();
|
||||
if (wasActive && onCancel_) onCancel_();
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case QEvent::MouseMove: {
|
||||
auto* me = static_cast<QMouseEvent*>(ev);
|
||||
lastCursor_ = me->pos();
|
||||
if (!dataPts_.empty()) refreshOverlay();
|
||||
return true; // 绘制期消费移动(不弹 hover)
|
||||
}
|
||||
case QEvent::KeyPress: {
|
||||
auto* ke = static_cast<QKeyEvent*>(ev);
|
||||
if (ke->key() == Qt::Key_Return || ke->key() == Qt::Key_Enter) { finish(); return true; }
|
||||
if (ke->key() == Qt::Key_Escape) {
|
||||
cancel();
|
||||
if (onCancel_) onCancel_();
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return QObject::eventFilter(obj, ev);
|
||||
}
|
||||
|
||||
} // namespace geopro::app
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
#pragma once
|
||||
#include <functional>
|
||||
#include <vector>
|
||||
|
||||
#include <QObject>
|
||||
#include <QPoint>
|
||||
#include <QPointF>
|
||||
#include <QPointer>
|
||||
#include <Qt> // Qt::FocusPolicy
|
||||
|
||||
class QwtPlot;
|
||||
|
||||
namespace geopro::app {
|
||||
|
||||
class ContourDrawOverlay;
|
||||
|
||||
// I9 图上绘形工具:开启后在等值面图上用鼠标采集几何,实时预览,完成后回调数据坐标点。
|
||||
// 复刻原版 contour overlay 绘制交互(先弹窗选类型→再图上画→drawingComplete):
|
||||
// markType 1 点 / 4 文字:单击落点立即完成;
|
||||
// markType 2 线:逐点单击,双击或回车结束(≥2 点);
|
||||
// markType 3 面:逐点单击,双击或回车闭合(≥3 点)。
|
||||
// Esc 取消。绘制时事件优先于 LivePanner/hover 消费(开启期间禁用平移)。
|
||||
// 不拥有 plot(外部持有,QPointer 守护);overlay 父=canvas 随之析构。
|
||||
class ContourDrawTool : public QObject {
|
||||
Q_OBJECT
|
||||
public:
|
||||
// xAxis/yAxis:等值面所在轴(GridDataChartView 为 xBottom/yLeft)。
|
||||
ContourDrawTool(QwtPlot* plot, int xAxis, int yAxis, QObject* parent = nullptr);
|
||||
|
||||
// 完成回调:参数为数据坐标点序列(已按类型归一化:点/文字 1 点,线≥2,面≥3)。
|
||||
void setOnComplete(std::function<void(const std::vector<QPointF>&)> cb) { onComplete_ = std::move(cb); }
|
||||
// 取消回调(Esc / 右键 / 外部 cancel):调用方据此恢复 UI(如重新开放工具条)。
|
||||
void setOnCancel(std::function<void()> cb) { onCancel_ = std::move(cb); }
|
||||
|
||||
// 开始绘制指定标注类型("1".."4" 对应整数)。会重置已采集点并显示提示。
|
||||
void begin(int markType);
|
||||
// 外部强制取消(不触发 onCancel_)。
|
||||
void cancel();
|
||||
bool isActive() const { return active_; }
|
||||
|
||||
protected:
|
||||
bool eventFilter(QObject* obj, QEvent* ev) override;
|
||||
|
||||
private:
|
||||
QPointF toData(const QPoint& canvasPos) const; // 像素 → 数据坐标
|
||||
void addVertex(const QPoint& canvasPos);
|
||||
void finish(); // 校验最少点数 → onComplete_
|
||||
void refreshOverlay(); // 把当前已落点(数据坐标)映射回像素喂给 overlay 预览
|
||||
|
||||
QPointer<QwtPlot> plot_;
|
||||
int xAxis_;
|
||||
int yAxis_;
|
||||
std::function<void(const std::vector<QPointF>&)> onComplete_;
|
||||
std::function<void()> onCancel_;
|
||||
|
||||
ContourDrawOverlay* overlay_ = nullptr; // 父=canvas
|
||||
bool active_ = false;
|
||||
int markType_ = 0;
|
||||
std::vector<QPointF> dataPts_; // 已采集点(数据坐标)
|
||||
QPoint lastCursor_; // 当前光标(橡皮筋预览到此)
|
||||
Qt::FocusPolicy savedFocus_ = Qt::NoFocus; // begin 前 canvas 焦点策略(退出还原)
|
||||
bool savedFocusValid_ = false;
|
||||
void restoreCanvas(); // 退出绘制态:还原光标/焦点策略 + 隐藏 overlay
|
||||
};
|
||||
|
||||
} // namespace geopro::app
|
||||
|
|
@ -5,14 +5,22 @@
|
|||
#include <QCursor>
|
||||
#include <QHBoxLayout>
|
||||
#include <QHeaderView>
|
||||
#include <QJsonArray>
|
||||
#include <QMessageBox>
|
||||
#include <QPainter>
|
||||
#include <QPointer>
|
||||
#include <QPushButton>
|
||||
#include <QRadioButton>
|
||||
#include <QTableView>
|
||||
#include <QToolTip>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
#include "Theme.hpp"
|
||||
|
||||
#include "panels/chart/InversionFormDialog.hpp"
|
||||
#include "panels/chart/ScatterDataOps.hpp" // toggledDisplayStatus
|
||||
#include "panels/chart/TablePager.hpp"
|
||||
#include "repo/IDatasetCommandRepository.hpp"
|
||||
|
||||
namespace geopro::app {
|
||||
|
||||
|
|
@ -70,6 +78,38 @@ geopro::core::TableColumnKind TablePayloadModel::columnKind(int column) const {
|
|||
return payload_.columns[static_cast<size_t>(column)].kind;
|
||||
}
|
||||
|
||||
int TablePayloadModel::toggleColumn() const {
|
||||
for (size_t i = 0; i < payload_.columns.size(); ++i)
|
||||
if (payload_.columns[i].kind == geopro::core::TableColumnKind::Toggle)
|
||||
return static_cast<int>(i);
|
||||
return -1;
|
||||
}
|
||||
|
||||
QString TablePayloadModel::rowId(int row) const {
|
||||
if (row < 0 || row >= static_cast<int>(payload_.rowIds.size())) return {};
|
||||
return payload_.rowIds[static_cast<size_t>(row)];
|
||||
}
|
||||
|
||||
int TablePayloadModel::rowDisplayStatus(int row) const {
|
||||
const int col = toggleColumn();
|
||||
if (col < 0 || row < 0 || row >= static_cast<int>(payload_.rows.size())) return 0;
|
||||
const auto& cells = payload_.rows[static_cast<size_t>(row)];
|
||||
if (col >= static_cast<int>(cells.size())) return 0;
|
||||
// Toggle 单元 "1"=ON/可见 → displayStatus 0;否则隐藏 → 1。
|
||||
return cells[static_cast<size_t>(col)] == QLatin1String("1") ? 0 : 1;
|
||||
}
|
||||
|
||||
void TablePayloadModel::setRowDisplayStatus(int row, int status) {
|
||||
const int col = toggleColumn();
|
||||
if (col < 0 || row < 0 || row >= static_cast<int>(payload_.rows.size())) return;
|
||||
auto& cells = payload_.rows[static_cast<size_t>(row)];
|
||||
if (col >= static_cast<int>(cells.size())) return;
|
||||
// status 0=显示 → 单元 "1"(ON);status 1=隐藏 → "0"(OFF)。
|
||||
cells[static_cast<size_t>(col)] = (status == 0) ? QStringLiteral("1") : QStringLiteral("0");
|
||||
const QModelIndex idx = index(row, col);
|
||||
emit dataChanged(idx, idx, {Qt::DisplayRole});
|
||||
}
|
||||
|
||||
ToggleSwitchDelegate::ToggleSwitchDelegate(const TablePayloadModel* model, QObject* parent)
|
||||
: QStyledItemDelegate(parent), model_(model) {}
|
||||
|
||||
|
|
@ -118,11 +158,16 @@ DataTableView::DataTableView(QWidget* parent) : QWidget(parent) {
|
|||
lay->setContentsMargins(0, 0, 0, 0);
|
||||
lay->setSpacing(0);
|
||||
|
||||
// 顶部功能按钮行(默认隐藏;仅 dd_grid 载荷带 functionButtons 时显示)。右对齐贴近原版布局。
|
||||
// 顶部功能按钮行(默认隐藏;仅 dd_grid 载荷带 functionButtons 时显示)。
|
||||
// 布局对照原版 DdGrid/index.vue .swicth:左侧 radio-group(「电法列表」单选项) + 右侧主按钮组,
|
||||
// space-between。radio 仅视觉占位(原版亦仅一项、无实际切换作用)。
|
||||
toolbar_ = new QWidget(this);
|
||||
toolbarLay_ = new QHBoxLayout(toolbar_);
|
||||
toolbarLay_->setContentsMargins(0, 0, 0, 8);
|
||||
toolbarLay_->addStretch(1);
|
||||
auto* listRadio = new QRadioButton(QStringLiteral("电法列表"), toolbar_);
|
||||
listRadio->setChecked(true);
|
||||
toolbarLay_->addWidget(listRadio); // index 0:左侧单选项
|
||||
toolbarLay_->addStretch(1); // index 1:把功能按钮推到右侧(rebuildToolbar 在末尾追加)
|
||||
toolbar_->hide();
|
||||
lay->addWidget(toolbar_);
|
||||
|
||||
|
|
@ -141,6 +186,8 @@ DataTableView::DataTableView(QWidget* parent) : QWidget(parent) {
|
|||
|
||||
// Toggle 列委托:把“隐藏/显示”列画成蓝色药丸开关。
|
||||
table_->setItemDelegate(new ToggleSwitchDelegate(model_, table_));
|
||||
// M2:点击 Toggle 列(仅 measurement 可交互)→ 行级显隐切换。
|
||||
connect(table_, &QTableView::clicked, this, &DataTableView::onCellClicked);
|
||||
|
||||
lay->addWidget(table_);
|
||||
|
||||
|
|
@ -191,24 +238,36 @@ void DataTableView::setCommandRepo(geopro::data::IDatasetCommandRepository* repo
|
|||
}
|
||||
|
||||
void DataTableView::rebuildToolbar(const std::vector<geopro::core::TableFunctionButton>& buttons) {
|
||||
// 清空旧按钮(保留末尾 addStretch;逐项删 QPushButton)。
|
||||
// 清空旧功能按钮(仅删 QPushButton,保留左侧 radio 与中间 stretch)。
|
||||
for (int i = toolbarLay_->count() - 1; i >= 0; --i) {
|
||||
if (auto* w = toolbarLay_->itemAt(i)->widget()) {
|
||||
toolbarLay_->removeWidget(w);
|
||||
w->deleteLater();
|
||||
if (auto* btn = qobject_cast<QPushButton*>(toolbarLay_->itemAt(i)->widget())) {
|
||||
toolbarLay_->removeWidget(btn);
|
||||
btn->deleteLater();
|
||||
}
|
||||
}
|
||||
|
||||
// 仅渲染 enable 的按钮(原版 v-show="enable");全空/全禁用 → 隐藏整行。
|
||||
// 主按钮蓝色实心(对照原版 type="primary"),复用 primaryBtn QSS。
|
||||
int shown = 0;
|
||||
for (const auto& b : buttons) {
|
||||
if (!b.enable) continue;
|
||||
auto* btn = new QPushButton(b.nameChn, toolbar_);
|
||||
btn->setObjectName(QStringLiteral("primaryBtn"));
|
||||
const QString code = b.code;
|
||||
connect(btn, &QPushButton::clicked, this, [this, code] { onFunctionButton(code); });
|
||||
toolbarLay_->addWidget(btn);
|
||||
toolbarLay_->addWidget(btn); // 末尾追加 → 落在 stretch 之后(右侧)
|
||||
++shown;
|
||||
}
|
||||
if (shown > 0) {
|
||||
applyTokenizedStyleSheet(
|
||||
toolbar_,
|
||||
QStringLiteral(
|
||||
"QPushButton#primaryBtn { background: {{accent/primary}}; color: {{text/on-primary}};"
|
||||
" border: 1px solid {{accent/primary}}; border-radius: 6px; padding: 6px 14px; }"
|
||||
"QPushButton#primaryBtn:hover { background: {{accent/primary-hover}};"
|
||||
" border-color: {{accent/primary-hover}}; }"
|
||||
"QPushButton#primaryBtn:pressed { background: {{accent/primary-pressed}}; }"));
|
||||
}
|
||||
toolbar_->setVisible(shown > 0);
|
||||
}
|
||||
|
||||
|
|
@ -227,4 +286,42 @@ void DataTableView::onFunctionButton(const QString& code) {
|
|||
dlg.exec(); // 提交反馈由对话框内部处理;列表无需刷新(原版亦仅 Message.success 提示)。
|
||||
}
|
||||
|
||||
void DataTableView::onCellClicked(const QModelIndex& index) {
|
||||
// M2 行级显隐:仅 measurement 列表(toggleInteractive)的 Toggle 列响应点击;其余视图无操作。
|
||||
if (!index.isValid() || !model_->isToggleInteractive()) return;
|
||||
if (model_->columnKind(index.column()) != geopro::core::TableColumnKind::Toggle) return;
|
||||
|
||||
const int row = index.row();
|
||||
const QString id = model_->rowId(row);
|
||||
const int cur = model_->rowDisplayStatus(row); // 0=显示 1=隐藏
|
||||
const int next = toggledDisplayStatus(cur); // 取反(对照原版 record.displayStatus ? 0 : 1)
|
||||
|
||||
// popconfirm 文案:当前显示(0)→将隐藏;当前隐藏→将显示(对照原版 scatterPopHide/scatterPopShow)。
|
||||
const QString text = (cur == 0) ? QStringLiteral("该操作会隐藏该散点,确认?")
|
||||
: QStringLiteral("该操作会显示该散点,确认?");
|
||||
if (QMessageBox::question(this, QStringLiteral("提示"), text,
|
||||
QMessageBox::Ok | QMessageBox::Cancel) != QMessageBox::Ok)
|
||||
return;
|
||||
|
||||
const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString();
|
||||
if (!cmdRepo_ || dsId.isEmpty() || id.isEmpty()) {
|
||||
// 无仓储/无 dsId/无行 id → 仅本地切换(退化,不持久化)。
|
||||
model_->setRowDisplayStatus(row, next);
|
||||
return;
|
||||
}
|
||||
|
||||
QJsonArray ids;
|
||||
ids.append(id);
|
||||
QPointer<DataTableView> self(this);
|
||||
cmdRepo_->saveDisplayStatus(dsId, ids, next, [self, row, next](bool ok, QString msg) {
|
||||
if (!self) return;
|
||||
if (!ok) {
|
||||
QMessageBox::warning(self, QStringLiteral("提示"),
|
||||
msg.isEmpty() ? QStringLiteral("操作失败") : msg);
|
||||
return;
|
||||
}
|
||||
self->model_->setRowDisplayStatus(row, next); // 持久化成功后更新该行状态
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace geopro::app
|
||||
|
|
|
|||
|
|
@ -33,7 +33,17 @@ public:
|
|||
// 列渲染种类(供委托判断是否画开关)。越界返回 Text。
|
||||
geopro::core::TableColumnKind columnKind(int column) const;
|
||||
|
||||
// M2:该 Toggle 列是否可交互(仅 measurement 载荷为 true)。
|
||||
bool isToggleInteractive() const { return payload_.toggleInteractive; }
|
||||
// M2:取行点 id(越界/无 id → 空串)。
|
||||
QString rowId(int row) const;
|
||||
// M2:取行当前显隐状态(0=显示 1=隐藏;据 Toggle 单元 "1"=ON/可见反推)。
|
||||
int rowDisplayStatus(int row) const;
|
||||
// M2:把某行 Toggle 单元就地设为指定状态(status 0=显示 → "1"/ON;持久化成功后调用)。
|
||||
void setRowDisplayStatus(int row, int status);
|
||||
|
||||
private:
|
||||
int toggleColumn() const; // Toggle 列下标(无则 -1)
|
||||
geopro::core::TablePayload payload_;
|
||||
};
|
||||
|
||||
|
|
@ -81,6 +91,7 @@ signals:
|
|||
private:
|
||||
void rebuildToolbar(const std::vector<geopro::core::TableFunctionButton>& buttons);
|
||||
void onFunctionButton(const QString& code); // 功能按钮路由(仅 inversion 起效)
|
||||
void onCellClicked(const QModelIndex& index); // M2 行级显隐切换(仅 measurement Toggle 列)
|
||||
|
||||
QWidget* toolbar_; // 顶部功能按钮行容器(functionButtons 空时隐藏)
|
||||
QHBoxLayout* toolbarLay_; // 功能按钮布局(重建时清空重填)
|
||||
|
|
|
|||
|
|
@ -16,12 +16,15 @@ std::unique_ptr<IDetailView> makeDetailView(controller::ViewKind kind, QWidget*
|
|||
geopro::data::IColorTemplateRepository* colorTplRepo,
|
||||
std::function<QString()> projectIdGetter,
|
||||
geopro::data::IDatasetCommandRepository* cmdRepo,
|
||||
std::function<QString()> dsIdGetter) {
|
||||
std::function<QString()> dsIdGetter,
|
||||
std::function<QString()> tmObjectIdGetter) {
|
||||
switch (kind) {
|
||||
case controller::ViewKind::Scatter: {
|
||||
auto* raw = new RawDataChartView(parent);
|
||||
// 注入反演命令仓储 + dsId/projectId 取值回调(measurement 反演运算/生成视电阻率)。
|
||||
raw->setCommandRepo(cmdRepo, dsIdGetter, projectIdGetter);
|
||||
// 注入色阶模板仓储(散点「色阶配置」编辑器另存为/打开/覆盖用;projectId 复用上面的 getter)。
|
||||
raw->setColorTemplateRepo(colorTplRepo);
|
||||
return std::unique_ptr<IDetailView>(raw);
|
||||
}
|
||||
case controller::ViewKind::FilledContour: {
|
||||
|
|
@ -30,6 +33,8 @@ std::unique_ptr<IDetailView> makeDetailView(controller::ViewKind kind, QWidget*
|
|||
grid->setColorTemplateRepo(colorTplRepo, projectIdGetter);
|
||||
// 注入反演命令仓储 + dsId/projectId 取值回调(网格化/白化/滤波/另存为按钮)。
|
||||
grid->setCommandRepo(cmdRepo, std::move(dsIdGetter), std::move(projectIdGetter));
|
||||
// 注入 tmObjectId 取值回调(白化对话框模板列表用,= 数据集 structParentId)。
|
||||
grid->setTmObjectIdGetter(std::move(tmObjectIdGetter));
|
||||
return std::unique_ptr<IDetailView>(grid);
|
||||
}
|
||||
case controller::ViewKind::Table: {
|
||||
|
|
|
|||
|
|
@ -22,11 +22,13 @@ class IDetailView;
|
|||
// 现阶段命中会抛 std::runtime_error(明确失败,而非静默空指针)。
|
||||
// colorTplRepo/projectIdGetter:FilledContour 视图的色阶模板仓储注入(可空 → 编辑器后端按钮禁用)。
|
||||
// cmdRepo/dsIdGetter:Scatter 视图(measurement)反演运算/生成视电阻率命令仓储注入(可空 → 按钮占位提示)。
|
||||
// tmObjectIdGetter:FilledContour 视图白化对话框所需 tmObjectId(=数据集 structParentId)取值回调(可空 → 模板列表空)。
|
||||
std::unique_ptr<IDetailView> makeDetailView(
|
||||
controller::ViewKind kind, QWidget* parent,
|
||||
geopro::data::IColorTemplateRepository* colorTplRepo = nullptr,
|
||||
std::function<QString()> projectIdGetter = {},
|
||||
geopro::data::IDatasetCommandRepository* cmdRepo = nullptr,
|
||||
std::function<QString()> dsIdGetter = {});
|
||||
std::function<QString()> dsIdGetter = {},
|
||||
std::function<QString()> tmObjectIdGetter = {});
|
||||
|
||||
} // namespace geopro::app
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
#include "panels/chart/ExceptionDetailDialog.hpp"
|
||||
|
||||
#include <QColorDialog>
|
||||
#include <QComboBox>
|
||||
#include <QDoubleSpinBox>
|
||||
|
||||
#include "EmptyAwareComboBox.hpp"
|
||||
#include <QFile>
|
||||
#include <QFileDialog>
|
||||
#include <QFormLayout>
|
||||
#include <QFrame>
|
||||
#include <QHBoxLayout>
|
||||
#include <QHeaderView>
|
||||
#include <QJsonObject>
|
||||
|
|
@ -13,7 +16,9 @@
|
|||
#include <QPlainTextEdit>
|
||||
#include <QPointer>
|
||||
#include <QPushButton>
|
||||
#include <QTabWidget>
|
||||
#include <QTableWidget>
|
||||
#include <QTextStream>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
#include "FormKit.hpp"
|
||||
|
|
@ -22,82 +27,58 @@
|
|||
|
||||
namespace geopro::app {
|
||||
|
||||
namespace {
|
||||
|
||||
// 只读色块(对照原版 disabled ColorPicker):显示 hex + 背景色,不可点。
|
||||
QLabel* readonlySwatch(const QString& hex, QWidget* parent) {
|
||||
auto* lbl = new QLabel(hex, parent);
|
||||
lbl->setStyleSheet(
|
||||
QStringLiteral("background:%1;border:1px solid #ccc;padding:2px 6px;color:#fff;").arg(hex));
|
||||
return lbl;
|
||||
}
|
||||
|
||||
// 线型 code → 中文(对照原版 solid/dash)。
|
||||
QString lineTypeName(bool dashed) {
|
||||
return dashed ? QStringLiteral("虚线") : QStringLiteral("实线");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
ExceptionDetailDialog::ExceptionDetailDialog(geopro::data::IDatasetCommandRepository* repo,
|
||||
const geopro::core::Anomaly& anomaly, QWidget* parent)
|
||||
: QDialog(parent), repo_(repo), anomaly_(anomaly) {
|
||||
setWindowTitle(QStringLiteral("异常详情"));
|
||||
setWindowTitle(QStringLiteral("标注详情"));
|
||||
setModal(true);
|
||||
resize(420, 460);
|
||||
|
||||
lineColor_ = QString::fromStdString(anomaly_.lineColor);
|
||||
if (lineColor_.isEmpty()) lineColor_ = QStringLiteral("#000000");
|
||||
// 右侧抽屉观感:窄而高(对照原版 ADrawer width=380)。
|
||||
resize(geopro::app::scaledPx(380), geopro::app::scaledPx(560));
|
||||
|
||||
auto* root = formkit::dialogRoot(this);
|
||||
|
||||
auto* card = formkit::formCard(this);
|
||||
auto* cardLay = formkit::cardBody(card);
|
||||
auto* form = formkit::makeEditForm();
|
||||
|
||||
// ── 头部:名称(可编辑) + 异常类型(只读) ───────────────────────────────────
|
||||
auto* head = formkit::makeEditForm();
|
||||
nameEdit_ = new QLineEdit(QString::fromStdString(anomaly_.name), this);
|
||||
formkit::capField(nameEdit_);
|
||||
form->addRow(formkit::editLabel(QStringLiteral("名称")), nameEdit_);
|
||||
|
||||
head->addRow(formkit::editLabel(QStringLiteral("名称")), nameEdit_);
|
||||
auto* typeLabel = new QLabel(QString::fromStdString(anomaly_.typeName), this);
|
||||
form->addRow(formkit::editLabel(QStringLiteral("异常类型")), typeLabel);
|
||||
head->addRow(formkit::editLabel(QStringLiteral("异常类型")), typeLabel);
|
||||
root->addLayout(head);
|
||||
|
||||
// 图例:线色 / 线宽 / 线型(对照原版 legend.polyline*)。
|
||||
colorBtn_ = new QPushButton(lineColor_, this);
|
||||
colorBtn_->setStyleSheet(QStringLiteral("background:%1;").arg(lineColor_));
|
||||
connect(colorBtn_, &QPushButton::clicked, this, [this]() {
|
||||
const QColor c = QColorDialog::getColor(QColor(lineColor_), this, QStringLiteral("线色"));
|
||||
if (c.isValid()) {
|
||||
lineColor_ = c.name();
|
||||
colorBtn_->setText(lineColor_);
|
||||
colorBtn_->setStyleSheet(QStringLiteral("background:%1;").arg(lineColor_));
|
||||
}
|
||||
});
|
||||
formkit::capField(colorBtn_);
|
||||
form->addRow(formkit::editLabel(QStringLiteral("线色")), colorBtn_);
|
||||
|
||||
widthSpin_ = new QDoubleSpinBox(this);
|
||||
widthSpin_->setRange(0.1, 20.0);
|
||||
widthSpin_->setSingleStep(0.5);
|
||||
widthSpin_->setValue(anomaly_.lineWidth > 0 ? anomaly_.lineWidth : 1.0);
|
||||
formkit::capField(widthSpin_);
|
||||
form->addRow(formkit::editLabel(QStringLiteral("线宽")), widthSpin_);
|
||||
|
||||
shapeCombo_ = new QComboBox(this);
|
||||
shapeCombo_->addItem(QStringLiteral("实线"), QStringLiteral("solid"));
|
||||
shapeCombo_->addItem(QStringLiteral("虚线"), QStringLiteral("dash"));
|
||||
shapeCombo_->setCurrentIndex(anomaly_.dashed ? 1 : 0);
|
||||
formkit::capField(shapeCombo_);
|
||||
form->addRow(formkit::editLabel(QStringLiteral("线型")), shapeCombo_);
|
||||
// ── 双 Tab:图例信息 / 坐标信息 ───────────────────────────────────────────
|
||||
auto* tabs = new QTabWidget(this);
|
||||
tabs->addTab(buildLegendTab(), QStringLiteral("图例信息"));
|
||||
tabs->addTab(buildCoordTab(), QStringLiteral("坐标信息"));
|
||||
root->addWidget(tabs, 1);
|
||||
|
||||
// ── 底部:备注(可编辑) ───────────────────────────────────────────────────
|
||||
root->addWidget(new QLabel(QStringLiteral("备注:"), this));
|
||||
remarkEdit_ = new QPlainTextEdit(QString::fromStdString(anomaly_.remark), this);
|
||||
remarkEdit_->setFixedHeight(geopro::app::scaledPx(60));
|
||||
formkit::capField(remarkEdit_);
|
||||
form->addRow(formkit::editLabel(QStringLiteral("备注")), remarkEdit_);
|
||||
cardLay->addLayout(form);
|
||||
root->addWidget(card);
|
||||
|
||||
// 坐标(只读展示,对照原版坐标信息 tab)。
|
||||
root->addWidget(new QLabel(QStringLiteral("坐标:"), this));
|
||||
auto* coordTable = new QTableWidget(static_cast<int>(anomaly_.localPts.size()), 2, this);
|
||||
coordTable->setHorizontalHeaderLabels({QStringLiteral("x"), QStringLiteral("y")});
|
||||
coordTable->horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch);
|
||||
coordTable->setEditTriggers(QAbstractItemView::NoEditTriggers);
|
||||
for (int r = 0; r < static_cast<int>(anomaly_.localPts.size()); ++r) {
|
||||
coordTable->setItem(r, 0,
|
||||
new QTableWidgetItem(QString::number(anomaly_.localPts[r].x, 'f', 7)));
|
||||
coordTable->setItem(r, 1,
|
||||
new QTableWidgetItem(QString::number(anomaly_.localPts[r].y, 'f', 7)));
|
||||
}
|
||||
root->addWidget(coordTable, 1);
|
||||
remarkEdit_->setFixedHeight(geopro::app::scaledPx(70));
|
||||
root->addWidget(remarkEdit_);
|
||||
|
||||
auto* btnLay = new QHBoxLayout();
|
||||
btnLay->addStretch();
|
||||
auto* cancelBtn = new QPushButton(QStringLiteral("取消"), this);
|
||||
okBtn_ = new QPushButton(QStringLiteral("确定"), this);
|
||||
okBtn_ = new QPushButton(QStringLiteral("更新"), this); // 对照原版 ok-text="更新"
|
||||
okBtn_->setDefault(true);
|
||||
btnLay->addWidget(cancelBtn);
|
||||
btnLay->addWidget(okBtn_);
|
||||
|
|
@ -107,6 +88,136 @@ ExceptionDetailDialog::ExceptionDetailDialog(geopro::data::IDatasetCommandReposi
|
|||
connect(okBtn_, &QPushButton::clicked, this, &ExceptionDetailDialog::onConfirm);
|
||||
}
|
||||
|
||||
QWidget* ExceptionDetailDialog::buildLegendTab() {
|
||||
auto* tab = new QWidget(this);
|
||||
auto* form = formkit::makeEditForm();
|
||||
|
||||
// 类型 + 顶点数/端点数(对照原版:多边形→顶点数,多段线→端点数)。
|
||||
const int mt = static_cast<int>(anomaly_.markType);
|
||||
const QString geoTypeName = mt == 1 ? QStringLiteral("点")
|
||||
: mt == 3 ? QStringLiteral("多边形")
|
||||
: mt == 4 ? QStringLiteral("文字")
|
||||
: QStringLiteral("多段线");
|
||||
form->addRow(formkit::editLabel(QStringLiteral("类型")), new QLabel(geoTypeName, tab));
|
||||
if (mt == 2 || mt == 3) {
|
||||
const QString cntLabel = mt == 3 ? QStringLiteral("顶点数") : QStringLiteral("端点数");
|
||||
form->addRow(formkit::editLabel(cntLabel),
|
||||
new QLabel(QString::number(anomaly_.localPts.size()), tab));
|
||||
}
|
||||
|
||||
// 线样式(只读展示,对照原版 disabled 控件:线色/线宽/线型)。
|
||||
form->addRow(formkit::editLabel(QStringLiteral("线色")),
|
||||
readonlySwatch(QString::fromStdString(anomaly_.lineColor), tab));
|
||||
form->addRow(formkit::editLabel(QStringLiteral("线宽")),
|
||||
new QLabel(QString::number(anomaly_.lineWidth), tab));
|
||||
form->addRow(formkit::editLabel(QStringLiteral("线型")),
|
||||
new QLabel(lineTypeName(anomaly_.dashed), tab));
|
||||
|
||||
// 文字类型:另展示字体/字号/字色/不透明度(只读)。
|
||||
if (mt == 4) {
|
||||
form->addRow(formkit::editLabel(QStringLiteral("内容")),
|
||||
new QLabel(QString::fromStdString(anomaly_.textContent), tab));
|
||||
form->addRow(formkit::editLabel(QStringLiteral("字色")),
|
||||
readonlySwatch(QString::fromStdString(anomaly_.textColor), tab));
|
||||
form->addRow(formkit::editLabel(QStringLiteral("字号")),
|
||||
new QLabel(QString::number(anomaly_.textSize), tab));
|
||||
}
|
||||
|
||||
auto* lay = new QVBoxLayout(tab);
|
||||
lay->addLayout(form);
|
||||
lay->addStretch();
|
||||
return tab;
|
||||
}
|
||||
|
||||
QWidget* ExceptionDetailDialog::buildCoordTab() {
|
||||
auto* tab = new QWidget(this);
|
||||
auto* lay = new QVBoxLayout(tab);
|
||||
|
||||
// 坐标系切换 + 顶点数 + 导出(对照原版坐标信息 tab)。
|
||||
auto* topRow = new QHBoxLayout();
|
||||
topRow->addWidget(new QLabel(QStringLiteral("坐标系:"), tab));
|
||||
coordSysCombo_ = new EmptyAwareComboBox(tab);
|
||||
coordSysCombo_->addItem(QStringLiteral("图形坐标"), QStringLiteral("jb"));
|
||||
// 条件显示(对照原版 drawerExceptionInfo:latLon.length===0 → 仅图形坐标;否则三项)。
|
||||
// 纯展示响应坐标,不做客户端换算;响应未携带经纬度时退化为仅图形坐标,与原版一致。
|
||||
if (!anomaly_.lonLatPts.empty()) {
|
||||
coordSysCombo_->addItem(QStringLiteral("经纬度坐标"), QStringLiteral("lonlat"));
|
||||
coordSysCombo_->addItem(QStringLiteral("投影坐标"), QStringLiteral("projection"));
|
||||
}
|
||||
topRow->addWidget(coordSysCombo_);
|
||||
topRow->addStretch();
|
||||
vertexCountLabel_ =
|
||||
new QLabel(QStringLiteral("顶点数:%1").arg(anomaly_.localPts.size()), tab);
|
||||
topRow->addWidget(vertexCountLabel_);
|
||||
auto* exportBtn = new QPushButton(QStringLiteral("导出"), tab);
|
||||
topRow->addWidget(exportBtn);
|
||||
lay->addLayout(topRow);
|
||||
|
||||
coordTable_ = new QTableWidget(0, 4, tab);
|
||||
coordTable_->setHorizontalHeaderLabels(
|
||||
{QStringLiteral("序号"), QStringLiteral("X"), QStringLiteral("Y"), QStringLiteral("Z")});
|
||||
coordTable_->horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch);
|
||||
coordTable_->setEditTriggers(QAbstractItemView::NoEditTriggers);
|
||||
lay->addWidget(coordTable_, 1);
|
||||
|
||||
connect(coordSysCombo_, &QComboBox::currentIndexChanged, this,
|
||||
[this](int) { onCoordSystemChanged(); });
|
||||
connect(exportBtn, &QPushButton::clicked, this, &ExceptionDetailDialog::exportCoords);
|
||||
|
||||
onCoordSystemChanged(); // 初次填图形坐标
|
||||
return tab;
|
||||
}
|
||||
|
||||
const std::vector<geopro::core::Vec2>& ExceptionDetailDialog::activeCoords() const {
|
||||
// 按当前坐标系返回对应点集(对照原版 handleCoordChange:jb=图形 / lonlat=经纬度 / projection=投影)。
|
||||
const QString sys = coordSysCombo_ ? coordSysCombo_->currentData().toString() : QString();
|
||||
if (sys == QStringLiteral("lonlat")) return anomaly_.lonLatPts; // x=经度 y=纬度
|
||||
if (sys == QStringLiteral("projection")) return anomaly_.eastNorthPts; // x=northCoord y=eastCoord
|
||||
return anomaly_.localPts; // 图形坐标
|
||||
}
|
||||
|
||||
void ExceptionDetailDialog::onCoordSystemChanged() {
|
||||
if (!coordTable_) return;
|
||||
// 纯展示响应坐标(不做客户端换算):按当前坐标系填表(对照原版 showCoord 重填)。
|
||||
const std::vector<geopro::core::Vec2>& pts = activeCoords();
|
||||
const int n = static_cast<int>(pts.size());
|
||||
coordTable_->setRowCount(n);
|
||||
for (int r = 0; r < n; ++r) {
|
||||
coordTable_->setItem(r, 0, new QTableWidgetItem(QString::number(r + 1)));
|
||||
coordTable_->setItem(r, 1, new QTableWidgetItem(QString::number(pts[r].x, 'f', 7)));
|
||||
coordTable_->setItem(r, 2, new QTableWidgetItem(QString::number(pts[r].y, 'f', 7)));
|
||||
coordTable_->setItem(r, 3, new QTableWidgetItem(QString())); // Z 空(对照原版)
|
||||
}
|
||||
if (vertexCountLabel_) vertexCountLabel_->setText(QStringLiteral("顶点数:%1").arg(n));
|
||||
}
|
||||
|
||||
void ExceptionDetailDialog::exportCoords() {
|
||||
const std::vector<geopro::core::Vec2>& pts = activeCoords();
|
||||
if (pts.empty()) {
|
||||
QMessageBox::information(this, windowTitle(), QStringLiteral("当前坐标系无可导出的坐标。"));
|
||||
return;
|
||||
}
|
||||
const QString base = QString::fromStdString(anomaly_.name);
|
||||
const QString path = QFileDialog::getSaveFileName(
|
||||
this, QStringLiteral("导出坐标"),
|
||||
(base.isEmpty() ? QStringLiteral("coordinates") : base) + QStringLiteral(".txt"),
|
||||
QStringLiteral("Text (*.txt)"));
|
||||
if (path.isEmpty()) return;
|
||||
QFile f(path);
|
||||
if (!f.open(QIODevice::WriteOnly | QIODevice::Text)) {
|
||||
QMessageBox::warning(this, windowTitle(), QStringLiteral("无法写入文件"));
|
||||
return;
|
||||
}
|
||||
QTextStream ts(&f);
|
||||
// 对照原版:TSV「序号\tX\tY\tZ」,X/Y 7位小数,Z 空。
|
||||
ts << QStringLiteral("序号\tX\tY\tZ\n");
|
||||
for (int i = 0; i < static_cast<int>(pts.size()); ++i) {
|
||||
ts << (i + 1) << '\t' << QString::number(pts[i].x, 'f', 7) << '\t'
|
||||
<< QString::number(pts[i].y, 'f', 7) << "\t\n";
|
||||
}
|
||||
f.close();
|
||||
}
|
||||
|
||||
void ExceptionDetailDialog::onConfirm() {
|
||||
if (!repo_ || anomaly_.id.empty()) { reject(); return; }
|
||||
const QString name = nameEdit_->text().trimmed();
|
||||
|
|
@ -114,9 +225,8 @@ void ExceptionDetailDialog::onConfirm() {
|
|||
QMessageBox::warning(this, windowTitle(), QStringLiteral("请输入名称"));
|
||||
return;
|
||||
}
|
||||
// 原版详情抽屉「改名称/备注」走 PUT /business/exception 的局部更新,
|
||||
// 仅发 {id, exceptionName, remark}(线样式是另一条独立 PUT,且抽屉里样式控件 disabled)。
|
||||
// 对齐原版 contourPage.vue onOk:不合并/不重发 legend。
|
||||
// 对照原版 drawerExceptionInfo onOk:图例样式控件 disabled、不发 legend,
|
||||
// 仅 PUT {id, exceptionName, remark}。
|
||||
QJsonObject body{
|
||||
{QStringLiteral("id"), QString::fromStdString(anomaly_.id)},
|
||||
{QStringLiteral("exceptionName"), name},
|
||||
|
|
|
|||
|
|
@ -6,9 +6,10 @@
|
|||
|
||||
class QLineEdit;
|
||||
class QPlainTextEdit;
|
||||
class QDoubleSpinBox;
|
||||
class QComboBox;
|
||||
class QTableWidget;
|
||||
class QPushButton;
|
||||
class QLabel;
|
||||
|
||||
namespace geopro::data {
|
||||
class IDatasetCommandRepository;
|
||||
|
|
@ -16,10 +17,13 @@ class IDatasetCommandRepository;
|
|||
|
||||
namespace geopro::app {
|
||||
|
||||
// 异常详情/编辑对话框(I11,复刻原版 drawerExceptionInfo 的可编辑部分):
|
||||
// 名称(可编辑) / 异常类型(只读) / 图例样式(线色/线宽/线型) / 备注(可编辑) / 坐标(只读展示)。
|
||||
// 确认 → updateException(PUT body {id, exceptionName, remark, legend:{polylineColor,
|
||||
// polylineWidth, polylineShape}}),成功 accept(),调用方随后 reloadGrid。
|
||||
// 异常详情/编辑对话框(I11,复刻原版 drawerExceptionInfo 右侧抽屉形态):
|
||||
// 双 Tab「图例信息 / 坐标信息」。
|
||||
// - 头部:名称(可编辑) + 异常类型(只读)。
|
||||
// - 图例信息:类型 + 顶点/端点数 + 线色/线宽/线型/不透明度(全部「只读展示」,对照原版 disabled)。
|
||||
// - 坐标信息:坐标系切换(图形/经纬度/投影) + 顶点数 + 坐标表(7位小数) + 导出 txt。
|
||||
// - 底部:备注(可编辑)。
|
||||
// 确认 → updateException(PUT body 仅 {id, exceptionName, remark},与原版一致;线样式只读不发)。
|
||||
class ExceptionDetailDialog : public QDialog {
|
||||
Q_OBJECT
|
||||
public:
|
||||
|
|
@ -28,16 +32,21 @@ public:
|
|||
|
||||
private:
|
||||
void onConfirm();
|
||||
void onCoordSystemChanged(); // 切换坐标系 → 按对应点集重填坐标表(图形/经纬度/投影)
|
||||
void exportCoords(); // 导出当前坐标系坐标为 txt(7位小数)
|
||||
// 当前坐标系对应的点集(jb=图形 / lonlat=经纬度 / projection=投影;纯展示响应数据,不换算)。
|
||||
const std::vector<geopro::core::Vec2>& activeCoords() const;
|
||||
QWidget* buildLegendTab(); // 图例信息 Tab(只读样式)
|
||||
QWidget* buildCoordTab(); // 坐标信息 Tab
|
||||
|
||||
geopro::data::IDatasetCommandRepository* repo_ = nullptr;
|
||||
geopro::core::Anomaly anomaly_; // 拷贝(不可变范式:编辑后组装新 body,不改原对象)
|
||||
|
||||
QLineEdit* nameEdit_ = nullptr;
|
||||
QPlainTextEdit* remarkEdit_ = nullptr;
|
||||
QPushButton* colorBtn_ = nullptr; // 线色选择(弹 QColorDialog)
|
||||
QString lineColor_; // 当前线色 hex
|
||||
QDoubleSpinBox* widthSpin_ = nullptr;
|
||||
QComboBox* shapeCombo_ = nullptr; // solid / dash
|
||||
QComboBox* coordSysCombo_ = nullptr; // jb 图形 / lonlat 经纬度 / projection 投影
|
||||
QTableWidget* coordTable_ = nullptr;
|
||||
QLabel* vertexCountLabel_ = nullptr;
|
||||
QPushButton* okBtn_ = nullptr;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
#include <utility>
|
||||
|
||||
#include <QComboBox>
|
||||
|
||||
#include "EmptyAwareComboBox.hpp"
|
||||
#include <QFormLayout>
|
||||
#include <QHBoxLayout>
|
||||
#include <QHeaderView>
|
||||
|
|
@ -18,6 +20,7 @@
|
|||
|
||||
#include "FormKit.hpp"
|
||||
#include "Theme.hpp"
|
||||
#include "panels/chart/ExceptionTypeDialog.hpp"
|
||||
#include "repo/IDatasetCommandRepository.hpp"
|
||||
|
||||
namespace geopro::app {
|
||||
|
|
@ -48,7 +51,7 @@ ExceptionDialog::ExceptionDialog(geopro::data::IDatasetCommandRepository* repo,
|
|||
auto* form = formkit::makeEditForm();
|
||||
|
||||
// 标注类型(remarkSourceType "1".."4",与原版 annotationType 一致)。
|
||||
markTypeCombo_ = new QComboBox(this);
|
||||
markTypeCombo_ = new EmptyAwareComboBox(this);
|
||||
markTypeCombo_->addItem(QStringLiteral("点"), QStringLiteral("1"));
|
||||
markTypeCombo_->addItem(QStringLiteral("线"), QStringLiteral("2"));
|
||||
markTypeCombo_->addItem(QStringLiteral("面"), QStringLiteral("3"));
|
||||
|
|
@ -56,9 +59,18 @@ ExceptionDialog::ExceptionDialog(geopro::data::IDatasetCommandRepository* repo,
|
|||
formkit::capField(markTypeCombo_);
|
||||
form->addRow(formkit::editLabel(QStringLiteral("标注类型")), markTypeCombo_);
|
||||
|
||||
exceptionTypeCombo_ = new QComboBox(this);
|
||||
// 异常类型行:下拉 + 「新增异常类型」按钮(对照原版 exceptionDialog 同行布局)。
|
||||
// 空态感知下拉:异常类型异步加载(loadExceptionTypes),未选显占位、无数据弹「暂无数据」。
|
||||
exceptionTypeCombo_ = formkit::comboBox(QStringLiteral("请选择异常类型"), this);
|
||||
formkit::capField(exceptionTypeCombo_);
|
||||
form->addRow(formkit::editLabel(QStringLiteral("异常类型")), exceptionTypeCombo_);
|
||||
addTypeBtn_ = new QPushButton(QStringLiteral("新增异常类型"), this);
|
||||
auto* typeRow = new QWidget(this);
|
||||
auto* typeRowLay = new QHBoxLayout(typeRow);
|
||||
typeRowLay->setContentsMargins(0, 0, 0, 0);
|
||||
typeRowLay->addWidget(exceptionTypeCombo_, 1);
|
||||
typeRowLay->addWidget(addTypeBtn_);
|
||||
form->addRow(formkit::editLabel(QStringLiteral("异常类型")), typeRow);
|
||||
connect(addTypeBtn_, &QPushButton::clicked, this, &ExceptionDialog::onAddType);
|
||||
|
||||
nameEdit_ = new QLineEdit(this);
|
||||
nameEdit_->setPlaceholderText(QStringLiteral("数据名称+异常类型代号+序号"));
|
||||
|
|
@ -72,8 +84,8 @@ ExceptionDialog::ExceptionDialog(geopro::data::IDatasetCommandRepository* repo,
|
|||
cardLay->addLayout(form);
|
||||
root->addWidget(card);
|
||||
|
||||
// 坐标表(x/y 多行),下方加/减行按钮。
|
||||
root->addWidget(new QLabel(QStringLiteral("坐标(x,y):"), this));
|
||||
// 坐标兜底表(x/y 多行):留空 → 确定后在图上绘形采集(主路径);手填 → 直接提交。
|
||||
root->addWidget(new QLabel(QStringLiteral("坐标(x,y,留空则在图上绘制):"), this));
|
||||
coordTable_ = new QTableWidget(0, 2, this);
|
||||
coordTable_->setHorizontalHeaderLabels({QStringLiteral("x"), QStringLiteral("y")});
|
||||
coordTable_->horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch);
|
||||
|
|
@ -115,13 +127,59 @@ QString ExceptionDialog::markTypeValue() const {
|
|||
return markTypeCombo_->currentData().toString();
|
||||
}
|
||||
|
||||
QString ExceptionDialog::exceptionTypeId() const {
|
||||
return exceptionTypeCombo_->currentData().toString();
|
||||
}
|
||||
|
||||
QString ExceptionDialog::exceptionName() const {
|
||||
return nameEdit_->text().trimmed();
|
||||
}
|
||||
|
||||
QString ExceptionDialog::exceptionRemark() const {
|
||||
return remarkEdit_->toPlainText();
|
||||
}
|
||||
|
||||
QJsonArray ExceptionDialog::manualCoordinates() const {
|
||||
QJsonArray coords;
|
||||
for (int r = 0; r < coordTable_->rowCount(); ++r) {
|
||||
auto* ix = coordTable_->item(r, 0);
|
||||
auto* iy = coordTable_->item(r, 1);
|
||||
if (!ix || !iy || ix->text().trimmed().isEmpty() || iy->text().trimmed().isEmpty()) continue;
|
||||
bool okx = false, oky = false;
|
||||
const double x = ix->text().toDouble(&okx);
|
||||
const double y = iy->text().toDouble(&oky);
|
||||
if (!okx || !oky) continue;
|
||||
coords.append(QJsonObject{{QStringLiteral("x"), x}, {QStringLiteral("y"), y}});
|
||||
}
|
||||
return coords;
|
||||
}
|
||||
|
||||
void ExceptionDialog::onTypeChanged() {
|
||||
// 调整坐标表行数到该形态最少点数(不足则补行;已多则保留)。
|
||||
const int need = minPoints(markTypeValue());
|
||||
while (coordTable_->rowCount() < need) coordTable_->insertRow(coordTable_->rowCount());
|
||||
// 对照原版 handleAnnotationTypeChange:标注类型变 → 清空名称(待重选类型后回填)+
|
||||
// 重拉对应几何形态的异常类型列表 + 刷新「新增类型」按钮可用性。
|
||||
nameEdit_->clear();
|
||||
updateAddTypeEnabled();
|
||||
loadExceptionTypes();
|
||||
}
|
||||
|
||||
void ExceptionDialog::onAddType() {
|
||||
if (!repo_) return;
|
||||
// 完整复刻原版:打开「标注类型」对话框(异常属性 + 标注名称双 Tab + 按 markType 图例编辑器),
|
||||
// 内部走 addExceptionType;成功后回此处刷新异常类型下拉并按名称选中新建项(对照原版 emit ok)。
|
||||
ExceptionTypeDialog typeDlg(repo_, projectId_, markTypeValue(), this);
|
||||
if (typeDlg.exec() != QDialog::Accepted) return;
|
||||
|
||||
pendingSelectTypeName_ = typeDlg.createdTypeName(); // 重拉列表后按名称匹配选中
|
||||
loadExceptionTypes();
|
||||
}
|
||||
|
||||
void ExceptionDialog::updateAddTypeEnabled() {
|
||||
if (!addTypeBtn_) return;
|
||||
// 原版:文字类型(4) 或 未选标注类型时禁用「新增异常类型」。
|
||||
const QString mt = markTypeValue();
|
||||
addTypeBtn_->setEnabled(!mt.isEmpty() && mt != QStringLiteral("4"));
|
||||
}
|
||||
|
||||
void ExceptionDialog::loadExceptionTypes() {
|
||||
if (!repo_) return;
|
||||
QPointer<ExceptionDialog> self(this);
|
||||
|
|
@ -138,6 +196,12 @@ void ExceptionDialog::loadExceptionTypes() {
|
|||
if (id.isEmpty()) id = o.value(QStringLiteral("id")).toString();
|
||||
if (!id.isEmpty()) self->exceptionTypeCombo_->addItem(label, id);
|
||||
}
|
||||
// 若刚新建了类型 → 按名称匹配选中(找不到则保持默认首项,不报错)。
|
||||
if (!self->pendingSelectTypeName_.isEmpty()) {
|
||||
const int idx = self->exceptionTypeCombo_->findText(self->pendingSelectTypeName_);
|
||||
if (idx >= 0) self->exceptionTypeCombo_->setCurrentIndex(idx);
|
||||
self->pendingSelectTypeName_.clear();
|
||||
}
|
||||
if (self->exceptionTypeCombo_->count() > 0) self->suggestName();
|
||||
});
|
||||
}
|
||||
|
|
@ -147,12 +211,9 @@ void ExceptionDialog::suggestName() {
|
|||
const QString typeId = exceptionTypeCombo_->currentData().toString();
|
||||
if (typeId.isEmpty()) return;
|
||||
QPointer<ExceptionDialog> self(this);
|
||||
repo_->getExceptionName(typeId, remarkSourceId_, [self](bool ok, QJsonObject data, QString) {
|
||||
// 对照原版 handleExceptionTypeChange:每次选/换异常类型都回填名称(res.data 为纯字符串)。
|
||||
repo_->getExceptionName(typeId, remarkSourceId_, [self](bool ok, QString name, QString) {
|
||||
if (!self || !ok) return;
|
||||
// 仅当用户未手填时回填建议名(避免覆盖)。
|
||||
if (!self->nameEdit_->text().trimmed().isEmpty()) return;
|
||||
QString name = data.value(QStringLiteral("exceptionName")).toString();
|
||||
if (name.isEmpty()) name = data.value(QStringLiteral("name")).toString();
|
||||
self->nameEdit_->setText(name);
|
||||
});
|
||||
}
|
||||
|
|
@ -169,18 +230,11 @@ void ExceptionDialog::onConfirm() {
|
|||
QMessageBox::warning(this, windowTitle(), QStringLiteral("请选择异常类型"));
|
||||
return;
|
||||
}
|
||||
// 收集坐标(跳过空行)。
|
||||
QJsonArray coords;
|
||||
for (int r = 0; r < coordTable_->rowCount(); ++r) {
|
||||
auto* ix = coordTable_->item(r, 0);
|
||||
auto* iy = coordTable_->item(r, 1);
|
||||
if (!ix || !iy || ix->text().trimmed().isEmpty() || iy->text().trimmed().isEmpty()) continue;
|
||||
bool okx = false, oky = false;
|
||||
const double x = ix->text().toDouble(&okx);
|
||||
const double y = iy->text().toDouble(&oky);
|
||||
if (!okx || !oky) continue;
|
||||
coords.append(QJsonObject{{QStringLiteral("x"), x}, {QStringLiteral("y"), y}});
|
||||
}
|
||||
// 主路径:坐标表留空 → accept(),由调用方在图上绘形采集坐标后 newException。
|
||||
const QJsonArray coords = manualCoordinates();
|
||||
if (coords.isEmpty()) { accept(); return; }
|
||||
|
||||
// 兜底路径:用户手填了坐标 → 校验点数后直接弹窗内提交。
|
||||
if (coords.size() < minPoints(markTypeValue())) {
|
||||
QMessageBox::warning(this, windowTitle(),
|
||||
QStringLiteral("坐标点数不足(点/文字≥1,线≥2,面≥3)"));
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
#include <functional>
|
||||
|
||||
#include <QDialog>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonObject>
|
||||
#include <QString>
|
||||
|
||||
|
|
@ -17,24 +18,33 @@ class IDatasetCommandRepository;
|
|||
|
||||
namespace geopro::app {
|
||||
|
||||
// 异常创建对话框(I9,复刻原版 exceptionDialog + contourPage 保存链路):
|
||||
// 标注类型(点/线/面/文字 → remarkSourceType "1"/"2"/"3"/"4") + 异常类型(listExceptionTypes)
|
||||
// + 名称(getExceptionName 建议) + 备注 + 坐标表。
|
||||
// 确认 → newException(body),成功 accept(),调用方随后 reloadGrid。
|
||||
// 说明:原版「图上交互式绘制几何」在 Qwt 成本高,本实现以坐标表(x/y 多行)采集 location,
|
||||
// 覆盖点(1 行)/线(≥2)/面(≥3)/文字(1) 全形态,打通完整创建链路;on-chart 拖拽绘制为后置项。
|
||||
// 异常创建对话框(I9,复刻原版 exceptionDialog 时序):
|
||||
// 先弹窗选 标注类型(点/线/面/文字 → remarkSourceType "1".."4") + 异常类型(listExceptionTypes)
|
||||
// + 名称(getExceptionName 建议) + 备注;确认后由调用方在图上交互绘形采集坐标,再 newException。
|
||||
// 时序对照原版 contourPage/exceptionDialog:弹窗仅收元信息(不收坐标),accept() 后调用方读
|
||||
// markTypeValue()/exceptionTypeId()/name()/remark() 启动 ContourDrawTool 绘形 → 完成提交。
|
||||
// 兜底:坐标表仍保留(无法图上绘形时可手填),有有效坐标行则直接走旧版「弹窗内提交」链路。
|
||||
class ExceptionDialog : public QDialog {
|
||||
Q_OBJECT
|
||||
public:
|
||||
ExceptionDialog(geopro::data::IDatasetCommandRepository* repo, QString projectId,
|
||||
QString remarkSourceId, QWidget* parent = nullptr);
|
||||
|
||||
// accept() 后供调用方读取(驱动图上绘形 + newException)。
|
||||
QString markTypeValue() const; // 标注类型 "1".."4"(remarkSourceType)
|
||||
QString exceptionTypeId() const; // 异常类型 id
|
||||
QString exceptionName() const; // 名称
|
||||
QString exceptionRemark() const; // 备注
|
||||
// 兜底坐标(用户在表里手填的有效行);空 = 走图上绘形主路径。
|
||||
QJsonArray manualCoordinates() const;
|
||||
|
||||
private:
|
||||
void onTypeChanged(); // 标注类型变 → 重拉异常类型列表 + 调整坐标表最少行
|
||||
void onTypeChanged(); // 标注类型变 → 清名称 + 重拉异常类型列表 + 刷新「新增类型」可用性
|
||||
void onAddType(); // 「新增异常类型」:打开 ExceptionTypeDialog(双 Tab) → addExceptionType → 刷新+选中
|
||||
void loadExceptionTypes(); // listExceptionTypes(projectId, remarkSourceType)
|
||||
void suggestName(); // getExceptionName(exceptionTypeId, remarkSourceId) → 名称建议
|
||||
void onConfirm(); // 校验 → newException
|
||||
QString markTypeValue() const; // 当前标注类型字符串("1".."4")
|
||||
void suggestName(); // getExceptionName(exceptionTypeId, remarkSourceId) → 名称回填
|
||||
void onConfirm(); // 校验 → 有手填坐标则直接 newException,否则 accept() 交给绘形
|
||||
void updateAddTypeEnabled(); // 「新增异常类型」可用性:文字类型/未选类型时禁用(对照原版)
|
||||
|
||||
geopro::data::IDatasetCommandRepository* repo_ = nullptr;
|
||||
QString projectId_;
|
||||
|
|
@ -42,6 +52,8 @@ private:
|
|||
|
||||
QComboBox* markTypeCombo_ = nullptr; // userData = "1".."4"
|
||||
QComboBox* exceptionTypeCombo_ = nullptr; // userData = 异常类型 id
|
||||
QPushButton* addTypeBtn_ = nullptr; // 新增异常类型(对照原版,文字/未选类型禁用)
|
||||
QString pendingSelectTypeName_; // 新建类型后待选中的类型名(下一次列表刷新时匹配选中)
|
||||
QLineEdit* nameEdit_ = nullptr;
|
||||
QPlainTextEdit* remarkEdit_ = nullptr;
|
||||
QTableWidget* coordTable_ = nullptr;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,115 @@
|
|||
#include "panels/chart/ExceptionTextDialog.hpp"
|
||||
|
||||
#include <QColorDialog>
|
||||
#include <QComboBox>
|
||||
|
||||
#include "EmptyAwareComboBox.hpp"
|
||||
#include <QFormLayout>
|
||||
#include <QHBoxLayout>
|
||||
#include <QLabel>
|
||||
#include <QMessageBox>
|
||||
#include <QPlainTextEdit>
|
||||
#include <QPushButton>
|
||||
#include <QSlider>
|
||||
#include <QSpinBox>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
#include "FormKit.hpp"
|
||||
#include "Theme.hpp"
|
||||
|
||||
namespace geopro::app {
|
||||
|
||||
ExceptionTextDialog::ExceptionTextDialog(QWidget* parent)
|
||||
: QDialog(parent), color_(QStringLiteral("#000000")) {
|
||||
setWindowTitle(QStringLiteral("文本编辑"));
|
||||
setModal(true);
|
||||
resize(geopro::app::scaledPx(560), geopro::app::scaledPx(420));
|
||||
|
||||
auto* root = formkit::dialogRoot(this);
|
||||
auto* card = formkit::formCard(this);
|
||||
auto* cardLay = formkit::cardBody(card);
|
||||
auto* form = formkit::makeEditForm();
|
||||
|
||||
// 字体(对照原版 1宋体/2微软雅黑/3黑体/4楷体,值为字符串 int)。
|
||||
fontCombo_ = new EmptyAwareComboBox(this);
|
||||
fontCombo_->addItem(QStringLiteral("宋体"), QStringLiteral("1"));
|
||||
fontCombo_->addItem(QStringLiteral("微软雅黑"), QStringLiteral("2"));
|
||||
fontCombo_->addItem(QStringLiteral("黑体"), QStringLiteral("3"));
|
||||
fontCombo_->addItem(QStringLiteral("楷体"), QStringLiteral("4"));
|
||||
formkit::capField(fontCombo_);
|
||||
form->addRow(formkit::editLabel(QStringLiteral("字体")), fontCombo_);
|
||||
|
||||
// 大小(px,默认 12)。
|
||||
sizeSpin_ = new QSpinBox(this);
|
||||
sizeSpin_->setRange(1, 200);
|
||||
sizeSpin_->setValue(12);
|
||||
formkit::capField(sizeSpin_);
|
||||
form->addRow(formkit::editLabel(QStringLiteral("大小")), sizeSpin_);
|
||||
|
||||
// 颜色(默认黑)。
|
||||
colorBtn_ = new QPushButton(color_, this);
|
||||
colorBtn_->setStyleSheet(QStringLiteral("background:%1;").arg(color_));
|
||||
connect(colorBtn_, &QPushButton::clicked, this, &ExceptionTextDialog::pickColor);
|
||||
formkit::capField(colorBtn_);
|
||||
form->addRow(formkit::editLabel(QStringLiteral("颜色")), colorBtn_);
|
||||
|
||||
// 不透明度(0–100%,默认 100)。
|
||||
auto* opRow = new QWidget(this);
|
||||
auto* opLay = new QHBoxLayout(opRow);
|
||||
opLay->setContentsMargins(0, 0, 0, 0);
|
||||
opacitySlider_ = new QSlider(Qt::Horizontal, opRow);
|
||||
opacitySlider_->setRange(0, 100);
|
||||
opacitySlider_->setValue(100);
|
||||
opacityLabel_ = new QLabel(QStringLiteral("100%"), opRow);
|
||||
opacityLabel_->setFixedWidth(geopro::app::scaledPx(40));
|
||||
connect(opacitySlider_, &QSlider::valueChanged, this,
|
||||
[this](int v) { opacityLabel_->setText(QStringLiteral("%1%").arg(v)); });
|
||||
opLay->addWidget(opacitySlider_, 1);
|
||||
opLay->addWidget(opacityLabel_);
|
||||
form->addRow(formkit::editLabel(QStringLiteral("不透明度")), opRow);
|
||||
cardLay->addLayout(form);
|
||||
|
||||
// 内容(必填)。
|
||||
cardLay->addWidget(new QLabel(QStringLiteral("内容:"), this));
|
||||
contentEdit_ = new QPlainTextEdit(this);
|
||||
contentEdit_->setPlaceholderText(QStringLiteral("请输入内容"));
|
||||
contentEdit_->setFixedHeight(geopro::app::scaledPx(140));
|
||||
cardLay->addWidget(contentEdit_);
|
||||
root->addWidget(card);
|
||||
|
||||
auto* btnLay = new QHBoxLayout();
|
||||
btnLay->addStretch();
|
||||
auto* cancelBtn = new QPushButton(QStringLiteral("取消"), this);
|
||||
auto* okBtn = new QPushButton(QStringLiteral("确定"), this);
|
||||
okBtn->setDefault(true);
|
||||
btnLay->addWidget(cancelBtn);
|
||||
btnLay->addWidget(okBtn);
|
||||
root->addLayout(btnLay);
|
||||
|
||||
connect(cancelBtn, &QPushButton::clicked, this, &QDialog::reject);
|
||||
connect(okBtn, &QPushButton::clicked, this, &ExceptionTextDialog::onConfirm);
|
||||
}
|
||||
|
||||
void ExceptionTextDialog::pickColor() {
|
||||
const QColor c = QColorDialog::getColor(QColor(color_), this, QStringLiteral("颜色"));
|
||||
if (!c.isValid()) return;
|
||||
color_ = c.name(QColor::HexRgb);
|
||||
colorBtn_->setText(color_);
|
||||
colorBtn_->setStyleSheet(QStringLiteral("background:%1;").arg(color_));
|
||||
}
|
||||
|
||||
QString ExceptionTextDialog::fontFamilyValue() const { return fontCombo_->currentData().toString(); }
|
||||
int ExceptionTextDialog::fontSize() const { return sizeSpin_->value(); }
|
||||
QString ExceptionTextDialog::color() const { return color_; }
|
||||
int ExceptionTextDialog::opacityPercent() const { return opacitySlider_->value(); }
|
||||
QString ExceptionTextDialog::content() const { return contentEdit_->toPlainText().trimmed(); }
|
||||
|
||||
void ExceptionTextDialog::onConfirm() {
|
||||
if (content().isEmpty()) { // 对照原版:内容必填。
|
||||
QMessageBox::warning(this, windowTitle(), QStringLiteral("请输入文本内容"));
|
||||
return;
|
||||
}
|
||||
accept();
|
||||
}
|
||||
|
||||
} // namespace geopro::app
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
#pragma once
|
||||
#include <QDialog>
|
||||
#include <QString>
|
||||
|
||||
class QComboBox;
|
||||
class QSpinBox;
|
||||
class QSlider;
|
||||
class QPlainTextEdit;
|
||||
class QPushButton;
|
||||
class QLabel;
|
||||
|
||||
namespace geopro::app {
|
||||
|
||||
// 文字标注编辑对话框(I9,复刻原版 exceptionText.vue「文本编辑」):
|
||||
// 字体(1宋体/2微软雅黑/3黑体/4楷体) / 大小(px) / 颜色 / 不透明度(0–100%) / 内容(必填)。
|
||||
// 确定 → accept(),调用方读取各字段组装 newException 的 customLegend。
|
||||
// 时序对照原版:文字类型绘制落点后弹此对话框,提交带 customLegend
|
||||
// {text, content, color, size, font(CSS族), opacity(0–1)}。
|
||||
class ExceptionTextDialog : public QDialog {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit ExceptionTextDialog(QWidget* parent = nullptr);
|
||||
|
||||
// accept() 后供调用方读取。
|
||||
QString fontFamilyValue() const; // "1".."4"(字体族 int 字符串)
|
||||
int fontSize() const; // px
|
||||
QString color() const; // #rrggbb
|
||||
int opacityPercent() const; // 0–100
|
||||
QString content() const; // 文字内容
|
||||
|
||||
private:
|
||||
void onConfirm(); // 校验内容非空 → accept()
|
||||
void pickColor();
|
||||
|
||||
QComboBox* fontCombo_ = nullptr;
|
||||
QSpinBox* sizeSpin_ = nullptr;
|
||||
QPushButton* colorBtn_ = nullptr;
|
||||
QString color_; // 当前色 #rrggbb
|
||||
QSlider* opacitySlider_ = nullptr;
|
||||
QLabel* opacityLabel_ = nullptr;
|
||||
QPlainTextEdit* contentEdit_ = nullptr;
|
||||
};
|
||||
|
||||
} // namespace geopro::app
|
||||
|
|
@ -0,0 +1,415 @@
|
|||
#include "panels/chart/ExceptionTypeDialog.hpp"
|
||||
|
||||
#include <utility>
|
||||
|
||||
#include <QButtonGroup>
|
||||
#include <QColorDialog>
|
||||
#include <QComboBox>
|
||||
|
||||
#include "EmptyAwareComboBox.hpp"
|
||||
#include <QDialogButtonBox>
|
||||
#include <QFormLayout>
|
||||
#include <QGroupBox>
|
||||
#include <QHBoxLayout>
|
||||
#include <QHeaderView>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonObject>
|
||||
#include <QLabel>
|
||||
#include <QLineEdit>
|
||||
#include <QMessageBox>
|
||||
#include <QPlainTextEdit>
|
||||
#include <QPointer>
|
||||
#include <QPushButton>
|
||||
#include <QRadioButton>
|
||||
#include <QSpinBox>
|
||||
#include <QStackedWidget>
|
||||
#include <QTableWidget>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
#include "FormKit.hpp"
|
||||
#include "Theme.hpp" // scaledPx
|
||||
#include "repo/IDatasetCommandRepository.hpp"
|
||||
|
||||
namespace geopro::app {
|
||||
|
||||
namespace {
|
||||
// 图例选项(对照原版 ExceptionLabel/const.js 的启用项,value 与原版一致)。
|
||||
struct Opt {
|
||||
const char* value;
|
||||
const char* label;
|
||||
};
|
||||
const Opt kPointShapes[] = {{"circle", "圆点"}, {"diamond", "方块"}, {"triangle", "三角形"}};
|
||||
const Opt kLineShapes[] = {{"dash", "虚线"}, {"solid", "实线"}};
|
||||
const Opt kSurfaceFills[] = {
|
||||
{"/", "斜线"}, {"+", "正交网格"}, {".", "圆点阵列"}, {"", "颜色填充"}};
|
||||
const Opt kTextFonts[] = {{"1", "宋体"}, {"2", "微软雅黑"}, {"3", "黑体"}, {"4", "楷体"}};
|
||||
|
||||
// 用 Opt 表填充下拉(userData = value 字符串),并按 value 选中默认项。
|
||||
template <std::size_t N>
|
||||
void fillCombo(QComboBox* c, const Opt (&opts)[N], const QString& defValue) {
|
||||
for (const Opt& o : opts) c->addItem(QString::fromUtf8(o.label), QString::fromUtf8(o.value));
|
||||
const int idx = c->findData(defValue);
|
||||
if (idx >= 0) c->setCurrentIndex(idx);
|
||||
}
|
||||
|
||||
// 不透明度 0–100 spin。
|
||||
QSpinBox* makeOpacity(int def) {
|
||||
auto* s = new QSpinBox();
|
||||
s->setRange(0, 100);
|
||||
s->setValue(def);
|
||||
s->setSuffix(QStringLiteral("%"));
|
||||
return s;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
ExceptionTypeDialog::ExceptionTypeDialog(geopro::data::IDatasetCommandRepository* repo,
|
||||
QString projectId, QString markType, QWidget* parent)
|
||||
: QDialog(parent),
|
||||
repo_(repo),
|
||||
projectId_(std::move(projectId)),
|
||||
markType_(std::move(markType)),
|
||||
pointColor_(Qt::black),
|
||||
polylineColor_(Qt::black),
|
||||
polygonFillColor_(Qt::black),
|
||||
textColor_(Qt::black) {
|
||||
markTypeInt_ = markType_.toInt();
|
||||
if (markTypeInt_ < 1 || markTypeInt_ > 4) markTypeInt_ = 1;
|
||||
|
||||
setWindowTitle(QStringLiteral("标注类型"));
|
||||
setModal(true);
|
||||
resize(geopro::app::scaledPx(880), geopro::app::scaledPx(560));
|
||||
|
||||
auto* root = formkit::dialogRoot(this);
|
||||
|
||||
// 顶部 RadioGroup(按钮态) 双 Tab:异常属性 / 标注名称(对照原版 RadioGroup type=button)。
|
||||
auto* tabRow = new QHBoxLayout();
|
||||
auto* attrTab = new QRadioButton(QStringLiteral("异常属性"), this);
|
||||
auto* nameTab = new QRadioButton(QStringLiteral("标注名称"), this);
|
||||
attrTab->setChecked(true);
|
||||
auto* grp = new QButtonGroup(this);
|
||||
grp->addButton(attrTab, 0);
|
||||
grp->addButton(nameTab, 1);
|
||||
tabRow->addWidget(attrTab);
|
||||
tabRow->addWidget(nameTab);
|
||||
tabRow->addStretch();
|
||||
root->addLayout(tabRow);
|
||||
|
||||
stack_ = new QStackedWidget(this);
|
||||
auto* attrPage = new QWidget(stack_);
|
||||
auto* namePage = new QWidget(stack_);
|
||||
buildAttributeTab(attrPage);
|
||||
buildNameTab(namePage);
|
||||
stack_->addWidget(attrPage);
|
||||
stack_->addWidget(namePage);
|
||||
root->addWidget(stack_, 1);
|
||||
connect(grp, &QButtonGroup::idClicked, this, [this](int id) { stack_->setCurrentIndex(id); });
|
||||
|
||||
auto* buttons = formkit::addDialogButtons(root, this, QStringLiteral("确定"),
|
||||
QStringLiteral("取消"));
|
||||
// 接管 OK:改走 onSubmit(addDialogButtons 默认接的 accept 会被 onSubmit 内部按需调用)。
|
||||
disconnect(buttons, nullptr, this, nullptr);
|
||||
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
|
||||
connect(buttons, &QDialogButtonBox::accepted, this, &ExceptionTypeDialog::onSubmit);
|
||||
}
|
||||
|
||||
// ── 异常属性 Tab ──────────────────────────────────────────────────────────────
|
||||
void ExceptionTypeDialog::buildAttributeTab(QWidget* page) {
|
||||
auto* lay = new QVBoxLayout(page);
|
||||
lay->setContentsMargins(0, 0, 0, 0);
|
||||
|
||||
auto* form = formkit::makeEditForm();
|
||||
nameEdit_ = new QLineEdit(page);
|
||||
nameEdit_->setPlaceholderText(QStringLiteral("请输入异常类型名称"));
|
||||
form->addRow(formkit::editLabel(QStringLiteral("异常类型名称")), nameEdit_);
|
||||
codeEdit_ = new QLineEdit(page);
|
||||
codeEdit_->setPlaceholderText(QStringLiteral("请输入"));
|
||||
form->addRow(formkit::editLabel(QStringLiteral("代号")), codeEdit_);
|
||||
standardNumberEdit_ = new QLineEdit(page);
|
||||
standardNumberEdit_->setPlaceholderText(QStringLiteral("请输入"));
|
||||
form->addRow(formkit::editLabel(QStringLiteral("标准编号")), standardNumberEdit_);
|
||||
standardNameEdit_ = new QLineEdit(page);
|
||||
standardNameEdit_->setPlaceholderText(QStringLiteral("请输入"));
|
||||
form->addRow(formkit::editLabel(QStringLiteral("标准名称")), standardNameEdit_);
|
||||
descEdit_ = new QPlainTextEdit(page);
|
||||
descEdit_->setFixedHeight(geopro::app::scaledPx(56));
|
||||
form->addRow(formkit::editLabel(QStringLiteral("说明")), descEdit_);
|
||||
lay->addLayout(form);
|
||||
|
||||
// 图例样式:按 markType 选择性显示(对照原版 ACollapseItem v-if 条件)。
|
||||
if (markTypeInt_ == 1) lay->addWidget(buildPointLegend());
|
||||
if (markTypeInt_ == 2 || markTypeInt_ == 3 || markTypeInt_ == 4)
|
||||
lay->addWidget(buildPolylineLegend());
|
||||
if (markTypeInt_ == 3 || markTypeInt_ == 4) lay->addWidget(buildPolygonLegend());
|
||||
lay->addWidget(buildTextLegend()); // 文字图例对所有 markType 显示
|
||||
lay->addStretch();
|
||||
}
|
||||
|
||||
QPushButton* ExceptionTypeDialog::makeColorSwatch(QColor& target) {
|
||||
auto* btn = new QPushButton(this);
|
||||
btn->setText(target.name(QColor::HexArgb));
|
||||
btn->setStyleSheet(QStringLiteral("background-color: rgba(%1,%2,%3,%4);")
|
||||
.arg(target.red())
|
||||
.arg(target.green())
|
||||
.arg(target.blue())
|
||||
.arg(target.alpha()));
|
||||
connect(btn, &QPushButton::clicked, this, [this, btn, &target]() { pickColor(btn, target); });
|
||||
return btn;
|
||||
}
|
||||
|
||||
void ExceptionTypeDialog::pickColor(QPushButton* swatch, QColor& target) {
|
||||
const QColor picked =
|
||||
QColorDialog::getColor(target, this, QStringLiteral("颜色"), QColorDialog::ShowAlphaChannel);
|
||||
if (!picked.isValid()) return;
|
||||
target = picked;
|
||||
swatch->setText(picked.name(QColor::HexArgb));
|
||||
swatch->setStyleSheet(QStringLiteral("background-color: rgba(%1,%2,%3,%4);")
|
||||
.arg(picked.red())
|
||||
.arg(picked.green())
|
||||
.arg(picked.blue())
|
||||
.arg(picked.alpha()));
|
||||
}
|
||||
|
||||
QGroupBox* ExceptionTypeDialog::buildPointLegend() {
|
||||
auto* box = new QGroupBox(QStringLiteral("点"), this);
|
||||
auto* form = formkit::makeEditForm();
|
||||
pointShape_ = new EmptyAwareComboBox(box);
|
||||
fillCombo(pointShape_, kPointShapes, QStringLiteral("circle"));
|
||||
form->addRow(formkit::editLabel(QStringLiteral("形状:")), pointShape_);
|
||||
pointSize_ = new QSpinBox(box);
|
||||
pointSize_->setRange(1, 18);
|
||||
pointSize_->setValue(8);
|
||||
form->addRow(formkit::editLabel(QStringLiteral("大小:")), pointSize_);
|
||||
pointColorBtn_ = makeColorSwatch(pointColor_);
|
||||
form->addRow(formkit::editLabel(QStringLiteral("颜色:")), pointColorBtn_);
|
||||
pointOpacity_ = makeOpacity(100);
|
||||
form->addRow(formkit::editLabel(QStringLiteral("不透明度:")), pointOpacity_);
|
||||
box->setLayout(form);
|
||||
return box;
|
||||
}
|
||||
|
||||
QGroupBox* ExceptionTypeDialog::buildPolylineLegend() {
|
||||
auto* box = new QGroupBox(QStringLiteral("多段线"), this);
|
||||
auto* form = formkit::makeEditForm();
|
||||
polylineShape_ = new EmptyAwareComboBox(box);
|
||||
fillCombo(polylineShape_, kLineShapes, QStringLiteral("solid"));
|
||||
form->addRow(formkit::editLabel(QStringLiteral("线形:")), polylineShape_);
|
||||
polylineWidth_ = new QSpinBox(box);
|
||||
polylineWidth_->setRange(1, 10);
|
||||
polylineWidth_->setValue(1);
|
||||
form->addRow(formkit::editLabel(QStringLiteral("线宽:")), polylineWidth_);
|
||||
polylineColorBtn_ = makeColorSwatch(polylineColor_);
|
||||
form->addRow(formkit::editLabel(QStringLiteral("颜色:")), polylineColorBtn_);
|
||||
polylineOpacity_ = makeOpacity(100);
|
||||
form->addRow(formkit::editLabel(QStringLiteral("不透明度:")), polylineOpacity_);
|
||||
box->setLayout(form);
|
||||
return box;
|
||||
}
|
||||
|
||||
QGroupBox* ExceptionTypeDialog::buildPolygonLegend() {
|
||||
auto* box = new QGroupBox(QStringLiteral("多边形"), this);
|
||||
auto* form = formkit::makeEditForm();
|
||||
polygonFill_ = new EmptyAwareComboBox(box);
|
||||
fillCombo(polygonFill_, kSurfaceFills, QString()); // 默认 '' = 颜色填充
|
||||
form->addRow(formkit::editLabel(QStringLiteral("填充图例:")), polygonFill_);
|
||||
polygonFillColorBtn_ = makeColorSwatch(polygonFillColor_);
|
||||
form->addRow(formkit::editLabel(QStringLiteral("颜色:")), polygonFillColorBtn_);
|
||||
polygonFillOpacity_ = makeOpacity(100);
|
||||
form->addRow(formkit::editLabel(QStringLiteral("不透明度:")), polygonFillOpacity_);
|
||||
box->setLayout(form);
|
||||
return box;
|
||||
}
|
||||
|
||||
QGroupBox* ExceptionTypeDialog::buildTextLegend() {
|
||||
auto* box = new QGroupBox(QStringLiteral("文字"), this);
|
||||
auto* form = formkit::makeEditForm();
|
||||
textFont_ = new EmptyAwareComboBox(box);
|
||||
fillCombo(textFont_, kTextFonts, QStringLiteral("1")); // 默认 1=宋体
|
||||
form->addRow(formkit::editLabel(QStringLiteral("字体:")), textFont_);
|
||||
textSize_ = new QSpinBox(box);
|
||||
textSize_->setRange(8, 24);
|
||||
textSize_->setValue(12);
|
||||
form->addRow(formkit::editLabel(QStringLiteral("大小:")), textSize_);
|
||||
textColorBtn_ = makeColorSwatch(textColor_);
|
||||
form->addRow(formkit::editLabel(QStringLiteral("颜色:")), textColorBtn_);
|
||||
textOpacity_ = makeOpacity(100);
|
||||
form->addRow(formkit::editLabel(QStringLiteral("不透明度:")), textOpacity_);
|
||||
box->setLayout(form);
|
||||
return box;
|
||||
}
|
||||
|
||||
// ── 标注名称 Tab ──────────────────────────────────────────────────────────────
|
||||
void ExceptionTypeDialog::buildNameTab(QWidget* page) {
|
||||
auto* lay = new QVBoxLayout(page);
|
||||
lay->setContentsMargins(0, 0, 0, 0);
|
||||
|
||||
auto* form = formkit::makeEditForm();
|
||||
customFormatEdit_ = new QLineEdit(page);
|
||||
customFormatEdit_->setReadOnly(true);
|
||||
customFormatEdit_->setPlaceholderText(QStringLiteral("请输入自定义格式描述"));
|
||||
form->addRow(formkit::editLabel(QStringLiteral("自定义格式描述")), customFormatEdit_);
|
||||
separatorEdit_ = new QLineEdit(page);
|
||||
separatorEdit_->setPlaceholderText(QStringLiteral("请输入分隔符号"));
|
||||
form->addRow(formkit::editLabel(QStringLiteral("分隔符号")), separatorEdit_);
|
||||
lay->addLayout(form);
|
||||
connect(separatorEdit_, &QLineEdit::textChanged, this,
|
||||
[this](const QString&) { onSeparatorChanged(); });
|
||||
|
||||
lay->addWidget(new QLabel(QStringLiteral("列表:"), page));
|
||||
nameTable_ = new QTableWidget(0, 2, page);
|
||||
nameTable_->setHorizontalHeaderLabels({QStringLiteral("名称"), QStringLiteral("代号")});
|
||||
nameTable_->horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch);
|
||||
lay->addWidget(nameTable_, 1);
|
||||
// 任意单元格改动(含名称编辑)→ 重算自定义格式描述。
|
||||
connect(nameTable_, &QTableWidget::itemChanged, this,
|
||||
[this](QTableWidgetItem*) { recomputeCustomFormat(); });
|
||||
|
||||
auto* rowBtns = new QHBoxLayout();
|
||||
auto* addBtn = new QPushButton(QStringLiteral("新增"), page);
|
||||
auto* delBtn = new QPushButton(QStringLiteral("删除"), page);
|
||||
rowBtns->addWidget(addBtn);
|
||||
rowBtns->addWidget(delBtn);
|
||||
rowBtns->addStretch();
|
||||
lay->addLayout(rowBtns);
|
||||
connect(addBtn, &QPushButton::clicked, this, &ExceptionTypeDialog::addNameRow);
|
||||
connect(delBtn, &QPushButton::clicked, this, &ExceptionTypeDialog::delNameRow);
|
||||
}
|
||||
|
||||
void ExceptionTypeDialog::onSeparatorChanged() { recomputeCustomFormat(); }
|
||||
|
||||
void ExceptionTypeDialog::recomputeCustomFormat() {
|
||||
// 对照原版:自定义格式描述 = 名称列按分隔符号拼接(原版按选中行,此处按全部名称行)。
|
||||
if (!nameTable_ || !customFormatEdit_) return;
|
||||
const QString sep = separatorEdit_ ? separatorEdit_->text() : QString();
|
||||
QStringList names;
|
||||
for (int r = 0; r < nameTable_->rowCount(); ++r) {
|
||||
auto* it = nameTable_->item(r, 0);
|
||||
if (it && !it->text().trimmed().isEmpty()) names << it->text().trimmed();
|
||||
}
|
||||
customFormatEdit_->setText(names.join(sep));
|
||||
}
|
||||
|
||||
QString ExceptionTypeDialog::nextFieldCode() const {
|
||||
// 自动代号 custom_N(N 取当前最大序号 +1,避免与已填代号冲突)。
|
||||
int maxN = 0;
|
||||
for (int r = 0; r < nameTable_->rowCount(); ++r) {
|
||||
auto* it = nameTable_->item(r, 1);
|
||||
if (!it) continue;
|
||||
const QString code = it->text();
|
||||
if (code.startsWith(QStringLiteral("custom_"))) {
|
||||
const int n = code.mid(7).toInt();
|
||||
if (n > maxN) maxN = n;
|
||||
}
|
||||
}
|
||||
return QStringLiteral("custom_%1").arg(maxN + 1);
|
||||
}
|
||||
|
||||
void ExceptionTypeDialog::addNameRow() {
|
||||
const int r = nameTable_->rowCount();
|
||||
nameTable_->insertRow(r);
|
||||
nameTable_->setItem(r, 0, new QTableWidgetItem(QString()));
|
||||
// 代号默认自动生成,用户可手填覆盖。
|
||||
nameTable_->setItem(r, 1, new QTableWidgetItem(nextFieldCode()));
|
||||
}
|
||||
|
||||
void ExceptionTypeDialog::delNameRow() {
|
||||
const int r = nameTable_->currentRow();
|
||||
if (r >= 0) {
|
||||
nameTable_->removeRow(r);
|
||||
recomputeCustomFormat();
|
||||
}
|
||||
}
|
||||
|
||||
// ── 提交 ────────────────────────────────────────────────────────────────────
|
||||
QJsonObject ExceptionTypeDialog::buildLegend() const {
|
||||
// 对照原版 form.legend:整对象提交(与 markType 无关,全字段都带,rgba 用 alpha 0–1 不同,
|
||||
// 这里沿用色块的 HexArgb 颜色串 + 0–100 不透明度,与原版控件取值一致)。
|
||||
auto hex = [](const QColor& c) { return c.name(QColor::HexArgb); };
|
||||
return QJsonObject{
|
||||
{QStringLiteral("pointShape"),
|
||||
pointShape_ ? pointShape_->currentData().toString() : QStringLiteral("circle")},
|
||||
{QStringLiteral("pointSize"), pointSize_ ? pointSize_->value() : 8},
|
||||
{QStringLiteral("pointColor"), hex(pointColor_)},
|
||||
{QStringLiteral("pointNoOpacity"), pointOpacity_ ? pointOpacity_->value() : 100},
|
||||
{QStringLiteral("polylineShape"),
|
||||
polylineShape_ ? polylineShape_->currentData().toString() : QStringLiteral("solid")},
|
||||
{QStringLiteral("polylineWidth"), polylineWidth_ ? polylineWidth_->value() : 1},
|
||||
{QStringLiteral("polylineColor"), hex(polylineColor_)},
|
||||
{QStringLiteral("polylineNoOpacity"), polylineOpacity_ ? polylineOpacity_->value() : 100},
|
||||
{QStringLiteral("polygonFill"),
|
||||
polygonFill_ ? polygonFill_->currentData().toString() : QString()},
|
||||
{QStringLiteral("polygonFillColor"), hex(polygonFillColor_)},
|
||||
{QStringLiteral("polygonFillNoOpacity"),
|
||||
polygonFillOpacity_ ? polygonFillOpacity_->value() : 100},
|
||||
{QStringLiteral("textFont"), textFont_ ? textFont_->currentData().toString() : QString("1")},
|
||||
{QStringLiteral("textSize"), textSize_ ? textSize_->value() : 12},
|
||||
{QStringLiteral("textColor"), hex(textColor_)},
|
||||
{QStringLiteral("textNoOpacity"), textOpacity_ ? textOpacity_->value() : 100},
|
||||
};
|
||||
}
|
||||
|
||||
QJsonObject ExceptionTypeDialog::buildFormData() const {
|
||||
// exceptionNameList:表格行 → {fieldName, fieldCode, sort}(对照原版 handleBeforeOk 映射)。
|
||||
QJsonArray nameList;
|
||||
for (int r = 0; r < nameTable_->rowCount(); ++r) {
|
||||
auto* nameItem = nameTable_->item(r, 0);
|
||||
if (!nameItem || nameItem->text().trimmed().isEmpty()) continue;
|
||||
auto* codeItem = nameTable_->item(r, 1);
|
||||
nameList.append(QJsonObject{
|
||||
{QStringLiteral("fieldName"), nameItem->text().trimmed()},
|
||||
{QStringLiteral("fieldCode"), codeItem ? codeItem->text().trimmed() : QString()},
|
||||
{QStringLiteral("sort"), nameList.size()},
|
||||
});
|
||||
}
|
||||
return QJsonObject{
|
||||
{QStringLiteral("exceptionTypeName"), nameEdit_->text().trimmed()},
|
||||
{QStringLiteral("exceptionTypeCode"), codeEdit_->text().trimmed()},
|
||||
{QStringLiteral("standardNumber"), standardNumberEdit_->text().trimmed()},
|
||||
{QStringLiteral("standardName"), standardNameEdit_->text().trimmed()},
|
||||
{QStringLiteral("description"), descEdit_->toPlainText()},
|
||||
{QStringLiteral("legend"), buildLegend()},
|
||||
{QStringLiteral("exceptionNameList"), nameList},
|
||||
{QStringLiteral("customFormat"), customFormatEdit_->text()},
|
||||
{QStringLiteral("separatorSymbol"), separatorEdit_->text()},
|
||||
{QStringLiteral("projectId"), projectId_},
|
||||
{QStringLiteral("exceptionMarkType"), markType_},
|
||||
{QStringLiteral("type"), 2},
|
||||
};
|
||||
}
|
||||
|
||||
void ExceptionTypeDialog::onSubmit() {
|
||||
if (!repo_) { reject(); return; }
|
||||
// 校验:代号(exceptionTypeCode) 必填(对照原版 validateForm)。
|
||||
if (codeEdit_->text().trimmed().isEmpty()) {
|
||||
QMessageBox::warning(this, windowTitle(), QStringLiteral("请完善异常属性信息"));
|
||||
return;
|
||||
}
|
||||
// 若处于「标注名称」Tab:至少一个名称(对照原版 labelTag==name 分支校验)。
|
||||
if (stack_->currentIndex() == 1) {
|
||||
bool hasName = false;
|
||||
for (int r = 0; r < nameTable_->rowCount(); ++r) {
|
||||
auto* it = nameTable_->item(r, 0);
|
||||
if (it && !it->text().trimmed().isEmpty()) { hasName = true; break; }
|
||||
}
|
||||
if (!hasName) {
|
||||
QMessageBox::warning(this, windowTitle(), QStringLiteral("请添加至少一个标注名称"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const QJsonObject body = buildFormData();
|
||||
QPointer<ExceptionTypeDialog> self(this);
|
||||
repo_->addExceptionType(body, [self](bool ok, QString msg) {
|
||||
if (!self) return;
|
||||
if (ok) {
|
||||
self->createdTypeName_ = self->nameEdit_->text().trimmed();
|
||||
QMessageBox::information(self, self->windowTitle(),
|
||||
QStringLiteral("新增异常类型成功!"));
|
||||
self->accept();
|
||||
} else {
|
||||
QMessageBox::warning(self, self->windowTitle(),
|
||||
msg.isEmpty() ? QStringLiteral("新增异常类型失败!") : msg);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace geopro::app
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
#pragma once
|
||||
#include <functional>
|
||||
|
||||
#include <QColor>
|
||||
#include <QDialog>
|
||||
#include <QJsonObject>
|
||||
#include <QString>
|
||||
|
||||
class QComboBox;
|
||||
class QGroupBox;
|
||||
class QLineEdit;
|
||||
class QPlainTextEdit;
|
||||
class QPushButton;
|
||||
class QSpinBox;
|
||||
class QStackedWidget;
|
||||
class QTableWidget;
|
||||
class QWidget;
|
||||
|
||||
namespace geopro::data {
|
||||
class IDatasetCommandRepository;
|
||||
}
|
||||
|
||||
namespace geopro::app {
|
||||
|
||||
// 新建异常类型对话框(1:1 复刻原版 ExceptionLabel/index.vue + ExceptionAttribute.vue + LabelName.vue)。
|
||||
// 880px 宽,标题「标注类型」;顶部 RadioGroup(按钮态) 双 Tab:异常属性 / 标注名称。
|
||||
// - 异常属性:异常类型名称 / 代号(exceptionTypeCode 必填) / 标准编号 / 标准名称 / 说明,
|
||||
// 以及按 markType(点1/线2/面3/文字4) 显示的图例样式编辑器(点形状·大小·颜色·不透明度 /
|
||||
// 多段线线形·线宽·颜色·不透明度 / 多边形填充·颜色·不透明度 / 文字字体·大小·颜色·不透明度)。
|
||||
// - 标注名称:分隔符号 + 自定义格式描述(只读,按分隔符拼接)+ 可增删的名称表(fieldName/fieldCode)。
|
||||
// 提交 handleBeforeOk:组装 formData = {...异常属性, exceptionNameList(映射 sort), customFormat,
|
||||
// separatorSymbol, projectId, exceptionMarkType:markType, type:2} → addExceptionType。
|
||||
class ExceptionTypeDialog : public QDialog {
|
||||
Q_OBJECT
|
||||
public:
|
||||
// markType:"1".."4"(与 ExceptionDialog::markTypeValue / 原版 exceptionMarkType 一致)。
|
||||
ExceptionTypeDialog(geopro::data::IDatasetCommandRepository* repo, QString projectId,
|
||||
QString markType, QWidget* parent = nullptr);
|
||||
|
||||
// accept() 后供调用方读取(用于刷新下拉后按名称选中新建项)。
|
||||
QString createdTypeName() const { return createdTypeName_; }
|
||||
|
||||
private:
|
||||
void buildAttributeTab(QWidget* page); // 异常属性 Tab(表单 + 按 markType 的图例分组)
|
||||
void buildNameTab(QWidget* page); // 标注名称 Tab(分隔符 + 自定义格式 + 名称表)
|
||||
// 图例分组构建器(按 markType 选择性创建,单一职责拆分以控函数行数)。
|
||||
QGroupBox* buildPointLegend(); // 点(markType==1)
|
||||
QGroupBox* buildPolylineLegend(); // 多段线(markType∈{2,3,4})
|
||||
QGroupBox* buildPolygonLegend(); // 多边形(markType∈{3,4})
|
||||
QGroupBox* buildTextLegend(); // 文字(所有 markType)
|
||||
|
||||
void pickColor(QPushButton* swatch, QColor& target); // 色块 → QColorDialog → 回填
|
||||
QPushButton* makeColorSwatch(QColor& target); // 创建已接 pickColor 的色块按钮
|
||||
void onSeparatorChanged(); // 分隔符变 → 重算自定义格式描述(选中名称按分隔符拼接)
|
||||
void recomputeCustomFormat();
|
||||
void addNameRow(); // 名称表加一行(fieldName 手填,fieldCode 自动)
|
||||
void delNameRow(); // 删选中行
|
||||
QString nextFieldCode() const; // 自动 fieldCode("custom_"+序号,去重)
|
||||
|
||||
QJsonObject buildLegend() const; // 按 markType 组装 legend 子对象(对照原版默认结构)
|
||||
QJsonObject buildFormData() const; // 组装完整提交体
|
||||
void onSubmit(); // 校验(代号必填) → addExceptionType → 成功 accept()
|
||||
|
||||
geopro::data::IDatasetCommandRepository* repo_ = nullptr;
|
||||
QString projectId_;
|
||||
QString markType_;
|
||||
int markTypeInt_ = 1;
|
||||
QString createdTypeName_; // 提交成功后记录的异常类型名称(供刷新下拉选中)
|
||||
|
||||
QStackedWidget* stack_ = nullptr; // 双 Tab 内容容器
|
||||
|
||||
// ── 异常属性表单 ──
|
||||
QLineEdit* nameEdit_ = nullptr;
|
||||
QLineEdit* codeEdit_ = nullptr;
|
||||
QLineEdit* standardNumberEdit_ = nullptr;
|
||||
QLineEdit* standardNameEdit_ = nullptr;
|
||||
QPlainTextEdit* descEdit_ = nullptr;
|
||||
|
||||
// 点图例
|
||||
QComboBox* pointShape_ = nullptr;
|
||||
QSpinBox* pointSize_ = nullptr;
|
||||
QColor pointColor_;
|
||||
QPushButton* pointColorBtn_ = nullptr;
|
||||
QSpinBox* pointOpacity_ = nullptr;
|
||||
// 多段线图例
|
||||
QComboBox* polylineShape_ = nullptr;
|
||||
QSpinBox* polylineWidth_ = nullptr;
|
||||
QColor polylineColor_;
|
||||
QPushButton* polylineColorBtn_ = nullptr;
|
||||
QSpinBox* polylineOpacity_ = nullptr;
|
||||
// 多边形图例
|
||||
QComboBox* polygonFill_ = nullptr;
|
||||
QColor polygonFillColor_;
|
||||
QPushButton* polygonFillColorBtn_ = nullptr;
|
||||
QSpinBox* polygonFillOpacity_ = nullptr;
|
||||
// 文字图例
|
||||
QComboBox* textFont_ = nullptr;
|
||||
QSpinBox* textSize_ = nullptr;
|
||||
QColor textColor_;
|
||||
QPushButton* textColorBtn_ = nullptr;
|
||||
QSpinBox* textOpacity_ = nullptr;
|
||||
|
||||
// ── 标注名称 Tab ──
|
||||
QLineEdit* customFormatEdit_ = nullptr; // 只读
|
||||
QLineEdit* separatorEdit_ = nullptr;
|
||||
QTableWidget* nameTable_ = nullptr; // 列:名称(fieldName) / 代号(fieldCode)
|
||||
};
|
||||
|
||||
} // namespace geopro::app
|
||||
|
|
@ -3,7 +3,10 @@
|
|||
#include <utility>
|
||||
|
||||
#include <QComboBox>
|
||||
#include <QFormLayout>
|
||||
|
||||
#include "EmptyAwareComboBox.hpp"
|
||||
#include <QDialogButtonBox>
|
||||
#include <QFrame>
|
||||
#include <QHBoxLayout>
|
||||
#include <QHeaderView>
|
||||
#include <QHash>
|
||||
|
|
@ -20,14 +23,15 @@
|
|||
#include <QTreeWidget>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
#include "FormKit.hpp"
|
||||
#include "FormKit.hpp" // addDialogButtons / addSection / editLabel
|
||||
#include "Theme.hpp"
|
||||
#include "panels/chart/InversionProcessOps.hpp" // buildFilterApplyBody / buildNewFilterBody
|
||||
#include "repo/IDatasetCommandRepository.hpp"
|
||||
|
||||
namespace geopro::app {
|
||||
|
||||
namespace {
|
||||
constexpr double kFillRange = 1e9;
|
||||
constexpr int kDialogW = 900; // 原版弹窗宽 900px
|
||||
constexpr int kMatrixMin = 1, kMatrixMax = 21; // 矩阵行列范围(对照原版 1~21)
|
||||
constexpr int kDefaultDim = 3;
|
||||
const char kDefaultCustomKey[] = "default-custom-filter"; // 默认自定义滤波器(不可删)
|
||||
|
|
@ -40,101 +44,60 @@ double cellValue(const QTableWidgetItem* it) {
|
|||
const double v = it->text().toDouble(&ok);
|
||||
return ok ? v : 0.0;
|
||||
}
|
||||
|
||||
// 分组小标题:走 §7.0.10 唯一实现 formkit::addSection(heading 半粗 + 标题下 1px divider)。
|
||||
void addSpecTitle(QVBoxLayout* into, const QString& title, QWidget* parent) {
|
||||
formkit::addSection(into, title, parent, /*topGap=*/false);
|
||||
}
|
||||
|
||||
// 原版带边框卡片(1px 边框 + 圆角 + 内距)。
|
||||
QFrame* cardFrame(QWidget* parent) {
|
||||
auto* card = new QFrame(parent);
|
||||
card->setFrameShape(QFrame::StyledPanel);
|
||||
return card;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
FilterDialog::FilterDialog(geopro::data::IDatasetCommandRepository* repo, QString dsId,
|
||||
QString projectId, QWidget* parent)
|
||||
: QDialog(parent), repo_(repo), dsId_(std::move(dsId)), projectId_(std::move(projectId)) {
|
||||
setWindowTitle(QStringLiteral("滤波处理"));
|
||||
setWindowTitle(QStringLiteral("滤波设置")); // 原版 filterSetting
|
||||
setModal(true);
|
||||
resize(820, 520);
|
||||
setFixedWidth(kDialogW);
|
||||
|
||||
auto* root = formkit::dialogRoot(this);
|
||||
auto* root = new QVBoxLayout(this);
|
||||
root->setContentsMargins(geopro::app::space::kLg, geopro::app::space::kLg,
|
||||
geopro::app::space::kLg, geopro::app::space::kLg);
|
||||
root->setSpacing(geopro::app::space::kMd);
|
||||
auto* body = new QHBoxLayout();
|
||||
body->setSpacing(geopro::app::space::kLg);
|
||||
root->addLayout(body, 1);
|
||||
|
||||
// ── 左:滤波器树 + 增删按钮 ─────────────────────────────────────────
|
||||
auto* leftLay = new QVBoxLayout();
|
||||
tree_ = new QTreeWidget(this);
|
||||
tree_->setHeaderHidden(true);
|
||||
leftLay->addWidget(tree_, 1);
|
||||
auto* treeBtnLay = new QHBoxLayout();
|
||||
auto* addBtn = new QPushButton(QStringLiteral("另存为"), this);
|
||||
auto* delBtn = new QPushButton(QStringLiteral("删除"), this);
|
||||
treeBtnLay->addWidget(addBtn);
|
||||
treeBtnLay->addWidget(delBtn);
|
||||
leftLay->addLayout(treeBtnLay);
|
||||
body->addLayout(leftLay, 1);
|
||||
buildLeft(body);
|
||||
buildRight(body);
|
||||
|
||||
// ── 右:配置面板 ────────────────────────────────────────────────────
|
||||
auto* rightLay = new QVBoxLayout();
|
||||
auto* form = formkit::makeEditForm();
|
||||
dataEdge_ = new QComboBox(this);
|
||||
dataEdge_->addItem(QStringLiteral("设为无效点"), QStringLiteral("whitening"));
|
||||
dataEdge_->addItem(QStringLiteral("忽略"), QStringLiteral("skip"));
|
||||
dataEdge_->addItem(QStringLiteral("复制边缘点"), QStringLiteral("edgePoint"));
|
||||
dataEdge_->addItem(QStringLiteral("填充"), QStringLiteral("filling"));
|
||||
dataEdgeValue_ = new QLineEdit(this);
|
||||
dataEdgeValue_->setEnabled(false);
|
||||
noDataPoints_ = new QComboBox(this);
|
||||
noDataPoints_->addItem(QStringLiteral("扩展"), QStringLiteral("expansion"));
|
||||
noDataPoints_->addItem(QStringLiteral("保留"), QStringLiteral("retain"));
|
||||
noDataPoints_->addItem(QStringLiteral("跳过"), QStringLiteral("skip"));
|
||||
noDataPoints_->addItem(QStringLiteral("填充"), QStringLiteral("filling"));
|
||||
noDataPoints_->setCurrentIndex(3); // 默认填充(对照原版)
|
||||
noDataValue_ = new QLineEdit(this);
|
||||
filterTimes_ = new QSpinBox(this);
|
||||
filterTimes_->setRange(1, 10);
|
||||
rows_ = new QSpinBox(this);
|
||||
rows_->setRange(kMatrixMin, kMatrixMax);
|
||||
rows_->setValue(kDefaultDim);
|
||||
cols_ = new QSpinBox(this);
|
||||
cols_->setRange(kMatrixMin, kMatrixMax);
|
||||
cols_->setValue(kDefaultDim);
|
||||
formkit::capField(dataEdge_);
|
||||
formkit::capField(dataEdgeValue_);
|
||||
formkit::capField(noDataPoints_);
|
||||
formkit::capField(noDataValue_);
|
||||
formkit::capField(filterTimes_);
|
||||
formkit::capField(rows_);
|
||||
formkit::capField(cols_);
|
||||
form->addRow(formkit::editLabel(QStringLiteral("数据边缘:")), dataEdge_);
|
||||
form->addRow(formkit::editLabel(QStringLiteral("数据边缘值:")), dataEdgeValue_);
|
||||
form->addRow(formkit::editLabel(QStringLiteral("无数据点:")), noDataPoints_);
|
||||
form->addRow(formkit::editLabel(QStringLiteral("无数据点值:")), noDataValue_);
|
||||
form->addRow(formkit::editLabel(QStringLiteral("滤波次数:")), filterTimes_);
|
||||
form->addRow(formkit::editLabel(QStringLiteral("行:")), rows_);
|
||||
form->addRow(formkit::editLabel(QStringLiteral("列:")), cols_);
|
||||
rightLay->addLayout(form);
|
||||
matrix_ = new QTableWidget(kDefaultDim, kDefaultDim, this);
|
||||
matrix_->horizontalHeader()->setVisible(false);
|
||||
matrix_->verticalHeader()->setVisible(false);
|
||||
rightLay->addWidget(matrix_, 1);
|
||||
body->addLayout(rightLay, 2);
|
||||
|
||||
// 底部按钮。
|
||||
auto* btnLay = new QHBoxLayout();
|
||||
btnLay->addStretch();
|
||||
auto* cancelBtn = new QPushButton(QStringLiteral("取消"), this);
|
||||
okBtn_ = new QPushButton(QStringLiteral("应用"), this);
|
||||
okBtn_->setDefault(true);
|
||||
btnLay->addWidget(cancelBtn);
|
||||
btnLay->addWidget(okBtn_);
|
||||
root->addLayout(btnLay);
|
||||
// 规范 §7.5 底部操作栏:右对齐 取消(次) + 确认(主);「保存设置」为次按钮
|
||||
// 经 ActionRole 落在左侧(QDialogButtonBox 自动把 ActionRole 排到主操作左边),不抢 primary。
|
||||
auto* box = formkit::addDialogButtons(root, this, QStringLiteral("确认"), QStringLiteral("取消"));
|
||||
okBtn_ = box->button(QDialogButtonBox::Ok);
|
||||
auto* saveSettingBtn = box->addButton(QStringLiteral("保存设置"), QDialogButtonBox::ActionRole);
|
||||
// 确认需异步 applyFilter 成功才关闭 → 断开默认 accept,改接 onConfirm。
|
||||
QObject::disconnect(box, &QDialogButtonBox::accepted, this, &QDialog::accept);
|
||||
connect(okBtn_, &QPushButton::clicked, this, &FilterDialog::onConfirm);
|
||||
|
||||
resizeMatrix(); // 默认 3x3 中心 1
|
||||
if (auto* c = matrix_->item(1, 1)) c->setText(QStringLiteral("1"));
|
||||
|
||||
connect(cancelBtn, &QPushButton::clicked, this, &QDialog::reject);
|
||||
connect(okBtn_, &QPushButton::clicked, this, &FilterDialog::onConfirm);
|
||||
connect(addBtn, &QPushButton::clicked, this, &FilterDialog::saveCustomFilter);
|
||||
connect(delBtn, &QPushButton::clicked, this, &FilterDialog::deleteSelectedFilter);
|
||||
connect(saveSettingBtn, &QPushButton::clicked, this, &FilterDialog::saveCustomFilter);
|
||||
connect(tree_, &QTreeWidget::itemSelectionChanged, this,
|
||||
&FilterDialog::onTreeSelectionChanged);
|
||||
// 行/列:仅奇数允许,偶数弹警告并回退旧值(对照原版 watch rows/cols)。
|
||||
prevRows_ = kDefaultDim;
|
||||
prevCols_ = kDefaultDim;
|
||||
connect(rows_, QOverload<int>::of(&QSpinBox::valueChanged), this,
|
||||
[this](int) { resizeMatrix(); });
|
||||
[this](int v) { onDimChanged(rows_, v, prevRows_, QStringLiteral("行")); });
|
||||
connect(cols_, QOverload<int>::of(&QSpinBox::valueChanged), this,
|
||||
[this](int) { resizeMatrix(); });
|
||||
[this](int v) { onDimChanged(cols_, v, prevCols_, QStringLiteral("列")); });
|
||||
// 数据边缘/无数据点:仅「填充」启用对应值输入框(对照原版 v-if=filling)。
|
||||
connect(dataEdge_, QOverload<int>::of(&QComboBox::currentIndexChanged), this, [this](int) {
|
||||
dataEdgeValue_->setEnabled(dataEdge_->currentData().toString() ==
|
||||
|
|
@ -149,6 +112,123 @@ FilterDialog::FilterDialog(geopro::data::IDatasetCommandRepository* repo, QStrin
|
|||
loadFilters();
|
||||
}
|
||||
|
||||
void FilterDialog::buildLeft(QHBoxLayout* body) {
|
||||
auto* card = cardFrame(this);
|
||||
card->setMinimumWidth(270); // 原版左卡片 min-width:270px
|
||||
auto* leftLay = new QVBoxLayout(card);
|
||||
leftLay->setContentsMargins(geopro::app::space::kLg, geopro::app::space::kLg,
|
||||
geopro::app::space::kLg, geopro::app::space::kLg);
|
||||
auto* title = new QLabel(QStringLiteral("滤波方式:"), card); // 原版 filterType:
|
||||
auto tf = title->font();
|
||||
tf.setBold(true);
|
||||
title->setFont(tf);
|
||||
leftLay->addWidget(title);
|
||||
tree_ = new QTreeWidget(card);
|
||||
tree_->setHeaderHidden(true);
|
||||
leftLay->addWidget(tree_, 1);
|
||||
auto* treeBtnLay = new QHBoxLayout();
|
||||
auto* addBtn = new QPushButton(QStringLiteral("另存为"), card);
|
||||
auto* delBtn = new QPushButton(QStringLiteral("删除"), card);
|
||||
treeBtnLay->addWidget(addBtn);
|
||||
treeBtnLay->addWidget(delBtn);
|
||||
leftLay->addLayout(treeBtnLay);
|
||||
body->addWidget(card, 3); // 左 ~30%
|
||||
|
||||
connect(addBtn, &QPushButton::clicked, this, &FilterDialog::saveCustomFilter);
|
||||
connect(delBtn, &QPushButton::clicked, this, &FilterDialog::deleteSelectedFilter);
|
||||
}
|
||||
|
||||
void FilterDialog::buildRight(QHBoxLayout* body) {
|
||||
auto* card = cardFrame(this);
|
||||
auto* rightLay = new QVBoxLayout(card);
|
||||
rightLay->setContentsMargins(geopro::app::space::kLg, geopro::app::space::kLg,
|
||||
geopro::app::space::kLg, geopro::app::space::kLg);
|
||||
rightLay->setSpacing(geopro::app::space::kMd);
|
||||
|
||||
// 滤波设置分组。
|
||||
addSpecTitle(rightLay, QStringLiteral("滤波设置"), card);
|
||||
dataEdge_ = new EmptyAwareComboBox(card);
|
||||
dataEdge_->addItem(QStringLiteral("设置为无效点"), QStringLiteral("whitening"));
|
||||
dataEdge_->addItem(QStringLiteral("忽略"), QStringLiteral("skip"));
|
||||
dataEdge_->addItem(QStringLiteral("复制边缘点"), QStringLiteral("edgePoint"));
|
||||
dataEdge_->addItem(QStringLiteral("填充"), QStringLiteral("filling"));
|
||||
dataEdgeValue_ = new QLineEdit(card);
|
||||
dataEdgeValue_->setEnabled(false);
|
||||
noDataPoints_ = new EmptyAwareComboBox(card);
|
||||
noDataPoints_->addItem(QStringLiteral("扩展"), QStringLiteral("expansion"));
|
||||
noDataPoints_->addItem(QStringLiteral("保留"), QStringLiteral("retain"));
|
||||
noDataPoints_->addItem(QStringLiteral("忽略"), QStringLiteral("skip")); // 原版 skip → 忽略
|
||||
noDataPoints_->addItem(QStringLiteral("填充"), QStringLiteral("filling"));
|
||||
noDataPoints_->setCurrentIndex(3); // 默认填充(对照原版)
|
||||
noDataValue_ = new QLineEdit(card);
|
||||
filterTimes_ = new QSpinBox(card);
|
||||
filterTimes_->setRange(1, 10);
|
||||
|
||||
rightLay->addLayout(settingRow(QStringLiteral("数据边缘:"), dataEdge_,
|
||||
QStringLiteral("值:"), dataEdgeValue_, card));
|
||||
rightLay->addLayout(settingRow(QStringLiteral("无数据点:"), noDataPoints_,
|
||||
QStringLiteral("值:"), noDataValue_, card));
|
||||
rightLay->addLayout(settingRow(QStringLiteral("滤波次数:"), filterTimes_,
|
||||
QString(), nullptr, card));
|
||||
|
||||
// 滤波器规格分组。
|
||||
addSpecTitle(rightLay, QStringLiteral("滤波器规格"), card);
|
||||
rows_ = new QSpinBox(card);
|
||||
rows_->setRange(kMatrixMin, kMatrixMax);
|
||||
rows_->setValue(kDefaultDim);
|
||||
cols_ = new QSpinBox(card);
|
||||
cols_->setRange(kMatrixMin, kMatrixMax);
|
||||
cols_->setValue(kDefaultDim);
|
||||
auto* specsRow = new QHBoxLayout();
|
||||
auto* rowLbl = new QLabel(QStringLiteral("行:"), card);
|
||||
rowLbl->setMinimumWidth(30);
|
||||
specsRow->addWidget(rowLbl);
|
||||
specsRow->addWidget(rows_);
|
||||
specsRow->addSpacing(geopro::app::space::kLg);
|
||||
auto* colLbl = new QLabel(QStringLiteral("列:"), card);
|
||||
colLbl->setMinimumWidth(30);
|
||||
specsRow->addWidget(colLbl);
|
||||
specsRow->addWidget(cols_);
|
||||
specsRow->addStretch();
|
||||
rightLay->addLayout(specsRow);
|
||||
|
||||
matrix_ = new QTableWidget(kDefaultDim, kDefaultDim, card);
|
||||
matrix_->horizontalHeader()->setVisible(true); // 原版矩阵带行列号表头
|
||||
matrix_->verticalHeader()->setVisible(true);
|
||||
rightLay->addWidget(matrix_, 1);
|
||||
body->addWidget(card, 6); // 右 ~60%
|
||||
}
|
||||
|
||||
// 设置行:定宽右标签列(§7.0.2 editLabel)+ 主控件 [+ 右侧「值:」标签 + 值框]。
|
||||
QHBoxLayout* FilterDialog::settingRow(const QString& label, QWidget* main, const QString& valLabel,
|
||||
QWidget* valField, QWidget* parent) {
|
||||
auto* row = new QHBoxLayout();
|
||||
row->setSpacing(geopro::app::space::kMd);
|
||||
row->addWidget(formkit::editLabel(label, parent));
|
||||
row->addWidget(main, 3);
|
||||
if (valField) {
|
||||
row->addSpacing(geopro::app::space::kLg);
|
||||
row->addWidget(new QLabel(valLabel, parent));
|
||||
row->addWidget(valField, 3);
|
||||
} else {
|
||||
row->addStretch(4);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
void FilterDialog::onDimChanged(QSpinBox* box, int newVal, int& prev, const QString& which) {
|
||||
if (newVal % 2 == 0) { // 偶数 → 警告并回退(对照原版 watch 回退旧值)
|
||||
QMessageBox::warning(
|
||||
this, windowTitle(),
|
||||
QStringLiteral("滤波矩阵%1数必须为奇数,以确保有唯一的中心点").arg(which));
|
||||
QSignalBlocker b(box);
|
||||
box->setValue(prev);
|
||||
return;
|
||||
}
|
||||
prev = newVal;
|
||||
resizeMatrix();
|
||||
}
|
||||
|
||||
void FilterDialog::loadFilters() {
|
||||
if (!repo_) return;
|
||||
QPointer<FilterDialog> self(this);
|
||||
|
|
@ -226,6 +306,12 @@ void FilterDialog::resizeMatrix() {
|
|||
std::vector<std::vector<double>> old = readMatrix();
|
||||
matrix_->setRowCount(r);
|
||||
matrix_->setColumnCount(c);
|
||||
// 行列号表头(1..n)。
|
||||
QStringList hh, vh;
|
||||
for (int j = 0; j < c; ++j) hh << QString::number(j + 1);
|
||||
for (int i = 0; i < r; ++i) vh << QString::number(i + 1);
|
||||
matrix_->setHorizontalHeaderLabels(hh);
|
||||
matrix_->setVerticalHeaderLabels(vh);
|
||||
for (int i = 0; i < r; ++i)
|
||||
for (int j = 0; j < c; ++j) {
|
||||
auto* it = matrix_->item(i, j);
|
||||
|
|
@ -272,8 +358,8 @@ void FilterDialog::saveCustomFilter() {
|
|||
return;
|
||||
}
|
||||
bool ok = false;
|
||||
const QString name = QInputDialog::getText(this, QStringLiteral("另存为新自定义滤波器"),
|
||||
QStringLiteral("名称:"), QLineEdit::Normal,
|
||||
const QString name = QInputDialog::getText(this, QStringLiteral("保存为新的自定义滤波器"),
|
||||
QStringLiteral("请输入滤波器名称"), QLineEdit::Normal,
|
||||
QStringLiteral("自定义滤波器1"), &ok);
|
||||
if (!ok || name.trimmed().isEmpty()) return;
|
||||
QPointer<FilterDialog> self(this);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ class QTreeWidget;
|
|||
class QTreeWidgetItem;
|
||||
class QTableWidget;
|
||||
class QLineEdit;
|
||||
class QHBoxLayout;
|
||||
class QWidget;
|
||||
|
||||
namespace geopro::data {
|
||||
class IDatasetCommandRepository;
|
||||
|
|
@ -31,6 +33,11 @@ public:
|
|||
QWidget* parent = nullptr);
|
||||
|
||||
private:
|
||||
void buildLeft(QHBoxLayout* body); // 左:滤波方式树卡片
|
||||
void buildRight(QHBoxLayout* body); // 右:滤波设置 + 滤波器规格卡片
|
||||
QHBoxLayout* settingRow(const QString& label, QWidget* main, const QString& valLabel,
|
||||
QWidget* valField, QWidget* parent); // 原版 .setting-row
|
||||
void onDimChanged(QSpinBox* box, int newVal, int& prev, const QString& which); // 奇偶校验
|
||||
void loadFilters(); // 拉滤波器树
|
||||
void buildTree(); // 由 flatItems_ 建树
|
||||
void onTreeSelectionChanged(); // 选中叶节点 → 右侧回填
|
||||
|
|
@ -56,6 +63,8 @@ private:
|
|||
QSpinBox* filterTimes_ = nullptr;
|
||||
QSpinBox* rows_ = nullptr;
|
||||
QSpinBox* cols_ = nullptr;
|
||||
int prevRows_ = 3; // 奇偶校验回退用(上一合法行数)
|
||||
int prevCols_ = 3; // 上一合法列数
|
||||
QTableWidget* matrix_ = nullptr;
|
||||
|
||||
QPushButton* okBtn_ = nullptr;
|
||||
|
|
|
|||
|
|
@ -4,11 +4,14 @@
|
|||
|
||||
#include <QCheckBox>
|
||||
#include <QCursor>
|
||||
#include <QHash>
|
||||
#include <QHBoxLayout>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonObject>
|
||||
#include <QMessageBox>
|
||||
#include <QPointF>
|
||||
#include <QPointer>
|
||||
#include <vector>
|
||||
#include <QSignalBlocker>
|
||||
#include <QLabel>
|
||||
#include <QSlider>
|
||||
|
|
@ -33,10 +36,12 @@
|
|||
#include "panels/chart/AutoAnnotationDialog.hpp"
|
||||
#include "panels/chart/ColorBarWidget.hpp"
|
||||
#include "panels/chart/ColorMapService.hpp"
|
||||
#include "panels/chart/ContourDrawTool.hpp"
|
||||
#include "panels/chart/ContourHoverTip.hpp"
|
||||
#include "panels/chart/ContourPlotItem.hpp"
|
||||
#include "panels/chart/ExceptionDetailDialog.hpp"
|
||||
#include "panels/chart/ExceptionDialog.hpp"
|
||||
#include "panels/chart/ExceptionTextDialog.hpp"
|
||||
#include "panels/chart/FilterDialog.hpp"
|
||||
#include "panels/chart/GridWizardDialog.hpp"
|
||||
#include "panels/chart/LivePanner.hpp"
|
||||
|
|
@ -119,10 +124,11 @@ GridDataChartView::GridDataChartView(QWidget* parent) : QWidget(parent) {
|
|||
tbLay->addWidget(lblSimplify);
|
||||
tbLay->addWidget(simplifySlider_);
|
||||
tbLay->addWidget(simplifyValueLabel_);
|
||||
// 原版 .right-buttons margin-left:auto:异常标注/自动标注/另存为 右对齐。
|
||||
tbLay->addStretch();
|
||||
tbLay->addWidget(btnAnomalyLabel);
|
||||
tbLay->addWidget(btnAutoLabel);
|
||||
tbLay->addWidget(btnSaveAs);
|
||||
tbLay->addStretch();
|
||||
|
||||
lay->addWidget(toolbar);
|
||||
|
||||
|
|
@ -217,13 +223,16 @@ GridDataChartView::GridDataChartView(QWidget* parent) : QWidget(parent) {
|
|||
|
||||
// 描述保存(I14)。
|
||||
connect(descriptionPanel_, &DescriptionPanel::saveRequested, this,
|
||||
[this](const QString& t) { saveDescription(t); });
|
||||
[this]() { saveDescription(); });
|
||||
|
||||
// I7 显示等值线提示信息:hover tooltip 显隐(本地,挂画布事件过滤器)。
|
||||
contourTip_ = new ContourHoverTip(plot_, QwtPlot::xBottom, QwtPlot::yLeft, this);
|
||||
connect(chkContourTip, &QCheckBox::toggled, this,
|
||||
[this](bool on) { if (contourTip_) contourTip_->setEnabled(on); });
|
||||
|
||||
// I9 图上绘形工具(后装于 LivePanner/hover,绘制期优先消费事件)。默认空闲。
|
||||
drawTool_ = new ContourDrawTool(plot_, QwtPlot::xBottom, QwtPlot::yLeft, this);
|
||||
|
||||
// 主题配色:当前主题套一次 + 监听切换热更新。
|
||||
applyChartPlotTheme(plot_);
|
||||
QObject::connect(&ThemeManager::instance(), &ThemeManager::changed, plot_,
|
||||
|
|
@ -241,6 +250,7 @@ void GridDataChartView::setPayload(const QVariant& payload) {
|
|||
return;
|
||||
}
|
||||
const auto p = payload.value<geopro::core::ContourPayload>();
|
||||
lvlTemplateId_ = p.templateId; // 色阶模板 id(保存/覆盖回带,对照原版 lvlTemplateId)
|
||||
setGridData(p.grid, p.scale, p.anomalies);
|
||||
}
|
||||
|
||||
|
|
@ -309,8 +319,9 @@ void GridDataChartView::openColorScaleEditor() {
|
|||
|
||||
// projectId 在打开时取一次(随项目切换生效);无 getter 退化为空 → 后端按钮禁用。
|
||||
const QString projectId = projectIdGetter_ ? projectIdGetter_() : QString();
|
||||
// 传入网格色阶模板 id(getDetail type2 顶层 templateId)→ 「另存为覆盖」可用(对照原版 lvlTemplateId)。
|
||||
ColorScaleConfigDialog dlg(gridScale_, grid_.vmin, grid_.vmax, std::move(samples), lineCfg_,
|
||||
tplRepo_, projectId, this);
|
||||
tplRepo_, projectId, lvlTemplateId_, this);
|
||||
if (dlg.exec() != QDialog::Accepted) return;
|
||||
|
||||
gridScale_ = dlg.colorScale();
|
||||
|
|
@ -359,6 +370,7 @@ void GridDataChartView::reloadGrid() {
|
|||
msg.isEmpty() ? QStringLiteral("重载失败") : msg);
|
||||
return;
|
||||
}
|
||||
self->lvlTemplateId_ = p.templateId; // 重载后同步模板 id(色阶覆盖回带)
|
||||
self->setGridData(p.grid, p.scale, p.anomalies);
|
||||
});
|
||||
}
|
||||
|
|
@ -374,8 +386,10 @@ void GridDataChartView::openWhitening() {
|
|||
const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString();
|
||||
const QString projectId = projectIdGetter_ ? projectIdGetter_() : QString();
|
||||
if (!cmdRepo_ || dsId.isEmpty()) { showNotImplemented(nullptr); return; }
|
||||
// tmObjectId(白化模板列表用)客户端视图未透传 structParentId,按原版兜底空串。
|
||||
WhiteningDialog dlg(cmdRepo_, dsId, projectId, QString(), this);
|
||||
// tmObjectId(白化模板列表用)= 当前数据集的 structParentId(对照原版 dsFileRow.structParentId)。
|
||||
// 经 open 链路从数据集列表行透传至此(注入的 tmObjectIdGetter_)。空串也照常打开(仅模板列表为空)。
|
||||
const QString tmObjectId = tmObjectIdGetter_ ? tmObjectIdGetter_() : QString();
|
||||
WhiteningDialog dlg(cmdRepo_, dsId, projectId, tmObjectId, this);
|
||||
if (dlg.exec() == QDialog::Accepted) reloadGrid();
|
||||
}
|
||||
|
||||
|
|
@ -406,16 +420,90 @@ void GridDataChartView::openExceptionDialog() {
|
|||
const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString();
|
||||
const QString projectId = projectIdGetter_ ? projectIdGetter_() : QString();
|
||||
if (!cmdRepo_ || dsId.isEmpty()) { showNotImplemented(nullptr); return; }
|
||||
// remarkSourceId = dsObjectId(异常挂当前等值面数据集)。
|
||||
// 时序复刻原版:先弹窗选 标注类型/异常类型/名称/备注(remarkSourceId = dsObjectId)。
|
||||
ExceptionDialog dlg(cmdRepo_, projectId, dsId, this);
|
||||
if (dlg.exec() == QDialog::Accepted) reloadGrid();
|
||||
if (dlg.exec() != QDialog::Accepted) return;
|
||||
|
||||
// 兜底:用户手填了坐标 → 对话框内部已提交,仅重载。
|
||||
if (!dlg.manualCoordinates().isEmpty()) { reloadGrid(); return; }
|
||||
|
||||
// 主路径:弹窗后在图上交互绘形 → 完成回调组装 newException(对照原版 startDraw*→
|
||||
// drawingComplete→newExceptionInProfileInversion)。
|
||||
const QString markType = dlg.markTypeValue();
|
||||
const QString typeId = dlg.exceptionTypeId();
|
||||
const QString name = dlg.exceptionName();
|
||||
const QString remark = dlg.exceptionRemark();
|
||||
if (!drawTool_) return;
|
||||
QPointer<GridDataChartView> self(this);
|
||||
drawTool_->setOnComplete([self, markType, typeId, name, remark](const std::vector<QPointF>& pts) {
|
||||
if (!self) return;
|
||||
QJsonArray coords;
|
||||
for (const QPointF& p : pts)
|
||||
coords.append(QJsonObject{{QStringLiteral("x"), p.x()}, {QStringLiteral("y"), p.y()}});
|
||||
// 文字类型(4):落点后另弹「文本编辑」对话框,提交带 customLegend(对照原版 exceptionText)。
|
||||
if (markType == QStringLiteral("4")) {
|
||||
ExceptionTextDialog tdlg(self);
|
||||
if (tdlg.exec() != QDialog::Accepted) return; // 取消 → 不提交
|
||||
// 字体族 int → CSS family(对照原版 fontFamily 映射)。
|
||||
static const QHash<QString, QString> kCssFont{
|
||||
{QStringLiteral("1"), QStringLiteral("SimSun")},
|
||||
{QStringLiteral("2"), QStringLiteral("Microsoft YaHei")},
|
||||
{QStringLiteral("3"), QStringLiteral("SimHei")},
|
||||
{QStringLiteral("4"), QStringLiteral("KaiTi")}};
|
||||
const QString content = tdlg.content();
|
||||
QJsonObject customLegend{
|
||||
{QStringLiteral("text"), content},
|
||||
{QStringLiteral("content"), content},
|
||||
{QStringLiteral("color"), tdlg.color()},
|
||||
{QStringLiteral("size"), tdlg.fontSize()},
|
||||
{QStringLiteral("font"), kCssFont.value(tdlg.fontFamilyValue())},
|
||||
{QStringLiteral("opacity"), tdlg.opacityPercent() / 100.0}, // 0–1
|
||||
};
|
||||
self->submitDrawnException(markType, typeId, name, remark, coords, customLegend);
|
||||
return;
|
||||
}
|
||||
self->submitDrawnException(markType, typeId, name, remark, coords);
|
||||
});
|
||||
drawTool_->setOnCancel([] {}); // 取消绘形:无操作(不提交)
|
||||
drawTool_->begin(markType.toInt());
|
||||
}
|
||||
|
||||
void GridDataChartView::submitDrawnException(const QString& markType, const QString& typeId,
|
||||
const QString& name, const QString& remark,
|
||||
const QJsonArray& coords,
|
||||
const QJsonObject& customLegend) {
|
||||
const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString();
|
||||
const QString projectId = projectIdGetter_ ? projectIdGetter_() : QString();
|
||||
if (!cmdRepo_ || dsId.isEmpty()) return;
|
||||
QJsonObject body{
|
||||
{QStringLiteral("exceptionName"), name},
|
||||
{QStringLiteral("exceptionTypeId"), typeId},
|
||||
{QStringLiteral("remark"), remark},
|
||||
{QStringLiteral("remarkSourceType"), markType}, // 几何形态字符串 "1".."4"
|
||||
{QStringLiteral("remarkSourceId"), dsId}, // = dsObjectId
|
||||
{QStringLiteral("projectId"), projectId},
|
||||
{QStringLiteral("location"), QJsonObject{{QStringLiteral("coordinate"), coords}}},
|
||||
};
|
||||
// 文字类型带 customLegend(对照原版:仅文字非空,其它形态不带此字段)。
|
||||
if (!customLegend.isEmpty()) body.insert(QStringLiteral("customLegend"), customLegend);
|
||||
QPointer<GridDataChartView> self(this);
|
||||
cmdRepo_->newException(body, [self](bool ok, QString msg) {
|
||||
if (!self) return;
|
||||
if (!ok) {
|
||||
QMessageBox::warning(self, QStringLiteral("新建异常"),
|
||||
msg.isEmpty() ? QStringLiteral("创建失败") : msg);
|
||||
return;
|
||||
}
|
||||
self->reloadGrid(); // 成功后重载(列表 + 图层同步)
|
||||
});
|
||||
}
|
||||
|
||||
void GridDataChartView::openAutoAnnotation() {
|
||||
const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString();
|
||||
const QString projectId = projectIdGetter_ ? projectIdGetter_() : QString();
|
||||
if (!cmdRepo_ || dsId.isEmpty()) { showNotImplemented(nullptr); return; }
|
||||
AutoAnnotationDialog dlg(cmdRepo_, dsId, projectId, this);
|
||||
// 透传网格 + 色阶:右上预览图(ContourPlotItem 等值面)+ 数据统计(max/min/mean/median)。
|
||||
AutoAnnotationDialog dlg(cmdRepo_, dsId, projectId, grid_, gridScale_, this);
|
||||
if (dlg.exec() == QDialog::Accepted) reloadGrid();
|
||||
}
|
||||
|
||||
|
|
@ -472,32 +560,33 @@ void GridDataChartView::loadDescription() {
|
|||
QPointer<GridDataChartView> self(this);
|
||||
cmdRepo_->getDsObjectDetail(dsId, [self](bool ok, QJsonObject data, QString) {
|
||||
if (!self || !ok) return;
|
||||
// 原版从 attachedParameters.deltaContent 取 Quill Delta;Qt 退化为纯文本:
|
||||
// 优先 description 字段,否则拼接 delta ops 的 insert 文本。
|
||||
QString text = data.value(QStringLiteral("description")).toString();
|
||||
if (text.isEmpty()) {
|
||||
const QJsonArray ops = data.value(QStringLiteral("attachedParameters"))
|
||||
.toObject()
|
||||
.value(QStringLiteral("deltaContent"))
|
||||
.toArray();
|
||||
for (const QJsonValue& op : ops)
|
||||
text += op.toObject().value(QStringLiteral("insert")).toString();
|
||||
}
|
||||
self->descriptionPanel_->setText(text);
|
||||
// 原版从 attachedParameters.deltaContent 取 Quill Delta 回填编辑器(quill.setContents)。
|
||||
// 客户端用 QuillDelta::deltaToDocument 还原富文本;无 deltaContent 时回退 description 纯文本。
|
||||
const QJsonArray ops = data.value(QStringLiteral("attachedParameters"))
|
||||
.toObject()
|
||||
.value(QStringLiteral("deltaContent"))
|
||||
.toArray();
|
||||
if (!ops.isEmpty())
|
||||
self->descriptionPanel_->setDelta(ops);
|
||||
else
|
||||
self->descriptionPanel_->setPlainText(
|
||||
data.value(QStringLiteral("description")).toString());
|
||||
});
|
||||
}
|
||||
|
||||
void GridDataChartView::saveDescription(const QString& text) {
|
||||
void GridDataChartView::saveDescription() {
|
||||
const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString();
|
||||
if (!cmdRepo_ || dsId.isEmpty()) { showNotImplemented(nullptr); return; }
|
||||
// attachedParameters.deltaContent:以最简单 op 包纯文本(reload 时可还原为纯文本)。
|
||||
QJsonArray ops;
|
||||
if (!text.isEmpty()) ops.append(QJsonObject{{QStringLiteral("insert"), text}});
|
||||
if (!cmdRepo_ || dsId.isEmpty() || !descriptionPanel_) {
|
||||
showNotImplemented(nullptr);
|
||||
return;
|
||||
}
|
||||
// 与原版 saveQuillEditorContent 对齐:
|
||||
// description = 纯文本(quill.getText());deltaContent = Quill Delta ops(quill.getContents().ops)。
|
||||
QJsonObject body{
|
||||
{QStringLiteral("dsObjectId"), dsId},
|
||||
{QStringLiteral("description"), text},
|
||||
{QStringLiteral("description"), descriptionPanel_->plainText()},
|
||||
{QStringLiteral("attachedParameters"),
|
||||
QJsonObject{{QStringLiteral("deltaContent"), ops}}},
|
||||
QJsonObject{{QStringLiteral("deltaContent"), descriptionPanel_->delta()}}},
|
||||
};
|
||||
QPointer<GridDataChartView> self(this);
|
||||
cmdRepo_->updateDsObject(body, [self](bool ok, QString msg) {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,14 @@
|
|||
#pragma once
|
||||
#include <functional>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include <QJsonObject> // submitDrawnException 默认参数 const QJsonObject& = {} 需完整类型
|
||||
#include <QString>
|
||||
#include <QWidget>
|
||||
|
||||
class QJsonArray;
|
||||
|
||||
#include "model/Anomaly.hpp"
|
||||
#include "model/ColorScale.hpp"
|
||||
#include "model/Field.hpp"
|
||||
|
|
@ -32,6 +36,7 @@ class ColorBarWidget;
|
|||
class ColorMapService;
|
||||
class ContourPlotItem;
|
||||
class ContourHoverTip;
|
||||
class ContourDrawTool;
|
||||
|
||||
// 网格数据图表视图:工具条 + QwtPlot(白底 + 真实比尺 + 实时平移/滚轮缩放,x 轴在底部)
|
||||
// + 独立色阶条 + 底部双页签(异常列表/描述)。
|
||||
|
|
@ -61,6 +66,11 @@ public:
|
|||
std::function<QString()> dsIdGetter,
|
||||
std::function<QString()> projectIdGetter);
|
||||
|
||||
// 注入 tmObjectId 取值回调(= 数据集 structParentId)。白化对话框模板列表用;空 → 模板列表为空。
|
||||
void setTmObjectIdGetter(std::function<QString()> tmObjectIdGetter) {
|
||||
tmObjectIdGetter_ = std::move(tmObjectIdGetter);
|
||||
}
|
||||
|
||||
private:
|
||||
void rebuildContour(); // 按当前显隐开关重建并重绘 ContourPlotItem
|
||||
void openColorScaleEditor(); // 「色阶配置」→ 共享色阶编辑器(色阶 + 层级⚙ + 线形⚙)
|
||||
|
|
@ -72,13 +82,18 @@ private:
|
|||
void applySimplify(); // I8:把当前滑块容差透传给 ContourPlotItem 并重绘
|
||||
void showNotImplemented(QWidget* anchor); // 占位提示(无仓储/无 dsId)
|
||||
|
||||
void openExceptionDialog(); // I9 异常创建
|
||||
void openExceptionDialog(); // I9 异常创建(弹窗选类型 → 图上绘形 →[文字另弹文本编辑]→ 提交)
|
||||
// I9 图上绘形完成:组装 body 提交 newException(成功 reloadGrid)。
|
||||
// customLegend 仅文字类型非空(对照原版:文字 customLegend,其它形态留空 {})。
|
||||
void submitDrawnException(const QString& markType, const QString& typeId, const QString& name,
|
||||
const QString& remark, const QJsonArray& coords,
|
||||
const QJsonObject& customLegend = {});
|
||||
void openAutoAnnotation(); // I13 自动标注
|
||||
void deleteAnomaly(int index); // I10 异常删除
|
||||
void showAnomalyDetail(int index); // I11 异常详情/编辑
|
||||
void locateAnomaly(int index); // I12 异常定位(高亮 + 缩放)
|
||||
void loadDescription(); // I14 进入时回填描述
|
||||
void saveDescription(const QString& text); // I14 保存描述
|
||||
void saveDescription(); // I14 保存描述(从面板取 Delta + 纯文本)
|
||||
|
||||
QwtPlot* plot_ = nullptr;
|
||||
QwtPlotRescaler* rescaler_ = nullptr;
|
||||
|
|
@ -90,6 +105,7 @@ private:
|
|||
QCheckBox* chkShowLabels_ = nullptr; // 工具条「显示等值线标注」(线形⚙ 改标注显隐后同步)
|
||||
QTimer* simplifyDebounce_ = nullptr; // I8 简化容差防抖(~300ms)
|
||||
ContourHoverTip* contourTip_ = nullptr; // I7 等值线提示(hover)
|
||||
ContourDrawTool* drawTool_ = nullptr; // I9 图上绘形工具(QObject,this 持有)
|
||||
|
||||
// 渲染状态
|
||||
ColorMapService* colorSvc_ = nullptr; // heap,setGridData 重建
|
||||
|
|
@ -97,6 +113,7 @@ private:
|
|||
geopro::core::Grid grid_{1, 1};
|
||||
geopro::core::ColorScale gridScale_;
|
||||
std::vector<geopro::core::Anomaly> anoms_;
|
||||
QString lvlTemplateId_; // 网格色阶模板 id(getDetail type2 顶层 templateId);色阶「另存为覆盖」用
|
||||
bool hasGrid_ = false;
|
||||
|
||||
// 工具条显隐开关
|
||||
|
|
@ -111,6 +128,9 @@ private:
|
|||
// 反演命令仓储 + dsId 取值回调(注入;空则处理类按钮占位提示)。
|
||||
geopro::data::IDatasetCommandRepository* cmdRepo_ = nullptr;
|
||||
std::function<QString()> dsIdGetter_;
|
||||
|
||||
// tmObjectId 取值回调(= 数据集 structParentId)。白化对话框模板列表用;空 → 模板列表为空。
|
||||
std::function<QString()> tmObjectIdGetter_;
|
||||
};
|
||||
|
||||
} // namespace geopro::app
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
#include "panels/chart/GridWizardDialog.hpp"
|
||||
|
||||
#include <cmath>
|
||||
#include <utility>
|
||||
|
||||
#include <QComboBox>
|
||||
|
||||
#include "EmptyAwareComboBox.hpp"
|
||||
#include <QDoubleSpinBox>
|
||||
#include <QFormLayout>
|
||||
#include <QGridLayout>
|
||||
#include <QHBoxLayout>
|
||||
#include <QLabel>
|
||||
#include <QListWidget>
|
||||
|
|
@ -15,97 +18,80 @@
|
|||
#include <QStackedWidget>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
#include "FormKit.hpp"
|
||||
#include "FormKit.hpp" // addSection / editLabel
|
||||
#include "Theme.hpp"
|
||||
#include "panels/chart/InversionProcessOps.hpp" // buildGridToBody
|
||||
#include "repo/IDatasetCommandRepository.hpp"
|
||||
|
||||
namespace geopro::app {
|
||||
|
||||
namespace {
|
||||
constexpr double kCoordRange = 1e9; // 坐标范围(足够宽)
|
||||
constexpr int kSizeMin = 1, kSizeMax = 300; // 点数范围(对照原版 1~300)
|
||||
constexpr int kDefaultXSize = 100; // 默认点数(原版 xPoints 默认 100)
|
||||
constexpr double kCoordRange = 1e9; // 坐标范围(足够宽)
|
||||
constexpr int kSizeMin = 1, kSizeMax = 300; // 点数范围(对照原版 1~300)
|
||||
constexpr int kDefaultXSize = 100; // 默认点数(原版 xPoints 默认 100)
|
||||
constexpr int kStep1W = 500, kStep2W = 800; // 原版步骤 1/2 弹窗宽
|
||||
constexpr int kParamLabelW = 60; // 原版 .param-group label min-width:60px
|
||||
constexpr int kParamFieldW = 100; // 原版输入框宽 100px
|
||||
|
||||
// 配一个坐标 spinbox(6 位小数,宽范围)。
|
||||
QDoubleSpinBox* makeCoordSpin(QWidget* parent) {
|
||||
auto* sp = new QDoubleSpinBox(parent);
|
||||
sp->setRange(-kCoordRange, kCoordRange);
|
||||
sp->setDecimals(6);
|
||||
sp->setFixedWidth(kParamFieldW);
|
||||
return sp;
|
||||
}
|
||||
|
||||
// 分组标题:走 §7.0.10 唯一实现 formkit::addSection(heading 半粗 + 标题下 1px divider)。
|
||||
void addSectionTitle(QVBoxLayout* into, const QString& title, QWidget* parent) {
|
||||
formkit::addSection(into, title, parent, /*topGap=*/false);
|
||||
}
|
||||
|
||||
// 原版 .param-group:定宽右标签 + 紧随输入框,多个并排成一行栅格。
|
||||
void addParamCell(QGridLayout* grid, int row, int col, const QString& label, QWidget* field,
|
||||
QWidget* parent) {
|
||||
auto* cell = new QHBoxLayout();
|
||||
cell->setSpacing(geopro::app::space::kSm);
|
||||
auto* lbl = new QLabel(label, parent);
|
||||
lbl->setMinimumWidth(kParamLabelW);
|
||||
lbl->setAlignment(Qt::AlignRight | Qt::AlignVCenter);
|
||||
cell->addWidget(lbl);
|
||||
cell->addWidget(field);
|
||||
grid->addLayout(cell, row, col);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
GridWizardDialog::GridWizardDialog(geopro::data::IDatasetCommandRepository* repo, QString dsId,
|
||||
QWidget* parent)
|
||||
: QDialog(parent), repo_(repo), dsId_(std::move(dsId)) {
|
||||
setWindowTitle(QStringLiteral("网格化"));
|
||||
setWindowTitle(QStringLiteral("网格配置")); // 原版 gridSetting
|
||||
setModal(true);
|
||||
resize(560, 420);
|
||||
setFixedWidth(kStep1W);
|
||||
|
||||
auto* root = formkit::dialogRoot(this);
|
||||
auto* root = new QVBoxLayout(this);
|
||||
root->setContentsMargins(geopro::app::space::kLg, geopro::app::space::kLg,
|
||||
geopro::app::space::kLg, geopro::app::space::kLg);
|
||||
root->setSpacing(geopro::app::space::kMd);
|
||||
stack_ = new QStackedWidget(this);
|
||||
root->addWidget(stack_, 1);
|
||||
|
||||
// ── 步骤 1:算法选择(单选列表)──────────────────────────────────────
|
||||
auto* page1 = new QWidget(this);
|
||||
auto* p1Lay = new QVBoxLayout(page1);
|
||||
p1Lay->addWidget(new QLabel(QStringLiteral("请选择网格方法:"), page1));
|
||||
algoList_ = new QListWidget(page1);
|
||||
p1Lay->addWidget(algoList_, 1);
|
||||
stack_->addWidget(page1);
|
||||
buildStep1();
|
||||
buildStep2();
|
||||
|
||||
// ── 步骤 2:网格参数 + 数据值设置 ────────────────────────────────────
|
||||
auto* page2 = new QWidget(this);
|
||||
auto* p2Lay = new QVBoxLayout(page2);
|
||||
xMin_ = makeCoordSpin(page2); xMax_ = makeCoordSpin(page2);
|
||||
yMin_ = makeCoordSpin(page2); yMax_ = makeCoordSpin(page2);
|
||||
vMin_ = makeCoordSpin(page2); vMax_ = makeCoordSpin(page2);
|
||||
xSize_ = new QSpinBox(page2); xSize_->setRange(kSizeMin, kSizeMax); xSize_->setValue(kDefaultXSize);
|
||||
ySize_ = new QSpinBox(page2); ySize_->setRange(kSizeMin, kSizeMax); ySize_->setValue(kDefaultXSize);
|
||||
xSpacing_ = makeCoordSpin(page2); xSpacing_->setRange(0.0, kCoordRange);
|
||||
ySpacing_ = makeCoordSpin(page2); ySpacing_->setRange(0.0, kCoordRange);
|
||||
saveFormat_ = new QComboBox(page2);
|
||||
saveFormat_->addItem(QStringLiteral("线性"), QStringLiteral("linear"));
|
||||
saveFormat_->addItem(QStringLiteral("对数"), QStringLiteral("log"));
|
||||
|
||||
formkit::capField(xMin_);
|
||||
formkit::capField(xMax_);
|
||||
formkit::capField(xSpacing_);
|
||||
formkit::capField(yMin_);
|
||||
formkit::capField(yMax_);
|
||||
formkit::capField(ySpacing_);
|
||||
formkit::capField(vMin_);
|
||||
formkit::capField(vMax_);
|
||||
formkit::capField(xSize_);
|
||||
formkit::capField(ySize_);
|
||||
formkit::capField(saveFormat_);
|
||||
|
||||
auto* form = formkit::makeEditForm();
|
||||
form->addRow(formkit::editLabel(QStringLiteral("Xmin:")), xMin_);
|
||||
form->addRow(formkit::editLabel(QStringLiteral("Xmax:")), xMax_);
|
||||
form->addRow(formkit::editLabel(QStringLiteral("X点数:")), xSize_);
|
||||
form->addRow(formkit::editLabel(QStringLiteral("X间距:")), xSpacing_);
|
||||
form->addRow(formkit::editLabel(QStringLiteral("Ymin:")), yMin_);
|
||||
form->addRow(formkit::editLabel(QStringLiteral("Ymax:")), yMax_);
|
||||
form->addRow(formkit::editLabel(QStringLiteral("Y点数:")), ySize_);
|
||||
form->addRow(formkit::editLabel(QStringLiteral("Y间距:")), ySpacing_);
|
||||
form->addRow(formkit::editLabel(QStringLiteral("数据值min:")), vMin_);
|
||||
form->addRow(formkit::editLabel(QStringLiteral("数据值max:")), vMax_);
|
||||
form->addRow(formkit::editLabel(QStringLiteral("保存格式:")), saveFormat_);
|
||||
p2Lay->addLayout(form);
|
||||
stack_->addWidget(page2);
|
||||
|
||||
// ── 底部按钮(上一步 / 下一步 / 确认 / 取消)────────────────────────
|
||||
// ── 底部操作栏(规范 §7.5 右对齐):上一步(次按钮) 左;取消(次) + 下一步/确认(主) 右。──
|
||||
auto* btnLay = new QHBoxLayout();
|
||||
btnLay->addStretch();
|
||||
prevBtn_ = new QPushButton(QStringLiteral("上一步"), this);
|
||||
btnLay->setSpacing(geopro::app::space::kMd);
|
||||
prevBtn_ = new QPushButton(QStringLiteral("上一步"), this); // 次按钮(描边),左侧
|
||||
auto* cancelBtn = new QPushButton(QStringLiteral("取消"), this);
|
||||
nextBtn_ = new QPushButton(QStringLiteral("下一步"), this);
|
||||
okBtn_ = new QPushButton(QStringLiteral("确认"), this);
|
||||
auto* cancelBtn = new QPushButton(QStringLiteral("取消"), this);
|
||||
nextBtn_->setDefault(true); // 步骤 1 主操作
|
||||
okBtn_->setDefault(true); // 步骤 2 主操作(每屏仅一个可见,故无双 primary)
|
||||
btnLay->addWidget(prevBtn_);
|
||||
btnLay->addStretch();
|
||||
btnLay->addWidget(cancelBtn);
|
||||
btnLay->addWidget(nextBtn_);
|
||||
btnLay->addWidget(okBtn_);
|
||||
btnLay->addWidget(cancelBtn);
|
||||
root->addLayout(btnLay);
|
||||
prevBtn_->setVisible(false);
|
||||
okBtn_->setVisible(false);
|
||||
|
|
@ -114,18 +100,108 @@ GridWizardDialog::GridWizardDialog(geopro::data::IDatasetCommandRepository* repo
|
|||
connect(nextBtn_, &QPushButton::clicked, this, &GridWizardDialog::goToStep2);
|
||||
connect(prevBtn_, &QPushButton::clicked, this, [this]() {
|
||||
stack_->setCurrentIndex(0);
|
||||
setFixedWidth(kStep1W);
|
||||
prevBtn_->setVisible(false); okBtn_->setVisible(false); nextBtn_->setVisible(true);
|
||||
});
|
||||
connect(okBtn_, &QPushButton::clicked, this, &GridWizardDialog::onConfirm);
|
||||
// 点数变化 → 重算间距(原版 calculateXInterval/calculateYInterval)。
|
||||
connect(xSize_, QOverload<int>::of(&QSpinBox::valueChanged), this,
|
||||
[this](int) { recalcXSpacing(); });
|
||||
connect(ySize_, QOverload<int>::of(&QSpinBox::valueChanged), this,
|
||||
[this](int) { recalcYSpacing(); });
|
||||
|
||||
loadAlgorithms();
|
||||
}
|
||||
|
||||
void GridWizardDialog::buildStep1() {
|
||||
auto* page1 = new QWidget(this);
|
||||
auto* p1Lay = new QVBoxLayout(page1);
|
||||
p1Lay->setContentsMargins(0, 0, 0, 0);
|
||||
p1Lay->setSpacing(geopro::app::space::kMd);
|
||||
p1Lay->addWidget(new QLabel(QStringLiteral("请选择网格方法:"), page1));
|
||||
algoList_ = new QListWidget(page1);
|
||||
p1Lay->addWidget(algoList_, 1);
|
||||
stack_->addWidget(page1);
|
||||
}
|
||||
|
||||
void GridWizardDialog::buildStep2() {
|
||||
auto* page2 = new QWidget(this);
|
||||
auto* p2Lay = new QVBoxLayout(page2);
|
||||
p2Lay->setContentsMargins(0, 0, 0, 0);
|
||||
p2Lay->setSpacing(geopro::app::space::kLg);
|
||||
|
||||
xMin_ = makeCoordSpin(page2); xMax_ = makeCoordSpin(page2);
|
||||
yMin_ = makeCoordSpin(page2); yMax_ = makeCoordSpin(page2);
|
||||
vMin_ = makeCoordSpin(page2); vMax_ = makeCoordSpin(page2);
|
||||
xSize_ = new QSpinBox(page2); xSize_->setRange(kSizeMin, kSizeMax);
|
||||
xSize_->setValue(kDefaultXSize); xSize_->setFixedWidth(100);
|
||||
ySize_ = new QSpinBox(page2); ySize_->setRange(kSizeMin, kSizeMax);
|
||||
ySize_->setValue(kDefaultXSize); ySize_->setFixedWidth(100);
|
||||
xSpacing_ = makeCoordSpin(page2); xSpacing_->setRange(0.0, kCoordRange);
|
||||
ySpacing_ = makeCoordSpin(page2); ySpacing_->setRange(0.0, kCoordRange);
|
||||
|
||||
// 分组 1:网格参数(栅格:Xmax→Xmin→X间距→X点数 同行;Y 同理)。
|
||||
addSectionTitle(p2Lay, QStringLiteral("网格参数"), page2);
|
||||
auto* grid = new QGridLayout();
|
||||
grid->setHorizontalSpacing(geopro::app::space::kMd);
|
||||
grid->setVerticalSpacing(geopro::app::space::kMd);
|
||||
addParamCell(grid, 0, 0, QStringLiteral("Xmax"), xMax_, page2);
|
||||
addParamCell(grid, 0, 1, QStringLiteral("Xmin"), xMin_, page2);
|
||||
addParamCell(grid, 0, 2, QStringLiteral("X间距"), xSpacing_, page2);
|
||||
addParamCell(grid, 0, 3, QStringLiteral("X点数"), xSize_, page2);
|
||||
addParamCell(grid, 1, 0, QStringLiteral("Ymax"), yMax_, page2);
|
||||
addParamCell(grid, 1, 1, QStringLiteral("Ymin"), yMin_, page2);
|
||||
addParamCell(grid, 1, 2, QStringLiteral("Y间距"), ySpacing_, page2);
|
||||
addParamCell(grid, 1, 3, QStringLiteral("Y点数"), ySize_, page2);
|
||||
grid->setColumnStretch(4, 1);
|
||||
p2Lay->addLayout(grid);
|
||||
|
||||
// 分组 2:数据值设置(数据值max/min + 恢复默认值 + 数据值保存为)。
|
||||
addSectionTitle(p2Lay, QStringLiteral("数据值设置"), page2);
|
||||
saveFormat_ = new EmptyAwareComboBox(page2);
|
||||
saveFormat_->addItem(QStringLiteral("线性"), QStringLiteral("linear"));
|
||||
saveFormat_->addItem(QStringLiteral("对数"), QStringLiteral("log"));
|
||||
saveFormat_->setFixedWidth(120);
|
||||
auto* restoreBtn = new QPushButton(QStringLiteral("恢复默认值"), page2);
|
||||
|
||||
auto* dv = new QGridLayout();
|
||||
dv->setHorizontalSpacing(geopro::app::space::kLg);
|
||||
dv->setVerticalSpacing(geopro::app::space::kMd);
|
||||
auto* vmaxLbl = new QLabel(QStringLiteral("数据值max"), page2);
|
||||
vmaxLbl->setMinimumWidth(kParamLabelW + 20);
|
||||
vmaxLbl->setAlignment(Qt::AlignRight | Qt::AlignVCenter);
|
||||
dv->addWidget(vmaxLbl, 0, 0);
|
||||
dv->addWidget(vMax_, 0, 1);
|
||||
dv->addWidget(restoreBtn, 0, 3);
|
||||
auto* vminLbl = new QLabel(QStringLiteral("数据值min"), page2);
|
||||
vminLbl->setMinimumWidth(kParamLabelW + 20);
|
||||
vminLbl->setAlignment(Qt::AlignRight | Qt::AlignVCenter);
|
||||
dv->addWidget(vminLbl, 1, 0);
|
||||
dv->addWidget(vMin_, 1, 1);
|
||||
auto* fmtLbl = new QLabel(QStringLiteral("数据值保存为"), page2); // 原版 saveFormat
|
||||
fmtLbl->setAlignment(Qt::AlignRight | Qt::AlignVCenter);
|
||||
dv->addWidget(fmtLbl, 1, 2);
|
||||
dv->addWidget(saveFormat_, 1, 3);
|
||||
dv->setColumnStretch(4, 1);
|
||||
p2Lay->addLayout(dv);
|
||||
p2Lay->addStretch();
|
||||
stack_->addWidget(page2);
|
||||
|
||||
// 联动:Xmax/Xmin/X点数变 → 反算 X间距(round);X间距变 → 反算 X点数(round)。
|
||||
connect(xMax_, QOverload<double>::of(&QDoubleSpinBox::valueChanged), this,
|
||||
[this](double) { calcXInterval(); });
|
||||
connect(xMin_, QOverload<double>::of(&QDoubleSpinBox::valueChanged), this,
|
||||
[this](double) { calcXInterval(); });
|
||||
connect(xSize_, QOverload<int>::of(&QSpinBox::valueChanged), this,
|
||||
[this](int) { calcXInterval(); });
|
||||
connect(xSpacing_, QOverload<double>::of(&QDoubleSpinBox::valueChanged), this,
|
||||
[this](double) { calcXPoints(); });
|
||||
connect(yMax_, QOverload<double>::of(&QDoubleSpinBox::valueChanged), this,
|
||||
[this](double) { calcYInterval(); });
|
||||
connect(yMin_, QOverload<double>::of(&QDoubleSpinBox::valueChanged), this,
|
||||
[this](double) { calcYInterval(); });
|
||||
connect(ySize_, QOverload<int>::of(&QSpinBox::valueChanged), this,
|
||||
[this](int) { calcYInterval(); });
|
||||
connect(ySpacing_, QOverload<double>::of(&QDoubleSpinBox::valueChanged), this,
|
||||
[this](double) { calcYPoints(); });
|
||||
connect(restoreBtn, &QPushButton::clicked, this, &GridWizardDialog::loadParams);
|
||||
}
|
||||
|
||||
void GridWizardDialog::loadAlgorithms() {
|
||||
if (!repo_) return;
|
||||
QPointer<GridWizardDialog> self(this);
|
||||
|
|
@ -149,6 +225,7 @@ void GridWizardDialog::goToStep2() {
|
|||
return;
|
||||
}
|
||||
stack_->setCurrentIndex(1);
|
||||
setFixedWidth(kStep2W);
|
||||
nextBtn_->setVisible(false);
|
||||
prevBtn_->setVisible(true);
|
||||
okBtn_->setVisible(true);
|
||||
|
|
@ -166,28 +243,63 @@ void GridWizardDialog::loadParams() {
|
|||
self->yMax_->setValue(d.value(QStringLiteral("ymax")).toDouble());
|
||||
self->vMin_->setValue(d.value(QStringLiteral("vmin")).toDouble());
|
||||
self->vMax_->setValue(d.value(QStringLiteral("vmax")).toDouble());
|
||||
// 初始间距 = (xmax-xmin)/xSize,y 间距同 x(原版 onMounted 逻辑)。
|
||||
self->recalcXSpacing();
|
||||
// 原版 onMounted:X点数固定 100 → 算 X间距 → Y间距=X间距 → Y点数=ceil。
|
||||
self->xSize_->setValue(kDefaultXSize);
|
||||
self->calcXInterval();
|
||||
const double dx = self->xSpacing_->value();
|
||||
if (dx > 0) self->ySpacing_->setValue(dx);
|
||||
if (dx > 0) self->ySpacing_->setValue(dx); // 触发 calcYPoints
|
||||
});
|
||||
}
|
||||
|
||||
void GridWizardDialog::recalcXSpacing() {
|
||||
void GridWizardDialog::calcXInterval() {
|
||||
const int n = xSize_->value();
|
||||
if (n > 0) xSpacing_->setValue((xMax_->value() - xMin_->value()) / n);
|
||||
const double range = xMax_->value() - xMin_->value();
|
||||
if (n > 0 && range > 0) {
|
||||
QSignalBlocker b(xSpacing_); // 防与 calcXPoints 互触发
|
||||
xSpacing_->setValue(range / n);
|
||||
}
|
||||
}
|
||||
|
||||
void GridWizardDialog::recalcYSpacing() {
|
||||
void GridWizardDialog::calcXPoints() {
|
||||
const double iv = xSpacing_->value();
|
||||
const double range = xMax_->value() - xMin_->value();
|
||||
if (iv > 0 && range > 0) {
|
||||
QSignalBlocker b(xSize_);
|
||||
xSize_->setValue(static_cast<int>(std::lround(range / iv))); // 原版 round
|
||||
}
|
||||
}
|
||||
|
||||
void GridWizardDialog::calcYInterval() {
|
||||
const int n = ySize_->value();
|
||||
if (n > 0) ySpacing_->setValue((yMax_->value() - yMin_->value()) / n);
|
||||
const double range = yMax_->value() - yMin_->value();
|
||||
if (n > 0 && range > 0) {
|
||||
QSignalBlocker b(ySpacing_);
|
||||
ySpacing_->setValue(range / n);
|
||||
}
|
||||
}
|
||||
|
||||
void GridWizardDialog::calcYPoints() {
|
||||
const double iv = ySpacing_->value();
|
||||
const double range = yMax_->value() - yMin_->value();
|
||||
if (iv > 0 && range > 0) {
|
||||
QSignalBlocker b(ySize_);
|
||||
ySize_->setValue(static_cast<int>(std::ceil(range / iv))); // 原版 ceil(与 X 的 round 不同)
|
||||
}
|
||||
}
|
||||
|
||||
void GridWizardDialog::onConfirm() {
|
||||
if (!repo_ || !algoList_->currentItem()) { reject(); return; }
|
||||
if (xMax_->value() < xMin_->value() || yMax_->value() < yMin_->value() ||
|
||||
vMax_->value() < vMin_->value()) {
|
||||
QMessageBox::warning(this, windowTitle(), QStringLiteral("最大值不能小于最小值"));
|
||||
// 原版按 Xmax/Ymax/数据值分别提示。
|
||||
if (xMax_->value() < xMin_->value()) {
|
||||
QMessageBox::warning(this, windowTitle(), QStringLiteral("Xmax不能小于Xmin"));
|
||||
return;
|
||||
}
|
||||
if (yMax_->value() < yMin_->value()) {
|
||||
QMessageBox::warning(this, windowTitle(), QStringLiteral("Ymax不能小于Ymin"));
|
||||
return;
|
||||
}
|
||||
if (vMax_->value() < vMin_->value()) {
|
||||
QMessageBox::warning(this, windowTitle(), QStringLiteral("数据值max不能小于数据值min"));
|
||||
return;
|
||||
}
|
||||
GridToParams p;
|
||||
|
|
|
|||
|
|
@ -27,11 +27,15 @@ public:
|
|||
QWidget* parent = nullptr);
|
||||
|
||||
private:
|
||||
void buildStep1(); // 步骤 1:算法选择列表
|
||||
void buildStep2(); // 步骤 2:网格参数 + 数据值设置(两分组卡片)
|
||||
void loadAlgorithms(); // 步骤 1:拉算法列表
|
||||
void loadParams(); // 步骤 2:拉 x/y/v 默认参数
|
||||
void loadParams(); // 步骤 2:拉 x/y/v 默认参数(兼「恢复默认值」)
|
||||
void goToStep2(); // 下一步(校验算法已选)
|
||||
void recalcXSpacing(); // (xMax-xMin)/xSize → xSpacing(间距已有值时)
|
||||
void recalcYSpacing(); // (yMax-yMin)/ySize → ySpacing
|
||||
void calcXInterval(); // Xmax/Xmin/X点数变 → X间距=(xMax-xMin)/xSize
|
||||
void calcXPoints(); // X间距变 → X点数=round((xMax-xMin)/xSpacing)
|
||||
void calcYInterval(); // Ymax/Ymin/Y点数变 → Y间距=(yMax-yMin)/ySize
|
||||
void calcYPoints(); // Y间距变 → Y点数=ceil((yMax-yMin)/ySpacing)
|
||||
void onConfirm(); // 确认 → toGrid
|
||||
|
||||
geopro::data::IDatasetCommandRepository* repo_ = nullptr;
|
||||
|
|
|
|||
|
|
@ -3,22 +3,21 @@
|
|||
#include <utility>
|
||||
|
||||
#include <QComboBox>
|
||||
#include <QFrame>
|
||||
#include <QGridLayout>
|
||||
#include <QHBoxLayout>
|
||||
#include <QMessageBox>
|
||||
#include <QPointer>
|
||||
#include <QPushButton>
|
||||
#include <QScrollArea>
|
||||
#include <QSignalBlocker>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
#include "FormKit.hpp"
|
||||
#include "dto/NavDto.hpp" // parseEditableForm(与对象/结构编辑共用的动态表单解析)
|
||||
#include "panels/DynamicFormEditor.hpp"
|
||||
#include "repo/IDatasetCommandRepository.hpp"
|
||||
|
||||
namespace geopro::app {
|
||||
|
||||
// 纯解析/组装函数定义在 InversionFormParse.cpp(Qt-Core-only,便于单测)。
|
||||
|
||||
namespace {
|
||||
constexpr char kVisualResistivityCode[] = "script_visual_resistivity_data";
|
||||
} // namespace
|
||||
|
|
@ -40,16 +39,22 @@ InversionFormDialog::InversionFormDialog(Mode mode, geopro::data::IDatasetComman
|
|||
// 模型选择行(label + 下拉)。生成视电阻率下拉禁用(复刻原版 disabled)。
|
||||
auto* modelLay = new QVBoxLayout();
|
||||
modelLay->addWidget(formkit::editLabel(QStringLiteral("反演模型"), this));
|
||||
modelCombo_ = new QComboBox(this);
|
||||
// 空态感知下拉:反演模型异步加载(listInversionScripts)。反演运算模式占位「请选择反演模型」
|
||||
// (替代旧的空首项 allow-clear hack),未选显占位、无脚本弹「暂无数据」;生成视电阻率模式
|
||||
// 禁用并由 loadScripts 显式选中,占位不影响其默认选中。
|
||||
modelCombo_ = formkit::comboBox(QStringLiteral("请选择反演模型"), this);
|
||||
if (mode_ == Mode::ApparentResistivity) modelCombo_->setEnabled(false);
|
||||
modelLay->addWidget(modelCombo_);
|
||||
root->addLayout(modelLay);
|
||||
|
||||
// 动态字段容器(按 groups_ 重建)。
|
||||
formHost_ = new QWidget(this);
|
||||
formHostLay_ = new QVBoxLayout(formHost_);
|
||||
formHostLay_->setContentsMargins(0, 0, 0, 0);
|
||||
root->addWidget(formHost_, 1);
|
||||
// 动态字段容器:复用 DynamicFormEditor(按 displayComponentType 渲染 11 种控件 + 必填校验)。
|
||||
// 放滚动区内,避免字段多时撑爆对话框。
|
||||
auto* scroll = new QScrollArea(this);
|
||||
scroll->setWidgetResizable(true);
|
||||
scroll->setFrameShape(QFrame::NoFrame);
|
||||
editor_ = new DynamicFormEditor();
|
||||
scroll->setWidget(editor_);
|
||||
root->addWidget(scroll, 1);
|
||||
|
||||
// 底部按钮(取消 / 确定)。
|
||||
auto* btnLay = new QHBoxLayout();
|
||||
|
|
@ -79,8 +84,7 @@ void InversionFormDialog::loadScripts() {
|
|||
if (!ok) return;
|
||||
QSignalBlocker block(self->modelCombo_); // 填充期不触发 onModelChanged
|
||||
self->modelCombo_->clear();
|
||||
// 反演运算:首项为空(对应原版 allow-clear,无默认选中)。
|
||||
if (mode == Mode::Inversion) self->modelCombo_->addItem(QString(), QString());
|
||||
// 反演运算:不再插空首项——占位文案 + currentIndex=-1 即「无默认选中」(对应原版 allow-clear)。
|
||||
int defaultIdx = -1;
|
||||
for (const QJsonValue& v : list) {
|
||||
const QJsonObject o = v.toObject();
|
||||
|
|
@ -103,9 +107,8 @@ void InversionFormDialog::loadScripts() {
|
|||
|
||||
void InversionFormDialog::onModelChanged() {
|
||||
const QString typeId = modelCombo_->currentData().toString();
|
||||
groups_.clear();
|
||||
if (typeId.isEmpty()) {
|
||||
rebuildFormArea(); // 清空表单(复刻 changeModel: 清空 dynamicForms)
|
||||
editor_->clear(); // 清空表单(复刻 changeModel: 清空 dynamicForms)
|
||||
return;
|
||||
}
|
||||
loadDynamicForm(typeId);
|
||||
|
|
@ -117,50 +120,12 @@ void InversionFormDialog::loadDynamicForm(const QString& typeId) {
|
|||
repo_->getDynamicForm(projectId_, typeId, [self](bool ok, QJsonObject data, QString) {
|
||||
if (!self) return;
|
||||
if (!ok) return;
|
||||
self->groups_ = parseDynamicForm(data);
|
||||
self->rebuildFormArea();
|
||||
// 复用 parseEditableForm(formList → values → displayComponentType/requiredType/optionsObject)
|
||||
// + DynamicFormEditor(11 种控件渲染)。confType 对反演渲染无影响,取 0 占位。
|
||||
self->editor_->setForm(geopro::data::dto::parseEditableForm(data, 0));
|
||||
});
|
||||
}
|
||||
|
||||
void InversionFormDialog::rebuildFormArea() {
|
||||
// 清空旧字段控件(含布局子项),重置取值索引。
|
||||
fieldCombos_.clear();
|
||||
fieldCodes_.clear();
|
||||
QLayoutItem* item = nullptr;
|
||||
while ((item = formHostLay_->takeAt(0)) != nullptr) {
|
||||
if (item->widget()) item->widget()->deleteLater();
|
||||
delete item;
|
||||
}
|
||||
|
||||
const bool fillDefaults = (mode_ == Mode::ApparentResistivity);
|
||||
for (const auto& g : groups_) {
|
||||
// 分组卡片:标题 + 字段两列网格(复刻原版 a-card 分组 + 半宽字段)。
|
||||
auto* card = new QFrame(formHost_);
|
||||
card->setObjectName(QStringLiteral("inversionGroupCard"));
|
||||
auto* cardLay = new QVBoxLayout(card);
|
||||
if (!g.groupName.isEmpty())
|
||||
formkit::addSection(cardLay, g.groupName, card, /*topGap=*/false);
|
||||
auto* grid = new QGridLayout();
|
||||
cardLay->addLayout(grid);
|
||||
int col = 0, gridRow = 0;
|
||||
for (const auto& f : g.fields) {
|
||||
auto* fieldBox = new QVBoxLayout();
|
||||
fieldBox->addWidget(formkit::editLabel(f.fieldName, card));
|
||||
auto* combo = new QComboBox(card);
|
||||
for (const auto& o : f.options) combo->addItem(o.label, o.value);
|
||||
// 生成视电阻率:默认选首项(复刻 initDynamicFieldsDefaultValues)。
|
||||
if (fillDefaults && combo->count() > 0) combo->setCurrentIndex(0);
|
||||
fieldBox->addWidget(combo);
|
||||
grid->addLayout(fieldBox, gridRow, col);
|
||||
fieldCombos_.push_back(combo);
|
||||
fieldCodes_.push_back(f.fieldCode);
|
||||
if (++col >= 2) { col = 0; ++gridRow; }
|
||||
}
|
||||
formHostLay_->addWidget(card);
|
||||
}
|
||||
formHostLay_->addStretch();
|
||||
}
|
||||
|
||||
void InversionFormDialog::onConfirm() {
|
||||
if (!repo_) { reject(); return; }
|
||||
const QString scriptId = modelCombo_->currentData().toString();
|
||||
|
|
@ -169,14 +134,24 @@ void InversionFormDialog::onConfirm() {
|
|||
return;
|
||||
}
|
||||
|
||||
// 由当前各字段下拉选值装配 {fieldCode: value}。
|
||||
QJsonObject selected;
|
||||
for (size_t i = 0; i < fieldCombos_.size(); ++i) {
|
||||
selected.insert(fieldCodes_[static_cast<int>(i)],
|
||||
fieldCombos_[i]->currentData().toString());
|
||||
// 必填校验(requiredType===1):拦截提交并聚焦首个缺失字段(对照原版 a-form rules)。
|
||||
QString missing;
|
||||
if (!editor_->validateRequired(&missing)) {
|
||||
editor_->focusFirstInvalid();
|
||||
QMessageBox::warning(this, windowTitle(),
|
||||
QStringLiteral("请填写必填项:%1").arg(missing));
|
||||
return;
|
||||
}
|
||||
|
||||
// 由各动态控件收集 {fieldCode: value}。生成视电阻率:空值不进体(对照原版 if(selectedValue));
|
||||
// 反演运算:保留全部字段(对照原版 InversionForm 提交 form.properties 整体)。
|
||||
const auto values = editor_->collectValues();
|
||||
QJsonObject fields;
|
||||
const bool omitEmpty = (mode_ == Mode::ApparentResistivity);
|
||||
for (auto it = values.constBegin(); it != values.constEnd(); ++it) {
|
||||
if (omitEmpty && it.value().trimmed().isEmpty()) continue;
|
||||
fields.insert(it.key(), it.value());
|
||||
}
|
||||
const bool fillDefaults = (mode_ == Mode::ApparentResistivity);
|
||||
const QJsonObject fields = assembleFieldMap(groups_, selected, fillDefaults);
|
||||
|
||||
okBtn_->setEnabled(false);
|
||||
QPointer<InversionFormDialog> self(this);
|
||||
|
|
|
|||
|
|
@ -1,14 +1,9 @@
|
|||
#pragma once
|
||||
#include <vector>
|
||||
|
||||
#include <QDialog>
|
||||
#include <QJsonObject>
|
||||
#include <QString>
|
||||
|
||||
#include "panels/chart/InversionFormParse.hpp" // InversionGroup + 纯解析/组装函数
|
||||
|
||||
class QComboBox;
|
||||
class QVBoxLayout;
|
||||
class QPushButton;
|
||||
class QWidget;
|
||||
|
||||
|
|
@ -18,14 +13,18 @@ class IDatasetCommandRepository;
|
|||
|
||||
namespace geopro::app {
|
||||
|
||||
class DynamicFormEditor;
|
||||
|
||||
// 反演动态表单对话框(1:1 复刻原版 web)。一套对话框服务两个入口,用 Mode 区分:
|
||||
// - Inversion → measurement「反演运算」(原版 InversionForm.vue + postInversionTask)
|
||||
// - ApparentResistivity → measurement「生成视电阻率」(原版 InversionDialog.vue + createVisualResistivityData)
|
||||
// 共同流程:① 拉模型列表 → ② 选模型 → ③ 按 typeId 拉动态表单 → ④ 分组卡片渲染字段 → ⑤ 提交。
|
||||
// 动态字段渲染复用项目内 DynamicFormEditor(与对象/结构编辑同一套控件),按 displayComponentType
|
||||
// 渲染 11 种控件并做 requiredType===1 必填校验、requiredType===2 只读禁用(对照原版 FormItem.vue)。
|
||||
// 差异(严格对照原版):
|
||||
// Inversion:模型下拉可选(allow-clear)、无默认选中、无字段默认值;提交体 {dsId,scriptId,properties}。
|
||||
// ApparentResistivity:模型下拉禁用、默认选中 code=='script_visual_resistivity_data'、
|
||||
// 字段默认取首个选项;提交体 {dsObjectId,scriptId,scriptParamListJsonStr}。
|
||||
// Inversion:模型下拉可选(allow-clear)、无默认选中;提交体 {dsId,scriptId,properties}。
|
||||
// ApparentResistivity:模型下拉禁用、默认选中 code=='script_visual_resistivity_data';
|
||||
// 提交体 {dsObjectId,scriptId,scriptParamListJsonStr}。
|
||||
// 回调用 QPointer 守卫(对话框 modal exec,但异步回调仍可能在关闭后到达)。
|
||||
class InversionFormDialog : public QDialog {
|
||||
Q_OBJECT
|
||||
|
|
@ -42,7 +41,6 @@ private:
|
|||
void loadScripts(); // 拉模型列表填下拉
|
||||
void onModelChanged(); // 模型变更 → 拉动态表单
|
||||
void loadDynamicForm(const QString& typeId);
|
||||
void rebuildFormArea(); // 按 groups_ 重建分组卡片
|
||||
void onConfirm(); // 提交(按 mode 走不同端点)
|
||||
|
||||
Mode mode_;
|
||||
|
|
@ -51,13 +49,8 @@ private:
|
|||
QString projectId_;
|
||||
|
||||
QComboBox* modelCombo_ = nullptr;
|
||||
QWidget* formHost_ = nullptr; // 动态字段容器(重建时清空重填)
|
||||
QVBoxLayout* formHostLay_ = nullptr;
|
||||
DynamicFormEditor* editor_ = nullptr; // 动态字段渲染/收集/必填校验(项目内复用)
|
||||
QPushButton* okBtn_ = nullptr;
|
||||
|
||||
std::vector<InversionGroup> groups_; // 当前模型的动态表单(已解析)
|
||||
std::vector<QComboBox*> fieldCombos_; // 与 groups_ 展平后的字段同序(取值用)
|
||||
std::vector<QString> fieldCodes_; // 与 fieldCombos_ 同序的 fieldCode
|
||||
};
|
||||
|
||||
} // namespace geopro::app
|
||||
|
|
|
|||
|
|
@ -1,52 +0,0 @@
|
|||
#include "panels/chart/InversionFormParse.hpp"
|
||||
|
||||
#include <utility>
|
||||
|
||||
#include <QJsonArray>
|
||||
#include <QJsonValue>
|
||||
|
||||
namespace geopro::app {
|
||||
|
||||
std::vector<InversionGroup> parseDynamicForm(const QJsonObject& data) {
|
||||
std::vector<InversionGroup> groups;
|
||||
const QJsonArray formList = data.value(QStringLiteral("formList")).toArray();
|
||||
for (const QJsonValue& gv : formList) {
|
||||
const QJsonObject g = gv.toObject();
|
||||
InversionGroup group;
|
||||
group.groupName = g.value(QStringLiteral("groupName")).toString();
|
||||
const QJsonArray values = g.value(QStringLiteral("values")).toArray();
|
||||
for (const QJsonValue& fv : values) {
|
||||
const QJsonObject f = fv.toObject();
|
||||
InversionField field;
|
||||
field.fieldCode = f.value(QStringLiteral("fieldCode")).toString();
|
||||
field.fieldName = f.value(QStringLiteral("fieldName")).toString();
|
||||
const QJsonArray opts = f.value(QStringLiteral("optionsObject")).toArray();
|
||||
for (const QJsonValue& ov : opts) {
|
||||
const QJsonObject o = ov.toObject();
|
||||
field.options.push_back({o.value(QStringLiteral("label")).toString(),
|
||||
o.value(QStringLiteral("value")).toString()});
|
||||
}
|
||||
group.fields.push_back(std::move(field));
|
||||
}
|
||||
groups.push_back(std::move(group));
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
QJsonObject assembleFieldMap(const std::vector<InversionGroup>& groups, const QJsonObject& selected,
|
||||
bool fillDefaults) {
|
||||
QJsonObject out;
|
||||
for (const auto& g : groups) {
|
||||
for (const auto& f : g.fields) {
|
||||
QString value;
|
||||
if (selected.contains(f.fieldCode)) value = selected.value(f.fieldCode).toString();
|
||||
if (value.isEmpty() && fillDefaults && !f.options.empty()) {
|
||||
value = f.options.front().value; // 复刻 initDynamicFieldsDefaultValues
|
||||
}
|
||||
if (!value.isEmpty()) out.insert(f.fieldCode, value); // 复刻 handleConfirm:空值不进体
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace geopro::app
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
#pragma once
|
||||
#include <vector>
|
||||
|
||||
#include <QJsonObject>
|
||||
#include <QString>
|
||||
|
||||
namespace geopro::app {
|
||||
|
||||
// 反演动态表单的数据模型 + 纯解析/组装函数(仅依赖 QtCore JSON,无 Widgets/MOC)。
|
||||
// 拆出独立 TU 以便单测(tests 链 geopro_data/Qt6::Core 即可,不必拖入对话框)。
|
||||
|
||||
// 一个动态表单字段(仅取渲染/提交所需,复刻原版 optionsObject + fieldCode/fieldName)。
|
||||
struct InversionFieldOption {
|
||||
QString label;
|
||||
QString value;
|
||||
};
|
||||
struct InversionField {
|
||||
QString fieldCode;
|
||||
QString fieldName;
|
||||
std::vector<InversionFieldOption> options; // optionsObject(Select 选项)
|
||||
};
|
||||
struct InversionGroup {
|
||||
QString groupName;
|
||||
std::vector<InversionField> fields;
|
||||
};
|
||||
|
||||
// 解析动态表单响应 data.formList → 分组/字段模型。
|
||||
std::vector<InversionGroup> parseDynamicForm(const QJsonObject& data);
|
||||
|
||||
// 组装提交字段表 {fieldCode: value}。fillDefaults=true 时空选值回退首个选项(生成视电阻率用)。
|
||||
// 复刻原版 handleConfirm:仅写入有值的字段(空值不进体)。
|
||||
QJsonObject assembleFieldMap(const std::vector<InversionGroup>& groups, const QJsonObject& selected,
|
||||
bool fillDefaults);
|
||||
|
||||
} // namespace geopro::app
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
#include "panels/chart/RangeSlider.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
#include <QMouseEvent>
|
||||
#include <QPainter>
|
||||
|
||||
namespace geopro::app {
|
||||
|
||||
namespace {
|
||||
constexpr int kHandleR = 7; // 手柄半径(像素)
|
||||
constexpr int kTrackH = 4; // 轨道高度
|
||||
constexpr int kMargin = kHandleR + 2; // 左右留边,保证端点手柄不被裁
|
||||
const QColor kTrackBg(229, 230, 235); // 轨道底(浅灰)
|
||||
const QColor kTrackSel(245, 63, 63); // 选中段:红(对照原版滑轨 #f53f3f)
|
||||
const QColor kHandleFill(255, 255, 255);
|
||||
const QColor kHandleBorder(245, 63, 63); // 手柄边框红(对照原版)
|
||||
} // namespace
|
||||
|
||||
RangeSlider::RangeSlider(QWidget* parent) : QWidget(parent) {
|
||||
setMinimumHeight(2 * kHandleR + 6);
|
||||
setMouseTracking(false);
|
||||
setCursor(Qt::PointingHandCursor);
|
||||
}
|
||||
|
||||
void RangeSlider::setRange(double min, double max) {
|
||||
min_ = min;
|
||||
max_ = (max > min) ? max : min + 1.0; // 退化区间兜底,避免除零
|
||||
low_ = std::clamp(low_, min_, max_);
|
||||
high_ = std::clamp(high_, min_, max_);
|
||||
update();
|
||||
}
|
||||
|
||||
void RangeSlider::setValues(double low, double high) {
|
||||
low_ = std::clamp(std::min(low, high), min_, max_);
|
||||
high_ = std::clamp(std::max(low, high), min_, max_);
|
||||
update();
|
||||
}
|
||||
|
||||
int RangeSlider::valueToX(double v) const {
|
||||
const int usable = width() - 2 * kMargin;
|
||||
if (usable <= 0 || max_ <= min_) return kMargin;
|
||||
return kMargin + static_cast<int>((v - min_) / (max_ - min_) * usable);
|
||||
}
|
||||
|
||||
double RangeSlider::xToValue(int px) const {
|
||||
const int usable = width() - 2 * kMargin;
|
||||
if (usable <= 0) return min_;
|
||||
const double t = std::clamp(static_cast<double>(px - kMargin) / usable, 0.0, 1.0);
|
||||
return min_ + t * (max_ - min_);
|
||||
}
|
||||
|
||||
void RangeSlider::paintEvent(QPaintEvent*) {
|
||||
QPainter p(this);
|
||||
p.setRenderHint(QPainter::Antialiasing, true);
|
||||
const int cy = height() / 2;
|
||||
const int xLo = valueToX(low_);
|
||||
const int xHi = valueToX(high_);
|
||||
|
||||
// 轨道底
|
||||
p.setPen(Qt::NoPen);
|
||||
p.setBrush(kTrackBg);
|
||||
p.drawRoundedRect(QRect(kMargin, cy - kTrackH / 2, width() - 2 * kMargin, kTrackH), 2, 2);
|
||||
// 选中段(红)
|
||||
p.setBrush(kTrackSel);
|
||||
p.drawRect(QRect(xLo, cy - kTrackH / 2, std::max(0, xHi - xLo), kTrackH));
|
||||
|
||||
// 两个手柄
|
||||
QPen border(kHandleBorder, 2);
|
||||
p.setPen(border);
|
||||
p.setBrush(kHandleFill);
|
||||
p.drawEllipse(QPoint(xLo, cy), kHandleR, kHandleR);
|
||||
p.drawEllipse(QPoint(xHi, cy), kHandleR, kHandleR);
|
||||
}
|
||||
|
||||
void RangeSlider::mousePressEvent(QMouseEvent* event) {
|
||||
const int px = event->position().toPoint().x();
|
||||
const int dLo = std::abs(px - valueToX(low_));
|
||||
const int dHi = std::abs(px - valueToX(high_));
|
||||
// 就近选手柄;距离相等时按点击位置相对中点决定(左半选低,右半选高)。
|
||||
if (dLo == dHi)
|
||||
dragging_ = (px < valueToX((low_ + high_) / 2.0)) ? 1 : 2;
|
||||
else
|
||||
dragging_ = (dLo < dHi) ? 1 : 2;
|
||||
mouseMoveEvent(event);
|
||||
}
|
||||
|
||||
void RangeSlider::mouseMoveEvent(QMouseEvent* event) {
|
||||
if (dragging_ == 0) return;
|
||||
const double v = xToValue(event->position().toPoint().x());
|
||||
if (dragging_ == 1)
|
||||
low_ = std::min(v, high_);
|
||||
else
|
||||
high_ = std::max(v, low_);
|
||||
update();
|
||||
emit rangeChanged(low_, high_);
|
||||
}
|
||||
|
||||
void RangeSlider::mouseReleaseEvent(QMouseEvent*) {
|
||||
dragging_ = 0;
|
||||
}
|
||||
|
||||
} // namespace geopro::app
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
#pragma once
|
||||
#include <QWidget>
|
||||
|
||||
namespace geopro::app {
|
||||
|
||||
// 双手柄范围滑块(M3 数据过滤底部 a-slider range 的 Qt 复刻)。
|
||||
// Qt 无原生双手柄滑块,故自绘:一条水平轨道 + 两个圆形手柄(低/高),
|
||||
// 选中段轨道用强调红(对照原版滑轨 #f53f3f)。值域用 double(连续,对照原版 step 0.01)。
|
||||
// 拖动手柄发 rangeChanged(low, high);外部 setRange/setValues 同步。
|
||||
class RangeSlider : public QWidget {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit RangeSlider(QWidget* parent = nullptr);
|
||||
|
||||
void setRange(double min, double max); // 设定值域(数据 min/max)
|
||||
void setValues(double low, double high); // 设定当前低/高手柄值(外部联动用,不发信号)
|
||||
double lowValue() const { return low_; }
|
||||
double highValue() const { return high_; }
|
||||
|
||||
signals:
|
||||
void rangeChanged(double low, double high);
|
||||
|
||||
protected:
|
||||
void paintEvent(QPaintEvent* event) override;
|
||||
void mousePressEvent(QMouseEvent* event) override;
|
||||
void mouseMoveEvent(QMouseEvent* event) override;
|
||||
void mouseReleaseEvent(QMouseEvent* event) override;
|
||||
|
||||
private:
|
||||
int valueToX(double v) const; // 值 → 像素 x
|
||||
double xToValue(int px) const; // 像素 x → 值(夹到值域)
|
||||
|
||||
double min_ = 0.0;
|
||||
double max_ = 1.0;
|
||||
double low_ = 0.0;
|
||||
double high_ = 1.0;
|
||||
int dragging_ = 0; // 0=无,1=低手柄,2=高手柄
|
||||
};
|
||||
|
||||
} // namespace geopro::app
|
||||
|
|
@ -8,6 +8,7 @@
|
|||
#include "panels/chart/ScatterDataOps.hpp"
|
||||
#include "panels/chart/ScatterFilterDialog.hpp"
|
||||
#include "panels/chart/ScatterHoverTip.hpp"
|
||||
#include "panels/chart/ScatterMarqueePicker.hpp"
|
||||
#include "panels/chart/ScatterPlotItem.hpp"
|
||||
|
||||
#include <utility>
|
||||
|
|
@ -16,6 +17,8 @@
|
|||
|
||||
#include <QByteArray>
|
||||
#include <QComboBox>
|
||||
|
||||
#include "EmptyAwareComboBox.hpp"
|
||||
#include <QCursor>
|
||||
#include <QEvent>
|
||||
#include <QFileDialog>
|
||||
|
|
@ -52,6 +55,7 @@
|
|||
|
||||
#include "panels/chart/LivePanner.hpp"
|
||||
#include "Theme.hpp"
|
||||
#include "ToastOverlay.hpp" // showToast:统一成功轻提示(规范 §7.7)
|
||||
|
||||
namespace geopro::app {
|
||||
|
||||
|
|
@ -76,7 +80,7 @@ RawDataChartView::RawDataChartView(QWidget* parent) : QWidget(parent) {
|
|||
|
||||
auto* lblCurrentChart = new QLabel(QStringLiteral("当前图形:"), toolbar);
|
||||
|
||||
chartTypeCombo_ = new QComboBox(toolbar);
|
||||
chartTypeCombo_ = new EmptyAwareComboBox(toolbar);
|
||||
chartTypeCombo_->addItem(QStringLiteral("散点图"));
|
||||
|
||||
auto* btnSaveAs = new QToolButton(toolbar);
|
||||
|
|
@ -86,8 +90,9 @@ RawDataChartView::RawDataChartView(QWidget* parent) : QWidget(parent) {
|
|||
tbLay->addWidget(btnColorScale);
|
||||
tbLay->addWidget(lblCurrentChart);
|
||||
tbLay->addWidget(chartTypeCombo_);
|
||||
tbLay->addWidget(btnSaveAs);
|
||||
// 原版 .right-buttons margin-left:auto:另存为 右对齐。
|
||||
tbLay->addStretch();
|
||||
tbLay->addWidget(btnSaveAs);
|
||||
|
||||
// 反演原数据默认工具条交互(O1 网格 / O2 色阶配置 / O3 另存为)。
|
||||
connect(btnGrid, &QToolButton::clicked, this, [this, btnGrid]() { openGridWizard(btnGrid); });
|
||||
|
|
@ -141,6 +146,11 @@ RawDataChartView::RawDataChartView(QWidget* parent) : QWidget(parent) {
|
|||
hoverTip_ = new ScatterHoverTip(plot_, QwtPlot::xTop, QwtPlot::yLeft, this);
|
||||
hoverTip_->setField(&data_.scatter);
|
||||
|
||||
// M14 框选拾取器(最后装 → 事件链最先收到;active 时优先消费拖拽,禁用平移)。默认关闭。
|
||||
marquee_ = new ScatterMarqueePicker(plot_, QwtPlot::xTop, QwtPlot::yLeft, this);
|
||||
marquee_->setField(&data_.scatter);
|
||||
marquee_->setOnSelected([this](const std::vector<int>& idx) { onMarqueeSelected(idx); });
|
||||
|
||||
// 允许随停靠面板自由收缩(不强制最小宽度)。
|
||||
plot_->setMinimumSize(0, 0);
|
||||
|
||||
|
|
@ -206,6 +216,12 @@ void fillCombo(QComboBox* combo, const std::vector<geopro::core::FieldOption>& o
|
|||
constexpr int kToolIconPx = 16; // 逻辑图标边长(与 setIconSize 对齐)
|
||||
constexpr qreal kToolIconScale = 2.0; // 超采样倍率(HiDPI 清晰)
|
||||
|
||||
// measurement 工具条下拉固定宽度(对照原版 datasetTool.vue 各 a-select 的 width)。
|
||||
constexpr int kComboW_X = 120; // X 下拉 width:120px
|
||||
constexpr int kComboW_Y = 160; // Y 下拉 width:160px
|
||||
constexpr int kComboW_V = 160; // V 值下拉 width:160px
|
||||
constexpr int kComboW_ValueType = 120; // 值类型下拉 width:120px
|
||||
|
||||
QPixmap makeToolIconCanvas(QPainter& p) {
|
||||
// 调用方在 [0,kToolIconPx] 逻辑坐标系下作画;返回前缩放 + 设 dpr。
|
||||
const int dim = qRound(kToolIconPx * kToolIconScale);
|
||||
|
|
@ -338,6 +354,10 @@ void RawDataChartView::setCommandRepo(geopro::data::IDatasetCommandRepository* r
|
|||
projectIdGetter_ = std::move(projectIdGetter);
|
||||
}
|
||||
|
||||
void RawDataChartView::setColorTemplateRepo(geopro::data::IColorTemplateRepository* repo) {
|
||||
colorTplRepo_ = repo;
|
||||
}
|
||||
|
||||
void RawDataChartView::openInversionDialog(bool apparentResistivity, QWidget* anchor) {
|
||||
// 无仓储/无 dsId 取值回调 → 退化占位(与未注入时一致)。
|
||||
const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString();
|
||||
|
|
@ -374,7 +394,10 @@ void RawDataChartView::openInversionColorScale(QWidget* anchor) {
|
|||
}
|
||||
if (vMin > vMax) { vMin = 0.0; vMax = 1.0; }
|
||||
std::vector<double> samples = data_.scatter.v;
|
||||
ColorScaleConfigDialog dlg(data_.scale, vMin, vMax, std::move(samples), {}, nullptr, {}, this);
|
||||
// 接通色阶模板库:注入仓储 + 当前 projectId + 载荷 templateId(另存为/打开/覆盖 可用)。
|
||||
ColorScaleConfigDialog dlg(data_.scale, vMin, vMax, std::move(samples), {}, colorTplRepo_,
|
||||
projectIdGetter_ ? projectIdGetter_() : QString(), data_.templateId,
|
||||
this);
|
||||
if (dlg.exec() != QDialog::Accepted) return;
|
||||
|
||||
// 本地重建上色重绘。
|
||||
|
|
@ -383,6 +406,7 @@ void RawDataChartView::openInversionColorScale(QWidget* anchor) {
|
|||
colorSvc_ = new ColorMapService(data_.scale);
|
||||
redrawScatter();
|
||||
colorBar_->setColorScale(data_.scale);
|
||||
showToast(this, QStringLiteral("色阶应用成功")); // M8 成功提示(对照原版 Message.success)
|
||||
|
||||
// 持久化(businessCode 空,对照原版 originPage newLvlColorLevel businessCode:'')。
|
||||
const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString();
|
||||
|
|
@ -390,6 +414,7 @@ void RawDataChartView::openInversionColorScale(QWidget* anchor) {
|
|||
if (!cmdRepo_ || dsId.isEmpty()) return;
|
||||
QJsonObject body{
|
||||
{QStringLiteral("dsObjectId"), dsId},
|
||||
{QStringLiteral("templateId"), data_.templateId}, // 读取到的色阶模板 id(对照原版,可空)
|
||||
{QStringLiteral("businessCode"), QString()},
|
||||
{QStringLiteral("projectId"), projectId},
|
||||
{QStringLiteral("properties"), buildColorScaleProperties(data_.scale, dlg.lineConfig())},
|
||||
|
|
@ -443,31 +468,55 @@ void RawDataChartView::onShowHide(bool hide) {
|
|||
QMessageBox::Ok | QMessageBox::Cancel);
|
||||
if (ans != QMessageBox::Ok) return;
|
||||
|
||||
// 本地切换:显示/隐藏全部数据方块(电极保留)。
|
||||
auto localToggle = [this, hide]() {
|
||||
// 选区联动(M14↔M1):隐藏且有选区 → 只对选中点(原版 getSelectedPointIds);
|
||||
// 其余(隐藏无选区 / 显示)维持全部(原版显示恒为全部隐藏点)。
|
||||
const bool selective = hide && scatterItem_ && scatterItem_->hasSelection();
|
||||
|
||||
// 本地切换可见性。selective:逐点改 displayStatus(仅选中点隐藏);否则整体显隐全部方块。
|
||||
auto localToggle = [this, hide, selective]() {
|
||||
if (!scatterItem_) return;
|
||||
scatterItem_->setScatterVisible(!hide);
|
||||
if (selective) {
|
||||
scatterItem_->setData(data_.scatter, colorSvc_); // 重读 displayStatus 逐点生效
|
||||
scatterItem_->clearSelection();
|
||||
} else {
|
||||
for (int& s : data_.scatter.displayStatus) s = hide ? 1 : 0;
|
||||
scatterItem_->setScatterVisible(!hide);
|
||||
if (!hide) scatterItem_->setData(data_.scatter, colorSvc_); // 显示全部:清逐点隐藏
|
||||
}
|
||||
plot_->replot();
|
||||
};
|
||||
|
||||
// 收集要持久化的点 id:selective → 选中点 id;否则隐藏取可见点 / 显示取隐藏点。
|
||||
QJsonArray ids;
|
||||
if (selective) {
|
||||
for (const QString& id : scatterItem_->getSelectedIds()) ids.append(id);
|
||||
// 先把选中点的 displayStatus 标为隐藏(本地,供 localToggle 重读生效)。
|
||||
const auto sel = scatterItem_->getSelectedIds();
|
||||
for (int i = 0; i < static_cast<int>(data_.scatter.id.size()); ++i) {
|
||||
const QString id = QString::fromStdString(data_.scatter.id[i]);
|
||||
if (!id.isEmpty() && sel.end() != std::find(sel.begin(), sel.end(), id))
|
||||
data_.scatter.displayStatus[i] = 1;
|
||||
}
|
||||
} else {
|
||||
ids = collectScatterIds(data_.scatter, hide);
|
||||
}
|
||||
|
||||
// 无仓储/无 dsId → 仅本地切换(退化,不持久化)。
|
||||
const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString();
|
||||
if (!cmdRepo_ || dsId.isEmpty()) { localToggle(); return; }
|
||||
|
||||
// 收集要持久化的点 id(隐藏取可见点 / 显示取隐藏点),status:0=显示 1=隐藏。
|
||||
const QJsonArray ids = collectScatterIds(data_.scatter, hide);
|
||||
const int status = hide ? 1 : 0;
|
||||
QPointer<RawDataChartView> self(this);
|
||||
cmdRepo_->saveDisplayStatus(dsId, ids, status, [self, hide, localToggle](bool ok, QString msg) {
|
||||
cmdRepo_->saveDisplayStatus(dsId, ids, status, [self, hide, selective, localToggle](bool ok, QString msg) {
|
||||
if (!self) return;
|
||||
if (!ok) {
|
||||
QMessageBox::warning(self, QStringLiteral("提示"),
|
||||
msg.isEmpty() ? QStringLiteral("操作失败") : msg);
|
||||
return;
|
||||
}
|
||||
// 持久化成功后同步本地 displayStatus 与方块可见性。
|
||||
const int newStatus = hide ? 1 : 0;
|
||||
for (int& s : self->data_.scatter.displayStatus) s = newStatus;
|
||||
// selective 时本地 displayStatus 已在请求前更新;非 selective 同步整体状态。
|
||||
if (!selective)
|
||||
for (int& s : self->data_.scatter.displayStatus) s = hide ? 1 : 0;
|
||||
localToggle();
|
||||
});
|
||||
}
|
||||
|
|
@ -475,7 +524,8 @@ void RawDataChartView::onShowHide(bool hide) {
|
|||
void RawDataChartView::openFilterDialog(QWidget* anchor) {
|
||||
const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString();
|
||||
if (!cmdRepo_ || dsId.isEmpty()) { showNotImplemented(anchor); return; }
|
||||
ScatterFilterDialog dlg(cmdRepo_, dsId, currentVFieldCode(), this);
|
||||
// 传当前 V 值数组驱动分布直方图(与图上散点同源,反映当前值类型变换后的分布)。
|
||||
ScatterFilterDialog dlg(cmdRepo_, dsId, currentVFieldCode(), data_.scatter.v, this);
|
||||
dlg.exec(); // 成功/失败由对话框内部反馈(生成过滤数据集为后端动作)。
|
||||
}
|
||||
|
||||
|
|
@ -524,8 +574,10 @@ void RawDataChartView::openScatterColorScale(QWidget* anchor) {
|
|||
if (vMin > vMax) { vMin = 0.0; vMax = 1.0; }
|
||||
std::vector<double> samples = data_.scatter.v; // 直方图/等积分层用原始标量
|
||||
|
||||
// 散点无独立 lvl 模板仓储(视图只持命令仓储)→ tplRepo 传空(另存为/打开 禁用)。
|
||||
ColorScaleConfigDialog dlg(data_.scale, vMin, vMax, std::move(samples), {}, nullptr, {}, this);
|
||||
// 接通色阶模板库:注入仓储 + 当前 projectId + 载荷 templateId(另存为/打开/覆盖 可用)。
|
||||
ColorScaleConfigDialog dlg(data_.scale, vMin, vMax, std::move(samples), {}, colorTplRepo_,
|
||||
projectIdGetter_ ? projectIdGetter_() : QString(), data_.templateId,
|
||||
this);
|
||||
if (dlg.exec() != QDialog::Accepted) return;
|
||||
|
||||
// 本地重建 colorSvc_ 重绘散点(M8 即时生效)。
|
||||
|
|
@ -536,6 +588,7 @@ void RawDataChartView::openScatterColorScale(QWidget* anchor) {
|
|||
// 同步右侧竖条/底部横条色阶图例。
|
||||
if (data_.verticalLegend) colorBarV_->setColorScale(data_.scale);
|
||||
else colorBar_->setColorScale(data_.scale);
|
||||
showToast(this, QStringLiteral("色阶应用成功")); // M8 成功提示(对照原版 Message.success)
|
||||
|
||||
// 持久化到后端(saveColorGradation,businessCode=当前 V 值,type=3 散点路径)。
|
||||
const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString();
|
||||
|
|
@ -543,6 +596,7 @@ void RawDataChartView::openScatterColorScale(QWidget* anchor) {
|
|||
if (!cmdRepo_ || dsId.isEmpty()) return; // 无仓储 → 仅本地生效(不阻塞)
|
||||
QJsonObject body{
|
||||
{QStringLiteral("dsObjectId"), dsId},
|
||||
{QStringLiteral("templateId"), data_.templateId}, // 读取到的色阶模板 id(对照原版,可空)
|
||||
{QStringLiteral("businessCode"), currentVFieldCode()},
|
||||
{QStringLiteral("projectId"), projectId},
|
||||
{QStringLiteral("properties"), buildColorScaleProperties(data_.scale, dlg.lineConfig())},
|
||||
|
|
@ -592,17 +646,38 @@ void RawDataChartView::toggleInfoMode(bool on) {
|
|||
infoMode_ = on;
|
||||
if (on && !infoPanel_) {
|
||||
// 首次开启:建覆盖在图区右上角的属性面板(复刻原版 .scatterInfos 浮层)。
|
||||
// A/B/M/N 按原版逐项配色(item-label-a 红 / -b 蓝 / -m 绿 / -n 橙#F4B008)。
|
||||
infoPanel_ = new QWidget(plot_->canvas());
|
||||
infoPanel_->setObjectName(QStringLiteral("scatterInfoPanel"));
|
||||
auto* il = new QVBoxLayout(infoPanel_);
|
||||
il->setContentsMargins(8, 6, 8, 6);
|
||||
infoLabel_ = new QLabel(QStringLiteral("点选散点查看属性"), infoPanel_);
|
||||
il->addWidget(infoLabel_);
|
||||
il->setSpacing(2);
|
||||
infoHint_ = new QLabel(QStringLiteral("点选散点查看属性"), infoPanel_);
|
||||
il->addWidget(infoHint_);
|
||||
// 各属性行:标签上色(label QSS 不染行值),初始隐藏,命中点后填值显示。
|
||||
infoValA_ = new QLabel(infoPanel_);
|
||||
infoValA_->setObjectName(QStringLiteral("infoA"));
|
||||
infoValB_ = new QLabel(infoPanel_);
|
||||
infoValB_->setObjectName(QStringLiteral("infoB"));
|
||||
infoValM_ = new QLabel(infoPanel_);
|
||||
infoValM_->setObjectName(QStringLiteral("infoM"));
|
||||
infoValN_ = new QLabel(infoPanel_);
|
||||
infoValN_->setObjectName(QStringLiteral("infoN"));
|
||||
infoValRow_ = new QLabel(infoPanel_);
|
||||
infoValPseu_ = new QLabel(infoPanel_);
|
||||
for (QLabel* l : {infoValA_, infoValB_, infoValM_, infoValN_, infoValRow_, infoValPseu_}) {
|
||||
l->setVisible(false);
|
||||
il->addWidget(l);
|
||||
}
|
||||
applyTokenizedStyleSheet(
|
||||
infoPanel_,
|
||||
QStringLiteral("QWidget#scatterInfoPanel { background: {{bg/panel}};"
|
||||
" border: 1px solid {{border/default}}; border-radius: 6px; }"
|
||||
"QLabel { color: {{text/primary}}; }"));
|
||||
"QLabel { color: {{text/primary}}; }"
|
||||
"QLabel#infoA { color: #FF0000; }" // A 红
|
||||
"QLabel#infoB { color: #0000FF; }" // B 蓝
|
||||
"QLabel#infoM { color: #008000; }" // M 绿
|
||||
"QLabel#infoN { color: #F4B008; }")); // N 橙黄
|
||||
// 画布事件过滤器:信息模式下点击找最近点显示属性。
|
||||
plot_->canvas()->installEventFilter(this);
|
||||
}
|
||||
|
|
@ -617,7 +692,7 @@ void RawDataChartView::toggleInfoMode(bool on) {
|
|||
}
|
||||
|
||||
void RawDataChartView::showPointInfoAt(const QPoint& canvasPos) {
|
||||
if (!infoLabel_ || data_.scatter.x.empty()) return;
|
||||
if (!infoValA_ || data_.scatter.x.empty()) return;
|
||||
const QwtScaleMap xMap = plot_->canvasMap(QwtPlot::xTop);
|
||||
const QwtScaleMap yMap = plot_->canvasMap(QwtPlot::yLeft);
|
||||
const auto& s = data_.scatter;
|
||||
|
|
@ -635,19 +710,39 @@ void RawDataChartView::showPointInfoAt(const QPoint& canvasPos) {
|
|||
auto at = [](const std::vector<double>& v, std::size_t i) {
|
||||
return i < v.size() ? v[i] : 0.0;
|
||||
};
|
||||
// 复刻原版 scatterInfos:A / B / M / N / DataRow / Pseu_Resis。
|
||||
infoLabel_->setText(QStringLiteral("A= %1\nB= %2\nM= %3\nN= %4\nDataRow= %5\nPseu_Resis= %6")
|
||||
.arg(QString::number(at(s.a, bestI), 'g', 6),
|
||||
QString::number(at(s.b, bestI), 'g', 6),
|
||||
QString::number(at(s.m, bestI), 'g', 6),
|
||||
QString::number(at(s.n, bestI), 'g', 6),
|
||||
QString::number(at(s.row, bestI), 'g', 6),
|
||||
QString::number(at(s.pseu, bestI), 'g', 6)));
|
||||
// 复刻原版 scatterInfos:A / B / M / N / DataRow / Pseu_Resis(A/B/M/N 标签逐项配色)。
|
||||
if (infoHint_) infoHint_->setVisible(false);
|
||||
infoValA_->setText(QStringLiteral("A= %1").arg(QString::number(at(s.a, bestI), 'g', 6)));
|
||||
infoValB_->setText(QStringLiteral("B= %1").arg(QString::number(at(s.b, bestI), 'g', 6)));
|
||||
infoValM_->setText(QStringLiteral("M= %1").arg(QString::number(at(s.m, bestI), 'g', 6)));
|
||||
infoValN_->setText(QStringLiteral("N= %1").arg(QString::number(at(s.n, bestI), 'g', 6)));
|
||||
infoValRow_->setText(QStringLiteral("DataRow= %1").arg(QString::number(at(s.row, bestI), 'g', 6)));
|
||||
infoValPseu_->setText(
|
||||
QStringLiteral("Pseu_Resis= %1").arg(QString::number(at(s.pseu, bestI), 'g', 6)));
|
||||
for (QLabel* l : {infoValA_, infoValB_, infoValM_, infoValN_, infoValRow_, infoValPseu_})
|
||||
l->setVisible(true);
|
||||
infoPanel_->adjustSize();
|
||||
infoPanel_->move(plot_->canvas()->width() - infoPanel_->width() - 10, 10);
|
||||
infoPanel_->raise();
|
||||
}
|
||||
|
||||
void RawDataChartView::toggleMarqueeMode(bool on) {
|
||||
marqueeMode_ = on;
|
||||
if (marquee_) marquee_->setActive(on);
|
||||
if (!on && scatterItem_) {
|
||||
// 退出框选:清选区高亮(与原版 exitSelectMode clearSelection 一致)。
|
||||
scatterItem_->clearSelection();
|
||||
plot_->replot();
|
||||
}
|
||||
}
|
||||
|
||||
void RawDataChartView::onMarqueeSelected(const std::vector<int>& indices) {
|
||||
// 框选完成:高亮框内散点(红框)。空框 → 清选区。
|
||||
if (!scatterItem_) return;
|
||||
scatterItem_->setSelectedIndices(indices);
|
||||
plot_->replot();
|
||||
}
|
||||
|
||||
bool RawDataChartView::eventFilter(QObject* obj, QEvent* ev) {
|
||||
// 仅信息模式 + 画布左键点击:找最近散点显示属性,不消费事件(保留平移链路)。
|
||||
if (infoMode_ && plot_ && obj == plot_->canvas() && ev->type() == QEvent::MouseButtonPress) {
|
||||
|
|
@ -694,10 +789,10 @@ void RawDataChartView::buildMeasurementToolbar(const geopro::core::ScatterToolba
|
|||
|
||||
// [i] info + [▣] 框选:占位(暂未实现)。用 QPainter 画的线性图标(HiDPI 清晰,随主题)。
|
||||
auto* btnInfo = new QToolButton(toolbar);
|
||||
btnInfo->setToolTip(QStringLiteral("信息"));
|
||||
btnInfo->setToolTip(QStringLiteral("查看散点属性")); // 对照原版 datasetTool.vue tooltip
|
||||
styleToolIconButton(btnInfo, makeInfoIcon());
|
||||
auto* btnMarquee = new QToolButton(toolbar);
|
||||
btnMarquee->setToolTip(QStringLiteral("框选"));
|
||||
btnMarquee->setToolTip(QStringLiteral("散点的点选")); // 对照原版 datasetTool.vue tooltip
|
||||
styleToolIconButton(btnMarquee, makeMarqueeIcon());
|
||||
// 主题热切:重绘图标(info 锚定品牌蓝,marquee 描边随次要文本色)。
|
||||
connect(&ThemeManager::instance(), &ThemeManager::changed, btnInfo,
|
||||
|
|
@ -708,9 +803,9 @@ void RawDataChartView::buildMeasurementToolbar(const geopro::core::ScatterToolba
|
|||
// [i] 信息:切换信息模式(点选散点看 A/B/M/N/DataRow/Pseu_Resis)。
|
||||
btnInfo->setCheckable(true);
|
||||
connect(btnInfo, &QToolButton::toggled, this, [this](bool on) { toggleInfoMode(on); });
|
||||
// [▣] 框选:本轮后置(Qwt 橡皮筋框选 + 选区联动隐藏成本较高),保持占位提示。
|
||||
connect(btnMarquee, &QToolButton::clicked, this,
|
||||
[this, btnMarquee]() { showNotImplemented(btnMarquee); });
|
||||
// [▣] 框选:可勾选 → 进入框选模式(橡皮筋选框内散点高亮;显示/隐藏改对选中点)。
|
||||
btnMarquee->setCheckable(true);
|
||||
connect(btnMarquee, &QToolButton::toggled, this, [this](bool on) { toggleMarqueeMode(on); });
|
||||
|
||||
// 显示 / 隐藏:popconfirm 确认 → saveDisplayStatus 持久化 → 本地切换(M1)。
|
||||
auto* btnShow = new QPushButton(QStringLiteral("显示"), toolbar);
|
||||
|
|
@ -728,18 +823,30 @@ void RawDataChartView::buildMeasurementToolbar(const geopro::core::ScatterToolba
|
|||
connect(btnExport, &QPushButton::clicked, this, [this]() { exportDat(); });
|
||||
|
||||
// x / y 下拉:本地换列重绘;v 下拉:重新请求散点+色阶(M6);值类型下拉:本地变换(M7)。
|
||||
xCombo_ = new QComboBox(toolbar);
|
||||
// 各下拉固定宽度对照原版 datasetTool.vue(X=120/Y=160/V=160/值类型=120)。
|
||||
xCombo_ = new EmptyAwareComboBox(toolbar);
|
||||
xCombo_->setFixedWidth(kComboW_X);
|
||||
fillCombo(xCombo_, conf.x, conf.defaultX, QString());
|
||||
yCombo_ = new QComboBox(toolbar);
|
||||
yCombo_ = new EmptyAwareComboBox(toolbar);
|
||||
yCombo_->setFixedWidth(kComboW_Y);
|
||||
fillCombo(yCombo_, conf.y, conf.defaultY, QString());
|
||||
vCombo_ = new QComboBox(toolbar);
|
||||
vCombo_ = new EmptyAwareComboBox(toolbar);
|
||||
vCombo_->setFixedWidth(kComboW_V);
|
||||
fillCombo(vCombo_, conf.v, conf.defaultV, QString());
|
||||
valueTypeCombo_ = new QComboBox(toolbar);
|
||||
valueTypeCombo_ = new EmptyAwareComboBox(toolbar);
|
||||
valueTypeCombo_->setFixedWidth(kComboW_ValueType);
|
||||
// 值类型固定三项(原版 linearity/inverse/logarithm),本地变换无后端。
|
||||
valueTypeCombo_->addItem(QStringLiteral("线性"), QStringLiteral("linearity"));
|
||||
valueTypeCombo_->addItem(QStringLiteral("倒数"), QStringLiteral("inverse"));
|
||||
valueTypeCombo_->addItem(QStringLiteral("对数"), QStringLiteral("logarithm"));
|
||||
|
||||
// 无高程时禁用 X/Y 下拉(对照原版 :disabled="!currentHasElevation")。
|
||||
// 判断依据:高程相关备选列 altYElevationPseudo 非空即视为「有高程数据」(与 y 下拉
|
||||
// 「伪深度+高程」项的数据源一致;无高程时该列为空,X/Y 轴切换无意义故禁用)。
|
||||
const bool hasElevation = !data_.altYElevationPseudo.empty();
|
||||
xCombo_->setEnabled(hasElevation);
|
||||
yCombo_->setEnabled(hasElevation);
|
||||
|
||||
connect(xCombo_, QOverload<int>::of(&QComboBox::currentIndexChanged), this,
|
||||
[this](int) { replotForAxis(); });
|
||||
connect(yCombo_, QOverload<int>::of(&QComboBox::currentIndexChanged), this,
|
||||
|
|
@ -776,13 +883,17 @@ void RawDataChartView::buildMeasurementToolbar(const geopro::core::ScatterToolba
|
|||
tbLay->addWidget(btnShow);
|
||||
tbLay->addWidget(btnHide);
|
||||
tbLay->addWidget(btnFilter);
|
||||
tbLay->addWidget(btnExport);
|
||||
tbLay->addWidget(xCombo_);
|
||||
tbLay->addWidget(yCombo_);
|
||||
tbLay->addWidget(vCombo_);
|
||||
tbLay->addWidget(valueTypeCombo_);
|
||||
tbLay->addWidget(btnColorScale);
|
||||
tbLay->addStretch(); // 把主操作推到右侧
|
||||
tbLay->addStretch(); // 把导出 + 主操作推到右侧
|
||||
// 导出:原版在详情页头 Header(非工具条)。客户端页头「导出」HeaderAction 为跨 ddCode
|
||||
// 共用的静态占位(PanelHeader 不暴露按钮/不发信号,IDetailView 亦无导出接口),按 ddCode
|
||||
// 分派转发成本高且易误触其它视图;故 measurement 专属导出保留在工具条内,置于最右侧、
|
||||
// 紧邻主操作组,样式贴近原版(outline 风格的普通按钮)。
|
||||
tbLay->addWidget(btnExport);
|
||||
tbLay->addWidget(btnGen);
|
||||
tbLay->addWidget(btnInvert);
|
||||
tbLay->addWidget(btnSaveAs);
|
||||
|
|
@ -816,6 +927,7 @@ void RawDataChartView::setData(const geopro::core::ScatterPayload& p) {
|
|||
data_ = p;
|
||||
baseV_ = data_.scatter.v; // 缓存原始 v(线性),M7 值类型变换从原值算,不累积误差
|
||||
if (hoverTip_) hoverTip_->setField(&data_.scatter); // 显式重绑(地址稳定,消除隐式依赖)
|
||||
if (marquee_) marquee_->setField(&data_.scatter); // M14 框选拾取同源重绑
|
||||
|
||||
// measurement 载荷(toolbar 非空):首次到来时建并替换工具条(视觉 1:1)。反演留空 → 不动。
|
||||
if (!p.toolbar.empty() && !measurementToolbar_) buildMeasurementToolbar(p.toolbar);
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ class QwtPlotRescaler;
|
|||
|
||||
namespace geopro::data {
|
||||
class IDatasetCommandRepository;
|
||||
class IColorTemplateRepository;
|
||||
}
|
||||
|
||||
namespace geopro::app {
|
||||
|
|
@ -22,6 +23,7 @@ namespace geopro::app {
|
|||
class ColorBarWidget;
|
||||
class ScatterPlotItem;
|
||||
class ScatterHoverTip;
|
||||
class ScatterMarqueePicker;
|
||||
|
||||
// 原数据图表视图:工具条 + QwtPlot(x 轴顶部、Panner/Magnifier)+ 独立色阶条。
|
||||
class RawDataChartView : public QWidget, public IDetailView {
|
||||
|
|
@ -46,6 +48,10 @@ public:
|
|||
std::function<QString()> dsIdGetter,
|
||||
std::function<QString()> projectIdGetter);
|
||||
|
||||
// 注入色阶模板仓储(散点「色阶配置」编辑器「另存为/打开/覆盖」用;projectId 复用
|
||||
// setCommandRepo 注入的 projectIdGetter_)。可传空 → 编辑器后端按钮禁用。
|
||||
void setColorTemplateRepo(geopro::data::IColorTemplateRepository* repo);
|
||||
|
||||
protected:
|
||||
// 信息模式(M13)下捕获画布点击:找最近散点显示属性。其余事件不消费。
|
||||
bool eventFilter(QObject* obj, QEvent* ev) override;
|
||||
|
|
@ -76,6 +82,8 @@ private:
|
|||
void exportDat(); // M12 导出 DAT
|
||||
void toggleInfoMode(bool on); // M13 [i] 信息模式开关
|
||||
void showPointInfoAt(const QPoint& canvasPos); // M13 点选显示属性
|
||||
void toggleMarqueeMode(bool on); // M14 框选模式开关
|
||||
void onMarqueeSelected(const std::vector<int>& indices); // M14 框选回调:高亮选中点
|
||||
// 用 colorSvc_ 重绘当前散点(M7/M8 本地变换/色阶变更后复用)。
|
||||
void redrawScatter();
|
||||
QString currentVFieldCode() const; // 当前 V 值下拉 fieldCode
|
||||
|
|
@ -100,17 +108,28 @@ private:
|
|||
// M13 [i]信息:信息模式开关 + 覆盖在图区右上的属性面板。
|
||||
bool infoMode_ = false;
|
||||
QWidget* infoPanel_ = nullptr; // 属性覆盖面板(A/B/M/N/DataRow/Pseu_Resis)
|
||||
QLabel* infoLabel_ = nullptr;
|
||||
QLabel* infoHint_ = nullptr; // 未点选时的提示文案
|
||||
// A/B/M/N 标签按原版配色(A 红 / B 蓝 / M 绿 / N 橙#F4B008);DataRow/Pseu_Resis 默认色。
|
||||
QLabel* infoValA_ = nullptr;
|
||||
QLabel* infoValB_ = nullptr;
|
||||
QLabel* infoValM_ = nullptr;
|
||||
QLabel* infoValN_ = nullptr;
|
||||
QLabel* infoValRow_ = nullptr;
|
||||
QLabel* infoValPseu_ = nullptr;
|
||||
|
||||
// 使用 unique_ptr 管理生命周期;attach 后 QwtPlot 接管绘制,但我们仍持有指针
|
||||
ColorMapService* colorSvc_ = nullptr; // heap,由 setData 重建
|
||||
ScatterPlotItem* scatterItem_ = nullptr;
|
||||
ScatterHoverTip* hoverTip_ = nullptr; // 散点 hover 提示(QObject,this 持有)
|
||||
ScatterMarqueePicker* marquee_ = nullptr; // M14 框选拾取器(QObject,this 持有)
|
||||
bool marqueeMode_ = false; // M14 框选模式开关
|
||||
|
||||
// 反演命令仓储 + dsId/projectId 取值回调(注入;空则反演按钮占位)。
|
||||
geopro::data::IDatasetCommandRepository* cmdRepo_ = nullptr;
|
||||
std::function<QString()> dsIdGetter_;
|
||||
std::function<QString()> projectIdGetter_;
|
||||
// 色阶模板仓储(注入;空则编辑器「另存为/打开」禁用)。
|
||||
geopro::data::IColorTemplateRepository* colorTplRepo_ = nullptr;
|
||||
};
|
||||
|
||||
} // namespace geopro::app
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
#include <utility>
|
||||
|
||||
#include <QButtonGroup>
|
||||
#include <QDialogButtonBox>
|
||||
#include <QFormLayout>
|
||||
#include <QHBoxLayout>
|
||||
#include <QLabel>
|
||||
|
|
@ -13,49 +14,66 @@
|
|||
#include <QRadioButton>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
#include "FormKit.hpp"
|
||||
#include "FormKit.hpp" // makeEditForm / editLabel / capField / addDialogButtons
|
||||
#include "Theme.hpp"
|
||||
#include "ToastOverlay.hpp" // showToast:统一成功轻提示(规范 §7.7)
|
||||
#include "panels/chart/ScatterDataOps.hpp" // buildSaveRawDataBody(纯组装,便于单测)
|
||||
#include "repo/IDatasetCommandRepository.hpp"
|
||||
|
||||
namespace geopro::app {
|
||||
|
||||
namespace {
|
||||
constexpr int kInversionW = 420; // 规范 §7.5 小号对话框宽
|
||||
constexpr int kRawDataW = 420; // 同上(窄内容仍取小号标准宽,避免局促)
|
||||
} // namespace
|
||||
|
||||
SaveAsDialog::SaveAsDialog(Mode mode, geopro::data::IDatasetCommandRepository* repo, QString dsId,
|
||||
QWidget* parent)
|
||||
: QDialog(parent), mode_(mode), repo_(repo), dsId_(std::move(dsId)) {
|
||||
setWindowTitle(QStringLiteral("数据另存为"));
|
||||
setModal(true);
|
||||
|
||||
// 规范 §7.5 对话框外壳 + §7.0.10 唯一表单实现(makeEditForm)。
|
||||
auto* root = formkit::dialogRoot(this);
|
||||
auto* form = formkit::makeEditForm();
|
||||
|
||||
auto* card = formkit::formCard(this);
|
||||
auto* cardLay = formkit::cardBody(card);
|
||||
if (mode_ == Mode::Inversion) {
|
||||
// ── inversion:原版「另存为新的网格数据」,仅名称行 ──
|
||||
setWindowTitle(QStringLiteral("另存为新的网格数据"));
|
||||
setFixedWidth(scaledPx(kInversionW));
|
||||
|
||||
if (mode_ == Mode::RawData) {
|
||||
// 新增/覆盖单选(复刻原版 a-radio-group:1=新增 0=覆盖)。
|
||||
auto* opLay = new QHBoxLayout();
|
||||
auto* rbNew = new QRadioButton(QStringLiteral("新增"), this);
|
||||
auto* rbOverwrite = new QRadioButton(QStringLiteral("覆盖"), this);
|
||||
nameLabel_ = formkit::editLabel(QStringLiteral("名称"), this); // 原版 label「名称」
|
||||
nameEdit_ = new QLineEdit(this);
|
||||
nameEdit_->setPlaceholderText(QStringLiteral("请输入名称"));
|
||||
nameEdit_->setText(QStringLiteral("网格数据1")); // 原版默认值
|
||||
formkit::capField(nameEdit_);
|
||||
form->addRow(nameLabel_, nameEdit_);
|
||||
root->addLayout(form);
|
||||
} else {
|
||||
// ── RawData(measurement):新增/覆盖 + 名称(对照原版「数据另存为」)──
|
||||
setWindowTitle(QStringLiteral("数据另存为"));
|
||||
setFixedWidth(scaledPx(kRawDataW));
|
||||
|
||||
auto* opWrap = new QWidget(this);
|
||||
auto* opLay = new QHBoxLayout(opWrap);
|
||||
opLay->setContentsMargins(0, 0, 0, 0);
|
||||
auto* rbNew = new QRadioButton(QStringLiteral("新增"), opWrap);
|
||||
auto* rbOverwrite = new QRadioButton(QStringLiteral("覆盖"), opWrap);
|
||||
opGroup_ = new QButtonGroup(this);
|
||||
opGroup_->addButton(rbNew, 1);
|
||||
opGroup_->addButton(rbOverwrite, 0);
|
||||
rbNew->setChecked(true); // 默认新增(与原版 dataStored 初值一致)
|
||||
rbNew->setChecked(true); // 默认新增
|
||||
opLay->addWidget(rbNew);
|
||||
opLay->addWidget(rbOverwrite);
|
||||
opLay->addStretch();
|
||||
cardLay->addLayout(opLay);
|
||||
}
|
||||
form->addRow(formkit::editLabel(QStringLiteral("操作"), this), opWrap);
|
||||
|
||||
// 名称行:RawData 仅新增可见;Inversion 始终可见。
|
||||
auto* nameForm = formkit::makeEditForm();
|
||||
nameLabel_ = formkit::editLabel(QStringLiteral("数据名称"), this);
|
||||
nameEdit_ = new QLineEdit(this);
|
||||
formkit::capField(nameEdit_);
|
||||
nameForm->addRow(nameLabel_, nameEdit_);
|
||||
cardLay->addLayout(nameForm);
|
||||
root->addWidget(card);
|
||||
nameLabel_ = formkit::editLabel(QStringLiteral("数据名称"), this);
|
||||
nameEdit_ = new QLineEdit(this);
|
||||
formkit::capField(nameEdit_);
|
||||
form->addRow(nameLabel_, nameEdit_);
|
||||
root->addLayout(form);
|
||||
|
||||
if (mode_ == Mode::RawData && opGroup_) {
|
||||
// 切到覆盖隐藏名称框,切回新增显示(复刻原版 v-show=dataStored===1)。
|
||||
// 切到覆盖隐藏名称框,切回新增显示。
|
||||
connect(opGroup_, QOverload<int>::of(&QButtonGroup::idClicked), this, [this](int id) {
|
||||
const bool isNew = (id == 1);
|
||||
nameLabel_->setVisible(isNew);
|
||||
|
|
@ -63,16 +81,10 @@ SaveAsDialog::SaveAsDialog(Mode mode, geopro::data::IDatasetCommandRepository* r
|
|||
});
|
||||
}
|
||||
|
||||
auto* btnLay = new QHBoxLayout();
|
||||
btnLay->addStretch();
|
||||
auto* cancelBtn = new QPushButton(QStringLiteral("取消"), this);
|
||||
okBtn_ = new QPushButton(QStringLiteral("确定"), this);
|
||||
okBtn_->setDefault(true);
|
||||
btnLay->addWidget(cancelBtn);
|
||||
btnLay->addWidget(okBtn_);
|
||||
root->addLayout(btnLay);
|
||||
|
||||
connect(cancelBtn, &QPushButton::clicked, this, &QDialog::reject);
|
||||
// 规范 §7.5 底部操作栏:右对齐 取消(次) + 确认(主);确认需异步保存成功才关闭。
|
||||
auto* box = formkit::addDialogButtons(root, this, QStringLiteral("确认"), QStringLiteral("取消"));
|
||||
okBtn_ = box->button(QDialogButtonBox::Ok);
|
||||
QObject::disconnect(box, &QDialogButtonBox::accepted, this, &QDialog::accept);
|
||||
connect(okBtn_, &QPushButton::clicked, this, &SaveAsDialog::onConfirm);
|
||||
}
|
||||
|
||||
|
|
@ -84,7 +96,7 @@ void SaveAsDialog::onConfirm() {
|
|||
const bool needName = (mode_ == Mode::Inversion) || (operationType == 1);
|
||||
const QString name = nameEdit_->text().trimmed();
|
||||
if (needName && name.isEmpty()) {
|
||||
QMessageBox::warning(this, windowTitle(), QStringLiteral("请输入数据名称"));
|
||||
QMessageBox::warning(this, windowTitle(), QStringLiteral("请输入名称"));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -94,6 +106,8 @@ void SaveAsDialog::onConfirm() {
|
|||
if (!self) return;
|
||||
self->okBtn_->setEnabled(true);
|
||||
if (ok) {
|
||||
// 成功提示挂到父窗口(对话框随即关闭,toast 取顶层窗口锚定,故用 parentWidget)。
|
||||
if (auto* anchor = self->parentWidget()) showToast(anchor, QStringLiteral("保存成功"));
|
||||
self->accept();
|
||||
} else {
|
||||
QMessageBox::warning(self, self->windowTitle(),
|
||||
|
|
|
|||
|
|
@ -65,4 +65,36 @@ QJsonObject buildSaveRawDataBody(const QString& dsId, int operationType, const Q
|
|||
return body;
|
||||
}
|
||||
|
||||
ScatterHistogram buildScatterHistogram(const std::vector<double>& v, double min, double max,
|
||||
int binCount) {
|
||||
ScatterHistogram h;
|
||||
h.binMin = min;
|
||||
h.binMax = max;
|
||||
if (binCount <= 0 || !(max > min)) return h; // 退化:返回空 counts(视图渲染空态)
|
||||
h.counts.assign(static_cast<std::size_t>(binCount), 0);
|
||||
h.step = (max - min) / binCount;
|
||||
for (double x : v) {
|
||||
if (!std::isfinite(x) || x < min || x > max) continue; // 区间外/非有限 跳过
|
||||
int idx = static_cast<int>((x - min) / h.step);
|
||||
if (idx >= binCount) idx = binCount - 1; // 末箱右闭(恰等于 max 归入末箱)
|
||||
if (idx < 0) idx = 0;
|
||||
++h.counts[static_cast<std::size_t>(idx)];
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
int toggledDisplayStatus(int currentStatus) {
|
||||
return currentStatus == 0 ? 1 : 0; // 0 显示 → 1 隐藏;其余 → 0 显示
|
||||
}
|
||||
|
||||
int countScatterInRange(const std::vector<double>& v, double min, double max) {
|
||||
if (max < min) return 0;
|
||||
int count = 0;
|
||||
for (double x : v) {
|
||||
if (!std::isfinite(x)) continue; // 非有限值不计入(与直方图一致)
|
||||
if (x >= min && x <= max) ++count; // 闭区间 [min,max],与原版过滤一致
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
} // namespace geopro::app
|
||||
|
|
|
|||
|
|
@ -40,4 +40,27 @@ QJsonObject buildScatterFilterBody(const QString& dsObjectId, const QString& vFi
|
|||
// {dsId, operationType(1新增/0覆盖)},仅新增(operationType==1)才带 name。
|
||||
QJsonObject buildSaveRawDataBody(const QString& dsId, int operationType, const QString& name);
|
||||
|
||||
// 直方图分箱结果(M3 数据过滤分布图):等宽 binCount 个箱,落在 [min,max] 区间内计数。
|
||||
// binMin/binMax = 分箱区间端点(= 入参 min/max);step = 单箱宽度;counts[i] = 第 i 箱内点数。
|
||||
// 边界归属:左闭右开 [lo,hi),末箱右闭以纳入恰等于 max 的点。
|
||||
struct ScatterHistogram {
|
||||
double binMin = 0.0;
|
||||
double binMax = 0.0;
|
||||
double step = 0.0;
|
||||
std::vector<int> counts;
|
||||
};
|
||||
|
||||
// 对 v 数组在 [min,max] 区间按 binCount 等宽分箱(M3,对照原版 D3 直方图 stepRange=20)。
|
||||
// 非有限值(NaN/inf)跳过;区间外的点不计入;min>=max 或 binCount<=0 → counts 全 0。
|
||||
ScatterHistogram buildScatterHistogram(const std::vector<double>& v, double min, double max,
|
||||
int binCount);
|
||||
|
||||
// 行级显隐状态取反(M2,对照原版 updateStatus = record.displayStatus ? 0 : 1)。
|
||||
// 入参/返回 0=显示、1=隐藏。当前显示(0) → 隐藏(1);当前隐藏(非0) → 显示(0)。
|
||||
int toggledDisplayStatus(int currentStatus);
|
||||
|
||||
// 统计落在闭区间 [min,max] 内的有限值个数(M3 数据过滤「当前点数」/占比用)。
|
||||
// 非有限值(NaN/inf)不计入;max<min → 0。
|
||||
int countScatterInRange(const std::vector<double>& v, double min, double max);
|
||||
|
||||
} // namespace geopro::app
|
||||
|
|
|
|||
|
|
@ -1,58 +1,135 @@
|
|||
#include "panels/chart/ScatterFilterDialog.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <utility>
|
||||
|
||||
#include <QDoubleSpinBox>
|
||||
#include <QFormLayout>
|
||||
#include <QFrame>
|
||||
#include <QHBoxLayout>
|
||||
#include <QLabel>
|
||||
#include <QMessageBox>
|
||||
#include <QPointer>
|
||||
#include <QPushButton>
|
||||
#include <QSignalBlocker>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
#include "FormKit.hpp"
|
||||
#include "panels/chart/ScatterDataOps.hpp" // buildScatterFilterBody
|
||||
#include "FormKit.hpp" // makeEditForm / editLabel / capField
|
||||
#include "Theme.hpp"
|
||||
#include "ToastOverlay.hpp" // showToast:成功轻提示
|
||||
#include "panels/chart/RangeSlider.hpp"
|
||||
#include "panels/chart/ScatterDataOps.hpp" // buildScatterFilterBody / countScatterInRange
|
||||
#include "panels/chart/ScatterHistogram.hpp"
|
||||
#include "repo/IDatasetCommandRepository.hpp"
|
||||
|
||||
namespace geopro::app {
|
||||
|
||||
namespace {
|
||||
constexpr double kSpinRange = 1e12; // 数值范围足够宽,覆盖电阻率/电位等量纲
|
||||
constexpr int kDialogW = 1000; // 原版 dataFilter.vue width:1000
|
||||
constexpr int kBodyH = 500; // 原版 .data-filter-container height:500
|
||||
constexpr int kInfoW = 300; // 原版 .filter-options width:300
|
||||
constexpr int kInfoLabelW = 120; // 原版 .label min-width:120
|
||||
const char* kHighlight = "#f77234"; // 原版橙色高亮(当前点数/原始点数)
|
||||
|
||||
// 信息区一行:定宽 label(左)+ 值(右)。返回值标签供调用方写值/上色。
|
||||
QLabel* addInfoRow(QFormLayout* form, const QString& labelText) {
|
||||
auto* lbl = new QLabel(labelText);
|
||||
lbl->setMinimumWidth(kInfoLabelW);
|
||||
auto* val = new QLabel();
|
||||
form->addRow(lbl, val);
|
||||
return val;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
ScatterFilterDialog::ScatterFilterDialog(geopro::data::IDatasetCommandRepository* repo,
|
||||
QString dsObjectId, QString vFieldCode, QWidget* parent)
|
||||
QString dsObjectId, QString vFieldCode,
|
||||
std::vector<double> values, QWidget* parent)
|
||||
: QDialog(parent),
|
||||
repo_(repo),
|
||||
dsObjectId_(std::move(dsObjectId)),
|
||||
vFieldCode_(std::move(vFieldCode)) {
|
||||
setWindowTitle(QStringLiteral("数据过滤"));
|
||||
setModal(true);
|
||||
resize(360, 200);
|
||||
resize(kDialogW, kBodyH + 120); // body 500 + 滑块/按钮/边距
|
||||
|
||||
auto* root = formkit::dialogRoot(this);
|
||||
// 全量有限值 + 数据域 + 原始点数(统计基线)。
|
||||
values_.reserve(values.size());
|
||||
for (double x : values)
|
||||
if (std::isfinite(x)) values_.push_back(x);
|
||||
originalPoints_ = static_cast<int>(values_.size());
|
||||
if (!values_.empty()) {
|
||||
dataMin_ = *std::min_element(values_.begin(), values_.end());
|
||||
dataMax_ = *std::max_element(values_.begin(), values_.end());
|
||||
}
|
||||
|
||||
rangeLabel_ = new QLabel(QStringLiteral("数值范围:—"), this);
|
||||
root->addWidget(rangeLabel_);
|
||||
auto* root = new QVBoxLayout(this);
|
||||
root->setContentsMargins(space::kXl, space::kXl, space::kXl, space::kXl);
|
||||
root->setSpacing(space::kLg);
|
||||
|
||||
auto* card = formkit::formCard(this);
|
||||
auto* cardLay = formkit::cardBody(card);
|
||||
// ── 上半区:左直方图 + 右信息区(高 500)──
|
||||
auto* bodyLay = new QHBoxLayout();
|
||||
bodyLay->setSpacing(space::kXl);
|
||||
histogram_ = new ScatterHistogramView(this);
|
||||
histogram_->setValues(values_);
|
||||
histogram_->setMinimumHeight(kBodyH);
|
||||
bodyLay->addWidget(histogram_, 1);
|
||||
|
||||
auto* form = formkit::makeEditForm();
|
||||
minSpin_ = new QDoubleSpinBox(this);
|
||||
minSpin_->setRange(-kSpinRange, kSpinRange);
|
||||
minSpin_->setDecimals(2);
|
||||
formkit::capField(minSpin_);
|
||||
// 右信息区(定宽 300,带边框卡片)。
|
||||
auto* info = new QFrame(this);
|
||||
info->setObjectName(QStringLiteral("filterInfo"));
|
||||
info->setFixedWidth(kInfoW);
|
||||
applyTokenizedStyleSheet(
|
||||
info, QStringLiteral("QFrame#filterInfo { border:1px solid {{border/default}};"
|
||||
" border-radius:4px; }"));
|
||||
auto* infoLay = new QVBoxLayout(info);
|
||||
infoLay->setContentsMargins(space::kXl, space::kXl, space::kXl, space::kXl);
|
||||
infoLay->setSpacing(space::kLg);
|
||||
|
||||
auto* statForm = new QFormLayout();
|
||||
statForm->setLabelAlignment(Qt::AlignLeft);
|
||||
rangeValueLbl_ = addInfoRow(statForm, QStringLiteral("数值范围:"));
|
||||
percentLbl_ = addInfoRow(statForm, QStringLiteral("当前数据量占比:"));
|
||||
auto* origVal = addInfoRow(statForm, QStringLiteral("原始点数:"));
|
||||
origVal->setText(QString::number(originalPoints_));
|
||||
origVal->setStyleSheet(QStringLiteral("color:%1;").arg(kHighlight)); // 橙色高亮
|
||||
currentPtsLbl_ = addInfoRow(statForm, QStringLiteral("当前点数:"));
|
||||
currentPtsLbl_->setStyleSheet(QStringLiteral("color:%1;").arg(kHighlight)); // 橙色高亮
|
||||
infoLay->addLayout(statForm);
|
||||
|
||||
// 最大值在上、最小值在下(对照原版输入框顺序)。可编辑表单走 §7.0.10 唯一实现。
|
||||
auto* inputForm = formkit::makeEditForm();
|
||||
maxSpin_ = new QDoubleSpinBox(this);
|
||||
maxSpin_->setRange(-kSpinRange, kSpinRange);
|
||||
maxSpin_->setDecimals(2);
|
||||
formkit::capField(maxSpin_);
|
||||
form->addRow(formkit::editLabel(QStringLiteral("最小值")), minSpin_);
|
||||
form->addRow(formkit::editLabel(QStringLiteral("最大值")), maxSpin_);
|
||||
cardLay->addLayout(form);
|
||||
root->addWidget(card);
|
||||
minSpin_ = new QDoubleSpinBox(this);
|
||||
minSpin_->setRange(-kSpinRange, kSpinRange);
|
||||
minSpin_->setDecimals(2);
|
||||
inputForm->addRow(formkit::editLabel(QStringLiteral("最大值"), this), maxSpin_);
|
||||
inputForm->addRow(formkit::editLabel(QStringLiteral("最小值"), this), minSpin_);
|
||||
infoLay->addLayout(inputForm);
|
||||
|
||||
// 计算分布 / 重置(信息区中部,对照原版 .filter-actions)。
|
||||
auto* actionLay = new QHBoxLayout();
|
||||
auto* calcBtn = new QPushButton(QStringLiteral("计算分布"), this);
|
||||
auto* resetBtn = new QPushButton(QStringLiteral("重置"), this);
|
||||
actionLay->addStretch();
|
||||
actionLay->addWidget(calcBtn);
|
||||
actionLay->addWidget(resetBtn);
|
||||
infoLay->addLayout(actionLay);
|
||||
infoLay->addStretch();
|
||||
|
||||
bodyLay->addWidget(info);
|
||||
root->addLayout(bodyLay, 1);
|
||||
|
||||
// ── 底部:范围滑块 ──
|
||||
slider_ = new RangeSlider(this);
|
||||
slider_->setRange(dataMin_, dataMax_);
|
||||
slider_->setValues(dataMin_, dataMax_);
|
||||
root->addWidget(slider_);
|
||||
|
||||
// ── 底部按钮:取消 / 应用过滤(右对齐)──
|
||||
auto* btnLay = new QHBoxLayout();
|
||||
btnLay->addStretch();
|
||||
auto* cancelBtn = new QPushButton(QStringLiteral("取消"), this);
|
||||
|
|
@ -62,25 +139,69 @@ ScatterFilterDialog::ScatterFilterDialog(geopro::data::IDatasetCommandRepository
|
|||
btnLay->addWidget(applyBtn_);
|
||||
root->addLayout(btnLay);
|
||||
|
||||
// ── 三方联动(min/max 输入 ↔ 滑块 ↔ 直方图/统计)──
|
||||
connect(minSpin_, QOverload<double>::of(&QDoubleSpinBox::valueChanged), this,
|
||||
[this](double v) { setCurrentRange(v, maxSpin_->value(), false, true); });
|
||||
connect(maxSpin_, QOverload<double>::of(&QDoubleSpinBox::valueChanged), this,
|
||||
[this](double v) { setCurrentRange(minSpin_->value(), v, false, true); });
|
||||
connect(slider_, &RangeSlider::rangeChanged, this,
|
||||
[this](double lo, double hi) { setCurrentRange(lo, hi, true, false); });
|
||||
connect(calcBtn, &QPushButton::clicked, this, [this]() { refreshStats(); });
|
||||
connect(resetBtn, &QPushButton::clicked, this, &ScatterFilterDialog::onResetFilter);
|
||||
connect(cancelBtn, &QPushButton::clicked, this, &QDialog::reject);
|
||||
connect(applyBtn_, &QPushButton::clicked, this, &ScatterFilterDialog::onApply);
|
||||
|
||||
loadConfig();
|
||||
}
|
||||
|
||||
void ScatterFilterDialog::setCurrentRange(double min, double max, bool fromSlider, bool fromSpin) {
|
||||
// 同步未发起方的控件(屏蔽信号避免回环),再刷新统计/直方图。
|
||||
if (!fromSpin) {
|
||||
const QSignalBlocker b1(minSpin_);
|
||||
const QSignalBlocker b2(maxSpin_);
|
||||
minSpin_->setValue(min);
|
||||
maxSpin_->setValue(max);
|
||||
}
|
||||
if (!fromSlider && slider_) {
|
||||
const QSignalBlocker b(slider_);
|
||||
slider_->setValues(min, max);
|
||||
}
|
||||
refreshStats();
|
||||
}
|
||||
|
||||
void ScatterFilterDialog::refreshStats() {
|
||||
const double mn = minSpin_->value();
|
||||
const double mx = maxSpin_->value();
|
||||
rangeValueLbl_->setText(QStringLiteral("%1 — %2")
|
||||
.arg(QString::number(mn, 'g', 6), QString::number(mx, 'g', 6)));
|
||||
const int cur = countScatterInRange(values_, mn, mx);
|
||||
currentPtsLbl_->setText(QString::number(cur));
|
||||
const QString pct = (originalPoints_ > 0)
|
||||
? QStringLiteral("%1%").arg(
|
||||
QString::number(100.0 * cur / originalPoints_, 'f', 2))
|
||||
: QStringLiteral("0%");
|
||||
percentLbl_->setText(pct);
|
||||
if (histogram_) histogram_->setSelection(mn, mx);
|
||||
}
|
||||
|
||||
void ScatterFilterDialog::onResetFilter() {
|
||||
// 重置:恢复到全量数据域(对照原版 resetFilter)。
|
||||
setCurrentRange(dataMin_, dataMax_, false, false);
|
||||
}
|
||||
|
||||
void ScatterFilterDialog::loadConfig() {
|
||||
if (!repo_) return;
|
||||
if (!repo_) {
|
||||
setCurrentRange(dataMin_, dataMax_, false, false); // 无仓储:用数据域初值
|
||||
return;
|
||||
}
|
||||
QPointer<ScatterFilterDialog> self(this);
|
||||
repo_->getScatterFilterConfig(
|
||||
dsObjectId_, vFieldCode_, [self](bool ok, QJsonObject cfg, QString) {
|
||||
if (!self || !ok) return;
|
||||
if (!self) return;
|
||||
if (!ok) { self->setCurrentRange(self->dataMin_, self->dataMax_, false, false); return; }
|
||||
const double mn = cfg.value(QStringLiteral("min")).toDouble();
|
||||
const double mx = cfg.value(QStringLiteral("max")).toDouble();
|
||||
self->minSpin_->setValue(mn);
|
||||
self->maxSpin_->setValue(mx);
|
||||
self->rangeLabel_->setText(QStringLiteral("数值范围:%1 — %2")
|
||||
.arg(QString::number(mn, 'g', 6),
|
||||
QString::number(mx, 'g', 6)));
|
||||
self->setCurrentRange(mn, mx, false, false);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -100,8 +221,9 @@ void ScatterFilterDialog::onApply() {
|
|||
if (!self) return;
|
||||
self->applyBtn_->setEnabled(true);
|
||||
if (ok) {
|
||||
QMessageBox::information(self, self->windowTitle(),
|
||||
QStringLiteral("应用过滤成功!"));
|
||||
// 成功提示挂父窗口(对话框随即关闭)。文案对照原版「应用过滤成功!」。
|
||||
if (auto* anchor = self->parentWidget())
|
||||
showToast(anchor, QStringLiteral("应用过滤成功!"));
|
||||
self->accept();
|
||||
} else {
|
||||
QMessageBox::warning(self, self->windowTitle(),
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
#pragma once
|
||||
#include <vector>
|
||||
|
||||
#include <QDialog>
|
||||
#include <QString>
|
||||
|
||||
|
|
@ -12,29 +14,48 @@ class IDatasetCommandRepository;
|
|||
|
||||
namespace geopro::app {
|
||||
|
||||
// 「数据过滤」对话框(复刻原版 web dataFilter.vue 的范围过滤部分):
|
||||
// 打开时经 getScatterFilterConfig 取 min/max 初值填入「最小值/最大值」输入框;
|
||||
// 「应用过滤」经 applyScatterFilter 生成过滤后数据集({sourceDsObjectId, sourceVFieldCode, min, max})。
|
||||
// 直方图(原版左侧 D3 分布图)本轮后置:范围过滤为核心,直方图仅可视化辅助。
|
||||
class ScatterHistogramView;
|
||||
class RangeSlider;
|
||||
|
||||
// 「数据过滤」对话框(1:1 复刻原版 web dataFilter.vue,弹窗宽 1000px):
|
||||
// 左:数值分布直方图(自绘 ScatterHistogram,hover 柱变红 + tooltip);
|
||||
// 右:信息区(数值范围 / 当前数据量占比 / 原始点数 / 当前点数橙色高亮 + 最大值/最小值输入 +
|
||||
// 「计算分布」「重置」);底部:范围双手柄滑块 + 「取消」「应用过滤」。
|
||||
// min/max 输入、滑块、直方图选区三方联动;统计随当前区间实时更新。
|
||||
// 打开时经 getScatterFilterConfig 取 min/max 初值;「应用过滤」经 applyScatterFilter
|
||||
// 生成过滤后数据集({sourceDsObjectId, sourceVFieldCode, min, max});成功提示 toast。
|
||||
// 回调用 QPointer 守卫(虽 modal exec,仍异步回调)。
|
||||
class ScatterFilterDialog : public QDialog {
|
||||
Q_OBJECT
|
||||
public:
|
||||
// values = 当前 V 值数组(与图上散点 v 同源,驱动直方图分布 + 统计);可空(空则空态)。
|
||||
ScatterFilterDialog(geopro::data::IDatasetCommandRepository* repo, QString dsObjectId,
|
||||
QString vFieldCode, QWidget* parent = nullptr);
|
||||
QString vFieldCode, std::vector<double> values, QWidget* parent = nullptr);
|
||||
|
||||
private:
|
||||
void loadConfig(); // 取 min/max 初值
|
||||
void onApply(); // 应用过滤
|
||||
void loadConfig(); // 取 min/max 初值
|
||||
void onApply(); // 应用过滤
|
||||
void onResetFilter(); // 重置:恢复到全量数据域
|
||||
void setCurrentRange(double min, double max, bool fromSlider, bool fromSpin); // 三方联动
|
||||
void refreshStats(); // 刷新统计(占比/当前点数)与直方图选区
|
||||
|
||||
geopro::data::IDatasetCommandRepository* repo_ = nullptr;
|
||||
QString dsObjectId_;
|
||||
QString vFieldCode_;
|
||||
|
||||
QLabel* rangeLabel_ = nullptr; // 「数值范围:min — max」
|
||||
std::vector<double> values_; // 全量有限 v 值(统计/分箱用)
|
||||
double dataMin_ = 0.0; // 数据域下界
|
||||
double dataMax_ = 0.0; // 数据域上界
|
||||
int originalPoints_ = 0; // 原始点数(全量有限值个数)
|
||||
|
||||
QLabel* rangeValueLbl_ = nullptr; // 「数值范围」值
|
||||
QLabel* percentLbl_ = nullptr; // 「当前数据量占比」值
|
||||
QLabel* currentPtsLbl_ = nullptr; // 「当前点数」值(橙色高亮)
|
||||
QDoubleSpinBox* minSpin_ = nullptr;
|
||||
QDoubleSpinBox* maxSpin_ = nullptr;
|
||||
QPushButton* applyBtn_ = nullptr;
|
||||
ScatterHistogramView* histogram_ = nullptr; // 左侧分布直方图
|
||||
RangeSlider* slider_ = nullptr; // 底部范围滑块
|
||||
};
|
||||
|
||||
} // namespace geopro::app
|
||||
|
|
|
|||
|
|
@ -0,0 +1,175 @@
|
|||
#include "panels/chart/ScatterHistogram.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
#include <QEvent>
|
||||
#include <QMouseEvent>
|
||||
#include <QPaintEvent>
|
||||
#include <QPainter>
|
||||
#include <QToolTip>
|
||||
|
||||
#include "panels/chart/ScatterDataOps.hpp" // buildScatterHistogram
|
||||
|
||||
namespace geopro::app {
|
||||
|
||||
namespace {
|
||||
constexpr int kBinCount = 20; // 分箱数(对照原版 D3 stepRange=20)
|
||||
const QColor kBarIn(64, 128, 255); // 选区内柱:蓝(对照原版 #4080FF)
|
||||
const QColor kBarOut(200, 205, 215); // 选区外柱:灰
|
||||
const QColor kBarHover(245, 63, 63); // hover 柱:红(对照原版 #F53F3F)
|
||||
const QColor kIndicator(64, 128, 255, 51); // 选区指示矩形:rgba(64,128,255,0.2)
|
||||
const QColor kAxis(150, 150, 150); // 轴线/刻度
|
||||
constexpr int kPadL = 8; // 左右内边距
|
||||
constexpr int kPadR = 8;
|
||||
constexpr int kPadTop = 8; // 顶部内边距
|
||||
constexpr int kAxisH = 18; // 底部刻度区高度
|
||||
constexpr int kBarGap = 1; // 柱间距(像素)
|
||||
} // namespace
|
||||
|
||||
ScatterHistogramView::ScatterHistogramView(QWidget* parent) : QWidget(parent) {
|
||||
setMinimumHeight(160);
|
||||
setMinimumWidth(280);
|
||||
setMouseTracking(true); // 无按键也接收 MouseMove,用于 hover 高亮
|
||||
}
|
||||
|
||||
void ScatterHistogramView::setValues(const std::vector<double>& values) {
|
||||
values_.clear();
|
||||
values_.reserve(values.size());
|
||||
for (double x : values)
|
||||
if (std::isfinite(x)) values_.push_back(x);
|
||||
if (values_.empty()) {
|
||||
dataMin_ = dataMax_ = 0.0;
|
||||
} else {
|
||||
dataMin_ = *std::min_element(values_.begin(), values_.end());
|
||||
dataMax_ = *std::max_element(values_.begin(), values_.end());
|
||||
}
|
||||
hoverBin_ = -1;
|
||||
update();
|
||||
}
|
||||
|
||||
void ScatterHistogramView::setSelection(double min, double max) {
|
||||
selMin_ = min;
|
||||
selMax_ = max;
|
||||
hasSel_ = (min <= max);
|
||||
update();
|
||||
}
|
||||
|
||||
int ScatterHistogramView::binAtX(int px) const {
|
||||
if (values_.empty() || !(dataMax_ > dataMin_)) return -1;
|
||||
const QRect r = rect();
|
||||
const int plotL = r.left() + kPadL;
|
||||
const int plotR = r.right() - kPadR;
|
||||
const int plotW = plotR - plotL;
|
||||
if (plotW <= 0) return -1;
|
||||
const double binW = static_cast<double>(plotW) / kBinCount;
|
||||
if (binW <= 0) return -1;
|
||||
const int idx = static_cast<int>((px - plotL) / binW);
|
||||
if (idx < 0 || idx >= kBinCount) return -1;
|
||||
return idx;
|
||||
}
|
||||
|
||||
void ScatterHistogramView::mouseMoveEvent(QMouseEvent* event) {
|
||||
const int bin = binAtX(event->position().toPoint().x());
|
||||
if (bin != hoverBin_) {
|
||||
hoverBin_ = bin;
|
||||
update();
|
||||
}
|
||||
if (bin >= 0 && dataMax_ > dataMin_) {
|
||||
// tooltip:数值范围 + 数据点数量(对照原版 D3 tooltip 两行)。
|
||||
const double step = (dataMax_ - dataMin_) / kBinCount;
|
||||
const double lo = dataMin_ + bin * step;
|
||||
const double hi = lo + step;
|
||||
const auto h = buildScatterHistogram(values_, dataMin_, dataMax_, kBinCount);
|
||||
const int cnt = (bin < static_cast<int>(h.counts.size())) ? h.counts[static_cast<std::size_t>(bin)] : 0;
|
||||
QToolTip::showText(event->globalPosition().toPoint(),
|
||||
QStringLiteral("数值范围: %1 - %2\n数据点数量: %3")
|
||||
.arg(QString::number(std::llround(lo)),
|
||||
QString::number(std::llround(hi)),
|
||||
QString::number(cnt)),
|
||||
this);
|
||||
} else {
|
||||
QToolTip::hideText();
|
||||
}
|
||||
QWidget::mouseMoveEvent(event);
|
||||
}
|
||||
|
||||
void ScatterHistogramView::leaveEvent(QEvent* event) {
|
||||
if (hoverBin_ != -1) {
|
||||
hoverBin_ = -1;
|
||||
update();
|
||||
}
|
||||
QToolTip::hideText();
|
||||
QWidget::leaveEvent(event);
|
||||
}
|
||||
|
||||
void ScatterHistogramView::paintEvent(QPaintEvent*) {
|
||||
QPainter p(this);
|
||||
p.setRenderHint(QPainter::Antialiasing, false);
|
||||
|
||||
const QRect r = rect();
|
||||
const int plotL = r.left() + kPadL;
|
||||
const int plotR = r.right() - kPadR;
|
||||
const int plotTop = r.top() + kPadTop;
|
||||
const int plotBottom = r.bottom() - kAxisH;
|
||||
const int plotW = plotR - plotL;
|
||||
const int plotH = plotBottom - plotTop;
|
||||
if (plotW <= 0 || plotH <= 0) return;
|
||||
|
||||
// 数据域无效(无值/退化区间)→ 仅画基线,空态。
|
||||
if (values_.empty() || !(dataMax_ > dataMin_)) {
|
||||
p.setPen(kAxis);
|
||||
p.drawLine(plotL, plotBottom, plotR, plotBottom);
|
||||
return;
|
||||
}
|
||||
|
||||
// 在全量数据域上分箱(每柱代表一个等宽区间)。
|
||||
const auto h = buildScatterHistogram(values_, dataMin_, dataMax_, kBinCount);
|
||||
const int n = static_cast<int>(h.counts.size());
|
||||
if (n <= 0) return;
|
||||
const int maxCount = *std::max_element(h.counts.begin(), h.counts.end());
|
||||
if (maxCount <= 0) {
|
||||
p.setPen(kAxis);
|
||||
p.drawLine(plotL, plotBottom, plotR, plotBottom);
|
||||
return;
|
||||
}
|
||||
|
||||
// 值 → 像素 x 的映射(数据域 [dataMin,dataMax] → [plotL,plotR])。
|
||||
auto xPix = [&](double val) {
|
||||
return plotL + (val - dataMin_) / (dataMax_ - dataMin_) * plotW;
|
||||
};
|
||||
|
||||
// 选区指示矩形(先画,柱叠其上)。
|
||||
if (hasSel_ && selMax_ > selMin_) {
|
||||
const double lo = std::clamp(selMin_, dataMin_, dataMax_);
|
||||
const double hi = std::clamp(selMax_, dataMin_, dataMax_);
|
||||
const QRectF ind(xPix(lo), plotTop, xPix(hi) - xPix(lo), plotH);
|
||||
p.fillRect(ind, kIndicator);
|
||||
}
|
||||
|
||||
// 画柱:高度按计数归一;hover 柱红;其余按选区内蓝/外灰。
|
||||
const double binW = static_cast<double>(plotW) / n;
|
||||
for (int i = 0; i < n; ++i) {
|
||||
const double binLo = dataMin_ + i * h.step;
|
||||
const double binHi = binLo + h.step;
|
||||
const int barH = static_cast<int>(static_cast<double>(h.counts[static_cast<std::size_t>(i)]) /
|
||||
maxCount * plotH);
|
||||
const int bx = static_cast<int>(plotL + i * binW) + kBarGap;
|
||||
const int bw = std::max(1, static_cast<int>(binW) - 2 * kBarGap);
|
||||
// 柱中心落在选区内 → 高亮蓝(与原版“区间内点高亮”观感一致);hover 优先红。
|
||||
const double binCenter = (binLo + binHi) / 2.0;
|
||||
const bool inSel = hasSel_ && binCenter >= selMin_ && binCenter <= selMax_;
|
||||
const QColor c = (i == hoverBin_) ? kBarHover : (inSel ? kBarIn : kBarOut);
|
||||
p.fillRect(bx, plotBottom - barH, bw, barH, c);
|
||||
}
|
||||
|
||||
// 底部基线 + 两端数值刻度(min/max)。
|
||||
p.setPen(kAxis);
|
||||
p.drawLine(plotL, plotBottom, plotR, plotBottom);
|
||||
p.drawText(QRect(plotL, plotBottom, plotW / 2, kAxisH), Qt::AlignLeft | Qt::AlignVCenter,
|
||||
QString::number(dataMin_, 'g', 4));
|
||||
p.drawText(QRect(plotL + plotW / 2, plotBottom, plotW / 2, kAxisH),
|
||||
Qt::AlignRight | Qt::AlignVCenter, QString::number(dataMax_, 'g', 4));
|
||||
}
|
||||
|
||||
} // namespace geopro::app
|
||||