diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index d38bec8..c6fcaf6 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -296,6 +296,41 @@ namespace jdi */ void ApplySelectionAutoFit(); + /** + * @brief 网格结构变化后,若「全局 Y 轴自适应」处于开启状态则重新做一次全局自适应。 + * + * 用户要求(2026-10-15): + * * 新勾选信号产生的**新网格**要按**当前视界**自适应(新网格继承开关由 + * `RebuildLanes` 负责,这里负责“立即算一次”); + * * 多信号网格内**取消显示**某条信号、或把网格**拆分**之后,剩余/新生的网格 + * 都要重新收敛到当前视界(原来的幅值区间是按旧信号集合算的)。 + * + * 判据与工具栏「Y 轴自适应」一致 = `AllLanesAutoFitToSelection()`(全部网格都开启)。 + */ + void ReapplyGlobalAutoFitIfEnabled(); + + /** + * @brief 依据「采样点是否稀疏」自动启用/恢复「点线」绘制方式。 + * + * 用户要求(2026-10-15):把波形放大到一定程度(此时光标会吸附数据点)后, + * 线段模式已经看不出数据点在哪 → 自动为该信号启用「点线」提示;视界回退到阈值 + * 之外(例如适应视图)时自动恢复**原来的**绘制方式。 + * + * ⚠️ 自动启用期间若用户**手动**改了绘制方式(`SignalStyle::autoScatterUserOverride` + * 由本函数自动识别:自动状态下 mode 不等于 `LINE_SCATTER` 即说明是用户改的), + * 退出时**不再**恢复,用户的选择优先。 + * + * @return 有任何信号的绘制方式被改变返回 true(调用方需作废缓存)。 + */ + bool UpdateAutoScatterStyles(); + + /** + * @brief 判断某个时间轴在当前视界下是否已经“稀疏到需要点线提示”。 + * @param [IN] axis 目标时间轴。 + * @return 采样点间距超过半个绘图区宽度返回 true。 + */ + bool AxisSparseInView(const TimeAxis& axis) const; + /** * @brief 把时间吸附到距今最近的数据采样点。 * diff --git a/Components/PlotView/Src/PlotView.cpp b/Components/PlotView/Src/PlotView.cpp index 044a2e1..7655165 100644 --- a/Components/PlotView/Src/PlotView.cpp +++ b/Components/PlotView/Src/PlotView.cpp @@ -123,6 +123,13 @@ namespace jdi } const float kCursorSnapMinPixels = 6.0f; /**< 光标吸附采样点的最小采样间隔(逻辑像素)。 */ + /** + * @brief 「只有一个数据点」的信号的光标吸附半径(逻辑像素)。 + * + * 这类信号没有“采样间距”可依据,因此改用**半径判据**:光标距该点不超过该半径时吸附过去, + * 其它区域自由移动(用户明确要求“吸附而不是卡住光标”,只是一个提示性响应)。 + */ + const float kCursorSnapPointRadiusPixels = 8.0f; const float kLaneLabelPad = 6.0f; /**< 网格内侧标尺带的内边距。 */ const float kLegendHeight = 19.0f; /**< 图例条目高度(逻辑像素)。 */ const float kLegendPad = 5.0f; /**< 图例相对网格左上角的内缩。 */ @@ -968,11 +975,27 @@ namespace jdi m_plotRect.Y, kScrollBarThickness * m_scale, m_plotRect.Height); BuildLaneVisuals(); + + // 布局变化带上网格结构变化(`Relayout` 在网格数量变化时被调用): + // 新的网格要按当前视界自适应(全局开关开启时),稀疏放大状态也可能随绘图区宽度变化。 + ReapplyGlobalAutoFitIfEnabled(); + if (UpdateAutoScatterStyles()) + { + InvalidateSceneCache(); + } } void PlotView::RefreshLanes() { BuildLaneVisuals(); + // 信号可见性变化后会走这里(网格数量不变的情形): + // ① 新出现的信号若继承到「Y 轴自适应」,需要立即按当前视界算一次; + // ② 多信号网格内取消显示某条信号后,剩余信号要重新收敛(旧区间含已移除的信号)。 + ReapplyGlobalAutoFitIfEnabled(); + if (UpdateAutoScatterStyles()) + { + InvalidateSceneCache(); + } } const SignalRef& PlotView::SelectedRef() const @@ -1208,6 +1231,15 @@ namespace jdi Tab().ViewMin() = low; Tab().ViewMax() = high; ClampTimeView(); + + // 视界是「自动点线提示」的驱动量:放大到采样点稀疏时自动切点线,退出阈值时恢复。 + // 所有缩放/平移都经本函数(视界唯一写入口),因此这是唯一需要的接入点; + // 平移时缩放等级不变,结果稳定,不会产生样式抖动。 + if (UpdateAutoScatterStyles()) + { + // 绘制方式属于内容指纹,场景/装饰缓存需立即作废(面板可能正显示旧样式)。 + InvalidateSceneCache(); + } } void PlotView::ZoomTimeAt(double factor, double anchorTime) @@ -1633,6 +1665,13 @@ namespace jdi // 网格数量变化 → 几何与滚动范围需要重建,静态场景缓存也要失效。 BuildLaneVisuals(); InvalidateSceneCache(); + // 拆出来的每个网格都继承了原网格的「Y 轴自适应」开关;全局开启时按当前视界重算一次 + // (原来的幅值区间是按“多条信号叠在一起”算的,拆开后每条信号都应铺满自己的网格)。 + ReapplyGlobalAutoFitIfEnabled(); + if (UpdateAutoScatterStyles()) + { + InvalidateSceneCache(); + } return true; } @@ -1704,6 +1743,39 @@ namespace jdi double bestDistance = std::numeric_limits::infinity(); double bestTime = 0.0; + // ---- 只有一个有效采样点的信号:**靠近才吸附**(半径判据)---- + // 这类信号根本算不出“采样间距”(间距无定义),旧逻辑会直接跳过 ⇒ 光标永远无法 + // 准确指到那个点(用户报告“无法准确指向数据点”)。 + // 现在改为:光标在半径内才吸附过去,其它区域完全自由移动(**吸附而非卡住**, + // 只是为用户移动光标时给出提示性响应)。 + const double viewSpan = viewMax - viewMin; + const double pointSnapRadiusTime = + (m_plotRect.Width > 1.0f) + ? (static_cast(kCursorSnapPointRadiusPixels) * static_cast(m_scale) / + static_cast(m_plotRect.Width) * viewSpan) + : 0.0; + if (pointSnapRadiusTime > 0.0) + { + for (const SignalGroup& group : m_dataSet->Groups()) + { + for (const SignalData& signal : group.signals) + { + const double only = signal.singlePointTime; + if (!std::isfinite(only) || only < viewMin || only > viewMax) + { + continue; + } + const double distance = std::fabs(only - time); + if (distance <= pointSnapRadiusTime && distance < bestDistance) + { + bestDistance = distance; + bestTime = only; + found = true; + } + } + } + } + for (const SignalGroup& group : m_dataSet->Groups()) { // 只有采样点在屏幕上足够稀疏时才吸附,否则缩小视图后光标会“跳格”。 @@ -3989,6 +4061,107 @@ namespace jdi } } + void PlotView::ReapplyGlobalAutoFitIfEnabled() + { + if (m_tab == nullptr || m_dataSet == nullptr) + { + return; + } + // 与工具栏「Y 轴自适应」按钮的勾选判据完全一致(全部网格都开启才叫“全局开启”)。 + if (!AllLanesAutoFitToSelection()) + { + return; + } + // 结构变化后重新匹配一次:新网格要按当前视界自适应, + // 移除信号后的网格也要按剩下信号重新收敛(旧幅值区间是按旧信号集合算的)。 + ApplyAllAutoFitToSelectionNow(); + } + + bool PlotView::AxisSparseInView(const TimeAxis& axis) const + { + if (m_plotRect.Width <= 1.0f) + { + return false; + } + // 单个采样点的时间轴不算“放大到稀疏”(它本来就处于最稀疏状态,默认样式已是点线); + // 若把这种情况也算进来,用户手动改成线段会被自动逻辑立刻改回去。 + double pixels = 0.0; + const double center = (Tab().ViewMin() + Tab().ViewMax()) * 0.5; + if (!SampleSpacingPixels(axis, center, pixels)) + { + return false; + } + // 与光标吸附的上门限同源:采样点间距超过半个绘图区宽度时,视界内只剩一两个采样点。 + return pixels > 0.5 * static_cast(m_plotRect.Width); + } + + bool PlotView::UpdateAutoScatterStyles() + { + if (m_dataSetWritable == nullptr || m_tab == nullptr) + { + return false; + } + + bool changed = false; + // 只处理当前标签页里**可见**的信号:绘制方式的自动切换是「视界」驱动的, + // 看不见的信号与当前视界无关(它们可见时会经 `RefreshLanes` 再判一次)。 + for (const Lane& lane : m_tab->Lanes()) + { + for (const SignalRef& ref : lane.signals) + { + if (!m_tab->IsVisible(ref)) + { + continue; + } + const TimeAxis* axis = m_dataSetWritable->Axis(ref); + SignalStyle* style = m_dataSetWritable->Style(ref); + if (axis == nullptr || style == nullptr) + { + continue; + } + + // ⚠️ **先**判定“用户是否手动改过绘制方式”:本机制只会写入「点线」, + // 所以自动启用期间 mode 不等于「点线」只可能是用户改的。 + // 这一步必须在下面的分支之前做 —— 用户改完样式可能**紧接着就缩出去** + // (例如直接点适应视图),那次调用的视界已经不稀疏,若等到那时才判定, + // 恢复逻辑会先把用户的修改覆盖掉(用户明确要求不能这样)。 + if (style->autoScatterActive && style->mode != DrawMode::LINE_SCATTER) + { + style->autoScatterUserOverride = true; + } + + if (AxisSparseInView(*axis)) + { + if (!style->autoScatterActive) + { + // 进入阈值:保存原绘制方式并自动切到「点线」提示用户数据点位置。 + style->autoScatterSavedMode = style->mode; + style->autoScatterActive = true; + style->autoScatterUserOverride = false; + if (style->mode != DrawMode::LINE_SCATTER) + { + style->mode = DrawMode::LINE_SCATTER; + changed = true; + } + } + } + else if (style->autoScatterActive) + { + // 退出阈值(例如适应视图):恢复自动启用之前的绘制方式; + // 但用户手动改过时**不恢复**(尊重用户的选择)。 + style->autoScatterActive = false; + if (!style->autoScatterUserOverride && style->mode != style->autoScatterSavedMode) + { + style->mode = style->autoScatterSavedMode; + changed = true; + } + style->autoScatterUserOverride = false; + } + } + } + return changed; + } + void PlotView::ShiftLaneRange(int laneIndex, double deltaValue) { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_lanes.size()) || diff --git a/Components/SignalPanel/Inc/SignalPanel.hpp b/Components/SignalPanel/Inc/SignalPanel.hpp index e9da74a..bd037d4 100644 --- a/Components/SignalPanel/Inc/SignalPanel.hpp +++ b/Components/SignalPanel/Inc/SignalPanel.hpp @@ -439,6 +439,30 @@ namespace jdi */ 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 把分组行「钉在列表顶部」绘制(组名跟随滚动,直到整组滚过去)。 + * + * 用户要求长列表(例如上百个信号的 MF4)滚动时组名一直可见。 + * ⚠️ 必须在常规行循环**之后**调用(它要盖在信号行之上),并且常规循环要**跳过** + * `row` 行,否则该行会被画两次。 + * + * @param [IN,OUT] g 绘图上下文。 + * @param [IN] row 需要悬浮的分组行下标(< 0 表示什么都不做)。 + */ + void PaintStickyGroupRow(gd::Graphics& g, int row); + /** * @brief 在列表区内绘制行内文本(**显式裁剪到列表矩形**)。 * @@ -678,6 +702,16 @@ namespace jdi bool m_scrollBarVisible = false; /**< 是否需要显示滚动条。 */ float m_scrollOffset = 0.0f; /**< 列表滚动偏移量。 */ + /** + * @brief 「组名悬浮」绘制用的行顶部坐标覆盖。 + * + * 置位时 `RowRect` 忽略滚动偏移、直接返回 `m_rowYOverrideY` 作为行顶部, + * 从而使分组行(以及它派生的名称框 / ✕ / 徽标)整体画在钉住的位置。 + * 只在使用期间短暂置位,绘制完立即复位(见 `PaintStickyGroupRow`)。 + */ + bool m_rowYOverrideActive = false; + float m_rowYOverrideY = 0.0f; + bool m_draggingScrollBar = false; /**< 是否正在拖拽滚动条。 */ int m_scrollGrabOffset = 0; /**< 拖拽时鼠标相对滑块顶部的偏移。 */ bool m_draggingOpacity = false; /**< 是否正在拖拽不透明度滑块。 */ diff --git a/Components/SignalPanel/Src/SignalPanel.cpp b/Components/SignalPanel/Src/SignalPanel.cpp index cd3e72f..804d20b 100644 --- a/Components/SignalPanel/Src/SignalPanel.cpp +++ b/Components/SignalPanel/Src/SignalPanel.cpp @@ -114,8 +114,10 @@ namespace jdi const int kAdjustAbsolute = 2; /**< 绝对值。 */ const int kAdjustCount = 3; /**< 按钮数量。 */ - const float kWidthMin = 0.5f; /**< 允许的最小线宽(px)。 */ - const float kWidthMax = 10.0f; /**< 允许的最大线宽(px)。 */ + // 线宽范围与「稀疏信号默认线宽」共用 `DataModel.hpp` 的常量,避免两处漂移 + //(稀疏信号初始化时用的最大线宽必须与这里滑块的上限完全一致)。 + const float kWidthMin = kSignalLineWidthMin; /**< 允许的最小线宽(px)。 */ + const float kWidthMax = kSignalLineWidthMax; /**< 允许的最大线宽(px)。 */ /** @brief 系数调整允许的绝对值上限(防止输入极端值把波形完全挤出可视区)。 */ const double kGainLimit = 10000.0; @@ -561,6 +563,15 @@ namespace jdi return gd::RectF(); } + // 「组名悬浮」期间把分组行钉在列表顶部绘制(见 `PaintStickyGroupRow`): + // 本函数是行内所有派生的唯一几何来源,因此只需在这里覆盖顶部坐标, + // 名称框 / ✕ / 徽标等位置会自动跟随。 + if (m_rowYOverrideActive) + { + return gd::RectF(m_listRect.X, m_rowYOverrideY, m_listRect.Width, + m_rows[static_cast(row)].height); + } + float top = m_listRect.Y - m_scrollOffset; for (int index = 0; index < row; ++index) { @@ -569,6 +580,70 @@ namespace jdi return gd::RectF(m_listRect.X, top, m_listRect.Width, m_rows[static_cast(row)].height); } + 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(row)].kind != RowKind::GROUP) + { + continue; + } + if (RowRect(row).Y >= m_listRect.Y) + { + // 分组行本身仍在列表内(或更靠下):无需悬浮。 + continue; + } + + // 本组的最后一行(列表行按分组连续排列)。 + int last = row; + for (int next = row + 1; next < rowCount; ++next) + { + if (m_rows[static_cast(next)].kind == RowKind::GROUP) + { + break; + } + last = next; + } + const gd::RectF lastRect = RowRect(last); + if (lastRect.Y + lastRect.Height <= m_listRect.Y) + { + // 整组(含最后一行)已全部滚过去:组名跟随一起向上,不再出现。 + continue; + } + return row; + } + return -1; + } + + void SignalPanel::PaintStickyGroupRow(gd::Graphics& g, int row) + { + if (row < 0 || row >= static_cast(m_rows.size()) || + m_rows[static_cast(row)].kind != RowKind::GROUP) + { + return; + } + + const float height = m_rows[static_cast(row)].height; + m_rowYOverrideActive = true; + m_rowYOverrideY = m_listRect.Y; + // 悬浮行必须是不透明的,否则下面的信号行会与组名叠在一起看不清; + // `FillRowRect` 会把矩形夹到列表矩形(GDI 填充不受 GDI+ 裁剪约束)。 + FillRowRect(g, gd::RectF(m_listRect.X, m_listRect.Y, m_listRect.Width, height), m_theme->panelBg); + PaintFileRow(g, row); + // 底部一条边界线,让悬浮行与它下面滚动的信号行区分开。 + UiDrawLine(g, m_listRect.X, m_listRect.Y + height - 0.5f, m_listRect.X + m_listRect.Width, + m_listRect.Y + height - 0.5f, m_theme->border, 1.0f); + m_rowYOverrideActive = false; + } + gd::RectF SignalPanel::FileCloseRect(int row) const { const gd::RectF rect = RowRect(row); @@ -980,8 +1055,15 @@ namespace jdi else { const int rowCount = static_cast(m_rows.size()); + // 先定出要「悬浮」的分组行(若有):常规循环跳过它,最后统一钉在列表顶部绘制, + // 否则它会先被画一次在滚动位置上、又被画一次在顶部(视觉上重叠两份)。 + const int stickyRow = StickyGroupRowIndex(rowCount); for (int row = 0; row < rowCount; ++row) { + if (row == stickyRow) + { + continue; + } const gd::RectF rect = RowRect(row); if (rect.Y + rect.Height < m_listRect.Y || rect.Y > m_listRect.Y + m_listRect.Height) { @@ -996,6 +1078,8 @@ namespace jdi PaintSignalRow(g, row); } } + + PaintStickyGroupRow(g, stickyRow); } g.Restore(saveState); diff --git a/Documents/agent_mem.md b/Documents/agent_mem.md index 6a850cf..9030719 100644 --- a/Documents/agent_mem.md +++ b/Documents/agent_mem.md @@ -1502,6 +1502,83 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸( --- +### 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 假差异)。 + +--- + ## 10. 变更日志(本文件维护记录) | 日期 | 设备/模型 | 摘要 | @@ -1543,6 +1620,7 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸( | 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(**起源设备**) | **信号列表滚动越界绘制 + 边沿统计面板「显示冻结」(详见 §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` 零警告。 | --- @@ -1745,6 +1823,29 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸( ⚠️ 推论(写显示类测试时必须注意):**任何“内容随时间变化”的面板都要把该内容计入键**, 否则既会显示陈旧像素,也会掩盖“自持帧推进”的真实行为;反之,**为使显示类用例真正有判别力**, 测试数据要避开“其它变量恰好也在改变缓存键”的情形(如数字位数变化改变面板宽度)。 +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. ⚠️ **「少数数据点」信号的默认样式**(用户明确要求):有效采样点 **1 个** → 「点线」+ + **最大线宽(10px)**(单点就是一个小圆点,半径与线宽成正比);**2 … 16 个** → 「点线」+ 2px; + 其余保持「线段」+ 1px。只作用于**新建**信号(不覆盖用户设置)。 + ⚠️ 线宽范围常量(`kSignalLineWidthMin/Max`)必须**单一来源**(`DataModel.hpp`), + 信号面板滑块要引用它,否则「最大线宽」会在两处漂移。 + ⚠️ 单点信号的光标吸附是**半径判据**(`kCursorSnapPointRadiusPixels = 8` 逻辑像素), + 半径外必须自由移动 —— 是「吸附」不是「卡住」;其时刻由 `SignalData::singlePointTime` + 在加载时缓存(不要改成鼠标移动时线性扫描采样)。 ### 11.3 离屏验证方法学(本机偏好,强烈建议沿用) diff --git a/Utilities/Inc/DataModel.hpp b/Utilities/Inc/DataModel.hpp index 9fdd42a..a4c3eab 100644 --- a/Utilities/Inc/DataModel.hpp +++ b/Utilities/Inc/DataModel.hpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -22,9 +23,37 @@ namespace jdi { LINE = 0, /**< 相邻采样点之间以插值直线连接。 */ SCATTER = 1, /**< 仅绘制采样点散点。 */ - LINE_SCATTER = 2 /**< 同时绘制插值直线与采样点散点。 */ + LINE_SCATTER = 2 /**< 同时绘制插值直线与采样点散点(界面文案「点线」)。 */ }; + /** + * @brief 样式允许的线宽范围(逻辑像素)。 + * + * 信号面板的线宽滑块与所有线宽写入路径都以此为唯一依据; + * 稀疏信号(见 `kSparseSignalSampleLimit`)的初始线宽取其中的较大值。 + */ + constexpr float kSignalLineWidthMin = 0.5f; + constexpr float kSignalLineWidthMax = 10.0f; + + /** + * @brief 「少数数据点信号」的有效采样点数量上限(含)。 + * + * 有效采样点不超过该值的信号,初始样式直接使用「点线」(`LINE_SCATTER`)并加粗到 + * `kSparseSignalLineWidth`:采样点这么少时线段模式几乎什么也看不见 + *(只有一个点时甚至完全画不出来)。正常密度的信号保持「线段 + 1px」。 + */ + constexpr std::size_t kSparseSignalSampleLimit = 16; + + /** @brief 稀疏信号(有效采样点 2 … `kSparseSignalSampleLimit`)的初始线宽。 */ + constexpr float kSparseSignalLineWidth = 2.0f; + + /** + * @brief **只含一个有效采样点**的信号的初始线宽(取样式允许的最大线宽)。 + * + * 单个数据点在「点线」模式下就是一个圆点,半径与线宽成正比;取最大值才能看清。 + */ + constexpr float kSinglePointSignalLineWidth = kSignalLineWidthMax; + /** * @brief 绘图区拖拽缩放模式(由工具栏按钮切换)。 */ @@ -146,6 +175,22 @@ namespace jdi { 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 +353,15 @@ namespace jdi double maxValue = 0.0; /**< 有限采样值最大值。 */ std::size_t finiteCount = 0; /**< 有限采样点数量。 */ + /** + * @brief 只含**一个**有效采样点时的该点时刻(不存在该情况时为 NaN)。 + * + * 在加载/插入信号时与 `ComputeStats` 同一趟扫描就地填充,供光标吸附使用: + * 只有一个数据点的信号,光标在其它区域自由移动、靠近该点时吸附过去 + *(吸附而非“卡住”)。没有这个缓存就只能在鼠标移动时线性扫描采样值,大信号下不可接受。 + */ + double singlePointTime = std::numeric_limits::quiet_NaN(); + /** * @brief 粗粒度包络层:每 coarseStride 个采样一组的 min/max。 * diff --git a/Utilities/Src/DataModel.cpp b/Utilities/Src/DataModel.cpp index 6e027dc..11a8afc 100644 --- a/Utilities/Src/DataModel.cpp +++ b/Utilities/Src/DataModel.cpp @@ -64,6 +64,7 @@ namespace jdi // 随网格携带的“网格自身状态”(按信号继承):重建网格时不能丢。 struct LaneCarry { + bool matched = false; /**< 是否找到该信号原本所在的网格(false = 新出现的信号)。 */ bool fitYToSelection = false; bool edgeCount = false; double edgeValue = 0.0; @@ -90,10 +91,25 @@ namespace jdi // 先记下每个信号原来所在网格的网格自身状态: // 勾选可见性会重建网格,若不携带,用户刚打开的开关会被悄悄清掉。 + // + // ⚠️ 同时记下「重建之前是否**每个**网格都开着 `fitYToSelection`」: + // 那就是「全局 Y 轴自适应」的正式定义。新勾选信号产生的**新网格**必须继承它, + // 否则全局开关看着是开的、新网格却不按当前视界自适应(用户报告的缺陷)。 + bool previousAllAutoFit = !lanes.empty(); + for (const Lane& lane : lanes) + { + if (!lane.fitYToSelection) + { + previousAllAutoFit = false; + break; + } + } + std::vector> previousCarry; for (const Lane& lane : lanes) { LaneCarry carry; + carry.matched = true; carry.fitYToSelection = lane.fitYToSelection; carry.edgeCount = lane.edgeCount; carry.edgeValue = lane.edgeValue; @@ -132,10 +148,11 @@ namespace jdi } 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); - lane.fitYToSelection = carry.fitYToSelection; + // 新出现的信号(之前没有网格):继承「全局 Y 轴自适应」是否开启。 + lane.fitYToSelection = carry.matched ? carry.fitYToSelection : previousAllAutoFit; lane.edgeCount = carry.edgeCount; lane.edgeValue = carry.edgeValue; lane.edgeValueValid = carry.edgeValueValid; @@ -353,8 +370,37 @@ namespace jdi double minValue = 0.0; /**< 有限采样值最小值。 */ double maxValue = 0.0; /**< 有限采样值最大值。 */ std::size_t finiteCount = 0; /**< 有限采样点数量。 */ + std::size_t firstFiniteIndex = 0; /**< 第一个有限采样点的下标(无有效值时无意义)。 */ }; + /** + * @brief 按有效采样点数量设置「稀疏信号」的**初始**绘制样式。 + * + * 有效采样点极少时线段模式几乎什么也看不见(只有一个点时更是完全画不出东西), + * 因此这类信号初始就用「点线」并适当加粗: + * * **只有一个有效采样点** → 点线 + `kSinglePointSignalLineWidth`(最大线宽):那个点本身 + * 就是一个圆点,半径与线宽成正比,不取最大值根本看不清; + * * **有效采样点 2 … `kSparseSignalSampleLimit`** → 点线 + `kSparseSignalLineWidth`(2px)。 + * + * 其余信号保持既有默认(线段 + 1px)。只在**新建信号**时调用(用户改过样式后不再覆盖)。 + * + * @param [IN] finiteCount 有效采样点数量。 + * @param [IN,OUT] style 待初始化的显示外观。 + */ + void ApplySparseDefaultStyle(std::size_t finiteCount, SignalStyle& style) + { + if (finiteCount == 1) + { + style.mode = DrawMode::LINE_SCATTER; + style.lineWidth = kSinglePointSignalLineWidth; + } + else if (finiteCount <= kSparseSignalSampleLimit) + { + style.mode = DrawMode::LINE_SCATTER; + style.lineWidth = kSparseSignalLineWidth; + } + } + /** * @brief 统计一列采样的最小/最大值与有效点数量,并顺带构建粗粒度包络层。 * @@ -389,6 +435,10 @@ namespace jdi const double value = values[index]; if (std::isfinite(value)) { + if (stats.finiteCount == 0) + { + stats.firstFiniteIndex = index; // 供「单点信号」缓存其时刻 + } ++stats.finiteCount; low = std::min(low, value); high = std::max(high, value); @@ -569,6 +619,15 @@ namespace jdi signal.minValue = stats.minValue; signal.maxValue = stats.maxValue; signal.finiteCount = stats.finiteCount; + // 只含一个有效采样点时缓存它的时刻(光标吸附用)。 + if (stats.finiteCount == 1) + { + const TimeAxis& ownAxis = group.axes[std::min(axisIndex, group.axes.size() - 1)]; + if (stats.firstFiniteIndex < ownAxis.time.size()) + { + signal.singlePointTime = ownAxis.time[stats.firstFiniteIndex]; + } + } // 显示外观随信号一起初始化:调色板按「全局信号序号」取色, // 使同一信号在任意标签页中都保持同一默认颜色。 @@ -592,6 +651,8 @@ namespace jdi signal.style.opacity = 1.0f; signal.style.lineWidth = 1.0f; signal.style.mode = DrawMode::LINE; + // 采样点极少的信号(含只有一个点)改用「点线」并加粗,否则几乎不可见。 + ApplySparseDefaultStyle(stats.finiteCount, signal.style); } group.signals.push_back(std::move(signal)); @@ -656,6 +717,15 @@ namespace jdi signal.minValue = stats.minValue; signal.maxValue = stats.maxValue; signal.finiteCount = stats.finiteCount; + // 只含一个有效采样点时缓存它的时刻(光标吸附用);派生信号沿用源信号的时间轴。 + if (stats.finiteCount == 1 && !group.axes.empty()) + { + const TimeAxis& ownAxis = group.AxisAt(signal.axisIndex); + if (stats.firstFiniteIndex < ownAxis.time.size()) + { + signal.singlePointTime = ownAxis.time[stats.firstFiniteIndex]; + } + } // 2. 显示外观按「全局信号序号」取调色板颜色(与普通新信号完全一致的规则)。 const int insertPos = afterSignalIndex + 1; @@ -676,6 +746,9 @@ namespace jdi signal.style.color = palette[slot]; signal.style.customColor = palette[(slot + 1) % count]; } + + // 采样点极少的派生信号同样改用「点线」并加粗(与普通加载路径同一规则)。 + ApplySparseDefaultStyle(stats.finiteCount, signal.style); } group.signals.insert(group.signals.begin() + insertPos, std::move(signal));