diff --git a/Components/Application/Src/Application.cpp b/Components/Application/Src/Application.cpp index 8c2b7fd..3597232 100644 --- a/Components/Application/Src/Application.cpp +++ b/Components/Application/Src/Application.cpp @@ -627,7 +627,12 @@ namespace jdi } else if (!tab.ViewInitialized()) { - m_plot.FitAll(); + // 「单点信号」有专门的初始视图(数据点居中、时间跨度 1s、幅值跨度 1): + // 整段自适应对它不适用(时间轴会覆盖整条时间列、幅值区间退化成几乎为 0)。 + if (!m_plot.FitSinglePointSignal()) + { + m_plot.FitAll(); + } } const bool laneCountChanged = (laneCount != previousLaneCount); diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index c6fcaf6..12678f4 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -310,11 +310,12 @@ namespace jdi void ReapplyGlobalAutoFitIfEnabled(); /** - * @brief 依据「采样点是否稀疏」自动启用/恢复「点线」绘制方式。 + * @brief 依据「光标吸附判定」自动启用/恢复「点线」绘制方式。 * - * 用户要求(2026-10-15):把波形放大到一定程度(此时光标会吸附数据点)后, - * 线段模式已经看不出数据点在哪 → 自动为该信号启用「点线」提示;视界回退到阈值 - * 之外(例如适应视图)时自动恢复**原来的**绘制方式。 + * 用户要求(2026-10-15):**仅在满足光标吸附判定的信号上**生效 —— + * 即“一屏内数据点少于 N×4”(见 `SignalSnapEnabled`)时自动切到「点线」提示 + * 用户数据点的位置;一旦不再满足(适应视图、或把视界从稀疏区移到稠密区) + * 自动恢复**原来的**绘制方式。 * * ⚠️ 自动启用期间若用户**手动**改了绘制方式(`SignalStyle::autoScatterUserOverride` * 由本函数自动识别:自动状态下 mode 不等于 `LINE_SCATTER` 即说明是用户改的), @@ -325,24 +326,76 @@ namespace jdi bool UpdateAutoScatterStyles(); /** - * @brief 判断某个时间轴在当前视界下是否已经“稀疏到需要点线提示”。 - * @param [IN] axis 目标时间轴。 - * @return 采样点间距超过半个绘图区宽度返回 true。 + * @brief 网格底衬**一横排的小格数量 N**(与 `PaintLane` 画出来的网格同源)。 + * + * 时间刻度用 `NiceStep(span, kTimeTickTarget)` 定主刻度、再五等分 ⇒ + * 不管缩放等级如何,一横排总是差不多同样多的小格。 + * + * @return 小格数量(无法判定时返回 0)。 */ - bool AxisSparseInView(const TimeAxis& axis) const; + int GridMinorCellCount() const; + + /** + * @brief 判断某信号在当前视界下是否**开启光标吸附**。 + * + * 用户 2026-10-15 的定义:**当且仅当**该信号在“当前一屏”(当前视界)内的数据点 + * 个数 `< N × 4`(N = `GridMinorCellCount()`)时开启吸附。 + * 判据随视界实时重算 ⇒ 疏密变化的信号在稀疏区自动吸附、稠密区自动自由移动。 + * + * ⚠️ 额外要求视界内**至少有一个可见数据点**:数据点全在视界之外的信号既没有可吸附 + * 的对象、也没有可提示的内容(否则它会被判成“0 < N×4 稀疏”而白切一次「点线」)。 + * + * @param [IN] axis 该信号使用的时间轴。 + * @param [IN] signal 目标信号。 + * @return 开启吸附返回 true。 + */ + bool SignalSnapEnabled(const TimeAxis& axis, const SignalData& signal) const; + + /** + * @brief 判断某信号在给定可见下标区间内是否存在**落在视界内**的有效采样点。 + * @param [IN] axis 该信号使用的时间轴。 + * @param [IN] signal 目标信号。 + * @param [IN] begin 可见下标区间起点。 @param [IN] end 终点(不含)。 + * @return 存在返回 true。 + */ + bool HasVisibleSample(const TimeAxis& axis, const SignalData& signal, std::size_t begin, + std::size_t end) const; + + /** + * @brief 找某信号**可见区间内**离给定时刻最近的有效采样点。 + * + * 稀疏信号(有效点已缓存)用二分;一般信号从最近下标向两侧**有界**探测 + *(NaN 只出现在缺少数据的区段)。视界之外的采样点不是合法落点,一律排除。 + * + * @param [IN] axis 该信号使用的时间轴。 + * @param [IN] signal 目标信号。 + * @param [IN] time 目标时刻。 + * @param [OUT] candidate 最近的可见有效采样点时刻。 + * @return 找到返回 true。 + */ + bool NearestSnapSample(const TimeAxis& axis, const SignalData& signal, double time, + double& candidate) const; + + /** + * @brief 取某时刻附近的**局部采样间距**(时间单位,用于限制吸附半径)。 + * @param [IN] axis 该信号使用的时间轴。 + * @param [IN] signal 目标信号。 + * @param [IN] time 目标时刻。 + * @param [OUT] spacing 相邻采样点的时间间隔(无法判定时为 0)。 + */ + void LocalSampleSpacing(const TimeAxis& axis, const SignalData& signal, double time, + double& spacing) const; /** * @brief 把时间吸附到距今最近的数据采样点。 * - * 仅在横向放大到采样点间距足够大(可精确分辨)时生效, - * 避免缩小时光标出现“抖动”难以微调。 - * - * ⚠️ 吸附有**上下两个门限**: - * * 下门限:采样点间距 ≥ `kCursorSnapMinPixels`(缩小时不吸附,否则难以微调); - * * 上门限:视界内至少能容纳 **2 个**采样点(`pixels <= plotWidth / 2`)。 - * 视界比采样间隔还窄时“最近采样点”在视界之外,吸附会把光标一把甩到视界边缘 - * 并钉住(表现为“一拖光标就飞到视界边缘、跟不动鼠标”)。 - * * 并且只接受**落在当前可见区间内**的采样点。 + * 判定与半径(用户 2026-10-15 统一后的定义): + * * **判定只针对鼠标所在的网格**:该网格内的信号各自按 `SignalSnapEnabled` 判定, + * 符合条件的信号的数据点全部参与吸附;网格内没有符合条件的信号时完全自由移动; + * * 同一网格内有多条信号时,所有符合条件的数据点都参与比较; + * * 吸附半径 = **一个网格小格的宽度**(与判定同源),且**不超过半个局部采样间距** + * ⇒ 数据点很稀疏(一屏只有一两个点)时光标在点与点之间仍可自由移动, + * 而不是被“死死吸住”(吸附只是提示性响应,不是卡住光标)。 * * @param [IN] time 原始时间。 * @param [OUT] snapped 吸附后的时间。 @@ -350,15 +403,6 @@ namespace jdi */ 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 判断坐标是否落在绘图区范围内。 * @param [IN] x 客户区横坐标。 @@ -380,6 +424,19 @@ namespace jdi */ void FitAll(); + /** + * @brief 「单点信号」的初始视图:把唯一的数据点居中,时间跨度 1s、幅值跨度 1。 + * + * 用户要求(2026-10-15):当前**没有任何信号被勾选**、而勾选的第一个信号 + * **只有一个有效数据点**时,整段自适应并不能把它的状态显示出来 + *(时间轴会覆盖整条时间列、幅值区间退化成几乎为 0)。 + * 因此这种情况改为:数据点居中放置,**时间轴跨度 = 一个大格 = 1s**、 + * **Y 轴跨度 = 一个大格 = 1**。 + * + * @return 已按“单点信号”初始化返回 true(调用方无需再做 `FitAll`)。 + */ + bool FitSinglePointSignal(); + /** * @brief 以给定倍率缩放时间轴(以视图中心为锚点)。 * @param [IN] factor 缩放倍率,大于 1 表示放大。 diff --git a/Components/PlotView/Src/PlotView.cpp b/Components/PlotView/Src/PlotView.cpp index 7655165..f09f498 100644 --- a/Components/PlotView/Src/PlotView.cpp +++ b/Components/PlotView/Src/PlotView.cpp @@ -122,14 +122,40 @@ namespace jdi g.FillPolygon(&brush, arrow, 3); } - const float kCursorSnapMinPixels = 6.0f; /**< 光标吸附采样点的最小采样间隔(逻辑像素)。 */ /** - * @brief 「只有一个数据点」的信号的光标吸附半径(逻辑像素)。 + * @brief 光标吸附的**判定系数**:一屏内的数据点少于 `网格小格数 × 该系数` 时才开启吸附。 * - * 这类信号没有“采样间距”可依据,因此改用**半径判据**:光标距该点不超过该半径时吸附过去, - * 其它区域自由移动(用户明确要求“吸附而不是卡住光标”,只是一个提示性响应)。 + * 用户 2026-10-15 明确定义:网格底衬一横排有 N 个小格(见 `GridMinorCellCount`), + * **当且仅当**某信号在当前视界内的数据点个数 `< N × 4` 时为它开启光标吸附。 + * 该判据随视界实时重算 ⇒ 疏密变化的信号在稀疏区自动吸附、稠密区自动自由移动。 */ - const float kCursorSnapPointRadiusPixels = 8.0f; + const int kSnapVisibleCellsPerPoint = 4; + + /** + * @brief 吸附半径(以**网格小格**为单位):一个数据点只用其**一个小格宽**内的移动来吸。 + * + * 吸附半径 = 一个网格小格的宽度(与判定用同一套网格定义),且**不超过半个采样间距** + * ——后者保证点很稀疏时(例如一屏只有一两个点)光标在点与点之间仍可自由移动, + * 而不是被“死死吸住”(用户明确要求:吸附只是提示性响应)。 + */ + const double kSnapRadiusCells = 1.0; + + /** + * @brief 一般信号查找“离光标最近的有效采样点”时向两侧探测的最大步数。 + * + * 采样值里的 NaN 只出现在缺少数据的区段(例如多时间轴 CSV 的较短轴的尾部), + * 因此从最近下标向外少量探测即可找到有效点;探测失败就不吸附(宁可自由移动)。 + */ + const int kSnapSampleProbeSteps = 64; + + /** + * @brief 判定“可见数据点是否足够少”时允许扫描的最大样本数(相对阈值的倍数)。 + * + * 判定只需知道“有效点是否少于阈值”,因此扫到阈值个有效点就早退; + * 但连续 NaN 不会被计入,因此再给一个**有界**的扫描窗口防止极端情况下的长循环。 + */ + const std::size_t kSnapScanWindowFactor = 64; + const float kLaneLabelPad = 6.0f; /**< 网格内侧标尺带的内边距。 */ const float kLegendHeight = 19.0f; /**< 图例条目高度(逻辑像素)。 */ const float kLegendPad = 5.0f; /**< 图例相对网格左上角的内缩。 */ @@ -1346,6 +1372,104 @@ namespace jdi BuildLaneVisuals(); } + bool PlotView::FitSinglePointSignal() + { + if (m_tab == nullptr || m_dataSet == nullptr || m_dataSet->Empty()) + { + return false; + } + + // 只在「当前没有任何信号被勾选,勾选的第一个信号只有一个有效数据点」时生效 + // (用户 2026-10-15 明确要求):此时整段自适应会让时间轴覆盖整条时间列、 + // Y 轴退化成几乎为 0 的区间,数据点既不在中心也看不出来。 + SignalRef only; + int visibleCount = 0; + for (const Lane& lane : m_tab->Lanes()) + { + for (const SignalRef& ref : lane.signals) + { + if (!m_tab->IsVisible(ref)) + { + continue; + } + only = ref; + if (++visibleCount > 1) + { + return false; // 多个可见信号:按常规的整段自适应处理 + } + } + } + if (visibleCount != 1) + { + return false; + } + + const SignalData* signal = m_dataSet->Resolve(only); + const TimeAxis* axis = m_dataSet->Axis(only); + if (signal == nullptr || axis == nullptr || signal->finiteCount != 1) + { + return false; + } + + // 取出那个唯一数据点的时刻与取值(加载时已缓存为稀疏点)。 + double pointTime = std::numeric_limits::quiet_NaN(); + double pointValue = std::numeric_limits::quiet_NaN(); + if (!signal->sparseTimes.empty() && signal->sparseTimes.size() == signal->sparseValues.size()) + { + pointTime = signal->sparseTimes.front(); + pointValue = signal->sparseValues.front(); + } + else + { + // 兜底:逐个采样找那个有效点(单点信号的采样数组必然很短)。 + const std::size_t count = std::min(axis->time.size(), signal->values.size()); + for (std::size_t index = 0; index < count; ++index) + { + if (std::isfinite(signal->values[index]) && std::isfinite(axis->time[index])) + { + pointTime = axis->time[index]; + pointValue = signal->values[index]; + break; + } + } + } + if (!std::isfinite(pointTime) || !std::isfinite(pointValue)) + { + return false; + } + + // 时间轴:跨度 = **一个大格(1s)**,数据点居中。 + const double halfTimeSpan = 0.5; + SetTimeView(pointTime - halfTimeSpan, pointTime + halfTimeSpan); + Tab().ViewInitialized() = true; + Tab().LaneScroll() = 0.0f; + Tab().ZoomMode() = PlotZoomMode::NONE; + m_rubberRect = gd::RectF(); + m_dragMode = PlotDragMode::NONE; + m_rubberAxis = PlotRubberAxis::UNDECIDED; + SetHint(std::wstring()); + + // Y 轴:跨度 = **一个大格(1)**,数据点居中。 + // ⚠️ 必须同时关掉 `autoRange`(否则会被整段自适应覆盖)与 `fitYToSelection` + //(选区自适应会按可见区间算出退化区间;全局 Y 轴自适应开启时那条路径也会生效)。 + const double halfValueSpan = 0.5; + for (Lane& lane : m_tab->Lanes()) + { + if (lane.signals.size() != 1 || !(lane.signals.front() == only)) + { + continue; + } + lane.autoRange = false; + lane.fitYToSelection = false; + lane.yMin = pointValue - halfValueSpan; + lane.yMax = pointValue + halfValueSpan; + } + + InvalidateSceneCache(); + BuildLaneVisuals(); + return true; + } + void PlotView::DisableCursor() { Tab().CursorMode() = PlotCursorMode::NONE; @@ -1684,134 +1808,342 @@ namespace jdi return m_tab->Lanes()[static_cast(laneIndex)].signals.size(); } - bool PlotView::SampleSpacingPixels(const TimeAxis& axis, double time, double& pixels) const - { pixels = 0.0; + int PlotView::GridMinorCellCount() const + { + // 与实际画出来的网格用同一套算法(`PaintLane`/`PaintTimeAxis` 的 `NiceStep` + 五等分), + // 因此这里得到的“一横排小格数”就是用户看到的格子数。 + if (m_plotRect.Width <= 1.0f) + { + return 0; + } + const double span = Tab().ViewMax() - Tab().ViewMin(); + if (!(span > 0.0) || !std::isfinite(span)) + { + return 0; + } + const double majorStep = NiceStep(span, kTimeTickTarget); + const double minorStep = majorStep / static_cast(kMinorDivisions); + if (!(minorStep > 0.0) || !std::isfinite(minorStep)) + { + return 0; + } + const double cells = span / minorStep; + if (!std::isfinite(cells) || cells <= 0.0) + { + return 0; + } + // 视界两端各可能只覆盖半格,四舍五入到最近的整格数即可(判据只用到它 × 4)。 + return std::max(1, static_cast(std::llround(cells))); + } - const std::vector& samples = axis.time; - if (samples.size() < 2 || m_plotRect.Width <= 1.0f) + bool PlotView::SignalSnapEnabled(const TimeAxis& axis, const SignalData& signal) const + { + const int cells = GridMinorCellCount(); + if (cells <= 0 || signal.finiteCount == 0) + { + return false; + } + const std::size_t threshold = + static_cast(cells) * static_cast(kSnapVisibleCellsPerPoint); + if (threshold == 0) { return false; } - const double viewSpan = Tab().ViewMax() - Tab().ViewMin(); - if (!(viewSpan > 0.0)) + const std::size_t count = std::min(axis.time.size(), signal.values.size()); + if (count == 0) + { + return false; + } + std::size_t begin = 0; + std::size_t end = 0; + VisibleIndexRange(axis, count, begin, end); + if (end <= begin) { return false; } + // ⚠️ 视界内**至少要有一个可见数据点**:一个数据点都不在屏幕上的信号既没有可吸附的 + // 对象、也没有可提示的内容。少了这一条,“数据点全都落在视界之外”的信号会被判成 + // “稀疏”(0 < N×4)而被自动切成「点线」,属于无意义的状态抖动。 + if (signal.finiteCount < threshold) + { + // 有效点总数本来就不足阈值 ⇒ 只需确认视界内确实有点(稀疏点已缓存时二分判定)。 + return HasVisibleSample(axis, signal, begin, end); + } + + // 一般信号:统计**当前视界内**的有效点数量(攒够阈值立刻早退,代价有界)。 + // 连续 NaN 不计入有效点,因此再给一个**有界**的扫描窗口,防止极端情况下长循环。 + const std::size_t window = + std::min(std::min(end, signal.values.size()), begin + threshold * kSnapScanWindowFactor + 8u); + std::size_t seen = 0; + for (std::size_t index = begin; index < window; ++index) + { + if (!std::isfinite(signal.values[index])) + { + continue; + } + if (!std::isfinite(axis.time[index]) || axis.time[index] < Tab().ViewMin() || + axis.time[index] > Tab().ViewMax()) + { + continue; + } + if (++seen >= threshold) + { + return false; // 一屏内的数据点已经足够密:保持自由移动 + } + } + return seen > 0; + } + + bool PlotView::HasVisibleSample(const TimeAxis& axis, const SignalData& signal, std::size_t begin, + std::size_t end) const + { + const double viewMin = Tab().ViewMin(); + const double viewMax = Tab().ViewMax(); + + // 稀疏信号(有效点已缓存且升序):二分即可。 + if (!signal.sparseTimes.empty()) + { + const std::vector& times = signal.sparseTimes; + const std::vector::const_iterator it = std::lower_bound(times.begin(), times.end(), viewMin); + return it != times.end() && *it <= viewMax; + } + + end = std::min(end, std::min(axis.time.size(), signal.values.size())); + for (std::size_t index = begin; index < end; ++index) + { + const double time = axis.time[index]; + if (!std::isfinite(signal.values[index]) || !std::isfinite(time) || time < viewMin || + time > viewMax) + { + continue; + } + return true; + } + return false; + } + + bool PlotView::NearestSnapSample(const TimeAxis& axis, const SignalData& signal, double time, + double& candidate) const + { + const double viewMin = Tab().ViewMin(); + const double viewMax = Tab().ViewMax(); + + // 稀疏信号:有效点已在加载时缓存(按时间升序)⇒ 二分取最近的一个。 + if (!signal.sparseTimes.empty()) + { + const std::vector& times = signal.sparseTimes; + const std::vector::const_iterator it = std::lower_bound(times.begin(), times.end(), time); + bool found = false; + double bestDistance = std::numeric_limits::infinity(); + for (int side = 0; side < 2; ++side) + { + // side 0 = 第一个 >= time 的点;side 1 = 它前面的那个点。 + if (side == 1 && it == times.begin()) + { + continue; + } + const std::vector::const_iterator pick = (side == 0) ? it : (it - 1); + if (pick == times.end()) + { + continue; + } + const double value = *pick; + if (!std::isfinite(value) || value < viewMin || value > viewMax) + { + continue; + } + const double distance = std::fabs(value - time); + if (distance < bestDistance) + { + bestDistance = distance; + candidate = value; + found = true; + } + } + return found; + } + + // 一般信号:取下标最近的采样点;它若无效(NaN)则向两侧**有界**探测 + //(NaN 只出现在缺少数据的区段,通常几步内就能找到有效点;找不到就不吸附)。 + const std::size_t count = std::min(axis.time.size(), signal.values.size()); + if (count == 0) + { + return false; + } + const int center = FindNearestSample(axis, time); + if (center < 0) + { + return false; + } + const int lastIndex = static_cast(count) - 1; + for (int step = 0; step <= kSnapSampleProbeSteps; ++step) + { + for (int direction = 0; direction < 2; ++direction) + { + if (step == 0 && direction == 1) + { + continue; // 中心点只查一次 + } + const int probe = (direction == 0) ? (center - step) : (center + step); + if (probe < 0 || probe > lastIndex) + { + continue; + } + const double value = signal.values[static_cast(probe)]; + const double sampleTime = axis.time[static_cast(probe)]; + if (!std::isfinite(value) || !std::isfinite(sampleTime)) + { + continue; + } + // 只接受**可见区间内**的采样点:视界之外的采样点不是合法的落点 + //(否则吸附会把光标一把甩到视界边缘并钉住)。 + if (sampleTime < viewMin || sampleTime > viewMax) + { + continue; + } + candidate = sampleTime; + return true; + } + } + return false; + } + + void PlotView::LocalSampleSpacing(const TimeAxis& axis, const SignalData& signal, double time, + double& spacing) const + { + spacing = 0.0; + if (signal.sparseTimes.size() >= 2) + { + const std::vector& times = signal.sparseTimes; + const std::size_t found = + static_cast(std::lower_bound(times.begin(), times.end(), time) - times.begin()); + const std::size_t left = (found > 0) ? (found - 1) : 0; + const std::size_t right = std::min(times.size() - 1, left + 1); + if (right > left) + { + const double gap = times[right] - times[left]; + if (gap > 0.0 && std::isfinite(gap)) + { + spacing = gap; + } + } + return; + } + + // ⚠️ 有效点只有一个(或没有)时,**数据点之间的间距没有定义**:此时不能拿时间轴的 + // 采样间隔充数(NaN 填充的轴上那可能是 1ms,而数据点其实孤零零一个)—— + // 否则吸附半径会被压到极小、单点信号就永远吸不住。返回 0 = 不限制半径。 + if (signal.finiteCount <= 1) + { + return; + } + + const std::size_t count = std::min(axis.time.size(), signal.values.size()); + if (count < 2) + { + return; + } const int index = FindNearestSample(axis, time); if (index < 0) { - return false; + return; } - - double spacing = 0.0; - if (index > 0) + const int first = (index > 0) ? (index - 1) : 0; + const int second = std::min(static_cast(count) - 1, first + 1); + if (second <= first) { - spacing = samples[static_cast(index)] - samples[static_cast(index) - 1]; + return; } - else + // 只接受两端都是**有效**采样点的区间(NaN 段的“间距”没有意义)。 + if (!std::isfinite(signal.values[static_cast(first)]) || + !std::isfinite(signal.values[static_cast(second)])) { - spacing = samples[1] - samples[0]; + return; } - // 时间轴非单调时前后差值可能为负,此时不做吸附。 - if (!(spacing > 0.0) || !std::isfinite(spacing)) + const double gap = axis.time[static_cast(second)] - + axis.time[static_cast(first)]; + if (gap > 0.0 && std::isfinite(gap)) { - return false; + spacing = gap; } - - pixels = spacing / viewSpan * static_cast(m_plotRect.Width); - return true; } bool PlotView::SnapTimeToSample(double time, double& snapped) const { - if (m_dataSet == nullptr) + if (m_dataSet == nullptr || m_tab == nullptr) { return false; } - const double minPixels = static_cast(kCursorSnapMinPixels) * static_cast(m_scale); - // 吸附的上门限:视界内至少要放得下 2 个采样点。 - // 视界比采样间隔还窄时(选区放大很容易做到),"最近采样点"落在视界之外或半个视界之外, - // 吸附会把光标一把甩到视界边缘并钉住 —— 用户报告的"拖光标直接飞到视界外"。 - const double maxPixels = 0.5 * static_cast(m_plotRect.Width); - const double viewMin = Tab().ViewMin(); - const double viewMax = Tab().ViewMax(); + // 吸附判定**只针对鼠标所在的网格**(用户明确要求): + // 一个网格内的数据已达“稀疏”标准、另一个没有时,光标只在前者上吸附, + // 在后者上仍然完全自由移动。 + const int laneIndex = LaneAtY(m_mouseY); + if (laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) + { + return false; + } + + const int cells = GridMinorCellCount(); + const double plotWidth = static_cast(m_plotRect.Width); + const double viewSpan = Tab().ViewMax() - Tab().ViewMin(); + if (cells <= 0 || plotWidth <= 1.0 || !(viewSpan > 0.0)) + { + return false; + } + // 吸附半径 = **一个网格小格的宽度**(与判定用同一套网格定义推出), + // 且**不超过半个采样间距** —— 后者保证数据点很稀疏时(一屏只有一两个点) + // 光标在点与点之间仍可自由移动,而不是被“死死吸住” + //(用户明确要求:吸附只是提示性响应)。 + const double radiusPixels = kSnapRadiusCells * plotWidth / static_cast(cells); + const double radiusTime = radiusPixels / plotWidth * viewSpan; bool found = false; 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 SignalRef& ref : m_tab->Lanes()[static_cast(laneIndex)].signals) { - for (const SignalGroup& group : m_dataSet->Groups()) + if (!m_tab->IsVisible(ref)) { - 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; - } - } + continue; } - } - - for (const SignalGroup& group : m_dataSet->Groups()) - { - // 只有采样点在屏幕上足够稀疏时才吸附,否则缩小视图后光标会“跳格”。 - // 一个分组可能有多条时间轴(多 `time` 列 CSV / MF4 多通道组),逐条尝试。 - for (const TimeAxis& groupAxis : group.axes) + const SignalData* signal = m_dataSet->Resolve(ref); + const TimeAxis* axis = m_dataSet->Axis(ref); + if (signal == nullptr || axis == nullptr) { - double pixels = 0.0; - if (!SampleSpacingPixels(groupAxis, time, pixels) || pixels < minPixels || - pixels > maxPixels) - { - continue; - } + continue; + } + // 只有“一屏内数据点少于 N×4”的信号参与吸附;判据随视界实时重算, + // 因此疏密变化的信号在稀疏区自动吸附、移到稠密区自动恢复自由移动。 + if (!SignalSnapEnabled(*axis, *signal)) + { + continue; + } + double candidate = 0.0; + if (!NearestSnapSample(*axis, *signal, time, candidate)) + { + continue; + } - const int index = FindNearestSample(groupAxis, time); - if (index < 0) - { - continue; - } - const double candidate = groupAxis.time[static_cast(index)]; - if (!std::isfinite(candidate)) - { - continue; - } - // 只吸附**当前可见区间内**的采样点:视界之外的采样点不是合法的落点。 - if (candidate < viewMin || candidate > viewMax) - { - continue; - } + // 吸附半径再受“半个局部采样间距”限制(点很稀疏时不被吸死)。 + double limitTime = radiusTime; + double spacingTime = 0.0; + LocalSampleSpacing(*axis, *signal, candidate, spacingTime); + if (spacingTime > 0.0) + { + limitTime = std::min(limitTime, spacingTime * 0.5); + } - const double distance = std::fabs(candidate - time); - if (distance < bestDistance) - { - bestDistance = distance; - bestTime = candidate; - found = true; - } + const double distance = std::fabs(candidate - time); + if (distance <= limitTime && distance < bestDistance) + { + bestDistance = distance; + bestTime = candidate; + found = true; } } @@ -4015,9 +4347,13 @@ namespace jdi } else { - const double pad = std::max(1.0e-9, std::fabs(low) * 0.05); - low -= pad; - high += pad; + // ⚠️ 可见区间内只有一个取值(单点信号 / 恒定值):给一个**可读**的跨度, + // 而不是几乎为 0 的退化区间 —— 退化区间会让幅值网格线挤成一团、 + // 数据点也看不出位置(用户报告“单值信号的全局自适应不生效”)。 + // 这里的留白与 `TabConfig::ComputeLaneRange` 的 `PadRange` 完全一致。 + const double magnitude = std::max(1.0, std::fabs(low) * 0.1); + low -= magnitude; + high += magnitude; } Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; @@ -4077,24 +4413,6 @@ namespace jdi 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) @@ -4114,8 +4432,9 @@ namespace jdi continue; } const TimeAxis* axis = m_dataSetWritable->Axis(ref); + const SignalData* data = m_dataSetWritable->Resolve(ref); SignalStyle* style = m_dataSetWritable->Style(ref); - if (axis == nullptr || style == nullptr) + if (axis == nullptr || data == nullptr || style == nullptr) { continue; } @@ -4123,14 +4442,17 @@ namespace jdi // ⚠️ **先**判定“用户是否手动改过绘制方式”:本机制只会写入「点线」, // 所以自动启用期间 mode 不等于「点线」只可能是用户改的。 // 这一步必须在下面的分支之前做 —— 用户改完样式可能**紧接着就缩出去** - // (例如直接点适应视图),那次调用的视界已经不稀疏,若等到那时才判定, + // (例如直接点适应视图),那次调用的视界已经不再满足条件,若等到那时才判定, // 恢复逻辑会先把用户的修改覆盖掉(用户明确要求不能这样)。 if (style->autoScatterActive && style->mode != DrawMode::LINE_SCATTER) { style->autoScatterUserOverride = true; } - if (AxisSparseInView(*axis)) + // 判定与**光标吸附完全同源**(用户 2026-10-15 明确要求): + // 只有“一屏内数据点少于 N×4”的信号才自动切「点线」, + // 不再满足时(适应视图、或把视界移到信号的稠密区)自动恢复原样式。 + if (SignalSnapEnabled(*axis, *data)) { if (!style->autoScatterActive) { diff --git a/Components/SignalPanel/Inc/SignalPanel.hpp b/Components/SignalPanel/Inc/SignalPanel.hpp index bd037d4..cee19c3 100644 --- a/Components/SignalPanel/Inc/SignalPanel.hpp +++ b/Components/SignalPanel/Inc/SignalPanel.hpp @@ -443,7 +443,7 @@ namespace jdi * @brief 判断哪个分组行需要「悬浮」在列表顶部(返回行下标,无需悬浮时返回 -1)。 * * 判据:分组行已被滚出列表上边界(`RowRect(row).Y < m_listRect.Y`), - * 但该组内**仍有行留在列表里**(本组最后一行的下边界尚未滚过去)。 + * 但该组内**仍有行留在列表里**(本组最后一行的下边界尚未越过列表上边界)。 * 两者同时成立时组名必须钉在最上方;整组都滚过去后组名跟随一起消失。 * * @param [IN] rowCount 行总数。 @@ -452,9 +452,31 @@ namespace jdi int StickyGroupRowIndex(int rowCount) const; /** - * @brief 把分组行「钉在列表顶部」绘制(组名跟随滚动,直到整组滚过去)。 + * @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 把分组行「按悬浮位置」绘制(组名跟随滚动,直到整组滚过去)。 * - * 用户要求长列表(例如上百个信号的 MF4)滚动时组名一直可见。 * ⚠️ 必须在常规行循环**之后**调用(它要盖在信号行之上),并且常规循环要**跳过** * `row` 行,否则该行会被画两次。 * diff --git a/Components/SignalPanel/Src/SignalPanel.cpp b/Components/SignalPanel/Src/SignalPanel.cpp index 804d20b..9e60e32 100644 --- a/Components/SignalPanel/Src/SignalPanel.cpp +++ b/Components/SignalPanel/Src/SignalPanel.cpp @@ -580,10 +580,25 @@ namespace jdi return gd::RectF(m_listRect.X, top, m_listRect.Width, m_rows[static_cast(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(next)].kind == RowKind::GROUP) + { + break; + } + last = next; + } + return last; + } + int SignalPanel::StickyGroupRowIndex(int rowCount) const { // 「组名悬浮」:分组行被滚出列表上边界、而该组内仍有行留在列表里时, - // 把分组行钉在列表顶部,直到该组全部行都滚过去为止。 + // 把分组行**钉在列表顶部**,直到该组全部行都滚过去为止。 // 用户要求:组名要一直可见,方便在长列表(例如上百个信号的 MF4)里辨认当前分组。 if (m_rowYOverrideActive || m_listRect.Height <= 0.0f) { @@ -601,19 +616,8 @@ namespace jdi // 分组行本身仍在列表内(或更靠下):无需悬浮。 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) + const int last = GroupLastRowIndex(row, rowCount); + if (RowRect(last).Y + m_rows[static_cast(last)].height <= m_listRect.Y) { // 整组(含最后一行)已全部滚过去:组名跟随一起向上,不再出现。 continue; @@ -623,6 +627,21 @@ namespace jdi 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(last)].height; + return std::min(m_listRect.Y, lastBottom - m_rows[static_cast(row)].height); + } + void SignalPanel::PaintStickyGroupRow(gd::Graphics& g, int row) { if (row < 0 || row >= static_cast(m_rows.size()) || @@ -631,16 +650,27 @@ namespace jdi return; } + const int rowCount = static_cast(m_rows.size()); const float height = m_rows[static_cast(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 = m_listRect.Y; + m_rowYOverrideY = stickyY; // 悬浮行必须是不透明的,否则下面的信号行会与组名叠在一起看不清; // `FillRowRect` 会把矩形夹到列表矩形(GDI 填充不受 GDI+ 裁剪约束)。 - FillRowRect(g, gd::RectF(m_listRect.X, m_listRect.Y, m_listRect.Width, height), m_theme->panelBg); + FillRowRect(g, gd::RectF(m_listRect.X, stickyY, 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); + // 只有真正“浮”在其它行之上时才补一条底部边界线(否则会与正常绘制有差异)。 + 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; } diff --git a/Documents/agent_mem.md b/Documents/agent_mem.md index 9030719..5cb4b5b 100644 --- a/Documents/agent_mem.md +++ b/Documents/agent_mem.md @@ -1579,6 +1579,89 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸( --- +### 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)`**(用户 2026-10-15 的定义): + **当且仅当**该信号在**当前一屏**内的数据点个数 `< N × 4` 时开启吸附; + 判据随视界实时重算 ⇒ 疏密变化的信号“移到稀疏区自动吸附、移回稠密区自动自由移动”。 + ⚠️ 额外要求视界内**至少有一个可见数据点**(否则“0 < N×4”会把“数据全在视界之外”的信号 + 误判成稀疏,白切一次点线且没有任何可吸附对象)。 + ⚠️ 性能:总数不足阈值时直接返回(配合 `sparseTimes` 二分);否则只统计可见下标区间内的 + 有限值并**攒够阈值即早退**,扫描窗口还有 `threshold × 64` 的上限(防 NaN 长循环)。 +- **`SnapTimeToSample`**:判定**只针对鼠标所在网格**(`LaneAtY(m_mouseY)`)—— + 同一个时间轴上,一个网格达到稀疏标准、另一个没有时,只在前者吸附; + 同网格内**所有符合判定的信号**的数据点都参与比较。 + **吸附半径 = 一个网格小格的宽度**(与判定同源),且**不超过半个局部采样间距** + ⇒ 数据点很稀疏(一屏只有一两个点)时光标在点之间仍可自由移动(吸附 ≠ 卡住)。 + ⚠️ `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`,之前缓存的 `SignalData*`/`TimeAxis*` + 引**用可能失效**,用完要重新 `Resolve`/`Axis`; + ④ 改完 `PlotView.cpp` 后要确认 `make release` 真的重编译了(曾遇时间戳未更新而链接到旧 `.o`, + 症状恰好等于“修复没生效”——先 `(Get-Item …).LastWriteTime = Get-Date` 再 make)。 + +--- + ## 10. 变更日志(本文件维护记录) | 日期 | 设备/模型 | 摘要 | @@ -1621,6 +1704,7 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸( | 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` 零警告。 | --- @@ -1838,14 +1922,31 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸( (名称框 / ✕ / 徽标)的唯一来源,因此「组名悬浮」只需给它加一个顶部坐标覆盖 (`m_rowYOverrideActive/m_rowYOverrideY`)即可整体跟随;并且**常规行循环必须跳过被钉住的那一行**, 否则同一行会被画两次(一次在滚动位置、一次在顶部)。 -58. ⚠️ **「少数数据点」信号的默认样式**(用户明确要求):有效采样点 **1 个** → 「点线」+ - **最大线宽(10px)**(单点就是一个小圆点,半径与线宽成正比);**2 … 16 个** → 「点线」+ 2px; - 其余保持「线段」+ 1px。只作用于**新建**信号(不覆盖用户设置)。 - ⚠️ 线宽范围常量(`kSignalLineWidthMin/Max`)必须**单一来源**(`DataModel.hpp`), - 信号面板滑块要引用它,否则「最大线宽」会在两处漂移。 - ⚠️ 单点信号的光标吸附是**半径判据**(`kCursorSnapPointRadiusPixels = 8` 逻辑像素), - 半径外必须自由移动 —— 是「吸附」不是「卡住」;其时刻由 `SignalData::singlePointTime` - 在加载时缓存(不要改成鼠标移动时线性扫描采样)。 +58. ⚠️ **光标吸附的判定只有一套定义(§9.30)**:某信号在当前一屏内的数据点个数 + `< N × 4`(N = `GridMinorCellCount()`,网格一横排的小格数,与网格绘制同源)时 + 才为它开启吸附;判据**随视界实时重算**(疏密变化的信号自动在稀疏区吸附、稠密区自由)。 + ⚠️ 额外要求视界内**至少有一个可见数据点**; + ⚠️ 判定**只针对鼠标所在网格**(`SnapTimeToSample` 用 `LaneAtY(m_mouseY)`); + ⚠️ 吸附半径 = **一个网格小格宽**,且**不超过半个局部采样间距**(吸附 ≠ 卡住光标); + ⚠️ 旧的 `SampleSpacingPixels`(6px 下门限 + 半屏宽上门限)**已删除,不要再恢复**。 + ⚠️「放大自动切点线」必须**与吸附判定完全同源**(`UpdateAutoScatterStyles` 调 + `SignalSnapEnabled`),不要再引入“采样间距 > 半屏宽”之类的独立判据。 + ⚠️ `SignalData` 用 **`sparseTimes` / `sparseValues`**(有效点 ≤ 1024 时缓存全部有效点) + 支撑吸附与单点初始视图;**不要**在鼠标移动时扫描整条采样数组。 + ⚠️ `LocalSampleSpacing` 对 `finiteCount <= 1` 必须返回 0(数据点间距无定义), + 否则单点信号永远吸不住。 +59. ⚠️ **「2…16 个有效采样点用点线 + 2px」的需求已作废**(用户 2026-10-15 明确取消)。 + 仍保留:**只有一个有效采样点** → 「点线」+ **最大线宽**;其余信号保持默认「线段 + 1px」。 + 需要“提示数据点位置”的场景一律由 §9.30 的“放大自动点线”承担(同源判定)。 +60. ⚠️ **单点信号的初始视图**:当前无任何信号被勾选、而勾选的第一个信号只有**一个**有效数据点时, + 必须走 `FitSinglePointSignal()`(数据点居中、**时间跨度 1s**、**Y 轴跨度 1**), + 不要再落到 `FitAll()` 的整段自适应(时间轴会覆盖整条时间列、Y 区间退化成几乎为 0)。 + 同时 `AutoRangeLaneToView` 的退化区间要按 `PadRange` 同款留白 `max(1, |v|×0.1)`。 +61. ⚠️ **组名悬浮的位置公式(勿改回“固定钉在列表上边界”)**: + `min(列表上边界, 本组最后一行下边界 − 组名高度)` ⇒ + 组名随最后一行**平滑滚出**(不突然消失),且下边界永不越过下一个分组行的自然位置 + ⇒ **不可能遮住下一个组名**;**折叠的分组不悬浮**(随滚动自然划过)。 + 行几何仍只由 `RowRect()` 提供(用 `m_rowYOverrideActive/m_rowYOverrideY` 覆盖顶部坐标)。 ### 11.3 离屏验证方法学(本机偏好,强烈建议沿用) diff --git a/Utilities/Inc/DataModel.hpp b/Utilities/Inc/DataModel.hpp index a4c3eab..9254ec9 100644 --- a/Utilities/Inc/DataModel.hpp +++ b/Utilities/Inc/DataModel.hpp @@ -35,25 +35,26 @@ namespace jdi 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 **只含一个有效采样点**的信号的初始线宽(取样式允许的最大线宽)。 * * 单个数据点在「点线」模式下就是一个圆点,半径与线宽成正比;取最大值才能看清。 + * ⚠️ 曾经还对「有效采样点 2…16」的信号也设了「点线 + 2px」的默认样式, + * 该需求已**作废**(用户 2026-10-15 明确取消):放大后自动切「点线」的机制 + * 已经覆盖了那个场景,无需再改默认样式。 */ constexpr float kSinglePointSignalLineWidth = kSignalLineWidthMax; + /** + * @brief 「稀疏信号」缓存有效采样点时刻/取值的上限(有效点不超过该值才缓存)。 + * + * 光标吸附需要“当前视界内有多少个数据点”以及“离光标最近的那个数据点”。 + * 对有效点很少的信号(典型:单点信号、只有零星采样的列)事先把它们的时刻与取值缓存下来, + * 判定与查找都退化成二分 → 不必在鼠标移动时扫描整条采样数组(千万级信号无法承受)。 + * 1024 个有效点 × 16 字节 ≈ 16 KB/条,可忽略。 + */ + constexpr std::size_t kSparseTimesLimit = 1024; + /** * @brief 绘图区拖拽缩放模式(由工具栏按钮切换)。 */ @@ -354,13 +355,19 @@ namespace jdi std::size_t finiteCount = 0; /**< 有限采样点数量。 */ /** - * @brief 只含**一个**有效采样点时的该点时刻(不存在该情况时为 NaN)。 + * @brief 有效采样点很少时的**全部有效点**(按时间升序;有效点很多时为空)。 * - * 在加载/插入信号时与 `ComputeStats` 同一趟扫描就地填充,供光标吸附使用: - * 只有一个数据点的信号,光标在其它区域自由移动、靠近该点时吸附过去 - *(吸附而非“卡住”)。没有这个缓存就只能在鼠标移动时线性扫描采样值,大信号下不可接受。 + * 仅当 `finiteCount <= kSparseTimesLimit` 时在加载/插入信号时就地填充,用于: + * * 光标吸附:直接二分得到“视界内有几个数据点”“离光标最近的数据点”; + * * 单点信号的初始视图:Centering 需要那个点的时刻与取值。 + * ⚠️ 不要在鼠标移动时改成扫描整条采样数组来找有效点(千万级信号承受不住)。 + * + * 说明:`sparseTimes.size() == 1` 即“只有一个数据点的信号”。 */ - double singlePointTime = std::numeric_limits::quiet_NaN(); + std::vector sparseTimes; + + /** @brief `sparseTimes` 对应的取值(同序)。 */ + std::vector sparseValues; /** * @brief 粗粒度包络层:每 coarseStride 个采样一组的 min/max。 diff --git a/Utilities/Src/DataModel.cpp b/Utilities/Src/DataModel.cpp index 11a8afc..35cbb71 100644 --- a/Utilities/Src/DataModel.cpp +++ b/Utilities/Src/DataModel.cpp @@ -370,34 +370,86 @@ namespace jdi double minValue = 0.0; /**< 有限采样值最小值。 */ double maxValue = 0.0; /**< 有限采样值最大值。 */ std::size_t finiteCount = 0; /**< 有限采样点数量。 */ - std::size_t firstFiniteIndex = 0; /**< 第一个有限采样点的下标(无有效值时无意义)。 */ }; /** - * @brief 按有效采样点数量设置「稀疏信号」的**初始**绘制样式。 + * @brief 为**只有一个有效采样点**的信号设置初始绘制样式。 * - * 有效采样点极少时线段模式几乎什么也看不见(只有一个点时更是完全画不出东西), - * 因此这类信号初始就用「点线」并适当加粗: - * * **只有一个有效采样点** → 点线 + `kSinglePointSignalLineWidth`(最大线宽):那个点本身 - * 就是一个圆点,半径与线宽成正比,不取最大值根本看不清; - * * **有效采样点 2 … `kSparseSignalSampleLimit`** → 点线 + `kSparseSignalLineWidth`(2px)。 + * 单个数据点在「线段」模式下完全画不出来,因此初始就用「点线」并取**最大线宽** + * (点线模式下数据点是一个圆点,半径与线宽成正比)。 * - * 其余信号保持既有默认(线段 + 1px)。只在**新建信号**时调用(用户改过样式后不再覆盖)。 + * ⚠️ 曾经还对「有效采样点 2…16」的信号也改用「点线 + 2px」—— 该需求已**作废** + *(用户 2026-10-15 明确取消:放大后自动切点线的机制已覆盖那个场景)。 + * + * 只在**新建信号**时调用(用户改过样式后不再覆盖)。 * * @param [IN] finiteCount 有效采样点数量。 * @param [IN,OUT] style 待初始化的显示外观。 */ - void ApplySparseDefaultStyle(std::size_t finiteCount, SignalStyle& style) + void ApplySinglePointDefaultStyle(std::size_t finiteCount, SignalStyle& style) { if (finiteCount == 1) { style.mode = DrawMode::LINE_SCATTER; style.lineWidth = kSinglePointSignalLineWidth; } - else if (finiteCount <= kSparseSignalSampleLimit) + } + + /** + * @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) { - style.mode = DrawMode::LINE_SCATTER; - style.lineWidth = kSparseSignalLineWidth; + 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 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 times; + std::vector 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); } } @@ -435,10 +487,6 @@ 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); @@ -619,15 +667,8 @@ 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]; - } - } + // 有效点很少时缓存全部有效点(光标吸附 / 单点信号初始视图用)。 + CacheSparseSamples(group.AxisAt(signal.axisIndex), signal); // 显示外观随信号一起初始化:调色板按「全局信号序号」取色, // 使同一信号在任意标签页中都保持同一默认颜色。 @@ -651,8 +692,8 @@ namespace jdi signal.style.opacity = 1.0f; signal.style.lineWidth = 1.0f; signal.style.mode = DrawMode::LINE; - // 采样点极少的信号(含只有一个点)改用「点线」并加粗,否则几乎不可见。 - ApplySparseDefaultStyle(stats.finiteCount, signal.style); + // 只有一个有效采样点的信号改用「点线」+ 最大线宽,否则根本看不见。 + ApplySinglePointDefaultStyle(stats.finiteCount, signal.style); } group.signals.push_back(std::move(signal)); @@ -717,15 +758,8 @@ 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]; - } - } + // 有效点很少时缓存全部有效点;派生信号沿用源信号的时间轴。 + CacheSparseSamples(group.AxisAt(signal.axisIndex), signal); // 2. 显示外观按「全局信号序号」取调色板颜色(与普通新信号完全一致的规则)。 const int insertPos = afterSignalIndex + 1; @@ -748,7 +782,7 @@ namespace jdi } // 采样点极少的派生信号同样改用「点线」并加粗(与普通加载路径同一规则)。 - ApplySparseDefaultStyle(stats.finiteCount, signal.style); + ApplySinglePointDefaultStyle(stats.finiteCount, signal.style); } group.signals.insert(group.signals.begin() + insertPos, std::move(signal));