feat(core+chart): ColorScale::stops() 暴露断点 + ColorMapService 连续插值色阶服务

- 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 个用例,全绿)。
This commit is contained in:
gaozheng 2026-06-11 15:40:27 +08:00
parent 7808b8422a
commit c7fec86d3b
7 changed files with 173 additions and 0 deletions

View File

@ -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 qmakeqwtbuild.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()

View File

@ -0,0 +1,71 @@
#include "panels/chart/ColorMapService.hpp"
#include <algorithm>
#include <cmath>
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<unsigned char>(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

View File

@ -0,0 +1,38 @@
#pragma once
#include "model/ColorScale.hpp"
#include <vector>
#include <utility>
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<NormStop> normStops_;
double minVal_;
double maxVal_;
};
} // namespace geopro::app

View File

@ -40,6 +40,13 @@ std::vector<double> ColorScale::stopValues() const {
return v;
}
std::vector<std::pair<double, Rgba>> ColorScale::stops() const {
std::vector<std::pair<double, Rgba>> 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};

View File

@ -1,5 +1,6 @@
#pragma once
#include <string>
#include <utility>
#include <vector>
#include <optional>
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<double> stopValues() const; // 升序分段值(供等值线用真实非均匀级)
// 返回升序断点 (value, color) 列表,供连续插值用。
std::vector<std::pair<double, Rgba>> stops() const;
std::size_t stopCount() const { return stops_.size(); }
void setUnder(Rgba c) { under_ = c; }
void setOver(Rgba c) { over_ = c; }

View File

@ -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)

View File

@ -0,0 +1,46 @@
#include <gtest/gtest.h>
#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);
}