geopro/docs/superpowers/plans/2026-06-11-dataset-detail-c...

1629 lines
67 KiB
Markdown
Raw 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.

# 数据集详情视图(平面图表)实现计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 把客户端「数据详情」dock 从 VTK 渲染重建为本地面板 + 平面图表QGraphicsView接真实 API真正接上数据集选择链路100% 复刻 web datasetInfo 的展示ERT 反演首版)。
**Architecture:** VTK 仅当几何算法库(`vtkBandedPolyDataContourFilter` 出色带/等值线几何,无 render windowQGraphicsView 画 2D 场景。新增 `ContourBuilder`render 层)、`ApiDatasetRepository` + DTO 解析、`DatasetChartView`、dd 策略注册表、`AnomalyTablePanel`/`DatasetDetailPage`/`DatasetDetailPanel`、`DatasetDetailController`,并在 `main.cpp` 换掉旧 VTK 详情 dock。复用 `core::Grid/ScatterField/ColorScale/Anomaly`
**Tech Stack:** C++17、Qt6 WidgetsQGraphicsView/Scene/Item、VTK 9.6仅几何、GoogleTest/CTest、CMake、`net::ApiClient`(同步 HTTP
**设计依据:** `docs/superpowers/specs/2026-06-11-dataset-detail-view-design.md`
---
## 文件结构(创建/修改)
| 文件 | 职责 |
|---|---|
| `src/core/model/Field.hpp`(改) | `Grid` 增 NaN 约定 + `hasValue(i,j)` 辅助 |
| `src/render/ContourBands.hpp/.cpp`(建) | `core::Grid + ColorScale → 色带多边形 + 等值线`VTK 几何提取 + 上采样/平滑/裁剪/简化) |
| `src/render/CMakeLists.txt`(改) | 注册 ContourBands |
| `src/data/dto/DatasetChartDto.hpp/.cpp`(建) | `QJsonObject → Grid/ScatterField/ColorScale/Anomaly[]` 解析 |
| `src/data/repo/IDatasetRepository.hpp`(改) | 增 `loadScatterColorScale` |
| `src/data/api/ApiDatasetRepository.hpp/.cpp`(建) | 实现 `IDatasetRepository` 接真实 API |
| `src/data/repo/LocalSampleRepository.hpp`(改) | `loadScatterColorScale``override` |
| `src/data/CMakeLists.txt`(改) | 注册 DatasetChartDto + ApiDatasetRepository |
| `src/app/panels/chart/DatasetChartView.hpp/.cpp`(建) | QGraphicsView 2D 渲染器 |
| `src/app/panels/chart/IDatasetChartStrategy.hpp`(建) | dd 策略接口 + 注册表 |
| `src/app/panels/chart/ErtInversionStrategy.hpp/.cpp`(建) | dd_inversion_data 策略 |
| `src/app/panels/AnomalyTablePanel.hpp/.cpp`(建) | ds 级异常表 |
| `src/app/panels/DatasetDetailPage.hpp/.cpp`(建) | 单数据集详情页 |
| `src/app/panels/DatasetDetailPanel.hpp/.cpp`(建) | 多 Tab 壳 |
| `src/controller/DatasetDetailController.hpp/.cpp`(建) | 编排:选策略→拉数据→发信号 |
| `src/app/CMakeLists.txt` / `src/controller/CMakeLists.txt`(改) | 注册新文件 |
| `src/app/main.cpp`(改) | 移除旧 VTK 详情 dock接上新面板 + 控制器 + 单击/双击 |
| `tests/render/test_contour_bands.cpp`(建) | ContourBands 单测 |
| `tests/data/test_dataset_chart_dto.cpp`(建) | DTO 解析单测 |
| `tests/app/test_chart_strategy_registry.cpp`(建) | 注册表单测 |
| `tests/CMakeLists.txt`(改) | 注册新测试源 |
**构建/测试命令(全程统一):**
- 配置:`cmake --preset default`(若首次);构建:`cmake --build build`
- 跑全部测试:`ctest --test-dir build --output-on-failure`
- 跑单个:`ctest --test-dir build -R <用例名> --output-on-failure`
---
## Phase 1core::Grid NaN + ContourBandsVTK 几何提取)
### Task 1.1Grid 增 NaN 约定 + hasValue
**Files:**
- Modify: `src/core/model/Field.hpp``class Grid`,在 `valueAt` 附近)
- [ ] **Step 1: 写失败测试**
Create `tests/core/test_model_data.cpp` 末尾追加(该文件已在 CMake 中):
```cpp
#include <cmath>
#include "model/Field.hpp"
TEST(GridNaN, HasValueReflectsNaN) {
geopro::core::Grid g(2, 2);
g.valueAt(0, 0) = 3.0;
g.valueAt(1, 0) = std::nan("");
EXPECT_TRUE(g.hasValue(0, 0));
EXPECT_FALSE(g.hasValue(1, 0));
}
```
- [ ] **Step 2: 运行确认失败**
Run: `cmake --build build && ctest --test-dir build -R GridNaN --output-on-failure`
Expected: 编译失败「no member named 'hasValue'」
- [ ] **Step 3: 实现**
`Field.hpp``Grid` 类 public 区(`valueAt` 之后)加:
```cpp
bool hasValue(int i, int j) const { return !std::isnan(valueAt(i, j)); }
```
并在文件顶部 include 区确保有 `#include <cmath>`。在 `Grid` 类注释补一行:`// NaN 值=无效/测区外(凸包裁剪据此)。`
- [ ] **Step 4: 运行确认通过**
Run: `ctest --test-dir build -R GridNaN --output-on-failure`
Expected: PASS
- [ ] **Step 5: 提交**
```bash
git add src/core/model/Field.hpp tests/core/test_model_data.cpp
git commit -m "feat(core): Grid 增 NaN 约定 + hasValue凸包裁剪用"
```
---
### Task 1.2ContourBands 骨架 + 色带提取(不含预处理)
**Files:**
- Create: `src/render/ContourBands.hpp`, `src/render/ContourBands.cpp`
- Modify: `src/render/CMakeLists.txt`
- Test: `tests/render/test_contour_bands.cpp`, `tests/CMakeLists.txt`
- [ ] **Step 1: 写头文件(类型 + 接口)**
Create `src/render/ContourBands.hpp`:
```cpp
#pragma once
#include <vector>
#include "model/Grid.hpp"
#include "model/ColorScale.hpp"
namespace geopro::render {
struct BandPolygon {
geopro::core::Rgba color;
std::vector<geopro::core::Vec2> ring; // 单环多边形局部坐标y=高程向上为正)
};
struct ContourLine {
double level;
std::vector<geopro::core::Vec2> pts;
};
struct ContourBandsResult {
std::vector<BandPolygon> bands;
std::vector<ContourLine> lines;
};
struct ContourOptions {
int upsample = 2; // 双线性上采样倍数1=不采样)
double smooth = 0.3; // 平滑强度 0..10=不平滑)
double simplifyTol = 0.5; // 等值线简化容差数据单位0=不简化)
bool makeLines = true;
};
// core::GridNaN=无效)+ ColorScale 分段 → 色带多边形 + 等值线几何。
// VTK 仅作算法vtkBandedPolyDataContourFilter无 render window。
ContourBandsResult buildContourBands(const geopro::core::Grid& g,
const geopro::core::ColorScale& cs,
const ContourOptions& opt = {});
} // namespace geopro::render
```
> 注:`core::Vec2{double x,y}` 已定义于 `Anomaly.hpp``ContourBands.hpp` 经 `Grid.hpp`/`ColorScale.hpp` 间接需要它,显式 `#include "model/Anomaly.hpp"` 以引入 `Vec2`。在上面 include 区加 `#include "model/Anomaly.hpp"`。
- [ ] **Step 2: 写失败测试**
Create `tests/render/test_contour_bands.cpp`:
```cpp
#include <gtest/gtest.h>
#include "ContourBands.hpp"
using namespace geopro::core;
using namespace geopro::render;
// 2x2 平滑梯度网格 + 2 段色阶 → 至少 1 个色带多边形,多边形顶点 >=3。
TEST(ContourBands, ProducesNonEmptyBands) {
Grid g(3, 3);
g.x = {0, 1, 2}; g.y = {0, 1, 2};
for (int j = 0; j < 3; ++j)
for (int i = 0; i < 3; ++i) g.valueAt(i, j) = static_cast<double>(i + j); // 0..4
g.vmin = 0; g.vmax = 4;
ColorScale cs;
cs.addStop(0.0, Rgba{0, 0, 255, 255});
cs.addStop(2.0, Rgba{0, 255, 0, 255});
cs.addStop(4.0, Rgba{255, 0, 0, 255});
ContourOptions opt; opt.upsample = 1; opt.smooth = 0; opt.simplifyTol = 0;
auto r = buildContourBands(g, cs, opt);
ASSERT_FALSE(r.bands.empty());
for (const auto& b : r.bands) EXPECT_GE(b.ring.size(), 3u);
}
```
- [ ] **Step 3: 注册 CMake + 跑确认失败**
`src/render/CMakeLists.txt`:在现有 `target_sources(geopro_render PRIVATE ...)` 列表加一行 `ContourBands.cpp`(与 `ColorLutBuilder.cpp` 同处)。
`tests/CMakeLists.txt`:在 render 测试段(`render/test_color_lut.cpp` 之后)加 `target_sources(geopro_tests PRIVATE render/test_contour_bands.cpp)`。
Run: `cmake --build build && ctest --test-dir build -R ContourBands --output-on-failure`
Expected: 链接失败「buildContourBands 未定义」
- [ ] **Step 4: 实现色带提取(先不做预处理)**
Create `src/render/ContourBands.cpp`:
```cpp
#include "ContourBands.hpp"
#include <vtkBandedPolyDataContourFilter.h>
#include <vtkCell.h>
#include <vtkCellData.h>
#include <vtkDataSetSurfaceFilter.h>
#include <vtkDoubleArray.h>
#include <vtkIdList.h>
#include <vtkNew.h>
#include <vtkPointData.h>
#include <vtkPoints.h>
#include <vtkPolyData.h>
#include <vtkStructuredGrid.h>
#include <cmath>
namespace geopro::render {
using geopro::core::Grid;
using geopro::core::ColorScale;
using geopro::core::Vec2;
using geopro::core::Rgba;
namespace {
// 用 Grid 构 vtkStructuredGrid点 (x[i], y[j], 0)NaN 值置 0 但记录于掩膜——
// 首版裁剪在 Task 1.4 接入,这里先全量)。
vtkSmartPointer<vtkStructuredGrid> toStructuredGrid(const Grid& g) {
const int nx = g.nx(), ny = g.ny();
auto sg = vtkSmartPointer<vtkStructuredGrid>::New();
sg->SetDimensions(nx, ny, 1);
vtkNew<vtkPoints> pts; pts->SetNumberOfPoints(static_cast<vtkIdType>(nx) * ny);
vtkNew<vtkDoubleArray> sc; sc->SetName("v");
sc->SetNumberOfTuples(static_cast<vtkIdType>(nx) * ny);
for (int j = 0; j < ny; ++j)
for (int i = 0; i < nx; ++i) {
const vtkIdType id = static_cast<vtkIdType>(j) * nx + i;
pts->SetPoint(id, g.x[i], g.y[j], 0.0);
const double v = g.valueAt(i, j);
sc->SetValue(id, std::isnan(v) ? 0.0 : v);
}
sg->SetPoints(pts);
sg->GetPointData()->SetScalars(sc);
return sg;
}
} // namespace
ContourBandsResult buildContourBands(const Grid& g, const ColorScale& cs, const ContourOptions&) {
ContourBandsResult out;
const int nx = g.nx(), ny = g.ny();
if (nx < 2 || ny < 2 || g.x.size() < 2 || g.y.size() < 2) return out;
const std::vector<double> stops = cs.stopValues();
if (stops.size() < 2) return out;
auto sg = toStructuredGrid(g);
vtkNew<vtkDataSetSurfaceFilter> surf; surf->SetInputData(sg);
vtkNew<vtkBandedPolyDataContourFilter> banded;
banded->SetInputConnection(surf->GetOutputPort());
banded->SetNumberOfContours(static_cast<int>(stops.size()));
for (int i = 0; i < static_cast<int>(stops.size()); ++i) banded->SetValue(i, stops[i]);
banded->GenerateContourEdgesOn();
banded->SetScalarModeToValue();
banded->Update();
// port0色带多边形cell 标量=带的代表值)→ 按 ColorScale 上色。
vtkPolyData* poly = banded->GetOutput();
vtkDataArray* cellScalars = poly->GetCellData()->GetScalars();
const vtkIdType nCells = poly->GetNumberOfCells();
for (vtkIdType c = 0; c < nCells; ++c) {
vtkCell* cell = poly->GetCell(c);
vtkPoints* cp = cell->GetPoints();
if (cp->GetNumberOfPoints() < 3) continue;
BandPolygon bp;
const double val = cellScalars ? cellScalars->GetTuple1(c) : 0.0;
bp.color = cs.colorAt(val);
for (vtkIdType p = 0; p < cp->GetNumberOfPoints(); ++p) {
double xyz[3]; cp->GetPoint(p, xyz);
bp.ring.push_back(Vec2{xyz[0], xyz[1]});
}
out.bands.push_back(std::move(bp));
}
// port1等值线polylines
vtkPolyData* edges = banded->GetContourEdgesOutput();
if (edges) {
edges->BuildCells();
const vtkIdType nLines = edges->GetNumberOfCells();
for (vtkIdType c = 0; c < nLines; ++c) {
vtkCell* cell = edges->GetCell(c);
vtkPoints* cp = cell->GetPoints();
ContourLine cl; cl.level = 0.0;
for (vtkIdType p = 0; p < cp->GetNumberOfPoints(); ++p) {
double xyz[3]; cp->GetPoint(p, xyz);
cl.pts.push_back(Vec2{xyz[0], xyz[1]});
}
if (cl.pts.size() >= 2) out.lines.push_back(std::move(cl));
}
}
return out;
}
} // namespace geopro::render
```
- [ ] **Step 5: 跑确认通过 + 提交**
Run: `ctest --test-dir build -R ContourBands --output-on-failure`
Expected: PASS
```bash
git add src/render/ContourBands.hpp src/render/ContourBands.cpp src/render/CMakeLists.txt \
tests/render/test_contour_bands.cpp tests/CMakeLists.txt
git commit -m "feat(render): ContourBands 从 VTK banded 提取色带多边形+等值线几何"
```
---
### Task 1.3:双线性上采样 + 平滑预处理
**Files:**
- Modify: `src/render/ContourBands.cpp`(在 `buildContourBands` 开头插入预处理)
- [ ] **Step 1: 写失败测试**
`tests/render/test_contour_bands.cpp` 追加:
```cpp
// 上采样 2x色带边界更密 → 多边形数应多于不上采样。
TEST(ContourBands, UpsampleIncreasesDetail) {
Grid g(3, 3);
g.x = {0, 1, 2}; g.y = {0, 1, 2};
for (int j = 0; j < 3; ++j)
for (int i = 0; i < 3; ++i) g.valueAt(i, j) = static_cast<double>(i * i + j);
g.vmin = 0; g.vmax = 6;
ColorScale cs;
cs.addStop(0, Rgba{0,0,255,255}); cs.addStop(3, Rgba{0,255,0,255}); cs.addStop(6, Rgba{255,0,0,255});
ContourOptions a; a.upsample = 1; a.smooth = 0; a.simplifyTol = 0;
ContourOptions b; b.upsample = 2; b.smooth = 0; b.simplifyTol = 0;
auto ra = buildContourBands(g, cs, a);
auto rb = buildContourBands(g, cs, b);
EXPECT_GT(rb.bands.size(), ra.bands.size());
}
```
- [ ] **Step 2: 跑确认失败**
Run: `cmake --build build && ctest --test-dir build -R ContourBands.UpsampleIncreasesDetail --output-on-failure`
Expected: FAIL当前忽略 opt两者相等
- [ ] **Step 3: 实现上采样 + 平滑**
`ContourBands.cpp` 的匿名命名空间加:
```cpp
// 双线性上采样nx×ny → ((nx-1)*k+1)×((ny-1)*k+1)。NaN 单元参与的目标点置 NaN。
Grid upsampleBilinear(const Grid& g, int k) {
if (k <= 1) return g;
const int nx = g.nx(), ny = g.ny();
const int Nx = (nx - 1) * k + 1, Ny = (ny - 1) * k + 1;
Grid out(Nx, Ny);
out.x.resize(Nx); out.y.resize(Ny);
for (int I = 0; I < Nx; ++I) { double fi = double(I) / k; int i0 = std::min(int(fi), nx - 2);
double t = fi - i0; out.x[I] = g.x[i0] * (1 - t) + g.x[i0 + 1] * t; }
for (int J = 0; J < Ny; ++J) { double fj = double(J) / k; int j0 = std::min(int(fj), ny - 2);
double t = fj - j0; out.y[J] = g.y[j0] * (1 - t) + g.y[j0 + 1] * t; }
for (int J = 0; J < Ny; ++J) {
double fj = double(J) / k; int j0 = std::min(int(fj), ny - 2); double tj = fj - j0;
for (int I = 0; I < Nx; ++I) {
double fi = double(I) / k; int i0 = std::min(int(fi), nx - 2); double ti = fi - i0;
double v00 = g.valueAt(i0, j0), v10 = g.valueAt(i0 + 1, j0);
double v01 = g.valueAt(i0, j0 + 1), v11 = g.valueAt(i0 + 1, j0 + 1);
if (std::isnan(v00) || std::isnan(v10) || std::isnan(v01) || std::isnan(v11))
out.valueAt(I, J) = std::nan("");
else
out.valueAt(I, J) = (v00 * (1 - ti) + v10 * ti) * (1 - tj)
+ (v01 * (1 - ti) + v11 * ti) * tj;
}
}
out.vmin = g.vmin; out.vmax = g.vmax;
return out;
}
// 3x3 盒式平滑NaN 跳过strength 0..1 线性混合原值。
Grid smoothGrid(const Grid& g, double strength) {
if (strength <= 0) return g;
const int nx = g.nx(), ny = g.ny();
Grid out = g;
for (int j = 0; j < ny; ++j)
for (int i = 0; i < nx; ++i) {
double v = g.valueAt(i, j);
if (std::isnan(v)) continue;
double sum = 0; int n = 0;
for (int dj = -1; dj <= 1; ++dj) for (int di = -1; di <= 1; ++di) {
int ii = i + di, jj = j + dj;
if (ii < 0 || ii >= nx || jj < 0 || jj >= ny) continue;
double w = g.valueAt(ii, jj); if (std::isnan(w)) continue; sum += w; ++n;
}
if (n) out.valueAt(i, j) = v * (1 - strength) + (sum / n) * strength;
}
return out;
}
```
`buildContourBands` 函数体最前面(`if (nx<2...)` 之后插入
```cpp
Grid work = smoothGrid(upsampleBilinear(g, std::max(1, opt.upsample)), opt.smooth);
```
并把后续 `toStructuredGrid(g)`、`nx/ny` 改用 `work` `const int nx=work.nx()...`、`toStructuredGrid(work)`)。注意把函数签名第三参恢复命名`const ContourOptions& opt`。
- [ ] **Step 4: 跑确认通过**
Run: `ctest --test-dir build -R ContourBands --output-on-failure`
Expected: 两个用例均 PASS
- [ ] **Step 5: 提交**
```bash
git add src/render/ContourBands.cpp tests/render/test_contour_bands.cpp
git commit -m "feat(render): ContourBands 双线性上采样+盒式平滑预处理(对齐 web 2x+smooth"
```
---
### Task 1.4NaN 凸包裁剪 + 等值线简化
**Files:**
- Modify: `src/render/ContourBands.cpp`
- [ ] **Step 1: 写失败测试**
追加
```cpp
// 含 NaN 无效角的网格:裁剪后该角不应被任何色带覆盖(所有多边形顶点远离该角)。
TEST(ContourBands, ClipsNaNRegion) {
Grid g(3, 3);
g.x = {0, 1, 2}; g.y = {0, 1, 2};
for (int j = 0; j < 3; ++j) for (int i = 0; i < 3; ++i) g.valueAt(i, j) = 5.0;
g.valueAt(2, 2) = std::nan(""); // 右上角无效
g.vmin = 0; g.vmax = 10;
ColorScale cs; cs.addStop(0, Rgba{0,0,255,255}); cs.addStop(10, Rgba{255,0,0,255});
ContourOptions opt; opt.upsample = 1; opt.smooth = 0; opt.simplifyTol = 0;
auto r = buildContourBands(g, cs, opt);
bool coversCorner = false;
for (const auto& b : r.bands)
for (const auto& p : b.ring)
if (p.x > 1.5 && p.y > 1.5) coversCorner = true;
EXPECT_FALSE(coversCorner);
}
```
- [ ] **Step 2: 跑确认失败**
Run: `cmake --build build && ctest --test-dir build -R ContourBands.ClipsNaNRegion --output-on-failure`
Expected: FAIL当前 NaN 0整片覆盖
- [ ] **Step 3: 实现裁剪 + 简化**
裁剪策略首版务实把含任一 NaN 顶点的网格单元quad `vtkStructuredGrid` **剔除**—— `toStructuredGrid` `vtkUnstructuredGrid` quad 仅当 4 顶点都 `hasValue` `InsertNextCell(VTK_QUAD,...)` `ContourBands.cpp` 顶部加 `#include <vtkUnstructuredGrid.h>`、`#include <vtkCellArray.h>`,并把 `toStructuredGrid` 替换为:
```cpp
vtkSmartPointer<vtkUnstructuredGrid> toCellGrid(const Grid& g) {
const int nx = g.nx(), ny = g.ny();
auto ug = vtkSmartPointer<vtkUnstructuredGrid>::New();
vtkNew<vtkPoints> pts; pts->SetNumberOfPoints(static_cast<vtkIdType>(nx) * ny);
vtkNew<vtkDoubleArray> sc; sc->SetName("v");
sc->SetNumberOfTuples(static_cast<vtkIdType>(nx) * ny);
for (int j = 0; j < ny; ++j) for (int i = 0; i < nx; ++i) {
const vtkIdType id = static_cast<vtkIdType>(j) * nx + i;
pts->SetPoint(id, g.x[i], g.y[j], 0.0);
const double v = g.valueAt(i, j);
sc->SetValue(id, std::isnan(v) ? 0.0 : v);
}
ug->SetPoints(pts);
ug->GetPointData()->SetScalars(sc);
auto idAt = [nx](int i, int j) { return static_cast<vtkIdType>(j) * nx + i; };
for (int j = 0; j < ny - 1; ++j) for (int i = 0; i < nx - 1; ++i) {
if (!g.hasValue(i, j) || !g.hasValue(i+1, j) || !g.hasValue(i, j+1) || !g.hasValue(i+1, j+1))
continue; // 含 NaN 的 quad 不入网格 → 凸包裁剪
vtkIdType quad[4] = { idAt(i,j), idAt(i+1,j), idAt(i+1,j+1), idAt(i,j+1) };
ug->InsertNextCell(VTK_QUAD, 4, quad);
}
return ug;
}
```
`vtkDataSetSurfaceFilter``vtkUnstructuredGrid` 同样适用:把 `surf->SetInputData(sg)` 改为 `surf->SetInputData(toCellGrid(work))`(删除原 `toStructuredGrid(work)` 调用与 `sg` 变量)。
等值线简化:在收集 `cl.pts` 后调用 Douglas-Peucker。加匿名函数
```cpp
void simplifyInPlace(std::vector<Vec2>& pts, double tol) {
if (tol <= 0 || pts.size() < 3) return;
std::vector<char> keep(pts.size(), 0); keep.front() = keep.back() = 1;
std::vector<std::pair<int,int>> st{{0, int(pts.size()) - 1}};
auto dist = [](const Vec2& p, const Vec2& a, const Vec2& b) {
double dx = b.x - a.x, dy = b.y - a.y, L = std::hypot(dx, dy);
if (L < 1e-12) return std::hypot(p.x - a.x, p.y - a.y);
return std::fabs((p.x - a.x) * dy - (p.y - a.y) * dx) / L; };
while (!st.empty()) { auto [s, e] = st.back(); st.pop_back();
double dmax = 0; int idx = -1;
for (int k = s + 1; k < e; ++k) { double d = dist(pts[k], pts[s], pts[e]);
if (d > dmax) { dmax = d; idx = k; } }
if (idx >= 0 && dmax > tol) { keep[idx] = 1; st.push_back({s, idx}); st.push_back({idx, e}); } }
std::vector<Vec2> r; for (size_t k = 0; k < pts.size(); ++k) if (keep[k]) r.push_back(pts[k]);
pts.swap(r);
}
```
`if (cl.pts.size() >= 2)` 之前调用 `simplifyInPlace(cl.pts, opt.simplifyTol);`。文件顶部加 `#include <cmath>`(已含)与 `<vector>`(经头文件已含)。
- [ ] **Step 4: 跑确认通过**
Run: `ctest --test-dir build -R ContourBands --output-on-failure`
Expected: 全部 PASS
- [ ] **Step 5: 提交**
```bash
git add src/render/ContourBands.cpp tests/render/test_contour_bands.cpp
git commit -m "feat(render): ContourBands NaN 凸包裁剪(剔除无效quad)+等值线DP简化"
```
---
## Phase 2DTO 解析 + ApiDatasetRepository
### Task 2.1DatasetChartDto 解析Grid/Scatter/ColorBar/Anomaly
**Files:**
- Create: `src/data/dto/DatasetChartDto.hpp`, `src/data/dto/DatasetChartDto.cpp`
- Modify: `src/data/CMakeLists.txt`
- Test: `tests/data/test_dataset_chart_dto.cpp`, `tests/CMakeLists.txt`
- [ ] **Step 1: 写头文件**
Create `src/data/dto/DatasetChartDto.hpp`:
```cpp
#pragma once
#include <vector>
#include <QJsonObject>
#include <QJsonArray>
#include "model/Field.hpp"
#include "model/ColorScale.hpp"
#include "model/Anomaly.hpp"
namespace geopro::data::dto {
// inversion/rows 的 data{ x,y,v[ny][nx],z,elevation,vmin,vmax } → Gridv 缺省/非数→NaN
geopro::core::Grid parseInversionGrid(const QJsonObject& data);
// getErtRawDataScatterGraph 的 data{ xlist,ylist,hlist,vlist,projectXList,projectYList } → ScatterField。
geopro::core::ScatterField parseScatterGraph(const QJsonObject& data);
// colorGradation/getDetail 的 data.properties.colorBar [[值,"rgba()"],…] → ColorScale。
geopro::core::ColorScale parseColorBar(const QJsonObject& data);
// queryException 的 data 数组 → Anomaly[]location.coordinate / legend
std::vector<geopro::core::Anomaly> parseDatasetAnomalies(const QJsonArray& arr);
} // namespace geopro::data::dto
```
- [ ] **Step 2: 写失败测试**
Create `tests/data/test_dataset_chart_dto.cpp`:
```cpp
#include <gtest/gtest.h>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonArray>
#include "dto/DatasetChartDto.hpp"
using namespace geopro::data::dto;
static QJsonObject obj(const char* json) {
return QJsonDocument::fromJson(json).object();
}
TEST(DatasetChartDto, ParsesInversionGrid) {
auto d = obj(R"({"x":[0,1],"y":[0,1,2],"v":[[1,2],[3,4],[5,6]],"vmin":1,"vmax":6})");
auto g = parseInversionGrid(d);
EXPECT_EQ(g.nx(), 2); EXPECT_EQ(g.ny(), 3);
EXPECT_DOUBLE_EQ(g.valueAt(1, 2), 6.0);
EXPECT_DOUBLE_EQ(g.vmax, 6.0);
}
TEST(DatasetChartDto, ParsesColorBar) {
auto d = obj(R"({"properties":{"colorBar":[["10","rgba(0,0,255,255)"],["20","rgba(255,0,0,255)"]]}})");
auto cs = parseColorBar(d);
auto stops = cs.stopValues();
ASSERT_EQ(stops.size(), 2u);
EXPECT_DOUBLE_EQ(stops[0], 10.0);
auto c = cs.colorAt(12.0); // [10,20) → 蓝
EXPECT_GT(c.b, c.r);
}
TEST(DatasetChartDto, ParsesAnomalyPolyline) {
auto arr = QJsonDocument::fromJson(
R"([{"exceptionName":"A1","exceptionTypeName":"异常区","exceptionMarkType":2,
"legend":{"polylineColor":"#0D0101","polylineWidth":4,"polylineShape":"dash"},
"location":{"coordinate":[{"x":1,"y":2},{"x":3,"y":4}]}}])").array();
auto v = parseDatasetAnomalies(arr);
ASSERT_EQ(v.size(), 1u);
EXPECT_EQ(v[0].name, "A1");
EXPECT_EQ(static_cast<int>(v[0].markType), 2);
ASSERT_EQ(v[0].localPts.size(), 2u);
EXPECT_DOUBLE_EQ(v[0].localPts[1].x, 3.0);
EXPECT_TRUE(v[0].dashed);
}
```
- [ ] **Step 3: 注册 CMake + 跑确认失败**
`src/data/CMakeLists.txt`:在 `target_sources(geopro_data PRIVATE ...)``dto/DatasetChartDto.cpp`
`tests/CMakeLists.txt`data 段加 `target_sources(geopro_tests PRIVATE data/test_dataset_chart_dto.cpp)`
Run: `cmake --build build && ctest --test-dir build -R DatasetChartDto --output-on-failure`
Expected: 链接失败
- [ ] **Step 4: 实现**
Create `src/data/dto/DatasetChartDto.cpp`:
```cpp
#include "dto/DatasetChartDto.hpp"
#include <cmath>
#include <QString>
namespace geopro::data::dto {
using namespace geopro::core;
static double num(const QJsonValue& v, double def = 0.0) {
if (v.isDouble()) return v.toDouble();
if (v.isString()) { bool ok = false; double d = v.toString().toDouble(&ok); return ok ? d : def; }
return def;
}
Grid parseInversionGrid(const QJsonObject& data) {
const QJsonArray x = data.value("x").toArray();
const QJsonArray y = data.value("y").toArray();
const QJsonArray v = data.value("v").toArray(); // [ny][nx]
const int nx = x.size(), ny = y.size();
Grid g(nx < 1 ? 1 : nx, ny < 1 ? 1 : ny);
g.x.clear(); for (auto e : x) g.x.push_back(num(e));
g.y.clear(); for (auto e : y) g.y.push_back(num(e));
for (int j = 0; j < ny; ++j) {
const QJsonArray row = v.at(j).toArray();
for (int i = 0; i < nx; ++i) {
const QJsonValue cell = row.at(i);
g.valueAt(i, j) = (cell.isNull() || cell.isUndefined()) ? std::nan("") : num(cell, std::nan(""));
}
}
g.vmin = num(data.value("vmin")); g.vmax = num(data.value("vmax"));
return g;
}
ScatterField parseScatterGraph(const QJsonObject& data) {
ScatterField s;
auto fill = [&](const char* key, std::vector<double>& dst) {
for (auto e : data.value(key).toArray()) dst.push_back(num(e)); };
fill("xlist", s.x); fill("ylist", s.y); fill("hlist", s.z); fill("vlist", s.v);
fill("projectXList", s.projX); fill("projectYList", s.projY);
return s;
}
ColorScale parseColorBar(const QJsonObject& data) {
ColorScale cs;
const QJsonArray bar = data.value("properties").toObject().value("colorBar").toArray();
for (auto e : bar) {
const QJsonArray pair = e.toArray();
if (pair.size() < 2) continue;
const double val = num(pair.at(0));
const std::string rgba = pair.at(1).toString().toStdString();
cs.addStop(val, parseColor(rgba, AlphaScale::Bit255));
}
return cs;
}
std::vector<Anomaly> parseDatasetAnomalies(const QJsonArray& arr) {
std::vector<Anomaly> out;
for (auto e : arr) {
const QJsonObject o = e.toObject();
Anomaly a;
a.name = o.value("exceptionName").toString().toStdString();
a.typeName = o.value("exceptionTypeName").toString().toStdString();
a.markType = static_cast<AnomalyMarkType>(o.value("exceptionMarkType").toInt(2));
const QJsonObject lg = o.value("legend").toObject();
a.lineColor = lg.value("polylineColor").toString("#000000").toStdString();
a.lineWidth = lg.value("polylineWidth").toDouble(1.0);
a.dashed = lg.value("polylineShape").toString() == "dash";
for (auto c : o.value("location").toObject().value("coordinate").toArray()) {
const QJsonObject p = c.toObject();
a.localPts.push_back(Vec2{p.value("x").toDouble(), p.value("y").toDouble()});
}
out.push_back(std::move(a));
}
return out;
}
} // namespace geopro::data::dto
```
- [ ] **Step 5: 跑确认通过 + 提交**
Run: `ctest --test-dir build -R DatasetChartDto --output-on-failure`
Expected: PASS
```bash
git add src/data/dto/DatasetChartDto.hpp src/data/dto/DatasetChartDto.cpp \
src/data/CMakeLists.txt tests/data/test_dataset_chart_dto.cpp tests/CMakeLists.txt
git commit -m "feat(data): DatasetChartDto 解析 inversion网格/散点/colorBar/异常"
```
---
### Task 2.2IDatasetRepository 增 loadScatterColorScale + ApiDatasetRepository
**Files:**
- Modify: `src/data/repo/IDatasetRepository.hpp`, `src/data/repo/LocalSampleRepository.hpp`
- Create: `src/data/api/ApiDatasetRepository.hpp`, `src/data/api/ApiDatasetRepository.cpp`
- Modify: `src/data/CMakeLists.txt`
- [ ] **Step 1: 接口增方法**
`src/data/repo/IDatasetRepository.hpp`:在 `loadColorScale` 后加纯虚:
```cpp
virtual geopro::core::ColorScale loadScatterColorScale(const std::string& dsId) = 0;
```
`src/data/repo/LocalSampleRepository.hpp`:给已有的 `loadScatterColorScale` 声明加 `override`(若未标)。
- [ ] **Step 2: 写头文件**
Create `src/data/api/ApiDatasetRepository.hpp`:
```cpp
#pragma once
#include "repo/IDatasetRepository.hpp"
namespace geopro::net { class ApiClient; }
namespace geopro::data {
// 真实 API 实现 IDatasetRepositoryERT 反演相关)。失败抛 std::runtime_error。
class ApiDatasetRepository : public IDatasetRepository {
public:
explicit ApiDatasetRepository(net::ApiClient& api);
std::vector<GsNode> loadStructure() override { return {}; } // 不经此仓储取结构
geopro::core::Grid loadGrid(const std::string& dsId) override;
geopro::core::ScatterField loadScatter(const std::string& dsId) override;
geopro::core::ColorScale loadColorScale(const std::string& dsId) override; // type2 网格
geopro::core::ColorScale loadScatterColorScale(const std::string& dsId) override; // type1 原数据
std::vector<geopro::core::Anomaly> loadAnomalies(const std::string& dsId) override;
private:
net::ApiClient& api_;
};
} // namespace geopro::data
```
- [ ] **Step 3: 实现**
Create `src/data/api/ApiDatasetRepository.cpp`:
```cpp
#include "api/ApiDatasetRepository.hpp"
#include <stdexcept>
#include <QJsonObject>
#include <QString>
#include <QUrl>
#include "ApiClient.hpp"
#include "dto/DatasetChartDto.hpp"
namespace geopro::data {
namespace {
QString enc(const std::string& s) {
return QString::fromUtf8(QUrl::toPercentEncoding(QString::fromStdString(s)));
}
void must(const net::ApiResponse& r, const char* what) {
if (r.code != 200) throw std::runtime_error(std::string(what) + " failed: " +
(r.msg.isEmpty() ? r.rawError.toStdString() : r.msg.toStdString()));
}
geopro::core::ColorScale colorScale(net::ApiClient& api, const std::string& dsId, int type) {
QJsonObject body{{"dsObjectId", QString::fromStdString(dsId)}, {"businessCode", ""}, {"type", type}};
const net::ApiResponse r = api.postJson(QStringLiteral("/business/lvl/colorGradation/getDetail"), body);
must(r, "colorGradation");
return dto::parseColorBar(r.data);
}
} // namespace
ApiDatasetRepository::ApiDatasetRepository(net::ApiClient& api) : api_(api) {}
geopro::core::Grid ApiDatasetRepository::loadGrid(const std::string& dsId) {
const net::ApiResponse r = api_.get(
QStringLiteral("/business/dd/ert/inversion/rows/%1").arg(enc(dsId)));
must(r, "inversion/rows");
return dto::parseInversionGrid(r.data);
}
geopro::core::ScatterField ApiDatasetRepository::loadScatter(const std::string& dsId) {
const net::ApiResponse r = api_.get(
QStringLiteral("/business/dd/ert/inversion/getErtRawDataScatterGraph/%1").arg(enc(dsId)));
must(r, "scatterGraph");
return dto::parseScatterGraph(r.data);
}
geopro::core::ColorScale ApiDatasetRepository::loadColorScale(const std::string& dsId) {
return colorScale(api_, dsId, 2);
}
geopro::core::ColorScale ApiDatasetRepository::loadScatterColorScale(const std::string& dsId) {
return colorScale(api_, dsId, 1);
}
std::vector<geopro::core::Anomaly> ApiDatasetRepository::loadAnomalies(const std::string& dsId) {
const net::ApiResponse r = api_.get(
QStringLiteral("/business/exception/queryException/%1").arg(enc(dsId)));
must(r, "queryException");
return dto::parseDatasetAnomalies(r.data.value(QStringLiteral("value")).toArray());
}
} // namespace geopro::data
```
> 注:`queryException` 的 `data` 是否含 `value` 包裹按现网验证调整——若 `r.data` 直接是数组语义,改为对应取法;本仓返回信封 `data` 为对象,数组在 `value` 字段(同 `loadExceptionsByTm`)。
`src/data/CMakeLists.txt``target_sources(geopro_data PRIVATE api/ApiDatasetRepository.cpp)`。确认 `geopro_data` 链接了 `geopro_net``ApiClient`)——`ApiProjectRepository` 已用,故已链。
- [ ] **Step 4: 构建确认通过**
Run: `cmake --build build`
Expected: 编译链接通过(无新测试,编译即验证签名一致)。
- [ ] **Step 5: 提交**
```bash
git add src/data/repo/IDatasetRepository.hpp src/data/repo/LocalSampleRepository.hpp \
src/data/api/ApiDatasetRepository.hpp src/data/api/ApiDatasetRepository.cpp src/data/CMakeLists.txt
git commit -m "feat(data): ApiDatasetRepository 接真实 API + loadScatterColorScale 提到接口"
```
---
## Phase 3DatasetChartViewQGraphicsView 渲染器)
### Task 3.1DatasetChartView 散点/等值面/异常渲染
**Files:**
- Create: `src/app/panels/chart/DatasetChartView.hpp`, `src/app/panels/chart/DatasetChartView.cpp`
- Modify: `src/app/CMakeLists.txt`
> 说明UI 渲染类以集成/视觉验证为主,不做像素级单测。本任务给出可编译运行的骨架与渲染逻辑;坐标轴钉边重算在 Task 3.2。
- [ ] **Step 1: 写头文件**
Create `src/app/panels/chart/DatasetChartView.hpp`:
```cpp
#pragma once
#include <set>
#include <vector>
#include <QGraphicsView>
#include "model/Field.hpp"
#include "model/ColorScale.hpp"
#include "model/Anomaly.hpp"
#include "ContourBands.hpp"
namespace geopro::app {
// 平面图表视图:散点(原数据) / 等值面(网格) + 异常叠加 + 色阶图例。VTK 仅经 ContourBands 算几何。
class DatasetChartView : public QGraphicsView {
Q_OBJECT
public:
explicit DatasetChartView(QWidget* parent = nullptr);
void showScatter(const geopro::core::ScatterField& f, const geopro::core::ColorScale& cs);
void showContour(const geopro::core::Grid& g, const geopro::core::ColorScale& cs,
const geopro::render::ContourOptions& opt);
void setAnomalies(const std::vector<geopro::core::Anomaly>& list);
void setHiddenAnomalies(const std::set<int>& hidden); // 下标=list 序
void setShowAnomalies(bool on);
void setShowContourLines(bool on);
void clearChart();
protected:
void wheelEvent(QWheelEvent* e) override; // 滚轮缩放
private:
void rebuildAnomalyItems();
QGraphicsScene* scene_;
std::vector<geopro::core::Anomaly> anomalies_;
std::set<int> hidden_;
bool showAnomalies_ = true;
bool showContourLines_ = true;
std::vector<class QGraphicsItem*> anomalyItems_;
};
} // namespace geopro::app
```
- [ ] **Step 2: 实现**
Create `src/app/panels/chart/DatasetChartView.cpp`:
```cpp
#include "panels/chart/DatasetChartView.hpp"
#include <QGraphicsScene>
#include <QGraphicsPathItem>
#include <QGraphicsRectItem>
#include <QPainterPath>
#include <QPen>
#include <QBrush>
#include <QColor>
#include <QWheelEvent>
namespace geopro::app {
using geopro::core::Rgba;
static QColor toQ(const Rgba& c) { return QColor(c.r, c.g, c.b, c.a); }
DatasetChartView::DatasetChartView(QWidget* parent)
: QGraphicsView(parent), scene_(new QGraphicsScene(this)) {
setScene(scene_);
setRenderHint(QPainter::Antialiasing, true);
setDragMode(QGraphicsView::ScrollHandDrag);
// 场景 y 向上为正:用 y 翻转的变换QGraphicsView 默认 y 向下)。
scale(1, -1);
}
void DatasetChartView::clearChart() {
scene_->clear(); anomalyItems_.clear();
}
void DatasetChartView::showScatter(const geopro::core::ScatterField& f, const geopro::core::ColorScale& cs) {
clearChart();
const double sz = 0.6; // 方块边长(数据单位,近似 web 方点)
for (size_t i = 0; i < f.v.size(); ++i) {
auto* r = scene_->addRect(f.x[i] - sz / 2, f.y[i] - sz / 2, sz, sz,
QPen(Qt::white, 0), QBrush(toQ(cs.colorAt(f.v[i]))));
r->setPen(QPen(Qt::white, 0)); // 白描边
}
rebuildAnomalyItems();
fitInView(scene_->itemsBoundingRect(), Qt::KeepAspectRatio); // x:y 等比
}
void DatasetChartView::showContour(const geopro::core::Grid& g, const geopro::core::ColorScale& cs,
const geopro::render::ContourOptions& opt) {
clearChart();
const auto r = geopro::render::buildContourBands(g, cs, opt);
for (const auto& b : r.bands) {
QPainterPath path; if (b.ring.empty()) continue;
path.moveTo(b.ring[0].x, b.ring[0].y);
for (size_t k = 1; k < b.ring.size(); ++k) path.lineTo(b.ring[k].x, b.ring[k].y);
path.closeSubpath();
auto* it = scene_->addPath(path, QPen(Qt::NoPen), QBrush(toQ(b.color)));
it->setZValue(0);
}
if (showContourLines_)
for (const auto& l : r.lines) {
QPainterPath path; path.moveTo(l.pts[0].x, l.pts[0].y);
for (size_t k = 1; k < l.pts.size(); ++k) path.lineTo(l.pts[k].x, l.pts[k].y);
auto* it = scene_->addPath(path, QPen(QColor(0, 0, 0), 0));
it->setZValue(1);
}
rebuildAnomalyItems();
fitInView(scene_->itemsBoundingRect(), Qt::IgnoreAspectRatio); // 剖面 X/Y 各自铺满
}
void DatasetChartView::setAnomalies(const std::vector<geopro::core::Anomaly>& list) {
anomalies_ = list; rebuildAnomalyItems();
}
void DatasetChartView::setHiddenAnomalies(const std::set<int>& hidden) { hidden_ = hidden; rebuildAnomalyItems(); }
void DatasetChartView::setShowAnomalies(bool on) { showAnomalies_ = on; rebuildAnomalyItems(); }
void DatasetChartView::setShowContourLines(bool on) { showContourLines_ = on; }
void DatasetChartView::rebuildAnomalyItems() {
for (auto* it : anomalyItems_) scene_->removeItem(it), delete it;
anomalyItems_.clear();
if (!showAnomalies_) return;
for (int i = 0; i < static_cast<int>(anomalies_.size()); ++i) {
if (hidden_.count(i)) continue;
const auto& a = anomalies_[i];
if (a.localPts.size() < 2) continue;
QPainterPath path; path.moveTo(a.localPts[0].x, a.localPts[0].y);
for (size_t k = 1; k < a.localPts.size(); ++k) path.lineTo(a.localPts[k].x, a.localPts[k].y);
if (static_cast<int>(a.markType) == 3) path.closeSubpath();
QPen pen(QColor(QString::fromStdString(a.lineColor)), 0);
if (a.dashed) pen.setStyle(Qt::DashLine);
auto* it = scene_->addPath(path, pen);
it->setZValue(2);
anomalyItems_.push_back(it);
}
}
void DatasetChartView::wheelEvent(QWheelEvent* e) {
const double f = e->angleDelta().y() > 0 ? 1.15 : 1 / 1.15;
scale(f, f);
}
} // namespace geopro::app
```
`src/app/CMakeLists.txt`:把 `panels/chart/DatasetChartView.cpp` 加入 app 目标的 `target_sources`(与其它 panel 同处);确认 app 目标 include 了 `src/render` 头路径(已链 geopro_render 则传递;若无,`target_link_libraries(... geopro_render)` 已存在即可)。
- [ ] **Step 3: 构建确认通过**
Run: `cmake --build build`
Expected: 编译通过moc 处理 Q_OBJECT
- [ ] **Step 4: 提交**
```bash
git add src/app/panels/chart/DatasetChartView.hpp src/app/panels/chart/DatasetChartView.cpp src/app/CMakeLists.txt
git commit -m "feat(ui): DatasetChartView 散点/等值面/异常叠加(QGraphicsView)"
```
---
### Task 3.2:坐标轴 overlay + 色阶图例 + 等比
**Files:**
- Modify: `src/app/panels/chart/DatasetChartView.hpp/.cpp`
- [ ] **Step 1: 加坐标轴与图例绘制**
`DatasetChartView` 重写 `drawForeground(QPainter*, const QRectF&)`:用 `mapFromScene` 把场景刻度投影到视口画轴线/刻度文字(轴钉视口边缘,缩放/平移随 `drawForeground` 自动重绘);底部画色阶图例(离散色带 + 分段值)。
头文件加:
```cpp
protected:
void drawForeground(QPainter* p, const QRectF& rect) override;
private:
geopro::core::ColorScale legendScale_;
void setLegend(const geopro::core::ColorScale& cs) { legendScale_ = cs; }
```
`showScatter`/`showContour` 末尾调用 `setLegend(cs);``viewport()->update();`
`drawForeground` 实现:以 `viewport()->rect()` 为基;遍历场景包围盒 x/y 范围取若干刻度,`mapFromScene(QPointF(sx, sy))` 得像素位置画 `p->drawLine`/`p->drawText`;底部按 `legendScale_.stopValues()` 平均分段画矩形 + 文字。
(完整像素代码在实现时据视觉对照微调,逻辑:轴线沿 viewport 左/下边,刻度 6~8 个,图例条高约 16px 居底。)
- [ ] **Step 2: 构建 + 手动运行核对**
Run: `cmake --build build`,启动 app`build/src/app/geopro_app` 或既有启动脚本),选中一个反演数据集,肉眼对照 `docs/superpowers/specs/assets/web-datasetinfo-*.png`:散点等比、等值面带边、异常虚线、底部色阶图例、坐标轴刻度。
Expected: 视觉与 web 接近(~95%)。
- [ ] **Step 3: 提交**
```bash
git add src/app/panels/chart/DatasetChartView.hpp src/app/panels/chart/DatasetChartView.cpp
git commit -m "feat(ui): DatasetChartView 坐标轴 overlay(钉边重算)+底部色阶图例"
```
---
## Phase 4dd 策略框架 + DatasetDetailController
### Task 4.1:策略接口 + 注册表(含降级)
**Files:**
- Create: `src/app/panels/chart/IDatasetChartStrategy.hpp`
- Test: `tests/app/test_chart_strategy_registry.cpp`, `tests/CMakeLists.txt`
- [ ] **Step 1: 写头文件**
Create `src/app/panels/chart/IDatasetChartStrategy.hpp`:
```cpp
#pragma once
#include <map>
#include <memory>
#include <string>
namespace geopro::app {
class DatasetDetailPage; // 前置
namespace geopro::data { }
// dd 类型驱动的图表策略:决定某 ddCode 的详情页如何加载/渲染。
struct IDatasetChartStrategy {
virtual ~IDatasetChartStrategy() = default;
virtual std::string ddCode() const = 0;
};
class ChartStrategyRegistry {
public:
void add(std::unique_ptr<IDatasetChartStrategy> s) {
const std::string code = s->ddCode();
map_[code] = std::move(s);
}
IDatasetChartStrategy* find(const std::string& ddCode) const {
auto it = map_.find(ddCode);
return it == map_.end() ? nullptr : it->second.get();
}
bool supports(const std::string& ddCode) const { return map_.count(ddCode) > 0; }
private:
std::map<std::string, std::unique_ptr<IDatasetChartStrategy>> map_;
};
} // namespace geopro::app
```
- [ ] **Step 2: 写失败测试**
Create `tests/app/test_chart_strategy_registry.cpp`:
```cpp
#include <gtest/gtest.h>
#include "panels/chart/IDatasetChartStrategy.hpp"
using namespace geopro::app;
namespace {
struct Fake : IDatasetChartStrategy { std::string ddCode() const override { return "dd_inversion_data"; } };
}
TEST(ChartStrategyRegistry, FindsRegisteredAndDegradesUnknown) {
ChartStrategyRegistry reg;
reg.add(std::make_unique<Fake>());
EXPECT_TRUE(reg.supports("dd_inversion_data"));
EXPECT_NE(reg.find("dd_inversion_data"), nullptr);
EXPECT_FALSE(reg.supports("dd_unknown"));
EXPECT_EQ(reg.find("dd_unknown"), nullptr);
}
```
- [ ] **Step 3: 注册 CMake + 跑确认失败 → 通过**
`tests/CMakeLists.txt`:新增一段把 `app/test_chart_strategy_registry.cpp` 加入 `geopro_tests``target_link_libraries(geopro_tests PRIVATE geopro_app)`(若 `geopro_app` 是可执行而非库,则改为仅 `target_include_directories(geopro_tests PRIVATE ${CMAKE_SOURCE_DIR}/src/app)`,因该测试只用头文件 `IDatasetChartStrategy.hpp`,无需链 app。优先后者纯头测试
```cmake
target_include_directories(geopro_tests PRIVATE ${CMAKE_SOURCE_DIR}/src/app)
target_sources(geopro_tests PRIVATE app/test_chart_strategy_registry.cpp)
```
Run: `cmake --build build && ctest --test-dir build -R ChartStrategyRegistry --output-on-failure`
Expected: PASS
- [ ] **Step 4: 提交**
```bash
git add src/app/panels/chart/IDatasetChartStrategy.hpp tests/app/test_chart_strategy_registry.cpp tests/CMakeLists.txt
git commit -m "feat(ui): dd 图表策略接口 + 注册表(未注册降级)"
```
---
### Task 4.2DatasetDetailController + ErtInversionStrategy
**Files:**
- Create: `src/controller/DatasetDetailController.hpp/.cpp`, `src/app/panels/chart/ErtInversionStrategy.hpp/.cpp`
- Modify: `src/controller/CMakeLists.txt`, `src/app/CMakeLists.txt`
- [ ] **Step 1: 写控制器头**
Create `src/controller/DatasetDetailController.hpp`:
```cpp
#pragma once
#include <string>
#include <QObject>
#include <QString>
#include "model/Field.hpp"
#include "model/ColorScale.hpp"
#include "model/Anomaly.hpp"
namespace geopro::data { class IDatasetRepository; }
namespace geopro::controller {
// 数据详情编排:单击/双击数据集 → 拉 散点/网格/色阶/异常 → 发信号给详情面板。被动视图。
// 仅服务图表,不与 WorkbenchNavController项目/结构导航)耦合。
class DatasetDetailController : public QObject {
Q_OBJECT
public:
struct ChartData {
QString dsId, ddCode;
geopro::core::ScatterField scatter;
geopro::core::ColorScale scatterScale;
geopro::core::Grid grid;
geopro::core::ColorScale gridScale;
std::vector<geopro::core::Anomaly> anomalies;
};
explicit DatasetDetailController(data::IDatasetRepository& repo, QObject* parent = nullptr);
public slots:
void openDataset(const QString& dsId, const QString& ddCode); // 双击=新建/聚焦页
void focusDataset(const QString& dsId); // 单击=聚焦已开页
signals:
void chartReady(const ChartData& data);
void focusRequested(const QString& dsId);
void loadFailed(const QString& dsId, const QString& message);
private:
data::IDatasetRepository& repo_;
};
} // namespace geopro::controller
```
- [ ] **Step 2: 实现控制器**
Create `src/controller/DatasetDetailController.cpp`:
```cpp
#include "DatasetDetailController.hpp"
#include <stdexcept>
#include "repo/IDatasetRepository.hpp"
namespace geopro::controller {
DatasetDetailController::DatasetDetailController(data::IDatasetRepository& repo, QObject* parent)
: QObject(parent), repo_(repo) {}
void DatasetDetailController::openDataset(const QString& dsId, const QString& ddCode) {
const std::string id = dsId.toStdString();
ChartData d; d.dsId = dsId; d.ddCode = ddCode;
try {
d.scatter = repo_.loadScatter(id);
d.scatterScale = repo_.loadScatterColorScale(id);
d.grid = repo_.loadGrid(id);
d.gridScale = repo_.loadColorScale(id);
d.anomalies = repo_.loadAnomalies(id);
} catch (const std::exception& e) {
emit loadFailed(dsId, QString::fromStdString(e.what()));
return;
}
emit chartReady(d);
}
void DatasetDetailController::focusDataset(const QString& dsId) { emit focusRequested(dsId); }
} // namespace geopro::controller
```
`src/controller/CMakeLists.txt`:把 `DatasetDetailController.cpp` 加入 `geopro_controller``target_sources`;确认链接 `geopro_data`(接口)+ `Qt6::Core`(已有)。
- [ ] **Step 3: ErtInversionStrategy标识用渲染由页驱动**
Create `src/app/panels/chart/ErtInversionStrategy.hpp`:
```cpp
#pragma once
#include "panels/chart/IDatasetChartStrategy.hpp"
namespace geopro::app {
struct ErtInversionStrategy : IDatasetChartStrategy {
std::string ddCode() const override { return "dd_inversion_data"; }
};
} // namespace geopro::app
```
> 首版 ErtInversion 仅作 ddCode 标识/能力声明;具体「散点+等值面」渲染由 `DatasetDetailPage` 消费 `ChartData` 完成(见 Task 5.2)。后续 dd 类型可把渲染细节下沉到策略。无 .cpp。
- [ ] **Step 4: 构建确认通过 + 提交**
Run: `cmake --build build`
Expected: 通过
```bash
git add src/controller/DatasetDetailController.hpp src/controller/DatasetDetailController.cpp \
src/controller/CMakeLists.txt src/app/panels/chart/ErtInversionStrategy.hpp
git commit -m "feat(controller): DatasetDetailController 编排 + ErtInversionStrategy 标识"
```
---
## Phase 5异常表 + 详情页 + 多 Tab 壳
### Task 5.1AnomalyTablePanel行显隐→图表
**Files:**
- Create: `src/app/panels/AnomalyTablePanel.hpp/.cpp`
- Modify: `src/app/CMakeLists.txt`
- [ ] **Step 1: 写头文件**
Create `src/app/panels/AnomalyTablePanel.hpp`:
```cpp
#pragma once
#include <set>
#include <vector>
#include <QWidget>
#include "model/Anomaly.hpp"
class QTableWidget;
namespace geopro::app {
// ds 级异常表:名称/异常类型/几何类型/创建时间/备注/操作(显隐眼睛)。行显隐 → 信号驱动图表叠加。
class AnomalyTablePanel : public QWidget {
Q_OBJECT
public:
explicit AnomalyTablePanel(QWidget* parent = nullptr);
void setAnomalies(const std::vector<geopro::core::Anomaly>& list,
const std::vector<QString>& createTimes,
const std::vector<QString>& remarks);
signals:
void hiddenChanged(const std::set<int>& hiddenIndices);
private:
QTableWidget* table_;
std::set<int> hidden_;
};
} // namespace geopro::app
```
- [ ] **Step 2: 实现**
Create `src/app/panels/AnomalyTablePanel.cpp`:
```cpp
#include "panels/AnomalyTablePanel.hpp"
#include <QVBoxLayout>
#include <QTableWidget>
#include <QHeaderView>
#include <QToolButton>
namespace geopro::app {
static QString markName(int t) { return t == 1 ? "点" : t == 3 ? "多边形" : "多段线"; }
AnomalyTablePanel::AnomalyTablePanel(QWidget* parent) : QWidget(parent) {
auto* lay = new QVBoxLayout(this); lay->setContentsMargins(0, 0, 0, 0);
table_ = new QTableWidget(this);
table_->setColumnCount(6);
table_->setHorizontalHeaderLabels({"名称", "异常类型", "几何类型", "创建时间", "备注", "操作"});
table_->horizontalHeader()->setStretchLastSection(true);
table_->setEditTriggers(QAbstractItemView::NoEditTriggers);
lay->addWidget(table_);
}
void AnomalyTablePanel::setAnomalies(const std::vector<geopro::core::Anomaly>& list,
const std::vector<QString>& createTimes,
const std::vector<QString>& remarks) {
hidden_.clear();
table_->setRowCount(static_cast<int>(list.size()));
for (int i = 0; i < static_cast<int>(list.size()); ++i) {
const auto& a = list[i];
table_->setItem(i, 0, new QTableWidgetItem(QString::fromStdString(a.name)));
table_->setItem(i, 1, new QTableWidgetItem(QString::fromStdString(a.typeName)));
table_->setItem(i, 2, new QTableWidgetItem(markName(static_cast<int>(a.markType))));
table_->setItem(i, 3, new QTableWidgetItem(i < (int)createTimes.size() ? createTimes[i] : ""));
table_->setItem(i, 4, new QTableWidgetItem(i < (int)remarks.size() ? remarks[i] : ""));
auto* eye = new QToolButton(table_); eye->setCheckable(true); eye->setChecked(true);
eye->setText("👁");
connect(eye, &QToolButton::toggled, this, [this, i](bool on) {
if (on) hidden_.erase(i); else hidden_.insert(i);
emit hiddenChanged(hidden_);
});
table_->setCellWidget(i, 5, eye);
}
}
} // namespace geopro::app
```
`src/app/CMakeLists.txt`:加 `panels/AnomalyTablePanel.cpp`
- [ ] **Step 3: 构建 + 提交**
Run: `cmake --build build`
```bash
git add src/app/panels/AnomalyTablePanel.hpp src/app/panels/AnomalyTablePanel.cpp src/app/CMakeLists.txt
git commit -m "feat(ui): AnomalyTablePanel ds级异常表(行眼睛→隐藏集信号)"
```
---
### Task 5.2DatasetDetailPage标题+切换+图表+异常表)
**Files:**
- Create: `src/app/panels/DatasetDetailPage.hpp/.cpp`
- Modify: `src/app/CMakeLists.txt`
- [ ] **Step 1: 写头文件**
Create `src/app/panels/DatasetDetailPage.hpp`:
```cpp
#pragma once
#include <QWidget>
#include "DatasetDetailController.hpp" // ChartData
namespace geopro::app {
class DatasetChartView; class AnomalyTablePanel;
// 单个数据集详情页:标题 + 原数据/网格数据 切换 + 叠加开关 + 图表 + 异常表。
class DatasetDetailPage : public QWidget {
Q_OBJECT
public:
explicit DatasetDetailPage(QWidget* parent = nullptr);
void setData(const geopro::controller::DatasetDetailController::ChartData& d);
QString dsId() const { return dsId_; }
private:
void showScatterMode(); void showGridMode();
QString dsId_;
geopro::controller::DatasetDetailController::ChartData data_;
DatasetChartView* chart_;
AnomalyTablePanel* anomalyTable_;
bool gridMode_ = true;
};
} // namespace geopro::app
```
- [ ] **Step 2: 实现(接线切换/叠加/异常联动)**
Create `src/app/panels/DatasetDetailPage.cpp`:
```cpp
#include "panels/DatasetDetailPage.hpp"
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QLabel>
#include <QToolButton>
#include <QButtonGroup>
#include <QCheckBox>
#include "panels/chart/DatasetChartView.hpp"
#include "panels/AnomalyTablePanel.hpp"
namespace geopro::app {
DatasetDetailPage::DatasetDetailPage(QWidget* parent) : QWidget(parent) {
auto* lay = new QVBoxLayout(this);
auto* bar = new QHBoxLayout();
auto* origin = new QToolButton(this); origin->setText("原数据"); origin->setCheckable(true);
auto* grid = new QToolButton(this); grid->setText("网格数据"); grid->setCheckable(true);
grid->setChecked(true);
auto* grp = new QButtonGroup(this); grp->setExclusive(true); grp->addButton(origin); grp->addButton(grid);
auto* showAnom = new QCheckBox("显示异常", this); showAnom->setChecked(true);
auto* showLines = new QCheckBox("显示等值线", this); showLines->setChecked(true);
bar->addWidget(origin); bar->addWidget(grid); bar->addStretch();
bar->addWidget(showAnom); bar->addWidget(showLines);
lay->addLayout(bar);
chart_ = new DatasetChartView(this);
anomalyTable_ = new AnomalyTablePanel(this);
lay->addWidget(chart_, 3);
lay->addWidget(anomalyTable_, 1);
connect(grid, &QToolButton::clicked, this, [this] { gridMode_ = true; showGridMode(); });
connect(origin, &QToolButton::clicked, this, [this] { gridMode_ = false; showScatterMode(); });
connect(showAnom, &QCheckBox::toggled, chart_, [this](bool on) { chart_->setShowAnomalies(on); });
connect(showLines, &QCheckBox::toggled, chart_, [this](bool on) {
chart_->setShowContourLines(on); if (gridMode_) showGridMode(); });
connect(anomalyTable_, &AnomalyTablePanel::hiddenChanged, chart_,
[this](const std::set<int>& h) { chart_->setHiddenAnomalies(h); });
}
void DatasetDetailPage::setData(const geopro::controller::DatasetDetailController::ChartData& d) {
dsId_ = d.dsId; data_ = d;
chart_->setAnomalies(d.anomalies);
anomalyTable_->setAnomalies(d.anomalies, {}, {}); // 创建时间/备注可后续从 VO 补
if (gridMode_) showGridMode(); else showScatterMode();
}
void DatasetDetailPage::showGridMode() {
geopro::render::ContourOptions opt; // 默认 2x+smooth+simplify0.5
chart_->showContour(data_.grid, data_.gridScale, opt);
}
void DatasetDetailPage::showScatterMode() {
chart_->showScatter(data_.scatter, data_.scatterScale);
}
} // namespace geopro::app
```
`src/app/CMakeLists.txt`:加 `panels/DatasetDetailPage.cpp`;确认 app 链接 `geopro_controller`(用 ChartData——main 已链则已有。
- [ ] **Step 3: 构建 + 提交**
Run: `cmake --build build`
```bash
git add src/app/panels/DatasetDetailPage.hpp src/app/panels/DatasetDetailPage.cpp src/app/CMakeLists.txt
git commit -m "feat(ui): DatasetDetailPage 原数据/网格切换+叠加开关+异常联动"
```
---
### Task 5.3DatasetDetailPanel多 Tab按 dsId 去重)
**Files:**
- Create: `src/app/panels/DatasetDetailPanel.hpp/.cpp`
- Modify: `src/app/CMakeLists.txt`
- [ ] **Step 1: 写头文件**
Create `src/app/panels/DatasetDetailPanel.hpp`:
```cpp
#pragma once
#include <QTabWidget>
#include "DatasetDetailController.hpp"
namespace geopro::app {
class DatasetDetailPage;
// 多 Tab 壳:每数据集一页(按 dsId 去重)。R095。
class DatasetDetailPanel : public QTabWidget {
Q_OBJECT
public:
explicit DatasetDetailPanel(QWidget* parent = nullptr);
void openOrUpdate(const geopro::controller::DatasetDetailController::ChartData& d); // 双击/数据到达
void focusDataset(const QString& dsId); // 单击聚焦已开页
signals:
void activeDatasetChanged(const QString& dsId); // 反向联动数据集列表
private:
DatasetDetailPage* pageFor(const QString& dsId) const;
};
} // namespace geopro::app
```
- [ ] **Step 2: 实现**
Create `src/app/panels/DatasetDetailPanel.cpp`:
```cpp
#include "panels/DatasetDetailPanel.hpp"
#include "panels/DatasetDetailPage.hpp"
namespace geopro::app {
DatasetDetailPanel::DatasetDetailPanel(QWidget* parent) : QTabWidget(parent) {
setTabsClosable(true);
connect(this, &QTabWidget::tabCloseRequested, this, [this](int i) { delete widget(i); });
connect(this, &QTabWidget::currentChanged, this, [this](int i) {
if (auto* p = qobject_cast<DatasetDetailPage*>(widget(i)))
emit activeDatasetChanged(p->dsId());
});
}
DatasetDetailPage* DatasetDetailPanel::pageFor(const QString& dsId) const {
for (int i = 0; i < count(); ++i)
if (auto* p = qobject_cast<DatasetDetailPage*>(widget(i)))
if (p->dsId() == dsId) return p;
return nullptr;
}
void DatasetDetailPanel::openOrUpdate(const geopro::controller::DatasetDetailController::ChartData& d) {
auto* p = pageFor(d.dsId);
if (!p) { p = new DatasetDetailPage(this); addTab(p, d.dsId); } // 标题后续可换 ds 名
p->setData(d);
setCurrentWidget(p);
}
void DatasetDetailPanel::focusDataset(const QString& dsId) {
if (auto* p = pageFor(dsId)) setCurrentWidget(p);
}
} // namespace geopro::app
```
`src/app/CMakeLists.txt`:加 `panels/DatasetDetailPanel.cpp`
- [ ] **Step 3: 构建 + 提交**
Run: `cmake --build build`
```bash
git add src/app/panels/DatasetDetailPanel.hpp src/app/panels/DatasetDetailPanel.cpp src/app/CMakeLists.txt
git commit -m "feat(ui): DatasetDetailPanel 多Tab壳(按dsId去重+反向联动信号)"
```
---
## Phase 6main.cpp 接线(移除旧 VTK 详情,接上新面板)
### Task 6.1:替换详情 dock + 接上控制器与单击/双击
**Files:**
- Modify: `src/app/main.cpp`
- [ ] **Step 1: 移除旧 VTK 详情 dock 构建**
删除 `main.cpp:451-523``detailWidget`/`detailRenderWindow`/`detailRenderer`/`detailContainer`/`detailToolBar`/`actScatter`/`actSection`/`actShow*`/`detailDock` 整段)与 `main.cpp:602-702``currentDsId/detailMode/showAnomalies/...rebuildDetail` lambda 及其在 716-742、795-800 的相关连接。保留 `rebuildCentral`(中央场景)。
> 注:`rebuildDetail` 在 795-800「VTK 背景随主题」连接中与 `rebuildCentral` 同列,删除其中 `rebuildDetail` 调用即可,保留 `rebuildCentral`。
- [ ] **Step 2: 新建详情面板 dock + 控制器**
`buildWorkbench` 内(原 detailDock 位置)替换为:
```cpp
auto* detailPanel = new geopro::app::DatasetDetailPanel();
auto* detailDock = new ads::CDockWidget(QStringLiteral("数据详情"));
detailDock->setWidget(wrapWithHeader(geopro::app::Glyph::Detail, QStringLiteral("数据详情"), detailPanel));
dockManager->addDockWidget(ads::BottomDockWidgetArea, detailDock, centerDockArea);
```
`buildWorkbench` 签名加参 `geopro::controller::DatasetDetailController& detailCtrl`(在 `WorkbenchNavController& nav` 之后)。文件顶部 include 新头:`panels/DatasetDetailPanel.hpp`、`DatasetDetailController.hpp`。
- [ ] **Step 3: 接线单击/双击 + 反向联动**
`main.cpp:705-713` 的 datasetList 单击 lambda 改为同时联动控制器:
```cpp
QObject::connect(datasetList, &QListWidget::itemClicked, datasetList,
[&nav, &detailCtrl](QListWidgetItem* item) {
if (item->data(geopro::app::kDsLoadMoreRole).toBool()) { nav.loadMoreData(); return; }
const QString dsId = item->data(geopro::app::kDsIdRole).toString();
if (dsId.isEmpty()) return;
nav.selectDataset(dsId); // 属性表单(现状)
detailCtrl.focusDataset(dsId); // 单击=聚焦已开页
});
QObject::connect(datasetList, &QListWidget::itemDoubleClicked, datasetList,
[&detailCtrl](QListWidgetItem* item) {
const QString dsId = item->data(geopro::app::kDsIdRole).toString();
const QString ddCode = item->data(geopro::app::kDsDdCodeRole).toString();
if (!dsId.isEmpty()) detailCtrl.openDataset(dsId, ddCode); // 双击=新建/聚焦页
});
QObject::connect(&detailCtrl, &geopro::controller::DatasetDetailController::chartReady,
detailPanel, [detailPanel](const geopro::controller::DatasetDetailController::ChartData& d) {
detailPanel->openOrUpdate(d);
});
QObject::connect(&detailCtrl, &geopro::controller::DatasetDetailController::focusRequested,
detailPanel, [detailPanel](const QString& dsId) { detailPanel->focusDataset(dsId); });
QObject::connect(detailPanel, &geopro::app::DatasetDetailPanel::activeDatasetChanged,
datasetList, [datasetList](const QString& dsId) {
for (int i = 0; i < datasetList->count(); ++i)
if (datasetList->item(i)->data(geopro::app::kDsIdRole).toString() == dsId)
datasetList->setCurrentRow(i);
});
```
> 需确认列表项是否存了 `kDsDdCodeRole`ddCode。若无在填充 datasetList 的 `datasetsLoaded` 连接处(`main.cpp:908`)给每项 `setData(kDsDdCodeRole, QString::fromStdString(row.ddCode))`,并在 `Glyphs`/角色定义处加 `constexpr int kDsDdCodeRole = Qt::UserRole + N;`(取未占用值)。
- [ ] **Step 4: 构造控制器并传入**
`main()``main.cpp:1046-1060` 附近):
```cpp
geopro::data::ApiDatasetRepository datasetRepo(api); // api 为现有 ApiClient
geopro::controller::DatasetDetailController detailCtrl(datasetRepo);
```
并把 `buildWorkbench(*window, repo, projectRepo, nav);` 改为 `buildWorkbench(*window, repo, projectRepo, nav, detailCtrl);`。include `api/ApiDatasetRepository.hpp`、`DatasetDetailController.hpp`。
> `repo`LocalSampleRepository现已不被详情使用——若中央场景仍用 `repo.loadGrid("grid1")``main.cpp:241`)则保留;否则一并清理其未用引用(仅清理本次改动产生的孤儿)。
- [ ] **Step 5: 构建 + 手动验证 + 提交**
Run: `cmake --build build`,启动 app单击数据集→聚焦/属性;双击→详情页打开真实反演剖面(散点/网格切换、异常虚线叠加、异常表、色阶图例);切换 Tab 反向高亮列表。
```bash
git add src/app/main.cpp src/app/Glyphs.hpp
git commit -m "feat(ui): 接线数据详情面板(移除旧VTK详情)+单击聚焦/双击打开/反向联动"
```
---
## Phase 7集成测试 + 视觉核对
### Task 7.1DatasetDetailController 编排集成测试
**Files:**
- Create: `tests/controller/test_dataset_detail_controller.cpp`
- Modify: `tests/CMakeLists.txt`
- [ ] **Step 1: 写测试(用桩 repo**
Create `tests/controller/test_dataset_detail_controller.cpp`:
```cpp
#include <gtest/gtest.h>
#include <QSignalSpy>
#include "DatasetDetailController.hpp"
#include "repo/IDatasetRepository.hpp"
using namespace geopro;
namespace {
struct StubRepo : data::IDatasetRepository {
bool fail = false;
std::vector<data::GsNode> loadStructure() override { return {}; }
core::Grid loadGrid(const std::string&) override { if (fail) throw std::runtime_error("x"); core::Grid g(2,2); g.x={0,1}; g.y={0,1}; return g; }
core::ScatterField loadScatter(const std::string&) override { return {}; }
core::ColorScale loadColorScale(const std::string&) override { return {}; }
core::ColorScale loadScatterColorScale(const std::string&) override { return {}; }
std::vector<core::Anomaly> loadAnomalies(const std::string&) override { return {}; }
};
}
TEST(DatasetDetailController, EmitsChartReadyOnSuccess) {
StubRepo repo;
controller::DatasetDetailController c(repo);
QSignalSpy spy(&c, &controller::DatasetDetailController::chartReady);
c.openDataset("ds1", "dd_inversion_data");
EXPECT_EQ(spy.count(), 1);
}
TEST(DatasetDetailController, EmitsLoadFailedOnThrow) {
StubRepo repo; repo.fail = true;
controller::DatasetDetailController c(repo);
QSignalSpy spy(&c, &controller::DatasetDetailController::loadFailed);
c.openDataset("ds1", "dd_inversion_data");
EXPECT_EQ(spy.count(), 1);
}
```
- [ ] **Step 2: 注册 + 跑确认通过**
`tests/CMakeLists.txt`:加 `target_sources(geopro_tests PRIVATE controller/test_dataset_detail_controller.cpp)``target_link_libraries(geopro_tests PRIVATE geopro_controller)`+ `Qt6::Test` 用于 QSignalSpy`find_package(Qt6 COMPONENTS Test REQUIRED)` 与 `Qt6::Test` 链接)。
Run: `cmake --build build && ctest --test-dir build -R DatasetDetailController --output-on-failure`
Expected: 两个 PASS
- [ ] **Step 3: 提交**
```bash
git add tests/controller/test_dataset_detail_controller.cpp tests/CMakeLists.txt
git commit -m "test(controller): DatasetDetailController chartReady/loadFailed 编排"
```
---
### Task 7.2:全量回归 + 视觉核对
- [ ] **Step 1: 全量测试**
Run: `ctest --test-dir build --output-on-failure`
Expected: 全绿(含既有用例不回归)。
- [ ] **Step 2: 视觉核对**
启动 app选真实反演数据集逐项对照 `docs/superpowers/specs/assets/web-datasetinfo-scatter.png``web-datasetinfo-grid-with-anomaly.png`
- 原数据:方块散点、色阶、等比
- 网格:色带 + 等值线 + 不规则白边(凸包裁剪)、底部色阶
- 异常:剖面虚线叠加 + 底部异常表行、眼睛切换隐藏叠加
- 多 Tab双击多个数据集分别成页、单击/Tab 切换反向高亮
- [ ] **Step 3: code review**
`~/.claude/rules` 用 cpp-reviewer 审查本分支改动;处理 CRITICAL/HIGH。
- [ ] **Step 4: 收尾提交(如有修正)**
```bash
git add -A && git commit -m "fix: 视觉核对与 review 修正"
```
---
## 自审Spec 覆盖核对)
- §2.2 范围内逐项 → Phase 1-6 均有任务覆盖(散点/等值面/色阶/叠加开关/异常叠加+表/多Tab。✓
- §3 源码契约Plotly 方块/marching-squares 引擎/4 接口/colorBar/异常)→ Task 3.1(方块)、Phase1(等值面+预处理)、Task 2.1-2.2(接口+解析)。✓
- §5 组件ContourBuilder→ContourBands、DatasetChartView、策略、ApiDatasetRepository、AnomalyTablePanel、DatasetDetailPage/Panel、DatasetDetailController→ 一一对应任务。✓
- §6 模型映射 → Task 2.1 DTO 解析 + 单测。✓
- §7 数据流/Tab/异常联动 → Task 5.2/5.3/6.1。✓
- §7.3 异常归属(右上不动 + 详情底部表)→ 仅新增详情底部表,右上面板不触及。✓
- §8 混合渲染(上采样+平滑+banded+凸包裁剪+简化)→ Task 1.2-1.4。✓
- §9 错误/空态 → Task 4.2 loadFailed、Task 4.1 注册表降级、空数据安全ContourBands 退化返回空)。✓
- §10 测试 → Task 1.x/2.1/4.1/7.1 单元+集成。✓
**命名一致性核对**`buildContourBands`/`ContourBandsResult`/`ContourOptions`、`DatasetChartView::{showScatter,showContour,setAnomalies,setHiddenAnomalies,setShowAnomalies,setShowContourLines}`、`DatasetDetailController::{openDataset,focusDataset,chartReady,focusRequested,loadFailed,ChartData}`、`DatasetDetailPanel::{openOrUpdate,focusDataset,activeDatasetChanged}` 跨任务一致。✓
**已知留白(实现时据现网/视觉微调,非阻塞)**
- Task 2.2 `queryException``data` 包裹层(`value` 数组 vs 直接数组)以现网响应为准。
- Task 3.2 坐标轴/图例像素布局据视觉对照微调。
- Task 5.2 异常表「创建时间/备注」列需从 `queryException` VO 透传(首版传空,后续在 ChartData 携带原始 createTime/remark