## 为什么
用户 2026-10-29 要求:按既定计划完整实现改进,但
① **不得把编译产物拆成 DLL**(项目定位是单文件轻量工具,最终只能有一个 .exe);
② 异常处理改用**预检验**而不是 try/catch(-fno-exceptions 省体积,且 try/catch 本身开销大);
③ 先删除 Documents 内三份 UI 重构文档再按"计划 / 当前进度 / 最终报告"重建;
④ 顺便审计代码,删除无用/过时注释,修复名实不符;
⑤ 提交到本地仓库,期间不要占用用户的鼠标/键盘/屏幕。
## 改了什么
### 1. 体积(909,824 B -> 788,992 B,-120,832 B,-13.3%)
Makefile 新增三个可覆盖开关(默认全为 1):
- `LAYOUT_BOUNDS` 编入/剔除「显示布局边界」叠加层
- `NO_EXCEPTIONS` 追加 `-fno-exceptions`
- `LTO` 追加 `-flto`(编译 + 链接)
派生:`FUNC_SECTIONS`(LTO 下为空)、`GC_SECTIONS`(`-Wl,--gc-sections`)、
`ASM_LISTING`/`ASM_LISTING_C`(LTO 下为空,因为 lto-wrapper 会丢弃 `-Wa` 清单)。
两个实测复现的坑(已写进 Makefile 注释):
- **切换 LTO 必须 `make clean`**:LTO 与非 LTO 目标文件混链会让 GCC 15.2.0 在
`binds_to_current_def_p (symtab.cc:2598)` 处 ICE。
- **`-flto` 与 `-ffunction-sections -fdata-sections` 同时打开也 ICE**(干净重编也必崩,
报错落点是 `DataModel.hpp:393`,但那是 COMDAT 解析落点、不是原因)⇒
LTO 下 `FUNC_SECTIONS` 置空,并加 `-flto-partition=one`:默认 11 个分区的 LTRANS
稳定复现,单分区从未复现。**这是稳定性选项,不是性能选项。**
### 2. 关异常必须成对交付:预检验 + 兜底
新增 `Utilities/Inc/MemProbe.hpp` + `Utilities/Src/MemProbe.cpp`:
- `MemAvailablePhysical()`:`GlobalMemoryStatusEx` + 200ms 缓存
- `MemCanAllocate(bytes)`:预留后仍须保有 1/8 可用内存
- `MemReserveIfRoom(container, n)`:含 `capacity()` 早退与乘法溢出保护
- `MemInstallTerminateHandler()`:OOM 时弹一次带建议的模态提示后 `_Exit`(而不是静默消失)
接入:
- `Utilities/Src/DataModel.cpp`:全仓**唯一**的 `try/catch`(`extremes.reserve` 捕
`std::bad_alloc`)改为 `MemReserveIfRoom(extremes, count / 8)`
- `Components/PlotView/Src/PlotView.cpp`:`AvailablePhysicalBytes()` 原有一份**完整重复**
的 `GlobalMemoryStatusEx` + 缓存实现 → 转发 `MemAvailablePhysical()`
- `Components/Application/Src/Application.cpp`:`Initialize()` 开头安装 terminate handler
### 3. L1 几何产物层
新增 `Components/Shared/Inc/UiBox.hpp` + `Src/UiBox.cpp`:
- `struct UiBox { rect, clip, depth, layer }` —— 纯值类型,不继承任何东西
(继承会和 PlotView 4 层位图缓存 / SignalPanel 行位图缓存的失效体系打架)
- `class UiBoxTable : public UiTextRectSink` —— 只累计几何 + 裁剪/层/遮挡,**不含绘制代码**
`UiComponent::CollectDebugBoxes` → **`virtual void BuildBoxes(UiBoxTable&)`**:
组件从"收集布局边界(调试用)"改为"上报几何产物",不再需要知道"调试"这个概念存在。
`UiDebugBounds.hpp/.cpp` 退化为唯一的消费者(配色 + 包角描边 + 遮挡挖洞)。
除宿主 `ApplicationCommands.cpp` 外,所有组件源码现在只包含 `UiBox.hpp`。
### 4. 审计
- `MemProbe` 里"省 40~60KB"的估算 → 改为实测 27,136 B
- `MemProbe.hpp` 的 `<new>`、`MemProbe.cpp` 的 `<cstdio>`:未使用,删除
- `UiDebugPaintBounds(g, rect, depth)`:零调用者,删除(只保留带 clip 的版本)
- `TabBar`/`Toolbar`/`PlotView`/`SignalPanel` 的 `BuildBoxes` 文档"(调试用)"→"几何 box"
- `PlotView::SetForceFullRepaint` 补写"为什么保留而不优化"
### 5. 文档
`Documents/UI_Refactor_Plan.md` / `_Progress.md` / `_Report.md` 先 `git rm` 再按
「计划 / 当前进度 / 最终报告」重建;`Documents/agent_mem.md` 追加第十三轮记录。
## 怎么验证
```
make clean
make release TARGET_FILE_NAME=JustDataInspector -j16 # 零 error / 零 warning,788,992 B
make debug TARGET_FILE_NAME=JustDataInspectorD -j16 # 零 warning
uicheck 69/69 lbcheck 21/21 lbresid 5/5 lbzorder 4/4 lbcovr 7/7 lbpanel 10/10
rendercheck: 8 个场景哈希与改造前逐一相同 + FUNC 7/7 PASS, exit=0
A FAED59DF284153DB B E6443BFE4E3955F8 C 96A0AA8C402F1729 D E3D5DBD998E43720
E DA0583CB13C26A6F F FFB822159F604D1D G 19F8DE8FE3A8DF18 H DBBDDE4CB2C97C92
```
体积溯源(`objdump -h`,基线 → 最终):
`.text` 735,600 → 657,168;`.rdata` 49,184 → 35,016;
`.xdata` 48,132 → 27,512;`.pdata` 35,424 → 28,524;`.rsrc` 20,656 不变。
## 没做什么(有意为之)
- **没有拆 DLL**(用户明确要求单文件)
- **没有改优化等级**(`-Oz` 保持)
- **L2(`UiDamage` 脏矩形 + 独立叠加层)评估后缓做**:受益方只有"调试开关打开时的帧率",
而正确实现必须把叠加层旧矩形注入底层组件重绘范围,穿透 PlotView 四条快路径 + 两层缓存;
在用户反复报过"拖拽错位 / 贴图残留"的背景下,风险与收益不成比例。理由写在
`Documents/UI_Refactor_Plan.md` §5 与 `PlotView::SetForceFullRepaint` 的文档里。
969 lines
31 KiB
C++
969 lines
31 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()
|
||
{
|
||
// 统一走 `MemProbe` 的实现:它自带 200ms 缓存,且与"分配前预检验"
|
||
// (`MemCanAllocate` / `MemReserveIfRoom`)共用同一份数据 ——
|
||
// 历史上这里有一份完全重复的 `GlobalMemoryStatusEx` + 缓存实现。
|
||
return MemAvailablePhysical();
|
||
}
|
||
|
||
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
|
||
|