Files
Just-DataInspector/Components/PlotView/Src/PlotView.cpp
T
DeepSeek V4.1 Flash 53b981a2a9 全项目大检查:修复数据层/UI 真 BUG、清理死代码与过期注释、并评估二进制体积
按「性能 / BUG / 错误 / 过期注释 / 命名与实际含义不符」四项对全仓做了一次审查,
四个只读子审查交叉核对后逐条回到源码确认再改。

一、数据层真 BUG
- MatH5:U8/U16/U32/U64 用逐字节 Cached() 拼装,而 Cached 只服务「完全落在同一个
  64KB 块内」的请求 => 跨块时静默读成 0(U32 整段为 0、U64 高 32 位为 0),分块 B-tree
  的 chunk 偏移/子节点地址被破坏且无任何报错。新增 ScalarLE():命中缓存用缓存,
  否则退回一次 ReadAt;U64 顺带从 8 次缓存查找降到 1 次。
- Mf4Loader::IsBlock 未校验链接表是否落在块内 => DataOffset 越界,随后
  block_len - (data - offset) 在 uint64 上下溢,得到一个约 2^64 的"可用字节数",
  下游 vector(usable+1) 直接 bad_alloc。同时把 offset + bytes > size 改成不产生回绕的写法。
- DecodeRawValue 的整数路径:bit_offset%8 != 0 且位宽 64 时需 9 个字节,先拼成 64 位
  再整体右移会把最高字节静默丢弃 => 改为逐字节带位偏移累加。
- cg_cycle_count 加「数据段总字节 / 记录字节」上限(压缩段按 deflate 最大膨胀比估算)。
- CsvLoader 表头分支同样跳过 #/% 注释行(否则整份文件按 1 列解析并被丢弃)。
- MatLoader:double -> int 的越界转换加了范围检查;普通对象递归加深度上限与成环检测。
- Utf8Util::BuildTicks:double -> long long 越界转换前先判商是否落在 ±9e18 内。

二、界面 BUG
- 工具栏按钮永远不显示悬停高亮:Paint 读的 Button::hover 全仓从未赋值,改为按 m_hoverIndex 判断。
- 菜单打开时"取消底层拖拽"对每个消息都执行,把菜单自己的 Target::EDIT_FIELD 也清掉
  => 菜单内联输入框无法拖拽框选。排除 EDIT_FIELD。
- GdiplusShutdown 之前未释放 PlotView 的 GDI+ 位图(Application 是进程单例,成员析构在
  WinMain 之后)=> 新增 PlotView::ReleaseGraphicsResources() 并在 Shutdown 里调用。
- WM_KILLFOCUS 现在会提交标签页/绘图区编辑并复位捕获与拖拽状态。
- WM_LBUTTONUP 不再抢走中键平移的鼠标捕获。
- TextEdit:被过滤器拒绝的字符返回 false;无选区时 Ctrl+X 只复制、不再清空整段文本。
- SignalPanel::InvalidateRows 作废编辑/待确认/改名计时等行下标状态。
- EndFilterEdit 改走 ApplyFilterText(否则输入纯空格后回车,列表仍停在筛选结果)。
- 新增 SignalBadgeVisible 作为唯一判据(原来命中只看"可见",绘制还要求"已分配网格")。
- 折起动画结束后夹取滚动量;双击分组名改名时清掉该组正在播放的折起动画。
- ScrollToSignal 复用 SignalScrollTarget(含悬浮组名避让)。
- RowNameRect 与绘制使用同一最小宽度(原来退化时返回空矩形)。
- 分隔条拖拽上限改用与 Relayout 同一公式;UpdateDragCursor 补 SPLITTER/EDIT_FIELD。
- SignalPanel 里几个守卫改为判断真正被解引用的指针;分组行 ✕ 也返回手型光标。

三、PlotView
- FindStyleOnlyLanes 改用完整的 SceneStyleEntry::operator==(原来只比 4 个字段,
  另一个网格同时改"调整"时带着旧像素被当成最新)。
- 宽基底图层不再烘焙拖拽光晕(LaneLayerHash 不含它),改由 PaintHoverHighlight 每帧叠加。
- PaintLaneLegend 提前返回前清掉本网格的命中矩形(否则能点中看不见的图例条目)。
- 面板主体改为完全不透明(消除平移后 4% 透出的旧波形,零额外代价)。
- PAN / Shift+滚轮改走 SetTimeView,使"自动点线"判据随平移重算。
- 内联输入框命中字体与绘制统一为 monoSmall。
- 派生曲线取值与窗口按 SourceIndexFor 映射(水平镜像时不再读到镜像另一端)。
- EdgePanelWidth 补 style/axis 空指针守卫;二分结果夹到 count。

四、清理
- 删除 Toolbar::ActionLabel、Theme::statusBg、PopupMenu 的 editResult 死机制、
  Toolbar::Layout 的重复 clear()、HitRow 的恒假分支、OnDoubleClick 的重复判定、
  PaintUtil 里孤立的注释块。
- 修正一批过期/错误注释(包络带"不做加宽"、LaneLayerHash 的图例说法、
  均值滤波曲线的窗口说法、样式区行数与「调整」按钮顺序、FIT_ALL 退出缩放模式、
  RemoveSignal 的 ✕ 位置、.mat/.dat 格式文档、MatH5 的"映射视图"、DataModel 的
  "点线/被移除文件"、SignalPanel 的"行高连续缩小"、TextEdit 的 PaintUtil 引用等)。

五、二进制体积评估(按要求只评估,未做压缩修改)
- release 809,472 字节;.text 652KB、.xdata+.pdata 约 75KB、.rdata 42KB、
  .rsrc 20KB、.idata 11.5KB。
- 去掉 -ld3d11 -ldwmapi -ld3dcompiler(仓内无任何调用)实测 0 字节收益
  (静态库只拉取被引用的对象)=> 删链接库不是压缩手段,只是配置整洁。
- 仓内已无 <iostream>/<sstream>/<filesystem>/<regex>/<locale> 等体积炸弹。
- 结论:已无明显的浪费可捡;要显著变小只能改变取舍(动态 CRT / UPX / 砍功能),
  需要用户决策,本轮未动。

验证
- editcheck 20/20、round3check 31/31、round4check 34/34、round5check 5/5、convcheck 18/18。
- check 136/137:唯一失败项经 git stash 回到改动前复现同一数值,属该探针自身的既有
  不稳定项(取决于前置用例留下的缓存/时序),与本轮改动无关。
- 真实数据:4 个 MF4 => 283/8/18/24 个信号;.mat 样例 8 信号、
  带无障碍对照-防夹力曲线数据 8 信号、BIG_MAT.mat 5 信号(私有内存 +741MB)。
- make release / make debug 零警告。

文档:Documents/agent_mem.md 同步 §10 变更日志、§11.2 第 171-180 条硬约束
与文末本轮小节(含体积评估数据)。
2026-10-20 16:10:29 +08:00

989 lines
32 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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<float>(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<float>(ClampDouble(Tab().LaneScroll(), 0.0, maxOffset));
}
bool PlotView::ScrollLaneIntoView(int laneIndex)
{
if (laneIndex < 0 || laneIndex >= static_cast<int>(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<std::size_t>(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<float>(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<std::uint32_t>(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<float>(elapsed) / static_cast<float>(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<std::uint32_t>(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<float>(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<int>(m_tab->Lanes().size()))
{
return false;
}
int firstGroup = -1;
for (const SignalRef& ref : m_tab->Lanes()[static_cast<std::size_t>(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<Lane>& 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<float>(count);
if (laneHeight < m_minLaneHeight * m_scale)
{
laneHeight = m_minLaneHeight * m_scale;
}
m_laneContentHeight = laneHeight * static_cast<float>(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<float>(index) * (laneHeight + gap) - scrollOffset,
m_plotRect.Width, laneHeight);
m_tab->ComputeLaneRange(*m_dataSet, static_cast<int>(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<float>((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<double>(x - m_plotRect.X) / static_cast<double>(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<float>(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<double>(y - lane.rect.Y) / static_cast<double>(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<double>::infinity()) - magnitude;
const double width = std::max(64.0, static_cast<double>(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<float>(fraction * static_cast<double>(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<int>(m_tab->Lanes().size()))
{
return 0;
}
return m_tab->Lanes()[static_cast<std::size_t>(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<double>(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<int>(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<double>& times = signal.sparseTimes;
const std::vector<double>::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<double>& times = signal.sparseTimes;
const std::size_t found =
static_cast<std::size_t>(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<int>(count) - 1, first + 1);
if (second <= first)
{
return;
}
// 只接受两端都是**有效**采样点的区间(NaN 段的“间距”没有意义)。
if (!std::isfinite(signal.values[static_cast<std::size_t>(first)]) ||
!std::isfinite(signal.values[static_cast<std::size_t>(second)]))
{
return;
}
const double gap = axis.time[static_cast<std::size_t>(second)] -
axis.time[static_cast<std::size_t>(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<int>(m_lanes.size()))
{
return false;
}
const LaneVisual& visual = m_lanes[static_cast<std::size_t>(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<std::size_t>(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<std::uint64_t> cachedAt{0};
static std::atomic<std::size_t> 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<std::size_t>::max();
if (GlobalMemoryStatusEx(&status) != FALSE)
{
available = static_cast<std::size_t>(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<int>(m_lanes.size()) ||
!std::isfinite(deltaValue) || deltaValue == 0.0)
{
return;
}
// 首次纵向拖动时先把当前自适应范围固化下来,再进行偏移。
const LaneVisual& visual = m_lanes[static_cast<std::size_t>(laneIndex)];
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(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<int>(m_tab->Lanes().size()))
{
return false;
}
if (m_lanes.empty())
{
BuildLaneVisuals();
}
if (laneIndex >= static_cast<int>(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<std::size_t>(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<std::size_t>(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<int>(m_lanes.size()))
{
// 菜单弹出时几何可能尚未建立,回退到配置内的自适应范围。
if (m_tab == nullptr || m_dataSet == nullptr || laneIndex < 0 ||
laneIndex >= static_cast<int>(m_tab->Lanes().size()))
{
return false;
}
m_tab->ComputeLaneRange(*m_dataSet, laneIndex, yMin, yMax);
return true;
}
const LaneVisual& visual = m_lanes[static_cast<std::size_t>(laneIndex)];
yMin = visual.yMin;
yMax = visual.yMax;
return true;
}
} // namespace jdi