绘图区交互修正:悬停不改光标、图例联动、时间轴子刻度、边沿计数规则与光标区间联动

- 网格内信号悬停不再改变鼠标光标(维持原始箭头);只有真正按下拖动(MOVE_SIGNAL)
  时才切换为抓握光标。

- 修复网格左上角图例的联动缺陷:
  * 根因是图例的选中/悬停底色绘制在静态场景缓存里,而缓存指纹(SceneContentHash)
    未包含「选中信号」→ 在左侧信号面板点选信号后缓存未失效,图例不会高亮。
    现在把 SelectedRef() 折叠进指纹,选中变化会正确重绘。
  * 图例悬停态随鼠标变化,不能进缓存:新增 m_hoverLegend 与 PaintLegendHighlight(),
    在静态场景贴图之后用半透明强调色叠在本条目上形成“半高亮”,并在两条绘制路径
    (缓存贴图快路径 / 整幅重绘路径)中都调用。
  * 悬停图例时不再触发曲线拖拽提示(不画信号光晕、不弹“按住拖拽可移入其它网格”气泡),
    仅半高亮;点选后参与联动。

- 主时间轴新增五等分子刻度线:在相邻主刻度之间插入 4 条短刻度(3px),
  与主刻度重合的位置跳过。

- 「实验性功能 → 边沿计数」按用户明确的规则重做统计:
  * 判定改为“穿越横向光标所在的 Y 值”:信号从下往上穿越 → 上升沿,
    从上往下穿越 → 下降沿(原先按信号幅值中点判定,与光标无关,因此拖动光标
    结果不变)。
  * 缓存键随之扩展为「文件 + 信号 + 横向光标幅值 + 时间区间」,
    拖动横向光标或拖动时间光标都会重算。
  * 时间单调时二分定位采样区间,非单调时按区间过滤;NaN 仍视为断点。
  * 面板宽度改为按文本实测宽度(EdgePanelWidth,使用不含省略号的
    UiMeasureTextWidth)自适应,名称与“上升沿 N / 下降沿 M”一律完整显示,
    数字不再被省略或截断。
  * 点击 ✕ 现在同时关闭该网格的边沿计数功能(横向光标一并消失),
    不再是仅收起面板。

- 边沿计数与光标功能联动:双光标模式下面板标题追加尾缀“(光标区间内)”,
  统计只覆盖左右光标之间的时间区间;区间端点始终取两条光标时间的较小/较大值,
  因此把任一光标拖到另一侧(左右交换)后区间与计数依然正确。

验证(TestData/VERY_BIG_DATA.CSV,编码器A相,125%、1725x996):
- 光标形态:悬停曲线 = ARROW(不改变);拖拽 = SIZEALL。
- 图例联动:在左侧面板点选信号后图例条目正确高亮;悬停图例显示半高亮、
  无拖拽提示气泡。
- 时间轴五等分子刻度可见。
- 边沿计数(用独立脚本 .preflight/edgeprobe2.ps1 / edgeprobe3.ps1 按同一规则复算,
  与界面显示逐位一致):
  * 阈值 2.54415(全区间)→ 界面/复算均 上升沿 1333422 下降沿 1333421;
    拖动横向光标到 5.08994 → 界面/复算均 上升沿 1843881 下降沿 1843881(计数随光标变化);
    阈值 100 → 0 / 0。
  * 双光标区间 [48.0211, 64.4051] → 界面/复算均 上升沿 193161 下降沿 193161;
    区间 [64.4051, 79.8623] → 163647 / 163647。
  * 光标交换:把光标 1 的时间改为 120(越过光标 2 的 79.8623)后,Δt 变为
    40s 137.726ms,面板标题为“边沿计数(光标区间内)”,
    计数 404668 / 404669 与复算的区间 [79.8623, 120] 完全一致。
- ✕:面板与横向光标同时消失。
- 回归:[1].CSV 双光标正常(Δt:151ms 200us),面板标题带尾缀;
  make clean && make all 无警告无错误。
This commit is contained in:
DeepSeek V4.1 Flash committed 2026-10-09 10:13:38 +08:00
1 parent cd6e0ed058
commit b884e7ecb4
2 files changed
+275 -39

No files matched your search

+221 -32
View File
@@ -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<double>& times = file.time;
const std::size_t count = std::min(times.size(), signal.values.size());
begin = static_cast<std::size_t>(
std::lower_bound(times.begin(), times.begin() + static_cast<std::ptrdiff_t>(count),
rangeMin) -
times.begin());
end = static_cast<std::size_t>(
std::upper_bound(times.begin(), times.begin() + static_cast<std::ptrdiff_t>(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<int>(m_tab->Lanes().size()))
{
return kEdgePanelWidth * m_scale;
}
const Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(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<int>(m_tab->Lanes().size()))
@@ -2348,11 +2464,9 @@ namespace jdi
if (UiPointInRect(m_plotRect, static_cast<float>(x), static_cast<float>(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<std::size_t>(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<int>(m_lanes.size()))
{
return;
}
const gd::RectF laneRect = m_lanes[static_cast<std::size_t>(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<std::uint64_t>(selected.fileIndex));
mix(static_cast<std::uint64_t>(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<double>(kMinorDivisions);
std::vector<double> 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,