diff --git a/docs/superpowers/plans/2026-06-11-dataset-detail-chart.md b/docs/superpowers/plans/2026-06-11-dataset-detail-chart.md new file mode 100644 index 0000000..de020db --- /dev/null +++ b/docs/superpowers/plans/2026-06-11-dataset-detail-chart.md @@ -0,0 +1,1628 @@ +# 数据集详情视图(平面图表)实现计划 + +> **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 window);QGraphicsView 画 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 Widgets(QGraphicsView/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 1:core::Grid NaN + ContourBands(VTK 几何提取) + +### Task 1.1:Grid 增 NaN 约定 + hasValue + +**Files:** +- Modify: `src/core/model/Field.hpp`(`class Grid`,在 `valueAt` 附近) + +- [ ] **Step 1: 写失败测试** + +Create `tests/core/test_model_data.cpp` 末尾追加(该文件已在 CMake 中): +```cpp +#include +#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 `。在 `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.2:ContourBands 骨架 + 色带提取(不含预处理) + +**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 +#include "model/Grid.hpp" +#include "model/ColorScale.hpp" +namespace geopro::render { + +struct BandPolygon { + geopro::core::Rgba color; + std::vector ring; // 单环多边形(局部坐标,y=高程向上为正) +}; +struct ContourLine { + double level; + std::vector pts; +}; +struct ContourBandsResult { + std::vector bands; + std::vector lines; +}; +struct ContourOptions { + int upsample = 2; // 双线性上采样倍数(1=不采样) + double smooth = 0.3; // 平滑强度 0..1(0=不平滑) + double simplifyTol = 0.5; // 等值线简化容差(数据单位,0=不简化) + bool makeLines = true; +}; + +// core::Grid(NaN=无效)+ 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 +#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(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 +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +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 toStructuredGrid(const Grid& g) { + const int nx = g.nx(), ny = g.ny(); + auto sg = vtkSmartPointer::New(); + sg->SetDimensions(nx, ny, 1); + vtkNew pts; pts->SetNumberOfPoints(static_cast(nx) * ny); + vtkNew sc; sc->SetName("v"); + sc->SetNumberOfTuples(static_cast(nx) * ny); + for (int j = 0; j < ny; ++j) + for (int i = 0; i < nx; ++i) { + const vtkIdType id = static_cast(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 stops = cs.stopValues(); + if (stops.size() < 2) return out; + + auto sg = toStructuredGrid(g); + vtkNew surf; surf->SetInputData(sg); + vtkNew banded; + banded->SetInputConnection(surf->GetOutputPort()); + banded->SetNumberOfContours(static_cast(stops.size())); + for (int i = 0; i < static_cast(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(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.4:NaN 凸包裁剪 + 等值线简化 + +**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 `、`#include `,并把 `toStructuredGrid` 替换为: +```cpp +vtkSmartPointer toCellGrid(const Grid& g) { + const int nx = g.nx(), ny = g.ny(); + auto ug = vtkSmartPointer::New(); + vtkNew pts; pts->SetNumberOfPoints(static_cast(nx) * ny); + vtkNew sc; sc->SetName("v"); + sc->SetNumberOfTuples(static_cast(nx) * ny); + for (int j = 0; j < ny; ++j) for (int i = 0; i < nx; ++i) { + const vtkIdType id = static_cast(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(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& pts, double tol) { + if (tol <= 0 || pts.size() < 3) return; + std::vector keep(pts.size(), 0); keep.front() = keep.back() = 1; + std::vector> 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 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 `(已含)与 ``(经头文件已含)。 + +- [ ] **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 2:DTO 解析 + ApiDatasetRepository + +### Task 2.1:DatasetChartDto 解析(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 +#include +#include +#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 } → Grid(v 缺省/非数→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 parseDatasetAnomalies(const QJsonArray& arr); + +} // namespace geopro::data::dto +``` + +- [ ] **Step 2: 写失败测试** + +Create `tests/data/test_dataset_chart_dto.cpp`: +```cpp +#include +#include +#include +#include +#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(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 +#include +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& 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 parseDatasetAnomalies(const QJsonArray& arr) { + std::vector 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(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.2:IDatasetRepository 增 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 实现 IDatasetRepository(ERT 反演相关)。失败抛 std::runtime_error。 +class ApiDatasetRepository : public IDatasetRepository { +public: + explicit ApiDatasetRepository(net::ApiClient& api); + std::vector 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 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 +#include +#include +#include +#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 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 3:DatasetChartView(QGraphicsView 渲染器) + +### Task 3.1:DatasetChartView 散点/等值面/异常渲染 + +**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 +#include +#include +#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& list); + void setHiddenAnomalies(const std::set& 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 anomalies_; + std::set hidden_; + bool showAnomalies_ = true; + bool showContourLines_ = true; + std::vector anomalyItems_; +}; +} // namespace geopro::app +``` + +- [ ] **Step 2: 实现** + +Create `src/app/panels/chart/DatasetChartView.cpp`: +```cpp +#include "panels/chart/DatasetChartView.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +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& list) { + anomalies_ = list; rebuildAnomalyItems(); +} +void DatasetChartView::setHiddenAnomalies(const std::set& 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(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(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 4:dd 策略框架 + 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 +#include +#include +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 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> map_; +}; +} // namespace geopro::app +``` + +- [ ] **Step 2: 写失败测试** + +Create `tests/app/test_chart_strategy_registry.cpp`: +```cpp +#include +#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()); + 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.2:DatasetDetailController + 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 +#include +#include +#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 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 +#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.1:AnomalyTablePanel(行显隐→图表) + +**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 +#include +#include +#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& list, + const std::vector& createTimes, + const std::vector& remarks); +signals: + void hiddenChanged(const std::set& hiddenIndices); +private: + QTableWidget* table_; + std::set hidden_; +}; +} // namespace geopro::app +``` + +- [ ] **Step 2: 实现** + +Create `src/app/panels/AnomalyTablePanel.cpp`: +```cpp +#include "panels/AnomalyTablePanel.hpp" +#include +#include +#include +#include +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& list, + const std::vector& createTimes, + const std::vector& remarks) { + hidden_.clear(); + table_->setRowCount(static_cast(list.size())); + for (int i = 0; i < static_cast(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(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.2:DatasetDetailPage(标题+切换+图表+异常表) + +**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 +#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 +#include +#include +#include +#include +#include +#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& 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.3:DatasetDetailPanel(多 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 +#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(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(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 6:main.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.1:DatasetDetailController 编排集成测试 + +**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 +#include +#include "DatasetDetailController.hpp" +#include "repo/IDatasetRepository.hpp" +using namespace geopro; +namespace { +struct StubRepo : data::IDatasetRepository { + bool fail = false; + std::vector 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 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)。