#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 #include #include #include #include #include #include #include #include #include #include #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& 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 内的多帧**完全不推进**(用户报告:“按住横向光标不动时统计卡在统计中”, * “回车确认参数后不移动鼠标就一直停在部分值”)。 * * ⚠️⚠️ **这是「非交互帧」的预算**:用户 2026-11-02 报告——288 个信号合并到同一网格、 * 开启中心轴边沿计数后,拖动横向光标每帧要 30ms(实测),界面完全跟不上手。 * 原因就是本预算太大:中心轴每个采样对还要按“均值点数 × 迭代次数”算一次均值窗口, * 1200000 对 × 11 次浮点运算 ≈ 3 位千万级运算/帧。 * 现在交互期间改用 `kEdgeScanPairsPerFrameInteractive`(见 `Paint` 开头)。 */ inline const std::size_t kEdgeScanPairsPerFrame = 400000; /** * @brief **交互帧**(正在拖动 / 鼠标刚移动过)允许的边沿统计预算。 * * 交互时首要目标是跟手:把预算压到非交互帧的 1/5(实测单帧统计开销 ≈6ms 以内), * 鼠标停下 `kEdgeScanInteractiveIdleMs` 之后自动恢复高预算、快速把统计补齐。 * ⚠️ 不能降到 0 —— 那样“按住横向光标不动”时统计会停在“统计中…”。 */ inline const std::size_t kEdgeScanPairsPerFrameInteractive = 80000; /** * @brief 鼠标停止移动多久之后恢复高统计预算(毫秒)。 */ inline const std::uint64_t kEdgeScanInteractiveIdleMs = 140; /** * @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& time, const std::vector& 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 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::llround(static_cast(style.gain) * 1.0e6))); mix(static_cast( std::llround(static_cast(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& times = axis.time; const std::size_t timeCount = std::min(times.size(), count); begin = static_cast( std::lower_bound(times.begin(), times.begin() + static_cast(timeCount), rangeMin) - times.begin()); // ⚠️ **不要**在左端多取一个采样点:那会让“跨越区间边界的边沿”也算进来, // 而用户明确要求“在光标选区外的点不要算入计数规则”。 // (例:光标恰好落在越过阈值的第一个采样点 B 上时,A→B 这一段处于选区之外, // 不计入;旧实现在左端多取一个点,于是 B 被错认成上升沿。) end = static_cast( std::upper_bound(times.begin(), times.begin() + static_cast(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(kHintFadeMs); /** * @brief 实验性功能浮动面板的遍历顺序(命中测试与绘制共用,保证层序一致)。 * * 顺序即绘制层序:先绘制的在下方,最后绘制的在最上方。 */ inline const EdgePanelKind kEdgePanelKinds[] = {EdgePanelKind::COUNT, EdgePanelKind::CENTER, EdgePanelKind::MEAN, EdgePanelKind::PEAK, EdgePanelKind::MARK}; /** 实验性功能浮动面板的种类数(遍历与命中测试统一用它,避免新增功能时漏改)。 */ inline const int kEdgePanelKindCount = static_cast(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 ComputeDerivedWindow(const SignalData& signal, std::size_t windowBegin, std::size_t windowEnd, int kind, int points, int iterations, int amountPercent, const std::atomic* cancel, bool throttle) { std::vector out; const std::vector& 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 window = wholeSignal ? std::vector() : std::vector( values.begin() + static_cast(lo), values.begin() + static_cast(hi)); const std::vector& input = wholeSignal ? values : window; if (kind == static_cast(DerivedCurveKind::WEAK_PEAK)) { ComputeWeakPeakAdjust(input, static_cast(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& columns, std::vector& band, std::vector& 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(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(count) * static_cast(laneHeight)) { spiky = true; } } // ⚠️ 线宽补偿(见函数头注释): // 1. 纵向:上下包络各外扩 `halfWidth`(水平段因此有厚度); // 2. 横向:对上下包络做窗口膨胀(`top` 取最小、`bottom` 取最大), // 竖直沿因此从「1 个像素列」变成「2*halfWidth + 列宽」。 // 只在 `halfWidth` 有效时才做膨胀,避免给默认线宽引入额外的横向糊化。 std::vector 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::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& 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(style.opacity); if (opacity < 0.0) { opacity = 0.0; } if (opacity > 1.0) { opacity = 1.0; } return WithAlpha(style.color, static_cast(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__