Compare commits

...

46 Commits

Author SHA1 Message Date
gaozheng 9d3b103e32 docs(api): 三维体/切片/异常 OpenAPI 设计稿(贴合存量 dsObject 面)
三件套 Swagger(JSON):
- 三维体/切片 = 纯元数据 dsObject:增删改查/属性复用存量 dsObject 面,
  各加 1 个登记端点;体素字节/切面数据全在客户端,后端零数据端点。
- 异常复用整套 /business/exception 端点(实体无关),异常体(consortium)
  分组为存量已有;3D 仅扩展 location 几何(worldPts+plane)与截图(R88)。
- 归属结构 TM → 三维体(dd_voxel) → 切片(dd_slice),异常挂三维体。
2026-06-23 21:31:55 +08:00
gaozheng c2ec1d34b4 feat(io): IprbReader 新增 readIprbRange 道区间读取
只 seek 并读取 [t0,t1) 道,不载全文件,供流式 slab 装配内存有界。
偏移与读取字节数全 64 位防大文件溢出;越界与文件打不开抛 std::runtime_error。
不改动 readIprb 现有行为。
2026-06-23 21:29:13 +08:00
gaozheng 0537e938b4 feat(vtk): 12d 打磨探针-梯度不透明度+光照,出体内部对比3图
新增 gpr_poc polish 子命令:同一全分辨率 level0 局部段 + 斜穿俯视视角离屏渲三图对比体内部白雾能否靠打磨解决。梯度不透明度 piecewise 按实测梯度幅值分布(median/p90/p99)标定。三图唯一变量为梯度不透明度/光照:a 基线白雾、b 加梯度不透明度、c 加梯度+光照。各报结构像素/亮度/真实fps。结论:打磨消雾并让强梯度层界面浮出,但沿线长均匀段固有偏雾,梯度不透明度无法凭空长出层。
2026-06-23 20:53:42 +08:00
gaozheng fb175d6d3d fix(gpr_poc): view 交互窗口统一走单纹理快路,去 MultiBlock 分块
交互 view 任何相机位置都只渲一张 vtkImageData + 单 vtkSmartVolumeMapper,
与 --preview 走完全同一条产单图 + 同一 mapper 的路径:

- LOD 选层:远观/中景升到最细的整卷各轴<=16384 层(L2/L3)整卷一张纹理;
  拉近取 level0 视野覆盖的 X 子区域重组一张纹理。两条都用 buildLevelImage /
  buildLocalLevel0Image 产单图。
- 去掉交互路径的 OutOfCoreSource/BrickPager/MultiBlock + budget 分块渲染
  (renderC 等 POC 子命令仍保留)。
- LOD 随缩放真切换,只在 EndInteraction 重组一次。
- 新增 view --preview --near 拉近预览(view-near.png),与真窗口同路径。
- parseArgs 支持无值布尔旗标(--preview --near 不再误吞)。

修复:开窗+拉近无缺块(整张纹理),fps 从个位数升到几十~上百。
2026-06-23 20:15:28 +08:00
gaozheng f4922dd6e2 feat(gpr_poc): view 交互窗口默认视觉参数采用 gallery var4
无 --gallery/--preview 的交互窗口此前用旧暗默认,现统一为 var4:
seismic 配色 + V 形实体不透明度包络(floor0.035/mid0.38/max0.84) +
取景(El18/Az22/Zoom2.0) + exagg8 + 略亮冷灰背景。

- 抽出 kViewDefaultVariant 引用 kGalleryVariants 末项(var4)作单一来源,
  交互默认与 view-var4 走同一份参数,避免复制粘贴漂移(DRY)。
- 新增 makeVariantProperty 统一按包络建体属性,gallery 与交互默认共用。
- viewSetupDefaultFrame 取景角度改取自 var4;cmdView 配色/包络/背景/exagg
  默认改为 var4;--exagg/--opacity 显式传入仍覆盖。
- 验证:view --preview 产出 view-default.png 与 view-var4 一致
  (结构像素 30.18% vs 30.11%、亮度 33.9 vs 33.7、fps 77 vs 75);
  view --smoke 仍 OK。
2026-06-23 19:52:35 +08:00
gaozheng 9af363080a feat(poc): view 视觉调参画廊 4 组离屏 preview
view 加 --gallery / --preview --variant N:同一沿线中段局部段+同一相机框法,渲 4 组不同视觉参数(不透明度包络/配色/取景/背景)各存 PNG 供挑选。

新增 makeSeismicColorScale(红白蓝高对比)、makeJetColorScale(全程高饱和)、makeSolidVolumeProperty(V形实体感包络:近零背景压低但可见、中高值段普遍 0.3~0.85、半透明实心内部层次可读)、meanBrightness(画面均亮度度量)。

基线默认结构像素仅 0.07%(几乎全黑、缩角落);4 组拉到 17~30%,亮度显著提升,fps 76~86(≫30)。推荐 var4 作默认。
2026-06-23 19:40:52 +08:00
gaozheng b1a8d1365d fix(gpr_poc): view 默认取景改局部段+加 --preview+fps 显真实帧率
修 view 开窗空白:默认相机框整条 2.2km 线(横截面 1:34),即便 exagg=8
也是隐形细带。三处修复:
1. 默认取景改对准沿线中段一个 ~256 道(4 brick 列)的全分辨率局部体,
   ResetCamera+Zoom 框满画面,首帧即见层状结构(同 lod-tuned-local.png 取景)。
2. 新增 view --preview:用与真窗口完全相同的相机/source/exagg/传函离屏渲一帧
   存 view-default.png,旋 N 帧报真实 fps+结构像素数(排除深蓝灰背景)+纹理错。
3. fps 文本改为松手时连渲 3 帧累计实际 Render 耗时取均值,不再把空闲间隔算进去。

实测 preview:默认视角有结构(结构像素>0)、无纹理错、真实渲染 ~185fps。
2026-06-23 19:27:39 +08:00
gaozheng e62e2cdc8d fix(vtk): 切项目复位重锚标志,修底图清空后再选不重显的回归
上一改 basemap->hide() 引入回归:切项目后再次勾选数据集,底图不再渲染。
根因:勾选是增量渲染、不走 VtkSceneView::clear(),frameAnchoredToData_ 残留为
旧项目的 true → anchorFrameIfNeeded 直接 return 不重锚 → onFrameReanchored 不触发
→ 被 hide() 的底图永不再 show(hide 前底图一直挂着才显得"正常")。
修:VtkSceneView 加 resetFrameAnchor(),clearCentral 中 hide() 前复位 →
新项目首个数据重锚→onFrameReanchored→底图按新项目位置重显。

build all 绿,341/341。
2026-06-23 19:17:11 +08:00
gaozheng 07309da1b3 fix(gpr_poc): 修复 view 子命令无限渲染循环导致窗口卡死
EndEvent 观察者会在每次 Render() 结束触发,而回调内部又调用
rw->Render(),那次 Render 再触发 EndEvent → 再进回调,形成无限
递归重渲,窗口无响应、fps 趋近 0。

- 删除 rw->AddObserver(EndEvent, cb) 自激源
- 保留 iren EndInteractionEvent 观察者(仅松手触发一次,不自激)
- viewOnInteract 加 inCb 防重入布尔(双保险)
- 整卷粗层 cachedWholeLevel 缓存不变,概览级别不变时不重建 image
2026-06-23 19:09:17 +08:00
gaozheng 251046f885 fix(vtk): 切项目 clearCentral 补清底图瓦片(basemap->hide)
切项目时帘面/体素/切片/2D足迹已清,但底图瓦片(锚在旧项目地理位置)残留。
clearCentral 增 basemap->hide() 移除全部瓦片;新项目数据到来 onFrameReanchored
按新位置重显。至此切项目中央区(三栏+渲染图元+底图)全部清空。

build all 绿,341/341。
2026-06-23 19:06:52 +08:00
gaozheng bdc6c90db8 fix(gpr_poc): view 概览整卷渲染修空窗 + 控制台 UTF-8 修乱码
概览/中远视角相机选中粗层时,viewRefreshBlocks 升到最细的整卷各轴
≤16384 的 level(本数据 level0/1 长线 X 超 16384 → 升 level2),用
buildLevelImage 整卷重组单块渲(忽略 budget),不再被 budget=64 砍成
64/696 稀疏块;仅相机选中 level0 全分辨率才走分块+budget(同 12c)。
main 入口 SetConsoleOutputCP(CP_UTF8) 修 Windows 中文控制台 GBK 乱码。
2026-06-23 18:58:42 +08:00
gaozheng 27905511e6 fix(vtk): 空态浮层钳进视图不溢出+文案更新;切项目清空中央区
- CenterOverlay::reposition 钳制浮层尺寸≤host、偏移取非负,缩小子视图时不再超界;
  空态标题/提示改为与当前功能一致(勾选左侧三维/二维/三维分析栏数据集渲染),标签换行
- 切换项目(topBar projectSwitchRequested / ProjectListDialog projectChosen,带 id≠当前
  守卫避开 delete-refresh)先 clearCentral:清三栏(col3D/col2D/colAnalysis setDatasets({}))
  + 清勾选集(checkedProfiles/Analysis/Slice)下发空到 VTK(pushChecked/syncSlices/
  setChecked2DDatasets({})) + 恢复空态浮层

build all 绿,341/341。
2026-06-23 18:56:40 +08:00
gaozheng bec6a376d5 fix(ui): 详情对话框页脚/表单改走 FormKit 符合视觉规范(去Arco式大按钮)
之前为"像原版"手搭 QFormLayout + Arco式页脚(45%等宽/两端对齐/多主按钮/顺序反),
违反 Geopro3.0 视觉规范 §6.7/§7.5/§7.0.10。改为规范实现(字段结构/API 不动):
- 白化/另存/滤波/网格化:页脚改 formkit::addDialogButtons(右对齐 取消+确定,主按钮蓝);
  异步确认改接 Ok 按钮 clicked(校验/whitenData/save 成功才 accept);表单改 makeEditForm
  +editLabel+capField+addSection;宽度按规范(白化560/另存420/滤波保留宽/网格分组)
- 滤波"保存设置"作次按钮(ActionRole)不抢 primary;网格化 上一步(次)左+取消/确定右
- 快查:ScatterFilterDialog 可编辑输入改 makeEditForm;AutoAnnotation 主操作 setDefault
- 异常各弹窗/反演表单 已合规未动

build all 绿,341/341。
2026-06-23 18:40:11 +08:00
gaozheng 824898a65c feat(poc): gpr_poc renderLOD 探针验证 LOD-fps 全量交互渲染可行性
四件事全离屏双闸实测(本机 RTX3060):
(a)粗层概览 level2 ~752fps (b)全分辨率局部 level0 ~380fps
(c)LOD 切换过渡切换帧 ~5.5ms 无可感知卡顿 (d)存 3 张对比 PNG。
双闸:无 3D 纹理维度错误 + 三段均回读非空像素,fps 可信。
判据:两端均达交互级且切换无卡顿 -> LOD-based C 路线钉死可行。
最低配未验,需目标机复测。tools CMake 加 IOImage 供 PNG 截图。
2026-06-23 17:49:11 +08:00
gaozheng 7d0e72dec2 feat(ui): 全局下拉空态优化 EmptyAwareComboBox(占位+暂无数据,对齐Arco)
下拉无数据时原为空白框+空弹窗,不优雅。新增统一空态感知下拉对齐原版 Arco ASelect:
- EmptyAwareComboBox(QComboBox 子类):showPopup 无真实项时临时插禁用灰色「暂无数据」
  条(关闭即移除,不污染取值),仍可点开;占位经 setPlaceholderText(currentIndex=-1)显示
- FormKit 加 comboBox(placeholder) 统一入口
- 全局替换全部 37 处 new QComboBox:7 处数据驱动给占位(白化文件/异常类型/反演模型/
  导入类型脚本/导出模板),30 处仅换类保留自动选首项语义(逐处判断,不破坏取值)

build all 绿,341/341。
2026-06-23 17:35:54 +08:00
gaozheng f51706b4b3 feat(poc): renderC-partitioned 单 mapper SetPartitions 整卷体绘制 fps 探针
整卷喂单个 vtkOpenGLGPUVolumeRayCastMapper + SetPartitions(ceil(nx/16384),1,1),
绕过 GL_MAX_3D_TEXTURE_SIZE 纹理墙。双闸(纹理错捕获+真实回读非空像素)防假帧率。

实测(44476x29x162,398MB):SetPartitions(3,1,1) 真渲出(非空 1264px,无纹理错),
体绘制 ~8.8-11fps,峰值内存 ~556-653MB。绕过纹理墙成功但未达交互级(<15fps),
与 renderC MultiBlock 9.5 静态同档 → 瓶颈在全分辨率整卷 ray cast 本身,
非每块一 mapper。VTK 这条路交互级天花板暴露。
2026-06-23 17:10:49 +08:00
gaozheng 2beb97fa73 feat(vtk): 核外分块体绘制 OutOfCoreSource + renderC 基准(POC-C 命门探针)
OutOfCoreSource 实现 IVolumeRenderSource:相机选 LOD + 视锥裁剪选视野块 →
BrickPager(LRU,内存恒定)→ 每块 ≤64³ vtkImageData(带世界坐标)。renderC 用
vtkMultiBlockVolumeMapper 渲染工作集,绕开 GL_MAX_3D_TEXTURE_SIZE(16384)单轴墙。

实测(单线 store nx=44476>16384,renderB INVALID):
- 分块核外真渲出(非空像素,无纹理维度错),对照 renderB 整卷上传失败。
- 内存恒定:budget=64 驻留 64 块 / 220MB,与体总量无关。
- 静态工作集 9.5 fps;动态换页 1.45 fps(qUncompress 解压+每帧重建 mapper 177ms/帧,
  撞墙);fps 随块数近似反比劣化(256 块 0.47 fps)。结论与缓解见 poc-results-C.md。

含单元测试(纹理安全/budget 恒定/块世界坐标)。
2026-06-23 15:07:09 +08:00
gaozheng 438ed78aad feat(detail): 新增异常类型完整1:1(ExceptionTypeDialog 880px双Tab图例编辑器)
替换最小版,完整复刻原版 ExceptionLabel 子弹窗:
- 新建 ExceptionTypeDialog(880px,双Tab异常属性/标注名称):
  异常属性Tab(类型名称/代号必填/标准编号/标准名称/说明 + 按markType点/线/面/文字的
  图例样式编辑器:形状/大小/颜色/不透明度/线形/填充/字体,选项默认对照原版)
  标注名称Tab(自定义格式+分隔符+可增删名称列表 fieldName/fieldCode)
- 仓储 newCustomExceptionType 替换为 addExceptionType(POST /business/exceptionType,
  body 全字段对照原版 handleBeforeOk:legend/exceptionNameList/type:2/exceptionMarkType)
- ExceptionDialog「新增异常类型」按钮接通,成功刷新类型下拉并选中

build all 绿,341/341。
2026-06-23 14:58:36 +08:00
gaozheng 6cc973a183 feat(detail): 异常详情坐标系/网格色阶templateId/新增异常类型 收尾1:1
- I11 异常详情经纬度/投影坐标:Anomaly 加 lonLatPts/eastNorthPts,parseDatasetAnomalies
  按原版响应字段(latitudeLongitude.latLon / geographicalCoordinates.coordinates)解析;
  坐标系下拉条件显示(有 latLon 才给三项,对照原版 latLon.length===0),纯展示不换算
- 网格剖面色阶 templateId:ContourPayload 加 templateId,inversion.grid 加载/重载解析
  getDetail 顶层 templateId,GridDataChartView 传入色阶编辑器→网格色阶另存覆盖可用
- 新增异常类型:仓储加 newCustomExceptionType(POST /business/customExceptionType
  {projectId,exceptionTypeName}),ExceptionDialog 按钮接通+刷新类型下拉

build all 绿,338/338。
2026-06-23 14:35:27 +08:00
gaozheng 3dfe8b54f5 feat(detail): 色阶编辑器另存覆盖 + 散点模板库可用(1:1)
- IColorTemplateRepository/Api 加 updateLvlTemplate(PUT /business/lvlTemplate
  {id,templateName,properties}),对照原版 updateLvlTemplate
- ColorScaleConfigDialog 另存为改自定义弹窗:名称+「覆盖原模板」复选(仅 lvlTemplateId
  非空可勾)→勾选 updateLvlTemplate / 否则 saveLvlTemplate;ctor 增 lvlTemplateId(默认空)
- 散点路径接通模板库:工厂给 Scatter 视图注入 colorTplRepo,构造色阶编辑器传
  colorTplRepo+projectId+data_.templateId→另存/打开/覆盖可用(原 nullptr 禁用)
- 3D 体色阶编辑器(main.cpp)及网格(GridDataChartView)用默认空 templateId,行为不变

build all 绿,336/336。
2026-06-23 14:08:35 +08:00
gaozheng 0212fb5d2e feat(detail): 自动标注对话框补等值线预览图(I13 1:1)
右上补轻量 QwtPlot+ContourPlotItem 渲染反演网格等值面(复用 GridDataChartView 同款
渲染器与 ColorMapService);执行自动标注后 parseDatasetAnomalies 解析预演异常实时叠加,
删除预览行同步移除。构造改收 Grid+ColorScale(统计从 grid.values 算)。

build all 绿,336/336。
2026-06-23 13:53:56 +08:00
gaozheng 03805f4326 feat(poc): POC-B 离屏 GPU 渲染基准(offscreen-smoke 闸门 + renderB 体绘制/切片 fps)
- gpr_poc 新增 offscreen-smoke: 离屏 vtkRenderWindow + cube + 读回像素, 闸门验证离屏 GL 可用
- gpr_poc 新增 renderB: 整卷 VTK_SHORT 体绘制(旋相机) + 切片扫描(reslice 沿 Z) fps 实测
- 关键发现: line 001 cellXY=0.05 整卷 44476x29x162, X 维超 GL_MAX_3D_TEXTURE_SIZE(16384),
  vtkVolumeTexture 上传失败, 体绘制 fps 如实标 INVALID(绝不上报假帧率); 切片 54.6fps 真实流畅
- 用 CapturingOutputWindow 捕获纹理维度错误 + 维度超限双判据, 避免误把空纹理假帧率当性能
- CMakeLists 补 RenderingVolume/RenderingVolumeOpenGL2/ImagingCore/InteractionStyle 组件
2026-06-23 13:52:51 +08:00
gaozheng 75cf8d40ba fix(detail): 反演动态表单支持11种控件+必填校验 + grid按钮视觉 + 色阶templateId
- InversionFormDialog 动态表单不再一律下拉:复用项目既有 parseEditableForm +
  DynamicFormEditor(与对象/结构编辑同款),按 displayComponentType 渲染 11 种控件
  (文本/只读/复选/下拉/日期/时间/日期时间/多行/数字按dataType+limit/树选降级/步进)
  + requiredType 必填校验/只读禁用。生成视电阻率纯select行为不变。
  删除被孤立的 InversionFormParse + 其测试。
- grid 反演按钮行:左"电法列表"radio + 右蓝色主按钮 space-between(仅dd_grid)
- 色阶保存带 templateId(ScatterPayload+DTO捕获色阶detail顶层templateId,measurement
  与反演原数据两路;空可省,对照原版)

后续项(未动,与3D共享风险):ColorScaleConfigDialog 另存覆盖/散点模板库可用。
build all 绿,336/336。GPR/金字塔/.superpowers WIP 未碰。
2026-06-23 12:44:42 +08:00
gaozheng 6fa0a31f3e docs(poc): 补齐 POC-B 真实数据实测指标 + Z 尺度诊断结论
readIprb 修复后线 001 (cellXY=0.2,cellZ=0.05,levels=2) 端到端跑通:
体维度 11120x8x162、体素 14.4M、压缩比 1.88x、build≈22.6s/峰值 4.98GB、
load 335ms/38MB。深度链路验证正确:dz≈0.009778m、nz=162(非旧 §3 误报的 1,
为 soilVelocity 换算缺失时代的遗留),CLI specFromSurvey 无需修改。
2026-06-23 12:37:53 +08:00
gaozheng d75a52e519 fix(io/gpr): readIprb 以文件大小为权威推导道数
真实明星路数据规律为「道数 == LAST TRACE」(非 lastTrace+1),旧实现硬假设
traces=lastTrace+1 并严格校验文件字节相等,导致真实 .iprb 装配抛错。

改为 traces = fileBytes / (samples*2),要求字节数为 samples*2 整数倍(否则抛),
lastTrace 仅作 header 提示不再决定道数。更新单测:新增
FileSizeIsAuthoritativeNotLastTracePlusOne(lastTrace=N 但文件含 N 道 → traces==N),
ReadsInt16AndLayout/ThrowsOnSizeMismatch 语义不变仍通过。
2026-06-23 12:37:42 +08:00
gaozheng bfd7d4aafd feat(poc): gpr_poc headless 度量 CLI(地基端到端串联)
串起 assembleGprSurvey→buildGprVolume→ChunkedVolumeStore::write→
buildPyramid→WholeVolumeSource,提供 build/load/selftest 三子命令,
输出建体耗时/维度/体积/压缩比/加载/峰值内存指标(Psapi 峰值工作集)。

selftest 合成数据端到端 PASS。真实明星路数据 BLOCKED:前置 readIprb
的 traces=lastTrace+1 严格校验与真实文件「道数=lastTrace」系统性不符,
装配阶段即抛异常,未擅自改前置/其单测,如实记录于 poc-results-B.md。
2026-06-23 12:27:10 +08:00
gaozheng 6bc7c23a8c fix(detail): inversion 异常/自动标注/描述交互返工对齐原版 + 修 getExceptionName
- I9 文字标注:落点后弹 ExceptionTextDialog(字体/大小/颜色/不透明度/内容)写 customLegend;
  补"新增异常类型"按钮(完整子流程标注待办);Anomaly 增 Text=4 + 文字字段
- 修 getExceptionName:原版 data 为纯字符串,客户端误当对象解析→名称回填失败;
  改 wireString 解析,回调签名改 (bool,QString,QString);切类型每次回填
- I10 删除文案对齐原版 contourContentDelete
- I11 详情返工:380px 抽屉式双Tab(图例/坐标),线样式改只读,坐标系切换(图形/经纬度/投影)
  +顶点数+导出txt(经纬度/投影无换算数据,标注;图形坐标可用),提交体仍 {id,exceptionName,remark}
- I13 自动标注返工:1400px,规则卡片(标题/折叠/删除),阈值模式 radio(切换清空),
  右上统计(max/min/mean/median),预览表序号+逐条删除(等值线预览图高成本待办)
- I14 富文本补 背景色/对齐/字体族 工具栏 + QuillDelta 字体族往返;去下划线/列表(原版无)

build all 绿,339/339。GPR/金字塔 WIP 未碰。
2026-06-23 12:26:16 +08:00
gaozheng 5dbbb2576c feat(render): brick 分页器(LRU 工作集,内存恒定核外渲染)
实现 geopro::render::BrickPager:驻留 ≤ budget 个解压块,
按 LRU 淘汰,与体总大小无关。requestVisible 按请求顺序更新
LRU 并淘汰至预算;get 命中返回数据指针、不改 LRU。
键为完整 BrickId(含 level);std::list 记录 recency +
unordered_map 存数据与迭代器,touch/淘汰均 O(1)。
TDD:test_brick_pager 验证恒定驻留与最早块淘汰。
2026-06-23 12:08:29 +08:00
gaozheng 86e2b6b8a8 fix(store): brickRange 用 hasRange 标志替代 (0,0) 哨兵
(0,0) 是合法值域(真实全零块,kBlank=INT16_MIN 非 0),旧实现用
(vmin==0&&vmax==0) 当未计算哨兵会误判,导致全零块每次 brickRange
都无谓解压重算,且 buildPyramid 后仍走惰性。

- BrickEntry 加 bool hasRange 显式标志
- brickRange: hasRange 真→直接返回;假→惰性算并就地缓存(mutable levels_)
- meta.json 序列化/反序列化带 hasRange(老 store 缺字段→false,惰性兼容)
- buildPyramid 回填值域时一并置 hasRange=true
- 补测试:真实全零块 brickRange 返回 (0,0) 不退化(金字塔/老 store 两路)
2026-06-23 12:02:17 +08:00
gaozheng c21226a3d7 fix(detail): measurement 对话框/工具条视觉返工对齐原版
以原版 web 为准返工 measurement 散点交互视觉:
- 数据过滤:1000px;左直方图(hover柱变红+tooltip)+右信息区(数值范围/占比/原始点数/
  当前点数橙色高亮)+底部双手柄范围滑块(新增RangeSlider)+计算分布/重置;min/max输入
  最大在上最小在下;三方联动(输入↔滑块↔直方图)
- 另存为(RawData):280px、标题"数据另存为"、确认/取消
- 色阶/另存/过滤成功 toast
- 信息面板 A红/B蓝/M绿/N橙(#F4B008);tooltip"查看散点属性"/"散点的点选"
- X/Y/V/值类型下拉固定宽 120/160/160/120;无高程禁用 X/Y
- 导出置工具条最右(页头HeaderAction跨ddCode静态)

API 字段未动。build all 绿。
2026-06-23 11:53:13 +08:00
gaozheng 687edfeca1 feat(store): ChunkedVolumeStore 增加多分辨率金字塔与每块 min/max
- buildPyramid(levels): level 0 全分辨率(复用 data.bin),level 1..levels
  逐级 2x 平均降采样(ceil(n/2));非 blank 取均值 round,全 blank 置 kBlank。
  各级独立 data_L<level>.bin + 逐块 qCompress。
- 每级每块计算并存 (min,max)(跳过 kBlank;全 blank 块 = (kBlank,kBlank)),
  写入 meta.json 的 levels 数组并回填原 bricks 索引。
- 新增 levels()/bricksX|Y|Z(level)/dims(level)/readBrick(level,...)/
  brickRange(level,...);保留兼容重载 readBrick(bx,by,bz)==readBrick(0,...)。
- 不破坏 Task 6/8:write/readBrick(bx,by,bz)/meta/bricksXYZ() 语义不变;
  老 store 无 levels 时 levels()=1,brickRange(0,...) 惰性读块计算。
2026-06-23 11:52:47 +08:00
gaozheng c15555dd8a feat(io/gpr): 多通道 .iprb+.ord 装配 GprSurvey
assembleGprSurvey 把一条测线若干通道 .iprb(同名 .iprh)+.ord 装配为
geopro::core::GprSurvey:校验各通道 samples 一致、ntraces 取最小值对齐、
按 .ord 横偏 Y 升序重排通道(values 同步置换)、x0/z0=0、dx=道距、
dz=depthOfSample(1,h);通道数与 .ord 有效通道数不符抛 runtime_error。
索引 64 位。纯 C++17,零 Qt/VTK。
2026-06-23 11:36:56 +08:00
gaozheng 4a1fecb149 fix(detail): inversion 处理类对话框视觉返工对齐原版(白化/网格化/滤波/另存)
之前用客户端 FormKit 外壳导致与原版 web modal 系统性不一致,以原版为准返工:
- 白化:550px、第2项"白化文件"、边界扩展改文本框、确认/取消顺序、标签右对齐
- 网格化:步1 500/步2 800px、网格参数/数据值设置双分组栅格、"数据值保存为"、
  补恢复默认值按钮 + 间距↔点数双向联动 + 分项校验
- 滤波:900px 左树右设置双卡片、"忽略"、矩阵行列表头 + 奇偶校验
- 另存为(Inversion):标题"另存为新的网格数据"、400px、默认名"网格数据1"、确认/取消
- 工具条:异常标注/自动标注/另存为、原数据另存为 右对齐

API 端点/请求体字段未动(已 1:1)。build all + test 324/324 绿。
2026-06-23 11:31:14 +08:00
gaozheng cc3c5bf755 feat(render): IVolumeRenderSource 接缝 + WholeVolumeSource(B) 整卷重组
新增 B/C 共用的体渲染数据源接口 IVolumeRenderSource(meta/update/
currentImages/sliceSource),并实现 B 的 WholeVolumeSource:构造时读
ChunkedVolumeStore,遍历所有 brick 按全局坐标(vtkIdType 64 位)重组为
整卷 VTK_SHORT vtkImageData(含边缘块),供整卷体绘制与切片 reslice。

VoxelActor 新增 buildVoxelI16FromImage 重载:直接以预构建 VTK_SHORT
图像成体,传函/着色复用量化域逻辑(抽出 assembleVolumeI16),不改动
Task 7 现有 buildVoxel/buildVoxelI16 行为。

geopro_render 链 geopro_store;新增 test_whole_volume_source 校验
dims/类型/边缘块重组位置。
2026-06-23 11:23:37 +08:00
gaozheng b362156364 feat(render): VoxelActor 新增 buildVoxelI16 量化域体绘制
int16 量化体经 vtkShortArray 填 vtkImageData,vtkSmartVolumeMapper GPU 体绘制。
传递函数在量化域取控制点:qmin/qmax=q.toQ(vmin/vmaxPhys),颜色对每量化级用
q.toPhys 反查物理值再经 ColorScale 取色;kBlank→不透明度0(透明)。抽 assembleVolume
公用 mapper/property 配置,double 版 buildVoxel 行为不变。附无窗冒烟测试。
2026-06-23 11:12:25 +08:00
gaozheng d908556166 feat(store): GPR 三维体分块压缩落盘 ChunkedVolumeStore
新增 geopro_store 库(B/C 方案共用基座):int16 体逐块 qCompress 压缩写入
data.bin + nlohmann-json sidecar(meta.json 记几何/量化/逐块偏移索引)。
write/readMeta/readBrick 三接口 + 边缘块(< brick)支持;偏移/长度全程 64 位。
不引入 vtkHDFWriter,不加 vcpkg 依赖(压缩用 QtCore 自带 zlib)。
2026-06-23 11:00:47 +08:00
gaozheng 8f167b62c9 fix(detail): 白化 tmObjectId 经 open 链路从数据集列表透传(修模板列表为空)
白化「白化模板/模型」方式文件列表为空:原 plan A 用 getDsObjectDetail(dsId)
取 structParentId,实测该响应不含此字段。原版 web 取自数据集行所属 TM
(dsFileRow.structParentId)。

改为 plan B:tmObjectId 从 datasetsLoaded(tmObjectId) 存入树节点新角色
kDsTmObjectIdRole,双击/右键打开时读出,经 openDataset→datasetOpened 信号
→Panel→Page→DetailViewFactory→GridDataChartView 透传,openWhitening 直接用,
删除 getDsObjectDetail 懒拉。

build all(app 链接) + test 全绿,320/320。
2026-06-23 10:51:12 +08:00
gaozheng a9e8eb9d5c feat(core): GPR 结构化建体 buildGprVolume(X/Z 落格 + Y 向 1D 线性插值 → int16 量化体)
- 新增 GprSurvey 规则化建体输入模型(放 core/model 保持 geopro_core 自洽,避免 core->io 反向依赖)
- buildGprVolume: X/Z 取最近道/采样落格,仅跨通道 Y 做 1D 线性插值,边界外不外推
- int16 量化用值域中点为 offset 对称铺满 ~64000 码位,两端留余量不撞 int16/kBlank
- 整型乘积索引走 size_t
2026-06-23 10:45:06 +08:00
gaozheng 9874af77ee docs(detail): 台账标记收尾 6 项已接通 + build.bat all 验证教训 2026-06-23 10:34:47 +08:00
gaozheng ec4a7e81ef feat(detail): 补全详情视图剩余交互(框选/绘形/直方图/行级可见性/富文本/白化)
继续数据集详情视图 100% 复刻,补齐上批后置/降级的 6 项。

- M2 measurement 列表行级可见性:DataTableView 载荷驱动可交互开关列
  (仅 measurement 启用),行级 popconfirm → saveDisplayStatus
- M3 数据过滤直方图:新增自绘 ScatterHistogramView,分布柱 + 选区高亮 +
  与 min/max 输入联动
- M14 散点框选:ScatterMarqueePicker 橡皮筋框选 + ScatterPlotItem 选中高亮,
  显示/隐藏对选中子集操作
- I9 异常图上绘形:ContourDrawTool 在等值面上交互绘制 点/线/面/文字
  (先弹窗填类型/名称→图上绘制→newException),坐标表保留为兜底
- I14 富文本描述:DescriptionPanel 升级富文本(粗体/斜体/下划线/字色/字号/
  标题/列表) + QuillDelta 与 Quill Delta 常见格式往返(非 Quill 不可字节级1:1)
- I3 白化 tmObjectId:openWhitening 经 getDsObjectDetail 取 structParentId

修复 ScatterHistogram 命名冲突(widget 改名 ScatterHistogramView,与
ScatterDataOps 的分箱结构 struct 区分),desktop 目标恢复可链接。

抽纯函数 ChartPickGeometry/QuillDelta/buildScatterHistogram + 单测。
build app + test 全绿,318/318 通过。
2026-06-23 10:33:14 +08:00
gaozheng c6ff9c2271 feat(core): 新增 int16 量化体类型 ScalarVolumeI16 + Quant
GPR 三维体地基:int16 量化标量体,内存/显存/磁盘为 double 体的 1/4。
- Quant: 物理值↔int16 映射,toQ 下钳保留 INT16_MIN 给 kBlank 哨兵
- ScalarVolumeI16: 与 double ScalarVolume 并列,i 最快 k 最慢布局
- idx(i,j,k) 64 位计算(整卷可达约 96 亿体素,防 int 溢出)
- header-only,纯 C++17,零 Qt/VTK
2026-06-23 10:28:40 +08:00
gaozheng 0bbed9c0c3 feat(io/gpr): GPR 几何-通道横偏解析与采样深度换算
新增 geopro::io::gpr 两个纯 C++17 几何函数:
- parseChannelXOffsets: 解析 .ord 末列==1 的有效通道横向偏移
- depthOfSample: 按物理把采样序号换算为深度米(samples<=1 防除零)

含失败先行的单测,GprGeometry.cpp 接入 geopro_io_gpr,
test 接入 geopro_tests。
2026-06-23 10:19:11 +08:00
gaozheng 379875dff0 fix(io/gpr): traces/大小计算改 64 位防溢出
MSVC 的 long 是 32 位,samples*traces 大体下会溢出。
BScan.traces 改 std::int64_t;大小校验 expected 与 data
分配均在 64 位域计算,为后续整卷(数十亿体素)立纪律。
2026-06-23 10:10:05 +08:00
gaozheng 0d7f646941 feat(io/gpr): 实现 .iprb B-scan 二进制读取器
readIprb 读取 int16 雷达剖面(布局 [trace*samples + s]):
traces=lastTrace+1;校验文件字节数=samples*traces*2,
不符或打不开抛 std::runtime_error。纯 C++17,零 Qt/VTK。
2026-06-23 10:00:08 +08:00
gaozheng c395921ca8 feat(io/gpr): 新增 .iprh 头解析器(纯 C++17,零 Qt/VTK)
- IprHeader/parseIprHeader:按行解析 KEY: value,支持含空格键名
  (LAST TRACE/SOIL VELOCITY/DISTANCE INTERVAL)
- SOIL VELOCITY 由 m/µs 统一换算为 m/s 存储(×1e6)
- 缺 SAMPLES/LAST TRACE/CHANNELS 任一抛 std::runtime_error
- CMake 接线:src/io(gpr) 静态库 geopro_io_gpr + tests 链接
- TDD:2 个新用例,全测试套件 100% 通过
2026-06-23 09:45:13 +08:00
gaozheng b509795ffd docs(gpr): 三维体三方案 spec(A/B/C) + POC 实现计划
B/C 对等双方案(用户运行时按需切换),A 并入 B;含 opus 评审修订
(VTKHDF Writer 写不了规则体→裸分块落盘、量化贯穿、最小真实核外分页器)。
2026-06-23 09:38:28 +08:00
176 changed files with 13027 additions and 904 deletions

View File

@ -0,0 +1,67 @@
# Task 12b 报告SetPartitions 单 mapper fps 去风险探针
## 状态
完成(探针真实跑出,结论:渲出但未达交互级)。
## 实测环境与数据
- store9c/12 同款单线全分辨率整卷):`D:\Git\lanbingtech\geopro\build\tmp\gpr_store_B_001`
- 整卷维度44476 × 29 × 162 = 208,948,248 体素417,896,496 B398.5 MBVTK_SHORT
- 离屏渲染vtkRenderWindow SetOffScreenRenderingOn硬件加速 OpenGLoffscreen-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 ≤16384ny=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.811 区间)。
- **峰值进程内存**~556 ~ 653 MB整卷 398.5 MB 常驻 + 渲染开销)。
## 对照表
| 路径 | 是否渲出 | fps |
|---|---|---|
| renderB 整卷单 SmartVolumeMapper | INVALID纹理墙沿线 44476>16384 | — |
| renderC MultiBlock每块一 mapper | 渲出 | 9.5 静态 / 1.45 换页 |
| **renderC-partitioned 单 mapper SetPartitions** | **渲出** | **~8.811静态整卷** |
## 是否达交互级
**否**。目标 ≥15~30 fps实测 8.811 fps低于交互级下限。
## 判据落点
- “对的架构”(单 mapper + SetPartitions**确实绕过了纹理墙、确实把全分辨率整卷一次性渲出**——
这点比 9cINVALID与 12每块一 mapper都更干净证明架构方向正确。
- 但**纯 GPU ray cast 静态整卷 fps 仍只有 ~911**,与 renderC MultiBlock 的 9.5 静态 **基本同档**
未拉开差距、未到交互级。即:**“每块一 mapper”不是 9.5fps 的主要元凶;瓶颈在 208M 体素全分辨率
整卷的 ray cast 本身**(采样量 + 显存带宽),单 mapper 分区并不能把它变快。
- 结论:**VTK 这条路(整卷全分辨率体绘制)的交互级天花板在本数据上已暴露**。要到交互级,
production C 必须靠 LOD/降采样/核外换块(动态分辨率),而非寄望“单 mapper 分区”本身提速。
brief 判据的“仍不到 → 评估 OpenVDS/自建 GL”一支成立。
## concerns
1. **fps 受相机框选与视图影响**8.811 的波动主要来自每帧旋转中视线穿过体的采样深度差异;
该数为“静态整卷、绕轴旋转”口径,已剔除换页/解压(不像 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`

View File

@ -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×81level2=11119×8×41level3=5560×4×21上跑非合成。
- (a)/(b):把对应 level 的所有 brick 重组成单张 VTK_SHORT vtkImageData
(逻辑同 `WholeVolumeSource`,按 level 维度 + spacing×2^level / 局部段 X 偏移),
`buildVoxelI16FromImage`SmartVolumeMapperGPU 路径),旋相机 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 GPUNVIDIA 555.97OpenGL 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. **最低配仍是最大未知**(见上声明)。

View File

@ -0,0 +1,53 @@
# Task 12d-fix 报告:修 gpr_poc view 空窗 + 控制台乱码
## 状态
DONE。两 bug 均修复构建通过Community vcvars64 直驱 ninjaexit 0离屏自检通过。
## 提交短哈希
`1495d0e`feat/vtk-3d-view 分支)
## 改动文件
`tools/gpr_poc/main.cpp`+70 -1
### Bug 1概览空窗LOD 策略错)
- 根因:`view` 每帧 `viewRefreshBlocks` 无脑走分块路径,相机概览时 `pickLevel` 选 level1696 块)被 budget=64 砍到 64/6969% 稀疏)→ 看着空。
- 修复:`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 --smoketmp\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
```

View File

@ -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.32p75=20.1p90=196.2p99=9058.5max=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 |
光照参数cShadeOnAmbient 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 专用辅助函数)。

View File

@ -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 跟踪, 是瞬时产物。

View File

@ -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-releasebuild/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 binheadless 工具仍依赖这些 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,勿带入其他未暂存改动。

View File

@ -0,0 +1,44 @@
# Task 9c 报告POC-B 离屏 GPU 渲染基准
状态:**DONE**(闸门通过;真实基准实测完成;关键发现如实记录,无任何编造 fps
执行机Windows 11MSVCVS18 Community+ NinjaReleaseGPU = NVIDIA RTX 3060 Laptop GPUOpenGL 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`:离屏 vtkRenderWindowSetOffScreenRendering+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
- **体绘制 fpsINVALID** —— 整卷 X 维 44476 超 `GL_MAX_3D_TEXTURE_SIZE=16384`
`vtkVolumeTexture``Invalid texture dimensions [44476,29,162]`,未真正绘出体数据。
raw_fps=295.6 是空纹理假帧率,已显式标 INVALID**不作为体绘制性能上报**。
- **切片扫描 fps54.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 一致);无 OOMcellXY=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 枚举。

View File

@ -78,5 +78,8 @@ endif()
add_subdirectory(src) add_subdirectory(src)
# POC-B headless CLIgpr_poc io_gpr/core/store/render
add_subdirectory(tools/gpr_poc)
enable_testing() enable_testing()
add_subdirectory(tests) add_subdirectory(tests)

View File

@ -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=dsObjectId3D=三维体 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=三维体 dsObjectIdconsortiumId 归入异常体(存量已有)。",
"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=dsObjectId3D=三维体 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=dsObjectId3D=三维体 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=dsObjectId3D=三维体 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】" }
}
}
}
}
}

View File

@ -0,0 +1,383 @@
# GPR 三维体 POCB & 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`GoogleTestnlohmann-jsonsidecar现有 `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-scanmmap/分块读)
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 ModifybuildVoxel 增 int16 重载 + 量化域传函
tests/io/gpr/ tests/core/ tests/data/store/ ← 对应测试
tools/gpr_poc/ ← POC 度量台(建体/加载/显存/fps 探针 + CLI
```
POC 度量统一进 `tools/gpr_poc`(建体耗时、输出维度、落盘体积/压缩比、加载耗时、显存、切片/体绘制 fpsB/C 用同一套指标对照。
> **POC vs 生产**Task 16地基+ 78、1011接口/存储)是**生产代码,走 TDD、有完整代码**。Task 9、1213 是**可行性探针**:给出明确实验、被测未知、通过/失败判据与度量,不预先杜撰我们正要验证的 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) {
// 写 tmpsamples=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/s100 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: 分块存储 ChunkedVolumeStoreB/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 + WholeVolumeSourceB
**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; // B1 个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 18 全部。
**被验证的未知 / 阻塞点:** ①int16 GPU ray cast 在真机正常出图;②真实建体耗时与输出体积;③整卷 5~10GB 加载耗时、显存峰值;④切片拖动 fps、体绘制 fps⑤超显存时 `vtkSmartVolumeMapper``LowResResample`(`MaxMemoryInBytes`) 自动降质观感。
- [ ] **Step 1:** `gpr_poc build 明星路` → 跑 Task 16输出建体耗时、`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: OutOfCoreSourceC— 最高风险探针:核外体绘制
**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:** 逐项记录未知 14 的实测结论接缝截图、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 Bint16 路径(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. 占位符扫描**:地基任务(18,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 只需证可行 + 撞出阻塞,不追求生产质量分页器;但若未知 1VTK 渲染动态工作集)撞墙,是 C 的根本阻塞,须立刻反馈 spec C 并评估替代OpenVDS / 自建 GL
- **真实 13G 在合理分辨率下可能装得进显存** → B 顺过、C 的核外价值要靠"细分辨率/拼全路段大体"的真实配置才能压出Task 9/12 的网格参数留 CLI 可调,用同一份真实数据调出超显存体来考验 C
- **量化贯穿**漏一处即色阶/读数错Global 约束 + Task 4/7Self-Review 已盯。

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 267 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

View File

@ -0,0 +1,179 @@
# POC-B 实测结果gpr_poc headless 度量)
工具:`tools/gpr_poc`CLI构建产物 `build/release/tools/gpr_poc/gpr_poc.exe`
执行机Windows 11MSVCVS18 Community+ NinjaRelease/O2
日期2026-06-23。
整条地基链路:
`assembleGprSurvey → buildGprVolume → ChunkedVolumeStore::write → buildPyramid → WholeVolumeSource(load)`
---
## 1. selftest合成极小数据—— PASS
命令:`gpr_poc selftest`
- 构造 2 通道合成 surveysamples=8traces=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实测**
> 更新(任务 9b2026-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-onelastTrace+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** |
| GridSpecnx×ny×nz | **11120 × 8 × 162** |
| 体素数 | 14,411,520 |
| 原始体积int16 | 28,823,040 B27.49 MB |
| 落盘 data.bin含金字塔各级 | 15,317,628 B14.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 B27.49 MB |
| 峰值内存 | 38 MB |
无 OOM、无超时未调粗 cellXY 即一次通过。
### 峰值内存说明4.98 GB
峰值由**装配阶段**主导:同时持有 14 通道 BScan14×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=821TIMEWINDOW=160.352 nsSOIL 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 渲染基准(任务 9c2026-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 Corporationrenderer=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.84.0 s |
| renderB 进程峰值内存 | **≈509 MB**(加载整卷 398 MB + 渲染管线) |
无 build OOMcellXY=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**,而非显存字节数。
#### 结论
- **切片**:✅ 本机离屏下整卷切片 ≥30fps54.6fps),交互流畅,满足目标。
- **整卷体绘制**:❌ 在「整卷成单张 3D 纹理」的朴素路径下**不可行**——
长测线 X 维超 GL 单轴上限。这正是 **Task 12核外 / 分块 LOD / 体纹理分区
`vtkOpenGLGPUVolumeRayCastMapper::SetPartitions`** 必须解决的问题:
要么沿 X 分区/分块上传,要么按视相机做 LOD 工作集。本任务9c按约束**不做核外**
仅如实记录此限制作为 Task 12 的硬性依据。
- 该限制与显存容量无关,是 OpenGL 纹理维度硬上限;任何「整卷一次性 3D 纹理」方案
对长测线都会撞同一面墙。

View File

@ -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_001level0=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_001level0=44476x29x162brick=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。**

View File

@ -156,13 +156,19 @@
- **#4 视电阻率模型锁定**`InversionFormDialog::ApparentResistivity` 已 `modelCombo_->setEnabled(false)` 且锁定 `code==script_visual_resistivity_data` 的项——与原版 `InversionDialog.vue`(静态 `disabled` + 锁脚本)一致。 - **#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`)一致。 - **#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-23commit ec4a7e8
| 项 | 原因 | 后续所需 | §6.4 原列的 6 项后置/降级项已全部实现build app + test 全绿318/318
| 项 | 状态 | 实现 / 残留边界 |
|---|---|---| |---|---|---|
| **M14 框选/点选模式** | Qwt 橡皮筋框选 + 选区联动隐藏成本高,原版 enter/exitSelectMode 交互重 | 接入 QwtPlotPickerRubberBand 矩形)+ 选区命中→批量 saveDisplayStatus保留占位提示 | | **M2 行级可见性 switch** | ✅ | DataTableView 载荷驱动可交互开关列(`toggleInteractive`+`rowIds`,仅 measurement 置位),行级 popconfirm → `saveDisplayStatus` |
| **M2 行级可见性 switch** | DataTableView 需新增可选开关列 + 行级 popconfirm 交互 | 给 measurement 列表加 optional 开关列,复用 saveDisplayStatusids=[record.id]status 取反) | | **M3 过滤直方图** | ✅ | 新增自绘 `ScatterHistogramView`20 箱,选区高亮 + min/max 输入联动);拖拽刷选未做(原版用输入/滑块,非画布 brush |
| **M3 过滤直方图** | 过滤范围已通,仅缺直方图绘制(须取 getDataFilterConfig 分桶并渲染) | 在 ScatterFilterDialog 加直方图视图(分桶 + min/max 区间叠加) | | **M14 框选/点选模式** | ✅ | `ScatterMarqueePicker` 橡皮筋矩形 → `ScatterPlotItem` 选中红边高亮;显示/隐藏对选中子集操作(无选区回退全部)。复刻 box-select 变体;原版单击逐点选未做 |
| **I9 异常图上绘形** | 表单已通;图上交互绘制多边形/折线/点(橡皮筋 + 顶点编辑)属重型 Qwt 交互 | 接入图上绘制工具(绘形→坐标回填 location与表单提交合流 | | **I9 异常图上绘形** | ✅ | `ContourDrawTool` 在等值面交互绘制 点/线/面/文字(先弹窗填类型/名称→图上绘制→`newException`);坐标表保留为兜底。文字类型无原版独立富文本样式编辑器 |
| **I14 Quill 富文本** | 原版 attachedParameters.deltaContent 为 Quill DeltaQt 暂降级为纯文本 | 引入富文本编辑器QTextEdit 富文本 ↔ Delta 互转)或保持纯文本兜底 | | **I14 Quill 富文本** | ✅(降级可用) | `DescriptionPanel` 升级富文本(粗体/斜体/下划线/字色/字号/标题/列表)+ `QuillDelta` 与 Quill Delta 常见格式往返。**Qt 无 Quill不可字节级 1:1**:未知 attributes/嵌入对象容错降级(保文本、丢样式、不崩) |
| **I3 白化 tmObjectId 透传** | 客户端视图未透传 `structParentId`(白化模板列表用),现兜底空串 | 上游改造:数据集列表把 `structParentId` 接进视图(属上游数据流改造) | | **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`

View File

@ -0,0 +1,99 @@
# GPR 三维体 · 方案 A整卷上纹理不用金字塔复用现有管线最简基线
- 日期2026-06-23
- 范围:把 GPR探地雷达阵列数据插值成三维体并在 VTK 中渲染/切片,**直接复用现有剖面三维体管线**,整卷一次性进显存,不做分块/金字塔/核外。
- 定位:三选一中的**最小改动基线**。用于评估"现有架构原样接雷达,能做到什么、卡在哪"。
- **⚠ 评审结论2026-06-23opusA 不应作为独立交付步,建议并入 B。** 唯一值得从 A 单独先做的是"三方案共有的地基"§2。`double`+400³+暴力 IDW 三条硬约束使 A 产出的 GPR 体既无业务分辨率(沿线被强制粗化到 5.5m vs 物理 5cm、又无法落盘秒开。详见 §5/§6。
- 测试数据明星路450MHz 阵列 GPR14 通道,每道 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::SliceToolvtkImagePlaneWidget / vtkImageResliceCPU 重采样)
持久化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`)归 3DDTO 解析器放 `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。**

View File

@ -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-23opusGo条件式。** 体积测算、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 → 结构化建体(仅横向插值)→ ScalarVolumeI16int16
→ vtkImageData + vtkShortArray → vtkSmartVolumeMapper整卷进显存
切片:复用 SliceToolreslice 对 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/分块索引) + 逐块 zlibVTK 自带 `vtkzlib`,无需新依赖)**。分块布局从一开始就设计好C 的"切片核外"可几乎免费复用同一格式。
- 备选:直接用底层 `vtkhdf5` C API 自写 chunked dataset绕过 `vtkHDFWriter`),获得 HDF5 生态兼容;成本高于 raw 分块。
- 不引入独立 zstd/bloscvcpkg 未含;如需更高压缩比再加)。
- **加载**:有明细 → 读盘(可 mmap)→ 整卷上显存;无明细 → 按参数后台线程重算落缓存(复用现有重算逻辑 `Api3dRepository.cpp:212-225`,从 mock 升级为真实落盘)。
- **后台重算不阻塞 UI评审 MEDIUM**:现 `loadVolume` 回调**在主线程**mock 同步)。改为工作线程建体/重算后,回调要**跨线程编组**回 UIQt 信号 / `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 分块/金字塔的基座)。

View File

@ -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-23opus+ 用户决策****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/bloscvcpkg 未含);如压缩比不足再评估加入。
### 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/maxVTKHDF 或自定义)(~中大)。
3. 多分辨率金字塔生成流水线(后台一次)(~中)。
4. **切片核外**:按切面读相交块重采样(~中)—— 先交付这条。
5. 整卷体绘制核外分页器 + LOD 拖动降级 + 预取(~大,后续)。
6. 显存/内存缓存与淘汰策略(~中)。
---
## 6. 与 A/B 的关系
- 能力A ⊂ B ⊂ C成本同序递增。
- A、B 共享渲染/切片现有架构;**C 是不同的存储+渲染架构**(分块+核外),是 B 撞到显存天花板后的演进,不是平行替代。
- **推荐策略**:现在做 B 满足明星路与多数工程;把 C 的"分块格式 + 切片核外"作为**预案**,待出现真正超大数据再启动;整卷体绘制核外列为最后一档。
> **切片核外"最易落地"有隐藏前提(评审)**:它要求分块格式**已做完**才能"读相交块"。所以"先交付切片核外"≠ 可跳过分块——§5 顺序(先分块格式再切片核外)正确,别误读为切片核外是独立小工程。
**结论用户定GoC 与 B 都做)**C 的总体架构成立、对标 OpenVDS/ZGY/OME-Zarr 无误,是与 B 对等的已承诺成品;工程最重、渲染端整卷核外非开箱、落盘须裸 HDF5已改
- **落盘与 B 统一**:裸分块格式(不依赖 vtkHDFWriterB 落盘本就改裸分块C 的分块/切片核外在同一格式上增量获得。
- **整卷核外分页器**是 C 的最高风险件(两个 CRITICAL + 热路径解压 HIGH月级**POC 即以"最小真实分页器"正面验证**,确保"过了能落地"。
- **B/C 切换**:经 `IVolumeRenderSource` 运行时切换,用户按数据选;`LowResResample` 是 C 内的降质手段,不替代 C 也不替代用户选择。

View File

@ -9,6 +9,7 @@
# add_subdirectory(controller) # # add_subdirectory(controller) #
# #
add_subdirectory(core) add_subdirectory(core)
add_subdirectory(io)
add_subdirectory(data) add_subdirectory(data)
add_subdirectory(net) add_subdirectory(net)
add_subdirectory(render) add_subdirectory(render)

View File

@ -1,6 +1,8 @@
#include "AnomalySaveDialog.hpp" #include "AnomalySaveDialog.hpp"
#include <QComboBox> #include <QComboBox>
#include "EmptyAwareComboBox.hpp"
#include <QFormLayout> #include <QFormLayout>
#include <QLabel> #include <QLabel>
#include <QLineEdit> #include <QLineEdit>
@ -39,7 +41,7 @@ AnomalySaveDialog::AnomalySaveDialog(const QString& screenshotPath, int shotW, i
formkit::capField(name_); formkit::capField(name_);
form->addRow(formkit::editLabel(QStringLiteral("名称")), name_); form->addRow(formkit::editLabel(QStringLiteral("名称")), name_);
type_ = new QComboBox(); type_ = new EmptyAwareComboBox();
for (const auto& t : kMockTypes) for (const auto& t : kMockTypes)
type_->addItem(QString::fromUtf8(t.label), QString::fromUtf8(t.id)); type_->addItem(QString::fromUtf8(t.label), QString::fromUtf8(t.id));
formkit::capField(type_); formkit::capField(type_);

View File

@ -22,6 +22,7 @@ add_executable(geopro_desktop WIN32
main.cpp main.cpp
Theme.cpp Theme.cpp
FormKit.cpp FormKit.cpp
EmptyAwareComboBox.cpp
TopBar.cpp TopBar.cpp
ToastOverlay.cpp ToastOverlay.cpp
Glyphs.cpp Glyphs.cpp
@ -38,17 +39,21 @@ add_executable(geopro_desktop WIN32
panels/DatasetAttrPanel.cpp panels/DatasetAttrPanel.cpp
panels/ObjectExceptionPanel.cpp panels/ObjectExceptionPanel.cpp
panels/DescriptionPanel.cpp panels/DescriptionPanel.cpp
panels/QuillDelta.cpp
panels/chart/RawDataChartView.cpp panels/chart/RawDataChartView.cpp
panels/chart/InversionFormDialog.cpp panels/chart/InversionFormDialog.cpp
panels/chart/InversionFormParse.cpp
panels/chart/ScatterDataOps.cpp panels/chart/ScatterDataOps.cpp
panels/chart/SaveAsDialog.cpp panels/chart/SaveAsDialog.cpp
panels/chart/ScatterFilterDialog.cpp panels/chart/ScatterFilterDialog.cpp
panels/chart/ScatterHistogram.cpp
panels/chart/RangeSlider.cpp
panels/chart/InversionProcessOps.cpp panels/chart/InversionProcessOps.cpp
panels/chart/GridWizardDialog.cpp panels/chart/GridWizardDialog.cpp
panels/chart/WhiteningDialog.cpp panels/chart/WhiteningDialog.cpp
panels/chart/FilterDialog.cpp panels/chart/FilterDialog.cpp
panels/chart/ExceptionDialog.cpp panels/chart/ExceptionDialog.cpp
panels/chart/ExceptionTypeDialog.cpp
panels/chart/ExceptionTextDialog.cpp
panels/chart/ExceptionDetailDialog.cpp panels/chart/ExceptionDetailDialog.cpp
panels/chart/AutoAnnotationDialog.cpp panels/chart/AutoAnnotationDialog.cpp
panels/chart/ContourSimplify.cpp panels/chart/ContourSimplify.cpp
@ -70,6 +75,9 @@ add_executable(geopro_desktop WIN32
panels/chart/ContourPlotItem.cpp panels/chart/ContourPlotItem.cpp
panels/chart/LivePanner.cpp panels/chart/LivePanner.cpp
panels/chart/ScatterHoverTip.cpp panels/chart/ScatterHoverTip.cpp
panels/chart/ChartPickGeometry.cpp
panels/chart/ScatterMarqueePicker.cpp
panels/chart/ContourDrawTool.cpp
panels/columns/Column2DDataset.cpp panels/columns/Column2DDataset.cpp
panels/columns/Column3DDataset.cpp panels/columns/Column3DDataset.cpp
panels/columns/Column3DAnalysis.cpp panels/columns/Column3DAnalysis.cpp

View File

@ -5,6 +5,8 @@
#include <QColorDialog> #include <QColorDialog>
#include <QComboBox> #include <QComboBox>
#include "EmptyAwareComboBox.hpp"
#include <QDialogButtonBox> #include <QDialogButtonBox>
#include <QDoubleSpinBox> #include <QDoubleSpinBox>
#include <QFile> #include <QFile>
@ -105,7 +107,7 @@ ColorGradientDialog::ColorGradientDialog(const std::vector<Stop>& init, double m
int rowIdx = 0; int rowIdx = 0;
// 配色方案(下拉带预览色条)。 // 配色方案(下拉带预览色条)。
schemeCombo_ = new QComboBox(this); schemeCombo_ = new EmptyAwareComboBox(this);
schemeCombo_->setIconSize(QSize(100, 16)); schemeCombo_->setIconSize(QSize(100, 16));
{ {
auto* cell = new QHBoxLayout(); auto* cell = new QHBoxLayout();
@ -118,7 +120,7 @@ ColorGradientDialog::ColorGradientDialog(const std::vector<Stop>& init, double m
{ {
auto* cell = new QHBoxLayout(); auto* cell = new QHBoxLayout();
cell->addWidget(formkit::editLabel(QStringLiteral("分布方式:"))); cell->addWidget(formkit::editLabel(QStringLiteral("分布方式:")));
auto* distCombo = new QComboBox(this); auto* distCombo = new EmptyAwareComboBox(this);
distCombo->addItem(QStringLiteral("线性"), QStringLiteral("linear")); distCombo->addItem(QStringLiteral("线性"), QStringLiteral("linear"));
distCombo->addItem(QStringLiteral("对数"), QStringLiteral("log")); distCombo->addItem(QStringLiteral("对数"), QStringLiteral("log"));
distCombo->setCurrentIndex(0); distCombo->setCurrentIndex(0);

View File

@ -97,14 +97,16 @@ ColorScaleConfigDialog::ColorScaleConfigDialog(const geopro::core::ColorScale& i
double vmax, std::vector<double> samples, double vmax, std::vector<double> samples,
const ContourLineConfig& lineInit, const ContourLineConfig& lineInit,
geopro::data::IColorTemplateRepository* tplRepo, geopro::data::IColorTemplateRepository* tplRepo,
QString projectId, QWidget* parent) QString projectId, QString lvlTemplateId,
QWidget* parent)
: QDialog(parent), : QDialog(parent),
vmin_(vmin), vmin_(vmin),
vmax_(vmax), vmax_(vmax),
samples_(std::move(samples)), samples_(std::move(samples)),
lineCfg_(lineInit), lineCfg_(lineInit),
tplRepo_(tplRepo), tplRepo_(tplRepo),
projectId_(std::move(projectId)) { projectId_(std::move(projectId)),
lvlTemplateId_(std::move(lvlTemplateId)) {
setWindowTitle(QStringLiteral("色阶配置")); setWindowTitle(QStringLiteral("色阶配置"));
setModal(true); setModal(true);
resize(560, 420); resize(560, 420);
@ -433,11 +435,31 @@ void ColorScaleConfigDialog::loadColorBar(
void ColorScaleConfigDialog::onSaveOther() { void ColorScaleConfigDialog::onSaveOther() {
if (tplRepo_ == nullptr || projectId_.isEmpty()) return; if (tplRepo_ == nullptr || projectId_.isEmpty()) return;
bool ok = false;
const QString name = QInputDialog::getText(this, QStringLiteral("另存模板配置"), // 自定义另存为弹窗(复刻 handleSaveOther名称输入 + 覆盖复选框。
QStringLiteral("模板名称:"), QLineEdit::Normal, // 「覆盖」仅当有来源模板 idlvlTemplateId_ 非空)时可勾选,对照原版 props.data.lvlTemplateId。
QStringLiteral("等值线配置.lvl"), &ok); QDialog askDlg(this);
if (!ok || name.trimmed().isEmpty()) return; 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 // 组装 properties复刻 handleSaveOther
QJsonArray colorBar; QJsonArray colorBar;
@ -457,18 +479,22 @@ void ColorScaleConfigDialog::onSaveOther() {
{QStringLiteral("colorBar"), colorBar}}; {QStringLiteral("colorBar"), colorBar}};
// 走仓储传输;回调里用 QPointer 守卫 this模态对话框可能已关 // 走仓储传输;回调里用 QPointer 守卫 this模态对话框可能已关
// 勾选覆盖 → PUT 更新来源模板updateLvlTemplate否则 → POST 新建saveLvlTemplate
QPointer<ColorScaleConfigDialog> self(this); QPointer<ColorScaleConfigDialog> self(this);
tplRepo_->saveLvlTemplate(projectId_, name.trimmed(), properties, auto onDone = [self, overwrite](bool ok, QString msg) {
[self](bool ok, QString msg) {
if (!self) return; if (!self) return;
if (ok) if (ok)
QMessageBox::information(self, QStringLiteral("另存"), QMessageBox::information(
QStringLiteral("另存成功。"));
else
QMessageBox::warning(
self, QStringLiteral("另存"), self, QStringLiteral("另存"),
overwrite ? QStringLiteral("更新成功。") : QStringLiteral("另存成功。"));
else
QMessageBox::warning(self, QStringLiteral("另存"),
QStringLiteral("另存失败:%1").arg(msg)); 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() { void ColorScaleConfigDialog::onOpen() {

View File

@ -29,12 +29,15 @@ public:
// init当前色阶升序断点填表vmin/vmax数据原始范围层级/颜色子对话框 + 新增外推用); // init当前色阶升序断点填表vmin/vmax数据原始范围层级/颜色子对话框 + 新增外推用);
// samples数据原始标量等积分层 + 颜色编辑器直方图用,空则等积退化为线性); // samples数据原始标量等积分层 + 颜色编辑器直方图用,空则等积退化为线性);
// lineInit线形/标注初值2D 传当前态3D 用默认); // lineInit线形/标注初值2D 传当前态3D 用默认);
// tplRepo/projectIdlvl 模板库仓储句柄(可空 → 另存为/打开 禁用)。 // tplRepo/projectIdlvl 模板库仓储句柄(可空 → 另存为/打开 禁用);
// lvlTemplateId当前色阶来源模板 id可空对照原版 props.data.lvlTemplateId
// 非空时「另存为」弹窗的「覆盖」复选框可勾选 → 走 PUT 更新该模板3D/无模板场景不传即可。
ColorScaleConfigDialog(const geopro::core::ColorScale& init, double vmin, double vmax, ColorScaleConfigDialog(const geopro::core::ColorScale& init, double vmin, double vmax,
std::vector<double> samples = {}, std::vector<double> samples = {},
const ContourLineConfig& lineInit = {}, const ContourLineConfig& lineInit = {},
geopro::data::IColorTemplateRepository* tplRepo = nullptr, geopro::data::IColorTemplateRepository* tplRepo = nullptr,
QString projectId = {}, QWidget* parent = nullptr); QString projectId = {}, QString lvlTemplateId = {},
QWidget* parent = nullptr);
// 由表格当前断点装配的新色阶(按层级升序 addStop // 由表格当前断点装配的新色阶(按层级升序 addStop
geopro::core::ColorScale colorScale() const; geopro::core::ColorScale colorScale() const;
@ -74,6 +77,7 @@ private:
geopro::data::IColorTemplateRepository* tplRepo_ = nullptr; // lvl 模板库仓储(可空) geopro::data::IColorTemplateRepository* tplRepo_ = nullptr; // lvl 模板库仓储(可空)
QString projectId_; QString projectId_;
QString lvlTemplateId_; // 当前色阶来源模板 id可空 → 另存为弹窗禁用「覆盖」)
// 随子对话框更新、写入另存为 properties复刻原版透传字段 // 随子对话框更新、写入另存为 properties复刻原版透传字段
QString lvlSchemeType_ = QStringLiteral("normal"); QString lvlSchemeType_ = QStringLiteral("normal");
int logLinesCount_ = 8; int logLinesCount_ = 8;

View File

@ -3,6 +3,8 @@
#include <cmath> #include <cmath>
#include <QComboBox> #include <QComboBox>
#include "EmptyAwareComboBox.hpp"
#include <QDialogButtonBox> #include <QDialogButtonBox>
#include <QDoubleValidator> #include <QDoubleValidator>
#include <QFormLayout> #include <QFormLayout>
@ -38,7 +40,7 @@ ContourLevelDialog::ContourLevelDialog(const ContourLevelParams& init, double or
new QLabel(QStringLiteral("%1 ~ %2").arg(originMin_).arg(originMax_))); new QLabel(QStringLiteral("%1 ~ %2").arg(originMin_).arg(originMax_)));
// 分层方式。 // 分层方式。
methodCombo_ = new QComboBox(this); methodCombo_ = new EmptyAwareComboBox(this);
methodCombo_->addItem(QStringLiteral("一般的"), 0); methodCombo_->addItem(QStringLiteral("一般的"), 0);
methodCombo_->addItem(QStringLiteral("对数"), 1); methodCombo_->addItem(QStringLiteral("对数"), 1);
methodCombo_->addItem(QStringLiteral("等积"), 2); methodCombo_->addItem(QStringLiteral("等积"), 2);

View File

@ -4,6 +4,8 @@
#include <QColor> #include <QColor>
#include <QColorDialog> #include <QColorDialog>
#include <QComboBox> #include <QComboBox>
#include "EmptyAwareComboBox.hpp"
#include <QDialogButtonBox> #include <QDialogButtonBox>
#include <QFormLayout> #include <QFormLayout>
#include <QPushButton> #include <QPushButton>
@ -34,7 +36,7 @@ ContourLineDialog::ContourLineDialog(const ContourLineConfig& init, QWidget* par
auto* form = formkit::makeEditForm(); auto* form = formkit::makeEditForm();
// 复刻 contourLine.vue选项顺序「虚线」在前、「实线」在后。 // 复刻 contourLine.vue选项顺序「虚线」在前、「实线」在后。
lineTypeCombo_ = new QComboBox(this); lineTypeCombo_ = new EmptyAwareComboBox(this);
lineTypeCombo_->addItem(QStringLiteral("- - - - - - - - -"), true); // dashed lineTypeCombo_->addItem(QStringLiteral("- - - - - - - - -"), true); // dashed
lineTypeCombo_->addItem(QStringLiteral("——————"), false); // solid lineTypeCombo_->addItem(QStringLiteral("——————"), false); // solid
lineTypeCombo_->setCurrentIndex(cfg_.dashed ? 0 : 1); lineTypeCombo_->setCurrentIndex(cfg_.dashed ? 0 : 1);

View File

@ -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

View File

@ -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 自带 placeholderTextcurrentIndex=-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

View File

@ -35,7 +35,8 @@ ExportDatasetDialog::ExportDatasetDialog(geopro::data::IAsyncProjectRepository&
auto* cardLay = formkit::cardBody(card); auto* cardLay = formkit::cardBody(card);
auto* fl = formkit::makeEditForm(); auto* fl = formkit::makeEditForm();
templateCombo_ = new QComboBox(this); // 空态感知下拉:数据驱动(异步 loadTemplates未选显占位、无数据弹「暂无数据」。
templateCombo_ = formkit::comboBox(QStringLiteral("请选择导出模板"), this);
formkit::capField(templateCombo_); formkit::capField(templateCombo_);
fl->addRow(formkit::editLabel(QStringLiteral("导出模板")), templateCombo_); fl->addRow(formkit::editLabel(QStringLiteral("导出模板")), templateCombo_);
cardLay->addLayout(fl); cardLay->addLayout(fl);

View File

@ -11,11 +11,20 @@
#include <utility> #include <utility>
#include "EmptyAwareComboBox.hpp"
#include "Theme.hpp" #include "Theme.hpp"
#include "panels/KeyValueView.hpp" #include "panels/KeyValueView.hpp"
namespace geopro::app::formkit { 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) { DetailForm& DetailForm::group(const QString& name) {
geopro::data::DynamicFormGroup g; geopro::data::DynamicFormGroup g;
g.name = name.toStdString(); g.name = name.toStdString();

View File

@ -15,6 +15,7 @@
#include "repo/RepoTypes.hpp" #include "repo/RepoTypes.hpp"
class QBoxLayout; class QBoxLayout;
class QComboBox;
class QDialog; class QDialog;
class QDialogButtonBox; class QDialogButtonBox;
class QFormLayout; class QFormLayout;
@ -23,6 +24,12 @@ class QWidget;
namespace geopro::app::formkit { 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。 // 链式 group()/row() 产出 data::DynamicForm喂给唯一的 §6.4 渲染器 DynamicFormView。
// 三维体/切片/异常等「数据详情」对话框共用,杜绝裸 QFormLayout 漂移。 // 三维体/切片/异常等「数据详情」对话框共用,杜绝裸 QFormLayout 漂移。

View File

@ -53,10 +53,11 @@ ImportDatasetDialog::ImportDatasetDialog(geopro::data::IAsyncProjectRepository&
auto* fl = formkit::makeEditForm(); auto* fl = formkit::makeEditForm();
typeCombo_ = new QComboBox(card); // 空态感知下拉:数据类型/导入脚本均数据驱动(异步加载),未选显占位、无数据弹「暂无数据」。
typeCombo_ = formkit::comboBox(QStringLiteral("请选择数据类型"), card);
formkit::capField(typeCombo_); formkit::capField(typeCombo_);
fl->addRow(formkit::editLabel(QStringLiteral("数据类型")), typeCombo_); fl->addRow(formkit::editLabel(QStringLiteral("数据类型")), typeCombo_);
scriptCombo_ = new QComboBox(card); scriptCombo_ = formkit::comboBox(QStringLiteral("请选择导入脚本"), card);
formkit::capField(scriptCombo_); formkit::capField(scriptCombo_);
fl->addRow(formkit::editLabel(QStringLiteral("导入脚本")), scriptCombo_); fl->addRow(formkit::editLabel(QStringLiteral("导入脚本")), scriptCombo_);

View File

@ -3,6 +3,8 @@
#include <utility> #include <utility>
#include <QComboBox> #include <QComboBox>
#include "EmptyAwareComboBox.hpp"
#include <QFormLayout> #include <QFormLayout>
#include <QHBoxLayout> #include <QHBoxLayout>
#include <QJsonDocument> #include <QJsonDocument>
@ -140,7 +142,7 @@ void ObjectFormDialog::buildTopFields() {
// 新建 GS/TM类型下拉数据源 gsList / tmList选择后重载动态表单 // 新建 GS/TM类型下拉数据源 gsList / tmList选择后重载动态表单
const QString label = const QString label =
confType_ == kConfTm ? QStringLiteral("方法类型") : QStringLiteral("对象类型"); confType_ == kConfTm ? QStringLiteral("方法类型") : QStringLiteral("对象类型");
typeCombo_ = new QComboBox(topBox_); typeCombo_ = new EmptyAwareComboBox(topBox_);
addRow(label, typeCombo_); addRow(label, typeCombo_);
QObject::connect(typeCombo_, qOverload<int>(&QComboBox::currentIndexChanged), this, QObject::connect(typeCombo_, qOverload<int>(&QComboBox::currentIndexChanged), this,
[this](int) { [this](int) {

View File

@ -3,6 +3,8 @@
#include <QAbstractItemView> #include <QAbstractItemView>
#include <QColor> #include <QColor>
#include <QComboBox> #include <QComboBox>
#include "EmptyAwareComboBox.hpp"
#include <QFont> #include <QFont>
#include <QHBoxLayout> #include <QHBoxLayout>
#include <QHeaderView> #include <QHeaderView>
@ -54,7 +56,7 @@ ProjectListDialog::ProjectListDialog(data::IAsyncProjectRepository& repo, QWidge
filter->addWidget(nameEdit_); filter->addWidget(nameEdit_);
filter->addSpacing(8); filter->addSpacing(8);
filter->addWidget(new QLabel(QStringLiteral("项目类型"), this)); filter->addWidget(new QLabel(QStringLiteral("项目类型"), this));
typeCombo_ = new QComboBox(this); typeCombo_ = new EmptyAwareComboBox(this);
typeCombo_->setFixedWidth(160); typeCombo_->setFixedWidth(160);
filter->addWidget(typeCombo_); filter->addWidget(typeCombo_);
filter->addSpacing(8); filter->addSpacing(8);

View File

@ -1,6 +1,8 @@
#include "SettingsDialog.hpp" #include "SettingsDialog.hpp"
#include <QComboBox> #include <QComboBox>
#include "EmptyAwareComboBox.hpp"
#include <QCoreApplication> #include <QCoreApplication>
#include <QHBoxLayout> #include <QHBoxLayout>
#include <QLabel> #include <QLabel>
@ -63,7 +65,7 @@ QWidget* buildAppearancePage() {
geopro::app::formkit::addSection(v, QStringLiteral("外观"), page, false); 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("system"));
themeCombo->addItem(QStringLiteral("浅色"), QStringLiteral("light")); themeCombo->addItem(QStringLiteral("浅色"), QStringLiteral("light"));
themeCombo->addItem(QStringLiteral("深色"), QStringLiteral("dark")); themeCombo->addItem(QStringLiteral("深色"), QStringLiteral("dark"));
@ -76,7 +78,7 @@ QWidget* buildAppearancePage() {
QStringLiteral("跟随系统 / 浅色 / 深色,切换即时生效"))); QStringLiteral("跟随系统 / 浅色 / 深色,切换即时生效")));
// 界面字号:小/标准/大/特大(重启生效)。 // 界面字号:小/标准/大/特大(重启生效)。
auto* fontCombo = new QComboBox(page); auto* fontCombo = new EmptyAwareComboBox(page);
fontCombo->addItem(QStringLiteral(""), 90); fontCombo->addItem(QStringLiteral(""), 90);
fontCombo->addItem(QStringLiteral("标准"), 100); fontCombo->addItem(QStringLiteral("标准"), 100);
fontCombo->addItem(QStringLiteral(""), 115); fontCombo->addItem(QStringLiteral(""), 115);

View File

@ -1,6 +1,8 @@
#include "VolumeParamsDialog.hpp" #include "VolumeParamsDialog.hpp"
#include <QComboBox> #include <QComboBox>
#include "EmptyAwareComboBox.hpp"
#include <QDialogButtonBox> #include <QDialogButtonBox>
#include <QDoubleSpinBox> #include <QDoubleSpinBox>
#include <QFormLayout> #include <QFormLayout>
@ -43,7 +45,7 @@ VolumeParamsDialog::VolumeParamsDialog(int sourceCount, QWidget* parent) : QDial
formkit::capField(name_); formkit::capField(name_);
form->addRow(formkit::editLabel(QStringLiteral("名称")), name_); form->addRow(formkit::editLabel(QStringLiteral("名称")), name_);
model_ = new QComboBox(); model_ = new EmptyAwareComboBox();
model_->addItem(QStringLiteral("反距离加权 (IDW)"), model_->addItem(QStringLiteral("反距离加权 (IDW)"),
static_cast<int>(geopro::data::VolumeBuildParams::Model::Idw)); static_cast<int>(geopro::data::VolumeBuildParams::Model::Idw));
model_->addItem(QStringLiteral("克里金 (Kriging)"), model_->addItem(QStringLiteral("克里金 (Kriging)"),

View File

@ -72,6 +72,10 @@ public:
// frame 原点重锚(首个带经纬剖面到达)后回调,供底图等随之刷新到数据所在位置。 // frame 原点重锚(首个带经纬剖面到达)后回调,供底图等随之刷新到数据所在位置。
std::function<void()> onFrameReanchored; std::function<void()> onFrameReanchored;
// 复位"已按数据重锚"标志:切换项目清场后调,使新项目首个数据重新触发重锚(→ onFrameReanchored
// → 底图按新项目位置重显)。否则增量勾选不走 clear(),旧标志残留 → 不重锚 → 底图不再显示。
void resetFrameAnchor() { frameAnchoredToData_ = false; }
// 相机程序化变化(取景/预设/缩放)后回调,供底图按新视锥重算覆盖(否则首帧部分瓦片要手动微动才出)。 // 相机程序化变化(取景/预设/缩放)后回调,供底图按新视锥重算覆盖(否则首帧部分瓦片要手动微动才出)。
std::function<void()> onCameraChanged; std::function<void()> onCameraChanged;

View File

@ -192,9 +192,15 @@ public:
{ {
overlay_->adjustSize(); overlay_->adjustSize();
const QSize h = host_->size(); const QSize h = host_->size();
const QSize o = overlay_->size(); // 浮层尺寸钳到不超过 hosthost 比内容小(窗口/抽屉收窄)时不再溢出视图。
overlay_->move(host_->x() + (h.width() - o.width()) / 2, QSize o = overlay_->size();
host_->y() + (h.height() - o.height()) / 2); 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(); overlay_->raise();
} }
@ -807,10 +813,11 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
} }
// 3D 无等值线,线形/标注配置忽略(用默认);仅取色阶应用。 // 3D 无等值线,线形/标注配置忽略(用默认);仅取色阶应用。
// 「另存为/打开」与「新建色阶/配色方案」走色阶模板仓储projectId 取当前项目。 // 「另存为/打开」与「新建色阶/配色方案」走色阶模板仓储projectId 取当前项目。
// 3D 体无来源 lvl 模板 → lvlTemplateId 传空(覆盖复选框禁用,行为不变)。
geopro::app::ColorScaleConfigDialog dlg( geopro::app::ColorScaleConfigDialog dlg(
sceneView->currentColorScale(), sceneView->currentVmin(), sceneView->currentColorScale(), sceneView->currentVmin(),
sceneView->currentVmax(), std::move(samples), {}, &colorTplRepo, sceneView->currentVmax(), std::move(samples), {}, &colorTplRepo,
nav.currentProjectId(), &window); nav.currentProjectId(), QString(), &window);
if (dlg.exec() == QDialog::Accepted) if (dlg.exec() == QDialog::Accepted)
sceneCtrl->setVolumeColorScale(dsId, dlg.colorScale()); sceneCtrl->setVolumeColorScale(dsId, dlg.colorScale());
}); });
@ -918,17 +925,19 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
.pixmap(56, 56)); .pixmap(56, 56));
esIcon->setAlignment(Qt::AlignCenter); esIcon->setAlignment(Qt::AlignCenter);
auto* esTitle = new QLabel(QStringLiteral("选择左侧数据集开始分析"), emptyState); auto* esTitle = new QLabel(QStringLiteral("勾选左侧数据集开始渲染"), emptyState);
esTitle->setAlignment(Qt::AlignCenter); esTitle->setAlignment(Qt::AlignCenter);
esTitle->setWordWrap(true); // 窄时换行,不撑宽浮层
geopro::app::applyTokenizedStyleSheet( geopro::app::applyTokenizedStyleSheet(
esTitle, QStringLiteral("color:{{canvas/text}}; font-size:%1px; font-weight:%2;") esTitle, QStringLiteral("color:{{canvas/text}}; font-size:%1px; font-weight:%2;")
.arg(geopro::app::scaledPx(geopro::app::type::kHeading)) .arg(geopro::app::scaledPx(geopro::app::type::kHeading))
.arg(geopro::app::type::kWeightSemibold)); .arg(geopro::app::type::kWeightSemibold));
auto* esHint = new QLabel(QStringLiteral("单击左侧采集批次,查看反演剖面与异常点\n" auto* esHint = new QLabel(QStringLiteral("在左侧「三维数据集 / 二维数据集 / 三维分析」栏勾选数据集,\n"
"切到「三维视图」可叠加帘面、体素与地形图层"), "在此叠加显示;可切换二维 / 三维视图。"),
emptyState); emptyState);
esHint->setAlignment(Qt::AlignCenter); esHint->setAlignment(Qt::AlignCenter);
esHint->setWordWrap(true); // 窄时换行,不撑宽浮层
geopro::app::applyTokenizedStyleSheet( geopro::app::applyTokenizedStyleSheet(
esHint, esHint,
QStringLiteral("color:{{canvas/text-dim}}; font-size:%1px;").arg(geopro::app::scaledPx(geopro::app::type::kBody))); 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 dsId = item->data(0, geopro::app::kDsIdRole).toString();
const QString ddCode = item->data(0, geopro::app::kDsDdCodeRole).toString(); const QString ddCode = item->data(0, geopro::app::kDsDdCodeRole).toString();
const QString dsName = item->data(0, geopro::app::kDsNameRole).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 引擎):开页 / 页签就绪 / 加载中 / 聚焦 ── // ── 控制器信号 → 详情面板tab 引擎):开页 / 页签就绪 / 加载中 / 聚焦 ──
@ -1193,8 +1205,37 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
}; };
QObject::connect(topBar, &geopro::app::TopBar::workspaceSwitchRequested, &nav, QObject::connect(topBar, &geopro::app::TopBar::workspaceSwitchRequested, &nav,
&geopro::controller::WorkbenchNavController::switchWorkspace); &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, 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) → 重启应用回到登录页。 // 退出登录:清除记住的凭证(QtKeychain+QSettings) → 重启应用回到登录页。
QObject::connect(topBar, &geopro::app::TopBar::logoutRequested, &window, []() { QObject::connect(topBar, &geopro::app::TopBar::logoutRequested, &window, []() {
geopro::app::forgetSession(); geopro::app::forgetSession();
@ -1208,12 +1249,15 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
dlg.exec(); dlg.exec();
}); });
QObject::connect(topBar, &geopro::app::TopBar::allProjectsRequested, &window, 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); auto* dlg = new geopro::app::ProjectListDialog(projectRepo, &window);
dlg->setAttribute(Qt::WA_DeleteOnClose); dlg->setAttribute(Qt::WA_DeleteOnClose);
QObject::connect(dlg, &geopro::app::ProjectListDialog::projectChosen, &nav, 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); topBar->setProjectButtonText(name);
if (id != nav.currentProjectId())
clearCentral(); // 真正换项目才清
nav.switchProject(id); nav.switchProject(id);
}); });
dlg->exec(); dlg->exec();
@ -1468,10 +1512,12 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
if (dsId.isEmpty()) return; if (dsId.isEmpty()) return;
const QString ddCode = item->data(0, geopro::app::kDsDdCodeRole).toString(); const QString ddCode = item->data(0, geopro::app::kDsDdCodeRole).toString();
const QString dsName = item->data(0, geopro::app::kDsNameRole).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); QMenu menu(datasetList);
menu.addAction(QStringLiteral("数据集详情"), datasetList, menu.addAction(QStringLiteral("数据集详情"), datasetList,
[&detailCtrl, dsId, ddCode, dsName]() { [&detailCtrl, dsId, ddCode, dsName, tmObjectId]() {
detailCtrl.openDataset(dsId, ddCode, dsName); detailCtrl.openDataset(dsId, ddCode, dsName, tmObjectId);
}); });
menu.addAction(QStringLiteral("属性"), datasetList, [&nav, dsId]() { menu.addAction(QStringLiteral("属性"), datasetList, [&nav, dsId]() {
nav.selectDataset(dsId); // 只读元字段 nav.selectDataset(dsId); // 只读元字段
@ -1636,10 +1682,11 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
}); });
QObject::connect(&nav, &geopro::controller::WorkbenchNavController::datasetsLoaded, datasetList, QObject::connect(&nav, &geopro::controller::WorkbenchNavController::datasetsLoaded, datasetList,
[removeTreeLoadMore, addTreeLoadMore, datasetList, datasetTitle, datasetTabs]( [removeTreeLoadMore, addTreeLoadMore, datasetList, datasetTitle, datasetTabs](
const QString&, const std::vector<geopro::data::DsRow>& rows, int total, const QString& tmObjectId, const std::vector<geopro::data::DsRow>& rows,
bool append) { int total, bool append) {
removeTreeLoadMore(datasetList); 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); const int loaded = addTreeLoadMore(datasetList, total);
if (datasetTitle) datasetTitle->setText(QStringLiteral("数据集")); if (datasetTitle) datasetTitle->setText(QStringLiteral("数据集"));
datasetTabs->setTabText( datasetTabs->setTabText(

View File

@ -6,7 +6,9 @@
#include <QToolButton> #include <QToolButton>
#include <QVBoxLayout> #include <QVBoxLayout>
namespace geopro::app { 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) { AnomalyTablePanel::AnomalyTablePanel(QWidget* parent) : QWidget(parent) {
auto* lay = new QVBoxLayout(this); lay->setContentsMargins(0, 0, 0, 0); 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); }); connect(btnDetail, &QToolButton::clicked, this, [this, i]() { emit detailRequested(i); });
auto* btnDelete = new QToolButton(ops); btnDelete->setText(QStringLiteral("删除")); auto* btnDelete = new QToolButton(ops); btnDelete->setText(QStringLiteral("删除"));
connect(btnDelete, &QToolButton::clicked, this, [this, i]() { connect(btnDelete, &QToolButton::clicked, this, [this, i]() {
// 原版 a-popconfirm 二次确认 → 这里用 QMessageBox 确认 // 原版 a-popconfirm 二次确认contourContentDelete→ 这里用 QMessageBox文案对齐原版
if (QMessageBox::question(this, QStringLiteral("提示"), if (QMessageBox::question(this, QStringLiteral("提示"),
QStringLiteral("确定删除该异常?")) == QMessageBox::Yes) QStringLiteral("该操作会删除该异常标注数据,确认?")) ==
QMessageBox::Yes)
emit deleteRequested(i); emit deleteRequested(i);
}); });
opLay->addWidget(eye); opLay->addWidget(eye);

View File

@ -50,7 +50,8 @@ void DatasetDetailPage::build(const QString& dsId, const QString& ddCode, const
// 仓储与 projectId 回调透传给工厂FilledContour 用色阶模板仓储Scatter 用反演命令仓储)。 // 仓储与 projectId 回调透传给工厂FilledContour 用色阶模板仓储Scatter 用反演命令仓储)。
// dsIdGetter 用本页 dsId_此处已赋值随项目/数据集稳定。 // dsIdGetter 用本页 dsId_此处已赋值随项目/数据集稳定。
auto view = makeDetailView(spec.kind, this, colorTplRepo_, projectIdGetter_, cmdRepo_, 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 父子树接管生命周期 IDetailView* raw = view.release(); // QWidget 由 this/QwtPlot 父子树接管生命周期
views_[i] = raw; views_[i] = raw;
// lazy 页签:建覆盖该视图的加载遮罩(父为视图 widget随其尺寸覆盖图区 // lazy 页签:建覆盖该视图的加载遮罩(父为视图 widget随其尺寸覆盖图区

View File

@ -32,6 +32,9 @@ public:
// dsId 用本页 dsId_build 内构造 dsIdGetter此时 dsId_ 已赋值projectId 复用上面的 getter。 // dsId 用本页 dsId_build 内构造 dsIdGetter此时 dsId_ 已赋值projectId 复用上面的 getter。
void setCommandRepo(geopro::data::IDatasetCommandRepository* repo); void setCommandRepo(geopro::data::IDatasetCommandRepository* repo);
// 所属 TM 对象 id=白化 structParentId注入须在 build 前设置 → tmObjectIdGetter 透传给视图)。
void setTmObjectId(const QString& tmObjectId) { tmObjectId_ = tmObjectId; }
// 按页签集构建页签首次打开调一次。dsId/ddCode/dsName 用于 tabNeeded。 // 按页签集构建页签首次打开调一次。dsId/ddCode/dsName 用于 tabNeeded。
void build(const QString& dsId, const QString& ddCode, const QString& dsName, void build(const QString& dsId, const QString& ddCode, const QString& dsName,
const std::vector<geopro::controller::TabSpec>& tabs); const std::vector<geopro::controller::TabSpec>& tabs);
@ -57,6 +60,7 @@ private:
QString dsId_; QString dsId_;
QString ddCode_; QString ddCode_;
QString dsName_; QString dsName_;
QString tmObjectId_; // 所属 TM 对象 id白化 structParentId经 tmObjectIdGetter 透传给视图
std::vector<geopro::controller::TabSpec> tabs_; std::vector<geopro::controller::TabSpec> tabs_;
// 与 tabs_ 同序。每个 IDetailView 持有的 QWidget 经 build() 以 this 为父接管, // 与 tabs_ 同序。每个 IDetailView 持有的 QWidget 经 build() 以 this 为父接管,
// 生命周期由 Qt 父子树清理(不在此 deletebuild() 仅调用一次(见其断言)。 // 生命周期由 Qt 父子树清理(不在此 deletebuild() 仅调用一次(见其断言)。

View File

@ -32,7 +32,7 @@ DatasetDetailPage* DatasetDetailPanel::pageFor(const QString& dsId) const {
} }
void DatasetDetailPanel::onDatasetOpened(const QString& dsId, const QString& ddCode, 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) { const std::vector<geopro::controller::TabSpec>& tabs) {
auto* p = pageFor(dsId); auto* p = pageFor(dsId);
if (!p) { if (!p) {
@ -40,6 +40,7 @@ void DatasetDetailPanel::onDatasetOpened(const QString& dsId, const QString& ddC
// 注入须在 build 前build 内造视图时即透传给工厂)。 // 注入须在 build 前build 内造视图时即透传给工厂)。
p->setColorTemplateRepo(colorTplRepo_, projectIdGetter_); p->setColorTemplateRepo(colorTplRepo_, projectIdGetter_);
p->setCommandRepo(cmdRepo_); p->setCommandRepo(cmdRepo_);
p->setTmObjectId(tmObjectId); // 白化 structParentIdbuild 前设置 → 透传给视图)
p->build(dsId, ddCode, dsName, tabs); // ddCode 透传 → 页内 tabNeeded 携带 p->build(dsId, ddCode, dsName, tabs); // ddCode 透传 → 页内 tabNeeded 携带
const QString title = dsName.isEmpty() ? dsId : dsName; // 页签标题用数据名(空则回退 id const QString title = dsName.isEmpty() ? dsId : dsName; // 页签标题用数据名(空则回退 id
const int idx = addTab(p, title); const int idx = addTab(p, title);

View File

@ -26,7 +26,9 @@ public:
void setCommandRepo(geopro::data::IDatasetCommandRepository* repo); void setCommandRepo(geopro::data::IDatasetCommandRepository* repo);
// 数据集打开find-or-create 页 → build(tabs) → 加/抬该面板页签。 // 数据集打开find-or-create 页 → build(tabs) → 加/抬该面板页签。
// tmObjectId所属 TM 对象 id白化 structParentIdbuild 前交给页 → 视图。
void onDatasetOpened(const QString& dsId, const QString& ddCode, const QString& dsName, void onDatasetOpened(const QString& dsId, const QString& ddCode, const QString& dsName,
const QString& tmObjectId,
const std::vector<geopro::controller::TabSpec>& tabs); const std::vector<geopro::controller::TabSpec>& tabs);
void onTabReady(const QString& dsId, int tabIndex, const QVariant& payload); void onTabReady(const QString& dsId, int tabIndex, const QVariant& payload);
void onTabLoadStarted(const QString& dsId, int tabIndex); void onTabLoadStarted(const QString& dsId, int tabIndex);

View File

@ -160,7 +160,7 @@ public:
namespace { namespace {
// 建一条数据集树项不挂载列0 文本 = dsName +「创建时间 · 类型名」data 存各角色。 // 建一条数据集树项不挂载列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 text = QString::fromStdString(d.dsName);
QString sub = QString::fromStdString(d.createTime); // 名称下先创建时间 QString sub = QString::fromStdString(d.createTime); // 名称下先创建时间
if (!d.typeName.empty()) 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, kDsNameRole, QString::fromStdString(d.dsName));
item->setData(0, kDsTypeNameRole, QString::fromStdString(d.typeName)); item->setData(0, kDsTypeNameRole, QString::fromStdString(d.typeName));
item->setData(0, kDsCreateTimeRole, QString::fromStdString(d.createTime)); item->setData(0, kDsCreateTimeRole, QString::fromStdString(d.createTime));
item->setData(0, kDsTmObjectIdRole, tmObjectId); // 所属 TM 对象 id白化 structParentId
// 单击 tip显示数据集主要属性名称 / 类型 / 创建时间对齐菜单文档「tip显示ds的主要属性」。 // 单击 tip显示数据集主要属性名称 / 类型 / 创建时间对齐菜单文档「tip显示ds的主要属性」。
QString tip = QStringLiteral("名称:%1").arg(QString::fromStdString(d.dsName)); QString tip = QStringLiteral("名称:%1").arg(QString::fromStdString(d.dsName));
if (!d.typeName.empty()) tip += QStringLiteral("\n类型:%1").arg(QString::fromStdString(d.typeName)); 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 } // 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 (!tree) return;
if (!append) tree->clear(); if (!append) tree->clear();
@ -199,7 +201,7 @@ void populateDatasetList(QTreeWidget* tree, const std::vector<geopro::data::DsRo
std::vector<QTreeWidgetItem*> batch; std::vector<QTreeWidgetItem*> batch;
batch.reserve(rows.size()); batch.reserve(rows.size());
for (const auto& d : rows) { for (const auto& d : rows) {
auto* item = makeDatasetItem(d); auto* item = makeDatasetItem(d, tmObjectId);
byId.insert(QString::fromStdString(d.id), item); byId.insert(QString::fromStdString(d.id), item);
batch.push_back(item); batch.push_back(item);
} }

View File

@ -22,11 +22,14 @@ constexpr int kDsDdCodeRole = 0x0104; // Qt::UserRole + 4ddCode双击详
constexpr int kDsNameRole = 0x0105; // Qt::UserRole + 5dsName详情页签标题用 constexpr int kDsNameRole = 0x0105; // Qt::UserRole + 5dsName详情页签标题用
constexpr int kDsTypeNameRole = 0x0106; // Qt::UserRole + 6类型名快速筛选用 constexpr int kDsTypeNameRole = 0x0106; // Qt::UserRole + 6类型名快速筛选用
constexpr int kDsCreateTimeRole = 0x0107; // Qt::UserRole + 7创建时间按日期筛选用 constexpr int kDsCreateTimeRole = 0x0107; // Qt::UserRole + 7创建时间按日期筛选用
constexpr int kDsTmObjectIdRole = 0x0108; // Qt::UserRole + 8所属 TM 对象 id=白化 structParentId
// 数据页签:树形(按 DsRow.parentId 嵌套,源数据为根、派生数据挂其下,对齐原版 el-table 树)。 // 数据页签:树形(按 DsRow.parentId 嵌套,源数据为根、派生数据挂其下,对齐原版 el-table 树)。
// 每项列0文本 = dsName +「创建时间 · 类型名」data(0,角色) 存 dsId/ddCode/dsName。 // 每项列0文本 = dsName +「创建时间 · 类型名」data(0,角色) 存 dsId/ddCode/dsName。
// append=true 时把新行挂到已加载的父节点下(分页)。 // 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。空时显示占位。 // 文件页签:每条 = 文件名 +可读大小UserRole 存 dsId、+2 存文件 url。空时显示占位。
void populateFileList(QListWidget* list, const std::vector<geopro::data::DsRow>& rows, bool append); void populateFileList(QListWidget* list, const std::vector<geopro::data::DsRow>& rows, bool append);

View File

@ -1,20 +1,47 @@
#include "panels/DescriptionPanel.hpp" #include "panels/DescriptionPanel.hpp"
#include <QColorDialog>
#include <QComboBox>
#include "EmptyAwareComboBox.hpp"
#include <QHBoxLayout> #include <QHBoxLayout>
#include <QPushButton> #include <QPushButton>
#include <QTextCharFormat>
#include <QTextEdit> #include <QTextEdit>
#include <QToolBar>
#include <QToolButton>
#include <QVBoxLayout> #include <QVBoxLayout>
#include "Theme.hpp" #include "Theme.hpp"
#include "panels/QuillDelta.hpp"
namespace geopro::app { 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) { DescriptionPanel::DescriptionPanel(QWidget* parent) : QWidget(parent) {
auto* lay = new QVBoxLayout(this); auto* lay = new QVBoxLayout(this);
lay->setContentsMargins(geopro::app::space::kMd, geopro::app::space::kMd, lay->setContentsMargins(geopro::app::space::kMd, geopro::app::space::kMd,
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_ = new QTextEdit(this);
edit_->setAcceptRichText(true);
edit_->setPlaceholderText(QStringLiteral("暂无描述")); edit_->setPlaceholderText(QStringLiteral("暂无描述"));
lay->addWidget(edit_, 1); lay->addWidget(edit_, 1);
@ -25,13 +52,121 @@ DescriptionPanel::DescriptionPanel(QWidget* parent) : QWidget(parent) {
btnLay->addWidget(saveBtn_); btnLay->addWidget(saveBtn_);
lay->addLayout(btnLay); lay->addLayout(btnLay);
connect(saveBtn_, &QPushButton::clicked, this, connect(saveBtn_, &QPushButton::clicked, this, [this]() { emit saveRequested(); });
[this]() { emit saveRequested(edit_->toPlainText()); });
} }
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); } void DescriptionPanel::setSaveEnabled(bool on) { saveBtn_->setEnabled(on); }

View File

@ -1,25 +1,38 @@
#pragma once #pragma once
#include <QJsonArray>
#include <QWidget> #include <QWidget>
class QTextEdit; class QTextEdit;
class QPushButton; class QPushButton;
class QToolBar;
namespace geopro::app { namespace geopro::app {
// 数据集描述面板:可编辑文本 + 保存按钮I14 // 数据集描述面板:富文本编辑器 + 格式工具栏 + 保存按钮I14
// 原版用 Quill 富文本DeltaQt 无对应控件 → 退化为纯文本编辑 + 保存; // 对照原版 webcontourPage.vue的 Quill 编辑器:粗体/斜体/下划线/字色/字号 +
// 保存时由调用方组装 {description, attachedParameters:{deltaContent}}(见 GridDataChartView // 有序/无序列表 + 标题。保存时把富文本转 Quill DeltaattachedParameters.deltaContent
// 与纯文本description一并提交组装/请求见 GridDataChartView
// Delta↔QTextDocument 互转见 QuillDelta.{hpp,cpp}(纯函数,可单测)。
class DescriptionPanel : public QWidget { class DescriptionPanel : public QWidget {
Q_OBJECT Q_OBJECT
public: public:
explicit DescriptionPanel(QWidget* parent = nullptr); 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 时禁用保存按钮(占位)。 // 注入「保存」可用性:无 cmdRepo/dsId 时禁用保存按钮(占位)。
void setSaveEnabled(bool on); void setSaveEnabled(bool on);
signals: signals:
void saveRequested(const QString& text); void saveRequested();
private: private:
void buildToolbar(QToolBar* tb);
QTextEdit* edit_; QTextEdit* edit_;
QPushButton* saveBtn_; QPushButton* saveBtn_;
}; };

View File

@ -2,6 +2,8 @@
#include <QCheckBox> #include <QCheckBox>
#include <QComboBox> #include <QComboBox>
#include "EmptyAwareComboBox.hpp"
#include <QDate> #include <QDate>
#include <QDateEdit> #include <QDateEdit>
#include <QDateTime> #include <QDateTime>
@ -101,7 +103,7 @@ QWidget* buildWidget(const data::EditField& f) {
if (ro) le->setEnabled(false); if (ro) le->setEnabled(false);
return le; return le;
} }
auto* cb = new QComboBox(); auto* cb = new EmptyAwareComboBox();
flattenOptions(f.options, cb); flattenOptions(f.options, cb);
const int idx = cb->findData(val); const int idx = cb->findData(val);
if (idx >= 0) cb->setCurrentIndex(idx); if (idx >= 0) cb->setCurrentIndex(idx);

View File

@ -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 名。
// tokenwhitelist 值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

View File

@ -0,0 +1,32 @@
#pragma once
#include <QJsonArray>
class QTextDocument;
namespace geopro::app {
// Quill Delta ↔ QTextDocument 互转(纯函数,仅依赖 Qt Core/Gui无 Widgets/MOC
//
// 背景:原版 webcontourPage.vue描述用 Quill 富文本,保存
// attachedParameters.deltaContent = quill.getContents().opsQuill Delta ops 数组),
// description = quill.getText()(纯文本)。读取时 quill.setContents(deltaContent)。
// 客户端无 Quill这里用 QTextDocument 承载富文本,并在两种表示间转换以与原版互通。
//
// 边界(无法做到字节级 1:1目标是「常见格式往返可用」
// - 支持的 inline attributesbold / italic / underline / color / background / size"NNpx")。
// - 支持的 block attributes挂在换行 op 上header1-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

View File

@ -1,8 +1,12 @@
#include "panels/chart/AutoAnnotationDialog.hpp" #include "panels/chart/AutoAnnotationDialog.hpp"
#include <algorithm>
#include <cmath>
#include <utility> #include <utility>
#include <QButtonGroup>
#include <QComboBox> #include <QComboBox>
#include <QFormLayout>
#include <QFrame> #include <QFrame>
#include <QHBoxLayout> #include <QHBoxLayout>
#include <QHeaderView> #include <QHeaderView>
@ -12,12 +16,19 @@
#include <QMessageBox> #include <QMessageBox>
#include <QPointer> #include <QPointer>
#include <QPushButton> #include <QPushButton>
#include <QRadioButton>
#include <QSpinBox> #include <QSpinBox>
#include <QTableWidget> #include <QTableWidget>
#include <QToolButton>
#include <QVBoxLayout> #include <QVBoxLayout>
#include <qwt_plot.h>
#include "FormKit.hpp" #include "FormKit.hpp"
#include "Theme.hpp" // scaledPx #include "Theme.hpp" // scaledPx
#include "dto/DatasetChartDto.hpp" // parseDatasetAnomaliesJSON→Anomaly
#include "panels/chart/ColorMapService.hpp"
#include "panels/chart/ContourPlotItem.hpp"
#include "repo/IDatasetCommandRepository.hpp" #include "repo/IDatasetCommandRepository.hpp"
namespace geopro::app { namespace geopro::app {
@ -26,54 +37,93 @@ namespace {
constexpr int kDefaultMinPoints = 4; // 原版 minPointCount 默认 4 constexpr int kDefaultMinPoints = 4; // 原版 minPointCount 默认 4
// 面异常类型固定 remarkSourceType="3"(原版自动标注仅支持面/polygon // 面异常类型固定 remarkSourceType="3"(原版自动标注仅支持面/polygon
const QString kPolygonType = QStringLiteral("3"); 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 } // namespace
AutoAnnotationDialog::AutoAnnotationDialog(geopro::data::IDatasetCommandRepository* repo, 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), : QDialog(parent),
repo_(repo), repo_(repo),
dsObjectId_(std::move(dsObjectId)), dsObjectId_(std::move(dsObjectId)),
projectId_(std::move(projectId)) { projectId_(std::move(projectId)),
grid_(grid),
scale_(scale) {
setWindowTitle(QStringLiteral("自动标注")); setWindowTitle(QStringLiteral("自动标注"));
setModal(true); setModal(true);
resize(820, 520); resize(geopro::app::scaledPx(1400), geopro::app::scaledPx(600));
auto* root = formkit::dialogRoot(this); auto* root = formkit::dialogRoot(this);
auto* split = new QHBoxLayout(); auto* split = new QHBoxLayout();
// ── 左:规则列表 ──────────────────────────────────────────────── // ── 左:规则卡片列表35%,对照原版左栏)────────────────────────────────
auto* leftCol = new QVBoxLayout(); auto* leftCol = new QVBoxLayout();
leftCol->addWidget(new QLabel(QStringLiteral("标注规则:"), this)); leftCol->addWidget(new QLabel(QStringLiteral("异常判定规则"), this));
auto* ruleContainer = new QWidget(this); auto* ruleContainer = new QWidget(this);
ruleHost_ = new QVBoxLayout(ruleContainer); ruleHost_ = new QVBoxLayout(ruleContainer);
ruleHost_->setContentsMargins(0, 0, 0, 0); ruleHost_->setContentsMargins(0, 0, 0, 0);
leftCol->addWidget(ruleContainer); leftCol->addWidget(ruleContainer);
auto* addBtn = new QPushButton(QStringLiteral("加规则"), this); auto* addBtn = new QPushButton(QStringLiteral("加规则"), this);
connect(addBtn, &QPushButton::clicked, this, [this]() { addRule(); }); connect(addBtn, &QPushButton::clicked, this, [this]() { addRule(); });
leftCol->addWidget(addBtn); leftCol->addWidget(addBtn);
leftCol->addStretch(); leftCol->addStretch();
split->addLayout(leftCol, 1); auto* leftWrap = new QWidget(this);
leftWrap->setLayout(leftCol);
split->addWidget(leftWrap, 35);
// ── 右:预览表 ────────────────────────────────────────────────── // ── 右:上(统计条 + 预览图) + 下预览表 ──────────────────────────────────
// 对照原版右上 <ContourPreview>:等值面预览图为主、数据统计在上。
auto* rightCol = new QVBoxLayout(); auto* rightCol = new QVBoxLayout();
rightCol->addWidget(new QLabel(QStringLiteral("预览:"), this)); buildStatsBar(rightCol);
previewTable_ = new QTableWidget(0, 4, this); buildPreviewPlot(rightCol);
previewTable_->setHorizontalHeaderLabels(
{QStringLiteral("异常名称"), QStringLiteral("异常类型"), QStringLiteral("阈值范围"), detectedLabel_ = new QLabel(QStringLiteral("自动标注结果"), this);
QStringLiteral("阈值模式")}); rightCol->addWidget(detectedLabel_);
previewTable_ = new QTableWidget(0, 6, this);
previewTable_->setHorizontalHeaderLabels({QStringLiteral("序号"), QStringLiteral("异常名称"),
QStringLiteral("异常类型"), QStringLiteral("阈值范围"),
QStringLiteral("阈值模式"), QStringLiteral("操作")});
previewTable_->horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch); previewTable_->horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch);
previewTable_->setEditTriggers(QAbstractItemView::NoEditTriggers); previewTable_->setEditTriggers(QAbstractItemView::NoEditTriggers);
rightCol->addWidget(previewTable_, 1); rightCol->addWidget(previewTable_, 1);
split->addLayout(rightCol, 1); auto* rightWrap = new QWidget(this);
rightWrap->setLayout(rightCol);
split->addWidget(rightWrap, 65);
root->addLayout(split, 1); root->addLayout(split, 1);
// ── 底部按钮 ──────────────────────────────────────────────────── // ── 底部按钮:取消 / 执行自动标注 / 确认保存 ─────────────────────────────
auto* btnLay = new QHBoxLayout(); auto* btnLay = new QHBoxLayout();
btnLay->addStretch(); btnLay->addStretch();
auto* cancelBtn = new QPushButton(QStringLiteral("取消"), this); auto* cancelBtn = new QPushButton(QStringLiteral("取消"), this);
auto* execBtn = 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); // 必须先执行得到预览才能保存 saveBtn_->setEnabled(false); // 必须先执行得到预览才能保存
btnLay->addWidget(cancelBtn); btnLay->addWidget(cancelBtn);
btnLay->addWidget(execBtn); btnLay->addWidget(execBtn);
@ -88,6 +138,76 @@ AutoAnnotationDialog::AutoAnnotationDialog(geopro::data::IDatasetCommandReposito
loadExceptionTypes(); // 拉面异常类型 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() { void AutoAnnotationDialog::loadExceptionTypes() {
if (!repo_) return; if (!repo_) return;
QPointer<AutoAnnotationDialog> self(this); QPointer<AutoAnnotationDialog> self(this);
@ -104,7 +224,7 @@ void AutoAnnotationDialog::loadExceptionTypes() {
self->exceptionTypeOptions_.append( self->exceptionTypeOptions_.append(
QJsonObject{{QStringLiteral("id"), id}, {QStringLiteral("name"), name}}); QJsonObject{{QStringLiteral("id"), id}, {QStringLiteral("name"), name}});
} }
// 回填已存在的规则行下拉。 // 回填已存在规则卡片下拉。
for (auto& r : self->rules_) { for (auto& r : self->rules_) {
r.type->clear(); r.type->clear();
for (const QJsonValue& ov : self->exceptionTypeOptions_) { for (const QJsonValue& ov : self->exceptionTypeOptions_) {
@ -119,39 +239,114 @@ void AutoAnnotationDialog::loadExceptionTypes() {
void AutoAnnotationDialog::addRule() { void AutoAnnotationDialog::addRule() {
auto* card = new QFrame(this); auto* card = new QFrame(this);
card->setFrameShape(QFrame::StyledPanel); card->setFrameShape(QFrame::StyledPanel);
auto* lay = new QHBoxLayout(card); auto* cardLay = new QVBoxLayout(card);
lay->setContentsMargins(4, 4, 4, 4); cardLay->setContentsMargins(6, 6, 6, 6);
RuleRow row; RuleCard rc;
row.mode = new QComboBox(card); rc.frame = card;
row.mode->addItem(QStringLiteral("数值"), 1);
row.mode->addItem(QStringLiteral("百分位"), 2); // 卡片头:折叠 + 「规则N」 + 删除。
row.min = new QLineEdit(card); auto* header = new QHBoxLayout();
row.min->setPlaceholderText(QStringLiteral("min")); auto* collapseBtn = new QToolButton(card);
row.min->setFixedWidth(scaledPx(60)); collapseBtn->setText(QStringLiteral(""));
row.max = new QLineEdit(card); collapseBtn->setCheckable(true);
row.max->setPlaceholderText(QStringLiteral("max")); rc.title = new QLabel(card);
row.max->setFixedWidth(scaledPx(60)); auto* delBtn = new QToolButton(card);
row.minPoints = new QSpinBox(card); delBtn->setText(QStringLiteral("删除"));
row.minPoints->setRange(1, 100000); header->addWidget(collapseBtn);
row.minPoints->setValue(kDefaultMinPoints); header->addWidget(rc.title, 1);
row.type = new QComboBox(card); 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_) { for (const QJsonValue& ov : exceptionTypeOptions_) {
const QJsonObject o = ov.toObject(); const QJsonObject o = ov.toObject();
row.type->addItem(o.value(QStringLiteral("name")).toString(), rc.type->addItem(o.value(QStringLiteral("name")).toString(),
o.value(QStringLiteral("id")).toString()); o.value(QStringLiteral("id")).toString());
} }
form->addRow(formkit::editLabel(QStringLiteral("异常类型")), rc.type);
lay->addWidget(new QLabel(QStringLiteral("模式"), card)); rc.body->setLayout(form);
lay->addWidget(row.mode); cardLay->addWidget(rc.body);
lay->addWidget(row.min);
lay->addWidget(row.max);
lay->addWidget(new QLabel(QStringLiteral("最小点数"), card));
lay->addWidget(row.minPoints);
lay->addWidget(row.type, 1);
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); 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 { QJsonArray AutoAnnotationDialog::buildRuleList() const {
@ -159,7 +354,7 @@ QJsonArray AutoAnnotationDialog::buildRuleList() const {
for (const auto& r : rules_) { for (const auto& r : rules_) {
QJsonObject rule{ QJsonObject rule{
{QStringLiteral("exceptionTypeId"), r.type->currentData().toString()}, {QStringLiteral("exceptionTypeId"), r.type->currentData().toString()},
{QStringLiteral("thresholdMode"), r.mode->currentData().toInt()}, {QStringLiteral("thresholdMode"), currentMode(r)},
{QStringLiteral("minPointCount"), r.minPoints->value()}, {QStringLiteral("minPointCount"), r.minPoints->value()},
}; };
// min/max空 → null对照原版 Number(...) 或 null // min/max空 → null对照原版 Number(...) 或 null
@ -176,11 +371,18 @@ QJsonArray AutoAnnotationDialog::buildRuleList() const {
void AutoAnnotationDialog::onExecute() { void AutoAnnotationDialog::onExecute() {
if (!repo_) return; if (!repo_) return;
const QJsonArray rules = buildRuleList(); // 校验:每条规则 min/max 至少填一个、异常类型必选(对照原版 handleExecute
if (rules.isEmpty()) { for (const auto& r : rules_) {
QMessageBox::warning(this, windowTitle(), QStringLiteral("请至少添加一条规则")); if (r.min->text().trimmed().isEmpty() && r.max->text().trimmed().isEmpty()) {
QMessageBox::warning(this, windowTitle(), QStringLiteral("阈值范围至少填写一项"));
return; return;
} }
if (r.type->currentData().toString().isEmpty()) {
QMessageBox::warning(this, windowTitle(), QStringLiteral("请选择异常类型"));
return;
}
}
const QJsonArray rules = buildRuleList();
QJsonObject body{ QJsonObject body{
{QStringLiteral("dsObjectId"), dsObjectId_}, {QStringLiteral("dsObjectId"), dsObjectId_},
{QStringLiteral("projectId"), projectId_}, {QStringLiteral("projectId"), projectId_},
@ -191,37 +393,83 @@ void AutoAnnotationDialog::onExecute() {
if (!self) return; if (!self) return;
if (!ok) { if (!ok) {
QMessageBox::warning(self, self->windowTitle(), QMessageBox::warning(self, self->windowTitle(),
msg.isEmpty() ? QStringLiteral("执行失败") : msg); msg.isEmpty() ? QStringLiteral("自动标注执行失败") : msg);
return; return;
} }
// 预览异常:兼容 data 直接为数组(wireObject 包成 value) 或 data.list。 // 预览异常:兼容 data 直接为数组(wireObject 包成 value) 或 data.list。
QJsonArray list = data.value(QStringLiteral("value")).toArray(); QJsonArray list = data.value(QStringLiteral("value")).toArray();
if (list.isEmpty()) list = data.value(QStringLiteral("list")).toArray(); if (list.isEmpty()) list = data.value(QStringLiteral("list")).toArray();
self->previewExceptions_ = list; self->previewExceptions_ = list;
self->detectedLabel_->setText(
QStringLiteral("自动标注结果(共识别到 %1 个异常)").arg(list.size()));
self->previewTable_->setRowCount(list.size()); self->previewTable_->setRowCount(list.size());
for (int i = 0; i < list.size(); ++i) { for (int i = 0; i < list.size(); ++i) {
const QJsonObject o = list[i].toObject(); const QJsonObject o = list[i].toObject();
self->previewTable_->setItem(i, 0, new QTableWidgetItem(QString::number(i + 1)));
self->previewTable_->setItem( self->previewTable_->setItem(
i, 0, new QTableWidgetItem(o.value(QStringLiteral("exceptionName")).toString())); i, 1, new QTableWidgetItem(o.value(QStringLiteral("exceptionName")).toString()));
self->previewTable_->setItem( self->previewTable_->setItem(
i, 1, i, 2,
new QTableWidgetItem(o.value(QStringLiteral("exceptionTypeName")).toString())); new QTableWidgetItem(o.value(QStringLiteral("exceptionTypeName")).toString()));
self->previewTable_->setItem( self->previewTable_->setItem(
i, 2, new QTableWidgetItem(o.value(QStringLiteral("remark")).toString())); i, 3, new QTableWidgetItem(o.value(QStringLiteral("remark")).toString()));
self->previewTable_->setItem( self->previewTable_->setItem(
i, 3, i, 4,
new QTableWidgetItem(o.value(QStringLiteral("thresholdModeName")).toString())); 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->saveBtn_->setEnabled(!list.isEmpty());
self->refreshPreviewAnomalies(); // 实时叠加预演异常到预览图
if (list.isEmpty()) if (list.isEmpty())
QMessageBox::information(self, self->windowTitle(), QMessageBox::information(self, self->windowTitle(), QStringLiteral("暂未识别到异常"));
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() { void AutoAnnotationDialog::onSave() {
if (!repo_ || previewExceptions_.isEmpty()) return; if (!repo_ || previewExceptions_.isEmpty()) {
// 组装 exceptionList保留 execute 返回项的关键字段。 QMessageBox::warning(this, windowTitle(), QStringLiteral("暂无可保存的异常,请先执行自动标注"));
return;
}
// 组装 exceptionList保留 execute 返回项的关键字段(对照原版 batchCreateException
QJsonArray exceptionList; QJsonArray exceptionList;
for (const QJsonValue& v : previewExceptions_) { for (const QJsonValue& v : previewExceptions_) {
const QJsonObject o = v.toObject(); const QJsonObject o = v.toObject();

View File

@ -2,14 +2,23 @@
#include <QDialog> #include <QDialog>
#include <QJsonArray> #include <QJsonArray>
#include <QString> #include <QString>
#include <memory>
#include <vector> #include <vector>
#include "model/Field.hpp" // core::Grid预览图等值面
#include "model/ColorScale.hpp" // core::ColorScale预览图色阶
class QButtonGroup;
class QComboBox; class QComboBox;
class QLineEdit; class QLineEdit;
class QSpinBox; class QSpinBox;
class QTableWidget; class QTableWidget;
class QPushButton; class QPushButton;
class QToolButton;
class QLabel;
class QVBoxLayout; class QVBoxLayout;
class QWidget;
class QwtPlot;
namespace geopro::data { namespace geopro::data {
class IDatasetCommandRepository; class IDatasetCommandRepository;
@ -17,40 +26,70 @@ class IDatasetCommandRepository;
namespace geopro::app { namespace geopro::app {
// 自动标注对话框I13复刻原版 AutoAnnotationDialog class ColorMapService;
// 左:规则列表(阈值模式 数值/百分位、min/max、最小点数、异常类型 class ContourPlotItem;
// 执行 → executeExceptionMark(预演) → 预览表;确定保存 → batchCreateException → reloadGrid。
// 异常类型仅支持面/polygon原版同故 listExceptionTypes 取 remarkSourceType="3"。 // 自动标注对话框I13复刻原版 AutoAnnotationDialog1400×600
// 左规则卡片列表标题「规则N」+ 折叠 + 删除;阈值模式 radio-button 数值/百分位、min/max、
// 最小点数、异常类型)+「添加规则」。
// 右上:数据统计(最大/最小/均值/中位数,从网格标量算)+ 预览图(后置标注)。
// 右下:预览表(序号/异常名称/异常类型/阈值范围/阈值模式/操作删除)。
// 执行 → executeExceptionMark(预演) → 预览表;确认保存 → batchCreateException → reloadGrid。
// 异常类型仅支持面/polygon原版同listExceptionTypes 取 remarkSourceType="3"。
class AutoAnnotationDialog : public QDialog { class AutoAnnotationDialog : public QDialog {
Q_OBJECT Q_OBJECT
public: public:
// grid/scale当前反演网格 + 色阶用于右上预览图ContourPlotItem 渲染等值面)
// 及数据统计max/min/mean/median 从 grid.values() 算)。
AutoAnnotationDialog(geopro::data::IDatasetCommandRepository* repo, QString dsObjectId, 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: private:
struct RuleRow { // 一条规则卡片的控件集合(卡片标题/折叠/删除 + 模式 radio + min/max + 最小点数 + 类型)。
QComboBox* mode = nullptr; // 1 数值 / 2 百分位 struct RuleCard {
QWidget* frame = nullptr; // 整张卡片(删除时移除)
QWidget* body = nullptr; // 折叠隐藏的主体
QLabel* title = nullptr; // 「规则N」
QButtonGroup* modeGroup = nullptr; // 1 数值 / 2 百分位radio-button
QLineEdit* min = nullptr; QLineEdit* min = nullptr;
QLineEdit* max = nullptr; QLineEdit* max = nullptr;
QSpinBox* minPoints = 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 addRule(); // 加一条规则卡片
void removeRule(QWidget* frame); // 删除指定卡片(至少保留一条)
void renumberRules(); // 重排卡片标题「规则N」
int currentMode(const RuleCard& c) const; // 取当前 radio 模式1/2
QJsonArray buildRuleList() const; // 组装 exceptionMarkRuleList QJsonArray buildRuleList() const; // 组装 exceptionMarkRuleList
void onExecute(); // executeExceptionMark → 预览 void onExecute(); // executeExceptionMark → 预览
void onSave(); // batchCreateException void onSave(); // batchCreateException
void deletePreviewRow(int row); // 删除一条预览异常
geopro::data::IDatasetCommandRepository* repo_ = nullptr; geopro::data::IDatasetCommandRepository* repo_ = nullptr;
QString dsObjectId_; QString dsObjectId_;
QString projectId_; 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; QVBoxLayout* ruleHost_ = nullptr;
std::vector<RuleRow> rules_; std::vector<RuleCard> rules_;
QJsonArray exceptionTypeOptions_; // 缓存的类型列表({id,name}),新增规则行复用 QJsonArray exceptionTypeOptions_; // 缓存的类型列表({id,name}),新增规则卡片复用
QTableWidget* previewTable_ = nullptr; QTableWidget* previewTable_ = nullptr;
QJsonArray previewExceptions_; // execute 返回的预览异常confirm 时批量存) QJsonArray previewExceptions_; // execute 返回的预览异常confirm 时批量存)
QLabel* detectedLabel_ = nullptr; // 「共识别到 N 个异常」
QPushButton* saveBtn_ = nullptr; QPushButton* saveBtn_ = nullptr;
}; };

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -5,14 +5,22 @@
#include <QCursor> #include <QCursor>
#include <QHBoxLayout> #include <QHBoxLayout>
#include <QHeaderView> #include <QHeaderView>
#include <QJsonArray>
#include <QMessageBox>
#include <QPainter> #include <QPainter>
#include <QPointer>
#include <QPushButton> #include <QPushButton>
#include <QRadioButton>
#include <QTableView> #include <QTableView>
#include <QToolTip> #include <QToolTip>
#include <QVBoxLayout> #include <QVBoxLayout>
#include "Theme.hpp"
#include "panels/chart/InversionFormDialog.hpp" #include "panels/chart/InversionFormDialog.hpp"
#include "panels/chart/ScatterDataOps.hpp" // toggledDisplayStatus
#include "panels/chart/TablePager.hpp" #include "panels/chart/TablePager.hpp"
#include "repo/IDatasetCommandRepository.hpp"
namespace geopro::app { namespace geopro::app {
@ -70,6 +78,38 @@ geopro::core::TableColumnKind TablePayloadModel::columnKind(int column) const {
return payload_.columns[static_cast<size_t>(column)].kind; 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) ToggleSwitchDelegate::ToggleSwitchDelegate(const TablePayloadModel* model, QObject* parent)
: QStyledItemDelegate(parent), model_(model) {} : QStyledItemDelegate(parent), model_(model) {}
@ -118,11 +158,16 @@ DataTableView::DataTableView(QWidget* parent) : QWidget(parent) {
lay->setContentsMargins(0, 0, 0, 0); lay->setContentsMargins(0, 0, 0, 0);
lay->setSpacing(0); lay->setSpacing(0);
// 顶部功能按钮行(默认隐藏;仅 dd_grid 载荷带 functionButtons 时显示)。右对齐贴近原版布局。 // 顶部功能按钮行(默认隐藏;仅 dd_grid 载荷带 functionButtons 时显示)。
// 布局对照原版 DdGrid/index.vue .swicth左侧 radio-group(「电法列表」单选项) + 右侧主按钮组,
// space-between。radio 仅视觉占位(原版亦仅一项、无实际切换作用)。
toolbar_ = new QWidget(this); toolbar_ = new QWidget(this);
toolbarLay_ = new QHBoxLayout(toolbar_); toolbarLay_ = new QHBoxLayout(toolbar_);
toolbarLay_->setContentsMargins(0, 0, 0, 8); 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(); toolbar_->hide();
lay->addWidget(toolbar_); lay->addWidget(toolbar_);
@ -141,6 +186,8 @@ DataTableView::DataTableView(QWidget* parent) : QWidget(parent) {
// Toggle 列委托:把“隐藏/显示”列画成蓝色药丸开关。 // Toggle 列委托:把“隐藏/显示”列画成蓝色药丸开关。
table_->setItemDelegate(new ToggleSwitchDelegate(model_, table_)); table_->setItemDelegate(new ToggleSwitchDelegate(model_, table_));
// M2点击 Toggle 列(仅 measurement 可交互)→ 行级显隐切换。
connect(table_, &QTableView::clicked, this, &DataTableView::onCellClicked);
lay->addWidget(table_); lay->addWidget(table_);
@ -191,24 +238,36 @@ void DataTableView::setCommandRepo(geopro::data::IDatasetCommandRepository* repo
} }
void DataTableView::rebuildToolbar(const std::vector<geopro::core::TableFunctionButton>& buttons) { void DataTableView::rebuildToolbar(const std::vector<geopro::core::TableFunctionButton>& buttons) {
// 清空旧按钮(保留末尾 addStretch逐项删 QPushButton)。 // 清空旧功能按钮(仅删 QPushButton保留左侧 radio 与中间 stretch)。
for (int i = toolbarLay_->count() - 1; i >= 0; --i) { for (int i = toolbarLay_->count() - 1; i >= 0; --i) {
if (auto* w = toolbarLay_->itemAt(i)->widget()) { if (auto* btn = qobject_cast<QPushButton*>(toolbarLay_->itemAt(i)->widget())) {
toolbarLay_->removeWidget(w); toolbarLay_->removeWidget(btn);
w->deleteLater(); btn->deleteLater();
} }
} }
// 仅渲染 enable 的按钮(原版 v-show="enable");全空/全禁用 → 隐藏整行。 // 仅渲染 enable 的按钮(原版 v-show="enable");全空/全禁用 → 隐藏整行。
// 主按钮蓝色实心(对照原版 type="primary"),复用 primaryBtn QSS。
int shown = 0; int shown = 0;
for (const auto& b : buttons) { for (const auto& b : buttons) {
if (!b.enable) continue; if (!b.enable) continue;
auto* btn = new QPushButton(b.nameChn, toolbar_); auto* btn = new QPushButton(b.nameChn, toolbar_);
btn->setObjectName(QStringLiteral("primaryBtn"));
const QString code = b.code; const QString code = b.code;
connect(btn, &QPushButton::clicked, this, [this, code] { onFunctionButton(code); }); connect(btn, &QPushButton::clicked, this, [this, code] { onFunctionButton(code); });
toolbarLay_->addWidget(btn); toolbarLay_->addWidget(btn); // 末尾追加 → 落在 stretch 之后(右侧)
++shown; ++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); toolbar_->setVisible(shown > 0);
} }
@ -227,4 +286,42 @@ void DataTableView::onFunctionButton(const QString& code) {
dlg.exec(); // 提交反馈由对话框内部处理;列表无需刷新(原版亦仅 Message.success 提示)。 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 } // namespace geopro::app

View File

@ -33,7 +33,17 @@ public:
// 列渲染种类(供委托判断是否画开关)。越界返回 Text。 // 列渲染种类(供委托判断是否画开关)。越界返回 Text。
geopro::core::TableColumnKind columnKind(int column) const; 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: private:
int toggleColumn() const; // Toggle 列下标(无则 -1
geopro::core::TablePayload payload_; geopro::core::TablePayload payload_;
}; };
@ -81,6 +91,7 @@ signals:
private: private:
void rebuildToolbar(const std::vector<geopro::core::TableFunctionButton>& buttons); void rebuildToolbar(const std::vector<geopro::core::TableFunctionButton>& buttons);
void onFunctionButton(const QString& code); // 功能按钮路由(仅 inversion 起效) void onFunctionButton(const QString& code); // 功能按钮路由(仅 inversion 起效)
void onCellClicked(const QModelIndex& index); // M2 行级显隐切换(仅 measurement Toggle 列)
QWidget* toolbar_; // 顶部功能按钮行容器functionButtons 空时隐藏) QWidget* toolbar_; // 顶部功能按钮行容器functionButtons 空时隐藏)
QHBoxLayout* toolbarLay_; // 功能按钮布局(重建时清空重填) QHBoxLayout* toolbarLay_; // 功能按钮布局(重建时清空重填)

View File

@ -16,12 +16,15 @@ std::unique_ptr<IDetailView> makeDetailView(controller::ViewKind kind, QWidget*
geopro::data::IColorTemplateRepository* colorTplRepo, geopro::data::IColorTemplateRepository* colorTplRepo,
std::function<QString()> projectIdGetter, std::function<QString()> projectIdGetter,
geopro::data::IDatasetCommandRepository* cmdRepo, geopro::data::IDatasetCommandRepository* cmdRepo,
std::function<QString()> dsIdGetter) { std::function<QString()> dsIdGetter,
std::function<QString()> tmObjectIdGetter) {
switch (kind) { switch (kind) {
case controller::ViewKind::Scatter: { case controller::ViewKind::Scatter: {
auto* raw = new RawDataChartView(parent); auto* raw = new RawDataChartView(parent);
// 注入反演命令仓储 + dsId/projectId 取值回调measurement 反演运算/生成视电阻率)。 // 注入反演命令仓储 + dsId/projectId 取值回调measurement 反演运算/生成视电阻率)。
raw->setCommandRepo(cmdRepo, dsIdGetter, projectIdGetter); raw->setCommandRepo(cmdRepo, dsIdGetter, projectIdGetter);
// 注入色阶模板仓储(散点「色阶配置」编辑器另存为/打开/覆盖用projectId 复用上面的 getter
raw->setColorTemplateRepo(colorTplRepo);
return std::unique_ptr<IDetailView>(raw); return std::unique_ptr<IDetailView>(raw);
} }
case controller::ViewKind::FilledContour: { case controller::ViewKind::FilledContour: {
@ -30,6 +33,8 @@ std::unique_ptr<IDetailView> makeDetailView(controller::ViewKind kind, QWidget*
grid->setColorTemplateRepo(colorTplRepo, projectIdGetter); grid->setColorTemplateRepo(colorTplRepo, projectIdGetter);
// 注入反演命令仓储 + dsId/projectId 取值回调(网格化/白化/滤波/另存为按钮)。 // 注入反演命令仓储 + dsId/projectId 取值回调(网格化/白化/滤波/另存为按钮)。
grid->setCommandRepo(cmdRepo, std::move(dsIdGetter), std::move(projectIdGetter)); grid->setCommandRepo(cmdRepo, std::move(dsIdGetter), std::move(projectIdGetter));
// 注入 tmObjectId 取值回调(白化对话框模板列表用,= 数据集 structParentId
grid->setTmObjectIdGetter(std::move(tmObjectIdGetter));
return std::unique_ptr<IDetailView>(grid); return std::unique_ptr<IDetailView>(grid);
} }
case controller::ViewKind::Table: { case controller::ViewKind::Table: {

View File

@ -22,11 +22,13 @@ class IDetailView;
// 现阶段命中会抛 std::runtime_error明确失败而非静默空指针 // 现阶段命中会抛 std::runtime_error明确失败而非静默空指针
// colorTplRepo/projectIdGetterFilledContour 视图的色阶模板仓储注入(可空 → 编辑器后端按钮禁用)。 // colorTplRepo/projectIdGetterFilledContour 视图的色阶模板仓储注入(可空 → 编辑器后端按钮禁用)。
// cmdRepo/dsIdGetterScatter 视图measurement反演运算/生成视电阻率命令仓储注入(可空 → 按钮占位提示)。 // cmdRepo/dsIdGetterScatter 视图measurement反演运算/生成视电阻率命令仓储注入(可空 → 按钮占位提示)。
// tmObjectIdGetterFilledContour 视图白化对话框所需 tmObjectId=数据集 structParentId取值回调可空 → 模板列表空)。
std::unique_ptr<IDetailView> makeDetailView( std::unique_ptr<IDetailView> makeDetailView(
controller::ViewKind kind, QWidget* parent, controller::ViewKind kind, QWidget* parent,
geopro::data::IColorTemplateRepository* colorTplRepo = nullptr, geopro::data::IColorTemplateRepository* colorTplRepo = nullptr,
std::function<QString()> projectIdGetter = {}, std::function<QString()> projectIdGetter = {},
geopro::data::IDatasetCommandRepository* cmdRepo = nullptr, geopro::data::IDatasetCommandRepository* cmdRepo = nullptr,
std::function<QString()> dsIdGetter = {}); std::function<QString()> dsIdGetter = {},
std::function<QString()> tmObjectIdGetter = {});
} // namespace geopro::app } // namespace geopro::app

View File

@ -1,9 +1,12 @@
#include "panels/chart/ExceptionDetailDialog.hpp" #include "panels/chart/ExceptionDetailDialog.hpp"
#include <QColorDialog>
#include <QComboBox> #include <QComboBox>
#include <QDoubleSpinBox>
#include "EmptyAwareComboBox.hpp"
#include <QFile>
#include <QFileDialog>
#include <QFormLayout> #include <QFormLayout>
#include <QFrame>
#include <QHBoxLayout> #include <QHBoxLayout>
#include <QHeaderView> #include <QHeaderView>
#include <QJsonObject> #include <QJsonObject>
@ -13,7 +16,9 @@
#include <QPlainTextEdit> #include <QPlainTextEdit>
#include <QPointer> #include <QPointer>
#include <QPushButton> #include <QPushButton>
#include <QTabWidget>
#include <QTableWidget> #include <QTableWidget>
#include <QTextStream>
#include <QVBoxLayout> #include <QVBoxLayout>
#include "FormKit.hpp" #include "FormKit.hpp"
@ -22,82 +27,58 @@
namespace geopro::app { 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, ExceptionDetailDialog::ExceptionDetailDialog(geopro::data::IDatasetCommandRepository* repo,
const geopro::core::Anomaly& anomaly, QWidget* parent) const geopro::core::Anomaly& anomaly, QWidget* parent)
: QDialog(parent), repo_(repo), anomaly_(anomaly) { : QDialog(parent), repo_(repo), anomaly_(anomaly) {
setWindowTitle(QStringLiteral("异常详情")); setWindowTitle(QStringLiteral("标注详情"));
setModal(true); setModal(true);
resize(420, 460); // 右侧抽屉观感:窄而高(对照原版 ADrawer width=380
resize(geopro::app::scaledPx(380), geopro::app::scaledPx(560));
lineColor_ = QString::fromStdString(anomaly_.lineColor);
if (lineColor_.isEmpty()) lineColor_ = QStringLiteral("#000000");
auto* root = formkit::dialogRoot(this); auto* root = formkit::dialogRoot(this);
auto* card = formkit::formCard(this); // ── 头部:名称(可编辑) + 异常类型(只读) ───────────────────────────────────
auto* cardLay = formkit::cardBody(card); auto* head = formkit::makeEditForm();
auto* form = formkit::makeEditForm();
nameEdit_ = new QLineEdit(QString::fromStdString(anomaly_.name), this); nameEdit_ = new QLineEdit(QString::fromStdString(anomaly_.name), this);
formkit::capField(nameEdit_); formkit::capField(nameEdit_);
form->addRow(formkit::editLabel(QStringLiteral("名称")), nameEdit_); head->addRow(formkit::editLabel(QStringLiteral("名称")), nameEdit_);
auto* typeLabel = new QLabel(QString::fromStdString(anomaly_.typeName), this); 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*)。 // ── 双 Tab图例信息 / 坐标信息 ───────────────────────────────────────────
colorBtn_ = new QPushButton(lineColor_, this); auto* tabs = new QTabWidget(this);
colorBtn_->setStyleSheet(QStringLiteral("background:%1;").arg(lineColor_)); tabs->addTab(buildLegendTab(), QStringLiteral("图例信息"));
connect(colorBtn_, &QPushButton::clicked, this, [this]() { tabs->addTab(buildCoordTab(), QStringLiteral("坐标信息"));
const QColor c = QColorDialog::getColor(QColor(lineColor_), this, QStringLiteral("线色")); root->addWidget(tabs, 1);
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_);
// ── 底部:备注(可编辑) ───────────────────────────────────────────────────
root->addWidget(new QLabel(QStringLiteral("备注:"), this));
remarkEdit_ = new QPlainTextEdit(QString::fromStdString(anomaly_.remark), this); remarkEdit_ = new QPlainTextEdit(QString::fromStdString(anomaly_.remark), this);
remarkEdit_->setFixedHeight(geopro::app::scaledPx(60)); remarkEdit_->setFixedHeight(geopro::app::scaledPx(70));
formkit::capField(remarkEdit_); root->addWidget(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);
auto* btnLay = new QHBoxLayout(); auto* btnLay = new QHBoxLayout();
btnLay->addStretch(); btnLay->addStretch();
auto* cancelBtn = new QPushButton(QStringLiteral("取消"), this); auto* cancelBtn = new QPushButton(QStringLiteral("取消"), this);
okBtn_ = new QPushButton(QStringLiteral("确定"), this); okBtn_ = new QPushButton(QStringLiteral("更新"), this); // 对照原版 ok-text="更新"
okBtn_->setDefault(true); okBtn_->setDefault(true);
btnLay->addWidget(cancelBtn); btnLay->addWidget(cancelBtn);
btnLay->addWidget(okBtn_); btnLay->addWidget(okBtn_);
@ -107,6 +88,136 @@ ExceptionDetailDialog::ExceptionDetailDialog(geopro::data::IDatasetCommandReposi
connect(okBtn_, &QPushButton::clicked, this, &ExceptionDetailDialog::onConfirm); 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"));
// 条件显示(对照原版 drawerExceptionInfolatLon.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 {
// 按当前坐标系返回对应点集(对照原版 handleCoordChangejb=图形 / 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() { void ExceptionDetailDialog::onConfirm() {
if (!repo_ || anomaly_.id.empty()) { reject(); return; } if (!repo_ || anomaly_.id.empty()) { reject(); return; }
const QString name = nameEdit_->text().trimmed(); const QString name = nameEdit_->text().trimmed();
@ -114,9 +225,8 @@ void ExceptionDetailDialog::onConfirm() {
QMessageBox::warning(this, windowTitle(), QStringLiteral("请输入名称")); QMessageBox::warning(this, windowTitle(), QStringLiteral("请输入名称"));
return; return;
} }
// 原版详情抽屉「改名称/备注」走 PUT /business/exception 的局部更新, // 对照原版 drawerExceptionInfo onOk图例样式控件 disabled、不发 legend
// 仅发 {id, exceptionName, remark}(线样式是另一条独立 PUT且抽屉里样式控件 disabled // 仅 PUT {id, exceptionName, remark}。
// 对齐原版 contourPage.vue onOk不合并/不重发 legend。
QJsonObject body{ QJsonObject body{
{QStringLiteral("id"), QString::fromStdString(anomaly_.id)}, {QStringLiteral("id"), QString::fromStdString(anomaly_.id)},
{QStringLiteral("exceptionName"), name}, {QStringLiteral("exceptionName"), name},

View File

@ -6,9 +6,10 @@
class QLineEdit; class QLineEdit;
class QPlainTextEdit; class QPlainTextEdit;
class QDoubleSpinBox;
class QComboBox; class QComboBox;
class QTableWidget;
class QPushButton; class QPushButton;
class QLabel;
namespace geopro::data { namespace geopro::data {
class IDatasetCommandRepository; class IDatasetCommandRepository;
@ -16,10 +17,13 @@ class IDatasetCommandRepository;
namespace geopro::app { namespace geopro::app {
// 异常详情/编辑对话框I11复刻原版 drawerExceptionInfo 的可编辑部分): // 异常详情/编辑对话框I11复刻原版 drawerExceptionInfo 右侧抽屉形态):
// 名称(可编辑) / 异常类型(只读) / 图例样式(线色/线宽/线型) / 备注(可编辑) / 坐标(只读展示)。 // 双 Tab「图例信息 / 坐标信息」。
// 确认 → updateException(PUT body {id, exceptionName, remark, legend:{polylineColor, // - 头部:名称(可编辑) + 异常类型(只读)。
// polylineWidth, polylineShape}}),成功 accept(),调用方随后 reloadGrid。 // - 图例信息:类型 + 顶点/端点数 + 线色/线宽/线型/不透明度(全部「只读展示」,对照原版 disabled
// - 坐标信息:坐标系切换(图形/经纬度/投影) + 顶点数 + 坐标表(7位小数) + 导出 txt。
// - 底部:备注(可编辑)。
// 确认 → updateException(PUT body 仅 {id, exceptionName, remark},与原版一致;线样式只读不发)。
class ExceptionDetailDialog : public QDialog { class ExceptionDetailDialog : public QDialog {
Q_OBJECT Q_OBJECT
public: public:
@ -28,16 +32,21 @@ public:
private: private:
void onConfirm(); void onConfirm();
void onCoordSystemChanged(); // 切换坐标系 → 按对应点集重填坐标表(图形/经纬度/投影)
void exportCoords(); // 导出当前坐标系坐标为 txt7位小数
// 当前坐标系对应的点集jb=图形 / lonlat=经纬度 / projection=投影;纯展示响应数据,不换算)。
const std::vector<geopro::core::Vec2>& activeCoords() const;
QWidget* buildLegendTab(); // 图例信息 Tab只读样式
QWidget* buildCoordTab(); // 坐标信息 Tab
geopro::data::IDatasetCommandRepository* repo_ = nullptr; geopro::data::IDatasetCommandRepository* repo_ = nullptr;
geopro::core::Anomaly anomaly_; // 拷贝(不可变范式:编辑后组装新 body不改原对象 geopro::core::Anomaly anomaly_; // 拷贝(不可变范式:编辑后组装新 body不改原对象
QLineEdit* nameEdit_ = nullptr; QLineEdit* nameEdit_ = nullptr;
QPlainTextEdit* remarkEdit_ = nullptr; QPlainTextEdit* remarkEdit_ = nullptr;
QPushButton* colorBtn_ = nullptr; // 线色选择(弹 QColorDialog QComboBox* coordSysCombo_ = nullptr; // jb 图形 / lonlat 经纬度 / projection 投影
QString lineColor_; // 当前线色 hex QTableWidget* coordTable_ = nullptr;
QDoubleSpinBox* widthSpin_ = nullptr; QLabel* vertexCountLabel_ = nullptr;
QComboBox* shapeCombo_ = nullptr; // solid / dash
QPushButton* okBtn_ = nullptr; QPushButton* okBtn_ = nullptr;
}; };

View File

@ -3,6 +3,8 @@
#include <utility> #include <utility>
#include <QComboBox> #include <QComboBox>
#include "EmptyAwareComboBox.hpp"
#include <QFormLayout> #include <QFormLayout>
#include <QHBoxLayout> #include <QHBoxLayout>
#include <QHeaderView> #include <QHeaderView>
@ -18,6 +20,7 @@
#include "FormKit.hpp" #include "FormKit.hpp"
#include "Theme.hpp" #include "Theme.hpp"
#include "panels/chart/ExceptionTypeDialog.hpp"
#include "repo/IDatasetCommandRepository.hpp" #include "repo/IDatasetCommandRepository.hpp"
namespace geopro::app { namespace geopro::app {
@ -48,7 +51,7 @@ ExceptionDialog::ExceptionDialog(geopro::data::IDatasetCommandRepository* repo,
auto* form = formkit::makeEditForm(); auto* form = formkit::makeEditForm();
// 标注类型remarkSourceType "1".."4",与原版 annotationType 一致)。 // 标注类型remarkSourceType "1".."4",与原版 annotationType 一致)。
markTypeCombo_ = new QComboBox(this); markTypeCombo_ = new EmptyAwareComboBox(this);
markTypeCombo_->addItem(QStringLiteral(""), QStringLiteral("1")); markTypeCombo_->addItem(QStringLiteral(""), QStringLiteral("1"));
markTypeCombo_->addItem(QStringLiteral("线"), QStringLiteral("2")); markTypeCombo_->addItem(QStringLiteral("线"), QStringLiteral("2"));
markTypeCombo_->addItem(QStringLiteral(""), QStringLiteral("3")); markTypeCombo_->addItem(QStringLiteral(""), QStringLiteral("3"));
@ -56,9 +59,18 @@ ExceptionDialog::ExceptionDialog(geopro::data::IDatasetCommandRepository* repo,
formkit::capField(markTypeCombo_); formkit::capField(markTypeCombo_);
form->addRow(formkit::editLabel(QStringLiteral("标注类型")), markTypeCombo_); form->addRow(formkit::editLabel(QStringLiteral("标注类型")), markTypeCombo_);
exceptionTypeCombo_ = new QComboBox(this); // 异常类型行:下拉 + 「新增异常类型」按钮(对照原版 exceptionDialog 同行布局)。
// 空态感知下拉异常类型异步加载loadExceptionTypes未选显占位、无数据弹「暂无数据」。
exceptionTypeCombo_ = formkit::comboBox(QStringLiteral("请选择异常类型"), this);
formkit::capField(exceptionTypeCombo_); 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_ = new QLineEdit(this);
nameEdit_->setPlaceholderText(QStringLiteral("数据名称+异常类型代号+序号")); nameEdit_->setPlaceholderText(QStringLiteral("数据名称+异常类型代号+序号"));
@ -72,8 +84,8 @@ ExceptionDialog::ExceptionDialog(geopro::data::IDatasetCommandRepository* repo,
cardLay->addLayout(form); cardLay->addLayout(form);
root->addWidget(card); root->addWidget(card);
// 坐标x/y 多行),下方加/减行按钮 // 坐标兜底表x/y 多行):留空 → 确定后在图上绘形采集(主路径);手填 → 直接提交
root->addWidget(new QLabel(QStringLiteral("坐标xy"), this)); root->addWidget(new QLabel(QStringLiteral("坐标xy,留空则在图上绘制"), this));
coordTable_ = new QTableWidget(0, 2, this); coordTable_ = new QTableWidget(0, 2, this);
coordTable_->setHorizontalHeaderLabels({QStringLiteral("x"), QStringLiteral("y")}); coordTable_->setHorizontalHeaderLabels({QStringLiteral("x"), QStringLiteral("y")});
coordTable_->horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch); coordTable_->horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch);
@ -115,13 +127,59 @@ QString ExceptionDialog::markTypeValue() const {
return markTypeCombo_->currentData().toString(); 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() { void ExceptionDialog::onTypeChanged() {
// 调整坐标表行数到该形态最少点数(不足则补行;已多则保留)。 // 对照原版 handleAnnotationTypeChange标注类型变 → 清空名称(待重选类型后回填)+
const int need = minPoints(markTypeValue()); // 重拉对应几何形态的异常类型列表 + 刷新「新增类型」按钮可用性。
while (coordTable_->rowCount() < need) coordTable_->insertRow(coordTable_->rowCount()); nameEdit_->clear();
updateAddTypeEnabled();
loadExceptionTypes(); 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() { void ExceptionDialog::loadExceptionTypes() {
if (!repo_) return; if (!repo_) return;
QPointer<ExceptionDialog> self(this); QPointer<ExceptionDialog> self(this);
@ -138,6 +196,12 @@ void ExceptionDialog::loadExceptionTypes() {
if (id.isEmpty()) id = o.value(QStringLiteral("id")).toString(); if (id.isEmpty()) id = o.value(QStringLiteral("id")).toString();
if (!id.isEmpty()) self->exceptionTypeCombo_->addItem(label, id); 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(); if (self->exceptionTypeCombo_->count() > 0) self->suggestName();
}); });
} }
@ -147,12 +211,9 @@ void ExceptionDialog::suggestName() {
const QString typeId = exceptionTypeCombo_->currentData().toString(); const QString typeId = exceptionTypeCombo_->currentData().toString();
if (typeId.isEmpty()) return; if (typeId.isEmpty()) return;
QPointer<ExceptionDialog> self(this); 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 || !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); self->nameEdit_->setText(name);
}); });
} }
@ -169,18 +230,11 @@ void ExceptionDialog::onConfirm() {
QMessageBox::warning(this, windowTitle(), QStringLiteral("请选择异常类型")); QMessageBox::warning(this, windowTitle(), QStringLiteral("请选择异常类型"));
return; return;
} }
// 收集坐标(跳过空行)。 // 主路径:坐标表留空 → accept(),由调用方在图上绘形采集坐标后 newException。
QJsonArray coords; const QJsonArray coords = manualCoordinates();
for (int r = 0; r < coordTable_->rowCount(); ++r) { if (coords.isEmpty()) { accept(); return; }
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}});
}
if (coords.size() < minPoints(markTypeValue())) { if (coords.size() < minPoints(markTypeValue())) {
QMessageBox::warning(this, windowTitle(), QMessageBox::warning(this, windowTitle(),
QStringLiteral("坐标点数不足(点/文字≥1线≥2面≥3")); QStringLiteral("坐标点数不足(点/文字≥1线≥2面≥3"));

View File

@ -2,6 +2,7 @@
#include <functional> #include <functional>
#include <QDialog> #include <QDialog>
#include <QJsonArray>
#include <QJsonObject> #include <QJsonObject>
#include <QString> #include <QString>
@ -17,24 +18,33 @@ class IDatasetCommandRepository;
namespace geopro::app { namespace geopro::app {
// 异常创建对话框I9复刻原版 exceptionDialog + contourPage 保存链路 // 异常创建对话框I9复刻原版 exceptionDialog 时序
// 标注类型(点/线/面/文字 → remarkSourceType "1"/"2"/"3"/"4") + 异常类型(listExceptionTypes) // 先弹窗选 标注类型(点/线/面/文字 → remarkSourceType "1".."4") + 异常类型(listExceptionTypes)
// + 名称(getExceptionName 建议) + 备注 + 坐标表 // + 名称(getExceptionName 建议) + 备注;确认后由调用方在图上交互绘形采集坐标,再 newException
// 确认 → newException(body),成功 accept(),调用方随后 reloadGrid。 // 时序对照原版 contourPage/exceptionDialog弹窗仅收元信息不收坐标accept() 后调用方读
// 说明:原版「图上交互式绘制几何」在 Qwt 成本高本实现以坐标表x/y 多行)采集 location // markTypeValue()/exceptionTypeId()/name()/remark() 启动 ContourDrawTool 绘形 → 完成提交。
// 覆盖点(1 行)/线(≥2)/面(≥3)/文字(1) 全形态打通完整创建链路on-chart 拖拽绘制为后置项 // 兜底:坐标表仍保留(无法图上绘形时可手填),有有效坐标行则直接走旧版「弹窗内提交」链路
class ExceptionDialog : public QDialog { class ExceptionDialog : public QDialog {
Q_OBJECT Q_OBJECT
public: public:
ExceptionDialog(geopro::data::IDatasetCommandRepository* repo, QString projectId, ExceptionDialog(geopro::data::IDatasetCommandRepository* repo, QString projectId,
QString remarkSourceId, QWidget* parent = nullptr); 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: private:
void onTypeChanged(); // 标注类型变 → 重拉异常类型列表 + 调整坐标表最少行 void onTypeChanged(); // 标注类型变 → 清名称 + 重拉异常类型列表 + 刷新「新增类型」可用性
void onAddType(); // 「新增异常类型」:打开 ExceptionTypeDialog(双 Tab) → addExceptionType → 刷新+选中
void loadExceptionTypes(); // listExceptionTypes(projectId, remarkSourceType) void loadExceptionTypes(); // listExceptionTypes(projectId, remarkSourceType)
void suggestName(); // getExceptionName(exceptionTypeId, remarkSourceId) → 名称建议 void suggestName(); // getExceptionName(exceptionTypeId, remarkSourceId) → 名称回填
void onConfirm(); // 校验 → newException void onConfirm(); // 校验 → 有手填坐标则直接 newException,否则 accept() 交给绘形
QString markTypeValue() const; // 当前标注类型字符串("1".."4") void updateAddTypeEnabled(); // 「新增异常类型」可用性:文字类型/未选类型时禁用(对照原版)
geopro::data::IDatasetCommandRepository* repo_ = nullptr; geopro::data::IDatasetCommandRepository* repo_ = nullptr;
QString projectId_; QString projectId_;
@ -42,6 +52,8 @@ private:
QComboBox* markTypeCombo_ = nullptr; // userData = "1".."4" QComboBox* markTypeCombo_ = nullptr; // userData = "1".."4"
QComboBox* exceptionTypeCombo_ = nullptr; // userData = 异常类型 id QComboBox* exceptionTypeCombo_ = nullptr; // userData = 异常类型 id
QPushButton* addTypeBtn_ = nullptr; // 新增异常类型(对照原版,文字/未选类型禁用)
QString pendingSelectTypeName_; // 新建类型后待选中的类型名(下一次列表刷新时匹配选中)
QLineEdit* nameEdit_ = nullptr; QLineEdit* nameEdit_ = nullptr;
QPlainTextEdit* remarkEdit_ = nullptr; QPlainTextEdit* remarkEdit_ = nullptr;
QTableWidget* coordTable_ = nullptr; QTableWidget* coordTable_ = nullptr;

View File

@ -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_);
// 不透明度0100%,默认 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

View File

@ -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) / 颜色 / 不透明度(0100%) / 内容(必填)。
// 确定 → accept(),调用方读取各字段组装 newException 的 customLegend。
// 时序对照原版:文字类型绘制落点后弹此对话框,提交带 customLegend
// {text, content, color, size, font(CSS族), opacity(01)}。
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; // 0100
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

View File

@ -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);
}
// 不透明度 0100 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改走 onSubmitaddDialogButtons 默认接的 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_NN 取当前最大序号 +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 01 不同,
// 这里沿用色块的 HexArgb 颜色串 + 0100 不透明度,与原版控件取值一致)。
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

View File

@ -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

View File

@ -3,7 +3,10 @@
#include <utility> #include <utility>
#include <QComboBox> #include <QComboBox>
#include <QFormLayout>
#include "EmptyAwareComboBox.hpp"
#include <QDialogButtonBox>
#include <QFrame>
#include <QHBoxLayout> #include <QHBoxLayout>
#include <QHeaderView> #include <QHeaderView>
#include <QHash> #include <QHash>
@ -20,14 +23,15 @@
#include <QTreeWidget> #include <QTreeWidget>
#include <QVBoxLayout> #include <QVBoxLayout>
#include "FormKit.hpp" #include "FormKit.hpp" // addDialogButtons / addSection / editLabel
#include "Theme.hpp"
#include "panels/chart/InversionProcessOps.hpp" // buildFilterApplyBody / buildNewFilterBody #include "panels/chart/InversionProcessOps.hpp" // buildFilterApplyBody / buildNewFilterBody
#include "repo/IDatasetCommandRepository.hpp" #include "repo/IDatasetCommandRepository.hpp"
namespace geopro::app { namespace geopro::app {
namespace { namespace {
constexpr double kFillRange = 1e9; constexpr int kDialogW = 900; // 原版弹窗宽 900px
constexpr int kMatrixMin = 1, kMatrixMax = 21; // 矩阵行列范围(对照原版 1~21 constexpr int kMatrixMin = 1, kMatrixMax = 21; // 矩阵行列范围(对照原版 1~21
constexpr int kDefaultDim = 3; constexpr int kDefaultDim = 3;
const char kDefaultCustomKey[] = "default-custom-filter"; // 默认自定义滤波器(不可删) const char kDefaultCustomKey[] = "default-custom-filter"; // 默认自定义滤波器(不可删)
@ -40,101 +44,60 @@ double cellValue(const QTableWidgetItem* it) {
const double v = it->text().toDouble(&ok); const double v = it->text().toDouble(&ok);
return ok ? v : 0.0; return ok ? v : 0.0;
} }
// 分组小标题:走 §7.0.10 唯一实现 formkit::addSectionheading 半粗 + 标题下 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 } // namespace
FilterDialog::FilterDialog(geopro::data::IDatasetCommandRepository* repo, QString dsId, FilterDialog::FilterDialog(geopro::data::IDatasetCommandRepository* repo, QString dsId,
QString projectId, QWidget* parent) QString projectId, QWidget* parent)
: QDialog(parent), repo_(repo), dsId_(std::move(dsId)), projectId_(std::move(projectId)) { : QDialog(parent), repo_(repo), dsId_(std::move(dsId)), projectId_(std::move(projectId)) {
setWindowTitle(QStringLiteral("滤波处理")); setWindowTitle(QStringLiteral("滤波设置")); // 原版 filterSetting
setModal(true); 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(); auto* body = new QHBoxLayout();
body->setSpacing(geopro::app::space::kLg);
root->addLayout(body, 1); root->addLayout(body, 1);
// ── 左:滤波器树 + 增删按钮 ───────────────────────────────────────── buildLeft(body);
auto* leftLay = new QVBoxLayout(); buildRight(body);
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);
// ── 右:配置面板 ──────────────────────────────────────────────────── // 规范 §7.5 底部操作栏:右对齐 取消(次) + 确认(主);「保存设置」为次按钮
auto* rightLay = new QVBoxLayout(); // 经 ActionRole 落在左侧QDialogButtonBox 自动把 ActionRole 排到主操作左边),不抢 primary。
auto* form = formkit::makeEditForm(); auto* box = formkit::addDialogButtons(root, this, QStringLiteral("确认"), QStringLiteral("取消"));
dataEdge_ = new QComboBox(this); okBtn_ = box->button(QDialogButtonBox::Ok);
dataEdge_->addItem(QStringLiteral("设为无效点"), QStringLiteral("whitening")); auto* saveSettingBtn = box->addButton(QStringLiteral("保存设置"), QDialogButtonBox::ActionRole);
dataEdge_->addItem(QStringLiteral("忽略"), QStringLiteral("skip")); // 确认需异步 applyFilter 成功才关闭 → 断开默认 accept改接 onConfirm。
dataEdge_->addItem(QStringLiteral("复制边缘点"), QStringLiteral("edgePoint")); QObject::disconnect(box, &QDialogButtonBox::accepted, this, &QDialog::accept);
dataEdge_->addItem(QStringLiteral("填充"), QStringLiteral("filling")); connect(okBtn_, &QPushButton::clicked, this, &FilterDialog::onConfirm);
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);
resizeMatrix(); // 默认 3x3 中心 1 resizeMatrix(); // 默认 3x3 中心 1
if (auto* c = matrix_->item(1, 1)) c->setText(QStringLiteral("1")); if (auto* c = matrix_->item(1, 1)) c->setText(QStringLiteral("1"));
connect(cancelBtn, &QPushButton::clicked, this, &QDialog::reject); connect(saveSettingBtn, &QPushButton::clicked, this, &FilterDialog::saveCustomFilter);
connect(okBtn_, &QPushButton::clicked, this, &FilterDialog::onConfirm);
connect(addBtn, &QPushButton::clicked, this, &FilterDialog::saveCustomFilter);
connect(delBtn, &QPushButton::clicked, this, &FilterDialog::deleteSelectedFilter);
connect(tree_, &QTreeWidget::itemSelectionChanged, this, connect(tree_, &QTreeWidget::itemSelectionChanged, this,
&FilterDialog::onTreeSelectionChanged); &FilterDialog::onTreeSelectionChanged);
// 行/列:仅奇数允许,偶数弹警告并回退旧值(对照原版 watch rows/cols
prevRows_ = kDefaultDim;
prevCols_ = kDefaultDim;
connect(rows_, QOverload<int>::of(&QSpinBox::valueChanged), this, 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, connect(cols_, QOverload<int>::of(&QSpinBox::valueChanged), this,
[this](int) { resizeMatrix(); }); [this](int v) { onDimChanged(cols_, v, prevCols_, QStringLiteral("")); });
// 数据边缘/无数据点:仅「填充」启用对应值输入框(对照原版 v-if=filling // 数据边缘/无数据点:仅「填充」启用对应值输入框(对照原版 v-if=filling
connect(dataEdge_, QOverload<int>::of(&QComboBox::currentIndexChanged), this, [this](int) { connect(dataEdge_, QOverload<int>::of(&QComboBox::currentIndexChanged), this, [this](int) {
dataEdgeValue_->setEnabled(dataEdge_->currentData().toString() == dataEdgeValue_->setEnabled(dataEdge_->currentData().toString() ==
@ -149,6 +112,123 @@ FilterDialog::FilterDialog(geopro::data::IDatasetCommandRepository* repo, QStrin
loadFilters(); 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() { void FilterDialog::loadFilters() {
if (!repo_) return; if (!repo_) return;
QPointer<FilterDialog> self(this); QPointer<FilterDialog> self(this);
@ -226,6 +306,12 @@ void FilterDialog::resizeMatrix() {
std::vector<std::vector<double>> old = readMatrix(); std::vector<std::vector<double>> old = readMatrix();
matrix_->setRowCount(r); matrix_->setRowCount(r);
matrix_->setColumnCount(c); 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 i = 0; i < r; ++i)
for (int j = 0; j < c; ++j) { for (int j = 0; j < c; ++j) {
auto* it = matrix_->item(i, j); auto* it = matrix_->item(i, j);
@ -272,8 +358,8 @@ void FilterDialog::saveCustomFilter() {
return; return;
} }
bool ok = false; bool ok = false;
const QString name = QInputDialog::getText(this, QStringLiteral("另存为新自定义滤波器"), const QString name = QInputDialog::getText(this, QStringLiteral("保存为新的自定义滤波器"),
QStringLiteral("名称"), QLineEdit::Normal, QStringLiteral("请输入滤波器名称"), QLineEdit::Normal,
QStringLiteral("自定义滤波器1"), &ok); QStringLiteral("自定义滤波器1"), &ok);
if (!ok || name.trimmed().isEmpty()) return; if (!ok || name.trimmed().isEmpty()) return;
QPointer<FilterDialog> self(this); QPointer<FilterDialog> self(this);

View File

@ -13,6 +13,8 @@ class QTreeWidget;
class QTreeWidgetItem; class QTreeWidgetItem;
class QTableWidget; class QTableWidget;
class QLineEdit; class QLineEdit;
class QHBoxLayout;
class QWidget;
namespace geopro::data { namespace geopro::data {
class IDatasetCommandRepository; class IDatasetCommandRepository;
@ -31,6 +33,11 @@ public:
QWidget* parent = nullptr); QWidget* parent = nullptr);
private: 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 loadFilters(); // 拉滤波器树
void buildTree(); // 由 flatItems_ 建树 void buildTree(); // 由 flatItems_ 建树
void onTreeSelectionChanged(); // 选中叶节点 → 右侧回填 void onTreeSelectionChanged(); // 选中叶节点 → 右侧回填
@ -56,6 +63,8 @@ private:
QSpinBox* filterTimes_ = nullptr; QSpinBox* filterTimes_ = nullptr;
QSpinBox* rows_ = nullptr; QSpinBox* rows_ = nullptr;
QSpinBox* cols_ = nullptr; QSpinBox* cols_ = nullptr;
int prevRows_ = 3; // 奇偶校验回退用(上一合法行数)
int prevCols_ = 3; // 上一合法列数
QTableWidget* matrix_ = nullptr; QTableWidget* matrix_ = nullptr;
QPushButton* okBtn_ = nullptr; QPushButton* okBtn_ = nullptr;

View File

@ -4,11 +4,14 @@
#include <QCheckBox> #include <QCheckBox>
#include <QCursor> #include <QCursor>
#include <QHash>
#include <QHBoxLayout> #include <QHBoxLayout>
#include <QJsonArray> #include <QJsonArray>
#include <QJsonObject> #include <QJsonObject>
#include <QMessageBox> #include <QMessageBox>
#include <QPointF>
#include <QPointer> #include <QPointer>
#include <vector>
#include <QSignalBlocker> #include <QSignalBlocker>
#include <QLabel> #include <QLabel>
#include <QSlider> #include <QSlider>
@ -33,10 +36,12 @@
#include "panels/chart/AutoAnnotationDialog.hpp" #include "panels/chart/AutoAnnotationDialog.hpp"
#include "panels/chart/ColorBarWidget.hpp" #include "panels/chart/ColorBarWidget.hpp"
#include "panels/chart/ColorMapService.hpp" #include "panels/chart/ColorMapService.hpp"
#include "panels/chart/ContourDrawTool.hpp"
#include "panels/chart/ContourHoverTip.hpp" #include "panels/chart/ContourHoverTip.hpp"
#include "panels/chart/ContourPlotItem.hpp" #include "panels/chart/ContourPlotItem.hpp"
#include "panels/chart/ExceptionDetailDialog.hpp" #include "panels/chart/ExceptionDetailDialog.hpp"
#include "panels/chart/ExceptionDialog.hpp" #include "panels/chart/ExceptionDialog.hpp"
#include "panels/chart/ExceptionTextDialog.hpp"
#include "panels/chart/FilterDialog.hpp" #include "panels/chart/FilterDialog.hpp"
#include "panels/chart/GridWizardDialog.hpp" #include "panels/chart/GridWizardDialog.hpp"
#include "panels/chart/LivePanner.hpp" #include "panels/chart/LivePanner.hpp"
@ -119,10 +124,11 @@ GridDataChartView::GridDataChartView(QWidget* parent) : QWidget(parent) {
tbLay->addWidget(lblSimplify); tbLay->addWidget(lblSimplify);
tbLay->addWidget(simplifySlider_); tbLay->addWidget(simplifySlider_);
tbLay->addWidget(simplifyValueLabel_); tbLay->addWidget(simplifyValueLabel_);
// 原版 .right-buttons margin-left:auto异常标注/自动标注/另存为 右对齐。
tbLay->addStretch();
tbLay->addWidget(btnAnomalyLabel); tbLay->addWidget(btnAnomalyLabel);
tbLay->addWidget(btnAutoLabel); tbLay->addWidget(btnAutoLabel);
tbLay->addWidget(btnSaveAs); tbLay->addWidget(btnSaveAs);
tbLay->addStretch();
lay->addWidget(toolbar); lay->addWidget(toolbar);
@ -217,13 +223,16 @@ GridDataChartView::GridDataChartView(QWidget* parent) : QWidget(parent) {
// 描述保存I14 // 描述保存I14
connect(descriptionPanel_, &DescriptionPanel::saveRequested, this, connect(descriptionPanel_, &DescriptionPanel::saveRequested, this,
[this](const QString& t) { saveDescription(t); }); [this]() { saveDescription(); });
// I7 显示等值线提示信息hover tooltip 显隐(本地,挂画布事件过滤器)。 // I7 显示等值线提示信息hover tooltip 显隐(本地,挂画布事件过滤器)。
contourTip_ = new ContourHoverTip(plot_, QwtPlot::xBottom, QwtPlot::yLeft, this); contourTip_ = new ContourHoverTip(plot_, QwtPlot::xBottom, QwtPlot::yLeft, this);
connect(chkContourTip, &QCheckBox::toggled, this, connect(chkContourTip, &QCheckBox::toggled, this,
[this](bool on) { if (contourTip_) contourTip_->setEnabled(on); }); [this](bool on) { if (contourTip_) contourTip_->setEnabled(on); });
// I9 图上绘形工具(后装于 LivePanner/hover绘制期优先消费事件。默认空闲。
drawTool_ = new ContourDrawTool(plot_, QwtPlot::xBottom, QwtPlot::yLeft, this);
// 主题配色:当前主题套一次 + 监听切换热更新。 // 主题配色:当前主题套一次 + 监听切换热更新。
applyChartPlotTheme(plot_); applyChartPlotTheme(plot_);
QObject::connect(&ThemeManager::instance(), &ThemeManager::changed, plot_, QObject::connect(&ThemeManager::instance(), &ThemeManager::changed, plot_,
@ -241,6 +250,7 @@ void GridDataChartView::setPayload(const QVariant& payload) {
return; return;
} }
const auto p = payload.value<geopro::core::ContourPayload>(); const auto p = payload.value<geopro::core::ContourPayload>();
lvlTemplateId_ = p.templateId; // 色阶模板 id保存/覆盖回带,对照原版 lvlTemplateId
setGridData(p.grid, p.scale, p.anomalies); setGridData(p.grid, p.scale, p.anomalies);
} }
@ -309,8 +319,9 @@ void GridDataChartView::openColorScaleEditor() {
// projectId 在打开时取一次(随项目切换生效);无 getter 退化为空 → 后端按钮禁用。 // projectId 在打开时取一次(随项目切换生效);无 getter 退化为空 → 后端按钮禁用。
const QString projectId = projectIdGetter_ ? projectIdGetter_() : QString(); const QString projectId = projectIdGetter_ ? projectIdGetter_() : QString();
// 传入网格色阶模板 idgetDetail type2 顶层 templateId→ 「另存为覆盖」可用(对照原版 lvlTemplateId
ColorScaleConfigDialog dlg(gridScale_, grid_.vmin, grid_.vmax, std::move(samples), lineCfg_, ColorScaleConfigDialog dlg(gridScale_, grid_.vmin, grid_.vmax, std::move(samples), lineCfg_,
tplRepo_, projectId, this); tplRepo_, projectId, lvlTemplateId_, this);
if (dlg.exec() != QDialog::Accepted) return; if (dlg.exec() != QDialog::Accepted) return;
gridScale_ = dlg.colorScale(); gridScale_ = dlg.colorScale();
@ -359,6 +370,7 @@ void GridDataChartView::reloadGrid() {
msg.isEmpty() ? QStringLiteral("重载失败") : msg); msg.isEmpty() ? QStringLiteral("重载失败") : msg);
return; return;
} }
self->lvlTemplateId_ = p.templateId; // 重载后同步模板 id色阶覆盖回带
self->setGridData(p.grid, p.scale, p.anomalies); self->setGridData(p.grid, p.scale, p.anomalies);
}); });
} }
@ -374,8 +386,10 @@ void GridDataChartView::openWhitening() {
const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString(); const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString();
const QString projectId = projectIdGetter_ ? projectIdGetter_() : QString(); const QString projectId = projectIdGetter_ ? projectIdGetter_() : QString();
if (!cmdRepo_ || dsId.isEmpty()) { showNotImplemented(nullptr); return; } if (!cmdRepo_ || dsId.isEmpty()) { showNotImplemented(nullptr); return; }
// tmObjectId白化模板列表用客户端视图未透传 structParentId按原版兜底空串。 // tmObjectId白化模板列表用= 当前数据集的 structParentId对照原版 dsFileRow.structParentId
WhiteningDialog dlg(cmdRepo_, dsId, projectId, QString(), this); // 经 open 链路从数据集列表行透传至此(注入的 tmObjectIdGetter_。空串也照常打开仅模板列表为空
const QString tmObjectId = tmObjectIdGetter_ ? tmObjectIdGetter_() : QString();
WhiteningDialog dlg(cmdRepo_, dsId, projectId, tmObjectId, this);
if (dlg.exec() == QDialog::Accepted) reloadGrid(); if (dlg.exec() == QDialog::Accepted) reloadGrid();
} }
@ -406,16 +420,90 @@ void GridDataChartView::openExceptionDialog() {
const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString(); const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString();
const QString projectId = projectIdGetter_ ? projectIdGetter_() : QString(); const QString projectId = projectIdGetter_ ? projectIdGetter_() : QString();
if (!cmdRepo_ || dsId.isEmpty()) { showNotImplemented(nullptr); return; } if (!cmdRepo_ || dsId.isEmpty()) { showNotImplemented(nullptr); return; }
// remarkSourceId = dsObjectId(异常挂当前等值面数据集)。 // 时序复刻原版:先弹窗选 标注类型/异常类型/名称/备注(remarkSourceId = dsObjectId
ExceptionDialog dlg(cmdRepo_, projectId, dsId, this); 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}, // 01
};
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() { void GridDataChartView::openAutoAnnotation() {
const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString(); const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString();
const QString projectId = projectIdGetter_ ? projectIdGetter_() : QString(); const QString projectId = projectIdGetter_ ? projectIdGetter_() : QString();
if (!cmdRepo_ || dsId.isEmpty()) { showNotImplemented(nullptr); return; } 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(); if (dlg.exec() == QDialog::Accepted) reloadGrid();
} }
@ -472,32 +560,33 @@ void GridDataChartView::loadDescription() {
QPointer<GridDataChartView> self(this); QPointer<GridDataChartView> self(this);
cmdRepo_->getDsObjectDetail(dsId, [self](bool ok, QJsonObject data, QString) { cmdRepo_->getDsObjectDetail(dsId, [self](bool ok, QJsonObject data, QString) {
if (!self || !ok) return; if (!self || !ok) return;
// 原版从 attachedParameters.deltaContent 取 Quill DeltaQt 退化为纯文本: // 原版从 attachedParameters.deltaContent 取 Quill Delta 回填编辑器quill.setContents
// 优先 description 字段,否则拼接 delta ops 的 insert 文本。 // 客户端用 QuillDelta::deltaToDocument 还原富文本;无 deltaContent 时回退 description 纯文本。
QString text = data.value(QStringLiteral("description")).toString();
if (text.isEmpty()) {
const QJsonArray ops = data.value(QStringLiteral("attachedParameters")) const QJsonArray ops = data.value(QStringLiteral("attachedParameters"))
.toObject() .toObject()
.value(QStringLiteral("deltaContent")) .value(QStringLiteral("deltaContent"))
.toArray(); .toArray();
for (const QJsonValue& op : ops) if (!ops.isEmpty())
text += op.toObject().value(QStringLiteral("insert")).toString(); self->descriptionPanel_->setDelta(ops);
} else
self->descriptionPanel_->setText(text); self->descriptionPanel_->setPlainText(
data.value(QStringLiteral("description")).toString());
}); });
} }
void GridDataChartView::saveDescription(const QString& text) { void GridDataChartView::saveDescription() {
const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString(); const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString();
if (!cmdRepo_ || dsId.isEmpty()) { showNotImplemented(nullptr); return; } if (!cmdRepo_ || dsId.isEmpty() || !descriptionPanel_) {
// attachedParameters.deltaContent以最简单 op 包纯文本reload 时可还原为纯文本)。 showNotImplemented(nullptr);
QJsonArray ops; return;
if (!text.isEmpty()) ops.append(QJsonObject{{QStringLiteral("insert"), text}}); }
// 与原版 saveQuillEditorContent 对齐:
// description = 纯文本quill.getText()deltaContent = Quill Delta opsquill.getContents().ops
QJsonObject body{ QJsonObject body{
{QStringLiteral("dsObjectId"), dsId}, {QStringLiteral("dsObjectId"), dsId},
{QStringLiteral("description"), text}, {QStringLiteral("description"), descriptionPanel_->plainText()},
{QStringLiteral("attachedParameters"), {QStringLiteral("attachedParameters"),
QJsonObject{{QStringLiteral("deltaContent"), ops}}}, QJsonObject{{QStringLiteral("deltaContent"), descriptionPanel_->delta()}}},
}; };
QPointer<GridDataChartView> self(this); QPointer<GridDataChartView> self(this);
cmdRepo_->updateDsObject(body, [self](bool ok, QString msg) { cmdRepo_->updateDsObject(body, [self](bool ok, QString msg) {

View File

@ -1,10 +1,14 @@
#pragma once #pragma once
#include <functional> #include <functional>
#include <utility>
#include <vector> #include <vector>
#include <QJsonObject> // submitDrawnException 默认参数 const QJsonObject& = {} 需完整类型
#include <QString> #include <QString>
#include <QWidget> #include <QWidget>
class QJsonArray;
#include "model/Anomaly.hpp" #include "model/Anomaly.hpp"
#include "model/ColorScale.hpp" #include "model/ColorScale.hpp"
#include "model/Field.hpp" #include "model/Field.hpp"
@ -32,6 +36,7 @@ class ColorBarWidget;
class ColorMapService; class ColorMapService;
class ContourPlotItem; class ContourPlotItem;
class ContourHoverTip; class ContourHoverTip;
class ContourDrawTool;
// 网格数据图表视图:工具条 + QwtPlot白底 + 真实比尺 + 实时平移/滚轮缩放x 轴在底部) // 网格数据图表视图:工具条 + QwtPlot白底 + 真实比尺 + 实时平移/滚轮缩放x 轴在底部)
// + 独立色阶条 + 底部双页签(异常列表/描述)。 // + 独立色阶条 + 底部双页签(异常列表/描述)。
@ -61,6 +66,11 @@ public:
std::function<QString()> dsIdGetter, std::function<QString()> dsIdGetter,
std::function<QString()> projectIdGetter); std::function<QString()> projectIdGetter);
// 注入 tmObjectId 取值回调(= 数据集 structParentId。白化对话框模板列表用空 → 模板列表为空。
void setTmObjectIdGetter(std::function<QString()> tmObjectIdGetter) {
tmObjectIdGetter_ = std::move(tmObjectIdGetter);
}
private: private:
void rebuildContour(); // 按当前显隐开关重建并重绘 ContourPlotItem void rebuildContour(); // 按当前显隐开关重建并重绘 ContourPlotItem
void openColorScaleEditor(); // 「色阶配置」→ 共享色阶编辑器(色阶 + 层级⚙ + 线形⚙) void openColorScaleEditor(); // 「色阶配置」→ 共享色阶编辑器(色阶 + 层级⚙ + 线形⚙)
@ -72,13 +82,18 @@ private:
void applySimplify(); // I8把当前滑块容差透传给 ContourPlotItem 并重绘 void applySimplify(); // I8把当前滑块容差透传给 ContourPlotItem 并重绘
void showNotImplemented(QWidget* anchor); // 占位提示(无仓储/无 dsId 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 openAutoAnnotation(); // I13 自动标注
void deleteAnomaly(int index); // I10 异常删除 void deleteAnomaly(int index); // I10 异常删除
void showAnomalyDetail(int index); // I11 异常详情/编辑 void showAnomalyDetail(int index); // I11 异常详情/编辑
void locateAnomaly(int index); // I12 异常定位(高亮 + 缩放) void locateAnomaly(int index); // I12 异常定位(高亮 + 缩放)
void loadDescription(); // I14 进入时回填描述 void loadDescription(); // I14 进入时回填描述
void saveDescription(const QString& text); // I14 保存描述 void saveDescription(); // I14 保存描述(从面板取 Delta + 纯文本)
QwtPlot* plot_ = nullptr; QwtPlot* plot_ = nullptr;
QwtPlotRescaler* rescaler_ = nullptr; QwtPlotRescaler* rescaler_ = nullptr;
@ -90,6 +105,7 @@ private:
QCheckBox* chkShowLabels_ = nullptr; // 工具条「显示等值线标注」(线形⚙ 改标注显隐后同步) QCheckBox* chkShowLabels_ = nullptr; // 工具条「显示等值线标注」(线形⚙ 改标注显隐后同步)
QTimer* simplifyDebounce_ = nullptr; // I8 简化容差防抖(~300ms QTimer* simplifyDebounce_ = nullptr; // I8 简化容差防抖(~300ms
ContourHoverTip* contourTip_ = nullptr; // I7 等值线提示hover ContourHoverTip* contourTip_ = nullptr; // I7 等值线提示hover
ContourDrawTool* drawTool_ = nullptr; // I9 图上绘形工具QObjectthis 持有)
// 渲染状态 // 渲染状态
ColorMapService* colorSvc_ = nullptr; // heapsetGridData 重建 ColorMapService* colorSvc_ = nullptr; // heapsetGridData 重建
@ -97,6 +113,7 @@ private:
geopro::core::Grid grid_{1, 1}; geopro::core::Grid grid_{1, 1};
geopro::core::ColorScale gridScale_; geopro::core::ColorScale gridScale_;
std::vector<geopro::core::Anomaly> anoms_; std::vector<geopro::core::Anomaly> anoms_;
QString lvlTemplateId_; // 网格色阶模板 idgetDetail type2 顶层 templateId色阶「另存为覆盖」用
bool hasGrid_ = false; bool hasGrid_ = false;
// 工具条显隐开关 // 工具条显隐开关
@ -111,6 +128,9 @@ private:
// 反演命令仓储 + dsId 取值回调(注入;空则处理类按钮占位提示)。 // 反演命令仓储 + dsId 取值回调(注入;空则处理类按钮占位提示)。
geopro::data::IDatasetCommandRepository* cmdRepo_ = nullptr; geopro::data::IDatasetCommandRepository* cmdRepo_ = nullptr;
std::function<QString()> dsIdGetter_; std::function<QString()> dsIdGetter_;
// tmObjectId 取值回调(= 数据集 structParentId。白化对话框模板列表用空 → 模板列表为空。
std::function<QString()> tmObjectIdGetter_;
}; };
} // namespace geopro::app } // namespace geopro::app

View File

@ -1,10 +1,13 @@
#include "panels/chart/GridWizardDialog.hpp" #include "panels/chart/GridWizardDialog.hpp"
#include <cmath>
#include <utility> #include <utility>
#include <QComboBox> #include <QComboBox>
#include "EmptyAwareComboBox.hpp"
#include <QDoubleSpinBox> #include <QDoubleSpinBox>
#include <QFormLayout> #include <QGridLayout>
#include <QHBoxLayout> #include <QHBoxLayout>
#include <QLabel> #include <QLabel>
#include <QListWidget> #include <QListWidget>
@ -15,7 +18,8 @@
#include <QStackedWidget> #include <QStackedWidget>
#include <QVBoxLayout> #include <QVBoxLayout>
#include "FormKit.hpp" #include "FormKit.hpp" // addSection / editLabel
#include "Theme.hpp"
#include "panels/chart/InversionProcessOps.hpp" // buildGridToBody #include "panels/chart/InversionProcessOps.hpp" // buildGridToBody
#include "repo/IDatasetCommandRepository.hpp" #include "repo/IDatasetCommandRepository.hpp"
@ -25,87 +29,69 @@ namespace {
constexpr double kCoordRange = 1e9; // 坐标范围(足够宽) constexpr double kCoordRange = 1e9; // 坐标范围(足够宽)
constexpr int kSizeMin = 1, kSizeMax = 300; // 点数范围(对照原版 1~300 constexpr int kSizeMin = 1, kSizeMax = 300; // 点数范围(对照原版 1~300
constexpr int kDefaultXSize = 100; // 默认点数(原版 xPoints 默认 100 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
// 配一个坐标 spinbox6 位小数,宽范围)。 // 配一个坐标 spinbox6 位小数,宽范围)。
QDoubleSpinBox* makeCoordSpin(QWidget* parent) { QDoubleSpinBox* makeCoordSpin(QWidget* parent) {
auto* sp = new QDoubleSpinBox(parent); auto* sp = new QDoubleSpinBox(parent);
sp->setRange(-kCoordRange, kCoordRange); sp->setRange(-kCoordRange, kCoordRange);
sp->setDecimals(6); sp->setDecimals(6);
sp->setFixedWidth(kParamFieldW);
return sp; return sp;
} }
// 分组标题:走 §7.0.10 唯一实现 formkit::addSectionheading 半粗 + 标题下 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 } // namespace
GridWizardDialog::GridWizardDialog(geopro::data::IDatasetCommandRepository* repo, QString dsId, GridWizardDialog::GridWizardDialog(geopro::data::IDatasetCommandRepository* repo, QString dsId,
QWidget* parent) QWidget* parent)
: QDialog(parent), repo_(repo), dsId_(std::move(dsId)) { : QDialog(parent), repo_(repo), dsId_(std::move(dsId)) {
setWindowTitle(QStringLiteral("网格化")); setWindowTitle(QStringLiteral("网格配置")); // 原版 gridSetting
setModal(true); 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); stack_ = new QStackedWidget(this);
root->addWidget(stack_, 1); root->addWidget(stack_, 1);
// ── 步骤 1算法选择单选列表────────────────────────────────────── buildStep1();
auto* page1 = new QWidget(this); buildStep2();
auto* p1Lay = new QVBoxLayout(page1);
p1Lay->addWidget(new QLabel(QStringLiteral("请选择网格方法:"), page1));
algoList_ = new QListWidget(page1);
p1Lay->addWidget(algoList_, 1);
stack_->addWidget(page1);
// ── 步骤 2网格参数 + 数据值设置 ──────────────────────────────────── // ── 底部操作栏(规范 §7.5 右对齐):上一步(次按钮) 左;取消(次) + 下一步/确认(主) 右。──
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);
// ── 底部按钮(上一步 / 下一步 / 确认 / 取消)────────────────────────
auto* btnLay = new QHBoxLayout(); auto* btnLay = new QHBoxLayout();
btnLay->addStretch(); btnLay->setSpacing(geopro::app::space::kMd);
prevBtn_ = new QPushButton(QStringLiteral("上一步"), this); prevBtn_ = new QPushButton(QStringLiteral("上一步"), this); // 次按钮(描边),左侧
auto* cancelBtn = new QPushButton(QStringLiteral("取消"), this);
nextBtn_ = new QPushButton(QStringLiteral("下一步"), this); nextBtn_ = new QPushButton(QStringLiteral("下一步"), this);
okBtn_ = 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->addWidget(prevBtn_);
btnLay->addStretch();
btnLay->addWidget(cancelBtn);
btnLay->addWidget(nextBtn_); btnLay->addWidget(nextBtn_);
btnLay->addWidget(okBtn_); btnLay->addWidget(okBtn_);
btnLay->addWidget(cancelBtn);
root->addLayout(btnLay); root->addLayout(btnLay);
prevBtn_->setVisible(false); prevBtn_->setVisible(false);
okBtn_->setVisible(false); okBtn_->setVisible(false);
@ -114,18 +100,108 @@ GridWizardDialog::GridWizardDialog(geopro::data::IDatasetCommandRepository* repo
connect(nextBtn_, &QPushButton::clicked, this, &GridWizardDialog::goToStep2); connect(nextBtn_, &QPushButton::clicked, this, &GridWizardDialog::goToStep2);
connect(prevBtn_, &QPushButton::clicked, this, [this]() { connect(prevBtn_, &QPushButton::clicked, this, [this]() {
stack_->setCurrentIndex(0); stack_->setCurrentIndex(0);
setFixedWidth(kStep1W);
prevBtn_->setVisible(false); okBtn_->setVisible(false); nextBtn_->setVisible(true); prevBtn_->setVisible(false); okBtn_->setVisible(false); nextBtn_->setVisible(true);
}); });
connect(okBtn_, &QPushButton::clicked, this, &GridWizardDialog::onConfirm); 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(); 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() { void GridWizardDialog::loadAlgorithms() {
if (!repo_) return; if (!repo_) return;
QPointer<GridWizardDialog> self(this); QPointer<GridWizardDialog> self(this);
@ -149,6 +225,7 @@ void GridWizardDialog::goToStep2() {
return; return;
} }
stack_->setCurrentIndex(1); stack_->setCurrentIndex(1);
setFixedWidth(kStep2W);
nextBtn_->setVisible(false); nextBtn_->setVisible(false);
prevBtn_->setVisible(true); prevBtn_->setVisible(true);
okBtn_->setVisible(true); okBtn_->setVisible(true);
@ -166,28 +243,63 @@ void GridWizardDialog::loadParams() {
self->yMax_->setValue(d.value(QStringLiteral("ymax")).toDouble()); self->yMax_->setValue(d.value(QStringLiteral("ymax")).toDouble());
self->vMin_->setValue(d.value(QStringLiteral("vmin")).toDouble()); self->vMin_->setValue(d.value(QStringLiteral("vmin")).toDouble());
self->vMax_->setValue(d.value(QStringLiteral("vmax")).toDouble()); self->vMax_->setValue(d.value(QStringLiteral("vmax")).toDouble());
// 初始间距 = (xmax-xmin)/xSizey 间距同 x原版 onMounted 逻辑)。 // 原版 onMountedX点数固定 100 → 算 X间距 → Y间距=X间距 → Y点数=ceil。
self->recalcXSpacing(); self->xSize_->setValue(kDefaultXSize);
self->calcXInterval();
const double dx = self->xSpacing_->value(); 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(); 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(); 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() { void GridWizardDialog::onConfirm() {
if (!repo_ || !algoList_->currentItem()) { reject(); return; } if (!repo_ || !algoList_->currentItem()) { reject(); return; }
if (xMax_->value() < xMin_->value() || yMax_->value() < yMin_->value() || // 原版按 Xmax/Ymax/数据值分别提示。
vMax_->value() < vMin_->value()) { if (xMax_->value() < xMin_->value()) {
QMessageBox::warning(this, windowTitle(), QStringLiteral("最大值不能小于最小值")); 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; return;
} }
GridToParams p; GridToParams p;

View File

@ -27,11 +27,15 @@ public:
QWidget* parent = nullptr); QWidget* parent = nullptr);
private: private:
void buildStep1(); // 步骤 1算法选择列表
void buildStep2(); // 步骤 2网格参数 + 数据值设置(两分组卡片)
void loadAlgorithms(); // 步骤 1拉算法列表 void loadAlgorithms(); // 步骤 1拉算法列表
void loadParams(); // 步骤 2拉 x/y/v 默认参数 void loadParams(); // 步骤 2拉 x/y/v 默认参数(兼「恢复默认值」)
void goToStep2(); // 下一步(校验算法已选) void goToStep2(); // 下一步(校验算法已选)
void recalcXSpacing(); // (xMax-xMin)/xSize → xSpacing间距已有值时 void calcXInterval(); // Xmax/Xmin/X点数变 → X间距=(xMax-xMin)/xSize
void recalcYSpacing(); // (yMax-yMin)/ySize → ySpacing 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 void onConfirm(); // 确认 → toGrid
geopro::data::IDatasetCommandRepository* repo_ = nullptr; geopro::data::IDatasetCommandRepository* repo_ = nullptr;

View File

@ -3,22 +3,21 @@
#include <utility> #include <utility>
#include <QComboBox> #include <QComboBox>
#include <QFrame>
#include <QGridLayout>
#include <QHBoxLayout> #include <QHBoxLayout>
#include <QMessageBox> #include <QMessageBox>
#include <QPointer> #include <QPointer>
#include <QPushButton> #include <QPushButton>
#include <QScrollArea>
#include <QSignalBlocker> #include <QSignalBlocker>
#include <QVBoxLayout> #include <QVBoxLayout>
#include "FormKit.hpp" #include "FormKit.hpp"
#include "dto/NavDto.hpp" // parseEditableForm与对象/结构编辑共用的动态表单解析)
#include "panels/DynamicFormEditor.hpp"
#include "repo/IDatasetCommandRepository.hpp" #include "repo/IDatasetCommandRepository.hpp"
namespace geopro::app { namespace geopro::app {
// 纯解析/组装函数定义在 InversionFormParse.cppQt-Core-only便于单测
namespace { namespace {
constexpr char kVisualResistivityCode[] = "script_visual_resistivity_data"; constexpr char kVisualResistivityCode[] = "script_visual_resistivity_data";
} // namespace } // namespace
@ -40,16 +39,22 @@ InversionFormDialog::InversionFormDialog(Mode mode, geopro::data::IDatasetComman
// 模型选择行label + 下拉)。生成视电阻率下拉禁用(复刻原版 disabled // 模型选择行label + 下拉)。生成视电阻率下拉禁用(复刻原版 disabled
auto* modelLay = new QVBoxLayout(); auto* modelLay = new QVBoxLayout();
modelLay->addWidget(formkit::editLabel(QStringLiteral("反演模型"), this)); 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); if (mode_ == Mode::ApparentResistivity) modelCombo_->setEnabled(false);
modelLay->addWidget(modelCombo_); modelLay->addWidget(modelCombo_);
root->addLayout(modelLay); root->addLayout(modelLay);
// 动态字段容器(按 groups_ 重建)。 // 动态字段容器:复用 DynamicFormEditor按 displayComponentType 渲染 11 种控件 + 必填校验)。
formHost_ = new QWidget(this); // 放滚动区内,避免字段多时撑爆对话框。
formHostLay_ = new QVBoxLayout(formHost_); auto* scroll = new QScrollArea(this);
formHostLay_->setContentsMargins(0, 0, 0, 0); scroll->setWidgetResizable(true);
root->addWidget(formHost_, 1); scroll->setFrameShape(QFrame::NoFrame);
editor_ = new DynamicFormEditor();
scroll->setWidget(editor_);
root->addWidget(scroll, 1);
// 底部按钮(取消 / 确定)。 // 底部按钮(取消 / 确定)。
auto* btnLay = new QHBoxLayout(); auto* btnLay = new QHBoxLayout();
@ -79,8 +84,7 @@ void InversionFormDialog::loadScripts() {
if (!ok) return; if (!ok) return;
QSignalBlocker block(self->modelCombo_); // 填充期不触发 onModelChanged QSignalBlocker block(self->modelCombo_); // 填充期不触发 onModelChanged
self->modelCombo_->clear(); self->modelCombo_->clear();
// 反演运算:首项为空(对应原版 allow-clear无默认选中 // 反演运算:不再插空首项——占位文案 + currentIndex=-1 即「无默认选中」(对应原版 allow-clear
if (mode == Mode::Inversion) self->modelCombo_->addItem(QString(), QString());
int defaultIdx = -1; int defaultIdx = -1;
for (const QJsonValue& v : list) { for (const QJsonValue& v : list) {
const QJsonObject o = v.toObject(); const QJsonObject o = v.toObject();
@ -103,9 +107,8 @@ void InversionFormDialog::loadScripts() {
void InversionFormDialog::onModelChanged() { void InversionFormDialog::onModelChanged() {
const QString typeId = modelCombo_->currentData().toString(); const QString typeId = modelCombo_->currentData().toString();
groups_.clear();
if (typeId.isEmpty()) { if (typeId.isEmpty()) {
rebuildFormArea(); // 清空表单(复刻 changeModel: 清空 dynamicForms editor_->clear(); // 清空表单(复刻 changeModel: 清空 dynamicForms
return; return;
} }
loadDynamicForm(typeId); loadDynamicForm(typeId);
@ -117,50 +120,12 @@ void InversionFormDialog::loadDynamicForm(const QString& typeId) {
repo_->getDynamicForm(projectId_, typeId, [self](bool ok, QJsonObject data, QString) { repo_->getDynamicForm(projectId_, typeId, [self](bool ok, QJsonObject data, QString) {
if (!self) return; if (!self) return;
if (!ok) return; if (!ok) return;
self->groups_ = parseDynamicForm(data); // 复用 parseEditableFormformList → values → displayComponentType/requiredType/optionsObject
self->rebuildFormArea(); // + DynamicFormEditor11 种控件渲染。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() { void InversionFormDialog::onConfirm() {
if (!repo_) { reject(); return; } if (!repo_) { reject(); return; }
const QString scriptId = modelCombo_->currentData().toString(); const QString scriptId = modelCombo_->currentData().toString();
@ -169,14 +134,24 @@ void InversionFormDialog::onConfirm() {
return; return;
} }
// 由当前各字段下拉选值装配 {fieldCode: value}。 // 必填校验requiredType===1拦截提交并聚焦首个缺失字段对照原版 a-form rules
QJsonObject selected; QString missing;
for (size_t i = 0; i < fieldCombos_.size(); ++i) { if (!editor_->validateRequired(&missing)) {
selected.insert(fieldCodes_[static_cast<int>(i)], editor_->focusFirstInvalid();
fieldCombos_[i]->currentData().toString()); 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); okBtn_->setEnabled(false);
QPointer<InversionFormDialog> self(this); QPointer<InversionFormDialog> self(this);

View File

@ -1,14 +1,9 @@
#pragma once #pragma once
#include <vector>
#include <QDialog> #include <QDialog>
#include <QJsonObject> #include <QJsonObject>
#include <QString> #include <QString>
#include "panels/chart/InversionFormParse.hpp" // InversionGroup + 纯解析/组装函数
class QComboBox; class QComboBox;
class QVBoxLayout;
class QPushButton; class QPushButton;
class QWidget; class QWidget;
@ -18,14 +13,18 @@ class IDatasetCommandRepository;
namespace geopro::app { namespace geopro::app {
class DynamicFormEditor;
// 反演动态表单对话框1:1 复刻原版 web。一套对话框服务两个入口用 Mode 区分: // 反演动态表单对话框1:1 复刻原版 web。一套对话框服务两个入口用 Mode 区分:
// - Inversion → measurement「反演运算」原版 InversionForm.vue + postInversionTask // - Inversion → measurement「反演运算」原版 InversionForm.vue + postInversionTask
// - ApparentResistivity → measurement「生成视电阻率」原版 InversionDialog.vue + createVisualResistivityData // - ApparentResistivity → measurement「生成视电阻率」原版 InversionDialog.vue + createVisualResistivityData
// 共同流程:① 拉模型列表 → ② 选模型 → ③ 按 typeId 拉动态表单 → ④ 分组卡片渲染字段 → ⑤ 提交。 // 共同流程:① 拉模型列表 → ② 选模型 → ③ 按 typeId 拉动态表单 → ④ 分组卡片渲染字段 → ⑤ 提交。
// 动态字段渲染复用项目内 DynamicFormEditor与对象/结构编辑同一套控件),按 displayComponentType
// 渲染 11 种控件并做 requiredType===1 必填校验、requiredType===2 只读禁用(对照原版 FormItem.vue
// 差异(严格对照原版): // 差异(严格对照原版):
// Inversion模型下拉可选(allow-clear)、无默认选中、无字段默认值;提交体 {dsId,scriptId,properties}。 // Inversion模型下拉可选(allow-clear)、无默认选中;提交体 {dsId,scriptId,properties}。
// ApparentResistivity模型下拉禁用、默认选中 code=='script_visual_resistivity_data' // ApparentResistivity模型下拉禁用、默认选中 code=='script_visual_resistivity_data'
// 字段默认取首个选项;提交体 {dsObjectId,scriptId,scriptParamListJsonStr}。 // 提交体 {dsObjectId,scriptId,scriptParamListJsonStr}。
// 回调用 QPointer 守卫(对话框 modal exec但异步回调仍可能在关闭后到达 // 回调用 QPointer 守卫(对话框 modal exec但异步回调仍可能在关闭后到达
class InversionFormDialog : public QDialog { class InversionFormDialog : public QDialog {
Q_OBJECT Q_OBJECT
@ -42,7 +41,6 @@ private:
void loadScripts(); // 拉模型列表填下拉 void loadScripts(); // 拉模型列表填下拉
void onModelChanged(); // 模型变更 → 拉动态表单 void onModelChanged(); // 模型变更 → 拉动态表单
void loadDynamicForm(const QString& typeId); void loadDynamicForm(const QString& typeId);
void rebuildFormArea(); // 按 groups_ 重建分组卡片
void onConfirm(); // 提交(按 mode 走不同端点) void onConfirm(); // 提交(按 mode 走不同端点)
Mode mode_; Mode mode_;
@ -51,13 +49,8 @@ private:
QString projectId_; QString projectId_;
QComboBox* modelCombo_ = nullptr; QComboBox* modelCombo_ = nullptr;
QWidget* formHost_ = nullptr; // 动态字段容器(重建时清空重填) DynamicFormEditor* editor_ = nullptr; // 动态字段渲染/收集/必填校验(项目内复用)
QVBoxLayout* formHostLay_ = nullptr;
QPushButton* okBtn_ = nullptr; QPushButton* okBtn_ = nullptr;
std::vector<InversionGroup> groups_; // 当前模型的动态表单(已解析)
std::vector<QComboBox*> fieldCombos_; // 与 groups_ 展平后的字段同序(取值用)
std::vector<QString> fieldCodes_; // 与 fieldCombos_ 同序的 fieldCode
}; };
} // namespace geopro::app } // namespace geopro::app

View File

@ -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

View File

@ -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; // optionsObjectSelect 选项)
};
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

View File

@ -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

View File

@ -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

View File

@ -8,6 +8,7 @@
#include "panels/chart/ScatterDataOps.hpp" #include "panels/chart/ScatterDataOps.hpp"
#include "panels/chart/ScatterFilterDialog.hpp" #include "panels/chart/ScatterFilterDialog.hpp"
#include "panels/chart/ScatterHoverTip.hpp" #include "panels/chart/ScatterHoverTip.hpp"
#include "panels/chart/ScatterMarqueePicker.hpp"
#include "panels/chart/ScatterPlotItem.hpp" #include "panels/chart/ScatterPlotItem.hpp"
#include <utility> #include <utility>
@ -16,6 +17,8 @@
#include <QByteArray> #include <QByteArray>
#include <QComboBox> #include <QComboBox>
#include "EmptyAwareComboBox.hpp"
#include <QCursor> #include <QCursor>
#include <QEvent> #include <QEvent>
#include <QFileDialog> #include <QFileDialog>
@ -52,6 +55,7 @@
#include "panels/chart/LivePanner.hpp" #include "panels/chart/LivePanner.hpp"
#include "Theme.hpp" #include "Theme.hpp"
#include "ToastOverlay.hpp" // showToast统一成功轻提示规范 §7.7
namespace geopro::app { namespace geopro::app {
@ -76,7 +80,7 @@ RawDataChartView::RawDataChartView(QWidget* parent) : QWidget(parent) {
auto* lblCurrentChart = new QLabel(QStringLiteral("当前图形:"), toolbar); auto* lblCurrentChart = new QLabel(QStringLiteral("当前图形:"), toolbar);
chartTypeCombo_ = new QComboBox(toolbar); chartTypeCombo_ = new EmptyAwareComboBox(toolbar);
chartTypeCombo_->addItem(QStringLiteral("散点图")); chartTypeCombo_->addItem(QStringLiteral("散点图"));
auto* btnSaveAs = new QToolButton(toolbar); auto* btnSaveAs = new QToolButton(toolbar);
@ -86,8 +90,9 @@ RawDataChartView::RawDataChartView(QWidget* parent) : QWidget(parent) {
tbLay->addWidget(btnColorScale); tbLay->addWidget(btnColorScale);
tbLay->addWidget(lblCurrentChart); tbLay->addWidget(lblCurrentChart);
tbLay->addWidget(chartTypeCombo_); tbLay->addWidget(chartTypeCombo_);
tbLay->addWidget(btnSaveAs); // 原版 .right-buttons margin-left:auto另存为 右对齐。
tbLay->addStretch(); tbLay->addStretch();
tbLay->addWidget(btnSaveAs);
// 反演原数据默认工具条交互O1 网格 / O2 色阶配置 / O3 另存为)。 // 反演原数据默认工具条交互O1 网格 / O2 色阶配置 / O3 另存为)。
connect(btnGrid, &QToolButton::clicked, this, [this, btnGrid]() { openGridWizard(btnGrid); }); 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_ = new ScatterHoverTip(plot_, QwtPlot::xTop, QwtPlot::yLeft, this);
hoverTip_->setField(&data_.scatter); 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); 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 int kToolIconPx = 16; // 逻辑图标边长(与 setIconSize 对齐)
constexpr qreal kToolIconScale = 2.0; // 超采样倍率HiDPI 清晰) 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) { QPixmap makeToolIconCanvas(QPainter& p) {
// 调用方在 [0,kToolIconPx] 逻辑坐标系下作画;返回前缩放 + 设 dpr。 // 调用方在 [0,kToolIconPx] 逻辑坐标系下作画;返回前缩放 + 设 dpr。
const int dim = qRound(kToolIconPx * kToolIconScale); const int dim = qRound(kToolIconPx * kToolIconScale);
@ -338,6 +354,10 @@ void RawDataChartView::setCommandRepo(geopro::data::IDatasetCommandRepository* r
projectIdGetter_ = std::move(projectIdGetter); projectIdGetter_ = std::move(projectIdGetter);
} }
void RawDataChartView::setColorTemplateRepo(geopro::data::IColorTemplateRepository* repo) {
colorTplRepo_ = repo;
}
void RawDataChartView::openInversionDialog(bool apparentResistivity, QWidget* anchor) { void RawDataChartView::openInversionDialog(bool apparentResistivity, QWidget* anchor) {
// 无仓储/无 dsId 取值回调 → 退化占位(与未注入时一致)。 // 无仓储/无 dsId 取值回调 → 退化占位(与未注入时一致)。
const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString(); const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString();
@ -374,7 +394,10 @@ void RawDataChartView::openInversionColorScale(QWidget* anchor) {
} }
if (vMin > vMax) { vMin = 0.0; vMax = 1.0; } if (vMin > vMax) { vMin = 0.0; vMax = 1.0; }
std::vector<double> samples = data_.scatter.v; 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; if (dlg.exec() != QDialog::Accepted) return;
// 本地重建上色重绘。 // 本地重建上色重绘。
@ -383,6 +406,7 @@ void RawDataChartView::openInversionColorScale(QWidget* anchor) {
colorSvc_ = new ColorMapService(data_.scale); colorSvc_ = new ColorMapService(data_.scale);
redrawScatter(); redrawScatter();
colorBar_->setColorScale(data_.scale); colorBar_->setColorScale(data_.scale);
showToast(this, QStringLiteral("色阶应用成功")); // M8 成功提示(对照原版 Message.success
// 持久化businessCode 空,对照原版 originPage newLvlColorLevel businessCode:'')。 // 持久化businessCode 空,对照原版 originPage newLvlColorLevel businessCode:'')。
const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString(); const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString();
@ -390,6 +414,7 @@ void RawDataChartView::openInversionColorScale(QWidget* anchor) {
if (!cmdRepo_ || dsId.isEmpty()) return; if (!cmdRepo_ || dsId.isEmpty()) return;
QJsonObject body{ QJsonObject body{
{QStringLiteral("dsObjectId"), dsId}, {QStringLiteral("dsObjectId"), dsId},
{QStringLiteral("templateId"), data_.templateId}, // 读取到的色阶模板 id对照原版可空
{QStringLiteral("businessCode"), QString()}, {QStringLiteral("businessCode"), QString()},
{QStringLiteral("projectId"), projectId}, {QStringLiteral("projectId"), projectId},
{QStringLiteral("properties"), buildColorScaleProperties(data_.scale, dlg.lineConfig())}, {QStringLiteral("properties"), buildColorScaleProperties(data_.scale, dlg.lineConfig())},
@ -443,31 +468,55 @@ void RawDataChartView::onShowHide(bool hide) {
QMessageBox::Ok | QMessageBox::Cancel); QMessageBox::Ok | QMessageBox::Cancel);
if (ans != QMessageBox::Ok) return; if (ans != QMessageBox::Ok) return;
// 本地切换:显示/隐藏全部数据方块(电极保留)。 // 选区联动M14↔M1隐藏且有选区 → 只对选中点(原版 getSelectedPointIds
auto localToggle = [this, hide]() { // 其余(隐藏无选区 / 显示)维持全部(原版显示恒为全部隐藏点)。
const bool selective = hide && scatterItem_ && scatterItem_->hasSelection();
// 本地切换可见性。selective逐点改 displayStatus仅选中点隐藏否则整体显隐全部方块。
auto localToggle = [this, hide, selective]() {
if (!scatterItem_) return; if (!scatterItem_) return;
if (selective) {
scatterItem_->setData(data_.scatter, colorSvc_); // 重读 displayStatus 逐点生效
scatterItem_->clearSelection();
} else {
for (int& s : data_.scatter.displayStatus) s = hide ? 1 : 0;
scatterItem_->setScatterVisible(!hide); scatterItem_->setScatterVisible(!hide);
if (!hide) scatterItem_->setData(data_.scatter, colorSvc_); // 显示全部:清逐点隐藏
}
plot_->replot(); plot_->replot();
}; };
// 收集要持久化的点 idselective → 选中点 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 → 仅本地切换(退化,不持久化)。 // 无仓储/无 dsId → 仅本地切换(退化,不持久化)。
const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString(); const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString();
if (!cmdRepo_ || dsId.isEmpty()) { localToggle(); return; } if (!cmdRepo_ || dsId.isEmpty()) { localToggle(); return; }
// 收集要持久化的点 id隐藏取可见点 / 显示取隐藏点status0=显示 1=隐藏。
const QJsonArray ids = collectScatterIds(data_.scatter, hide);
const int status = hide ? 1 : 0; const int status = hide ? 1 : 0;
QPointer<RawDataChartView> self(this); 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 (!self) return;
if (!ok) { if (!ok) {
QMessageBox::warning(self, QStringLiteral("提示"), QMessageBox::warning(self, QStringLiteral("提示"),
msg.isEmpty() ? QStringLiteral("操作失败") : msg); msg.isEmpty() ? QStringLiteral("操作失败") : msg);
return; return;
} }
// 持久化成功后同步本地 displayStatus 与方块可见性 // selective 时本地 displayStatus 已在请求前更新;非 selective 同步整体状态
const int newStatus = hide ? 1 : 0; if (!selective)
for (int& s : self->data_.scatter.displayStatus) s = newStatus; for (int& s : self->data_.scatter.displayStatus) s = hide ? 1 : 0;
localToggle(); localToggle();
}); });
} }
@ -475,7 +524,8 @@ void RawDataChartView::onShowHide(bool hide) {
void RawDataChartView::openFilterDialog(QWidget* anchor) { void RawDataChartView::openFilterDialog(QWidget* anchor) {
const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString(); const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString();
if (!cmdRepo_ || dsId.isEmpty()) { showNotImplemented(anchor); return; } 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(); // 成功/失败由对话框内部反馈(生成过滤数据集为后端动作)。 dlg.exec(); // 成功/失败由对话框内部反馈(生成过滤数据集为后端动作)。
} }
@ -524,8 +574,10 @@ void RawDataChartView::openScatterColorScale(QWidget* anchor) {
if (vMin > vMax) { vMin = 0.0; vMax = 1.0; } if (vMin > vMax) { vMin = 0.0; vMax = 1.0; }
std::vector<double> samples = data_.scatter.v; // 直方图/等积分层用原始标量 std::vector<double> samples = data_.scatter.v; // 直方图/等积分层用原始标量
// 散点无独立 lvl 模板仓储(视图只持命令仓储)→ tplRepo 传空(另存为/打开 禁用)。 // 接通色阶模板库:注入仓储 + 当前 projectId + 载荷 templateId另存为/打开/覆盖 可用)。
ColorScaleConfigDialog dlg(data_.scale, vMin, vMax, std::move(samples), {}, nullptr, {}, this); ColorScaleConfigDialog dlg(data_.scale, vMin, vMax, std::move(samples), {}, colorTplRepo_,
projectIdGetter_ ? projectIdGetter_() : QString(), data_.templateId,
this);
if (dlg.exec() != QDialog::Accepted) return; if (dlg.exec() != QDialog::Accepted) return;
// 本地重建 colorSvc_ 重绘散点M8 即时生效)。 // 本地重建 colorSvc_ 重绘散点M8 即时生效)。
@ -536,6 +588,7 @@ void RawDataChartView::openScatterColorScale(QWidget* anchor) {
// 同步右侧竖条/底部横条色阶图例。 // 同步右侧竖条/底部横条色阶图例。
if (data_.verticalLegend) colorBarV_->setColorScale(data_.scale); if (data_.verticalLegend) colorBarV_->setColorScale(data_.scale);
else colorBar_->setColorScale(data_.scale); else colorBar_->setColorScale(data_.scale);
showToast(this, QStringLiteral("色阶应用成功")); // M8 成功提示(对照原版 Message.success
// 持久化到后端saveColorGradationbusinessCode=当前 V 值type=3 散点路径)。 // 持久化到后端saveColorGradationbusinessCode=当前 V 值type=3 散点路径)。
const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString(); const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString();
@ -543,6 +596,7 @@ void RawDataChartView::openScatterColorScale(QWidget* anchor) {
if (!cmdRepo_ || dsId.isEmpty()) return; // 无仓储 → 仅本地生效(不阻塞) if (!cmdRepo_ || dsId.isEmpty()) return; // 无仓储 → 仅本地生效(不阻塞)
QJsonObject body{ QJsonObject body{
{QStringLiteral("dsObjectId"), dsId}, {QStringLiteral("dsObjectId"), dsId},
{QStringLiteral("templateId"), data_.templateId}, // 读取到的色阶模板 id对照原版可空
{QStringLiteral("businessCode"), currentVFieldCode()}, {QStringLiteral("businessCode"), currentVFieldCode()},
{QStringLiteral("projectId"), projectId}, {QStringLiteral("projectId"), projectId},
{QStringLiteral("properties"), buildColorScaleProperties(data_.scale, dlg.lineConfig())}, {QStringLiteral("properties"), buildColorScaleProperties(data_.scale, dlg.lineConfig())},
@ -592,17 +646,38 @@ void RawDataChartView::toggleInfoMode(bool on) {
infoMode_ = on; infoMode_ = on;
if (on && !infoPanel_) { if (on && !infoPanel_) {
// 首次开启:建覆盖在图区右上角的属性面板(复刻原版 .scatterInfos 浮层)。 // 首次开启:建覆盖在图区右上角的属性面板(复刻原版 .scatterInfos 浮层)。
// A/B/M/N 按原版逐项配色item-label-a 红 / -b 蓝 / -m 绿 / -n 橙#F4B008
infoPanel_ = new QWidget(plot_->canvas()); infoPanel_ = new QWidget(plot_->canvas());
infoPanel_->setObjectName(QStringLiteral("scatterInfoPanel")); infoPanel_->setObjectName(QStringLiteral("scatterInfoPanel"));
auto* il = new QVBoxLayout(infoPanel_); auto* il = new QVBoxLayout(infoPanel_);
il->setContentsMargins(8, 6, 8, 6); il->setContentsMargins(8, 6, 8, 6);
infoLabel_ = new QLabel(QStringLiteral("点选散点查看属性"), infoPanel_); il->setSpacing(2);
il->addWidget(infoLabel_); 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( applyTokenizedStyleSheet(
infoPanel_, infoPanel_,
QStringLiteral("QWidget#scatterInfoPanel { background: {{bg/panel}};" QStringLiteral("QWidget#scatterInfoPanel { background: {{bg/panel}};"
" border: 1px solid {{border/default}}; border-radius: 6px; }" " 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); plot_->canvas()->installEventFilter(this);
} }
@ -617,7 +692,7 @@ void RawDataChartView::toggleInfoMode(bool on) {
} }
void RawDataChartView::showPointInfoAt(const QPoint& canvasPos) { 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 xMap = plot_->canvasMap(QwtPlot::xTop);
const QwtScaleMap yMap = plot_->canvasMap(QwtPlot::yLeft); const QwtScaleMap yMap = plot_->canvasMap(QwtPlot::yLeft);
const auto& s = data_.scatter; 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) { auto at = [](const std::vector<double>& v, std::size_t i) {
return i < v.size() ? v[i] : 0.0; return i < v.size() ? v[i] : 0.0;
}; };
// 复刻原版 scatterInfosA / B / M / N / DataRow / Pseu_Resis。 // 复刻原版 scatterInfosA / B / M / N / DataRow / Pseu_ResisA/B/M/N 标签逐项配色)。
infoLabel_->setText(QStringLiteral("A= %1\nB= %2\nM= %3\nN= %4\nDataRow= %5\nPseu_Resis= %6") if (infoHint_) infoHint_->setVisible(false);
.arg(QString::number(at(s.a, bestI), 'g', 6), infoValA_->setText(QStringLiteral("A= %1").arg(QString::number(at(s.a, bestI), 'g', 6)));
QString::number(at(s.b, bestI), 'g', 6), infoValB_->setText(QStringLiteral("B= %1").arg(QString::number(at(s.b, bestI), 'g', 6)));
QString::number(at(s.m, bestI), 'g', 6), infoValM_->setText(QStringLiteral("M= %1").arg(QString::number(at(s.m, bestI), 'g', 6)));
QString::number(at(s.n, bestI), 'g', 6), infoValN_->setText(QStringLiteral("N= %1").arg(QString::number(at(s.n, bestI), 'g', 6)));
QString::number(at(s.row, bestI), 'g', 6), infoValRow_->setText(QStringLiteral("DataRow= %1").arg(QString::number(at(s.row, bestI), 'g', 6)));
QString::number(at(s.pseu, 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_->adjustSize();
infoPanel_->move(plot_->canvas()->width() - infoPanel_->width() - 10, 10); infoPanel_->move(plot_->canvas()->width() - infoPanel_->width() - 10, 10);
infoPanel_->raise(); 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) { bool RawDataChartView::eventFilter(QObject* obj, QEvent* ev) {
// 仅信息模式 + 画布左键点击:找最近散点显示属性,不消费事件(保留平移链路)。 // 仅信息模式 + 画布左键点击:找最近散点显示属性,不消费事件(保留平移链路)。
if (infoMode_ && plot_ && obj == plot_->canvas() && ev->type() == QEvent::MouseButtonPress) { 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 清晰,随主题)。 // [i] info + [▣] 框选:占位(暂未实现)。用 QPainter 画的线性图标HiDPI 清晰,随主题)。
auto* btnInfo = new QToolButton(toolbar); auto* btnInfo = new QToolButton(toolbar);
btnInfo->setToolTip(QStringLiteral("信息")); btnInfo->setToolTip(QStringLiteral("查看散点属性")); // 对照原版 datasetTool.vue tooltip
styleToolIconButton(btnInfo, makeInfoIcon()); styleToolIconButton(btnInfo, makeInfoIcon());
auto* btnMarquee = new QToolButton(toolbar); auto* btnMarquee = new QToolButton(toolbar);
btnMarquee->setToolTip(QStringLiteral("框选")); btnMarquee->setToolTip(QStringLiteral("散点的点选")); // 对照原版 datasetTool.vue tooltip
styleToolIconButton(btnMarquee, makeMarqueeIcon()); styleToolIconButton(btnMarquee, makeMarqueeIcon());
// 主题热切重绘图标info 锚定品牌蓝marquee 描边随次要文本色)。 // 主题热切重绘图标info 锚定品牌蓝marquee 描边随次要文本色)。
connect(&ThemeManager::instance(), &ThemeManager::changed, btnInfo, 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 // [i] 信息:切换信息模式(点选散点看 A/B/M/N/DataRow/Pseu_Resis
btnInfo->setCheckable(true); btnInfo->setCheckable(true);
connect(btnInfo, &QToolButton::toggled, this, [this](bool on) { toggleInfoMode(on); }); connect(btnInfo, &QToolButton::toggled, this, [this](bool on) { toggleInfoMode(on); });
// [▣] 框选:本轮后置Qwt 橡皮筋框选 + 选区联动隐藏成本较高),保持占位提示 // [▣] 框选:可勾选 → 进入框选模式(橡皮筋选框内散点高亮;显示/隐藏改对选中点)
connect(btnMarquee, &QToolButton::clicked, this, btnMarquee->setCheckable(true);
[this, btnMarquee]() { showNotImplemented(btnMarquee); }); connect(btnMarquee, &QToolButton::toggled, this, [this](bool on) { toggleMarqueeMode(on); });
// 显示 / 隐藏popconfirm 确认 → saveDisplayStatus 持久化 → 本地切换M1 // 显示 / 隐藏popconfirm 确认 → saveDisplayStatus 持久化 → 本地切换M1
auto* btnShow = new QPushButton(QStringLiteral("显示"), toolbar); auto* btnShow = new QPushButton(QStringLiteral("显示"), toolbar);
@ -728,18 +823,30 @@ void RawDataChartView::buildMeasurementToolbar(const geopro::core::ScatterToolba
connect(btnExport, &QPushButton::clicked, this, [this]() { exportDat(); }); connect(btnExport, &QPushButton::clicked, this, [this]() { exportDat(); });
// x / y 下拉本地换列重绘v 下拉:重新请求散点+色阶M6值类型下拉本地变换M7 // x / y 下拉本地换列重绘v 下拉:重新请求散点+色阶M6值类型下拉本地变换M7
xCombo_ = new QComboBox(toolbar); // 各下拉固定宽度对照原版 datasetTool.vueX=120/Y=160/V=160/值类型=120
xCombo_ = new EmptyAwareComboBox(toolbar);
xCombo_->setFixedWidth(kComboW_X);
fillCombo(xCombo_, conf.x, conf.defaultX, QString()); 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()); 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()); fillCombo(vCombo_, conf.v, conf.defaultV, QString());
valueTypeCombo_ = new QComboBox(toolbar); valueTypeCombo_ = new EmptyAwareComboBox(toolbar);
valueTypeCombo_->setFixedWidth(kComboW_ValueType);
// 值类型固定三项(原版 linearity/inverse/logarithm本地变换无后端。 // 值类型固定三项(原版 linearity/inverse/logarithm本地变换无后端。
valueTypeCombo_->addItem(QStringLiteral("线性"), QStringLiteral("linearity")); valueTypeCombo_->addItem(QStringLiteral("线性"), QStringLiteral("linearity"));
valueTypeCombo_->addItem(QStringLiteral("倒数"), QStringLiteral("inverse")); valueTypeCombo_->addItem(QStringLiteral("倒数"), QStringLiteral("inverse"));
valueTypeCombo_->addItem(QStringLiteral("对数"), QStringLiteral("logarithm")); 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, connect(xCombo_, QOverload<int>::of(&QComboBox::currentIndexChanged), this,
[this](int) { replotForAxis(); }); [this](int) { replotForAxis(); });
connect(yCombo_, QOverload<int>::of(&QComboBox::currentIndexChanged), this, connect(yCombo_, QOverload<int>::of(&QComboBox::currentIndexChanged), this,
@ -776,13 +883,17 @@ void RawDataChartView::buildMeasurementToolbar(const geopro::core::ScatterToolba
tbLay->addWidget(btnShow); tbLay->addWidget(btnShow);
tbLay->addWidget(btnHide); tbLay->addWidget(btnHide);
tbLay->addWidget(btnFilter); tbLay->addWidget(btnFilter);
tbLay->addWidget(btnExport);
tbLay->addWidget(xCombo_); tbLay->addWidget(xCombo_);
tbLay->addWidget(yCombo_); tbLay->addWidget(yCombo_);
tbLay->addWidget(vCombo_); tbLay->addWidget(vCombo_);
tbLay->addWidget(valueTypeCombo_); tbLay->addWidget(valueTypeCombo_);
tbLay->addWidget(btnColorScale); tbLay->addWidget(btnColorScale);
tbLay->addStretch(); // 把主操作推到右侧 tbLay->addStretch(); // 把导出 + 主操作推到右侧
// 导出:原版在详情页头 Header非工具条。客户端页头「导出」HeaderAction 为跨 ddCode
// 共用的静态占位PanelHeader 不暴露按钮/不发信号IDetailView 亦无导出接口),按 ddCode
// 分派转发成本高且易误触其它视图;故 measurement 专属导出保留在工具条内,置于最右侧、
// 紧邻主操作组样式贴近原版outline 风格的普通按钮)。
tbLay->addWidget(btnExport);
tbLay->addWidget(btnGen); tbLay->addWidget(btnGen);
tbLay->addWidget(btnInvert); tbLay->addWidget(btnInvert);
tbLay->addWidget(btnSaveAs); tbLay->addWidget(btnSaveAs);
@ -816,6 +927,7 @@ void RawDataChartView::setData(const geopro::core::ScatterPayload& p) {
data_ = p; data_ = p;
baseV_ = data_.scatter.v; // 缓存原始 v线性M7 值类型变换从原值算,不累积误差 baseV_ = data_.scatter.v; // 缓存原始 v线性M7 值类型变换从原值算,不累积误差
if (hoverTip_) hoverTip_->setField(&data_.scatter); // 显式重绑(地址稳定,消除隐式依赖) if (hoverTip_) hoverTip_->setField(&data_.scatter); // 显式重绑(地址稳定,消除隐式依赖)
if (marquee_) marquee_->setField(&data_.scatter); // M14 框选拾取同源重绑
// measurement 载荷toolbar 非空):首次到来时建并替换工具条(视觉 1:1。反演留空 → 不动。 // measurement 载荷toolbar 非空):首次到来时建并替换工具条(视觉 1:1。反演留空 → 不动。
if (!p.toolbar.empty() && !measurementToolbar_) buildMeasurementToolbar(p.toolbar); if (!p.toolbar.empty() && !measurementToolbar_) buildMeasurementToolbar(p.toolbar);

View File

@ -15,6 +15,7 @@ class QwtPlotRescaler;
namespace geopro::data { namespace geopro::data {
class IDatasetCommandRepository; class IDatasetCommandRepository;
class IColorTemplateRepository;
} }
namespace geopro::app { namespace geopro::app {
@ -22,6 +23,7 @@ namespace geopro::app {
class ColorBarWidget; class ColorBarWidget;
class ScatterPlotItem; class ScatterPlotItem;
class ScatterHoverTip; class ScatterHoverTip;
class ScatterMarqueePicker;
// 原数据图表视图:工具条 + QwtPlotx 轴顶部、Panner/Magnifier+ 独立色阶条。 // 原数据图表视图:工具条 + QwtPlotx 轴顶部、Panner/Magnifier+ 独立色阶条。
class RawDataChartView : public QWidget, public IDetailView { class RawDataChartView : public QWidget, public IDetailView {
@ -46,6 +48,10 @@ public:
std::function<QString()> dsIdGetter, std::function<QString()> dsIdGetter,
std::function<QString()> projectIdGetter); std::function<QString()> projectIdGetter);
// 注入色阶模板仓储(散点「色阶配置」编辑器「另存为/打开/覆盖」用projectId 复用
// setCommandRepo 注入的 projectIdGetter_。可传空 → 编辑器后端按钮禁用。
void setColorTemplateRepo(geopro::data::IColorTemplateRepository* repo);
protected: protected:
// 信息模式M13下捕获画布点击找最近散点显示属性。其余事件不消费。 // 信息模式M13下捕获画布点击找最近散点显示属性。其余事件不消费。
bool eventFilter(QObject* obj, QEvent* ev) override; bool eventFilter(QObject* obj, QEvent* ev) override;
@ -76,6 +82,8 @@ private:
void exportDat(); // M12 导出 DAT void exportDat(); // M12 导出 DAT
void toggleInfoMode(bool on); // M13 [i] 信息模式开关 void toggleInfoMode(bool on); // M13 [i] 信息模式开关
void showPointInfoAt(const QPoint& canvasPos); // M13 点选显示属性 void showPointInfoAt(const QPoint& canvasPos); // M13 点选显示属性
void toggleMarqueeMode(bool on); // M14 框选模式开关
void onMarqueeSelected(const std::vector<int>& indices); // M14 框选回调:高亮选中点
// 用 colorSvc_ 重绘当前散点M7/M8 本地变换/色阶变更后复用)。 // 用 colorSvc_ 重绘当前散点M7/M8 本地变换/色阶变更后复用)。
void redrawScatter(); void redrawScatter();
QString currentVFieldCode() const; // 当前 V 值下拉 fieldCode QString currentVFieldCode() const; // 当前 V 值下拉 fieldCode
@ -100,17 +108,28 @@ private:
// M13 [i]信息:信息模式开关 + 覆盖在图区右上的属性面板。 // M13 [i]信息:信息模式开关 + 覆盖在图区右上的属性面板。
bool infoMode_ = false; bool infoMode_ = false;
QWidget* infoPanel_ = nullptr; // 属性覆盖面板A/B/M/N/DataRow/Pseu_Resis QWidget* infoPanel_ = nullptr; // 属性覆盖面板A/B/M/N/DataRow/Pseu_Resis
QLabel* infoLabel_ = nullptr; QLabel* infoHint_ = nullptr; // 未点选时的提示文案
// A/B/M/N 标签按原版配色A 红 / B 蓝 / M 绿 / N 橙#F4B008DataRow/Pseu_Resis 默认色。
QLabel* infoValA_ = nullptr;
QLabel* infoValB_ = nullptr;
QLabel* infoValM_ = nullptr;
QLabel* infoValN_ = nullptr;
QLabel* infoValRow_ = nullptr;
QLabel* infoValPseu_ = nullptr;
// 使用 unique_ptr 管理生命周期attach 后 QwtPlot 接管绘制,但我们仍持有指针 // 使用 unique_ptr 管理生命周期attach 后 QwtPlot 接管绘制,但我们仍持有指针
ColorMapService* colorSvc_ = nullptr; // heap由 setData 重建 ColorMapService* colorSvc_ = nullptr; // heap由 setData 重建
ScatterPlotItem* scatterItem_ = nullptr; ScatterPlotItem* scatterItem_ = nullptr;
ScatterHoverTip* hoverTip_ = nullptr; // 散点 hover 提示QObjectthis 持有) ScatterHoverTip* hoverTip_ = nullptr; // 散点 hover 提示QObjectthis 持有)
ScatterMarqueePicker* marquee_ = nullptr; // M14 框选拾取器QObjectthis 持有)
bool marqueeMode_ = false; // M14 框选模式开关
// 反演命令仓储 + dsId/projectId 取值回调(注入;空则反演按钮占位)。 // 反演命令仓储 + dsId/projectId 取值回调(注入;空则反演按钮占位)。
geopro::data::IDatasetCommandRepository* cmdRepo_ = nullptr; geopro::data::IDatasetCommandRepository* cmdRepo_ = nullptr;
std::function<QString()> dsIdGetter_; std::function<QString()> dsIdGetter_;
std::function<QString()> projectIdGetter_; std::function<QString()> projectIdGetter_;
// 色阶模板仓储(注入;空则编辑器「另存为/打开」禁用)。
geopro::data::IColorTemplateRepository* colorTplRepo_ = nullptr;
}; };
} // namespace geopro::app } // namespace geopro::app

View File

@ -3,6 +3,7 @@
#include <utility> #include <utility>
#include <QButtonGroup> #include <QButtonGroup>
#include <QDialogButtonBox>
#include <QFormLayout> #include <QFormLayout>
#include <QHBoxLayout> #include <QHBoxLayout>
#include <QLabel> #include <QLabel>
@ -13,49 +14,66 @@
#include <QRadioButton> #include <QRadioButton>
#include <QVBoxLayout> #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 "panels/chart/ScatterDataOps.hpp" // buildSaveRawDataBody纯组装便于单测
#include "repo/IDatasetCommandRepository.hpp" #include "repo/IDatasetCommandRepository.hpp"
namespace geopro::app { 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, SaveAsDialog::SaveAsDialog(Mode mode, geopro::data::IDatasetCommandRepository* repo, QString dsId,
QWidget* parent) QWidget* parent)
: QDialog(parent), mode_(mode), repo_(repo), dsId_(std::move(dsId)) { : QDialog(parent), mode_(mode), repo_(repo), dsId_(std::move(dsId)) {
setWindowTitle(QStringLiteral("数据另存为"));
setModal(true); setModal(true);
// 规范 §7.5 对话框外壳 + §7.0.10 唯一表单实现makeEditForm
auto* root = formkit::dialogRoot(this); auto* root = formkit::dialogRoot(this);
auto* form = formkit::makeEditForm();
auto* card = formkit::formCard(this); if (mode_ == Mode::Inversion) {
auto* cardLay = formkit::cardBody(card); // ── inversion原版「另存为新的网格数据」仅名称行 ──
setWindowTitle(QStringLiteral("另存为新的网格数据"));
setFixedWidth(scaledPx(kInversionW));
if (mode_ == Mode::RawData) { nameLabel_ = formkit::editLabel(QStringLiteral("名称"), this); // 原版 label「名称」
// 新增/覆盖单选(复刻原版 a-radio-group1=新增 0=覆盖)。 nameEdit_ = new QLineEdit(this);
auto* opLay = new QHBoxLayout(); nameEdit_->setPlaceholderText(QStringLiteral("请输入名称"));
auto* rbNew = new QRadioButton(QStringLiteral("新增"), this); nameEdit_->setText(QStringLiteral("网格数据1")); // 原版默认值
auto* rbOverwrite = new QRadioButton(QStringLiteral("覆盖"), this); formkit::capField(nameEdit_);
form->addRow(nameLabel_, nameEdit_);
root->addLayout(form);
} else {
// ── RawDatameasurement新增/覆盖 + 名称(对照原版「数据另存为」)──
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_ = new QButtonGroup(this);
opGroup_->addButton(rbNew, 1); opGroup_->addButton(rbNew, 1);
opGroup_->addButton(rbOverwrite, 0); opGroup_->addButton(rbOverwrite, 0);
rbNew->setChecked(true); // 默认新增(与原版 dataStored 初值一致) rbNew->setChecked(true); // 默认新增
opLay->addWidget(rbNew); opLay->addWidget(rbNew);
opLay->addWidget(rbOverwrite); opLay->addWidget(rbOverwrite);
opLay->addStretch(); opLay->addStretch();
cardLay->addLayout(opLay); form->addRow(formkit::editLabel(QStringLiteral("操作"), this), opWrap);
}
// 名称行RawData 仅新增可见Inversion 始终可见。
auto* nameForm = formkit::makeEditForm();
nameLabel_ = formkit::editLabel(QStringLiteral("数据名称"), this); nameLabel_ = formkit::editLabel(QStringLiteral("数据名称"), this);
nameEdit_ = new QLineEdit(this); nameEdit_ = new QLineEdit(this);
formkit::capField(nameEdit_); formkit::capField(nameEdit_);
nameForm->addRow(nameLabel_, nameEdit_); form->addRow(nameLabel_, nameEdit_);
cardLay->addLayout(nameForm); root->addLayout(form);
root->addWidget(card);
if (mode_ == Mode::RawData && opGroup_) { // 切到覆盖隐藏名称框,切回新增显示。
// 切到覆盖隐藏名称框,切回新增显示(复刻原版 v-show=dataStored===1
connect(opGroup_, QOverload<int>::of(&QButtonGroup::idClicked), this, [this](int id) { connect(opGroup_, QOverload<int>::of(&QButtonGroup::idClicked), this, [this](int id) {
const bool isNew = (id == 1); const bool isNew = (id == 1);
nameLabel_->setVisible(isNew); nameLabel_->setVisible(isNew);
@ -63,16 +81,10 @@ SaveAsDialog::SaveAsDialog(Mode mode, geopro::data::IDatasetCommandRepository* r
}); });
} }
auto* btnLay = new QHBoxLayout(); // 规范 §7.5 底部操作栏:右对齐 取消(次) + 确认(主);确认需异步保存成功才关闭。
btnLay->addStretch(); auto* box = formkit::addDialogButtons(root, this, QStringLiteral("确认"), QStringLiteral("取消"));
auto* cancelBtn = new QPushButton(QStringLiteral("取消"), this); okBtn_ = box->button(QDialogButtonBox::Ok);
okBtn_ = new QPushButton(QStringLiteral("确定"), this); QObject::disconnect(box, &QDialogButtonBox::accepted, this, &QDialog::accept);
okBtn_->setDefault(true);
btnLay->addWidget(cancelBtn);
btnLay->addWidget(okBtn_);
root->addLayout(btnLay);
connect(cancelBtn, &QPushButton::clicked, this, &QDialog::reject);
connect(okBtn_, &QPushButton::clicked, this, &SaveAsDialog::onConfirm); connect(okBtn_, &QPushButton::clicked, this, &SaveAsDialog::onConfirm);
} }
@ -84,7 +96,7 @@ void SaveAsDialog::onConfirm() {
const bool needName = (mode_ == Mode::Inversion) || (operationType == 1); const bool needName = (mode_ == Mode::Inversion) || (operationType == 1);
const QString name = nameEdit_->text().trimmed(); const QString name = nameEdit_->text().trimmed();
if (needName && name.isEmpty()) { if (needName && name.isEmpty()) {
QMessageBox::warning(this, windowTitle(), QStringLiteral("请输入数据名称")); QMessageBox::warning(this, windowTitle(), QStringLiteral("请输入名称"));
return; return;
} }
@ -94,6 +106,8 @@ void SaveAsDialog::onConfirm() {
if (!self) return; if (!self) return;
self->okBtn_->setEnabled(true); self->okBtn_->setEnabled(true);
if (ok) { if (ok) {
// 成功提示挂到父窗口对话框随即关闭toast 取顶层窗口锚定,故用 parentWidget
if (auto* anchor = self->parentWidget()) showToast(anchor, QStringLiteral("保存成功"));
self->accept(); self->accept();
} else { } else {
QMessageBox::warning(self, self->windowTitle(), QMessageBox::warning(self, self->windowTitle(),

View File

@ -65,4 +65,36 @@ QJsonObject buildSaveRawDataBody(const QString& dsId, int operationType, const Q
return body; 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 } // namespace geopro::app

View File

@ -40,4 +40,27 @@ QJsonObject buildScatterFilterBody(const QString& dsObjectId, const QString& vFi
// {dsId, operationType(1新增/0覆盖)},仅新增(operationType==1)才带 name。 // {dsId, operationType(1新增/0覆盖)},仅新增(operationType==1)才带 name。
QJsonObject buildSaveRawDataBody(const QString& dsId, int operationType, const QString& name); QJsonObject buildSaveRawDataBody(const QString& dsId, int operationType, const QString& name);
// 直方图分箱结果M3 数据过滤分布图):等宽 binCount 个箱,落在 [min,max] 区间内计数。
// binMin/binMax = 分箱区间端点(= 入参 min/maxstep = 单箱宽度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 } // namespace geopro::app

View File

@ -1,58 +1,135 @@
#include "panels/chart/ScatterFilterDialog.hpp" #include "panels/chart/ScatterFilterDialog.hpp"
#include <algorithm>
#include <cmath>
#include <utility> #include <utility>
#include <QDoubleSpinBox> #include <QDoubleSpinBox>
#include <QFormLayout> #include <QFormLayout>
#include <QFrame>
#include <QHBoxLayout> #include <QHBoxLayout>
#include <QLabel> #include <QLabel>
#include <QMessageBox> #include <QMessageBox>
#include <QPointer> #include <QPointer>
#include <QPushButton> #include <QPushButton>
#include <QSignalBlocker>
#include <QVBoxLayout> #include <QVBoxLayout>
#include "FormKit.hpp" #include "FormKit.hpp" // makeEditForm / editLabel / capField
#include "panels/chart/ScatterDataOps.hpp" // buildScatterFilterBody #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" #include "repo/IDatasetCommandRepository.hpp"
namespace geopro::app { namespace geopro::app {
namespace { namespace {
constexpr double kSpinRange = 1e12; // 数值范围足够宽,覆盖电阻率/电位等量纲 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 } // namespace
ScatterFilterDialog::ScatterFilterDialog(geopro::data::IDatasetCommandRepository* repo, ScatterFilterDialog::ScatterFilterDialog(geopro::data::IDatasetCommandRepository* repo,
QString dsObjectId, QString vFieldCode, QWidget* parent) QString dsObjectId, QString vFieldCode,
std::vector<double> values, QWidget* parent)
: QDialog(parent), : QDialog(parent),
repo_(repo), repo_(repo),
dsObjectId_(std::move(dsObjectId)), dsObjectId_(std::move(dsObjectId)),
vFieldCode_(std::move(vFieldCode)) { vFieldCode_(std::move(vFieldCode)) {
setWindowTitle(QStringLiteral("数据过滤")); setWindowTitle(QStringLiteral("数据过滤"));
setModal(true); 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); auto* root = new QVBoxLayout(this);
root->addWidget(rangeLabel_); root->setContentsMargins(space::kXl, space::kXl, space::kXl, space::kXl);
root->setSpacing(space::kLg);
auto* card = formkit::formCard(this); // ── 上半区:左直方图 + 右信息区(高 500──
auto* cardLay = formkit::cardBody(card); 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(); // 右信息区(定宽 300带边框卡片
minSpin_ = new QDoubleSpinBox(this); auto* info = new QFrame(this);
minSpin_->setRange(-kSpinRange, kSpinRange); info->setObjectName(QStringLiteral("filterInfo"));
minSpin_->setDecimals(2); info->setFixedWidth(kInfoW);
formkit::capField(minSpin_); 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_ = new QDoubleSpinBox(this);
maxSpin_->setRange(-kSpinRange, kSpinRange); maxSpin_->setRange(-kSpinRange, kSpinRange);
maxSpin_->setDecimals(2); maxSpin_->setDecimals(2);
formkit::capField(maxSpin_); minSpin_ = new QDoubleSpinBox(this);
form->addRow(formkit::editLabel(QStringLiteral("最小值")), minSpin_); minSpin_->setRange(-kSpinRange, kSpinRange);
form->addRow(formkit::editLabel(QStringLiteral("最大值")), maxSpin_); minSpin_->setDecimals(2);
cardLay->addLayout(form); inputForm->addRow(formkit::editLabel(QStringLiteral("最大值"), this), maxSpin_);
root->addWidget(card); 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(); auto* btnLay = new QHBoxLayout();
btnLay->addStretch(); btnLay->addStretch();
auto* cancelBtn = new QPushButton(QStringLiteral("取消"), this); auto* cancelBtn = new QPushButton(QStringLiteral("取消"), this);
@ -62,25 +139,69 @@ ScatterFilterDialog::ScatterFilterDialog(geopro::data::IDatasetCommandRepository
btnLay->addWidget(applyBtn_); btnLay->addWidget(applyBtn_);
root->addLayout(btnLay); 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(cancelBtn, &QPushButton::clicked, this, &QDialog::reject);
connect(applyBtn_, &QPushButton::clicked, this, &ScatterFilterDialog::onApply); connect(applyBtn_, &QPushButton::clicked, this, &ScatterFilterDialog::onApply);
loadConfig(); 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() { void ScatterFilterDialog::loadConfig() {
if (!repo_) return; if (!repo_) {
setCurrentRange(dataMin_, dataMax_, false, false); // 无仓储:用数据域初值
return;
}
QPointer<ScatterFilterDialog> self(this); QPointer<ScatterFilterDialog> self(this);
repo_->getScatterFilterConfig( repo_->getScatterFilterConfig(
dsObjectId_, vFieldCode_, [self](bool ok, QJsonObject cfg, QString) { 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 mn = cfg.value(QStringLiteral("min")).toDouble();
const double mx = cfg.value(QStringLiteral("max")).toDouble(); const double mx = cfg.value(QStringLiteral("max")).toDouble();
self->minSpin_->setValue(mn); self->setCurrentRange(mn, mx, false, false);
self->maxSpin_->setValue(mx);
self->rangeLabel_->setText(QStringLiteral("数值范围:%1 — %2")
.arg(QString::number(mn, 'g', 6),
QString::number(mx, 'g', 6)));
}); });
} }
@ -100,8 +221,9 @@ void ScatterFilterDialog::onApply() {
if (!self) return; if (!self) return;
self->applyBtn_->setEnabled(true); self->applyBtn_->setEnabled(true);
if (ok) { if (ok) {
QMessageBox::information(self, self->windowTitle(), // 成功提示挂父窗口(对话框随即关闭)。文案对照原版「应用过滤成功!」。
QStringLiteral("应用过滤成功!")); if (auto* anchor = self->parentWidget())
showToast(anchor, QStringLiteral("应用过滤成功!"));
self->accept(); self->accept();
} else { } else {
QMessageBox::warning(self, self->windowTitle(), QMessageBox::warning(self, self->windowTitle(),

View File

@ -1,4 +1,6 @@
#pragma once #pragma once
#include <vector>
#include <QDialog> #include <QDialog>
#include <QString> #include <QString>
@ -12,29 +14,48 @@ class IDatasetCommandRepository;
namespace geopro::app { namespace geopro::app {
// 「数据过滤」对话框(复刻原版 web dataFilter.vue 的范围过滤部分): class ScatterHistogramView;
// 打开时经 getScatterFilterConfig 取 min/max 初值填入「最小值/最大值」输入框; class RangeSlider;
// 「应用过滤」经 applyScatterFilter 生成过滤后数据集({sourceDsObjectId, sourceVFieldCode, min, max})。
// 直方图(原版左侧 D3 分布图)本轮后置:范围过滤为核心,直方图仅可视化辅助。 // 「数据过滤」对话框1:1 复刻原版 web dataFilter.vue弹窗宽 1000px
// 左:数值分布直方图(自绘 ScatterHistogramhover 柱变红 + tooltip
// 右:信息区(数值范围 / 当前数据量占比 / 原始点数 / 当前点数橙色高亮 + 最大值/最小值输入 +
// 「计算分布」「重置」);底部:范围双手柄滑块 + 「取消」「应用过滤」。
// min/max 输入、滑块、直方图选区三方联动;统计随当前区间实时更新。
// 打开时经 getScatterFilterConfig 取 min/max 初值;「应用过滤」经 applyScatterFilter
// 生成过滤后数据集({sourceDsObjectId, sourceVFieldCode, min, max});成功提示 toast。
// 回调用 QPointer 守卫(虽 modal exec仍异步回调 // 回调用 QPointer 守卫(虽 modal exec仍异步回调
class ScatterFilterDialog : public QDialog { class ScatterFilterDialog : public QDialog {
Q_OBJECT Q_OBJECT
public: public:
// values = 当前 V 值数组(与图上散点 v 同源,驱动直方图分布 + 统计);可空(空则空态)。
ScatterFilterDialog(geopro::data::IDatasetCommandRepository* repo, QString dsObjectId, ScatterFilterDialog(geopro::data::IDatasetCommandRepository* repo, QString dsObjectId,
QString vFieldCode, QWidget* parent = nullptr); QString vFieldCode, std::vector<double> values, QWidget* parent = nullptr);
private: private:
void loadConfig(); // 取 min/max 初值 void loadConfig(); // 取 min/max 初值
void onApply(); // 应用过滤 void onApply(); // 应用过滤
void onResetFilter(); // 重置:恢复到全量数据域
void setCurrentRange(double min, double max, bool fromSlider, bool fromSpin); // 三方联动
void refreshStats(); // 刷新统计(占比/当前点数)与直方图选区
geopro::data::IDatasetCommandRepository* repo_ = nullptr; geopro::data::IDatasetCommandRepository* repo_ = nullptr;
QString dsObjectId_; QString dsObjectId_;
QString vFieldCode_; 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* minSpin_ = nullptr;
QDoubleSpinBox* maxSpin_ = nullptr; QDoubleSpinBox* maxSpin_ = nullptr;
QPushButton* applyBtn_ = nullptr; QPushButton* applyBtn_ = nullptr;
ScatterHistogramView* histogram_ = nullptr; // 左侧分布直方图
RangeSlider* slider_ = nullptr; // 底部范围滑块
}; };
} // namespace geopro::app } // namespace geopro::app

View File

@ -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

Some files were not shown because too many files have changed in this diff Show More