diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index cd206ec..63ef8c8 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -870,14 +870,50 @@ namespace jdi gd::RectF EdgePanelCloseRect(int laneIndex) const; /** - * @brief 统计某信号的上/下边沿数量(以信号幅值中点为判定阈值),结果带缓存。 + * @brief 统计某信号穿越横向光标幅值的上/下边沿数量,结果带缓存。 * @param [IN] file 信号所属文件。 * @param [IN] signal 目标信号。 - * @param [OUT] rising 上升沿数量。 - * @param [OUT] falling 下降沿数量。 + * @param [IN] edgeValue 横向光标所在的幅值。 + * @param [IN] hasRange 是否只统计某个时间区间(双光标模式下为光标区间)。 + * @param [IN] rangeMin 区间左端点时间。 + * @param [IN] rangeMax 区间右端点时间。 + * @param [OUT] rising 上升沿数量(从下往上穿越)。 + * @param [OUT] falling 下降沿数量(从上往下穿越)。 */ - void EdgeCountsFor(const DataFile& file, const SignalData& signal, std::size_t& rising, - std::size_t& falling); + void EdgeCountsFor(const DataFile& file, const SignalData& signal, double edgeValue, + bool hasRange, double rangeMin, double rangeMax, std::size_t& rising, + std::size_t& falling) const; + + /** + * @brief 计算边沿统计面板所需的宽度(排版与绘制共用,保证文字完整不被省略)。 + * @param [IN] laneIndex 网格下标。 + * @return 面板宽度(像素)。 + */ + float EdgePanelWidth(int laneIndex) const; + + /** + * @brief 判断边沿统计是否限定在双光标区间内(双光标模式)。 + * @return 限定在光标区间内返回 true。 + */ + bool EdgeRangeActive() const; + + /** + * @brief 边沿统计时间区间的左端点(取两条光标的较小时间)。 + * @return 区间左端点。 + */ + double EdgeRangeMin() const; + + /** + * @brief 边沿统计时间区间的右端点(取两条光标的较大时间)。 + * @return 区间右端点。 + */ + double EdgeRangeMax() const; + + /** + * @brief 双光标模式下面板标题的尾缀文本(“(光标区间内)”)。 + * @return 尾缀文本,非双光标模式返回空串。 + */ + std::wstring EdgeRangeSuffix() const; /** * @brief 绘制拖动中的缩放选区。 @@ -967,6 +1003,7 @@ namespace jdi gd::RectF m_rubberRect; /**< 拖拽选区矩形。 */ SignalRef m_hoverSignal; /**< 鼠标悬停命中的信号(曲线高亮)。 */ + SignalRef m_hoverLegend; /**< 鼠标悬停命中的图例条目(半高亮提示,不触发曲线拖拽提示)。 */ SignalRef m_dragSignal; /**< 正在被拖拽的信号。 */ int m_dragTargetLane = -1; /**< 拖拽信号时的目标网格下标。 */ int m_dragMouseX = 0; /**< 拖拽过程中鼠标的当前横坐标(虚影跟随)。 */ @@ -999,15 +1036,19 @@ namespace jdi std::vector m_cursorLabelRects; /**< 本次绘制已占用的取值标注矩形(用于避让重叠)。 */ std::vector m_lanes; /**< 当前网格绘制信息集合。 */ - /** 边沿统计结果缓存项(按「文件指针 + 信号指针」标识,数据变化时整体失效)。 */ + /** 边沿统计结果缓存项(按「文件 + 信号 + 横向光标幅值 + 时间区间」标识,任一变化即重算)。 */ struct EdgeCountEntry { const DataFile* file = nullptr; const SignalData* signal = nullptr; + double edgeValue = 0.0; + bool hasRange = false; + double rangeMin = 0.0; + double rangeMax = 0.0; std::size_t rising = 0; std::size_t falling = 0; }; - std::vector m_edgeCountCache; /**< 边沿统计缓存。 */ + mutable std::vector m_edgeCountCache; /**< 边沿统计缓存(纯缓存,逻辑上不影响对象状态)。 */ // 「边沿计数」横向光标 / 面板的拖拽状态。 int m_edgeDragLane = -1; /**< 正在拖动的横向光标所属网格。 */ @@ -1124,6 +1165,12 @@ namespace jdi */ void PaintHoverHighlight(gd::Graphics& g); + /** + * @brief 绘制图例条目的悬停半高亮提示(在静态场景贴图之后绘制)。 + * @param [IN,OUT] g 绘图上下文。 + */ + void PaintLegendHighlight(gd::Graphics& g); + /** * @brief 绘制某条信号的悬停/拖拽光晕。 * @param [IN,OUT] g 绘图上下文。 diff --git a/Components/PlotView/Src/PlotView.cpp b/Components/PlotView/Src/PlotView.cpp index 132ccf4..7cee008 100644 --- a/Components/PlotView/Src/PlotView.cpp +++ b/Components/PlotView/Src/PlotView.cpp @@ -1348,15 +1348,18 @@ namespace jdi return enabled; } - void PlotView::EdgeCountsFor(const DataFile& file, const SignalData& signal, std::size_t& rising, - std::size_t& falling) + void PlotView::EdgeCountsFor(const DataFile& file, const SignalData& signal, double edgeValue, + bool hasRange, double rangeMin, double rangeMax, std::size_t& rising, + std::size_t& falling) const { rising = 0; falling = 0; + // 缓存键必须包含横向光标幅值与时间区间:拖动光标或拖动时间光标都会改变统计结果。 for (const EdgeCountEntry& entry : m_edgeCountCache) { - if (entry.file == &file && entry.signal == &signal) + if (entry.file == &file && entry.signal == &signal && entry.hasRange == hasRange && + entry.edgeValue == edgeValue && entry.rangeMin == rangeMin && entry.rangeMax == rangeMax) { rising = entry.rising; falling = entry.falling; @@ -1364,42 +1367,155 @@ namespace jdi } } - // 判定阈值取信号幅值中点:对数字/方波类信号等价于电平翻转,对模拟信号则是穿越中值。 - const double mid = (signal.minValue + signal.maxValue) * 0.5; - bool hasPrevious = false; - bool previousHigh = false; - for (double value : signal.values) + // 采样范围:时间单调时二分定位,否则按区间过滤(与波形绘制的处理一致)。 + std::size_t begin = 0; + std::size_t end = signal.values.size(); + if (hasRange && file.timeMonotonic) { + const std::vector& times = file.time; + const std::size_t count = std::min(times.size(), signal.values.size()); + begin = static_cast( + std::lower_bound(times.begin(), times.begin() + static_cast(count), + rangeMin) - + times.begin()); + end = static_cast( + std::upper_bound(times.begin(), times.begin() + static_cast(count), + rangeMax) - + times.begin()); + if (begin > 0) + { + // 区间左侧补一个采样点:跨越区间边界的边沿也能被计入。 + --begin; + } + end = std::min(end, count); + } + else if (hasRange) + { + end = std::min(end, signal.values.size()); + } + begin = std::min(begin, end); + + // 边沿判定:以横向光标的幅值 edgeValue 为阈值。 + // 信号从下往上穿越 → 上升沿;从上往下穿越 → 下降沿。 + bool hasPrevious = false; + double previous = 0.0; + for (std::size_t index = begin; index < end; ++index) + { + const double value = signal.values[index]; if (!std::isfinite(value)) { // NaN 视为断点:不参与边沿判定,也不与前一点相连。 hasPrevious = false; continue; } - const bool high = (value >= mid); - if (hasPrevious && high != previousHigh) + if (hasRange && !file.timeMonotonic && index < file.time.size()) { - if (high) + const double time = file.time[index]; + if (time < rangeMin || time > rangeMax) + { + hasPrevious = false; + continue; + } + } + + if (hasPrevious) + { + const double before = previous - edgeValue; + const double after = value - edgeValue; + if (before < 0.0 && after >= 0.0) { ++rising; } - else + else if (before > 0.0 && after <= 0.0) { ++falling; } } - previousHigh = high; + previous = value; hasPrevious = true; } EdgeCountEntry entry; entry.file = &file; entry.signal = &signal; + entry.edgeValue = edgeValue; + entry.hasRange = hasRange; + entry.rangeMin = rangeMin; + entry.rangeMax = rangeMax; entry.rising = rising; entry.falling = falling; m_edgeCountCache.push_back(entry); } + float PlotView::EdgePanelWidth(int laneIndex) const + { + if (m_tab == nullptr || m_fonts == nullptr || laneIndex < 0 || + laneIndex >= static_cast(m_tab->Lanes().size())) + { + return kEdgePanelWidth * m_scale; + } + const Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; + + // 标题行 = “GX” 徽标 + “边沿计数(光标区间内)”。 + const std::wstring title = L"边沿计数" + EdgeRangeSuffix(); + float width = kEdgePanelPad * 2.0f * m_scale + kLaneBadgeWidth * m_scale + + 8.0f * m_scale + UiMeasureTextWidth(m_fonts->uiSmall, title) + + kEdgePanelTitleHeight * m_scale; + + // 数据行 = 名称与“上升沿 N / 下降沿 M”两行,取较宽者。 + // 用 UiMeasureTextWidth(无省略号)测量,保证数字完整显示。 + for (const SignalRef& ref : lane.signals) + { + const ResolvedSignal resolved = ResolveSignal(ref); + if (resolved.data == nullptr || !resolved.visible) + { + continue; + } + const float nameWidth = UiMeasureTextWidth(m_fonts->uiSmall, resolved.data->Title()); + std::size_t rising = 0; + std::size_t falling = 0; + EdgeCountsFor(*resolved.file, *resolved.data, lane.edgeValue, EdgeRangeActive(), + EdgeRangeMin(), EdgeRangeMax(), rising, falling); + const float countWidth = + UiMeasureTextWidth(m_fonts->monoSmall, + L"上升沿 " + std::to_wstring(rising) + L" 下降沿 " + + std::to_wstring(falling)); + width = std::max(width, kEdgePanelPad * 2.0f * m_scale + std::max(nameWidth, countWidth)); + } + + // 夹在绘图区宽度之内(过窄的网格也不至于把面板挤到外面)。 + return std::min(width, std::max(120.0f * m_scale, m_plotRect.Width - 8.0f * m_scale)); + } + + bool PlotView::EdgeRangeActive() const + { + return m_tab != nullptr && Tab().CursorMode() == PlotCursorMode::DOUBLE; + } + + double PlotView::EdgeRangeMin() const + { + if (m_tab == nullptr) + { + return 0.0; + } + // 左右光标可能被拖动交换:始终取较小者为区间左端。 + return std::min(Tab().CursorTime(), Tab().CursorTimeAlt()); + } + + double PlotView::EdgeRangeMax() const + { + if (m_tab == nullptr) + { + return 0.0; + } + return std::max(Tab().CursorTime(), Tab().CursorTimeAlt()); + } + + std::wstring PlotView::EdgeRangeSuffix() const + { + return EdgeRangeActive() ? std::wstring(L"(光标区间内)") : std::wstring(); + } + bool PlotView::ToggleLaneAutoFitToSelection(int laneIndex) { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) @@ -2348,11 +2464,9 @@ namespace jdi if (UiPointInRect(m_plotRect, static_cast(x), static_cast(y))) { - // 悬停命中可拖拽信号时给出"手掌"提示光标;按下拖拽过程中改用抓握光标。 - if (Tab().ZoomMode() == PlotZoomMode::NONE && HitTestSignal(x, y).IsValid()) - { - return LoadCursorW(nullptr, IDC_HAND); - } + // 悬停命中可拖拽信号时**不改变鼠标形状**(维持原始箭头); + // 只有真正按下拖动(MOVE_SIGNAL)才切换为抓握光标。 + // 悬停图例时同样保持箭头,仅做半高亮提示。 // 选区放大模式下**未开始框选时保持默认箭头**(不改变鼠标形状); // 只有真正按下并拖动后才切成十字/方向箭头。 @@ -2600,12 +2714,14 @@ namespace jdi } // 常规模式下精确检测鼠标是否贴近某条信号曲线,用于给出可拖拽提示。 - // 图例优先于曲线:图例条目本身就是信号的可点选目标(与左侧信号列表一致)。 + // 图例优先于曲线:图例条目是可点选目标(与左侧信号列表一致), + // 但悬停图例时**只做半高亮提示**,不显示曲线拖拽提示(也不触发光晕)。 SignalRef hoverSignal; + SignalRef hoverLegend; if (Tab().ZoomMode() == PlotZoomMode::NONE && !m_cursorHover && !overEdgePanel) { - hoverSignal = LegendAtPoint(x, y); - if (!hoverSignal.IsValid()) + hoverLegend = LegendAtPoint(x, y); + if (!hoverLegend.IsValid()) { hoverSignal = HitTestSignal(x, y); } @@ -2615,6 +2731,11 @@ namespace jdi m_hoverSignal = hoverSignal; changed = true; } + if (hoverLegend != m_hoverLegend) + { + m_hoverLegend = hoverLegend; + changed = true; + } // 双光标差值标签的悬停状态(用于区分"抓握/悬停"光标与普通状态)。 const bool spanHover = IsOnCursorSpanLabel(x, y); @@ -2719,10 +2840,10 @@ namespace jdi } if (UiPointInRect(EdgePanelCloseRect(index), fx, fy)) { - // ✕ 只收起面板;横向光标仍保留(重新勾选菜单项可再次展开)。 + // ✕ 关闭面板的同时关闭该网格的边沿计数功能(横向光标一并消失)。 if (m_tab != nullptr) { - m_tab->Lanes()[static_cast(index)].edgePanelOpen = false; + SetLaneEdgeCount(index, false); } m_dragMode = PlotDragMode::NONE; return true; @@ -3336,6 +3457,7 @@ namespace jdi if (SceneCacheMatches() && BlitSceneCache(g)) { PaintHoverHighlight(g); + PaintLegendHighlight(g); PaintRubberBand(g); PaintCursor(g, m_lanes); PaintEdgeCursor(g, m_lanes); @@ -3382,6 +3504,7 @@ namespace jdi CaptureSceneCache(g); PaintHoverHighlight(g); + PaintLegendHighlight(g); PaintRubberBand(g); PaintCursor(g, m_lanes); PaintEdgeCursor(g, m_lanes); @@ -3479,6 +3602,40 @@ namespace jdi g.Restore(saveState); } + void PlotView::PaintLegendHighlight(gd::Graphics& g) + { + if (m_tab == nullptr || m_dragMode != PlotDragMode::NONE || !m_hoverLegend.IsValid()) + { + return; + } + + // 图例条目的底色绘制在静态场景缓存里;悬停态随鼠标变化,不能进缓存, + // 因此在贴图之后用半透明强调色在本条目范围内叠一层,形成“半高亮”提示。 + const int laneIndex = m_tab->LaneOfSignal(m_hoverLegend); + if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) + { + return; + } + const gd::RectF laneRect = m_lanes[static_cast(laneIndex)].rect; + if (laneRect.Y > m_plotRect.Y + m_plotRect.Height || + laneRect.Y + laneRect.Height < m_plotRect.Y) + { + return; + } + + const int saveState = g.Save(); + g.SetClip(m_area, gd::CombineModeIntersect); + for (const LegendHit& hit : m_legendHits) + { + if (hit.ref == m_hoverLegend) + { + UiFillRoundRect(g, hit.rect, 3.0f * m_scale, WithAlpha(m_theme->accent, 0x38)); + break; + } + } + g.Restore(saveState); + } + PlotView::SceneCacheKey PlotView::MakeSceneCacheKey() const { SceneCacheKey key; @@ -3559,6 +3716,15 @@ namespace jdi mixFloat(m_laneContentHeight); mix(m_scrollBarVisible ? 1ull : 0ull); + // 选中信号会改变图例条目的底色与文字颜色(绘制在缓存的静态场景里), + // 因此必须参与指纹:否则在左侧信号面板点选后图例不会高亮联动。 + { + const SignalRef selected = SelectedRef(); + mix(selected.IsValid() ? 1ull : 0ull); + mix(static_cast(selected.fileIndex)); + mix(static_cast(selected.signalIndex)); + } + for (std::size_t laneIndex = 0; laneIndex < m_lanes.size(); ++laneIndex) { const LaneVisual& visual = m_lanes[laneIndex]; @@ -4211,14 +4377,15 @@ namespace jdi const float itemLeft = cursorX; const float swatchSize = kLegendSwatchSize * m_scale; - // 图例条目与被选中的信号保持一致的高亮反馈(与左侧列表选中态呼应)。 + // 选中态(与左侧列表呼应)绘制在静态场景里:选中信号参与场景指纹, + // 因此选中变化会让缓存失效并重绘。**悬停半高亮不在此处**(随鼠标变化, + // 画在缓存贴图之后,见 PaintLegendHighlight)。 const bool selected = (SelectedRef() == ref); - const bool hovered = (ref == m_hoverSignal) || (ref == m_dragSignal); - if (selected || hovered) + if (selected) { UiFillRoundRect(g, gd::RectF(itemLeft - 3.0f * m_scale, top - 1.0f * m_scale, itemWidth + 2.0f * m_scale, height + 2.0f * m_scale), - 3.0f * m_scale, selected ? m_theme->selection : m_theme->hover); + 3.0f * m_scale, m_theme->selection); } UiDrawSwatch(g, gd::RectF(cursorX, top + (height - swatchSize) * 0.5f, swatchSize, swatchSize), @@ -4310,6 +4477,27 @@ namespace jdi m_theme->textMuted, TextAlign::LEFT); const float labelWidth = 96.0f * m_scale; + + // 五等分子刻度线:在相邻主刻度之间插入 4 条短刻度(更长的主刻度在最前绘制, + // 短刻度与主刻度重合的位置跳过),便于目视读取中间时刻。 + const double minorStep = timeStep / static_cast(kMinorDivisions); + std::vector minorTicks; + BuildTicks(Tab().ViewMin(), Tab().ViewMax(), minorStep, minorTicks); + for (const double tick : minorTicks) + { + const double majorIndex = tick / timeStep; + if (std::fabs(majorIndex - std::floor(majorIndex + 0.5)) < 1.0e-6) + { + continue; + } + const float x = TimeToX(tick); + if (x < m_plotRect.X - 1.0f || x > m_plotRect.X + m_plotRect.Width + 1.0f) + { + continue; + } + UiDrawLine(g, x, axisY, x, axisY + 3.0f * m_scale, m_theme->axisLine, 1.0f); + } + for (const double tick : timeTicks) { const float x = TimeToX(tick); @@ -4714,7 +4902,7 @@ namespace jdi return gd::RectF(); } - const float width = kEdgePanelWidth * m_scale; + const float width = EdgePanelWidth(laneIndex); const float titleHeight = kEdgePanelTitleHeight * m_scale; const float rowHeight = kEdgePanelRowHeight * m_scale; const float pad = kEdgePanelPad * m_scale; @@ -4898,7 +5086,7 @@ namespace jdi UiDrawRoundRect(g, badgeRect, badgeHeight * 0.5f, m_theme->border, 1.0f); UiText(g, L"G" + std::to_wstring(index + 1), m_fonts->monoSmall, badgeRect, m_theme->text, TextAlign::CENTER); - UiText(g, L"边沿计数", m_fonts->uiSmall, + UiText(g, L"边沿计数" + EdgeRangeSuffix(), m_fonts->uiSmall, gd::RectF(badgeRect.X + badgeRect.Width + 8.0f * m_scale, panel.Y, panel.Width - badgeRect.Width - 8.0f * m_scale - titleHeight, titleHeight), @@ -4936,14 +5124,15 @@ namespace jdi } const std::uint32_t signalColor = StyleColor(*resolved.style); const float textWidth = panel.Width - pad * 2.0f; - UiText(g, UiEllipsize(g, resolved.data->Title(), m_fonts->uiSmall, textWidth), - m_fonts->uiSmall, + // 名称与统计数字一律完整绘制(不省略):面板宽度已按文本实测宽度确定。 + UiText(g, resolved.data->Title(), m_fonts->uiSmall, gd::RectF(panel.X + pad, rowY, textWidth, rowHeight), signalColor, TextAlign::LEFT); std::size_t rising = 0; std::size_t falling = 0; - EdgeCountsFor(*resolved.file, *resolved.data, rising, falling); + EdgeCountsFor(*resolved.file, *resolved.data, tabLane.edgeValue, EdgeRangeActive(), + EdgeRangeMin(), EdgeRangeMax(), rising, falling); const std::wstring counts = L"上升沿 " + std::to_wstring(rising) + L" 下降沿 " + std::to_wstring(falling); UiText(g, counts, m_fonts->monoSmall,