新增实验性功能「自由标记」:网格内跟随鼠标的纵向光标 + 单击固定数据点 + 浮动面板

需求(用户):网格右键菜单「实验性功能」里新增带 √ 的「自由标记」。开启后网格内跟随鼠标
出现一条仅限本网格的纵向光标(上下各一个把手、不显示时间值),穿过信号时只标注离鼠标
最近的数据点;左键单击把该点固定(带 ✕ 可移除,鼠标移开仍显示、超出视界直接不显示),
后续固定点显示为相对前一个的增量;面板按信号分组列出「值(±增量) = 枚举」+「t:… Δt:…」
(两列右对齐、可双击按秒编辑并自动换位);命中自动点线判据的信号要有吸附提示;
两级 ✕ 都要悬停提示;与选区放大共存时原地单击 = 固定、按下拖动 = 框选。

一、数据层
- Lane 新增 freeMarker 开关、freeMarkerItems(FreeMarkerItem{ref, time})、freeMarkerPanel。
  time 是「显示时刻」(画在 TimeToX(time) 处),与光标同一口径;取值必须走
  SampleDisplayedSignalAt(内部处理时间偏移与水平镜像)。
- 新增 AddFreeMarker / RemoveFreeMarker / RemoveFreeMarkerAt / RemoveFreeMarkersOf /
  FindFreeMarker / HasFreeMarkers。同一信号同一时刻不重复加入(相对容差 1e-12);
  freeMarkerItems 保持插入顺序,显示顺序与「前一个固定点」一律由 SortedFreeMarkerIndices
  按时间排序得出(删除某项不打乱其它项下标)。
- LaneCarry 同步携带,勾选/取消勾选信号导致网格重建时不会丢掉已固定的点。

二、网格内交互
- ProbeFreeMarker:仅当鼠标所在网格开启该功能时有效;在网格内全部可见信号里挑屏幕纵向
  距离鼠标最近的数据点。MinorCellSparse 成立时经 SnapTimeToSample 吸附到真实采样时刻,
  绘制侧画一圈圆环作为吸附提示。吸附只看判据,不看当前绘制方式。
- PaintFreeMarker(接在 paintOverlays 里、浮动面板之前):纵向线只在本网格内,上把手尖端
  朝下(与常规光标顶部把手一致)、下把手尖端朝上,不画时间读数;已固定点画低透明度细线 +
  圆点 + 「值(±增量) = 枚举」胶囊 + 行尾 ✕(悬停高亮 + 提示气泡);
  超出视界直接不显示(不复用常规数据点的贴边定格 + 方向三角)。
- 网格内固定点 ✕ 的命中矩形由绘制时登记(m_freeMarkerHits),因为它的位置取决于取值文本宽度。
- OnMouseLeave 清跟随光标与三处悬停态(已固定的点保留)。

三、单击 vs 框选
- OnLButtonDown 在开启该功能的网格上只登记 m_freeMarkerPendingFix,并把拖拽模式设为
  「选区放大 ? RUBBER_SELECT : PAN」;OnLButtonUp 里只有「整段操作没有拖动且选区轴向未定」
  才 AddFreeMarker,否则照旧框选/平移。

四、浮动面板(EdgePanelKind::MARK / PLOT_CMD_LANE_FREE_MARKER = 12)
- 每个可见信号一行名称(右侧带清除该信号全部固定点的 ✕),名称行下方按时间升序列出该信号的
  固定点;没有任何固定点时第一行给灰色提示。
- 行 = 左侧「值(±增量) = 枚举文本」(无尖头括号、首项无增量)+ 右侧两列各自右对齐的
  「t:<时刻>s」与「Δt:+<复合单位>」(自动单位匹配)+ 行尾 ✕。
  两列右边界存进 EdgePanelLayout(markerTimeRight / markerDeltaRight),保证所有条目纵向对齐。
- 双击行:左半编辑自身时刻(FREE_MARKER_TIME),右半编辑相对前一固定点的间隔
  (FREE_MARKER_DELTA,最左边的点没有前一项所以不进入编辑)。提交后只改 time,
  顺序由排序函数自动重排(越过后续点即交换条目位置)。
- PanelSlotKey 把每个固定点的信号 + 时刻、全部 marker 行矩形与两列边界一起 mix。

五、一并修掉的两个自伤
1. EdgePanelWidth 的 MARK 分支原本逐行取采样值算文本宽度 —— 它被 EdgePanelLayoutFor 调用,
   而后者在 TopPanelAtPoint / SyncPanelOrder 里每次鼠标移动调用数次,等于「鼠标一动就做几十次
   采样查询」。改为固定宽度 kFreeMarkerPanelWidth(340 逻辑像素)+ 绘制侧 UiEllipsize。
2. PaintFreeMarker 与跟随光标的胶囊绘制原本是两份重复代码,抽 PaintFreeMarkerCapsule 共用;
   FreeMarkerLabelRects 增加 text 出参,避免绘制侧重复求解一次取值。

验证(全离屏,不占用鼠标/键盘/屏幕)
- 新增 .preflight/markcheck 31/31:探测有效与就近选择、原地单击恰好新增 1 个固定点、
  选区放大共存(单击固定不缩放 / 拖动缩放不固定)、面板行数与时间升序、首项无增量、
  后续项增量与 Δt 正确、两列右对齐、行尾 ✕ 只删 1 项、信号名 ✕ 清空该信号、
  编辑 Δt 后移动并重排、深放大时吸附且时刻精确等于采样时刻、视界外固定点不绘制。
- 回归:editcheck 20/20、round3check 31/31、round4check 34/34、round5check 5/5、
  convcheck 18/18;check 136/137(唯一失败项是本轮之前就存在的不稳定项,上一轮已用
  git stash 在基线复现同一数值);真实 MF4 仍 283/8/18/24 个信号。
- make release / make debug 零警告。
- TargetCheck.exe 833,536 字节(814.0 KiB)—— 比上一轮 +24 KB,首次超出旧的 800 KiB
  红线约 14 KB。已做的瘦身见上;剩余为新功能的真实代码量,若要回到 800 KiB 以内需要
  改变取舍(动态 CRT / UPX / 砍其它功能),本轮按「实现功能优先」未动。

文档:Documents/agent_mem.md 同步 §10 变更日志、§11.2 第 181-188 条硬约束
与文末本轮小节。
This commit is contained in:
DeepSeek V4.1 Flash committed 2026-10-20 19:47:55 +08:00
1 parent 53b981a2a9
commit dd8f63aa0e
10 files changed
+1735 -16

No files matched your search

+724 -10
View File
@@ -142,6 +142,16 @@ namespace jdi
width = std::max(width, kEdgePanelPad * 2.0f * m_scale + buttonWidth);
}
if (kind == EdgePanelKind::MARK)
{
// 「自由标记」面板:**宽度用固定值**,不做逐行文本测量。
// ⚠️ `EdgePanelWidth` 被 `EdgePanelLayoutFor` 调用,而后者又在 `TopPanelAtPoint` /
// `SyncPanelOrder` 里被高频调用(每次鼠标移动数次)——若在这里逐行取采样值算文本宽度,
// 就变成"鼠标一动就做几十次采样查询",会明显拖慢交互。
// 取值的文本超长时由绘制侧的 `UiEllipsize` 截断即可。
width = std::max(width, kFreeMarkerPanelWidth * m_scale);
}
// 夹在绘图区宽度之内(过窄的网格也不至于把面板挤到外面)。
return std::min(width, std::max(120.0f * m_scale, m_plotRect.Width - 8.0f * m_scale));
}
@@ -764,6 +774,9 @@ namespace jdi
PaintLegendHighlight(g);
PaintRubberBand(g);
PaintCursor(g, m_lanes);
// 「自由标记」的跟随光标与已固定数据点(在统计面板**之下**绘制,
// 这样浮动面板仍压在最上层,与其它实验性功能的层序一致)。
PaintFreeMarker(g, m_lanes);
PaintEdgeCursor(g, m_lanes);
PaintSignalInteraction(g);
PaintHint(g);
@@ -5311,15 +5324,20 @@ namespace jdi
const bool centerAxis = (kind == EdgePanelKind::CENTER);
const bool meanFilter = (kind == EdgePanelKind::MEAN);
const bool weakPeak = (kind == EdgePanelKind::PEAK);
const bool freeMarker = (kind == EdgePanelKind::MARK);
// 「均值滤波」与「弱峰调整」共用同一套「逐条目 + 底部按钮」排布。
const bool itemized = (centerAxis || meanFilter || weakPeak);
const bool hasButton = (meanFilter || weakPeak);
const bool enabled = meanFilter ? lane.meanFilter
: (weakPeak ? lane.weakPeak
: (centerAxis ? lane.edgeCenter : lane.edgeCount));
: (freeMarker ? lane.freeMarker
: (centerAxis ? lane.edgeCenter
: lane.edgeCount)));
const bool open = meanFilter ? lane.meanFilterPanel.open
: (weakPeak ? lane.weakPeakPanel.open
: (centerAxis ? lane.edgeCenterPanel.open : lane.edgePanel.open));
: (freeMarker ? lane.freeMarkerPanel.open
: (centerAxis ? lane.edgeCenterPanel.open
: lane.edgePanel.open)));
if (!enabled || !open)
{
return layout;
@@ -5397,6 +5415,41 @@ namespace jdi
height = titleHeight + pad + static_cast<float>(rowCount) * rowHeight * rowsPerSignal + pad;
}
// 「自由标记」面板:每个可见信号一行名称,下面跟着它的全部固定点;
// 没有任何固定点时在名称行下方给一行灰色提示。
bool markerAny = false;
for (const Lane::FreeMarkerItem& item : lane.freeMarkerItems)
{
if (item.ref.IsValid())
{
markerAny = true;
break;
}
}
if (freeMarker)
{
const float nameRow = kFreeMarkerNameRowHeight * m_scale;
const float itemRow = kFreeMarkerItemRowHeight * m_scale;
float content = 0.0f;
std::vector<std::size_t> order;
if (!markerAny)
{
content += nameRow;
}
for (const SignalRef& ref : lane.signals)
{
const ResolvedSignal resolved = ResolveSignal(ref);
if (resolved.data == nullptr || !resolved.visible)
{
continue;
}
content += nameRow;
SortedFreeMarkerIndices(lane, ref, order);
content += static_cast<float>(order.size()) * itemRow;
}
height = titleHeight + pad + content + pad;
}
// 首次使用(位置为负)时自动放到绘图区**右上角**,并按网格在**当前可见网格中的序数**
// 依次错开,避免完全重叠。
// ⚠️ 旧实现用 `laneIndex` 错开 —— 上百个网格时 `laneIndex` 很大,
@@ -5406,10 +5459,14 @@ namespace jdi
// 中间偏下”以内,于是面板始终停**右上区域**,同时保留“依次错开”的观感。
const double storedX = meanFilter ? lane.meanFilterPanel.x
: (weakPeak ? lane.weakPeakPanel.x
: (centerAxis ? lane.edgeCenterPanel.x : lane.edgePanel.x));
: (freeMarker ? lane.freeMarkerPanel.x
: (centerAxis ? lane.edgeCenterPanel.x
: lane.edgePanel.x)));
const double storedY = meanFilter ? lane.meanFilterPanel.y
: (weakPeak ? lane.weakPeakPanel.y
: (centerAxis ? lane.edgeCenterPanel.y : lane.edgePanel.y));
: (freeMarker ? lane.freeMarkerPanel.y
: (centerAxis ? lane.edgeCenterPanel.y
: lane.edgePanel.y)));
int openPanelOrdinal = 0;
{
// ⚠️ 错开量用「**当前真正显示出来的面板**中排在本面板之前的个数」:
@@ -5431,11 +5488,14 @@ namespace jdi
return lane.meanFilter && lane.meanFilterPanel.open;
case EdgePanelKind::PEAK:
return lane.weakPeak && lane.weakPeakPanel.open;
case EdgePanelKind::MARK:
return lane.freeMarker && lane.freeMarkerPanel.open;
}
return false;
};
static const EdgePanelKind kOrder[4] = {EdgePanelKind::COUNT, EdgePanelKind::CENTER,
EdgePanelKind::MEAN, EdgePanelKind::PEAK};
static const EdgePanelKind kOrder[5] = {EdgePanelKind::COUNT, EdgePanelKind::CENTER,
EdgePanelKind::MEAN, EdgePanelKind::PEAK,
EdgePanelKind::MARK};
int openSeen = 0;
bool found = false;
for (std::size_t lane = 0; lane < m_lanes.size() && !found; ++lane)
@@ -5617,6 +5677,61 @@ namespace jdi
}
}
}
if (freeMarker)
{
// 「自由标记」面板:每个可见信号一行名称(右侧带「清除本信号全部固定点」✕),
// 下面逐行列出它的固定点(组内按时间升序 == 从左到右),行尾再带一个 ✕。
const float nameRow = kFreeMarkerNameRowHeight * m_scale;
const float itemRow = kFreeMarkerItemRowHeight * m_scale;
const float closeSize = kFreeMarkerCloseSize * m_scale;
const float gap = 4.0f * m_scale;
// 时间两列右对齐:Δt 列贴面板右内边界(✕ 左侧),t 列在它左边。
layout.markerDeltaRight = left + width - pad - closeSize - gap;
layout.markerTimeRight = layout.markerDeltaRight - 74.0f * m_scale;
float rowY = top + titleHeight + pad;
std::vector<std::size_t> order;
bool markerAny = false;
for (const Lane::FreeMarkerItem& item : lane.freeMarkerItems)
{
markerAny = markerAny || item.ref.IsValid();
}
if (!markerAny)
{
// 还没有任何固定点:第一行留作提示文案(绘制时画「在网格内单击以固定数据点」)。
rowY += nameRow;
}
for (const SignalRef& ref : lane.signals)
{
const ResolvedSignal resolved = ResolveSignal(ref);
if (resolved.data == nullptr || !resolved.visible)
{
continue;
}
layout.rowRefs.push_back(ref);
SortedFreeMarkerIndices(lane, ref, order);
const bool hasMarkers = !order.empty();
layout.rowNameRects.push_back(gd::RectF(left + pad, rowY,
std::max(0.0f, width - pad * 2.0f - closeSize - gap),
nameRow));
layout.rowNameCloses.push_back(
hasMarkers ? gd::RectF(left + width - pad - closeSize,
rowY + (nameRow - closeSize) * 0.5f, closeSize, closeSize)
: gd::RectF());
rowY += nameRow;
for (const std::size_t itemIndex : order)
{
EdgePanelLayout::MarkerRow markerRow;
markerRow.itemIndex = itemIndex;
markerRow.rect = gd::RectF(left + pad, rowY, width - pad * 2.0f, itemRow);
markerRow.close = gd::RectF(left + width - pad - closeSize,
rowY + (itemRow - closeSize) * 0.5f, closeSize, closeSize);
layout.markerRows.push_back(markerRow);
rowY += itemRow;
}
}
}
return layout;
}
@@ -6098,8 +6213,34 @@ namespace jdi
{
mixRect(rect);
}
// 「自由标记」:固定点行/名称 ✕ 的矩形、时间列边界与每个固定点的身份 + 时刻都要入键。
for (const EdgePanelLayout::MarkerRow& row : layout.markerRows)
{
mixRect(row.rect);
mixRect(row.close);
mix(static_cast<std::uint64_t>(row.itemIndex + 2));
}
for (const gd::RectF& rect : layout.rowNameCloses)
{
mixRect(rect);
}
mixFloat(layout.markerTimeRight);
mixFloat(layout.markerDeltaRight);
const Lane& lane = Tab().Lanes()[static_cast<std::size_t>(laneIndex)];
if (kind == EdgePanelKind::MARK)
{
// 固定点的**内容**(哪个信号、什么时刻)直接决定面板文字与行数,必须进键;
// 否则新增/删除一个固定点后可能贴回旧像素。
for (const Lane::FreeMarkerItem& item : lane.freeMarkerItems)
{
mix(static_cast<std::uint64_t>(item.ref.groupIndex + 2));
mix(static_cast<std::uint64_t>(item.ref.signalIndex + 2));
std::uint64_t bits = 0;
std::memcpy(&bits, &item.time, sizeof(bits));
mix(bits);
}
}
for (const SignalRef& ref : layout.rowRefs)
{
mix(ref.IsValid() ? 1ull : 0ull);
@@ -6214,6 +6355,445 @@ namespace jdi
return hash;
}
bool PlotView::FreeMarkerValue(const Lane& lane, const Lane::FreeMarkerItem& item, double& value,
double& enumValue) const
{
if (m_dataSet == nullptr)
{
return false;
}
const ResolvedSignal resolved = ResolveSignal(item.ref);
if (resolved.data == nullptr || resolved.style == nullptr || resolved.axis == nullptr ||
!resolved.visible)
{
return false;
}
// 固定点存的是**显示时刻**,因此要用 `SampleDisplayedSignalAt`(内部处理时间偏移与
// 水平镜像)。散点模式下不插值,取最近采样点,与光标读数的规则一致。
const bool interpolate = (resolved.style->mode != DrawMode::SCATTER);
double sampled = 0.0;
if (!SampleDisplayedSignalAt(*resolved.axis, *resolved.data, *resolved.style, item.time,
interpolate, sampled) ||
!std::isfinite(sampled))
{
return false;
}
(void)lane;
enumValue = sampled; // 枚举按**原值**查
value = ApplyDrawTransform(*resolved.style, sampled); // 读数与画出来的波形一致
return std::isfinite(value);
}
bool PlotView::FreeMarkerDelta(const Lane& lane, std::size_t itemIndex, double value, bool& hasPrev,
double& delta, double& timeDelta) const
{
hasPrev = false;
delta = 0.0;
timeDelta = 0.0;
if (itemIndex >= lane.freeMarkerItems.size())
{
return false;
}
const Lane::FreeMarkerItem& item = lane.freeMarkerItems[itemIndex];
std::vector<std::size_t> order;
SortedFreeMarkerIndices(lane, item.ref, order);
// 找到自己在**排序后序列**里的位置;前一个即「左边最近的那个固定点」。
std::size_t position = order.size();
for (std::size_t index = 0; index < order.size(); ++index)
{
if (order[index] == itemIndex)
{
position = index;
break;
}
}
if (position == order.size() || position == 0)
{
return true; // 最左边(或找不到):没有增量
}
const Lane::FreeMarkerItem& previous = lane.freeMarkerItems[order[position - 1]];
double previousValue = 0.0;
double previousEnum = 0.0;
if (!FreeMarkerValue(lane, previous, previousValue, previousEnum))
{
return true; // 前一个点没有有效取值:不显示增量(避免编造一个数)
}
hasPrev = true;
delta = value - previousValue;
timeDelta = item.time - previous.time;
return true;
}
bool PlotView::FreeMarkerLabelRects(int laneIndex, std::size_t itemIndex, gd::RectF& label,
gd::RectF& close, std::wstring& text) const
{
label = gd::RectF();
close = gd::RectF();
text.clear();
if (m_tab == nullptr || m_fonts == nullptr || laneIndex < 0 ||
laneIndex >= static_cast<int>(m_tab->Lanes().size()) ||
laneIndex >= static_cast<int>(m_lanes.size()))
{
return false;
}
const Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
if (itemIndex >= lane.freeMarkerItems.size())
{
return false;
}
const Lane::FreeMarkerItem& item = lane.freeMarkerItems[itemIndex];
double value = 0.0;
double enumValue = 0.0;
if (!FreeMarkerValue(lane, item, value, enumValue))
{
return false;
}
bool hasPrev = false;
double delta = 0.0;
double timeDelta = 0.0;
FreeMarkerDelta(lane, itemIndex, value, hasPrev, delta, timeDelta);
// 超出视界**直接不显示**(用户明确要求:不按常规数据点那样贴边定格 + 三角指示)。
const float rawX = TimeToX(item.time);
if (rawX < m_plotRect.X - 0.5f || rawX > m_plotRect.X + m_plotRect.Width + 0.5f)
{
return false;
}
const LaneVisual& visual = m_lanes[static_cast<std::size_t>(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);
const float rawY = ValueToY(value, visual);
if (!std::isfinite(rawY) || rawY < clipTop - 0.5f || rawY > clipBottom + 0.5f)
{
return false; // 纵向越界同样直接不显示
}
// 枚举文本按**采样原值**查找(与光标读数完全一致)。
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);
const float closeSize = kFreeMarkerCloseSize * m_scale;
const float gap = kFreeMarkerCloseGap * m_scale;
const float textWidth = UiMeasureTextWidth(m_fonts->monoSmall, text) + 10.0f * m_scale;
const float textHeight = 17.0f * m_scale;
const float totalWidth = textWidth + gap + closeSize;
float left = rawX + 6.0f * m_scale;
if (left + totalWidth > m_plotRect.X + m_plotRect.Width - 2.0f * m_scale)
{
left = rawX - 6.0f * m_scale - totalWidth;
}
left = std::max(m_plotRect.X + 2.0f * m_scale,
std::min(left, m_plotRect.X + m_plotRect.Width - totalWidth - 2.0f * m_scale));
float top = rawY - textHeight * 0.5f;
top = std::max(clipTop + 1.0f * m_scale, std::min(top, clipBottom - textHeight - 1.0f * m_scale));
label = gd::RectF(left, top, textWidth, textHeight);
close = gd::RectF(left + textWidth + gap, top + (textHeight - closeSize) * 0.5f, closeSize,
closeSize);
return true;
}
void PlotView::PaintFreeMarkerCapsule(gd::Graphics& g, const gd::RectF& label,
const std::wstring& text, std::uint32_t color,
const gd::RectF& close, bool closeHot)
{
const UiInk ink = UiContrastInk(color);
UiFillRoundRect(g, label, 3.0f * m_scale, WithAlpha(color, 0xF0));
DrawLabelOutline(g, label, 3.0f * m_scale, ink.outline);
UiText(g, text, m_fonts->monoSmall, label, ink.text, TextAlign::CENTER);
if (close.Width <= 0.0f)
{
return;
}
// ✕ 按钮:悬停时用 danger 底色 + 叉(与面板里的 ✕ 同一套语义)。
UiFillRoundRect(g, close, 3.0f * m_scale,
closeHot ? WithAlpha(m_theme->danger, 0x66) : WithAlpha(color, 0xC0));
const float crossPad = 3.5f * m_scale;
const std::uint32_t crossColor = closeHot ? m_theme->danger : ink.text;
UiDrawLine(g, close.X + crossPad, close.Y + crossPad, close.X + close.Width - crossPad,
close.Y + close.Height - crossPad, crossColor, 1.4f * m_scale);
UiDrawLine(g, close.X + close.Width - crossPad, close.Y + crossPad, close.X + crossPad,
close.Y + close.Height - crossPad, crossColor, 1.4f * m_scale);
}
void PlotView::PaintFreeMarker(gd::Graphics& g, const std::vector<LaneVisual>& lanes)
{
m_freeMarkerHits.clear();
if (m_tab == nullptr || m_dataSet == nullptr || m_fonts == nullptr)
{
return;
}
const std::uint32_t lineColor = m_theme->accent;
const int saveState = g.Save();
g.SetClip(m_plotRect, gd::CombineModeIntersect);
const std::vector<Lane>& tabLanes = m_tab->Lanes();
const std::size_t count = std::min(lanes.size(), tabLanes.size());
// ---- (1) 已固定的数据点(含各自的 ✕)----
for (std::size_t laneIndex = 0; laneIndex < count; ++laneIndex)
{
const Lane& lane = tabLanes[laneIndex];
if (!lane.freeMarker || lane.freeMarkerItems.empty())
{
continue;
}
for (std::size_t itemIndex = 0; itemIndex < lane.freeMarkerItems.size(); ++itemIndex)
{
gd::RectF label;
gd::RectF close;
std::wstring text;
if (!FreeMarkerLabelRects(static_cast<int>(laneIndex), itemIndex, label, close, text))
{
continue; // 超出视界直接不显示(不贴边、不画三角)
}
const Lane::FreeMarkerItem& item = lane.freeMarkerItems[itemIndex];
const ResolvedSignal resolved = ResolveSignal(item.ref);
if (resolved.style == nullptr)
{
continue;
}
const std::uint32_t signalColor = StyleColor(*resolved.style);
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 float x = TimeToX(item.time);
UiDrawLine(g, x, clipTop, x, clipBottom, WithAlpha(signalColor, 0x70),
1.0f * m_scale);
// 数据点:与光标的数据点同一画法(圆点)。
const float radius = 3.4f * m_scale;
gd::SolidBrush dot(ToGdiColor(signalColor));
const float dotY = valueOk ? ValueToY(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);
FreeMarkerHit hit;
hit.laneIndex = static_cast<int>(laneIndex);
hit.itemIndex = itemIndex;
hit.close = close;
m_freeMarkerHits.push_back(hit);
}
}
// ---- (2) 跟随鼠标的纵向光标(仅限本网格,上下各一个三角把手)----
const FreeMarkerProbe& probe = m_freeMarker;
if (probe.valid && probe.laneIndex >= 0 &&
probe.laneIndex < static_cast<int>(lanes.size()))
{
const LaneVisual& visual = lanes[static_cast<std::size_t>(probe.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);
float x = probe.x;
x = std::max(visual.rect.X + kFreeMarkerLineInset * m_scale,
std::min(x, visual.rect.X + visual.rect.Width - kFreeMarkerLineInset * m_scale));
if (clipBottom > clipTop)
{
UiDrawLine(g, x, clipTop, x, clipBottom, lineColor, 1.4f * m_scale);
// 上把手:尖端朝下(与常规光标的顶部把手一致);下把手:尖端朝上(用户明确要求)。
const float arm = 5.0f * m_scale;
gd::SolidBrush handle(ToGdiColor(lineColor));
gd::PointF top[3];
top[0] = gd::PointF(x - arm, clipTop);
top[1] = gd::PointF(x + arm, clipTop);
top[2] = gd::PointF(x, clipTop + arm * 1.4f);
g.FillPolygon(&handle, top, 3);
gd::PointF bottom[3];
bottom[0] = gd::PointF(x - arm, clipBottom);
bottom[1] = gd::PointF(x + arm, clipBottom);
bottom[2] = gd::PointF(x, clipBottom - arm * 1.4f);
g.FillPolygon(&handle, bottom, 3);
}
// 就近数据点的取值胶囊(**不显示时间读数**,用户明确要求)。
if (probe.ref.IsValid() && probe.time >= Tab().ViewMin() && probe.time <= Tab().ViewMax())
{
const ResolvedSignal resolved = ResolveSignal(probe.ref);
if (resolved.data != nullptr && resolved.style != nullptr)
{
const std::uint32_t signalColor = StyleColor(*resolved.style);
const std::wstring* enumText = resolved.data->enumText.Find(probe.enumValue);
const std::wstring text = FreeMarkerValueText(probe.value, false, 0.0, enumText);
const float textWidth =
UiMeasureTextWidth(m_fonts->monoSmall, text) + 10.0f * m_scale;
const float textHeight = 17.0f * m_scale;
float left = x + 6.0f * m_scale;
if (left + textWidth > m_plotRect.X + m_plotRect.Width - 2.0f * m_scale)
{
left = x - 6.0f * m_scale - textWidth;
}
left = std::max(m_plotRect.X + 2.0f * m_scale,
std::min(left, m_plotRect.X + m_plotRect.Width - textWidth -
2.0f * m_scale));
float top = probe.y - textHeight * 0.5f;
top = std::max(clipTop + 1.0f * m_scale,
std::min(top, clipBottom - textHeight - 1.0f * m_scale));
const gd::RectF label(left, top, textWidth, textHeight);
PaintFreeMarkerCapsule(g, label, text, signalColor, gd::RectF(), false);
// 吸附提示:命中自动点线判据时数据点被吸附到采样点,用一圈描边示意
//(与"可拖拽"的光晕区分:这里只是单点圆环,不描整条曲线)。
const float radius = 3.4f * m_scale;
gd::SolidBrush dot(ToGdiColor(signalColor));
g.FillEllipse(&dot, x - radius, probe.y - radius, radius * 2.0f, radius * 2.0f);
if (probe.snapped)
{
gd::Pen ring(ToGdiColor(lineColor), 1.2f * m_scale);
g.DrawEllipse(&ring, x - radius - 3.0f * m_scale, probe.y - radius - 3.0f * m_scale,
(radius + 3.0f * m_scale) * 2.0f, (radius + 3.0f * m_scale) * 2.0f);
}
}
}
}
g.Restore(saveState);
// 悬停在固定点 ✕ 上时给出提示(与面板 ✕ 的提示风格一致)。
if (m_freeMarkerHoverLane >= 0 && m_freeMarkerHoverItem != static_cast<std::size_t>(-1))
{
PaintFreeMarkerTooltip(g, L"点击移除此固定数据点");
}
}
void PlotView::PaintFreeMarkerTooltip(gd::Graphics& g, const std::wstring& text)
{
if (text.empty() || m_fonts == nullptr)
{
return;
}
const float pad = 5.0f * m_scale;
const float width = UiMeasureTextWidth(m_fonts->uiSmall, text) + pad * 2.0f;
const float height = 18.0f * m_scale;
float left = static_cast<float>(m_mouseX) + 12.0f * m_scale;
float top = static_cast<float>(m_mouseY) + 16.0f * m_scale;
if (left + width > m_plotRect.X + m_plotRect.Width - 2.0f * m_scale)
{
left = m_plotRect.X + m_plotRect.Width - width - 2.0f * m_scale;
}
if (top + height > m_plotRect.Y + m_plotRect.Height - 2.0f * m_scale)
{
top = static_cast<float>(m_mouseY) - height - 10.0f * m_scale;
}
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);
}
PlotView::FreeMarkerProbe PlotView::ProbeFreeMarker(int mouseX, int mouseY) const
{
FreeMarkerProbe probe;
if (m_tab == nullptr || m_dataSet == nullptr ||
!UiPointInRect(m_plotRect, static_cast<float>(mouseX), static_cast<float>(mouseY)))
{
return probe;
}
const int laneIndex = LaneAtY(mouseY);
if (laneIndex < 0 || laneIndex >= static_cast<int>(m_lanes.size()) ||
laneIndex >= static_cast<int>(m_tab->Lanes().size()))
{
return probe;
}
const Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
if (!lane.freeMarker)
{
return probe;
}
const LaneVisual& visual = m_lanes[static_cast<std::size_t>(laneIndex)];
probe.valid = true;
probe.laneIndex = laneIndex;
probe.time = XToTime(static_cast<float>(mouseX));
probe.x = static_cast<float>(mouseX);
// 在网格内所有可见信号中找**离鼠标最近**的那个数据点。
double bestDistance = std::numeric_limits<double>::infinity();
double bestTime = probe.time;
double bestValue = 0.0;
double bestEnum = 0.0;
float bestY = 0.0f;
bool bestSnapped = false;
for (const SignalRef& ref : lane.signals)
{
const ResolvedSignal resolved = ResolveSignal(ref);
if (resolved.data == nullptr || resolved.style == nullptr || resolved.axis == nullptr ||
!resolved.visible)
{
continue;
}
// 「自动点线」判据成立时吸附到最近采样点(与光标吸附同一规则)。
// ⚠️ 用户明确要求:只要判据成立就吸附,**不**管当前是散点还是用户自己改过的样式。
const bool sparse = MinorCellSparse(*resolved.axis, *resolved.data, resolved.style->offsetTime);
double time = probe.time;
bool snapped = false;
if (sparse)
{
double candidate = 0.0;
if (SnapTimeToSample(probe.time, candidate))
{
time = candidate;
snapped = true;
}
}
const bool interpolate = (resolved.style->mode != DrawMode::SCATTER);
double sampled = 0.0;
if (!SampleDisplayedSignalAt(*resolved.axis, *resolved.data, *resolved.style, time,
interpolate, sampled) ||
!std::isfinite(sampled))
{
continue;
}
const double value = ApplyDrawTransform(*resolved.style, sampled);
if (!std::isfinite(value))
{
continue;
}
const float y = ValueToY(value, visual);
const double distance = std::fabs(static_cast<double>(y) - static_cast<double>(mouseY));
if (distance >= bestDistance)
{
continue;
}
bestDistance = distance;
bestTime = time;
bestValue = value;
bestEnum = sampled;
bestY = y;
bestSnapped = snapped;
probe.ref = ref;
}
if (!probe.ref.IsValid())
{
return probe; // 网格内没有任何可用数据点:只画纵向线
}
probe.time = bestTime;
probe.x = TimeToX(bestTime);
probe.value = bestValue;
probe.enumValue = bestEnum;
probe.y = bestY;
probe.snapped = bestSnapped;
return probe;
}
void PlotView::PaintEdgeCursor(gd::Graphics& g, const std::vector<LaneVisual>& lanes)
{
if (m_tab == nullptr || m_dataSet == nullptr)
@@ -6363,6 +6943,7 @@ namespace jdi
const bool centerAxis = (kind == EdgePanelKind::CENTER);
const bool meanFilter = (kind == EdgePanelKind::MEAN);
const bool weakPeak = (kind == EdgePanelKind::PEAK);
const bool freeMarker = (kind == EdgePanelKind::MARK);
// 「均值滤波」与「弱峰调整」共用「逐条目 + 底部按钮」排布。
const bool itemized = (centerAxis || meanFilter || weakPeak);
const bool hasButton = (meanFilter || weakPeak);
@@ -6470,8 +7051,9 @@ namespace jdi
const std::wstring title =
meanFilter ? std::wstring(L"均值滤波")
: (weakPeak ? std::wstring(kWeakPeakTitle)
: ((centerAxis ? L"中心轴边沿计数" : L"边沿计数") +
EdgeRangeSuffix()));
: (freeMarker ? std::wstring(L"自由标记")
: ((centerAxis ? L"中心轴边沿计数" : L"边沿计数") +
EdgeRangeSuffix())));
UiText(g, title, m_fonts->uiSmall,
gd::RectF(badgeRect.X + badgeRect.Width + 8.0f * m_scale, panel.Y,
panel.Width - badgeRect.Width - 8.0f * m_scale - titleHeight,
@@ -6719,6 +7301,138 @@ namespace jdi
}
}
// 「自由标记」面板:逐信号一行名称(带清除全部固定点的 ✕),
// 下面按时间升序列出该信号的固定点(`值(±增量) = 枚举` + 右对齐的 `t:… Δt:…` + 行尾 ✕)。
if (freeMarker)
{
const float closeSize = kFreeMarkerCloseSize * m_scale;
const float gap = 4.0f * m_scale;
bool markerAny = false;
for (const Lane::FreeMarkerItem& item : tabLane.freeMarkerItems)
{
markerAny = markerAny || item.ref.IsValid();
}
if (!markerAny)
{
UiText(g, L"在网格内单击可固定数据点",
m_fonts->uiSmall,
gd::RectF(panel.X + pad, panel.Y + titleHeight + pad,
panel.Width - pad * 2.0f, kFreeMarkerNameRowHeight * m_scale),
m_theme->textMuted, TextAlign::LEFT);
}
for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex)
{
const SignalRef& ref = layout.rowRefs[rowIndex];
const ResolvedSignal resolved = ResolveSignal(ref);
if (resolved.data == nullptr || resolved.style == nullptr)
{
continue;
}
const std::uint32_t signalColor = StyleColor(*resolved.style);
// 名称行矩形直接取自布局(与 ✕ 同一套几何,绘制侧不反推)。
const gd::RectF nameRect =
(rowIndex < layout.rowNameRects.size())
? layout.rowNameRects[rowIndex]
: gd::RectF(panel.X + pad, panel.Y + titleHeight + pad,
panel.Width - pad * 2.0f - (closeSize + gap),
kFreeMarkerNameRowHeight * m_scale);
UiText(g, UiEllipsize(resolved.data->name, m_fonts->uiSmall, nameRect.Width),
m_fonts->uiSmall, nameRect, signalColor, TextAlign::LEFT);
// 信号名右侧的 ✕:清除该信号的**全部**固定点。
if (rowIndex < layout.rowNameCloses.size() &&
layout.rowNameCloses[rowIndex].Width > 0.0f)
{
const gd::RectF& closeRect = layout.rowNameCloses[rowIndex];
const bool hot = (m_freeMarkerPanelHoverLane == static_cast<int>(index) &&
m_freeMarkerPanelHoverSignal == ref &&
m_freeMarkerPanelHoverItem == static_cast<std::size_t>(-1));
UiFillRoundRect(g, closeRect, 3.0f * m_scale,
hot ? WithAlpha(m_theme->danger, 0x66) : WithAlpha(signalColor, 0xC0));
const float crossPad = 3.5f * m_scale;
const std::uint32_t crossColor = hot ? m_theme->danger : m_theme->textOnAccent;
UiDrawLine(g, closeRect.X + crossPad, closeRect.Y + crossPad,
closeRect.X + closeRect.Width - crossPad,
closeRect.Y + closeRect.Height - crossPad, crossColor,
1.4f * m_scale);
UiDrawLine(g, closeRect.X + closeRect.Width - crossPad,
closeRect.Y + crossPad, closeRect.X + crossPad,
closeRect.Y + closeRect.Height - crossPad, crossColor,
1.4f * m_scale);
}
}
// 固定点行(已按信号分组 + 组内时间升序展开)。
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)
{
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 =
FreeMarkerValueText(valueOk ? value : 0.0, hasPrev, delta, enumText);
const std::wstring timeText = FreeMarkerTimeText(item.time);
const std::wstring deltaText = FreeMarkerDeltaText(hasPrev, timeDelta);
const float timeRight = layout.markerTimeRight;
const float deltaRight = layout.markerDeltaRight;
const float valueWidth =
std::max(0.0f, timeRight - gap * 2.0f - (panel.X + pad));
UiText(g, UiEllipsize(valueText, m_fonts->monoSmall, valueWidth),
m_fonts->monoSmall,
gd::RectF(panel.X + pad, markerRow.rect.Y, valueWidth,
markerRow.rect.Height),
m_theme->text, TextAlign::LEFT);
// 时间列各自右对齐 ⇒ 所有条目上下对齐(用户明确要求)。
const float timeWidth = UiMeasureTextWidth(m_fonts->monoSmall, timeText);
UiText(g, timeText, m_fonts->monoSmall,
gd::RectF(timeRight - timeWidth, markerRow.rect.Y, timeWidth,
markerRow.rect.Height),
m_theme->textMuted, TextAlign::LEFT);
if (!deltaText.empty())
{
const float deltaWidth = UiMeasureTextWidth(m_fonts->monoSmall, deltaText);
UiText(g, deltaText, m_fonts->monoSmall,
gd::RectF(deltaRight - deltaWidth, markerRow.rect.Y, deltaWidth,
markerRow.rect.Height),
m_theme->textMuted, TextAlign::LEFT);
}
// 行尾 ✕:移除该固定点。
const gd::RectF& closeRect = markerRow.close;
const bool hot = (m_freeMarkerPanelHoverLane == static_cast<int>(index) &&
m_freeMarkerPanelHoverItem == markerRow.itemIndex);
UiFillRoundRect(g, closeRect, 3.0f * m_scale,
hot ? WithAlpha(m_theme->danger, 0x66) : m_theme->cardBg);
UiDrawRoundRect(g, closeRect, 3.0f * m_scale,
hot ? m_theme->danger : m_theme->border, 1.0f);
const float crossPad = 3.5f * m_scale;
const std::uint32_t crossColor = hot ? 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.2f * m_scale);
UiDrawLine(g, closeRect.X + closeRect.Width - crossPad, closeRect.Y + crossPad,
closeRect.X + crossPad, closeRect.Y + closeRect.Height - crossPad,
crossColor, 1.2f * m_scale);
}
}
float rowY = panel.Y + titleHeight + pad;
for (const SignalRef& ref : tabLane.signals)
{
@@ -6727,9 +7441,9 @@ namespace jdi
{
continue;
}
if (itemized)
if (itemized || freeMarker)
{
// 逐条目绘制已在上面完成。
// 逐条目绘制已在上面完成(自由标记同样是逐条目排布)。
continue;
}
const std::uint32_t signalColor = StyleColor(*resolved.style);