// PlotView 实现分片 —— 本文件只包含 PlotView 的**成员函数定义**(纯代码搬移,行为与拆分前完全一致)。 // // 共用的常量与工具函数在 PlotViewInternal.hpp(命名空间 `pvdetail`,全部 inline)。 // 四个分片的职责划分(按功能): // core 视图核心:构造/析构、上下文绑定、布局、坐标映射、视界与平移、网格结构与归属、 // 选区/网格划分、拖动取消 // paint 渲染与像素缓存:全部绘制(Paint*/Render*)、场景缓存、装饰缓存、每网格图层、 // 宽基底与滚动补条、并行图层重建 // interaction 交互与编辑:命中测试、鼠标与键盘处理、光标形状、内联输入框、浮动面板层序与命中 // features 视图特性与派生数据:光标状态与读数、提示浮层、缩放模式、自适应、光标吸附、 // 单点信号、派生曲线线程池、边沿统计、实验性功能(均值滤波 / 弱峰调整)参数 #include "PlotViewInternal.hpp" namespace jdi { PlotView::PlotView() = default; PlotView::~PlotView() { ReleaseGraphicsResources(); } void PlotView::ReleaseGraphicsResources() { // 先停掉后台计算线程:它可能正在读取采样数据,必须在释放其它资源前回收。 StopCurveWorker(); // 并行图层渲染的工作上下文(各自持有图层位图,须在 GDI+ 关闭前释放)。 ReleaseLayerWorkers(); ReleaseSceneCache(); ReleaseDecorCache(); ReleaseWideBuffer(); ReleasePanelCache(); ReleaseHaloLayer(); } void PlotView::Setup(const UiFonts* fonts, const Theme* theme) { m_fonts = fonts; m_theme = theme; } void PlotView::SetContext(DataSet* dataSet, TabConfig* tab) { m_dataSet = dataSet; m_dataSetWritable = dataSet; m_tab = tab; m_hoverLane = -1; m_dragMode = PlotDragMode::NONE; // 重新绑定数据源后旧的几何键不再有意义,必须清空折线缓存。 InvalidateDerivedCaches(); InvalidateSceneCache(); RefreshLanes(); } void PlotView::SetScale(float scale) { m_scale = (scale > 0.1f) ? scale : 1.0f; InvalidateSceneCache(); InvalidateWideBuffer(); } bool PlotView::HasDrawableContent() const { return (m_dataSet != nullptr) && !m_dataSet->Empty() && (m_tab != nullptr) && m_tab->AnyVisible(); } void PlotView::TimeBounds(double& low, double& high) const { low = 0.0; high = 1.0; if (m_dataSet == nullptr || m_dataSet->Empty()) { return; } // 优先使用可见信号所覆盖的范围,使视图随勾选内容自适应。 if (m_tab != nullptr && m_tab->ComputeVisibleTimeBounds(*m_dataSet, low, high)) { return; } low = m_dataSet->MinTime(); high = m_dataSet->MaxTime(); if (!(high > low)) { high = low + 1.0; } } PlotView::ResolvedSignal PlotView::ResolveSignal(const SignalRef& ref) const { ResolvedSignal result; if (m_dataSet == nullptr) { return result; } result.data = m_dataSet->Resolve(ref); result.group = m_dataSet->GroupAt(ref.groupIndex); result.axis = m_dataSet->Axis(ref); result.style = m_dataSet->Style(ref); result.visible = (m_tab != nullptr) && m_tab->IsVisible(ref); return result; } void PlotView::Layout(const gd::RectF& area) { m_area = area; // 布局变化(窗口缩放、面板宽度调整、网格数量变化)后静态场景不再有效。 InvalidateSceneCache(); InvalidateWideBuffer(); m_decorValid = false; // 装饰锚定在网格左缘,布局变了就必须重画装饰 m_labelBandWidth = 66.0f * m_scale; m_timeAxisHeight = 34.0f * m_scale; // ⚠️ `m_minLaneHeight` 存的是**逻辑像素**(见头文件),使用处才乘 `m_scale`。 // 旧实现这里写成 `54.0f * m_scale`,而 `BuildLaneVisuals` 又乘了一次 `m_scale`, // 在 125% 缩放下实际最小值被放大成 54×1.5625(而非 54×1.25),且与 // 「是否需要滚动条」的判定(只乘一次)不一致 ⇒ 会出现“网格溢出了却没有滚动条”。 m_minLaneHeight = 81.0f; // 用户 2026-10-19:最小网格高度提升到原来的 1.5 倍(54 → 81) // 先为时间轴保留固定高度,剩余空间才交给网格,保证时间轴不会被挤压。 const float topPadding = 6.0f * m_scale; const float availHeight = std::max(0.0f, area.Height - m_timeAxisHeight - topPadding); // 预估是否需要纵向滚动条:仅取决于网格数量与最小网格高度(网格之间不再留间隔)。 std::size_t laneCount = (m_tab != nullptr) ? m_tab->Lanes().size() : 0; if (laneCount > 0) { const float needed = static_cast(laneCount) * m_minLaneHeight * m_scale; m_scrollBarVisible = (needed > availHeight); } else { m_scrollBarVisible = false; } const float sidePadding = kPlotSidePadding * m_scale; const float rightReserve = m_scrollBarVisible ? (kScrollBarThickness * m_scale + sidePadding) : sidePadding; const float plotWidth = std::max(0.0f, area.Width - sidePadding - rightReserve); m_plotRect = gd::RectF(area.X + sidePadding, area.Y + topPadding, plotWidth, availHeight); m_timeAxisRect = gd::RectF(m_plotRect.X, m_plotRect.Y + m_plotRect.Height, m_plotRect.Width, m_timeAxisHeight); m_scrollBarRect = gd::RectF(area.X + area.Width - kScrollBarThickness * m_scale - 3.0f * m_scale, m_plotRect.Y, kScrollBarThickness * m_scale, m_plotRect.Height); BuildLaneVisuals(); // 布局变化带上网格结构变化(`Relayout` 在网格数量变化时被调用): // 新的网格要按当前视界自适应(全局开关开启时),稀疏放大状态也可能随绘图区宽度变化。 ReapplyGlobalAutoFitIfEnabled(); if (UpdateAutoScatterStyles()) { InvalidateSceneCache(); } } void PlotView::RefreshLanes() { BuildLaneVisuals(); // 信号可见性变化后会走这里(网格数量不变的情形): // ① 新出现的信号若继承到「Y 轴自适应」,需要立即按当前视界算一次; // ② 多信号网格内取消显示某条信号后,剩余信号要重新收敛(旧区间含已移除的信号)。 ReapplyGlobalAutoFitIfEnabled(); if (UpdateAutoScatterStyles()) { InvalidateSceneCache(); } } bool PlotView::TakePanelScrollRequest(SignalRef& ref) { if (!m_pendingPanelScrollFlag) { return false; } ref = m_pendingPanelScroll; m_pendingPanelScrollFlag = false; m_pendingPanelScroll = SignalRef(); return ref.IsValid(); } void PlotView::ClampLaneScroll() { const float maxOffset = std::max(0.0f, m_laneContentHeight - m_plotRect.Height); Tab().LaneScroll() = static_cast(ClampDouble(Tab().LaneScroll(), 0.0, maxOffset)); } bool PlotView::ScrollLaneIntoView(int laneIndex) { if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size()) || m_plotRect.Height <= 0.0f) { return false; } // ⚠️ 不能直接用 `laneIndex * 网格高`:网格之间虽然目前没有间距,但内容高度会随 // “最小网格高”夹取而大于绘图区高,公式里已经含有 `- LaneScroll()`。 // 从当前绘制位置反推**内容偏移**最稳妥:rect.Y = plotRect.Y + contentTop - LaneScroll()。 const gd::RectF& rect = m_lanes[static_cast(laneIndex)].rect; const float contentTop = rect.Y - m_plotRect.Y + Tab().LaneScroll(); const float laneHeight = rect.Height; float desired = Tab().LaneScroll(); if (contentTop - desired < 0.0f) { // 目标网格在可视区上方:把它的上边界对齐到绘图区上沿。 desired = contentTop; } else if (contentTop + laneHeight - desired > m_plotRect.Height) { // 目标网格在可视区下方:把它的下边界对齐到绘图区下沿。 desired = contentTop + laneHeight - m_plotRect.Height; } else { return false; // 已经完整可见 } // **平滑滚动**(用户 2026-10-16 要求):与信号列表的“滚动到目标信号”同源, // 先用 ease-out cubic 过渡过去,而不是把视界瞬移过去。 const float maxOffset = std::max(0.0f, m_laneContentHeight - m_plotRect.Height); desired = static_cast(ClampDouble(desired, 0.0, maxOffset)); const float distance = std::fabs(desired - Tab().LaneScroll()); if (distance < 1.0f) { // 差值极小:直接到位(不再为 1px 抖动做动画)。 Tab().LaneScroll() = desired; ClampLaneScroll(); m_laneScrollAnimActive = false; BuildLaneVisuals(); return true; } const float durationMs = std::max( kLaneScrollAnimMinMs, std::min(kLaneScrollAnimMaxMs, distance * kLaneScrollAnimMsPerPixel)); m_laneScrollAnimFrom = Tab().LaneScroll(); m_laneScrollAnimTo = desired; m_laneScrollAnimStartMs = GetTickCount64(); m_laneScrollAnimDurationMs = static_cast(durationMs + 0.5f); m_laneScrollAnimActive = true; return true; } bool PlotView::AdvanceLaneScrollAnimation() { if (!m_laneScrollAnimActive) { return false; } const std::uint64_t now = GetTickCount64(); const std::uint64_t elapsed = (now >= m_laneScrollAnimStartMs) ? (now - m_laneScrollAnimStartMs) : 0; float progress = (m_laneScrollAnimDurationMs > 0u) ? static_cast(elapsed) / static_cast(m_laneScrollAnimDurationMs) : 1.0f; if (progress > 1.0f) { progress = 1.0f; } // ease-out cubic:起步快、靠近目标时减速(与信号列表完全一致的手感)。 const float eased = 1.0f - std::pow(1.0f - progress, 3.0f); Tab().LaneScroll() = m_laneScrollAnimFrom + (m_laneScrollAnimTo - m_laneScrollAnimFrom) * eased; if (progress >= 1.0f) { // 结束:精确落到终点并夹取(内容高度可能在动画期间变化过)。 Tab().LaneScroll() = m_laneScrollAnimTo; m_laneScrollAnimActive = false; ClampLaneScroll(); BuildLaneVisuals(); return false; } // 网格滚动量参与场景/图层指纹与各网格 `rect`,每帧重建可视几何。 ClampLaneScroll(); BuildLaneVisuals(); return true; } bool PlotView::ScrollLanesToTop() { // 已经在最上方:什么都不做(按钮此时也不该可见)。 if (Tab().LaneScroll() <= 0.5f) { Tab().LaneScroll() = 0.0f; m_laneScrollAnimActive = false; return true; } // 复用与「点选信号滚入可视区」完全相同的平滑过渡(ease-out cubic)。 const float distance = Tab().LaneScroll(); const float durationMs = std::max( kLaneScrollAnimMinMs, std::min(kLaneScrollAnimMaxMs, distance * kLaneScrollAnimMsPerPixel)); m_laneScrollAnimFrom = Tab().LaneScroll(); m_laneScrollAnimTo = 0.0f; m_laneScrollAnimStartMs = GetTickCount64(); m_laneScrollAnimDurationMs = static_cast(durationMs + 0.5f); m_laneScrollAnimActive = true; return true; } bool PlotView::UpdateBackToTopTarget() { // 目标可见性 = 网格需要滚动条(说明网格多到需要滚动)且已经向下滚动了一段距离。 const bool want = m_scrollBarVisible && (Tab().LaneScroll() > kBackToTopShowOffset * m_scale); if (want == m_backToTopFadeIn && !m_backToTopAnimating) { return false; } if (want == m_backToTopFadeIn) { return false; // 方向未变且正在过渡:让它继续 } m_backToTopFadeIn = want; m_backToTopAnimating = true; m_backToTopAnimStartMs = GetTickCount64(); if (!want) { // 淡出开始时取消悬停/按下态(按钮即将消失)。 m_backToTopHover = false; m_backToTopPressed = false; } return true; } bool PlotView::AdvanceBackToTopAnimation() { if (!m_backToTopAnimating) { return false; } const std::uint64_t now = GetTickCount64(); const std::uint64_t elapsed = (now >= m_backToTopAnimStartMs) ? (now - m_backToTopAnimStartMs) : 0; // ⚠️ 用户 2026-10-17 要求:**淡出与提示浮层(`PaintHint`)完全一致** —— // 到达顶部后**立即开始**淡出(不停留),时长 = `kBackToTopFadeOutMs`(= `kHintFadeMs`), // 且 alpha **线性**变化(提示浮层就是线性淡出,看起来比 ease-out 更"渐变")。 // 淡入(按钮出现)保留原来的 ease-out 手感,用户没有意见。 const bool fadingOut = !m_backToTopFadeIn; const float durationMs = fadingOut ? kBackToTopFadeOutMs : kBackToTopFadeInMs; float progress = (durationMs > 0.0f) ? (static_cast(elapsed) / durationMs) : 1.0f; if (progress > 1.0f) { progress = 1.0f; } // 淡入:ease-out cubic(起步快、末尾慢);淡出:线性(与提示浮层同款)。 const float eased = fadingOut ? progress : (1.0f - std::pow(1.0f - progress, 3.0f)); const float from = m_backToTopFadeIn ? 0.0f : 1.0f; const float to = m_backToTopFadeIn ? 1.0f : 0.0f; m_backToTopAlpha = from + (to - from) * eased; if (progress >= 1.0f) { m_backToTopAlpha = to; m_backToTopAnimating = false; return false; } return true; } gd::RectF PlotView::BackToTopRect() const { if (!m_scrollBarVisible) { return gd::RectF(); } const float size = kBackToTopSize * m_scale; const float margin = kBackToTopMargin * m_scale; // ⚠️ 贴着**绘图区**右下角(`m_plotRect` 已经让出了右侧滚动条的位置, // 因此按钮不会压到滚动条上)。 return gd::RectF(m_plotRect.X + m_plotRect.Width - margin - size, m_plotRect.Y + m_plotRect.Height - margin - size, size, size); } bool PlotView::LaneSpansMultipleGroups(int laneIndex) const { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return false; } int firstGroup = -1; for (const SignalRef& ref : m_tab->Lanes()[static_cast(laneIndex)].signals) { const SignalStyle* style = m_dataSet->Style(ref); if (style == nullptr || !m_tab->IsVisible(ref)) { continue; } if (firstGroup < 0) { firstGroup = ref.groupIndex; } else if (ref.groupIndex != firstGroup) { return true; } } return false; } void PlotView::BuildLaneVisuals() { m_lanes.clear(); if (m_tab == nullptr || m_dataSet == nullptr || !HasDrawableContent()) { m_laneContentHeight = 0.0f; return; } const std::vector& lanes = m_tab->Lanes(); const std::size_t count = lanes.size(); if (count == 0) { m_laneContentHeight = 0.0f; return; } const float gap = 0.0f; float laneHeight = m_plotRect.Height / static_cast(count); if (laneHeight < m_minLaneHeight * m_scale) { laneHeight = m_minLaneHeight * m_scale; } m_laneContentHeight = laneHeight * static_cast(count); ClampLaneScroll(); const float scrollOffset = Tab().LaneScroll(); m_lanes.resize(count); for (std::size_t index = 0; index < count; ++index) { LaneVisual& visual = m_lanes[index]; visual.rect = gd::RectF( m_plotRect.X, m_plotRect.Y + static_cast(index) * (laneHeight + gap) - scrollOffset, m_plotRect.Width, laneHeight); m_tab->ComputeLaneRange(*m_dataSet, static_cast(index), visual.yMin, visual.yMax); if (!(visual.yMax > visual.yMin)) { visual.yMin = 0.0; visual.yMax = 1.0; } } } float PlotView::TimeToX(double time) const { const double viewMin = Tab().ViewMin(); const double viewMax = Tab().ViewMax(); const double span = viewMax - viewMin; if (!(span > 0.0) || m_plotRect.Width <= 0.0f) { return m_plotRect.X; } return m_plotRect.X + static_cast((time - viewMin) / span * m_plotRect.Width); } double PlotView::XToTime(float x) const { const double viewMin = Tab().ViewMin(); const double span = Tab().ViewMax() - viewMin; if (m_plotRect.Width <= 0.0f || !(span > 0.0)) { return viewMin; } const double ratio = static_cast(x - m_plotRect.X) / static_cast(m_plotRect.Width); return viewMin + ratio * span; } float PlotView::ValueToY(double value, const LaneVisual& lane) const { const double span = lane.yMax - lane.yMin; if (!(span > 0.0)) { return lane.rect.Y + lane.rect.Height * 0.5f; } double ratio = (value - lane.yMin) / span; ratio = ClampDouble(ratio, -50.0, 50.0); return lane.rect.Y + lane.rect.Height * static_cast(1.0 - ratio); } double PlotView::YToValue(float y, const LaneVisual& lane) const { if (lane.rect.Height <= 0.0f) { return lane.yMin; } const double ratio = 1.0 - static_cast(y - lane.rect.Y) / static_cast(lane.rect.Height); return lane.yMin + ratio * (lane.yMax - lane.yMin); } void PlotView::ClampTimeView() { if (m_dataSet == nullptr || m_dataSet->Empty()) { return; } double span = Tab().ViewMax() - Tab().ViewMin(); if (!(span > 0.0)) { span = kMinTimeSpan; } double dataMin = 0.0; double dataMax = 1.0; TimeBounds(dataMin, dataMax); const double pad = span * 2.0; const double minAllowed = dataMin - pad; const double maxAllowed = dataMax + pad; if (Tab().ViewMin() < minAllowed) { Tab().ViewMin() = minAllowed; Tab().ViewMax() = Tab().ViewMin() + span; } if (Tab().ViewMax() > maxAllowed) { Tab().ViewMax() = maxAllowed; Tab().ViewMin() = Tab().ViewMax() - span; } } double PlotView::MinimumTimeSpan(double low, double high) const { double dataMin = 0.0; double dataMax = 1.0; TimeBounds(dataMin, dataMax); const double dataSpan = std::max(dataMax - dataMin, kMinTimeSpan); // double 在“时间量级”上的最小刻度(ULP):视界宽度低于 `ULP × 画布宽度` 时, // 一列像素对应的 double 已经无法区分,映射彻底退化。 const double magnitude = std::max(std::fabs(low), std::fabs(high)); const double ulp = std::nextafter(magnitude, std::numeric_limits::infinity()) - magnitude; const double width = std::max(64.0, static_cast(m_plotRect.Width)); return std::max(dataSpan * 1.0e-9, std::max(ulp, kMinTimeSpan) * width); } void PlotView::SetTimeView(double low, double high) { if (!std::isfinite(low) || !std::isfinite(high)) { return; } if (high < low) { std::swap(low, high); } const double minimum = MinimumTimeSpan(low, high); if (!(high - low >= minimum)) { // 低于下限:以中心为锚点对称展开(而不是静默地放大到不可表示)。 const double center = (low + high) * 0.5; low = center - minimum * 0.5; high = center + minimum * 0.5; if (!(high - low > 0.0)) { // 连下限都无法表示(时间量级远超可分辨范围):放弃这次修改, // 保留上一个仍然可用的视界比制造一个空视界更安全。 return; } } Tab().ViewMin() = low; Tab().ViewMax() = high; ClampTimeView(); // 视界是「自动点线提示」的驱动量:放大到采样点稀疏时自动切点线,退出阈值时恢复。 // 所有缩放/平移都经本函数(视界唯一写入口),因此这是唯一需要的接入点; // 平移时缩放等级不变,结果稳定,不会产生样式抖动。 if (UpdateAutoScatterStyles()) { // 绘制方式属于内容指纹,场景/装饰缓存需立即作废(面板可能正显示旧样式)。 InvalidateSceneCache(); } } void PlotView::PanTimeByFraction(double fraction) { const double span = Tab().ViewMax() - Tab().ViewMin(); const double delta = span * fraction; SetTimeView(Tab().ViewMin() + delta, Tab().ViewMax() + delta); } void PlotView::ScrollLanesByFraction(double fraction) { Tab().LaneScroll() += static_cast(fraction * static_cast(m_laneContentHeight)); // 立即重建几何,避免命中测试使用旧位置。 BuildLaneVisuals(); } /** * @brief 「适应视图」:时间轴与幅值轴同时自适应到可见信号范围。 * * 边界规则(用户 2026-10-15 第五轮**重新定义**,取代此前"左右各留一小格"的做法): * * 先做一次常规的全局适应(时间 = 可见信号的整段时间范围;幅值走 * `ComputeLaneRange`,含 `PadValueRange` 的统一留白); * * 若某条边界(时间轴的左 / 右、每个网格幅值的上 / 下)被一个**单值信号的 * 数据点**顶住,则把该边界再向外让出**该散点的半径**(+1 像素),使它 * **完整地显示在网格内**;边界若不是单值信号的数据点,则边界就落在这个信号点上 * (时间轴不留额外余量); * * 唯一特例:**所有可见信号都是单值数据点、且时刻完全相同**时,时间轴以该时刻 * 居中并缩放到"一大格 = 1s"(`kSinglePointClusterSecondsPerCell`), * 每个网格的幅值区间保证该网格内的数据点都能完整画出。 */ bool PlotView::SplitLane(int laneIndex) { if (m_tab == nullptr) { return false; } // 拆分由 TabConfig 完成(含网格自身状态的继承与编号刷新)。 const int first = m_tab->SplitLane(laneIndex); if (first < 0) { return false; } // 网格数量变化 → 几何与滚动范围需要重建,静态场景缓存也要失效。 BuildLaneVisuals(); InvalidateSceneCache(); // 拆出来的每个网格都继承了原网格的「Y 轴自适应」开关;全局开启时按当前视界重算一次 // (原来的幅值区间是按“多条信号叠在一起”算的,拆开后每条信号都应铺满自己的网格)。 ReapplyGlobalAutoFitIfEnabled(); if (UpdateAutoScatterStyles()) { InvalidateSceneCache(); } return true; } std::size_t PlotView::LaneSignalCount(int laneIndex) const { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return 0; } return m_tab->Lanes()[static_cast(laneIndex)].signals.size(); } 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; } // 视界两端各可能只覆盖半格,四舍五入到最近的整格数即可 // (调用方 `MinorCellSparse` 用它把小格逐一切分;判据本身是「任一小格有效点 < 5」)。 return std::max(1, static_cast(std::llround(cells))); } bool PlotView::HasVisibleSample(const TimeAxis& axis, const SignalData& signal, std::size_t begin, std::size_t end, double timeShift) const { const double viewMin = Tab().ViewMin() - timeShift; const double viewMax = Tab().ViewMax() - timeShift; // 稀疏信号(有效点已缓存且升序):二分即可(整体平移不改变有序性)。 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; } void PlotView::LocalSampleSpacing(const TimeAxis& axis, const SignalData& signal, double time, double timeShift, double& spacing) const { spacing = 0.0; // `time` 是**显示时间**,而缓存/采样数组是源时间 ⇒ 查询统一换算回源空间。 const double target = time - timeShift; 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(), target) - 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, target); if (index < 0) { return; } const int first = (index > 0) ? (index - 1) : 0; const int second = std::min(static_cast(count) - 1, first + 1); if (second <= first) { return; } // 只接受两端都是**有效**采样点的区间(NaN 段的“间距”没有意义)。 if (!std::isfinite(signal.values[static_cast(first)]) || !std::isfinite(signal.values[static_cast(second)])) { return; } const double gap = axis.time[static_cast(second)] - axis.time[static_cast(first)]; if (gap > 0.0 && std::isfinite(gap)) { spacing = gap; } } bool PlotView::ApplyTimeSelection(float x0, float x1) { if (m_plotRect.Width <= 0.0f) { return false; } float left = std::min(x0, x1); float right = std::max(x0, x1); left = std::max(left, m_plotRect.X); right = std::min(right, m_plotRect.X + m_plotRect.Width); if (right - left < 8.0f * m_scale) { return false; } double timeA = XToTime(left); double timeB = XToTime(right); if (timeB < timeA) { std::swap(timeA, timeB); } if (!(timeB - timeA > 0.0)) { return false; } // 统一入口:内含最小跨度下限。 // ⚠️ 选区放大过去**没有**这个下限,可以一直放大到视界宽度只有几个 double ULP; // 到那时「像素 → 时间」已经退化(相邻像素映射到同一个 double), // 用户看到的就是“拖光标跟不动、一下跳到视界边缘/几百像素外”。 SetTimeView(timeA, timeB); // 注意:不在这里移动光标——即使选区不包含光标,也只把它的读数贴在网格边缘显示。 // 「选区 Y 轴自适应」开关开启的网格:时间选区放大后自动把 Y 轴匹配到新的可见区间。 ApplySelectionAutoFit(); return true; } bool PlotView::ApplyValueSelection(int laneIndex, float y0, float y1) { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) { return false; } const LaneVisual& visual = m_lanes[static_cast(laneIndex)]; if (visual.rect.Height <= 0.0f) { return false; } float top = std::min(y0, y1); float bottom = std::max(y0, y1); top = std::max(top, visual.rect.Y); bottom = std::min(bottom, visual.rect.Y + visual.rect.Height); if (bottom - top < 10.0f * m_scale) { return false; } const double valueTop = YToValue(top, visual); const double valueBottom = YToValue(bottom, visual); if (!(std::fabs(valueTop - valueBottom) > 0.0)) { return false; } Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; lane.autoRange = false; // 手动 Y 轴选区放大:关闭本网格的「选区 Y 轴自适应」。 lane.fitYToSelection = false; lane.yMin = std::min(valueTop, valueBottom); lane.yMax = std::max(valueTop, valueBottom); return true; } std::size_t PlotView::AvailablePhysicalBytes() { // ⚠️ 结果按 200ms 缓存:本函数在绘制路径上每次请求曲线都会调用, // `GlobalMemoryStatusEx` 是系统调用,逐帧多次调用没有必要(内存变化远慢于 200ms)。 static std::atomic cachedAt{0}; static std::atomic cachedBytes{0}; const std::uint64_t now = GetTickCount64(); const std::uint64_t last = cachedAt.load(std::memory_order_relaxed); if (last != 0 && (now - last) < 200) { const std::size_t value = cachedBytes.load(std::memory_order_relaxed); if (value != 0) { return value; } } MEMORYSTATUSEX status = {}; status.dwLength = sizeof(status); std::size_t available = std::numeric_limits::max(); if (GlobalMemoryStatusEx(&status) != FALSE) { available = static_cast(status.ullAvailPhys); } cachedBytes.store(available, std::memory_order_relaxed); cachedAt.store(now, std::memory_order_relaxed); return available; } void PlotView::ShiftLaneRange(int laneIndex, double deltaValue) { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_lanes.size()) || !std::isfinite(deltaValue) || deltaValue == 0.0) { return; } // 首次纵向拖动时先把当前自适应范围固化下来,再进行偏移。 const LaneVisual& visual = m_lanes[static_cast(laneIndex)]; Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; // 手动纵向平移属于手动调整幅值:关闭本网格的「选区 Y 轴自适应」。 lane.fitYToSelection = false; if (lane.autoRange) { lane.autoRange = false; lane.yMin = visual.yMin; lane.yMax = visual.yMax; } lane.yMin += deltaValue; lane.yMax += deltaValue; BuildLaneVisuals(); } bool PlotView::ApplyLaneValueText(int laneIndex, bool editMax, const std::wstring& text) { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return false; } if (m_lanes.empty()) { BuildLaneVisuals(); } if (laneIndex >= static_cast(m_lanes.size())) { return false; } const std::wstring trimmed = Trim(text); if (trimmed.empty()) { return false; } wchar_t* stop = nullptr; const double parsed = std::wcstod(trimmed.c_str(), &stop); if (stop == trimmed.c_str() || !std::isfinite(parsed)) { return false; } const LaneVisual& visual = m_lanes[static_cast(laneIndex)]; const double span = visual.yMax - visual.yMin; const double minimumGap = (span > 0.0) ? span * 0.01 : std::max(1.0e-9, std::fabs(parsed) * 0.01); Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; // 手工输入即视为取消自动自适应,与拖拽框选缩放的语义一致。 // 必须先把当前(自适应计算出的)显示范围固化到 lane 里,再只改动被编辑的一端, // 否则另一端会回退到 Lane 的初始占位值,导致量程被莫名其妙地放大。 lane.fitYToSelection = false; if (lane.autoRange) { lane.autoRange = false; lane.yMin = visual.yMin; lane.yMax = visual.yMax; } if (editMax) { lane.yMax = parsed; if (!(lane.yMax > lane.yMin)) { lane.yMin = lane.yMax - minimumGap; } } else { lane.yMin = parsed; if (!(lane.yMax > lane.yMin)) { lane.yMax = lane.yMin + minimumGap; } } BuildLaneVisuals(); return true; } bool PlotView::LaneRange(int laneIndex, double& yMin, double& yMax) const { if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) { // 菜单弹出时几何可能尚未建立,回退到配置内的自适应范围。 if (m_tab == nullptr || m_dataSet == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return false; } m_tab->ComputeLaneRange(*m_dataSet, laneIndex, yMin, yMax); return true; } const LaneVisual& visual = m_lanes[static_cast(laneIndex)]; yMin = visual.yMin; yMax = visual.yMax; return true; } } // namespace jdi