Compare commits

...
7 Commits
Author SHA1 Message Date
DeepSeek V4.1 Flash cb9d3e4b8f 修复浮动面板双击穿透、网格高亮触发来源、新增样式区「目标」行与联动平滑滚动
用户 2026-10-15 第六轮报告的四个问题与要求(详见 Documents/agent_mem.md §9.34):

1. 在网格内的实验性功能浮动面板上双击任意位置会穿透到下层网格,导致网格触发自适应。
   根因:PlotView::OnDoubleClick 在“差值标签 / 光标标签 / 光标线 / 图例”都不命中时
        无条件调用 FitAll()。
   修法:在该函数最前面(高于一切下层命中)用 TopPanelAtPoint() 判定,命中浮动面板
        则直接消费(面板自身双击无额外语义)。确立“上层元素的交互不得影响下层”的规则。

2. 未开启任何工具栏功能时鼠标移到网格上会让整个网格变色高亮(旧需求已不适用)。
   修法:取消“鼠标在网格里移动就高亮”的行为;改为在信号选择区域悬停某条信号时,
        若该信号已被勾选绘制到某个网格,则该网格变色高亮,并且该信号曲线本身也高亮;
        鼠标移开即恢复。悬停未勾选的信号不产生任何高亮。
   实现:PlotView 新增 m_externalHoverSignal + SetExternalHoverSignal()(与 m_hoverSignal
        分开保存,因为绘图区 OnMouseLeave 会清掉后者,而列表悬停时鼠标不在绘图区内)、
        EffectiveHoverLane();SignalPanel 新增 HoveredSignal(),并在 InvalidateRows()
        里一并作废;Application::Render() 每帧同步(覆盖 WM_MOUSELEAVE / 数据变化 /
        切换标签页等全部路径)。GX 徽标的悬停高亮仍按鼠标位置独立判定。

3. 在信号样式自定义区域最上方(预设颜色色块之上)新增「目标」行。
   内容:当前正在被编辑的目标信号名称;若它已绘制到网格,则在名称后方显示所在 GX 标签。
   交互:与信号选择区域的信号行一致 —— 名称/GX 标签悬停有高亮提示,且悬停时同样触发
        该信号所在网格高亮 + 曲线高亮;单击不进入编辑(只触发联动滚动),双击才进入改名
        (复用 StyleEditField::SIGNAL_NAME,m_editRow < 0 表示目标是选中信号)。
   实现:SignalPanel 新增 m_targetRowRect / PaintTargetRow / UpdateTargetRowGeometry
        (名称宽度与“是否有 GX 标签”都可能在不重排时变化,因此按需重算并带缓存键)、
        StyleHit::TARGET_NAME / TARGET_BADGE;DesiredStyleHeight() 与 LayoutStyleControls()
        相应多算一行。

4. 联动机制更新:从文件导入大量信号(如 MF4 的 124 条)时,被选中的信号可能被滚动到
   可见范围之外而找不到。
   修法:新增 SignalPanel::ScrollToSignalAnimated()(+ AdvanceScrollAnimation),
        触发联动时把信号列表平滑滚动到目标信号完整可见:
        * 目标在视界上方 ⇒ 向上滚动,且顶部让出一个组行高度,避免被悬浮组名遮挡;
        * 目标在视界下方 ⇒ 向下滚动到完整露出;
        * 已经完整可见则不动;
        * ease-out cubic(越远越快、靠近变慢),时长 = clamp(距离×0.35ms, 130, 300),
          总时长有上限;用户滚轮 / 拖滚动条立即取消动画。
   触发点:绘图区内把某条信号选为当前目标(点击图例、点击信号曲线、双击图例改名)
        ⇒ PlotView::SelectSignal() 发布请求(TakePanelScrollRequest()),宿主在鼠标消息
        处理后调用 ApplyPendingPanelScroll();样式自定义区的任何交互(预设色块、自定义
        颜色入口、样式按钮、线宽/透明度滑块与数值框、「系数/偏移」「镜像」二级菜单、
        绝对值)以及「目标」行的单击也都会触发同一次滚动。
   宿主:Application 新增 16ms 定时器(kScrollTimerId)驱动动画,并由 Render 自管开关
        (与提示浮层淡出同一套做法)。

验证(全部离屏,不占用鼠标 / 键盘 / 屏幕):
- .preflight/check(SW_HIDE + 只注入合成消息 + 进程/线程 BELOW_NORMAL)**73/73 通过**,
  连跑 3 次稳定:[A] 浮动面板双击不移视界,且对照组(空白处双击)必须触发自适应;
  [B] 鼠标在网格里移动不高亮;[C] 列表悬停 → 网格高亮 + 移开恢复 + 未勾选信号不高亮;
  [D] 目标行的布局/占位/GX 标签位置与显隐/悬停联动/单击不编辑 + 双击改名(初始文本正确);
  [E] 长列表 122 行:动画分多帧、单调、时长上限 300ms、目标完整可见且不被悬浮组名遮挡、
      已可见时不动、滚轮取消动画;[F] 样式区交互与绘图区联动都会滚动到目标并完整显示;
  [G] 布局回归(目标行在列表下方、样式区仍完整、默认无悬停与高亮)。
- A/B 判别:回退六处实现后 **15 项如实失败**(含"面板双击仍触发自适应"、"鼠标移到网格即高亮"、
  "目标行不联动"、"联动瞬移而非动画")。
- make release:全部源文件重新编译 **零警告**;链接用临时 `make release TARGET_FILE_NAME=Target_check` 通过。

同步记忆:Documents/agent_mem.md 新增 §9.34、§11.2 第 72–75 条硬约束、变更日志新行。
2026-10-15 20:32:16 +08:00
DeepSeek V4.1 Flash 73389f424c 适应视图:撤回旧策略并重做边界规则(单值散点让出半径 + 单点簇视图)
用户 2026-10-15 第五轮报告的两个问题与要求(详见 Documents/agent_mem.md §9.33):

1. 加载文件后不勾选任何信号、连点几次「适应视图」,再勾选一个单值信号时,
   初次自适应没有发生、数据点既不居中也不在网格内。
   根因:FitAll() 在没有任何可见信号时也把 Tab().ViewInitialized() 置为 true,
        随后勾选第一个信号时 Application::SynchronizeView 认为"已经初始化",
        于是跳过自适应,视界停在"无信号"那一次的整段范围上(实测 MF4 为 42.386s,
        数据点被推到绘图区右边缘之外)。
   修法:FitAll() 只有在存在可见信号时才置位 ViewInitialized();
        并让"初次自适应"与工具栏「适应视图」共用 FitAll() 这一条实现
        (不再先试 FitSinglePointSignal)。

2. 仅勾选 WIN_Motor_StallCurrentVolBrk(单值信号)与 m_MOT_Current_DCom[0](连续信号)
   时点「适应视图」,单值信号的点被挤到显示区域外只剩半个;上一轮"为左右空出一小格"
   的需求效果不佳。
   修法(按用户要求先撤回旧策略、再重做规则):
   * 撤回并删除 FitSinglePointLanes()(左右各留一小格)、TimeGridCellSeconds()、
     LaneValueGridCell()、FitSinglePointSignal()(首个单值信号的时间跨度 1s 视图)。
   * 新规则:常规全局适应后,任何一条边界(时间轴左/右、每个网格幅值上/下)
     若被一个单值信号的数据点顶住,就把该边界向外让出该散点的半径(+1 像素),
     使其完整显示在网格内;只在散点会被裁切时才扩展(内部散点使视界一点不动);
     边界不是单值信号的数据点时就落在该信号点上(时间轴不留额外余量)。
     判断依据用信号自身的数据点,而不是时间轴范围(MF4 里单值信号与连续信号
     常共用同一条时间轴,用轴的范围会误判)。
   * 特例:全部可见数据点都是单值信号且时刻完全相同时,时间轴以该时刻居中、
     缩放到一大格 = 1s(视界 = kTimeTickTarget × 1s = 10s,此时 NiceStep(10,10) 恰为 1.0),
     每个网格的幅值区间以"能完整画出该网格内的全部数据点"为准
     (退化成单值时以跨度 1 居中)。
   * 新增 FitContribution / CollectFitContributions / FitLaneHeight /
     ApplySinglePointValueMargins / FitSinglePointCluster 五个成员承载上述规则。

验证(全部离屏,不占用鼠标 / 键盘 / 屏幕):
- .preflight/check(SW_HIDE + 只注入合成消息 + 进程/线程 BELOW_NORMAL)**62/62 通过**,
  连跑 3 次稳定:[A] 初次自适应、[B] 时间边界(外扩量恰为半径 + 1px)、
  [C] 幅值边界、[D] 单点簇特例(跨度 10、一大格 1s、每个点都在网格内)、
  [E] 回归(普通信号视界 = 数据范围、网格保持自动自适应、§9.31 的
  "适应视图 ↔ Y 轴自适应互不干扰"仍逐位成立)。
- [F] 真实文件 TestData/MF4-Record.MF4:WIN_Motor_StallCurrentVolBrk 确实只有 1 个有效点
  (t = 0.005492),与 m_MOT_Current_DCom[0](8475 样点)复现用户场景 —— 点「适应视图」后
  散点圆心离绘图区左边界 16.5px、半径 15px,完整可见(修复前它正好压在边界上、只剩半个)。
- A/B 判别:回退四处规则后 **20 项如实失败**,失败信息即用户症状
  ("the dot is horizontally centred (got 1697, want 1084.75)"、
   "[F] initial view = one big cell per second (got 42.386313, want 10.000000)")。
- make release:全部源文件重新编译 **零警告**;链接用临时 `make release TARGET_FILE_NAME=Target_check` 通过。

同步记忆:Documents/agent_mem.md 新增 §9.33、§11.2 第 69–71 条硬约束(并把第 68 条与
§9.32 的第四小节显式标注为"已作废/被取代")、变更日志新行。
2026-10-15 18:35:11 +08:00
DeepSeek V4.1 Flash aee3a388b5 修复信号面板滚动条跟随、工具栏连点丢状态、图层竖向网格线步长、多网格单点自适应与单点信号拖拽
用户 2026-10-15 报告的五个问题,逐一给出根因与修法(详见 Documents/agent_mem.md §9.32):

1. 拖动信号面板滚动条、鼠标移到绘图区后“滑块卡在原地不动”
   根因:Application::WM_MOUSEMOVE 的 plotOnlyMove 只看 inPlot && m_mouseOverPlot,
        拖滚动条进入绘图区后判定“本帧只影响绘图区” ⇒ 面板滚动偏移已变却从不重绘。
   修法:新增 draggingNonPlot(正在拖非绘图区组件时一律走完整失效)。

2. 工具栏快速连点 / 双击丢状态
   根因:(a) 窗口类带 CS_DBLCLKS,快速第二次按下被合成 WM_LBUTTONDBLCLK 而工具栏未处理
             (那一次按下完全丢失);
        (b) HandleMouseMove 只要鼠标不在工具栏上就调 OnMouseLeave(),清掉 m_pressedIndex
             ⇒ 按住期间从按钮边缘划出再回到按钮松手不会触发任何动作。
   修法:WM_LBUTTONDBLCLK 命中工具栏时按“按下”处理;m_activeTarget == TOOLBAR 期间不调 OnMouseLeave()。

3. 网格叠底:拖拽期间竖向网格线错位 / 丢失,操作信号样式时“闪烁”
   根因:PaintLane 的竖向主刻步长由 Tab().ViewMin/ViewMax 推出,而渲染信号图层时该区间已被
        RenderLaneLayerRegion 改成“宽窗口”(≈2× 前台)⇒ 图层辅助线步长 2×、位置与前台不同、
        “与主刻重合则跳过”的判据也随之错位 ⇒ 贴图后网格线错位 / 整条丢失,
        并在“图层帧 ↔ 结算帧”之间来回切换时闪烁。
   修法:新增 m_renderTimeStep,由 RenderLaneLayerRegion 用前台视界跨度算好传入
        (与纵向 m_wideTickSpan/m_wideTickHeight 同一思路),PaintLane 优先使用它。

4. 多个网格、每个网格只有一个数据点时「适应视图」把数据点甩飞
   修法:新增 FitSinglePointLanes(),按用户给出的规则:
        * 左右边界 = 所有网格中最左 / 最右数据点的时间,再向外各偏“一个网格小格”的时间跨度;
        * 逐网格 Y 自适应,并在纵向上下各留“一个 Y 小格”的高度;
        * 全部数据点在同一时刻时 ⇒ 时间跨度 1s 且以该时刻居中(与首个单点信号的初始视图对齐);
        * 某网格只有一个取值 ⇒ 该网格 Y 跨度 1 且居中。
        仅在全部可见信号都是单点(finiteCount == 1)时启用,否则交回常规 FitAll。

5. 单点信号的悬停高亮与拖拽换网格
   修法:HitTestSignal 新增单点分支(SinglePointScreenPos + 容差 = max(常规容差, 该点绘制半径));
        悬停用环状光晕且不改鼠标形状(QueryCursor 不查 HitTestSignal),按下拖拽后才改 IDC_SIZEALL;
        拖拽虚影对单点信号回退为实心点 + 环。

验证(全部离屏,不占用鼠标 / 键盘 / 屏幕):
- .preflight/check(SW_HIDE + 只注入合成消息 + 进程/线程 BELOW_NORMAL)**67/67 通过**,连跑 3 次稳定。
  其中 [C] 直接量“宽基底图层位图自身”的网格线间距(图层主刻 123px vs 前台 122px、辅助线 25px vs 25px),
  并断言拖拽帧确实来自宽基底贴图路径(m_wideLastValid)。
- A/B 判别:回退上述六处修复后 **17 项如实失败**([C] 辅助线 49px vs 25px、20 vs 40 条、最大偏移 539px)。
- make release:三个改动文件重新编译 **零警告**;链接用临时 `make release TARGET_FILE_NAME=Target_check` 通过。

同步记忆:Documents/agent_mem.md 新增 §9.32、§11.2 第 64–68 条硬约束、变更日志末行。
2026-10-15 17:58:33 +08:00
DeepSeek V4.1 Flash 68d8b7ede6 自适应范围/时机澄清 + 适应视图与 Y 轴自适应互不干扰 + 吸附跟随时间偏移
一、澄清「自适应范围与时机」(并修正记忆里不完善的表述)
 * 准确说法:「额外要求视界内至少有一个可见数据点」这句不完善,完整规则是:
   - 首个网格显示内容时对它做完整自适应 —— **Y 轴与时间轴都**匹配自己的数据范围;
   - 只有一个数据点的信号必须显示在网格**中间**,保证其可见
     (FitSinglePointSignal:数据点居中、时间跨度 1s、Y 轴跨度 1);
   - **后续新增网格只做 Y 轴自适应,不再调整时间轴**。
 * Documents/agent_mem.md 相应章节(§9.30 正文、§11.2 第 58/60 条)已按此改写。

二、修复「适应视图」与「Y 轴自适应」互相干扰
 * 根因:两条自适应路径的留白不同 —— 整段自适应(ComputeLaneRange → PadRange)用 6%,
   「选区 Y 轴自适应」(AutoRangeLaneToView)用 5%。视界未变时两者本应完全相等,
   于是「点过适应视图 → 打开 Y 轴自适应」会算出不同区间、网格被无意义重绘
   (实测切一次 143513 像素),反向操作同样(137326 像素)。
 * 修法:新增 DataModel.hpp 的 PadValueRange() 作为**唯一**留白实现(含退化区间规则
   max(1, |值|×0.1)),PadRange 与 AutoRangeLaneToView 共用它。
 * 结果:「适应视图 → 开自适应 → 关自适应 → 再适应视图」四个步骤上生效区间逐位相同,
   绘图区逐像素 0 差异;而真正改变视界(缩放)时仍然正常重绘。

三、修复「适应视图」在单点信号上让数据点“飞了”
 * FitAll() 开头先尝试 FitSinglePointSignal():仅一个网格、且唯一可见信号只有一个有效
   数据点时,走与首次加载完全相同的单点初始视图(数据点居中、时间跨度 1s、Y 跨度 1),
   并保持幂等(重复点击像素不变)。

四、修复光标吸附未考虑「调整 → 时间轴偏移」
 * 根因:采样点 k 画在 time[k] + offsetTime 处,而光标存的也是显示时间,
   但吸附一直在源时间空间查找 —— 设过时间轴偏移后吸附会落到历史位置。
 * 修法:SignalSnapEnabled / HasVisibleSample / NearestSnapSample / LocalSampleSpacing
   全部增加 timeShift 形参:可见性与点数统计比较 time[k] + timeShift;
   NearestSnapSample 内部用 time - timeShift 回源空间查找、返回时再加回去;
   UpdateAutoScatterStyles(自动点线)同步传入 offsetTime,保持与吸附判定同源。

五、验证(离屏 .preflight/check:SW_HIDE 窗口、不注入真实鼠标键盘、进程/线程 BELOW_NORMAL)
 39/39 通过(连跑 3 次稳定):
 [A] 四个步骤的生效区间逐位相同 + 绘图区逐像素 0 差异(对照组:真缩放重绘 79581 像素);
 [B] 单点信号的适应视图与首次加载一致(1s/1、居中、幂等);
 [C] 首个网格自适应时间轴、后续新增/隐藏信号都不再改动时间轴;
 [D] 偏移 +0.3 时吸到“画面上的点”,偏移 0 行为不变,偏移把点移出视界时不吸附;
 [E] 回归:单点默认样式(点线 + 最大线宽)与“2…16 点不再改点线”均保持。
 A/B:回退三项修复 ⇒ 7 项如实失败(含 143513 / 137326 像素的重绘症状)。
 make release 零警告(用户正运行 Target.exe,链接用临时 -o 名字验证通过)。

六、规则同步
 Documents/agent_mem.md 新增 §9.31、§11.2 第 58–63 条(含修正后的自适应范围/时机规则)
 与变更日志。
2026-10-15 15:46:14 +08:00
DeepSeek V4.1 Flash 57cc714c1d 组名平滑悬浮 + 单点信号初始视图 + 光标吸附统一判定(自动点线同源)
一、信号列表「组名悬浮」平滑化、且不再遮住下一个组名
 * 悬浮位置改为 min(列表上边界, 本组最后一行下边界 − 组名高度):
   - 本组还有行在列表里时钉在列表上边界;
   - 本组最后一行开始滑出上边界时,组名跟着一起上移、随滚动条自然离界
     (不再像旧实现那样突然消失);
   - 「本组最后一行的下边界」恰好就是下一个分组行的自然位置,因此悬浮组名的下边界
     永远不超过下一组名 —— 旧实现固定钉在列表上边界会把下一组名盖住,让别人以为
     后面的信号属于上一组。
 * 折叠的分组不再悬浮(折叠时本组“最后一行”就是它自己,悬浮位置 = 自然位置,
   于是随滚动自然划过,不会停在最上方)。
 * 底部边界线只在真的“浮”在其它行之上时才画,避免与正常绘制出现像素差。

二、单点信号的初始视图
 * 新增 PlotView::FitSinglePointSignal():当前没有任何信号被勾选、而勾选的第一个信号
   只有一个有效数据点时,把该数据点居中放置,时间跨度 = 1s、Y 轴跨度 = 1
   (各为一个大格),并关闭该网格的整段自适应以免被覆盖。
   Application::SynchronizeView 在首次勾选时先尝试它,失败才走 FitAll()。
 * AutoRangeLaneToView 的退化区间(可见区间内只有一个取值)改用与 PadRange 一致的留白
   max(1, |v|×0.1),不再产生“几乎为 0”的区间(那会让幅值网格线挤成一团、看不出数据点)。
 * 需求作废:上一轮“2…16 个有效采样点默认改为点线 + 2px”已取消(放大自动点线已覆盖该场景);
   只保留“只有一个有效采样点 → 点线 + 最大线宽”。
 * SignalData::singlePointTime 升级为 sparseTimes/sparseValues(有效点 ≤ 1024 时在加载/插入
   时缓存全部有效点),同时服务单点初始视图与光标吸附。

三、光标吸附的统一判定
 * N = GridMinorCellCount():网格底衬一横排的小格数,与网格绘制用同一套
   NiceStep + 五等分算出(因此不随缩放等级大幅变化)。
 * SignalSnapEnabled():当且仅当某信号在当前一屏内的数据点个数 < N×4 时为它开启吸附;
   判据随视界实时重算 ⇒ 疏密变化的信号在稀疏区自动吸附、移回稠密区自动自由移动。
   额外要求视界内至少有一个可见数据点(否则“0 < N×4”会把数据全在视界之外的信号
   误判为稀疏)。统计只在可见下标区间内进行且攒够阈值即早退,代价有界。
 * SnapTimeToSample():判定只针对鼠标所在网格(同一个时间轴上不同网格结果可以不同);
   同一网格内所有符合判定的信号的数据点都参与吸附比较;
   吸附半径 = 一个网格小格的宽度,且不超过半个局部采样间距 ⇒ 数据点很稀疏
   (一屏只有一两个点)时光标在点之间仍可自由移动(吸附是提示,不是卡住)。
 * 删除旧的 SampleSpacingPixels(6px 下门限 + 半屏宽上门限)。

四、放大自动切点线改为与吸附判定完全同源
 * UpdateAutoScatterStyles() 改用 SignalSnapEnabled:进入判定自动切「点线」,
   不再满足(适应视图、或视界从稀疏区移到稠密区)自动恢复原样式;
   自动期间用户手动改过则不恢复(尊重用户选择,判定放在恢复之前)。
 * 删除旧的 AxisSparseInView(“采样间距 > 半屏宽”)。

五、验证(离屏 .preflight/check:SW_HIDE 窗口、不注入真实鼠标键盘、进程/线程 BELOW_NORMAL)
 55/55 通过(连跑 3 次稳定):[A] 组名悬浮(钉住/平滑上移/整组滚过后换下一组/折叠组不悬浮/
 悬浮下边界不超过下一组名)、[B] 单点初始视图(跨度 1s 与 1、居中、点线 + 最大线宽、
 两信号时不再走单点路径)、[C] 吸附判定(满视图都关闭/稀疏窗口开启/无可见点不开启/
 同一时间轴不同网格结果不同/同网格多信号的点都参与/稠密↔稀疏双向动态切换/
 单点靠近吸附远离自由)、[D] 自动点线(同源判定/恢复/用户手动改过不覆盖)。
 A/B:临时禁用「悬浮位置公式 / 单点初始视图 / 吸附判定」后重跑 ⇒ 14 项如实失败。
 make release 零警告(用户正运行 Target.exe,链接用临时 -o 名字验证通过)。

六、规则同步
 Documents/agent_mem.md 新增 §9.30、§11.2 第 58–61 条硬约束与变更日志。
2026-10-15 15:10:36 +08:00
DeepSeek V4.1 Flash 9f6de3bba4 全局 Y 轴自适应跟随网格结构 + 组名悬浮 + 稀疏信号默认点线 + 单点光标吸附 + 放大自动点线
一、全局「Y 轴自适应」跟随网格结构
 * 新勾选信号产生的新网格现在会**继承**该开关:RebuildLanes 重建前先算
   previousAllAutoFit(非空且每个网格都开着 = 全局开启的正式定义),LaneCarry 加
   matched 标记,新出现的信号用它作为 fitYToSelection 的默认值。
 * 新增 PlotView::ReapplyGlobalAutoFitIfEnabled(),在 Layout()(网格数量变化 →
   Relayout)、RefreshLanes()(信号数量变化)、SplitLane() 之后立即重算一次。
   ⇒ 新勾选(按当前视界自适应)、多信号网格内取消显示某信号(剩余信号重新收敛)、
     拆分网格(每份各自自适应)三项都覆盖。

二、信号列表「组名悬浮」
 * 分组行被滚出列表上边界、而该组仍有信号留在列表里时,把分组行钉在列表顶部
   (不透明底 + 底部边界线);整组翻过去后本组组名消失,改由下一组悬浮。
 * 新增 SignalPanel::StickyGroupRowIndex() / PaintStickyGroupRow();RowRect 增加
   m_rowYOverrideActive/m_rowYOverrideY 覆盖行顶部坐标(RowRect 是行内名称框 / ✕ /
   徽标的唯一几何来源)。常规行循环跳过被钉住的那一行,避免同一行被画两次。

三、「少数数据点」信号的默认样式
 * 有效采样点 1 个 → 「点线」+ 最大线宽(10px);2 … 16 个 → 「点线」+ 2px;
   其余保持「线段」+ 1px。只在新建信号时生效(BuildSignalGroup 与
   InsertDerivedSignal),不覆盖用户设置。
 * 线宽范围常量统一到 DataModel.hpp(kSignalLineWidthMin/Max),SignalPanel 的线宽
   滑块改为引用它们(原本两处各自硬编码,容易漂移)。

四、单点信号的光标吸附(吸附而非卡住)
 * 旧逻辑要求时间轴至少 2 个采样点才能算「采样间距」,单点信号因此永远不吸附。
 * 新增 SignalData::singlePointTime(加载时与 ComputeStats 同趟扫描缓存该点时刻),
   SnapTimeToSample 新增半径分支(kCursorSnapPointRadiusPixels = 8 逻辑像素):
   半径内吸附到该点,半径外自由移动(移开即自由,不是把光标吸住)。

五、放大到「采样点稀疏」时自动启用点线
 * SignalStyle 新增运行态三字段:autoScatterActive / autoScatterSavedMode /
   autoScatterUserOverride。
 * AxisSparseInView()(采样点间距 > 半个绘图区宽度,与光标吸附上门限同源;单点时间轴
   不算稀疏)+ UpdateAutoScatterStyles():进入阈值保存原绘制方式并切「点线」,
   退出阈值(如适应视图)恢复;平移不改变缩放等级 ⇒ 结果稳定,不会样式抖动。
 * 自动启用期间用户手动改了绘制方式则退出时**不恢复**(尊重用户选择);该判定必须在
   恢复判断之前完成 —— 用户改完样式可能紧接着就退出阈值,否则会被覆盖。
 * 钩子:SetTimeView(视界唯一写入口)与 Layout / RefreshLanes / SplitLane。

六、验证(离屏 .preflight/check:SW_HIDE 窗口、不注入真实鼠标键盘、进程/线程 BELOW_NORMAL)
 65/65 通过(连跑 3 次稳定):[A1]/[A2]/[A3] 全局自适应、[B] 组名悬浮(含钉住行与自然
 位置逐像素一致、整组翻过后组名消失)、[C] 稀疏默认样式边界(1/2/5/16/17/1000 点)、
 [D] 单点吸附(半径内吸附、半径外自由、无残留粘滞)、[E] 自动点线(进入/退出/平移稳定/
 用户手动改过后不覆盖/再次进入再启用)。
 A/B:临时禁用六个机制后重跑 ⇒ 21 项如实失败(覆盖全部六项),证明断言有判别力。
 make release 零警告(用户正运行 Target.exe,链接用临时 -o 名字验证通过)。

七、规则同步
 Documents/agent_mem.md 新增 §9.29、§11.2 第 55–58 条硬约束与变更日志。
2026-10-15 14:10:13 +08:00
DeepSeek V4.1 Flash 3bca3b1201 修复「信号列表滚动把组名/信号名画到筛选框上」+「边沿统计面板在不移动鼠标时显示冻结」
一、列表滚动越界绘制(SignalPanel + PaintUtil)
根因:UiText 走 GDI DrawTextW,而 GDI 文本不遵循 GDI+ 的 Graphics::SetClip。
列表虽然用 g.SetClip(m_listRect) 裁剪,但被滚出视口的「部分可见」行(分组行、信号名)
仍会把文字画到列表上方的筛选框区域(用户报告 MF4 多信号场景);UiFillRect 对大面积的
不透明矩形会改走 GDI FillRect,同样越界。
修法:
 * PaintUtil 新增 UiTextClipped(g, text, font, rect, argb, align, clipRect)
   ——在同一个 HDC 上 SaveDC + IntersectClipRect + RestoreDC;UiText 重构为转发
   clip = nullptr,行为与旧实现完全一致。
 * SignalPanel 新增 PaintRowText()(裁剪到 m_listRect)与 FillRowRect()(夹取到
   m_listRect),分组行与信号行的全部文本、行底色/选中底色都改走它们。

二、两个边沿计数浮动面板「鼠标不动就不刷新」(PlotView::PanelSlotKey)
根因:浮动面板使用像素图层缓存(m_panelDc + m_panelSlots + PanelSlotKey),而界面上显示的
「上升沿 / 下降沿」是分帧推进出来的中间结果,PanelSlotKey 里没有这些数字 ⇒ 统计推进不会
让键变化 ⇒ 面板一直贴旧像素 ⇒ 显示停在「统计中…」或某个中间值,只有移动鼠标(改变面板
层序 / 悬停 / 鼠标坐标等其它键)才会重绘并显示早已算完的正确结果;扫描推进本身并没有停。
修法:PanelSlotKey 内按面板实际显示的条目回调 EdgeCountsFor(COUNT 面板)/
EdgeCenterCountsFor(CENTER 面板),把 (rising, falling, complete) 一并 mix 进键。

三、验证(离屏 .preflight/check:SW_HIDE 窗口、不注入真实鼠标键盘、进程/线程 BELOW_NORMAL)
39/39 通过(连跑两次稳定):
 * [A] 40 信号同一网格、滚轮下滚:列表上边界以上区域逐像素 0 变化,列表内部仍有变化
   (证明是裁剪而不是整块不画);
 * [B] 3 信号 × 300 万采样、两种统计同时开启:面板像素图层缓存键在扫描期间持续变化,
   且不注入任何鼠标消息即可 complete;
 * [C] 真实 TestData/MF4-Record.MF4(124 信号 / 3 条时间轴):同样 0 像素变化。
 A/B:git stash 回退本轮修复后重跑 ⇒ [A] 1571 像素被污染、[B] 两个面板的缓存键均不推进,
 如实失败。
 make release 零警告(用户正在运行 Target.exe,链接用临时 -o 名字验证通过)。

四、规则同步
 Documents/agent_mem.md 新增 §9.28、§11.2 第 53/54 条硬约束与变更日志。
2026-10-15 12:13:40 +08:00
11 changed files with 3242 additions and 241 deletions

No files matched your search

@@ -254,6 +254,17 @@ namespace jdi
*/ */
bool ApplyPendingDerivedBuild(); bool ApplyPendingDerivedBuild();
/**
* @brief 应用绘图区提交的「把信号列表滚动到目标信号」的联动请求。
*
* 用户 2026-10-15 要求:在绘图区触发联动(点击图例 / 点击信号曲线等把某条信号选为
* 当前目标)时,信号选择区域要**平滑滚动**到该信号并完整显示它
*(信号很多时否则找不见选中的那一条)。
*
* @return 存在并应用了请求返回 true。
*/
bool ApplyPendingPanelScroll();
/** /**
* @brief 派生信号构建请求(多次迭代均值滤波 / 弱峰调整共用同一套插入流程)。 * @brief 派生信号构建请求(多次迭代均值滤波 / 弱峰调整共用同一套插入流程)。
*/ */
@@ -400,6 +411,8 @@ namespace jdi
Target m_activeTarget = Target::NONE; /**< 当前鼠标作用目标。 */ Target m_activeTarget = Target::NONE; /**< 当前鼠标作用目标。 */
bool m_trackingLeave = false; /**< 是否已注册鼠标离开通知。 */ bool m_trackingLeave = false; /**< 是否已注册鼠标离开通知。 */
bool m_hintTimerActive = false; /**< 提示浮层淡出定时器是否已启动。 */ bool m_hintTimerActive = false; /**< 提示浮层淡出定时器是否已启动。 */
/** 信号列表「滚动到目标信号」动画的定时器是否已启动(见 `SignalPanel::AdvanceScrollAnimation`)。 */
bool m_scrollTimerActive = false;
std::wstring m_windowTitleText; /**< 上次写入窗口标题的完整文本(用于避免重复设置)。 */ std::wstring m_windowTitleText; /**< 上次写入窗口标题的完整文本(用于避免重复设置)。 */
int m_tabMenuIndex = -1; /**< 标签栏右键菜单的目标标签页下标。 */ int m_tabMenuIndex = -1; /**< 标签栏右键菜单的目标标签页下标。 */
int m_plotMenuLane = -1; /**< 绘图区右键菜单的目标网格下标。 */ int m_plotMenuLane = -1; /**< 绘图区右键菜单的目标网格下标。 */
+92 -3
View File
@@ -49,6 +49,15 @@ namespace jdi
const UINT kHintTimerPeriodMs = 33; /**< 提示浮层淡出的重绘间隔(约 30fps)。 */ const UINT kHintTimerPeriodMs = 33; /**< 提示浮层淡出的重绘间隔(约 30fps)。 */
const UINT_PTR kRamTimerId = 2; /**< 标题栏内存占用刷新的定时器 ID。 */ const UINT_PTR kRamTimerId = 2; /**< 标题栏内存占用刷新的定时器 ID。 */
const UINT kRamTimerPeriodMs = 1000; /**< 标题栏内存占用的刷新间隔(1 秒)。 */ const UINT kRamTimerPeriodMs = 1000; /**< 标题栏内存占用的刷新间隔(1 秒)。 */
/**
* @brief 信号列表「滚动到目标信号」动画的定时器 ID。
*
* 用户 2026-10-15 要求:联动时列表要**平滑滚动**(不是立刻翻到目标位置),
* 且"整个动画过程不要太久"。动画本身由 `SignalPanel::AdvanceScrollAnimation()`
* 按经过时间推进,这里只负责按帧驱动它(约 60fps)。
*/
const UINT_PTR kScrollTimerId = 3;
const UINT kScrollTimerPeriodMs = 16;
/** /**
* @brief 查询本进程当前占用的内存(**私有工作集**,与任务管理器"内存"列同口径)。 * @brief 查询本进程当前占用的内存(**私有工作集**,与任务管理器"内存"列同口径)。
@@ -627,6 +636,8 @@ namespace jdi
} }
else if (!tab.ViewInitialized()) else if (!tab.ViewInitialized())
{ {
// 首次出现可见信号时做一次自适应。单点信号(时间/幅值范围退化成 0)由
// `FitAll` 内部按"单值散点"的规则处理(居中 + 一大格 1s),这里不再分开判断。
m_plot.FitAll(); m_plot.FitAll();
} }
@@ -887,6 +898,12 @@ namespace jdi
g.SetPixelOffsetMode(gd::PixelOffsetModeHalf); g.SetPixelOffsetMode(gd::PixelOffsetModeHalf);
g.SetTextRenderingHint(gd::TextRenderingHintClearTypeGridFit); g.SetTextRenderingHint(gd::TextRenderingHintClearTypeGridFit);
// 信号列表 / 样式区「目标」行的悬停 → 绘图区联动高亮(网格变色 + 该信号曲线高亮)。
// 用户 2026-10-15 要求;鼠标在网格里移动**不再**触发网格变色高亮。
// ⚠️ 每帧同步(而不是只在 WM_MOUSEMOVE 里同步)才能覆盖全部改变悬停的路径:
// 鼠标离开窗口(WM_MOUSELEAVE)、数据/行结构变化、切换标签页等。
m_plot.SetExternalHoverSignal(m_panel.HoveredSignal());
if (chromeDirty) if (chromeDirty)
{ {
UiFillRect(g, gd::RectF(0.0f, 0.0f, static_cast<float>(width), static_cast<float>(height)), UiFillRect(g, gd::RectF(0.0f, 0.0f, static_cast<float>(width), static_cast<float>(height)),
@@ -941,6 +958,21 @@ namespace jdi
KillTimer(m_hwnd, kHintTimerId); KillTimer(m_hwnd, kHintTimerId);
m_hintTimerActive = false; m_hintTimerActive = false;
} }
// 信号列表的目标信号滚动动画:仅在动画进行中保持定时重绘。
if (m_panel.ScrollAnimationActive())
{
if (!m_scrollTimerActive)
{
SetTimer(m_hwnd, kScrollTimerId, kScrollTimerPeriodMs, nullptr);
m_scrollTimerActive = true;
}
}
else if (m_scrollTimerActive)
{
KillTimer(m_hwnd, kScrollTimerId);
m_scrollTimerActive = false;
}
} }
void Application::SetStatusMessage(const std::wstring& text, bool isError) void Application::SetStatusMessage(const std::wstring& text, bool isError)
@@ -1474,8 +1506,17 @@ namespace jdi
bool changed = false; bool changed = false;
changed = changed =
(m_tabBar.HitTest(x, y) ? m_tabBar.OnMouseMove(x, y) : m_tabBar.OnMouseLeave()) || changed; (m_tabBar.HitTest(x, y) ? m_tabBar.OnMouseMove(x, y) : m_tabBar.OnMouseLeave()) || changed;
changed = // ⚠️ 按住工具栏按钮期间**绝不能**走 `OnMouseLeave()`:它会清掉按下态(`m_pressedIndex`),
(m_toolbar.HitTest(x, y) ? m_toolbar.OnMouseMove(x, y) : m_toolbar.OnMouseLeave()) || changed; // 于是随后在按钮上松手时什么也不会发生 —— 这正是“快速点击工具栏按钮时状态没切换”
// 的另一个成因(鼠标带着按下态从按钮边缘移出时会命中这里)。
if (m_toolbar.HitTest(x, y))
{
changed = m_toolbar.OnMouseMove(x, y) || changed;
}
else if (m_activeTarget != Target::TOOLBAR)
{
changed = m_toolbar.OnMouseLeave() || changed;
}
changed = (m_panel.HitTest(x, y) ? m_panel.OnMouseMove(x, y) : m_panel.OnMouseLeave()) || changed; changed = (m_panel.HitTest(x, y) ? m_panel.OnMouseMove(x, y) : m_panel.OnMouseLeave()) || changed;
changed = (m_plot.HitTest(x, y) ? m_plot.OnMouseMove(x, y) : m_plot.OnMouseLeave()) || changed; changed = (m_plot.HitTest(x, y) ? m_plot.OnMouseMove(x, y) : m_plot.OnMouseLeave()) || changed;
return changed; return changed;
@@ -1766,6 +1807,18 @@ namespace jdi
return true; return true;
} }
bool Application::ApplyPendingPanelScroll()
{
SignalRef ref;
if (!m_plot.TakePanelScrollRequest(ref) || !ref.IsValid())
{
return false;
}
// 平滑滚动(带动画):用户要求"是滚动的而不是立刻翻到目标信号位置"。
m_panel.ScrollToSignalAnimated(ref);
return true;
}
bool Application::ApplyPendingDerivedBuild() bool Application::ApplyPendingDerivedBuild()
{ {
// 两类「构建为新信号」请求(均值滤波 / 弱峰调整)机制完全一致, // 两类「构建为新信号」请求(均值滤波 / 弱峰调整)机制完全一致,
@@ -2471,8 +2524,17 @@ namespace jdi
// 仅当本次移动不会影响绘图区之外的区域时,才跳过装饰区域的重绘: // 仅当本次移动不会影响绘图区之外的区域时,才跳过装饰区域的重绘:
// 正在拖拽绘图区(鼠标可能被捕获到绘图区外),或鼠标始终停留在绘图区内。 // 正在拖拽绘图区(鼠标可能被捕获到绘图区外),或鼠标始终停留在绘图区内。
//
// ⚠️ **必须同时看“当前正在拖拽哪个组件”**:拖拽信号面板的滚动条时,鼠标很容易
// 移动到绘图区上方,若只看 `inPlot && m_mouseOverPlot` 就会判定为“只影响绘图区”、
// 只失效绘图区 —— 面板的滚动位置明明已经变了,却一直不重绘(
// 用户报告的“拖住滚动条移到网格区域后滑块卡在原地不动”)。
const bool draggingNonPlot =
(m_activeTarget != Target::NONE) && (m_activeTarget != Target::PLOT) &&
(m_activeTarget != Target::EDIT_FIELD);
const bool inPlot = UiPointInRect(m_plotRect, static_cast<float>(x), static_cast<float>(y)); const bool inPlot = UiPointInRect(m_plotRect, static_cast<float>(x), static_cast<float>(y));
const bool plotOnlyMove = (m_activeTarget == Target::PLOT) || (inPlot && m_mouseOverPlot); const bool plotOnlyMove =
!draggingNonPlot && ((m_activeTarget == Target::PLOT) || (inPlot && m_mouseOverPlot));
if (!plotOnlyMove) if (!plotOnlyMove)
{ {
m_chromeDirty = true; m_chromeDirty = true;
@@ -2620,6 +2682,8 @@ namespace jdi
ApplyPendingDerivedBuild(); ApplyPendingDerivedBuild();
InvalidateRect(hwnd, nullptr, FALSE); InvalidateRect(hwnd, nullptr, FALSE);
} }
// 联动:在绘图区选中了某条信号 ⇒ 让信号列表平滑滚动到它并完整显示。
ApplyPendingPanelScroll();
// 拖动期间 Windows 不再派发 WM_SETCURSOR,需在按下后立即刷新一次, // 拖动期间 Windows 不再派发 WM_SETCURSOR,需在按下后立即刷新一次,
// 使"按下但尚未拖动"时能立刻看到十字/方向箭头提示。 // 使"按下但尚未拖动"时能立刻看到十字/方向箭头提示。
UpdateDragCursor(x, y); UpdateDragCursor(x, y);
@@ -2732,6 +2796,22 @@ namespace jdi
{ {
const int x = GET_X_LPARAM(lParam); const int x = GET_X_LPARAM(lParam);
const int y = GET_Y_LPARAM(lParam); const int y = GET_Y_LPARAM(lParam);
// ⚠️ **工具栏必须在这里也当作“按下”处理**:窗口类带 `CS_DBLCLKS`,快速连点同一个
// 按钮时,第二次(及之后的快速按下)会被 Windows 合成为 `WM_LBUTTONDBLCLK`。
// 工具栏没有双击语义,若这里不处理,那一次按下就完全丢失 ——
// 用户看到的正是“连点好几下、开关状态只切换了几次”。
if (m_toolbar.HitTest(x, y))
{
SetFocus(hwnd);
SetCapture(hwnd);
m_activeTarget = Target::TOOLBAR;
ToolbarAction action = ToolbarAction::NONE;
m_toolbar.OnLButtonDown(x, y, action);
InvalidateRect(hwnd, nullptr, FALSE);
return 0;
}
bool changed = false; bool changed = false;
if (m_tabBar.HitTest(x, y)) if (m_tabBar.HitTest(x, y))
{ {
@@ -2755,6 +2835,8 @@ namespace jdi
{ {
InvalidateRect(hwnd, nullptr, TRUE); InvalidateRect(hwnd, nullptr, TRUE);
} }
// 联动:双击图例改名也会选中该信号 ⇒ 同步把列表滚动到它。
ApplyPendingPanelScroll();
return 0; return 0;
} }
case WM_RBUTTONDOWN: case WM_RBUTTONDOWN:
@@ -2998,6 +3080,13 @@ namespace jdi
InvalidateRect(hwnd, nullptr, FALSE); InvalidateRect(hwnd, nullptr, FALSE);
return 0; return 0;
} }
// 信号列表「滚动到目标信号」动画:推进一步并重绘(Render 会在结束后停表)。
if (wParam == kScrollTimerId)
{
m_panel.AdvanceScrollAnimation();
InvalidateRect(hwnd, nullptr, FALSE);
return 0;
}
break; break;
} }
case WM_DROPFILES: case WM_DROPFILES:
+272 -21
View File
@@ -296,18 +296,116 @@ namespace jdi
*/ */
void ApplySelectionAutoFit(); void ApplySelectionAutoFit();
/**
* @brief 网格结构变化后,若「全局 Y 轴自适应」处于开启状态则重新做一次全局自适应。
*
* 用户要求(2026-10-15):
* * 新勾选信号产生的**新网格**要按**当前视界**自适应(新网格继承开关由
* `RebuildLanes` 负责,这里负责“立即算一次”);
* * 多信号网格内**取消显示**某条信号、或把网格**拆分**之后,剩余/新生的网格
* 都要重新收敛到当前视界(原来的幅值区间是按旧信号集合算的)。
*
* 判据与工具栏「Y 轴自适应」一致 = `AllLanesAutoFitToSelection()`(全部网格都开启)。
*/
void ReapplyGlobalAutoFitIfEnabled();
/**
* @brief 依据「光标吸附判定」自动启用/恢复「点线」绘制方式。
*
* 用户要求(2026-10-15):**仅在满足光标吸附判定的信号上**生效 ——
* 即“一屏内数据点少于 N×4”(见 `SignalSnapEnabled`)时自动切到「点线」提示
* 用户数据点的位置;一旦不再满足(适应视图、或把视界从稀疏区移到稠密区)
* 自动恢复**原来的**绘制方式。
*
* ⚠️ 自动启用期间若用户**手动**改了绘制方式(`SignalStyle::autoScatterUserOverride`
* 由本函数自动识别:自动状态下 mode 不等于 `LINE_SCATTER` 即说明是用户改的),
* 退出时**不再**恢复,用户的选择优先。
*
* @return 有任何信号的绘制方式被改变返回 true(调用方需作废缓存)。
*/
bool UpdateAutoScatterStyles();
/**
* @brief 网格底衬**一横排的小格数量 N**(与 `PaintLane` 画出来的网格同源)。
*
* 时间刻度用 `NiceStep(span, kTimeTickTarget)` 定主刻度、再五等分 ⇒
* 不管缩放等级如何,一横排总是差不多同样多的小格。
*
* @return 小格数量(无法判定时返回 0)。
*/
int GridMinorCellCount() const;
/**
* @brief 判断某信号在当前视界下是否**开启光标吸附**。
*
* 用户 2026-10-15 的定义:**当且仅当**该信号在“当前一屏”(当前视界)内的数据点
* 个数 `< N × 4`(N = `GridMinorCellCount()`)时开启吸附。
* 判据随视界实时重算 ⇒ 疏密变化的信号在稀疏区自动吸附、稠密区自动自由移动。
*
* ⚠️ 额外要求视界内**至少有一个可见数据点**:数据点全在视界之外的信号既没有可吸附
* 的对象、也没有可提示的内容(否则它会被判成“0 < N×4 稀疏”而白切一次「点线」)。
* ⚠️ 可见性一律按**显示时间**(`采样时刻 + timeShift`)判断 —— `timeShift` 就是该信号的
* `SignalStyle::offsetTime`(存在时间轴偏移时不能拿源时刻去比视界)。
*
* @param [IN] axis 该信号使用的时间轴。
* @param [IN] signal 目标信号。
* @param [IN] timeShift 该信号的显示时间偏移(`offsetTime`)。
* @return 开启吸附返回 true。
*/
bool SignalSnapEnabled(const TimeAxis& axis, const SignalData& signal, double timeShift) const;
/**
* @brief 判断某信号在给定可见下标区间内是否存在**落在视界内**的有效采样点。
* @param [IN] axis 该信号使用的时间轴。
* @param [IN] signal 目标信号。
* @param [IN] begin 可见下标区间起点。 @param [IN] end 终点(不含)。
* @param [IN] timeShift 该信号的显示时间偏移(`offsetTime`)。
* @return 存在返回 true。
*/
bool HasVisibleSample(const TimeAxis& axis, const SignalData& signal, std::size_t begin,
std::size_t end, double timeShift) const;
/**
* @brief 找某信号**可见区间内**离给定时刻最近的有效采样点。
*
* 稀疏信号(有效点已缓存)用二分;一般信号从最近下标向两侧**有界**探测
*(NaN 只出现在缺少数据的区段)。视界之外的采样点不是合法落点,一律排除。
*
* ⚠️ `time` 与输出的 `candidate` 都是**显示时间**(画面上的时刻):内部按 `timeShift`
* 换算回源空间查找、再把结果加回去,因此存在「调整 → 时间轴偏移」时也能吸到
* **画面上**的数据点,而不是历史位置。
*
* @param [IN] axis 该信号使用的时间轴。
* @param [IN] signal 目标信号。
* @param [IN] time 目标时刻(显示时间)。
* @param [IN] timeShift 该信号的显示时间偏移(`offsetTime`)。
* @param [OUT] candidate 最近的可见有效采样点时刻(显示时间)。
* @return 找到返回 true。
*/
bool NearestSnapSample(const TimeAxis& axis, const SignalData& signal, double time,
double timeShift, double& candidate) const;
/**
* @brief 取某时刻附近的**局部采样间距**(时间单位,用于限制吸附半径)。
* @param [IN] axis 该信号使用的时间轴。
* @param [IN] signal 目标信号。
* @param [IN] time 目标时刻(显示时间)。
* @param [IN] timeShift 该信号的显示时间偏移(`offsetTime`)。
* @param [OUT] spacing 相邻采样点的时间间隔(无法判定时为 0)。
*/
void LocalSampleSpacing(const TimeAxis& axis, const SignalData& signal, double time,
double timeShift, double& spacing) const;
/** /**
* @brief 把时间吸附到距今最近的数据采样点。 * @brief 把时间吸附到距今最近的数据采样点。
* *
* 仅在横向放大到采样点间距足够大(可精确分辨)时生效, * 判定与半径(用户 2026-10-15 统一后的定义):
* 避免缩小时光标出现“抖动”难以微调。 * * **判定只针对鼠标所在的网格**:该网格内的信号各自按 `SignalSnapEnabled` 判定,
* * 符合条件的信号的数据点全部参与吸附;网格内没有符合条件的信号时完全自由移动;
* ⚠️ 吸附有**上下两个门限**: * * 同一网格内有多条信号时,所有符合条件的数据点都参与比较;
* * 下门限:采样点间距 ≥ `kCursorSnapMinPixels`(缩小时不吸附,否则难以微调); * * 吸附半径 = **一个网格小格的宽度**(与判定同源),且**不超过半个局部采样间距**
* * 上门限:视界内至少能容纳 **2 个**采样点(`pixels <= plotWidth / 2`)。 * ⇒ 数据点很稀疏(一屏只有一两个点)时光标在点与点之间仍可自由移动,
* 视界比采样间隔还窄时“最近采样点”在视界之外,吸附会把光标一把甩到视界边缘 * 而不是被“死死吸住”(吸附只是提示性响应,不是卡住光标)。
* 并钉住(表现为“一拖光标就飞到视界边缘、跟不动鼠标”)。
* * 并且只接受**落在当前可见区间内**的采样点。
* *
* @param [IN] time 原始时间。 * @param [IN] time 原始时间。
* @param [OUT] snapped 吸附后的时间。 * @param [OUT] snapped 吸附后的时间。
@@ -315,15 +413,6 @@ namespace jdi
*/ */
bool SnapTimeToSample(double time, double& snapped) const; bool SnapTimeToSample(double time, double& snapped) const;
/**
* @brief 计算指定文件中某一时刻附近采样点的时间间隔。
* @param [IN] file 目标文件。
* @param [IN] time 目标时刻。
* @param [OUT] pixels 间隔换算到屏幕上的像素宽度。
* @return 计算成功返回 true。
*/
bool SampleSpacingPixels(const TimeAxis& axis, double time, double& pixels) const;
/** /**
* @brief 判断坐标是否落在绘图区范围内。 * @brief 判断坐标是否落在绘图区范围内。
* @param [IN] x 客户区横坐标。 * @param [IN] x 客户区横坐标。
@@ -341,10 +430,82 @@ namespace jdi
HCURSOR QueryCursor(int x, int y) const; HCURSOR QueryCursor(int x, int y) const;
/** /**
* @brief 时间轴与幅值轴同时自适应到可见信号范围。 * @brief 「适应视图」:时间轴与幅值轴同时自适应到可见信号范围。
*
* 边界规则(用户 2026-10-15 第五轮**重新定义**,取代此前"左右各留一小格"的旧做法):
* * 常规的全局适应后,若某条边界(时间轴的左 / 右、每个网格幅值的上 / 下)被一个
* **单值信号的数据点**顶住,则该边界再向外让出**该散点的半径**,使它**完整地
* 显示在网格内**;边界若不是单值信号的数据点,则边界就落在这个信号点上
* (时间轴不留额外余量,幅值仍走 `PadValueRange` 的统一留白)。
* * 唯一特例:**所有可见信号都是单值数据点、且时刻完全相同**时,时间轴以该时刻
* 居中并缩放到"一大格 = 1s",每个网格的幅值区间保证其数据点都能完整画出。
*
* ⚠️ 没有任何可见信号时**不置位** `Tab().ViewInitialized()`:用户先连点几次
* 「适应视图」后再勾选第一个信号,初次自适应仍必须发生。
*/ */
void FitAll(); void FitAll();
/**
* @brief 一个**可见信号**在「适应视图」里提供的边界贡献(见 `FitAll` 的边界规则)。
*/
struct FitContribution
{
SignalRef ref; /**< 信号引用。 */
double minTime = 0.0; /**< 时间轴下限(源时刻,与 `ComputeVisibleTimeBounds` 同源)。 */
double maxTime = 0.0; /**< 时间轴上限。 */
bool singlePoint = false; /**< 是否只有一个有效数据点(画出来就是一个圆点)。 */
double pointTime = 0.0; /**< 单值时:数据点的源时刻。 */
double pointValue = 0.0; /**< 单值时:**绘制变换后**的取值。 */
float dotRadius = 0.0f; /**< 单值时:散点半径(设备像素)。 */
};
/**
* @brief 汇总全部可见信号的 `FitContribution`(按网格分组)。
* @param [OUT] perLane 与 `Tab().Lanes()` 同序的分组结果。
* @return 可见信号总数(0 表示当前没有任何可见信号)。
*/
std::size_t CollectFitContributions(std::vector<std::vector<FitContribution>>& perLane) const;
/**
* @brief 当前布局下**单个网格的高度**(设备像素)。
*
* 与 `BuildLaneVisuals()` 用同一公式(而不是读 `m_lanes[i].rect.Height`):
* `FitAll` 可能在网格数量刚变化、布局尚未重建时被调用,公式给出"将要生效"的值。
* @return 网格高度;无网格时返回 0。
*/
float FitLaneHeight() const;
/**
* @brief 若该网格的**幅值上/下边界**被单值散点顶住,把区间向外让出散点半径。
*
* 只在散点**会被裁切**时才改动区间("散点本来就完整可见"不动它,保持自动自适应的
* 语义);改动时把 `autoRange` 置 false 并写入显式区间。
*/
void ApplySinglePointValueMargins(int laneIndex, const std::vector<FitContribution>& items,
float laneHeight);
/**
* @brief 「所有可见数据点都是单值信号、且时刻完全相同」时的自适应。
*
* 时间轴以 `sharedTime` 居中、**一大格 = 1s**;每个网格的幅值区间保证其数据点
* 完整显示(网格内只有一个取值时以跨度 1 居中)。
*/
void FitSinglePointCluster(const std::vector<std::vector<FitContribution>>& perLane,
double sharedTime);
/**
* @brief 取某信号**唯一的有效数据点**(时刻 + 原始取值)。
*
* 优先用加载时缓存的稀疏点;没有缓存时逐个采样找(单点信号的采样数组必然很短)。
*
* @param [IN] signal 目标信号。
* @param [IN] axis 该信号使用的时间轴。
* @param [OUT] time 数据点时刻。 @param [OUT] value 数据点原始取值。
* @return 取到有效点返回 true。
*/
bool SinglePointOfSignal(const SignalData& signal, const TimeAxis& axis, double& time,
double& value) const;
/** /**
* @brief 以给定倍率缩放时间轴(以视图中心为锚点)。 * @brief 以给定倍率缩放时间轴(以视图中心为锚点)。
* @param [IN] factor 缩放倍率,大于 1 表示放大。 * @param [IN] factor 缩放倍率,大于 1 表示放大。
@@ -719,6 +880,39 @@ namespace jdi
*/ */
bool TakePendingRename(SignalRef& ref, std::wstring& name); bool TakePendingRename(SignalRef& ref, std::wstring& name);
/**
* @brief 设置「外部悬停信号」(信号列表 / 样式区「目标」行悬停到某条信号时调用)。
*
* 用户 2026-10-15 要求:
* * 鼠标**在网格里移动不再触发**网格变色高亮(旧行为已取消);
* * 改为**在信号列表里悬停某条信号**时,若该信号已勾选绘制到某个网格,
* 则该网格变色高亮,并且该信号曲线本身也高亮;鼠标移开即恢复。
*
* ⚠️ 与 `m_hoverSignal`(鼠标在绘图区内直接命中的信号)**分开保存**:绘图区的
* `OnMouseLeave` 会清掉后者,而列表悬停时鼠标恰恰不在绘图区内。
*
* @param [IN] ref 悬停的信号;无效引用表示取消外部悬停。
*/
void SetExternalHoverSignal(const SignalRef& ref);
/**
* @brief 当前外部悬停的信号(供验证 / 宿主查询)。
* @return 悬停的信号引用,无则无效。
*/
const SignalRef& ExternalHoverSignal() const { return m_externalHoverSignal; }
/**
* @brief 取出并清除「请把信号列表滚动到该信号」的联动请求。
*
* 用户 2026-10-15 要求:在绘图区触发联动(点击图例 / 点击信号曲线等把某条信号选为
* 当前目标)时,信号选择区域要**平滑滚动**到该信号并完整显示它 ——
* 信号很多时(如 MF4 的 124 条)否则找不见选中的那一条。
*
* @param [OUT] ref 需要滚动到的信号。
* @return 存在待处理的请求返回 true。
*/
bool TakePanelScrollRequest(SignalRef& ref);
/** /**
* @brief 处理编辑态下的字符输入。 * @brief 处理编辑态下的字符输入。
* @param [IN] character 输入字符。 * @param [IN] character 输入字符。
@@ -1813,6 +2007,20 @@ namespace jdi
gd::RectF m_rubberRect; /**< 拖拽选区矩形。 */ gd::RectF m_rubberRect; /**< 拖拽选区矩形。 */
SignalRef m_hoverSignal; /**< 鼠标悬停命中的信号(曲线高亮)。 */ SignalRef m_hoverSignal; /**< 鼠标悬停命中的信号(曲线高亮)。 */
/**
* @brief 「外部悬停信号」:信号列表 / 样式区「目标」行悬停到某条信号时的联动高亮源。
*
* 与 `m_hoverSignal` 分开(绘图区的 `OnMouseLeave` 会清掉后者,而列表悬停时
* 鼠标并不在绘图区内);二者的高亮效果相同:网格变色 + 曲线光晕。
*/
SignalRef m_externalHoverSignal;
/**
* @brief 待宿主处理的「把信号列表滚动到该信号」请求(见 `TakePanelScrollRequest`)。
*
* 绘图区自身无法访问信号面板,因此把请求记在这里,由宿主在鼠标消息处理后取走。
*/
SignalRef m_pendingPanelScroll;
bool m_pendingPanelScrollFlag = false;
SignalRef m_hoverLegend; /**< 鼠标悬停命中的图例条目(半高亮提示,不触发曲线拖拽提示)。 */ SignalRef m_hoverLegend; /**< 鼠标悬停命中的图例条目(半高亮提示,不触发曲线拖拽提示)。 */
SignalRef m_dragSignal; /**< 正在被拖拽的信号。 */ SignalRef m_dragSignal; /**< 正在被拖拽的信号。 */
int m_dragTargetLane = -1; /**< 拖拽信号时的目标网格下标。 */ int m_dragTargetLane = -1; /**< 拖拽信号时的目标网格下标。 */
@@ -1874,6 +2082,14 @@ namespace jdi
EdgePanelKind m_pendingPanelCloseKind = EdgePanelKind::COUNT; /**< 待关闭面板的功能类型。 */ EdgePanelKind m_pendingPanelCloseKind = EdgePanelKind::COUNT; /**< 待关闭面板的功能类型。 */
int m_hoverLane = -1; /**< 当前悬停网格下标。 */ int m_hoverLane = -1; /**< 当前悬停网格下标。 */
/**
* @brief 取本次绘制应该高亮的网格下标(-1 表示无)。
*
* 来源两处:① 鼠标悬停在实验性功能浮动面板上(该面板所属网格);
* ② 外部悬停信号(信号列表 / 样式区)且它已绘制到某个网格时,高亮该网格。
* ⚠️ 鼠标在网格里移动**不再**触发高亮(用户 2026-10-15 要求取消旧行为)。
*/
int EffectiveHoverLane() const;
/** /**
* @brief 上一帧鼠标是否悬停在某个网格的“GX”编号徽标上(是则记录网格下标,否则 -1)。 * @brief 上一帧鼠标是否悬停在某个网格的“GX”编号徽标上(是则记录网格下标,否则 -1)。
* *
@@ -2381,21 +2597,44 @@ namespace jdi
* @brief 绘制某条信号的悬停/拖拽光晕(按缓存的折线模式选择最省的画法)。 * @brief 绘制某条信号的悬停/拖拽光晕(按缓存的折线模式选择最省的画法)。
* @param [IN,OUT] g 绘图上下文。 * @param [IN,OUT] g 绘图上下文。
* @param [IN] resolved 已解析的信号。 * @param [IN] resolved 已解析的信号。
* @param [IN] lane 该信号所在网格(单点信号需要它把数据点换算到屏幕坐标)。
* @param [IN] cached 该信号的折线缓存项(包络带模式下会走带外扩填充)。 * @param [IN] cached 该信号的折线缓存项(包络带模式下会走带外扩填充)。
*/ */
void PaintSignalHaloFor(gd::Graphics& g, const ResolvedSignal& resolved, void PaintSignalHaloFor(gd::Graphics& g, const ResolvedSignal& resolved, const LaneVisual& lane,
const PolylineCacheEntry& cached); const PolylineCacheEntry& cached);
/** /**
* @brief 绘制某条信号的悬停/拖拽光晕(折线描边版本)。 * @brief 绘制某条信号的悬停/拖拽光晕(折线描边版本)。
*
* ⚠️ 只有**单个有效数据点**的信号没有折线可描边(其默认样式是「点线」),
* 此时改为围绕那个数据点画一圈强调色圆环 —— 否则悬停时完全没有视觉反馈。
*
* @param [IN,OUT] g 绘图上下文。 * @param [IN,OUT] g 绘图上下文。
* @param [IN] resolved 已解析的信号。 * @param [IN] resolved 已解析的信号。
* @param [IN] lane 该信号所在网格。
* @param [IN] points 折线顶点。 * @param [IN] points 折线顶点。
* @param [IN] breaks 每段折线的起始下标。 * @param [IN] breaks 每段折线的起始下标。
*/ */
void PaintSignalHalo(gd::Graphics& g, const ResolvedSignal& resolved, void PaintSignalHalo(gd::Graphics& g, const ResolvedSignal& resolved, const LaneVisual& lane,
const std::vector<gd::PointF>& points, const std::vector<std::size_t>& breaks); const std::vector<gd::PointF>& points, const std::vector<std::size_t>& breaks);
/**
* @brief 单点信号那个数据点绘制时的半径(与散点绘制同源)。
* @param [IN] resolved 已解析的信号。
* @return 半径(像素)。
*/
float SinglePointDotRadius(const ResolvedSignal& resolved) const;
/**
* @brief 取某信号**唯一数据点**在屏幕上的位置(含时间偏移与「调整」变换)。
* @param [IN] resolved 已解析的信号(要求 `finiteCount == 1`)。
* @param [IN] lane 该信号所在网格。
* @param [OUT] out 屏幕坐标。
* @return 取到有效位置返回 true。
*/
bool SinglePointScreenPos(const ResolvedSignal& resolved, const LaneVisual& lane,
gd::PointF& out) const;
/** /**
* @brief 计算某网格图例中 GX 编号徽标的矩形(绘制与命中测试共用,保证一致)。 * @brief 计算某网格图例中 GX 编号徽标的矩形(绘制与命中测试共用,保证一致)。
* @param [IN] laneIndex 网格下标。 * @param [IN] laneIndex 网格下标。
@@ -2784,6 +3023,18 @@ namespace jdi
*/ */
double m_wideTickSpan = 0.0; double m_wideTickSpan = 0.0;
float m_wideTickHeight = 0.0f; float m_wideTickHeight = 0.0f;
/**
* @brief 渲染「信号图层(宽基底)」时使用的时间主刻步长(秒);0 = 按当前视界自行推算。
*
* ⚠️ **必须由 `RenderLaneLayerRegion` 显式传入(前台视界跨度算出的步长)**:
* 图层画布比前台宽约一倍(左右各半屏),而 `Tab().ViewMin/ViewMax` 在图层的临场渲染里
* 被改成了**宽窗口**区间 —— 若 `PaintLane` 直接拿这个区间去 `NiceStep`,算出的主刻步长会
* 比前台大(往往正好 2 倍),于是图层里的竖向网格线(含五等分辅助线)**落在与前台不同的位置**、
* 并且“与主刻重合则跳过”的判据也错位 → 用户看到网格线**错位/部分整条丢失**、
* 且在“拖拽帧(用图层)”与“结算帧(整幅重绘)”之间来回切换时**闪烁**。
* 与纵向的 `m_wideTickSpan/m_wideTickHeight` 是同一套思路(那两处已经做对了)。
*/
double m_renderTimeStep = 0.0;
/** /**
* @brief 本次 `PaintLane` 只绘制「装饰」(Y 轴刻度胶囊 + 图例 + 网格分界线)。 * @brief 本次 `PaintLane` 只绘制「装饰」(Y 轴刻度胶囊 + 图例 + 网格分界线)。
File diff suppressed because it is too large. Load diff
+190 -3
View File
@@ -83,7 +83,15 @@ namespace jdi
/** /**
* @brief 标记列表结构失效(数据或折叠状态变化后调用)。 * @brief 标记列表结构失效(数据或折叠状态变化后调用)。
*/ */
void InvalidateRows() { m_rowsDirty = true; } void InvalidateRows()
{
m_rowsDirty = true;
// 列表结构变化后行下标会失效 ⇒ 悬停联动高亮也必须一并作废,
// 否则可能对一条已经不存在(或已被替换)的信号继续高亮它的网格。
m_hoveredSignal = SignalRef();
m_hoverRow = -1;
m_hoverPart = RowPart::NONE;
}
/** /**
* @brief 设置 DPI 缩放比例。 * @brief 设置 DPI 缩放比例。
@@ -292,11 +300,48 @@ namespace jdi
HCURSOR QueryCursor(int x, int y); HCURSOR QueryCursor(int x, int y);
/** /**
* @brief 将列表滚动到指定信号所在位置。 * @brief 将列表滚动到指定信号所在位置(无动画,立即到位)。
* @param [IN] ref 目标信号引用。 * @param [IN] ref 目标信号引用。
*/ */
void ScrollToSignal(const SignalRef& ref); void ScrollToSignal(const SignalRef& ref);
/**
* @brief 平滑滚动到指定信号(带动画)。
*
* 用户 2026-10-15 要求:触发联动机制(点击图例 / 信号曲线,或在样式自定义区与「目标」行
* 做任何交互)时,要把信号列表**滚动**到目标信号完整可见:
* * 目标在当前视界上方 ⇒ 向上滚动,直到它**完整露出且不被悬浮组名遮挡**;
* * 目标在当前视界下方 ⇒ 向下滚动直到它完整露出;
* * 滚动速度随距离变化(越远越快、靠近时变慢),但总时长有上限(不要拖得太久)。
*
* @param [IN] ref 目标信号引用。
*/
void ScrollToSignalAnimated(const SignalRef& ref);
/**
* @brief 推进一步滚动动画(由宿主定时器驱动)。
* @return 动画仍在进行返回 true(宿主应保持定时重绘)。
*/
bool AdvanceScrollAnimation();
/** @brief 滚动动画是否正在进行。 */
bool ScrollAnimationActive() const { return m_scrollAnimActive; }
/**
* @brief 供绘图区联动高亮的「悬停信号」。
*
* 来源:① 列表里鼠标悬停的信号行;② 样式区「目标」行的信号名 / GX 标签。
* 鼠标移开即返回无效引用。
* @return 悬停的信号引用。
*/
const SignalRef& HoveredSignal() const { return m_hoveredSignal; }
/**
* @brief 「目标」(正在编辑样式的)信号所在的网格下标。
* @return 网格下标;未勾选绘制到任何网格时返回 -1。
*/
int TargetLaneIndex() const;
private: private:
/** /**
* @brief 样式区与列表行中可进行内联文本编辑的字段。 * @brief 样式区与列表行中可进行内联文本编辑的字段。
@@ -306,7 +351,7 @@ namespace jdi
NONE = 0, /**< 未处于编辑状态。 */ NONE = 0, /**< 未处于编辑状态。 */
WIDTH = 1, /**< 线宽数值。 */ WIDTH = 1, /**< 线宽数值。 */
OPACITY = 2, /**< 不透明度数值(百分比)。 */ OPACITY = 2, /**< 不透明度数值(百分比)。 */
SIGNAL_NAME = 3, /**< 信号显示名称(列表行内)。 */ SIGNAL_NAME = 3, /**< 信号显示名称(列表行内;`row < 0` 表示样式区「目标」行)。 */
FILE_NAME = 4 /**< 分组显示名称(列表行内)。 */ FILE_NAME = 4 /**< 分组显示名称(列表行内)。 */
}; };
@@ -349,6 +394,8 @@ namespace jdi
GAIN_ADJUST, /**< 「系数」按钮(点击展开系数二级面板)。 */ GAIN_ADJUST, /**< 「系数」按钮(点击展开系数二级面板)。 */
MIRROR_ADJUST, /**< 「镜像」按钮(点击展开镜像二级面板)。 */ MIRROR_ADJUST, /**< 「镜像」按钮(点击展开镜像二级面板)。 */
ABSOLUTE_TOGGLE, /**< 「绝对值」切换按钮。 */ ABSOLUTE_TOGGLE, /**< 「绝对值」切换按钮。 */
TARGET_NAME, /**< 样式区「目标」行的信号名。 */
TARGET_BADGE, /**< 样式区「目标」行的 GX 网格标签。 */
TOGGLE_ALL, /**< 全部显示/隐藏按钮。 */ TOGGLE_ALL, /**< 全部显示/隐藏按钮。 */
FILTER_CLEAR /**< 「筛选信号」输入框右侧的清空 ✕。 */ FILTER_CLEAR /**< 「筛选信号」输入框右侧的清空 ✕。 */
}; };
@@ -364,6 +411,35 @@ namespace jdi
float height = 0.0f; /**< 行高(像素)。 */ float height = 0.0f; /**< 行高(像素)。 */
}; };
/**
* @brief 计算「把某信号完整滚到可见」所需的滚动偏移。
*
* 顶部避让:当本组组名正在列表顶部悬浮时,列表顶部一段被组名占据,
* 上行方向必须让目标行落在组名的下方才算“完整可见且不被遮挡”。
*
* @param [IN] ref 目标信号。
* @param [OUT] desired 目标滚动偏移(未经 clamp)。
* @return 找到了该行返回 true。
*/
bool SignalScrollTarget(const SignalRef& ref, float& desired) const;
/**
* @brief 重新计算「目标」行内信号名与 GX 标签的矩形。
*
* 名称宽度依赖文本实际宽度、GX 标签是否出现依赖该信号是否已绘制到网格 —— 两者都可能在
* 不重新布局的情况下变化(切换选中、勾选/取消勾选),因此用一个小缓存键避免重算。
*/
void UpdateTargetRowGeometry();
/**
* @brief 绘制「目标」行(样式区最上方:名称 + GX 标签,可悬停 / 双击改名)。
* @param [IN,OUT] g 绘图上下文。
*/
void PaintTargetRow(gd::Graphics& g);
/** @brief 取消正在进行的滚动动画(用户主动滚动时调用)。 */
void CancelScrollAnimation() { m_scrollAnimActive = false; }
/** /**
* @brief 重新构建列表行集合。 * @brief 重新构建列表行集合。
*/ */
@@ -439,6 +515,81 @@ namespace jdi
*/ */
void PaintSignalRow(gd::Graphics& g, int row); void PaintSignalRow(gd::Graphics& g, int row);
/**
* @brief 判断哪个分组行需要「悬浮」在列表顶部(返回行下标,无需悬浮时返回 -1)。
*
* 判据:分组行已被滚出列表上边界(`RowRect(row).Y < m_listRect.Y`),
* 但该组内**仍有行留在列表里**(本组最后一行的下边界尚未越过列表上边界)。
* 两者同时成立时组名必须钉在最上方;整组都滚过去后组名跟随一起消失。
*
* @param [IN] rowCount 行总数。
* @return 需要悬浮的分组行下标;无需悬浮返回 -1。
*/
int StickyGroupRowIndex(int rowCount) const;
/**
* @brief 取某分组行的**最后一行**下标(列表行按分组连续排列)。
* @param [IN] row 分组行下标。
* @param [IN] rowCount 行总数。
* @return 本组最后一行下标(即下一个分组行的上一行)。
*/
int GroupLastRowIndex(int row, int rowCount) const;
/**
* @brief 计算分组行悬浮时应绘制到的**顶部坐标**(含平滑滚出)。
*
* 用户要求(2026-10-15):组名不能突然消失,也不能盖住下一个组名。公式:
* `min(列表上边界, 本组最后一行下边界 − 组名高度)` ⇒
* * 本组还有行在列表里时钉在列表上边界;
* * 本组最后一行开始滑出上边界时,组名跟着一起上移、随滚动条自然离界;
* * 悬浮组名的下边界永远不超过下一个分组行的自然位置 ⇒ 不会遮挡下一组。
*
* @param [IN] row 分组行下标。
* @param [IN] rowCount 行总数。
* @return 悬浮绘制的顶部 Y 坐标。
*/
float StickyGroupRowTop(int row, int rowCount) const;
/**
* @brief 把分组行「按悬浮位置」绘制(组名跟随滚动,直到整组滚过去)。
*
* ⚠️ 必须在常规行循环**之后**调用(它要盖在信号行之上),并且常规循环要**跳过**
* `row` 行,否则该行会被画两次。
*
* @param [IN,OUT] g 绘图上下文。
* @param [IN] row 需要悬浮的分组行下标(< 0 表示什么都不做)。
*/
void PaintStickyGroupRow(gd::Graphics& g, int row);
/**
* @brief 在列表区内绘制行内文本(**显式裁剪到列表矩形**)。
*
* ⚠️ `UiText` 走 GDI `DrawTextW`,**不遵循 GDI+ 的 `SetClip`**:列表需要滚动时,
* 被滚出视口的行(顶部部分可见的两三行)会把组名/信号名画到上方筛选框上。
* 因此行内文本一律经本函数绘制(见 `UiTextClipped`)。
*
* @param [IN,OUT] g 绘图上下文。
* @param [IN] text 文本内容。
* @param [IN] font 字体句柄。
* @param [IN] rect 文本布局矩形。
* @param [IN] argb 文本颜色。
* @param [IN] align 水平对齐方式。
*/
void PaintRowText(gd::Graphics& g, const std::wstring& text, HFONT font, const gd::RectF& rect,
std::uint32_t argb, TextAlign align);
/**
* @brief 在列表区内填充行内矩形(**夹取到列表矩形**)。
*
* ⚠️ `UiFillRect` 对大面积的**不透明**矩形会改走 GDI `FillRect`(同样不受 GDI+ 裁剪约束),
* 高 DPI 下行的面积足以超过该阈值 ⇒ 行背景/选中底色必须先把矩形夹到列表矩形。
*
* @param [IN,OUT] g 绘图上下文。
* @param [IN] rect 目标矩形。
* @param [IN] argb 填充颜色。
*/
void FillRowRect(gd::Graphics& g, const gd::RectF& rect, std::uint32_t argb);
/** /**
* @brief 绘制样式编辑区(固定四行:颜色 / 线宽 / 样式 / 透明度)。 * @brief 绘制样式编辑区(固定四行:颜色 / 线宽 / 样式 / 透明度)。
* @param [IN,OUT] g 绘图上下文。 * @param [IN,OUT] g 绘图上下文。
@@ -649,6 +800,16 @@ namespace jdi
bool m_scrollBarVisible = false; /**< 是否需要显示滚动条。 */ bool m_scrollBarVisible = false; /**< 是否需要显示滚动条。 */
float m_scrollOffset = 0.0f; /**< 列表滚动偏移量。 */ float m_scrollOffset = 0.0f; /**< 列表滚动偏移量。 */
/**
* @brief 「组名悬浮」绘制用的行顶部坐标覆盖。
*
* 置位时 `RowRect` 忽略滚动偏移、直接返回 `m_rowYOverrideY` 作为行顶部,
* 从而使分组行(以及它派生的名称框 / ✕ / 徽标)整体画在钉住的位置。
* 只在使用期间短暂置位,绘制完立即复位(见 `PaintStickyGroupRow`)。
*/
bool m_rowYOverrideActive = false;
float m_rowYOverrideY = 0.0f;
bool m_draggingScrollBar = false; /**< 是否正在拖拽滚动条。 */ bool m_draggingScrollBar = false; /**< 是否正在拖拽滚动条。 */
int m_scrollGrabOffset = 0; /**< 拖拽时鼠标相对滑块顶部的偏移。 */ int m_scrollGrabOffset = 0; /**< 拖拽时鼠标相对滑块顶部的偏移。 */
bool m_draggingOpacity = false; /**< 是否正在拖拽不透明度滑块。 */ bool m_draggingOpacity = false; /**< 是否正在拖拽不透明度滑块。 */
@@ -657,6 +818,32 @@ namespace jdi
RowPart m_hoverPart = RowPart::NONE; /**< 当前悬停的行内区域。 */ RowPart m_hoverPart = RowPart::NONE; /**< 当前悬停的行内区域。 */
StyleHit m_hoverStyle = StyleHit::NONE; /**< 当前悬停的样式条目类型。 */ StyleHit m_hoverStyle = StyleHit::NONE; /**< 当前悬停的样式条目类型。 */
int m_hoverStyleIndex = -1; /**< 当前悬停的样式条目下标。 */ int m_hoverStyleIndex = -1; /**< 当前悬停的样式条目下标。 */
/**
* @brief 供绘图区联动高亮的悬停信号(列表信号行 / 样式区「目标」行)。
*
* 用户 2026-10-15 要求:鼠标悬停在信号列表里的某条信号(或样式区「目标」行的
* 信号名 / GX 标签)上时,若该信号已勾选绘制到某个网格,则该网格变色高亮、
* 且该信号曲线本身也高亮;鼠标移开即恢复。
*
* ⚠️ 不能用 `m_hoverRow` 现算:鼠标移出列表后它被清掉,而悬停可能仍在「目标」行上;
* 且列表行会在数据变化时重建,行下标会失效。
*/
SignalRef m_hoveredSignal;
/** 「目标」行(样式区最上方)的几何。 */
gd::RectF m_targetRowRect;
gd::RectF m_targetNameRect;
gd::RectF m_targetBadgeRect;
/** 「目标」行几何的缓存键(目标信号 + 所在网格 + 名称),避免每次鼠标移动都重算。 */
SignalRef m_targetGeomRef;
int m_targetGeomLane = -2; /**< -2 = 尚未计算过。 */
std::wstring m_targetGeomTitle;
bool m_targetGeomValid = false;
/** 滚动动画状态(见 `ScrollToSignalAnimated` / `AdvanceScrollAnimation`)。 */
bool m_scrollAnimActive = false;
float m_scrollAnimFrom = 0.0f;
float m_scrollAnimTo = 0.0f;
std::uint64_t m_scrollAnimStartMs = 0;
std::uint32_t m_scrollAnimDurationMs = 0;
StyleEditField m_editField = StyleEditField::NONE; /**< 当前处于输入状态的字段。 */ StyleEditField m_editField = StyleEditField::NONE; /**< 当前处于输入状态的字段。 */
int m_editRow = -1; /**< 行内输入所作用的列表行下标。 */ int m_editRow = -1; /**< 行内输入所作用的列表行下标。 */
+591 -37
View File
@@ -96,7 +96,23 @@ namespace jdi
const float kStyleRowGap = 10.0f; /**< 样式区行间距。 */ const float kStyleRowGap = 10.0f; /**< 样式区行间距。 */
const float kStyleLabelWidth = 44.0f; /**< 样式行标签列宽度。 */ const float kStyleLabelWidth = 44.0f; /**< 样式行标签列宽度。 */
const float kStyleLabelGap = 8.0f; /**< 标签列与内容列间距。 */ const float kStyleLabelGap = 8.0f; /**< 标签列与内容列间距。 */
const float kStyleRowCount = 5.0f; /**< 样式区固定行数。 */ const float kStyleRowCount = 5.0f; /**< 样式区固定行数(不含顶部「目标」行)。 */
const float kStyleTargetRowHeight = 26.0f; /**< 样式区顶部「目标」行高度(与其它行一致)。 */
/**
* @brief GX 网格标签与信号名之间的间距(逻辑像素)。
*
* 用户要求:目标信号已绘制到网格时,在信号名**后方**显示它所在的 GX 标签。
*/
const float kTargetBadgeGap = 8.0f;
/**
* @brief 平滑滚动的动画时长(毫秒)。
*
* 距离越远时长越长(但不会无限增长),靠近时由 ease-out 自动变慢 —— 用户要求
* “越远时越快、靠近时变慢”且“整个动画过程不要太久”。
*/
const float kScrollAnimMinMs = 130.0f; /**< 最短时长(短距离)。 */
const float kScrollAnimMaxMs = 300.0f; /**< 最长时长(长距离)。 */
const float kScrollAnimMsPerPixel = 0.35f; /**< 每像素距离增加的时长。 */
const float kStyleContentMax = 260.0f; /**< 内容列最大宽度(再拉宽面板也不再延伸)。 */ const float kStyleContentMax = 260.0f; /**< 内容列最大宽度(再拉宽面板也不再延伸)。 */
const float kSwatchEdge = 18.0f; /**< 颜色色块边长上限(含自定义颜色入口)。 */ const float kSwatchEdge = 18.0f; /**< 颜色色块边长上限(含自定义颜色入口)。 */
const float kSwatchMin = 11.0f; /**< 颜色色块边长下限。 */ const float kSwatchMin = 11.0f; /**< 颜色色块边长下限。 */
@@ -114,8 +130,10 @@ namespace jdi
const int kAdjustAbsolute = 2; /**< 绝对值。 */ const int kAdjustAbsolute = 2; /**< 绝对值。 */
const int kAdjustCount = 3; /**< 按钮数量。 */ const int kAdjustCount = 3; /**< 按钮数量。 */
const float kWidthMin = 0.5f; /**< 允许的最小线宽(px)。 */ // 线宽范围与「稀疏信号默认线宽」共用 `DataModel.hpp` 的常量,避免两处漂移
const float kWidthMax = 10.0f; /**< 允许的最大线宽(px)。 */ //(稀疏信号初始化时用的最大线宽必须与这里滑块的上限完全一致)。
const float kWidthMin = kSignalLineWidthMin; /**< 允许的最小线宽(px)。 */
const float kWidthMax = kSignalLineWidthMax; /**< 允许的最大线宽(px)。 */
/** @brief 系数调整允许的绝对值上限(防止输入极端值把波形完全挤出可视区)。 */ /** @brief 系数调整允许的绝对值上限(防止输入极端值把波形完全挤出可视区)。 */
const double kGainLimit = 10000.0; const double kGainLimit = 10000.0;
@@ -314,8 +332,10 @@ namespace jdi
float SignalPanel::DesiredStyleHeight() const float SignalPanel::DesiredStyleHeight() const
{ {
return kStylePadV * 2.0f + kStyleRowCount * kStyleRowHeight + // ⚠️ 「目标」行也要计入:它是样式区最上方的一行(用户 2026-10-15 要求),
(kStyleRowCount - 1.0f) * kStyleRowGap; // 与其余五行同高、同间距,少算一行会导致列表与样式区重叠。
return kStylePadV * 2.0f + (kStyleRowCount + 1.0f) * kStyleRowHeight +
kStyleRowCount * kStyleRowGap;
} }
bool SignalPanel::TakeLayoutDirty() bool SignalPanel::TakeLayoutDirty()
@@ -368,6 +388,13 @@ namespace jdi
// 样式区的可点击控件(「调整」行三个按钮、线宽/透明度数值框)也用手型光标。 // 样式区的可点击控件(「调整」行三个按钮、线宽/透明度数值框)也用手型光标。
const float fx = static_cast<float>(x); const float fx = static_cast<float>(x);
const float fy = static_cast<float>(y); const float fy = static_cast<float>(y);
// 样式区顶部「目标」行的信号名与 GX 标签:与信号列表行一致,用手型光标提示可交互。
UpdateTargetRowGeometry();
if ((m_targetNameRect.Width > 0.0f && UiPointInRect(m_targetNameRect, fx, fy)) ||
(m_targetBadgeRect.Width > 0.0f && UiPointInRect(m_targetBadgeRect, fx, fy)))
{
return LoadCursorW(nullptr, IDC_HAND);
}
if (UiPointInRect(m_adjustRects[kAdjustGain], fx, fy) || if (UiPointInRect(m_adjustRects[kAdjustGain], fx, fy) ||
UiPointInRect(m_adjustRects[kAdjustMirror], fx, fy) || UiPointInRect(m_adjustRects[kAdjustMirror], fx, fy) ||
UiPointInRect(m_adjustRects[kAdjustAbsolute], fx, fy) || UiPointInRect(m_widthValueRect, fx, fy) || UiPointInRect(m_adjustRects[kAdjustAbsolute], fx, fy) || UiPointInRect(m_widthValueRect, fx, fy) ||
@@ -561,6 +588,15 @@ namespace jdi
return gd::RectF(); return gd::RectF();
} }
// 「组名悬浮」期间把分组行钉在列表顶部绘制(见 `PaintStickyGroupRow`):
// 本函数是行内所有派生的唯一几何来源,因此只需在这里覆盖顶部坐标,
// 名称框 / ✕ / 徽标等位置会自动跟随。
if (m_rowYOverrideActive)
{
return gd::RectF(m_listRect.X, m_rowYOverrideY, m_listRect.Width,
m_rows[static_cast<std::size_t>(row)].height);
}
float top = m_listRect.Y - m_scrollOffset; float top = m_listRect.Y - m_scrollOffset;
for (int index = 0; index < row; ++index) for (int index = 0; index < row; ++index)
{ {
@@ -569,6 +605,100 @@ namespace jdi
return gd::RectF(m_listRect.X, top, m_listRect.Width, m_rows[static_cast<std::size_t>(row)].height); return gd::RectF(m_listRect.X, top, m_listRect.Width, m_rows[static_cast<std::size_t>(row)].height);
} }
int SignalPanel::GroupLastRowIndex(int row, int rowCount) const
{
// 列表行按分组连续排列 ⇒ 本组的最后一行就是下一个分组行之前的那一行。
int last = row;
for (int next = row + 1; next < rowCount; ++next)
{
if (m_rows[static_cast<std::size_t>(next)].kind == RowKind::GROUP)
{
break;
}
last = next;
}
return last;
}
int SignalPanel::StickyGroupRowIndex(int rowCount) const
{
// 「组名悬浮」:分组行被滚出列表上边界、而该组内仍有行留在列表里时,
// 把分组行**钉在列表顶部**,直到该组全部行都滚过去为止。
// 用户要求:组名要一直可见,方便在长列表(例如上百个信号的 MF4)里辨认当前分组。
if (m_rowYOverrideActive || m_listRect.Height <= 0.0f)
{
return -1;
}
for (int row = 0; row < rowCount; ++row)
{
if (m_rows[static_cast<std::size_t>(row)].kind != RowKind::GROUP)
{
continue;
}
if (RowRect(row).Y >= m_listRect.Y)
{
// 分组行本身仍在列表内(或更靠下):无需悬浮。
continue;
}
const int last = GroupLastRowIndex(row, rowCount);
if (RowRect(last).Y + m_rows[static_cast<std::size_t>(last)].height <= m_listRect.Y)
{
// 整组(含最后一行)已全部滚过去:组名跟随一起向上,不再出现。
continue;
}
return row;
}
return -1;
}
float SignalPanel::StickyGroupRowTop(int row, int rowCount) const
{
// 悬浮位置(用户 2026-10-15 要求“平滑过渡”,不要突然消失):
// * 平时钉在列表上边界(`m_listRect.Y`);
// * 当**本组最后一行的下边界**上移越过悬浮组名的下边界时,组名跟着一起上移
// ⇒ 随滚动条自然滚出列表,而不是跳到位置后突然消失。
// 该公式同时保证「悬浮组名的下边界永远不超过本组最后一行的下边界」,
// 而后者就是**下一个分组行的自然位置** —— 因此紧挨着的下一个组名不可能被覆盖
//(旧实现固定钉在 `m_listRect.Y`,会把下一个组名盖住,让它的信号看起来属于上一组)。
const int last = GroupLastRowIndex(row, rowCount);
const float lastBottom =
RowRect(last).Y + m_rows[static_cast<std::size_t>(last)].height;
return std::min(m_listRect.Y, lastBottom - m_rows[static_cast<std::size_t>(row)].height);
}
void SignalPanel::PaintStickyGroupRow(gd::Graphics& g, int row)
{
if (row < 0 || row >= static_cast<int>(m_rows.size()) ||
m_rows[static_cast<std::size_t>(row)].kind != RowKind::GROUP)
{
return;
}
const int rowCount = static_cast<int>(m_rows.size());
const float height = m_rows[static_cast<std::size_t>(row)].height;
const float naturalY = RowRect(row).Y;
const float stickyY = StickyGroupRowTop(row, rowCount);
if (stickyY + height <= m_listRect.Y + 0.5f)
{
return; // 已经整块滑出列表上边界
}
m_rowYOverrideActive = true;
m_rowYOverrideY = stickyY;
// 悬浮行必须是不透明的,否则下面的信号行会与组名叠在一起看不清;
// `FillRowRect` 会把矩形夹到列表矩形(GDI 填充不受 GDI+ 裁剪约束)。
FillRowRect(g, gd::RectF(m_listRect.X, stickyY, m_listRect.Width, height), m_theme->panelBg);
PaintFileRow(g, row);
// 只有真正“浮”在其它行之上时才补一条底部边界线(否则会与正常绘制有差异)。
if (stickyY > naturalY + 0.5f)
{
UiDrawLine(g, m_listRect.X, stickyY + height - 0.5f, m_listRect.X + m_listRect.Width,
stickyY + height - 0.5f, m_theme->border, 1.0f);
}
m_rowYOverrideActive = false;
}
gd::RectF SignalPanel::FileCloseRect(int row) const gd::RectF SignalPanel::FileCloseRect(int row) const
{ {
const gd::RectF rect = RowRect(row); const gd::RectF rect = RowRect(row);
@@ -744,10 +874,16 @@ namespace jdi
const float groupLeft = m_styleRect.X + padH; const float groupLeft = m_styleRect.X + padH;
const float contentLeft = groupLeft + labelWidth + labelGap; const float contentLeft = groupLeft + labelWidth + labelGap;
// ---- 顶部「目标」行:显示当前正在被编辑的目标信号(名称 + GX 标签)----
// 用户 2026-10-15 要求:位于样式自定义区最上方(预设颜色色块之上),
// 显示效果与交互效果与信号选择区域的信号行相同(悬停高亮、双击改名、单键滚动联动)。
m_targetRowRect = gd::RectF(groupLeft, firstTop, groupWidth, kStyleTargetRowHeight * m_scale);
for (int index = 0; index < static_cast<int>(kStyleRowCount); ++index) for (int index = 0; index < static_cast<int>(kStyleRowCount); ++index)
{ {
m_styleRowRects[static_cast<std::size_t>(index)] = m_styleRowRects[static_cast<std::size_t>(index)] =
gd::RectF(groupLeft, firstTop + static_cast<float>(index) * rowStep, groupWidth, rowHeight); gd::RectF(groupLeft, firstTop + static_cast<float>(index + 1) * rowStep, groupWidth,
rowHeight);
} }
const gd::RectF& colorRow = m_styleRowRects[0]; const gd::RectF& colorRow = m_styleRowRects[0];
@@ -834,6 +970,77 @@ namespace jdi
trackWidth, trackHeight); trackWidth, trackHeight);
m_opacityValueRect = gd::RectF(contentLeft + trackWidth + kSliderValueGap * m_scale, opacityRow.Y, m_opacityValueRect = gd::RectF(contentLeft + trackWidth + kSliderValueGap * m_scale, opacityRow.Y,
valueWidth, rowHeight); valueWidth, rowHeight);
// 「目标」行的子几何依赖目标信号的名称与可见性(可能在未重排时变化),
// 因此每次重排后让缓存失效,下一次绘制 / 命中测试时按需重算。
m_targetGeomValid = false;
}
int SignalPanel::TargetLaneIndex() const
{
if (m_tab == nullptr || !SelectedRef().IsValid())
{
return -1;
}
// 未勾选绘制的信号不在任何网格的信号列表里,`LaneOfSignal` 自然返回 -1。
return m_tab->LaneOfSignal(SelectedRef());
}
void SignalPanel::UpdateTargetRowGeometry()
{
if (m_fonts == nullptr || m_dataSet == nullptr)
{
m_targetNameRect = gd::RectF();
m_targetBadgeRect = gd::RectF();
return;
}
const SignalRef& target = SelectedRef();
const SignalData* signal = target.IsValid() ? m_dataSet->Resolve(target) : nullptr;
const std::wstring title = (signal != nullptr) ? signal->Title() : std::wstring();
const int lane = TargetLaneIndex();
// 缓存键:目标信号 + 它所在网格 + 名称(名称宽度与 GX 标签是否出现都由它们决定)。
if (m_targetGeomValid && m_targetGeomRef == target && m_targetGeomLane == lane &&
m_targetGeomTitle == title)
{
return;
}
m_targetGeomRef = target;
m_targetGeomLane = lane;
m_targetGeomTitle = title;
m_targetGeomValid = true;
m_targetNameRect = gd::RectF();
m_targetBadgeRect = gd::RectF();
if (signal == nullptr || m_targetRowRect.Width <= 0.0f)
{
return;
}
const float labelWidth = kStyleLabelWidth * m_scale;
const float labelGap = kStyleLabelGap * m_scale;
const float contentLeft = m_targetRowRect.X + labelWidth + labelGap;
const float contentWidth = std::max(0.0f, m_targetRowRect.Width - labelWidth - labelGap);
const float badgeWidth = kBadgeWidth * m_scale;
const float badgeHeight = kBadgeHeight * m_scale;
const float badgeGap = kTargetBadgeGap * m_scale;
const bool badgeShown = (lane >= 0);
// 名称可用宽度:有 GX 标签时把它排在名称**后面**(用户要求“名称后方”),因此要预留宽度。
const float nameLimit = std::max(0.0f, contentWidth - (badgeShown ? badgeWidth + badgeGap : 0.0f));
const float measured = UiMeasureTextWidth(m_fonts->ui, title);
const float nameWidth = std::min(measured, nameLimit);
// 名称的可交互矩形:整行高,便于悬停(文本较短时也容易命中)。
m_targetNameRect = gd::RectF(contentLeft, m_targetRowRect.Y, nameWidth, m_targetRowRect.Height);
if (badgeShown)
{
m_targetBadgeRect = gd::RectF(contentLeft + nameWidth + badgeGap,
m_targetRowRect.Y + (m_targetRowRect.Height - badgeHeight) * 0.5f,
badgeWidth, badgeHeight);
}
} }
void SignalPanel::Layout(const gd::RectF& area) void SignalPanel::Layout(const gd::RectF& area)
@@ -980,8 +1187,15 @@ namespace jdi
else else
{ {
const int rowCount = static_cast<int>(m_rows.size()); const int rowCount = static_cast<int>(m_rows.size());
// 先定出要「悬浮」的分组行(若有):常规循环跳过它,最后统一钉在列表顶部绘制,
// 否则它会先被画一次在滚动位置上、又被画一次在顶部(视觉上重叠两份)。
const int stickyRow = StickyGroupRowIndex(rowCount);
for (int row = 0; row < rowCount; ++row) for (int row = 0; row < rowCount; ++row)
{ {
if (row == stickyRow)
{
continue;
}
const gd::RectF rect = RowRect(row); const gd::RectF rect = RowRect(row);
if (rect.Y + rect.Height < m_listRect.Y || rect.Y > m_listRect.Y + m_listRect.Height) if (rect.Y + rect.Height < m_listRect.Y || rect.Y > m_listRect.Y + m_listRect.Height)
{ {
@@ -996,6 +1210,8 @@ namespace jdi
PaintSignalRow(g, row); PaintSignalRow(g, row);
} }
} }
PaintStickyGroupRow(g, stickyRow);
} }
g.Restore(saveState); g.Restore(saveState);
@@ -1017,6 +1233,27 @@ namespace jdi
} }
} }
void SignalPanel::PaintRowText(gd::Graphics& g, const std::wstring& text, HFONT font,
const gd::RectF& rect, std::uint32_t argb, TextAlign align)
{
// 行内文本必须显式裁剪到列表矩形:GDI 文本不遵循 GDI+ 的 `SetClip`,
// 否则列表滚动时顶部那些“部分可见”的行会把组名/信号名画到筛选框上。
UiTextClipped(g, text, font, rect, argb, align, m_listRect);
}
void SignalPanel::FillRowRect(gd::Graphics& g, const gd::RectF& rect, std::uint32_t argb)
{
// 夹取而不是裁剪:纯色填充下两者等价,而夹取对 `UiFillRect` 的 GDI 快路径同样有效
//(大面积不透明矩形会改走 GDI `FillRect`,它同样不受 GDI+ 裁剪约束)。
gd::RectF clipped = rect;
clipped.Intersect(m_listRect);
if (clipped.Width <= 0.0f || clipped.Height <= 0.0f)
{
return;
}
UiFillRect(g, clipped, argb);
}
void SignalPanel::PaintFileRow(gd::Graphics& g, int row) void SignalPanel::PaintFileRow(gd::Graphics& g, int row)
{ {
if (m_dataSet == nullptr || m_tab == nullptr || row < 0 || row >= static_cast<int>(m_rows.size())) if (m_dataSet == nullptr || m_tab == nullptr || row < 0 || row >= static_cast<int>(m_rows.size()))
@@ -1035,10 +1272,10 @@ namespace jdi
const bool hovered = (m_hoverRow == row); const bool hovered = (m_hoverRow == row);
if (hovered) if (hovered)
{ {
UiFillRect(g, rect, m_theme->hover); FillRowRect(g, rect, m_theme->hover);
} }
UiFillRect(g, gd::RectF(rect.X, rect.Y, 2.0f * m_scale, rect.Height), FillRowRect(g, gd::RectF(rect.X, rect.Y, 2.0f * m_scale, rect.Height),
WithAlpha(m_theme->borderStrong, 0x90)); WithAlpha(m_theme->borderStrong, 0x90));
const float baseX = rect.X + kPadding * m_scale; const float baseX = rect.X + kPadding * m_scale;
const float centerY = rect.Y + rect.Height * 0.5f; const float centerY = rect.Y + rect.Height * 0.5f;
@@ -1076,14 +1313,14 @@ namespace jdi
std::max(20.0f, statsRight - statsWidth - 6.0f * m_scale - nameAreaLeft); std::max(20.0f, statsRight - statsWidth - 6.0f * m_scale - nameAreaLeft);
// 序号以强调色突出,便于在多组之间快速定位。 // 序号以强调色突出,便于在多组之间快速定位。
UiText(g, groupLabel, m_fonts->uiBold, gd::RectF(nameLeft, rect.Y, groupWidth, rect.Height), PaintRowText(g, groupLabel, m_fonts->uiBold, gd::RectF(nameLeft, rect.Y, groupWidth, rect.Height),
m_theme->accent, TextAlign::LEFT); m_theme->accent, TextAlign::LEFT);
// 竖线分隔符:以绘制方式实现,与文字基线无关,排版更稳。 // 竖线分隔符:以绘制方式实现,与文字基线无关,排版更稳。
UiFillRect(g, gd::RectF(nameLeft + groupWidth + kGroupSeparatorGap * m_scale, FillRowRect(g, gd::RectF(nameLeft + groupWidth + kGroupSeparatorGap * m_scale,
rect.Y + 7.0f * m_scale, kGroupSeparatorWidth * m_scale, rect.Y + 7.0f * m_scale, kGroupSeparatorWidth * m_scale,
rect.Height - 14.0f * m_scale), rect.Height - 14.0f * m_scale),
WithAlpha(m_theme->borderStrong, 0xB0)); WithAlpha(m_theme->borderStrong, 0xB0));
if (m_editField == StyleEditField::FILE_NAME && m_editRow == row) if (m_editField == StyleEditField::FILE_NAME && m_editRow == row)
{ {
@@ -1091,13 +1328,13 @@ namespace jdi
} }
else else
{ {
UiText(g, UiEllipsize(group->Title(), m_fonts->uiBold, nameWidth), m_fonts->uiBold, PaintRowText(g, UiEllipsize(group->Title(), m_fonts->uiBold, nameWidth), m_fonts->uiBold,
gd::RectF(nameAreaLeft, rect.Y, nameWidth, rect.Height), m_theme->textStrong, gd::RectF(nameAreaLeft, rect.Y, nameWidth, rect.Height), m_theme->textStrong,
TextAlign::LEFT); TextAlign::LEFT);
} }
UiText(g, stats, m_fonts->uiSmall, gd::RectF(statsRight - statsWidth, rect.Y, statsWidth, rect.Height), PaintRowText(g, stats, m_fonts->uiSmall, gd::RectF(statsRight - statsWidth, rect.Y, statsWidth, rect.Height),
m_theme->textMuted, TextAlign::RIGHT); m_theme->textMuted, TextAlign::RIGHT);
// 关闭按钮(从工作区移除该文件)。 // 关闭按钮(从工作区移除该文件)。
const bool closeHot = hovered && (m_hoverPart == RowPart::CLOSE); const bool closeHot = hovered && (m_hoverPart == RowPart::CLOSE);
@@ -1130,12 +1367,12 @@ namespace jdi
if (selected) if (selected)
{ {
UiFillRect(g, rect, m_theme->selection); FillRowRect(g, rect, m_theme->selection);
UiFillRect(g, gd::RectF(rect.X, rect.Y, 2.0f * m_scale, rect.Height), m_theme->accent); FillRowRect(g, gd::RectF(rect.X, rect.Y, 2.0f * m_scale, rect.Height), m_theme->accent);
} }
else if (hovered) else if (hovered)
{ {
UiFillRect(g, rect, m_theme->hover); FillRowRect(g, rect, m_theme->hover);
} }
const float baseX = rect.X + kPadding * m_scale + kIndent * m_scale; const float baseX = rect.X + kPadding * m_scale + kIndent * m_scale;
@@ -1177,8 +1414,8 @@ namespace jdi
{ {
const float nameWidth = std::max(16.0f, nameRightLimit - nameLeft); const float nameWidth = std::max(16.0f, nameRightLimit - nameLeft);
const std::uint32_t nameColor = visible ? m_theme->text : m_theme->textMuted; const std::uint32_t nameColor = visible ? m_theme->text : m_theme->textMuted;
UiText(g, UiEllipsize(signal->Title(), m_fonts->ui, nameWidth), m_fonts->ui, PaintRowText(g, UiEllipsize(signal->Title(), m_fonts->ui, nameWidth), m_fonts->ui,
gd::RectF(nameLeft, rect.Y, nameWidth, rect.Height), nameColor, TextAlign::LEFT); gd::RectF(nameLeft, rect.Y, nameWidth, rect.Height), nameColor, TextAlign::LEFT);
} }
if (badgeVisible) if (badgeVisible)
@@ -1188,8 +1425,8 @@ namespace jdi
badgeHot ? m_theme->accentSoft : m_theme->cardBg); badgeHot ? m_theme->accentSoft : m_theme->cardBg);
UiDrawRoundRect(g, badgeRect, kBadgeHeight * m_scale * 0.5f, UiDrawRoundRect(g, badgeRect, kBadgeHeight * m_scale * 0.5f,
badgeHot ? m_theme->accent : m_theme->border, 1.0f); badgeHot ? m_theme->accent : m_theme->border, 1.0f);
UiText(g, L"G" + std::to_wstring(laneIndex + 1), m_fonts->monoSmall, badgeRect, PaintRowText(g, L"G" + std::to_wstring(laneIndex + 1), m_fonts->monoSmall, badgeRect,
badgeHot ? m_theme->textStrong : m_theme->text, TextAlign::CENTER); badgeHot ? m_theme->textStrong : m_theme->text, TextAlign::CENTER);
} }
// 「移除该信号」✕:位于信号条目最右端,尺寸/样式/右边距与分组行的关闭 ✕ 完全一致。 // 「移除该信号」✕:位于信号条目最右端,尺寸/样式/右边距与分组行的关闭 ✕ 完全一致。
@@ -1241,11 +1478,18 @@ namespace jdi
UiFillRect(g, m_styleRect, m_theme->panelBg); UiFillRect(g, m_styleRect, m_theme->panelBg);
// ---- 顶部「目标」行(无论是否已选中信号都要绘制:未选中时显示占位文本)----
PaintTargetRow(g);
const SignalStyle* style = (m_tab == nullptr) ? nullptr : StyleOf(SelectedRef()); const SignalStyle* style = (m_tab == nullptr) ? nullptr : StyleOf(SelectedRef());
if (style == nullptr || SelectedRef().IsValid() == false) if (style == nullptr || SelectedRef().IsValid() == false)
{ {
UiText(g, L"请选择信号以编辑样式", m_fonts->uiSmall, m_styleRect, m_theme->textMuted, // 提示文本只占「目标」行以下的区域,避免与目标行重叠。
TextAlign::CENTER); const float top = m_targetRowRect.Y + m_targetRowRect.Height;
UiText(g, L"请选择信号以编辑样式", m_fonts->uiSmall,
gd::RectF(m_styleRect.X, top, m_styleRect.Width,
std::max(0.0f, m_styleRect.Y + m_styleRect.Height - top)),
m_theme->textMuted, TextAlign::CENTER);
return; return;
} }
@@ -1410,6 +1654,190 @@ namespace jdi
} }
} }
void SignalPanel::PaintTargetRow(gd::Graphics& g)
{
if (m_fonts == nullptr || m_theme == nullptr || m_targetRowRect.Width <= 0.0f)
{
return;
}
UpdateTargetRowGeometry();
const float labelWidth = kStyleLabelWidth * m_scale;
// 行标签与前五行一致(同一标签列宽度、同一纵向居中基准)。
UiText(g, L"目标", m_fonts->uiSmall,
gd::RectF(m_targetRowRect.X, m_targetRowRect.Y, labelWidth, m_targetRowRect.Height),
m_theme->textMuted, TextAlign::LEFT);
const SignalRef& target = SelectedRef();
const SignalData* signal =
(m_dataSet != nullptr && target.IsValid()) ? m_dataSet->Resolve(target) : nullptr;
// 没有可编辑的目标信号:只显示占位提示(与样式区其余行的"请选择信号"提示一致)。
if (signal == nullptr)
{
const float contentLeft = m_targetRowRect.X + labelWidth + kStyleLabelGap * m_scale;
UiText(g, L"未选择信号", m_fonts->uiSmall,
gd::RectF(contentLeft, m_targetRowRect.Y,
std::max(0.0f, m_targetRowRect.X + m_targetRowRect.Width - contentLeft),
m_targetRowRect.Height),
m_theme->textMuted, TextAlign::LEFT);
return;
}
// 名称:悬停时给一层与信号列表行一致的背景高亮(用户要求"显示与交互效果相同")。
if (m_hoverStyle == StyleHit::TARGET_NAME && m_targetNameRect.Width > 0.0f)
{
UiFillRoundRect(g, m_targetNameRect, kCorner * m_scale, m_theme->hover);
}
if (m_editField == StyleEditField::SIGNAL_NAME && m_editRow < 0)
{
UiDrawEditField(g, EditFieldRect(), m_edit, m_fonts->ui, m_scale);
}
else
{
// 颜色与信号列表行一致:已绘制到网格用常态色,未绘制用弱色。
const std::uint32_t nameColor = (m_targetGeomLane >= 0) ? m_theme->text : m_theme->textMuted;
PaintRowText(g, UiEllipsize(signal->Title(), m_fonts->ui, m_targetNameRect.Width),
m_fonts->ui, m_targetNameRect, nameColor, TextAlign::LEFT);
}
// GX 网格标签:信号已绘制到某个网格时显示在名称**后方**(用户 2026-10-15 要求)。
if (m_targetBadgeRect.Width > 0.0f)
{
const bool badgeHot = (m_hoverStyle == StyleHit::TARGET_BADGE);
UiFillRoundRect(g, m_targetBadgeRect, kBadgeHeight * m_scale * 0.5f,
badgeHot ? m_theme->accentSoft : m_theme->cardBg);
UiDrawRoundRect(g, m_targetBadgeRect, kBadgeHeight * m_scale * 0.5f,
badgeHot ? m_theme->accent : m_theme->border, 1.0f);
PaintRowText(g, L"G" + std::to_wstring(m_targetGeomLane + 1), m_fonts->monoSmall,
m_targetBadgeRect, badgeHot ? m_theme->textStrong : m_theme->text,
TextAlign::CENTER);
}
}
bool SignalPanel::SignalScrollTarget(const SignalRef& ref, float& desired) const
{
desired = m_scrollOffset;
if (!ref.IsValid() || m_rows.empty() || m_listRect.Height <= 0.0f)
{
return false;
}
float top = 0.0f;
float rowTop = -1.0f;
float rowHeight = 0.0f;
int rowIndex = -1;
for (std::size_t index = 0; index < m_rows.size(); ++index)
{
const ListRow& row = m_rows[index];
if (row.kind == RowKind::SIGNAL && row.groupIndex == ref.groupIndex &&
row.signalIndex == ref.signalIndex)
{
rowTop = top;
rowHeight = row.height;
rowIndex = static_cast<int>(index);
break;
}
top += row.height;
}
if (rowTop < 0.0f)
{
return false;
}
// ⚠️ **顶部避让悬浮组名**(用户明确要求"完全出现时不会被浮动组名遮挡"):
// 上行方向必须让目标行落在悬浮组名的下方。目前悬浮的组名只可能是目标信号**自己那个**
// 分组:分组行在其信号行之上、各分组连续排列,若目标行完整可见,则更靠下的分组行
// 不可能同时位于列表顶部(参见 `StickyGroupRowTop` 的公式)。
float topInset = 0.0f;
for (int index = rowIndex; index >= 0; --index)
{
if (m_rows[static_cast<std::size_t>(index)].kind == RowKind::GROUP)
{
topInset = m_rows[static_cast<std::size_t>(index)].height;
break;
}
}
const float visibleTop = m_scrollOffset + topInset;
const float visibleBottom = m_scrollOffset + m_listRect.Height;
if (rowTop < visibleTop)
{
desired = rowTop - topInset; // 向上滚动,直到它完整出现在组名下方
}
else if (rowTop + rowHeight > visibleBottom)
{
desired = rowTop + rowHeight - m_listRect.Height; // 向下滚动,直到完整出现
}
else
{
desired = m_scrollOffset; // 已经完整可见:不动
}
desired = std::max(0.0f, std::min(desired, std::max(0.0f, m_contentHeight - m_listRect.Height)));
return true;
}
void SignalPanel::ScrollToSignalAnimated(const SignalRef& ref)
{
EnsureRows();
float desired = m_scrollOffset;
if (!SignalScrollTarget(ref, desired))
{
return;
}
const float distance = std::fabs(desired - m_scrollOffset);
if (distance < 1.0f)
{
// 已经到位:不需要动画(但仍然要把残余的亚像素差抹平)。
m_scrollOffset = desired;
ClampScroll();
m_scrollAnimActive = false;
return;
}
// 时长随距离增长(越远越快 = 同样时间内走更多距离),到上限后不再增长,
// 因此"整个动画过程不会太久"(最长 300ms)。
const float durationMs =
std::max(kScrollAnimMinMs, std::min(kScrollAnimMaxMs, distance * kScrollAnimMsPerPixel));
m_scrollAnimFrom = m_scrollOffset;
m_scrollAnimTo = desired;
m_scrollAnimStartMs = GetTickCount64();
m_scrollAnimDurationMs = static_cast<std::uint32_t>(durationMs + 0.5f);
m_scrollAnimActive = true;
}
bool SignalPanel::AdvanceScrollAnimation()
{
if (!m_scrollAnimActive)
{
return false;
}
const std::uint64_t now = GetTickCount64();
const std::uint64_t elapsed = (now >= m_scrollAnimStartMs) ? (now - m_scrollAnimStartMs) : 0;
float progress = (m_scrollAnimDurationMs > 0u)
? static_cast<float>(elapsed) / static_cast<float>(m_scrollAnimDurationMs)
: 1.0f;
if (progress > 1.0f)
{
progress = 1.0f;
}
// ease-out cubic:起步快、靠近目标时变慢(用户要求"越远时越快,靠近时变慢")。
const float eased = 1.0f - std::pow(1.0f - progress, 3.0f);
m_scrollOffset = m_scrollAnimFrom + (m_scrollAnimTo - m_scrollAnimFrom) * eased;
if (progress >= 1.0f)
{
m_scrollOffset = m_scrollAnimTo;
m_scrollAnimActive = false;
ClampScroll();
return false;
}
return true;
}
bool SignalPanel::AreAllSignalsVisible() const bool SignalPanel::AreAllSignalsVisible() const
{ {
if (m_dataSet == nullptr || m_tab == nullptr || m_dataSet->Empty()) if (m_dataSet == nullptr || m_tab == nullptr || m_dataSet->Empty())
@@ -1529,7 +1957,18 @@ namespace jdi
} }
case StyleEditField::SIGNAL_NAME: case StyleEditField::SIGNAL_NAME:
{ {
if (row < 0 || row >= static_cast<int>(m_rows.size())) // `row < 0`:样式区顶部「目标」行 —— 编辑对象就是当前选中的(目标)信号。
if (row < 0)
{
const SignalData* signal = SelectedRef().IsValid() ? m_dataSet->Resolve(SelectedRef()) : nullptr;
if (signal == nullptr)
{
return false;
}
initial = signal->Title();
break;
}
if (row >= static_cast<int>(m_rows.size()))
{ {
return false; return false;
} }
@@ -1563,6 +2002,12 @@ namespace jdi
m_editField = field; m_editField = field;
m_editRow = row; m_editRow = row;
if (field == StyleEditField::SIGNAL_NAME && row < 0)
{
// 「目标」行的输入框与行内名称矩形同源,必须先按当前目标算好几何。
UpdateTargetRowGeometry();
}
// 名称类字段可输入任意字符,数值类字段限制为数字与小数点。 // 名称类字段可输入任意字符,数值类字段限制为数字与小数点。
const bool nameField = const bool nameField =
(field == StyleEditField::SIGNAL_NAME) || (field == StyleEditField::FILE_NAME); (field == StyleEditField::SIGNAL_NAME) || (field == StyleEditField::FILE_NAME);
@@ -1633,7 +2078,23 @@ namespace jdi
} }
case StyleEditField::SIGNAL_NAME: case StyleEditField::SIGNAL_NAME:
{ {
if (row < 0 || row >= static_cast<int>(m_rows.size()) || m_dataSetWritable == nullptr) if (m_dataSetWritable == nullptr)
{
break;
}
if (row < 0)
{
// 样式区「目标」行:重命名当前选中的(目标)信号。
const SignalRef& target = SelectedRef();
if (target.IsValid())
{
m_dataSetWritable->SetSignalDisplayName(target.groupIndex, target.signalIndex, trimmed);
}
// 名称变化会改变「目标」行的文本宽度 ⇒ 几何缓存作废。
m_targetGeomValid = false;
break;
}
if (row >= static_cast<int>(m_rows.size()))
{ {
break; break;
} }
@@ -1687,6 +2148,15 @@ namespace jdi
return m_opacityValueRect; return m_opacityValueRect;
case StyleEditField::SIGNAL_NAME: case StyleEditField::SIGNAL_NAME:
case StyleEditField::FILE_NAME: case StyleEditField::FILE_NAME:
// `row < 0` = 样式区「目标」行:输入框用内容列全宽(名称本身很短时也能舒适地编辑)。
if (m_editRow < 0)
{
const float labelWidth = kStyleLabelWidth * m_scale;
const float labelGap = kStyleLabelGap * m_scale;
const float left = m_targetRowRect.X + labelWidth + labelGap;
const float width = std::max(24.0f, m_targetRowRect.X + m_targetRowRect.Width - left);
return gd::RectF(left, m_targetRowRect.Y, width, m_targetRowRect.Height);
}
return RowNameRect(m_editRow); return RowNameRect(m_editRow);
case StyleEditField::NONE: case StyleEditField::NONE:
default: default:
@@ -2201,6 +2671,9 @@ namespace jdi
if (m_draggingScrollBar) if (m_draggingScrollBar)
{ {
const gd::RectF thumb = ScrollThumbRect(); const gd::RectF thumb = ScrollThumbRect();
// ⚠️ 拖拽期间滑块高度不会变(内容高度与列表高度都没变),因此 `thumb.Height` 可安全
// 用于本次换算;但 `m_scrollBarRect.Y` 必须取**按下时**记录的值(局部刷新/重排可能
// 改掉它)。坐标一律用**客户区** y,因此鼠标移到面板外(如绘图区)也能继续跟随。
const float travel = static_cast<float>(y - m_scrollGrabOffset) - m_scrollBarRect.Y; const float travel = static_cast<float>(y - m_scrollGrabOffset) - m_scrollBarRect.Y;
const float usable = m_scrollBarRect.Height - thumb.Height; const float usable = m_scrollBarRect.Height - thumb.Height;
if (usable > 0.0f) if (usable > 0.0f)
@@ -2232,7 +2705,21 @@ namespace jdi
} }
else if (m_tab != nullptr && StyleOf(SelectedRef()) != nullptr) else if (m_tab != nullptr && StyleOf(SelectedRef()) != nullptr)
{ {
for (int index = 0; index < m_colorCount; ++index) // 「目标」行的信号名 / GX 标签(优先于其余样式控件:它们几何不重叠,
// 但显式放在最前更易读)。
UpdateTargetRowGeometry();
if (UiPointInRect(m_targetRowRect, fx, fy))
{
if (m_targetBadgeRect.Width > 0.0f && UiPointInRect(m_targetBadgeRect, fx, fy))
{
hoverStyle = StyleHit::TARGET_BADGE;
}
else if (m_targetNameRect.Width > 0.0f && UiPointInRect(m_targetNameRect, fx, fy))
{
hoverStyle = StyleHit::TARGET_NAME;
}
}
for (int index = 0; hoverStyle == StyleHit::NONE && index < m_colorCount; ++index)
{ {
if (UiPointInRect(m_colorRects[static_cast<std::size_t>(index)], fx, fy)) if (UiPointInRect(m_colorRects[static_cast<std::size_t>(index)], fx, fy))
{ {
@@ -2299,6 +2786,25 @@ namespace jdi
m_hoverPart = part; m_hoverPart = part;
m_hoverStyle = hoverStyle; m_hoverStyle = hoverStyle;
m_hoverStyleIndex = hoverStyleIndex; m_hoverStyleIndex = hoverStyleIndex;
// 供绘图区联动高亮的悬停信号(用户 2026-10-15 要求):
// * 列表里悬停某条信号行 → 高亮它所在的网格 + 该信号的曲线;
// * 样式区「目标」行的信号名 / GX 标签上悬停 → 同样联动高亮。
// ⚠️ ✕(移除该信号)不算"悬停到该信号":它是一条即将被删除的信号,
// 高亮它会让人误以为要保留它。
SignalRef hovered;
if (hoverRow >= 0 && hoverRow < static_cast<int>(m_rows.size()) &&
m_rows[static_cast<std::size_t>(hoverRow)].kind == RowKind::SIGNAL &&
part != RowPart::REMOVE_SIGNAL)
{
hovered = SignalRef{m_rows[static_cast<std::size_t>(hoverRow)].groupIndex,
m_rows[static_cast<std::size_t>(hoverRow)].signalIndex};
}
else if (hoverStyle == StyleHit::TARGET_NAME || hoverStyle == StyleHit::TARGET_BADGE)
{
hovered = SelectedRef();
}
m_hoveredSignal = hovered;
return true; return true;
} }
@@ -2339,6 +2845,7 @@ namespace jdi
// 滚动条拖拽优先于其它命中测试。 // 滚动条拖拽优先于其它命中测试。
if (m_scrollBarVisible && UiPointInRect(m_scrollBarRect, fx, fy)) if (m_scrollBarVisible && UiPointInRect(m_scrollBarRect, fx, fy))
{ {
CancelScrollAnimation();
const gd::RectF thumb = ScrollThumbRect(); const gd::RectF thumb = ScrollThumbRect();
m_draggingScrollBar = true; m_draggingScrollBar = true;
if (UiPointInRect(thumb, fx, fy)) if (UiPointInRect(thumb, fx, fy))
@@ -2383,6 +2890,22 @@ namespace jdi
// 样式编辑区。 // 样式编辑区。
if (SignalStyle* style = StyleOf(SelectedRef())) if (SignalStyle* style = StyleOf(SelectedRef()))
{ {
// ---- 顶层「目标」行:单击**不进入编辑**(用户明确要求),只做联动滚动 ----
// 双击改名由 `OnDoubleClick` 处理;这里单击即把列表滚动到目标信号,
// 便于在长列表(上百条信号的 MF4)里定位正在编辑的那条信号。
{
UpdateTargetRowGeometry();
const bool onName =
m_targetNameRect.Width > 0.0f && UiPointInRect(m_targetNameRect, fx, fy);
const bool onBadge =
m_targetBadgeRect.Width > 0.0f && UiPointInRect(m_targetBadgeRect, fx, fy);
if (onName || onBadge)
{
ScrollToSignalAnimated(SelectedRef());
return true;
}
}
int paletteCount = 0; int paletteCount = 0;
const std::uint32_t* palette = SignalPalette(paletteCount); const std::uint32_t* palette = SignalPalette(paletteCount);
for (int index = 0; index < m_colorCount; ++index) for (int index = 0; index < m_colorCount; ++index)
@@ -2390,6 +2913,8 @@ namespace jdi
if (UiPointInRect(m_colorRects[static_cast<std::size_t>(index)], fx, fy)) if (UiPointInRect(m_colorRects[static_cast<std::size_t>(index)], fx, fy))
{ {
style->color = palette[index % paletteCount]; style->color = palette[index % paletteCount];
// 联动:样式自定义区的任何交互都把列表滚动到正在编辑的目标信号。
ScrollToSignalAnimated(SelectedRef());
return true; return true;
} }
} }
@@ -2398,6 +2923,7 @@ namespace jdi
// 先恢复到记忆的自定义颜色(即便随后收起浮窗也会生效),再在抬起时展开浮窗。 // 先恢复到记忆的自定义颜色(即便随后收起浮窗也会生效),再在抬起时展开浮窗。
style->color = style->customColor; style->color = style->customColor;
m_pendingColorPicker = true; m_pendingColorPicker = true;
ScrollToSignalAnimated(SelectedRef());
return true; return true;
} }
for (int index = 0; index < kModeOptionCount; ++index) for (int index = 0; index < kModeOptionCount; ++index)
@@ -2405,6 +2931,7 @@ namespace jdi
if (UiPointInRect(m_modeRects[static_cast<std::size_t>(index)], fx, fy)) if (UiPointInRect(m_modeRects[static_cast<std::size_t>(index)], fx, fy))
{ {
style->mode = kModeOptions[index]; style->mode = kModeOptions[index];
ScrollToSignalAnimated(SelectedRef());
return true; return true;
} }
} }
@@ -2412,21 +2939,25 @@ namespace jdi
{ {
m_draggingOpacity = true; m_draggingOpacity = true;
ApplyOpacityFromX(fx); ApplyOpacityFromX(fx);
ScrollToSignalAnimated(SelectedRef());
return true; return true;
} }
if (UiPointInRect(SliderHitRect(m_widthTrackRect, m_scale), fx, fy)) if (UiPointInRect(SliderHitRect(m_widthTrackRect, m_scale), fx, fy))
{ {
m_draggingWidth = true; m_draggingWidth = true;
ApplyWidthFromX(fx); ApplyWidthFromX(fx);
ScrollToSignalAnimated(SelectedRef());
return true; return true;
} }
// 数值框的命中优先于滑块:两者几何重叠时要能进入输入状态。 // 数值框的命中优先于滑块:两者几何重叠时要能进入输入状态。
if (UiPointInRect(m_widthValueRect, fx, fy)) if (UiPointInRect(m_widthValueRect, fx, fy))
{ {
ScrollToSignalAnimated(SelectedRef());
return BeginEdit(StyleEditField::WIDTH); return BeginEdit(StyleEditField::WIDTH);
} }
if (UiPointInRect(m_opacityValueRect, fx, fy)) if (UiPointInRect(m_opacityValueRect, fx, fy))
{ {
ScrollToSignalAnimated(SelectedRef());
return BeginEdit(StyleEditField::OPACITY); return BeginEdit(StyleEditField::OPACITY);
} }
// 「调整」行:系数与镜像(按下只登记,**抬起**时才展开各自的二级面板, // 「调整」行:系数与镜像(按下只登记,**抬起**时才展开各自的二级面板,
@@ -2434,16 +2965,19 @@ namespace jdi
if (UiPointInRect(m_adjustRects[kAdjustGain], fx, fy)) if (UiPointInRect(m_adjustRects[kAdjustGain], fx, fy))
{ {
m_pendingGainMenu = true; m_pendingGainMenu = true;
ScrollToSignalAnimated(SelectedRef());
return true; return true;
} }
if (UiPointInRect(m_adjustRects[kAdjustMirror], fx, fy)) if (UiPointInRect(m_adjustRects[kAdjustMirror], fx, fy))
{ {
m_pendingMirrorMenu = true; m_pendingMirrorMenu = true;
ScrollToSignalAnimated(SelectedRef());
return true; return true;
} }
if (UiPointInRect(m_adjustRects[kAdjustAbsolute], fx, fy)) if (UiPointInRect(m_adjustRects[kAdjustAbsolute], fx, fy))
{ {
style->absolute = !style->absolute; style->absolute = !style->absolute;
ScrollToSignalAnimated(SelectedRef());
return true; return true;
} }
} }
@@ -2650,6 +3184,23 @@ namespace jdi
return BeginEdit(StyleEditField::OPACITY); return BeginEdit(StyleEditField::OPACITY);
} }
// 样式区顶部「目标」行:双击信号名(或它后面的 GX 标签)进入**改名**编辑态。
// ⚠️ 单击不进入编辑(见 `OnLButtonDown`),必须双击 —— 用户明确要求。
if (m_tab != nullptr && StyleOf(SelectedRef()) != nullptr)
{
UpdateTargetRowGeometry();
const bool onName =
m_targetNameRect.Width > 0.0f && UiPointInRect(m_targetNameRect, fx, fy);
const bool onBadge =
m_targetBadgeRect.Width > 0.0f && UiPointInRect(m_targetBadgeRect, fx, fy);
if (onName || onBadge)
{
// `row < 0` 表示编辑目标是"当前选中信号"(即目标行显示的信号)。
m_rowsDirty = true;
return BeginEdit(StyleEditField::SIGNAL_NAME, -1);
}
}
RowPart part = RowPart::NONE; RowPart part = RowPart::NONE;
const int row = HitRow(x, y, part); const int row = HitRow(x, y, part);
if (row < 0) if (row < 0)
@@ -2718,6 +3269,9 @@ namespace jdi
bool SignalPanel::OnMouseWheel(int delta) bool SignalPanel::OnMouseWheel(int delta)
{ {
// 用户主动滚动 ⇒ 立即取消正在进行的目标信号滚动动画(否则两者会互相打架)。
CancelScrollAnimation();
if (m_dataSet == nullptr) if (m_dataSet == nullptr)
{ {
return false; return false;
@@ -2731,15 +3285,15 @@ namespace jdi
bool SignalPanel::OnMouseLeave() bool SignalPanel::OnMouseLeave()
{ {
if (m_hoverRow < 0 && m_hoverStyle == StyleHit::NONE) const bool changed =
{ (m_hoverRow >= 0) || (m_hoverStyle != StyleHit::NONE) || m_hoveredSignal.IsValid();
return false;
}
m_hoverRow = -1; m_hoverRow = -1;
m_hoverPart = RowPart::NONE; m_hoverPart = RowPart::NONE;
m_hoverStyle = StyleHit::NONE; m_hoverStyle = StyleHit::NONE;
m_hoverStyleIndex = -1; m_hoverStyleIndex = -1;
return true; // 鼠标离开面板 → 取消绘图区的联动高亮(用户要求"移开鼠标时恢复")。
m_hoveredSignal = SignalRef();
return changed;
} }
void SignalPanel::OpenSignalMenu(const SignalRef& ref, int x, int y) void SignalPanel::OpenSignalMenu(const SignalRef& ref, int x, int y)
+633
View File
@@ -1453,6 +1453,495 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸(
--- ---
### 9.28 信号列表滚动越界绘制 + 边沿统计面板「显示冻结」(本轮,起源设备)
> 用户报告两项:① MF4 文件信号过多、列表需要滚动时,**组名/信号名被画到上方「筛选信号」框上**;
> ② 改完某个信号的中心轴滤波参数并确认后,**全程不移动鼠标**时两个边沿计数浮动面板的统计数字
> 一直不刷新,移动鼠标才显示正确的最终结果。两项都已定位根因并修复;
> 离屏验证 `[A]/[B]/[C]` **39/39 通过**,并做了 A/B(回退修复后两项均如实失败)。
#### 一、列表滚动把文字画到筛选框上(`SignalPanel` + `PaintUtil`)
- **根因**:`UiText` 走 GDI `DrawTextW`,而 **GDI 文本不遵循 GDI+ 的 `Graphics::SetClip`**。
列表虽然用 `g.SetClip(m_listRect)` 裁剪,但被滚出视口的「部分可见」行(尤其是分组行与信号名)
仍会把文字画到列表上方的筛选框区域。`UiFillRect` 对大面积**不透明**矩形走 GDI `FillRect`,同理越界。
- **修法**:`PaintUtil` 新增 **`UiTextClipped(g, text, font, rect, argb, align, clipRect)`**
(在同一个 HDC 上 `SaveDC` + `IntersectClipRect` 并完整还原;`UiText` 重构为转发 `clip = nullptr`,
行为与旧实现逐位一致);`SignalPanel` 新增 `PaintRowText()`(裁剪到 `m_listRect`)与
`FillRowRect()`(夹取到 `m_listRect`),**分组行与信号行的全部文本、行底色/选中底色都改走它们**。
- ⚠️ **新硬约束**:滚动/裁剪类容器内的 GDI 文本必须**显式**裁剪,不能依赖 `g.SetClip`。
#### 二、边沿统计面板在「鼠标不动」时显示冻结(`PlotView::PanelSlotKey`)
- **根因**:浮动面板有**像素图层缓存**(`m_panelDc` + `m_panelSlots` + `PanelSlotKey`)。
界面上显示的「上升沿 / 下降沿」是**分帧推进**出来的中间结果,而 `PanelSlotKey` 里**没有**这些数字
⇒ 「统计推进 → 数字变化」不会让键变化 ⇒ 面板一直贴旧像素 ⇒ 显示停在「统计中…」或某个中间值;
**只有移动鼠标**(改变面板层序 / 悬停 / 鼠标坐标等其它键)才会重绘并显示出早已算完的正确结果。
注意:**扫描推进本身没有停**(`EdgePanelWidth` 每帧都会被调,从而调 `EdgeScanFor` 并自持请求下一帧),
停住的只有**显示**。
- **修法**:`PanelSlotKey` 内按面板实际显示的条目回调 `EdgeCountsFor`(COUNT 面板)/
`EdgeCenterCountsFor`(CENTER 面板),把 `(rising, falling, complete)` 一并 mix 进键
⇒ 推进即让键变化 ⇒ 面板重绘 ⇒ 显示随进度更新;扫完后数字稳定,重新命中缓存(不再每帧重画)。
#### 三、验证(离屏 `.preflight/check`,`SW_HIDE` + 不注入真实输入 + 进程/线程 `BELOW_NORMAL`)
- **[A]** 40 信号同一网格 + 分组展开 ⇒ 列表可滚动;滚轮下滚后断言
「列表上边界**以上**区域逐像素 **0 变化**」(修复前 **1571 像素被污染**),并断言
「列表**内部**像素仍有变化」(证明是“裁剪”而不是“整块不画”)。
- **[B]** 3 信号 × 300 万采样同一网格、同时开启两种边沿计数:断言面板**像素图层缓存键在扫描期间
持续变化** + 面板像素确实刷新(修复前键**不变化** = 显示冻结),并在**不注入任何鼠标消息**的
前提下最终 `complete`(统计值与参考实现一致)。
⚠️ 构造要点:信号名要**足够长**(使面板宽度由固定文字决定)且穿越次数少,否则「数字位数变化」
本身就会改变面板矩形 ⇒ 缓存键也跟着变 ⇒ 该用例在**修复前也会“通过”**(失去判别力)。
⚠️ 校验最终值要**直接读累积状态**(`m_edgeScans`),不要用不同阈值去调 `EdgeCountsFor` —— 它会
把状态**就地重置**,反而丢弃已扫完的结果。
- **[C]** 真实 `TestData/MF4-Record.MF4`(**124 信号 / 3 条时间轴**):同样的滚动断言,0 像素变化
(直接复现用户报告的文件场景)。
- **A/B 验证**:把本轮修复 `git stash` 掉重跑 ⇒ `[A]`(1571 px)与 `[B]`(两个面板缓存键均不推进)
**如实失败**;恢复后 **39/39 通过**(连跑两次稳定);`make release` 零警告。
---
### 9.29 全局 Y 轴自适应跟随网格结构 + 组名悬浮 + 稀疏信号默认点线 + 单点光标吸附 + 放大自动点线(本轮,起源设备)
> 本轮六项(用户 2026-10-15 报告):① 全局「Y 轴自适应」开启时,新勾选信号产生的新网格没有按当前视界自适应;
> ② 全局开启时,多信号网格内取消显示某信号 / 拆分网格后需要重新做一次全局自适应;
> ③ 信号列表滚动时组名要**悬浮**在最上方,直到该组信号全部翻过去;
> ④ 「少数数据点」信号默认用「点线」+ 2px,只有一个点时用最大线宽(10px);
> ⑤ 单点信号的光标吸附(吸附而非卡住);⑥ 放大到采样点稀疏时自动启用点线、退出阈值恢复原样式。
> 离屏验证 **65/65 通过**(连跑 3 次稳定),并做了 A/B(临时禁用六个机制后 **21 项如实失败**)。
#### 一、全局 Y 轴自适应要跟住网格结构
- **根因**:`RebuildLanes`(`DataModel.cpp`)对**新出现的**信号返回默认 `LaneCarry()` ⇒
`fitYToSelection = false`,于是全局开关看着是开的、新网格却不自适应。
- **修法 1(继承)**:重建前先算 `previousAllAutoFit = 非空且每个网格都开着 fitYToSelection`
(这就是「全局 Y 轴自适应」的正式定义),`LaneCarry` 加 `matched` 标记,
`applyCarry` 对新信号用 `previousAllAutoFit` 作为默认值。
- **修法 2(立即算一次)**:新增 `PlotView::ReapplyGlobalAutoFitIfEnabled()`
(判据 = `AllLanesAutoFitToSelection()`,与工具栏按钮同源),在 **`Layout()`**
(网格数量变化走 `Relayout`)、**`RefreshLanes()`**(信号数量变化)、**`SplitLane()`** 之后调用。
⇒ 新勾选信号(按当前视界自适应)、取消显示信号(剩余信号重新收敛)、拆分(每份各自自适应)三项都覆盖。
- ⚠️ **新硬约束**:凡「与网格集合相关」的自动行为,必须同时覆盖 `Layout` 与 `RefreshLanes`
两条路径 —— `Application::SynchronizeView` 是**按“网格数量是否变化”**二选一的
(变化 → `Relayout`→`Layout`;不变 → `RefreshLanes`),只挂一处必然漏掉一半场景。
⚠️ 这两个钩子都**不能**放进 `BuildLaneVisuals()`:它在 `Paint` 里也会被调用(配合 `m_renderMutex`),
在那里自动改样式/自动算 Y 会污染渲染路径。
#### 二、组名悬浮(`SignalPanel`)
- 新增 `StickyGroupRowIndex(rowCount)`(纯计算)+ `PaintStickyGroupRow(g, row)`(钉在列表顶部绘制:
不透明 `panelBg` 底 + 底部一条 `border` 线),`RowRect` 增加 `m_rowYOverrideActive/m_rowYOverrideY`
覆盖行顶部坐标 —— `RowRect` 是行内所有几何(名称框 / ✕ / 徽标)的唯一来源,因此只需覆盖它。
- 判据:分组行 `RowRect(row).Y < m_listRect.Y` **且**本组最后一行的下边界 `> m_listRect.Y`。
整组滚过去后本组组名消失;若下一组有信号可见,则改由下一组悬浮。
- ⚠️ 常规行循环必须**跳过**被钉住的那一行(否则同一行会被画两次:一次在滚动位置、一次在顶部)。
#### 三、稀疏信号的默认样式(`DataModel`)
- 新增常量(`DataModel.hpp`):`kSignalLineWidthMin/Max = 0.5/10`(**SignalPanel 的线宽滑块改为引用它们**,
避免“最大线宽”两处漂移)、`kSparseSignalSampleLimit = 16`、`kSparseSignalLineWidth = 2`、
`kSinglePointSignalLineWidth = kSignalLineWidthMax`。
- 新增 `ApplySparseDefaultStyle(finiteCount, style)`:1 个有效点 → 点线 + 10px;2..16 → 点线 + 2px。
在 `BuildSignalGroup` 与 `InsertDerivedSignal` **两处**调用(只在新建信号时生效,不覆盖用户设置)。
- `ComputeStats` 新增输出 `firstFiniteIndex`;`SignalData` 新增 **`singlePointTime`**
(单点信号的那个时刻,加载时填好)—— 供光标吸附使用,避免在鼠标移动时线性扫描采样。
#### 四、单点信号的光标吸附(`PlotView::SnapTimeToSample`)
- 旧逻辑对「只有一个采样点」的信号无能为力:`SampleSpacingPixels` 要求 ≥2 个采样 ⇒ 永远不吸附
(用户报告“无法准确指向数据点”)。
- 新增半径分支:`signal.singlePointTime` 在视界内且与光标的距离 ≤ `kCursorSnapPointRadiusPixels`
(8 逻辑像素)时吸附过去;半径外**不吸附** ⇒ 自由移动。
⚠️ 这是「吸附」而不是「卡住」:移开半径后光标立刻自由(用户明确要求只是提示性响应)。
#### 五、放大到稀疏时自动启用点线(`SignalStyle` + `PlotView`)
- `SignalStyle` 新增运行态三字段:`autoScatterActive` / `autoScatterSavedMode` / `autoScatterUserOverride`。
- `PlotView::AxisSparseInView(axis)`:采样点间距 > 半个绘图区宽度(与光标吸附上门限**同源**)。
⚠️ **单点时间轴不算稀疏**(它一直最稀疏,默认样式已是点线;若算进去,用户手动改成线段会被立刻改回)。
- `UpdateAutoScatterStyles()`:进入阈值 → 保存原 mode + 切「点线」;退出阈值 → 恢复。
⚠️⚠️ **必须在恢复判断之前先做“用户是否手动改过”的判定**(本机制只写点线,所以自动期间
`mode != LINE_SCATTER` 只能是用户改的):用户改完样式可能**紧接着就缩出去**(例如直接点适应视图),
那次调用的视界已经不稀疏;若等到那时才判定,恢复逻辑会先把用户的修改覆盖掉。
- 钩子:**`SetTimeView`**(视界唯一写入口 ⇒ 缩放/平移/适应视图全覆盖;
平移不改变缩放等级 ⇒ 结果稳定、不会样式抖动)以及 `Layout` / `RefreshLanes` / `SplitLane`。
#### 六、验证(离屏 `.preflight/check`,`SW_HIDE` + 不注入真实输入 + 进程/线程 `BELOW_NORMAL`)
- **65/65 通过**(连跑 3 次稳定):`[A1]` 新网格继承开关且按**当前视界**自适应、`[A2]` 取消勾选后重算、
`[A3]` 拆分后每份各自重算、`[B]` 组名悬浮(含“钉住行与自然位置逐像素一致”与“整组翻过后组名消失”)、
`[C]` 稀疏默认样式(1/2/5/16/17/1000 点边界)、`[D]` 单点吸附(半径内吸附、半径外自由、无残留粘滞)、
`[E]` 自动点线(进入/退出/平移稳定/用户手动改过后不覆盖/再次进入再启用)。
- **A/B**:临时禁用六个机制后重跑 ⇒ **21 项如实失败**(覆盖全部六项),证明断言确有判别力。
- ⚠️ 两个测试手法坑:① 用例之间必须**清空分组**(否则行号/网格号不确定);
② 抓取「列表顶部像素带」时必须**排除右侧滚动条**(滑块位置随滚动变化,实测会混入 54px 假差异)。
---
### 9.30 组名平滑悬浮 + 单点信号初始视图 + 光标吸附统一判定(含自动点线同源)(本轮,起源设备)
> 本轮四项(用户 2026-10-15 第二轮报告):① 组名消失太突兀、且悬浮组名会盖住下一个组名
>(让人误以为后面的信号属于上一组);② 只有一个数据点的信号“全局自适应不生效”、
> 初始视图看不出它的状态;③ 光标吸附体验差且经常错误吸附,改用“一屏数据点 < N×4”的统一判定;
> ④ 放大后自动切点线没按预期工作,改为**与吸附判定完全同源**。
> 离屏验证 **55/55 通过**(连跑 3 次稳定),A/B 禁用三项机制后 **14 项如实失败**。
#### 一、组名悬浮:平滑滚出 + 不遮挡下一组(`SignalPanel`)
- 悬浮位置由 `StickyGroupRowTop(row, rowCount)` 给出:
**`min(列表上边界, 本组最后一行下边界 − 组名高度)`**。
* 本组还有行在列表里 ⇒ 钉在列表上边界;
* 本组最后一行开始滑出上边界 ⇒ 组名跟着一起上移、随滚动条**自然离界**(不再突然消失);
* 因为“本组最后一行的下边界”恰好就是**下一个分组行的自然位置**,所以悬浮组名的下边界
永远不超过下一个分组行 ⇒ **不可能遮住下一组名**(旧实现固定钉在 `m_listRect.Y` 会盖住它)。
- **折叠的分组不悬浮**:折叠时本组的“最后一行”就是它自己那一行,于是悬浮位置 = 它的自然位置
⇒ 随滚动自然划过(旧实现会让它停在最上方遮住下一组)。
- 底部边界线只在“真的浮在其它行之上”(`stickyTop > 自然位置`)时才画,避免与正常绘制有像素差。
- 常规行循环仍必须**跳过**被悬浮的那一行(否则同一行画两次)。
#### 二、单点信号的初始视图(`PlotView::FitSinglePointSignal`)
- 触发条件(用户明确要求):当前**没有任何信号被勾选**、勾选的第一个信号**只有一个有效数据点**
⇒ 旧路径(`FitAll` 的整段自适应)会让时间轴覆盖整条时间列、Y 轴退化成几乎为 0 的区间。
- 新行为:数据点**居中**,**时间跨度 = 1s**、**Y 轴跨度 = 1**(各为“一个大格”);
同时设 `autoRange = false`、`fitYToSelection = false`(否则会被自适应路径改回去)。
- `Application::SynchronizeView` 在首次勾选时先试 `FitSinglePointSignal()`,失败才 `FitAll()`。
- `AutoRangeLaneToView` 的**退化区间**(可见区间里只有一个取值)也改为 `PadRange` 同款留白
`max(1, |v|×0.1)`,不再给“几乎为 0”的区间(那会让幅值网格线挤成一团)。
- ⚠️ **需求作废**:上一轮“有效采样点 2…16 的信号默认改成点线 + 2px”**已取消**
(用户 2026-10-15 明确作废:下面第四项的“放大自动点线”已覆盖那个场景)。
只保留“**只有一个**有效采样点 → 点线 + 最大线宽”这一条。
- `SignalData::singlePointTime` 已升级为 **`sparseTimes` / `sparseValues`**
(有效点 ≤ `kSparseTimesLimit = 1024` 时在加载/插入时缓存全部有效点,按时间升序),
同时服务于:单点初始视图(取时刻与取值)、吸附判定(二分)、最近数据点查找。
#### 三、光标吸附的统一判定(`PlotView`)
- **N = `GridMinorCellCount()`**:网格底衬一横排的小格数,与 `PaintLane`/`PaintTimeAxis`
用**同一套** `NiceStep(span, kTimeTickTarget)` + 五等分算出(所以不随缩放等级大幅变化)。
- **`SignalSnapEnabled(axis, signal, timeShift)`**(用户 2026-10-15 的定义):
**当且仅当**该信号在**当前一屏**内的数据点个数 `< N × 4` 时开启吸附;
判据随视界实时重算 ⇒ 疏密变化的信号“移到稀疏区自动吸附、移回稠密区自动自由移动”。
⚠️ 可见性与点个数一律按**显示时间**(`采样时刻 + timeShift`,`timeShift` = 该信号的
`offsetTime`)判断;且要求视界内**至少有一个可见数据点**(否则“0 < N×4”会把“数据全在
视界之外”的信号误判成稀疏,白切一次点线且没有任何可吸附对象)。
⚠️ 性能:总数不足阈值时直接返回(配合 `sparseTimes` 二分);否则只统计可见下标区间内的
有限值并**攒够阈值即早退**,扫描窗口还有 `threshold × 64` 的上限(防 NaN 长循环)。
- **`SnapTimeToSample`**:判定**只针对鼠标所在网格**(`LaneAtY(m_mouseY)`)——
同一个时间轴上,一个网格达到稀疏标准、另一个没有时,只在前者吸附;
同网格内**所有符合判定的信号**的数据点都参与比较。
⚠️ 一切判定与查找都在**显示时间**(`采样时刻 + offsetTime`)空间进行,详见 §9.31。
**吸附半径 = 一个网格小格的宽度**(与判定同源),且**不超过半个局部采样间距**
⇒ 数据点很稀疏(一屏只有一两个点)时光标在点之间仍可自由移动(吸附 ≠ 卡住)。
⚠️ `LocalSampleSpacing` 对 `finiteCount <= 1` 必须返回 0(“数据点间距无定义”),
否则会拿时间轴的采样间隔充数(NaN 填充的轴上即 1ms)把半径压到极小、单点永远吸不住。
⚠️ 旧实现(`SampleSpacingPixels` 的“下门限 6px + 上门限半屏宽”)**已删除**,不要再恢复。
#### 四、自动点线:与吸附判定**完全同源**(`UpdateAutoScatterStyles`)
- 只有 `SignalSnapEnabled` 为真的信号自动切「点线」;不再满足时(适应视图、或视界从稀疏区
移到稠密区)自动恢复原样式;自动期间用户手动改过则不恢复(用户选择优先)。
- ⚠️ “用户是否手动改过”的判定必须在恢复判断**之前**(用户改完可能紧接着就退出阈值)。
- `AxisSparseInView`(旧的“采样间距 > 半屏宽”)**已删除**。
#### 五、验证(离屏 `.preflight/check`,`SW_HIDE` + 不注入真实输入 + 进程/线程 `BELOW_NORMAL`)
- **55/55 通过**(连跑 3 次稳定):`[A]` 组名悬浮(钉住 / 平滑上移 / 整组滚过后换下一组 /
折叠组不悬浮 / 悬浮下边界不超过下一组名)、`[B]` 单点初始视图(跨度 1s 与 1、居中、
样式为点线 + 最大线宽、两个信号时不再走单点路径)、`[C]` 吸附判定(满视图都关闭 /
稀疏窗口开启 / 无可见点不开启 / 同时间轴不同网格结果不同 / 同网格多信号的点都参与 /
稠密↔稀疏双向动态切换 / 单点靠近吸附远离自由)、`[D]` 自动点线(同源判定 /
恢复 / 用户手动改过不覆盖)。
- **A/B**:临时禁用「悬浮位置公式 / 单点初始视图 / 吸附判定」后重跑 ⇒ **14 项如实失败**。
- ⚠️ 测试手法坑(本轮踩到并修掉):① 每个用例前 `ClearGroups`,否则行号/网格号不确定;
② **测试夹具的时间列必须单调升序** —— 否则时间轴被判为非单调、`VisibleIndexRange` 退化成整段,
于是“稀疏窗口”里会把视界外的稠密点也数进去(本轮的 `mixed` 信号就是这样被误判的);
③ `AddGroup` 会 push 进 `vector<SignalGroup>`,之前缓存的 `SignalData*`/`TimeAxis*`
引**用可能失效**,用完要重新 `Resolve`/`Axis`;
④ 改完 `PlotView.cpp` 后要确认 `make release` 真的重编译了(曾遇时间戳未更新而链接到旧 `.o`,
症状恰好等于“修复没生效”——先 `(Get-Item …).LastWriteTime = Get-Date` 再 make)。
---
### 9.31 自适应范围/时机澄清 + 适应视图与 Y 轴自适应互不干扰 + 吸附跟随时间偏移(本轮,起源设备)
> 本轮四项(用户 2026-10-15 第三轮):① 澄清“自适应范围/时机”的准确说法(并修正记忆里不完善的表述);
> ② 「适应视图」与「Y 轴自适应」互相干扰(相互切换都会重绘、区间会变);
> ③ 「适应视图」在单点信号上让数据点“飞了”;④ 光标吸附没有考虑「调整 → 时间轴偏移」。
> 离屏验证 **39/39 通过**(连跑 3 次稳定),A/B 回退三项修复后 **7 项如实失败**。
#### 一、自适应范围/时机的准确说法(用户澄清)
* **首个网格**显示内容时做完整自适应:**Y 轴与时间轴都**匹配自己的数据范围;
* **只有一个数据点**的信号必须显示在网格**中间**(`FitSinglePointSignal()`:居中 + 1s + 1);
⚠️ **工具栏「适应视图」也要走这条路**(仅一个网格 + 唯一可见信号为单点时),
否则数据点会被挤到网格边缘、看起来“飞了”。
* **后续新增网格**只做 **Y 轴**自适应(`ReapplyGlobalAutoFitIfEnabled`),**不再调整时间轴**。
⇒ 记忆里“额外要求视界内至少有一个可见数据点”这句是**不完善**的表述,完整含义见上
(“可见”指的是单点信号要被居中摆在网格里而不是挪出视界)。
#### 二、「适应视图」与「Y 轴自适应」互不干扰(`PadValueRange`)
- **根因**:两条自适应路径的**留白不同** —— 整段自适应(`ComputeLaneRange` → `PadRange`)用
**6%**,「选区 Y 轴自适应」(`AutoRangeLaneToView`)用 **5%**。视界未变时两者本应完全相等,
于是“点过适应视图 → 打开 Y 轴自适应”会算出不同区间、网格被无意义重绘(实测 **143513 像素**),
反向操作同样(**137326 像素**)。
- **修法**:新增 `DataModel.hpp` 的 **`PadValueRange()`**(含退化区间规则)作为**唯一**留白实现,
`PadRange` 与 `AutoRangeLaneToView` 都调它。
- 验证:`[A]` 在“适应视图 → 开自适应 → 关自适应 → 再适应视图”四个步骤上断言
**生效区间逐位相同**且**绘图区逐像素 0 差异**(含对照组:真缩放会重绘 79581 像素)。
⚠️ 断言必须用 `ComputeLaneRange` 取**生效区间** —— `autoRange == true` 时
`Lane::yMin/yMax` 只是初始占位值(±1),直接读它会得到假失败。
#### 三、吸附跟随「调整 → 时间轴偏移」
- **根因**:采样点 k 画在 `time[k] + offsetTime` 处,而光标存的也是**显示时间**,
但吸附一直在**源时间**空间查找 —— 设过时间轴偏移后,吸附会落到历史位置(用户报告)。
- **修法**:`SignalSnapEnabled` / `HasVisibleSample` / `NearestSnapSample` / `LocalSampleSpacing`
全部增加 `timeShift` 形参:可见性与点数统计比 `time[k] + timeShift`,
`NearestSnapSample` 内部用 `time - timeShift` 回源空间查找、返回时再加回去
⇒ `SnapTimeToSample` 拿到的 `candidate` 是**画面上的**时刻;`UpdateAutoScatterStyles` 同步传 `offsetTime`。
- 验证:`[D]` 偏移 +0.3 时“靠近画面上的点”必须吸到 `源时刻 + 0.3`;偏移=0 时行为不变;
偏移把点移出视界时不开启吸附也不吸(不会吸到看不见的位置)。
---
### 9.32 信号面板滚动条跟随 / 工具栏连点丢状态 / 图层网格线步长 / 多网格单点自适应 / 单点信号拖拽(本轮,起源设备)
> 本轮五项(用户 2026-10-15 第四轮)。离屏验证 `.preflight/check` **67/67 通过**(连跑 3 次稳定);
> **A/B** 回退六处修复后 **17 项如实失败**(含 [C] 的"辅助线 49px vs 前台 25px / 20 vs 40 条 / 最大偏移 539px")。
#### 一、拖动信号面板滚动条、鼠标移到绘图区后"滑块卡在原地不动"
- **根因**:`Application::WM_MOUSEMOVE` 里
`plotOnlyMove = (m_activeTarget == Target::PLOT) || (inPlot && m_mouseOverPlot)`。
拖滚动条时鼠标很容易进入绘图区 ⇒ `inPlot && m_mouseOverPlot` 为真 ⇒ 判定"本帧只影响绘图区"
⇒ 只 `InvalidatePlotRect`;而面板的滚动偏移**已经变了**却没有重绘 ⇒ 滑块看着不动(滚动值其实在变)。
- **修法**:`draggingNonPlot = (m_activeTarget != NONE) && != PLOT && != EDIT_FIELD`,
`plotOnlyMove` 必须先排除它(正在拖拽非绘图区组件时,一律走完整失效)。
- 验证 `[A]`:真实按下滚动条 + 移入绘图区移动两次 ⇒ 滚动偏移 `0 → 2088.7 → 1766.1`、`m_chromeDirty` 为真、松手结束拖拽。
#### 二、工具栏快速连点 / 双击丢状态
- **两个根因**:
(a) 窗口类带 `CS_DBLCLKS`,快速第二次按下被 Windows 合成 `WM_LBUTTONDBLCLK`,而工具栏没处理它
⇒ **那一次按下完全丢失**("连点好几下,开关只切换了几次");
(b) `HandleMouseMove` 只要鼠标不在工具栏上就调 `m_toolbar.OnMouseLeave()` ⇒ 清掉 `m_pressedIndex`
⇒ 随后在按钮上松手什么也不发生(按住期间从按钮边缘划出即命中这条)。
- **修法**:`WM_LBUTTONDBLCLK` 命中工具栏时按"按下"处理(`SetFocus`/`SetCapture`/`OnLButtonDown`);
`m_activeTarget == Target::TOOLBAR` 期间**不调** `OnMouseLeave()`。
- 验证 `[B]`:三次快速点击(第 2/3 次走 `WM_LBUTTONDBLCLK`)⇒ `NONE → SINGLE → DOUBLE → NONE`;
按住 → 移出一屏 → 回到按钮松手仍能切换。
#### 三、网格叠底:拖拽期间竖向网格线错位/丢失 + 操作信号样式时"闪烁"
- **根因**:`PaintLane` 的竖向网格**主刻步长**由 `Tab().ViewMax() - Tab().ViewMin()` 推出,
而渲染信号图层时 `RenderLaneLayerRegion` 把该区间改成了**宽窗口**(≈2× 前台)
⇒ 图层里的辅助线步长 2×、位置与前台不同,且"与主刻重合则跳过"的判据也随之错位
⇒ 贴图后网格线**错位 / 整条丢失**;在"拖拽帧(图层像素)"与"结算帧(整幅重绘)"之间来回切换时就是用户看到的**闪烁**。
- **修法**:新增 `PlotView::m_renderTimeStep`,由 `RenderLaneLayerRegion` 用**前台视界跨度**算好传入
(`PaintLane` 优先用它;与纵向已做对的 `m_wideTickSpan/m_wideTickHeight` 同一思路),渲染完恢复原值。
- 验证 `[C]`:① 直接检查**图层位图自己的像素** —— 图层主刻间距 123px vs 前台 122px、**辅助线间距 25px vs 25px**;
② 子窗口贴图帧与同视界整幅重绘帧的竖线数量一致(**10 主 / 40 辅**)且逐列偏移 ≤1px;
③ 断言贴图帧确实来自宽基底路径(`m_wideLastValid`)。
A/B(回退)如实报:辅助线 **49px vs 25px**、**20 vs 40 条**、最大列偏移 **539px**。
#### 四、多个网格、每个网格只有一个数据点时「适应视图」
> ⚠️⚠️ **本节规则(“左右各留一小格” + `FitSinglePointLanes()`)已被 §9.33 作废/取代**
> (用户 2026-10-15 第五轮:“首先应该对之前的策略做撤回,然后重新设计新的规则”)。
> 保留本节仅为历史脉络:现行规则见 **§9.33**(边界处的单值散点让出**半径**,
> 而不是“留一小格”)。
用户给出的规则(逐条落地在 `PlotView::FitSinglePointLanes()`):
* **时间轴**:左边界 = 所有网格中**最左**的数据点时间,再向左偏**一个网格小格**对应的时间跨度
(`TimeGridCellSeconds()`,与网格绘制同源);右边界 = 最右数据点时间 + 一个小格。
* **Y 轴(逐网格)**:按该网格自己的数据点范围自适应;若网格内存在**时间一致但 Y 值不一致**的多个单点
⇒ 纵向上下边界也各留**一个 Y 小格**的高度(`LaneValueGridCell()`,与网格绘制同源)。
* **最极端情况**(网格中所有数据点都在**同一个时间位置**):每个网格的适应按
"首个网格加载且加载为单数据点信号时"的时间轴对齐方式 —— **跨度 1s、以该时刻居中**。
* 某网格只有一个取值 ⇒ 该网格 Y 跨度 **1** 且居中于该值。
* ⚠️ 规则只在**所有可见信号都是单点**(`finiteCount == 1`)时启用,否则交回常规 `FitAll`。
验证 `[D]`:MERGED 后把 b0 移到新网格(网格0 = {a0=5.0, a1=−3.0}、网格1 = {b0=−2.0})⇒
时间视界 `[0.000, 3.800]`(数据 `[0.1, 3.7]` ± 一小格 0.1)、网格0 区间覆盖 4.5~5.5、
网格1 = `[−2.5, −1.5]`(跨度 1 居中)、同刻退化用例跨度 1 居中于 0.1;并含"有常规信号时规则不生效"的反向断言。
#### 五、单点信号的高亮与拖拽换网格
- `HitTestSignal` 新增**单点分支**(`SinglePointScreenPos()` + 容差 = `max(常规命中容差, 该点绘制半径)`)——
单点信号没有折线可测距,不单独处理就永远悬停不到。
- 悬停:光晕换成**圆环**(复用 `PaintSignalHalo`,单点用环)且**不改鼠标形状**
(`QueryCursor` 不查 `HitTestSignal`);按下后进入常规信号拖拽(`IDC_SIZEALL`、虚影为实心点 + 环)。
- 验证 `[E]`:命中判定、`QueryCursor == nullptr`、光晕 57600 像素、从网格1 拖到网格0 成功。
#### 六、本轮验证方法学(重要教训)
- 离屏 `.preflight/check`:`SW_HIDE` 窗口 + 只注入合成消息 + 进程/线程 `BELOW_NORMAL`;**67/67 通过**。
- ⚠️ **A/B 必须让被测代码真的参与编译**:该 harness 链接的是 `Build/Release/*.o`,
只改源文件不 `make release` ⇒ 测的仍是旧目标文件(实测"回退后依然全绿"的假结论);
`Copy-Item` 还会**保留时间戳**,使 `make` 认为 `.o` 最新(实测"恢复后直接 make 不重编译")
⇒ **回退/恢复后都要把时间戳推到当前,再 `make release`**,最后用临时输出名验证链接
(`make release TARGET_FILE_NAME=Target_check`,因为用户正在运行 `Target.exe` 时它被锁定)。
- ⚠️ **判别力要靠"量"而不是"看起来对"**:本轮的 `[C]` 一开始写的是"两帧逐列比较",
但在图层没被真正使用时两帧**恒等** ⇒ 毫无判别力;直到
(a) 断言帧确实来自宽基底贴图(`m_wideLastValid`)、(b) 直接检查**图层位图自身的网格线间距**,才真正抓住缺陷。
- ⚠️ 网格线检测细节:GDI 写 DIB **不设置 alpha 字节**(比较必须按 24 位掩码);
"竖线"的列阈值**必须 ≥ 半个捕获高度**(横向网格线会让每一列都带上几十像素,
用小阈值会把 1212/1225 列全判成竖线 → 位置比较彻底失真)。
---
### 9.33 「适应视图」边界规则**撤回重做**:单值散点让出半径 + 单点簇视图(本轮,起源设备)
> 本轮(用户 2026-10-15 第五轮)。用户原话要点:
> ① 加载文件后**不勾选任何信号**时点几次「适应视图」,再勾选一个单值信号 —— 初次自适应**没有发生**
> (数据点既不居中也不在网格内);② 只勾选 `WIN_Motor_StallCurrentVolBrk`(单值)+ `m_MOT_Current_DCom[0]`
> (连续)时点「适应视图」,单值信号的点被挤到显示区域外**只剩半个**;
> ③ **撤撤回上一轮的策略**(§9.32 的"左右各留一小格"),重新设计边界规则。
> 离屏验证 `.preflight/check` **62/62 通过**(连跑 3 次稳定);**A/B** 回退四处规则后 **20 项如实失败**
> (含 `the dot is horizontally centred (got 1697, want 1084.75)` = 散点正好压在绘图区**右边缘**、
> `[F] initial view (got 42.386313, want 10)` = 初次自适应压根没发生)。
#### 一、撤回的旧策略(勿再用)
- 删除 `PlotView::FitSinglePointLanes()`("左右边界 = 最左/最右数据点 ± **一个网格小格**")。
- 删除 `PlotView::TimeGridCellSeconds()` / `PlotView::LaneValueGridCell()`(只为它服务)。
- 删除 `PlotView::FitSinglePointSignal()`("首个单值信号:时间跨度 1s + Y 跨度 1")——
初次自适应现在与工具栏「适应视图」**走同一条路径**(`FitAll()`),规则不会再出现两套。
#### 二、新的边界规则(`PlotView::FitAll()`)
1. 先做一次**常规**的全局适应:
* 时间 = `TimeBounds()`(可见信号的**时间轴**范围并集,与 `ComputeVisibleTimeBounds` 同源);
* 幅值 = `ComputeLaneRange()`(含唯一的留白实现 `PadValueRange`,即 **6%**)。
2. **单值散点让位**(上下左右四条边界统一规则):
* 若某条边界被一个**单值信号的数据点**(`finiteCount == 1`,画出来是一个圆点)顶住,
则把该边界再向外让出**该散点的半径**(`SinglePointDotRadius()`,再 +1 像素;
两个方向都迭代两遍,因为"半径换算成多少时间/数值"依赖当前跨度);
* **只在散点会被裁切时**才扩展(散点本来就在内部 ⇒ 视界**一点不动**);
* 边界不是单值散点时 ⇒ 边界就落在这个信号点上,**不留额外余量**
(时间轴本来就不留;幅值仍沿用 `PadValueRange` 的 6%,这是 §11.2 第 62 条确立的
"幅值留白只能有一个来源",本次**没有**改动它)。
* 判断依据是**信号自身的数据点**(不是轴),因此"单值信号与连续信号共用一条时间轴"
(MF4 同一通道组)时不会误判。
3. **特例:全部可见数据点都是单值信号、且时刻完全相同**(`FitSinglePointCluster()`):
* 时间轴以该时刻**居中**、缩放到 **一大格 = 1s**
(视界 = `kTimeTickTarget × 1s` = **10s**,此时 `NiceStep(10, 10)` 正好是 1.0
——用户要求的"一大格 1s 的级别");
* 每个网格的幅值区间以**能完整画出该网格内的全部数据点**为准
(取值不同时 + 两侧半径余量并迭代;退化成单值时以**跨度 1 居中**,
与"单值信号居中显示"的观感一致),并置 `autoRange = false`。
* 该分支不再复位 `ZoomMode()`(用户要求"点适应视图后仍保持已启用的缩放模式")。
#### 三、初次自适应"没有发生"的根因(用户报告 ①)
- 没有任何信号被勾选时 `FitAll()` 也把 `Tab().ViewInitialized()` 置成了 **true**,
于是随后勾选第一个信号时 `Application::SynchronizeView()` 认为"已经初始化过"⇒ **跳过自适应**,
视界停在"无信号"那次的整段范围上(实测 MF4:42.386s 的视界,数据点被推到右边缘外)。
- 修法:`FitAll()` **只有存在可见信号时**才置位 `ViewInitialized()`;
`Application::SynchronizeView` 也不再分两步(不再先试 `FitSinglePointSignal`),直接 `FitAll()`。
#### 四、验证(离屏 `.preflight/check`,`SW_HIDE` + 只注入合成消息 + `BELOW_NORMAL`)
- `[A]` 无信号时连点 3 次"适应视图" ⇒ `ViewInitialized()` 仍为 false;再勾选单值信号 ⇒
初次自适应发生(时间跨度 **10**、以该点居中、幅值跨度 **1** 居中、散点落在绘图区正中)。
- `[B]` 时间边界:只有普通信号 ⇒ 视界**正好**等于数据范围;内部单值点 ⇒ 视界不变;
左/右边界处的单值点 ⇒ 边界外扩,且**外扩量 = 半径 + 1px**(实测 0.013408s),散点完整可见。
- `[C]` 幅值边界:单值点顶住某网格上边界 ⇒ 该网格上边界外扩、散点完整可见;
反之"该网格只有一个取值"⇒ 保持自动自适应(区间 = 退化留白 `max(1,|v|×0.1)` 两侧展开)。
- `[D]` 特例:两个网格、全部为同一时刻的单值点(一个网格内有 3 和 7 两个取值)⇒
时间跨度 **10**、以该时刻居中、`NiceStep(10,10) == 1.0`、每个散点都在自己的网格内。
- `[E]` 回归:普通信号的视界 = 数据范围、各网格保持 `autoRange`;
§9.31 的"适应视图 ↔ Y 轴自适应互不干扰"(区间逐位相同)仍然成立。
- `[F]` **真实文件** `TestData/MF4-Record.MF4`:`signals=124`,`WIN_Motor_StallCurrentVolBrk`
确实只有 **1** 个有效点(t = 0.005492),用它 + `m_MOT_Current_DCom[0]`(**8475** 样点)复现用户场景:
点「适应视图」后散点圆心离绘图区左边界 **16.5px**、半径 15px ⇒ 完整可见
(修复前它正好落在边界上、只剩半个)。
---
### 9.34 浮动面板不穿透 / 网格高亮改由信号列表触发 / 样式区「目标」行 / 联动平滑滚动(本轮,起源设备)
> 本轮四项(用户 2026-10-15 第六轮)。离屏验证 `.preflight/check` **73/73 通过**(连跑 3 次稳定);
> **A/B** 回退六处实现后 **15 项如实失败**。
#### 一、浮动面板上双击不再穿透到网格(用户报告)
- **根因**:实验性功能的浮动面板绘制在网格之上,但 `PlotView::OnDoubleClick` 在
"差值标签 → 光标标签 → 光标线 → 图例" 都不命中时**无条件** `FitAll()` ⇒
在面板上双击任意位置都会让网格莫名其妙地自适应一次。
- **修法**:在 `OnDoubleClick` 的**最前面**(高于一切下层命中)用
`TopPanelAtPoint()` 判定,命中浮动面板则直接消费(面板自身双击无额外语义)。
这是"上层元素的交互不得影响下层"的通用做法,与 `QueryCursor` / `OnLButtonDown`
里已有的面板守卫同源。
#### 二、网格变色高亮改由"信号列表悬停"触发(用户重新定义)
- **旧行为(已取消)**:鼠标在网格里移动 ⇒ `m_hoverLane = LaneAtY(y)` ⇒ 整个网格变色高亮。
- **新行为**:
* 鼠标**在网格里移动不再触发**任何网格高亮;
* 在**信号列表**里悬停某条信号 ⇒ 若它已勾选绘制到某个网格,则该网格变色高亮,
**并且该信号曲线本身也高亮**(复用 `PaintSignalHalo`);鼠标移开即恢复;
* 悬停**未勾选**的信号不产生任何高亮(`LaneOfSignal` 只认可见信号);
* 悬停信号行的 ✕(移除)**不算**"悬停到该信号"。
- **实现要点**:
* `PlotView` 新增 `m_externalHoverSignal` + `SetExternalHoverSignal()`:
⚠️ **必须与 `m_hoverSignal` 分开**——绘图区的 `OnMouseLeave` 会清掉后者,
而列表悬停时鼠标恰恰不在绘图区内;
* 新增 `PlotView::EffectiveHoverLane()`:`m_hoverLane`(浮动面板悬停)优先,
否则取外部悬停信号所在网格;`PaintHoverHighlight` 与光晕都改用它 /
`m_hoverSignal ?: m_externalHoverSignal`;
* `SignalPanel` 新增 `HoveredSignal()`(列表信号行 / 「目标」行),
⚠️ 不能用 `m_hoverRow` 现算(鼠标移出列表即被清、行下标会因重建而失效),
且在 `InvalidateRows()` 里一并作废(结构变化后悬停引用不再可信);
* 同步时机:`Application::Render()` **每帧**同步(覆盖 WM_MOUSELEAVE、数据变化、
切换标签页等全部改变悬停的路径,而不只是 WM_MOUSEMOVE)。
* GX 徽标的悬停高亮仍按鼠标纵坐标现算(它与"悬停网格高亮"已解耦)。
#### 三、样式区顶部「目标」行(用户要求)
- 位置:样式自定义区**最上方**(预设颜色色块之上),与其余五行同高同间距
(`DesiredStyleHeight()` 因此多算一行;标签列文字「目标」)。
- 内容:当前**正在被编辑的目标信号**(= 选中信号)的名称;
**已绘制到网格时**在名称**后方**显示它所在的 GX 标签(`TargetLaneIndex()`)。
- 交互(用户要求"与信号选择区域的信号相同"):
* 名称悬停 ⇒ 一层 `hover` 背景;GX 标签悬停 ⇒ `accentSoft` 底 + `accent` 边;
两者都用手型光标;
* **悬停名称 / GX 标签也触发绘图区的网格高亮 + 曲线高亮**(与列表悬停同效);
* **单击不进入编辑**,只触发"滚动到目标信号"的联动;**双击**才进入改名
(复用 `StyleEditField::SIGNAL_NAME`,用 `m_editRow < 0` 表示"目标是选中信号");
* 名称颜色与列表一致:已绘制 = `text`,未绘制 = `textMuted`。
- ⚠️ 名称 / GX 标签的矩形依赖**文本实际宽度**与**是否已绘制**,
这两者都可能在不重排的情况下变化 ⇒ 用 `UpdateTargetRowGeometry()` 在
绘制 / 悬停 / 命中 / 取光标四处按需重算(带小缓存键)。
#### 四、联动:把信号列表**平滑滚动**到目标信号(用户要求)
- **触发**:绘图区内把某条信号选为当前目标(点击图例、点击信号曲线、双击图例改名)
⇒ `PlotView::SelectSignal()` 记录一个待处理请求(`TakePanelScrollRequest()`),
宿主在鼠标消息处理后取走并调用 `SignalPanel::ScrollToSignalAnimated()`;
另外样式自定义区的**任何交互**(预设色块、自定义颜色入口、样式按钮、
线宽 / 透明度滑块与数值框、「系数/偏移」「镜像」二级菜单、绝对值)与
「目标」行的单击也都会触发同一次滚动。
- **滚动目标位置**(`SignalScrollTarget()`):
* 目标在当前视界**上方** ⇒ 向上滚动到 `rowTop - 组名高度`,
⚠️ **顶部必须避让悬浮组名**(用户明确要求"完全出现时不会被浮动组名遮挡"):
悬浮组名只可能是目标自己那个分组的组名(分组连续排列,见 `StickyGroupRowTop`),
因此让出**一个组行高度**即可;
* 目标在视界**下方** ⇒ 向下滚动到 `rowBottom - 列表高度`;
* 已经完整可见 ⇒ **不动**。
- **动画**(`AdvanceScrollAnimation()`,宿主用 16ms 定时器驱动):
* ease-out cubic(起步快、靠近目标变慢 —— 用户要求"越远时越快,靠近时变慢");
* 时长 = `clamp(距离 × 0.35ms, 130ms, 300ms)`(用户要求"不要太久");
* 用户滚轮 / 拖滚动条 ⇒ **立即取消**动画(否则互相打架)。
- ⚠️ 动画只改"面板的滚动偏移",因此宿主只需持续 `InvalidateRect`(整帧),
由 `Render` 检测 `ScrollAnimationActive()` 自管定时器(与提示浮层淡出同一套做法)。
---
## 10. 变更日志(本文件维护记录) ## 10. 变更日志(本文件维护记录)
| 日期 | 设备/模型 | 摘要 | | 日期 | 设备/模型 | 摘要 |
@@ -1493,6 +1982,13 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸(
| 2026-10-14 | DeepSeek-V4.1-Flash(**起源设备**) | **边沿计数改用「绘制值」+ 派生信号烘入时间偏移 + 图例托底统一为 GX 徽标款 + 调整二级面板向上展开(详见 §9.21)**。① **边沿计数恒为 0**:`EdgeCountsFor`/`EdgeCenterCountsFor` 只比原始采样值,而阈值是屏幕上的值 → 「系数 50」的信号统计恒为 0;修法:判定值改为 `ApplyDrawTransform(style, values[SourceIndexFor(...)])`(中心轴两侧同套变换;水平镜像时曲线窗口也要先映射下标);缓存新增 **`styleKey`**(否则改完「调整」仍命中旧计数)。② **区间边界点被误计**:`EdgeIndexRange` 左端“多取一个采样点”的外扩**已删除**,收紧为“**两个端点都在区间内**才计入”(光标恰落在越过阈值的点 B 上时 B 不再被算作上升沿)。③ **`offsetTime` 也烘进派生采样值**(**推翻 §9.19 的“唯一例外”**):按 `time[i]-offsetTime` 取最近采样点重采样(单调时间轴用只前进的游标,O(n);越界置 NaN),新信号样式**全默认**;非单调时间轴退回到把 `offsetTime` 带在样式上。④ **图例托底 = GX 徽标同款**(同高 / 圆角 = 高度一半 / 同配色 / 水平内边距 = 胶囊半径),文本改用新增字体 **`uiTiny`(10 逻辑 px)**;`kLegendPlateRadius` 已删。⑤ **`PopupMenu::Open(..., expandUp=true)`**(锤点为**左下角**、向上展开,`m_buildExpandUp` 供 `RebuildLayout` 复用)+ 「系数/偏移」「镜像」面板锤在按钮左上角再上移 `kAdjustMenuGap`。验证:`.preflight/uitest` 新增 `[AR]`(五组断言全部与**独立参考实现**逐位比对,含 A/B:“按原始采样值算会得到 7/6 个边沿”);**并同步修正 harness 里两处旧规则断言**(`[AP]` 派生信号继承 → 默认样式 + 烘值;`[AN]` 图例带无托底 → 装饰矩形逐块且暴露列可见底层内容);合计 **722/722 通过**,`make release` 零警告。 | | 2026-10-14 | DeepSeek-V4.1-Flash(**起源设备**) | **边沿计数改用「绘制值」+ 派生信号烘入时间偏移 + 图例托底统一为 GX 徽标款 + 调整二级面板向上展开(详见 §9.21)**。① **边沿计数恒为 0**:`EdgeCountsFor`/`EdgeCenterCountsFor` 只比原始采样值,而阈值是屏幕上的值 → 「系数 50」的信号统计恒为 0;修法:判定值改为 `ApplyDrawTransform(style, values[SourceIndexFor(...)])`(中心轴两侧同套变换;水平镜像时曲线窗口也要先映射下标);缓存新增 **`styleKey`**(否则改完「调整」仍命中旧计数)。② **区间边界点被误计**:`EdgeIndexRange` 左端“多取一个采样点”的外扩**已删除**,收紧为“**两个端点都在区间内**才计入”(光标恰落在越过阈值的点 B 上时 B 不再被算作上升沿)。③ **`offsetTime` 也烘进派生采样值**(**推翻 §9.19 的“唯一例外”**):按 `time[i]-offsetTime` 取最近采样点重采样(单调时间轴用只前进的游标,O(n);越界置 NaN),新信号样式**全默认**;非单调时间轴退回到把 `offsetTime` 带在样式上。④ **图例托底 = GX 徽标同款**(同高 / 圆角 = 高度一半 / 同配色 / 水平内边距 = 胶囊半径),文本改用新增字体 **`uiTiny`(10 逻辑 px)**;`kLegendPlateRadius` 已删。⑤ **`PopupMenu::Open(..., expandUp=true)`**(锤点为**左下角**、向上展开,`m_buildExpandUp` 供 `RebuildLayout` 复用)+ 「系数/偏移」「镜像」面板锤在按钮左上角再上移 `kAdjustMenuGap`。验证:`.preflight/uitest` 新增 `[AR]`(五组断言全部与**独立参考实现**逐位比对,含 A/B:“按原始采样值算会得到 7/6 个边沿”);**并同步修正 harness 里两处旧规则断言**(`[AP]` 派生信号继承 → 默认样式 + 烘值;`[AN]` 图例带无托底 → 装饰矩形逐块且暴露列可见底层内容);合计 **722/722 通过**,`make release` 零警告。 |
| 2026-10-15 | DeepSeek-V4.1-Flash(**另一台设备**) | **「调整」数值符号规则 + 边沿统计按帧推进(含首帧公平分摊)+ 浮动面板排版与弱峰复位控件(详见 §9.26)**。① **符号**:Y 轴偏移 0 不再显示 `+0.00`;系数非 0 正值带 `+`(编辑态初始文本仍无符号);`Δt:` 后按正负带符号(`Δt:+5s 60ms` / `Δt:-5s 60ms` / `Δt:0s`)。② **边沿统计卡住**根因:预算按 `GetTickCount64()` 分桶(系统粒度 15.6ms)而 `PostMessage` 自持重绘把帧率拉高 ⇒ 同一 tick 内多帧完全不推进 → 改为 **`Paint` 开头按帧重置**;并给**每个状态独立每帧配额**(`share = max(6, m_edgeScans.size())`,避免同帧新建状态被第一个独占整额)→ 实测首帧 5 个状态全部推进、**9 帧内全部扫完且不注入任何鼠标消息**、与参考实现逐位一致。③ **面板排版**:信号名宽度上限 `kEdgePanelNameMaxWidth=150`(300 字符名字只多 44px,旧实现多 5100px);统计信息**两列等宽**(`2×max(列宽)+间距`)⇒ 多信号“上升沿/下降沿”列起点固定、垂直对齐;未完成时**两个数字后都带 `…`**(旧实现只跟在“下降沿”后)。④ **弱峰调整**:每行滑块右侧新增**复位控件**(复用还原图标,悬停高亮 + `IDC_HAND` + 提示;点击恢复幅度 0 / 迭代 1 且上限回 10);上限输入框提示改为“输入上限(当前N)”;滑块标签去掉多余的 `%` / `N`。验证:新增离屏探针 `panelcheck` **46/46 通过**(全部走真实菜单/点击路径),既有 `verify` 47/47(另 2 条为本机未同步的旧规则断言),`make release` 零警告。 | | 2026-10-15 | DeepSeek-V4.1-Flash(**另一台设备**) | **「调整」数值符号规则 + 边沿统计按帧推进(含首帧公平分摊)+ 浮动面板排版与弱峰复位控件(详见 §9.26)**。① **符号**:Y 轴偏移 0 不再显示 `+0.00`;系数非 0 正值带 `+`(编辑态初始文本仍无符号);`Δt:` 后按正负带符号(`Δt:+5s 60ms` / `Δt:-5s 60ms` / `Δt:0s`)。② **边沿统计卡住**根因:预算按 `GetTickCount64()` 分桶(系统粒度 15.6ms)而 `PostMessage` 自持重绘把帧率拉高 ⇒ 同一 tick 内多帧完全不推进 → 改为 **`Paint` 开头按帧重置**;并给**每个状态独立每帧配额**(`share = max(6, m_edgeScans.size())`,避免同帧新建状态被第一个独占整额)→ 实测首帧 5 个状态全部推进、**9 帧内全部扫完且不注入任何鼠标消息**、与参考实现逐位一致。③ **面板排版**:信号名宽度上限 `kEdgePanelNameMaxWidth=150`(300 字符名字只多 44px,旧实现多 5100px);统计信息**两列等宽**(`2×max(列宽)+间距`)⇒ 多信号“上升沿/下降沿”列起点固定、垂直对齐;未完成时**两个数字后都带 `…`**(旧实现只跟在“下降沿”后)。④ **弱峰调整**:每行滑块右侧新增**复位控件**(复用还原图标,悬停高亮 + `IDC_HAND` + 提示;点击恢复幅度 0 / 迭代 1 且上限回 10);上限输入框提示改为“输入上限(当前N)”;滑块标签去掉多余的 `%` / `N`。验证:新增离屏探针 `panelcheck` **46/46 通过**(全部走真实菜单/点击路径),既有 `verify` 47/47(另 2 条为本机未同步的旧规则断言),`make release` 零警告。 |
| 2026-10-15 | DeepSeek-V4.1-Flash(**另一台设备**) | **数据层大重构(文件→分组 / 时间轴一等公民 / 加载器抽象)+ CSV 多时间轴 + MF4 支持(详见 §9.27 与 `Documents/refactor_report_2026-10-15.md`)**。① **解耦**:`DataFile`→**`SignalGroup`**(删除 `filePath`/`fileName`/单一 `time`/`timeColumnName`/`timeMonotonic`/`minTime`/`maxTime`),新增 **`TimeAxis`**(1..N 条/组)与 `SignalData::axisIndex`,`SignalData` 删除冗余的 `rawHeader` 与**全仓无人使用的 `unit`**;`SignalRef::fileIndex`→`groupIndex`;`DataSet`/`TabConfig` 全套改名并新增 `Axis(ref)`;采样/统计 API(`SampleSignalAt`/`FindNearestSample`/`VisibleIndexRange`/`EdgeIndexRange`/`EdgeCountsFor`/`EdgeCenterCountsFor`/`EdgeScanFor`/`MeanFilterValueAt`/`WeakPeakValueAt`/`SampleSpacingPixels`)**首参由 `const DataFile&` 改为 `const TimeAxis&`**;`ResolvedSignal` 携带 `{group, axis}`。② **加载器抽象**:新增 `DataTable.hpp`(`DataTable`/`LoadProgress`/`TableBlock`/`LoadedSource`)、`DataLoader.*`(唯一入口 `LoadSignalSource` 按扩展名分发)、`CsvLoader.*`、`Mf4Loader.*`,删除 `CsvTable.*`;唯一装配点 `BuildSignalGroup` 与格式无关(新增格式只写加载器)。③ **CSV 多时间轴**:每个 `time` 列开启新块、各块按自己的时间列裁剪 ⇒ 支持各轴不等长(实测 3 条轴 7/4/7),**不再把其它 `time` 列当成信号**;时间轴判据收紧。④ **MF4 支持**:加载后新建分组、组名=文件名,全文件信号归入该组;每个通道组(CG)一条时间轴并自动去重(实测 27 CG → 3 轴 / 124 信号);实现 MDF v4 块解析(DG/CG/CN/CC/DT)、位级整数与 32/64 位浮点解码、线性换算 `phys = cc_val[0] + cc_val[1]*raw`、无效位处理;**`##TX` 兼容规范的「长度+UTF-16」与 CANape 的「裸 NUL 结尾文本」**(否则通道名全空)。⑤ 顺带修复:`Application::Initialize` 在窗口类已注册时不再弹模态框(旧实现导致同进程二次构造永久阻塞),`Shutdown` 注销窗口类;UI 文案统一到「分组」,对话框支持 `*.mf4/*.mdf`。验证:新增 `datacheck` **69/69**、`edgecheck` **46/46**(替代被编码破坏的 `panelcheck`)、既有 `verify` **47/47**(另 2 条本机旧规则断言)、`make release` 零警告。 | | 2026-10-15 | DeepSeek-V4.1-Flash(**另一台设备**) | **数据层大重构(文件→分组 / 时间轴一等公民 / 加载器抽象)+ CSV 多时间轴 + MF4 支持(详见 §9.27 与 `Documents/refactor_report_2026-10-15.md`)**。① **解耦**:`DataFile`→**`SignalGroup`**(删除 `filePath`/`fileName`/单一 `time`/`timeColumnName`/`timeMonotonic`/`minTime`/`maxTime`),新增 **`TimeAxis`**(1..N 条/组)与 `SignalData::axisIndex`,`SignalData` 删除冗余的 `rawHeader` 与**全仓无人使用的 `unit`**;`SignalRef::fileIndex`→`groupIndex`;`DataSet`/`TabConfig` 全套改名并新增 `Axis(ref)`;采样/统计 API(`SampleSignalAt`/`FindNearestSample`/`VisibleIndexRange`/`EdgeIndexRange`/`EdgeCountsFor`/`EdgeCenterCountsFor`/`EdgeScanFor`/`MeanFilterValueAt`/`WeakPeakValueAt`/`SampleSpacingPixels`)**首参由 `const DataFile&` 改为 `const TimeAxis&`**;`ResolvedSignal` 携带 `{group, axis}`。② **加载器抽象**:新增 `DataTable.hpp`(`DataTable`/`LoadProgress`/`TableBlock`/`LoadedSource`)、`DataLoader.*`(唯一入口 `LoadSignalSource` 按扩展名分发)、`CsvLoader.*`、`Mf4Loader.*`,删除 `CsvTable.*`;唯一装配点 `BuildSignalGroup` 与格式无关(新增格式只写加载器)。③ **CSV 多时间轴**:每个 `time` 列开启新块、各块按自己的时间列裁剪 ⇒ 支持各轴不等长(实测 3 条轴 7/4/7),**不再把其它 `time` 列当成信号**;时间轴判据收紧。④ **MF4 支持**:加载后新建分组、组名=文件名,全文件信号归入该组;每个通道组(CG)一条时间轴并自动去重(实测 27 CG → 3 轴 / 124 信号);实现 MDF v4 块解析(DG/CG/CN/CC/DT)、位级整数与 32/64 位浮点解码、线性换算 `phys = cc_val[0] + cc_val[1]*raw`、无效位处理;**`##TX` 兼容规范的「长度+UTF-16」与 CANape 的「裸 NUL 结尾文本」**(否则通道名全空)。⑤ 顺带修复:`Application::Initialize` 在窗口类已注册时不再弹模态框(旧实现导致同进程二次构造永久阻塞),`Shutdown` 注销窗口类;UI 文案统一到「分组」,对话框支持 `*.mf4/*.mdf`。验证:新增 `datacheck` **69/69**、`edgecheck` **46/46**(替代被编码破坏的 `panelcheck`)、既有 `verify` **47/47**(另 2 条本机旧规则断言)、`make release` 零警告。 |
| 2026-10-15 | DeepSeek-V4.1-Flash(**起源设备**) | **信号列表滚动越界绘制 + 边沿统计面板「显示冻结」(详见 §9.28)**。① **列表把组名/信号名画到筛选框上**(MF4 多信号场景):根因是 `UiText` 走 GDI `DrawTextW`,**GDI 文本不遵循 GDI+ 的 `SetClip`** —— 被滚出视口的「部分可见」行会把文字画到列表上方的筛选框上(`UiFillRect` 的大面积不透明矩形走 GDI `FillRect` 同理)。修法:`PaintUtil` 新增 **`UiTextClipped`**(同一 HDC `SaveDC` + `IntersectClipRect` + `RestoreDC`;`UiText` 转发 `clip=nullptr`,行为不变),`SignalPanel` 新增 `PaintRowText`(裁到 `m_listRect`)/ `FillRowRect`(夹到 `m_listRect`),分组行与信号行的全部文本与行底色均改走它们。② **边沿统计面板在不移动鼠标时显示冻结**:面板像素图层缓存键(`PanelSlotKey`)**不含**面板上显示的统计数字,而统计是**分帧推进**的中间结果 ⇒ 推进不会让键变化 ⇒ 面板一直贴旧像素 ⇒ 显示停在「统计中…」/中间值,**只有移动鼠标**(改变面板层序/悬停/鼠标坐标等其它键)才重绘并显示早已算完的正确结果(扫描推进本身没停)。修法:`PanelSlotKey` 内按面板实际显示的条目回调 `EdgeCountsFor`/`EdgeCenterCountsFor` 并把 `(rising, falling, complete)` mix 进键。验证:新增离屏 `.preflight/check`(`SW_HIDE` + 不注入真实输入 + `BELOW_NORMAL`)**39/39 通过**(连跑两次稳定):`[A]` 40 信号滚轮下滚后「列表上方区域逐像素 0 变化」且「列表内部仍有变化」(修复前 **1571 像素被污染**)、`[B]` 3 信号 × 300 万采样下两种统计面板的**缓存键持续变化**且**无鼠标输入即完成**(修复前键不变化)、`[C]` 真实 `MF4-Record.MF4`(**124 信号 / 3 条时间轴**)同样 0 像素变化;**并做了 A/B**(`git stash` 回退修复后 `[A]/[B]` 如实失败);`make release` 零警告。 |
| 2026-10-15 | DeepSeek-V4.1-Flash(**起源设备**) | **全局 Y 轴自适应跟随网格结构 + 组名悬浮 + 稀疏信号默认点线 + 单点光标吸附 + 放大自动点线(详见 §9.29)**。① **新网格不继承全局 Y 轴自适应**:`RebuildLanes` 对新出现的信号返回默认 `LaneCarry()` ⇒ `fitYToSelection=false`;修法:重建前先算 `previousAllAutoFit`(非空且**每个**网格都开着 = 全局开启的正式定义),`LaneCarry` 加 `matched` 标记,新信号用它当默认;并新增 `ReapplyGlobalAutoFitIfEnabled()` 在 **`Layout()`(网格数变化)/`RefreshLanes()`(信号数变化)/`SplitLane()`** 之后立即重算 ⇒ 新勾选(按**当前视界**自适应)、取消显示(剩余信号重新收敛)、拆分(每份各自自适应)三项全覆盖。② **组名悬浮**:新增 `SignalPanel::StickyGroupRowIndex()` + `PaintStickyGroupRow()`(不透明底 + 底部边界线),`RowRect` 加 `m_rowYOverrideActive/Y` 覆盖行顶部(行内所有几何的唯一来源);判据 = 分组行已在列表上边界之上且本组最后一行仍在列表内;常规循环**跳过**被钉住的行(否则画两次)。③ **稀疏信号默认样式**:新增 `ApplySparseDefaultStyle`(1 个有效点 → 点线 + **最大线宽 10px**;2..16 → 点线 + 2px),在 `BuildSignalGroup`/`InsertDerivedSignal` 两处调用;线宽常量统一到 `DataModel.hpp`(`kSignalLineWidthMin/Max`)并被 SignalPanel 滑块引用;`ComputeStats` 输出 `firstFiniteIndex`、`SignalData` 新增 `singlePointTime`。④ **单点光标吸附**:`SnapTimeToSample` 新增半径分支(`kCursorSnapPointRadiusPixels=8` 逻辑像素)吸附 `singlePointTime`,半径外自由移动(**吸附而非卡住**)。⑤ **放大自动点线**:`SignalStyle` 新增 `autoScatterActive/autoScatterSavedMode/autoScatterUserOverride`,`AxisSparseInView`(间距 > 半个绘图区宽度,与吸附上门限同源,**单点轴不算稀疏**)+ `UpdateAutoScatterStyles()`(在 `SetTimeView` 等四处调用)⇒ 进入阈值自动切点线、退出阈值恢复原样式,**用户手动改过则不恢复**(判定必须放在恢复判断**之前**,否则用户改完紧接着缩出去会被覆盖)。验证:新增离屏 `.preflight/check` **65/65 通过**(连跑 3 次稳定,覆盖 7 组断言;含“钉住行与自然位置逐像素一致”“单点吸附半径内外”“用户手动改样式后退出不覆盖”),**A/B**:临时禁用六个机制后 **21 项如实失败**;`make release` 零警告。 |
| 2026-10-15 | DeepSeek-V4.1-Flash(**起源设备**) | **组名平滑悬浮 + 单点信号初始视图 + 光标吸附统一判定(自动点线同源)(详见 §9.30)**。① **组名悬浮平滑化**:位置改为 `StickyGroupRowTop = min(列表上边界, 本组最后一行下边界 − 组名高度)` ⇒ 组名随最后一行**自然滚出**(不再突然消失),且下边界永不越过下一个分组行的自然位置 ⇒ **不再遮住下一组名**(旧实现固定钉在列表上边界会把下一组名盖住,让别人以为后面的信号属于上一组);**折叠的分组不悬浮**(随滚动自然划过);底部边界线只在真的“浮”起来时才画。② **单点信号初始视图**:新增 `PlotView::FitSinglePointSignal()`(无任何信号被勾选、勾选的第一个信号只有 1 个有效点时生效)—— 数据点居中、**时间跨度 1s**、**Y 跨度 1**,并置 `autoRange=false`/`fitYToSelection=false`;`Application::SynchronizeView` 首次勾选时先试它、失败才 `FitAll()`;`AutoRangeLaneToView` 的退化区间改为 `PadRange` 同款留白 `max(1,|v|×0.1)`。③ **吸附判定统一**(用户给出的唯一判据):新增 `GridMinorCellCount()`(网格一横排小格数 N,与网格绘制同源)+ `SignalSnapEnabled()`(**当且仅当**某信号在当前一屏内的数据点 `< N×4` 才开启吸附,随视界实时重算 ⇒ 疏密信号自适应),`SnapTimeToSample` **只按鼠标所在网格**判定、同网格内所有符合判定的信号数据点都参与、吸附半径 = 一个网格小格宽且不超过半个局部采样间距(吸附≠卡住);旧的 `SampleSpacingPixels`(6px 下门限+半屏宽上门限)**已删除**。④ **自动点线改为与吸附判定完全同源**(`UpdateAutoScatterStyles` 调 `SignalSnapEnabled`),旧 `AxisSparseInView` **已删除**;进入阈值自动切点线、不再满足自动恢复、用户手动改过则不恢复。⑤ **需求作废**:“2…16 个有效采样点默认点线 + 2px”**已取消**(只保留“1 个有效点 → 点线 + 最大线宽”);`SignalData::singlePointTime` 升级为 **`sparseTimes`/`sparseValues`**(有效点 ≤ 1024 时全部缓存,供吸附与单点视图使用)。验证:新增离屏 `.preflight/check` **55/55 通过**(连跑 3 次稳定;覆盖组名悬浮四种场景、单点初始视图、吸附判定/网格作用域/多信号/动态切换/单点半径、自动点线同源与用户覆盖);**A/B** 禁用三项机制 ⇒ **14 项如实失败**;`make release` 零警告。 |
| 2026-10-15 | DeepSeek-V4.1-Flash(**起源设备**) | **自适应范围/时机澄清 + 适应视图与 Y 轴自适应互不干扰 + 吸附跟随时间偏移(详见 §9.31)**。① **澄清“自适应范围/时机”**(修正记忆里“额外要求视界内至少有一个可见数据点”这句不完善的表述):**首个网格**显示内容时 **Y 轴与时间轴都**自适应;只有一个数据点的信号要**居中显示在网格里**(`FitSinglePointSignal`:居中 + 时间跨度 1s + Y 跨度 1);**后续新增网格只做 Y 轴自适应、不再调整时间轴**。② **「适应视图」与「Y 轴自适应」互相干扰**:根因是两条自适应路径**留白不同**(`PadRange` 6% vs `AutoRangeLaneToView` 5%)⇒ 视界未变时也会算出不同区间、网格被无意义重绘(实测切一次 **143513** 像素、反向 **137326** 像素)。修法:新增 `DataModel.hpp` 的 **`PadValueRange()`** 作为唯一留白实现(含退化区间 `max(1,|v|×0.1)`),`PadRange` 与 `AutoRangeLaneToView` 共用 ⇒ 四个步骤(适应视图→开自适应→关自适应→再适应视图)**区间逐位相同且绘图区逐像素 0 差异**。③ **「适应视图」在单点信号上让数据点“飞了”**:`FitAll()` 开头先试 `FitSinglePointSignal()`(仅一个网格 + 唯一可见信号为单点),与首次加载效果一致且幂等。④ **吸附跟随「调整 → 时间轴偏移」**:采样点画在 `time[k] + offsetTime` 处而光标存的是显示时间,旧实现按源时刻吸附 ⇒ 落到历史位置;修法:`SignalSnapEnabled`/`HasVisibleSample`/`NearestSnapSample`/`LocalSampleSpacing` 全部加 `timeShift`,查找时 `time - timeShift` 回源空间、返回时加回去,`UpdateAutoScatterStyles` 同步传 `offsetTime`。验证:离屏 `.preflight/check` **39/39 通过**(连跑 3 次稳定;含像素级零变化断言与“真缩放会重绘”对照组);**A/B** 回退三项修复 ⇒ **7 项如实失败**(含 143513/137326 像素重绘症状);`make release` 零警告。 |
| 2026-10-15 | DeepSeek-V4.1-Flash(**起源设备**) | **浮动面板不穿透 / 网格高亮改由信号列表触发 / 样式区「目标」行 / 联动平滑滚动(详见 §9.34)**。① **浮动面板上双击穿透到网格**:`PlotView::OnDoubleClick` 在所有下层命中都不成立时无条件 `FitAll()` ⇒ 在面板上双击会让网格莫名其妙自适应一次;修法:**最前面**用 `TopPanelAtPoint()` 判定并消费。② **网格变色高亮的触发来源重新定义**(旧行为"鼠标在网格里移动就高亮"**作废**):改为**信号列表 / 样式区「目标」行悬停到某条已绘制信号** ⇒ 该信号所在网格变色高亮**且该信号曲线本身也高亮**(新增 `m_externalHoverSignal`,与 `m_hoverSignal` 分开保存,由 `Render()` 每帧同步;新增 `EffectiveHoverLane()`)。③ **样式区「目标」行**(样式自定义区最上方):显示当前编辑的目标信号名称 + (已绘制时)名称**后方**的 GX 标签,悬停高亮并联动网格/曲线高亮,**单击不编辑、双击改名**。④ **联动平滑滚动**:绘图区选中信号(点图例 / 点曲线 / 双击图例)与样式区任何交互都触发 `ScrollToSignalAnimated()`(ease-out cubic、时长 `clamp(距离×0.35ms,130,300)`、**上行让出组行高度避免被悬浮组名遮挡**、滚轮/拖滚动条即取消)。验证:离屏 `.preflight/check` **73/73 通过**(连跑 3 次稳定;含浮动面板双击不移视界 vs 空白处双击必自适应的对照组、未勾选信号悬停不高亮、目标行 GX 标签位置与单击/双击区分、长列表 122 行平滑滚动与遮挡检查);**A/B** 回退六处实现 ⇒ **15 项如实失败**;`make release` 零警告、链接通过。 |
| 2026-10-15 | DeepSeek-V4.1-Flash(**起源设备**) | **「适应视图」边界规则撤回重做:单值散点让出半径 + 单点簇视图(详见 §9.33)**。① **初次自适应没有发生**(用户报告):`FitAll()` 在**没有任何可见信号**时也把 `ViewInitialized()` 置 true ⇒ 随后勾选第一个信号时 `SynchronizeView` 认为已初始化、**跳过自适应**(实测 MF4 视界停在 42.386s,数据点被推到右边缘外);修法:只有存在可见信号才置位,并让初次自适应与工具栏「适应视图」**共用 `FitAll()` 一条路径**。② **单值散点被裁掉半个**(用户报告):旧规则"左右各留一小格"在"单值 + 连续信号"下既不准也不够(`WIN_Motor_StallCurrentVolBrk` 只有 1 个有效点,正好落在边界上);按用户要求**撤回旧策略**(删除 `FitSinglePointLanes`/`TimeGridCellSeconds`/`LaneValueGridCell`/`FitSinglePointSignal`),改为:**任何一条边界被单值散点顶住就让出该散点的半径(+1px)**,只在散点**会被裁切**时才扩展(内部散点不动视界),边界不是单值散点时边界就落在信号点上。③ **特例**:全部可见数据点都是单值信号且时刻相同 ⇒ 时间轴以该时刻居中、缩放到**一大格 = 1s**(视界 10s,`NiceStep(10,10)==1.0`),每个网格的幅值区间保证其数据点都能完整画出(退化成单值时跨度 1 居中)。验证:离屏 `.preflight/check` **62/62 通过**(连跑 3 次稳定;含**真实文件** `TestData/MF4-Record.MF4`:`WIN_Motor_StallCurrentVolBrk`(1 点) + `m_MOT_Current_DCom[0]`(8475 点) 复现用户场景,散点圆心离绘图区左边界 16.5px、半径 15px ⇒ 完整可见);**A/B** 回退四处规则后 **20 项如实失败**(含"散点正好压在绘图区右边缘"、"初次视界 42.386s");`make release` 零警告、链接通过。 |
| 2026-10-15 | DeepSeek-V4.1-Flash(**起源设备**) | **信号面板滚动条跟随 / 工具栏连点丢状态 / 图层网格线步长 / 多网格单点自适应 / 单点信号拖拽(详见 §9.32)**。① **滚动条"卡住"**:`Application::WM_MOUSEMOVE` 的 `plotOnlyMove` 只看 `inPlot && m_mouseOverPlot`,拖滚动条进绘图区后判定"只影响绘图区"⇒ 面板滚动值变了却不重绘;修法:新增 `draggingNonPlot`(正在拖非绘图区组件时一律完整失效)。② **工具栏丢点击**:窗口类带 `CS_DBLCLKS` ⇒ 快速第二次按下被合成 `WM_LBUTTONDBLCLK` 而工具栏没处理(**按下丢失**);且 `HandleMouseMove` 只要不在工具栏上就 `OnMouseLeave()` ⇒ 清掉 `m_pressedIndex`(按住划出再回按钮松手无效);两处都已修。③ **网格线错位/丢失/闪烁**:`PaintLane` 的竖向主刻步长取自 `Tab().ViewMin/ViewMax`,而渲染信号图层时该区间已被改成**宽窗口**(≈2×)⇒ 图层辅助线步长 2×、位置与前台不同、判重判据也错位;修法:新增 `m_renderTimeStep`,由 `RenderLaneLayerRegion` 用**前台跨度**算好传入。④ **多网格单点「适应视图」**:新增 `FitSinglePointLanes()`(左右边界 = 最左/最右数据点 ± **一个网格小格**;逐网格 Y 自适应、各留**一个 Y 小格**;全部点同刻 ⇒ 跨度 1s 居中;单值网格 ⇒ Y 跨度 1 居中)。⑤ **单点信号**:`HitTestSignal` 新增单点分支 + 环状光晕 + 可拖拽换网格(悬停**不改光标**,按下后才改)。验证:离屏 `.preflight/check` **67/67 通过**(连跑 3 次稳定);**A/B** 回退六处修复 ⇒ **17 项如实失败**(含 [C] 辅助线 49px vs 25px、20 vs 40 条、最大偏移 539px);`make release` 零警告、链接通过。 |
--- ---
@@ -1680,6 +2176,143 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸(
⚠️ **`##TX` 文本块必须兼容两种写法**:规范的「U32 长度 + UTF-16/UTF-8」**与** CANape 的 ⚠️ **`##TX` 文本块必须兼容两种写法**:规范的「U32 长度 + UTF-16/UTF-8」**与** CANape 的
「无长度前缀的 NUL 结尾裸文本」;只按规范解析会得到空通道名。 「无长度前缀的 NUL 结尾裸文本」;只按规范解析会得到空通道名。
压缩块(`##DZ`/`##HL`/`##SD`)、`##DL` 数据列表、字符串/MIME 通道**未实现**(跳过该组)。 压缩块(`##DZ`/`##HL`/`##SD`)、`##DL` 数据列表、字符串/MIME 通道**未实现**(跳过该组)。
53. ⚠️ **滚动 / 裁剪容器内的 GDI 文本必须显式裁剪,不能依赖 GDI+ 的 `SetClip`**:
`UiText` 走 `DrawTextW`,**GDI 文本不受 `Graphics::SetClip` 约束** —— 信号列表滚动时,
被滚出视口的「部分可见」行会把组名/信号名画到上方的筛选框上(用户报告 MF4 多信号场景)。
✓ 用新增的 `PaintUtil::UiTextClipped(g, text, font, rect, argb, align, clipRect)`
(同一个 HDC 上 `SaveDC` + `IntersectClipRect` + `RestoreDC`,其余行为与 `UiText` 完全一致);
行内**纯色填充**还要把矩形**夹到**容器矩形(`UiFillRect` 对大面积不透明矩形走 GDI `FillRect`,同样越界)。
54. ⚠️ **浮动面板像素图层的缓存键必须包含「面板上显示的动态内容」**:
边沿统计(上升沿/下降沿)是**分帧推进**的中间结果;`PanelSlotKey` 不含这些数字时,
「推进 → 数字变化」不会让键变化 ⇒ 面板一直贴旧像素 ⇒ 用户在**不移动鼠标**时看到统计「永不刷新」
(移动鼠标改变其它键后才显示正确值;注意扫描推进本身并未停止)。✓ `PanelSlotKey` 内按面板
**实际显示的条目**调用 `EdgeCountsFor` / `EdgeCenterCountsFor`,把 `(rising, falling, complete)`
mix 进键。
⚠️ 推论(写显示类测试时必须注意):**任何“内容随时间变化”的面板都要把该内容计入键**,
否则既会显示陈旧像素,也会掩盖“自持帧推进”的真实行为;反之,**为使显示类用例真正有判别力**,
测试数据要避开“其它变量恰好也在改变缓存键”的情形(如数字位数变化改变面板宽度)。
55. ⚠️ **与「网格集合」相关的自动行为必须同时挂在 `Layout()` 与 `RefreshLanes()` 两处**:
`Application::SynchronizeView` 是**按“网格数量是否变化”二选一**的 ——
变化 → `Relayout()` → `PlotView::Layout()`;不变 → `PlotView::RefreshLanes()`。
只挂一处必然漏掉一半场景(本轮:新勾选信号走 `Layout`、取消勾选走 `RefreshLanes`)。
⚠️ 这两处逻辑**绝不能放进 `BuildLaneVisuals()`** —— 它在 `Paint` 里也会被调用
(配合 `m_renderMutex`),在里面自动改样式 / 自动算 Y 会污染渲染路径。
56. ⚠️ **「自动行为」一旦要尊重用户的手动覆盖,其判定必须在恢复动作之前**:
本轮「自动点线」用**状态自证**(本机制只写 `LINE_SCATTER` ⇒ 自动期间 `mode != LINE_SCATTER`
就是用户改的)。⚠️ 用户改完样式可能**紧接着就退出阈值**(例如直接点适应视图),
而那次调用看到的**已经是新视界**;若把判定写在“退出分支”里,恢复动作会先覆盖用户的修改。
✓ 先无条件判定覆盖 → 再决定是否恢复。
57. ⚠️ **滚动容器的行几何只能有一个来源**:`SignalPanel::RowRect()` 是行内所有派生矩形
(名称框 / ✕ / 徽标)的唯一来源,因此「组名悬浮」只需给它加一个顶部坐标覆盖
(`m_rowYOverrideActive/m_rowYOverrideY`)即可整体跟随;并且**常规行循环必须跳过被钉住的那一行**,
否则同一行会被画两次(一次在滚动位置、一次在顶部)。
58. ⚠️ **光标吸附的判定只有一套定义(§9.30)**:某信号在当前一屏内的数据点个数
`< N × 4`(N = `GridMinorCellCount()`,网格一横排的小格数,与网格绘制同源)时
才为它开启吸附;判据**随视界实时重算**(疏密变化的信号自动在稀疏区吸附、稠密区自由)。
⚠️ 可见性与点数一律按**显示时间**(`采样时刻 + offsetTime`)判断
(`SignalSnapEnabled/HasVisibleSample/NearestSnapSample/LocalSampleSpacing` 都带 `timeShift`);
视界内至少要有**一个可见数据点**(否则不开启)。
⚠️ 判定**只针对鼠标所在网格**(`SnapTimeToSample` 用 `LaneAtY(m_mouseY)`);
⚠️ 吸附半径 = **一个网格小格宽**,且**不超过半个局部采样间距**(吸附 ≠ 卡住光标)。
⚠️ 旧的 `SampleSpacingPixels`(6px 下门限 + 半屏宽上门限)**已删除,不要再恢复**;
且吸附必须落在**画面上**的数据点(源时刻 + `offsetTime`),不能吸到历史位置。
⚠️「放大自动切点线」必须**与吸附判定完全同源**(`UpdateAutoScatterStyles` 调
`SignalSnapEnabled`),不要再引入“采样间距 > 半屏宽”之类的独立判据。
⚠️ `SignalData` 用 **`sparseTimes` / `sparseValues`**(有效点 ≤ 1024 时缓存全部有效点)
支撑吸附与单点初始视图;**不要**在鼠标移动时扫描整条采样数组。
⚠️ `LocalSampleSpacing` 对 `finiteCount <= 1` 必须返回 0(数据点间距无定义),
否则单点信号永远吸不住。
⚠️「放大自动切点线」必须**与吸附判定完全同源**(`UpdateAutoScatterStyles` 调
`SignalSnapEnabled`,同样传入 `offsetTime`),不要再引入“采样间距 > 半屏宽”之类的独立判据。
⚠️ 判定与查找都在**显示时间**空间:`NearestSnapSample` 内部按 `timeShift` 换算回源空间、
返回时再加回去,因此存在「调整 → 时间轴偏移」时也能吸到画面上的数据点。
59. ⚠️ **「2…16 个有效采样点用点线 + 2px」的需求已作废**(用户 2026-10-15 明确取消)。
仍保留:**只有一个有效采样点** → 「点线」+ **最大线宽**;其余信号保持默认「线段 + 1px」。
需要“提示数据点位置”的场景一律由 §9.30 的“放大自动点线”承担(同源判定)。
60. ⚠️ **自适应的范围与时机(用户 2026-10-15 澄清,这是“可见”要求的准确含义)**:
* **首个网格**显示内容时,要对它做完整自适应 —— **Y 轴与时间轴都**匹配自己的数据范围;
* **只有一个数据点**的信号必须显示在网格**中间**(否则时间轴覆盖整条时间列、
Y 区间退化成几乎为 0,数据点被挤到边缘甚至看不出位置)。
✓ 走 `FitSinglePointSignal()`(数据点居中、**时间跨度 1s**、**Y 轴跨度 1**);
⚠️ **工具栏「适应视图」也要走这条路**(仅一个网格 + 唯一可见信号为单点时),
否则用户点“适应视图”后数据点会“飞了”。
* **后续新增网格**只做 **Y 轴**自适应,**不再调整时间轴**(`ReapplyGlobalAutoFitIfEnabled`
挂在 `Layout`/`RefreshLanes`/`SplitLane`,这些路径都不碰 `Tab().ViewMin/ViewMax`)。
* 退化区间(恒定值 / 单点)的留白统一为 `max(1, |值|×0.1)`。
62. ⚠️ **幅值留白只能有一个来源**:`PadValueRange()`(`DataModel.hpp`,6%)。
整段自适应(`ComputeLaneRange`)与「选区 Y 轴自适应」(`AutoRangeLaneToView`)
**必须共用它** —— 两处各自硬编码(6% / 5%)时,「点过适应视图后再打开 Y 轴自适应」
会算出不同区间 ⇒ 网格被无意义地重绘一次(实测 ~14 万像素)。
反向(关掉自适应再点适应视图)同样会变。统一后这两种操作在视界未变时**像素级零变化**。
63. ⚠️ **组名悬浮的位置公式(勿改回“固定钉在列表上边界”)**:
`min(列表上边界, 本组最后一行下边界 − 组名高度)` ⇒
组名随最后一行**平滑滚出**(不突然消失),且下边界永不越过下一个分组行的自然位置
⇒ **不可能遮住下一个组名**;**折叠的分组不悬浮**(随滚动自然划过)。
行几何仍只由 `RowRect()` 提供(用 `m_rowYOverrideActive/m_rowYOverrideY` 覆盖顶部坐标)。
64. ⚠️ **图层(宽基底)里的任何刻度步长都必须由"前台视界"显式传入**:
渲染图层时 `RenderLaneLayerRegion` 会把 `Tab().ViewMin/ViewMax` 改成**宽窗口**(≈2× 前台),
因此 `PaintLane` **绝不能**拿它去 `NiceStep` —— 竖向必须优先用 `m_renderTimeStep`
(由 `RenderLaneLayerRegion` 用前台跨度算好传入),横向必须用 `m_wideTickSpan/m_wideTickHeight`。
违反时:图层里的辅助线步长 2×、位置与前台不同、"与主刻重合则跳过"的判据也错位
⇒ 拖拽期间网格线错位/整条丢失,且在图层帧与结算帧之间**闪烁**(实测 49px vs 25px、20 vs 40 条)。
65. ⚠️ **拖动"非绘图区组件"(信号面板滚动条等)时,鼠标移动不得只失效绘图区**:
`plotOnlyMove` 必须先排除 `draggingNonPlot = (m_activeTarget != NONE) && != PLOT && != EDIT_FIELD`。
否则鼠标一进绘图区就只重绘绘图区,而组件自身的滚动偏移已经变了 ⇒ 滑块/滚动条"卡在原地不动"。
66. ⚠️ **工具栏必须把 `WM_LBUTTONDBLCLK` 当作"按下"处理**(窗口类带 `CS_DBLCLKS`:快速连点时第 2 次及以后
的按下会被合成成它,不处理就**完全丢失那一次点击**);并且**按住期间不得**因 `OnMouseLeave()` 清掉按下态
(`m_pressedIndex`)—— 否则"按住按钮划出边缘再回来松手"不会触发任何动作。
67. ⚠️ **单点信号(`finiteCount == 1`)必须在命中测试 / 光晕 / 拖拽虚影里单独处理**:
它没有折线可测距,`HitTestSignal` 要用 `SinglePointScreenPos()` + 容差 `max(常规容差, 点绘制半径)`;
悬停时**不改鼠标形状**(`QueryCursor` 不查 `HitTestSignal`),只在按下拖拽后改(`IDC_SIZEALL`)。
68. ~~⚠️ **多网格单点信号的「适应视图」规则**(用户 2026-10-15 明确给出):左/右边界 = 所有网格中
最左/最右数据点时间再向外偏**一个网格小格**的时间跨度;每个网格的 Y 按自身数据点自适应,
若该网格内存在"时间一致但取值不一致"的多个单点 ⇒ 纵向上下各留**一个 Y 小格**;
全部点在同一时刻 ⇒ 时间跨度 **1s** 居中于该时刻(与"首个单点信号初始视图"对齐);
某网格只有一个取值 ⇒ 该网格 Y 跨度 **1** 居中。仅在**所有可见信号都是单点**时启用。~~
⚠️ **本规则已作废(§9.33,用户 2026-10-15 第五轮明确要求撤回重做)**:
改为“边界处的单值散点让出**该散点的半径**”(而不是一小格),且不再要求“全部都是单点”
才生效 —— 任何一条边界被单值散点顶住都要让位。现行规则见 69–71。
69. ⚠️ **「适应视图」的边界规则(§9.33,用户 2026-10-15 第五轮重新定义)**:
常规全局适应后,**若某条边界(时间轴左 / 右、每个网格幅值上 / 下)被一个单值信号的
数据点顶住,则把该边界向外让出该散点的半径(+1px)**,使其完整显示在网格内;
**只在散点会被裁切时才扩展**(内部散点不动视界);边界不是单值散点时边界就落在该
信号点上(时间轴不留余量;幅值仍走 `PadValueRange` 的 6%,**没有**改动它)。
判断依据必须是**信号自身的数据点**,不能用“轴的范围”——MF4 里单值信号与连续信号
常共用同一通道组(同一条轴),用轴判断会误判。
70. ⚠️ **特例:全部可见数据点都是单值信号且时刻完全相同**(`FitSinglePointCluster`):
时间轴**以该时刻居中**、缩放到 **一大格 = 1s**(视界 = `kTimeTickTarget × 1s` = 10s,
此时 `NiceStep(10,10) == 1.0`);每个网格的幅值区间以“能完整画出该网格内的全部数据点”
为准(退化成单值时以**跨度 1 居中**)。旧规则“跨度 1s 居中”“各留一小格”**已作废**。
71. ⚠️ **`FitAll()` 在没有任何可见信号时不得置位 `Tab().ViewInitialized()`**:
否则用户先连点几次「适应视图」(此时无信号被勾选)再勾选第一个信号,
`Application::SynchronizeView` 会**跳过初次自适应**,视界停在“无信号”那次的整段范围上,
单值信号的数据点既不居中、还会有一半被裁在网格外(用户 2026-10-15 报告)。
另外「适应视图」两条路径(初次自适应 / 工具栏按钮)必须**只有一条实现**(都走 `FitAll`)。
72. ⚠️ **上层元素的交互不得穿透到下层**(用户 2026-10-15 第六轮明确要求):
实验性功能的浮动面板浮在网格之上,`PlotView::OnDoubleClick` 必须**最先**用
`TopPanelAtPoint()` 判定并消费 —— 旧实现在"差值标签/光标标签/光标线/图例都不命中"时
无条件 `FitAll()`,于是在面板上双击会让网格莫名其妙地自适应一次。
新增/修改任何"空白处点击/双击"的默认行为时,都要先过一遍所有更上层的元素
(浮动面板、菜单、拾色器、装饰层)。
73. ⚠️ **网格变色高亮的触发来源只有两个**(旧行为"鼠标在网格里移动就高亮"**已作废**):
① 鼠标悬停在实验性功能浮动面板上 ⇒ 高亮该面板所属网格;
② 信号列表 / 样式区「目标」行**悬停到某条已绘制信号** ⇒ 高亮它所在网格**并且
该信号曲线本身也高亮**(`PaintSignalHalo`)。
`PlotView` 的 `m_externalHoverSignal` 必须与 `m_hoverSignal` **分开保存**
(绘图区 `OnMouseLeave` 会清掉后者,而列表悬停时鼠标不在绘图区内),
并由 `Application::Render()` **每帧**同步(覆盖 WM_MOUSELEAVE / 数据变化 / 切标签页)。
`SignalPanel::HoveredSignal()` 不能在 `InvalidateRows()` 之后继续有效(必须一并作废)。
74. ⚠️ **样式区「目标」行**(用户 2026-10-15 第六轮要求):位于样式自定义区**最上方**,
显示当前正在编辑的目标信号名称,**已绘制到网格时**在名称**后方**加 GX 标签;
悬停名称/GX 同时触发绘图区网格高亮 + 曲线高亮;**单击不进入编辑**(只做联动滚动),
**双击才进入改名**(`StyleEditField::SIGNAL_NAME` + `m_editRow < 0` 表示目标信号)。
它的名称宽度与"是否有 GX 标签"都可能在**未重排**时变化 ⇒ 必须用
`UpdateTargetRowGeometry()` 按需重算(带缓存键),不能只在 `LayoutStyleControls()` 里算一次。
75. ⚠️ **联动滚动必须是平滑动画,且目标不得被悬浮组名遮挡**(用户 2026-10-15 第六轮要求):
`SignalPanel::ScrollToSignalAnimated()` + `AdvanceScrollAnimation()`
(ease-out cubic;时长 = `clamp(距离×0.35ms, 130, 300)`;宿主 16ms 定时器驱动)。
向上滚动时顶部要**让出一个组行高度**(悬浮组名只可能是目标自己那个分组的组名,
因为分组在列表里连续排列)。用户滚轮 / 拖滚动条必须**立即取消**动画。
### 11.3 离屏验证方法学(本机偏好,强烈建议沿用) ### 11.3 离屏验证方法学(本机偏好,强烈建议沿用)
+106 -1
View File
@@ -5,6 +5,7 @@
#include <cmath> #include <cmath>
#include <cstddef> #include <cstddef>
#include <cstdint> #include <cstdint>
#include <limits>
#include <string> #include <string>
#include <atomic> #include <atomic>
#include <vector> #include <vector>
@@ -22,9 +23,82 @@ namespace jdi
{ {
LINE = 0, /**< 相邻采样点之间以插值直线连接。 */ LINE = 0, /**< 相邻采样点之间以插值直线连接。 */
SCATTER = 1, /**< 仅绘制采样点散点。 */ SCATTER = 1, /**< 仅绘制采样点散点。 */
LINE_SCATTER = 2 /**< 同时绘制插值直线与采样点散点。 */ LINE_SCATTER = 2 /**< 同时绘制插值直线与采样点散点(界面文案「点线」)。 */
}; };
/**
* @brief 样式允许的线宽范围(逻辑像素)。
*
* 信号面板的线宽滑块与所有线宽写入路径都以此为唯一依据;
* 稀疏信号(见 `kSparseSignalSampleLimit`)的初始线宽取其中的较大值。
*/
constexpr float kSignalLineWidthMin = 0.5f;
constexpr float kSignalLineWidthMax = 10.0f;
/**
* @brief **只含一个有效采样点**的信号的初始线宽(取样式允许的最大线宽)。
*
* 单个数据点在「点线」模式下就是一个圆点,半径与线宽成正比;取最大值才能看清。
* ⚠️ 曾经还对「有效采样点 2…16」的信号也设了「点线 + 2px」的默认样式,
* 该需求已**作废**(用户 2026-10-15 明确取消):放大后自动切「点线」的机制
* 已经覆盖了那个场景,无需再改默认样式。
*/
constexpr float kSinglePointSignalLineWidth = kSignalLineWidthMax;
/**
* @brief 「稀疏信号」缓存有效采样点时刻/取值的上限(有效点不超过该值才缓存)。
*
* 光标吸附需要“当前视界内有多少个数据点”以及“离光标最近的那个数据点”。
* 对有效点很少的信号(典型:单点信号、只有零星采样的列)事先把它们的时刻与取值缓存下来,
* 判定与查找都退化成二分 → 不必在鼠标移动时扫描整条采样数组(千万级信号无法承受)。
* 1024 个有效点 × 16 字节 ≈ 16 KB/条,可忽略。
*/
constexpr std::size_t kSparseTimesLimit = 1024;
/** @brief 网格 Y 轴自适应时为幅值区间补充的留白比例(相对区间跨度)。 */
constexpr double kLaneRangePadRatio = 0.06;
/**
* @brief 为幅值区间补充留白 —— **网格 Y 轴自适应的唯一留白规则**。
*
* ⚠️ 必须只有这一份实现:整段自适应(`TabConfig::ComputeLaneRange`)与
* 「选区 Y 轴自适应」(`PlotView::AutoRangeLaneToView`)都要调用它。
* 过去两处各自硬编码(6% / 5%),于是「点过适应视图后再打开 Y 轴自适应」会因为
* 留白不同而算出**不一样的区间** → 网格被无意义地重绘一次;反向(关掉自适应再点适应视图)
* 同样会变。统一留白后这两种操作在视界未变时都是**像素级无变化**。
*
* 区间退化(恒定值 / 单点信号)时给一个可读的跨度 `max(1, |值|×0.1)`,
* 而不是“几乎为 0”的区间(那会让幅值网格线挤成一团、数据点也看不出位置)。
*
* @param [IN,OUT] low 区间下限。 @param [IN,OUT] high 区间上限。
*/
inline void PadValueRange(double& low, double& high)
{
if (!std::isfinite(low) || !std::isfinite(high))
{
low = 0.0;
high = 1.0;
return;
}
if (low > high)
{
std::swap(low, high);
}
const double span = high - low;
if (span <= 0.0)
{
const double magnitude = std::max(1.0, std::fabs(low) * 0.1);
low -= magnitude;
high += magnitude;
return;
}
const double pad = span * kLaneRangePadRatio;
low -= pad;
high += pad;
}
/** /**
* @brief 绘图区拖拽缩放模式(由工具栏按钮切换)。 * @brief 绘图区拖拽缩放模式(由工具栏按钮切换)。
*/ */
@@ -146,6 +220,22 @@ namespace jdi
{ {
return std::fabs(offsetY) > 1.0e-9f || std::fabs(offsetTime) > 1.0e-15; return std::fabs(offsetY) > 1.0e-9f || std::fabs(offsetTime) > 1.0e-15;
} }
/**
* @brief 「放大到采样点稀疏时自动启用点线」的运行时状态。
*
* 视界放大到采样点间距超过半个绘图区宽度时(此时光标也会吸附数据点),
* 线段模式会退化成「看不出数据点在哪」,因此自动切到「点线」给用户提示;
* 视界回退到阈值之外(如适应视图)时自动恢复**原来的**绘制方式。
*
* * `autoScatterActive` —— 当前绘制方式是否由该机制自动设置的;
* * `autoScatterSavedMode` —— 自动启用**之前**的绘制方式(退出时恢复它);
* * `autoScatterUserOverride` —— 自动启用期间用户**手动**改过绘制方式:
* 此时退出不再恢复(用户的选择优先,用户明确要求)。
*/
bool autoScatterActive = false;
DrawMode autoScatterSavedMode = DrawMode::LINE;
bool autoScatterUserOverride = false;
}; };
/** /**
@@ -308,6 +398,21 @@ namespace jdi
double maxValue = 0.0; /**< 有限采样值最大值。 */ double maxValue = 0.0; /**< 有限采样值最大值。 */
std::size_t finiteCount = 0; /**< 有限采样点数量。 */ std::size_t finiteCount = 0; /**< 有限采样点数量。 */
/**
* @brief 有效采样点很少时的**全部有效点**(按时间升序;有效点很多时为空)。
*
* 仅当 `finiteCount <= kSparseTimesLimit` 时在加载/插入信号时就地填充,用于:
* * 光标吸附:直接二分得到“视界内有几个数据点”“离光标最近的数据点”;
* * 单点信号的初始视图:Centering 需要那个点的时刻与取值。
* ⚠️ 不要在鼠标移动时改成扫描整条采样数组来找有效点(千万级信号承受不住)。
*
* 说明:`sparseTimes.size() == 1` 即“只有一个数据点的信号”。
*/
std::vector<double> sparseTimes;
/** @brief `sparseTimes` 对应的取值(同序)。 */
std::vector<double> sparseValues;
/** /**
* @brief 粗粒度包络层:每 coarseStride 个采样一组的 min/max。 * @brief 粗粒度包络层:每 coarseStride 个采样一组的 min/max。
* *
+19
View File
@@ -152,6 +152,25 @@ namespace jdi
void UiText(gd::Graphics& g, const std::wstring& text, HFONT font, const gd::RectF& rect, std::uint32_t argb, void UiText(gd::Graphics& g, const std::wstring& text, HFONT font, const gd::RectF& rect, std::uint32_t argb,
TextAlign align = TextAlign::LEFT); TextAlign align = TextAlign::LEFT);
/**
* @brief 在**显式裁剪矩形**内绘制单行文本(除裁剪范围外与 `UiText` 完全一致)。
*
* `UiText` 走 `DrawTextW`,而 **GDI 文本不遵循 GDI+ 的 `SetClip`**:滚动类列表在滚出
* 视口的行上仍会把文字画到相邻区域(例如把组名/信号名画到上方筛选框上)。
* 本函数在同一个 HDC 上做 `SaveDC` + `IntersectClipRect` 并在结束后完整还原,
* 因此对齐、垂直居中与省略号规则都与 `UiText` 一模一样,只是多了 GDI 级裁剪。
*
* @param [IN,OUT] g 绘图上下文。
* @param [IN] text 文本内容。
* @param [IN] font 字体句柄。
* @param [IN] rect 文本布局矩形。
* @param [IN] argb 文本颜色。
* @param [IN] align 水平对齐方式。
* @param [IN] clipRect 裁剪矩形(超出部分不绘制)。
*/
void UiTextClipped(gd::Graphics& g, const std::wstring& text, HFONT font, const gd::RectF& rect,
std::uint32_t argb, TextAlign align, const gd::RectF& clipRect);
/** /**
* @brief 测量单行文本的像素宽度(无需 GDI+ 上下文,可在布局阶段调用)。 * @brief 测量单行文本的像素宽度(无需 GDI+ 上下文,可在布局阶段调用)。
* @param [IN] font 字体句柄。 * @param [IN] font 字体句柄。
+114 -21
View File
@@ -26,30 +26,16 @@ namespace jdi
/** /**
* @brief 为网格范围补充留白,避免波形紧贴边界。 * @brief 为网格范围补充留白,避免波形紧贴边界。
*
* ⚠️ 实际规则实现在 `DataModel.hpp` 的 `PadValueRange()`:它同时被
* 「选区 Y 轴自适应」使用,两处必须逐位一致(否则两种自适应会算出不同区间)。
*
* @param [IN, OUT] yMin 范围下限。 * @param [IN, OUT] yMin 范围下限。
* @param [IN, OUT] yMax 范围上限。 * @param [IN, OUT] yMax 范围上限。
*/ */
void PadRange(double& yMin, double& yMax) void PadRange(double& yMin, double& yMax)
{ {
if (!std::isfinite(yMin) || !std::isfinite(yMax)) PadValueRange(yMin, yMax);
{
yMin = 0.0;
yMax = 1.0;
return;
}
const double span = yMax - yMin;
if (span <= 0.0)
{
const double magnitude = std::max(1.0, std::fabs(yMin) * 0.1);
yMin -= magnitude;
yMax += magnitude;
return;
}
const double pad = span * 0.06;
yMin -= pad;
yMax += pad;
} }
/** /**
@@ -64,6 +50,7 @@ namespace jdi
// 随网格携带的“网格自身状态”(按信号继承):重建网格时不能丢。 // 随网格携带的“网格自身状态”(按信号继承):重建网格时不能丢。
struct LaneCarry struct LaneCarry
{ {
bool matched = false; /**< 是否找到该信号原本所在的网格(false = 新出现的信号)。 */
bool fitYToSelection = false; bool fitYToSelection = false;
bool edgeCount = false; bool edgeCount = false;
double edgeValue = 0.0; double edgeValue = 0.0;
@@ -90,10 +77,25 @@ namespace jdi
// 先记下每个信号原来所在网格的网格自身状态: // 先记下每个信号原来所在网格的网格自身状态:
// 勾选可见性会重建网格,若不携带,用户刚打开的开关会被悄悄清掉。 // 勾选可见性会重建网格,若不携带,用户刚打开的开关会被悄悄清掉。
//
// ⚠️ 同时记下「重建之前是否**每个**网格都开着 `fitYToSelection`」:
// 那就是「全局 Y 轴自适应」的正式定义。新勾选信号产生的**新网格**必须继承它,
// 否则全局开关看着是开的、新网格却不按当前视界自适应(用户报告的缺陷)。
bool previousAllAutoFit = !lanes.empty();
for (const Lane& lane : lanes)
{
if (!lane.fitYToSelection)
{
previousAllAutoFit = false;
break;
}
}
std::vector<std::pair<SignalRef, LaneCarry>> previousCarry; std::vector<std::pair<SignalRef, LaneCarry>> previousCarry;
for (const Lane& lane : lanes) for (const Lane& lane : lanes)
{ {
LaneCarry carry; LaneCarry carry;
carry.matched = true;
carry.fitYToSelection = lane.fitYToSelection; carry.fitYToSelection = lane.fitYToSelection;
carry.edgeCount = lane.edgeCount; carry.edgeCount = lane.edgeCount;
carry.edgeValue = lane.edgeValue; carry.edgeValue = lane.edgeValue;
@@ -132,10 +134,11 @@ namespace jdi
} }
return LaneCarry(); return LaneCarry();
}; };
const auto applyCarry = [&inheritedCarry](Lane& lane, const SignalRef& ref) const auto applyCarry = [&inheritedCarry, previousAllAutoFit](Lane& lane, const SignalRef& ref)
{ {
const LaneCarry carry = inheritedCarry(ref); const LaneCarry carry = inheritedCarry(ref);
lane.fitYToSelection = carry.fitYToSelection; // 新出现的信号(之前没有网格):继承「全局 Y 轴自适应」是否开启。
lane.fitYToSelection = carry.matched ? carry.fitYToSelection : previousAllAutoFit;
lane.edgeCount = carry.edgeCount; lane.edgeCount = carry.edgeCount;
lane.edgeValue = carry.edgeValue; lane.edgeValue = carry.edgeValue;
lane.edgeValueValid = carry.edgeValueValid; lane.edgeValueValid = carry.edgeValueValid;
@@ -355,6 +358,87 @@ namespace jdi
std::size_t finiteCount = 0; /**< 有限采样点数量。 */ std::size_t finiteCount = 0; /**< 有限采样点数量。 */
}; };
/**
* @brief 为**只有一个有效采样点**的信号设置初始绘制样式。
*
* 单个数据点在「线段」模式下完全画不出来,因此初始就用「点线」并取**最大线宽**
* (点线模式下数据点是一个圆点,半径与线宽成正比)。
*
* ⚠️ 曾经还对「有效采样点 2…16」的信号也改用「点线 + 2px」—— 该需求已**作废**
*(用户 2026-10-15 明确取消:放大后自动切点线的机制已覆盖那个场景)。
*
* 只在**新建信号**时调用(用户改过样式后不再覆盖)。
*
* @param [IN] finiteCount 有效采样点数量。
* @param [IN,OUT] style 待初始化的显示外观。
*/
void ApplySinglePointDefaultStyle(std::size_t finiteCount, SignalStyle& style)
{
if (finiteCount == 1)
{
style.mode = DrawMode::LINE_SCATTER;
style.lineWidth = kSinglePointSignalLineWidth;
}
}
/**
* @brief 缓存「有效采样点很少」的信号的**全部有效点**(时刻 + 取值,按时间升序)。
*
* 供光标吸附(二分查找最近数据点、O(log n) 判定稀疏)与单点信号的初始视图使用。
* 有效点很多时(> `kSparseTimesLimit`)不做任何缓存(用不到,也没必要占内存)。
*
* @param [IN] axis 该信号使用的时间轴。
* @param [IN,OUT] signal 目标信号。
*/
void CacheSparseSamples(const TimeAxis& axis, SignalData& signal)
{
signal.sparseTimes.clear();
signal.sparseValues.clear();
if (signal.finiteCount == 0 || signal.finiteCount > kSparseTimesLimit)
{
return;
}
const std::size_t count = std::min(axis.time.size(), signal.values.size());
signal.sparseTimes.reserve(signal.finiteCount);
signal.sparseValues.reserve(signal.finiteCount);
for (std::size_t index = 0; index < count; ++index)
{
const double value = signal.values[index];
const double time = axis.time[index];
if (!std::isfinite(value) || !std::isfinite(time))
{
continue;
}
signal.sparseTimes.push_back(time);
signal.sparseValues.push_back(value);
}
// 非单调时间轴:按时间排序,保证后续二分查找合法
//(单调时间轴的采样本身就是升序,无需排序)。
if (!axis.monotonic && signal.sparseTimes.size() > 1)
{
std::vector<std::size_t> order(signal.sparseTimes.size());
for (std::size_t index = 0; index < order.size(); ++index)
{
order[index] = index;
}
std::stable_sort(order.begin(), order.end(), [&signal](std::size_t a, std::size_t b)
{ return signal.sparseTimes[a] < signal.sparseTimes[b]; });
std::vector<double> times;
std::vector<double> values;
times.reserve(order.size());
values.reserve(order.size());
for (const std::size_t index : order)
{
times.push_back(signal.sparseTimes[index]);
values.push_back(signal.sparseValues[index]);
}
signal.sparseTimes = std::move(times);
signal.sparseValues = std::move(values);
}
}
/** /**
* @brief 统计一列采样的最小/最大值与有效点数量,并顺带构建粗粒度包络层。 * @brief 统计一列采样的最小/最大值与有效点数量,并顺带构建粗粒度包络层。
* *
@@ -569,6 +653,8 @@ namespace jdi
signal.minValue = stats.minValue; signal.minValue = stats.minValue;
signal.maxValue = stats.maxValue; signal.maxValue = stats.maxValue;
signal.finiteCount = stats.finiteCount; signal.finiteCount = stats.finiteCount;
// 有效点很少时缓存全部有效点(光标吸附 / 单点信号初始视图用)。
CacheSparseSamples(group.AxisAt(signal.axisIndex), signal);
// 显示外观随信号一起初始化:调色板按「全局信号序号」取色, // 显示外观随信号一起初始化:调色板按「全局信号序号」取色,
// 使同一信号在任意标签页中都保持同一默认颜色。 // 使同一信号在任意标签页中都保持同一默认颜色。
@@ -592,6 +678,8 @@ namespace jdi
signal.style.opacity = 1.0f; signal.style.opacity = 1.0f;
signal.style.lineWidth = 1.0f; signal.style.lineWidth = 1.0f;
signal.style.mode = DrawMode::LINE; signal.style.mode = DrawMode::LINE;
// 只有一个有效采样点的信号改用「点线」+ 最大线宽,否则根本看不见。
ApplySinglePointDefaultStyle(stats.finiteCount, signal.style);
} }
group.signals.push_back(std::move(signal)); group.signals.push_back(std::move(signal));
@@ -656,6 +744,8 @@ namespace jdi
signal.minValue = stats.minValue; signal.minValue = stats.minValue;
signal.maxValue = stats.maxValue; signal.maxValue = stats.maxValue;
signal.finiteCount = stats.finiteCount; signal.finiteCount = stats.finiteCount;
// 有效点很少时缓存全部有效点;派生信号沿用源信号的时间轴。
CacheSparseSamples(group.AxisAt(signal.axisIndex), signal);
// 2. 显示外观按「全局信号序号」取调色板颜色(与普通新信号完全一致的规则)。 // 2. 显示外观按「全局信号序号」取调色板颜色(与普通新信号完全一致的规则)。
const int insertPos = afterSignalIndex + 1; const int insertPos = afterSignalIndex + 1;
@@ -676,6 +766,9 @@ namespace jdi
signal.style.color = palette[slot]; signal.style.color = palette[slot];
signal.style.customColor = palette[(slot + 1) % count]; signal.style.customColor = palette[(slot + 1) % count];
} }
// 采样点极少的派生信号同样改用「点线」并加粗(与普通加载路径同一规则)。
ApplySinglePointDefaultStyle(stats.finiteCount, signal.style);
} }
group.signals.insert(group.signals.begin() + insertPos, std::move(signal)); group.signals.insert(group.signals.begin() + insertPos, std::move(signal));
+93 -41
View File
@@ -355,51 +355,103 @@ namespace jdi
g.DrawLine(&pen, x1, y1, x2, y2); g.DrawLine(&pen, x1, y1, x2, y2);
} }
namespace
{
/**
* @brief `UiText` 与 `UiTextClipped` 的公共实现。
*
* ⚠️ `clip == nullptr` 时**不加任何 GDI 裁剪**(与历史行为完全一致);
* 提供裁剪时在同一个 HDC 上 `SaveDC`/`IntersectClipRect` 并完整还原 ——
* 这是唯一能让 `DrawTextW` 受限于指定范围的办法(GDI+ 的 `SetClip` 对 GDI 文本无效)。
*
* @param [IN,OUT] g 绘图上下文。
* @param [IN] text 文本内容。
* @param [IN] font 字体句柄。
* @param [IN] rect 文本布局矩形。
* @param [IN] argb 文本颜色。
* @param [IN] align 水平对齐方式。
* @param [IN] clip 裁剪矩形(nullptr = 不裁剪)。
*/
void DrawUiText(gd::Graphics& g, const std::wstring& text, HFONT font, const gd::RectF& rect,
std::uint32_t argb, TextAlign align, const gd::RectF* clip)
{
if (text.empty() || font == nullptr || rect.Width <= 0.0f || rect.Height <= 0.0f)
{
return;
}
if (clip != nullptr && (clip->Width <= 0.0f || clip->Height <= 0.0f))
{
return; // 裁剪区为空:任何文字都落在界外
}
HDC hdc = g.GetHDC();
if (hdc == nullptr)
{
return;
}
// GDI 文本不受 GDI+ 裁剪区约束:需要限制范围时显式裁剪,结束前完整还原 DC 状态。
int saved = 0;
if (clip != nullptr)
{
saved = SaveDC(hdc);
if (saved != 0)
{
IntersectClipRect(hdc, static_cast<int>(std::floor(clip->X)),
static_cast<int>(std::floor(clip->Y)),
static_cast<int>(std::ceil(clip->X + clip->Width)),
static_cast<int>(std::ceil(clip->Y + clip->Height)));
}
}
HGDIOBJ oldFont = SelectObject(hdc, font);
const int oldMode = SetBkMode(hdc, TRANSPARENT);
const COLORREF oldColor = SetTextColor(hdc, ToColorRef(argb));
RECT rc;
rc.left = static_cast<LONG>(std::floor(rect.X));
rc.top = static_cast<LONG>(std::floor(rect.Y));
rc.right = static_cast<LONG>(std::ceil(rect.X + rect.Width));
rc.bottom = static_cast<LONG>(std::ceil(rect.Y + rect.Height));
UINT flags = DT_SINGLELINE | DT_VCENTER | DT_NOPREFIX | DT_END_ELLIPSIS;
switch (align)
{
case TextAlign::CENTER:
flags |= DT_CENTER;
break;
case TextAlign::RIGHT:
flags |= DT_RIGHT;
break;
case TextAlign::LEFT:
default:
flags |= DT_LEFT;
break;
}
DrawTextW(hdc, text.c_str(), static_cast<int>(text.size()), &rc, flags);
SetTextColor(hdc, oldColor);
SetBkMode(hdc, oldMode);
SelectObject(hdc, oldFont);
if (saved != 0)
{
RestoreDC(hdc, saved);
}
g.ReleaseHDC(hdc);
}
} // namespace
void UiText(gd::Graphics& g, const std::wstring& text, HFONT font, const gd::RectF& rect, std::uint32_t argb, void UiText(gd::Graphics& g, const std::wstring& text, HFONT font, const gd::RectF& rect, std::uint32_t argb,
TextAlign align) TextAlign align)
{ {
if (text.empty() || font == nullptr || rect.Width <= 0.0f || rect.Height <= 0.0f) DrawUiText(g, text, font, rect, argb, align, nullptr);
{ }
return;
}
HDC hdc = g.GetHDC(); void UiTextClipped(gd::Graphics& g, const std::wstring& text, HFONT font, const gd::RectF& rect,
if (hdc == nullptr) std::uint32_t argb, TextAlign align, const gd::RectF& clipRect)
{ {
return; DrawUiText(g, text, font, rect, argb, align, &clipRect);
}
HGDIOBJ oldFont = SelectObject(hdc, font);
const int oldMode = SetBkMode(hdc, TRANSPARENT);
const COLORREF oldColor = SetTextColor(hdc, ToColorRef(argb));
RECT rc;
rc.left = static_cast<LONG>(std::floor(rect.X));
rc.top = static_cast<LONG>(std::floor(rect.Y));
rc.right = static_cast<LONG>(std::ceil(rect.X + rect.Width));
rc.bottom = static_cast<LONG>(std::ceil(rect.Y + rect.Height));
UINT flags = DT_SINGLELINE | DT_VCENTER | DT_NOPREFIX | DT_END_ELLIPSIS;
switch (align)
{
case TextAlign::CENTER:
flags |= DT_CENTER;
break;
case TextAlign::RIGHT:
flags |= DT_RIGHT;
break;
case TextAlign::LEFT:
default:
flags |= DT_LEFT;
break;
}
DrawTextW(hdc, text.c_str(), static_cast<int>(text.size()), &rc, flags);
SetTextColor(hdc, oldColor);
SetBkMode(hdc, oldMode);
SelectObject(hdc, oldFont);
g.ReleaseHDC(hdc);
} }
float UiMeasureTextWidth(HFONT font, const std::wstring& text) float UiMeasureTextWidth(HFONT font, const std::wstring& text)