feat/dataset-detail-chart #5
|
|
@ -14,6 +14,13 @@ set(CMAKE_AUTOUIC ON)
|
|||
|
||||
if(MSVC)
|
||||
add_compile_options(/utf-8 /MP /W4 /permissive-)
|
||||
# 生成 PDB——即使 Release 优化构建也产出调试符号,使 minidump / 运行期崩溃栈可符号化分析
|
||||
# (生产桌面端排障必需)。/Zi 编译期调试信息;/DEBUG 链接产 PDB;/OPT:REF,ICF 抵消 /DEBUG
|
||||
# 默认关掉的优化,保持二进制优化+精简。仅非 Debug 配置启用。
|
||||
add_compile_options($<$<NOT:$<CONFIG:Debug>>:/Zi>)
|
||||
add_link_options($<$<NOT:$<CONFIG:Debug>>:/DEBUG>
|
||||
$<$<NOT:$<CONFIG:Debug>>:/OPT:REF>
|
||||
$<$<NOT:$<CONFIG:Debug>>:/OPT:ICF>)
|
||||
endif()
|
||||
|
||||
# =====================================================================
|
||||
|
|
@ -63,6 +70,12 @@ FetchContent_Declare(qtkeychain
|
|||
GIT_TAG v0.14.0)
|
||||
FetchContent_MakeAvailable(qtkeychain)
|
||||
|
||||
# Qwt 6.2(二维科学图表:数据集详情散点/等值线图)。见 cmake/qwt.cmake 说明。
|
||||
# 源码 external/qwt-src 已 gitignore,需按 docs/ENV_SETUP 拉取。
|
||||
if(EXISTS "${CMAKE_SOURCE_DIR}/external/qwt-src/src")
|
||||
include("${CMAKE_SOURCE_DIR}/cmake/qwt.cmake")
|
||||
endif()
|
||||
|
||||
add_subdirectory(src)
|
||||
|
||||
enable_testing()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
# 定义 qwt 静态库目标(Qwt 6.2 二维科学图表:数据集详情散点/等值线图)。
|
||||
#
|
||||
# 为何不用 Qwt 原生 qmake:本机 VS2026 预览版缺 vswhere,qmake 探测 cl 失败。
|
||||
# 为何不用 vcpkg 的 qwt:其端口依赖 vcpkg-qtbase,会与官方 Qt 形成双 Qt 冲突(项目铁律:
|
||||
# 依赖 Qt 的组件不走 vcpkg)。故此处用 CMake 直接编 Qwt 源码,对接官方 Qt6(同 VTK 思路)。
|
||||
#
|
||||
# 源码目录 external/qwt-src(已 gitignore,按 docs/ENV_SETUP 拉取):
|
||||
# git clone --depth 1 https://github.com/opencor/qwt external/qwt-src # Qwt 6.2.0 镜像
|
||||
# 由根 CMakeLists 在源码存在时 include 本文件。
|
||||
|
||||
set(QWT_SRC_DIR "${CMAKE_SOURCE_DIR}/external/qwt-src/src")
|
||||
|
||||
find_package(Qt6 REQUIRED COMPONENTS Widgets Concurrent PrintSupport Svg)
|
||||
|
||||
file(GLOB _qwt_sources CONFIGURE_DEPENDS "${QWT_SRC_DIR}/*.cpp")
|
||||
file(GLOB _qwt_headers CONFIGURE_DEPENDS "${QWT_SRC_DIR}/*.h")
|
||||
# 排除:OpenGL 画布 / SVG plot item / QwtPolar 部件(避免额外模块依赖与面积)。
|
||||
# 保留 qwt_symbol.cpp(用 QSvgRenderer,故链 Qt6::Svg)与 qwt_point_polar.cpp(几何工具)。
|
||||
list(FILTER _qwt_sources EXCLUDE REGEX "qwt_plot_glcanvas|qwt_plot_opengl_canvas|qwt_plot_svgitem|qwt_polar_")
|
||||
list(FILTER _qwt_headers EXCLUDE REGEX "qwt_plot_glcanvas|qwt_plot_opengl_canvas|qwt_plot_svgitem|qwt_polar_")
|
||||
|
||||
add_library(qwt STATIC ${_qwt_sources} ${_qwt_headers})
|
||||
set_target_properties(qwt PROPERTIES AUTOMOC ON)
|
||||
target_include_directories(qwt PUBLIC "${QWT_SRC_DIR}")
|
||||
target_link_libraries(qwt PUBLIC Qt6::Widgets Qt6::Concurrent Qt6::PrintSupport Qt6::Svg)
|
||||
target_compile_features(qwt PUBLIC cxx_std_17)
|
||||
# QWT_MOC_INCLUDE=1:启用 Qwt 源文件末尾 #if QWT_MOC_INCLUDE 保护的 #include "moc_xxx.cpp"。
|
||||
# Qwt 原生用 qmake(qwtbuild.pri 里设置),CMake 构建需显式定义,否则 MOC 元对象代码不被编译进 .obj。
|
||||
target_compile_definitions(qwt PRIVATE QWT_MOC_INCLUDE=1)
|
||||
if(MSVC)
|
||||
target_compile_options(qwt PRIVATE /bigobj /EHsc /wd4244 /wd4267 /wd4305 /wd4456)
|
||||
endif()
|
||||
|
|
@ -0,0 +1,234 @@
|
|||
# Geopro 3.0 桌面客户端 — 数据集详情「二维图表」返工技术方案
|
||||
|
||||
**版本 v1.0** · 适用范围:数据集详情页的「原数据(散点图)」与「网格数据(填充等值线图)」
|
||||
**状态**:返工指令(推翻当前 QGraphicsView 实现,重做)
|
||||
|
||||
---
|
||||
|
||||
## 0. 给开发(含 AI 编码)的硬约束 — 先读这一段
|
||||
|
||||
1. **不嵌入 QWebEngine,不引入 Chromium。** 整个客户端的技术路线是 Qt C++ 原生,核心动机包含离线/现场作业能力;把核心数据可视化做成依赖 web 渲染会破坏该前提,禁止。
|
||||
2. **推翻当前的裸 `QGraphicsView` + VTK 渲染窗口方案。** 当前 9 个问题的根因是 2D 框架选错,不是"没嵌 web"。
|
||||
3. **改用 `QwtPlot` 作为两类图的坐标系框架;等值线多边形用 VTK 算法(仅算法,不开 VTK 渲染窗口)。**
|
||||
4. **验收标准 = 视觉等价 + 交互一致**,不是字节级像素 diff。原生渲染与 web 端 Plotly 在抗锯齿、字体、亚像素上天生有别,任何工具都无法字节级相同;目标是"用户看不出降级感"。
|
||||
5. **不要自由发挥架构。** 本文档把分层、类、职责定死,照做;遇到本文档未覆盖的细节,先问,不要自行另起一套。
|
||||
|
||||
---
|
||||
|
||||
## 1. 根因诊断(为什么会做崩)
|
||||
|
||||
当前实现把"图形 + 坐标轴 + 色阶图例"全部糊在一个 `QGraphicsView` 场景里一起做变换,导致系统性错误。裸 `QGraphicsView` 是通用图形框架,**不具备"科学图表"语义**——它不知道什么是固定坐标轴、什么是"仅数据区缩放"、什么是"图例不参与变换"。
|
||||
|
||||
把用户列的 9 个问题按根因归类:
|
||||
|
||||
| # | 问题 | 根因类别 | 由谁解决 |
|
||||
|---|---|---|---|
|
||||
| 1 | 色阶图随大图缩放、与横坐标重叠 | 框架(图例未独立) | QwtPlot 分层 |
|
||||
| 3 | 拖动时坐标轴跟着动(应只动数据、轴值跟随) | 框架(无数据区/轴分离) | QwtPlot 内置交互 |
|
||||
| 4 | 一拖动画面就花 | 框架(手写变换 + 重绘错误) | QwtPlot 内置交互 |
|
||||
| 9 | 图比原版瘦(纵横比错) | 框架(无纵横比控制) | QwtPlot 纵横比锁定 |
|
||||
| 2 | 配色与原版不一致(离散色带 vs 连续插值) | 实现细节(色阶映射) | QwtLinearColorMap |
|
||||
| 5 | 等值线数字没显示 | 实现细节(缺标注) | 等值线 label |
|
||||
| 6 | 原数据/网格数据切换样式被改错 | UI 布局(与图表无关) | 改回页签样式 |
|
||||
| 7 | 显示异常/等值线开关跑到面板外 | UI 布局(与图表无关) | 归位工具条 |
|
||||
| 8 | 下方缺「描述」页签 | UI 布局(与图表无关) | 补页签 |
|
||||
|
||||
**关键结论**:9 个问题没有一个需要"嵌 web"才能解决。第 1 类(#1/#3/#4/#9)换对框架即消失;第 2 类(#2/#5)是渲染细节;第 3 类(#6/#7/#8)是纯 UI,跟用什么画图无关。
|
||||
|
||||
---
|
||||
|
||||
## 2. 目标架构 — 三层分离
|
||||
|
||||
整个二维图表 widget 必须做成**三层分离**,这是治本:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ ChartPanel (QWidget) │
|
||||
│ ┌───────────────────────────────────────┐ │
|
||||
│ │ 工具条 ToolBar(网格/色阶配置/白化/...) │ │ ← 工具条层(属于面板,不属于绘图区)
|
||||
│ ├───────────────────────────────────────┤ │
|
||||
│ │ QwtPlot(数据区 + 固定坐标轴) │ │ ← 绘图层(QwtPlot 管坐标轴与交互)
|
||||
│ │ • QwtPlotItem: 等值线多边形 / 散点 │ │
|
||||
│ │ • QwtPlotMagnifier / Panner(交互) │ │
|
||||
│ ├───────────────────────────────────────┤ │
|
||||
│ │ ColorBarWidget(独立固定) │ │ ← 图例层(独立 widget,永不参与数据区变换)
|
||||
│ └───────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**铁律**:
|
||||
- **坐标轴**由 QwtPlot 管理,**永远固定**(不随拖动移动其位置),拖动/缩放时只有**数据区内容**平移缩放,**轴上的刻度数值自动跟随重算**。这正是 QwtPlot 的默认行为,也正是原版的行为。
|
||||
- **色阶条(ColorBar)是独立 widget**,放在绘图区下方,**绝不放进 QwtPlot 的数据坐标系**,因此永远不会随图缩放、不会和横坐标重叠(直接消灭 #1)。
|
||||
- **工具条属于面板**,不属于绘图区(修正 #7)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 框架与依赖
|
||||
|
||||
| 用途 | 选用 | 协议 | 说明 |
|
||||
|---|---|---|---|
|
||||
| 2D 图表坐标系框架 | **QwtPlot(Qwt 6.2+)** | QWT License(LGPL + 例外,商用免费) | 坐标轴、缩放、平移、刻度跟随、图例、色阶映射全内置 |
|
||||
| 等值线多边形算法 | **vtkBandedPolyDataContourFilter** | BSD(商用免费) | 仅当算法用,**不开 VTK 渲染窗口** |
|
||||
| 等值线(线)算法 | vtkContourFilter / vtkFlyingEdges2D | BSD | 生成等值线及其标注位置 |
|
||||
| 连续色阶映射 | **QwtLinearColorMap** | QWT License | 对齐原版 Plotly 的连续插值色阶 |
|
||||
|
||||
> 协议全部商用友好,零授权费。QWT 与 VTK 都不触发开源传染。
|
||||
|
||||
> **VTK 的双重角色**(务必理解,否则又会做重):在 3D 视图 / 地图视图里 VTK 是**渲染器**(开渲染窗口);在本页这种纯二维详情图里,VTK **退化为算法库**(只用它的 filter 算多边形,取出顶点交给 QwtPlot 画)。本页**不允许**出现 QVTKOpenGLWidget / 渲染窗口。
|
||||
|
||||
---
|
||||
|
||||
## 4. 逐项解法(对应 9 个问题)
|
||||
|
||||
### 4.1 色阶条独立固定(治 #1)
|
||||
|
||||
- 新建 `ColorBarWidget : public QWidget`,自绘一条水平色带 + 刻度数值 + 单位。
|
||||
- 它在布局上位于 QwtPlot **下方**,是**兄弟 widget**,不是 QwtPlot 的内部 item。
|
||||
- 它的色阶**与绘图区用同一个色阶对象**(见 §6 色阶服务),保证图与图例配色一致。
|
||||
- 因为它不在数据坐标系内,**永远不随拖动/缩放移动,不会与横坐标轴重叠**。
|
||||
- 刻度数值与原版对齐:用与数据相同的分级断点(原版色阶断点形如 17.105 / 25.937 / ...,等比/对数分布,见 §6)。
|
||||
|
||||
### 4.2 仅数据区缩放、坐标轴固定、轴值跟随(治 #3)
|
||||
|
||||
- 用 QwtPlot,坐标轴(`QwtPlot::xBottom` / `yLeft`)由 QwtPlot 框架绘制,位置固定。
|
||||
- 缩放/平移用 QwtPlot 内置:
|
||||
- `QwtPlotMagnifier`(滚轮缩放数据区)
|
||||
- `QwtPlotPanner`(拖动平移数据区)
|
||||
- 需要框选缩放可加 `QwtPlotZoomer`
|
||||
- 这些组件只改变**坐标轴的数值范围(scaleDiv)**,QwtPlot 自动重算刻度并重绘——**轴不动、轴上的数字跟着变**,与原版完全一致。
|
||||
- **原数据 = 四象限坐标系**(数据含正负,x/y 轴交叉于原点区域);**网格数据 = 单象限**(数据全正,原点在左下)。在 QwtPlot 里就是设置不同的轴范围与原点位置,不需要两套框架。
|
||||
|
||||
### 4.3 拖动不再花屏(治 #4)
|
||||
|
||||
- 当前"花屏"是手写 `QGraphicsView` 变换 + 重绘时机错误导致。改用 QwtPlot 后,重绘由 QwtPlot 的 replot 机制统一管理,不会出现脏区残留。
|
||||
- 大数据量等值线多边形若导致拖动卡顿:对 `QwtPlotItem` 开启 `QwtPlotItem::RenderAntialiased` 视情况关闭、并启用绘制缓存;必要时对多边形做 LOD 简化(与原版"简化容差"滑块一致,见 §7)。
|
||||
|
||||
### 4.4 纵横比 / 宽度修正(治 #9)
|
||||
|
||||
- 原版图是"宽扁"的剖面图(x 范围远大于 y)。当前实现"偏瘦"是因为没有锁定数据纵横比、或 widget 尺寸策略错误。
|
||||
- 解法:根据数据的 x/y 跨度设定数据区的纵横比;若要求等比例(1 个数据单位 x = 1 个数据单位 y 的像素),自定义布局在 replot 时按数据范围调整 canvas 的宽高比,或固定 y 轴范围、x 轴自适应填充。
|
||||
- 先对齐原版的"宽扁"观感即可,不必强制物理等比,除非业务要求等比例尺。
|
||||
|
||||
### 4.5 连续插值色阶,对齐 Plotly 配色(治 #2)
|
||||
|
||||
- 原版散点/等值线用的是**连续插值色阶**(Plotly 的 colorscale),当前实现用了**离散色带**,所以配色不同。
|
||||
- 用 `QwtLinearColorMap`,设置与原版相同的**控制点颜色 + 位置**,开启连续插值(`QwtLinearColorMap` 默认 `ScaledColors` 连续模式,不要用 `FixedColors`)。
|
||||
- 控制点取自原版色阶(紫蓝→蓝→青→绿→黄→橙→红→深红的彩虹序列)。**必须从原版抓取实际色值**(见 §6 第 4 步),不要凭记忆配色。
|
||||
- 色阶分布是**对数/等比**的(断点 17.105, 25.937, 34.382... 比值递增),映射时对数据取 log 再线性映射,否则颜色分布会与原版不同。
|
||||
|
||||
### 4.6 等值线数值标注(治 #5)
|
||||
|
||||
- 等值线(contour line)由 `vtkContourFilter` 在各分级阈值生成线,取出线的折点。
|
||||
- 在 QwtPlot 中用自定义 `QwtPlotItem` 绘制这些线,并在线的合适位置绘制数值标签(label):沿线取中点或曲率较小处,绘制旋转贴合线方向的文本(原版标签是贴着等值线方向旋转的)。
|
||||
- 受工具条「显示等值线标注」复选框控制开关。
|
||||
- 「显示等值线提示信息」控制 hover 时的 tooltip(鼠标移到等值线上显示数值),与原版一致。
|
||||
|
||||
### 4.7 页签切换样式归位(治 #6)
|
||||
|
||||
- 「原数据 / 网格数据」切换**不是两个按钮**,而是与「数据集面板的 数据/文件」「右栏的 异常/对象属性」**同一种下划线页签样式**(之前已专门调过样式,复用那套 QSS/组件,不要重写成 QPushButton)。
|
||||
- 直接复用既有的下划线 Tab 组件类,保证全客户端切换样式统一。
|
||||
|
||||
### 4.8 工具条归位到网格数据面板内(治 #7)
|
||||
|
||||
- 「显示异常 / 显示等值线标注 / 显示等值线提示信息 / 简化容差滑块 / 网格 / 色阶配置 / 白化 / 滤波处理 / 异常标注 / 自动标注 / 另存为」这些**属于网格数据视图的工具条**,必须在网格数据面板**内部**的工具条上,不能浮到外层。
|
||||
- 「原数据」页签的工具条是另一套(网格 / 色阶配置 / 当前图形下拉 / 另存为),各自归属各自页签。
|
||||
|
||||
### 4.9 下方补「描述」页签(治 #8)
|
||||
|
||||
- 图下方区域是一个含两个页签的容器:**「异常列表」+「描述」**,当前只做了异常列表表格,缺「描述」。
|
||||
- 补上「描述」页签(富文本/纯文本说明区),与原版结构一致。
|
||||
|
||||
---
|
||||
|
||||
## 5. 组件清单与职责(定死,不要改结构)
|
||||
|
||||
| 类 | 基类 | 职责 |
|
||||
|---|---|---|
|
||||
| `DatasetDetailPanel` | QWidget | 数据集详情页根容器:顶部下划线页签(原数据/网格数据)+ 内容区 + 下方(异常列表/描述)|
|
||||
| `RawDataChartView` | QWidget | 「原数据」散点图:工具条 + QwtPlot(四象限)+ ColorBar |
|
||||
| `GridDataChartView` | QWidget | 「网格数据」等值线图:工具条 + QwtPlot(单象限)+ ColorBar |
|
||||
| `ContourPlotItem` | QwtPlotItem | 自定义:绘制 VTK 算出的分层填充多边形 + 等值线 + 标注 |
|
||||
| `ScatterPlotItem` | QwtPlotItem / QwtPlotSpectroCurve | 散点,按值连续着色 |
|
||||
| `ColorBarWidget` | QWidget | 独立色阶条(固定,不入数据坐标系)|
|
||||
| `ColorMapService` | (单例/服务) | 全局色阶:断点、颜色、命名保存、对数映射;图与图例同源 |
|
||||
| `ContourEngine` | (封装 VTK) | 输入网格数据 → 输出分层多边形 + 等值线折点(封装 vtkBandedPolyDataContourFilter / vtkContourFilter)|
|
||||
|
||||
---
|
||||
|
||||
## 6. 色阶服务(对齐原版的关键)
|
||||
|
||||
色阶是"配色一致"的命门,单独抽成服务,**图、图例、3D 视图共用同一份**(呼应视觉设计规范 §8):
|
||||
|
||||
1. **断点(分级阈值)**:采用原版的对数/等比分布。原版断点示例:`17.105, 25.937, 34.382, 35.972, 42.555, 49.051, 56.312, 68.950, 83.029, 98.454, 110.570, 129.660, 158.270, 204.160, 260.820, 321.710, 1335.500`(不同数据集断点不同,应由数据的 min/max + 分级规则动态生成,规则与原版一致)。
|
||||
2. **颜色控制点**:彩虹序列(深蓝 → 蓝 → 亮蓝 → 青 → 浅绿 → 绿 → 黄绿 → 黄 → 土黄 → 橙 → 黄 → 红 → 深红 → 紫红 → 紫 → 深紫黑)。
|
||||
3. **映射方式**:连续插值(`QwtLinearColorMap` ScaledColors)+ 对数标度(数据先 log,再线性映射到 [0,1])。
|
||||
4. **必须从原版抓真实色值**:打开原版页面 `http://tenant.geomative.cn/#/projectSpace/datasetMange/datasetInfo?id=...&ddCode=dd_inversion_data`,用浏览器开发者工具检查 Plotly 的 `colorscale` 配置,或对色阶条截图取色(每个色块取中心像素 RGB)。**严禁凭记忆配色** —— 这是 #2 反复出错的根源。
|
||||
5. **命名保存**:色阶可命名、保存、切换,调整后图与图例实时刷新(业务规约要求)。
|
||||
|
||||
---
|
||||
|
||||
## 7. 工具条功能对照(两个页签各一套)
|
||||
|
||||
**原数据页签工具条**:网格 | 色阶配置 | 当前图形(下拉:散点图/...)| 另存为
|
||||
|
||||
**网格数据页签工具条**:网格 | 色阶配置 | 白化 | 滤波处理 | ☑显示异常 | ☑显示等值线标注 | ☐显示等值线提示信息 | 简化容差(滑块,0–1)| 异常标注 | 自动标注 | 另存为
|
||||
|
||||
- 「简化容差」滑块:控制等值线多边形的简化程度(值越大,多边形越简化、越平滑),对应 VTK 侧对多边形做 `vtkDecimatePolylineFilter` 或道格拉斯-普克简化的容差参数。
|
||||
- 「白化」「滤波处理」是对网格数据的预处理操作,作用于 ContourEngine 的输入。
|
||||
- 「自动标注」弹出对话框(见 §8)。
|
||||
|
||||
---
|
||||
|
||||
## 8. 自动标注对话框(复用同一图表组件)
|
||||
|
||||
- 「自动标注」弹出的对话框里那张图,**必须与主页面网格数据图完全一致**——做法是**复用同一个 `GridDataChartView` / `ContourPlotItem` 类的另一个实例**,传入同一份数据 + 同一个 ColorMapService,**不要另写一套渲染**(否则又会出现两张图不一致)。
|
||||
- 对话框结构:左侧「异常判定规则」(阈值模式 数值/百分位、最小点数、异常类型)+ 右侧统计信息(最大/最小/均值/中位数)+ 图(同主图)+ 自动标注结果列表 + 底部(取消/执行自动标注/确认保存)。
|
||||
- 因为是普通 QWidget(QwtPlot 而非 VTK 窗口),嵌进对话框无任何 OpenGL 上下文问题,开关对话框不会闪烁/泄漏。
|
||||
|
||||
---
|
||||
|
||||
## 9. 实施顺序(建议分步验收,避免再次大返工)
|
||||
|
||||
> 每一步做完就和原版对照截图验收,通过再进入下一步。不要一次性全做完再看。
|
||||
|
||||
1. **搭三层骨架**:DatasetDetailPanel + 两个页签(下划线样式)+ 下方(异常列表/描述)。先不画图,验证 UI 结构对齐(治 #6/#7/#8)。
|
||||
2. **QwtPlot 接入 + 交互**:空 QwtPlot,配好固定坐标轴 + Magnifier/Panner,验证"拖动只动数据、轴值跟随、不花屏、纵横比对"(治 #3/#4/#9)。
|
||||
3. **ColorBar 独立**:加 ColorBarWidget 固定在下方,验证缩放时色阶条不动、不与轴重叠(治 #1)。
|
||||
4. **色阶服务 + 抓原版色值**:实现 ColorMapService,从原版抓真实色值与断点,连续对数映射(治 #2 的基础)。
|
||||
5. **散点图**:原数据页用 ScatterPlotItem 连续着色,对照原版配色(治 #2)。
|
||||
6. **等值线图**:ContourEngine(VTK 算多边形)+ ContourPlotItem 绘制填充 + 等值线 + 标注(治 #2/#5)。
|
||||
7. **工具条联动**:显示异常/等值线/提示、简化容差滑块、白化、滤波接通。
|
||||
8. **自动标注对话框**:复用图表组件 + 规则面板 + 统计 + 结果。
|
||||
9. **整体对照验收**:原数据、网格数据分别与原版并排截图,核对配色/布局/交互/标注/纵横比。
|
||||
|
||||
---
|
||||
|
||||
## 10. 验收标准(写清楚,避免预期错位)
|
||||
|
||||
**通过标准 = 视觉等价 + 交互一致**:
|
||||
|
||||
- [ ] 配色与原版视觉一致(同一色阶、连续插值、对数分布)
|
||||
- [ ] 坐标轴固定,拖动/缩放只动数据区,轴刻度值跟随
|
||||
- [ ] 色阶条固定在轴下方,不随图缩放,不与坐标轴重叠
|
||||
- [ ] 等值线数值标注显示,贴合线方向
|
||||
- [ ] 原数据=四象限、网格数据=单象限,缩放/移动行为分别与原版一致
|
||||
- [ ] 纵横比/宽扁观感与原版一致
|
||||
- [ ] 拖动流畅、无花屏
|
||||
- [ ] 页签切换为下划线样式(与数据/文件、异常/属性一致)
|
||||
- [ ] 工具条开关位于各自页签面板内
|
||||
- [ ] 下方含「异常列表 + 描述」两个页签
|
||||
- [ ] 自动标注对话框内图与主图完全一致
|
||||
|
||||
**不作为通过标准**(明确排除,避免无意义返工):
|
||||
|
||||
- ✗ 与 web 端逐像素 diff 相同(抗锯齿/字体/亚像素天生有别,任何原生方案都无法做到,非缺陷)。
|
||||
|
||||
---
|
||||
|
||||
## 11. 一句话方向
|
||||
|
||||
> 不嵌 Chromium。推翻 QGraphicsView,用 **QwtPlot(坐标系/交互/图例)+ VTK 算法(等值线多边形)+ 连续对数色阶(对齐原版)**,三层分离(绘图区 / 固定坐标轴 / 独立色阶条),UI 三处(页签样式/工具条归位/描述页)按原版恢复。验收按"视觉等价 + 交互一致",非字节级。
|
||||
|
||||
---
|
||||
|
||||
*本方案针对当前返工,钉死架构与分层,禁止在图表渲染框架上再自由发挥。未覆盖的细节先确认再实现。*
|
||||
|
|
@ -0,0 +1,225 @@
|
|||
# 交接文档:数据集详情图表 + 全 App 网络层异步化
|
||||
|
||||
> 给下一个会话:读完本文件即可无缝接手。最后更新 2026-06-12。
|
||||
> 分支 **`feat/dataset-detail-chart`**,领先 main 68 commits。**测试 122/122 全绿**。
|
||||
> ⚠️ **工作区脏**:sessions 0.1–0.5 的代码(散点 hover/日志崩溃捕获/colormap/数据集树/按根分页/暗色主题)**全部未提交**(用户多次选「保持现状」不提交不合并)。`git status` 一大堆 M/??,**别假设干净树**;接手前先 `git status` 看清。新增未跟踪文件:`src/app/Logging.*`、`src/app/panels/chart/{ScatterHoverTip,ChartTheme}.*`、`tests/app/test_scatter_hover.cpp`,以及若干 `*.jpeg/*.yml` 临时抓图/抓取产物(非源码,可忽略/清理)。
|
||||
|
||||
---
|
||||
|
||||
## 0. 一句话现状
|
||||
|
||||
geopro(Qt6/C++ 离线桌面客户端,1:1 复刻赛盈地空 web)已完成两大块:
|
||||
1. **数据集详情图表**(仅 ERT 反演 `dd_inversion_data`,QwtPlot 落地,用户已验收)。
|
||||
2. **全 App 网络层 100% 异步化**(详情/导航/登录/项目列表全异步,同步 `QEventLoop` 阻塞路径已彻底删除,无技术债)。
|
||||
|
||||
均**未合并入 main**,分支挂起等收尾。
|
||||
|
||||
### 0.1 2026-06-12 渲染保真修复(本次会话)
|
||||
|
||||
用户报客户端散点/网格颜色与原版差异大(大量点透明发白、图例只有 6 色 + 白缝)、散点缺 hover。经 Playwright 抓原版真实 API + 读源码定位为**通用根因**:
|
||||
|
||||
- **colorBar alpha 标度 bug**(核心):`lvl/colorGradation/getDetail` 返回的 colorBar 是**混合格式**——hex `#00008B` 与 CSS `rgba(0, 0, 170, 1)`(**alpha 是 0–1 浮点**),共 18 段。`DatasetChartDto.cpp` 用 `AlphaScale::Bit255` 解析 → rgba 的 `a=1` 被当字节 → alpha≈1/255 近透明(12 个 rgba 段全成白缝;6 个 hex 段因 hex 分支强制 255 才可见)。**修复:`Bit255`→`Unit`**(一行)。散点连续插值 `ColorMapService` 的归一化位置 `val/maxVal` 已证**精确等于原版 Plotly colorscale 位置**,无需改。
|
||||
- **散点 hover**:新增 `ScatterHoverTip`(canvas 事件过滤器,与 LivePanner 共存),最近点命中显示 `X/Y/值`(各 3 位小数,对齐原版 Plotly hovertemplate `<b>X:</b> %{x:.3f}…`)。
|
||||
- 文件:改 `src/data/dto/DatasetChartDto.cpp`;新增 `src/app/panels/chart/ScatterHoverTip.{hpp,cpp}` + 接线 `RawDataChartView.{hpp,cpp}`;测试 `tests/data/test_dataset_chart_dto.cpp`(改用真实混合格式 + 断言 alpha=255 回归)、新增 `tests/app/test_scatter_hover.cpp`。**测试 116→118 全绿**,cpp-reviewer APPROVE。
|
||||
- ⚠️ **像素级视觉 1:1 待用户在运行的 app 内登录核对**(原生 Qt 窗口无法用 Playwright 驱动;以下各层已机器验证:抓包/源码/RED-GREEN/插值位置匹配)。
|
||||
- 观察(未改,待定):图例 `ColorBarWidget` 画 `stops-1=17` 段,丢弃最后一段最深色;原版疑为 18 段。非本次报障,留待用户决定是否补齐。
|
||||
|
||||
### 0.2 2026-06-12 第二轮修复(散点 cauto 归一化 + hover mouseTracking + 页签名)
|
||||
|
||||
第一轮 alpha 修复后颜色不再透明,但散点仍与原版不一致(挤在暖色中段、无蓝无紫)。复查 Plotly `_fullData`:散点 **`cmin/cmax` 未设 → `cauto` 自动取 vlist 实际 min/max(本例 45.93–197.79)**,把整段色阶铺满数据范围;而客户端 `ColorMapService` 错用 colorBar 全程 0–1323 归一化数据 → 压进色阶 0.03–0.15 段。
|
||||
|
||||
- **修复 A(散点 cauto)**:`ColorMapService.setDataRange(dataMin,dataMax)` 解耦「色阶形状位置(按断点值)」与「数据值归一化(按数据 min/max)」;`RawDataChartView::setData` 按 `d.scatter.v` 有限值 min/max 调用。**数值验证 1:1**:手算修复后客户端对样本值的 RGB 与原版 Plotly 实测逐字节相同(如中位 92.01→[168,0,35]、62.34→[255,119,0])。网格仍用绝对阈值(`colorAtDiscrete`),不变。
|
||||
- **修复 B(hover 仍无效)**:根因 `QwtPlotCanvas` 默认不开 mouseTracking → 无按键时收不到 MouseMove。`ScatterHoverTip` 构造里加 `canvas->setMouseTracking(true)`。
|
||||
- **修复 C(页签用数据名)**:`ChartData` 加 `dsName`;`openDataset` 加可选第 3 参 `dsName`(默认空,向后兼容测试);`kDsNameRole` 存 dsName,`main.cpp` 双击传入,`DatasetDetailPanel` `addTab` 用 `dsName`(空回退 dsId)+ tooltip。
|
||||
- 文件:`ColorMapService.{hpp,cpp}`、`RawDataChartView.cpp`、`ScatterHoverTip.cpp`、`DatasetDetailController.{hpp,cpp}`、`DatasetListPanel.{hpp,cpp}`、`DatasetDetailPanel.cpp`、`main.cpp`;测试 `test_colormap_service.cpp`(+DataRangeDecouples)。**119/119 全绿**。
|
||||
- ⚠️ hover/页签名/视觉仍待用户运行核对(GUI 无法自动驱动)。
|
||||
|
||||
### 0.3 2026-06-12 桌面端日志 + 崩溃捕获(新基础设施)
|
||||
|
||||
用户反馈:双击 ds 后状态栏报「内部系统错误」然后客户端崩溃;后端错误可接受,但客户端不该崩。复测发现 scatter/color 接口当时其实 200/干净数据 → 无日志根本无从定位。遂加生产级日志 + 崩溃捕获(已实测打通)。
|
||||
|
||||
- **`src/app/Logging.{hpp,cpp}`**:`initLogging()`(main.cpp 在 setApplicationName 后调用)。
|
||||
- `qInstallMessageHandler` 接管全 App `qDebug/qInfo/qWarning/qCritical/qFatal` → 写带时间戳/级别的滚动日志:`%LOCALAPPDATA%/Geomative/Geopro3/logs/geopro_YYYYMMDD.log`(按天,UTF-8+BOM,启动清理 14 天前旧文件)。
|
||||
- **崩溃捕获**:`SetUnhandledExceptionFilter`(SEH,含未捕获 C++ 异常 0xE06D7363)+ `std::set_terminate`;崩溃时 `MiniDumpWriteDump` 写 `crash_*.dmp`(链 `Dbghelp`)+ 记一行 `[FATAL] 崩溃 code=… addr=… dump=…`。`SetErrorMode` 抑制系统弹窗。**已用临时 env 自检实测**:空指针崩溃 → 生成 6MB dmp + 日志记录 code=0xc0000005(自检块已删)。
|
||||
- dmp 可 VS/WinDbg 加载看完整调用栈。
|
||||
- **埋点**:`ApiCall::onFinished` 记录每个 API 响应(URL/http/code/err,成功 INFO/失败 WARN);`DatasetDetailController` openDataset + 各 loadFailed;App 启动版本/路径。net 层 `qWarning` 自动收录。
|
||||
- **顺带堵崩溃根因**(评审 H-2 + 防脏数据):`ColorMapService::colorAtContinuous` 对 NaN/Inf 的 t 回退首断点色(原会 `upper_bound` 返回 end() 后解引用越界崩溃);`RawDataChartView` vlist min/max 用 `isfinite` 跳过 NaN/±Inf。+ 单测 `ColorAtContinuousNaNSafe`。
|
||||
- **测试 119→120 全绿**。
|
||||
### 0.4 2026-06-12 崩溃定位 + 顶层异常护栏
|
||||
|
||||
用户提供 dump + log。日志序列:`openDataset(ERT2-WS_result.dat)` → **`dynamicForm` 后端返回 `code=500 sys.internalServerError`**(即"内部系统错误")→ scatter 200 → color 200 → `Qt has caught an exception thrown from an event handler` → 崩溃。
|
||||
|
||||
**dump 分析(winget 装 Microsoft.WinDbg,`!analyze -v`)**:异常 `e06d7363`(C++ EH),栈 `VCRUNTIME140!CxxThrowException ← msvcp140!std::_Xlength_error ← geopro_desktop+0x9ad0 ← +0xa94ee ← +0x24b71 ← +0x235c0`。即 **`std::length_error`**(STL 容器/字符串用非法 size resize/reserve/构造)。栈帧都在 geopro_desktop(含静态链接的 Qwt),但**该 dump 是旧构建、当前 PDB 已不匹配 → 符号化不出函数名**。静态排查 dynamicForm 解析 / DynamicFormView / selectDataset 失败路径 / 散点解析(已 try/catch) / 渲染 / ColorMapService 均未见明显抛点 → 确切行**待运行期护栏日志点名**。
|
||||
|
||||
**修复(主,根治"不该崩")**:`main.cpp` 加 `GuardedApplication : QApplication`,override `notify()` try/catch:任何 slot/事件处理器抛出的异常被拦截 + `qCritical` 记录 `[guard] 拦截... what | type | receiver=<对象类名> | event=<事件类型>`,然后吞掉(不终止)。后端故障等异常**不再使整个客户端退出**。
|
||||
**附带**:修 `appendCrashLine` 文件共享 bug(崩溃行原写不进日志——g_logFile 独占,第二句柄 open 失败;改为复用已打开句柄);`colorAtContinuous` NaN/Inf 守卫。
|
||||
|
||||
**护栏实测**:用户复现,护栏拦到 → 日志 `[guard] 拦截未捕获异常: vector too long | type=std::length_error | receiver=QNetworkReplyHttpImpl | event=43(MetaCall)`。即异常在**网络响应 finished 同步链**里(最后一个 chart 请求 color 返回 → `chartReady→openOrUpdate→渲染`)抛出 `std::length_error`("vector too long" = std::vector 用了非法尺寸,典型负数转 size_t)。进程**未崩**(护栏吞掉),但图表没渲染出来。
|
||||
|
||||
**诊断基础设施(关键补强)**:
|
||||
1. **Release 构建之前不生成 PDB** → dump/崩溃栈对自家代码全部符号化失败(只有系统 DLL 导出表能解析)。已在根 `CMakeLists.txt` MSVC 块加 `/Zi`(编译) + `/DEBUG /OPT:REF /OPT:ICF`(链接):Release 保持优化的同时产出匹配 PDB。**生产桌面端排障必需。**
|
||||
2. **VEH 抛点堆栈符号化**(`Logging.cpp`):`AddVectoredExceptionHandler` 在 C++ 异常(0xE06D7363)**抛出瞬间**(栈未展开、PDB 匹配)用 DbgHelp(`SymInitialize`+`SymLoadModuleExW`+`SymFromAddrW`+`SymGetLineFromAddrW64`,**宽字符**——UNICODE 下必须)打印 `模块+RVA 函数名+偏移 (文件:行)`。自测验证:`reserve(-1)` → 正确打印 `geopro::app::initLogging (Logging.cpp:245)`。即使异常被护栏吞掉也留下抛点栈。
|
||||
|
||||
**下一步定位 length_error 确切行**:用当前构建复现一次 → 日志 `[THROW]` 段直接给出 `geopro::app::<类>::<方法> (文件:行)`。**测试 120/120 全绿。**
|
||||
|
||||
### 0.5 2026-06-12 数据集列表树化 + 按根分页 + 暗色主题保真(本次会话)
|
||||
|
||||
用户三组报障,均已修复、构建链接通过、应用真实流程跑通无崩溃、**测试 120→122 全绿**:
|
||||
|
||||
**(1) 数据集列表应是树(原为平铺)**
|
||||
- **实地确认(铁律 Playwright)**:原版「数据管理」选 TM 后的数据列表是 **el-table tree-data**(有展开箭头)。脚本直连 API 验证 TM E3(项目 1458977804960256 垃圾掩埋場):10 个 DS → **4 个根**(默认折叠)= 3×源「ERT原始数据」+ 1×「ERT电极坐标」;派生「反演/接地电阻」按 `parentId`(==`sourceShowParentId`)挂源「原始数据」下。根 = parentId 为空或指向不在本批的「源文件节点」。
|
||||
- **改**:`DsRow` 加 `parentId`;`NavDto::parseDsRows` 解析 `sourceShowParentId`(回退 `parentId`);`DatasetListPanel::populateDatasetList` 由 `QListWidget` 改建 **`QTreeWidget`**(两遍法按 parentId 嵌套,卡片委托 `applyDatasetCardDelegate` 泛化到 `QAbstractItemView`);`main.cpp` `datasetList` 改 `QTreeWidget`(`setExpandsOnDoubleClick(false)`:双击=开详情、展开靠箭头),点击/双击/反向高亮/加载更多 4 处改 `QTreeWidget` API。文件页签仍平铺 `QListWidget`。默认折叠(对齐原版)。
|
||||
- 测试:`test_nav_dto.cpp::ParseDsRowsParentIdForTree`。
|
||||
|
||||
**(2) 分页应按「第一层节点(根)」算(原按扁平 DS)**
|
||||
- **根因**:后端 `dsObject/data/page` 按**扁平 DS** 分页(脚本验证:total=10、pageSize=5 返回 5 条扁平行)——子节点的父常落在下一页 → 按页建树出孤儿根、首层数错乱。
|
||||
- **改**:`IAsyncProjectRepository::loadRowsAsync` 加 `int pageSize=5` 参数(接口/`ApiProjectRepository`/测试 stub 同步);`WorkbenchNavController::selectObject` 数据页改用大 pageSize(`kFetchAllPageSize=1000`)**一次取全**整棵,缓存 `allDataRows_`;新私有 `emitNextDataRootPage(bool append)` **客户端按根切页**(每页 `kDataRootPageSize=5` 个根 + 各自整棵子树,DFS 收集后按原序输出,`total`=根总数);`loadMoreData` 改同步切下一页(无请求);`dataRootsShown_` 游标,`resetSelectionState` 清空。删因此空置的 `moreDataReq_`。`main.cpp` `addTreeLoadMore` 计数改按顶层根。若 `listCount<total`(pageSize 不足取全)`qWarning` 告警。
|
||||
- 测试:`test_workbench_nav_controller.cpp::DataPaginatesByRootNodeNotFlatCount`(6 根→首页 5 根+子=7 行/total=6,续页第 6 根;用 `qRegisterMetaType<std::vector<DsRow>>` 读 spy 行参)。
|
||||
|
||||
**(3) 暗色主题图表样式未跟随**
|
||||
- 原详情图 `QwtPlot`、`ColorBarWidget`、`LoadingOverlay` 全硬编码白底/浅色 → 暗色主题下刺眼白底/白蒙板。**原版 web 无暗色,故暗色为客户端自定**:**浅色分支保持原硬编码值=与原版 1:1 不动,仅暗色改 token**。
|
||||
- 新增 **`src/app/panels/chart/ChartTheme.{hpp,cpp}`** `applyChartPlotTheme(QwtPlot*)`:按 `isDarkTheme()` 设画布底色(`bg/panel`)/轴字(`text/secondary`)/网格(`border/default`)/零线(`border/strong`),遍历 itemList 重着色 grid/marker。`Raw/GridDataChartView` 删硬编码白块、ctor 末尾调用 + 连 `ThemeManager::changed` 热切换。
|
||||
- `ColorBarWidget::paintEvent` 底色/边框/刻度字按 `isDarkTheme()`(暗色 `bg/panel`/`border/strong`/`text/secondary`;色带格=数据色不变)+ ctor 连 changed→update。
|
||||
- `LoadingOverlay` 遮罩纱色由 `rgba(255,255,255,160)` 改按主题(暗色 `bg/app` 深纱)+ label 文字 `text/primary`,连 changed 热切换。
|
||||
- token 表见 `src/app/Theme.cpp`(`bg/panel` 白/`#161A20`、`text/secondary`、`border/*`)。
|
||||
|
||||
**新文件**:`src/app/panels/chart/ChartTheme.{hpp,cpp}`(已加 `src/app/CMakeLists.txt`)。
|
||||
**改动文件**:`RepoTypes.hpp`、`NavDto.cpp`、`IAsyncProjectRepository.hpp`、`ApiProjectRepository.{hpp,cpp}`、`WorkbenchNavController.{hpp,cpp}`、`DatasetListPanel.{hpp,cpp}`、`main.cpp`、`RawDataChartView.cpp`、`GridDataChartView.cpp`、`ColorBarWidget.cpp`、`LoadingOverlay.cpp`、`src/app/CMakeLists.txt`、`tests/{data/test_nav_dto,controller/test_workbench_nav_controller}.cpp`。
|
||||
**记忆新增**:`dataset-list-is-tree`(树结构 + 扁平分页坑 + 按根分页解法 + 暗色图表方案)。
|
||||
- ⚠️ **待用户运行核对(GUI 无法自动驱动)**:① 暗色下网格详情的「加载中…」蒙板是深纱、色阶条深底浅字;② 数据列表按 5 根分页(根多的 TM 才出「加载更多」)+ 树嵌套正确。机器侧已验证:脚本抓原版结构 + 构建 + 122 测试 + 真实登录流程日志跑通(项目 1458977804960256→E3→data/page→getDetail→dynamicForm,无崩溃)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景
|
||||
|
||||
- geopro = **Qt6 / C++ 离线桌面客户端**,目标**像素级 1:1 复刻** web 系统 `http://tenant.geomative.cn/#/projectSpace/datasetMange/datasetInfo`(赛盈地空)。
|
||||
- **硬约束**:禁 QWebEngine/Chromium(离线)。图表用本地 **QwtPlot(轴/交互/图例)+ VTK 算法层(等值线几何)+ 连续/离散色阶**。验收 = **视觉等价 + 交互一致**(非字节级 diff)。
|
||||
- **标准测试 ds**:`id=1458990939709440`(ddCode=dd_inversion_data)。
|
||||
- 站点 baseUrl:`http://tenant.geomative.cn/pop-api`。Auth header:`geomativeauthorization: Geomative <token>`。
|
||||
|
||||
---
|
||||
|
||||
## 2. 技术栈与构建/测试
|
||||
|
||||
- Qt6 Widgets + **Qwt 6.2**(源码 `external/qwt-src/`,**用 `cmake/qwt.cmake` 以 CMake 构建**静态库,**不要用 qmake**——本机 VS2026 缺 vswhere)。
|
||||
- **VTK 9.6 仅算法层**(`vtkBandedPolyDataContourFilter`/`vtkStripper`/`vtkSplineFilter`/`vtkDataSetSurfaceFilter`),不做 VTK 渲染。
|
||||
- CMake(VS 自带)+ Ninja + MSVC 14.51;GoogleTest/CTest;vcpkg(仅非 Qt 依赖)。
|
||||
- **构建**:`powershell.exe -ExecutionPolicy Bypass -File scripts/dev-build.ps1`
|
||||
- ⚠️ `LNK1104 无法打开 geopro_desktop.exe` = exe 在运行:先 `Get-Process geopro_desktop -ErrorAction SilentlyContinue | Stop-Process -Force` 再构建。
|
||||
- ⚠️ **改头文件后偶发** ninja 增量陈旧导致链接报「符号在 main.cpp.obj 重复定义」:删 `build/release/src/app/CMakeFiles/geopro_desktop.dir/main.cpp.obj` 后重建。
|
||||
- **测试**:`powershell.exe -ExecutionPolicy Bypass -File scripts/dev-test.ps1`(**只跑 ctest 不构建——必须先 dev-build**)。当前 **116/116 绿**。
|
||||
- 单测过滤:`build/release/tests/geopro_tests.exe --gtest_filter=ApiBatch.*`
|
||||
- ⚠️ 直接跑 exe 时 4 个 `CrsTransform/VoxelRegister/Terrain` 失败是缺 `PROJ_DATA` 环境项;**ctest(dev-test)会注入环境、全绿**——以 dev-test 的 "X/X passed" 为准。
|
||||
- **网络/署名**:AuthLiveTest 联网真打站点;提交信息**全局禁用署名**(勿加 Co-Authored-By)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 第一块:数据集详情图表(已完成,仅 dd_inversion_data)
|
||||
|
||||
详情含两页签,均经用户逐项验收:
|
||||
- **原数据**:Plotly scattergl 风格散点(方形点/白描边/连续色阶/x 轴顶部)。
|
||||
- **网格数据**:填充等值面(栅格 QImage)+ 黑色等值线(vtkStripper+vtkSplineFilter 样条平滑,**每条线只标一个**数值)+ NaN 白边裁剪 + 底部异常表/描述。
|
||||
|
||||
数据 API(均经 Playwright 实测):
|
||||
| 页 | 接口 |
|
||||
|---|---|
|
||||
| 原数据 | `lvl/colorGradation/getDetail`(type1) + `dd/ert/inversion/getErtRawDataScatterGraph/{id}` |
|
||||
| 网格 | `dd/ert/inversion/rows/{id}`(服务端网格化,**波动 1–4s**) + `colorGradation/getDetail`(type2) + `exception/queryException/{id}` |
|
||||
|
||||
> ⚠️ 架构偏离:原 spec 定 QGraphicsView,**实际落地用 QwtPlot**(见 `plans/2026-06-11-dataset-detail-chart-v2-qwt.md`,权威)。
|
||||
|
||||
**策略分派已打通**(本次会话):控制器从硬编码 `dd_inversion_data` 改为走 `ChartStrategyRegistry`(在 **controller 层** `src/controller/IDatasetChartStrategy.hpp`,含 `hasGridPhase()`),未注册类型优雅降级「暂不支持」。`ErtInversionStrategy`(app 层)已注册。**这是接其余 dd 类型的地基**。
|
||||
|
||||
---
|
||||
|
||||
## 4. 第二块:全 App 网络层异步化(本次会话完成,无技术债)
|
||||
|
||||
**动机**:原 `ApiClient` 用 `QEventLoop` 死等每个请求 → 全 App 冻 UI(网格 rows 1–4s 最痛)。
|
||||
|
||||
**核心安全不变量(spec §5.0,务必遵守)**:「abort 后绝不回灌」靠三件套——
|
||||
1. 每层 `aborted_` 入口守卫(`disconnect` 只是尽力而为,挡不住已入队的迟到信号);
|
||||
2. 控制器**句柄身份比对**(`if (load != current_) return;` 丢弃迟到信号);
|
||||
3. **一律 `deleteLater`**,禁止同步 delete。
|
||||
错误判定口径:业务 `code != 200 || !rawError.isEmpty()`(HTTP 200 也可能 code=500)。
|
||||
|
||||
**net 层原语**(`src/net/`,AUTOMOC 已 ON):
|
||||
- `IApiCall`/`ApiCall`:单请求句柄(包 QNetworkReply),`finished(ApiResponse)` + `abort()`,自管理 deleteLater。
|
||||
- `ApiBatch`:**并发汇聚** N 个 IApiCall,全成功 `succeeded(QList)` / 任一失败 **fail-fast** `failed(i,resp)`+abort 其余。
|
||||
- `ApiChain`:**串行依赖链**(上步结果喂下步工厂,工厂可抛转 failed)。**契约:首个 step 工厂不得同步抛/同步 fire**(否则信号在调用方连接前丢失;生产路径都发异步请求,满足)。
|
||||
- `ApiResponseParse::buildResponse`:sync/async 共用解析(已无 sync 调用方,仅 ApiCall 用)。
|
||||
- `ApiClient`:仅 `getAsync/postJsonAsync`(**同步 `get/postJson`/`await`/QEventLoop 已删除**)。
|
||||
|
||||
**data 层**:
|
||||
- 详情:`ChartLoad`/`GridLoad`(抽象基 + `ApiChartLoad`/`ApiGridLoad` 实现,包 ApiBatch + 注入 parse)、`IAsyncDatasetRepository`、`ApiDatasetRepository.loadChartAsync/loadGridAsync`、`DatasetLoads.hpp`(ChartParts/GridParts)。
|
||||
- 导航:`NavRequest`(单非模板句柄,`done(QVariant)`/`failed(QString)`;`ApiNavRequest` 包 IApiCall + 解析器)、`NavLoads.hpp`(各类型 `Q_DECLARE_METATYPE`)、`IAsyncProjectRepository`(9 方法 `...Async` 后缀,返回 `NavRequest*`,薄封装;汇聚/链编排放控制器)、`ApiProjectRepository` 仅实现异步接口。
|
||||
|
||||
**controller 层**:
|
||||
- `DatasetDetailController`:abort-and-replace + 句柄身份比对 + `loadStarted(dsId,Phase)` + 析构 abort;走 `ChartStrategyRegistry` 分派。
|
||||
- `WorkbenchNavController`:全异步(NavRequest 续延依赖链 + 控制器内并发计数 + abort-and-replace + 身份比对);**删除 `busy_`/`BusyGuard`/`drainPendingCheckedTms`**;`busyChanged(bool)` 语义改为「有在飞句柄」(去抖);`setCheckedTms` 入口去重 + 「最后一次为准」由 abort-and-replace 承接 + `tmExceptionCache_` 缓存命中不发请求。对外信号面**零改动**(main.cpp 接线没动)。
|
||||
|
||||
**app 层**:
|
||||
- `AuthService`(net 层):`fetchCaptchaAsync()→CaptchaLoad`、`loginAsync()→LoginLoad`(内用 ApiChain:verifyCodeCheck→RSA(step2 工厂内)→login2)。`LoginWindow`:不冻 + 可取消(析构 abort),删 `repaint()` hack。
|
||||
- `ProjectListDialog`:改用 `IAsyncProjectRepository`(NavRequest + abort-and-replace + 身份比对 + 析构 abort),过滤/分页/选项目行为等价。
|
||||
- `LoadingOverlay`:网格懒加载「加载中」遮罩,接 `loadStarted`、就绪/失败隐藏。
|
||||
- `main.cpp`:`qRegisterMetaType<ApiResponse>()`;装配注入 registry/异步 repo(引用绑定,接线信号面不变)。
|
||||
|
||||
**执行方式**:全程 subagent-driven(每块 implementer + spec 符合度评审 + 代码质量评审 opus + follow-up 加固)。测试 75 → 116(+41,含 abort 回灌防护回归用例)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 关键文件地图
|
||||
|
||||
- net:`src/net/{IApiCall.hpp,ApiCall.*,ApiBatch.*,ApiChain.*,ApiResponseParse.*,ApiClient.*,AuthService.*,AuthLoads.*}`
|
||||
- data:`src/data/api/{DatasetLoads.hpp,DatasetLoadHandles.*,ApiDatasetRepository.*,NavRequest.*,NavLoads.hpp,ApiProjectRepository.*,DatasetChartDto.*}`、`src/data/repo/{IAsyncDatasetRepository.hpp,IAsyncProjectRepository.hpp,IDatasetRepository.hpp(本地样例同步,保留),RepoTypes.hpp}`
|
||||
- controller:`src/controller/{DatasetDetailController.*,WorkbenchNavController.*,IDatasetChartStrategy.hpp}`
|
||||
- app:`src/app/main.cpp`、`src/app/panels/{DatasetDetailPanel.*,DatasetDetailPage.*,LoadingOverlay.*,chart/*}`、`src/app/login/LoginWindow.*`、`src/app/ProjectListDialog.*`、`src/app/panels/chart/ErtInversionStrategy.hpp`
|
||||
- 测试:`tests/net/{FakeApiCall.hpp,test_api_batch.cpp,test_api_chain.cpp,test_auth.cpp(live),test_auth_loads.cpp}`、`tests/data/{test_nav_request.cpp,test_dataset_load_handles.cpp}`、`tests/controller/{test_dataset_detail_controller.cpp,test_workbench_nav_controller.cpp}`、`tests/app/test_chart_strategy_registry.cpp`
|
||||
|
||||
---
|
||||
|
||||
## 6. 尚未完成 / 下一步(按优先级)
|
||||
|
||||
### A. 收尾本分支(最先)
|
||||
分支领先 main 68 commits、**122/122 绿**,但**工作区脏**(sessions 0.1–0.5 代码全未提交,见顶部 ⚠️)。用户多次选「保持现状」未提交未合并。**先决**:本次会话两组改动(数据集树/按根分页、暗色主题)**待用户在运行的 app 内目视核对**(见 0.5 末尾清单)——核对通过再谈提交。下一步可问用户:①目视核对本次改动 ②提交这批未提交工作(建议按主题分多个 commit:详情图保真 / 日志崩溃捕获 / 数据集树+分页 / 暗色主题)③合并回 main(本地) ④推送建 PR(origin=gitea `https://gitea.geomative.cn/gaozheng/geopro.git`) ⑤保持现状。用 `superpowers:finishing-a-development-branch`。
|
||||
|
||||
### B. 其余 dd 类型详情图(计划已写,多数 BLOCKED)
|
||||
计划:`plans/2026-06-11-dataset-detail-other-dd-types.md`。
|
||||
- **Phase 1(策略分派打通):✅ 已完成**(本次会话)。
|
||||
- **Phase 0(样本探查):需做**——用 Playwright 登录原版 web,对每类 dd 找有数据对象、抓真实响应存 fixtures + 渲染规格。**这是后续所有类型的前置。**
|
||||
- **Phase 2(ERT 测量散点)/ Phase 3(TEM 折线,新 LineChartView)**:仅当 Phase 0 确认有样本才解锁。
|
||||
- **Phase 4:BLOCKED**——dd_grid/轨迹/测井/GPR,**当前租户无活数据样本**(GPR 对象无数据、无测井数据),按 1:1 复刻铁律不能凭空实现。
|
||||
- 矩阵详见计划文件。
|
||||
|
||||
### C. 工具条编辑类功能(网格页占位按钮,未做,单独立项)
|
||||
白化 / 滤波处理 / 色阶配置 / 异常框注 / 自动标注 / 网格化 / 另存为 / 导出 / 描述富文本 / 大视图(Esc)全屏。当前是占位按钮。交互较重,建议先 brainstorm 拆解。spec §2.3 列为范围外。
|
||||
|
||||
### D. 纯整洁 follow-up(非债、非阻断)
|
||||
- 删 `DatasetDetailController::ChartData.grid/gridScale` 死字段(从未填充,预存)。
|
||||
- 若未来引入 cross-thread/QueuedConnection,再补 `qRegisterMetaType<QList<ApiResponse>>()`。
|
||||
|
||||
---
|
||||
|
||||
## 7. 相关文档
|
||||
|
||||
- **spec**:`docs/superpowers/specs/2026-06-11-apiclient-async-design.md`(异步设计 + §5.0 安全不变量 + §7 错误判定/退出契约 + 顶部「状态更新」=已完成)、`docs/superpowers/specs/2026-06-11-dataset-detail-view-design.md`(详情视图,顶部状态=仅 ERT 反演完成 + QwtPlot 偏离)。
|
||||
- **plan**:`docs/superpowers/plans/2026-06-11-apiclient-async-datasetdetail.md`(详情试点)、`2026-06-11-apiclient-async-rollout.md`(导航 Part A + 登录 Part B,均落地)、`2026-06-11-dataset-detail-other-dd-types.md`(其余 dd 类型,Phase 1 done)、`2026-06-11-dataset-detail-chart-v2-qwt.md`(QwtPlot 返工,权威)。
|
||||
- API 文档:`docs/apis`(business_OpenAPI.json)。
|
||||
- 外部方案:`docs/Geopro3.0_二维图表返工技术方案.md`。
|
||||
|
||||
---
|
||||
|
||||
## 8. 记忆(务必遵守,`~/.claude/projects/D--Git-lanbingtech-geopro/memory/`)
|
||||
|
||||
- `reply-in-chinese` — 全程中文回复。
|
||||
- `study-original-via-playwright` — **任何不确定必须用 Playwright 实地看原版,禁止臆测**(尤其做新 dd 类型详情图前抓样本/规格)。
|
||||
- `no-embellishment-replicate-exactly` — 严格 1:1,不加原版没有的特性(曾因等值线「周期重复标注」被纠正)。
|
||||
|
||||
---
|
||||
|
||||
## 9. 工作方式备注(本次会话沿用,效果好)
|
||||
|
||||
- 用户偏好 **subagent-driven** 执行 + 实现后 **code review + spec 符合度** 双评审(opus 评审);「非必要不停,一口气做完」。
|
||||
- **真并行构建在本项目不安全**(dev-build 硬编码单一 build 目录 + 多任务共改 main.cpp/CMakeLists)→ 用顺序执行;worktree 隔离不了构建(硬编码路径 + 冷配置开销)。
|
||||
- 破坏性接口改形需**原子落地**(同批提交)保持构建绿(详情试点 Task5+6 合并、Part A A4+A5、本次清债 都是此教训)。
|
||||
- 评审 follow-up 多为小加固,直接在新代码上补;「不为不可能的场景写错误处理」(CLAUDE.md §2)——评审若建议为构造保证不会发生的情况加防御,可不采纳。
|
||||
</content>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,205 @@
|
|||
# 数据集详情「二维图表」返工实现计划 v2(QwtPlot)
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: 用 superpowers:subagent-driven-development 或 executing-plans 执行。步骤用 `- [ ]`。
|
||||
> **铁律(来自返工方案):** 每一步做完**必须**与原版对照验收,通过再进下一步——不许一次性做完才看。任何对原版的不确定,**必须用 Playwright 实地学习原版**,禁止联想猜测。
|
||||
|
||||
**Goal:** 推翻当前 QGraphicsView 二维图表,按《Geopro3.0_二维图表返工技术方案.md》用 **QwtPlot(坐标系/交互/图例三层分离)+ VTK 算法(等值线多边形)+ 连续对数色阶** 重做「原数据散点图」与「网格数据等值线图」,修复用户列出的 9 个问题,达到**视觉等价 + 交互一致**(非字节级像素)。
|
||||
|
||||
**Architecture:** 三层分离——工具条(属于面板)/ QwtPlot(数据区 + 固定坐标轴 + Magnifier/Panner 交互)/ 独立 ColorBarWidget(不入数据坐标系)。等值线多边形复用已有 `ContourBands`(VTK 算法);色阶用 `QwtLinearColorMap` 连续插值 + 对数标度,图与图例同源(ColorMapService)。
|
||||
|
||||
**Tech Stack:** C++17、Qt6 Widgets、**Qwt 6.2(已集成,见 cmake/qwt.cmake,目标名 `qwt`,头在 external/qwt-src/src)**、VTK 9.6(仅算法)、GoogleTest/CTest。
|
||||
|
||||
**依据:** `docs/Geopro3.0_二维图表返工技术方案.md`(架构权威)、`docs/superpowers/specs/2026-06-11-dataset-detail-view-design.md`(数据/接口)。
|
||||
|
||||
---
|
||||
|
||||
## 0. 复用与替换盘点(不要重造已有的)
|
||||
|
||||
**复用(不动):**
|
||||
- `src/data/api/ApiDatasetRepository.*`、`src/data/dto/DatasetChartDto.*`、`src/controller/DatasetDetailController.*`(数据层+编排,`ChartData` 已带 scatter/grid/colorScale×2/anomalies)。
|
||||
- `src/render/ContourBands.*`(VTK 算分层多边形+等值线几何,作为等值线图的几何来源 = 方案的 ContourEngine)。
|
||||
- `src/core/model/{Field,ColorScale,Anomaly}.hpp`、`src/app/panels/AnomalyTablePanel.*`(异常表,列对,复用)。
|
||||
- 既有下划线页签组件 `buildTabbedPanel`(main.cpp 中「异常/对象属性」面板用的同款,§步骤1 复用)。
|
||||
|
||||
**替换(删除/重写):**
|
||||
- ❌ `src/app/panels/chart/DatasetChartView.*`(裸 QGraphicsView)—— 删除。
|
||||
- ❌ `src/app/panels/DatasetDetailPage.*`(旧两按钮+图表)—— 重写为基于 QwtPlot 的视图。
|
||||
- `src/app/panels/DatasetDetailPanel.*`、`main.cpp` 接线 —— 按新组件调整。
|
||||
|
||||
---
|
||||
|
||||
## 文件结构(新建/修改)
|
||||
|
||||
| 文件 | 职责 |
|
||||
|---|---|
|
||||
| `src/app/panels/chart/ColorMapService.hpp/.cpp`(建) | 色阶服务:断点+颜色、连续对数映射、产出 `QwtLinearColorMap` 与离散色带;图/图例同源 |
|
||||
| `src/app/panels/chart/ColorBarWidget.hpp/.cpp`(建) | 独立色阶条 QWidget(固定,不入数据坐标系) |
|
||||
| `src/app/panels/chart/ScatterPlotItem.hpp/.cpp`(建) | QwtPlotItem:散点方块按连续色阶着色 |
|
||||
| `src/app/panels/chart/ContourPlotItem.hpp/.cpp`(建) | QwtPlotItem:画 ContourBands 分层填充多边形 + 等值线 + 标注 + 异常叠加 |
|
||||
| `src/app/panels/chart/RawDataChartView.hpp/.cpp`(建) | 原数据页:工具条 + QwtPlot + ScatterPlotItem + ColorBarWidget |
|
||||
| `src/app/panels/chart/GridDataChartView.hpp/.cpp`(建) | 网格数据页:工具条 + QwtPlot + ContourPlotItem + ColorBarWidget |
|
||||
| `src/app/panels/DatasetDetailPage.hpp/.cpp`(重写) | 单 ds 页:下划线页签(原数据/网格数据) + 两视图 + 下方(异常列表/描述) |
|
||||
| `src/app/panels/DatasetDetailPanel.hpp/.cpp`(改) | 多 Tab 壳(保留 dsId 去重/反向联动) |
|
||||
| `src/app/panels/DescriptionPanel.hpp/.cpp`(建) | 下方「描述」页(纯文本/只读富文本占位) |
|
||||
| `src/app/panels/chart/DatasetChartView.*`(删) | 旧 QGraphicsView 渲染器 |
|
||||
| `src/app/CMakeLists.txt`(改) | 注册新文件 + 链接 `qwt`;移除 DatasetChartView |
|
||||
| 测试 | ColorMapService 对数映射 / ContourBands 既有 / 散点着色 单测 |
|
||||
|
||||
**构建/测试命令(本机工具链):**
|
||||
- 构建:`powershell.exe -ExecutionPolicy Bypass -File scripts/dev-build.ps1`
|
||||
- 测试:`powershell.exe -ExecutionPolicy Bypass -File scripts/dev-test.ps1`
|
||||
- 运行(视觉验收):`build/release/src/app/geopro_desktop.exe`(需登录)
|
||||
|
||||
**Qwt 关键类参考**(实现时可查 `external/qwt-src/src/` 头文件确认签名):`QwtPlot`、`QwtPlotItem`、`QwtPlotMagnifier`、`QwtPlotPanner`、`QwtPlotZoomer`、`QwtLinearColorMap`、`QwtScaleEngine`/`QwtLogScaleEngine`、`QwtText`、`QwtPlotCanvas`。
|
||||
|
||||
---
|
||||
|
||||
## 步骤 1:三层骨架 + UI 结构(治 #6 #7 #8)
|
||||
|
||||
**目标**:先把布局结构做对,不画图。下划线页签、工具条归位、下方双页签。
|
||||
|
||||
**研究原版(Playwright)**:对照 `assets/web-datasetinfo-*.png` 与实测——确认 (a) 原数据/网格数据 = 下划线页签(非按钮);(b) 两个页签各自的工具条项(§方案7);(c) 下方 = 异常列表 + 描述 双页签。
|
||||
|
||||
**Files:** 重写 `DatasetDetailPage.*`;建 `DescriptionPanel.*`;改 `DatasetDetailPanel.*`、`src/app/CMakeLists.txt`。
|
||||
|
||||
- [ ] **Step 1.1** 先研究:用 Playwright 打开 `datasetInfo?id=1439812897218560&ddCode=dd_inversion_data`,截图原数据/网格两页签的工具条与下方区域,确认结构(不靠记忆)。
|
||||
- [ ] **Step 1.2** 复用 `buildTabbedPanel`(main.cpp 中「异常/对象属性」用的下划线页签)做 `DatasetDetailPage` 顶部「原数据/网格数据」切换;内容区先放两个空 `QWidget` 占位(RawDataChartView/GridDataChartView 后续填)。
|
||||
- [ ] **Step 1.3** 下方用同款 `buildTabbedPanel` 做「异常列表(现有 `AnomalyTablePanel`)/ 描述(新 `DescriptionPanel`,先放只读文本占位)」。
|
||||
- [ ] **Step 1.4** 工具条:原数据页工具条 = [网格][色阶配置][当前图形▼][另存为];网格数据页工具条 = [网格][色阶配置][白化][滤波处理][☑显示异常][☑显示等值线标注][☐显示等值线提示信息][简化容差滑块][异常标注][自动标注][另存为]。**先只放按钮/控件占位(无功能)**,位置在各自页签视图内部顶部。
|
||||
- [ ] **Step 1.5** 构建通过;启动 app 双击 ds,**对照原版验收**:页签是下划线样式、工具条在各自页签内、下方有两个页签。截图发用户确认后再进步骤 2。
|
||||
|
||||
---
|
||||
|
||||
## 步骤 2:QwtPlot 接入 + 交互(治 #3 #4 #9)
|
||||
|
||||
**研究原版(Playwright,关键——消除「四象限」歧义)**:
|
||||
- [ ] **Step 2.1** 在原版散点页拖动/缩放,观察:坐标轴是否固定、刻度值是否跟随、能否平移到数据范围外(负区)。抓 Plotly `layout.xaxis/yaxis`(range/scaleanchor/autorange/side)。**判定原数据到底是「真四象限」还是「单象限+自由平移」**,网格数据同样观察。把结论记入本步骤再实现,不靠猜。
|
||||
|
||||
**Files:** `RawDataChartView.*`、`GridDataChartView.*`(先只含 QwtPlot 空图 + 交互);`src/app/CMakeLists.txt` 链 `qwt`。
|
||||
|
||||
- [ ] **Step 2.2** 两视图各内嵌一个 `QwtPlot`。按步骤2.1结论设轴:网格数据 = 单象限(x/y 从数据 min,原点左下);原数据 = 按结论(四象限则轴范围含负、十字交叉;单象限则同网格)。
|
||||
- [ ] **Step 2.3** 加 `QwtPlotMagnifier(plot->canvas())`(滚轮缩放)+ `QwtPlotPanner(plot->canvas())`(拖动平移)。确认:**轴位置不动、只有数据区平移缩放、刻度数值自动跟随**(QwtPlot 默认行为)。
|
||||
- [ ] **Step 2.4** 纵横比(#9):原版剖面"宽扁"。先用 `plot->setAxisScale` 固定 y 数据范围、x 自适应填充宽度;若需等比例尺,在 resize/replot 时按数据 x/y 跨度调 canvas 宽高(方案 §4.4)。
|
||||
- [ ] **Step 2.5** 构建+运行,**对照原版验收**:拖动只动数据、不花屏、轴值跟随、宽扁比例接近。截图确认后进步骤 3。
|
||||
|
||||
---
|
||||
|
||||
## 步骤 3:独立色阶条 ColorBarWidget(治 #1)
|
||||
|
||||
**Files:** `ColorBarWidget.*`;放进 Raw/GridDataChartView 布局中 QwtPlot **下方**(兄弟 widget)。
|
||||
|
||||
- [ ] **Step 3.1** `ColorBarWidget : QWidget`,`paintEvent` 自绘一条水平色带(暂用占位色阶)+ 刻度数值。固定高度(~36px:色条+文字)。
|
||||
- [ ] **Step 3.2** 布局:`QVBoxLayout`= [工具条][QwtPlot stretch][ColorBarWidget 固定高]。**ColorBar 不入 QwtPlot 坐标系**。
|
||||
- [ ] **Step 3.3** 运行验收:缩放/拖动主图时色阶条**不动、不与横轴重叠**(治 #1)。截图确认进步骤 4。
|
||||
|
||||
---
|
||||
|
||||
## 步骤 4:ColorMapService + 抓原版真实色值(治 #2 基础)
|
||||
|
||||
**研究原版(Playwright,关键)**:
|
||||
- [ ] **Step 4.1** 抓原版色值与映射方式(**严禁凭记忆配色**):
|
||||
- 散点页:抓 Plotly `data[0].marker.colorscale`([[pos,color],…])、`cmin/cmax`、是否连续;判断标度(线性 vs 对数)——对色阶条断点 `17.105,25.937,…` 做比值检验确认对数/等比。
|
||||
- 网格页:色阶来自 `colorGradation/getDetail`(已知 `properties.colorBar=[[值,rgba],…]`、type1 原数据/type2 网格)。
|
||||
- 取色兜底:对色阶条截图每格中心取 RGB。
|
||||
|
||||
**Files:** `ColorMapService.hpp/.cpp`;测试 `tests/app/test_colormap_service.cpp`。
|
||||
|
||||
- [ ] **Step 4.2 写失败测试**(对数映射):
|
||||
```cpp
|
||||
#include <gtest/gtest.h>
|
||||
#include "panels/chart/ColorMapService.hpp"
|
||||
using geopro::app::ColorMapService;
|
||||
TEST(ColorMapService, LogMapsValueToUnitInterval) {
|
||||
ColorMapService s;
|
||||
s.setRange(10.0, 1000.0, /*logScale=*/true);
|
||||
EXPECT_NEAR(s.normalized(10.0), 0.0, 1e-9);
|
||||
EXPECT_NEAR(s.normalized(1000.0), 1.0, 1e-9);
|
||||
EXPECT_NEAR(s.normalized(100.0), 0.5, 1e-9); // log10 中点
|
||||
}
|
||||
```
|
||||
- [ ] **Step 4.3** 注册测试到 `tests/CMakeLists.txt`(app 段,已 `target_include_directories ... src/app`);跑确认失败(编译失败)。
|
||||
- [ ] **Step 4.4 实现** `ColorMapService`:
|
||||
- 持 `core::ColorScale`(离散断点+颜色,来自 colorBar)+ `vmin/vmax/logScale`。
|
||||
- `double normalized(double v)`:log 时 `(log(v)-log(vmin))/(log(vmax)-log(vmin))`,否则线性;clamp [0,1]。
|
||||
- `QwtLinearColorMap* makeQwtColorMap()`:用断点的归一化位置 + 颜色 `addColorStop`,连续插值(默认 ScaledColors)。
|
||||
- `core::Rgba colorAt(double v)`:连续插值取色(散点用);以及离散 `bandColor`(等值线用)。
|
||||
- 图与图例(ColorBarWidget)+ 等值线图都用**同一个 ColorMapService 实例**。
|
||||
- [ ] **Step 4.5** 跑测试通过;提交。
|
||||
|
||||
---
|
||||
|
||||
## 步骤 5:原数据散点图(治 #2)
|
||||
|
||||
**研究原版(Playwright)**:确认散点 = 方块 symbol、白描边、按 vlist 连续着色、x:y 关系(步骤2结论)。
|
||||
|
||||
**Files:** `ScatterPlotItem.*`;接入 `RawDataChartView`。
|
||||
|
||||
- [ ] **Step 5.1** `ScatterPlotItem : public QwtPlotItem`,重写 `draw(QPainter*, xMap, yMap, canvasRect)`:对每个点 `(xlist[i],ylist[i])` 用 `xMap.transform/yMap.transform` 映射到像素,画固定像素边长的方块(白描边 1px),填色 = `ColorMapService::colorAt(vlist[i])`。`rtti()` 返回自定义。
|
||||
- [ ] **Step 5.2** `RawDataChartView::setData(scatter, colorMapService)`:建 ScatterPlotItem attach 到 QwtPlot,设轴范围为数据包围盒,`replot()`;ColorBarWidget 用同一 service 刷新。
|
||||
- [ ] **Step 5.3** 运行,**与原版散点并排对照配色**(#2):颜色应连续插值、与原版一致。截图确认进步骤 6。
|
||||
|
||||
---
|
||||
|
||||
## 步骤 6:网格数据等值线图(治 #2 #5)
|
||||
|
||||
**研究原版(Playwright)**:确认填充色带(离散 banded)+ 黑色等值线 + **线上数值标注**(贴线方向)+ 数据凸包白边。
|
||||
|
||||
**Files:** `ContourPlotItem.*`(复用 `ContourBands`);接入 `GridDataChartView`。
|
||||
|
||||
- [ ] **Step 6.1** `ContourPlotItem : QwtPlotItem`,持 `ContourBandsResult`(来自 `geopro::render::buildContourBands(grid, colorScale, opt)`)。`draw()`:
|
||||
- 填充:每个 `BandPolygon` 用 xMap/yMap 把局部坐标映射到像素,`painter->drawPolygon`,填 `band.color`(离散)。
|
||||
- 等值线:每条 `ContourLine` `drawPolyline`(黑、细);受「显示等值线标注」控制在线中段绘制旋转贴线的数值文本(#5)。
|
||||
- [ ] **Step 6.2** 异常叠加:把 ds 异常(`core::Anomaly`,局部坐标)按 markType 画在同一 item 之上(虚线多段线/多边形/点),受「显示异常」控制。
|
||||
- [ ] **Step 6.3** `GridDataChartView::setData(grid, gridColorMapService, anomalies)`:构建 ContourPlotItem attach;轴=数据范围;ColorBar 用网格色阶(type2)。
|
||||
- [ ] **Step 6.4** 运行,**与原版网格图并排对照**(#2 配色、#5 标注、凸包白边、宽扁比例)。截图确认进步骤 7。
|
||||
|
||||
---
|
||||
|
||||
## 步骤 7:工具条联动(显示开关 + 简化容差)
|
||||
|
||||
- [ ] **Step 7.1** 「显示异常 / 显示等值线标注 / 显示等值线提示信息」复选框 → 切 ContourPlotItem 对应开关 + replot。
|
||||
- [ ] **Step 7.2** 「简化容差」滑块(0–1)→ 改 `ContourOptions::simplifyTol` 重算 ContourBands + replot(对应原版滑块)。
|
||||
- [ ] **Step 7.3** 「白化 / 滤波处理 / 网格 / 色阶配置 / 异常标注 / 自动标注 / 另存为」**先做占位按钮**(弹「待实现」或留空槽),功能后续单独立项。运行验收开关行为与原版一致。
|
||||
|
||||
---
|
||||
|
||||
## 步骤 8:接线 DatasetDetailController + main.cpp(替换旧渲染)
|
||||
|
||||
- [ ] **Step 8.1** `DatasetDetailPage::setData(ChartData)`:分别喂 `RawDataChartView`(scatter + scatterColorScale)与 `GridDataChartView`(grid + gridColorScale + anomalies);异常列表喂 `AnomalyTablePanel`。
|
||||
- [ ] **Step 8.2** `main.cpp`:删除对旧 `DatasetChartView`/`DatasetDetailPage` 的引用;`DatasetDetailPanel` 改用新 `DatasetDetailPage`。`chartReady`→`openOrUpdate` 链路不变。删除 `src/app/panels/chart/DatasetChartView.*` 及其 CMake 注册。
|
||||
- [ ] **Step 8.3** 全量构建 + `dev-test.ps1` 不回归;启动 app 双击 ds **整体对照验收**。
|
||||
|
||||
---
|
||||
|
||||
## 步骤 9:整体对照验收(治全部 9 项)
|
||||
|
||||
- [ ] **Step 9.1** 原数据、网格数据分别与原版并排截图,逐条核对方案 §10 验收清单:配色/轴固定/色阶条固定/标注/坐标系象限/宽扁比例/拖动流畅/页签样式/工具条归位/下方双页签。
|
||||
- [ ] **Step 9.2** cpp-reviewer 审查本次改动;处理 CRITICAL/HIGH。
|
||||
- [ ] **Step 9.3** 收尾提交。
|
||||
|
||||
---
|
||||
|
||||
## 验收标准(方案 §10,视觉等价 + 交互一致,非字节级)
|
||||
|
||||
- [ ] 配色与原版视觉一致(连续插值、对数分布、同色阶)
|
||||
- [ ] 轴固定、拖动/缩放只动数据区、刻度值跟随、不花屏
|
||||
- [ ] 色阶条固定轴下方、不随图缩放、不与轴重叠
|
||||
- [ ] 等值线数值标注显示、贴线方向
|
||||
- [ ] 原数据/网格数据坐标系象限与缩放行为分别与原版一致
|
||||
- [ ] 宽扁比例与原版一致
|
||||
- [ ] 页签下划线样式、工具条各自页签内、下方异常列表+描述双页签
|
||||
- [ ] 全量测试不回归
|
||||
- ✗(明确不作为标准):与 web 逐像素 diff 相同
|
||||
|
||||
---
|
||||
|
||||
## 自审(Spec/方案 覆盖核对)
|
||||
|
||||
- 9 个问题逐条 → 步骤 1(#6/#7/#8)、2(#3/#4/#9)、3(#1)、4-5(#2)、6(#2/#5) 全覆盖。✓
|
||||
- 复用项(ContourBands/数据层/AnomalyTablePanel/buildTabbedPanel)已标注,不重造。✓
|
||||
- 替换项(DatasetChartView/旧 DatasetDetailPage)明确删除。✓
|
||||
- 每步含「Playwright 研究原版 + 对照验收」检查点(铁律)。✓
|
||||
- 可测部分(ColorMapService 对数映射)有 TDD;视觉部分以对照验收为准(方案明确视觉等价非像素级)。✓
|
||||
- 待澄清/研究项(步骤 2.1 四象限歧义、步骤 4.1 真实色值与标度)已显式列为开工先研究项,不靠猜。✓
|
||||
|
||||
> 工具功能(白化/滤波/自动标注/网格化/色阶配置/另存为/导出)本计划只做**占位归位**(治 #7),功能实现后续单独立项。
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,235 @@
|
|||
# 数据集详情图:扩展到其余 dd 类型 实现计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: 用 `superpowers:subagent-driven-development` 执行(每个 bite-sized 任务派一个 subagent,TDD,频繁提交)。步骤用 `- [ ]`。
|
||||
> **铁律(项目记忆,务必遵守):** 任何对原版的不确定,**必须用 Playwright 实地学习原版**,禁止联想猜测;严格 **1:1 复刻**,不加原版没有的特性。**没有活样本的 dd 类型一律 BLOCKED,禁止凭想象写渲染代码。**
|
||||
|
||||
**Goal:** 把「数据集详情图」从只支持 ERT 反演(`dd_inversion_data`)扩展到其余 dd 类型。先打通「控制器按 ddCode 走策略注册表」的分派骨架(未注册类型优雅降级「暂不支持」),再按"样本可得性"逐类落地:有活样本的(ERT 测量类、TEM/timeSensor)做完整复刻,无活样本的(dd_grid / 轨迹 / 测井 / GPR)只列调研占位 + 取样前置条件,BLOCKED。
|
||||
|
||||
**Architecture:** 沿用现有分层,不重造:
|
||||
- 编排层 `DatasetDetailController`(现状对非 `dd_inversion_data` 直接拒绝)→ 改为持 `ChartStrategyRegistry`,按 ddCode 选策略;策略决定「拉哪些接口 + 用哪个 View 渲染」。
|
||||
- 异步数据层 `IAsyncDatasetRepository` + `ChartLoad`/`GridLoad` 句柄(`ApiBatch` 多请求聚合 + 注入解析函数)。每类新 dd 视形态新增句柄类型(如折线 `LineLoad`、图像 `ImageLoad`)或复用现有句柄。
|
||||
- DTO 解析层 `src/data/dto/`(纯函数,单测友好,用抓到的真实响应做夹具)。
|
||||
- 渲染层 `src/app/panels/chart/`:复用 QwtPlot(轴/交互/图例)+ VTK 算法(等值线几何)。按形态归类复用或新建 View:等值面类复用 `ContourPlotItem`/`GridDataChartView`;散点类复用 `ScatterPlotItem`/`RawDataChartView`;折线类(测井)新建 `LineChartView`;图像类(GPR)新建 `ImageChartView`。
|
||||
- 页面壳 `DatasetDetailPanel`(多 Tab) / `DatasetDetailPage`(单 ds 内部页签)。
|
||||
|
||||
**Tech Stack:** C++17、Qt6 Widgets、Qwt 6.2(`cmake/qwt.cmake`,目标名 `qwt`,头在 `external/qwt-src/src`)、VTK 9.6(仅算法层)、GoogleTest/CTest、vcpkg(仅非 Qt 依赖)。
|
||||
|
||||
**依据文档:**
|
||||
- `docs/superpowers/specs/2026-06-11-dataset-detail-view-design.md`(§2.4 后续 dd 类型、§5.3 策略框架、§3 原 web 分析方法)。
|
||||
- `docs/superpowers/HANDOFF-dataset-detail-chart.md`(技术栈/构建/已完成范围)。
|
||||
- `docs/apis/business_OpenAPI.json`(各 dd 类型取数接口,下文已核对路径/参数)。
|
||||
|
||||
**构建/测试命令(本机工具链,固定):**
|
||||
- 构建:`powershell.exe -ExecutionPolicy Bypass -File scripts/dev-build.ps1`
|
||||
- ⚠️ 若 `LNK1104 无法打开 geopro_desktop.exe`:先 `Get-Process geopro_desktop | Stop-Process -Force` 再构建。
|
||||
- 测试:`powershell.exe -ExecutionPolicy Bypass -File scripts/dev-test.ps1`(先 dev-build 再 dev-test)。**基线 89/89 绿。**
|
||||
- 单测过滤:`build/release/tests/geopro_tests.exe --gtest_filter=<Suite>.*`
|
||||
- 运行(视觉验收):`build/release/src/app/geopro_desktop.exe`(需登录)。
|
||||
|
||||
---
|
||||
|
||||
## 0. 现状核实(已读真实代码,勿臆测)
|
||||
|
||||
**关键结论:策略框架已存在但未接入控制器。** 详情:
|
||||
|
||||
- `src/app/panels/chart/IDatasetChartStrategy.hpp`:`IDatasetChartStrategy`(只有纯虚 `std::string ddCode()`)+ `ChartStrategyRegistry`(`add/find/supports`,`std::map<string, unique_ptr>`)。**已写好,但全仓只有单测引用它,控制器/main.cpp 从未构造/使用注册表。**
|
||||
- `src/app/panels/chart/ErtInversionStrategy.hpp`:仅一个 stub —— `ddCode()` 返回 `"dd_inversion_data"`,**无任何 load/render 逻辑**。
|
||||
- `src/controller/DatasetDetailController.cpp`:
|
||||
- `openDataset(dsId, ddCode)`:`if (ddCode != "dd_inversion_data") { emit loadFailed("暂不支持该数据类型的预览"); return; }` —— **硬编码**,未走注册表。
|
||||
- `loadGridData(dsId, ddCode)`:同样 `if (ddCode != "dd_inversion_data") return;` 硬编码。
|
||||
- `tests/app/test_chart_strategy_registry.cpp`:仅测 `add/find/supports/降级`,**未与控制器联动**。
|
||||
- `src/data/repo/IAsyncDatasetRepository.hpp`:`loadChartAsync`(scatter+色阶type1)/ `loadGridAsync`(rows+色阶type2+异常),均**写死 ERT 反演接口**(见 `ApiDatasetRepository.cpp`)。
|
||||
- ddCode 来源:`src/data/dto/NavDto.cpp:124` 从数据集列表项 `o["ddCode"]` 解析;`main.cpp:501` 双击时从 `kDsDdCodeRole` 取出传给 `openDataset`。**ddCode 是 API 给的字符串,源码里除 `dd_inversion_data` 外无其它 dd code 常量**(grep 到的 `dd_custom_command` 等是 CMake `add_custom_command` 等构建符号,非数据类型)。
|
||||
|
||||
**结论:本 plan 的 Phase 1 必须先「打通控制器→注册表分派」,这是所有后续 dd 类型的前置。** 现有 `IDatasetChartStrategy` 接口过窄(只有 ddCode),需扩展为能驱动「加载 + 渲染」。
|
||||
|
||||
---
|
||||
|
||||
## 1. 各 dd 类型:实测编目(2026-06-12 全量遍历,已坐实)
|
||||
|
||||
> **本节已由 2026-06-12 全量实测替换原"推断矩阵"。** 用脚本直连 API 遍历**两个账号**(威立雅租户 + 赛盈地空"数据多"账号 20 项目 / 108 TM / 752 DS),逐个 ds 核对 `ddCode` + `dsTypeCode` + 真实渲染。Phase 0 探查目标基本达成。
|
||||
|
||||
### 1.0 实测方法(脚本,已验证可用,勿臆测照此复刻)
|
||||
|
||||
枚举 DS 的 API 链(**踩过的坑都在这**):
|
||||
1. 项目列表:`POST /business/my/profile/project/page` body `{pageNo, pageSize}`(注意 **`pageNo` 不是 `pageNum`**)。
|
||||
2. 结构树:`GET /business/projectStruct/queryProjectStruct/{projectId}` → **扁平节点数组**,每节点 `{id, parentId, type, name, confCode, typeId}`。**`type=1`=项目根或 GS 节点,`type=2`=TM**。层级靠 `parentId` 链:项目根(parentId="0") → GS(type=1) → TM(type=2) → DS。**TM 可能直挂项目、也可能挂在 GS 下**(如雷达项目 项目→GS"北京"→TM)。
|
||||
3. DS 列表:`POST /business/dsObject/data/page` body **`{projectId, structParentId:<tmId>, structParentConfType:2, classifyTypeList:[3], pageNo, pageSize}`**。
|
||||
- ⚠️ **字段是 `structParentId`(=tmId)+ `structParentConfType:2`,不是 `tmObjectId`**;
|
||||
- ⚠️ **必须带 `classifyTypeList:[3]`**(=数据,缺了/换值后端直接 `code:500 sys.internalServerError`);
|
||||
- ⚠️ `pageNo` 不是 `pageNum`。
|
||||
- 时序类另走 `POST /business/dsObject/timeSensor/data/page`(同 body)。
|
||||
4. DS 对象关键字段:`ddCode`(如 `dd_grid`)、`dsTypeCode`(英文,如 `WhitenedData`)、`name`(中文数据类型名)、`dsName`(文件名)、`id`(dsId)。
|
||||
5. 后端对该接口**间歇 500**,脚本需重试(4–8 次)。
|
||||
|
||||
**铁律纠正(重要):详情渲染由数据集的"真实数据类型"决定,URL 详情链接里的 `ddCode` 经常标错。** 实测:「视电阻率数据」「接地电阻」的详情链接都带 `ddCode=dd_inversion_data`,但渲染分别是反演剖面 / 柱状图,完全不同。**客户端策略分派必须用真实数据类型(ds 元数据的 `dsTypeCode`/`ddCode`),绝不能用详情链接的 ddCode。**
|
||||
|
||||
### 1.1 已确认数据类型(10 种 ddCode,均有活样本)
|
||||
|
||||
| ddCode | dsTypeCode | 数据类型(中) | 渲染视图(实测) | 客户端 | 样本 |
|
||||
|---|---|---|---|---|---|
|
||||
| `dd_inversion_data` | ERT inversion data | 电阻率(反演)数据;TEM反演剖面;视电阻率数据 | **① 原数据(Plotly散点,cauto) + 网格数据(等值面+等值线)** 2 页签 | ✅ 已做 | 多 |
|
||||
| `dd_ert_measurement_data` | ERT raw data | ERT原始数据 | **③ 散点伪剖面**(x=斜距/y=伪深度, 视电阻率着色, A/B/M/N 悬浮, 含反演运算/生成视电阻率/色阶工具) + 数据列表 | 可做 | 多 |
|
||||
| `dd_ert_measurement_gr_data` | earth resistance | ERT接地电阻 | **② 柱状图**(Y=电阻/欧姆, X=电极点) + 列表 | 可做 | 多 |
|
||||
| `dd_trajectory_data` | Platform trajectory | ERT电极坐标;TEM坐标 | **④ 轨迹**:地图(测线折线) + 列表 + 高程 3 页签 | 可做 | 多 |
|
||||
| `dd_grid` | WhitenedData | 白化数据 | **⑤ 列表**(序号/x/y 地表点) | 可做 | 有 |
|
||||
| `dd_gpr_channel_detail` | RADAR_SINGLE_CHANNEL_PROFILE | 雷达单通道剖面数据 | **⑥ 雷达剖面灰度图像(B-scan radargram)** 左 + **单道波形(A-scan wiggle)** 右;工具:对比度滑块/灰度色阶/显示频谱图 | 待需求确认 | 雷达项目 |
|
||||
| `dd_gpr_channel_image` | RADAR_SINGLE_CHANNEL_IMAGE_LIST | 雷达单通道图片列表 | **⑥ 同上**(B-scan 灰度图像 + A-scan 波形,与 channel_detail 同视图) | 待需求确认 | 雷达项目 |
|
||||
| `dd_radar_channel_trajectory` | RADAR_SINGLE_CHANNEL_TRAJECTORY | 雷达单通道轨迹坐标 | **⑦ 地图轨迹**:真实地理底图(街道/河流) + GPS 测线路径(黄/绿线) + 起点/终点/轨迹点标记 | 待需求确认 | 雷达项目 |
|
||||
| `dd_radar_rtk_trajectory` | rtk_trajectory | RTK 轨迹坐标 | **⑦ 地图轨迹 + RTK 坐标点设置面板**(GPS定位/解状态过滤:固定解/浮点解/单点解/差分、最大距离过滤、数据还原) | 待需求确认 | 雷达项目 |
|
||||
| `dd_radar_preprocess_data` | RADAR_PREPROCESS_DATA | 雷达预处理数据 | **空白**(无标题/内容,仅空 map 容器)——疑数据中间态、无独立可视详情 | 待需求确认 | 雷达项目 |
|
||||
|
||||
> 渲染视图编号①②③④⑤⑥⑦。**全 10 种 ddCode 渲染均已逐个打开实测(看截图)确认。** 归并后**共 7 种渲染视图**(① 散点+网格[已做] / ② 柱状图 / ③ 散点伪剖面 / ④ 轨迹(地图/列表/高程) / ⑤ 列表 / ⑥ 雷达剖面图像(B-scan+A-scan) / ⑦ 地图轨迹(GIS底图+GPS路径)),外加 `dd_radar_preprocess_data` 无独立详情。
|
||||
>
|
||||
> 注:④(ERT/TEM 坐标 `dd_trajectory_data` 的"地图/列表/高程") 与 ⑦(雷达 `dd_radar_*_trajectory` 的"GIS底图+GPS路径") 都是"轨迹/地图"族但组件形态不同——④是平面测线轨迹+高程剖面页签,⑦是真实地理底图叠 GPS 路径(+RTK 解过滤);实现时可能各自一个 View 或共用底图 View 加配置。
|
||||
|
||||
**5 个 radar/gpr 样本路径**(同一条 GPR 测线):项目 **雷达0331**(projectId=`1454042286333952`) → GS **北京**(`1454042309754880`) → TM **1229cx1-0_160**(`1454042386726912`, confCode=gpr) → DS:
|
||||
- `dd_gpr_channel_detail` dsId=`1454042504011776`、`dd_gpr_channel_image`=`1454042523484160`、`dd_radar_channel_trajectory`=`1454042504060928`、`dd_radar_rtk_trajectory`=`1454042387619840`、`dd_radar_preprocess_data`=`1454042387505152`。
|
||||
|
||||
### 1.2 仍无样本(保持 BLOCKED)
|
||||
|
||||
| 类型 | 接口(OpenAPI) | 状态 |
|
||||
|---|---|---|
|
||||
| TEM 时序 / timeSensor | `POST dd/ert/timeSensor/rows`、`dsObject/timeSensor/data/page` | 🚫 两账号均无此 ds 类型样本(TEM 数据实为 dd_inversion_data + dd_trajectory_data)。`LineChartView` 折线视图无依据,不实现。 |
|
||||
| 测井 well logging | OpenAPI 无明确接口 | 🚫 无样本、无接口。 |
|
||||
| 电流法 currentmethod | `dd/indicator/currentmethod/rows`、`scatter/graph/{dsObjectId}` | 🚫 项目「填埋场监测」有 CurrentMethod 方法类型 TM,但无带数据 DS 样本。 |
|
||||
|
||||
**小结:** 实测确认 **10 种 ddCode 有样本**(ERT/通用 5 种渲染已定 + radar/gpr 5 种渲染待定本轮确认);timeSensor/测井/电流法 仍无样本,保持 BLOCKED。客户端已做 1 种(`dd_inversion_data`)。
|
||||
|
||||
---
|
||||
|
||||
## 文件结构(新建/修改总览)
|
||||
|
||||
| 文件 | 动作 | 职责 |
|
||||
|---|---|---|
|
||||
| `src/app/panels/chart/IDatasetChartStrategy.hpp` | 改 | 扩展接口:除 `ddCode()` 外,加描述「加载/渲染契约」的虚方法(详见 Task 1.1) |
|
||||
| `src/app/panels/chart/ErtInversionStrategy.{hpp,cpp}` | 改 | 把 stub 实化为「ERT 反演策略」:声明它需要 chart+grid 加载、用散点/等值面视图 |
|
||||
| `src/controller/DatasetDetailController.{hpp,cpp}` | 改 | 持 `ChartStrategyRegistry&`;`openDataset`/`loadGridData` 改为走注册表分派,未注册→`loadFailed("暂不支持…")` |
|
||||
| `src/app/main.cpp` | 改 | 构造 `ChartStrategyRegistry`,注册 `ErtInversionStrategy`,注入控制器 |
|
||||
| `tests/controller/test_dataset_detail_controller.cpp` | 改 | 加「未注册 ddCode 优雅降级 / 已注册走加载」用例 |
|
||||
| `tests/app/test_chart_strategy_registry.cpp` | 改 | 已有降级用例;按接口扩展补充 |
|
||||
| `docs/superpowers/sample-probe-other-dd-types.md` | 建(Phase 0 产出) | 各类 dd 的「样本可得性矩阵 + 渲染规格 + 真实 API 响应夹具索引」 |
|
||||
| `tests/fixtures/dd/*.json` | 建(Phase 0 产出) | 抓到的真实响应,做 DTO 单测夹具(仅有样本的类型) |
|
||||
| `src/data/dto/DatasetChartDto.{hpp,cpp}` | 改/拆 | 新增各类 dd 的 parse 函数(如 `parseMeasurementScatter`、`parseTimeSensorSeries`)。若文件超 ~400 行则按类型拆 `MeasurementDto.*` / `TimeSensorDto.*` |
|
||||
| `src/data/api/ApiDatasetRepository.{hpp,cpp}` | 改 | 新增对应 `load*Async`(仅有样本类型)|
|
||||
| `src/data/api/DatasetLoads.hpp` | 改 | 新增 `*Parts` 结构(仅有样本类型)|
|
||||
| `src/data/api/DatasetLoadHandles.hpp` + `.cpp` | 改 | 视形态新增句柄类型(如 `LineLoad`/`ImageLoad`),或复用现有 |
|
||||
| `src/app/panels/chart/LineChartView.{hpp,cpp}` | 建(仅当 TEM/测井解锁) | 折线视图(QwtPlot + QwtPlotCurve) |
|
||||
| `src/app/panels/chart/ImageChartView.{hpp,cpp}` | 建(仅当 GPR 解锁) | 图像剖面视图 |
|
||||
| `src/app/CMakeLists.txt` / `tests/CMakeLists.txt` | 改 | 注册新文件/测试 |
|
||||
|
||||
---
|
||||
|
||||
## Phase 0:样本探查(必做,先于一切实现)
|
||||
|
||||
> 目的:把矩阵里的「待核对/BLOCKED」逐一坐实。**不写任何渲染代码。**产出是「样本规格 + 真实响应夹具」,作为后续 TDD 的 RED 依据。
|
||||
|
||||
- [ ] **Task 0.1(核对 ddCode 真值)** 用 Playwright 登录原版 `http://tenant.geomative.cn`,进入 `#/projectSpace/datasetMange/datasetInfo`,遍历可见数据集,抓每个数据集列表项的 `ddCode` 字段(网络响应或前端状态)。把真实 ddCode 回填到本 plan §1 矩阵。→ verify:矩阵「推断 ddCode」列全部替换为实测值,无残留「待核对」。
|
||||
- [ ] **Task 0.2(逐类样本探查)** 对 ERT 测量原始 / ERT 测量gr / TEM / dd_grid / 轨迹 / 测井 / GPR 七类,逐一在原版找「有数据的对象」:
|
||||
- 打开其详情页,截图渲染形态(散点?等值面?折线?图像?);
|
||||
- 在 Network 抓其取数请求的**完整 URL(含 query/path 参数)+ 完整响应 JSON**;
|
||||
- 记录坐标轴语义(x/y 单位、方向、是否等比)、色阶/图例有无、是否有异常叠加。
|
||||
- → verify:每类得出「有样本 / 无样本」结论,有样本的把响应存到 `tests/fixtures/dd/<type>.json`,写进 `docs/superpowers/sample-probe-other-dd-types.md`。
|
||||
- [ ] **Task 0.3(产出规格文档)** 写 `docs/superpowers/sample-probe-other-dd-types.md`:每类一节,含「样本可得性 / 真实接口 / 响应结构 / 渲染规格 / 渲染归类 / 是否解锁」。无样本类明确标 **BLOCKED:待样本**,列「解锁前置条件」(如「需租户导入 GPR 数据 / 需测井样本数据集」)。→ verify:文档七类齐全,与 §1 矩阵一致。
|
||||
- [ ] **Task 0.4(提交)** `docs: dd 类型样本探查矩阵 + 真实响应夹具`。提交规格文档与 fixtures。
|
||||
|
||||
**Phase 0 决策门:** 完成后回到本 plan,把 §1 矩阵中实测有样本的类型从「候选」升为 Phase 2+ 的实现任务;无样本的保持 BLOCKED,不进入实现 Phase。
|
||||
|
||||
---
|
||||
|
||||
## Phase 1:打通策略分派骨架(前置,无须样本即可做)
|
||||
|
||||
> 把控制器从硬编码 `dd_inversion_data` 改为走 `ChartStrategyRegistry`。**行为对 `dd_inversion_data` 必须零回归(仍正常出图),对未注册类型仍降级「暂不支持」。** 这是所有后续 dd 类型的地基。
|
||||
|
||||
- [ ] **Task 1.1(扩展策略接口)** 先读 `IDatasetChartStrategy.hpp`、`DatasetDetailController.{hpp,cpp}` 真实签名。把 `IDatasetChartStrategy` 从「只有 `ddCode()`」扩展为能表达「该类型支持哪些加载阶段」的最小契约,**避免过度设计(YAGNI)**。建议最小形:
|
||||
- 保留 `std::string ddCode() const`。
|
||||
- 加 `bool hasGridPhase() const`(ERT 反演=true;纯散点/折线/图像类=false)—— 让控制器据此决定是否允许 `loadGridData`,替代当前对 `loadGridData` 的硬编码 ddCode 判断。
|
||||
- (渲染分派暂不进接口:现阶段 `chartReady`/`gridReady` 信号 + 现有 `DatasetDetailPage` 已驱动渲染;待新 View 真要接入时再在对应 Phase 扩展,不在 Phase 1 预先抽象。)
|
||||
- 先改 `tests/app/test_chart_strategy_registry.cpp`:加「策略报告 hasGridPhase」的断言(RED)→ 改接口 + `ErtInversionStrategy`(GREEN)。
|
||||
- → verify:`dev-test` `ChartStrategyRegistry.*` 绿。
|
||||
- [ ] **Task 1.2(控制器走注册表 — openDataset)** 改 `DatasetDetailController`:构造函数加 `app::ChartStrategyRegistry& registry`(与现有 `IAsyncDatasetRepository&` 并列)。`openDataset`:把 `if (ddCode != "dd_inversion_data")` 改为 `if (!registry.supports(ddCode)) { emit loadFailed(dsId, "暂不支持该数据类型的预览"); return; }`,其余加载逻辑暂不变(仍走 `loadChartAsync`)。
|
||||
- 先改 `tests/controller/test_dataset_detail_controller.cpp`:用「空注册表 → openDataset 任意 ddCode → 收到 loadFailed」+「注册了 dd_inversion_data → 走加载(mock repo 的 loadChartAsync 被调用)」两个用例(RED)。读现有该测试看 mock repo/句柄如何桩(`tests/data/test_dataset_load_handles.cpp` 有句柄桩可参考)。
|
||||
- → verify:`DatasetDetailController.*` 测试绿。
|
||||
- [ ] **Task 1.3(控制器走注册表 — loadGridData)** `loadGridData`:把 `if (ddCode != "dd_inversion_data") return;` 改为「查策略,`!strategy || !strategy->hasGridPhase()` → return」。补单测:注册一个 `hasGridPhase()==false` 的 fake 策略 → `loadGridData` 不触发 `loadGridAsync`。→ verify:测试绿。
|
||||
- [ ] **Task 1.4(main.cpp 接线)** 在 `main.cpp` 构造 `geopro::app::ChartStrategyRegistry registry;`,`registry.add(std::make_unique<ErtInversionStrategy>());`,把 `registry` 注入 `DatasetDetailController detailCtrl(datasetRepo, registry);`。注意生命周期:registry 须比 detailCtrl 活得久(同作用域、registry 在前声明)。
|
||||
- → verify:`dev-build` 通过;启动 app,双击 ERT 反演 ds **零回归**(原数据散点 + 网格等值面 + 异常均正常出图);双击其它类型仍显示「暂不支持」。
|
||||
- [ ] **Task 1.5(提交)** `refactor(detail): 控制器按 ddCode 走 ChartStrategyRegistry 分派, 未注册优雅降级 (替代硬编码 dd_inversion_data)`。
|
||||
|
||||
---
|
||||
|
||||
## Phase 2:ERT 测量类(散点形态,仅当 Task 0.2 确认有样本才进行)
|
||||
|
||||
> 🔓 解锁条件:Phase 0 确认 ERT 测量(`measurement/scatter/graph` 或 `measurement/rows`)有活样本且抓到真实响应。**未解锁则本 Phase 全部 BLOCKED,跳过。**
|
||||
> 渲染归类:散点类 → **复用** `ScatterPlotItem`/`RawDataChartView`,不新建 View(DRY)。
|
||||
> 接口(已核对):`GET /business/dd/ert/measurement/scatter/graph?dsObjectId=&vFieldCode=`、`GET /business/dd/ert/measurement/rows?dsObjectId=`。注意:**dsObjectId 是 query 参数(≠ 反演的 path 参数)**,且 scatter 需 `vFieldCode`(Phase 0 抓其真实取值)。
|
||||
|
||||
- [ ] **Task 2.1(DTO 解析 + 单测)** 用 `tests/fixtures/dd/ert-measurement-scatter.json` 真实响应做夹具,先写 `tests/data/test_dataset_chart_dto.cpp` 新用例断言字段映射(RED)→ 在 `DatasetChartDto.cpp`(或新拆 `MeasurementDto.cpp`,若主文件超 ~400 行)实现 `parseMeasurementScatter(QJsonObject)`(GREEN)。映射须严格按真实响应字段,**不臆造字段名**。→ verify:DTO 测试绿。
|
||||
- [ ] **Task 2.2(色阶处置)** Phase 0 确认测量散点是否有独立色阶接口/type(反演散点用 `colorGradation/getDetail` type1)。若有,按真实 type 拉;若无色阶(纯散点),ColorBar 隐藏。补单测覆盖色阶解析或缺省。→ verify:测试绿。
|
||||
- [ ] **Task 2.3(异步句柄 + 仓储)** 复用 `ChartLoad`/`ChartParts`(结构相同则直接复用;字段不同则在 `DatasetLoads.hpp` 加 `MeasurementParts` + 句柄)。在 `ApiDatasetRepository` 加 `loadMeasurementChartAsync(dsId)`(`ApiBatch` 组 scatter[+色阶] 请求 + 注入解析)。注意 query 参数编码(参考现有 `enc()`)。补 `tests/data/test_dataset_load_handles.cpp` 用例。→ verify:测试绿。
|
||||
- [ ] **Task 2.4(策略 + 控制器分派)** 新建 `MeasurementStrategy`(`ddCode()` 用 Task 0.1 实测值,`hasGridPhase()` 按 rows 是否等值面而定)。`DatasetDetailController::openDataset` 据策略选择调 `loadMeasurementChartAsync`(若加载形态与反演不同,控制器按 ddCode/策略分支;保持函数 <50 行,必要时抽私有 helper)。`main.cpp` 注册该策略。补控制器单测。→ verify:测试绿。
|
||||
- [ ] **Task 2.5(接入视图 + 视觉验收)** `DatasetDetailPage`/`RawDataChartView` 接收 `chartReady` 渲染散点。启动 app 双击测量类 ds,**对照 Phase 0 截图逐项验收**(点形/色阶/轴/等比),截图发用户确认。→ verify:视觉等价。
|
||||
- [ ] **Task 2.6(rows 形态)** 若 Phase 0 显示 `measurement/rows` 是另一种展示(如伪剖面/列表),据规格归类(等值面→复用 `ContourPlotItem`;表格→另议),重复 2.1–2.5 节奏。**形态不明则 BLOCKED 待 Phase 0 规格。**
|
||||
- [ ] **Task 2.7(提交)** 每 1–2 个 Task 一次原子提交,保持构建绿。`feat(detail): ERT 测量散点详情图 (DTO+仓储+策略+视图)`。
|
||||
|
||||
---
|
||||
|
||||
## Phase 3:TEM / 设备时序(折线形态,仅当 Task 0.2 确认有样本才进行)
|
||||
|
||||
> 🔓 解锁条件:Phase 0 确认 TEM/timeSensor 有活样本且抓到响应。**未解锁 BLOCKED,跳过。**
|
||||
> 渲染归类:时序折线类 → **新建** `LineChartView`(QwtPlot + `QwtPlotCurve`,x=时间,y=数值)。
|
||||
> 接口(已核对):`POST /business/dd/ert/timeSensor/rows`(body `DDTimeSensorDataQueryReqVO`,Phase 0 抓真实 body 字段)、`GET dd/ert/timeSensor/page`。
|
||||
|
||||
- [ ] **Task 3.1(DTO 解析 + 单测)** 用真实响应夹具 `tests/fixtures/dd/tem-timesensor.json`,先写测试(RED)→ 实现 `parseTimeSensorSeries`(→ 时间数组 + 多通道数值序列模型;模型若无现成 core 类型,加最小 `core::TimeSeries`,YAGNI)。→ verify:测试绿。
|
||||
- [ ] **Task 3.2(异步句柄 + 仓储)** 因是 POST + 不同返回,加 `SeriesLoad`/`SeriesParts` 句柄(参照 `ApiChartLoad`/`ApiGridLoad` 写 `ApiSeriesLoad`)+ `loadTimeSensorAsync(dsId)`(`postJsonAsync` 组 body)。补句柄桩单测。→ verify:测试绿。
|
||||
- [ ] **Task 3.3(LineChartView + 组件测)** 新建 `src/app/panels/chart/LineChartView.{hpp,cpp}`:QwtPlot + 每通道一条 `QwtPlotCurve` + 图例 + 平移/缩放(复用 `LivePanner` 模式)。组件测:给定 series → 断言曲线条数/点数。→ verify:测试绿。
|
||||
- [ ] **Task 3.4(策略 + 控制器 + 页面接入)** `TimeSensorStrategy`(实测 ddCode,`hasGridPhase()==false`)。控制器据策略走 `loadTimeSensorAsync` → 新增 `seriesReady` 信号 → `DatasetDetailPage` 用 `LineChartView` 渲染(页内页签按类型选 View)。main.cpp 注册。补控制器单测。→ verify:测试绿 + 启动 app 双击 TEM ds,对照 Phase 0 截图验收。
|
||||
- [ ] **Task 3.5(提交)** `feat(detail): TEM 时序折线详情图 (LineChartView+DTO+仓储+策略)`。
|
||||
|
||||
---
|
||||
|
||||
## Phase 4(BLOCKED:待样本)—— dd_grid / 轨迹 / 测井 / GPR
|
||||
|
||||
> 以下类型当前租户**无活样本**(spec §2.4 + Phase 0 复核)。**绝不规划凭想象的渲染任务**(违反 1:1 复刻原则)。本 Phase 只列「解锁前置条件 + 调研占位」,待样本到位后各自展开为类似 Phase 2/3 的 TDD 任务序列。
|
||||
|
||||
- [ ] **Task 4.1(dd_grid 网格)** 🚫 BLOCKED。
|
||||
- 解锁前置:租户内出现带数据的 dd_grid 对象,Phase 0 抓 `GET dd/ert/grid/rows?dsObjectId&pageNo&pageSize` 真实响应 + 详情页截图。
|
||||
- 预归类:等值面类 → 复用 `ContourPlotItem`/`GridDataChartView`(与反演 rows 同构则可大量复用 `parseInversionGrid` 思路;**须实测响应字段是否一致再决定复用/新 parse**)。
|
||||
- 注意:grid/rows 带分页参数,须确认是否需多页拼接。
|
||||
- [ ] **Task 4.2(轨迹 trajectory)** 🚫 BLOCKED。
|
||||
- 解锁前置:带数据的轨迹对象,抓 `GET dd/ert/trajectory/rows?dsObjectId` + `GET dd/ert/trajectory/line?dsObjectId&frontCrsCode` 响应 + 截图。
|
||||
- 预归类:路径/折线类 → 待样本定(散点连线 or `LineChartView`)。`frontCrsCode` 取值须 Phase 0 抓。
|
||||
- [ ] **Task 4.3(测井 well logging)** 🚫 BLOCKED(双重阻塞:无样本 + 接口未确认)。
|
||||
- 解锁前置:(a) 拿到测井数据样本;(b) **OpenAPI 未见明确测井 rows 接口** → Phase 0 须在原版「测井参数表」相关页面抓真实请求确认接口。
|
||||
- 预归类:折线类 → 复用 `LineChartView`(y=深度向下 / x=数值;或 x=时间 y=数值曲线,按菜单「测井参数表」两种形态,实测后定)。
|
||||
- [ ] **Task 4.4(GPR 雷达剖面)** 🚫 BLOCKED。
|
||||
- 解锁前置:GPR 对象有数据(spec 明确当前 GPR 对象无数据),抓 `GET dd/gpr/channel/image/{dsObjectId}` 响应(确认返回是图片 URL / base64 / 像素数组)+ 详情页截图。
|
||||
- 预归类:图像类 → 新建 `ImageChartView`(位图剖面 + 坐标轴叠加)。返回形态决定加载方式(图片下载 vs JSON 像素)。
|
||||
- 参考相关接口:`dd/gpr/channel/trace/spectrogram`、`dd/gpr/channel/querySegmentation`、`radar/trajectory/ds/{dsObjectId}`。
|
||||
|
||||
---
|
||||
|
||||
## 任务顺序与可构建性
|
||||
|
||||
1. **Phase 0 先行**:无样本不写代码。Phase 0 是纯调研 + 夹具,零代码风险。
|
||||
2. **Phase 1 是地基**:策略分派打通后,每个新 dd 类型才能「注册即生效」。Phase 1 必须保证 ERT 反演零回归(已有 89/89 测试 + 视觉验收兜底)。
|
||||
3. **接口/控制器改形原子落地**:`DatasetDetailController` 构造函数签名变更(加 registry)+ `main.cpp` 接线**须在同一提交**完成(否则构建红)。参照详情图 v2 「Task5+6 合并」教训:跨文件签名变更不拆提交。
|
||||
4. **Phase 2/3 仅在 Phase 0 解锁后展开**;Phase 4 永远 BLOCKED 直到样本到位。
|
||||
5. **每 1–2 个 bite-sized Task 一次提交**,每次提交前 `dev-test` 必须绿。
|
||||
|
||||
## 范围边界
|
||||
|
||||
- 本 plan 仅「详情图渲染扩展」。**不含**工具条编辑功能(白化/滤波/色阶配置/异常框注/自动标注/网格化/另存为/导出/描述富文本/大视图全屏)—— 另案(spec §2.3)。
|
||||
- 不改中央 2D/3D VTK 地图视图。
|
||||
- 不改 ApiClient 异步机制(已单独立项,详情详情链路已异步)。
|
||||
|
||||
---
|
||||
|
||||
## Self-Review(写完计划的自查结论)
|
||||
|
||||
- **核实优先**:所有接口路径/参数(query vs path、POST body schema)均已对 `docs/apis/business_OpenAPI.json` 核对(见 §1 表),未臆造。ddCode 除 `dd_inversion_data` 外源码无常量,已明确标注「待 Phase 0 实测核对」而非编造。
|
||||
- **关键现状坐实**:已读 `DatasetDetailController.cpp` 确认控制器硬编码 `dd_inversion_data`、未用注册表;`ErtInversionStrategy` 是空 stub。Phase 1「打通分派」据此设计,是真实缺口而非假想。
|
||||
- **诚实对待样本约束**:唯一确定有样本的是已完成的反演;其余全部置于 Phase 0 探查门之后。无样本类(dd_grid/轨迹/测井/GPR)一律 BLOCKED + 解锁前置条件,**未规划任何凭想象的渲染任务**,符合 1:1 复刻铁律。
|
||||
- **DRY/复用**:散点复用 `ScatterPlotItem`、等值面复用 `ContourPlotItem`、异步复用 `ApiBatch`/句柄模式;仅折线(TEM/测井)与图像(GPR)两种新形态新建 View,且各自有样本/解锁条件兜底。
|
||||
- **可构建性**:标注了「构造函数签名变更 + main.cpp 接线须同提交」的原子落地约束,避免中间态构建红。
|
||||
- **TDD 无占位符**:每个可推进 Task 给出确切文件路径、RED→GREEN 顺序、verify 命令;BLOCKED Task 给出明确解锁前置条件而非空泛描述。
|
||||
- **风险点**:(1) Phase 0 可能发现 ERT 测量/TEM 也无具体带数据对象 → 则 Phase 2/3 同样 BLOCKED,plan 仍成立(Phase 1 独立有价值)。(2) `IDatasetChartStrategy` 接口扩展刻意保守(只加 `hasGridPhase()`),避免为未解锁类型过度抽象;待新 View 真接入时再演进。
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
# 数据详情「按类型渲染」通用引擎 + 迁移 inversion + measurement_data 实现计划
|
||||
|
||||
> 2026-06-12。承接 `2026-06-11-dataset-detail-other-dd-types.md`(Phase 1 策略分派已 done)。
|
||||
> **决策(用户已拍板=选项2)**:建通用渲染引擎,并把已验收的 `dd_inversion_data` 一起迁到新引擎、删旧专用代码,终态只有一套。
|
||||
> **铁律**:不确定必用 Playwright 实看原版;严格 1:1;无活样本类型 BLOCKED。
|
||||
> **工作方式**:subagent-driven + TDD(真实夹具) + 破坏性接口改形原子落地保持构建绿。
|
||||
|
||||
## 0. 背景与动机
|
||||
|
||||
详情视图有多种 dd 类型,每类页签集 + 图形 kind 不同。现状把"两页签(chartReady/gridReady) + 两个专用 Load 句柄 + 写死 inversion 字段"耦合进三层。要支持 measurement_data / gr_data(柱状) / trajectory(轨迹) / grid(白化) 等且可持续扩展,需围绕「页签描述符」统一。
|
||||
|
||||
## 1. Phase 0 实测成果(2026-06-12,真实响应,已抓夹具)
|
||||
|
||||
API base:`http://tenant.geomative.cn/pop-api/business`。Auth header:`geomativeauthorization: Geomative <token>`。
|
||||
同一 TM(project=1438889436225536 / structParentId=1438889842614272)集齐 4 类样本:
|
||||
|
||||
| ddCode | dsId 样本 | 端点 | 数据形状 | 页签 → render kind |
|
||||
|---|---|---|---|---|
|
||||
| `dd_ert_measurement_data` 原始 | 1453611522236416 | `GET dd/ert/measurement/scatter/graph?dsObjectId=&vFieldCode=`(图) + `GET dd/ert/measurement/rows?dsObjectId=`(列表) | 图:`{electrodeList[40], scatterGraphConf{x[2],v[6],y[3],vcalculationMethod[3]}, scatterGraphData{hasCoordinate,hasElevation,min[9],max[9],rows[576]位置数组}}`;列表:`{filedList[12], rowList[576]键控对象,含 vmap{R,V,I,R0_RD,SP,R0}}` | 散点伪剖面=Scatter + 列表=Table |
|
||||
| `dd_ert_measurement_gr_data` 接地电阻 | 1453611522285569 | `GET dd/ert/measurement/gr/rows?dsObjectId=` | `array[40]` of `{electrodeId,testDate,testTime,p1Rg,p1RgStatus,p2Rg,p2RgStatus}` | 柱状图=Bar(X=electrodeId,Y=电阻Ω,系列P1) + 列表=Table |
|
||||
| `dd_trajectory_data` 坐标 | 1438893961060352 | `GET dd/ert/trajectory/rows?dsObjectId=`(+`/line?…&frontCrsCode` 400 需参数) | `{rowList[40]{electrodeNo,projectX,projectY,longitude,latitude,elevation,lineNo,channelNo,…}, gridHeaderDisplay[14]}` | 地图=PolylineMap(平面折线,非GIS底图) + 列表=Table + 高程=LineProfile |
|
||||
| `dd_grid` 白化 | 1438944148742207 | `GET dd/ert/grid/rows?dsObjectId=&pageNo=&pageSize=` | `{rowList[N]{x,y,id}, gridHeaderDisplay[2], total}` **分页** | 列表=Table(仅,分页) |
|
||||
|
||||
夹具:`tests/fixtures/dd/ert-measurement-scatter.json`、`ert-measurement-rows.json`(真实响应,rows 裁剪至 20,保 conf/min/max/electrodeList 全量)。
|
||||
|
||||
**散点位置数组列序(rows[i],与键控 rows 比对得)**:`[0]平距 [1]斜距 [2]layerNo(null) [3]伪深度(负,向下) [4]选中v原值 [5]log10(值) [6]导数态 [7]0 [8]id(str) [9]高程伪深度 [10]a [11]b [12]m [13]n [14]K [15]rowNo [16]选中v值(=色)`。默认 vFieldCode 空 → v=视电阻率 R0;x=平距;y=伪深度。色阶 cauto(同反演原数据,按 v 有限值 min/max 归一化)。
|
||||
|
||||
**渲染 kind 全集**:Scatter / FilledContour / Bar / LineProfile / PolylineMap / Table /(未来 Image,GPR BLOCKED)。
|
||||
**通用 Table**:`{gridHeaderDisplay 或 filedList(列定义) + rowList(对象)}` 形状被 measurement列表/grid白化/trajectory列表共用 → 一个 `DataTableView` + 一个 `parseTable` 通吃(分页与列格式化为 parse-time/decorator,见 §4)。
|
||||
|
||||
## 2. 目标架构
|
||||
|
||||
依赖箭头:`controller(ViewKind/TabSpec/Controller) ← app/strategies, app/views ; data(DetailLoad/Repo) 产出 QVariant 载荷`。
|
||||
|
||||
### 2.1 载荷(payload)——`src/model/detail/DetailPayloads.hpp`(纯数据,无 Qt-widget 依赖,Q_DECLARE_METATYPE)
|
||||
- `ScatterPayload { core::ScatterField scatter; core::ColorScale scale; }`(反演原数据 + measurement 散点共用;≈现 `ChartParts`)
|
||||
- `ContourPayload { core::Grid grid; core::ColorScale scale; std::vector<core::Anomaly> anomalies; }`(≈现 `GridParts`)
|
||||
- `TablePayload { std::vector<TableColumn> columns; std::vector<std::vector<QString>> rows; int total = 0; }`;`TableColumn { QString code, title; int width; int sort; }`
|
||||
- (Bar/LineProfile/Polyline 载荷待对应类型阶段再加,YAGNI)
|
||||
|
||||
### 2.2 页签描述符 + 策略——`src/controller/DatasetDetailTab.hpp` + `IDatasetChartStrategy.hpp`
|
||||
```cpp
|
||||
enum class ViewKind { Scatter, FilledContour, Bar, LineProfile, PolylineMap, Table /*,Image*/ };
|
||||
struct TabSpec { QString title; ViewKind kind; QString loaderKey; bool lazy=false; bool paginated=false; };
|
||||
struct IDatasetChartStrategy { virtual std::string ddCode() const=0; virtual std::vector<TabSpec> tabs() const=0; }; // tabs() 取代 hasGridPhase()
|
||||
```
|
||||
`ChartStrategyRegistry` 不变。`ErtInversionStrategy::tabs()` = `{{"原数据",Scatter,"inversion.scatter",false},{"网格数据",FilledContour,"inversion.grid",true}}`。
|
||||
|
||||
### 2.3 通用异步句柄——`src/data/api/DatasetLoadHandles.*`
|
||||
合并 `ChartLoad`/`GridLoad` → 单 `DetailLoad{ virtual void abort(); signals: done(QVariant); failed(QString); }` + `ApiDetailLoad`(包 ApiBatch + Parser=`std::function<QVariant(const QList<net::ApiResponse>&)>`)。**aborted_ 守卫 + 双 try/catch→failed + deleteLater 三件套从 ApiChartLoad 原样照搬**。删 `ChartParts`/`GridParts`(被 payload 取代)或保留作中间态(由实现者定,优先删以免双份)。
|
||||
|
||||
### 2.4 仓储——`IAsyncDatasetRepository::loadAsync(loaderKey,dsId)` + `ApiDatasetRepository` 分派表
|
||||
唯一端点+解析器集中处。`if (key=="inversion.scatter") return makeInversionScatter(dsId); …`;未知 key 抛。每 make 建 ApiBatch + 注入返回 `QVariant::fromValue(payload)` 的解析器。删 `loadChartAsync/loadGridAsync`。
|
||||
> measurement scatter 的 `dsObjectId` 是 **query 参数**(`?dsObjectId=&vFieldCode=`,默认 vFieldCode 空),≠反演 path 参数;用现有 `enc()`。
|
||||
|
||||
### 2.5 控制器——`DatasetDetailController` 变通用(无 per-ddCode if)
|
||||
```cpp
|
||||
slots: openDataset(dsId,ddCode,dsName), loadTab(dsId,ddCode,tabIndex), focusDataset(dsId);
|
||||
signals: datasetOpened(dsId,dsName, std::vector<TabSpec>), tabLoadStarted(dsId,tabIndex),
|
||||
tabReady(dsId,tabIndex,QVariant), loadFailed(dsId,msg), focusRequested(dsId);
|
||||
```
|
||||
`openDataset`:查 registry,无→`loadFailed("暂不支持…")`(降级不变);`emit datasetOpened(...,strategy->tabs())`;对每个非 lazy 页签 `loadTab`。
|
||||
`loadTab`:**唯一保存 §5.0 安全不变量处**,按 tab 槽位:`inflight_`=`QMap<int,QPointer<DetailLoad>>`;abort 旧槽 → 新 load → `tabLoadStarted` → done 回调里 `if (load!=inflight_.value(i)) return;`(身份比对)→ `tabReady`。析构 abort 全部。删 `chartReady/gridReady/ChartData/GridData/loadGridData`。
|
||||
|
||||
### 2.6 视图 + 工厂 + 壳
|
||||
- `src/app/panels/chart/IDetailView.hpp`:`{ QWidget* widget(); void setPayload(const QVariant&); }`。
|
||||
- `RawDataChartView`/`GridDataChartView` 实现 IDetailView:`setPayload` 解包 `ScatterPayload`/`ContourPayload` → 调原 `setData(...)`(**渲染代码零改**)。坏/空 variant → 可见"渲染数据格式错误"不崩。
|
||||
- `DetailViewFactory.{hpp,cpp}`:`makeDetailView(ViewKind,parent)` switch → 对应视图。
|
||||
- `DatasetDetailPage`:`build(tabs)` 按 TabSpec 建页签(`buildTabbedPanel` + 工厂);lazy 页签首次激活发 `tabNeeded(dsId,ddCode,i)`(泛化现 gridDataNeeded);`setTabPayload(i,QVariant)` → `views_[i]->setPayload`。删写死 `rawView_/gridView_/kGridTabIndex`。
|
||||
- `DatasetDetailPanel`:`onDatasetOpened(dsId,dsName,tabs)` 建 page→build;按 dsId 转发 tabReady/tabNeeded。dsId 去重壳逻辑不动。
|
||||
- `main.cpp`:接线改 datasetOpened/tabReady/tabNeeded/tabLoadStarted;registry 注册不变(后续加 MeasurementStrategy)。**与控制器改形同提交(原子)。**
|
||||
|
||||
## 3. 实施序列(任务)
|
||||
|
||||
### E1a 引擎地基(加性,构建保持绿) [task #1]
|
||||
新增 payloads / TabSpec/ViewKind / DetailLoad+ApiDetailLoad(与旧句柄并存) / `loadAsync` 分派(inversion.scatter/grid 产 payload),与旧 `loadChartAsync/loadGridAsync` 并存。TDD:DTO→payload、句柄桩(`tests/data/test_dataset_load_handles.cpp` 仿现有桩)、loadAsync 分派。**构建+测试全绿,旧路径不动。**
|
||||
|
||||
### E1b 破坏性改形 + inversion 迁移(原子) [task #2]
|
||||
控制器/策略接口/视图 IDetailView/工厂/Page/Panel/main.cpp 全部切到 tab 引擎;删旧 `chartReady/gridReady/ChartData/GridData/ChartLoad/GridLoad/loadChartAsync/loadGridAsync/hasGridPhase`。改全部相关测试(controller/strategy_registry/handles)。**一个提交内落地,构建+测试绿 + 跑 app 双击标准 ds `1458990939709440` 目视回归两页签(散点+网格等值面+异常)正常。**
|
||||
|
||||
### E2 measurement_data(③散点伪剖面 + 列表) [task #3]
|
||||
|
||||
**Phase 0 色阶补查(2026-06-12,已坐实,夹具齐)**:measurement 散点**有 colorBar**,来自 `POST lvl/colorGradation/getDetail` body **`{dsObjectId, businessCode:"<vFieldCode,默认R0>", type:3}`**(注意:businessCode=v字段码、type=3,≠反演的 type1/2;用 businessCode='' 会返回 null)。响应是**与反演同构的混合格式 colorBar**(hex + rgba alpha 0–1,18 个对数间距 stops `0.00→#00008B … 208.40→#FF0000 … 1323.20→rgba(48,0,48,1)`,`lvlSchemeType:normal`/equalAreaLayerCount/labelConfig/lineConfig)——**现有 `DatasetChartDto` colorBar 解析器(AlphaScale::Unit)直接可用**。截图核对:散点按 v 值**绝对值离散映射**到 colorBar(同反演**网格** `colorAtDiscrete`,**非**反演原数据的连续 cauto),右侧 ColorBarWidget 显示离散级。夹具:`tests/fixtures/dd/ert-measurement-{scatter,rows,colorbar}.json`;原版截图 `meas-scatter-original.jpeg`。
|
||||
|
||||
**⚠️ 着色模型修正(2026-06-12 读原版 Plotly `_fullData` 实测,推翻"离散"判断)**:原版是 **Plotly scattergl**,散点**连续 cauto 着色**(非离散!):`marker.color`=原始 v 值(col4,**含负值**,本例范围 -1066.59~1232.44);`cauto:true` → `cmin/cmax`=数据 v 的真实 min/max(**含负离群值**);`colorscale`=连续 18 段,**position=colorBar值/maxColorBar值(1323.20)**;上色=`norm(v)=(v-cmin)/(cmax-cmin)` 查 colorscale 连续插值。验证 v=50.18→norm 0.4857→暗紫(stop 0.358 与 0.540 之间)。**这与 inversion 原数据完全同路(交接 §0.2:cauto 数据范围 + colorscale 位置 val/maxVal),measurement 必须用 `discreteColor=false` 走同一条已验收路径**,而非我 E2 误用的 colorAtDiscrete。负离群值撑大数据范围 → R0=25~250 全挤色阶中段暗紫,故原版几乎全暗紫(仅极端值现橙/蓝)。colorBar 图例(竖排右侧离散色块标 0.00~1323.20)独立于 cauto,显示 colorBar 自身 value→color。hover=浮动黑底框 X/Y/Value/a/b/m/n(另有底部 footer A=B=M=N=DataRow=Pseu_Resis=)。
|
||||
|
||||
**散点渲染规格(截图确认)**:x=**斜距 slopeDistance**(toolbar 默认,col1;≈平距,几乎不可辨,存疑标用户核对)、y=**伪深度 pseudoDepth**(col3,负向下)、色=**视电阻率 R0**(col16,离散 colorBar)、x 轴在**顶部**(同反演原数据)、y=0 处画 **40 个灰色菱形=电极位置**(electrodeList 的 x)、hover 显示 `A= B= M= N= DataRow= Pseu_Resis=`(col10-13=A/B/M/N + 值)。工具条(散点图/数据列表 切换、显示/隐藏、数据过滤、x/y/v/计算方法下拉、色阶配置、生成视电阻率、反演运算、另存为、导出)=**范围外**,MVP 只渲染默认静态视图。
|
||||
- 端点:scatter `GET dd/ert/measurement/scatter/graph?dsObjectId=&vFieldCode=`(空=默认R0)+ colorBar `POST lvl/colorGradation/getDetail{dsObjectId,businessCode:"R0",type:3}`;列表 `GET dd/ert/measurement/rows?dsObjectId=`。loaderKey `ert_measurement.scatter` 用 ApiBatch 组 scatter+colorBar 两请求(类比反演 loadChartAsync 的 scatter+type1)。
|
||||
|
||||
- `src/data/dto/MeasurementDto.{hpp,cpp}`:`parseMeasurementScatter(scatterJson, colorBarJson)→ScatterPayload`(位置数组 rows:x=col1 斜距/y=col3 伪深度/色值=col16;colorBar 复用现有解析器→离散 ColorScale;电极 electrodeList→灰菱形,可放 ScatterPayload 扩展或单列);`parseTable(QJsonObject)→TablePayload`(通用:列来自 `gridHeaderDisplay` 或 `filedList`,值预格式化为 QString;measurement 的 `vmap` 由解析器摊平为列——按 filedList 列码取 vmap[code])。
|
||||
- `src/app/panels/chart/DataTableView.{hpp,cpp}`:IDetailView + QTableView + QAbstractTableModel(由 TablePayload 构),列宽/标题/排序来自 TableColumn。
|
||||
- `src/app/strategies/MeasurementStrategy.hpp`:ddCode=`dd_ert_measurement_data`,tabs=`{{"散点伪剖面",Scatter,"ert_measurement.scatter",false},{"列表",Table,"ert_measurement.rows",true}}`。
|
||||
- `ApiDatasetRepository`:加 `ert_measurement.scatter`(scatter/graph,query 参数 + 默认 vFieldCode 空)、`ert_measurement.rows`(measurement/rows)loaderKey。
|
||||
- `DetailViewFactory`:加 `Table` case。`main.cpp`:注册 MeasurementStrategy 一行。
|
||||
- TDD 用 `tests/fixtures/dd` 夹具:`tests/data/test_measurement_dto.cpp`(散点位置解码/y 负号/列提取/vmap 摊平) + 控制器 2 页签谱 + loadAsync 分派。
|
||||
- 构建+测试绿 + 跑 app 双击「ERT原始数据」对照原版散点伪剖面 + 列表目视核对。
|
||||
|
||||
## 4. 风险与裁断(architect 评估)
|
||||
|
||||
1. **不要过度泛化控制器信号面**:`tabReady(dsId,i,QVariant)` 单信号已吸收全部近期形状(类型擦除)。激进泛化"句柄/载荷"(合一 DetailLoad/QVariant,是纯去重),但控制器信号保持扁平最小。分页的 `pageNo` 等真做 dd_grid 再加一个默认参,加性无重构。
|
||||
2. **QVariant 类型擦除以编译期安全换扩展性**:错的 loaderKey→ViewKind→payload 三元组是运行期 cast miss 而非编译错。缓解:downcast 仅在每视图 `setPayload` 一处;坏 variant 出可见错误不崩(配合现 GuardedApplication::notify);每策略一条 round-trip 测试。接受此权衡(否则 N×3 个 XxxLoad/XxxReady/XxxData 爆炸)。
|
||||
3. **不要提前耦合 BLOCKED 的 GPR/radar**:引擎结构上可容纳(加 ViewKind::Image/GisMap + 新视图+解析器+loaderKey,控制器/句柄零改),故不被锁死;但现在不加任何 enum 值/桩(1:1 + 无样本)。trajectory 的 PolylineMap 是金丝雀,证明非图非表视图也能套进引擎。
|
||||
|
||||
**通用 Table 的边界**:list 形状(measurement/trajectory/grid)共用对;但 ① 分页(dd_grid 独有)= `PaginatedTableView` 装饰器或 spec `paginated` 标志 + page 发 `loadTab(...,pageNo)`,**基类 DataTableView 保持哑**;② 列格式化/值语义(measurement 的 vmap 摊平、gr 的 status 标志着色)= **parse-time** 处理(TablePayload 存预格式化 QString),需要彩色状态格时再加 `TableColumn.kind` 提示(YAGNI)。bar 不是表复用——gr_data 主页签是 Bar,列表页签才复用 parseTable。
|
||||
|
||||
## 5. 安全不变量(spec §5.0,务必保留)
|
||||
|
||||
"abort 后绝不回灌"三件套:① 每层 `aborted_` 入口守卫;② 控制器句柄身份比对(`if (load!=inflight_.value(i)) return;`);③ 一律 `deleteLater`。错误判定:业务 `code!=200 || !rawError.isEmpty()`(HTTP 200 也可能 code=500)。迁移后这三件套从"按 Chart/Grid 阶段"改"按页签槽位",逐字保留,不得重造削弱。
|
||||
|
||||
## 6. 构建/测试命令
|
||||
|
||||
- 构建:`powershell.exe -ExecutionPolicy Bypass -File scripts/dev-build.ps1`(LNK1104 先 `Get-Process geopro_desktop|Stop-Process -Force`)。
|
||||
- 测试:`powershell.exe -ExecutionPolicy Bypass -File scripts/dev-test.ps1`(先 build)。基线 **122/122 绿**。
|
||||
- 单测过滤:`build/release/tests/geopro_tests.exe --gtest_filter=<Suite>.*`。
|
||||
|
|
@ -0,0 +1,298 @@
|
|||
# 设计:ApiClient 异步化(DatasetDetail 路径试点)
|
||||
|
||||
> 日期 2026-06-11。范围:把数据集详情路径从同步阻塞改为异步非阻塞,作为全 App 异步化的模式样板。
|
||||
> 后续会按此模式铺开到导航/登录路径(本期不做)。
|
||||
|
||||
## 状态更新(2026-06-11)
|
||||
|
||||
**DatasetDetail 试点:✅ 已完成并通过评审。** 实现计划 `plans/2026-06-11-apiclient-async-datasetdetail.md`(8 任务,逐任务 spec+质量双评审 + 整体评审)。测试 75 → 89(+14 离线用例)全绿。落地原语:`IApiCall`/`ApiCall`/`ApiBatch`(net)、`ChartLoad`/`GridLoad`/`IAsyncDatasetRepository`(data)、控制器 abort-and-replace + 句柄身份比对 + 退出契约、`LoadingOverlay` 网格懒加载遮罩。核心收益落地:详情路径不冻 UI、慢请求可 abort 不回灌、多请求并发 + fail-fast。
|
||||
|
||||
**铺开进展(2026-06-12 更新):**
|
||||
- **导航路径 ✅**(计划 `plans/2026-06-11-apiclient-async-rollout.md` Part A):新增 `ApiChain`(串行依赖链原语)、`NavRequest`(单请求句柄,QVariant payload)、`IAsyncProjectRepository`;`WorkbenchNavController` 全异步(NavRequest 续延 + 并发计数 + abort-and-replace + 身份比对,删 busy_/drain,busyChanged=在飞存在性)。
|
||||
- **登录路径 ✅**(同计划 Part B:B1/B2/B4):`AuthService` 异步(`CaptchaLoad`/`LoginLoad` + `ApiChain` 编排 verify→RSA→login2);`LoginWindow` 不冻 + 可取消(析构 abort);`test_auth` live 异步化。
|
||||
- 测试 89 → 116。每块逐任务 spec+质量双评审 + 整体评审通过。
|
||||
|
||||
**技术债清除 ✅(2026-06-12):** `ProjectListDialog` 已迁到 `IAsyncProjectRepository`(NavRequest + abort-and-replace + 身份比对 + 析构 abort),随即删除同步 `IProjectRepository`、`RepoResult`、`ApiProjectRepository` 9 个同步方法、`ApiClient` 同步 `get/postJson`+`await`(A6+B3 解锁完成)。**全 App 网络层现已 100% 异步,无 `QEventLoop` 阻塞、无过渡双接口债。** 测试 116/116。
|
||||
|
||||
**结论:异步化主题完成。** 数据详情(试点)+ 导航(Part A)+ 登录(Part B)+ 项目列表弹窗全部异步;同步路径彻底移除。
|
||||
|
||||
可选 follow-up(评审建议,非阻断,纯整洁):删 `DatasetDetailController::ChartData.grid/gridScale` 死字段;如未来引入 cross-thread 再补 `qRegisterMetaType<QList<ApiResponse>>()`。
|
||||
|
||||
> 铺开实现计划:`plans/2026-06-11-apiclient-async-rollout.md`(Part A/B + 债务清除均已落地)。
|
||||
|
||||
## 1. 背景
|
||||
|
||||
geopro 现网络栈三层全同步阻塞:
|
||||
|
||||
- **`ApiClient`**(`src/net/`):单个 `QNetworkAccessManager`,`get/postJson` 内部用 `QNetworkReply + QEventLoop` 死等响应。共享 cookie / JSESSIONID。
|
||||
- **Repository**(`ApiDatasetRepository`、`ApiProjectRepository`):同步调 `api_.get/postJson`,解析后返回 typed 值或抛 `std::runtime_error`。
|
||||
- **Controller**(`DatasetDetailController`、`WorkbenchNavController`):在 slot 内同步调 repo,再 `emit` 结果信号。
|
||||
|
||||
**核心问题**:每次请求都用 `QEventLoop` 阻塞 UI 线程 → 全 App 冻结。其中数据集详情「网格数据」页签的 `dd/ert/inversion/rows/{id}` 请求因**服务端网格化波动 1–4s**,期间界面完全卡死。
|
||||
|
||||
`QEventLoop` 阻塞的副作用是**可重入**:阻塞期间 Qt 仍泵事件,用户的双击/切换会重入 slot,因此现有控制器到处用 `busy_` 守卫挡重入——这些守卫本身就是同步阻塞架构的产物。
|
||||
|
||||
`QNetworkAccessManager` 本就是异步设计;当前架构用 `QEventLoop` 把它人为变同步,是反模式。本设计回归 Qt 原生异步。
|
||||
|
||||
## 2. 目标与非目标
|
||||
|
||||
### 目标
|
||||
|
||||
1. 数据集详情路径(原数据 + 网格数据)全程**不阻塞 UI 线程**。
|
||||
2. 慢请求在飞时切换/关闭数据集页签可**主动 abort 过期请求**,结果不回灌。
|
||||
3. 同一逻辑加载内的多个独立请求**并发**发出(原数据 2 个、网格 3 个),缩短总等待。
|
||||
4. 加载期间给用户**轻量"加载中"反馈**(不再因冻结而"看起来在响应")。
|
||||
5. 产出**可复用的异步原语**(`ApiCall` / `ApiBatch` / 句柄模式),供后续导航/登录路径照搬。
|
||||
6. 现有测试不退化;新增异步路径的单测可离线运行。
|
||||
|
||||
### 非目标(本期不做)
|
||||
|
||||
- **不**改 `WorkbenchNavController` / `ApiProjectRepository` / `AuthService` / 登录流程——它们继续走同步 `ApiClient.get/postJson`。
|
||||
- **不**做后台工作线程:网络是 I/O 密集,`QNetworkAccessManager` 原生异步即可,不引入 `QThread`/`QtConcurrent`。
|
||||
- **不**追求与原版 loading 动画像素级一致(轻量提示即可,细抠后续单独做)。
|
||||
- **不**改 `LocalSampleRepository`(它实现旧同步 `IDatasetRepository`,但不经详情控制器使用)。
|
||||
|
||||
## 3. 已定决策(及理由)
|
||||
|
||||
| 决策 | 选择 | 理由 |
|
||||
|---|---|---|
|
||||
| 范围 | 仅 DatasetDetail 路径 | 风险小、最痛点(1–4s rows)先解决、产出可复用模式 |
|
||||
| 取消语义 | 主动 abort 过期请求 | 体验最好;句柄对象使 abort 成为一等公民 |
|
||||
| 加载反馈 | 轻量"加载中"遮罩 + 禁用交互 | 不阻塞后必须给反馈;先跑通机制 |
|
||||
| 异步机制 | 方案 A:每请求一个 QObject 句柄(信号 + abort) | 纯 Qt 信号、贴合现有信号驱动代码库;abort 用具体句柄最干净(QFuture 的 cancel 不中断底层 reply,反而要再加句柄);测试用 QSignalSpy 顺手 |
|
||||
| 多请求汇聚 | **fail-fast with abort**(任一项失败立即回报并 abort 其余在飞 call) | 慢的 rows 1–4s,若 colorScale 200ms 先失败,gather-all 会让用户干等 1–4s 才看到失败(失败比成功还慢,违背"及时反馈")。fail-fast 依赖下方「abort 闸门」机制杜绝迟到回灌,原"避免部分-abort 竞态"的顾虑已被该机制消解。成功路径仍需全部到齐 |
|
||||
| **abort 闸门(核心安全机制)** | 链路级 `aborted_` 标志 + 句柄身份比对 | 见 §5.1/§5.3:仅靠 disconnect 挡不住「已 emit、正在事件队列等待派发」的迟到信号;必须每层入口判 `aborted_` + 控制器比对句柄身份,才能兑现"abort 后绝不回灌"的核心承诺 |
|
||||
|
||||
## 4. 架构
|
||||
|
||||
```
|
||||
DatasetDetailController (QObject,状态/编排)
|
||||
│ 调 repo.loadChartAsync(id) / loadGridAsync(id),持有返回的 *Load 句柄
|
||||
│ 切换/关页 → 句柄.abort()
|
||||
▼
|
||||
IAsyncDatasetRepository(纯抽象) ←── ApiDatasetRepository 实现
|
||||
│ 每次 loadXxxAsync 创建一个 *Load 句柄,内部用 ApiBatch 并发 N 个 ApiCall
|
||||
│ batch 全部完成 → 逐项校验+解析 → emit done(Parts) / failed(msg)
|
||||
▼
|
||||
ApiClient.getAsync(path) → ApiCall(包一个 QNetworkReply,实现 IApiCall)
|
||||
(ApiClient 保留同步 get/postJson 供 Nav/登录继续使用,共享同一 QNetworkAccessManager)
|
||||
```
|
||||
|
||||
**信号面保持不变**:`DatasetDetailController` 对外仍发 `chartReady(ChartData)` / `gridReady(GridData)` / `loadFailed(dsId,msg)` / `focusRequested(dsId)`,新增 `loadStarted(dsId, Phase)`。因此 `main.cpp` 接线与 `DatasetDetailPanel` 几乎不动,仅接 `loadStarted` 显示遮罩。
|
||||
|
||||
## 5. 组件设计
|
||||
|
||||
### 5.0 核心安全不变量:abort 后绝不回灌(贯穿全链路)
|
||||
|
||||
> 这是本设计成败的关键。`disconnect` 只能阻止「将来才发」的信号,**挡不住「已经 emit、已转成 `QMetaCallEvent` 在事件队列中等待派发」的迟到信号**(尤其在共享 NAM 同步嵌套 `QEventLoop` 期间,见 5.1)。因此 disconnect 仅作"尽力而为",真正的权威闸门是下面两条:
|
||||
|
||||
1. **每层 `aborted_` 标志 + 入口守卫**:`ApiCall` / `ApiBatch` / `ChartLoad` / `GridLoad` 各持一个 `bool aborted_`。所有 `finished`/`done`/`failed` 槽**入口第一行** `if (aborted_) return;`。`abort()` 先置 `aborted_=true` 再断连/中断。聚合 emit 前同样判 `aborted_`。
|
||||
2. **控制器句柄身份比对**:`DatasetDetailController` 在 `done`/`failed` 槽内校验「发信号的句柄 == 当前持有的 `chartLoad_`/`gridLoad_`」(用 `sender()` 或 lambda 捕获句柄指针比对),过期句柄的迟到信号直接丢弃。与 abort-and-replace 形成纵深防御。
|
||||
3. **销毁一律 `deleteLater`**:`ApiCall`/`ApiBatch`/`*Load`/`QNetworkReply` 在 abort 或完成后**一律 `deleteLater`,禁止任何同步 `delete`**——避免「abort 一个正在自己回调栈中的对象」导致 use-after-free。
|
||||
|
||||
### 5.1 net 层
|
||||
|
||||
#### `IApiCall`(新增,抽象,可测试缝)
|
||||
```cpp
|
||||
// src/net/IApiCall.hpp
|
||||
class IApiCall : public QObject {
|
||||
Q_OBJECT
|
||||
public:
|
||||
using QObject::QObject;
|
||||
virtual void abort() = 0; // 中断;不再 emit finished;deleteLater
|
||||
signals:
|
||||
void finished(const geopro::net::ApiResponse& resp); // 成功/错误均经此(错误写 rawError)
|
||||
};
|
||||
```
|
||||
存在意义:`ApiBatch` 只依赖 `IApiCall`,单测可注入假 call(不碰真实网络),离线测 gather/abort。
|
||||
|
||||
#### `ApiCall`(新增,实现 `IApiCall`)
|
||||
- 持 `bool aborted_=false`(见 §5.0)。
|
||||
- 构造接管一个 `QNetworkReply*`;连接 `reply->finished` → 槽入口判 `if (aborted_) return;` → 解析为 `ApiResponse`(复用现 `parseBody`)→ emit `finished` → `deleteLater()` 自删。
|
||||
- `abort()`:`aborted_=true` → `disconnect` 自身与 reply 的连接 → `reply->abort()` → `reply->deleteLater()` → `this->deleteLater()`;**禁止同步 delete**。`reply->abort()` 本身会再触发一次 `finished`(OperationCanceledError),已被 disconnect + `aborted_` 双重挡掉。
|
||||
- 跨 `QueuedConnection` 传 `ApiResponse` 需 `qRegisterMetaType<geopro::net::ApiResponse>()`(应用启动时注册一次)。
|
||||
|
||||
#### `ApiClient`(扩展,不破坏现有)
|
||||
```cpp
|
||||
net::IApiCall* getAsync(const QString& path); // 立即返回,不阻塞
|
||||
net::IApiCall* postJsonAsync(const QString& path, const QJsonObject& body);
|
||||
// 保留:ApiResponse get(path); ApiResponse postJson(path, body); // Nav/登录继续用
|
||||
```
|
||||
- `getAsync/postJsonAsync` 用同一 `nam_` 发起,返回 `new ApiCall(reply)`。token 注入、`buildRequest` 复用。
|
||||
- **已知限制(非"无害",受 §5.0 三条约束兜底)**:Nav 的同步嵌套 `QEventLoop`(`ApiClient::get` 的 `loop.exec()`)期间,详情的异步 reply 若完成会被一并泵出,其整条槽链(ApiCall→ApiBatch→*Load→Controller→`chartReady`→`detailPanel->openOrUpdate`)会**在 Nav 同步调用栈、嵌套循环里重入执行**,即详情 UI 重建可能发生在 Nav 网络调用返回前。这不是"互不写状态"能涵盖的,但有 §5.0 兜底:(a) 一律 `deleteLater` 杜绝栈内同步释放;(b) 各层 `aborted_` 入口守卫;(c) 控制器句柄身份比对。本期 Nav 仍同步、窗口期有限;**全异步后该同步路径与本限制一并消失**。可选缓解(YAGNI,暂不做):详情完成回调改 `QueuedConnection` 投递,避免在嵌套栈内驱动 UI。
|
||||
|
||||
#### `ApiBatch`(新增,汇聚原语,fail-fast)
|
||||
```cpp
|
||||
// src/net/ApiBatch.hpp
|
||||
class ApiBatch : public QObject {
|
||||
Q_OBJECT
|
||||
public:
|
||||
// calls 接管所有权;predicate 由 repo 注入,判定单个 ApiResponse 是否业务失败
|
||||
ApiBatch(QList<net::IApiCall*> calls,
|
||||
std::function<bool(const net::ApiResponse&)> isFailure,
|
||||
QObject* parent=nullptr);
|
||||
void abort(); // aborted_=true,abort 全部未完成子 call,self deleteLater,不 emit
|
||||
signals:
|
||||
void succeeded(const QList<geopro::net::ApiResponse>& responses); // 全部成功,按下标对齐
|
||||
void failed(int index, const geopro::net::ApiResponse& resp); // 首个失败项
|
||||
};
|
||||
```
|
||||
- **所有权契约**:内部用 `QList<QPointer<net::IApiCall>>` 持有子 call;子 call 正常 `finished` 后自删(`QPointer` 自动置空)。`abort()` 遍历时跳过空指针。子 call parent 不设为 batch(各自 `deleteLater` 自管),靠 `QPointer` + `aborted_` 防悬垂。
|
||||
- 持 `bool aborted_`(§5.0)。每个子 call `finished` 槽入口判 `aborted_`。
|
||||
- **fail-fast**:每个子 call `finished` 到达时立即用 `isFailure(resp)` 判定——失败则 `emit failed(i, resp)` + `aborted_=true` + abort 其余在飞子 call + `deleteLater`;成功则记入 `responses_[i]`,全部到齐 → `emit succeeded(responses)` + `deleteLater`。
|
||||
- 业务/传输/HTTP 三类失败统一由 `isFailure` 判定(见 §7)。
|
||||
|
||||
### 5.2 data 层
|
||||
|
||||
#### data 层结果载体(中性,避免 data→controller 反向依赖)
|
||||
```cpp
|
||||
// src/data/api/DatasetLoads.hpp
|
||||
struct ChartParts { core::ScatterField scatter; core::ColorScale scatterScale; };
|
||||
struct GridParts { core::Grid grid{1,1}; core::ColorScale gridScale; std::vector<core::Anomaly> anomalies; };
|
||||
```
|
||||
|
||||
#### `*Load` 句柄(每次加载一个,承载 typed 结果 + abort)
|
||||
```cpp
|
||||
class ChartLoad : public QObject {
|
||||
Q_OBJECT
|
||||
public:
|
||||
void abort(); // aborted_=true,转发给内部 ApiBatch
|
||||
signals:
|
||||
void done(const geopro::data::ChartParts& parts);
|
||||
void failed(const QString& message);
|
||||
};
|
||||
class GridLoad : public QObject { /* done(GridParts) / failed(QString) / abort() */ };
|
||||
```
|
||||
- 持 `bool aborted_`(§5.0);连 `ApiBatch::succeeded` 槽入口判 `aborted_` 后解析→ emit `done`→ `deleteLater`;连 `ApiBatch::failed` → 取 `msg`(见 §7)→ emit `failed`→ `deleteLater`。`abort()` 置 `aborted_` 并转发给内部 `QPointer<ApiBatch>`。
|
||||
- 用句柄对象做"每次逻辑加载"的天然关联点 + abort 目标(避免请求 id 簿记)。
|
||||
|
||||
#### 旧同步方法去向(接口改形审计)
|
||||
`ApiDatasetRepository` 不再继承 `IDatasetRepository`。旧 6 个同步方法去向:
|
||||
|
||||
| 旧方法 | 去向 |
|
||||
|---|---|
|
||||
| `loadStructure()` | **丢弃**(详情路径不用;现实现即返回 `{}` 占位;结构树走 `LocalSampleRepository`/Nav) |
|
||||
| `loadScatter` + `loadScatterColorScale` | 合并进 `loadChartAsync` |
|
||||
| `loadGrid` + `loadColorScale` + `loadAnomalies` | 合并进 `loadGridAsync` |
|
||||
|
||||
`LocalSampleRepository` **继续实现同步 `IDatasetRepository`**,二接口并存;`buildWorkbench` 的 `repo` 形参类型不变。已核对:无任何处把 `ApiDatasetRepository` 当 `IDatasetRepository*` 用于 `loadStructure`,丢弃安全。
|
||||
|
||||
#### `IAsyncDatasetRepository`(新增抽象,详情控制器依赖它)
|
||||
```cpp
|
||||
// src/data/repo/IAsyncDatasetRepository.hpp
|
||||
class IAsyncDatasetRepository {
|
||||
public:
|
||||
virtual ~IAsyncDatasetRepository() = default;
|
||||
virtual data::ChartLoad* loadChartAsync(const std::string& dsId) = 0; // scatter + scatterScale(type1)
|
||||
virtual data::GridLoad* loadGridAsync(const std::string& dsId) = 0; // grid(rows) + scale(type2) + anomalies
|
||||
};
|
||||
```
|
||||
- 复合方法(而非旧 6 个原子 `loadXxx`):原子方法把"该发哪几个请求"泄漏给了控制器;汇聚+abort 是核心复杂度,集中放 repo(它本就知道请求集)是正确归属,控制器保持轻薄。
|
||||
|
||||
#### `ApiDatasetRepository`(改造)
|
||||
- 不再继承同步 `IDatasetRepository`;改实现 `IAsyncDatasetRepository`。
|
||||
- `loadChartAsync`:建 `ApiBatch{ getScatter, getScatterScale }`,注入 `isFailure`(见 §7)→ `succeeded` 时复用现有 DTO 解析(`DatasetChartDto` 等)填 `ChartParts` 并 `done`;`failed` → 取错误原因并 `failed`。
|
||||
- `loadGridAsync`:`ApiBatch{ getRows, getColorScale, getException }` → 同构填 `GridParts`。
|
||||
- 现有同步解析/校验逻辑(DTO、v 行数校验、markType 钳制)与 `must()` 的判定(`code==200`)**原样搬入**,不重写——`must()` 的判定提炼为 batch 的 `isFailure` 谓词。
|
||||
- 空数据非失败:异常列表可能为空——业务 `code==200` 且 data 为空走成功(空 `GridParts.anomalies`),与现状一致。
|
||||
|
||||
### 5.3 controller 层
|
||||
|
||||
#### `DatasetDetailController`(改造)
|
||||
```cpp
|
||||
enum class Phase { Chart, Grid };
|
||||
signals:
|
||||
void loadStarted(const QString& dsId, Phase phase); // 新增:驱动加载遮罩
|
||||
// 不变:chartReady(ChartData)/gridReady(GridData)/loadFailed(dsId,msg)/focusRequested(dsId)
|
||||
private:
|
||||
IAsyncDatasetRepository& repo_;
|
||||
QPointer<data::ChartLoad> chartLoad_; // 当前在飞(QPointer 防悬垂)
|
||||
QPointer<data::GridLoad> gridLoad_;
|
||||
```
|
||||
- `openDataset(dsId, ddCode)`:ddCode 非 `dd_inversion_data` → `loadFailed`;否则 **若 `chartLoad_` 在飞先 `abort()`**,`repo_.loadChartAsync` 取新句柄存入 `chartLoad_`,emit `loadStarted(dsId, Chart)`,连:
|
||||
- `done` → **句柄身份比对**(捕获的句柄指针 == 当前 `chartLoad_`?否则丢弃迟到信号,见 §5.0)→ 组 `ChartData` → `chartReady` → 清空 `chartLoad_`。
|
||||
- `failed` → 同样身份比对 → `loadFailed` → 清空 `chartLoad_`。
|
||||
- `loadGridData(dsId, ddCode)`:同构,针对 `gridLoad_` 与 `gridReady`。
|
||||
- **移除 `busy_` 守卫**:由 abort-and-replace + 句柄身份比对取代(新请求来→abort 旧→发新;旧句柄的迟到信号被身份比对丢弃)。
|
||||
- 句柄完成/失败后清空对应 `QPointer`。**控制器析构时 abort 所有在飞句柄**(见 §7 退出契约)。
|
||||
- 现有 `catch(...)` 兜底不再需要(无同步抛出路径);错误统一经 `failed` 信号传递。
|
||||
|
||||
### 5.4 UI 层(加载反馈)
|
||||
|
||||
- 新增 `LoadingOverlay`(`QWidget`,半透明 + "加载中…" + 可选 busy 指示),可贴在任一视图区上层。
|
||||
- `DatasetDetailPanel`:
|
||||
- 接 `loadStarted(dsId, Grid)` → 在对应 ds 页的网格视图上显示遮罩 + 禁用交互;`gridReady`/`loadFailed` → 隐藏。
|
||||
- 原数据初次加载(~0.8s):页面仍于 `chartReady` 创建;加载期用 busy 光标 + 状态栏"加载中…"(最小改动)。网格遮罩是高价值项(页已存在、等待 1–4s)。
|
||||
- 像素级对齐原版 loading 留后续。
|
||||
|
||||
## 6. 数据流(时序)
|
||||
|
||||
**原数据(双击数据集)**
|
||||
1. `detailCtrl.openDataset(id, "dd_inversion_data")` → emit `loadStarted(id,Chart)`(UI 置 busy)。
|
||||
2. abort 旧 `chartLoad_`(若有)→ `repo.loadChartAsync(id)` 返回 `ChartLoad*`。
|
||||
3. repo 内 `ApiBatch{getScatter, getScatterScale}` 并发;UI 线程**保持响应**。
|
||||
4. 两 reply 全成功到齐 → `batch.succeeded` → repo 解析 → `ChartLoad::done(ChartParts)`。
|
||||
5. controller 身份比对通过 → 组 `ChartData` → `chartReady` → `detailPanel->openOrUpdate`(清 busy)。
|
||||
- **任一项失败(fail-fast)**:首个失败 → `batch.failed` + abort 其余在飞 → `ChartLoad::failed` → `loadFailed` → 状态栏提示(清 busy),不等其余请求。
|
||||
|
||||
**网格数据(首次切到网格页签)**
|
||||
1. `gridDataNeeded` → `loadGridData(id, ...)` → emit `loadStarted(id,Grid)`(网格视图遮罩)。
|
||||
2. abort 旧 `gridLoad_` → `repo.loadGridAsync(id)`。
|
||||
3. `ApiBatch{getRows(慢1–4s), getColorScale, getException}` 并发。
|
||||
4. 全成功到齐 → 解析 → `done(GridParts)` → `gridReady` → `setGridData`(隐遮罩)。若 colorScale/exception 先失败 → fail-fast 立即 `failed` + abort rows,不干等 1–4s。
|
||||
5. 期间用户切走/关页 → controller abort `gridLoad_` → 子 reply 全 abort + `aborted_` 闸门 → 即便有迟到信号也被丢弃,无回灌。
|
||||
|
||||
## 7. 错误处理与边界
|
||||
|
||||
- **失败判定三要素(`isFailure` 谓词,由现 `must()` 提炼)**:一个 `ApiResponse` 视为失败当:(1) 传输错误 `!rawError.isEmpty()`;或 (2) HTTP 异常(必要时查 `httpStatus`);或 (3) **业务码 `code != 200`**(服务端常返回 HTTP 200 但 `code=500`,这是现 `must()` 的判定口径,**不能只看 httpStatus**)。失败原因取 `msg`(空则 `rawError`)。
|
||||
- **abort 竞态(核心,见 §5.0)**:单靠 disconnect 不足以挡「已入队的迟到信号」。权威闸门 = 各层 `aborted_` 入口守卫 + 控制器句柄身份比对 + 一律 `deleteLater`。`ApiCall::abort` 置 `aborted_` 后 disconnect+`reply->abort()`。
|
||||
- **重复触发**:abort-and-replace + 身份比对天然幂等(新覆旧,旧迟到信号丢弃)。
|
||||
- **控制器析构(退出契约)**:abort 所有在飞句柄。`ApiCall`/`ApiBatch`/`*Load` **不得在析构或 `deleteLater` 回调里访问 `nam`/`reply`**(reply 在 abort 时已 `deleteLater`)。main.cpp 栈对象析构逆序为 `detailCtrl` → `datasetRepo` → `api`,确保控制器先 abort、ApiClient 最后毁;句柄不持有对 `nam` 的长期引用。
|
||||
- **共享 NAM 同步/异步并存**:见 §5.1,**已知限制**(非"无害"),受 §5.0 三约束兜底,全异步后消除。
|
||||
- **空/缺数据**:异常列表可能为空——业务 `code==200` 走 `done`(空),非 `failed`,与现状一致。
|
||||
|
||||
## 8. 测试策略
|
||||
|
||||
- **`ApiBatch`(离线单测)**:注入假 `IApiCall`(受测可控 emit `finished`/记录 `abort`)。
|
||||
- 全成功 → `succeeded` 一次、下标对齐。
|
||||
- **fail-fast**:某子 call 先返回失败 → `failed(i,resp)` 一次 + 其余在飞子 call 被 `abort`(且不再等待)。
|
||||
- **abort 闸门**:batch `abort()` 后,未完成子 call 均被 abort;之后即便假 call 延迟 emit `finished`,batch 也不 emit(`aborted_` 守卫)。
|
||||
- **`DatasetDetailController`(离线单测,QSignalSpy + 事件循环)**:用 `StubAsyncRepo` 返回假 `*Load`,可控 emit `done`/`failed`(即时或 `QMetaObject::invokeMethod(..., QueuedConnection)` 模拟迟到)。异步桩非即时返回,断言需 `spy.wait()` / `QTest::qWait` spin 事件循环。
|
||||
- `done` → 一次 `chartReady`/`gridReady`;`failed` → 一次 `loadFailed`;非 `dd_inversion_data` → 直接 `loadFailed`。
|
||||
- 在飞时再 `openDataset` → 旧句柄被 `abort`(stub 记录)。
|
||||
- **回灌防护(回归原 bug 的核心用例)**:① 句柄 abort 后延迟 emit `done` → 控制器**零** `chartReady`(身份比对 + aborted_ 生效);② `openDataset(A)` 在飞 → `openDataset(B)` → A 句柄迟到 emit `done` → **仅 B 的数据被 `chartReady`,A 丢弃**。
|
||||
- **`ApiCall`/`getAsync`(可选 live test)**:依现有 `AuthLiveTest` 先例,标记为 live,不计入离线覆盖门槛。
|
||||
- 现有 75 个测试中,`DatasetDetailController.*` 两个改为异步桩版本(加事件循环 spin);其余不动。`LocalRepo.*` 不受影响(同步接口保留)。
|
||||
- 目标:异步新增逻辑(batch/controller)离线单测覆盖 ≥ 80%。
|
||||
|
||||
## 9. 迁移步骤(供 writing-plans 细化)
|
||||
|
||||
1. net:`IApiCall` + `ApiCall` + `ApiClient::getAsync/postJsonAsync`(保留同步)。`parseBody` 抽为可复用。
|
||||
2. net:`ApiBatch` + 离线单测。
|
||||
3. data:`DatasetLoads.hpp`(ChartParts/GridParts)+ `ChartLoad`/`GridLoad` + `IAsyncDatasetRepository`。
|
||||
4. data:`ApiDatasetRepository` 改实现异步接口(搬入现有 DTO 解析)。
|
||||
5. controller:`DatasetDetailController` 改异步 + abort-and-replace + `loadStarted`;改其单测。
|
||||
6. `main.cpp` 装配换 `IAsyncDatasetRepository` + 启动注册 `qRegisterMetaType<ApiResponse>()`。
|
||||
7. UI:`LoadingOverlay` + `DatasetDetailPanel` 接 `loadStarted`。**此步与异步内核无依赖,可在步骤 1–6 通过测试后再做/并行**,以减小试点 PR 体积(surgical)。
|
||||
8. 全量构建 + 测试(dev-build / dev-test);手动验证:切换/关页 abort、网格遮罩、并发加载、UI 不冻、失败 fail-fast 即时报错。
|
||||
|
||||
## 10. 风险
|
||||
|
||||
| 风险 | 缓解 |
|
||||
|---|---|
|
||||
| **已入队的迟到信号回灌(最危险)** | §5.0 闸门:各层 `aborted_` 入口守卫 + 控制器句柄身份比对 + 一律 `deleteLater`;§8 专项回归用例 |
|
||||
| abort 后回调悬垂/二次释放 | `QPointer` + abort 先置 `aborted_`+disconnect + **禁止同步 delete,仅 `deleteLater`** |
|
||||
| 句柄/batch 生命周期泄漏 | 完成或 abort 后统一 `deleteLater`;`ApiBatch` 用 `QPointer` 持子 call |
|
||||
| 同步/异步共用 NAM 嵌套重入 | **已知限制**(非无害);§5.0 三约束兜底;全异步后消除 |
|
||||
| 退出期 UAF(句柄 deleteLater 晚于 ApiClient 析构) | 退出契约:句柄不在析构/回调访问 nam/reply;栈析构逆序保证 ctrl→repo→api |
|
||||
| 失败比成功慢(gather-all 缺陷) | 改 fail-fast + abort 其余在飞 call |
|
||||
| DTO 解析逻辑搬迁出错 | 原样搬入、不重写;`must()`→`isFailure` 谓词;保留现有解析单测 |
|
||||
| 接口改形(原子→复合)波及面 | 仅详情控制器消费该接口;§5.2 旧方法去向表;`LocalSampleRepository` 不碰 |
|
||||
|
||||
## 11. 相关文件
|
||||
|
||||
- 改:`src/net/ApiClient.{hpp,cpp}`、`src/data/api/ApiDatasetRepository.{hpp,cpp}`、`src/controller/DatasetDetailController.{hpp,cpp}`、`src/app/main.cpp`、`src/app/panels/.../DatasetDetailPanel.*`、`tests/controller/test_dataset_detail_controller.cpp`、各 `CMakeLists.txt`。
|
||||
- 新:`src/net/IApiCall.hpp`、`src/net/ApiCall.{hpp,cpp}`、`src/net/ApiBatch.{hpp,cpp}`、`src/data/api/DatasetLoads.hpp`、`src/data/api/DatasetLoadHandles.{hpp,cpp}`(ChartLoad/GridLoad)、`src/data/repo/IAsyncDatasetRepository.hpp`、`src/app/.../LoadingOverlay.{hpp,cpp}`、`tests/net/test_api_batch.cpp`。
|
||||
- 不动:`src/data/repo/IDatasetRepository.hpp`(同步,留给 LocalSampleRepository)、Nav/Auth/Project 路径。
|
||||
</content>
|
||||
</invoke>
|
||||
|
|
@ -0,0 +1,341 @@
|
|||
# 数据集详情视图(平面图表)改造 设计
|
||||
|
||||
- 日期:2026-06-11
|
||||
- 分支建议:`feat/dataset-detail-chart`
|
||||
- 状态:**ERT 反演(dd_inversion_data)展示功能已落地并经用户验收**;其余 dd 类型 + 工具条编辑功能待后续
|
||||
|
||||
## 状态更新(2026-06-11)
|
||||
|
||||
**架构偏离(重要):** spec 原定渲染器为 **QGraphicsView**,实际落地改用 **QwtPlot(轴/交互/图例)+ VTK 算法层(等值线几何)+ 连续/离散色阶**(见返工方案 `plans/2026-06-11-dataset-detail-chart-v2-qwt.md`,权威)。展示结果视觉等价,下文 §5.2/§8 的 QGraphicsView 细节已被 QwtPlot 方案取代,保留作背景参考。
|
||||
|
||||
**已完成(仅 `dd_inversion_data` ERT 反演,§2.2 展示范围内):** 原数据散点(方形点/白描边/连续色阶/x 轴顶部 + **hover 显 X/Y/值**)+ 网格等值面(填充栅格 + 黑色等值线 + 沿线数值标注 + NaN 白边裁剪)+ 色阶图例 + 异常叠加 + 底部异常表/描述 + 多 Tab + 网格数据懒加载 + 页签内滚动/分割条 + 实时平移/滚轮缩放。数据加载已异步化(见 `specs/2026-06-11-apiclient-async-design.md`)。
|
||||
|
||||
**2026-06-12 渲染保真修复:** colorBar 是混合 hex+CSS-rgba 格式且 rgba 的 **alpha 为 0–1 浮点**;原 `DatasetChartDto` 用 `AlphaScale::Bit255` 解析致 12/18 段近透明(散点/网格/图例全发白)。修为 `AlphaScale::Unit`(一行根因修复,通用生效)。散点 `ColorMapService` 连续插值的归一化位置已证 1:1 等于原版 Plotly colorscale。新增 `ScatterHoverTip` 复刻原版 hovertemplate。详见 `HANDOFF-dataset-detail-chart.md §0.1`。
|
||||
|
||||
**未完成:**
|
||||
- **其余 dd 类型的详情图渲染**(§2.4):`dd_ert_measurement_data`、`dd_ert_measurement_gr_data`、`dd_grid`、`dd_trajectory_data`、测井(深度/时序折线)、GPR(`dd/gpr/channel/image`)、TEM 等。控制器目前对非 `dd_inversion_data` 直接「暂不支持该类型预览」。**现实约束:当前租户仅 ERT/TEM/GPR 三类,GPR 对象无数据、无测井数据 → 多数类型无活样本,须先取样本。** 实现计划见 `plans/2026-06-11-dataset-detail-other-dd-types.md`(如已生成)。
|
||||
- **工具条编辑功能**(§2.3,范围外/后续单独立项):白化 / 滤波处理 / 色阶配置 / 异常框注 / 自动标注 / 网格化 / 另存为 / 导出 / 描述富文本 / 大视图全屏。当前为占位按钮。
|
||||
- 加载态:网格懒加载已有「加载中」遮罩;原数据初次加载仅 busy 光标,未做骨架屏。
|
||||
- 参考材料:
|
||||
- 客户端菜单:`D:\Projects\GEOPRO\Geopro3.0 菜单.xlsx`「客户端」页签(R051–R096)、「测井参数表」「DD类型」
|
||||
- 原 web 系统:`http://tenant.geomative.cn/#/projectSpace/datasetMange/datasetInfo`(经 Playwright 操作页面 + 抓取 JS chunk 做源码级分析)
|
||||
- API:`docs/apis/business_OpenAPI.json`
|
||||
- 参考截图:`assets/web-datasetinfo-scatter.png`(原数据散点)、`assets/web-datasetinfo-grid-with-anomaly.png`(网格等值面 + 异常叠加 + 异常表)
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与问题
|
||||
|
||||
客户端工作台的「数据详情」dock 当前实现有两个问题:
|
||||
|
||||
1. **用了 VTK 渲染**:`main.cpp` 下方「数据详情」dock 是一个独立的 `QVTKOpenGLStereoWidget` + `vtkRenderer`,`rebuildDetail` lambda 用 `vtkBandedPolyDataContourFilter`/散点/电极 actor 配合 `applyTop2D` 正交相机"平躺"渲染剖面。数据集详情本质是**平面图表**,不该走 3D 渲染管线。
|
||||
2. **从未真正接上数据集选择**:`main.cpp:705-713` 数据集列表单击只调 `nav.selectDataset(dsId)`(驱动右下「数据集属性」表单),`main.cpp:604` 创建的 `currentDsId` 此后**再无任何赋值**,`itemClicked` 也不触发 `rebuildDetail`。即详情图表收不到选中的 dsId,点数据集时它是空的。
|
||||
|
||||
本设计将「数据详情」dock 重建为**本地面板 + 平面图表**(QGraphicsView),接真实 API,并真正接上数据集选择链路,目标 100% 复刻原 web 系统 datasetInfo 页面的展示功能。
|
||||
|
||||
---
|
||||
|
||||
## 2. 目标与范围
|
||||
|
||||
### 2.1 目标
|
||||
- 数据详情 dock 改为平面图表(QGraphicsView),**不再使用 VTK 渲染窗口**(VTK 仅作几何算法库)。
|
||||
- 100% 复刻原 web datasetInfo 的**展示**:原数据散点视图、网格等值面视图、色阶图例、等值线/标注、异常叠加 + 异常列表。
|
||||
- 接真实 API(`ApiDatasetRepository`)。
|
||||
- 架构按 **dd 类型驱动的图表策略框架**搭建,首版落地 `dd_inversion_data`(ERT 反演),其余 dd 类型作为框架内后续扩展。
|
||||
|
||||
### 2.2 范围内(仅展示)
|
||||
- 原数据/网格数据 视图切换
|
||||
- 网格等值面渲染(混合方案,见 §8)+ 等值线 + 标注
|
||||
- 原数据散点渲染
|
||||
- 色阶图例(原数据 type1 / 网格 type2)
|
||||
- 显示异常/电极/等值线 开关
|
||||
- 异常叠加(剖面上)+ 底部异常列表(数据集级,§7.3 辨析)
|
||||
- 多 Tab 壳:一个或多个数据集详情页(R095)
|
||||
|
||||
### 2.3 范围外(编辑/工具类,后续单独立项)
|
||||
网格化参数(GridDialog)、色阶配置(colorEditor)、白化(WhiteningDialog)、滤波/迭代处理、异常框注/自动标注(AutoAnnotationDialog)、另存为(SaveAsDialog)、导出(ExportDialog)、描述富文本编辑、大视图(Esc) 全屏。
|
||||
|
||||
### 2.4 后续 dd 类型(框架内扩展,非本 spec)
|
||||
其余 dd 类型详情图见下文 **§2.5(2026-06-12 全量实测编目)** 与实现计划 `plans/2026-06-11-dataset-detail-other-dd-types.md`。客户端按真实数据类型走 `ChartStrategyRegistry` 分派。
|
||||
|
||||
### 2.5 数据类型全景:设计 taxonomy(Excel)vs 实测运行 ddCode + 渲染映射(2026-06-12)
|
||||
|
||||
> 用脚本直连 API 全量遍历两个账号(威立雅 + 赛盈地空"数据多"账号,20 项目/108 TM/752 DS)+ 逐个打开详情看截图确认渲染。**关键纠正:详情渲染由数据集"真实数据类型"决定,URL 详情链接的 `ddCode` 经常标错,客户端分派须用 ds 元数据的真实类型。**
|
||||
|
||||
#### 2.5.1 实测 10 种 ddCode → 7 种渲染视图(均有活样本、看截图确认)
|
||||
|
||||
| 渲染视图 | ddCode | 数据类型 | 客户端 |
|
||||
|---|---|---|---|
|
||||
| **① 原数据(Plotly散点,cauto) + 网格数据(等值面+等值线)** | `dd_inversion_data` | ERT/TEM 反演剖面、视电阻率数据 | ✅ 已做 |
|
||||
| **② 柱状图(Y=电阻欧姆,X=电极点) + 列表** | `dd_ert_measurement_gr_data` | ERT接地电阻 | ❌ |
|
||||
| **③ 散点伪剖面(斜距/伪深度,视电阻率着色,反演运算工具) + 数据列表** | `dd_ert_measurement_data` | ERT原始数据 | ❌ |
|
||||
| **④ 轨迹:地图 + 列表 + 高程 3页签** | `dd_trajectory_data` | ERT电极坐标、TEM坐标 | ❌ |
|
||||
| **⑤ 列表(序号/x/y)** | `dd_grid` | 白化数据 | ❌ |
|
||||
| **⑥ 雷达剖面灰度图像(B-scan) + 单道波形(A-scan) + 对比度/灰度色阶/频谱** | `dd_gpr_channel_detail`、`dd_gpr_channel_image` | 雷达单通道剖面/图片列表 | ❌ |
|
||||
| **⑦ 地图轨迹(真实GIS底图 + GPS路径 + 起点/终点)**(RTK 另带坐标点设置/解状态过滤面板) | `dd_radar_channel_trajectory`、`dd_radar_rtk_trajectory` | 雷达单通道轨迹/RTK轨迹 | ❌ |
|
||||
| (无独立可视详情,疑数据中间态) | `dd_radar_preprocess_data` | 雷达预处理数据 | — |
|
||||
|
||||
**共 7 种渲染视图,客户端已做 1 种(①);待做 6 种:② 柱状图、③ 散点伪剖面、④ 轨迹、⑤ 列表、⑥ 雷达剖面图像、⑦ 地图轨迹。**
|
||||
|
||||
#### 2.5.2 与 `Geopro3.0 菜单.xlsx`「DD类型」设计 taxonomy 的对应
|
||||
|
||||
Excel「DD类型」页签是**设计期格式蓝图**,用的是另一套命名(`dd_Section`/`dd_Track`/`dd_ERTRawData`/`dd_GPRSection`/`dd_Images`/`dd_TimeVarious`…)。与实际运行 ddCode **命名对不上、语义对得上**:
|
||||
|
||||
| Excel 设计格式 | 描述(Excel) | 对应实测运行 ddCode | 样本 |
|
||||
|---|---|---|---|
|
||||
| `dd_Section`("最重要":水平/垂直剖面) | 反演剖面 | `dd_inversion_data`(①)、`dd_gpr_channel_detail`(⑥) | ✅ |
|
||||
| `dd_ERTRawData` | ERT原始数据 | `dd_ert_measurement_data`(③)、`dd_ert_measurement_gr_data`(②) | ✅ |
|
||||
| `dd_GPRSection`(设计注:讨论是否并入 dd_Section) | 雷达剖面 | `dd_gpr_channel_detail`/`image`(⑥) | ✅ |
|
||||
| `dd_Track`(坐标+轨迹合并) | 电极坐标/采集轨迹 | `dd_trajectory_data`(④)、`dd_radar_*_trajectory`(⑦) | ✅ |
|
||||
| `dd_Images` | 图片类 | `dd_gpr_channel_image`(⑥) | ✅ |
|
||||
| `dd_Files` | 文件打包(如三维雷达原始包) | `dd_radar_preprocess_data`(疑) | ✅部分 |
|
||||
| `dd_TimeVarious` | 时间连续变量 | =设计中的 TEM时序/timeSensor 折线 | ❌ 无样本 |
|
||||
| `dd_Stratigraphy` | 地层编录/**测井** | =设计中的测井折线/深度 | ❌ 无样本 |
|
||||
| `dd_Segy` | 地震 SEGY(只展示) | — | ❌ 无地震数据 |
|
||||
| `dd_Sampling` | 采样/化学(XRF/VOCs) | — | ❌ |
|
||||
| `dd_LinearVarious` / `dd_Structual3D` / `dd_Property3D` / `dd_Video` | 线性变量/三维结构/三维属性/视频 | — | ❌ |
|
||||
| `dd_KML`/`dd_GeoJson`/`dd_shp`/`dd_dem`/`dd_bim`/`dd_czml`… | 背景资料**图层**(导入/预览/坐标对齐) | — | 非"数据详情图",是地图图层 |
|
||||
|
||||
**结论:** Excel 是"应有哪些数据格式"的设计蓝图;实测 10 种是"现在真有数据、详情页真能出图"的子集(电法 ERT + 地质雷达 GPR 两大类的落地)。两者语义吻合;Excel 里 时序(dd_TimeVarious)/测井(dd_Stratigraphy)/地震(dd_Segy)/采样/三维模型/视频 等当前**无活样本,BLOCKED**(与 plan §1.2 一致);KML/shp/dem/bim 等是地图图层,不属本 spec 的详情图范畴。
|
||||
|
||||
#### 2.5.3 枚举 DS 的 API(实测可用,踩坑记录)
|
||||
|
||||
`POST my/profile/project/page`{pageNo,pageSize} → `GET projectStruct/queryProjectStruct/{pid}`(扁平节点:type=1=项目/GS,type=2=TM,parentId 链表层级;TM 可挂项目或 GS 下)→ `POST dsObject/data/page` body **`{projectId, structParentId:<tmId>, structParentConfType:2, classifyTypeList:[3], pageNo, pageSize}`**(字段是 `structParentId` 不是 `tmObjectId`;必须带 `classifyTypeList:[3]` 否则后端 `code:500`;`pageNo` 不是 `pageNum`;时序类另走 `dsObject/timeSensor/data/page`;后端间歇 500 需重试)。DS 字段:`ddCode`+`dsTypeCode`(英文)+`name`(中文类型名)+`dsName`(文件)。
|
||||
|
||||
> 现实约束:当前可访问租户实测有样本的方法类为 ERT/TEM/GPR;测井/地震/采样/三维/时序无活样本。无样本类详情页**无可参照**,须待数据样本到位后再精确 1:1 复刻。
|
||||
|
||||
---
|
||||
|
||||
## 3. 原 web 系统分析(源码级)
|
||||
|
||||
> 方法:Playwright 操作 datasetInfo 页面 → 抓取无障碍快照、网络请求、localStorage、活 API 响应;进一步抓取并分析前端 JS chunk(`contourChartAdapter`/`plotlyScatterSimpleChart`/`contourPage`/`colorUtils` 等)。无 sourcemap,代码已压缩但可读。
|
||||
|
||||
### 3.1 页面结构(客户端只取内容区,左侧 web 导航不复刻)
|
||||
标题(ds 名)→ `原数据 | 网格数据` 切换 → 工具条 → 图表 → 色阶图例 →(网格视图)底部 `异常列表 | 描述` 表。
|
||||
|
||||
### 3.2 原数据散点 —— Plotly `scattergl`(`plotlyScatterSimpleChart`)
|
||||
```
|
||||
type:"scattergl", mode:"markers",
|
||||
marker:{ size:10-12, color:vlist, colorscale, zmin/zmax, symbol:"square",
|
||||
line:{color:white, width:1} }
|
||||
yaxis.scaleanchor = x // x:y 等比锁定
|
||||
colorbar:{ title:"数值", thickness:15, len:.7 } // 可选
|
||||
```
|
||||
方形点、按 `vlist` 经色阶着色、白描边、等比。
|
||||
→ **客户端用 `QGraphicsRectItem`(方块)按色阶着色复刻输出,不引入 Plotly。**
|
||||
|
||||
### 3.3 网格等值面 —— 自制 marching-squares 引擎(`contourChartAdapter`,canvas)
|
||||
源码为自带等值线库(`window.contourCore`),API:`computeContours / marchingSquares / pathFinding / levels / smooth / nullHandling / labels / colorbar / renderers / axes / Overlay`。
|
||||
- **渲染模式**:`heatmap`(`putImageData` 逐格 + canvas 缩放平滑)/ `fill`(填充色带 `drawFilledPaths`)/ `lines`(等值线 `drawStrokePaths`)/ `fill+lines`(默认显示:色带 + 等值线 + 标注)。
|
||||
- **levels** = colorBar 离散分段值;`mapColors(v, vmin, vmax, colorscale)` 着色。
|
||||
- **额外处理**:2× 双线性上采样(`DEFAULT_UPSAMPLE_SCALE:2`)+ 平滑(`DEFAULT_SMOOTHING:.3`)+ 简化容差(`simplifyTolerance` 默认 `.5`,滑块)+ **数据凸包裁剪**(`makeBinaryMask`/`createClipPath`/`DEFAULT_CLIP_LEVEL:.95`,对应截图里随地形起伏的不规则白边)+ 标注(`showLabels:true`)。
|
||||
|
||||
### 3.4 数据接口(全部真实 API 验活)
|
||||
| 用途 | 接口 | 返回(关键字段) |
|
||||
|---|---|---|
|
||||
| 原数据散点 | `GET dd/ert/inversion/getErtRawDataScatterGraph/{dsId}` | `xlist/ylist/vlist/hlist/projectXList/projectYList`(各 1D,本例 240)、`min/max` |
|
||||
| 网格等值面 | `GET dd/ert/inversion/rows/{dsId}` | `DDErtInversionGraphDataVO`:`x[nx]`、`y[ny]`、`v[ny][nx]`、`z[ny][nx]`(地形)、`elevation[nx]`、`vmin/vmax`、`sectionType`(1垂直/2水平) |
|
||||
| 色阶 | `POST lvl/colorGradation/getDetail`(body `{dsObjectId, businessCode:"", type}`,type1=原数据 / type2=网格) | `data.properties.colorBar=[[值,"rgba(r,g,b,a)"],…]`、`lvlMinMax`、`lineConfig{showLines,color,lineType}`、`labelConfig{showLabels,color}` |
|
||||
| 异常 | `GET exception/queryException/{dsId}` | 见 §6.4(与 `core::Anomaly` 一致) |
|
||||
|
||||
> 注:网格等值面数据来自服务端**已网格化**的 `inversion/rows`,前端不做插值;前端"网格"按钮是把 `xSpacing/xsize…` POST 给 `inversion/grid` 让服务端重算(范围外)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 架构总览
|
||||
|
||||
**核心思路**:详情 dock 的渲染器从「VTK 3D 管线」换成「VTK 仅算等值面几何 + QGraphicsView 画 2D 场景」。数据模型、仓储接口复用,新增渲染器 + 面板壳 + dd 策略 + API 仓储 + 一个小详情控制器;并真正接上数据集选择链路。
|
||||
|
||||
```
|
||||
┌─ 数据详情 dock(重建)──────────────────────────────────────┐
|
||||
│ DatasetDetailPanel (QTabWidget 壳, 每 ds 一页, R095 多Tab) │
|
||||
│ └─ DatasetDetailPage │
|
||||
│ ├─ 标题栏(ds 名) │
|
||||
│ ├─ 原数据 / 网格数据 切换 + 显示异常/电极/等值线 开关 │
|
||||
│ ├─ DatasetChartView : QGraphicsView ← 新渲染器 │
|
||||
│ │ scene: 等值面色带 / 散点方块 / 坐标轴 / 异常 / 图例 │
|
||||
│ └─ AnomalyTablePanel(ds 级异常表, 行显隐→图表叠加) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
右上「对象异常」面板(不动)= TM 异常总和, 眼睛→中央 VTK 地图
|
||||
右下「数据集属性」面板(不动)= dynamicForm 表单
|
||||
▲ 渲染输入(复用现有 core 模型) ▲ dd 类型分派
|
||||
┌─ chart 层(新)──────────────────┐ ┌─ 策略注册(新)──────────────┐
|
||||
│ ContourBuilder (src/render/) │ │ IDatasetChartStrategy │
|
||||
│ VTK 仅算法: 上采样+平滑→ │ │ + ErtInversionStrategy(首个)│
|
||||
│ vtkBandedPolyDataContourFilter │ │ registry: ddCode → strategy │
|
||||
│ → 分层带多边形+等值线(几何,无窗口)│ │ (扩展点: 测井/时序/GPR…) │
|
||||
└──────────────────────────────────┘ └──────────────────────────────┘
|
||||
▲ 数据模型(复用,零改动)
|
||||
┌─ core::Grid / ScatterField / ColorScale / Anomaly ──────────┐
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
▲ 加载
|
||||
┌─ IDatasetRepository(接口已存在)───────────────────────────┐
|
||||
│ loadGrid / loadScatter / loadColorScale / loadAnomalies │
|
||||
│ · ApiDatasetRepository(新, 真实 API, §6.3) │
|
||||
│ · LocalSampleRepository(保留, 离线/测试) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
▲ 编排(新小控制器, 不污染 WorkbenchNavController)
|
||||
┌─ DatasetDetailController(新, 持 IDatasetRepository + 注册表)┐
|
||||
│ 在 main.cpp 与 nav.selectDataset 并联挂到 datasetList 单击 │
|
||||
│ 读 ddCode → 选策略 → 拉数据 → 发信号 → 面板被动渲染 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**关键边界**
|
||||
- **中央 2D/3D 地图视图仍是 VTK**(在地图上显示数据集是 VTK 的正职),本次只换数据详情 dock。
|
||||
- **VTK 在新链路里只当算法库**(无 render window);**新增依赖仅 Qt Widgets 的 `QGraphicsView`**(LGPL 合规),无第三方图表库。
|
||||
|
||||
---
|
||||
|
||||
## 5. 组件细化
|
||||
|
||||
### 5.1 `ContourBuilder`(新,`src/render/`,VTK 仅算法)
|
||||
复用 `GridContourActor.cpp:36-82` 已验证的「`core::Grid` → `vtkStructuredGrid` → `vtkDataSetSurfaceFilter` → `vtkBandedPolyDataContourFilter`(按 colorBar 真实非均匀分段 + `GenerateContourEdgesOn`)」管线,**只在输出端分叉**:不建 mapper/actor,而是从 `banded->GetOutput()`(port0, cell 标量) 提取色带多边形、从 port1 提取等值线折线。
|
||||
|
||||
混合保真预处理(见 §8):入网格先 **2× 双线性上采样 + 高斯平滑** → banded → 按 NaN 掩膜做**数据凸包裁剪** → 等值线 Douglas-Peucker **简化**(容差参数)。
|
||||
|
||||
- 入:`core::Grid`(含 NaN 标记无效区)+ `core::ColorScale`(分段值)+ `ContourOptions{upsample, smooth, simplifyTol, showLines, showLabels}`。
|
||||
- 出:`std::vector<BandPolygon{ core::Rgba color; std::vector<core::Vec2> ring; }>` + `std::vector<ContourLine{ double level; std::vector<core::Vec2> pts; }>`。
|
||||
- 纯函数、无 Qt 依赖、可单测(给定网格→断言色带数/层级/裁剪边界)。
|
||||
|
||||
### 5.2 `DatasetChartView : QGraphicsView`(新,`src/app/panels/chart/`)
|
||||
持 `QGraphicsScene`,对外:`showContour(grid, scale, opts)` / `showScatter(field, scale)` / `setAnomalies(list, hidden)` / `setOverlays(showAnomaly, showElectrode, showContourLine)`。
|
||||
- Item:色带 `QGraphicsPathItem`(填充)、等值线 `QGraphicsPathItem`、散点 `QGraphicsRectItem`(方块, 白描边)、异常 `QGraphicsPathItem`(可拾取)、电极顶部标记、标注 `QGraphicsSimpleTextItem`。
|
||||
- **坐标轴**:viewport 层 overlay 自绘,监听 `transform` 变化重算刻度(缩放/平移时轴钉边缘)。**y 轴用高程、向上为正**(场景 y 翻转);散点视图 **x:y 等比**(对齐 Plotly `scaleanchor`)。
|
||||
- 交互:滚轮缩放、拖动平移、点击异常高亮、重置视图。
|
||||
- **同一个类**供详情页、(后续)大视图、自动框注对话框复用——普通 QWidget、无 OpenGL 上下文负担。
|
||||
|
||||
### 5.3 dd 类型策略(新,扩展点,`src/app/panels/chart/`)
|
||||
```cpp
|
||||
struct IDatasetChartStrategy {
|
||||
virtual ~IDatasetChartStrategy() = default;
|
||||
virtual std::string ddCode() const = 0;
|
||||
virtual void load(DatasetDetailController&, const std::string& dsId) = 0; // 决定拉哪些数据、画什么
|
||||
};
|
||||
```
|
||||
- `ErtInversionStrategy`(首个,`ddCode()=="dd_inversion_data"`):原数据→`loadScatter`+散点;网格→`loadGrid`+`ContourBuilder`+色带/等值线;异常→`loadAnomalies`。
|
||||
- `ChartStrategyRegistry`:`ddCode → strategy`;未注册 → 页内占位「暂不支持该类型预览」(优雅降级)。
|
||||
|
||||
### 5.4 `ApiDatasetRepository`(新,实现 `IDatasetRepository`,真实 API,`src/data/api/`)
|
||||
| 方法 | 接口 | 解析(→ `src/data/parse/`) |
|
||||
|---|---|---|
|
||||
| `loadScatter(dsId)` | `getErtRawDataScatterGraph/{dsId}` | → `ScatterField`(xlist/ylist/hlist/vlist→x/y/z/v,projectX/Y) |
|
||||
| `loadGrid(dsId)` | `inversion/rows/{dsId}` | `DDErtInversionGraphDataVO` → `Grid`(x/y/v/z/elevation/vmin/vmax) |
|
||||
| `loadColorScale(dsId, variant)` | `colorGradation/getDetail`(type1/2) | `properties.colorBar[[值,rgba]]` → `ColorScale::addStop` |
|
||||
| `loadAnomalies(dsId)` | `queryException/{dsId}` | → `core::Anomaly[]`(§6.4) |
|
||||
> 现有 `IDatasetRepository` 的 `loadColorScale` 需加 `variant`(原数据/网格) 参数;`loadScatterColorScale` 可并入。`LocalSampleRepository` 保留为离线/测试桩。
|
||||
|
||||
### 5.5 `AnomalyTablePanel`(新或复用 `AnomalyListPanel`,`src/app/panels/`)
|
||||
ds 级异常表,列:名称 / 异常类型 / 几何类型 / 创建时间 / 备注 / 操作(仅展示版:显隐眼睛;定位)。行显隐 → 发信号驱动 `DatasetChartView::setAnomalies` 的 hidden 集(对齐现有 `hiddenAnoms` 语义)。
|
||||
|
||||
### 5.6 `DatasetDetailPanel` / `DatasetDetailPage`(新,`src/app/panels/`)
|
||||
- `DatasetDetailPanel`:`QTabWidget`,按 dsId 去重托管多个 `DatasetDetailPage`(R095)。
|
||||
- `DatasetDetailPage`:标题 + 原数据/网格数据 切换 + 叠加开关 + `DatasetChartView` + `AnomalyTablePanel`。
|
||||
|
||||
### 5.7 `DatasetDetailController`(新,`src/controller/`)
|
||||
持 `IDatasetRepository&` + `ChartStrategyRegistry&`;slot `openDataset(dsId, ddCode)`:选策略→拉数据→`emit chartReady(pageModel)`;被动视图。**不并入 `WorkbenchNavController`**(后者只持 `IProjectRepository`、专注项目/结构/异常树导航,混入图表数据会破坏单一职责)。
|
||||
|
||||
---
|
||||
|
||||
## 6. 数据模型与映射
|
||||
|
||||
### 6.1 复用的 core 模型(零改动)
|
||||
- `core::Grid`(`Field.hpp`):`nx/ny/valueAt(i,j)`、`x/y`、`z[nx*ny]`、`elevation[nx]`、`vmin/vmax` ⇆ `DDErtInversionGraphDataVO`。
|
||||
- `core::ScatterField`:`x/y/z/v`、`projX/projY` ⇆ 散点 VO。
|
||||
- `core::ColorScale`:阶梯色阶 `addStop/colorAt/stopValues` ⇆ colorBar。
|
||||
- `core::Anomaly`:`markType`(点/线/面)、`localPts`、`lineColor/lineWidth/dashed` ⇆ queryException。
|
||||
|
||||
### 6.2 NaN / 无效区
|
||||
`inversion/rows` 的 `v` 在测区外为无效;`core::Grid` 用 NaN 标记,`ContourBuilder` 据此做凸包裁剪(§8),渲染成不规则白边。
|
||||
|
||||
### 6.3 色阶(colorBar)
|
||||
`properties.colorBar` 为 `[[值字符串, "rgba(r,g,b,a)"], …]`(本例 17 段),`type1`(原数据)/`type2`(网格) 颜色相同、分段值不同。映射:每段 `addStop(parseDouble(值), core::parseColor(rgba))`;`lineConfig` 驱动等值线显隐/色/线型。
|
||||
|
||||
### 6.4 异常(queryException → core::Anomaly,活数据验证)
|
||||
```
|
||||
exceptionName → name
|
||||
exceptionTypeName(异常区) → typeName
|
||||
exceptionMarkType(1点/2线/3面) → markType
|
||||
exceptionMarkTypeName(多段线…) → 表"几何类型"列
|
||||
location.coordinate[{x,y}] → localPts (剖面局部坐标: x=桩号, y=高程)
|
||||
legend.polylineColor/Width → lineColor/lineWidth
|
||||
legend.polylineShape=="dash"→ dashed
|
||||
createTime/remark → 表"创建时间/备注"列
|
||||
geographicalCoordinates / latitudeLongitude → (地图用, 详情视图不需要)
|
||||
```
|
||||
> 客户端已有 `parseExceptions` 映射此结构(`Anomaly.hpp` 注释印证),活数据已验证一致。
|
||||
|
||||
---
|
||||
|
||||
## 7. 数据流与交互
|
||||
|
||||
### 7.1 选择 → 渲染
|
||||
`datasetList` 单击 → 同时:(a) `nav.selectDataset(dsId)`(现状,驱动右下属性表单);(b) `detailCtrl.openDataset(dsId, ddCode)`(新)→ 选策略 → 拉 scatter/grid/colorScale/anomalies → `emit chartReady` → `DatasetDetailPanel` 渲染。
|
||||
|
||||
### 7.2 Tab 与联动(R055-057, R095)
|
||||
- 双击数据集 = 新建或聚焦已开页(按 dsId 去重);单击 = 聚焦已开页(若已打开)。
|
||||
- 活动 Tab 切换 → 反向高亮数据集列表对应项。
|
||||
- 视图切换 原数据/网格数据;叠加开关 显示异常/电极/等值线。
|
||||
|
||||
### 7.3 异常归属辨析(决策记录)
|
||||
原 web datasetInfo 是**单页**,底部异常表是单页布局产物。客户端是**多面板工作台**,存在两处不同作用域的异常显示,**经决策保留两者、各管各的视图**:
|
||||
|
||||
| 位置 | 接口 | 作用域 | 眼睛控制 |
|
||||
|---|---|---|---|
|
||||
| 右上「对象异常」面板(现状不动) | `queryExceptionByTmObjectId/{tmId}` | 对象/TM 级(勾选驱动,多 TM 聚合) | 中央 VTK 2D/3D **地图** |
|
||||
| 详情 dock 底部「异常列表」(本 spec, 复刻 web) | `queryException/{dsId}` | 数据集级(更直观) | **图表剖面**叠加 |
|
||||
|
||||
剖面上的**异常叠加**属"画在图上",天然归详情视图;右上面板的列表与地图叠加不变。
|
||||
|
||||
---
|
||||
|
||||
## 8. 渲染保真度(混合方案)
|
||||
|
||||
网格等值面采用 **VTK 算法 + 预处理** 混合方案(~95% 保真,复用 VTK,不重写整套引擎):
|
||||
|
||||
1. **2× 双线性上采样** `core::Grid`(对齐 web `DEFAULT_UPSAMPLE_SCALE:2`)——可用 `vtkImageData` + `vtkImageResize`/手写双线性。
|
||||
2. **高斯平滑**(对齐 web `DEFAULT_SMOOTHING:.3`)。
|
||||
3. `vtkBandedPolyDataContourFilter` 按 colorBar 分段值出**色带多边形 + 等值线**。
|
||||
4. **数据凸包裁剪**:按 NaN 掩膜裁掉测区外(对齐 web `makeBinaryMask`/`clipLevel .95`),得不规则白边。
|
||||
5. 等值线 **Douglas-Peucker 简化**(容差参数,默认 0.5,对应"简化容差")。
|
||||
6. 默认 `fill+lines + showLabels`;等值线色/显隐由 colorBar `lineConfig` 驱动。
|
||||
|
||||
原数据散点:`QGraphicsRectItem` 方块、按色阶着色、白描边、**x:y 等比**(对齐 Plotly `scaleanchor`)。
|
||||
|
||||
> 与「轻量 VTK banded 原生」(~85%, 矩形范围/块状) 和「高保真整体移植引擎」(~100%, 工作量最大) 相比,混合方案是保真度/工作量的折中。
|
||||
|
||||
---
|
||||
|
||||
## 9. 错误 / 空 / 加载态
|
||||
- 加载中:图表区骨架/转圈。
|
||||
- 空态:异常表「暂无数据」;未注册 ddCode「暂不支持该类型预览」;无网格/散点数据时空图占位。
|
||||
- 错误:API 失败 → 页内内联错误(沿用 `RepoResult` 错误传播,不崩)。
|
||||
- 跨项目上下文/数据过期:优雅降级。主题令牌复用(明暗一致)。
|
||||
|
||||
---
|
||||
|
||||
## 10. 测试策略(GoogleTest/CTest)
|
||||
- **单元**:`ContourBuilder`(给网格→断言色带数/层级/凸包裁剪/简化点数);`ColorScale`(colorBar 解析、`colorAt` 边界);解析器(散点/反演网格/色阶/异常 VO→模型);`ChartStrategyRegistry` 查找与降级。
|
||||
- **组件**:`DatasetChartView` 按数据生成 scene item 数;叠加开关隐藏对应 item;`AnomalyTablePanel` 行开关发信号;`DatasetDetailPanel` 按 dsId 去重 Tab。
|
||||
- **集成**:`DatasetDetailController.openDataset` 编排(用 `LocalSampleRepository` 桩)→ 断言 `chartReady` 负载。
|
||||
- **视觉**:对照 `assets/web-datasetinfo-*.png` 人工核对散点/等值面/异常叠加。
|
||||
|
||||
---
|
||||
|
||||
## 11. 风险与开放问题
|
||||
1. **凸包裁剪精度**:web 用 binary mask + clipLevel .95;VTK 侧用 NaN 掩膜裁剪需调参,可能与 web 边界略有出入(在 95% 目标内可接受)。
|
||||
2. **坐标轴钉边重算**:QGraphicsView 缩放时轴刻度重算是已知但非免费的模式,需专门处理。
|
||||
3. **`IDatasetRepository.loadColorScale` 签名变更**(加 variant)会动到 `LocalSampleRepository` 与现有 VTK 详情调用点——后者将随本次重建移除,注意配平。
|
||||
4. **其它 dd 类型无活样本**(测井/GPR/TEM 详情页):框架预留,落地需数据样本。
|
||||
5. **大数据集性能**:散点 240 点、网格 32×100→上采样 64×200 量级,QGraphicsView 可承受;更大测线需评估(web 用 `hightPerformaceContainer`,本版先不优化)。
|
||||
|
||||
---
|
||||
|
||||
## 12. 落地顺序(供 plan 细化)
|
||||
1. `core::Grid` NaN 支持 + `ContourBuilder`(VTK 几何提取 + 上采样/平滑/裁剪/简化)+ 单测。
|
||||
2. `ApiDatasetRepository`(4 接口 + 解析)+ `loadColorScale` variant + 单测。
|
||||
3. `DatasetChartView`(散点/等值面/轴/图例/异常叠加 + 交互)。
|
||||
4. dd 策略框架 + `ErtInversionStrategy` + 注册表。
|
||||
5. `AnomalyTablePanel` + `DatasetDetailPage/Panel`(多 Tab)。
|
||||
6. `DatasetDetailController` + `main.cpp` 接线(移除旧 VTK 详情 dock,接上数据集单击/双击 + 反向联动)。
|
||||
7. 视觉核对 + 集成测试。
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 518 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
|
|
@ -0,0 +1,8 @@
|
|||
# 本地开发构建脚本(封装 VS MSVC 环境 + VS 自带 cmake,构建 build/release)。
|
||||
# 用法(在仓库根,用 PowerShell 工具运行): pwsh -File scripts\dev-build.ps1
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$vcvars = 'D:\Program Files\Microsoft Visual Studio\18\Community\VC\Auxiliary\Build\vcvars64.bat'
|
||||
$cmake = 'D:\Program Files\Microsoft Visual Studio\18\Community\Common7\IDE\CommonExtensions\Microsoft\CMake\CMake\bin\cmake.exe'
|
||||
$bdir = 'D:\Git\lanbingtech\geopro\build\release'
|
||||
cmd /c "`"$vcvars`" >nul 2>&1 && `"$cmake`" --build `"$bdir`""
|
||||
exit $LASTEXITCODE
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
# 本地开发测试脚本(VS 自带 ctest 跑 build/release,可选 -Filter 按用例名过滤)。
|
||||
# 用法: pwsh -File scripts\dev-test.ps1 # 全量
|
||||
# pwsh -File scripts\dev-test.ps1 ContourBands # 仅匹配 ContourBands.* 的用例
|
||||
param([string]$Filter = '')
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$ctest = 'D:\Program Files\Microsoft Visual Studio\18\Community\Common7\IDE\CommonExtensions\Microsoft\CMake\CMake\bin\ctest.exe'
|
||||
$bdir = 'D:\Git\lanbingtech\geopro\build\release'
|
||||
$cargs = @('--test-dir', $bdir, '--output-on-failure')
|
||||
if ($Filter) { $cargs += @('-R', $Filter) }
|
||||
& $ctest @cargs
|
||||
exit $LASTEXITCODE
|
||||
|
|
@ -12,6 +12,10 @@ find_package(VTK REQUIRED COMPONENTS
|
|||
)
|
||||
find_package(nlohmann_json CONFIG REQUIRED)
|
||||
find_package(Qt6 REQUIRED COMPONENTS Svg)
|
||||
# trajectory 地图(QWebEngineView + Leaflet + 天地图)实现于 TrajectoryMapView.{hpp,cpp} +
|
||||
# resources/map/。WebEngine 硬依赖模块 Qt6WebChannel / Qt6Positioning 已补装,恢复 find_package/link/
|
||||
# deploy 三处 + main.cpp 初始化,DetailViewFactory 的 WebMap 分支切回 TrajectoryMapView。
|
||||
find_package(Qt6 REQUIRED COMPONENTS WebEngineWidgets WebEngineQuick)
|
||||
|
||||
add_executable(geopro_desktop WIN32
|
||||
main.cpp
|
||||
|
|
@ -26,9 +30,31 @@ add_executable(geopro_desktop WIN32
|
|||
panels/ObjectTreePanel.cpp
|
||||
panels/DynamicFormView.cpp
|
||||
panels/ObjectExceptionPanel.cpp
|
||||
panels/DescriptionPanel.cpp
|
||||
panels/chart/RawDataChartView.cpp
|
||||
panels/chart/GridDataChartView.cpp
|
||||
panels/chart/DataTableView.cpp
|
||||
panels/chart/TablePager.cpp
|
||||
panels/chart/BarChartView.cpp
|
||||
panels/chart/LineChartView.cpp
|
||||
panels/chart/TrajectoryMapView.cpp
|
||||
panels/chart/DetailViewFactory.cpp
|
||||
resources/map/map.qrc
|
||||
panels/chart/ChartTheme.cpp
|
||||
panels/chart/ColorMapService.cpp
|
||||
panels/chart/ColorBarWidget.cpp
|
||||
panels/chart/ScatterPlotItem.cpp
|
||||
panels/chart/ContourPlotItem.cpp
|
||||
panels/chart/LivePanner.cpp
|
||||
panels/chart/ScatterHoverTip.cpp
|
||||
panels/AnomalyTablePanel.cpp
|
||||
panels/LoadingOverlay.cpp
|
||||
panels/DatasetDetailPage.cpp
|
||||
panels/DatasetDetailPanel.cpp
|
||||
CentralScene.cpp
|
||||
ProjectListDialog.cpp
|
||||
SettingsDialog.cpp)
|
||||
SettingsDialog.cpp
|
||||
Logging.cpp)
|
||||
|
||||
target_include_directories(geopro_desktop PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
|
||||
# QtKeychain 经 FetchContent 接入,头文件不随 target 传播,显式加源/构建目录(含生成的 export 头)。
|
||||
|
|
@ -36,6 +62,7 @@ target_include_directories(geopro_desktop PRIVATE ${qtkeychain_SOURCE_DIR} ${qtk
|
|||
|
||||
target_link_libraries(geopro_desktop PRIVATE
|
||||
Qt6::Core Qt6::Gui Qt6::Widgets Qt6::Svg
|
||||
Qt6::WebEngineWidgets Qt6::WebEngineQuick
|
||||
${VTK_LIBRARIES}
|
||||
ads::qt6advanceddocking
|
||||
qt6keychain
|
||||
|
|
@ -47,8 +74,20 @@ target_link_libraries(geopro_desktop PRIVATE
|
|||
geopro_controller # Phase 5:导航编排(WorkbenchNavController)
|
||||
)
|
||||
|
||||
# Qwt(二维科学图表;源码存在时由根 CMake 定义 qwt 目标)。
|
||||
# cmake/qwt.cmake 已加 QWT_MOC_INCLUDE=1,Qwt Q_OBJECT 类的 MOC 代码直接编译进各 .cpp.obj,
|
||||
# 标准按需链接可正确解析所有 MOC 符号(无需 /WHOLEARCHIVE)。
|
||||
if(TARGET qwt)
|
||||
target_link_libraries(geopro_desktop PRIVATE qwt)
|
||||
endif()
|
||||
|
||||
vtk_module_autoinit(TARGETS geopro_desktop MODULES ${VTK_LIBRARIES})
|
||||
|
||||
# 崩溃 minidump:DbgHelp(Windows 自带)提供 MiniDumpWriteDump。
|
||||
if(WIN32)
|
||||
target_link_libraries(geopro_desktop PRIVATE Dbghelp)
|
||||
endif()
|
||||
|
||||
if(WIN32)
|
||||
add_custom_command(TARGET geopro_desktop POST_BUILD
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_if_different
|
||||
|
|
@ -64,4 +103,19 @@ if(WIN32)
|
|||
"$<TARGET_FILE_DIR:Qt6::Core>/../plugins/${_pl}"
|
||||
"$<TARGET_FILE_DIR:geopro_desktop>/${_pl}")
|
||||
endforeach()
|
||||
|
||||
# ── Qt WebEngine 运行期部署(地图页签需)──────────────────────────────────
|
||||
# WebEngine 子进程 QtWebEngineProcess.exe(Chromium 渲染进程宿主)须与 exe 同目录或在 PATH;
|
||||
# 其依赖 resources/*.pak + icudtl.dat + v8 快照,以及 qtwebengine_locales/*.pak。dev-build 不跑
|
||||
# windeployqt,故显式拷贝。WebEngine DLL(Qt6WebEngineCore/Widgets/Quick、WebChannel、Positioning、
|
||||
# Qml、Quick、QuickWidgets 等)由上方 TARGET_RUNTIME_DLLS 步随链接依赖自动拷贝。
|
||||
set(_qt_bin "$<TARGET_FILE_DIR:Qt6::Core>")
|
||||
add_custom_command(TARGET geopro_desktop POST_BUILD
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_if_different
|
||||
"${_qt_bin}/QtWebEngineProcess.exe" "$<TARGET_FILE_DIR:geopro_desktop>"
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_directory
|
||||
"${_qt_bin}/../resources" "$<TARGET_FILE_DIR:geopro_desktop>/resources"
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_directory
|
||||
"${_qt_bin}/../translations/qtwebengine_locales"
|
||||
"$<TARGET_FILE_DIR:geopro_desktop>/qtwebengine_locales")
|
||||
endif()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,243 @@
|
|||
#include "Logging.hpp"
|
||||
|
||||
#include <QCoreApplication>
|
||||
#include <QDateTime>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfoList>
|
||||
#include <QMutex>
|
||||
#include <QStandardPaths>
|
||||
#include <QtGlobal>
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <exception>
|
||||
|
||||
#ifdef Q_OS_WIN
|
||||
// clang-format off
|
||||
#include <windows.h>
|
||||
#include <dbghelp.h> // MiniDumpWriteDump(链接 Dbghelp)
|
||||
// clang-format on
|
||||
#endif
|
||||
|
||||
namespace geopro::app {
|
||||
namespace {
|
||||
|
||||
QFile g_logFile;
|
||||
QMutex g_mutex;
|
||||
QString g_logDir;
|
||||
constexpr int kRetentionDays = 14; // 旧日志/dump 保留天数
|
||||
|
||||
const char* levelStr(QtMsgType t) {
|
||||
switch (t) {
|
||||
case QtDebugMsg: return "DEBUG";
|
||||
case QtInfoMsg: return "INFO";
|
||||
case QtWarningMsg: return "WARN";
|
||||
case QtCriticalMsg: return "ERROR";
|
||||
case QtFatalMsg: return "FATAL";
|
||||
}
|
||||
return "INFO";
|
||||
}
|
||||
|
||||
// 线程安全写一行(落盘 + 同步到 stderr 便于开发期观察)。
|
||||
void writeLine(const QString& line) {
|
||||
const QByteArray utf8 = line.toUtf8();
|
||||
QMutexLocker lock(&g_mutex);
|
||||
if (g_logFile.isOpen()) {
|
||||
g_logFile.write(utf8);
|
||||
g_logFile.write("\n", 1);
|
||||
g_logFile.flush();
|
||||
}
|
||||
std::fwrite(utf8.constData(), 1, static_cast<size_t>(utf8.size()), stderr);
|
||||
std::fputc('\n', stderr);
|
||||
}
|
||||
|
||||
void messageHandler(QtMsgType type, const QMessageLogContext& ctx, const QString& msg) {
|
||||
const QString ts = QDateTime::currentDateTime().toString(QStringLiteral("yyyy-MM-dd HH:mm:ss.zzz"));
|
||||
QString line = QStringLiteral("%1 [%2] %3").arg(ts, QString::fromLatin1(levelStr(type)), msg);
|
||||
if (ctx.file && type >= QtWarningMsg) // 警告及以上带源码定位,便于排查
|
||||
line += QStringLiteral(" (%1:%2)").arg(QString::fromUtf8(ctx.file)).arg(ctx.line);
|
||||
writeLine(line);
|
||||
if (type == QtFatalMsg) {
|
||||
std::abort(); // qFatal 语义:记录后终止(触发崩溃捕获 → dump)
|
||||
}
|
||||
}
|
||||
|
||||
void pruneOldFiles(const QString& dir) {
|
||||
const QDateTime cutoff = QDateTime::currentDateTime().addDays(-kRetentionDays);
|
||||
const QFileInfoList files =
|
||||
QDir(dir).entryInfoList({QStringLiteral("geopro_*.log"), QStringLiteral("crash_*.dmp")},
|
||||
QDir::Files);
|
||||
for (const QFileInfo& fi : files)
|
||||
if (fi.lastModified() < cutoff) QFile::remove(fi.absoluteFilePath());
|
||||
}
|
||||
|
||||
#ifdef Q_OS_WIN
|
||||
// 崩溃时(写完 dump 后)追加一行摘要。直接写已打开的 g_logFile(进程将终止,不抢 g_mutex —
|
||||
// 否则崩溃发生在持锁线程时会死锁;另开第二句柄又会因独占共享冲突失败)。best-effort。
|
||||
void appendCrashLine(const QString& line) {
|
||||
const QByteArray utf8 = line.toUtf8();
|
||||
if (g_logFile.isOpen()) {
|
||||
g_logFile.write(utf8);
|
||||
g_logFile.write("\n", 1);
|
||||
g_logFile.flush();
|
||||
}
|
||||
std::fwrite(utf8.constData(), 1, static_cast<size_t>(utf8.size()), stderr);
|
||||
std::fputc('\n', stderr);
|
||||
}
|
||||
|
||||
LONG WINAPI crashFilter(EXCEPTION_POINTERS* info) {
|
||||
const DWORD code = (info && info->ExceptionRecord) ? info->ExceptionRecord->ExceptionCode : 0;
|
||||
const void* addr = (info && info->ExceptionRecord) ? info->ExceptionRecord->ExceptionAddress : nullptr;
|
||||
const QString ts = QDateTime::currentDateTime().toString(QStringLiteral("yyyyMMdd_HHmmss_zzz"));
|
||||
const QString dumpPath = g_logDir + QStringLiteral("/crash_") + ts + QStringLiteral(".dmp");
|
||||
|
||||
bool dumped = false;
|
||||
HANDLE hFile = CreateFileW(reinterpret_cast<const wchar_t*>(dumpPath.utf16()), GENERIC_WRITE, 0,
|
||||
nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
|
||||
if (hFile != INVALID_HANDLE_VALUE) {
|
||||
MINIDUMP_EXCEPTION_INFORMATION mei{};
|
||||
mei.ThreadId = GetCurrentThreadId();
|
||||
mei.ExceptionPointers = info;
|
||||
mei.ClientPointers = FALSE;
|
||||
const MINIDUMP_TYPE flags = static_cast<MINIDUMP_TYPE>(
|
||||
MiniDumpWithDataSegs | MiniDumpWithThreadInfo | MiniDumpWithHandleData);
|
||||
dumped = MiniDumpWriteDump(GetCurrentProcess(), GetCurrentProcessId(), hFile, flags,
|
||||
info ? &mei : nullptr, nullptr, nullptr);
|
||||
CloseHandle(hFile);
|
||||
}
|
||||
appendCrashLine(QStringLiteral("%1 [FATAL] 崩溃 code=0x%2 addr=0x%3 dump=%4")
|
||||
.arg(QDateTime::currentDateTime().toString(QStringLiteral("yyyy-MM-dd HH:mm:ss.zzz")))
|
||||
.arg(static_cast<quint32>(code), 0, 16)
|
||||
.arg(reinterpret_cast<quintptr>(addr), 0, 16)
|
||||
.arg(dumped ? dumpPath : QStringLiteral("写入失败")));
|
||||
return EXCEPTION_EXECUTE_HANDLER; // 记录后终止进程
|
||||
}
|
||||
#endif // Q_OS_WIN
|
||||
|
||||
#ifdef Q_OS_WIN
|
||||
// 向量化异常处理器:在 C++ 异常(0xE06D7363)**抛出瞬间**(栈未展开、进程健康、符号匹配)
|
||||
// 捕获并符号化调用栈写日志。即使异常随后被 try/catch(如顶层护栏)吞掉,也已留下抛点堆栈。
|
||||
constexpr DWORD kCppExceptionCode = 0xE06D7363;
|
||||
thread_local bool g_inVeh = false; // 防符号化过程自身再抛异常导致的重入
|
||||
|
||||
LONG WINAPI throwStackVeh(EXCEPTION_POINTERS* info) {
|
||||
if (!info || !info->ExceptionRecord) return EXCEPTION_CONTINUE_SEARCH;
|
||||
if (info->ExceptionRecord->ExceptionCode != kCppExceptionCode) return EXCEPTION_CONTINUE_SEARCH;
|
||||
if (g_inVeh) return EXCEPTION_CONTINUE_SEARCH;
|
||||
g_inVeh = true;
|
||||
|
||||
void* frames[32];
|
||||
const USHORT n = CaptureStackBackTrace(1, 32, frames, nullptr);
|
||||
const HANDLE proc = GetCurrentProcess();
|
||||
SymRefreshModuleList(proc); // 确保已加载模块(含本 exe 的 PDB)在符号表中
|
||||
alignas(SYMBOL_INFOW) char symBuf[sizeof(SYMBOL_INFOW) + 1024] = {};
|
||||
auto* sym = reinterpret_cast<SYMBOL_INFOW*>(symBuf);
|
||||
sym->SizeOfStruct = sizeof(SYMBOL_INFOW);
|
||||
sym->MaxNameLen = 1024 / sizeof(wchar_t) - 1;
|
||||
appendCrashLine(QStringLiteral("%1 [THROW] C++ 异常抛出,调用栈:")
|
||||
.arg(QDateTime::currentDateTime().toString(QStringLiteral("yyyy-MM-dd HH:mm:ss.zzz"))));
|
||||
for (USHORT i = 0; i < n; ++i) {
|
||||
const DWORD64 addr = reinterpret_cast<DWORD64>(frames[i]);
|
||||
// 模块名 + RVA:总是可得;即使符号未解析,也能离线用匹配 PDB 还原。
|
||||
QString modRva;
|
||||
HMODULE mod = nullptr;
|
||||
if (GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS |
|
||||
GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
|
||||
reinterpret_cast<LPCWSTR>(addr), &mod) &&
|
||||
mod) {
|
||||
wchar_t mpath[MAX_PATH] = {};
|
||||
GetModuleFileNameW(mod, mpath, MAX_PATH);
|
||||
QString base = QString::fromWCharArray(mpath);
|
||||
base = base.mid(base.lastIndexOf('\\') + 1);
|
||||
modRva = QStringLiteral("%1+0x%2").arg(base).arg(addr - reinterpret_cast<DWORD64>(mod), 0, 16);
|
||||
} else {
|
||||
modRva = QStringLiteral("0x%1").arg(addr, 0, 16);
|
||||
}
|
||||
// 符号名(宽字符:UNICODE 下 SymFromAddrW + WCHAR Name)。
|
||||
QString fn;
|
||||
DWORD64 symDisp = 0;
|
||||
if (SymFromAddrW(proc, addr, &symDisp, sym))
|
||||
fn = QStringLiteral(" %1+0x%2").arg(QString::fromWCharArray(sym->Name)).arg(symDisp, 0, 16);
|
||||
// 文件:行。
|
||||
QString loc;
|
||||
DWORD lineDisp = 0;
|
||||
IMAGEHLP_LINEW64 line = {};
|
||||
line.SizeOfStruct = sizeof(IMAGEHLP_LINEW64);
|
||||
if (SymGetLineFromAddrW64(proc, addr, &lineDisp, &line))
|
||||
loc = QStringLiteral(" (%1:%2)").arg(QString::fromWCharArray(line.FileName)).arg(line.LineNumber);
|
||||
appendCrashLine(QStringLiteral(" #%1 %2%3%4").arg(i).arg(modRva).arg(fn).arg(loc));
|
||||
}
|
||||
g_inVeh = false;
|
||||
return EXCEPTION_CONTINUE_SEARCH; // 不处理,交回正常流程(顶层护栏 try/catch 仍会接住)
|
||||
}
|
||||
#endif // Q_OS_WIN
|
||||
|
||||
void installCrashHandlers() {
|
||||
#ifdef Q_OS_WIN
|
||||
SetErrorMode(SEM_FAILCRITICALERRORS | SEM_NOGPFAULTERRORBOX); // 抑制系统崩溃弹窗
|
||||
SetUnhandledExceptionFilter(crashFilter);
|
||||
SymSetOptions(SYMOPT_LOAD_LINES | SYMOPT_UNDNAME | SYMOPT_FAIL_CRITICAL_ERRORS);
|
||||
// 显式把 exe 目录作为符号搜索路径(geopro_desktop.pdb 与 exe 同目录、匹配);立即(非 deferred)加载。
|
||||
const QByteArray searchPath = QCoreApplication::applicationDirPath().toLocal8Bit();
|
||||
if (!SymInitialize(GetCurrentProcess(), searchPath.constData(), TRUE))
|
||||
std::fprintf(stderr, "[Logging] SymInitialize 失败 err=%lu\n", GetLastError());
|
||||
// 显式加载本 exe 的符号(invade 偶尔不加载主模块的私有符号 → 内部函数无名)。
|
||||
wchar_t selfPath[MAX_PATH] = {};
|
||||
GetModuleFileNameW(nullptr, selfPath, MAX_PATH);
|
||||
const DWORD64 selfBase =
|
||||
SymLoadModuleExW(GetCurrentProcess(), nullptr, selfPath, nullptr,
|
||||
reinterpret_cast<DWORD64>(GetModuleHandleW(nullptr)), 0, nullptr, 0);
|
||||
if (selfBase == 0 && GetLastError() != ERROR_SUCCESS)
|
||||
std::fprintf(stderr, "[Logging] SymLoadModuleExW 失败 err=%lu\n", GetLastError());
|
||||
AddVectoredExceptionHandler(1, throwStackVeh); // first=1:先于 SEH 链,抛出瞬间捕获
|
||||
#endif
|
||||
// 未捕获 C++ 异常(跨事件循环逃逸)→ 记录后终止。SEH 过滤器通常已先写 dump。
|
||||
std::set_terminate([] {
|
||||
QString what = QStringLiteral("(无异常信息)");
|
||||
if (std::exception_ptr e = std::current_exception()) {
|
||||
try {
|
||||
std::rethrow_exception(e);
|
||||
} catch (const std::exception& ex) {
|
||||
what = QString::fromUtf8(ex.what());
|
||||
} catch (...) {
|
||||
what = QStringLiteral("(非 std::exception)");
|
||||
}
|
||||
}
|
||||
writeLine(QStringLiteral("%1 [FATAL] std::terminate 未捕获异常: %2")
|
||||
.arg(QDateTime::currentDateTime().toString(QStringLiteral("yyyy-MM-dd HH:mm:ss.zzz")), what));
|
||||
std::abort();
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void initLogging() {
|
||||
const QString base = QStandardPaths::writableLocation(QStandardPaths::AppLocalDataLocation);
|
||||
g_logDir = base + QStringLiteral("/logs");
|
||||
QDir().mkpath(g_logDir);
|
||||
pruneOldFiles(g_logDir);
|
||||
|
||||
const QString fname = QStringLiteral("geopro_%1.log")
|
||||
.arg(QDateTime::currentDateTime().toString(QStringLiteral("yyyyMMdd")));
|
||||
g_logFile.setFileName(g_logDir + QStringLiteral("/") + fname);
|
||||
const bool opened = g_logFile.open(QIODevice::Append | QIODevice::Text);
|
||||
if (!opened) { // 打不开(权限等)则仅写 stderr(writeLine 已容错)
|
||||
std::fprintf(stderr, "[Logging] 无法打开日志文件: %s\n", qUtf8Printable(g_logFile.fileName()));
|
||||
} else if (g_logFile.size() == 0) {
|
||||
g_logFile.write("\xEF\xBB\xBF", 3); // 新建文件写 UTF-8 BOM,便于中文 Windows 记事本正确识别编码
|
||||
}
|
||||
|
||||
qInstallMessageHandler(messageHandler);
|
||||
installCrashHandlers();
|
||||
|
||||
qInfo("=== Geopro 启动 v%s | 日志目录 %s ===",
|
||||
qUtf8Printable(QCoreApplication::applicationVersion().isEmpty()
|
||||
? QStringLiteral("dev")
|
||||
: QCoreApplication::applicationVersion()),
|
||||
qUtf8Printable(g_logDir));
|
||||
}
|
||||
|
||||
QString logDirectory() { return g_logDir; }
|
||||
|
||||
} // namespace geopro::app
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
#pragma once
|
||||
#include <QString>
|
||||
|
||||
namespace geopro::app {
|
||||
|
||||
// 初始化全局日志与崩溃捕获。需在 QApplication 构造、setOrganizationName/setApplicationName
|
||||
// 之后调用一次(依赖 AppLocalDataLocation 定位日志目录)。
|
||||
// - 安装 qInstallMessageHandler:全 App 的 qDebug/qInfo/qWarning/qCritical/qFatal 写入
|
||||
// 带时间戳/级别的滚动日志文件(%LOCALAPPDATA%/<Org>/<App>/logs/geopro_YYYYMMDD.log)。
|
||||
// - 安装崩溃捕获:未处理 SEH 异常(段错误等)+ std::terminate(未捕获 C++ 异常)→
|
||||
// 记录异常码/地址 + flush 日志,并在 Windows 上用 DbgHelp 写 crash_*.dmp(可 VS/WinDbg 事后分析)。
|
||||
// - 启动时清理 14 天前的旧日志/dump。
|
||||
void initLogging();
|
||||
|
||||
// 当前日志目录绝对路径(供 UI「打开日志目录」等使用,可选)。
|
||||
QString logDirectory();
|
||||
|
||||
} // namespace geopro::app
|
||||
|
|
@ -125,6 +125,7 @@ TabbedPanel buildTabbedPanel(const QVector<PanelTab>& tabs, const QVector<Header
|
|||
|
||||
TabbedPanel result;
|
||||
result.container = box;
|
||||
result.tabGroup = group;
|
||||
|
||||
for (int i = 0; i < tabs.size(); ++i) {
|
||||
const PanelTab& t = tabs[i];
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
class QWidget;
|
||||
class QLabel;
|
||||
class QToolButton;
|
||||
class QButtonGroup;
|
||||
|
||||
namespace geopro::app {
|
||||
|
||||
|
|
@ -34,10 +35,12 @@ struct PanelTab {
|
|||
bool hasBadge;
|
||||
};
|
||||
|
||||
// 带 Tab 表头的面板构建结果:容器 + 各 Tab 的徽标标签(无徽标处为 nullptr,供动态更新)。
|
||||
// 带 Tab 表头的面板构建结果:容器 + 各 Tab 的徽标标签(无徽标处为 nullptr,供动态更新)
|
||||
// + Tab 互斥按钮组(idClicked(int),供调用方监听页签切换做懒加载等)。
|
||||
struct TabbedPanel {
|
||||
QWidget* container;
|
||||
QVector<QLabel*> badges;
|
||||
QButtonGroup* tabGroup = nullptr;
|
||||
};
|
||||
|
||||
// 构建带 Tab 表头的面板(首个 Tab 默认激活;点击 Tab 切换下方堆叠内容)。
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@
|
|||
#include <QVBoxLayout>
|
||||
|
||||
#include "Theme.hpp"
|
||||
#include "api/NavLoads.hpp"
|
||||
#include "api/NavRequest.hpp"
|
||||
|
||||
|
||||
namespace geopro::app {
|
||||
|
|
@ -37,7 +39,7 @@ QColor statusColor(int s) {
|
|||
}
|
||||
} // namespace
|
||||
|
||||
ProjectListDialog::ProjectListDialog(data::IProjectRepository& repo, QWidget* parent)
|
||||
ProjectListDialog::ProjectListDialog(data::IAsyncProjectRepository& repo, QWidget* parent)
|
||||
: QDialog(parent), repo_(repo) {
|
||||
setWindowTitle(QStringLiteral("全部项目"));
|
||||
resize(980, 560);
|
||||
|
|
@ -125,27 +127,45 @@ ProjectListDialog::ProjectListDialog(data::IProjectRepository& repo, QWidget* pa
|
|||
query();
|
||||
}
|
||||
|
||||
ProjectListDialog::~ProjectListDialog() {
|
||||
// 退出契约:模态 exec 关窗后本对话框析构 → abort 在飞请求,防回调打到已析构窗口。
|
||||
if (typesReq_) typesReq_->abort();
|
||||
if (queryReq_) queryReq_->abort();
|
||||
}
|
||||
|
||||
void ProjectListDialog::fillTypeFilter() {
|
||||
typeCombo_->addItem(QStringLiteral("全部类型"), QString());
|
||||
const auto r = repo_.listProjectTypes();
|
||||
if (!r.ok) return;
|
||||
for (const auto& t : r.value)
|
||||
// abort-and-replace:最新一次过滤填充为准。
|
||||
if (typesReq_) typesReq_->abort();
|
||||
auto* r = repo_.listProjectTypesAsync();
|
||||
typesReq_ = r;
|
||||
QObject::connect(r, &data::NavRequest::done, this, [this, r](const QVariant& v) {
|
||||
if (r != typesReq_) return; // 身份比对:丢弃迟到/被替换信号
|
||||
typesReq_.clear();
|
||||
const auto types = qvariant_cast<std::vector<data::ProjectType>>(v);
|
||||
for (const auto& t : types)
|
||||
typeCombo_->addItem(QString::fromStdString(t.name), QString::fromStdString(t.id));
|
||||
});
|
||||
QObject::connect(r, &data::NavRequest::failed, this, [this, r](const QString&) {
|
||||
if (r != typesReq_) return;
|
||||
typesReq_.clear();
|
||||
// 失败时仅保留“全部类型”项(与原同步版失败仅 return 一致)。
|
||||
});
|
||||
}
|
||||
|
||||
void ProjectListDialog::query() {
|
||||
const std::string name = nameEdit_->text().trimmed().toStdString();
|
||||
const std::string typeId = typeCombo_->currentData().toString().toStdString();
|
||||
const auto r = repo_.pageProjects(name, typeId, pageNo_, pageSize_);
|
||||
if (!r.ok) {
|
||||
table_->setRowCount(0);
|
||||
pageLabel_->setText(QStringLiteral("加载失败:%1").arg(QString::fromStdString(r.error)));
|
||||
prevBtn_->setEnabled(false);
|
||||
nextBtn_->setEnabled(false);
|
||||
return;
|
||||
}
|
||||
total_ = r.value.total;
|
||||
const auto& rows = r.value.rows;
|
||||
// abort-and-replace:丢弃上一查询,仅最新结果落表。
|
||||
if (queryReq_) queryReq_->abort();
|
||||
auto* r = repo_.pageProjectsAsync(name, typeId, pageNo_, pageSize_);
|
||||
queryReq_ = r;
|
||||
QObject::connect(r, &data::NavRequest::done, this, [this, r](const QVariant& v) {
|
||||
if (r != queryReq_) return; // 身份比对
|
||||
queryReq_.clear();
|
||||
const auto page = qvariant_cast<data::ProjectListPage>(v);
|
||||
total_ = page.total;
|
||||
const auto& rows = page.rows;
|
||||
table_->setRowCount(static_cast<int>(rows.size()));
|
||||
for (int i = 0; i < static_cast<int>(rows.size()); ++i) {
|
||||
const auto& p = rows[i];
|
||||
|
|
@ -173,9 +193,19 @@ void ProjectListDialog::query() {
|
|||
set(7, QString::fromStdString(p.createTime));
|
||||
}
|
||||
const int pages = total_ > 0 ? (total_ + pageSize_ - 1) / pageSize_ : 1;
|
||||
pageLabel_->setText(QStringLiteral("共 %1 条 第 %2 / %3 页").arg(total_).arg(pageNo_).arg(pages));
|
||||
pageLabel_->setText(
|
||||
QStringLiteral("共 %1 条 第 %2 / %3 页").arg(total_).arg(pageNo_).arg(pages));
|
||||
prevBtn_->setEnabled(pageNo_ > 1);
|
||||
nextBtn_->setEnabled(pageNo_ < pages);
|
||||
});
|
||||
QObject::connect(r, &data::NavRequest::failed, this, [this, r](const QString& msg) {
|
||||
if (r != queryReq_) return;
|
||||
queryReq_.clear();
|
||||
table_->setRowCount(0);
|
||||
pageLabel_->setText(QStringLiteral("加载失败:%1").arg(msg));
|
||||
prevBtn_->setEnabled(false);
|
||||
nextBtn_->setEnabled(false);
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace geopro::app
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
#pragma once
|
||||
#include <QDialog>
|
||||
#include <QPointer>
|
||||
|
||||
#include "repo/IProjectRepository.hpp"
|
||||
#include "repo/IAsyncProjectRepository.hpp"
|
||||
|
||||
class QLineEdit;
|
||||
class QComboBox;
|
||||
|
|
@ -9,13 +10,16 @@ class QTableWidget;
|
|||
class QLabel;
|
||||
class QPushButton;
|
||||
|
||||
namespace geopro::data { class NavRequest; }
|
||||
|
||||
namespace geopro::app {
|
||||
|
||||
// 项目列表弹窗:名称/类型过滤 + 分页表格;点项目名 → 切换项目并关闭。
|
||||
class ProjectListDialog : public QDialog {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit ProjectListDialog(data::IProjectRepository& repo, QWidget* parent = nullptr);
|
||||
explicit ProjectListDialog(data::IAsyncProjectRepository& repo, QWidget* parent = nullptr);
|
||||
~ProjectListDialog() override;
|
||||
|
||||
signals:
|
||||
void projectChosen(const QString& projectId, const QString& projectName);
|
||||
|
|
@ -24,7 +28,9 @@ private:
|
|||
void query();
|
||||
void fillTypeFilter();
|
||||
|
||||
data::IProjectRepository& repo_;
|
||||
data::IAsyncProjectRepository& repo_;
|
||||
QPointer<data::NavRequest> typesReq_;
|
||||
QPointer<data::NavRequest> queryReq_;
|
||||
QLineEdit* nameEdit_ = nullptr;
|
||||
QComboBox* typeCombo_ = nullptr;
|
||||
QTableWidget* table_ = nullptr;
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
#include <QVBoxLayout>
|
||||
#include <QWidget>
|
||||
|
||||
#include "AuthLoads.hpp"
|
||||
#include "AuthService.hpp"
|
||||
#include "Theme.hpp"
|
||||
|
||||
|
|
@ -219,20 +220,36 @@ LoginWindow::LoginWindow(geopro::net::AuthService& auth, QWidget* parent)
|
|||
userEdit_->setFocus(); // 焦点落在第一个待填字段
|
||||
}
|
||||
|
||||
LoginWindow::~LoginWindow()
|
||||
{
|
||||
// 退出契约:窗口析构时 abort 在飞句柄,防回调打到已析构窗口(spec §5.0)。
|
||||
if (captchaLoad_) captchaLoad_->abort();
|
||||
if (loginLoad_) loginLoad_->abort();
|
||||
}
|
||||
|
||||
void LoginWindow::refreshCaptcha()
|
||||
{
|
||||
codeEdit_->clear();
|
||||
try {
|
||||
const auto cap = auth_.fetchCaptcha();
|
||||
if (captchaLoad_) captchaLoad_->abort(); // 取消上一张在飞请求
|
||||
refreshBtn_->setEnabled(false);
|
||||
|
||||
auto* l = auth_.fetchCaptchaAsync();
|
||||
captchaLoad_ = l;
|
||||
connect(l, &geopro::net::CaptchaLoad::done, this,
|
||||
[this, l](const geopro::net::AuthService::Captcha& cap) {
|
||||
if (l != captchaLoad_) return; // 身份比对:仅处理最新请求
|
||||
captchaLoad_.clear();
|
||||
codeId_ = cap.codeId;
|
||||
captchaLabel_->setPixmap(renderCaptchaPixmap(cap.code));
|
||||
} catch (const std::exception& e) {
|
||||
showError(QStringLiteral("获取验证码失败:%1").arg(QString::fromUtf8(e.what())));
|
||||
refreshBtn_->setEnabled(true);
|
||||
});
|
||||
connect(l, &geopro::net::CaptchaLoad::failed, this, [this, l](const QString& msg) {
|
||||
if (l != captchaLoad_) return;
|
||||
captchaLoad_.clear();
|
||||
showError(QStringLiteral("获取验证码失败:%1").arg(msg));
|
||||
captchaLabel_->setText(QStringLiteral("加载失败"));
|
||||
} catch (...) {
|
||||
showError(QStringLiteral("获取验证码失败"));
|
||||
captchaLabel_->setText(QStringLiteral("加载失败"));
|
||||
}
|
||||
refreshBtn_->setEnabled(true);
|
||||
});
|
||||
}
|
||||
|
||||
void LoginWindow::attemptLogin()
|
||||
|
|
@ -249,31 +266,25 @@ void LoginWindow::attemptLogin()
|
|||
errorLabel_->clear();
|
||||
loginBtn_->setEnabled(false);
|
||||
const QString origText = loginBtn_->text();
|
||||
loginBtn_->setText(QStringLiteral("登录中..."));
|
||||
loginBtn_->repaint(); // 同步阻塞前刷新按钮文案
|
||||
|
||||
geopro::net::AuthService::LoginResult result;
|
||||
try {
|
||||
result = auth_.login(user, pwd, code, codeId_);
|
||||
} catch (const std::exception& e) {
|
||||
result.ok = false;
|
||||
result.error = QStringLiteral("登录异常:%1").arg(QString::fromUtf8(e.what()));
|
||||
} catch (...) {
|
||||
result.ok = false;
|
||||
result.error = QStringLiteral("登录发生未知错误");
|
||||
}
|
||||
loginBtn_->setText(QStringLiteral("登录中...")); // 异步不冻 UI,无需 repaint hack
|
||||
|
||||
if (loginLoad_) loginLoad_->abort(); // 取消上一次在飞登录
|
||||
auto* l = auth_.loginAsync(user, pwd, code, codeId_);
|
||||
loginLoad_ = l;
|
||||
connect(l, &geopro::net::LoginLoad::done, this, [this, l](const QString& token) {
|
||||
if (l != loginLoad_) return; // 身份比对
|
||||
loginLoad_.clear();
|
||||
token_ = token;
|
||||
accept();
|
||||
});
|
||||
connect(l, &geopro::net::LoginLoad::failed, this, [this, l, origText](const QString& msg) {
|
||||
if (l != loginLoad_) return;
|
||||
loginLoad_.clear();
|
||||
loginBtn_->setText(origText);
|
||||
loginBtn_->setEnabled(true);
|
||||
|
||||
if (result.ok) {
|
||||
token_ = result.token;
|
||||
accept();
|
||||
return;
|
||||
}
|
||||
|
||||
showError(result.error.isEmpty() ? QStringLiteral("登录失败") : result.error);
|
||||
showError(msg.isEmpty() ? QStringLiteral("登录失败") : msg);
|
||||
refreshCaptcha(); // 失败刷新验证码
|
||||
});
|
||||
}
|
||||
|
||||
bool LoginWindow::remember() const
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
// 成功后 accept() 并经 token() 暴露 accessToken 给主流程注入 ApiClient。
|
||||
|
||||
#include <QDialog>
|
||||
#include <QPointer>
|
||||
#include <QString>
|
||||
|
||||
class QCheckBox;
|
||||
|
|
@ -13,6 +14,8 @@ class QPushButton;
|
|||
|
||||
namespace geopro::net {
|
||||
class AuthService;
|
||||
class CaptchaLoad;
|
||||
class LoginLoad;
|
||||
}
|
||||
|
||||
namespace geopro::app {
|
||||
|
|
@ -22,6 +25,7 @@ class LoginWindow : public QDialog {
|
|||
|
||||
public:
|
||||
explicit LoginWindow(geopro::net::AuthService& auth, QWidget* parent = nullptr);
|
||||
~LoginWindow() override;
|
||||
|
||||
// 登录成功后的 accessToken(形如 "Geomative <hash>");未登录为空。
|
||||
QString token() const { return token_; }
|
||||
|
|
@ -31,12 +35,14 @@ public:
|
|||
|
||||
private:
|
||||
void refreshCaptcha(); // 拉新验证码并重绘图片
|
||||
void attemptLogin(); // 校验输入并发起阻塞登录
|
||||
void attemptLogin(); // 校验输入并发起异步登录
|
||||
void showError(const QString& msg);
|
||||
|
||||
geopro::net::AuthService& auth_;
|
||||
QString token_;
|
||||
QString codeId_;
|
||||
QPointer<geopro::net::CaptchaLoad> captchaLoad_;
|
||||
QPointer<geopro::net::LoginLoad> loginLoad_;
|
||||
|
||||
QLineEdit* userEdit_ = nullptr;
|
||||
QLineEdit* pwdEdit_ = nullptr;
|
||||
|
|
|
|||
427
src/app/main.cpp
427
src/app/main.cpp
|
|
@ -21,6 +21,7 @@
|
|||
#include <memory>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <typeinfo>
|
||||
#include <vector>
|
||||
|
||||
#include <QActionGroup>
|
||||
|
|
@ -56,9 +57,12 @@
|
|||
#include <QToolBar>
|
||||
#include <QTreeWidget>
|
||||
#include <QTreeWidgetItem>
|
||||
#include <QTreeWidgetItemIterator>
|
||||
#include <QVBoxLayout>
|
||||
#include <QWidget>
|
||||
|
||||
#include <QtWebEngineQuick/QtWebEngineQuick>
|
||||
|
||||
#include <DockAreaTitleBar.h>
|
||||
#include <DockAreaWidget.h>
|
||||
#include <DockManager.h>
|
||||
|
|
@ -73,6 +77,7 @@
|
|||
#include "AuthService.hpp"
|
||||
#include "Credential.hpp"
|
||||
#include "Glyphs.hpp"
|
||||
#include "Logging.hpp"
|
||||
#include "PanelHeader.hpp"
|
||||
#include "Theme.hpp"
|
||||
#include "SettingsDialog.hpp"
|
||||
|
|
@ -80,10 +85,18 @@
|
|||
#include "CentralScene.hpp"
|
||||
#include "ProjectListDialog.hpp"
|
||||
#include "WorkbenchNavController.hpp"
|
||||
#include "DatasetDetailController.hpp"
|
||||
#include "panels/chart/ErtInversionStrategy.hpp"
|
||||
#include "panels/chart/MeasurementStrategy.hpp"
|
||||
#include "panels/chart/GrMeasurementStrategy.hpp"
|
||||
#include "panels/chart/TrajectoryStrategy.hpp"
|
||||
#include "panels/chart/GridStrategy.hpp"
|
||||
#include "api/ApiProjectRepository.hpp"
|
||||
#include "api/ApiDatasetRepository.hpp"
|
||||
#include "panels/ObjectTreePanel.hpp"
|
||||
#include "login/LoginWindow.hpp"
|
||||
#include "panels/DatasetListPanel.hpp"
|
||||
#include "panels/DatasetDetailPanel.hpp"
|
||||
#include "panels/DynamicFormView.hpp"
|
||||
#include "panels/ObjectExceptionPanel.hpp"
|
||||
|
||||
|
|
@ -155,44 +168,6 @@ private:
|
|||
QWidget* host_;
|
||||
};
|
||||
|
||||
// 相机补间 + actor 淡入:从 from 位姿平滑过渡到 to 位姿,同时 actors 透明度 0→1。
|
||||
// vtkCameraInterpolator 两关键帧线性插值(缓动交给 QEasingCurve),单条 QVariantAnimation
|
||||
// 逐帧驱动并 Render;结束回调锁定到目标态(防插值末值误差/残留半透明)。
|
||||
// 渐进增强:动效只是过渡,最终一帧永远是正确的目标态,故即使观感不佳也不破坏功能。
|
||||
void animateReveal(vtkRenderer* renderer, vtkGenericOpenGLRenderWindow* rw,
|
||||
vtkSmartPointer<vtkCamera> fromCam, vtkSmartPointer<vtkCamera> toCam,
|
||||
std::vector<vtkSmartPointer<vtkActor>> actors, int durationMs, QObject* owner)
|
||||
{
|
||||
auto interp = vtkSmartPointer<vtkCameraInterpolator>::New();
|
||||
interp->SetInterpolationTypeToLinear();
|
||||
interp->AddCamera(0.0, fromCam);
|
||||
interp->AddCamera(1.0, toCam);
|
||||
|
||||
auto* anim = new QVariantAnimation(owner);
|
||||
anim->setDuration(durationMs);
|
||||
anim->setStartValue(0.0);
|
||||
anim->setEndValue(1.0);
|
||||
anim->setEasingCurve(QEasingCurve::OutCubic);
|
||||
QObject::connect(anim, &QVariantAnimation::valueChanged, owner,
|
||||
[interp, renderer, rw, actors](const QVariant& v) {
|
||||
const double t = v.toDouble();
|
||||
interp->InterpolateCamera(t, renderer->GetActiveCamera());
|
||||
for (const auto& a : actors)
|
||||
if (a) a->GetProperty()->SetOpacity(t);
|
||||
renderer->ResetCameraClippingRange();
|
||||
rw->Render();
|
||||
});
|
||||
QObject::connect(anim, &QVariantAnimation::finished, owner,
|
||||
[renderer, rw, actors, toCam]() {
|
||||
renderer->GetActiveCamera()->DeepCopy(toCam);
|
||||
for (const auto& a : actors)
|
||||
if (a) a->GetProperty()->SetOpacity(1.0);
|
||||
renderer->ResetCameraClippingRange();
|
||||
rw->Render();
|
||||
});
|
||||
anim->start(QAbstractAnimation::DeleteWhenStopped);
|
||||
}
|
||||
|
||||
// 读取 RSA 公钥 PEM 全文(登录时密码加密用)。读不到返回空串,登录将报错。
|
||||
std::string readPem(const std::string& path)
|
||||
{
|
||||
|
|
@ -215,12 +190,6 @@ double median(std::vector<double> v)
|
|||
// 当前中央视图(默认二维地图)。二维地图=测线红线俯视;三维视图=断面墙。
|
||||
using geopro::app::ViewMode;
|
||||
|
||||
// 数据详情显示内容(默认网格数据)。网格数据=#18 banded;原数据=#17 散点(对齐原型命名)。
|
||||
enum class DetailMode { Section18, Scatter17 };
|
||||
|
||||
// #17 散点屏幕像素方块边长。
|
||||
constexpr float kScatterPointSize = 4.0F;
|
||||
|
||||
// 纵向夸张倍数(Z 基准统一,M-3):全项目共用同一倍数,使 帘面(z) / 体素 / 切片 /
|
||||
// 数据详情剖面(y) / 地形(relief) 的纵向比例一致——避免「剖面×1.5、帘面×3」不一致。
|
||||
// 单一可调常量:要整体调纵向观感改这一处即可。
|
||||
|
|
@ -233,8 +202,9 @@ constexpr const char* kWgs84 = "EPSG:4326";
|
|||
// 在给定 QMainWindow 上构建 M1 工作台。
|
||||
// repo 生命周期须覆盖到事件循环结束(由调用方保证)。
|
||||
void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& repo,
|
||||
geopro::data::IProjectRepository& projectRepo,
|
||||
geopro::controller::WorkbenchNavController& nav)
|
||||
geopro::data::IAsyncProjectRepository& projectRepo,
|
||||
geopro::controller::WorkbenchNavController& nav,
|
||||
geopro::controller::DatasetDetailController& detailCtrl)
|
||||
{
|
||||
// ── 世界系:启动取一次 grid1 的 lat/lon,用中位数作 GeoLocalFrame 原点 ──
|
||||
// 全项目共享(shared_ptr 持有):所有帘面用同一 frame 投影,保证多条测线空间配准。
|
||||
|
|
@ -322,23 +292,6 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
|
|||
centerLayout->setContentsMargins(0, 0, 0, 0);
|
||||
centerLayout->setSpacing(0);
|
||||
|
||||
// 分段工具条按钮样式(QToolButton + 主题化 QSS):选中=强调色文字 + 强调色下划线,明暗都清晰。
|
||||
// ElaToolButton 选中只画极淡 BasicHover、且不可经 QSS 改,故这类需清晰选中态的用 QToolButton。
|
||||
const QString kBarBtnQss =
|
||||
QStringLiteral(
|
||||
"QToolButton{ border:none; border-radius:6px; padding:6px 12px; color:{{text/primary}};"
|
||||
" font-size:%1px; }"
|
||||
"QToolButton:hover{ background:{{bg/hover}}; }"
|
||||
"QToolButton:checked{ color:{{accent/primary}}; font-weight:%2;"
|
||||
" border-bottom:2px solid {{accent/primary}}; }"
|
||||
"QToolButton#dataTab{ border:none; border-radius:0; background:transparent;"
|
||||
" border-bottom:2px solid transparent; color:{{text/secondary}}; padding:8px 8px; }"
|
||||
"QToolButton#dataTab:hover{ color:{{text/primary}}; background:transparent; }"
|
||||
"QToolButton#dataTab:checked{ color:{{accent/primary}}; font-weight:%2;"
|
||||
" border-bottom:2px solid {{accent/primary}}; }")
|
||||
.arg(geopro::app::scaledPx(geopro::app::type::kBody))
|
||||
.arg(geopro::app::type::kWeightSemibold);
|
||||
|
||||
// 「二维地图/三维视图」分段切换表头:与「异常/属性」面板表头同款(42px 表头底 + 强调色下划线页签)。
|
||||
auto seg = geopro::app::buildSegmentedHeader(
|
||||
{QStringLiteral("二维地图"), QStringLiteral("三维视图")},
|
||||
|
|
@ -448,77 +401,16 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
|
|||
vtkDock->setWidget(centerWidget);
|
||||
auto* centerDockArea = dockManager->addDockWidget(ads::CenterDockWidgetArea, vtkDock);
|
||||
|
||||
// ── 下方「数据详情」dock:独立 QVTK 小视图(独立 renderer/renderWindow)──
|
||||
// 单击 DS → 显示该数据集平面反演剖面(#18 banded,平躺俯视正交)。
|
||||
auto* detailWidget = new QVTKOpenGLStereoWidget();
|
||||
vtkNew<vtkGenericOpenGLRenderWindow> detailRenderWindow;
|
||||
vtkNew<vtkRenderer> detailRenderer;
|
||||
{
|
||||
double r, g, b;
|
||||
geopro::app::vtkBackground(r, g, b); // 背景随主题
|
||||
detailRenderer->SetBackground(r, g, b);
|
||||
}
|
||||
detailWidget->setRenderWindow(detailRenderWindow);
|
||||
detailRenderWindow->AddRenderer(detailRenderer);
|
||||
vtkRenderer* detailRendererPtr = detailRenderer.Get();
|
||||
vtkGenericOpenGLRenderWindow* detailRenderWindowPtr = detailRenderWindow.Get();
|
||||
// 注:VTK 背景随主题切换的连接放在 rebuildCentral/rebuildDetail 定义之后(直接重跑它们,
|
||||
// 走完整渲染路径必重绘,比手动 SetBackground+Render 稳)。
|
||||
|
||||
// 数据详情容器:顶部「反演剖面/原数据」工具条 + 下方 QVTK 小视图。
|
||||
auto* detailContainer = new QWidget();
|
||||
auto* detailLayout = new QVBoxLayout(detailContainer);
|
||||
detailLayout->setContentsMargins(0, 0, 0, 0);
|
||||
detailLayout->setSpacing(0);
|
||||
|
||||
// 工具条对齐原型:「原数据 | 网格数据」互斥 +「显示异常/电极/等值线」开关。
|
||||
// QToolButton + 主题化 QSS(选中=强调色文字+下划线,明暗都清晰)。
|
||||
auto* detailToolBar = new QWidget();
|
||||
auto* detailBarLay = new QHBoxLayout(detailToolBar);
|
||||
detailBarLay->setContentsMargins(8, 6, 8, 6);
|
||||
detailBarLay->setSpacing(6);
|
||||
auto makeBarBtn = [detailToolBar](const QString& text, bool checkable) {
|
||||
auto* b = new QToolButton(detailToolBar);
|
||||
b->setText(text);
|
||||
b->setCheckable(checkable);
|
||||
return b;
|
||||
};
|
||||
auto* detailGroup = new QButtonGroup(detailToolBar);
|
||||
detailGroup->setExclusive(true);
|
||||
auto* actScatter = makeBarBtn(QStringLiteral("原数据"), true);
|
||||
auto* actSection = makeBarBtn(QStringLiteral("网格数据"), true);
|
||||
actScatter->setObjectName(QStringLiteral("dataTab"));
|
||||
actSection->setObjectName(QStringLiteral("dataTab"));
|
||||
detailGroup->addButton(actScatter);
|
||||
detailGroup->addButton(actSection);
|
||||
detailBarLay->addWidget(actScatter);
|
||||
detailBarLay->addWidget(actSection);
|
||||
actSection->setChecked(true); // 默认网格数据 (#18)
|
||||
auto* barSep = new QFrame(detailToolBar);
|
||||
barSep->setFrameShape(QFrame::VLine);
|
||||
barSep->setObjectName(QStringLiteral("topDivider"));
|
||||
detailBarLay->addSpacing(4);
|
||||
detailBarLay->addWidget(barSep);
|
||||
detailBarLay->addSpacing(4);
|
||||
auto* actShowAnomaly = makeBarBtn(QStringLiteral("显示异常"), true);
|
||||
actShowAnomaly->setChecked(true); // 默认显示异常(对齐原型 ☑显示异常)
|
||||
auto* actShowElectrodes = makeBarBtn(QStringLiteral("显示电极"), true);
|
||||
actShowElectrodes->setChecked(true); // 默认显示电极 ▼(对齐原型)
|
||||
auto* actShowContour = makeBarBtn(QStringLiteral("显示等值线"), true);
|
||||
actShowContour->setChecked(true); // 默认显示等值线(对齐原型)
|
||||
detailBarLay->addWidget(actShowAnomaly);
|
||||
detailBarLay->addWidget(actShowElectrodes);
|
||||
detailBarLay->addWidget(actShowContour);
|
||||
detailBarLay->addStretch();
|
||||
geopro::app::applyTokenizedStyleSheet(detailToolBar, kBarBtnQss);
|
||||
detailLayout->addWidget(detailToolBar);
|
||||
detailLayout->addWidget(detailWidget, 1);
|
||||
|
||||
// ── 下方「数据详情」dock:平面图表多 Tab 面板(QGraphicsView,VTK 仅算几何)──
|
||||
// 单击数据集 → 聚焦已开页;双击 → 新建/聚焦页(真实反演剖面/散点/异常/色阶)。
|
||||
auto* detailPanel = new geopro::app::DatasetDetailPanel();
|
||||
auto* detailDock = new ads::CDockWidget(QStringLiteral("数据详情"));
|
||||
// ForceNoScrollArea:禁止 ADS 默认把整块内容(含标题栏/页签栏)包进 QScrollArea。
|
||||
// 否则内容最小尺寸超过 dock 时,整个面板(标题+页签+图)一起滚动。改为内容自适应填充;
|
||||
// 需要时由内层(图表内容区)自行滚动,标题/页签固定。
|
||||
detailDock->setWidget(wrapWithHeader(
|
||||
geopro::app::Glyph::Detail, QStringLiteral("数据详情"), detailContainer,
|
||||
{{geopro::app::Glyph::Collapse, QStringLiteral("折叠")},
|
||||
{geopro::app::Glyph::Download, QStringLiteral("导出")}}));
|
||||
geopro::app::Glyph::Detail, QStringLiteral("数据详情"), detailPanel),
|
||||
ads::CDockWidget::ForceNoScrollArea);
|
||||
// 放在中央视图下方。
|
||||
dockManager->addDockWidget(ads::BottomDockWidgetArea, detailDock, centerDockArea);
|
||||
|
||||
|
|
@ -532,7 +424,14 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
|
|||
|
||||
// 左下 dock:数据真实显示栏(选中测线后列其采集批次=数据集;tab 数据/文件)。
|
||||
auto* datasetTabs = new QTabWidget();
|
||||
auto* datasetList = new QListWidget();
|
||||
// 数据页签:树形列表(原版 el-table 树——派生数据挂源数据下,按 DsRow.parentId 嵌套)。
|
||||
auto* datasetList = new QTreeWidget();
|
||||
datasetList->setHeaderHidden(true);
|
||||
datasetList->setColumnCount(1);
|
||||
datasetList->setRootIsDecorated(true); // 显展开/折叠箭头
|
||||
datasetList->setIndentation(geopro::app::scaledPx(14));
|
||||
datasetList->setExpandsOnDoubleClick(false); // 双击=打开详情,不切展开(展开靠箭头)
|
||||
datasetList->setSelectionBehavior(QAbstractItemView::SelectRows);
|
||||
geopro::app::applyDatasetCardDelegate(datasetList);
|
||||
datasetTabs->addTab(datasetList, QStringLiteral("数据"));
|
||||
auto* fileList = new QListWidget();
|
||||
|
|
@ -599,146 +498,60 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
|
|||
*frame, kVerticalExaggeration);
|
||||
};
|
||||
|
||||
// ── 数据详情共享状态 + 重建 ──────────────────────────────────────────
|
||||
// 当前选中数据集 id(空=未选)与详情显示模式(反演剖面/原数据);切模式或换选中都重建。
|
||||
auto currentDsId = std::make_shared<QString>();
|
||||
auto detailMode = std::make_shared<DetailMode>(DetailMode::Section18);
|
||||
auto showAnomalies = std::make_shared<bool>(true); // 默认显示异常(对齐原型)
|
||||
auto showElectrodes = std::make_shared<bool>(true); // 默认显示电极 ▼
|
||||
auto showContour = std::make_shared<bool>(true); // 默认显示等值线
|
||||
auto hiddenAnoms = std::make_shared<std::set<int>>(); // 异常列表中被取消勾选(隐藏)的异常下标
|
||||
auto prevDsId = std::make_shared<QString>(); // 上次渲染的 DS id:判定“切换数据集”以触发揭示过渡
|
||||
|
||||
// 按当前选中 DS + 详情模式重建下方数据详情(平躺俯视正交,纵向夸张填面板)。
|
||||
// 勾选「显示异常/电极/等值线」控制对应叠加(同纵向夸张对齐)。
|
||||
// overdrive(A):仅“切换数据集”这一加载时刻播放相机补间 + actor 淡入揭示;模式/叠加层开关
|
||||
// 属同一数据集内微调,直接落定不放动画(特殊时刻才特殊,避免每次交互都动的疲劳)。
|
||||
auto rebuildDetail = [&repo, detailRendererPtr, detailRenderWindowPtr, detailWidget, currentDsId,
|
||||
prevDsId, detailMode, showAnomalies, showElectrodes, showContour,
|
||||
hiddenAnoms]() {
|
||||
const bool dsChanged = (*currentDsId != *prevDsId);
|
||||
const bool animate = dsChanged && !prevDsId->isEmpty() && !currentDsId->isEmpty();
|
||||
|
||||
// 过渡起点:清场景前先快照当前相机位姿。
|
||||
auto fromCam = vtkSmartPointer<vtkCamera>::New();
|
||||
fromCam->DeepCopy(detailRendererPtr->GetActiveCamera());
|
||||
|
||||
detailRendererPtr->RemoveAllViewProps();
|
||||
{ // 背景随主题
|
||||
double r, g, b;
|
||||
geopro::app::vtkBackground(r, g, b);
|
||||
detailRendererPtr->SetBackground(r, g, b);
|
||||
}
|
||||
if (currentDsId->isEmpty()) { // 未选数据集:清空即可
|
||||
*prevDsId = *currentDsId;
|
||||
detailRenderWindowPtr->Render();
|
||||
return;
|
||||
}
|
||||
std::vector<vtkSmartPointer<vtkActor>> added; // 本次加入的 actor,供淡入
|
||||
const std::string id = currentDsId->toStdString();
|
||||
if (*detailMode == DetailMode::Section18) {
|
||||
// 网格数据:#18 banded 等值面(+「显示等值线」时叠黑色等值线),纵向夸张 1.5x(沿 y)。
|
||||
const auto g = repo.loadGrid(id);
|
||||
const auto cs = repo.loadColorScale(id);
|
||||
const auto actors = geopro::render::buildGridContour(g, cs);
|
||||
if (actors.bands) {
|
||||
actors.bands->SetScale(1.0, kVerticalExaggeration, 1.0);
|
||||
detailRendererPtr->AddViewProp(actors.bands);
|
||||
added.push_back(actors.bands);
|
||||
}
|
||||
if (actors.edges && *showContour) {
|
||||
actors.edges->SetScale(1.0, kVerticalExaggeration, 1.0);
|
||||
detailRendererPtr->AddViewProp(actors.edges);
|
||||
added.push_back(actors.edges);
|
||||
}
|
||||
// 顶部电极标记 ▼(仅网格数据;同纵向夸张对齐)。
|
||||
if (*showElectrodes) {
|
||||
auto elec = geopro::render::buildElectrodes(g);
|
||||
if (elec) {
|
||||
elec->SetScale(1.0, kVerticalExaggeration, 1.0);
|
||||
detailRendererPtr->AddViewProp(elec);
|
||||
added.push_back(elec);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 原数据:#17 彩色散点,用散点自带色阶;纵向夸张同剖面以对齐观感。
|
||||
const auto s = repo.loadScatter(id);
|
||||
const auto scs = repo.loadScatterColorScale(id);
|
||||
auto a = geopro::render::buildScatter(s, scs, kScatterPointSize);
|
||||
if (a) {
|
||||
a->SetScale(1.0, kVerticalExaggeration, 1.0);
|
||||
detailRendererPtr->AddViewProp(a);
|
||||
added.push_back(a);
|
||||
}
|
||||
}
|
||||
// 异常叠加(与剖面同坐标系/同纵向夸张)。逐异常构建以按列表显隐(下标=原 vector 序)过滤。
|
||||
if (*showAnomalies) {
|
||||
const auto anomalies = repo.loadAnomalies(id);
|
||||
for (int i = 0; i < static_cast<int>(anomalies.size()); ++i) {
|
||||
if (hiddenAnoms->count(i)) continue; // 列表中取消勾选→隐藏
|
||||
for (auto& act : geopro::render::buildAnomalies({anomalies[i]})) {
|
||||
act->SetScale(1.0, kVerticalExaggeration, 1.0);
|
||||
detailRendererPtr->AddViewProp(act);
|
||||
added.push_back(act);
|
||||
}
|
||||
}
|
||||
}
|
||||
geopro::render::applyTop2D(detailRendererPtr);
|
||||
detailRendererPtr->ResetCamera();
|
||||
*prevDsId = *currentDsId;
|
||||
|
||||
if (animate) {
|
||||
// 目标位姿快照 → 相机回退到旧位姿 + actors 透明 → 补间到目标并淡入。
|
||||
auto toCam = vtkSmartPointer<vtkCamera>::New();
|
||||
toCam->DeepCopy(detailRendererPtr->GetActiveCamera());
|
||||
for (const auto& a : added) a->GetProperty()->SetOpacity(0.0);
|
||||
detailRendererPtr->GetActiveCamera()->DeepCopy(fromCam);
|
||||
detailRendererPtr->ResetCameraClippingRange();
|
||||
animateReveal(detailRendererPtr, detailRenderWindowPtr, fromCam, toCam, added, 450,
|
||||
detailWidget);
|
||||
} else {
|
||||
detailRenderWindowPtr->Render();
|
||||
}
|
||||
};
|
||||
|
||||
// ── 单击左下数据列表的采集批次(DS) → 加载数据集动态表单(数据集属性面板)──
|
||||
QObject::connect(datasetList, &QListWidget::itemClicked, datasetList,
|
||||
[&nav](QListWidgetItem* item) {
|
||||
if (item->data(geopro::app::kDsLoadMoreRole).toBool()) {
|
||||
// ── 单击左下数据列表的采集批次(DS) → 属性表单 + 聚焦详情已开页 ──
|
||||
QObject::connect(datasetList, &QTreeWidget::itemClicked, datasetList,
|
||||
[&nav, &detailCtrl](QTreeWidgetItem* item, int) {
|
||||
if (item->data(0, geopro::app::kDsLoadMoreRole).toBool()) {
|
||||
nav.loadMoreData();
|
||||
return;
|
||||
}
|
||||
const QString dsId = item->data(geopro::app::kDsIdRole).toString();
|
||||
if (!dsId.isEmpty()) nav.selectDataset(dsId);
|
||||
const QString dsId = item->data(0, geopro::app::kDsIdRole).toString();
|
||||
if (dsId.isEmpty()) return;
|
||||
nav.selectDataset(dsId); // 属性表单(现状)
|
||||
detailCtrl.focusDataset(dsId); // 单击=聚焦已开页
|
||||
});
|
||||
|
||||
// ── 数据详情工具条「反演剖面/原数据」:切模式 → 重建数据详情 ──
|
||||
QObject::connect(actSection, &QAbstractButton::clicked, detailWidget,
|
||||
[detailMode, rebuildDetail]() {
|
||||
*detailMode = DetailMode::Section18;
|
||||
rebuildDetail();
|
||||
});
|
||||
QObject::connect(actScatter, &QAbstractButton::clicked, detailWidget,
|
||||
[detailMode, rebuildDetail]() {
|
||||
*detailMode = DetailMode::Scatter17;
|
||||
rebuildDetail();
|
||||
// ── 双击 → 打开/聚焦该数据集的详情图表页(拉真实反演剖面/散点/异常/色阶)──
|
||||
QObject::connect(datasetList, &QTreeWidget::itemDoubleClicked, datasetList,
|
||||
[&detailCtrl](QTreeWidgetItem* item, int) {
|
||||
const QString dsId = item->data(0, geopro::app::kDsIdRole).toString();
|
||||
const QString ddCode = item->data(0, geopro::app::kDsDdCodeRole).toString();
|
||||
const QString dsName = item->data(0, geopro::app::kDsNameRole).toString();
|
||||
if (!dsId.isEmpty()) detailCtrl.openDataset(dsId, ddCode, dsName);
|
||||
});
|
||||
|
||||
// ──「显示异常 / 显示电极 / 显示等值线」开关:切换叠加 → 重建数据详情 ──
|
||||
QObject::connect(actShowAnomaly, &QAbstractButton::toggled, detailWidget,
|
||||
[showAnomalies, rebuildDetail](bool on) {
|
||||
*showAnomalies = on;
|
||||
rebuildDetail();
|
||||
// ── 控制器信号 → 详情面板(tab 引擎):开页 / 页签就绪 / 加载中 / 聚焦 ──
|
||||
QObject::connect(&detailCtrl, &geopro::controller::DatasetDetailController::datasetOpened,
|
||||
detailPanel, &geopro::app::DatasetDetailPanel::onDatasetOpened);
|
||||
QObject::connect(&detailCtrl, &geopro::controller::DatasetDetailController::tabReady,
|
||||
detailPanel, &geopro::app::DatasetDetailPanel::onTabReady);
|
||||
QObject::connect(&detailCtrl, &geopro::controller::DatasetDetailController::tabLoadStarted,
|
||||
detailPanel, &geopro::app::DatasetDetailPanel::onTabLoadStarted);
|
||||
QObject::connect(&detailCtrl, &geopro::controller::DatasetDetailController::focusRequested,
|
||||
detailPanel, &geopro::app::DatasetDetailPanel::focusDataset);
|
||||
// ── 页签懒加载:lazy 页签首次激活 → 控制器按 (dsId,ddCode,tabIndex) 拉载荷 → 回填 ──
|
||||
QObject::connect(detailPanel, &geopro::app::DatasetDetailPanel::tabNeeded, &detailCtrl,
|
||||
&geopro::controller::DatasetDetailController::loadTab);
|
||||
// ── 分页:分页器翻页/改每页条数 → 控制器按页加载 → 回填(同 tabReady 路径,刷新表格+分页器)──
|
||||
QObject::connect(detailPanel, &geopro::app::DatasetDetailPanel::tabPageNeeded, &detailCtrl,
|
||||
&geopro::controller::DatasetDetailController::loadTabPaged);
|
||||
// context 用 detailPanel:析构即自动断连,避免野指针。window 比 detailPanel 活得久,
|
||||
// 捕 &window 取状态栏安全。失败时清该页 lazy 遮罩(幂等)并状态栏提示。
|
||||
QObject::connect(&detailCtrl, &geopro::controller::DatasetDetailController::loadFailed, detailPanel,
|
||||
[&window, detailPanel](const QString& dsId, const QString& msg) {
|
||||
detailPanel->onLoadFailed(dsId, msg);
|
||||
window.statusBar()->showMessage(
|
||||
QStringLiteral("数据集 %1 加载失败:%2").arg(dsId, msg), 5000);
|
||||
});
|
||||
QObject::connect(actShowElectrodes, &QAbstractButton::toggled, detailWidget,
|
||||
[showElectrodes, rebuildDetail](bool on) {
|
||||
*showElectrodes = on;
|
||||
rebuildDetail();
|
||||
});
|
||||
QObject::connect(actShowContour, &QAbstractButton::toggled, detailWidget,
|
||||
[showContour, rebuildDetail](bool on) {
|
||||
*showContour = on;
|
||||
rebuildDetail();
|
||||
|
||||
// ── 详情面板切 Tab → 反向高亮数据集列表对应行 ──
|
||||
QObject::connect(detailPanel, &geopro::app::DatasetDetailPanel::activeDatasetChanged,
|
||||
datasetList, [datasetList](const QString& dsId) {
|
||||
for (QTreeWidgetItemIterator it(datasetList); *it; ++it)
|
||||
if ((*it)->data(0, geopro::app::kDsIdRole).toString() == dsId) {
|
||||
datasetList->setCurrentItem(*it);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// 「视图详情」浮层显隐:仅三维显示,置于 QVTK 左上(工具条下方)并置顶。
|
||||
|
|
@ -792,12 +605,11 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
|
|||
// ── 启动:建立一次空背景中央视图(真实 sections 数据由下一轮接入)。
|
||||
rebuildCentral();
|
||||
|
||||
// VTK 背景随主题切换:直接重跑 rebuildCentral/rebuildDetail(走完整渲染路径、末尾必 Render,
|
||||
// VTK 背景随主题切换:直接重跑 rebuildCentral(走完整渲染路径、末尾必 Render,
|
||||
// 比手动 SetBackground+Render 稳;兼顾 syncSystemTheme 异步切暗的时序)。
|
||||
QObject::connect(&geopro::app::ThemeManager::instance(), &geopro::app::ThemeManager::changed, &window,
|
||||
[rebuildCentral, rebuildDetail]() {
|
||||
[rebuildCentral]() {
|
||||
rebuildCentral();
|
||||
rebuildDetail();
|
||||
});
|
||||
|
||||
// 顶部应用区(静态视觉壳,对齐原型):上=菜单栏(视图/项目管理/业务工具/设备),
|
||||
|
|
@ -830,6 +642,25 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
|
|||
}
|
||||
return loaded;
|
||||
};
|
||||
// 数据树的「加载更多」:末尾顶层项;已加载数=树中非"加载更多"项总数(含各层子节点)。
|
||||
auto removeTreeLoadMore = [](QTreeWidget* tw) {
|
||||
const int n = tw->topLevelItemCount();
|
||||
if (n > 0 && tw->topLevelItem(n - 1)->data(0, geopro::app::kDsLoadMoreRole).toBool())
|
||||
delete tw->takeTopLevelItem(n - 1);
|
||||
};
|
||||
// total = 根节点总数(控制器按根分页);loaded 也按「第一层节点(根)」计 → 加载更多/页签数一致。
|
||||
auto addTreeLoadMore = [](QTreeWidget* tw, int total) {
|
||||
int loaded = 0;
|
||||
for (int i = 0; i < tw->topLevelItemCount(); ++i)
|
||||
if (!tw->topLevelItem(i)->data(0, geopro::app::kDsLoadMoreRole).toBool()) ++loaded;
|
||||
if (loaded < total) {
|
||||
auto* m = new QTreeWidgetItem(tw);
|
||||
m->setText(0, QStringLiteral("加载更多(%1/%2)").arg(loaded).arg(total));
|
||||
m->setData(0, geopro::app::kDsLoadMoreRole, true);
|
||||
m->setTextAlignment(0, Qt::AlignCenter);
|
||||
}
|
||||
return loaded;
|
||||
};
|
||||
QObject::connect(topBar, &geopro::app::TopBar::workspaceSwitchRequested, &nav,
|
||||
&geopro::controller::WorkbenchNavController::switchWorkspace);
|
||||
QObject::connect(topBar, &geopro::app::TopBar::projectSwitchRequested, &nav,
|
||||
|
|
@ -906,12 +737,12 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
|
|||
datasetTabs->setTabText(1, QStringLiteral("文件"));
|
||||
});
|
||||
QObject::connect(&nav, &geopro::controller::WorkbenchNavController::datasetsLoaded, datasetList,
|
||||
[removeLoadMore, addLoadMore, datasetList, datasetTitle, datasetTabs](
|
||||
[removeTreeLoadMore, addTreeLoadMore, datasetList, datasetTitle, datasetTabs](
|
||||
const QString&, const std::vector<geopro::data::DsRow>& rows, int total,
|
||||
bool append) {
|
||||
removeLoadMore(datasetList);
|
||||
removeTreeLoadMore(datasetList);
|
||||
geopro::app::populateDatasetList(datasetList, rows, append);
|
||||
const int loaded = addLoadMore(datasetList, total);
|
||||
const int loaded = addTreeLoadMore(datasetList, total);
|
||||
if (datasetTitle) datasetTitle->setText(QStringLiteral("数据集"));
|
||||
datasetTabs->setTabText(
|
||||
0, total > 0 ? QStringLiteral("数据 (%1/%2)").arg(loaded).arg(total)
|
||||
|
|
@ -984,8 +815,40 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
|
|||
|
||||
} // namespace
|
||||
|
||||
namespace {
|
||||
// 顶层异常护栏:任何 slot / 事件处理器抛出的 C++ 异常都会经过 QApplication::notify。
|
||||
// 默认 Qt 不允许异常穿透事件循环 → terminate(崩溃)。这里拦截 + 记录(异常信息 + 接收者
|
||||
// 对象类名 + 事件类型,足以定位崩点),并吞掉以**保证后端故障等异常不致整个客户端退出**。
|
||||
// 注:吞异常后该次事件处理中断,可能留下局部不一致;但"不崩 + 有日志"优先于直接退出。
|
||||
class GuardedApplication : public QApplication {
|
||||
public:
|
||||
using QApplication::QApplication;
|
||||
bool notify(QObject* receiver, QEvent* e) override {
|
||||
try {
|
||||
return QApplication::notify(receiver, e);
|
||||
} catch (const std::exception& ex) {
|
||||
qCritical("[guard] 拦截未捕获异常: %s | type=%s | receiver=%s | event=%d",
|
||||
ex.what(), typeid(ex).name(),
|
||||
receiver ? receiver->metaObject()->className() : "null",
|
||||
e ? static_cast<int>(e->type()) : 0);
|
||||
} catch (...) {
|
||||
qCritical("[guard] 拦截未捕获非 std 异常 | receiver=%s | event=%d",
|
||||
receiver ? receiver->metaObject()->className() : "null",
|
||||
e ? static_cast<int>(e->type()) : 0);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char* argv[])
|
||||
{
|
||||
// Qt WebEngine(地图页签的 QWebEngineView):必须在 QApplication 构造前初始化,
|
||||
// 且需启用跨上下文共享 OpenGL(QtWebEngine 与 QVTK 同进程共用 GL context,避免黑屏/崩溃)。
|
||||
// AA_ShareOpenGLContexts 须在 QApplication 之前设置;QtWebEngineQuick::initialize() 同样须前置。
|
||||
QCoreApplication::setAttribute(Qt::AA_ShareOpenGLContexts);
|
||||
QtWebEngineQuick::initialize();
|
||||
|
||||
// 高 DPI 缩放采用直通策略:在 125%/150% 等分数缩放下字体/图标按真实比例渲染,更清晰。
|
||||
// 必须在 QApplication 构造前设置。
|
||||
QApplication::setHighDpiScaleFactorRoundingPolicy(
|
||||
|
|
@ -993,12 +856,19 @@ int main(int argc, char* argv[])
|
|||
|
||||
// QVTK 默认 surface format 必须在 QApplication 之前设置(全局一次,两个 QVTK widget 共用)。
|
||||
QSurfaceFormat::setDefaultFormat(QVTKOpenGLStereoWidget::defaultFormat());
|
||||
QApplication app(argc, argv);
|
||||
GuardedApplication app(argc, argv); // 顶层异常护栏:slot/事件里的异常不致客户端崩溃
|
||||
|
||||
// 异步 ApiCall::finished 等信号携带 ApiResponse;注册元类型以支持跨 QueuedConnection 传递
|
||||
// (当前详情链路为同线程 DirectConnection,非严格必需,但作防御性注册,见 spec §5.1)。
|
||||
qRegisterMetaType<geopro::net::ApiResponse>();
|
||||
|
||||
// 组织/应用名:QSettings 持久化(dock 布局、登录记忆等)按此定位存储位置。
|
||||
QCoreApplication::setOrganizationName(QStringLiteral("Geomative"));
|
||||
QCoreApplication::setApplicationName(QStringLiteral("Geopro3"));
|
||||
|
||||
// 日志 + 崩溃捕获:尽早安装(依赖上面的 Org/App 名定位日志目录)。生产桌面端问题可回溯。
|
||||
geopro::app::initLogging();
|
||||
|
||||
// 主题与字号:应用持久化偏好(跟随系统/浅/深 + 字号缩放),再套用 Fusion + 全局 QSS + 调色板。
|
||||
// 在弹登录窗之前完成 → 登录页与主页 明暗/字号 统一。
|
||||
geopro::app::applyPersistedThemeMode();
|
||||
|
|
@ -1050,6 +920,17 @@ int main(int argc, char* argv[])
|
|||
geopro::data::ApiProjectRepository projectRepo(api);
|
||||
geopro::controller::WorkbenchNavController nav(projectRepo);
|
||||
|
||||
// 数据详情仓储 + 控制器(接真实反演 API):同一共享会话 ApiClient。
|
||||
geopro::data::ApiDatasetRepository datasetRepo(api);
|
||||
// 策略注册表须比 detailCtrl 活得久(同作用域、在前声明)。
|
||||
geopro::controller::ChartStrategyRegistry chartRegistry;
|
||||
chartRegistry.add(std::make_unique<geopro::app::ErtInversionStrategy>());
|
||||
chartRegistry.add(std::make_unique<geopro::app::MeasurementStrategy>());
|
||||
chartRegistry.add(std::make_unique<geopro::app::GrMeasurementStrategy>());
|
||||
chartRegistry.add(std::make_unique<geopro::app::TrajectoryStrategy>());
|
||||
chartRegistry.add(std::make_unique<geopro::app::GridStrategy>());
|
||||
geopro::controller::DatasetDetailController detailCtrl(datasetRepo, chartRegistry);
|
||||
|
||||
// ── 外壳:标准 QMainWindow(原生标题栏)。buildWorkbench 直接用其
|
||||
// setCentralWidget/setMenuWidget/statusBar 承载工作台。
|
||||
const QString kTitle = QStringLiteral("Geopro 3.0 — 项目分析视图 (M1)");
|
||||
|
|
@ -1057,7 +938,7 @@ int main(int argc, char* argv[])
|
|||
window->setWindowTitle(kTitle);
|
||||
window->resize(1280, 800);
|
||||
window->setMinimumSize(1024, 680);
|
||||
buildWorkbench(*window, repo, projectRepo, nav);
|
||||
buildWorkbench(*window, repo, projectRepo, nav, detailCtrl);
|
||||
|
||||
// 主题桥:ThemeManager 明/暗切换 → 重应用全局 QSS+调色板(标准控件 + ADS;内联 chrome 经各自连接)。
|
||||
QObject::connect(&geopro::app::ThemeManager::instance(), &geopro::app::ThemeManager::changed,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
#include "panels/AnomalyTablePanel.hpp"
|
||||
#include <QVBoxLayout>
|
||||
#include <QTableWidget>
|
||||
#include <QHeaderView>
|
||||
#include <QToolButton>
|
||||
namespace geopro::app {
|
||||
static QString markName(int t) { return t == 1 ? "点" : t == 3 ? "多边形" : "多段线"; }
|
||||
|
||||
AnomalyTablePanel::AnomalyTablePanel(QWidget* parent) : QWidget(parent) {
|
||||
auto* lay = new QVBoxLayout(this); lay->setContentsMargins(0, 0, 0, 0);
|
||||
table_ = new QTableWidget(this);
|
||||
table_->setColumnCount(6);
|
||||
table_->setHorizontalHeaderLabels({"名称", "异常类型", "几何类型", "创建时间", "备注", "操作"});
|
||||
table_->horizontalHeader()->setStretchLastSection(true);
|
||||
table_->setEditTriggers(QAbstractItemView::NoEditTriggers);
|
||||
lay->addWidget(table_);
|
||||
}
|
||||
|
||||
void AnomalyTablePanel::setAnomalies(const std::vector<geopro::core::Anomaly>& list,
|
||||
const std::vector<QString>& createTimes,
|
||||
const std::vector<QString>& remarks) {
|
||||
hidden_.clear();
|
||||
table_->setRowCount(static_cast<int>(list.size()));
|
||||
for (int i = 0; i < static_cast<int>(list.size()); ++i) {
|
||||
const auto& a = list[i];
|
||||
table_->setItem(i, 0, new QTableWidgetItem(QString::fromStdString(a.name)));
|
||||
table_->setItem(i, 1, new QTableWidgetItem(QString::fromStdString(a.typeName)));
|
||||
table_->setItem(i, 2, new QTableWidgetItem(markName(static_cast<int>(a.markType))));
|
||||
table_->setItem(i, 3, new QTableWidgetItem(i < (int)createTimes.size() ? createTimes[i] : ""));
|
||||
table_->setItem(i, 4, new QTableWidgetItem(i < (int)remarks.size() ? remarks[i] : ""));
|
||||
auto* eye = new QToolButton(table_); eye->setCheckable(true); eye->setChecked(true);
|
||||
eye->setText("👁");
|
||||
connect(eye, &QToolButton::toggled, this, [this, i](bool on) {
|
||||
if (on) hidden_.erase(i); else hidden_.insert(i);
|
||||
emit hiddenChanged(hidden_);
|
||||
});
|
||||
table_->setCellWidget(i, 5, eye);
|
||||
}
|
||||
}
|
||||
} // namespace geopro::app
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
#pragma once
|
||||
#include <set>
|
||||
#include <vector>
|
||||
#include <QWidget>
|
||||
#include "model/Anomaly.hpp"
|
||||
class QTableWidget;
|
||||
namespace geopro::app {
|
||||
|
||||
// ds 级异常表:名称/异常类型/几何类型/创建时间/备注/操作(显隐眼睛)。行显隐 → 信号驱动图表叠加。
|
||||
class AnomalyTablePanel : public QWidget {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit AnomalyTablePanel(QWidget* parent = nullptr);
|
||||
void setAnomalies(const std::vector<geopro::core::Anomaly>& list,
|
||||
const std::vector<QString>& createTimes,
|
||||
const std::vector<QString>& remarks);
|
||||
signals:
|
||||
void hiddenChanged(const std::set<int>& hiddenIndices);
|
||||
private:
|
||||
QTableWidget* table_;
|
||||
std::set<int> hidden_;
|
||||
};
|
||||
} // namespace geopro::app
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
#include "panels/DatasetDetailPage.hpp"
|
||||
|
||||
#include <QButtonGroup>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
#include "Glyphs.hpp"
|
||||
#include "PanelHeader.hpp"
|
||||
#include "panels/LoadingOverlay.hpp"
|
||||
#include "panels/chart/DataTableView.hpp"
|
||||
#include "panels/chart/DetailViewFactory.hpp"
|
||||
#include "panels/chart/IDetailView.hpp"
|
||||
|
||||
namespace geopro::app {
|
||||
|
||||
DatasetDetailPage::DatasetDetailPage(QWidget* parent) : QWidget(parent) {
|
||||
auto* lay = new QVBoxLayout(this);
|
||||
lay->setContentsMargins(0, 0, 0, 0);
|
||||
lay->setSpacing(0);
|
||||
}
|
||||
|
||||
void DatasetDetailPage::build(const QString& dsId, const QString& ddCode, const QString& dsName,
|
||||
const std::vector<geopro::controller::TabSpec>& tabs) {
|
||||
Q_ASSERT(views_.empty()); // build() 仅一次:views_ 为已 release 的裸指针,二次构建会泄漏旧视图
|
||||
if (!views_.empty()) return;
|
||||
dsId_ = dsId;
|
||||
ddCode_ = ddCode;
|
||||
dsName_ = dsName;
|
||||
tabs_ = tabs;
|
||||
views_.assign(tabs.size(), nullptr);
|
||||
loaded_.assign(tabs.size(), false);
|
||||
requested_.assign(tabs.size(), false);
|
||||
overlays_.clear();
|
||||
|
||||
// 按 TabSpec 经工厂造视图,并组装为带表头页签的面板。
|
||||
QVector<PanelTab> panelTabs;
|
||||
for (size_t i = 0; i < tabs.size(); ++i) {
|
||||
const auto& spec = tabs[i];
|
||||
auto view = makeDetailView(spec.kind, this); // 抛出由调用栈兜底(GuardedApplication)
|
||||
IDetailView* raw = view.release(); // QWidget 由 this/QwtPlot 父子树接管生命周期
|
||||
views_[i] = raw;
|
||||
// lazy 页签:建覆盖该视图的加载遮罩(父为视图 widget,随其尺寸覆盖图区)。
|
||||
if (spec.lazy) overlays_[static_cast<int>(i)] = new LoadingOverlay(raw->widget());
|
||||
// 分页型页签:把表格视图的分页请求冒泡为页信号(携带 dsId/ddCode/tabIndex + 页参数)。
|
||||
if (spec.paginated) {
|
||||
if (auto* table = qobject_cast<DataTableView*>(raw->widget())) {
|
||||
const int idx = static_cast<int>(i);
|
||||
connect(table, &DataTableView::pageRequested, this,
|
||||
[this, idx](int pageNo, int pageSize) {
|
||||
emit tabPageNeeded(dsId_, ddCode_, idx, pageNo, pageSize);
|
||||
});
|
||||
}
|
||||
}
|
||||
panelTabs.append({Glyph::Detail, spec.title, raw->widget(), false});
|
||||
}
|
||||
const QVector<HeaderAction> actions = {
|
||||
{Glyph::Download, QStringLiteral("导出")},
|
||||
};
|
||||
|
||||
auto tabbedPanel = buildTabbedPanel(panelTabs, actions);
|
||||
layout()->addWidget(tabbedPanel.container);
|
||||
|
||||
// lazy 页签首次激活 → 发 tabNeeded 请求懒加载(数据慢,故不随开页同步拉)。
|
||||
if (tabbedPanel.tabGroup) {
|
||||
connect(tabbedPanel.tabGroup, &QButtonGroup::idClicked, this, [this](int idx) {
|
||||
if (idx < 0 || idx >= static_cast<int>(tabs_.size())) return;
|
||||
if (!tabs_[static_cast<size_t>(idx)].lazy) return;
|
||||
if (requested_[static_cast<size_t>(idx)] || loaded_[static_cast<size_t>(idx)]) return;
|
||||
if (dsId_.isEmpty()) return;
|
||||
requested_[static_cast<size_t>(idx)] = true;
|
||||
emit tabNeeded(dsId_, ddCode_, idx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void DatasetDetailPage::setTabPayload(int tabIndex, const QVariant& payload) {
|
||||
if (tabIndex < 0 || tabIndex >= static_cast<int>(views_.size())) return;
|
||||
if (auto* v = views_[static_cast<size_t>(tabIndex)]) v->setPayload(payload);
|
||||
loaded_[static_cast<size_t>(tabIndex)] = true;
|
||||
requested_[static_cast<size_t>(tabIndex)] = true; // 已加载,切回不再重复请求
|
||||
setTabLoading(tabIndex, false); // 数据到达,隐藏遮罩
|
||||
}
|
||||
|
||||
void DatasetDetailPage::setTabLoading(int tabIndex, bool on) {
|
||||
auto it = overlays_.find(tabIndex);
|
||||
if (it == overlays_.end()) return; // 非 lazy 页签无遮罩
|
||||
if (on) it.value()->showOver(); else it.value()->hide();
|
||||
}
|
||||
|
||||
void DatasetDetailPage::clearAllLoadingOverlays() {
|
||||
for (auto* overlay : overlays_)
|
||||
if (overlay) overlay->hide();
|
||||
}
|
||||
|
||||
} // namespace geopro::app
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
#pragma once
|
||||
#include <vector>
|
||||
#include <QMap>
|
||||
#include <QVariant>
|
||||
#include <QWidget>
|
||||
#include "DatasetDetailTab.hpp" // geopro::controller::TabSpec
|
||||
|
||||
namespace geopro::app {
|
||||
|
||||
class IDetailView;
|
||||
class LoadingOverlay;
|
||||
|
||||
// 单个数据集详情页:按策略 tabs() 动态建页签 + 右侧「导出」操作。
|
||||
// 每页签由工厂造的 IDetailView 承载;lazy 页签首次激活时发 tabNeeded 请求懒加载。
|
||||
class DatasetDetailPage : public QWidget {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit DatasetDetailPage(QWidget* parent = nullptr);
|
||||
|
||||
// 按页签集构建页签(首次打开调一次)。dsId/ddCode/dsName 用于 tabNeeded。
|
||||
void build(const QString& dsId, const QString& ddCode, const QString& dsName,
|
||||
const std::vector<geopro::controller::TabSpec>& tabs);
|
||||
|
||||
// 页签载荷到达 → 下发给对应视图并标记已加载、隐藏遮罩。
|
||||
void setTabPayload(int tabIndex, const QVariant& payload);
|
||||
// 页签加载进行中 → 对 lazy 页签显示遮罩(非 lazy 页签无遮罩,幂等忽略)。
|
||||
void setTabLoading(int tabIndex, bool on);
|
||||
// 清掉本页全部加载遮罩(失败兜底用,不假设页签数;幂等)。
|
||||
void clearAllLoadingOverlays();
|
||||
|
||||
QString dsId() const { return dsId_; }
|
||||
int tabCount() const { return static_cast<int>(tabs_.size()); }
|
||||
|
||||
signals:
|
||||
// lazy 页签首次激活且未加载 → 请求懒加载。
|
||||
void tabNeeded(const QString& dsId, const QString& ddCode, int tabIndex);
|
||||
// 分页型页签(paginated)分页器翻页/改每页条数 → 请求按页加载。
|
||||
void tabPageNeeded(const QString& dsId, const QString& ddCode, int tabIndex, int pageNo,
|
||||
int pageSize);
|
||||
|
||||
private:
|
||||
QString dsId_;
|
||||
QString ddCode_;
|
||||
QString dsName_;
|
||||
std::vector<geopro::controller::TabSpec> tabs_;
|
||||
// 与 tabs_ 同序。每个 IDetailView 持有的 QWidget 经 build() 以 this 为父接管,
|
||||
// 生命周期由 Qt 父子树清理(不在此 delete);build() 仅调用一次(见其断言)。
|
||||
std::vector<IDetailView*> views_;
|
||||
std::vector<bool> loaded_; // 各页签是否已加载(避免重复请求)
|
||||
std::vector<bool> requested_; // lazy 页签是否已请求过
|
||||
QMap<int, LoadingOverlay*> overlays_; // lazy 页签的加载遮罩(覆盖该视图)
|
||||
};
|
||||
|
||||
} // namespace geopro::app
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
#include "panels/DatasetDetailPanel.hpp"
|
||||
#include "panels/DatasetDetailPage.hpp"
|
||||
namespace geopro::app {
|
||||
|
||||
DatasetDetailPanel::DatasetDetailPanel(QWidget* parent) : QTabWidget(parent) {
|
||||
setTabsClosable(true);
|
||||
connect(this, &QTabWidget::tabCloseRequested, this, [this](int i) { widget(i)->deleteLater(); });
|
||||
connect(this, &QTabWidget::currentChanged, this, [this](int i) {
|
||||
if (auto* p = qobject_cast<DatasetDetailPage*>(widget(i)))
|
||||
emit activeDatasetChanged(p->dsId());
|
||||
});
|
||||
}
|
||||
|
||||
DatasetDetailPage* DatasetDetailPanel::pageFor(const QString& dsId) const {
|
||||
for (int i = 0; i < count(); ++i)
|
||||
if (auto* p = qobject_cast<DatasetDetailPage*>(widget(i)))
|
||||
if (p->dsId() == dsId) return p;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void DatasetDetailPanel::onDatasetOpened(const QString& dsId, const QString& ddCode,
|
||||
const QString& dsName,
|
||||
const std::vector<geopro::controller::TabSpec>& tabs) {
|
||||
auto* p = pageFor(dsId);
|
||||
if (!p) {
|
||||
p = new DatasetDetailPage(this);
|
||||
p->build(dsId, ddCode, dsName, tabs); // ddCode 透传 → 页内 tabNeeded 携带
|
||||
const QString title = dsName.isEmpty() ? dsId : dsName; // 页签标题用数据名(空则回退 id)
|
||||
const int idx = addTab(p, title);
|
||||
setTabToolTip(idx, title); // 名称过长被省略时悬停可见全名
|
||||
// 页内 lazy 页签首次激活 → 冒泡为面板信号(外部接控制器 loadTab)。
|
||||
connect(p, &DatasetDetailPage::tabNeeded, this, &DatasetDetailPanel::tabNeeded);
|
||||
// 页内分页器翻页 → 冒泡为面板信号(外部接控制器 loadTabPaged)。
|
||||
connect(p, &DatasetDetailPage::tabPageNeeded, this, &DatasetDetailPanel::tabPageNeeded);
|
||||
}
|
||||
setCurrentWidget(p);
|
||||
}
|
||||
|
||||
void DatasetDetailPanel::onTabReady(const QString& dsId, int tabIndex, const QVariant& payload) {
|
||||
if (auto* p = pageFor(dsId)) p->setTabPayload(tabIndex, payload);
|
||||
}
|
||||
|
||||
void DatasetDetailPanel::onTabLoadStarted(const QString& dsId, int tabIndex) {
|
||||
if (auto* p = pageFor(dsId)) p->setTabLoading(tabIndex, true);
|
||||
}
|
||||
|
||||
void DatasetDetailPanel::onLoadFailed(const QString& dsId, const QString&) {
|
||||
// 失败:清掉该页全部「加载中」遮罩(不假设页签数;幂等:非 lazy/已隐藏皆无害)。
|
||||
if (auto* p = pageFor(dsId)) p->clearAllLoadingOverlays();
|
||||
}
|
||||
|
||||
void DatasetDetailPanel::focusDataset(const QString& dsId) {
|
||||
if (auto* p = pageFor(dsId)) setCurrentWidget(p);
|
||||
}
|
||||
} // namespace geopro::app
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
#pragma once
|
||||
#include <vector>
|
||||
#include <QTabWidget>
|
||||
#include <QVariant>
|
||||
#include "DatasetDetailTab.hpp" // geopro::controller::TabSpec
|
||||
namespace geopro::app {
|
||||
class DatasetDetailPage;
|
||||
|
||||
// 多 Tab 壳:每数据集一页(按 dsId 去重)。R095。tab 引擎版。
|
||||
class DatasetDetailPanel : public QTabWidget {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit DatasetDetailPanel(QWidget* parent = nullptr);
|
||||
|
||||
// 数据集打开:find-or-create 页 → build(tabs) → 加/抬该面板页签。
|
||||
void onDatasetOpened(const QString& dsId, const QString& ddCode, const QString& dsName,
|
||||
const std::vector<geopro::controller::TabSpec>& tabs);
|
||||
void onTabReady(const QString& dsId, int tabIndex, const QVariant& payload);
|
||||
void onTabLoadStarted(const QString& dsId, int tabIndex);
|
||||
void onLoadFailed(const QString& dsId, const QString& message);
|
||||
void focusDataset(const QString& dsId); // 单击聚焦已开页
|
||||
|
||||
signals:
|
||||
void activeDatasetChanged(const QString& dsId); // 反向联动数据集列表
|
||||
void tabNeeded(const QString& dsId, const QString& ddCode, int tabIndex); // lazy 页首激活 → 懒加载
|
||||
// 分页型页签分页器翻页 → 按页加载(外部接控制器 loadTabPaged)。
|
||||
void tabPageNeeded(const QString& dsId, const QString& ddCode, int tabIndex, int pageNo,
|
||||
int pageSize);
|
||||
|
||||
private:
|
||||
DatasetDetailPage* pageFor(const QString& dsId) const;
|
||||
};
|
||||
} // namespace geopro::app
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
#include "panels/DatasetListPanel.hpp"
|
||||
|
||||
#include <QAbstractItemView>
|
||||
#include <QColor>
|
||||
#include <QHash>
|
||||
#include <QListWidget>
|
||||
#include <QListWidgetItem>
|
||||
#include <QObject>
|
||||
|
|
@ -8,6 +10,9 @@
|
|||
#include <QPainterPath>
|
||||
#include <QString>
|
||||
#include <QStyledItemDelegate>
|
||||
#include <QTreeWidget>
|
||||
#include <QTreeWidgetItem>
|
||||
#include <QTreeWidgetItemIterator>
|
||||
|
||||
#include "Theme.hpp"
|
||||
|
||||
|
|
@ -104,19 +109,53 @@ public:
|
|||
};
|
||||
} // namespace
|
||||
|
||||
void populateDatasetList(QListWidget* list, const std::vector<geopro::data::DsRow>& rows, bool append) {
|
||||
if (!list) return;
|
||||
if (!append) list->clear();
|
||||
for (const auto& d : rows) {
|
||||
namespace {
|
||||
// 建一条数据集树项(不挂载):列0 文本 = dsName +「创建时间 · 类型名」,data 存各角色。
|
||||
QTreeWidgetItem* makeDatasetItem(const geopro::data::DsRow& d) {
|
||||
QString text = QString::fromStdString(d.dsName);
|
||||
QString sub = QString::fromStdString(d.createTime); // 名称下先创建时间
|
||||
if (!d.typeName.empty())
|
||||
sub += QStringLiteral(" · %1").arg(QString::fromStdString(d.typeName)); // 再跟类型
|
||||
if (!sub.isEmpty()) text += QStringLiteral("\n%1").arg(sub);
|
||||
auto* item = new QListWidgetItem(text, list);
|
||||
item->setData(kDsIdRole, QString::fromStdString(d.id));
|
||||
item->setData(kDsDdTypeRole, QString::fromStdString(d.ddCode));
|
||||
auto* item = new QTreeWidgetItem();
|
||||
item->setText(0, text);
|
||||
item->setData(0, kDsIdRole, QString::fromStdString(d.id));
|
||||
item->setData(0, kDsDdTypeRole, QString::fromStdString(d.ddCode));
|
||||
item->setData(0, kDsDdCodeRole, QString::fromStdString(d.ddCode));
|
||||
item->setData(0, kDsNameRole, QString::fromStdString(d.dsName));
|
||||
return item;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void populateDatasetList(QTreeWidget* tree, const std::vector<geopro::data::DsRow>& rows, bool append) {
|
||||
if (!tree) return;
|
||||
if (!append) tree->clear();
|
||||
|
||||
// id→已在树中的项:!append 时为空;append(分页)时含已加载行,使新行能挂到既有父下。
|
||||
QHash<QString, QTreeWidgetItem*> byId;
|
||||
for (QTreeWidgetItemIterator it(tree); *it; ++it) {
|
||||
const QString id = (*it)->data(0, kDsIdRole).toString();
|
||||
if (!id.isEmpty()) byId.insert(id, *it);
|
||||
}
|
||||
|
||||
// 第一遍:本批全部建项(不挂载)并登记 id,使同批内的父子也能互相找到。
|
||||
std::vector<QTreeWidgetItem*> batch;
|
||||
batch.reserve(rows.size());
|
||||
for (const auto& d : rows) {
|
||||
auto* item = makeDatasetItem(d);
|
||||
byId.insert(QString::fromStdString(d.id), item);
|
||||
batch.push_back(item);
|
||||
}
|
||||
// 第二遍:按 parentId 挂载。父在集合内→作其子;否则(父是源文件节点/不在本批)→作树根。
|
||||
for (std::size_t i = 0; i < rows.size(); ++i) {
|
||||
const QString pid = QString::fromStdString(rows[i].parentId);
|
||||
QTreeWidgetItem* parent = pid.isEmpty() ? nullptr : byId.value(pid, nullptr);
|
||||
if (parent && parent != batch[i])
|
||||
parent->addChild(batch[i]);
|
||||
else
|
||||
tree->addTopLevelItem(batch[i]);
|
||||
}
|
||||
// 默认折叠(对齐原版:仅显源数据根行,派生数据收在展开箭头内)。
|
||||
}
|
||||
|
||||
void populateFileList(QListWidget* list, const std::vector<geopro::data::DsRow>& rows, bool append) {
|
||||
|
|
@ -140,13 +179,13 @@ void populateFileList(QListWidget* list, const std::vector<geopro::data::DsRow>&
|
|||
}
|
||||
}
|
||||
|
||||
void applyDatasetCardDelegate(QListWidget* list) {
|
||||
if (!list) return;
|
||||
list->setItemDelegate(new DatasetCardDelegate(list));
|
||||
list->setMouseTracking(true); // 让委托收到 hover 状态
|
||||
list->setSpacing(0); // 卡间距由委托内边距控制
|
||||
QObject::connect(&ThemeManager::instance(), &ThemeManager::changed, list,
|
||||
[list]() { list->viewport()->update(); });
|
||||
void applyDatasetCardDelegate(QAbstractItemView* view) {
|
||||
if (!view) return;
|
||||
view->setItemDelegate(new DatasetCardDelegate(view));
|
||||
view->setMouseTracking(true); // 让委托收到 hover 状态
|
||||
if (auto* list = qobject_cast<QListWidget*>(view)) list->setSpacing(0); // 卡间距由委托内边距控制
|
||||
QObject::connect(&ThemeManager::instance(), &ThemeManager::changed, view,
|
||||
[view]() { view->viewport()->update(); });
|
||||
}
|
||||
|
||||
} // namespace geopro::app
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@
|
|||
#include "repo/RepoTypes.hpp"
|
||||
|
||||
class QListWidget;
|
||||
class QTreeWidget;
|
||||
class QAbstractItemView;
|
||||
|
||||
namespace geopro::app {
|
||||
|
||||
|
|
@ -12,13 +14,18 @@ constexpr int kDsIdRole = 0x0100; // Qt::UserRole
|
|||
constexpr int kDsDdTypeRole = 0x0101; // Qt::UserRole + 1
|
||||
constexpr int kDsFileUrlRole = 0x0102; // Qt::UserRole + 2(文件下载 url,备用)
|
||||
constexpr int kDsLoadMoreRole = 0x0103; // 标记"加载更多"行
|
||||
constexpr int kDsDdCodeRole = 0x0104; // Qt::UserRole + 4(ddCode,双击详情选策略用)
|
||||
constexpr int kDsNameRole = 0x0105; // Qt::UserRole + 5(dsName,详情页签标题用)
|
||||
|
||||
// 数据页签:每条 = dsName +(类型名);UserRole 存 dsId、+1 存 ddCode。
|
||||
void populateDatasetList(QListWidget* list, const std::vector<geopro::data::DsRow>& rows, bool append);
|
||||
// 数据页签:树形(按 DsRow.parentId 嵌套,源数据为根、派生数据挂其下,对齐原版 el-table 树)。
|
||||
// 每项列0:文本 = dsName +「创建时间 · 类型名」;data(0,角色) 存 dsId/ddCode/dsName。
|
||||
// append=true 时把新行挂到已加载的父节点下(分页)。
|
||||
void populateDatasetList(QTreeWidget* tree, const std::vector<geopro::data::DsRow>& rows, bool append);
|
||||
// 文件页签:每条 = 文件名 +(可读大小);UserRole 存 dsId、+2 存文件 url。空时显示占位。
|
||||
void populateFileList(QListWidget* list, const std::vector<geopro::data::DsRow>& rows, bool append);
|
||||
|
||||
// 给数据/文件列表套用卡片委托(标题+元信息双行、悬停/选中圆角高亮+左强调竖条,规范§6.2)。
|
||||
void applyDatasetCardDelegate(QListWidget* list);
|
||||
// 接受 QListWidget(文件)或 QTreeWidget(数据树)——故形参为其共同基类 QAbstractItemView。
|
||||
void applyDatasetCardDelegate(QAbstractItemView* view);
|
||||
|
||||
} // namespace geopro::app
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
#include "panels/DescriptionPanel.hpp"
|
||||
|
||||
#include <QTextEdit>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
namespace geopro::app {
|
||||
|
||||
DescriptionPanel::DescriptionPanel(QWidget* parent) : QWidget(parent) {
|
||||
auto* lay = new QVBoxLayout(this);
|
||||
lay->setContentsMargins(8, 8, 8, 8);
|
||||
|
||||
edit_ = new QTextEdit(this);
|
||||
edit_->setReadOnly(true);
|
||||
edit_->setPlaceholderText(QStringLiteral("暂无描述"));
|
||||
lay->addWidget(edit_);
|
||||
}
|
||||
|
||||
void DescriptionPanel::setText(const QString& text) {
|
||||
edit_->setPlainText(text);
|
||||
}
|
||||
|
||||
} // namespace geopro::app
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
#pragma once
|
||||
#include <QWidget>
|
||||
|
||||
class QTextEdit;
|
||||
|
||||
namespace geopro::app {
|
||||
|
||||
// 数据集描述面板:只读文本,供网格数据底部页签「描述」使用。
|
||||
class DescriptionPanel : public QWidget {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit DescriptionPanel(QWidget* parent = nullptr);
|
||||
void setText(const QString& text);
|
||||
private:
|
||||
QTextEdit* edit_;
|
||||
};
|
||||
|
||||
} // namespace geopro::app
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
#include "panels/LoadingOverlay.hpp"
|
||||
#include <QColor>
|
||||
#include <QEvent>
|
||||
#include <QLabel>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
#include "Theme.hpp"
|
||||
|
||||
namespace geopro::app {
|
||||
|
||||
LoadingOverlay::LoadingOverlay(QWidget* parent) : QWidget(parent), label_(new QLabel(this)) {
|
||||
Q_ASSERT(parent); // 契约:必须有父(遮罩几何跟随父,无父无法工作)
|
||||
setAttribute(Qt::WA_StyledBackground, true);
|
||||
label_->setText(QStringLiteral("加载中…"));
|
||||
label_->setAlignment(Qt::AlignCenter);
|
||||
auto* lay = new QVBoxLayout(this);
|
||||
lay->addWidget(label_);
|
||||
if (parent) parent->installEventFilter(this);
|
||||
|
||||
// 半透明遮罩跟随主题:浅色白纱深字(原版),暗色深纱浅字,避免暗色下刺眼白蒙板。
|
||||
const auto applyTheme = [this]() {
|
||||
const QColor veil = isDarkTheme() ? tokenColor("bg/app") : QColor(255, 255, 255);
|
||||
setStyleSheet(QStringLiteral("background: rgba(%1,%2,%3,160);")
|
||||
.arg(veil.red()).arg(veil.green()).arg(veil.blue()));
|
||||
label_->setStyleSheet(QStringLiteral("background: transparent; color: %1;")
|
||||
.arg(tokenColor("text/primary").name()));
|
||||
};
|
||||
applyTheme();
|
||||
connect(&ThemeManager::instance(), &ThemeManager::changed, this, applyTheme);
|
||||
hide();
|
||||
}
|
||||
|
||||
void LoadingOverlay::showOver() {
|
||||
if (parentWidget()) setGeometry(parentWidget()->rect());
|
||||
raise();
|
||||
show();
|
||||
}
|
||||
|
||||
bool LoadingOverlay::eventFilter(QObject* obj, QEvent* ev) {
|
||||
if (obj == parentWidget() && ev->type() == QEvent::Resize && isVisible()) {
|
||||
setGeometry(parentWidget()->rect());
|
||||
}
|
||||
return QWidget::eventFilter(obj, ev);
|
||||
}
|
||||
|
||||
} // namespace geopro::app
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
#pragma once
|
||||
#include <QWidget>
|
||||
class QLabel;
|
||||
namespace geopro::app {
|
||||
|
||||
// 半透明「加载中…」遮罩。贴在目标视图上层,showOver()/hide() 切换,几何随父 resize 跟随。
|
||||
class LoadingOverlay : public QWidget {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit LoadingOverlay(QWidget* parent);
|
||||
void showOver(); // 铺满父尺寸、置顶、显示
|
||||
protected:
|
||||
bool eventFilter(QObject* obj, QEvent* ev) override; // 跟随父 resize
|
||||
private:
|
||||
QLabel* label_;
|
||||
};
|
||||
|
||||
} // namespace geopro::app
|
||||
|
|
@ -0,0 +1,215 @@
|
|||
#include "panels/chart/BarChartView.hpp"
|
||||
|
||||
#include <QHBoxLayout>
|
||||
#include <QLabel>
|
||||
#include <QPalette>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
#include <qwt_column_symbol.h>
|
||||
#include <qwt_legend.h>
|
||||
#include <qwt_plot.h>
|
||||
#include <qwt_plot_barchart.h>
|
||||
#include <qwt_plot_grid.h>
|
||||
#include <qwt_plot_multi_barchart.h>
|
||||
#include <qwt_samples.h>
|
||||
#include <qwt_scale_draw.h>
|
||||
#include <qwt_text.h>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include "Theme.hpp"
|
||||
#include "panels/chart/ChartTheme.hpp"
|
||||
|
||||
namespace geopro::app {
|
||||
|
||||
namespace {
|
||||
|
||||
// 类目轴刻度:把整数刻度位(0,1,2,…)映射为类目标签 "#1","#2",…(来自 categories)。
|
||||
// 非整数/越界刻度返回空标签(避免次刻度污染)。
|
||||
class CategoryScaleDraw : public QwtScaleDraw {
|
||||
public:
|
||||
explicit CategoryScaleDraw(std::vector<QString> labels) : labels_(std::move(labels)) {
|
||||
enableComponent(QwtScaleDraw::Backbone, true);
|
||||
enableComponent(QwtScaleDraw::Ticks, true);
|
||||
}
|
||||
QwtText label(double v) const override {
|
||||
const double r = std::round(v);
|
||||
if (std::abs(v - r) > 1e-6) return QwtText(); // 仅整数刻度出标签
|
||||
const int i = static_cast<int>(r);
|
||||
if (i < 0 || i >= static_cast<int>(labels_.size())) return QwtText();
|
||||
return labels_[static_cast<size_t>(i)];
|
||||
}
|
||||
|
||||
private:
|
||||
std::vector<QString> labels_;
|
||||
};
|
||||
|
||||
QColor barColor(const QString& hex) {
|
||||
QColor c(hex);
|
||||
return c.isValid() ? c : QColor(0x54, 0x70, 0xc6); // 回退 ECharts 蓝
|
||||
}
|
||||
|
||||
// 造一个 Box 样式柱符号(实心填充、无边框)。
|
||||
QwtColumnSymbol* makeBarSymbol(const QColor& fill) {
|
||||
auto* sym = new QwtColumnSymbol(QwtColumnSymbol::Box);
|
||||
sym->setLineWidth(0);
|
||||
sym->setFrameStyle(QwtColumnSymbol::NoFrame);
|
||||
QPalette pal(fill);
|
||||
pal.setColor(QPalette::Window, fill);
|
||||
pal.setColor(QPalette::Dark, fill);
|
||||
sym->setPalette(pal);
|
||||
return sym;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
BarChartView::BarChartView(QWidget* parent) : QWidget(parent) {
|
||||
auto* lay = new QVBoxLayout(this);
|
||||
lay->setContentsMargins(0, 0, 0, 0);
|
||||
lay->setSpacing(0);
|
||||
|
||||
// 左上水平 y 轴标题(ECharts 风格,置于图例行左侧)。
|
||||
yTitle_ = new QLabel(this);
|
||||
auto* titleRow = new QWidget(this);
|
||||
auto* titleLay = new QHBoxLayout(titleRow);
|
||||
titleLay->setContentsMargins(48, 6, 8, 0); // 左缩进对齐 y 轴上方
|
||||
titleLay->setSpacing(0);
|
||||
titleLay->addWidget(yTitle_);
|
||||
titleLay->addStretch();
|
||||
lay->addWidget(titleRow);
|
||||
|
||||
plot_ = new QwtPlot(this);
|
||||
plot_->setObjectName(QStringLiteral("grBarPlotArea"));
|
||||
plot_->enableAxis(QwtPlot::xBottom, true);
|
||||
plot_->enableAxis(QwtPlot::yLeft, true);
|
||||
|
||||
// x 轴标题「电极点」底部居中(setData 设文本)。
|
||||
plot_->setAxisTitle(QwtPlot::xBottom, QwtText());
|
||||
|
||||
// 顶部图例(P1/P2)。
|
||||
plot_->insertLegend(new QwtLegend(), QwtPlot::TopLegend);
|
||||
|
||||
// 仅横向(y)网格,弱化(与原版 ECharts 一致:仅水平刻度线)。
|
||||
auto* grid = new QwtPlotGrid();
|
||||
grid->enableX(false);
|
||||
grid->enableY(true);
|
||||
grid->enableXMin(false);
|
||||
grid->enableYMin(false);
|
||||
grid->setMajorPen(QColor(225, 225, 225), 1.0, Qt::SolidLine);
|
||||
grid->attach(plot_);
|
||||
|
||||
plot_->setMinimumSize(0, 0);
|
||||
lay->addWidget(plot_, 1);
|
||||
|
||||
// 主题:底色/轴字/网格按当前主题套一次 + 热切换。
|
||||
applyChartPlotTheme(plot_);
|
||||
QObject::connect(&ThemeManager::instance(), &ThemeManager::changed, plot_,
|
||||
[this]() {
|
||||
applyChartPlotTheme(plot_);
|
||||
// y 轴标题文字色随主题。
|
||||
QPalette pal = yTitle_->palette();
|
||||
pal.setColor(QPalette::WindowText,
|
||||
isDarkTheme() ? tokenColor("text/secondary")
|
||||
: QColor(90, 90, 90));
|
||||
yTitle_->setPalette(pal);
|
||||
});
|
||||
}
|
||||
|
||||
BarChartView::~BarChartView() {
|
||||
// 卸载并删除已挂的柱状项,先于 QwtPlot autoDelete 触发(与 RawDataChartView/
|
||||
// GridDataChartView 析构对称,避免悬垂/双删)。
|
||||
clearSeries();
|
||||
}
|
||||
|
||||
void BarChartView::clearSeries() {
|
||||
for (QwtPlotItem* it : barItems_) {
|
||||
it->detach();
|
||||
delete it;
|
||||
}
|
||||
barItems_.clear();
|
||||
}
|
||||
|
||||
void BarChartView::setPayload(const QVariant& payload) {
|
||||
if (!payload.canConvert<geopro::core::BarPayload>()) return; // 坏/空 → 空态
|
||||
setData(payload.value<geopro::core::BarPayload>());
|
||||
}
|
||||
|
||||
void BarChartView::setData(const geopro::core::BarPayload& p) {
|
||||
data_ = p;
|
||||
clearSeries();
|
||||
|
||||
const int n = static_cast<int>(p.categories.size());
|
||||
|
||||
// y 轴标题(左上水平 QLabel)。
|
||||
yTitle_->setText(p.yTitle);
|
||||
QPalette tpal = yTitle_->palette();
|
||||
tpal.setColor(QPalette::WindowText,
|
||||
isDarkTheme() ? tokenColor("text/secondary") : QColor(90, 90, 90));
|
||||
yTitle_->setPalette(tpal);
|
||||
|
||||
// x 轴标题「电极点」+ 类目刻度 "#1".."#40"。
|
||||
plot_->setAxisTitle(QwtPlot::xBottom, p.xTitle);
|
||||
plot_->setAxisScaleDraw(QwtPlot::xBottom, new CategoryScaleDraw(p.categories));
|
||||
plot_->setAxisScale(QwtPlot::xBottom, -0.5, n - 0.5, 1.0); // 每类目一刻度
|
||||
plot_->setAxisMaxMinor(QwtPlot::xBottom, 0);
|
||||
|
||||
// y 范围 0..max(自动从数据取上界,留 5% 余量)。
|
||||
double yMax = 0.0;
|
||||
for (const auto& s : p.series)
|
||||
for (double v : s.values) yMax = std::max(yMax, v);
|
||||
plot_->setAxisScale(QwtPlot::yLeft, 0.0, yMax > 0 ? yMax * 1.05 : 1.0);
|
||||
|
||||
if (p.series.empty()) {
|
||||
plot_->replot();
|
||||
return;
|
||||
}
|
||||
|
||||
if (p.series.size() == 1) {
|
||||
// 单系列 → QwtPlotBarChart。
|
||||
const auto& s = p.series.front();
|
||||
auto* chart = new QwtPlotBarChart(s.name);
|
||||
QVector<QPointF> samples;
|
||||
samples.reserve(n);
|
||||
for (int i = 0; i < n && i < static_cast<int>(s.values.size()); ++i)
|
||||
samples.append(QPointF(i, s.values[static_cast<size_t>(i)]));
|
||||
chart->setSamples(samples);
|
||||
chart->setSymbol(makeBarSymbol(barColor(s.color)));
|
||||
chart->setLegendMode(QwtPlotBarChart::LegendChartTitle);
|
||||
chart->setLayoutPolicy(QwtPlotBarChart::AutoAdjustSamples);
|
||||
chart->setSpacing(10); // 柱间留白(默认柱状观感)
|
||||
chart->setMargin(4);
|
||||
chart->attach(plot_);
|
||||
barItems_.push_back(chart);
|
||||
} else {
|
||||
// 多系列(P1+P2)→ QwtPlotMultiBarChart(分组柱)。
|
||||
auto* chart = new QwtPlotMultiBarChart();
|
||||
QList<QwtText> titles;
|
||||
for (size_t k = 0; k < p.series.size(); ++k) {
|
||||
chart->setSymbol(static_cast<int>(k), makeBarSymbol(barColor(p.series[k].color)));
|
||||
titles.append(QwtText(p.series[k].name));
|
||||
}
|
||||
chart->setBarTitles(titles);
|
||||
chart->setStyle(QwtPlotMultiBarChart::Grouped);
|
||||
chart->setLayoutPolicy(QwtPlotMultiBarChart::AutoAdjustSamples);
|
||||
chart->setSpacing(10);
|
||||
chart->setMargin(4);
|
||||
|
||||
QVector<QwtSetSample> samples;
|
||||
samples.reserve(n);
|
||||
for (int i = 0; i < n; ++i) {
|
||||
QVector<double> set;
|
||||
set.reserve(static_cast<int>(p.series.size()));
|
||||
for (const auto& s : p.series)
|
||||
set.append(i < static_cast<int>(s.values.size()) ? s.values[static_cast<size_t>(i)]
|
||||
: 0.0);
|
||||
samples.append(QwtSetSample(i, set));
|
||||
}
|
||||
chart->setSamples(samples);
|
||||
chart->attach(plot_);
|
||||
barItems_.push_back(chart);
|
||||
}
|
||||
|
||||
plot_->replot();
|
||||
}
|
||||
|
||||
} // namespace geopro::app
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
#pragma once
|
||||
#include <QWidget>
|
||||
#include <vector>
|
||||
|
||||
#include "model/detail/DetailPayloads.hpp"
|
||||
#include "panels/chart/IDetailView.hpp"
|
||||
|
||||
class QLabel;
|
||||
class QwtPlot;
|
||||
class QwtPlotItem;
|
||||
|
||||
namespace geopro::app {
|
||||
|
||||
// 柱状图视图(dd_ert_measurement_gr_data 接地电阻 柱状图页签):
|
||||
// QwtPlot + QwtPlotBarChart(单系列 P1)或 QwtPlotMultiBarChart(分组 P1+P2)。
|
||||
// x 轴类目标签 "#1".."#40"(自定义 QwtScaleDraw);x 轴标题「电极点」(底部居中);
|
||||
// y 轴标题「电阻(单位:欧姆)」用左上水平 QLabel(ECharts 风格,对齐原版);
|
||||
// 顶部图例(P1/P2);柱填充 #5470c6(P1,数据色,两主题一致)。
|
||||
// 背景/轴字/网格随主题(ChartTheme / ThemeManager)。
|
||||
class BarChartView : public QWidget, public IDetailView {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit BarChartView(QWidget* parent = nullptr);
|
||||
~BarChartView() override;
|
||||
|
||||
void setData(const geopro::core::BarPayload& p);
|
||||
|
||||
QWidget* widget() override { return this; }
|
||||
void setPayload(const QVariant& payload) override; // 坏/空 variant → 空态不崩
|
||||
|
||||
private:
|
||||
void clearSeries(); // 卸载并删除已挂的柱状项(避免 QwtPlot autoDelete 双删)
|
||||
|
||||
geopro::core::BarPayload data_;
|
||||
QwtPlot* plot_;
|
||||
QLabel* yTitle_; // 左上水平 y 轴标题(ECharts 风格)
|
||||
std::vector<QwtPlotItem*> barItems_; // 当前挂载的柱状项(已 attach;卸载时 detach+delete)
|
||||
};
|
||||
|
||||
} // namespace geopro::app
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
#include "panels/chart/ChartTheme.hpp"
|
||||
|
||||
#include <QColor>
|
||||
#include <QPalette>
|
||||
#include <qwt_plot.h>
|
||||
#include <qwt_plot_grid.h>
|
||||
#include <qwt_plot_item.h>
|
||||
#include <qwt_plot_marker.h>
|
||||
|
||||
#include "Theme.hpp"
|
||||
|
||||
namespace geopro::app {
|
||||
|
||||
void applyChartPlotTheme(QwtPlot* plot) {
|
||||
if (!plot) return;
|
||||
const bool dark = isDarkTheme();
|
||||
// 浅色分支=原有硬编码值(保持与原版 1:1);暗色分支=主题 token。
|
||||
const QColor bg = dark ? tokenColor("bg/panel") : QColor(Qt::white);
|
||||
const QColor axisText = dark ? tokenColor("text/secondary"): QColor(90, 90, 90);
|
||||
const QColor gridMajor = dark ? tokenColor("border/default"): QColor(225, 225, 225);
|
||||
const QColor gridMinor = dark ? tokenColor("bg/hover") : QColor(240, 240, 240);
|
||||
const QColor zeroPen = dark ? tokenColor("border/strong") : QColor(180, 180, 180);
|
||||
|
||||
plot->setCanvasBackground(QBrush(bg));
|
||||
plot->setAutoFillBackground(true);
|
||||
QPalette pal = plot->palette();
|
||||
pal.setColor(QPalette::Window, bg);
|
||||
pal.setColor(QPalette::WindowText, axisText);
|
||||
pal.setColor(QPalette::Text, axisText);
|
||||
plot->setPalette(pal);
|
||||
|
||||
// 网格线 / 零线(line marker)按主题重着色——不动其它 item(散点/网格填充自带配色)。
|
||||
const QwtPlotItemList items = plot->itemList();
|
||||
for (QwtPlotItem* it : items) {
|
||||
if (auto* g = dynamic_cast<QwtPlotGrid*>(it)) {
|
||||
g->setMajorPen(gridMajor, 1.0, Qt::SolidLine);
|
||||
g->setMinorPen(gridMinor, 1.0, Qt::DotLine);
|
||||
} else if (auto* m = dynamic_cast<QwtPlotMarker*>(it)) {
|
||||
if (m->lineStyle() != QwtPlotMarker::NoLine) m->setLinePen(zeroPen, 1.0);
|
||||
}
|
||||
}
|
||||
plot->replot();
|
||||
}
|
||||
|
||||
} // namespace geopro::app
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
#pragma once
|
||||
|
||||
class QwtPlot;
|
||||
|
||||
namespace geopro::app {
|
||||
|
||||
// 按当前主题给 QwtPlot 套底色/轴文字/网格/零线配色,并 replot。
|
||||
// 浅色:与原版 web 图表一致(白底 + 深灰轴字)——保持 1:1,不动。
|
||||
// 暗色:改深色画布(bg/panel) + 浅色轴字,避免暗色主题下刺眼白底。
|
||||
// 网格线 / 零线标记按主题重新着色(遍历 plot 的 item 列表,无需各视图持有指针)。
|
||||
// 在视图构造末尾调用一次;并连 ThemeManager::changed 再调用以热切换。
|
||||
void applyChartPlotTheme(QwtPlot* plot);
|
||||
|
||||
} // namespace geopro::app
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
#include "panels/chart/ColorBarWidget.hpp"
|
||||
#include <QPainter>
|
||||
#include <QPaintEvent>
|
||||
|
||||
#include "Theme.hpp"
|
||||
|
||||
namespace geopro::app {
|
||||
|
||||
static constexpr int kBarHeight = 18; // 色带高度(px)
|
||||
static constexpr int kTickHeight = 4; // 刻度短线高度(px)
|
||||
static constexpr int kFontSize = 9; // 刻度字号
|
||||
static constexpr int kVBarWidth = 16; // 竖条色带宽度(px)
|
||||
static constexpr int kVLabelGap = 4; // 竖条值标签与色带间距(px)
|
||||
|
||||
ColorBarWidget::ColorBarWidget(QWidget* parent, Orientation orient)
|
||||
: QWidget(parent), orient_(orient) {
|
||||
if (orient_ == Orientation::Vertical) {
|
||||
setFixedWidth(64); // 竖条 + 左侧值标签
|
||||
} else {
|
||||
setFixedHeight(36);
|
||||
}
|
||||
// 主题热切换:底色/边框/刻度字跟随主题重绘(色带本身=数据色,不变)。
|
||||
connect(&ThemeManager::instance(), &ThemeManager::changed, this,
|
||||
qOverload<>(&QWidget::update));
|
||||
}
|
||||
|
||||
void ColorBarWidget::setColorScale(const core::ColorScale& scale) {
|
||||
scale_ = scale;
|
||||
update();
|
||||
}
|
||||
|
||||
void ColorBarWidget::paintEvent(QPaintEvent* /*event*/) {
|
||||
QPainter p(this);
|
||||
p.setRenderHint(QPainter::Antialiasing, false);
|
||||
if (orient_ == Orientation::Vertical) {
|
||||
paintVertical(p);
|
||||
} else {
|
||||
paintHorizontal(p);
|
||||
}
|
||||
}
|
||||
|
||||
void ColorBarWidget::paintHorizontal(QPainter& p) {
|
||||
const int W = width();
|
||||
const int H = height();
|
||||
// 浅色=白底深字(原版 1:1);暗色=深底浅字,避免刺眼白条。色带格(数据色)两者不变。
|
||||
const bool dark = isDarkTheme();
|
||||
const QColor bgColor = dark ? tokenColor("bg/panel") : QColor(Qt::white);
|
||||
const QColor borderColor = dark ? tokenColor("border/strong") : QColor(120, 120, 120);
|
||||
const QColor textColor = dark ? tokenColor("text/secondary"): QColor(60, 60, 60);
|
||||
p.fillRect(0, 0, W, H, bgColor);
|
||||
|
||||
auto stops = scale_.stops();
|
||||
if (stops.size() < 2) return;
|
||||
|
||||
const int barY = 2;
|
||||
const int barH = kBarHeight;
|
||||
// 居中约 74% 宽(对齐原版:色阶条不占满,两侧各留 ~13% 边距)。
|
||||
const int marginX = static_cast<int>(W * 0.13);
|
||||
const int barLeft = marginX;
|
||||
const int barRight = W - marginX;
|
||||
const int barW = barRight - barLeft;
|
||||
if (barW < 10) return;
|
||||
// 等宽色带:每相邻断点一格,等宽(对齐原版图例,非按值比例)。
|
||||
const int nSeg = static_cast<int>(stops.size()) - 1;
|
||||
auto segX = [&](int i) { return barLeft + static_cast<int>(static_cast<double>(i) / nSeg * barW); };
|
||||
|
||||
for (int i = 0; i < nSeg; ++i) {
|
||||
int xL = segX(i), xR = segX(i + 1);
|
||||
if (xR <= xL) xR = xL + 1;
|
||||
const auto& c = stops[i].second;
|
||||
p.fillRect(xL, barY, xR - xL, barH, QColor(c.r, c.g, c.b, c.a));
|
||||
}
|
||||
// 外边框
|
||||
p.setPen(QPen(borderColor, 1));
|
||||
p.drawRect(barLeft, barY, barW - 1, barH - 1);
|
||||
|
||||
// 边界值标签(随主题:浅色深字 / 暗色浅字),在各分格边界下方。
|
||||
QFont font = p.font();
|
||||
font.setPixelSize(kFontSize);
|
||||
p.setFont(font);
|
||||
p.setPen(textColor);
|
||||
QFontMetrics fm(font);
|
||||
const int tickY = barY + barH + 1;
|
||||
for (int i = 0; i <= nSeg; ++i) {
|
||||
int x = segX(i);
|
||||
p.drawLine(x, tickY, x, tickY + kTickHeight);
|
||||
const QString label = QString::number(stops[i].first, 'g', 4);
|
||||
int tw = fm.horizontalAdvance(label);
|
||||
int tx = x - tw / 2;
|
||||
if (tx < 0) tx = 0;
|
||||
if (tx + tw > W) tx = W - tw;
|
||||
p.drawText(tx, H - 2, label);
|
||||
}
|
||||
}
|
||||
|
||||
void ColorBarWidget::paintVertical(QPainter& p) {
|
||||
const int W = width();
|
||||
const int H = height();
|
||||
const bool dark = isDarkTheme();
|
||||
const QColor bgColor = dark ? tokenColor("bg/panel") : QColor(Qt::white);
|
||||
const QColor borderColor = dark ? tokenColor("border/strong") : QColor(120, 120, 120);
|
||||
const QColor textColor = dark ? tokenColor("text/secondary"): QColor(60, 60, 60);
|
||||
p.fillRect(0, 0, W, H, bgColor);
|
||||
|
||||
auto stops = scale_.stops();
|
||||
if (stops.size() < 2) return;
|
||||
|
||||
QFont font = p.font();
|
||||
font.setPixelSize(kFontSize);
|
||||
p.setFont(font);
|
||||
QFontMetrics fm(font);
|
||||
|
||||
// 竖条靠右;左侧留出值标签宽度。上下各留半行高,避免顶/底标签出界。
|
||||
const int halfLine = fm.height() / 2 + 1;
|
||||
const int barRight = W - 2;
|
||||
const int barLeft = barRight - kVBarWidth;
|
||||
const int barTop = halfLine;
|
||||
const int barBottom = H - halfLine;
|
||||
const int barH = barBottom - barTop;
|
||||
if (barH < 10 || barLeft < 0) return;
|
||||
|
||||
// 等高色带:每相邻断点一格,最大值在顶、最小值在底(与原版 1323→0 自上而下一致)。
|
||||
const int nSeg = static_cast<int>(stops.size()) - 1;
|
||||
// segY(i): 第 i 个边界的 y(i=0 为最小值在底,i=nSeg 为最大值在顶)。
|
||||
auto segY = [&](int i) {
|
||||
return barBottom - static_cast<int>(static_cast<double>(i) / nSeg * barH);
|
||||
};
|
||||
for (int i = 0; i < nSeg; ++i) {
|
||||
int yB = segY(i), yT = segY(i + 1);
|
||||
if (yT >= yB) yT = yB - 1;
|
||||
const auto& c = stops[i].second; // 段 [stop_i, stop_{i+1}) 取 stop_i 色(与离散一致)
|
||||
p.fillRect(barLeft, yT, kVBarWidth, yB - yT, QColor(c.r, c.g, c.b, c.a));
|
||||
}
|
||||
// 外边框
|
||||
p.setPen(QPen(borderColor, 1));
|
||||
p.drawRect(barLeft, barTop, kVBarWidth - 1, barH - 1);
|
||||
|
||||
// 边界值标签(色带左侧,垂直居中于各边界)。
|
||||
p.setPen(textColor);
|
||||
for (int i = 0; i <= nSeg; ++i) {
|
||||
int y = segY(i);
|
||||
const QString label = QString::number(stops[i].first, 'f', 2);
|
||||
int tw = fm.horizontalAdvance(label);
|
||||
int tx = barLeft - kVLabelGap - tw;
|
||||
if (tx < 0) tx = 0;
|
||||
p.drawText(tx, y + fm.ascent() / 2 - 1, label);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace geopro::app
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
#pragma once
|
||||
#include "model/ColorScale.hpp"
|
||||
#include <QWidget>
|
||||
|
||||
namespace geopro::app {
|
||||
|
||||
// 独立色阶条 Widget,作为 QwtPlot 的兄弟 widget,不进入 Qwt 坐标系,
|
||||
// 因此不随图表缩放/平移移动,也不与轴标注重叠。
|
||||
// Horizontal(默认,反演原数据):底部横条,等宽色带 + 下方边界刻度值。
|
||||
// Vertical(measurement):右侧竖条,等高色带,最大值在顶、最小值在底,
|
||||
// 边界值标在色带左侧(对齐原版 Plotly 离散图例)。
|
||||
class ColorBarWidget : public QWidget {
|
||||
Q_OBJECT
|
||||
public:
|
||||
enum class Orientation { Horizontal, Vertical };
|
||||
|
||||
explicit ColorBarWidget(QWidget* parent = nullptr, Orientation orient = Orientation::Horizontal);
|
||||
|
||||
void setColorScale(const core::ColorScale& scale);
|
||||
|
||||
protected:
|
||||
void paintEvent(QPaintEvent* event) override;
|
||||
|
||||
private:
|
||||
void paintHorizontal(QPainter& p);
|
||||
void paintVertical(QPainter& p);
|
||||
|
||||
core::ColorScale scale_;
|
||||
Orientation orient_;
|
||||
};
|
||||
|
||||
} // namespace geopro::app
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
#include "panels/chart/ColorMapService.hpp"
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
namespace geopro::app {
|
||||
|
||||
namespace {
|
||||
inline double clamp01(double v) {
|
||||
return v < 0.0 ? 0.0 : (v > 1.0 ? 1.0 : v);
|
||||
}
|
||||
inline unsigned char lerpByte(unsigned char a, unsigned char b, double t) {
|
||||
return static_cast<unsigned char>(a + (b - a) * t + 0.5);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
ColorMapService::ColorMapService(const core::ColorScale& scale)
|
||||
: scale_(scale) {
|
||||
auto raw = scale.stops();
|
||||
if (raw.empty()) {
|
||||
minVal_ = 0.0;
|
||||
maxVal_ = 1.0;
|
||||
dataMin_ = minVal_;
|
||||
dataMax_ = maxVal_;
|
||||
return;
|
||||
}
|
||||
minVal_ = raw.front().first;
|
||||
maxVal_ = raw.back().first;
|
||||
dataMin_ = minVal_; // 默认数据范围 = 断点范围;setDataRange 可改为数据 min/max(cauto)
|
||||
dataMax_ = maxVal_;
|
||||
double range = maxVal_ - minVal_;
|
||||
normStops_.reserve(raw.size());
|
||||
for (const auto& [val, color] : raw) {
|
||||
double pos = (range > 0.0) ? (val - minVal_) / range : 0.0;
|
||||
normStops_.push_back({clamp01(pos), color});
|
||||
}
|
||||
}
|
||||
|
||||
void ColorMapService::setDataRange(double dataMin, double dataMax) {
|
||||
dataMin_ = dataMin;
|
||||
dataMax_ = dataMax;
|
||||
}
|
||||
|
||||
double ColorMapService::normalized(double v) const {
|
||||
double range = dataMax_ - dataMin_;
|
||||
if (range <= 0.0) return 0.0;
|
||||
return clamp01((v - dataMin_) / range);
|
||||
}
|
||||
|
||||
core::Rgba ColorMapService::colorAtContinuous(double v) const {
|
||||
if (normStops_.empty()) return core::Rgba{0, 0, 0, 255};
|
||||
if (normStops_.size() == 1) return normStops_.front().color;
|
||||
|
||||
double t = normalized(v);
|
||||
// 非有限值(NaN/Inf,可能来自降级后端的脏数据或退化数据范围):回退首断点色,
|
||||
// 避免下方 upper_bound 用 NaN 比较返回 end() 后解引用越界(崩溃)。
|
||||
if (!std::isfinite(t)) return normStops_.front().color;
|
||||
|
||||
// 找到 t 落在哪两个 normStop 之间
|
||||
if (t <= normStops_.front().pos) return normStops_.front().color;
|
||||
if (t >= normStops_.back().pos) return normStops_.back().color;
|
||||
|
||||
// 二分查找第一个 pos > t
|
||||
auto it = std::upper_bound(normStops_.begin(), normStops_.end(), t,
|
||||
[](double val, const NormStop& s) { return val < s.pos; });
|
||||
|
||||
const NormStop& hi = *it;
|
||||
const NormStop& lo = *(it - 1);
|
||||
double segLen = hi.pos - lo.pos;
|
||||
double frac = (segLen > 0.0) ? (t - lo.pos) / segLen : 0.0;
|
||||
|
||||
return core::Rgba{
|
||||
lerpByte(lo.color.r, hi.color.r, frac),
|
||||
lerpByte(lo.color.g, hi.color.g, frac),
|
||||
lerpByte(lo.color.b, hi.color.b, frac),
|
||||
lerpByte(lo.color.a, hi.color.a, frac)
|
||||
};
|
||||
}
|
||||
|
||||
core::Rgba ColorMapService::colorAtDiscrete(double v) const {
|
||||
return scale_.colorAt(v);
|
||||
}
|
||||
|
||||
} // namespace geopro::app
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
#pragma once
|
||||
#include "model/ColorScale.hpp"
|
||||
#include <vector>
|
||||
#include <utility>
|
||||
|
||||
namespace geopro::app {
|
||||
|
||||
// 连续/离散双模式色阶服务,供散点渲染(连续插值)和网格/图例(阶梯)使用。
|
||||
// 连续模式:按归一化位置在相邻断点之间线性插值 RGB,与原版 Plotly colorscale 一致。
|
||||
class ColorMapService {
|
||||
public:
|
||||
explicit ColorMapService(const core::ColorScale& scale);
|
||||
|
||||
// 设置数据值归一化范围(散点用,对齐原版 Plotly cauto=数据 min/max)。
|
||||
// 与色阶形状(断点位置,按断点值范围)解耦:色阶位置不变,只改输入值→[0,1] 的映射。
|
||||
// 默认(不调用)数据范围 = 断点值范围(首/末断点)。
|
||||
void setDataRange(double dataMin, double dataMax);
|
||||
|
||||
// 将数据值归一化到 [0,1](按数据范围,默认=断点值范围),超范围 clamp。
|
||||
double normalized(double v) const;
|
||||
|
||||
// 连续插值取色(散点用):按断点位置线性插值 RGB。
|
||||
core::Rgba colorAtContinuous(double v) const;
|
||||
|
||||
// 离散阶梯取色(网格/图例用):复用 ColorScale::colorAt。
|
||||
core::Rgba colorAtDiscrete(double v) const;
|
||||
|
||||
// 返回原始 ColorScale 引用(色阶条图例用)。
|
||||
const core::ColorScale& scale() const { return scale_; }
|
||||
|
||||
private:
|
||||
core::ColorScale scale_;
|
||||
// 归一化位置与颜色预计算缓存(stops 已升序)。
|
||||
struct NormStop {
|
||||
double pos; // normalized position in [0,1]
|
||||
core::Rgba color;
|
||||
};
|
||||
std::vector<NormStop> normStops_;
|
||||
double minVal_; // 断点值范围下界(色阶形状用)
|
||||
double maxVal_; // 断点值范围上界
|
||||
double dataMin_; // 数据归一化下界(默认=minVal_,setDataRange 可改)
|
||||
double dataMax_; // 数据归一化上界(默认=maxVal_)
|
||||
};
|
||||
|
||||
} // namespace geopro::app
|
||||
|
|
@ -0,0 +1,281 @@
|
|||
#include "panels/chart/ContourPlotItem.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
#include <QFont>
|
||||
#include <QPainter>
|
||||
#include <QPen>
|
||||
#include <QPolygonF>
|
||||
#include <qwt_plot.h>
|
||||
#include <qwt_scale_map.h>
|
||||
|
||||
#include "panels/chart/ColorMapService.hpp"
|
||||
|
||||
namespace geopro::app {
|
||||
|
||||
namespace {
|
||||
constexpr int kFillUpsample = 4; // 填充图像每网格格细分 K(双线性插值平滑带边界)
|
||||
constexpr int kMaxFillDim = 2400; // 填充图像单边像素上限(防极端网格爆内存)
|
||||
constexpr int kLabelFontPx = 10; // 等值线标注字号
|
||||
constexpr double kLabelMinLenPx = 24.0; // 整条线像素长度小于此不标注(极短碎线)
|
||||
constexpr double kRad2Deg = 57.29577951308232; // 180/π(避免依赖 M_PI)
|
||||
} // namespace
|
||||
|
||||
ContourPlotItem::ContourPlotItem() : QwtPlotItem() {
|
||||
setRenderHint(QwtPlotItem::RenderAntialiased, false);
|
||||
// 网格数据 x 轴在底部(与 RawDataChartView 的顶部 x 轴不同);y 轴在左。
|
||||
setXAxis(QwtPlot::xBottom);
|
||||
setYAxis(QwtPlot::yLeft);
|
||||
}
|
||||
|
||||
void ContourPlotItem::setData(const core::Grid& g, ColorMapService* svc,
|
||||
const std::vector<core::Anomaly>& anoms, bool showLines,
|
||||
bool showLabels) {
|
||||
showLines_ = showLines;
|
||||
showLabels_ = showLabels;
|
||||
anoms_ = anoms;
|
||||
|
||||
const int nx = g.nx(), ny = g.ny();
|
||||
if (nx < 2 || ny < 2 || static_cast<int>(g.x.size()) < nx ||
|
||||
static_cast<int>(g.y.size()) < ny) {
|
||||
fillImage_ = QImage();
|
||||
dataBBox_ = QRectF();
|
||||
lines_.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
const double xmin = g.x.front(), xmax = g.x.back();
|
||||
const double ymin = g.y.front(), ymax = g.y.back();
|
||||
dataBBox_ = QRectF(xmin, ymin, xmax - xmin, ymax - ymin);
|
||||
|
||||
buildFillImage(g, svc);
|
||||
|
||||
// 等值线(矢量):复用 render 管线,仅取 lines(填充走栅格)。
|
||||
if (showLines_) {
|
||||
render::ContourOptions opt;
|
||||
opt.upsample = 2;
|
||||
opt.makeLines = true;
|
||||
auto res = render::buildContourBands(g, svc->scale(), opt);
|
||||
lines_ = std::move(res.lines);
|
||||
// buildContourBands 当前未回填 level(恒 0);在此按线上代表点采网格值并吸附到最近色阶级,
|
||||
// 使标注显示真实等值线值。
|
||||
resolveLineLevels(g, svc->scale());
|
||||
} else {
|
||||
lines_.clear();
|
||||
}
|
||||
}
|
||||
|
||||
void ContourPlotItem::resolveLineLevels(const core::Grid& g, const core::ColorScale& cs) {
|
||||
const auto stops = cs.stopValues();
|
||||
if (stops.empty() || lines_.empty()) return;
|
||||
|
||||
const int nx = g.nx(), ny = g.ny();
|
||||
const double xmin = g.x.front(), xmax = g.x.back();
|
||||
const double ymin = g.y.front(), ymax = g.y.back();
|
||||
const double xspan = (xmax - xmin), yspan = (ymax - ymin);
|
||||
|
||||
// 在数据坐标点做双线性采样(NaN 安全)。
|
||||
auto sampleAt = [&](const core::Vec2& p) -> double {
|
||||
if (xspan <= 0 || yspan <= 0) return std::nan("");
|
||||
double fi = (p.x - xmin) / xspan * (nx - 1);
|
||||
double fj = (p.y - ymin) / yspan * (ny - 1);
|
||||
fi = std::clamp(fi, 0.0, static_cast<double>(nx - 1));
|
||||
fj = std::clamp(fj, 0.0, static_cast<double>(ny - 1));
|
||||
int i0 = std::min(static_cast<int>(fi), nx - 2);
|
||||
int j0 = std::min(static_cast<int>(fj), ny - 2);
|
||||
double ti = fi - i0, tj = fj - j0;
|
||||
double v00 = g.valueAt(i0, j0), v10 = g.valueAt(i0 + 1, j0);
|
||||
double v01 = g.valueAt(i0, j0 + 1), v11 = g.valueAt(i0 + 1, j0 + 1);
|
||||
if (std::isnan(v00) || std::isnan(v10) || std::isnan(v01) || std::isnan(v11))
|
||||
return std::nan("");
|
||||
return (v00 * (1 - ti) + v10 * ti) * (1 - tj) + (v01 * (1 - ti) + v11 * ti) * tj;
|
||||
};
|
||||
|
||||
for (auto& ln : lines_) {
|
||||
double sampled = std::nan("");
|
||||
for (const auto& p : ln.pts) { // 取首个非 NaN 采样点
|
||||
sampled = sampleAt(p);
|
||||
if (!std::isnan(sampled)) break;
|
||||
}
|
||||
if (std::isnan(sampled)) {
|
||||
ln.level = std::nan("");
|
||||
continue;
|
||||
}
|
||||
// 吸附到最近色阶级(等值线恰落在某级上)。
|
||||
double best = stops.front();
|
||||
double bestD = std::fabs(sampled - best);
|
||||
for (double s : stops) {
|
||||
double d = std::fabs(sampled - s);
|
||||
if (d < bestD) { bestD = d; best = s; }
|
||||
}
|
||||
ln.level = best;
|
||||
}
|
||||
}
|
||||
|
||||
void ContourPlotItem::buildFillImage(const core::Grid& g, ColorMapService* svc) {
|
||||
const int nx = g.nx(), ny = g.ny();
|
||||
int W = (nx - 1) * kFillUpsample + 1;
|
||||
int H = (ny - 1) * kFillUpsample + 1;
|
||||
// 限幅:极端网格下按**统一比例**降采样(W/H 独立截断会使采样非均匀→内容横/纵失真)。
|
||||
if (W > kMaxFillDim || H > kMaxFillDim) {
|
||||
const double s = std::min(static_cast<double>(kMaxFillDim) / W,
|
||||
static_cast<double>(kMaxFillDim) / H);
|
||||
W = std::max(2, static_cast<int>(W * s));
|
||||
H = std::max(2, static_cast<int>(H * s));
|
||||
}
|
||||
|
||||
QImage img(W, H, QImage::Format_ARGB32);
|
||||
img.fill(Qt::transparent);
|
||||
|
||||
// 每像素 → 归一化网格坐标 (fi,fj) → 四邻格双线性插值;任一邻格 NaN 则该像素透明。
|
||||
for (int py = 0; py < H; ++py) {
|
||||
// 图像顶行 py=0 对应 y 最大(ymax,地表);底行对应 y 最小(最深)。
|
||||
double fj = static_cast<double>(H - 1 - py) / (H - 1) * (ny - 1);
|
||||
int j0 = std::min(static_cast<int>(fj), ny - 2);
|
||||
double tj = fj - j0;
|
||||
auto* scan = reinterpret_cast<QRgb*>(img.scanLine(py));
|
||||
for (int px = 0; px < W; ++px) {
|
||||
double fi = static_cast<double>(px) / (W - 1) * (nx - 1);
|
||||
int i0 = std::min(static_cast<int>(fi), nx - 2);
|
||||
double ti = fi - i0;
|
||||
|
||||
double v00 = g.valueAt(i0, j0), v10 = g.valueAt(i0 + 1, j0);
|
||||
double v01 = g.valueAt(i0, j0 + 1), v11 = g.valueAt(i0 + 1, j0 + 1);
|
||||
if (std::isnan(v00) || std::isnan(v10) || std::isnan(v01) || std::isnan(v11))
|
||||
continue; // 含无数据格 → 像素透明(不规则白边)
|
||||
|
||||
double v = (v00 * (1 - ti) + v10 * ti) * (1 - tj) +
|
||||
(v01 * (1 - ti) + v11 * ti) * tj;
|
||||
auto c = svc->colorAtDiscrete(v); // 离散色带 → 平滑填充带边界
|
||||
scan[px] = qRgba(c.r, c.g, c.b, c.a ? c.a : 255);
|
||||
}
|
||||
}
|
||||
fillImage_ = std::move(img);
|
||||
}
|
||||
|
||||
QRectF ContourPlotItem::boundingRect() const {
|
||||
return dataBBox_;
|
||||
}
|
||||
|
||||
void ContourPlotItem::draw(QPainter* painter, const QwtScaleMap& xMap, const QwtScaleMap& yMap,
|
||||
const QRectF& /*canvasRect*/) const {
|
||||
if (dataBBox_.isNull()) return;
|
||||
|
||||
const double xmin = dataBBox_.left(), xmax = dataBBox_.right();
|
||||
const double ymin = dataBBox_.top(), ymax = dataBBox_.bottom();
|
||||
|
||||
// 1) 填充:数据 bbox → 像素矩形(注意 y 翻转:ymax→画布上沿、ymin→下沿),blit + 平滑缩放。
|
||||
if (!fillImage_.isNull()) {
|
||||
const double pxL = xMap.transform(xmin);
|
||||
const double pxR = xMap.transform(xmax);
|
||||
const double pyTop = yMap.transform(ymax);
|
||||
const double pyBot = yMap.transform(ymin);
|
||||
QRectF target(pxL, pyTop, pxR - pxL, pyBot - pyTop);
|
||||
painter->save();
|
||||
painter->setRenderHint(QPainter::SmoothPixmapTransform, true);
|
||||
painter->drawImage(target, fillImage_);
|
||||
painter->restore();
|
||||
}
|
||||
|
||||
auto mapPt = [&](const core::Vec2& p) {
|
||||
return QPointF(xMap.transform(p.x), yMap.transform(p.y));
|
||||
};
|
||||
|
||||
// 2) 等值线:黑色 0 宽(cosmetic)细线。
|
||||
if (showLines_ && !lines_.empty()) {
|
||||
painter->save();
|
||||
painter->setRenderHint(QPainter::Antialiasing, true);
|
||||
QPen pen(QColor(0, 0, 0));
|
||||
pen.setWidthF(1.0); // 1px 黑色等值线
|
||||
painter->setPen(pen);
|
||||
for (const auto& ln : lines_) {
|
||||
if (ln.pts.size() < 2) continue;
|
||||
QPolygonF poly;
|
||||
poly.reserve(static_cast<int>(ln.pts.size()));
|
||||
for (const auto& p : ln.pts) poly << mapPt(p);
|
||||
painter->drawPolyline(poly);
|
||||
}
|
||||
painter->restore();
|
||||
|
||||
// 3) 标注:每条等值线只标一个(对齐原版),放弧长中点,随该处方向旋转。
|
||||
if (showLabels_) {
|
||||
painter->save();
|
||||
painter->setRenderHint(QPainter::Antialiasing, true);
|
||||
QFont f = painter->font();
|
||||
f.setPixelSize(kLabelFontPx);
|
||||
painter->setFont(f);
|
||||
painter->setPen(QColor(0, 0, 0));
|
||||
const QFontMetricsF fm(f);
|
||||
for (const auto& ln : lines_) {
|
||||
if (ln.pts.size() < 2 || std::isnan(ln.level)) continue;
|
||||
// 映射到像素 + 累计像素弧长。
|
||||
std::vector<QPointF> px;
|
||||
px.reserve(ln.pts.size());
|
||||
for (const auto& p : ln.pts) px.push_back(mapPt(p));
|
||||
double total = 0.0;
|
||||
for (std::size_t i = 1; i < px.size(); ++i)
|
||||
total += std::hypot(px[i].x() - px[i - 1].x(), px[i].y() - px[i - 1].y());
|
||||
if (total < kLabelMinLenPx) continue; // 极短碎线不标注
|
||||
const QString txt = QString::number(ln.level, 'g', 4);
|
||||
const double halfW = fm.horizontalAdvance(txt) * 0.5;
|
||||
const double targetAt = total * 0.5; // 弧长中点
|
||||
double acc = 0.0;
|
||||
for (std::size_t i = 1; i < px.size(); ++i) {
|
||||
const double seg =
|
||||
std::hypot(px[i].x() - px[i - 1].x(), px[i].y() - px[i - 1].y());
|
||||
if (acc + seg >= targetAt || i == px.size() - 1) {
|
||||
const double t = seg > 1e-6 ? (targetAt - acc) / seg : 0.0;
|
||||
const QPointF pos(px[i - 1].x() + (px[i].x() - px[i - 1].x()) * t,
|
||||
px[i - 1].y() + (px[i].y() - px[i - 1].y()) * t);
|
||||
double ang =
|
||||
std::atan2(px[i].y() - px[i - 1].y(), px[i].x() - px[i - 1].x()) *
|
||||
kRad2Deg;
|
||||
if (ang > 90.0) ang -= 180.0; // 文字大体正向
|
||||
if (ang < -90.0) ang += 180.0;
|
||||
painter->save();
|
||||
painter->translate(pos);
|
||||
painter->rotate(ang);
|
||||
painter->drawText(QPointF(-halfW, -2), txt);
|
||||
painter->restore();
|
||||
break; // 只标一个
|
||||
}
|
||||
acc += seg;
|
||||
}
|
||||
}
|
||||
painter->restore();
|
||||
}
|
||||
}
|
||||
|
||||
// 4) 异常叠加:点=小方块、线=折线、面=闭合多边形;颜色用 lineColor,dashed→虚线。
|
||||
if (showAnomalies_ && !anoms_.empty()) {
|
||||
painter->save();
|
||||
painter->setRenderHint(QPainter::Antialiasing, true);
|
||||
painter->setBrush(Qt::NoBrush);
|
||||
for (const auto& a : anoms_) {
|
||||
if (a.localPts.empty()) continue;
|
||||
QColor col(QString::fromStdString(a.lineColor));
|
||||
if (!col.isValid()) col = QColor(0, 0, 0);
|
||||
QPen pen(col);
|
||||
pen.setWidthF(a.lineWidth > 0 ? a.lineWidth : 1.0);
|
||||
pen.setStyle(a.dashed ? Qt::DashLine : Qt::SolidLine);
|
||||
painter->setPen(pen);
|
||||
|
||||
if (a.markType == core::AnomalyMarkType::Point) {
|
||||
const QPointF c = mapPt(a.localPts.front());
|
||||
painter->drawRect(QRectF(c.x() - 3, c.y() - 3, 6, 6));
|
||||
} else {
|
||||
QPolygonF poly;
|
||||
poly.reserve(static_cast<int>(a.localPts.size()));
|
||||
for (const auto& p : a.localPts) poly << mapPt(p);
|
||||
if (a.markType == core::AnomalyMarkType::Polygon)
|
||||
painter->drawPolygon(poly); // 闭合
|
||||
else
|
||||
painter->drawPolyline(poly);
|
||||
}
|
||||
}
|
||||
painter->restore();
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace geopro::app
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
#pragma once
|
||||
#include <vector>
|
||||
|
||||
#include <QImage>
|
||||
#include <QRectF>
|
||||
#include <qwt_plot_item.h>
|
||||
|
||||
#include "model/Anomaly.hpp"
|
||||
#include "model/Field.hpp"
|
||||
#include "ContourBands.hpp"
|
||||
|
||||
class QPainter;
|
||||
class QwtScaleMap;
|
||||
|
||||
namespace geopro::app {
|
||||
|
||||
class ColorMapService;
|
||||
|
||||
// 网格等值线图项(QwtPlotItem,非 Q_OBJECT):
|
||||
// - 填充:预渲染 QImage 热力图(双线性插值 + 离散色带取色),draw 时 blit+平滑缩放
|
||||
// (避免 banded 多边形数万顶点导致拖动卡顿);含 NaN 的像素透明 → 不规则白边。
|
||||
// - 等值线:buildContourBands 返回的矢量折线(黑细线),随轴变换映射后 drawPolyline。
|
||||
// - 标注:沿线方向旋转的 level 数值(小字黑)。
|
||||
// - 异常叠加:按 markType 画 点(方块)/线(折线)/面(闭合多边形),dashed→虚线。
|
||||
class ContourPlotItem : public QwtPlotItem {
|
||||
public:
|
||||
ContourPlotItem();
|
||||
|
||||
// 构建填充图像 + 缓存等值线/异常。svc 不被拥有(由 GridDataChartView 持有)。
|
||||
void setData(const core::Grid& g, ColorMapService* svc,
|
||||
const std::vector<core::Anomaly>& anoms, bool showLines, bool showLabels);
|
||||
|
||||
void setShowLines(bool on) { showLines_ = on; }
|
||||
void setShowLabels(bool on) { showLabels_ = on; }
|
||||
void setShowAnomalies(bool on) { showAnomalies_ = on; }
|
||||
|
||||
int rtti() const override { return QwtPlotItem::Rtti_PlotUserItem; }
|
||||
|
||||
QRectF boundingRect() const override;
|
||||
|
||||
void draw(QPainter* painter, const QwtScaleMap& xMap, const QwtScaleMap& yMap,
|
||||
const QRectF& canvasRect) const override;
|
||||
|
||||
private:
|
||||
void buildFillImage(const core::Grid& g, ColorMapService* svc);
|
||||
void resolveLineLevels(const core::Grid& g, const core::ColorScale& cs);
|
||||
|
||||
QImage fillImage_; // 预渲染填充热力图(ARGB32,含透明无数据区)
|
||||
QRectF dataBBox_; // 数据包围盒(x[xmin,xmax] y[ymin,ymax])
|
||||
std::vector<render::ContourLine> lines_; // 矢量等值线(含 level)
|
||||
std::vector<core::Anomaly> anoms_; // 异常叠加
|
||||
|
||||
bool showLines_ = true;
|
||||
bool showLabels_ = true;
|
||||
bool showAnomalies_ = true;
|
||||
};
|
||||
|
||||
} // namespace geopro::app
|
||||
|
|
@ -0,0 +1,167 @@
|
|||
#include "panels/chart/DataTableView.hpp"
|
||||
|
||||
#include <QHeaderView>
|
||||
#include <QPainter>
|
||||
#include <QTableView>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
#include "panels/chart/TablePager.hpp"
|
||||
|
||||
namespace geopro::app {
|
||||
|
||||
namespace {
|
||||
// 药丸开关配色(原版蓝色 ON)。
|
||||
const QColor kSwitchOn(64, 158, 255); // 蓝色(ON/可见)
|
||||
const QColor kSwitchOff(190, 190, 190); // 灰色(OFF/隐藏)
|
||||
constexpr int kSwitchW = 36; // 轨道宽
|
||||
constexpr int kSwitchH = 18; // 轨道高(=直径)
|
||||
constexpr int kKnobMargin = 2; // 旋钮与轨道边距
|
||||
constexpr int kToggleColW = 80; // Toggle 列固定窄宽(开关 36 + 两侧留白)
|
||||
} // namespace
|
||||
|
||||
TablePayloadModel::TablePayloadModel(QObject* parent) : QAbstractTableModel(parent) {}
|
||||
|
||||
void TablePayloadModel::setPayload(const geopro::core::TablePayload& payload) {
|
||||
beginResetModel();
|
||||
payload_ = payload;
|
||||
endResetModel();
|
||||
}
|
||||
|
||||
int TablePayloadModel::rowCount(const QModelIndex& parent) const {
|
||||
if (parent.isValid()) return 0;
|
||||
return static_cast<int>(payload_.rows.size());
|
||||
}
|
||||
|
||||
int TablePayloadModel::columnCount(const QModelIndex& parent) const {
|
||||
if (parent.isValid()) return 0;
|
||||
return static_cast<int>(payload_.columns.size());
|
||||
}
|
||||
|
||||
QVariant TablePayloadModel::data(const QModelIndex& index, int role) const {
|
||||
if (!index.isValid()) return {};
|
||||
const size_t r = static_cast<size_t>(index.row());
|
||||
const size_t c = static_cast<size_t>(index.column());
|
||||
if (r >= payload_.rows.size() || c >= payload_.rows[r].size()) return {};
|
||||
if (role == Qt::TextAlignmentRole)
|
||||
return static_cast<int>(Qt::AlignCenter); // 单元内容水平+垂直居中
|
||||
if (role == Qt::DisplayRole) return payload_.rows[r][c];
|
||||
return {};
|
||||
}
|
||||
|
||||
QVariant TablePayloadModel::headerData(int section, Qt::Orientation orientation, int role) const {
|
||||
if (role != Qt::DisplayRole) return {};
|
||||
if (orientation == Qt::Horizontal) {
|
||||
if (section < 0 || section >= static_cast<int>(payload_.columns.size())) return {};
|
||||
return payload_.columns[static_cast<size_t>(section)].title;
|
||||
}
|
||||
return section + 1; // 行号
|
||||
}
|
||||
|
||||
geopro::core::TableColumnKind TablePayloadModel::columnKind(int column) const {
|
||||
if (column < 0 || column >= static_cast<int>(payload_.columns.size()))
|
||||
return geopro::core::TableColumnKind::Text;
|
||||
return payload_.columns[static_cast<size_t>(column)].kind;
|
||||
}
|
||||
|
||||
ToggleSwitchDelegate::ToggleSwitchDelegate(const TablePayloadModel* model, QObject* parent)
|
||||
: QStyledItemDelegate(parent), model_(model) {}
|
||||
|
||||
void ToggleSwitchDelegate::paint(QPainter* painter, const QStyleOptionViewItem& option,
|
||||
const QModelIndex& index) const {
|
||||
if (!model_ || model_->columnKind(index.column()) != geopro::core::TableColumnKind::Toggle) {
|
||||
QStyledItemDelegate::paint(painter, option, index);
|
||||
return;
|
||||
}
|
||||
// 画背景(选中态/交替行),但不画文本。
|
||||
QStyleOptionViewItem opt = option;
|
||||
initStyleOption(&opt, index);
|
||||
opt.text.clear();
|
||||
if (const QWidget* w = opt.widget)
|
||||
w->style()->drawControl(QStyle::CE_ItemViewItem, &opt, painter, w);
|
||||
|
||||
const bool on = index.data(Qt::DisplayRole).toString() == QLatin1String("1");
|
||||
|
||||
// 居中药丸开关。
|
||||
const QRect cell = option.rect;
|
||||
QRectF track(cell.center().x() - kSwitchW / 2.0, cell.center().y() - kSwitchH / 2.0,
|
||||
kSwitchW, kSwitchH);
|
||||
painter->save();
|
||||
painter->setRenderHint(QPainter::Antialiasing, true);
|
||||
painter->setPen(Qt::NoPen);
|
||||
painter->setBrush(on ? kSwitchOn : kSwitchOff);
|
||||
painter->drawRoundedRect(track, kSwitchH / 2.0, kSwitchH / 2.0);
|
||||
// 白色旋钮(ON 靠右,OFF 靠左)。
|
||||
const double d = kSwitchH - 2.0 * kKnobMargin;
|
||||
const double knobX = on ? track.right() - kKnobMargin - d : track.left() + kKnobMargin;
|
||||
painter->setBrush(Qt::white);
|
||||
painter->drawEllipse(QRectF(knobX, track.top() + kKnobMargin, d, d));
|
||||
painter->restore();
|
||||
}
|
||||
|
||||
QSize ToggleSwitchDelegate::sizeHint(const QStyleOptionViewItem& option,
|
||||
const QModelIndex& index) const {
|
||||
if (!model_ || model_->columnKind(index.column()) != geopro::core::TableColumnKind::Toggle)
|
||||
return QStyledItemDelegate::sizeHint(option, index);
|
||||
// 贴合开关的窄宽(+两侧内边距),高度沿用默认行高,避免 ResizeToContents 撑宽该列。
|
||||
return QSize(kToggleColW, kSwitchH + 6);
|
||||
}
|
||||
|
||||
DataTableView::DataTableView(QWidget* parent) : QWidget(parent) {
|
||||
auto* lay = new QVBoxLayout(this);
|
||||
lay->setContentsMargins(0, 0, 0, 0);
|
||||
lay->setSpacing(0);
|
||||
|
||||
model_ = new TablePayloadModel(this);
|
||||
table_ = new QTableView(this);
|
||||
table_->setModel(model_);
|
||||
table_->setAlternatingRowColors(true);
|
||||
table_->setEditTriggers(QAbstractItemView::NoEditTriggers);
|
||||
table_->setSelectionBehavior(QAbstractItemView::SelectRows);
|
||||
// 列宽策略在 setPayload 按列种类设置(Toggle 列固定窄宽;文本列均分 Stretch)。
|
||||
// 不强制拉伸末列——末列通常是窄的 Toggle 列,拉伸会把它撑得过宽。
|
||||
table_->horizontalHeader()->setStretchLastSection(false);
|
||||
table_->horizontalHeader()->setDefaultAlignment(Qt::AlignCenter); // 表头文本居中
|
||||
table_->verticalHeader()->setDefaultSectionSize(24);
|
||||
table_->verticalHeader()->setVisible(false); // 原版无行号列
|
||||
|
||||
// Toggle 列委托:把“隐藏/显示”列画成蓝色药丸开关。
|
||||
table_->setItemDelegate(new ToggleSwitchDelegate(model_, table_));
|
||||
|
||||
lay->addWidget(table_);
|
||||
|
||||
// 分页器(默认隐藏;分页型载荷 pageSize>0 时显示)。转发翻页请求给壳。
|
||||
pager_ = new TablePager(this);
|
||||
pager_->hide();
|
||||
connect(pager_, &TablePager::pageRequested, this, &DataTableView::pageRequested);
|
||||
lay->addWidget(pager_);
|
||||
}
|
||||
|
||||
void DataTableView::setPayload(const QVariant& payload) {
|
||||
if (!payload.canConvert<geopro::core::TablePayload>()) return; // 坏/空 → 空态
|
||||
const auto t = payload.value<geopro::core::TablePayload>();
|
||||
model_->setPayload(t);
|
||||
|
||||
// 列宽策略:文本列等宽均分,Toggle 列窄。
|
||||
// Toggle 列 → Fixed 固定窄宽(贴合开关 + 留白),开关居中。
|
||||
// 其余文本列 → Stretch(均分可用宽度,整体均衡,无单一巨列、无右侧空白)。
|
||||
auto* header = table_->horizontalHeader();
|
||||
for (size_t i = 0; i < t.columns.size(); ++i) {
|
||||
const int col = static_cast<int>(i);
|
||||
if (t.columns[i].kind == geopro::core::TableColumnKind::Toggle) {
|
||||
header->setSectionResizeMode(col, QHeaderView::Fixed);
|
||||
table_->setColumnWidth(col, kToggleColW);
|
||||
} else {
|
||||
header->setSectionResizeMode(col, QHeaderView::Stretch);
|
||||
}
|
||||
}
|
||||
|
||||
// 分页器:分页型载荷(pageSize>0,dd_grid)显示并同步状态;否则隐藏(全量列表)。
|
||||
if (t.pageSize > 0) {
|
||||
pager_->setState(t.total, t.pageNo, t.pageSize);
|
||||
pager_->show();
|
||||
} else {
|
||||
pager_->hide();
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace geopro::app
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
#pragma once
|
||||
#include <QAbstractTableModel>
|
||||
#include <QStyledItemDelegate>
|
||||
#include <QWidget>
|
||||
#include "model/detail/DetailPayloads.hpp"
|
||||
#include "panels/chart/IDetailView.hpp"
|
||||
|
||||
class QTableView;
|
||||
|
||||
namespace geopro::app {
|
||||
|
||||
// TablePayload 驱动的只读表模型(列标题来自 TableColumn,单元为预格式化 QString)。
|
||||
class TablePayloadModel : public QAbstractTableModel {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit TablePayloadModel(QObject* parent = nullptr);
|
||||
void setPayload(const geopro::core::TablePayload& payload);
|
||||
|
||||
int rowCount(const QModelIndex& parent = QModelIndex()) const override;
|
||||
int columnCount(const QModelIndex& parent = QModelIndex()) const override;
|
||||
QVariant data(const QModelIndex& index, int role) const override;
|
||||
QVariant headerData(int section, Qt::Orientation orientation, int role) const override;
|
||||
|
||||
// 列渲染种类(供委托判断是否画开关)。越界返回 Text。
|
||||
geopro::core::TableColumnKind columnKind(int column) const;
|
||||
|
||||
private:
|
||||
geopro::core::TablePayload payload_;
|
||||
};
|
||||
|
||||
// Toggle 列委托:把单元值("1"=ON/"0"=OFF)画成蓝色药丸开关(仅展示状态,暂不联动散点)。
|
||||
// Text 列回退到默认绘制。
|
||||
class ToggleSwitchDelegate : public QStyledItemDelegate {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit ToggleSwitchDelegate(const TablePayloadModel* model, QObject* parent = nullptr);
|
||||
void paint(QPainter* painter, const QStyleOptionViewItem& option,
|
||||
const QModelIndex& index) const override;
|
||||
// Toggle 列返回贴合开关的窄尺寸(供 ResizeToContents 不至于撑宽);其余回退默认。
|
||||
QSize sizeHint(const QStyleOptionViewItem& option, const QModelIndex& index) const override;
|
||||
|
||||
private:
|
||||
const TablePayloadModel* model_; // 不拥有
|
||||
};
|
||||
|
||||
class TablePager;
|
||||
|
||||
// 通用数据列表视图:IDetailView + QTableView(+ 分页型载荷时底部 TablePager 分页器)。
|
||||
// measurement/grid/trajectory 列表共用。载荷 pageSize>0(dd_grid)时显示分页器并转发翻页请求;
|
||||
// 否则隐藏分页器(全量列表)。
|
||||
class DataTableView : public QWidget, public IDetailView {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit DataTableView(QWidget* parent = nullptr);
|
||||
|
||||
QWidget* widget() override { return this; }
|
||||
void setPayload(const QVariant& payload) override; // 坏/空 variant → 保持空态不崩
|
||||
|
||||
signals:
|
||||
// 分页器请求加载某页(翻页/跳页/改每页条数)。壳据此触发控制器 loadTabPaged。
|
||||
void pageRequested(int pageNo, int pageSize);
|
||||
|
||||
private:
|
||||
QTableView* table_;
|
||||
TablePayloadModel* model_;
|
||||
TablePager* pager_; // 分页器(pageSize>0 时显示,否则隐藏)
|
||||
};
|
||||
|
||||
} // namespace geopro::app
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
#include "panels/chart/DetailViewFactory.hpp"
|
||||
|
||||
#include <stdexcept>
|
||||
|
||||
#include "panels/chart/BarChartView.hpp"
|
||||
#include "panels/chart/DataTableView.hpp"
|
||||
#include "panels/chart/GridDataChartView.hpp"
|
||||
#include "panels/chart/LineChartView.hpp"
|
||||
#include "panels/chart/RawDataChartView.hpp"
|
||||
#include "panels/chart/TrajectoryMapView.hpp"
|
||||
|
||||
namespace geopro::app {
|
||||
|
||||
std::unique_ptr<IDetailView> makeDetailView(controller::ViewKind kind, QWidget* parent) {
|
||||
switch (kind) {
|
||||
case controller::ViewKind::Scatter:
|
||||
return std::unique_ptr<IDetailView>(new RawDataChartView(parent));
|
||||
case controller::ViewKind::FilledContour:
|
||||
return std::unique_ptr<IDetailView>(new GridDataChartView(parent));
|
||||
case controller::ViewKind::Table:
|
||||
return std::unique_ptr<IDetailView>(new DataTableView(parent));
|
||||
case controller::ViewKind::Bar:
|
||||
return std::unique_ptr<IDetailView>(new BarChartView(parent));
|
||||
case controller::ViewKind::LineProfile:
|
||||
return std::unique_ptr<IDetailView>(new LineChartView(parent));
|
||||
case controller::ViewKind::WebMap:
|
||||
// trajectory 地图:QWebEngineView + Leaflet + 天地图瓦片,电极经纬点画橙色空心圈并取景。
|
||||
return std::unique_ptr<IDetailView>(new TrajectoryMapView(parent));
|
||||
case controller::ViewKind::PolylineMap:
|
||||
// 后续阶段补:PolylineMap。
|
||||
throw std::runtime_error("makeDetailView: ViewKind not yet implemented");
|
||||
}
|
||||
throw std::runtime_error("makeDetailView: unknown ViewKind");
|
||||
}
|
||||
|
||||
} // namespace geopro::app
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
#pragma once
|
||||
#include <memory>
|
||||
#include "DatasetDetailTab.hpp" // geopro::controller::ViewKind
|
||||
|
||||
class QWidget;
|
||||
|
||||
namespace geopro::app {
|
||||
|
||||
class IDetailView;
|
||||
|
||||
// 按 render kind 造详情视图。E1b 仅支持 Scatter / FilledContour(反演两页签);
|
||||
// Bar/LineProfile/PolylineMap/Table 留待后续阶段(measurement/trajectory/grid)补,
|
||||
// 现阶段命中会抛 std::runtime_error(明确失败,而非静默空指针)。
|
||||
std::unique_ptr<IDetailView> makeDetailView(controller::ViewKind kind, QWidget* parent);
|
||||
|
||||
} // namespace geopro::app
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
#pragma once
|
||||
#include <vector>
|
||||
#include "IDatasetChartStrategy.hpp" // geopro::controller(geopro_controller PUBLIC include)
|
||||
namespace geopro::app {
|
||||
// ERT 反演策略:散点(原数据,同步) + 网格等值面(网格数据,懒加载) 两页签。
|
||||
struct ErtInversionStrategy : controller::IDatasetChartStrategy {
|
||||
std::string ddCode() const override { return "dd_inversion_data"; }
|
||||
std::vector<controller::TabSpec> tabs() const override {
|
||||
return {
|
||||
{QStringLiteral("原数据"), controller::ViewKind::Scatter,
|
||||
QStringLiteral("inversion.scatter"), /*lazy*/ false, /*paginated*/ false},
|
||||
{QStringLiteral("网格数据"), controller::ViewKind::FilledContour,
|
||||
QStringLiteral("inversion.grid"), /*lazy*/ true, /*paginated*/ false},
|
||||
};
|
||||
}
|
||||
};
|
||||
} // namespace geopro::app
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
#pragma once
|
||||
#include <vector>
|
||||
#include "IDatasetChartStrategy.hpp" // geopro::controller
|
||||
namespace geopro::app {
|
||||
|
||||
// ERT 接地电阻(measurement gr_data)策略:柱状图(同步)+ 列表(懒加载)两页签。
|
||||
// 两页签同一端点 measurement/gr/rows,loaderKey 不同(gr.bar 产 BarPayload / gr.rows 产 TablePayload)。
|
||||
struct GrMeasurementStrategy : controller::IDatasetChartStrategy {
|
||||
std::string ddCode() const override { return "dd_ert_measurement_gr_data"; }
|
||||
std::vector<controller::TabSpec> tabs() const override {
|
||||
return {
|
||||
{QStringLiteral("柱状图"), controller::ViewKind::Bar,
|
||||
QStringLiteral("gr.bar"), /*lazy*/ false, /*paginated*/ false},
|
||||
{QStringLiteral("列表"), controller::ViewKind::Table,
|
||||
QStringLiteral("gr.rows"), /*lazy*/ true, /*paginated*/ false},
|
||||
};
|
||||
}
|
||||
};
|
||||
} // namespace geopro::app
|
||||
|
|
@ -0,0 +1,235 @@
|
|||
#include "panels/chart/GridDataChartView.hpp"
|
||||
|
||||
#include <QCheckBox>
|
||||
#include <QHBoxLayout>
|
||||
#include <QLabel>
|
||||
#include <QSlider>
|
||||
#include <QToolButton>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
#include <qwt_plot.h>
|
||||
#include <qwt_plot_grid.h>
|
||||
#include <QSplitter>
|
||||
#include <QScrollArea>
|
||||
#include <QFrame>
|
||||
#include <qwt_plot_rescaler.h>
|
||||
|
||||
#include "PanelHeader.hpp"
|
||||
#include "Theme.hpp"
|
||||
#include "panels/AnomalyTablePanel.hpp"
|
||||
#include "panels/DescriptionPanel.hpp"
|
||||
#include "panels/chart/ChartTheme.hpp"
|
||||
#include "panels/chart/ColorBarWidget.hpp"
|
||||
#include "panels/chart/ColorMapService.hpp"
|
||||
#include "panels/chart/ContourPlotItem.hpp"
|
||||
#include "panels/chart/LivePanner.hpp"
|
||||
|
||||
namespace geopro::app {
|
||||
|
||||
GridDataChartView::GridDataChartView(QWidget* parent) : QWidget(parent) {
|
||||
auto* lay = new QVBoxLayout(this);
|
||||
lay->setContentsMargins(0, 0, 0, 0);
|
||||
lay->setSpacing(0);
|
||||
|
||||
// ---- 工具条 ----
|
||||
auto* toolbar = new QWidget(this);
|
||||
auto* tbLay = new QHBoxLayout(toolbar);
|
||||
tbLay->setContentsMargins(4, 4, 4, 4);
|
||||
tbLay->setSpacing(4);
|
||||
|
||||
auto* btnGrid = new QToolButton(toolbar);
|
||||
btnGrid->setText(QStringLiteral("网格"));
|
||||
|
||||
auto* btnColorScale = new QToolButton(toolbar);
|
||||
btnColorScale->setText(QStringLiteral("色阶配置"));
|
||||
|
||||
auto* btnWhiten = new QToolButton(toolbar);
|
||||
btnWhiten->setText(QStringLiteral("白化"));
|
||||
|
||||
auto* btnFilter = new QToolButton(toolbar);
|
||||
btnFilter->setText(QStringLiteral("滤波处理"));
|
||||
|
||||
auto* chkShowAnom = new QCheckBox(QStringLiteral("显示异常"), toolbar);
|
||||
chkShowAnom->setChecked(true);
|
||||
|
||||
auto* chkShowContourLabel = new QCheckBox(QStringLiteral("显示等值线标注"), toolbar);
|
||||
chkShowContourLabel->setChecked(true);
|
||||
|
||||
auto* chkContourTip = new QCheckBox(QStringLiteral("显示等值线提示信息"), toolbar);
|
||||
chkContourTip->setChecked(false);
|
||||
|
||||
auto* lblSimplify = new QLabel(QStringLiteral("简化容差:"), toolbar);
|
||||
|
||||
simplifySlider_ = new QSlider(Qt::Horizontal, toolbar);
|
||||
simplifySlider_->setRange(0, 100);
|
||||
simplifySlider_->setValue(50);
|
||||
simplifySlider_->setFixedWidth(80);
|
||||
|
||||
simplifyValueLabel_ = new QLabel(QStringLiteral("0.5"), toolbar);
|
||||
simplifyValueLabel_->setFixedWidth(28);
|
||||
|
||||
connect(simplifySlider_, &QSlider::valueChanged, this, [this](int v) {
|
||||
simplifyValueLabel_->setText(QString::number(v / 100.0, 'f', 1));
|
||||
});
|
||||
|
||||
auto* btnAnomalyLabel = new QToolButton(toolbar);
|
||||
btnAnomalyLabel->setText(QStringLiteral("异常标注"));
|
||||
|
||||
auto* btnAutoLabel = new QToolButton(toolbar);
|
||||
btnAutoLabel->setText(QStringLiteral("自动标注"));
|
||||
|
||||
auto* btnSaveAs = new QToolButton(toolbar);
|
||||
btnSaveAs->setText(QStringLiteral("另存为"));
|
||||
|
||||
tbLay->addWidget(btnGrid);
|
||||
tbLay->addWidget(btnColorScale);
|
||||
tbLay->addWidget(btnWhiten);
|
||||
tbLay->addWidget(btnFilter);
|
||||
tbLay->addWidget(chkShowAnom);
|
||||
tbLay->addWidget(chkShowContourLabel);
|
||||
tbLay->addWidget(chkContourTip);
|
||||
tbLay->addWidget(lblSimplify);
|
||||
tbLay->addWidget(simplifySlider_);
|
||||
tbLay->addWidget(simplifyValueLabel_);
|
||||
tbLay->addWidget(btnAnomalyLabel);
|
||||
tbLay->addWidget(btnAutoLabel);
|
||||
tbLay->addWidget(btnSaveAs);
|
||||
tbLay->addStretch();
|
||||
|
||||
lay->addWidget(toolbar);
|
||||
|
||||
// ---- QwtPlot(仿 RawDataChartView,差异:x 轴在底部、不画过原点零线、网格线被填充覆盖故省)----
|
||||
plot_ = new QwtPlot(this);
|
||||
plot_->setObjectName(QStringLiteral("gridPlotArea"));
|
||||
plot_->enableAxis(QwtPlot::xBottom, true);
|
||||
plot_->enableAxis(QwtPlot::xTop, false);
|
||||
plot_->enableAxis(QwtPlot::yLeft, true);
|
||||
|
||||
// 底色/轴字由 applyChartPlotTheme 按主题设置(ctor 末尾 + 主题热切换):
|
||||
// 浅色=原版白底深灰字(1:1),暗色=深色画布避免刺眼白底。
|
||||
|
||||
// 交互:LivePanner 统一左键实时平移 + 滚轮缩放(消费滚轮事件,不冒泡触发滚动条)。
|
||||
new LivePanner(plot_, QwtPlot::xBottom, QwtPlot::yLeft, this);
|
||||
|
||||
plot_->setMinimumSize(0, 0);
|
||||
|
||||
// 锁定 x:y 真实比尺(参考轴 xBottom,Expanding)。
|
||||
rescaler_ = new QwtPlotRescaler(plot_->canvas(), QwtPlot::xBottom, QwtPlotRescaler::Expanding);
|
||||
rescaler_->setAspectRatio(1.0);
|
||||
rescaler_->setEnabled(true);
|
||||
|
||||
// 图表区 = 绘图 + 色阶条(色阶条固定高 36)。
|
||||
colorBar_ = new ColorBarWidget(this);
|
||||
colorBar_->setObjectName(QStringLiteral("gridColorScaleBar"));
|
||||
auto* chartArea = new QWidget(this);
|
||||
auto* chartLay = new QVBoxLayout(chartArea);
|
||||
chartLay->setContentsMargins(0, 0, 0, 0);
|
||||
chartLay->setSpacing(0);
|
||||
chartLay->addWidget(plot_, 1);
|
||||
chartLay->addWidget(colorBar_);
|
||||
|
||||
// ---- 底部双页签(异常列表 / 描述)----
|
||||
anomalyTable_ = new AnomalyTablePanel(this);
|
||||
descriptionPanel_ = new DescriptionPanel(this);
|
||||
const QVector<PanelTab> bottomTabs = {
|
||||
{Glyph::Anomaly, QStringLiteral("异常列表"), anomalyTable_, true},
|
||||
{Glyph::Property, QStringLiteral("描述"), descriptionPanel_, false},
|
||||
};
|
||||
auto tabbedBottom = buildTabbedPanel(bottomTabs, {});
|
||||
|
||||
// 图表区 | 底部表 竖直 QSplitter(可拖拽调整比例)。给定最小高度→分割器有最小尺寸。
|
||||
chartArea->setMinimumHeight(280);
|
||||
tabbedBottom.container->setMinimumHeight(160);
|
||||
auto* splitter = new QSplitter(Qt::Vertical);
|
||||
splitter->addWidget(chartArea);
|
||||
splitter->addWidget(tabbedBottom.container);
|
||||
splitter->setStretchFactor(0, 3);
|
||||
splitter->setStretchFactor(1, 1);
|
||||
splitter->setChildrenCollapsible(false);
|
||||
|
||||
// 页签内滚动:把(图表+异常)分割器放进 QScrollArea。dock 够高→分割器填满(可拖动调整);
|
||||
// dock 太矮→在页签内部出现竖滚动条(工具条/页签/标题固定),而非整个面板滚动。
|
||||
auto* scroll = new QScrollArea(this);
|
||||
scroll->setWidgetResizable(true);
|
||||
scroll->setFrameShape(QFrame::NoFrame);
|
||||
scroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
|
||||
scroll->setWidget(splitter);
|
||||
lay->addWidget(scroll, 1);
|
||||
|
||||
// ---- 工具条开关 → 重建/重绘 ----
|
||||
connect(chkShowAnom, &QCheckBox::toggled, this, [this](bool on) {
|
||||
showAnomalies_ = on;
|
||||
if (contourItem_) { contourItem_->setShowAnomalies(on); plot_->replot(); }
|
||||
});
|
||||
connect(chkShowContourLabel, &QCheckBox::toggled, this, [this](bool on) {
|
||||
showLabels_ = on;
|
||||
if (contourItem_) { contourItem_->setShowLabels(on); plot_->replot(); }
|
||||
});
|
||||
|
||||
// 主题配色:当前主题套一次 + 监听切换热更新。
|
||||
applyChartPlotTheme(plot_);
|
||||
QObject::connect(&ThemeManager::instance(), &ThemeManager::changed, plot_,
|
||||
[this]() { applyChartPlotTheme(plot_); });
|
||||
}
|
||||
|
||||
GridDataChartView::~GridDataChartView() {
|
||||
// colorSvc_ 非 QObject、无 parent,需手动释放(contourItem_ 由 QwtPlot autoDelete 处理)。
|
||||
delete colorSvc_;
|
||||
}
|
||||
|
||||
void GridDataChartView::setPayload(const QVariant& payload) {
|
||||
if (!payload.canConvert<geopro::core::ContourPayload>()) {
|
||||
// 坏/空 variant:保持空态(不渲染、不崩)。
|
||||
return;
|
||||
}
|
||||
const auto p = payload.value<geopro::core::ContourPayload>();
|
||||
setGridData(p.grid, p.scale, p.anomalies);
|
||||
}
|
||||
|
||||
void GridDataChartView::setGridData(const geopro::core::Grid& grid,
|
||||
const geopro::core::ColorScale& gridScale,
|
||||
const std::vector<geopro::core::Anomaly>& anoms) {
|
||||
grid_ = grid;
|
||||
gridScale_ = gridScale;
|
||||
anoms_ = anoms;
|
||||
hasGrid_ = true;
|
||||
|
||||
// 重建色彩服务(旧 contourItem 已 detach/delete)。
|
||||
delete colorSvc_;
|
||||
colorSvc_ = new ColorMapService(gridScale_);
|
||||
|
||||
rebuildContour();
|
||||
|
||||
// 色阶条 + 底部异常表(懒加载结果含真实异常)。
|
||||
colorBar_->setColorScale(gridScale_);
|
||||
anomalyTable_->setAnomalies(anoms_, {}, {});
|
||||
}
|
||||
|
||||
void GridDataChartView::rebuildContour() {
|
||||
if (!hasGrid_ || !colorSvc_) return;
|
||||
|
||||
// 卸载旧项:QwtPlot 默认 autoDelete=true(析构时 delete 仍在 dict 的 item)。
|
||||
// 必须先 detach()(从 dict 移除)再 delete,否则 QwtPlot 析构时会 double-free。
|
||||
if (contourItem_) {
|
||||
contourItem_->detach();
|
||||
delete contourItem_;
|
||||
contourItem_ = nullptr;
|
||||
}
|
||||
|
||||
contourItem_ = new ContourPlotItem();
|
||||
contourItem_->setData(grid_, colorSvc_, anoms_, /*showLines*/ true, showLabels_);
|
||||
contourItem_->setShowAnomalies(showAnomalies_);
|
||||
contourItem_->attach(plot_);
|
||||
|
||||
// 轴范围 = 数据范围(x=距离、y=深度/高程)。
|
||||
const QRectF bbox = contourItem_->boundingRect();
|
||||
if (!bbox.isNull()) {
|
||||
plot_->setAxisScale(QwtPlot::xBottom, bbox.left(), bbox.right());
|
||||
plot_->setAxisScale(QwtPlot::yLeft, bbox.top(), bbox.bottom());
|
||||
}
|
||||
plot_->updateAxes();
|
||||
if (rescaler_) rescaler_->rescale(); // 应用真实比尺
|
||||
plot_->replot();
|
||||
}
|
||||
|
||||
} // namespace geopro::app
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
#pragma once
|
||||
#include <vector>
|
||||
|
||||
#include <QWidget>
|
||||
|
||||
#include "model/Anomaly.hpp"
|
||||
#include "model/ColorScale.hpp"
|
||||
#include "model/Field.hpp"
|
||||
#include "model/detail/DetailPayloads.hpp"
|
||||
#include "panels/chart/IDetailView.hpp"
|
||||
|
||||
class QSlider;
|
||||
class QLabel;
|
||||
class QwtPlot;
|
||||
class QwtPlotRescaler;
|
||||
|
||||
namespace geopro::app {
|
||||
|
||||
class AnomalyTablePanel;
|
||||
class DescriptionPanel;
|
||||
class ColorBarWidget;
|
||||
class ColorMapService;
|
||||
class ContourPlotItem;
|
||||
|
||||
// 网格数据图表视图:工具条 + QwtPlot(白底 + 真实比尺 + 实时平移/滚轮缩放,x 轴在底部)
|
||||
// + 独立色阶条 + 底部双页签(异常列表/描述)。
|
||||
// 填充走 ContourPlotItem 栅格热力图 + 矢量等值线 + 标注 + 异常叠加。
|
||||
class GridDataChartView : public QWidget, public IDetailView {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit GridDataChartView(QWidget* parent = nullptr);
|
||||
~GridDataChartView() override;
|
||||
|
||||
// 网格数据到达(懒加载结果):建色彩服务 + ContourPlotItem,挂到 QwtPlot,更新色阶条/异常表。
|
||||
void setGridData(const geopro::core::Grid& grid, const geopro::core::ColorScale& gridScale,
|
||||
const std::vector<geopro::core::Anomaly>& anoms);
|
||||
|
||||
// IDetailView:解包 ContourPayload(grid + 色阶 + 异常)→ setGridData。坏/空 variant 保持空态。
|
||||
QWidget* widget() override { return this; }
|
||||
void setPayload(const QVariant& payload) override;
|
||||
|
||||
private:
|
||||
void rebuildContour(); // 按当前显隐开关重建并重绘 ContourPlotItem
|
||||
|
||||
QwtPlot* plot_ = nullptr;
|
||||
QwtPlotRescaler* rescaler_ = nullptr;
|
||||
ColorBarWidget* colorBar_ = nullptr;
|
||||
AnomalyTablePanel* anomalyTable_ = nullptr;
|
||||
DescriptionPanel* descriptionPanel_ = nullptr;
|
||||
QSlider* simplifySlider_ = nullptr;
|
||||
QLabel* simplifyValueLabel_ = nullptr;
|
||||
|
||||
// 渲染状态
|
||||
ColorMapService* colorSvc_ = nullptr; // heap,setGridData 重建
|
||||
ContourPlotItem* contourItem_ = nullptr;
|
||||
geopro::core::Grid grid_{1, 1};
|
||||
geopro::core::ColorScale gridScale_;
|
||||
std::vector<geopro::core::Anomaly> anoms_;
|
||||
bool hasGrid_ = false;
|
||||
|
||||
// 工具条显隐开关
|
||||
bool showAnomalies_ = true;
|
||||
bool showLabels_ = true;
|
||||
};
|
||||
|
||||
} // namespace geopro::app
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
#pragma once
|
||||
#include <vector>
|
||||
#include "IDatasetChartStrategy.hpp" // geopro::controller
|
||||
namespace geopro::app {
|
||||
|
||||
// dd_grid(白化数据)策略:单「列表」页签,服务端分页(vxe-pager)。
|
||||
// 列表 = Table(paginated;grid.rows 产 TablePayload:序号/x/y 列 + total/pageNo/pageSize;
|
||||
// 端点 dd/ert/grid/rows)。非 lazy(单页签,开页即载首页)。
|
||||
struct GridStrategy : controller::IDatasetChartStrategy {
|
||||
std::string ddCode() const override { return "dd_grid"; }
|
||||
std::vector<controller::TabSpec> tabs() const override {
|
||||
return {
|
||||
{QStringLiteral("列表"), controller::ViewKind::Table,
|
||||
QStringLiteral("grid.rows"), /*lazy*/ false, /*paginated*/ true},
|
||||
};
|
||||
}
|
||||
};
|
||||
} // namespace geopro::app
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
#pragma once
|
||||
#include <QVariant>
|
||||
|
||||
class QWidget;
|
||||
|
||||
namespace geopro::app {
|
||||
|
||||
// 详情页签视图统一接口:壳/工厂只依赖它,渲染细节由具体视图自行解包载荷。
|
||||
// widget() 返回承载的 QWidget(多为 this);setPayload 解包 QVariant 载荷并渲染。
|
||||
class IDetailView {
|
||||
public:
|
||||
virtual ~IDetailView() = default;
|
||||
virtual QWidget* widget() = 0;
|
||||
virtual void setPayload(const QVariant& payload) = 0;
|
||||
};
|
||||
|
||||
} // namespace geopro::app
|
||||
|
|
@ -0,0 +1,380 @@
|
|||
#include "panels/chart/LineChartView.hpp"
|
||||
|
||||
#include <QBrush>
|
||||
#include <QEvent>
|
||||
#include <QGuiApplication>
|
||||
#include <QHBoxLayout>
|
||||
#include <QLabel>
|
||||
#include <QMouseEvent>
|
||||
#include <QPainter>
|
||||
#include <QPalette>
|
||||
#include <QPen>
|
||||
#include <QScreen>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
#include <qwt_plot.h>
|
||||
#include <qwt_plot_canvas.h>
|
||||
#include <qwt_plot_curve.h>
|
||||
#include <qwt_plot_grid.h>
|
||||
#include <qwt_scale_draw.h>
|
||||
#include <qwt_scale_map.h>
|
||||
#include <qwt_spline_local.h>
|
||||
#include <qwt_spline_parametrization.h>
|
||||
#include <qwt_text.h>
|
||||
#include <qwt_widget_overlay.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <limits>
|
||||
|
||||
#include "Theme.hpp"
|
||||
#include "panels/chart/ChartTheme.hpp"
|
||||
|
||||
namespace geopro::app {
|
||||
|
||||
namespace {
|
||||
|
||||
// 类目轴刻度:把整数刻度位(0,1,2,…)映射为类目标签 "#1","#2",…(来自 categories)。
|
||||
// 非整数/越界刻度返回空标签(避免次刻度污染)。与 BarChartView::CategoryScaleDraw 同款。
|
||||
class CategoryScaleDraw : public QwtScaleDraw {
|
||||
public:
|
||||
explicit CategoryScaleDraw(std::vector<QString> labels) : labels_(std::move(labels)) {
|
||||
enableComponent(QwtScaleDraw::Backbone, true);
|
||||
enableComponent(QwtScaleDraw::Ticks, true);
|
||||
}
|
||||
QwtText label(double v) const override {
|
||||
const double r = std::round(v);
|
||||
if (std::abs(v - r) > 1e-6) return QwtText(); // 仅整数刻度出标签
|
||||
const int i = static_cast<int>(r);
|
||||
if (i < 0 || i >= static_cast<int>(labels_.size())) return QwtText();
|
||||
return labels_[static_cast<size_t>(i)];
|
||||
}
|
||||
|
||||
private:
|
||||
std::vector<QString> labels_;
|
||||
};
|
||||
|
||||
QColor lineColor(const QString& hex) {
|
||||
QColor c(hex);
|
||||
return c.isValid() ? c : QColor(0x54, 0x70, 0xc6); // 回退 ECharts 蓝
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
LineChartView::LineChartView(QWidget* parent) : QWidget(parent) {
|
||||
auto* lay = new QVBoxLayout(this);
|
||||
lay->setContentsMargins(0, 0, 0, 0);
|
||||
lay->setSpacing(0);
|
||||
|
||||
// 左上水平 y 轴标题(ECharts 风格,对齐 BarChartView)。
|
||||
yTitle_ = new QLabel(this);
|
||||
auto* titleRow = new QWidget(this);
|
||||
auto* titleLay = new QHBoxLayout(titleRow);
|
||||
titleLay->setContentsMargins(48, 6, 8, 0); // 左缩进对齐 y 轴上方
|
||||
titleLay->setSpacing(0);
|
||||
titleLay->addWidget(yTitle_);
|
||||
titleLay->addStretch();
|
||||
lay->addWidget(titleRow);
|
||||
|
||||
plot_ = new QwtPlot(this);
|
||||
plot_->setObjectName(QStringLiteral("trajLinePlotArea"));
|
||||
plot_->enableAxis(QwtPlot::xBottom, true);
|
||||
plot_->enableAxis(QwtPlot::yLeft, true);
|
||||
|
||||
// x 轴标题「电极号」底部居中(setData 设文本)。
|
||||
plot_->setAxisTitle(QwtPlot::xBottom, QwtText());
|
||||
|
||||
// 仅横向(y)网格,弱化(与原版 ECharts 一致:仅水平刻度线)。
|
||||
auto* grid = new QwtPlotGrid();
|
||||
grid->enableX(false);
|
||||
grid->enableY(true);
|
||||
grid->enableXMin(false);
|
||||
grid->enableYMin(false);
|
||||
grid->setMajorPen(QColor(225, 225, 225), 1.0, Qt::SolidLine);
|
||||
grid->attach(plot_);
|
||||
|
||||
plot_->setMinimumSize(0, 0);
|
||||
lay->addWidget(plot_, 1);
|
||||
|
||||
// 鼠标 hover 追踪:虚线参考线 + 实心点 + 浮动框(data_ 地址稳定,setData 只改其内容)。
|
||||
hover_ = new LineHoverTip(plot_, &data_, this);
|
||||
|
||||
// 主题:底色/轴字/网格按当前主题套一次 + 热切换。
|
||||
applyChartPlotTheme(plot_);
|
||||
QObject::connect(&ThemeManager::instance(), &ThemeManager::changed, plot_, [this]() {
|
||||
applyChartPlotTheme(plot_);
|
||||
QPalette pal = yTitle_->palette();
|
||||
pal.setColor(QPalette::WindowText,
|
||||
isDarkTheme() ? tokenColor("text/secondary") : QColor(90, 90, 90));
|
||||
yTitle_->setPalette(pal);
|
||||
});
|
||||
}
|
||||
|
||||
LineChartView::~LineChartView() {
|
||||
// 卸载并删除已挂折线,先于 QwtPlot autoDelete 触发(与 BarChartView 析构对称,避免双删)。
|
||||
clearCurve();
|
||||
}
|
||||
|
||||
void LineChartView::clearCurve() {
|
||||
if (curve_) {
|
||||
curve_->detach();
|
||||
delete curve_;
|
||||
curve_ = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
void LineChartView::setPayload(const QVariant& payload) {
|
||||
if (!payload.canConvert<geopro::core::LinePayload>()) return; // 坏/空 → 空态
|
||||
setData(payload.value<geopro::core::LinePayload>());
|
||||
}
|
||||
|
||||
void LineChartView::setData(const geopro::core::LinePayload& p) {
|
||||
data_ = p;
|
||||
clearCurve();
|
||||
|
||||
const int n = static_cast<int>(p.categories.size());
|
||||
|
||||
// y 轴标题(左上水平 QLabel)。
|
||||
yTitle_->setText(p.yTitle);
|
||||
QPalette tpal = yTitle_->palette();
|
||||
tpal.setColor(QPalette::WindowText,
|
||||
isDarkTheme() ? tokenColor("text/secondary") : QColor(90, 90, 90));
|
||||
yTitle_->setPalette(tpal);
|
||||
|
||||
// 锁定两轴为固定刻度(关闭自动缩放),使 hover 时挂/卸标记的 replot 不会重新拟合 →
|
||||
// 曲线不再随鼠标进入而上下漂移(FIX 3)。
|
||||
plot_->setAxisAutoScale(QwtPlot::xBottom, false);
|
||||
plot_->setAxisAutoScale(QwtPlot::yLeft, false);
|
||||
|
||||
// x 轴标题「电极号」+ 类目刻度 "#1".."#40"。
|
||||
plot_->setAxisTitle(QwtPlot::xBottom, p.xTitle);
|
||||
plot_->setAxisScaleDraw(QwtPlot::xBottom, new CategoryScaleDraw(p.categories));
|
||||
plot_->setAxisScale(QwtPlot::xBottom, 0.0, n > 0 ? n - 1 : 0.0, 1.0); // 每类目一刻度
|
||||
plot_->setAxisMaxMinor(QwtPlot::xBottom, 0);
|
||||
|
||||
// y 范围固定 0..max*1.1(从数据取上界,留 10% 余量;对齐原版 0–30 观感)。
|
||||
// 固定刻度后 hover 标记不再触发 y 轴重算。
|
||||
double yMax = 0.0;
|
||||
for (double v : p.y) yMax = std::max(yMax, v);
|
||||
plot_->setAxisScale(QwtPlot::yLeft, 0.0, yMax > 0 ? yMax * 1.1 : 1.0);
|
||||
|
||||
if (p.y.empty()) {
|
||||
plot_->replot();
|
||||
return;
|
||||
}
|
||||
|
||||
// 单条折线:电极号(0..n-1) → 高程。
|
||||
//
|
||||
// 平滑必须在【数据坐标】里一次性算好,绝不能用 QwtPlotCurve::Fitted。原因(本 BUG 根因):
|
||||
// QwtPlotCurve 的 Fitted 拟合器在【绘制时】对【已映射到画布像素】的点跑样条
|
||||
// (见 qwt_plot_curve.cpp 注释 “The curve fitter operates on the translated points
|
||||
// ( = widget coordinates)”)。于是拟合结果取决于当时画布的像素几何;当首次 hover 触发
|
||||
// replot 时画布几何与首帧哪怕有一丁点差异,样条就在不同像素网格上重采样 → 曲线竖直方向
|
||||
// 细微漂移。锁轴并不能消除这一点(漂移源是像素级重拟合,不是自动缩放)。
|
||||
// 解法(QWT 官方建议:把拟合结果缓存进 series):在数据坐标里把样条展开成稠密折线,
|
||||
// 作为静态 samples 交给普通 Lines 曲线。此后每次 replot 都映射同一条固定折线 → 首帧与
|
||||
// hover 帧像素完全一致,零漂移。Cardinal + ParameterUniform 与原 QwtSplineCurveFitter
|
||||
// 内部一致,平滑观感不变。
|
||||
curve_ = new QwtPlotCurve(p.seriesName);
|
||||
QVector<QPointF> nodes;
|
||||
nodes.reserve(n);
|
||||
for (int i = 0; i < n && i < static_cast<int>(p.y.size()); ++i)
|
||||
nodes.append(QPointF(i, p.y[static_cast<size_t>(i)]));
|
||||
|
||||
QVector<QPointF> samples = nodes;
|
||||
if (p.smooth && nodes.size() > 2) {
|
||||
QwtSplineLocal spline(QwtSplineLocal::Cardinal);
|
||||
spline.setParametrization(QwtSplineParametrization::ParameterUniform);
|
||||
// 展平容差取数据范围的极小比例 → 折线足够稠密、肉眼平滑;数据坐标空间,缩放无关。
|
||||
const double xSpan = nodes.isEmpty() ? 1.0 : (nodes.last().x() - nodes.first().x());
|
||||
const double tolerance = std::max(std::abs(xSpan), 1.0) / 2000.0;
|
||||
const QPolygonF smooth = spline.polygon(QPolygonF(nodes), tolerance);
|
||||
if (smooth.size() > 1) samples = QVector<QPointF>(smooth.begin(), smooth.end());
|
||||
}
|
||||
curve_->setSamples(samples);
|
||||
curve_->setStyle(QwtPlotCurve::Lines);
|
||||
curve_->setPen(QPen(lineColor(p.color), 2));
|
||||
curve_->setRenderHint(QwtPlotItem::RenderAntialiased, true);
|
||||
curve_->attach(plot_);
|
||||
|
||||
plot_->replot();
|
||||
}
|
||||
|
||||
// ── LineHoverOverlay / LineHoverTip:折线 hover 虚线参考线 + 实心点 + 浮动框 ──────
|
||||
|
||||
namespace {
|
||||
|
||||
// 从类目标签 "#N" 解析电极号;无前缀/非数字 → 回退 1-based 索引。
|
||||
int electrodeNoFromCategory(const std::vector<QString>& cats, int i) {
|
||||
if (i >= 0 && i < static_cast<int>(cats.size())) {
|
||||
QString s = cats[static_cast<size_t>(i)];
|
||||
if (s.startsWith(QLatin1Char('#'))) s = s.mid(1);
|
||||
bool ok = false;
|
||||
const int no = s.toInt(&ok);
|
||||
if (ok) return no;
|
||||
}
|
||||
return i + 1;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// hover 叠层:画在 plot canvas 之上的 QwtWidgetOverlay。激活时用 plot 的 canvasMap
|
||||
// 把数据坐标 (idx, yVal) 变换为像素,画一条垂直虚线参考线(满画布高)+ 曲线上实心点。
|
||||
// updateOverlay() 只重绘本叠层、【不】触碰 plot/曲线 → 曲线零漂移。颜色每帧按主题重算(廉价)。
|
||||
class LineHoverOverlay : public QwtWidgetOverlay {
|
||||
public:
|
||||
explicit LineHoverOverlay(QwtPlot* plot)
|
||||
: QwtWidgetOverlay(plot->canvas()), plot_(plot) {
|
||||
// 不用 mask(默认 MaskHint 在未重写 maskHint 时会把可见区域裁空 → 叠层不显示)。
|
||||
setMaskMode(QwtWidgetOverlay::NoMask);
|
||||
}
|
||||
|
||||
// 设置 hover 状态并重绘叠层(仅叠层,不 replot plot)。
|
||||
void setHover(bool active, int idx, double yVal, const QColor& dotColor) {
|
||||
active_ = active;
|
||||
idx_ = idx;
|
||||
yVal_ = yVal;
|
||||
dotColor_ = dotColor;
|
||||
updateOverlay();
|
||||
}
|
||||
|
||||
void clearHover() {
|
||||
if (!active_) return;
|
||||
active_ = false;
|
||||
updateOverlay();
|
||||
}
|
||||
|
||||
protected:
|
||||
void drawOverlay(QPainter* painter) const override {
|
||||
if (!active_ || !plot_) return;
|
||||
|
||||
const QwtScaleMap xMap = plot_->canvasMap(QwtPlot::xBottom);
|
||||
const QwtScaleMap yMap = plot_->canvasMap(QwtPlot::yLeft);
|
||||
const double xPix = xMap.transform(idx_);
|
||||
const double yPix = yMap.transform(yVal_);
|
||||
|
||||
// 垂直虚线参考线:满画布高(主题感知颜色)。
|
||||
const QColor lineCol =
|
||||
isDarkTheme() ? tokenColor("border/strong") : QColor(150, 170, 210);
|
||||
painter->setRenderHint(QPainter::Antialiasing, true);
|
||||
painter->setPen(QPen(lineCol, 1.0, Qt::DashLine));
|
||||
painter->drawLine(QPointF(xPix, 0.0), QPointF(xPix, static_cast<double>(height())));
|
||||
|
||||
// 曲线上实心圆点(系列色填充 + 同色描边,直径 ~8px)。
|
||||
QColor dot = dotColor_.isValid() ? dotColor_ : QColor(0x54, 0x70, 0xc6);
|
||||
painter->setPen(QPen(dot, 1.0));
|
||||
painter->setBrush(QBrush(dot));
|
||||
painter->drawEllipse(QPointF(xPix, yPix), 4.0, 4.0);
|
||||
}
|
||||
|
||||
private:
|
||||
QwtPlot* plot_;
|
||||
bool active_ = false;
|
||||
int idx_ = 0;
|
||||
double yVal_ = 0.0;
|
||||
QColor dotColor_;
|
||||
};
|
||||
|
||||
LineHoverTip::LineHoverTip(QwtPlot* plot, const geopro::core::LinePayload* data, QObject* parent)
|
||||
: QObject(parent), plot_(plot), data_(data) {
|
||||
if (plot_ && plot_->canvas()) {
|
||||
// hover(无按键)需开启鼠标跟踪,否则默认仅按键按下时才收到 MouseMove。
|
||||
plot_->canvas()->setMouseTracking(true);
|
||||
plot_->canvas()->installEventFilter(this);
|
||||
// 叠层 parent 为 canvas → 随 canvas 尺寸/位置自动跟随(QwtWidgetOverlay 处理 resize)。
|
||||
overlay_ = new LineHoverOverlay(plot_);
|
||||
overlay_->show();
|
||||
}
|
||||
}
|
||||
|
||||
LineHoverTip::~LineHoverTip() {
|
||||
// tip_ 是无父顶层 widget,须显式删除。overlay_ parent 为 canvas,随其析构(无需手删)。
|
||||
delete tip_;
|
||||
tip_ = nullptr;
|
||||
}
|
||||
|
||||
void LineHoverTip::hideHover() {
|
||||
// 叠层置非激活并重绘(仅叠层,不 replot plot);隐藏浮动框。
|
||||
if (overlay_) overlay_->clearHover();
|
||||
if (tip_) tip_->hide();
|
||||
}
|
||||
|
||||
void LineHoverTip::showTip(const QString& html, const QPoint& globalPos) {
|
||||
// 自定义浮动框:Qt::ToolTip 顶层 QLabel(富文本)。每次 MouseMove 必更新文本与位置,
|
||||
// 不经 QToolTip 去重逻辑 → 提示稳定显示(FIX 4)。主题感知配色(白框/暗框)。
|
||||
if (!tip_) {
|
||||
tip_ = new QLabel(nullptr, Qt::ToolTip | Qt::FramelessWindowHint);
|
||||
tip_->setObjectName(QStringLiteral("lineHoverTip"));
|
||||
tip_->setTextFormat(Qt::RichText);
|
||||
tip_->setMargin(8);
|
||||
tip_->setAttribute(Qt::WA_ShowWithoutActivating, true);
|
||||
}
|
||||
const bool dark = isDarkTheme();
|
||||
const QColor bg = dark ? tokenColor("bg/panel") : QColor(0xFF, 0xFF, 0xFF);
|
||||
const QColor border = dark ? tokenColor("border/strong") : QColor(0xE3, 0xE6, 0xEB);
|
||||
const QColor fg = dark ? tokenColor("text/primary") : QColor(0x27, 0x2C, 0x35);
|
||||
tip_->setStyleSheet(QStringLiteral(
|
||||
"QLabel#lineHoverTip{background:%1;color:%2;border:1px solid %3;"
|
||||
"border-radius:4px;}")
|
||||
.arg(bg.name(), fg.name(), border.name()));
|
||||
tip_->setText(html);
|
||||
tip_->adjustSize();
|
||||
|
||||
// 在光标右下偏移摆放;越右/下边界则翻向左/上,避免被屏幕裁切。
|
||||
QPoint pos = globalPos + QPoint(14, 16);
|
||||
if (QScreen* scr = QGuiApplication::screenAt(globalPos)) {
|
||||
const QRect g = scr->availableGeometry();
|
||||
if (pos.x() + tip_->width() > g.right()) pos.setX(globalPos.x() - tip_->width() - 14);
|
||||
if (pos.y() + tip_->height() > g.bottom()) pos.setY(globalPos.y() - tip_->height() - 16);
|
||||
}
|
||||
tip_->move(pos);
|
||||
if (!tip_->isVisible()) tip_->show();
|
||||
}
|
||||
|
||||
bool LineHoverTip::eventFilter(QObject* obj, QEvent* ev) {
|
||||
if (!plot_ || obj != plot_->canvas()) return QObject::eventFilter(obj, ev);
|
||||
|
||||
if (ev->type() == QEvent::Leave) {
|
||||
hideHover();
|
||||
return false;
|
||||
}
|
||||
if (ev->type() != QEvent::MouseMove) return QObject::eventFilter(obj, ev);
|
||||
|
||||
auto* me = static_cast<QMouseEvent*>(ev);
|
||||
// 拖动中(有按键)或无数据 → 不弹提示。
|
||||
if (me->buttons() != Qt::NoButton || !data_ || data_->y.empty()) {
|
||||
hideHover();
|
||||
return false;
|
||||
}
|
||||
|
||||
const auto& ys = data_->y;
|
||||
const int n = static_cast<int>(ys.size());
|
||||
|
||||
// 按 x 吸附到最近电极索引(类目位于整数 x = 0..n-1)。
|
||||
const QwtScaleMap xMap = plot_->canvasMap(QwtPlot::xBottom);
|
||||
const double xVal = xMap.invTransform(me->position().x());
|
||||
int idx = static_cast<int>(std::lround(xVal));
|
||||
idx = std::clamp(idx, 0, n - 1);
|
||||
|
||||
const double yVal = ys[static_cast<size_t>(idx)];
|
||||
const int electrodeNo = electrodeNoFromCategory(data_->categories, idx);
|
||||
|
||||
// 实心点系列色(虚线参考线颜色在 overlay 内按主题重算)。
|
||||
QColor dotCol(data_->color);
|
||||
if (!dotCol.isValid()) dotCol = QColor(0x54, 0x70, 0xc6);
|
||||
|
||||
// 关键:把虚线 + 实心点交给叠层重绘(仅叠层重绘,【绝不】replot plot → 曲线零漂移)。
|
||||
if (overlay_) overlay_->setHover(true, idx, yVal, dotCol);
|
||||
|
||||
// 浮动框:#<电极号> 表头 + 系列点·标签·值(高程保留 3 位小数)。系列点用系列色圆点。
|
||||
const QString tip = QStringLiteral("#%1<br><span style='color:%2'>●</span> 高程(m)"
|
||||
" %3")
|
||||
.arg(electrodeNo)
|
||||
.arg(dotCol.name())
|
||||
.arg(yVal, 0, 'f', 3);
|
||||
showTip(tip, me->globalPosition().toPoint());
|
||||
|
||||
return false; // 不消费,保留其它过滤器链路
|
||||
}
|
||||
|
||||
} // namespace geopro::app
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
#pragma once
|
||||
#include <QObject>
|
||||
#include <QWidget>
|
||||
|
||||
#include "model/detail/DetailPayloads.hpp"
|
||||
#include "panels/chart/IDetailView.hpp"
|
||||
|
||||
class QEvent;
|
||||
class QLabel;
|
||||
class QPoint;
|
||||
class QwtPlot;
|
||||
class QwtPlotCurve;
|
||||
|
||||
namespace geopro::app {
|
||||
|
||||
class LineHoverTip;
|
||||
class LineHoverOverlay;
|
||||
|
||||
// 折线图视图(dd_trajectory_data 高程页签):
|
||||
// QwtPlot + QwtPlotCurve(单条平滑折线,Fitted + QwtSplineCurveFitter)。
|
||||
// x 轴类目标签 "#1".."#40"(自定义 QwtScaleDraw,复用 BarChartView 同款思路);
|
||||
// x 轴标题「电极号」(底部居中);y 轴标题「高程」用左上水平 QLabel(ECharts 风格);
|
||||
// 线色 #5470c6(ECharts 默认蓝,数据色,两主题一致);无图例(单系列)。
|
||||
// 背景/轴字/网格随主题(ChartTheme / ThemeManager)。
|
||||
class LineChartView : public QWidget, public IDetailView {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit LineChartView(QWidget* parent = nullptr);
|
||||
~LineChartView() override;
|
||||
|
||||
void setData(const geopro::core::LinePayload& p);
|
||||
|
||||
QWidget* widget() override { return this; }
|
||||
void setPayload(const QVariant& payload) override; // 坏/空 variant → 空态不崩
|
||||
|
||||
private:
|
||||
void clearCurve(); // 卸载并删除已挂折线(避免 QwtPlot autoDelete 双删)
|
||||
|
||||
geopro::core::LinePayload data_;
|
||||
QwtPlot* plot_;
|
||||
QLabel* yTitle_; // 左上水平 y 轴标题(ECharts 风格)
|
||||
QwtPlotCurve* curve_ = nullptr; // 当前挂载的折线(已 attach;卸载时 detach+delete)
|
||||
LineHoverTip* hover_ = nullptr; // 鼠标 hover 追踪器(虚线参考线 + 实心点 + 浮动框)
|
||||
};
|
||||
|
||||
// 折线 hover 追踪器(对齐原版 ECharts 高程折线 hover):监听画布鼠标移动(无按键时),
|
||||
// 按 x 吸附到最近电极索引,绘制:垂直虚线参考线 + 曲线上该点实心圆点 +
|
||||
// 自定义浮动框(#<电极号> / ● 高程(m): <值,3 位>)。
|
||||
//
|
||||
// 关键(DEFINITIVE FIX):虚线参考线 + 实心点用 QwtWidgetOverlay 画在 canvas 之上,
|
||||
// hover 路径【绝不】调用 plot_->replot()。此前用 QwtPlotMarker + 每次 MouseMove replot,
|
||||
// 那次 replot 重跑 plot 布局/updateAxes,使曲线随 hover 标记的挂/卸而上下漂移(锁轴 + 数据
|
||||
// 坐标样条都无法阻止——漂移源是 replot/marker-attach 本身)。改为 overlay 后曲线在 setData
|
||||
// 里只渲染一次、hover 全程不再 replot → 曲线不可能移动。
|
||||
// 浮动框仍用独立的 Qt::ToolTip 顶层 QLabel(富文本):QToolTip 会去重抑制相同/相近位置的重复
|
||||
// showText(FIX 4),自定义 widget 每次 MouseMove 必更新位置/内容。
|
||||
// 鼠标离开/越界时隐藏。不消费事件(镜像 ScatterHoverTip)。
|
||||
class LineHoverTip : public QObject {
|
||||
Q_OBJECT
|
||||
public:
|
||||
LineHoverTip(QwtPlot* plot, const geopro::core::LinePayload* data, QObject* parent = nullptr);
|
||||
~LineHoverTip() override;
|
||||
|
||||
protected:
|
||||
bool eventFilter(QObject* obj, QEvent* ev) override;
|
||||
|
||||
private:
|
||||
void hideHover(); // overlay 置非激活 + 隐藏浮动框(无 replot)
|
||||
// 在 globalPos 旁显示富文本浮动框(按需创建、主题感知样式;每次必更新)。
|
||||
void showTip(const QString& html, const QPoint& globalPos);
|
||||
|
||||
QwtPlot* plot_;
|
||||
const geopro::core::LinePayload* data_; // 由 LineChartView 持有(data_ 成员,地址稳定);只读不拥有
|
||||
LineHoverOverlay* overlay_ = nullptr; // 画布之上的 hover 叠层(虚线 + 实心点;不触发 plot replot)
|
||||
QLabel* tip_ = nullptr; // 自定义浮动框(Qt::ToolTip 顶层 QLabel,富文本;替代 QToolTip)
|
||||
};
|
||||
|
||||
} // namespace geopro::app
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
#include "panels/chart/LivePanner.hpp"
|
||||
|
||||
#include <QEvent>
|
||||
#include <QMouseEvent>
|
||||
#include <QWheelEvent>
|
||||
#include <qwt_plot.h>
|
||||
#include <qwt_scale_div.h>
|
||||
|
||||
namespace geopro::app {
|
||||
|
||||
LivePanner::LivePanner(QwtPlot* plot, int xAxis, int yAxis, QObject* parent)
|
||||
: QObject(parent), plot_(plot), xAxis_(xAxis), yAxis_(yAxis) {
|
||||
if (plot_ && plot_->canvas()) plot_->canvas()->installEventFilter(this);
|
||||
}
|
||||
|
||||
bool LivePanner::eventFilter(QObject* obj, QEvent* ev) {
|
||||
if (!plot_ || obj != plot_->canvas()) return QObject::eventFilter(obj, ev);
|
||||
|
||||
switch (ev->type()) {
|
||||
case QEvent::MouseButtonPress: {
|
||||
auto* me = static_cast<QMouseEvent*>(ev);
|
||||
if (me->button() == Qt::LeftButton) {
|
||||
panning_ = true;
|
||||
startPos_ = me->pos();
|
||||
x0Min_ = plot_->axisScaleDiv(xAxis_).lowerBound();
|
||||
x0Max_ = plot_->axisScaleDiv(xAxis_).upperBound();
|
||||
y0Min_ = plot_->axisScaleDiv(yAxis_).lowerBound();
|
||||
y0Max_ = plot_->axisScaleDiv(yAxis_).upperBound();
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case QEvent::MouseMove: {
|
||||
if (!panning_) break;
|
||||
auto* me = static_cast<QMouseEvent*>(ev);
|
||||
const int W = plot_->canvas()->width();
|
||||
const int H = plot_->canvas()->height();
|
||||
if (W <= 0 || H <= 0) break;
|
||||
const double dxData = (me->pos().x() - startPos_.x()) * (x0Max_ - x0Min_) / W;
|
||||
const double dyData = (me->pos().y() - startPos_.y()) * (y0Max_ - y0Min_) / H;
|
||||
// 拖右(dx>0)→窗口左移(xmin/xmax 减),内容随光标右移;
|
||||
// 拖下(像素 dy>0)→yLeft 数据向上→窗口上移(ymin/ymax 增),内容随光标下移。
|
||||
plot_->setAxisScale(xAxis_, x0Min_ - dxData, x0Max_ - dxData);
|
||||
plot_->setAxisScale(yAxis_, y0Min_ + dyData, y0Max_ + dyData);
|
||||
plot_->replot();
|
||||
return true;
|
||||
}
|
||||
case QEvent::MouseButtonRelease: {
|
||||
auto* me = static_cast<QMouseEvent*>(ev);
|
||||
if (me->button() == Qt::LeftButton) {
|
||||
panning_ = false;
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case QEvent::Wheel: {
|
||||
// 滚轮缩放(以光标为中心,上滚=放大),并 **消费事件**(return true)避免冒泡到外层
|
||||
// 滚动区域触发滚动条。两轴同因子缩放→保持锁定的真实比尺。
|
||||
auto* we = static_cast<QWheelEvent*>(ev);
|
||||
const int d = we->angleDelta().y();
|
||||
if (d == 0) return true;
|
||||
const double f = (d > 0) ? (1.0 / 1.15) : 1.15; // 上滚→区间缩小→放大
|
||||
const QPointF pos = we->position();
|
||||
const double cx = plot_->invTransform(xAxis_, pos.x());
|
||||
const double cy = plot_->invTransform(yAxis_, pos.y());
|
||||
const double xMin = plot_->axisScaleDiv(xAxis_).lowerBound();
|
||||
const double xMax = plot_->axisScaleDiv(xAxis_).upperBound();
|
||||
const double yMin = plot_->axisScaleDiv(yAxis_).lowerBound();
|
||||
const double yMax = plot_->axisScaleDiv(yAxis_).upperBound();
|
||||
plot_->setAxisScale(xAxis_, cx - (cx - xMin) * f, cx + (xMax - cx) * f);
|
||||
plot_->setAxisScale(yAxis_, cy - (cy - yMin) * f, cy + (yMax - cy) * f);
|
||||
plot_->replot();
|
||||
we->accept();
|
||||
return true;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return QObject::eventFilter(obj, ev);
|
||||
}
|
||||
|
||||
} // namespace geopro::app
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
#pragma once
|
||||
#include <QObject>
|
||||
#include <QPoint>
|
||||
|
||||
class QwtPlot;
|
||||
|
||||
namespace geopro::app {
|
||||
|
||||
// 实时拖动平移:左键拖动时连续平移两轴并 replot(坐标轴 + 内容一起实时移动),
|
||||
// 区别于 QwtPlotPanner 的"像素抓取"(拖动中轴不动、边缘空白、松手才定稿)。
|
||||
// 平移为纯平移,不改变纵横比(与 QwtPlotRescaler 锁定的真实比尺兼容)。
|
||||
class LivePanner : public QObject {
|
||||
Q_OBJECT
|
||||
public:
|
||||
LivePanner(QwtPlot* plot, int xAxis, int yAxis, QObject* parent = nullptr);
|
||||
|
||||
protected:
|
||||
bool eventFilter(QObject* obj, QEvent* ev) override;
|
||||
|
||||
private:
|
||||
QwtPlot* plot_;
|
||||
int xAxis_;
|
||||
int yAxis_;
|
||||
bool panning_ = false;
|
||||
QPoint startPos_;
|
||||
double x0Min_ = 0, x0Max_ = 0, y0Min_ = 0, y0Max_ = 0;
|
||||
};
|
||||
|
||||
} // namespace geopro::app
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
#pragma once
|
||||
#include <vector>
|
||||
#include "IDatasetChartStrategy.hpp" // geopro::controller
|
||||
namespace geopro::app {
|
||||
|
||||
// ERT 原始数据(measurement)策略:散点伪剖面(同步)+ 数据列表(懒加载)两页签。
|
||||
struct MeasurementStrategy : controller::IDatasetChartStrategy {
|
||||
std::string ddCode() const override { return "dd_ert_measurement_data"; }
|
||||
std::vector<controller::TabSpec> tabs() const override {
|
||||
return {
|
||||
{QStringLiteral("散点图"), controller::ViewKind::Scatter,
|
||||
QStringLiteral("ert_measurement.scatter"), /*lazy*/ false, /*paginated*/ false},
|
||||
{QStringLiteral("数据列表"), controller::ViewKind::Table,
|
||||
QStringLiteral("ert_measurement.rows"), /*lazy*/ true, /*paginated*/ false},
|
||||
};
|
||||
}
|
||||
};
|
||||
} // namespace geopro::app
|
||||
|
|
@ -0,0 +1,468 @@
|
|||
#include "panels/chart/RawDataChartView.hpp"
|
||||
#include "panels/chart/ChartTheme.hpp"
|
||||
#include "panels/chart/ColorBarWidget.hpp"
|
||||
#include "panels/chart/ScatterHoverTip.hpp"
|
||||
#include "panels/chart/ScatterPlotItem.hpp"
|
||||
|
||||
#include <QComboBox>
|
||||
#include <QCursor>
|
||||
#include <QHBoxLayout>
|
||||
#include <QIcon>
|
||||
#include <QLabel>
|
||||
#include <QPainter>
|
||||
#include <QPainterPath>
|
||||
#include <QPen>
|
||||
#include <QPixmap>
|
||||
#include <QPushButton>
|
||||
#include <QToolButton>
|
||||
#include <QToolTip>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
#include <qwt_plot.h>
|
||||
#include <qwt_plot_canvas.h>
|
||||
#include <qwt_plot_marker.h>
|
||||
#include <qwt_plot_grid.h>
|
||||
#include <qwt_plot_rescaler.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <limits>
|
||||
|
||||
#include "panels/chart/LivePanner.hpp"
|
||||
#include "Theme.hpp"
|
||||
|
||||
namespace geopro::app {
|
||||
|
||||
RawDataChartView::RawDataChartView(QWidget* parent) : QWidget(parent) {
|
||||
auto* lay = new QVBoxLayout(this);
|
||||
lay->setContentsMargins(0, 0, 0, 0);
|
||||
lay->setSpacing(0);
|
||||
rootLay_ = lay;
|
||||
|
||||
// ---- 工具条(默认 = 反演原数据;measurement 到来时 buildMeasurementToolbar 替换)----
|
||||
auto* toolbar = new QWidget(this);
|
||||
toolbar_ = toolbar;
|
||||
auto* tbLay = new QHBoxLayout(toolbar);
|
||||
tbLay->setContentsMargins(4, 4, 4, 4);
|
||||
tbLay->setSpacing(4);
|
||||
|
||||
auto* btnGrid = new QToolButton(toolbar);
|
||||
btnGrid->setText(QStringLiteral("网格"));
|
||||
|
||||
auto* btnColorScale = new QToolButton(toolbar);
|
||||
btnColorScale->setText(QStringLiteral("色阶配置"));
|
||||
|
||||
auto* lblCurrentChart = new QLabel(QStringLiteral("当前图形:"), toolbar);
|
||||
|
||||
chartTypeCombo_ = new QComboBox(toolbar);
|
||||
chartTypeCombo_->addItem(QStringLiteral("散点图"));
|
||||
|
||||
auto* btnSaveAs = new QToolButton(toolbar);
|
||||
btnSaveAs->setText(QStringLiteral("另存为"));
|
||||
|
||||
tbLay->addWidget(btnGrid);
|
||||
tbLay->addWidget(btnColorScale);
|
||||
tbLay->addWidget(lblCurrentChart);
|
||||
tbLay->addWidget(chartTypeCombo_);
|
||||
tbLay->addWidget(btnSaveAs);
|
||||
tbLay->addStretch();
|
||||
|
||||
lay->addWidget(toolbar);
|
||||
|
||||
// ---- QwtPlot(stretch 填满剩余空间)----
|
||||
plot_ = new QwtPlot(this);
|
||||
plot_->setObjectName(QStringLiteral("rawPlotArea"));
|
||||
|
||||
// x 轴顶部,关闭底部 x 轴
|
||||
plot_->enableAxis(QwtPlot::xTop, true);
|
||||
plot_->enableAxis(QwtPlot::xBottom, false);
|
||||
plot_->enableAxis(QwtPlot::yLeft, true);
|
||||
|
||||
// 底色/轴字/网格/零线配色由 applyChartPlotTheme 按主题统一设置(见 ctor 末尾 + 主题热切换)。
|
||||
// 浅色与原版 web 一致(白底深灰字);暗色改深色画布避免刺眼白底。
|
||||
|
||||
// 横纵网格线(浅灰,暗色下由 applyChartPlotTheme 重着色)。
|
||||
auto* grid = new QwtPlotGrid();
|
||||
grid->setMajorPen(QColor(225, 225, 225), 1.0, Qt::SolidLine);
|
||||
grid->setMinorPen(QColor(240, 240, 240), 1.0, Qt::DotLine);
|
||||
grid->enableXMin(false);
|
||||
grid->enableYMin(false);
|
||||
grid->setXAxis(QwtPlot::xTop);
|
||||
grid->setYAxis(QwtPlot::yLeft);
|
||||
grid->attach(plot_);
|
||||
|
||||
// 过原点零线(对齐原版 zeroline:x=0 竖线 + y=0 横线 → "四象限"观感)。
|
||||
auto* zeroX = new QwtPlotMarker();
|
||||
zeroX->setLineStyle(QwtPlotMarker::VLine);
|
||||
zeroX->setXValue(0.0);
|
||||
zeroX->setLinePen(QColor(180, 180, 180), 1.0);
|
||||
zeroX->setXAxis(QwtPlot::xTop);
|
||||
zeroX->attach(plot_);
|
||||
auto* zeroY = new QwtPlotMarker();
|
||||
zeroY->setLineStyle(QwtPlotMarker::HLine);
|
||||
zeroY->setYValue(0.0);
|
||||
zeroY->setLinePen(QColor(180, 180, 180), 1.0);
|
||||
zeroY->attach(plot_);
|
||||
|
||||
// 交互:LivePanner 统一处理左键实时平移 + 滚轮缩放(并消费滚轮事件,不冒泡触发滚动条)。
|
||||
new LivePanner(plot_, QwtPlot::xTop, QwtPlot::yLeft, this);
|
||||
|
||||
// 散点 hover 提示(X/Y/值)。后装(晚于 LivePanner)→ 事件链中先收到 MouseMove,
|
||||
// 无按键时弹提示且不消费;有按键(拖动)跳过,交给 LivePanner。数据地址稳定,装配期绑定一次。
|
||||
hoverTip_ = new ScatterHoverTip(plot_, QwtPlot::xTop, QwtPlot::yLeft, this);
|
||||
hoverTip_->setField(&data_.scatter);
|
||||
|
||||
// 允许随停靠面板自由收缩(不强制最小宽度)。
|
||||
plot_->setMinimumSize(0, 0);
|
||||
|
||||
// 锁定 x:y 真实比尺(用户选定):1 数据单位 x = 1 数据单位 y(像素)。
|
||||
// 参考轴 xTop、Expanding 策略——resize/缩放时维持等比,剖面呈真实"宽扁"形状。
|
||||
rescaler_ = new QwtPlotRescaler(plot_->canvas(), QwtPlot::xTop, QwtPlotRescaler::Expanding);
|
||||
rescaler_->setAspectRatio(1.0);
|
||||
rescaler_->setEnabled(true);
|
||||
|
||||
// ---- 图表行:plot(stretch)+ 右侧竖向色阶条(measurement 用,默认隐藏)----
|
||||
auto* plotRow = new QWidget(this);
|
||||
auto* plotRowLay = new QHBoxLayout(plotRow);
|
||||
plotRowLay->setContentsMargins(0, 0, 0, 0);
|
||||
plotRowLay->setSpacing(0);
|
||||
plotRowLay->addWidget(plot_, 1);
|
||||
|
||||
colorBarV_ = new ColorBarWidget(plotRow, ColorBarWidget::Orientation::Vertical);
|
||||
colorBarV_->setObjectName(QStringLiteral("rawColorScaleBarV"));
|
||||
colorBarV_->setVisible(false); // 默认(反演原数据)用底部横条
|
||||
plotRowLay->addWidget(colorBarV_);
|
||||
|
||||
lay->addWidget(plotRow, 1);
|
||||
|
||||
// ---- 底部独立色阶条(固定高 36px,反演原数据用)----
|
||||
colorBar_ = new ColorBarWidget(this);
|
||||
colorBar_->setObjectName(QStringLiteral("rawColorScaleBar"));
|
||||
lay->addWidget(colorBar_);
|
||||
|
||||
// 主题配色:当前主题套一次 + 监听切换热更新(暗色给深底,浅色保持白底=原版)。
|
||||
applyChartPlotTheme(plot_);
|
||||
QObject::connect(&ThemeManager::instance(), &ThemeManager::changed, plot_,
|
||||
[this]() { applyChartPlotTheme(plot_); });
|
||||
}
|
||||
|
||||
RawDataChartView::~RawDataChartView() {
|
||||
// colorSvc_ 非 QObject、无 parent,需手动释放(plot_ 的 item 由 QwtPlot autoDelete 处理)。
|
||||
delete colorSvc_;
|
||||
}
|
||||
|
||||
QWidget* RawDataChartView::plotArea() const {
|
||||
return plot_;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// 把一组 FieldOption 填进下拉,并按 defaultCode(或 defaultName 回退)选中默认项。
|
||||
void fillCombo(QComboBox* combo, const std::vector<geopro::core::FieldOption>& opts,
|
||||
const QString& defaultCode, const QString& defaultName) {
|
||||
for (const auto& o : opts) {
|
||||
// 用户可见名为 name;userData 存 fieldCode(重绘/识别用)。
|
||||
combo->addItem(o.name, o.code);
|
||||
}
|
||||
// 默认选中:优先匹配 fieldCode,否则匹配 name(method 的 code 全为 null,用 name)。
|
||||
int idx = defaultCode.isEmpty() ? -1 : combo->findData(defaultCode);
|
||||
if (idx < 0 && !defaultName.isEmpty()) idx = combo->findText(defaultName);
|
||||
if (idx >= 0) combo->setCurrentIndex(idx);
|
||||
}
|
||||
|
||||
// ── 工具条占位图标(线性风格,2× 超采样 → HiDPI 清晰)────────────────────
|
||||
// 原版用 Arco line icons(viewBox 0 0 48 48,stroke-width 4,stroke=currentColor)。
|
||||
// 这里以 QPainter 画细线风格,逻辑边长 logical px(按钮 setIconSize 用同值),
|
||||
// 像素 2× 渲染并 setDevicePixelRatio(2) 保证缩放下不糊。
|
||||
constexpr int kToolIconPx = 16; // 逻辑图标边长(与 setIconSize 对齐)
|
||||
constexpr qreal kToolIconScale = 2.0; // 超采样倍率(HiDPI 清晰)
|
||||
|
||||
QPixmap makeToolIconCanvas(QPainter& p) {
|
||||
// 调用方在 [0,kToolIconPx] 逻辑坐标系下作画;返回前缩放 + 设 dpr。
|
||||
const int dim = qRound(kToolIconPx * kToolIconScale);
|
||||
QPixmap pm(dim, dim);
|
||||
pm.fill(Qt::transparent);
|
||||
p.begin(&pm);
|
||||
p.setRenderHint(QPainter::Antialiasing, true);
|
||||
p.setRenderHint(QPainter::SmoothPixmapTransform, true);
|
||||
p.scale(kToolIconScale, kToolIconScale); // 之后用逻辑 px 坐标
|
||||
return pm;
|
||||
}
|
||||
|
||||
// 信息图标:细描边圆 + 内部 "i"(圆点 + 竖线),品牌蓝(两主题一致,对应原版小蓝圈-i)。
|
||||
QIcon makeInfoIcon() {
|
||||
const QColor accent = tokenColor("accent/primary");
|
||||
QPainter p;
|
||||
QPixmap pm = makeToolIconCanvas(p);
|
||||
const qreal s = kToolIconPx;
|
||||
QPen pen(accent, 1.4);
|
||||
pen.setCapStyle(Qt::RoundCap);
|
||||
p.setPen(pen);
|
||||
p.setBrush(Qt::NoBrush);
|
||||
// 外圈(留 1.5px 边距,避免描边被裁)。
|
||||
const qreal m = 1.5;
|
||||
p.drawEllipse(QRectF(m, m, s - 2 * m, s - 2 * m));
|
||||
// "i":上点 + 下竖线(居中)。
|
||||
const qreal cx = s / 2.0;
|
||||
p.setBrush(accent);
|
||||
p.setPen(Qt::NoPen);
|
||||
p.drawEllipse(QPointF(cx, s * 0.34), 0.9, 0.9); // 点
|
||||
QPen stem(accent, 1.4);
|
||||
stem.setCapStyle(Qt::RoundCap);
|
||||
p.setPen(stem);
|
||||
p.drawLine(QPointF(cx, s * 0.46), QPointF(cx, s * 0.70)); // 竖
|
||||
p.end();
|
||||
pm.setDevicePixelRatio(kToolIconScale);
|
||||
return QIcon(pm);
|
||||
}
|
||||
|
||||
// 框选图标:虚线方框(选区)+ 右下角小箭头光标。描边随主题(次要文本色,两主题可见)。
|
||||
QIcon makeMarqueeIcon() {
|
||||
const QColor stroke = tokenColor("text/secondary");
|
||||
QPainter p;
|
||||
QPixmap pm = makeToolIconCanvas(p);
|
||||
const qreal s = kToolIconPx;
|
||||
// 虚线选区框(左上偏移,给右下角箭头让位)。
|
||||
QPen dash(stroke, 1.3);
|
||||
dash.setCapStyle(Qt::FlatCap);
|
||||
dash.setJoinStyle(Qt::MiterJoin);
|
||||
QVector<qreal> pattern{2.0, 1.6};
|
||||
dash.setDashPattern(pattern);
|
||||
p.setPen(dash);
|
||||
p.setBrush(Qt::NoBrush);
|
||||
p.drawRect(QRectF(2.0, 2.0, s - 6.0, s - 6.0));
|
||||
// 右下角小箭头光标(实线填充,指向右下)。
|
||||
QPainterPath cur;
|
||||
const qreal ax = s - 5.0, ay = s - 5.0;
|
||||
cur.moveTo(ax, ay);
|
||||
cur.lineTo(ax + 4.6, ay + 1.8);
|
||||
cur.lineTo(ax + 1.9, ay + 2.6);
|
||||
cur.lineTo(ax + 1.1, ay + 4.9);
|
||||
cur.closeSubpath();
|
||||
p.setPen(Qt::NoPen);
|
||||
p.setBrush(stroke);
|
||||
p.drawPath(cur);
|
||||
p.end();
|
||||
pm.setDevicePixelRatio(kToolIconScale);
|
||||
return QIcon(pm);
|
||||
}
|
||||
|
||||
// 把占位 QToolButton 配成图标按钮:清文字、设图标 + 固定尺寸(与工具条其它按钮一致高度)。
|
||||
void styleToolIconButton(QToolButton* btn, const QIcon& icon) {
|
||||
btn->setText(QString());
|
||||
btn->setIcon(icon);
|
||||
btn->setIconSize(QSize(kToolIconPx, kToolIconPx));
|
||||
btn->setAutoRaise(true);
|
||||
btn->setFixedSize(QSize(28, 28)); // 与下拉/按钮行高协调;不再过小或锯齿
|
||||
btn->setCursor(Qt::PointingHandCursor);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void RawDataChartView::showNotImplemented(QWidget* anchor) {
|
||||
// 轻提示:占位按钮/下拉点击 → 暂未实现(不阻塞,不弹窗)。
|
||||
const QPoint pos = anchor ? anchor->mapToGlobal(QPoint(0, anchor->height()))
|
||||
: QCursor::pos();
|
||||
QToolTip::showText(pos, QStringLiteral("暂未实现"), anchor);
|
||||
}
|
||||
|
||||
void RawDataChartView::replotForAxis() {
|
||||
// 本地换 x/y(无网络):按下拉 fieldCode 从备选列取数据,重设 scatter.x/.y 并重绘。
|
||||
if (!xCombo_ || !yCombo_ || !scatterItem_) return;
|
||||
const QString xCode = xCombo_->currentData().toString();
|
||||
const QString yCode = yCombo_->currentData().toString();
|
||||
|
||||
if (xCode == QStringLiteral("horizontalDistance") && !data_.altXHorizontal.empty())
|
||||
data_.scatter.x = data_.altXHorizontal;
|
||||
else if (!data_.altXSlope.empty())
|
||||
data_.scatter.x = data_.altXSlope; // 默认/斜距
|
||||
|
||||
if (yCode == QStringLiteral("elevationPseudoDepth") && !data_.altYElevationPseudo.empty())
|
||||
data_.scatter.y = data_.altYElevationPseudo;
|
||||
else if (yCode == QStringLiteral("pseudoDepth") && !data_.altYPseudo.empty())
|
||||
data_.scatter.y = data_.altYPseudo;
|
||||
// 层数(Layer No):数据为 null → 不改轴(保持当前),选项可选但 no-op。
|
||||
|
||||
scatterItem_->setData(data_.scatter, colorSvc_);
|
||||
if (hoverTip_) hoverTip_->setField(&data_.scatter);
|
||||
QRectF bbox = scatterItem_->boundingRect();
|
||||
if (!bbox.isEmpty()) {
|
||||
plot_->setAxisScale(QwtPlot::xTop, bbox.left(), bbox.right());
|
||||
plot_->setAxisScale(QwtPlot::yLeft, bbox.top(), bbox.bottom());
|
||||
}
|
||||
plot_->updateAxes();
|
||||
if (rescaler_) rescaler_->rescale();
|
||||
plot_->replot();
|
||||
}
|
||||
|
||||
void RawDataChartView::buildMeasurementToolbar(const geopro::core::ScatterToolbarConf& conf) {
|
||||
auto* toolbar = new QWidget(this);
|
||||
auto* tbLay = new QHBoxLayout(toolbar);
|
||||
tbLay->setContentsMargins(4, 4, 4, 4);
|
||||
tbLay->setSpacing(4);
|
||||
|
||||
// [i] info + [▣] 框选:占位(暂未实现)。用 QPainter 画的线性图标(HiDPI 清晰,随主题)。
|
||||
auto* btnInfo = new QToolButton(toolbar);
|
||||
btnInfo->setToolTip(QStringLiteral("信息"));
|
||||
styleToolIconButton(btnInfo, makeInfoIcon());
|
||||
connect(btnInfo, &QToolButton::clicked, this, [this, btnInfo]() { showNotImplemented(btnInfo); });
|
||||
auto* btnMarquee = new QToolButton(toolbar);
|
||||
btnMarquee->setToolTip(QStringLiteral("框选"));
|
||||
styleToolIconButton(btnMarquee, makeMarqueeIcon());
|
||||
connect(btnMarquee, &QToolButton::clicked, this, [this, btnMarquee]() { showNotImplemented(btnMarquee); });
|
||||
// 主题热切:重绘图标(info 锚定品牌蓝,marquee 描边随次要文本色)。
|
||||
connect(&ThemeManager::instance(), &ThemeManager::changed, btnInfo,
|
||||
[btnInfo]() { btnInfo->setIcon(makeInfoIcon()); });
|
||||
connect(&ThemeManager::instance(), &ThemeManager::changed, btnMarquee,
|
||||
[btnMarquee]() { btnMarquee->setIcon(makeMarqueeIcon()); });
|
||||
|
||||
// 显示 / 隐藏:功能性——切换全部数据方块可见性(电极保留)。
|
||||
auto* btnShow = new QPushButton(QStringLiteral("显示"), toolbar);
|
||||
auto* btnHide = new QPushButton(QStringLiteral("隐藏"), toolbar);
|
||||
connect(btnShow, &QPushButton::clicked, this, [this]() {
|
||||
if (scatterItem_) { scatterItem_->setScatterVisible(true); plot_->replot(); }
|
||||
});
|
||||
connect(btnHide, &QPushButton::clicked, this, [this]() {
|
||||
if (scatterItem_) { scatterItem_->setScatterVisible(false); plot_->replot(); }
|
||||
});
|
||||
|
||||
// 数据过滤:占位(暂未实现)。
|
||||
auto* btnFilter = new QPushButton(QStringLiteral("数据过滤"), toolbar);
|
||||
connect(btnFilter, &QPushButton::clicked, this, [this, btnFilter]() { showNotImplemented(btnFilter); });
|
||||
|
||||
// x / y 下拉:功能性(本地换列重绘);v / method 下拉:视觉占位(选不同 v/method 提示暂未实现)。
|
||||
xCombo_ = new QComboBox(toolbar);
|
||||
fillCombo(xCombo_, conf.x, conf.defaultX, QString());
|
||||
yCombo_ = new QComboBox(toolbar);
|
||||
fillCombo(yCombo_, conf.y, conf.defaultY, QString());
|
||||
auto* vCombo = new QComboBox(toolbar);
|
||||
fillCombo(vCombo, conf.v, conf.defaultV, QString());
|
||||
auto* methodCombo = new QComboBox(toolbar);
|
||||
fillCombo(methodCombo, conf.method, QString(), conf.defaultMethod);
|
||||
|
||||
connect(xCombo_, QOverload<int>::of(&QComboBox::currentIndexChanged), this,
|
||||
[this](int) { replotForAxis(); });
|
||||
connect(yCombo_, QOverload<int>::of(&QComboBox::currentIndexChanged), this,
|
||||
[this](int) { replotForAxis(); });
|
||||
// v / method:换选项 → 暂未实现(需重新请求散点/色阶,属重交互,本轮不做)。
|
||||
connect(vCombo, QOverload<int>::of(&QComboBox::activated), this,
|
||||
[this, vCombo](int) { showNotImplemented(vCombo); });
|
||||
connect(methodCombo, QOverload<int>::of(&QComboBox::activated), this,
|
||||
[this, methodCombo](int) { showNotImplemented(methodCombo); });
|
||||
|
||||
// 色阶配置:占位(暂未实现)。
|
||||
auto* btnColorScale = new QPushButton(QStringLiteral("色阶配置"), toolbar);
|
||||
connect(btnColorScale, &QPushButton::clicked, this, [this, btnColorScale]() { showNotImplemented(btnColorScale); });
|
||||
|
||||
// 右侧主操作(蓝色):生成视电阻率数据 / 反演运算 / 另存为 —— 占位(暂未实现)。
|
||||
auto* btnGen = new QPushButton(QStringLiteral("生成视电阻率数据"), toolbar);
|
||||
auto* btnInvert = new QPushButton(QStringLiteral("反演运算"), toolbar);
|
||||
auto* btnSaveAs = new QPushButton(QStringLiteral("另存为"), toolbar);
|
||||
for (auto* b : {btnGen, btnInvert, btnSaveAs}) {
|
||||
b->setObjectName(QStringLiteral("primaryBtn")); // 蓝色主按钮(下方 QSS)
|
||||
connect(b, &QPushButton::clicked, this, [this, b]() { showNotImplemented(b); });
|
||||
}
|
||||
|
||||
tbLay->addWidget(btnInfo);
|
||||
tbLay->addWidget(btnMarquee);
|
||||
tbLay->addWidget(btnShow);
|
||||
tbLay->addWidget(btnHide);
|
||||
tbLay->addWidget(btnFilter);
|
||||
tbLay->addWidget(xCombo_);
|
||||
tbLay->addWidget(yCombo_);
|
||||
tbLay->addWidget(vCombo);
|
||||
tbLay->addWidget(methodCombo);
|
||||
tbLay->addWidget(btnColorScale);
|
||||
tbLay->addStretch(); // 把主操作推到右侧
|
||||
tbLay->addWidget(btnGen);
|
||||
tbLay->addWidget(btnInvert);
|
||||
tbLay->addWidget(btnSaveAs);
|
||||
|
||||
// 蓝色主按钮样式(随主题;普通按钮/下拉走全局 QSS 已支持明暗)。
|
||||
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}}; }"));
|
||||
|
||||
// 替换 ctor 建的 inversion 工具条(同一顶层布局首位)。
|
||||
rootLay_->replaceWidget(toolbar_, toolbar);
|
||||
toolbar_->deleteLater();
|
||||
toolbar_ = toolbar;
|
||||
chartTypeCombo_ = nullptr; // 已随旧工具条移除
|
||||
measurementToolbar_ = true;
|
||||
}
|
||||
|
||||
void RawDataChartView::setPayload(const QVariant& payload) {
|
||||
if (!payload.canConvert<geopro::core::ScatterPayload>()) {
|
||||
// 坏/空 variant:保持空态(不渲染、不崩)。E2+ 可在此显式提示「渲染数据格式错误」。
|
||||
return;
|
||||
}
|
||||
setData(payload.value<geopro::core::ScatterPayload>());
|
||||
}
|
||||
|
||||
void RawDataChartView::setData(const geopro::core::ScatterPayload& p) {
|
||||
data_ = p;
|
||||
if (hoverTip_) hoverTip_->setField(&data_.scatter); // 显式重绑(地址稳定,消除隐式依赖)
|
||||
|
||||
// measurement 载荷(toolbar 非空):首次到来时建并替换工具条(视觉 1:1)。反演留空 → 不动。
|
||||
if (!p.toolbar.empty() && !measurementToolbar_) buildMeasurementToolbar(p.toolbar);
|
||||
|
||||
if (p.scale.empty()) return;
|
||||
|
||||
// 重建 ColorMapService(旧的 scatterItem 已被 QwtPlot detach/delete)
|
||||
delete colorSvc_;
|
||||
colorSvc_ = new ColorMapService(p.scale);
|
||||
|
||||
// 散点颜色归一化对齐原版 Plotly(cmin/cmax 未设 → cauto=数据 min/max),按 vlist 有限值
|
||||
// 的 min/max 设数据范围,使整段色阶铺满数据实际范围。measurement 与反演原数据同此路径:
|
||||
// v 含负异常值(如 -1066),故 cmin<0,中段视电阻率归一化到色阶中部(深品红/紫)。
|
||||
{
|
||||
double vMin = std::numeric_limits<double>::max();
|
||||
double vMax = std::numeric_limits<double>::lowest();
|
||||
for (double v : p.scatter.v) {
|
||||
if (!std::isfinite(v)) continue; // 跳过 NaN/±Inf(脏数据),否则数据范围被污染→全图 NaN 取色
|
||||
if (v < vMin) vMin = v;
|
||||
if (v > vMax) vMax = v;
|
||||
}
|
||||
if (vMin <= vMax) colorSvc_->setDataRange(vMin, vMax);
|
||||
}
|
||||
|
||||
// 卸载旧散点项:QwtPlot 默认 autoDelete=true(析构时 delete 仍在 dict 的 item)。
|
||||
// 必须先 detach()(从 dict 移除)再 delete,否则 QwtPlot 析构时会 double-free。
|
||||
if (scatterItem_) {
|
||||
scatterItem_->detach();
|
||||
delete scatterItem_;
|
||||
scatterItem_ = nullptr;
|
||||
}
|
||||
|
||||
// 新建散点项并挂到 plot
|
||||
scatterItem_ = new ScatterPlotItem();
|
||||
scatterItem_->setData(p.scatter, colorSvc_);
|
||||
scatterItem_->attach(plot_);
|
||||
|
||||
// 按数据包围盒设置轴范围
|
||||
QRectF bbox = scatterItem_->boundingRect();
|
||||
if (!bbox.isEmpty()) {
|
||||
plot_->setAxisScale(QwtPlot::xTop, bbox.left(), bbox.right());
|
||||
plot_->setAxisScale(QwtPlot::yLeft, bbox.top(), bbox.bottom());
|
||||
}
|
||||
plot_->updateAxes();
|
||||
if (rescaler_) rescaler_->rescale(); // 应用真实比尺
|
||||
plot_->replot();
|
||||
|
||||
// 更新色阶条:measurement 用右侧竖条,反演原数据用底部横条(二选一显示)。
|
||||
if (p.verticalLegend) {
|
||||
colorBarV_->setColorScale(p.scale);
|
||||
colorBarV_->setVisible(true);
|
||||
colorBar_->setVisible(false);
|
||||
} else {
|
||||
colorBar_->setColorScale(p.scale);
|
||||
colorBar_->setVisible(true);
|
||||
colorBarV_->setVisible(false);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace geopro::app
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
#pragma once
|
||||
#include <QWidget>
|
||||
#include "model/detail/DetailPayloads.hpp"
|
||||
#include "panels/chart/ColorMapService.hpp"
|
||||
#include "panels/chart/IDetailView.hpp"
|
||||
|
||||
class QComboBox;
|
||||
class QVBoxLayout;
|
||||
class QwtPlot;
|
||||
class QwtPlotRescaler;
|
||||
|
||||
namespace geopro::app {
|
||||
|
||||
class ColorBarWidget;
|
||||
class ScatterPlotItem;
|
||||
class ScatterHoverTip;
|
||||
|
||||
// 原数据图表视图:工具条 + QwtPlot(x 轴顶部、Panner/Magnifier)+ 独立色阶条。
|
||||
class RawDataChartView : public QWidget, public IDetailView {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit RawDataChartView(QWidget* parent = nullptr);
|
||||
~RawDataChartView() override;
|
||||
|
||||
// 渲染散点载荷(解包后调内部渲染;渲染代码不变)。
|
||||
void setData(const geopro::core::ScatterPayload& p);
|
||||
|
||||
// IDetailView:壳/工厂统一入口。坏/空 variant → 保持空态不崩。
|
||||
QWidget* widget() override { return this; }
|
||||
void setPayload(const QVariant& payload) override;
|
||||
|
||||
// 供外部访问(已不再是占位,保留兼容接口返回 plot_)
|
||||
QWidget* plotArea() const;
|
||||
|
||||
private:
|
||||
// 工具条按载荷类型二选一:反演原数据 = ctor 默认建的 inversion 工具条;measurement =
|
||||
// 首个非空 ScatterToolbarConf 到来时建一次并替换(视觉 1:1)。建好后缓存,后续 setData 复用。
|
||||
void buildMeasurementToolbar(const geopro::core::ScatterToolbarConf& conf);
|
||||
// 按当前 x/y 下拉选择,从备选列重绘散点(本地,无网络)。code 为下拉项 fieldCode。
|
||||
void replotForAxis();
|
||||
// “暂未实现”轻提示(占位按钮/下拉点击)。
|
||||
void showNotImplemented(QWidget* anchor);
|
||||
|
||||
geopro::core::ScatterPayload data_;
|
||||
QwtPlot* plot_;
|
||||
QwtPlotRescaler* rescaler_ = nullptr; // 锁定 x:y 真实比尺
|
||||
ColorBarWidget* colorBar_; // 底部横条(反演原数据)
|
||||
ColorBarWidget* colorBarV_; // 右侧竖条(measurement)
|
||||
QComboBox* chartTypeCombo_ = nullptr;
|
||||
|
||||
QVBoxLayout* rootLay_ = nullptr; // 顶层竖向布局(用于替换工具条)
|
||||
QWidget* toolbar_ = nullptr; // 当前工具条(inversion 或 measurement)
|
||||
bool measurementToolbar_ = false; // 已建 measurement 工具条
|
||||
QComboBox* xCombo_ = nullptr; // measurement x 下拉
|
||||
QComboBox* yCombo_ = nullptr; // measurement y 下拉
|
||||
|
||||
// 使用 unique_ptr 管理生命周期;attach 后 QwtPlot 接管绘制,但我们仍持有指针
|
||||
ColorMapService* colorSvc_ = nullptr; // heap,由 setData 重建
|
||||
ScatterPlotItem* scatterItem_ = nullptr;
|
||||
ScatterHoverTip* hoverTip_ = nullptr; // 散点 hover 提示(QObject,this 持有)
|
||||
};
|
||||
|
||||
} // namespace geopro::app
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
#include "panels/chart/ScatterHoverTip.hpp"
|
||||
|
||||
#include <QEvent>
|
||||
#include <QMouseEvent>
|
||||
#include <QToolTip>
|
||||
#include <qwt_plot.h>
|
||||
#include <qwt_plot_canvas.h>
|
||||
#include <qwt_scale_map.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <limits>
|
||||
|
||||
namespace geopro::app {
|
||||
|
||||
ScatterHoverTip::ScatterHoverTip(QwtPlot* plot, int xAxis, int yAxis, QObject* parent)
|
||||
: QObject(parent), plot_(plot), xAxis_(xAxis), yAxis_(yAxis) {
|
||||
if (plot_ && plot_->canvas()) {
|
||||
// 默认 widget 仅在按键按下时才收到 MouseMove;hover(无按键)需开启鼠标跟踪。
|
||||
plot_->canvas()->setMouseTracking(true);
|
||||
plot_->canvas()->installEventFilter(this);
|
||||
}
|
||||
}
|
||||
|
||||
bool ScatterHoverTip::eventFilter(QObject* obj, QEvent* ev) {
|
||||
if (!plot_ || obj != plot_->canvas()) return QObject::eventFilter(obj, ev);
|
||||
|
||||
if (ev->type() == QEvent::Leave) {
|
||||
QToolTip::hideText();
|
||||
return false;
|
||||
}
|
||||
if (ev->type() != QEvent::MouseMove) return QObject::eventFilter(obj, ev);
|
||||
|
||||
auto* me = static_cast<QMouseEvent*>(ev);
|
||||
// 拖动平移中(有按键)不弹提示——交给 LivePanner;无数据则跳过。
|
||||
if (me->buttons() != Qt::NoButton || !field_) return false;
|
||||
|
||||
const auto& xs = field_->x;
|
||||
const auto& ys = field_->y;
|
||||
const auto& vs = field_->v;
|
||||
const std::size_t n = std::min(xs.size(), ys.size());
|
||||
|
||||
// 在像素空间找最近散点(与 ScatterPlotItem 同用 canvasMap)。
|
||||
const QwtScaleMap xMap = plot_->canvasMap(xAxis_);
|
||||
const QwtScaleMap yMap = plot_->canvasMap(yAxis_);
|
||||
const QPointF mp = me->position();
|
||||
|
||||
// 电极 hover(y=0 行,与数据点 hover 分开):电极 marker 略大于数据方块,
|
||||
// 命中半径稍大。命中则显示 x/y/num 浮动框(measurement 专有,反演 electrodeX 空→跳过)。
|
||||
{
|
||||
const auto& ex = field_->electrodeX;
|
||||
const auto& eno = field_->electrodeNo;
|
||||
const double ey0 = yMap.transform(0.0);
|
||||
double eBestD2 = std::numeric_limits<double>::max();
|
||||
std::size_t eBestI = 0;
|
||||
for (std::size_t i = 0; i < ex.size(); ++i) {
|
||||
const double dx = xMap.transform(ex[i]) - mp.x();
|
||||
const double dy = ey0 - mp.y();
|
||||
const double d2 = dx * dx + dy * dy;
|
||||
if (d2 < eBestD2) {
|
||||
eBestD2 = d2;
|
||||
eBestI = i;
|
||||
}
|
||||
}
|
||||
if (eBestD2 <= kElectrodeHitRadiusPx * kElectrodeHitRadiusPx) {
|
||||
const int num = (eBestI < eno.size())
|
||||
? static_cast<int>(eno[eBestI])
|
||||
: static_cast<int>(eBestI) + 1;
|
||||
QToolTip::showText(me->globalPosition().toPoint(),
|
||||
electrodeHoverText(ex[eBestI], num), plot_->canvas());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (n == 0) {
|
||||
QToolTip::hideText();
|
||||
return false;
|
||||
}
|
||||
|
||||
double bestD2 = std::numeric_limits<double>::max();
|
||||
std::size_t bestI = 0;
|
||||
for (std::size_t i = 0; i < n; ++i) {
|
||||
const double dx = xMap.transform(xs[i]) - mp.x();
|
||||
const double dy = yMap.transform(ys[i]) - mp.y();
|
||||
const double d2 = dx * dx + dy * dy;
|
||||
if (d2 < bestD2) {
|
||||
bestD2 = d2;
|
||||
bestI = i;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestD2 <= kHitRadiusPx * kHitRadiusPx) {
|
||||
const double v = (bestI < vs.size()) ? vs[bestI] : 0.0;
|
||||
// 有 A/B/M/N 元数据(measurement)→ 浮动框含 X/Y/Value/a/b/m/n;否则回退 X/Y/值(反演原数据)。
|
||||
const auto& a = field_->a;
|
||||
QString text;
|
||||
if (bestI < a.size() && bestI < field_->b.size() && bestI < field_->m.size() &&
|
||||
bestI < field_->n.size()) {
|
||||
text = measurementHoverText(xs[bestI], ys[bestI], v, a[bestI], field_->b[bestI],
|
||||
field_->m[bestI], field_->n[bestI]);
|
||||
} else {
|
||||
text = scatterHoverText(xs[bestI], ys[bestI], v);
|
||||
}
|
||||
QToolTip::showText(me->globalPosition().toPoint(), text, plot_->canvas());
|
||||
} else {
|
||||
QToolTip::hideText();
|
||||
}
|
||||
return false; // 不消费,保留其它过滤器(LivePanner)链路
|
||||
}
|
||||
|
||||
} // namespace geopro::app
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
#pragma once
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include "model/Field.hpp" // core::ScatterField
|
||||
|
||||
class QwtPlot;
|
||||
|
||||
namespace geopro::app {
|
||||
|
||||
// 格式化散点 hover 提示文本,对齐原版 Plotly hovertemplate:
|
||||
// <b>X:</b> {x:.3f}<br><b>Y:</b> {y:.3f}<br><b>值:</b> {v:.3f}
|
||||
// inline 纯函数:无 qwt 依赖,可独立单测。
|
||||
inline QString scatterHoverText(double x, double y, double v) {
|
||||
return QStringLiteral("<b>X:</b> %1<br><b>Y:</b> %2<br><b>值:</b> %3")
|
||||
.arg(x, 0, 'f', 3)
|
||||
.arg(y, 0, 'f', 3)
|
||||
.arg(v, 0, 'f', 3);
|
||||
}
|
||||
|
||||
// measurement 散点 hover 浮动框:对齐原版 Plotly 浮动框多行
|
||||
// X / Y / Value(视电阻率 col16) / a / b / m / n。
|
||||
inline QString measurementHoverText(double x, double y, double v,
|
||||
double a, double b, double m, double n) {
|
||||
return QStringLiteral(
|
||||
"<b>X:</b> %1<br><b>Y:</b> %2<br><b>Value:</b> %3"
|
||||
"<br><b>a:</b> %4<br><b>b:</b> %5<br><b>m:</b> %6<br><b>n:</b> %7")
|
||||
.arg(x, 0, 'f', 3)
|
||||
.arg(y, 0, 'f', 3)
|
||||
.arg(v, 0, 'f', 3)
|
||||
.arg(a, 0, 'g', 6)
|
||||
.arg(b, 0, 'g', 6)
|
||||
.arg(m, 0, 'g', 6)
|
||||
.arg(n, 0, 'g', 6);
|
||||
}
|
||||
|
||||
// 电极 hover 浮动框:对齐原版 Plotly 电极 trace 浮动框(与数据点 hover 分开),
|
||||
// x: {slopeDistance}<br>y: 0<br>num: {electrodeNo}
|
||||
// x 用 6 位有效数字(对齐原版,如 5.123837),num 为电极编号(1-based 整数)。
|
||||
inline QString electrodeHoverText(double x, int num) {
|
||||
return QStringLiteral("x: %1<br>y: 0<br>num: %2")
|
||||
.arg(x, 0, 'g', 7)
|
||||
.arg(num);
|
||||
}
|
||||
|
||||
// 散点 hover 提示:监听画布鼠标移动(无按键时),找最近散点,
|
||||
// 命中半径内用 QToolTip 显示 X/Y/值(对齐原版 Plotly 悬浮)。
|
||||
// 不消费事件,与 LivePanner(左键平移/滚轮缩放)共存。
|
||||
class ScatterHoverTip : public QObject {
|
||||
Q_OBJECT
|
||||
public:
|
||||
ScatterHoverTip(QwtPlot* plot, int xAxis, int yAxis, QObject* parent = nullptr);
|
||||
|
||||
// 数据由 RawDataChartView 持有(其成员 data_.scatter,地址稳定);本类只读不拥有。
|
||||
void setField(const core::ScatterField* field) { field_ = field; }
|
||||
|
||||
protected:
|
||||
bool eventFilter(QObject* obj, QEvent* ev) override;
|
||||
|
||||
private:
|
||||
QwtPlot* plot_;
|
||||
int xAxis_;
|
||||
int yAxis_;
|
||||
const core::ScatterField* field_ = nullptr;
|
||||
|
||||
static constexpr double kHitRadiusPx = 6.0; // 命中半径(像素),对齐方块 marker 尺寸
|
||||
static constexpr double kElectrodeHitRadiusPx = 7.0; // 电极菱形略大,命中半径稍大
|
||||
};
|
||||
|
||||
} // namespace geopro::app
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
#include "panels/chart/ScatterPlotItem.hpp"
|
||||
#include <QPainter>
|
||||
#include <QPolygonF>
|
||||
#include <qwt_plot.h>
|
||||
#include <qwt_scale_map.h>
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <limits>
|
||||
|
||||
namespace geopro::app {
|
||||
|
||||
ScatterPlotItem::ScatterPlotItem()
|
||||
: QwtPlotItem() {
|
||||
setTitle("Scatter");
|
||||
setRenderHint(QwtPlotItem::RenderAntialiased, false);
|
||||
// 关联到 x 顶轴(原数据 x 轴在顶部;与 RawDataChartView 的 setAxisScale(xTop,...) 一致,
|
||||
// 否则项默认用 xBottom 的(未设置/自动)刻度 → 散点被错误压缩)。
|
||||
setXAxis(QwtPlot::xTop);
|
||||
setYAxis(QwtPlot::yLeft);
|
||||
}
|
||||
|
||||
void ScatterPlotItem::setData(const core::ScatterField& field, ColorMapService* svc) {
|
||||
field_ = field;
|
||||
colorSvc_ = svc;
|
||||
|
||||
// 计算数据包围盒
|
||||
if (field_.x.empty() || field_.y.empty()) {
|
||||
bounding_ = QRectF();
|
||||
return;
|
||||
}
|
||||
double xMin = *std::min_element(field_.x.begin(), field_.x.end());
|
||||
double xMax = *std::max_element(field_.x.begin(), field_.x.end());
|
||||
double yMin = *std::min_element(field_.y.begin(), field_.y.end());
|
||||
double yMax = *std::max_element(field_.y.begin(), field_.y.end());
|
||||
// 电极 marker 在 y=0、x=electrodeX 处:纳入包围盒,避免电极被截。
|
||||
for (double ex : field_.electrodeX) { xMin = std::min(xMin, ex); xMax = std::max(xMax, ex); }
|
||||
if (!field_.electrodeX.empty()) { yMin = std::min(yMin, 0.0); yMax = std::max(yMax, 0.0); }
|
||||
// 加少量 margin,避免边界点被截
|
||||
double xM = (xMax - xMin) * 0.03 + 0.1;
|
||||
double yM = (yMax - yMin) * 0.03 + 0.1;
|
||||
bounding_ = QRectF(xMin - xM, yMin - yM,
|
||||
(xMax - xMin) + 2.0 * xM,
|
||||
(yMax - yMin) + 2.0 * yM);
|
||||
}
|
||||
|
||||
QRectF ScatterPlotItem::boundingRect() const {
|
||||
return bounding_;
|
||||
}
|
||||
|
||||
void ScatterPlotItem::draw(QPainter* painter,
|
||||
const QwtScaleMap& xMap,
|
||||
const QwtScaleMap& yMap,
|
||||
const QRectF& /*canvasRect*/) const {
|
||||
if (!colorSvc_) return;
|
||||
const auto& xs = field_.x;
|
||||
const auto& ys = field_.y;
|
||||
const auto& vs = field_.v;
|
||||
// x/y/v 来自服务端独立数组,长度可能不一致——取 min 防越界。
|
||||
const std::size_t n = std::min(xs.size(), ys.size());
|
||||
if (n == 0) return;
|
||||
|
||||
painter->save();
|
||||
painter->setRenderHint(QPainter::Antialiasing, false);
|
||||
|
||||
// 电极位置:y=0 处灰色菱形(先画,置于散点下层)。
|
||||
// 原版 Plotly 电极 marker:实心 #BEBEBE(190,190,190) 填充 + 白色 2px 描边,
|
||||
// 菱形比数据方块略大(size 16 vs 12)。
|
||||
if (!field_.electrodeX.empty()) {
|
||||
const double y0 = yMap.transform(0.0);
|
||||
painter->setPen(QPen(Qt::white, kElectrodePenWidth));
|
||||
painter->setBrush(QColor(190, 190, 190));
|
||||
const double h = kElectrodeHalfSide;
|
||||
for (double ex : field_.electrodeX) {
|
||||
const double px = xMap.transform(ex);
|
||||
QPolygonF dia;
|
||||
dia << QPointF(px, y0 - h) << QPointF(px + h, y0)
|
||||
<< QPointF(px, y0 + h) << QPointF(px - h, y0);
|
||||
painter->drawPolygon(dia);
|
||||
}
|
||||
}
|
||||
|
||||
// 隐藏数据方块:电极已绘,直接收尾(保留电极菱形)。
|
||||
if (!scatterVisible_) { painter->restore(); return; }
|
||||
|
||||
painter->setPen(QPen(Qt::white, kPenWidth));
|
||||
for (std::size_t i = 0; i < n; ++i) {
|
||||
double px = xMap.transform(xs[i]);
|
||||
double py = yMap.transform(ys[i]);
|
||||
double val = (i < vs.size()) ? vs[i] : 0.0;
|
||||
// 非有限值(NaN/±Inf,可能来自降级后端的脏数据):跳过该点,不绘制。
|
||||
// 与 RawDataChartView 的 setDataRange 的 isfinite 跳过一致。
|
||||
if (!std::isfinite(val)) continue;
|
||||
auto c = colorSvc_->colorAtContinuous(val);
|
||||
painter->setBrush(QColor(c.r, c.g, c.b, c.a));
|
||||
painter->drawRect(QRectF(px - kHalfSide, py - kHalfSide,
|
||||
kHalfSide * 2.0, kHalfSide * 2.0));
|
||||
}
|
||||
painter->restore();
|
||||
}
|
||||
|
||||
} // namespace geopro::app
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
#pragma once
|
||||
#include "model/Field.hpp"
|
||||
#include "panels/chart/ColorMapService.hpp"
|
||||
#include <qwt_plot_item.h>
|
||||
#include <QRectF>
|
||||
|
||||
class QPainter;
|
||||
class QwtScaleMap;
|
||||
|
||||
namespace geopro::app {
|
||||
|
||||
// QwtPlotItem:把 ScatterField 数据渲染为彩色方块散点。
|
||||
// 每个点用固定像素边长方块绘制(不随缩放变大),白色描边,
|
||||
// 颜色由 ColorMapService 连续插值决定(cauto,与原版 Plotly 一致;measurement/反演同路径)。
|
||||
class ScatterPlotItem : public QwtPlotItem {
|
||||
public:
|
||||
ScatterPlotItem();
|
||||
|
||||
// 按 ColorMapService 连续插值上色(cauto);数据范围由调用方在 svc 上 setDataRange。
|
||||
void setData(const core::ScatterField& field, ColorMapService* svc);
|
||||
|
||||
// 显示/隐藏数据方块(measurement 工具条“显示/隐藏”)。电极菱形不受影响,始终绘制。
|
||||
void setScatterVisible(bool on) { scatterVisible_ = on; }
|
||||
|
||||
int rtti() const override { return QwtPlotItem::Rtti_PlotUserItem; }
|
||||
|
||||
QRectF boundingRect() const override;
|
||||
|
||||
void draw(QPainter* painter,
|
||||
const QwtScaleMap& xMap,
|
||||
const QwtScaleMap& yMap,
|
||||
const QRectF& canvasRect) const override;
|
||||
|
||||
private:
|
||||
core::ScatterField field_;
|
||||
ColorMapService* colorSvc_ = nullptr; // 不拥有,由 RawDataChartView 持有
|
||||
QRectF bounding_;
|
||||
bool scatterVisible_ = true; // false:仅画电极,隐藏数据方块
|
||||
|
||||
// 数据方块:原版 Plotly marker.size 12px(绝对像素直径)→ 半边长 6px,真 1:1。
|
||||
static constexpr double kHalfSide = 6.0; // 方块半边长(像素,全宽 12px)
|
||||
static constexpr double kPenWidth = 1.0; // 白色描边宽度(像素)
|
||||
// 电极菱形:原版 Plotly marker.size 16px(绝对像素直径)→ 半对角 8px,真 1:1。
|
||||
// 相对数据方块 ≈1.33×(16 vs 12),实心 #BEBEBE 填充 + 白色 2px 描边。
|
||||
static constexpr double kElectrodeHalfSide = 8.0; // 半对角(像素,全宽 16px)
|
||||
static constexpr double kElectrodePenWidth = 2.0; // 电极白色描边宽度(像素)
|
||||
};
|
||||
|
||||
} // namespace geopro::app
|
||||
|
|
@ -0,0 +1,188 @@
|
|||
#include "panels/chart/TablePager.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include <QComboBox>
|
||||
#include <QHBoxLayout>
|
||||
#include <QIntValidator>
|
||||
#include <QLabel>
|
||||
#include <QLineEdit>
|
||||
#include <QSignalBlocker>
|
||||
#include <QToolButton>
|
||||
|
||||
namespace geopro::app {
|
||||
|
||||
namespace {
|
||||
// 每页条数选项(实测原版 vxe-pager 下拉:50/100/500/1000,默认 50)。
|
||||
const int kPageSizes[] = {50, 100, 500, 1000};
|
||||
constexpr int kPagerCount = 7; // 页码窗口(>此值用 … 折叠,对齐 vxe 默认)
|
||||
constexpr int kJumpStep = 5; // 点击 … 的跳页步长
|
||||
|
||||
// 分页器样式:常态不设字色(跟随主题/全局样式表);边框用半透明灰(两主题通用);
|
||||
// hover/选中页用强调蓝 #409EFF(与 DataTableView 开关同色)。
|
||||
const char* kPagerQss = R"(
|
||||
QToolButton {
|
||||
border: 1px solid rgba(128,128,128,0.35);
|
||||
border-radius: 3px;
|
||||
padding: 1px 6px;
|
||||
min-width: 16px;
|
||||
min-height: 20px;
|
||||
background: transparent;
|
||||
}
|
||||
QToolButton:hover:enabled { color: #409EFF; border-color: #409EFF; }
|
||||
QToolButton[active="true"] { color: #409EFF; border-color: #409EFF; font-weight: bold; }
|
||||
QToolButton:disabled { color: rgba(128,128,128,0.55); }
|
||||
)";
|
||||
} // namespace
|
||||
|
||||
TablePager::TablePager(QWidget* parent) : QWidget(parent) {
|
||||
setStyleSheet(QString::fromUtf8(kPagerQss));
|
||||
|
||||
auto* lay = new QHBoxLayout(this);
|
||||
lay->setContentsMargins(8, 6, 8, 6);
|
||||
lay->setSpacing(6);
|
||||
lay->addStretch(1); // 右对齐
|
||||
|
||||
prevBtn_ = new QToolButton(this);
|
||||
prevBtn_->setText(QStringLiteral("‹"));
|
||||
prevBtn_->setCursor(Qt::PointingHandCursor);
|
||||
connect(prevBtn_, &QToolButton::clicked, this,
|
||||
[this] { emit pageRequested(pageNo_ - 1, pageSize_); });
|
||||
lay->addWidget(prevBtn_);
|
||||
|
||||
numHost_ = new QWidget(this);
|
||||
numLay_ = new QHBoxLayout(numHost_);
|
||||
numLay_->setContentsMargins(0, 0, 0, 0);
|
||||
numLay_->setSpacing(6);
|
||||
lay->addWidget(numHost_);
|
||||
|
||||
nextBtn_ = new QToolButton(this);
|
||||
nextBtn_->setText(QStringLiteral("›"));
|
||||
nextBtn_->setCursor(Qt::PointingHandCursor);
|
||||
connect(nextBtn_, &QToolButton::clicked, this,
|
||||
[this] { emit pageRequested(pageNo_ + 1, pageSize_); });
|
||||
lay->addWidget(nextBtn_);
|
||||
|
||||
auto* gotoLabel = new QLabel(QStringLiteral("前往"), this);
|
||||
lay->addWidget(gotoLabel);
|
||||
|
||||
jumpEdit_ = new QLineEdit(this);
|
||||
jumpEdit_->setFixedWidth(40);
|
||||
jumpEdit_->setAlignment(Qt::AlignCenter);
|
||||
jumpValidator_ = new QIntValidator(1, 1, jumpEdit_);
|
||||
jumpEdit_->setValidator(jumpValidator_);
|
||||
connect(jumpEdit_, &QLineEdit::returnPressed, this, [this] {
|
||||
bool ok = false;
|
||||
int p = jumpEdit_->text().toInt(&ok);
|
||||
const int pc = pageCount();
|
||||
if (!ok) {
|
||||
jumpEdit_->setText(QString::number(pageNo_));
|
||||
return;
|
||||
}
|
||||
p = std::min(std::max(1, p), std::max(1, pc));
|
||||
if (p == pageNo_) {
|
||||
jumpEdit_->setText(QString::number(pageNo_));
|
||||
return;
|
||||
}
|
||||
emit pageRequested(p, pageSize_);
|
||||
});
|
||||
lay->addWidget(jumpEdit_);
|
||||
|
||||
lay->addWidget(new QLabel(QStringLiteral("页"), this));
|
||||
|
||||
sizeCombo_ = new QComboBox(this);
|
||||
for (int s : kPageSizes)
|
||||
sizeCombo_->addItem(QStringLiteral("%1条/页").arg(s), s);
|
||||
connect(sizeCombo_, &QComboBox::activated, this, [this](int i) {
|
||||
emit pageRequested(1, sizeCombo_->itemData(i).toInt()); // 改每页条数 → 回第 1 页
|
||||
});
|
||||
lay->addWidget(sizeCombo_);
|
||||
|
||||
totalLabel_ = new QLabel(this);
|
||||
lay->addWidget(totalLabel_);
|
||||
|
||||
setState(0, 1, pageSize_);
|
||||
}
|
||||
|
||||
int TablePager::pageCount() const {
|
||||
if (pageSize_ <= 0) return 1;
|
||||
return std::max(1, (total_ + pageSize_ - 1) / pageSize_);
|
||||
}
|
||||
|
||||
void TablePager::setState(int total, int pageNo, int pageSize) {
|
||||
total_ = std::max(0, total);
|
||||
if (pageSize > 0) pageSize_ = pageSize;
|
||||
const int pc = pageCount();
|
||||
pageNo_ = std::min(std::max(1, pageNo), pc);
|
||||
|
||||
prevBtn_->setEnabled(pageNo_ > 1);
|
||||
nextBtn_->setEnabled(pageNo_ < pc);
|
||||
|
||||
jumpValidator_->setTop(pc);
|
||||
{
|
||||
QSignalBlocker b(jumpEdit_);
|
||||
jumpEdit_->setText(QString::number(pageNo_));
|
||||
}
|
||||
{
|
||||
QSignalBlocker b(sizeCombo_);
|
||||
int idx = sizeCombo_->findData(pageSize_);
|
||||
if (idx < 0) { // 非预设条数(兜底):补一项再选中
|
||||
sizeCombo_->addItem(QStringLiteral("%1条/页").arg(pageSize_), pageSize_);
|
||||
idx = sizeCombo_->findData(pageSize_);
|
||||
}
|
||||
sizeCombo_->setCurrentIndex(idx);
|
||||
}
|
||||
totalLabel_->setText(QStringLiteral("共 %1 条记录").arg(total_));
|
||||
|
||||
rebuildNumbers();
|
||||
}
|
||||
|
||||
void TablePager::rebuildNumbers() {
|
||||
// 清空旧页码按钮。
|
||||
while (QLayoutItem* it = numLay_->takeAt(0)) {
|
||||
if (QWidget* w = it->widget()) w->deleteLater();
|
||||
delete it;
|
||||
}
|
||||
|
||||
const int pc = pageCount();
|
||||
auto addNum = [this](int p, bool active) {
|
||||
auto* b = new QToolButton(numHost_);
|
||||
b->setText(QString::number(p));
|
||||
b->setCursor(Qt::PointingHandCursor);
|
||||
b->setProperty("active", active);
|
||||
b->setEnabled(!active); // 当前页不可再点
|
||||
connect(b, &QToolButton::clicked, this, [this, p] { emit pageRequested(p, pageSize_); });
|
||||
numLay_->addWidget(b);
|
||||
};
|
||||
auto addDots = [this](int target) {
|
||||
auto* b = new QToolButton(numHost_);
|
||||
b->setText(QStringLiteral("..."));
|
||||
b->setCursor(Qt::PointingHandCursor);
|
||||
connect(b, &QToolButton::clicked, this,
|
||||
[this, target] { emit pageRequested(target, pageSize_); });
|
||||
numLay_->addWidget(b);
|
||||
};
|
||||
|
||||
if (pc <= kPagerCount) {
|
||||
for (int p = 1; p <= pc; ++p) addNum(p, p == pageNo_);
|
||||
return;
|
||||
}
|
||||
// 折叠窗口:首页 [ … ] 中段(当前±2) [ … ] 末页。
|
||||
addNum(1, pageNo_ == 1);
|
||||
int left = pageNo_ - 2;
|
||||
int right = pageNo_ + 2;
|
||||
if (left < 2) {
|
||||
left = 2;
|
||||
right = 5;
|
||||
}
|
||||
if (right > pc - 1) {
|
||||
right = pc - 1;
|
||||
left = pc - 4;
|
||||
}
|
||||
if (left > 2) addDots(std::max(1, pageNo_ - kJumpStep));
|
||||
for (int p = left; p <= right; ++p) addNum(p, p == pageNo_);
|
||||
if (right < pc - 1) addDots(std::min(pc, pageNo_ + kJumpStep));
|
||||
addNum(pc, pageNo_ == pc);
|
||||
}
|
||||
|
||||
} // namespace geopro::app
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
#pragma once
|
||||
#include <QWidget>
|
||||
|
||||
class QToolButton;
|
||||
class QLineEdit;
|
||||
class QComboBox;
|
||||
class QLabel;
|
||||
class QHBoxLayout;
|
||||
class QIntValidator;
|
||||
|
||||
namespace geopro::app {
|
||||
|
||||
// 分页器(对齐原版 vxe-pager,size--mini):上一页 / 页码(多页带 … 省略跳页)/ 下一页 +
|
||||
// 「前往 [n] 页」跳页框 + 每页条数下拉 [50/100/500/1000] + 「共 N 条记录」。右对齐。
|
||||
// 数据驱动:setState(total,pageNo,pageSize) 重建页码并同步各控件。任何翻页/改每页条数都发
|
||||
// pageRequested(改每页条数时回到第 1 页,镜像原版)。仅展示与请求,不持有数据。
|
||||
// 配色:常态随主题(不显式设色,跟随全局样式表);hover/选中页用强调蓝 #409EFF(与表格开关同色)。
|
||||
class TablePager : public QWidget {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit TablePager(QWidget* parent = nullptr);
|
||||
|
||||
// 用总数/当前页/每页条数刷新分页器(重建页码按钮、同步跳页框/下拉/总数文案)。
|
||||
void setState(int total, int pageNo, int pageSize);
|
||||
|
||||
signals:
|
||||
// 请求加载某页(翻页/跳页/改每页条数)。改每页条数时 pageNo=1。
|
||||
void pageRequested(int pageNo, int pageSize);
|
||||
|
||||
private:
|
||||
int pageCount() const; // ceil(total/pageSize),至少 1
|
||||
void rebuildNumbers(); // 按当前页重建页码按钮(含 … 跳页)
|
||||
|
||||
int total_ = 0;
|
||||
int pageNo_ = 1;
|
||||
int pageSize_ = 50;
|
||||
|
||||
QToolButton* prevBtn_ = nullptr;
|
||||
QToolButton* nextBtn_ = nullptr;
|
||||
QWidget* numHost_ = nullptr;
|
||||
QHBoxLayout* numLay_ = nullptr;
|
||||
QLineEdit* jumpEdit_ = nullptr;
|
||||
QIntValidator* jumpValidator_ = nullptr;
|
||||
QComboBox* sizeCombo_ = nullptr;
|
||||
QLabel* totalLabel_ = nullptr;
|
||||
};
|
||||
|
||||
} // namespace geopro::app
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
#include "panels/chart/TrajectoryMapView.hpp"
|
||||
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QUrl>
|
||||
#include <QVBoxLayout>
|
||||
#include <QWebEngineView>
|
||||
|
||||
namespace geopro::app {
|
||||
|
||||
namespace {
|
||||
|
||||
// MapPayload → 紧凑 JSON 数组串 "[{\"no\":1,\"lon\":..,\"lat\":..}, ...]"(供页面 setPoints)。
|
||||
QString pointsToJson(const geopro::core::MapPayload& m) {
|
||||
QJsonArray arr;
|
||||
for (const auto& p : m.points) {
|
||||
QJsonObject o;
|
||||
o.insert(QStringLiteral("no"), p.electrodeNo);
|
||||
o.insert(QStringLiteral("lon"), p.lon);
|
||||
o.insert(QStringLiteral("lat"), p.lat);
|
||||
arr.append(o);
|
||||
}
|
||||
return QString::fromUtf8(QJsonDocument(arr).toJson(QJsonDocument::Compact));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TrajectoryMapView::TrajectoryMapView(QWidget* parent) : QWidget(parent) {
|
||||
auto* lay = new QVBoxLayout(this);
|
||||
lay->setContentsMargins(0, 0, 0, 0);
|
||||
lay->setSpacing(0);
|
||||
|
||||
view_ = new QWebEngineView(this);
|
||||
lay->addWidget(view_, 1);
|
||||
|
||||
// 页面加载完成 → 标记就绪并注入待推送的点(载荷可能先到)。
|
||||
QObject::connect(view_, &QWebEngineView::loadFinished, this, [this](bool ok) {
|
||||
pageLoaded_ = ok;
|
||||
if (ok) injectPoints();
|
||||
});
|
||||
|
||||
view_->load(QUrl(QStringLiteral("qrc:/map/trajectory_map.html")));
|
||||
}
|
||||
|
||||
void TrajectoryMapView::setPayload(const QVariant& payload) {
|
||||
if (!payload.canConvert<geopro::core::MapPayload>()) return; // 坏/空 → 忽略
|
||||
pendingJson_ = pointsToJson(payload.value<geopro::core::MapPayload>());
|
||||
if (pageLoaded_) injectPoints(); // 页面已就绪则立即注入,否则待 loadFinished
|
||||
}
|
||||
|
||||
void TrajectoryMapView::injectPoints() {
|
||||
if (!pageLoaded_ || pendingJson_.isEmpty()) return;
|
||||
// 无 QWebChannel:单向经 runJavaScript 调页面侧 setPoints(arr)。
|
||||
view_->page()->runJavaScript(QStringLiteral("setPoints(%1)").arg(pendingJson_));
|
||||
}
|
||||
|
||||
} // namespace geopro::app
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
#pragma once
|
||||
#include <QString>
|
||||
#include <QWidget>
|
||||
|
||||
#include "model/detail/DetailPayloads.hpp"
|
||||
#include "panels/chart/IDetailView.hpp"
|
||||
|
||||
class QWebEngineView;
|
||||
|
||||
namespace geopro::app {
|
||||
|
||||
// 轨迹「地图」页签视图(dd_trajectory_data 地图页签):
|
||||
// QWebEngineView 加载本地 MapLibre GL 地图页(qrc:/map/trajectory_map.html),WebGL 渲染,
|
||||
// 底图用天地图 WMTS 栅格瓦片 + 注记瓦片,含街道/卫星/地形三套底图切换(仅瓦片联网,
|
||||
// MapLibre 库本地内置于 qrc)。
|
||||
// setPayload 解包 MapPayload(电极经纬点),构造 JSON 数组后经 page()->runJavaScript()
|
||||
// 调页面侧 setPoints(arr) 画实心橙点 + 连线 + 起点标记 + 取景;无 QWebChannel(未安装),单向推数据。
|
||||
// 载荷可能早于页面 loadFinished 到达 → 暂存,待 loadFinished 后再注入(幂等)。
|
||||
class TrajectoryMapView : public QWidget, public IDetailView {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit TrajectoryMapView(QWidget* parent = nullptr);
|
||||
|
||||
QWidget* widget() override { return this; }
|
||||
void setPayload(const QVariant& payload) override; // 坏/空 variant → 忽略
|
||||
|
||||
private:
|
||||
void injectPoints(); // 把 pendingJson_ 推入页面(仅页面就绪时)
|
||||
|
||||
QWebEngineView* view_ = nullptr;
|
||||
QString pendingJson_; // 最近一次载荷的 JS 数组串("[]" 表示无点)
|
||||
bool pageLoaded_ = false;
|
||||
};
|
||||
|
||||
} // namespace geopro::app
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
#pragma once
|
||||
#include <vector>
|
||||
#include "IDatasetChartStrategy.hpp" // geopro::controller
|
||||
namespace geopro::app {
|
||||
|
||||
// ERT 电极坐标轨迹(dd_trajectory_data)策略:地图 / 列表 / 高程 三页签(页签序对齐原版)。
|
||||
// 地图(默认/首位)= WebMap(QWebEngineView + Leaflet + 天地图瓦片;traj.map 产 MapPayload,
|
||||
// 端点 trajectory/line,electrodeCoordinate → 经纬点)。
|
||||
// 列表 = Table(懒加载,gridHeaderDisplay+rowList → TablePayload)。
|
||||
// 高程 = LineProfile(平滑折线,electrodeNo→elevation)。
|
||||
// 列表与高程同端点 trajectory/rows,loaderKey 不同(traj.rows 产 TablePayload / traj.elev 产 LinePayload)。
|
||||
struct TrajectoryStrategy : controller::IDatasetChartStrategy {
|
||||
std::string ddCode() const override { return "dd_trajectory_data"; }
|
||||
std::vector<controller::TabSpec> tabs() const override {
|
||||
return {
|
||||
{QStringLiteral("地图"), controller::ViewKind::WebMap,
|
||||
QStringLiteral("traj.map"), /*lazy*/ false, /*paginated*/ false},
|
||||
{QStringLiteral("列表"), controller::ViewKind::Table,
|
||||
QStringLiteral("traj.rows"), /*lazy*/ true, /*paginated*/ false},
|
||||
{QStringLiteral("高程"), controller::ViewKind::LineProfile,
|
||||
QStringLiteral("traj.elev"), /*lazy*/ false, /*paginated*/ false},
|
||||
};
|
||||
}
|
||||
};
|
||||
} // namespace geopro::app
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
<!DOCTYPE RCC>
|
||||
<RCC version="1.0">
|
||||
<qresource prefix="/map">
|
||||
<file>trajectory_map.html</file>
|
||||
<file>maplibre-gl.js</file>
|
||||
<file>maplibre-gl.css</file>
|
||||
</qresource>
|
||||
</RCC>
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,363 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>电极轨迹地图</title>
|
||||
<link rel="stylesheet" href="maplibre-gl.css" />
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; height: 100%; }
|
||||
#map { position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: #f0eee0; }
|
||||
|
||||
/* 底图切换控件:右上角小圆角按钮组(街道 / 卫星)。主题中性(纯 Web 控件)。 */
|
||||
.basemap-switcher {
|
||||
display: flex;
|
||||
background: #ffffff;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
|
||||
overflow: hidden;
|
||||
font: 13px/1 "Microsoft YaHei", Arial, sans-serif;
|
||||
}
|
||||
.basemap-switcher button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #333;
|
||||
padding: 6px 12px;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
border-right: 1px solid #e0e0e0;
|
||||
transition: background 120ms ease;
|
||||
}
|
||||
.basemap-switcher button:last-child { border-right: 0; }
|
||||
.basemap-switcher button:hover { background: #f2f2f2; }
|
||||
.basemap-switcher button.active {
|
||||
background: #ff8c00;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* 定位/回到测线控件:白色小圆角方块 + 十字准星图标(对齐主流地图「回到当前位置」按钮)。 */
|
||||
.locate-ctrl {
|
||||
background: #ffffff;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: background 120ms ease;
|
||||
}
|
||||
.locate-ctrl:hover { background: #f2f2f2; }
|
||||
.locate-ctrl svg { display: block; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="map"></div>
|
||||
<script src="maplibre-gl.js"></script>
|
||||
<script>
|
||||
// 天地图 WMTS 令牌(实证:与原版一致)。
|
||||
var TK = 'aca91d8c9f59a4f779f39061b8a07737';
|
||||
// 子域 t0-t7(MapLibre raster source 用 tiles 数组列全所有子域主机实现负载分担)。
|
||||
var SUBS = ['0', '1', '2', '3', '4', '5', '6', '7'];
|
||||
|
||||
// 电极轨迹橙(线 + 点 + 起点标记共用一个常量;对齐先前修复后的实心橙观感)。
|
||||
var TRAJ_ORANGE = '#ff8c00';
|
||||
|
||||
// 天地图各图层的原生最细瓦片层:vec/cva(街道)、img/cia(卫星)原生到 z18。
|
||||
// 把每个 source 的 maxzoom 设为其真实原生上限后,MapLibre 在该层级之上【对已有最细瓦片
|
||||
// 超采样放大(over-zoom upscale)】而不会去请求不存在的更细瓦片(否则 404 → 无数据 →
|
||||
// 整图退化成纯蓝灰)。地图 maxZoom=22 仍是人工超放大上限。
|
||||
// 注:地形(ter/cta)底图已移除——其原生只到 z14,缩放太浅不可用。
|
||||
var TILE_MAXZOOM = 18; // 街道/卫星原生最细
|
||||
var MAP_MAXZOOM = 22;
|
||||
var MAP_MINZOOM = 1;
|
||||
|
||||
// 构造某一天地图图层的 WMTS XYZ 瓦片 URL 模板数组(列全 t0-t7 子域主机)。
|
||||
// layerDir 形如 'vec_w'(URL 路径段),LAYER 形如 'vec'(KVP 参数)。
|
||||
function tdtTiles(layerDir, LAYER) {
|
||||
return SUBS.map(function (s) {
|
||||
return 'http://t' + s + '.tianditu.gov.cn/' + layerDir + '/wmts' +
|
||||
'?service=wmts&request=GetTile&version=1.0.0' +
|
||||
'&LAYER=' + LAYER +
|
||||
'&tileMatrixSet=w&TileMatrix={z}&TileRow={y}&TileCol={x}' +
|
||||
'&style=default&format=tiles&tk=' + TK;
|
||||
});
|
||||
}
|
||||
|
||||
// 两套底图:每套 = 底图层 + 注记叠加层(标准配对 vec/cva、img/cia)。
|
||||
// maxzoom 按图层原生上限给:街道/卫星均 18(base 与其注记同上限)。
|
||||
var BASEMAPS = {
|
||||
street: { base: ['vec_w', 'vec'], anno: ['cva_w', 'cva'], maxzoom: TILE_MAXZOOM }, // 街道(默认)
|
||||
satellite: { base: ['img_w', 'img'], anno: ['cia_w', 'cia'], maxzoom: TILE_MAXZOOM }, // 卫星
|
||||
};
|
||||
var DEFAULT_BASEMAP = 'street';
|
||||
|
||||
// 为某套底图注册 source(base 或 anno)。maxzoom 取该套底图的原生上限:
|
||||
// MapLibre 据此在上限以上对最细瓦片超采样放大,而不去请求不存在的更细瓦片。
|
||||
function rasterSource(layer, maxzoom) {
|
||||
return {
|
||||
type: 'raster',
|
||||
tiles: tdtTiles(layer[0], layer[1]),
|
||||
tileSize: 256,
|
||||
minzoom: MAP_MINZOOM,
|
||||
maxzoom: maxzoom,
|
||||
};
|
||||
}
|
||||
|
||||
// 空样式(无外部 sprite/glyph 依赖 → 完全离线于库)。底图层在运行期按需添加。
|
||||
var map = new maplibregl.Map({
|
||||
container: 'map',
|
||||
style: { version: 8, sources: {}, layers: [] },
|
||||
center: [114.1637, 22.5458], // 深圳一带([lon, lat];setPoints 到达后 fitBounds 覆盖)
|
||||
zoom: 16,
|
||||
minZoom: MAP_MINZOOM,
|
||||
maxZoom: MAP_MAXZOOM,
|
||||
attributionControl: false,
|
||||
// 超放大时 WebGL 连续光栅化,关闭瓦片淡入抖动让超放大平移更顺滑。
|
||||
fadeDuration: 0,
|
||||
});
|
||||
map.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-left');
|
||||
|
||||
// 当前激活底图 key。切换时:移除旧 base+anno 层与 source,添加新的;电极层始终置顶。
|
||||
var activeBasemap = null;
|
||||
|
||||
// 电极数据层/source 的固定 id(确保切底图后仍在最上层)。
|
||||
var SRC_POINTS = 'traj-points';
|
||||
var SRC_LINE = 'traj-line';
|
||||
var SRC_START = 'traj-start';
|
||||
var LYR_LINE = 'traj-line-lyr';
|
||||
var LYR_POINTS = 'traj-points-lyr';
|
||||
var LYR_START = 'traj-start-lyr';
|
||||
|
||||
// 移除某 id 的 layer + source(存在才移)。
|
||||
function removeLayerSource(id) {
|
||||
if (map.getLayer(id)) map.removeLayer(id);
|
||||
if (map.getSource(id)) map.removeSource(id);
|
||||
}
|
||||
|
||||
// 切换底图:先卸载旧底图层,再装新底图层(base 先、anno 后,确保注记压在底图上)。
|
||||
// 电极层在 setBasemap 之后重新置顶(见 reorderTrajectoryLayers)。
|
||||
function setBasemap(key) {
|
||||
if (!BASEMAPS[key]) return;
|
||||
// 卸载旧底图。
|
||||
if (activeBasemap) {
|
||||
removeLayerSource('bm-base');
|
||||
removeLayerSource('bm-anno');
|
||||
}
|
||||
var bm = BASEMAPS[key];
|
||||
map.addSource('bm-base', rasterSource(bm.base, bm.maxzoom));
|
||||
map.addLayer({ id: 'bm-base', type: 'raster', source: 'bm-base' });
|
||||
map.addSource('bm-anno', rasterSource(bm.anno, bm.maxzoom));
|
||||
map.addLayer({ id: 'bm-anno', type: 'raster', source: 'bm-anno' });
|
||||
activeBasemap = key;
|
||||
reorderTrajectoryLayers();
|
||||
updateSwitcherUI(key);
|
||||
}
|
||||
|
||||
// 把电极轨迹层移到最顶(底图切换后底图层是新加的,会盖住电极层 → 重新置顶)。
|
||||
function reorderTrajectoryLayers() {
|
||||
[LYR_LINE, LYR_POINTS, LYR_START].forEach(function (id) {
|
||||
if (map.getLayer(id)) map.moveLayer(id); // 无 beforeId → 移到最顶
|
||||
});
|
||||
}
|
||||
|
||||
// ---- 底图切换控件(右上角自定义 control)----
|
||||
function BasemapSwitcher() {}
|
||||
BasemapSwitcher.prototype.onAdd = function () {
|
||||
var wrap = document.createElement('div');
|
||||
wrap.className = 'maplibregl-ctrl basemap-switcher';
|
||||
var items = [
|
||||
{ key: 'street', label: '街道' },
|
||||
{ key: 'satellite', label: '卫星' },
|
||||
];
|
||||
this._buttons = {};
|
||||
var self = this;
|
||||
items.forEach(function (it) {
|
||||
var btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.textContent = it.label;
|
||||
btn.dataset.key = it.key;
|
||||
btn.addEventListener('click', function () { setBasemap(it.key); });
|
||||
wrap.appendChild(btn);
|
||||
self._buttons[it.key] = btn;
|
||||
});
|
||||
this._container = wrap;
|
||||
switcherButtons = this._buttons;
|
||||
return wrap;
|
||||
};
|
||||
BasemapSwitcher.prototype.onRemove = function () {
|
||||
if (this._container && this._container.parentNode) {
|
||||
this._container.parentNode.removeChild(this._container);
|
||||
}
|
||||
};
|
||||
var switcherButtons = null;
|
||||
function updateSwitcherUI(key) {
|
||||
if (!switcherButtons) return;
|
||||
Object.keys(switcherButtons).forEach(function (k) {
|
||||
switcherButtons[k].classList.toggle('active', k === key);
|
||||
});
|
||||
}
|
||||
map.addControl(new BasemapSwitcher(), 'top-right');
|
||||
|
||||
// ---- 定位/回到测线控件(左下角,导航控件附近)----
|
||||
// 点击 → 用最近一次 setPoints 的 bounds 重新 fitBounds 取景(回到/重新框住测线)。
|
||||
// 十字准星图标(圆 + 中心点 + 四向短刻度),对齐主流地图「回到当前位置」按钮观感。
|
||||
var currentBounds = null; // 最近一次 setPoints 计算的 LngLatBounds(locate 用)
|
||||
function LocateControl() {}
|
||||
LocateControl.prototype.onAdd = function () {
|
||||
var btn = document.createElement('div');
|
||||
btn.className = 'maplibregl-ctrl locate-ctrl';
|
||||
btn.title = '回到测线';
|
||||
btn.innerHTML =
|
||||
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" ' +
|
||||
'stroke="#333" stroke-width="2" stroke-linecap="round">' +
|
||||
'<circle cx="12" cy="12" r="6"></circle>' +
|
||||
'<circle cx="12" cy="12" r="1.6" fill="#333" stroke="none"></circle>' +
|
||||
'<line x1="12" y1="1.5" x2="12" y2="5"></line>' +
|
||||
'<line x1="12" y1="19" x2="12" y2="22.5"></line>' +
|
||||
'<line x1="1.5" y1="12" x2="5" y2="12"></line>' +
|
||||
'<line x1="19" y1="12" x2="22.5" y2="12"></line>' +
|
||||
'</svg>';
|
||||
btn.addEventListener('click', function () {
|
||||
if (currentBounds && !currentBounds.isEmpty()) {
|
||||
map.fitBounds(currentBounds, { padding: 40, maxZoom: 18 });
|
||||
}
|
||||
});
|
||||
this._container = btn;
|
||||
return btn;
|
||||
};
|
||||
LocateControl.prototype.onRemove = function () {
|
||||
if (this._container && this._container.parentNode) {
|
||||
this._container.parentNode.removeChild(this._container);
|
||||
}
|
||||
};
|
||||
map.addControl(new LocateControl(), 'bottom-left');
|
||||
|
||||
// ---- 电极点/线/起点渲染 ----
|
||||
// 待注入载荷队列:若 setPoints 在 map 'load' 之前被调用(C++ 侧 loadFinished 与
|
||||
// 页面 load 时序不保证),先暂存,待 load 后应用。
|
||||
var mapLoaded = false;
|
||||
var pendingPoints = null;
|
||||
|
||||
// 由宿主推送:arr = [{no, lon, lat}, ...]。构造 GeoJSON → 画线 + 实心橙点 + 起点标记,fitBounds 取景。
|
||||
window.setPoints = function (arr) {
|
||||
if (!mapLoaded) { pendingPoints = arr; return; }
|
||||
renderPoints(arr);
|
||||
};
|
||||
|
||||
function renderPoints(arr) {
|
||||
// 清旧电极层。
|
||||
removeLayerSource(SRC_LINE);
|
||||
removeLayerSource(SRC_POINTS);
|
||||
removeLayerSource(SRC_START);
|
||||
if (!arr || !arr.length) return;
|
||||
|
||||
var features = [];
|
||||
var lineCoords = [];
|
||||
var bounds = new maplibregl.LngLatBounds();
|
||||
for (var i = 0; i < arr.length; i++) {
|
||||
var p = arr[i];
|
||||
if (typeof p.lat !== 'number' || typeof p.lon !== 'number') continue;
|
||||
var coord = [p.lon, p.lat];
|
||||
lineCoords.push(coord);
|
||||
bounds.extend(coord);
|
||||
features.push({
|
||||
type: 'Feature',
|
||||
geometry: { type: 'Point', coordinates: coord },
|
||||
properties: { no: p.no },
|
||||
});
|
||||
}
|
||||
if (!features.length) return;
|
||||
|
||||
// 线(细橙),置于点之下。
|
||||
if (lineCoords.length > 1) {
|
||||
map.addSource(SRC_LINE, {
|
||||
type: 'geojson',
|
||||
data: { type: 'Feature', geometry: { type: 'LineString', coordinates: lineCoords } },
|
||||
});
|
||||
map.addLayer({
|
||||
id: LYR_LINE, type: 'line', source: SRC_LINE,
|
||||
paint: { 'line-color': TRAJ_ORANGE, 'line-width': 1, 'line-opacity': 0.5 },
|
||||
});
|
||||
}
|
||||
|
||||
// 圆点:实心橙填充 + 白描边(实心橙点配白环,对齐修复后观感)。
|
||||
map.addSource(SRC_POINTS, {
|
||||
type: 'geojson',
|
||||
data: { type: 'FeatureCollection', features: features },
|
||||
});
|
||||
map.addLayer({
|
||||
id: LYR_POINTS, type: 'circle', source: SRC_POINTS,
|
||||
paint: {
|
||||
'circle-radius': 5,
|
||||
'circle-color': TRAJ_ORANGE,
|
||||
'circle-opacity': 1,
|
||||
'circle-stroke-color': '#ffffff',
|
||||
'circle-stroke-width': 1.5,
|
||||
},
|
||||
});
|
||||
|
||||
// 起点标记:首点处一个略大的橙色方块(distinct marker,对齐原版起点小图标)。
|
||||
var first = features[0].geometry.coordinates;
|
||||
map.addSource(SRC_START, {
|
||||
type: 'geojson',
|
||||
data: { type: 'Feature', geometry: { type: 'Point', coordinates: first }, properties: {} },
|
||||
});
|
||||
map.addLayer({
|
||||
id: LYR_START, type: 'circle', source: SRC_START,
|
||||
paint: {
|
||||
'circle-radius': 6,
|
||||
'circle-color': TRAJ_ORANGE,
|
||||
'circle-stroke-color': '#b35f00',
|
||||
'circle-stroke-width': 1.5,
|
||||
},
|
||||
});
|
||||
|
||||
// 取景:覆盖所有点,maxZoom 18 避免过度放大。bounds 存为 currentBounds 供 locate 按钮复用。
|
||||
currentBounds = bounds;
|
||||
if (!bounds.isEmpty()) {
|
||||
map.fitBounds(bounds, { padding: 40, maxZoom: 18, duration: 0 });
|
||||
}
|
||||
|
||||
// 悬停提示 #<no>(nice-to-have)。
|
||||
attachHoverTooltip();
|
||||
}
|
||||
|
||||
// 圆点悬停时弹出 #<no> 提示(低优先级)。
|
||||
var hoverPopup = null;
|
||||
function attachHoverTooltip() {
|
||||
if (hoverPopup) return; // 仅绑定一次
|
||||
hoverPopup = new maplibregl.Popup({ closeButton: false, closeOnClick: false, offset: 8 });
|
||||
map.on('mouseenter', LYR_POINTS, function (e) {
|
||||
map.getCanvas().style.cursor = 'pointer';
|
||||
var f = e.features && e.features[0];
|
||||
if (!f) return;
|
||||
hoverPopup
|
||||
.setLngLat(f.geometry.coordinates)
|
||||
.setHTML('#' + f.properties.no)
|
||||
.addTo(map);
|
||||
});
|
||||
map.on('mousemove', LYR_POINTS, function (e) {
|
||||
var f = e.features && e.features[0];
|
||||
if (f) hoverPopup.setLngLat(f.geometry.coordinates).setHTML('#' + f.properties.no);
|
||||
});
|
||||
map.on('mouseleave', LYR_POINTS, function () {
|
||||
map.getCanvas().style.cursor = '';
|
||||
hoverPopup.remove();
|
||||
});
|
||||
}
|
||||
|
||||
// 页面就绪:装默认底图,应用排队的载荷(若 C++ 已先调 setPoints)。
|
||||
map.on('load', function () {
|
||||
setBasemap(DEFAULT_BASEMAP);
|
||||
mapLoaded = true;
|
||||
if (pendingPoints) {
|
||||
var pts = pendingPoints;
|
||||
pendingPoints = null;
|
||||
renderPoints(pts);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
find_package(Qt6 COMPONENTS Core REQUIRED)
|
||||
add_library(geopro_controller STATIC
|
||||
WorkbenchNavController.cpp)
|
||||
WorkbenchNavController.cpp
|
||||
DatasetDetailController.cpp)
|
||||
target_include_directories(geopro_controller PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
|
||||
target_link_libraries(geopro_controller PUBLIC geopro_data Qt6::Core)
|
||||
target_compile_features(geopro_controller PUBLIC cxx_std_17)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,87 @@
|
|||
#include "DatasetDetailController.hpp"
|
||||
#include <exception>
|
||||
#include <QtGlobal>
|
||||
#include "repo/IAsyncDatasetRepository.hpp"
|
||||
#include "api/DatasetLoadHandles.hpp"
|
||||
namespace geopro::controller {
|
||||
|
||||
DatasetDetailController::DatasetDetailController(data::IAsyncDatasetRepository& repo,
|
||||
ChartStrategyRegistry& registry, QObject* parent)
|
||||
: QObject(parent), repo_(repo), registry_(registry) {
|
||||
// QSignalSpy / 队列连接对自定义类型需注册(页签集随 datasetOpened 传递)。
|
||||
qRegisterMetaType<std::vector<controller::TabSpec>>("std::vector<controller::TabSpec>");
|
||||
}
|
||||
|
||||
DatasetDetailController::~DatasetDetailController() {
|
||||
// 退出契约:abort 全部在飞句柄,不依赖外部析构顺序兜底。
|
||||
for (auto& load : inflight_)
|
||||
if (load) load->abort();
|
||||
}
|
||||
|
||||
void DatasetDetailController::openDataset(const QString& dsId, const QString& ddCode,
|
||||
const QString& dsName) {
|
||||
qInfo("[detail] openDataset id=%s ddCode=%s name=%s", qUtf8Printable(dsId),
|
||||
qUtf8Printable(ddCode), qUtf8Printable(dsName));
|
||||
auto* s = registry_.find(ddCode.toStdString());
|
||||
if (!s) { // 未注册策略 → 优雅降级
|
||||
qWarning("[detail] 未注册策略 ddCode=%s → 降级提示", qUtf8Printable(ddCode));
|
||||
emit loadFailed(dsId, QStringLiteral("暂不支持该数据类型的预览"));
|
||||
return;
|
||||
}
|
||||
const std::vector<controller::TabSpec> tabs = s->tabs();
|
||||
emit datasetOpened(dsId, ddCode, dsName, tabs);
|
||||
for (int i = 0; i < static_cast<int>(tabs.size()); ++i)
|
||||
if (!tabs[static_cast<size_t>(i)].lazy) loadTab(dsId, ddCode, i);
|
||||
}
|
||||
|
||||
void DatasetDetailController::loadTab(const QString& dsId, const QString& ddCode, int tabIndex) {
|
||||
loadTabImpl(dsId, ddCode, tabIndex, /*pageNo*/ 1, /*pageSize*/ 0);
|
||||
}
|
||||
|
||||
void DatasetDetailController::loadTabPaged(const QString& dsId, const QString& ddCode, int tabIndex,
|
||||
int pageNo, int pageSize) {
|
||||
loadTabImpl(dsId, ddCode, tabIndex, pageNo, pageSize);
|
||||
}
|
||||
|
||||
void DatasetDetailController::loadTabImpl(const QString& dsId, const QString& ddCode, int tabIndex,
|
||||
int pageNo, int pageSize) {
|
||||
auto* s = registry_.find(ddCode.toStdString());
|
||||
if (!s) return; // 策略消失(不应发生):静默不加载
|
||||
const std::vector<controller::TabSpec> tabs = s->tabs();
|
||||
if (tabIndex < 0 || tabIndex >= static_cast<int>(tabs.size())) return;
|
||||
const controller::TabSpec& spec = tabs[static_cast<size_t>(tabIndex)];
|
||||
|
||||
// loadAsync 对未知 loaderKey 抛 std::runtime_error;若逃逸槽函数会被 GuardedApplication
|
||||
// 吞掉、遮罩永久悬挂(文档化的崩溃/挂起类)。就地兜底为 loadFailed,且不留半注册的在飞句柄。
|
||||
data::DetailLoad* load = nullptr;
|
||||
try {
|
||||
load = repo_.loadAsync(spec.loaderKey.toStdString(), dsId.toStdString(), pageNo, pageSize);
|
||||
} catch (const std::exception& e) {
|
||||
qWarning("[detail] loadAsync 失败 id=%s tab=%d key=%s: %s", qUtf8Printable(dsId), tabIndex,
|
||||
qUtf8Printable(spec.loaderKey), e.what());
|
||||
emit loadFailed(dsId, QStringLiteral("暂不支持该数据类型的预览"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (auto& prev = inflight_[tabIndex]) prev->abort(); // abort-and-replace 该槽位
|
||||
inflight_[tabIndex] = load;
|
||||
emit tabLoadStarted(dsId, tabIndex);
|
||||
|
||||
QObject::connect(load, &data::DetailLoad::done, this,
|
||||
[this, load, dsId, tabIndex](const QVariant& payload) {
|
||||
if (load != inflight_.value(tabIndex)) return; // §5.0 句柄身份比对:丢弃迟到信号
|
||||
inflight_.remove(tabIndex);
|
||||
emit tabReady(dsId, tabIndex, payload);
|
||||
});
|
||||
QObject::connect(load, &data::DetailLoad::failed, this,
|
||||
[this, load, dsId, tabIndex](const QString& msg) {
|
||||
if (load != inflight_.value(tabIndex)) return;
|
||||
inflight_.remove(tabIndex);
|
||||
qWarning("[detail] 页签加载失败 id=%s tab=%d: %s", qUtf8Printable(dsId), tabIndex,
|
||||
qUtf8Printable(msg));
|
||||
emit loadFailed(dsId, msg);
|
||||
});
|
||||
}
|
||||
|
||||
void DatasetDetailController::focusDataset(const QString& dsId) { emit focusRequested(dsId); }
|
||||
} // namespace geopro::controller
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
#pragma once
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <QMap>
|
||||
#include <QObject>
|
||||
#include <QPointer>
|
||||
#include <QString>
|
||||
#include <QVariant>
|
||||
#include "DatasetDetailTab.hpp"
|
||||
#include "IDatasetChartStrategy.hpp"
|
||||
namespace geopro::data { class IAsyncDatasetRepository; class DetailLoad; }
|
||||
namespace geopro::controller {
|
||||
|
||||
// 数据详情通用 tab 引擎编排:双击/页签激活 → 按 loaderKey 异步拉载荷(QVariant) → 发信号给详情面板。
|
||||
// 无 per-ddCode 分支:页签集由策略 tabs() 描述,载荷经 QVariant 类型擦除。被动视图。
|
||||
class DatasetDetailController : public QObject {
|
||||
Q_OBJECT
|
||||
public:
|
||||
DatasetDetailController(data::IAsyncDatasetRepository& repo,
|
||||
ChartStrategyRegistry& registry, QObject* parent = nullptr);
|
||||
~DatasetDetailController() override; // 退出契约(spec §7):abort 全部在飞句柄,避免迟到信号打到已析构 this
|
||||
public slots:
|
||||
// 打开数据集:查策略 → datasetOpened(页签集) → 对每个非 lazy 页签发起 loadTab。
|
||||
void openDataset(const QString& dsId, const QString& ddCode, const QString& dsName = QString());
|
||||
// 加载某页签(lazy 页签首次激活时由壳触发;非 lazy 由 openDataset 自动触发)。
|
||||
// 分页型页签(如 dd_grid 列表)首载用默认页(pageNo=1/pageSize=0 → 仓储解析默认每页条数)。
|
||||
void loadTab(const QString& dsId, const QString& ddCode, int tabIndex);
|
||||
// 分页加载某页签(分页器翻页/改每页条数时由壳触发)。pageSize=0 → 仓储用该类型默认值。
|
||||
void loadTabPaged(const QString& dsId, const QString& ddCode, int tabIndex, int pageNo,
|
||||
int pageSize);
|
||||
void focusDataset(const QString& dsId);
|
||||
signals:
|
||||
void datasetOpened(const QString& dsId, const QString& ddCode, const QString& dsName,
|
||||
const std::vector<controller::TabSpec>& tabs);
|
||||
void tabLoadStarted(const QString& dsId, int tabIndex);
|
||||
void tabReady(const QString& dsId, int tabIndex, const QVariant& payload);
|
||||
void loadFailed(const QString& dsId, const QString& message);
|
||||
void focusRequested(const QString& dsId);
|
||||
private:
|
||||
// loadTab/loadTabPaged 共用实现:按 (dsId,ddCode,tabIndex) 查 loaderKey,带分页参数异步加载。
|
||||
void loadTabImpl(const QString& dsId, const QString& ddCode, int tabIndex, int pageNo,
|
||||
int pageSize);
|
||||
|
||||
data::IAsyncDatasetRepository& repo_;
|
||||
ChartStrategyRegistry& registry_;
|
||||
QMap<int, QPointer<data::DetailLoad>> inflight_; // 按页签槽位的在飞句柄(§5.0 身份比对)
|
||||
};
|
||||
} // namespace geopro::controller
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
#pragma once
|
||||
#include <vector>
|
||||
#include <QMetaType>
|
||||
#include <QString>
|
||||
|
||||
namespace geopro::controller {
|
||||
|
||||
// 详情页签的渲染 kind 全集(Image/GISMap 待 GPR/radar 有活样本再加,YAGNI)。
|
||||
// WebMap = trajectory 地图页签占位(真实 GIS 地图待 Qt WebEngine 模块安装后接 QWebEngineView 替换)。
|
||||
enum class ViewKind { Scatter, FilledContour, Bar, LineProfile, PolylineMap, Table, WebMap };
|
||||
|
||||
// 页签描述符:策略声明每个 dd 类型的页签集(标题/kind/加载键/惰性/分页)。
|
||||
struct TabSpec {
|
||||
QString title;
|
||||
ViewKind kind;
|
||||
QString loaderKey;
|
||||
bool lazy = false;
|
||||
bool paginated = false;
|
||||
};
|
||||
|
||||
} // namespace geopro::controller
|
||||
|
||||
// QSignalSpy 对 datasetOpened(..., std::vector<TabSpec>) 需注册的元类型。
|
||||
Q_DECLARE_METATYPE(geopro::controller::TabSpec)
|
||||
Q_DECLARE_METATYPE(std::vector<geopro::controller::TabSpec>)
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
#pragma once
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include "DatasetDetailTab.hpp"
|
||||
namespace geopro::controller {
|
||||
|
||||
// dd 类型驱动的图表策略:决定某 ddCode 的详情页如何加载/渲染。
|
||||
struct IDatasetChartStrategy {
|
||||
virtual ~IDatasetChartStrategy() = default;
|
||||
virtual std::string ddCode() const = 0;
|
||||
// 该类型的页签集(标题/render kind/加载键/惰性/分页)。控制器据此建页签 + 驱动加载,
|
||||
// 取代硬编码的 hasGridPhase()/ddCode 判断。
|
||||
virtual std::vector<TabSpec> tabs() const = 0;
|
||||
};
|
||||
|
||||
class ChartStrategyRegistry {
|
||||
public:
|
||||
ChartStrategyRegistry() = default;
|
||||
// 禁拷贝(含 unique_ptr,本就不可拷贝;显式 delete 让意图清晰)。
|
||||
// 保留移动:map 移动只搬节点,find() 返回的裸指针指向的策略对象地址不变、仍有效;
|
||||
// 且测试 makeInversionRegistry() 按值返回需要移动。
|
||||
ChartStrategyRegistry(const ChartStrategyRegistry&) = delete;
|
||||
ChartStrategyRegistry& operator=(const ChartStrategyRegistry&) = delete;
|
||||
ChartStrategyRegistry(ChartStrategyRegistry&&) = default;
|
||||
ChartStrategyRegistry& operator=(ChartStrategyRegistry&&) = default;
|
||||
|
||||
void add(std::unique_ptr<IDatasetChartStrategy> s) {
|
||||
const std::string code = s->ddCode();
|
||||
map_[code] = std::move(s);
|
||||
}
|
||||
IDatasetChartStrategy* find(const std::string& ddCode) const {
|
||||
auto it = map_.find(ddCode);
|
||||
return it == map_.end() ? nullptr : it->second.get();
|
||||
}
|
||||
bool supports(const std::string& ddCode) const { return map_.count(ddCode) > 0; }
|
||||
private:
|
||||
std::map<std::string, std::unique_ptr<IDatasetChartStrategy>> map_;
|
||||
};
|
||||
} // namespace geopro::controller
|
||||
|
|
@ -1,70 +1,140 @@
|
|||
#include "WorkbenchNavController.hpp"
|
||||
|
||||
#include <QMetaObject>
|
||||
#include <QDebug>
|
||||
#include <algorithm>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
|
||||
#include "api/NavLoads.hpp"
|
||||
#include "api/NavRequest.hpp"
|
||||
#include "dto/NavDto.hpp"
|
||||
#include "repo/IAsyncProjectRepository.hpp"
|
||||
|
||||
namespace geopro::controller {
|
||||
|
||||
namespace {
|
||||
// 数据页树形:一次取全的大 pageSize(远超单 TM 实际 DS 数;超出会日志告警)+ 每页根节点数。
|
||||
constexpr int kFetchAllPageSize = 1000;
|
||||
constexpr int kDataRootPageSize = 5;
|
||||
} // namespace
|
||||
|
||||
using data::DsPage;
|
||||
using data::DsRow;
|
||||
using data::DynamicForm;
|
||||
using data::ExceptionRow;
|
||||
using data::NavRequest;
|
||||
using data::ProjectListPage;
|
||||
using data::ProjectSummary;
|
||||
using data::StructNode;
|
||||
using data::Workspace;
|
||||
|
||||
WorkbenchNavController::WorkbenchNavController(data::IProjectRepository& repo, QObject* parent)
|
||||
WorkbenchNavController::WorkbenchNavController(data::IAsyncProjectRepository& repo, QObject* parent)
|
||||
: QObject(parent), repo_(repo) {}
|
||||
|
||||
// RAII:进入公共导航操作时置忙(驱动等待光标),任何返回路径都复位——保证 busyChanged 配平。
|
||||
// 命名(非匿名)以匹配 controller 的 friend 声明,从而在析构时排空挂起的勾选请求。
|
||||
struct BusyGuard {
|
||||
WorkbenchNavController* self;
|
||||
bool* busy;
|
||||
BusyGuard(WorkbenchNavController* s, bool* b) : self(s), busy(b) {
|
||||
*busy = true;
|
||||
emit self->busyChanged(true);
|
||||
WorkbenchNavController::~WorkbenchNavController() { abortAll(); }
|
||||
|
||||
bool WorkbenchNavController::anyInflight() const {
|
||||
if (startStepReq_ || structReq_ || selDataReq_ || selFileReq_ || selDetailReq_ ||
|
||||
moreFilesReq_ || datasetReq_)
|
||||
return true;
|
||||
for (const auto& h : checkedInflight_)
|
||||
if (h) return true;
|
||||
return false;
|
||||
}
|
||||
~BusyGuard() {
|
||||
WorkbenchNavController* ctrl = self; // 取本地副本:lambda 不能捕获成员
|
||||
*busy = false;
|
||||
emit ctrl->busyChanged(false);
|
||||
// 触发源是延迟合并发射,可能落在嵌套事件循环里:用队列调用在调用栈/嵌套循环展开后再排空,
|
||||
// 那时 busy_ 已可靠为 false,重放才会真正执行(lambda 捕获的 ctrl 生命周期与应用一致,安全)。
|
||||
if (ctrl->checkedTmsPending_)
|
||||
QMetaObject::invokeMethod(
|
||||
ctrl, [ctrl] { ctrl->drainPendingCheckedTms(); }, Qt::QueuedConnection);
|
||||
|
||||
void WorkbenchNavController::emitBusyIfChanged() {
|
||||
const bool now = anyInflight();
|
||||
if (now != lastBusy_) {
|
||||
lastBusy_ = now;
|
||||
emit busyChanged(now);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
void WorkbenchNavController::abortAll() {
|
||||
if (startStepReq_) startStepReq_->abort();
|
||||
if (structReq_) structReq_->abort();
|
||||
if (selDataReq_) selDataReq_->abort();
|
||||
if (selFileReq_) selFileReq_->abort();
|
||||
if (selDetailReq_) selDetailReq_->abort();
|
||||
if (moreFilesReq_) moreFilesReq_->abort();
|
||||
if (datasetReq_) datasetReq_->abort();
|
||||
for (const auto& h : checkedInflight_)
|
||||
if (h) h->abort();
|
||||
checkedInflight_.clear();
|
||||
}
|
||||
|
||||
void WorkbenchNavController::resetSelectionState() {
|
||||
tmExceptionCache_.clear();
|
||||
currentParentId_.clear();
|
||||
currentParentConfType_ = 0;
|
||||
allDataRows_.clear();
|
||||
dataRootsShown_ = 0;
|
||||
dataTotal_ = 0;
|
||||
}
|
||||
|
||||
// ── start / switchWorkspace 依赖链:listWorkspaces → pageProjects → loadStructure ──
|
||||
// 用 NavRequest 续延(每级 done 内用业务值构造下一级),startStepReq_ 跟踪当前在飞级。
|
||||
// abort-and-replace:start/switchWorkspace 入口 abort 旧 startStepReq_ → 自然丢弃旧链迟到信号。
|
||||
|
||||
void WorkbenchNavController::start() {
|
||||
if (busy_) return;
|
||||
BusyGuard guard(this, &busy_);
|
||||
const auto ws = repo_.listWorkspaces();
|
||||
if (!ws.ok) {
|
||||
emit loadFailed(QStringLiteral("workspaces"), QString::fromStdString(ws.error));
|
||||
return;
|
||||
}
|
||||
if (startStepReq_) startStepReq_->abort();
|
||||
NavRequest* req = repo_.listWorkspacesAsync();
|
||||
startStepReq_ = req;
|
||||
emitBusyIfChanged();
|
||||
QObject::connect(req, &NavRequest::done, this, [this, req](const QVariant& v) {
|
||||
if (req != startStepReq_) return; // §5.0 身份比对
|
||||
startStepReq_.clear();
|
||||
const auto ws = qvariant_cast<std::vector<Workspace>>(v);
|
||||
QString cur;
|
||||
for (const auto& w : ws.value)
|
||||
for (const auto& w : ws)
|
||||
if (w.isCurrent) cur = QString::fromStdString(w.id);
|
||||
if (cur.isEmpty() && !ws.value.empty()) cur = QString::fromStdString(ws.value.front().id);
|
||||
if (cur.isEmpty() && !ws.empty()) cur = QString::fromStdString(ws.front().id);
|
||||
currentWorkspaceId_ = cur.toStdString();
|
||||
emit workspacesLoaded(ws.value, cur);
|
||||
loadProjectsAndStructure();
|
||||
emit workspacesLoaded(ws, cur);
|
||||
runProjectsAndStructure();
|
||||
});
|
||||
QObject::connect(req, &NavRequest::failed, this, [this, req](const QString& msg) {
|
||||
if (req != startStepReq_) return;
|
||||
startStepReq_.clear();
|
||||
emit loadFailed(QStringLiteral("workspaces"), msg);
|
||||
emitBusyIfChanged();
|
||||
});
|
||||
}
|
||||
|
||||
void WorkbenchNavController::loadProjectsAndStructure() {
|
||||
const auto ps = repo_.pageProjects(std::string(), std::string(), 1, 10); // 下拉首页 10
|
||||
if (!ps.ok) {
|
||||
emit loadFailed(QStringLiteral("projects"), QString::fromStdString(ps.error));
|
||||
return;
|
||||
void WorkbenchNavController::switchWorkspace(const QString& tenantId) {
|
||||
if (tenantId.isEmpty()) return;
|
||||
if (startStepReq_) startStepReq_->abort();
|
||||
NavRequest* req = repo_.switchWorkspaceAsync(tenantId.toStdString());
|
||||
startStepReq_ = req;
|
||||
emitBusyIfChanged();
|
||||
const std::string tid = tenantId.toStdString();
|
||||
QObject::connect(req, &NavRequest::done, this, [this, req, tid](const QVariant&) {
|
||||
if (req != startStepReq_) return;
|
||||
startStepReq_.clear();
|
||||
currentWorkspaceId_ = tid;
|
||||
runProjectsAndStructure();
|
||||
});
|
||||
QObject::connect(req, &NavRequest::failed, this, [this, req](const QString& msg) {
|
||||
if (req != startStepReq_) return;
|
||||
startStepReq_.clear();
|
||||
emit loadFailed(QStringLiteral("switchWorkspace"), msg);
|
||||
emitBusyIfChanged();
|
||||
});
|
||||
}
|
||||
lastProjects_ = ps.value.rows;
|
||||
tmExceptionCache_.clear();
|
||||
currentParentId_.clear(); // 切项目/工作空间重置选中态(spec §6)
|
||||
currentParentConfType_ = 0;
|
||||
checkedTmsPending_ = false; // 丢弃跨项目的陈旧挂起重放
|
||||
pendingCheckedTms_.clear();
|
||||
|
||||
void WorkbenchNavController::runProjectsAndStructure() {
|
||||
NavRequest* req = repo_.pageProjectsAsync(std::string(), std::string(), 1, 10); // 下拉首页 10
|
||||
startStepReq_ = req;
|
||||
emitBusyIfChanged();
|
||||
QObject::connect(req, &NavRequest::done, this, [this, req](const QVariant& v) {
|
||||
if (req != startStepReq_) return;
|
||||
startStepReq_.clear();
|
||||
const auto page = qvariant_cast<ProjectListPage>(v);
|
||||
lastProjects_ = page.rows;
|
||||
resetSelectionState();
|
||||
QString curP;
|
||||
if (!ps.value.rows.empty()) {
|
||||
const auto& first = ps.value.rows.front();
|
||||
if (!page.rows.empty()) {
|
||||
const auto& first = page.rows.front();
|
||||
curP = QString::fromStdString(first.id);
|
||||
currentProjectId_ = first.id;
|
||||
currentProjectName_ = first.name;
|
||||
|
|
@ -74,119 +144,302 @@ void WorkbenchNavController::loadProjectsAndStructure() {
|
|||
currentProjectName_.clear();
|
||||
currentCrsCode_.clear();
|
||||
}
|
||||
emit projectsLoaded(ps.value.rows, curP, ps.value.total);
|
||||
|
||||
emit projectsLoaded(page.rows, curP, page.total);
|
||||
if (curP.isEmpty()) {
|
||||
lastStructNodes_.clear();
|
||||
emit structureLoaded(QString(), {}); // 暂无项目 → 空树
|
||||
emitBusyIfChanged();
|
||||
return;
|
||||
}
|
||||
const auto st = repo_.loadStructure(currentProjectId_);
|
||||
if (!st.ok) {
|
||||
emit loadFailed(QStringLiteral("structure"), QString::fromStdString(st.error));
|
||||
return;
|
||||
}
|
||||
lastStructNodes_ = st.value;
|
||||
emit structureLoaded(QString::fromStdString(currentProjectName_), st.value);
|
||||
}
|
||||
|
||||
void WorkbenchNavController::switchWorkspace(const QString& tenantId) {
|
||||
if (tenantId.isEmpty() || busy_) return;
|
||||
BusyGuard guard(this, &busy_);
|
||||
const auto r = repo_.switchWorkspace(tenantId.toStdString());
|
||||
if (!r.ok) {
|
||||
emit loadFailed(QStringLiteral("switchWorkspace"), QString::fromStdString(r.error));
|
||||
return;
|
||||
}
|
||||
currentWorkspaceId_ = tenantId.toStdString();
|
||||
loadProjectsAndStructure();
|
||||
NavRequest* st = repo_.loadStructureAsync(currentProjectId_);
|
||||
startStepReq_ = st;
|
||||
emitBusyIfChanged();
|
||||
QObject::connect(st, &NavRequest::done, this, [this, st](const QVariant& sv) {
|
||||
if (st != startStepReq_) return;
|
||||
startStepReq_.clear();
|
||||
const auto nodes = qvariant_cast<std::vector<StructNode>>(sv);
|
||||
lastStructNodes_ = nodes;
|
||||
emit structureLoaded(QString::fromStdString(currentProjectName_), nodes);
|
||||
emitBusyIfChanged();
|
||||
});
|
||||
QObject::connect(st, &NavRequest::failed, this, [this, st](const QString& msg) {
|
||||
if (st != startStepReq_) return;
|
||||
startStepReq_.clear();
|
||||
emit loadFailed(QStringLiteral("structure"), msg);
|
||||
emitBusyIfChanged();
|
||||
});
|
||||
});
|
||||
QObject::connect(req, &NavRequest::failed, this, [this, req](const QString& msg) {
|
||||
if (req != startStepReq_) return;
|
||||
startStepReq_.clear();
|
||||
emit loadFailed(QStringLiteral("projects"), msg);
|
||||
emitBusyIfChanged();
|
||||
});
|
||||
}
|
||||
|
||||
// ── switchProject:单请求 loadStructure ──
|
||||
void WorkbenchNavController::switchProject(const QString& projectId) {
|
||||
if (projectId.isEmpty() || busy_) return;
|
||||
BusyGuard guard(this, &busy_);
|
||||
if (projectId.isEmpty()) return;
|
||||
currentProjectId_ = projectId.toStdString();
|
||||
for (const auto& p : lastProjects_)
|
||||
if (p.id == currentProjectId_) {
|
||||
currentProjectName_ = p.name;
|
||||
currentCrsCode_ = p.crsCode;
|
||||
}
|
||||
const auto st = repo_.loadStructure(currentProjectId_);
|
||||
if (!st.ok) {
|
||||
emit loadFailed(QStringLiteral("structure"), QString::fromStdString(st.error));
|
||||
return;
|
||||
}
|
||||
lastStructNodes_ = st.value;
|
||||
tmExceptionCache_.clear();
|
||||
currentParentId_.clear(); // 切项目/工作空间重置选中态(spec §6)
|
||||
currentParentConfType_ = 0;
|
||||
checkedTmsPending_ = false; // 丢弃跨项目的陈旧挂起重放
|
||||
pendingCheckedTms_.clear();
|
||||
emit structureLoaded(QString::fromStdString(currentProjectName_), st.value);
|
||||
if (structReq_) structReq_->abort(); // abort-and-replace
|
||||
NavRequest* req = repo_.loadStructureAsync(currentProjectId_);
|
||||
structReq_ = req;
|
||||
emitBusyIfChanged();
|
||||
QObject::connect(req, &NavRequest::done, this, [this, req](const QVariant& v) {
|
||||
if (req != structReq_) return; // §5.0 身份比对
|
||||
structReq_.clear();
|
||||
const auto nodes = qvariant_cast<std::vector<StructNode>>(v);
|
||||
lastStructNodes_ = nodes;
|
||||
resetSelectionState();
|
||||
emit structureLoaded(QString::fromStdString(currentProjectName_), nodes);
|
||||
emitBusyIfChanged();
|
||||
});
|
||||
QObject::connect(req, &NavRequest::failed, this, [this, req](const QString& msg) {
|
||||
if (req != structReq_) return;
|
||||
structReq_.clear();
|
||||
emit loadFailed(QStringLiteral("structure"), msg);
|
||||
emitBusyIfChanged();
|
||||
});
|
||||
}
|
||||
|
||||
// ── selectObject:三并发(data 行 / file 行 / 对象详情),各自身份比对、独立 emit ──
|
||||
void WorkbenchNavController::selectObject(const QString& objectId, int confType) {
|
||||
if (objectId.isEmpty() || busy_) return;
|
||||
BusyGuard guard(this, &busy_);
|
||||
if (objectId.isEmpty()) return;
|
||||
if (selDataReq_) selDataReq_->abort(); // abort-and-replace 三路
|
||||
if (selFileReq_) selFileReq_->abort();
|
||||
if (selDetailReq_) selDetailReq_->abort();
|
||||
currentParentId_ = objectId.toStdString();
|
||||
currentParentConfType_ = confType;
|
||||
const std::string pid = currentProjectId_;
|
||||
dataPageNo_ = 1;
|
||||
filePageNo_ = 1;
|
||||
const auto d = repo_.loadRows(pid, currentParentId_, confType, 3, dataPageNo_);
|
||||
if (!d.ok) {
|
||||
emit loadFailed(QStringLiteral("datasets"), QString::fromStdString(d.error));
|
||||
return;
|
||||
}
|
||||
dataTotal_ = d.value.total;
|
||||
emit datasetsLoaded(objectId, d.value.rows, d.value.total, false);
|
||||
const auto f = repo_.loadRows(pid, currentParentId_, confType, 1, filePageNo_);
|
||||
if (!f.ok) {
|
||||
emit loadFailed(QStringLiteral("files"), QString::fromStdString(f.error));
|
||||
return;
|
||||
}
|
||||
fileTotal_ = f.value.total;
|
||||
emit filesLoaded(objectId, f.value.rows, f.value.total, false);
|
||||
allDataRows_.clear();
|
||||
dataRootsShown_ = 0;
|
||||
|
||||
const auto detail = repo_.loadObjectDetail(currentParentId_, confType);
|
||||
if (!detail.ok) {
|
||||
emit loadFailed(QStringLiteral("objectDetail"), QString::fromStdString(detail.error));
|
||||
return;
|
||||
}
|
||||
emit objectDetailLoaded(objectId, detail.value);
|
||||
// 数据页:一次取全(大 pageSize),再按根客户端分页——保证树完整(子节点不会跨服务端分页丢失父)。
|
||||
NavRequest* dReq = repo_.loadRowsAsync(pid, currentParentId_, confType, 3, 1, kFetchAllPageSize);
|
||||
selDataReq_ = dReq;
|
||||
NavRequest* fReq = repo_.loadRowsAsync(pid, currentParentId_, confType, 1, filePageNo_);
|
||||
selFileReq_ = fReq;
|
||||
NavRequest* detReq = repo_.loadObjectDetailAsync(currentParentId_, confType);
|
||||
selDetailReq_ = detReq;
|
||||
emitBusyIfChanged();
|
||||
|
||||
QObject::connect(dReq, &NavRequest::done, this, [this, dReq, objectId](const QVariant& v) {
|
||||
if (dReq != selDataReq_) return;
|
||||
selDataReq_.clear();
|
||||
const auto page = qvariant_cast<DsPage>(v);
|
||||
if (static_cast<int>(page.rows.size()) < page.total)
|
||||
qWarning() << "[nav] data/page 未取全:listCount=" << page.rows.size()
|
||||
<< " total=" << page.total << " → 树可能不完整(pageSize 不足)";
|
||||
allDataRows_ = page.rows; // 全量缓存;按根分页由 emitNextDataRootPage 切
|
||||
dataRootsShown_ = 0;
|
||||
emitNextDataRootPage(false); // 首页(append=false)
|
||||
emitBusyIfChanged();
|
||||
});
|
||||
QObject::connect(dReq, &NavRequest::failed, this, [this, dReq](const QString& msg) {
|
||||
if (dReq != selDataReq_) return;
|
||||
selDataReq_.clear();
|
||||
emit loadFailed(QStringLiteral("datasets"), msg);
|
||||
emitBusyIfChanged();
|
||||
});
|
||||
QObject::connect(fReq, &NavRequest::done, this, [this, fReq, objectId](const QVariant& v) {
|
||||
if (fReq != selFileReq_) return;
|
||||
selFileReq_.clear();
|
||||
const auto page = qvariant_cast<DsPage>(v);
|
||||
fileTotal_ = page.total;
|
||||
emit filesLoaded(objectId, page.rows, page.total, false);
|
||||
emitBusyIfChanged();
|
||||
});
|
||||
QObject::connect(fReq, &NavRequest::failed, this, [this, fReq](const QString& msg) {
|
||||
if (fReq != selFileReq_) return;
|
||||
selFileReq_.clear();
|
||||
emit loadFailed(QStringLiteral("files"), msg);
|
||||
emitBusyIfChanged();
|
||||
});
|
||||
QObject::connect(detReq, &NavRequest::done, this, [this, detReq, objectId](const QVariant& v) {
|
||||
if (detReq != selDetailReq_) return;
|
||||
selDetailReq_.clear();
|
||||
emit objectDetailLoaded(objectId, qvariant_cast<DynamicForm>(v));
|
||||
emitBusyIfChanged();
|
||||
});
|
||||
QObject::connect(detReq, &NavRequest::failed, this, [this, detReq](const QString& msg) {
|
||||
if (detReq != selDetailReq_) return;
|
||||
selDetailReq_.clear();
|
||||
emit loadFailed(QStringLiteral("objectDetail"), msg);
|
||||
emitBusyIfChanged();
|
||||
});
|
||||
}
|
||||
|
||||
// ── 数据页树形分页:从 allDataRows_ 按根切下一页(同步,无请求)──
|
||||
// 根 = parentId 为空或不在本 TM 全量集合内(其父是源文件节点,不在 data/page 返回里)。
|
||||
// 每页取 kDataRootPageSize 个根 + 各自整棵子树;行序保持后端原序(便于稳定显示)。
|
||||
void WorkbenchNavController::emitNextDataRootPage(bool append) {
|
||||
const QString parent = QString::fromStdString(currentParentId_);
|
||||
// 本 TM 全部行 id 集合(判定谁是根)。
|
||||
std::unordered_set<std::string> ids;
|
||||
ids.reserve(allDataRows_.size());
|
||||
for (const auto& r : allDataRows_) ids.insert(r.id);
|
||||
// 根索引(按原序)+ parentId→子索引表。
|
||||
std::vector<std::size_t> rootIdx;
|
||||
std::unordered_map<std::string, std::vector<std::size_t>> kids;
|
||||
for (std::size_t i = 0; i < allDataRows_.size(); ++i) {
|
||||
const std::string& p = allDataRows_[i].parentId;
|
||||
if (p.empty() || ids.find(p) == ids.end())
|
||||
rootIdx.push_back(i);
|
||||
else
|
||||
kids[p].push_back(i);
|
||||
}
|
||||
const int rootCount = static_cast<int>(rootIdx.size());
|
||||
dataTotal_ = rootCount;
|
||||
|
||||
// 取本页根 [shown, end) 的整棵子树(DFS 收集索引),再按原序输出保稳定。
|
||||
const int end = std::min(dataRootsShown_ + kDataRootPageSize, rootCount);
|
||||
std::unordered_set<std::size_t> picked;
|
||||
for (int k = dataRootsShown_; k < end; ++k) {
|
||||
std::vector<std::size_t> stack{rootIdx[k]};
|
||||
while (!stack.empty()) {
|
||||
const std::size_t cur = stack.back();
|
||||
stack.pop_back();
|
||||
if (!picked.insert(cur).second) continue;
|
||||
auto it = kids.find(allDataRows_[cur].id);
|
||||
if (it != kids.end())
|
||||
for (std::size_t c : it->second) stack.push_back(c);
|
||||
}
|
||||
}
|
||||
std::vector<DsRow> out;
|
||||
out.reserve(picked.size());
|
||||
for (std::size_t i = 0; i < allDataRows_.size(); ++i)
|
||||
if (picked.count(i)) out.push_back(allDataRows_[i]);
|
||||
|
||||
dataRootsShown_ = end;
|
||||
emit datasetsLoaded(parent, out, rootCount, append);
|
||||
}
|
||||
|
||||
// ── loadMoreData:数据页树形——同步切下一页根(无请求)。loadMoreFiles:文件页服务端分页 ──
|
||||
void WorkbenchNavController::loadMoreData() {
|
||||
if (currentParentId_.empty() || busy_) return;
|
||||
BusyGuard guard(this, &busy_);
|
||||
const auto d = repo_.loadRows(currentProjectId_, currentParentId_, currentParentConfType_, 3, ++dataPageNo_);
|
||||
if (!d.ok) {
|
||||
emit loadFailed(QStringLiteral("datasets"), QString::fromStdString(d.error));
|
||||
return;
|
||||
}
|
||||
dataTotal_ = d.value.total;
|
||||
emit datasetsLoaded(QString::fromStdString(currentParentId_), d.value.rows, d.value.total, true);
|
||||
if (currentParentId_.empty()) return;
|
||||
if (dataRootsShown_ >= dataTotal_) return; // 无更多根
|
||||
emitNextDataRootPage(true);
|
||||
}
|
||||
|
||||
void WorkbenchNavController::loadMoreFiles() {
|
||||
if (currentParentId_.empty() || busy_) return;
|
||||
BusyGuard guard(this, &busy_);
|
||||
const auto f = repo_.loadRows(currentProjectId_, currentParentId_, currentParentConfType_, 1, ++filePageNo_);
|
||||
if (!f.ok) {
|
||||
emit loadFailed(QStringLiteral("files"), QString::fromStdString(f.error));
|
||||
return;
|
||||
}
|
||||
fileTotal_ = f.value.total;
|
||||
emit filesLoaded(QString::fromStdString(currentParentId_), f.value.rows, f.value.total, true);
|
||||
if (currentParentId_.empty()) return;
|
||||
if (moreFilesReq_) moreFilesReq_->abort();
|
||||
NavRequest* req =
|
||||
repo_.loadRowsAsync(currentProjectId_, currentParentId_, currentParentConfType_, 1, ++filePageNo_);
|
||||
moreFilesReq_ = req;
|
||||
const QString parent = QString::fromStdString(currentParentId_);
|
||||
emitBusyIfChanged();
|
||||
QObject::connect(req, &NavRequest::done, this, [this, req, parent](const QVariant& v) {
|
||||
if (req != moreFilesReq_) return;
|
||||
moreFilesReq_.clear();
|
||||
const auto page = qvariant_cast<DsPage>(v);
|
||||
fileTotal_ = page.total;
|
||||
emit filesLoaded(parent, page.rows, page.total, true);
|
||||
emitBusyIfChanged();
|
||||
});
|
||||
QObject::connect(req, &NavRequest::failed, this, [this, req](const QString& msg) {
|
||||
if (req != moreFilesReq_) return;
|
||||
moreFilesReq_.clear();
|
||||
--filePageNo_; // 回滚:本请求失败,页号复位,避免下次 loadMoreFiles 跳页
|
||||
emit loadFailed(QStringLiteral("files"), msg);
|
||||
emitBusyIfChanged();
|
||||
});
|
||||
}
|
||||
|
||||
// ── selectDataset:单请求 ──
|
||||
void WorkbenchNavController::selectDataset(const QString& dsObjectId) {
|
||||
if (dsObjectId.isEmpty()) return;
|
||||
if (datasetReq_) datasetReq_->abort();
|
||||
NavRequest* req = repo_.loadDatasetFormAsync(dsObjectId.toStdString());
|
||||
datasetReq_ = req;
|
||||
emitBusyIfChanged();
|
||||
QObject::connect(req, &NavRequest::done, this, [this, req](const QVariant& v) {
|
||||
if (req != datasetReq_) return;
|
||||
datasetReq_.clear();
|
||||
emit datasetDetailLoaded(qvariant_cast<DynamicForm>(v));
|
||||
emitBusyIfChanged();
|
||||
});
|
||||
QObject::connect(req, &NavRequest::failed, this, [this, req](const QString& msg) {
|
||||
if (req != datasetReq_) return;
|
||||
datasetReq_.clear();
|
||||
emit loadFailed(QStringLiteral("datasetDetail"), msg);
|
||||
emitBusyIfChanged();
|
||||
});
|
||||
}
|
||||
|
||||
// ── setCheckedTms:未命中缓存项并发拉取,全到齐后组装;新勾选 abort 旧批(以最后一次为准)──
|
||||
void WorkbenchNavController::setCheckedTms(const QStringList& tmObjectIds) {
|
||||
if (busy_) { // 触发源是延迟合并发射,可能落在别的同步操作的嵌套事件循环里:
|
||||
pendingCheckedTms_ = tmObjectIds; // 不丢弃,记下最新一次请求,待空闲重放
|
||||
checkedTmsPending_ = true;
|
||||
for (const auto& h : checkedInflight_) // abort-and-replace 旧批
|
||||
if (h) h->abort();
|
||||
checkedInflight_.clear();
|
||||
|
||||
// 入口去重:保留首次出现顺序,剔除重复 id(避免重复请求 + 异常树重复 group)。
|
||||
QStringList deduped;
|
||||
QSet<QString> seen;
|
||||
for (const QString& tmQ : tmObjectIds) {
|
||||
if (seen.contains(tmQ)) continue;
|
||||
seen.insert(tmQ);
|
||||
deduped.push_back(tmQ);
|
||||
}
|
||||
|
||||
QStringList missing;
|
||||
for (const QString& tmQ : deduped) {
|
||||
const std::string tm = tmQ.toStdString();
|
||||
if (tmExceptionCache_.find(tm) == tmExceptionCache_.end()) missing.push_back(tmQ);
|
||||
}
|
||||
if (missing.isEmpty()) { // 全命中缓存:同步组装,busyChanged 不抖动
|
||||
assembleAndEmitExceptionTree(deduped);
|
||||
emitBusyIfChanged();
|
||||
return;
|
||||
}
|
||||
BusyGuard guard(this, &busy_);
|
||||
|
||||
// 并发拉取未命中项;每个 done 写缓存、清自身槽,全到齐后组装。计数用 shared 计数器。
|
||||
auto remaining = std::make_shared<int>(missing.size());
|
||||
auto failedFlag = std::make_shared<bool>(false);
|
||||
for (const QString& tmQ : missing) {
|
||||
const std::string tm = tmQ.toStdString();
|
||||
NavRequest* req = repo_.loadExceptionsByTmAsync(tm);
|
||||
checkedInflight_.push_back(req);
|
||||
QObject::connect(req, &NavRequest::done, this,
|
||||
[this, req, tm, remaining, failedFlag, deduped](const QVariant& v) {
|
||||
// 身份比对:req 仍在当前批中才接受(旧批已 abort + 从 vector 移除)。
|
||||
bool inCurrent = false;
|
||||
for (const auto& h : checkedInflight_)
|
||||
if (h == req) inCurrent = true;
|
||||
if (!inCurrent) return;
|
||||
tmExceptionCache_[tm] = qvariant_cast<std::vector<ExceptionRow>>(v);
|
||||
if (--(*remaining) == 0 && !*failedFlag) {
|
||||
checkedInflight_.clear();
|
||||
assembleAndEmitExceptionTree(deduped);
|
||||
emitBusyIfChanged();
|
||||
}
|
||||
});
|
||||
QObject::connect(req, &NavRequest::failed, this,
|
||||
[this, req, remaining, failedFlag](const QString& msg) {
|
||||
bool inCurrent = false;
|
||||
for (const auto& h : checkedInflight_)
|
||||
if (h == req) inCurrent = true;
|
||||
if (!inCurrent) return;
|
||||
if (*failedFlag) return; // 仅首个失败发一次
|
||||
*failedFlag = true;
|
||||
for (const auto& h : checkedInflight_) // abort 其余在飞
|
||||
if (h && h != req) h->abort();
|
||||
checkedInflight_.clear();
|
||||
emit loadFailed(QStringLiteral("exceptions"), msg);
|
||||
emitBusyIfChanged();
|
||||
});
|
||||
}
|
||||
emitBusyIfChanged();
|
||||
}
|
||||
|
||||
void WorkbenchNavController::assembleAndEmitExceptionTree(const QStringList& tmObjectIds) {
|
||||
auto nameOf = [this](const std::string& id) -> std::string {
|
||||
for (const auto& n : lastStructNodes_)
|
||||
if (n.id == id) return n.name;
|
||||
|
|
@ -197,14 +450,7 @@ void WorkbenchNavController::setCheckedTms(const QStringList& tmObjectIds) {
|
|||
for (const QString& tmQ : tmObjectIds) {
|
||||
const std::string tm = tmQ.toStdString();
|
||||
auto it = tmExceptionCache_.find(tm);
|
||||
if (it == tmExceptionCache_.end()) {
|
||||
const auto ex = repo_.loadExceptionsByTm(tm);
|
||||
if (!ex.ok) {
|
||||
emit loadFailed(QStringLiteral("exceptions"), QString::fromStdString(ex.error));
|
||||
return;
|
||||
}
|
||||
it = tmExceptionCache_.emplace(tm, ex.value).first;
|
||||
}
|
||||
if (it == tmExceptionCache_.end()) continue; // 防御:理论上此时全命中
|
||||
auto grouped = data::dto::groupExceptionsByConsortium(it->second);
|
||||
data::ObjectExceptionGroup g;
|
||||
g.objectId = tm;
|
||||
|
|
@ -217,21 +463,4 @@ void WorkbenchNavController::setCheckedTms(const QStringList& tmObjectIds) {
|
|||
emit exceptionTreeLoaded(groups, total);
|
||||
}
|
||||
|
||||
void WorkbenchNavController::drainPendingCheckedTms() {
|
||||
if (busy_ || !checkedTmsPending_) return;
|
||||
checkedTmsPending_ = false; // 先清标志再重放,避免重入自旋
|
||||
setCheckedTms(pendingCheckedTms_); // 此时 busy_=false,会正常执行
|
||||
}
|
||||
|
||||
void WorkbenchNavController::selectDataset(const QString& dsObjectId) {
|
||||
if (dsObjectId.isEmpty() || busy_) return;
|
||||
BusyGuard guard(this, &busy_);
|
||||
const auto form = repo_.loadDatasetForm(dsObjectId.toStdString());
|
||||
if (!form.ok) {
|
||||
emit loadFailed(QStringLiteral("datasetDetail"), QString::fromStdString(form.error));
|
||||
return;
|
||||
}
|
||||
emit datasetDetailLoaded(form.value);
|
||||
}
|
||||
|
||||
} // namespace geopro::controller
|
||||
|
|
|
|||
|
|
@ -1,30 +1,39 @@
|
|||
#pragma once
|
||||
#include <QObject>
|
||||
#include <QPointer>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "repo/IProjectRepository.hpp"
|
||||
#include "repo/RepoTypes.hpp"
|
||||
|
||||
namespace geopro::data {
|
||||
class IAsyncProjectRepository;
|
||||
class NavRequest;
|
||||
} // namespace geopro::data
|
||||
|
||||
namespace geopro::controller {
|
||||
|
||||
// 导航状态机:编排 IProjectRepository,持有当前 空间/项目 状态,经信号驱动 UI。不持有 widget。
|
||||
// 导航状态机:编排 IAsyncProjectRepository(异步句柄),持有当前 空间/项目 状态,经信号驱动 UI。
|
||||
// 不持有 widget。abort-and-replace + 句柄身份比对保证迟到信号被丢弃(spec §5.0)。
|
||||
// busyChanged 语义:「是否存在任一在飞句柄」(去抖:值变才发)。
|
||||
class WorkbenchNavController : public QObject {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit WorkbenchNavController(data::IProjectRepository& repo, QObject* parent = nullptr);
|
||||
explicit WorkbenchNavController(data::IAsyncProjectRepository& repo, QObject* parent = nullptr);
|
||||
~WorkbenchNavController() override; // 退出契约:abort 所有在飞句柄
|
||||
|
||||
void start(); // 启动:拉空间 → 项目 → 结构
|
||||
void start(); // 启动:拉空间 → 项目 → 结构(依赖链)
|
||||
|
||||
QString currentCrsCode() const { return QString::fromStdString(currentCrsCode_); }
|
||||
|
||||
public slots:
|
||||
void switchWorkspace(const QString& tenantId);
|
||||
void switchProject(const QString& projectId);
|
||||
void selectObject(const QString& objectId, int confType); // 单击对象→DS列表+对象详情
|
||||
void setCheckedTms(const QStringList& tmObjectIds); // 勾选叶子集→异常树
|
||||
void selectObject(const QString& objectId, int confType); // 单击对象→DS列表+对象详情(并发)
|
||||
void setCheckedTms(const QStringList& tmObjectIds); // 勾选叶子集→异常树(并发,带缓存)
|
||||
void selectDataset(const QString& dsObjectId); // 单击DS→数据集动态表单
|
||||
void loadMoreData();
|
||||
void loadMoreFiles();
|
||||
|
|
@ -45,14 +54,30 @@ signals:
|
|||
void loadFailed(const QString& stage, const QString& message);
|
||||
|
||||
private:
|
||||
friend struct BusyGuard; // 允许在 guard 析构时排空挂起的勾选请求
|
||||
void loadProjectsAndStructure(); // start + switchWorkspace 共用
|
||||
void drainPendingCheckedTms(); // 空闲后重放最近一次被挂起的勾选集
|
||||
// start / switchWorkspace 依赖链:拉项目 → 拉结构(续延,复用)。
|
||||
void runProjectsAndStructure();
|
||||
void abortAll(); // 退出/重置时 abort 所有在飞句柄
|
||||
void resetSelectionState(); // 切项目/工作空间重置选中态(spec §6)
|
||||
void emitBusyIfChanged(); // 据「是否存在任一在飞句柄」去抖发 busyChanged
|
||||
bool anyInflight() const; // OR 所有在飞 QPointer / 集合
|
||||
void assembleAndEmitExceptionTree(const QStringList& tmObjectIds); // 缓存命中后组装异常树
|
||||
// 数据页树形分页:从 allDataRows_(一次取全的整棵)按「第一层节点(根)」切下一页,
|
||||
// 每页 kDataRootPageSize 个根 + 各自整棵子树;total=根总数。append=false 首页、true 加载更多。
|
||||
void emitNextDataRootPage(bool append);
|
||||
|
||||
data::IAsyncProjectRepository& repo_;
|
||||
bool lastBusy_ = false;
|
||||
|
||||
// 在飞句柄(QPointer 防悬垂;身份比对用):
|
||||
QPointer<data::NavRequest> startStepReq_; // start / switchWorkspace 依赖链当前在飞级
|
||||
QPointer<data::NavRequest> structReq_; // switchProject
|
||||
QPointer<data::NavRequest> selDataReq_; // selectObject:data 行
|
||||
QPointer<data::NavRequest> selFileReq_; // selectObject:file 行
|
||||
QPointer<data::NavRequest> selDetailReq_; // selectObject:对象详情
|
||||
QPointer<data::NavRequest> moreFilesReq_; // loadMoreFiles(数据页改客户端按根分页,无在飞句柄)
|
||||
QPointer<data::NavRequest> datasetReq_;
|
||||
std::vector<QPointer<data::NavRequest>> checkedInflight_; // setCheckedTms:未命中缓存的并发批
|
||||
|
||||
data::IProjectRepository& repo_;
|
||||
bool busy_ = false;
|
||||
bool checkedTmsPending_ = false;
|
||||
QStringList pendingCheckedTms_;
|
||||
std::vector<data::ProjectSummary> lastProjects_;
|
||||
std::string currentWorkspaceId_, currentProjectId_, currentProjectName_, currentCrsCode_;
|
||||
std::string currentParentId_;
|
||||
|
|
@ -61,8 +86,10 @@ private:
|
|||
std::map<std::string, std::vector<data::ExceptionRow>> tmExceptionCache_;
|
||||
int dataPageNo_ = 0;
|
||||
int filePageNo_ = 0;
|
||||
int dataTotal_ = 0;
|
||||
int dataTotal_ = 0; // 数据页:根节点总数(树形分页单位)
|
||||
int fileTotal_ = 0;
|
||||
std::vector<data::DsRow> allDataRows_; // 当前 TM 一次取全的所有数据行(树形按根客户端分页用)
|
||||
int dataRootsShown_ = 0; // 已 emit 的根节点数(loadMoreData 续切)
|
||||
};
|
||||
|
||||
} // namespace geopro::controller
|
||||
|
|
|
|||
|
|
@ -40,6 +40,13 @@ std::vector<double> ColorScale::stopValues() const {
|
|||
return v;
|
||||
}
|
||||
|
||||
std::vector<std::pair<double, Rgba>> ColorScale::stops() const {
|
||||
std::vector<std::pair<double, Rgba>> result;
|
||||
result.reserve(stops_.size());
|
||||
for (const auto& s : stops_) result.emplace_back(s.value, s.color);
|
||||
return result;
|
||||
}
|
||||
|
||||
Rgba ColorScale::colorAt(double value) const {
|
||||
if (std::isnan(value)) return nan_.value_or(Rgba{0, 0, 0, 0});
|
||||
if (stops_.empty()) return Rgba{0, 0, 0, 0};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
#pragma once
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
#include <optional>
|
||||
namespace geopro::core {
|
||||
|
|
@ -15,6 +16,8 @@ public:
|
|||
void addStop(double value, Rgba color); // 内部保持按 value 升序
|
||||
Rgba colorAt(double value) const; // 含 under/over/NaN 处理
|
||||
std::vector<double> stopValues() const; // 升序分段值(供等值线用真实非均匀级)
|
||||
// 返回升序断点 (value, color) 列表,供连续插值用。
|
||||
std::vector<std::pair<double, Rgba>> stops() const;
|
||||
std::size_t stopCount() const { return stops_.size(); }
|
||||
void setUnder(Rgba c) { under_ = c; }
|
||||
void setOver(Rgba c) { over_ = c; }
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
#pragma once
|
||||
#include <vector>
|
||||
#include <cstddef>
|
||||
#include <cmath>
|
||||
namespace geopro::core {
|
||||
|
||||
// 规则三维标量场(IInterpolator 输出;render 层转 vtkImageData)。
|
||||
|
|
@ -24,12 +25,14 @@ private:
|
|||
};
|
||||
|
||||
// 规则二维网格剖面(x=距离 i, y=深度 j)。values 存 [j*nx + i],i 最快。
|
||||
// NaN 值=无效/测区外(凸包裁剪据此)。
|
||||
class Grid {
|
||||
public:
|
||||
Grid(int nx, int ny) : nx_(nx), ny_(ny), values_(static_cast<size_t>(nx) * ny, 0.0) {}
|
||||
int nx() const { return nx_; } int ny() const { return ny_; }
|
||||
double& valueAt(int i, int j) { return values_[static_cast<size_t>(j) * nx_ + i]; }
|
||||
double valueAt(int i, int j) const { return values_[static_cast<size_t>(j) * nx_ + i]; }
|
||||
bool hasValue(int i, int j) const { return !std::isnan(valueAt(i, j)); }
|
||||
const std::vector<double>& values() const { return values_; }
|
||||
std::vector<double>& values() { return values_; }
|
||||
std::vector<double> x, y; // 轴坐标(规则)
|
||||
|
|
@ -46,6 +49,12 @@ private:
|
|||
struct ScatterField {
|
||||
std::vector<double> x, y, z, v; // 对应样本 xlist/ylist/(hlist)/vlist
|
||||
std::vector<double> projX, projY; // GIS 平面坐标
|
||||
// 可选元数据(measurement 散点用,反演留空)。a/b/m/n 与各点一一对应(hover 显示
|
||||
// A/B/M/N),electrodeX 为电极沿测线位置(y=0 处灰菱形 marker),electrodeNo 为对应
|
||||
// 电极编号(1-based,hover 显示 num,与 electrodeX 一一对应)。
|
||||
std::vector<double> a, b, m, n;
|
||||
std::vector<double> electrodeX;
|
||||
std::vector<double> electrodeNo;
|
||||
};
|
||||
|
||||
} // namespace geopro::core
|
||||
|
|
|
|||
|
|
@ -0,0 +1,123 @@
|
|||
#pragma once
|
||||
#include <vector>
|
||||
#include <QMetaType>
|
||||
#include <QString>
|
||||
#include "model/Field.hpp"
|
||||
#include "model/ColorScale.hpp"
|
||||
#include "model/Anomaly.hpp"
|
||||
|
||||
// 详情渲染载荷(纯数据,跨 QVariant 类型擦除传递)。无 Qt-widget 依赖。
|
||||
// 命名空间 geopro::core(与同目录 Field.hpp/ColorScale.hpp 一致)。
|
||||
namespace geopro::core {
|
||||
|
||||
// 下拉项:字段码 + 显示名(measurement 工具条 x/y/v/method 下拉驱动;code 可空如 method)。
|
||||
struct FieldOption {
|
||||
QString code;
|
||||
QString name;
|
||||
};
|
||||
|
||||
// measurement 散点工具条配置(来自服务端 scatterGraphConf;反演留空 → 视图渲染反演工具条)。
|
||||
// x/y/v/method 为各下拉的可选项(含 fieldCode+name);defaultX/Y/V/Method 为默认选中项的
|
||||
// fieldCode(method 的 fieldCode 全为 null,故 defaultMethod 用 name)。empty() 判定走 x 是否为空。
|
||||
struct ScatterToolbarConf {
|
||||
std::vector<FieldOption> x, y, v, method;
|
||||
QString defaultX, defaultY, defaultV, defaultMethod;
|
||||
bool empty() const { return x.empty(); }
|
||||
};
|
||||
|
||||
// 散点载荷:反演原数据 / measurement 散点共用(≈ data::ChartParts)。
|
||||
// 两者上色一致——按数据 min/max 连续插值(Plotly cauto,含负异常值,RawDataChartView 据 v
|
||||
// 有限值 min/max 设 setDataRange)。verticalLegend=true(measurement)时色阶图例画在右侧竖条
|
||||
// (离散带,1323→0 自上而下,对齐原版);false(默认,反演原数据)时画在底部横条。
|
||||
// toolbar 非空(measurement)时视图渲染 measurement 工具条;altX*/altY* 为 x/y 下拉本地重绘
|
||||
// 用的备选列(平距/斜距、伪深度/伪深度+高程),与 scatter.v/.a... 同序、一一对应,避免再发请求。
|
||||
struct ScatterPayload {
|
||||
geopro::core::ScatterField scatter;
|
||||
geopro::core::ColorScale scale;
|
||||
bool verticalLegend = false;
|
||||
ScatterToolbarConf toolbar;
|
||||
std::vector<double> altXHorizontal, altXSlope; // x 下拉:平距 / 斜距
|
||||
std::vector<double> altYPseudo, altYElevationPseudo; // y 下拉:伪深度 / 伪深度+高程
|
||||
};
|
||||
|
||||
// 等值面载荷:grid(rows) + 色阶 + 异常(≈ data::GridParts)。
|
||||
// Grid 无默认构造,给占位初始化以满足 QVariant 对默认可构造的要求。
|
||||
struct ContourPayload {
|
||||
geopro::core::Grid grid{1, 1};
|
||||
geopro::core::ColorScale scale;
|
||||
std::vector<geopro::core::Anomaly> anomalies;
|
||||
};
|
||||
|
||||
// 列渲染种类:Text=预格式化文本(默认);Toggle=每行开关(蓝色药丸开关,ON=可见)。
|
||||
enum class TableColumnKind { Text, Toggle };
|
||||
|
||||
// 通用表格列定义。
|
||||
struct TableColumn {
|
||||
QString code;
|
||||
QString title;
|
||||
int width = 0;
|
||||
int sort = 0;
|
||||
TableColumnKind kind = TableColumnKind::Text;
|
||||
};
|
||||
|
||||
// 通用表格载荷:列定义 + 预格式化的行(每格 QString)+ 总数(分页用)。
|
||||
// 分页(dd_grid 列表,服务端分页 vxe-pager):pageSize>0 时视图渲染分页器,pageNo 为当前页(1 基);
|
||||
// pageSize=0(默认)= 不分页(measurement/trajectory 全量列表,一次性返回所有行)。
|
||||
struct TablePayload {
|
||||
std::vector<TableColumn> columns;
|
||||
std::vector<std::vector<QString>> rows;
|
||||
int total = 0;
|
||||
int pageNo = 1; // 当前页(1 基);分页用
|
||||
int pageSize = 0; // 每页条数;>0 才渲染分页器(vxe-pager),0=不分页
|
||||
};
|
||||
|
||||
// 柱状图系列:名称(图例/legend)+ 各类目的 y 值 + 填充色(hex,如 #5470c6;数据色,两主题一致)。
|
||||
struct BarSeries {
|
||||
QString name;
|
||||
std::vector<double> values;
|
||||
QString color;
|
||||
};
|
||||
|
||||
// 柱状图载荷(dd_ert_measurement_gr_data 接地电阻):类目(x 轴标签,如 "#1".."#40")+
|
||||
// 一个或多个系列(P1/P2,每系列一组按类目对齐的 y 值)+ 轴标题。
|
||||
struct BarPayload {
|
||||
std::vector<QString> categories;
|
||||
std::vector<BarSeries> series;
|
||||
QString xTitle;
|
||||
QString yTitle;
|
||||
};
|
||||
|
||||
// 折线图载荷(dd_trajectory_data 高程页签):单条平滑折线。
|
||||
// 类目(x 轴标签,如 "#1".."#40",来自 electrodeNo)+ 对齐的 y 值(高程)+ 系列名 + 线色(hex,
|
||||
// 如 #5470c6,ECharts 默认蓝,两主题一致)+ 轴标题(x「电极号」/ y「高程」)+ 是否平滑。
|
||||
struct LinePayload {
|
||||
std::vector<QString> categories;
|
||||
std::vector<double> y;
|
||||
QString seriesName;
|
||||
QString color;
|
||||
QString xTitle;
|
||||
QString yTitle;
|
||||
bool smooth = true;
|
||||
};
|
||||
|
||||
// 轨迹地图点(dd_trajectory_data 地图页签):电极号 + WGS84 经纬度。
|
||||
// 服务端 dd/ert/trajectory/line 直接返回 EPSG:4326 经纬(x=经度 lon、y=纬度 lat)。
|
||||
struct MapPoint {
|
||||
int electrodeNo = 0;
|
||||
double lon = 0;
|
||||
double lat = 0;
|
||||
};
|
||||
|
||||
// 轨迹地图载荷:一组电极经纬点(按电极号顺序)。视图据此在 Leaflet 上画橙色空心圈标记。
|
||||
struct MapPayload {
|
||||
std::vector<MapPoint> points;
|
||||
};
|
||||
|
||||
} // namespace geopro::core
|
||||
|
||||
Q_DECLARE_METATYPE(geopro::core::ScatterPayload)
|
||||
Q_DECLARE_METATYPE(geopro::core::ContourPayload)
|
||||
Q_DECLARE_METATYPE(geopro::core::TablePayload)
|
||||
Q_DECLARE_METATYPE(geopro::core::BarPayload)
|
||||
Q_DECLARE_METATYPE(geopro::core::LinePayload)
|
||||
Q_DECLARE_METATYPE(geopro::core::MapPayload)
|
||||
|
|
@ -4,8 +4,16 @@ add_library(geopro_data STATIC
|
|||
parse/SampleParsers.cpp
|
||||
repo/LocalSampleRepository.cpp
|
||||
dto/NavDto.cpp
|
||||
api/ApiProjectRepository.cpp)
|
||||
dto/DatasetChartDto.cpp
|
||||
dto/MeasurementDto.cpp
|
||||
dto/GrMeasurementDto.cpp
|
||||
dto/TrajectoryDto.cpp
|
||||
dto/GridDto.cpp
|
||||
api/ApiProjectRepository.cpp
|
||||
api/ApiDatasetRepository.cpp
|
||||
api/DatasetLoadHandles.cpp
|
||||
api/NavRequest.cpp)
|
||||
target_include_directories(geopro_data PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
|
||||
target_link_libraries(geopro_data PUBLIC geopro_core geopro_net Qt6::Core PRIVATE nlohmann_json::nlohmann_json)
|
||||
target_compile_features(geopro_data PUBLIC cxx_std_17)
|
||||
set_target_properties(geopro_data PROPERTIES AUTOMOC OFF AUTOUIC OFF AUTORCC OFF)
|
||||
set_target_properties(geopro_data PROPERTIES AUTOMOC ON AUTOUIC OFF AUTORCC OFF)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
Repository 抽象(**异步契约**:QFuture/回调 + 取消 + 分页),DTO 与领域模型分离。
|
||||
|
||||
子目录(设计 §3、§6):
|
||||
- `repo/` — IProjectRepository, IDatasetRepository
|
||||
- `repo/` — IAsyncProjectRepository, IDatasetRepository
|
||||
- `local/` — LocalSampleRepository(M1,QtConcurrent 跑解析)+ 各格式解析器
|
||||
- `api/` — ApiRepository(M1 骨架,签名对齐 pop-api)
|
||||
- `dto/` — 后端 JSON DTO + → model 映射
|
||||
|
|
|
|||
|
|
@ -0,0 +1,236 @@
|
|||
#include "api/ApiDatasetRepository.hpp"
|
||||
#include <stdexcept>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonObject>
|
||||
#include <QString>
|
||||
#include <QUrl>
|
||||
#include <QVariant>
|
||||
#include "ApiClient.hpp"
|
||||
#include "ApiBatch.hpp"
|
||||
#include "api/DatasetLoadHandles.hpp"
|
||||
#include "dto/DatasetChartDto.hpp"
|
||||
#include "dto/GrMeasurementDto.hpp"
|
||||
#include "dto/GridDto.hpp"
|
||||
#include "dto/MeasurementDto.hpp"
|
||||
#include "dto/TrajectoryDto.hpp"
|
||||
#include "model/detail/DetailPayloads.hpp"
|
||||
|
||||
namespace geopro::data {
|
||||
namespace {
|
||||
|
||||
QString enc(const std::string& s) {
|
||||
return QString::fromUtf8(QUrl::toPercentEncoding(QString::fromStdString(s)));
|
||||
}
|
||||
|
||||
// 解析中间态(仅本 .cpp 内部使用,故置匿名命名空间,避免对外双份表示)。
|
||||
// 原数据加载结果:scatter + 散点色阶(type1)。
|
||||
struct ChartParts {
|
||||
geopro::core::ScatterField scatter;
|
||||
geopro::core::ColorScale scatterScale;
|
||||
};
|
||||
// 网格数据加载结果:grid(rows) + 网格色阶(type2) + 异常。
|
||||
struct GridParts {
|
||||
geopro::core::Grid grid{1, 1}; // Grid 无默认构造;占位
|
||||
geopro::core::ColorScale gridScale;
|
||||
std::vector<geopro::core::Anomaly> anomalies;
|
||||
};
|
||||
|
||||
// 失败判定(原 must() 口径):业务码 != 200 或传输错误。
|
||||
bool isFailure(const geopro::net::ApiResponse& r) {
|
||||
return r.code != 200 || !r.rawError.isEmpty();
|
||||
}
|
||||
|
||||
QJsonObject colorBody(const std::string& dsId, int type) {
|
||||
return QJsonObject{{"dsObjectId", QString::fromStdString(dsId)}, {"businessCode", ""}, {"type", type}};
|
||||
}
|
||||
|
||||
// ── 共享批次构造:唯一端点定义处,old/new 路径复用,避免双份解析逻辑。 ──
|
||||
// 反演原数据:index 0 = scatter(GET),1 = 散点色阶 type1(POST)。
|
||||
net::ApiBatch* inversionScatterBatch(net::ApiClient& api, const std::string& dsId) {
|
||||
QList<net::IApiCall*> calls{
|
||||
api.getAsync(QStringLiteral("/business/dd/ert/inversion/getErtRawDataScatterGraph/%1").arg(enc(dsId))),
|
||||
api.postJsonAsync(QStringLiteral("/business/lvl/colorGradation/getDetail"), colorBody(dsId, 1)),
|
||||
};
|
||||
return new net::ApiBatch(calls, &isFailure);
|
||||
}
|
||||
|
||||
ChartParts parseScatterParts(const QList<net::ApiResponse>& r) {
|
||||
ChartParts p;
|
||||
p.scatter = dto::parseScatterGraph(r[0].data);
|
||||
p.scatterScale = dto::parseColorBar(r[1].data);
|
||||
return p;
|
||||
}
|
||||
|
||||
// 反演网格:index 0 = rows(GET,慢),1 = 色阶 type2(POST),2 = 异常(GET)。
|
||||
net::ApiBatch* inversionGridBatch(net::ApiClient& api, const std::string& dsId) {
|
||||
QList<net::IApiCall*> calls{
|
||||
api.getAsync(QStringLiteral("/business/dd/ert/inversion/rows/%1").arg(enc(dsId))),
|
||||
api.postJsonAsync(QStringLiteral("/business/lvl/colorGradation/getDetail"), colorBody(dsId, 2)),
|
||||
api.getAsync(QStringLiteral("/business/exception/queryException/%1").arg(enc(dsId))),
|
||||
};
|
||||
return new net::ApiBatch(calls, &isFailure);
|
||||
}
|
||||
|
||||
GridParts parseGridParts(const QList<net::ApiResponse>& r) {
|
||||
GridParts p;
|
||||
p.grid = dto::parseInversionGrid(r[0].data);
|
||||
p.gridScale = dto::parseColorBar(r[1].data);
|
||||
p.anomalies = dto::parseDatasetAnomalies(r[2].data.value(QStringLiteral("value")).toArray());
|
||||
return p;
|
||||
}
|
||||
|
||||
// measurement 散点:index 0 = scatter/graph(GET,query 参数),1 = 色阶 type3(POST,businessCode=R0)。
|
||||
net::ApiBatch* measurementScatterBatch(net::ApiClient& api, const std::string& dsId) {
|
||||
const QString did = enc(dsId);
|
||||
QList<net::IApiCall*> calls{
|
||||
api.getAsync(QStringLiteral("/business/dd/ert/measurement/scatter/graph?dsObjectId=%1&vFieldCode=").arg(did)),
|
||||
api.postJsonAsync(QStringLiteral("/business/lvl/colorGradation/getDetail"),
|
||||
QJsonObject{{"dsObjectId", QString::fromStdString(dsId)},
|
||||
{"businessCode", "R0"},
|
||||
{"type", 3}}),
|
||||
};
|
||||
return new net::ApiBatch(calls, &isFailure);
|
||||
}
|
||||
|
||||
// measurement 列表:单请求 measurement/rows(GET,query 参数)。
|
||||
net::ApiBatch* measurementRowsBatch(net::ApiClient& api, const std::string& dsId) {
|
||||
QList<net::IApiCall*> calls{
|
||||
api.getAsync(QStringLiteral("/business/dd/ert/measurement/rows?dsObjectId=%1").arg(enc(dsId))),
|
||||
};
|
||||
return new net::ApiBatch(calls, &isFailure);
|
||||
}
|
||||
|
||||
// 接地电阻(gr):柱状图与列表同一端点 measurement/gr/rows(GET,query 参数)。
|
||||
// 响应 data 是 JSON 数组 → ApiResponseParse 包成 {value:[...]},取 r[0].data.value("value").toArray()。
|
||||
net::ApiBatch* grRowsBatch(net::ApiClient& api, const std::string& dsId) {
|
||||
QList<net::IApiCall*> calls{
|
||||
api.getAsync(QStringLiteral("/business/dd/ert/measurement/gr/rows?dsObjectId=%1").arg(enc(dsId))),
|
||||
};
|
||||
return new net::ApiBatch(calls, &isFailure);
|
||||
}
|
||||
|
||||
// gr 响应数组(包在 data.value 里)。
|
||||
QJsonArray grDataArray(const QList<net::ApiResponse>& r) {
|
||||
return r[0].data.value(QStringLiteral("value")).toArray();
|
||||
}
|
||||
|
||||
// 轨迹(trajectory):列表与高程同一端点 trajectory/rows(GET,query 参数)。
|
||||
// 响应 data = {rowList[40], gridHeaderDisplay[14], ...}。
|
||||
net::ApiBatch* trajectoryRowsBatch(net::ApiClient& api, const std::string& dsId) {
|
||||
QList<net::IApiCall*> calls{
|
||||
api.getAsync(QStringLiteral("/business/dd/ert/trajectory/rows?dsObjectId=%1").arg(enc(dsId))),
|
||||
};
|
||||
return new net::ApiBatch(calls, &isFailure);
|
||||
}
|
||||
|
||||
// 轨迹地图:单请求 trajectory/line(GET,query 参数)。frontCrsCode=EPSG:4326(原版发 EPSG%3A4326,
|
||||
// 此处用 enc() 对整串 url 编码故 ':' → %3A)。响应 data = {electrodelList[...]}。
|
||||
net::ApiBatch* trajectoryLineBatch(net::ApiClient& api, const std::string& dsId) {
|
||||
QList<net::IApiCall*> calls{
|
||||
api.getAsync(QStringLiteral("/business/dd/ert/trajectory/line?dsObjectId=%1&frontCrsCode=%2")
|
||||
.arg(enc(dsId), enc("EPSG:4326"))),
|
||||
};
|
||||
return new net::ApiBatch(calls, &isFailure);
|
||||
}
|
||||
|
||||
// dd_grid 白化数据列表(服务端分页):单请求 grid/rows(GET,query:dsObjectId/pageNo/pageSize)。
|
||||
// 响应 data = {rowList[{x,y,id}], gridHeaderDisplay[x,y], total}。
|
||||
net::ApiBatch* gridRowsBatch(net::ApiClient& api, const std::string& dsId, int pageNo, int pageSize) {
|
||||
QList<net::IApiCall*> calls{
|
||||
api.getAsync(QStringLiteral("/business/dd/ert/grid/rows?dsObjectId=%1&pageNo=%2&pageSize=%3")
|
||||
.arg(enc(dsId))
|
||||
.arg(pageNo)
|
||||
.arg(pageSize)),
|
||||
};
|
||||
return new net::ApiBatch(calls, &isFailure);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
ApiDatasetRepository::ApiDatasetRepository(net::ApiClient& api) : api_(api) {}
|
||||
|
||||
DetailLoad* ApiDatasetRepository::loadAsync(const std::string& loaderKey, const std::string& dsId,
|
||||
int pageNo, int pageSize) {
|
||||
if (loaderKey == "inversion.scatter") return makeInversionScatter(dsId);
|
||||
if (loaderKey == "inversion.grid") return makeInversionGrid(dsId);
|
||||
if (loaderKey == "ert_measurement.scatter") return makeMeasurementScatter(dsId);
|
||||
if (loaderKey == "ert_measurement.rows") return makeMeasurementRows(dsId);
|
||||
if (loaderKey == "gr.bar") return makeGrBar(dsId);
|
||||
if (loaderKey == "gr.rows") return makeGrRows(dsId);
|
||||
if (loaderKey == "traj.rows") return makeTrajectoryRows(dsId);
|
||||
if (loaderKey == "traj.elev") return makeTrajectoryElevation(dsId);
|
||||
if (loaderKey == "traj.map") return makeTrajectoryMap(dsId);
|
||||
if (loaderKey == "grid.rows") return makeGridRows(dsId, pageNo, pageSize);
|
||||
throw std::runtime_error("unknown loaderKey: " + loaderKey);
|
||||
}
|
||||
|
||||
DetailLoad* ApiDatasetRepository::makeInversionScatter(const std::string& dsId) {
|
||||
// 复用同一批次 + 解析器,再映射为 ScatterPayload(不复制 JSON 解析逻辑)。
|
||||
return new ApiDetailLoad(inversionScatterBatch(api_, dsId), [](const QList<net::ApiResponse>& r) {
|
||||
ChartParts p = parseScatterParts(r);
|
||||
return QVariant::fromValue(core::ScatterPayload{p.scatter, p.scatterScale});
|
||||
});
|
||||
}
|
||||
|
||||
DetailLoad* ApiDatasetRepository::makeInversionGrid(const std::string& dsId) {
|
||||
return new ApiDetailLoad(inversionGridBatch(api_, dsId), [](const QList<net::ApiResponse>& r) {
|
||||
GridParts p = parseGridParts(r);
|
||||
return QVariant::fromValue(core::ContourPayload{p.grid, p.gridScale, p.anomalies});
|
||||
});
|
||||
}
|
||||
|
||||
DetailLoad* ApiDatasetRepository::makeMeasurementScatter(const std::string& dsId) {
|
||||
// index 0 = scatter/graph, 1 = colorBar(type3) → 离散上色的 ScatterPayload。
|
||||
return new ApiDetailLoad(measurementScatterBatch(api_, dsId), [](const QList<net::ApiResponse>& r) {
|
||||
return QVariant::fromValue(dto::parseMeasurementScatter(r[0].data, r[1].data));
|
||||
});
|
||||
}
|
||||
|
||||
DetailLoad* ApiDatasetRepository::makeMeasurementRows(const std::string& dsId) {
|
||||
return new ApiDetailLoad(measurementRowsBatch(api_, dsId), [](const QList<net::ApiResponse>& r) {
|
||||
return QVariant::fromValue(dto::parseMeasurementTable(r[0].data));
|
||||
});
|
||||
}
|
||||
|
||||
DetailLoad* ApiDatasetRepository::makeGrBar(const std::string& dsId) {
|
||||
return new ApiDetailLoad(grRowsBatch(api_, dsId), [](const QList<net::ApiResponse>& r) {
|
||||
return QVariant::fromValue(dto::parseGrBar(grDataArray(r)));
|
||||
});
|
||||
}
|
||||
|
||||
DetailLoad* ApiDatasetRepository::makeGrRows(const std::string& dsId) {
|
||||
return new ApiDetailLoad(grRowsBatch(api_, dsId), [](const QList<net::ApiResponse>& r) {
|
||||
return QVariant::fromValue(dto::parseGrTable(grDataArray(r)));
|
||||
});
|
||||
}
|
||||
|
||||
DetailLoad* ApiDatasetRepository::makeTrajectoryRows(const std::string& dsId) {
|
||||
return new ApiDetailLoad(trajectoryRowsBatch(api_, dsId), [](const QList<net::ApiResponse>& r) {
|
||||
return QVariant::fromValue(dto::parseTrajectoryTable(r[0].data));
|
||||
});
|
||||
}
|
||||
|
||||
DetailLoad* ApiDatasetRepository::makeTrajectoryElevation(const std::string& dsId) {
|
||||
return new ApiDetailLoad(trajectoryRowsBatch(api_, dsId), [](const QList<net::ApiResponse>& r) {
|
||||
return QVariant::fromValue(dto::parseTrajectoryElevation(r[0].data));
|
||||
});
|
||||
}
|
||||
|
||||
DetailLoad* ApiDatasetRepository::makeTrajectoryMap(const std::string& dsId) {
|
||||
return new ApiDetailLoad(trajectoryLineBatch(api_, dsId), [](const QList<net::ApiResponse>& r) {
|
||||
return QVariant::fromValue(dto::parseTrajectoryMap(r[0].data));
|
||||
});
|
||||
}
|
||||
|
||||
DetailLoad* ApiDatasetRepository::makeGridRows(const std::string& dsId, int pageNo, int pageSize) {
|
||||
// 解析默认值:pageNo<=0→1,pageSize<=0→50(原版默认每页 50 条),再传入 URL 与解析器
|
||||
// (保证 URL 参数与「序号」列偏移一致)。
|
||||
const int pn = pageNo > 0 ? pageNo : 1;
|
||||
const int ps = pageSize > 0 ? pageSize : 50;
|
||||
return new ApiDetailLoad(gridRowsBatch(api_, dsId, pn, ps),
|
||||
[pn, ps](const QList<net::ApiResponse>& r) {
|
||||
return QVariant::fromValue(dto::parseGridTable(r[0].data, pn, ps));
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace geopro::data
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
#pragma once
|
||||
#include "repo/IAsyncDatasetRepository.hpp"
|
||||
namespace geopro::net { class ApiClient; }
|
||||
namespace geopro::data {
|
||||
|
||||
// 真实 API 实现 IAsyncDatasetRepository(ERT 反演)。每次加载返回自管理句柄。
|
||||
class ApiDatasetRepository : public IAsyncDatasetRepository {
|
||||
public:
|
||||
explicit ApiDatasetRepository(net::ApiClient& api);
|
||||
DetailLoad* loadAsync(const std::string& loaderKey, const std::string& dsId,
|
||||
int pageNo = 1, int pageSize = 0) override;
|
||||
private:
|
||||
DetailLoad* makeInversionScatter(const std::string& dsId);
|
||||
DetailLoad* makeInversionGrid(const std::string& dsId);
|
||||
DetailLoad* makeMeasurementScatter(const std::string& dsId);
|
||||
DetailLoad* makeMeasurementRows(const std::string& dsId);
|
||||
DetailLoad* makeGrBar(const std::string& dsId);
|
||||
DetailLoad* makeGrRows(const std::string& dsId);
|
||||
DetailLoad* makeTrajectoryRows(const std::string& dsId);
|
||||
DetailLoad* makeTrajectoryElevation(const std::string& dsId);
|
||||
DetailLoad* makeTrajectoryMap(const std::string& dsId);
|
||||
DetailLoad* makeGridRows(const std::string& dsId, int pageNo, int pageSize);
|
||||
net::ApiClient& api_;
|
||||
};
|
||||
} // namespace geopro::data
|
||||
|
|
@ -6,6 +6,8 @@
|
|||
#include <QUrl>
|
||||
|
||||
#include "ApiClient.hpp"
|
||||
#include "api/NavLoads.hpp"
|
||||
#include "api/NavRequest.hpp"
|
||||
#include "dto/NavDto.hpp"
|
||||
|
||||
namespace geopro::data {
|
||||
|
|
@ -13,13 +15,8 @@ namespace geopro::data {
|
|||
namespace {
|
||||
constexpr int kCodeSuccess = 200;
|
||||
|
||||
bool ok(const net::ApiResponse& r) { return r.code == kCodeSuccess; }
|
||||
|
||||
std::string errorOf(const net::ApiResponse& r, const char* fallback) {
|
||||
if (!r.msg.isEmpty()) return r.msg.toStdString();
|
||||
if (!r.rawError.isEmpty()) return r.rawError.toStdString();
|
||||
return fallback;
|
||||
}
|
||||
// 异步失败谓词:业务码非 200 或网络错误。
|
||||
bool isFailureA(const net::ApiResponse& r) { return r.code != kCodeSuccess || !r.rawError.isEmpty(); }
|
||||
|
||||
// 后端 id 进 URL 前做百分号编码(不可信外部数据:防 ? # & / 空格 破坏路径/查询)。
|
||||
QString enc(const std::string& s) {
|
||||
|
|
@ -29,54 +26,59 @@ QString enc(const std::string& s) {
|
|||
|
||||
ApiProjectRepository::ApiProjectRepository(net::ApiClient& api) : api_(api) {}
|
||||
|
||||
RepoResult<std::vector<Workspace>> ApiProjectRepository::listWorkspaces() {
|
||||
const net::ApiResponse r =
|
||||
api_.get(QStringLiteral("/business/system/tenant/enterprise/joined/list"));
|
||||
if (!ok(r)) return {false, {}, errorOf(r, "listWorkspaces failed")};
|
||||
return {true, dto::parseWorkspaces(r.data.value(QStringLiteral("value")).toArray()), {}};
|
||||
// ── 异步实现(薄封装:解析器在 try 内见 ApiNavRequest)──
|
||||
|
||||
NavRequest* ApiProjectRepository::listWorkspacesAsync() {
|
||||
auto* call = api_.getAsync(QStringLiteral("/business/system/tenant/enterprise/joined/list"));
|
||||
return new ApiNavRequest(call, [](const net::ApiResponse& r) {
|
||||
return QVariant::fromValue(dto::parseWorkspaces(r.data.value(QStringLiteral("value")).toArray()));
|
||||
}, &isFailureA);
|
||||
}
|
||||
|
||||
RepoResult<bool> ApiProjectRepository::switchWorkspace(const std::string& tenantId) {
|
||||
NavRequest* ApiProjectRepository::switchWorkspaceAsync(const std::string& tenantId) {
|
||||
const QString path =
|
||||
QStringLiteral("/business/system/tenant/enterprise/switch/%1").arg(enc(tenantId));
|
||||
const net::ApiResponse r = api_.postJson(path, QJsonObject{});
|
||||
if (!ok(r)) return {false, false, errorOf(r, "switchWorkspace failed")};
|
||||
// 切换空间返回新 accessToken:必须重新注入,后续请求才落到新空间。
|
||||
auto* call = api_.postJsonAsync(path, QJsonObject{});
|
||||
// 切换空间返回新 accessToken:必须重新注入(与同步版一致),后续请求才落到新空间。
|
||||
return new ApiNavRequest(call, [this](const net::ApiResponse& r) {
|
||||
const QString token = r.data.value(QStringLiteral("accessToken")).toString();
|
||||
if (!token.isEmpty()) api_.setToken(token);
|
||||
return {true, true, {}};
|
||||
return QVariant::fromValue(true);
|
||||
}, &isFailureA);
|
||||
}
|
||||
|
||||
RepoResult<ProjectListPage> ApiProjectRepository::pageProjects(const std::string& nameFilter,
|
||||
NavRequest* ApiProjectRepository::pageProjectsAsync(const std::string& nameFilter,
|
||||
const std::string& typeId, int pageNo,
|
||||
int pageSize) {
|
||||
QJsonObject body{{QStringLiteral("projectName"), QString::fromStdString(nameFilter)},
|
||||
{QStringLiteral("pageNo"), pageNo},
|
||||
{QStringLiteral("pageSize"), pageSize}};
|
||||
if (!typeId.empty()) body[QStringLiteral("projectTypeId")] = QString::fromStdString(typeId);
|
||||
const net::ApiResponse r = api_.postJson(QStringLiteral("/business/my/profile/project/page"), body);
|
||||
if (!ok(r)) return {false, {}, errorOf(r, "pageProjects failed")};
|
||||
return {true, dto::parseProjectPage(r.data), {}};
|
||||
auto* call = api_.postJsonAsync(QStringLiteral("/business/my/profile/project/page"), body);
|
||||
return new ApiNavRequest(call, [](const net::ApiResponse& r) {
|
||||
return QVariant::fromValue(dto::parseProjectPage(r.data));
|
||||
}, &isFailureA);
|
||||
}
|
||||
|
||||
RepoResult<std::vector<ProjectType>> ApiProjectRepository::listProjectTypes() {
|
||||
const net::ApiResponse r = api_.get(QStringLiteral("/business/project/type/list"));
|
||||
if (!ok(r)) return {false, {}, errorOf(r, "listProjectTypes failed")};
|
||||
return {true, dto::parseProjectTypes(r.data.value(QStringLiteral("value")).toArray()), {}};
|
||||
NavRequest* ApiProjectRepository::listProjectTypesAsync() {
|
||||
auto* call = api_.getAsync(QStringLiteral("/business/project/type/list"));
|
||||
return new ApiNavRequest(call, [](const net::ApiResponse& r) {
|
||||
return QVariant::fromValue(dto::parseProjectTypes(r.data.value(QStringLiteral("value")).toArray()));
|
||||
}, &isFailureA);
|
||||
}
|
||||
|
||||
RepoResult<std::vector<StructNode>> ApiProjectRepository::loadStructure(const std::string& projectId) {
|
||||
// 项目结构(项目根 + GS + TM;不含 DS)。比 projectWorkbench 干净。
|
||||
NavRequest* ApiProjectRepository::loadStructureAsync(const std::string& projectId) {
|
||||
const QString path =
|
||||
QStringLiteral("/business/projectStruct/queryProjectStruct/%1").arg(enc(projectId));
|
||||
const net::ApiResponse r = api_.get(path);
|
||||
if (!ok(r)) return {false, {}, errorOf(r, "loadStructure failed")};
|
||||
return {true, dto::parseStructNodes(r.data.value(QStringLiteral("value")).toArray()), {}};
|
||||
auto* call = api_.getAsync(path);
|
||||
return new ApiNavRequest(call, [](const net::ApiResponse& r) {
|
||||
return QVariant::fromValue(dto::parseStructNodes(r.data.value(QStringLiteral("value")).toArray()));
|
||||
}, &isFailureA);
|
||||
}
|
||||
|
||||
RepoResult<DsPage> ApiProjectRepository::loadRows(const std::string& projectId,
|
||||
NavRequest* ApiProjectRepository::loadRowsAsync(const std::string& projectId,
|
||||
const std::string& parentId, int parentConfType,
|
||||
int classifyType, int pageNo) {
|
||||
int classifyType, int pageNo, int pageSize) {
|
||||
const QString path = (classifyType == 1) ? QStringLiteral("/business/dsObject/file/page")
|
||||
: QStringLiteral("/business/dsObject/data/page");
|
||||
const QJsonObject body{
|
||||
|
|
@ -85,38 +87,39 @@ RepoResult<DsPage> ApiProjectRepository::loadRows(const std::string& projectId,
|
|||
{QStringLiteral("structParentConfType"), parentConfType},
|
||||
{QStringLiteral("classifyTypeList"), QJsonArray{classifyType}},
|
||||
{QStringLiteral("pageNo"), pageNo},
|
||||
{QStringLiteral("pageSize"), 5}};
|
||||
const net::ApiResponse r = api_.postJson(path, body);
|
||||
if (!ok(r)) return {false, {}, errorOf(r, "loadRows failed")};
|
||||
return {true, dto::parseDsPage(r.data), {}};
|
||||
{QStringLiteral("pageSize"), pageSize}};
|
||||
auto* call = api_.postJsonAsync(path, body);
|
||||
return new ApiNavRequest(call, [](const net::ApiResponse& r) {
|
||||
return QVariant::fromValue(dto::parseDsPage(r.data));
|
||||
}, &isFailureA);
|
||||
}
|
||||
|
||||
RepoResult<DynamicForm> ApiProjectRepository::loadObjectDetail(const std::string& objectId,
|
||||
int confType) {
|
||||
NavRequest* ApiProjectRepository::loadObjectDetailAsync(const std::string& objectId, int confType) {
|
||||
const QString path =
|
||||
(confType == 1)
|
||||
? QStringLiteral("/business/gsObject/getGsObjectDetail/%1").arg(enc(objectId))
|
||||
: QStringLiteral("/business/tmObject/getDetail/%1").arg(enc(objectId));
|
||||
const net::ApiResponse r = api_.get(path);
|
||||
if (!ok(r)) return {false, {}, errorOf(r, "loadObjectDetail failed")};
|
||||
return {true, dto::parseDynamicForm(r.data), {}};
|
||||
auto* call = api_.getAsync(path);
|
||||
return new ApiNavRequest(call, [](const net::ApiResponse& r) {
|
||||
return QVariant::fromValue(dto::parseDynamicForm(r.data));
|
||||
}, &isFailureA);
|
||||
}
|
||||
|
||||
RepoResult<DynamicForm> ApiProjectRepository::loadDatasetForm(const std::string& dsObjectId) {
|
||||
const QString path =
|
||||
QStringLiteral("/business/dsObject/dynamicForm/%1").arg(enc(dsObjectId));
|
||||
const net::ApiResponse r = api_.get(path);
|
||||
if (!ok(r)) return {false, {}, errorOf(r, "loadDatasetForm failed")};
|
||||
return {true, dto::parseDynamicForm(r.data), {}};
|
||||
NavRequest* ApiProjectRepository::loadDatasetFormAsync(const std::string& dsObjectId) {
|
||||
const QString path = QStringLiteral("/business/dsObject/dynamicForm/%1").arg(enc(dsObjectId));
|
||||
auto* call = api_.getAsync(path);
|
||||
return new ApiNavRequest(call, [](const net::ApiResponse& r) {
|
||||
return QVariant::fromValue(dto::parseDynamicForm(r.data));
|
||||
}, &isFailureA);
|
||||
}
|
||||
|
||||
RepoResult<std::vector<ExceptionRow>> ApiProjectRepository::loadExceptionsByTm(
|
||||
const std::string& tmObjectId) {
|
||||
NavRequest* ApiProjectRepository::loadExceptionsByTmAsync(const std::string& tmObjectId) {
|
||||
const QString path =
|
||||
QStringLiteral("/business/exception/queryExceptionByTmObjectId/%1").arg(enc(tmObjectId));
|
||||
const net::ApiResponse r = api_.get(path);
|
||||
if (!ok(r)) return {false, {}, errorOf(r, "loadExceptionsByTm failed")};
|
||||
return {true, dto::parseExceptions(r.data.value(QStringLiteral("value")).toArray()), {}};
|
||||
auto* call = api_.getAsync(path);
|
||||
return new ApiNavRequest(call, [](const net::ApiResponse& r) {
|
||||
return QVariant::fromValue(dto::parseExceptions(r.data.value(QStringLiteral("value")).toArray()));
|
||||
}, &isFailureA);
|
||||
}
|
||||
|
||||
} // namespace geopro::data
|
||||
|
|
|
|||
|
|
@ -1,26 +1,30 @@
|
|||
#pragma once
|
||||
#include "repo/IProjectRepository.hpp"
|
||||
#include "repo/IAsyncProjectRepository.hpp"
|
||||
|
||||
namespace geopro::net { class ApiClient; }
|
||||
|
||||
namespace geopro::data {
|
||||
|
||||
// 用共享会话 ApiClient 实现导航仓储(同步阻塞)。token 由调用方注入 ApiClient。
|
||||
class ApiProjectRepository : public IProjectRepository {
|
||||
class NavRequest;
|
||||
|
||||
// 用共享会话 ApiClient 实现导航异步仓储。token 由调用方注入 ApiClient。
|
||||
class ApiProjectRepository : public IAsyncProjectRepository {
|
||||
public:
|
||||
explicit ApiProjectRepository(net::ApiClient& api);
|
||||
|
||||
RepoResult<std::vector<Workspace>> listWorkspaces() override;
|
||||
RepoResult<bool> switchWorkspace(const std::string& tenantId) override;
|
||||
RepoResult<ProjectListPage> pageProjects(const std::string& nameFilter, const std::string& typeId,
|
||||
// ── 异步 ── 返回 NavRequest*(薄封装,一方法一请求)。
|
||||
NavRequest* listWorkspacesAsync() override;
|
||||
NavRequest* switchWorkspaceAsync(const std::string& tenantId) override;
|
||||
NavRequest* pageProjectsAsync(const std::string& nameFilter, const std::string& typeId,
|
||||
int pageNo, int pageSize) override;
|
||||
RepoResult<std::vector<ProjectType>> listProjectTypes() override;
|
||||
RepoResult<std::vector<StructNode>> loadStructure(const std::string& projectId) override;
|
||||
RepoResult<DsPage> loadRows(const std::string& projectId, const std::string& parentId,
|
||||
int parentConfType, int classifyType, int pageNo) override;
|
||||
RepoResult<DynamicForm> loadObjectDetail(const std::string& objectId, int confType) override;
|
||||
RepoResult<DynamicForm> loadDatasetForm(const std::string& dsObjectId) override;
|
||||
RepoResult<std::vector<ExceptionRow>> loadExceptionsByTm(const std::string& tmObjectId) override;
|
||||
NavRequest* listProjectTypesAsync() override;
|
||||
NavRequest* loadStructureAsync(const std::string& projectId) override;
|
||||
NavRequest* loadRowsAsync(const std::string& projectId, const std::string& parentId,
|
||||
int parentConfType, int classifyType, int pageNo,
|
||||
int pageSize = 5) override;
|
||||
NavRequest* loadObjectDetailAsync(const std::string& objectId, int confType) override;
|
||||
NavRequest* loadDatasetFormAsync(const std::string& dsObjectId) override;
|
||||
NavRequest* loadExceptionsByTmAsync(const std::string& tmObjectId) override;
|
||||
|
||||
private:
|
||||
net::ApiClient& api_;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
#include "api/DatasetLoadHandles.hpp"
|
||||
#include <stdexcept>
|
||||
|
||||
namespace geopro::data {
|
||||
|
||||
namespace {
|
||||
QString reasonOf(const geopro::net::ApiResponse& r) {
|
||||
return r.msg.isEmpty() ? r.rawError : r.msg;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
ApiDetailLoad::ApiDetailLoad(geopro::net::ApiBatch* batch, Parser parse, QObject* parent)
|
||||
: DetailLoad(parent), batch_(batch), parse_(std::move(parse)) {
|
||||
QObject::connect(batch, &geopro::net::ApiBatch::succeeded, this,
|
||||
[this](const QList<geopro::net::ApiResponse>& resps) {
|
||||
if (aborted_) return; // §5.0
|
||||
QVariant payload;
|
||||
try {
|
||||
payload = parse_(resps); // 仅解析在 try 内:下游 done 处理器抛出不应误报为解析失败
|
||||
} catch (const std::exception& e) {
|
||||
emit failed(QString::fromUtf8(e.what()));
|
||||
deleteLater();
|
||||
return;
|
||||
} catch (...) { // 非 std 异常跨信号槽会 terminate,兜底转 failed
|
||||
emit failed(QStringLiteral("解析失败:未知异常"));
|
||||
deleteLater();
|
||||
return;
|
||||
}
|
||||
emit done(payload);
|
||||
deleteLater();
|
||||
});
|
||||
QObject::connect(batch, &geopro::net::ApiBatch::failed, this,
|
||||
[this](int, const geopro::net::ApiResponse& r) {
|
||||
if (aborted_) return;
|
||||
emit failed(reasonOf(r));
|
||||
deleteLater();
|
||||
});
|
||||
}
|
||||
|
||||
void ApiDetailLoad::abort() {
|
||||
if (aborted_) return;
|
||||
aborted_ = true;
|
||||
if (batch_) batch_->abort();
|
||||
deleteLater();
|
||||
}
|
||||
|
||||
} // namespace geopro::data
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
#pragma once
|
||||
#include <functional>
|
||||
#include <QList>
|
||||
#include <QObject>
|
||||
#include <QPointer>
|
||||
#include <QString>
|
||||
#include <QVariant>
|
||||
#include "ApiBatch.hpp"
|
||||
|
||||
namespace geopro::data {
|
||||
|
||||
// ── 通用详情句柄(tab 引擎):载荷经 QVariant 类型擦除,单一 done(QVariant)。 ──
|
||||
// 可测试缝(类比 IApiCall):仓储返回基类指针,控制器/测试只依赖它。
|
||||
class DetailLoad : public QObject {
|
||||
Q_OBJECT
|
||||
public:
|
||||
using QObject::QObject;
|
||||
~DetailLoad() override = default;
|
||||
virtual void abort() = 0;
|
||||
signals:
|
||||
void done(const QVariant& payload);
|
||||
void failed(const QString& message);
|
||||
};
|
||||
|
||||
// Api 实现:包 ApiBatch + 注入解析器(返回 QVariant 载荷)。逻辑与 ApiChartLoad 等价。
|
||||
class ApiDetailLoad : public DetailLoad {
|
||||
Q_OBJECT
|
||||
public:
|
||||
using Parser = std::function<QVariant(const QList<geopro::net::ApiResponse>&)>;
|
||||
ApiDetailLoad(geopro::net::ApiBatch* batch, Parser parse, QObject* parent = nullptr);
|
||||
void abort() override;
|
||||
private:
|
||||
QPointer<geopro::net::ApiBatch> batch_;
|
||||
Parser parse_;
|
||||
bool aborted_ = false;
|
||||
};
|
||||
|
||||
} // namespace geopro::data
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
#pragma once
|
||||
#include <vector>
|
||||
#include <QMetaType>
|
||||
#include "repo/RepoTypes.hpp"
|
||||
|
||||
// 导航异步返回类型经 QVariant 承载:同线程直连仅需 Q_DECLARE_METATYPE(无需 qRegisterMetaType)。
|
||||
Q_DECLARE_METATYPE(std::vector<geopro::data::Workspace>)
|
||||
Q_DECLARE_METATYPE(geopro::data::ProjectListPage)
|
||||
Q_DECLARE_METATYPE(std::vector<geopro::data::ProjectType>)
|
||||
Q_DECLARE_METATYPE(std::vector<geopro::data::StructNode>)
|
||||
Q_DECLARE_METATYPE(geopro::data::DsPage)
|
||||
Q_DECLARE_METATYPE(geopro::data::DynamicForm)
|
||||
Q_DECLARE_METATYPE(std::vector<geopro::data::ExceptionRow>)
|
||||
// bool 已内置 QMetaType。
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
#include "api/NavRequest.hpp"
|
||||
#include <stdexcept>
|
||||
|
||||
namespace geopro::data {
|
||||
|
||||
namespace {
|
||||
QString reasonOf(const geopro::net::ApiResponse& r) {
|
||||
return r.msg.isEmpty() ? r.rawError : r.msg;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
ApiNavRequest::ApiNavRequest(geopro::net::IApiCall* call, Parser parse, Predicate isFailure,
|
||||
QObject* parent)
|
||||
: NavRequest(parent), call_(call), parse_(std::move(parse)), isFailure_(std::move(isFailure)) {
|
||||
QObject::connect(call, &geopro::net::IApiCall::finished, this,
|
||||
[this](const geopro::net::ApiResponse& resp) {
|
||||
if (aborted_) return; // §5.0 入口守卫
|
||||
if (isFailure_(resp)) {
|
||||
emit failed(reasonOf(resp));
|
||||
deleteLater();
|
||||
return;
|
||||
}
|
||||
QVariant out;
|
||||
try {
|
||||
out = parse_(resp); // 仅解析在 try 内(下游 done 处理器抛出不误报)
|
||||
} catch (const std::exception& e) {
|
||||
emit failed(QString::fromUtf8(e.what()));
|
||||
deleteLater();
|
||||
return;
|
||||
} catch (...) {
|
||||
emit failed(QStringLiteral("解析失败:未知异常"));
|
||||
deleteLater();
|
||||
return;
|
||||
}
|
||||
emit done(out);
|
||||
deleteLater();
|
||||
});
|
||||
}
|
||||
|
||||
void ApiNavRequest::abort() {
|
||||
if (aborted_) return;
|
||||
aborted_ = true;
|
||||
if (call_) call_->abort();
|
||||
deleteLater();
|
||||
}
|
||||
|
||||
} // namespace geopro::data
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
#pragma once
|
||||
#include <functional>
|
||||
#include <QObject>
|
||||
#include <QPointer>
|
||||
#include <QString>
|
||||
#include <QVariant>
|
||||
#include "IApiCall.hpp"
|
||||
|
||||
namespace geopro::data {
|
||||
|
||||
// 单请求异步句柄(抽象基,可测试缝):payload 经 QVariant 承载,控制器侧 qvariant_cast<T> 取出。
|
||||
class NavRequest : public QObject {
|
||||
Q_OBJECT
|
||||
public:
|
||||
using QObject::QObject;
|
||||
~NavRequest() override = default;
|
||||
virtual void abort() = 0;
|
||||
signals:
|
||||
void done(const QVariant& value);
|
||||
void failed(const QString& message);
|
||||
};
|
||||
|
||||
// Api 实现:包一个 IApiCall + 注入的解析器(ApiResponse → QVariant)+ 失败谓词。
|
||||
class ApiNavRequest : public NavRequest {
|
||||
Q_OBJECT
|
||||
public:
|
||||
using Parser = std::function<QVariant(const geopro::net::ApiResponse&)>;
|
||||
using Predicate = std::function<bool(const geopro::net::ApiResponse&)>;
|
||||
ApiNavRequest(geopro::net::IApiCall* call, Parser parse, Predicate isFailure,
|
||||
QObject* parent = nullptr); // 持有非拥有引用(QPointer);call 完成(finished)或 abort 后自行 deleteLater 自管理生命周期,本类不得 delete 它
|
||||
void abort() override;
|
||||
private:
|
||||
QPointer<geopro::net::IApiCall> call_;
|
||||
Parser parse_;
|
||||
Predicate isFailure_;
|
||||
bool aborted_ = false;
|
||||
};
|
||||
|
||||
} // namespace geopro::data
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue