// PlotView 实现分片 —— 本文件只包含 PlotView 的**成员函数定义**(纯代码搬移,行为与拆分前完全一致)。 // // 共用的常量与工具函数在 PlotViewInternal.hpp(命名空间 `pvdetail`,全部 inline)。 // 四个分片的职责划分(按功能): // core 视图核心:构造/析构、上下文绑定、布局、坐标映射、视界与平移、网格结构与归属、 // 选区/网格划分、拖动取消 // paint 渲染与像素缓存:全部绘制(Paint*/Render*)、场景缓存、装饰缓存、每网格图层、 // 宽基底与滚动补条、并行图层重建 // interaction 交互与编辑:命中测试、鼠标与键盘处理、光标形状、内联输入框、浮动面板层序与命中 // features 视图特性与派生数据:光标状态与读数、提示浮层、缩放模式、自适应、光标吸附、 // 单点信号、派生曲线线程池、边沿统计、实验性功能(均值滤波 / 弱峰调整)参数 #include "PlotViewInternal.hpp" namespace jdi { bool PlotView::CursorEnabled() const { return Tab().CursorEnabled(); } double PlotView::CursorTime() const { return Tab().CursorTime(); } PlotCursorMode PlotView::CursorMode() const { return Tab().CursorMode(); } void PlotView::ZoomTimeAt(double factor, double anchorTime) { if (m_dataSet == nullptr || m_dataSet->Empty() || !(factor > 0.0) || !std::isfinite(anchorTime)) { return; } double dataMin = 0.0; double dataMax = 1.0; TimeBounds(dataMin, dataMax); const double dataSpan = std::max(dataMax - dataMin, kMinTimeSpan); const double span = Tab().ViewMax() - Tab().ViewMin(); if (!(span > 0.0)) { return; } // 下限与选区放大共用同一个来源(见 `MinimumTimeSpan`), // 否则“滚轮缩放有限制、选区放大没有”会让视界退化到不可用。 const double minimum = MinimumTimeSpan(Tab().ViewMin(), Tab().ViewMax()); const double newSpan = ClampDouble(span / factor, minimum, dataSpan * 100.0); const double ratio = (anchorTime - Tab().ViewMin()) / span; const double low = anchorTime - ratio * newSpan; SetTimeView(low, low + newSpan); } void PlotView::ZoomTimeStep(double factor) { const double anchor = (Tab().ViewMin() + Tab().ViewMax()) * 0.5; ZoomTimeAt(factor, anchor); } void PlotView::FitAll() { if (m_dataSet == nullptr || m_dataSet->Empty()) { SetTimeView(0.0, 1.0); return; } if (m_tab == nullptr) { return; } // 全部可见信号的"边界贡献"(时刻范围 / 绘制取值 / 是否单点 / 散点半径)。 std::vector> perLane; const std::size_t visibleCount = CollectFitContributions(perLane); // ---- 特例:所有可见数据点都是单值信号、且时刻完全相同 ---- bool allSingle = true; bool sameTime = true; double sharedTime = 0.0; bool haveTime = false; for (const std::vector& items : perLane) { for (const FitContribution& item : items) { if (!item.singlePoint) { allSingle = false; continue; } if (!haveTime) { sharedTime = item.pointTime; haveTime = true; } else if (std::fabs(item.pointTime - sharedTime) > kSinglePointSameTimeEpsilon * std::max(1.0, std::fabs(sharedTime))) { sameTime = false; } } } if (visibleCount > 0 && allSingle && sameTime) { FitSinglePointCluster(perLane, sharedTime); return; } // ---- 常规的整段适应 ---- double low = 0.0; double high = 1.0; TimeBounds(low, high); if (!(high > low)) { const double padding = std::max(1.0e-6, std::fabs(low) * 0.01); low -= padding; high += padding; } // 时间边界处的单值散点不能被裁切:**被裁切的那一侧**向外让出散点半径。 // ⚠️ 迭代两遍:让出余量会改变跨度,而"半径换算成多少时间"又依赖跨度。 const double plotWidth = std::max(1.0, static_cast(m_plotRect.Width)); for (int pass = 0; pass < 2; ++pass) { const double span = high - low; if (!(span > 0.0)) { break; } for (const std::vector& items : perLane) { for (const FitContribution& item : items) { if (!item.singlePoint || !(item.dotRadius > 0.0f)) { continue; } const double radius = (static_cast(item.dotRadius) + kSinglePointBoundaryPadPixels) * span / plotWidth; if (item.pointTime - radius < low) { low = item.pointTime - radius; } if (item.pointTime + radius > high) { high = item.pointTime + radius; } } } } SetTimeView(low, high); // ⚠️ **只有真的存在可见信号时才置位 `ViewInitialized()`**:没有任何信号被勾选时 //(用户先连点几次「适应视图」)必须保持"未初始化",否则随后勾选第一个信号时 // `Application::SynchronizeView` 会**跳过初次自适应** —— 数据点既不居中, // 还可能有一半被裁在网格外(用户 2026-10-15 报告)。 if (visibleCount > 0) { Tab().ViewInitialized() = true; } // ⚠️ **不再复位网格纵向滚动(`Tab().LaneScroll()`)**:用户 2026-10-16 明确要求 // “适应视图不要影响滚动条” —— 加载了大量网格并向下翻动后点「适应视图」, // 旧实现会把视界瞬移回最上方(用户报告)。适应视图只负责时间轴/幅值轴。 m_laneScrollAnimActive = false; // 只放下进行中的选区与拖拽,**不改变「选区放大」等缩放模式的启用状态** // (用户明确要求:点适应视图后仍保持已启用的缩放模式)。 m_rubberRect = gd::RectF(); m_dragMode = PlotDragMode::NONE; m_dragSignal = SignalRef(); m_dragTargetLane = -1; m_hoverSignal = SignalRef(); SetHint(Tab().ZoomMode() == PlotZoomMode::NONE ? std::wstring() : L"横向或纵向拖动以放大选择区域,ESC退出选区模式"); // ---- 逐网格的幅值轴 ---- // 保留既有语义:开启了「选区 Y 轴自适应」的网格按新的(整段)时间视图重新匹配, // 其余网格回到整段自适应;随后再为"被单值散点顶住的边界"让出余量。 const float laneHeight = FitLaneHeight(); for (std::size_t index = 0; index < m_tab->Lanes().size(); ++index) { const int laneIndex = static_cast(index); if (m_tab->Lanes()[index].fitYToSelection) { AutoRangeLaneToView(laneIndex); } else { m_tab->Lanes()[index].autoRange = true; } ApplySinglePointValueMargins(laneIndex, perLane[index], laneHeight); } // 适应视图已回到初始状态,这里不再移动光标(光标允许停留在可视区外)。 BuildLaneVisuals(); } std::size_t PlotView::CollectFitContributions(std::vector>& perLane) const { perLane.clear(); if (m_tab == nullptr || m_dataSet == nullptr) { return 0; } perLane.assign(m_tab->Lanes().size(), std::vector()); std::size_t total = 0; for (std::size_t laneIndex = 0; laneIndex < m_tab->Lanes().size(); ++laneIndex) { for (const SignalRef& ref : m_tab->Lanes()[laneIndex].signals) { if (!m_tab->IsVisible(ref)) { continue; } const SignalData* data = m_dataSet->Resolve(ref); const TimeAxis* axis = m_dataSet->Axis(ref); if (data == nullptr || axis == nullptr || data->finiteCount == 0) { continue; } // ⚠️ 该信号自己的时间轴偏移:采样画在 `t + offsetTime` 处, // 因此"这段数据在屏幕上占用的时间范围"与"单值数据点的时刻"都要加上它。 // 少了这一项,「适应视图」在信号被时间偏移后仍按原始时间范围取景 // (用户 2026-10-29 报告的缺陷)。 const SignalStyle* style = m_dataSet->Style(ref); const double timeShift = (style != nullptr) ? style->offsetTime : 0.0; FitContribution item; item.ref = ref; // 与 `ComputeVisibleTimeBounds` 同源:时间边界用**时间轴**的范围 //(一个分组的多条信号可能共用一条轴,轴范围才是"这一段数据"的边界)。 item.minTime = axis->minTime + timeShift; item.maxTime = axis->maxTime + timeShift; item.singlePoint = (data->finiteCount == 1); if (item.singlePoint) { double time = 0.0; double value = 0.0; if (!SinglePointOfSignal(*data, *axis, time, value)) { item.singlePoint = false; } else { // 同样是**显示时刻**(`time + offsetTime`):视界的居中与边界让位都以它为准。 item.pointTime = time + timeShift; item.pointValue = (style != nullptr) ? ApplyDrawTransform(*style, value) : value; item.dotRadius = SinglePointDotRadius(ResolveSignal(ref)); } } perLane[laneIndex].push_back(item); ++total; } } return total; } float PlotView::FitLaneHeight() const { // 与 `BuildLaneVisuals()` **完全同源**:网格高度 = 绘图区高 / 网格数(不低于最小高度)。 // 直接复用它(而不是读 `m_lanes[i].rect.Height`):`FitAll` 可能在网格数量刚变化、 // 布局尚未重建时被调用,用公式能拿到**将要生效**的高度。 const std::size_t count = (m_tab != nullptr) ? m_tab->Lanes().size() : 0; if (count == 0 || !(m_plotRect.Height > 0.0f)) { return 0.0f; } float height = m_plotRect.Height / static_cast(count); if (height < m_minLaneHeight * m_scale) { height = m_minLaneHeight * m_scale; } return height; } void PlotView::ApplySinglePointValueMargins(int laneIndex, const std::vector& items, float laneHeight) { if (m_tab == nullptr || m_dataSet == nullptr || laneHeight <= 1.0f) { return; } if (laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return; } bool anySingle = false; for (const FitContribution& item : items) { if (item.singlePoint && item.dotRadius > 0.0f) { anySingle = true; break; } } if (!anySingle) { return; // 该网格的边界不是单值散点顶住的:保持现有(自动)区间 } // 该网格当前**生效**的幅值区间(自动自适应时即 `ComputeLaneRange` 的结果)。 double low = 0.0; double high = 0.0; m_tab->ComputeLaneRange(*m_dataSet, laneIndex, low, high); if (!(high > low)) { return; } double newLow = low; double newHigh = high; // 迭代两遍:让出余量会改变跨度,而"半径换算成多少数值"又依赖跨度。 for (int pass = 0; pass < 2; ++pass) { const double span = newHigh - newLow; if (!(span > 0.0)) { break; } for (const FitContribution& item : items) { if (!item.singlePoint || !(item.dotRadius > 0.0f)) { continue; } const double radius = (static_cast(item.dotRadius) + kSinglePointBoundaryPadPixels) * span / static_cast(laneHeight); if (item.pointValue - radius < newLow) { newLow = item.pointValue - radius; } if (item.pointValue + radius > newHigh) { newHigh = item.pointValue + radius; } } } if (newLow == low && newHigh == high) { return; // 散点本来就完整可见:不改动区间(保持自动自适应的语义) } // ⚠️ 必须**同时**关掉 `autoRange`:否则 `ComputeLaneRange` 会忽略下面写入的区间。 Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; lane.autoRange = false; lane.yMin = newLow; lane.yMax = newHigh; } void PlotView::FitSinglePointCluster(const std::vector>& perLane, double sharedTime) { // 时间轴:该时刻居中、**一大格 = 1s**(视界 = 大格数 × 1s)。 // // 幅值轴与其余场景**共用同一段逻辑**(见函数末尾):`fitYToSelection` 的网格走 // `AutoRangeLaneToView`、其余走整段自适应(`ComputeLaneRange`),两者都只由 // `PadValueRange` 提供留白。这样「适应视图」与「Y 轴自适应」互为幂等 —— // 同一视界下反复切换两者不会改变区间,也不会关掉 Y 轴自适应开关(用户 2026-10-16)。 const double span = static_cast(kTimeTickTarget) * kSinglePointClusterSecondsPerCell; SetTimeView(sharedTime - span * 0.5, sharedTime + span * 0.5); Tab().ViewInitialized() = true; // ⚠️ 同 `FitAll()`:**不复位网格纵向滚动**(用户 2026-10-16 要求)。 m_laneScrollAnimActive = false; // ⚠️ **不要**在这里复位 `ZoomMode()`:用户明确要求"点适应视图后仍保持已启用的 // 缩放模式"(`FitAll` 的常规路径同样只更新提示、不动缩放模式)。 m_rubberRect = gd::RectF(); m_dragMode = PlotDragMode::NONE; m_dragSignal = SignalRef(); m_dragTargetLane = -1; m_hoverSignal = SignalRef(); m_rubberAxis = PlotRubberAxis::UNDECIDED; SetHint(Tab().ZoomMode() == PlotZoomMode::NONE ? std::wstring() : L"横向或纵向拖动以放大选择区域,ESC退出选区模式"); // ---- 逐网格的幅值轴 ---- // ⚠️ **必须与 `FitAll()` 的常规路径、以及「Y 轴自适应」(`AutoRangeLaneToView`)同源**: // 旧实现在这条特例里按“散点半径留白 + 跨度 1”另算一套区间,而 Y 轴自适应走 // `PadValueRange`,于是对**同一个视界**两者会算出不同的幅值范围 //(用户 2026-10-16 报告:单数据点信号上「适应视图」与「Y 轴自适应」的范围不一致); // 并且旧实现还把 `fitYToSelection` 置回 false —— 原本开着「Y 轴自适应」时点一下 // 「适应视图」会把这个开关**悄悄关掉**。 // 现在两者共用同一段逻辑:留白只来自 `PadValueRange`,且**不改动** Y 轴自适应开关。 const float laneHeight = FitLaneHeight(); for (std::size_t index = 0; index < perLane.size() && index < m_tab->Lanes().size(); ++index) { const int laneIndex = static_cast(index); if (m_tab->Lanes()[index].fitYToSelection) { // 已开启「选区 Y 轴自适应」的网格按当前(刚设好的)时间视图重新匹配 —— // 与工具栏开关打开时的算法完全一致,因此两种操作互为幂等。 AutoRangeLaneToView(laneIndex); } else { m_tab->Lanes()[index].autoRange = true; } // 补齐“被单值散点顶住的边界”(散点本来就完整可见时保持不变)。 ApplySinglePointValueMargins(laneIndex, perLane[index], laneHeight); } InvalidateSceneCache(); BuildLaneVisuals(); } bool PlotView::SinglePointOfSignal(const SignalData& signal, const TimeAxis& axis, double& time, double& value) const { time = std::numeric_limits::quiet_NaN(); value = std::numeric_limits::quiet_NaN(); // 正常路径:加载/插入信号时已把“有效点很少”的信号的全部有效点缓存下来。 if (!signal.sparseTimes.empty() && signal.sparseTimes.size() == signal.sparseValues.size()) { time = signal.sparseTimes.front(); value = signal.sparseValues.front(); return std::isfinite(time) && std::isfinite(value); } // 兜底:逐个采样找那个有效点(单点信号的采样数组必然很短)。 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])) { time = axis.time[index]; value = signal.values[index]; return true; } } return false; } void PlotView::DisableCursor() { Tab().CursorMode() = PlotCursorMode::NONE; m_cursorHover = false; m_hoverCursor = 0; if (m_editTarget == PlotEditTarget::CURSOR_TIME || m_editTarget == PlotEditTarget::CURSOR_SPAN) { EndEdit(false); } if (m_dragMode == PlotDragMode::CURSOR_MOVE || m_dragMode == PlotDragMode::CURSOR_SPAN_MOVE) { m_dragMode = PlotDragMode::NONE; } } bool PlotView::CycleCursorMode() { // 三态循环:无 → 单光标 → 双光标 → 无。光标模式本身不显示提示。 switch (Tab().CursorMode()) { case PlotCursorMode::NONE: { Tab().CursorMode() = PlotCursorMode::SINGLE; // 首次启用时置于视图中心,随后可用鼠标靠近拖拽或双击指定精确时间。 const double center = (Tab().ViewMin() + Tab().ViewMax()) * 0.5; Tab().CursorTime() = center; Tab().CursorTimeAlt() = center; m_cursorHover = true; m_hoverCursor = 1; m_activeCursor = 1; break; } case PlotCursorMode::SINGLE: { Tab().CursorMode() = PlotCursorMode::DOUBLE; // 光标 2 首次出现:紧邻光标 1 且必须落在当前可见时间范围内, // 这样无需额外操作就能立即看到两个光标的对比读数。 const double low = Tab().ViewMin(); const double high = Tab().ViewMax(); const double span = (high > low) ? (high - low) : 1.0; const double offset = span * 0.12; double second = Tab().CursorTime() + offset; if (second > high - span * 0.02) { second = Tab().CursorTime() - offset; } if (second < low + span * 0.02 || second > high - span * 0.02) { second = low + span * 0.5; } Tab().CursorTimeAlt() = second; ClampCursorToView(2); m_cursorHover = true; m_hoverCursor = 2; m_activeCursor = 2; break; } case PlotCursorMode::DOUBLE: default: DisableCursor(); break; } return true; } bool PlotView::ResetCursorPlacementOnFreshContent() { if (m_tab == nullptr || !Tab().CursorEnabled() || m_plotRect.Width <= 0.0f) { return false; } // 与 `CycleCursorMode()` 的“光标首次出现”规则完全同源: // 单光标置于视图中心;双光标的第二条紧邻第一条、且保证落在可见时间范围内。 const double low = Tab().ViewMin(); const double high = Tab().ViewMax(); const double span = (high > low) ? (high - low) : 1.0; const double center = (low + high) * 0.5; Tab().CursorTime() = center; if (Tab().CursorMode() == PlotCursorMode::DOUBLE) { const double offset = span * 0.12; double second = center + offset; if (second > high - span * 0.02) { second = center - offset; } if (second < low + span * 0.02 || second > high - span * 0.02) { second = low + span * 0.5; } Tab().CursorTimeAlt() = second; ClampCursorToView(2); } ClampCursorToView(1); m_cursorHover = true; m_hoverCursor = 1; m_activeCursor = 1; return true; } void PlotView::SetHint(const std::wstring& text) { m_hintText = text; m_hintSticky = false; m_hintShownAt = (text.empty()) ? 0 : GetTickCount64(); } void PlotView::SetHintSticky(const std::wstring& text) { m_hintText = text; m_hintSticky = !text.empty(); m_hintShownAt = (text.empty()) ? 0 : GetTickCount64(); } void PlotView::ShowPersistentHint(const std::wstring& text) { SetHintSticky(text); } void PlotView::ReleaseHint() { // 粘滞结束:从这里开始重新计时(先停留再淡出)。 m_hintSticky = false; if (!m_hintText.empty()) { m_hintShownAt = GetTickCount64(); } } bool PlotView::HintNeedsAnimation() const { if (m_hintText.empty() || m_hintShownAt == 0) { return false; } if (m_hintSticky) { return true; } const std::uint64_t elapsed = GetTickCount64() - m_hintShownAt; return elapsed < (kHintHoldMs + kHintFadeMs); } double PlotView::CursorSpan() const { if (m_tab == nullptr || Tab().CursorMode() != PlotCursorMode::DOUBLE) { return 0.0; } return std::fabs(Tab().CursorTimeAlt() - Tab().CursorTime()); } void PlotView::ClampCursorToView(int which) { if (m_tab == nullptr || m_plotRect.Width <= 0.0f) { return; } const double low = Tab().ViewMin(); const double high = Tab().ViewMax(); if (!(high > low)) { return; } // 只在拖拽时夹取;留一点内缩,保证光标线及把手完整可见。 // ⚠️ 视图变化(平移 / 缩放 / 自适应 / 滚轮 / 方向键)**不**调用本函数:光标是一个固定的 // **时间位置**,视界移动时它必须留在原处(可以滑出可视区,此时读数贴在网格边缘显示)。 const double inset = (high - low) * 0.002; if (which == 2) { Tab().CursorTimeAlt() = ClampDouble(Tab().CursorTimeAlt(), low + inset, high - inset); } else { Tab().CursorTime() = ClampDouble(Tab().CursorTime(), low + inset, high - inset); } } std::uint32_t PlotView::CursorColor() const { // 两条光标统一使用同一样式与颜色,不按序号区分配色。 if (m_theme == nullptr) { return 0xFF000000u; } return m_theme->cursor; } void PlotView::InvalidateDerivedCaches() { // 后台线程可能在读取采样数据:先暂停并作废异步结果,再清同步缓存。 PauseDerivedAsync(); m_polylineCache.clear(); // 数据层结构变化后边沿统计与均值滤波曲线的派生结果都会失效。 m_edgeScans.clear(); m_edgeScanBudget = 0; m_meanCurveCache.clear(); m_weakPeakCurveCache.clear(); // 信号下标可能已经整体位移:清掉按引用缓存的悬停/拖拽目标,避免指向错误的信号。 m_hoverSignal = SignalRef(); m_hoverLegend = SignalRef(); m_dragSignal = SignalRef(); m_dragTargetLane = -1; // 面板条目栏的悬停同样按引用保存(均值滤波 / 弱峰调整的预览曲线高亮)。 m_meanRowHoverLane = -1; m_meanRowHoverRef = SignalRef(); InvalidateSceneCache(); } void PlotView::PauseDerivedAsync() { // 等待在算任务结束(先发取消),然后清空队列与缓存,并自增代次使任何迟到结果作废。 StopCurveWorker(); std::lock_guard lock(m_curveMutex); ++m_curveGeneration; m_curveJobs.clear(); m_curveEntries.clear(); m_curvePins.clear(); m_curveLatest.clear(); m_curveHugeActive = 0; m_curveSceneDirty.store(false, std::memory_order_relaxed); m_curveCancelAll.store(false, std::memory_order_relaxed); // 恢复:下次需要时可以重新开始 } bool PlotView::AnyDerivedPreviewEnabled() const { if (m_tab == nullptr) { return false; } for (const Lane& lane : m_tab->Lanes()) { if (lane.weakPeak || lane.meanFilter || lane.edgeCenter) { return true; } } return false; } void PlotView::TrimDerivedCurveDataIfIdle() { if (AnyDerivedPreviewEnabled()) { return; // 还有网格在用预览曲线:缓存仍然有用,不能清 } // 停掉后台线程池:这会让各线程的 `thread_local` 工作缓冲**析构并归还内存** —— // 这是"关掉预览后内存不下降"的根因(thread_local 只在线程退出时释放)。 PauseDerivedAsync(); m_meanCurveCache.clear(); m_weakPeakCurveCache.clear(); } bool PlotView::FitTimeToLane(int laneIndex) { if (m_dataSet == nullptr || m_tab == nullptr) { return false; } const std::vector& lanes = m_tab->Lanes(); if (laneIndex < 0 || laneIndex >= static_cast(lanes.size())) { return false; } double low = std::numeric_limits::infinity(); double high = -std::numeric_limits::infinity(); for (const SignalRef& ref : lanes[static_cast(laneIndex)].signals) { const TimeAxis* axis = m_dataSet->Axis(ref); if (axis == nullptr || axis->finiteCount == 0) { continue; } // 与 `TimeBounds` 同源:叠加该信号自己的时间轴偏移(采样画在 t + offsetTime 处)。 const SignalStyle* style = m_dataSet->Style(ref); const double timeShift = (style != nullptr) ? style->offsetTime : 0.0; low = std::min(low, axis->minTime + timeShift); high = std::max(high, axis->maxTime + timeShift); } if (!std::isfinite(low) || !std::isfinite(high) || !(high > low)) { return false; } SetTimeView(low, high); Tab().ViewInitialized() = true; // 与“适应视图”保持一致:退出选区放大模式,避免模式按钮与实际状态不符。 Tab().ZoomMode() = PlotZoomMode::NONE; m_rubberRect = gd::RectF(); m_dragMode = PlotDragMode::NONE; m_rubberAxis = PlotRubberAxis::UNDECIDED; // 针对本网格信号范围展开时间轴后,同时把该网格的 Y 轴自适应一次, // 否则展开后会看到“时间对了但幅值区间不合适”的波形。 AutoRangeLaneToView(laneIndex); // 其它开启了「选区 Y 轴自适应」的网格同样要跟新的时间视图重新匹配: // 时间视图变了,只让被展开的那个网格合适、其余网格停留在旧幅值区间并不符合预期。 ApplySelectionAutoFit(); // 注意:不在这里移动光标——光标允许滞留在网格可视区域之外。 BuildLaneVisuals(); return true; } bool PlotView::MinorCellSparse(const TimeAxis& axis, const SignalData& signal, double timeShift) const { const int cells = GridMinorCellCount(); if (cells <= 0 || signal.finiteCount == 0) { return false; } 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, timeShift); if (end <= begin) { return false; } // 视界内一个可见数据点都没有:既没有要提示的位置,也不该因为“小格全空”而无意义地抖动。 if (!HasVisibleSample(axis, signal, begin, end, timeShift)) { return false; } const double viewMin = Tab().ViewMin() - timeShift; const double viewMax = Tab().ViewMax() - timeShift; const double span = viewMax - viewMin; if (!(span > 0.0)) { return false; } // ⚠️⚠️ **判定只在这个信号【实际有数据】的时间段内进行**(用户 2026-10-17 报告): // 同时加载 `VERY_BIG_DATA`(视界跨度 ~130s)与 MF4(数据只覆盖 ~11s)时, // 旧实现把**整个视界**切成小格 —— MF4 信号在 90% 的格子里本来就没有任何采样点, // 于是“存在一个格子内 < 5 个点”恒成立 ⇒ **默认状态下就被错判成稀疏、自动切点线**。 // 现在先用「可见数据的显示时间跨度」把格子范围收窄,只对这些格子做判定。 double dataMin = viewMin; double dataMax = viewMax; { // 绝大多数信号的两端采样本就有效 ⇒ **零扫描**快速路径(每帧 × 上百个信号,这很重要)。 const bool edgeOk = std::isfinite(signal.values[begin]) && std::isfinite(axis.time[begin]) && std::isfinite(signal.values[end - 1]) && std::isfinite(axis.time[end - 1]); if (edgeOk) { dataMin = axis.time[begin]; dataMax = axis.time[end - 1]; } else { // 端点无效(NaN)时向内侧做**有界**探测(NaN 只出现在缺数据段,几步内就能找到); // 探测不到就回退到整段轴区间(宁可多算几格,也不要放弃判定)。 const std::size_t probe = 64u; const std::size_t forwardCap = std::min(end, begin + probe); bool found = false; for (std::size_t index = begin; index < forwardCap; ++index) { if (std::isfinite(signal.values[index]) && std::isfinite(axis.time[index])) { dataMin = axis.time[index]; found = true; break; } } if (!found) { dataMin = axis.time[begin]; } const std::size_t backwardFloor = (end > probe) ? std::max(begin, end - probe) : begin; found = false; for (std::size_t index = end; index-- > backwardFloor;) { if (std::isfinite(signal.values[index]) && std::isfinite(axis.time[index])) { dataMax = axis.time[index]; found = true; break; } } if (!found) { dataMax = axis.time[end - 1]; } } dataMin = std::max(dataMin, viewMin); dataMax = std::min(dataMax, viewMax); // ⚠️ **`dataMax == dataMin` 是合法且常见的情形**(视界内只有 1 个有效采样点, // 典型:单数据点信号)。旧实现在这里直接 `return false`,等于把"只有一个点" // 的信号判成"不稀疏" ⇒ 光标不会吸附到那个点上 // (用户 2026-10-29 报告"单数据点信号的吸附没了")。 // 正确做法是让它走下面"覆盖那唯一一个小格"的路径: // `firstCell == lastCell`,`need = 5`,而 `finiteCount == 1 < 5` ⇒ 判为稀疏。 if (dataMax < dataMin) { return false; } } const double cellSpan = span / static_cast(cells); if (!(cellSpan > 0.0) || !std::isfinite(cellSpan)) { return false; } // 只覆盖「有数据的显示时间范围」所涉及的格子(闭区间)。 int firstCell = static_cast(std::floor((dataMin - viewMin) / cellSpan)); int lastCell = static_cast(std::floor((dataMax - viewMin) / cellSpan)); firstCell = std::max(0, std::min(firstCell, cells - 1)); lastCell = std::max(0, std::min(lastCell, cells - 1)); if (lastCell < firstCell) { return false; } const int consideredCells = lastCell - firstCell + 1; // 鸽巢原理:有效点总数都不足“每格 5 个” ⇒ 必然存在一个稀疏小格(O(1) 充分判定)。 // ⚠️ 分母必须用**实际参与的格子数**(不是整个视界的格子数)。 const std::size_t need = static_cast(consideredCells) * static_cast(kSparseMinPointsPerCell); if (signal.finiteCount < need) { return true; } // 时间轴单调 ⇒ 每个小格用两次二分取出它的样本区间(代价与采样数无关)。 if (axis.monotonic) { for (int cell = firstCell; cell <= lastCell; ++cell) { const double cellMin = viewMin + cellSpan * static_cast(cell); const double cellMax = (cell + 1 == cells) ? viewMax : (viewMin + cellSpan * static_cast(cell + 1)); // ⚠️ 二分作用在整条 `axis.time` 上,必须夹到 `count`(= min(时间轴长度, values 长度)), // 否则分组时间轴比这条信号长时会越界读 `signal.values[index]`。 const std::size_t lo = std::min( count, static_cast( std::lower_bound(axis.time.begin(), axis.time.end(), cellMin) - axis.time.begin())); const std::size_t hi = std::min( count, static_cast( std::lower_bound(axis.time.begin(), axis.time.end(), cellMax) - axis.time.begin())); const std::size_t cap = std::min(hi, lo + kSparseCellScanCap); std::size_t valid = 0; for (std::size_t index = lo; index < cap; ++index) { if (!std::isfinite(signal.values[index])) { continue; } if (++valid >= static_cast(kSparseMinPointsPerCell)) { break; } } if (valid < static_cast(kSparseMinPointsPerCell)) { return true; // 该小格稀疏 ⇒ 自动切「点线」 } } return false; } // 非单调时间轴:一次有界扫描,按小格分桶计数。 std::vector buckets(static_cast(cells), 0); const std::size_t scanLimit = kSparseCellScanCap * static_cast(cells); std::size_t scanned = 0; for (std::size_t index = begin; index < end; ++index) { if (++scanned > scanLimit) { break; } if (!std::isfinite(signal.values[index])) { continue; } const double time = axis.time[index]; if (!std::isfinite(time) || time < viewMin || time > viewMax) { continue; } int cell = static_cast((time - viewMin) / cellSpan); if (cell < 0) { cell = 0; } if (cell >= cells) { cell = cells - 1; } const std::size_t slot = static_cast(cell); if (buckets[slot] < kSparseMinPointsPerCell) { ++buckets[slot]; } } for (int cell = firstCell; cell <= lastCell; ++cell) { if (buckets[static_cast(cell)] < kSparseMinPointsPerCell) { return true; } } return false; } bool PlotView::NearestSnapSample(const TimeAxis& axis, const SignalData& signal, double time, double timeShift, double& candidate) const { // ⚠️ `time` / `candidate` 都是**显示时间**(画面上的时刻):采样点 k 画在 `time[k] + timeShift` // 处,而光标存的也是显示时间,因此查找与可见性判定都要整体平移(否则存在 // 「调整 → 时间轴偏移」时吸附会落在历史位置上,用户 2026-10-15 报告)。 const double viewMin = Tab().ViewMin() - timeShift; const double viewMax = Tab().ViewMax() - timeShift; // 稀疏信号:有效点已在加载时缓存(按时间升序)⇒ 二分取最近的一个。 if (!signal.sparseTimes.empty()) { const std::vector& times = signal.sparseTimes; const double target = time - timeShift; const std::vector::const_iterator it = std::lower_bound(times.begin(), times.end(), target); bool found = false; double bestDistance = std::numeric_limits::infinity(); for (int side = 0; side < 2; ++side) { // side 0 = 第一个 >= target 的点;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 + timeShift) - time); if (distance < bestDistance) { bestDistance = distance; candidate = value + timeShift; 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 - timeShift); 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 + timeShift; return true; } } return false; } bool PlotView::SnapTimeToSample(double time, double& snapped) const { if (m_dataSet == nullptr || m_tab == nullptr) { return false; } // 吸附判定**只针对鼠标所在的网格**(用户明确要求): // 一个网格内的数据已达“稀疏”标准、另一个没有时,光标只在前者上吸附, // 在后者上仍然完全自由移动。 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; // 同一个网格内可能存在多条信号:**所有**符合吸附判定的数据点都参与吸附。 for (const SignalRef& ref : m_tab->Lanes()[static_cast(laneIndex)].signals) { if (!m_tab->IsVisible(ref)) { continue; } const SignalData* signal = m_dataSet->Resolve(ref); const TimeAxis* axis = m_dataSet->Axis(ref); const SignalStyle* style = m_dataSet->Style(ref); if (signal == nullptr || axis == nullptr) { continue; } // ⚠️ 一切吸附判定与查找都在**显示时间**空间进行:采样点 k 画在 // `time[k] + offsetTime` 处,而光标存的也是显示时间。 // 少了这一步,信号一旦设置过「调整 → 时间轴偏移」,吸附就会落在**历史位置** // (用户 2026-10-15 报告“存在时间轴偏移调整时无法吸附到正确位置”)。 const double timeShift = (style != nullptr) ? style->offsetTime : 0.0; // ⚠️ 判定与「放大自动切点线」**完全同源**(用户 2026-10-17 明确要求“同样的状态判定”): // 只要存在任何一个小格内的数据点少于 5 个就参与吸附; // 判定**不看绘制方式** ⇒ 即使用户手动改过显示样式,该吸附的照样吸附。 // 网格内既有符合又有不符合的信号时,只有符合的那条的数据点进入比较(用户要求)。 if (!MinorCellSparse(*axis, *signal, timeShift)) { continue; } double candidate = 0.0; if (!NearestSnapSample(*axis, *signal, time, timeShift, candidate)) { continue; } // 吸附半径再受“半个局部采样间距”限制(点很稀疏时不被吸死)。 double limitTime = radiusTime; double spacingTime = 0.0; LocalSampleSpacing(*axis, *signal, candidate, timeShift, spacingTime); if (spacingTime > 0.0) { limitTime = std::min(limitTime, spacingTime * 0.5); } const double distance = std::fabs(candidate - time); if (distance <= limitTime && distance < bestDistance) { bestDistance = distance; bestTime = candidate; found = true; } } if (found) { snapped = bestTime; } return found; } void PlotView::PlaceCursorTime(int which, double time) { if (!std::isfinite(time)) { // NaN/Inf 会让后面所有比较都失效(`ClampDouble` 也拦不住 NaN), // 光标会被画到画布之外:直接丢弃这次位置更新。 return; } double snapped = 0.0; if (SnapTimeToSample(time, snapped)) { time = snapped; } if (which == 2) { Tab().CursorTimeAlt() = time; } else { Tab().CursorTime() = time; } ClampCursorToView(which); } PlotZoomMode PlotView::CurrentZoomMode() const { return Tab().ZoomMode(); } bool PlotView::ToggleZoomMode(PlotZoomMode mode) { const PlotZoomMode current = Tab().ZoomMode(); Tab().ZoomMode() = (current == mode) ? PlotZoomMode::NONE : mode; m_rubberAxis = PlotRubberAxis::UNDECIDED; m_rubberRect = gd::RectF(); SetHint(Tab().ZoomMode() == PlotZoomMode::NONE ? std::wstring() : L"横向或纵向拖动以放大选择区域,ESC退出选区模式"); return true; } float PlotView::CursorX(int which) const { const double time = (which == 2) ? Tab().CursorTimeAlt() : Tab().CursorTime(); return TimeToX(time); } float PlotView::CursorDisplayX(int which) const { // 光标允许滞留在可视区之外:此时把它"钉"在最近的网格边缘上显示, // 保证它的取值与时间读数始终可见。 const float raw = CursorX(which); return std::max(m_plotRect.X + 1.0f * m_scale, std::min(raw, m_plotRect.X + m_plotRect.Width - 1.0f * m_scale)); } gd::RectF PlotView::CursorLabelRect(int which) const { if (m_fonts == nullptr || m_fonts->monoSmall == nullptr) { return gd::RectF(); } // 未经叠放处理的"原始"位置:单条标签独占底部一行。 const auto rawRect = [this](int index) -> gd::RectF { const double time = (index == 2) ? Tab().CursorTimeAlt() : Tab().CursorTime(); const std::wstring text = FormatCursorTime(time); const float width = std::max(64.0f * m_scale, UiMeasureTextWidth(m_fonts->monoSmall, text) + 16.0f * m_scale); const float height = 20.0f * m_scale; // 时间读数改到绘图区**底部**:顶部留给网格图例与幅值标尺,避免互相遮挡。 float left = CursorDisplayX(index) - width * 0.5f; left = std::max(m_plotRect.X, std::min(left, m_plotRect.X + m_plotRect.Width - width)); // 光标滞留在视界之外时,胶囊外侧还要放一个方向三角(与网格内数据点越界同一套画法): // 把胶囊整体**向内**让出三角 + 间隙的宽度,否则贴边时三角没有落脚之处、 // 只能压在胶囊上(用户报告的"没有间隔、样式不统一")。 bool offRight = false; if (CursorOffViewSide(index, offRight)) { const float reserved = (kMarkerTriangleDepth + kMarkerTriangleGap) * m_scale; left = offRight ? (left - reserved) : (left + reserved); left = std::max(m_plotRect.X, std::min(left, m_plotRect.X + m_plotRect.Width - width)); } const float top = m_plotRect.Y + m_plotRect.Height - height - 2.0f * m_scale; return gd::RectF(left, top, width, height); }; gd::RectF rect = rawRect(which); // 双光标靠得很近时,两条时间读数标签会互相覆盖:把它们分两行叠放。 // **小值在上、大值在下**(用户明确要求)——按时间值决定行序, // 而不是固定让光标 2 上移。左/右光标被拖动交换后排序依然正确。 if (Tab().CursorMode() == PlotCursorMode::DOUBLE) { const gd::RectF first = rawRect(1); const gd::RectF second = rawRect(2); // 方向三角已移到胶囊**外侧**,胶囊之间不再需要额外留白。 const float gap = 2.0f * m_scale; const bool overlapX = (first.X < second.X + second.Width + gap) && (second.X < first.X + first.Width + gap); if (overlapX) { const bool firstIsSmaller = (Tab().CursorTime() <= Tab().CursorTimeAlt()); // which == 1 是否属于"小值"。 const bool whichIsSmaller = (which == 1) ? firstIsSmaller : !firstIsSmaller; const float bottomRow = rawRect(1).Y; const float topRow = std::max(m_plotRect.Y + 2.0f * m_scale, bottomRow - rect.Height - gap); rect.Y = whichIsSmaller ? topRow : bottomRow; } } return rect; } bool PlotView::LaneAutoFitToSelection(int laneIndex) const { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return false; } return m_tab->Lanes()[static_cast(laneIndex)].fitYToSelection; } bool PlotView::AllLanesAutoFitToSelection() const { if (m_tab == nullptr) { return false; } const std::vector& lanes = m_tab->Lanes(); if (lanes.empty()) { return false; } for (const Lane& lane : lanes) { if (!lane.fitYToSelection) { // 任一网格未开启 → 全局项就不是勾选状态。 return false; } } return true; } bool PlotView::LaneEdgeCount(int laneIndex) const { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return false; } return m_tab->Lanes()[static_cast(laneIndex)].edgeCount; } bool PlotView::SetLaneEdgeCount(int laneIndex, bool enabled) { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return false; } Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; if (lane.edgeCount == enabled) { return false; } lane.edgeCount = enabled; if (enabled) { // 首次开启:把横向光标放到可见幅值范围的中点,并让面板重新展开。 if (!lane.edgeValueValid) { double yMin = 0.0; double yMax = 1.0; LaneRange(laneIndex, yMin, yMax); lane.edgeValue = (yMin + yMax) * 0.5; lane.edgeValueValid = true; } lane.edgePanel.open = true; } else { lane.edgePanel.open = false; } return true; } bool PlotView::ToggleLaneEdgeCount(int laneIndex) { const bool enabled = !LaneEdgeCount(laneIndex); SetLaneEdgeCount(laneIndex, enabled); return enabled; } bool PlotView::LaneEdgeCenterCount(int laneIndex) const { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return false; } return m_tab->Lanes()[static_cast(laneIndex)].edgeCenter; } bool PlotView::SetLaneEdgeCenterCount(int laneIndex, bool enabled) { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return false; } Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; if (lane.edgeCenter == enabled) { return false; } lane.edgeCenter = enabled; if (enabled) { // 与「均值滤波」完全一致:默认只勾选网格内**第一个可见信号**。 if (lane.edgeCenterItems.empty()) { for (const SignalRef& ref : lane.signals) { const ResolvedSignal resolved = ResolveSignal(ref); if (resolved.data != nullptr && resolved.visible) { lane.EnsureEdgeCenterItem(ref); break; } } } lane.edgeCenterPanel.open = true; } else { lane.edgeCenterPanel.open = false; } // 关闭后若本标签页**已无任何预览曲线功能**,立即释放派生曲线数据(缓存 + 后台线程的 // thread_local 工作缓冲)—— 否则关掉面板后内存不会下降(用户报告)。 if (!enabled) { TrimDerivedCurveDataIfIdle(); } // 预建「宽缓冲基底」,避免首次拖动参数滑块时的一次性重建尖峰。 // ⚠️ 只补「缺失 / 失效」的网格,**不**整批重建(见 `EnsureLaneLayersPrefetched`)。 EnsureLaneLayersPrefetched(); return true; } bool PlotView::ToggleLaneEdgeCenterCount(int laneIndex) { const bool enabled = !LaneEdgeCenterCount(laneIndex); SetLaneEdgeCenterCount(laneIndex, enabled); return enabled; } bool PlotView::ToggleLaneEdgeCenterSignal(int laneIndex, const SignalRef& ref) { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return false; } Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; if (lane.EdgeCenterEnabled(ref)) { lane.RemoveEdgeCenterItem(ref); // 该信号的累积统计状态一并释放(否则会占着 `kEdgeScanStateLimit` 的名额)。 InvalidateEdgeScansFor(ResolveSignal(ref).data, true); return false; } lane.EnsureEdgeCenterItem(ref); return true; } bool PlotView::LaneEdgeCenterSignalEnabled(int laneIndex, const SignalRef& ref) const { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return false; } return m_tab->Lanes()[static_cast(laneIndex)].EdgeCenterEnabled(ref); } bool PlotView::LaneMeanFilter(int laneIndex) const { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return false; } return m_tab->Lanes()[static_cast(laneIndex)].meanFilter; } bool PlotView::LaneWeakPeak(int laneIndex) const { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return false; } return m_tab->Lanes()[static_cast(laneIndex)].weakPeak; } bool PlotView::SetLaneWeakPeak(int laneIndex, bool enabled) { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return false; } Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; if (lane.weakPeak == enabled) { return false; } lane.weakPeak = enabled; if (enabled) { // 与「均值滤波」完全一致:默认只勾选网格内**第一个可见信号**。 if (lane.weakPeakItems.empty()) { for (const SignalRef& ref : lane.signals) { const ResolvedSignal resolved = ResolveSignal(ref); if (resolved.data != nullptr && resolved.visible) { lane.EnsureWeakPeakItem(ref); break; } } } lane.weakPeakPanel.open = true; } else { lane.weakPeakPanel.open = false; } // 关闭后若本标签页**已无任何预览曲线功能**,立即释放派生曲线数据(缓存 + 后台线程的 // thread_local 工作缓冲)—— 否则关掉面板后内存不会下降(用户报告)。 if (!enabled) { TrimDerivedCurveDataIfIdle(); } // 预建「宽缓冲基底」(不含派生曲线):只为**尚未建立 / 已失效**的网格补图层, // 避免用户首次拖动该滑块时一次性重建的尖峰。 // ⚠️⚠️ **绝不能在这里无条件重建【全部】网格**(旧实现是 `RebuildWideBuffer()`): // “全部信号各自一块网格”时可达上百个网格、其中可能含着千万级采样的巨型信号, // 整批重渲染要**几百毫秒** ⇒ 用户点一下功能开关 / 关闭浮动面板就会“卡住一段时间” //(用户 2026-10-17 报告:三明治加载后开关面板会卡、关面板要等一会才消失)。 // ✓ 只有「图层数组与网格数量不符」(首次绘制 / 增删网格)才需要同步整批建立, // 其余只登记进待重画队列,由既有预算机制(每帧最多一个网格)逐帧补齐。 EnsureLaneLayersPrefetched(); return true; } bool PlotView::ToggleLaneWeakPeak(int laneIndex) { const bool enabled = !LaneWeakPeak(laneIndex); SetLaneWeakPeak(laneIndex, enabled); return enabled; } bool PlotView::ToggleLaneWeakPeakSignal(int laneIndex, const SignalRef& ref) { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return false; } Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; if (lane.WeakPeakEnabled(ref)) { lane.RemoveWeakPeakItem(ref); return false; } lane.EnsureWeakPeakItem(ref); return true; } bool PlotView::LaneWeakPeakSignalEnabled(int laneIndex, const SignalRef& ref) const { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return false; } return m_tab->Lanes()[static_cast(laneIndex)].WeakPeakEnabled(ref); } bool PlotView::TakePendingWeakPeakBuild(int& laneIndex, std::vector& items) { if (m_pendingWeakPeakLane < 0) { return false; } laneIndex = m_pendingWeakPeakLane; items = m_pendingWeakPeakItems; m_pendingWeakPeakLane = -1; m_pendingWeakPeakItems.clear(); return true; } bool PlotView::SetLaneMeanFilter(int laneIndex, bool enabled) { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return false; } Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; if (lane.meanFilter == enabled) { return false; } lane.meanFilter = enabled; if (enabled) { // 默认只勾选网格内**第一个可见信号**(用户明确要求),其余保持未勾选。 if (lane.meanFilterItems.empty()) { for (const SignalRef& ref : lane.signals) { const ResolvedSignal resolved = ResolveSignal(ref); if (resolved.data != nullptr && resolved.visible) { lane.EnsureMeanFilterItem(ref); break; } } } lane.meanFilterPanel.open = true; } else { lane.meanFilterPanel.open = false; } // 关闭后若本标签页**已无任何预览曲线功能**,立即释放派生曲线数据(缓存 + 后台线程的 // thread_local 工作缓冲)—— 否则关掉面板后内存不会下降(用户报告)。 if (!enabled) { TrimDerivedCurveDataIfIdle(); } // 预建「宽缓冲基底」,避免首次拖动参数滑块时的一次性重建尖峰。 // ⚠️ 只补「缺失 / 失效」的网格,**不**整批重建(见 `EnsureLaneLayersPrefetched`)。 EnsureLaneLayersPrefetched(); return true; } bool PlotView::ToggleLaneMeanFilterSignal(int laneIndex, const SignalRef& ref) { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return false; } Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; if (lane.MeanFilterEnabled(ref)) { lane.RemoveMeanFilterItem(ref); return false; } lane.EnsureMeanFilterItem(ref); return true; } bool PlotView::LaneMeanFilterSignalEnabled(int laneIndex, const SignalRef& ref) const { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return false; } return m_tab->Lanes()[static_cast(laneIndex)].MeanFilterEnabled(ref); } bool PlotView::ToggleLaneMeanFilter(int laneIndex) { const bool enabled = !LaneMeanFilter(laneIndex); SetLaneMeanFilter(laneIndex, enabled); return enabled; } bool PlotView::TakePendingMeanFilterBuild(int& laneIndex, std::vector& items) { if (m_pendingMeanFilterLane < 0) { return false; } laneIndex = m_pendingMeanFilterLane; items = m_pendingMeanFilterItems; m_pendingMeanFilterLane = -1; m_pendingMeanFilterItems.clear(); return true; } bool PlotView::SetLaneFreeMarker(int laneIndex, bool enabled) { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return false; } Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; if (lane.freeMarker == enabled) { return false; } lane.freeMarker = enabled; // 关闭时**保留**已固定的点(用户可能只是暂时收起光标,再打开还能继续看); // 清空固定点由面板上的 ✕ 负责。 lane.freeMarkerPanel.open = enabled; if (!enabled && m_freeMarker.laneIndex == laneIndex) { m_freeMarker = FreeMarkerProbe(); // 跟随光标立即失效 } // 自由标记只画叠加层(不进图层位图),但网格底衬仍可能需要预建, // 与其它实验性功能保持同一套"只补缺失图层"的处理。 EnsureLaneLayersPrefetched(); return true; } bool PlotView::LaneFreeMarker(int laneIndex) const { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return false; } return m_tab->Lanes()[static_cast(laneIndex)].freeMarker; } bool PlotView::ToggleLaneFreeMarker(int laneIndex) { const bool enabled = !LaneFreeMarker(laneIndex); SetLaneFreeMarker(laneIndex, enabled); return enabled; } bool PlotView::SetLaneFreeMarkerCursor(int laneIndex, bool enabled) { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return false; } Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; if (!lane.freeMarker || lane.freeMarkerCursor == enabled) { return false; } lane.freeMarkerCursor = enabled; // 光标模式的可见结果依赖鼠标位置,模式一变必须让已缓存的探针立刻作废, // 否则关闭后仍会残留一条纵向光标(或开启后要等下一次移动才出现)。 if (m_freeMarker.laneIndex == laneIndex) { m_freeMarker = FreeMarkerProbe(); } return true; } bool PlotView::LaneFreeMarkerCursor(int laneIndex) const { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return false; } const Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; return lane.freeMarker && lane.freeMarkerCursor; } bool PlotView::ToggleLaneFreeMarkerCursor(int laneIndex) { const bool enabled = !LaneFreeMarkerCursor(laneIndex); SetLaneFreeMarkerCursor(laneIndex, enabled); return enabled; } bool PlotView::SetLaneFreeMarkerShowDelta(int laneIndex, bool enabled) { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return false; } Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; if (lane.freeMarkerShowDelta == enabled) { return false; } lane.freeMarkerShowDelta = enabled; return true; } bool PlotView::SetLaneFreeMarkerShowEnum(int laneIndex, bool enabled) { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return false; } Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; if (lane.freeMarkerShowEnum == enabled) { return false; } lane.freeMarkerShowEnum = enabled; return true; } bool PlotView::LaneFreeMarkerShowDelta(int laneIndex) const { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return false; } return m_tab->Lanes()[static_cast(laneIndex)].freeMarkerShowDelta; } bool PlotView::LaneFreeMarkerShowEnum(int laneIndex) const { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return false; } return m_tab->Lanes()[static_cast(laneIndex)].freeMarkerShowEnum; } bool PlotView::SetLaneFreeMarkerOpacity(int laneIndex, float opacity) { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return false; } Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; const float clamped = std::max(0.0f, std::min(1.0f, opacity)); if (std::fabs(lane.freeMarkerOpacity - clamped) < 0.0005f) { return false; } lane.freeMarkerOpacity = clamped; return true; } bool PlotView::SetLaneFreeMarkerScroll(int laneIndex, float scroll) { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return false; } Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; const float clamped = std::max(0.0f, scroll); if (std::fabs(lane.freeMarkerScroll - clamped) < 0.5f) { return false; } lane.freeMarkerScroll = clamped; return true; } bool PlotView::SetLaneMeanFilterScroll(int laneIndex, float scroll) { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return false; } Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; const float clamped = std::max(0.0f, scroll); if (std::fabs(lane.meanFilterScroll - clamped) < 0.5f) { return false; } // 滚动只影响面板内部像素,缓冲区/场景都不变(面板像素缓存按键失效即可)。 lane.meanFilterScroll = clamped; return true; } bool PlotView::SetLaneEdgeCenterScroll(int laneIndex, float scroll) { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return false; } Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; const float clamped = std::max(0.0f, scroll); if (std::fabs(lane.edgeCenterScroll - clamped) < 0.5f) { return false; } // 滚动只影响面板内部像素,缓冲区/场景都不变(面板像素缓存按键失效即可)。 lane.edgeCenterScroll = clamped; return true; } bool PlotView::SetLaneWeakPeakScroll(int laneIndex, float scroll) { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return false; } Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; const float clamped = std::max(0.0f, scroll); if (std::fabs(lane.weakPeakScroll - clamped) < 0.5f) { return false; } // 滚动只影响面板内部像素,缓冲区/场景都不变(面板像素缓存按键失效即可)。 lane.weakPeakScroll = clamped; return true; } bool PlotView::SetLaneEdgeCountScroll(int laneIndex, float scroll) { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return false; } Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; const float clamped = std::max(0.0f, scroll); if (std::fabs(lane.edgeCountScroll - clamped) < 0.5f) { return false; } // 滚动只影响面板内部像素,缓冲区/场景都不变(面板像素缓存按键失效即可)。 lane.edgeCountScroll = clamped; return true; } bool PlotView::ToggleLaneFreeMarkerGroup(int laneIndex, const SignalRef& ref) { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return false; } Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; return lane.ToggleFreeMarkerGroupCollapsed(ref); } bool PlotView::SelectLaneFreeMarker(int laneIndex, int itemIndex) { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return false; } Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; // 越界一律视为“取消选中”(面板重建、条目被删之后调用方不必先做下界检查)。 const int clamped = (itemIndex >= 0 && static_cast(itemIndex) < lane.freeMarkerItems.size()) ? itemIndex : -1; if (lane.freeMarkerSelected == clamped) { return false; } lane.freeMarkerSelected = clamped; return true; } bool PlotView::SetLanePanelCollapsed(int laneIndex, EdgePanelKind kind, bool collapsed) { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return false; } Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; LanePanelState* state = nullptr; switch (kind) { case EdgePanelKind::COUNT: state = &lane.edgePanel; break; case EdgePanelKind::CENTER: state = &lane.edgeCenterPanel; break; case EdgePanelKind::MEAN: state = &lane.meanFilterPanel; break; case EdgePanelKind::PEAK: state = &lane.weakPeakPanel; break; case EdgePanelKind::MARK: state = &lane.freeMarkerPanel; break; default: return false; } if (state->collapsed == collapsed) { return false; } state->collapsed = collapsed; return true; } bool PlotView::LanePanelCollapsed(int laneIndex, EdgePanelKind kind) const { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return false; } const Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; switch (kind) { case EdgePanelKind::COUNT: return lane.edgePanel.collapsed; case EdgePanelKind::CENTER: return lane.edgeCenterPanel.collapsed; case EdgePanelKind::MEAN: return lane.meanFilterPanel.collapsed; case EdgePanelKind::PEAK: return lane.weakPeakPanel.collapsed; case EdgePanelKind::MARK: return lane.freeMarkerPanel.collapsed; default: return false; } } bool PlotView::ToggleLanePanelCollapsed(int laneIndex, EdgePanelKind kind) { const bool collapsed = !LanePanelCollapsed(laneIndex, kind); SetLanePanelCollapsed(laneIndex, kind, collapsed); return collapsed; } bool PlotView::MeanFilterValueAt(const TimeAxis& axis, const SignalData& signal, int points, int iterations, double time, double& value) const { const int index = FindNearestSample(axis, time); if (index < 0) { return false; } // ⚠️ **绝不能调用整条同步的 `MeanFilterCurveFor`**:光标读数在绘制路径上被调用, // 整条曲线(千万级采样)同步计算会让界面停顿数秒(用户报告"程序被卡住很久")。 // 这里只取「需要的那一个下标 ± 保护带」的窗口,并在窗口很大时走后台异步计算 // (本帧取不到就返回 false,读数暂时不显示该曲线的取值)。 std::size_t spanBegin = 0; const std::vector* curve = CurveSpanFor( DerivedCurveKind::MEAN_FILTER, signal, points, iterations, 0, static_cast(index), static_cast(index) + 1, spanBegin, true); if (curve == nullptr || static_cast(index) < spanBegin || (static_cast(index) - spanBegin) >= curve->size()) { return false; } const float sampled = (*curve)[static_cast(index) - spanBegin]; if (!std::isfinite(sampled)) { return false; } value = static_cast(sampled); return true; } void PlotView::VisibleIndexRange(const TimeAxis& axis, std::size_t count, std::size_t& begin, std::size_t& end, double timeShift) const { begin = 0; end = count; if (!axis.monotonic || count == 0) { return; } // 时间偏移:采样画在 `t + timeShift` 处,因此落在当前视界内的采样其源时刻区间要整体左移。 const std::vector& time = axis.time; begin = static_cast( std::lower_bound(time.begin(), time.end(), Tab().ViewMin() - timeShift) - time.begin()); if (begin > 0) { --begin; } end = static_cast( std::lower_bound(time.begin(), time.end(), Tab().ViewMax() - timeShift) - time.begin()); if (end < count) { ++end; } end = std::max(end, begin); begin = std::min(begin, count); end = std::min(end, count); } const std::vector* PlotView::CurveSpanFor(DerivedCurveKind kind, const SignalData& signal, int points, int iterations, int amountPercent, std::size_t begin, std::size_t end, std::size_t& spanBegin, bool allowAsyncCompute, bool computeIfMissing) const { spanBegin = 0; const std::size_t count = signal.values.size(); if (count == 0) { return nullptr; } begin = std::min(begin, count); end = std::min(std::max(end, begin), count); if (end <= begin) { return nullptr; } const int kindValue = static_cast(kind); const int safePoints = std::max(0, std::min(points, 1000000)); const int safeIterations = std::max(1, std::min(iterations, 1000)); const int safeLevel = std::max(kWeakPeakAmountMin, std::min(kWeakPeakAmountMax, amountPercent)); // 对齐到粗网格:轻微平移/缩放时可直接复用同一条窗口,避免每帧重算。 const std::size_t grid = std::max(4096, (end - begin) / 4); const std::size_t alignedBegin = (begin / grid) * grid; const std::size_t alignedEnd = std::min(count, ((end + grid - 1) / grid) * grid); CurveKey key; key.signal = &signal; key.kind = kindValue; key.points = safePoints; key.iterations = safeIterations; key.amountPercent = safeLevel; // 1) 已有覆盖请求区间的最新条目 → 直接复用(钉住,防止本帧被回收)。 if (const CurveEntry* hit = AcquireCoveringCurve(key, begin, end, spanBegin)) { return &hit->values; } // 1b) 「只读」模式(光标取值胶囊):**绝不新建计算任务 / 条目**。 if (!computeIfMissing) { if (const CurveEntry* stale = AcquireNewestCurve(key, begin, end, spanBegin)) { return &stale->values; } return nullptr; } // 1b) “只读”模式(光标取值胶囊):**绝不新建计算任务/条目**。 // // ⚠️⚠️ 为什么必须这么做(用户 2026-11-02 反馈“拖光标时有明显卡顿”): // 胶囊只为“光标所在的那两个采样”取值,请求窗口只有几个采样 —— 但缓存键里含窗口, // 而窗口随光标每帧都在变 ⇒ 每帧都未命中 ⇒ 每帧同步计算并 `PublishCurve` 一次。 // 15 信号 × 3 种曲线 = 每帧 45 次发布,条目列表不断变长,`Acquire*` 的线性搜索 // 也随之变慢(实测拖动光标单帧 200ms,其中约 150ms 来自这 45 次重算 + 发布)。 // 现在:命中就取(绘制路径发布的窗口**带保护带**、覆盖整个可见区间,正常情况一定命中), // 没命中就退到同 (信号, 种类) 的最新曲线,再没有就本帧不标注。 if (!computeIfMissing) { if (const CurveEntry* stale = AcquireNewestCurve(key, begin, end, spanBegin)) { return &stale->values; } return nullptr; } // 2) 代价较大时交后台计算,本帧先用最新旧曲线顶替(双缓冲)。 // 这样绘制线程永远不做重计算,拖动滑块/光标/视界/缩放的反馈因此始终跟手。 // // ⚠️ **超大信号 + 正在拖动参数:不要每帧都排一次"整条信号"的重算**。 // 预览曲线一律按整条信号计算(`[0, count]`,与「构建为新信号」逐位一致); // 当信号极大(`VERY_BIG_DATA` 有 2576 万采样)时,每条曲线都要扫描上亿个采样, // 后台线程会被**持续占满**(实测把可用内存带宽吃光),反过来把前台绘制拖慢 // (用户报告的"深放大下拖预览滑块非常非常卡"——实测前台每帧 14ms,而绘制本身只要 0.4ms)。 // 此时沿用旧曲线,等松手(不再处于拖拽)后再算一次最新的即可:反正整条信号的重算 // 本来也追不上滑块速度,拖动期间"冻结"与"每帧重排"在观感上没有区别,但后者会拖慢前台。 // 小信号(< `kHugeSignalPreviewSamples`)不受影响,仍保留"边拖边算"的实时预览。 if (count > kHugeSignalPreviewSamples && m_dragMode != PlotDragMode::NONE) { if (const CurveEntry* stale = AcquireNewestCurve(key, begin, end, spanBegin)) { return &stale->values; } return nullptr; } // 计算窗口(含保护带):既用于同步计算,也用于判断是否值得交后台。 std::size_t computedBegin = 0; std::size_t computedEnd = 0; DerivedGuardWindow(kind, alignedBegin, alignedEnd, safePoints, safeIterations, count, computedBegin, computedEnd); if (computedEnd <= computedBegin) { return nullptr; } const std::size_t computeSamples = computedEnd - computedBegin; if (allowAsyncCompute && computeSamples > kDerivedAsyncWindowThreshold) { ScheduleCurveJob(key, alignedBegin, alignedEnd, count); if (const CurveEntry* stale = AcquireNewestCurve(key, begin, end, spanBegin)) { return &stale->values; } return nullptr; // 尚无任何可显示曲线:本帧跳过该预览曲线(不阻塞绘制) } // 3) 代价很小或需要精确取值 → 同步计算并发布(代价可接受,且保证取值精确)。 // ⚠️ 同步路径也要先做内存判断:窗口虽小,但低内存设备上仍可能失败 —— // 宁可这次不画预览,也不能抛 `bad_alloc` 把进程带走。 { bool throttle = false; // 窗口 ≤ 门槛(65536 采样)→ 无需节流 if (!DerivedMemoryAllows(computeSamples, throttle)) { return nullptr; } } std::vector values = ComputeDerivedWindow(signal, computedBegin, computedEnd, kindValue, safePoints, safeIterations, safeLevel, nullptr, false); const CurveEntry* published = PublishCurve(key, computedBegin, computedEnd, std::move(values), spanBegin); return (published != nullptr) ? &published->values : nullptr; } void PlotView::DerivedGuardWindow(DerivedCurveKind kind, std::size_t alignedBegin, std::size_t alignedEnd, int points, int iterations, std::size_t count, std::size_t& begin, std::size_t& end) const { // 保护带取窗口长度的 1/8(夹在 4096~65536):弱峰调整的修正沿相邻极值向内传播, // 1/8 个可见宽度足以覆盖真实信号的极值间距。 // 均值滤波必须额外**覆盖滤波半径**:N 阶共向外传播 X*N 个采样,否则窗口边界取值 // 会与「整条曲线」不同(边界上可用采样数更少)。 std::size_t guard = std::clamp((alignedEnd - alignedBegin) / 8, std::size_t(4096), kDerivedCurveGuardMax); if (kind == DerivedCurveKind::MEAN_FILTER) { const std::size_t reach = static_cast(points + 1) * static_cast(iterations) + 64u; guard = std::clamp(std::max(guard, reach), std::size_t(4096), kDerivedCurveGuardMax); } begin = (alignedBegin > guard) ? (alignedBegin - guard) : 0; end = std::min(count, alignedEnd + guard); } const PlotView::CurveEntry* PlotView::AcquireCoveringCurve(const CurveKey& key, std::size_t begin, std::size_t end, std::size_t& spanBegin) const { std::lock_guard lock(m_curveMutex); const CurveEntryPtr* best = nullptr; for (const CurveEntryPtr& entry : m_curveEntries) { if (entry->key == key && entry->begin <= begin && entry->end >= end) { if (best == nullptr || entry->serial > (*best)->serial) { best = &entry; } } } if (best == nullptr) { return nullptr; } m_curvePins.push_back(*best); // 钉住:保证本帧内指针有效 spanBegin = (*best)->begin; return best->get(); } const PlotView::CurveEntry* PlotView::AcquireNewestCurve(const CurveKey& key, std::size_t begin, std::size_t end, std::size_t& spanBegin) const { std::lock_guard lock(m_curveMutex); // ⚠️ **只按「信号 + 曲线种类」匹配**(忽略参数):拖动参数滑块时每帧参数都在变, // 若要求键完全相同就永远找不到"上一帧的曲线",于是本帧无曲线可画 → **预览曲线闪烁**、 // 甚至短暂消失(用户报告的"滑动滑块时曲线消失一会儿又出现")。 // 旧曲线只是参数略旧,按采样下标映射位置依然完全正确,先顶上再等后台算好新曲线, // 视觉上就是平滑过渡而不是闪烁。 // // 优先挑「窗口覆盖所需区间」的:覆盖不到时曲线会缺一段(看起来像被切断)。 const CurveEntryPtr* newest = nullptr; const CurveEntryPtr* newestCovering = nullptr; for (const CurveEntryPtr& entry : m_curveEntries) { if (entry->key.signal != key.signal || entry->key.kind != key.kind) { continue; } if (newest == nullptr || entry->serial > (*newest)->serial) { newest = &entry; } if (entry->begin <= begin && entry->end >= end && (newestCovering == nullptr || entry->serial > (*newestCovering)->serial)) { newestCovering = &entry; } } const CurveEntryPtr* pick = (newestCovering != nullptr) ? newestCovering : newest; if (pick == nullptr) { return nullptr; } m_curvePins.push_back(*pick); spanBegin = (*pick)->begin; return pick->get(); } const PlotView::CurveEntry* PlotView::PublishCurve(const CurveKey& key, std::size_t begin, std::size_t end, std::vector&& values, std::size_t& spanBegin) const { std::shared_ptr entry = std::make_shared(); entry->key = key; entry->begin = begin; entry->end = end; entry->values = std::move(values); { std::lock_guard lock(m_curveMutex); entry->serial = ++m_curveSerial; m_curveEntries.push_back(entry); } m_curvePins.push_back(entry); spanBegin = begin; return entry.get(); } bool PlotView::CurveJobIsStale(const CurveKey& key, std::uint64_t requestSerial) const { std::lock_guard lock(m_curveMutex); const auto found = m_curveLatest.find(std::make_pair(key.signal, key.kind)); if (found == m_curveLatest.end()) { return true; // 已经被清理(例如预览被关闭)→ 没有意义,直接作废 } return found->second != requestSerial; } std::size_t PlotView::DerivedMinimumBytes(std::size_t samples) { // 输出曲线本体(float,占 1 份)+ 内部工作缓冲(「弱峰调整」实测 ≈34 B/采样, // 含两个 float 工作缓冲与极值表)——取 48 B/采样作为「连一条曲线都装不下」的门槛。 return samples * 48u + (64u * 1024u * 1024u); } bool PlotView::DerivedMemoryAllows(std::size_t samples, bool& throttle) const { throttle = false; const std::size_t minimum = DerivedMinimumBytes(samples); const std::size_t available = AvailablePhysicalBytes(); if (available < minimum) { NotifyDerivedLowMemory(); return false; } // 低于「舒适线」(最小需求的 4 倍)→ 低内存模式:串行 + 节流,慢慢算完。 if (available < minimum * 4u) { throttle = true; } return true; } void PlotView::NotifyDerivedLowMemory() const { if (m_curveLowMemoryWarned) { return; // 同一轮只提示一次,避免反复弹窗 } m_curveLowMemoryWarned = true; const HWND host = m_host; if (host != nullptr) { PostMessageW(host, kMessagePlotLowMemory, 0, 0); } } void PlotView::ScheduleCurveJob(const CurveKey& key, std::size_t alignedBegin, std::size_t alignedEnd, std::size_t count) const { std::size_t windowBegin = 0; std::size_t windowEnd = 0; DerivedGuardWindow(static_cast(key.kind), alignedBegin, alignedEnd, key.points, key.iterations, count, windowBegin, windowEnd); if (windowEnd <= windowBegin) { return; } // 「大任务」判据:窗口采样数超过大信号门槛(此时工作缓冲会到数百 MB 量级)。 const std::size_t windowSamples = windowEnd - windowBegin; const bool huge = (windowSamples >= kHugeSignalPreviewSamples); int hugeLimit = 1; // ⚠️ **内存严重不足时根本不入队**(连一条曲线都装不下):告警一次并直接放弃本次计算。 // 这样“低内存设备”不会因为换页而把整机拖死,也不会抛 `bad_alloc` 逸出线程导致崩溃。 if (huge) { bool throttle = false; if (!DerivedMemoryAllows(windowSamples, throttle)) { return; } hugeLimit = throttle ? 1 : 2; } { std::lock_guard lock(m_curveMutex); // 新的计算请求 ⇒ 取消标志必须清零(此前可能因关闭预览/结构变化而被置位)。 m_curveCancelAll.store(false, std::memory_order_relaxed); // ⚠️ **幂等**:边沿统计会在“曲线还没算好”时每帧请求一次整条曲线的计算。 // 如果每次都当作新请求(自增序号),正在算的那份就会被判为过时而退出 —— // 于是永远算不完(请求 → 作废 → 重请求 的死循环)。 for (const CurveKey& running : m_curveRunningKeys) { if (running == key) { return; // 同一份曲线正在算,无需重复排队 } } for (const CurveJob& pending : m_curveJobs) { if (pending.key == key) { return; // 同一份曲线已在队列里等待 } } // 全局请求序号:这次请求之后,同一 (信号, 种类) 的旧任务就都过时了。 const std::uint64_t requestSerial = ++m_curveRequestSerial; m_curveLatest[std::make_pair(key.signal, key.kind)] = requestSerial; // 同一「信号 + 曲线种类」的**其它参数**的待算任务全部移除:参数已被取代, // 正在计算的那个会在下一阶处看到自己已过时并**立即退出**。 for (auto iterator = m_curveJobs.begin(); iterator != m_curveJobs.end();) { if (iterator->key.signal == key.signal && iterator->key.kind == key.kind) { iterator = m_curveJobs.erase(iterator); } else { ++iterator; } } CurveJob job; job.key = key; job.windowBegin = windowBegin; job.windowEnd = windowEnd; job.generation = m_curveGeneration; job.requestSerial = requestSerial; job.huge = huge; job.hugeLimit = hugeLimit; m_curveJobs.push_back(job); } EnsureCurveWorker(); m_curveCv.notify_all(); } void PlotView::EnsureCurveWorker() const { if (!m_curveWorkers.empty()) { return; } // **多核并行**:后台线程池并发计算多条派生曲线(5 个网格各一条时, // 串行要 5×368ms,并行只要约 1×368ms),预览曲线因此能很快出现。 // 线程数 = 硬件并发 - 1(给 UI/绘制线程留一个核),夹在 1~4。 unsigned int hardware = std::thread::hardware_concurrency(); int workers = static_cast(hardware == 0 ? 2u : hardware) - 1; workers = std::max(1, std::min(workers, 4)); m_curveWorkerStop = false; m_curveWorkers.reserve(static_cast(workers)); for (int index = 0; index < workers; ++index) { m_curveWorkers.emplace_back([this]() { CurveWorkerLoop(); }); } } void PlotView::StopCurveWorker() const { // 先请所有在算任务尽快退出(每阶 / 每 1M 采样检查一次),再等线程结束 —— // 这是「过时任务立即清退并释放资源」的关键一步:千万级采样的一条曲线工作缓冲 // 可达数百 MB,必须尽早归还。 m_curveCancelAll.store(true, std::memory_order_relaxed); if (m_curveWorkers.empty()) { return; } { std::lock_guard lock(m_curveMutex); m_curveWorkerStop = true; m_curveJobs.clear(); m_curveLatest.clear(); m_curveRunningKeys.clear(); } m_curveCv.notify_all(); for (std::thread& worker : m_curveWorkers) { if (worker.joinable()) { worker.join(); } } m_curveWorkers.clear(); m_curveHugeActive = 0; } void PlotView::CurveWorkerLoop() const { // 后台计算线程的优先级降到「低于正常」:派生曲线计算是**可延迟的后台工作**, // 绝不能让它在拖动时与 UI / 绘制线程抢 CPU。降低优先级后,只要 UI 线程要运行, // 系统就会优先调度 UI;计算只在 UI 空闲时占用 CPU,从而保证交互始终跟手 //(用户明确要求"无论如何 UI 及控件的响应需要是跟手的")。 SetThreadPriority(GetCurrentThread(), THREAD_PRIORITY_BELOW_NORMAL); for (;;) { CurveJob job; { std::unique_lock lock(m_curveMutex); m_curveCv.wait(lock, [this]() { return m_curveWorkerStop || !m_curveJobs.empty(); }); if (m_curveJobs.empty()) { return; // 仅在 stop 且队列为空时到达 } job = m_curveJobs.front(); // ⚠️ **大窗口任务不能无限并发**:一条「整段信号」的弱峰调整工作缓冲可达数百 MB, // 多个同时算就是 GB 级峰值(用户报告的 4.4GB)。上限由调度时的内存压力决定: // 内存充足时允许 2 条(仍然很快,但峰值封顶);内存紧张时只允许 1 条。 if (job.huge && m_curveHugeActive >= std::max(1, job.hugeLimit)) { m_curveCv.wait_for(lock, std::chrono::milliseconds(5)); continue; } m_curveJobs.pop_front(); if (job.generation != m_curveGeneration) { continue; // 代次已变化:丢弃过期任务 } if (job.huge) { ++m_curveHugeActive; } // 记录“正在计算的曲线键”:`ScheduleCurveJob` 靠它保持幂等 //(边沿统计会每帧请求同一份整条曲线,不能每次都当作新请求)。 m_curveRunningKeys.push_back(job.key); } // 计算在锁外进行:读取的采样在「结构未变」期间只读、且地址稳定 //(结构变化前宿主会先 `PauseDerivedAsync`)。 // ⚠️ 传入取消标志:一旦同一 (信号, 种类) 有了更新的请求、或发生了结构变化/关闭预览, // 计算会在下一阶(或每 1M 采样)处**立即退出**,尽早归还工作缓冲。 bool throttle = false; if (job.huge) { DerivedMemoryAllows(job.windowEnd - job.windowBegin, throttle); } std::vector values = ComputeDerivedWindow( *job.key.signal, job.windowBegin, job.windowEnd, job.key.kind, job.key.points, job.key.iterations, job.key.amountPercent, &m_curveCancelAll, throttle); if (job.huge) { std::lock_guard lock(m_curveMutex); m_curveHugeActive = std::max(0, m_curveHugeActive - 1); for (auto iterator = m_curveRunningKeys.begin(); iterator != m_curveRunningKeys.end(); ++iterator) { if (*iterator == job.key) { m_curveRunningKeys.erase(iterator); break; } } m_curveCv.notify_all(); } else { std::lock_guard lock(m_curveMutex); for (auto iterator = m_curveRunningKeys.begin(); iterator != m_curveRunningKeys.end(); ++iterator) { if (*iterator == job.key) { m_curveRunningKeys.erase(iterator); break; } } } // ⚠️ **过时结果一律不发布,并立即可回收**: // * 代次变化(结构变化)→ 丢弃; // * 同一 (信号, 种类) 已有更新的请求 → 本结果参数已过时,丢弃(后台很快会有新的)。 // * 被取消(`m_curveCancelAll`)→ 曲线可能只算了一部分,绝不能发布。 if (m_curveCancelAll.load(std::memory_order_relaxed) || CurveJobIsStale(job.key, job.requestSerial)) { continue; } { std::lock_guard lock(m_curveMutex); if (job.generation != m_curveGeneration) { continue; // 计算期间发生了结构变化:丢弃,避免发布过期结果 } if (values.empty()) { continue; } std::shared_ptr entry = std::make_shared(); entry->key = job.key; entry->begin = job.windowBegin; entry->end = job.windowEnd; entry->serial = ++m_curveSerial; entry->values = std::move(values); m_curveEntries.push_back(std::move(entry)); } // 通知宿主重绘:下一帧会取到刚发布的曲线(双缓冲切换)。 m_curveSceneDirty.store(true, std::memory_order_relaxed); const HWND host = m_host; if (host != nullptr) { PostMessageW(host, kMessagePlotDerivedReady, 0, 0); } } } void PlotView::PruneCurveEntriesLocked() const { // 1) 去掉被「同键、更新、且完全覆盖」的条目取代的旧条目。 std::deque kept; for (const CurveEntryPtr& entry : m_curveEntries) { bool superseded = false; for (const CurveEntryPtr& other : m_curveEntries) { if (other.get() == entry.get()) { continue; } if (other->key == entry->key && other->serial > entry->serial && other->begin <= entry->begin && other->end >= entry->end) { superseded = true; break; } } if (!superseded) { kept.push_back(entry); } } m_curveEntries.swap(kept); // 2) 条目数与总字节数双重约束(最旧优先淘汰)。 const auto bytes = [this]() -> std::size_t { std::size_t total = 0; for (const CurveEntryPtr& entry : m_curveEntries) { total += entry->values.size() * sizeof(float); } return total; }; while (!m_curveEntries.empty() && (m_curveEntries.size() > kCurveSpanCacheLimit || bytes() > kCurveSpanCacheMaxBytes)) { m_curveEntries.pop_front(); } } const std::vector& PlotView::MeanFilterCurveFor(const SignalData& signal, int points, int iterations) const { const int radius = std::max(0, points); const int stages = std::max(1, iterations); for (const MeanCurveEntry& entry : m_meanCurveCache) { if (entry.signal == &signal && entry.points == radius && entry.iterations == stages) { return entry.values; } } MeanCurveEntry entry; entry.signal = &signal; entry.points = radius; entry.iterations = stages; // 与「构建为新信号」共用同一实现(Utilities 内的自由函数), // 因此预览曲线与构建出的采样值逐位一致。 ComputeMovingAverageIterated(signal.values, radius, stages, entry.values); m_meanCurveCache.push_back(std::move(entry)); return m_meanCurveCache.back().values; } const std::vector& PlotView::WeakPeakCurveFor(const SignalData& signal, int amountPercent, int iterations) const { const int level = std::max(kWeakPeakAmountMin, std::min(kWeakPeakAmountMax, amountPercent)); const int stages = std::max(kWeakPeakIterationsMin, std::min(kWeakPeakIterationsMaxLimit, iterations)); for (const WeakPeakCurveEntry& entry : m_weakPeakCurveCache) { if (entry.signal == &signal && entry.amountPercent == level && entry.iterations == stages) { return entry.values; } } WeakPeakCurveEntry entry; entry.signal = &signal; entry.amountPercent = level; entry.iterations = stages; // 与「构建为新信号」共用同一实现(Utilities 内的自由函数), // 因此预览曲线与构建出的采样值逐位一致。 ComputeWeakPeakAdjust(signal.values, static_cast(level) / 100.0, stages, entry.values); m_weakPeakCurveCache.push_back(std::move(entry)); return m_weakPeakCurveCache.back().values; } bool PlotView::WeakPeakValueAt(const TimeAxis& axis, const SignalData& signal, int amountPercent, int iterations, double time, double& value) const { const int index = FindNearestSample(axis, time); if (index < 0) { return false; } std::size_t spanBegin = 0; const std::vector* curve = CurveSpanFor( DerivedCurveKind::WEAK_PEAK, signal, 0, iterations, amountPercent, static_cast(index), static_cast(index) + 1, spanBegin); if (curve == nullptr || static_cast(index) < spanBegin || (static_cast(index) - spanBegin) >= curve->size()) { return false; } const float sampled = (*curve)[static_cast(index) - spanBegin]; if (!std::isfinite(sampled)) { return false; } value = static_cast(sampled); return true; } void PlotView::RequestEdgeScanFrame() const { // 统计还没扫完时主动请宿主再送一帧 —— 否则用户不移动鼠标就不会有任何重绘, // 预算也就永远不推进(用户报告:"面板过去很久都一直显示统计中, // 只有当鼠标瞄到浮动面板上时才会显示统计好的值")。 // 与派生曲线就绪用同一条消息(宿主只失效绘图区、不置脏,代价极小)。 // // ⚠️⚠️ **每个绘制帧最多 Post 一次**(用户 2026-11-02 报告“上百条信号时所有交互都卡”): // 本函数在 `EdgeScanFor` 里按“未完成的状态”逐个调用,288 条信号时单帧会 Post 288 次! // 大量 `WM_MSG`+重绘会把优先级更低的 `WM_TIMER` 饿死(分组折起动画因此永远结束不了), // 同时把 CPU 占满、拖慢所有交互。用一个“本帧已请求”标志收敛成一次。 if (m_loadingBusy) { return; // 加载中:不推进、不自请求(否则加载进度被统计重绘拖慢) } if (m_edgeScanRequestFrame == m_edgeScanFrame) { return; } m_edgeScanRequestFrame = m_edgeScanFrame; const HWND host = m_host; if (host != nullptr) { PostMessageW(host, kMessagePlotDerivedReady, 0, 0); } } std::size_t PlotView::EdgeScanStateLimit() const { // 每个信号最多两类累积状态(普通边沿计数 / 中心轴边沿计数),另留一点余量。 // ⚠️ 不能用“当前网格内信号数”:边缘面板可能同时开在多个网格上, // 而这里用全仓可见信号总数更省心(多出来的容量只是几个几十 KB 的块和数组)。 std::size_t signalCount = 0; if (m_dataSet != nullptr) { signalCount = m_dataSet->SignalCount(); } const std::size_t scaled = signalCount * 2u + 16u; return std::max(kEdgeScanStateLimit, scaled); } void PlotView::InvalidateEdgeScansFor(const SignalData* signal, bool centreOnly) { // ⚠️ **定向作废**:只有被改动的那个信号(并且只限需要的那一类)要重算, // 其它信号的键没有变化、累计的块和依然有效 → 必须原样保留。 // 否则(过去用 `m_edgeScans.clear()`)会连**普通边沿计数**与**其它信号的中心轴统计** // 一起丢掉,于是两个浮动面板里的每一个数字都要从头重新扫描: // 用户报告“改变一个信号的中心轴参数,其它未改变参数的统计也重新计算了”。 if (signal == nullptr) { return; } m_edgeScans.erase(std::remove_if(m_edgeScans.begin(), m_edgeScans.end(), [signal, centreOnly](const EdgeScanState& state) { if (state.signal != signal) { return false; // 别的信号:原样保留 } return !centreOnly || state.centre; }), m_edgeScans.end()); } void PlotView::EdgeScanFor(const TimeAxis& axis, const SignalData& signal, const SignalStyle& style, bool centre, double threshold, int points, int iterations, bool hasRange, double rangeMin, double rangeMax, std::size_t& rising, std::size_t& falling, bool& complete) const { rising = 0; falling = 0; complete = false; // ⚠️ **宿主正在加载文件时不推进**:加载线程的主循环不断 `UpdateWindow` 触发整帧绘制, // 若这里继续扫,加载会被统计抢走 CPU(用户报告“统计没算完就无法开始加载”)。 // 加载完成后数据层结构变化会整体作废累积状态,本来就要重算。 if (m_loadingBusy) { return; } const std::size_t count = signal.values.size(); if (count < 2) { complete = true; return; } const bool monotonic = axis.monotonic; // 非单调时间轴无法用「采样对下标区间」表达统计范围,只能逐点按时间过滤 → // 此时区间必须进键(否则会混用别的区间结果)。 const bool rangeInKey = hasRange && !monotonic; const std::uint64_t styleKey = EdgeStyleKey(style); const int safePoints = std::max(0, std::min(points, 1000000)); const int safeIterations = std::max(1, std::min(iterations, 1000)); const bool mirrored = style.mirrorHorizontal; const bool transform = (style.gain != 1.0f || style.absolute || style.mirrorVertical || style.offsetY != 0.0f); // 请求的「采样对」区间 [pairFirst, pairLast](pair k = 采样 k-1 → k,k ≥ 1)。 // ⚠️ 只有**两个端点都落在区间内**的采样对才计入(非单调时由 deltaAt 过滤)。 std::size_t pairFirst = 1; std::size_t pairLast = count - 1; if (hasRange && monotonic) { std::size_t indexBegin = 0; std::size_t indexEnd = 0; EdgeIndexRange(axis, count, hasRange, rangeMin, rangeMax, indexBegin, indexEnd); pairFirst = (indexBegin + 1 < count) ? (indexBegin + 1) : count; pairLast = (indexEnd > 0) ? (indexEnd - 1) : 0; if (pairLast < pairFirst) { complete = true; return; } } // ---- 定位累积状态 ------------------------------------------------------- EdgeScanState* state = nullptr; for (EdgeScanState& candidate : m_edgeScans) { if (candidate.axis == &axis && candidate.signal == &signal && candidate.centre == centre && candidate.styleKey == styleKey && candidate.points == safePoints && candidate.iterations == safeIterations && candidate.hasRange == rangeInKey && (!rangeInKey || (candidate.rangeMin == rangeMin && candidate.rangeMax == rangeMax))) { state = &candidate; break; } } if (state == nullptr) { // ⚠️⚠️ **累积状态上限必须随“信号数”缩放**(用户 2026-11-02 报告:288 条信号合并到 // 同一网格、开启「边沿计数」后**全部停在“统计中…”**且永不更新): // 旧上限固定 64,而面板会在一帧内为**每条信号**各创建一个状态(288 条 > 64)⇒ // 新建状态时总是把队首(尚未扫完的)淘汰 ⇒ 没有任何状态能活到扫完 ⇒ 永远重头开始。 // 每个信号最多两类状态(普通 / 中心轴),因此上限取 `2×信号数 + 余量`。 const std::size_t stateLimit = EdgeScanStateLimit(); if (m_edgeScans.size() >= stateLimit) { // ⚠️ 超限时**优先淘汰「已扫完」的最旧状态**(它重算很快、而且通常已经不需要再变), // 绝不淘汰正在推进的那个 —— 否则其它信号会被迫从头重扫 //(那正是“改一个参数、其它几个信号也重新统计”这类现象)。 std::size_t victim = 0; bool foundVictim = false; for (std::size_t index = 0; index < m_edgeScans.size(); ++index) { if (m_edgeScans[index].complete) { victim = index; foundVictim = true; break; } } if (!foundVictim) { victim = 0; } m_edgeScans.erase(m_edgeScans.begin() + static_cast(victim)); } EdgeScanState created; created.axis = &axis; created.signal = &signal; created.centre = centre; created.styleKey = styleKey; created.points = safePoints; created.iterations = safeIterations; created.hasRange = rangeInKey; created.rangeMin = rangeMin; created.rangeMax = rangeMax; created.threshold = threshold; const std::size_t blocks = (count - 1) / kEdgeBlockSamples + 2; created.blockRising.assign(blocks, 0); created.blockFalling.assign(blocks, 0); created.justReset = true; m_edgeScans.push_back(std::move(created)); state = &m_edgeScans.back(); ++m_edgeScanRevision; // 新状态:让面板重算(可能刚从“统计中…”切换) } else if (!centre && state->threshold != threshold) { // 阈值变了(横向光标正在拖动):**就地重新开始**,不新建条目(避免拖动时状态爆炸)。 // ⚠️ 本 tick 不展示部分值(`justReset`):部分计数每帧都会被重置, // 显示出来就是"错误且不更新的数字"(用户报告过这个现象)。 state->threshold = threshold; state->scannedPair = 1; state->rising = 0; state->falling = 0; state->complete = false; state->justReset = true; std::fill(state->blockRising.begin(), state->blockRising.end(), 0); std::fill(state->blockFalling.begin(), state->blockFalling.end(), 0); ++m_edgeScanRevision; } // ---- 中心轴:**就地**计算所需的那一小段均值滤波曲线 --------------------- // ⚠️ 过去这里用 `AcquireCoveringCurve`/`ScheduleCurveJob` 拿**整条**曲线,带来两个问题: // 1. 千万级信号的一条曲线 ≈103MB,多条信号叠加会超出曲线缓存预算(768MB)→ 条目被 // 淘汰后该信号被判为“曲线未就绪”,界面显示“统计中…”并从头重扫 —— // **偶发**(取决于当时缓存里积了多少条目,用户报告“改一个参数其它几个信号也重新统计”); // 2. 几个信号的统计通过这块共享缓存互相牵连。 // 现在只算「当前扫描块 ± 滤波半径」:`points * iterations` 就足以让块内取值与整条曲线 // 完全一致(迭代 N 次、半径 X 的均值滤波最多向两侧传播 X*N 个采样),内存只有几十 KB, // 且与其它信号、其它曲线彻底解耦。 std::size_t localReach = 0; if (centre) { localReach = static_cast(safePoints) * static_cast(safeIterations) + 2u; localReach = std::min(localReach, kDerivedCurveGuardMax); } const std::vector& values = signal.values; const bool filterByTime = hasRange && !monotonic; /** 把显示下标区间映射为源下标区间(水平镜像时头尾对调)。 */ const auto toSourceRange = [&](std::size_t displayFirst, std::size_t displayLast, std::size_t& srcFirst, std::size_t& srcLast) { if (mirrored) { srcFirst = (count - 1) - displayLast; srcLast = (count - 1) - displayFirst; } else { srcFirst = displayFirst; srcLast = displayLast; } }; /** 确保本地曲线覆盖显示下标区间 [displayFirst, displayLast](含端点)。 */ const auto ensureLocalCurve = [&](std::size_t displayFirst, std::size_t displayLast) -> bool { if (!centre) { return true; } std::size_t srcFirst = 0; std::size_t srcLast = 0; toSourceRange(displayFirst, displayLast, srcFirst, srcLast); if (state->localValid && state->localBegin <= srcFirst && state->localEnd > srcLast) { return true; } const std::size_t windowBegin = (srcFirst > localReach) ? (srcFirst - localReach) : 0; std::size_t windowEnd = std::min(count, srcLast + 1u + localReach); // 至少覆盖 8 个扫描块:迭代均值滤波每算一次窗口要 O(窗口 × 迭代次数), // 只覆盖一个块会让参数较大时每块都重算一遍(摊薄到 8 个块上就便宜得多)。 const std::size_t minSpan = kEdgeBlockSamples * 8u; windowEnd = std::min(count, std::max(windowEnd, windowBegin + minSpan)); if (windowEnd <= windowBegin) { return false; } std::vector local = ComputeDerivedWindow(signal, windowBegin, windowEnd, static_cast(DerivedCurveKind::MEAN_FILTER), safePoints, safeIterations, 0, nullptr, false); if (local.size() != (windowEnd - windowBegin)) { ++m_edgeScanLocalCurveFails; return false; // 计算失败(例如内存不足):本帧不推进,下帧重试 } state->localCurve = std::move(local); state->localBegin = windowBegin; state->localEnd = windowEnd; state->localValid = true; // 本地窗口的计算成本也计入预算(按「采样数 × 迭代次数」折算),避免一帧算太多。 const std::size_t cost = (windowEnd - windowBegin) * static_cast(safeIterations); m_edgeScanBudget -= std::min(m_edgeScanBudget, cost); return true; }; // 判定基准:**绘制后**的值相对阈值 / 中心曲线的偏差(区间之外 / 缺失值 → NaN)。 const auto deltaAt = [&](std::size_t index) -> double { const double nan = std::numeric_limits::quiet_NaN(); if (index >= count) { return nan; } if (filterByTime && index < axis.time.size()) { const double time = axis.time[index]; if (time < rangeMin || time > rangeMax) { return nan; } } const std::size_t sourceIndex = mirrored ? ((count - 1) - index) : index; if (sourceIndex >= count) { return nan; } const double raw = values[sourceIndex]; if (!std::isfinite(raw)) { return nan; } const double drawn = transform ? ApplyDrawTransform(style, raw) : raw; if (!std::isfinite(drawn)) { return nan; } if (!centre) { return drawn - threshold; } if (!state->localValid || sourceIndex < state->localBegin || sourceIndex >= state->localEnd) { return nan; } const float centerSample = state->localCurve[sourceIndex - state->localBegin]; if (!std::isfinite(centerSample)) { return nan; } const double drawnCenter = transform ? ApplyDrawTransform(style, static_cast(centerSample)) : static_cast(centerSample); return std::isfinite(drawnCenter) ? (drawn - drawnCenter) : nan; }; // ---- 推进(受「每帧总量 + 每状态配额」双重限制)-------------------------- // ⚠️ 预算在 `Paint` 开头**按帧**重置(不再按 `GetTickCount64()` 分桶):系统时钟 // 粒度约 15.6ms,帧率一高就会在同一 tick 内连画多帧而完全不推进 —— 用户报告的 // “按住横向光标不动时统计卡在统计中”“回车确认参数后不移动鼠标就一直停在部分值” // 就是这个原因。 // ⚠️ 同时**每个状态各自持有配额**:否则推进总从列表第一个状态扫起,多条信号时 // 只有第一条有进展、其余长期显示“统计中…”(用户报告“很多项都停在统计中不更新”)。 const bool suppressPartial = state->justReset && !state->complete; if (!state->complete && !state->blockRising.empty()) { // ⚠️ 分母只数**还没扫完**的状态:已完成的状态不再消耗配额,若把它们也算进分母, // 上百条短信号里夹着几条千万级长信号时,长信号每帧只能分到 1/300 的预算 // (实测要 20 分钟才出结果)。按“未完成数”分摊后,短信号一完成就把预算让出来。 std::size_t activeStates = 0; for (const EdgeScanState& candidate : m_edgeScans) { if (!candidate.complete) { ++activeStates; } } activeStates = std::max(1, activeStates); const std::size_t share = std::max(kEdgeScanFairShare, activeStates); // ⚠️ **不要给每状态一个固定下限**(旧实现是 `max(60000, budget/share)`): // 状态数一多,下限就让**前几个状态**(恰好是网格里最前面的信号)吃掉整帧预算, // 后面的信号永远分不到配额 ⇒ 一直显示“统计中…”(用户 2026-11-02 报告的 // 288 信号场景)。改为纯按未完成状态数平分本帧预算(至多 1)。 const std::size_t perState = std::max(1, m_edgeScanBudget / share); if (state->pumpedFrame != m_edgeScanFrame) { state->pumpedFrame = m_edgeScanFrame; state->pumpedPairs = 0; } std::size_t quota = (perState > state->pumpedPairs) ? (perState - state->pumpedPairs) : 0; quota = std::min(quota, m_edgeScanBudget); if (quota > 0) { state->justReset = false; // 快路径:时间单调、无水平镜像、无「调整」变换、非中心轴 ⇒ 直接比较采样值与阈值。 // (NaN 与任何数比较都为 false ⇒ 天然满足"NaN 不参与且打断连续"的语义。) // 绝大多数信号都走这条,单次预算的耗时因此从 ~18ms 降到 ~2ms。 const bool plainFast = monotonic && !mirrored && !transform && !centre; std::size_t scanned = 0; std::size_t pair = state->scannedPair; std::size_t block = (pair > 0) ? ((pair - 1) / kEdgeBlockSamples) : 0; /** @brief 本帧的**时间预算**是否已用完(每块检查一次,QPC 开销可忽略)。 */ const auto timeUp = [this]() -> bool { if (m_edgeScanDeadline <= 0) { return false; } LARGE_INTEGER now = {}; QueryPerformanceCounter(&now); return now.QuadPart >= m_edgeScanDeadline; }; while (pair < count && scanned < quota && m_edgeScanBudget > 0) { const std::size_t blockFirst = block * kEdgeBlockSamples + 1; const std::size_t blockLast = std::min(count - 1, (block + 1) * kEdgeBlockSamples); const std::size_t from = std::max(pair, blockFirst); const std::size_t avail = std::min(quota - scanned, m_edgeScanBudget); const std::size_t to = std::min(blockLast, from + avail - 1); if (to < from) { break; } // 中心轴:先确保本地区间曲线覆盖本块(含两端各一个采样点,供边界比较)。 if (!ensureLocalCurve(from - 1, to)) { break; // 算不出来(内存不足等):本帧到此为止,下帧重试 } std::size_t localRising = 0; std::size_t localFalling = 0; if (plainFast) { double previous = values[from - 1] - threshold; for (std::size_t index = from; index <= to; ++index) { const double current = values[index] - threshold; if (previous < 0.0 && current >= 0.0) { ++localRising; } else if (previous > 0.0 && current <= 0.0) { ++localFalling; } previous = current; } } else { double previous = deltaAt(from - 1); for (std::size_t index = from; index <= to; ++index) { const double current = deltaAt(index); if (std::isfinite(previous) && std::isfinite(current)) { if (previous < 0.0 && current >= 0.0) { ++localRising; } else if (previous > 0.0 && current <= 0.0) { ++localFalling; } } previous = current; } } if (block < state->blockRising.size()) { state->blockRising[block] += localRising; state->blockFalling[block] += localFalling; } state->rising += localRising; state->falling += localFalling; scanned += (to - from + 1); pair = to + 1; if (to == blockLast) { ++block; } if (timeUp()) { break; // 本帧的时间预算用完:剩下的帧接着扫 } } state->scannedPair = pair; state->complete = (pair >= count); if (state->complete) { // 扫完后**释放**本地曲线(区间查询需要时会按需重算)—— // 否则多信号网格下每个状态都要常驻几十 KB~几 MB。 state->localCurve.clear(); state->localCurve.shrink_to_fit(); state->localBegin = 0; state->localEnd = 0; state->localValid = false; } state->pumpedPairs += scanned; m_edgeScanBudget -= std::min(m_edgeScanBudget, scanned); if (scanned > 0) { // 数字(或“是否扫完”)变了 ⇒ 面板缓存键要跟着变,否则会一直贴旧像素。 ++m_edgeScanRevision; } } } // ---- 汇总请求区间 ------------------------------------------------------- // 内部整块用块和相加,只有两端边界块(≤ 2 块)需要就地精确扫描 // ⇒ 拖动**时间**光标时单帧代价与区间长度无关。 if (!suppressPartial) { const std::size_t limit = (state->scannedPair > 0) ? std::min(pairLast, state->scannedPair - 1) : 0; if (limit >= pairFirst) { const std::size_t firstBlock = (pairFirst - 1) / kEdgeBlockSamples; const std::size_t lastBlock = (limit - 1) / kEdgeBlockSamples; bool boundaryOk = true; for (std::size_t block = firstBlock; block <= lastBlock && boundaryOk; ++block) { const std::size_t blockFirst = block * kEdgeBlockSamples + 1; const std::size_t blockLast = std::min(count - 1, (block + 1) * kEdgeBlockSamples); const std::size_t low = std::max(pairFirst, blockFirst); const std::size_t high = std::min(limit, blockLast); const bool wholeBlock = (low == blockFirst) && (high == blockLast) && (state->scannedPair > blockLast) && (block < state->blockRising.size()); if (wholeBlock) { rising += state->blockRising[block]; falling += state->blockFalling[block]; continue; } // 边界块 / 尚未扫完的块:就地精确扫描这一小段(≤ 1 个块)。 if (!ensureLocalCurve(low - 1, high)) { boundaryOk = false; // 中心轴曲线算不出来:本帧不展示(下帧重试) break; } double previous = deltaAt(low - 1); for (std::size_t index = low; index <= high; ++index) { const double current = deltaAt(index); if (std::isfinite(previous) && std::isfinite(current)) { if (previous < 0.0 && current >= 0.0) { ++rising; } else if (previous > 0.0 && current <= 0.0) { ++falling; } } previous = current; } } // 边界块扫描失败(中心轴曲线算不出来)时不展示结果:保持 complete=false。 complete = boundaryOk && (pairLast < state->scannedPair); } else { complete = (pairLast < state->scannedPair); } } if (!complete) { RequestEdgeScanFrame(); } } void PlotView::EdgeCountsFor(const TimeAxis& axis, const SignalData& signal, const SignalStyle& style, double edgeValue, bool hasRange, double rangeMin, double rangeMax, std::size_t& rising, std::size_t& falling, bool& complete) const { EdgeScanFor(axis, signal, style, false, edgeValue, 0, 1, hasRange, rangeMin, rangeMax, rising, falling, complete); } void PlotView::EdgeCenterCountsFor(const TimeAxis& axis, const SignalData& signal, const SignalStyle& style, int points, int iterations, bool hasRange, double rangeMin, double rangeMax, std::size_t& rising, std::size_t& falling, bool& complete) const { // 与普通边沿计数完全共用同一套「逐块累积 + 每 tick 预算推进」机制; // 区别只在判定基准(信号 − 中心平均值曲线)与所需曲线(均值滤波,需整条)。 EdgeScanFor(axis, signal, style, true, 0.0, points, iterations, hasRange, rangeMin, rangeMax, rising, falling, complete); } bool PlotView::EdgeRangeActive() const { return m_tab != nullptr && Tab().CursorMode() == PlotCursorMode::DOUBLE; } double PlotView::EdgeRangeMin() const { if (m_tab == nullptr) { return 0.0; } // 左右光标可能被拖动交换:始终取较小者为区间左端。 return std::min(Tab().CursorTime(), Tab().CursorTimeAlt()); } double PlotView::EdgeRangeMax() const { if (m_tab == nullptr) { return 0.0; } return std::max(Tab().CursorTime(), Tab().CursorTimeAlt()); } std::wstring PlotView::EdgeRangeSuffix() const { return EdgeRangeActive() ? std::wstring(L"(光标区间内)") : std::wstring(); } bool PlotView::ToggleLaneAutoFitToSelection(int laneIndex) { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return false; } Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; lane.fitYToSelection = !lane.fitYToSelection; if (lane.fitYToSelection) { // 打开时立即按当前视图自适应一次,否则当前量程不合适时看不到任何变化。 AutoRangeLaneToView(laneIndex); } return lane.fitYToSelection; } void PlotView::ApplyAutoFitToSelectionNow(int laneIndex) { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return; } if (!m_tab->Lanes()[static_cast(laneIndex)].fitYToSelection) { return; } AutoRangeLaneToView(laneIndex); } void PlotView::ApplyAllAutoFitToSelectionNow() { if (m_tab == nullptr) { return; } const std::size_t count = m_tab->Lanes().size(); for (std::size_t index = 0; index < count; ++index) { ApplyAutoFitToSelectionNow(static_cast(index)); } } bool PlotView::SetAllLanesAutoFitToSelection(bool enabled, bool applyNow) { if (m_tab == nullptr) { return false; } bool changed = false; for (Lane& lane : m_tab->Lanes()) { if (lane.fitYToSelection != enabled) { lane.fitYToSelection = enabled; changed = true; } } if (enabled && applyNow) { // 全局打开时立刻让每个网格都自适应一次,保证界面立刻反映开关状态。 ApplyAllAutoFitToSelectionNow(); } return changed; } bool PlotView::AutoRangeLaneToView(int laneIndex) { if (m_tab == nullptr || m_dataSet == nullptr) { return false; } const std::vector& lanes = m_tab->Lanes(); if (laneIndex < 0 || laneIndex >= static_cast(lanes.size())) { return false; } const double viewMin = Tab().ViewMin(); const double viewMax = Tab().ViewMax(); if (!(viewMax > viewMin)) { return false; } // 只统计时间轴当前可见区间内的采样点,因此放大局部波形后可以快速把该段铺满网格。 double low = std::numeric_limits::infinity(); double high = -std::numeric_limits::infinity(); for (const SignalRef& ref : lanes[static_cast(laneIndex)].signals) { if (!Tab().IsVisible(ref)) { continue; } const SignalData* signal = m_dataSet->Resolve(ref); const TimeAxis* axis = m_dataSet->Axis(ref); if (signal == nullptr || axis == nullptr) { continue; } const std::size_t count = std::min(axis->time.size(), signal->values.size()); const SignalStyle* style = m_dataSet->Style(ref); // 时间偏移:可见采样对应的源时刻区间要整体左移(画在 t + shift 处)。 const double timeShift = (style != nullptr) ? style->offsetTime : 0.0; const double sourceMin = viewMin - timeShift; const double sourceMax = viewMax - timeShift; std::size_t begin = 0; std::size_t end = count; if (axis->monotonic) { // 时间单调时直接二分定位区间,百万级采样也只需扫过可见段。 begin = static_cast( std::lower_bound(axis->time.begin(), axis->time.end(), sourceMin) - axis->time.begin()); if (begin > 0) { --begin; } end = static_cast( std::lower_bound(axis->time.begin(), axis->time.end(), sourceMax) - axis->time.begin()); if (end < count) { ++end; } } // ⚠️ 二分作用在**整条** `axis->time` 上,而可访问的采样数是 `count = // min(时间轴长度, values 长度)`。分组的时间轴比某条信号长时(多信号共轴、 // 有的信号短),`begin/end` 会超出 `values` 的范围 ⇒ 下面的逐点扫描越界读。 // 本组件其它索引遍历(`HasVisibleSample` / `VisibleIndexRange` / `BuildPolyline`) // 都做了同样的夹取。 begin = std::min(begin, count); end = std::min(end, count); // ⚠️ **性能:把样式指针提到循环之外**。`DataSet::Style(ref)` 每次调用都要做 // 指针解析与边界检查;放在循环体里等于**每个采样点解析两次** //(实测全段自适应因此从 147ms 劣化到 1200ms —— 8 倍回归)。 const SignalStyle* resolvedStyle = style; // 可能为 nullptr(视为无变换) const bool mirrored = (resolvedStyle != nullptr) && resolvedStyle->mirrorHorizontal; const bool transform = (resolvedStyle != nullptr) && (resolvedStyle->gain != 1.0f || resolvedStyle->absolute || resolvedStyle->mirrorVertical || resolvedStyle->offsetY != 0.0f); // ⚠️ **整段自适应直接取信号已缓存的 min/max**(O(1)): // 当视界覆盖整条信号、且该信号没有任何数值变换时,逐点扫描的结果与 // `ComputeStats` 预存的 min/max 完全一致。工具栏「Y 轴自适应」开关在全视图下 // 会一次性对全部网格做这件事 —— 逐点扫描 2580 万 × 5 是秒级卡顿(实测 1200ms), // 这里改为 O(1)。 if (axis->monotonic && begin == 0 && end == count && !mirrored && !transform && signal->finiteCount > 0) { low = std::min(low, signal->minValue); high = std::max(high, signal->maxValue); continue; } if (!mirrored && !transform) { // 无变换:与既有实现完全一致的紧凑循环(无逐点分支,便于向量化)。 for (std::size_t index = begin; index < end; ++index) { if (!axis->monotonic) { const double t = axis->time[index]; if (!std::isfinite(t) || t < sourceMin || t > sourceMax) { continue; } } const double value = signal->values[index]; if (!std::isfinite(value)) { continue; } low = std::min(low, value); high = std::max(high, value); } continue; } // 有变换(系数 / 绝对值 / 镜像 / Y 偏移)或水平镜像:逐点套用与绘制**相同**的变换。 // ⚠️ 自适应必须基于**画出来的曲线**,否则调整系数/镜像后自适应范围会按原始数据算错 //(用户报告"自适应区域错误,看上去还是按照原始数据来的")。 // 水平镜像时画面上的第 index 个位置取源下标 count-1-index 的值。 for (std::size_t index = begin; index < end; ++index) { if (!axis->monotonic) { const double t = axis->time[index]; if (!std::isfinite(t) || t < sourceMin || t > sourceMax) { continue; } } const std::size_t sourceIndex = (mirrored && count > 0) ? ((count - 1) - index) : index; if (sourceIndex >= count) { continue; } const double raw = signal->values[sourceIndex]; if (!std::isfinite(raw)) { continue; } const double value = transform ? ApplyDrawTransform(*resolvedStyle, raw) : raw; if (std::isfinite(value)) { low = std::min(low, value); high = std::max(high, value); } } } if (!std::isfinite(low) || !std::isfinite(high)) { return false; } // ⚠️ 留白必须与整段自适应(`TabConfig::ComputeLaneRange` → `PadRange`)**共用同一规则** //(`PadValueRange`):否则「点过适应视图后再打开 Y 轴自适应」会因为留白不同 //(6% vs 5%)而算出不一样的区间,网格被无意义地重绘一次; // 反向(关掉 Y 轴自适应再点适应视图)同样会变。 PadValueRange(low, high); Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; lane.autoRange = false; lane.yMin = low; lane.yMax = high; BuildLaneVisuals(); return true; } void PlotView::ApplySelectionAutoFit() { if (m_tab == nullptr || m_dataSet == nullptr) { return; } const std::vector& lanes = m_tab->Lanes(); bool any = false; for (const Lane& lane : lanes) { if (lane.fitYToSelection) { any = true; break; } } if (!any) { return; } // 只对开启了「选区 Y 轴自适应」的网格重新匹配 Y 轴; // 重新匹配依据的是刚刚更新过的时间视图范围。 for (std::size_t index = 0; index < lanes.size(); ++index) { if (m_tab->Lanes()[index].fitYToSelection) { AutoRangeLaneToView(static_cast(index)); } } } void PlotView::ReapplyGlobalAutoFitIfEnabled() { if (m_tab == nullptr || m_dataSet == nullptr) { return; } // 与工具栏「Y 轴自适应」按钮的勾选判据完全一致(全部网格都开启才叫“全局开启”)。 if (!AllLanesAutoFitToSelection()) { return; } // 结构变化后重新匹配一次:新网格要按当前视界自适应, // 移除信号后的网格也要按剩下信号重新收敛(旧幅值区间是按旧信号集合算的)。 ApplyAllAutoFitToSelectionNow(); } 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); const SignalData* data = m_dataSetWritable->Resolve(ref); SignalStyle* style = m_dataSetWritable->Style(ref); if (axis == nullptr || data == nullptr || style == nullptr) { continue; } // ⚠️ **只有一个有效采样点**的信号**不参与**自动切换: // 它们的初始样式固定为「散点」(见 `ApplySinglePointDefaultStyle`),而信号面板的 // 「线段」/「点线」按钮已对其置灰(用户 2026-10-16 要求)—— // 若这里再自动写成「点线」,用户会看到一个“已置灰却仍处于点线态”的矛盾状态。 if (data->finiteCount == 1) { style->autoScatterActive = false; style->autoScatterUserOverride = false; continue; } // ⚠️ **先**判定“用户是否手动改过绘制方式”:本机制只会写入「点线」, // 所以自动启用期间 mode 不等于「点线」只可能是用户改的。 // 这一步必须在下面的分支之前做 —— 用户改完样式可能**紧接着就缩出去** // (例如直接点适应视图),那次调用的视界已经不再满足条件,若等到那时才判定, // 恢复逻辑会先把用户的修改覆盖掉(用户明确要求不能这样)。 if (style->autoScatterActive && style->mode != DrawMode::LINE_SCATTER) { style->autoScatterUserOverride = true; } // 判定改为**用户 2026-10-17 重新定义的新规则**:按缩放后的横向时间轴网格划分小格, // **只要存在任何一个小格内的数据点少于 5 个**就自动切「点线」(局部稀疏即提示); // 不再满足时(适应视图、或把视界移到信号的稠密区)自动恢复原样式。 // ⚠️ 可见性同样按**显示时间**判断(含 `offsetTime`)。 // ⚠️ 「光标吸附」用的是**同一个判定**(`MinorCellSparse`),用户要求两者“同样的状态判定”。 if (MinorCellSparse(*axis, *data, style->offsetTime)) { if (!style->autoScatterActive) { // ⚠️ **只有「线段」模式的信号才自动切「点线」**(用户 2026-10-17 要求): // 本来就是「散点」的信号放大后**不**自动切换(散点已经足够提示数据点位置, // 再叠上线段反而干扰);已经是「点线」的也不需要动。 // ⚠️ 用户在此期间手动改过样式时也不切换,避免“改完下一帧又被改回去”。 if (style->mode != DrawMode::LINE || style->autoScatterUserOverride) { style->autoScatterActive = false; continue; } // 进入阈值:保存原绘制方式并自动切到「点线」提示用户数据点位置。 style->autoScatterSavedMode = style->mode; style->autoScatterActive = true; style->autoScatterUserOverride = false; 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; } float PlotView::SinglePointDotRadius(const ResolvedSignal& resolved) const { if (resolved.style == nullptr) { return kMinScatterRadius * m_scale; } // 与 `PaintLane` / 拖拽虚影画那个点时的半径**完全同源**(散点半径随线宽线性增长), // 命中判定与光晕半径都复用它,三者不会出现“看着点中了却没反应”。 const double radius = ClampDouble(static_cast(resolved.style->lineWidth) * kScatterRadiusScale, static_cast(kMinScatterRadius), static_cast(kMaxScatterRadius)); return static_cast(radius) * m_scale; } bool PlotView::SinglePointScreenPos(const ResolvedSignal& resolved, const LaneVisual& lane, gd::PointF& out) const { if (resolved.data == nullptr || resolved.axis == nullptr || resolved.style == nullptr) { return false; } if (resolved.data->finiteCount != 1) { return false; } double time = 0.0; double value = 0.0; if (!SinglePointOfSignal(*resolved.data, *resolved.axis, time, value)) { return false; } // 与绘制一致:采样画在 `t + offsetTime` 处,取值先过「调整」变换。 const double displayTime = time + resolved.style->offsetTime; const double drawValue = ApplyDrawTransform(*resolved.style, value); if (!std::isfinite(displayTime) || !std::isfinite(drawValue)) { return false; } out.X = TimeToX(displayTime); out.Y = ValueToY(drawValue, lane); return true; } } // namespace jdi