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

需求(用户):网格右键菜单「实验性功能」里新增带 √ 的「自由标记」。开启后网格内跟随鼠标
出现一条仅限本网格的纵向光标(上下各一个把手、不显示时间值),穿过信号时只标注离鼠标
最近的数据点;左键单击把该点固定(带 ✕ 可移除,鼠标移开仍显示、超出视界直接不显示),
后续固定点显示为相对前一个的增量;面板按信号分组列出「值(±增量) = 枚举」+「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

+350 -1
View File
@@ -558,6 +558,59 @@ namespace jdi
return true;
}
// 「自由标记」的时间编辑:
// * `FREE_MARKER_TIME`:直接设定该固定点的**自身时刻**(秒);
// * `FREE_MARKER_DELTA`:设定它相对「同信号前一个固定点」的**时间间隔**(秒),
// 前方固定点不动,本点被移动到 `前方时刻 + 输入间隔`。
// 两者都只改 `time`;面板条目的顺序由 `SortedFreeMarkerIndices` 每次按时间重排,
// 因此"越过后面固定点时交换条目位置"是自动发生的,无需手工搬动数组。
if (target == PlotEditTarget::FREE_MARKER_TIME || target == PlotEditTarget::FREE_MARKER_DELTA)
{
const std::size_t itemIndex = m_editMarkerIndex;
m_editMarkerIndex = static_cast<std::size_t>(-1);
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
if (itemIndex >= lane.freeMarkerItems.size())
{
return true;
}
Lane::FreeMarkerItem& item = lane.freeMarkerItems[itemIndex];
double movedTime = 0.0;
if (target == PlotEditTarget::FREE_MARKER_TIME)
{
movedTime = parsed;
}
else
{
// 先取"同信号前一个固定点"的时刻(当前排序下的左邻居)。
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; // 最左边:没有前一个固定点,间隔无意义
}
movedTime = lane.freeMarkerItems[order[position - 1]].time + std::fabs(parsed);
}
// 夹在数据时间范围外一点点以内(与光标时间编辑同一套宽容度),并拒绝非有限值。
double low = 0.0;
double high = 1.0;
TimeBounds(low, high);
const double span = std::max(high - low, kMinTimeSpan);
if (std::isfinite(movedTime))
{
item.time = ClampDouble(movedTime, low - span * 10.0, high + span * 10.0);
}
return true;
}
// 「弱峰调整」迭代次数滑块的**上限**:用户可直接输入一个正整数作为新的量程上限。
// 规则(用户明确要求):
// * 输入框只允许输入正整数(过滤策略为 BYTE = 纯数字,粘贴同样会被清洗);
@@ -900,6 +953,26 @@ namespace jdi
// 「构建为新信号」按钮:手型光标作为可点击提示。
return LoadCursorW(nullptr, IDC_HAND);
}
// 「自由标记」面板:行尾 ✕ 与信号名右侧 ✕ 都是可点击控件(手型)。
if (static_cast<EdgePanelKind>(panelKind) == EdgePanelKind::MARK)
{
for (const EdgePanelLayout::MarkerRow& row : layout.markerRows)
{
if (row.close.Width > 0.0f &&
UiPointInRect(row.close, static_cast<float>(x), static_cast<float>(y)))
{
return LoadCursorW(nullptr, IDC_HAND);
}
}
for (const gd::RectF& rect : layout.rowNameCloses)
{
if (rect.Width > 0.0f &&
UiPointInRect(rect, static_cast<float>(x), static_cast<float>(y)))
{
return LoadCursorW(nullptr, IDC_HAND);
}
}
}
// 「弱峰调整」的逐信号滑块:轨道 → 左右箭头;迭代次数右侧数值 → 手型(可点击改上限)。
if (static_cast<EdgePanelKind>(panelKind) == EdgePanelKind::PEAK)
{
@@ -958,6 +1031,16 @@ namespace jdi
return nullptr;
}
// 「自由标记」:网格内固定点的 ✕ 是可点击控件(手型 + 悬停高亮 + 提示气泡)。
for (const FreeMarkerHit& hit : m_freeMarkerHits)
{
if (hit.close.Width > 0.0f &&
UiPointInRect(hit.close, static_cast<float>(x), static_cast<float>(y)))
{
return LoadCursorW(nullptr, IDC_HAND);
}
}
// 光标时间读数标签与双光标差值标签:**悬停(未按下)时不改变鼠标形状**。
// 两个标签都叠在光标线的正上方,因此必须先于"光标线"判定返回 nullptr,
// 由调用方回退成箭头;只有真正拖拽时才切成左右箭头(见 CURSOR_MOVE / CURSOR_SPAN_MOVE)。
@@ -1351,6 +1434,94 @@ namespace jdi
bool changed = false;
// ---- 「自由标记」:跟随光标解析 + 两处 ✕ 的悬停态 ----
// 跟随光标是**每帧随鼠标变化**的叠加层(不进任何缓存),因此只要鼠标位置变了就得重绘。
{
const FreeMarkerProbe probe = ProbeFreeMarker(x, y);
const bool probeChanged = (probe.valid != m_freeMarker.valid) ||
(probe.laneIndex != m_freeMarker.laneIndex) ||
(probe.time != m_freeMarker.time) ||
(probe.snapped != m_freeMarker.snapped) ||
(probe.ref != m_freeMarker.ref);
m_freeMarker = probe;
if (probeChanged || (probe.valid && mouseMoved))
{
changed = true;
}
}
// 网格内固定点 ✕ 的悬停(矩形由绘制时登记,见 `m_freeMarkerHits`)。
{
int hoverLane = -1;
std::size_t hoverItem = static_cast<std::size_t>(-1);
for (const FreeMarkerHit& hit : m_freeMarkerHits)
{
if (hit.close.Width > 0.0f &&
UiPointInRect(hit.close, static_cast<float>(x), static_cast<float>(y)))
{
hoverLane = hit.laneIndex;
hoverItem = hit.itemIndex;
break;
}
}
if (hoverLane != m_freeMarkerHoverLane || hoverItem != m_freeMarkerHoverItem)
{
m_freeMarkerHoverLane = hoverLane;
m_freeMarkerHoverItem = hoverItem;
changed = true;
}
}
// 面板内 ✕ 的悬停(信号名 ✕ 与固定点行 ✕)。
{
int hoverLane = -1;
std::size_t hoverItem = static_cast<std::size_t>(-1);
SignalRef hoverSignal;
int markLane = -1;
int markKind = 0;
if (m_tab != nullptr &&
TopPanelAtPoint(static_cast<float>(x), static_cast<float>(y), markLane, markKind) &&
static_cast<EdgePanelKind>(markKind) == EdgePanelKind::MARK)
{
const EdgePanelLayout layout = EdgePanelLayoutFor(markLane, EdgePanelKind::MARK);
// 固定点行 ✕ 优先(更具体)。
for (const EdgePanelLayout::MarkerRow& row : layout.markerRows)
{
if (row.close.Width > 0.0f &&
UiPointInRect(row.close, static_cast<float>(x), static_cast<float>(y)))
{
hoverLane = markLane;
hoverItem = row.itemIndex;
break;
}
}
if (hoverLane < 0)
{
for (std::size_t rowIndex = 0; rowIndex < layout.rowNameCloses.size(); ++rowIndex)
{
const gd::RectF& rect = layout.rowNameCloses[rowIndex];
if (rect.Width > 0.0f &&
UiPointInRect(rect, static_cast<float>(x), static_cast<float>(y)) &&
rowIndex < layout.rowRefs.size())
{
hoverLane = markLane;
hoverItem = static_cast<std::size_t>(-1);
hoverSignal = layout.rowRefs[rowIndex];
break;
}
}
}
}
if (hoverLane != m_freeMarkerPanelHoverLane || hoverItem != m_freeMarkerPanelHoverItem ||
!(hoverSignal == m_freeMarkerPanelHoverSignal))
{
m_freeMarkerPanelHoverLane = hoverLane;
m_freeMarkerPanelHoverItem = hoverItem;
m_freeMarkerPanelHoverSignal = hoverSignal;
changed = true;
}
}
// 鼠标压在实验性功能面板上时不再触发信号悬停提示(面板本来就盖住波形),
// 但该面板所属的网格仍然要高亮(与鼠标直接停在网格上一致)。
// 按**层序**取最上层的面板(与点击命中保持一致,重叠时以最上层为准)。
@@ -1955,6 +2126,39 @@ namespace jdi
}
}
// 「自由标记」面板:行尾 ✕ 移除该固定点;信号名右侧 ✕ 清除该信号的全部固定点。
// 与其它逐行 ✕ / 复位控件同一语义:**按下即生效**(不进拖拽、不穿透)。
if (kind == EdgePanelKind::MARK && m_tab != nullptr)
{
Lane& tabLane = m_tab->Lanes()[static_cast<std::size_t>(index)];
for (const EdgePanelLayout::MarkerRow& row : layout.markerRows)
{
if (row.close.Width > 0.0f && UiPointInRect(row.close, fx, fy))
{
tabLane.RemoveFreeMarkerAt(row.itemIndex);
m_dragMode = PlotDragMode::NONE;
m_freeMarkerPanelHoverLane = -1;
m_freeMarkerPanelHoverItem = static_cast<std::size_t>(-1);
m_freeMarkerPanelHoverSignal = SignalRef();
return true;
}
}
for (std::size_t rowIndex = 0; rowIndex < layout.rowNameCloses.size(); ++rowIndex)
{
const gd::RectF& rect = layout.rowNameCloses[rowIndex];
if (rect.Width > 0.0f && UiPointInRect(rect, fx, fy) &&
rowIndex < layout.rowRefs.size())
{
tabLane.RemoveFreeMarkersOf(layout.rowRefs[rowIndex]);
m_dragMode = PlotDragMode::NONE;
m_freeMarkerPanelHoverLane = -1;
m_freeMarkerPanelHoverItem = static_cast<std::size_t>(-1);
m_freeMarkerPanelHoverSignal = SignalRef();
return true;
}
}
}
if (UiPointInRect(layout.title, fx, fy))
{
m_dragMode = PlotDragMode::EDGE_PANEL_MOVE;
@@ -1964,7 +2168,6 @@ namespace jdi
m_edgePanelDragOffset.Y = fy - layout.panel.Y;
return true;
}
// 面板主体:消费该次点击,避免误触发底层的平移/选区/信号拖拽。
m_dragMode = PlotDragMode::NONE;
return true;
@@ -1988,6 +2191,43 @@ namespace jdi
return false;
}
// ---- 「自由标记」----
// (1) 网格内固定点的 ✕:按下即移除该固定点(矩形由绘制时登记)。
for (const FreeMarkerHit& hit : m_freeMarkerHits)
{
if (hit.close.Width > 0.0f && UiPointInRect(hit.close, fx, fy) && m_tab != nullptr &&
hit.laneIndex >= 0 && hit.laneIndex < static_cast<int>(m_tab->Lanes().size()))
{
Lane& tabLane = m_tab->Lanes()[static_cast<std::size_t>(hit.laneIndex)];
tabLane.RemoveFreeMarkerAt(hit.itemIndex);
m_dragMode = PlotDragMode::NONE;
return true;
}
}
// (2) 在开启「自由标记」的网格内按下:单击固定数据点、拖动仍是原有语义
// (常规模式 = 平移;选区放大模式 = 框选)。
// ⚠️ 用户明确要求:「仅单击时」才算固定数据点 —— 因此这里**只登记意图**,
// 真正固定发生在 `OnLButtonUp` 且整段操作没有发生过拖动。
{
const int freeLane = LaneAtY(y);
if (freeLane >= 0 && freeLane < static_cast<int>(m_tab ? m_tab->Lanes().size() : 0) &&
m_tab->Lanes()[static_cast<std::size_t>(freeLane)].freeMarker)
{
const FreeMarkerProbe probe = ProbeFreeMarker(x, y);
if (probe.valid && probe.ref.IsValid())
{
m_freeMarkerPendingFix = true;
m_freeMarkerPendingLane = freeLane;
m_dragLane = freeLane;
// 拖动时保持原有语义:选区放大模式下走 RUBBER_SELECT,否则平移。
m_dragMode = (Tab().ZoomMode() == PlotZoomMode::SELECT) ? PlotDragMode::RUBBER_SELECT
: PlotDragMode::PAN;
return true;
}
}
}
const PlotZoomMode mode = Tab().ZoomMode();
if (mode == PlotZoomMode::SELECT)
{
@@ -2060,6 +2300,39 @@ namespace jdi
{
bool changed = (m_dragMode != PlotDragMode::NONE);
// 「自由标记」:整段操作**没有拖动过**(原地单击)才把当时标注的数据点固定下来。
// ⚠️ 必须在下面的 `switch (m_dragMode)` **之前**处理,并跳过选区放大的收尾逻辑:
// 用户明确要求"仅单击时为标记固定数据点,按下后拖动才进入选区放大"。
if (m_freeMarkerPendingFix)
{
const int laneIndex = m_freeMarkerPendingLane;
const bool plainClick = !m_dragMoved && m_rubberAxis == PlotRubberAxis::UNDECIDED;
m_freeMarkerPendingFix = false;
m_freeMarkerPendingLane = -1;
if (plainClick && laneIndex >= 0 && m_tab != nullptr &&
laneIndex < static_cast<int>(m_tab->Lanes().size()))
{
const FreeMarkerProbe probe = ProbeFreeMarker(x, y);
Lane& tabLane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
if (probe.valid && probe.laneIndex == laneIndex && probe.ref.IsValid())
{
tabLane.AddFreeMarker(probe.ref, probe.time);
changed = true;
}
}
// 原地单击时**不要**落到 RUBBER_SELECT 分支(否则会以 0 宽选区收尾)。
if (plainClick)
{
m_dragMode = PlotDragMode::NONE;
m_rubberAxis = PlotRubberAxis::UNDECIDED;
m_dragLane = -1;
m_dragMoved = false;
m_rubberRect = gd::RectF();
ReleaseHint();
return true;
}
}
// 右下角「回到最上方」按钮:按下后仍在按钮上抬起 ⇒ 平滑滚回最上方。
if (m_backToTopPressed)
{
@@ -2098,6 +2371,9 @@ namespace jdi
case EdgePanelKind::PEAK:
SetLaneWeakPeak(lane, false);
break;
case EdgePanelKind::MARK:
SetLaneFreeMarker(lane, false);
break;
case EdgePanelKind::COUNT:
default:
SetLaneEdgeCount(lane, false);
@@ -2223,6 +2499,60 @@ namespace jdi
int panelKind = 0;
if (TopPanelAtPoint(static_cast<float>(x), static_cast<float>(y), panelLane, panelKind))
{
// 「自由标记」面板:双击某一固定点行 → 编辑它的时间(左侧是自身时刻、
// 右侧是相对前一固定点的间隔,对应行内两列的位置)。
if (static_cast<EdgePanelKind>(panelKind) == EdgePanelKind::MARK && m_tab != nullptr &&
panelLane >= 0 && panelLane < static_cast<int>(m_tab->Lanes().size()))
{
const EdgePanelLayout layout =
EdgePanelLayoutFor(panelLane, EdgePanelKind::MARK);
Lane& tabLane = m_tab->Lanes()[static_cast<std::size_t>(panelLane)];
for (const EdgePanelLayout::MarkerRow& row : layout.markerRows)
{
if (row.rect.Width <= 0.0f ||
!UiPointInRect(row.rect, static_cast<float>(x), static_cast<float>(y)) ||
row.itemIndex >= tabLane.freeMarkerItems.size())
{
continue;
}
const Lane::FreeMarkerItem& item = tabLane.freeMarkerItems[row.itemIndex];
// 分隔点:时间列左边界(`markerTimeRight` 减去最宽文本的近似值)。
// 右侧(Δt 列)→ 编辑间隔;左侧 → 编辑自身时刻。
const float splitX = layout.markerTimeRight - 40.0f * m_scale;
m_editMarkerIndex = row.itemIndex;
if (static_cast<float>(x) >= splitX)
{
// 间隔编辑:初始值 = 当前间隔(秒)。最左边的点没有前一项 ⇒ 不允许编辑。
std::vector<std::size_t> order;
SortedFreeMarkerIndices(tabLane, item.ref, order);
if (order.empty() || order.front() == row.itemIndex)
{
return true; // 最左边的固定点没有"相对前一个"的间隔
}
double previousTime = 0.0;
for (std::size_t index = 0; index < order.size(); ++index)
{
if (order[index] == row.itemIndex && index > 0)
{
previousTime = tabLane.freeMarkerItems[order[index - 1]].time;
break;
}
}
const gd::RectF anchor(layout.markerTimeRight,
row.rect.Y,
std::max(10.0f, layout.markerDeltaRight - layout.markerTimeRight),
row.rect.Height);
BeginEdit(PlotEditTarget::FREE_MARKER_DELTA, panelLane,
FormatValue(item.time - previousTime), anchor);
m_editFieldRect = row.rect;
return true;
}
BeginEdit(PlotEditTarget::FREE_MARKER_TIME, panelLane, FormatValue(item.time),
row.rect);
m_editFieldRect = row.rect;
return true;
}
}
return true;
}
}
@@ -2358,6 +2688,25 @@ namespace jdi
m_hoverLegend = SignalRef();
changed = true;
}
// 「自由标记」的跟随光标随鼠标离开绘图区而消失(已固定的点仍然保留)。
if (m_freeMarker.valid)
{
m_freeMarker = FreeMarkerProbe();
changed = true;
}
if (m_freeMarkerHoverLane != -1 || m_freeMarkerHoverItem != static_cast<std::size_t>(-1))
{
m_freeMarkerHoverLane = -1;
m_freeMarkerHoverItem = static_cast<std::size_t>(-1);
changed = true;
}
if (m_freeMarkerPanelHoverLane != -1)
{
m_freeMarkerPanelHoverLane = -1;
m_freeMarkerPanelHoverItem = static_cast<std::size_t>(-1);
m_freeMarkerPanelHoverSignal = SignalRef();
changed = true;
}
if (m_cursorHover)
{
m_cursorHover = false;