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

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

@@ -1053,6 +1053,12 @@ namespace jdi
weakPeak.id = PLOT_CMD_LANE_WEAK_PEAK;
weakPeak.checked = m_plot.LaneWeakPeak(laneIndex);
experimental.submenu.push_back(weakPeak);
MenuItem freeMarker;
freeMarker.label = L"自由标记";
freeMarker.id = PLOT_CMD_LANE_FREE_MARKER;
freeMarker.checked = m_plot.LaneFreeMarker(laneIndex);
experimental.submenu.push_back(freeMarker);
}
items.push_back(experimental);
@@ -1171,6 +1177,10 @@ namespace jdi
// 「实验性功能 → 弱峰调整」:切换弱峰调整曲线与参数面板。
m_plot.ToggleLaneWeakPeak(laneIndex);
break;
case PLOT_CMD_LANE_FREE_MARKER:
// 「实验性功能 → 自由标记」:切换网格内跟随鼠标的纵向光标与固定数据点面板。
m_plot.ToggleLaneFreeMarker(laneIndex);
break;
case PLOT_CMD_LANE_SPLIT:
// 「拆分当前网格」:信号按个拆成独立网格,新网格顺次插入其下方。
m_plot.SplitLane(laneIndex);
+207 -4
View File
@@ -70,7 +70,7 @@ namespace jdi
};
/**
* @brief 绘图区内的内联数值输入目标。
* @brief 绘图区内联数值输入的目标。
*/
enum class PlotEditTarget : int
{
@@ -84,7 +84,9 @@ namespace jdi
EDGE_CENTER_ITERATIONS, /**< 编辑「中心轴边沿计数」的迭代次数 N。 */
MEAN_FILTER_POINTS, /**< 编辑「均值滤波」的均值点数 X。 */
MEAN_FILTER_ITERATIONS, /**< 编辑「均值滤波」的迭代次数 N。 */
PEAK_ITERATIONS_MAX /**< 编辑「弱峰调整」某信号「迭代次数 N」滑块的**上限**(正整数)。 */
PEAK_ITERATIONS_MAX, /**< 编辑「弱峰调整」某信号「迭代次数 N」滑块的**上限**(正整数)。 */
FREE_MARKER_TIME, /**< 编辑「自由标记」某固定点的**自身时间**(秒)。 */
FREE_MARKER_DELTA /**< 编辑「自由标记」某固定点相对前一固定点的**时间间隔**(秒)。 */
};
/**
@@ -102,7 +104,8 @@ namespace jdi
PLOT_CMD_LANE_EDGE_CENTER = 8, /**< 切换本网格的「实验性功能 → 边沿计数(中心轴)」开关。 */
PLOT_CMD_LANE_SPLIT = 9, /**< 拆分当前网格:把网格内信号按个拆成独立网格并顺次下移编号。 */
PLOT_CMD_LANE_MEAN_FILTER = 10, /**< 切换本网格的「实验性功能 → 均值滤波」开关。 */
PLOT_CMD_LANE_WEAK_PEAK = 11 /**< 切换本网格的「实验性功能 → 弱峰调整」开关。 */
PLOT_CMD_LANE_WEAK_PEAK = 11, /**< 切换本网格的「实验性功能 → 弱峰调整」开关。 */
PLOT_CMD_LANE_FREE_MARKER = 12 /**< 切换本网格的「实验性功能 → 自由标记」开关。 */
};
/**
@@ -113,7 +116,8 @@ namespace jdi
COUNT = 0, /**< 边沿计数。 */
CENTER = 1, /**< 边沿计数(中心轴)。 */
MEAN = 2, /**< 均值滤波。 */
PEAK = 3 /**< 弱峰调整。 */
PEAK = 3, /**< 弱峰调整。 */
MARK = 4 /**< 自由标记。 */
};
/**
@@ -909,6 +913,34 @@ namespace jdi
*/
bool LaneWeakPeakSignalEnabled(int laneIndex, const SignalRef& ref) const;
/**
* @brief 切换某网格的「自由标记」开关。
*
* 开启后该网格内跟随鼠标显示一条仅限本网格的纵向光标(上下各一个三角把手、
* 不显示时间读数),光标穿过的信号中**离鼠标最近**的那个会被标注取值;
* 左键原地单击即把该数据点固定下来。关闭时**保留**已固定的点(用户可再次开启
* 继续查看),需要清空请用面板上的 ✕。
*
* @param [IN] laneIndex 网格下标。
* @param [IN] enabled 是否开启。
* @return 状态发生变化返回 true。
*/
bool SetLaneFreeMarker(int laneIndex, bool enabled);
/**
* @brief 查询某网格的「自由标记」开关。
* @param [IN] laneIndex 网格下标。
* @return 已开启返回 true。
*/
bool LaneFreeMarker(int laneIndex) const;
/**
* @brief 切换某网格的「自由标记」开关。
* @param [IN] laneIndex 网格下标。
* @return 切换后的状态。
*/
bool ToggleLaneFreeMarker(int laneIndex);
/**
* @brief 直接把文本解析为网格 Y 轴上限/下限并应用(供菜单内联输入框提交使用)。
* @param [IN] laneIndex 网格下标。
@@ -1579,6 +1611,40 @@ namespace jdi
std::vector<gd::RectF> rowLevelResets;
std::vector<gd::RectF> rowIterResets;
std::vector<SignalRef> rowRefs;
/**
* @brief 「自由标记」面板的一行(一个固定点)。
*
* `itemIndex` 是 `Lane::freeMarkerItems` 的下标(绘制文本、增删都通过它定位,
* 不依赖面板里的显示顺序);`rect` 是整行矩形(悬停/双击编辑时间用),
* `close` 是该行末尾的 ✕ 按钮。
*/
struct MarkerRow
{
std::size_t itemIndex = 0;
gd::RectF rect;
gd::RectF close;
};
/** 「自由标记」:每个固定点一行(按信号分组、组内按时间升序展开后的顺序)。 */
std::vector<MarkerRow> markerRows;
/**
* @brief 「自由标记」:每个信号名右侧的「清除该信号全部固定点」✕。
*
* 与 `rowRefs` 一一对应;该信号没有任何固定点时为空矩形。
*/
std::vector<gd::RectF> rowNameCloses;
/**
* @brief 「自由标记」:每个信号**名称行**的矩形(与 `rowRefs` 一一对应)。
*
* 绘制只读这个矩形(与 `rowNameCloses` 同一套横坐标),人为主观反推容易与布局漂移。
*/
std::vector<gd::RectF> rowNameRects;
/**
* @brief 「自由标记」时间列的两条右边界(`t:` 列与 `Δt:` 列)。
*
* 两列各自右对齐 ⇒ 所有条目上下对齐;首项没有 `Δt` 时该列留白。
*/
float markerTimeRight = 0.0f;
float markerDeltaRight = 0.0f;
};
/**
@@ -1589,6 +1655,108 @@ namespace jdi
*/
EdgePanelLayout EdgePanelLayoutFor(int laneIndex, EdgePanelKind kind) const;
/**
* @brief 「自由标记」跟随光标的解析结果(每次鼠标移动/每帧重算,绘制与命中共用)。
*
* 它描述「鼠标此刻所在的、开启了自由标记的网格」里应当标注的那个数据点:
* 该网格内所有可见信号中**离鼠标最近**的那一个。
*/
struct FreeMarkerProbe
{
bool valid = false; /**< 鼠标是否落在某个开启了自由标记的网格内。 */
int laneIndex = -1; /**< 该网格下标。 */
double time = 0.0; /**< 标注时刻(显示时刻;吸附时 = 采样时刻)。 */
float x = 0.0f; /**< 该时刻的屏幕横坐标。 */
SignalRef ref; /**< 就近信号(网格内没有可用信号时无效)。 */
double value = 0.0; /**< 已叠加「调整」变换的取值(用于标注与增量)。 */
double enumValue = 0.0; /**< 采样原值(用于查枚举表)。 */
float y = 0.0f; /**< 数据点的屏幕纵坐标。 */
bool snapped = false; /**< 是否吸附到了采样点(需要给出吸附提示)。 */
};
/**
* @brief 解析「自由标记」跟随光标应当标注的数据点。
* @param [IN] mouseX 鼠标客户区横坐标。
* @param [IN] mouseY 鼠标客户区纵坐标。
* @return 解析结果;鼠标不在任何开启该功能的网格内时 `valid == false`。
*/
FreeMarkerProbe ProbeFreeMarker(int mouseX, int mouseY) const;
/**
* @brief 绘制「自由标记」:跟随光标 + 全部已固定点(含各自的 ✕)。
*
* 同时重建 `m_freeMarkerHits`(固定点 ✕ 的命中矩形),供鼠标悬停与点击使用。
*/
void PaintFreeMarker(gd::Graphics& g, const std::vector<LaneVisual>& lanes);
/**
* @brief 在鼠标附近画一个小提示气泡(与面板 GX 徽标的提示同一套样式)。
* @param [IN,OUT] g 绘图上下文。
* @param [IN] text 提示文本(空串则不绘制)。
*/
void PaintFreeMarkerTooltip(gd::Graphics& g, const std::wstring& text);
/** @brief 一个固定点 ✕ 的命中矩形(绘制时重建,命中测试时读取)。 */
struct FreeMarkerHit
{
int laneIndex = -1;
std::size_t itemIndex = 0;
gd::RectF close;
};
/**
* @brief 计算某个网格里「第 index 个固定点」的标注几何(绘制与命中共源)。
*
* @param [IN] laneIndex 网格下标。
* @param [IN] itemIndex `Lane::freeMarkerItems` 下标。
* @param [OUT] label 取值胶囊矩形(**不含** ✕)。
* @param [OUT] close ✕ 按钮矩形。
* @param [OUT] text 取值文本(`值(±增量) = 枚举`,绘制侧直接复用,避免重复求解)。
* @return 该固定点可显示(在视界内、取值有效)返回 true。
*/
bool FreeMarkerLabelRects(int laneIndex, std::size_t itemIndex, gd::RectF& label,
gd::RectF& close, std::wstring& text) const;
/**
* @brief 画一个「取值胶囊」(信号色底 + 高对比度文字 + 可选 ✕ 按钮)。
*
* 网格内的固定点与跟随鼠标的预览点共用这一段(两者外观规则完全相同)。
*
* @param [IN,OUT] g 绘图上下文。
* @param [IN] label 胶囊矩形。
* @param [IN] text 文本。
* @param [IN] color 信号色。
* @param [IN] close ✕ 按钮矩形(宽度为 0 表示不画)。
* @param [IN] closeHot ✕ 是否处于悬停态。
*/
void PaintFreeMarkerCapsule(gd::Graphics& g, const gd::RectF& label, const std::wstring& text,
std::uint32_t color, const gd::RectF& close, bool closeHot);
/**
* @brief 计算某个固定点相对**同信号**前一个固定点的增量与其时间间隔。
*
* @param [IN] lane 目标网格。
* @param [IN] itemIndex `Lane::freeMarkerItems` 下标。
* @param [IN] value 当前点的取值(已叠加「调整」变换)。
* @param [OUT] hasPrev 是否存在前一个同信号固定点。
* @param [OUT] delta 取值增量(当前 − 前一个)。
* @param [OUT] timeDelta 时间间隔(当前 − 前一个,恒为正)。
* @return 当前点取值有效返回 true。
*/
bool FreeMarkerDelta(const Lane& lane, std::size_t itemIndex, double value, bool& hasPrev,
double& delta, double& timeDelta) const;
/**
* @brief 计算某个固定点的取值(已叠加「调整」变换)。
* @param [IN] lane 目标网格。
* @param [IN] item 固定点。
* @param [OUT] value 输出取值(已含界面变换)。
* @param [OUT] enumValue 输出采样原值(用于查枚举表)。
* @return 取到有效值返回 true。
*/
bool FreeMarkerValue(const Lane& lane, const Lane::FreeMarkerItem& item, double& value,
double& enumValue) const;
/**
* @brief 在面板布局里找出某个信号对应的「逐条目」行下标。
* @param [IN] layout 面板布局。
@@ -2325,6 +2493,41 @@ namespace jdi
int m_hoverPanelBadgeKind = -1;
/** 「弱峰调整」每行滑块右侧的复位控件是否处于悬停态(用于“进入/离开”时只提示一次)。 */
bool m_peakResetHover = false;
// ---------------------------------------------------------------- 自由标记
/** 最近一次解析出的「自由标记」跟随数据点(绘制与命中共用)。 */
FreeMarkerProbe m_freeMarker;
/**
* @brief 上一次绘制时登记的固定点 ✕ 命中矩形。
*
* 固定点 ✕ 的位置由「取值文本宽度」决定(文本又取决于数值),因此无法在命中测试里
* 重新推算 —— 改为绘制时登记、命中时读取(与 `m_legendHits` 同一套路)。
*/
mutable std::vector<FreeMarkerHit> m_freeMarkerHits;
/** 网格内固定点 ✕ 的悬停态:`m_freeMarkerHoverItem` 为 `SIZE_MAX` 表示没有悬停。 */
int m_freeMarkerHoverLane = -1;
std::size_t m_freeMarkerHoverItem = static_cast<std::size_t>(-1);
/** 面板内 ✕ 的悬停态:信号名 ✕(`itemIndex == SIZE_MAX`)或某一固定点行 ✕。 */
int m_freeMarkerPanelHoverLane = -1;
std::size_t m_freeMarkerPanelHoverItem = static_cast<std::size_t>(-1);
SignalRef m_freeMarkerPanelHoverSignal;
/**
* @brief 本次按下是否**可能**固定一个数据点(抬起时若"原地未拖动"就真正固定)。
*
* 自由标记网格 + 选区放大模式共存时,单击 = 固定数据点、按下后拖动 = 框选,
* 因此按下时不能立即固定,必须等抬起时判断是否发生过位移。
*/
bool m_freeMarkerPendingFix = false;
int m_freeMarkerPendingLane = -1;
/** 正在编辑的固定点(`m_editLane` 网格内的 `freeMarkerItems` 下标)。 */
std::size_t m_editMarkerIndex = static_cast<std::size_t>(-1);
int m_mouseX = 0; /**< 最近一次鼠标横坐标(用于悬停提示定位)。 */
int m_mouseY = 0; /**< 最近一次鼠标纵坐标(用于悬停提示定位)。 */
bool m_spanHover = false; /**< 鼠标是否悬停在双光标差值标签上。 */
@@ -1614,6 +1614,47 @@ namespace jdi
return true;
}
bool PlotView::SetLaneFreeMarker(int laneIndex, bool enabled)
{
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
{
return false;
}
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
if (lane.freeMarker == enabled)
{
return false;
}
lane.freeMarker = enabled;
// 关闭时**保留**已固定的点(用户可能只是暂时收起光标,再打开还能继续看);
// 清空固定点由面板上的 ✕ 负责。
lane.freeMarkerPanel.open = enabled;
if (!enabled && m_freeMarker.laneIndex == laneIndex)
{
m_freeMarker = FreeMarkerProbe(); // 跟随光标立即失效
}
// 自由标记只画叠加层(不进图层位图),但网格底衬仍可能需要预建,
// 与其它实验性功能保持同一套"只补缺失图层"的处理。
EnsureLaneLayersPrefetched();
return true;
}
bool PlotView::LaneFreeMarker(int laneIndex) const
{
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
{
return false;
}
return m_tab->Lanes()[static_cast<std::size_t>(laneIndex)].freeMarker;
}
bool PlotView::ToggleLaneFreeMarker(int laneIndex)
{
const bool enabled = !LaneFreeMarker(laneIndex);
SetLaneFreeMarker(laneIndex, enabled);
return enabled;
}
bool PlotView::MeanFilterValueAt(const TimeAxis& axis, const SignalData& signal, int points, int iterations,
double time, double& value) const
{
+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;
+124 -1
View File
@@ -328,6 +328,128 @@ namespace jdi
inline const float kWeakPeakResetButtonSize = 16.0f;
/** 复位控件与其左侧数值列之间的间距(逻辑像素)。 */
inline const float kWeakPeakResetGap = 4.0f;
// ---------------------------------------------------------------- 自由标记
/** 「自由标记」面板里信号名行的高度(比普通条目略高:右侧多一个 ✕)。 */
inline const float kFreeMarkerNameRowHeight = 22.0f;
/** 「自由标记」面板里每个固定点行的高度。 */
inline const float kFreeMarkerItemRowHeight = 18.0f;
/** 「自由标记」面板与网格内固定点 ✕ 按钮的边长。 */
inline const float kFreeMarkerCloseSize = 13.0f;
/**
* @brief 「自由标记」面板的宽度(逻辑像素)。
*
* 取固定值而**不做逐行文本测量**:`EdgePanelWidth` 会被 `EdgePanelLayoutFor` 调用,
* 而后者在 `TopPanelAtPoint` / `SyncPanelOrder` 里被高频调用(每次鼠标移动数次);
* 逐行取采样值算宽度会变成"鼠标一动就做几十次采样查询"。
*/
inline const float kFreeMarkerPanelWidth = 340.0f;
/** 固定点取值胶囊与 ✕ 之间的间距。 */
inline const float kFreeMarkerCloseGap = 3.0f;
/** 跟随光标纵向线与网格左右边界之间的留白(避免线与边框重叠)。 */
inline const float kFreeMarkerLineInset = 1.0f;
/**
* @brief 把数值格式化为**始终带符号**的文本(`+1.5` / `-0.25` / `+0`)。
*
* 「自由标记」的取值增量与时间增量都要求显式正负号(即使是 0 也带 `+`),
* 与双光标模式的增量显示规则一致。
*/
inline std::wstring FormatSignedValue(double value)
{
std::wstring text = (value < 0.0) ? L"-" : L"+";
text += FormatValue(std::fabs(value));
return text;
}
/**
* @brief 「自由标记」面板/胶囊里的取值部分:`值(+增量) = 枚举文本`。
*
* @param value 当前取值(已叠加「调整」变换)。
* @param hasPrev 是否存在前一个同信号固定点(存在才显示增量)。
* @param delta 相对前一个固定点的取值增量。
* @param enumText 枚举文本(可为空指针)。
*/
inline std::wstring FreeMarkerValueText(double value, bool hasPrev, double delta,
const std::wstring* enumText)
{
std::wstring text = FormatValue(value);
if (hasPrev)
{
text += L"(";
text += FormatSignedValue(delta);
text += L")";
}
if (enumText != nullptr && !enumText->empty())
{
text += L" = ";
text += *enumText;
}
return text;
}
/**
* @brief 「自由标记」的时间列文本:`t:<时刻>`。
*
* 数值格式与光标时间读数**完全相同**(`FormatValue` + `s` 后缀,
* 即 `FormatCursorTime` 的口径);这里不调用 `FormatCursorTime` 是因为它在本头里
* 定义在本段**之后**,直接展开可以避免前向声明。
*/
inline std::wstring FreeMarkerTimeText(double time)
{
return L"t:" + FormatValue(time) + L"s";
}
/**
* @brief 「自由标记」的时间间隔列文本:`Δt:<间隔>`。
*
* 平时用复合单位(自动匹配 ms / s / min,与双光标差值读数一致),
* 双击后按**秒**编辑。首个固定点没有前一项 ⇒ 返回空串(该列留白)。
*/
inline std::wstring FreeMarkerDeltaText(bool hasPrev, double timeDelta)
{
if (!hasPrev)
{
return std::wstring();
}
// 时间间隔恒为正(按时间排序后当前点总在后方);显示时显式带 `+`。
return L"Δt:+" + FormatDurationCompound(std::fabs(timeDelta));
}
/**
* @brief 收集某信号的全部固定点下标,按**时间升序**输出。
*
* 面板里同一信号下的条目按「从左到右」从上到下排列,「前一个固定点」也按这个顺序
* 计算,因此绘制、命中、增量计算必须共用本函数(否则三者会各自排一次序而错位)。
*
* @param [IN] lane 目标网格。
* @param [IN] ref 目标信号。
* @param [OUT] out 追加写入的下标(`Lane::freeMarkerItems` 的下标)。
*/
inline void SortedFreeMarkerIndices(const Lane& lane, const SignalRef& ref,
std::vector<std::size_t>& out)
{
out.clear();
for (std::size_t index = 0; index < lane.freeMarkerItems.size(); ++index)
{
if (lane.freeMarkerItems[index].ref == ref)
{
out.push_back(index);
}
}
std::stable_sort(out.begin(), out.end(),
[&lane](std::size_t a, std::size_t b)
{
return lane.freeMarkerItems[a].time <
lane.freeMarkerItems[b].time;
});
}
inline const int kWeakPeakIterationsMin = 1; /**< 迭代次数 N 的下限。 */
/** 迭代次数滑块「自定义上限」的允许范围(与算法内部的阶数夹取保持一致)。 */
inline const int kWeakPeakIterationsMaxLimit = 1000;
@@ -727,7 +849,8 @@ namespace jdi
* 顺序即绘制层序:先绘制的在下方,最后绘制的在最上方。
*/
inline const EdgePanelKind kEdgePanelKinds[] = {EdgePanelKind::COUNT, EdgePanelKind::CENTER,
EdgePanelKind::MEAN, EdgePanelKind::PEAK};
EdgePanelKind::MEAN, EdgePanelKind::PEAK,
EdgePanelKind::MARK};
/** 实验性功能浮动面板的种类数(遍历与命中测试统一用它,避免新增功能时漏改)。 */
inline const int kEdgePanelKindCount = static_cast<int>(sizeof(kEdgePanelKinds) / sizeof(kEdgePanelKinds[0]));
+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);
+116
View File
@@ -3009,6 +3009,8 @@ else if (panelOnly) { /* 只回送“本帧重绘过的区域并集” */ }
| 2026-10-20 | DeepSeek-V4.1-Flash(**本机**) | **全项目大检查(性能 / BUG / 过期注释 / 错误命名)+ 修复(详见 §12 末尾「2026-10-20 第六轮」与 §11.2 第 171–180 条)**。① **数据层 BUG**:`MatH5` 的 `U8/U16/U32/U64` 用「逐字节 `Cached()`」拼装,而 `Cached` 只服务同一 64KB 块内的请求 ⇒ **跨块标量静默读成 0**(分块 B-tree 的 chunk 偏移/子节点地址被破坏,无任何报错);改为 `ScalarLE()`(命中缓存用缓存,否则一次 `ReadAt`)。`Mf4Loader::IsBlock` 未校验链接表是否落在块内 ⇒ `DataOffset` 越界后 `block_len - (data-offset)` **uint64 下溢**,下游 `vector(usable+1)` 直接 `bad_alloc`;`ReadAt`/`IsBlock` 的 `offset + bytes > size` 也回绕。非字节对齐的 **64 位整数**用「先拼 64 位再右移」会丢掉最高字节(32/8 位宽度组合受影响);`cg_cycle_count` 无上限(损坏文件可让程序申请几十 GB);`CsvLoader` 表头分支不跳过 `#`/`%` 注释行(整份文件解析失败);`MatLoader` 把文件里的 double 直接 `static_cast<int>`(NaN 越界 = UB)、普通对象递归无深度上限;`Utf8Util::BuildTicks` 的 `double→long long` 越界转换。② **UI BUG**:工具栏按钮**永远不显示悬停高亮**(`Paint` 读的 `Button::hover` 全仓从未赋值);菜单打开时**每个消息**都无条件取消底层拖拽 ⇒ 菜单自己的内联输入框无法拖拽框选(`Target::EDIT_FIELD` 被误清);`GdiplusShutdown` 前未释放 `PlotView` 的 GDI+ 位图(`Application` 是进程单例,退出时访问违例);`WM_KILLFOCUS` 不提交标签页/绘图区编辑;`WM_LBUTTONUP` 无条件 `ReleaseCapture` 抢走中键平移的捕获;`TextEdit` 字符被过滤器拒绝却返回 `true`、无选区时 `Ctrl+X` **清空整段文本**;`InvalidateRows()` 不作废 `m_editRow` ⇒ 行集合变化后回车把新名字写到**另一条**信号;`EndFilterEdit` 不调 `ApplyFilterText`(空格+回车后列表仍处于筛选态);`SignalBadgeVisible` 三处判据不一致(未绘制处仍可点出菜单);折起动画结束后不夹取滚动量(列表底部空白 + 滑块画出轨道);快速双击分组名改名后**折起动画仍在播**。③ **PlotView 缓存/正确性**:`FindStyleOnlyLanes` 只比 4 个外观字段,而 `SceneGeometryHash` 还排除了系数/镜像/偏移 ⇒ 两个网格同时改不同属性时有一个带着旧像素被当成“最新”;拖拽光晕被烘进宽基底图层(`LaneLayerHash` 不含它);`m_legendHits` 半可见时提前返回导致点中“看不见的图例”;面板主体 96% → **不透明**(消除平移后 4% 透出的旧波形);PAN / Shift+滚轮绕过 `SetTimeView`(自动点线不随平移重判);内联输入框命中用 `uiSmall` 而绘制用 `monoSmall`;派生曲线取值未按 `SourceIndexFor` 映射(水平镜像时读到镜像另一端);`EdgePanelWidth` 缺 `style/axis` 空指针守卫;`MinorCellSparse`/`AutoRangeLaneToView` 的二分结果未夹到 `count`(时间轴比 values 长时越界)。④ **清理**:删除死接口 `Toolbar::ActionLabel`、`Theme::statusBg`、`PopupMenu::editResult` 死机制、`Toolbar::Layout` 的重复 `clear()`、`HitRow` 的恒假分支、`OnDoubleClick` 的重复命中判定;修正一批过期/错误注释(`EmitEnvelopeBand` “不做加宽”、`LaneLayerHash` 的图例说法、`MeanFilterCurve` 的窗口说法、样式区行数与「调整」按钮顺序、`FIT_ALL` 退出缩放模式、`RemoveSignal` 的 ✕ 位置、`.mat`/`.dat` 格式文档、`MatH5` “映射视图”等)。⑤ **体积评估(未做修改)**:release **809,472 B**;`objdump` 分节 `.text 652KB / .xdata 43KB / .pdata 32KB / .rdata 42KB / .rsrc 20KB / .idata 11KB`;**去掉 `-ld3d11 -ldwmapi -ld3dcompiler` 实测 0 字节收益**(静态库只拉引用到的对象);仓内已无 `<iostream>/<sstream>/<filesystem>` 等体积炸弹。验证:全部离屏探针(`check`/`editcheck`/`round3check`/`round4check`/`round5check`/`convcheck`/`loadcheck`)与 4 个 MF4 + 3 个 `.mat`(含 1.29GB `BIG_MAT.mat`)回归通过;`make release`/`make debug` 零警告。 |
| 2026-10-20 | DeepSeek-V4.1-Flash(**本机**) | **新增实验性功能「自由标记」(网格内跟随鼠标的纵向光标 + 单击固定数据点 + 面板)**(详见 §12 末尾「2026-10-20 第七轮」与 §11.2 第 181–188 条)。① **数据层**:`Lane` 新增 `freeMarker` 开关、`FreeMarkerItem{ref, time}` 列表(`time` 存**显示时刻**,与光标同一口径)与 `freeMarkerPanel`;新增 `AddFreeMarker/RemoveFreeMarker/RemoveFreeMarkerAt/RemoveFreeMarkersOf/FindFreeMarker/HasFreeMarkers`(同一信号同一时刻不重复加入,容差 1e-12 相对量);`LaneCarry` 一并携带(重建网格不丢)。② **网格内交互**:`ProbeFreeMarker` 在「开启该功能的网格」内挑**离鼠标最近**的那个数据点(`MinorCellSparse` 成立时经 `SnapTimeToSample` 吸附到采样时刻并画圆环提示);`PaintFreeMarker` 画**仅限本网格**的纵向光标(上把手尖端朝下、下把手尖端朝上,**不显示时间读数**)+ 就近取值胶囊;已固定点画细线 + 圆点 + `值(±增量) = 枚举` 胶囊 + 行尾 ✕(悬停高亮 + 提示气泡)。**超出视界直接不显示**(不像常规数据点那样贴边定格 + 三角)。③ **单击 vs 框选**:`OnLButtonDown` 只登记 `m_freeMarkerPendingFix`,`OnLButtonUp` 里**原地未拖动**才真正 `AddFreeMarker`;选区放大模式下「按下后拖动」照旧进入框选并缩放。④ **面板**:新 `EdgePanelKind::MARK`(`PLOT_CMD_LANE_FREE_MARKER = 12`),按「网格内信号顺序 → 该信号内时间升序」展开;每行 `值(±增量) = 枚举` + 两列**右对齐**的 `t:<时刻>s` / `Δt:+<复合单位>`;信号名右侧 ✕ 清除该信号全部固定点、行尾 ✕ 移除单个;双击行编辑「自身时刻」或「相对前一固定点的间隔」(秒),提交后 `SortedFreeMarkerIndices` 自动重排(越过后续点即交换条目位置)。⑤ **两处修正**:`EdgePanelWidth` 的 MARK 分支**不做采样查询**(它被 `TopPanelAtPoint`/`SyncPanelOrder` 高频调用,逐行取值会拖慢鼠标),改用固定宽度 `kFreeMarkerPanelWidth` + 绘制侧省略号;`PaintFreeMarker` 与跟随光标共用 `PaintFreeMarkerCapsule`(避免两份重复绘制)。验证:新增离屏 `.preflight/markcheck` **31/31**(探测/就近选择/单击固定/三处✕/增量与时间列对齐/选区放大共存/吸附/越界不显示/间隔编辑后重排);回归 `check` 136/137(唯一失败项为既有不稳定项,基线同样失败)、`editcheck` 20/20、`round3check` 31/31、`round4check` 34/34、`round5check` 5/5、`convcheck` 18/18,真实 MF4 仍 283/8/18/24;`make release`/`make debug` 零警告;`TargetCheck.exe` **833,536 字节(814.0 KiB)** —— 比上一轮 **+24 KB**,**首次超出 800 KiB 的旧红线约 14 KB**。 |
## 11. 跨设备协作须知(起源设备视角)
> **本文件是唯一权威记忆**。所有设备 / Agent 共用它,**不要再新建 `agent_mem_origin.md` 等旁支记忆文件**。
@@ -3868,6 +3870,36 @@ else if (panelOnly) { /* 只回送“本帧重绘过的区域并集” */ }
180. ⚠️ **二分得到的可见下标必须夹到 `count = min(时间轴长度, values 长度)`**:分组时间轴可能比
某条信号长(多信号共轴、有的信号短),`lower_bound` 在**整条** `axis.time` 上进行时
`begin/end` 会超出 `values` 范围 ⇒ 逐点扫描越界读。
181. ⚠️⚠️ **新增实验性功能必须同时改这 6 处,缺一个就会出现"开关点了没反应/面板不出现"**:
① `EdgePanelKind` 加枚举;② `PlotViewInternal.hpp` 的 `kEdgePanelKinds[]`(绘制与命中共用的
遍历表);③ `EdgePanelWidth` 与 `EdgePanelLayoutFor` 的 `enabled/open` 三元表达式与
`shown` lambda + `kOrder[]`;④ `PaintEdgeCursor` 里该面板的绘制分支;
⑤ `PanelSlotKey`(面板像素缓存的键,**凡影响面板文字的都要 mix**);
⑥ `PlotMenuCommand` + `ApplicationCommands` 的菜单项与派发。
⚠️ `EdgePanelLayoutFor` 里那些 `x ? ... : (y ? ... : ...)` 链很容易漏一个分支——
漏掉的后果是该功能的 `enabled/open` 恒为 false ⇒ 面板永远不显示。
182. ⚠️⚠️ **`EdgePanelWidth` 里绝不能查采样值**:它被 `EdgePanelLayoutFor` 调用,而后者在
`TopPanelAtPoint` / `SyncPanelOrder` 里被**每次鼠标移动调用数次**。若在那里逐行
`SampleDisplayedSignalAt` 算文本宽度,就变成"鼠标一动几十次采样查询",交互立刻变卡。
需要宽度时用固定常量 + 绘制侧 `UiEllipsize` 截断。
183. ⚠️⚠️ **「自由标记」的 `FreeMarkerItem::time` 是「显示时刻」**(= 画在 `TimeToX(time)` 处),
与 `TabConfig::CursorTime()` 同一口径;取值必须用 `SampleDisplayedSignalAt`
(内部减 `style.offsetTime`、处理水平镜像),**不要**直接用 `time` 索引 `axis.time`。
184. ⚠️⚠️ **「自由标记」的条目顺序只能由 `SortedFreeMarkerIndices` 决定**:
`Lane::freeMarkerItems` 保持**插入顺序**(删除某项不会打乱其它项的下标),
面板显示顺序、"前一个固定点"(增量与 Δt 的基准)都由那个函数按时间排序后得到。
绘制、命中、增量计算三处必须共用它,否则会各自排一次序而错位。
185. ⚠️ **「自由标记」超出视界的固定点直接不显示**(用户明确要求):不要复用常规数据点
那套"贴边定格 + 方向三角"的处理(`MakeDataMarker`),否则会被误认为该点在视界内。
186. ⚠️ **「自由标记」与选区放大共存的单击语义**:按下时**只登记意图**
(`m_freeMarkerPendingFix`),抬起时若"整段操作没有拖动且选区轴向未定"才固定数据点;
否则照旧进入框选。⚠️ 不能在 `OnLButtonDown` 里直接固定 —— 那样用户想框选时每按一次都会
多一个固定点。
187. ⚠️ **网格内固定点的 ✕ 命中矩形由绘制时登记**(`m_freeMarkerHits`):它依赖取值文本宽度
(而文本取决于数值),命中测试无法重新推算。与 `m_legendHits` 同一套路:
绘制时重建、`OnMouseMove`/`OnLButtonDown` 读取。
188. ⚠️ **`PaintFreeMarker` 必须画在 `PaintEdgeCursor`(浮动面板)之前**:面板在最上层,
自由标记的胶囊/✕ 不得盖住面板;接入点是 `paintOverlays()`(三个绘制分支共用)。
### 11.3 离屏验证方法学(本机偏好,强烈建议沿用)
@@ -7084,5 +7116,89 @@ DG3 data=0 cycle=0 (空组,一个字符串通道)
`带无障碍对照-防夹力曲线数据 Group1 x2.mat` 4 组 8 信号、**`BIG_MAT.mat` 5 信号(私有内存 +741MB)**。
- `make release` / `make debug` **零警告**。
---
## 本轮(2026-10-20 第七轮,本机):实验性功能「自由标记」
> 用户需求:在网格右键菜单的「实验性功能」里新增带 √ 的「自由标记」。
> 开启后网格内跟随鼠标出现一条**仅限本网格**的纵向光标(上下各一个把手、不显示时间值),
> 光标穿过信号时只标注**离鼠标最近**的数据点;左键单击把该点固定下来(带 ✕ 可单独移除,
> 固定点在鼠标移开后仍显示、超出视界直接不显示),后续固定点显示为相对前一个的增量;
> 浮动面板按信号分组列出 `值(±增量) = 枚举文本` + `t:… Δt:…`(两列右对齐,可双击按秒编辑,
> 编辑后位置移动并在越过后续点时自动换位);命中「自动点线」判据的信号要给出吸附提示;
> 面板两级 ✕(信号名清空该信号 / 行尾移除单项)都必须有悬停提示;
> 与「选区放大」共存时**原地单击 = 固定数据点、按下后拖动 = 框选**。
### A. 数据层(`Utilities`)
- `Lane` 新增:`bool freeMarker`、`std::vector<FreeMarkerItem> freeMarkerItems`、`LanePanelState freeMarkerPanel`。
`FreeMarkerItem{ SignalRef ref; double time; }` —— **`time` 是显示时刻**(= 画在 `TimeToX(time)` 处),
与 `TabConfig::CursorTime()` 同一口径;取值用 `SampleDisplayedSignalAt`(内部处理时间偏移与水平镜像)。
- 新增 `AddFreeMarker / RemoveFreeMarker / RemoveFreeMarkerAt / RemoveFreeMarkersOf / FindFreeMarker /
HasFreeMarkers`。同一信号同一时刻**不重复加入**(相对容差 1e-12);`freeMarkerItems` **保持插入顺序**
(删除某项不打乱其它项的下标),显示顺序一律由 `SortedFreeMarkerIndices` 按时间排序得出。
- `LaneCarry` 同步携带三件套 ⇒ 勾选/取消勾选信号导致网格重建时**不会丢掉已固定的点**。
### B. 网格内:跟随光标 + 固定点
- `ProbeFreeMarker(mouseX, mouseY)`:仅当鼠标所在网格开启了该功能时有效;在网格内**全部可见信号**里
挑屏幕纵向距离鼠标最近的那个数据点。判据 `MinorCellSparse` 成立时经 `SnapTimeToSample` **吸附**到
真实采样时刻(并置 `snapped`,绘制侧画一圈圆环作为吸附提示)。
⚠️ 吸附与否**只看判据**,不管当前是散点/线段、也不管用户是否手动改过样式(用户明确要求)。
- `PaintFreeMarker`(在 `paintOverlays` 里、`PaintEdgeCursor` **之前**调用):
* 跟随光标:纵向线**只在本网格内**(`clipTop..clipBottom`),上把手尖端朝下(与常规光标顶部把手一致)、
**下把手尖端朝上**(用户明确要求),**不画时间读数**;只画最近点的取值胶囊;
* 已固定点:低透明度细线 + 圆点 + `值(±增量) = 枚举` 胶囊 + 行尾 ✕(悬停高亮 + 提示气泡);
* **超出视界直接不显示**(不复用常规数据点的"贴边定格 + 方向三角")。
- 两处 ✕ 的命中:网格内固定点 ✕ 的矩形**由绘制时登记**(`m_freeMarkerHits`,与 `m_legendHits` 同套路),
因为它的位置取决于取值文本宽度;面板内 ✕ 直接取布局矩形。
- `OnMouseLeave` 清 `m_freeMarker` 与三处悬停态(已固定的点保留)。
### C. 单击 vs 框选(与「选区放大」共存)
`OnLButtonDown` 在绘图区内、开启该功能的网格上**只登记** `m_freeMarkerPendingFix`,
并把 `m_dragMode` 设为「选区放大模式 ? RUBBER_SELECT : PAN」;`OnLButtonUp` 里只有
**整段操作没有拖动且选区轴向未定**时才 `AddFreeMarker`,否则照旧走框选/平移。
⚠️ 不能在按下时就固定 —— 那样用户想框选时每按一次都会多一个固定点。
### D. 浮动面板(`EdgePanelKind::MARK`)
- 布局:标题「自由标记」;每个**可见信号**一行名称(右侧带「清除该信号全部固定点」✕),
名称行下方按 `SortedFreeMarkerIndices` 的顺序逐行列出该信号的固定点(从左到右 == 从上到下);
没有任何固定点时第一行给灰色提示「在网格内单击可固定数据点」。
- 行内容:左侧 `值(±增量) = 枚举文本`(**没有尖头括号**;首项无增量),右侧两列**各自右对齐**:
`t:<时刻>s`(与光标时间读数同格式)与 `Δt:+<复合单位>`(与双光标差值同格式,自动单位匹配),
再右侧是行尾 ✕。两列右边界存在 `EdgePanelLayout::markerTimeRight/markerDeltaRight`,
⇒ 所有条目的时间尾缀纵向对齐。
- 双击行:左半 → `PlotEditTarget::FREE_MARKER_TIME`(编辑自身时刻,秒);
右半 → `FREE_MARKER_DELTA`(编辑相对前一固定点的间隔,秒;最左边的点没有前一项 ⇒ 不进入编辑)。
提交后只改 `time`,顺序由排序函数自动重排(**越过后续点即交换条目位置**)。
- `PanelSlotKey` 把每个固定点的「信号 + 时刻」与全部 marker 行矩形、两列边界一起 mix
(否则增删固定点后会贴回旧像素)。
### E. 一并修掉的两个自伤
1. **`EdgePanelWidth` 的 MARK 分支原本逐行取采样值算文本宽度** —— 它被 `EdgePanelLayoutFor` 调用,
而后者在 `TopPanelAtPoint` / `SyncPanelOrder` 里**每次鼠标移动调用数次** ⇒ 变成"鼠标一动就几十次
采样查询"。改为固定宽度 `kFreeMarkerPanelWidth`(340 逻辑像素)+ 绘制侧 `UiEllipsize`。
2. `PaintFreeMarker` 与跟随光标的胶囊绘制原本是两份重复代码 ⇒ 抽 `PaintFreeMarkerCapsule` 共用;
`FreeMarkerLabelRects` 增加 `text` 出参,避免绘制侧重复求解一次取值。
### F. 验证
- 新增离屏 `.preflight/markcheck` **31/31**:
[A] 探测有效/就近选择随鼠标纵向变化;[B] 原地单击恰好新增 1 个固定点;
[C] 选区放大模式下原地单击 = 固定且不缩放、拖动 = 缩放且不固定;
[D] 面板行数 == 固定点数、组内按时间升序、首项无增量、后续项增量与 Δt 正确、两列右对齐;
[E] 行尾 ✕ 只删 1 项、信号名 ✕ 清空该信号全部;[F] 编辑 Δt 后该点移动到「前一点 + 间隔」并在越过后续点时变成最右项;
[G] 深度放大时 `snapped == true` 且时刻精确等于某个采样时刻;视界外的固定点 `FreeMarkerLabelRects` 返回 false。
- 回归:`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)**。
⚠️ **体积超出旧的 800 KiB 红线约 14 KB**(本轮 +24 KB)。已做的瘦身:面板宽度不测文本、
胶囊绘制去重。剩余体积是新功能的真实代码量(~800 行新代码 / ~30 B 每行)——
若必须回到 800 KiB 以内,需要用户决策(动态 CRT / UPX / 砍其它功能),本轮按"实现功能优先"未动。
+69
View File
@@ -765,6 +765,23 @@ namespace jdi
int maxIterations = 10; /**< 「迭代次数 N」滑块的上限(用户可在面板上修改,恒 ≥ 1)。 */
};
/**
* @brief 「自由标记」中一个**固定**的数据点。
*
* 用户开启「自由标记」后,网格内会跟随鼠标出现一条纵向光标;左键单击一次就把当时
* 就近的那个数据点固定下来。固定点按信号分组显示在浮动面板里,
* 同一信号内的条目按**时间从左到右**排列(面板里从上到下)。
*
* ⚠️ `time` 存的是**显示时刻**(= 画在 `TimeToX(time)` 处),
* 与 `TabConfig::CursorTime()` 同一口径;采样查询要用
* `SampleDisplayedSignalAt`(它内部会减掉 `style.offsetTime`)。
*/
struct FreeMarkerItem
{
SignalRef ref; /**< 目标信号。 */
double time = 0.0; /**< 显示时刻(秒)。 */
};
std::wstring name; /**< 网格名称。 */
std::vector<SignalRef> signals; /**< 该网格包含的信号(按加入顺序)。 */
bool autoRange = true; /**< 是否依据信号取值范围自动计算 Y 轴。 */
@@ -900,6 +917,58 @@ namespace jdi
/** 弱峰调整浮动面板的 UI 状态(展开 + 位置)。 */
LanePanelState weakPeakPanel;
/**
* @brief 「实验性功能 → 自由标记」开关。
*
* 开启后本网格内跟随鼠标显示一条**仅限本网格**的纵向光标(上下各一个三角把手,
* 不显示时间读数);光标穿过信号时只标注**离鼠标最近**的那个数据点。
* 左键单击把该数据点固定下来(固定点带 ✕ 可单独移除),浮动面板按信号分组列出
* 「值(±增量) = 枚举文本」+「t:… Δt:…」。
*/
bool freeMarker = false;
/**
* @brief 已固定的自由标记点(**未排序**,按加入顺序)。
*
* 面板内的显示顺序是按「网格内信号顺序 → 该信号内按时间升序」临时计算的,
* 这样删除某一项不会打乱其它项的下标。同一信号同一时刻不会被重复加入。
*/
std::vector<FreeMarkerItem> freeMarkerItems;
/** 取某信号在某时刻的固定点下标(未找到返回 -1)。 */
int FindFreeMarker(const SignalRef& ref, double time) const;
/**
* @brief 加入一个固定点(同一信号同一时刻已存在时不重复加入)。
* @return 新加入返回 true。
*/
bool AddFreeMarker(const SignalRef& ref, double time);
/** 移除某信号在某时刻的固定点(按时刻容差匹配)。 */
void RemoveFreeMarker(const SignalRef& ref, double time);
/** 移除某个下标对应的固定点。 */
void RemoveFreeMarkerAt(std::size_t index);
/** 移除某信号的**全部**固定点。 */
void RemoveFreeMarkersOf(const SignalRef& ref);
/** 某信号是否已有固定点。 */
bool HasFreeMarkers(const SignalRef& ref) const
{
for (const FreeMarkerItem& item : freeMarkerItems)
{
if (item.ref == ref)
{
return true;
}
}
return false;
}
/** 自由标记浮动面板的 UI 状态(展开 + 位置)。 */
LanePanelState freeMarkerPanel;
};
/**
+83
View File
@@ -1401,6 +1401,89 @@ namespace jdi
weakPeakItems.end());
}
// ---------------------------------------------------------------- 自由标记
namespace
{
/**
* @brief 判断两个显示时刻是否算「同一个固定点」。
*
* 固定点是用户用鼠标点出来的,同一位置重复点击不应产生两条;但两次点击之间
* 鼠标可能相差 1 位采样,因此用一个**极小**的相对容差判定而不是精确 `==`。
*/
bool SameMarkerTime(double a, double b)
{
if (a == b)
{
return true;
}
const double scale = std::max(1.0, std::max(std::fabs(a), std::fabs(b)));
return std::fabs(a - b) <= scale * 1.0e-12;
}
} // namespace
int Lane::FindFreeMarker(const SignalRef& ref, double time) const
{
for (std::size_t index = 0; index < freeMarkerItems.size(); ++index)
{
const FreeMarkerItem& item = freeMarkerItems[index];
if (item.ref == ref && SameMarkerTime(item.time, time))
{
return static_cast<int>(index);
}
}
return -1;
}
bool Lane::AddFreeMarker(const SignalRef& ref, double time)
{
if (!ref.IsValid() || !std::isfinite(time))
{
return false;
}
if (FindFreeMarker(ref, time) >= 0)
{
return false; // 同一信号同一位置只保留一个
}
FreeMarkerItem item;
item.ref = ref;
item.time = time;
freeMarkerItems.push_back(item);
return true;
}
void Lane::RemoveFreeMarker(const SignalRef& ref, double time)
{
const int index = FindFreeMarker(ref, time);
if (index >= 0)
{
freeMarkerItems.erase(freeMarkerItems.begin() + index);
}
}
void Lane::RemoveFreeMarkerAt(std::size_t index)
{
if (index >= freeMarkerItems.size())
{
return;
}
// 用移动 + `pop_back` 而不是 `erase`:固定点不带需要保持地址顺序的引用,
// 但这样能让尾部缓冲立即析构,与其它移除路径保持一致的写法。
for (std::size_t current = index; current + 1 < freeMarkerItems.size(); ++current)
{
freeMarkerItems[current] = freeMarkerItems[current + 1];
}
freeMarkerItems.pop_back();
}
void Lane::RemoveFreeMarkersOf(const SignalRef& ref)
{
freeMarkerItems.erase(
std::remove_if(freeMarkerItems.begin(), freeMarkerItems.end(),
[&ref](const FreeMarkerItem& item) { return item.ref == ref; }),
freeMarkerItems.end());
}
int FindNearestSample(const TimeAxis& axis, double time)
{
const std::size_t count = axis.time.size();
+11
View File
@@ -88,6 +88,9 @@ namespace jdi
bool weakPeak = false;
std::vector<Lane::WeakPeakItem> weakPeakItems;
LanePanelState weakPeakPanel;
bool freeMarker = false;
std::vector<Lane::FreeMarkerItem> freeMarkerItems;
LanePanelState freeMarkerPanel;
};
// 先记下每个信号原来所在网格的网格自身状态:
@@ -125,6 +128,9 @@ namespace jdi
carry.weakPeak = lane.weakPeak;
carry.weakPeakItems = lane.weakPeakItems;
carry.weakPeakPanel = lane.weakPeakPanel;
carry.freeMarker = lane.freeMarker;
carry.freeMarkerItems = lane.freeMarkerItems;
carry.freeMarkerPanel = lane.freeMarkerPanel;
for (const SignalRef& ref : lane.signals)
{
previousCarry.emplace_back(ref, carry);
@@ -167,6 +173,11 @@ namespace jdi
lane.weakPeakPanel.open = carry.weakPeakPanel.open;
lane.weakPeakPanel.x = carry.weakPeakPanel.x;
lane.weakPeakPanel.y = carry.weakPeakPanel.y;
lane.freeMarker = carry.freeMarker;
lane.freeMarkerItems = carry.freeMarkerItems;
lane.freeMarkerPanel.open = carry.freeMarkerPanel.open;
lane.freeMarkerPanel.x = carry.freeMarkerPanel.x;
lane.freeMarkerPanel.y = carry.freeMarkerPanel.y;
};
// 收集全部可见信号引用。