按「性能 / 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 条硬约束 与文末本轮小节(含体积评估数据)。
989 lines
32 KiB
C++
989 lines
32 KiB
C++
// 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
|
||
|