From a00aeb9a56dcbe535c3a1fcfb52be2aec2a7b426 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Sat, 13 Jun 2026 10:51:24 +0800 Subject: [PATCH] =?UTF-8?q?feat(dataset-detail):=20=E6=8C=89=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B=E6=B8=B2=E6=9F=93=E5=BC=95=E6=93=8E=20+=20inversion?= =?UTF-8?q?=20=E8=BF=81=E7=A7=BB=20+=20dd=5Fert=5Fmeasurement=5Fdata=20?= =?UTF-8?q?=E8=AF=A6=E6=83=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将数据集详情从硬编码 ERT 反演重构为通用「ddCode→页签→视图kind」渲染引擎, 把已验收的 dd_inversion_data 迁入新引擎(渲染零变化),并在引擎上实现 dd_ert_measurement_data(ERT原始数据)详情。 引擎: - 策略声明页签:IDatasetChartStrategy::tabs()→vector 取代 hasGridPhase();控制器通用化(datasetOpened/loadTab/ tabReady),按页签槽位保留 abort-and-replace + 句柄身份比对 + deleteLater 三件套 - 单一异步句柄 DetailLoad/ApiDetailLoad(QVariant 载荷),仓储 loadAsync(loaderKey, dsId) 扁平分派;删 ChartLoad/GridLoad/loadChartAsync/loadGridAsync/DatasetLoads.hpp - 视图 IDetailView{widget,setPayload(QVariant)} + DetailViewFactory 按 ViewKind 实例化; 载荷 Scatter/Contour/TablePayload(core/model/detail/DetailPayloads.hpp) - Page 按 TabSpec 建页签 + 通用 lazy;inversion 散点/网格迁入,渲染代码未改 dd_ert_measurement_data(③散点伪剖面 + 列表): - 散点复用 RawDataChartView/ScatterPlotItem:x=斜距/y=伪深度(负向)/色=视电阻率, 连续 cauto(含负离群值),colorBar 取自 colorGradation businessCode=R0&type=3(混合 hex+rgba/AlphaScale::Unit);电极灰菱形(#BEBEBE实心+白描边 size16);A/B/M/N 与电极 浮动 hover;数据点尺寸对齐原版 12px;色阶底部横向(与反演一致) - 通用 DataTableView(gridHeaderDisplay/filedList+rowList,均衡等宽列+居中+隐藏/显示 开关列);MeasurementDto 解析(散点位置数组 + vmap 摊平) - 工具条视觉 1:1(信息/框选/显示/隐藏/数据过滤/x·y·v·计算方法下拉/色阶配置/生成视电阻 率/反演运算/另存为);显示隐藏+x/y 下拉轻交互,其余占位 测试 75→138 全绿。Phase0 真实响应夹具 tests/fixtures/dd/*。 --- .../plans/2026-06-12-detail-render-engine.md | 114 +++ src/app/CMakeLists.txt | 2 + src/app/main.cpp | 44 +- src/app/panels/DatasetDetailPage.cpp | 81 +- src/app/panels/DatasetDetailPage.hpp | 48 +- src/app/panels/DatasetDetailPanel.cpp | 29 +- src/app/panels/DatasetDetailPanel.hpp | 22 +- src/app/panels/chart/ColorBarWidget.cpp | 74 +- src/app/panels/chart/ColorBarWidget.hpp | 14 +- src/app/panels/chart/DataTableView.cpp | 151 ++++ src/app/panels/chart/DataTableView.hpp | 60 ++ src/app/panels/chart/DetailViewFactory.cpp | 28 + src/app/panels/chart/DetailViewFactory.hpp | 16 + src/app/panels/chart/ErtInversionStrategy.hpp | 12 +- src/app/panels/chart/GridDataChartView.cpp | 11 +- src/app/panels/chart/GridDataChartView.hpp | 14 +- src/app/panels/chart/IDetailView.hpp | 17 + src/app/panels/chart/MeasurementStrategy.hpp | 18 + src/app/panels/chart/RawDataChartView.cpp | 323 ++++++- src/app/panels/chart/RawDataChartView.hpp | 34 +- src/app/panels/chart/ScatterHoverTip.cpp | 50 +- src/app/panels/chart/ScatterHoverTip.hpp | 26 + src/app/panels/chart/ScatterPlotItem.cpp | 30 +- src/app/panels/chart/ScatterPlotItem.hpp | 14 +- src/controller/DatasetDetailController.cpp | 97 +- src/controller/DatasetDetailController.hpp | 49 +- src/controller/DatasetDetailTab.hpp | 24 + src/controller/IDatasetChartStrategy.hpp | 8 +- src/core/model/Field.hpp | 6 + src/core/model/detail/DetailPayloads.hpp | 74 ++ src/data/CMakeLists.txt | 1 + src/data/api/ApiDatasetRepository.cpp | 129 ++- src/data/api/ApiDatasetRepository.hpp | 7 +- src/data/api/DatasetLoadHandles.cpp | 47 +- src/data/api/DatasetLoadHandles.hpp | 42 +- src/data/api/DatasetLoads.hpp | 21 - src/data/dto/MeasurementDto.cpp | 162 ++++ src/data/dto/MeasurementDto.hpp | 20 + src/data/repo/IAsyncDatasetRepository.hpp | 7 +- tests/CMakeLists.txt | 5 +- tests/app/test_chart_strategy_registry.cpp | 36 +- tests/app/test_scatter_hover.cpp | 15 + .../test_dataset_detail_controller.cpp | 175 ++-- tests/data/test_async_repo_dispatch.cpp | 79 ++ tests/data/test_dataset_load_handles.cpp | 50 +- tests/data/test_measurement_dto.cpp | 254 ++++++ .../fixtures/dd/ert-measurement-colorbar.json | 39 + tests/fixtures/dd/ert-measurement-rows.json | 575 ++++++++++++ .../fixtures/dd/ert-measurement-scatter.json | 830 ++++++++++++++++++ 49 files changed, 3520 insertions(+), 464 deletions(-) create mode 100644 docs/superpowers/plans/2026-06-12-detail-render-engine.md create mode 100644 src/app/panels/chart/DataTableView.cpp create mode 100644 src/app/panels/chart/DataTableView.hpp create mode 100644 src/app/panels/chart/DetailViewFactory.cpp create mode 100644 src/app/panels/chart/DetailViewFactory.hpp create mode 100644 src/app/panels/chart/IDetailView.hpp create mode 100644 src/app/panels/chart/MeasurementStrategy.hpp create mode 100644 src/controller/DatasetDetailTab.hpp create mode 100644 src/core/model/detail/DetailPayloads.hpp delete mode 100644 src/data/api/DatasetLoads.hpp create mode 100644 src/data/dto/MeasurementDto.cpp create mode 100644 src/data/dto/MeasurementDto.hpp create mode 100644 tests/data/test_async_repo_dispatch.cpp create mode 100644 tests/data/test_measurement_dto.cpp create mode 100644 tests/fixtures/dd/ert-measurement-colorbar.json create mode 100644 tests/fixtures/dd/ert-measurement-rows.json create mode 100644 tests/fixtures/dd/ert-measurement-scatter.json diff --git a/docs/superpowers/plans/2026-06-12-detail-render-engine.md b/docs/superpowers/plans/2026-06-12-detail-render-engine.md new file mode 100644 index 0000000..5cceb30 --- /dev/null +++ b/docs/superpowers/plans/2026-06-12-detail-render-engine.md @@ -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 `。 +同一 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 anomalies; }`(≈现 `GridParts`) +- `TablePayload { std::vector columns; std::vector> 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 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&)>`)。**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), 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>`;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:"", 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=.*`。 diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index 1ef6c5a..4f09f65 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -29,6 +29,8 @@ add_executable(geopro_desktop WIN32 panels/DescriptionPanel.cpp panels/chart/RawDataChartView.cpp panels/chart/GridDataChartView.cpp + panels/chart/DataTableView.cpp + panels/chart/DetailViewFactory.cpp panels/chart/ChartTheme.cpp panels/chart/ColorMapService.cpp panels/chart/ColorBarWidget.cpp diff --git a/src/app/main.cpp b/src/app/main.cpp index d22883c..2b09d7f 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -85,6 +85,7 @@ #include "WorkbenchNavController.hpp" #include "DatasetDetailController.hpp" #include "panels/chart/ErtInversionStrategy.hpp" +#include "panels/chart/MeasurementStrategy.hpp" #include "api/ApiProjectRepository.hpp" #include "api/ApiDatasetRepository.hpp" #include "panels/ObjectTreePanel.hpp" @@ -514,37 +515,23 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re if (!dsId.isEmpty()) detailCtrl.openDataset(dsId, ddCode, dsName); }); - // ── 控制器信号 → 详情面板:数据就绪开页 / 聚焦请求 ── - QObject::connect( - &detailCtrl, &geopro::controller::DatasetDetailController::chartReady, detailPanel, - [detailPanel](const geopro::controller::DatasetDetailController::ChartData& d) { - detailPanel->openOrUpdate(d); - }); + // ── 控制器信号 → 详情面板(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, [detailPanel](const QString& dsId) { - detailPanel->focusDataset(dsId); - }); - // ── 网格数据懒加载:网格页首次激活 → 拉 rows+色阶type2+异常 → 回填对应页 ── - QObject::connect(detailPanel, &geopro::app::DatasetDetailPanel::gridDataNeeded, &detailCtrl, - &geopro::controller::DatasetDetailController::loadGridData); - QObject::connect( - &detailCtrl, &geopro::controller::DatasetDetailController::gridReady, detailPanel, - [detailPanel](const geopro::controller::DatasetDetailController::GridData& d) { - detailPanel->setGridData(d); - }); - QObject::connect( - &detailCtrl, &geopro::controller::DatasetDetailController::loadStarted, detailPanel, - [detailPanel](const QString& dsId, - geopro::controller::DatasetDetailController::LoadPhase phase) { - if (phase == geopro::controller::DatasetDetailController::LoadPhase::Grid) - detailPanel->setGridLoading(dsId, true); - }); - // context 用 detailPanel(与 loadStarted 一致):detailPanel 析构即自动断连,避免野指针。 - // window 比 detailPanel 活得久,lambda 捕 &window 取状态栏安全。loadFailed 无 phase: - // Chart 失败时 gridOverlay 本已隐藏,setGridLoading(false) 幂等无害;Grid 失败时正确清遮罩。 + detailPanel, &geopro::app::DatasetDetailPanel::focusDataset); + // ── 页签懒加载:lazy 页签首次激活 → 控制器按 (dsId,ddCode,tabIndex) 拉载荷 → 回填 ── + QObject::connect(detailPanel, &geopro::app::DatasetDetailPanel::tabNeeded, &detailCtrl, + &geopro::controller::DatasetDetailController::loadTab); + // context 用 detailPanel:析构即自动断连,避免野指针。window 比 detailPanel 活得久, + // 捕 &window 取状态栏安全。失败时清该页 lazy 遮罩(幂等)并状态栏提示。 QObject::connect(&detailCtrl, &geopro::controller::DatasetDetailController::loadFailed, detailPanel, [&window, detailPanel](const QString& dsId, const QString& msg) { - detailPanel->setGridLoading(dsId, false); + detailPanel->onLoadFailed(dsId, msg); window.statusBar()->showMessage( QStringLiteral("数据集 %1 加载失败:%2").arg(dsId, msg), 5000); }); @@ -924,6 +911,7 @@ int main(int argc, char* argv[]) // 策略注册表须比 detailCtrl 活得久(同作用域、在前声明)。 geopro::controller::ChartStrategyRegistry chartRegistry; chartRegistry.add(std::make_unique()); + chartRegistry.add(std::make_unique()); geopro::controller::DatasetDetailController detailCtrl(datasetRepo, chartRegistry); // ── 外壳:标准 QMainWindow(原生标题栏)。buildWorkbench 直接用其 diff --git a/src/app/panels/DatasetDetailPage.cpp b/src/app/panels/DatasetDetailPage.cpp index 7b5f47f..aacae48 100644 --- a/src/app/panels/DatasetDetailPage.cpp +++ b/src/app/panels/DatasetDetailPage.cpp @@ -6,63 +6,78 @@ #include "Glyphs.hpp" #include "PanelHeader.hpp" #include "panels/LoadingOverlay.hpp" -#include "panels/chart/GridDataChartView.hpp" -#include "panels/chart/RawDataChartView.hpp" +#include "panels/chart/DetailViewFactory.hpp" +#include "panels/chart/IDetailView.hpp" namespace geopro::app { -namespace { -constexpr int kGridTabIndex = 1; // 「网格数据」页签在 tabs 中的索引 -} - DatasetDetailPage::DatasetDetailPage(QWidget* parent) : QWidget(parent) { auto* lay = new QVBoxLayout(this); lay->setContentsMargins(0, 0, 0, 0); lay->setSpacing(0); +} - rawView_ = new RawDataChartView(this); - gridView_ = new GridDataChartView(this); - gridOverlay_ = new LoadingOverlay(gridView_); // 父为 gridView_,随其尺寸覆盖网格图区 +void DatasetDetailPage::build(const QString& dsId, const QString& ddCode, const QString& dsName, + const std::vector& 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(); - const QVector tabs = { - {Glyph::Detail, QStringLiteral("原数据"), rawView_, false}, - {Glyph::Dataset, QStringLiteral("网格数据"), gridView_, false}, - }; + // 按 TabSpec 经工厂造视图,并组装为带表头页签的面板。 + QVector 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(i)] = new LoadingOverlay(raw->widget()); + panelTabs.append({Glyph::Detail, spec.title, raw->widget(), false}); + } const QVector actions = { {Glyph::Download, QStringLiteral("导出")}, }; - auto tabbedPanel = buildTabbedPanel(tabs, actions); - lay->addWidget(tabbedPanel.container); + auto tabbedPanel = buildTabbedPanel(panelTabs, actions); + layout()->addWidget(tabbedPanel.container); - // 「网格数据」页签首次激活 → 懒加载网格数据(rows 服务端网格化慢,故不随开页同步拉)。 + // lazy 页签首次激活 → 发 tabNeeded 请求懒加载(数据慢,故不随开页同步拉)。 if (tabbedPanel.tabGroup) { connect(tabbedPanel.tabGroup, &QButtonGroup::idClicked, this, [this](int idx) { - if (idx != kGridTabIndex || gridRequested_ || dsId_.isEmpty()) return; - gridRequested_ = true; - emit gridDataNeeded(dsId_, ddCode_); + if (idx < 0 || idx >= static_cast(tabs_.size())) return; + if (!tabs_[static_cast(idx)].lazy) return; + if (requested_[static_cast(idx)] || loaded_[static_cast(idx)]) return; + if (dsId_.isEmpty()) return; + requested_[static_cast(idx)] = true; + emit tabNeeded(dsId_, ddCode_, idx); }); } } -void DatasetDetailPage::setData(const geopro::controller::DatasetDetailController::ChartData& d) { - dsId_ = d.dsId; - ddCode_ = d.ddCode; - gridRequested_ = false; // 新数据集 → 网格数据需重新按需加载 - rawView_->setData(d); - gridView_->setData(d); - setGridLoading(false); // 重开/换 ds:重置遮罩状态,避免上次的「加载中」残留 +void DatasetDetailPage::setTabPayload(int tabIndex, const QVariant& payload) { + if (tabIndex < 0 || tabIndex >= static_cast(views_.size())) return; + if (auto* v = views_[static_cast(tabIndex)]) v->setPayload(payload); + loaded_[static_cast(tabIndex)] = true; + requested_[static_cast(tabIndex)] = true; // 已加载,切回不再重复请求 + setTabLoading(tabIndex, false); // 数据到达,隐藏遮罩 } -void DatasetDetailPage::setGridData( - const geopro::controller::DatasetDetailController::GridData& d) { - gridRequested_ = true; // 已加载,切回网格页不再重复请求 - gridView_->setGridData(d.grid, d.gridScale, d.anomalies); - setGridLoading(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::setGridLoading(bool on) { - if (on) gridOverlay_->showOver(); else gridOverlay_->hide(); +void DatasetDetailPage::clearAllLoadingOverlays() { + for (auto* overlay : overlays_) + if (overlay) overlay->hide(); } } // namespace geopro::app diff --git a/src/app/panels/DatasetDetailPage.hpp b/src/app/panels/DatasetDetailPage.hpp index b408d06..323b90d 100644 --- a/src/app/panels/DatasetDetailPage.hpp +++ b/src/app/panels/DatasetDetailPage.hpp @@ -1,35 +1,51 @@ #pragma once +#include +#include +#include #include -#include "DatasetDetailController.hpp" +#include "DatasetDetailTab.hpp" // geopro::controller::TabSpec namespace geopro::app { -class RawDataChartView; -class GridDataChartView; +class IDetailView; class LoadingOverlay; -// 单个数据集详情页:下划线页签「原数据 / 网格数据」+ 右侧「导出」操作。 -// 内部分别由 RawDataChartView / GridDataChartView 实现各自三层布局。 +// 单个数据集详情页:按策略 tabs() 动态建页签 + 右侧「导出」操作。 +// 每页签由工厂造的 IDetailView 承载;lazy 页签首次激活时发 tabNeeded 请求懒加载。 class DatasetDetailPage : public QWidget { Q_OBJECT public: explicit DatasetDetailPage(QWidget* parent = nullptr); - void setData(const geopro::controller::DatasetDetailController::ChartData& d); - // 网格数据到达(懒加载结果)→ 下发给 GridDataChartView 并标记已加载。 - void setGridData(const geopro::controller::DatasetDetailController::GridData& d); - // 网格懒加载进行中(true)/结束(false)时切换遮罩显隐。 - void setGridLoading(bool on); + + // 按页签集构建页签(首次打开调一次)。dsId/ddCode/dsName 用于 tabNeeded。 + void build(const QString& dsId, const QString& ddCode, const QString& dsName, + const std::vector& 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(tabs_.size()); } + signals: - // 「网格数据」页签首次激活且本页网格数据未加载 → 请求懒加载。 - void gridDataNeeded(const QString& dsId, const QString& ddCode); + // lazy 页签首次激活且未加载 → 请求懒加载。 + void tabNeeded(const QString& dsId, const QString& ddCode, int tabIndex); + private: QString dsId_; QString ddCode_; - bool gridRequested_ = false; // 已请求过(避免重复发信号) - RawDataChartView* rawView_; - GridDataChartView* gridView_; - LoadingOverlay* gridOverlay_; // 网格懒加载期间覆盖 gridView_ 的遮罩 + QString dsName_; + std::vector tabs_; + // 与 tabs_ 同序。每个 IDetailView 持有的 QWidget 经 build() 以 this 为父接管, + // 生命周期由 Qt 父子树清理(不在此 delete);build() 仅调用一次(见其断言)。 + std::vector views_; + std::vector loaded_; // 各页签是否已加载(避免重复请求) + std::vector requested_; // lazy 页签是否已请求过 + QMap overlays_; // lazy 页签的加载遮罩(覆盖该视图) }; } // namespace geopro::app diff --git a/src/app/panels/DatasetDetailPanel.cpp b/src/app/panels/DatasetDetailPanel.cpp index 205b843..4567cb6 100644 --- a/src/app/panels/DatasetDetailPanel.cpp +++ b/src/app/panels/DatasetDetailPanel.cpp @@ -18,26 +18,35 @@ DatasetDetailPage* DatasetDetailPanel::pageFor(const QString& dsId) const { return nullptr; } -void DatasetDetailPanel::openOrUpdate(const geopro::controller::DatasetDetailController::ChartData& d) { - auto* p = pageFor(d.dsId); +void DatasetDetailPanel::onDatasetOpened(const QString& dsId, const QString& ddCode, + const QString& dsName, + const std::vector& tabs) { + auto* p = pageFor(dsId); if (!p) { p = new DatasetDetailPage(this); - const QString title = d.dsName.isEmpty() ? d.dsId : d.dsName; // 页签标题用数据名(空则回退 id) + 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); // 名称过长被省略时悬停可见全名 - // 页内「网格数据」页签首次激活 → 冒泡为面板信号(外部接控制器懒加载)。 - connect(p, &DatasetDetailPage::gridDataNeeded, this, &DatasetDetailPanel::gridDataNeeded); + // 页内 lazy 页签首次激活 → 冒泡为面板信号(外部接控制器 loadTab)。 + connect(p, &DatasetDetailPage::tabNeeded, this, &DatasetDetailPanel::tabNeeded); } - p->setData(d); setCurrentWidget(p); } -void DatasetDetailPanel::setGridData(const geopro::controller::DatasetDetailController::GridData& d) { - if (auto* p = pageFor(d.dsId)) p->setGridData(d); +void DatasetDetailPanel::onTabReady(const QString& dsId, int tabIndex, const QVariant& payload) { + if (auto* p = pageFor(dsId)) p->setTabPayload(tabIndex, payload); } -void DatasetDetailPanel::setGridLoading(const QString& dsId, bool on) { - if (auto* p = pageFor(dsId)) p->setGridLoading(on); + +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); } diff --git a/src/app/panels/DatasetDetailPanel.hpp b/src/app/panels/DatasetDetailPanel.hpp index 344c882..5fcf7e8 100644 --- a/src/app/panels/DatasetDetailPanel.hpp +++ b/src/app/panels/DatasetDetailPanel.hpp @@ -1,21 +1,29 @@ #pragma once +#include #include -#include "DatasetDetailController.hpp" +#include +#include "DatasetDetailTab.hpp" // geopro::controller::TabSpec namespace geopro::app { class DatasetDetailPage; -// 多 Tab 壳:每数据集一页(按 dsId 去重)。R095。 +// 多 Tab 壳:每数据集一页(按 dsId 去重)。R095。tab 引擎版。 class DatasetDetailPanel : public QTabWidget { Q_OBJECT public: explicit DatasetDetailPanel(QWidget* parent = nullptr); - void openOrUpdate(const geopro::controller::DatasetDetailController::ChartData& d); // 双击/数据到达 - void setGridData(const geopro::controller::DatasetDetailController::GridData& d); // 网格数据懒加载到达 - void setGridLoading(const QString& dsId, bool on); // 网格懒加载进行中/结束 → 转发给对应页 + + // 数据集打开:find-or-create 页 → build(tabs) → 加/抬该面板页签。 + void onDatasetOpened(const QString& dsId, const QString& ddCode, const QString& dsName, + const std::vector& 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 gridDataNeeded(const QString& dsId, const QString& ddCode); // 网格页首次激活 → 请求懒加载 + void activeDatasetChanged(const QString& dsId); // 反向联动数据集列表 + void tabNeeded(const QString& dsId, const QString& ddCode, int tabIndex); // lazy 页首激活 → 懒加载 + private: DatasetDetailPage* pageFor(const QString& dsId) const; }; diff --git a/src/app/panels/chart/ColorBarWidget.cpp b/src/app/panels/chart/ColorBarWidget.cpp index a637760..030fb04 100644 --- a/src/app/panels/chart/ColorBarWidget.cpp +++ b/src/app/panels/chart/ColorBarWidget.cpp @@ -9,10 +9,16 @@ 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) - : QWidget(parent) { - setFixedHeight(36); +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)); @@ -26,6 +32,14 @@ void ColorBarWidget::setColorScale(const core::ColorScale& scale) { 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);暗色=深底浅字,避免刺眼白条。色带格(数据色)两者不变。 @@ -79,4 +93,58 @@ void ColorBarWidget::paintEvent(QPaintEvent* /*event*/) { } } +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(stops.size()) - 1; + // segY(i): 第 i 个边界的 y(i=0 为最小值在底,i=nSeg 为最大值在顶)。 + auto segY = [&](int i) { + return barBottom - static_cast(static_cast(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 diff --git a/src/app/panels/chart/ColorBarWidget.hpp b/src/app/panels/chart/ColorBarWidget.hpp index b58d77f..b90c857 100644 --- a/src/app/panels/chart/ColorBarWidget.hpp +++ b/src/app/panels/chart/ColorBarWidget.hpp @@ -4,13 +4,17 @@ namespace geopro::app { -// 独立色阶条 Widget,水平排布:上方彩色色带 + 下方刻度值。 -// 作为 QwtPlot 的兄弟 widget 布局在图表下方,不进入 Qwt 坐标系, +// 独立色阶条 Widget,作为 QwtPlot 的兄弟 widget,不进入 Qwt 坐标系, // 因此不随图表缩放/平移移动,也不与轴标注重叠。 +// Horizontal(默认,反演原数据):底部横条,等宽色带 + 下方边界刻度值。 +// Vertical(measurement):右侧竖条,等高色带,最大值在顶、最小值在底, +// 边界值标在色带左侧(对齐原版 Plotly 离散图例)。 class ColorBarWidget : public QWidget { Q_OBJECT public: - explicit ColorBarWidget(QWidget* parent = nullptr); + enum class Orientation { Horizontal, Vertical }; + + explicit ColorBarWidget(QWidget* parent = nullptr, Orientation orient = Orientation::Horizontal); void setColorScale(const core::ColorScale& scale); @@ -18,7 +22,11 @@ protected: void paintEvent(QPaintEvent* event) override; private: + void paintHorizontal(QPainter& p); + void paintVertical(QPainter& p); + core::ColorScale scale_; + Orientation orient_; }; } // namespace geopro::app diff --git a/src/app/panels/chart/DataTableView.cpp b/src/app/panels/chart/DataTableView.cpp new file mode 100644 index 0000000..c6a9bcf --- /dev/null +++ b/src/app/panels/chart/DataTableView.cpp @@ -0,0 +1,151 @@ +#include "panels/chart/DataTableView.hpp" + +#include +#include +#include +#include + +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(payload_.rows.size()); +} + +int TablePayloadModel::columnCount(const QModelIndex& parent) const { + if (parent.isValid()) return 0; + return static_cast(payload_.columns.size()); +} + +QVariant TablePayloadModel::data(const QModelIndex& index, int role) const { + if (!index.isValid()) return {}; + const size_t r = static_cast(index.row()); + const size_t c = static_cast(index.column()); + if (r >= payload_.rows.size() || c >= payload_.rows[r].size()) return {}; + if (role == Qt::TextAlignmentRole) + return static_cast(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(payload_.columns.size())) return {}; + return payload_.columns[static_cast(section)].title; + } + return section + 1; // 行号 +} + +geopro::core::TableColumnKind TablePayloadModel::columnKind(int column) const { + if (column < 0 || column >= static_cast(payload_.columns.size())) + return geopro::core::TableColumnKind::Text; + return payload_.columns[static_cast(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_); +} + +void DataTableView::setPayload(const QVariant& payload) { + if (!payload.canConvert()) return; // 坏/空 → 空态 + const auto t = payload.value(); + 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(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); + } + } +} + +} // namespace geopro::app diff --git a/src/app/panels/chart/DataTableView.hpp b/src/app/panels/chart/DataTableView.hpp new file mode 100644 index 0000000..72cf498 --- /dev/null +++ b/src/app/panels/chart/DataTableView.hpp @@ -0,0 +1,60 @@ +#pragma once +#include +#include +#include +#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_; // 不拥有 +}; + +// 通用数据列表视图:IDetailView + QTableView。measurement/grid/trajectory 列表共用。 +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 → 保持空态不崩 + +private: + QTableView* table_; + TablePayloadModel* model_; +}; + +} // namespace geopro::app diff --git a/src/app/panels/chart/DetailViewFactory.cpp b/src/app/panels/chart/DetailViewFactory.cpp new file mode 100644 index 0000000..e5114af --- /dev/null +++ b/src/app/panels/chart/DetailViewFactory.cpp @@ -0,0 +1,28 @@ +#include "panels/chart/DetailViewFactory.hpp" + +#include + +#include "panels/chart/DataTableView.hpp" +#include "panels/chart/GridDataChartView.hpp" +#include "panels/chart/RawDataChartView.hpp" + +namespace geopro::app { + +std::unique_ptr makeDetailView(controller::ViewKind kind, QWidget* parent) { + switch (kind) { + case controller::ViewKind::Scatter: + return std::unique_ptr(new RawDataChartView(parent)); + case controller::ViewKind::FilledContour: + return std::unique_ptr(new GridDataChartView(parent)); + case controller::ViewKind::Table: + return std::unique_ptr(new DataTableView(parent)); + case controller::ViewKind::Bar: + case controller::ViewKind::LineProfile: + case controller::ViewKind::PolylineMap: + // 后续阶段补:Bar(gr_data)/LineProfile,PolylineMap(trajectory)。 + throw std::runtime_error("makeDetailView: ViewKind not yet implemented"); + } + throw std::runtime_error("makeDetailView: unknown ViewKind"); +} + +} // namespace geopro::app diff --git a/src/app/panels/chart/DetailViewFactory.hpp b/src/app/panels/chart/DetailViewFactory.hpp new file mode 100644 index 0000000..98464b0 --- /dev/null +++ b/src/app/panels/chart/DetailViewFactory.hpp @@ -0,0 +1,16 @@ +#pragma once +#include +#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 makeDetailView(controller::ViewKind kind, QWidget* parent); + +} // namespace geopro::app diff --git a/src/app/panels/chart/ErtInversionStrategy.hpp b/src/app/panels/chart/ErtInversionStrategy.hpp index 1fde3ae..62a5ffa 100644 --- a/src/app/panels/chart/ErtInversionStrategy.hpp +++ b/src/app/panels/chart/ErtInversionStrategy.hpp @@ -1,9 +1,17 @@ #pragma once +#include #include "IDatasetChartStrategy.hpp" // geopro::controller(geopro_controller PUBLIC include) namespace geopro::app { -// ERT 反演策略:散点(chart) + 网格等值面(grid) 两阶段。 +// ERT 反演策略:散点(原数据,同步) + 网格等值面(网格数据,懒加载) 两页签。 struct ErtInversionStrategy : controller::IDatasetChartStrategy { std::string ddCode() const override { return "dd_inversion_data"; } - bool hasGridPhase() const override { return true; } // 反演有网格数据阶段 + std::vector 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 diff --git a/src/app/panels/chart/GridDataChartView.cpp b/src/app/panels/chart/GridDataChartView.cpp index f3c16c2..d268977 100644 --- a/src/app/panels/chart/GridDataChartView.cpp +++ b/src/app/panels/chart/GridDataChartView.cpp @@ -177,10 +177,13 @@ GridDataChartView::~GridDataChartView() { delete colorSvc_; } -void GridDataChartView::setData(const geopro::controller::DatasetDetailController::ChartData& d) { - data_ = d; - // 开页:仅把 anomalies 喂给底部异常列表;图表区待网格数据懒加载后填充。 - anomalyTable_->setAnomalies(d.anomalies, {}, {}); +void GridDataChartView::setPayload(const QVariant& payload) { + if (!payload.canConvert()) { + // 坏/空 variant:保持空态(不渲染、不崩)。 + return; + } + const auto p = payload.value(); + setGridData(p.grid, p.scale, p.anomalies); } void GridDataChartView::setGridData(const geopro::core::Grid& grid, diff --git a/src/app/panels/chart/GridDataChartView.hpp b/src/app/panels/chart/GridDataChartView.hpp index cf0acf7..ab8c6d4 100644 --- a/src/app/panels/chart/GridDataChartView.hpp +++ b/src/app/panels/chart/GridDataChartView.hpp @@ -3,10 +3,11 @@ #include -#include "DatasetDetailController.hpp" #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; @@ -24,24 +25,23 @@ class ContourPlotItem; // 网格数据图表视图:工具条 + QwtPlot(白底 + 真实比尺 + 实时平移/滚轮缩放,x 轴在底部) // + 独立色阶条 + 底部双页签(异常列表/描述)。 // 填充走 ContourPlotItem 栅格热力图 + 矢量等值线 + 标注 + 异常叠加。 -class GridDataChartView : public QWidget { +class GridDataChartView : public QWidget, public IDetailView { Q_OBJECT public: explicit GridDataChartView(QWidget* parent = nullptr); ~GridDataChartView() override; - // 开页时调用:仅喂底部异常列表(网格数据随网格页激活懒加载)。 - void setData(const geopro::controller::DatasetDetailController::ChartData& d); - // 网格数据到达(懒加载结果):建色彩服务 + ContourPlotItem,挂到 QwtPlot,更新色阶条/异常表。 void setGridData(const geopro::core::Grid& grid, const geopro::core::ColorScale& gridScale, const std::vector& anoms); + // IDetailView:解包 ContourPayload(grid + 色阶 + 异常)→ setGridData。坏/空 variant 保持空态。 + QWidget* widget() override { return this; } + void setPayload(const QVariant& payload) override; + private: void rebuildContour(); // 按当前显隐开关重建并重绘 ContourPlotItem - geopro::controller::DatasetDetailController::ChartData data_; - QwtPlot* plot_ = nullptr; QwtPlotRescaler* rescaler_ = nullptr; ColorBarWidget* colorBar_ = nullptr; diff --git a/src/app/panels/chart/IDetailView.hpp b/src/app/panels/chart/IDetailView.hpp new file mode 100644 index 0000000..35782e8 --- /dev/null +++ b/src/app/panels/chart/IDetailView.hpp @@ -0,0 +1,17 @@ +#pragma once +#include + +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 diff --git a/src/app/panels/chart/MeasurementStrategy.hpp b/src/app/panels/chart/MeasurementStrategy.hpp new file mode 100644 index 0000000..0904fdb --- /dev/null +++ b/src/app/panels/chart/MeasurementStrategy.hpp @@ -0,0 +1,18 @@ +#pragma once +#include +#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 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 diff --git a/src/app/panels/chart/RawDataChartView.cpp b/src/app/panels/chart/RawDataChartView.cpp index 3748a86..7268f10 100644 --- a/src/app/panels/chart/RawDataChartView.cpp +++ b/src/app/panels/chart/RawDataChartView.cpp @@ -5,9 +5,17 @@ #include "panels/chart/ScatterPlotItem.hpp" #include +#include #include +#include #include +#include +#include +#include +#include +#include #include +#include #include #include @@ -29,9 +37,11 @@ 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); @@ -111,9 +121,21 @@ RawDataChartView::RawDataChartView(QWidget* parent) : QWidget(parent) { rescaler_->setAspectRatio(1.0); rescaler_->setEnabled(true); - lay->addWidget(plot_, 1); + // ---- 图表行: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); - // ---- 独立色阶条(固定高 36px,QwtPlot 的兄弟 widget)---- + 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_); @@ -133,27 +155,280 @@ QWidget* RawDataChartView::plotArea() const { return plot_; } -void RawDataChartView::setData( - const geopro::controller::DatasetDetailController::ChartData& d) { - data_ = d; +namespace { + +// 把一组 FieldOption 填进下拉,并按 defaultCode(或 defaultName 回退)选中默认项。 +void fillCombo(QComboBox* combo, const std::vector& 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 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::of(&QComboBox::currentIndexChanged), this, + [this](int) { replotForAxis(); }); + connect(yCombo_, QOverload::of(&QComboBox::currentIndexChanged), this, + [this](int) { replotForAxis(); }); + // v / method:换选项 → 暂未实现(需重新请求散点/色阶,属重交互,本轮不做)。 + connect(vCombo, QOverload::of(&QComboBox::activated), this, + [this, vCombo](int) { showNotImplemented(vCombo); }); + connect(methodCombo, QOverload::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()) { + // 坏/空 variant:保持空态(不渲染、不崩)。E2+ 可在此显式提示「渲染数据格式错误」。 + return; + } + setData(payload.value()); +} + +void RawDataChartView::setData(const geopro::core::ScatterPayload& p) { + data_ = p; if (hoverTip_) hoverTip_->setField(&data_.scatter); // 显式重绑(地址稳定,消除隐式依赖) - if (d.scatterScale.empty()) return; + // 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(d.scatterScale); + colorSvc_ = new ColorMapService(p.scale); - // 散点颜色归一化对齐原版 Plotly(cmin/cmax 未设 → cauto=数据 min/max): - // 按 vlist 有限值的 min/max 设数据范围,使整段色阶铺满数据实际范围(而非压进 colorBar 全程一小段)。 - double vMin = std::numeric_limits::max(); - double vMax = std::numeric_limits::lowest(); - for (double v : d.scatter.v) { - if (!std::isfinite(v)) continue; // 跳过 NaN/±Inf(脏数据),否则数据范围被污染→全图 NaN 取色 - if (v < vMin) vMin = v; - if (v > vMax) vMax = v; + // 散点颜色归一化对齐原版 Plotly(cmin/cmax 未设 → cauto=数据 min/max),按 vlist 有限值 + // 的 min/max 设数据范围,使整段色阶铺满数据实际范围。measurement 与反演原数据同此路径: + // v 含负异常值(如 -1066),故 cmin<0,中段视电阻率归一化到色阶中部(深品红/紫)。 + { + double vMin = std::numeric_limits::max(); + double vMax = std::numeric_limits::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); } - if (vMin <= vMax) colorSvc_->setDataRange(vMin, vMax); // 卸载旧散点项:QwtPlot 默认 autoDelete=true(析构时 delete 仍在 dict 的 item)。 // 必须先 detach()(从 dict 移除)再 delete,否则 QwtPlot 析构时会 double-free。 @@ -165,7 +440,7 @@ void RawDataChartView::setData( // 新建散点项并挂到 plot scatterItem_ = new ScatterPlotItem(); - scatterItem_->setData(d.scatter, colorSvc_); + scatterItem_->setData(p.scatter, colorSvc_); scatterItem_->attach(plot_); // 按数据包围盒设置轴范围 @@ -178,8 +453,16 @@ void RawDataChartView::setData( if (rescaler_) rescaler_->rescale(); // 应用真实比尺 plot_->replot(); - // 更新色阶条 - colorBar_->setColorScale(d.scatterScale); + // 更新色阶条: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 diff --git a/src/app/panels/chart/RawDataChartView.hpp b/src/app/panels/chart/RawDataChartView.hpp index befb465..38b2c51 100644 --- a/src/app/panels/chart/RawDataChartView.hpp +++ b/src/app/panels/chart/RawDataChartView.hpp @@ -1,9 +1,11 @@ #pragma once #include -#include "DatasetDetailController.hpp" +#include "model/detail/DetailPayloads.hpp" #include "panels/chart/ColorMapService.hpp" +#include "panels/chart/IDetailView.hpp" class QComboBox; +class QVBoxLayout; class QwtPlot; class QwtPlotRescaler; @@ -14,23 +16,43 @@ class ScatterPlotItem; class ScatterHoverTip; // 原数据图表视图:工具条 + QwtPlot(x 轴顶部、Panner/Magnifier)+ 独立色阶条。 -class RawDataChartView : public QWidget { +class RawDataChartView : public QWidget, public IDetailView { Q_OBJECT public: explicit RawDataChartView(QWidget* parent = nullptr); ~RawDataChartView() override; - void setData(const geopro::controller::DatasetDetailController::ChartData& d); + // 渲染散点载荷(解包后调内部渲染;渲染代码不变)。 + 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: - geopro::controller::DatasetDetailController::ChartData data_; + // 工具条按载荷类型二选一:反演原数据 = 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_; - QComboBox* chartTypeCombo_; + 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 重建 diff --git a/src/app/panels/chart/ScatterHoverTip.cpp b/src/app/panels/chart/ScatterHoverTip.cpp index f38280b..1150190 100644 --- a/src/app/panels/chart/ScatterHoverTip.cpp +++ b/src/app/panels/chart/ScatterHoverTip.cpp @@ -38,15 +38,44 @@ bool ScatterHoverTip::eventFilter(QObject* obj, QEvent* ev) { const auto& ys = field_->y; const auto& vs = field_->v; const std::size_t n = std::min(xs.size(), ys.size()); - if (n == 0) { - QToolTip::hideText(); - return false; - } // 在像素空间找最近散点(与 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::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(eno[eBestI]) + : static_cast(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::max(); std::size_t bestI = 0; for (std::size_t i = 0; i < n; ++i) { @@ -61,8 +90,17 @@ bool ScatterHoverTip::eventFilter(QObject* obj, QEvent* ev) { if (bestD2 <= kHitRadiusPx * kHitRadiusPx) { const double v = (bestI < vs.size()) ? vs[bestI] : 0.0; - QToolTip::showText(me->globalPosition().toPoint(), - scatterHoverText(xs[bestI], ys[bestI], v), plot_->canvas()); + // 有 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(); } diff --git a/src/app/panels/chart/ScatterHoverTip.hpp b/src/app/panels/chart/ScatterHoverTip.hpp index 7c83409..ac1916e 100644 --- a/src/app/panels/chart/ScatterHoverTip.hpp +++ b/src/app/panels/chart/ScatterHoverTip.hpp @@ -17,6 +17,31 @@ inline QString scatterHoverText(double x, double y, double v) { .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( + "X: %1
Y: %2
Value: %3" + "
a: %4
b: %5
m: %6
n: %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}
y: 0
num: {electrodeNo} +// x 用 6 位有效数字(对齐原版,如 5.123837),num 为电极编号(1-based 整数)。 +inline QString electrodeHoverText(double x, int num) { + return QStringLiteral("x: %1
y: 0
num: %2") + .arg(x, 0, 'g', 7) + .arg(num); +} + // 散点 hover 提示:监听画布鼠标移动(无按键时),找最近散点, // 命中半径内用 QToolTip 显示 X/Y/值(对齐原版 Plotly 悬浮)。 // 不消费事件,与 LivePanner(左键平移/滚轮缩放)共存。 @@ -38,6 +63,7 @@ private: const core::ScatterField* field_ = nullptr; static constexpr double kHitRadiusPx = 6.0; // 命中半径(像素),对齐方块 marker 尺寸 + static constexpr double kElectrodeHitRadiusPx = 7.0; // 电极菱形略大,命中半径稍大 }; } // namespace geopro::app diff --git a/src/app/panels/chart/ScatterPlotItem.cpp b/src/app/panels/chart/ScatterPlotItem.cpp index bfd8ed1..85a051a 100644 --- a/src/app/panels/chart/ScatterPlotItem.cpp +++ b/src/app/panels/chart/ScatterPlotItem.cpp @@ -1,8 +1,10 @@ #include "panels/chart/ScatterPlotItem.hpp" #include +#include #include #include #include +#include #include namespace geopro::app { @@ -30,6 +32,9 @@ void ScatterPlotItem::setData(const core::ScatterField& field, ColorMapService* 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; @@ -56,12 +61,35 @@ void ScatterPlotItem::draw(QPainter* painter, painter->save(); painter->setRenderHint(QPainter::Antialiasing, false); - painter->setPen(QPen(Qt::white, kPenWidth)); + // 电极位置: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, diff --git a/src/app/panels/chart/ScatterPlotItem.hpp b/src/app/panels/chart/ScatterPlotItem.hpp index 34342a9..3b39b32 100644 --- a/src/app/panels/chart/ScatterPlotItem.hpp +++ b/src/app/panels/chart/ScatterPlotItem.hpp @@ -11,13 +11,17 @@ namespace geopro::app { // QwtPlotItem:把 ScatterField 数据渲染为彩色方块散点。 // 每个点用固定像素边长方块绘制(不随缩放变大),白色描边, -// 颜色由 ColorMapService 连续插值决定(与原版 Plotly 一致)。 +// 颜色由 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; @@ -31,9 +35,15 @@ private: core::ScatterField field_; ColorMapService* colorSvc_ = nullptr; // 不拥有,由 RawDataChartView 持有 QRectF bounding_; + bool scatterVisible_ = true; // false:仅画电极,隐藏数据方块 - static constexpr double kHalfSide = 3.5; // 方块半边长(像素) + // 数据方块:原版 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 diff --git a/src/controller/DatasetDetailController.cpp b/src/controller/DatasetDetailController.cpp index c21d7a6..a496f17 100644 --- a/src/controller/DatasetDetailController.cpp +++ b/src/controller/DatasetDetailController.cpp @@ -1,4 +1,5 @@ #include "DatasetDetailController.hpp" +#include #include #include "repo/IAsyncDatasetRepository.hpp" #include "api/DatasetLoadHandles.hpp" @@ -6,70 +7,68 @@ namespace geopro::controller { DatasetDetailController::DatasetDetailController(data::IAsyncDatasetRepository& repo, ChartStrategyRegistry& registry, QObject* parent) - : QObject(parent), repo_(repo), registry_(registry) {} + : QObject(parent), repo_(repo), registry_(registry) { + // QSignalSpy / 队列连接对自定义类型需注册(页签集随 datasetOpened 传递)。 + qRegisterMetaType>("std::vector"); +} DatasetDetailController::~DatasetDetailController() { - if (chartLoad_) chartLoad_->abort(); // 退出契约:abort 在飞句柄,不依赖外部析构顺序兜底 - if (gridLoad_) gridLoad_->abort(); + // 退出契约: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)); - if (!registry_.supports(ddCode.toStdString())) { // 未注册策略 → 优雅降级 + auto* s = registry_.find(ddCode.toStdString()); + if (!s) { // 未注册策略 → 优雅降级 qWarning("[detail] 未注册策略 ddCode=%s → 降级提示", qUtf8Printable(ddCode)); emit loadFailed(dsId, QStringLiteral("暂不支持该数据类型的预览")); return; } - if (chartLoad_) chartLoad_->abort(); // abort-and-replace - data::ChartLoad* load = repo_.loadChartAsync(dsId.toStdString()); - chartLoad_ = load; - emit loadStarted(dsId, LoadPhase::Chart); - QObject::connect(load, &data::ChartLoad::done, this, - [this, load, dsId, ddCode, dsName](const data::ChartParts& parts) { - if (load != chartLoad_) return; // §5.0 句柄身份比对:丢弃迟到信号 - chartLoad_.clear(); - ChartData d; - d.dsId = dsId; - d.ddCode = ddCode; - d.dsName = dsName; - d.scatter = parts.scatter; - d.scatterScale = parts.scatterScale; - emit chartReady(d); - }); - QObject::connect(load, &data::ChartLoad::failed, this, - [this, load, dsId](const QString& msg) { - if (load != chartLoad_) return; - chartLoad_.clear(); - qWarning("[detail] 原数据加载失败 id=%s: %s", qUtf8Printable(dsId), qUtf8Printable(msg)); - emit loadFailed(dsId, msg); - }); + const std::vector tabs = s->tabs(); + emit datasetOpened(dsId, ddCode, dsName, tabs); + for (int i = 0; i < static_cast(tabs.size()); ++i) + if (!tabs[static_cast(i)].lazy) loadTab(dsId, ddCode, i); } -void DatasetDetailController::loadGridData(const QString& dsId, const QString& ddCode) { - auto* strategy = registry_.find(ddCode.toStdString()); - if (!strategy || !strategy->hasGridPhase()) return; // 仅有网格阶段的类型加载网格数据 - if (gridLoad_) gridLoad_->abort(); // abort-and-replace - data::GridLoad* load = repo_.loadGridAsync(dsId.toStdString()); - gridLoad_ = load; - emit loadStarted(dsId, LoadPhase::Grid); - QObject::connect(load, &data::GridLoad::done, this, - [this, load, dsId](const data::GridParts& parts) { - if (load != gridLoad_) return; // §5.0 句柄身份比对:丢弃迟到信号 - gridLoad_.clear(); - GridData d; - d.dsId = dsId; - d.grid = parts.grid; - d.gridScale = parts.gridScale; - d.anomalies = parts.anomalies; - emit gridReady(d); +void DatasetDetailController::loadTab(const QString& dsId, const QString& ddCode, int tabIndex) { + auto* s = registry_.find(ddCode.toStdString()); + if (!s) return; // 策略消失(不应发生):静默不加载 + const std::vector tabs = s->tabs(); + if (tabIndex < 0 || tabIndex >= static_cast(tabs.size())) return; + const controller::TabSpec& spec = tabs[static_cast(tabIndex)]; + + // loadAsync 对未知 loaderKey 抛 std::runtime_error;若逃逸槽函数会被 GuardedApplication + // 吞掉、遮罩永久悬挂(文档化的崩溃/挂起类)。就地兜底为 loadFailed,且不留半注册的在飞句柄。 + data::DetailLoad* load = nullptr; + try { + load = repo_.loadAsync(spec.loaderKey.toStdString(), dsId.toStdString()); + } 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::GridLoad::failed, this, - [this, load, dsId](const QString& msg) { - if (load != gridLoad_) return; - gridLoad_.clear(); - qWarning("[detail] 网格数据加载失败 id=%s: %s", qUtf8Printable(dsId), qUtf8Printable(msg)); + 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); }); } diff --git a/src/controller/DatasetDetailController.hpp b/src/controller/DatasetDetailController.hpp index 5487bf0..20b5260 100644 --- a/src/controller/DatasetDetailController.hpp +++ b/src/controller/DatasetDetailController.hpp @@ -1,55 +1,40 @@ #pragma once #include +#include +#include #include #include #include -#include "model/Field.hpp" -#include "model/ColorScale.hpp" -#include "model/Anomaly.hpp" +#include +#include "DatasetDetailTab.hpp" #include "IDatasetChartStrategy.hpp" -namespace geopro::data { class IAsyncDatasetRepository; class ChartLoad; class GridLoad; } +namespace geopro::data { class IAsyncDatasetRepository; class DetailLoad; } namespace geopro::controller { -// 数据详情编排:双击/网格页签 → 异步拉 散点/网格/色阶/异常 → 发信号给详情面板。被动视图。 +// 数据详情通用 tab 引擎编排:双击/页签激活 → 按 loaderKey 异步拉载荷(QVariant) → 发信号给详情面板。 +// 无 per-ddCode 分支:页签集由策略 tabs() 描述,载荷经 QVariant 类型擦除。被动视图。 class DatasetDetailController : public QObject { Q_OBJECT public: - enum class LoadPhase { Chart, Grid }; - Q_ENUM(LoadPhase) - - struct ChartData { - QString dsId, ddCode, dsName; // dsName:页签标题用(空则回退 dsId) - geopro::core::ScatterField scatter; - geopro::core::ColorScale scatterScale; - geopro::core::Grid grid{1, 1}; // Grid 无默认构造;以占位值初始化,openDataset 会覆盖 - geopro::core::ColorScale gridScale; - std::vector anomalies; - }; - // 网格数据(inversion/rows + 色阶 type2 + 异常):随「网格数据」页签首次激活按需懒加载。 - struct GridData { - QString dsId; - geopro::core::Grid grid{1, 1}; // Grid 无默认构造;占位值初始化,loadGridData 会覆盖 - geopro::core::ColorScale gridScale; - std::vector anomalies; - }; - DatasetDetailController(data::IAsyncDatasetRepository& repo, ChartStrategyRegistry& registry, QObject* parent = nullptr); - ~DatasetDetailController() override; // 退出契约(spec §7):abort 在飞句柄,避免迟到信号打到已析构 this + ~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 自动触发)。 + void loadTab(const QString& dsId, const QString& ddCode, int tabIndex); void focusDataset(const QString& dsId); - void loadGridData(const QString& dsId, const QString& ddCode); signals: - void loadStarted(const QString& dsId, LoadPhase phase); - void chartReady(const ChartData& data); - void gridReady(const GridData& data); - void focusRequested(const QString& dsId); + void datasetOpened(const QString& dsId, const QString& ddCode, const QString& dsName, + const std::vector& 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: data::IAsyncDatasetRepository& repo_; ChartStrategyRegistry& registry_; - QPointer chartLoad_; - QPointer gridLoad_; + QMap> inflight_; // 按页签槽位的在飞句柄(§5.0 身份比对) }; } // namespace geopro::controller diff --git a/src/controller/DatasetDetailTab.hpp b/src/controller/DatasetDetailTab.hpp new file mode 100644 index 0000000..d4b1676 --- /dev/null +++ b/src/controller/DatasetDetailTab.hpp @@ -0,0 +1,24 @@ +#pragma once +#include +#include +#include + +namespace geopro::controller { + +// 详情页签的渲染 kind 全集(Image/GISMap 待 GPR/radar 有活样本再加,YAGNI)。 +enum class ViewKind { Scatter, FilledContour, Bar, LineProfile, PolylineMap, Table }; + +// 页签描述符:策略声明每个 dd 类型的页签集(标题/kind/加载键/惰性/分页)。 +struct TabSpec { + QString title; + ViewKind kind; + QString loaderKey; + bool lazy = false; + bool paginated = false; +}; + +} // namespace geopro::controller + +// QSignalSpy 对 datasetOpened(..., std::vector) 需注册的元类型。 +Q_DECLARE_METATYPE(geopro::controller::TabSpec) +Q_DECLARE_METATYPE(std::vector) diff --git a/src/controller/IDatasetChartStrategy.hpp b/src/controller/IDatasetChartStrategy.hpp index 29c1452..4c7aa3c 100644 --- a/src/controller/IDatasetChartStrategy.hpp +++ b/src/controller/IDatasetChartStrategy.hpp @@ -2,15 +2,17 @@ #include #include #include +#include +#include "DatasetDetailTab.hpp" namespace geopro::controller { // dd 类型驱动的图表策略:决定某 ddCode 的详情页如何加载/渲染。 struct IDatasetChartStrategy { virtual ~IDatasetChartStrategy() = default; virtual std::string ddCode() const = 0; - // 该类型是否有「网格数据」加载阶段(ERT 反演=true;纯散点/折线/图像类=false)。 - // 控制器据此决定是否允许 loadGridData,替代硬编码 ddCode 判断。 - virtual bool hasGridPhase() const = 0; + // 该类型的页签集(标题/render kind/加载键/惰性/分页)。控制器据此建页签 + 驱动加载, + // 取代硬编码的 hasGridPhase()/ddCode 判断。 + virtual std::vector tabs() const = 0; }; class ChartStrategyRegistry { diff --git a/src/core/model/Field.hpp b/src/core/model/Field.hpp index 6e1d65f..95d7ca0 100644 --- a/src/core/model/Field.hpp +++ b/src/core/model/Field.hpp @@ -49,6 +49,12 @@ private: struct ScatterField { std::vector x, y, z, v; // 对应样本 xlist/ylist/(hlist)/vlist std::vector 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 a, b, m, n; + std::vector electrodeX; + std::vector electrodeNo; }; } // namespace geopro::core diff --git a/src/core/model/detail/DetailPayloads.hpp b/src/core/model/detail/DetailPayloads.hpp new file mode 100644 index 0000000..616c81b --- /dev/null +++ b/src/core/model/detail/DetailPayloads.hpp @@ -0,0 +1,74 @@ +#pragma once +#include +#include +#include +#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 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 altXHorizontal, altXSlope; // x 下拉:平距 / 斜距 + std::vector altYPseudo, altYElevationPseudo; // y 下拉:伪深度 / 伪深度+高程 +}; + +// 等值面载荷:grid(rows) + 色阶 + 异常(≈ data::GridParts)。 +// Grid 无默认构造,给占位初始化以满足 QVariant 对默认可构造的要求。 +struct ContourPayload { + geopro::core::Grid grid{1, 1}; + geopro::core::ColorScale scale; + std::vector 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)+ 总数(分页用)。 +struct TablePayload { + std::vector columns; + std::vector> rows; + int total = 0; +}; + +} // namespace geopro::core + +Q_DECLARE_METATYPE(geopro::core::ScatterPayload) +Q_DECLARE_METATYPE(geopro::core::ContourPayload) +Q_DECLARE_METATYPE(geopro::core::TablePayload) diff --git a/src/data/CMakeLists.txt b/src/data/CMakeLists.txt index 86f09c5..45594ec 100644 --- a/src/data/CMakeLists.txt +++ b/src/data/CMakeLists.txt @@ -5,6 +5,7 @@ add_library(geopro_data STATIC repo/LocalSampleRepository.cpp dto/NavDto.cpp dto/DatasetChartDto.cpp + dto/MeasurementDto.cpp api/ApiProjectRepository.cpp api/ApiDatasetRepository.cpp api/DatasetLoadHandles.cpp diff --git a/src/data/api/ApiDatasetRepository.cpp b/src/data/api/ApiDatasetRepository.cpp index ad9ee39..ba7e71d 100644 --- a/src/data/api/ApiDatasetRepository.cpp +++ b/src/data/api/ApiDatasetRepository.cpp @@ -1,11 +1,15 @@ #include "api/ApiDatasetRepository.hpp" +#include #include #include #include +#include #include "ApiClient.hpp" #include "ApiBatch.hpp" #include "api/DatasetLoadHandles.hpp" #include "dto/DatasetChartDto.hpp" +#include "dto/MeasurementDto.hpp" +#include "model/detail/DetailPayloads.hpp" namespace geopro::data { namespace { @@ -14,6 +18,19 @@ 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 anomalies; +}; + // 失败判定(原 must() 口径):业务码 != 200 或传输错误。 bool isFailure(const geopro::net::ApiResponse& r) { return r.code != 200 || !r.rawError.isEmpty(); @@ -23,39 +40,99 @@ 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 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& 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 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& 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 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 calls{ + api.getAsync(QStringLiteral("/business/dd/ert/measurement/rows?dsObjectId=%1").arg(enc(dsId))), + }; + return new net::ApiBatch(calls, &isFailure); +} + } // namespace ApiDatasetRepository::ApiDatasetRepository(net::ApiClient& api) : api_(api) {} -ChartLoad* ApiDatasetRepository::loadChartAsync(const std::string& dsId) { - // index 0 = scatter(GET),index 1 = 散点色阶 type1(POST) - QList calls{ - api_.getAsync(QStringLiteral("/business/dd/ert/inversion/getErtRawDataScatterGraph/%1").arg(enc(dsId))), - api_.postJsonAsync(QStringLiteral("/business/lvl/colorGradation/getDetail"), colorBody(dsId, 1)), - }; - auto* batch = new net::ApiBatch(calls, &isFailure); - return new ApiChartLoad(batch, [](const QList& r) { - ChartParts p; - p.scatter = dto::parseScatterGraph(r[0].data); - p.scatterScale = dto::parseColorBar(r[1].data); - return p; +DetailLoad* ApiDatasetRepository::loadAsync(const std::string& loaderKey, const std::string& dsId) { + 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); + throw std::runtime_error("unknown loaderKey: " + loaderKey); +} + +DetailLoad* ApiDatasetRepository::makeInversionScatter(const std::string& dsId) { + // 复用同一批次 + 解析器,再映射为 ScatterPayload(不复制 JSON 解析逻辑)。 + return new ApiDetailLoad(inversionScatterBatch(api_, dsId), [](const QList& r) { + ChartParts p = parseScatterParts(r); + return QVariant::fromValue(core::ScatterPayload{p.scatter, p.scatterScale}); }); } -GridLoad* ApiDatasetRepository::loadGridAsync(const std::string& dsId) { - // index 0 = rows(GET,慢),1 = 色阶 type2(POST),2 = 异常(GET) - QList 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))), - }; - auto* batch = new net::ApiBatch(calls, &isFailure); - return new ApiGridLoad(batch, [](const QList& 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; +DetailLoad* ApiDatasetRepository::makeInversionGrid(const std::string& dsId) { + return new ApiDetailLoad(inversionGridBatch(api_, dsId), [](const QList& 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& 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& r) { + return QVariant::fromValue(dto::parseMeasurementTable(r[0].data)); }); } diff --git a/src/data/api/ApiDatasetRepository.hpp b/src/data/api/ApiDatasetRepository.hpp index c341fca..cb76a91 100644 --- a/src/data/api/ApiDatasetRepository.hpp +++ b/src/data/api/ApiDatasetRepository.hpp @@ -7,9 +7,12 @@ namespace geopro::data { class ApiDatasetRepository : public IAsyncDatasetRepository { public: explicit ApiDatasetRepository(net::ApiClient& api); - ChartLoad* loadChartAsync(const std::string& dsId) override; - GridLoad* loadGridAsync(const std::string& dsId) override; + DetailLoad* loadAsync(const std::string& loaderKey, const std::string& dsId) 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); net::ApiClient& api_; }; } // namespace geopro::data diff --git a/src/data/api/DatasetLoadHandles.cpp b/src/data/api/DatasetLoadHandles.cpp index ba23173..f5c136d 100644 --- a/src/data/api/DatasetLoadHandles.cpp +++ b/src/data/api/DatasetLoadHandles.cpp @@ -9,14 +9,14 @@ QString reasonOf(const geopro::net::ApiResponse& r) { } } // namespace -ApiChartLoad::ApiChartLoad(geopro::net::ApiBatch* batch, Parser parse, QObject* parent) - : ChartLoad(parent), batch_(batch), parse_(std::move(parse)) { +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& resps) { if (aborted_) return; // §5.0 - ChartParts parts; + QVariant payload; try { - parts = parse_(resps); // 仅解析在 try 内:下游 done 处理器抛出不应误报为解析失败 + payload = parse_(resps); // 仅解析在 try 内:下游 done 处理器抛出不应误报为解析失败 } catch (const std::exception& e) { emit failed(QString::fromUtf8(e.what())); deleteLater(); @@ -26,7 +26,7 @@ ApiChartLoad::ApiChartLoad(geopro::net::ApiBatch* batch, Parser parse, QObject* deleteLater(); return; } - emit done(parts); + emit done(payload); deleteLater(); }); QObject::connect(batch, &geopro::net::ApiBatch::failed, this, @@ -37,42 +37,7 @@ ApiChartLoad::ApiChartLoad(geopro::net::ApiBatch* batch, Parser parse, QObject* }); } -void ApiChartLoad::abort() { - if (aborted_) return; - aborted_ = true; - if (batch_) batch_->abort(); - deleteLater(); -} - -ApiGridLoad::ApiGridLoad(geopro::net::ApiBatch* batch, Parser parse, QObject* parent) - : GridLoad(parent), batch_(batch), parse_(std::move(parse)) { - QObject::connect(batch, &geopro::net::ApiBatch::succeeded, this, - [this](const QList& resps) { - if (aborted_) return; - GridParts parts; - try { - parts = parse_(resps); // 仅解析在 try 内:下游 done 处理器抛出不应误报为解析失败 - } catch (const std::exception& e) { - emit failed(QString::fromUtf8(e.what())); - deleteLater(); - return; - } catch (...) { - emit failed(QStringLiteral("解析失败:未知异常")); - deleteLater(); - return; - } - emit done(parts); - 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 ApiGridLoad::abort() { +void ApiDetailLoad::abort() { if (aborted_) return; aborted_ = true; if (batch_) batch_->abort(); diff --git a/src/data/api/DatasetLoadHandles.hpp b/src/data/api/DatasetLoadHandles.hpp index cc65328..7e4d867 100644 --- a/src/data/api/DatasetLoadHandles.hpp +++ b/src/data/api/DatasetLoadHandles.hpp @@ -4,52 +4,30 @@ #include #include #include +#include #include "ApiBatch.hpp" -#include "DatasetLoads.hpp" namespace geopro::data { -// ── 抽象句柄(可测试缝,类比 IApiCall):仓储返回基类指针,控制器/测试只依赖它 ── -class ChartLoad : public QObject { +// ── 通用详情句柄(tab 引擎):载荷经 QVariant 类型擦除,单一 done(QVariant)。 ── +// 可测试缝(类比 IApiCall):仓储返回基类指针,控制器/测试只依赖它。 +class DetailLoad : public QObject { Q_OBJECT public: using QObject::QObject; - ~ChartLoad() override = default; + ~DetailLoad() override = default; virtual void abort() = 0; signals: - void done(const geopro::data::ChartParts& parts); + void done(const QVariant& payload); void failed(const QString& message); }; -class GridLoad : public QObject { +// Api 实现:包 ApiBatch + 注入解析器(返回 QVariant 载荷)。逻辑与 ApiChartLoad 等价。 +class ApiDetailLoad : public DetailLoad { Q_OBJECT public: - using QObject::QObject; - ~GridLoad() override = default; - virtual void abort() = 0; -signals: - void done(const geopro::data::GridParts& parts); - void failed(const QString& message); -}; - -// ── Api 实现:包一个 ApiBatch + 注入的解析函数。batch.succeeded→解析→done;failed→failed ── -class ApiChartLoad : public ChartLoad { - Q_OBJECT -public: - using Parser = std::function&)>; - ApiChartLoad(geopro::net::ApiBatch* batch, Parser parse, QObject* parent = nullptr); - void abort() override; -private: - QPointer batch_; - Parser parse_; - bool aborted_ = false; -}; - -class ApiGridLoad : public GridLoad { - Q_OBJECT -public: - using Parser = std::function&)>; - ApiGridLoad(geopro::net::ApiBatch* batch, Parser parse, QObject* parent = nullptr); + using Parser = std::function&)>; + ApiDetailLoad(geopro::net::ApiBatch* batch, Parser parse, QObject* parent = nullptr); void abort() override; private: QPointer batch_; diff --git a/src/data/api/DatasetLoads.hpp b/src/data/api/DatasetLoads.hpp deleted file mode 100644 index 1e303ae..0000000 --- a/src/data/api/DatasetLoads.hpp +++ /dev/null @@ -1,21 +0,0 @@ -#pragma once -#include -#include "model/Field.hpp" -#include "model/ColorScale.hpp" -#include "model/Anomaly.hpp" - -namespace geopro::data { - -// 原数据加载结果: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 anomalies; -}; - -} // namespace geopro::data diff --git a/src/data/dto/MeasurementDto.cpp b/src/data/dto/MeasurementDto.cpp new file mode 100644 index 0000000..d206c02 --- /dev/null +++ b/src/data/dto/MeasurementDto.cpp @@ -0,0 +1,162 @@ +#include "dto/MeasurementDto.hpp" + +#include +#include +#include "dto/DatasetChartDto.hpp" // parseColorBar + +namespace geopro::data::dto { +using namespace geopro::core; + +namespace { + +// 位置数组列序(rows[i] 为 17 列定位数组)。 +constexpr int kColHorizontalDistance = 0; // x 备选:平距 +constexpr int kColSlopeDistance = 1; // x:斜距(默认) +constexpr int kColPseudoDepth = 3; // y:伪深度(负,向下,默认) +constexpr int kColElevationPseudoDepth = 9; // y 备选:伪深度+高程 +constexpr int kColA = 10; +constexpr int kColB = 11; +constexpr int kColM = 12; +constexpr int kColN = 13; +constexpr int kColValue = 16; // 色值:选中 v(默认视电阻率 R0) + +double numAt(const QJsonArray& arr, int i) { + if (i < 0 || i >= arr.size()) return 0.0; + const QJsonValue v = arr.at(i); + if (v.isDouble()) return v.toDouble(); + if (v.isString()) { bool ok = false; double d = v.toString().toDouble(&ok); return ok ? d : 0.0; } + return 0.0; +} + +// 把 JSON 值预格式化为单元格 QString(数字按原值,null/缺省→空串)。 +QString cellText(const QJsonValue& v) { + if (v.isDouble()) { + const double d = v.toDouble(); + // 整数值不带小数点(与列表观感一致);非整数保留有效位。 + if (d == static_cast(static_cast(d))) return QString::number(static_cast(d)); + return QString::number(d, 'g', 10); + } + if (v.isString()) return v.toString(); + if (v.isBool()) return v.toBool() ? QStringLiteral("true") : QStringLiteral("false"); + return QString(); // null/undefined +} + +// scatterGraphConf 的某一组下拉项 → FieldOption 列表(保留服务端顺序)。 +std::vector parseOptions(const QJsonArray& arr) { + std::vector out; + out.reserve(arr.size()); + for (const auto& e : arr) { + const QJsonObject o = e.toObject(); + out.push_back({o.value(QStringLiteral("fieldCode")).toString(), + o.value(QStringLiteral("name")).toString()}); + } + return out; +} + +} // namespace + +ScatterPayload parseMeasurementScatter(const QJsonObject& scatterData, const QJsonObject& colorBarData) { + ScatterPayload p; + // 上色与反演原数据同路径:连续插值(cauto)。色值 col16 原样保留(含负异常值), + // RawDataChartView 据 v 有限值 min/max 设 setDataRange → cmin<0,与原版 Plotly 一致。 + // 图例与反演统一为底部横条(verticalLegend 保持默认 false)。 + + const QJsonArray rows = scatterData.value(QStringLiteral("scatterGraphData")) + .toObject() + .value(QStringLiteral("rows")) + .toArray(); + auto& s = p.scatter; + for (const auto& e : rows) { + const QJsonArray row = e.toArray(); + s.x.push_back(numAt(row, kColSlopeDistance)); + s.y.push_back(numAt(row, kColPseudoDepth)); + s.v.push_back(numAt(row, kColValue)); + s.a.push_back(numAt(row, kColA)); + s.b.push_back(numAt(row, kColB)); + s.m.push_back(numAt(row, kColM)); + s.n.push_back(numAt(row, kColN)); + // x/y 下拉本地重绘备选列(与上面 push 同序、一一对应 → 视图换 x/y 无需再请求)。 + p.altXHorizontal.push_back(numAt(row, kColHorizontalDistance)); + p.altXSlope.push_back(numAt(row, kColSlopeDistance)); + p.altYPseudo.push_back(numAt(row, kColPseudoDepth)); + p.altYElevationPseudo.push_back(numAt(row, kColElevationPseudoDepth)); + } + + // 工具条下拉配置(scatterGraphConf):驱动 measurement 工具条 x/y/v/method 下拉与默认选中。 + // x/y/method 默认取首项(斜距 / 伪深度 / 线性);v 默认取“视电阻率”(R0),回退末-… 改为首项。 + const QJsonObject conf = scatterData.value(QStringLiteral("scatterGraphConf")).toObject(); + auto& tb = p.toolbar; + tb.x = parseOptions(conf.value(QStringLiteral("x")).toArray()); + tb.y = parseOptions(conf.value(QStringLiteral("y")).toArray()); + tb.v = parseOptions(conf.value(QStringLiteral("v")).toArray()); + tb.method = parseOptions(conf.value(QStringLiteral("vcalculationMethod")).toArray()); + // 默认选中:x=斜距(slopeDistance)、y=伪深度(pseudoDepth)、v=视电阻率(R0)、method=线性。 + tb.defaultX = QStringLiteral("slopeDistance"); + tb.defaultY = QStringLiteral("pseudoDepth"); + tb.defaultV = QStringLiteral("R0"); + if (!tb.method.empty()) tb.defaultMethod = tb.method.front().name; // 线性 + + // 电极沿测线位置(y=0 处灰菱形):取 slopeDistance 与 x(斜距)同量纲; + // electrodeNo 为电极编号(1-based),与 electrodeX 一一对应,供 hover 显示 num。 + for (const auto& e : scatterData.value(QStringLiteral("electrodeList")).toArray()) { + const QJsonObject eo = e.toObject(); + s.electrodeX.push_back(eo.value(QStringLiteral("slopeDistance")).toDouble()); + s.electrodeNo.push_back(eo.value(QStringLiteral("electrodeNo")).toDouble()); + } + + p.scale = parseColorBar(colorBarData); // 复用既有混合格式解析器(AlphaScale::Unit) + return p; +} + +TablePayload parseMeasurementTable(const QJsonObject& data) { + TablePayload t; + + // 列定义:优先 filedList,其次 gridHeaderDisplay(grid/trajectory 列表用同形状)。 + QJsonArray cols = data.value(QStringLiteral("filedList")).toArray(); + if (cols.isEmpty()) cols = data.value(QStringLiteral("gridHeaderDisplay")).toArray(); + for (const auto& e : cols) { + const QJsonObject c = e.toObject(); + TableColumn col; + col.code = c.value(QStringLiteral("fieldCode")).toString(); + col.title = c.value(QStringLiteral("name")).toString(); + if (col.title.isEmpty()) col.title = col.code; + t.columns.push_back(col); + } + + // 末尾追加“隐藏/显示”开关列(原版每行右侧蓝色药丸开关,全部 ON=可见)。 + // 仅 measurement 列表有此列(filedList 驱动时);grid/trajectory 走 gridHeaderDisplay 不追加。 + const bool hasToggleCol = !data.value(QStringLiteral("filedList")).toArray().isEmpty(); + if (hasToggleCol) { + TableColumn toggle; + toggle.code = QStringLiteral("displayStatus"); + toggle.title = QStringLiteral("隐藏/显示"); + toggle.kind = TableColumnKind::Toggle; + t.columns.push_back(toggle); + } + + // 行:每格按列码先查 vmap,再查行对象顶层。 + // Toggle 列:displayStatus==0 ⇒ ON/可见("1"),否则 OFF("0")。 + const QJsonArray rowList = data.value(QStringLiteral("rowList")).toArray(); + for (const auto& e : rowList) { + const QJsonObject obj = e.toObject(); + const QJsonObject vmap = obj.value(QStringLiteral("vmap")).toObject(); + std::vector cells; + cells.reserve(t.columns.size()); + for (const auto& col : t.columns) { + if (col.kind == TableColumnKind::Toggle) { + const int status = obj.value(QStringLiteral("displayStatus")).toInt(0); + cells.push_back(status == 0 ? QStringLiteral("1") : QStringLiteral("0")); + continue; + } + const QJsonValue v = vmap.contains(col.code) ? vmap.value(col.code) : obj.value(col.code); + cells.push_back(cellText(v)); + } + t.rows.push_back(std::move(cells)); + } + + // 总数(分页用):measurement 用 __rowListTotal,回退到本批行数。 + t.total = data.value(QStringLiteral("__rowListTotal")).toInt(static_cast(t.rows.size())); + return t; +} + +} // namespace geopro::data::dto diff --git a/src/data/dto/MeasurementDto.hpp b/src/data/dto/MeasurementDto.hpp new file mode 100644 index 0000000..3f9f468 --- /dev/null +++ b/src/data/dto/MeasurementDto.hpp @@ -0,0 +1,20 @@ +#pragma once +#include +#include "model/detail/DetailPayloads.hpp" + +namespace geopro::data::dto { + +// dd_ert_measurement_data 散点伪剖面: +// scatterData = scatter/graph 的 data{ electrodeList[], scatterGraphData{rows[位置数组]} } +// colorBarData = colorGradation/getDetail{type:3} 的 data{ properties.colorBar } +// 位置数组列序:[1]斜距(x) [3]伪深度(y,负向下) [10]a [11]b [12]m [13]n [16]视电阻率(色值)。 +// 上色与反演原数据同路径:连续插值(cauto),色值含负异常值不过滤;色阶复用 +// DatasetChartDto::parseColorBar。图例画在右侧竖条(verticalLegend=true)。 +geopro::core::ScatterPayload parseMeasurementScatter(const QJsonObject& scatterData, + const QJsonObject& colorBarData); + +// 通用列表:data{ filedList[列定义] | gridHeaderDisplay, rowList[键控对象,含 vmap] } → TablePayload。 +// 列码命中 vmap 时从 vmap 取值,否则从行对象顶层取;值预格式化为 QString。可复用于 grid/trajectory 列表。 +geopro::core::TablePayload parseMeasurementTable(const QJsonObject& data); + +} // namespace geopro::data::dto diff --git a/src/data/repo/IAsyncDatasetRepository.hpp b/src/data/repo/IAsyncDatasetRepository.hpp index 5103b03..1bf0629 100644 --- a/src/data/repo/IAsyncDatasetRepository.hpp +++ b/src/data/repo/IAsyncDatasetRepository.hpp @@ -3,15 +3,14 @@ namespace geopro::data { -class ChartLoad; -class GridLoad; +class DetailLoad; // 数据集详情异步仓储抽象。返回自管理句柄(完成/失败后 deleteLater)。 class IAsyncDatasetRepository { public: virtual ~IAsyncDatasetRepository() = default; - virtual ChartLoad* loadChartAsync(const std::string& dsId) = 0; // scatter + 散点色阶(type1) - virtual GridLoad* loadGridAsync(const std::string& dsId) = 0; // grid(rows) + 色阶(type2) + 异常 + // 通用页签加载(tab 引擎):按 loaderKey 分派,载荷经 QVariant 类型擦除。 + virtual DetailLoad* loadAsync(const std::string& loaderKey, const std::string& dsId) = 0; }; } // namespace geopro::data diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 25f26f3..b5e9669 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -38,7 +38,10 @@ target_sources(geopro_tests PRIVATE data/test_parsers.cpp) target_sources(geopro_tests PRIVATE data/test_local_repo.cpp) target_sources(geopro_tests PRIVATE data/test_nav_dto.cpp) target_sources(geopro_tests PRIVATE data/test_dataset_chart_dto.cpp) +target_sources(geopro_tests PRIVATE data/test_measurement_dto.cpp) target_sources(geopro_tests PRIVATE data/test_dataset_load_handles.cpp) +# 通用仓储分派离线单测(loadAsync 分派 + QVariant payload round-trip)。 +target_sources(geopro_tests PRIVATE data/test_async_repo_dispatch.cpp) # NavRequest 离线单测(QVariant payload: done/failed/abort 闸门)。 target_sources(geopro_tests PRIVATE data/test_nav_request.cpp) target_link_libraries(geopro_tests PRIVATE geopro_data) @@ -107,7 +110,7 @@ target_sources(geopro_tests PRIVATE # 散点 hover 文本格式(inline 纯函数,无需链 qwt/实例化 ScatterHoverTip)。 target_sources(geopro_tests PRIVATE app/test_scatter_hover.cpp) -# controller 层:DatasetDetailController 编排集成测试(QSignalSpy 验证 chartReady/loadFailed)。 +# controller 层:DatasetDetailController 编排集成测试(QSignalSpy 验证 datasetOpened/tabReady/loadFailed)。 find_package(Qt6 COMPONENTS Test REQUIRED) target_sources(geopro_tests PRIVATE controller/test_dataset_detail_controller.cpp) target_sources(geopro_tests PRIVATE controller/test_workbench_nav_controller.cpp) diff --git a/tests/app/test_chart_strategy_registry.cpp b/tests/app/test_chart_strategy_registry.cpp index d54c7ba..0de0772 100644 --- a/tests/app/test_chart_strategy_registry.cpp +++ b/tests/app/test_chart_strategy_registry.cpp @@ -1,10 +1,19 @@ #include +#include "DatasetDetailTab.hpp" #include "IDatasetChartStrategy.hpp" // geopro::controller(控制器层) +#include "panels/chart/MeasurementStrategy.hpp" using namespace geopro::controller; namespace { struct Fake : IDatasetChartStrategy { std::string ddCode() const override { return "dd_inversion_data"; } - bool hasGridPhase() const override { return true; } + std::vector tabs() const override { + return { + {QStringLiteral("原数据"), ViewKind::Scatter, + QStringLiteral("inversion.scatter"), /*lazy*/ false, /*paginated*/ false}, + {QStringLiteral("网格数据"), ViewKind::FilledContour, + QStringLiteral("inversion.grid"), /*lazy*/ true, /*paginated*/ false}, + }; + } }; } TEST(ChartStrategyRegistry, FindsRegisteredAndDegradesUnknown) { @@ -15,10 +24,31 @@ TEST(ChartStrategyRegistry, FindsRegisteredAndDegradesUnknown) { EXPECT_FALSE(reg.supports("dd_unknown")); EXPECT_EQ(reg.find("dd_unknown"), nullptr); } -TEST(ChartStrategyRegistry, ReportsHasGridPhase) { +TEST(ChartStrategyRegistry, ExposesTabSpecsFromStrategy) { ChartStrategyRegistry reg; reg.add(std::make_unique()); auto* s = reg.find("dd_inversion_data"); ASSERT_NE(s, nullptr); - EXPECT_TRUE(s->hasGridPhase()); + const auto tabs = s->tabs(); + ASSERT_EQ(tabs.size(), 2u); + EXPECT_FALSE(tabs[0].lazy); // 原数据非 lazy + EXPECT_TRUE(tabs[1].lazy); // 网格数据 lazy + EXPECT_EQ(tabs[1].kind, ViewKind::FilledContour); +} + +TEST(MeasurementStrategy, DrivesTwoTabsScatterAndTable) { + geopro::app::MeasurementStrategy s; + EXPECT_EQ(s.ddCode(), "dd_ert_measurement_data"); + const auto tabs = s.tabs(); + ASSERT_EQ(tabs.size(), 2u); + // 散点图:Scatter,非 lazy。 + EXPECT_EQ(tabs[0].title.toStdString(), std::string("散点图")); + EXPECT_EQ(tabs[0].kind, ViewKind::Scatter); + EXPECT_FALSE(tabs[0].lazy); + EXPECT_EQ(tabs[0].loaderKey.toStdString(), "ert_measurement.scatter"); + // 数据列表:Table,lazy。 + EXPECT_EQ(tabs[1].title.toStdString(), std::string("数据列表")); + EXPECT_EQ(tabs[1].kind, ViewKind::Table); + EXPECT_TRUE(tabs[1].lazy); + EXPECT_EQ(tabs[1].loaderKey.toStdString(), "ert_measurement.rows"); } diff --git a/tests/app/test_scatter_hover.cpp b/tests/app/test_scatter_hover.cpp index e640929..bff1fa9 100644 --- a/tests/app/test_scatter_hover.cpp +++ b/tests/app/test_scatter_hover.cpp @@ -1,6 +1,8 @@ #include #include "panels/chart/ScatterHoverTip.hpp" +using geopro::app::electrodeHoverText; +using geopro::app::measurementHoverText; using geopro::app::scatterHoverText; // 对齐原版 Plotly hovertemplate: @@ -15,3 +17,16 @@ TEST(ScatterHoverTip, RoundsAndPadsToFixed3) { EXPECT_EQ(scatterHoverText(-1.0, 0.5, 1.0), QStringLiteral("X: -1.000
Y: 0.500
值: 1.000")); } + +// measurement 浮动框:X/Y/Value/a/b/m/n 多行(对齐原版 Plotly 浮动框)。 +TEST(ScatterHoverTip, MeasurementFloatingTipShowsXYValueABMN) { + const QString t = measurementHoverText(2.283, -1.2, 242.952988, 1, 4, 2, 3); + EXPECT_EQ(t, QStringLiteral("X: 2.283
Y: -1.200
Value: 242.953" + "
a: 1
b: 4
m: 2
n: 3")); +} + +// 电极浮动框:x(7 位有效数字)/ y: 0 / num(电极编号),对齐原版 Plotly 电极 trace。 +TEST(ScatterHoverTip, ElectrodeFloatingTipShowsXYNum) { + const QString t = electrodeHoverText(5.123837209632222, 4); + EXPECT_EQ(t, QStringLiteral("x: 5.123837
y: 0
num: 4")); +} diff --git a/tests/controller/test_dataset_detail_controller.cpp b/tests/controller/test_dataset_detail_controller.cpp index e996ddc..be8c8a4 100644 --- a/tests/controller/test_dataset_detail_controller.cpp +++ b/tests/controller/test_dataset_detail_controller.cpp @@ -1,21 +1,33 @@ #include #include +#include #include "DatasetDetailController.hpp" +#include "DatasetDetailTab.hpp" #include "IDatasetChartStrategy.hpp" #include "repo/IAsyncDatasetRepository.hpp" #include "api/DatasetLoadHandles.hpp" using namespace geopro; namespace { -// 反演策略桩:散点 + 网格两阶段。 +// 反演策略桩:散点(非 lazy) + 网格(lazy) 两页签。 struct InversionStrategy : controller::IDatasetChartStrategy { std::string ddCode() const override { return "dd_inversion_data"; } - bool hasGridPhase() const override { return true; } + std::vector 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}, + }; + } }; -// 无网格阶段策略桩:仅散点(如纯散点类型)。 -struct NoGridStrategy : controller::IDatasetChartStrategy { +// 单页签策略桩(仅一个非 lazy 页签)。 +struct SingleTabStrategy : controller::IDatasetChartStrategy { std::string ddCode() const override { return "dd_scatter_only"; } - bool hasGridPhase() const override { return false; } + std::vector tabs() const override { + return {{QStringLiteral("散点"), controller::ViewKind::Scatter, + QStringLiteral("scatter.only"), /*lazy*/ false, /*paginated*/ false}}; + } }; // 注册了反演策略的注册表(多数用例复用)。 controller::ChartStrategyRegistry makeInversionRegistry() { @@ -24,39 +36,56 @@ controller::ChartStrategyRegistry makeInversionRegistry() { return reg; } -// 桩句柄:不声明 Q_OBJECT —— 发射继承自 data::ChartLoad/GridLoad 的信号、override abort。 -struct StubChartLoad : data::ChartLoad { +// 桩详情句柄:不声明 Q_OBJECT —— 发射继承自 data::DetailLoad 的信号、override abort。 +struct StubDetailLoad : data::DetailLoad { bool aborted = false; void abort() override { aborted = true; } - void fireDone() { emit done(data::ChartParts{}); } - void fireFailed() { emit failed(QStringLiteral("x")); } -}; -struct StubGridLoad : data::GridLoad { - bool aborted = false; - void abort() override { aborted = true; } - void fireDone() { emit done(data::GridParts{}); } + void fireDone() { emit done(QVariant{}); } void fireFailed() { emit failed(QStringLiteral("x")); } }; +// 桩仓储:每个 loaderKey 都造一个新句柄,记录最近一个用于 fire。 struct StubAsyncRepo : data::IAsyncDatasetRepository { - StubChartLoad* lastChart = nullptr; - StubGridLoad* lastGrid = nullptr; - data::ChartLoad* loadChartAsync(const std::string&) override { - lastChart = new StubChartLoad; return lastChart; - } - data::GridLoad* loadGridAsync(const std::string&) override { - lastGrid = new StubGridLoad; return lastGrid; + StubDetailLoad* last = nullptr; + data::DetailLoad* loadAsync(const std::string&, const std::string&) override { + last = new StubDetailLoad; + return last; } }; -} +} // namespace -TEST(DatasetDetailController, EmitsChartReadyOnDone) { +TEST(DatasetDetailController, OpenEmitsDatasetOpenedWithTabsAndDdCode) { StubAsyncRepo repo; auto reg = makeInversionRegistry(); controller::DatasetDetailController c(repo, reg); - QSignalSpy spy(&c, &controller::DatasetDetailController::chartReady); + QSignalSpy spy(&c, &controller::DatasetDetailController::datasetOpened); + c.openDataset("ds1", "dd_inversion_data", "名称"); + ASSERT_EQ(spy.count(), 1); + const auto args = spy.takeFirst(); + EXPECT_EQ(args.at(0).toString(), QStringLiteral("ds1")); + EXPECT_EQ(args.at(1).toString(), QStringLiteral("dd_inversion_data")); // ddCode 透传 + EXPECT_EQ(args.at(2).toString(), QStringLiteral("名称")); +} + +TEST(DatasetDetailController, OpenLoadsNonLazyTabsOnly) { + StubAsyncRepo repo; + auto reg = makeInversionRegistry(); + controller::DatasetDetailController c(repo, reg); + QSignalSpy started(&c, &controller::DatasetDetailController::tabLoadStarted); c.openDataset("ds1", "dd_inversion_data"); - repo.lastChart->fireDone(); - EXPECT_EQ(spy.count(), 1); + // 反演:tab0 非 lazy 自动加载,tab1 lazy 不加载。 + ASSERT_EQ(started.count(), 1); + EXPECT_EQ(started.takeFirst().at(1).toInt(), 0); +} + +TEST(DatasetDetailController, EmitsTabReadyOnDone) { + StubAsyncRepo repo; + auto reg = makeInversionRegistry(); + controller::DatasetDetailController c(repo, reg); + QSignalSpy spy(&c, &controller::DatasetDetailController::tabReady); + c.openDataset("ds1", "dd_inversion_data"); + repo.last->fireDone(); + ASSERT_EQ(spy.count(), 1); + EXPECT_EQ(spy.takeFirst().at(1).toInt(), 0); // tabIndex 0 } TEST(DatasetDetailController, EmitsLoadFailedOnFailed) { @@ -65,7 +94,7 @@ TEST(DatasetDetailController, EmitsLoadFailedOnFailed) { controller::DatasetDetailController c(repo, reg); QSignalSpy spy(&c, &controller::DatasetDetailController::loadFailed); c.openDataset("ds1", "dd_inversion_data"); - repo.lastChart->fireFailed(); + repo.last->fireFailed(); EXPECT_EQ(spy.count(), 1); } @@ -76,7 +105,7 @@ TEST(DatasetDetailController, UnsupportedTypeFailsImmediately) { QSignalSpy spy(&c, &controller::DatasetDetailController::loadFailed); c.openDataset("ds1", "dd_other"); EXPECT_EQ(spy.count(), 1); - EXPECT_EQ(repo.lastChart, nullptr); // 未发起加载 + EXPECT_EQ(repo.last, nullptr); // 未发起加载 } // 空注册表 → 任意 ddCode 都不支持 → loadFailed,不发起加载。 @@ -87,76 +116,62 @@ TEST(DatasetDetailController, EmptyRegistryFailsAnyType) { QSignalSpy spy(&c, &controller::DatasetDetailController::loadFailed); c.openDataset("ds1", "dd_inversion_data"); EXPECT_EQ(spy.count(), 1); - EXPECT_EQ(repo.lastChart, nullptr); + EXPECT_EQ(repo.last, nullptr); } -// 无网格阶段的策略 → loadGridData 不发起网格加载。 -TEST(DatasetDetailController, NoGridPhaseStrategySkipsGridLoad) { - StubAsyncRepo repo; - controller::ChartStrategyRegistry reg; - reg.add(std::make_unique()); - controller::DatasetDetailController c(repo, reg); - c.loadGridData("ds1", "dd_scatter_only"); - EXPECT_EQ(repo.lastGrid, nullptr); // hasGridPhase()==false → 未发起 -} - -TEST(DatasetDetailController, AbortsPreviousOnReopen) { +// 越界 tabIndex → loadTab 静默不加载。 +TEST(DatasetDetailController, LoadTabOutOfRangeDoesNothing) { StubAsyncRepo repo; auto reg = makeInversionRegistry(); controller::DatasetDetailController c(repo, reg); - c.openDataset("dsA", "dd_inversion_data"); - StubChartLoad* a = repo.lastChart; - c.openDataset("dsB", "dd_inversion_data"); // 替换 - EXPECT_TRUE(a->aborted); // 旧句柄被 abort + c.loadTab("ds1", "dd_inversion_data", 99); + EXPECT_EQ(repo.last, nullptr); +} + +// lazy 页签经 loadTab 触发后加载。 +TEST(DatasetDetailController, LoadTabLazyTabStartsLoad) { + StubAsyncRepo repo; + auto reg = makeInversionRegistry(); + controller::DatasetDetailController c(repo, reg); + QSignalSpy ready(&c, &controller::DatasetDetailController::tabReady); + c.loadTab("ds1", "dd_inversion_data", 1); // lazy 网格页 + ASSERT_NE(repo.last, nullptr); + repo.last->fireDone(); + ASSERT_EQ(ready.count(), 1); + EXPECT_EQ(ready.takeFirst().at(1).toInt(), 1); +} + +TEST(DatasetDetailController, AbortsPreviousOnSameSlotReload) { + StubAsyncRepo repo; + auto reg = makeInversionRegistry(); + controller::DatasetDetailController c(repo, reg); + c.loadTab("dsA", "dd_inversion_data", 0); + StubDetailLoad* a = repo.last; + c.loadTab("dsB", "dd_inversion_data", 0); // 同槽位替换 + EXPECT_TRUE(a->aborted); // 旧句柄被 abort } TEST(DatasetDetailController, DropsLateSignalFromAbortedLoad) { StubAsyncRepo repo; auto reg = makeInversionRegistry(); controller::DatasetDetailController c(repo, reg); - QSignalSpy spy(&c, &controller::DatasetDetailController::chartReady); - c.openDataset("dsA", "dd_inversion_data"); - StubChartLoad* a = repo.lastChart; - c.openDataset("dsB", "dd_inversion_data"); - StubChartLoad* b = repo.lastChart; + QSignalSpy spy(&c, &controller::DatasetDetailController::tabReady); + c.loadTab("dsA", "dd_inversion_data", 0); + StubDetailLoad* a = repo.last; + c.loadTab("dsB", "dd_inversion_data", 0); // 同槽位替换 + StubDetailLoad* b = repo.last; a->fireDone(); // 旧句柄迟到 → 身份比对丢弃 EXPECT_EQ(spy.count(), 0); b->fireDone(); // 当前句柄 → 正常 EXPECT_EQ(spy.count(), 1); } -TEST(DatasetDetailController, EmitsGridReadyOnDone) { +TEST(DatasetDetailController, FocusEmitsFocusRequested) { StubAsyncRepo repo; auto reg = makeInversionRegistry(); controller::DatasetDetailController c(repo, reg); - QSignalSpy spy(&c, &controller::DatasetDetailController::gridReady); - c.loadGridData("ds1", "dd_inversion_data"); - repo.lastGrid->fireDone(); - EXPECT_EQ(spy.count(), 1); -} - -TEST(DatasetDetailController, EmitsLoadFailedOnGridFailed) { - StubAsyncRepo repo; - auto reg = makeInversionRegistry(); - controller::DatasetDetailController c(repo, reg); - QSignalSpy spy(&c, &controller::DatasetDetailController::loadFailed); - c.loadGridData("ds1", "dd_inversion_data"); - repo.lastGrid->fireFailed(); - EXPECT_EQ(spy.count(), 1); -} - -TEST(DatasetDetailController, DropsLateGridSignalFromAbortedLoad) { - StubAsyncRepo repo; - auto reg = makeInversionRegistry(); - controller::DatasetDetailController c(repo, reg); - QSignalSpy spy(&c, &controller::DatasetDetailController::gridReady); - c.loadGridData("dsA", "dd_inversion_data"); - StubGridLoad* a = repo.lastGrid; - c.loadGridData("dsB", "dd_inversion_data"); // 替换 → 旧句柄被 abort - StubGridLoad* b = repo.lastGrid; - EXPECT_TRUE(a->aborted); - a->fireDone(); // 旧句柄迟到 → 身份比对丢弃 - EXPECT_EQ(spy.count(), 0); - b->fireDone(); // 当前句柄 → 正常 - EXPECT_EQ(spy.count(), 1); + QSignalSpy spy(&c, &controller::DatasetDetailController::focusRequested); + c.focusDataset("ds1"); + ASSERT_EQ(spy.count(), 1); + EXPECT_EQ(spy.takeFirst().at(0).toString(), QStringLiteral("ds1")); } diff --git a/tests/data/test_async_repo_dispatch.cpp b/tests/data/test_async_repo_dispatch.cpp new file mode 100644 index 0000000..2da68e6 --- /dev/null +++ b/tests/data/test_async_repo_dispatch.cpp @@ -0,0 +1,79 @@ +#include +#include +#include +#include +#include "ApiClient.hpp" +#include "api/ApiDatasetRepository.hpp" +#include "api/DatasetLoadHandles.hpp" +#include "model/detail/DetailPayloads.hpp" + +using geopro::data::ApiDatasetRepository; +using geopro::data::DetailLoad; +using geopro::core::ContourPayload; +using geopro::core::ScatterPayload; + +namespace { +// ApiClient 构造 + getAsync/postJsonAsync 创建网络句柄需 QCoreApplication(QNAM 事件循环)。 +QCoreApplication* ensureApp() { + if (!QCoreApplication::instance()) { + static int argc = 0; + static char** argv = nullptr; + new QCoreApplication(argc, argv); + } + return QCoreApplication::instance(); +} +} // namespace + +// 已知 loaderKey 返回非空句柄;返回后立即 abort 取消在飞请求(不依赖网络可达)。 +TEST(AsyncRepoDispatch, KnownKeysReturnNonNullHandle) { + ensureApp(); + geopro::net::ApiClient api(QStringLiteral("http://tenant.geomative.cn/pop-api")); + ApiDatasetRepository repo(api); + + DetailLoad* scatter = repo.loadAsync("inversion.scatter", "ds1"); + ASSERT_NE(scatter, nullptr); + scatter->abort(); + + DetailLoad* grid = repo.loadAsync("inversion.grid", "ds1"); + ASSERT_NE(grid, nullptr); + grid->abort(); + + DetailLoad* measScatter = repo.loadAsync("ert_measurement.scatter", "ds1"); + ASSERT_NE(measScatter, nullptr); + measScatter->abort(); + + DetailLoad* measRows = repo.loadAsync("ert_measurement.rows", "ds1"); + ASSERT_NE(measRows, nullptr); + measRows->abort(); +} + +// 未知 loaderKey 抛 std::runtime_error。 +TEST(AsyncRepoDispatch, UnknownKeyThrows) { + ensureApp(); + geopro::net::ApiClient api(QStringLiteral("http://tenant.geomative.cn/pop-api")); + ApiDatasetRepository repo(api); + EXPECT_THROW(repo.loadAsync("bogus", "ds1"), std::runtime_error); +} + +// payload 经 QVariant 类型擦除往返还原(不触网)。 +TEST(AsyncRepoDispatch, ScatterPayloadRoundTrips) { + ScatterPayload in; + in.scatter.x = {1.0, 2.0, 3.0}; + in.scatter.v = {10.0, 20.0, 30.0}; + QVariant v = QVariant::fromValue(in); + ASSERT_TRUE(v.canConvert()); + ScatterPayload out = v.value(); + ASSERT_EQ(out.scatter.x.size(), 3u); + EXPECT_DOUBLE_EQ(out.scatter.v[2], 30.0); +} + +TEST(AsyncRepoDispatch, ContourPayloadRoundTrips) { + ContourPayload in; // Grid{1,1} 占位 + 空异常 + in.grid.vmin = 1.5; + in.grid.vmax = 9.5; + QVariant v = QVariant::fromValue(in); + ASSERT_TRUE(v.canConvert()); + ContourPayload out = v.value(); + EXPECT_DOUBLE_EQ(out.grid.vmin, 1.5); + EXPECT_DOUBLE_EQ(out.grid.vmax, 9.5); +} diff --git a/tests/data/test_dataset_load_handles.cpp b/tests/data/test_dataset_load_handles.cpp index d0590c7..686df30 100644 --- a/tests/data/test_dataset_load_handles.cpp +++ b/tests/data/test_dataset_load_handles.cpp @@ -1,13 +1,16 @@ #include #include #include +#include #include "api/DatasetLoadHandles.hpp" +#include "model/detail/DetailPayloads.hpp" #include "net/FakeApiCall.hpp" using namespace geopro::data; using geopro::net::ApiBatch; using geopro::net::ApiResponse; using geopro::net::test::FakeApiCall; +using geopro::core::ScatterPayload; namespace { ApiResponse ok() { ApiResponse r; r.code = 200; r.httpStatus = 200; return r; } @@ -15,51 +18,54 @@ ApiResponse bad() { ApiResponse r; r.code = 500; r.httpStatus = 200; r.msg = QSt auto isFailure = [](const ApiResponse& r) { return r.code != 200 || !r.rawError.isEmpty(); }; } -TEST(DatasetLoadHandles, ChartLoadEmitsDoneOnSuccess) { +// ── 通用详情句柄 ApiDetailLoad(tab 引擎):done(QVariant) / failed / abort 闸门 ── + +TEST(DatasetLoadHandles, DetailLoadEmitsDoneWithPayloadOnSuccess) { auto* a = new FakeApiCall; auto* b = new FakeApiCall; auto* batch = new ApiBatch({a, b}, isFailure); - bool parsed = false; - auto* load = new ApiChartLoad(batch, [&](const QList& resps) { - parsed = (resps.size() == 2); - return ChartParts{}; + auto* load = new ApiDetailLoad(batch, [](const QList& resps) { + ScatterPayload p; + p.scatter.v = std::vector(static_cast(resps.size()), 0.0); // 携带可校验状态 + return QVariant::fromValue(p); }); - QSignalSpy doneSpy(load, &ChartLoad::done); + QSignalSpy doneSpy(load, &DetailLoad::done); a->fire(ok()); b->fire(ok()); - EXPECT_EQ(doneSpy.count(), 1); - EXPECT_TRUE(parsed); + ASSERT_EQ(doneSpy.count(), 1); + QVariant payload = doneSpy.takeFirst().at(0).value(); + ASSERT_TRUE(payload.canConvert()); + EXPECT_EQ(payload.value().scatter.v.size(), 2u); // round-trip 还原字段 } -TEST(DatasetLoadHandles, ChartLoadEmitsFailedOnBatchFailure) { +TEST(DatasetLoadHandles, DetailLoadEmitsFailedOnBatchFailure) { auto* a = new FakeApiCall; - auto* b = new FakeApiCall; - auto* batch = new ApiBatch({a, b}, isFailure); - auto* load = new ApiChartLoad(batch, [](const QList&) { return ChartParts{}; }); - QSignalSpy failSpy(load, &ChartLoad::failed); + auto* batch = new ApiBatch({a}, isFailure); + auto* load = new ApiDetailLoad(batch, [](const QList&) { return QVariant{}; }); + QSignalSpy failSpy(load, &DetailLoad::failed); a->fire(bad()); EXPECT_EQ(failSpy.count(), 1); } -TEST(DatasetLoadHandles, ChartLoadEmitsFailedWhenParseThrows) { +TEST(DatasetLoadHandles, DetailLoadEmitsFailedWhenParseThrows) { auto* a = new FakeApiCall; auto* batch = new ApiBatch({a}, isFailure); - auto* load = new ApiChartLoad(batch, [](const QList&) -> ChartParts { + auto* load = new ApiDetailLoad(batch, [](const QList&) -> QVariant { throw std::runtime_error("parse boom"); }); - QSignalSpy doneSpy(load, &ChartLoad::done); - QSignalSpy failSpy(load, &ChartLoad::failed); - a->fire(ok()); // batch 成功 → parse 抛异常 → failed(emit done 已移出 try) + QSignalSpy doneSpy(load, &DetailLoad::done); + QSignalSpy failSpy(load, &DetailLoad::failed); + a->fire(ok()); // batch 成功 → parse 抛 → failed(done 已移出 try) EXPECT_EQ(doneSpy.count(), 0); EXPECT_EQ(failSpy.count(), 1); } -TEST(DatasetLoadHandles, GridLoadAbortSuppressesLateDone) { +TEST(DatasetLoadHandles, DetailLoadAbortSuppressesLateDone) { auto* a = new FakeApiCall; auto* batch = new ApiBatch({a}, isFailure); - auto* load = new ApiGridLoad(batch, [](const QList&) { return GridParts{}; }); - QSignalSpy doneSpy(load, &GridLoad::done); + auto* load = new ApiDetailLoad(batch, [](const QList&) { return QVariant{}; }); + QSignalSpy doneSpy(load, &DetailLoad::done); load->abort(); a->fire(ok()); // 迟到 - EXPECT_EQ(doneSpy.count(), 0); // batch.aborted_ + load.aborted_ 双闸门 + EXPECT_EQ(doneSpy.count(), 0); // 双闸门 } diff --git a/tests/data/test_measurement_dto.cpp b/tests/data/test_measurement_dto.cpp new file mode 100644 index 0000000..25f3b0d --- /dev/null +++ b/tests/data/test_measurement_dto.cpp @@ -0,0 +1,254 @@ +#include +#include +#include +#include +#include "dto/MeasurementDto.hpp" +using namespace geopro::data::dto; + +static QJsonObject obj(const char* json) { + return QJsonDocument::fromJson(json).object(); +} + +// scatter/graph 的 data:electrodeList + scatterGraphData.rows(17 列定位数组,取自真实夹具前 3 行)。 +static const char* kScatterData = R"({ + "electrodeList": [ + {"electrodeNo":1,"horizontalDistance":0,"slopeDistance":0,"pseudoDepth":0}, + {"electrodeNo":2,"horizontalDistance":1.08,"slopeDistance":1.2459494894830485,"pseudoDepth":0}, + {"electrodeNo":3,"horizontalDistance":3.15,"slopeDistance":3.3194843662393243,"pseudoDepth":0} + ], + "scatterGraphData": { + "hasCoordinate": true, + "rows": [ + [2.115013167852418,2.2827169278611863,null,-1.2,242.952988,2.385,4.116,0,"1453611521843200",24.288,1,4,2,3,12.5664,1,242.952988], + [5.124875148343908,5.300065674086864,null,-2.4,-1066.592407,1.873,13.377,0,"1453611521843201",23.12,1,7,3,5,25.1327,2,-1066.592407], + [7.994418521491841,8.175900836015376,null,-3.6,100.239388,2.001,9.976,0,"1453611521843202",21.987,1,10,4,7,37.6991,3,100.239388] + ], + "__rowsTotal": 576 + } +})"; + +// colorGradation/getDetail{type:3} 的 data:与反演同构混合格式 colorBar(hex + rgba alpha 0–1)。 +static const char* kColorBarData = R"json({ + "properties": { + "lvlSchemeType": "normal", + "colorBar": [ + ["0.00", "#00008B"], + ["1.51", "rgba(0, 0, 170, 1)"], + ["208.40", "#FF0000"], + ["1323.20", "rgba(48, 0, 48, 1)"] + ] + } +})json"; + +// measurement/rows 的 data:filedList(列定义)+ rowList(含 vmap 摊平),取自真实夹具前 2 行。 +static const char* kRowsData = R"({ + "filedList": [ + {"fieldCode":"a","name":"A"}, + {"fieldCode":"b","name":"B"}, + {"fieldCode":"k","name":"K"}, + {"fieldCode":"R","name":"电阻"}, + {"fieldCode":"R0","name":"视电阻率"} + ], + "rowList": [ + {"id":"1453611521843200","rowNo":1,"displayStatus":0,"a":1,"b":4,"k":12.5664, + "vmap":{"R":19.333542,"V":10223.44531,"I":528.793213,"R0_RD":0,"SP":127.640175,"R0":242.952988}}, + {"id":"1453611521843201","rowNo":2,"displayStatus":0,"a":1,"b":7,"k":25.1327, + "vmap":{"R":2.974368,"V":1822.526123,"I":612.743958,"R0_RD":0,"SP":92.380089,"R0":74.753899}} + ], + "__rowListTotal": 576 +})"; + +TEST(MeasurementDto, ParsesScatterPositionalRows) { + auto p = parseMeasurementScatter(obj(kScatterData), obj(kColorBarData)); + const auto& s = p.scatter; + + // 3 个散点;连续上色(cauto,与反演原数据同路径,已无 discreteColor 字段) + // + 图例与反演统一为底部横条。 + EXPECT_FALSE(p.verticalLegend); // measurement 图例改用底部横条(与反演一致) + ASSERT_EQ(s.x.size(), 3u); + ASSERT_EQ(s.y.size(), 3u); + ASSERT_EQ(s.v.size(), 3u); + + // x = col1 斜距;y = col3 伪深度(负);色值 = col16。 + EXPECT_DOUBLE_EQ(s.x[0], 2.2827169278611863); + EXPECT_DOUBLE_EQ(s.y[0], -1.2); + EXPECT_LT(s.y[0], 0.0); // 伪深度向下为负 + EXPECT_DOUBLE_EQ(s.v[0], 242.952988); + EXPECT_DOUBLE_EQ(s.x[2], 8.175900836015376); + EXPECT_DOUBLE_EQ(s.y[2], -3.6); + + // cauto 关键事实:负异常值(col16)原样保留——视图 setDataRange 据此得 cmin<0, + // 使中段视电阻率(≈25–250)归一化到色阶中部(深品红/紫),与原版 Plotly 一致。 + EXPECT_DOUBLE_EQ(s.v[1], -1066.592407); + EXPECT_LT(s.v[1], 0.0); // 负异常值未被过滤 + const double vMin = std::min({s.v[0], s.v[1], s.v[2]}); + EXPECT_DOUBLE_EQ(vMin, -1066.592407); // 数据范围下界为负 → cmin<0 + + // A/B/M/N = col10-13。 + ASSERT_EQ(s.a.size(), 3u); + EXPECT_DOUBLE_EQ(s.a[0], 1.0); + EXPECT_DOUBLE_EQ(s.b[0], 4.0); + EXPECT_DOUBLE_EQ(s.m[0], 2.0); + EXPECT_DOUBLE_EQ(s.n[0], 3.0); + + // 电极 X = electrodeList.slopeDistance。 + ASSERT_EQ(s.electrodeX.size(), 3u); + EXPECT_DOUBLE_EQ(s.electrodeX[0], 0.0); + EXPECT_DOUBLE_EQ(s.electrodeX[1], 1.2459494894830485); + + // 电极编号 = electrodeList.electrodeNo(1-based),与 electrodeX 一一对应(供 hover num)。 + ASSERT_EQ(s.electrodeNo.size(), 3u); + EXPECT_DOUBLE_EQ(s.electrodeNo[0], 1.0); + EXPECT_DOUBLE_EQ(s.electrodeNo[1], 2.0); + EXPECT_DOUBLE_EQ(s.electrodeNo[2], 3.0); +} + +// scatter/graph 含 scatterGraphConf 时:工具条下拉项 + 默认选中 + x/y 本地重绘备选列。 +static const char* kScatterDataWithConf = R"({ + "electrodeList": [ + {"electrodeNo":1,"horizontalDistance":0,"slopeDistance":0,"pseudoDepth":0} + ], + "scatterGraphConf": { + "x": [ + {"fieldCode":"horizontalDistance","name":"平距"}, + {"fieldCode":"slopeDistance","name":"斜距"} + ], + "y": [ + {"fieldCode":"Layer No","name":"层数"}, + {"fieldCode":"pseudoDepth","name":"伪深度"}, + {"fieldCode":"elevationPseudoDepth","name":"伪深度+高程"} + ], + "v": [ + {"fieldCode":"I","name":"电流"}, + {"fieldCode":"R0","name":"视电阻率"}, + {"fieldCode":"SP","name":"自然电位"} + ], + "vcalculationMethod": [ + {"fieldCode":null,"name":"线性"}, + {"fieldCode":null,"name":"对数"}, + {"fieldCode":null,"name":"导数"} + ] + }, + "scatterGraphData": { + "rows": [ + [2.115013167852418,2.2827169278611863,null,-1.2,242.952988,2.385,4.116,0,"id0",24.288,1,4,2,3,12.5664,1,242.952988] + ] + } +})"; + +TEST(MeasurementDto, ParsesScatterToolbarConfAndDefaults) { + auto p = parseMeasurementScatter(obj(kScatterDataWithConf), obj(kColorBarData)); + const auto& tb = p.toolbar; + + EXPECT_FALSE(tb.empty()); + ASSERT_EQ(tb.x.size(), 2u); + ASSERT_EQ(tb.y.size(), 3u); + ASSERT_EQ(tb.v.size(), 3u); + ASSERT_EQ(tb.method.size(), 3u); + + // 选项名/码(顺序保留)。 + EXPECT_EQ(tb.x[0].code.toStdString(), "horizontalDistance"); + EXPECT_EQ(tb.x[0].name.toStdString(), std::string("平距")); + EXPECT_EQ(tb.x[1].code.toStdString(), "slopeDistance"); + EXPECT_EQ(tb.method[0].name.toStdString(), std::string("线性")); + EXPECT_TRUE(tb.method[0].code.isEmpty()); // method 的 fieldCode 为 null + + // 默认选中:斜距 / 伪深度 / 视电阻率 / 线性。 + EXPECT_EQ(tb.defaultX.toStdString(), "slopeDistance"); + EXPECT_EQ(tb.defaultY.toStdString(), "pseudoDepth"); + EXPECT_EQ(tb.defaultV.toStdString(), "R0"); + EXPECT_EQ(tb.defaultMethod.toStdString(), std::string("线性")); +} + +TEST(MeasurementDto, CarriesAltColumnsForLocalReplot) { + auto p = parseMeasurementScatter(obj(kScatterDataWithConf), obj(kColorBarData)); + + // x 备选:平距(col0) / 斜距(col1);y 备选:伪深度(col3) / 伪深度+高程(col9)。 + ASSERT_EQ(p.altXHorizontal.size(), 1u); + ASSERT_EQ(p.altXSlope.size(), 1u); + ASSERT_EQ(p.altYPseudo.size(), 1u); + ASSERT_EQ(p.altYElevationPseudo.size(), 1u); + EXPECT_DOUBLE_EQ(p.altXHorizontal[0], 2.115013167852418); + EXPECT_DOUBLE_EQ(p.altXSlope[0], 2.2827169278611863); + EXPECT_DOUBLE_EQ(p.altYPseudo[0], -1.2); + EXPECT_DOUBLE_EQ(p.altYElevationPseudo[0], 24.288); +} + +TEST(MeasurementDto, ToolbarEmptyWhenNoConf) { + // 无 scatterGraphConf(反演原数据形状):工具条空 → 视图渲染反演工具条。 + auto p = parseMeasurementScatter(obj(kScatterData), obj(kColorBarData)); + EXPECT_TRUE(p.toolbar.empty()); +} + +TEST(MeasurementDto, ParsesScatterColorBarOpaque) { + auto p = parseMeasurementScatter(obj(kScatterData), obj(kColorBarData)); + auto stops = p.scale.stops(); + ASSERT_EQ(stops.size(), 4u); + // 首/末值。 + EXPECT_DOUBLE_EQ(stops.front().first, 0.0); + EXPECT_DOUBLE_EQ(stops.back().first, 1323.20); + // 首色 #00008B(深蓝),末色 rgba(48,0,48,1)(alpha=1 → 255,不透明)。 + EXPECT_EQ(stops.front().second.r, 0); + EXPECT_EQ(stops.front().second.g, 0); + EXPECT_EQ(stops.front().second.b, 0x8B); + EXPECT_EQ(stops.front().second.a, 255); + EXPECT_EQ(stops.back().second.r, 48); + EXPECT_EQ(stops.back().second.g, 0); + EXPECT_EQ(stops.back().second.b, 48); + EXPECT_EQ(stops.back().second.a, 255); // 回归:alpha=1 须映射为 255 不透明 +} + +TEST(MeasurementDto, ParsesTableColumnsAndVmapFlattened) { + auto t = parseMeasurementTable(obj(kRowsData)); + + // 列来自 filedList(5 列)+ 末尾追加“隐藏/显示”开关列 = 6 列。 + ASSERT_EQ(t.columns.size(), 6u); + EXPECT_EQ(t.columns[0].code.toStdString(), "a"); + EXPECT_EQ(t.columns[0].title.toStdString(), "A"); + EXPECT_EQ(t.columns[4].code.toStdString(), "R0"); + EXPECT_EQ(t.columns[4].title.toStdString(), std::string("视电阻率")); + + // 2 行;total 来自 __rowListTotal。 + ASSERT_EQ(t.rows.size(), 2u); + EXPECT_EQ(t.total, 576); + + // 顶层列(a/b/k)从行对象取;vmap 列(R/R0)从 vmap 摊平。 + ASSERT_EQ(t.rows[0].size(), 6u); + EXPECT_EQ(t.rows[0][0].toStdString(), "1"); // a(整数无小数点) + EXPECT_EQ(t.rows[0][1].toStdString(), "4"); // b + EXPECT_EQ(t.rows[0][3].toStdString(), "19.333542"); // R from vmap + EXPECT_EQ(t.rows[0][4].toStdString(), "242.952988"); // R0 from vmap + EXPECT_EQ(t.rows[1][4].toStdString(), "74.753899"); +} + +TEST(MeasurementDto, AppendsHideShowToggleColumn) { + auto t = parseMeasurementTable(obj(kRowsData)); + + // 末列为“隐藏/显示”,kind=Toggle,code=displayStatus。 + ASSERT_EQ(t.columns.size(), 6u); + const auto& toggle = t.columns.back(); + EXPECT_EQ(toggle.title.toStdString(), std::string("隐藏/显示")); + EXPECT_EQ(toggle.code.toStdString(), "displayStatus"); + EXPECT_EQ(toggle.kind, geopro::core::TableColumnKind::Toggle); + + // displayStatus==0 ⇒ ON/可见("1"),全部 ON。 + ASSERT_EQ(t.rows.size(), 2u); + EXPECT_EQ(t.rows[0].back().toStdString(), "1"); + EXPECT_EQ(t.rows[1].back().toStdString(), "1"); +} + +TEST(MeasurementDto, ToggleOffWhenDisplayStatusNonZero) { + // displayStatus!=0 ⇒ OFF("0")。 + auto t = parseMeasurementTable(obj(R"({ + "filedList": [{"fieldCode":"a","name":"A"}], + "rowList": [ + {"id":"x","a":1,"displayStatus":0}, + {"id":"y","a":2,"displayStatus":1} + ] + })")); + ASSERT_EQ(t.columns.size(), 2u); + EXPECT_EQ(t.columns.back().kind, geopro::core::TableColumnKind::Toggle); + ASSERT_EQ(t.rows.size(), 2u); + EXPECT_EQ(t.rows[0].back().toStdString(), "1"); // displayStatus 0 → ON + EXPECT_EQ(t.rows[1].back().toStdString(), "0"); // displayStatus 1 → OFF +} diff --git a/tests/fixtures/dd/ert-measurement-colorbar.json b/tests/fixtures/dd/ert-measurement-colorbar.json new file mode 100644 index 0000000..7b1b490 --- /dev/null +++ b/tests/fixtures/dd/ert-measurement-colorbar.json @@ -0,0 +1,39 @@ +{ + "code": 200, + "msg": "成功", + "data": { + "id": null, + "projectId": "1438889436225536", + "templateId": "1439864962138112", + "dsObjectId": null, + "tmObjectId": null, + "graphicArea": 0, + "properties": { + "lvlSchemeType": "normal", + "colorBar": [ + ["0.00", "#00008B"], + ["1.51", "rgba(0, 0, 170, 1)"], + ["2.27", "rgba(0, 0, 211, 1)"], + ["3.43", "#0000FF"], + ["5.17", "rgba(0, 128, 255, 1)"], + ["7.79", "#00FFFF"], + ["11.75", "rgba(0, 192, 128, 1)"], + ["17.72", "#00FF00"], + ["26.73", "rgba(0, 128, 0, 1)"], + ["40.30", "rgba(128, 192, 0, 1)"], + ["60.77", "#FFFF00"], + ["91.64", "rgba(191, 128, 0, 1)"], + ["138.20", "rgba(255, 128, 0, 1)"], + ["208.40", "#FF0000"], + ["314.20", "rgba(211, 0, 0, 1)"], + ["473.80", "rgba(132, 0, 64, 1)"], + ["714.50", "rgba(96, 0, 96, 1)"], + ["1323.20", "rgba(48, 0, 48, 1)"] + ], + "equalAreaLayerCount": 10, + "labelConfig": { "showLabels": true, "color": "#000000" }, + "logLinesCount": 8, + "lineConfig": { "showLines": true, "color": "#000000", "lineType": "dashed" } + } + } +} diff --git a/tests/fixtures/dd/ert-measurement-rows.json b/tests/fixtures/dd/ert-measurement-rows.json new file mode 100644 index 0000000..98eb0d9 --- /dev/null +++ b/tests/fixtures/dd/ert-measurement-rows.json @@ -0,0 +1,575 @@ +{ + "code": 200, + "msg": "成功", + "data": { + "filedList": [ + { + "fieldCode": "a", + "name": "A", + "sheetHeaderNameList": [ + "A" + ] + }, + { + "fieldCode": "b", + "name": "B", + "sheetHeaderNameList": [ + "B" + ] + }, + { + "fieldCode": "m", + "name": "M", + "sheetHeaderNameList": [ + "M" + ] + }, + { + "fieldCode": "n", + "name": "N", + "sheetHeaderNameList": [ + "N" + ] + }, + { + "fieldCode": "k", + "name": "K", + "sheetHeaderNameList": [ + "K" + ] + }, + { + "fieldCode": "stacking", + "name": "Stacking", + "sheetHeaderNameList": [ + "Stacking" + ] + }, + { + "fieldCode": "I", + "name": "电流", + "sheetHeaderNameList": [ + "I(mA)" + ] + }, + { + "fieldCode": "V", + "name": "电压", + "sheetHeaderNameList": [ + "V(mV)" + ] + }, + { + "fieldCode": "R", + "name": "电阻", + "sheetHeaderNameList": [ + "R(Ohm)" + ] + }, + { + "fieldCode": "R0", + "name": "视电阻率", + "sheetHeaderNameList": [ + "R0" + ] + }, + { + "fieldCode": "SP", + "name": "自然电位", + "sheetHeaderNameList": [ + "SP" + ] + }, + { + "fieldCode": "R0_RD", + "name": "电阻误差", + "sheetHeaderNameList": [ + "R0_RD" + ] + } + ], + "rowList": [ + { + "id": "1453611521843200", + "rowNo": 1, + "displayStatus": 0, + "horizontalDistance": 2.115013167852418, + "slopeDistance": 2.2827169278611863, + "layerNo": null, + "pseudoDepth": -1.2, + "elevationPseudoDepth": 24.288, + "n": 3, + "a": 1, + "b": 4, + "m": 2, + "k": 12.5664, + "vmap": { + "R": 19.333542, + "V": 10223.44531, + "I": 528.793213, + "R0_RD": 0, + "SP": 127.640175, + "R0": 242.952988 + }, + "stacking": 1 + }, + { + "id": "1453611521843201", + "rowNo": 2, + "displayStatus": 0, + "horizontalDistance": 5.124875148343908, + "slopeDistance": 5.300065674086864, + "layerNo": null, + "pseudoDepth": -2.4, + "elevationPseudoDepth": 23.12, + "n": 5, + "a": 1, + "b": 7, + "m": 3, + "k": 25.1327, + "vmap": { + "R": 2.974368, + "V": 1822.526123, + "I": 612.743958, + "R0_RD": 0, + "SP": 92.380089, + "R0": 74.753899 + }, + "stacking": 1 + }, + { + "id": "1453611521843202", + "rowNo": 3, + "displayStatus": 0, + "horizontalDistance": 7.994418521491841, + "slopeDistance": 8.175900836015376, + "layerNo": null, + "pseudoDepth": -3.6, + "elevationPseudoDepth": 21.987, + "n": 7, + "a": 1, + "b": 10, + "m": 4, + "k": 37.6991, + "vmap": { + "R": 2.658933, + "V": 886.042603, + "I": 333.232361, + "R0_RD": 0, + "SP": -186.630417, + "R0": 100.239388 + }, + "stacking": 1 + }, + { + "id": "1453611521843203", + "rowNo": 4, + "displayStatus": 0, + "horizontalDistance": 8.00601333013422, + "slopeDistance": 8.186794593291953, + "layerNo": null, + "pseudoDepth": -3.6, + "elevationPseudoDepth": 22.020999999999997, + "n": 6, + "a": 1, + "b": 10, + "m": 5, + "k": 125.663696, + "vmap": { + "R": 0.561675, + "V": 187.168427, + "I": 333.232361, + "R0_RD": 0, + "SP": -129.25145, + "R0": 70.582214 + }, + "stacking": 1 + }, + { + "id": "1453611521843204", + "rowNo": 5, + "displayStatus": 0, + "horizontalDistance": 11.066663821597649, + "slopeDistance": 11.272344909478555, + "layerNo": null, + "pseudoDepth": -4.8, + "elevationPseudoDepth": 20.9685, + "n": 9, + "a": 1, + "b": 13, + "m": 5, + "k": 50.265499, + "vmap": { + "R": 1.496101, + "V": 528.129211, + "I": 353.003784, + "R0_RD": 0, + "SP": -22.242311, + "R0": 75.202255 + }, + "stacking": 1 + }, + { + "id": "1453611521843205", + "rowNo": 6, + "displayStatus": 0, + "horizontalDistance": 11.00295159513125, + "slopeDistance": 11.184890199081117, + "layerNo": null, + "pseudoDepth": -4.8, + "elevationPseudoDepth": 20.758499999999998, + "n": 8, + "a": 1, + "b": 13, + "m": 6, + "k": 109.955704, + "vmap": { + "R": 0.941678, + "V": 332.415863, + "I": 353.003784, + "R0_RD": 0, + "SP": 288.221405, + "R0": 103.542862 + }, + "stacking": 1 + }, + { + "id": "1453611521859584", + "rowNo": 7, + "displayStatus": 0, + "horizontalDistance": 13.92101964767117, + "slopeDistance": 14.166540305149894, + "layerNo": null, + "pseudoDepth": -6, + "elevationPseudoDepth": 19.490499999999997, + "n": 11, + "a": 1, + "b": 16, + "m": 6, + "k": 62.831902, + "vmap": { + "R": 1.357018, + "V": 693.766174, + "I": 511.243073, + "R0_RD": 0, + "SP": 289.488678, + "R0": 85.264038 + }, + "stacking": 1 + }, + { + "id": "1453611521859585", + "rowNo": 8, + "displayStatus": 0, + "horizontalDistance": 14.000862630669717, + "slopeDistance": 14.247085672002468, + "layerNo": null, + "pseudoDepth": -6, + "elevationPseudoDepth": 19.456, + "n": 10, + "a": 1, + "b": 16, + "m": 7, + "k": 113.097298, + "vmap": { + "R": 0.557539, + "V": 285.037994, + "I": 511.243073, + "R0_RD": 0, + "SP": -35.634811, + "R0": 63.056168 + }, + "stacking": 1 + }, + { + "id": "1453611521859586", + "rowNo": 9, + "displayStatus": 0, + "horizontalDistance": 14.063602086594681, + "slopeDistance": 14.27044051526772, + "layerNo": null, + "pseudoDepth": -6, + "elevationPseudoDepth": 19.706, + "n": 9, + "a": 1, + "b": 16, + "m": 8, + "k": 351.858398, + "vmap": { + "R": 0.125825, + "V": 64.3274, + "I": 511.243073, + "R0_RD": 0, + "SP": -187.302017, + "R0": 44.272751 + }, + "stacking": 1 + }, + { + "id": "1453611521859587", + "rowNo": 10, + "displayStatus": 0, + "horizontalDistance": 16.987234838304097, + "slopeDistance": 17.233999253766825, + "layerNo": null, + "pseudoDepth": -7.2, + "elevationPseudoDepth": 18.296499999999998, + "n": 13, + "a": 1, + "b": 19, + "m": 7, + "k": 75.398201, + "vmap": { + "R": 0.889478, + "V": 318.904541, + "I": 358.53006, + "R0_RD": 0, + "SP": -63.969704, + "R0": 67.065033 + }, + "stacking": 1 + }, + { + "id": "1453611521859588", + "rowNo": 11, + "displayStatus": 0, + "horizontalDistance": 17.03278556632032, + "slopeDistance": 17.279602362635487, + "layerNo": null, + "pseudoDepth": -7.2, + "elevationPseudoDepth": 18.24, + "n": 12, + "a": 1, + "b": 19, + "m": 8, + "k": 120.951302, + "vmap": { + "R": 0.585498, + "V": 209.918564, + "I": 358.53006, + "R0_RD": 0, + "SP": -268.122803, + "R0": 70.816719 + }, + "stacking": 1 + }, + { + "id": "1453611521859589", + "rowNo": 12, + "displayStatus": 0, + "horizontalDistance": 16.9816701391346, + "slopeDistance": 17.2520906213365, + "layerNo": null, + "pseudoDepth": -7.2, + "elevationPseudoDepth": 18.438, + "n": 11, + "a": 1, + "b": 19, + "m": 9, + "k": 251.327393, + "vmap": { + "R": 0.166266, + "V": 59.611279, + "I": 358.53006, + "R0_RD": 0, + "SP": 187.193558, + "R0": 41.787144 + }, + "stacking": 1 + }, + { + "id": "1453611521859590", + "rowNo": 13, + "displayStatus": 0, + "horizontalDistance": 20.01061875986487, + "slopeDistance": 20.259070486101244, + "layerNo": null, + "pseudoDepth": -8.4, + "elevationPseudoDepth": 17.037, + "n": 15, + "a": 1, + "b": 22, + "m": 8, + "k": 87.9646, + "vmap": { + "R": 0.778783, + "V": 222.046936, + "I": 285.120575, + "R0_RD": 0, + "SP": -188.105652, + "R0": 68.505302 + }, + "stacking": 1 + }, + { + "id": "1453611521859591", + "rowNo": 14, + "displayStatus": 0, + "horizontalDistance": 19.924955341057455, + "slopeDistance": 20.196078557097973, + "layerNo": null, + "pseudoDepth": -8.4, + "elevationPseudoDepth": 17.301000000000002, + "n": 14, + "a": 1, + "b": 22, + "m": 9, + "k": 130.690308, + "vmap": { + "R": 0.586209, + "V": 167.140335, + "I": 285.120575, + "R0_RD": 0, + "SP": -30.588928, + "R0": 76.61187 + }, + "stacking": 1 + }, + { + "id": "1453611521859592", + "rowNo": 15, + "displayStatus": 0, + "horizontalDistance": 19.94235941950773, + "slopeDistance": 20.25312046337076, + "layerNo": null, + "pseudoDepth": -8.4, + "elevationPseudoDepth": 17.003500000000003, + "n": 13, + "a": 1, + "b": 22, + "m": 10, + "k": 226.194595, + "vmap": { + "R": 0.258264, + "V": 73.636406, + "I": 285.120575, + "R0_RD": 0, + "SP": -38.283382, + "R0": 58.417942 + }, + "stacking": 1 + }, + { + "id": "1453611521859593", + "rowNo": 16, + "displayStatus": 0, + "horizontalDistance": 19.95085361886024, + "slopeDistance": 20.261252468704264, + "layerNo": null, + "pseudoDepth": -8.4, + "elevationPseudoDepth": 16.972, + "n": 12, + "a": 1, + "b": 22, + "m": 11, + "k": 691.150391, + "vmap": { + "R": 0.219839, + "V": 62.776394, + "I": 285.55603, + "R0_RD": 0, + "SP": -264.453094, + "R0": 151.941925 + }, + "stacking": 1 + }, + { + "id": "1453611521859594", + "rowNo": 17, + "displayStatus": 0, + "horizontalDistance": 22.95679663366693, + "slopeDistance": 23.231100221926333, + "layerNo": null, + "pseudoDepth": -9.6, + "elevationPseudoDepth": 16.131, + "n": 17, + "a": 1, + "b": 25, + "m": 9, + "k": 100.530998, + "vmap": { + "R": 0.865843, + "V": 235.531586, + "I": 272.025665, + "R0_RD": 0, + "SP": 252.63089, + "R0": 87.04409 + }, + "stacking": 1 + }, + { + "id": "1453611521859595", + "rowNo": 18, + "displayStatus": 0, + "horizontalDistance": 22.822828222784292, + "slopeDistance": 23.136758664349642, + "layerNo": null, + "pseudoDepth": -9.6, + "elevationPseudoDepth": 15.8315, + "n": 16, + "a": 1, + "b": 25, + "m": 10, + "k": 141.371704, + "vmap": { + "R": 0.636254, + "V": 173.077347, + "I": 272.025665, + "R0_RD": 0, + "SP": 17.840714, + "R0": 89.94828 + }, + "stacking": 1 + }, + { + "id": "1453611521867776", + "rowNo": 19, + "displayStatus": 0, + "horizontalDistance": 22.928686812404784, + "slopeDistance": 23.24072059217002, + "layerNo": null, + "pseudoDepth": -9.6, + "elevationPseudoDepth": 15.769, + "n": 15, + "a": 1, + "b": 25, + "m": 11, + "k": 219.911407, + "vmap": { + "R": 0.112888, + "V": 30.708496, + "I": 272.025665, + "R0_RD": 0, + "SP": -189.315582, + "R0": 24.825411 + }, + "stacking": 1 + }, + { + "id": "1453611521867777", + "rowNo": 20, + "displayStatus": 0, + "horizontalDistance": 22.8941388207831, + "slopeDistance": 23.205240404465737, + "layerNo": null, + "pseudoDepth": -9.6, + "elevationPseudoDepth": 15.835000000000003, + "n": 14, + "a": 1, + "b": 25, + "m": 12, + "k": 449.247803, + "vmap": { + "R": 0.086995, + "V": 23.698902, + "I": 272.416779, + "R0_RD": 0, + "SP": 45.706585, + "R0": 39.082321 + }, + "stacking": 1 + } + ], + "__rowListTotal": 576 + } +} \ No newline at end of file diff --git a/tests/fixtures/dd/ert-measurement-scatter.json b/tests/fixtures/dd/ert-measurement-scatter.json new file mode 100644 index 0000000..91fedd6 --- /dev/null +++ b/tests/fixtures/dd/ert-measurement-scatter.json @@ -0,0 +1,830 @@ +{ + "code": 200, + "msg": "成功", + "data": { + "electrodeList": [ + { + "electrodeNo": 1, + "horizontalDistance": 0, + "slopeDistance": 0, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 26.172 + }, + { + "electrodeNo": 2, + "horizontalDistance": 1.0801616223246715, + "slopeDistance": 1.2459494894830485, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 25.551 + }, + { + "electrodeNo": 3, + "horizontalDistance": 3.1498647133801647, + "slopeDistance": 3.3194843662393243, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 25.425 + }, + { + "electrodeNo": 4, + "horizontalDistance": 4.943098993517602, + "slopeDistance": 5.123837209632222, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 25.625 + }, + { + "electrodeNo": 5, + "horizontalDistance": 7.0998855833076515, + "slopeDistance": 7.280646981934403, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 25.615 + }, + { + "electrodeNo": 6, + "horizontalDistance": 8.912141076960786, + "slopeDistance": 9.092942204649502, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 25.627 + }, + { + "electrodeNo": 7, + "horizontalDistance": 11.045738049466081, + "slopeDistance": 11.227964462398532, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 25.549 + }, + { + "electrodeNo": 8, + "horizontalDistance": 13.093762113301716, + "slopeDistance": 13.276838193512734, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 25.49 + }, + { + "electrodeNo": 9, + "horizontalDistance": 15.033442059887646, + "slopeDistance": 15.264042837022707, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 25.922 + }, + { + "electrodeNo": 10, + "horizontalDistance": 16.955987211873353, + "slopeDistance": 17.266206881606404, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 25.363 + }, + { + "electrodeNo": 11, + "horizontalDistance": 18.929898218381553, + "slopeDistance": 19.24013840565029, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 25.354 + }, + { + "electrodeNo": 12, + "horizontalDistance": 20.971809019338927, + "slopeDistance": 21.282366531758235, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 25.39 + }, + { + "electrodeNo": 13, + "horizontalDistance": 22.92873162714211, + "slopeDistance": 23.240034045135115, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 25.444 + }, + { + "electrodeNo": 14, + "horizontalDistance": 24.816468622227266, + "slopeDistance": 25.12811427717324, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 25.48 + }, + { + "electrodeNo": 15, + "horizontalDistance": 26.92747540642802, + "slopeDistance": 27.241302778689754, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 25.384 + }, + { + "electrodeNo": 16, + "horizontalDistance": 28.68966923369523, + "slopeDistance": 29.007310447092884, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 25.5 + }, + { + "electrodeNo": 17, + "horizontalDistance": 30.880151207446215, + "slopeDistance": 31.19815760682996, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 25.54 + }, + { + "electrodeNo": 18, + "horizontalDistance": 32.7659271758147, + "slopeDistance": 33.084258347213705, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 25.505 + }, + { + "electrodeNo": 19, + "horizontalDistance": 34.71171810948364, + "slopeDistance": 35.03134421012479, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 25.576 + }, + { + "electrodeNo": 20, + "horizontalDistance": 36.644544469129066, + "slopeDistance": 36.96419152342716, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 25.567 + }, + { + "electrodeNo": 21, + "horizontalDistance": 38.31639860471849, + "slopeDistance": 38.640204724654254, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 25.685 + }, + { + "electrodeNo": 22, + "horizontalDistance": 40.713354415497676, + "slopeDistance": 41.050580869331284, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 25.431 + }, + { + "electrodeNo": 23, + "horizontalDistance": 42.28359724152586, + "slopeDistance": 42.626616253671685, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 25.296 + }, + { + "electrodeNo": 24, + "horizontalDistance": 44.00336585439301, + "slopeDistance": 44.351366984010134, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 25.427 + }, + { + "electrodeNo": 25, + "horizontalDistance": 45.95916673864363, + "slopeDistance": 46.30721797496919, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 25.413 + }, + { + "electrodeNo": 26, + "horizontalDistance": 48.03509295173176, + "slopeDistance": 48.385051135303875, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 25.502 + }, + { + "electrodeNo": 27, + "horizontalDistance": 50.00439250362521, + "slopeDistance": 50.35438140854088, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 25.513 + }, + { + "electrodeNo": 28, + "horizontalDistance": 52.15587463692657, + "slopeDistance": 52.50823292847325, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 25.614 + }, + { + "electrodeNo": 29, + "horizontalDistance": 54.31927360220433, + "slopeDistance": 54.671698685762905, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 25.597 + }, + { + "electrodeNo": 30, + "horizontalDistance": 56.070653463392375, + "slopeDistance": 56.42330235616756, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 25.625 + }, + { + "electrodeNo": 31, + "horizontalDistance": 58.3971778197452, + "slopeDistance": 58.820309778079924, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 26.202 + }, + { + "electrodeNo": 32, + "horizontalDistance": 60.267675140394914, + "slopeDistance": 60.69681190346048, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 26.052 + }, + { + "electrodeNo": 33, + "horizontalDistance": 62.3517973628095, + "slopeDistance": 62.78119537057067, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 26.085 + }, + { + "electrodeNo": 34, + "horizontalDistance": 64.26099256115273, + "slopeDistance": 64.69219388061114, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 26.002 + }, + { + "electrodeNo": 35, + "horizontalDistance": 66.23694132533387, + "slopeDistance": 66.66845259815523, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 25.967 + }, + { + "electrodeNo": 36, + "horizontalDistance": 68.50618870757245, + "slopeDistance": 68.9401279010532, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 26.072 + }, + { + "electrodeNo": 37, + "horizontalDistance": 70.12425130226687, + "slopeDistance": 70.55912498577858, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 26.127 + }, + { + "electrodeNo": 38, + "horizontalDistance": 72.45685720369549, + "slopeDistance": 72.90445786010146, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 26.371 + }, + { + "electrodeNo": 39, + "horizontalDistance": 74.330372956236, + "slopeDistance": 74.85313876651416, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 26.907 + }, + { + "electrodeNo": 40, + "horizontalDistance": 75.76722057945078, + "slopeDistance": 76.30859577114809, + "layerNo": 0, + "pseudoDepth": 0, + "elevationPseudoDepth": 27.139 + } + ], + "scatterGraphConf": { + "canFilter": null, + "x": [ + { + "fieldCode": "horizontalDistance", + "name": "平距", + "sheetHeaderNameList": null + }, + { + "fieldCode": "slopeDistance", + "name": "斜距", + "sheetHeaderNameList": null + } + ], + "v": [ + { + "fieldCode": "I", + "name": "电流", + "sheetHeaderNameList": [ + "I(mA)" + ] + }, + { + "fieldCode": "V", + "name": "电压", + "sheetHeaderNameList": [ + "V(mV)" + ] + }, + { + "fieldCode": "R", + "name": "电阻", + "sheetHeaderNameList": [ + "R(Ohm)" + ] + }, + { + "fieldCode": "R0", + "name": "视电阻率", + "sheetHeaderNameList": [ + "R0" + ] + }, + { + "fieldCode": "SP", + "name": "自然电位", + "sheetHeaderNameList": [ + "SP" + ] + }, + { + "fieldCode": "R0_RD", + "name": "电阻误差", + "sheetHeaderNameList": [ + "R0_RD" + ] + } + ], + "y": [ + { + "fieldCode": "Layer No", + "name": "层数", + "sheetHeaderNameList": null + }, + { + "fieldCode": "pseudoDepth", + "name": "伪深度", + "sheetHeaderNameList": null + }, + { + "fieldCode": "elevationPseudoDepth", + "name": "伪深度+高程", + "sheetHeaderNameList": null + } + ], + "vcalculationMethod": [ + { + "fieldCode": null, + "name": "线性", + "sheetHeaderNameList": null + }, + { + "fieldCode": null, + "name": "对数", + "sheetHeaderNameList": null + }, + { + "fieldCode": null, + "name": "导数", + "sheetHeaderNameList": null + } + ] + }, + "scatterGraphData": { + "hasCoordinate": true, + "hasElevation": true, + "min": [ + 2.115013167852418, + 2.2827169278611863, + 0, + -15.6, + -1066.592407, + -1.1950568103363985, + -7538.067239559778, + 0, + "" + ], + "max": [ + 73.39361507996574, + 73.87879831330781, + 0, + -1.2, + 1232.438965, + "NaN", + 15669.56031213764, + 0, + "" + ], + "rows": [ + [ + 2.115013167852418, + 2.2827169278611863, + null, + -1.2, + 242.952988, + 2.385522244678844, + 4.116022643854044, + 0, + "1453611521843200", + 24.288, + 1, + 4, + 2, + 3, + 12.5664, + 1, + 242.952988 + ], + [ + 5.124875148343908, + 5.300065674086864, + null, + -2.4, + 74.753899, + 1.8736338494382652, + 13.377228657999497, + 0, + "1453611521843201", + 23.12, + 1, + 7, + 3, + 5, + 25.1327, + 2, + 74.753899 + ], + [ + 7.994418521491841, + 8.175900836015376, + null, + -3.6, + 100.239388, + 2.001038406459415, + 9.976118369757005, + 0, + "1453611521843202", + 21.987, + 1, + 10, + 4, + 7, + 37.6991, + 3, + 100.239388 + ], + [ + 8.00601333013422, + 8.186794593291953, + null, + -3.6, + 70.582214, + 1.8486952770460858, + 14.16787521003521, + 0, + "1453611521843203", + 22.020999999999997, + 1, + 10, + 5, + 6, + 125.663696, + 4, + 70.582214 + ], + [ + 11.066663821597649, + 11.272344909478555, + null, + -4.8, + 75.202255, + 1.8762308634556513, + 13.297473593045847, + 0, + "1453611521843204", + 20.9685, + 1, + 13, + 5, + 9, + 50.265499, + 5, + 75.202255 + ], + [ + 11.00295159513125, + 11.184890199081117, + null, + -4.8, + 103.542862, + 2.015120165027033, + 9.65783619154742, + 0, + "1453611521843205", + 20.758499999999998, + 1, + 13, + 6, + 8, + 109.955704, + 6, + 103.542862 + ], + [ + 13.92101964767117, + 14.166540305149894, + null, + -6, + 85.264038, + 1.9307658964508176, + 11.728273999877885, + 0, + "1453611521859584", + 19.490499999999997, + 1, + 16, + 6, + 11, + 62.831902, + 7, + 85.264038 + ], + [ + 14.000862630669717, + 14.247085672002468, + null, + -6, + 63.056168, + 1.7997275746100803, + 15.858876803297024, + 0, + "1453611521859585", + 19.456, + 1, + 16, + 7, + 10, + 113.097298, + 8, + 63.056168 + ], + [ + 14.063602086594681, + 14.27044051526772, + null, + -6, + 44.272751, + 1.6461365088096525, + 22.587256888554318, + 0, + "1453611521859586", + 19.706, + 1, + 16, + 8, + 9, + 351.858398, + 9, + 44.272751 + ], + [ + 16.987234838304097, + 17.233999253766825, + null, + -7.2, + 67.065033, + 1.8264961426225779, + 14.91089999165437, + 0, + "1453611521859587", + 18.296499999999998, + 1, + 19, + 7, + 13, + 75.398201, + 10, + 67.065033 + ], + [ + 17.03278556632032, + 17.279602362635487, + null, + -7.2, + 70.816719, + 1.85013580164825, + 14.120959204562979, + 0, + "1453611521859588", + 18.24, + 1, + 19, + 8, + 12, + 120.951302, + 11, + 70.816719 + ], + [ + 16.9816701391346, + 17.2520906213365, + null, + -7.2, + 41.787144, + 1.6210426897024701, + 23.930805129922256, + 0, + "1453611521859589", + 18.438, + 1, + 19, + 9, + 11, + 251.327393, + 12, + 41.787144 + ], + [ + 20.01061875986487, + 20.259070486101244, + null, + -8.4, + 68.505302, + 1.8357241852184725, + 14.597410285119246, + 0, + "1453611521859590", + 17.037, + 1, + 22, + 8, + 15, + 87.9646, + 13, + 68.505302 + ], + [ + 19.924955341057455, + 20.196078557097973, + null, + -8.4, + 76.61187, + 1.8842960630545889, + 13.05280761323278, + 0, + "1453611521859591", + 17.301000000000002, + 1, + 22, + 9, + 14, + 130.690308, + 14, + 76.61187 + ], + [ + 19.94235941950773, + 20.25312046337076, + null, + -8.4, + 58.417942, + 1.7665462531889349, + 17.118028567319268, + 0, + "1453611521859592", + 17.003500000000003, + 1, + 22, + 10, + 13, + 226.194595, + 15, + 58.417942 + ], + [ + 19.95085361886024, + 20.261252468704264, + null, + -8.4, + 151.941925, + 2.1816776243164018, + 6.581461963180999, + 0, + "1453611521859593", + 16.972, + 1, + 22, + 11, + 12, + 691.150391, + 16, + 151.941925 + ], + [ + 22.95679663366693, + 23.231100221926333, + null, + -9.6, + 87.04409, + 1.9397392893244523, + 11.488430748141546, + 0, + "1453611521859594", + 16.131, + 1, + 25, + 9, + 17, + 100.530998, + 17, + 87.04409 + ], + [ + 22.822828222784292, + 23.136758664349642, + null, + -9.6, + 89.94828, + 1.9539928631384818, + 11.117499967759251, + 0, + "1453611521859595", + 15.8315, + 1, + 25, + 10, + 16, + 141.371704, + 18, + 89.94828 + ], + [ + 22.928686812404784, + 23.24072059217002, + null, + -9.6, + 24.825411, + 1.3948964472401078, + 40.28130692378064, + 0, + "1453611521867776", + 15.769, + 1, + 25, + 11, + 15, + 219.911407, + 19, + 24.825411 + ], + [ + 22.8941388207831, + 23.205240404465737, + null, + -9.6, + 39.082321, + 1.5919803474612875, + 25.587016697396244, + 0, + "1453611521867777", + 15.835000000000003, + 1, + 25, + 12, + 14, + 449.247803, + 20, + 39.082321 + ] + ], + "__rowsTotal": 576 + } + } +} \ No newline at end of file