Files
Just-DataInspector/Components/PlotView/Inc/PlotViewInternal.hpp
T
DeepSeek V4.1 Flash 2d83b494a3 均值面板越界根因 + 网格线进入主题配色 + 条目栏悬停/整行点击 + 均值点数下限 1
1. 「元素超出浮动面板」真根因(用户 2026-10-30 报告的截图)
   - 绘制侧 PaintEdgeCursor 的 itemized 仍是 (centerAxis || meanFilter || weakPeak),
     而布局侧 EdgePanelLayoutFor 早已是 (centerAxis || weakPeak)。
     于是均值滤波面板被旧的「逐条目」分支**再画一遍**(每行名称 + 复选框,且没有任何裁切),
     滚动后那些行的坐标是负值/越界 ⇒ 直接画到标题栏上。
   - 修法:绘制侧与布局侧统一;旧分支的兜底循环跳过条件补 meanFilter。
     底部「构建为新信号」由共享的 if (hasButton) 绘制,不受影响。
   - 顺带删除「只画完全落在内容区里的行」的 fullyInside 门控:真因修掉后
     g.SetClip(layout.content) 已足够,半截行正常显示,滚动观感连续。

2. 条目栏交互(与「自由标记」分组栏一致)
   - 整行可点:复选框区 = 切换是否预览;已启用 = 展开/收起参数行;未启用 = 直接开始预览。
     命中只要求点落在 layout.content 内(不再要求整行在内容区内),因此半截行也能操作。
   - 悬停行 → 对应预览曲线高亮:新增 m_meanRowHoverLane / m_meanRowHoverRef,
     由 OnMouseMove 维护、OnMouseLeave 与 InvalidateDerivedCaches 清除;
     PaintDerivedCurve/PaintMeanFilterCurve/PaintWeakPeakCurve 新增 bool highlight,
     先画 +5px 半透明同色光晕再把线加粗,行头叠一层 accent;悬停态变化会 InvalidateSceneCache。

3. 均值点数下限 1(用户要求:不可以为 0)
   - kMeanFilterPointsMin 0 → 1;输入提交用 centerTarget ? 0 : kMeanFilterPointsMin
     (中心轴边沿计数沿用旧语义 0 = 不平滑);预览与构建请求都再夹一次下限,
     即使条目被外部写成 0,渲染/构建结果与 1 点逐位一致。

4. 网格线颜色成为主题配色的一部分
   - struct Theme 新增 laneBg(网格叠底色);NormalizePlotContrast → FinalizeTheme,
     先解 laneBg = EnsureContrast(plotBg, cardBg, 1.03),再让 gridWeak/gridStrong/
     axisLine/borderStrong 统一对 laneBg 归一(1.30 / 1.70 / 2.10 / 1.70)。
   - 四个主题各自给出显式取值:Dark 的网格线对叠底色原本只有 1.071(几乎不可见)⇒ 现 ≥1.30;
     其它三个主题同步微调,网格可见性一致。ThemeLaneBackground 返回 laneBg。

5. 行三角尺寸统一
   - 新增 kEdgePanelRowTriangleSize = 12 逻辑像素,均值滤波行与自由标记分组栏共用;
     标题栏的 kEdgePanelDisclosureWidth = 20 保持不变。

6. 均值面板滚动条与文本
   - 滚动条必须在 g.Restore(meanSave) 之后绘制(它在内容裁剪区之外,裁剪内画会被整条裁掉)。
   - 滑块标签/数值改用 UiTextClipped(..., layout.content)(GDI 文本不受 GDI+ 裁剪约束)。

验证(全部离屏)
   uicheck 69/69、lbcheck 21/21、lbresid 5/5、lbzorder 4/4、lbcovr 7/7、lbpanel 10/10、
   derivecheck 16/16、round15check 28/28、themecheck 17/17、.preflight/meancheck 17/17;
   .preflight/meanpix:滚到顶/中/底时标题栏与面板外均为 0 像素差异,内容区正常滚动;
   rendercheck:A–H 八个场景哈希与基线完全一致;
   make release 零 warning(805,376 B);make debug 零 warning。

同步文档:Documents/agent_mem.md 追加「2026-10-30 第十七轮」,
并把第十六轮里「均值点数(0…上限)」「命中要求行完全落在内容区里」两条改成现行做法。
2026-10-30 10:51:39 +08:00

1333 lines
65 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#ifndef __PLOT_VIEW_INTERNAL_HPP__
#define __PLOT_VIEW_INTERNAL_HPP__
// Components/PlotView 组件内部共享的常量与工具函数(私有头,仅本组件的 .cpp 使用)。
//
// PlotView 的实现按功能拆成多个协作文件:
// PlotView.cpp 视图核心(上下文 / 布局 / 坐标映射 / 视界与缩放 / 网格结构)
// PlotViewPaint.cpp 渲染与像素缓存(绘制 / 场景与装饰缓存 / 图层 / 宽基底)
// PlotViewInteraction.cpp 交互、命中测试与内联编辑
// PlotViewFeatures.cpp 实验性功能、派生曲线、边沿统计、自适应与光标吸附
// 它们共用这一份内部工具集。
//
// ⚠️ 为让多个翻译单元都能使用,原匿名命名空间改为**具名**命名空间 `pvdetail`,
// 并把其中每个函数与常量标为 `inline` —— 单一定义、单一副本,不会产生代码重复。
// 各实现文件在包含本头之后用 `using namespace pvdetail;` 引入这些工具,
// 因此函数体里原有的非限定调用(如 `IntersectRect` / `NiceStep`)一行都不用改。
#include <windows.h>
#include <objidl.h>
#include <gdiplus.h>
#include <algorithm>
#include <cmath>
#include <cstring>
#include <cstdint>
#include <limits>
#include <string>
#include <utility>
#include <vector>
#include "PlotView.hpp"
#include "MemProbe.hpp"
#include "UiControls.hpp"
#include "Utf8Util.hpp"
namespace jdi
{
namespace pvdetail
{
/**
* @brief PlotView 内部工具集(常量 / 几何与配色辅助 / 边沿统计辅助 / 绘制辅助)。
*
* 全部符号都是 `inline` ⇒ 多个翻译单元共用同一个定义:既不重复符号,也不产生多份代码。
* 不要把它们移回匿名命名空间(那样每个 .cpp 都会各自复制一份)。
*/
inline const int kTimeTickTarget = 10; /**< 时间轴期望刻度数。 */
inline const int kYTickTarget = 6; /**< 幅值轴期望刻度数(网格较高时)。 */
inline const int kYTickTargetMin = 3; /**< 幅值轴最少刻度数(网格很矮时也要保证可读数)。 */
inline const int kYTickTargetMax = 8; /**< 幅值轴最多刻度数。 */
inline const float kYTickSpacing = 28.0f; /**< 幅值刻度之间的理想间距(逻辑像素)。 */
inline const float kYLabelMinGap = 15.0f; /**< 幅值标签的最小行距(逻辑像素)。 */
inline const int kMinorDivisions = 5; /**< 相邻主刻度之间的等分数(用于估读)。 */
inline const unsigned int kMinorLineAlpha = 0x9Au; /**< 辅助等分线的透明度(越高越清晰)。 */
inline const float kScrollBarThickness = 9.0f; /**< 滚动条宽度(逻辑像素)。 */
inline const float kLanePadding = 8.0f; /**< 网格内边距。 */
inline const size_t kMaxPolylinePoints = 20000; /**< 单条折线最大顶点数(上限保护)。 */
inline const size_t kPolylinePointsPerPixel = 2; /**< 每个像素列允许的折线顶点数(上/下包络两顶点)。 */
inline const size_t kMinPolylinePoints = 512; /**< 折线最少顶点数(窗口很窄时仍保留细节)。 */
/**
* @brief 图层**纵向滚动**的量化阈值(位图像素)。
*
* 幅值平移不足该值时**不滚动**图层,交给贴图时的纵向偏移(`srcY`)处理;
* 超过才滚动一次并补画新暴露的一整条。这样画布映射在拖动过程中保持稳定 →
* 折线缓存(键含幅值映射)能命中,避免"每帧滚 1 行 → 每帧重算折线"的性能陷阱。
*/
inline const int kLayerRollQuantum = 16;
/**
* @brief 图层**横向滚动**的量化阀值(位图像素)。
*
* `BlitWideBuffer` 贴图时按 `(ViewMin - layer.wideMin) * scale` 算源偏移,因此图层
* **不需要每帧重新居中**:只要视界仍在位图覆盖范围内,直接按偏移贴就够了。
* 旧实现每帧都把图层滚回“居中”位置(补一条 2px 宽的竖带),而每次补条都要重跑
* 一次 `BuildLaneVisuals()`(O(网格数))⇒ 253 个网格、30 个可视网格时
* **单帧 26.8ms**(用户 2026-10-17 报告:三明治加载后拖拽视界卡顿)。
* 改成“位移达到一个量子才滚一次”后,正常拖动几乎不会触发补条。
*/
inline const int kLayerRollQuantumX = 32;
/**
* @brief 单次「横向重新居中」的**最大**滚动量(位图像素)。
*
* ⚠️ 滚动本身是廉价的(BitBlt + 补一条 `|roll|` 宽的竖带),但补条要重建那条带的
* 折线/包络 ⇒ 代价与 `|roll|` 成正比。限制单次步长可以把“快速拖动”的补条代价
* 摊到多帧;余额由 `BlitWideBuffer` 的源偏移承担(**不会空白**,也不会画错)。
*/
inline const int kLayerRollMaxStepPixels = 240;
/**
* @brief 每帧最多为多少个网格做**横向重新居中**(补细条)。
*
* ⚠️ 重新居中**纯粹是优化**:`BlitWideBuffer` 本来就按 `(ViewMin - layer.wideMin)` 算源偏移,
* 不滚动也照样能正确贴图(只要还在位图覆盖范围内)。因此完全可以分摊到多帧。
* 每条补条的固定开销 ~1.5ms(含 `m_lanes` 快照/还原与 GDI+ 调用),
* 30 个可见网格同时滚就是 45ms —— 取 3 个 ⇒ 最坏单帧约 4~5ms,
* 拖拽时既不会卡顿、也不会让位移累积到需要作废图层的程度。
*/
inline const int kLayerRollsPerFrame = 3;
/**
* @brief 「图层预热」两次请求重绘之间的最小间隔(毫秒)。
*
* 上百个网格时待重画队列可能长期非空(每个网格的图层约 2~15ms)。若每帧都请求重绘会把
* 动画的 `WM_TIMER` 饿死(见 `PrefetchStepAllowed`);完全不请求则滚动到那些网格时
* 它们还没建过图层,每帧要靠底衬兜底重画十几份网格(~8ms/帧)而卡顿。
* 取 32ms ≈ 31 次/秒:既留足了动画定时器(15.5ms 周期)的预算,又能让 130 个网格
* 在大约 4 秒内置好。
*/
inline const std::uint64_t kPrefetchMinIntervalMs = 32;
/**
* @brief 「点选信号 ⇒ 把目标网格滚入可视区」的平滑过渡参数(与信号列表同源)。
*
* 时长 = `clamp(距离 × kLaneScrollAnimMsPerPixel, kLaneScrollAnimMinMs, kLaneScrollAnimMaxMs)`,
* 进度再过 ease-out cubic(起步快、靠近目标时减速)⇒ 用户要求的“动态过渡而不是瞬移”。
*/
inline const float kLaneScrollAnimMinMs = 130.0f; /**< 最短时长(短距离)。 */
inline const float kLaneScrollAnimMaxMs = 300.0f; /**< 最长时长(长距离)。 */
inline const float kLaneScrollAnimMsPerPixel = 0.35f; /**< 每像素距离增加的时长。 */
/**
* @brief 右下角「回到最上方」浮动按钮的几何与过渡参数。
*
* 用户 2026-10-16 要求:参考长文档网页的常见做法(向上箭头浮动按钮),
* 仅在向下滚动时出现、回到上方时消失,出现/消失都要有过渡动画。
*/
inline const float kBackToTopSize = 36.0f; /**< 按钮边长(逻辑像素,圆形直径)。 */
inline const float kBackToTopMargin = 12.0f; /**< 与绘图区右下角的边距。 */
/** 淡入时长(毫秒):出现时的 ease-out 手感保持原样。 */
inline const float kBackToTopFadeInMs = 170.0f;
// ⚠️ 淡出时长 = `kBackToTopFadeOutMs`(**与提示浮层一致**),见下方 `kHintFadeMs` 处。
/** 向下滚动超过该距离(逻辑像素)才显示按钮(避免轻微滚动就弹出)。 */
inline const float kBackToTopShowOffset = 24.0f;
/** 向上箭头相对按钮尺寸的比例(箭头顶端到中心、半宽)。 */
inline const float kBackToTopArrowDepth = 0.24f;
inline const float kBackToTopArrowHalfWidth = 0.20f;
inline const float kEnvelopeMinPixelSpan = 0.5f; /**< 包络纵向跨度小于该像素值时合并为单个顶点。 */
inline const float kCursorHandleReserve = 9.0f; /**< 光标顶部三角把手占用的高度(数据点贴边时需避让)。 */
inline const float kMarkerEdgeInset = 7.0f; /**< 数据点越界贴边时距网格可见边界的间距。 */
inline const float kMarkerInsideMargin = 2.0f; /**< 数据点在视界内时允许贴到边界的最小余量。 */
inline const float kMarkerTriangleArm = 5.0f; /**< 越界方向三角"半宽/半高"(垂直于朝向的半边长)。 */
/** 越界方向三角沿朝向的深度(与光标把手同比例)。 */
inline const float kMarkerTriangleDepth = kMarkerTriangleArm * 1.4f;
/**
* 越界方向三角与数值/时间标注之间的间隙(逻辑像素)。
*
* 三角与胶囊之间**必须留出这段空白**:贴在一起时三角看起来像胶囊上多出来的
* 一个尖角(用户报告的"三角形与数值显示部分没有间隔")。
*/
inline const float kMarkerTriangleGap = 4.0f;
inline const size_t kMaxScatterPoints = 2500; /**< 单条散点最大绘制点数。 */
/**
* @brief 悬停光晕缓存中「关闭抗锯齿」的顶点数阈值。
*
* 光晕是**沿整条曲线**画出来的(不能只画鼠标附近的一段:在满幅方波上那看起来
* 就是一个长方形色块,像选区框 —— 用户 2026-10-18 第二轮报告)。它的代价只
* 在「光晕缓存重建」时付一次,因此通常可直接用抗锯齿;但顶点很多的区间
* (深放大后的方波:每段都跨满网格高)GDI+ 抗锯齿的扫描线代价 ≈ 线段数 × 覆盖行数,
* 实测可差 20 倍以上。这类密集情形关闭抗锯齿(几何完全不变,半透明光晕上看不出差别)。
*/
inline const size_t kHaloNoAaMinPoints = 360;
inline const size_t kPolylineCacheLimit = 64; /**< 折线顶点缓存条目上限。 */
inline const float kMinScatterRadius = 1.6f; /**< 散点最小半径(逻辑像素)。 */
inline const float kMaxScatterRadius = 12.0f; /**< 散点最大半径(逻辑像素)。 */
inline const float kScatterRadiusScale = 1.5f; /**< 散点半径相对线宽的增长系数。 */
inline const double kMinTimeSpan = 1.0e-12; /**< 允许的最小时间跨度。 */
inline const float kPlotSidePadding = 8.0f; /**< 绘图区左右留白(逻辑像素)。 */
inline const float kLaneDividerWidth = 2.0f; /**< 网格之间分界线的宽度。 */
inline const float kSignalHitTolerance = 6.0f; /**< 信号曲线命中容差(逻辑像素)。 */
/**
* @brief 两个矩形的交集(空交集返回宽高为 0 的矩形)。
*
* 用于把 GDI 绘制(不受 GDI+ 裁剪约束)限制在"本次真正要重画的区域"内,
* 见 `PaintLane` 里的 `laneClip`。
*/
inline gd::RectF IntersectRect(const gd::RectF& first, const gd::RectF& second)
{
const float left = std::max(first.X, second.X);
const float top = std::max(first.Y, second.Y);
const float right = std::min(first.X + first.Width, second.X + second.Width);
const float bottom = std::min(first.Y + first.Height, second.Y + second.Height);
if (right <= left || bottom <= top)
{
return gd::RectF(left, top, 0.0f, 0.0f);
}
return gd::RectF(left, top, right - left, bottom - top);
}
/**
* @brief 绘制「还原/重置」图标(逆时针回环箭头)。
*
* 与 `PopupMenu` 里「系数 / 偏移」二级面板标题栏右侧的还原图标**同一套笔画**
*(用户要求:“可以直接挪用信号『调整』功能中『系数/偏移』二级菜单内的复位控件”),
* 因此弱峰调整面板的复位控件与它视觉完全一致。不依赖字体或资源,任意 DPI 都清晰。
*
* @param [IN,OUT] g 绘图上下文。
* @param [IN] rect 图标外接矩形(正方形)。
* @param [IN] color 笔画颜色(0xAARRGGBB)。
* @param [IN] scale DPI 缩放比例。
*/
inline void PaintPanelResetIcon(gd::Graphics& g, const gd::RectF& rect, std::uint32_t color,
float scale)
{
// 唯一实现见 `PaintUtil.hpp`(弹出菜单的还原图标、信号样式区的复位按钮共用同一套笔画)。
UiPaintResetIcon(g, rect, color, scale);
}
/**
* @brief 吸附半径(以**网格小格**为单位):一个数据点只用其**一个小格宽**内的移动来吸。
*
* 吸附半径 = 一个网格小格的宽度(与判定用同一套网格定义),且**不超过半个采样间距**
* ——后者保证点很稀疏时(例如一屏只有一两个点)光标在点与点之间仍可自由移动,
* 而不是被“死死吸住”(用户明确要求:吸附只是提示性响应)。
*/
inline const double kSnapRadiusCells = 1.0;
/**
* @brief 一般信号查找“离光标最近的有效采样点”时向两侧探测的最大步数。
*
* 采样值里的 NaN 只出现在缺少数据的区段(例如多时间轴 CSV 的较短轴的尾部),
* 因此从最近下标向外少量探测即可找到有效点;探测失败就不吸附(宁可自由移动)。
*/
inline const int kSnapSampleProbeSteps = 64;
/**
* @brief 「按小格判定稀疏」的最少数据点数:**单个横向网格小格**内少于该值即视为稀疏。
*
* 用户 2026-10-17 重新定义(**推翻**此前的“一屏内数据点 < N×4”):
* 按缩放后的横向时间轴网格划分小格,**只要存在任何一个小格内的数据点少于 5 个**,
* 就视为稀疏。这比旧判据更贴近用户意图:**局部**稀疏(例如数据只覆盖视界的一部分、
* 或中间有空洞)也会提示出数据点的位置,而不是等整屏都变稀疏。
*
* ⚠️ 该判据是**同一个判定**的两处用途(用户 2026-10-17 明确要求“同样的状态判定”):
* * 「放大自动切点线」(`UpdateAutoScatterStyles`);
* * 「光标吸附」(`SnapTimeToSample`)—— 即使用户手动改过显示样式,也照样吸附。
*/
inline const int kSparseMinPointsPerCell = 5;
/**
* @brief 单个小格内“判定为稠密”所需的扫描上限(样本数)。
*
* 新判据需要逐小格数点,但只需知道“是否 ≥ `kSparseMinPointsPerCell`”
* ⇒ 累计到该值即早退;这里再给一个上限,保证含大量 NaN 的极端小格也不会长循环。
*/
inline const std::size_t kSparseCellScanCap = 512;
/**
* @brief 「所有可见数据点都是单值信号、且时刻完全相同」时,时间轴**一个大格**对应的秒数。
*
* 用户 2026-10-15 明确要求:这种最极端情况下时间轴以该时刻居中、并缩放到
* **一大格 = 1s** 的级别。视界跨度取 `kTimeTickTarget` 个大格 ⇒
* 跨度 = 10s、`NiceStep(10, kTimeTickTarget)` = **1.0 秒**,正好是"一大格 1s"。
*/
inline const double kSinglePointClusterSecondsPerCell = 1.0;
/**
* @brief 让边界处的单值散点"完整显示"时,在半径之外额外多留的像素(设备像素)。
*
* 只让出**正好等于半径**的量会让圆点边缘贴着网格边框(抗锯齿边缘看起来被切了一刀),
* 因此再多留 1 个像素。
*/
inline const float kSinglePointBoundaryPadPixels = 1.0f;
/**
* @brief 判定"多个单值数据点处于同一时刻"的相对误差。
*
* 同一列里读出的时刻是完全相同的 double;但不同来源(例如 MF4 的整数刻度 ×
* 通道组的标定系数)可能差 1ulp,因此用相对误差而不是精确相等。
*/
inline const double kSinglePointSameTimeEpsilon = 1.0e-9;
inline const float kLaneLabelPad = 6.0f; /**< 网格内侧标尺带的内边距。 */
inline const float kLegendHeight = 19.0f; /**< 图例条目高度(逻辑像素)。 */
inline const float kLegendPad = 5.0f; /**< 图例相对网格左上角的内缩。 */
inline const float kLegendSwatchSize = 8.0f; /**< 图例色块尺寸。 */
inline const float kLegendItemGap = 14.0f; /**< 图例条目之间的间距。 */
inline const float kLegendMaxTextWidth = 200.0f; /**< 图例条目的文本宽度上限(超出则省略)。 */
inline const float kLaneBadgeWidth = 30.0f; /**< 网格编号徽标宽度(与信号面板保持一致)。 */
inline const float kLaneBadgeHeight = 17.0f; /**< 网格编号徽标高度。 */
inline const float kLaneBadgeGap = 8.0f; /**< 编号徽标与图例条目之间的间距。 */
inline const float kEdgePanelWidth = 230.0f; /**< 边沿统计浮动面板宽度。 */
inline const float kEdgePanelTitleHeight = 26.0f; /**< 面板标题栏高度。 */
inline const float kEdgePanelRowHeight = 18.0f; /**< 面板内单行文本高度。 */
inline const float kEdgePanelPad = 7.0f; /**< 面板内边距。 */
inline const float kEdgePanelNameMaxWidth = 150.0f; /**< 面板内信号名的宽度上限(超过则省略;避免超长信号名把面板顶得很长)。 */
inline const float kEdgePanelStatColumnGap = 14.0f; /**< 「上升沿 / 下降沿」两列之间的间距(保证多信号的文字垂直对齐)。 */
inline const float kEdgePanelEditFieldWidth = 64.0f; /**< 数值框的最小宽度。 */
inline const float kEdgePanelButtonHeight = 30.0f; /**< 均值滤波面板底部按钮行高度。 */
inline const float kEdgePanelMeanRowHeight = 22.0f; /**< 逐条目面板中每条信号的名称行高度。 */
inline const float kEdgePanelMeanEditHeight = 24.0f; /**< 逐条目面板中每条信号的参数行高度。 */
inline const float kEdgePanelMeanRowGap = 2.0f; /**< 名称行与参数行之间的间距。 */
inline const float kEdgePanelMeanCheckSize = 15.0f; /**< 逐条目面板中条目复选框尺寸。 */
inline const float kWeakPeakSliderRowHeight = 26.0f; /**< 弱峰调整面板中每个滑块行的高度。 */
inline const float kWeakPeakTrackHeight = 6.0f; /**< 弱峰调整滑块的轨道高度。 */
inline const float kWeakPeakTrackMinWidth = 120.0f; /**< 弱峰调整滑块轨道的最小宽度。 */
/**
* @brief 「弱峰调整」滑块行右侧**复位控件**的边长(逻辑像素)。
*
* 每行滑块都有一个复位按钮(与「系数/偏移」二级面板里的「还原」图标同一套绘制),
* 点击把该行的参数恢复为默认值(幅度 0 / 迭代次数 1 且上限回到初始上限)。
*/
inline const float kWeakPeakResetButtonSize = 16.0f;
/** 复位控件与其左侧数值列之间的间距(逻辑像素)。 */
inline const float kWeakPeakResetGap = 4.0f;
// ---------------------------------------------------------------- 自由标记
/** 「自由标记」面板里信号**分组栏**的高度(左侧三角 + 信号名,右侧标记点数 + ✕)。 */
inline const float kFreeMarkerNameRowHeight = 22.0f;
/** 「自由标记」面板里每个固定点行的高度(五列 + 行尾 ✕)。 */
inline const float kFreeMarkerItemRowHeight = 20.0f;
/**
* @brief 「自由标记」面板里每组分栏下方那一行**列表头**的高度(逻辑像素)。
*
* 表头 = 序号 / 标记值 / (枚举文本)/ 增量 / 时间点 / 时间间隔 的列标题,
* 与数据行共用同一组列边界 ⇒ 表头与数据严格对齐。
* ⚠️ 滚动时分组栏与表头会**各自吸顶**(见 `EdgePanelLayoutFor` 的 `MarkerGroup`),
* 因此表头高度必须参与内容总高度与吸顶量计算。
*/
inline const float kFreeMarkerHeaderRowHeight = 18.0f;
/**
* @brief 「自由标记」面板表头的列标题(顺序与 `markerColumns` 一致:
* ① 序号 ② 标记值 ③ 枚举文本 ④ 增量 ⑤ 时间点 ⑥ 时间间隔)。
*/
inline const wchar_t* const kFreeMarkerColumnLabels[6] = {
L"序号", L"标记值", L"枚举文本", L"增量", L"时间点", L"时间间隔"};
/** 「自由标记」面板与行尾 ✕ 按钮的边长(网格内取值胶囊后的 ✕ 另按胶囊高度取正方形)。 */
inline const float kFreeMarkerCloseSize = 13.0f;
/**
* @brief 「自由标记」面板的宽度(逻辑像素,作为下限)。
*
* 实际宽度 = `max(本值, 六列实测/缺省/列标题宽度之和 + 行 ✕ + 滚动条预留)`;
* 这里取固定下限而**不做逐行文本测量**(`EdgePanelWidth` 被 `EdgePanelLayoutFor` 调用,
* 而后者在 `TopPanelAtPoint` / `SyncPanelOrder` 里被高频调用)。
*/
inline const float kFreeMarkerPanelWidth = 424.0f;
/**
* @brief 「自由标记」面板六列的逻辑宽度下限
* (**按此顺序**:序号 / 标记值 / 枚举文本 / 增量 / 时间点 / 时间间隔)。
*
* 实际列宽取「本下限」「实测内容宽度」「列标题宽度」三者的**较大者**
* ⇒ 面板里的数据与表头都不会出现省略号。
* 当整个面板里没有任何枚举文本时,枚举这一列会被**整列缺省**(列宽记 0,
* 后面的列左移),其它列的宽度不变。
*/
inline const float kFreeMarkerColumnWidths[6] = {34.0f, 74.0f, 80.0f, 60.0f, 72.0f, 78.0f};
/** 「自由标记」面板行内相邻两列之间的间距。 */
inline const float kFreeMarkerColumnGap = 2.0f;
/** 「自由标记」面板内容滚动条的宽度(逻辑像素)。 */
inline const float kFreeMarkerScrollWidth = 7.0f;
/**
* @brief 「自由标记」面板**内容区**的最大高度(逻辑像素)。
*
* 超过它就出现上下滚动条(分组栏与固定点条目一起滚动);面板底部的按钮与
* 「标记透明度」滑动条不参与滚动。
*/
inline const float kFreeMarkerContentMaxHeight = 260.0f;
/** 「自由标记」面板底部「标记透明度」滑动条行的高度(与信号样式区的行高同量级)。 */
inline const float kFreeMarkerSliderRowHeight = 24.0f;
/**
* @brief 「自由标记」面板底部相邻两行按钮之间的间隙(逻辑像素)。
*
* 底部固定区自上而下:`标记透明度` 滑块行 → `自由标记点选取` → `显示增量 | 显示枚举文本`。
* 后两行之间用本间隙分开,避免两个按钮看起来连成一块。
*/
inline const float kFreeMarkerButtonGap = 4.0f;
/**
* @brief 所有实验性功能浮动面板标题栏左侧「展开/折起」指示器的宽度。
*
* 指示器 = 与信号列表组名**同款**的折叠三角(朝右 = 已折起、朝下 = 已展开),
* 单击它切换面板的折起状态。`GX` 编号徽标向右让出这块宽度。
* ⚠️ 2026-10-21 起**不再**画三角后方那条横线(用户要求与组名标记完全一致)。
*/
inline const float kEdgePanelDisclosureWidth = 20.0f;
/**
* @brief 面板内**条目行 / 分组栏**左侧展开三角的边长(逻辑像素)。
*
* ⚠️ 与标题栏的 `kEdgePanelDisclosureWidth`(20)**刻意不同**:行高只有 22 逻辑像素,
* 20 的三角会把整行顶满、显得又大又笨(用户 2026-10-30 报告)。
* 「均值滤波」的条目栏与「自由标记」的分组栏共用本值(12),观感完全一致。
*/
inline const float kEdgePanelRowTriangleSize = 12.0f;
// ⚠️ 这里曾有 `kFreeMarkerHoverTolerance = 8`(旧的非光标模式“纵向贴近”容差)。
// 用户 2026-10-22 要求把自由标记的触发范围与「常规状态下触发信号曲线高亮」**统一**,
// 因此现在直接复用 `kSignalHitTolerance`(6 逻辑像素)并改判“到折线的垂直距离”
//(见 `PlotView::ProbeFreeMarker`)。旧的 8 像素常量已删除,不要再加回来。
/** 固定点取值胶囊与 ✕ 之间的间距。 */
inline const float kFreeMarkerCloseGap = 3.0f;
/** 跟随光标纵向线与网格左右边界之间的留白(避免线与边框重叠)。 */
inline const float kFreeMarkerLineInset = 1.0f;
/**
* @brief 把数值格式化为**始终带符号**的文本(`+1.5` / `-0.25` / `+0`)。
*
* 「自由标记」的取值增量与时间增量都要求显式正负号(即使是 0 也带 `+`),
* 与双光标模式的增量显示规则一致。
*/
inline std::wstring FormatSignedValue(double value)
{
std::wstring text = (value < 0.0) ? L"-" : L"+";
text += FormatValue(std::fabs(value));
return text;
}
/**
* @brief 「自由标记」面板/胶囊里的取值部分:`值[(±增量)] [= 枚举文本]`。
*
* 两个开关仅作用于**已固定点**的显示(「显示增量」/「显示枚举文本」):
* 跟随鼠标的预览胶囊恒传 `showDelta=false, showEnum=true`(保持原有观感),
* 面板统计列也不看这两个开关(它总是显示全部信息)。
*
* @param value 当前取值(已叠加「调整」变换)。
* @param showDelta 是否显示括号内增量。
* @param hasPrev 是否存在前一个同信号固定点(存在才显示增量)。
* @param delta 相对前一个固定点的取值增量。
* @param showEnum 是否显示枚举文本。
* @param enumText 枚举文本(可为空指针)。
*/
inline std::wstring FreeMarkerValueText(double value, bool showDelta, bool hasPrev, double delta,
bool showEnum, const std::wstring* enumText)
{
std::wstring text = FormatValue(value);
if (showDelta && hasPrev)
{
text += L"(";
text += FormatSignedValue(delta);
text += L")";
}
if (showEnum && enumText != nullptr && !enumText->empty())
{
text += L" = ";
text += *enumText;
}
return text;
}
/**
* @brief 「自由标记」的时间列文本:`<时刻>s`。
*
* 数值格式与光标时间读数**完全相同**(`FormatValue` + `s` 后缀)。
*/
inline std::wstring FreeMarkerTimeText(double time)
{
return FormatValue(time) + L"s";
}
/** 「自由标记」面板里缺省(不适用)的单元格文本。 */
inline const wchar_t* const kFreeMarkerNotAvailable = L"N/A";
/**
* @brief 「自由标记」的时间间隔列文本:`Δt:+<间隔>`。
*
* 平时用复合单位(自动匹配 ms / s / min,与双光标差值读数一致),
* 双击后按**秒**编辑。首个固定点没有前一项 ⇒ 显示 `N/A`(该列不省略,保持列对齐)。
*/
inline std::wstring FreeMarkerDeltaText(bool hasPrev, double timeDelta)
{
if (!hasPrev)
{
return std::wstring(kFreeMarkerNotAvailable);
}
// 时间间隔恒为正(按时间排序后当前点总在后方);显示时显式带 `+`。
return L"Δt:+" + FormatDurationCompound(std::fabs(timeDelta));
}
/**
* @brief 收集某信号的全部固定点下标,按**时间升序**输出。
*
* 面板里同一信号下的条目按「从左到右」从上到下排列,「前一个固定点」也按这个顺序
* 计算,因此绘制、命中、增量计算必须共用本函数(否则三者会各自排一次序而错位)。
*
* @param [IN] lane 目标网格。
* @param [IN] ref 目标信号。
* @param [OUT] out 追加写入的下标(`Lane::freeMarkerItems` 的下标)。
*/
inline void SortedFreeMarkerIndices(const Lane& lane, const SignalRef& ref,
std::vector<std::size_t>& out)
{
out.clear();
for (std::size_t index = 0; index < lane.freeMarkerItems.size(); ++index)
{
if (lane.freeMarkerItems[index].ref == ref)
{
out.push_back(index);
}
}
std::stable_sort(out.begin(), out.end(),
[&lane](std::size_t a, std::size_t b)
{
return lane.freeMarkerItems[a].time <
lane.freeMarkerItems[b].time;
});
}
inline const int kWeakPeakIterationsMin = 1; /**< 迭代次数 N 的下限。 */
/** 迭代次数滑块「自定义上限」的允许范围(与算法内部的阶数夹取保持一致)。 */
inline const int kWeakPeakIterationsMaxLimit = 1000;
/** 迭代次数滑块上限的默认值(与 `Lane::WeakPeakItem::maxIterations` 一致)。 */
inline const int kWeakPeakIterationsDefaultMax = 10;
/**
* @brief 「弱峰调整」滑动条的量程(±400%)。
*
* 滑动条同时驱动三组识别规则(见 `ComputeWeakPeakAdjust`):0–100% 只有第一类规则在动,
* 之后每 100% 加入下一类、而前一类继续走完自己的 0–200%。因此量程是算法内部
* 「单组 ±200%」的两倍。
*/
inline const double kWeakPeakSliderScale = 4.0;
/** 弱峰调整幅度百分比上限(滑动条右端,用于构建新信号 / 曲线缓存键)。 */
inline const int kWeakPeakAmountMax = 400;
/** 弱峰调整幅度百分比下限(滑动条左端,负值 = 削弱)。 */
inline const int kWeakPeakAmountMin = -400;
/**
* @brief 派生曲线「区间窗口」缓存的保护带上限(采样点数)。
*
* 窗口只覆盖可见区间 ± 保护带,可见区间内的取值与整条曲线一致(见 `CurveSpanFor`)。
* 上限避免在完全缩小时把保护带放大到整条信号(那就退化成重算整条曲线)。
*/
inline const std::size_t kDerivedCurveGuardMax = 65536;
/** 派生曲线缓存条目上限(超出时按「最旧优先」淘汰;它只是加速与内存控制手段)。 */
inline const std::size_t kCurveSpanCacheLimit = 32;
/** 派生曲线缓存允许占用的总字节数(约 768MB):超出时按「最旧优先」淘汰。
* 取足够大是为了让「完全放大时 5 个网格各缓存一条整段曲线」可以同时驻留。 */
inline const std::size_t kCurveSpanCacheMaxBytes = 768ull * 1024ull * 1024ull;
/**
* @brief 派生(预览)曲线走「后台异步计算」的窗口采样数门槛。
*
* 拖动滑块 / 移动视界 / 缩放每帧都会请求预览曲线。窗口不超过本门槛时同步重算足够快
* (弱峰调整 ≈0.9ms/6.5 万采样),预览曲线能**实时跟随控件**;一旦窗口超过门槛
* (大时间跨度、整条信号),同步重算会阻塞交互(弱峰调整整条信号单条 ≈368ms),
* 此时改为交后台线程计算、本帧先用旧曲线顶替(双缓冲),保证控件反馈始终跟手。
*/
inline const std::size_t kDerivedAsyncWindowThreshold = 65536;
/**
* @brief 「超大信号」门槛:信号采样数超过它时,**拖拽参数期间不再反复排队**"整条信号"的
* 预览曲线重算(见 `CurveSpanFor`)。
*
* 预览曲线按整条信号计算(`[0, count]`,与「构建为新信号」逐位一致)。信号极大时每条
* 曲线要扫描上亿个采样,后台线程被持续占满会**反过来拖慢前台绘制**(实测前台 14ms/帧,
* 而绘制本身仅 0.4ms)。此时拖动期间沿用旧曲线、松手后再算一次最新的即可 ——
* 整条信号的重算本来就追不上滑块速度,"冻结"与"每帧重排"观感相同,但后者拖慢前台。
*/
inline const std::size_t kHugeSignalPreviewSamples = 4000000ull;
inline const wchar_t* const kWeakPeakLevelLabel = L"调整幅度"; /**< 逐条目面板里「幅度」滑块行的标签(用户要求:滑动条前不再单独显示 `%`)。 */
inline const wchar_t* const kWeakPeakIterationsLabel = L"迭代次数 N"; /**< MEAN / CENTER 面板里「迭代次数」输入框的标签。 */
/** 「弱峰调整」迭代次数滑块行的标签(用户要求:滑动条前不再单独显示 `N`)。 */
inline const wchar_t* const kWeakPeakIterationsSliderLabel = L"迭代次数";
inline const wchar_t* const kWeakPeakTitle = L"弱峰调整"; /**< 弱峰调整面板标题。 */
inline const wchar_t* const kBuildSignalLabel = L"构建为新信号"; /**< 逐条目面板底部按钮文案(均值滤波 / 弱峰调整共用)。 */
// ---- 「均值滤波」面板的两行参数滑块(用户 2026-10-30 要求)----
/**
* 标签一律**不带 `X` / `N`**:滑块的量程本身已经说明是数值,符号只会让标签变长。
* (信号样式区与菜单里仍保留 `均值点数 X` / `迭代次数 N` 的写法,那是输入框语义。)
*/
inline const wchar_t* const kMeanFilterPointsSliderLabel = L"均值点数";
inline const wchar_t* const kMeanFilterIterationsSliderLabel = L"迭代次数";
/**
* @brief 「均值点数」下限。
*
* ⚠️ **必须是 1**(用户 2026-10-30 要求):窗口 = `2X+1`,`X = 0` 等于"不做任何平滑",
* 滑块拖到最左端时预览曲线与原始波形完全重合 —— 那不像"滤波",用户会以为功能坏了。
* 下限取 1 ⇒ 最左端是 3 点均值,始终有可见的平滑效果。
*/
inline const int kMeanFilterPointsMin = 1;
/** 「均值点数」滑块的默认上限(用户可点击数值改成其它正整数)。 */
inline const int kMeanFilterPointsDefaultMax = 50;
/** 「均值点数」上限允许的最大值(防止输入一个天文数字把滤波窗口撑爆)。 */
inline const int kMeanFilterPointsMaxLimit = 100000;
/** 新建条目时的默认均值点数(与 `Lane::MeanFilterItem::points` 初值一致)。 */
inline const int kMeanFilterPointsDefault = 5;
/** 「迭代次数」下限。 */
inline const int kMeanFilterIterationsMin = 1;
/** 「迭代次数」滑块的默认上限。 */
inline const int kMeanFilterIterationsDefaultMax = 10;
/** 「迭代次数」上限允许的最大值。 */
inline const int kMeanFilterIterationsMaxLimit = 1000;
/** 「自由标记」面板底部「自由标记点选取」切换按钮文案(激活态 = 光标模式开启)。 */
inline const wchar_t* const kFreeMarkerCursorModeLabel = L"自由标记点选取";
/** 「自由标记」面板底部「显示增量」切换按钮文案(只影响已固定点的显示)。 */
inline const wchar_t* const kFreeMarkerShowDeltaLabel = L"显示增量";
/** 「自由标记」面板底部「显示枚举文本」切换按钮文案(只影响已固定点的显示)。 */
inline const wchar_t* const kFreeMarkerShowEnumLabel = L"显示枚举文本";
/** 「自由标记」面板底部切换按钮激活时出现在其上方的滑动条标签。 */
inline const wchar_t* const kFreeMarkerOpacityLabel = L"标记透明度";
/**
* @brief 边沿统计累积状态上限。
*
* ⚠️ 必须足够大:`普通 + 中心轴` × 一个网格里的信号数(都勾选时)会被全部使用。
* 上限太小会**淘汰其它信号的已扫完状态** → 它们从头重扫(表现为“改一个参数、
* 其它几个信号也重新统计”)。一个状态仅占「块数组 ≈ (采样数/8192)×16B」+ 几十 KB
* 本地曲线,64 个状态对内存无压力。
* 真的超限时**优先淘汰「已扫完」的最旧状态**(它重新扫完很快),绝不淘汰正在推进的。
*/
inline const std::size_t kEdgeScanStateLimit = 64;
/**
* @brief 「边沿计数」累积统计的分块大小(采样点数)。
*
* 每块各存上升/下降沿数:查询某个时间区间时,内部整块用存储值相加、只有两端边界块
* 需要就地精确扫描(≤ 2 块),因此拖动时间光标的单帧代价与区间长度无关。
* 取小一点 ⇒ 边界块扫描更便宜;取大一点 ⇒ 块数组更省内存。
*/
inline const std::size_t kEdgeBlockSamples = 8192;
/**
* @brief 每一帧允许「边沿计数」扫描的采样对总数(跨全部累积状态共享)。
*
* 千万级信号整段扫描一次 ≈115ms(用户报告:5 条信号同一网格时单帧 ≈575ms,
* 界面完全失去响应)。改成预算推进后,单帧最多只花 ≈5ms 在统计上,
* 剩下的部分在后续帧里接着扫(未扫完时界面显示上一个完整结果 + “…”)。
*
* ⚠️ **必须按帧重置**(在 `Paint` 开头):旧实现按 `GetTickCount64()` 分桶,
* 而系统时钟粒度约 15.6ms —— 当帧率高于它时(`PostMessage` 自持重绘会把帧率拉高),
* 同一 tick 内的多帧**完全不推进**(用户报告:“按住横向光标不动时统计卡在统计中”,
* “回车确认参数后不移动鼠标就一直停在部分值”)。
*/
inline const std::size_t kEdgeScanPairsPerFrame = 1200000;
/**
* @brief 每个累积状态每帧的扫描配额下限(防止多个状态时前面的把预算吃光)。
*
* 过去所有状态共享一个“本 tick 总预算”,而推进总是从列表中的第一个状态开始
* —— 5 条信号时只有第一条有进展,其余 4 条长期显示“统计中…”(用户报告
* “很多项都停在了统计中不更新”)。现在每个状态各自持有每帧配额,互不饿死。
*/
inline const std::size_t kEdgeScanPairsPerStateMin = 60000;
/**
* @brief 计算每状态配额时假定的“最小并发状态数”。
*
* ⚠️ 不能直接用 `m_edgeScans.size()`:状态是在**同一帧内逐个创建**的,第一个状态的
* 那一刻 `size() == 1`,会拿走整额预算 → 同帧后面新建的状态全部被截断为 0 配额
*(首帧只有第一条信号推进)。按一个典型并发量(普通 + 中心轴 × 网格内信号数)
* 下限分摊,可保证同一帧里所有状态都得到推进。
*/
inline const std::size_t kEdgeScanFairShare = 6;
/**
* @brief 「边沿计数」结果里的**上升沿**部分文本。
* @param [IN] rising 上升沿数量。 @param [IN] complete 是否为完整精确值。
* @return 形如 "上升沿 123"(未算完时追加 " …")的文本。
*/
inline std::wstring EdgeRisingText(std::size_t rising, bool complete)
{
std::wstring text = L"上升沿 " + std::to_wstring(rising);
if (!complete)
{
text += L" …";
}
return text;
}
/**
* @brief 「边沿计数」结果里的**下降沿**部分文本。
* @param [IN] falling 下降沿数量。 @param [IN] complete 是否为完整精确值。
* @return 形如 "下降沿 456"(未算完时追加 " …")的文本。
*/
inline std::wstring EdgeFallingText(std::size_t falling, bool complete)
{
std::wstring text = L"下降沿 " + std::to_wstring(falling);
if (!complete)
{
text += L" …";
}
return text;
}
/**
* @brief 「边沿计数」是否处于“完全未算出”状态(用于显示“统计中…”)。
*/
inline bool EdgeCountsPending(std::size_t rising, std::size_t falling, bool complete)
{
return (!complete && rising == 0 && falling == 0);
}
/**
* @brief 「边沿计数」结果的显示文本(合并成一行,用于宽度测量与兼容旧调用)。
*
* ⚠️ 未完成时**两个数字后面都要带 `…`**(旧实现只在整行末尾追加一个 `…`,
* 视觉上只跟在“下降沿”的数字后面,用户明确要求“为上升沿也加上这个尾缀”)。
* @param [IN] rising 上升沿数量。 @param [IN] falling 下降沿数量。
* @param [IN] complete 是否为该阈值的完整精确值。
* @return 面板/宽度测量共用的文本。
*/
[[maybe_unused]] inline std::wstring EdgeCountsText(std::size_t rising, std::size_t falling,
bool complete)
{
if (EdgeCountsPending(rising, falling, complete))
{
return L"统计中…";
}
return EdgeRisingText(rising, complete) + L" " + EdgeFallingText(falling, complete);
}
/**
* @brief 判断坐标是否命中「弱峰调整」的某条滑块轨道。
*
* 轨道本身只有几个像素高,直接按轨道矩形命中很难抓住,因此把命中区在纵向上下各放宽
* (至少一个轨道高度),与样式区滑块的交互一致。
*
* @param [IN] track 滑块轨道矩形(宽度为 0 表示当前没有滑块)。
* @param [IN] scale DPI 缩放比例。
* @param [IN] x 客户区横坐标。
* @param [IN] y 客户区纵坐标。
* @return 命中返回 true。
*/
inline bool WeakPeakSliderHit(const gd::RectF& track, float scale, float x, float y)
{
if (track.Width <= 0.0f)
{
return false;
}
gd::RectF hit = track;
const float pad = std::max(8.0f * scale, track.Height);
hit.Y -= pad;
hit.Height += pad * 2.0f;
return UiPointInRect(hit, x, y);
}
/**
* @brief 「弱峰调整」滑块行右侧**数值列**的宽度(布局与面板宽度共用同一算式)。
*
* 取「+400%」与「10000」两者较宽者:前者是幅度滑块的最大显示文本
*(**正负号始终显示**,且量程已扩到 ±400%),
* 后者为迭代次数上限留出足够位宽(上限可被用户改大)。
*/
inline float WeakPeakValueColumnWidth(HFONT monoSmall)
{
return std::max(UiMeasureTextWidth(monoSmall, L"+400%"),
UiMeasureTextWidth(monoSmall, L"10000"));
}
/** @brief 「弱峰调整」滑块行左侧**标签列**的宽度(两条标签取较宽者,保证轨道左对齐)。 */
inline float WeakPeakLabelColumnWidth(HFONT uiSmall)
{
return std::max(UiMeasureTextWidth(uiSmall, kWeakPeakLevelLabel),
UiMeasureTextWidth(uiSmall, kWeakPeakIterationsSliderLabel));
}
/**
* @brief 取折线(相邻采样点之间线性连接)在给定时刻的取值。
*
* 包络是按像素列聚合的,因此某一段折线可能**跨越列边界**:跨越边界的那一小段
* 在两个列里都不属于"落在本列内的采样点",只看列内采样点就会把它整段丢掉。
* 该函数用于在列边界处取回这段折线的取值,使相邻两列的包络严丝合缝。
*
* @param [IN] time 时间轴。
* @param [IN] values 采样值。
* @param [IN] count 可用采样点数量。
* @param [IN] style 显示外观(水平镜像时按**源下标反序**取值)。
* @param [IN] firstIndex 第一个时间不小于查询时刻的采样下标(相交线段为 firstIndex-1 → firstIndex)。
* @param [IN] t 查询时刻。
* @param [OUT] out 输出取值。
* @return 折线覆盖该时刻且取到有效值返回 true。
*/
inline bool PolylineValueAt(const std::vector<double>& time, const std::vector<double>& values,
std::size_t count, const SignalStyle& style, std::size_t firstIndex,
double t, double& out)
{
if (firstIndex == 0 || firstIndex >= count)
{
// 该时刻之前(或之后)没有采样点,折线并未延伸到这里。
return false;
}
const double t0 = time[firstIndex - 1];
const double t1 = time[firstIndex];
// 水平镜像时画面上的相邻两点取源数组中头尾对调后的两个采样。
const double v0 = values[SourceIndexFor(style, count, firstIndex - 1)];
const double v1 = values[SourceIndexFor(style, count, firstIndex)];
if (!std::isfinite(t0) || !std::isfinite(t1) || !std::isfinite(v0) || !std::isfinite(v1) ||
!(t1 > t0))
{
return false;
}
double ratio = (t - t0) / (t1 - t0);
if (ratio < 0.0)
{
ratio = 0.0;
}
else if (ratio > 1.0)
{
ratio = 1.0;
}
out = v0 + (v1 - v0) * ratio;
return true;
}
/**
* @brief 统计相邻采样对 (k-1, k) 中穿越判定基准的边沿数量。
*
* `deltaAt(k)` 返回第 k 个采样相对判定基准的偏差;返回非有限值(NaN)表示该采样
* 不参与判定,并**打断连续性**(与前一点之间不计边沿)——这正好覆盖
* 「采样缺失」与「采样落在统计时间区间之外」两种情况。
*
* 统计的是采样对 `k ∈ [pairFirst, pairLast]`(闭区间),每对的「前一点」为 `k-1`,
* 因此起点对的连续性由 `deltaAt(pairFirst - 1)` 决定(它无效时从下一个有效点重新起算)。
*
* 该函数是增量统计的基本单位:只要把区间切成互不相交的若干段分别累加(或从总量中减去),
* 结果与一次性扫描整段完全一致,因此拖动光标时可以只扫描两端差集。
*
* @param [IN] pairFirst 起始采样对的下标 k(含),必须 ≥ 1。
* @param [IN] pairLast 结束采样对的下标 k(含);小于起点时不做任何事。
* @param [IN] deltaAt 偏差取值仿函数。
* @param [IN,OUT] rising 累加的上升沿数量。
* @param [IN,OUT] falling 累加的下降沿数量。
*/
template <typename DeltaAt>
inline void AccumulateEdgeCrossings(std::size_t pairFirst, std::size_t pairLast, DeltaAt deltaAt,
std::size_t& rising, std::size_t& falling)
{
if (pairFirst == 0 || pairFirst > pairLast)
{
return;
}
double previousDelta = deltaAt(pairFirst - 1);
bool hasPrevious = std::isfinite(previousDelta);
for (std::size_t index = pairFirst; index <= pairLast; ++index)
{
const double current = deltaAt(index);
if (!std::isfinite(current))
{
hasPrevious = false;
continue;
}
if (hasPrevious)
{
if (previousDelta < 0.0 && current >= 0.0)
{
++rising;
}
else if (previousDelta > 0.0 && current <= 0.0)
{
++falling;
}
}
previousDelta = current;
hasPrevious = true;
}
}
/**
* @brief 「调整」变换 + 水平镜像的指纹(用于边沿统计缓存键)。
*
* 边沿判定基于**画出来的曲线**,因此任何一个变换项改变都可能改变计数结果;
* 把这些项折叠成一个指纹后,样式一变就能立刻失效缓存项(否则会沿用变换前的旧计数)。
*
* @param [IN] style 信号外观。
* @return 指纹值。
*/
inline std::uint64_t EdgeStyleKey(const SignalStyle& style)
{
std::uint64_t key = 1469598103934665603ull;
const auto mix = [&key](std::uint64_t value)
{
key ^= value;
key *= 1099511628211ull;
};
mix(static_cast<std::uint64_t>(
std::llround(static_cast<double>(style.gain) * 1.0e6)));
mix(static_cast<std::uint64_t>(
std::llround(static_cast<double>(style.offsetY) * 1.0e6)));
mix(style.absolute ? 1u : 0u);
mix(style.mirrorVertical ? 2u : 0u);
mix(style.mirrorHorizontal ? 4u : 0u);
return key;
}
/**
* @brief 计算统计区间对应的采样下标区间 [begin, end)。
*
* 时间轴单调时用二分定位。⚠️ 区间**之外**的采样一律不参与计数,
* 因此这里**不做任何外扩**:只有两端都落在 `[begin, end)` 内的采样对才会被累计
*(调用方从 `begin + 1` 开始枚举采样对)。
*
* @param [IN] group 所属数据分组。
* @param [IN] count 采样点数量。
* @param [IN] hasRange 是否限定时间区间。
* @param [IN] rangeMin 区间左端点时间。
* @param [IN] rangeMax 区间右端点时间。
* @param [OUT] begin 输出区间起点(含)。
* @param [OUT] end 输出区间终点(不含)。
* @return 计数是否完全由该下标区间决定(时间轴单调时为真)。
*/
inline bool EdgeIndexRange(const TimeAxis& axis, std::size_t count, bool hasRange, double rangeMin,
double rangeMax, std::size_t& begin, std::size_t& end)
{
begin = 0;
end = count;
if (!hasRange)
{
return true;
}
if (!axis.monotonic)
{
// 时间轴非单调时无法用下标区间表达统计范围(需逐点按时间过滤)。
return false;
}
const std::vector<double>& times = axis.time;
const std::size_t timeCount = std::min(times.size(), count);
begin = static_cast<std::size_t>(
std::lower_bound(times.begin(), times.begin() + static_cast<std::ptrdiff_t>(timeCount),
rangeMin) -
times.begin());
// ⚠️ **不要**在左端多取一个采样点:那会让“跨越区间边界的边沿”也算进来,
// 而用户明确要求“在光标选区外的点不要算入计数规则”。
// (例:光标恰好落在越过阈值的第一个采样点 B 上时,A→B 这一段处于选区之外,
// 不计入;旧实现在左端多取一个点,于是 B 被错认成上升沿。)
end = static_cast<std::size_t>(
std::upper_bound(times.begin(), times.begin() + static_cast<std::ptrdiff_t>(timeCount),
rangeMax) -
times.begin());
end = std::min(end, timeCount);
begin = std::min(begin, end);
return true;
}
inline const std::uint64_t kHintHoldMs = 1300; /**< 提示浮层完全显示的停留时长(毫秒)。 */
inline const std::uint64_t kHintFadeMs = 500; /**< 提示浮层的淡出时长(毫秒)。 */
/**
* @brief 「回到最上方」浮动按钮的**淡出**时长(毫秒)—— **与提示浮层完全一致**。
*
* 用户 2026-10-17 明确要求(参考「选区放大框选过程中的浮动提示淡出」):
* 到达顶部后按钮**立即开始**淡出(**不停留**,不像提示浮层那样先停 `kHintHoldMs`),
* 且淡出时长与 alpha 曲线(**线性**)都跟 `PaintHint` 的淡出一模一样。
*/
inline const float kBackToTopFadeOutMs = static_cast<float>(kHintFadeMs);
/**
* @brief 实验性功能浮动面板的遍历顺序(命中测试与绘制共用,保证层序一致)。
*
* 顺序即绘制层序:先绘制的在下方,最后绘制的在最上方。
*/
inline const EdgePanelKind kEdgePanelKinds[] = {EdgePanelKind::COUNT, EdgePanelKind::CENTER,
EdgePanelKind::MEAN, EdgePanelKind::PEAK,
EdgePanelKind::MARK};
/** 实验性功能浮动面板的种类数(遍历与命中测试统一用它,避免新增功能时漏改)。 */
inline const int kEdgePanelKindCount = static_cast<int>(sizeof(kEdgePanelKinds) / sizeof(kEdgePanelKinds[0]));
/**
* @brief 计算一条派生曲线在给定采样窗口内的取值(同步路径与后台线程共用)。
*
* 只拷贝所需的窗口,再调用与「构建为新信号」完全相同的自由函数,因此
* 预览曲线与构建出的采样值逐位一致。本函数**只读**入参、不接触任何成员状态,
* 因此可以安全地在后台线程调用。
*
* @param [IN] signal 目标信号(数据只读)。
* @param [IN] windowBegin 窗口起点(采样下标)。
* @param [IN] windowEnd 窗口终点(不含)。
* @param [IN] kind `DerivedCurveKind` 的整型值。
* @param [IN] points 均值点数 X。
* @param [IN] iterations 迭代次数 N。
* @param [IN] amountPercent 弱峰调整幅度(-400–400)。
* @param [IN] cancel 可选的取消标志(透传给算法,使过时任务尽快退出)。
* @param [IN] throttle 是否在阶与阶之间让出 CPU(低内存设备的“少量多次”模式)。
* @return 曲线数值(长度 = 有效窗口长度)。
*/
inline std::vector<float> ComputeDerivedWindow(const SignalData& signal, std::size_t windowBegin,
std::size_t windowEnd, int kind, int points,
int iterations, int amountPercent,
const std::atomic<bool>* cancel, bool throttle)
{
std::vector<float> out;
const std::vector<double>& values = signal.values;
const std::size_t hi = std::min(windowEnd, values.size());
const std::size_t lo = std::min(windowBegin, hi);
if (hi <= lo)
{
return out;
}
// ⚠️ **整段窗口不要复制**:`VERY_BIG_DATA` 一条曲线要 8 B/采样 × 2580 万 ≈ 206 MB,
// 而“预览曲线一律按整条信号计算”正是最常见的情形。
// 采样在本函数执行期间不会被修改(结构变化前宿主会先 `PauseDerivedAsync`),
// 因此整段时直接引用原向量;只有子窗口才切片复制。
const bool wholeSignal = (lo == 0) && (hi == values.size());
const std::vector<double> window = wholeSignal
? std::vector<double>()
: std::vector<double>(
values.begin() + static_cast<std::ptrdiff_t>(lo),
values.begin() + static_cast<std::ptrdiff_t>(hi));
const std::vector<double>& input = wholeSignal ? values : window;
if (kind == static_cast<int>(DerivedCurveKind::WEAK_PEAK))
{
ComputeWeakPeakAdjust(input, static_cast<double>(amountPercent) / 100.0, iterations, out,
cancel, throttle);
}
else
{
ComputeMovingAverageIterated(input, points, iterations, out, cancel, throttle);
}
return out;
}
/**
* @brief 包络带的单个像素列(已映射到屏幕坐标)。
*/
struct BandColumn
{
float x = 0.0f; /**< 列中心横坐标。 */
float top = 0.0f; /**< 本列的**上**包络(较小的纵坐标)。 */
float bottom = 0.0f; /**< 本列的**下**包络(较大的纵坐标)。 */
};
/**
* @brief 把逐列极值拼成闭合的包络带多边形。
*
* 输出格式为「上包络正向 + 下包络反向」,调用方(`PaintLane`)依赖这个顺序把
* 它拆成上下两条折线画成一个闭合环。
*
* ⚠️ **几何本身不做"数据加宽"**:曾经为了让填充更快而做过 ±1 列的极值扩展,
* 结果把"梳状"振荡糊成了实心块,波形细节整体丢失
* (用户反馈"显示效果没有之前好了")。性能问题改由**绘制侧**解决:把梳状带
* 交给无抗锯齿填充(见 `spiky` 输出与 `PlotView::m_fastFrame`)。
*
* ⚠️⚠️ **但线宽必须同时在两个方向加粗**(用户 2026-10-20 报告:把线宽从 1px
* 拖到 10px,图像上**竖直方向的沿**(方波的上升/下降段)宽度毫无变化):
* 包络带每列只有 1 个 x,靠 `top/bottom` 各外扩只能让**水平段**变粗;竖直沿的
* **横向**宽度始终是「1 个像素列」。正确做法是对上下包络做**横向膨胀**
* (窗口内 `top` 取最小、`bottom` 取最大,等价于用一个边长 `2*halfWidth` 的方块
* 做闵可夫斯基膨胀)——这样竖直沿也会被撑成 `2*halfWidth + 列宽` 宽。
* 膨胀半径由**线宽**决定(`halfWidth`),与数据无关,因此不会掩盖波形细节。
* ⚠️ 膨胀窗口**只在同一段内**生效(`segmentColumns` 按段清空),因此 NaN 断点处
* 不会被"跨断点连线"填满。
*
* @param [IN] columns 逐列极值(同一段内按列顺序排列)。
* @param [OUT] band 追加写入的包络顶点(上包络正向 + 下包络反向)。
* @param [OUT] bandBreaks 追加写入的各段起点(相对 band 的下标)。
* @param [OUT] spiky 该段是否为「梳状 / 密集满幅」(建议关闭抗锯齿)。
* @param [IN] laneHeight 网格的可见像素高度(用于「覆盖率」判据;≤0 时跳过)。
* @param [IN] halfWidth 线宽的一半(**设备像素**):上下包络各外扩这么多,
* 并作为横向膨胀的半径;≤0 表示不做线宽补偿。
*/
inline void EmitEnvelopeBand(const std::vector<BandColumn>& columns, std::vector<gd::PointF>& band,
std::vector<std::size_t>& bandBreaks, bool& spiky, float laneHeight,
float halfWidth = 0.0f)
{
if (columns.size() < 2)
{
return;
}
const std::size_t count = columns.size();
bandBreaks.push_back(band.size());
// 「梳状」判定:相邻列的上下包络几乎不相交(振荡周期与像素列宽相当时出现)。
// 这种形状下抗锯齿填充的扫描线代价暴涨,而抗锯齿对它的平滑作用几乎看不出来。
std::size_t inverted = 0;
const float slack = kEnvelopeMinPixelSpan;
for (std::size_t index = 1; index < count; ++index)
{
if (columns[index].top > columns[index - 1].bottom + slack ||
columns[index].bottom < columns[index - 1].top - slack)
{
++inverted;
}
}
if (inverted * 8 > count)
{
spiky = true;
}
// 「密集满幅」判定:包络带在**每一列内部**都横跨了网格的大部分高度
//(编码器方波在 1~2 采样/像素时,多数像素列里都含一次整幅跳变)。
// GDI+ **抗锯齿**的代价与这些近竖直、满幅的边成正比:实测同一 4832 顶点的几何
// **抗锯齿 694ms、关抗锯齿 29ms(23 倍)**,而这么密的色块上抗锯齿的平滑作用肉眼不可辨。
// ⚠️ 只影响「本段是否关抗锯齿」,几何(逐列 min/max)完全不变。
if (laneHeight > 0.0f)
{
std::size_t tallColumns = 0;
double covered = 0.0;
for (const BandColumn& column : columns)
{
const float span = column.bottom - column.top;
covered += static_cast<double>(span);
if (span > 0.5f * laneHeight)
{
++tallColumns;
}
}
// 「锯齿」判定(最能代表 GDI+ 抗锯齿代价):相邻列的上下包络**整幅跳变**。
std::size_t jumps = 0;
const float jumpThreshold = 0.25f * laneHeight;
for (std::size_t index = 1; index < count; ++index)
{
const float dTop = std::fabs(columns[index].top - columns[index - 1].top);
const float dBottom = std::fabs(columns[index].bottom - columns[index - 1].bottom);
if (dTop > jumpThreshold || dBottom > jumpThreshold)
{
++jumps;
}
}
if (tallColumns * 4 > count || jumps * 4 > count ||
covered > 0.45 * static_cast<double>(count) * static_cast<double>(laneHeight))
{
spiky = true;
}
}
// ⚠️ 线宽补偿(见函数头注释):
// 1. 纵向:上下包络各外扩 `halfWidth`(水平段因此有厚度);
// 2. 横向:对上下包络做窗口膨胀(`top` 取最小、`bottom` 取最大),
// 竖直沿因此从「1 个像素列」变成「2*halfWidth + 列宽」。
// 只在 `halfWidth` 有效时才做膨胀,避免给默认线宽引入额外的横向糊化。
std::vector<BandColumn> widened;
if (halfWidth > 0.0f && count > 1)
{
// 列间距由前两列推断(同一段内等宽等距);退化时按 1px 处理。
const float spacing = columns[1].x - columns[0].x;
const float step = (spacing > 0.01f) ? spacing : 1.0f;
// ⚠️ **必须向下取整**(用户 2026-10-21 报告的阶跃信号线宽不一致):
// `ceil` 会让 1px 线宽(`halfWidth` ≈ 0.5–0.7 设备像素)也膨胀 1 列,
// 阶跃的竖直沿因此从 1 列变成 3 列(≈3px),而水平段只有 ~1.25px ⇒
// 视觉上“竖直跳变的线宽明显大于水平持续段”(放大到走逐点描边路径后又正常了)。
// `floor` 下 1px 线宽不膨胀(竖直沿 = 1 列),粗线(≥ 2 列宽)仍按整数列膨胀。
const std::size_t radius =
static_cast<std::size_t>(std::floor(halfWidth / step + 1e-4f));
if (radius > 0 && radius * 2 + 1 < count)
{
widened.resize(count);
for (std::size_t index = 0; index < count; ++index)
{
const std::size_t first = (index > radius) ? (index - radius) : 0;
const std::size_t last = std::min(count - 1, index + radius);
float top = columns[index].top;
float bottom = columns[index].bottom;
for (std::size_t probe = first; probe <= last; ++probe)
{
top = std::min(top, columns[probe].top);
bottom = std::max(bottom, columns[probe].bottom);
}
widened[index].x = columns[index].x;
widened[index].top = top;
widened[index].bottom = bottom;
}
}
}
const std::vector<BandColumn>& shape = widened.empty() ? columns : widened;
const bool expand = (halfWidth > 0.0f);
for (std::size_t index = 0; index < count; ++index)
{
const float top = shape[index].top - (expand ? halfWidth : 0.0f);
band.push_back(gd::PointF(shape[index].x, top));
}
for (std::size_t index = count; index-- > 0;)
{
const float bottom = shape[index].bottom + (expand ? halfWidth : 0.0f);
band.push_back(gd::PointF(shape[index].x, bottom));
}
}
/**
* @brief 双光标差值的完整显示文本("Δt:" + 复合单位间隔)。
* @param [IN] seconds 时间间隔(秒)。
* @return 形如 "Δt:1h 1min 2s" 的文本。
*/
inline std::wstring FormatSpanText(double seconds)
{
return L"Δt:" + FormatDurationCompound(seconds);
}
/**
* @brief 时间光标的读数文本("数值 + s 后缀")。
*
* 时间轴本身就是秒,但与幅值读数放在一起时没有单位容易误读,
* 因此读数恒带 "s" 后缀;内联编辑仍使用不带单位的纯数值(见 BeginEdit 调用处)。
*
* @param [IN] seconds 光标所在时刻(秒)。
* @return 形如 "1.25s" 的文本。
*/
inline std::wstring FormatCursorTime(double seconds)
{
return FormatValue(seconds) + L"s";
}
/**
* @brief 按比例缩放颜色的 alpha 通道(用于提示浮层淡出)。
* @param [IN] argb 原始 0xAARRGGBB 颜色。
* @param [IN] alpha 新的 alpha(0-255)。
* @return 缩放后的颜色。
*/
inline std::uint32_t ScaleAlpha(std::uint32_t argb, unsigned int alpha)
{
const unsigned int a = std::min(255u, alpha);
return (argb & 0x00FFFFFFu) | (a << 24);
}
/**
* @brief 将数值限制到合法区间。
* @param [IN] value 输入值。
* @param [IN] low 下限。
* @param [IN] high 上限。
* @return 限制后的数值。
*/
inline double ClampDouble(double value, double low, double high)
{
if (value < low)
{
return low;
}
if (value > high)
{
return high;
}
return value;
}
/**
* @brief 组装图例文本:使用信号当前显示名(用户重命名后立即生效)。
* @param [IN] data 信号数据。
* @return 图例文本。
*/
inline std::wstring BuildLegendText(const SignalData& data)
{
return data.name;
}
/**
* @brief 将信号样式颜色按不透明度换算为实际绘制颜色。
* @param [IN] style 信号样式。
* @return 叠加不透明度后的 0xAARRGGBB 颜色值。
*/
inline std::uint32_t StyleColor(const SignalStyle& style)
{
double opacity = static_cast<double>(style.opacity);
if (opacity < 0.0)
{
opacity = 0.0;
}
if (opacity > 1.0)
{
opacity = 1.0;
}
return WithAlpha(style.color, static_cast<unsigned int>(opacity * 255.0 + 0.5));
}
/**
* @brief 派生预览曲线相对底色的**最低对比度**(WCAG 比值)。
*
* 用比值而不是"亮度差":同样的亮度差在很亮的底色上(白)几乎看不出来,
* 在深色底上却很显眼 —— 比值才是一致可见性的正确度量。
*/
inline const double kDerivedPreviewContrast = 3.2;
/**
* @brief 派生预览曲线与**源信号色**之间还要拉开的亮度差。
*
* 源信号本身已经与底色有足够对比时,预览曲线再往外走这么多,避免"两条线同色叠在一起"。
*/
inline const double kDerivedPreviewGap = 0.18;
/**
* @brief 由信号色派生「派生预览曲线」的颜色(与底色、与源信号同时保持足够对比)。
*
* 规则(**连续**,不含任何"临界点切换"):
* 1. 方向:沿"信号色相对底色的那一侧"继续远离底色
*(深底上的信号往更亮走、浅底上的信号往更暗走);
* 2. 先求"与底色对比度达到 `kDerivedPreviewContrast` 的亮度"(`EnsureContrast`),
* 再与"信号亮度 + `kDerivedPreviewGap`"取**更远离**底色的那个;
* 3. 用 `MixToLuminance` 把信号色沿亮度轴平滑推到该目标(保持色相)。
*
* 步 2 的两个候选都是输入的**连续函数**(取 max/min),因此拖拾色器 / 换预设色块 /
* 切主题时派生色**连续变化**,不会像旧实现那样"混 72% 黑还是白"按评分在阈值处跳变。
*
* @param [IN] color 源信号色(0xAARRGGBB,alpha 被忽略)。
* @param [IN] background 网格**底色**(填充 `ThemeLaneBackground`)。
* @return 派生颜色(不透明;透明度由调用方按信号不透明度重新叠加)。
*/
inline std::uint32_t HighContrastDerived(std::uint32_t color, std::uint32_t background)
{
const std::uint32_t base = color | 0xFF000000u;
const std::uint32_t back = background | 0xFF000000u;
const double backgroundLuminance = SrgbLuminance(back);
const double signalLuminance = SrgbLuminance(base);
// 方向:沿信号所在的"那一侧"继续远离底色。信号与底色几乎同亮度时
// 按底色的明暗选一侧(此时源曲线本身几乎看不见,方向没有更好选择)。
double direction = 0.0;
if (signalLuminance > backgroundLuminance + 1.0e-4)
{
direction = 1.0;
}
else if (signalLuminance < backgroundLuminance - 1.0e-4)
{
direction = -1.0;
}
else
{
direction = (backgroundLuminance < 0.5) ? 1.0 : -1.0;
}
// 候选一:与底色对比度达到阈值的那个颜色(已达标时就是原色)。
const std::uint32_t required = EnsureContrast(back, base, kDerivedPreviewContrast);
const double requiredLuminance = SrgbLuminance(required);
// 候选二:在信号亮度的基础上再拉开一段。
const double gapLuminance = signalLuminance + direction * kDerivedPreviewGap;
double target = (direction > 0.0) ? std::max(requiredLuminance, gapLuminance)
: std::min(requiredLuminance, gapLuminance);
if (target > 1.0)
{
target = 1.0;
}
if (target < 0.0)
{
target = 0.0;
}
return MixToLuminance(base, target);
}
/** @brief FNV-1a 混合(面板像素缓存键共用)。 */
inline void MixPanelKey(std::uint64_t& hash, std::uint64_t value)
{
hash ^= value;
hash *= 1099511628211ull;
}
} // namespace pvdetail
using namespace pvdetail;
} // namespace jdi
#endif // __PLOT_VIEW_INTERNAL_HPP__