From dd8f63aa0e2b795106a0f4f6b3be89a347113e4d Mon Sep 17 00:00:00 2001 From: "DeepSeek V4.1 Flash" <> Date: Tue, 20 Oct 2026 19:47:55 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=AE=9E=E9=AA=8C=E6=80=A7?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E3=80=8C=E8=87=AA=E7=94=B1=E6=A0=87=E8=AE=B0?= =?UTF-8?q?=E3=80=8D=EF=BC=9A=E7=BD=91=E6=A0=BC=E5=86=85=E8=B7=9F=E9=9A=8F?= =?UTF-8?q?=E9=BC=A0=E6=A0=87=E7=9A=84=E7=BA=B5=E5=90=91=E5=85=89=E6=A0=87?= =?UTF-8?q?=20+=20=E5=8D=95=E5=87=BB=E5=9B=BA=E5=AE=9A=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E7=82=B9=20+=20=E6=B5=AE=E5=8A=A8=E9=9D=A2=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 需求(用户):网格右键菜单「实验性功能」里新增带 √ 的「自由标记」。开启后网格内跟随鼠标 出现一条仅限本网格的纵向光标(上下各一个把手、不显示时间值),穿过信号时只标注离鼠标 最近的数据点;左键单击把该点固定(带 ✕ 可移除,鼠标移开仍显示、超出视界直接不显示), 后续固定点显示为相对前一个的增量;面板按信号分组列出「值(±增量) = 枚举」+「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 条硬约束 与文末本轮小节。 --- .../Application/Src/ApplicationCommands.cpp | 10 + Components/PlotView/Inc/PlotView.hpp | 211 ++++- Components/PlotView/Src/PlotViewFeatures.cpp | 41 + .../PlotView/Src/PlotViewInteraction.cpp | 351 ++++++++- Components/PlotView/Src/PlotViewInternal.hpp | 125 ++- Components/PlotView/Src/PlotViewPaint.cpp | 734 +++++++++++++++++- Documents/agent_mem.md | 116 +++ Utilities/Inc/DataModel.hpp | 69 ++ Utilities/Src/DataModel.cpp | 83 ++ Utilities/Src/DataModelInternal.hpp | 11 + 10 files changed, 1735 insertions(+), 16 deletions(-) diff --git a/Components/Application/Src/ApplicationCommands.cpp b/Components/Application/Src/ApplicationCommands.cpp index 43c5ed3..85787c3 100644 --- a/Components/Application/Src/ApplicationCommands.cpp +++ b/Components/Application/Src/ApplicationCommands.cpp @@ -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); diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index 644c12d..8d81abe 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -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 rowLevelResets; std::vector rowIterResets; std::vector rowRefs; + /** + * @brief 「自由标记」面板的一行(一个固定点)。 + * + * `itemIndex` 是 `Lane::freeMarkerItems` 的下标(绘制文本、增删都通过它定位, + * 不依赖面板里的显示顺序);`rect` 是整行矩形(悬停/双击编辑时间用), + * `close` 是该行末尾的 ✕ 按钮。 + */ + struct MarkerRow + { + std::size_t itemIndex = 0; + gd::RectF rect; + gd::RectF close; + }; + /** 「自由标记」:每个固定点一行(按信号分组、组内按时间升序展开后的顺序)。 */ + std::vector markerRows; + /** + * @brief 「自由标记」:每个信号名右侧的「清除该信号全部固定点」✕。 + * + * 与 `rowRefs` 一一对应;该信号没有任何固定点时为空矩形。 + */ + std::vector rowNameCloses; + /** + * @brief 「自由标记」:每个信号**名称行**的矩形(与 `rowRefs` 一一对应)。 + * + * 绘制只读这个矩形(与 `rowNameCloses` 同一套横坐标),人为主观反推容易与布局漂移。 + */ + std::vector 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& 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 m_freeMarkerHits; + + /** 网格内固定点 ✕ 的悬停态:`m_freeMarkerHoverItem` 为 `SIZE_MAX` 表示没有悬停。 */ + int m_freeMarkerHoverLane = -1; + std::size_t m_freeMarkerHoverItem = static_cast(-1); + + /** 面板内 ✕ 的悬停态:信号名 ✕(`itemIndex == SIZE_MAX`)或某一固定点行 ✕。 */ + int m_freeMarkerPanelHoverLane = -1; + std::size_t m_freeMarkerPanelHoverItem = static_cast(-1); + SignalRef m_freeMarkerPanelHoverSignal; + + /** + * @brief 本次按下是否**可能**固定一个数据点(抬起时若"原地未拖动"就真正固定)。 + * + * 自由标记网格 + 选区放大模式共存时,单击 = 固定数据点、按下后拖动 = 框选, + * 因此按下时不能立即固定,必须等抬起时判断是否发生过位移。 + */ + bool m_freeMarkerPendingFix = false; + int m_freeMarkerPendingLane = -1; + + /** 正在编辑的固定点(`m_editLane` 网格内的 `freeMarkerItems` 下标)。 */ + std::size_t m_editMarkerIndex = static_cast(-1); + int m_mouseX = 0; /**< 最近一次鼠标横坐标(用于悬停提示定位)。 */ int m_mouseY = 0; /**< 最近一次鼠标纵坐标(用于悬停提示定位)。 */ bool m_spanHover = false; /**< 鼠标是否悬停在双光标差值标签上。 */ diff --git a/Components/PlotView/Src/PlotViewFeatures.cpp b/Components/PlotView/Src/PlotViewFeatures.cpp index a3d3533..030dac7 100644 --- a/Components/PlotView/Src/PlotViewFeatures.cpp +++ b/Components/PlotView/Src/PlotViewFeatures.cpp @@ -1614,6 +1614,47 @@ namespace jdi return true; } + bool PlotView::SetLaneFreeMarker(int laneIndex, bool enabled) + { + if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) + { + return false; + } + Lane& lane = m_tab->Lanes()[static_cast(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(m_tab->Lanes().size())) + { + return false; + } + return m_tab->Lanes()[static_cast(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 { diff --git a/Components/PlotView/Src/PlotViewInteraction.cpp b/Components/PlotView/Src/PlotViewInteraction.cpp index 25e26d3..04a0643 100644 --- a/Components/PlotView/Src/PlotViewInteraction.cpp +++ b/Components/PlotView/Src/PlotViewInteraction.cpp @@ -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(-1); + Lane& lane = m_tab->Lanes()[static_cast(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 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(panelKind) == EdgePanelKind::MARK) + { + for (const EdgePanelLayout::MarkerRow& row : layout.markerRows) + { + if (row.close.Width > 0.0f && + UiPointInRect(row.close, static_cast(x), static_cast(y))) + { + return LoadCursorW(nullptr, IDC_HAND); + } + } + for (const gd::RectF& rect : layout.rowNameCloses) + { + if (rect.Width > 0.0f && + UiPointInRect(rect, static_cast(x), static_cast(y))) + { + return LoadCursorW(nullptr, IDC_HAND); + } + } + } // 「弱峰调整」的逐信号滑块:轨道 → 左右箭头;迭代次数右侧数值 → 手型(可点击改上限)。 if (static_cast(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(x), static_cast(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(-1); + for (const FreeMarkerHit& hit : m_freeMarkerHits) + { + if (hit.close.Width > 0.0f && + UiPointInRect(hit.close, static_cast(x), static_cast(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(-1); + SignalRef hoverSignal; + int markLane = -1; + int markKind = 0; + if (m_tab != nullptr && + TopPanelAtPoint(static_cast(x), static_cast(y), markLane, markKind) && + static_cast(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(x), static_cast(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(x), static_cast(y)) && + rowIndex < layout.rowRefs.size()) + { + hoverLane = markLane; + hoverItem = static_cast(-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(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(-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(-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(m_tab->Lanes().size())) + { + Lane& tabLane = m_tab->Lanes()[static_cast(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(m_tab ? m_tab->Lanes().size() : 0) && + m_tab->Lanes()[static_cast(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(m_tab->Lanes().size())) + { + const FreeMarkerProbe probe = ProbeFreeMarker(x, y); + Lane& tabLane = m_tab->Lanes()[static_cast(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(x), static_cast(y), panelLane, panelKind)) { + // 「自由标记」面板:双击某一固定点行 → 编辑它的时间(左侧是自身时刻、 + // 右侧是相对前一固定点的间隔,对应行内两列的位置)。 + if (static_cast(panelKind) == EdgePanelKind::MARK && m_tab != nullptr && + panelLane >= 0 && panelLane < static_cast(m_tab->Lanes().size())) + { + const EdgePanelLayout layout = + EdgePanelLayoutFor(panelLane, EdgePanelKind::MARK); + Lane& tabLane = m_tab->Lanes()[static_cast(panelLane)]; + for (const EdgePanelLayout::MarkerRow& row : layout.markerRows) + { + if (row.rect.Width <= 0.0f || + !UiPointInRect(row.rect, static_cast(x), static_cast(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(x) >= splitX) + { + // 间隔编辑:初始值 = 当前间隔(秒)。最左边的点没有前一项 ⇒ 不允许编辑。 + std::vector 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(-1)) + { + m_freeMarkerHoverLane = -1; + m_freeMarkerHoverItem = static_cast(-1); + changed = true; + } + if (m_freeMarkerPanelHoverLane != -1) + { + m_freeMarkerPanelHoverLane = -1; + m_freeMarkerPanelHoverItem = static_cast(-1); + m_freeMarkerPanelHoverSignal = SignalRef(); + changed = true; + } if (m_cursorHover) { m_cursorHover = false; diff --git a/Components/PlotView/Src/PlotViewInternal.hpp b/Components/PlotView/Src/PlotViewInternal.hpp index 30c784e..96a0895 100644 --- a/Components/PlotView/Src/PlotViewInternal.hpp +++ b/Components/PlotView/Src/PlotViewInternal.hpp @@ -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& 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(sizeof(kEdgePanelKinds) / sizeof(kEdgePanelKinds[0])); diff --git a/Components/PlotView/Src/PlotViewPaint.cpp b/Components/PlotView/Src/PlotViewPaint.cpp index b911d37..bcbe15a 100644 --- a/Components/PlotView/Src/PlotViewPaint.cpp +++ b/Components/PlotView/Src/PlotViewPaint.cpp @@ -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(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 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(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 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(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(laneIndex)]; + if (kind == EdgePanelKind::MARK) + { + // 固定点的**内容**(哪个信号、什么时刻)直接决定面板文字与行数,必须进键; + // 否则新增/删除一个固定点后可能贴回旧像素。 + for (const Lane::FreeMarkerItem& item : lane.freeMarkerItems) + { + mix(static_cast(item.ref.groupIndex + 2)); + mix(static_cast(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 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(m_tab->Lanes().size()) || + laneIndex >= static_cast(m_lanes.size())) + { + return false; + } + const Lane& lane = m_tab->Lanes()[static_cast(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(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& 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& 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(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(laneIndex) && + m_freeMarkerHoverItem == itemIndex); + PaintFreeMarkerCapsule(g, label, text, signalColor, close, hot); + + FreeMarkerHit hit; + hit.laneIndex = static_cast(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(lanes.size())) + { + const LaneVisual& visual = lanes[static_cast(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(-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(m_mouseX) + 12.0f * m_scale; + float top = static_cast(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(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(mouseX), static_cast(mouseY))) + { + return probe; + } + const int laneIndex = LaneAtY(mouseY); + if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size()) || + laneIndex >= static_cast(m_tab->Lanes().size())) + { + return probe; + } + const Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; + if (!lane.freeMarker) + { + return probe; + } + const LaneVisual& visual = m_lanes[static_cast(laneIndex)]; + + probe.valid = true; + probe.laneIndex = laneIndex; + probe.time = XToTime(static_cast(mouseX)); + probe.x = static_cast(mouseX); + + // 在网格内所有可见信号中找**离鼠标最近**的那个数据点。 + double bestDistance = std::numeric_limits::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(y) - static_cast(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& 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(index) && + m_freeMarkerPanelHoverSignal == ref && + m_freeMarkerPanelHoverItem == static_cast(-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(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); diff --git a/Documents/agent_mem.md b/Documents/agent_mem.md index a3aa96c..100e0a2 100644 --- a/Documents/agent_mem.md +++ b/Documents/agent_mem.md @@ -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`(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 字节收益**(静态库只拉引用到的对象);仓内已无 `//` 等体积炸弹。验证:全部离屏探针(`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 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 / 砍其它功能),本轮按"实现功能优先"未动。 + diff --git a/Utilities/Inc/DataModel.hpp b/Utilities/Inc/DataModel.hpp index 332e2bb..13ae67f 100644 --- a/Utilities/Inc/DataModel.hpp +++ b/Utilities/Inc/DataModel.hpp @@ -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 signals; /**< 该网格包含的信号(按加入顺序)。 */ bool autoRange = true; /**< 是否依据信号取值范围自动计算 Y 轴。 */ @@ -900,6 +917,58 @@ namespace jdi /** 弱峰调整浮动面板的 UI 状态(展开 + 位置)。 */ LanePanelState weakPeakPanel; + + /** + * @brief 「实验性功能 → 自由标记」开关。 + * + * 开启后本网格内跟随鼠标显示一条**仅限本网格**的纵向光标(上下各一个三角把手, + * 不显示时间读数);光标穿过信号时只标注**离鼠标最近**的那个数据点。 + * 左键单击把该数据点固定下来(固定点带 ✕ 可单独移除),浮动面板按信号分组列出 + * 「值(±增量) = 枚举文本」+「t:… Δt:…」。 + */ + bool freeMarker = false; + + /** + * @brief 已固定的自由标记点(**未排序**,按加入顺序)。 + * + * 面板内的显示顺序是按「网格内信号顺序 → 该信号内按时间升序」临时计算的, + * 这样删除某一项不会打乱其它项的下标。同一信号同一时刻不会被重复加入。 + */ + std::vector 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; }; /** diff --git a/Utilities/Src/DataModel.cpp b/Utilities/Src/DataModel.cpp index d5c93f3..3b641be 100644 --- a/Utilities/Src/DataModel.cpp +++ b/Utilities/Src/DataModel.cpp @@ -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(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(); diff --git a/Utilities/Src/DataModelInternal.hpp b/Utilities/Src/DataModelInternal.hpp index 45f9b84..2727d78 100644 --- a/Utilities/Src/DataModelInternal.hpp +++ b/Utilities/Src/DataModelInternal.hpp @@ -88,6 +88,9 @@ namespace jdi bool weakPeak = false; std::vector weakPeakItems; LanePanelState weakPeakPanel; + bool freeMarker = false; + std::vector 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; }; // 收集全部可见信号引用。