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

67 KiB
Raw Blame History

数据集详情视图(平面图表)实现计划

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 场景。新增 ContourBuilderrender 层)、ApiDatasetRepository + DTO 解析、DatasetChartView、dd 策略注册表、AnomalyTablePanel/DatasetDetailPage/DatasetDetailPanelDatasetDetailController,并在 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(改) loadScatterColorScaleoverride
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.hppclass Grid,在 valueAt 附近)

  • Step 1: 写失败测试

Create tests/core/test_model_data.cpp 末尾追加(该文件已在 CMake 中):

#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.hppGrid 类 public 区(valueAt 之后)加:

    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: 提交
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:

#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.hppContourBands.hppGrid.hpp/ColorScale.hpp 间接需要它,显式 #include "model/Anomaly.hpp" 以引入 Vec2。在上面 include 区加 #include "model/Anomaly.hpp"

  • Step 2: 写失败测试

Create tests/render/test_contour_bands.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:

#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

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 追加:

// 上采样 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 的匿名命名空间加:

// 双线性上采样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...) 之后)插入:

    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: 提交
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: 写失败测试

追加:

// 含 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 顶点的网格单元quadvtkStructuredGrid剔除——改 toStructuredGridvtkUnstructuredGrid,逐 quad 仅当 4 顶点都 hasValueInsertNextCell(VTK_QUAD,...)。在 ContourBands.cpp 顶部加 #include <vtkUnstructuredGrid.h>#include <vtkCellArray.h>,并把 toStructuredGrid 替换为:

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

vtkDataSetSurfaceFiltervtkUnstructuredGrid 同样适用:把 surf->SetInputData(sg) 改为 surf->SetInputData(toCellGrid(work))(删除原 toStructuredGrid(work) 调用与 sg 变量)。 等值线简化:在收集 cl.pts 后调用 Douglas-Peucker。加匿名函数

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: 提交
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:

#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 缺省/非数→NaNgeopro::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 / legendstd::vector<geopro::core::Anomaly> parseDatasetAnomalies(const QJsonArray& arr);

}  // namespace geopro::data::dto
  • Step 2: 写失败测试

Create tests/data/test_dataset_chart_dto.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.cpptests/CMakeLists.txtdata 段加 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:

#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

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 后加纯虚:

    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:

#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:

#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

注:queryExceptiondata 是否含 value 包裹按现网验证调整——若 r.data 直接是数组语义,改为对应取法;本仓返回信封 data 为对象,数组在 value 字段(同 loadExceptionsByTm)。 src/data/CMakeLists.txttarget_sources(geopro_data PRIVATE api/ApiDatasetRepository.cpp)。确认 geopro_data 链接了 geopro_netApiClient)——ApiProjectRepository 已用,故已链。

  • Step 4: 构建确认通过

Run: cmake --build build Expected: 编译链接通过(无新测试,编译即验证签名一致)。

  • Step 5: 提交
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:

#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:

#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: 提交
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 自动重绘);底部画色阶图例(离散色带 + 分段值)。 头文件加:

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,启动 appbuild/src/app/geopro_app 或既有启动脚本),选中一个反演数据集,肉眼对照 docs/superpowers/specs/assets/web-datasetinfo-*.png:散点等比、等值面带边、异常虚线、底部色阶图例、坐标轴刻度。 Expected: 视觉与 web 接近(~95%)。

  • Step 3: 提交
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:

#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:

#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_teststarget_link_libraries(geopro_tests PRIVATE geopro_app)(若 geopro_app 是可执行而非库,则改为仅 target_include_directories(geopro_tests PRIVATE ${CMAKE_SOURCE_DIR}/src/app),因该测试只用头文件 IDatasetChartStrategy.hpp,无需链 app。优先后者纯头测试

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: 提交
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:

#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:

#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_controllertarget_sources;确认链接 geopro_data(接口)+ Qt6::Core(已有)。

  • Step 3: ErtInversionStrategy标识用渲染由页驱动

Create src/app/panels/chart/ErtInversionStrategy.hpp:

#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: 通过

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:

#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:

#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

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:

#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:

#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

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:

#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:

#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

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-523detailWidget/detailRenderWindow/detailRenderer/detailContainer/detailToolBar/actScatter/actSection/actShow*/detailDock 整段)与 main.cpp:602-702currentDsId/detailMode/showAnomalies/...rebuildDetail lambda 及其在 716-742、795-800 的相关连接。保留 rebuildCentral(中央场景)。

注:rebuildDetail 在 795-800「VTK 背景随主题」连接中与 rebuildCentral 同列,删除其中 rebuildDetail 调用即可,保留 rebuildCentral

  • Step 2: 新建详情面板 dock + 控制器

buildWorkbench 内(原 detailDock 位置)替换为:

    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.hppDatasetDetailController.hpp

  • Step 3: 接线单击/双击 + 反向联动

main.cpp:705-713 的 datasetList 单击 lambda 改为同时联动控制器:

    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);
                     });

需确认列表项是否存了 kDsDdCodeRoleddCode。若无在填充 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 附近):

    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.hppDatasetDetailController.hpp

repoLocalSampleRepository现已不被详情使用——若中央场景仍用 repo.loadGrid("grid1")main.cpp:241)则保留;否则一并清理其未用引用(仅清理本次改动产生的孤儿)。

  • Step 5: 构建 + 手动验证 + 提交

Run: cmake --build build,启动 app单击数据集→聚焦/属性;双击→详情页打开真实反演剖面(散点/网格切换、异常虚线叠加、异常表、色阶图例);切换 Tab 反向高亮列表。

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:

#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 用于 QSignalSpyfind_package(Qt6 COMPONENTS Test REQUIRED)Qt6::Test 链接)。 Run: cmake --build build && ctest --test-dir build -R DatasetDetailController --output-on-failure Expected: 两个 PASS

  • Step 3: 提交
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.pngweb-datasetinfo-grid-with-anomaly.png

  • 原数据:方块散点、色阶、等比

  • 网格:色带 + 等值线 + 不规则白边(凸包裁剪)、底部色阶

  • 异常:剖面虚线叠加 + 底部异常表行、眼睛切换隐藏叠加

  • 多 Tab双击多个数据集分别成页、单击/Tab 切换反向高亮

  • Step 3: code review

~/.claude/rules 用 cpp-reviewer 审查本分支改动;处理 CRITICAL/HIGH。

  • Step 4: 收尾提交(如有修正)
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/ContourOptionsDatasetChartView::{showScatter,showContour,setAnomalies,setHiddenAnomalies,setShowAnomalies,setShowContourLines}DatasetDetailController::{openDataset,focusDataset,chartReady,focusRequested,loadFailed,ChartData}DatasetDetailPanel::{openOrUpdate,focusDataset,activeDatasetChanged} 跨任务一致。✓

已知留白(实现时据现网/视觉微调,非阻塞)

  • Task 2.2 queryExceptiondata 包裹层(value 数组 vs 直接数组)以现网响应为准。
  • Task 3.2 坐标轴/图例像素布局据视觉对照微调。
  • Task 5.2 异常表「创建时间/备注」列需从 queryException VO 透传(首版传空,后续在 ChartData 携带原始 createTime/remark