承接上一条提交(0871eb0)—— 该提交只完成了 Components/Shared 与注释/命名清理,并**主动未做**
大文件拆分;本提交把大文件拆分补齐,至此“按功能拆分逻辑块到各组件自己的 Inc/Src”已落地。
## 一、拆分结果
| 原文件 | 拆分后 |
| --- | --- |
| Components/PlotView/Src/PlotView.cpp(13 695 行 / 544 KB) | PlotView.cpp(787) + PlotViewPaint.cpp(6 367) + PlotViewInteraction.cpp(2 715) + PlotViewFeatures.cpp(3 005) + PlotViewInternal.hpp(936) |
| Components/SignalPanel/Src/SignalPanel.cpp(4 184 行) | SignalPanel.cpp(1 141) + SignalPanelPaint.cpp(697) + SignalPanelInteraction.cpp(2 095) + SignalPanelInternal.hpp |
| Components/Application/Src/Application.cpp(3 135 行) | Application.cpp(1 427) + ApplicationCommands.cpp(1 184) + ApplicationLoading.cpp(270) + ApplicationInternal.hpp |
| Utilities/Src/DataModel.cpp(2 615 行) | DataModel.cpp(1 583,含自由函数区) + DataModelTab.cpp(732) + DataModelInternal.hpp |
分片职责(以 PlotView 为例):
core 视图核心:构造/析构、上下文、布局、坐标映射、视界与平移、网格结构与归属
paint 渲染与像素缓存:全部绘制、场景/装饰缓存、每网格图层、宽基底与滚动补条
interaction 交互与编辑:命中测试、鼠标/键盘、光标形状、内联输入框、浮动面板层序
features 视图特性与派生数据:光标读数/提示/缩放、自适应、吸附、单点、派生曲线线程池、
边沿统计、实验性功能参数
(SignalPanel 为 core/paint/interaction;Application 为 core/commands/loading;
DataModel 为 core/tab。)
## 二、关键手法(这是能安全拆开的前提)
原文件的辅助区都是**匿名命名空间**(PlotView 约 870 行常量与工具函数),被所有成员函数广泛引用。
直接拆文件会让各 .cpp 拿不到这些工具,因此:
1. 把该块整块搬进 `<组件>Internal.hpp`,从 `namespace { }` 改为**具名**命名空间
(pvdetail / spdetail / appdetail / dmdetail);
2. 块内**每个函数与常量都标 inline** ⇒ 多个翻译单元共用同一个定义:既不重复符号,
也不产生 N 份函数定义;
3. 头部末尾 `using namespace <ns>;` ⇒ 各 .cpp 函数体里原有的非限定调用(IntersectRect /
NiceStep / ClampDouble …)**一行都不用改**;
4. 成员定义按「方法名 → 分片」归类后**整块搬移** —— 全程**纯代码搬移**,没有改动任何一行函数体。
## 三、拆分过程中踩到并修正的四个坑(已固化到 .preflight/perf/split_component.ps1)
1. `[[maybe_unused]]` 等属性**必须排在 inline 之前**(`inline [[x]] T` 非法);
单独的 `{`/`}` 行、注释行、`struct`/`enum`/`using`/`template` 行不能加 inline。
2. 匿名命名空间的**结束行可能带注释**(`} // namespace`),只认 `^\t}$` 会一路匹配到后面
某个成员函数的结束括号,把大段成员定义误当成“辅助区”搬走。
3. **首个成员定义之前的所有行**必须跟着进核心文件:DataModel.cpp 在匿名命名空间之后、
首个成员定义之前还有约 390 行命名空间外的自由函数(SignalStats / ComputeStats /
CacheSparseSamples / ApplySinglePointDefaultStyle …),漏掉会被静默丢弃。
4. 内部头必须带上原文件真正用到的全部头:DataModel 需要 Theme.hpp / <cstring> / <cstdio> /
<deque> / <atomic> / <cwctype> / <new>(原文件靠这些头才编译得过)。
## 四、附带完成的命名审计
PlotView 中残留的 File 语义命名一并修正:LaneSpansMultipleFiles → LaneSpansMultipleGroups、
firstFile → firstGroup、multiFile → multiGroup、SceneCacheKey::haloFile → haloGroup。
## 五、验证(全部离屏:SW_HIDE + 不注入真实输入 + 进程/线程 BELOW_NORMAL)
- .preflight/perf/verify.exe(PlotView 渲染/交互,含逐像素断言)49/49
- .preflight/perf/panelanim.exe(信号面板:卷尺式折起/展开、筛选态、悬浮组名、目标行)156/156
- .preflight/perf/datacheck.exe(数据层:单轴/多时间轴 CSV、MF4、重命名语义)69/69
- .preflight/perf/edgecheck.exe(边沿统计与面板排版)46/46
- make release / make debug 零警告。
即:拆分后**行为与拆分前完全一致**(纯代码搬移,四套探针全绿)。
## 六、体积(诚实记录)
Target.exe: 754 176 → 764 416 B(+10 240 B,+1.4%)
因为 inline 工具函数会在每个引用它的翻译单元里各生成一份(PE/COFF 链接器没有 ICF 合并)。
这再次印证:拆分解决的是**可维护性**,不是体积(本轮的 −24.6% 全部来自 ICO 256×256 条目改 PNG)。
## 七、文档
Documents/agent_mem.md:§9.37 新增“七、多文件拆分(已完成)”与“八、仍未完成”(只剩
Lane → LanePanelState、模态提示解耦、MF4 压缩块三项);§11.2 新增第 91、92 条(多文件化的
唯一安全手法 + 体积代价)。Documents/refactor_report_2026-10-16.md 同步更新 §五/§七/§八。
788 lines
24 KiB
C++
788 lines
24 KiB
C++
// PlotView 实现分片 —— 本文件只包含 PlotView 的**成员函数定义**(纯代码搬移,行为与拆分前完全一致)。
|
||
//
|
||
// 共用的常量与工具函数在 PlotViewInternal.hpp(命名空间 `pvdetail`,全部 inline)。
|
||
// 四个分片的职责划分(按功能):
|
||
// core 视图核心:构造/析构、上下文绑定、布局、坐标映射、视界与平移、网格结构与归属、
|
||
// 选区/网格划分、拖动取消
|
||
// paint 渲染与像素缓存:全部绘制(Paint*/Render*)、场景缓存、装饰缓存、每网格图层、
|
||
// 宽基底与滚动补条、并行图层重建
|
||
// interaction 交互与编辑:命中测试、鼠标与键盘处理、光标形状、内联输入框、浮动面板层序与命中
|
||
// features 视图特性与派生数据:光标状态与读数、提示浮层、缩放模式、自适应、光标吸附、
|
||
// 单点信号、派生曲线线程池、边沿统计、实验性功能(均值滤波 / 弱峰调整)参数
|
||
#include "PlotViewInternal.hpp"
|
||
|
||
namespace jdi
|
||
{
|
||
|
||
PlotView::PlotView() = default;
|
||
|
||
PlotView::~PlotView()
|
||
{
|
||
// 先停掉后台计算线程:它可能正在读取采样数据,必须在释放其它资源前回收。
|
||
StopCurveWorker();
|
||
// 并行图层渲染的工作上下文(各自持有图层位图,须在 GDI+ 关闭前释放)。
|
||
ReleaseLayerWorkers();
|
||
ReleaseSceneCache();
|
||
ReleaseDecorCache();
|
||
ReleaseWideBuffer();
|
||
ReleasePanelCache();
|
||
}
|
||
|
||
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 = 54.0f * m_scale;
|
||
|
||
// 先为时间轴保留固定高度,剩余空间才交给网格,保证时间轴不会被挤压。
|
||
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_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::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;
|
||
}
|
||
// 视界两端各可能只覆盖半格,四舍五入到最近的整格数即可(判据只用到它 × 4)。
|
||
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
|
||
|