Files
Just-DataInspector/Components/PlotView/Src/PlotViewInternal.hpp
T
DeepSeek V4.1 Flash f845616938 回退 5b52eef 并修复「屏幕不刷新」根因:拖动滚动条卡顿 / 悬停高亮卡住 / 信号拖拽无效果
根因(代码级确认)
- Render() 把界面画进后备缓冲区后用 GetDC + BitBlt 回送窗口,5b52eef 把回送写成
  `if (fullFrame) { BitBlt(整窗) } else if (panelOnly) { BitBlt(本帧重绘区域并集) }`,没有兜底 else。
- 「鼠标只在绘图区内移动」的帧(WM_MOUSEMOVE 的 plotOnlyMove 路径只 InvalidatePlotRect)恰好
  fullFrame == false && panelOnly == false ⇒ 后备缓冲区被正确重绘,窗口 DC 一个像素都不回送。
- 这一个根因同时造成用户报告的三个症状:悬停光晕卡住(移开不消失)、拖动滚动条不跟手/非常卡顿、
  拖拽信号换网格看不到任何结果。

修法
- git reset --hard HEAD~1 撤销 5b52eef(保留 backup-bad-commit 标签供 A/B)。
- 只挑回该提交里与更新区域无关的功能改动(用户明确要求过、本身正确):
  * SignalPanel::ExpandGroupForSignal():联动滚动前立刻展开折起的分组(不播展开动画);
  * FitAll() / FitSinglePointCluster() 不再复位 Tab().LaneScroll()(适应视图不影响滚动条);
  * 右下角「回到最上方」浮动按钮 + 网格滚动到可视区的平滑动画。
- Application.cpp 只保留 3 处最小改动:Render 里把 panelAnimating 计入整帧脏、WM_TIMER 推进
  AdvanceBackToTopAnimation() 且保持整窗失效、定时器存活判定带 BackToTopAnimationActive()。
  Render 的绘制与 BitBlt 路径逐字节等同于 82b5ebe。
- 彻底删除 m_paintPlot / m_panelDirty / panelOnly / InvalidatePanelRect / 并集回送。

验证(全部离屏执行,不移动真实光标、不抢焦点、不截屏)
- .preflight/diag(新增离屏探针,19/19):窗口移到屏幕外 + WS_EX_TOOLWINDOW + SW_SHOWNOACTIVATE,
  保证有真实更新区域与 WM_PAINT 但不影响用户。断言滚动条拖动(滑块与绘图区都在后备缓冲区被重绘)、
  悬停光晕出现与消退(像素完全回到基线)、在曲线上按下进入 MOVE_SIGNAL 且拖到另一网格后归属正确。
- .preflight/check 137/137。
- .preflight/diag/verify_blit.ps1(新增源码级护栏,10/10):断言 Render() 的回送块必须有无条件 else、
  不得出现 else if、m_paintPlot/m_panelDirty/panelOnly 必须不存在、失效只允许整窗或仅绘图区。
  A/B:换回 backup-bad-commit ⇒ 4 项如实失败;换回修复版 ⇒ 10/10。
- make release 零错误零警告。

文档:Documents/agent_mem.md 新增 §9.41、§11.2 第 108–116 条(含 §9.40 与旧 108–114 条的作废标注)
与变更日志一行。

教训(写进记忆):① 离屏探针无法验证「窗口回送 / 合成」(屏幕外 DWM 不合成、窗口 DC 恒为空白)⇒
回送类约束必须有源码级护栏;② 离屏窗口 + TrackMouseEvent 会立刻收到 WM_MOUSELEAVE,探针须丢弃该消息;
③ 网格回收会让下标左移,断言要用锚信号。
2026-10-16 17:33:08 +08:00

956 lines
46 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 「点选信号 ⇒ 把目标网格滚入可视区」的平滑过渡参数(与信号列表同源)。
*
* 时长 = `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; /**< 与绘图区右下角的边距。 */
inline const float kBackToTopFadeMs = 170.0f; /**< 淡入/淡出时长(毫秒)。 */
/** 向下滚动超过该距离(逻辑像素)才显示按钮(避免轻微滚动就弹出)。 */
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; /**< 单条散点最大绘制点数。 */
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 光标吸附的**判定系数**:一屏内的数据点少于 `网格小格数 × 该系数` 时才开启吸附。
*
* 用户 2026-10-15 明确定义:网格底衬一横排有 N 个小格(见 `GridMinorCellCount`),
* **当且仅当**某信号在当前视界内的数据点个数 `< N × 4` 时为它开启光标吸附。
* 该判据随视界实时重算 ⇒ 疏密变化的信号在稀疏区自动吸附、稠密区自动自由移动。
*/
inline const int kSnapVisibleCellsPerPoint = 4;
/**
* @brief 吸附半径(以**网格小格**为单位):一个数据点只用其**一个小格宽**内的移动来吸。
*
* 吸附半径 = 一个网格小格的宽度(与判定用同一套网格定义),且**不超过半个采样间距**
* ——后者保证点很稀疏时(例如一屏只有一两个点)光标在点与点之间仍可自由移动,
* 而不是被“死死吸住”(用户明确要求:吸附只是提示性响应)。
*/
inline const double kSnapRadiusCells = 1.0;
/**
* @brief 一般信号查找“离光标最近的有效采样点”时向两侧探测的最大步数。
*
* 采样值里的 NaN 只出现在缺少数据的区段(例如多时间轴 CSV 的较短轴的尾部),
* 因此从最近下标向外少量探测即可找到有效点;探测失败就不吸附(宁可自由移动)。
*/
inline const int kSnapSampleProbeSteps = 64;
/**
* @brief 判定“可见数据点是否足够少”时允许扫描的最大样本数(相对阈值的倍数)。
*
* 判定只需知道“有效点是否少于阈值”,因此扫到阈值个有效点就早退;
* 但连续 NaN 不会被计入,因此再给一个**有界**的扫描窗口防止极端情况下的长循环。
*/
inline const std::size_t kSnapScanWindowFactor = 64;
/**
* @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 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"构建为新信号"; /**< 逐条目面板底部按钮文案(均值滤波 / 弱峰调整共用)。 */
/**
* @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 实验性功能浮动面板的遍历顺序(命中测试与绘制共用,保证层序一致)。
*
* 顺序即绘制层序:先绘制的在下方,最后绘制的在最上方。
*/
inline const EdgePanelKind kEdgePanelKinds[] = {EdgePanelKind::COUNT, EdgePanelKind::CENTER,
EdgePanelKind::MEAN, EdgePanelKind::PEAK};
/** 实验性功能浮动面板的种类数(遍历与命中测试统一用它,避免新增功能时漏改)。 */
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`)。
*
* @param [IN] columns 逐列极值(同一段内按列顺序排列)。
* @param [OUT] band 追加写入的包络顶点(上包络正向 + 下包络反向)。
* @param [OUT] bandBreaks 追加写入的各段起点(相对 band 的下标)。
* @param [OUT] spiky 该段是否为「梳状 / 密集满幅」(建议关闭抗锯齿)。
* @param [IN] laneHeight 网格的可见像素高度(用于「覆盖率」判据;≤0 时跳过)。
*/
inline void EmitEnvelopeBand(const std::vector<BandColumn>& columns, std::vector<gd::PointF>& band,
std::vector<std::size_t>& bandBreaks, bool& spiky, float laneHeight)
{
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;
}
}
for (std::size_t index = 0; index < count; ++index)
{
band.push_back(gd::PointF(columns[index].x, columns[index].top));
}
for (std::size_t index = count; index-- > 0;)
{
band.push_back(gd::PointF(columns[index].x, columns[index].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__