geopro/src/app/ColorScaleConfigDialog.cpp

540 lines
25 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#include "ColorScaleConfigDialog.hpp"
#include <algorithm>
#include <cmath>
#include <utility>
#include <QCheckBox>
#include <QColor>
#include <QColorDialog>
#include <QDialogButtonBox>
#include <QHBoxLayout>
#include <QHeaderView>
#include <QInputDialog>
#include <QLabel>
#include <QLineEdit>
#include <QPushButton>
#include <QTableWidget>
#include <QTableWidgetItem>
#include <QVBoxLayout>
#include <QFile>
#include <QFileDialog>
#include <QMessageBox>
#include <QJsonArray>
#include <QJsonObject>
#include <QJsonValue>
#include <QPointer>
#include <QRegularExpression>
#include "ColorGradientDialog.hpp"
#include "ColorScaleIO.hpp"
#include "ContourLevelDialog.hpp"
#include "ContourLevels.hpp"
#include "ContourLineDialog.hpp"
#include "repo/IColorTemplateRepository.hpp"
namespace geopro::app {
namespace {
QColor toQColor(const geopro::core::Rgba& c) {
return QColor(c.r, c.g, c.b, c.a);
}
geopro::core::Rgba fromQColor(const QColor& c) {
return geopro::core::Rgba{static_cast<unsigned char>(c.red()),
static_cast<unsigned char>(c.green()),
static_cast<unsigned char>(c.blue()),
static_cast<unsigned char>(c.alpha())};
}
// 两端按比例 t∈[0,1] 线性插值(含 alpha供「新增」取中间色。
geopro::core::Rgba lerp(const geopro::core::Rgba& a, const geopro::core::Rgba& b, double t) {
auto mix = [t](unsigned char x, unsigned char y) {
return static_cast<unsigned char>(x + (y - x) * t + 0.5);
};
return geopro::core::Rgba{mix(a.r, b.r), mix(a.g, b.g), mix(a.b, b.b), mix(a.a, b.a)};
}
// core::Rgba → 颜色串:不透明用 #RRGGBB半透明用 rgba(r,g,b,a∈0..1)(与后端 colorBar 互通)。
QString rgbaToCss(const geopro::core::Rgba& c) {
if (c.a >= 255)
return QStringLiteral("#%1%2%3")
.arg(c.r, 2, 16, QLatin1Char('0'))
.arg(c.g, 2, 16, QLatin1Char('0'))
.arg(c.b, 2, 16, QLatin1Char('0'))
.toUpper();
return QStringLiteral("rgba(%1, %2, %3, %4)")
.arg(c.r)
.arg(c.g)
.arg(c.b)
.arg(QString::number(c.a / 255.0, 'g', 3));
}
// 颜色串 → core::Rgba支持 #RRGGBB / #AARRGGBB / rgb()/rgba()alpha 0..1/命名黑。
geopro::core::Rgba parseCssColor(const QString& s) {
const QString t = s.trimmed();
if (t.startsWith('#')) {
const QColor q(t); // QColor 识别 #RRGGBB / #AARRGGBB
if (q.isValid()) return fromQColor(q);
}
static const QRegularExpression re(
QStringLiteral("rgba?\\(\\s*(\\d+)\\s*,\\s*(\\d+)\\s*,\\s*(\\d+)\\s*(?:,\\s*([0-9.]+))?\\)"),
QRegularExpression::CaseInsensitiveOption);
const auto m = re.match(t);
if (m.hasMatch()) {
const int r = m.captured(1).toInt();
const int g = m.captured(2).toInt();
const int b = m.captured(3).toInt();
double a = m.captured(4).isEmpty() ? 1.0 : m.captured(4).toDouble();
if (a > 1.0) a = a / 255.0; // 容错:偶有 0..255 alpha
return geopro::core::Rgba{static_cast<unsigned char>(r), static_cast<unsigned char>(g),
static_cast<unsigned char>(b),
static_cast<unsigned char>(a * 255.0 + 0.5)};
}
return geopro::core::Rgba{0, 0, 0, 255};
}
} // namespace
ColorScaleConfigDialog::ColorScaleConfigDialog(const geopro::core::ColorScale& init, double vmin,
double vmax, std::vector<double> samples,
const ContourLineConfig& lineInit,
geopro::data::IColorTemplateRepository* tplRepo,
QString projectId, QWidget* parent)
: QDialog(parent),
vmin_(vmin),
vmax_(vmax),
samples_(std::move(samples)),
lineCfg_(lineInit),
tplRepo_(tplRepo),
projectId_(std::move(projectId)) {
setWindowTitle(QStringLiteral("色阶配置"));
setModal(true);
resize(560, 420);
// 用初始色阶的升序断点填模型;空色阶兜底成 vmin/vmax 两端蓝红。
for (const auto& [value, color] : init.stops()) rows_.push_back({value, color});
if (rows_.empty()) {
rows_.push_back({vmin_, geopro::core::Rgba{0, 0, 255, 255}});
rows_.push_back({vmax_, geopro::core::Rgba{255, 0, 0, 255}});
}
auto* root = new QVBoxLayout(this);
auto* mid = new QHBoxLayout();
root->addLayout(mid, 1);
// 左:三列表格(层级 / 线形 / 颜色),每列表头带 ⚙,点击表头打开对应子对话框。
table_ = new QTableWidget(this);
table_->setColumnCount(3);
table_->setHorizontalHeaderLabels(
{QStringLiteral("层级 ⚙"), QStringLiteral("线形 ⚙"), QStringLiteral("颜色 ⚙")});
table_->horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch);
table_->horizontalHeader()->setSectionsClickable(true);
table_->verticalHeader()->setVisible(false);
table_->setSortingEnabled(false);
table_->setSelectionBehavior(QAbstractItemView::SelectRows);
table_->setSelectionMode(QAbstractItemView::SingleSelection);
table_->setEditTriggers(QAbstractItemView::NoEditTriggers); // 改值/改色走双击
connect(table_, &QTableWidget::cellDoubleClicked, this,
&ColorScaleConfigDialog::onCellDoubleClicked);
connect(table_->horizontalHeader(), &QHeaderView::sectionClicked, this, [this](int section) {
if (section == 0)
onLevelScheme();
else if (section == 1)
onLineScheme();
else
onColorScheme();
});
mid->addWidget(table_, 1);
// 右:竖排按钮 新增 / 删除 / 另存为 / 导出 / 导入 / 打开(复刻 colorLevel.vue 操作列)。
auto* rightCol = new QVBoxLayout();
auto* btnAdd = new QPushButton(QStringLiteral("新增"), this);
auto* btnDel = new QPushButton(QStringLiteral("删除"), this);
btnSaveOther_ = new QPushButton(QStringLiteral("另存"), this);
auto* btnExport = new QPushButton(QStringLiteral("导出"), this);
auto* btnImport = new QPushButton(QStringLiteral("导入"), this);
btnOpen_ = new QPushButton(QStringLiteral("打开"), this);
connect(btnAdd, &QPushButton::clicked, this, &ColorScaleConfigDialog::onAdd);
connect(btnDel, &QPushButton::clicked, this, &ColorScaleConfigDialog::onRemove);
connect(btnSaveOther_, &QPushButton::clicked, this, &ColorScaleConfigDialog::onSaveOther);
connect(btnExport, &QPushButton::clicked, this, &ColorScaleConfigDialog::onExportLvl);
connect(btnImport, &QPushButton::clicked, this, &ColorScaleConfigDialog::onImportLvl);
connect(btnOpen_, &QPushButton::clicked, this, &ColorScaleConfigDialog::onOpen);
for (auto* b : {btnAdd, btnDel, btnSaveOther_, btnExport, btnImport, btnOpen_})
rightCol->addWidget(b);
rightCol->addStretch();
mid->addLayout(rightCol);
// 「另存为 / 打开」依赖后端 lvl 模板库(走仓储),无仓储/无项目时禁用。
const bool hasBackend = tplRepo_ != nullptr && !projectId_.isEmpty();
btnSaveOther_->setEnabled(hasBackend);
btnOpen_->setEnabled(hasBackend);
auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
buttons->button(QDialogButtonBox::Ok)->setText(QStringLiteral("应用"));
buttons->button(QDialogButtonBox::Cancel)->setText(QStringLiteral("取消"));
connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept);
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
root->addWidget(buttons);
rebuildTable();
}
void ColorScaleConfigDialog::rebuildTable() {
const int n = static_cast<int>(rows_.size());
table_->setRowCount(n);
const QString solid = QStringLiteral("——————");
const QString dashed = QStringLiteral("- - - - - - - - -");
const QColor lineQc = toQColor(lineCfg_.lineColor);
// 升序显示:低值在上(复刻原版 tableData 自然数组序)。
for (int r = 0; r < n; ++r) {
const Row& row = rows_[static_cast<std::size_t>(r)];
auto* valItem = new QTableWidgetItem(QString::number(row.value, 'g', 6));
valItem->setTextAlignment(Qt::AlignRight | Qt::AlignVCenter);
table_->setItem(r, 0, valItem);
auto* lineItem = new QTableWidgetItem(lineCfg_.dashed ? dashed : solid);
lineItem->setForeground(lineQc);
lineItem->setTextAlignment(Qt::AlignCenter);
table_->setItem(r, 1, lineItem);
auto* colItem = new QTableWidgetItem();
colItem->setBackground(toQColor(row.color));
table_->setItem(r, 2, colItem);
}
}
int ColorScaleConfigDialog::selectedModelIndex() const {
const int r = table_->currentRow();
if (r < 0 || r >= static_cast<int>(rows_.size())) return -1;
return r; // 升序显示,行号即模型下标
}
void ColorScaleConfigDialog::onCellDoubleClicked(int row, int col) {
if (row < 0 || row >= static_cast<int>(rows_.size())) return;
const int idx = row;
if (col == 0) { // 改层级值(复刻 handleLevelDblClick
bool ok = false;
const double v = QInputDialog::getDouble(this, QStringLiteral("修改层级值"),
QStringLiteral("数据值"), rows_[idx].value, -1e12,
1e12, 6, &ok);
if (!ok) return;
const geopro::core::Rgba color = rows_[idx].color;
rows_[idx].value = v;
std::sort(rows_.begin(), rows_.end(),
[](const Row& a, const Row& b) { return a.value < b.value; });
rebuildTable();
for (int i = 0; i < static_cast<int>(rows_.size()); ++i) {
const Row& ri = rows_[static_cast<std::size_t>(i)];
if (ri.value == v && ri.color.r == color.r && ri.color.g == color.g &&
ri.color.b == color.b && ri.color.a == color.a) {
table_->selectRow(i);
break;
}
}
} else if (col == 2) { // 改颜色(复刻 handleColorDblClick
const QColor cur = toQColor(rows_[idx].color);
const QColor picked = QColorDialog::getColor(cur, this, QStringLiteral("选择颜色"),
QColorDialog::ShowAlphaChannel);
if (!picked.isValid()) return;
rows_[idx].color = fromQColor(picked);
rebuildTable();
table_->selectRow(row);
}
// 线形列col==1双击无动作复刻原版线形改动走表头 ⚙)。
}
void ColorScaleConfigDialog::onAdd() {
// 复刻 handleAdd选中行上方插入中点断点未选中则提示。
const int idx = selectedModelIndex();
if (idx < 0) {
QMessageBox::warning(this, QStringLiteral("新增"), QStringLiteral("请先选择要插入的行。"));
return;
}
const Row& sel = rows_[static_cast<std::size_t>(idx)];
double newLevel = sel.value;
if (idx > 0) // 升序:上一行(idx-1)为更低值,取两者中点
newLevel = (rows_[static_cast<std::size_t>(idx - 1)].value + sel.value) / 2.0;
rows_.insert(rows_.begin() + idx, Row{newLevel, sel.color});
rebuildTable();
table_->selectRow(idx); // 选中新插入行
}
void ColorScaleConfigDialog::onRemove() {
// 复刻 handleDelete未选中提示至少保留 2 行。
const int idx = selectedModelIndex();
if (idx < 0) {
QMessageBox::warning(this, QStringLiteral("删除"), QStringLiteral("请先选择要删除的行。"));
return;
}
if (rows_.size() <= 2) {
QMessageBox::warning(this, QStringLiteral("删除"), QStringLiteral("至少需要保留两行数据。"));
return;
}
rows_.erase(rows_.begin() + idx);
rebuildTable();
table_->clearSelection(); // 复刻 handleDelete删除后清空选中
}
geopro::core::Rgba ColorScaleConfigDialog::interpColor(double value) const {
// 复刻 colorUtils.js mapColors升序断点上钳位 + 找区间 + 线性 RGBA 插值。
if (rows_.empty()) return geopro::core::Rgba{0, 0, 0, 255};
const double ysMin = rows_.front().value, ysMax = rows_.back().value;
if (value <= ysMin) return rows_.front().color;
if (value >= ysMax) return rows_.back().color;
std::size_t i = 0;
while (i + 1 < rows_.size() && value > rows_[i + 1].value) ++i;
const double x0 = rows_[i].value, x1 = rows_[i + 1].value;
const double ratio = (x1 > x0) ? (value - x0) / (x1 - x0) : 0.0;
return lerp(rows_[i].color, rows_[i + 1].color, ratio);
}
void ColorScaleConfigDialog::onLevelScheme() {
// 由当前断点推导 contourLevel 初值(复刻 colorLevel.vue case 'level')。
ContourLevelParams init;
init.method = ContourLevelParams::Method::Normal;
if (lvlSchemeType_ == QStringLiteral("logarithmic"))
init.method = ContourLevelParams::Method::Logarithmic;
else if (lvlSchemeType_ == QStringLiteral("equalArea"))
init.method = ContourLevelParams::Method::EqualArea;
init.minValue = rows_.front().value;
init.maxValue = rows_.back().value;
init.layerCount = static_cast<int>(rows_.size());
init.interval =
(rows_.size() >= 2) ? std::abs(rows_[1].value - rows_[0].value) : (vmax_ - vmin_);
init.logLinesCount = logLinesCount_;
init.equalAreaLayerCount = equalAreaLayerCount_;
const double totalArea =
samples_.empty() ? 1000.0 : static_cast<double>(samples_.size()); // 等积「区间面积」分母
ContourLevelDialog dlg(init, vmin_, vmax_, totalArea, this);
if (dlg.exec() != QDialog::Accepted) return;
const ContourLevelParams p = dlg.params();
// 记录方案字段(另存为 properties 透传,复刻原版)。
switch (p.method) {
case ContourLevelParams::Method::Logarithmic:
lvlSchemeType_ = QStringLiteral("logarithmic");
break;
case ContourLevelParams::Method::EqualArea:
lvlSchemeType_ = QStringLiteral("equalArea");
break;
default:
lvlSchemeType_ = QStringLiteral("normal");
break;
}
logLinesCount_ = p.logLinesCount;
equalAreaLayerCount_ = p.equalAreaLayerCount;
// 1) 按分层方式生成新层级(纯算法)。 2) 旧色阶上插值取色mapColors重建表。
const std::vector<double> levels = generateContourLevels(p, samples_);
std::vector<Row> next;
next.reserve(levels.size());
for (double lv : levels) next.push_back({lv, interpColor(lv)});
if (next.size() < 2) return; // 退化保护
std::sort(next.begin(), next.end(),
[](const Row& a, const Row& b) { return a.value < b.value; });
rows_ = std::move(next);
rebuildTable();
}
void ColorScaleConfigDialog::onLineScheme() {
ContourLineDialog dlg(lineCfg_, this);
if (dlg.exec() == QDialog::Accepted) {
lineCfg_ = dlg.config();
rebuildTable(); // 线形列文字/颜色随之刷新
}
}
void ColorScaleConfigDialog::onColorScheme() {
if (rows_.size() < 2) return; // 防御front/back
// 用当前断点归一化位置作渐变初值lo..hi → 0..1)。
const double lo = rows_.front().value, hi = rows_.back().value;
const double span = (hi > lo) ? (hi - lo) : 1.0;
std::vector<GradientEditWidget::Stop> seed;
for (const auto& r : rows_) seed.push_back({(r.value - lo) / span, r.color});
ColorGradientDialog dlg(seed, lo, hi, vmin_, vmax_, samples_, 1.0, tplRepo_, projectId_, this);
if (dlg.exec() != QDialog::Accepted) return;
const auto grad = dlg.stops();
if (grad.size() < 2) return;
const double opacity = dlg.opacity();
const unsigned char alpha = static_cast<unsigned char>(opacity * 255.0 + 0.5);
// 在新渐变上按各层级位置连续采样回填颜色(复刻 mapColors + addAlphaToColor 整体透明度)。
auto sampleGrad = [&](double pos) -> geopro::core::Rgba {
if (pos <= grad.front().pos) return grad.front().color;
if (pos >= grad.back().pos) return grad.back().color;
std::size_t i = 0;
while (i + 1 < grad.size() && pos > grad[i + 1].pos) ++i;
const double x0 = grad[i].pos, x1 = grad[i + 1].pos;
const double t = (x1 > x0) ? (pos - x0) / (x1 - x0) : 0.0;
return lerp(grad[i].color, grad[i + 1].color, t);
};
for (auto& r : rows_) {
geopro::core::Rgba c = sampleGrad((r.value - lo) / span);
if (opacity < 1.0) c.a = alpha; // 整体透明度覆盖 alpha
r.color = c;
}
rebuildTable();
}
void ColorScaleConfigDialog::onImportLvl() {
const QString path = QFileDialog::getOpenFileName(this, QStringLiteral("导入 .lvl"), {},
QStringLiteral("色阶层级文件 (*.lvl)"));
if (path.isEmpty()) return;
QFile f(path);
if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) {
QMessageBox::warning(this, QStringLiteral("导入"), QStringLiteral("无法打开文件。"));
return;
}
const std::vector<LvlRow> parsed = parseLvl(f.readAll().toStdString());
if (parsed.size() < 2) {
QMessageBox::warning(this, QStringLiteral("导入"),
QStringLiteral("文件格式不正确或层级不足。"));
return;
}
rows_.clear();
for (const auto& lr : parsed) rows_.push_back({lr.level, lr.color});
std::sort(rows_.begin(), rows_.end(),
[](const Row& a, const Row& b) { return a.value < b.value; });
lineCfg_.dashed = parsed.front().dashed; // 线形从首行带入
lineCfg_.lineColor = parsed.front().lineColor;
rebuildTable();
}
void ColorScaleConfigDialog::onExportLvl() {
const QString path = QFileDialog::getSaveFileName(this, QStringLiteral("导出 .lvl"),
QStringLiteral("等值线配置.lvl"),
QStringLiteral("色阶层级文件 (*.lvl)"));
if (path.isEmpty()) return;
std::vector<LvlRow> out;
for (const auto& r : rows_)
out.push_back({r.value, r.color, lineCfg_.dashed, lineCfg_.lineColor});
QFile f(path);
if (!f.open(QIODevice::WriteOnly | QIODevice::Text)) {
QMessageBox::warning(this, QStringLiteral("导出"), QStringLiteral("无法写入文件。"));
return;
}
const std::string text = generateLvl(out);
if (f.write(text.c_str(), static_cast<qint64>(text.size())) < 0)
QMessageBox::warning(this, QStringLiteral("导出"), QStringLiteral("写入失败。"));
}
void ColorScaleConfigDialog::loadColorBar(
const std::vector<std::pair<double, geopro::core::Rgba>>& bar) {
if (bar.size() < 2) return;
rows_.clear();
for (const auto& [level, color] : bar) rows_.push_back({level, color});
std::sort(rows_.begin(), rows_.end(),
[](const Row& a, const Row& b) { return a.value < b.value; });
rebuildTable();
if (!rows_.empty()) table_->selectRow(0); // 复刻 handleOpen载入后默认选中首行
}
void ColorScaleConfigDialog::onSaveOther() {
if (tplRepo_ == nullptr || projectId_.isEmpty()) return;
bool ok = false;
const QString name = QInputDialog::getText(this, QStringLiteral("另存模板配置"),
QStringLiteral("模板名称:"), QLineEdit::Normal,
QStringLiteral("等值线配置.lvl"), &ok);
if (!ok || name.trimmed().isEmpty()) return;
// 组装 properties复刻 handleSaveOther
QJsonArray colorBar;
for (const auto& r : rows_)
colorBar.append(QJsonArray{QString::number(r.value, 'f', 2), rgbaToCss(r.color)});
QJsonObject lineConfig{{QStringLiteral("showLines"), lineCfg_.lineShow},
{QStringLiteral("color"), rgbaToCss(lineCfg_.lineColor)},
{QStringLiteral("lineType"),
lineCfg_.dashed ? QStringLiteral("dashed") : QStringLiteral("solid")}};
QJsonObject labelConfig{{QStringLiteral("showLabels"), lineCfg_.labelShow},
{QStringLiteral("color"), rgbaToCss(lineCfg_.labelColor)}};
QJsonObject properties{{QStringLiteral("lineConfig"), lineConfig},
{QStringLiteral("labelConfig"), labelConfig},
{QStringLiteral("lvlSchemeType"), lvlSchemeType_},
{QStringLiteral("logLinesCount"), logLinesCount_},
{QStringLiteral("equalAreaLayerCount"), equalAreaLayerCount_},
{QStringLiteral("colorBar"), colorBar}};
// 走仓储传输;回调里用 QPointer 守卫 this模态对话框可能已关
QPointer<ColorScaleConfigDialog> self(this);
tplRepo_->saveLvlTemplate(projectId_, name.trimmed(), properties,
[self](bool ok, QString msg) {
if (!self) return;
if (ok)
QMessageBox::information(self, QStringLiteral("另存"),
QStringLiteral("另存成功。"));
else
QMessageBox::warning(
self, QStringLiteral("另存"),
QStringLiteral("另存失败:%1").arg(msg));
});
}
void ColorScaleConfigDialog::onOpen() {
if (tplRepo_ == nullptr || projectId_.isEmpty()) return;
QPointer<ColorScaleConfigDialog> self(this);
tplRepo_->listLvlTemplates(projectId_, [self](bool ok, QJsonArray list, QString msg) {
if (!self) return;
if (!ok) {
QMessageBox::warning(self, QStringLiteral("打开"),
QStringLiteral("获取色阶列表失败:%1").arg(msg));
return;
}
if (list.isEmpty()) {
QMessageBox::information(self, QStringLiteral("打开"),
QStringLiteral("暂无可用色阶模板。"));
return;
}
QStringList names;
for (const auto& it : list)
names << it.toObject().value(QStringLiteral("templateName")).toString();
bool picked = false;
const QString chosen = QInputDialog::getItem(
self, QStringLiteral("引用色阶"), QStringLiteral("请选择色阶:"), names, 0,
false, &picked);
if (!picked) return;
const int sel = names.indexOf(chosen);
if (sel < 0) return;
const QJsonObject props =
list[sel].toObject().value(QStringLiteral("properties")).toObject();
const QJsonArray colorBar = props.value(QStringLiteral("colorBar")).toArray();
std::vector<std::pair<double, geopro::core::Rgba>> bar;
for (const auto& e : colorBar) {
const QJsonArray pair = e.toArray();
if (pair.size() < 2) continue;
bar.emplace_back(pair[0].toVariant().toDouble(),
parseCssColor(pair[1].toString()));
}
if (bar.size() < 2) {
QMessageBox::warning(self, QStringLiteral("打开"),
QStringLiteral("色阶数据无效。"));
return;
}
// 透传方案字段。
self->lvlSchemeType_ =
props.value(QStringLiteral("lvlSchemeType")).toString(QStringLiteral("normal"));
self->logLinesCount_ =
props.value(QStringLiteral("logLinesCount")).toInt(8);
self->equalAreaLayerCount_ =
props.value(QStringLiteral("equalAreaLayerCount")).toInt(10);
const QJsonObject lc = props.value(QStringLiteral("lineConfig")).toObject();
if (!lc.isEmpty()) {
self->lineCfg_.lineShow = lc.value(QStringLiteral("showLines")).toBool(true);
self->lineCfg_.dashed =
lc.value(QStringLiteral("lineType")).toString() == QStringLiteral("dashed");
self->lineCfg_.lineColor =
parseCssColor(lc.value(QStringLiteral("color")).toString());
}
self->loadColorBar(bar);
});
}
geopro::core::ColorScale ColorScaleConfigDialog::colorScale() const {
geopro::core::ColorScale cs;
for (const auto& row : rows_) cs.addStop(row.value, row.color); // 内部按 value 升序
return cs;
}
} // namespace geopro::app