67 KiB
数据集详情视图(平面图表)实现计划
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 中):
#include <cmath>
#include "model/Field.hpp"
TEST(GridNaN, HasValueReflectsNaN) {
geopro::core::Grid g(2, 2);
g.valueAt(0, 0) = 3.0;
g.valueAt(1, 0) = std::nan("");
EXPECT_TRUE(g.hasValue(0, 0));
EXPECT_FALSE(g.hasValue(1, 0));
}
- Step 2: 运行确认失败
Run: cmake --build build && ctest --test-dir build -R GridNaN --output-on-failure
Expected: 编译失败「no member named 'hasValue'」
- Step 3: 实现
在 Field.hpp 的 Grid 类 public 区(valueAt 之后)加:
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.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:
#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..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:
#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.4:NaN 凸包裁剪 + 等值线简化
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 顶点的网格单元(quad)从 vtkStructuredGrid 中剔除——改 toStructuredGrid 为 vtkUnstructuredGrid,逐 quad 仅当 4 顶点都 hasValue 才 InsertNextCell(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;
}
vtkDataSetSurfaceFilter 对 vtkUnstructuredGrid 同样适用:把 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 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:
#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 } → 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<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.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:
#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.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 后加纯虚:
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 实现 IDatasetRepository(ERT 反演相关)。失败抛 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
注:
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: 提交
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:
#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,启动 app(build/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 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:
#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_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)。优先后者(纯头测试):
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.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:
#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_controller 的 target_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.1:AnomalyTablePanel(行显隐→图表)
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.2:DatasetDetailPage(标题+切换+图表+异常表)
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.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:
#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 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 位置)替换为:
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 改为同时联动控制器:
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 附近):
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 反向高亮列表。
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:
#include <gtest/gtest.h>
#include <QSignalSpy>
#include "DatasetDetailController.hpp"
#include "repo/IDatasetRepository.hpp"
using namespace geopro;
namespace {
struct StubRepo : data::IDatasetRepository {
bool fail = false;
std::vector<data::GsNode> loadStructure() override { return {}; }
core::Grid loadGrid(const std::string&) override { if (fail) throw std::runtime_error("x"); core::Grid g(2,2); g.x={0,1}; g.y={0,1}; return g; }
core::ScatterField loadScatter(const std::string&) override { return {}; }
core::ColorScale loadColorScale(const std::string&) override { return {}; }
core::ColorScale loadScatterColorScale(const std::string&) override { return {}; }
std::vector<core::Anomaly> loadAnomalies(const std::string&) override { return {}; }
};
}
TEST(DatasetDetailController, EmitsChartReadyOnSuccess) {
StubRepo repo;
controller::DatasetDetailController c(repo);
QSignalSpy spy(&c, &controller::DatasetDetailController::chartReady);
c.openDataset("ds1", "dd_inversion_data");
EXPECT_EQ(spy.count(), 1);
}
TEST(DatasetDetailController, EmitsLoadFailedOnThrow) {
StubRepo repo; repo.fail = true;
controller::DatasetDetailController c(repo);
QSignalSpy spy(&c, &controller::DatasetDetailController::loadFailed);
c.openDataset("ds1", "dd_inversion_data");
EXPECT_EQ(spy.count(), 1);
}
- Step 2: 注册 + 跑确认通过
tests/CMakeLists.txt:加 target_sources(geopro_tests PRIVATE controller/test_dataset_detail_controller.cpp) 并 target_link_libraries(geopro_tests PRIVATE geopro_controller)(+ Qt6::Test 用于 QSignalSpy:find_package(Qt6 COMPONENTS Test REQUIRED) 与 Qt6::Test 链接)。
Run: cmake --build build && ctest --test-dir build -R DatasetDetailController --output-on-failure
Expected: 两个 PASS
- Step 3: 提交
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: 收尾提交(如有修正)
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 异常表「创建时间/备注」列需从
queryExceptionVO 透传(首版传空,后续在 ChartData 携带原始 createTime/remark)。