Files
Just-DataInspector/Components/PlotView/Src/PlotViewInternal.hpp
T
DeepSeek-V4.1-Flash 3400ac7c29 自由标记体验重做 + 全部实验性面板可折起
- 网格内固定点:取值胶囊按已放置矩形做纵向避让(不再互相遮挡);行尾 ✕ 改为与
  胶囊同高的正方形;移除固定点的纵向半透明竖线
- 新增「标记透明度」(0–100%),作用于本网格全部固定点的圆点/胶囊/描边/文字/✕;
  半透明时文字改走 GDI+(GDI DrawTextW 忽略 alpha),100% 时像素零变化;
  ≤2% 时不绘制且不登记 ✕ 命中矩形(看不见的按钮不可点)
- 面板按信号分组:信号色托底分组栏(展开三角 + 信号名 + N 标记点 + ✕,文字/三角/✕
  一律用自适应对比色),点击分组栏收起/展开;条目五列(值/枚举/增量/时刻/间隔),
  首项增列与 Δt 显示 N/A,整个面板没有枚举文本时枚举列整列缺省;行尾仍有 ✕
- 面板内容超高时右侧出现滚动条(分组栏与条目一起滚动),滚轮在内容区翻页;
  底部按钮与「标记透明度」滑块固定在底部、不参与滚动
- 底部按钮改为「光标选取模式」切换类按钮(外观与工具栏切换按钮一致,激活高亮),
  激活时其上方出现「标记透明度」滑动条(与信号样式区的透明度滑块同款)
- 所有实验性功能浮动面板支持展开/折起:标题栏 GX 徽标左侧新增「三角 + 横线」指示器,
  单击切换、双击标题栏切换;折起的面板只有标题栏
- 数据层:LanePanelState.collapsed、Lane.freeMarkerOpacity/freeMarkerScroll/
  freeMarkerCollapsedGroups,LaneCarry 与 SplitLane 一并继承
- 验证:新增离屏 .preflight/perf/mark2check 69/69(含两组 A/B);回归 datacheck 69/69、
  verify 49/49、panelanim 158/158、errcheck 19/19;make release/debug 零警告
2026-10-21 02:59:05 +08:00

1247 lines
60 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 "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)
{
const float inset = 2.5f * scale;
gd::RectF box(rect.X + inset, rect.Y + inset, rect.Width - inset * 2.0f,
rect.Height - inset * 2.0f);
if (box.Width <= 1.0f || box.Height <= 1.0f)
{
return;
}
gd::Pen pen(gd::Color((color >> 24) & 0xFFu, (color >> 16) & 0xFFu, (color >> 8) & 0xFFu,
color & 0xFFu),
std::max(1.0f, 1.5f * scale));
pen.SetStartCap(gd::LineCapRound);
pen.SetEndCap(gd::LineCapRound);
// 缺口留在右上角(从 300° 到 20° 处断开),与常见的"重置/刷新"图标一致。
g.DrawArc(&pen, box.X, box.Y, box.Width, box.Height, 300.0f, 300.0f);
// 箭头:位于缺口末端(约 300° 方向),指向前进方向。
const float cx = box.X + box.Width * 0.5f;
const float cy = box.Y + box.Height * 0.5f;
const float radius = box.Width * 0.5f;
const float angle = 300.0f * 3.14159265f / 180.0f;
const float tipX = cx + std::cos(angle) * radius;
const float tipY = cy + std::sin(angle) * radius;
const float size = std::max(2.5f, 3.2f * scale);
gd::PointF arrow[3];
arrow[0] = gd::PointF(tipX, tipY);
arrow[1] = gd::PointF(tipX - size, tipY - size * 0.25f);
arrow[2] = gd::PointF(tipX - size * 0.25f, tipY - size);
gd::SolidBrush brush(gd::Color((color >> 24) & 0xFFu, (color >> 16) & 0xFFu,
(color >> 8) & 0xFFu, color & 0xFFu));
g.FillPolygon(&brush, arrow, 3);
}
/**
* @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;
/** 「自由标记」面板与行尾 ✕ 按钮的边长(网格内取值胶囊后的 ✕ 另按胶囊高度取正方形)。 */
inline const float kFreeMarkerCloseSize = 13.0f;
/**
* @brief 「自由标记」面板的宽度(逻辑像素)。
*
* 取固定值而**不做逐行文本测量**:`EdgePanelWidth` 会被 `EdgePanelLayoutFor` 调用,
* 而后者在 `TopPanelAtPoint` / `SyncPanelOrder` 里被高频调用(每次鼠标移动数次);
* 逐行取采样值算宽度会变成"鼠标一动就做几十次采样查询"。
*
* 宽度 = 左右内边距 + 五列(取值/枚举/增量/时刻/间隔)+ 行 ✕ ⇒ 保证五列在纵向上对齐。
*/
inline const float kFreeMarkerPanelWidth = 424.0f;
/**
* @brief 「自由标记」面板五列的逻辑宽度
* (**按此顺序**:取值 / 枚举文本 / 增量 / 时刻 / 间隔)。
*
* 固定列宽(不按文本测量,见上),绘制时用 `UiEllipsize` 截断超长文本。
* 当整个面板里没有任何枚举文本时,枚举这一列会被**整列缺省**(列宽记 0,
* 后面的列左移),其它列的宽度不变。
*/
inline const float kFreeMarkerColumnWidths[5] = {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 所有实验性功能浮动面板标题栏左侧「展开/折起」指示器的宽度。
*
* 指示器 = 三角(朝右 = 已折起、朝下 = 已展开)+ 三角后方的一条横线;
* 点击它切换面板的折起状态。`GX` 编号徽标向右让出这块宽度。
*/
inline const float kEdgePanelDisclosureWidth = 20.0f;
/**
* @brief 非光标模式下判定"鼠标在信号曲线上"的纵向容差(逻辑像素)。
*
* 默认(不启用光标模式)时只有鼠标贴近曲线才显示取值预览;这个容差决定"贴近"的量级。
* 取得太小会很难命中细线,太大则会在空白处误判成"有信号"从而吃掉平移操作。
*/
inline const float kFreeMarkerHoverTolerance = 8.0f;
/** 固定点取值胶囊与 ✕ 之间的间距。 */
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 「自由标记」面板/胶囊里的取值部分:`值(+增量) = 枚举文本`。
*
* @param value 当前取值(已叠加「调整」变换)。
* @param hasPrev 是否存在前一个同信号固定点(存在才显示增量)。
* @param delta 相对前一个固定点的取值增量。
* @param enumText 枚举文本(可为空指针)。
*/
inline std::wstring FreeMarkerValueText(double value, bool hasPrev, double delta,
const std::wstring* enumText)
{
std::wstring text = FormatValue(value);
if (hasPrev)
{
text += L"(";
text += FormatSignedValue(delta);
text += L")";
}
if (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"构建为新信号"; /**< 逐条目面板底部按钮文案(均值滤波 / 弱峰调整共用)。 */
/** 「自由标记」面板底部切换按钮文案(切换按钮的激活态 = 光标模式开启)。 */
inline const wchar_t* const kFreeMarkerCursorModeLabel = 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;
std::size_t radius = static_cast<std::size_t>(std::ceil(halfWidth / step));
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 由信号色派生一个「高对比度」颜色:保持色相、大幅拉开明度。
*
* 用途:「边沿计数(中心轴)」的平均值曲线叠加在原始波形之上,
* 若直接用信号色会被波形淹没。这里把信号色向黑或白方向大幅偏移,
* 并**同时考虑与原始波形色以及与绘图区底色的距离**,取对比更强的一侧,
* 保证曲线既能与波形区分、又能压在底图上看得清。
*
* 结果是从信号色派生的,因此原信号改颜色时曲线颜色会自动跟着变。
*
* @param [IN] color 原始信号色(0xAARRGGBB)。
* @param [IN] background 绘图区底色,用于评估可见度。
* @return 派生出的高对比度颜色(alpha 与输入一致,由调用方再覆盖)。
*/
inline std::uint32_t HighContrastDerived(std::uint32_t color, std::uint32_t background = 0xFFFDF6E3u)
{
const auto squaredDistance = [](std::uint32_t a, std::uint32_t b)
{
const double dr =
static_cast<double>((a >> 16) & 0xFFu) - static_cast<double>((b >> 16) & 0xFFu);
const double dg =
static_cast<double>((a >> 8) & 0xFFu) - static_cast<double>((b >> 8) & 0xFFu);
const double db = static_cast<double>(a & 0xFFu) - static_cast<double>(b & 0xFFu);
return dr * dr + dg * dg + db * db;
};
const std::uint32_t darker = MixColor(color, 0xFF000000u, 0.72);
const std::uint32_t lighter = MixColor(color, 0xFFFFFFFFu, 0.72);
// 评分:与原始波形色的区分度为主(权重 1.0),与底色的可见度为辅(权重 0.6)。
const double scoreDarker =
squaredDistance(darker, color) + 0.6 * squaredDistance(darker, background);
const double scoreLighter =
squaredDistance(lighter, color) + 0.6 * squaredDistance(lighter, background);
return (scoreDarker >= scoreLighter) ? darker : lighter;
}
/** @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__