一、悬停高亮恢复"整条曲线发光"(上一轮裁成鼠标附近一段 ⇒ 满幅方波上就是个长方形色块) 光晕的语义是"整条曲线一起发光",对编码器这种满幅方波,一列包络本来就跨满网格高, 裁成 144px 宽 × 满高的窗口在屏幕上就是一个像选区框的色块(用户 2026-10-18 第二轮报告)。 修法(PlotViewPaint.cpp): - PaintSignalHaloFor 恢复**整条**几何(包络带 FillPath / 折线 DrawLines); - 新增 PaintSignalHaloCached:光晕几何只取决于「信号 + 视界 + 网格几何 + 样式」,**与鼠标无关** ⇒ 渲染一次进离屏位图,之后每帧只做一次 AlphaBlend(源位图预乘 alpha,用 256×256 查表); - 缓存键与 AcquirePolyline 同源,**不含网格屏幕位置**(滚动网格只按新位置贴同一张位图)。 210ms/帧(整条描边)→ 一次性 15ms 渲染 + 每帧 ~2ms 贴图。 halocheck 校验:悬停必命中(60/60 位置)、光晕贴合带宽(outsideBand=0)、不越出网格、 bbox 覆盖整条曲线(不是中段方块)、列表行悬停联动仍生效。 二、信号列表动画卡顿的三个真因(探针 panelstutter:129 个可见网格 + 反复"展开→切组复选框→收起") 1) 面板每帧重画 ~36 行 × ~0.35ms(十来个 GDI+ 原语)⇒ panel.Paint 24~35ms; 2) 动画帧里仍在预热网格图层:每帧重建 1 个网格图层,2500 万采样信号 10~30ms ⇒ 帧被拉长; 3) 勾选分组会把上百个**离屏**网格排进待重画队列,随后每帧补 1 个 ⇒ 数秒持续掉帧。 修法: 1) 行位图缓存(SignalPanel::PaintRowCached):按"行的可见内容"做键,整行渲染进离屏 DIB, 命中时一次 BitBlt。panel.Paint 24~35ms → 5~6ms。 ⚠️ 必须 DIB + 内存 DC(gd::Bitmap 上 GDI 会关闭 ClearType,整列文字退化); ⚠️ 行内几何在 RowRect 里平移(GDI 文本不认 GDI+ 世界变换,否则文字画到小位图之外); ⚠️ 渲染到行位图时不按列表矩形裁剪,裁剪放到贴图时的 DC(GDI 不认 GDI+ 裁剪, 必须自己 SaveDC + IntersectClipRect,折起动画时行不许画到组名上); ⚠️ 缓存上限 192 行(≈10MB)。静态画面逐像素一致(面板区域 54/275200 不同)。 2) 新增 PlotView::SetAnimationBusy:UI 动画期间不预热网格图层(队列保留,动画结束后继续)。 3) 待重画队列只保留可视 + 预取带内的网格;RollWideBuffer 不再登记离屏网格。 实测:展开 p50 35ms → 13ms、收起 33ms → 13ms、动画帧内图层重建 56 次 → 0 次。 三、试过并放弃(避免以后重走) - ExtTextOutW 替代 DrawTextW:快 2~4 倍但光栅化不同(面板 10% 像素差异)⇒ 观感变化,放弃; - 用 gd::Bitmap 做行位图:文字丢失 ClearType,放弃。 四、验证(全部离屏,不占用鼠标/键盘/屏幕) - 新增 .preflight/perf/panelstutter_main.cpp(8/8)、halocheck_main.cpp(16/16); - 回归 verify 49/49、panelanim 158/158、hoverdrag 20/20、gridanim 93/93、lanescroll 23/23、 datacheck 69/69、edgecheck 46/46、errcheck 19/19、panflash 19/19、stress 6/6; - make release / make debug 零警告(TARGET_FILE_NAME=TargetCheck/TargetCheckD)。 文档:Documents/agent_mem.md 追加本轮小节(含 4 条新规则:高亮语义不得为性能让步、 GDI 文本的三个隐形陷阱、面板卡顿先量"每行成本×可见行数"与"本帧是否顺带做绘图区重活"、 动画帧内绝不做可延迟的整帧级工作)。
979 lines
31 KiB
C++
979 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()
|
||
{
|
||
// 先停掉后台计算线程:它可能正在读取采样数据,必须在释放其它资源前回收。
|
||
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 = 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::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;
|
||
}
|
||
// 视界两端各可能只覆盖半格,四舍五入到最近的整格数即可(判据只用到它 × 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
|
||
|