界面增强:网格图例 GX 编号、工具栏状态修复、增量括号、实验性边沿计数

- 每个网格图例左侧新增当前网格的 “GX” 编号徽标,与左侧信号列表行末的 G1/G2 徽标
  使用完全一致的样式(同宽高、同圆角、同配色、同字体),并纳入图例底板范围。

- 修复工具栏 toggle 状态被重置的缺陷:Toolbar::Layout() 会重建全部按钮,
  原先会把 checked 全部清零。因此当「光标」按钮因为模式变化(光标x2 → 光标)
  触发文本变更与 Relayout 时,同帧刚设置好的「Y 轴自适应」「选区放大」勾选态被清掉,
  表现为切换光标模式时前面两个开关被一并取消。现在 Layout() 会按 action 继承既有
  checked 状态,重建后状态保持不变。

- 双光标右侧光标的增量显示去掉逗号,改为包裹在圆括号中:`4.9425 (+0.0008)`。

- 网格右键菜单在分隔横线上方新增「实验性功能」(带二级子菜单指示箭头):
  鼠标悬停即向右展开二级菜单,其中包含「边沿计数」切换项(带 √)。
  为此为 PopupMenu 增加了完整的二级子菜单支持:子菜单排版(贴主菜单右缘、
  右侧空间不足时翻到左侧、限制在宿主窗口内)、悬停展开/移开收起、
  点击父项不下发命令、子菜单内按下/抬起配对提交、键盘(← 收起 / → 展开 /
  ↑↓ 在子菜单内移动 / Enter 提交 / Esc 先收起子菜单再关菜单)、
  独立的投影与绘制、ContainsPoint 覆盖子菜单区域、Relayout 后子菜单位置重建。

- 「边沿计数」开启后(按网格独立,状态随 RebuildLanes 按信号继承):
  * 在该网格绘制一条横向光标,风格与纵向时间光标一致(同色同线宽),
    左右两端各带一个尖端朝内的三角把手。
  * 左端显示当前光标的 Y 轴值,读数胶囊纵向居中于横线、靠网格左侧。
  * 横向光标不绘制任何数据点取值标注(与纵向光标的行为不同)。
  * 横向光标可上下拖动改变幅值,拖动时显示上下箭头光标。
  * 生成带 ✕ 关闭按钮的浮动面板:标题栏显示 “GX” 徽标 + “边沿计数”,
    下方按信号列出名称(信号色)与「上升沿 N / 下降沿 M」。
    面板可在整个绘图区内拖动(限制在绘图区内),✕ 仅收起面板、保留横向光标,
    重新勾选菜单项可再次展开。
  * 边沿判定阈值为信号幅值的 (min+max)/2 中点穿越,NaN 视为断点;
    统计结果按 (文件, 信号) 缓存,数据层变化时随折线缓存一并失效。

验证(TestData/VERY_BIG_DATA.CSV,编码器A/B相密集方波,125%、1725x996):
- 图例编号:G1 / G2 徽标与左侧列表徽标样式一致。
- 工具栏:开启「Y 轴自适应」+「选区放大」后,光标由 光标x2 切回 光标,
  两个开关仍保持勾选(截图核对)。
- 增量:右光标显示 `4.9425 (+0.0008)`。
- 二级菜单:悬停「实验性功能」展开「边沿计数」,箭头与子菜单位置正确。
- 边沿计数:横向光标 + 左端读数 `2.54415` + 面板
  `G1 边沿计数 / 编码器A相 / 上升沿 1333422 下降沿 1333421`;
  上下拖动后横线上移到 4.13602(与 Y 轴刻度一致);面板拖到绘图区左下并保持在内;
  点击 ✕ 后面板收起而横向光标保留;
  光标形态探测:横线悬停 SIZENS、面板悬停 SIZEALL。
- 合并网格后面板同时列出网格内两条信号(编码器A相 / 编码器B相)。
- 回归:[1].CSV 双光标正常(光标x2),Δt:151ms 200us;
  make clean && make all 无警告无错误。
- 修复过程中发现:静态场景缓存命中路径(贴图快路径)与整幅重绘路径是两处独立的
  叠加层绘制入口,新增的随鼠标变化内容必须同时加在两边。
This commit is contained in:
DeepSeek V4.1 Flash committed 2026-10-09 09:30:30 +08:00
1 parent a566d98bab
commit cd6e0ed058
8 files changed
+1138 -26

No files matched your search

+520 -6
View File
@@ -44,6 +44,13 @@ namespace jdi
const float kLegendSwatchSize = 8.0f; /**< 图例色块尺寸。 */
const float kLegendItemGap = 14.0f; /**< 图例条目之间的间距。 */
const float kLegendMaxTextWidth = 200.0f; /**< 图例条目的文本宽度上限(超出则省略)。 */
const float kLaneBadgeWidth = 30.0f; /**< 网格编号徽标宽度(与信号面板保持一致)。 */
const float kLaneBadgeHeight = 17.0f; /**< 网格编号徽标高度。 */
const float kLaneBadgeGap = 8.0f; /**< 编号徽标与图例条目之间的间距。 */
const float kEdgePanelWidth = 230.0f; /**< 边沿统计浮动面板宽度。 */
const float kEdgePanelTitleHeight = 26.0f; /**< 面板标题栏高度。 */
const float kEdgePanelRowHeight = 18.0f; /**< 面板内单行文本高度。 */
const float kEdgePanelPad = 7.0f; /**< 面板内边距。 */
const std::uint64_t kHintHoldMs = 1300; /**< 提示浮层完全显示的停留时长(毫秒)。 */
const std::uint64_t kHintFadeMs = 500; /**< 提示浮层的淡出时长(毫秒)。 */
@@ -899,6 +906,8 @@ namespace jdi
void PlotView::InvalidatePolylineCache()
{
m_polylineCache.clear();
// 数据层结构变化后边沿统计结果也会失效。
m_edgeCountCache.clear();
InvalidateSceneCache();
}
@@ -1291,6 +1300,106 @@ namespace jdi
return true;
}
bool PlotView::LaneEdgeCount(int laneIndex) const
{
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
{
return false;
}
return m_tab->Lanes()[static_cast<std::size_t>(laneIndex)].edgeCount;
}
bool PlotView::SetLaneEdgeCount(int laneIndex, bool enabled)
{
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
{
return false;
}
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
if (lane.edgeCount == enabled)
{
return false;
}
lane.edgeCount = enabled;
if (enabled)
{
// 首次开启:把横向光标放到可见幅值范围的中点,并让面板重新展开。
if (!lane.edgeValueValid)
{
double yMin = 0.0;
double yMax = 1.0;
LaneRange(laneIndex, yMin, yMax);
lane.edgeValue = (yMin + yMax) * 0.5;
lane.edgeValueValid = true;
}
lane.edgePanelOpen = true;
}
else
{
lane.edgePanelOpen = false;
}
return true;
}
bool PlotView::ToggleLaneEdgeCount(int laneIndex)
{
const bool enabled = !LaneEdgeCount(laneIndex);
SetLaneEdgeCount(laneIndex, enabled);
return enabled;
}
void PlotView::EdgeCountsFor(const DataFile& file, const SignalData& signal, std::size_t& rising,
std::size_t& falling)
{
rising = 0;
falling = 0;
for (const EdgeCountEntry& entry : m_edgeCountCache)
{
if (entry.file == &file && entry.signal == &signal)
{
rising = entry.rising;
falling = entry.falling;
return;
}
}
// 判定阈值取信号幅值中点:对数字/方波类信号等价于电平翻转,对模拟信号则是穿越中值。
const double mid = (signal.minValue + signal.maxValue) * 0.5;
bool hasPrevious = false;
bool previousHigh = false;
for (double value : signal.values)
{
if (!std::isfinite(value))
{
// NaN 视为断点:不参与边沿判定,也不与前一点相连。
hasPrevious = false;
continue;
}
const bool high = (value >= mid);
if (hasPrevious && high != previousHigh)
{
if (high)
{
++rising;
}
else
{
++falling;
}
}
previousHigh = high;
hasPrevious = true;
}
EdgeCountEntry entry;
entry.file = &file;
entry.signal = &signal;
entry.rising = rising;
entry.falling = falling;
m_edgeCountCache.push_back(entry);
}
bool PlotView::ToggleLaneAutoFitToSelection(int laneIndex)
{
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
@@ -2179,6 +2288,12 @@ namespace jdi
case PlotDragMode::MOVE_SIGNAL:
// 拖动信号(参考 Photoshop 平移元素的抓握光标)。
return LoadCursorW(nullptr, IDC_SIZEALL);
case PlotDragMode::EDGE_MOVE:
// 「边沿计数」横向光标:上下移动幅值。
return LoadCursorW(nullptr, IDC_SIZENS);
case PlotDragMode::EDGE_PANEL_MOVE:
// 边沿统计浮动面板:可自由拖动。
return LoadCursorW(nullptr, IDC_SIZEALL);
default:
break;
}
@@ -2189,6 +2304,25 @@ namespace jdi
return LoadCursorW(nullptr, IDC_SIZENS);
}
// 边沿统计面板与横向光标优先于信号悬停提示判定(面板会盖住波形)。
if (m_tab != nullptr)
{
for (int index = 0; index < static_cast<int>(m_lanes.size()); ++index)
{
const gd::RectF panel = EdgePanelRect(index);
if (panel.Width > 0.0f &&
UiPointInRect(panel, static_cast<float>(x), static_cast<float>(y)))
{
return LoadCursorW(nullptr, IDC_SIZEALL);
}
}
int edgeLane = -1;
if (IsOnEdgeCursorLine(x, y, edgeLane))
{
return LoadCursorW(nullptr, IDC_SIZENS);
}
}
// 光标时间读数标签与双光标差值标签:**悬停(未按下)时不改变鼠标形状**。
// 两个标签都叠在光标线的正上方,因此必须先于"光标线"判定返回 nullptr,
// 由调用方回退成箭头;只有真正拖拽时才切成左右箭头(见 CURSOR_MOVE / CURSOR_SPAN_MOVE)。
@@ -2379,6 +2513,51 @@ namespace jdi
}
return true;
}
case PlotDragMode::EDGE_MOVE:
{
// 横向光标:上下移动幅值(夹在该网格的可见幅值范围内)。
if (m_tab == nullptr || m_edgeDragLane < 0 ||
m_edgeDragLane >= static_cast<int>(m_lanes.size()) ||
m_edgeDragLane >= static_cast<int>(m_tab->Lanes().size()))
{
break;
}
const LaneVisual& visual = m_lanes[static_cast<std::size_t>(m_edgeDragLane)];
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(m_edgeDragLane)];
double value = YToValue(static_cast<float>(y), visual);
const double low = std::min(visual.yMin, visual.yMax);
const double high = std::max(visual.yMin, visual.yMax);
value = std::max(low, std::min(value, high));
lane.edgeValue = value;
lane.edgeValueValid = true;
m_dragMoved = true;
return true;
}
case PlotDragMode::EDGE_PANEL_MOVE:
{
// 浮动面板:自由拖动,但整体限制在绘图区内。
if (m_tab == nullptr || m_edgePanelDragLane < 0 ||
m_edgePanelDragLane >= static_cast<int>(m_tab->Lanes().size()))
{
break;
}
const gd::RectF panel = EdgePanelRect(m_edgePanelDragLane);
if (panel.Width <= 0.0f)
{
break;
}
float left = static_cast<float>(x) - m_edgePanelDragOffset.X;
float top = static_cast<float>(y) - m_edgePanelDragOffset.Y;
left = std::max(m_plotRect.X + 2.0f * m_scale,
std::min(left, m_plotRect.X + m_plotRect.Width - panel.Width - 2.0f * m_scale));
top = std::max(m_plotRect.Y + 2.0f * m_scale,
std::min(top, m_plotRect.Y + m_plotRect.Height - panel.Height - 2.0f * m_scale));
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(m_edgePanelDragLane)];
lane.edgePanelX = left;
lane.edgePanelY = top;
m_dragMoved = true;
return true;
}
case PlotDragMode::NONE:
default:
break;
@@ -2386,6 +2565,19 @@ namespace jdi
bool changed = false;
// 鼠标压在边沿统计面板上时不再触发信号悬停提示(面板本来就盖住波形)。
bool overEdgePanel = false;
for (int index = 0; index < static_cast<int>(m_lanes.size()); ++index)
{
const gd::RectF panel = EdgePanelRect(index);
if (panel.Width > 0.0f &&
UiPointInRect(panel, static_cast<float>(x), static_cast<float>(y)))
{
overEdgePanel = true;
break;
}
}
const int lane = LaneAtY(y);
if (lane != m_hoverLane)
{
@@ -2410,7 +2602,7 @@ namespace jdi
// 常规模式下精确检测鼠标是否贴近某条信号曲线,用于给出可拖拽提示。
// 图例优先于曲线:图例条目本身就是信号的可点选目标(与左侧信号列表一致)。
SignalRef hoverSignal;
if (Tab().ZoomMode() == PlotZoomMode::NONE && !m_cursorHover)
if (Tab().ZoomMode() == PlotZoomMode::NONE && !m_cursorHover && !overEdgePanel)
{
hoverSignal = LegendAtPoint(x, y);
if (!hoverSignal.IsValid())
@@ -2517,6 +2709,42 @@ namespace jdi
}
}
// 边沿统计浮动面板:关闭按钮 / 面板拖动(面板压在波形之上,必须先于信号拖拽判定)。
for (int index = 0; index < static_cast<int>(m_lanes.size()); ++index)
{
const gd::RectF panel = EdgePanelRect(index);
if (panel.Width <= 0.0f || !UiPointInRect(panel, fx, fy))
{
continue;
}
if (UiPointInRect(EdgePanelCloseRect(index), fx, fy))
{
// ✕ 只收起面板;横向光标仍保留(重新勾选菜单项可再次展开)。
if (m_tab != nullptr)
{
m_tab->Lanes()[static_cast<std::size_t>(index)].edgePanelOpen = false;
}
m_dragMode = PlotDragMode::NONE;
return true;
}
m_dragMode = PlotDragMode::EDGE_PANEL_MOVE;
m_edgePanelDragLane = index;
m_edgePanelDragOffset.X = fx - panel.X;
m_edgePanelDragOffset.Y = fy - panel.Y;
return true;
}
// 「边沿计数」横向光标:上下拖动改变幅值。
{
int edgeLane = -1;
if (IsOnEdgeCursorLine(x, y, edgeLane))
{
m_dragMode = PlotDragMode::EDGE_MOVE;
m_edgeDragLane = edgeLane;
return true;
}
}
if (!UiPointInRect(m_plotRect, fx, fy))
{
m_dragMode = PlotDragMode::NONE;
@@ -2620,6 +2848,8 @@ namespace jdi
m_dragMoved = false;
m_rubberRect = gd::RectF();
m_cursorGrabOffset = 0;
m_edgeDragLane = -1;
m_edgePanelDragLane = -1;
// 松开左键:粘滞提示结束计时,开始"停留后淡出"。
ReleaseHint();
(void)x;
@@ -3108,6 +3338,7 @@ namespace jdi
PaintHoverHighlight(g);
PaintRubberBand(g);
PaintCursor(g, m_lanes);
PaintEdgeCursor(g, m_lanes);
PaintSignalInteraction(g);
PaintHint(g);
if (m_editTarget == PlotEditTarget::LANE_Y_MAX ||
@@ -3153,6 +3384,7 @@ namespace jdi
PaintHoverHighlight(g);
PaintRubberBand(g);
PaintCursor(g, m_lanes);
PaintEdgeCursor(g, m_lanes);
PaintSignalInteraction(g);
PaintHint(g);
@@ -3883,6 +4115,12 @@ namespace jdi
float cursorX = legendBand.X;
const float maxX = lane.rect.X + lane.rect.Width - kLanePadding * m_scale;
// 网格编号徽标(图例左侧):与左侧信号面板每行末尾的 “GX” 徽标样式完全一致。
const float badgeWidth = kLaneBadgeWidth * m_scale;
const float badgeHeight = kLaneBadgeHeight * m_scale;
const gd::RectF badgeRect(cursorX, top + (height - badgeHeight) * 0.5f, badgeWidth, badgeHeight);
cursorX = badgeRect.X + badgeRect.Width + kLaneBadgeGap * m_scale;
bool anyVisible = false;
int visibleCount = 0;
int drawnCount = 0;
@@ -3912,13 +4150,20 @@ namespace jdi
}
legendWidth += itemWidth;
}
if (legendWidth > 0.0f)
{
UiFillRoundRect(g, gd::RectF(cursorX - 4.0f * m_scale, top - 2.0f * m_scale,
legendWidth + 6.0f * m_scale, height + 4.0f * m_scale),
// 底板从编号徽标开始一直盖到最后一个图例条目(编号徽标一并被掠开底下的网格线)。
const float plateRight = cursorX + legendWidth + (legendWidth > 0.0f ? 2.0f * m_scale : 0.0f);
UiFillRoundRect(g, gd::RectF(legendBand.X - 4.0f * m_scale, top - 2.0f * m_scale,
plateRight - legendBand.X + 4.0f * m_scale, height + 4.0f * m_scale),
3.0f * m_scale, WithAlpha(m_theme->cardBg, 0xE8));
}
// 编号徽标绘制在底板之上(与信号面板的 GX 徽标使用同一套配色与圆角)。
UiFillRoundRect(g, badgeRect, badgeHeight * 0.5f, m_theme->cardBg);
UiDrawRoundRect(g, badgeRect, badgeHeight * 0.5f, m_theme->border, 1.0f);
UiText(g, L"G" + std::to_wstring(laneIndex + 1), m_fonts->monoSmall, badgeRect,
m_theme->text, TextAlign::CENTER);
// 记录本次绘制命中的条目矩形,供点击选中与双击改名使用。
for (auto iterator = m_legendHits.begin(); iterator != m_legendHits.end();)
{
@@ -4296,10 +4541,11 @@ namespace jdi
if (SampleFileAt(*resolved.file, *resolved.data, otherTime, interpolate, otherValue))
{
const double delta = value - otherValue;
// 增量 = 右侧光标取值 − 左侧光标取值,按差值显式给出正负号。
text += L" , ";
// 增量 = 右侧光标取值 − 左侧光标取值,包在圆括号里并按差值显式给出正负号。
text += L" (";
text += (delta < 0.0) ? L"-" : L"+";
text += FormatValue(std::fabs(delta));
text += L")";
}
}
@@ -4440,6 +4686,274 @@ namespace jdi
}
}
gd::RectF PlotView::EdgePanelRect(int laneIndex) const
{
if (m_tab == nullptr || laneIndex < 0 ||
laneIndex >= static_cast<int>(m_tab->Lanes().size()) ||
laneIndex >= static_cast<int>(m_lanes.size()))
{
return gd::RectF();
}
const Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
if (!lane.edgeCount || !lane.edgePanelOpen)
{
return gd::RectF();
}
int rowCount = 0;
for (const SignalRef& ref : lane.signals)
{
const ResolvedSignal resolved = ResolveSignal(ref);
if (resolved.data != nullptr && resolved.visible)
{
++rowCount;
}
}
if (rowCount <= 0)
{
return gd::RectF();
}
const float width = kEdgePanelWidth * m_scale;
const float titleHeight = kEdgePanelTitleHeight * m_scale;
const float rowHeight = kEdgePanelRowHeight * m_scale;
const float pad = kEdgePanelPad * m_scale;
const float height = titleHeight + pad + static_cast<float>(rowCount) * rowHeight * 2.0f + pad;
// 首次使用(位置为负)时自动放到绘图区右侧,并各网格依次错开,避免完全重叠。
float left = (lane.edgePanelX >= 0.0)
? static_cast<float>(lane.edgePanelX)
: (m_plotRect.X + m_plotRect.Width - width - 16.0f * m_scale);
float top = (lane.edgePanelY >= 0.0)
? static_cast<float>(lane.edgePanelY)
: (m_plotRect.Y + 16.0f * m_scale +
static_cast<float>(laneIndex) * 28.0f * m_scale);
// 面板始终限制在绘图区内(用户明确要求"可以在右侧的整个绘制区域拖动")。
left = std::max(m_plotRect.X + 2.0f * m_scale,
std::min(left, m_plotRect.X + m_plotRect.Width - width - 2.0f * m_scale));
top = std::max(m_plotRect.Y + 2.0f * m_scale,
std::min(top, m_plotRect.Y + m_plotRect.Height - height - 2.0f * m_scale));
return gd::RectF(left, top, width, height);
}
gd::RectF PlotView::EdgePanelCloseRect(int laneIndex) const
{
const gd::RectF rect = EdgePanelRect(laneIndex);
if (rect.Width <= 0.0f)
{
return gd::RectF();
}
const float size = kEdgePanelTitleHeight * m_scale;
return gd::RectF(rect.X + rect.Width - size, rect.Y, size, size);
}
bool PlotView::IsOnEdgeCursorLine(int x, int y, int& laneIndex) const
{
laneIndex = -1;
if (m_tab == nullptr)
{
return false;
}
const std::vector<Lane>& tabLanes = m_tab->Lanes();
const float fx = static_cast<float>(x);
const float fy = static_cast<float>(y);
const float tolerance = 5.0f * m_scale;
for (std::size_t index = 0; index < m_lanes.size() && index < tabLanes.size(); ++index)
{
if (!tabLanes[index].edgeCount)
{
continue;
}
const LaneVisual& visual = m_lanes[index];
if (fx < visual.rect.X || fx > visual.rect.X + visual.rect.Width)
{
continue;
}
const float clipTop = std::max(visual.rect.Y, m_plotRect.Y);
const float clipBottom =
std::min(visual.rect.Y + visual.rect.Height, m_plotRect.Y + m_plotRect.Height);
if (clipBottom <= clipTop)
{
continue;
}
const float lineY = std::max(
clipTop, std::min(ValueToY(tabLanes[index].edgeValue, visual), clipBottom));
if (std::fabs(fy - lineY) <= tolerance)
{
laneIndex = static_cast<int>(index);
return true;
}
}
return false;
}
void PlotView::PaintEdgeCursor(gd::Graphics& g, const std::vector<LaneVisual>& lanes)
{
if (m_tab == nullptr || m_dataSet == nullptr)
{
return;
}
std::vector<Lane>& tabLanes = m_tab->Lanes();
const std::size_t count = std::min(lanes.size(), tabLanes.size());
const std::uint32_t color = CursorColor(1);
const bool dark = IsDarkColor(color);
// ---- 横向光标(线与两端把手 + 左侧幅值读数)----
for (std::size_t index = 0; index < count; ++index)
{
Lane& tabLane = tabLanes[index];
if (!tabLane.edgeCount)
{
continue;
}
const LaneVisual& visual = lanes[index];
const float clipTop = std::max(visual.rect.Y, m_plotRect.Y);
const float clipBottom =
std::min(visual.rect.Y + visual.rect.Height, m_plotRect.Y + m_plotRect.Height);
if (clipBottom <= clipTop)
{
continue;
}
// 首次绘制时把横向光标落在可见幅值范围的中点。
if (!tabLane.edgeValueValid)
{
tabLane.edgeValue = (visual.yMin + visual.yMax) * 0.5;
tabLane.edgeValueValid = true;
}
const float lineY =
std::max(clipTop, std::min(ValueToY(tabLane.edgeValue, visual), clipBottom));
const float left = visual.rect.X;
const float right = visual.rect.X + visual.rect.Width;
// 横线与纵向光标同色同宽;两端各画一个尖端朝内的三角把手。
UiDrawLine(g, left, lineY, right, lineY, color, 1.4f * m_scale);
const float arm = 5.0f * m_scale;
gd::SolidBrush handle(ToGdiColor(color));
gd::PointF leftHandle[3];
leftHandle[0] = gd::PointF(left, lineY - arm);
leftHandle[1] = gd::PointF(left, lineY + arm);
leftHandle[2] = gd::PointF(left + arm * 1.4f, lineY);
g.FillPolygon(&handle, leftHandle, 3);
gd::PointF rightHandle[3];
rightHandle[0] = gd::PointF(right, lineY - arm);
rightHandle[1] = gd::PointF(right, lineY + arm);
rightHandle[2] = gd::PointF(right - arm * 1.4f, lineY);
g.FillPolygon(&handle, rightHandle, 3);
// 幅值读数:靠网格左侧、纵向居中于横线(与纵向光标的时间读数同款胶囊)。
const std::wstring text = FormatValue(tabLane.edgeValue);
const float textWidth = std::max(
56.0f * m_scale, UiMeasureTextWidth(m_fonts->monoSmall, text) + 16.0f * m_scale);
const float textHeight = 18.0f * m_scale;
const float labelLeft = left + 3.0f * m_scale;
const float labelTop =
std::max(clipTop + 1.0f * m_scale, std::min(lineY - textHeight * 0.5f,
clipBottom - textHeight - 1.0f * m_scale));
const gd::RectF labelRect(labelLeft, labelTop, textWidth, textHeight);
UiFillRoundRect(g, labelRect, 3.0f * m_scale, WithAlpha(color, 0xE0));
DrawLabelOutline(g, labelRect, 3.0f * m_scale, dark);
UiText(g, text, m_fonts->monoSmall, labelRect, dark ? 0xFFFDF6E3u : 0xFF073642u,
TextAlign::CENTER);
}
// ---- 统计浮动面板(最后绘制,覆盖在波形之上)----
for (std::size_t index = 0; index < count; ++index)
{
const Lane& tabLane = tabLanes[index];
if (!tabLane.edgeCount || !tabLane.edgePanelOpen)
{
continue;
}
const gd::RectF panel = EdgePanelRect(static_cast<int>(index));
if (panel.Width <= 0.0f || panel.Height <= 0.0f)
{
continue;
}
const float titleHeight = kEdgePanelTitleHeight * m_scale;
const float rowHeight = kEdgePanelRowHeight * m_scale;
const float pad = kEdgePanelPad * m_scale;
// 面板主体 + 标题栏。
UiFillRoundRect(g, gd::RectF(panel.X + 1.0f * m_scale, panel.Y + 2.0f * m_scale,
panel.Width, panel.Height),
5.0f * m_scale, 0x14000000u);
UiFillRoundRect(g, panel, 5.0f * m_scale, WithAlpha(m_theme->cardBg, 0xF4));
UiDrawRoundRect(g, panel, 5.0f * m_scale, m_theme->borderStrong, 1.0f);
UiFillRoundRect(g, gd::RectF(panel.X, panel.Y, panel.Width, titleHeight), 5.0f * m_scale,
WithAlpha(m_theme->accent, 0x2A));
// 标题:与左侧信号面板一致的 “GX” 徽标。
const float badgeWidth = kLaneBadgeWidth * m_scale;
const float badgeHeight = kLaneBadgeHeight * m_scale;
const gd::RectF badgeRect(panel.X + pad, panel.Y + (titleHeight - badgeHeight) * 0.5f,
badgeWidth, badgeHeight);
UiFillRoundRect(g, badgeRect, badgeHeight * 0.5f, m_theme->cardBg);
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,
gd::RectF(badgeRect.X + badgeRect.Width + 8.0f * m_scale, panel.Y,
panel.Width - badgeRect.Width - 8.0f * m_scale - titleHeight,
titleHeight),
m_theme->textStrong, TextAlign::LEFT);
// 关闭按钮 ✕。
const gd::RectF closeRect = EdgePanelCloseRect(static_cast<int>(index));
const bool closeHot =
(m_dragMode != PlotDragMode::EDGE_PANEL_MOVE) &&
UiPointInRect(closeRect, static_cast<float>(m_mouseX), static_cast<float>(m_mouseY));
if (closeHot)
{
UiFillRoundRect(g, gd::RectF(closeRect.X + 3.0f * m_scale, closeRect.Y + 3.0f * m_scale,
closeRect.Width - 6.0f * m_scale,
closeRect.Height - 6.0f * m_scale),
3.0f * m_scale, WithAlpha(m_theme->danger, 0x33));
}
const float crossPad = 8.0f * m_scale;
const std::uint32_t crossColor = closeHot ? m_theme->danger : m_theme->textMuted;
UiDrawLine(g, closeRect.X + crossPad, closeRect.Y + crossPad,
closeRect.X + closeRect.Width - crossPad, closeRect.Y + closeRect.Height - crossPad,
crossColor, 1.6f * m_scale);
UiDrawLine(g, closeRect.X + closeRect.Width - crossPad, closeRect.Y + crossPad,
closeRect.X + crossPad, closeRect.Y + closeRect.Height - crossPad, crossColor,
1.6f * m_scale);
// 每个信号两行:名称(信号色)+ 上升沿/下降沿数量。
float rowY = panel.Y + titleHeight + pad;
for (const SignalRef& ref : tabLane.signals)
{
const ResolvedSignal resolved = ResolveSignal(ref);
if (resolved.data == nullptr || resolved.style == nullptr || !resolved.visible)
{
continue;
}
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,
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);
const std::wstring counts = L"上升沿 " + std::to_wstring(rising) + L" 下降沿 " +
std::to_wstring(falling);
UiText(g, counts, m_fonts->monoSmall,
gd::RectF(panel.X + pad, rowY + rowHeight, textWidth, rowHeight),
m_theme->textMuted, TextAlign::LEFT);
rowY += rowHeight * 2.0f;
}
}
}
void PlotView::PaintEditField(gd::Graphics& g)
{
const gd::RectF fieldRect = EditFieldRect();