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

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

+13
View File
@@ -436,6 +436,19 @@ namespace jdi
return;
}
// 折线缓存容量 = 全部信号数 × 2(下限 `kPolylineCacheLimit`)。
// ⚠️ 必须随信号总数增长:固定 64 条在"上百个信号合并到一个网格"时会持续清表重建,
// 使绘制与自由标记探测每帧重算全部折线(用户 2026-10-30 报告的卡顿)。
{
std::size_t totalSignals = 0;
for (const Lane& lane : lanes)
{
totalSignals += lane.signals.size();
}
const std::size_t wanted = totalSignals * 2ull;
m_polylineCacheCapacity = (wanted > kPolylineCacheLimit) ? wanted : kPolylineCacheLimit;
}
const float gap = 0.0f;
float laneHeight = m_plotRect.Height / static_cast<float>(count);
if (laneHeight < m_minLaneHeight * m_scale)
+1 -1
View File
@@ -458,7 +458,7 @@ namespace jdi
if (cardBottom > cardTop)
{
UiFillRect(g, gd::RectF(patch.X, cardTop, patch.Width, cardBottom - cardTop),
m_theme->cardBg);
ThemeLaneBackground(*m_theme));
}
const double viewMin = Tab().ViewMin();
@@ -1789,6 +1789,23 @@ namespace jdi
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)
{
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::MEAN_FILTER_POINTS ||
m_editTarget == PlotEditTarget::MEAN_FILTER_ITERATIONS ||
m_editTarget == PlotEditTarget::PEAK_ITERATIONS_MAX)
m_editTarget == PlotEditTarget::PEAK_ITERATIONS_MAX ||
m_editTarget == PlotEditTarget::MEAN_POINTS_MAX ||
m_editTarget == PlotEditTarget::MEAN_ITERATIONS_MAX)
{
// 「均值点数 X」/「迭代次数 N」(均值滤波、中心轴)与「迭代次数 N 上限」(弱峰调整):
// 「均值点数 X」/「迭代次数 N」(均值滤波、中心轴)与「滑块上限」(弱峰调整 / 均值滤波):
// 输入框都落在对应的字段/滑块行矩形上。
if (m_editFieldRect.Width <= 0.0f)
{
@@ -641,6 +643,44 @@ namespace jdi
return true;
}
// 「均值滤波」两行滑块的**上限**:与「弱峰调整 → 迭代次数上限」同一交互语义
//(点击滑块右侧数值 → 整行变成输入框,输入正整数作为新的量程上限)。
// 规则:空内容 / 非法值(低于下限或超出允许范围)→ 保持原上限不变;
// 生效后若当前值超过新上限,自动夹到新上限。
if (target == PlotEditTarget::MEAN_POINTS_MAX ||
target == PlotEditTarget::MEAN_ITERATIONS_MAX)
{
Lane& lane = m_tab->Lanes()[static_cast<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」:作用于被勾选信号的均值滤波曲线,夹在合理范围内。
// 「中心轴边沿计数」用**完全相同的参数与语义**,只是作用于自己的条目列表。
const bool centerTarget =
@@ -882,7 +922,9 @@ namespace jdi
return LoadCursorW(nullptr, IDC_SIZEWE);
case PlotDragMode::PEAK_LEVEL:
case PlotDragMode::PEAK_ITERATIONS:
// 「弱峰调整」滑块拖拽 → 左右箭头。
case PlotDragMode::MEAN_POINTS:
case PlotDragMode::MEAN_ITERATIONS:
// 「弱峰调整 / 均值滤波」参数滑块拖拽 → 左右箭头。
return LoadCursorW(nullptr, IDC_SIZEWE);
case PlotDragMode::MOVE_SIGNAL:
// 拖动信号(参考 Photoshop 平移元素的抓握光标)。
@@ -1393,6 +1435,72 @@ namespace jdi
}
return true;
}
case PlotDragMode::MEAN_POINTS:
case PlotDragMode::MEAN_ITERATIONS:
{
// 「均值滤波」某信号的参数滑块:量程 = 下限 … 该信号自己的上限。
if (m_tab == nullptr || m_meanDragLane < 0 ||
m_meanDragLane >= static_cast<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:
{
// 被拖拽的网格自由跟随鼠标;目标位置变化时其它网格实时让位(吸附)。
@@ -1526,6 +1634,35 @@ namespace jdi
}
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:
default:
break;
@@ -2054,6 +2191,161 @@ namespace jdi
{
const bool meanMode = (kind == EdgePanelKind::MEAN);
const bool boostMode = (kind == EdgePanelKind::PEAK);
// 「均值滤波」:组头三角(展开 / 收起参数行)+ 两行参数滑块
//(轨道拖动 / 点击数值改上限 / 右侧复位按钮)。
// ⚠️ 所有命中都要求落在**内容区**之内:滚动到可视范围之外的行仍保留矩形,
// 若不加这条限制,它们会抢走底部「构建为新信号」按钮的点击。
if (meanMode)
{
Lane& meanLane = m_tab->Lanes()[static_cast<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)];
// 「弱峰调整」:先判两行滑块(数值文本 → 改上限;轨道 → 拖动)。
@@ -2707,6 +2999,9 @@ namespace jdi
// 弱峰滑块拖拽结束:清掉目标(避免留下上一轮的信号引用)。
m_peakDragLane = -1;
m_peakDragRef = SignalRef();
// 「均值滤波」参数滑块拖拽结束:同样清掉目标。
m_meanDragLane = -1;
m_meanDragRef = SignalRef();
// 「标记透明度」滑块 / 面板内容滚动条拖拽结束。
m_markerOpacityDragLane = -1;
m_markerScrollDragLane = -1;
@@ -2755,24 +3050,49 @@ namespace jdi
int panelLane = -1;
int panelKind = 0;
if (TopPanelAtPoint(static_cast<float>(m_mouseX), static_cast<float>(m_mouseY),
panelLane, panelKind) &&
static_cast<EdgePanelKind>(panelKind) == EdgePanelKind::MARK)
panelLane, panelKind))
{
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 EdgePanelKind hoveredKind = static_cast<EdgePanelKind>(panelKind);
// 「均值滤波」面板:内容区超高时用滚轮翻页(上百个信号合并时必备)。
if (hoveredKind == EdgePanelKind::MEAN)
{
const float maxScroll =
std::max(0.0f, layout.markerContentHeight - layout.content.Height);
if (maxScroll > 0.0f)
const EdgePanelLayout layout = EdgePanelLayoutFor(panelLane, EdgePanelKind::MEAN);
if (layout.content.Width > 0.0f &&
UiPointInRect(layout.content, static_cast<float>(m_mouseX),
static_cast<float>(m_mouseY)))
{
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;
const float maxScroll =
std::max(0.0f, layout.markerContentHeight - layout.content.Height);
if (maxScroll > 0.0f)
{
const float current =
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` 的说明;漏掉它会让线宽改动在**稠密信号**上不生效)。
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,
+213 -18
View File
@@ -69,6 +69,17 @@ namespace jdi
width = std::max(width, kEdgePanelPad * 2.0f * m_scale + nameWidth +
kEdgePanelMeanCheckSize * m_scale + 6.0f * m_scale);
}
if (meanFilter)
{
// 「均值滤波」的条目 = **信号色叠底标题栏**(三角 + 名称 + 复选框)
// 与两行滑块(标签 + 轨道 + 数值 + 复位按钮),右侧还要给滚动条留位。
const float groupWidth = kEdgePanelPad * 2.0f * m_scale +
kEdgePanelDisclosureWidth * m_scale + 6.0f * m_scale +
nameWidth + 6.0f * m_scale +
kEdgePanelMeanCheckSize * m_scale +
(kFreeMarkerScrollWidth + 4.0f) * m_scale;
width = std::max(width, groupWidth);
}
std::size_t rising = 0;
std::size_t falling = 0;
bool complete = true;
@@ -116,7 +127,7 @@ namespace jdi
if (centerAxis || meanFilter)
{
// 参数行 = 两个「说明文字 + 数值框」,必须保证文字不被数值框压住。
// (MEAN / CENTER:「均值点数 X」+「迭代次数 N」。标签与数值框各占半行。)
// (CENTER:「均值点数 X」+「迭代次数 N」。MEAN 已改为滑块,见下面单独的分支。)
const float labelXWidth = UiMeasureTextWidth(m_fonts->uiSmall, L"均值点数 X");
const float labelN = UiMeasureTextWidth(m_fonts->uiSmall, L"迭代次数 N");
const float fieldWidth =
@@ -127,6 +138,23 @@ namespace jdi
width = std::max(width, kEdgePanelPad * 2.0f * m_scale + rowWidth);
}
if (meanFilter)
{
// 两行滑块 = 「标签列 + 轨道(最小宽度)+ 数值列 + 复位按钮」(两行共用同一列宽),
// 右侧再给滚动条留位(上百个信号时内容必然超高)。
const float labelColumn =
std::max(UiMeasureTextWidth(m_fonts->uiSmall, kMeanFilterPointsSliderLabel),
UiMeasureTextWidth(m_fonts->uiSmall, kMeanFilterIterationsSliderLabel));
const float valueColumn =
std::max(UiMeasureTextWidth(m_fonts->monoSmall, L"100000"),
UiMeasureTextWidth(m_fonts->monoSmall, L"1000"));
const float sliderRowWidth = labelColumn + 6.0f * m_scale + kWeakPeakTrackMinWidth * m_scale +
6.0f * m_scale + valueColumn + kWeakPeakResetGap * m_scale +
kWeakPeakResetButtonSize * m_scale;
width = std::max(width, kEdgePanelPad * 2.0f * m_scale + sliderRowWidth +
(kFreeMarkerScrollWidth + 4.0f) * m_scale);
}
if (weakPeak)
{
// 滑块行 = 「标签列 + 轨道(最小宽度)+ 数值列 + 复位按钮」(等级与迭代次数两行共用同一列宽)。
@@ -233,7 +261,9 @@ namespace jdi
const bool weakPeak = (kind == EdgePanelKind::PEAK);
const bool freeMarker = (kind == EdgePanelKind::MARK);
// 「均值滤波」与「弱峰调整」共用同一套「逐条目 + 底部按钮」排布。
const bool itemized = (centerAxis || meanFilter || weakPeak);
// ⚠️ 「均值滤波」从 2026-10-30 起改为**可滚动的分组列表**(信号色叠底标题栏 +
// 展开后的两行滑块),与「自由标记」同一套浏览方式 ⇒ 不再走这里的 `itemized` 分支。
const bool itemized = (centerAxis || weakPeak);
const bool hasButton = (meanFilter || weakPeak);
const bool enabled = meanFilter ? lane.meanFilter
: (weakPeak ? lane.weakPeak
@@ -286,12 +316,43 @@ namespace jdi
// + 参数部分(**只在该条目被勾选时才占位**,因此面板高度随勾选数量动态变化):
// * MEAN / CENTER:一行「均值点数 X + 迭代次数 N」输入框(中心轴再多一行统计结果);
// * PEAK:两行滑块(调整幅度 % / 迭代次数 N),每个信号各有一套自己的参数。
const bool itemHasParams = (centerAxis || meanFilter || weakPeak);
const bool itemHasParams = (centerAxis || weakPeak);
const float weakPeakSliderRow = kWeakPeakSliderRowHeight * m_scale;
float meanRowHeight = 0.0f;
float itemsHeight = 0.0f;
float meanContentHeight = 0.0f;
float height = 0.0f;
if (itemized)
if (meanFilter)
{
// 「均值滤波」:内容区 = 逐信号的**信号色叠底标题栏**(内容总是显示,
// 用于勾选/取消预览)+(勾选且未收起时)两行参数滑块。
// 内容超高时出现滚动条;底部「构建为新信号」按钮固定不滚动。
const float groupRow = kEdgePanelMeanRowHeight * m_scale;
const float sliderRow = weakPeakSliderRow;
float content = 0.0f;
for (const SignalRef& ref : lane.signals)
{
const ResolvedSignal resolved = ResolveSignal(ref);
if (resolved.data == nullptr || !resolved.visible)
{
continue;
}
content += groupRow;
const Lane::MeanFilterItem* item = lane.FindMeanFilterItem(ref);
if (item != nullptr && !item->collapsed)
{
content += sliderRow * 2.0f;
}
}
meanContentHeight = content;
const float budget = m_plotRect.Height - (titleHeight + pad * 3.0f + buttonHeight);
const float maxContent =
std::max(3.0f * groupRow,
std::min(kFreeMarkerContentMaxHeight * m_scale, std::max(3.0f * groupRow, budget)));
const float view = std::min(content, maxContent);
height = titleHeight + pad + view + pad + buttonHeight + pad;
}
else if (itemized)
{
meanRowHeight = kEdgePanelMeanRowHeight * m_scale;
for (const SignalRef& ref : lane.signals)
@@ -501,7 +562,119 @@ namespace jdi
{
return layout;
}
if (itemized)
if (meanFilter)
{
// 「均值滤波」:可滚动内容区(逐信号的信号色叠底标题栏 + 展开后的两行滑块)+ 底部按钮。
const float buttonRowHeight = kEdgePanelButtonHeight * m_scale;
layout.button = gd::RectF(left + pad, top + height - pad - buttonRowHeight,
std::max(0.0f, width - pad * 2.0f), buttonRowHeight);
const float groupRow = kEdgePanelMeanRowHeight * m_scale;
const float sliderRow = kWeakPeakSliderRowHeight * m_scale;
const float scrollW = kFreeMarkerScrollWidth * m_scale;
const float viewHeight =
std::max(0.0f, height - titleHeight - pad * 3.0f - buttonRowHeight);
const bool scrollable = meanContentHeight > viewHeight + 0.5f;
const float maxScroll = std::max(0.0f, meanContentHeight - viewHeight);
const float scroll = std::max(0.0f, std::min(lane.meanFilterScroll, maxScroll));
const float innerWidth =
std::max(0.0f, width - pad * 2.0f - (scrollable ? (scrollW + 4.0f * m_scale) : 0.0f));
layout.content = gd::RectF(left + pad, top + titleHeight + pad, innerWidth, viewHeight);
layout.markerContentHeight = meanContentHeight;
layout.scrollOffset = scroll;
if (scrollable)
{
layout.scrollTrack =
gd::RectF(left + pad + innerWidth + 4.0f * m_scale, layout.content.Y, scrollW,
viewHeight);
const float thumbHeight =
std::max(18.0f * m_scale, viewHeight * (viewHeight / meanContentHeight));
const float thumbTop =
layout.content.Y +
(viewHeight - thumbHeight) * ((maxScroll > 0.0f) ? (scroll / maxScroll) : 0.0f);
layout.scrollThumb = gd::RectF(layout.scrollTrack.X, thumbTop, scrollW, thumbHeight);
}
// 两行滑块的列宽(与 `EdgePanelWidth` 同一组算式 ⇒ 布局与宽度不会漂移)。
const float labelColumn =
std::max(UiMeasureTextWidth(m_fonts->uiSmall, kMeanFilterPointsSliderLabel),
UiMeasureTextWidth(m_fonts->uiSmall, kMeanFilterIterationsSliderLabel));
const float valueColumn =
std::max(UiMeasureTextWidth(m_fonts->monoSmall, L"100000"),
UiMeasureTextWidth(m_fonts->monoSmall, L"1000"));
const float resetSize = kWeakPeakResetButtonSize * m_scale;
const float resetGap = kWeakPeakResetGap * m_scale;
const float trackHeight = kWeakPeakTrackHeight * m_scale;
const float closeSize = kEdgePanelMeanCheckSize * m_scale;
const float contentLeft = layout.content.X;
const float trackLeft = contentLeft + labelColumn + 6.0f * m_scale;
const float resetLeft = contentLeft + innerWidth - resetSize;
const float trackWidth =
std::max(10.0f * m_scale,
(resetLeft - resetGap - valueColumn - 6.0f * m_scale) - trackLeft);
float rowY = layout.content.Y - scroll;
for (const SignalRef& ref : lane.signals)
{
const ResolvedSignal resolved = ResolveSignal(ref);
if (resolved.data == nullptr || !resolved.visible)
{
continue;
}
const Lane::MeanFilterItem* item = lane.FindMeanFilterItem(ref);
const bool checked = (item != nullptr);
layout.rowRefs.push_back(ref);
// 组头(信号色叠底):整行宽度,左侧三角(仅勾选时)+ 名称,右端复选框。
layout.rowHeaders.push_back(
gd::RectF(contentLeft, rowY, innerWidth, groupRow));
layout.rowNameRects.push_back(gd::RectF(contentLeft, rowY, innerWidth, groupRow));
layout.rowTriangles.push_back(
checked ? gd::RectF(contentLeft + 3.0f * m_scale,
rowY + (groupRow - kEdgePanelDisclosureWidth * m_scale) * 0.5f,
kEdgePanelDisclosureWidth * m_scale,
kEdgePanelDisclosureWidth * m_scale)
: gd::RectF());
layout.rowChecks.push_back(
gd::RectF(contentLeft + innerWidth - closeSize - 4.0f * m_scale,
rowY + (groupRow - closeSize) * 0.5f, closeSize, closeSize));
layout.rowPoints.push_back(gd::RectF());
layout.rowIterations.push_back(gd::RectF());
layout.rowStats.push_back(gd::RectF());
rowY += groupRow;
// 参数行:只有勾选且未收起时才有几何(未勾选的行两者都是空矩形)。
if (!checked || item->collapsed)
{
layout.rowLevelTracks.push_back(gd::RectF());
layout.rowLevelValues.push_back(gd::RectF());
layout.rowLevelResets.push_back(gd::RectF());
layout.rowIterTracks.push_back(gd::RectF());
layout.rowIterValues.push_back(gd::RectF());
layout.rowIterResets.push_back(gd::RectF());
continue;
}
const gd::RectF pointsTrack(trackLeft, rowY + (sliderRow - trackHeight) * 0.5f,
trackWidth, trackHeight);
layout.rowLevelTracks.push_back(pointsTrack);
layout.rowLevelValues.push_back(
gd::RectF(pointsTrack.X + pointsTrack.Width + 6.0f * m_scale, rowY, valueColumn,
sliderRow));
layout.rowLevelResets.push_back(
gd::RectF(resetLeft, rowY + (sliderRow - resetSize) * 0.5f, resetSize, resetSize));
rowY += sliderRow;
const gd::RectF iterTrack(trackLeft, rowY + (sliderRow - trackHeight) * 0.5f,
trackWidth, trackHeight);
layout.rowIterTracks.push_back(iterTrack);
layout.rowIterValues.push_back(
gd::RectF(iterTrack.X + iterTrack.Width + 6.0f * m_scale, rowY, valueColumn,
sliderRow));
layout.rowIterResets.push_back(
gd::RectF(resetLeft, rowY + (sliderRow - resetSize) * 0.5f, resetSize, resetSize));
rowY += sliderRow;
}
}
else if (itemized)
{
const float buttonRowHeight = kEdgePanelButtonHeight * m_scale;
if (hasButton)
@@ -1209,7 +1382,11 @@ namespace jdi
double bestValue = 0.0;
double bestEnum = 0.0;
float bestY = 0.0f;
bool bestSnapped = false;
// 胜出信号的轴 / 数据 / 时间偏移:循环结束后**只为它**做一次「自动点线」稀疏判定
// (判定代价与信号数量成正比,而它只影响一个小圆环 ⇒ 绝不能每个信号都算)。
const TimeAxis* bestAxis = nullptr;
const SignalData* bestData = nullptr;
double bestOffsetTime = 0.0;
for (const SignalRef& ref : lane.signals)
{
const ResolvedSignal resolved = ResolveSignal(ref);
@@ -1218,9 +1395,10 @@ namespace jdi
{
continue;
}
// 「自动点线」判据成立时吸附到最近采样点(与光标吸附同一规则)。
// ⚠️ 用户明确要求:只要判据成立就吸附,**不**管当前是散点还是用户自己改过的样式。
const bool sparse = MinorCellSparse(*resolved.axis, *resolved.data, resolved.style->offsetTime);
// 「自动点线」判据只用于决定要不要画“已吸附”圆环 ⇒ **不在循环里逐个信号算**:
// 它的代价是“每个小格两次二分”(视界 50 格 × 上百信号 = 数万次比较/帧),
// 而**只有最后胜出的那一个信号**需要它(用户 2026-10-30 报告的卡顿根因之一)。
// 见循环结束后的统一判定。
// ---- ① 取“鼠标到这条曲线”的距离(复用绘制阶段的折线顶点)----
float nearestX = fx;
@@ -1348,13 +1526,22 @@ namespace jdi
ApplyDrawTransform(*resolved.style, candidateSample);
if (std::isfinite(candidateValue))
{
// ⚠️⚠️ 距离必须包含**横向**分量(用户 2026-10-30 报告):
// 只看纵向距离时,一个“单数据点信号”只要它的点在鼠标的**高度**附近,
// 就会在整幅宽度内一直胜出 —— 光标被它“吸住”,无法标注另一个常规信号。
// 改为鼠标到**该采样点屏幕位置**的欧氏距离后:稠密信号的横向分量≈ 0(仍等价于
// “纵向最近”);单点/稀疏信号只有鼠标真的靠近它时才会被选中。
const double pointX = static_cast<double>(TimeToX(candidateTime));
const double dx = pointX - static_cast<double>(fx);
const double vertical = std::fabs(
static_cast<double>(ValueToY(candidateValue, visual)) -
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;
nearestX = fx;
distance = sampleDistance;
nearestX = static_cast<float>(pointX);
time = candidateTime;
sampled = candidateSample;
candidateOk = true;
@@ -1368,9 +1555,6 @@ namespace jdi
continue;
}
// 固定点必须落在真实采样时刻上(两个候选都已吸附到采样点)。
// 表示“已吸附”的圆环仍只在「自动点线」判据成立(`sparse`)时画:
// 密采样信号里吸附量远小于 1 像素,圆环会一直挂着,反而像是画错了。
const bool snapped = sparse;
const double value = ApplyDrawTransform(*resolved.style, sampled);
if (!std::isfinite(value))
{
@@ -1386,7 +1570,9 @@ namespace jdi
bestValue = value;
bestEnum = sampled;
bestY = y;
bestSnapped = snapped;
bestAxis = resolved.axis;
bestData = resolved.data;
bestOffsetTime = resolved.style->offsetTime;
probe.ref = ref;
}
@@ -1398,11 +1584,20 @@ namespace jdi
}
probe.valid = true;
probe.time = bestTime;
probe.x = TimeToX(bestTime);
// ⚠️ 纵向光标**画在鼠标所在的 x**(用户 2026-10-30 明确要求:纵向光标跟随鼠标,
// 被标注的数据点才是"离鼠标最近"的那个)。旧实现把竖线画在吸附后的采样时刻,
// 于是稀疏/单点信号会让整条竖线"跳"到数据点上,观感像光标被吸走了。
probe.x = fx;
probe.pointX = TimeToX(bestTime);
probe.value = bestValue;
probe.enumValue = bestEnum;
probe.y = bestY;
probe.snapped = bestSnapped;
// 「已吸附」圆环只在「自动点线」稀疏判据成立时画(密采样信号里吸附量 < 1 像素,
// 圆环会一直挂着反而像画错了)。**只对胜出的这一个信号算**(见循环里的说明)。
if (bestAxis != nullptr && bestData != nullptr)
{
probe.snapped = MinorCellSparse(*bestAxis, *bestData, bestOffsetTime);
}
return probe;
}
+2 -2
View File
@@ -1446,7 +1446,7 @@ namespace jdi
g.SetTextRenderingHint(gd::TextRenderingHintClearTypeGridFit);
UiFillRect(g, m_area, m_theme->plotBg);
UiFillRect(g, m_area, m_theme->cardBg);
UiFillRect(g, m_area, ThemeLaneBackground(*m_theme));
// 竖向网格刻度沿用**前台视界跨度**(而非宽窗口的跨度),否则竖网格线会只有一半。
// ⚠️ 同时把它作为竖向网格的**主刻步长**传给 `PaintLane`(见 `m_renderTimeStep`):
@@ -1948,7 +1948,7 @@ namespace jdi
//(用户 2026-10-17 报告:滚动过渡动画在巨型信号处卡顿)。
// 这一情形在滚动到**从未构建过图层的区域**时必然出现(上百个网格时绝大多数
// 网格的图层都还没建立):旧实现在这里 `return false` ⇒ 落到 `Paint` 末尾的
// **整幅重绘** ⇒ 把**本帧全部可见网格的波形**(可能包含上千�万采样的巨型信号)
// **整幅重绘** ⇒ 把**本帧全部可见网格的波形**(可能包含上千万采样的巨型信号)
// 每帧重画一遍 —— 实测单帧 **20~35ms**,而手动拖滚动条只要 ~6ms。
// 正确行为:本帧只画**网格底衬**(廉价,见下方),波形等视界真正停下之后再按预算补
//(这正是用户要求的“没来得及画信号时就按只有网格叠底的状态继续滚动”)。