feat(ui): ContourPlotItem 网格填充栅格热力图 + 矢量等值线/标注/异常叠加
QwtPlotItem(非 Q_OBJECT)。填充用预渲染 ARGB32 QImage(每格细分 K=4, 双线性插值, 离散色带取色 → 平滑填充带边界; 含 NaN 格的像素透明 → 不规则白边), draw 时按数据 bbox 映射目标矩形 blit + SmoothPixmapTransform(拖动/缩放快)。 等值线复用 buildContourBands 的 lines(矢量), 黑 cosmetic 细线; level 由线上代表点采网格值吸附最近色阶级回填(管线恒 0), 沿线方向旋转标注(字号10)。 异常按 markType 画 点(方块)/线(折线)/面(闭合多边形), lineColor + dashed->虚线。 x 轴绑 xBottom, y 轴绑 yLeft。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
bac0a198ff
commit
32e0aaec28
|
|
@ -32,6 +32,7 @@ add_executable(geopro_desktop WIN32
|
||||||
panels/chart/ColorMapService.cpp
|
panels/chart/ColorMapService.cpp
|
||||||
panels/chart/ColorBarWidget.cpp
|
panels/chart/ColorBarWidget.cpp
|
||||||
panels/chart/ScatterPlotItem.cpp
|
panels/chart/ScatterPlotItem.cpp
|
||||||
|
panels/chart/ContourPlotItem.cpp
|
||||||
panels/chart/LivePanner.cpp
|
panels/chart/LivePanner.cpp
|
||||||
panels/AnomalyTablePanel.cpp
|
panels/AnomalyTablePanel.cpp
|
||||||
panels/DatasetDetailPage.cpp
|
panels/DatasetDetailPage.cpp
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,260 @@
|
||||||
|
#include "panels/chart/ContourPlotItem.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
|
#include <QFont>
|
||||||
|
#include <QPainter>
|
||||||
|
#include <QPen>
|
||||||
|
#include <QPolygonF>
|
||||||
|
#include <qwt_plot.h>
|
||||||
|
#include <qwt_scale_map.h>
|
||||||
|
|
||||||
|
#include "panels/chart/ColorMapService.hpp"
|
||||||
|
|
||||||
|
namespace geopro::app {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
constexpr int kFillUpsample = 4; // 填充图像每网格格细分 K(双线性插值平滑带边界)
|
||||||
|
constexpr int kMaxFillDim = 2400; // 填充图像单边像素上限(防极端网格爆内存)
|
||||||
|
constexpr int kLabelFontPx = 10; // 等值线标注字号
|
||||||
|
constexpr int kLabelMinSegPx = 60; // 太短的等值线不标注(像素长度阈值,draw 期判定)
|
||||||
|
constexpr double kRad2Deg = 57.29577951308232; // 180/π(避免依赖 M_PI)
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
ContourPlotItem::ContourPlotItem() : QwtPlotItem() {
|
||||||
|
setRenderHint(QwtPlotItem::RenderAntialiased, false);
|
||||||
|
// 网格数据 x 轴在底部(与 RawDataChartView 的顶部 x 轴不同);y 轴在左。
|
||||||
|
setXAxis(QwtPlot::xBottom);
|
||||||
|
setYAxis(QwtPlot::yLeft);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ContourPlotItem::setData(const core::Grid& g, ColorMapService* svc,
|
||||||
|
const std::vector<core::Anomaly>& anoms, bool showLines,
|
||||||
|
bool showLabels) {
|
||||||
|
showLines_ = showLines;
|
||||||
|
showLabels_ = showLabels;
|
||||||
|
anoms_ = anoms;
|
||||||
|
|
||||||
|
const int nx = g.nx(), ny = g.ny();
|
||||||
|
if (nx < 2 || ny < 2 || static_cast<int>(g.x.size()) < nx ||
|
||||||
|
static_cast<int>(g.y.size()) < ny) {
|
||||||
|
fillImage_ = QImage();
|
||||||
|
dataBBox_ = QRectF();
|
||||||
|
lines_.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const double xmin = g.x.front(), xmax = g.x.back();
|
||||||
|
const double ymin = g.y.front(), ymax = g.y.back();
|
||||||
|
dataBBox_ = QRectF(xmin, ymin, xmax - xmin, ymax - ymin);
|
||||||
|
|
||||||
|
buildFillImage(g, svc);
|
||||||
|
|
||||||
|
// 等值线(矢量):复用 render 管线,仅取 lines(填充走栅格)。
|
||||||
|
if (showLines_) {
|
||||||
|
render::ContourOptions opt;
|
||||||
|
opt.upsample = 2;
|
||||||
|
opt.makeLines = true;
|
||||||
|
auto res = render::buildContourBands(g, svc->scale(), opt);
|
||||||
|
lines_ = std::move(res.lines);
|
||||||
|
// buildContourBands 当前未回填 level(恒 0);在此按线上代表点采网格值并吸附到最近色阶级,
|
||||||
|
// 使标注显示真实等值线值。
|
||||||
|
resolveLineLevels(g, svc->scale());
|
||||||
|
} else {
|
||||||
|
lines_.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ContourPlotItem::resolveLineLevels(const core::Grid& g, const core::ColorScale& cs) {
|
||||||
|
const auto stops = cs.stopValues();
|
||||||
|
if (stops.empty() || lines_.empty()) return;
|
||||||
|
|
||||||
|
const int nx = g.nx(), ny = g.ny();
|
||||||
|
const double xmin = g.x.front(), xmax = g.x.back();
|
||||||
|
const double ymin = g.y.front(), ymax = g.y.back();
|
||||||
|
const double xspan = (xmax - xmin), yspan = (ymax - ymin);
|
||||||
|
|
||||||
|
// 在数据坐标点做双线性采样(NaN 安全)。
|
||||||
|
auto sampleAt = [&](const core::Vec2& p) -> double {
|
||||||
|
if (xspan <= 0 || yspan <= 0) return std::nan("");
|
||||||
|
double fi = (p.x - xmin) / xspan * (nx - 1);
|
||||||
|
double fj = (p.y - ymin) / yspan * (ny - 1);
|
||||||
|
fi = std::clamp(fi, 0.0, static_cast<double>(nx - 1));
|
||||||
|
fj = std::clamp(fj, 0.0, static_cast<double>(ny - 1));
|
||||||
|
int i0 = std::min(static_cast<int>(fi), nx - 2);
|
||||||
|
int j0 = std::min(static_cast<int>(fj), ny - 2);
|
||||||
|
double ti = fi - i0, tj = fj - j0;
|
||||||
|
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))
|
||||||
|
return std::nan("");
|
||||||
|
return (v00 * (1 - ti) + v10 * ti) * (1 - tj) + (v01 * (1 - ti) + v11 * ti) * tj;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (auto& ln : lines_) {
|
||||||
|
double sampled = std::nan("");
|
||||||
|
for (const auto& p : ln.pts) { // 取首个非 NaN 采样点
|
||||||
|
sampled = sampleAt(p);
|
||||||
|
if (!std::isnan(sampled)) break;
|
||||||
|
}
|
||||||
|
if (std::isnan(sampled)) {
|
||||||
|
ln.level = std::nan("");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// 吸附到最近色阶级(等值线恰落在某级上)。
|
||||||
|
double best = stops.front();
|
||||||
|
double bestD = std::fabs(sampled - best);
|
||||||
|
for (double s : stops) {
|
||||||
|
double d = std::fabs(sampled - s);
|
||||||
|
if (d < bestD) { bestD = d; best = s; }
|
||||||
|
}
|
||||||
|
ln.level = best;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ContourPlotItem::buildFillImage(const core::Grid& g, ColorMapService* svc) {
|
||||||
|
const int nx = g.nx(), ny = g.ny();
|
||||||
|
int W = (nx - 1) * kFillUpsample + 1;
|
||||||
|
int H = (ny - 1) * kFillUpsample + 1;
|
||||||
|
// 限幅:极端网格下按比例降采样,保证内存/性能可控。
|
||||||
|
if (W > kMaxFillDim) W = kMaxFillDim;
|
||||||
|
if (H > kMaxFillDim) H = kMaxFillDim;
|
||||||
|
|
||||||
|
QImage img(W, H, QImage::Format_ARGB32);
|
||||||
|
img.fill(Qt::transparent);
|
||||||
|
|
||||||
|
// 每像素 → 归一化网格坐标 (fi,fj) → 四邻格双线性插值;任一邻格 NaN 则该像素透明。
|
||||||
|
for (int py = 0; py < H; ++py) {
|
||||||
|
// 图像顶行 py=0 对应 y 最大(ymax,地表);底行对应 y 最小(最深)。
|
||||||
|
double fj = static_cast<double>(H - 1 - py) / (H - 1) * (ny - 1);
|
||||||
|
int j0 = std::min(static_cast<int>(fj), ny - 2);
|
||||||
|
double tj = fj - j0;
|
||||||
|
auto* scan = reinterpret_cast<QRgb*>(img.scanLine(py));
|
||||||
|
for (int px = 0; px < W; ++px) {
|
||||||
|
double fi = static_cast<double>(px) / (W - 1) * (nx - 1);
|
||||||
|
int i0 = std::min(static_cast<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))
|
||||||
|
continue; // 含无数据格 → 像素透明(不规则白边)
|
||||||
|
|
||||||
|
double v = (v00 * (1 - ti) + v10 * ti) * (1 - tj) +
|
||||||
|
(v01 * (1 - ti) + v11 * ti) * tj;
|
||||||
|
auto c = svc->colorAtDiscrete(v); // 离散色带 → 平滑填充带边界
|
||||||
|
scan[px] = qRgba(c.r, c.g, c.b, c.a ? c.a : 255);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fillImage_ = std::move(img);
|
||||||
|
}
|
||||||
|
|
||||||
|
QRectF ContourPlotItem::boundingRect() const {
|
||||||
|
return dataBBox_;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ContourPlotItem::draw(QPainter* painter, const QwtScaleMap& xMap, const QwtScaleMap& yMap,
|
||||||
|
const QRectF& /*canvasRect*/) const {
|
||||||
|
if (dataBBox_.isNull()) return;
|
||||||
|
|
||||||
|
const double xmin = dataBBox_.left(), xmax = dataBBox_.right();
|
||||||
|
const double ymin = dataBBox_.top(), ymax = dataBBox_.bottom();
|
||||||
|
|
||||||
|
// 1) 填充:数据 bbox → 像素矩形(注意 y 翻转:ymax→画布上沿、ymin→下沿),blit + 平滑缩放。
|
||||||
|
if (!fillImage_.isNull()) {
|
||||||
|
const double pxL = xMap.transform(xmin);
|
||||||
|
const double pxR = xMap.transform(xmax);
|
||||||
|
const double pyTop = yMap.transform(ymax);
|
||||||
|
const double pyBot = yMap.transform(ymin);
|
||||||
|
QRectF target(pxL, pyTop, pxR - pxL, pyBot - pyTop);
|
||||||
|
painter->save();
|
||||||
|
painter->setRenderHint(QPainter::SmoothPixmapTransform, true);
|
||||||
|
painter->drawImage(target, fillImage_);
|
||||||
|
painter->restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
auto mapPt = [&](const core::Vec2& p) {
|
||||||
|
return QPointF(xMap.transform(p.x), yMap.transform(p.y));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2) 等值线:黑色 0 宽(cosmetic)细线。
|
||||||
|
if (showLines_ && !lines_.empty()) {
|
||||||
|
painter->save();
|
||||||
|
painter->setRenderHint(QPainter::Antialiasing, true);
|
||||||
|
QPen pen(QColor(0, 0, 0));
|
||||||
|
pen.setWidthF(0.0); // cosmetic:恒 1px,不随缩放变粗
|
||||||
|
painter->setPen(pen);
|
||||||
|
for (const auto& ln : lines_) {
|
||||||
|
if (ln.pts.size() < 2) continue;
|
||||||
|
QPolygonF poly;
|
||||||
|
poly.reserve(static_cast<int>(ln.pts.size()));
|
||||||
|
for (const auto& p : ln.pts) poly << mapPt(p);
|
||||||
|
painter->drawPolyline(poly);
|
||||||
|
}
|
||||||
|
painter->restore();
|
||||||
|
|
||||||
|
// 3) 标注:沿线中段画 level 数值(小字黑,随相邻两点方向旋转)。
|
||||||
|
if (showLabels_) {
|
||||||
|
painter->save();
|
||||||
|
painter->setRenderHint(QPainter::Antialiasing, true);
|
||||||
|
QFont f = painter->font();
|
||||||
|
f.setPixelSize(kLabelFontPx);
|
||||||
|
painter->setFont(f);
|
||||||
|
painter->setPen(QColor(0, 0, 0));
|
||||||
|
for (const auto& ln : lines_) {
|
||||||
|
if (ln.pts.size() < 2 || std::isnan(ln.level)) continue;
|
||||||
|
// 取折线中段两点定位置/朝向。
|
||||||
|
const std::size_t mid = ln.pts.size() / 2;
|
||||||
|
const QPointF a = mapPt(ln.pts[mid - 1]);
|
||||||
|
const QPointF b = mapPt(ln.pts[mid]);
|
||||||
|
// 整条线像素长度太短不标注(避免密集杂乱)。
|
||||||
|
const QPointF s = mapPt(ln.pts.front());
|
||||||
|
const QPointF e = mapPt(ln.pts.back());
|
||||||
|
if (std::hypot(e.x() - s.x(), e.y() - s.y()) < kLabelMinSegPx) continue;
|
||||||
|
double ang = std::atan2(b.y() - a.y(), b.x() - a.x()) * kRad2Deg;
|
||||||
|
if (ang > 90.0) ang -= 180.0; // 保持文字大体正向(不上下颠倒)
|
||||||
|
if (ang < -90.0) ang += 180.0;
|
||||||
|
const QString txt = QString::number(ln.level, 'g', 4);
|
||||||
|
painter->save();
|
||||||
|
painter->translate((a.x() + b.x()) * 0.5, (a.y() + b.y()) * 0.5);
|
||||||
|
painter->rotate(ang);
|
||||||
|
painter->drawText(QPointF(-12, -2), txt);
|
||||||
|
painter->restore();
|
||||||
|
}
|
||||||
|
painter->restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) 异常叠加:点=小方块、线=折线、面=闭合多边形;颜色用 lineColor,dashed→虚线。
|
||||||
|
if (showAnomalies_ && !anoms_.empty()) {
|
||||||
|
painter->save();
|
||||||
|
painter->setRenderHint(QPainter::Antialiasing, true);
|
||||||
|
painter->setBrush(Qt::NoBrush);
|
||||||
|
for (const auto& a : anoms_) {
|
||||||
|
if (a.localPts.empty()) continue;
|
||||||
|
QColor col(QString::fromStdString(a.lineColor));
|
||||||
|
if (!col.isValid()) col = QColor(0, 0, 0);
|
||||||
|
QPen pen(col);
|
||||||
|
pen.setWidthF(a.lineWidth > 0 ? a.lineWidth : 1.0);
|
||||||
|
pen.setStyle(a.dashed ? Qt::DashLine : Qt::SolidLine);
|
||||||
|
painter->setPen(pen);
|
||||||
|
|
||||||
|
if (a.markType == core::AnomalyMarkType::Point) {
|
||||||
|
const QPointF c = mapPt(a.localPts.front());
|
||||||
|
painter->drawRect(QRectF(c.x() - 3, c.y() - 3, 6, 6));
|
||||||
|
} else {
|
||||||
|
QPolygonF poly;
|
||||||
|
poly.reserve(static_cast<int>(a.localPts.size()));
|
||||||
|
for (const auto& p : a.localPts) poly << mapPt(p);
|
||||||
|
if (a.markType == core::AnomalyMarkType::Polygon)
|
||||||
|
painter->drawPolygon(poly); // 闭合
|
||||||
|
else
|
||||||
|
painter->drawPolyline(poly);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
painter->restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace geopro::app
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
#pragma once
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <QImage>
|
||||||
|
#include <QRectF>
|
||||||
|
#include <qwt_plot_item.h>
|
||||||
|
|
||||||
|
#include "model/Anomaly.hpp"
|
||||||
|
#include "model/Field.hpp"
|
||||||
|
#include "ContourBands.hpp"
|
||||||
|
|
||||||
|
class QPainter;
|
||||||
|
class QwtScaleMap;
|
||||||
|
|
||||||
|
namespace geopro::app {
|
||||||
|
|
||||||
|
class ColorMapService;
|
||||||
|
|
||||||
|
// 网格等值线图项(QwtPlotItem,非 Q_OBJECT):
|
||||||
|
// - 填充:预渲染 QImage 热力图(双线性插值 + 离散色带取色),draw 时 blit+平滑缩放
|
||||||
|
// (避免 banded 多边形数万顶点导致拖动卡顿);含 NaN 的像素透明 → 不规则白边。
|
||||||
|
// - 等值线:buildContourBands 返回的矢量折线(黑细线),随轴变换映射后 drawPolyline。
|
||||||
|
// - 标注:沿线方向旋转的 level 数值(小字黑)。
|
||||||
|
// - 异常叠加:按 markType 画 点(方块)/线(折线)/面(闭合多边形),dashed→虚线。
|
||||||
|
class ContourPlotItem : public QwtPlotItem {
|
||||||
|
public:
|
||||||
|
ContourPlotItem();
|
||||||
|
|
||||||
|
// 构建填充图像 + 缓存等值线/异常。svc 不被拥有(由 GridDataChartView 持有)。
|
||||||
|
void setData(const core::Grid& g, ColorMapService* svc,
|
||||||
|
const std::vector<core::Anomaly>& anoms, bool showLines, bool showLabels);
|
||||||
|
|
||||||
|
void setShowLines(bool on) { showLines_ = on; }
|
||||||
|
void setShowLabels(bool on) { showLabels_ = on; }
|
||||||
|
void setShowAnomalies(bool on) { showAnomalies_ = on; }
|
||||||
|
|
||||||
|
int rtti() const override { return QwtPlotItem::Rtti_PlotUserItem; }
|
||||||
|
|
||||||
|
QRectF boundingRect() const override;
|
||||||
|
|
||||||
|
void draw(QPainter* painter, const QwtScaleMap& xMap, const QwtScaleMap& yMap,
|
||||||
|
const QRectF& canvasRect) const override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
void buildFillImage(const core::Grid& g, ColorMapService* svc);
|
||||||
|
void resolveLineLevels(const core::Grid& g, const core::ColorScale& cs);
|
||||||
|
|
||||||
|
QImage fillImage_; // 预渲染填充热力图(ARGB32,含透明无数据区)
|
||||||
|
QRectF dataBBox_; // 数据包围盒(x[xmin,xmax] y[ymin,ymax])
|
||||||
|
std::vector<render::ContourLine> lines_; // 矢量等值线(含 level)
|
||||||
|
std::vector<core::Anomaly> anoms_; // 异常叠加
|
||||||
|
|
||||||
|
bool showLines_ = true;
|
||||||
|
bool showLabels_ = true;
|
||||||
|
bool showAnomalies_ = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace geopro::app
|
||||||
Loading…
Reference in New Issue