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

① 主题自适应(`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

+417 -104
View File
@@ -113,29 +113,65 @@ namespace jdi
//(详见 `PolylineCacheEntry::lineWidth` 的说明;漏掉它会让线宽改动在**稠密信号**上不生效)。
const float lineWidth = (resolved.style != nullptr) ? resolved.style->lineWidth : 1.0f;
// 全部键字段的 64 位哈希(顺序固定;只用于**查找**,命中后再做廉价校验)。
std::uint64_t key = 1469598103934665603ull;
const auto mixHash = [&key](std::uint64_t value)
{
key ^= value;
key *= 1099511628211ull;
};
const auto mixHashFloat = [&mixHash](float value)
{
std::uint32_t bits = 0;
std::memcpy(&bits, &value, sizeof(bits));
mixHash(static_cast<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)
{
if (entry.ref == ref && entry.group == resolved.group && entry.sampleCount == sampleCount &&
entry.viewMin == viewMin && entry.viewMax == viewMax && entry.plotX == m_plotRect.X &&
entry.plotWidth == m_plotRect.Width && entry.laneY == lane.rect.Y &&
entry.laneHeight == lane.rect.Height && entry.laneYMin == lane.yMin &&
entry.laneYMax == lane.yMax && entry.gain == gain && entry.absolute == absolute &&
entry.mirrorVertical == mirrorVertical && entry.mirrorHorizontal == mirrorHorizontal &&
entry.offsetY == offsetY && entry.offsetTime == offsetTime &&
entry.lineWidth == lineWidth)
// 快路径:只比哈希。命中后再校验三项最可能变化的字段(碰撞代价可忽略)。
if (entry.key != key || entry.sampleCount != sampleCount || !(entry.ref == ref) ||
entry.viewMin != viewMin || entry.viewMax != viewMax)
{
return &entry;
continue;
}
return &entry;
}
// 未命中:重建并写入缓存(deque 保证已有元素的引用在追加后依然有效)。
// 缓存只用于消除重复计算,容量上限约等于“网格数 × 每网格信号数”的两倍。
if (m_polylineCache.size() >= kPolylineCacheLimit)
// ⚠️ 容量必须**覆盖所有网格的全部信号**:旧实现固定 64 条,283 个信号合并到
// 同一网格时会不断"清空重建"⇒ 绘制与自由标记探测每帧重算全部折线(严重卡顿)。
if (m_polylineCache.size() >= m_polylineCacheCapacity)
{
m_polylineCache.clear();
}
m_polylineCache.push_back(PolylineCacheEntry());
PolylineCacheEntry& entry = m_polylineCache.back();
entry.key = key;
entry.ref = ref;
entry.group = resolved.group;
entry.sampleCount = sampleCount;
@@ -656,104 +692,102 @@ namespace jdi
// 下面这三样在 `paintOverlays` 里绘制在**浮动面板之后**(压在面板之上):
// 滚动条、右下角「回到最上方」悬浮按钮、内联输入框。
// ⇒ 必须比面板层(`baseLayer + 1`)再高一层,否则与实际像素的层序相反。
boxes.SetLayer(baseLayer + 2);
boxes.Add(m_scrollBarRect, 5);
boxes.Add(BackToTopRect(), 5);
boxes.Add(EditFieldRect(), 5);
// ⇒ 它们的层号在下面「面板循环」之后统一给(比最上面板再高一层),
// 否则与实际像素的层序相反。这里先复位到组件基层。
boxes.SetLayer(baseLayer);
// ⚠️⚠️ **浮动面板必须另起一个绘制层,并把面板矩形登记为遮挡矩形**:
// ⚠️⚠️ **浮动面板必须另起绘制层,并把面板矩形登记为遮挡矩形**:
// 面板是不透明浮层、且常常**跨多个网格上下排列**,与网格区域重叠。
// 若与网格同层,网格的矩形边线会横穿面板 —— 用户 2026-10-29 报告的
// "网格的布局边界穿透显示在浮动面板上"。`layer` 决定谁压谁,
// `AddOccluder` 才真正把下层 box 从面板区域里**挖掉**。
bool panelLayerPushed = false;
// 层级 2/3/4:5 类实验性功能浮动面板的内部控件。
// 这些矩形由 `EdgePanelLayoutFor` 统一算出(绘制与命中共用),一个循环即可全部上报。
//
// ⚠️⚠️ **每个面板各占一个绘制层,且顺序必须是 `m_panelOrder`(底 → 顶)**:
// 旧实现把所有面板都放在 `baseLayer + 1` 一层,于是同一层内只能按 `depth` 排序 ——
// 下层面板的"行/文本"(depth 4)会画在**上层**面板的本体(depth 2/3)之上,
// 表现为"被遮挡的下层面板的边框/文字穿透到上层面板上"(用户 2026-10-30 报告)。
// 层号与 `paintOverlays` 里 `UiSetTextLayer` 的推进**完全同源**。
//
// 说明(已知边界):面板层号从 `baseLayer + 1` 起逐面板递增,因此**同时显示 15 个以上**
// 面板时会撞上浮层层号(`OverlayLayer(1)` = 组件数 × 3 + 3)。那时只有"菜单不再挖掉
// 面板边框"这一条调试观感受影响;实际布局下同时可见的面板是几个到十几个。
SyncPanelOrder();
const std::size_t panelCount = m_panelOrder.size();
const int topOverlayLayer = baseLayer + 1 + static_cast<int>(panelCount);
// ⚠️ **先用最便宜的判据跳过"没有面板"的网格**:`EdgePanelLayoutFor` 内部要取
// 标签页配置、逐项判断种类与开关,单次约 2~3µs;283 个网格 × 5 种 = 1415 次调用
// 实测约 **4ms/帧**(这是"开调试后卡"的主要来源之一)。
// 只有该网格**至少有一个面板处于展开状态**时才值得去算布局。
const std::vector<Lane>& laneConfigs =
(m_tab != nullptr) ? m_tab->Lanes() : m_localTab.Lanes();
for (std::size_t index = 0; index < m_lanes.size() && index < laneConfigs.size(); ++index)
// 现在只对**层序表里真正显示出来的面板**算布局(层序表本来就只含展开且启用的面板)。
for (std::size_t ordinal = 0; ordinal < panelCount; ++ordinal)
{
const Lane& laneConfig = laneConfigs[index];
const bool anyPanelOpen = laneConfig.edgePanel.open ||
laneConfig.edgeCenterPanel.open ||
laneConfig.meanFilterPanel.open ||
laneConfig.weakPeakPanel.open ||
laneConfig.freeMarkerPanel.open;
if (!anyPanelOpen)
const PanelRef& panelRef = m_panelOrder[ordinal];
const EdgePanelLayout layout =
EdgePanelLayoutFor(panelRef.laneIndex, static_cast<EdgePanelKind>(panelRef.kind));
if (!UiRectHasArea(layout.panel))
{
continue;
}
const int laneIndex = static_cast<int>(index);
for (int kindValue = static_cast<int>(EdgePanelKind::COUNT);
kindValue <= static_cast<int>(EdgePanelKind::MARK); ++kindValue)
// 本面板独占一层(层号越大越靠上)。
boxes.SetLayer(baseLayer + 1 + static_cast<int>(ordinal));
boxes.AddOccluder(layout.panel);
// 面板内部元素的调试框同样裁到面板本身:面板滚动的内容区里,
// 滚出可视区的行其矩形仍在(与弹出菜单的条目同一类问题)。
boxes.SetClip(layout.panel);
boxes.Add(layout.panel, 2);
boxes.Add(layout.title, 3);
boxes.Add(layout.close, 3);
boxes.Add(layout.disclosure, 3);
boxes.Add(layout.badge, 3);
boxes.Add(layout.edit, 3);
boxes.Add(layout.button, 3);
boxes.Add(layout.content, 3);
boxes.Add(layout.scrollTrack, 3);
boxes.Add(layout.scrollThumb, 4);
boxes.Add(layout.opacityTrack, 3);
boxes.Add(layout.opacityValue, 3);
boxes.Add(layout.showDelta, 3);
boxes.Add(layout.showEnum, 3);
boxes.AddAll(layout.rowChecks, 4);
boxes.AddAll(layout.rowPoints, 4);
boxes.AddAll(layout.rowIterations, 4);
boxes.AddAll(layout.rowStats, 4);
boxes.AddAll(layout.rowLevelTracks, 4);
boxes.AddAll(layout.rowLevelValues, 4);
boxes.AddAll(layout.rowIterTracks, 4);
boxes.AddAll(layout.rowIterValues, 4);
boxes.AddAll(layout.rowLevelResets, 4);
boxes.AddAll(layout.rowIterResets, 4);
boxes.AddAll(layout.rowNameRects, 4);
boxes.AddAll(layout.rowNameCloses, 4);
boxes.AddAll(layout.rowTriangles, 4);
boxes.AddAll(layout.rowHeaders, 4);
for (const EdgePanelLayout::MarkerGroup& group : layout.markerGroups)
{
const EdgePanelLayout layout =
EdgePanelLayoutFor(laneIndex, static_cast<EdgePanelKind>(kindValue));
if (!UiRectHasArea(layout.panel))
{
continue;
}
if (!panelLayerPushed)
{
boxes.SetLayer(baseLayer + 1);
panelLayerPushed = true;
}
boxes.AddOccluder(layout.panel);
// 面板内部元素的调试框同样裁到面板本身:面板滚动的内容区里,
// 滚出可视区的行其矩形仍在(与弹出菜单的条目同一类问题)。
boxes.SetClip(layout.panel);
boxes.Add(layout.panel, 2);
boxes.Add(layout.title, 3);
boxes.Add(layout.close, 3);
boxes.Add(layout.disclosure, 3);
boxes.Add(layout.badge, 3);
boxes.Add(layout.edit, 3);
boxes.Add(layout.button, 3);
boxes.Add(layout.content, 3);
boxes.Add(layout.scrollTrack, 3);
boxes.Add(layout.scrollThumb, 4);
boxes.Add(layout.opacityTrack, 3);
boxes.Add(layout.opacityValue, 3);
boxes.Add(layout.showDelta, 3);
boxes.Add(layout.showEnum, 3);
boxes.AddAll(layout.rowChecks, 4);
boxes.AddAll(layout.rowPoints, 4);
boxes.AddAll(layout.rowIterations, 4);
boxes.AddAll(layout.rowStats, 4);
boxes.AddAll(layout.rowLevelTracks, 4);
boxes.AddAll(layout.rowLevelValues, 4);
boxes.AddAll(layout.rowIterTracks, 4);
boxes.AddAll(layout.rowIterValues, 4);
boxes.AddAll(layout.rowLevelResets, 4);
boxes.AddAll(layout.rowIterResets, 4);
boxes.AddAll(layout.rowNameRects, 4);
boxes.AddAll(layout.rowNameCloses, 4);
for (const EdgePanelLayout::MarkerGroup& group : layout.markerGroups)
{
boxes.Add(group.header, 3);
boxes.Add(group.tableHeader, 3);
boxes.Add(group.triangle, 4);
boxes.Add(group.close, 4);
}
for (const EdgePanelLayout::MarkerRow& row : layout.markerRows)
{
boxes.Add(row.rect, 4);
boxes.Add(row.close, 4);
}
boxes.Add(group.header, 3);
boxes.Add(group.tableHeader, 3);
boxes.Add(group.triangle, 4);
boxes.Add(group.close, 4);
boxes.Add(group.check, 4);
}
for (const EdgePanelLayout::MarkerRow& row : layout.markerRows)
{
boxes.Add(row.rect, 4);
boxes.Add(row.close, 4);
}
}
// 收尾:解除面板裁剪并**复位层号**(宿主后续组件依赖它)。
// 浮动面板之上的叠加层(滚动条 / 回到最上方 / 内联输入框)必须比**最上面板**再高一层。
boxes.SetClip(gd::RectF());
boxes.SetLayer(topOverlayLayer);
boxes.Add(m_scrollBarRect, 5);
boxes.Add(BackToTopRect(), 5);
boxes.Add(EditFieldRect(), 5);
boxes.SetLayer(baseLayer);
}
@@ -848,17 +882,19 @@ namespace jdi
// 这样浮动面板仍压在最上层,与其它实验性功能的层序一致)。
PaintFreeMarker(g, m_lanes);
// 从这里开始都在面板之上:文本层同步 +1(与 `BuildBoxes` 的 `baseLayer + 1` 一致)。
// ⚠️ `PaintEdgeCursor` 内部会**逐面板**再把层号 +1(每个面板独占一层),
// 并在返回前把层号推到**最上面板之上**,因此这里不再写死 `+2`。
UiSetTextLayer(textLayer + 1);
PaintEdgeCursor(g, m_lanes);
// 之后这几样绘制在浮动面板**之上**:与 `BuildBoxes` 的 `baseLayer + 2` 对应。
UiSetTextLayer(textLayer + 2);
PaintSignalInteraction(g);
PaintHint(g);
// 网格 Y 轴上下限的输入框、以及「弱峰调整 → 迭代次数上限」的输入框都绘制在最上层
//(后者覆盖在浮动面板的滑块行之上)。
if (m_editTarget == PlotEditTarget::LANE_Y_MAX ||
m_editTarget == PlotEditTarget::LANE_Y_MIN ||
m_editTarget == PlotEditTarget::PEAK_ITERATIONS_MAX)
m_editTarget == PlotEditTarget::PEAK_ITERATIONS_MAX ||
m_editTarget == PlotEditTarget::MEAN_POINTS_MAX ||
m_editTarget == PlotEditTarget::MEAN_ITERATIONS_MAX)
{
PaintEditField(g);
}
@@ -1105,7 +1141,9 @@ namespace jdi
UiFillRect(g, m_area, m_theme->plotBg);
// 波形区域底色。
UiFillRect(g, m_plotRect, m_theme->cardBg);
// ⚠️ 「网格叠底」必须用 `ThemeLaneBackground()`:主题的 `cardBg` 与 `plotBg` 过于接近时
// (深色主题)它会自动拉开一点点,保证网格这一层结构在任何主题下都看得见。
UiFillRect(g, m_plotRect, ThemeLaneBackground(*m_theme));
// 图例命中矩形每帧重建,避免残留已消失的信号。
m_legendHits.clear();
@@ -1580,7 +1618,8 @@ namespace jdi
const gd::RectF gridClip = laneClip;
/** 标尺数值文本的裁剪矩形(GDI 文本需要显式裁剪,否则会越过绘图区)。 */
const gd::RectF clipRectForLabels = gridClip;
const std::uint32_t minorLineColor = UiBlendOver(m_theme->cardBg, WithAlpha(m_theme->gridWeak, kMinorLineAlpha));
const std::uint32_t laneBackground = ThemeLaneBackground(*m_theme);
const std::uint32_t minorLineColor = UiBlendOver(laneBackground, WithAlpha(m_theme->gridWeak, kMinorLineAlpha));
const std::uint32_t weakLineColor = m_theme->gridWeak | 0xFF000000u;
const std::uint32_t strongLineColor = m_theme->gridStrong | 0xFF000000u;
@@ -3145,12 +3184,14 @@ namespace jdi
const float limitTop = lane.rect.Y - lane.rect.Height * 4.0f;
const float limitBottom = lane.rect.Y + lane.rect.Height * 5.0f;
// 派生曲线的颜色:与原信号**同色相**但大幅拉开明度,使其与原始波形形成高对比
// (原信号改颜色时曲线随之改变,因为它是从信号色派生出来的);透明度与原信号一致。
// 派生曲线的颜色:与原信号**同色相**、亮度按**当前网格底色**自适应拉开,
// 使其与原始波形、与背景都形成足够对比(原信号改颜色时曲线随之改变,因为它是从信号色
// 派生出来的);透明度与原信号一致。
const unsigned int baseAlpha = static_cast<unsigned int>(
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)),
baseAlpha);
const std::uint32_t derived = WithAlpha(
HighContrastDerived(StyleColor(*resolved.style), ThemeLaneBackground(*m_theme)),
baseAlpha);
// 顶点预算与波形一致(每像素列两个顶点)。
// 交互期间进一步降到 1/2:派生曲线往往有 10 条(5 网格 × 均值滤波 + 弱峰调整),
@@ -3480,6 +3521,10 @@ namespace jdi
mix(lane.freeMarkerPanel.collapsed ? 1ull : 0ull);
mix(lane.edgeValueValid ? 1ull : 0ull);
mixDouble(lane.edgeValue);
// 「自由标记」面板内容区的滚动偏移(历史行为:计入全局键)。
// ⚠️ 「均值滤波」的滚动偏移只进**该面板自己的**槽键(见 `PanelSlotKey`),
// 否则滚动一个面板会让屏幕上所有面板一起重画。
mixFloat(lane.freeMarkerScroll);
}
}
@@ -3573,6 +3618,8 @@ namespace jdi
mixRect(group.tableHeader);
mixRect(group.triangle);
mixRect(group.close);
// 「均值滤波」的组头右端是复选框(「自由标记」为空矩形,不影响它的键)。
mixRect(group.check);
mix(group.collapsed ? 1ull : 0ull);
mix(group.pinned ? 1ull : 0ull);
mix(static_cast<std::uint64_t>(group.ref.groupIndex + 2));
@@ -3616,6 +3663,11 @@ namespace jdi
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->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);
mix(static_cast<std::uint64_t>(centerItem != nullptr ? centerItem->points + 2 : 0));
mix(static_cast<std::uint64_t>(centerItem != nullptr ? centerItem->iterations + 2 : 0));
@@ -3704,6 +3756,17 @@ namespace jdi
{
mixRect(rect);
}
// 「均值滤波」:信号色叠底标题栏 / 展开三角 / 内容区滚动偏移都要入键
//(滚动改变的是面板内部像素,位置与尺寸不变)。
for (const gd::RectF& rect : layout.rowHeaders)
{
mixRect(rect);
}
for (const gd::RectF& rect : layout.rowTriangles)
{
mixRect(rect);
}
mixFloat(layout.scrollOffset);
// 鼠标落在面板内时,✕ / 复选框 / 滑块 / 底部按钮的悬停态都依赖精确坐标。
const float margin = 6.0f * m_scale;
@@ -4067,12 +4130,23 @@ namespace jdi
//(与"可拖拽"的光晕区分:这里只是单点圆环,不描整条曲线)。
const float radius = 3.4f * m_scale;
gd::SolidBrush dot(ToGdiColor(signalColor));
g.FillEllipse(&dot, x - radius, probe.y - radius, radius * 2.0f, radius * 2.0f);
// 取值圆点与"已吸附"圆环都画在**数据点本身**的横坐标上(`pointX`),
// 而纵向光标画在鼠标位置(`x`)。稀疏信号下两者会分开 —— 这是刻意的:
// 光标跟随鼠标、提示落在真实采样点上(用户 2026-10-30 要求)。
float pointX = probe.pointX;
if (!(pointX > 0.0f))
{
pointX = x; // 兼容:未填 `pointX` 时退回光标位置
}
g.FillEllipse(&dot, pointX - radius, probe.y - radius, radius * 2.0f,
radius * 2.0f);
if (probe.snapped)
{
gd::Pen ring(ToGdiColor(lineColor), 1.2f * m_scale);
g.DrawEllipse(&ring, x - radius - 3.0f * m_scale, probe.y - radius - 3.0f * m_scale,
(radius + 3.0f * m_scale) * 2.0f, (radius + 3.0f * m_scale) * 2.0f);
g.DrawEllipse(&ring, pointX - radius - 3.0f * m_scale,
probe.y - radius - 3.0f * m_scale,
(radius + 3.0f * m_scale) * 2.0f,
(radius + 3.0f * m_scale) * 2.0f);
}
}
}
@@ -4094,6 +4168,9 @@ namespace jdi
const std::size_t count = std::min(lanes.size(), tabLanes.size());
const std::uint32_t color = CursorColor();
const UiInk ink = UiContrastInk(color);
// 进入本函数时的文本层号 = 宿主给本组件分配的面板层起始(`baseLayer + 1`)。
// 每个面板在循环里各用 `panelTextLayer + 面板序数`,函数结束前把层号推到**最上面板之上**。
const int panelTextLayer = UiGetTextLayer();
// ---- 横向光标(线与两端把手 + 左侧幅值读数)----
for (std::size_t index = 0; index < count; ++index)
@@ -4283,6 +4360,10 @@ namespace jdi
const PanelRef& panelRef = m_panelOrder[panelOrdinal];
const EdgePanelKind kind = static_cast<EdgePanelKind>(panelRef.kind);
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 meanFilter = (kind == EdgePanelKind::MEAN);
const bool weakPeak = (kind == EdgePanelKind::PEAK);
@@ -4353,11 +4434,43 @@ namespace jdi
}
if (hit != nullptr && hit->key != 0 && hit->key == slotKey)
{
// ⚠️⚠️ **只贴「面板圆角形状之内」的像素**(用户 2026-10-30 报告:
// 中键移动视界时面板周围带着一圈**旧视界的波形像素**)。
// 原因是缓存位图按 `panel ± cacheMargin` 取块,块的边缘那几像素是
// **上一帧的场景**(波形/网格线);`PanelGlobalKey` 刻意不含视界
// (否则平移会重画全部面板),于是整块贴回时就把旧波形"复印"了一圈。
// 现在:① 先把**投影**按本帧重新画一遍(它在圆角形状之外,不能靠缓存);
// ② 用圆角区域裁剪后只 BitBlt 面板本体(圆角之外仍是本帧场景)。
if (!m_fastFrame)
{
UiFillRoundRect(g, gd::RectF(panel.X + 1.0f * m_scale,
panel.Y + 2.0f * m_scale, panel.Width,
panel.Height),
5.0f * m_scale, 0x14000000u);
}
HDC target = g.GetHDC();
if (target != nullptr)
{
BitBlt(target, hit->x0, hit->y0, hit->x1 - hit->x0, hit->y1 - hit->y0,
m_panelDc, hit->x0, hit->y0, SRCCOPY);
const int bx0 = static_cast<int>(std::floor(panel.X)) - 1;
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);
}
// 贴图不会真的画字 ⇒ 必须把上一帧渲染时存下的文本框**回放**给
@@ -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 标记点」+ ✕),下属固定点条目六列对齐(序号 / 取值 / 枚举 / 增量 / 时刻 / 间隔),
// 行尾各带一个 ✕。内容区有滚动偏移时按内容区裁剪。
@@ -5166,6 +5464,10 @@ namespace jdi
m_panelSlots = std::move(nextSlots);
m_panelGlobalKey = panelGlobalKey;
m_panelValid = panelCacheReady;
// 面板之后绘制的叠加层(信号拖拽虚影 / 提示 / 输入框 / 滚动条 / 回到最上方)
// 都在**所有面板之上** ⇒ 文本层号推到最上面板的上一层(与 `BuildBoxes` 同源)。
UiSetTextLayer(panelTextLayer + static_cast<int>(m_panelOrder.size()));
}
void PlotView::PaintEditField(gd::Graphics& g)
@@ -5181,10 +5483,21 @@ namespace jdi
// 「迭代次数 N 上限」:输入框**以空内容开始**,未输入任何内容时以灰色提示当前上限值。
// 提示文案 = “输入上限(当前N)”(用户明确要求:旧实现只显示一个孤立的数字,缺少说明)。
if (m_editTarget == PlotEditTarget::PEAK_ITERATIONS_MAX && m_edit.Text().empty())
// 「均值滤波」两行滑块的上限输入框共用同一套提示文案。
if ((m_editTarget == PlotEditTarget::PEAK_ITERATIONS_MAX ||
m_editTarget == PlotEditTarget::MEAN_POINTS_MAX ||
m_editTarget == PlotEditTarget::MEAN_ITERATIONS_MAX) &&
m_edit.Text().empty())
{
const int currentMax = m_peakEditCurrentMax > 0 ? m_peakEditCurrentMax
: kWeakPeakIterationsDefaultMax;
const bool peakTarget = (m_editTarget == PlotEditTarget::PEAK_ITERATIONS_MAX);
const int currentMax =
peakTarget
? ((m_peakEditCurrentMax > 0) ? m_peakEditCurrentMax : kWeakPeakIterationsDefaultMax)
: ((m_meanEditCurrentMax > 0)
? m_meanEditCurrentMax
: ((m_editTarget == PlotEditTarget::MEAN_POINTS_MAX)
? kMeanFilterPointsDefaultMax
: kMeanFilterIterationsDefaultMax));
const std::wstring hint = L"输入上限(当前" + std::to_wstring(currentMax) + L")";
const float hintLeft = fieldRect.X + 11.0f * m_scale;
UiTextAtClipped(g, hint, m_fonts->monoSmall, hintLeft,