diff --git a/Components/Application/Src/Application.cpp b/Components/Application/Src/Application.cpp index bba66bb..7d5616c 100644 --- a/Components/Application/Src/Application.cpp +++ b/Components/Application/Src/Application.cpp @@ -424,6 +424,9 @@ namespace jdi m_dataSet.ValidateSelection(); m_panel.InvalidateRows(); + // 文件/信号结构变化会让信号引用与文件地址改变,折线顶点缓存必须作废。 + m_plot.InvalidatePolylineCache(); + // 强制下一次同步重新计算视图状态。 m_cachedVisibleCount = -1; m_cachedAnyVisible = false; @@ -867,8 +870,11 @@ namespace jdi switch (key) { case VK_ESCAPE: - m_plot.DisableCursor(); - return true; + // Esc 只用于放弃当前正在进行的拖拽(典型场景:缩放的框选选区还没松开左键)。 + // 取消后不应用选区、视图范围保持不变;松开左键也不会再生效(拖拽模式已复位)。 + // Esc 不再与时间光标关联:关闭光标请用工具栏「光标」按钮或再次点击该按钮, + // 避免用户按 Esc 时意外把指针关掉。 + return m_plot.CancelDrag(); case VK_ADD: case VK_OEM_PLUS: m_plot.ZoomTimeStep(1.4); diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index ceb4868..b6a64a4 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -5,6 +5,7 @@ #include #include +#include #include #include @@ -229,6 +230,18 @@ namespace jdi void DisableCursor(); /** + * @brief 放弃当前正在进行的拖拽(缩放选区/平移/信号拖动),不改变视图范围。 + * @return 确实取消了某个拖拽返回 true。 + */ + bool CancelDrag(); + + /** + * @brief 清空折线顶点缓存。 + * + * 数据层结构变化(加载/移除文件、信号增删)后信号引用与文件地址都可能改变, + * 必须清空缓存,避免旧几何键与新的信号意外重合而取到过期顶点。 + */ + void InvalidatePolylineCache(); /** * @brief 切换时间光标:启用时置于当前视图中心,再调用则关闭。 * @return 状态发生变化返回 true。 */ @@ -742,6 +755,43 @@ namespace jdi std::vector m_legendHits; /**< 最近一次绘制时记录的图例条目矩形。 */ + /** + * @brief 折线顶点缓存条目。 + * + * 折线抽稀是 O(采样点数) 的(百万级采样约 2~3ms/条), + * 而绘制、悬停命中检测每帧都要用到同一批顶点。 + * 用“构建折线时读取的全部输入”作为键缓存顶点, + * 视图/网格几何未变化时可直接复用,避免重复扫描全量采样。 + */ + struct PolylineCacheEntry + { + SignalRef ref; /**< 信号引用。 */ + const DataFile* file = nullptr; /**< 所属文件(内容不可变)。 */ + std::size_t sampleCount = 0; /**< 采样点数(数据变化时改变)。 */ + double viewMin = 0.0; /**< 时间轴显示下界。 */ + double viewMax = 0.0; /**< 时间轴显示上界。 */ + float plotX = 0.0f; /**< 绘图区左边界。 */ + float plotWidth = 0.0f; /**< 绘图区宽度。 */ + float laneY = 0.0f; /**< 网格上边界。 */ + float laneHeight = 0.0f; /**< 网格高度。 */ + double laneYMin = 0.0; /**< 网格幅值下界。 */ + double laneYMax = 0.0; /**< 网格幅值上界。 */ + std::vector points; /**< 折线顶点。 */ + std::vector breaks; /**< 每段折线在 points 中的起始下标。 */ + }; + + /** + * @brief 取得(必要时重建)指定信号在当前几何下的折线顶点。 + * @param [IN] ref 信号引用(缓存键的一部分)。 + * @param [IN] resolved 已解析的信号。 + * @param [IN] lane 网格绘制信息。 + * @return 缓存条目指针,无法构建时返回 nullptr。 + */ + const PolylineCacheEntry* AcquirePolyline(const SignalRef& ref, const ResolvedSignal& resolved, + const LaneVisual& lane) const; + + mutable std::deque m_polylineCache; /**< 折线顶点缓存(按几何键复用)。 */ + /** * @brief 查询坐标命中的图例条目。 * @param [IN] x 客户区横坐标。 diff --git a/Components/PlotView/Src/PlotView.cpp b/Components/PlotView/Src/PlotView.cpp index de2e584..b8e954a 100644 --- a/Components/PlotView/Src/PlotView.cpp +++ b/Components/PlotView/Src/PlotView.cpp @@ -21,8 +21,12 @@ namespace jdi const unsigned int kMinorLineAlpha = 0x9Au; /**< 辅助等分线的透明度(越高越清晰)。 */ const float kScrollBarThickness = 9.0f; /**< 滚动条宽度(逻辑像素)。 */ const float kLanePadding = 8.0f; /**< 网格内边距。 */ - const size_t kMaxPolylinePoints = 20000; /**< 单条折线最大顶点数。 */ + const size_t kMaxPolylinePoints = 20000; /**< 单条折线最大顶点数(上限保护)。 */ + const size_t kPolylinePointsPerPixel = 2; /**< 每个像素列允许的折线顶点数(上/下包络两顶点)。 */ + const size_t kMinPolylinePoints = 512; /**< 折线最少顶点数(窗口很窄时仍保留细节)。 */ + const float kEnvelopeMinPixelSpan = 0.5f; /**< 包络纵向跨度小于该像素值时合并为单个顶点。 */ const size_t kMaxScatterPoints = 2500; /**< 单条散点最大绘制点数。 */ + const size_t kPolylineCacheLimit = 64; /**< 折线顶点缓存条目上限。 */ const float kMinScatterRadius = 1.6f; /**< 散点最小半径(逻辑像素)。 */ const float kMaxScatterRadius = 12.0f; /**< 散点最大半径(逻辑像素)。 */ const float kScatterRadiusScale = 1.5f; /**< 散点半径相对线宽的增长系数。 */ @@ -103,6 +107,8 @@ namespace jdi m_tab = tab; m_hoverLane = -1; m_dragMode = PlotDragMode::NONE; + // 重新绑定数据源后旧的几何键不再有意义,必须清空折线缓存。 + InvalidatePolylineCache(); RefreshLanes(); } @@ -503,6 +509,30 @@ namespace jdi } } + bool PlotView::CancelDrag() + { + if (m_dragMode == PlotDragMode::NONE) + { + return false; + } + + // 放弃当前拖拽:清空拖拽状态与尚未生效的缩放选区,不改动任何视图范围。 + // 调用方仍持有鼠标捕获,松开左键时会走 OnLButtonUp, + // 此时拖拽模式已是 NONE,选区不会被应用到时间轴/幅值轴上。 + m_dragMode = PlotDragMode::NONE; + m_dragLane = -1; + m_dragMoved = false; + m_rubberRect = gd::RectF(); + m_dragSignal = SignalRef(); + m_dragTargetLane = -1; + return true; + } + + void PlotView::InvalidatePolylineCache() + { + m_polylineCache.clear(); + } + bool PlotView::FitTimeToLane(int laneIndex) { if (m_dataSet == nullptr || m_tab == nullptr) @@ -917,9 +947,6 @@ namespace jdi const float fx = static_cast(x); const float fy = static_cast(y); - std::vector points; - std::vector breaks; - SignalRef best; float bestDistance = tolerance; @@ -932,17 +959,47 @@ namespace jdi continue; } - BuildPolyline(resolved, lane, points, breaks); + // 复用绘制阶段的折线顶点:悬停检测通常与上一帧几何完全相同, + // 不再为“鼠标移动但命中结果未变”重复扫描全量采样点。 + const PolylineCacheEntry* cached = AcquirePolyline(ref, resolved, lane); + if (cached == nullptr) + { + continue; + } + const std::vector& points = cached->points; + const std::vector& breaks = cached->breaks; if (points.size() < 2) { continue; } + // 横向先行裁剪:点到线段的距离必然不小于横向距离本身, + // 因此只有横向落在容差范围内的顶点才可能命中。 + // 时间单调时折线顶点按 x 递增,可二分定位后只扫描这一小段; + // 否则每次鼠标移动都要遍历上万个顶点(悬停卡顿的来源之一)。 + std::size_t windowFirst = 0; + std::size_t windowLast = points.size(); + if (resolved.file->timeMonotonic) + { + const auto byX = [](const gd::PointF& point, float limit) { return point.X < limit; }; + windowFirst = static_cast( + std::lower_bound(points.begin(), points.end(), fx - tolerance, byX) - points.begin()); + windowLast = static_cast( + std::upper_bound(points.begin(), points.end(), fx + tolerance, + [](float limit, const gd::PointF& point) { return limit < point.X; }) - + points.begin()); + } + if (windowFirst >= windowLast) + { + continue; + } + // 散点模式下把采样点本身也视为可命中目标。 if (resolved.style->mode == DrawMode::SCATTER) { - for (const gd::PointF& point : points) + for (std::size_t index = windowFirst; index < windowLast; ++index) { + const gd::PointF& point = points[index]; const float dx = point.X - fx; const float dy = point.Y - fy; const float distance = std::sqrt(dx * dx + dy * dy); @@ -959,7 +1016,9 @@ namespace jdi { const std::size_t start = breaks[k]; const std::size_t stop = (k + 1 < breaks.size()) ? breaks[k + 1] : points.size(); - for (std::size_t index = start + 1; index < stop; ++index) + const std::size_t scanBegin = std::max(start + 1, windowFirst); + const std::size_t scanEnd = std::min(stop, windowLast); + for (std::size_t index = scanBegin; index < scanEnd; ++index) { const gd::PointF& a = points[index - 1]; const gd::PointF& b = points[index]; @@ -1770,6 +1829,59 @@ namespace jdi } + const PlotView::PolylineCacheEntry* PlotView::AcquirePolyline(const SignalRef& ref, + const ResolvedSignal& resolved, + const LaneVisual& lane) const + { + if (resolved.data == nullptr || resolved.file == nullptr) + { + return nullptr; + } + + // 键 = 构建折线时实际读取的全部输入: + // 信号(引用 + 文件内容 + 采样点数)、时间轴显示范围、绘图区横向几何、网格纵向几何。 + // 这些值全部相同时,BuildPolyline 的输出必然完全一致,可直接复用。 + const std::size_t sampleCount = std::min(resolved.file->time.size(), resolved.data->values.size()); + const double viewMin = Tab().ViewMin(); + const double viewMax = Tab().ViewMax(); + + for (const PolylineCacheEntry& entry : m_polylineCache) + { + if (entry.ref == ref && entry.file == resolved.file && entry.sampleCount == sampleCount && + entry.viewMin == viewMin && entry.viewMax == viewMax && entry.plotX == m_plotRect.X && + entry.plotWidth == m_plotRect.Width && entry.laneY == lane.rect.Y && + entry.laneHeight == lane.rect.Height && entry.laneYMin == lane.yMin && + entry.laneYMax == lane.yMax) + { + return &entry; + } + } + + // 未命中:重建并写入缓存(deque 保证已有元素的引用在追加后依然有效)。 + // 缓存只用于消除重复计算,容量上限约等于“网格数 × 每网格信号数”的两倍。 + if (m_polylineCache.size() >= kPolylineCacheLimit) + { + m_polylineCache.clear(); + } + m_polylineCache.push_back(PolylineCacheEntry()); + PolylineCacheEntry& entry = m_polylineCache.back(); + entry.ref = ref; + entry.file = resolved.file; + entry.sampleCount = sampleCount; + entry.viewMin = viewMin; + entry.viewMax = viewMax; + entry.plotX = m_plotRect.X; + entry.plotWidth = m_plotRect.Width; + entry.laneY = lane.rect.Y; + entry.laneHeight = lane.rect.Height; + entry.laneYMin = lane.yMin; + entry.laneYMax = lane.yMax; + + BuildPolyline(resolved, lane, entry.points, entry.breaks); + // BuildPolyline 通过引用填充,push_back 过程中不会再次进入本函数,引用安全。 + return &m_polylineCache.back(); + } + void PlotView::BuildPolyline(const ResolvedSignal& resolved, const LaneVisual& lane, std::vector& points, std::vector& breaks) const { @@ -1816,16 +1928,136 @@ namespace jdi return; } - std::size_t stride = 1; - const std::size_t visibleCount = end - begin; - if (visibleCount > kMaxPolylinePoints) - { - stride = (visibleCount + kMaxPolylinePoints - 1) / kMaxPolylinePoints; - } - const float limitTop = lane.rect.Y - lane.rect.Height * 4.0f; const float limitBottom = lane.rect.Y + lane.rect.Height * 5.0f; + const std::size_t visibleCount = end - begin; + + // 顶点预算由绘图区宽度决定(而不是固定值):折线顶点数直接决定 + // GDI+ 抗锯齿折线的绘制耗时,百万级采样文件若按固定 20000 顶点绘制, + // 会把大量顶点堆在同一个像素列上(画出来完全重合),纯粹浪费 CPU。 + std::size_t budget = static_cast(std::max(1.0f, m_plotRect.Width)) * kPolylinePointsPerPixel; + budget = std::max(budget, kMinPolylinePoints); + budget = std::min(budget, kMaxPolylinePoints); + + // 采样点密度远超像素分辨率时改用“像素列上下包络”抽稀: + // 每个像素列只保留最小/最大两个顶点(按出现先后排序), + // 既不会漏掉尖峰(等间隔抽样会整段跳过),又把顶点数压到像素量级。 + if (visibleCount > budget && resolved.file->timeMonotonic && m_plotRect.Width > 1.0f) + { + const double viewSpan = viewMax - viewMin; + const float plotLeft = m_plotRect.X; + const float plotWidth = m_plotRect.Width; + const std::size_t columns = std::max(1, budget / 2); + const float columnWidth = plotWidth / static_cast(columns); + + const std::vector::const_iterator rangeBegin = time.begin() + static_cast(begin); + const std::vector::const_iterator rangeEnd = time.begin() + static_cast(end); + + bool inSegment = false; + for (std::size_t column = 0; column < columns; ++column) + { + const double t0 = viewMin + viewSpan * (static_cast(column) / static_cast(columns)); + const double t1 = + viewMin + viewSpan * (static_cast(column + 1) / static_cast(columns)); + + const std::size_t i0 = static_cast( + std::lower_bound(rangeBegin, rangeEnd, t0) - time.begin()); + const std::size_t i1 = (column + 1 == columns) + ? end + : static_cast( + std::lower_bound(rangeBegin, rangeEnd, t1) - time.begin()); + if (i1 <= i0) + { + // 该列没有采样点:说明数据在此处存在时间间隙,断开当前折线。 + inSegment = false; + continue; + } + + double low = std::numeric_limits::infinity(); + double high = -std::numeric_limits::infinity(); + std::size_t lowIndex = 0; + std::size_t highIndex = 0; + bool broken = false; + for (std::size_t index = i0; index < i1; ++index) + { + const double value = values[index]; + if (!std::isfinite(value)) + { + // 该列含无效值:包络绘制到本列结束,后续另起一段。 + broken = true; + continue; + } + if (value < low) + { + low = value; + lowIndex = index; + } + if (value > high) + { + high = value; + highIndex = index; + } + } + + if (!(low <= high)) + { + // 本列全是无效值。 + inSegment = false; + continue; + } + + const float x = plotLeft + (static_cast(column) + 0.5f) * columnWidth; + const float yTop = + static_cast(ClampDouble(ValueToY(high, lane), limitTop, limitBottom)); + const float yBottom = + static_cast(ClampDouble(ValueToY(low, lane), limitTop, limitBottom)); + + if (yBottom - yTop < kEnvelopeMinPixelSpan) + { + // 波动不足半个像素:只留一个顶点,避免无意义的重复顶点。 + if (!inSegment) + { + breaks.push_back(points.size()); + inSegment = true; + } + points.push_back(gd::PointF(x, yTop)); + } + else + { + if (!inSegment) + { + breaks.push_back(points.size()); + inSegment = true; + } + // 按采样点出现的先后顺序连接上下包络,保持走线方向一致。 + if (lowIndex <= highIndex) + { + points.push_back(gd::PointF(x, yBottom)); + points.push_back(gd::PointF(x, yTop)); + } + else + { + points.push_back(gd::PointF(x, yTop)); + points.push_back(gd::PointF(x, yBottom)); + } + } + + if (broken) + { + inSegment = false; + } + } + + return; + } + + std::size_t stride = 1; + if (visibleCount > budget) + { + stride = (visibleCount + budget - 1) / budget; + } + bool inSegment = false; for (std::size_t index = begin; index < end; index += stride) @@ -2080,9 +2312,6 @@ namespace jdi const int waveSave = g.Save(); g.SetClip(lane.rect, gd::CombineModeIntersect); - std::vector points; - std::vector breaks; - for (const SignalRef& ref : laneSignals) { const ResolvedSignal resolved = ResolveSignal(ref); @@ -2092,7 +2321,14 @@ namespace jdi } const DrawMode mode = resolved.style->mode; - BuildPolyline(resolved, lane, points, breaks); + // 顶点由“几何键”缓存提供:同一视图下重复绘制不再重算全量采样。 + const PolylineCacheEntry* cached = AcquirePolyline(ref, resolved, lane); + if (cached == nullptr) + { + continue; + } + const std::vector& points = cached->points; + const std::vector& breaks = cached->breaks; const bool drawLine = (mode != DrawMode::SCATTER); const bool drawScatter = (mode == DrawMode::SCATTER || mode == DrawMode::LINE_SCATTER);