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