diff --git a/Components/Application/Src/Application.cpp b/Components/Application/Src/Application.cpp index 16503e0..63fcb0d 100644 --- a/Components/Application/Src/Application.cpp +++ b/Components/Application/Src/Application.cpp @@ -689,20 +689,15 @@ namespace jdi g.SetPixelOffsetMode(gd::PixelOffsetModeHalf); g.SetTextRenderingHint(gd::TextRenderingHintClearTypeGridFit); - // 拖拽(拖动信号曲线 / 时间光标)期间视图与数据都不变: - // 静态场景缓存已包含工具栏/标签栏/面板/全部网格,直接跳过这些区域的重复绘制, - // 实测可省下约 13ms/帧(面板绘制就占 8ms)。 - const bool reuseScene = m_plot.CanReuseSceneCache(); + // 工具栏/标签栏/面板各自带悬停态,必须每帧绘制; + // 真正昂贵的是绘图区的波形(大文件约 80ms/帧), + // 由 PlotView 内部的静态场景缓存消除,与数据量无关。 + UiFillRect(g, gd::RectF(0.0f, 0.0f, static_cast(width), static_cast(height)), + m_theme.windowBg); - if (!reuseScene) - { - UiFillRect(g, gd::RectF(0.0f, 0.0f, static_cast(width), static_cast(height)), - m_theme.windowBg); - - m_tabBar.Paint(g); - m_toolbar.Paint(g); - m_panel.Paint(g); - } + m_tabBar.Paint(g); + m_toolbar.Paint(g); + m_panel.Paint(g); m_plot.Paint(g); @@ -1033,8 +1028,8 @@ namespace jdi if (m_plot.ToggleZoomMode(PlotZoomMode::TIME)) { SetStatusMessage((m_plot.CurrentZoomMode() == PlotZoomMode::TIME) - ? L"时间轴缩放:在绘图区横向拖拽选择区间即可放大。" - : L"已退出时间轴缩放模式。", + ? L"时间轴选区:在绘图区横向拖拽选择区间即可放大。" + : L"已退出时间轴选区模式。", false); } break; @@ -1042,8 +1037,8 @@ namespace jdi if (m_plot.ToggleZoomMode(PlotZoomMode::VALUE)) { SetStatusMessage((m_plot.CurrentZoomMode() == PlotZoomMode::VALUE) - ? L"Y 轴缩放:在某个网格内纵向拖拽选择区间即可放大。" - : L"已退出 Y 轴缩放模式。", + ? L"Y 轴选区:在某个网格内纵向拖拽选择区间即可放大。" + : L"已退出 Y 轴选区模式。", false); } break; @@ -1126,7 +1121,7 @@ namespace jdi // Esc 的语义: // 1) 放弃当前正在进行的拖拽(典型场景:缩放的框选选区还没松开左键); // 取消后不应用选区、视图范围保持不变; - // 2) 同时退出全部缩放模式,使工具栏「时间轴缩放」「Y 轴缩放」回到未选中态。 + // 2) 同时退出全部缩放模式,使工具栏「时间轴选区」「Y 轴选区」回到未选中态。 // Esc 不与时间光标关联:关闭光标请用工具栏「光标」按钮,避免误关指针。 bool changed = m_plot.CancelDrag(); changed = m_plot.ExitZoomModes() || changed; @@ -1409,6 +1404,7 @@ namespace jdi MenuItem autoViewItem; autoViewItem.label = L"选区 Y 轴自适应"; autoViewItem.id = PLOT_CMD_LANE_AUTO_VIEW; + autoViewItem.checked = m_plot.LaneRangeIsAutoToView(laneIndex); items.push_back(autoViewItem); MenuItem separator; @@ -1458,6 +1454,10 @@ namespace jdi const bool isAuto = m_plot.LaneRangeIsAuto(laneIndex); m_menu.UpdateItemLabel(PLOT_CMD_LANE_AUTO, isAuto ? L"Y 轴自适应(当前)" : L"恢复 Y 轴自适应", isAuto, !isAuto); + + // “选区 Y 轴自适应”是带状态的操作,同样需要实时反映其“当前生效”标记。 + m_menu.UpdateItemLabel(PLOT_CMD_LANE_AUTO_VIEW, L"选区 Y 轴自适应", + m_plot.LaneRangeIsAutoToView(laneIndex), true); } void Application::DispatchMenuResult(const MenuResult& result) diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index dc2ce52..e4dc72a 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -253,17 +253,7 @@ namespace jdi bool CancelDrag(); /** - * @brief 判断本帧是否可以整体复用拖拽静态场景缓存。 - * - * 拖动信号曲线 / 时间光标时视图与数据都不变,缓存内容仍然有效。 - * 宿王可据此跳过工具栏/标签栏/面板的重复绘制(它们也在缓存内)。 - * - * @return 可复用返回 true。 - */ - bool CanReuseSceneCache() const; - - /** - * @brief 退出全部缩放模式(时间轴缩放 / Y 轴缩放),使工具栏缩放按钮回到未选中态。 + * @brief 退出全部缩放模式(时间轴选区 / Y 轴选区),使工具栏缩放按钮回到未选中态。 * @return 之前处于某个缩放模式返回 true。 */ bool ExitZoomModes(); @@ -320,6 +310,17 @@ namespace jdi */ bool LaneRangeIsAuto(int laneIndex) const; + /** + * @brief 判断某网格当前范围是否正是「选区 Y 轴自适应」的结果(用于菜单勾选标记)。 + * + * 只有在该操作之后未发生任何手动幅值改动、且时间选区没有变化时才返回 true, + * 因为一旦时间选区改变,之前的选区自适应结果就不再对应当前视野。 + * + * @param [IN] laneIndex 网格下标。 + * @return 当前生效返回 true。 + */ + bool LaneRangeIsAutoToView(int laneIndex) const; + /** * @brief 让某网格的 Y 轴恢复自适应。 * @param [IN] laneIndex 网格下标。 @@ -844,23 +845,27 @@ namespace jdi mutable std::deque m_polylineCache; /**< 折线顶点缓存(按几何键复用)。 */ /** - * @brief 静态场景缓存的几何键。 + * @brief 静态场景缓存的几何/内容键。 * * 只要这组参数完全一致,"背景 + 全部网格 + 时间轴"的渲染结果就逐像素相同, * 可以直接复用离屏位图,无需重算百万级采样的包络与绘制。 + * 这使鼠标悬停、拖拽选区等"只改一小块叠加层"的交互不再重绘波形: + * 大文件下整幅重绘约 80ms,命中缓存时只需 1~2ms 贴图。 */ struct SceneCacheKey { - int dragMode = -1; /**< 拖拽模式(拖拽中才允许复用)。 */ - double viewMin = 0.0; /**< 时间轴下界。 */ - double viewMax = 0.0; /**< 时间轴上界。 */ - float laneScroll = 0.0f; /**< 网格滚动偏移。 */ - int laneCount = 0; /**< 网格数量。 */ - float areaX = 0.0f; /**< 绘图区左上角。 */ + double viewMin = 0.0; /**< 时间轴下界。 */ + double viewMax = 0.0; /**< 时间轴上界。 */ + float laneScroll = 0.0f; /**< 网格滚动偏移。 */ + int laneCount = 0; /**< 网格数量。 */ + float areaX = 0.0f; /**< 绘图区左上角。 */ float areaY = 0.0f; - float areaW = 0.0f; /**< 绘图区尺寸。 */ + float areaW = 0.0f; /**< 绘图区尺寸。 */ float areaH = 0.0f; - float scale = 1.0f; /**< DPI 缩放。 */ + float scale = 1.0f; /**< DPI 缩放。 */ + int haloFile = -1; /**< 已绘入缓存的拖拽光晕信号(无则 -1)。 */ + int haloSignal = -1; + std::uint64_t contentHash = 0; /**< 信号集合/显示样式/网格幅值等内容指纹。 */ }; /** @@ -869,6 +874,49 @@ namespace jdi */ SceneCacheKey MakeSceneCacheKey() const; + /** + * @brief 判断当前缓存键是否与已捕获的场景一致。 + * @return 可直接贴图返回 true。 + */ + bool SceneCacheMatches() const; + + /** + * @brief 计算场景内容指纹(信号集合、显示样式、网格幅值、图例文案)。 + * + * 视图几何以外的任何内容变化(改颜色/线宽/透明度/样式、重命名、增删信号) + * 都会改变该指纹,从而让静态场景缓存自动失效,无需在各处手工调用失效接口。 + * + * @return 指纹值。 + */ + std::uint64_t SceneContentHash() const; + + /** + * @brief 当前应当绘入静态场景缓存的信号光晕(仅拖拽中的信号)。 + * + * 拖拽信号的光晕在整个拖拽过程中不变,可以进入缓存; + * 悬停光晕随鼠标移动,必须画在缓存之外。 + * + * @return 信号引用,无则返回无效引用。 + */ + SignalRef SceneHaloRef() const; + + /** + * @brief 绘制静态场景缓存之外的"悬停高亮"(悬停网格底色 + 悬停信号光晕)。 + * @param [IN,OUT] g 绘图上下文。 + */ + void PaintHoverHighlight(gd::Graphics& g); + + /** + * @brief 绘制某条信号的悬停/拖拽光晕。 + * @param [IN,OUT] g 绘图上下文。 + * @param [IN] ref 信号引用。 + * @param [IN] resolved 已解析的信号。 + * @param [IN] points 折线顶点。 + * @param [IN] breaks 每段折线的起始下标。 + */ + void PaintSignalHalo(gd::Graphics& g, const SignalRef& ref, const ResolvedSignal& resolved, + const std::vector& points, const std::vector& breaks); + /** * @brief 确保离屏缓存位图存在且尺寸匹配。 * @param [IN] width 位图宽度(像素)。 @@ -878,7 +926,11 @@ namespace jdi bool EnsureSceneCache(int width, int height); /** - * @brief 把当前画布内容(背景 + 界面外框 + 全部网格 + 时间轴)拷贝进离屏缓存。 + * @brief 把当前画布内容(绘图区底色 + 全部网格 + 时间轴)拷贝进离屏缓存。 + * + * 缓存区域严格限定为绘图区(m_area)。悬停与拖拽产生的内容一律不进入缓存, + * 因此在任何交互过程中都可以安全复用,只贴图而不再重绘波形。 + * * @param [IN,OUT] g 当前绘图上下文(必须未设置裁剪区)。 * @return 拷贝成功返回 true。 */ @@ -906,8 +958,8 @@ namespace jdi HGDIOBJ m_sceneOldBitmap = nullptr; /**< 离屏 DC 原选位图。 */ int m_sceneWidth = 0; /**< 离屏位图宽度。 */ int m_sceneHeight = 0; /**< 离屏位图高度。 */ - int m_sceneX = 0; /**< 离屏缓存对应的客户区横坐标。 */ - int m_sceneY = 0; /**< 离屏缓存对应的客户区纵坐标。 */ + int m_sceneX = 0; /**< 缓存对应的客户区横坐标(绘图区左边界)。 */ + int m_sceneY = 0; /**< 缓存对应的客户区纵坐标(绘图区上边界)。 */ bool m_sceneValid = false; /**< 缓存内容是否有效。 */ SceneCacheKey m_sceneKey; /**< 缓存对应的场景参数。 */ diff --git a/Components/PlotView/Src/PlotView.cpp b/Components/PlotView/Src/PlotView.cpp index f0c088a..243a730 100644 --- a/Components/PlotView/Src/PlotView.cpp +++ b/Components/PlotView/Src/PlotView.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include "Utf8Util.hpp" @@ -501,6 +502,7 @@ namespace jdi for (Lane& lane : m_tab->Lanes()) { lane.autoRange = true; + lane.viewAutoRange = false; } } BuildLaneVisuals(); @@ -820,6 +822,7 @@ namespace jdi Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; lane.autoRange = false; + lane.viewAutoRange = false; lane.yMin = std::min(valueTop, valueBottom); lane.yMax = std::max(valueTop, valueBottom); return true; @@ -843,6 +846,22 @@ namespace jdi return m_tab->Lanes()[static_cast(laneIndex)].autoRange; } + bool PlotView::LaneRangeIsAutoToView(int laneIndex) const + { + // “选区 Y 轴自适应”是带状态的操作:只有应用后未被任何手动改动、 + // 且时间选区没有变化时,当前范围才仍等于该操作的结果。 + if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) + { + return false; + } + const Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; + if (!lane.viewAutoRange || lane.autoRange) + { + return false; + } + return (lane.viewAutoMin == Tab().ViewMin()) && (lane.viewAutoMax == Tab().ViewMax()); + } + bool PlotView::ResetLaneRange(int laneIndex) { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) @@ -856,6 +875,7 @@ namespace jdi return false; } lane.autoRange = true; + lane.viewAutoRange = false; BuildLaneVisuals(); return true; } @@ -959,6 +979,10 @@ namespace jdi lane.autoRange = false; lane.yMin = low; lane.yMax = high; + // 记录应用时的视图时间区间:时间选区一变,本项就不再是“当前生效”状态。 + lane.viewAutoRange = true; + lane.viewAutoMin = viewMin; + lane.viewAutoMax = viewMax; BuildLaneVisuals(); return true; } @@ -988,6 +1012,7 @@ namespace jdi Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; lane.autoRange = false; + lane.viewAutoRange = false; lane.yMin = anchorValue - ratio * newSpan; lane.yMax = lane.yMin + newSpan; BuildLaneVisuals(); @@ -1004,6 +1029,7 @@ namespace jdi // 首次纵向拖动时先把当前自适应范围固化下来,再进行偏移。 const LaneVisual& visual = m_lanes[static_cast(laneIndex)]; Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; + lane.viewAutoRange = false; if (lane.autoRange) { lane.autoRange = false; @@ -1071,6 +1097,7 @@ namespace jdi // 手工输入即视为取消自动自适应,与拖拽框选缩放的语义一致。 // 必须先把当前(自适应计算出的)显示范围固化到 lane 里,再只改动被编辑的一端, // 否则另一端会回退到 Lane 的初始占位值,导致量程被莫名其妙地放大。 + lane.viewAutoRange = false; if (lane.autoRange) { lane.autoRange = false; @@ -1410,6 +1437,7 @@ namespace jdi } const LaneVisual& visual = m_lanes[static_cast(laneIndex)]; Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; + lane.viewAutoRange = false; if (lane.autoRange) { lane.autoRange = false; @@ -2359,40 +2387,29 @@ namespace jdi return; } - // 拖拽期间的静态场景复用:拖动信号曲线 / 时间光标时视图与数据都没有变化, - // 每帧重绘百万级采样的包络纯属浪费(实测 5 条密集信号约 100ms/帧)。 - // 这里命中缓存时只贴图 + 重绘随鼠标变化的部分,使拖拽帧率不随数据量下降。 - const bool sceneCacheable = - (m_dragMode == PlotDragMode::MOVE_SIGNAL || m_dragMode == PlotDragMode::CURSOR_MOVE); - - if (sceneCacheable && m_sceneValid) + // 静态场景复用(不再局限于拖拽):只要视图几何、信号集合与显示样式都没变, + // "绘图区底色 + 全部网格 + 时间轴"就逐像素相同,直接贴图即可。 + // 大文件下整幅重绘约 80ms,贴图约 1ms —— 鼠标悬停、拖拽选区这些 + // "只改一小块叠加层"的交互因此不再重绘波形。 + // 随鼠标变化的内容(悬停高亮、读数框、选区、光标、拖拽虚影、输入框)一律画在贴图之后。 + if (SceneCacheMatches() && BlitSceneCache(g)) { - const SceneCacheKey& cached = m_sceneKey; - const SceneCacheKey key = MakeSceneCacheKey(); - const bool same = (key.dragMode == cached.dragMode) && (key.viewMin == cached.viewMin) && - (key.viewMax == cached.viewMax) && (key.laneScroll == cached.laneScroll) && - (key.laneCount == cached.laneCount) && (key.areaX == cached.areaX) && - (key.areaY == cached.areaY) && (key.areaW == cached.areaW) && - (key.areaH == cached.areaH) && (key.scale == cached.scale); - if (same && BlitSceneCache(g)) + PaintHoverHighlight(g); + PaintLaneReadouts(g); + PaintRubberBand(g); + PaintCursor(g, m_lanes); + PaintSignalInteraction(g); + if (m_editTarget == PlotEditTarget::LANE_Y_MAX || + m_editTarget == PlotEditTarget::LANE_Y_MIN) { - PaintLaneReadouts(g); - PaintRubberBand(g); - PaintCursor(g, m_lanes); - PaintSignalInteraction(g); - if (m_editTarget == PlotEditTarget::LANE_Y_MAX || - m_editTarget == PlotEditTarget::LANE_Y_MIN) - { - PaintEditField(g); - } - PaintScrollBar(g); - - // 绘图区外框(比网格内部分界线更重,形成明确整体边界)。 - gd::Pen borderPen(ToGdiColor(m_theme->axisLine), 1.6f); - g.DrawRectangle(&borderPen, m_plotRect.X, m_plotRect.Y, m_plotRect.Width, - m_plotRect.Height); - return; + PaintEditField(g); } + PaintScrollBar(g); + + // 绘图区外框(比网格内部分界线更重,形成明确整体边界)。 + gd::Pen borderPen(ToGdiColor(m_theme->axisLine), 1.6f); + g.DrawRectangle(&borderPen, m_plotRect.X, m_plotRect.Y, m_plotRect.Width, m_plotRect.Height); + return; } const double viewMin = Tab().ViewMin(); @@ -2402,7 +2419,7 @@ namespace jdi std::vector timeTicks; BuildTicks(viewMin, viewMax, timeStep, timeTicks); - // 绘图区整体底色(缓存路径下该内容已在离屏位图中)。 + // 绘图区整体底色。 UiFillRect(g, m_area, m_theme->plotBg); // 波形区域底色。 @@ -2420,15 +2437,9 @@ namespace jdi // 此刻画布上正好是"绘图区底色 + 全部网格 + 时间轴",尚未绘制任何随鼠标变化的内容, // 因此是捕获静态场景缓存的最佳时点(必须在裁剪区已全部恢复之后执行)。 - if (sceneCacheable) - { - CaptureSceneCache(g); - } - else - { - InvalidateSceneCache(); - } + CaptureSceneCache(g); + PaintHoverHighlight(g); PaintLaneReadouts(g); PaintRubberBand(g); PaintCursor(g, m_lanes); @@ -2447,10 +2458,87 @@ namespace jdi g.DrawRectangle(&borderPen, m_plotRect.X, m_plotRect.Y, m_plotRect.Width, m_plotRect.Height); } + void PlotView::PaintSignalHalo(gd::Graphics& g, const SignalRef& ref, const ResolvedSignal& resolved, + const std::vector& points, + const std::vector& breaks) + { + const bool drawLine = (resolved.style->mode != DrawMode::SCATTER); + if (!drawLine || breaks.empty()) + { + // 散点模式没有光晕(原实现同样只对折线绘制光晕)。 + (void)g; + (void)ref; + return; + } + + gd::Pen halo(ToGdiColor(WithAlpha(m_theme->accent, 0x66)), + (resolved.style->lineWidth + 4.0f) * m_scale); + halo.SetLineJoin(gd::LineJoinRound); + halo.SetStartCap(gd::LineCapRound); + halo.SetEndCap(gd::LineCapRound); + for (std::size_t k = 0; k < breaks.size(); ++k) + { + const std::size_t start = breaks[k]; + const std::size_t stop = (k + 1 < breaks.size()) ? breaks[k + 1] : points.size(); + if (stop <= start + 1) + { + continue; + } + g.DrawLines(&halo, points.data() + start, static_cast(stop - start)); + } + } + + void PlotView::PaintHoverHighlight(gd::Graphics& g) + { + if (m_tab == nullptr || m_dragMode != PlotDragMode::NONE) + { + // 拖拽过程中不显示悬停提示(拖拽中的信号光晕已绘入静态场景缓存)。 + return; + } + + const int saveState = g.Save(); + g.SetClip(m_area, gd::CombineModeIntersect); + + // 悬停网格:一层很淡的强调色叠加。为了能画在静态场景之上, + // 这里用半透明而非不透明混色,观感与原实现基本一致。 + if (m_hoverLane >= 0 && m_hoverLane < static_cast(m_lanes.size())) + { + const gd::RectF& laneRect = m_lanes[static_cast(m_hoverLane)].rect; + const float clipTop = std::max(laneRect.Y, m_plotRect.Y); + const float clipBottom = std::min(laneRect.Y + laneRect.Height, m_plotRect.Y + m_plotRect.Height); + if (clipBottom > clipTop) + { + // alpha 0x0A ≈ 4%,与原「不透明混色 3.5%」的观感基本一致。 + UiFillRect(g, gd::RectF(laneRect.X, clipTop, laneRect.Width, clipBottom - clipTop), + WithAlpha(m_theme->accent, 0x0A)); + } + } + + // 悬停信号:外扩一圈半透明强调色作为"可拖拽"提示。 + if (m_hoverSignal.IsValid()) + { + const ResolvedSignal resolved = ResolveSignal(m_hoverSignal); + if (resolved.data != nullptr && resolved.style != nullptr && resolved.visible) + { + const int laneIndex = m_tab->LaneOfSignal(m_hoverSignal); + if (laneIndex >= 0 && laneIndex < static_cast(m_lanes.size())) + { + const LaneVisual& lane = m_lanes[static_cast(laneIndex)]; + const PolylineCacheEntry* cached = AcquirePolyline(m_hoverSignal, resolved, lane); + if (cached != nullptr) + { + PaintSignalHalo(g, m_hoverSignal, resolved, cached->points, cached->breaks); + } + } + } + } + + g.Restore(saveState); + } + PlotView::SceneCacheKey PlotView::MakeSceneCacheKey() const { SceneCacheKey key; - key.dragMode = static_cast(m_dragMode); key.viewMin = Tab().ViewMin(); key.viewMax = Tab().ViewMax(); key.laneScroll = Tab().LaneScroll(); @@ -2460,16 +2548,118 @@ namespace jdi key.areaW = m_area.Width; key.areaH = m_area.Height; key.scale = m_scale; + + const SignalRef halo = SceneHaloRef(); + key.haloFile = halo.fileIndex; + key.haloSignal = halo.signalIndex; + key.contentHash = SceneContentHash(); return key; } + bool PlotView::SceneCacheMatches() const + { + if (!m_sceneValid) + { + return false; + } + const SceneCacheKey& cached = m_sceneKey; + const SceneCacheKey key = MakeSceneCacheKey(); + return (key.viewMin == cached.viewMin) && (key.viewMax == cached.viewMax) && + (key.laneScroll == cached.laneScroll) && (key.laneCount == cached.laneCount) && + (key.areaX == cached.areaX) && (key.areaY == cached.areaY) && + (key.areaW == cached.areaW) && (key.areaH == cached.areaH) && + (key.scale == cached.scale) && (key.haloFile == cached.haloFile) && + (key.haloSignal == cached.haloSignal) && (key.contentHash == cached.contentHash); + } + + SignalRef PlotView::SceneHaloRef() const + { + // 只有拖拽中的信号光晕是“整个拖拽过程都不变”的,可以安全地绘入静态场景缓存; + // 悬停光晕随鼠标移动,必须画在缓存之外(见 PaintHoverHighlight)。 + if (m_dragMode == PlotDragMode::MOVE_SIGNAL && m_dragSignal.IsValid()) + { + return m_dragSignal; + } + return SignalRef(); + } + + std::uint64_t PlotView::SceneContentHash() const + { + // FNV-1a:把“视图几何之外的一切影响绘图区像素的内容”折叠成一个指纹。 + // 这样颜色/线宽/透明度/样式/重命名/信号增删等变化都会自动让缓存失效, + // 不需要在各处手工调用 InvalidateSceneCache(容易漏)。 + std::uint64_t hash = 1469598103934665603ull; + const auto mix = [&hash](std::uint64_t value) + { + hash ^= value; + hash *= 1099511628211ull; + }; + const auto mixFloat = [&mix](float value) + { + std::uint32_t bits = 0; + std::memcpy(&bits, &value, sizeof(bits)); + mix(bits); + }; + const auto mixText = [&mix](const std::wstring& text) + { + mix(text.size()); + for (const wchar_t character : text) + { + mix(static_cast(character)); + } + }; + + mixFloat(m_plotRect.X); + mixFloat(m_plotRect.Y); + mixFloat(m_plotRect.Width); + mixFloat(m_plotRect.Height); + mixFloat(m_laneContentHeight); + mix(m_scrollBarVisible ? 1ull : 0ull); + + for (std::size_t laneIndex = 0; laneIndex < m_lanes.size(); ++laneIndex) + { + const LaneVisual& visual = m_lanes[laneIndex]; + mixFloat(visual.rect.Y); + mixFloat(visual.rect.Height); + mixFloat(static_cast(visual.yMin)); + mixFloat(static_cast(visual.yMax)); + + const std::vector& signals = Tab().Lanes()[laneIndex].signals; + mix(signals.size()); + for (const SignalRef& ref : signals) + { + const ResolvedSignal resolved = ResolveSignal(ref); + mix(static_cast(ref.fileIndex)); + mix(static_cast(ref.signalIndex)); + mix(resolved.visible ? 1ull : 0ull); + if (resolved.data == nullptr || resolved.style == nullptr) + { + continue; + } + mix(static_cast(resolved.style->color)); + mixFloat(resolved.style->opacity); + mixFloat(resolved.style->lineWidth); + mix(static_cast(resolved.style->mode)); + // 图例文案参与绘制,重命名必须让缓存失效。 + mixText(resolved.data->Title()); + if (resolved.file != nullptr) + { + mixText(resolved.file->Title()); + } + } + } + return hash; + } + bool PlotView::EnsureSceneCache(int width, int height) { if (width <= 0 || height <= 0) { return false; } - if (m_sceneDc != nullptr && m_sceneWidth >= width && m_sceneHeight >= height) + // 位图尺寸必须与当前缓存区域**严格一致**:贴图时按位图尺寸整块拷贝, + // 若沿用更大的旧位图,缩小窗口后会把旧像素画到绘图区之外。 + if (m_sceneDc != nullptr && m_sceneWidth == width && m_sceneHeight == height) { return true; } @@ -2498,36 +2688,24 @@ namespace jdi bool PlotView::CaptureSceneCache(gd::Graphics& g) { - // 缓存覆盖**整个客户区**:拖拽期间工具栏/标签栏/面板也同样不变, - // 宿主(Application)据此跳过这些区域的重复绘制(实测面板绘制就占 8ms/帧)。 - int clientWidth = 0; - int clientHeight = 0; - if (m_host != nullptr) + // 缓存区域严格限定为绘图区本身(m_area):工具栏/标签栏/面板由宿主每帧绘制, + // 因为它们各自带有悬停态;绘图区的波形才是真正昂贵的部分(大文件约 80ms/帧)。 + const int x = static_cast(std::floor(m_area.X)); + const int y = static_cast(std::floor(m_area.Y)); + const int right = static_cast(std::ceil(m_area.X + m_area.Width)); + const int bottom = static_cast(std::ceil(m_area.Y + m_area.Height)); + const int width = right - x; + const int height = bottom - y; + if (width <= 0 || height <= 0) { - RECT client = {}; - GetClientRect(m_host, &client); - clientWidth = client.right - client.left; - clientHeight = client.bottom - client.top; - } - if (clientWidth <= 0 || clientHeight <= 0) - { - // 无宿主句柄时退化为只缓存绘图区。 - const int x = static_cast(std::floor(m_area.X)) - 2; - const int y = static_cast(std::floor(m_area.Y)) - 2; - const int right = static_cast(std::ceil(m_area.X + m_area.Width)) + 2; - const int bottom = static_cast(std::ceil(m_area.Y + m_area.Height)) + 2; - m_sceneX = x; - m_sceneY = y; - clientWidth = right - x; - clientHeight = bottom - y; - } - else - { - m_sceneX = 0; - m_sceneY = 0; + m_sceneValid = false; + return false; } - if (!EnsureSceneCache(clientWidth, clientHeight)) + m_sceneX = x; + m_sceneY = y; + + if (!EnsureSceneCache(width, height)) { m_sceneValid = false; return false; @@ -2539,8 +2717,7 @@ namespace jdi m_sceneValid = false; return false; } - const BOOL ok = BitBlt(m_sceneDc, 0, 0, clientWidth, clientHeight, source, m_sceneX, m_sceneY, - SRCCOPY); + const BOOL ok = BitBlt(m_sceneDc, 0, 0, width, height, source, m_sceneX, m_sceneY, SRCCOPY); g.ReleaseHDC(source); if (ok == FALSE) @@ -2554,26 +2731,6 @@ namespace jdi return true; } - bool PlotView::CanReuseSceneCache() const - { - if (!m_sceneValid) - { - return false; - } - if (m_dragMode != PlotDragMode::MOVE_SIGNAL && m_dragMode != PlotDragMode::CURSOR_MOVE) - { - return false; - } - - const SceneCacheKey& cached = m_sceneKey; - const SceneCacheKey key = MakeSceneCacheKey(); - return (key.dragMode == cached.dragMode) && (key.viewMin == cached.viewMin) && - (key.viewMax == cached.viewMax) && (key.laneScroll == cached.laneScroll) && - (key.laneCount == cached.laneCount) && (key.areaX == cached.areaX) && - (key.areaY == cached.areaY) && (key.areaW == cached.areaW) && - (key.areaH == cached.areaH) && (key.scale == cached.scale); - } - bool PlotView::BlitSceneCache(gd::Graphics& g) { if (!m_sceneValid || m_sceneDc == nullptr) @@ -2682,12 +2839,6 @@ namespace jdi const int saveState = g.Save(); g.SetClip(gd::RectF(m_area.X, clipTop, m_area.Width, clipBottom - clipTop)); - // 悬停高亮。 - if (laneIndex == m_hoverLane && m_dragMode == PlotDragMode::NONE) - { - UiFillRect(g, lane.rect, MixColor(m_theme->cardBg, m_theme->accent, 0.035)); - } - // 幅值网格线(先画线,标尺稍后作为覆盖层绘制,避免被波形遮挡)。 // 刻度密度随网格高度自适应:所有网格高度相同 → 各网格的刻度数量一致, // 不会出现“有的网格只有 1 条标签、有的有 3 条”的不一致观感。 @@ -2777,6 +2928,9 @@ namespace jdi const int waveSave = g.Save(); g.SetClip(lane.rect, gd::CombineModeIntersect); + // 只有拖拽中的信号光晕可以进入静态场景缓存(悬停光晕见 PaintHoverHighlight)。 + const SignalRef haloRef = SceneHaloRef(); + for (const SignalRef& ref : laneSignals) { const ResolvedSignal resolved = ResolveSignal(ref); @@ -2799,24 +2953,12 @@ namespace jdi const bool drawScatter = (mode == DrawMode::SCATTER || mode == DrawMode::LINE_SCATTER); // 悬停命中的信号先画一层浅色光晕,作为“可拖拽”的视觉提示。 - const bool highlighted = (ref == m_hoverSignal) || (ref == m_dragSignal); - if (highlighted && drawLine && !breaks.empty()) + // 这里只处理拖拽中的信号(其光晕在拖拽期间不变,可以进入静态场景缓存); + // 悬停光晕随鼠标变化,改由 PaintHoverHighlight 在缓存贴图之后绘制。 + const bool highlighted = (ref == haloRef); + if (highlighted) { - gd::Pen halo(ToGdiColor(WithAlpha(m_theme->accent, 0x66)), - (resolved.style->lineWidth + 4.0f) * m_scale); - halo.SetLineJoin(gd::LineJoinRound); - halo.SetStartCap(gd::LineCapRound); - halo.SetEndCap(gd::LineCapRound); - for (std::size_t k = 0; k < breaks.size(); ++k) - { - const std::size_t start = breaks[k]; - const std::size_t stop = (k + 1 < breaks.size()) ? breaks[k + 1] : points.size(); - if (stop <= start + 1) - { - continue; - } - g.DrawLines(&halo, points.data() + start, static_cast(stop - start)); - } + PaintSignalHalo(g, ref, resolved, points, breaks); } if (drawLine && !breaks.empty()) diff --git a/Components/Toolbar/Inc/Toolbar.hpp b/Components/Toolbar/Inc/Toolbar.hpp index 7103bd3..7a4e2b9 100644 --- a/Components/Toolbar/Inc/Toolbar.hpp +++ b/Components/Toolbar/Inc/Toolbar.hpp @@ -22,8 +22,8 @@ namespace jdi NONE = 0, /**< 未触发任何动作。 */ OPEN_FILE, /**< 打开 CSV 文件。 */ FIT_ALL, /**< 视图自适应到可见信号范围(同时退出缩放模式)。 */ - ZOOM_TIME, /**< 切换「在时间轴缩放」模式。 */ - ZOOM_VALUE, /**< 切换「在 Y 轴缩放」模式。 */ + ZOOM_TIME, /**< 切换「时间轴选区」模式(框选时间区间后放大)。 */ + ZOOM_VALUE, /**< 切换「Y 轴选区」模式(框选幅值区间后放大)。 */ CURSOR, /**< 切换时间光标。 */ MERGE_LANES, /**< 合并为单一网格。 */ SPLIT_LANES, /**< 按信号拆分为多网格。 */ diff --git a/Components/Toolbar/Src/Toolbar.cpp b/Components/Toolbar/Src/Toolbar.cpp index 1cf0189..cd6bfa6 100644 --- a/Components/Toolbar/Src/Toolbar.cpp +++ b/Components/Toolbar/Src/Toolbar.cpp @@ -68,8 +68,8 @@ namespace jdi const Spec specs[] = { {ToolbarAction::OPEN_FILE, L"导入文件", true, false, false}, {ToolbarAction::FIT_ALL, L"适应视图", false, true, false}, - {ToolbarAction::ZOOM_TIME, L"时间轴缩放", false, true, true}, - {ToolbarAction::ZOOM_VALUE, L"Y 轴缩放", false, true, true}, + {ToolbarAction::ZOOM_TIME, L"时间轴选区", false, true, true}, + {ToolbarAction::ZOOM_VALUE, L"Y 轴选区", false, true, true}, {ToolbarAction::CURSOR, L"光标", false, true, true}, {ToolbarAction::MERGE_LANES, L"合并网格", false, true, false}, {ToolbarAction::SPLIT_LANES, L"拆分网格", false, true, false}, diff --git a/Utilities/Inc/DataModel.hpp b/Utilities/Inc/DataModel.hpp index 722970a..23541de 100644 --- a/Utilities/Inc/DataModel.hpp +++ b/Utilities/Inc/DataModel.hpp @@ -330,6 +330,9 @@ namespace jdi bool autoRange = true; /**< 是否依据信号取值范围自动计算 Y 轴。 */ double yMin = -1.0; /**< 手动 Y 轴下限。 */ double yMax = 1.0; /**< 手动 Y 轴上限。 */ + bool viewAutoRange = false; /**< 是否由「选区 Y 轴自适应」产生且时间选区未再变化。 */ + double viewAutoMin = 0.0; /**< 应用「选区 Y 轴自适应」时的视图时间下限。 */ + double viewAutoMax = 0.0; /**< 应用「选区 Y 轴自适应」时的视图时间上限。 */ }; /** diff --git a/Utilities/Inc/PopupMenu.hpp b/Utilities/Inc/PopupMenu.hpp index df34a65..c192a9b 100644 --- a/Utilities/Inc/PopupMenu.hpp +++ b/Utilities/Inc/PopupMenu.hpp @@ -287,6 +287,13 @@ namespace jdi */ gd::RectF EditFieldRect(int index) const; + /** + * @brief 计算 EDIT 项输入框的“自然宽度”(排版与绘制共用同一公式)。 + * @param [IN] item 菜单项。 + * @return 宽度(像素)。 + */ + float NaturalEditFieldWidth(const MenuItem& item) const; + /** * @brief 进入指定项的内联编辑状态。 * @param [IN] index 项目下标。 diff --git a/Utilities/Src/DataModel.cpp b/Utilities/Src/DataModel.cpp index 9427906..da06d16 100644 --- a/Utilities/Src/DataModel.cpp +++ b/Utilities/Src/DataModel.cpp @@ -1048,6 +1048,8 @@ namespace jdi for (Lane& lane : m_lanes) { + // 网格的信号组成发生变化,“选区 Y 轴自适应”的结果不再对应当前视野。 + lane.viewAutoRange = false; std::vector& refs = lane.signals; refs.erase(std::remove(refs.begin(), refs.end(), ref), refs.end()); } diff --git a/Utilities/Src/PopupMenu.cpp b/Utilities/Src/PopupMenu.cpp index bdd0cd1..68c2da6 100644 --- a/Utilities/Src/PopupMenu.cpp +++ b/Utilities/Src/PopupMenu.cpp @@ -13,9 +13,10 @@ namespace jdi const float kSeparatorHeight = 9.0f; /**< 分隔线高度。 */ const float kSwatchRowHeight = 32.0f; /**< 色块行高度。 */ const float kPadV = 5.0f; /**< 菜单上下内边距。 */ - const float kPadH = 6.0f; /**< 菜单左右内边距。 */ - const float kMarkerColumn = 22.0f; /**< 选中标记列宽度。 */ - const float kRightPad = 14.0f; /**< 文本右侧留白。 */ + const float kPadH = 5.0f; /**< 菜单左右内边距。 */ + const float kItemPad = 8.0f; /**< 条目内文本的左右内缩(所有条目统一)。 */ + const float kMarkerColumn = 18.0f; /**< 条目**右侧**的选中标记列宽度(对勾画在这里)。 */ + const float kMarkerGap = 4.0f; /**< 文本与右侧标记列之间的间隔。 */ const float kMinWidth = 148.0f; /**< 菜单最小宽度。 */ const float kMaxWidth = 340.0f; /**< 菜单最大宽度。 */ const float kCorner = 6.0f; /**< 菜单圆角半径。 */ @@ -80,31 +81,32 @@ namespace jdi if (item.kind == MenuItemKind::SWATCHES) { const int count = std::max(0, std::min(item.swatchCount, 16)); - const float width = kPadH * 2.0f * m_scale + static_cast(count) * kSwatchSize * m_scale + + const float width = (kPadH + kItemPad) * 2.0f * m_scale + + static_cast(count) * kSwatchSize * m_scale + static_cast(std::max(0, count - 1)) * kSwatchGap * m_scale; contentWidth = std::max(contentWidth, width); } else if (item.kind == MenuItemKind::HEADER) { - const float width = (kPadH + kMarkerColumn) * m_scale + - UiMeasureTextWidth(m_fonts->uiSmallBold, item.label) + kRightPad * m_scale; + // 标题与普通项共用同一文字列与右侧标记列,保证整个菜单的左右边距一致。 + const float width = (kPadH + kItemPad) * 2.0f * m_scale + kMarkerColumn * m_scale + + kMarkerGap * m_scale + + UiMeasureTextWidth(m_fonts->uiSmallBold, item.label); contentWidth = std::max(contentWidth, width); } else if (item.kind == MenuItemKind::NORMAL) { - const float width = (kPadH + kMarkerColumn) * m_scale + - UiMeasureTextWidth(m_fonts->uiSmall, item.label) + kRightPad * m_scale; + const float width = (kPadH + kItemPad) * 2.0f * m_scale + kMarkerColumn * m_scale + + kMarkerGap * m_scale + UiMeasureTextWidth(m_fonts->uiSmall, item.label); contentWidth = std::max(contentWidth, width); } else if (item.kind == MenuItemKind::EDIT) { - // 说明文字 + 输入框 + 右侧留白;输入框宽度按数值文本自适应(取一个上限)。 - const float fieldWidth = - std::max(kEditFieldWidth * m_scale, - UiMeasureTextWidth(m_fonts->uiSmall, item.value) + 20.0f * m_scale); - const float width = (kPadH + kMarkerColumn) * m_scale + + // 说明文字 + 输入框;输入框宽度按数值文本自适应(与 EditFieldRect 保持一致)。 + const float fieldWidth = NaturalEditFieldWidth(item); + const float width = (kPadH + kItemPad) * 2.0f * m_scale + UiMeasureTextWidth(m_fonts->uiSmall, item.label) + kEditFieldGap * m_scale + - fieldWidth + kRightPad * m_scale; + fieldWidth; contentWidth = std::max(contentWidth, width); } } @@ -144,7 +146,7 @@ namespace jdi std::vector& rects = m_swatchRects[index]; rects.reserve(static_cast(count)); const float swatchY = cursorY + (itemHeight - kSwatchSize * m_scale) * 0.5f; - const float startX = left + (kPadH + 4.0f) * m_scale; + const float startX = left + (kPadH + kItemPad) * m_scale; for (int swatch = 0; swatch < count; ++swatch) { const float swatchX = @@ -157,27 +159,35 @@ namespace jdi } } + float PopupMenu::NaturalEditFieldWidth(const MenuItem& item) const + { + return std::max(kEditFieldWidth * m_scale, + UiMeasureTextWidth(m_fonts->uiSmall, item.value) + 20.0f * m_scale); + } + gd::RectF PopupMenu::EditFieldRect(int index) const { if (index < 0 || index >= static_cast(m_items.size())) { return gd::RectF(); } - if (m_items[static_cast(index)].kind != MenuItemKind::EDIT) + const MenuItem& item = m_items[static_cast(index)]; + if (item.kind != MenuItemKind::EDIT) { return gd::RectF(); } - // 输入框靠右对齐:宽度取固定下限与当前文本宽度中的较大者,但不得超过菜单可用宽度的一半。 + // 输入框靠右对齐到条目内容区右边界;宽度取自然宽度,但必须给说明文字留出自然宽度, + // 避免菜单被最大宽度夹取后输入框把左侧标签挤成省略号。 const gd::RectF& rect = m_itemRects[static_cast(index)]; - const float maxWidth = std::max(40.0f * m_scale, rect.Width * 0.5f); - float width = std::max(kEditFieldWidth * m_scale, - UiMeasureTextWidth(m_fonts->uiSmall, m_items[static_cast(index)].value) + - 20.0f * m_scale); - width = std::min(width, maxWidth); + const float contentWidth = std::max(40.0f * m_scale, rect.Width - 2.0f * kItemPad * m_scale); + const float labelWidth = UiMeasureTextWidth(m_fonts->uiSmall, item.label) + kEditFieldGap * m_scale; + float width = NaturalEditFieldWidth(item); + width = std::min(width, std::max(40.0f * m_scale, contentWidth - labelWidth)); + width = std::min(width, contentWidth * 0.75f); const float height = rect.Height - kEditFieldPadV * 2.0f * m_scale; - return gd::RectF(rect.X + rect.Width - width - 4.0f * m_scale, + return gd::RectF(rect.X + rect.Width - kItemPad * m_scale - width, rect.Y + (rect.Height - height) * 0.5f, width, height); } @@ -745,15 +755,16 @@ namespace jdi if (item.kind == MenuItemKind::SEPARATOR) { const float lineY = rect.Y + rect.Height * 0.5f; - UiDrawLine(g, rect.X + 4.0f * m_scale, lineY, rect.X + rect.Width - 4.0f * m_scale, lineY, - m_theme->border, 1.0f); + UiDrawLine(g, rect.X + kItemPad * m_scale, lineY, + rect.X + rect.Width - kItemPad * m_scale, lineY, m_theme->border, 1.0f); continue; } if (item.kind == MenuItemKind::HEADER) { + // 标题与普通项共用同一文字列,保证菜单内所有文本严格左对齐。 UiText(g, item.label, m_fonts->uiSmallBold, - gd::RectF(rect.X + kMarkerColumn * m_scale, rect.Y, rect.Width, rect.Height), + gd::RectF(rect.X + kItemPad * m_scale, rect.Y, rect.Width, rect.Height), m_theme->textMuted, TextAlign::LEFT); continue; } @@ -771,8 +782,8 @@ namespace jdi } const std::uint32_t labelColor = item.enabled ? m_theme->text : m_theme->textMuted; - // 说明文字与普通项使用同一文字列(kMarkerColumn),保证菜单内所有文本左对齐。 - const float labelLeft = rect.X + kMarkerColumn * m_scale; + // 说明文字与普通项使用同一文字列(kItemPad),保证菜单内所有文本左对齐。 + const float labelLeft = rect.X + kItemPad * m_scale; const gd::RectF fieldRect = EditFieldRect(static_cast(index)); const float labelWidth = std::max(8.0f, fieldRect.X - labelLeft - kEditFieldGap * m_scale); UiText(g, UiEllipsize(g, item.label, m_fonts->uiSmall, labelWidth), m_fonts->uiSmall, @@ -841,18 +852,21 @@ namespace jdi if (item.checked) { - // 单选标记:对勾。 - const float cx = rect.X + kMarkerColumn * m_scale * 0.5f + 2.0f * m_scale; + // 单选标记:对勾固定画在条目**右侧**的标记列内,左侧只留统一的小边距, + // 这样菜单文本左边距不会因为“可能存在的对勾”而被整体推右。 + const float markerRight = rect.X + rect.Width - kItemPad * m_scale; + const float cx = markerRight - kMarkerColumn * m_scale * 0.5f; const float cy = rect.Y + rect.Height * 0.5f; + const std::uint32_t markerColor = hovered ? m_theme->textOnAccent : m_theme->accent; UiDrawLine(g, cx - 4.0f * m_scale, cy, cx - 1.0f * m_scale, cy + 3.0f * m_scale, - hovered ? m_theme->textOnAccent : m_theme->accent, 1.8f * m_scale); + markerColor, 1.8f * m_scale); UiDrawLine(g, cx - 1.0f * m_scale, cy + 3.0f * m_scale, cx + 4.5f * m_scale, - cy - 4.0f * m_scale, hovered ? m_theme->textOnAccent : m_theme->accent, - 1.8f * m_scale); + cy - 4.0f * m_scale, markerColor, 1.8f * m_scale); } - const float textLeft = rect.X + kMarkerColumn * m_scale; - const float textWidth = rect.Width - kMarkerColumn * m_scale - 4.0f * m_scale; + const float textLeft = rect.X + kItemPad * m_scale; + const float textWidth = + rect.Width - 2.0f * kItemPad * m_scale - kMarkerColumn * m_scale - kMarkerGap * m_scale; UiText(g, UiEllipsize(g, item.label, m_fonts->uiSmall, textWidth), m_fonts->uiSmall, gd::RectF(textLeft, rect.Y, std::max(8.0f, textWidth), rect.Height), textColor, TextAlign::LEFT);