geopro/src/app/panels/chart/LineChartView.cpp

381 lines
15 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 "panels/chart/LineChartView.hpp"
#include <QBrush>
#include <QEvent>
#include <QGuiApplication>
#include <QHBoxLayout>
#include <QLabel>
#include <QMouseEvent>
#include <QPainter>
#include <QPalette>
#include <QPen>
#include <QScreen>
#include <QVBoxLayout>
#include <qwt_plot.h>
#include <qwt_plot_canvas.h>
#include <qwt_plot_curve.h>
#include <qwt_plot_grid.h>
#include <qwt_scale_draw.h>
#include <qwt_scale_map.h>
#include <qwt_spline_local.h>
#include <qwt_spline_parametrization.h>
#include <qwt_text.h>
#include <qwt_widget_overlay.h>
#include <algorithm>
#include <cmath>
#include <limits>
#include "Theme.hpp"
#include "panels/chart/ChartTheme.hpp"
namespace geopro::app {
namespace {
// 类目轴刻度把整数刻度位0,1,2,…)映射为类目标签 "#1","#2",…(来自 categories
// 非整数/越界刻度返回空标签(避免次刻度污染)。与 BarChartView::CategoryScaleDraw 同款。
class CategoryScaleDraw : public QwtScaleDraw {
public:
explicit CategoryScaleDraw(std::vector<QString> labels) : labels_(std::move(labels)) {
enableComponent(QwtScaleDraw::Backbone, true);
enableComponent(QwtScaleDraw::Ticks, true);
}
QwtText label(double v) const override {
const double r = std::round(v);
if (std::abs(v - r) > 1e-6) return QwtText(); // 仅整数刻度出标签
const int i = static_cast<int>(r);
if (i < 0 || i >= static_cast<int>(labels_.size())) return QwtText();
return labels_[static_cast<size_t>(i)];
}
private:
std::vector<QString> labels_;
};
QColor lineColor(const QString& hex) {
QColor c(hex);
return c.isValid() ? c : QColor(0x54, 0x70, 0xc6); // 回退 ECharts 蓝
}
} // namespace
LineChartView::LineChartView(QWidget* parent) : QWidget(parent) {
auto* lay = new QVBoxLayout(this);
lay->setContentsMargins(0, 0, 0, 0);
lay->setSpacing(0);
// 左上水平 y 轴标题ECharts 风格,对齐 BarChartView
yTitle_ = new QLabel(this);
auto* titleRow = new QWidget(this);
auto* titleLay = new QHBoxLayout(titleRow);
titleLay->setContentsMargins(48, 6, 8, 0); // 左缩进对齐 y 轴上方
titleLay->setSpacing(0);
titleLay->addWidget(yTitle_);
titleLay->addStretch();
lay->addWidget(titleRow);
plot_ = new QwtPlot(this);
plot_->setObjectName(QStringLiteral("trajLinePlotArea"));
plot_->enableAxis(QwtPlot::xBottom, true);
plot_->enableAxis(QwtPlot::yLeft, true);
// x 轴标题「电极号」底部居中setData 设文本)。
plot_->setAxisTitle(QwtPlot::xBottom, QwtText());
// 仅横向y网格弱化与原版 ECharts 一致:仅水平刻度线)。
auto* grid = new QwtPlotGrid();
grid->enableX(false);
grid->enableY(true);
grid->enableXMin(false);
grid->enableYMin(false);
grid->setMajorPen(QColor(225, 225, 225), 1.0, Qt::SolidLine);
grid->attach(plot_);
plot_->setMinimumSize(0, 0);
lay->addWidget(plot_, 1);
// 鼠标 hover 追踪:虚线参考线 + 实心点 + 浮动框data_ 地址稳定setData 只改其内容)。
hover_ = new LineHoverTip(plot_, &data_, this);
// 主题:底色/轴字/网格按当前主题套一次 + 热切换。
applyChartPlotTheme(plot_);
QObject::connect(&ThemeManager::instance(), &ThemeManager::changed, plot_, [this]() {
applyChartPlotTheme(plot_);
QPalette pal = yTitle_->palette();
pal.setColor(QPalette::WindowText,
isDarkTheme() ? tokenColor("text/secondary") : QColor(90, 90, 90));
yTitle_->setPalette(pal);
});
}
LineChartView::~LineChartView() {
// 卸载并删除已挂折线,先于 QwtPlot autoDelete 触发(与 BarChartView 析构对称,避免双删)。
clearCurve();
}
void LineChartView::clearCurve() {
if (curve_) {
curve_->detach();
delete curve_;
curve_ = nullptr;
}
}
void LineChartView::setPayload(const QVariant& payload) {
if (!payload.canConvert<geopro::core::LinePayload>()) return; // 坏/空 → 空态
setData(payload.value<geopro::core::LinePayload>());
}
void LineChartView::setData(const geopro::core::LinePayload& p) {
data_ = p;
clearCurve();
const int n = static_cast<int>(p.categories.size());
// y 轴标题(左上水平 QLabel
yTitle_->setText(p.yTitle);
QPalette tpal = yTitle_->palette();
tpal.setColor(QPalette::WindowText,
isDarkTheme() ? tokenColor("text/secondary") : QColor(90, 90, 90));
yTitle_->setPalette(tpal);
// 锁定两轴为固定刻度(关闭自动缩放),使 hover 时挂/卸标记的 replot 不会重新拟合 →
// 曲线不再随鼠标进入而上下漂移FIX 3
plot_->setAxisAutoScale(QwtPlot::xBottom, false);
plot_->setAxisAutoScale(QwtPlot::yLeft, false);
// x 轴标题「电极号」+ 类目刻度 "#1".."#40"。
plot_->setAxisTitle(QwtPlot::xBottom, p.xTitle);
plot_->setAxisScaleDraw(QwtPlot::xBottom, new CategoryScaleDraw(p.categories));
plot_->setAxisScale(QwtPlot::xBottom, 0.0, n > 0 ? n - 1 : 0.0, 1.0); // 每类目一刻度
plot_->setAxisMaxMinor(QwtPlot::xBottom, 0);
// y 范围固定 0..max*1.1(从数据取上界,留 10% 余量;对齐原版 030 观感)。
// 固定刻度后 hover 标记不再触发 y 轴重算。
double yMax = 0.0;
for (double v : p.y) yMax = std::max(yMax, v);
plot_->setAxisScale(QwtPlot::yLeft, 0.0, yMax > 0 ? yMax * 1.1 : 1.0);
if (p.y.empty()) {
plot_->replot();
return;
}
// 单条折线:电极号(0..n-1) → 高程。
//
// 平滑必须在【数据坐标】里一次性算好,绝不能用 QwtPlotCurve::Fitted。原因本 BUG 根因):
// QwtPlotCurve 的 Fitted 拟合器在【绘制时】对【已映射到画布像素】的点跑样条
// (见 qwt_plot_curve.cpp 注释 “The curve fitter operates on the translated points
// ( = widget coordinates)”)。于是拟合结果取决于当时画布的像素几何;当首次 hover 触发
// replot 时画布几何与首帧哪怕有一丁点差异,样条就在不同像素网格上重采样 → 曲线竖直方向
// 细微漂移。锁轴并不能消除这一点(漂移源是像素级重拟合,不是自动缩放)。
// 解法QWT 官方建议:把拟合结果缓存进 series在数据坐标里把样条展开成稠密折线
// 作为静态 samples 交给普通 Lines 曲线。此后每次 replot 都映射同一条固定折线 → 首帧与
// hover 帧像素完全一致零漂移。Cardinal + ParameterUniform 与原 QwtSplineCurveFitter
// 内部一致,平滑观感不变。
curve_ = new QwtPlotCurve(p.seriesName);
QVector<QPointF> nodes;
nodes.reserve(n);
for (int i = 0; i < n && i < static_cast<int>(p.y.size()); ++i)
nodes.append(QPointF(i, p.y[static_cast<size_t>(i)]));
QVector<QPointF> samples = nodes;
if (p.smooth && nodes.size() > 2) {
QwtSplineLocal spline(QwtSplineLocal::Cardinal);
spline.setParametrization(QwtSplineParametrization::ParameterUniform);
// 展平容差取数据范围的极小比例 → 折线足够稠密、肉眼平滑;数据坐标空间,缩放无关。
const double xSpan = nodes.isEmpty() ? 1.0 : (nodes.last().x() - nodes.first().x());
const double tolerance = std::max(std::abs(xSpan), 1.0) / 2000.0;
const QPolygonF smooth = spline.polygon(QPolygonF(nodes), tolerance);
if (smooth.size() > 1) samples = QVector<QPointF>(smooth.begin(), smooth.end());
}
curve_->setSamples(samples);
curve_->setStyle(QwtPlotCurve::Lines);
curve_->setPen(QPen(lineColor(p.color), 2));
curve_->setRenderHint(QwtPlotItem::RenderAntialiased, true);
curve_->attach(plot_);
plot_->replot();
}
// ── LineHoverOverlay / LineHoverTip折线 hover 虚线参考线 + 实心点 + 浮动框 ──────
namespace {
// 从类目标签 "#N" 解析电极号;无前缀/非数字 → 回退 1-based 索引。
int electrodeNoFromCategory(const std::vector<QString>& cats, int i) {
if (i >= 0 && i < static_cast<int>(cats.size())) {
QString s = cats[static_cast<size_t>(i)];
if (s.startsWith(QLatin1Char('#'))) s = s.mid(1);
bool ok = false;
const int no = s.toInt(&ok);
if (ok) return no;
}
return i + 1;
}
} // namespace
// hover 叠层:画在 plot canvas 之上的 QwtWidgetOverlay。激活时用 plot 的 canvasMap
// 把数据坐标 (idx, yVal) 变换为像素,画一条垂直虚线参考线(满画布高)+ 曲线上实心点。
// updateOverlay() 只重绘本叠层、【不】触碰 plot/曲线 → 曲线零漂移。颜色每帧按主题重算(廉价)。
class LineHoverOverlay : public QwtWidgetOverlay {
public:
explicit LineHoverOverlay(QwtPlot* plot)
: QwtWidgetOverlay(plot->canvas()), plot_(plot) {
// 不用 mask默认 MaskHint 在未重写 maskHint 时会把可见区域裁空 → 叠层不显示)。
setMaskMode(QwtWidgetOverlay::NoMask);
}
// 设置 hover 状态并重绘叠层(仅叠层,不 replot plot
void setHover(bool active, int idx, double yVal, const QColor& dotColor) {
active_ = active;
idx_ = idx;
yVal_ = yVal;
dotColor_ = dotColor;
updateOverlay();
}
void clearHover() {
if (!active_) return;
active_ = false;
updateOverlay();
}
protected:
void drawOverlay(QPainter* painter) const override {
if (!active_ || !plot_) return;
const QwtScaleMap xMap = plot_->canvasMap(QwtPlot::xBottom);
const QwtScaleMap yMap = plot_->canvasMap(QwtPlot::yLeft);
const double xPix = xMap.transform(idx_);
const double yPix = yMap.transform(yVal_);
// 垂直虚线参考线:满画布高(主题感知颜色)。
const QColor lineCol =
isDarkTheme() ? tokenColor("border/strong") : QColor(150, 170, 210);
painter->setRenderHint(QPainter::Antialiasing, true);
painter->setPen(QPen(lineCol, 1.0, Qt::DashLine));
painter->drawLine(QPointF(xPix, 0.0), QPointF(xPix, static_cast<double>(height())));
// 曲线上实心圆点(系列色填充 + 同色描边,直径 ~8px
QColor dot = dotColor_.isValid() ? dotColor_ : QColor(0x54, 0x70, 0xc6);
painter->setPen(QPen(dot, 1.0));
painter->setBrush(QBrush(dot));
painter->drawEllipse(QPointF(xPix, yPix), 4.0, 4.0);
}
private:
QwtPlot* plot_;
bool active_ = false;
int idx_ = 0;
double yVal_ = 0.0;
QColor dotColor_;
};
LineHoverTip::LineHoverTip(QwtPlot* plot, const geopro::core::LinePayload* data, QObject* parent)
: QObject(parent), plot_(plot), data_(data) {
if (plot_ && plot_->canvas()) {
// hover无按键需开启鼠标跟踪否则默认仅按键按下时才收到 MouseMove。
plot_->canvas()->setMouseTracking(true);
plot_->canvas()->installEventFilter(this);
// 叠层 parent 为 canvas → 随 canvas 尺寸/位置自动跟随QwtWidgetOverlay 处理 resize
overlay_ = new LineHoverOverlay(plot_);
overlay_->show();
}
}
LineHoverTip::~LineHoverTip() {
// tip_ 是无父顶层 widget须显式删除。overlay_ parent 为 canvas随其析构无需手删
delete tip_;
tip_ = nullptr;
}
void LineHoverTip::hideHover() {
// 叠层置非激活并重绘(仅叠层,不 replot plot隐藏浮动框。
if (overlay_) overlay_->clearHover();
if (tip_) tip_->hide();
}
void LineHoverTip::showTip(const QString& html, const QPoint& globalPos) {
// 自定义浮动框Qt::ToolTip 顶层 QLabel富文本。每次 MouseMove 必更新文本与位置,
// 不经 QToolTip 去重逻辑 → 提示稳定显示FIX 4。主题感知配色白框/暗框)。
if (!tip_) {
tip_ = new QLabel(nullptr, Qt::ToolTip | Qt::FramelessWindowHint);
tip_->setObjectName(QStringLiteral("lineHoverTip"));
tip_->setTextFormat(Qt::RichText);
tip_->setMargin(8);
tip_->setAttribute(Qt::WA_ShowWithoutActivating, true);
}
const bool dark = isDarkTheme();
const QColor bg = dark ? tokenColor("bg/panel") : QColor(0xFF, 0xFF, 0xFF);
const QColor border = dark ? tokenColor("border/strong") : QColor(0xE3, 0xE6, 0xEB);
const QColor fg = dark ? tokenColor("text/primary") : QColor(0x27, 0x2C, 0x35);
tip_->setStyleSheet(QStringLiteral(
"QLabel#lineHoverTip{background:%1;color:%2;border:1px solid %3;"
"border-radius:4px;}")
.arg(bg.name(), fg.name(), border.name()));
tip_->setText(html);
tip_->adjustSize();
// 在光标右下偏移摆放;越右/下边界则翻向左/上,避免被屏幕裁切。
QPoint pos = globalPos + QPoint(14, 16);
if (QScreen* scr = QGuiApplication::screenAt(globalPos)) {
const QRect g = scr->availableGeometry();
if (pos.x() + tip_->width() > g.right()) pos.setX(globalPos.x() - tip_->width() - 14);
if (pos.y() + tip_->height() > g.bottom()) pos.setY(globalPos.y() - tip_->height() - 16);
}
tip_->move(pos);
if (!tip_->isVisible()) tip_->show();
}
bool LineHoverTip::eventFilter(QObject* obj, QEvent* ev) {
if (!plot_ || obj != plot_->canvas()) return QObject::eventFilter(obj, ev);
if (ev->type() == QEvent::Leave) {
hideHover();
return false;
}
if (ev->type() != QEvent::MouseMove) return QObject::eventFilter(obj, ev);
auto* me = static_cast<QMouseEvent*>(ev);
// 拖动中(有按键)或无数据 → 不弹提示。
if (me->buttons() != Qt::NoButton || !data_ || data_->y.empty()) {
hideHover();
return false;
}
const auto& ys = data_->y;
const int n = static_cast<int>(ys.size());
// 按 x 吸附到最近电极索引(类目位于整数 x = 0..n-1
const QwtScaleMap xMap = plot_->canvasMap(QwtPlot::xBottom);
const double xVal = xMap.invTransform(me->position().x());
int idx = static_cast<int>(std::lround(xVal));
idx = std::clamp(idx, 0, n - 1);
const double yVal = ys[static_cast<size_t>(idx)];
const int electrodeNo = electrodeNoFromCategory(data_->categories, idx);
// 实心点系列色(虚线参考线颜色在 overlay 内按主题重算)。
QColor dotCol(data_->color);
if (!dotCol.isValid()) dotCol = QColor(0x54, 0x70, 0xc6);
// 关键:把虚线 + 实心点交给叠层重绘仅叠层重绘【绝不】replot plot → 曲线零漂移)。
if (overlay_) overlay_->setHover(true, idx, yVal, dotCol);
// 浮动框:#<电极号> 表头 + 系列点·标签·值(高程保留 3 位小数)。系列点用系列色圆点。
const QString tip = QStringLiteral("#%1<br><span style='color:%2'>&#9679;</span> 高程(m)"
"&nbsp;&nbsp;%3")
.arg(electrodeNo)
.arg(dotCol.name())
.arg(yVal, 0, 'f', 3);
showTip(tip, me->globalPosition().toPoint());
return false; // 不消费,保留其它过滤器链路
}
} // namespace geopro::app