From bdd703b88b80426d97d0bcfbacb96a65e1cb96f1 Mon Sep 17 00:00:00 2001 From: "DeepSeek-V4.1-Flash" <> Date: Fri, 30 Oct 2026 03:10:51 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=BB=E9=A2=98=E8=87=AA=E9=80=82=E5=BA=94?= =?UTF-8?q?=E9=85=8D=E8=89=B2=20+=20=E9=A2=84=E8=A7=88=E6=9B=B2=E7=BA=BF?= =?UTF-8?q?=E8=BF=9E=E7=BB=AD=E9=85=8D=E8=89=B2=20+=20=E6=A0=B7=E5=BC=8F?= =?UTF-8?q?=E5=8C=BA=E5=A4=8D=E4=BD=8D=E6=8C=89=E9=92=AE=20+=20=E5=9D=87?= =?UTF-8?q?=E5=80=BC=E6=BB=A4=E6=B3=A2=E9=9D=A2=E6=9D=BF=E9=87=8D=E5=81=9A?= =?UTF-8?q?=20+=20=E9=9D=A2=E6=9D=BF=E7=BC=93=E5=AD=98=E7=8E=AF=E5=B8=A6?= =?UTF-8?q?=E6=AE=8B=E7=95=99=20+=20=E8=B0=83=E8=AF=95=E5=B1=82=E5=BA=8F?= =?UTF-8?q?=20+=20=E8=87=AA=E7=94=B1=E6=A0=87=E8=AE=B0=E9=80=89=E5=8F=96?= =?UTF-8?q?=E4=B8=8E=E6=80=A7=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ① 主题自适应(`Theme.cpp`):新增 `SrgbLuminance` / `ContrastRatio` / `EnsureContrast` / `MixToLuminance` / `ThemeLaneBackground`;四套主题的结构色(gridWeak 1.30 / gridStrong 1.65 / axisLine 2.10 / borderStrong 1.70)按底色自动校正,Solarised Light 逐位不变;网格叠底改用 `ThemeLaneBackground()`(4 处),深色主题下网格层与绘图区底色可辨。 ② 派生预览曲线(`HighContrastDerived`)重写为**连续**方案:方向沿信号所在侧远离底色, 目标亮度 = max(与底色对比度 3.2, 信号亮度 ± 0.18),再按亮度平滑混色;调用点传入真实网格底色 (旧实现默认写死 Solarised Light 底色 ⇒ 深色主题永远选到更暗的那一侧、几乎看不见)。 ③ 信号样式区「线宽 / 透明度」两行右侧新增**复位按钮**(`UiPaintResetIcon` 成为唯一实现, 菜单/弱峰面板/样式区共用同一套笔画);默认线宽统一到 `DefaultSignalLineWidth(finiteCount)` (单点信号取 10.0)。按钮右边界与内容列对齐,内容列过窄时整列不显示。 ④ 「均值滤波」面板重做:信号色叠底标题栏 + 复选框 + 展开三角 + 「均值点数 / 迭代次数」两行滑块 (点击数值可改滑块上限、右侧复位)+ 内容区滚动;新增 `MeanFilterItem::{maxPoints,maxIterations, collapsed}` 与 `Lane::meanFilterScroll`,缓存键同步补全。 ⑤ 浮动面板命中像素缓存时只贴**面板圆角形状之内**的像素(`CreateRoundRectRgn` 裁剪)并按本帧重画投影 ⇒ 中键移视界后面板周围不再出现一圈旧波形。 ⑥ 显示布局边界:每个浮动面板**独占一个绘制层**(按 `m_panelOrder` 递增),绘制侧 `UiSetTextLayer` 在同一循环内同步推进 ⇒ 下层面板的边框/文字不再画到上层面板上。 ⑦ 自由标记:采样候选距离改为**二维欧氏距离**(单点信号不再在整幅宽度内吸住光标), 纵向光标画在鼠标位置、圆点落在真实采样点(新增 `FreeMarkerProbe::pointX`); `MinorCellSparse` 只对胜出信号算一次;折线缓存容量改为 `max(64, 信号数×2)` 且查找改为 单键哈希 ⇒ MF4 的 285 信号合并网格中 `ProbeFreeMarker` 平均 0.11ms。 验证:`.preflight` 探针重建(`panelcheck` 71/71、`markercheck` 13/13、`ringcheck` 6/6,合计 90 条, LTO 目标文件上复跑同样全绿);`make release` / `make debug` 零警告;产物 815,104 B。 文档:`agent_mem.md` 第十六轮(含补丁脚本与编码/行尾踩坑)。 --- Components/PlotView/Inc/PlotView.hpp | 80 ++- Components/PlotView/Inc/PlotViewInternal.hpp | 152 +++-- Components/PlotView/Src/PlotView.cpp | 13 + Components/PlotView/Src/PlotViewCacheKey.cpp | 2 +- Components/PlotView/Src/PlotViewFeatures.cpp | 17 + .../PlotView/Src/PlotViewInteraction.cpp | 356 +++++++++++- Components/PlotView/Src/PlotViewPaint.cpp | 521 ++++++++++++++---- Components/PlotView/Src/PlotViewPanels.cpp | 231 +++++++- Components/PlotView/Src/PlotViewRaster.cpp | 4 +- Components/Shared/Inc/PaintUtil.hpp | 14 + Components/Shared/Inc/PopupMenuInternal.hpp | 33 +- Components/Shared/Inc/Theme.hpp | 61 ++ Components/Shared/Src/PaintUtil.cpp | 36 ++ Components/Shared/Src/Theme.cpp | 157 +++++- Components/SignalPanel/Inc/SignalPanel.hpp | 38 ++ .../SignalPanel/Inc/SignalPanelInternal.hpp | 11 +- Components/SignalPanel/Src/SignalPanel.cpp | 30 +- .../SignalPanel/Src/SignalPanelEdit.cpp | 51 +- .../Src/SignalPanelInteraction.cpp | 31 ++ .../SignalPanel/Src/SignalPanelPaint.cpp | 31 +- Documents/agent_mem.md | 153 +++++ Utilities/Inc/DataModel.hpp | 42 ++ Utilities/Inc/DataModelInternal.hpp | 4 + Utilities/Src/DataModel.cpp | 3 +- 24 files changed, 1815 insertions(+), 256 deletions(-) diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index aa6d32c..fff7177 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -62,7 +62,10 @@ namespace jdi PEAK_ITERATIONS, /**< 拖动「弱峰调整」某信号的迭代次数滑块(1 … 该信号自定义上限)。 */ LANE_REORDER, /**< 拖动网格图例中的 GX 编号徽标:上下调整网格次序。 */ MARKER_OPACITY, /**< 拖动「自由标记」面板的「标记透明度」滑块。 */ - MARKER_SCROLL /**< 拖动「自由标记」面板内容的滚动条滑块。 */ + MARKER_SCROLL, /**< 拖动「自由标记」面板内容的滚动条滑块。 */ + MEAN_POINTS, /**< 拖动「均值滤波」某信号的「均值点数」滑块(0 … 该信号自定义上限)。 */ + MEAN_ITERATIONS,/**< 拖动「均值滤波」某信号的「迭代次数」滑块(1 … 该信号自定义上限)。 */ + MEAN_SCROLL /**< 拖动「均值滤波」面板内容的滚动条滑块。 */ }; /** @@ -91,6 +94,15 @@ namespace jdi MEAN_FILTER_POINTS, /**< 编辑「均值滤波」的均值点数 X。 */ MEAN_FILTER_ITERATIONS, /**< 编辑「均值滤波」的迭代次数 N。 */ PEAK_ITERATIONS_MAX, /**< 编辑「弱峰调整」某信号「迭代次数 N」滑块的**上限**(正整数)。 */ + /** + * @brief 编辑「均值滤波」某信号「均值点数」滑块的**上限**(正整数)。 + * + * 与 `PEAK_ITERATIONS_MAX` 同一交互语义:点击滑块右侧数值 → 整行变成输入框, + * 输入框以空内容开始、并显示“输入上限(当前N)”提示。 + */ + MEAN_POINTS_MAX, + /** @brief 编辑「均值滤波」某信号「迭代次数」滑块的**上限**(正整数)。 */ + MEAN_ITERATIONS_MAX, FREE_MARKER_TIME, /**< 编辑「自由标记」某固定点的**自身时间**(秒)。 */ FREE_MARKER_DELTA /**< 编辑「自由标记」某固定点相对前一固定点的**时间间隔**(秒)。 */ }; @@ -1079,6 +1091,12 @@ namespace jdi */ bool SetLaneFreeMarkerScroll(int laneIndex, float scroll); + /** + * @brief 设置某网格「均值滤波」面板内容的滚动偏移(客户区像素,0 = 顶部)。 + * @return 偏移真的发生变化返回 true。 + */ + bool SetLaneMeanFilterScroll(int laneIndex, float scroll); + /** * @brief 切换某网格「自由标记」面板里某个信号分组的收起状态。 * @return 切换后为「已收起」返回 true。 @@ -1799,6 +1817,15 @@ namespace jdi */ std::vector rowLevelResets; std::vector rowIterResets; + /** + * @brief 「均值滤波」面板每个信号的**信号色叠底标题栏**矩形(与 `rowRefs` 同序)。 + * + * 排版与「自由标记」面板的信号分组栏一致(左侧展开三角 + 信号名), + * 但右端是**复选框**(`rowChecks`)而不是 ✕。未勾选时标题栏仍显示(用于勾选)。 + */ + std::vector rowHeaders; + /** @brief 「均值滤波」面板每个信号标题栏左侧的**展开 / 收起三角**(仅勾选后出现)。 */ + std::vector rowTriangles; std::vector rowRefs; /** * @brief 「自由标记」面板的一行(一个固定点)。 @@ -1843,6 +1870,13 @@ namespace jdi gd::RectF tableHeader; gd::RectF triangle; /**< 展开/收起三角的命中矩形。 */ gd::RectF close; /**< 右侧✕的命中矩形。 */ + /** + * @brief 右侧**复选框**的命中矩形(仅「均值滤波」面板使用;「自由标记」为空)。 + * + * 均值滤波面板的组头右端是"是否预览该信号"的复选框(用户 2026-10-30 要求), + * 只有它被勾选时左侧的展开三角才出现、才能展开参数行。 + */ + gd::RectF check; bool collapsed = false; /** * @brief 该分组栏是否处于**吸顶**(滚动时被钉在内容区顶部)。 @@ -1862,6 +1896,14 @@ namespace jdi gd::RectF content; /** 「自由标记」面板的**内容总高度**(含被滚动出去的条目),用于换算滚动比例。 */ float markerContentHeight = 0.0f; + /** + * @brief 内容区当前生效的**滚动偏移**(像素,≥ 0)。 + * + * 「自由标记」与「均值滤波」两个面板的内容区都可能需要滚动;取值来源不同 + * (`Lane::freeMarkerScroll` / `Lane::meanFilterScroll`),但布局之后一律从这里读, + * 绘制与命中共用同一份偏移(避免两处各自夹取导致"画的和点的不一致")。 + */ + float scrollOffset = 0.0f; gd::RectF scrollTrack; /**< 内容区右侧的滚动条轨道(不需要滚动时为空矩形)。 */ gd::RectF scrollThumb; /**< 滚动条滑块。 */ gd::RectF opacityTrack; /**< 「标记透明度」滑动条轨道(总是显示,与选取模式无关)。 */ @@ -1945,7 +1987,15 @@ namespace jdi bool cursorMode = false; /**< 该网格是否处于「自由标记点选取」(决定是否画纵向光标)。 */ int laneIndex = -1; /**< 该网格下标。 */ double time = 0.0; /**< 标注时刻(显示时刻;吸附时 = 采样时刻)。 */ - float x = 0.0f; /**< 该时刻的屏幕横坐标。 */ + float x = 0.0f; /**< 纵向光标的横坐标(= 鼠标位置,跟随鼠标)。 */ + /** + * @brief 被标注的那个**数据点**的屏幕横坐标。 + * + * 与 `x`(鼠标位置)分开:纵向光标画在 `x`,而取值圆点必须落在**真实采样点**上 + *(`TimeToX(time)`)—— 稀疏信号下两者可能相差十几个像素。 + * 用户 2026-10-30 明确要求"光标跟着鼠标走,提示靠纵向最近的数据点"。 + */ + float pointX = 0.0f; SignalRef ref; /**< 就近信号(网格内没有可用信号时无效)。 */ double value = 0.0; /**< 已叠加「调整」变换的取值(用于标注与增量)。 */ double enumValue = 0.0; /**< 采样原值(用于查枚举表)。 */ @@ -2750,6 +2800,14 @@ namespace jdi bool m_mouseOverPanel = false; /**< 上一帧鼠标是否落在某个实验性功能面板上。 */ int m_peakDragLane = -1; /**< 正在拖动弱峰滑块的面板所属网格。 */ SignalRef m_peakDragRef; /**< 正在拖动弱峰滑块的**信号**(每个信号一套参数)。 */ + int m_meanDragLane = -1; /**< 正在拖动「均值滤波」参数滑块的面板所属网格。 */ + SignalRef m_meanDragRef; /**< 正在拖动「均值滤波」参数滑块的信号(每信号一套参数)。 */ + /** + * @brief 正在编辑「均值滤波」某个滑块上限时,输入框内灰色提示用的**当前上限值**。 + * + * 与 `m_peakEditCurrentMax` 同一语义(输入框以空内容开始,空内容时显示当前上限)。 + */ + int m_meanEditCurrentMax = 0; /** * @brief 正在编辑「迭代次数 N 上限」时,输入框内灰色提示用的**当前上限值**。 * @@ -3079,6 +3137,16 @@ namespace jdi { SignalRef ref; /**< 信号引用。 */ const SignalGroup* group = nullptr; /**< 所属分组(缓存键的一部分)。 */ + /** + * @brief 全部键字段的 64 位哈希(FNV-1a)。 + * + * ⚠️ **缓存查找只比这一个值**:旧实现逐条比较下面十几个字段, + * 而"上百个信号合并到同一网格"时(例如 MF4-Record 的 283 个信号)每次查找 + * 都要扫过整张表 ⇒ 绘制与 `ProbeFreeMarker` 每帧做数万次字段比较, + * 鼠标在网格内移动会明显卡顿(用户 2026-10-30 报告)。 + * 命中哈希后再校验 `ref` / `sampleCount` / 视界三项(极低成本),可忽略碰撞风险。 + */ + std::uint64_t key = 0; std::size_t sampleCount = 0; /**< 采样点数(数据变化时改变)。 */ double viewMin = 0.0; /**< 时间轴显示下界。 */ double viewMax = 0.0; /**< 时间轴显示上界。 */ @@ -3145,6 +3213,14 @@ namespace jdi const LaneVisual& lane) const; mutable std::deque m_polylineCache; /**< 折线顶点缓存(按几何键复用)。 */ + /** + * @brief 折线顶点缓存的**容量上限**(条目数)。 + * + * 由 `BuildLaneVisuals` 按"当前全部网格的信号总数 × 2"刷新(下限 `kPolylineCacheLimit`)。 + * ⚠️ 固定 64 条在"多信号合并网格"下会**持续抖动**(装满即整表清空 ⇒ 下一帧全部重建), + * 这正是"上百个信号合并到同一网格时自由标记非常卡"的根因之一。 + */ + mutable std::size_t m_polylineCacheCapacity = 64; /** * @brief 图层**横向补条**渲染期间的折线裁剪范围(省掉整段折线的重建与描边)。 diff --git a/Components/PlotView/Inc/PlotViewInternal.hpp b/Components/PlotView/Inc/PlotViewInternal.hpp index 16bf69b..5f5fc30 100644 --- a/Components/PlotView/Inc/PlotViewInternal.hpp +++ b/Components/PlotView/Inc/PlotViewInternal.hpp @@ -200,36 +200,8 @@ namespace jdi 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); + // 唯一实现见 `PaintUtil.hpp`(弹出菜单的还原图标、信号样式区的复位按钮共用同一套笔画)。 + UiPaintResetIcon(g, rect, color, scale); } /** @@ -579,6 +551,28 @@ namespace jdi 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"迭代次数"; + /** 「均值点数」下限(窗口 = 2X+1 ⇒ X=0 就是原信号)。 */ + inline const int kMeanFilterPointsMin = 0; + /** 「均值点数」滑块的默认上限(用户可点击数值改成其它正整数)。 */ + 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"自由标记点选取"; /** 「自由标记」面板底部「显示增量」切换按钮文案(只影响已固定点的显示)。 */ @@ -1235,39 +1229,77 @@ namespace jdi } /** - * @brief 由信号色派生一个「高对比度」颜色:保持色相、大幅拉开明度。 + * @brief 派生预览曲线相对底色的**最低对比度**(WCAG 比值)。 * - * 用途:「边沿计数(中心轴)」的平均值曲线叠加在原始波形之上, - * 若直接用信号色会被波形淹没。这里把信号色向黑或白方向大幅偏移, - * 并**同时考虑与原始波形色以及与绘图区底色的距离**,取对比更强的一侧, - * 保证曲线既能与波形区分、又能压在底图上看得清。 - * - * 结果是从信号色派生的,因此原信号改颜色时曲线颜色会自动跟着变。 - * - * @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((a >> 16) & 0xFFu) - static_cast((b >> 16) & 0xFFu); - const double dg = - static_cast((a >> 8) & 0xFFu) - static_cast((b >> 8) & 0xFFu); - const double db = static_cast(a & 0xFFu) - static_cast(b & 0xFFu); - return dr * dr + dg * dg + db * db; - }; + inline const double kDerivedPreviewContrast = 3.2; - 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 派生预览曲线与**源信号色**之间还要拉开的亮度差。 + * + * 源信号本身已经与底色有足够对比时,预览曲线再往外走这么多,避免"两条线同色叠在一起"。 + */ + 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) diff --git a/Components/PlotView/Src/PlotView.cpp b/Components/PlotView/Src/PlotView.cpp index 2f21942..e485d16 100644 --- a/Components/PlotView/Src/PlotView.cpp +++ b/Components/PlotView/Src/PlotView.cpp @@ -436,6 +436,19 @@ namespace jdi return; } + // 折线缓存容量 = 全部信号数 × 2(下限 `kPolylineCacheLimit`)。 + // ⚠️ 必须随信号总数增长:固定 64 条在"上百个信号合并到一个网格"时会持续清表重建, + // 使绘制与自由标记探测每帧重算全部折线(用户 2026-10-30 报告的卡顿)。 + { + std::size_t totalSignals = 0; + for (const Lane& lane : lanes) + { + totalSignals += lane.signals.size(); + } + const std::size_t wanted = totalSignals * 2ull; + m_polylineCacheCapacity = (wanted > kPolylineCacheLimit) ? wanted : kPolylineCacheLimit; + } + const float gap = 0.0f; float laneHeight = m_plotRect.Height / static_cast(count); if (laneHeight < m_minLaneHeight * m_scale) diff --git a/Components/PlotView/Src/PlotViewCacheKey.cpp b/Components/PlotView/Src/PlotViewCacheKey.cpp index 9effdbc..51a562f 100644 --- a/Components/PlotView/Src/PlotViewCacheKey.cpp +++ b/Components/PlotView/Src/PlotViewCacheKey.cpp @@ -458,7 +458,7 @@ namespace jdi if (cardBottom > cardTop) { UiFillRect(g, gd::RectF(patch.X, cardTop, patch.Width, cardBottom - cardTop), - m_theme->cardBg); + ThemeLaneBackground(*m_theme)); } const double viewMin = Tab().ViewMin(); diff --git a/Components/PlotView/Src/PlotViewFeatures.cpp b/Components/PlotView/Src/PlotViewFeatures.cpp index ca95e4e..f56d65f 100644 --- a/Components/PlotView/Src/PlotViewFeatures.cpp +++ b/Components/PlotView/Src/PlotViewFeatures.cpp @@ -1789,6 +1789,23 @@ namespace jdi return true; } + bool PlotView::SetLaneMeanFilterScroll(int laneIndex, float scroll) + { + if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) + { + return false; + } + Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; + const float clamped = std::max(0.0f, scroll); + if (std::fabs(lane.meanFilterScroll - clamped) < 0.5f) + { + return false; + } + // 滚动只影响面板内部像素,缓冲区/场景都不变(面板像素缓存按键失效即可)。 + lane.meanFilterScroll = clamped; + return true; + } + bool PlotView::ToggleLaneFreeMarkerGroup(int laneIndex, const SignalRef& ref) { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) diff --git a/Components/PlotView/Src/PlotViewInteraction.cpp b/Components/PlotView/Src/PlotViewInteraction.cpp index d5c8c7f..c3368d9 100644 --- a/Components/PlotView/Src/PlotViewInteraction.cpp +++ b/Components/PlotView/Src/PlotViewInteraction.cpp @@ -389,9 +389,11 @@ namespace jdi m_editTarget == PlotEditTarget::EDGE_CENTER_ITERATIONS || m_editTarget == PlotEditTarget::MEAN_FILTER_POINTS || m_editTarget == PlotEditTarget::MEAN_FILTER_ITERATIONS || - m_editTarget == PlotEditTarget::PEAK_ITERATIONS_MAX) + m_editTarget == PlotEditTarget::PEAK_ITERATIONS_MAX || + m_editTarget == PlotEditTarget::MEAN_POINTS_MAX || + m_editTarget == PlotEditTarget::MEAN_ITERATIONS_MAX) { - // 「均值点数 X」/「迭代次数 N」(均值滤波、中心轴)与「迭代次数 N 上限」(弱峰调整): + // 「均值点数 X」/「迭代次数 N」(均值滤波、中心轴)与「滑块上限」(弱峰调整 / 均值滤波): // 输入框都落在对应的字段/滑块行矩形上。 if (m_editFieldRect.Width <= 0.0f) { @@ -641,6 +643,44 @@ namespace jdi return true; } + // 「均值滤波」两行滑块的**上限**:与「弱峰调整 → 迭代次数上限」同一交互语义 + //(点击滑块右侧数值 → 整行变成输入框,输入正整数作为新的量程上限)。 + // 规则:空内容 / 非法值(低于下限或超出允许范围)→ 保持原上限不变; + // 生效后若当前值超过新上限,自动夹到新上限。 + if (target == PlotEditTarget::MEAN_POINTS_MAX || + target == PlotEditTarget::MEAN_ITERATIONS_MAX) + { + Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; + if (lane.FindMeanFilterItem(m_editMeanRef) == nullptr) + { + return true; + } + if (parsedOk) + { + const bool isPoints = (target == PlotEditTarget::MEAN_POINTS_MAX); + const int minimum = isPoints ? kMeanFilterPointsMin : kMeanFilterIterationsMin; + const int limit = isPoints ? kMeanFilterPointsMaxLimit : kMeanFilterIterationsMaxLimit; + const long long value = static_cast(std::llround(parsed)); + if (value >= minimum && value <= limit) + { + Lane::MeanFilterItem& item = lane.EnsureMeanFilterItem(m_editMeanRef); + if (isPoints) + { + item.maxPoints = static_cast(value); + item.points = std::max(minimum, std::min(item.maxPoints, item.points)); + } + else + { + item.maxIterations = static_cast(value); + item.iterations = + std::max(minimum, std::min(item.maxIterations, item.iterations)); + } + m_meanCurveCache.clear(); + } + } + return true; + } + // 「均值点数 X」/「迭代次数 N」:作用于被勾选信号的均值滤波曲线,夹在合理范围内。 // 「中心轴边沿计数」用**完全相同的参数与语义**,只是作用于自己的条目列表。 const bool centerTarget = @@ -882,7 +922,9 @@ namespace jdi return LoadCursorW(nullptr, IDC_SIZEWE); case PlotDragMode::PEAK_LEVEL: case PlotDragMode::PEAK_ITERATIONS: - // 「弱峰调整」滑块拖拽 → 左右箭头。 + case PlotDragMode::MEAN_POINTS: + case PlotDragMode::MEAN_ITERATIONS: + // 「弱峰调整 / 均值滤波」参数滑块拖拽 → 左右箭头。 return LoadCursorW(nullptr, IDC_SIZEWE); case PlotDragMode::MOVE_SIGNAL: // 拖动信号(参考 Photoshop 平移元素的抓握光标)。 @@ -1393,6 +1435,72 @@ namespace jdi } return true; } + case PlotDragMode::MEAN_POINTS: + case PlotDragMode::MEAN_ITERATIONS: + { + // 「均值滤波」某信号的参数滑块:量程 = 下限 … 该信号自己的上限。 + if (m_tab == nullptr || m_meanDragLane < 0 || + m_meanDragLane >= static_cast(m_tab->Lanes().size())) + { + break; + } + Lane& lane = m_tab->Lanes()[static_cast(m_meanDragLane)]; + if (lane.FindMeanFilterItem(m_meanDragRef) == nullptr) + { + break; + } + Lane::MeanFilterItem& item = lane.EnsureMeanFilterItem(m_meanDragRef); + const bool pointsMode = (m_dragMode == PlotDragMode::MEAN_POINTS); + const EdgePanelLayout layout = + EdgePanelLayoutFor(m_meanDragLane, EdgePanelKind::MEAN); + int rowIndex = -1; + for (std::size_t candidate = 0; candidate < layout.rowRefs.size(); ++candidate) + { + if (layout.rowRefs[candidate] == m_meanDragRef) + { + rowIndex = static_cast(candidate); + break; + } + } + if (rowIndex < 0) + { + break; + } + const std::vector& tracks = + pointsMode ? layout.rowLevelTracks : layout.rowIterTracks; + if (static_cast(rowIndex) >= tracks.size() || + tracks[static_cast(rowIndex)].Width <= 0.0f) + { + break; + } + const double ratio = + UiSliderRatioFromX(tracks[static_cast(rowIndex)], fx); + if (pointsMode) + { + const int maximum = std::max( + kMeanFilterPointsMin, std::min(kMeanFilterPointsMaxLimit, item.maxPoints)); + item.points = std::max( + kMeanFilterPointsMin, + std::min(maximum, + kMeanFilterPointsMin + + static_cast(std::lround(ratio * (maximum - kMeanFilterPointsMin))))); + } + else + { + const int maximum = + std::max(kMeanFilterIterationsMin, + std::min(kMeanFilterIterationsMaxLimit, item.maxIterations)); + item.iterations = std::max( + kMeanFilterIterationsMin, + std::min(maximum, + kMeanFilterIterationsMin + + static_cast(std::lround( + ratio * (maximum - kMeanFilterIterationsMin))))); + } + m_meanCurveCache.clear(); + m_dragMoved = true; + return true; + } case PlotDragMode::LANE_REORDER: { // 被拖拽的网格自由跟随鼠标;目标位置变化时其它网格实时让位(吸附)。 @@ -1526,6 +1634,35 @@ namespace jdi } return false; } + case PlotDragMode::MEAN_SCROLL: + { + // 「均值滤波」面板内容滚动条:按滑块在轨道里的位置换算滚动偏移。 + if (m_tab == nullptr || m_markerScrollDragLane < 0 || + m_markerScrollDragLane >= static_cast(m_tab->Lanes().size())) + { + break; + } + const EdgePanelLayout layout = + EdgePanelLayoutFor(m_markerScrollDragLane, EdgePanelKind::MEAN); + const float travel = layout.scrollTrack.Height - layout.scrollThumb.Height; + const float maxScroll = + std::max(0.0f, layout.markerContentHeight - layout.content.Height); + if (layout.scrollTrack.Width <= 0.0f || travel <= 0.0f || maxScroll <= 0.0f) + { + break; + } + const float thumbTop = + std::max(layout.scrollTrack.Y, + std::min(static_cast(y) - m_markerScrollGrabOffset, + layout.scrollTrack.Y + travel)); + const float ratio = (thumbTop - layout.scrollTrack.Y) / travel; + if (SetLaneMeanFilterScroll(m_markerScrollDragLane, ratio * maxScroll)) + { + m_dragMoved = true; + return true; + } + return false; + } case PlotDragMode::NONE: default: break; @@ -2054,6 +2191,161 @@ namespace jdi { const bool meanMode = (kind == EdgePanelKind::MEAN); const bool boostMode = (kind == EdgePanelKind::PEAK); + // 「均值滤波」:组头三角(展开 / 收起参数行)+ 两行参数滑块 + //(轨道拖动 / 点击数值改上限 / 右侧复位按钮)。 + // ⚠️ 所有命中都要求落在**内容区**之内:滚动到可视范围之外的行仍保留矩形, + // 若不加这条限制,它们会抢走底部「构建为新信号」按钮的点击。 + if (meanMode) + { + Lane& meanLane = m_tab->Lanes()[static_cast(index)]; + // 内容区右侧的滚动条滑块:按下即进入拖动(与「自由标记」面板同一交互)。 + if (layout.scrollThumb.Width > 0.0f && + UiPointInRect(layout.scrollThumb, fx, fy)) + { + m_dragMode = PlotDragMode::MEAN_SCROLL; + m_markerScrollDragLane = index; + m_markerScrollGrabOffset = fy - layout.scrollThumb.Y; + return true; + } + const bool insideContent = + UiPointInRect(layout.content, fx, fy); + for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex) + { + const SignalRef& ref = layout.rowRefs[rowIndex]; + if (!insideContent) + { + break; + } + // 只允许命中**完全落在内容区里**的行(部分滚出的行其矩形会与内容区 + // 边界重叠,放行会造出"看得见一半却能点"的幽灵热区)。 + if (rowIndex < layout.rowHeaders.size()) + { + const gd::RectF& headerRect = layout.rowHeaders[rowIndex]; + if (headerRect.Y < layout.content.Y - 0.5f || + headerRect.Y + headerRect.Height > + layout.content.Y + layout.content.Height + 0.5f) + { + continue; + } + } + const bool hasParams = + rowIndex < layout.rowLevelTracks.size() && + layout.rowLevelTracks[rowIndex].Width > 0.0f && + meanLane.FindMeanFilterItem(ref) != nullptr; + if (hasParams) + { + Lane::MeanFilterItem& item = meanLane.EnsureMeanFilterItem(ref); + // 复位:均值点数 → 默认值(同时把滑块上限恢复为默认上限)。 + if (rowIndex < layout.rowLevelResets.size() && + layout.rowLevelResets[rowIndex].Width > 0.0f && + UiPointInRect(layout.rowLevelResets[rowIndex], fx, fy)) + { + item.points = kMeanFilterPointsDefault; + item.maxPoints = kMeanFilterPointsDefaultMax; + m_meanCurveCache.clear(); + m_dragMode = PlotDragMode::NONE; + return true; + } + // 复位:迭代次数 → 1(上限恢复为默认上限)。 + if (rowIndex < layout.rowIterResets.size() && + layout.rowIterResets[rowIndex].Width > 0.0f && + UiPointInRect(layout.rowIterResets[rowIndex], fx, fy)) + { + item.iterations = kMeanFilterIterationsMin; + item.maxIterations = kMeanFilterIterationsDefaultMax; + m_meanCurveCache.clear(); + m_dragMode = PlotDragMode::NONE; + return true; + } + // 点击「均值点数」右侧数值 → 输入滑块上限。 + if (rowIndex < layout.rowLevelValues.size() && + layout.rowLevelValues[rowIndex].Width > 0.0f && + UiPointInRect(layout.rowLevelValues[rowIndex], fx, fy)) + { + m_editMeanRef = ref; + m_meanEditCurrentMax = item.maxPoints; + const gd::RectF& track = layout.rowLevelTracks[rowIndex]; + const gd::RectF& value = layout.rowLevelValues[rowIndex]; + const gd::RectF rowRect(track.X, value.Y, + value.X + value.Width - track.X, + value.Height); + BeginEdit(PlotEditTarget::MEAN_POINTS_MAX, index, L"", rowRect); + m_edit.Begin(L"", false, EditFilter::BYTE, 0u); + m_dragMode = PlotDragMode::NONE; + return true; + } + // 点击「迭代次数」右侧数值 → 输入滑块上限。 + if (rowIndex < layout.rowIterValues.size() && + layout.rowIterValues[rowIndex].Width > 0.0f && + UiPointInRect(layout.rowIterValues[rowIndex], fx, fy)) + { + m_editMeanRef = ref; + m_meanEditCurrentMax = item.maxIterations; + const gd::RectF& track = layout.rowIterTracks[rowIndex]; + const gd::RectF& value = layout.rowIterValues[rowIndex]; + const gd::RectF rowRect(track.X, value.Y, + value.X + value.Width - track.X, + value.Height); + BeginEdit(PlotEditTarget::MEAN_ITERATIONS_MAX, index, L"", rowRect); + m_edit.Begin(L"", false, EditFilter::BYTE, 0u); + m_dragMode = PlotDragMode::NONE; + return true; + } + // 拖动「均值点数」滑块。 + if (rowIndex < layout.rowLevelTracks.size() && + WeakPeakSliderHit(layout.rowLevelTracks[rowIndex], m_scale, fx, fy)) + { + const int maximum = std::max( + kMeanFilterPointsMin, + std::min(kMeanFilterPointsMaxLimit, item.maxPoints)); + m_dragMode = PlotDragMode::MEAN_POINTS; + m_meanDragLane = index; + m_meanDragRef = ref; + const double ratio = UiSliderRatioFromX(layout.rowLevelTracks[rowIndex], fx); + item.points = std::max( + kMeanFilterPointsMin, + std::min(maximum, + kMeanFilterPointsMin + + static_cast(std::lround( + ratio * + (maximum - kMeanFilterPointsMin))))); + m_meanCurveCache.clear(); + return true; + } + // 拖动「迭代次数」滑块。 + if (rowIndex < layout.rowIterTracks.size() && + WeakPeakSliderHit(layout.rowIterTracks[rowIndex], m_scale, fx, fy)) + { + const int maximum = std::max( + kMeanFilterIterationsMin, + std::min(kMeanFilterIterationsMaxLimit, item.maxIterations)); + m_dragMode = PlotDragMode::MEAN_ITERATIONS; + m_meanDragLane = index; + m_meanDragRef = ref; + const double ratio = UiSliderRatioFromX(layout.rowIterTracks[rowIndex], fx); + item.iterations = std::max( + kMeanFilterIterationsMin, + std::min(maximum, + kMeanFilterIterationsMin + + static_cast(std::lround( + ratio * + (maximum - kMeanFilterIterationsMin))))); + m_meanCurveCache.clear(); + return true; + } + } + // 组头左侧三角:切换参数行的收起 / 展开(只有勾选后才有三角)。 + if (rowIndex < layout.rowTriangles.size() && + layout.rowTriangles[rowIndex].Width > 0.0f && + UiPointInRect(layout.rowTriangles[rowIndex], fx, fy)) + { + Lane::MeanFilterItem& item = meanLane.EnsureMeanFilterItem(ref); + item.collapsed = !item.collapsed; + m_dragMode = PlotDragMode::NONE; + return true; + } + } + } Lane& tabLane = m_tab->Lanes()[static_cast(index)]; // 「弱峰调整」:先判两行滑块(数值文本 → 改上限;轨道 → 拖动)。 @@ -2707,6 +2999,9 @@ namespace jdi // 弱峰滑块拖拽结束:清掉目标(避免留下上一轮的信号引用)。 m_peakDragLane = -1; m_peakDragRef = SignalRef(); + // 「均值滤波」参数滑块拖拽结束:同样清掉目标。 + m_meanDragLane = -1; + m_meanDragRef = SignalRef(); // 「标记透明度」滑块 / 面板内容滚动条拖拽结束。 m_markerOpacityDragLane = -1; m_markerScrollDragLane = -1; @@ -2755,24 +3050,49 @@ namespace jdi int panelLane = -1; int panelKind = 0; if (TopPanelAtPoint(static_cast(m_mouseX), static_cast(m_mouseY), - panelLane, panelKind) && - static_cast(panelKind) == EdgePanelKind::MARK) + panelLane, panelKind)) { - const EdgePanelLayout layout = EdgePanelLayoutFor(panelLane, EdgePanelKind::MARK); - if (layout.content.Width > 0.0f && - UiPointInRect(layout.content, static_cast(m_mouseX), - static_cast(m_mouseY))) + const EdgePanelKind hoveredKind = static_cast(panelKind); + // 「均值滤波」面板:内容区超高时用滚轮翻页(上百个信号合并时必备)。 + if (hoveredKind == EdgePanelKind::MEAN) { - const float maxScroll = - std::max(0.0f, layout.markerContentHeight - layout.content.Height); - if (maxScroll > 0.0f) + const EdgePanelLayout layout = EdgePanelLayoutFor(panelLane, EdgePanelKind::MEAN); + if (layout.content.Width > 0.0f && + UiPointInRect(layout.content, static_cast(m_mouseX), + static_cast(m_mouseY))) { - const float next = static_cast(Tab().Lanes()[static_cast( - panelLane)] - .freeMarkerScroll - - notches * 40.0 * m_scale); - SetLaneFreeMarkerScroll(panelLane, std::max(0.0f, std::min(next, maxScroll))); - return true; + const float maxScroll = + std::max(0.0f, layout.markerContentHeight - layout.content.Height); + if (maxScroll > 0.0f) + { + const float current = + Tab().Lanes()[static_cast(panelLane)].meanFilterScroll; + const float next = current - static_cast(notches * 40.0 * m_scale); + SetLaneMeanFilterScroll(panelLane, + std::max(0.0f, std::min(next, maxScroll))); + return true; + } + } + } + if (hoveredKind == EdgePanelKind::MARK) + { + const EdgePanelLayout layout = EdgePanelLayoutFor(panelLane, EdgePanelKind::MARK); + if (layout.content.Width > 0.0f && + UiPointInRect(layout.content, static_cast(m_mouseX), + static_cast(m_mouseY))) + { + const float maxScroll = + std::max(0.0f, layout.markerContentHeight - layout.content.Height); + if (maxScroll > 0.0f) + { + const float next = static_cast(Tab().Lanes()[static_cast( + panelLane)] + .freeMarkerScroll - + notches * 40.0 * m_scale); + SetLaneFreeMarkerScroll(panelLane, + std::max(0.0f, std::min(next, maxScroll))); + return true; + } } } } diff --git a/Components/PlotView/Src/PlotViewPaint.cpp b/Components/PlotView/Src/PlotViewPaint.cpp index df158ec..9998db2 100644 --- a/Components/PlotView/Src/PlotViewPaint.cpp +++ b/Components/PlotView/Src/PlotViewPaint.cpp @@ -113,29 +113,65 @@ namespace jdi //(详见 `PolylineCacheEntry::lineWidth` 的说明;漏掉它会让线宽改动在**稠密信号**上不生效)。 const float lineWidth = (resolved.style != nullptr) ? resolved.style->lineWidth : 1.0f; + // 全部键字段的 64 位哈希(顺序固定;只用于**查找**,命中后再做廉价校验)。 + std::uint64_t key = 1469598103934665603ull; + const auto mixHash = [&key](std::uint64_t value) + { + key ^= value; + key *= 1099511628211ull; + }; + const auto mixHashFloat = [&mixHash](float value) + { + std::uint32_t bits = 0; + std::memcpy(&bits, &value, sizeof(bits)); + mixHash(static_cast(bits)); + }; + const auto mixHashDouble = [&mixHash](double value) + { + std::uint64_t bits = 0; + std::memcpy(&bits, &value, sizeof(bits)); + mixHash(bits); + }; + mixHash(static_cast(static_cast(ref.groupIndex)) + 2ull); + mixHash(static_cast(static_cast(ref.signalIndex)) + 2ull); + mixHash(reinterpret_cast(resolved.group)); + mixHash(static_cast(sampleCount)); + mixHashDouble(viewMin); + mixHashDouble(viewMax); + mixHashFloat(m_plotRect.X); + mixHashFloat(m_plotRect.Width); + mixHashFloat(lane.rect.Y); + mixHashFloat(lane.rect.Height); + mixHashDouble(lane.yMin); + mixHashDouble(lane.yMax); + mixHashFloat(gain); + mixHash((absolute ? 1ull : 0ull) | (mirrorVertical ? 2ull : 0ull) | + (mirrorHorizontal ? 4ull : 0ull)); + mixHashFloat(offsetY); + mixHashDouble(offsetTime); + mixHashFloat(lineWidth); + for (const PolylineCacheEntry& entry : m_polylineCache) { - if (entry.ref == ref && entry.group == resolved.group && entry.sampleCount == sampleCount && - entry.viewMin == viewMin && entry.viewMax == viewMax && entry.plotX == m_plotRect.X && - entry.plotWidth == m_plotRect.Width && entry.laneY == lane.rect.Y && - entry.laneHeight == lane.rect.Height && entry.laneYMin == lane.yMin && - entry.laneYMax == lane.yMax && entry.gain == gain && entry.absolute == absolute && - entry.mirrorVertical == mirrorVertical && entry.mirrorHorizontal == mirrorHorizontal && - entry.offsetY == offsetY && entry.offsetTime == offsetTime && - entry.lineWidth == lineWidth) + // 快路径:只比哈希。命中后再校验三项最可能变化的字段(碰撞代价可忽略)。 + if (entry.key != key || entry.sampleCount != sampleCount || !(entry.ref == ref) || + entry.viewMin != viewMin || entry.viewMax != viewMax) { - return &entry; + continue; } + return &entry; } // 未命中:重建并写入缓存(deque 保证已有元素的引用在追加后依然有效)。 - // 缓存只用于消除重复计算,容量上限约等于“网格数 × 每网格信号数”的两倍。 - if (m_polylineCache.size() >= kPolylineCacheLimit) + // ⚠️ 容量必须**覆盖所有网格的全部信号**:旧实现固定 64 条,283 个信号合并到 + // 同一网格时会不断"清空重建"⇒ 绘制与自由标记探测每帧重算全部折线(严重卡顿)。 + if (m_polylineCache.size() >= m_polylineCacheCapacity) { m_polylineCache.clear(); } m_polylineCache.push_back(PolylineCacheEntry()); PolylineCacheEntry& entry = m_polylineCache.back(); + entry.key = key; entry.ref = ref; entry.group = resolved.group; entry.sampleCount = sampleCount; @@ -656,104 +692,102 @@ namespace jdi // 下面这三样在 `paintOverlays` 里绘制在**浮动面板之后**(压在面板之上): // 滚动条、右下角「回到最上方」悬浮按钮、内联输入框。 - // ⇒ 必须比面板层(`baseLayer + 1`)再高一层,否则与实际像素的层序相反。 - boxes.SetLayer(baseLayer + 2); - boxes.Add(m_scrollBarRect, 5); - boxes.Add(BackToTopRect(), 5); - boxes.Add(EditFieldRect(), 5); + // ⇒ 它们的层号在下面「面板循环」之后统一给(比最上面板再高一层), + // 否则与实际像素的层序相反。这里先复位到组件基层。 boxes.SetLayer(baseLayer); - // ⚠️⚠️ **浮动面板必须另起一个绘制层,并把面板矩形登记为遮挡矩形**: + // ⚠️⚠️ **浮动面板必须另起绘制层,并把面板矩形登记为遮挡矩形**: // 面板是不透明浮层、且常常**跨多个网格上下排列**,与网格区域重叠。 // 若与网格同层,网格的矩形边线会横穿面板 —— 用户 2026-10-29 报告的 // "网格的布局边界穿透显示在浮动面板上"。`layer` 决定谁压谁, // `AddOccluder` 才真正把下层 box 从面板区域里**挖掉**。 - bool panelLayerPushed = false; // 层级 2/3/4:5 类实验性功能浮动面板的内部控件。 // 这些矩形由 `EdgePanelLayoutFor` 统一算出(绘制与命中共用),一个循环即可全部上报。 // + // ⚠️⚠️ **每个面板各占一个绘制层,且顺序必须是 `m_panelOrder`(底 → 顶)**: + // 旧实现把所有面板都放在 `baseLayer + 1` 一层,于是同一层内只能按 `depth` 排序 —— + // 下层面板的"行/文本"(depth 4)会画在**上层**面板的本体(depth 2/3)之上, + // 表现为"被遮挡的下层面板的边框/文字穿透到上层面板上"(用户 2026-10-30 报告)。 + // 层号与 `paintOverlays` 里 `UiSetTextLayer` 的推进**完全同源**。 + // + // 说明(已知边界):面板层号从 `baseLayer + 1` 起逐面板递增,因此**同时显示 15 个以上** + // 面板时会撞上浮层层号(`OverlayLayer(1)` = 组件数 × 3 + 3)。那时只有"菜单不再挖掉 + // 面板边框"这一条调试观感受影响;实际布局下同时可见的面板是几个到十几个。 + SyncPanelOrder(); + const std::size_t panelCount = m_panelOrder.size(); + const int topOverlayLayer = baseLayer + 1 + static_cast(panelCount); + // ⚠️ **先用最便宜的判据跳过"没有面板"的网格**:`EdgePanelLayoutFor` 内部要取 // 标签页配置、逐项判断种类与开关,单次约 2~3µs;283 个网格 × 5 种 = 1415 次调用 // 实测约 **4ms/帧**(这是"开调试后卡"的主要来源之一)。 - // 只有该网格**至少有一个面板处于展开状态**时才值得去算布局。 - const std::vector& laneConfigs = - (m_tab != nullptr) ? m_tab->Lanes() : m_localTab.Lanes(); - for (std::size_t index = 0; index < m_lanes.size() && index < laneConfigs.size(); ++index) + // 现在只对**层序表里真正显示出来的面板**算布局(层序表本来就只含展开且启用的面板)。 + for (std::size_t ordinal = 0; ordinal < panelCount; ++ordinal) { - const Lane& laneConfig = laneConfigs[index]; - const bool anyPanelOpen = laneConfig.edgePanel.open || - laneConfig.edgeCenterPanel.open || - laneConfig.meanFilterPanel.open || - laneConfig.weakPeakPanel.open || - laneConfig.freeMarkerPanel.open; - if (!anyPanelOpen) + const PanelRef& panelRef = m_panelOrder[ordinal]; + const EdgePanelLayout layout = + EdgePanelLayoutFor(panelRef.laneIndex, static_cast(panelRef.kind)); + if (!UiRectHasArea(layout.panel)) { continue; } - const int laneIndex = static_cast(index); - for (int kindValue = static_cast(EdgePanelKind::COUNT); - kindValue <= static_cast(EdgePanelKind::MARK); ++kindValue) + // 本面板独占一层(层号越大越靠上)。 + boxes.SetLayer(baseLayer + 1 + static_cast(ordinal)); + boxes.AddOccluder(layout.panel); + // 面板内部元素的调试框同样裁到面板本身:面板滚动的内容区里, + // 滚出可视区的行其矩形仍在(与弹出菜单的条目同一类问题)。 + boxes.SetClip(layout.panel); + boxes.Add(layout.panel, 2); + boxes.Add(layout.title, 3); + boxes.Add(layout.close, 3); + boxes.Add(layout.disclosure, 3); + boxes.Add(layout.badge, 3); + boxes.Add(layout.edit, 3); + boxes.Add(layout.button, 3); + boxes.Add(layout.content, 3); + boxes.Add(layout.scrollTrack, 3); + boxes.Add(layout.scrollThumb, 4); + boxes.Add(layout.opacityTrack, 3); + boxes.Add(layout.opacityValue, 3); + boxes.Add(layout.showDelta, 3); + boxes.Add(layout.showEnum, 3); + + boxes.AddAll(layout.rowChecks, 4); + boxes.AddAll(layout.rowPoints, 4); + boxes.AddAll(layout.rowIterations, 4); + boxes.AddAll(layout.rowStats, 4); + boxes.AddAll(layout.rowLevelTracks, 4); + boxes.AddAll(layout.rowLevelValues, 4); + boxes.AddAll(layout.rowIterTracks, 4); + boxes.AddAll(layout.rowIterValues, 4); + boxes.AddAll(layout.rowLevelResets, 4); + boxes.AddAll(layout.rowIterResets, 4); + boxes.AddAll(layout.rowNameRects, 4); + boxes.AddAll(layout.rowNameCloses, 4); + boxes.AddAll(layout.rowTriangles, 4); + boxes.AddAll(layout.rowHeaders, 4); + + for (const EdgePanelLayout::MarkerGroup& group : layout.markerGroups) { - const EdgePanelLayout layout = - EdgePanelLayoutFor(laneIndex, static_cast(kindValue)); - if (!UiRectHasArea(layout.panel)) - { - continue; - } - if (!panelLayerPushed) - { - boxes.SetLayer(baseLayer + 1); - panelLayerPushed = true; - } - boxes.AddOccluder(layout.panel); - // 面板内部元素的调试框同样裁到面板本身:面板滚动的内容区里, - // 滚出可视区的行其矩形仍在(与弹出菜单的条目同一类问题)。 - boxes.SetClip(layout.panel); - boxes.Add(layout.panel, 2); - boxes.Add(layout.title, 3); - boxes.Add(layout.close, 3); - boxes.Add(layout.disclosure, 3); - boxes.Add(layout.badge, 3); - boxes.Add(layout.edit, 3); - boxes.Add(layout.button, 3); - boxes.Add(layout.content, 3); - boxes.Add(layout.scrollTrack, 3); - boxes.Add(layout.scrollThumb, 4); - boxes.Add(layout.opacityTrack, 3); - boxes.Add(layout.opacityValue, 3); - boxes.Add(layout.showDelta, 3); - boxes.Add(layout.showEnum, 3); - - boxes.AddAll(layout.rowChecks, 4); - boxes.AddAll(layout.rowPoints, 4); - boxes.AddAll(layout.rowIterations, 4); - boxes.AddAll(layout.rowStats, 4); - boxes.AddAll(layout.rowLevelTracks, 4); - boxes.AddAll(layout.rowLevelValues, 4); - boxes.AddAll(layout.rowIterTracks, 4); - boxes.AddAll(layout.rowIterValues, 4); - boxes.AddAll(layout.rowLevelResets, 4); - boxes.AddAll(layout.rowIterResets, 4); - boxes.AddAll(layout.rowNameRects, 4); - boxes.AddAll(layout.rowNameCloses, 4); - - for (const EdgePanelLayout::MarkerGroup& group : layout.markerGroups) - { - boxes.Add(group.header, 3); - boxes.Add(group.tableHeader, 3); - boxes.Add(group.triangle, 4); - boxes.Add(group.close, 4); - } - for (const EdgePanelLayout::MarkerRow& row : layout.markerRows) - { - boxes.Add(row.rect, 4); - boxes.Add(row.close, 4); - } + boxes.Add(group.header, 3); + boxes.Add(group.tableHeader, 3); + boxes.Add(group.triangle, 4); + boxes.Add(group.close, 4); + boxes.Add(group.check, 4); + } + for (const EdgePanelLayout::MarkerRow& row : layout.markerRows) + { + boxes.Add(row.rect, 4); + boxes.Add(row.close, 4); } } // 收尾:解除面板裁剪并**复位层号**(宿主后续组件依赖它)。 + // 浮动面板之上的叠加层(滚动条 / 回到最上方 / 内联输入框)必须比**最上面板**再高一层。 boxes.SetClip(gd::RectF()); + boxes.SetLayer(topOverlayLayer); + boxes.Add(m_scrollBarRect, 5); + boxes.Add(BackToTopRect(), 5); + boxes.Add(EditFieldRect(), 5); boxes.SetLayer(baseLayer); } @@ -848,17 +882,19 @@ namespace jdi // 这样浮动面板仍压在最上层,与其它实验性功能的层序一致)。 PaintFreeMarker(g, m_lanes); // 从这里开始都在面板之上:文本层同步 +1(与 `BuildBoxes` 的 `baseLayer + 1` 一致)。 + // ⚠️ `PaintEdgeCursor` 内部会**逐面板**再把层号 +1(每个面板独占一层), + // 并在返回前把层号推到**最上面板之上**,因此这里不再写死 `+2`。 UiSetTextLayer(textLayer + 1); PaintEdgeCursor(g, m_lanes); - // 之后这几样绘制在浮动面板**之上**:与 `BuildBoxes` 的 `baseLayer + 2` 对应。 - UiSetTextLayer(textLayer + 2); PaintSignalInteraction(g); PaintHint(g); // 网格 Y 轴上下限的输入框、以及「弱峰调整 → 迭代次数上限」的输入框都绘制在最上层 //(后者覆盖在浮动面板的滑块行之上)。 if (m_editTarget == PlotEditTarget::LANE_Y_MAX || m_editTarget == PlotEditTarget::LANE_Y_MIN || - m_editTarget == PlotEditTarget::PEAK_ITERATIONS_MAX) + m_editTarget == PlotEditTarget::PEAK_ITERATIONS_MAX || + m_editTarget == PlotEditTarget::MEAN_POINTS_MAX || + m_editTarget == PlotEditTarget::MEAN_ITERATIONS_MAX) { PaintEditField(g); } @@ -1105,7 +1141,9 @@ namespace jdi UiFillRect(g, m_area, m_theme->plotBg); // 波形区域底色。 - UiFillRect(g, m_plotRect, m_theme->cardBg); + // ⚠️ 「网格叠底」必须用 `ThemeLaneBackground()`:主题的 `cardBg` 与 `plotBg` 过于接近时 + // (深色主题)它会自动拉开一点点,保证网格这一层结构在任何主题下都看得见。 + UiFillRect(g, m_plotRect, ThemeLaneBackground(*m_theme)); // 图例命中矩形每帧重建,避免残留已消失的信号。 m_legendHits.clear(); @@ -1580,7 +1618,8 @@ namespace jdi const gd::RectF gridClip = laneClip; /** 标尺数值文本的裁剪矩形(GDI 文本需要显式裁剪,否则会越过绘图区)。 */ const gd::RectF clipRectForLabels = gridClip; - const std::uint32_t minorLineColor = UiBlendOver(m_theme->cardBg, WithAlpha(m_theme->gridWeak, kMinorLineAlpha)); + const std::uint32_t laneBackground = ThemeLaneBackground(*m_theme); + const std::uint32_t minorLineColor = UiBlendOver(laneBackground, WithAlpha(m_theme->gridWeak, kMinorLineAlpha)); const std::uint32_t weakLineColor = m_theme->gridWeak | 0xFF000000u; const std::uint32_t strongLineColor = m_theme->gridStrong | 0xFF000000u; @@ -3145,12 +3184,14 @@ namespace jdi const float limitTop = lane.rect.Y - lane.rect.Height * 4.0f; const float limitBottom = lane.rect.Y + lane.rect.Height * 5.0f; - // 派生曲线的颜色:与原信号**同色相**但大幅拉开明度,使其与原始波形形成高对比 - // (原信号改颜色时曲线随之改变,因为它是从信号色派生出来的);透明度与原信号一致。 + // 派生曲线的颜色:与原信号**同色相**、亮度按**当前网格底色**自适应拉开, + // 使其与原始波形、与背景都形成足够对比(原信号改颜色时曲线随之改变,因为它是从信号色 + // 派生出来的);透明度与原信号一致。 const unsigned int baseAlpha = static_cast( std::max(0.0, std::min(1.0, static_cast(resolved.style->opacity))) * 255.0 + 0.5); - const std::uint32_t derived = WithAlpha(HighContrastDerived(StyleColor(*resolved.style)), - baseAlpha); + const std::uint32_t derived = WithAlpha( + HighContrastDerived(StyleColor(*resolved.style), ThemeLaneBackground(*m_theme)), + baseAlpha); // 顶点预算与波形一致(每像素列两个顶点)。 // 交互期间进一步降到 1/2:派生曲线往往有 10 条(5 网格 × 均值滤波 + 弱峰调整), @@ -3480,6 +3521,10 @@ namespace jdi mix(lane.freeMarkerPanel.collapsed ? 1ull : 0ull); mix(lane.edgeValueValid ? 1ull : 0ull); mixDouble(lane.edgeValue); + // 「自由标记」面板内容区的滚动偏移(历史行为:计入全局键)。 + // ⚠️ 「均值滤波」的滚动偏移只进**该面板自己的**槽键(见 `PanelSlotKey`), + // 否则滚动一个面板会让屏幕上所有面板一起重画。 + mixFloat(lane.freeMarkerScroll); } } @@ -3573,6 +3618,8 @@ namespace jdi mixRect(group.tableHeader); mixRect(group.triangle); mixRect(group.close); + // 「均值滤波」的组头右端是复选框(「自由标记」为空矩形,不影响它的键)。 + mixRect(group.check); mix(group.collapsed ? 1ull : 0ull); mix(group.pinned ? 1ull : 0ull); mix(static_cast(group.ref.groupIndex + 2)); @@ -3616,6 +3663,11 @@ namespace jdi const Lane::MeanFilterItem* meanItem = lane.FindMeanFilterItem(ref); mix(static_cast(meanItem != nullptr ? meanItem->points + 2 : 0)); mix(static_cast(meanItem != nullptr ? meanItem->iterations + 2 : 0)); + // 「均值滤波」改为滑块后,**滑块上限**决定圆钮位置、**收起状态**决定是否显示参数行 + // ⇒ 两者都直接改变面板像素,必须进键(否则拖完滑块仍会贴回旧像素)。 + mix(static_cast(meanItem != nullptr ? meanItem->maxPoints + 2 : 0)); + mix(static_cast(meanItem != nullptr ? meanItem->maxIterations + 2 : 0)); + mix((meanItem != nullptr && meanItem->collapsed) ? 1ull : 0ull); const Lane::EdgeCenterItem* centerItem = lane.FindEdgeCenterItem(ref); mix(static_cast(centerItem != nullptr ? centerItem->points + 2 : 0)); mix(static_cast(centerItem != nullptr ? centerItem->iterations + 2 : 0)); @@ -3704,6 +3756,17 @@ namespace jdi { mixRect(rect); } + // 「均值滤波」:信号色叠底标题栏 / 展开三角 / 内容区滚动偏移都要入键 + //(滚动改变的是面板内部像素,位置与尺寸不变)。 + for (const gd::RectF& rect : layout.rowHeaders) + { + mixRect(rect); + } + for (const gd::RectF& rect : layout.rowTriangles) + { + mixRect(rect); + } + mixFloat(layout.scrollOffset); // 鼠标落在面板内时,✕ / 复选框 / 滑块 / 底部按钮的悬停态都依赖精确坐标。 const float margin = 6.0f * m_scale; @@ -4067,12 +4130,23 @@ namespace jdi //(与"可拖拽"的光晕区分:这里只是单点圆环,不描整条曲线)。 const float radius = 3.4f * m_scale; gd::SolidBrush dot(ToGdiColor(signalColor)); - g.FillEllipse(&dot, x - radius, probe.y - radius, radius * 2.0f, radius * 2.0f); + // 取值圆点与"已吸附"圆环都画在**数据点本身**的横坐标上(`pointX`), + // 而纵向光标画在鼠标位置(`x`)。稀疏信号下两者会分开 —— 这是刻意的: + // 光标跟随鼠标、提示落在真实采样点上(用户 2026-10-30 要求)。 + float pointX = probe.pointX; + if (!(pointX > 0.0f)) + { + pointX = x; // 兼容:未填 `pointX` 时退回光标位置 + } + g.FillEllipse(&dot, pointX - radius, probe.y - radius, radius * 2.0f, + radius * 2.0f); if (probe.snapped) { gd::Pen ring(ToGdiColor(lineColor), 1.2f * m_scale); - g.DrawEllipse(&ring, x - radius - 3.0f * m_scale, probe.y - radius - 3.0f * m_scale, - (radius + 3.0f * m_scale) * 2.0f, (radius + 3.0f * m_scale) * 2.0f); + g.DrawEllipse(&ring, pointX - radius - 3.0f * m_scale, + probe.y - radius - 3.0f * m_scale, + (radius + 3.0f * m_scale) * 2.0f, + (radius + 3.0f * m_scale) * 2.0f); } } } @@ -4094,6 +4168,9 @@ namespace jdi const std::size_t count = std::min(lanes.size(), tabLanes.size()); const std::uint32_t color = CursorColor(); const UiInk ink = UiContrastInk(color); + // 进入本函数时的文本层号 = 宿主给本组件分配的面板层起始(`baseLayer + 1`)。 + // 每个面板在循环里各用 `panelTextLayer + 面板序数`,函数结束前把层号推到**最上面板之上**。 + const int panelTextLayer = UiGetTextLayer(); // ---- 横向光标(线与两端把手 + 左侧幅值读数)---- for (std::size_t index = 0; index < count; ++index) @@ -4283,6 +4360,10 @@ namespace jdi const PanelRef& panelRef = m_panelOrder[panelOrdinal]; const EdgePanelKind kind = static_cast(panelRef.kind); const std::size_t index = static_cast(panelRef.laneIndex); + // 文本几何层号:**每个面板独占一层**,与 `BuildBoxes` 完全同源 + //(`baseLayer + 1 + 面板序数`,面板序数按 `m_panelOrder` 底 → 顶)。 + // 少了这一步,下层面板的文字框会记在比上层面板更低的层里被反复挖洞/穿透。 + UiSetTextLayer(panelTextLayer + static_cast(panelOrdinal)); const bool centerAxis = (kind == EdgePanelKind::CENTER); const bool meanFilter = (kind == EdgePanelKind::MEAN); const bool weakPeak = (kind == EdgePanelKind::PEAK); @@ -4353,11 +4434,43 @@ namespace jdi } if (hit != nullptr && hit->key != 0 && hit->key == slotKey) { + // ⚠️⚠️ **只贴「面板圆角形状之内」的像素**(用户 2026-10-30 报告: + // 中键移动视界时面板周围带着一圈**旧视界的波形像素**)。 + // 原因是缓存位图按 `panel ± cacheMargin` 取块,块的边缘那几像素是 + // **上一帧的场景**(波形/网格线);`PanelGlobalKey` 刻意不含视界 + // (否则平移会重画全部面板),于是整块贴回时就把旧波形"复印"了一圈。 + // 现在:① 先把**投影**按本帧重新画一遍(它在圆角形状之外,不能靠缓存); + // ② 用圆角区域裁剪后只 BitBlt 面板本体(圆角之外仍是本帧场景)。 + if (!m_fastFrame) + { + UiFillRoundRect(g, gd::RectF(panel.X + 1.0f * m_scale, + panel.Y + 2.0f * m_scale, panel.Width, + panel.Height), + 5.0f * m_scale, 0x14000000u); + } HDC target = g.GetHDC(); if (target != nullptr) { - BitBlt(target, hit->x0, hit->y0, hit->x1 - hit->x0, hit->y1 - hit->y0, - m_panelDc, hit->x0, hit->y0, SRCCOPY); + const int bx0 = static_cast(std::floor(panel.X)) - 1; + const int by0 = static_cast(std::floor(panel.Y)) - 1; + const int bx1 = static_cast(std::ceil(panel.X + panel.Width)) + 1; + const int by1 = static_cast(std::ceil(panel.Y + panel.Height)) + 1; + HRGN rounded = CreateRoundRectRgn( + bx0, by0, bx1 + 1, by1 + 1, + static_cast(std::lround(10.0f * m_scale)), + static_cast(std::lround(10.0f * m_scale))); + const int savedDc = SaveDC(target); + if (rounded != nullptr) + { + ExtSelectClipRgn(target, rounded, RGN_AND); + } + BitBlt(target, bx0, by0, bx1 - bx0, by1 - by0, m_panelDc, bx0, by0, + SRCCOPY); + if (rounded != nullptr) + { + DeleteObject(rounded); + } + RestoreDC(target, savedDc); g.ReleaseHDC(target); } // 贴图不会真的画字 ⇒ 必须把上一帧渲染时存下的文本框**回放**给 @@ -4701,6 +4814,191 @@ namespace jdi } } + // 「均值滤波」面板:逐信号的**信号色叠底标题栏**(左侧展开三角 + 信号名 + + // 右端复选框)。复选框勾选后才出现三角、才能展开下面的两行参数滑块 + // (「均值点数」/「迭代次数」);内容超高时可滚动(滚动条在内容区右侧)。 + if (meanFilter) + { + const int meanSave = g.Save(); + // 内容必须裁到内容区:滚动时行会越出可视范围。 + g.SetClip(layout.content, gd::CombineModeIntersect); + + const float valueColumn = + std::max(UiMeasureTextWidth(m_fonts->monoSmall, L"100000"), + UiMeasureTextWidth(m_fonts->monoSmall, L"1000")); + + // 复选框自绘(主题色在信号色叠底上看不清):用**自适应对比色**描边/填充。 + const auto paintMeanCheck = [this, &g](const gd::RectF& rect, bool checked, + const UiInk& ink, + std::uint32_t background) + { + if (rect.Width <= 0.0f || rect.Height <= 0.0f) + { + return; + } + const float radius = 2.5f * m_scale; + if (!checked) + { + UiDrawRoundRect(g, rect, radius, ink.text, 1.2f * m_scale); + return; + } + UiFillRoundRect(g, rect, radius, ink.text); + // 对勾:两段折线,颜色取叠底色(与填充色必成对比)。 + const float left = rect.X + rect.Width * 0.22f; + const float middleX = rect.X + rect.Width * 0.44f; + const float right = rect.X + rect.Width * 0.78f; + const float highY = rect.Y + rect.Height * 0.30f; + const float lowY = rect.Y + rect.Height * 0.72f; + UiDrawLine(g, left, rect.Y + rect.Height * 0.52f, middleX, lowY, background, + 1.6f * m_scale); + UiDrawLine(g, middleX, lowY, right, highY, background, 1.6f * m_scale); + }; + + for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex) + { + const SignalRef& ref = layout.rowRefs[rowIndex]; + if (rowIndex >= layout.rowHeaders.size() || + rowIndex >= layout.rowChecks.size()) + { + break; + } + const ResolvedSignal resolved = ResolveSignal(ref); + if (resolved.data == nullptr || resolved.style == nullptr) + { + continue; + } + const gd::RectF& header = layout.rowHeaders[rowIndex]; + if (header.Y + header.Height <= layout.content.Y || + header.Y >= layout.content.Y + layout.content.Height) + { + continue; // 完全滚出可视范围 + } + const std::uint32_t signalColor = StyleColor(*resolved.style); + const UiInk ink = UiContrastInk(signalColor); + const Lane::MeanFilterItem* item = tabLane.FindMeanFilterItem(ref); + const bool checked = (item != nullptr); + + // 叠底:先铺一层不透明同色再叠 0xE8(与「自由标记」分组栏同一做法), + // 这样滚动时下层内容不会透出来。 + UiFillRoundRect(g, header, 3.0f * m_scale, signalColor); + UiFillRoundRect(g, header, 3.0f * m_scale, WithAlpha(signalColor, 0xE8)); + + // 左侧三角(仅勾选时存在):朝下 = 展开、朝右 = 收起。 + const gd::RectF& triangle = layout.rowTriangles[rowIndex]; + if (triangle.Width > 0.0f && item != nullptr) + { + UiDrawTriangleIcon(g, triangle, ink.text, + item->collapsed ? IconDirection::RIGHT + : IconDirection::DOWN); + } + + // 名称:从三角右侧到复选框左侧。 + const gd::RectF& check = layout.rowChecks[rowIndex]; + const float nameLeft = (triangle.Width > 0.0f) + ? (triangle.X + triangle.Width + 6.0f * m_scale) + : (header.X + 8.0f * m_scale); + const float nameWidth = + std::max(10.0f, check.X - 6.0f * m_scale - nameLeft); + UiTextClipped(g, UiEllipsize(resolved.data->name, m_fonts->uiSmall, nameWidth), + m_fonts->uiSmall, + gd::RectF(nameLeft, header.Y, nameWidth, header.Height), ink.text, + TextAlign::LEFT, layout.content); + paintMeanCheck(check, checked, ink, signalColor); + + if (!checked || item->collapsed) + { + continue; + } + + // 两行参数滑块(标签 / 轨道 / 数值 / 复位按钮)。 + const auto paintMeanSlider = [&](const gd::RectF& track, + const gd::RectF& valueRect, + const gd::RectF& resetRect, + const wchar_t* label, int current, int minimum, + int maximum, bool active) + { + if (track.Width <= 0.0f) + { + return; + } + const float labelLeft = track.X - 6.0f * m_scale - + UiMeasureTextWidth(m_fonts->uiSmall, label); + UiText(g, label, m_fonts->uiSmall, + gd::RectF(labelLeft, track.Y - 8.0f * m_scale, + UiMeasureTextWidth(m_fonts->uiSmall, label), + track.Height + 16.0f * m_scale), + m_theme->textMuted, TextAlign::LEFT); + const double ratio = + (maximum > minimum) + ? (static_cast(current - minimum) / + static_cast(maximum - minimum)) + : 0.0; + UiDrawSlider(g, track, ratio, m_theme->accent, active, m_scale); + UiText(g, std::to_wstring(current), m_fonts->monoSmall, + gd::RectF(valueRect.X, valueRect.Y, valueColumn, valueRect.Height), + m_theme->textStrong, TextAlign::LEFT); + // 复位按钮:悬停高亮 + 与「系数/偏移」菜单同款图标。 + const bool hot = + (m_dragMode != PlotDragMode::EDGE_PANEL_MOVE) && + UiPointInRect(resetRect, static_cast(m_mouseX), + static_cast(m_mouseY)); + if (hot) + { + UiFillRoundRect( + g, gd::RectF(resetRect.X - 1.0f * m_scale, + resetRect.Y - 1.0f * m_scale, + resetRect.Width + 2.0f * m_scale, + resetRect.Height + 2.0f * m_scale), + 3.0f * m_scale, WithAlpha(m_theme->accent, 0x33)); + } + PaintPanelResetIcon(g, resetRect, + hot ? m_theme->accent : m_theme->textMuted, m_scale); + }; + + const int pointsMax = std::max(kMeanFilterPointsMin, + std::min(kMeanFilterPointsMaxLimit, + item->maxPoints)); + const int points = + std::max(kMeanFilterPointsMin, std::min(pointsMax, item->points)); + const int iterationsMax = std::max(kMeanFilterIterationsMin, + std::min(kMeanFilterIterationsMaxLimit, + item->maxIterations)); + const int iterations = std::max( + kMeanFilterIterationsMin, std::min(iterationsMax, item->iterations)); + const bool pointsActive = (m_dragMode == PlotDragMode::MEAN_POINTS) && + (m_meanDragLane == static_cast(index)) && + (m_meanDragRef == ref); + const bool iterationsActive = + (m_dragMode == PlotDragMode::MEAN_ITERATIONS) && + (m_meanDragLane == static_cast(index)) && (m_meanDragRef == ref); + if (rowIndex < layout.rowLevelTracks.size()) + { + paintMeanSlider(layout.rowLevelTracks[rowIndex], + rowIndex < layout.rowLevelValues.size() + ? layout.rowLevelValues[rowIndex] + : gd::RectF(), + rowIndex < layout.rowLevelResets.size() + ? layout.rowLevelResets[rowIndex] + : gd::RectF(), + kMeanFilterPointsSliderLabel, points, + kMeanFilterPointsMin, pointsMax, pointsActive); + } + if (rowIndex < layout.rowIterTracks.size()) + { + paintMeanSlider(layout.rowIterTracks[rowIndex], + rowIndex < layout.rowIterValues.size() + ? layout.rowIterValues[rowIndex] + : gd::RectF(), + rowIndex < layout.rowIterResets.size() + ? layout.rowIterResets[rowIndex] + : gd::RectF(), + kMeanFilterIterationsSliderLabel, iterations, + kMeanFilterIterationsMin, iterationsMax, iterationsActive); + } + } + + g.Restore(meanSave); + } // 「自由标记」面板:按信号分组的**分组栏**(信号色托底 + 三角 + 名称 + // 「N 标记点」+ ✕),下属固定点条目六列对齐(序号 / 取值 / 枚举 / 增量 / 时刻 / 间隔), // 行尾各带一个 ✕。内容区有滚动偏移时按内容区裁剪。 @@ -5166,6 +5464,10 @@ namespace jdi m_panelSlots = std::move(nextSlots); m_panelGlobalKey = panelGlobalKey; m_panelValid = panelCacheReady; + + // 面板之后绘制的叠加层(信号拖拽虚影 / 提示 / 输入框 / 滚动条 / 回到最上方) + // 都在**所有面板之上** ⇒ 文本层号推到最上面板的上一层(与 `BuildBoxes` 同源)。 + UiSetTextLayer(panelTextLayer + static_cast(m_panelOrder.size())); } void PlotView::PaintEditField(gd::Graphics& g) @@ -5181,10 +5483,21 @@ namespace jdi // 「迭代次数 N 上限」:输入框**以空内容开始**,未输入任何内容时以灰色提示当前上限值。 // 提示文案 = “输入上限(当前N)”(用户明确要求:旧实现只显示一个孤立的数字,缺少说明)。 - if (m_editTarget == PlotEditTarget::PEAK_ITERATIONS_MAX && m_edit.Text().empty()) + // 「均值滤波」两行滑块的上限输入框共用同一套提示文案。 + if ((m_editTarget == PlotEditTarget::PEAK_ITERATIONS_MAX || + m_editTarget == PlotEditTarget::MEAN_POINTS_MAX || + m_editTarget == PlotEditTarget::MEAN_ITERATIONS_MAX) && + m_edit.Text().empty()) { - const int currentMax = m_peakEditCurrentMax > 0 ? m_peakEditCurrentMax - : kWeakPeakIterationsDefaultMax; + const bool peakTarget = (m_editTarget == PlotEditTarget::PEAK_ITERATIONS_MAX); + const int currentMax = + peakTarget + ? ((m_peakEditCurrentMax > 0) ? m_peakEditCurrentMax : kWeakPeakIterationsDefaultMax) + : ((m_meanEditCurrentMax > 0) + ? m_meanEditCurrentMax + : ((m_editTarget == PlotEditTarget::MEAN_POINTS_MAX) + ? kMeanFilterPointsDefaultMax + : kMeanFilterIterationsDefaultMax)); const std::wstring hint = L"输入上限(当前" + std::to_wstring(currentMax) + L")"; const float hintLeft = fieldRect.X + 11.0f * m_scale; UiTextAtClipped(g, hint, m_fonts->monoSmall, hintLeft, diff --git a/Components/PlotView/Src/PlotViewPanels.cpp b/Components/PlotView/Src/PlotViewPanels.cpp index dec222f..8ceb15d 100644 --- a/Components/PlotView/Src/PlotViewPanels.cpp +++ b/Components/PlotView/Src/PlotViewPanels.cpp @@ -69,6 +69,17 @@ namespace jdi width = std::max(width, kEdgePanelPad * 2.0f * m_scale + nameWidth + kEdgePanelMeanCheckSize * m_scale + 6.0f * m_scale); } + if (meanFilter) + { + // 「均值滤波」的条目 = **信号色叠底标题栏**(三角 + 名称 + 复选框) + // 与两行滑块(标签 + 轨道 + 数值 + 复位按钮),右侧还要给滚动条留位。 + const float groupWidth = kEdgePanelPad * 2.0f * m_scale + + kEdgePanelDisclosureWidth * m_scale + 6.0f * m_scale + + nameWidth + 6.0f * m_scale + + kEdgePanelMeanCheckSize * m_scale + + (kFreeMarkerScrollWidth + 4.0f) * m_scale; + width = std::max(width, groupWidth); + } std::size_t rising = 0; std::size_t falling = 0; bool complete = true; @@ -116,7 +127,7 @@ namespace jdi if (centerAxis || meanFilter) { // 参数行 = 两个「说明文字 + 数值框」,必须保证文字不被数值框压住。 - // (MEAN / CENTER:「均值点数 X」+「迭代次数 N」。标签与数值框各占半行。) + // (CENTER:「均值点数 X」+「迭代次数 N」。MEAN 已改为滑块,见下面单独的分支。) const float labelXWidth = UiMeasureTextWidth(m_fonts->uiSmall, L"均值点数 X"); const float labelN = UiMeasureTextWidth(m_fonts->uiSmall, L"迭代次数 N"); const float fieldWidth = @@ -127,6 +138,23 @@ namespace jdi width = std::max(width, kEdgePanelPad * 2.0f * m_scale + rowWidth); } + if (meanFilter) + { + // 两行滑块 = 「标签列 + 轨道(最小宽度)+ 数值列 + 复位按钮」(两行共用同一列宽), + // 右侧再给滚动条留位(上百个信号时内容必然超高)。 + const float labelColumn = + std::max(UiMeasureTextWidth(m_fonts->uiSmall, kMeanFilterPointsSliderLabel), + UiMeasureTextWidth(m_fonts->uiSmall, kMeanFilterIterationsSliderLabel)); + const float valueColumn = + std::max(UiMeasureTextWidth(m_fonts->monoSmall, L"100000"), + UiMeasureTextWidth(m_fonts->monoSmall, L"1000")); + const float sliderRowWidth = labelColumn + 6.0f * m_scale + kWeakPeakTrackMinWidth * m_scale + + 6.0f * m_scale + valueColumn + kWeakPeakResetGap * m_scale + + kWeakPeakResetButtonSize * m_scale; + width = std::max(width, kEdgePanelPad * 2.0f * m_scale + sliderRowWidth + + (kFreeMarkerScrollWidth + 4.0f) * m_scale); + } + if (weakPeak) { // 滑块行 = 「标签列 + 轨道(最小宽度)+ 数值列 + 复位按钮」(等级与迭代次数两行共用同一列宽)。 @@ -233,7 +261,9 @@ namespace jdi const bool weakPeak = (kind == EdgePanelKind::PEAK); const bool freeMarker = (kind == EdgePanelKind::MARK); // 「均值滤波」与「弱峰调整」共用同一套「逐条目 + 底部按钮」排布。 - const bool itemized = (centerAxis || meanFilter || weakPeak); + // ⚠️ 「均值滤波」从 2026-10-30 起改为**可滚动的分组列表**(信号色叠底标题栏 + + // 展开后的两行滑块),与「自由标记」同一套浏览方式 ⇒ 不再走这里的 `itemized` 分支。 + const bool itemized = (centerAxis || weakPeak); const bool hasButton = (meanFilter || weakPeak); const bool enabled = meanFilter ? lane.meanFilter : (weakPeak ? lane.weakPeak @@ -286,12 +316,43 @@ namespace jdi // + 参数部分(**只在该条目被勾选时才占位**,因此面板高度随勾选数量动态变化): // * MEAN / CENTER:一行「均值点数 X + 迭代次数 N」输入框(中心轴再多一行统计结果); // * PEAK:两行滑块(调整幅度 % / 迭代次数 N),每个信号各有一套自己的参数。 - const bool itemHasParams = (centerAxis || meanFilter || weakPeak); + const bool itemHasParams = (centerAxis || weakPeak); const float weakPeakSliderRow = kWeakPeakSliderRowHeight * m_scale; float meanRowHeight = 0.0f; float itemsHeight = 0.0f; + float meanContentHeight = 0.0f; float height = 0.0f; - if (itemized) + if (meanFilter) + { + // 「均值滤波」:内容区 = 逐信号的**信号色叠底标题栏**(内容总是显示, + // 用于勾选/取消预览)+(勾选且未收起时)两行参数滑块。 + // 内容超高时出现滚动条;底部「构建为新信号」按钮固定不滚动。 + const float groupRow = kEdgePanelMeanRowHeight * m_scale; + const float sliderRow = weakPeakSliderRow; + float content = 0.0f; + for (const SignalRef& ref : lane.signals) + { + const ResolvedSignal resolved = ResolveSignal(ref); + if (resolved.data == nullptr || !resolved.visible) + { + continue; + } + content += groupRow; + const Lane::MeanFilterItem* item = lane.FindMeanFilterItem(ref); + if (item != nullptr && !item->collapsed) + { + content += sliderRow * 2.0f; + } + } + meanContentHeight = content; + const float budget = m_plotRect.Height - (titleHeight + pad * 3.0f + buttonHeight); + const float maxContent = + std::max(3.0f * groupRow, + std::min(kFreeMarkerContentMaxHeight * m_scale, std::max(3.0f * groupRow, budget))); + const float view = std::min(content, maxContent); + height = titleHeight + pad + view + pad + buttonHeight + pad; + } + else if (itemized) { meanRowHeight = kEdgePanelMeanRowHeight * m_scale; for (const SignalRef& ref : lane.signals) @@ -501,7 +562,119 @@ namespace jdi { return layout; } - if (itemized) + if (meanFilter) + { + // 「均值滤波」:可滚动内容区(逐信号的信号色叠底标题栏 + 展开后的两行滑块)+ 底部按钮。 + const float buttonRowHeight = kEdgePanelButtonHeight * m_scale; + layout.button = gd::RectF(left + pad, top + height - pad - buttonRowHeight, + std::max(0.0f, width - pad * 2.0f), buttonRowHeight); + + const float groupRow = kEdgePanelMeanRowHeight * m_scale; + const float sliderRow = kWeakPeakSliderRowHeight * m_scale; + const float scrollW = kFreeMarkerScrollWidth * m_scale; + const float viewHeight = + std::max(0.0f, height - titleHeight - pad * 3.0f - buttonRowHeight); + const bool scrollable = meanContentHeight > viewHeight + 0.5f; + const float maxScroll = std::max(0.0f, meanContentHeight - viewHeight); + const float scroll = std::max(0.0f, std::min(lane.meanFilterScroll, maxScroll)); + const float innerWidth = + std::max(0.0f, width - pad * 2.0f - (scrollable ? (scrollW + 4.0f * m_scale) : 0.0f)); + layout.content = gd::RectF(left + pad, top + titleHeight + pad, innerWidth, viewHeight); + layout.markerContentHeight = meanContentHeight; + layout.scrollOffset = scroll; + if (scrollable) + { + layout.scrollTrack = + gd::RectF(left + pad + innerWidth + 4.0f * m_scale, layout.content.Y, scrollW, + viewHeight); + const float thumbHeight = + std::max(18.0f * m_scale, viewHeight * (viewHeight / meanContentHeight)); + const float thumbTop = + layout.content.Y + + (viewHeight - thumbHeight) * ((maxScroll > 0.0f) ? (scroll / maxScroll) : 0.0f); + layout.scrollThumb = gd::RectF(layout.scrollTrack.X, thumbTop, scrollW, thumbHeight); + } + + // 两行滑块的列宽(与 `EdgePanelWidth` 同一组算式 ⇒ 布局与宽度不会漂移)。 + const float labelColumn = + std::max(UiMeasureTextWidth(m_fonts->uiSmall, kMeanFilterPointsSliderLabel), + UiMeasureTextWidth(m_fonts->uiSmall, kMeanFilterIterationsSliderLabel)); + const float valueColumn = + std::max(UiMeasureTextWidth(m_fonts->monoSmall, L"100000"), + UiMeasureTextWidth(m_fonts->monoSmall, L"1000")); + const float resetSize = kWeakPeakResetButtonSize * m_scale; + const float resetGap = kWeakPeakResetGap * m_scale; + const float trackHeight = kWeakPeakTrackHeight * m_scale; + const float closeSize = kEdgePanelMeanCheckSize * m_scale; + const float contentLeft = layout.content.X; + const float trackLeft = contentLeft + labelColumn + 6.0f * m_scale; + const float resetLeft = contentLeft + innerWidth - resetSize; + const float trackWidth = + std::max(10.0f * m_scale, + (resetLeft - resetGap - valueColumn - 6.0f * m_scale) - trackLeft); + + float rowY = layout.content.Y - scroll; + for (const SignalRef& ref : lane.signals) + { + const ResolvedSignal resolved = ResolveSignal(ref); + if (resolved.data == nullptr || !resolved.visible) + { + continue; + } + const Lane::MeanFilterItem* item = lane.FindMeanFilterItem(ref); + const bool checked = (item != nullptr); + layout.rowRefs.push_back(ref); + // 组头(信号色叠底):整行宽度,左侧三角(仅勾选时)+ 名称,右端复选框。 + layout.rowHeaders.push_back( + gd::RectF(contentLeft, rowY, innerWidth, groupRow)); + layout.rowNameRects.push_back(gd::RectF(contentLeft, rowY, innerWidth, groupRow)); + layout.rowTriangles.push_back( + checked ? gd::RectF(contentLeft + 3.0f * m_scale, + rowY + (groupRow - kEdgePanelDisclosureWidth * m_scale) * 0.5f, + kEdgePanelDisclosureWidth * m_scale, + kEdgePanelDisclosureWidth * m_scale) + : gd::RectF()); + layout.rowChecks.push_back( + gd::RectF(contentLeft + innerWidth - closeSize - 4.0f * m_scale, + rowY + (groupRow - closeSize) * 0.5f, closeSize, closeSize)); + layout.rowPoints.push_back(gd::RectF()); + layout.rowIterations.push_back(gd::RectF()); + layout.rowStats.push_back(gd::RectF()); + rowY += groupRow; + + // 参数行:只有勾选且未收起时才有几何(未勾选的行两者都是空矩形)。 + if (!checked || item->collapsed) + { + layout.rowLevelTracks.push_back(gd::RectF()); + layout.rowLevelValues.push_back(gd::RectF()); + layout.rowLevelResets.push_back(gd::RectF()); + layout.rowIterTracks.push_back(gd::RectF()); + layout.rowIterValues.push_back(gd::RectF()); + layout.rowIterResets.push_back(gd::RectF()); + continue; + } + const gd::RectF pointsTrack(trackLeft, rowY + (sliderRow - trackHeight) * 0.5f, + trackWidth, trackHeight); + layout.rowLevelTracks.push_back(pointsTrack); + layout.rowLevelValues.push_back( + gd::RectF(pointsTrack.X + pointsTrack.Width + 6.0f * m_scale, rowY, valueColumn, + sliderRow)); + layout.rowLevelResets.push_back( + gd::RectF(resetLeft, rowY + (sliderRow - resetSize) * 0.5f, resetSize, resetSize)); + rowY += sliderRow; + + const gd::RectF iterTrack(trackLeft, rowY + (sliderRow - trackHeight) * 0.5f, + trackWidth, trackHeight); + layout.rowIterTracks.push_back(iterTrack); + layout.rowIterValues.push_back( + gd::RectF(iterTrack.X + iterTrack.Width + 6.0f * m_scale, rowY, valueColumn, + sliderRow)); + layout.rowIterResets.push_back( + gd::RectF(resetLeft, rowY + (sliderRow - resetSize) * 0.5f, resetSize, resetSize)); + rowY += sliderRow; + } + } + else if (itemized) { const float buttonRowHeight = kEdgePanelButtonHeight * m_scale; if (hasButton) @@ -1209,7 +1382,11 @@ namespace jdi double bestValue = 0.0; double bestEnum = 0.0; float bestY = 0.0f; - bool bestSnapped = false; + // 胜出信号的轴 / 数据 / 时间偏移:循环结束后**只为它**做一次「自动点线」稀疏判定 + // (判定代价与信号数量成正比,而它只影响一个小圆环 ⇒ 绝不能每个信号都算)。 + const TimeAxis* bestAxis = nullptr; + const SignalData* bestData = nullptr; + double bestOffsetTime = 0.0; for (const SignalRef& ref : lane.signals) { const ResolvedSignal resolved = ResolveSignal(ref); @@ -1218,9 +1395,10 @@ namespace jdi { continue; } - // 「自动点线」判据成立时吸附到最近采样点(与光标吸附同一规则)。 - // ⚠️ 用户明确要求:只要判据成立就吸附,**不**管当前是散点还是用户自己改过的样式。 - const bool sparse = MinorCellSparse(*resolved.axis, *resolved.data, resolved.style->offsetTime); + // 「自动点线」判据只用于决定要不要画“已吸附”圆环 ⇒ **不在循环里逐个信号算**: + // 它的代价是“每个小格两次二分”(视界 50 格 × 上百信号 = 数万次比较/帧), + // 而**只有最后胜出的那一个信号**需要它(用户 2026-10-30 报告的卡顿根因之一)。 + // 见循环结束后的统一判定。 // ---- ① 取“鼠标到这条曲线”的距离(复用绘制阶段的折线顶点)---- float nearestX = fx; @@ -1348,13 +1526,22 @@ namespace jdi ApplyDrawTransform(*resolved.style, candidateSample); if (std::isfinite(candidateValue)) { + // ⚠️⚠️ 距离必须包含**横向**分量(用户 2026-10-30 报告): + // 只看纵向距离时,一个“单数据点信号”只要它的点在鼠标的**高度**附近, + // 就会在整幅宽度内一直胜出 —— 光标被它“吸住”,无法标注另一个常规信号。 + // 改为鼠标到**该采样点屏幕位置**的欧氏距离后:稠密信号的横向分量≈ 0(仍等价于 + // “纵向最近”);单点/稀疏信号只有鼠标真的靠近它时才会被选中。 + const double pointX = static_cast(TimeToX(candidateTime)); + const double dx = pointX - static_cast(fx); const double vertical = std::fabs( static_cast(ValueToY(candidateValue, visual)) - static_cast(fy)); - if (!candidateOk || vertical < distance) + const double horizontal = std::fabs(dx); + const double sampleDistance = std::sqrt(vertical * vertical + horizontal * horizontal); + if (!candidateOk || sampleDistance < distance) { - distance = vertical; - nearestX = fx; + distance = sampleDistance; + nearestX = static_cast(pointX); time = candidateTime; sampled = candidateSample; candidateOk = true; @@ -1368,9 +1555,6 @@ namespace jdi continue; } // 固定点必须落在真实采样时刻上(两个候选都已吸附到采样点)。 - // 表示“已吸附”的圆环仍只在「自动点线」判据成立(`sparse`)时画: - // 密采样信号里吸附量远小于 1 像素,圆环会一直挂着,反而像是画错了。 - const bool snapped = sparse; const double value = ApplyDrawTransform(*resolved.style, sampled); if (!std::isfinite(value)) { @@ -1386,7 +1570,9 @@ namespace jdi bestValue = value; bestEnum = sampled; bestY = y; - bestSnapped = snapped; + bestAxis = resolved.axis; + bestData = resolved.data; + bestOffsetTime = resolved.style->offsetTime; probe.ref = ref; } @@ -1398,11 +1584,20 @@ namespace jdi } probe.valid = true; probe.time = bestTime; - probe.x = TimeToX(bestTime); + // ⚠️ 纵向光标**画在鼠标所在的 x**(用户 2026-10-30 明确要求:纵向光标跟随鼠标, + // 被标注的数据点才是"离鼠标最近"的那个)。旧实现把竖线画在吸附后的采样时刻, + // 于是稀疏/单点信号会让整条竖线"跳"到数据点上,观感像光标被吸走了。 + probe.x = fx; + probe.pointX = TimeToX(bestTime); probe.value = bestValue; probe.enumValue = bestEnum; probe.y = bestY; - probe.snapped = bestSnapped; + // 「已吸附」圆环只在「自动点线」稀疏判据成立时画(密采样信号里吸附量 < 1 像素, + // 圆环会一直挂着反而像画错了)。**只对胜出的这一个信号算**(见循环里的说明)。 + if (bestAxis != nullptr && bestData != nullptr) + { + probe.snapped = MinorCellSparse(*bestAxis, *bestData, bestOffsetTime); + } return probe; } diff --git a/Components/PlotView/Src/PlotViewRaster.cpp b/Components/PlotView/Src/PlotViewRaster.cpp index 7564b98..040edec 100644 --- a/Components/PlotView/Src/PlotViewRaster.cpp +++ b/Components/PlotView/Src/PlotViewRaster.cpp @@ -1446,7 +1446,7 @@ namespace jdi g.SetTextRenderingHint(gd::TextRenderingHintClearTypeGridFit); UiFillRect(g, m_area, m_theme->plotBg); - UiFillRect(g, m_area, m_theme->cardBg); + UiFillRect(g, m_area, ThemeLaneBackground(*m_theme)); // 竖向网格刻度沿用**前台视界跨度**(而非宽窗口的跨度),否则竖网格线会只有一半。 // ⚠️ 同时把它作为竖向网格的**主刻步长**传给 `PaintLane`(见 `m_renderTimeStep`): @@ -1948,7 +1948,7 @@ namespace jdi //(用户 2026-10-17 报告:滚动过渡动画在巨型信号处卡顿)。 // 这一情形在滚动到**从未构建过图层的区域**时必然出现(上百个网格时绝大多数 // 网格的图层都还没建立):旧实现在这里 `return false` ⇒ 落到 `Paint` 末尾的 - // **整幅重绘** ⇒ 把**本帧全部可见网格的波形**(可能包含上千�万采样的巨型信号) + // **整幅重绘** ⇒ 把**本帧全部可见网格的波形**(可能包含上千万采样的巨型信号) // 每帧重画一遍 —— 实测单帧 **20~35ms**,而手动拖滚动条只要 ~6ms。 // 正确行为:本帧只画**网格底衬**(廉价,见下方),波形等视界真正停下之后再按预算补 //(这正是用户要求的“没来得及画信号时就按只有网格叠底的状态继续滚动”)。 diff --git a/Components/Shared/Inc/PaintUtil.hpp b/Components/Shared/Inc/PaintUtil.hpp index 0863ad2..fdcf69f 100644 --- a/Components/Shared/Inc/PaintUtil.hpp +++ b/Components/Shared/Inc/PaintUtil.hpp @@ -355,6 +355,20 @@ namespace jdi void UiFillRoundRects(gd::Graphics& g, const std::vector& rects, float radius, std::uint32_t argb); + /** + * @brief 绘制「还原 / 重置」图标(逆时针回环箭头)。 + * + * **唯一实现**:弹出菜单标题栏的还原图标(`PopupMenu`)、弱峰调整面板每行滑块的复位控件、 + * 信号样式区「线宽 / 透明度」两行右侧的复位按钮全部调用它,因此同一语义在任何位置 + * 都是同一套笔画,任意 DPI 下都清晰(不依赖字体或图标资源)。 + * + * @param [IN,OUT] g 绘图上下文。 + * @param [IN] rect 图标外接矩形(正方形;边长为 0 时不做任何绘制)。 + * @param [IN] argb 笔画颜色(0xAARRGGBB)。 + * @param [IN] scale DPI 缩放比例。 + */ + void UiPaintResetIcon(gd::Graphics& g, const gd::RectF& rect, std::uint32_t argb, float scale); + } // namespace jdi #endif // __PAINT_UTIL_HPP__ diff --git a/Components/Shared/Inc/PopupMenuInternal.hpp b/Components/Shared/Inc/PopupMenuInternal.hpp index 0c56dd5..b17a43b 100644 --- a/Components/Shared/Inc/PopupMenuInternal.hpp +++ b/Components/Shared/Inc/PopupMenuInternal.hpp @@ -18,6 +18,7 @@ #include #include "Theme.hpp" +#include "PaintUtil.hpp" namespace jdi { @@ -63,36 +64,8 @@ namespace jdi */ inline void PaintResetIcon(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); + // 唯一实现见 `PaintUtil.hpp`(信号样式区的复位按钮、弱峰调整面板的复位控件共用)。 + UiPaintResetIcon(g, rect, color, scale); } } // namespace pmdetail diff --git a/Components/Shared/Inc/Theme.hpp b/Components/Shared/Inc/Theme.hpp index 24f67e6..88ff681 100644 --- a/Components/Shared/Inc/Theme.hpp +++ b/Components/Shared/Inc/Theme.hpp @@ -74,6 +74,51 @@ namespace jdi return result; } + /** + * @brief 颜色的**相对亮度**(WCAG 2.x 定义:sRGB 线性化后按 0.2126/0.7152/0.0722 加权)。 + * @param [IN] argb 颜色值(只看 RGB 通道)。 + * @return 相对亮度,取值 0(黑)… 1(白)。 + */ + double SrgbLuminance(std::uint32_t argb); + + /** + * @brief 两色之间的**对比度**(WCAG 2.x 定义:`(L1 + 0.05) / (L2 + 0.05)`)。 + * @param [IN] firstColor 第一色。 + * @param [IN] secondColor 第二色。 + * @return 对比度,取值 1(完全相同)… 21(黑白)。 + */ + double ContrastRatio(std::uint32_t firstColor, std::uint32_t secondColor); + + /** + * @brief 保证前景色相对底色有**足够对比度**:不足时沿"远离底色"的方向混向黑/白。 + * + * 用途:绘图区内部的网格线、时间轴等分线、分界线等原本按某一个主题手工调色的元素, + * 必须跟着主题背景色自动适配(浅底上加深、深底上提亮),否则换到深色主题后 + * 网格线只剩几个色阶、几乎看不见。 + * + * ⚠️ **已达标时逐位返回原色**:Solarised Light 的历史配色是人工调过的, + * 只要它满足阈值就必须保持逐像素不变(避免"换主题修复"顺带改变默认主题观感)。 + * + * @param [IN] background 底色(不透明)。 + * @param [IN] foreground 前景色(不透明)。 + * @param [IN] minRatio 期望的最小对比度(≥ 1,1 表示不限制)。 + * @return 满足阈值的颜色(RGB 不变、alpha = 0xFF)。 + */ + std::uint32_t EnsureContrast(std::uint32_t background, std::uint32_t foreground, double minRatio); + + /** + * @brief 把颜色沿"亮度"插值到目标相对亮度(保持色相,二分求解混色比例)。 + * + * 比"固定比例混向黑/白"更可控:调用方指定**目标亮度**,本函数负责求出混色量。 + * 目标亮度高于当前亮度时混向白、低于时混向黑;求解连续 ⇒ 输入微调时输出也连续 + *(不会在两个极端色之间跳变)。 + * + * @param [IN] color 原色(不透明)。 + * @param [IN] targetLuminance 目标相对亮度(0…1)。 + * @return 亮度接近目标值的颜色(无法达到时取该方向上的极限)。 + */ + std::uint32_t MixToLuminance(std::uint32_t color, double targetLuminance); + /** * @brief 界面主题色板(Solarised Light 派生)。 */ @@ -152,6 +197,22 @@ namespace jdi */ void SetCurrentTheme(const Theme* theme); + /** + * @brief 取「绘图区网格底色」——**网格叠底**(每条网格内部的底色)。 + * + * 它与 `Theme::plotBg`(绘图区整体底色)分开:网格之外的时间轴/留白区用 `plotBg`, + * 网格内部用本函数。当两者过于接近时,本函数会把网格底色**轻轻地**往远离绘图区 + * 底色的方向挪一点(仅供深色主题的 Solarised Dark 这类"两者完全同色"的情形), + * 保证相邻网格仍能被分界线以外的底色层次区分开。 + * + * ⚠️ 两者本来就有可辨识的亮度差时**逐位返回 `cardBg`**(Solarised Light / Light / Dark + * 的默认观感不变)。 + * + * @param [IN] theme 主题。 + * @return 网格底色(不透明)。 + */ + std::uint32_t ThemeLaneBackground(const Theme& theme); + /** * @brief 获取波形默认配色表。 * @param [OUT] count 配色数量输出。 diff --git a/Components/Shared/Src/PaintUtil.cpp b/Components/Shared/Src/PaintUtil.cpp index ee1aaac..7988664 100644 --- a/Components/Shared/Src/PaintUtil.cpp +++ b/Components/Shared/Src/PaintUtil.cpp @@ -676,4 +676,40 @@ namespace jdi g.ReleaseHDC(hdc); } + void UiPaintResetIcon(gd::Graphics& g, const gd::RectF& rect, std::uint32_t argb, float scale) + { + if (rect.Width <= 0.0f || rect.Height <= 0.0f) + { + return; + } + const float inset = 2.5f * scale; + const 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; + } + const gd::Color color = ToGdiColor(argb); + gd::Pen pen(color, 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(color); + g.FillPolygon(&brush, arrow, 3); + } + } // namespace jdi diff --git a/Components/Shared/Src/Theme.cpp b/Components/Shared/Src/Theme.cpp index 4ab0162..100463d 100644 --- a/Components/Shared/Src/Theme.cpp +++ b/Components/Shared/Src/Theme.cpp @@ -1,5 +1,8 @@ #include "Theme.hpp" +#include +#include + namespace jdi { @@ -32,8 +35,32 @@ namespace jdi MakeArgb(kBase01), MakeArgb(kBase0), }; - /** @brief 全局唯一主题实例。 */ - const Theme kSolarizedLight = [] + /** + * @brief 把绘图区内部的"结构性"颜色按**底色**自动校正到足够对比(见 `EnsureContrast`)。 + * + * 阈值(对比度)取"Solarised Light 现有配色刚好达标"的水位: + * 该主题的历史配色是人工调过的,达标时 `EnsureContrast` 逐位返回原色 ⇒ 默认主题 + * **一个像素都不会变**;其它主题(尤其深色)则自动把网格线/时间轴/分界线提亮。 + * + * | 字段 | 阈值 | Solarised Light 现状 | + * | --- | ---: | ---: | + * | `gridWeak`(网格主刻线 + 五等分线基色) | 1.30 | 约 1.38 | + * | `gridStrong`(零线/加重线) | 1.65 | 约 1.80 | + * | `axisLine`(时间轴、网格分界线、绘图区外框) | 2.10 | 约 2.43 | + * | `borderStrong`(网格间分界线) | 1.70 | 约 2.43 | + */ + Theme NormalizePlotContrast(Theme theme) + { + const std::uint32_t plot = theme.plotBg | 0xFF000000u; + theme.gridWeak = EnsureContrast(plot, theme.gridWeak | 0xFF000000u, 1.30); + theme.gridStrong = EnsureContrast(plot, theme.gridStrong | 0xFF000000u, 1.65); + theme.axisLine = EnsureContrast(plot, theme.axisLine | 0xFF000000u, 2.10); + theme.borderStrong = EnsureContrast(plot, theme.borderStrong | 0xFF000000u, 1.70); + return theme; + } + + /** @brief 全局唯一主题实例(Solarised Light;历史默认主题)。 */ + const Theme kSolarizedLight = NormalizePlotContrast([] { Theme theme; theme.windowBg = MakeArgb(kBase3); @@ -65,7 +92,7 @@ namespace jdi theme.danger = MakeArgb(kRed); theme.warning = MakeArgb(kYellow); return theme; - }(); + }()); /** * @brief 浅色主题(参考 VS Code 2026 的默认浅色配色)。 @@ -73,7 +100,7 @@ namespace jdi * 与 Solarised Light 的区别:底色是**中性白/浅灰**而不是暖米色, * 强调色是 VS Code 的系统蓝 `#0078D4`,描边是中性灰。 */ - const Theme kLight = [] + const Theme kLight = NormalizePlotContrast([] { Theme theme; theme.windowBg = MakeArgb(0xFFFFFFu); // editor.background @@ -96,6 +123,7 @@ namespace jdi theme.selection = MakeArgb(0xE8E8E8u); // 白底上网格线必须够淡(否则抢波形),但主刻线要能与次级线区分。 + // ⚠️ 这三个颜色会被 `NormalizePlotContrast` 按白底自动校正到可见水位。 theme.gridWeak = MakeArgb(0xEDEDEDu); theme.gridStrong = MakeArgb(0xD6D6D6u); theme.axisLine = MakeArgb(0xCECECEu); @@ -104,12 +132,12 @@ namespace jdi theme.danger = MakeArgb(0xE51400u); // errorForeground theme.warning = MakeArgb(0xBF8803u); return theme; - }(); + }()); /** * @brief 深色主题(参考 VS Code Dark+)。 */ - const Theme kDark = [] + const Theme kDark = NormalizePlotContrast([] { Theme theme; theme.windowBg = MakeArgb(0x1E1E1Eu); // editor.background @@ -131,6 +159,8 @@ namespace jdi theme.pressed = MakeArgb(0x37373Du); theme.selection = MakeArgb(0x04395Eu); + // ⚠️ 深色底上的原始取值几乎与底色同亮度(差 2~3 个色阶)⇒ 交给 + // `NormalizePlotContrast` 按 `plotBg` 自动提亮到可见水位。 theme.gridWeak = MakeArgb(0x2B2B2Bu); theme.gridStrong = MakeArgb(0x3A3A3Au); theme.axisLine = MakeArgb(0x4A4A4Au); @@ -139,7 +169,7 @@ namespace jdi theme.danger = MakeArgb(0xF44747u); theme.warning = MakeArgb(0xCCA700u); return theme; - }(); + }()); /** * @brief Solarised Dark(参考 VS Code 的 Solarized Dark)。 @@ -147,7 +177,7 @@ namespace jdi * 底色用 base03、面板用 base02,强调色仍是 Solarised 那一套 —— 因此波形配色表 * (`SignalPalette`)与 Solarised Light 完全一致。 */ - const Theme kSolarizedDark = [] + const Theme kSolarizedDark = NormalizePlotContrast([] { Theme theme; theme.windowBg = MakeArgb(kBase03); @@ -177,12 +207,121 @@ namespace jdi theme.danger = MakeArgb(kRed); theme.warning = MakeArgb(kYellow); return theme; - }(); + }()); /** @brief 全局「当前主题」指针(由宿主设置,见 `SetCurrentTheme`)。 */ const Theme* g_currentTheme = &kSolarizedLight; } + double SrgbLuminance(std::uint32_t argb) + { + // sRGB → 线性光:> 0.03928 用 2.4 次幂,否则用 12.92 线性段(WCAG 2.x)。 + const auto channel = [](std::uint32_t value) -> double + { + const double c = static_cast(value & 0xFFu) / 255.0; + return (c <= 0.03928) ? (c / 12.92) : std::pow((c + 0.055) / 1.055, 2.4); + }; + const double r = channel((argb >> 16) & 0xFFu); + const double g = channel((argb >> 8) & 0xFFu); + const double b = channel(argb & 0xFFu); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; + } + + double ContrastRatio(std::uint32_t firstColor, std::uint32_t secondColor) + { + const double first = SrgbLuminance(firstColor); + const double second = SrgbLuminance(secondColor); + const double high = std::max(first, second); + const double low = std::min(first, second); + return (high + 0.05) / (low + 0.05); + } + + std::uint32_t EnsureContrast(std::uint32_t background, std::uint32_t foreground, double minRatio) + { + if (!(minRatio > 1.0) || ContrastRatio(background, foreground) >= minRatio) + { + return foreground; // 已达标(或不限制):**逐位返回原色** + } + // 远离底色:深底就提亮、浅底就加深。二分求解"刚好达标"的混色量, + // 使输出随底色**连续**变化(不会在阈值附近两个极端色之间跳)。 + const bool lighten = SrgbLuminance(background) < 0.5; + const std::uint32_t extreme = lighten ? 0xFFFFFFFFu : 0xFF000000u; + double low = 0.0; + double high = 1.0; + for (int iteration = 0; iteration < 18; ++iteration) + { + const double middle = (low + high) * 0.5; + if (ContrastRatio(background, MixColor(foreground, extreme, middle)) < minRatio) + { + low = middle; + } + else + { + high = middle; + } + } + return MixColor(foreground | 0xFF000000u, extreme, high); + } + + std::uint32_t MixToLuminance(std::uint32_t color, double targetLuminance) + { + const std::uint32_t base = color | 0xFF000000u; + const double current = SrgbLuminance(base); + if (!(targetLuminance > 0.0)) + { + targetLuminance = 0.0; + } + if (targetLuminance > 1.0) + { + targetLuminance = 1.0; + } + if (std::fabs(targetLuminance - current) < 1.0e-4) + { + return base; + } + // 亮度沿"混向白 / 混向黑"单调 ⇒ 二分解出混色量; + // 目标亮度等于当前亮度时混色量为 0(等价返回原色),因此两侧在该点**连续衔接**。 + const std::uint32_t extreme = (targetLuminance > current) ? 0xFFFFFFFFu : 0xFF000000u; + double low = 0.0; + double high = 1.0; + for (int iteration = 0; iteration < 20; ++iteration) + { + const double middle = (low + high) * 0.5; + const double luminance = SrgbLuminance(MixColor(base, extreme, middle)); + if (targetLuminance > current) + { + if (luminance < targetLuminance) + { + low = middle; + } + else + { + high = middle; + } + } + else + { + if (luminance > targetLuminance) + { + low = middle; + } + else + { + high = middle; + } + } + } + return MixColor(base, extreme, high); + } + + std::uint32_t ThemeLaneBackground(const Theme& theme) + { + // 网格底色必须与绘图区底色**可辨**(否则几十条网格看起来是一整片)。 + // 用统一的对比度阈值 1.03:本就有差异的主题(Solarised Light / Light / Dark) + // 逐位返回 `cardBg`;两侧完全同色的主题(Solarised Dark 的 base03)会被轻轻推开。 + return EnsureContrast(theme.plotBg | 0xFF000000u, theme.cardBg | 0xFF000000u, 1.03); + } + const Theme& ThemeFor(ThemeKind kind) { switch (kind) diff --git a/Components/SignalPanel/Inc/SignalPanel.hpp b/Components/SignalPanel/Inc/SignalPanel.hpp index d96ed2d..900a376 100644 --- a/Components/SignalPanel/Inc/SignalPanel.hpp +++ b/Components/SignalPanel/Inc/SignalPanel.hpp @@ -502,6 +502,20 @@ namespace jdi OPACITY_VALUE, /**< 不透明度数值框(可点击输入)。 */ WIDTH_SLIDER, /**< 线宽滑块。 */ WIDTH_VALUE, /**< 线宽数值框(可点击输入)。 */ + /** + * @brief 线宽行右侧的**复位按钮**(把线宽恢复为该信号的默认值)。 + * + * 默认值:普通信号 = `kDefaultSignalLineWidth`(1.0 逻辑像素), + * 单数据点信号 = `kSinglePointSignalLineWidth`(最大线宽:散点半径随线宽增长)。 + * 图标与「系数/偏移」二级菜单标题栏的还原图标**完全同款**(`UiPaintResetIcon`)。 + */ + WIDTH_RESET, + /** + * @brief 透明度行右侧的**复位按钮**(把不透明度恢复为 100%)。 + * + * 与 `WIDTH_RESET` 同一套图标与悬停观感,两者右边界与内容列右缘对齐。 + */ + OPACITY_RESET, GAIN_ADJUST, /**< 「系数」按钮(点击展开系数二级面板)。 */ MIRROR_ADJUST, /**< 「镜像」按钮(点击展开镜像二级面板)。 */ ABSOLUTE_TOGGLE, /**< 「绝对值」切换按钮。 */ @@ -1228,6 +1242,21 @@ namespace jdi */ bool ApplyOpacityFromX(float x); + /** + * @brief 把当前目标信号的**线宽恢复为默认值**(「线宽」行右侧复位按钮)。 + * + * 默认值与新建信号时同一来源(`DefaultSignalLineWidth(finiteCount)`): + * 普通信号 1.0 逻辑像素、**单数据点信号**取最大线宽(散点半径随线宽增长)。 + * @return 取值发生变化返回 true。 + */ + bool ResetStyleLineWidth(); + + /** + * @brief 把当前目标信号的**不透明度恢复为 100%**(「透明度」行右侧复位按钮)。 + * @return 取值发生变化返回 true。 + */ + bool ResetStyleOpacity(); + /** * @brief 断言某「样式」选项对当前目标信号是否可用(不可用时置灰且不可点)。 * @@ -1285,6 +1314,15 @@ namespace jdi gd::RectF m_opacityValueRect; /**< 不透明度数值文本矩形。 */ gd::RectF m_widthTrackRect; /**< 线宽滑块轨道矩形。 */ gd::RectF m_widthValueRect; /**< 线宽数值文本矩形(双击可输入)。 */ + /** + * @brief 线宽行右侧的**复位按钮**矩形(正方形,右边界与内容列右缘对齐)。 + * + * 点击把该信号的线宽恢复为默认值(单数据点信号与普通信号的默认线宽不同)。 + * 宽高为 0 表示当前不显示(没有目标信号)。 + */ + gd::RectF m_widthResetRect; + /** @brief 透明度行右侧的**复位按钮**矩形(与 `m_widthResetRect` 同一列、同一尺寸)。 */ + gd::RectF m_opacityResetRect; /** 「调整」行的三个等宽按钮(下标 = 内部常量 `kAdjust*`):0 = 系数/偏移,1 = 镜像,2 = 绝对值。 */ std::array m_adjustRects; diff --git a/Components/SignalPanel/Inc/SignalPanelInternal.hpp b/Components/SignalPanel/Inc/SignalPanelInternal.hpp index ae8b33a..b3527f0 100644 --- a/Components/SignalPanel/Inc/SignalPanelInternal.hpp +++ b/Components/SignalPanel/Inc/SignalPanelInternal.hpp @@ -174,8 +174,15 @@ namespace jdi inline const float kSwatchMaxGap = 10.0f; /**< 颜色色块最大间距(用于铺满内容列)。 */ inline const float kSliderValueWidth = 52.0f; /**< 滑块行数值文本宽度(透明度与线宽一致)。 */ inline const float kSliderValueGap = 8.0f; /**< 滑块轨道与数值文本间距。 */ - inline const float kTrackHeight = 6.0f; /**< 滑块轨道高度。 */ - inline const float kAdjustGap = 6.0f; /**< 「调整」行三个按钮之间的间距。 */ + inline const float kTrackHeight = 6.0f; /**< 滑块轨道高度。 */ /** + * @brief 「线宽 / 透明度」行右侧**复位按钮**的边长(逻辑像素)。 + * + * 与「系数/偏移」二级菜单标题栏的还原图标同一观感(`UiPaintResetIcon`), + * 因此尺寸也取同一量级;行高不足时会被夹到行高。 + */ + inline const float kSliderResetSize = 16.0f; + /** @brief 数值文本与复位按钮之间的间距。 */ + inline const float kSliderResetGap = 4.0f; inline const float kAdjustGap = 6.0f; /**< 「调整」行三个按钮之间的间距。 */ inline const float kAdjustMenuGap = 4.0f; /**< 「系数/偏移」「镜像」二级面板与按钮左上角的额外间距。 */ /** 「调整」行的按钮下标(与 `m_adjustRects` 同序)。 */ diff --git a/Components/SignalPanel/Src/SignalPanel.cpp b/Components/SignalPanel/Src/SignalPanel.cpp index 9dfe6ee..7872bde 100644 --- a/Components/SignalPanel/Src/SignalPanel.cpp +++ b/Components/SignalPanel/Src/SignalPanel.cpp @@ -119,8 +119,10 @@ namespace jdi } boxes.Add(m_widthTrackRect, 3); boxes.Add(m_widthValueRect, 3); + boxes.Add(m_widthResetRect, 3); boxes.Add(m_opacityTrackRect, 3); boxes.Add(m_opacityValueRect, 3); + boxes.Add(m_opacityResetRect, 3); // ── 「目标」行(样式区顶部,展示当前选中信号) ──────────────────── boxes.Add(m_targetRowRect, 2); @@ -972,19 +974,35 @@ namespace jdi } // ---- 线宽行与透明度行:几何完全一致,保证纵向宽度对齐(线宽在上、透明度在下)---- - const float valueWidth = std::min(kSliderValueWidth * m_scale, contentWidth * 0.4f); - const float trackWidth = std::max(24.0f, contentWidth - valueWidth - kSliderValueGap * m_scale); + // 两行右端各有一个**复位按钮**(用户 2026-10-29 要求): + // 按钮贴齐内容列右缘,数值文本与轨道在它左侧依次收缩 ⇒ 不会超出内容列。 + // 内容列太窄(放不下"轨道 + 数值 + 按钮")时整列不显示复位按钮,避免把滑块挤没。 + const float resetSize = (contentWidth >= kSliderResetSize * 3.0f * m_scale) + ? std::min(kSliderResetSize * m_scale, rowHeight) + : 0.0f; + const float resetGap = (resetSize > 0.0f) ? kSliderResetGap * m_scale : 0.0f; + const float sliderWidth = contentWidth - (resetSize > 0.0f ? (resetSize + resetGap) : 0.0f); + const float valueWidth = std::min(kSliderValueWidth * m_scale, sliderWidth * 0.4f); + const float trackWidth = std::max(24.0f, sliderWidth - valueWidth - kSliderValueGap * m_scale); const float trackHeight = kTrackHeight * m_scale; + const float valueLeft = contentLeft + trackWidth + kSliderValueGap * m_scale; + const float resetLeft = contentLeft + contentWidth - resetSize; m_widthTrackRect = gd::RectF(contentLeft, widthRow.Y + (rowHeight - trackHeight) * 0.5f, trackWidth, trackHeight); - m_widthValueRect = gd::RectF(contentLeft + trackWidth + kSliderValueGap * m_scale, widthRow.Y, - valueWidth, rowHeight); + m_widthValueRect = gd::RectF(valueLeft, widthRow.Y, valueWidth, rowHeight); + m_widthResetRect = + (resetSize > 0.0f) + ? gd::RectF(resetLeft, widthRow.Y + (rowHeight - resetSize) * 0.5f, resetSize, resetSize) + : gd::RectF(); m_opacityTrackRect = gd::RectF(contentLeft, opacityRow.Y + (rowHeight - trackHeight) * 0.5f, trackWidth, trackHeight); - m_opacityValueRect = gd::RectF(contentLeft + trackWidth + kSliderValueGap * m_scale, opacityRow.Y, - valueWidth, rowHeight); + m_opacityValueRect = gd::RectF(valueLeft, opacityRow.Y, valueWidth, rowHeight); + m_opacityResetRect = + (resetSize > 0.0f) + ? gd::RectF(resetLeft, opacityRow.Y + (rowHeight - resetSize) * 0.5f, resetSize, resetSize) + : gd::RectF(); // 「目标」行的子几何依赖目标信号的名称与可见性(可能在未重排时变化), // 因此每次重排后让缓存失效,下一次绘制 / 命中测试时按需重算。 diff --git a/Components/SignalPanel/Src/SignalPanelEdit.cpp b/Components/SignalPanel/Src/SignalPanelEdit.cpp index 8b06438..ca1aeb4 100644 --- a/Components/SignalPanel/Src/SignalPanelEdit.cpp +++ b/Components/SignalPanel/Src/SignalPanelEdit.cpp @@ -65,7 +65,9 @@ namespace jdi if (UiPointInRect(m_adjustRects[kAdjustGain], fx, fy) || UiPointInRect(m_adjustRects[kAdjustMirror], fx, fy) || UiPointInRect(m_adjustRects[kAdjustAbsolute], fx, fy) || UiPointInRect(m_widthValueRect, fx, fy) || - UiPointInRect(m_opacityValueRect, fx, fy)) + UiPointInRect(m_opacityValueRect, fx, fy) || + (m_widthResetRect.Width > 0.0f && UiPointInRect(m_widthResetRect, fx, fy)) || + (m_opacityResetRect.Width > 0.0f && UiPointInRect(m_opacityResetRect, fx, fy))) { return LoadCursorW(nullptr, IDC_HAND); } @@ -652,4 +654,51 @@ namespace jdi return true; } + bool SignalPanel::ResetStyleLineWidth() + { + if (m_dataSet == nullptr || m_dataSetWritable == nullptr) + { + return false; + } + SignalStyle* style = StyleOf(SelectedRef()); + if (style == nullptr) + { + return false; + } + const SignalData* signal = m_dataSet->Resolve(SelectedRef()); + if (signal == nullptr) + { + return false; + } + // 默认线与新建信号同源(`DefaultSignalLineWidth`):单数据点信号用最大线宽, + // 其余用 `kDefaultSignalLineWidth`(用户明确要求"单值信号和普通信号的默认线宽不一样")。 + const float target = DefaultSignalLineWidth(signal->finiteCount); + if (std::fabs(style->lineWidth - target) < 0.001f) + { + return false; + } + style->lineWidth = target; + return true; + } + + bool SignalPanel::ResetStyleOpacity() + { + if (m_dataSetWritable == nullptr) + { + return false; + } + SignalStyle* style = StyleOf(SelectedRef()); + if (style == nullptr) + { + return false; + } + if (std::fabs(style->opacity - 1.0f) < 0.001f) + { + return false; + } + style->opacity = 1.0f; + return true; + } + } // namespace jdi + diff --git a/Components/SignalPanel/Src/SignalPanelInteraction.cpp b/Components/SignalPanel/Src/SignalPanelInteraction.cpp index 208c7f7..b3919a9 100644 --- a/Components/SignalPanel/Src/SignalPanelInteraction.cpp +++ b/Components/SignalPanel/Src/SignalPanelInteraction.cpp @@ -195,6 +195,17 @@ namespace jdi { hoverStyle = StyleHit::WIDTH_VALUE; } + // 两行右侧的复位按钮(在数值框右边,命中区不重叠,但顺序放在数值之后更安全)。 + if (hoverStyle == StyleHit::NONE && m_widthResetRect.Width > 0.0f && + UiPointInRect(m_widthResetRect, fx, fy)) + { + hoverStyle = StyleHit::WIDTH_RESET; + } + if (hoverStyle == StyleHit::NONE && m_opacityResetRect.Width > 0.0f && + UiPointInRect(m_opacityResetRect, fx, fy)) + { + hoverStyle = StyleHit::OPACITY_RESET; + } if (hoverStyle == StyleHit::NONE && UiPointInRect(m_adjustRects[kAdjustGain], fx, fy)) { hoverStyle = StyleHit::GAIN_ADJUST; @@ -424,6 +435,20 @@ namespace jdi ScrollToSignalAnimated(SelectedRef()); return BeginEdit(StyleEditField::OPACITY); } + // 两行右侧的**复位按钮**:把该行参数恢复为默认值(线宽按信号类型取默认、 + // 不透明度回 100%)。与「系数/偏移」还原图标同一交互语义:就地回显、不弹任何东西。 + if (m_widthResetRect.Width > 0.0f && UiPointInRect(m_widthResetRect, fx, fy)) + { + ResetStyleLineWidth(); + ScrollToSignalAnimated(SelectedRef()); + return true; + } + if (m_opacityResetRect.Width > 0.0f && UiPointInRect(m_opacityResetRect, fx, fy)) + { + ResetStyleOpacity(); + ScrollToSignalAnimated(SelectedRef()); + return true; + } // 「调整」行:系数与镜像(按下只登记,**抬起**时才展开各自的二级面板, // 与自定义颜色入口同一套交互)、绝对值(单击即切换)。 if (UiPointInRect(m_adjustRects[kAdjustGain], fx, fy)) @@ -821,6 +846,12 @@ namespace jdi { return false; } + // 线宽 / 透明度两行右侧的复位按钮是"单次点击即生效"的按钮:双击不得被解释成别的语义。 + if ((m_widthResetRect.Width > 0.0f && UiPointInRect(m_widthResetRect, fx, fy)) || + (m_opacityResetRect.Width > 0.0f && UiPointInRect(m_opacityResetRect, fx, fy))) + { + return false; + } UpdateTargetRowGeometry(); if (m_targetNameRect.Width > 0.0f && UiPointInRect(m_targetNameRect, fx, fy)) { diff --git a/Components/SignalPanel/Src/SignalPanelPaint.cpp b/Components/SignalPanel/Src/SignalPanelPaint.cpp index 68fbd64..563bea7 100644 --- a/Components/SignalPanel/Src/SignalPanelPaint.cpp +++ b/Components/SignalPanel/Src/SignalPanelPaint.cpp @@ -768,6 +768,9 @@ namespace jdi m_theme->textMuted, TextAlign::CENTER); return; } + // 目标信号本身:线宽复位按钮需要它的有效采样点数(单点信号默认用最大线宽)。 + const SignalData* signal = + (m_dataSet != nullptr) ? m_dataSet->Resolve(SelectedRef()) : nullptr; const float labelWidth = kStyleLabelWidth * m_scale; @@ -779,6 +782,27 @@ namespace jdi m_theme->textMuted, TextAlign::LEFT); }; + // 「线宽 / 透明度」两行右侧的**复位按钮**(与「系数/偏移」二级菜单标题栏的还原图标同款)。 + // `changed` = 当前值是否已偏离默认值:偏离时才用强调色(一眼看出"这里可以点"), + // 已是默认值时用弱色(仍可点,但结果不变)。 + auto paintStyleReset = [&](const gd::RectF& rect, StyleHit hit, bool changed) + { + if (rect.Width <= 0.0f || rect.Height <= 0.0f) + { + return; + } + const bool hot = (m_hoverStyle == hit); + if (hot) + { + UiFillRoundRect(g, gd::RectF(rect.X - 1.0f * m_scale, rect.Y - 1.0f * m_scale, + rect.Width + 2.0f * m_scale, rect.Height + 2.0f * m_scale), + 3.0f * m_scale, WithAlpha(m_theme->accent, 0x33)); + } + UiPaintResetIcon(g, rect, hot ? m_theme->accent + : (changed ? m_theme->text : m_theme->textMuted), + m_scale); + }; + // ---- 第一行:颜色(预设色块 + 自定义颜色入口)---- { paintRowLabel(0, L"颜色"); @@ -888,7 +912,7 @@ namespace jdi paintToggle(kAdjustAbsolute, L"绝对值", style->absolute, StyleHit::ABSOLUTE_TOGGLE); } - // ---- 第四行:线宽(数值可点击输入)---- + // ---- 第四行:线宽(数值可点击输入;右侧为复位按钮)---- { paintRowLabel(3, L"线宽"); @@ -913,6 +937,9 @@ namespace jdi : fullText; UiText(g, shown, m_fonts->monoSmall, m_widthValueRect, m_theme->textStrong, TextAlign::RIGHT); } + paintStyleReset(m_widthResetRect, StyleHit::WIDTH_RESET, + style->lineWidth != + DefaultSignalLineWidth(signal != nullptr ? signal->finiteCount : 0)); } // ---- 第五行:透明度(数值可点击输入)---- @@ -933,6 +960,8 @@ namespace jdi UiText(g, std::to_wstring(percent) + L"%", m_fonts->monoSmall, m_opacityValueRect, m_theme->textStrong, TextAlign::RIGHT); } + paintStyleReset(m_opacityResetRect, StyleHit::OPACITY_RESET, + std::fabs(style->opacity - 1.0f) > 0.001f); } } diff --git a/Documents/agent_mem.md b/Documents/agent_mem.md index c381826..a439c6e 100644 --- a/Documents/agent_mem.md +++ b/Documents/agent_mem.md @@ -8611,3 +8611,156 @@ PlotView:0 = 网格/图例/Y轴标尺/GX/时间轴/光标读数与取值胶囊 并把面板位置改到胶囊上方(`lane.freeMarkerPanel.x/y`)。 * `lbpanel` 的 "layer 1 只能是面板内容" 断言会随层号约定变化而失效:现在滚动条/回到最上方 在 `base + 2`,因此各组件必须按 `kLayersPerComponent = 3` 取层。 + +## 2026-10-30 第十六轮(本机):主题自适应配色 + 预览曲线连续配色 + 样式区复位按钮 + 均值滤波面板重做 + 面板缓存环带残留 + 调试层序 + 自由标记选取与性能 + +> 用户本轮七件事:① 四个主题里除 Solarised Light 外,网格叠底 / 时间轴大格与五等分线 / +> 派生预览曲线在深色主题下看不清 ⇒ 结构线要按**背景色**自适应,预览曲线要随信号色**线性**找到 +> 高对比位置(旧实现在两个极端色之间"黑白相"跳变);② 信号样式区「线宽 / 透明度」两行右侧 +> 加**复位按钮**(与"系数/偏移"菜单的还原图标同款,右边界对齐内容列,单点信号与普通信号默认线宽不同); +> ③ 「均值滤波」面板改成「信号色叠底标题栏 + 复选框 + 展开三角 + 两行滑块 + 复位 + 滚动」; +> ④ 中键移视界时浮动面板周围出现一圈旧波形像素;⑤ 显示布局边界时下层浮动面板的边框画到上层; +> ⑥ 网格里同时有单点信号与常规信号时,光标被单点"吸住";⑦ 上百信号合并到一个网格时自由标记很卡。 + +### 0. 仓库约定变动 + +* `.preflight/`(本机离屏探针)已按要求**重建**:`perf/build.ps1` + `perf/probe/*.cpp`。 + ⚠️ `build.ps1` 链接的是 `Build/Release/*.o`;**LTO=1 构建后必须加 `-Lto`**(补 + `-flto -flto-partition=one`),否则链接失败。 +* 新增三个补丁脚本(纯 ASCII,配合 UTF-8 数据文件):`patch_insert.ps1`(在匹配行之后插入)、 + `patch_range.ps1`(替换行区间)、`patch_line.ps1`(替换单行)。**用途见 §9**。 + +### 1. 主题自适应:结构线按底色自动校正(`Theme.cpp`) + +新增三个**唯一实现**的调色工具(`Theme.hpp`): + +| 函数 | 语义 | +| --- | --- | +| `SrgbLuminance(argb)` | WCAG 相对亮度(sRGB 线性化 + 0.2126/0.7152/0.0722) | +| `ContrastRatio(a, b)` | `(L1+0.05)/(L2+0.05)`,1…21 | +| `EnsureContrast(bg, fg, minRatio)` | 不足阈值时沿"远离底色"方向**二分解出最小混色量**;**已达标逐位返回原色** | +| `MixToLuminance(color, target)` | 把颜色沿亮度轴平滑推到目标相对亮度(保持色相) | +| `ThemeLaneBackground(theme)` | 网格叠底 = `EnsureContrast(plotBg, cardBg, 1.03)` | + +四个内置主题都过一遍 `NormalizePlotContrast()`(阈值取"Solarised Light 现有值刚好达标"的水位): + +| 字段 | 阈值 | Solarised Light 现状 | +| --- | ---: | ---: | +| `gridWeak`(网格主刻线 + 五等分线基色) | 1.30 | 1.38 | +| `gridStrong`(零线/加重线) | 1.65 | 1.80 | +| `axisLine`(时间轴 / 分界线 / 绘图区外框) | 2.10 | 2.43 | +| `borderStrong`(网格之间分界线) | 1.70 | 2.43 | + +* **Solarised Light 四个值逐位不变**(探针钉住),默认观感零变化;深色主题自动提亮。 +* 「网格叠底」改用 `ThemeLaneBackground()`(4 处:整幅重绘 / 宽基底图层 / 样式补丁 / 五等分线混色基底)。 + Solarised Dark 的 `cardBg` 与 `plotBg` 本来是同一个 base03 ⇒ 现在被拉开 **1.03**(亮度变化 ≈ 3%), + 网格这一层才看得出来。**Solarised Light / Light 也会被同样拉开 1.03**(原本完全同色)—— + 这是刻意的统一规则,观感变化极小。 + +### 2. 派生预览曲线:连续、双向可比 + +`HighContrastDerived(color, background)`(`PlotViewInternal.hpp`)**重写**: + +1. 方向 = 信号相对底色的那一侧继续远离(几乎同亮度时按底色明暗选一侧); +2. 候选一 = `EnsureContrast(bg, color, 3.2)`(与底色对比度达标),候选二 = 信号亮度 ± 0.18, + 取**更远离底色**者;3. `MixToLuminance` 平滑推到该亮度。 + +两个候选都是输入的连续函数(max/min)⇒ **拖拾色器 / 换预设色 / 切主题时颜色连续变化**, +不再出现旧实现"混 72% 黑还是白、按评分二选一"的**阈值跳变**(探针实测最大亮度跳变 0.012)。 + +⚠️ 调用点必须传**网格底色**(`ThemeLaneBackground(*m_theme)`)——旧实现的默认背景写死成 +Solarised Light 的 `0xFFFDF6E3`,这是"换深色主题后预览曲线永远是更深的那一侧、几乎看不见"的根因。 + +### 3. 信号样式区「线宽 / 透明度」复位按钮 + +* `SignalStyle` 默认线的**唯一来源**:`DefaultSignalLineWidth(finiteCount)` + (普通 = `kDefaultSignalLineWidth` 1.0;**单数据点 = `kSinglePointSignalLineWidth` 10.0**)。 + `BuildSignalGroup` 的初始化与复位按钮共用它。 +* 布局:内容列右端先预留 `kSliderResetSize`(16) + `kSliderResetGap`(4),数值框与轨道依次收缩 ⇒ + **按钮右边界 == 内容列右缘**(探针断言误差 < 0.6px)。内容列太窄时整列不显示按钮。 +* 图标:`UiPaintResetIcon()`(`PaintUtil`)成为**唯一实现**, + `pmdetail::PaintResetIcon` / `pvdetail::PaintPanelResetIcon` 都改为转调它。 +* 命中:`StyleHit::WIDTH_RESET / OPACITY_RESET`(悬停高亮 + 手型 + 双击不解释为其它语义)。 + +### 4. 「均值滤波」面板重做(与「自由标记」同一套浏览方式) + +* 条目 = **信号色叠底标题栏**(左侧展开三角 + 名称 + 右端**复选框**)。勾选前只有标题栏; + 勾选后出现三角与两行滑块,三角可收起参数行。复选框自绘(对比色描边/填充), + 主题色在信号色叠底上不可辨。 +* 两行滑块:`均值点数`(0…上限)与 `迭代次数`(1…上限),**标签不带 X / N**; + 右侧数值可点击输入**滑块上限**(沿用"输入上限(当前N)"提示与 `EditFilter::BYTE`), + 最右侧是复位按钮(点数 → 5 且上限 → 50;迭代 → 1 且上限 → 10)。 + 新增 `Lane::MeanFilterItem::{maxPoints, maxIterations, collapsed}`、`Lane::meanFilterScroll`。 +* 内容区超高时**滚动**(`layout.content` + 滚动条 + 滚轮),底部「构建为新信号」按钮固定。 + 面板高度上限 = 绘图区可用高度 ⇒ 上百信号合并时不再顶出屏幕。 +* 命中要求"行**完全落在内容区**里",避免滚动出的行抢底部按钮的点击。 +* 缓存键:`PanelSlotKey` 增补 `rowHeaders / rowTriangles / scrollOffset / MarkerGroup::check / + meanItem->maxPoints / maxIterations / collapsed`;`meanFilterScroll` **只进槽键**(不进全局键, + 否则滚动一个面板会让所有面板重画)。 + +### 5. 浮动面板周围那一圈旧波形(中键移视界) + +**根因**:面板像素缓存按 `panel ± 4px` 取块,块的边缘是**上一帧的场景**; +`PanelGlobalKey` 刻意不含视界/幅值(否则平移会重画全部面板)⇒ 整块贴回时把旧波形"复印"了一圈。 + +**修法**:命中缓存时 +1. 先按本帧重画**投影**(它在圆角形状之外,不能靠缓存); +2. 用 `CreateRoundRectRgn` + `ExtSelectClipRgn(RGN_AND)` 裁成**面板圆角形状**, + 只 BitBlt **面板本体**那一块 ⇒ 圆角之外仍是本帧场景。 + +探针 `ringcheck`:同一视界下「走面板缓存」与「强制整帧重绘」在面板外扩 10px 环带上 +**逐像素 0 差异**(23850 像素),并且断言这一帧**确实命中了面板缓存**(否则判据空洞)。 + +### 6. 显示布局边界:每个浮动面板独占一层 + +**根因**:所有面板都被收集在同一层 `baseLayer + 1`,同层内只能按 `depth` 排序 ⇒ +下层面板的行/文本(depth 4)画在**上层**面板本体(depth 2/3)之上。 + +**修法**:按 `m_panelOrder`(底 → 顶)**逐面板 +1 层**,并让绘制侧的 +`UiSetTextLayer` 在 `PaintEdgeCursor` 里**同一个循环内同步推进**(`panelTextLayer + 序号`), +函数返回前把层号推到最上面板之上(滚动条 / 回到最上方 / 输入框用它的下一层)。 +`BuildBoxes` 与绘制**同源**:都以 `m_panelOrder` 的顺序 + 同一公式算层号。 +探针断言:两个完全重叠的面板,其遮挡矩形的层号严格递增,且上层有自己的 box 集合。 + +### 7. 自由标记:选取规则与性能 + +* **选取距离改为二维**:采样候选 = 鼠标到"该采样点屏幕位置"的**欧氏距离**(旧实现只看纵向 ⇒ + 单数据点只要高度接近就在**整幅宽度**内胜出,无法标注其它信号);曲线候选仍是到折线的垂距 + (阶跃竖直段靠它命中)。 +* **纵向光标画在鼠标位置**(`probe.x = 鼠标 x`),数据点圆点/吸附环落在**真实采样点** + (新增 `FreeMarkerProbe::pointX = TimeToX(time)`)。稀疏信号下两者分开是刻意的。 +* **稀疏判定只在胜出信号上算一次**(`MinorCellSparse` 每信号 50 格 × 2 次二分, + 上百信号时是纯浪费 —— 它只决定要不要画吸附环)。 +* **折线缓存两处根治**(上百信号合并网格时的卡顿主因): + ① 容量从固定 64 改为 `max(64, 全部信号数 × 2)`(`BuildLaneVisuals` 刷新 `m_polylineCacheCapacity`), + 否则装满就整表清空 ⇒ 每帧重建全部折线; + ② 查找改为比**一个 64 位键哈希**(命中后再校验 ref/采样数/视界),不再逐条比较十几个字段。 +* 实测:MF4-Record 的 **285 个信号合并到一个网格**,`ProbeFreeMarker` 平均 **0.11ms**(探针上限 8ms)。 + +### 8. 验证 + +``` +panelcheck : 71/71 (主题对比度 24 条、样式区复位 7 条、均值面板 12 条、调试层序 4 条…) +markercheck: 13/13 (单点/常规共存的选取 6 条 + 285 信号探测耗时) +ringcheck : 6/6 (面板缓存环带 0 差异 + 缓存确实命中) +合计 90 条断言全绿(LTO 目标文件上复跑同样全绿) +make release -> 零 error 零 warning;JustDataInspector.exe 815,104 B(+23,552 B ≈ +2.9%) +make debug -> 零 error 零 warning +``` + +### 9. 踩坑(新增) + +* ⚠️⚠️ **源文件里的中文不能用编辑工具"凭记忆"当匹配串**:本仓库若干注释里存在 + "重复滤波次数 ,至少"这类**多一个空格**的文本,肉眼与 `read_file` 都不易察觉, + 匹配必然失败。可靠做法:先按字节导出该行码点确认,或改用 + `.preflight/patch_{insert,range,line}.ps1`(纯 ASCII + UTF-8 数据文件 + **按行号**改动)。 +* ⚠️ 用 .NET `WriteAllText` 重写文件会把 **CRLF 全变成 LF** ⇒ `git` 提示 + "LF will be replaced by CRLF",整文件变成 4000 行 diff。改完必须把行尾恢复成 CRLF。 +* ⚠️ PowerShell 的 `$Matches` 是正则匹配自动变量(哈希表),在脚本里给 + `$Matches += $i` 会报"只能将哈希表添加到另一个哈希表中" ⇒ 换名 `$hits`。 +* ⚠️ `Start-Process -ArgumentList` 拼接的参数**不会自动加引号**:本工程路径含空格, + `-I<路径>` 与 `.o` 路径都必须手工包 `"`。 +* `PlotView::EdgePanelLayout` / `FreeMarkerProbe` / `LaneVisual` 是**嵌套类型**,探针里必须写 + `PlotView::` 前缀。 +* 探针的工程根目录推导:探针若放在 `.preflight\xxx.exe`,从 exe 路径**上溯 2 级**即根目录 + (上溯 3 级会跑到桌面上,`TestData` 找不到)。 diff --git a/Utilities/Inc/DataModel.hpp b/Utilities/Inc/DataModel.hpp index 37b6844..c9b67f8 100644 --- a/Utilities/Inc/DataModel.hpp +++ b/Utilities/Inc/DataModel.hpp @@ -48,6 +48,24 @@ namespace jdi */ constexpr float kSinglePointSignalLineWidth = kSignalLineWidthMax; + /** + * @brief 普通信号的**默认线宽**(逻辑像素):新建信号时写入,也是样式区「复位」按钮要恢复的值。 + * + * ⚠️ 只有这一个来源:`BuildSignalGroup` 的初始化与信号面板线宽复位都用它, + * 否则改了一处就会与"新建信号"的观感不一致。 + */ + constexpr float kDefaultSignalLineWidth = 1.0f; + + /** + * @brief 按有效采样点数取该信号**默认的线宽**(复位按钮与新建信号共用同一判定)。 + * @param [IN] finiteCount 有效采样点数。 + * @return 默认线宽(单数据点信号 = 最大线宽,其余 = `kDefaultSignalLineWidth`)。 + */ + inline float DefaultSignalLineWidth(std::size_t finiteCount) + { + return (finiteCount == 1) ? kSinglePointSignalLineWidth : kDefaultSignalLineWidth; + } + /** * @brief 「稀疏信号」缓存有效采样点时刻/取值的上限(有效点不超过该值才缓存)。 * @@ -752,6 +770,21 @@ namespace jdi SignalRef ref; /**< 目标信号。 */ int points = 5; /**< 均值点数 X(滑平半径,窗口 = 2X+1)。 */ int iterations = 1; /**< 迭代次数 N(重复滤波次数,至少 1)。 */ + /** + * @brief 「均值点数」滑块的**上限**(用户可点击滑块右侧数值改成其它正整数)。 + * + * 滑块量程 = `1 … maxPoints`,`points` 恒被夹在该区间内。 + */ + int maxPoints = 50; + /** @brief 「迭代次数」滑块的**上限**(同上,量程 = `1 … maxIterations`)。 */ + int maxIterations = 10; + /** + * @brief 参数行是否被**收起**(只保留信号色叠底的标题栏)。 + * + * 仅「均值滤波」面板使用:勾选后标题栏左侧出现展开三角,单击它切换收起/展开。 + * 未勾选时没有参数行,本字段无意义。 + */ + bool collapsed = false; }; /** 中心轴边沿计数的平滑参数:与均值滤波共用同一结构与同一套语义。 */ @@ -1025,6 +1058,15 @@ namespace jdi */ float freeMarkerScroll = 0.0f; + /** + * @brief 「均值滤波」面板内容的滚动偏移(客户区像素,0 = 顶部)。 + * + * 面板内容(每个信号的"信号色叠底标题栏 + 参数滑块行")超出可视高度时右侧出现滚动条; + * 底部的「构建为新信号」按钮**不参与**滚动(固定在面板底部)。 + * 上百个信号合并到同一网格时它是必备的(否则面板会被顶出绘图区)。 + */ + float meanFilterScroll = 0.0f; + /** * @brief 「自由标记」面板里被**收起**的信号分组(点击分组栏切换)。 * diff --git a/Utilities/Inc/DataModelInternal.hpp b/Utilities/Inc/DataModelInternal.hpp index ba1dcf1..73d0698 100644 --- a/Utilities/Inc/DataModelInternal.hpp +++ b/Utilities/Inc/DataModelInternal.hpp @@ -140,6 +140,8 @@ namespace jdi bool freeMarkerShowDelta = false; bool freeMarkerShowEnum = false; std::vector freeMarkerCollapsedGroups; + /** @brief 「均值滤波」面板内容的滚动偏移(详见 `Lane::meanFilterScroll`)。 */ + float meanFilterScroll = 0.0f; }; // 先记下每个信号原来所在网格的网格自身状态: @@ -174,6 +176,7 @@ namespace jdi carry.meanFilter = lane.meanFilter; carry.meanFilterItems = lane.meanFilterItems; carry.meanFilterPanel = lane.meanFilterPanel; + carry.meanFilterScroll = lane.meanFilterScroll; carry.weakPeak = lane.weakPeak; carry.weakPeakItems = lane.weakPeakItems; carry.weakPeakPanel = lane.weakPeakPanel; @@ -255,6 +258,7 @@ namespace jdi lane.freeMarkerShowDelta = carry.freeMarkerShowDelta; lane.freeMarkerShowEnum = carry.freeMarkerShowEnum; lane.freeMarkerCollapsedGroups = carry.freeMarkerCollapsedGroups; + lane.meanFilterScroll = carry.meanFilterScroll; }; // 收集全部可见信号引用。 diff --git a/Utilities/Src/DataModel.cpp b/Utilities/Src/DataModel.cpp index 6a98a6b..419918f 100644 --- a/Utilities/Src/DataModel.cpp +++ b/Utilities/Src/DataModel.cpp @@ -74,7 +74,6 @@ namespace jdi style.lineWidth = kSinglePointSignalLineWidth; } } - /** * @brief 缓存「有效采样点很少」的信号的**全部有效点**(时刻 + 取值,按时间升序)。 * @@ -376,7 +375,7 @@ namespace jdi signal.style.customColor = 0xFF859900u; } signal.style.opacity = 1.0f; - signal.style.lineWidth = 1.0f; + signal.style.lineWidth = kDefaultSignalLineWidth; signal.style.mode = DrawMode::LINE; // 只有一个有效采样点的信号改用「散点」+ 最大线宽,否则根本看不见。 ApplySinglePointDefaultStyle(stats.finiteCount, signal.style);