feat/dataset-detail-chart #5

Merged
gaozheng merged 74 commits from feat/dataset-detail-chart into main 2026-06-13 17:30:37 +08:00
49 changed files with 3520 additions and 464 deletions
Showing only changes of commit a00aeb9a56 - Show all commits

View File

@ -0,0 +1,114 @@
# 数据详情「按类型渲染」通用引擎 + 迁移 inversion + measurement_data 实现计划
> 2026-06-12。承接 `2026-06-11-dataset-detail-other-dd-types.md`(Phase 1 策略分派已 done)。
> **决策(用户已拍板=选项2)**:建通用渲染引擎,并把已验收的 `dd_inversion_data` 一起迁到新引擎、删旧专用代码,终态只有一套。
> **铁律**:不确定必用 Playwright 实看原版;严格 1:1;无活样本类型 BLOCKED。
> **工作方式**:subagent-driven + TDD(真实夹具) + 破坏性接口改形原子落地保持构建绿。
## 0. 背景与动机
详情视图有多种 dd 类型,每类页签集 + 图形 kind 不同。现状把"两页签(chartReady/gridReady) + 两个专用 Load 句柄 + 写死 inversion 字段"耦合进三层。要支持 measurement_data / gr_data(柱状) / trajectory(轨迹) / grid(白化) 等且可持续扩展,需围绕「页签描述符」统一。
## 1. Phase 0 实测成果(2026-06-12,真实响应,已抓夹具)
API base:`http://tenant.geomative.cn/pop-api/business`。Auth header:`geomativeauthorization: Geomative <token>`。
同一 TM(project=1438889436225536 / structParentId=1438889842614272)集齐 4 类样本:
| ddCode | dsId 样本 | 端点 | 数据形状 | 页签 → render kind |
|---|---|---|---|---|
| `dd_ert_measurement_data` 原始 | 1453611522236416 | `GET dd/ert/measurement/scatter/graph?dsObjectId=&vFieldCode=`(图) + `GET dd/ert/measurement/rows?dsObjectId=`(列表) | 图:`{electrodeList[40], scatterGraphConf{x[2],v[6],y[3],vcalculationMethod[3]}, scatterGraphData{hasCoordinate,hasElevation,min[9],max[9],rows[576]位置数组}}`;列表:`{filedList[12], rowList[576]键控对象,含 vmap{R,V,I,R0_RD,SP,R0}}` | 散点伪剖面=Scatter + 列表=Table |
| `dd_ert_measurement_gr_data` 接地电阻 | 1453611522285569 | `GET dd/ert/measurement/gr/rows?dsObjectId=` | `array[40]` of `{electrodeId,testDate,testTime,p1Rg,p1RgStatus,p2Rg,p2RgStatus}` | 柱状图=Bar(X=electrodeId,Y=电阻Ω,系列P1) + 列表=Table |
| `dd_trajectory_data` 坐标 | 1438893961060352 | `GET dd/ert/trajectory/rows?dsObjectId=`(+`/line?…&frontCrsCode` 400 需参数) | `{rowList[40]{electrodeNo,projectX,projectY,longitude,latitude,elevation,lineNo,channelNo,…}, gridHeaderDisplay[14]}` | 地图=PolylineMap(平面折线,非GIS底图) + 列表=Table + 高程=LineProfile |
| `dd_grid` 白化 | 1438944148742207 | `GET dd/ert/grid/rows?dsObjectId=&pageNo=&pageSize=` | `{rowList[N]{x,y,id}, gridHeaderDisplay[2], total}` **分页** | 列表=Table(仅,分页) |
夹具:`tests/fixtures/dd/ert-measurement-scatter.json`、`ert-measurement-rows.json`(真实响应,rows 裁剪至 20,保 conf/min/max/electrodeList 全量)。
**散点位置数组列序(rows[i],与键控 rows 比对得)**:`[0]平距 [1]斜距 [2]layerNo(null) [3]伪深度(负,向下) [4]选中v原值 [5]log10(值) [6]导数态 [7]0 [8]id(str) [9]高程伪深度 [10]a [11]b [12]m [13]n [14]K [15]rowNo [16]选中v值(=色)`。默认 vFieldCode 空 → v=视电阻率 R0;x=平距;y=伪深度。色阶 cauto(同反演原数据,按 v 有限值 min/max 归一化)。
**渲染 kind 全集**:Scatter / FilledContour / Bar / LineProfile / PolylineMap / Table /(未来 Image,GPR BLOCKED)。
**通用 Table**:`{gridHeaderDisplay 或 filedList(列定义) + rowList(对象)}` 形状被 measurement列表/grid白化/trajectory列表共用 → 一个 `DataTableView` + 一个 `parseTable` 通吃(分页与列格式化为 parse-time/decorator,见 §4)。
## 2. 目标架构
依赖箭头:`controller(ViewKind/TabSpec/Controller) ← app/strategies, app/views ; data(DetailLoad/Repo) 产出 QVariant 载荷`。
### 2.1 载荷(payload)——`src/model/detail/DetailPayloads.hpp`(纯数据,无 Qt-widget 依赖,Q_DECLARE_METATYPE)
- `ScatterPayload { core::ScatterField scatter; core::ColorScale scale; }`(反演原数据 + measurement 散点共用;≈现 `ChartParts`)
- `ContourPayload { core::Grid grid; core::ColorScale scale; std::vector<core::Anomaly> anomalies; }`(≈现 `GridParts`)
- `TablePayload { std::vector<TableColumn> columns; std::vector<std::vector<QString>> rows; int total = 0; }`;`TableColumn { QString code, title; int width; int sort; }`
- (Bar/LineProfile/Polyline 载荷待对应类型阶段再加,YAGNI)
### 2.2 页签描述符 + 策略——`src/controller/DatasetDetailTab.hpp` + `IDatasetChartStrategy.hpp`
```cpp
enum class ViewKind { Scatter, FilledContour, Bar, LineProfile, PolylineMap, Table /*,Image*/ };
struct TabSpec { QString title; ViewKind kind; QString loaderKey; bool lazy=false; bool paginated=false; };
struct IDatasetChartStrategy { virtual std::string ddCode() const=0; virtual std::vector<TabSpec> tabs() const=0; }; // tabs() 取代 hasGridPhase()
```
`ChartStrategyRegistry` 不变。`ErtInversionStrategy::tabs()` = `{{"原数据",Scatter,"inversion.scatter",false},{"网格数据",FilledContour,"inversion.grid",true}}`
### 2.3 通用异步句柄——`src/data/api/DatasetLoadHandles.*`
合并 `ChartLoad`/`GridLoad` → 单 `DetailLoad{ virtual void abort(); signals: done(QVariant); failed(QString); }` + `ApiDetailLoad`(包 ApiBatch + Parser=`std::function<QVariant(const QList<net::ApiResponse>&)>`)。**aborted_ 守卫 + 双 try/catch→failed + deleteLater 三件套从 ApiChartLoad 原样照搬**。删 `ChartParts`/`GridParts`(被 payload 取代)或保留作中间态(由实现者定,优先删以免双份)。
### 2.4 仓储——`IAsyncDatasetRepository::loadAsync(loaderKey,dsId)` + `ApiDatasetRepository` 分派表
唯一端点+解析器集中处。`if (key=="inversion.scatter") return makeInversionScatter(dsId); …`;未知 key 抛。每 make 建 ApiBatch + 注入返回 `QVariant::fromValue(payload)` 的解析器。删 `loadChartAsync/loadGridAsync`
> measurement scatter 的 `dsObjectId`**query 参数**(`?dsObjectId=&vFieldCode=`,默认 vFieldCode 空),≠反演 path 参数;用现有 `enc()`
### 2.5 控制器——`DatasetDetailController` 变通用(无 per-ddCode if)
```cpp
slots: openDataset(dsId,ddCode,dsName), loadTab(dsId,ddCode,tabIndex), focusDataset(dsId);
signals: datasetOpened(dsId,dsName, std::vector<TabSpec>), tabLoadStarted(dsId,tabIndex),
tabReady(dsId,tabIndex,QVariant), loadFailed(dsId,msg), focusRequested(dsId);
```
`openDataset`:查 registry,无→`loadFailed("暂不支持…")`(降级不变);`emit datasetOpened(...,strategy->tabs())`;对每个非 lazy 页签 `loadTab`
`loadTab`:**唯一保存 §5.0 安全不变量处**,按 tab 槽位:`inflight_`=`QMap<int,QPointer<DetailLoad>>`;abort 旧槽 → 新 load → `tabLoadStarted` → done 回调里 `if (load!=inflight_.value(i)) return;`(身份比对)→ `tabReady`。析构 abort 全部。删 `chartReady/gridReady/ChartData/GridData/loadGridData`
### 2.6 视图 + 工厂 + 壳
- `src/app/panels/chart/IDetailView.hpp`:`{ QWidget* widget(); void setPayload(const QVariant&); }`。
- `RawDataChartView`/`GridDataChartView` 实现 IDetailView:`setPayload` 解包 `ScatterPayload`/`ContourPayload` → 调原 `setData(...)`(**渲染代码零改**)。坏/空 variant → 可见"渲染数据格式错误"不崩。
- `DetailViewFactory.{hpp,cpp}`:`makeDetailView(ViewKind,parent)` switch → 对应视图。
- `DatasetDetailPage`:`build(tabs)` 按 TabSpec 建页签(`buildTabbedPanel` + 工厂);lazy 页签首次激活发 `tabNeeded(dsId,ddCode,i)`(泛化现 gridDataNeeded);`setTabPayload(i,QVariant)` → `views_[i]->setPayload`。删写死 `rawView_/gridView_/kGridTabIndex`
- `DatasetDetailPanel`:`onDatasetOpened(dsId,dsName,tabs)` 建 page→build;按 dsId 转发 tabReady/tabNeeded。dsId 去重壳逻辑不动。
- `main.cpp`:接线改 datasetOpened/tabReady/tabNeeded/tabLoadStarted;registry 注册不变(后续加 MeasurementStrategy)。**与控制器改形同提交(原子)。**
## 3. 实施序列(任务)
### E1a 引擎地基(加性,构建保持绿) [task #1]
新增 payloads / TabSpec/ViewKind / DetailLoad+ApiDetailLoad(与旧句柄并存) / `loadAsync` 分派(inversion.scatter/grid 产 payload),与旧 `loadChartAsync/loadGridAsync` 并存。TDD:DTO→payload、句柄桩(`tests/data/test_dataset_load_handles.cpp` 仿现有桩)、loadAsync 分派。**构建+测试全绿,旧路径不动。**
### E1b 破坏性改形 + inversion 迁移(原子) [task #2]
控制器/策略接口/视图 IDetailView/工厂/Page/Panel/main.cpp 全部切到 tab 引擎;删旧 `chartReady/gridReady/ChartData/GridData/ChartLoad/GridLoad/loadChartAsync/loadGridAsync/hasGridPhase`。改全部相关测试(controller/strategy_registry/handles)。**一个提交内落地,构建+测试绿 + 跑 app 双击标准 ds `1458990939709440` 目视回归两页签(散点+网格等值面+异常)正常。**
### E2 measurement_data(③散点伪剖面 + 列表) [task #3]
**Phase 0 色阶补查(2026-06-12,已坐实,夹具齐)**:measurement 散点**有 colorBar**,来自 `POST lvl/colorGradation/getDetail` body **`{dsObjectId, businessCode:"<vFieldCode,默认R0>", type:3}`**(注意:businessCode=v字段码、type=3,≠反演的 type1/2;用 businessCode='' 会返回 null)。响应是**与反演同构的混合格式 colorBar**(hex + rgba alpha 01,18 个对数间距 stops `0.00→#00008B … 208.40→#FF0000 … 1323.20→rgba(48,0,48,1)`,`lvlSchemeType:normal`/equalAreaLayerCount/labelConfig/lineConfig)——**现有 `DatasetChartDto` colorBar 解析器(AlphaScale::Unit)直接可用**。截图核对:散点按 v 值**绝对值离散映射**到 colorBar(同反演**网格** `colorAtDiscrete`,**非**反演原数据的连续 cauto),右侧 ColorBarWidget 显示离散级。夹具:`tests/fixtures/dd/ert-measurement-{scatter,rows,colorbar}.json`;原版截图 `meas-scatter-original.jpeg`
**⚠️ 着色模型修正(2026-06-12 读原版 Plotly `_fullData` 实测,推翻"离散"判断)**:原版是 **Plotly scattergl**,散点**连续 cauto 着色**(非离散!):`marker.color`=原始 v 值(col4,**含负值**,本例范围 -1066.59~1232.44);`cauto:true` → `cmin/cmax`=数据 v 的真实 min/max(**含负离群值**);`colorscale`=连续 18 段,**position=colorBar值/maxColorBar值(1323.20)**;上色=`norm(v)=(v-cmin)/(cmax-cmin)` 查 colorscale 连续插值。验证 v=50.18→norm 0.4857→暗紫(stop 0.358 与 0.540 之间)。**这与 inversion 原数据完全同路(交接 §0.2:cauto 数据范围 + colorscale 位置 val/maxVal),measurement 必须用 `discreteColor=false` 走同一条已验收路径**,而非我 E2 误用的 colorAtDiscrete。负离群值撑大数据范围 → R0=25~250 全挤色阶中段暗紫,故原版几乎全暗紫(仅极端值现橙/蓝)。colorBar 图例(竖排右侧离散色块标 0.00~1323.20)独立于 cauto,显示 colorBar 自身 value→color。hover=浮动黑底框 X/Y/Value/a/b/m/n(另有底部 footer A=B=M=N=DataRow=Pseu_Resis=)。
**散点渲染规格(截图确认)**:x=**斜距 slopeDistance**(toolbar 默认,col1;≈平距,几乎不可辨,存疑标用户核对)、y=**伪深度 pseudoDepth**(col3,负向下)、色=**视电阻率 R0**(col16,离散 colorBar)、x 轴在**顶部**(同反演原数据)、y=0 处画 **40 个灰色菱形=电极位置**(electrodeList 的 x)、hover 显示 `A= B= M= N= DataRow= Pseu_Resis=`(col10-13=A/B/M/N + 值)。工具条(散点图/数据列表 切换、显示/隐藏、数据过滤、x/y/v/计算方法下拉、色阶配置、生成视电阻率、反演运算、另存为、导出)=**范围外**,MVP 只渲染默认静态视图。
- 端点:scatter `GET dd/ert/measurement/scatter/graph?dsObjectId=&vFieldCode=`(空=默认R0)+ colorBar `POST lvl/colorGradation/getDetail{dsObjectId,businessCode:"R0",type:3}`;列表 `GET dd/ert/measurement/rows?dsObjectId=`。loaderKey `ert_measurement.scatter` 用 ApiBatch 组 scatter+colorBar 两请求(类比反演 loadChartAsync 的 scatter+type1)。
- `src/data/dto/MeasurementDto.{hpp,cpp}`:`parseMeasurementScatter(scatterJson, colorBarJson)→ScatterPayload`(位置数组 rows:x=col1 斜距/y=col3 伪深度/色值=col16;colorBar 复用现有解析器→离散 ColorScale;电极 electrodeList→灰菱形,可放 ScatterPayload 扩展或单列);`parseTable(QJsonObject)→TablePayload`(通用:列来自 `gridHeaderDisplay``filedList`,值预格式化为 QString;measurement 的 `vmap` 由解析器摊平为列——按 filedList 列码取 vmap[code])。
- `src/app/panels/chart/DataTableView.{hpp,cpp}`:IDetailView + QTableView + QAbstractTableModel(由 TablePayload 构),列宽/标题/排序来自 TableColumn。
- `src/app/strategies/MeasurementStrategy.hpp`:ddCode=`dd_ert_measurement_data`,tabs=`{{"散点伪剖面",Scatter,"ert_measurement.scatter",false},{"列表",Table,"ert_measurement.rows",true}}`。
- `ApiDatasetRepository`:加 `ert_measurement.scatter`(scatter/graph,query 参数 + 默认 vFieldCode 空)、`ert_measurement.rows`(measurement/rows)loaderKey。
- `DetailViewFactory`:加 `Table` case。`main.cpp`:注册 MeasurementStrategy 一行。
- TDD 用 `tests/fixtures/dd` 夹具:`tests/data/test_measurement_dto.cpp`(散点位置解码/y 负号/列提取/vmap 摊平) + 控制器 2 页签谱 + loadAsync 分派。
- 构建+测试绿 + 跑 app 双击「ERT原始数据」对照原版散点伪剖面 + 列表目视核对。
## 4. 风险与裁断(architect 评估)
1. **不要过度泛化控制器信号面**:`tabReady(dsId,i,QVariant)` 单信号已吸收全部近期形状(类型擦除)。激进泛化"句柄/载荷"(合一 DetailLoad/QVariant,是纯去重),但控制器信号保持扁平最小。分页的 `pageNo` 等真做 dd_grid 再加一个默认参,加性无重构。
2. **QVariant 类型擦除以编译期安全换扩展性**:错的 loaderKey→ViewKind→payload 三元组是运行期 cast miss 而非编译错。缓解:downcast 仅在每视图 `setPayload` 一处;坏 variant 出可见错误不崩(配合现 GuardedApplication::notify);每策略一条 round-trip 测试。接受此权衡(否则 N×3 个 XxxLoad/XxxReady/XxxData 爆炸)。
3. **不要提前耦合 BLOCKED 的 GPR/radar**:引擎结构上可容纳(加 ViewKind::Image/GisMap + 新视图+解析器+loaderKey,控制器/句柄零改),故不被锁死;但现在不加任何 enum 值/桩(1:1 + 无样本)。trajectory 的 PolylineMap 是金丝雀,证明非图非表视图也能套进引擎。
**通用 Table 的边界**:list 形状(measurement/trajectory/grid)共用对;但 ① 分页(dd_grid 独有)= `PaginatedTableView` 装饰器或 spec `paginated` 标志 + page 发 `loadTab(...,pageNo)`,**基类 DataTableView 保持哑**;② 列格式化/值语义(measurement 的 vmap 摊平、gr 的 status 标志着色)= **parse-time** 处理(TablePayload 存预格式化 QString),需要彩色状态格时再加 `TableColumn.kind` 提示(YAGNI)。bar 不是表复用——gr_data 主页签是 Bar,列表页签才复用 parseTable。
## 5. 安全不变量(spec §5.0,务必保留)
"abort 后绝不回灌"三件套:① 每层 `aborted_` 入口守卫;② 控制器句柄身份比对(`if (load!=inflight_.value(i)) return;`);③ 一律 `deleteLater`。错误判定:业务 `code!=200 || !rawError.isEmpty()`(HTTP 200 也可能 code=500)。迁移后这三件套从"按 Chart/Grid 阶段"改"按页签槽位",逐字保留,不得重造削弱。
## 6. 构建/测试命令
- 构建:`powershell.exe -ExecutionPolicy Bypass -File scripts/dev-build.ps1`(LNK1104 先 `Get-Process geopro_desktop|Stop-Process -Force`)。
- 测试:`powershell.exe -ExecutionPolicy Bypass -File scripts/dev-test.ps1`(先 build)。基线 **122/122 绿**
- 单测过滤:`build/release/tests/geopro_tests.exe --gtest_filter=<Suite>.*`。

View File

@ -29,6 +29,8 @@ add_executable(geopro_desktop WIN32
panels/DescriptionPanel.cpp panels/DescriptionPanel.cpp
panels/chart/RawDataChartView.cpp panels/chart/RawDataChartView.cpp
panels/chart/GridDataChartView.cpp panels/chart/GridDataChartView.cpp
panels/chart/DataTableView.cpp
panels/chart/DetailViewFactory.cpp
panels/chart/ChartTheme.cpp panels/chart/ChartTheme.cpp
panels/chart/ColorMapService.cpp panels/chart/ColorMapService.cpp
panels/chart/ColorBarWidget.cpp panels/chart/ColorBarWidget.cpp

View File

@ -85,6 +85,7 @@
#include "WorkbenchNavController.hpp" #include "WorkbenchNavController.hpp"
#include "DatasetDetailController.hpp" #include "DatasetDetailController.hpp"
#include "panels/chart/ErtInversionStrategy.hpp" #include "panels/chart/ErtInversionStrategy.hpp"
#include "panels/chart/MeasurementStrategy.hpp"
#include "api/ApiProjectRepository.hpp" #include "api/ApiProjectRepository.hpp"
#include "api/ApiDatasetRepository.hpp" #include "api/ApiDatasetRepository.hpp"
#include "panels/ObjectTreePanel.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); if (!dsId.isEmpty()) detailCtrl.openDataset(dsId, ddCode, dsName);
}); });
// ── 控制器信号 → 详情面板:数据就绪开页 / 聚焦请求 ── // ── 控制器信号 → 详情面板tab 引擎):开页 / 页签就绪 / 加载中 / 聚焦 ──
QObject::connect( QObject::connect(&detailCtrl, &geopro::controller::DatasetDetailController::datasetOpened,
&detailCtrl, &geopro::controller::DatasetDetailController::chartReady, detailPanel, detailPanel, &geopro::app::DatasetDetailPanel::onDatasetOpened);
[detailPanel](const geopro::controller::DatasetDetailController::ChartData& d) { QObject::connect(&detailCtrl, &geopro::controller::DatasetDetailController::tabReady,
detailPanel->openOrUpdate(d); detailPanel, &geopro::app::DatasetDetailPanel::onTabReady);
}); QObject::connect(&detailCtrl, &geopro::controller::DatasetDetailController::tabLoadStarted,
detailPanel, &geopro::app::DatasetDetailPanel::onTabLoadStarted);
QObject::connect(&detailCtrl, &geopro::controller::DatasetDetailController::focusRequested, QObject::connect(&detailCtrl, &geopro::controller::DatasetDetailController::focusRequested,
detailPanel, [detailPanel](const QString& dsId) { detailPanel, &geopro::app::DatasetDetailPanel::focusDataset);
detailPanel->focusDataset(dsId); // ── 页签懒加载lazy 页签首次激活 → 控制器按 (dsId,ddCode,tabIndex) 拉载荷 → 回填 ──
}); QObject::connect(detailPanel, &geopro::app::DatasetDetailPanel::tabNeeded, &detailCtrl,
// ── 网格数据懒加载:网格页首次激活 → 拉 rows+色阶type2+异常 → 回填对应页 ── &geopro::controller::DatasetDetailController::loadTab);
QObject::connect(detailPanel, &geopro::app::DatasetDetailPanel::gridDataNeeded, &detailCtrl, // context 用 detailPanel析构即自动断连避免野指针。window 比 detailPanel 活得久,
&geopro::controller::DatasetDetailController::loadGridData); // 捕 &window 取状态栏安全。失败时清该页 lazy 遮罩(幂等)并状态栏提示。
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 失败时正确清遮罩。
QObject::connect(&detailCtrl, &geopro::controller::DatasetDetailController::loadFailed, detailPanel, QObject::connect(&detailCtrl, &geopro::controller::DatasetDetailController::loadFailed, detailPanel,
[&window, detailPanel](const QString& dsId, const QString& msg) { [&window, detailPanel](const QString& dsId, const QString& msg) {
detailPanel->setGridLoading(dsId, false); detailPanel->onLoadFailed(dsId, msg);
window.statusBar()->showMessage( window.statusBar()->showMessage(
QStringLiteral("数据集 %1 加载失败:%2").arg(dsId, msg), 5000); QStringLiteral("数据集 %1 加载失败:%2").arg(dsId, msg), 5000);
}); });
@ -924,6 +911,7 @@ int main(int argc, char* argv[])
// 策略注册表须比 detailCtrl 活得久(同作用域、在前声明)。 // 策略注册表须比 detailCtrl 活得久(同作用域、在前声明)。
geopro::controller::ChartStrategyRegistry chartRegistry; geopro::controller::ChartStrategyRegistry chartRegistry;
chartRegistry.add(std::make_unique<geopro::app::ErtInversionStrategy>()); chartRegistry.add(std::make_unique<geopro::app::ErtInversionStrategy>());
chartRegistry.add(std::make_unique<geopro::app::MeasurementStrategy>());
geopro::controller::DatasetDetailController detailCtrl(datasetRepo, chartRegistry); geopro::controller::DatasetDetailController detailCtrl(datasetRepo, chartRegistry);
// ── 外壳:标准 QMainWindow原生标题栏。buildWorkbench 直接用其 // ── 外壳:标准 QMainWindow原生标题栏。buildWorkbench 直接用其

View File

@ -6,63 +6,78 @@
#include "Glyphs.hpp" #include "Glyphs.hpp"
#include "PanelHeader.hpp" #include "PanelHeader.hpp"
#include "panels/LoadingOverlay.hpp" #include "panels/LoadingOverlay.hpp"
#include "panels/chart/GridDataChartView.hpp" #include "panels/chart/DetailViewFactory.hpp"
#include "panels/chart/RawDataChartView.hpp" #include "panels/chart/IDetailView.hpp"
namespace geopro::app { namespace geopro::app {
namespace {
constexpr int kGridTabIndex = 1; // 「网格数据」页签在 tabs 中的索引
}
DatasetDetailPage::DatasetDetailPage(QWidget* parent) : QWidget(parent) { DatasetDetailPage::DatasetDetailPage(QWidget* parent) : QWidget(parent) {
auto* lay = new QVBoxLayout(this); auto* lay = new QVBoxLayout(this);
lay->setContentsMargins(0, 0, 0, 0); lay->setContentsMargins(0, 0, 0, 0);
lay->setSpacing(0); lay->setSpacing(0);
}
rawView_ = new RawDataChartView(this); void DatasetDetailPage::build(const QString& dsId, const QString& ddCode, const QString& dsName,
gridView_ = new GridDataChartView(this); const std::vector<geopro::controller::TabSpec>& tabs) {
gridOverlay_ = new LoadingOverlay(gridView_); // 父为 gridView_随其尺寸覆盖网格图区 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<PanelTab> tabs = { // 按 TabSpec 经工厂造视图,并组装为带表头页签的面板。
{Glyph::Detail, QStringLiteral("原数据"), rawView_, false}, QVector<PanelTab> panelTabs;
{Glyph::Dataset, QStringLiteral("网格数据"), gridView_, false}, for (size_t i = 0; i < tabs.size(); ++i) {
}; const auto& spec = tabs[i];
auto view = makeDetailView(spec.kind, this); // 抛出由调用栈兜底GuardedApplication
IDetailView* raw = view.release(); // QWidget 由 this/QwtPlot 父子树接管生命周期
views_[i] = raw;
// lazy 页签:建覆盖该视图的加载遮罩(父为视图 widget随其尺寸覆盖图区
if (spec.lazy) overlays_[static_cast<int>(i)] = new LoadingOverlay(raw->widget());
panelTabs.append({Glyph::Detail, spec.title, raw->widget(), false});
}
const QVector<HeaderAction> actions = { const QVector<HeaderAction> actions = {
{Glyph::Download, QStringLiteral("导出")}, {Glyph::Download, QStringLiteral("导出")},
}; };
auto tabbedPanel = buildTabbedPanel(tabs, actions); auto tabbedPanel = buildTabbedPanel(panelTabs, actions);
lay->addWidget(tabbedPanel.container); layout()->addWidget(tabbedPanel.container);
// 「网格数据」页签首次激活 → 懒加载网格数据rows 服务端网格化慢,故不随开页同步拉)。 // lazy 页签首次激活 → 发 tabNeeded 请求懒加载(数据慢,故不随开页同步拉)。
if (tabbedPanel.tabGroup) { if (tabbedPanel.tabGroup) {
connect(tabbedPanel.tabGroup, &QButtonGroup::idClicked, this, [this](int idx) { connect(tabbedPanel.tabGroup, &QButtonGroup::idClicked, this, [this](int idx) {
if (idx != kGridTabIndex || gridRequested_ || dsId_.isEmpty()) return; if (idx < 0 || idx >= static_cast<int>(tabs_.size())) return;
gridRequested_ = true; if (!tabs_[static_cast<size_t>(idx)].lazy) return;
emit gridDataNeeded(dsId_, ddCode_); if (requested_[static_cast<size_t>(idx)] || loaded_[static_cast<size_t>(idx)]) return;
if (dsId_.isEmpty()) return;
requested_[static_cast<size_t>(idx)] = true;
emit tabNeeded(dsId_, ddCode_, idx);
}); });
} }
} }
void DatasetDetailPage::setData(const geopro::controller::DatasetDetailController::ChartData& d) { void DatasetDetailPage::setTabPayload(int tabIndex, const QVariant& payload) {
dsId_ = d.dsId; if (tabIndex < 0 || tabIndex >= static_cast<int>(views_.size())) return;
ddCode_ = d.ddCode; if (auto* v = views_[static_cast<size_t>(tabIndex)]) v->setPayload(payload);
gridRequested_ = false; // 新数据集 → 网格数据需重新按需加载 loaded_[static_cast<size_t>(tabIndex)] = true;
rawView_->setData(d); requested_[static_cast<size_t>(tabIndex)] = true; // 已加载,切回不再重复请求
gridView_->setData(d); setTabLoading(tabIndex, false); // 数据到达,隐藏遮罩
setGridLoading(false); // 重开/换 ds重置遮罩状态避免上次的「加载中」残留
} }
void DatasetDetailPage::setGridData( void DatasetDetailPage::setTabLoading(int tabIndex, bool on) {
const geopro::controller::DatasetDetailController::GridData& d) { auto it = overlays_.find(tabIndex);
gridRequested_ = true; // 已加载,切回网格页不再重复请求 if (it == overlays_.end()) return; // 非 lazy 页签无遮罩
gridView_->setGridData(d.grid, d.gridScale, d.anomalies); if (on) it.value()->showOver(); else it.value()->hide();
setGridLoading(false); // 数据到达,隐藏遮罩
} }
void DatasetDetailPage::setGridLoading(bool on) { void DatasetDetailPage::clearAllLoadingOverlays() {
if (on) gridOverlay_->showOver(); else gridOverlay_->hide(); for (auto* overlay : overlays_)
if (overlay) overlay->hide();
} }
} // namespace geopro::app } // namespace geopro::app

View File

@ -1,35 +1,51 @@
#pragma once #pragma once
#include <vector>
#include <QMap>
#include <QVariant>
#include <QWidget> #include <QWidget>
#include "DatasetDetailController.hpp" #include "DatasetDetailTab.hpp" // geopro::controller::TabSpec
namespace geopro::app { namespace geopro::app {
class RawDataChartView; class IDetailView;
class GridDataChartView;
class LoadingOverlay; class LoadingOverlay;
// 单个数据集详情页:下划线页签「原数据 / 网格数据」+ 右侧「导出」操作。 // 单个数据集详情页:按策略 tabs() 动态建页签 + 右侧「导出」操作。
// 内部分别由 RawDataChartView / GridDataChartView 实现各自三层布局 // 每页签由工厂造的 IDetailView 承载lazy 页签首次激活时发 tabNeeded 请求懒加载
class DatasetDetailPage : public QWidget { class DatasetDetailPage : public QWidget {
Q_OBJECT Q_OBJECT
public: public:
explicit DatasetDetailPage(QWidget* parent = nullptr); explicit DatasetDetailPage(QWidget* parent = nullptr);
void setData(const geopro::controller::DatasetDetailController::ChartData& d);
// 网格数据到达(懒加载结果)→ 下发给 GridDataChartView 并标记已加载。 // 按页签集构建页签首次打开调一次。dsId/ddCode/dsName 用于 tabNeeded。
void setGridData(const geopro::controller::DatasetDetailController::GridData& d); void build(const QString& dsId, const QString& ddCode, const QString& dsName,
// 网格懒加载进行中(true)/结束(false)时切换遮罩显隐。 const std::vector<geopro::controller::TabSpec>& tabs);
void setGridLoading(bool on);
// 页签载荷到达 → 下发给对应视图并标记已加载、隐藏遮罩。
void setTabPayload(int tabIndex, const QVariant& payload);
// 页签加载进行中 → 对 lazy 页签显示遮罩(非 lazy 页签无遮罩,幂等忽略)。
void setTabLoading(int tabIndex, bool on);
// 清掉本页全部加载遮罩(失败兜底用,不假设页签数;幂等)。
void clearAllLoadingOverlays();
QString dsId() const { return dsId_; } QString dsId() const { return dsId_; }
int tabCount() const { return static_cast<int>(tabs_.size()); }
signals: signals:
// 「网格数据」页签首次激活且本页网格数据未加载 → 请求懒加载。 // lazy 页签首次激活且未加载 → 请求懒加载。
void gridDataNeeded(const QString& dsId, const QString& ddCode); void tabNeeded(const QString& dsId, const QString& ddCode, int tabIndex);
private: private:
QString dsId_; QString dsId_;
QString ddCode_; QString ddCode_;
bool gridRequested_ = false; // 已请求过(避免重复发信号) QString dsName_;
RawDataChartView* rawView_; std::vector<geopro::controller::TabSpec> tabs_;
GridDataChartView* gridView_; // 与 tabs_ 同序。每个 IDetailView 持有的 QWidget 经 build() 以 this 为父接管,
LoadingOverlay* gridOverlay_; // 网格懒加载期间覆盖 gridView_ 的遮罩 // 生命周期由 Qt 父子树清理(不在此 deletebuild() 仅调用一次(见其断言)。
std::vector<IDetailView*> views_;
std::vector<bool> loaded_; // 各页签是否已加载(避免重复请求)
std::vector<bool> requested_; // lazy 页签是否已请求过
QMap<int, LoadingOverlay*> overlays_; // lazy 页签的加载遮罩(覆盖该视图)
}; };
} // namespace geopro::app } // namespace geopro::app

View File

@ -18,26 +18,35 @@ DatasetDetailPage* DatasetDetailPanel::pageFor(const QString& dsId) const {
return nullptr; return nullptr;
} }
void DatasetDetailPanel::openOrUpdate(const geopro::controller::DatasetDetailController::ChartData& d) { void DatasetDetailPanel::onDatasetOpened(const QString& dsId, const QString& ddCode,
auto* p = pageFor(d.dsId); const QString& dsName,
const std::vector<geopro::controller::TabSpec>& tabs) {
auto* p = pageFor(dsId);
if (!p) { if (!p) {
p = new DatasetDetailPage(this); 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); const int idx = addTab(p, title);
setTabToolTip(idx, title); // 名称过长被省略时悬停可见全名 setTabToolTip(idx, title); // 名称过长被省略时悬停可见全名
// 页内「网格数据」页签首次激活 → 冒泡为面板信号(外部接控制器懒加载)。 // 页内 lazy 页签首次激活 → 冒泡为面板信号(外部接控制器 loadTab)。
connect(p, &DatasetDetailPage::gridDataNeeded, this, &DatasetDetailPanel::gridDataNeeded); connect(p, &DatasetDetailPage::tabNeeded, this, &DatasetDetailPanel::tabNeeded);
} }
p->setData(d);
setCurrentWidget(p); setCurrentWidget(p);
} }
void DatasetDetailPanel::setGridData(const geopro::controller::DatasetDetailController::GridData& d) { void DatasetDetailPanel::onTabReady(const QString& dsId, int tabIndex, const QVariant& payload) {
if (auto* p = pageFor(d.dsId)) p->setGridData(d); 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) { void DatasetDetailPanel::focusDataset(const QString& dsId) {
if (auto* p = pageFor(dsId)) setCurrentWidget(p); if (auto* p = pageFor(dsId)) setCurrentWidget(p);
} }

View File

@ -1,21 +1,29 @@
#pragma once #pragma once
#include <vector>
#include <QTabWidget> #include <QTabWidget>
#include "DatasetDetailController.hpp" #include <QVariant>
#include "DatasetDetailTab.hpp" // geopro::controller::TabSpec
namespace geopro::app { namespace geopro::app {
class DatasetDetailPage; class DatasetDetailPage;
// 多 Tab 壳:每数据集一页(按 dsId 去重)。R095。 // 多 Tab 壳:每数据集一页(按 dsId 去重)。R095。tab 引擎版。
class DatasetDetailPanel : public QTabWidget { class DatasetDetailPanel : public QTabWidget {
Q_OBJECT Q_OBJECT
public: public:
explicit DatasetDetailPanel(QWidget* parent = nullptr); explicit DatasetDetailPanel(QWidget* parent = nullptr);
void openOrUpdate(const geopro::controller::DatasetDetailController::ChartData& d); // 双击/数据到达
void setGridData(const geopro::controller::DatasetDetailController::GridData& d); // 网格数据懒加载到达 // 数据集打开find-or-create 页 → build(tabs) → 加/抬该面板页签。
void setGridLoading(const QString& dsId, bool on); // 网格懒加载进行中/结束 → 转发给对应页 void onDatasetOpened(const QString& dsId, const QString& ddCode, const QString& dsName,
const std::vector<geopro::controller::TabSpec>& tabs);
void onTabReady(const QString& dsId, int tabIndex, const QVariant& payload);
void onTabLoadStarted(const QString& dsId, int tabIndex);
void onLoadFailed(const QString& dsId, const QString& message);
void focusDataset(const QString& dsId); // 单击聚焦已开页 void focusDataset(const QString& dsId); // 单击聚焦已开页
signals: signals:
void activeDatasetChanged(const QString& dsId); // 反向联动数据集列表 void activeDatasetChanged(const QString& dsId); // 反向联动数据集列表
void gridDataNeeded(const QString& dsId, const QString& ddCode); // 网格页首次激活 → 请求懒加载 void tabNeeded(const QString& dsId, const QString& ddCode, int tabIndex); // lazy 页首激活 → 懒加载
private: private:
DatasetDetailPage* pageFor(const QString& dsId) const; DatasetDetailPage* pageFor(const QString& dsId) const;
}; };

View File

@ -9,10 +9,16 @@ namespace geopro::app {
static constexpr int kBarHeight = 18; // 色带高度px static constexpr int kBarHeight = 18; // 色带高度px
static constexpr int kTickHeight = 4; // 刻度短线高度px static constexpr int kTickHeight = 4; // 刻度短线高度px
static constexpr int kFontSize = 9; // 刻度字号 static constexpr int kFontSize = 9; // 刻度字号
static constexpr int kVBarWidth = 16; // 竖条色带宽度px
static constexpr int kVLabelGap = 4; // 竖条值标签与色带间距px
ColorBarWidget::ColorBarWidget(QWidget* parent) ColorBarWidget::ColorBarWidget(QWidget* parent, Orientation orient)
: QWidget(parent) { : QWidget(parent), orient_(orient) {
setFixedHeight(36); if (orient_ == Orientation::Vertical) {
setFixedWidth(64); // 竖条 + 左侧值标签
} else {
setFixedHeight(36);
}
// 主题热切换:底色/边框/刻度字跟随主题重绘(色带本身=数据色,不变)。 // 主题热切换:底色/边框/刻度字跟随主题重绘(色带本身=数据色,不变)。
connect(&ThemeManager::instance(), &ThemeManager::changed, this, connect(&ThemeManager::instance(), &ThemeManager::changed, this,
qOverload<>(&QWidget::update)); qOverload<>(&QWidget::update));
@ -26,6 +32,14 @@ void ColorBarWidget::setColorScale(const core::ColorScale& scale) {
void ColorBarWidget::paintEvent(QPaintEvent* /*event*/) { void ColorBarWidget::paintEvent(QPaintEvent* /*event*/) {
QPainter p(this); QPainter p(this);
p.setRenderHint(QPainter::Antialiasing, false); 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 W = width();
const int H = height(); const int H = height();
// 浅色=白底深字(原版 1:1暗色=深底浅字,避免刺眼白条。色带格(数据色)两者不变。 // 浅色=白底深字(原版 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<int>(stops.size()) - 1;
// segY(i): 第 i 个边界的 yi=0 为最小值在底i=nSeg 为最大值在顶)。
auto segY = [&](int i) {
return barBottom - static_cast<int>(static_cast<double>(i) / nSeg * barH);
};
for (int i = 0; i < nSeg; ++i) {
int yB = segY(i), yT = segY(i + 1);
if (yT >= yB) yT = yB - 1;
const auto& c = stops[i].second; // 段 [stop_i, stop_{i+1}) 取 stop_i 色(与离散一致)
p.fillRect(barLeft, yT, kVBarWidth, yB - yT, QColor(c.r, c.g, c.b, c.a));
}
// 外边框
p.setPen(QPen(borderColor, 1));
p.drawRect(barLeft, barTop, kVBarWidth - 1, barH - 1);
// 边界值标签(色带左侧,垂直居中于各边界)。
p.setPen(textColor);
for (int i = 0; i <= nSeg; ++i) {
int y = segY(i);
const QString label = QString::number(stops[i].first, 'f', 2);
int tw = fm.horizontalAdvance(label);
int tx = barLeft - kVLabelGap - tw;
if (tx < 0) tx = 0;
p.drawText(tx, y + fm.ascent() / 2 - 1, label);
}
}
} // namespace geopro::app } // namespace geopro::app

View File

@ -4,13 +4,17 @@
namespace geopro::app { namespace geopro::app {
// 独立色阶条 Widget水平排布上方彩色色带 + 下方刻度值。 // 独立色阶条 Widget作为 QwtPlot 的兄弟 widget不进入 Qwt 坐标系,
// 作为 QwtPlot 的兄弟 widget 布局在图表下方,不进入 Qwt 坐标系,
// 因此不随图表缩放/平移移动,也不与轴标注重叠。 // 因此不随图表缩放/平移移动,也不与轴标注重叠。
// Horizontal默认反演原数据底部横条等宽色带 + 下方边界刻度值。
// Verticalmeasurement右侧竖条等高色带最大值在顶、最小值在底
// 边界值标在色带左侧(对齐原版 Plotly 离散图例)。
class ColorBarWidget : public QWidget { class ColorBarWidget : public QWidget {
Q_OBJECT Q_OBJECT
public: 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); void setColorScale(const core::ColorScale& scale);
@ -18,7 +22,11 @@ protected:
void paintEvent(QPaintEvent* event) override; void paintEvent(QPaintEvent* event) override;
private: private:
void paintHorizontal(QPainter& p);
void paintVertical(QPainter& p);
core::ColorScale scale_; core::ColorScale scale_;
Orientation orient_;
}; };
} // namespace geopro::app } // namespace geopro::app

View File

@ -0,0 +1,151 @@
#include "panels/chart/DataTableView.hpp"
#include <QHeaderView>
#include <QPainter>
#include <QTableView>
#include <QVBoxLayout>
namespace geopro::app {
namespace {
// 药丸开关配色(原版蓝色 ON
const QColor kSwitchOn(64, 158, 255); // 蓝色ON/可见)
const QColor kSwitchOff(190, 190, 190); // 灰色OFF/隐藏)
constexpr int kSwitchW = 36; // 轨道宽
constexpr int kSwitchH = 18; // 轨道高(=直径)
constexpr int kKnobMargin = 2; // 旋钮与轨道边距
constexpr int kToggleColW = 80; // Toggle 列固定窄宽(开关 36 + 两侧留白)
} // namespace
TablePayloadModel::TablePayloadModel(QObject* parent) : QAbstractTableModel(parent) {}
void TablePayloadModel::setPayload(const geopro::core::TablePayload& payload) {
beginResetModel();
payload_ = payload;
endResetModel();
}
int TablePayloadModel::rowCount(const QModelIndex& parent) const {
if (parent.isValid()) return 0;
return static_cast<int>(payload_.rows.size());
}
int TablePayloadModel::columnCount(const QModelIndex& parent) const {
if (parent.isValid()) return 0;
return static_cast<int>(payload_.columns.size());
}
QVariant TablePayloadModel::data(const QModelIndex& index, int role) const {
if (!index.isValid()) return {};
const size_t r = static_cast<size_t>(index.row());
const size_t c = static_cast<size_t>(index.column());
if (r >= payload_.rows.size() || c >= payload_.rows[r].size()) return {};
if (role == Qt::TextAlignmentRole)
return static_cast<int>(Qt::AlignCenter); // 单元内容水平+垂直居中
if (role == Qt::DisplayRole) return payload_.rows[r][c];
return {};
}
QVariant TablePayloadModel::headerData(int section, Qt::Orientation orientation, int role) const {
if (role != Qt::DisplayRole) return {};
if (orientation == Qt::Horizontal) {
if (section < 0 || section >= static_cast<int>(payload_.columns.size())) return {};
return payload_.columns[static_cast<size_t>(section)].title;
}
return section + 1; // 行号
}
geopro::core::TableColumnKind TablePayloadModel::columnKind(int column) const {
if (column < 0 || column >= static_cast<int>(payload_.columns.size()))
return geopro::core::TableColumnKind::Text;
return payload_.columns[static_cast<size_t>(column)].kind;
}
ToggleSwitchDelegate::ToggleSwitchDelegate(const TablePayloadModel* model, QObject* parent)
: QStyledItemDelegate(parent), model_(model) {}
void ToggleSwitchDelegate::paint(QPainter* painter, const QStyleOptionViewItem& option,
const QModelIndex& index) const {
if (!model_ || model_->columnKind(index.column()) != geopro::core::TableColumnKind::Toggle) {
QStyledItemDelegate::paint(painter, option, index);
return;
}
// 画背景(选中态/交替行),但不画文本。
QStyleOptionViewItem opt = option;
initStyleOption(&opt, index);
opt.text.clear();
if (const QWidget* w = opt.widget)
w->style()->drawControl(QStyle::CE_ItemViewItem, &opt, painter, w);
const bool on = index.data(Qt::DisplayRole).toString() == QLatin1String("1");
// 居中药丸开关。
const QRect cell = option.rect;
QRectF track(cell.center().x() - kSwitchW / 2.0, cell.center().y() - kSwitchH / 2.0,
kSwitchW, kSwitchH);
painter->save();
painter->setRenderHint(QPainter::Antialiasing, true);
painter->setPen(Qt::NoPen);
painter->setBrush(on ? kSwitchOn : kSwitchOff);
painter->drawRoundedRect(track, kSwitchH / 2.0, kSwitchH / 2.0);
// 白色旋钮ON 靠右OFF 靠左)。
const double d = kSwitchH - 2.0 * kKnobMargin;
const double knobX = on ? track.right() - kKnobMargin - d : track.left() + kKnobMargin;
painter->setBrush(Qt::white);
painter->drawEllipse(QRectF(knobX, track.top() + kKnobMargin, d, d));
painter->restore();
}
QSize ToggleSwitchDelegate::sizeHint(const QStyleOptionViewItem& option,
const QModelIndex& index) const {
if (!model_ || model_->columnKind(index.column()) != geopro::core::TableColumnKind::Toggle)
return QStyledItemDelegate::sizeHint(option, index);
// 贴合开关的窄宽(+两侧内边距),高度沿用默认行高,避免 ResizeToContents 撑宽该列。
return QSize(kToggleColW, kSwitchH + 6);
}
DataTableView::DataTableView(QWidget* parent) : QWidget(parent) {
auto* lay = new QVBoxLayout(this);
lay->setContentsMargins(0, 0, 0, 0);
lay->setSpacing(0);
model_ = new TablePayloadModel(this);
table_ = new QTableView(this);
table_->setModel(model_);
table_->setAlternatingRowColors(true);
table_->setEditTriggers(QAbstractItemView::NoEditTriggers);
table_->setSelectionBehavior(QAbstractItemView::SelectRows);
// 列宽策略在 setPayload 按列种类设置Toggle 列固定窄宽;文本列均分 Stretch
// 不强制拉伸末列——末列通常是窄的 Toggle 列,拉伸会把它撑得过宽。
table_->horizontalHeader()->setStretchLastSection(false);
table_->horizontalHeader()->setDefaultAlignment(Qt::AlignCenter); // 表头文本居中
table_->verticalHeader()->setDefaultSectionSize(24);
table_->verticalHeader()->setVisible(false); // 原版无行号列
// Toggle 列委托:把“隐藏/显示”列画成蓝色药丸开关。
table_->setItemDelegate(new ToggleSwitchDelegate(model_, table_));
lay->addWidget(table_);
}
void DataTableView::setPayload(const QVariant& payload) {
if (!payload.canConvert<geopro::core::TablePayload>()) return; // 坏/空 → 空态
const auto t = payload.value<geopro::core::TablePayload>();
model_->setPayload(t);
// 列宽策略文本列等宽均分Toggle 列窄。
// Toggle 列 → Fixed 固定窄宽(贴合开关 + 留白),开关居中。
// 其余文本列 → Stretch均分可用宽度整体均衡无单一巨列、无右侧空白
auto* header = table_->horizontalHeader();
for (size_t i = 0; i < t.columns.size(); ++i) {
const int col = static_cast<int>(i);
if (t.columns[i].kind == geopro::core::TableColumnKind::Toggle) {
header->setSectionResizeMode(col, QHeaderView::Fixed);
table_->setColumnWidth(col, kToggleColW);
} else {
header->setSectionResizeMode(col, QHeaderView::Stretch);
}
}
}
} // namespace geopro::app

View File

@ -0,0 +1,60 @@
#pragma once
#include <QAbstractTableModel>
#include <QStyledItemDelegate>
#include <QWidget>
#include "model/detail/DetailPayloads.hpp"
#include "panels/chart/IDetailView.hpp"
class QTableView;
namespace geopro::app {
// TablePayload 驱动的只读表模型(列标题来自 TableColumn单元为预格式化 QString
class TablePayloadModel : public QAbstractTableModel {
Q_OBJECT
public:
explicit TablePayloadModel(QObject* parent = nullptr);
void setPayload(const geopro::core::TablePayload& payload);
int rowCount(const QModelIndex& parent = QModelIndex()) const override;
int columnCount(const QModelIndex& parent = QModelIndex()) const override;
QVariant data(const QModelIndex& index, int role) const override;
QVariant headerData(int section, Qt::Orientation orientation, int role) const override;
// 列渲染种类(供委托判断是否画开关)。越界返回 Text。
geopro::core::TableColumnKind columnKind(int column) const;
private:
geopro::core::TablePayload payload_;
};
// Toggle 列委托:把单元值("1"=ON/"0"=OFF画成蓝色药丸开关仅展示状态暂不联动散点
// Text 列回退到默认绘制。
class ToggleSwitchDelegate : public QStyledItemDelegate {
Q_OBJECT
public:
explicit ToggleSwitchDelegate(const TablePayloadModel* model, QObject* parent = nullptr);
void paint(QPainter* painter, const QStyleOptionViewItem& option,
const QModelIndex& index) const override;
// Toggle 列返回贴合开关的窄尺寸(供 ResizeToContents 不至于撑宽);其余回退默认。
QSize sizeHint(const QStyleOptionViewItem& option, const QModelIndex& index) const override;
private:
const TablePayloadModel* model_; // 不拥有
};
// 通用数据列表视图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

View File

@ -0,0 +1,28 @@
#include "panels/chart/DetailViewFactory.hpp"
#include <stdexcept>
#include "panels/chart/DataTableView.hpp"
#include "panels/chart/GridDataChartView.hpp"
#include "panels/chart/RawDataChartView.hpp"
namespace geopro::app {
std::unique_ptr<IDetailView> makeDetailView(controller::ViewKind kind, QWidget* parent) {
switch (kind) {
case controller::ViewKind::Scatter:
return std::unique_ptr<IDetailView>(new RawDataChartView(parent));
case controller::ViewKind::FilledContour:
return std::unique_ptr<IDetailView>(new GridDataChartView(parent));
case controller::ViewKind::Table:
return std::unique_ptr<IDetailView>(new DataTableView(parent));
case controller::ViewKind::Bar:
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

View File

@ -0,0 +1,16 @@
#pragma once
#include <memory>
#include "DatasetDetailTab.hpp" // geopro::controller::ViewKind
class QWidget;
namespace geopro::app {
class IDetailView;
// 按 render kind 造详情视图。E1b 仅支持 Scatter / FilledContour反演两页签
// Bar/LineProfile/PolylineMap/Table 留待后续阶段measurement/trajectory/grid
// 现阶段命中会抛 std::runtime_error明确失败而非静默空指针
std::unique_ptr<IDetailView> makeDetailView(controller::ViewKind kind, QWidget* parent);
} // namespace geopro::app

View File

@ -1,9 +1,17 @@
#pragma once #pragma once
#include <vector>
#include "IDatasetChartStrategy.hpp" // geopro::controllergeopro_controller PUBLIC include #include "IDatasetChartStrategy.hpp" // geopro::controllergeopro_controller PUBLIC include
namespace geopro::app { namespace geopro::app {
// ERT 反演策略:散点(chart) + 网格等值面(grid) 两阶段 // ERT 反演策略:散点(原数据,同步) + 网格等值面(网格数据,懒加载) 两页签
struct ErtInversionStrategy : controller::IDatasetChartStrategy { struct ErtInversionStrategy : controller::IDatasetChartStrategy {
std::string ddCode() const override { return "dd_inversion_data"; } std::string ddCode() const override { return "dd_inversion_data"; }
bool hasGridPhase() const override { return true; } // 反演有网格数据阶段 std::vector<controller::TabSpec> tabs() const override {
return {
{QStringLiteral("原数据"), controller::ViewKind::Scatter,
QStringLiteral("inversion.scatter"), /*lazy*/ false, /*paginated*/ false},
{QStringLiteral("网格数据"), controller::ViewKind::FilledContour,
QStringLiteral("inversion.grid"), /*lazy*/ true, /*paginated*/ false},
};
}
}; };
} // namespace geopro::app } // namespace geopro::app

View File

@ -177,10 +177,13 @@ GridDataChartView::~GridDataChartView() {
delete colorSvc_; delete colorSvc_;
} }
void GridDataChartView::setData(const geopro::controller::DatasetDetailController::ChartData& d) { void GridDataChartView::setPayload(const QVariant& payload) {
data_ = d; if (!payload.canConvert<geopro::core::ContourPayload>()) {
// 开页:仅把 anomalies 喂给底部异常列表;图表区待网格数据懒加载后填充。 // 坏/空 variant保持空态不渲染、不崩
anomalyTable_->setAnomalies(d.anomalies, {}, {}); return;
}
const auto p = payload.value<geopro::core::ContourPayload>();
setGridData(p.grid, p.scale, p.anomalies);
} }
void GridDataChartView::setGridData(const geopro::core::Grid& grid, void GridDataChartView::setGridData(const geopro::core::Grid& grid,

View File

@ -3,10 +3,11 @@
#include <QWidget> #include <QWidget>
#include "DatasetDetailController.hpp"
#include "model/Anomaly.hpp" #include "model/Anomaly.hpp"
#include "model/ColorScale.hpp" #include "model/ColorScale.hpp"
#include "model/Field.hpp" #include "model/Field.hpp"
#include "model/detail/DetailPayloads.hpp"
#include "panels/chart/IDetailView.hpp"
class QSlider; class QSlider;
class QLabel; class QLabel;
@ -24,24 +25,23 @@ class ContourPlotItem;
// 网格数据图表视图:工具条 + QwtPlot白底 + 真实比尺 + 实时平移/滚轮缩放x 轴在底部) // 网格数据图表视图:工具条 + QwtPlot白底 + 真实比尺 + 实时平移/滚轮缩放x 轴在底部)
// + 独立色阶条 + 底部双页签(异常列表/描述)。 // + 独立色阶条 + 底部双页签(异常列表/描述)。
// 填充走 ContourPlotItem 栅格热力图 + 矢量等值线 + 标注 + 异常叠加。 // 填充走 ContourPlotItem 栅格热力图 + 矢量等值线 + 标注 + 异常叠加。
class GridDataChartView : public QWidget { class GridDataChartView : public QWidget, public IDetailView {
Q_OBJECT Q_OBJECT
public: public:
explicit GridDataChartView(QWidget* parent = nullptr); explicit GridDataChartView(QWidget* parent = nullptr);
~GridDataChartView() override; ~GridDataChartView() override;
// 开页时调用:仅喂底部异常列表(网格数据随网格页激活懒加载)。
void setData(const geopro::controller::DatasetDetailController::ChartData& d);
// 网格数据到达(懒加载结果):建色彩服务 + ContourPlotItem挂到 QwtPlot更新色阶条/异常表。 // 网格数据到达(懒加载结果):建色彩服务 + ContourPlotItem挂到 QwtPlot更新色阶条/异常表。
void setGridData(const geopro::core::Grid& grid, const geopro::core::ColorScale& gridScale, void setGridData(const geopro::core::Grid& grid, const geopro::core::ColorScale& gridScale,
const std::vector<geopro::core::Anomaly>& anoms); const std::vector<geopro::core::Anomaly>& anoms);
// IDetailView解包 ContourPayloadgrid + 色阶 + 异常)→ setGridData。坏/空 variant 保持空态。
QWidget* widget() override { return this; }
void setPayload(const QVariant& payload) override;
private: private:
void rebuildContour(); // 按当前显隐开关重建并重绘 ContourPlotItem void rebuildContour(); // 按当前显隐开关重建并重绘 ContourPlotItem
geopro::controller::DatasetDetailController::ChartData data_;
QwtPlot* plot_ = nullptr; QwtPlot* plot_ = nullptr;
QwtPlotRescaler* rescaler_ = nullptr; QwtPlotRescaler* rescaler_ = nullptr;
ColorBarWidget* colorBar_ = nullptr; ColorBarWidget* colorBar_ = nullptr;

View File

@ -0,0 +1,17 @@
#pragma once
#include <QVariant>
class QWidget;
namespace geopro::app {
// 详情页签视图统一接口:壳/工厂只依赖它,渲染细节由具体视图自行解包载荷。
// widget() 返回承载的 QWidget多为 thissetPayload 解包 QVariant 载荷并渲染。
class IDetailView {
public:
virtual ~IDetailView() = default;
virtual QWidget* widget() = 0;
virtual void setPayload(const QVariant& payload) = 0;
};
} // namespace geopro::app

View File

@ -0,0 +1,18 @@
#pragma once
#include <vector>
#include "IDatasetChartStrategy.hpp" // geopro::controller
namespace geopro::app {
// ERT 原始数据measurement策略散点伪剖面同步+ 数据列表(懒加载)两页签。
struct MeasurementStrategy : controller::IDatasetChartStrategy {
std::string ddCode() const override { return "dd_ert_measurement_data"; }
std::vector<controller::TabSpec> tabs() const override {
return {
{QStringLiteral("散点图"), controller::ViewKind::Scatter,
QStringLiteral("ert_measurement.scatter"), /*lazy*/ false, /*paginated*/ false},
{QStringLiteral("数据列表"), controller::ViewKind::Table,
QStringLiteral("ert_measurement.rows"), /*lazy*/ true, /*paginated*/ false},
};
}
};
} // namespace geopro::app

View File

@ -5,9 +5,17 @@
#include "panels/chart/ScatterPlotItem.hpp" #include "panels/chart/ScatterPlotItem.hpp"
#include <QComboBox> #include <QComboBox>
#include <QCursor>
#include <QHBoxLayout> #include <QHBoxLayout>
#include <QIcon>
#include <QLabel> #include <QLabel>
#include <QPainter>
#include <QPainterPath>
#include <QPen>
#include <QPixmap>
#include <QPushButton>
#include <QToolButton> #include <QToolButton>
#include <QToolTip>
#include <QVBoxLayout> #include <QVBoxLayout>
#include <qwt_plot.h> #include <qwt_plot.h>
@ -29,9 +37,11 @@ RawDataChartView::RawDataChartView(QWidget* parent) : QWidget(parent) {
auto* lay = new QVBoxLayout(this); auto* lay = new QVBoxLayout(this);
lay->setContentsMargins(0, 0, 0, 0); lay->setContentsMargins(0, 0, 0, 0);
lay->setSpacing(0); lay->setSpacing(0);
rootLay_ = lay;
// ---- 工具条 ---- // ---- 工具条(默认 = 反演原数据measurement 到来时 buildMeasurementToolbar 替换)----
auto* toolbar = new QWidget(this); auto* toolbar = new QWidget(this);
toolbar_ = toolbar;
auto* tbLay = new QHBoxLayout(toolbar); auto* tbLay = new QHBoxLayout(toolbar);
tbLay->setContentsMargins(4, 4, 4, 4); tbLay->setContentsMargins(4, 4, 4, 4);
tbLay->setSpacing(4); tbLay->setSpacing(4);
@ -111,9 +121,21 @@ RawDataChartView::RawDataChartView(QWidget* parent) : QWidget(parent) {
rescaler_->setAspectRatio(1.0); rescaler_->setAspectRatio(1.0);
rescaler_->setEnabled(true); rescaler_->setEnabled(true);
lay->addWidget(plot_, 1); // ---- 图表行plotstretch+ 右侧竖向色阶条measurement 用,默认隐藏)----
auto* plotRow = new QWidget(this);
auto* plotRowLay = new QHBoxLayout(plotRow);
plotRowLay->setContentsMargins(0, 0, 0, 0);
plotRowLay->setSpacing(0);
plotRowLay->addWidget(plot_, 1);
// ---- 独立色阶条(固定高 36pxQwtPlot 的兄弟 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_ = new ColorBarWidget(this);
colorBar_->setObjectName(QStringLiteral("rawColorScaleBar")); colorBar_->setObjectName(QStringLiteral("rawColorScaleBar"));
lay->addWidget(colorBar_); lay->addWidget(colorBar_);
@ -133,27 +155,280 @@ QWidget* RawDataChartView::plotArea() const {
return plot_; return plot_;
} }
void RawDataChartView::setData( namespace {
const geopro::controller::DatasetDetailController::ChartData& d) {
data_ = d; // 把一组 FieldOption 填进下拉,并按 defaultCode或 defaultName 回退)选中默认项。
void fillCombo(QComboBox* combo, const std::vector<geopro::core::FieldOption>& opts,
const QString& defaultCode, const QString& defaultName) {
for (const auto& o : opts) {
// 用户可见名为 nameuserData 存 fieldCode重绘/识别用)。
combo->addItem(o.name, o.code);
}
// 默认选中:优先匹配 fieldCode否则匹配 namemethod 的 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 iconsviewBox 0 0 48 48stroke-width 4stroke=currentColor
// 这里以 QPainter 画细线风格,逻辑边长 logical px按钮 setIconSize 用同值),
// 像素 2× 渲染并 setDevicePixelRatio(2) 保证缩放下不糊。
constexpr int kToolIconPx = 16; // 逻辑图标边长(与 setIconSize 对齐)
constexpr qreal kToolIconScale = 2.0; // 超采样倍率HiDPI 清晰)
QPixmap makeToolIconCanvas(QPainter& p) {
// 调用方在 [0,kToolIconPx] 逻辑坐标系下作画;返回前缩放 + 设 dpr。
const int dim = qRound(kToolIconPx * kToolIconScale);
QPixmap pm(dim, dim);
pm.fill(Qt::transparent);
p.begin(&pm);
p.setRenderHint(QPainter::Antialiasing, true);
p.setRenderHint(QPainter::SmoothPixmapTransform, true);
p.scale(kToolIconScale, kToolIconScale); // 之后用逻辑 px 坐标
return pm;
}
// 信息图标:细描边圆 + 内部 "i"(圆点 + 竖线),品牌蓝(两主题一致,对应原版小蓝圈-i
QIcon makeInfoIcon() {
const QColor accent = tokenColor("accent/primary");
QPainter p;
QPixmap pm = makeToolIconCanvas(p);
const qreal s = kToolIconPx;
QPen pen(accent, 1.4);
pen.setCapStyle(Qt::RoundCap);
p.setPen(pen);
p.setBrush(Qt::NoBrush);
// 外圈(留 1.5px 边距,避免描边被裁)。
const qreal m = 1.5;
p.drawEllipse(QRectF(m, m, s - 2 * m, s - 2 * m));
// "i":上点 + 下竖线(居中)。
const qreal cx = s / 2.0;
p.setBrush(accent);
p.setPen(Qt::NoPen);
p.drawEllipse(QPointF(cx, s * 0.34), 0.9, 0.9); // 点
QPen stem(accent, 1.4);
stem.setCapStyle(Qt::RoundCap);
p.setPen(stem);
p.drawLine(QPointF(cx, s * 0.46), QPointF(cx, s * 0.70)); // 竖
p.end();
pm.setDevicePixelRatio(kToolIconScale);
return QIcon(pm);
}
// 框选图标:虚线方框(选区)+ 右下角小箭头光标。描边随主题(次要文本色,两主题可见)。
QIcon makeMarqueeIcon() {
const QColor stroke = tokenColor("text/secondary");
QPainter p;
QPixmap pm = makeToolIconCanvas(p);
const qreal s = kToolIconPx;
// 虚线选区框(左上偏移,给右下角箭头让位)。
QPen dash(stroke, 1.3);
dash.setCapStyle(Qt::FlatCap);
dash.setJoinStyle(Qt::MiterJoin);
QVector<qreal> pattern{2.0, 1.6};
dash.setDashPattern(pattern);
p.setPen(dash);
p.setBrush(Qt::NoBrush);
p.drawRect(QRectF(2.0, 2.0, s - 6.0, s - 6.0));
// 右下角小箭头光标(实线填充,指向右下)。
QPainterPath cur;
const qreal ax = s - 5.0, ay = s - 5.0;
cur.moveTo(ax, ay);
cur.lineTo(ax + 4.6, ay + 1.8);
cur.lineTo(ax + 1.9, ay + 2.6);
cur.lineTo(ax + 1.1, ay + 4.9);
cur.closeSubpath();
p.setPen(Qt::NoPen);
p.setBrush(stroke);
p.drawPath(cur);
p.end();
pm.setDevicePixelRatio(kToolIconScale);
return QIcon(pm);
}
// 把占位 QToolButton 配成图标按钮:清文字、设图标 + 固定尺寸(与工具条其它按钮一致高度)。
void styleToolIconButton(QToolButton* btn, const QIcon& icon) {
btn->setText(QString());
btn->setIcon(icon);
btn->setIconSize(QSize(kToolIconPx, kToolIconPx));
btn->setAutoRaise(true);
btn->setFixedSize(QSize(28, 28)); // 与下拉/按钮行高协调;不再过小或锯齿
btn->setCursor(Qt::PointingHandCursor);
}
} // namespace
void RawDataChartView::showNotImplemented(QWidget* anchor) {
// 轻提示:占位按钮/下拉点击 → 暂未实现(不阻塞,不弹窗)。
const QPoint pos = anchor ? anchor->mapToGlobal(QPoint(0, anchor->height()))
: QCursor::pos();
QToolTip::showText(pos, QStringLiteral("暂未实现"), anchor);
}
void RawDataChartView::replotForAxis() {
// 本地换 x/y无网络按下拉 fieldCode 从备选列取数据,重设 scatter.x/.y 并重绘。
if (!xCombo_ || !yCombo_ || !scatterItem_) return;
const QString xCode = xCombo_->currentData().toString();
const QString yCode = yCombo_->currentData().toString();
if (xCode == QStringLiteral("horizontalDistance") && !data_.altXHorizontal.empty())
data_.scatter.x = data_.altXHorizontal;
else if (!data_.altXSlope.empty())
data_.scatter.x = data_.altXSlope; // 默认/斜距
if (yCode == QStringLiteral("elevationPseudoDepth") && !data_.altYElevationPseudo.empty())
data_.scatter.y = data_.altYElevationPseudo;
else if (yCode == QStringLiteral("pseudoDepth") && !data_.altYPseudo.empty())
data_.scatter.y = data_.altYPseudo;
// 层数(Layer No):数据为 null → 不改轴(保持当前),选项可选但 no-op。
scatterItem_->setData(data_.scatter, colorSvc_);
if (hoverTip_) hoverTip_->setField(&data_.scatter);
QRectF bbox = scatterItem_->boundingRect();
if (!bbox.isEmpty()) {
plot_->setAxisScale(QwtPlot::xTop, bbox.left(), bbox.right());
plot_->setAxisScale(QwtPlot::yLeft, bbox.top(), bbox.bottom());
}
plot_->updateAxes();
if (rescaler_) rescaler_->rescale();
plot_->replot();
}
void RawDataChartView::buildMeasurementToolbar(const geopro::core::ScatterToolbarConf& conf) {
auto* toolbar = new QWidget(this);
auto* tbLay = new QHBoxLayout(toolbar);
tbLay->setContentsMargins(4, 4, 4, 4);
tbLay->setSpacing(4);
// [i] info + [▣] 框选:占位(暂未实现)。用 QPainter 画的线性图标HiDPI 清晰,随主题)。
auto* btnInfo = new QToolButton(toolbar);
btnInfo->setToolTip(QStringLiteral("信息"));
styleToolIconButton(btnInfo, makeInfoIcon());
connect(btnInfo, &QToolButton::clicked, this, [this, btnInfo]() { showNotImplemented(btnInfo); });
auto* btnMarquee = new QToolButton(toolbar);
btnMarquee->setToolTip(QStringLiteral("框选"));
styleToolIconButton(btnMarquee, makeMarqueeIcon());
connect(btnMarquee, &QToolButton::clicked, this, [this, btnMarquee]() { showNotImplemented(btnMarquee); });
// 主题热切重绘图标info 锚定品牌蓝marquee 描边随次要文本色)。
connect(&ThemeManager::instance(), &ThemeManager::changed, btnInfo,
[btnInfo]() { btnInfo->setIcon(makeInfoIcon()); });
connect(&ThemeManager::instance(), &ThemeManager::changed, btnMarquee,
[btnMarquee]() { btnMarquee->setIcon(makeMarqueeIcon()); });
// 显示 / 隐藏:功能性——切换全部数据方块可见性(电极保留)。
auto* btnShow = new QPushButton(QStringLiteral("显示"), toolbar);
auto* btnHide = new QPushButton(QStringLiteral("隐藏"), toolbar);
connect(btnShow, &QPushButton::clicked, this, [this]() {
if (scatterItem_) { scatterItem_->setScatterVisible(true); plot_->replot(); }
});
connect(btnHide, &QPushButton::clicked, this, [this]() {
if (scatterItem_) { scatterItem_->setScatterVisible(false); plot_->replot(); }
});
// 数据过滤:占位(暂未实现)。
auto* btnFilter = new QPushButton(QStringLiteral("数据过滤"), toolbar);
connect(btnFilter, &QPushButton::clicked, this, [this, btnFilter]() { showNotImplemented(btnFilter); });
// x / y 下拉功能性本地换列重绘v / method 下拉:视觉占位(选不同 v/method 提示暂未实现)。
xCombo_ = new QComboBox(toolbar);
fillCombo(xCombo_, conf.x, conf.defaultX, QString());
yCombo_ = new QComboBox(toolbar);
fillCombo(yCombo_, conf.y, conf.defaultY, QString());
auto* vCombo = new QComboBox(toolbar);
fillCombo(vCombo, conf.v, conf.defaultV, QString());
auto* methodCombo = new QComboBox(toolbar);
fillCombo(methodCombo, conf.method, QString(), conf.defaultMethod);
connect(xCombo_, QOverload<int>::of(&QComboBox::currentIndexChanged), this,
[this](int) { replotForAxis(); });
connect(yCombo_, QOverload<int>::of(&QComboBox::currentIndexChanged), this,
[this](int) { replotForAxis(); });
// v / method换选项 → 暂未实现(需重新请求散点/色阶,属重交互,本轮不做)。
connect(vCombo, QOverload<int>::of(&QComboBox::activated), this,
[this, vCombo](int) { showNotImplemented(vCombo); });
connect(methodCombo, QOverload<int>::of(&QComboBox::activated), this,
[this, methodCombo](int) { showNotImplemented(methodCombo); });
// 色阶配置:占位(暂未实现)。
auto* btnColorScale = new QPushButton(QStringLiteral("色阶配置"), toolbar);
connect(btnColorScale, &QPushButton::clicked, this, [this, btnColorScale]() { showNotImplemented(btnColorScale); });
// 右侧主操作(蓝色):生成视电阻率数据 / 反演运算 / 另存为 —— 占位(暂未实现)。
auto* btnGen = new QPushButton(QStringLiteral("生成视电阻率数据"), toolbar);
auto* btnInvert = new QPushButton(QStringLiteral("反演运算"), toolbar);
auto* btnSaveAs = new QPushButton(QStringLiteral("另存为"), toolbar);
for (auto* b : {btnGen, btnInvert, btnSaveAs}) {
b->setObjectName(QStringLiteral("primaryBtn")); // 蓝色主按钮(下方 QSS
connect(b, &QPushButton::clicked, this, [this, b]() { showNotImplemented(b); });
}
tbLay->addWidget(btnInfo);
tbLay->addWidget(btnMarquee);
tbLay->addWidget(btnShow);
tbLay->addWidget(btnHide);
tbLay->addWidget(btnFilter);
tbLay->addWidget(xCombo_);
tbLay->addWidget(yCombo_);
tbLay->addWidget(vCombo);
tbLay->addWidget(methodCombo);
tbLay->addWidget(btnColorScale);
tbLay->addStretch(); // 把主操作推到右侧
tbLay->addWidget(btnGen);
tbLay->addWidget(btnInvert);
tbLay->addWidget(btnSaveAs);
// 蓝色主按钮样式(随主题;普通按钮/下拉走全局 QSS 已支持明暗)。
applyTokenizedStyleSheet(
toolbar,
QStringLiteral(
"QPushButton#primaryBtn { background: {{accent/primary}}; color: {{text/on-primary}};"
" border: 1px solid {{accent/primary}}; border-radius: 6px; padding: 6px 14px; }"
"QPushButton#primaryBtn:hover { background: {{accent/primary-hover}}; border-color: {{accent/primary-hover}}; }"
"QPushButton#primaryBtn:pressed { background: {{accent/primary-pressed}}; }"));
// 替换 ctor 建的 inversion 工具条(同一顶层布局首位)。
rootLay_->replaceWidget(toolbar_, toolbar);
toolbar_->deleteLater();
toolbar_ = toolbar;
chartTypeCombo_ = nullptr; // 已随旧工具条移除
measurementToolbar_ = true;
}
void RawDataChartView::setPayload(const QVariant& payload) {
if (!payload.canConvert<geopro::core::ScatterPayload>()) {
// 坏/空 variant保持空态不渲染、不崩。E2+ 可在此显式提示「渲染数据格式错误」。
return;
}
setData(payload.value<geopro::core::ScatterPayload>());
}
void RawDataChartView::setData(const geopro::core::ScatterPayload& p) {
data_ = p;
if (hoverTip_) hoverTip_->setField(&data_.scatter); // 显式重绑(地址稳定,消除隐式依赖) 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 // 重建 ColorMapService旧的 scatterItem 已被 QwtPlot detach/delete
delete colorSvc_; delete colorSvc_;
colorSvc_ = new ColorMapService(d.scatterScale); colorSvc_ = new ColorMapService(p.scale);
// 散点颜色归一化对齐原版 Plotlycmin/cmax 未设 → cauto=数据 min/max // 散点颜色归一化对齐原版 Plotlycmin/cmax 未设 → cauto=数据 min/max按 vlist 有限值
// 按 vlist 有限值的 min/max 设数据范围,使整段色阶铺满数据实际范围(而非压进 colorBar 全程一小段)。 // 的 min/max 设数据范围使整段色阶铺满数据实际范围。measurement 与反演原数据同此路径:
double vMin = std::numeric_limits<double>::max(); // v 含负异常值(如 -1066故 cmin<0中段视电阻率归一化到色阶中部深品红/紫)。
double vMax = std::numeric_limits<double>::lowest(); {
for (double v : d.scatter.v) { double vMin = std::numeric_limits<double>::max();
if (!std::isfinite(v)) continue; // 跳过 NaN/±Inf脏数据否则数据范围被污染→全图 NaN 取色 double vMax = std::numeric_limits<double>::lowest();
if (v < vMin) vMin = v; for (double v : p.scatter.v) {
if (v > vMax) vMax = 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 // 卸载旧散点项QwtPlot 默认 autoDelete=true析构时 delete 仍在 dict 的 item
// 必须先 detach()(从 dict 移除)再 delete否则 QwtPlot 析构时会 double-free。 // 必须先 detach()(从 dict 移除)再 delete否则 QwtPlot 析构时会 double-free。
@ -165,7 +440,7 @@ void RawDataChartView::setData(
// 新建散点项并挂到 plot // 新建散点项并挂到 plot
scatterItem_ = new ScatterPlotItem(); scatterItem_ = new ScatterPlotItem();
scatterItem_->setData(d.scatter, colorSvc_); scatterItem_->setData(p.scatter, colorSvc_);
scatterItem_->attach(plot_); scatterItem_->attach(plot_);
// 按数据包围盒设置轴范围 // 按数据包围盒设置轴范围
@ -178,8 +453,16 @@ void RawDataChartView::setData(
if (rescaler_) rescaler_->rescale(); // 应用真实比尺 if (rescaler_) rescaler_->rescale(); // 应用真实比尺
plot_->replot(); plot_->replot();
// 更新色阶条 // 更新色阶条measurement 用右侧竖条,反演原数据用底部横条(二选一显示)。
colorBar_->setColorScale(d.scatterScale); 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 } // namespace geopro::app

View File

@ -1,9 +1,11 @@
#pragma once #pragma once
#include <QWidget> #include <QWidget>
#include "DatasetDetailController.hpp" #include "model/detail/DetailPayloads.hpp"
#include "panels/chart/ColorMapService.hpp" #include "panels/chart/ColorMapService.hpp"
#include "panels/chart/IDetailView.hpp"
class QComboBox; class QComboBox;
class QVBoxLayout;
class QwtPlot; class QwtPlot;
class QwtPlotRescaler; class QwtPlotRescaler;
@ -14,23 +16,43 @@ class ScatterPlotItem;
class ScatterHoverTip; class ScatterHoverTip;
// 原数据图表视图:工具条 + QwtPlotx 轴顶部、Panner/Magnifier+ 独立色阶条。 // 原数据图表视图:工具条 + QwtPlotx 轴顶部、Panner/Magnifier+ 独立色阶条。
class RawDataChartView : public QWidget { class RawDataChartView : public QWidget, public IDetailView {
Q_OBJECT Q_OBJECT
public: public:
explicit RawDataChartView(QWidget* parent = nullptr); explicit RawDataChartView(QWidget* parent = nullptr);
~RawDataChartView() override; ~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_ // 供外部访问(已不再是占位,保留兼容接口返回 plot_
QWidget* plotArea() const; QWidget* plotArea() const;
private: 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_; QwtPlot* plot_;
QwtPlotRescaler* rescaler_ = nullptr; // 锁定 x:y 真实比尺 QwtPlotRescaler* rescaler_ = nullptr; // 锁定 x:y 真实比尺
ColorBarWidget* colorBar_; ColorBarWidget* colorBar_; // 底部横条(反演原数据)
QComboBox* chartTypeCombo_; 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 接管绘制,但我们仍持有指针 // 使用 unique_ptr 管理生命周期attach 后 QwtPlot 接管绘制,但我们仍持有指针
ColorMapService* colorSvc_ = nullptr; // heap由 setData 重建 ColorMapService* colorSvc_ = nullptr; // heap由 setData 重建

View File

@ -38,15 +38,44 @@ bool ScatterHoverTip::eventFilter(QObject* obj, QEvent* ev) {
const auto& ys = field_->y; const auto& ys = field_->y;
const auto& vs = field_->v; const auto& vs = field_->v;
const std::size_t n = std::min(xs.size(), ys.size()); const std::size_t n = std::min(xs.size(), ys.size());
if (n == 0) {
QToolTip::hideText();
return false;
}
// 在像素空间找最近散点(与 ScatterPlotItem 同用 canvasMap // 在像素空间找最近散点(与 ScatterPlotItem 同用 canvasMap
const QwtScaleMap xMap = plot_->canvasMap(xAxis_); const QwtScaleMap xMap = plot_->canvasMap(xAxis_);
const QwtScaleMap yMap = plot_->canvasMap(yAxis_); const QwtScaleMap yMap = plot_->canvasMap(yAxis_);
const QPointF mp = me->position(); const QPointF mp = me->position();
// 电极 hovery=0 行,与数据点 hover 分开):电极 marker 略大于数据方块,
// 命中半径稍大。命中则显示 x/y/num 浮动框measurement 专有,反演 electrodeX 空→跳过)。
{
const auto& ex = field_->electrodeX;
const auto& eno = field_->electrodeNo;
const double ey0 = yMap.transform(0.0);
double eBestD2 = std::numeric_limits<double>::max();
std::size_t eBestI = 0;
for (std::size_t i = 0; i < ex.size(); ++i) {
const double dx = xMap.transform(ex[i]) - mp.x();
const double dy = ey0 - mp.y();
const double d2 = dx * dx + dy * dy;
if (d2 < eBestD2) {
eBestD2 = d2;
eBestI = i;
}
}
if (eBestD2 <= kElectrodeHitRadiusPx * kElectrodeHitRadiusPx) {
const int num = (eBestI < eno.size())
? static_cast<int>(eno[eBestI])
: static_cast<int>(eBestI) + 1;
QToolTip::showText(me->globalPosition().toPoint(),
electrodeHoverText(ex[eBestI], num), plot_->canvas());
return false;
}
}
if (n == 0) {
QToolTip::hideText();
return false;
}
double bestD2 = std::numeric_limits<double>::max(); double bestD2 = std::numeric_limits<double>::max();
std::size_t bestI = 0; std::size_t bestI = 0;
for (std::size_t i = 0; i < n; ++i) { for (std::size_t i = 0; i < n; ++i) {
@ -61,8 +90,17 @@ bool ScatterHoverTip::eventFilter(QObject* obj, QEvent* ev) {
if (bestD2 <= kHitRadiusPx * kHitRadiusPx) { if (bestD2 <= kHitRadiusPx * kHitRadiusPx) {
const double v = (bestI < vs.size()) ? vs[bestI] : 0.0; const double v = (bestI < vs.size()) ? vs[bestI] : 0.0;
QToolTip::showText(me->globalPosition().toPoint(), // 有 A/B/M/N 元数据measurement→ 浮动框含 X/Y/Value/a/b/m/n否则回退 X/Y/值(反演原数据)。
scatterHoverText(xs[bestI], ys[bestI], v), plot_->canvas()); 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 { } else {
QToolTip::hideText(); QToolTip::hideText();
} }

View File

@ -17,6 +17,31 @@ inline QString scatterHoverText(double x, double y, double v) {
.arg(v, 0, 'f', 3); .arg(v, 0, 'f', 3);
} }
// measurement 散点 hover 浮动框:对齐原版 Plotly 浮动框多行
// X / Y / Value(视电阻率 col16) / a / b / m / n。
inline QString measurementHoverText(double x, double y, double v,
double a, double b, double m, double n) {
return QStringLiteral(
"<b>X:</b> %1<br><b>Y:</b> %2<br><b>Value:</b> %3"
"<br><b>a:</b> %4<br><b>b:</b> %5<br><b>m:</b> %6<br><b>n:</b> %7")
.arg(x, 0, 'f', 3)
.arg(y, 0, 'f', 3)
.arg(v, 0, 'f', 3)
.arg(a, 0, 'g', 6)
.arg(b, 0, 'g', 6)
.arg(m, 0, 'g', 6)
.arg(n, 0, 'g', 6);
}
// 电极 hover 浮动框:对齐原版 Plotly 电极 trace 浮动框(与数据点 hover 分开),
// x: {slopeDistance}<br>y: 0<br>num: {electrodeNo}
// x 用 6 位有效数字(对齐原版,如 5.123837num 为电极编号1-based 整数)。
inline QString electrodeHoverText(double x, int num) {
return QStringLiteral("x: %1<br>y: 0<br>num: %2")
.arg(x, 0, 'g', 7)
.arg(num);
}
// 散点 hover 提示:监听画布鼠标移动(无按键时),找最近散点, // 散点 hover 提示:监听画布鼠标移动(无按键时),找最近散点,
// 命中半径内用 QToolTip 显示 X/Y/值(对齐原版 Plotly 悬浮)。 // 命中半径内用 QToolTip 显示 X/Y/值(对齐原版 Plotly 悬浮)。
// 不消费事件,与 LivePanner左键平移/滚轮缩放)共存。 // 不消费事件,与 LivePanner左键平移/滚轮缩放)共存。
@ -38,6 +63,7 @@ private:
const core::ScatterField* field_ = nullptr; const core::ScatterField* field_ = nullptr;
static constexpr double kHitRadiusPx = 6.0; // 命中半径(像素),对齐方块 marker 尺寸 static constexpr double kHitRadiusPx = 6.0; // 命中半径(像素),对齐方块 marker 尺寸
static constexpr double kElectrodeHitRadiusPx = 7.0; // 电极菱形略大,命中半径稍大
}; };
} // namespace geopro::app } // namespace geopro::app

View File

@ -1,8 +1,10 @@
#include "panels/chart/ScatterPlotItem.hpp" #include "panels/chart/ScatterPlotItem.hpp"
#include <QPainter> #include <QPainter>
#include <QPolygonF>
#include <qwt_plot.h> #include <qwt_plot.h>
#include <qwt_scale_map.h> #include <qwt_scale_map.h>
#include <algorithm> #include <algorithm>
#include <cmath>
#include <limits> #include <limits>
namespace geopro::app { 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 xMax = *std::max_element(field_.x.begin(), field_.x.end());
double yMin = *std::min_element(field_.y.begin(), field_.y.end()); double yMin = *std::min_element(field_.y.begin(), field_.y.end());
double yMax = *std::max_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避免边界点被截 // 加少量 margin避免边界点被截
double xM = (xMax - xMin) * 0.03 + 0.1; double xM = (xMax - xMin) * 0.03 + 0.1;
double yM = (yMax - yMin) * 0.03 + 0.1; double yM = (yMax - yMin) * 0.03 + 0.1;
@ -56,12 +61,35 @@ void ScatterPlotItem::draw(QPainter* painter,
painter->save(); painter->save();
painter->setRenderHint(QPainter::Antialiasing, false); 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) { for (std::size_t i = 0; i < n; ++i) {
double px = xMap.transform(xs[i]); double px = xMap.transform(xs[i]);
double py = yMap.transform(ys[i]); double py = yMap.transform(ys[i]);
double val = (i < vs.size()) ? vs[i] : 0.0; double val = (i < vs.size()) ? vs[i] : 0.0;
// 非有限值NaN/±Inf可能来自降级后端的脏数据跳过该点不绘制。
// 与 RawDataChartView 的 setDataRange 的 isfinite 跳过一致。
if (!std::isfinite(val)) continue;
auto c = colorSvc_->colorAtContinuous(val); auto c = colorSvc_->colorAtContinuous(val);
painter->setBrush(QColor(c.r, c.g, c.b, c.a)); painter->setBrush(QColor(c.r, c.g, c.b, c.a));
painter->drawRect(QRectF(px - kHalfSide, py - kHalfSide, painter->drawRect(QRectF(px - kHalfSide, py - kHalfSide,

View File

@ -11,13 +11,17 @@ namespace geopro::app {
// QwtPlotItem把 ScatterField 数据渲染为彩色方块散点。 // QwtPlotItem把 ScatterField 数据渲染为彩色方块散点。
// 每个点用固定像素边长方块绘制(不随缩放变大),白色描边, // 每个点用固定像素边长方块绘制(不随缩放变大),白色描边,
// 颜色由 ColorMapService 连续插值决定(与原版 Plotly 一致)。 // 颜色由 ColorMapService 连续插值决定(cauto与原版 Plotly 一致measurement/反演同路径)。
class ScatterPlotItem : public QwtPlotItem { class ScatterPlotItem : public QwtPlotItem {
public: public:
ScatterPlotItem(); ScatterPlotItem();
// 按 ColorMapService 连续插值上色cauto数据范围由调用方在 svc 上 setDataRange。
void setData(const core::ScatterField& field, ColorMapService* svc); void setData(const core::ScatterField& field, ColorMapService* svc);
// 显示/隐藏数据方块measurement 工具条“显示/隐藏”)。电极菱形不受影响,始终绘制。
void setScatterVisible(bool on) { scatterVisible_ = on; }
int rtti() const override { return QwtPlotItem::Rtti_PlotUserItem; } int rtti() const override { return QwtPlotItem::Rtti_PlotUserItem; }
QRectF boundingRect() const override; QRectF boundingRect() const override;
@ -31,9 +35,15 @@ private:
core::ScatterField field_; core::ScatterField field_;
ColorMapService* colorSvc_ = nullptr; // 不拥有,由 RawDataChartView 持有 ColorMapService* colorSvc_ = nullptr; // 不拥有,由 RawDataChartView 持有
QRectF bounding_; 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; // 白色描边宽度(像素) 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 } // namespace geopro::app

View File

@ -1,4 +1,5 @@
#include "DatasetDetailController.hpp" #include "DatasetDetailController.hpp"
#include <exception>
#include <QtGlobal> #include <QtGlobal>
#include "repo/IAsyncDatasetRepository.hpp" #include "repo/IAsyncDatasetRepository.hpp"
#include "api/DatasetLoadHandles.hpp" #include "api/DatasetLoadHandles.hpp"
@ -6,70 +7,68 @@ namespace geopro::controller {
DatasetDetailController::DatasetDetailController(data::IAsyncDatasetRepository& repo, DatasetDetailController::DatasetDetailController(data::IAsyncDatasetRepository& repo,
ChartStrategyRegistry& registry, QObject* parent) ChartStrategyRegistry& registry, QObject* parent)
: QObject(parent), repo_(repo), registry_(registry) {} : QObject(parent), repo_(repo), registry_(registry) {
// QSignalSpy / 队列连接对自定义类型需注册(页签集随 datasetOpened 传递)。
qRegisterMetaType<std::vector<controller::TabSpec>>("std::vector<controller::TabSpec>");
}
DatasetDetailController::~DatasetDetailController() { DatasetDetailController::~DatasetDetailController() {
if (chartLoad_) chartLoad_->abort(); // 退出契约abort 在飞句柄,不依赖外部析构顺序兜底 // 退出契约abort 全部在飞句柄,不依赖外部析构顺序兜底。
if (gridLoad_) gridLoad_->abort(); for (auto& load : inflight_)
if (load) load->abort();
} }
void DatasetDetailController::openDataset(const QString& dsId, const QString& ddCode, void DatasetDetailController::openDataset(const QString& dsId, const QString& ddCode,
const QString& dsName) { const QString& dsName) {
qInfo("[detail] openDataset id=%s ddCode=%s name=%s", qUtf8Printable(dsId), qInfo("[detail] openDataset id=%s ddCode=%s name=%s", qUtf8Printable(dsId),
qUtf8Printable(ddCode), qUtf8Printable(dsName)); qUtf8Printable(ddCode), qUtf8Printable(dsName));
if (!registry_.supports(ddCode.toStdString())) { // 未注册策略 → 优雅降级 auto* s = registry_.find(ddCode.toStdString());
if (!s) { // 未注册策略 → 优雅降级
qWarning("[detail] 未注册策略 ddCode=%s → 降级提示", qUtf8Printable(ddCode)); qWarning("[detail] 未注册策略 ddCode=%s → 降级提示", qUtf8Printable(ddCode));
emit loadFailed(dsId, QStringLiteral("暂不支持该数据类型的预览")); emit loadFailed(dsId, QStringLiteral("暂不支持该数据类型的预览"));
return; return;
} }
if (chartLoad_) chartLoad_->abort(); // abort-and-replace const std::vector<controller::TabSpec> tabs = s->tabs();
data::ChartLoad* load = repo_.loadChartAsync(dsId.toStdString()); emit datasetOpened(dsId, ddCode, dsName, tabs);
chartLoad_ = load; for (int i = 0; i < static_cast<int>(tabs.size()); ++i)
emit loadStarted(dsId, LoadPhase::Chart); if (!tabs[static_cast<size_t>(i)].lazy) loadTab(dsId, ddCode, i);
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);
});
} }
void DatasetDetailController::loadGridData(const QString& dsId, const QString& ddCode) { void DatasetDetailController::loadTab(const QString& dsId, const QString& ddCode, int tabIndex) {
auto* strategy = registry_.find(ddCode.toStdString()); auto* s = registry_.find(ddCode.toStdString());
if (!strategy || !strategy->hasGridPhase()) return; // 仅有网格阶段的类型加载网格数据 if (!s) return; // 策略消失(不应发生):静默不加载
if (gridLoad_) gridLoad_->abort(); // abort-and-replace const std::vector<controller::TabSpec> tabs = s->tabs();
data::GridLoad* load = repo_.loadGridAsync(dsId.toStdString()); if (tabIndex < 0 || tabIndex >= static_cast<int>(tabs.size())) return;
gridLoad_ = load; const controller::TabSpec& spec = tabs[static_cast<size_t>(tabIndex)];
emit loadStarted(dsId, LoadPhase::Grid);
QObject::connect(load, &data::GridLoad::done, this, // loadAsync 对未知 loaderKey 抛 std::runtime_error若逃逸槽函数会被 GuardedApplication
[this, load, dsId](const data::GridParts& parts) { // 吞掉、遮罩永久悬挂(文档化的崩溃/挂起类)。就地兜底为 loadFailed且不留半注册的在飞句柄。
if (load != gridLoad_) return; // §5.0 句柄身份比对:丢弃迟到信号 data::DetailLoad* load = nullptr;
gridLoad_.clear(); try {
GridData d; load = repo_.loadAsync(spec.loaderKey.toStdString(), dsId.toStdString());
d.dsId = dsId; } catch (const std::exception& e) {
d.grid = parts.grid; qWarning("[detail] loadAsync 失败 id=%s tab=%d key=%s: %s", qUtf8Printable(dsId), tabIndex,
d.gridScale = parts.gridScale; qUtf8Printable(spec.loaderKey), e.what());
d.anomalies = parts.anomalies; emit loadFailed(dsId, QStringLiteral("暂不支持该数据类型的预览"));
emit gridReady(d); 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, QObject::connect(load, &data::DetailLoad::failed, this,
[this, load, dsId](const QString& msg) { [this, load, dsId, tabIndex](const QString& msg) {
if (load != gridLoad_) return; if (load != inflight_.value(tabIndex)) return;
gridLoad_.clear(); inflight_.remove(tabIndex);
qWarning("[detail] 网格数据加载失败 id=%s: %s", qUtf8Printable(dsId), qUtf8Printable(msg)); qWarning("[detail] 页签加载失败 id=%s tab=%d: %s", qUtf8Printable(dsId), tabIndex,
qUtf8Printable(msg));
emit loadFailed(dsId, msg); emit loadFailed(dsId, msg);
}); });
} }

View File

@ -1,55 +1,40 @@
#pragma once #pragma once
#include <string> #include <string>
#include <vector>
#include <QMap>
#include <QObject> #include <QObject>
#include <QPointer> #include <QPointer>
#include <QString> #include <QString>
#include "model/Field.hpp" #include <QVariant>
#include "model/ColorScale.hpp" #include "DatasetDetailTab.hpp"
#include "model/Anomaly.hpp"
#include "IDatasetChartStrategy.hpp" #include "IDatasetChartStrategy.hpp"
namespace geopro::data { class IAsyncDatasetRepository; class ChartLoad; class GridLoad; } namespace geopro::data { class IAsyncDatasetRepository; class DetailLoad; }
namespace geopro::controller { namespace geopro::controller {
// 数据详情编排:双击/网格页签 → 异步拉 散点/网格/色阶/异常 → 发信号给详情面板。被动视图。 // 数据详情通用 tab 引擎编排:双击/页签激活 → 按 loaderKey 异步拉载荷(QVariant) → 发信号给详情面板。
// 无 per-ddCode 分支:页签集由策略 tabs() 描述,载荷经 QVariant 类型擦除。被动视图。
class DatasetDetailController : public QObject { class DatasetDetailController : public QObject {
Q_OBJECT Q_OBJECT
public: 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<geopro::core::Anomaly> anomalies;
};
// 网格数据inversion/rows + 色阶 type2 + 异常):随「网格数据」页签首次激活按需懒加载。
struct GridData {
QString dsId;
geopro::core::Grid grid{1, 1}; // Grid 无默认构造占位值初始化loadGridData 会覆盖
geopro::core::ColorScale gridScale;
std::vector<geopro::core::Anomaly> anomalies;
};
DatasetDetailController(data::IAsyncDatasetRepository& repo, DatasetDetailController(data::IAsyncDatasetRepository& repo,
ChartStrategyRegistry& registry, QObject* parent = nullptr); ChartStrategyRegistry& registry, QObject* parent = nullptr);
~DatasetDetailController() override; // 退出契约(spec §7)abort 在飞句柄,避免迟到信号打到已析构 this ~DatasetDetailController() override; // 退出契约(spec §7)abort 全部在飞句柄,避免迟到信号打到已析构 this
public slots: public slots:
// 打开数据集:查策略 → datasetOpened(页签集) → 对每个非 lazy 页签发起 loadTab。
void openDataset(const QString& dsId, const QString& ddCode, const QString& dsName = QString()); 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 focusDataset(const QString& dsId);
void loadGridData(const QString& dsId, const QString& ddCode);
signals: signals:
void loadStarted(const QString& dsId, LoadPhase phase); void datasetOpened(const QString& dsId, const QString& ddCode, const QString& dsName,
void chartReady(const ChartData& data); const std::vector<controller::TabSpec>& tabs);
void gridReady(const GridData& data); void tabLoadStarted(const QString& dsId, int tabIndex);
void focusRequested(const QString& dsId); void tabReady(const QString& dsId, int tabIndex, const QVariant& payload);
void loadFailed(const QString& dsId, const QString& message); void loadFailed(const QString& dsId, const QString& message);
void focusRequested(const QString& dsId);
private: private:
data::IAsyncDatasetRepository& repo_; data::IAsyncDatasetRepository& repo_;
ChartStrategyRegistry& registry_; ChartStrategyRegistry& registry_;
QPointer<data::ChartLoad> chartLoad_; QMap<int, QPointer<data::DetailLoad>> inflight_; // 按页签槽位的在飞句柄§5.0 身份比对)
QPointer<data::GridLoad> gridLoad_;
}; };
} // namespace geopro::controller } // namespace geopro::controller

View File

@ -0,0 +1,24 @@
#pragma once
#include <vector>
#include <QMetaType>
#include <QString>
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<TabSpec>) 需注册的元类型。
Q_DECLARE_METATYPE(geopro::controller::TabSpec)
Q_DECLARE_METATYPE(std::vector<geopro::controller::TabSpec>)

View File

@ -2,15 +2,17 @@
#include <map> #include <map>
#include <memory> #include <memory>
#include <string> #include <string>
#include <vector>
#include "DatasetDetailTab.hpp"
namespace geopro::controller { namespace geopro::controller {
// dd 类型驱动的图表策略:决定某 ddCode 的详情页如何加载/渲染。 // dd 类型驱动的图表策略:决定某 ddCode 的详情页如何加载/渲染。
struct IDatasetChartStrategy { struct IDatasetChartStrategy {
virtual ~IDatasetChartStrategy() = default; virtual ~IDatasetChartStrategy() = default;
virtual std::string ddCode() const = 0; virtual std::string ddCode() const = 0;
// 该类型是否有「网格数据」加载阶段ERT 反演=true纯散点/折线/图像类=false // 该类型的页签集(标题/render kind/加载键/惰性/分页)。控制器据此建页签 + 驱动加载,
// 控制器据此决定是否允许 loadGridData替代硬编码 ddCode 判断。 // 取代硬编码的 hasGridPhase()/ddCode 判断。
virtual bool hasGridPhase() const = 0; virtual std::vector<TabSpec> tabs() const = 0;
}; };
class ChartStrategyRegistry { class ChartStrategyRegistry {

View File

@ -49,6 +49,12 @@ private:
struct ScatterField { struct ScatterField {
std::vector<double> x, y, z, v; // 对应样本 xlist/ylist/(hlist)/vlist std::vector<double> x, y, z, v; // 对应样本 xlist/ylist/(hlist)/vlist
std::vector<double> projX, projY; // GIS 平面坐标 std::vector<double> projX, projY; // GIS 平面坐标
// 可选元数据measurement 散点用反演留空。a/b/m/n 与各点一一对应hover 显示
// A/B/M/NelectrodeX 为电极沿测线位置y=0 处灰菱形 markerelectrodeNo 为对应
// 电极编号1-basedhover 显示 num与 electrodeX 一一对应)。
std::vector<double> a, b, m, n;
std::vector<double> electrodeX;
std::vector<double> electrodeNo;
}; };
} // namespace geopro::core } // namespace geopro::core

View File

@ -0,0 +1,74 @@
#pragma once
#include <vector>
#include <QMetaType>
#include <QString>
#include "model/Field.hpp"
#include "model/ColorScale.hpp"
#include "model/Anomaly.hpp"
// 详情渲染载荷(纯数据,跨 QVariant 类型擦除传递)。无 Qt-widget 依赖。
// 命名空间 geopro::core与同目录 Field.hpp/ColorScale.hpp 一致)。
namespace geopro::core {
// 下拉项:字段码 + 显示名measurement 工具条 x/y/v/method 下拉驱动code 可空如 method
struct FieldOption {
QString code;
QString name;
};
// measurement 散点工具条配置(来自服务端 scatterGraphConf反演留空 → 视图渲染反演工具条)。
// x/y/v/method 为各下拉的可选项(含 fieldCode+namedefaultX/Y/V/Method 为默认选中项的
// fieldCodemethod 的 fieldCode 全为 null故 defaultMethod 用 name。empty() 判定走 x 是否为空。
struct ScatterToolbarConf {
std::vector<FieldOption> x, y, v, method;
QString defaultX, defaultY, defaultV, defaultMethod;
bool empty() const { return x.empty(); }
};
// 散点载荷:反演原数据 / measurement 散点共用(≈ data::ChartParts
// 两者上色一致——按数据 min/max 连续插值Plotly cauto含负异常值RawDataChartView 据 v
// 有限值 min/max 设 setDataRange。verticalLegend=truemeasurement时色阶图例画在右侧竖条
// 离散带1323→0 自上而下对齐原版false默认反演原数据时画在底部横条。
// toolbar 非空measurement时视图渲染 measurement 工具条altX*/altY* 为 x/y 下拉本地重绘
// 用的备选列(平距/斜距、伪深度/伪深度+高程),与 scatter.v/.a... 同序、一一对应,避免再发请求。
struct ScatterPayload {
geopro::core::ScatterField scatter;
geopro::core::ColorScale scale;
bool verticalLegend = false;
ScatterToolbarConf toolbar;
std::vector<double> altXHorizontal, altXSlope; // x 下拉:平距 / 斜距
std::vector<double> altYPseudo, altYElevationPseudo; // y 下拉:伪深度 / 伪深度+高程
};
// 等值面载荷grid(rows) + 色阶 + 异常(≈ data::GridParts
// Grid 无默认构造,给占位初始化以满足 QVariant 对默认可构造的要求。
struct ContourPayload {
geopro::core::Grid grid{1, 1};
geopro::core::ColorScale scale;
std::vector<geopro::core::Anomaly> anomalies;
};
// 列渲染种类Text=预格式化文本默认Toggle=每行开关蓝色药丸开关ON=可见)。
enum class TableColumnKind { Text, Toggle };
// 通用表格列定义。
struct TableColumn {
QString code;
QString title;
int width = 0;
int sort = 0;
TableColumnKind kind = TableColumnKind::Text;
};
// 通用表格载荷:列定义 + 预格式化的行(每格 QString+ 总数(分页用)。
struct TablePayload {
std::vector<TableColumn> columns;
std::vector<std::vector<QString>> 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)

View File

@ -5,6 +5,7 @@ add_library(geopro_data STATIC
repo/LocalSampleRepository.cpp repo/LocalSampleRepository.cpp
dto/NavDto.cpp dto/NavDto.cpp
dto/DatasetChartDto.cpp dto/DatasetChartDto.cpp
dto/MeasurementDto.cpp
api/ApiProjectRepository.cpp api/ApiProjectRepository.cpp
api/ApiDatasetRepository.cpp api/ApiDatasetRepository.cpp
api/DatasetLoadHandles.cpp api/DatasetLoadHandles.cpp

View File

@ -1,11 +1,15 @@
#include "api/ApiDatasetRepository.hpp" #include "api/ApiDatasetRepository.hpp"
#include <stdexcept>
#include <QJsonObject> #include <QJsonObject>
#include <QString> #include <QString>
#include <QUrl> #include <QUrl>
#include <QVariant>
#include "ApiClient.hpp" #include "ApiClient.hpp"
#include "ApiBatch.hpp" #include "ApiBatch.hpp"
#include "api/DatasetLoadHandles.hpp" #include "api/DatasetLoadHandles.hpp"
#include "dto/DatasetChartDto.hpp" #include "dto/DatasetChartDto.hpp"
#include "dto/MeasurementDto.hpp"
#include "model/detail/DetailPayloads.hpp"
namespace geopro::data { namespace geopro::data {
namespace { namespace {
@ -14,6 +18,19 @@ QString enc(const std::string& s) {
return QString::fromUtf8(QUrl::toPercentEncoding(QString::fromStdString(s))); return QString::fromUtf8(QUrl::toPercentEncoding(QString::fromStdString(s)));
} }
// 解析中间态(仅本 .cpp 内部使用,故置匿名命名空间,避免对外双份表示)。
// 原数据加载结果scatter + 散点色阶(type1)。
struct ChartParts {
geopro::core::ScatterField scatter;
geopro::core::ColorScale scatterScale;
};
// 网格数据加载结果grid(rows) + 网格色阶(type2) + 异常。
struct GridParts {
geopro::core::Grid grid{1, 1}; // Grid 无默认构造;占位
geopro::core::ColorScale gridScale;
std::vector<geopro::core::Anomaly> anomalies;
};
// 失败判定(原 must() 口径):业务码 != 200 或传输错误。 // 失败判定(原 must() 口径):业务码 != 200 或传输错误。
bool isFailure(const geopro::net::ApiResponse& r) { bool isFailure(const geopro::net::ApiResponse& r) {
return r.code != 200 || !r.rawError.isEmpty(); 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}}; return QJsonObject{{"dsObjectId", QString::fromStdString(dsId)}, {"businessCode", ""}, {"type", type}};
} }
// ── 共享批次构造唯一端点定义处old/new 路径复用,避免双份解析逻辑。 ──
// 反演原数据index 0 = scatter(GET)1 = 散点色阶 type1(POST)。
net::ApiBatch* inversionScatterBatch(net::ApiClient& api, const std::string& dsId) {
QList<net::IApiCall*> calls{
api.getAsync(QStringLiteral("/business/dd/ert/inversion/getErtRawDataScatterGraph/%1").arg(enc(dsId))),
api.postJsonAsync(QStringLiteral("/business/lvl/colorGradation/getDetail"), colorBody(dsId, 1)),
};
return new net::ApiBatch(calls, &isFailure);
}
ChartParts parseScatterParts(const QList<net::ApiResponse>& r) {
ChartParts p;
p.scatter = dto::parseScatterGraph(r[0].data);
p.scatterScale = dto::parseColorBar(r[1].data);
return p;
}
// 反演网格index 0 = rows(GET,慢)1 = 色阶 type2(POST)2 = 异常(GET)。
net::ApiBatch* inversionGridBatch(net::ApiClient& api, const std::string& dsId) {
QList<net::IApiCall*> calls{
api.getAsync(QStringLiteral("/business/dd/ert/inversion/rows/%1").arg(enc(dsId))),
api.postJsonAsync(QStringLiteral("/business/lvl/colorGradation/getDetail"), colorBody(dsId, 2)),
api.getAsync(QStringLiteral("/business/exception/queryException/%1").arg(enc(dsId))),
};
return new net::ApiBatch(calls, &isFailure);
}
GridParts parseGridParts(const QList<net::ApiResponse>& r) {
GridParts p;
p.grid = dto::parseInversionGrid(r[0].data);
p.gridScale = dto::parseColorBar(r[1].data);
p.anomalies = dto::parseDatasetAnomalies(r[2].data.value(QStringLiteral("value")).toArray());
return p;
}
// measurement 散点index 0 = scatter/graph(GETquery 参数)1 = 色阶 type3(POSTbusinessCode=R0)。
net::ApiBatch* measurementScatterBatch(net::ApiClient& api, const std::string& dsId) {
const QString did = enc(dsId);
QList<net::IApiCall*> calls{
api.getAsync(QStringLiteral("/business/dd/ert/measurement/scatter/graph?dsObjectId=%1&vFieldCode=").arg(did)),
api.postJsonAsync(QStringLiteral("/business/lvl/colorGradation/getDetail"),
QJsonObject{{"dsObjectId", QString::fromStdString(dsId)},
{"businessCode", "R0"},
{"type", 3}}),
};
return new net::ApiBatch(calls, &isFailure);
}
// measurement 列表:单请求 measurement/rows(GETquery 参数)。
net::ApiBatch* measurementRowsBatch(net::ApiClient& api, const std::string& dsId) {
QList<net::IApiCall*> calls{
api.getAsync(QStringLiteral("/business/dd/ert/measurement/rows?dsObjectId=%1").arg(enc(dsId))),
};
return new net::ApiBatch(calls, &isFailure);
}
} // namespace } // namespace
ApiDatasetRepository::ApiDatasetRepository(net::ApiClient& api) : api_(api) {} ApiDatasetRepository::ApiDatasetRepository(net::ApiClient& api) : api_(api) {}
ChartLoad* ApiDatasetRepository::loadChartAsync(const std::string& dsId) { DetailLoad* ApiDatasetRepository::loadAsync(const std::string& loaderKey, const std::string& dsId) {
// index 0 = scatter(GET)index 1 = 散点色阶 type1(POST) if (loaderKey == "inversion.scatter") return makeInversionScatter(dsId);
QList<net::IApiCall*> calls{ if (loaderKey == "inversion.grid") return makeInversionGrid(dsId);
api_.getAsync(QStringLiteral("/business/dd/ert/inversion/getErtRawDataScatterGraph/%1").arg(enc(dsId))), if (loaderKey == "ert_measurement.scatter") return makeMeasurementScatter(dsId);
api_.postJsonAsync(QStringLiteral("/business/lvl/colorGradation/getDetail"), colorBody(dsId, 1)), if (loaderKey == "ert_measurement.rows") return makeMeasurementRows(dsId);
}; throw std::runtime_error("unknown loaderKey: " + loaderKey);
auto* batch = new net::ApiBatch(calls, &isFailure); }
return new ApiChartLoad(batch, [](const QList<net::ApiResponse>& r) {
ChartParts p; DetailLoad* ApiDatasetRepository::makeInversionScatter(const std::string& dsId) {
p.scatter = dto::parseScatterGraph(r[0].data); // 复用同一批次 + 解析器,再映射为 ScatterPayload不复制 JSON 解析逻辑)。
p.scatterScale = dto::parseColorBar(r[1].data); return new ApiDetailLoad(inversionScatterBatch(api_, dsId), [](const QList<net::ApiResponse>& r) {
return p; ChartParts p = parseScatterParts(r);
return QVariant::fromValue(core::ScatterPayload{p.scatter, p.scatterScale});
}); });
} }
GridLoad* ApiDatasetRepository::loadGridAsync(const std::string& dsId) { DetailLoad* ApiDatasetRepository::makeInversionGrid(const std::string& dsId) {
// index 0 = rows(GET,慢)1 = 色阶 type2(POST)2 = 异常(GET) return new ApiDetailLoad(inversionGridBatch(api_, dsId), [](const QList<net::ApiResponse>& r) {
QList<net::IApiCall*> calls{ GridParts p = parseGridParts(r);
api_.getAsync(QStringLiteral("/business/dd/ert/inversion/rows/%1").arg(enc(dsId))), return QVariant::fromValue(core::ContourPayload{p.grid, p.gridScale, p.anomalies});
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); DetailLoad* ApiDatasetRepository::makeMeasurementScatter(const std::string& dsId) {
return new ApiGridLoad(batch, [](const QList<net::ApiResponse>& r) { // index 0 = scatter/graph, 1 = colorBar(type3) → 离散上色的 ScatterPayload。
GridParts p; return new ApiDetailLoad(measurementScatterBatch(api_, dsId), [](const QList<net::ApiResponse>& r) {
p.grid = dto::parseInversionGrid(r[0].data); return QVariant::fromValue(dto::parseMeasurementScatter(r[0].data, r[1].data));
p.gridScale = dto::parseColorBar(r[1].data); });
p.anomalies = dto::parseDatasetAnomalies(r[2].data.value(QStringLiteral("value")).toArray()); }
return p;
DetailLoad* ApiDatasetRepository::makeMeasurementRows(const std::string& dsId) {
return new ApiDetailLoad(measurementRowsBatch(api_, dsId), [](const QList<net::ApiResponse>& r) {
return QVariant::fromValue(dto::parseMeasurementTable(r[0].data));
}); });
} }

View File

@ -7,9 +7,12 @@ namespace geopro::data {
class ApiDatasetRepository : public IAsyncDatasetRepository { class ApiDatasetRepository : public IAsyncDatasetRepository {
public: public:
explicit ApiDatasetRepository(net::ApiClient& api); explicit ApiDatasetRepository(net::ApiClient& api);
ChartLoad* loadChartAsync(const std::string& dsId) override; DetailLoad* loadAsync(const std::string& loaderKey, const std::string& dsId) override;
GridLoad* loadGridAsync(const std::string& dsId) override;
private: 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_; net::ApiClient& api_;
}; };
} // namespace geopro::data } // namespace geopro::data

View File

@ -9,14 +9,14 @@ QString reasonOf(const geopro::net::ApiResponse& r) {
} }
} // namespace } // namespace
ApiChartLoad::ApiChartLoad(geopro::net::ApiBatch* batch, Parser parse, QObject* parent) ApiDetailLoad::ApiDetailLoad(geopro::net::ApiBatch* batch, Parser parse, QObject* parent)
: ChartLoad(parent), batch_(batch), parse_(std::move(parse)) { : DetailLoad(parent), batch_(batch), parse_(std::move(parse)) {
QObject::connect(batch, &geopro::net::ApiBatch::succeeded, this, QObject::connect(batch, &geopro::net::ApiBatch::succeeded, this,
[this](const QList<geopro::net::ApiResponse>& resps) { [this](const QList<geopro::net::ApiResponse>& resps) {
if (aborted_) return; // §5.0 if (aborted_) return; // §5.0
ChartParts parts; QVariant payload;
try { try {
parts = parse_(resps); // 仅解析在 try 内:下游 done 处理器抛出不应误报为解析失败 payload = parse_(resps); // 仅解析在 try 内:下游 done 处理器抛出不应误报为解析失败
} catch (const std::exception& e) { } catch (const std::exception& e) {
emit failed(QString::fromUtf8(e.what())); emit failed(QString::fromUtf8(e.what()));
deleteLater(); deleteLater();
@ -26,7 +26,7 @@ ApiChartLoad::ApiChartLoad(geopro::net::ApiBatch* batch, Parser parse, QObject*
deleteLater(); deleteLater();
return; return;
} }
emit done(parts); emit done(payload);
deleteLater(); deleteLater();
}); });
QObject::connect(batch, &geopro::net::ApiBatch::failed, this, QObject::connect(batch, &geopro::net::ApiBatch::failed, this,
@ -37,42 +37,7 @@ ApiChartLoad::ApiChartLoad(geopro::net::ApiBatch* batch, Parser parse, QObject*
}); });
} }
void ApiChartLoad::abort() { void ApiDetailLoad::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<geopro::net::ApiResponse>& 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() {
if (aborted_) return; if (aborted_) return;
aborted_ = true; aborted_ = true;
if (batch_) batch_->abort(); if (batch_) batch_->abort();

View File

@ -4,52 +4,30 @@
#include <QObject> #include <QObject>
#include <QPointer> #include <QPointer>
#include <QString> #include <QString>
#include <QVariant>
#include "ApiBatch.hpp" #include "ApiBatch.hpp"
#include "DatasetLoads.hpp"
namespace geopro::data { namespace geopro::data {
// ── 抽象句柄(可测试缝,类比 IApiCall仓储返回基类指针控制器/测试只依赖它 ── // ── 通用详情句柄tab 引擎):载荷经 QVariant 类型擦除,单一 done(QVariant)。 ──
class ChartLoad : public QObject { // 可测试缝(类比 IApiCall仓储返回基类指针控制器/测试只依赖它。
class DetailLoad : public QObject {
Q_OBJECT Q_OBJECT
public: public:
using QObject::QObject; using QObject::QObject;
~ChartLoad() override = default; ~DetailLoad() override = default;
virtual void abort() = 0; virtual void abort() = 0;
signals: signals:
void done(const geopro::data::ChartParts& parts); void done(const QVariant& payload);
void failed(const QString& message); void failed(const QString& message);
}; };
class GridLoad : public QObject { // Api 实现:包 ApiBatch + 注入解析器(返回 QVariant 载荷)。逻辑与 ApiChartLoad 等价。
class ApiDetailLoad : public DetailLoad {
Q_OBJECT Q_OBJECT
public: public:
using QObject::QObject; using Parser = std::function<QVariant(const QList<geopro::net::ApiResponse>&)>;
~GridLoad() override = default; ApiDetailLoad(geopro::net::ApiBatch* batch, Parser parse, QObject* parent = nullptr);
virtual void abort() = 0;
signals:
void done(const geopro::data::GridParts& parts);
void failed(const QString& message);
};
// ── Api 实现:包一个 ApiBatch + 注入的解析函数。batch.succeeded→解析→donefailed→failed ──
class ApiChartLoad : public ChartLoad {
Q_OBJECT
public:
using Parser = std::function<ChartParts(const QList<geopro::net::ApiResponse>&)>;
ApiChartLoad(geopro::net::ApiBatch* batch, Parser parse, QObject* parent = nullptr);
void abort() override;
private:
QPointer<geopro::net::ApiBatch> batch_;
Parser parse_;
bool aborted_ = false;
};
class ApiGridLoad : public GridLoad {
Q_OBJECT
public:
using Parser = std::function<GridParts(const QList<geopro::net::ApiResponse>&)>;
ApiGridLoad(geopro::net::ApiBatch* batch, Parser parse, QObject* parent = nullptr);
void abort() override; void abort() override;
private: private:
QPointer<geopro::net::ApiBatch> batch_; QPointer<geopro::net::ApiBatch> batch_;

View File

@ -1,21 +0,0 @@
#pragma once
#include <vector>
#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<geopro::core::Anomaly> anomalies;
};
} // namespace geopro::data

View File

@ -0,0 +1,162 @@
#include "dto/MeasurementDto.hpp"
#include <QJsonArray>
#include <QJsonValue>
#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<double>(static_cast<long long>(d))) return QString::number(static_cast<long long>(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<FieldOption> parseOptions(const QJsonArray& arr) {
std::vector<FieldOption> 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其次 gridHeaderDisplaygrid/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<QString> 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<int>(t.rows.size()));
return t;
}
} // namespace geopro::data::dto

View File

@ -0,0 +1,20 @@
#pragma once
#include <QJsonObject>
#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

View File

@ -3,15 +3,14 @@
namespace geopro::data { namespace geopro::data {
class ChartLoad; class DetailLoad;
class GridLoad;
// 数据集详情异步仓储抽象。返回自管理句柄(完成/失败后 deleteLater // 数据集详情异步仓储抽象。返回自管理句柄(完成/失败后 deleteLater
class IAsyncDatasetRepository { class IAsyncDatasetRepository {
public: public:
virtual ~IAsyncDatasetRepository() = default; virtual ~IAsyncDatasetRepository() = default;
virtual ChartLoad* loadChartAsync(const std::string& dsId) = 0; // scatter + 散点色阶(type1) // 通用页签加载tab 引擎):按 loaderKey 分派,载荷经 QVariant 类型擦除。
virtual GridLoad* loadGridAsync(const std::string& dsId) = 0; // grid(rows) + 色阶(type2) + 异常 virtual DetailLoad* loadAsync(const std::string& loaderKey, const std::string& dsId) = 0;
}; };
} // namespace geopro::data } // namespace geopro::data

View File

@ -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_local_repo.cpp)
target_sources(geopro_tests PRIVATE data/test_nav_dto.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_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) 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 # NavRequest 线QVariant payload: done/failed/abort
target_sources(geopro_tests PRIVATE data/test_nav_request.cpp) target_sources(geopro_tests PRIVATE data/test_nav_request.cpp)
target_link_libraries(geopro_tests PRIVATE geopro_data) target_link_libraries(geopro_tests PRIVATE geopro_data)
@ -107,7 +110,7 @@ target_sources(geopro_tests PRIVATE
# hover inline qwt/ ScatterHoverTip # hover inline qwt/ ScatterHoverTip
target_sources(geopro_tests PRIVATE app/test_scatter_hover.cpp) 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) find_package(Qt6 COMPONENTS Test REQUIRED)
target_sources(geopro_tests PRIVATE controller/test_dataset_detail_controller.cpp) target_sources(geopro_tests PRIVATE controller/test_dataset_detail_controller.cpp)
target_sources(geopro_tests PRIVATE controller/test_workbench_nav_controller.cpp) target_sources(geopro_tests PRIVATE controller/test_workbench_nav_controller.cpp)

View File

@ -1,10 +1,19 @@
#include <gtest/gtest.h> #include <gtest/gtest.h>
#include "DatasetDetailTab.hpp"
#include "IDatasetChartStrategy.hpp" // geopro::controller控制器层 #include "IDatasetChartStrategy.hpp" // geopro::controller控制器层
#include "panels/chart/MeasurementStrategy.hpp"
using namespace geopro::controller; using namespace geopro::controller;
namespace { namespace {
struct Fake : IDatasetChartStrategy { struct Fake : IDatasetChartStrategy {
std::string ddCode() const override { return "dd_inversion_data"; } std::string ddCode() const override { return "dd_inversion_data"; }
bool hasGridPhase() const override { return true; } std::vector<TabSpec> 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) { TEST(ChartStrategyRegistry, FindsRegisteredAndDegradesUnknown) {
@ -15,10 +24,31 @@ TEST(ChartStrategyRegistry, FindsRegisteredAndDegradesUnknown) {
EXPECT_FALSE(reg.supports("dd_unknown")); EXPECT_FALSE(reg.supports("dd_unknown"));
EXPECT_EQ(reg.find("dd_unknown"), nullptr); EXPECT_EQ(reg.find("dd_unknown"), nullptr);
} }
TEST(ChartStrategyRegistry, ReportsHasGridPhase) { TEST(ChartStrategyRegistry, ExposesTabSpecsFromStrategy) {
ChartStrategyRegistry reg; ChartStrategyRegistry reg;
reg.add(std::make_unique<Fake>()); reg.add(std::make_unique<Fake>());
auto* s = reg.find("dd_inversion_data"); auto* s = reg.find("dd_inversion_data");
ASSERT_NE(s, nullptr); 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");
// 数据列表Tablelazy。
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");
} }

View File

@ -1,6 +1,8 @@
#include <gtest/gtest.h> #include <gtest/gtest.h>
#include "panels/chart/ScatterHoverTip.hpp" #include "panels/chart/ScatterHoverTip.hpp"
using geopro::app::electrodeHoverText;
using geopro::app::measurementHoverText;
using geopro::app::scatterHoverText; using geopro::app::scatterHoverText;
// 对齐原版 Plotly hovertemplate // 对齐原版 Plotly hovertemplate
@ -15,3 +17,16 @@ TEST(ScatterHoverTip, RoundsAndPadsToFixed3) {
EXPECT_EQ(scatterHoverText(-1.0, 0.5, 1.0), EXPECT_EQ(scatterHoverText(-1.0, 0.5, 1.0),
QStringLiteral("<b>X:</b> -1.000<br><b>Y:</b> 0.500<br><b>值:</b> 1.000")); QStringLiteral("<b>X:</b> -1.000<br><b>Y:</b> 0.500<br><b>值:</b> 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("<b>X:</b> 2.283<br><b>Y:</b> -1.200<br><b>Value:</b> 242.953"
"<br><b>a:</b> 1<br><b>b:</b> 4<br><b>m:</b> 2<br><b>n:</b> 3"));
}
// 电极浮动框x7 位有效数字)/ y: 0 / num电极编号对齐原版 Plotly 电极 trace。
TEST(ScatterHoverTip, ElectrodeFloatingTipShowsXYNum) {
const QString t = electrodeHoverText(5.123837209632222, 4);
EXPECT_EQ(t, QStringLiteral("x: 5.123837<br>y: 0<br>num: 4"));
}

View File

@ -1,21 +1,33 @@
#include <gtest/gtest.h> #include <gtest/gtest.h>
#include <QSignalSpy> #include <QSignalSpy>
#include <QVariant>
#include "DatasetDetailController.hpp" #include "DatasetDetailController.hpp"
#include "DatasetDetailTab.hpp"
#include "IDatasetChartStrategy.hpp" #include "IDatasetChartStrategy.hpp"
#include "repo/IAsyncDatasetRepository.hpp" #include "repo/IAsyncDatasetRepository.hpp"
#include "api/DatasetLoadHandles.hpp" #include "api/DatasetLoadHandles.hpp"
using namespace geopro; using namespace geopro;
namespace { namespace {
// 反演策略桩:散点 + 网格两阶段 // 反演策略桩:散点(非 lazy) + 网格(lazy) 两页签
struct InversionStrategy : controller::IDatasetChartStrategy { struct InversionStrategy : controller::IDatasetChartStrategy {
std::string ddCode() const override { return "dd_inversion_data"; } std::string ddCode() const override { return "dd_inversion_data"; }
bool hasGridPhase() const override { return true; } std::vector<controller::TabSpec> tabs() const override {
return {
{QStringLiteral("原数据"), controller::ViewKind::Scatter,
QStringLiteral("inversion.scatter"), /*lazy*/ false, /*paginated*/ false},
{QStringLiteral("网格数据"), controller::ViewKind::FilledContour,
QStringLiteral("inversion.grid"), /*lazy*/ true, /*paginated*/ false},
};
}
}; };
// 无网格阶段策略桩:仅散点(如纯散点类型)。 // 单页签策略桩(仅一个非 lazy 页签)。
struct NoGridStrategy : controller::IDatasetChartStrategy { struct SingleTabStrategy : controller::IDatasetChartStrategy {
std::string ddCode() const override { return "dd_scatter_only"; } std::string ddCode() const override { return "dd_scatter_only"; }
bool hasGridPhase() const override { return false; } std::vector<controller::TabSpec> tabs() const override {
return {{QStringLiteral("散点"), controller::ViewKind::Scatter,
QStringLiteral("scatter.only"), /*lazy*/ false, /*paginated*/ false}};
}
}; };
// 注册了反演策略的注册表(多数用例复用)。 // 注册了反演策略的注册表(多数用例复用)。
controller::ChartStrategyRegistry makeInversionRegistry() { controller::ChartStrategyRegistry makeInversionRegistry() {
@ -24,39 +36,56 @@ controller::ChartStrategyRegistry makeInversionRegistry() {
return reg; return reg;
} }
// 桩句柄:不声明 Q_OBJECT —— 发射继承自 data::ChartLoad/GridLoad 的信号、override abort。 // 桩详情句柄:不声明 Q_OBJECT —— 发射继承自 data::DetailLoad 的信号、override abort。
struct StubChartLoad : data::ChartLoad { struct StubDetailLoad : data::DetailLoad {
bool aborted = false; bool aborted = false;
void abort() override { aborted = true; } void abort() override { aborted = true; }
void fireDone() { emit done(data::ChartParts{}); } void fireDone() { emit done(QVariant{}); }
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 fireFailed() { emit failed(QStringLiteral("x")); } void fireFailed() { emit failed(QStringLiteral("x")); }
}; };
// 桩仓储:每个 loaderKey 都造一个新句柄,记录最近一个用于 fire。
struct StubAsyncRepo : data::IAsyncDatasetRepository { struct StubAsyncRepo : data::IAsyncDatasetRepository {
StubChartLoad* lastChart = nullptr; StubDetailLoad* last = nullptr;
StubGridLoad* lastGrid = nullptr; data::DetailLoad* loadAsync(const std::string&, const std::string&) override {
data::ChartLoad* loadChartAsync(const std::string&) override { last = new StubDetailLoad;
lastChart = new StubChartLoad; return lastChart; return last;
}
data::GridLoad* loadGridAsync(const std::string&) override {
lastGrid = new StubGridLoad; return lastGrid;
} }
}; };
} } // namespace
TEST(DatasetDetailController, EmitsChartReadyOnDone) { TEST(DatasetDetailController, OpenEmitsDatasetOpenedWithTabsAndDdCode) {
StubAsyncRepo repo; StubAsyncRepo repo;
auto reg = makeInversionRegistry(); auto reg = makeInversionRegistry();
controller::DatasetDetailController c(repo, reg); 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"); c.openDataset("ds1", "dd_inversion_data");
repo.lastChart->fireDone(); // 反演tab0 非 lazy 自动加载tab1 lazy 不加载。
EXPECT_EQ(spy.count(), 1); 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) { TEST(DatasetDetailController, EmitsLoadFailedOnFailed) {
@ -65,7 +94,7 @@ TEST(DatasetDetailController, EmitsLoadFailedOnFailed) {
controller::DatasetDetailController c(repo, reg); controller::DatasetDetailController c(repo, reg);
QSignalSpy spy(&c, &controller::DatasetDetailController::loadFailed); QSignalSpy spy(&c, &controller::DatasetDetailController::loadFailed);
c.openDataset("ds1", "dd_inversion_data"); c.openDataset("ds1", "dd_inversion_data");
repo.lastChart->fireFailed(); repo.last->fireFailed();
EXPECT_EQ(spy.count(), 1); EXPECT_EQ(spy.count(), 1);
} }
@ -76,7 +105,7 @@ TEST(DatasetDetailController, UnsupportedTypeFailsImmediately) {
QSignalSpy spy(&c, &controller::DatasetDetailController::loadFailed); QSignalSpy spy(&c, &controller::DatasetDetailController::loadFailed);
c.openDataset("ds1", "dd_other"); c.openDataset("ds1", "dd_other");
EXPECT_EQ(spy.count(), 1); EXPECT_EQ(spy.count(), 1);
EXPECT_EQ(repo.lastChart, nullptr); // 未发起加载 EXPECT_EQ(repo.last, nullptr); // 未发起加载
} }
// 空注册表 → 任意 ddCode 都不支持 → loadFailed不发起加载。 // 空注册表 → 任意 ddCode 都不支持 → loadFailed不发起加载。
@ -87,76 +116,62 @@ TEST(DatasetDetailController, EmptyRegistryFailsAnyType) {
QSignalSpy spy(&c, &controller::DatasetDetailController::loadFailed); QSignalSpy spy(&c, &controller::DatasetDetailController::loadFailed);
c.openDataset("ds1", "dd_inversion_data"); c.openDataset("ds1", "dd_inversion_data");
EXPECT_EQ(spy.count(), 1); EXPECT_EQ(spy.count(), 1);
EXPECT_EQ(repo.lastChart, nullptr); EXPECT_EQ(repo.last, nullptr);
} }
// 无网格阶段的策略 → loadGridData 不发起网格加载。 // 越界 tabIndex → loadTab 静默不加载。
TEST(DatasetDetailController, NoGridPhaseStrategySkipsGridLoad) { TEST(DatasetDetailController, LoadTabOutOfRangeDoesNothing) {
StubAsyncRepo repo;
controller::ChartStrategyRegistry reg;
reg.add(std::make_unique<NoGridStrategy>());
controller::DatasetDetailController c(repo, reg);
c.loadGridData("ds1", "dd_scatter_only");
EXPECT_EQ(repo.lastGrid, nullptr); // hasGridPhase()==false → 未发起
}
TEST(DatasetDetailController, AbortsPreviousOnReopen) {
StubAsyncRepo repo; StubAsyncRepo repo;
auto reg = makeInversionRegistry(); auto reg = makeInversionRegistry();
controller::DatasetDetailController c(repo, reg); controller::DatasetDetailController c(repo, reg);
c.openDataset("dsA", "dd_inversion_data"); c.loadTab("ds1", "dd_inversion_data", 99);
StubChartLoad* a = repo.lastChart; EXPECT_EQ(repo.last, nullptr);
c.openDataset("dsB", "dd_inversion_data"); // 替换 }
EXPECT_TRUE(a->aborted); // 旧句柄被 abort
// 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) { TEST(DatasetDetailController, DropsLateSignalFromAbortedLoad) {
StubAsyncRepo repo; StubAsyncRepo repo;
auto reg = makeInversionRegistry(); auto reg = makeInversionRegistry();
controller::DatasetDetailController c(repo, reg); controller::DatasetDetailController c(repo, reg);
QSignalSpy spy(&c, &controller::DatasetDetailController::chartReady); QSignalSpy spy(&c, &controller::DatasetDetailController::tabReady);
c.openDataset("dsA", "dd_inversion_data"); c.loadTab("dsA", "dd_inversion_data", 0);
StubChartLoad* a = repo.lastChart; StubDetailLoad* a = repo.last;
c.openDataset("dsB", "dd_inversion_data"); c.loadTab("dsB", "dd_inversion_data", 0); // 同槽位替换
StubChartLoad* b = repo.lastChart; StubDetailLoad* b = repo.last;
a->fireDone(); // 旧句柄迟到 → 身份比对丢弃 a->fireDone(); // 旧句柄迟到 → 身份比对丢弃
EXPECT_EQ(spy.count(), 0); EXPECT_EQ(spy.count(), 0);
b->fireDone(); // 当前句柄 → 正常 b->fireDone(); // 当前句柄 → 正常
EXPECT_EQ(spy.count(), 1); EXPECT_EQ(spy.count(), 1);
} }
TEST(DatasetDetailController, EmitsGridReadyOnDone) { TEST(DatasetDetailController, FocusEmitsFocusRequested) {
StubAsyncRepo repo; StubAsyncRepo repo;
auto reg = makeInversionRegistry(); auto reg = makeInversionRegistry();
controller::DatasetDetailController c(repo, reg); controller::DatasetDetailController c(repo, reg);
QSignalSpy spy(&c, &controller::DatasetDetailController::gridReady); QSignalSpy spy(&c, &controller::DatasetDetailController::focusRequested);
c.loadGridData("ds1", "dd_inversion_data"); c.focusDataset("ds1");
repo.lastGrid->fireDone(); ASSERT_EQ(spy.count(), 1);
EXPECT_EQ(spy.count(), 1); EXPECT_EQ(spy.takeFirst().at(0).toString(), QStringLiteral("ds1"));
}
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);
} }

View File

@ -0,0 +1,79 @@
#include <gtest/gtest.h>
#include <stdexcept>
#include <QCoreApplication>
#include <QVariant>
#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 创建网络句柄需 QCoreApplicationQNAM 事件循环)。
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>());
ScatterPayload out = v.value<ScatterPayload>();
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>());
ContourPayload out = v.value<ContourPayload>();
EXPECT_DOUBLE_EQ(out.grid.vmin, 1.5);
EXPECT_DOUBLE_EQ(out.grid.vmax, 9.5);
}

View File

@ -1,13 +1,16 @@
#include <gtest/gtest.h> #include <gtest/gtest.h>
#include <stdexcept> #include <stdexcept>
#include <QSignalSpy> #include <QSignalSpy>
#include <QVariant>
#include "api/DatasetLoadHandles.hpp" #include "api/DatasetLoadHandles.hpp"
#include "model/detail/DetailPayloads.hpp"
#include "net/FakeApiCall.hpp" #include "net/FakeApiCall.hpp"
using namespace geopro::data; using namespace geopro::data;
using geopro::net::ApiBatch; using geopro::net::ApiBatch;
using geopro::net::ApiResponse; using geopro::net::ApiResponse;
using geopro::net::test::FakeApiCall; using geopro::net::test::FakeApiCall;
using geopro::core::ScatterPayload;
namespace { namespace {
ApiResponse ok() { ApiResponse r; r.code = 200; r.httpStatus = 200; return r; } 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(); }; auto isFailure = [](const ApiResponse& r) { return r.code != 200 || !r.rawError.isEmpty(); };
} }
TEST(DatasetLoadHandles, ChartLoadEmitsDoneOnSuccess) { // ── 通用详情句柄 ApiDetailLoadtab 引擎done(QVariant) / failed / abort 闸门 ──
TEST(DatasetLoadHandles, DetailLoadEmitsDoneWithPayloadOnSuccess) {
auto* a = new FakeApiCall; auto* a = new FakeApiCall;
auto* b = new FakeApiCall; auto* b = new FakeApiCall;
auto* batch = new ApiBatch({a, b}, isFailure); auto* batch = new ApiBatch({a, b}, isFailure);
bool parsed = false; auto* load = new ApiDetailLoad(batch, [](const QList<ApiResponse>& resps) {
auto* load = new ApiChartLoad(batch, [&](const QList<ApiResponse>& resps) { ScatterPayload p;
parsed = (resps.size() == 2); p.scatter.v = std::vector<double>(static_cast<size_t>(resps.size()), 0.0); // 携带可校验状态
return ChartParts{}; return QVariant::fromValue(p);
}); });
QSignalSpy doneSpy(load, &ChartLoad::done); QSignalSpy doneSpy(load, &DetailLoad::done);
a->fire(ok()); a->fire(ok());
b->fire(ok()); b->fire(ok());
EXPECT_EQ(doneSpy.count(), 1); ASSERT_EQ(doneSpy.count(), 1);
EXPECT_TRUE(parsed); QVariant payload = doneSpy.takeFirst().at(0).value<QVariant>();
ASSERT_TRUE(payload.canConvert<ScatterPayload>());
EXPECT_EQ(payload.value<ScatterPayload>().scatter.v.size(), 2u); // round-trip 还原字段
} }
TEST(DatasetLoadHandles, ChartLoadEmitsFailedOnBatchFailure) { TEST(DatasetLoadHandles, DetailLoadEmitsFailedOnBatchFailure) {
auto* a = new FakeApiCall; auto* a = new FakeApiCall;
auto* b = new FakeApiCall; auto* batch = new ApiBatch({a}, isFailure);
auto* batch = new ApiBatch({a, b}, isFailure); auto* load = new ApiDetailLoad(batch, [](const QList<ApiResponse>&) { return QVariant{}; });
auto* load = new ApiChartLoad(batch, [](const QList<ApiResponse>&) { return ChartParts{}; }); QSignalSpy failSpy(load, &DetailLoad::failed);
QSignalSpy failSpy(load, &ChartLoad::failed);
a->fire(bad()); a->fire(bad());
EXPECT_EQ(failSpy.count(), 1); EXPECT_EQ(failSpy.count(), 1);
} }
TEST(DatasetLoadHandles, ChartLoadEmitsFailedWhenParseThrows) { TEST(DatasetLoadHandles, DetailLoadEmitsFailedWhenParseThrows) {
auto* a = new FakeApiCall; auto* a = new FakeApiCall;
auto* batch = new ApiBatch({a}, isFailure); auto* batch = new ApiBatch({a}, isFailure);
auto* load = new ApiChartLoad(batch, [](const QList<ApiResponse>&) -> ChartParts { auto* load = new ApiDetailLoad(batch, [](const QList<ApiResponse>&) -> QVariant {
throw std::runtime_error("parse boom"); throw std::runtime_error("parse boom");
}); });
QSignalSpy doneSpy(load, &ChartLoad::done); QSignalSpy doneSpy(load, &DetailLoad::done);
QSignalSpy failSpy(load, &ChartLoad::failed); QSignalSpy failSpy(load, &DetailLoad::failed);
a->fire(ok()); // batch 成功 → parse 抛异常 → failedemit done 已移出 try a->fire(ok()); // batch 成功 → parse 抛 → faileddone 已移出 try
EXPECT_EQ(doneSpy.count(), 0); EXPECT_EQ(doneSpy.count(), 0);
EXPECT_EQ(failSpy.count(), 1); EXPECT_EQ(failSpy.count(), 1);
} }
TEST(DatasetLoadHandles, GridLoadAbortSuppressesLateDone) { TEST(DatasetLoadHandles, DetailLoadAbortSuppressesLateDone) {
auto* a = new FakeApiCall; auto* a = new FakeApiCall;
auto* batch = new ApiBatch({a}, isFailure); auto* batch = new ApiBatch({a}, isFailure);
auto* load = new ApiGridLoad(batch, [](const QList<ApiResponse>&) { return GridParts{}; }); auto* load = new ApiDetailLoad(batch, [](const QList<ApiResponse>&) { return QVariant{}; });
QSignalSpy doneSpy(load, &GridLoad::done); QSignalSpy doneSpy(load, &DetailLoad::done);
load->abort(); load->abort();
a->fire(ok()); // 迟到 a->fire(ok()); // 迟到
EXPECT_EQ(doneSpy.count(), 0); // batch.aborted_ + load.aborted_ 双闸门 EXPECT_EQ(doneSpy.count(), 0); // 双闸门
} }

View File

@ -0,0 +1,254 @@
#include <gtest/gtest.h>
#include <algorithm>
#include <QJsonDocument>
#include <QJsonObject>
#include "dto/MeasurementDto.hpp"
using namespace geopro::data::dto;
static QJsonObject obj(const char* json) {
return QJsonDocument::fromJson(json).object();
}
// scatter/graph 的 dataelectrodeList + scatterGraphData.rows17 列定位数组,取自真实夹具前 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与反演同构混合格式 colorBarhex + rgba alpha 01
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 的 datafiledList列定义+ 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
// 使中段视电阻率≈25250归一化到色阶中部深品红/紫),与原版 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.electrodeNo1-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));
// 列来自 filedList5 列)+ 末尾追加“隐藏/显示”开关列 = 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=Togglecode=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
}

View File

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

View File

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

View File

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