修复自适应/首帧卡顿回归 + 派生信号烘值 + 图例交互与托底重做

一、性能(详见 agent_mem §9.19)
- AutoRangeLaneToView:把 DataSet::Style(ref) 从逐采样循环里提出来(远端新增的「调整」
  功能让它每个采样点解析两次,实测 147ms 劣化到 1200ms),并在「视界覆盖整条信号且
  无任何数值变换」时直接取信号已缓存的 min/max(O(1))。工具栏「Y 轴自适应」在全视图下
  1200ms → 0.02ms,结果与逐点扫描逐位一致;有变换时仍逐点套用与绘制相同的变换。
- 新增 WarmLaneLayersStep():加载后宽基底图层从未分配,用户第一次平移/选区放大时
  Paint 会在那一帧同步重建全部网格图层(首帧 64.3ms)。现在改为在「命中静态场景缓存」
  (视界未变、非拖拽)时用既有预算机制每帧至多补 1 个网格,把整批重建摊到静止帧;
  选区放大首帧 64.3ms → 13.0ms。

二、派生信号
- 「构建为新信号」把源信号的「调整」变换(系数 → 绝对值 → 垂直镜像 → Y 偏移,以及
  水平镜像=数据反序)烘进派生采样值,新信号样式一律保持默认(不再是"继承的指纹");
  仅时间轴偏移 offsetTime 因派生信号与源信号共用同一个文件时间轴仍需继承。

三、交互
- 网格 GX 拖拽让位判据改为「鼠标指针」:指针越过相邻网格的中心水平线即让位并出现吸附
  提示(原先按"被拖网格的中心",要求把整个网格拖过去一格才吸附)。
- GX 徽标悬停高亮:徽标本体在装饰缓存里被冻结,高亮改为贴图之后的叠加层,并新增
  m_hoverBadge 状态驱动重绘/取消;QueryCursor 在徽标上返回 IDC_HAND —— 与左侧信号列表
  里同一个 GX 徽标一致。
- 修复「中键平移过视界之后图例悬停/点击/双击改名全部失效」:整幅重绘路径会清空
  m_legendHits,而装饰缓存命中时 PaintLaneLegend 提前 return 导致永不重建。现在把该函数
  拆成「几何 → 命中/装饰矩形 → 绘制」三段,任何路径都先重建命中矩形。

四、绘制
- 每个信号图例单独加圆角托底(配色/圆角风格与 GX 徽标一致);装饰矩形改为逐块
  (徽标一块 + 每个条目一块 + 每个 Y 轴胶囊一块),不再覆盖整条图例带;每块托底先用
  不透明底色铺满「外接整数像素再外扩 1px」的矩形再画圆角 —— 消除「GX/图例周边旧波形
  像素残留」(逐像素验证:装饰矩形内 0 差异)。
- 图例悬停高亮改为不透明托底并在其上重绘色块与文字(否则不透明托底会把图例内容盖住),
  同时保留对应信号的曲线光晕;Y 轴刻度胶囊补不透明外接底色,消除同类残留。

验证:离屏 Application harness(窗口 SW_HIDE + 直接注入鼠标消息 + 从后备缓冲区截图,
不占用鼠标/键盘/屏幕)40/40 通过;zoomcheck 新增 mode 20-26 量化;make release 零警告。
This commit is contained in:
DeepSeek V4.1 Flash committed 2026-10-14 01:55:34 +08:00
1 parent 940b8e67e6
commit 7c32eca004
4 files changed
+590 -183

No files matched your search

+375 -160
View File
@@ -80,6 +80,7 @@ namespace jdi
const float kLegendSwatchSize = 8.0f; /**< 图例色块尺寸。 */
const float kLegendItemGap = 14.0f; /**< 图例条目之间的间距。 */
const float kLegendMaxTextWidth = 200.0f; /**< 图例条目的文本宽度上限(超出则省略)。 */
const float kLegendPlateRadius = 4.0f; /**< 图例条目托底的圆角半径(逻辑像素)。 */
const float kLaneBadgeWidth = 30.0f; /**< 网格编号徽标宽度(与信号面板保持一致)。 */
const float kLaneBadgeHeight = 17.0f; /**< 网格编号徽标高度。 */
const float kLaneBadgeGap = 8.0f; /**< 编号徽标与图例条目之间的间距。 */
@@ -3162,6 +3163,56 @@ namespace jdi
}
}
// ⚠️ **性能:把样式指针提到循环之外**。`DataSet::Style(ref)` 每次调用都要做
// 指针解析与边界检查;放在循环体里等于**每个采样点解析两次**
//(实测全段自适应因此从 147ms 劣化到 1200ms —— 8 倍回归)。
const SignalStyle* resolvedStyle = style; // 可能为 nullptr(视为无变换)
const bool mirrored = (resolvedStyle != nullptr) && resolvedStyle->mirrorHorizontal;
const bool transform = (resolvedStyle != nullptr) &&
(resolvedStyle->gain != 1.0f || resolvedStyle->absolute ||
resolvedStyle->mirrorVertical || resolvedStyle->offsetY != 0.0f);
// ⚠️ **整段自适应直接取信号已缓存的 min/max**(O(1)):
// 当视界覆盖整条信号、且该信号没有任何数值变换时,逐点扫描的结果与
// `ComputeStats` 预存的 min/max 完全一致。工具栏「Y 轴自适应」开关在全视图下
// 会一次性对全部网格做这件事 —— 逐点扫描 2580 万 × 5 是秒级卡顿(实测 1200ms),
// 这里改为 O(1)。
if (file->timeMonotonic && begin == 0 && end == count && !mirrored && !transform &&
signal->finiteCount > 0)
{
low = std::min(low, signal->minValue);
high = std::max(high, signal->maxValue);
continue;
}
if (!mirrored && !transform)
{
// 无变换:与既有实现完全一致的紧凑循环(无逐点分支,便于向量化)。
for (std::size_t index = begin; index < end; ++index)
{
if (!file->timeMonotonic)
{
const double t = file->time[index];
if (!std::isfinite(t) || t < sourceMin || t > sourceMax)
{
continue;
}
}
const double value = signal->values[index];
if (!std::isfinite(value))
{
continue;
}
low = std::min(low, value);
high = std::max(high, value);
}
continue;
}
// 有变换(系数 / 绝对值 / 镜像 / Y 偏移)或水平镜像:逐点套用与绘制**相同**的变换。
// ⚠️ 自适应必须基于**画出来的曲线**,否则调整系数/镜像后自适应范围会按原始数据算错
//(用户报告"自适应区域错误,看上去还是按照原始数据来的")。
// 水平镜像时画面上的第 index 个位置取源下标 count-1-index 的值。
for (std::size_t index = begin; index < end; ++index)
{
if (!file->timeMonotonic)
@@ -3172,17 +3223,18 @@ namespace jdi
continue;
}
}
// ⚠️ 自适应必须基于**画出来的曲线**:套用与绘制完全相同的变换
//(系数 → 绝对值 → 镜像),否则调整系数/镜像后自适应范围会按原始数据算错
//(用户报告"自适应区域错误,看上去还是按照原始数据来的")。
// 水平镜像时画面上的第 index 个位置取源下标 count-1-index 的值。
const std::size_t sourceIndex = SourceIndexFor(*m_dataSet->Style(ref), count, index);
const std::size_t sourceIndex =
(mirrored && count > 0) ? ((count - 1) - index) : index;
if (sourceIndex >= count)
{
continue;
}
const double raw = signal->values[sourceIndex];
if (!std::isfinite(raw))
{
continue;
}
const double value = ApplyDrawTransform(*m_dataSet->Style(ref), raw);
const double value = transform ? ApplyDrawTransform(*resolvedStyle, raw) : raw;
if (std::isfinite(value))
{
low = std::min(low, value);
@@ -4056,8 +4108,13 @@ namespace jdi
}
}
// 图例中的 GX 编号徽标**悬停时不改变光标样式**(与其它可拖拽区域保持一致);
// 只有真正按下拖拽时才切为上下箭头(见 LANE_REORDER 分支)。
// 图例中的「GX」编号徽标:悬停时返回**手型** —— 与左侧信号列表里同一个 GX 徽标的
// 悬停效果完全一致(用户明确要求"参考信号选区的信号名后方的 GX 标签");
// 真正按下拖拽时再切为上下箭头(见 LANE_REORDER 分支)。
if (LaneBadgeAtPoint(x, y) >= 0)
{
return LoadCursorW(nullptr, IDC_HAND);
}
// 光标时间读数标签与双光标差值标签:**悬停(未按下)时不改变鼠标形状**。
// 两个标签都叠在光标线的正上方,因此必须先于"光标线"判定返回 nullptr,
@@ -4454,6 +4511,25 @@ namespace jdi
changed = true;
}
// 「GX」编号徽标的悬停高亮:徽标本体在静态场景 / 装饰缓存里被冻结,高亮是叠加层,
// 因此必须靠这个**单独的状态**触发重绘与取消(鼠标在同一个网格的徽标范围内移动时
// `m_hoverLane` 不变)。
int hoverBadge = -1;
if (m_dragMode == PlotDragMode::NONE && !overEdgePanel && lane >= 0)
{
const gd::RectF badge = LaneBadgeRect(lane);
if (badge.Width > 0.0f &&
UiPointInRect(badge, static_cast<float>(x), static_cast<float>(y)))
{
hoverBadge = lane;
}
}
if (hoverBadge != m_hoverBadge)
{
m_hoverBadge = hoverBadge;
changed = true;
}
// ✕ 关闭按钮 / 等级与迭代次数滑块 / 底部按钮的悬停提示都直接依赖鼠标坐标,
// 但它们在面板内部移动时上面这些状态可能毫无变化(例如从同一网格的波形移到面板上),
// 于是返回值恒为 false → 宿主不重绘 → **悬停提示不会出现**。
@@ -5226,6 +5302,11 @@ namespace jdi
m_hoverLane = -1;
changed = true;
}
if (m_hoverBadge != -1)
{
m_hoverBadge = -1;
changed = true;
}
if (m_hoverSignal.IsValid())
{
m_hoverSignal = SignalRef();
@@ -5867,6 +5948,12 @@ namespace jdi
// 随鼠标变化的内容(悬停高亮、读数框、选区、光标、拖拽虚影、输入框)一律画在贴图之后。
if (SceneCacheMatches() && BlitSceneCache(g))
{
// ⚠️ **顺手做一次"空闲预热"**:图层不存在(刚加载 / 网格数量变化)或仍是旧跨度时,
// 在这里用既有预算机制**每帧至多补 1 个网格**。若不预热,用户第一次平移 / 选区放大
// 时,`Paint` 会在那一帧里**同步重建全部网格图层**(5 个网格实测 30~80ms)——
// 这正是"选区放大的第一帧特别卡"的根因(该帧其余工作只有约 16ms 的占位拉伸)。
// 命中静态场景缓存 ⇒ 本帧视图与缓存同源(视界没变),因此预热出来的图层不会白费。
WarmLaneLayersStep();
paintOverlays();
return;
}
@@ -6164,21 +6251,7 @@ namespace jdi
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)
if (m_tab == nullptr || m_lanes.empty())
{
return;
}
@@ -6186,6 +6259,55 @@ namespace jdi
const int saveState = g.Save();
g.SetClip(m_area, gd::CombineModeIntersect);
// ------------------------------------------------------------------
// (1) 网格编号徽标(GX)的悬停高亮。
// 徽标本体画在静态场景 / 装饰缓存里(不随鼠标变化),因此悬停时必须在贴图之后
// **把徽标重画一遍**(不透明底色 + 圆角 + 文字)—— 否则它在缓存里被冻结,
// 鼠标移上去看不到任何反馈(用户要求的"参考信号列表里 GX 徽标"的悬停提示)。
// ⚠️ 只在**未拖拽**时绘制:`LANE_REORDER` 期间网格连同徽标被整体平移贴图,
// 这里按静态位置重画会画到错误的地方。
// ------------------------------------------------------------------
if (m_dragMode == PlotDragMode::NONE && m_hoverBadge >= 0 &&
m_hoverBadge < static_cast<int>(m_lanes.size()))
{
const gd::RectF badge = LaneBadgeRect(m_hoverBadge);
if (badge.Width > 0.0f)
{
const float radius = kLaneBadgeHeight * m_scale * 0.5f;
// 不透明底色铺满外接矩形(与缓存里的画法一致,避免圆角之外残留旧像素)。
const gd::RectF base(std::floor(badge.X) - 1.0f, std::floor(badge.Y) - 1.0f,
std::ceil(badge.X + badge.Width) - std::floor(badge.X) + 2.0f,
std::ceil(badge.Y + badge.Height) - std::floor(badge.Y) + 2.0f);
UiFillRect(g, base, m_theme->cardBg | 0xFF000000u);
UiFillRoundRect(g, badge, radius, m_theme->accentSoft);
UiDrawRoundRect(g, badge, radius, m_theme->accent, 1.0f);
UiText(g, L"G" + std::to_wstring(m_hoverBadge + 1), m_fonts->monoSmall, badge,
m_theme->textStrong, TextAlign::CENTER);
}
}
// ------------------------------------------------------------------
// (2) 图例条目悬停:条目托底高亮 + **对应信号曲线**的光晕。
// ------------------------------------------------------------------
if (m_dragMode != PlotDragMode::NONE || !m_hoverLegend.IsValid())
{
g.Restore(saveState);
return;
}
const int laneIndex = m_tab->LaneOfSignal(m_hoverLegend);
if (laneIndex < 0 || laneIndex >= static_cast<int>(m_lanes.size()))
{
g.Restore(saveState);
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)
{
g.Restore(saveState);
return;
}
// 除了图例条目本身,还要把**对应的信号曲线**一并高亮:用户在网格图例上移动时
// 一眼就能确认这条条目对应哪条曲线(与"鼠标贴近曲线"时的光晕提示一致)。
const ResolvedSignal resolved = ResolveSignal(m_hoverLegend);
@@ -6198,11 +6320,17 @@ namespace jdi
}
}
// 条目托底高亮:**不透明**圆角矩形(避免半透明叠加出杂色),并在其上**原样重绘
// 色块与文字** —— 否则不透明托底会把图例内容整块盖住(只剩一个空色块)。
for (const LegendHit& hit : m_legendHits)
{
if (hit.ref == m_hoverLegend)
{
UiFillRoundRect(g, hit.rect, 3.0f * m_scale, WithAlpha(m_theme->accent, 0x38));
const float radius = kLegendPlateRadius * m_scale;
UiFillRoundRect(g, hit.rect, radius, m_theme->accentSoft);
UiDrawRoundRect(g, hit.rect, radius, m_theme->accent, 1.0f);
UiDrawSwatch(g, hit.swatch, hit.swatchColor, hit.swatchOpacity, 1.5f * m_scale, m_scale);
UiText(g, hit.label, m_fonts->uiSmall, hit.text, hit.textColor, TextAlign::LEFT);
break;
}
}
@@ -6754,10 +6882,17 @@ namespace jdi
return m_laneDragFrom;
}
// 以"被拖网格的中心"为判据:中心越过某条网格的中线即认为目标变为该位置。
const float centerY = static_cast<float>(mouseY) - m_laneDragGrabOffset + laneHeight * 0.5f;
// 判据 = **鼠标指针**落在哪个网格的哪一半:指针进入某网格的"下半"即把插入位置
// 定在它之后(`index` = 该网格下标 + 1)。
//
// ⚠️ 旧实现用"被拖网格的**中心**"(`mouseY - grabOffset + laneHeight/2`)做判据,
// 等价于要求把整个网格拖过去一格才会吸附 —— 例如只有两个网格时,把 G2 往上拖
// 必须一直拖到鼠标接近 G1 的最上沿才会让位(用户报告"要拖到另一个网格的位置附近
// 才有吸附提示")。改为与鼠标位置挂钩后:**鼠标越过 G1 的中心水平线,G1 立刻让位
// 并出现吸附提示**(依次类推对任意网格数成立)。
const float firstTop = m_lanes.front().rect.Y;
int index = static_cast<int>(std::floor((centerY - firstTop) / laneHeight + 0.5f));
const float mouse = static_cast<float>(mouseY);
int index = static_cast<int>(std::floor((mouse - firstTop) / laneHeight + 0.5f));
index = std::max(0, std::min(index, count));
return index;
}
@@ -7885,6 +8020,46 @@ namespace jdi
return ok;
}
void PlotView::WarmLaneLayersStep()
{
if (m_dragMode != PlotDragMode::NONE || m_lanes.empty() || m_fonts == nullptr)
{
return;
}
if (!(m_plotRect.Width > 8.0f) || !(Tab().ViewMax() - Tab().ViewMin() > 0.0))
{
return;
}
{
std::lock_guard<std::recursive_mutex> renderLock(m_renderMutex);
std::lock_guard<std::mutex> layerLock(m_layerMutex);
if (m_layerFront.size() > m_lanes.size())
{
// 网格变少:先把多出来的图层位图释放掉(`resize` 不会调用 `ReleaseLaneLayer`)。
for (std::size_t index = m_lanes.size(); index < m_layerFront.size(); ++index)
{
ReleaseLaneLayer(m_layerFront[index]);
}
}
if (m_layerFront.size() != m_lanes.size())
{
m_layerFront.resize(m_lanes.size());
}
}
// 登记"跨度 / 内容不一致"的网格。空闲时视界未变 ⇒ `RollWideBuffer` 只登记、不做滚动,
// 也不会走"位移超出画布 → 立刻整块重画"那条分支。
RollWideBuffer();
const bool morePending = RenderPendingLaneRebuilds(1);
if (morePending)
{
const HWND host = m_host;
if (host != nullptr)
{
PostMessageW(host, kMessagePlotDerivedReady, 0, 0);
}
}
}
void PlotView::RollWideBuffer()
{
const float plotWidth = m_plotRect.Width;
@@ -8715,7 +8890,21 @@ namespace jdi
{
// 胶囊底板**不透明**:装饰像素因此与时间视界无关,可以跨视界整块复用;
// 半透明底板会把旧视界的波形混进缓存像素,平移贴回时在底板内部留下残影。
UiFillRoundRects(g, pills, 3.0f * m_scale, m_theme->cardBg | 0xFF000000u);
// ⚠️ 先用不透明底色**铺满每个胶囊的外接矩形**(含圆角之外的几个像素),
// 再画圆角胶囊:否则圆角之外的波形像素会被装饰缓存烘进去,
// 平移贴回时在胶囊边缘留下一圈"旧波形残留"。
const std::uint32_t pillBase = m_theme->cardBg | 0xFF000000u;
for (const gd::RectF& pill : pills)
{
const gd::RectF outer(pill.X - 2.0f * m_scale, pill.Y - 1.0f * m_scale,
pill.Width + 4.0f * m_scale, pill.Height + 2.0f * m_scale);
UiFillRect(g,
gd::RectF(std::floor(outer.X) - 1.0f, std::floor(outer.Y) - 1.0f,
std::ceil(outer.X + outer.Width) - std::floor(outer.X) + 2.0f,
std::ceil(outer.Y + outer.Height) - std::floor(outer.Y) + 2.0f),
pillBase);
}
UiFillRoundRects(g, pills, 3.0f * m_scale, pillBase);
}
for (std::size_t index = 0; index < pills.size(); ++index)
{
@@ -8856,79 +9045,93 @@ namespace jdi
const float top = legendBand.Y;
const float height = kLegendHeight * m_scale;
float cursorX = legendBand.X;
const float swatchSize = kLegendSwatchSize * m_scale;
const float maxX = lane.rect.X + lane.rect.Width - kLanePadding * m_scale;
// 网格编号徽标(图例左侧):与左侧信号面板每行末尾的 “GX” 徽标样式完全一致。
// 矩形由 LaneBadgeRect 统一计算,绘制与命中测试因此永远一致。
const gd::RectF badgeRect = LaneBadgeRect(laneIndex);
const float badgeHeight = kLaneBadgeHeight * m_scale;
cursorX = badgeRect.X + badgeRect.Width + kLaneBadgeGap * m_scale;
float cursorX = badgeRect.X + badgeRect.Width + kLaneBadgeGap * m_scale;
bool anyVisible = false;
// 图例条目托底的内缩外扩量:托底矩形**同时就是命中矩形**(看到的与点得到的严格一致)。
const float platePadX = 3.0f * m_scale;
const float platePadY = 2.0f * m_scale;
// ------------------------------------------------------------------
// 1) 几何:算出"放得下"的条目及其矩形(**唯一来源**,命中 / 装饰 / 绘制共用)。
// ------------------------------------------------------------------
struct LegendItem
{
SignalRef ref;
float left = 0.0f; // 色块左缘
float width = 0.0f; // 条目内容宽度
float textWidth = 0.0f;
std::wstring label;
bool selected = false;
};
std::vector<LegendItem> items;
int visibleCount = 0;
int drawnCount = 0;
// 先测量图例总宽度:只用来确定装饰缓存矩形(不再绘制任何托底/底板)。
float legendWidth = 0.0f;
for (const SignalRef& ref : laneSignals)
float contentRight = cursorX;
{
const ResolvedSignal resolved = ResolveSignal(ref);
if (resolved.data == nullptr || resolved.style == nullptr || !resolved.visible)
float probeX = cursorX;
for (const SignalRef& ref : laneSignals)
{
continue;
const ResolvedSignal resolved = ResolveSignal(ref);
if (resolved.data == nullptr || resolved.style == nullptr || !resolved.visible)
{
continue;
}
++visibleCount;
std::wstring label = BuildLegendText(*resolved.data);
if (multiFile && resolved.file != nullptr)
{
label += L" · " + resolved.file->Title();
}
const float maxTextWidth = kLegendMaxTextWidth * m_scale;
const float fullTextWidth = UiMeasureTextWidth(m_fonts->uiSmall, label);
const float textWidth = std::min(fullTextWidth, maxTextWidth);
const float itemWidth = swatchSize + 5.0f * m_scale + textWidth + kLegendItemGap * m_scale;
if (probeX + itemWidth > maxX)
{
break; // 空间不足:其余条目省略,下方用 "+N" 提示
}
LegendItem item;
item.ref = ref;
item.left = probeX;
item.width = itemWidth;
item.textWidth = textWidth;
item.label = (fullTextWidth > maxTextWidth)
? UiEllipsize(label, m_fonts->uiSmall, maxTextWidth)
: label;
item.selected = (SelectedRef() == ref);
items.push_back(std::move(item));
probeX += itemWidth;
}
std::wstring label = BuildLegendText(*resolved.data);
if (multiFile && resolved.file != nullptr)
{
label += L" · " + resolved.file->Title();
}
const float itemWidth = kLegendSwatchSize * m_scale + 5.0f * m_scale +
std::min(UiMeasureTextWidth(m_fonts->uiSmall, label),
kLegendMaxTextWidth * m_scale) +
kLegendItemGap * m_scale;
if (cursorX + legendWidth + itemWidth > maxX)
{
break;
}
legendWidth += itemWidth;
contentRight = probeX;
}
const auto PlateRect = [&](const LegendItem& item)
{
// 底板范围(编号徽标 + 图例条目):这里**只记录装饰矩形**用于缓存贴回。
const float plateRight = cursorX + legendWidth + (legendWidth > 0.0f ? 2.0f * m_scale : 0.0f);
{
const float left = std::min(badgeRect.X, legendBand.X - 4.0f * m_scale) - 2.0f * m_scale;
const float right = std::max(badgeRect.X + badgeRect.Width, plateRight) + 2.0f * m_scale;
m_decorRects.push_back(gd::RectF(left, top - 4.0f * m_scale, right - left,
height + 8.0f * m_scale));
}
// ⚠️ **不再绘制不透明托底**(用户要求去掉):原来的底板会把徽标与图例下方的
// 网格线整块擦掉,使这一条看起来像一个"可点击的控件",而它右侧的空白区域
// 实际并不响应点击(选区放大模式下会变成开始框选)。现在图例与徽标直接叠在
// 网格线之上,命中范围与看到的控件一一对应。
//
// 命中装饰缓存:整块图例(徽标 + 色块 + 文字)改由装饰层贴回,跳过重绘。
if (m_useDecorCache)
{
return;
}
}
return gd::RectF(item.left - platePadX, top - platePadY, item.width + platePadX * 2.0f,
height + platePadY * 2.0f);
};
// 不透明底色矩形:把托底外接的**整数像素**再外扩 1px。装饰缓存的捕获边界由
// `floor/ceil` 得出,必须完全落在这块不透明填充之内,否则边缘会残留旧波形像素。
const auto OpaqueBaseRect = [](const gd::RectF& rect)
{
const float x0 = std::floor(rect.X) - 1.0f;
const float y0 = std::floor(rect.Y) - 1.0f;
const float x1 = std::ceil(rect.X + rect.Width) + 1.0f;
const float y1 = std::ceil(rect.Y + rect.Height) + 1.0f;
return gd::RectF(x0, y0, x1 - x0, y1 - y0);
};
// 编号徽标绘制在底板之上(与信号面板的 GX 徽标使用同一套配色与圆角)。
// 拖拽重排时把被拖网格的徽标高亮,给出明确的"已抓住"反馈。
const bool badgeActive = (m_dragMode == PlotDragMode::LANE_REORDER && m_laneDragFrom == laneIndex);
const bool badgeHover = (m_dragMode == PlotDragMode::NONE && m_hoverLane == laneIndex &&
UiPointInRect(badgeRect, static_cast<float>(m_mouseX),
static_cast<float>(m_mouseY)));
UiFillRoundRect(g, badgeRect, badgeHeight * 0.5f,
(badgeActive || badgeHover) ? WithAlpha(m_theme->accent, 0x4A) : m_theme->cardBg);
UiDrawRoundRect(g, badgeRect, badgeHeight * 0.5f,
(badgeActive || badgeHover) ? m_theme->accent : m_theme->border, 1.0f);
UiText(g, L"G" + std::to_wstring(laneIndex + 1), m_fonts->monoSmall, badgeRect,
m_theme->text, TextAlign::CENTER);
// 记录本次绘制命中的条目矩形,供点击选中与双击改名使用。
// ------------------------------------------------------------------
// 2) 命中矩形:**无论是否命中装饰缓存都必须重建**。
// 整幅重绘路径会先清空 `m_legendHits`;若这里因为"装饰缓存命中"而提前返回、
// 不写入新矩形,图例的悬停高亮 / 点击选中 / 双击改名就会**全部失效**
//(用户报告:"用中键移动过视角之后图例点不动、悬停也没反应")。
// ------------------------------------------------------------------
for (auto iterator = m_legendHits.begin(); iterator != m_legendHits.end();)
{
if (m_tab->LaneOfSignal(iterator->ref) == laneIndex)
@@ -8940,93 +9143,105 @@ namespace jdi
++iterator;
}
}
// 绘制图例条目(与测量阶段保持一致的裁剪条件)。
float drawnWidth = 0.0f;
for (const SignalRef& ref : laneSignals)
for (const LegendItem& item : items)
{
const ResolvedSignal resolved = ResolveSignal(ref);
if (resolved.data == nullptr || resolved.style == nullptr || !resolved.visible)
{
continue;
}
anyVisible = true;
++visibleCount;
std::wstring label = BuildLegendText(*resolved.data);
if (multiFile && resolved.file != nullptr)
{
label += L" · " + resolved.file->Title();
}
// 文本宽度按上限夹取,超出部分用省略号(与测量阶段保持一致)。
const float maxTextWidth = kLegendMaxTextWidth * m_scale;
const float fullTextWidth = UiMeasureTextWidth(m_fonts->uiSmall, label);
const float textWidth = std::min(fullTextWidth, maxTextWidth);
const std::wstring drawLabel =
(fullTextWidth > maxTextWidth) ? UiEllipsize(label, m_fonts->uiSmall, maxTextWidth) : label;
const float itemWidth = kLegendSwatchSize * m_scale + 5.0f * m_scale + textWidth +
kLegendItemGap * m_scale;
if (drawnWidth + itemWidth > legendWidth + 0.5f)
{
break;
}
drawnWidth += itemWidth;
const float itemLeft = cursorX;
const float swatchSize = kLegendSwatchSize * m_scale;
// 选中态(与左侧列表呼应)绘制在静态场景里:选中信号参与场景指纹,
// 因此选中变化会让缓存失效并重绘。**悬停半高亮不在此处**(随鼠标变化,
// 画在缓存贴图之后,见 PaintLegendHighlight)。
const bool selected = (SelectedRef() == ref);
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, m_theme->selection);
}
UiDrawSwatch(g, gd::RectF(cursorX, top + (height - swatchSize) * 0.5f, swatchSize, swatchSize),
resolved.style->color, resolved.style->opacity, 1.5f * m_scale, m_scale);
// 改名编辑态:在原位置绘制输入框。
const ResolvedSignal resolved = ResolveSignal(item.ref);
LegendHit hit;
hit.ref = ref;
hit.rect = gd::RectF(itemLeft - 3.0f * m_scale, top - 2.0f * m_scale,
itemWidth + 2.0f * m_scale, height + 4.0f * m_scale);
m_legendHits.push_back(hit);
if (m_editTarget == PlotEditTarget::SIGNAL_NAME && m_editSignal == ref)
hit.ref = item.ref;
hit.rect = PlateRect(item);
hit.swatch =
gd::RectF(item.left, top + (height - swatchSize) * 0.5f, swatchSize, swatchSize);
hit.text = gd::RectF(item.left + swatchSize + 5.0f * m_scale, top,
std::max(8.0f, item.textWidth + 2.0f), height);
hit.label = item.label;
hit.textColor = item.selected ? m_theme->textStrong : m_theme->text;
if (resolved.style != nullptr)
{
m_editFieldRect = hit.rect;
UiDrawEditField(g, hit.rect, m_edit, m_fonts->uiSmall, m_scale);
cursorX += itemWidth;
++drawnCount;
continue;
hit.swatchColor = resolved.style->color;
hit.swatchOpacity = resolved.style->opacity;
}
m_legendHits.push_back(std::move(hit));
if (m_editTarget == PlotEditTarget::SIGNAL_NAME && m_editSignal == item.ref)
{
m_editFieldRect = PlateRect(item);
}
cursorX += swatchSize + 5.0f * m_scale;
UiText(g, drawLabel, m_fonts->uiSmall,
gd::RectF(cursorX, top, std::max(8.0f, textWidth + 2.0f), height),
selected ? m_theme->textStrong : m_theme->text, TextAlign::LEFT);
cursorX += textWidth + kLegendItemGap * m_scale;
++drawnCount;
}
if (!anyVisible)
// 装饰矩形:**逐块托底**(徽标一块 + 每个条目一块),而不是覆盖整条图例带 ——
// 覆盖整条带会把中间的波形像素也烘进缓存,平移贴回时露出"旧波形的像素残留"。
m_decorRects.push_back(badgeRect);
for (const LegendItem& item : items)
{
m_decorRects.push_back(PlateRect(item));
}
// 命中装饰缓存:徽标与图例的像素改由装饰层贴回,跳过重绘(命中矩形已在上方重建)。
if (m_useDecorCache)
{
return;
}
// ------------------------------------------------------------------
// 3) 绘制。⚠️ 每块托底都先用**不透明底色铺满外接矩形**再画圆角:
// 圆角之外的那几个像素若仍是波形,会被装饰缓存烘进去,平移贴回时表现为
// "徽标 / 图例边缘残留旧波形"。底色 = 网格底色,铺满外接矩形在视觉上不可见。
// ⚠️ 徽标与条目的**悬停 / 激活高亮不在这里**(随鼠标变化,画在贴图之后,
// 见 PaintLegendHighlight)—— 否则会被缓存冻结,悬停时看不到任何反馈。
// ------------------------------------------------------------------
{
UiFillRect(g, OpaqueBaseRect(badgeRect), m_theme->cardBg | 0xFF000000u);
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 (const LegendItem& item : items)
{
const ResolvedSignal resolved = ResolveSignal(item.ref);
if (resolved.data == nullptr || resolved.style == nullptr)
{
continue;
}
const gd::RectF plate = PlateRect(item);
UiFillRect(g, OpaqueBaseRect(plate), m_theme->cardBg | 0xFF000000u);
UiFillRoundRect(g, plate, kLegendPlateRadius * m_scale,
item.selected ? m_theme->selection : m_theme->cardBg);
UiDrawRoundRect(g, plate, kLegendPlateRadius * m_scale,
item.selected ? m_theme->accent : m_theme->border, 1.0f);
// 改名编辑态:整块托底变成输入框(与左侧列表双击改名一致)。
if (m_editTarget == PlotEditTarget::SIGNAL_NAME && m_editSignal == item.ref)
{
UiDrawEditField(g, plate, m_edit, m_fonts->uiSmall, m_scale);
continue;
}
UiDrawSwatch(g,
gd::RectF(item.left, top + (height - swatchSize) * 0.5f, swatchSize, swatchSize),
resolved.style->color, resolved.style->opacity, 1.5f * m_scale, m_scale);
UiText(g, item.label, m_fonts->uiSmall,
gd::RectF(item.left + swatchSize + 5.0f * m_scale, top,
std::max(8.0f, item.textWidth + 2.0f), height),
item.selected ? m_theme->textStrong : m_theme->text, TextAlign::LEFT);
}
}
if (visibleCount == 0)
{
UiText(g, L"(无可见信号)", m_fonts->uiSmall,
gd::RectF(legendBand.X, top, lane.rect.Width, height), m_theme->textMuted,
TextAlign::LEFT);
}
else if (drawnCount < visibleCount)
else if (static_cast<int>(items.size()) < visibleCount)
{
// 图例空间不足时提示剩余信号数量,避免误认为信号丢失。
const std::wstring moreText = L"+" + std::to_wstring(visibleCount - drawnCount);
if (cursorX + 20.0f * m_scale <= maxX)
const std::wstring moreText =
L"+" + std::to_wstring(visibleCount - static_cast<int>(items.size()));
if (contentRight + 20.0f * m_scale <= maxX)
{
UiText(g, moreText, m_fonts->uiSmall, gd::RectF(cursorX, top, 24.0f * m_scale, height),
m_theme->textMuted, TextAlign::LEFT);
UiText(g, moreText, m_fonts->uiSmall,
gd::RectF(contentRight, top, 24.0f * m_scale, height), m_theme->textMuted,
TextAlign::LEFT);
}
}
}