#include "panels/chart/LineChartView.hpp" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #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 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(r); if (i < 0 || i >= static_cast(labels_.size())) return QwtText(); return labels_[static_cast(i)]; } private: std::vector 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()) return; // 坏/空 → 空态 setData(payload.value()); } void LineChartView::setData(const geopro::core::LinePayload& p) { data_ = p; clearCurve(); const int n = static_cast(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 nodes; nodes.reserve(n); for (int i = 0; i < n && i < static_cast(p.y.size()); ++i) nodes.append(QPointF(i, p.y[static_cast(i)])); QVector 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(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& cats, int i) { if (i >= 0 && i < static_cast(cats.size())) { QString s = cats[static_cast(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(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(ev); // 拖动中(有按键)或无数据 → 不弹提示。 if (me->buttons() != Qt::NoButton || !data_ || data_->y.empty()) { hideHover(); return false; } const auto& ys = data_->y; const int n = static_cast(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(std::lround(xVal)); idx = std::clamp(idx, 0, n - 1); const double yVal = ys[static_cast(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
高程(m)" "  %3") .arg(electrodeNo) .arg(dotCol.name()) .arg(yVal, 0, 'f', 3); showTip(tip, me->globalPosition().toPoint()); return false; // 不消费,保留其它过滤器链路 } } // namespace geopro::app