① 主题自适应(`Theme.cpp`):新增 `SrgbLuminance` / `ContrastRatio` / `EnsureContrast` /
`MixToLuminance` / `ThemeLaneBackground`;四套主题的结构色(gridWeak 1.30 / gridStrong 1.65 /
axisLine 2.10 / borderStrong 1.70)按底色自动校正,Solarised Light 逐位不变;网格叠底改用
`ThemeLaneBackground()`(4 处),深色主题下网格层与绘图区底色可辨。
② 派生预览曲线(`HighContrastDerived`)重写为**连续**方案:方向沿信号所在侧远离底色,
目标亮度 = max(与底色对比度 3.2, 信号亮度 ± 0.18),再按亮度平滑混色;调用点传入真实网格底色
(旧实现默认写死 Solarised Light 底色 ⇒ 深色主题永远选到更暗的那一侧、几乎看不见)。
③ 信号样式区「线宽 / 透明度」两行右侧新增**复位按钮**(`UiPaintResetIcon` 成为唯一实现,
菜单/弱峰面板/样式区共用同一套笔画);默认线宽统一到 `DefaultSignalLineWidth(finiteCount)`
(单点信号取 10.0)。按钮右边界与内容列对齐,内容列过窄时整列不显示。
④ 「均值滤波」面板重做:信号色叠底标题栏 + 复选框 + 展开三角 + 「均值点数 / 迭代次数」两行滑块
(点击数值可改滑块上限、右侧复位)+ 内容区滚动;新增 `MeanFilterItem::{maxPoints,maxIterations,
collapsed}` 与 `Lane::meanFilterScroll`,缓存键同步补全。
⑤ 浮动面板命中像素缓存时只贴**面板圆角形状之内**的像素(`CreateRoundRectRgn` 裁剪)并按本帧重画投影
⇒ 中键移视界后面板周围不再出现一圈旧波形。
⑥ 显示布局边界:每个浮动面板**独占一个绘制层**(按 `m_panelOrder` 递增),绘制侧
`UiSetTextLayer` 在同一循环内同步推进 ⇒ 下层面板的边框/文字不再画到上层面板上。
⑦ 自由标记:采样候选距离改为**二维欧氏距离**(单点信号不再在整幅宽度内吸住光标),
纵向光标画在鼠标位置、圆点落在真实采样点(新增 `FreeMarkerProbe::pointX`);
`MinorCellSparse` 只对胜出信号算一次;折线缓存容量改为 `max(64, 信号数×2)` 且查找改为
单键哈希 ⇒ MF4 的 285 信号合并网格中 `ProbeFreeMarker` 平均 0.11ms。
验证:`.preflight` 探针重建(`panelcheck` 71/71、`markercheck` 13/13、`ringcheck` 6/6,合计 90 条,
LTO 目标文件上复跑同样全绿);`make release` / `make debug` 零警告;产物 815,104 B。
文档:`agent_mem.md` 第十六轮(含补丁脚本与编码/行尾踩坑)。
993 lines
32 KiB
C++
993 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();
|
||
}
|
||
|
||
void PlotView::OnThemeChanged()
|
||
{
|
||
// 折线/统计等纯数据缓存与主题无关,但它们的**像素化结果**全都与主题有关:
|
||
// 场景缓存位图、每网格宽基底图层、装饰层、光晕层、浮动面板层里都烘着主题色。
|
||
InvalidateDerivedCaches(); // 内部已经包含场景缓存作废
|
||
InvalidateWideBuffer();
|
||
ReleaseDecorCache();
|
||
ReleaseHaloLayer();
|
||
ReleasePanelCache();
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
// 折线缓存容量 = 全部信号数 × 2(下限 `kPolylineCacheLimit`)。
|
||
// ⚠️ 必须随信号总数增长:固定 64 条在"上百个信号合并到一个网格"时会持续清表重建,
|
||
// 使绘制与自由标记探测每帧重算全部折线(用户 2026-10-30 报告的卡顿)。
|
||
{
|
||
std::size_t totalSignals = 0;
|
||
for (const Lane& lane : lanes)
|
||
{
|
||
totalSignals += lane.signals.size();
|
||
}
|
||
const std::size_t wanted = totalSignals * 2ull;
|
||
m_polylineCacheCapacity = (wanted > kPolylineCacheLimit) ? wanted : kPolylineCacheLimit;
|
||
}
|
||
|
||
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
|
||
|