From c7fec86d3b4cf9e229fffd578a9bf5bb6b0154e7 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Thu, 11 Jun 2026 15:40:27 +0800 Subject: [PATCH] =?UTF-8?q?feat(core+chart):=20ColorScale::stops()=20?= =?UTF-8?q?=E6=9A=B4=E9=9C=B2=E6=96=AD=E7=82=B9=20+=20ColorMapService=20?= =?UTF-8?q?=E8=BF=9E=E7=BB=AD=E6=8F=92=E5=80=BC=E8=89=B2=E9=98=B6=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - core::ColorScale 新增 stops() 方法,返回升序 (value, Rgba) 断点列表,供连续插值用。 - app::ColorMapService:从 ColorScale 构建,支持 normalized()/colorAtContinuous()/colorAtDiscrete(); 连续模式在归一化断点位置间线性插值 RGB,与原版 Plotly colorscale 一致。 - cmake/qwt.cmake 补加 QWT_MOC_INCLUDE=1,修复 Qwt AUTOMOC self-include 宏保护缺失导致 Q_OBJECT MOC 代码不编入 .obj 的链接错误(LNK2019 x61 系列)。 - 新增 TDD 测试 ColorMapService.*(4 个用例,全绿)。 --- cmake/qwt.cmake | 3 + src/app/panels/chart/ColorMapService.cpp | 71 ++++++++++++++++++++++++ src/app/panels/chart/ColorMapService.hpp | 38 +++++++++++++ src/core/model/ColorScale.cpp | 7 +++ src/core/model/ColorScale.hpp | 3 + tests/CMakeLists.txt | 5 ++ tests/app/test_colormap_service.cpp | 46 +++++++++++++++ 7 files changed, 173 insertions(+) create mode 100644 src/app/panels/chart/ColorMapService.cpp create mode 100644 src/app/panels/chart/ColorMapService.hpp create mode 100644 tests/app/test_colormap_service.cpp diff --git a/cmake/qwt.cmake b/cmake/qwt.cmake index 9ffd2ca..870d033 100644 --- a/cmake/qwt.cmake +++ b/cmake/qwt.cmake @@ -24,6 +24,9 @@ set_target_properties(qwt PROPERTIES AUTOMOC ON) target_include_directories(qwt PUBLIC "${QWT_SRC_DIR}") target_link_libraries(qwt PUBLIC Qt6::Widgets Qt6::Concurrent Qt6::PrintSupport Qt6::Svg) target_compile_features(qwt PUBLIC cxx_std_17) +# QWT_MOC_INCLUDE=1:启用 Qwt 源文件末尾 #if QWT_MOC_INCLUDE 保护的 #include "moc_xxx.cpp"。 +# Qwt 原生用 qmake(qwtbuild.pri 里设置),CMake 构建需显式定义,否则 MOC 元对象代码不被编译进 .obj。 +target_compile_definitions(qwt PRIVATE QWT_MOC_INCLUDE=1) if(MSVC) target_compile_options(qwt PRIVATE /bigobj /EHsc /wd4244 /wd4267 /wd4305 /wd4456) endif() diff --git a/src/app/panels/chart/ColorMapService.cpp b/src/app/panels/chart/ColorMapService.cpp new file mode 100644 index 0000000..475ddf9 --- /dev/null +++ b/src/app/panels/chart/ColorMapService.cpp @@ -0,0 +1,71 @@ +#include "panels/chart/ColorMapService.hpp" +#include +#include + +namespace geopro::app { + +namespace { +inline double clamp01(double v) { + return v < 0.0 ? 0.0 : (v > 1.0 ? 1.0 : v); +} +inline unsigned char lerpByte(unsigned char a, unsigned char b, double t) { + return static_cast(a + (b - a) * t + 0.5); +} +} // namespace + +ColorMapService::ColorMapService(const core::ColorScale& scale) + : scale_(scale) { + auto raw = scale.stops(); + if (raw.empty()) { + minVal_ = 0.0; + maxVal_ = 1.0; + return; + } + minVal_ = raw.front().first; + maxVal_ = raw.back().first; + double range = maxVal_ - minVal_; + normStops_.reserve(raw.size()); + for (const auto& [val, color] : raw) { + double pos = (range > 0.0) ? (val - minVal_) / range : 0.0; + normStops_.push_back({clamp01(pos), color}); + } +} + +double ColorMapService::normalized(double v) const { + double range = maxVal_ - minVal_; + if (range <= 0.0) return 0.0; + return clamp01((v - minVal_) / range); +} + +core::Rgba ColorMapService::colorAtContinuous(double v) const { + if (normStops_.empty()) return core::Rgba{0, 0, 0, 255}; + if (normStops_.size() == 1) return normStops_.front().color; + + double t = normalized(v); + + // 找到 t 落在哪两个 normStop 之间 + if (t <= normStops_.front().pos) return normStops_.front().color; + if (t >= normStops_.back().pos) return normStops_.back().color; + + // 二分查找第一个 pos > t + auto it = std::upper_bound(normStops_.begin(), normStops_.end(), t, + [](double val, const NormStop& s) { return val < s.pos; }); + + const NormStop& hi = *it; + const NormStop& lo = *(it - 1); + double segLen = hi.pos - lo.pos; + double frac = (segLen > 0.0) ? (t - lo.pos) / segLen : 0.0; + + return core::Rgba{ + lerpByte(lo.color.r, hi.color.r, frac), + lerpByte(lo.color.g, hi.color.g, frac), + lerpByte(lo.color.b, hi.color.b, frac), + lerpByte(lo.color.a, hi.color.a, frac) + }; +} + +core::Rgba ColorMapService::colorAtDiscrete(double v) const { + return scale_.colorAt(v); +} + +} // namespace geopro::app diff --git a/src/app/panels/chart/ColorMapService.hpp b/src/app/panels/chart/ColorMapService.hpp new file mode 100644 index 0000000..a824794 --- /dev/null +++ b/src/app/panels/chart/ColorMapService.hpp @@ -0,0 +1,38 @@ +#pragma once +#include "model/ColorScale.hpp" +#include +#include + +namespace geopro::app { + +// 连续/离散双模式色阶服务,供散点渲染(连续插值)和网格/图例(阶梯)使用。 +// 连续模式:按归一化位置在相邻断点之间线性插值 RGB,与原版 Plotly colorscale 一致。 +class ColorMapService { +public: + explicit ColorMapService(const core::ColorScale& scale); + + // 将数据值归一化到 [0,1](min=首断点值, max=末断点值),超范围 clamp。 + double normalized(double v) const; + + // 连续插值取色(散点用):按断点位置线性插值 RGB。 + core::Rgba colorAtContinuous(double v) const; + + // 离散阶梯取色(网格/图例用):复用 ColorScale::colorAt。 + core::Rgba colorAtDiscrete(double v) const; + + // 返回原始 ColorScale 引用(色阶条图例用)。 + const core::ColorScale& scale() const { return scale_; } + +private: + core::ColorScale scale_; + // 归一化位置与颜色预计算缓存(stops 已升序)。 + struct NormStop { + double pos; // normalized position in [0,1] + core::Rgba color; + }; + std::vector normStops_; + double minVal_; + double maxVal_; +}; + +} // namespace geopro::app diff --git a/src/core/model/ColorScale.cpp b/src/core/model/ColorScale.cpp index dce6ad6..609153d 100644 --- a/src/core/model/ColorScale.cpp +++ b/src/core/model/ColorScale.cpp @@ -40,6 +40,13 @@ std::vector ColorScale::stopValues() const { return v; } +std::vector> ColorScale::stops() const { + std::vector> result; + result.reserve(stops_.size()); + for (const auto& s : stops_) result.emplace_back(s.value, s.color); + return result; +} + Rgba ColorScale::colorAt(double value) const { if (std::isnan(value)) return nan_.value_or(Rgba{0, 0, 0, 0}); if (stops_.empty()) return Rgba{0, 0, 0, 0}; diff --git a/src/core/model/ColorScale.hpp b/src/core/model/ColorScale.hpp index b859478..db793a7 100644 --- a/src/core/model/ColorScale.hpp +++ b/src/core/model/ColorScale.hpp @@ -1,5 +1,6 @@ #pragma once #include +#include #include #include namespace geopro::core { @@ -15,6 +16,8 @@ public: void addStop(double value, Rgba color); // 内部保持按 value 升序 Rgba colorAt(double value) const; // 含 under/over/NaN 处理 std::vector stopValues() const; // 升序分段值(供等值线用真实非均匀级) + // 返回升序断点 (value, color) 列表,供连续插值用。 + std::vector> stops() const; std::size_t stopCount() const { return stops_.size(); } void setUnder(Rgba c) { under_ = c; } void setOver(Rgba c) { over_ = c; } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index e7c13fb..f999606 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -88,6 +88,11 @@ endif() target_include_directories(geopro_tests PRIVATE ${CMAKE_SOURCE_DIR}/src/app) target_sources(geopro_tests PRIVATE app/test_chart_strategy_registry.cpp) +# ColorMapService 测试(geopro_desktop 是可执行文件,直接把源加入测试目标) +target_sources(geopro_tests PRIVATE + app/test_colormap_service.cpp + ${CMAKE_SOURCE_DIR}/src/app/panels/chart/ColorMapService.cpp +) # controller 层:DatasetDetailController 编排集成测试(QSignalSpy 验证 chartReady/loadFailed)。 find_package(Qt6 COMPONENTS Test REQUIRED) diff --git a/tests/app/test_colormap_service.cpp b/tests/app/test_colormap_service.cpp new file mode 100644 index 0000000..fb41754 --- /dev/null +++ b/tests/app/test_colormap_service.cpp @@ -0,0 +1,46 @@ +#include +#include "panels/chart/ColorMapService.hpp" +using namespace geopro; + +TEST(ColorMapService, ContinuousInterpolatesBetweenStops) { + core::ColorScale cs; + cs.addStop(0.0, core::Rgba{0, 0, 0, 255}); + cs.addStop(100.0, core::Rgba{255, 255, 255, 255}); + app::ColorMapService svc(cs); + EXPECT_NEAR(svc.normalized(50.0), 0.5, 1e-9); + auto c = svc.colorAtContinuous(50.0); // 黑白中点应约为灰 + EXPECT_GT(c.r, 100); EXPECT_LT(c.r, 160); +} + +TEST(ColorMapService, NormalizedClampsOutOfRange) { + core::ColorScale cs; + cs.addStop(0.0, core::Rgba{0, 0, 0, 255}); + cs.addStop(100.0, core::Rgba{255, 255, 255, 255}); + app::ColorMapService svc(cs); + EXPECT_NEAR(svc.normalized(-10.0), 0.0, 1e-9); + EXPECT_NEAR(svc.normalized(110.0), 1.0, 1e-9); +} + +TEST(ColorMapService, ColorAtContinuousAtExtremes) { + core::ColorScale cs; + cs.addStop(0.0, core::Rgba{10, 20, 30, 255}); + cs.addStop(100.0, core::Rgba{200, 210, 220, 255}); + app::ColorMapService svc(cs); + auto cMin = svc.colorAtContinuous(0.0); + EXPECT_EQ(cMin.r, 10); + EXPECT_EQ(cMin.g, 20); + EXPECT_EQ(cMin.b, 30); + auto cMax = svc.colorAtContinuous(100.0); + EXPECT_EQ(cMax.r, 200); + EXPECT_EQ(cMax.g, 210); + EXPECT_EQ(cMax.b, 220); +} + +TEST(ColorMapService, ScaleRefReturnsOriginal) { + core::ColorScale cs; + cs.addStop(0.0, core::Rgba{0, 0, 0, 255}); + cs.addStop(50.0, core::Rgba{128, 0, 0, 255}); + cs.addStop(100.0, core::Rgba{255, 0, 0, 255}); + app::ColorMapService svc(cs); + EXPECT_EQ(svc.scale().stopCount(), 3u); +}