主题自适应配色 + 预览曲线连续配色 + 样式区复位按钮 + 均值滤波面板重做 + 面板缓存环带残留 + 调试层序 + 自由标记选取与性能

① 主题自适应(`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` 第十六轮(含补丁脚本与编码/行尾踩坑)。
This commit is contained in:
DeepSeek-V4.1-Flash committed 2026-10-30 03:10:51 +08:00
1 parent 71195c4c48
commit bdd703b88b
24 files changed
+1815 -256

No files matched your search

+78 -2
View File
@@ -62,7 +62,10 @@ namespace jdi
PEAK_ITERATIONS, /**< 拖动「弱峰调整」某信号的迭代次数滑块(1 … 该信号自定义上限)。 */ PEAK_ITERATIONS, /**< 拖动「弱峰调整」某信号的迭代次数滑块(1 … 该信号自定义上限)。 */
LANE_REORDER, /**< 拖动网格图例中的 GX 编号徽标:上下调整网格次序。 */ LANE_REORDER, /**< 拖动网格图例中的 GX 编号徽标:上下调整网格次序。 */
MARKER_OPACITY, /**< 拖动「自由标记」面板的「标记透明度」滑块。 */ 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_POINTS, /**< 编辑「均值滤波」的均值点数 X。 */
MEAN_FILTER_ITERATIONS, /**< 编辑「均值滤波」的迭代次数 N。 */ MEAN_FILTER_ITERATIONS, /**< 编辑「均值滤波」的迭代次数 N。 */
PEAK_ITERATIONS_MAX, /**< 编辑「弱峰调整」某信号「迭代次数 N」滑块的**上限**(正整数)。 */ PEAK_ITERATIONS_MAX, /**< 编辑「弱峰调整」某信号「迭代次数 N」滑块的**上限**(正整数)。 */
/**
* @brief 编辑「均值滤波」某信号「均值点数」滑块的**上限**(正整数)。
*
* 与 `PEAK_ITERATIONS_MAX` 同一交互语义:点击滑块右侧数值 → 整行变成输入框,
* 输入框以空内容开始、并显示“输入上限(当前N)”提示。
*/
MEAN_POINTS_MAX,
/** @brief 编辑「均值滤波」某信号「迭代次数」滑块的**上限**(正整数)。 */
MEAN_ITERATIONS_MAX,
FREE_MARKER_TIME, /**< 编辑「自由标记」某固定点的**自身时间**(秒)。 */ FREE_MARKER_TIME, /**< 编辑「自由标记」某固定点的**自身时间**(秒)。 */
FREE_MARKER_DELTA /**< 编辑「自由标记」某固定点相对前一固定点的**时间间隔**(秒)。 */ FREE_MARKER_DELTA /**< 编辑「自由标记」某固定点相对前一固定点的**时间间隔**(秒)。 */
}; };
@@ -1079,6 +1091,12 @@ namespace jdi
*/ */
bool SetLaneFreeMarkerScroll(int laneIndex, float scroll); bool SetLaneFreeMarkerScroll(int laneIndex, float scroll);
/**
* @brief 设置某网格「均值滤波」面板内容的滚动偏移(客户区像素,0 = 顶部)。
* @return 偏移真的发生变化返回 true。
*/
bool SetLaneMeanFilterScroll(int laneIndex, float scroll);
/** /**
* @brief 切换某网格「自由标记」面板里某个信号分组的收起状态。 * @brief 切换某网格「自由标记」面板里某个信号分组的收起状态。
* @return 切换后为「已收起」返回 true。 * @return 切换后为「已收起」返回 true。
@@ -1799,6 +1817,15 @@ namespace jdi
*/ */
std::vector<gd::RectF> rowLevelResets; std::vector<gd::RectF> rowLevelResets;
std::vector<gd::RectF> rowIterResets; std::vector<gd::RectF> rowIterResets;
/**
* @brief 「均值滤波」面板每个信号的**信号色叠底标题栏**矩形(与 `rowRefs` 同序)。
*
* 排版与「自由标记」面板的信号分组栏一致(左侧展开三角 + 信号名),
* 但右端是**复选框**(`rowChecks`)而不是 ✕。未勾选时标题栏仍显示(用于勾选)。
*/
std::vector<gd::RectF> rowHeaders;
/** @brief 「均值滤波」面板每个信号标题栏左侧的**展开 / 收起三角**(仅勾选后出现)。 */
std::vector<gd::RectF> rowTriangles;
std::vector<SignalRef> rowRefs; std::vector<SignalRef> rowRefs;
/** /**
* @brief 「自由标记」面板的一行(一个固定点)。 * @brief 「自由标记」面板的一行(一个固定点)。
@@ -1843,6 +1870,13 @@ namespace jdi
gd::RectF tableHeader; gd::RectF tableHeader;
gd::RectF triangle; /**< 展开/收起三角的命中矩形。 */ gd::RectF triangle; /**< 展开/收起三角的命中矩形。 */
gd::RectF close; /**< 右侧✕的命中矩形。 */ gd::RectF close; /**< 右侧✕的命中矩形。 */
/**
* @brief 右侧**复选框**的命中矩形(仅「均值滤波」面板使用;「自由标记」为空)。
*
* 均值滤波面板的组头右端是"是否预览该信号"的复选框(用户 2026-10-30 要求),
* 只有它被勾选时左侧的展开三角才出现、才能展开参数行。
*/
gd::RectF check;
bool collapsed = false; bool collapsed = false;
/** /**
* @brief 该分组栏是否处于**吸顶**(滚动时被钉在内容区顶部)。 * @brief 该分组栏是否处于**吸顶**(滚动时被钉在内容区顶部)。
@@ -1862,6 +1896,14 @@ namespace jdi
gd::RectF content; gd::RectF content;
/** 「自由标记」面板的**内容总高度**(含被滚动出去的条目),用于换算滚动比例。 */ /** 「自由标记」面板的**内容总高度**(含被滚动出去的条目),用于换算滚动比例。 */
float markerContentHeight = 0.0f; float markerContentHeight = 0.0f;
/**
* @brief 内容区当前生效的**滚动偏移**(像素,≥ 0)。
*
* 「自由标记」与「均值滤波」两个面板的内容区都可能需要滚动;取值来源不同
* (`Lane::freeMarkerScroll` / `Lane::meanFilterScroll`),但布局之后一律从这里读,
* 绘制与命中共用同一份偏移(避免两处各自夹取导致"画的和点的不一致")。
*/
float scrollOffset = 0.0f;
gd::RectF scrollTrack; /**< 内容区右侧的滚动条轨道(不需要滚动时为空矩形)。 */ gd::RectF scrollTrack; /**< 内容区右侧的滚动条轨道(不需要滚动时为空矩形)。 */
gd::RectF scrollThumb; /**< 滚动条滑块。 */ gd::RectF scrollThumb; /**< 滚动条滑块。 */
gd::RectF opacityTrack; /**< 「标记透明度」滑动条轨道(总是显示,与选取模式无关)。 */ gd::RectF opacityTrack; /**< 「标记透明度」滑动条轨道(总是显示,与选取模式无关)。 */
@@ -1945,7 +1987,15 @@ namespace jdi
bool cursorMode = false; /**< 该网格是否处于「自由标记点选取」(决定是否画纵向光标)。 */ bool cursorMode = false; /**< 该网格是否处于「自由标记点选取」(决定是否画纵向光标)。 */
int laneIndex = -1; /**< 该网格下标。 */ int laneIndex = -1; /**< 该网格下标。 */
double time = 0.0; /**< 标注时刻(显示时刻;吸附时 = 采样时刻)。 */ 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; /**< 就近信号(网格内没有可用信号时无效)。 */ SignalRef ref; /**< 就近信号(网格内没有可用信号时无效)。 */
double value = 0.0; /**< 已叠加「调整」变换的取值(用于标注与增量)。 */ double value = 0.0; /**< 已叠加「调整」变换的取值(用于标注与增量)。 */
double enumValue = 0.0; /**< 采样原值(用于查枚举表)。 */ double enumValue = 0.0; /**< 采样原值(用于查枚举表)。 */
@@ -2750,6 +2800,14 @@ namespace jdi
bool m_mouseOverPanel = false; /**< 上一帧鼠标是否落在某个实验性功能面板上。 */ bool m_mouseOverPanel = false; /**< 上一帧鼠标是否落在某个实验性功能面板上。 */
int m_peakDragLane = -1; /**< 正在拖动弱峰滑块的面板所属网格。 */ int m_peakDragLane = -1; /**< 正在拖动弱峰滑块的面板所属网格。 */
SignalRef m_peakDragRef; /**< 正在拖动弱峰滑块的**信号**(每个信号一套参数)。 */ SignalRef m_peakDragRef; /**< 正在拖动弱峰滑块的**信号**(每个信号一套参数)。 */
int m_meanDragLane = -1; /**< 正在拖动「均值滤波」参数滑块的面板所属网格。 */
SignalRef m_meanDragRef; /**< 正在拖动「均值滤波」参数滑块的信号(每信号一套参数)。 */
/**
* @brief 正在编辑「均值滤波」某个滑块上限时,输入框内灰色提示用的**当前上限值**。
*
* 与 `m_peakEditCurrentMax` 同一语义(输入框以空内容开始,空内容时显示当前上限)。
*/
int m_meanEditCurrentMax = 0;
/** /**
* @brief 正在编辑「迭代次数 N 上限」时,输入框内灰色提示用的**当前上限值**。 * @brief 正在编辑「迭代次数 N 上限」时,输入框内灰色提示用的**当前上限值**。
* *
@@ -3079,6 +3137,16 @@ namespace jdi
{ {
SignalRef ref; /**< 信号引用。 */ SignalRef ref; /**< 信号引用。 */
const SignalGroup* group = nullptr; /**< 所属分组(缓存键的一部分)。 */ 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; /**< 采样点数(数据变化时改变)。 */ std::size_t sampleCount = 0; /**< 采样点数(数据变化时改变)。 */
double viewMin = 0.0; /**< 时间轴显示下界。 */ double viewMin = 0.0; /**< 时间轴显示下界。 */
double viewMax = 0.0; /**< 时间轴显示上界。 */ double viewMax = 0.0; /**< 时间轴显示上界。 */
@@ -3145,6 +3213,14 @@ namespace jdi
const LaneVisual& lane) const; const LaneVisual& lane) const;
mutable std::deque<PolylineCacheEntry> m_polylineCache; /**< 折线顶点缓存(按几何键复用)。 */ mutable std::deque<PolylineCacheEntry> m_polylineCache; /**< 折线顶点缓存(按几何键复用)。 */
/**
* @brief 折线顶点缓存的**容量上限**(条目数)。
*
* 由 `BuildLaneVisuals` 按"当前全部网格的信号总数 × 2"刷新(下限 `kPolylineCacheLimit`)。
* ⚠️ 固定 64 条在"多信号合并网格"下会**持续抖动**(装满即整表清空 ⇒ 下一帧全部重建),
* 这正是"上百个信号合并到同一网格时自由标记非常卡"的根因之一。
*/
mutable std::size_t m_polylineCacheCapacity = 64;
/** /**
* @brief 图层**横向补条**渲染期间的折线裁剪范围(省掉整段折线的重建与描边)。 * @brief 图层**横向补条**渲染期间的折线裁剪范围(省掉整段折线的重建与描边)。
+92 -60
View File
@@ -200,36 +200,8 @@ namespace jdi
inline void PaintPanelResetIcon(gd::Graphics& g, const gd::RectF& rect, std::uint32_t color, inline void PaintPanelResetIcon(gd::Graphics& g, const gd::RectF& rect, std::uint32_t color,
float scale) float scale)
{ {
const float inset = 2.5f * scale; // 唯一实现见 `PaintUtil.hpp`(弹出菜单的还原图标、信号样式区的复位按钮共用同一套笔画)。
gd::RectF box(rect.X + inset, rect.Y + inset, rect.Width - inset * 2.0f, UiPaintResetIcon(g, rect, color, scale);
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);
} }
/** /**
@@ -579,6 +551,28 @@ namespace jdi
inline const wchar_t* const kWeakPeakIterationsSliderLabel = L"迭代次数"; inline const wchar_t* const kWeakPeakIterationsSliderLabel = L"迭代次数";
inline const wchar_t* const kWeakPeakTitle = L"弱峰调整"; /**< 弱峰调整面板标题。 */ inline const wchar_t* const kWeakPeakTitle = L"弱峰调整"; /**< 弱峰调整面板标题。 */
inline const wchar_t* const kBuildSignalLabel = 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"自由标记点选取"; 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) inline const double kDerivedPreviewContrast = 3.2;
{
const auto squaredDistance = [](std::uint32_t a, std::uint32_t b)
{
const double dr =
static_cast<double>((a >> 16) & 0xFFu) - static_cast<double>((b >> 16) & 0xFFu);
const double dg =
static_cast<double>((a >> 8) & 0xFFu) - static_cast<double>((b >> 8) & 0xFFu);
const double db = static_cast<double>(a & 0xFFu) - static_cast<double>(b & 0xFFu);
return dr * dr + dg * dg + db * db;
};
const std::uint32_t darker = MixColor(color, 0xFF000000u, 0.72); /**
const std::uint32_t lighter = MixColor(color, 0xFFFFFFFFu, 0.72); * @brief 派生预览曲线与**源信号色**之间还要拉开的亮度差。
// 评分:与原始波形色的区分度为主(权重 1.0),与底色的可见度为辅(权重 0.6)。 *
const double scoreDarker = * 源信号本身已经与底色有足够对比时,预览曲线再往外走这么多,避免"两条线同色叠在一起"。
squaredDistance(darker, color) + 0.6 * squaredDistance(darker, background); */
const double scoreLighter = inline const double kDerivedPreviewGap = 0.18;
squaredDistance(lighter, color) + 0.6 * squaredDistance(lighter, background);
return (scoreDarker >= scoreLighter) ? darker : lighter; /**
* @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 混合(面板像素缓存键共用)。 */ /** @brief FNV-1a 混合(面板像素缓存键共用)。 */
inline void MixPanelKey(std::uint64_t& hash, std::uint64_t value) inline void MixPanelKey(std::uint64_t& hash, std::uint64_t value)
+13
View File
@@ -436,6 +436,19 @@ namespace jdi
return; 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; const float gap = 0.0f;
float laneHeight = m_plotRect.Height / static_cast<float>(count); float laneHeight = m_plotRect.Height / static_cast<float>(count);
if (laneHeight < m_minLaneHeight * m_scale) if (laneHeight < m_minLaneHeight * m_scale)
+1 -1
View File
@@ -458,7 +458,7 @@ namespace jdi
if (cardBottom > cardTop) if (cardBottom > cardTop)
{ {
UiFillRect(g, gd::RectF(patch.X, cardTop, patch.Width, cardBottom - cardTop), UiFillRect(g, gd::RectF(patch.X, cardTop, patch.Width, cardBottom - cardTop),
m_theme->cardBg); ThemeLaneBackground(*m_theme));
} }
const double viewMin = Tab().ViewMin(); const double viewMin = Tab().ViewMin();
@@ -1789,6 +1789,23 @@ namespace jdi
return true; return true;
} }
bool PlotView::SetLaneMeanFilterScroll(int laneIndex, float scroll)
{
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
{
return false;
}
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(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) bool PlotView::ToggleLaneFreeMarkerGroup(int laneIndex, const SignalRef& ref)
{ {
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size())) if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
+338 -18
View File
@@ -389,9 +389,11 @@ namespace jdi
m_editTarget == PlotEditTarget::EDGE_CENTER_ITERATIONS || m_editTarget == PlotEditTarget::EDGE_CENTER_ITERATIONS ||
m_editTarget == PlotEditTarget::MEAN_FILTER_POINTS || m_editTarget == PlotEditTarget::MEAN_FILTER_POINTS ||
m_editTarget == PlotEditTarget::MEAN_FILTER_ITERATIONS || 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) if (m_editFieldRect.Width <= 0.0f)
{ {
@@ -641,6 +643,44 @@ namespace jdi
return true; return true;
} }
// 「均值滤波」两行滑块的**上限**:与「弱峰调整 → 迭代次数上限」同一交互语义
//(点击滑块右侧数值 → 整行变成输入框,输入正整数作为新的量程上限)。
// 规则:空内容 / 非法值(低于下限或超出允许范围)→ 保持原上限不变;
// 生效后若当前值超过新上限,自动夹到新上限。
if (target == PlotEditTarget::MEAN_POINTS_MAX ||
target == PlotEditTarget::MEAN_ITERATIONS_MAX)
{
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(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<long long>(std::llround(parsed));
if (value >= minimum && value <= limit)
{
Lane::MeanFilterItem& item = lane.EnsureMeanFilterItem(m_editMeanRef);
if (isPoints)
{
item.maxPoints = static_cast<int>(value);
item.points = std::max(minimum, std::min(item.maxPoints, item.points));
}
else
{
item.maxIterations = static_cast<int>(value);
item.iterations =
std::max(minimum, std::min(item.maxIterations, item.iterations));
}
m_meanCurveCache.clear();
}
}
return true;
}
// 「均值点数 X」/「迭代次数 N」:作用于被勾选信号的均值滤波曲线,夹在合理范围内。 // 「均值点数 X」/「迭代次数 N」:作用于被勾选信号的均值滤波曲线,夹在合理范围内。
// 「中心轴边沿计数」用**完全相同的参数与语义**,只是作用于自己的条目列表。 // 「中心轴边沿计数」用**完全相同的参数与语义**,只是作用于自己的条目列表。
const bool centerTarget = const bool centerTarget =
@@ -882,7 +922,9 @@ namespace jdi
return LoadCursorW(nullptr, IDC_SIZEWE); return LoadCursorW(nullptr, IDC_SIZEWE);
case PlotDragMode::PEAK_LEVEL: case PlotDragMode::PEAK_LEVEL:
case PlotDragMode::PEAK_ITERATIONS: case PlotDragMode::PEAK_ITERATIONS:
// 「弱峰调整」滑块拖拽 → 左右箭头。 case PlotDragMode::MEAN_POINTS:
case PlotDragMode::MEAN_ITERATIONS:
// 「弱峰调整 / 均值滤波」参数滑块拖拽 → 左右箭头。
return LoadCursorW(nullptr, IDC_SIZEWE); return LoadCursorW(nullptr, IDC_SIZEWE);
case PlotDragMode::MOVE_SIGNAL: case PlotDragMode::MOVE_SIGNAL:
// 拖动信号(参考 Photoshop 平移元素的抓握光标)。 // 拖动信号(参考 Photoshop 平移元素的抓握光标)。
@@ -1393,6 +1435,72 @@ namespace jdi
} }
return true; return true;
} }
case PlotDragMode::MEAN_POINTS:
case PlotDragMode::MEAN_ITERATIONS:
{
// 「均值滤波」某信号的参数滑块:量程 = 下限 … 该信号自己的上限。
if (m_tab == nullptr || m_meanDragLane < 0 ||
m_meanDragLane >= static_cast<int>(m_tab->Lanes().size()))
{
break;
}
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(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<int>(candidate);
break;
}
}
if (rowIndex < 0)
{
break;
}
const std::vector<gd::RectF>& tracks =
pointsMode ? layout.rowLevelTracks : layout.rowIterTracks;
if (static_cast<std::size_t>(rowIndex) >= tracks.size() ||
tracks[static_cast<std::size_t>(rowIndex)].Width <= 0.0f)
{
break;
}
const double ratio =
UiSliderRatioFromX(tracks[static_cast<std::size_t>(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<int>(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<int>(std::lround(
ratio * (maximum - kMeanFilterIterationsMin)))));
}
m_meanCurveCache.clear();
m_dragMoved = true;
return true;
}
case PlotDragMode::LANE_REORDER: case PlotDragMode::LANE_REORDER:
{ {
// 被拖拽的网格自由跟随鼠标;目标位置变化时其它网格实时让位(吸附)。 // 被拖拽的网格自由跟随鼠标;目标位置变化时其它网格实时让位(吸附)。
@@ -1526,6 +1634,35 @@ namespace jdi
} }
return false; return false;
} }
case PlotDragMode::MEAN_SCROLL:
{
// 「均值滤波」面板内容滚动条:按滑块在轨道里的位置换算滚动偏移。
if (m_tab == nullptr || m_markerScrollDragLane < 0 ||
m_markerScrollDragLane >= static_cast<int>(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<float>(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: case PlotDragMode::NONE:
default: default:
break; break;
@@ -2054,6 +2191,161 @@ namespace jdi
{ {
const bool meanMode = (kind == EdgePanelKind::MEAN); const bool meanMode = (kind == EdgePanelKind::MEAN);
const bool boostMode = (kind == EdgePanelKind::PEAK); const bool boostMode = (kind == EdgePanelKind::PEAK);
// 「均值滤波」:组头三角(展开 / 收起参数行)+ 两行参数滑块
//(轨道拖动 / 点击数值改上限 / 右侧复位按钮)。
// ⚠️ 所有命中都要求落在**内容区**之内:滚动到可视范围之外的行仍保留矩形,
// 若不加这条限制,它们会抢走底部「构建为新信号」按钮的点击。
if (meanMode)
{
Lane& meanLane = m_tab->Lanes()[static_cast<std::size_t>(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<int>(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<int>(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<std::size_t>(index)]; Lane& tabLane = m_tab->Lanes()[static_cast<std::size_t>(index)];
// 「弱峰调整」:先判两行滑块(数值文本 → 改上限;轨道 → 拖动)。 // 「弱峰调整」:先判两行滑块(数值文本 → 改上限;轨道 → 拖动)。
@@ -2707,6 +2999,9 @@ namespace jdi
// 弱峰滑块拖拽结束:清掉目标(避免留下上一轮的信号引用)。 // 弱峰滑块拖拽结束:清掉目标(避免留下上一轮的信号引用)。
m_peakDragLane = -1; m_peakDragLane = -1;
m_peakDragRef = SignalRef(); m_peakDragRef = SignalRef();
// 「均值滤波」参数滑块拖拽结束:同样清掉目标。
m_meanDragLane = -1;
m_meanDragRef = SignalRef();
// 「标记透明度」滑块 / 面板内容滚动条拖拽结束。 // 「标记透明度」滑块 / 面板内容滚动条拖拽结束。
m_markerOpacityDragLane = -1; m_markerOpacityDragLane = -1;
m_markerScrollDragLane = -1; m_markerScrollDragLane = -1;
@@ -2755,24 +3050,49 @@ namespace jdi
int panelLane = -1; int panelLane = -1;
int panelKind = 0; int panelKind = 0;
if (TopPanelAtPoint(static_cast<float>(m_mouseX), static_cast<float>(m_mouseY), if (TopPanelAtPoint(static_cast<float>(m_mouseX), static_cast<float>(m_mouseY),
panelLane, panelKind) && panelLane, panelKind))
static_cast<EdgePanelKind>(panelKind) == EdgePanelKind::MARK)
{ {
const EdgePanelLayout layout = EdgePanelLayoutFor(panelLane, EdgePanelKind::MARK); const EdgePanelKind hoveredKind = static_cast<EdgePanelKind>(panelKind);
if (layout.content.Width > 0.0f && // 「均值滤波」面板:内容区超高时用滚轮翻页(上百个信号合并时必备)。
UiPointInRect(layout.content, static_cast<float>(m_mouseX), if (hoveredKind == EdgePanelKind::MEAN)
static_cast<float>(m_mouseY)))
{ {
const float maxScroll = const EdgePanelLayout layout = EdgePanelLayoutFor(panelLane, EdgePanelKind::MEAN);
std::max(0.0f, layout.markerContentHeight - layout.content.Height); if (layout.content.Width > 0.0f &&
if (maxScroll > 0.0f) UiPointInRect(layout.content, static_cast<float>(m_mouseX),
static_cast<float>(m_mouseY)))
{ {
const float next = static_cast<float>(Tab().Lanes()[static_cast<std::size_t>( const float maxScroll =
panelLane)] std::max(0.0f, layout.markerContentHeight - layout.content.Height);
.freeMarkerScroll - if (maxScroll > 0.0f)
notches * 40.0 * m_scale); {
SetLaneFreeMarkerScroll(panelLane, std::max(0.0f, std::min(next, maxScroll))); const float current =
return true; Tab().Lanes()[static_cast<std::size_t>(panelLane)].meanFilterScroll;
const float next = current - static_cast<float>(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<float>(m_mouseX),
static_cast<float>(m_mouseY)))
{
const float maxScroll =
std::max(0.0f, layout.markerContentHeight - layout.content.Height);
if (maxScroll > 0.0f)
{
const float next = static_cast<float>(Tab().Lanes()[static_cast<std::size_t>(
panelLane)]
.freeMarkerScroll -
notches * 40.0 * m_scale);
SetLaneFreeMarkerScroll(panelLane,
std::max(0.0f, std::min(next, maxScroll)));
return true;
}
} }
} }
} }
+417 -104
View File
@@ -113,29 +113,65 @@ namespace jdi
//(详见 `PolylineCacheEntry::lineWidth` 的说明;漏掉它会让线宽改动在**稠密信号**上不生效)。 //(详见 `PolylineCacheEntry::lineWidth` 的说明;漏掉它会让线宽改动在**稠密信号**上不生效)。
const float lineWidth = (resolved.style != nullptr) ? resolved.style->lineWidth : 1.0f; 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<std::uint64_t>(bits));
};
const auto mixHashDouble = [&mixHash](double value)
{
std::uint64_t bits = 0;
std::memcpy(&bits, &value, sizeof(bits));
mixHash(bits);
};
mixHash(static_cast<std::uint64_t>(static_cast<std::uint32_t>(ref.groupIndex)) + 2ull);
mixHash(static_cast<std::uint64_t>(static_cast<std::uint32_t>(ref.signalIndex)) + 2ull);
mixHash(reinterpret_cast<std::uintptr_t>(resolved.group));
mixHash(static_cast<std::uint64_t>(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) 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 && if (entry.key != key || entry.sampleCount != sampleCount || !(entry.ref == ref) ||
entry.plotWidth == m_plotRect.Width && entry.laneY == lane.rect.Y && entry.viewMin != viewMin || entry.viewMax != viewMax)
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)
{ {
return &entry; continue;
} }
return &entry;
} }
// 未命中:重建并写入缓存(deque 保证已有元素的引用在追加后依然有效)。 // 未命中:重建并写入缓存(deque 保证已有元素的引用在追加后依然有效)。
// 缓存只用于消除重复计算,容量上限约等于“网格数 × 每网格信号数”的两倍。 // ⚠️ 容量必须**覆盖所有网格的全部信号**:旧实现固定 64 条,283 个信号合并到
if (m_polylineCache.size() >= kPolylineCacheLimit) // 同一网格时会不断"清空重建"⇒ 绘制与自由标记探测每帧重算全部折线(严重卡顿)。
if (m_polylineCache.size() >= m_polylineCacheCapacity)
{ {
m_polylineCache.clear(); m_polylineCache.clear();
} }
m_polylineCache.push_back(PolylineCacheEntry()); m_polylineCache.push_back(PolylineCacheEntry());
PolylineCacheEntry& entry = m_polylineCache.back(); PolylineCacheEntry& entry = m_polylineCache.back();
entry.key = key;
entry.ref = ref; entry.ref = ref;
entry.group = resolved.group; entry.group = resolved.group;
entry.sampleCount = sampleCount; entry.sampleCount = sampleCount;
@@ -656,104 +692,102 @@ namespace jdi
// 下面这三样在 `paintOverlays` 里绘制在**浮动面板之后**(压在面板之上): // 下面这三样在 `paintOverlays` 里绘制在**浮动面板之后**(压在面板之上):
// 滚动条、右下角「回到最上方」悬浮按钮、内联输入框。 // 滚动条、右下角「回到最上方」悬浮按钮、内联输入框。
// ⇒ 必须比面板层(`baseLayer + 1`)再高一层,否则与实际像素的层序相反。 // ⇒ 它们的层号在下面「面板循环」之后统一给(比最上面板再高一层),
boxes.SetLayer(baseLayer + 2); // 否则与实际像素的层序相反。这里先复位到组件基层。
boxes.Add(m_scrollBarRect, 5);
boxes.Add(BackToTopRect(), 5);
boxes.Add(EditFieldRect(), 5);
boxes.SetLayer(baseLayer); boxes.SetLayer(baseLayer);
// ⚠️⚠️ **浮动面板必须另起一个绘制层,并把面板矩形登记为遮挡矩形**: // ⚠️⚠️ **浮动面板必须另起绘制层,并把面板矩形登记为遮挡矩形**:
// 面板是不透明浮层、且常常**跨多个网格上下排列**,与网格区域重叠。 // 面板是不透明浮层、且常常**跨多个网格上下排列**,与网格区域重叠。
// 若与网格同层,网格的矩形边线会横穿面板 —— 用户 2026-10-29 报告的 // 若与网格同层,网格的矩形边线会横穿面板 —— 用户 2026-10-29 报告的
// "网格的布局边界穿透显示在浮动面板上"。`layer` 决定谁压谁, // "网格的布局边界穿透显示在浮动面板上"。`layer` 决定谁压谁,
// `AddOccluder` 才真正把下层 box 从面板区域里**挖掉**。 // `AddOccluder` 才真正把下层 box 从面板区域里**挖掉**。
bool panelLayerPushed = false;
// 层级 2/3/4:5 类实验性功能浮动面板的内部控件。 // 层级 2/3/4:5 类实验性功能浮动面板的内部控件。
// 这些矩形由 `EdgePanelLayoutFor` 统一算出(绘制与命中共用),一个循环即可全部上报。 // 这些矩形由 `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<int>(panelCount);
// ⚠️ **先用最便宜的判据跳过"没有面板"的网格**:`EdgePanelLayoutFor` 内部要取 // ⚠️ **先用最便宜的判据跳过"没有面板"的网格**:`EdgePanelLayoutFor` 内部要取
// 标签页配置、逐项判断种类与开关,单次约 2~3µs;283 个网格 × 5 种 = 1415 次调用 // 标签页配置、逐项判断种类与开关,单次约 2~3µs;283 个网格 × 5 种 = 1415 次调用
// 实测约 **4ms/帧**(这是"开调试后卡"的主要来源之一)。 // 实测约 **4ms/帧**(这是"开调试后卡"的主要来源之一)。
// 只有该网格**至少有一个面板处于展开状态**时才值得去算布局。 // 现在只对**层序表里真正显示出来的面板**算布局(层序表本来就只含展开且启用的面板)。
const std::vector<Lane>& laneConfigs = for (std::size_t ordinal = 0; ordinal < panelCount; ++ordinal)
(m_tab != nullptr) ? m_tab->Lanes() : m_localTab.Lanes();
for (std::size_t index = 0; index < m_lanes.size() && index < laneConfigs.size(); ++index)
{ {
const Lane& laneConfig = laneConfigs[index]; const PanelRef& panelRef = m_panelOrder[ordinal];
const bool anyPanelOpen = laneConfig.edgePanel.open || const EdgePanelLayout layout =
laneConfig.edgeCenterPanel.open || EdgePanelLayoutFor(panelRef.laneIndex, static_cast<EdgePanelKind>(panelRef.kind));
laneConfig.meanFilterPanel.open || if (!UiRectHasArea(layout.panel))
laneConfig.weakPeakPanel.open ||
laneConfig.freeMarkerPanel.open;
if (!anyPanelOpen)
{ {
continue; continue;
} }
const int laneIndex = static_cast<int>(index); // 本面板独占一层(层号越大越靠上)。
for (int kindValue = static_cast<int>(EdgePanelKind::COUNT); boxes.SetLayer(baseLayer + 1 + static_cast<int>(ordinal));
kindValue <= static_cast<int>(EdgePanelKind::MARK); ++kindValue) 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 = boxes.Add(group.header, 3);
EdgePanelLayoutFor(laneIndex, static_cast<EdgePanelKind>(kindValue)); boxes.Add(group.tableHeader, 3);
if (!UiRectHasArea(layout.panel)) boxes.Add(group.triangle, 4);
{ boxes.Add(group.close, 4);
continue; boxes.Add(group.check, 4);
} }
if (!panelLayerPushed) for (const EdgePanelLayout::MarkerRow& row : layout.markerRows)
{ {
boxes.SetLayer(baseLayer + 1); boxes.Add(row.rect, 4);
panelLayerPushed = true; boxes.Add(row.close, 4);
}
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.SetClip(gd::RectF()); boxes.SetClip(gd::RectF());
boxes.SetLayer(topOverlayLayer);
boxes.Add(m_scrollBarRect, 5);
boxes.Add(BackToTopRect(), 5);
boxes.Add(EditFieldRect(), 5);
boxes.SetLayer(baseLayer); boxes.SetLayer(baseLayer);
} }
@@ -848,17 +882,19 @@ namespace jdi
// 这样浮动面板仍压在最上层,与其它实验性功能的层序一致)。 // 这样浮动面板仍压在最上层,与其它实验性功能的层序一致)。
PaintFreeMarker(g, m_lanes); PaintFreeMarker(g, m_lanes);
// 从这里开始都在面板之上:文本层同步 +1(与 `BuildBoxes` 的 `baseLayer + 1` 一致)。 // 从这里开始都在面板之上:文本层同步 +1(与 `BuildBoxes` 的 `baseLayer + 1` 一致)。
// ⚠️ `PaintEdgeCursor` 内部会**逐面板**再把层号 +1(每个面板独占一层),
// 并在返回前把层号推到**最上面板之上**,因此这里不再写死 `+2`。
UiSetTextLayer(textLayer + 1); UiSetTextLayer(textLayer + 1);
PaintEdgeCursor(g, m_lanes); PaintEdgeCursor(g, m_lanes);
// 之后这几样绘制在浮动面板**之上**:与 `BuildBoxes` 的 `baseLayer + 2` 对应。
UiSetTextLayer(textLayer + 2);
PaintSignalInteraction(g); PaintSignalInteraction(g);
PaintHint(g); PaintHint(g);
// 网格 Y 轴上下限的输入框、以及「弱峰调整 → 迭代次数上限」的输入框都绘制在最上层 // 网格 Y 轴上下限的输入框、以及「弱峰调整 → 迭代次数上限」的输入框都绘制在最上层
//(后者覆盖在浮动面板的滑块行之上)。 //(后者覆盖在浮动面板的滑块行之上)。
if (m_editTarget == PlotEditTarget::LANE_Y_MAX || if (m_editTarget == PlotEditTarget::LANE_Y_MAX ||
m_editTarget == PlotEditTarget::LANE_Y_MIN || 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); PaintEditField(g);
} }
@@ -1105,7 +1141,9 @@ namespace jdi
UiFillRect(g, m_area, m_theme->plotBg); 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(); m_legendHits.clear();
@@ -1580,7 +1618,8 @@ namespace jdi
const gd::RectF gridClip = laneClip; const gd::RectF gridClip = laneClip;
/** 标尺数值文本的裁剪矩形(GDI 文本需要显式裁剪,否则会越过绘图区)。 */ /** 标尺数值文本的裁剪矩形(GDI 文本需要显式裁剪,否则会越过绘图区)。 */
const gd::RectF clipRectForLabels = gridClip; 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 weakLineColor = m_theme->gridWeak | 0xFF000000u;
const std::uint32_t strongLineColor = m_theme->gridStrong | 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 limitTop = lane.rect.Y - lane.rect.Height * 4.0f;
const float limitBottom = lane.rect.Y + lane.rect.Height * 5.0f; const float limitBottom = lane.rect.Y + lane.rect.Height * 5.0f;
// 派生曲线的颜色:与原信号**同色相**但大幅拉开明度,使其与原始波形形成高对比 // 派生曲线的颜色:与原信号**同色相**、亮度按**当前网格底色**自适应拉开,
// (原信号改颜色时曲线随之改变,因为它是从信号色派生出来的);透明度与原信号一致。 // 使其与原始波形、与背景都形成足够对比(原信号改颜色时曲线随之改变,因为它是从信号色
// 派生出来的);透明度与原信号一致。
const unsigned int baseAlpha = static_cast<unsigned int>( const unsigned int baseAlpha = static_cast<unsigned int>(
std::max(0.0, std::min(1.0, static_cast<double>(resolved.style->opacity))) * 255.0 + 0.5); std::max(0.0, std::min(1.0, static_cast<double>(resolved.style->opacity))) * 255.0 + 0.5);
const std::uint32_t derived = WithAlpha(HighContrastDerived(StyleColor(*resolved.style)), const std::uint32_t derived = WithAlpha(
baseAlpha); HighContrastDerived(StyleColor(*resolved.style), ThemeLaneBackground(*m_theme)),
baseAlpha);
// 顶点预算与波形一致(每像素列两个顶点)。 // 顶点预算与波形一致(每像素列两个顶点)。
// 交互期间进一步降到 1/2:派生曲线往往有 10 条(5 网格 × 均值滤波 + 弱峰调整), // 交互期间进一步降到 1/2:派生曲线往往有 10 条(5 网格 × 均值滤波 + 弱峰调整),
@@ -3480,6 +3521,10 @@ namespace jdi
mix(lane.freeMarkerPanel.collapsed ? 1ull : 0ull); mix(lane.freeMarkerPanel.collapsed ? 1ull : 0ull);
mix(lane.edgeValueValid ? 1ull : 0ull); mix(lane.edgeValueValid ? 1ull : 0ull);
mixDouble(lane.edgeValue); mixDouble(lane.edgeValue);
// 「自由标记」面板内容区的滚动偏移(历史行为:计入全局键)。
// ⚠️ 「均值滤波」的滚动偏移只进**该面板自己的**槽键(见 `PanelSlotKey`),
// 否则滚动一个面板会让屏幕上所有面板一起重画。
mixFloat(lane.freeMarkerScroll);
} }
} }
@@ -3573,6 +3618,8 @@ namespace jdi
mixRect(group.tableHeader); mixRect(group.tableHeader);
mixRect(group.triangle); mixRect(group.triangle);
mixRect(group.close); mixRect(group.close);
// 「均值滤波」的组头右端是复选框(「自由标记」为空矩形,不影响它的键)。
mixRect(group.check);
mix(group.collapsed ? 1ull : 0ull); mix(group.collapsed ? 1ull : 0ull);
mix(group.pinned ? 1ull : 0ull); mix(group.pinned ? 1ull : 0ull);
mix(static_cast<std::uint64_t>(group.ref.groupIndex + 2)); mix(static_cast<std::uint64_t>(group.ref.groupIndex + 2));
@@ -3616,6 +3663,11 @@ namespace jdi
const Lane::MeanFilterItem* meanItem = lane.FindMeanFilterItem(ref); const Lane::MeanFilterItem* meanItem = lane.FindMeanFilterItem(ref);
mix(static_cast<std::uint64_t>(meanItem != nullptr ? meanItem->points + 2 : 0)); mix(static_cast<std::uint64_t>(meanItem != nullptr ? meanItem->points + 2 : 0));
mix(static_cast<std::uint64_t>(meanItem != nullptr ? meanItem->iterations + 2 : 0)); mix(static_cast<std::uint64_t>(meanItem != nullptr ? meanItem->iterations + 2 : 0));
// 「均值滤波」改为滑块后,**滑块上限**决定圆钮位置、**收起状态**决定是否显示参数行
// ⇒ 两者都直接改变面板像素,必须进键(否则拖完滑块仍会贴回旧像素)。
mix(static_cast<std::uint64_t>(meanItem != nullptr ? meanItem->maxPoints + 2 : 0));
mix(static_cast<std::uint64_t>(meanItem != nullptr ? meanItem->maxIterations + 2 : 0));
mix((meanItem != nullptr && meanItem->collapsed) ? 1ull : 0ull);
const Lane::EdgeCenterItem* centerItem = lane.FindEdgeCenterItem(ref); const Lane::EdgeCenterItem* centerItem = lane.FindEdgeCenterItem(ref);
mix(static_cast<std::uint64_t>(centerItem != nullptr ? centerItem->points + 2 : 0)); mix(static_cast<std::uint64_t>(centerItem != nullptr ? centerItem->points + 2 : 0));
mix(static_cast<std::uint64_t>(centerItem != nullptr ? centerItem->iterations + 2 : 0)); mix(static_cast<std::uint64_t>(centerItem != nullptr ? centerItem->iterations + 2 : 0));
@@ -3704,6 +3756,17 @@ namespace jdi
{ {
mixRect(rect); 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; const float margin = 6.0f * m_scale;
@@ -4067,12 +4130,23 @@ namespace jdi
//(与"可拖拽"的光晕区分:这里只是单点圆环,不描整条曲线)。 //(与"可拖拽"的光晕区分:这里只是单点圆环,不描整条曲线)。
const float radius = 3.4f * m_scale; const float radius = 3.4f * m_scale;
gd::SolidBrush dot(ToGdiColor(signalColor)); 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) if (probe.snapped)
{ {
gd::Pen ring(ToGdiColor(lineColor), 1.2f * m_scale); 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, g.DrawEllipse(&ring, pointX - radius - 3.0f * m_scale,
(radius + 3.0f * m_scale) * 2.0f, (radius + 3.0f * m_scale) * 2.0f); 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::size_t count = std::min(lanes.size(), tabLanes.size());
const std::uint32_t color = CursorColor(); const std::uint32_t color = CursorColor();
const UiInk ink = UiContrastInk(color); const UiInk ink = UiContrastInk(color);
// 进入本函数时的文本层号 = 宿主给本组件分配的面板层起始(`baseLayer + 1`)。
// 每个面板在循环里各用 `panelTextLayer + 面板序数`,函数结束前把层号推到**最上面板之上**。
const int panelTextLayer = UiGetTextLayer();
// ---- 横向光标(线与两端把手 + 左侧幅值读数)---- // ---- 横向光标(线与两端把手 + 左侧幅值读数)----
for (std::size_t index = 0; index < count; ++index) for (std::size_t index = 0; index < count; ++index)
@@ -4283,6 +4360,10 @@ namespace jdi
const PanelRef& panelRef = m_panelOrder[panelOrdinal]; const PanelRef& panelRef = m_panelOrder[panelOrdinal];
const EdgePanelKind kind = static_cast<EdgePanelKind>(panelRef.kind); const EdgePanelKind kind = static_cast<EdgePanelKind>(panelRef.kind);
const std::size_t index = static_cast<std::size_t>(panelRef.laneIndex); const std::size_t index = static_cast<std::size_t>(panelRef.laneIndex);
// 文本几何层号:**每个面板独占一层**,与 `BuildBoxes` 完全同源
//(`baseLayer + 1 + 面板序数`,面板序数按 `m_panelOrder` 底 → 顶)。
// 少了这一步,下层面板的文字框会记在比上层面板更低的层里被反复挖洞/穿透。
UiSetTextLayer(panelTextLayer + static_cast<int>(panelOrdinal));
const bool centerAxis = (kind == EdgePanelKind::CENTER); const bool centerAxis = (kind == EdgePanelKind::CENTER);
const bool meanFilter = (kind == EdgePanelKind::MEAN); const bool meanFilter = (kind == EdgePanelKind::MEAN);
const bool weakPeak = (kind == EdgePanelKind::PEAK); const bool weakPeak = (kind == EdgePanelKind::PEAK);
@@ -4353,11 +4434,43 @@ namespace jdi
} }
if (hit != nullptr && hit->key != 0 && hit->key == slotKey) 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(); HDC target = g.GetHDC();
if (target != nullptr) if (target != nullptr)
{ {
BitBlt(target, hit->x0, hit->y0, hit->x1 - hit->x0, hit->y1 - hit->y0, const int bx0 = static_cast<int>(std::floor(panel.X)) - 1;
m_panelDc, hit->x0, hit->y0, SRCCOPY); const int by0 = static_cast<int>(std::floor(panel.Y)) - 1;
const int bx1 = static_cast<int>(std::ceil(panel.X + panel.Width)) + 1;
const int by1 = static_cast<int>(std::ceil(panel.Y + panel.Height)) + 1;
HRGN rounded = CreateRoundRectRgn(
bx0, by0, bx1 + 1, by1 + 1,
static_cast<int>(std::lround(10.0f * m_scale)),
static_cast<int>(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); 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<double>(current - minimum) /
static_cast<double>(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<float>(m_mouseX),
static_cast<float>(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<int>(index)) &&
(m_meanDragRef == ref);
const bool iterationsActive =
(m_dragMode == PlotDragMode::MEAN_ITERATIONS) &&
(m_meanDragLane == static_cast<int>(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 标记点」+ ✕),下属固定点条目六列对齐(序号 / 取值 / 枚举 / 增量 / 时刻 / 间隔), // 「N 标记点」+ ✕),下属固定点条目六列对齐(序号 / 取值 / 枚举 / 增量 / 时刻 / 间隔),
// 行尾各带一个 ✕。内容区有滚动偏移时按内容区裁剪。 // 行尾各带一个 ✕。内容区有滚动偏移时按内容区裁剪。
@@ -5166,6 +5464,10 @@ namespace jdi
m_panelSlots = std::move(nextSlots); m_panelSlots = std::move(nextSlots);
m_panelGlobalKey = panelGlobalKey; m_panelGlobalKey = panelGlobalKey;
m_panelValid = panelCacheReady; m_panelValid = panelCacheReady;
// 面板之后绘制的叠加层(信号拖拽虚影 / 提示 / 输入框 / 滚动条 / 回到最上方)
// 都在**所有面板之上** ⇒ 文本层号推到最上面板的上一层(与 `BuildBoxes` 同源)。
UiSetTextLayer(panelTextLayer + static_cast<int>(m_panelOrder.size()));
} }
void PlotView::PaintEditField(gd::Graphics& g) void PlotView::PaintEditField(gd::Graphics& g)
@@ -5181,10 +5483,21 @@ namespace jdi
// 「迭代次数 N 上限」:输入框**以空内容开始**,未输入任何内容时以灰色提示当前上限值。 // 「迭代次数 N 上限」:输入框**以空内容开始**,未输入任何内容时以灰色提示当前上限值。
// 提示文案 = “输入上限(当前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 const bool peakTarget = (m_editTarget == PlotEditTarget::PEAK_ITERATIONS_MAX);
: kWeakPeakIterationsDefaultMax; 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 std::wstring hint = L"输入上限(当前" + std::to_wstring(currentMax) + L")";
const float hintLeft = fieldRect.X + 11.0f * m_scale; const float hintLeft = fieldRect.X + 11.0f * m_scale;
UiTextAtClipped(g, hint, m_fonts->monoSmall, hintLeft, UiTextAtClipped(g, hint, m_fonts->monoSmall, hintLeft,
+213 -18
View File
@@ -69,6 +69,17 @@ namespace jdi
width = std::max(width, kEdgePanelPad * 2.0f * m_scale + nameWidth + width = std::max(width, kEdgePanelPad * 2.0f * m_scale + nameWidth +
kEdgePanelMeanCheckSize * m_scale + 6.0f * m_scale); 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 rising = 0;
std::size_t falling = 0; std::size_t falling = 0;
bool complete = true; bool complete = true;
@@ -116,7 +127,7 @@ namespace jdi
if (centerAxis || meanFilter) if (centerAxis || meanFilter)
{ {
// 参数行 = 两个「说明文字 + 数值框」,必须保证文字不被数值框压住。 // 参数行 = 两个「说明文字 + 数值框」,必须保证文字不被数值框压住。
// (MEAN / CENTER:「均值点数 X」+「迭代次数 N」。标签与数值框各占半行。) // (CENTER:「均值点数 X」+「迭代次数 N」。MEAN 已改为滑块,见下面单独的分支。)
const float labelXWidth = UiMeasureTextWidth(m_fonts->uiSmall, L"均值点数 X"); const float labelXWidth = UiMeasureTextWidth(m_fonts->uiSmall, L"均值点数 X");
const float labelN = UiMeasureTextWidth(m_fonts->uiSmall, L"迭代次数 N"); const float labelN = UiMeasureTextWidth(m_fonts->uiSmall, L"迭代次数 N");
const float fieldWidth = const float fieldWidth =
@@ -127,6 +138,23 @@ namespace jdi
width = std::max(width, kEdgePanelPad * 2.0f * m_scale + rowWidth); 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) if (weakPeak)
{ {
// 滑块行 = 「标签列 + 轨道(最小宽度)+ 数值列 + 复位按钮」(等级与迭代次数两行共用同一列宽)。 // 滑块行 = 「标签列 + 轨道(最小宽度)+ 数值列 + 复位按钮」(等级与迭代次数两行共用同一列宽)。
@@ -233,7 +261,9 @@ namespace jdi
const bool weakPeak = (kind == EdgePanelKind::PEAK); const bool weakPeak = (kind == EdgePanelKind::PEAK);
const bool freeMarker = (kind == EdgePanelKind::MARK); 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 hasButton = (meanFilter || weakPeak);
const bool enabled = meanFilter ? lane.meanFilter const bool enabled = meanFilter ? lane.meanFilter
: (weakPeak ? lane.weakPeak : (weakPeak ? lane.weakPeak
@@ -286,12 +316,43 @@ namespace jdi
// + 参数部分(**只在该条目被勾选时才占位**,因此面板高度随勾选数量动态变化): // + 参数部分(**只在该条目被勾选时才占位**,因此面板高度随勾选数量动态变化):
// * MEAN / CENTER:一行「均值点数 X + 迭代次数 N」输入框(中心轴再多一行统计结果); // * MEAN / CENTER:一行「均值点数 X + 迭代次数 N」输入框(中心轴再多一行统计结果);
// * PEAK:两行滑块(调整幅度 % / 迭代次数 N),每个信号各有一套自己的参数。 // * PEAK:两行滑块(调整幅度 % / 迭代次数 N),每个信号各有一套自己的参数。
const bool itemHasParams = (centerAxis || meanFilter || weakPeak); const bool itemHasParams = (centerAxis || weakPeak);
const float weakPeakSliderRow = kWeakPeakSliderRowHeight * m_scale; const float weakPeakSliderRow = kWeakPeakSliderRowHeight * m_scale;
float meanRowHeight = 0.0f; float meanRowHeight = 0.0f;
float itemsHeight = 0.0f; float itemsHeight = 0.0f;
float meanContentHeight = 0.0f;
float height = 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; meanRowHeight = kEdgePanelMeanRowHeight * m_scale;
for (const SignalRef& ref : lane.signals) for (const SignalRef& ref : lane.signals)
@@ -501,7 +562,119 @@ namespace jdi
{ {
return layout; 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; const float buttonRowHeight = kEdgePanelButtonHeight * m_scale;
if (hasButton) if (hasButton)
@@ -1209,7 +1382,11 @@ namespace jdi
double bestValue = 0.0; double bestValue = 0.0;
double bestEnum = 0.0; double bestEnum = 0.0;
float bestY = 0.0f; 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) for (const SignalRef& ref : lane.signals)
{ {
const ResolvedSignal resolved = ResolveSignal(ref); const ResolvedSignal resolved = ResolveSignal(ref);
@@ -1218,9 +1395,10 @@ namespace jdi
{ {
continue; continue;
} }
// 「自动点线」判据成立时吸附到最近采样点(与光标吸附同一规则)。 // 「自动点线」判据只用于决定要不要画“已吸附”圆环 ⇒ **不在循环里逐个信号算**:
// ⚠️ 用户明确要求:只要判据成立就吸附,**不**管当前是散点还是用户自己改过的样式。 // 它的代价是“每个小格两次二分”(视界 50 格 × 上百信号 = 数万次比较/帧),
const bool sparse = MinorCellSparse(*resolved.axis, *resolved.data, resolved.style->offsetTime); // 而**只有最后胜出的那一个信号**需要它(用户 2026-10-30 报告的卡顿根因之一)。
// 见循环结束后的统一判定。
// ---- ① 取“鼠标到这条曲线”的距离(复用绘制阶段的折线顶点)---- // ---- ① 取“鼠标到这条曲线”的距离(复用绘制阶段的折线顶点)----
float nearestX = fx; float nearestX = fx;
@@ -1348,13 +1526,22 @@ namespace jdi
ApplyDrawTransform(*resolved.style, candidateSample); ApplyDrawTransform(*resolved.style, candidateSample);
if (std::isfinite(candidateValue)) if (std::isfinite(candidateValue))
{ {
// ⚠️⚠️ 距离必须包含**横向**分量(用户 2026-10-30 报告):
// 只看纵向距离时,一个“单数据点信号”只要它的点在鼠标的**高度**附近,
// 就会在整幅宽度内一直胜出 —— 光标被它“吸住”,无法标注另一个常规信号。
// 改为鼠标到**该采样点屏幕位置**的欧氏距离后:稠密信号的横向分量≈ 0(仍等价于
// “纵向最近”);单点/稀疏信号只有鼠标真的靠近它时才会被选中。
const double pointX = static_cast<double>(TimeToX(candidateTime));
const double dx = pointX - static_cast<double>(fx);
const double vertical = std::fabs( const double vertical = std::fabs(
static_cast<double>(ValueToY(candidateValue, visual)) - static_cast<double>(ValueToY(candidateValue, visual)) -
static_cast<double>(fy)); static_cast<double>(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; distance = sampleDistance;
nearestX = fx; nearestX = static_cast<float>(pointX);
time = candidateTime; time = candidateTime;
sampled = candidateSample; sampled = candidateSample;
candidateOk = true; candidateOk = true;
@@ -1368,9 +1555,6 @@ namespace jdi
continue; continue;
} }
// 固定点必须落在真实采样时刻上(两个候选都已吸附到采样点)。 // 固定点必须落在真实采样时刻上(两个候选都已吸附到采样点)。
// 表示“已吸附”的圆环仍只在「自动点线」判据成立(`sparse`)时画:
// 密采样信号里吸附量远小于 1 像素,圆环会一直挂着,反而像是画错了。
const bool snapped = sparse;
const double value = ApplyDrawTransform(*resolved.style, sampled); const double value = ApplyDrawTransform(*resolved.style, sampled);
if (!std::isfinite(value)) if (!std::isfinite(value))
{ {
@@ -1386,7 +1570,9 @@ namespace jdi
bestValue = value; bestValue = value;
bestEnum = sampled; bestEnum = sampled;
bestY = y; bestY = y;
bestSnapped = snapped; bestAxis = resolved.axis;
bestData = resolved.data;
bestOffsetTime = resolved.style->offsetTime;
probe.ref = ref; probe.ref = ref;
} }
@@ -1398,11 +1584,20 @@ namespace jdi
} }
probe.valid = true; probe.valid = true;
probe.time = bestTime; probe.time = bestTime;
probe.x = TimeToX(bestTime); // ⚠️ 纵向光标**画在鼠标所在的 x**(用户 2026-10-30 明确要求:纵向光标跟随鼠标,
// 被标注的数据点才是"离鼠标最近"的那个)。旧实现把竖线画在吸附后的采样时刻,
// 于是稀疏/单点信号会让整条竖线"跳"到数据点上,观感像光标被吸走了。
probe.x = fx;
probe.pointX = TimeToX(bestTime);
probe.value = bestValue; probe.value = bestValue;
probe.enumValue = bestEnum; probe.enumValue = bestEnum;
probe.y = bestY; probe.y = bestY;
probe.snapped = bestSnapped; // 「已吸附」圆环只在「自动点线」稀疏判据成立时画(密采样信号里吸附量 < 1 像素,
// 圆环会一直挂着反而像画错了)。**只对胜出的这一个信号算**(见循环里的说明)。
if (bestAxis != nullptr && bestData != nullptr)
{
probe.snapped = MinorCellSparse(*bestAxis, *bestData, bestOffsetTime);
}
return probe; return probe;
} }
+2 -2
View File
@@ -1446,7 +1446,7 @@ namespace jdi
g.SetTextRenderingHint(gd::TextRenderingHintClearTypeGridFit); g.SetTextRenderingHint(gd::TextRenderingHintClearTypeGridFit);
UiFillRect(g, m_area, m_theme->plotBg); UiFillRect(g, m_area, m_theme->plotBg);
UiFillRect(g, m_area, m_theme->cardBg); UiFillRect(g, m_area, ThemeLaneBackground(*m_theme));
// 竖向网格刻度沿用**前台视界跨度**(而非宽窗口的跨度),否则竖网格线会只有一半。 // 竖向网格刻度沿用**前台视界跨度**(而非宽窗口的跨度),否则竖网格线会只有一半。
// ⚠️ 同时把它作为竖向网格的**主刻步长**传给 `PaintLane`(见 `m_renderTimeStep`): // ⚠️ 同时把它作为竖向网格的**主刻步长**传给 `PaintLane`(见 `m_renderTimeStep`):
@@ -1948,7 +1948,7 @@ namespace jdi
//(用户 2026-10-17 报告:滚动过渡动画在巨型信号处卡顿)。 //(用户 2026-10-17 报告:滚动过渡动画在巨型信号处卡顿)。
// 这一情形在滚动到**从未构建过图层的区域**时必然出现(上百个网格时绝大多数 // 这一情形在滚动到**从未构建过图层的区域**时必然出现(上百个网格时绝大多数
// 网格的图层都还没建立):旧实现在这里 `return false` ⇒ 落到 `Paint` 末尾的 // 网格的图层都还没建立):旧实现在这里 `return false` ⇒ 落到 `Paint` 末尾的
// **整幅重绘** ⇒ 把**本帧全部可见网格的波形**(可能包含上千�万采样的巨型信号) // **整幅重绘** ⇒ 把**本帧全部可见网格的波形**(可能包含上千万采样的巨型信号)
// 每帧重画一遍 —— 实测单帧 **20~35ms**,而手动拖滚动条只要 ~6ms。 // 每帧重画一遍 —— 实测单帧 **20~35ms**,而手动拖滚动条只要 ~6ms。
// 正确行为:本帧只画**网格底衬**(廉价,见下方),波形等视界真正停下之后再按预算补 // 正确行为:本帧只画**网格底衬**(廉价,见下方),波形等视界真正停下之后再按预算补
//(这正是用户要求的“没来得及画信号时就按只有网格叠底的状态继续滚动”)。 //(这正是用户要求的“没来得及画信号时就按只有网格叠底的状态继续滚动”)。
+14
View File
@@ -355,6 +355,20 @@ namespace jdi
void UiFillRoundRects(gd::Graphics& g, const std::vector<gd::RectF>& rects, float radius, void UiFillRoundRects(gd::Graphics& g, const std::vector<gd::RectF>& rects, float radius,
std::uint32_t argb); 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 } // namespace jdi
#endif // __PAINT_UTIL_HPP__ #endif // __PAINT_UTIL_HPP__
+3 -30
View File
@@ -18,6 +18,7 @@
#include <cstdint> #include <cstdint>
#include "Theme.hpp" #include "Theme.hpp"
#include "PaintUtil.hpp"
namespace jdi namespace jdi
{ {
@@ -63,36 +64,8 @@ namespace jdi
*/ */
inline void PaintResetIcon(gd::Graphics& g, const gd::RectF& rect, std::uint32_t color, float scale) inline void PaintResetIcon(gd::Graphics& g, const gd::RectF& rect, std::uint32_t color, float scale)
{ {
const float inset = 2.5f * scale; // 唯一实现见 `PaintUtil.hpp`(信号样式区的复位按钮、弱峰调整面板的复位控件共用)。
gd::RectF box(rect.X + inset, rect.Y + inset, rect.Width - inset * 2.0f, UiPaintResetIcon(g, rect, color, scale);
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);
} }
} // namespace pmdetail } // namespace pmdetail
+61
View File
@@ -74,6 +74,51 @@ namespace jdi
return result; 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 派生)。 * @brief 界面主题色板(Solarised Light 派生)。
*/ */
@@ -152,6 +197,22 @@ namespace jdi
*/ */
void SetCurrentTheme(const Theme* theme); 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 获取波形默认配色表。 * @brief 获取波形默认配色表。
* @param [OUT] count 配色数量输出。 * @param [OUT] count 配色数量输出。
+36
View File
@@ -676,4 +676,40 @@ namespace jdi
g.ReleaseHDC(hdc); 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 } // namespace jdi
+148 -9
View File
@@ -1,5 +1,8 @@
#include "Theme.hpp" #include "Theme.hpp"
#include <algorithm>
#include <cmath>
namespace jdi namespace jdi
{ {
@@ -32,8 +35,32 @@ namespace jdi
MakeArgb(kBase01), MakeArgb(kBase0), 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 theme;
theme.windowBg = MakeArgb(kBase3); theme.windowBg = MakeArgb(kBase3);
@@ -65,7 +92,7 @@ namespace jdi
theme.danger = MakeArgb(kRed); theme.danger = MakeArgb(kRed);
theme.warning = MakeArgb(kYellow); theme.warning = MakeArgb(kYellow);
return theme; return theme;
}(); }());
/** /**
* @brief 浅色主题(参考 VS Code 2026 的默认浅色配色)。 * @brief 浅色主题(参考 VS Code 2026 的默认浅色配色)。
@@ -73,7 +100,7 @@ namespace jdi
* 与 Solarised Light 的区别:底色是**中性白/浅灰**而不是暖米色, * 与 Solarised Light 的区别:底色是**中性白/浅灰**而不是暖米色,
* 强调色是 VS Code 的系统蓝 `#0078D4`,描边是中性灰。 * 强调色是 VS Code 的系统蓝 `#0078D4`,描边是中性灰。
*/ */
const Theme kLight = [] const Theme kLight = NormalizePlotContrast([]
{ {
Theme theme; Theme theme;
theme.windowBg = MakeArgb(0xFFFFFFu); // editor.background theme.windowBg = MakeArgb(0xFFFFFFu); // editor.background
@@ -96,6 +123,7 @@ namespace jdi
theme.selection = MakeArgb(0xE8E8E8u); theme.selection = MakeArgb(0xE8E8E8u);
// 白底上网格线必须够淡(否则抢波形),但主刻线要能与次级线区分。 // 白底上网格线必须够淡(否则抢波形),但主刻线要能与次级线区分。
// ⚠️ 这三个颜色会被 `NormalizePlotContrast` 按白底自动校正到可见水位。
theme.gridWeak = MakeArgb(0xEDEDEDu); theme.gridWeak = MakeArgb(0xEDEDEDu);
theme.gridStrong = MakeArgb(0xD6D6D6u); theme.gridStrong = MakeArgb(0xD6D6D6u);
theme.axisLine = MakeArgb(0xCECECEu); theme.axisLine = MakeArgb(0xCECECEu);
@@ -104,12 +132,12 @@ namespace jdi
theme.danger = MakeArgb(0xE51400u); // errorForeground theme.danger = MakeArgb(0xE51400u); // errorForeground
theme.warning = MakeArgb(0xBF8803u); theme.warning = MakeArgb(0xBF8803u);
return theme; return theme;
}(); }());
/** /**
* @brief 深色主题(参考 VS Code Dark+)。 * @brief 深色主题(参考 VS Code Dark+)。
*/ */
const Theme kDark = [] const Theme kDark = NormalizePlotContrast([]
{ {
Theme theme; Theme theme;
theme.windowBg = MakeArgb(0x1E1E1Eu); // editor.background theme.windowBg = MakeArgb(0x1E1E1Eu); // editor.background
@@ -131,6 +159,8 @@ namespace jdi
theme.pressed = MakeArgb(0x37373Du); theme.pressed = MakeArgb(0x37373Du);
theme.selection = MakeArgb(0x04395Eu); theme.selection = MakeArgb(0x04395Eu);
// ⚠️ 深色底上的原始取值几乎与底色同亮度(差 2~3 个色阶)⇒ 交给
// `NormalizePlotContrast` 按 `plotBg` 自动提亮到可见水位。
theme.gridWeak = MakeArgb(0x2B2B2Bu); theme.gridWeak = MakeArgb(0x2B2B2Bu);
theme.gridStrong = MakeArgb(0x3A3A3Au); theme.gridStrong = MakeArgb(0x3A3A3Au);
theme.axisLine = MakeArgb(0x4A4A4Au); theme.axisLine = MakeArgb(0x4A4A4Au);
@@ -139,7 +169,7 @@ namespace jdi
theme.danger = MakeArgb(0xF44747u); theme.danger = MakeArgb(0xF44747u);
theme.warning = MakeArgb(0xCCA700u); theme.warning = MakeArgb(0xCCA700u);
return theme; return theme;
}(); }());
/** /**
* @brief Solarised Dark(参考 VS Code 的 Solarized Dark)。 * @brief Solarised Dark(参考 VS Code 的 Solarized Dark)。
@@ -147,7 +177,7 @@ namespace jdi
* 底色用 base03、面板用 base02,强调色仍是 Solarised 那一套 —— 因此波形配色表 * 底色用 base03、面板用 base02,强调色仍是 Solarised 那一套 —— 因此波形配色表
* (`SignalPalette`)与 Solarised Light 完全一致。 * (`SignalPalette`)与 Solarised Light 完全一致。
*/ */
const Theme kSolarizedDark = [] const Theme kSolarizedDark = NormalizePlotContrast([]
{ {
Theme theme; Theme theme;
theme.windowBg = MakeArgb(kBase03); theme.windowBg = MakeArgb(kBase03);
@@ -177,12 +207,121 @@ namespace jdi
theme.danger = MakeArgb(kRed); theme.danger = MakeArgb(kRed);
theme.warning = MakeArgb(kYellow); theme.warning = MakeArgb(kYellow);
return theme; return theme;
}(); }());
/** @brief 全局「当前主题」指针(由宿主设置,见 `SetCurrentTheme`)。 */ /** @brief 全局「当前主题」指针(由宿主设置,见 `SetCurrentTheme`)。 */
const Theme* g_currentTheme = &kSolarizedLight; 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<double>(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) const Theme& ThemeFor(ThemeKind kind)
{ {
switch (kind) switch (kind)
@@ -502,6 +502,20 @@ namespace jdi
OPACITY_VALUE, /**< 不透明度数值框(可点击输入)。 */ OPACITY_VALUE, /**< 不透明度数值框(可点击输入)。 */
WIDTH_SLIDER, /**< 线宽滑块。 */ WIDTH_SLIDER, /**< 线宽滑块。 */
WIDTH_VALUE, /**< 线宽数值框(可点击输入)。 */ WIDTH_VALUE, /**< 线宽数值框(可点击输入)。 */
/**
* @brief 线宽行右侧的**复位按钮**(把线宽恢复为该信号的默认值)。
*
* 默认值:普通信号 = `kDefaultSignalLineWidth`(1.0 逻辑像素),
* 单数据点信号 = `kSinglePointSignalLineWidth`(最大线宽:散点半径随线宽增长)。
* 图标与「系数/偏移」二级菜单标题栏的还原图标**完全同款**(`UiPaintResetIcon`)。
*/
WIDTH_RESET,
/**
* @brief 透明度行右侧的**复位按钮**(把不透明度恢复为 100%)。
*
* 与 `WIDTH_RESET` 同一套图标与悬停观感,两者右边界与内容列右缘对齐。
*/
OPACITY_RESET,
GAIN_ADJUST, /**< 「系数」按钮(点击展开系数二级面板)。 */ GAIN_ADJUST, /**< 「系数」按钮(点击展开系数二级面板)。 */
MIRROR_ADJUST, /**< 「镜像」按钮(点击展开镜像二级面板)。 */ MIRROR_ADJUST, /**< 「镜像」按钮(点击展开镜像二级面板)。 */
ABSOLUTE_TOGGLE, /**< 「绝对值」切换按钮。 */ ABSOLUTE_TOGGLE, /**< 「绝对值」切换按钮。 */
@@ -1228,6 +1242,21 @@ namespace jdi
*/ */
bool ApplyOpacityFromX(float x); bool ApplyOpacityFromX(float x);
/**
* @brief 把当前目标信号的**线宽恢复为默认值**(「线宽」行右侧复位按钮)。
*
* 默认值与新建信号时同一来源(`DefaultSignalLineWidth(finiteCount)`):
* 普通信号 1.0 逻辑像素、**单数据点信号**取最大线宽(散点半径随线宽增长)。
* @return 取值发生变化返回 true。
*/
bool ResetStyleLineWidth();
/**
* @brief 把当前目标信号的**不透明度恢复为 100%**(「透明度」行右侧复位按钮)。
* @return 取值发生变化返回 true。
*/
bool ResetStyleOpacity();
/** /**
* @brief 断言某「样式」选项对当前目标信号是否可用(不可用时置灰且不可点)。 * @brief 断言某「样式」选项对当前目标信号是否可用(不可用时置灰且不可点)。
* *
@@ -1285,6 +1314,15 @@ namespace jdi
gd::RectF m_opacityValueRect; /**< 不透明度数值文本矩形。 */ gd::RectF m_opacityValueRect; /**< 不透明度数值文本矩形。 */
gd::RectF m_widthTrackRect; /**< 线宽滑块轨道矩形。 */ gd::RectF m_widthTrackRect; /**< 线宽滑块轨道矩形。 */
gd::RectF m_widthValueRect; /**< 线宽数值文本矩形(双击可输入)。 */ gd::RectF m_widthValueRect; /**< 线宽数值文本矩形(双击可输入)。 */
/**
* @brief 线宽行右侧的**复位按钮**矩形(正方形,右边界与内容列右缘对齐)。
*
* 点击把该信号的线宽恢复为默认值(单数据点信号与普通信号的默认线宽不同)。
* 宽高为 0 表示当前不显示(没有目标信号)。
*/
gd::RectF m_widthResetRect;
/** @brief 透明度行右侧的**复位按钮**矩形(与 `m_widthResetRect` 同一列、同一尺寸)。 */
gd::RectF m_opacityResetRect;
/** 「调整」行的三个等宽按钮(下标 = 内部常量 `kAdjust*`):0 = 系数/偏移,1 = 镜像,2 = 绝对值。 */ /** 「调整」行的三个等宽按钮(下标 = 内部常量 `kAdjust*`):0 = 系数/偏移,1 = 镜像,2 = 绝对值。 */
std::array<gd::RectF, 3> m_adjustRects; std::array<gd::RectF, 3> m_adjustRects;
@@ -174,8 +174,15 @@ namespace jdi
inline const float kSwatchMaxGap = 10.0f; /**< 颜色色块最大间距(用于铺满内容列)。 */ inline const float kSwatchMaxGap = 10.0f; /**< 颜色色块最大间距(用于铺满内容列)。 */
inline const float kSliderValueWidth = 52.0f; /**< 滑块行数值文本宽度(透明度与线宽一致)。 */ inline const float kSliderValueWidth = 52.0f; /**< 滑块行数值文本宽度(透明度与线宽一致)。 */
inline const float kSliderValueGap = 8.0f; /**< 滑块轨道与数值文本间距。 */ inline const float kSliderValueGap = 8.0f; /**< 滑块轨道与数值文本间距。 */
inline const float kTrackHeight = 6.0f; /**< 滑块轨道高度。 */ inline const float kTrackHeight = 6.0f; /**< 滑块轨道高度。 */ /**
inline const float kAdjustGap = 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; /**< 「系数/偏移」「镜像」二级面板与按钮左上角的额外间距。 */ inline const float kAdjustMenuGap = 4.0f; /**< 「系数/偏移」「镜像」二级面板与按钮左上角的额外间距。 */
/** 「调整」行的按钮下标(与 `m_adjustRects` 同序)。 */ /** 「调整」行的按钮下标(与 `m_adjustRects` 同序)。 */
+24 -6
View File
@@ -119,8 +119,10 @@ namespace jdi
} }
boxes.Add(m_widthTrackRect, 3); boxes.Add(m_widthTrackRect, 3);
boxes.Add(m_widthValueRect, 3); boxes.Add(m_widthValueRect, 3);
boxes.Add(m_widthResetRect, 3);
boxes.Add(m_opacityTrackRect, 3); boxes.Add(m_opacityTrackRect, 3);
boxes.Add(m_opacityValueRect, 3); boxes.Add(m_opacityValueRect, 3);
boxes.Add(m_opacityResetRect, 3);
// ── 「目标」行(样式区顶部,展示当前选中信号) ──────────────────── // ── 「目标」行(样式区顶部,展示当前选中信号) ────────────────────
boxes.Add(m_targetRowRect, 2); boxes.Add(m_targetRowRect, 2);
@@ -972,19 +974,35 @@ namespace jdi
} }
// ---- 线宽行与透明度行:几何完全一致,保证纵向宽度对齐(线宽在上、透明度在下)---- // ---- 线宽行与透明度行:几何完全一致,保证纵向宽度对齐(线宽在上、透明度在下)----
const float valueWidth = std::min(kSliderValueWidth * m_scale, contentWidth * 0.4f); // 两行右端各有一个**复位按钮**(用户 2026-10-29 要求):
const float trackWidth = std::max(24.0f, contentWidth - valueWidth - kSliderValueGap * m_scale); // 按钮贴齐内容列右缘,数值文本与轨道在它左侧依次收缩 ⇒ 不会超出内容列。
// 内容列太窄(放不下"轨道 + 数值 + 按钮")时整列不显示复位按钮,避免把滑块挤没。
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 trackHeight = kTrackHeight * m_scale;
const float valueLeft = contentLeft + trackWidth + kSliderValueGap * m_scale;
const float resetLeft = contentLeft + contentWidth - resetSize;
m_widthTrackRect = m_widthTrackRect =
gd::RectF(contentLeft, widthRow.Y + (rowHeight - trackHeight) * 0.5f, trackWidth, trackHeight); gd::RectF(contentLeft, widthRow.Y + (rowHeight - trackHeight) * 0.5f, trackWidth, trackHeight);
m_widthValueRect = gd::RectF(contentLeft + trackWidth + kSliderValueGap * m_scale, widthRow.Y, m_widthValueRect = gd::RectF(valueLeft, widthRow.Y, valueWidth, rowHeight);
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, m_opacityTrackRect = gd::RectF(contentLeft, opacityRow.Y + (rowHeight - trackHeight) * 0.5f,
trackWidth, trackHeight); trackWidth, trackHeight);
m_opacityValueRect = gd::RectF(contentLeft + trackWidth + kSliderValueGap * m_scale, opacityRow.Y, m_opacityValueRect = gd::RectF(valueLeft, opacityRow.Y, valueWidth, rowHeight);
valueWidth, rowHeight); m_opacityResetRect =
(resetSize > 0.0f)
? gd::RectF(resetLeft, opacityRow.Y + (rowHeight - resetSize) * 0.5f, resetSize, resetSize)
: gd::RectF();
// 「目标」行的子几何依赖目标信号的名称与可见性(可能在未重排时变化), // 「目标」行的子几何依赖目标信号的名称与可见性(可能在未重排时变化),
// 因此每次重排后让缓存失效,下一次绘制 / 命中测试时按需重算。 // 因此每次重排后让缓存失效,下一次绘制 / 命中测试时按需重算。
+50 -1
View File
@@ -65,7 +65,9 @@ namespace jdi
if (UiPointInRect(m_adjustRects[kAdjustGain], fx, fy) || if (UiPointInRect(m_adjustRects[kAdjustGain], fx, fy) ||
UiPointInRect(m_adjustRects[kAdjustMirror], fx, fy) || UiPointInRect(m_adjustRects[kAdjustMirror], fx, fy) ||
UiPointInRect(m_adjustRects[kAdjustAbsolute], fx, fy) || UiPointInRect(m_widthValueRect, 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); return LoadCursorW(nullptr, IDC_HAND);
} }
@@ -652,4 +654,51 @@ namespace jdi
return true; 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 } // namespace jdi
@@ -195,6 +195,17 @@ namespace jdi
{ {
hoverStyle = StyleHit::WIDTH_VALUE; 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)) if (hoverStyle == StyleHit::NONE && UiPointInRect(m_adjustRects[kAdjustGain], fx, fy))
{ {
hoverStyle = StyleHit::GAIN_ADJUST; hoverStyle = StyleHit::GAIN_ADJUST;
@@ -424,6 +435,20 @@ namespace jdi
ScrollToSignalAnimated(SelectedRef()); ScrollToSignalAnimated(SelectedRef());
return BeginEdit(StyleEditField::OPACITY); 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)) if (UiPointInRect(m_adjustRects[kAdjustGain], fx, fy))
@@ -821,6 +846,12 @@ namespace jdi
{ {
return false; 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(); UpdateTargetRowGeometry();
if (m_targetNameRect.Width > 0.0f && UiPointInRect(m_targetNameRect, fx, fy)) if (m_targetNameRect.Width > 0.0f && UiPointInRect(m_targetNameRect, fx, fy))
{ {
@@ -768,6 +768,9 @@ namespace jdi
m_theme->textMuted, TextAlign::CENTER); m_theme->textMuted, TextAlign::CENTER);
return; return;
} }
// 目标信号本身:线宽复位按钮需要它的有效采样点数(单点信号默认用最大线宽)。
const SignalData* signal =
(m_dataSet != nullptr) ? m_dataSet->Resolve(SelectedRef()) : nullptr;
const float labelWidth = kStyleLabelWidth * m_scale; const float labelWidth = kStyleLabelWidth * m_scale;
@@ -779,6 +782,27 @@ namespace jdi
m_theme->textMuted, TextAlign::LEFT); 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"颜色"); paintRowLabel(0, L"颜色");
@@ -888,7 +912,7 @@ namespace jdi
paintToggle(kAdjustAbsolute, L"绝对值", style->absolute, StyleHit::ABSOLUTE_TOGGLE); paintToggle(kAdjustAbsolute, L"绝对值", style->absolute, StyleHit::ABSOLUTE_TOGGLE);
} }
// ---- 第四行:线宽(数值可点击输入)---- // ---- 第四行:线宽(数值可点击输入;右侧为复位按钮)----
{ {
paintRowLabel(3, L"线宽"); paintRowLabel(3, L"线宽");
@@ -913,6 +937,9 @@ namespace jdi
: fullText; : fullText;
UiText(g, shown, m_fonts->monoSmall, m_widthValueRect, m_theme->textStrong, TextAlign::RIGHT); 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, UiText(g, std::to_wstring(percent) + L"%", m_fonts->monoSmall, m_opacityValueRect,
m_theme->textStrong, TextAlign::RIGHT); m_theme->textStrong, TextAlign::RIGHT);
} }
paintStyleReset(m_opacityResetRect, StyleHit::OPACITY_RESET,
std::fabs(style->opacity - 1.0f) > 0.001f);
} }
} }
+153
View File
@@ -8611,3 +8611,156 @@ PlotView:0 = 网格/图例/Y轴标尺/GX/时间轴/光标读数与取值胶囊
并把面板位置改到胶囊上方(`lane.freeMarkerPanel.x/y`)。 并把面板位置改到胶囊上方(`lane.freeMarkerPanel.x/y`)。
* `lbpanel` 的 "layer 1 只能是面板内容" 断言会随层号约定变化而失效:现在滚动条/回到最上方 * `lbpanel` 的 "layer 1 只能是面板内容" 断言会随层号约定变化而失效:现在滚动条/回到最上方
在 `base + 2`,因此各组件必须按 `kLayersPerComponent = 3` 取层。 在 `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` 找不到)。
+42
View File
@@ -48,6 +48,24 @@ namespace jdi
*/ */
constexpr float kSinglePointSignalLineWidth = kSignalLineWidthMax; 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 「稀疏信号」缓存有效采样点时刻/取值的上限(有效点不超过该值才缓存)。 * @brief 「稀疏信号」缓存有效采样点时刻/取值的上限(有效点不超过该值才缓存)。
* *
@@ -752,6 +770,21 @@ namespace jdi
SignalRef ref; /**< 目标信号。 */ SignalRef ref; /**< 目标信号。 */
int points = 5; /**< 均值点数 X(滑平半径,窗口 = 2X+1)。 */ int points = 5; /**< 均值点数 X(滑平半径,窗口 = 2X+1)。 */
int iterations = 1; /**< 迭代次数 N(重复滤波次数,至少 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; float freeMarkerScroll = 0.0f;
/**
* @brief 「均值滤波」面板内容的滚动偏移(客户区像素,0 = 顶部)。
*
* 面板内容(每个信号的"信号色叠底标题栏 + 参数滑块行")超出可视高度时右侧出现滚动条;
* 底部的「构建为新信号」按钮**不参与**滚动(固定在面板底部)。
* 上百个信号合并到同一网格时它是必备的(否则面板会被顶出绘图区)。
*/
float meanFilterScroll = 0.0f;
/** /**
* @brief 「自由标记」面板里被**收起**的信号分组(点击分组栏切换)。 * @brief 「自由标记」面板里被**收起**的信号分组(点击分组栏切换)。
* *
+4
View File
@@ -140,6 +140,8 @@ namespace jdi
bool freeMarkerShowDelta = false; bool freeMarkerShowDelta = false;
bool freeMarkerShowEnum = false; bool freeMarkerShowEnum = false;
std::vector<SignalRef> freeMarkerCollapsedGroups; std::vector<SignalRef> freeMarkerCollapsedGroups;
/** @brief 「均值滤波」面板内容的滚动偏移(详见 `Lane::meanFilterScroll`)。 */
float meanFilterScroll = 0.0f;
}; };
// 先记下每个信号原来所在网格的网格自身状态: // 先记下每个信号原来所在网格的网格自身状态:
@@ -174,6 +176,7 @@ namespace jdi
carry.meanFilter = lane.meanFilter; carry.meanFilter = lane.meanFilter;
carry.meanFilterItems = lane.meanFilterItems; carry.meanFilterItems = lane.meanFilterItems;
carry.meanFilterPanel = lane.meanFilterPanel; carry.meanFilterPanel = lane.meanFilterPanel;
carry.meanFilterScroll = lane.meanFilterScroll;
carry.weakPeak = lane.weakPeak; carry.weakPeak = lane.weakPeak;
carry.weakPeakItems = lane.weakPeakItems; carry.weakPeakItems = lane.weakPeakItems;
carry.weakPeakPanel = lane.weakPeakPanel; carry.weakPeakPanel = lane.weakPeakPanel;
@@ -255,6 +258,7 @@ namespace jdi
lane.freeMarkerShowDelta = carry.freeMarkerShowDelta; lane.freeMarkerShowDelta = carry.freeMarkerShowDelta;
lane.freeMarkerShowEnum = carry.freeMarkerShowEnum; lane.freeMarkerShowEnum = carry.freeMarkerShowEnum;
lane.freeMarkerCollapsedGroups = carry.freeMarkerCollapsedGroups; lane.freeMarkerCollapsedGroups = carry.freeMarkerCollapsedGroups;
lane.meanFilterScroll = carry.meanFilterScroll;
}; };
// 收集全部可见信号引用。 // 收集全部可见信号引用。
+1 -2
View File
@@ -74,7 +74,6 @@ namespace jdi
style.lineWidth = kSinglePointSignalLineWidth; style.lineWidth = kSinglePointSignalLineWidth;
} }
} }
/** /**
* @brief 缓存「有效采样点很少」的信号的**全部有效点**(时刻 + 取值,按时间升序)。 * @brief 缓存「有效采样点很少」的信号的**全部有效点**(时刻 + 取值,按时间升序)。
* *
@@ -376,7 +375,7 @@ namespace jdi
signal.style.customColor = 0xFF859900u; signal.style.customColor = 0xFF859900u;
} }
signal.style.opacity = 1.0f; signal.style.opacity = 1.0f;
signal.style.lineWidth = 1.0f; signal.style.lineWidth = kDefaultSignalLineWidth;
signal.style.mode = DrawMode::LINE; signal.style.mode = DrawMode::LINE;
// 只有一个有效采样点的信号改用「散点」+ 最大线宽,否则根本看不见。 // 只有一个有效采样点的信号改用「散点」+ 最大线宽,否则根本看不见。
ApplySinglePointDefaultStyle(stats.finiteCount, signal.style); ApplySinglePointDefaultStyle(stats.finiteCount, signal.style);