geopro/docs/superpowers/plans/2026-06-12-detail-render-en...

115 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 数据详情「按类型渲染」通用引擎 + 迁移 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>.*`。