修复拆分网格误继承实验性功能 / 阶跃线宽 / 面板拖拽残影 / 自由标记卡顿与交互

- 拆分网格:实验性功能只由原网格的首个信号继承(LaneCarry::owner);
  SplitLane 只让第一份继承;新增 PruneLaneSignalItems 按 lane.signals 过滤四类
  逐信号条目(修复"新网格出现别人的固定点、面板里却查不到")
- 阶跃信号:包络带横向膨胀半径 ceil -> floor(1px 线宽不再把竖直沿撑成 3px)
- 浮动面板:面板位置移出 PanelGlobalKey(拖动单个面板不再重画全部面板),
  新增 vacatedRects 让"上一帧被移动/缩小/关闭的面板腾出的区域"也强制重画
- 自由标记:新增 FreeMarkerContents 内容缓存(键不含视界),大量固定点时拖光标/平移不再逐帧采样;
  透明度滑块与光标模式解耦(始终显示);悬停胶囊恢复不透明(瞄着被遮住的数据点时除外);
  胶囊与面板五列按实测文本撑开,不再省略
- 面板交互:折起指示器与信号列表组名同款三角;GX 徽标/标题栏悬停不改光标、移除徽标气泡;
  标题栏双击滚动到该网格(单击不触发,双击不再折起)
- 验证:新增离屏 .preflight/fixcheck 37/37(三处 A/B 均能如实失败);
  回归 round3check 31/31、round4check 34/34、round5check 5/5、editcheck 20/20、convcheck 18/18;
  make release/debug 零警告
This commit is contained in:
DeepSeek V4.1 Flash committed 2026-10-21 17:21:09 +08:00
1 parent 3400ac7c29
commit 8f735d51be
8 files changed
+654 -198

No files matched your search

+43 -11
View File
@@ -1836,6 +1836,46 @@ namespace jdi
gd::RectF close;
};
/**
* @brief 「自由标记」某个固定点的**内容**(取值与各种文本)。
*
* 这些值与**视界无关**(只取决于采样数据与信号样式),因此单独缓存:
* 平移视界、拖动时间光标时不需要重新采样/重新格式化。
* 面板的五列与网格内的取值胶囊共用这一份内容。
*/
struct FreeMarkerContentEntry
{
std::size_t itemIndex = 0; /**< `Lane::freeMarkerItems` 下标。 */
bool valid = false; /**< 取值是否有效(无效时全部文本为 `N/A`)。 */
double value = 0.0; /**< 绘制用取值(用于算胶囊/圆点的纵向位置)。 */
std::wstring capsuleText; /**< 网格内取值胶囊的文本(`值(±增量) = 枚举`)。 */
std::wstring valueText; /**< 面板:取值列。 */
std::wstring enumText; /**< 面板:枚举列(可能是空串)。 */
std::wstring deltaText; /**< 面板:增量列。 */
std::wstring timeText; /**< 面板:时刻列。 */
std::wstring deltaTimeText; /**< 面板:间隔(Δt)列。 */
std::uint32_t color = 0; /**< 信号色(信号样式里的颜色,已乘不透明度)。 */
};
/** 一个网格的「自由标记」内容缓存(`key` 只含与视界无关的输入)。 */
struct FreeMarkerContentCache
{
std::uint64_t key = 0;
std::vector<FreeMarkerContentEntry> entries;
};
/**
* @brief 取某个网格的「自由标记」内容列表(必要时重建,**与视界无关**)。
*
* ⚠️ 性能硬约束:大量固定点时,逐帧重新 `SampleDisplayedSignalAt` + 拼字符串
* 是“拖动全局光标/移动视界变卡”的主因。内容只依赖数据与样式,因此缓存起来;
* 命中缓存时每帧只做几何与绘制。
*/
const std::vector<FreeMarkerContentEntry>& FreeMarkerContents(int laneIndex) const;
/** 按网格下标的「自由标记」内容缓存(`mutable`:只在绘制时按需重建)。 */
mutable std::vector<FreeMarkerContentCache> m_freeMarkerContent;
/**
* @brief 计算某个网格里「第 index 个固定点」的标注几何(绘制与命中共源)。
*
@@ -2407,15 +2447,6 @@ namespace jdi
*/
void PaintSignalInteraction(gd::Graphics& g);
/**
* @brief 绘制浮动面板「GX」徽标的悬停提示气泡(用户 2026-10-19 要求)。
*
* 气泡锚定在徽标下方,内容为“G 编号”与当前可执行的操
*(目标网格已在视界内 / 点击滚动到该网格)。
* ⚠️ 必须在**面板像素图层之后**绘制(见 `PaintEdgeCursor` 末尾),否则会被缓存进面板位图。
*/
void PaintPanelBadgeTooltip(gd::Graphics& g);
/**
* @brief 在绘图区底部绘制临时提示条(如“已放弃当前选区”)。
* @param [IN,OUT] g 绘图上下文。
@@ -2620,8 +2651,9 @@ namespace jdi
/**
* @brief 上一帧鼠标是否悬停在某个浮动面板标题栏的「GX」徽标上(是则记录网格下标 / 面板种类)。
*
* 徽标绘制在面板**像素图层**里(缓存键只在鼠标位于面板内时才混入坐标),
* 而提示气泡是贴图之后的叠加层(见 `PaintPanelBadgeTooltip`),因此需要单独记录状态。
* 徽标本体绘制在面板**像素图层**里(缓存键只在鼠标位于面板内时才混入坐标),
* 因此需要单独记录状态才能让高亮跟着鼠标进出而刷新。
* ⚠️ 用户 2026-10-21 要求:徽标悬停**不改变鼠标形状**、也**不弹提示气泡**。
*/
int m_hoverPanelBadgeLane = -1;
int m_hoverPanelBadgeKind = -1;
@@ -934,12 +934,13 @@ namespace jdi
// ✕ 按钮:与面板/标签栏的其它 ✕ 一致,用手型光标作为可点击提示。
return LoadCursorW(nullptr, IDC_HAND);
}
// 「GX」编号徽标:悬停给出提示 + 手型光标;点击把该网格滚动到视界内。
// ⚠️ 必须排在标题栏之前(徽标位于标题栏矩形内部,否则会被判成“可拖拽”)。
// 「GX」编号徽标:用户 2026-10-21 明确要求**悬停不改变鼠标形状**,
// 也不弹出“是否在视界内”的浮动提示(点击行为不变:滚动到该网格)。
// ⚠️ 必须提前 return(而不是落到下面的标题栏判定),否则会被当成“可拖拽”。
if (layout.badge.Width > 0.0f &&
UiPointInRect(layout.badge, static_cast<float>(x), static_cast<float>(y)))
{
return LoadCursorW(nullptr, IDC_HAND);
return nullptr;
}
// 「展开 / 折起」三角指示器:可点击控件 ⇒ 手型光标。
if (layout.disclosure.Width > 0.0f &&
@@ -949,7 +950,9 @@ namespace jdi
}
if (UiPointInRect(layout.title, static_cast<float>(x), static_cast<float>(y)))
{
return LoadCursorW(nullptr, IDC_SIZEALL);
// 标题栏:悬停**保持箭头**,只有按住左键拖动时(`EDGE_PANEL_MOVE`,
// 见本函数开头的拖拽分支)才变成十字箭头(用户 2026-10-21 要求)。
return nullptr;
}
if ((static_cast<EdgePanelKind>(panelKind) == EdgePanelKind::MEAN ||
static_cast<EdgePanelKind>(panelKind) == EdgePanelKind::PEAK) &&
@@ -2720,8 +2723,9 @@ namespace jdi
int panelKind = 0;
if (TopPanelAtPoint(static_cast<float>(x), static_cast<float>(y), panelLane, panelKind))
{
// 标题栏双击 = 展开 / 折起该面板(折起的面板只显示标题栏)。
// ⚠️ 折起状态下标题栏是唯一可见区域,因此这里也是“双击重新展开”的入口。
// 标题栏双击 = 把该面板所属网格**滚动到视界内**(与单击 GX 徽标完全同效)。
// 用户 2026-10-21 明确要求:**只有双击标题栏**才触发这个联动,单击标题栏不触发
//(单击标题栏只是准备拖拽面板);面板的折起/展开只由左侧三角指示器负责。
{
const EdgePanelLayout titleLayout =
EdgePanelLayoutFor(panelLane, static_cast<EdgePanelKind>(panelKind));
@@ -2737,7 +2741,11 @@ namespace jdi
UiPointInRect(titleLayout.disclosure, static_cast<float>(x),
static_cast<float>(y))))
{
ToggleLanePanelCollapsed(panelLane, static_cast<EdgePanelKind>(panelKind));
m_dragMode = PlotDragMode::NONE;
if (ScrollLaneIntoView(panelLane))
{
SetHint(L"正在滚动到 G" + std::to_wstring(panelLane + 1) + L" 网格…");
}
return true;
}
}
@@ -2884,7 +2892,18 @@ namespace jdi
int panelKind = 0;
if (TopPanelAtPoint(fx, fy, panelLane, panelKind))
{
return true;
// ⚠️ 例外:**标题栏**(排除徽标 / ✕ / 折起三角)双击有明确语义 ——
// 把该面板所属网格滚动到视界内(用户 2026-10-21 要求,与单击 GX 徽标一致)。
// 其余落点仍然只有“按下”语义。
const EdgePanelLayout layout =
EdgePanelLayoutFor(panelLane, static_cast<EdgePanelKind>(panelKind));
const bool onTitle = layout.title.Width > 0.0f &&
UiPointInRect(layout.title, fx, fy) &&
!(layout.badge.Width > 0.0f && UiPointInRect(layout.badge, fx, fy)) &&
!(layout.close.Width > 0.0f && UiPointInRect(layout.close, fx, fy)) &&
!(layout.disclosure.Width > 0.0f &&
UiPointInRect(layout.disclosure, fx, fy));
return !onTitle;
}
}
// 右下角「回到最上方」浮动按钮:按下只登记、抬起才生效 ⇒ 只有单击语义。
+7 -1
View File
@@ -1074,7 +1074,13 @@ namespace jdi
// 列间距由前两列推断(同一段内等宽等距);退化时按 1px 处理。
const float spacing = columns[1].x - columns[0].x;
const float step = (spacing > 0.01f) ? spacing : 1.0f;
std::size_t radius = static_cast<std::size_t>(std::ceil(halfWidth / step));
// ⚠️ **必须向下取整**(用户 2026-10-21 报告的阶跃信号线宽不一致):
// `ceil` 会让 1px 线宽(`halfWidth` ≈ 0.5–0.7 设备像素)也膨胀 1 列,
// 阶跃的竖直沿因此从 1 列变成 3 列(≈3px),而水平段只有 ~1.25px ⇒
// 视觉上“竖直跳变的线宽明显大于水平持续段”(放大到走逐点描边路径后又正常了)。
// `floor` 下 1px 线宽不膨胀(竖直沿 = 1 列),粗线(≥ 2 列宽)仍按整数列膨胀。
const std::size_t radius =
static_cast<std::size_t>(std::floor(halfWidth / step + 1e-4f));
if (radius > 0 && radius * 2 + 1 < count)
{
widened.resize(count);
+320 -136
View File
@@ -144,12 +144,42 @@ namespace jdi
if (kind == EdgePanelKind::MARK)
{
// 「自由标记」面板:**宽度用固定值**,不做逐行文本测量。
// ⚠️ `EdgePanelWidth` 被 `EdgePanelLayoutFor` 调用,而后者又在 `TopPanelAtPoint` /
// `SyncPanelOrder` 里被高频调用(每次鼠标移动数次)——若在这里逐行取采样值算文本宽度,
// 就变成"鼠标一动就做几十次采样查询",会明显拖慢交互。
// 取值的文本超长时由绘制侧的 `UiEllipsize` 截断即可。
width = std::max(width, kFreeMarkerPanelWidth * m_scale);
// 「自由标记」面板:宽度 = 五列(取「缺省列宽」与**实测内容宽度**的较大者)+ 行 ✕
// + 左右内边距 + **滚动条预留位**(内容超高时右侧要放滚动条)。
// ⚠️ 用户 2026-10-21 要求面板里的数据列**不得出现省略号**,因此不能再按固定列宽。
// 逐行取采样值算宽度会拖慢交互(本函数被 `TopPanelAtPoint` / `SyncPanelOrder`
// 高频调用),所以走 `Lane::freeMarkerColumnWidths`:绘制该面板时顺便量好真实
// 文本宽度存起来,这里只读。还没量过(全 0)时退回缺省列宽。
const bool hasEnum = [this, &lane]() {
for (const SignalRef& ref : lane.signals)
{
const ResolvedSignal resolved = ResolveSignal(ref);
if (resolved.data != nullptr && resolved.visible &&
!resolved.data->enumText.Empty() && lane.HasFreeMarkers(ref))
{
return true;
}
}
return false;
}();
float columnsWidth = 0.0f;
for (int column = 0; column < 5; ++column)
{
const bool omitted = (column == EdgePanelLayout::MARKER_COL_ENUM) && !hasEnum;
if (omitted)
{
continue;
}
columnsWidth += std::max(kFreeMarkerColumnWidths[column] * m_scale,
lane.freeMarkerColumnWidths[static_cast<std::size_t>(column)]);
columnsWidth += kFreeMarkerColumnGap * m_scale;
}
// 行尾 ✕(按胶囊同高的正方形取宽)+ 滚动条预留 + 少量余量。
const float rowCloseWidth = kFreeMarkerItemRowHeight * m_scale + 2.0f * m_scale;
const float markerWidth = kEdgePanelPad * 2.0f * m_scale +
(kFreeMarkerScrollWidth + 4.0f) * m_scale + columnsWidth +
rowCloseWidth;
width = std::max(width, std::max(kFreeMarkerPanelWidth * m_scale, markerWidth));
}
// 夹在绘图区宽度之内(过窄的网格也不至于把面板挤到外面)。
@@ -5441,8 +5471,8 @@ namespace jdi
const float groupRow = kFreeMarkerNameRowHeight * m_scale;
const float itemRow = kFreeMarkerItemRowHeight * m_scale;
const float markerButtonHeight = kEdgePanelButtonHeight * m_scale;
const float sliderRow =
lane.freeMarkerCursor ? (kFreeMarkerSliderRowHeight * m_scale) : 0.0f;
// 「标记透明度」滑块行**始终显示**(用户 2026-10-21 要求:不再依附于光标模式按钮)。
const float sliderRow = kFreeMarkerSliderRowHeight * m_scale;
float content = 0.0f;
std::vector<std::size_t> order;
for (const SignalRef& ref : lane.signals)
@@ -5735,16 +5765,17 @@ namespace jdi
const float itemRow = kFreeMarkerItemRowHeight * m_scale;
const float closeSize = kFreeMarkerCloseSize * m_scale;
const float markerButtonHeight = kEdgePanelButtonHeight * m_scale;
const float sliderRow = lane.freeMarkerCursor ? (kFreeMarkerSliderRowHeight * m_scale) : 0.0f;
// 「标记透明度」独立于光标模式按钮:**总是**显示在按钮上方。
const float sliderRow = kFreeMarkerSliderRowHeight * m_scale;
const float scrollW = kFreeMarkerScrollWidth * m_scale;
// 底部固定区:切换按钮(整行宽度、与面板下边界留一个 pad)。
layout.button = gd::RectF(left + pad, top + height - pad - markerButtonHeight,
std::max(0.0f, width - pad * 2.0f), markerButtonHeight);
if (sliderRow > 0.0f)
{
// 「标记透明度」行:与信号样式区的「透明度」行同一套排版
//(左侧标签 + 中间轨道 + 右侧百分比),只是位置固定在按钮上方。
//(左侧标签 + 中间轨道 + 右侧百分比),位置固定在按钮上方。
// ⚠️ 无论是否启用「光标选取模式」都显示(用户 2026-10-21 要求)。
const float rowTop = layout.button.Y - sliderRow;
const float valueWidth =
std::max(36.0f * m_scale,
@@ -5829,8 +5860,11 @@ namespace jdi
for (int column = 0; column < 5; ++column)
{
const bool omitted = (column == EdgePanelLayout::MARKER_COL_ENUM) && !hasEnum;
// ⚠️ 列宽取「缺省列宽」与**实测内容宽度**的较大者 ⇒ 面板里不再出现省略号。
const float columnWidth =
omitted ? 0.0f : (kFreeMarkerColumnWidths[column] * m_scale);
omitted ? 0.0f
: std::max(kFreeMarkerColumnWidths[column] * m_scale,
lane.freeMarkerColumnWidths[static_cast<std::size_t>(column)]);
layout.markerColumns[column].left = columnX;
layout.markerColumns[column].width = columnWidth;
columnX += columnWidth + (omitted ? 0.0f : kFreeMarkerColumnGap * m_scale);
@@ -6303,18 +6337,23 @@ namespace jdi
const std::vector<Lane>& lanes = m_tab->Lanes();
for (const Lane& lane : lanes)
{
mixFloat(static_cast<float>(lane.edgePanel.x));
mixFloat(static_cast<float>(lane.edgePanel.y));
mixFloat(static_cast<float>(lane.edgeCenterPanel.x));
mixFloat(static_cast<float>(lane.edgeCenterPanel.y));
mixFloat(static_cast<float>(lane.meanFilterPanel.x));
mixFloat(static_cast<float>(lane.meanFilterPanel.y));
mixFloat(static_cast<float>(lane.weakPeakPanel.x));
mixFloat(static_cast<float>(lane.weakPeakPanel.y));
// ⚠️⚠️ **面板的位置 / 尺寸绝不能进全局键**(用户 2026-10-21 报告“拖动面板时
// 下方另一个面板出现拖拽残影”):位置只影响该面板自己,已在 `PanelSlotKey`
// 里(它混合 `layout.panel/title/...`)。以前把 4 类面板的 x/y 混进全局键 ⇒
// 拖动任意面板都会让**所有**面板重画(15 个 ≈33ms/帧),而「自由标记」面板的
// 位置偏偏漏了没混 ⇒ 拖动它时其它面板照旧贴旧像素 → 正好在下方留残影。
// 现在:拖动面板只重画它自己;它**腾出的区域**由 `PaintEdgeCursor` 的
// `vacatedRects` 机制兜底(覆盖到该区域的面板必须重画)。
mix(lane.edgePanel.open ? 1ull : 0ull);
mix(lane.edgeCenterPanel.open ? 1ull : 0ull);
mix(lane.meanFilterPanel.open ? 1ull : 0ull);
mix(lane.weakPeakPanel.open ? 1ull : 0ull);
mix(lane.freeMarkerPanel.open ? 1ull : 0ull);
mix(lane.edgePanel.collapsed ? 1ull : 0ull);
mix(lane.edgeCenterPanel.collapsed ? 1ull : 0ull);
mix(lane.meanFilterPanel.collapsed ? 1ull : 0ull);
mix(lane.weakPeakPanel.collapsed ? 1ull : 0ull);
mix(lane.freeMarkerPanel.collapsed ? 1ull : 0ull);
mix(lane.edgeValueValid ? 1ull : 0ull);
mixDouble(lane.edgeValue);
}
@@ -6614,6 +6653,103 @@ namespace jdi
return true;
}
const std::vector<PlotView::FreeMarkerContentEntry>& PlotView::FreeMarkerContents(int laneIndex) const
{
static const std::vector<FreeMarkerContentEntry> kNoEntries;
if (m_tab == nullptr || laneIndex < 0 ||
laneIndex >= static_cast<int>(m_tab->Lanes().size()))
{
return kNoEntries;
}
const Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
if (static_cast<std::size_t>(laneIndex) >= m_freeMarkerContent.size())
{
m_freeMarkerContent.resize(static_cast<std::size_t>(laneIndex) + 1);
}
FreeMarkerContentCache& cache = m_freeMarkerContent[static_cast<std::size_t>(laneIndex)];
// 缓存键:只混入**与视界无关**的输入(条目、信号样式、数据地址与长度)。
// ⚠️ 绝不能混入 `ViewMin/ViewMax`、鼠标位置或网格几何 —— 否则平移视界、
// 拖动时间光标时缓存永远命中不了,又回到"每帧对每个固定点采样一次"。
std::uint64_t key = 1469598103934665603ull;
const auto mix = [&key](std::uint64_t value) {
key ^= value;
key *= 1099511628211ull;
};
const auto mixFloat = [&mix](float value) {
std::uint32_t bits = 0;
std::memcpy(&bits, &value, sizeof(bits));
mix(static_cast<std::uint64_t>(bits));
};
const auto mixDouble = [&mix](double value) {
std::uint64_t bits = 0;
std::memcpy(&bits, &value, sizeof(bits));
mix(bits);
};
mix(static_cast<std::uint64_t>(lane.freeMarkerItems.size()));
for (const Lane::FreeMarkerItem& item : lane.freeMarkerItems)
{
mix(static_cast<std::uint64_t>(static_cast<std::uint32_t>(item.ref.groupIndex)));
mix(static_cast<std::uint64_t>(static_cast<std::uint32_t>(item.ref.signalIndex)));
mixDouble(item.time);
const ResolvedSignal resolved = ResolveSignal(item.ref);
if (resolved.data == nullptr || resolved.style == nullptr)
{
mix(0ull);
continue;
}
mix(reinterpret_cast<std::uint64_t>(resolved.data));
mix(static_cast<std::uint64_t>(resolved.data->values.size()));
mix(resolved.visible ? 1ull : 2ull);
mix(static_cast<std::uint64_t>(resolved.style->color));
mixFloat(resolved.style->opacity);
mixFloat(resolved.style->gain);
mixFloat(resolved.style->offsetY);
mixDouble(resolved.style->offsetTime);
mix(static_cast<std::uint64_t>(static_cast<int>(resolved.style->mode)));
mix((resolved.style->absolute ? 1ull : 0ull) | (resolved.style->mirrorVertical ? 2ull : 0ull) |
(resolved.style->mirrorHorizontal ? 4ull : 0ull));
}
if (cache.key != key)
{
cache.key = key;
cache.entries.clear();
cache.entries.reserve(lane.freeMarkerItems.size());
for (std::size_t itemIndex = 0; itemIndex < lane.freeMarkerItems.size(); ++itemIndex)
{
const Lane::FreeMarkerItem& item = lane.freeMarkerItems[itemIndex];
FreeMarkerContentEntry entry;
entry.itemIndex = itemIndex;
double value = 0.0;
double enumValue = 0.0;
const bool valueOk = FreeMarkerValue(lane, item, value, enumValue);
bool hasPrev = false;
double delta = 0.0;
double timeDelta = 0.0;
if (valueOk)
{
FreeMarkerDelta(lane, itemIndex, value, hasPrev, delta, timeDelta);
}
const ResolvedSignal resolved = ResolveSignal(item.ref);
const std::wstring* enumPtr =
(resolved.data != nullptr) ? resolved.data->enumText.Find(enumValue) : nullptr;
entry.valid = valueOk;
entry.value = value;
entry.color = (resolved.style != nullptr) ? StyleColor(*resolved.style) : 0u;
entry.capsuleText = FreeMarkerValueText(value, hasPrev, delta, enumPtr);
entry.valueText = valueOk ? FormatValue(value) : std::wstring(kFreeMarkerNotAvailable);
entry.enumText = (enumPtr != nullptr) ? *enumPtr : std::wstring();
entry.deltaText = (valueOk && hasPrev) ? FormatSignedValue(delta)
: std::wstring(kFreeMarkerNotAvailable);
entry.timeText = FreeMarkerTimeText(item.time);
entry.deltaTimeText = FreeMarkerDeltaText(valueOk && hasPrev, timeDelta);
cache.entries.push_back(std::move(entry));
}
}
return cache.entries;
}
bool PlotView::FreeMarkerLabelRects(int laneIndex, std::size_t itemIndex, gd::RectF& label,
gd::RectF& close, std::wstring& text) const
{
@@ -6632,16 +6768,14 @@ namespace jdi
return false;
}
const Lane::FreeMarkerItem& item = lane.freeMarkerItems[itemIndex];
double value = 0.0;
double enumValue = 0.0;
if (!FreeMarkerValue(lane, item, value, enumValue))
// 取值与文本来自**与视界无关**的内容缓存(大量固定点 + 拖光标/平移时不重复采样)。
const std::vector<FreeMarkerContentEntry>& contents = FreeMarkerContents(laneIndex);
if (itemIndex >= contents.size() || !contents[itemIndex].valid)
{
return false;
}
bool hasPrev = false;
double delta = 0.0;
double timeDelta = 0.0;
FreeMarkerDelta(lane, itemIndex, value, hasPrev, delta, timeDelta);
const FreeMarkerContentEntry& entry = contents[itemIndex];
const double value = entry.value;
// 超出视界**直接不显示**(用户明确要求:不按常规数据点那样贴边定格 + 三角指示)。
const float rawX = TimeToX(item.time);
@@ -6660,14 +6794,12 @@ namespace jdi
}
// 枚举文本按**采样原值**查找(与光标读数完全一致)。
const ResolvedSignal labelResolved = ResolveSignal(item.ref);
const std::wstring* enumText = (labelResolved.data != nullptr)
? labelResolved.data->enumText.Find(enumValue)
: nullptr;
text = FreeMarkerValueText(value, hasPrev, delta, enumText);
text = entry.capsuleText;
// ✕ 按钮 = **与取值胶囊同高的正方形**(用户明确要求:长宽都等于胶囊的上下高度)。
const float textWidth = UiMeasureTextWidth(m_fonts->monoSmall, text) + 10.0f * m_scale;
// ⚠️ 宽度多加 2px 余量:`DrawTextW` 的 `DT_END_ELLIPSIS` 有时会因为亚像素/字形
// 伸部而把“刚好装满”的文本截成省略号(用户报告枚举文本被 … 缺省)。
const float textWidth = UiMeasureTextWidth(m_fonts->monoSmall, text) + 12.0f * m_scale;
const float textHeight = 17.0f * m_scale;
const float closeSize = textHeight;
const float gap = kFreeMarkerCloseGap * m_scale;
@@ -6803,6 +6935,8 @@ namespace jdi
continue;
}
const float markerAlpha = std::max(0.0f, std::min(1.0f, lane.freeMarkerOpacity));
// 内容(取值 / 文本 / 信号色)来自与视界无关的缓存,不逐帧重新采样。
const std::vector<FreeMarkerContentEntry>& contents = FreeMarkerContents(static_cast<int>(laneIndex));
std::vector<gd::RectF> placed;
for (std::size_t itemIndex = 0; itemIndex < lane.freeMarkerItems.size(); ++itemIndex)
{
@@ -6814,20 +6948,18 @@ namespace jdi
continue; // 超出视界直接不显示(不贴边、不画三角)
}
const Lane::FreeMarkerItem& item = lane.freeMarkerItems[itemIndex];
const ResolvedSignal resolved = ResolveSignal(item.ref);
if (resolved.style == nullptr)
if (itemIndex >= contents.size())
{
continue;
}
const std::uint32_t signalColor = StyleColor(*resolved.style);
const FreeMarkerContentEntry& entry = contents[itemIndex];
const std::uint32_t signalColor = entry.color;
const LaneVisual& visual = lanes[laneIndex];
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);
double value = 0.0;
double enumValue = 0.0;
const bool valueOk = FreeMarkerValue(lane, item, value, enumValue);
const bool valueOk = entry.valid;
// ---- 纵向避让:与本次已放置的胶囊重叠时,向下 / 向上交替错开 ----
const auto occupied = [&placed](const gd::RectF& rect) {
@@ -6877,13 +7009,32 @@ namespace jdi
const float radius = 3.4f * m_scale;
const int dotAlpha = static_cast<int>(std::lround(255.0f * markerAlpha));
gd::SolidBrush dot(ToGdiColor(WithAlpha(signalColor, dotAlpha)));
const float dotY = valueOk ? ValueToY(value, visual) : (clipTop + clipBottom) * 0.5f;
const float dotY =
valueOk ? ValueToY(entry.value, visual) : (clipTop + clipBottom) * 0.5f;
g.FillEllipse(&dot, x - radius, dotY - radius, radius * 2.0f, radius * 2.0f);
// ✕ 按钮的悬停态决定它的配色,因此先算再画胶囊。
const bool hot = (m_freeMarkerHoverLane == static_cast<int>(laneIndex) &&
m_freeMarkerHoverItem == itemIndex);
PaintFreeMarkerCapsule(g, label, text, signalColor, close, hot, markerAlpha);
// 「标记透明度」:鼠标停在某个取值胶囊上时把**这一个**胶囊恢复为全不透明
//(用户 2026-10-21 要求:既能看淡全局标记,又能读清当前关注的数值)。
// ⚠️ 例外:如果此刻跟随探针正瞄着一个**被这个胶囊遮住的数据点**
//(用户意图标记被遮住的那条曲线),则不恢复 —— 否则胶囊会重新遮住目标点、
// 无法看出自己到底瞄到了哪个采样点。
float capsuleAlpha = markerAlpha;
const float fx = static_cast<float>(m_mouseX);
const float fy = static_cast<float>(m_mouseY);
if (markerAlpha < 0.999f && UiPointInRect(label, fx, fy))
{
const bool probeHiddenByLabel =
m_freeMarker.valid && m_freeMarker.laneIndex == static_cast<int>(laneIndex) &&
UiPointInRect(label, m_freeMarker.x, m_freeMarker.y);
if (!probeHiddenByLabel)
{
capsuleAlpha = 1.0f;
}
}
PaintFreeMarkerCapsule(g, label, text, signalColor, close, hot, capsuleAlpha);
FreeMarkerHit hit;
hit.laneIndex = static_cast<int>(laneIndex);
@@ -7261,8 +7412,62 @@ namespace jdi
// ⚠️ **按层序从下往上绘制**(与命中测试、像素缓存的层叠顺序完全一致):
// `m_panelOrder` 队尾 = 最上层 = 最后绘制。
SyncPanelOrder();
for (const PanelRef& panelRef : m_panelOrder)
// 预扫描一:本帧各面板的布局(循环里直接复用,避免每帧重复计算)。
std::vector<EdgePanelLayout> panelLayouts(m_panelOrder.size());
for (std::size_t ordinal = 0; ordinal < m_panelOrder.size(); ++ordinal)
{
panelLayouts[ordinal] = EdgePanelLayoutFor(
m_panelOrder[ordinal].laneIndex, static_cast<EdgePanelKind>(m_panelOrder[ordinal].kind));
}
// 预扫描二:上一帧已缓存、但本帧**位置/尺寸变了(或被移除)**的面板——它们腾出的
// 区域可能仍然留在**其它面板**的缓存像素里(那块像素是上一帧连它一起拷下来的),
// 于是鼠标拖动面板时,下方面板上会出现拖拽残影(用户 2026-10-21 报告)。
// 把腾空区域也当“脏区”,强制覆盖到它的面板重画,就能彻底消除残影。
std::vector<gd::RectF> vacatedRects;
if (panelCacheReady && !m_panelSlots.empty())
{
const float margin = 4.0f * m_scale;
for (const PanelSlot& slot : m_panelSlots)
{
int cx0 = slot.x0;
int cy0 = slot.y0;
int cx1 = slot.x1;
int cy1 = slot.y1;
bool alive = false;
for (std::size_t ordinal = 0; ordinal < m_panelOrder.size(); ++ordinal)
{
if (m_panelOrder[ordinal].laneIndex != slot.laneIndex ||
m_panelOrder[ordinal].kind != slot.kind)
{
continue;
}
const gd::RectF& panel = panelLayouts[ordinal].panel;
if (panel.Width <= 0.0f || panel.Height <= 0.0f)
{
break;
}
cx0 = std::max(0, static_cast<int>(std::floor(panel.X - margin)));
cy0 = std::max(0, static_cast<int>(std::floor(panel.Y - margin)));
cx1 = std::max(cx0, static_cast<int>(std::ceil(panel.X + panel.Width + margin)));
cy1 = std::max(cy0, static_cast<int>(std::ceil(panel.Y + panel.Height + margin)));
alive = true;
break;
}
if (!alive || cx0 != slot.x0 || cy0 != slot.y0 || cx1 != slot.x1 || cy1 != slot.y1)
{
vacatedRects.push_back(gd::RectF(static_cast<float>(slot.x0),
static_cast<float>(slot.y0),
static_cast<float>(slot.x1 - slot.x0),
static_cast<float>(slot.y1 - slot.y0)));
}
}
}
for (std::size_t panelOrdinal = 0; panelOrdinal < m_panelOrder.size(); ++panelOrdinal)
{
const PanelRef& panelRef = m_panelOrder[panelOrdinal];
const EdgePanelKind kind = static_cast<EdgePanelKind>(panelRef.kind);
const std::size_t index = static_cast<std::size_t>(panelRef.laneIndex);
const bool centerAxis = (kind == EdgePanelKind::CENTER);
@@ -7278,7 +7483,8 @@ namespace jdi
continue;
}
const Lane& tabLane = tabLanes[index];
const EdgePanelLayout layout = EdgePanelLayoutFor(static_cast<int>(index), kind);
// 布局已在预扫描里算好(同一帧内复用,避免重复计算)。
const EdgePanelLayout& layout = panelLayouts[panelOrdinal];
const gd::RectF& panel = layout.panel;
if (panel.Width <= 0.0f || panel.Height <= 0.0f)
{
@@ -7302,6 +7508,19 @@ namespace jdi
break;
}
}
if (!forcedDirty)
{
// 上一帧有面板在这里腾出了位置(移动 / 缩小 / 关闭)⇒ 本面板的缓存像素可能
// 仍然包含那块旧内容,必须重画(否则拖动上面那个面板时这里会留残影)。
for (const gd::RectF& vacated : vacatedRects)
{
if (vacated.IntersectsWith(cachedRect))
{
forcedDirty = true;
break;
}
}
}
std::uint64_t slotKey = 0;
const std::size_t slotIndex = nextSlots.size();
@@ -7360,8 +7579,9 @@ namespace jdi
UiFillRoundRect(g, gd::RectF(panel.X, panel.Y, panel.Width, titleHeight), 5.0f * m_scale,
WithAlpha(m_theme->accent, 0x2A));
// 标题栏左侧的「展开 / 折起」指示器:三角(朝下 = 已展开、朝右 = 已折起)
// + 三角后方的一条短横线(经典展开/折起标识),位于 GX 徽标左边。
// 标题栏左侧的「展开 / 折起」指示器:**与信号列表组名最左边的折叠三角完全同款**
//(同一尺寸 12 逻辑像素、同一个 `UiDrawTriangleIcon`、同样只在悬停时变强调色、
// 不再带三角后方那条横线 —— 用户 2026-10-21 要求“统一使用相同标记”)。
// 单击它切换折起状态;折起后面板只剩标题栏,这里是唯一的“重新展开”按钮。
{
const gd::RectF& disclosureRect = layout.disclosure;
@@ -7373,17 +7593,14 @@ namespace jdi
static_cast<float>(m_mouseY));
const std::uint32_t disclosureColor =
disclosureHot ? m_theme->accent : m_theme->text;
const float centerX = disclosureRect.X + 5.5f * m_scale;
const float centerX = disclosureRect.X + disclosureRect.Width * 0.5f;
const float centerY = disclosureRect.Y + disclosureRect.Height * 0.5f;
UiDrawTriangleIcon(
g,
gd::RectF(centerX - 4.5f * m_scale, centerY - 4.5f * m_scale,
9.0f * m_scale, 9.0f * m_scale),
gd::RectF(centerX - 6.0f * m_scale, centerY - 6.0f * m_scale,
12.0f * m_scale, 12.0f * m_scale),
disclosureColor,
layout.collapsed ? IconDirection::RIGHT : IconDirection::DOWN);
UiDrawLine(g, centerX + 6.0f * m_scale, centerY,
disclosureRect.X + disclosureRect.Width, centerY, disclosureColor,
1.4f * m_scale);
}
}
@@ -7751,60 +7968,79 @@ namespace jdi
}
// ---- 固定点条目(五列,列起点由布局给定 ⇒ 纵向严格对齐)----
// ⚠️ 取值与各列文本来自**与视界无关的内容缓存**(`FreeMarkerContents`):
// 大量固定点时逐帧重新采样会让"拖光标 / 平移视界"明显变卡。
const std::vector<FreeMarkerContentEntry>& contents =
FreeMarkerContents(static_cast<int>(index));
// 五列的**实测宽度**:含滚出视界的条目,保证列宽稳定、文本不被省略。
// 存回 `Lane` 供下一步的布局(`EdgePanelWidth` / `EdgePanelLayoutFor`)使用。
{
float widest[5] = {0.0f, 0.0f, 0.0f, 0.0f, 0.0f};
const auto measure = [this, &widest](std::size_t column, const std::wstring& text) {
if (text.empty() || column >= 5)
{
return;
}
widest[column] = std::max(widest[column],
UiMeasureTextWidth(m_fonts->monoSmall, text));
};
for (const FreeMarkerContentEntry& entry : contents)
{
measure(EdgePanelLayout::MARKER_COL_VALUE, entry.valueText);
measure(EdgePanelLayout::MARKER_COL_ENUM, entry.enumText);
measure(EdgePanelLayout::MARKER_COL_DELTA, entry.deltaText);
measure(EdgePanelLayout::MARKER_COL_TIME, entry.timeText);
measure(EdgePanelLayout::MARKER_COL_DT, entry.deltaTimeText);
}
std::array<float, 5>& stored =
tabLanes[index].freeMarkerColumnWidths;
for (std::size_t column = 0; column < 5; ++column)
{
// +2px 余量:`DrawTextW` 的 `DT_END_ELLIPSIS` 对"刚好装满"的文本
// 有时仍会截成省略号(亚像素/字形伸部),留一点余量可彻底避免。
const float want =
(widest[column] > 0.0f) ? (widest[column] + 2.0f * m_scale) : 0.0f;
if (std::fabs(stored[column] - want) > 0.5f)
{
stored[column] = want;
}
}
}
for (const EdgePanelLayout::MarkerRow& markerRow : layout.markerRows)
{
const Lane::FreeMarkerItem& item = tabLane.freeMarkerItems[markerRow.itemIndex];
const ResolvedSignal resolved = ResolveSignal(item.ref);
if (resolved.data == nullptr || resolved.style == nullptr)
if (markerRow.itemIndex >= contents.size())
{
continue;
}
double value = 0.0;
double enumValue = 0.0;
const bool valueOk = FreeMarkerValue(tabLane, item, value, enumValue);
bool hasPrev = false;
double delta = 0.0;
double timeDelta = 0.0;
if (valueOk)
{
FreeMarkerDelta(tabLane, markerRow.itemIndex, value, hasPrev, delta,
timeDelta);
}
const std::wstring* enumText = resolved.data->enumText.Find(enumValue);
const std::wstring valueText =
valueOk ? FormatValue(value) : std::wstring(kFreeMarkerNotAvailable);
const std::wstring deltaText =
(valueOk && hasPrev) ? FormatSignedValue(delta)
: std::wstring(kFreeMarkerNotAvailable);
const std::wstring timeText = FreeMarkerTimeText(item.time);
const std::wstring timeDeltaText =
FreeMarkerDeltaText(valueOk && hasPrev, timeDelta);
const FreeMarkerContentEntry& entry = contents[markerRow.itemIndex];
const auto columnRect = [&](EdgePanelLayout::MarkerColumnIndex column) {
return gd::RectF(layout.markerColumns[column].left, markerRow.rect.Y,
layout.markerColumns[column].width,
markerRow.rect.Height);
};
// ⚠️ 面板里的数据列**不得省略**(用户 2026-10-21 明确要求):
// 列宽已按实测文本宽度撑开,因此这里直接整段输出,不再 `UiEllipsize`。
const auto paintCell = [&](EdgePanelLayout::MarkerColumnIndex column,
const std::wstring& text, std::uint32_t color) {
if (layout.markerColumns[column].width <= 0.0f || text.empty())
{
return;
}
const gd::RectF rect = columnRect(column);
UiText(g, UiEllipsize(text, m_fonts->monoSmall, rect.Width),
m_fonts->monoSmall, rect, color, TextAlign::LEFT);
UiText(g, text, m_fonts->monoSmall, columnRect(column), color,
TextAlign::LEFT);
};
paintCell(EdgePanelLayout::MARKER_COL_VALUE, valueText, m_theme->textStrong);
paintCell(EdgePanelLayout::MARKER_COL_VALUE, entry.valueText,
m_theme->textStrong);
if (layout.markerHasEnumColumn)
{
paintCell(EdgePanelLayout::MARKER_COL_ENUM,
(enumText != nullptr) ? *enumText : std::wstring(),
m_theme->text);
paintCell(EdgePanelLayout::MARKER_COL_ENUM, entry.enumText, m_theme->text);
}
paintCell(EdgePanelLayout::MARKER_COL_DELTA, deltaText, m_theme->textMuted);
paintCell(EdgePanelLayout::MARKER_COL_TIME, timeText, m_theme->textMuted);
paintCell(EdgePanelLayout::MARKER_COL_DT, timeDeltaText, m_theme->textMuted);
paintCell(EdgePanelLayout::MARKER_COL_DELTA, entry.deltaText,
m_theme->textMuted);
paintCell(EdgePanelLayout::MARKER_COL_TIME, entry.timeText, m_theme->textMuted);
paintCell(EdgePanelLayout::MARKER_COL_DT, entry.deltaTimeText,
m_theme->textMuted);
// 行尾 ✕:移除该固定点(与之前一致)。
const gd::RectF& closeRect = markerRow.close;
@@ -7960,58 +8196,6 @@ namespace jdi
m_panelSlots = std::move(nextSlots);
m_panelGlobalKey = panelGlobalKey;
m_panelValid = panelCacheReady;
// 「GX」徽标的悬停提示气泡:必须在**全部面板像素都存进图层之后**绘制,
// 否则气泡会被烘进面板缓存(下一帧即使鼠标移开也会重现)。
PaintPanelBadgeTooltip(g);
}
void PlotView::PaintPanelBadgeTooltip(gd::Graphics& g)
{
if (m_hoverPanelBadgeLane < 0 || m_fonts == nullptr || m_fonts->uiSmall == nullptr ||
m_tab == nullptr || m_hoverPanelBadgeKind < 0)
{
return;
}
const EdgePanelKind kind = static_cast<EdgePanelKind>(m_hoverPanelBadgeKind);
const EdgePanelLayout layout = EdgePanelLayoutFor(m_hoverPanelBadgeLane, kind);
if (layout.badge.Width <= 0.0f)
{
return;
}
// 目标网格是否已完整可见:决定提示文案(不可见时提示“点击会滚动过去”)。
bool laneVisible = false;
const std::size_t laneIndex = static_cast<std::size_t>(m_hoverPanelBadgeLane);
if (laneIndex < m_lanes.size())
{
const gd::RectF& rect = m_lanes[laneIndex].rect;
laneVisible = (rect.Y >= m_plotRect.Y - 0.5f) &&
(rect.Y + rect.Height <= m_plotRect.Y + m_plotRect.Height + 0.5f);
}
const std::wstring label = L"G" + std::to_wstring(m_hoverPanelBadgeLane + 1);
const std::wstring text =
label + (laneVisible ? L" · 已在视界内" : L" · 点击滚动到该网格");
const float padX = 8.0f * m_scale;
const float height = 22.0f * m_scale;
const float width = std::min(UiMeasureTextWidth(m_fonts->uiSmall, text) + padX * 2.0f,
std::max(10.0f, m_plotRect.Width - 4.0f * m_scale));
float left = layout.badge.X - 2.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));
// 默认贴在徽标下方;下方放不下就翻到上方(与弹出菜单的翻转规则一致)。
float top = layout.badge.Y + layout.badge.Height + 4.0f * m_scale;
if (top + height > m_plotRect.Y + m_plotRect.Height - 2.0f * m_scale)
{
top = layout.badge.Y - height - 4.0f * m_scale;
}
top = std::max(m_plotRect.Y + 2.0f * m_scale, top);
const gd::RectF bubble(left, top, width, height);
UiFillRoundRect(g, bubble, 4.0f * m_scale, m_theme->readoutBg);
UiDrawRoundRect(g, bubble, 4.0f * m_scale, m_theme->borderStrong, 1.0f);
UiText(g, text, m_fonts->uiSmall, bubble, m_theme->textStrong, TextAlign::CENTER);
}
void PlotView::PaintEditField(gd::Graphics& g)