381 lines
15 KiB
C++
381 lines
15 KiB
C++
#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% 余量;对齐原版 0–30 观感)。
|
||
// 固定刻度后 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'>●</span> 高程(m)"
|
||
" %3")
|
||
.arg(electrodeNo)
|
||
.arg(dotCol.name())
|
||
.arg(yVal, 0, 'f', 3);
|
||
showTip(tip, me->globalPosition().toPoint());
|
||
|
||
return false; // 不消费,保留其它过滤器链路
|
||
}
|
||
|
||
} // namespace geopro::app
|