Files
Just-DataInspector/Components/PlotView/Src/PlotViewInteraction.cpp
T
DeepSeek V4.1 Flash 7a6fcd68e2 面板限高与滚动改造 + 边沿统计不再卡在“统计中…” + 组名折起真正收起
一、浮动面板(用户报告:下方空白、滚动时画到面板外、未改造的面板让一切交互卡顿)
1. ⭐ 根因:旧「逐条目」排布没有高度上限 —— 288 条信号时面板 13010px(绘图区 846px)
   ⇒ PanelRectInsideLayer 恒假 ⇒ 面板像素缓存一次都存不进去 ⇒ 每帧整块重画。
   现在 5 个面板全部限高 + 可滚动,旧的 itemized 布局/绘制/交互整体删除。
2. 无按钮面板(中心轴边沿计数)不再预留「按钮行 + pad」⇒ 底部空白 55px → 8.8px(1 个 pad)。
3. ⚠️ 统计文字改用 UiTextClipped:GDI 文本不受 GDI+ SetClip 约束,滚动时画到标题栏之外。
4. 「弱峰调整」改造为与均值滤波同款的分组列表(条目栏 + 右侧复选框 + 展开后的两行滑块
   + 复位按钮 + 底部「构建为新信号」);新增 Lane::weakPeakScroll / SetLaneWeakPeakScroll,
   WeakPeakItem 增加 collapsed。
5. 「边沿计数」保留两行排版,但内容区同样限高、可滚动(新增 Lane::edgeCountScroll)。
6. 鼠标指针:列表式面板的条目栏**只有已勾选**才显示手型(未勾选点击无效果,保持箭头);
   参数行控件只在参数行展开时给手型 / 左右箭头。
7. 内部标志 m_meanDragCenter / m_editMeanCenter(bool)改为 m_meanDragKind / m_editMeanKind
   (EdgePanelKind),以便 MEAN / CENTER / PEAK / COUNT 共用同一套滚动与滑块拖拽。

二、边沿统计“全部统计中…且永不更新”(288 信号合并到同一网格)
1. ⭐ 根因:累积状态上限固定 64 < 信号数 288 ⇒ 新建状态总淘汰未完成的 ⇒ 没有任何状态能扫完。
   新增 EdgeScanStateLimit() = max(64, 全部信号数 × 2 + 16)。
2. 去掉每状态 60000 的固定配额下限(状态一多就把整帧预算耗光),改为 budget / 未完成状态数;
   分母只数未完成状态,短信号扫完后立刻把预算让给长信号。

三、组名折起后“三角已收起但信号行仍在、仍可点选”
1. ⭐ 根因:RequestEdgeScanFrame() 在每个未完成状态里各 Post 一次(单帧最多 288 次),
   把优先级最低的 WM_TIMER 饿死 ⇒ AdvanceGroupAnimations 永不执行 ⇒ 折起动画永不结束。
   修法:每帧最多 Post 一次(m_edgeScanRequestFrame)。
2. 另一道保险:SignalPanel::EnsureRows() 开头推进一次动画 —— 动画进度只依赖挂钟时间,
   不依赖消息能否按时到达。

四、验证(离屏探针 .preflight/p20,全部 SW_HIDE + 合成消息,不占用鼠标/键盘/屏幕)
  p20.exe  9/9:面板限高且像素缓存真的写进去了、288 个统计全部 complete、中心轴面板底部留白、
               组名折起后行集合无该组信号、两组都折起后 signalRows == 0(真实消息 + WM_TIMER)。
  p20c.exe 11/11:弱峰面板限高/可滚动/缓存命中、展开与收起、滚动生效、条目栏手型规则、
               中心轴滚动时标题栏 0 像素变化(A/B 关掉裁剪 ⇒ 805px)、288 信号空闲帧 2.6ms。
  make release / make debug 零 warning。
2026-11-02 10:00:24 +08:00

4193 lines
151 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// PlotView 实现分片 —— 本文件只包含 PlotView 的**成员函数定义**(纯代码搬移,行为与拆分前完全一致)。
//
// 共用的常量与工具函数在 PlotViewInternal.hpp(命名空间 `pvdetail`,全部 inline)。
// 四个分片的职责划分(按功能):
// core 视图核心:构造/析构、上下文绑定、布局、坐标映射、视界与平移、网格结构与归属、
// 选区/网格划分、拖动取消
// paint 渲染与像素缓存:全部绘制(Paint*/Render*)、场景缓存、装饰缓存、每网格图层、
// 宽基底与滚动补条、并行图层重建
// interaction 交互与编辑:命中测试、鼠标与键盘处理、光标形状、内联输入框、浮动面板层序与命中
// features 视图特性与派生数据:光标状态与读数、提示浮层、缩放模式、自适应、光标吸附、
// 单点信号、派生曲线线程池、边沿统计、实验性功能(均值滤波 / 弱峰调整)参数
#include "PlotViewInternal.hpp"
namespace jdi
{
const SignalRef& PlotView::SelectedRef() const
{
static const SignalRef fallback;
return (m_dataSet != nullptr) ? m_dataSet->Selected() : fallback;
}
void PlotView::SelectSignal(const SignalRef& ref)
{
if (m_dataSetWritable != nullptr)
{
m_dataSetWritable->SetSelected(ref);
}
// 联动(用户 2026-10-15 要求):绘图区内把某条信号选为当前目标时,
// 请求宿主把信号列表**平滑滚动**到它,保证在长列表里也能看到它。
if (ref.IsValid())
{
m_pendingPanelScroll = ref;
m_pendingPanelScrollFlag = true;
}
}
void PlotView::SetExternalHoverSignal(const SignalRef& ref)
{
if (m_externalHoverSignal == ref)
{
return;
}
m_externalHoverSignal = ref;
}
int PlotView::EffectiveHoverLane() const
{
if (m_hoverLane >= 0)
{
return m_hoverLane;
}
// 外部悬停信号(信号列表 / 样式区「目标」行):只在该信号确实绘制到某个网格时生效。
if (m_tab != nullptr && m_externalHoverSignal.IsValid())
{
return m_tab->LaneOfSignal(m_externalHoverSignal);
}
return -1;
}
int PlotView::LaneAtY(int y) const
{
const float fy = static_cast<float>(y);
for (std::size_t index = 0; index < m_lanes.size(); ++index)
{
const gd::RectF& rect = m_lanes[index].rect;
if (fy >= rect.Y && fy <= rect.Y + rect.Height)
{
return static_cast<int>(index);
}
}
return -1;
}
bool PlotView::CancelDrag(bool showHint)
{
if (m_dragMode == PlotDragMode::NONE)
{
return false;
}
// 放弃当前拖拽:清空拖拽状态与尚未生效的缩放选区,不改动任何视图范围。
// 调用方仍持有鼠标捕获,松开左键时会走 OnLButtonUp,
// 此时拖拽模式已是 NONE,选区不会被应用到时间轴/幅值轴上。
const bool wasRubber = (m_dragMode == PlotDragMode::RUBBER_SELECT);
m_dragMode = PlotDragMode::NONE;
m_rubberAxis = PlotRubberAxis::UNDECIDED;
m_dragLane = -1;
m_dragMoved = false;
m_rubberRect = gd::RectF();
m_dragSignal = SignalRef();
m_dragTargetLane = -1;
if (showHint && wasRubber)
{
SetHint(L"已放弃当前选区(右键放弃选区,Esc 退出选区放大)");
}
return true;
}
bool PlotView::ExitZoomModes()
{
if (m_tab == nullptr)
{
return false;
}
// 退出缩放模式:工具栏的「选区放大」按钮会在 Application::Render 中同步为未选中态。
if (Tab().ZoomMode() == PlotZoomMode::NONE)
{
return false;
}
Tab().ZoomMode() = PlotZoomMode::NONE;
SetHint(std::wstring());
return true;
}
bool PlotView::IsOnCursorLine(int x, int& which) const
{
which = 0;
if (!Tab().CursorEnabled() || m_plotRect.Width <= 0.0f)
{
return false;
}
const float tolerance = 6.0f * m_scale;
const float fx = static_cast<float>(x);
// 双光标重叠时优先命中光标 1,避免出现“抓不起来第二条”的情况。
// 使用"显示横坐标":滞留在可视区外的光标被钉在边缘,也应该可以被抓住拖回来。
if (std::fabs(fx - CursorDisplayX(1)) <= tolerance)
{
which = 1;
return true;
}
if (Tab().CursorMode() == PlotCursorMode::DOUBLE && std::fabs(fx - CursorDisplayX(2)) <= tolerance)
{
which = 2;
return true;
}
return false;
}
bool PlotView::IsOnCursorTimeLabel(int x, int y, int& which) const
{
which = 0;
if (!Tab().CursorEnabled())
{
return false;
}
const float fx = static_cast<float>(x);
const float fy = static_cast<float>(y);
// 双光标重叠时优先命中光标 1(与光标线的命中顺序一致)。
const int count = (Tab().CursorMode() == PlotCursorMode::DOUBLE) ? 2 : 1;
for (int index = 1; index <= count; ++index)
{
const gd::RectF rect = CursorLabelRect(index);
if (rect.Width > 0.0f && UiPointInRect(rect, fx, fy))
{
which = index;
return true;
}
}
return false;
}
bool PlotView::IsOnCursorSpanLabel(int x, int y) const
{
if (Tab().CursorMode() != PlotCursorMode::DOUBLE || m_cursorSpanRect.Width <= 0.0f)
{
return false;
}
return UiPointInRect(m_cursorSpanRect, static_cast<float>(x), static_cast<float>(y));
}
int PlotView::LaneIndexAt(int x, int y) const
{
if (!UiPointInRect(m_plotRect, static_cast<float>(x), static_cast<float>(y)))
{
return -1;
}
return LaneAtY(y);
}
SignalRef PlotView::HitTestSignal(int x, int y) const
{
SignalRef empty;
if (m_tab == nullptr || m_dataSet == nullptr || !HasDrawableContent())
{
return empty;
}
if (!UiPointInRect(m_plotRect, static_cast<float>(x), static_cast<float>(y)))
{
return empty;
}
const int laneIndex = LaneAtY(y);
if (laneIndex < 0 || laneIndex >= static_cast<int>(m_lanes.size()))
{
return empty;
}
const LaneVisual& lane = m_lanes[static_cast<std::size_t>(laneIndex)];
const float tolerance = kSignalHitTolerance * m_scale;
const float fx = static_cast<float>(x);
const float fy = static_cast<float>(y);
SignalRef best;
float bestDistance = tolerance;
// 只为当前网格内的可见信号做精确的点到线段距离检测,避免跨网格误命中。
for (const SignalRef& ref : m_tab->Lanes()[static_cast<std::size_t>(laneIndex)].signals)
{
const ResolvedSignal resolved = ResolveSignal(ref);
if (resolved.data == nullptr || resolved.style == nullptr || !resolved.visible)
{
continue;
}
// ⚠️ **只有单个有效数据点**的信号没有“折线”可测距(`AcquirePolyline` 给不出两点的
// 线段),必须直接用数据点的屏幕坐标判定。否则用户永远无法悬停/拖动单点信号
//(用户 2026-10-15 报告:“光标瞄到数据点没有高亮、也不能拖拽换网格”)。
if (resolved.data->finiteCount == 1)
{
gd::PointF dot;
if (!SinglePointScreenPos(resolved, lane, dot))
{
continue;
}
const float dx = dot.X - fx;
const float dy = dot.Y - fy;
const float distance = std::sqrt(dx * dx + dy * dy);
// 容差取“常规命中容差”与“该点绘制半径”的较大者:点本身很粗时更好点中。
const float dotRadius = SinglePointDotRadius(resolved);
const float limit = std::max(tolerance, dotRadius);
if (distance <= limit && distance <= bestDistance + dotRadius)
{
bestDistance = distance;
best = ref;
}
continue;
}
// 复用绘制阶段的折线顶点:悬停检测通常与上一帧几何完全相同,
// 不再为“鼠标移动但命中结果未变”重复扫描全量采样点。
const PolylineCacheEntry* cached = AcquirePolyline(ref, resolved, lane);
if (cached == nullptr)
{
continue;
}
const std::vector<gd::PointF>& points = cached->points;
const std::vector<std::size_t>& breaks = cached->breaks;
if (points.size() < 2)
{
continue;
}
// 横向先行裁剪:点到线段的距离必然不小于横向距离本身,
// 因此只有横向落在容差范围内的顶点才可能命中。
// 时间单调时折线顶点按 x 递增,可二分定位后只扫描这一小段;
// 否则每次鼠标移动都要遍历上万个顶点(悬停卡顿的来源之一)。
std::size_t windowFirst = 0;
std::size_t windowLast = points.size();
if (resolved.axis->monotonic)
{
const auto byX = [](const gd::PointF& point, float limit) { return point.X < limit; };
windowFirst = static_cast<std::size_t>(
std::lower_bound(points.begin(), points.end(), fx - tolerance, byX) - points.begin());
windowLast = static_cast<std::size_t>(
std::upper_bound(points.begin(), points.end(), fx + tolerance,
[](float limit, const gd::PointF& point) { return limit < point.X; }) -
points.begin());
}
// 散点模式下把采样点本身也视为可命中目标。
// ⚠️ 窗口两侧各多含一个顶点:折线是**连续**的,鼠标落在两顶点之间时
// 两个端点都可能落在容差窗口之外(深放大 / 点线模式下相邻点相距几十像素),
// 旧实现因此完全扫不到这段连线(用户 2026-10-17 报告:只有数据点附近能高亮)。
if (resolved.style->mode == DrawMode::SCATTER)
{
const std::size_t scatterBegin = (windowFirst > 0) ? (windowFirst - 1) : 0;
const std::size_t scatterEnd = std::min(points.size(), windowLast + 1);
for (std::size_t index = scatterBegin; index < scatterEnd; ++index)
{
const gd::PointF& point = points[index];
const float dx = point.X - fx;
const float dy = point.Y - fy;
const float distance = std::sqrt(dx * dx + dy * dy);
if (distance <= bestDistance)
{
bestDistance = distance;
best = ref;
}
}
continue;
}
for (std::size_t k = 0; k < breaks.size(); ++k)
{
const std::size_t start = breaks[k];
const std::size_t stop = (k + 1 < breaks.size()) ? breaks[k + 1] : points.size();
const std::size_t scanBegin = std::max(start + 1, windowFirst);
// ⚠️ `windowLast + 1`:见上方说明 —— 需要包含"左端点在窗口内、右端点在窗口外"
// 以及“左端点在窗口外、右端点在窗口内”的跨窗口线段(正是鼠标所在的那一段)。
const std::size_t scanEnd = std::min(stop, windowLast + 1);
for (std::size_t index = scanBegin; index < scanEnd; ++index)
{
const gd::PointF& a = points[index - 1];
const gd::PointF& b = points[index];
const float vx = b.X - a.X;
const float vy = b.Y - a.Y;
const float wx = fx - a.X;
const float wy = fy - a.Y;
const float lengthSquared = vx * vx + vy * vy;
float t = 0.0f;
if (lengthSquared > 1.0e-6f)
{
t = (wx * vx + wy * vy) / lengthSquared;
t = std::max(0.0f, std::min(1.0f, t));
}
const float dx = wx - t * vx;
const float dy = wy - t * vy;
const float distance = std::sqrt(dx * dx + dy * dy);
if (distance < bestDistance)
{
bestDistance = distance;
best = ref;
}
}
}
}
return best;
}
gd::RectF PlotView::EditFieldRect() const
{
if (m_editTarget == PlotEditTarget::NONE || m_fonts == nullptr || m_fonts->monoSmall == nullptr)
{
return gd::RectF();
}
const float height = 22.0f * m_scale;
float width = std::max(90.0f * m_scale,
UiMeasureTextWidth(m_fonts->monoSmall, m_edit.Text()) + 24.0f * m_scale);
if (m_editTarget == PlotEditTarget::CURSOR_TIME)
{
// 与显示态标签取相同宽度与位置(底部),避免进入编辑时框体跳动。
const int which = (m_editCursor == 2) ? 2 : 1;
const gd::RectF displayRect = CursorLabelRect(which);
width = std::max(width, displayRect.Width);
float left = CursorX(which) - width * 0.5f;
left = std::max(m_plotRect.X, std::min(left, m_plotRect.X + m_plotRect.Width - width));
return gd::RectF(left, m_plotRect.Y + m_plotRect.Height - height - 2.0f * m_scale, width,
height);
}
if (m_editTarget == PlotEditTarget::CURSOR_SPAN)
{
// 差值输入框落在差值标签处(时间轴上方或时间轴内部)。
bool insideAxis = false;
const gd::RectF displayRect = CursorSpanRect(insideAxis);
if (displayRect.Width <= 0.0f)
{
return gd::RectF();
}
width = std::max(width, displayRect.Width);
float left = displayRect.X + displayRect.Width * 0.5f - width * 0.5f;
left = std::max(m_plotRect.X, std::min(left, m_plotRect.X + m_plotRect.Width - width));
return gd::RectF(left, displayRect.Y, width, height);
}
if (m_editTarget == PlotEditTarget::SIGNAL_NAME)
{
// 图例改名:输入框直接落在被双击的图例条目位置上。
if (m_editFieldRect.Width <= 0.0f)
{
return gd::RectF();
}
const float nameWidth = std::max(width, m_editFieldRect.Width);
return gd::RectF(m_editFieldRect.X, m_editFieldRect.Y, nameWidth, height);
}
if (m_editTarget == PlotEditTarget::EDGE_CENTER_POINTS ||
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::MEAN_POINTS_MAX ||
m_editTarget == PlotEditTarget::MEAN_ITERATIONS_MAX)
{
// 「均值点数 X」/「迭代次数 N」(均值滤波、中心轴)与「滑块上限」(弱峰调整 / 均值滤波):
// 输入框都落在对应的字段/滑块行矩形上。
if (m_editFieldRect.Width <= 0.0f)
{
return gd::RectF();
}
const float fieldHeight = std::max(height, m_editFieldRect.Height - 4.0f * m_scale);
return gd::RectF(m_editFieldRect.X, m_editFieldRect.Y + 2.0f * m_scale,
m_editFieldRect.Width, fieldHeight);
}
// 网格 Y 轴上下限输入框:优先落在被点击的菜单条目位置,保证"就地编辑"的观感。
if (m_editFieldRect.Width > 0.0f && m_editFieldRect.Height > 0.0f)
{ float left = m_editFieldRect.X;
float top = m_editFieldRect.Y;
left = std::max(m_area.X + 2.0f * m_scale,
std::min(left, m_area.X + m_area.Width - width - 2.0f * m_scale));
top = std::max(m_area.Y + 2.0f * m_scale,
std::min(top, m_area.Y + m_area.Height - height - 2.0f * m_scale));
return gd::RectF(left, top, width, height);
}
if (m_editLane < 0 || m_editLane >= static_cast<int>(m_lanes.size()))
{
return gd::RectF();
}
const LaneVisual& lane = m_lanes[static_cast<std::size_t>(m_editLane)];
const float left = lane.rect.X + kLaneLabelPad * m_scale;
const float top = lane.rect.Y + kLaneLabelPad * m_scale;
return gd::RectF(left, top, std::min(width, lane.rect.Width - 2.0f * kLaneLabelPad * m_scale), height);
}
bool PlotView::BeginEdit(PlotEditTarget target, int laneIndex, const std::wstring& initialText,
const gd::RectF& anchorRect)
{
if (target == PlotEditTarget::NONE)
{
return false;
}
// 切换编辑目标时清掉上一轮残留(图例改名需由调用方在进入前先设置 m_editSignal)。
if (target != PlotEditTarget::SIGNAL_NAME)
{
m_editSignal = SignalRef();
}
// 调用方可以用锚点矩形指定输入框位置(如菜单条目、图例条目);
// 未提供锚点时由 EditFieldRect() 按目标类型自动推导。
if (anchorRect.Width > 0.0f && anchorRect.Height > 0.0f)
{
m_editFieldRect = anchorRect;
}
else if (target != PlotEditTarget::SIGNAL_NAME)
{
m_editFieldRect = gd::RectF();
}
m_editTarget = target;
m_editLane = laneIndex;
// 名称与数值均不限长度(数值在提交时会做范围夹取)。
const bool nameField = (target == PlotEditTarget::SIGNAL_NAME);
m_edit.Begin(initialText, true, nameField ? EditFilter::ANY : EditFilter::NUMBER, 0u);
return true;
}
bool PlotView::TakePendingRename(SignalRef& ref, std::wstring& name)
{
if (!m_pendingRename)
{
return false;
}
ref = m_pendingRenameRef;
name = m_pendingRenameName;
m_pendingRename = false;
m_pendingRenameRef = SignalRef();
m_pendingRenameName.clear();
return true;
}
bool PlotView::EndEdit(bool commit)
{
if (m_editTarget == PlotEditTarget::NONE)
{
return false;
}
const PlotEditTarget target = m_editTarget;
const int laneIndex = m_editLane;
const SignalRef editSignal = m_editSignal;
const std::wstring text = Trim(m_edit.Text());
m_editTarget = PlotEditTarget::NONE;
m_editLane = -1;
m_editSignal = SignalRef();
m_editFieldRect = gd::RectF();
m_peakEditCurrentMax = 0; // 退出编辑:清除“迭代上限”输入框的灰色提示值
m_edit.End();
// 信号改名:交给宿主窗口落到数据层,绘图区只持有只读数据。
if (target == PlotEditTarget::SIGNAL_NAME)
{
if (commit && !text.empty() && editSignal.IsValid())
{
m_pendingRenameRef = editSignal;
m_pendingRenameName = text;
m_pendingRename = true;
}
return true;
}
double parsed = 0.0;
bool parsedOk = false;
if (commit && !text.empty())
{
wchar_t* stop = nullptr;
parsed = std::wcstod(text.c_str(), &stop);
parsedOk = (stop != text.c_str()) && (parsed == parsed) && std::isfinite(parsed);
}
if (!parsedOk)
{
return true;
}
if (target == PlotEditTarget::CURSOR_TIME)
{
double low = 0.0;
double high = 1.0;
TimeBounds(low, high);
const double span = std::max(high - low, kMinTimeSpan);
const double clamped = ClampDouble(parsed, low - span * 10.0, high + span * 10.0);
if (m_editCursor == 2 && Tab().CursorMode() == PlotCursorMode::DOUBLE)
{
Tab().CursorTimeAlt() = clamped;
}
else
{
Tab().CursorTime() = clamped;
}
m_activeCursor = (m_editCursor == 2) ? 2 : 1;
return true;
}
if (target == PlotEditTarget::CURSOR_SPAN)
{
// 差值编辑:输入值**始终以秒为单位**(即使界面上显示为 1.5min,编辑内容也是 90)。
// **前方(时间较小)的光标不动**,后方光标向后移动到"前方 + 输入差值"。
const double span = std::fabs(parsed);
const double front = std::min(Tab().CursorTime(), Tab().CursorTimeAlt());
const bool secondIsBack = (Tab().CursorTimeAlt() >= Tab().CursorTime());
const double moved = front + span;
if (secondIsBack)
{
Tab().CursorTime() = front;
Tab().CursorTimeAlt() = moved;
}
else
{
Tab().CursorTimeAlt() = front;
Tab().CursorTime() = moved;
}
m_activeCursor = secondIsBack ? 2 : 1;
return true;
}
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
{
return true;
}
// 「自由标记」的时间编辑:
// * `FREE_MARKER_TIME`:直接设定该固定点的**自身时刻**(秒);
// * `FREE_MARKER_DELTA`:设定它相对「同信号前一个固定点」的**时间间隔**(秒),
// 前方固定点不动,本点被移动到 `前方时刻 + 输入间隔`。
// 两者都只改 `time`;面板条目的顺序由 `SortedFreeMarkerIndices` 每次按时间重排,
// 因此"越过后面固定点时交换条目位置"是自动发生的,无需手工搬动数组。
if (target == PlotEditTarget::FREE_MARKER_TIME || target == PlotEditTarget::FREE_MARKER_DELTA)
{
const std::size_t itemIndex = m_editMarkerIndex;
m_editMarkerIndex = static_cast<std::size_t>(-1);
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
if (itemIndex >= lane.freeMarkerItems.size())
{
return true;
}
Lane::FreeMarkerItem& item = lane.freeMarkerItems[itemIndex];
double movedTime = 0.0;
if (target == PlotEditTarget::FREE_MARKER_TIME)
{
movedTime = parsed;
}
else
{
// 先取"同信号前一个固定点"的时刻(当前排序下的左邻居)。
std::vector<std::size_t> order;
SortedFreeMarkerIndices(lane, item.ref, order);
std::size_t position = order.size();
for (std::size_t index = 0; index < order.size(); ++index)
{
if (order[index] == itemIndex)
{
position = index;
break;
}
}
if (position == order.size() || position == 0)
{
return true; // 最左边:没有前一个固定点,间隔无意义
}
movedTime = lane.freeMarkerItems[order[position - 1]].time + std::fabs(parsed);
}
// 夹在数据时间范围外一点点以内(与光标时间编辑同一套宽容度),并拒绝非有限值。
double low = 0.0;
double high = 1.0;
TimeBounds(low, high);
const double span = std::max(high - low, kMinTimeSpan);
if (std::isfinite(movedTime))
{
item.time = ClampDouble(movedTime, low - span * 10.0, high + span * 10.0);
}
return true;
}
// 「弱峰调整」迭代次数滑块的**上限**:用户可直接输入一个正整数作为新的量程上限。
// 规则(用户明确要求):
// * 输入框只允许输入正整数(过滤策略为 BYTE = 纯数字,粘贴同样会被清洗);
// * 空内容 / 非法值(0、超出允许范围)→ 保持原上限不变;
// * 生效后若当前迭代次数超过新上限,则自动夹到新上限。
if (target == PlotEditTarget::PEAK_ITERATIONS_MAX)
{
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
const Lane::WeakPeakItem* existing = lane.FindWeakPeakItem(m_editMeanRef);
if (existing == nullptr)
{
return true;
}
if (parsedOk)
{
const long long value = static_cast<long long>(std::llround(parsed));
if (value >= kWeakPeakIterationsMin && value <= kWeakPeakIterationsMaxLimit)
{
Lane::WeakPeakItem& item = lane.EnsureWeakPeakItem(m_editMeanRef);
item.maxIterations = static_cast<int>(value);
// 当前值超出新上限 → 自动替换为上界。
item.iterations = std::max(kWeakPeakIterationsMin,
std::min(item.maxIterations, item.iterations));
// 曲线可能因迭代次数被夹取而变化,缓存整体作废最稳妥。
m_weakPeakCurveCache.clear();
}
}
return true;
}
// 「均值滤波」两行滑块的**上限**:与「弱峰调整 → 迭代次数上限」同一交互语义
//(点击滑块右侧数值 → 整行变成输入框,输入正整数作为新的量程上限)。
// 规则:空内容 / 非法值(低于下限或超出允许范围)→ 保持原上限不变;
// 生效后若当前值超过新上限,自动夹到新上限。
if (target == PlotEditTarget::MEAN_POINTS_MAX ||
target == PlotEditTarget::MEAN_ITERATIONS_MAX)
{
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
const bool centerEdit = (m_editMeanKind == EdgePanelKind::CENTER);
const Lane::MeanFilterItem* existing = centerEdit
? lane.FindEdgeCenterItem(m_editMeanRef)
: lane.FindMeanFilterItem(m_editMeanRef);
if (existing == nullptr)
{
return true;
}
if (parsedOk)
{
const bool isPoints = (target == PlotEditTarget::MEAN_POINTS_MAX);
const int minimum =
isPoints ? (centerEdit ? 0 : 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 = centerEdit
? lane.EnsureEdgeCenterItem(m_editMeanRef)
: 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();
if (centerEdit)
{
InvalidateEdgeScansFor(ResolveSignal(m_editMeanRef).data, true);
}
}
}
return true;
}
// 「均值点数 X」/「迭代次数 N」:作用于被勾选信号的均值滤波曲线,夹在合理范围内。
// 「中心轴边沿计数」用**完全相同的参数与语义**,只是作用于自己的条目列表。
const bool centerTarget =
(target == PlotEditTarget::EDGE_CENTER_POINTS || target == PlotEditTarget::EDGE_CENTER_ITERATIONS);
if (target == PlotEditTarget::MEAN_FILTER_POINTS || target == PlotEditTarget::MEAN_FILTER_ITERATIONS ||
centerTarget)
{
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
const bool isPoints = (target == PlotEditTarget::MEAN_FILTER_POINTS ||
target == PlotEditTarget::EDGE_CENTER_POINTS);
const Lane::MeanFilterItem* existing = centerTarget ? lane.FindEdgeCenterItem(m_editMeanRef)
: lane.FindMeanFilterItem(m_editMeanRef);
if (existing == nullptr)
{
return true;
}
const int maximum = isPoints ? 1000000 : 1000;
int value = static_cast<int>(std::lround(parsed));
// 迭代次数至少为 1(1 次迭代等于不做滤波)。
// 「均值滤波」的**均值点数下限为 1**(用户 2026-10-30 明确要求:不能为 0);
// 「中心轴边沿计数」沿用旧语义(0 = 不做平滑),因此两者下限分开取。
const int pointsMin = centerTarget ? 0 : kMeanFilterPointsMin;
value = isPoints ? std::max(pointsMin, std::min(value, maximum))
: std::max(1, std::min(value, maximum));
Lane::MeanFilterItem& item = centerTarget ? lane.EnsureEdgeCenterItem(m_editMeanRef)
: lane.EnsureMeanFilterItem(m_editMeanRef);
if (isPoints)
{
item.points = value;
}
else
{
item.iterations = value;
}
// ⚠️ **只作废被改动的那个信号**的统计状态。
// 过去的 `m_edgeScans.clear()` 会把两个面板里**所有**信号的统计一起丢掉,
// 于是改一个信号的参数会让其余未改参数的统计也从头重扫(用户报告的缺陷)。
m_meanCurveCache.clear();
if (centerTarget)
{
InvalidateEdgeScansFor(ResolveSignal(m_editMeanRef).data, true);
}
return true;
}
// 直接输入上下限:先固化当前范围,再用新值替换对应端点(保证 max > min)。
if (laneIndex >= static_cast<int>(m_lanes.size()))
{
return true;
}
const LaneVisual& visual = m_lanes[static_cast<std::size_t>(laneIndex)];
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
// 直接输入上下限属于手动调整幅值:关闭本网格的「选区 Y 轴自适应」。
lane.fitYToSelection = false;
if (lane.autoRange)
{
lane.autoRange = false;
lane.yMin = visual.yMin;
lane.yMax = visual.yMax;
}
const double magnitude = std::max(std::fabs(lane.yMin), std::fabs(lane.yMax));
const double minimumGap = std::max(magnitude * 1.0e-6, 1.0e-12);
if (target == PlotEditTarget::LANE_Y_MAX)
{
if (parsed <= lane.yMin)
{
parsed = lane.yMin + minimumGap;
}
lane.yMax = parsed;
}
else
{
if (parsed >= lane.yMax)
{
parsed = lane.yMax - minimumGap;
}
lane.yMin = parsed;
}
BuildLaneVisuals();
return true;
}
bool PlotView::OnEditChar(wchar_t character)
{
if (m_editTarget == PlotEditTarget::NONE)
{
return false;
}
// 剪贴板与全选快捷键经 WM_CHAR 送达(Ctrl+C/X/V/A 分别对应 3/24/22/1)。
switch (character)
{
case 0x03: // Ctrl+C
m_edit.CopyToClipboard(m_host);
return false;
case 0x18: // Ctrl+X
return m_edit.CutToClipboard(m_host);
case 0x16: // Ctrl+V
return m_edit.PasteFromClipboard(m_host);
case 0x01: // Ctrl+A
m_edit.SelectAll();
return true;
default:
break;
}
if (character == L'\r' || character == L'\n')
{
return EndEdit(true);
}
// 退格/删除已由 WM_KEYDOWN 的 OnEditKey 处理,此处忽略控制字符避免重复删除。
if (character < 0x20 || character == 0x7F)
{
return false;
}
return m_edit.Insert(character);
}
bool PlotView::CollapseEditSelectionToEnd()
{
if (m_editTarget == PlotEditTarget::NONE)
{
return false;
}
return m_edit.DeselectToEnd();
}
bool PlotView::OnEditMouseDown(int x, int y)
{
if (m_editTarget == PlotEditTarget::NONE)
{
return false;
}
const gd::RectF rect = EditFieldRect();
if (rect.Width <= 0.0f)
{
return false;
}
// ⚠️ 字体必须与 `PaintEditField` 里 `UiDrawEditField(..., m_fonts->monoSmall, ...)` 一致:
// 旧实现用 `uiSmall` 换算插入符位置,而文字按 `monoSmall` 绘制、`EditFieldRect` 也按
// `monoSmall` 量宽 ⇒ 点击框内会把插入符放到错误的字符位置(偏差 ≈ 字体宽度比)。
return UiEditFieldMouseDown(m_fonts->monoSmall, rect, m_edit, m_scale, x, y);
}
bool PlotView::OnEditMouseMove(int x)
{
if (m_editTarget == PlotEditTarget::NONE)
{
return false;
}
const gd::RectF rect = EditFieldRect();
if (rect.Width <= 0.0f)
{
return false;
}
return UiEditFieldMouseMove(m_fonts->monoSmall, rect, m_edit, m_scale, x);
}
bool PlotView::OnEditMouseUp()
{
if (!m_edit.Dragging())
{
return false;
}
m_edit.EndDrag();
return true;
}
bool PlotView::OnEditKey(WPARAM key)
{
if (m_editTarget == PlotEditTarget::NONE)
{
return false;
}
const bool shift = (GetKeyState(VK_SHIFT) & 0x8000) != 0;
const bool control = (GetKeyState(VK_CONTROL) & 0x8000) != 0;
switch (key)
{
case VK_RETURN:
return EndEdit(true);
case VK_ESCAPE:
return EndEdit(false);
case VK_BACK:
return m_edit.Backspace();
case VK_DELETE:
return m_edit.DeleteForward();
case VK_LEFT:
return m_edit.MoveCaret(-1, shift);
case VK_RIGHT:
return m_edit.MoveCaret(1, shift);
case VK_HOME:
return m_edit.MoveHome(shift);
case VK_END:
return m_edit.MoveEnd(shift);
case 'A':
if (control)
{
m_edit.SelectAll();
}
return true;
default:
break;
}
return true;
}
bool PlotView::HitTest(int x, int y) const
{
return UiPointInRect(m_area, static_cast<float>(x), static_cast<float>(y));
}
HCURSOR PlotView::QueryCursor(int x, int y) const
{
switch (m_dragMode)
{
case PlotDragMode::PAN:
return LoadCursorW(nullptr, IDC_SIZEALL);
case PlotDragMode::RUBBER_SELECT:
// 选区放大:轴向未定时为十字,确定后按方向给出上下/左右箭头。
if (m_rubberAxis == PlotRubberAxis::VALUE)
{
return LoadCursorW(nullptr, IDC_SIZENS);
}
if (m_rubberAxis == PlotRubberAxis::TIME)
{
return LoadCursorW(nullptr, IDC_SIZEWE);
}
return LoadCursorW(nullptr, IDC_CROSS);
case PlotDragMode::SCROLL_LANES:
return LoadCursorW(nullptr, IDC_SIZENS);
case PlotDragMode::CURSOR_MOVE:
// 光标拖拽(无论是抓住光标线还是抓住时间读数标签)都沿时间轴移动 → 左右箭头。
return LoadCursorW(nullptr, IDC_SIZEWE);
case PlotDragMode::CURSOR_SPAN_MOVE:
// 差值标签拖拽:两条光标整体沿时间轴平移 → 左右箭头。
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 平移元素的抓握光标)。
return LoadCursorW(nullptr, IDC_SIZEALL);
case PlotDragMode::EDGE_MOVE:
// 「边沿计数」横向光标:上下移动幅值。
return LoadCursorW(nullptr, IDC_SIZENS);
case PlotDragMode::EDGE_PANEL_MOVE:
// 边沿统计浮动面板:可自由拖动。
return LoadCursorW(nullptr, IDC_SIZEALL);
case PlotDragMode::LANE_REORDER:
// 网格次序拖拽:上下箭头(只能上下移动)。
return LoadCursorW(nullptr, IDC_SIZENS);
default:
break;
}
if (m_scrollBarVisible &&
UiPointInRect(m_scrollBarRect, static_cast<float>(x), static_cast<float>(y)))
{
return LoadCursorW(nullptr, IDC_SIZENS);
}
// 右下角「回到最上方」浮动按钮:与其它可点击控件一致,用手型光标作为提示。
// ⚠️ 必须在信号悬停 / 图例 / 面板判定**之前**(它绘制在最上层)。
if (m_backToTopAlpha > 0.01f)
{
const gd::RectF backToTop = BackToTopRect();
if (backToTop.Width > 0.0f &&
UiPointInRect(backToTop, static_cast<float>(x), static_cast<float>(y)))
{
return LoadCursorW(nullptr, IDC_HAND);
}
}
// 实验性功能面板与横向光标优先于信号悬停提示判定(面板会盖住波形)。
// 面板**只有标题栏**可以拖拽(显示抓握光标);✕ 与面板主体一律保持原始箭头。
if (m_tab != nullptr)
{
// 按**层序从上层往下**判定,重叠时以最上层的面板为准。
int panelLane = -1;
int panelKind = 0;
if (TopPanelAtPoint(static_cast<float>(x), static_cast<float>(y), panelLane, panelKind))
{
const EdgePanelLayout layout =
EdgePanelLayoutFor(panelLane, static_cast<EdgePanelKind>(panelKind));
if (UiPointInRect(layout.close, static_cast<float>(x), static_cast<float>(y)))
{
// ✕ 按钮:与面板/标签栏的其它 ✕ 一致,用手型光标作为可点击提示。
return LoadCursorW(nullptr, IDC_HAND);
}
// 「GX」编号徽标:用户 2026-10-21 明确要求**悬停不改变鼠标形状**,
// 也不弹出“是否在视界内”的浮动提示(点击行为不变:滚动到该网格)。
// ⚠️ 必须提前 return(而不是落到下面的标题栏判定),否则会被当成“可拖拽”。
if (layout.badge.Width > 0.0f &&
UiPointInRect(layout.badge, static_cast<float>(x), static_cast<float>(y)))
{
return nullptr;
}
// 「展开 / 折起」三角指示器:可点击控件 ⇒ 手型光标。
if (layout.disclosure.Width > 0.0f &&
UiPointInRect(layout.disclosure, static_cast<float>(x), static_cast<float>(y)))
{
return LoadCursorW(nullptr, IDC_HAND);
}
if (UiPointInRect(layout.title, static_cast<float>(x), static_cast<float>(y)))
{
// 标题栏:悬停**保持箭头**,只有按住左键拖动时(`EDGE_PANEL_MOVE`,
// 见本函数开头的拖拽分支)才变成十字箭头(用户 2026-10-21 要求)。
return nullptr;
}
if ((static_cast<EdgePanelKind>(panelKind) == EdgePanelKind::MEAN ||
static_cast<EdgePanelKind>(panelKind) == EdgePanelKind::PEAK) &&
layout.button.Width > 0.0f &&
UiPointInRect(layout.button, static_cast<float>(x), static_cast<float>(y)))
{
// 「构建为新信号」按钮:手型光标作为可点击提示。
return LoadCursorW(nullptr, IDC_HAND);
}
// 「自由标记」面板:行尾 ✕ / 分组栏 ✕ 与三角都是可点击控件(手型),
// 滚动条与「标记透明度」滑块则是拖动类控件(上下 / 左右箭头)。
if (static_cast<EdgePanelKind>(panelKind) == EdgePanelKind::MARK)
{
// 底部「自由标记点选取」切换按钮与两个显示开关。
if (layout.button.Width > 0.0f &&
UiPointInRect(layout.button, static_cast<float>(x), static_cast<float>(y)))
{
return LoadCursorW(nullptr, IDC_HAND);
}
if (layout.showDelta.Width > 0.0f &&
UiPointInRect(layout.showDelta, static_cast<float>(x), static_cast<float>(y)))
{
return LoadCursorW(nullptr, IDC_HAND);
}
if (layout.showEnum.Width > 0.0f &&
UiPointInRect(layout.showEnum, static_cast<float>(x), static_cast<float>(y)))
{
return LoadCursorW(nullptr, IDC_HAND);
}
if (layout.opacityTrack.Width > 0.0f &&
WeakPeakSliderHit(layout.opacityTrack, m_scale, static_cast<float>(x),
static_cast<float>(y)))
{
return LoadCursorW(nullptr, IDC_SIZEWE);
}
if (layout.scrollTrack.Width > 0.0f &&
UiPointInRect(layout.scrollTrack, static_cast<float>(x), static_cast<float>(y)))
{
return LoadCursorW(nullptr, IDC_SIZENS);
}
for (const EdgePanelLayout::MarkerRow& row : layout.markerRows)
{
if (row.close.Width > 0.0f &&
UiPointInRect(row.close, static_cast<float>(x), static_cast<float>(y)))
{
return LoadCursorW(nullptr, IDC_HAND);
}
}
for (const EdgePanelLayout::MarkerGroup& group : layout.markerGroups)
{
if (group.close.Width > 0.0f &&
UiPointInRect(group.close, static_cast<float>(x), static_cast<float>(y)))
{
return LoadCursorW(nullptr, IDC_HAND);
}
if (UiPointInRect(group.header, static_cast<float>(x), static_cast<float>(y)))
{
// 分组栏本体(含三角)单击 = 展开 / 收起。
return LoadCursorW(nullptr, IDC_HAND);
}
}
}
// 「均值滤波 / 中心轴边沿计数 / 弱峰调整」可滚动列表:
// * 条目栏:**只有该信号已被勾选**时才给手型(未勾选时点击毫无效果,
// 用户 2026-11-02 明确要求此时不要改变鼠标指针形状);
// * 滑块轨道 → 左右箭头;数值(可点击改上限)与复位按钮 → 手型;
// * 内容区滚动条 → 上下箭头。
{
const EdgePanelKind cursorKind = static_cast<EdgePanelKind>(panelKind);
const bool listCursor = (cursorKind == EdgePanelKind::MEAN ||
cursorKind == EdgePanelKind::CENTER ||
cursorKind == EdgePanelKind::PEAK);
const Lane& cursorLane =
Tab().Lanes()[static_cast<std::size_t>(panelLane)];
if (listCursor)
{
for (std::size_t rowIndex = 0; rowIndex < layout.rowHeaders.size(); ++rowIndex)
{
if (layout.rowHeaders[rowIndex].Width > 0.0f &&
UiPointInRect(layout.rowHeaders[rowIndex], static_cast<float>(x),
static_cast<float>(y)))
{
bool checked = false;
if (cursorKind == EdgePanelKind::PEAK)
{
checked = cursorLane.FindWeakPeakItem(
layout.rowRefs[rowIndex]) != nullptr;
}
else if (cursorKind == EdgePanelKind::CENTER)
{
checked = cursorLane.FindEdgeCenterItem(
layout.rowRefs[rowIndex]) != nullptr;
}
else
{
checked = cursorLane.FindMeanFilterItem(
layout.rowRefs[rowIndex]) != nullptr;
}
return checked ? LoadCursorW(nullptr, IDC_HAND) : nullptr;
}
const bool hasParams =
rowIndex < layout.rowLevelTracks.size() &&
layout.rowLevelTracks[rowIndex].Width > 0.0f;
if (!hasParams)
{
continue; // 参数行未展开(未勾选 / 已收起)⇒ 该行没有可交互控件
}
// 「均值点数 / 调整幅度」数值:均值滤波与中心轴可点击改上限(弱峰不能)。
if (cursorKind != EdgePanelKind::PEAK &&
rowIndex < layout.rowLevelValues.size() &&
layout.rowLevelValues[rowIndex].Width > 0.0f &&
UiPointInRect(layout.rowLevelValues[rowIndex], static_cast<float>(x),
static_cast<float>(y)))
{
return LoadCursorW(nullptr, IDC_HAND);
}
if (rowIndex < layout.rowIterValues.size() &&
layout.rowIterValues[rowIndex].Width > 0.0f &&
UiPointInRect(layout.rowIterValues[rowIndex], static_cast<float>(x),
static_cast<float>(y)))
{
return LoadCursorW(nullptr, IDC_HAND);
}
if (rowIndex < layout.rowLevelResets.size() &&
layout.rowLevelResets[rowIndex].Width > 0.0f &&
UiPointInRect(layout.rowLevelResets[rowIndex], static_cast<float>(x),
static_cast<float>(y)))
{
return LoadCursorW(nullptr, IDC_HAND);
}
if (rowIndex < layout.rowIterResets.size() &&
layout.rowIterResets[rowIndex].Width > 0.0f &&
UiPointInRect(layout.rowIterResets[rowIndex], static_cast<float>(x),
static_cast<float>(y)))
{
return LoadCursorW(nullptr, IDC_HAND);
}
if (rowIndex < layout.rowLevelTracks.size() &&
WeakPeakSliderHit(layout.rowLevelTracks[rowIndex], m_scale,
static_cast<float>(x), static_cast<float>(y)))
{
return LoadCursorW(nullptr, IDC_SIZEWE);
}
if (rowIndex < layout.rowIterTracks.size() &&
WeakPeakSliderHit(layout.rowIterTracks[rowIndex], m_scale,
static_cast<float>(x), static_cast<float>(y)))
{
return LoadCursorW(nullptr, IDC_SIZEWE);
}
}
}
if (layout.scrollThumb.Width > 0.0f &&
UiPointInRect(layout.scrollThumb, static_cast<float>(x), static_cast<float>(y)))
{
return LoadCursorW(nullptr, IDC_SIZENS);
}
}
// 面板主体(含底部编辑行与参数输入框):保持原始箭头。
return nullptr;
}
int edgeLane = -1;
if (IsOnEdgeCursorLine(x, y, edgeLane))
{
return LoadCursorW(nullptr, IDC_SIZENS);
}
}
// 图例中的「GX」编号徽标:**悬停时不改变鼠标形状**(用户明确要求:只在按下左键
// 拖拽时才显示上下箭头,见 `LANE_REORDER` 分支)。这里保持“不改变”的语义并
// **提前返回**(不再往下落到光标线/图例判定),与原先的优先级完全一致。
if (LaneBadgeAtPoint(x, y) >= 0)
{
return nullptr;
}
// 「自由标记」:网格内固定点的 ✕ 是可点击控件(手型 + 悬停高亮 + 提示气泡)。
for (const FreeMarkerHit& hit : m_freeMarkerHits)
{
if (hit.close.Width > 0.0f &&
UiPointInRect(hit.close, static_cast<float>(x), static_cast<float>(y)))
{
return LoadCursorW(nullptr, IDC_HAND);
}
}
// 光标时间读数标签与双光标差值标签:**悬停(未按下)时不改变鼠标形状**。
// 两个标签都叠在光标线的正上方,因此必须先于"光标线"判定返回 nullptr,
// 由调用方回退成箭头;只有真正拖拽时才切成左右箭头(见 CURSOR_MOVE / CURSOR_SPAN_MOVE)。
if (IsOnCursorSpanLabel(x, y))
{
return nullptr;
}
{
int labelWhich = 0;
if (IsOnCursorTimeLabel(x, y, labelWhich))
{
return nullptr;
}
}
{
int cursorWhich = 0;
if (IsOnCursorLine(x, cursorWhich) && UiPointInRect(m_area, static_cast<float>(x), static_cast<float>(y)))
{
return LoadCursorW(nullptr, IDC_SIZEWE);
}
}
if (UiPointInRect(m_plotRect, static_cast<float>(x), static_cast<float>(y)))
{
// 悬停命中可拖拽信号时**不改变鼠标形状**(维持原始箭头);
// 只有真正按下拖动(MOVE_SIGNAL)才切换为抓握光标。
// 悬停图例时同样保持箭头,仅做半高亮提示。
// 选区放大模式下**未开始框选时保持默认箭头**(不改变鼠标形状);
// 只有真正按下并拖动后才切成十字/方向箭头。
// (m_dragMode == RUBBER_SELECT 的情况已在上面处理。)
}
return nullptr;
}
bool PlotView::InputIsInteractive() const
{
if (m_dragMode != PlotDragMode::NONE)
{
return true;
}
if (m_lastInputActivityMs == 0)
{
return false;
}
const std::uint64_t now = GetTickCount64();
return (now >= m_lastInputActivityMs) &&
((now - m_lastInputActivityMs) < kEdgeScanInteractiveIdleMs);
}
bool PlotView::OnMouseMove(int x, int y)
{
const bool mouseMoved = (x != m_mouseX) || (y != m_mouseY);
m_mouseX = x;
m_mouseY = y;
// 交互活动时间戳:`Paint` 用它决定边沿统计本帧用「交互预算」还是「静止预算」。
// 只有真正的移动才更新 —— 悬停不动时统计应当全速推进。
if (mouseMoved)
{
m_lastInputActivityMs = GetTickCount64();
}
if (!HasDrawableContent())
{
return false;
}
const float fx = static_cast<float>(x);
// 右下角「回到最上方」按钮的悬停高亮(仅未拖拽时;按住其它东西时不该抢)。
if (m_dragMode == PlotDragMode::NONE)
{
const gd::RectF backToTop = BackToTopRect();
const bool hot = m_backToTopAlpha > 0.01f && backToTop.Width > 0.0f &&
UiPointInRect(backToTop, fx, static_cast<float>(y));
if (hot != m_backToTopHover)
{
m_backToTopHover = hot;
return true;
}
}
switch (m_dragMode)
{
case PlotDragMode::PAN:
{
if (m_plotRect.Width <= 0.0f)
{
return false;
}
// 横向拖动平移时间轴;纵向拖动偏移当前网格的幅值显示位置。
const double span = Tab().ViewMax() - Tab().ViewMin();
const double delta =
-static_cast<double>(x - m_dragLastX) / static_cast<double>(m_plotRect.Width) * span;
if (delta != 0.0)
{
// ⚠️ 必须走 `SetTimeView`(视界的**唯一**写入口):它内部会重算
// 「自动点线」判据并作废场景缓存。旧实现直接改 `ViewMin()/ViewMax()`,
// 于是从密集区平移到稀疏区(或反向)时点线样式**永远不重新判定**,
// 直到下次缩放才生效 —— 与文档承诺的「判据随视界实时重算」矛盾。
SetTimeView(Tab().ViewMin() + delta, Tab().ViewMax() + delta);
}
const int laneIndex = m_dragLane;
if (laneIndex >= 0 && laneIndex < static_cast<int>(m_lanes.size()) && y != m_dragLastY)
{
const LaneVisual& visual = m_lanes[static_cast<std::size_t>(laneIndex)];
if (visual.rect.Height > 0.0f)
{
const double valueSpan = visual.yMax - visual.yMin;
const double deltaValue = static_cast<double>(y - m_dragLastY) /
static_cast<double>(visual.rect.Height) * valueSpan;
ShiftLaneRange(laneIndex, deltaValue);
}
}
m_dragLastX = x;
m_dragLastY = y;
if (std::abs(x - m_dragStartX) > 3 || std::abs(y - m_dragStartY) > 3)
{
m_dragMoved = true;
}
return true;
}
case PlotDragMode::RUBBER_SELECT:
{
const float dx = static_cast<float>(x - m_dragStartX);
const float dy = static_cast<float>(y - m_dragStartY);
// 轴向由首次明显位移决定,之后固定不变(避免拖拽中途选区形态跳变)。
if (m_rubberAxis == PlotRubberAxis::UNDECIDED)
{
const float threshold = 4.0f * m_scale;
if (std::fabs(dx) < threshold && std::fabs(dy) < threshold)
{
return true;
}
m_rubberAxis = (std::fabs(dx) >= std::fabs(dy)) ? PlotRubberAxis::TIME : PlotRubberAxis::VALUE;
}
if (m_rubberAxis == PlotRubberAxis::TIME)
{
// 竖向选区贯穿整个绘图区高度,只取横向区间用于时间轴缩放。
// **横向必须夹在绘图区内**:鼠标被捕获时可能移动到工具栏 / 信号面板等处,
// 若不夹取,选区会画到绘图区之外(那些区域只会被局部失效,于是残留成"绘制错误")。
const float plotLeft = m_plotRect.X;
const float plotRight = m_plotRect.X + m_plotRect.Width;
const float from = ClampDouble(static_cast<double>(m_dragStartX),
static_cast<double>(plotLeft),
static_cast<double>(plotRight));
const float to = ClampDouble(static_cast<double>(x), static_cast<double>(plotLeft),
static_cast<double>(plotRight));
const float left = std::min(from, to);
const float width = std::fabs(to - from);
m_rubberRect = gd::RectF(left, m_plotRect.Y, width, m_plotRect.Height);
}
else
{
// 横向选区限定在按下时的网格内(纵向同样夹在该网格内),只取纵向区间用于幅值轴缩放。
if (m_dragLane < 0 || m_dragLane >= static_cast<int>(m_lanes.size()))
{
return false;
}
const gd::RectF& laneRect = m_lanes[static_cast<std::size_t>(m_dragLane)].rect;
const float from = ClampDouble(static_cast<double>(m_dragStartY),
static_cast<double>(laneRect.Y),
static_cast<double>(laneRect.Y + laneRect.Height));
const float to = ClampDouble(static_cast<double>(y), static_cast<double>(laneRect.Y),
static_cast<double>(laneRect.Y + laneRect.Height));
const float top = std::min(from, to);
const float bottom = std::max(from, to);
m_rubberRect = gd::RectF(laneRect.X, top, laneRect.Width, bottom - top);
}
m_dragMoved = true;
// 拖动过程中维持显示"右键取消选区"说明(粘滞,直到松开左键后才淡出)。
SetHintSticky(L"右键取消选区,ESC退出选区模式");
return true;
}
case PlotDragMode::SCROLL_LANES:
{
if (m_plotRect.Height <= 0.0f || m_laneContentHeight <= m_plotRect.Height)
{
return false;
}
const double travel =
static_cast<double>(y - m_dragLastY) / static_cast<double>(m_plotRect.Height);
// 用户自己拖滚动条 ⇒ 立即取消“点点信号后自动滚入可视区”的动画(否则两者会抢滚动位置)。
CancelLaneScrollAnimation();
Tab().LaneScroll() += static_cast<float>(travel * m_laneContentHeight);
m_dragLastY = y;
m_dragMoved = true;
BuildLaneVisuals();
return true;
}
case PlotDragMode::CURSOR_MOVE:
{
// 从时间读数标签上抓起时保留横向抓取偏移,避免一按下光标就跳到鼠标中心。
const double target = XToTime(fx - static_cast<float>(m_cursorGrabOffset));
const bool second =
(m_hoverCursor == 2 && Tab().CursorMode() == PlotCursorMode::DOUBLE);
// 吸附 + 夹取统一在 `PlaceCursorTime` 里完成(光标位置不变量只在那里维护):
// * 吸附只接受可见区间内、且视界内还能分辨的采样点;
// * 结果必定落在当前可见区间内(否则用户看到的是一拖就"飞到视界边缘")。
PlaceCursorTime(second ? 2 : 1, target);
m_dragMoved = true;
m_cursorHover = true;
return true;
}
case PlotDragMode::CURSOR_SPAN_MOVE:
{
// 拖拽差值标签:两条光标整体平移,间隔保持不变。
const double startTime = XToTime(static_cast<float>(m_spanDragStartX));
const double delta = XToTime(fx) - startTime;
double first = m_spanDragTime1 + delta;
double second = m_spanDragTime2 + delta;
// 整体平移后同样不能越出可视区:先尝试保持间隔地整体夹取,
// 两光标间隔本身就大于可视跨度时退化为各自夹取。
const double low = Tab().ViewMin();
const double high = Tab().ViewMax();
if (high > low)
{
const double inset = (high - low) * 0.002;
const double lo = low + inset;
const double hi = high - inset;
const double pairLow = std::min(first, second);
const double pairHigh = std::max(first, second);
if (pairHigh - pairLow <= hi - lo)
{
if (pairLow < lo)
{
first += lo - pairLow;
second += lo - pairLow;
}
else if (pairHigh > hi)
{
first -= pairHigh - hi;
second -= pairHigh - hi;
}
}
else
{
first = ClampDouble(first, lo, hi);
second = ClampDouble(second, lo, hi);
}
}
Tab().CursorTime() = first;
Tab().CursorTimeAlt() = second;
m_dragMoved = true;
return true;
}
case PlotDragMode::PEAK_LEVEL:
{
// 「弱峰调整」某信号的等级滑块:按滑轨横向位置换算 -400% – +400%。
if (m_tab == nullptr || m_peakDragLane < 0 ||
m_peakDragLane >= static_cast<int>(m_tab->Lanes().size()))
{
break;
}
const EdgePanelLayout layout = EdgePanelLayoutFor(m_peakDragLane, EdgePanelKind::PEAK);
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(m_peakDragLane)];
if (lane.FindWeakPeakItem(m_peakDragRef) == nullptr)
{
break;
}
Lane::WeakPeakItem& item = lane.EnsureWeakPeakItem(m_peakDragRef);
const int rowIndex = PeakRowIndexOf(layout, m_peakDragRef);
if (rowIndex < 0 || rowIndex >= static_cast<int>(layout.rowLevelTracks.size()) ||
layout.rowLevelTracks[static_cast<std::size_t>(rowIndex)].Width <= 0.0f)
{
break;
}
const double ratio =
UiSliderRatioFromX(layout.rowLevelTracks[static_cast<std::size_t>(rowIndex)], fx);
// 滑块量程 -200% – +200%:0 在正中间。
// 滑块量程 -400% – +400%(三组规则的梯度区间):0 在正中间。
item.amountPercent = std::max(
kWeakPeakAmountMin,
std::min(kWeakPeakAmountMax,
static_cast<int>(std::lround((ratio * 2.0 - 1.0) * 100.0 * kWeakPeakSliderScale))));
m_weakPeakCurveCache.clear();
m_dragMoved = true;
return true;
}
case PlotDragMode::PEAK_ITERATIONS:
{
// 「弱峰调整」某信号的迭代次数滑块:量程为 1 … 该信号自己的上限。
if (m_tab == nullptr || m_peakDragLane < 0 ||
m_peakDragLane >= static_cast<int>(m_tab->Lanes().size()))
{
break;
}
const EdgePanelLayout layout = EdgePanelLayoutFor(m_peakDragLane, EdgePanelKind::PEAK);
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(m_peakDragLane)];
if (lane.FindWeakPeakItem(m_peakDragRef) == nullptr)
{
break;
}
Lane::WeakPeakItem& item = lane.EnsureWeakPeakItem(m_peakDragRef);
const int rowIndex = PeakRowIndexOf(layout, m_peakDragRef);
if (rowIndex < 0 || rowIndex >= static_cast<int>(layout.rowIterTracks.size()) ||
layout.rowIterTracks[static_cast<std::size_t>(rowIndex)].Width <= 0.0f)
{
break;
}
const double ratio =
UiSliderRatioFromX(layout.rowIterTracks[static_cast<std::size_t>(rowIndex)], fx);
const int peakMax = std::max(kWeakPeakIterationsMin,
std::min(kWeakPeakIterationsMaxLimit, item.maxIterations));
item.iterations = std::max(
kWeakPeakIterationsMin,
std::min(peakMax, kWeakPeakIterationsMin +
static_cast<int>(std::lround(
ratio * (peakMax - kWeakPeakIterationsMin)))));
m_weakPeakCurveCache.clear();
m_dragMoved = true;
return true;
}
case PlotDragMode::MOVE_SIGNAL:
{
const int target = LaneAtY(y);
m_dragMoved = true;
m_dragMouseX = x;
m_dragMouseY = y;
if (target != m_dragTargetLane)
{
m_dragTargetLane = target;
}
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)];
const Lane::MeanFilterItem* existingItem = (m_meanDragKind == EdgePanelKind::CENTER)
? lane.FindEdgeCenterItem(m_meanDragRef)
: lane.FindMeanFilterItem(m_meanDragRef);
if (existingItem == nullptr)
{
break;
}
Lane::MeanFilterItem& item = (m_meanDragKind == EdgePanelKind::CENTER)
? lane.EnsureEdgeCenterItem(m_meanDragRef)
: lane.EnsureMeanFilterItem(m_meanDragRef);
const bool pointsMode = (m_dragMode == PlotDragMode::MEAN_POINTS);
const EdgePanelLayout layout = EdgePanelLayoutFor(
m_meanDragLane, m_meanDragKind);
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)
{
// ⚠️ 下限:均值滤波 = 1(用户 2026-10-30 要求不能为 0);中心轴 = 0(0 = 不平滑)。
const int floorValue = (m_meanDragKind == EdgePanelKind::CENTER) ? 0 : kMeanFilterPointsMin;
const int maximum =
std::max(floorValue, std::min(kMeanFilterPointsMaxLimit, item.maxPoints));
item.points =
std::max(floorValue,
std::min(maximum,
floorValue +
static_cast<int>(std::lround(ratio * (maximum - floorValue)))));
}
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();
// 中心轴统计依赖参数 ⇒ 参数一变必须定向作废该信号的累积状态。
if ((m_meanDragKind == EdgePanelKind::CENTER))
{
InvalidateEdgeScansFor(ResolveSignal(m_meanDragRef).data, true);
}
m_dragMoved = true;
return true;
}
case PlotDragMode::LANE_REORDER:
{
// 被拖拽的网格自由跟随鼠标;目标位置变化时其它网格实时让位(吸附)。
m_laneDragMouseY = y;
const int target = LaneDragTargetAt(y);
if (target != m_laneDragTarget)
{
m_laneDragTarget = target;
}
m_dragMoved = true;
return true;
}
case PlotDragMode::EDGE_MOVE:
{
// 横向光标:上下移动幅值(夹在该网格的可见幅值范围内)。
if (m_tab == nullptr || m_edgeDragLane < 0 ||
m_edgeDragLane >= static_cast<int>(m_lanes.size()) ||
m_edgeDragLane >= static_cast<int>(m_tab->Lanes().size()))
{
break;
}
const LaneVisual& visual = m_lanes[static_cast<std::size_t>(m_edgeDragLane)];
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(m_edgeDragLane)];
double value = YToValue(static_cast<float>(y), visual);
const double low = std::min(visual.yMin, visual.yMax);
const double high = std::max(visual.yMin, visual.yMax);
value = std::max(low, std::min(value, high));
lane.edgeValue = value;
lane.edgeValueValid = true;
m_dragMoved = true;
return true;
}
case PlotDragMode::EDGE_PANEL_MOVE:
{
// 浮动面板:**只能从标题栏拖动**(按下时已限制),整体限制在绘图区内。
if (m_tab == nullptr || m_edgePanelDragLane < 0 ||
m_edgePanelDragLane >= static_cast<int>(m_tab->Lanes().size()))
{
break;
}
const gd::RectF panel =
EdgePanelLayoutFor(m_edgePanelDragLane, m_edgePanelDragKind).panel;
if (panel.Width <= 0.0f)
{
break;
}
float left = static_cast<float>(x) - m_edgePanelDragOffset.X;
float top = static_cast<float>(y) - m_edgePanelDragOffset.Y;
left = std::max(m_plotRect.X + 2.0f * m_scale,
std::min(left, m_plotRect.X + m_plotRect.Width - panel.Width - 2.0f * m_scale));
top = std::max(m_plotRect.Y + 2.0f * m_scale,
std::min(top, m_plotRect.Y + m_plotRect.Height - panel.Height - 2.0f * m_scale));
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(m_edgePanelDragLane)];
switch (m_edgePanelDragKind)
{
case EdgePanelKind::CENTER:
lane.edgeCenterPanel.x = left;
lane.edgeCenterPanel.y = top;
break;
case EdgePanelKind::MEAN:
lane.meanFilterPanel.x = left;
lane.meanFilterPanel.y = top;
break;
case EdgePanelKind::PEAK:
lane.weakPeakPanel.x = left;
lane.weakPeakPanel.y = top;
break;
case EdgePanelKind::MARK:
// ⚠️ 漏掉这一支会让「自由标记」面板**拖不动**(位置写进了 edgePanel,
// 下一帧又从 freeMarkerPanel 读到旧值 ⇒ 面板纹丝不动)。
lane.freeMarkerPanel.x = left;
lane.freeMarkerPanel.y = top;
break;
case EdgePanelKind::COUNT:
default:
lane.edgePanel.x = left;
lane.edgePanel.y = top;
break;
}
m_dragMoved = true;
return true;
}
case PlotDragMode::MARKER_OPACITY:
{
// 「标记透明度」滑块:拖动时把滑块位置换算成 0–1 的不透明度。
if (m_tab == nullptr || m_markerOpacityDragLane < 0 ||
m_markerOpacityDragLane >= static_cast<int>(m_tab->Lanes().size()))
{
break;
}
const EdgePanelLayout layout =
EdgePanelLayoutFor(m_markerOpacityDragLane, EdgePanelKind::MARK);
if (layout.opacityTrack.Width <= 0.0f)
{
break;
}
const double ratio = UiSliderRatioFromX(layout.opacityTrack, static_cast<float>(x));
if (SetLaneFreeMarkerOpacity(m_markerOpacityDragLane, static_cast<float>(ratio)))
{
m_dragMoved = true;
return true;
}
return false;
}
case PlotDragMode::MARKER_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::MARK);
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 (SetLaneFreeMarkerScroll(m_markerScrollDragLane, ratio * maxScroll))
{
m_dragMoved = true;
return true;
}
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, m_meanDragKind);
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;
const float target = ratio * maxScroll;
bool scrolled = false;
switch (m_meanDragKind)
{
case EdgePanelKind::COUNT:
scrolled = SetLaneEdgeCountScroll(m_markerScrollDragLane, target);
break;
case EdgePanelKind::CENTER:
scrolled = SetLaneEdgeCenterScroll(m_markerScrollDragLane, target);
break;
case EdgePanelKind::PEAK:
scrolled = SetLaneWeakPeakScroll(m_markerScrollDragLane, target);
break;
default:
scrolled = SetLaneMeanFilterScroll(m_markerScrollDragLane, target);
break;
}
if (scrolled)
{
m_dragMoved = true;
return true;
}
return false;
}
case PlotDragMode::NONE:
default:
break;
}
bool changed = false;
// ---- 「自由标记」:跟随光标解析 + 两处 ✕ 的悬停态 ----
// 跟随光标是**每帧随鼠标变化**的叠加层(不进任何缓存),因此只要鼠标位置变了就得重绘。
//
// ⚠️ 鼠标压在浮动面板上时:**不画**网格内的自由光标、也**不**做任何自由标记交互
//(用户 2026-10-20 明确要求:"当鼠标处于浮动面板上时网格不绘制自由光标,
// 用户操作浮动面板时的操作不要穿透到网格上")。
bool freeMarkerOverPanel = false;
{
int freePanelLane = -1;
int freePanelKind = 0;
freeMarkerOverPanel = TopPanelAtPoint(static_cast<float>(x), static_cast<float>(y),
freePanelLane, freePanelKind);
}
{
const FreeMarkerProbe probe =
freeMarkerOverPanel ? FreeMarkerProbe() : ProbeFreeMarker(x, y);
const bool probeChanged = (probe.valid != m_freeMarker.valid) ||
(probe.cursorMode != m_freeMarker.cursorMode) ||
(probe.laneIndex != m_freeMarker.laneIndex) ||
(probe.time != m_freeMarker.time) ||
(probe.snapped != m_freeMarker.snapped) ||
(probe.ref != m_freeMarker.ref);
m_freeMarker = probe;
if (probeChanged || (probe.valid && mouseMoved))
{
changed = true;
}
}
// 网格内固定点 ✕ 的悬停(矩形由绘制时登记,见 `m_freeMarkerHits`)。
{
int hoverLane = -1;
std::size_t hoverItem = static_cast<std::size_t>(-1);
if (!freeMarkerOverPanel)
{
for (const FreeMarkerHit& hit : m_freeMarkerHits)
{
if (hit.close.Width > 0.0f &&
UiPointInRect(hit.close, static_cast<float>(x), static_cast<float>(y)))
{
hoverLane = hit.laneIndex;
hoverItem = hit.itemIndex;
break;
}
}
}
if (hoverLane != m_freeMarkerHoverLane || hoverItem != m_freeMarkerHoverItem)
{
m_freeMarkerHoverLane = hoverLane;
m_freeMarkerHoverItem = hoverItem;
changed = true;
}
}
// 面板内 ✕ 的悬停(分组栏 ✕ 与固定点行 ✕)与分组栏三角的悬停。
{
int hoverLane = -1;
std::size_t hoverItem = static_cast<std::size_t>(-1);
SignalRef hoverSignal;
int triLane = -1;
SignalRef triSignal;
int rowLane = -1;
std::size_t rowItem = static_cast<std::size_t>(-1);
int markLane = -1;
int markKind = 0;
if (m_tab != nullptr &&
TopPanelAtPoint(static_cast<float>(x), static_cast<float>(y), markLane, markKind) &&
static_cast<EdgePanelKind>(markKind) == EdgePanelKind::MARK)
{
const EdgePanelLayout layout = EdgePanelLayoutFor(markLane, EdgePanelKind::MARK);
// 只认**内容区**内的命中:被滚动到内容区之外的条目(它们的矩形可能落在
// 底部按钮 / 滑块区域)不得抢走那些控件的点击。
const bool insideContent =
UiPointInRect(layout.content, static_cast<float>(x), static_cast<float>(y));
// 固定点行 ✕ 优先(更具体)。
for (const EdgePanelLayout::MarkerRow& row : layout.markerRows)
{
if (insideContent && row.close.Width > 0.0f &&
UiPointInRect(row.close, static_cast<float>(x), static_cast<float>(y)))
{
hoverLane = markLane;
hoverItem = row.itemIndex;
break;
}
}
// 整行悬停(用于把网格内对应的固定点整体高亮)。
if (hoverLane < 0 && insideContent)
{
for (const EdgePanelLayout::MarkerRow& row : layout.markerRows)
{
if (row.rect.Width > 0.0f &&
UiPointInRect(row.rect, static_cast<float>(x), static_cast<float>(y)))
{
rowLane = markLane;
rowItem = row.itemIndex;
break;
}
}
}
if (hoverLane < 0)
{
for (const EdgePanelLayout::MarkerGroup& group : layout.markerGroups)
{
if (insideContent && group.close.Width > 0.0f &&
UiPointInRect(group.close, static_cast<float>(x), static_cast<float>(y)))
{
hoverLane = markLane;
hoverItem = static_cast<std::size_t>(-1);
hoverSignal = group.ref;
break;
}
if (insideContent &&
UiPointInRect(group.triangle, static_cast<float>(x), static_cast<float>(y)))
{
triLane = markLane;
triSignal = group.ref;
}
}
}
}
if (hoverLane != m_freeMarkerPanelHoverLane || hoverItem != m_freeMarkerPanelHoverItem ||
!(hoverSignal == m_freeMarkerPanelHoverSignal))
{
m_freeMarkerPanelHoverLane = hoverLane;
m_freeMarkerPanelHoverItem = hoverItem;
m_freeMarkerPanelHoverSignal = hoverSignal;
changed = true;
}
if (rowLane != m_freeMarkerRowHoverLane || rowItem != m_freeMarkerRowHoverItem)
{
m_freeMarkerRowHoverLane = rowLane;
m_freeMarkerRowHoverItem = rowItem;
changed = true;
}
if (triLane != m_freeMarkerTriangleHoverLane ||
!(triSignal == m_freeMarkerTriangleHoverSignal))
{
m_freeMarkerTriangleHoverLane = triLane;
m_freeMarkerTriangleHoverSignal = triSignal;
changed = true;
}
}
// 鼠标压在实验性功能面板上时不再触发信号悬停提示(面板本来就盖住波形),
// 但该面板所属的网格仍然要高亮(与鼠标直接停在网格上一致)。
// 按**层序**取最上层的面板(与点击命中保持一致,重叠时以最上层为准)。
bool overEdgePanel = false;
int panelLane = -1;
int hoverPanelKind = 0;
{
overEdgePanel = TopPanelAtPoint(static_cast<float>(x), static_cast<float>(y), panelLane,
hoverPanelKind);
}
// 「均值滤波 / 弱峰调整 / 中心轴边沿计数」面板**条目栏**的悬停 → 高亮该条目
//(用户 2026-10-30 要求:"鼠标处于条目栏上方时,对应的预览曲线需要高亮提示";
// 中心轴边沿计数自 2026-11-02 起同样有条目栏,条目本身也会叠一层强调色)。
{
int hoverLane = -1;
SignalRef hoverRef;
const EdgePanelKind hoverKind = static_cast<EdgePanelKind>(hoverPanelKind);
if (overEdgePanel && m_tab != nullptr && panelLane >= 0 &&
(hoverKind == EdgePanelKind::MEAN || hoverKind == EdgePanelKind::PEAK ||
hoverKind == EdgePanelKind::CENTER))
{
const EdgePanelLayout layout = EdgePanelLayoutFor(panelLane, hoverKind);
if (UiPointInRect(layout.content, static_cast<float>(x), static_cast<float>(y)))
{
for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex)
{
if (rowIndex >= layout.rowHeaders.size())
{
break;
}
const gd::RectF& headerRect = layout.rowHeaders[rowIndex];
// 半截滚出的行**也算命中**:绘制时行本来就会被裁剪成半截显示,
// 若这里要求“完整落在内容区内”,就会出现“看得见却悬停不到”的不一致。
// 命中点本身已被限定在内容区里,因此不会出现“看得见一半却点到”的幽灵热区。
if (UiPointInRect(headerRect, static_cast<float>(x), static_cast<float>(y)))
{
hoverLane = panelLane;
hoverRef = layout.rowRefs[rowIndex];
break;
}
}
}
}
if (hoverLane != m_meanRowHoverLane || !(hoverRef == m_meanRowHoverRef))
{
m_meanRowHoverLane = hoverLane;
m_meanRowHoverRef = hoverRef;
// 预览曲线在部分绘制路径里属于"静态场景"(整幅重绘会缓存它),
// 因此悬停状态一变就作废场景缓存 —— 只在"进出某一行"时发生,不是每帧。
InvalidateSceneCache();
changed = true;
}
}
// 「弱峰调整」每行滑块右侧的**复位控件**:悬停时给出一次性提示(进入时设置、离开时清除)。
// 与「系数/偏移」二级面板标题栏的还原图标同一交互语义(悬停高亮 + 手型 + 提示)。
bool overPeakReset = false;
if (overEdgePanel && panelLane >= 0 && m_tab != nullptr &&
static_cast<EdgePanelKind>(hoverPanelKind) == EdgePanelKind::PEAK)
{
const EdgePanelLayout resetLayout = EdgePanelLayoutFor(panelLane, EdgePanelKind::PEAK);
for (std::size_t rowIndex = 0; rowIndex < resetLayout.rowLevelResets.size(); ++rowIndex)
{
const gd::RectF& level = resetLayout.rowLevelResets[rowIndex];
if (level.Width > 0.0f && UiPointInRect(level, fx, static_cast<float>(y)))
{
overPeakReset = true;
break;
}
if (rowIndex < resetLayout.rowIterResets.size())
{
const gd::RectF& iter = resetLayout.rowIterResets[rowIndex];
if (iter.Width > 0.0f && UiPointInRect(iter, fx, static_cast<float>(y)))
{
overPeakReset = true;
break;
}
}
}
}
if (overPeakReset != m_peakResetHover)
{
m_peakResetHover = overPeakReset;
SetHint(overPeakReset ? std::wstring(L"点击恢复该参数的默认值") : std::wstring());
changed = true;
}
// 浮动面板标题栏左侧的「GX」编号徽标:悬停高亮 + 提示气泡(用户 2026-10-19 要求)。
// 徽标在面板像素图层里,因此悬停态必须由这里单独记录并触发重绘
//(面板缓存键在鼠标位于面板内时会混入坐标,所以重绘即可看到高亮)。
int hoverPanelBadgeLane = -1;
int hoverPanelBadgeKind = -1;
if (overEdgePanel && panelLane >= 0)
{
const EdgePanelLayout badgeLayout =
EdgePanelLayoutFor(panelLane, static_cast<EdgePanelKind>(hoverPanelKind));
if (badgeLayout.badge.Width > 0.0f &&
UiPointInRect(badgeLayout.badge, fx, static_cast<float>(y)))
{
hoverPanelBadgeLane = panelLane;
hoverPanelBadgeKind = hoverPanelKind;
}
}
if (hoverPanelBadgeLane != m_hoverPanelBadgeLane ||
hoverPanelBadgeKind != m_hoverPanelBadgeKind)
{
m_hoverPanelBadgeLane = hoverPanelBadgeLane;
m_hoverPanelBadgeKind = hoverPanelBadgeKind;
changed = true;
}
// 悬停网格:**只有**鼠标悬停在实验性功能浮动面板上时才高亮该面板所属网格。
// ⚠️ 用户 2026-10-15 明确要求:鼠标在网格里移动**不再触发**网格变色高亮;
// 改为在信号列表 / 样式区「目标」行悬停到某条已绘制信号时联动高亮
// (见 `SetExternalHoverSignal` 与 `EffectiveHoverLane`)。
const int lane = overEdgePanel ? panelLane : -1;
if (lane != m_hoverLane)
{
m_hoverLane = lane;
changed = true;
}
// 「GX」编号徽标的悬停高亮:徽标本体在静态场景 / 装饰缓存里被冻结,高亮是叠加层,
// 因此必须靠这个**单独的状态**触发重绘与取消。徽标命中的网格直接用鼠标纵坐标算,
// 与上面的「悬停网格高亮」解耦(后者已不再跟随鼠标位置)。
int hoverBadge = -1;
if (m_dragMode == PlotDragMode::NONE && !overEdgePanel)
{
const int underMouse = LaneAtY(y);
if (underMouse >= 0)
{
const gd::RectF badge = LaneBadgeRect(underMouse);
if (badge.Width > 0.0f &&
UiPointInRect(badge, static_cast<float>(x), static_cast<float>(y)))
{
hoverBadge = underMouse;
}
}
}
if (hoverBadge != m_hoverBadge)
{
m_hoverBadge = hoverBadge;
changed = true;
}
// ✕ 关闭按钮 / 等级与迭代次数滑块 / 底部按钮的悬停提示都直接依赖鼠标坐标,
// 但它们在面板内部移动时上面这些状态可能毫无变化(例如从同一网格的波形移到面板上),
// 于是返回值恒为 false → 宿主不重绘 → **悬停提示不会出现**。
// 因此在“鼠标进入 / 离开 / 在面板内移动”时强制标记需要重绘。
if ((overEdgePanel || m_mouseOverPanel) && mouseMoved)
{
changed = true;
}
m_mouseOverPanel = overEdgePanel;
// 时间光标不再跟随鼠标移动,仅在鼠标覆盖光标时才显示具体时间读数。
int hoverWhich = 0;
const bool hover = Tab().CursorEnabled() && IsOnCursorLine(x, hoverWhich);
if (hover != m_cursorHover)
{
m_cursorHover = hover;
changed = true;
}
if (hoverWhich != m_hoverCursor)
{
m_hoverCursor = hoverWhich;
changed = true;
}
// 精确检测鼠标是否贴近某条信号曲线,用于给出可拖拽提示。
// 图例优先于曲线:图例条目是可点选目标(与左侧信号列表一致),
// 但悬停图例时**只做半高亮提示**,不显示曲线拖拽提示(也不触发光晕)。
// **图例悬停在选区放大模式下同样生效**(该模式只改变拖拽语义)。
SignalRef hoverSignal;
SignalRef hoverLegend;
if (!m_cursorHover && !overEdgePanel)
{
hoverLegend = LegendAtPoint(x, y);
// ⚠️ **选区放大模式下也要响应信号悬停高亮**(用户 2026-10-17 要求):
// 该模式只改变“按下并拖动”的语义(框选而不是拖信号),悬停提示与高亮应当保留;
// 只是提示文案不再带“按住拖拽可移入其它网格”的尾缀(见 `PaintSignalInteraction`)。
if (!hoverLegend.IsValid())
{
hoverSignal = HitTestSignal(x, y);
}
}
if (hoverSignal != m_hoverSignal)
{
m_hoverSignal = hoverSignal;
changed = true;
}
if (hoverLegend != m_hoverLegend)
{
m_hoverLegend = hoverLegend;
changed = true;
}
// 双光标差值标签的悬停状态(用于区分"抓握/悬停"光标与普通状态)。
const bool spanHover = IsOnCursorSpanLabel(x, y);
if (spanHover != m_spanHover)
{
m_spanHover = spanHover;
changed = true;
}
// 悬停提示跟随鼠标:只要提示可见,鼠标移动本身就要触发重绘;
// 否则鼠标在可拖拽区域内继续移动时,提示会停在上一帧的位置。
if (m_hoverSignal.IsValid() && mouseMoved)
{
changed = true;
}
return changed;
}
bool PlotView::OnLButtonDown(int x, int y)
{
if (!HasDrawableContent())
{
return false;
}
const float fx = static_cast<float>(x);
const float fy = static_cast<float>(y);
m_dragStartX = x;
m_dragStartY = y;
m_dragLastX = x;
m_dragLastY = y;
m_dragMoved = false;
m_rubberRect = gd::RectF();
m_rubberAxis = PlotRubberAxis::UNDECIDED;
m_cursorGrabOffset = 0;
// 新的交互开始:清掉上一次留下的提示(拖动过程中会重新设置粘滞提示)。
SetHint(std::wstring());
// 上一轮尚未抬起确认的面板关闭请求一律作废。
m_pendingPanelClose = false;
m_pendingPanelCloseLane = -1;
// 右下角「回到最上方」浮动按钮:按下只登记,**抬起**时仍命中才真正生效
//(与其它 ✕ / 按钮的「可取消」交互一致),且**不**进入任何拖拽模式。
if (m_backToTopAlpha > 0.01f)
{
const gd::RectF backToTop = BackToTopRect();
if (backToTop.Width > 0.0f && UiPointInRect(backToTop, fx, fy))
{
m_backToTopPressed = true;
return true;
}
}
if (m_scrollBarVisible &&
UiPointInRect(m_scrollBarRect, static_cast<float>(x), static_cast<float>(y)))
{
m_dragMode = PlotDragMode::SCROLL_LANES;
return true;
}
// 内联输入中:输入框之外的点击视为提交(与线宽输入一致)。
if (IsEditing())
{
if (!UiPointInRect(EditFieldRect(), fx, fy))
{
EndEdit(true);
}
return true;
}
// 双光标模式下先检查差值标签:它可拖拽(两条光标整体平移,间隔不变)。
if (IsOnCursorSpanLabel(x, y))
{
m_dragMode = PlotDragMode::CURSOR_SPAN_MOVE;
m_spanDragStartX = x;
m_spanDragTime1 = Tab().CursorTime();
m_spanDragTime2 = Tab().CursorTimeAlt();
return true;
}
// 光标时间读数标签:可直接抓住拖动(与抓住光标线等价)。
{
int labelWhich = 0;
if (IsOnCursorTimeLabel(x, y, labelWhich))
{
m_dragMode = PlotDragMode::CURSOR_MOVE;
m_hoverCursor = labelWhich;
m_activeCursor = labelWhich;
m_cursorHover = true;
// 抓取偏移 = “手指按在读数标签的哪个位置”。
// ⚠️ 但光标**滞留在视界之外**时读数被钉在网格边缘,此时抓取点与光标真实位置
// 之间没有任何有意义的对应关系:仍然沿用偏移会让首次移动把目标送到视界之外
//(再被夹回边缘),表现为“一拖就跳很远 / 光标钉在边缘不动”。
// 因此这种情形下不带偏移:首次移动就把光标放在鼠标处(自然地拖回来)。
bool offRight = false;
const bool offView = CursorOffViewSide(labelWhich, offRight);
m_cursorGrabOffset =
offView ? 0
: (x - static_cast<int>(std::lround(CursorDisplayX(labelWhich))));
return true;
}
}
// 图例与 GX 编号徽标叠在波形之上、且直接位于网格顶部——用户明确要求它们**优先响应**:
// 必须先于「时间光标线 / 光标读数 / 边沿计数横向光标 / 选区放大」判定,否则:
// * 光标线恰好经过徽标/图例时,按下会变成拖光标;
// * 选区放大模式下会变成“开始框选”,用户看到的就是“GX 拖不动、图例点不中”。
// 层序上仍然让位于浮动面板(面板绘制在徽标与图例之上),因此先挡一次面板命中。
int guardLane = -1;
int guardKind = 0;
const bool guardPanel = TopPanelAtPoint(fx, fy, guardLane, guardKind);
// 网格次序拖拽:按在图例的 GX 编号徽标上即可整块拎起该网格上下移动。
if (!guardPanel)
{
const int badgeLane = LaneBadgeAtPoint(x, y);
if (badgeLane >= 0)
{
m_dragMode = PlotDragMode::LANE_REORDER;
m_laneDragFrom = badgeLane;
m_laneDragTarget = badgeLane;
m_laneDragMouseY = y;
m_laneDragGrabOffset =
static_cast<float>(y) - m_lanes[static_cast<std::size_t>(badgeLane)].rect.Y;
// 拖动期间保持静态场景缓存有效:内容按网格条带平移贴回,
// 因此不必重绘百万级采样的波形(见 PaintLaneReorder)。
return true;
}
}
// 点击图例条目等价于在左侧列表中选中该信号(样式区随之切换目标)。
// **必须在选区放大判定之前**:选区放大模式只改变拖拽语义,
// 不应影响图例的悬停与点选(否则用户在选区放大模式下无法切换信号)。
if (!guardPanel)
{
const SignalRef legendHit = LegendAtPoint(x, y);
if (legendHit.IsValid())
{
SelectSignal(legendHit);
m_dragMode = PlotDragMode::NONE;
return true;
}
}
// 靠近时间光标线时进入拖动。
// (放在徽标/图例之后:两者重叠时徽标与图例优先,与用户要求一致。)
if (!guardPanel)
{
int cursorWhich = 0;
if (IsOnCursorLine(x, cursorWhich) && UiPointInRect(m_area, fx, fy))
{
m_dragMode = PlotDragMode::CURSOR_MOVE;
m_hoverCursor = cursorWhich;
// 点中的光标成为“活动光标”:它触发的内容将画在上层。
m_activeCursor = cursorWhich;
m_cursorHover = true;
return true;
}
}
// 实验性功能浮动面板(面板压在波形之上,必须先于信号拖拽判定):
// * ✕ → 关闭该网格对应的功能;
// * 逐条目参数输入框 → 进入内联编辑;
// * 底部按钮 → 触发「构建为新信号」请求;
// * 标题栏 → 拖动面板;
// * 面板主体 → 消费点击,但**不拖动也不穿透**(用户明确要求只有标题栏可拖拽)。
//
// ⚠️ **按层序从上层往下命中**(重叠时只有最上层那个面板响应),并在命中后把该面板
// **置顶**(用户点击哪个就把哪个放到最上层),这样:
// * 上层面板的 ✕ / 标题栏不会被下层面板的参数输入框抢走(否则"上层面板关不掉 / 拖不动");
// * 两个面板重叠时总能操作到用户想要的那一个。
{
int panelLane = -1;
int panelKindValue = 0;
if (TopPanelAtPoint(fx, fy, panelLane, panelKindValue))
{
const EdgePanelKind kind = static_cast<EdgePanelKind>(panelKindValue);
const int index = panelLane;
BringPanelToFront(index, panelKindValue);
const EdgePanelLayout layout = EdgePanelLayoutFor(index, kind);
if (UiPointInRect(layout.close, fx, fy))
{
// ✕:**抬起**时才关闭该网格对应的实验性功能,这里只记录目标;
// 按住后移开再松开视为放弃(与面板/标签栏的关闭 ✕ 语义一致)。
// ⚠️ 必须在此**直接消费**该次按下:✕ 位于标题栏矩形内部,
// 若继续往下走会被“标题栏 → 拖动面板”分支命中,表现为“按下 ✕ 却拖走了面板”。
m_pendingPanelClose = true;
m_pendingPanelCloseLane = index;
m_pendingPanelCloseKind = kind;
m_dragMode = PlotDragMode::NONE;
return true;
}
// 「GX」编号徽标:点击把该网格滚动到视界内(用户 2026-10-19 要求)。
// ⚠️ 必须排在标题栏之前:徽标位于标题栏矩形内部,否则会变成“拖动面板”。
// `ScrollLaneIntoView` 自带“已经完整可见 ⇒ 什么都不做”,且是平滑动画。
if (layout.badge.Width > 0.0f && UiPointInRect(layout.badge, fx, fy))
{
m_dragMode = PlotDragMode::NONE;
if (ScrollLaneIntoView(index))
{
SetHint(L"正在滚动到 G" + std::to_wstring(index + 1) + L" 网格…");
}
return true;
}
// 标题栏左侧的「展开 / 折起」三角指示器:单击切换面板的折起状态。
// 折起后面板只剩标题栏(再次点击三角、或双击标题栏可展开)。
if (layout.disclosure.Width > 0.0f && UiPointInRect(layout.disclosure, fx, fy))
{
m_dragMode = PlotDragMode::NONE;
ToggleLanePanelCollapsed(index, kind);
return true;
}
// 「中心轴边沿计数」/「均值滤波」/「弱峰调整」面板:逐信号条目
// (前两者是「名称 + 复选框 + 两个参数输入框」,弱峰调整是「名称 + 复选框 + 两行滑块」)。
if ((kind == EdgePanelKind::MEAN || kind == EdgePanelKind::CENTER ||
kind == EdgePanelKind::PEAK || kind == EdgePanelKind::COUNT) &&
m_tab != nullptr)
{
const bool meanMode = (kind == EdgePanelKind::MEAN);
const bool centerMode = (kind == EdgePanelKind::CENTER);
const bool boostMode = (kind == EdgePanelKind::PEAK);
// 「均值滤波」「中心轴边沿计数」「弱峰调整」自 2026-11-02 起共用同一套
//**可滚动列表**交互(信号色叠底条目栏 + 右侧复选框 + 展开后的两行参数滑块)。
const bool listMode = (meanMode || centerMode || boostMode);
// 「边沿计数」仍是最简单的两行列表,只需要滚动条拖动。
if (kind == EdgePanelKind::COUNT)
{
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;
m_meanDragKind = EdgePanelKind::COUNT;
return true;
}
}
// 「列表式面板」:组头三角(展开 / 收起参数行)+ 两行参数滑块
//(轨道拖动 / 点击数值改上限 / 右侧复位按钮)。
// ⚠️ 所有命中都要求落在**内容区**之内:滚动到可视范围之外的行仍保留矩形,
// 若不加这条限制,它们会抢走底部「构建为新信号」按钮的点击。
if (listMode)
{
Lane& meanLane = m_tab->Lanes()[static_cast<std::size_t>(index)];
// 本面板的条目查找 / 创建(两者共用 `Lane::MeanFilterItem`)。
const auto findItem = [&meanLane, centerMode](const SignalRef& ref)
-> const Lane::MeanFilterItem* {
return centerMode ? meanLane.FindEdgeCenterItem(ref)
: meanLane.FindMeanFilterItem(ref);
};
const auto ensureItem = [&meanLane, centerMode](const SignalRef& ref)
-> Lane::MeanFilterItem& {
return centerMode ? meanLane.EnsureEdgeCenterItem(ref)
: meanLane.EnsureMeanFilterItem(ref);
};
// 内容区右侧的滚动条滑块:按下即进入拖动(与「自由标记」面板同一交互)。
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;
m_meanDragKind = kind;
return true;
}
const bool insideContent =
UiPointInRect(layout.content, fx, fy);
// 「弱峰调整」:两行滑块 = 调整幅度(-400% … +400%)/ 迭代次数(1 … 自定上限)。
// 语义与该面板旧式排版完全一致,只是外观改成与均值滤波同款的可滚动列表。
if (boostMode)
{
for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex)
{
const SignalRef& ref = layout.rowRefs[rowIndex];
if (!insideContent)
{
break;
}
const Lane::WeakPeakItem* peakItem = meanLane.FindWeakPeakItem(ref);
const bool hasParams =
peakItem != nullptr && rowIndex < layout.rowLevelTracks.size() &&
layout.rowLevelTracks[rowIndex].Width > 0.0f;
if (!hasParams)
{
continue;
}
Lane::WeakPeakItem& item = meanLane.EnsureWeakPeakItem(ref);
// 复位:调整幅度 → 0%
if (rowIndex < layout.rowLevelResets.size() &&
layout.rowLevelResets[rowIndex].Width > 0.0f &&
UiPointInRect(layout.rowLevelResets[rowIndex], fx, fy))
{
item.amountPercent = 0;
m_weakPeakCurveCache.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 = kWeakPeakIterationsMin;
item.maxIterations = kWeakPeakIterationsDefaultMax;
m_weakPeakCurveCache.clear();
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_peakEditCurrentMax = 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::PEAK_ITERATIONS_MAX, index, L"", rowRect);
m_edit.Begin(L"", false, EditFilter::BYTE, 0u);
m_dragMode = PlotDragMode::NONE;
return true;
}
// 拖动「迭代次数」滑块。
if (rowIndex < layout.rowIterTracks.size() &&
WeakPeakSliderHit(layout.rowIterTracks[rowIndex], m_scale, fx, fy))
{
m_dragMode = PlotDragMode::PEAK_ITERATIONS;
m_peakDragLane = index;
m_peakDragRef = ref;
const double ratio =
UiSliderRatioFromX(layout.rowIterTracks[rowIndex], fx);
const int peakMax = std::max(
kWeakPeakIterationsMin,
std::min(kWeakPeakIterationsMaxLimit, item.maxIterations));
item.iterations = std::max(
kWeakPeakIterationsMin,
std::min(peakMax,
kWeakPeakIterationsMin +
static_cast<int>(std::lround(
ratio * (peakMax - kWeakPeakIterationsMin)))));
m_weakPeakCurveCache.clear();
return true;
}
// 拖动「调整幅度」滑块(0 在正中间 ⇒ ±400%)。
if (rowIndex < layout.rowLevelTracks.size() &&
WeakPeakSliderHit(layout.rowLevelTracks[rowIndex], m_scale, fx, fy))
{
m_dragMode = PlotDragMode::PEAK_LEVEL;
m_peakDragLane = index;
m_peakDragRef = ref;
item.amountPercent = static_cast<int>(std::lround(
(UiSliderRatioFromX(layout.rowLevelTracks[rowIndex], fx) * 2.0 - 1.0) *
100.0 * kWeakPeakSliderScale));
item.amountPercent = std::max(
kWeakPeakAmountMin,
std::min(kWeakPeakAmountMax, item.amountPercent));
m_weakPeakCurveCache.clear();
return true;
}
}
}
else
{
for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex)
{
const SignalRef& ref = layout.rowRefs[rowIndex];
if (!insideContent)
{
break;
}
// 行可以“半截”显示(绘制时会按内容区裁剪),因此这里不再要求整行落在内容区;
// 命中点已由上面的 `insideContent` 限定在内容区内,不会抢走底部按钮的点击。
const bool hasParams =
rowIndex < layout.rowLevelTracks.size() &&
layout.rowLevelTracks[rowIndex].Width > 0.0f &&
findItem(ref) != nullptr;
if (hasParams)
{
Lane::MeanFilterItem& item = ensureItem(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();
if (centerMode)
{
InvalidateEdgeScansFor(ResolveSignal(ref).data, true);
}
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();
if (centerMode)
{
InvalidateEdgeScansFor(ResolveSignal(ref).data, true);
}
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;
m_editMeanKind = kind;
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;
m_editMeanKind = kind;
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 floorValue = centerMode ? 0 : kMeanFilterPointsMin;
const int maximum =
std::max(floorValue, std::min(kMeanFilterPointsMaxLimit, item.maxPoints));
m_dragMode = PlotDragMode::MEAN_POINTS;
m_meanDragLane = index;
m_meanDragRef = ref;
m_meanDragKind = kind;
const double ratio = UiSliderRatioFromX(layout.rowLevelTracks[rowIndex], fx);
item.points = std::max(
floorValue,
std::min(maximum,
floorValue + static_cast<int>(std::lround(ratio * (maximum - floorValue)))));
m_meanCurveCache.clear();
if (centerMode)
{
InvalidateEdgeScansFor(ResolveSignal(ref).data, true);
}
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;
m_meanDragKind = kind;
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();
if (centerMode)
{
InvalidateEdgeScansFor(ResolveSignal(ref).data, true);
}
return true;
}
}
}
}
// 条目栏(信号色叠底那一整行):
// * 点在右端复选框上 → 切换「是否启用该信号的预览」;
// * 已启用 → 整行切换参数行的收起 / 展开;
// * **未启用 → 什么都不做**(用户 2026-10-30 明确要求:未勾选前点击条目栏
// 既不能自动勾选、也不能展开 / 收起,只能点复选框;此时鼠标指针也保持箭头)。
// 仍然消费这次点击(不穿透到底层的平移 / 选区等)。
for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex)
{
if (!insideContent)
{
break;
}
const SignalRef& ref = layout.rowRefs[rowIndex];
if (rowIndex >= layout.rowHeaders.size() ||
layout.rowHeaders[rowIndex].Width <= 0.0f ||
!UiPointInRect(layout.rowHeaders[rowIndex], fx, fy))
{
continue;
}
const bool onCheckBox =
rowIndex < layout.rowChecks.size() &&
layout.rowChecks[rowIndex].Width > 0.0f &&
UiPointInRect(layout.rowChecks[rowIndex], fx, fy);
bool checked = false;
bool collapsed = false;
if (boostMode)
{
const Lane::WeakPeakItem* peakItem = meanLane.FindWeakPeakItem(ref);
checked = (peakItem != nullptr);
collapsed = (peakItem != nullptr && peakItem->collapsed);
}
else
{
const Lane::MeanFilterItem* meanItem = findItem(ref);
checked = (meanItem != nullptr);
collapsed = (meanItem != nullptr && meanItem->collapsed);
}
if (onCheckBox)
{
if (boostMode)
{
ToggleLaneWeakPeakSignal(index, ref);
}
else if (centerMode)
{
ToggleLaneEdgeCenterSignal(index, ref);
}
else
{
ToggleLaneMeanFilterSignal(index, ref);
}
m_meanRowHoverLane = -1;
m_meanRowHoverRef = SignalRef();
}
else if (checked)
{
// 已启用:整行切换参数行的收起 / 展开。
if (boostMode)
{
Lane::WeakPeakItem* item =
const_cast<Lane::WeakPeakItem*>(meanLane.FindWeakPeakItem(ref));
item->collapsed = !collapsed;
}
else
{
Lane::MeanFilterItem* item =
const_cast<Lane::MeanFilterItem*>(findItem(ref));
item->collapsed = !collapsed;
}
}
m_dragMode = PlotDragMode::NONE;
return true;
}
}
if (boostMode && layout.button.Width > 0.0f && UiPointInRect(layout.button, fx, fy))
{
// 「弱峰调整 → 构建为新信号」:与均值滤波同一套请求机制,
// **每个信号用自己的等级与迭代次数**(与预览曲线逐位一致)。
const Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(index)];
m_pendingWeakPeakItems.clear();
for (const SignalRef& ref : lane.signals)
{
const ResolvedSignal resolved = ResolveSignal(ref);
if (resolved.data == nullptr || !resolved.visible)
{
continue;
}
const Lane::WeakPeakItem* item = lane.FindWeakPeakItem(ref);
if (item == nullptr)
{
continue;
}
WeakPeakBuild build;
build.groupIndex = ref.groupIndex;
build.signalIndex = ref.signalIndex;
const int peakAmount =
std::max(kWeakPeakAmountMin, std::min(kWeakPeakAmountMax, item->amountPercent));
build.amountPercent = peakAmount;
// 迭代次数按**该信号自己的滑块量程**(1 … `maxIterations`)夹取。
const int peakMax = std::max(
kWeakPeakIterationsMin,
std::min(kWeakPeakIterationsMaxLimit, item->maxIterations));
build.iterations =
std::max(kWeakPeakIterationsMin, std::min(peakMax, item->iterations));
m_pendingWeakPeakItems.push_back(build);
}
if (!m_pendingWeakPeakItems.empty())
{
const WeakPeakBuild& first = m_pendingWeakPeakItems.front();
SetHint(L"正在构建弱峰调整信号(幅度 " + std::to_wstring(first.amountPercent) +
L"% / " + std::to_wstring(first.iterations) + L" 次迭代,共 " +
std::to_wstring(m_pendingWeakPeakItems.size()) + L" 条)…");
m_pendingWeakPeakLane = index;
}
m_dragMode = PlotDragMode::NONE;
return true;
}
if (meanMode && layout.button.Width > 0.0f && UiPointInRect(layout.button, fx, fy))
{
// 「构建为新信号」:只登记请求(含每个信号自己的 X/N),
// 真正的信号构建由宿主(Application)完成。
const Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(index)];
m_pendingMeanFilterItems.clear();
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);
if (item == nullptr)
{
continue;
}
MeanFilterBuild build;
build.groupIndex = ref.groupIndex;
build.signalIndex = ref.signalIndex;
// 与预览一致:点数下限 1、迭代次数下限 1(用户 2026-10-30 要求)。
build.points = std::max(kMeanFilterPointsMin, item->points);
build.iterations = std::max(kMeanFilterIterationsMin, item->iterations);
m_pendingMeanFilterItems.push_back(build);
}
if (!m_pendingMeanFilterItems.empty())
{
const MeanFilterBuild& first = m_pendingMeanFilterItems.front();
SetHint(L"正在构建均值滤波信号(" + std::to_wstring(first.points) +
L" 点 " + std::to_wstring(first.iterations) + L" 次迭代,共 " +
std::to_wstring(m_pendingMeanFilterItems.size()) + L" 条)…");
m_pendingMeanFilterLane = index;
}
m_dragMode = PlotDragMode::NONE;
return true;
}
}
// 「自由标记」面板:底部的切换按钮 / 「标记透明度」滑块 / 内容滚动条,
// 随后是分组栏(单击展开收起、右侧 ✕ 清空该信号)与固定点行 ✕。
// 与其它逐行 ✕ / 复位控件同一语义:**按下即生效**(不进拖拽、不穿透)。
if (kind == EdgePanelKind::MARK && m_tab != nullptr)
{
Lane& tabLane = m_tab->Lanes()[static_cast<std::size_t>(index)];
// 底部切换按钮「自由标记点选取」(默认关闭)。
if (layout.button.Width > 0.0f && UiPointInRect(layout.button, fx, fy))
{
ToggleLaneFreeMarkerCursor(index);
m_dragMode = PlotDragMode::NONE;
return true;
}
// 「显示增量」开关(只影响已固定点的显示;默认关闭)。
if (layout.showDelta.Width > 0.0f && UiPointInRect(layout.showDelta, fx, fy))
{
SetLaneFreeMarkerShowDelta(index, !tabLane.freeMarkerShowDelta);
m_dragMode = PlotDragMode::NONE;
return true;
}
// 「显示枚举文本」开关(只影响已固定点的显示;默认关闭)。
if (layout.showEnum.Width > 0.0f && UiPointInRect(layout.showEnum, fx, fy))
{
SetLaneFreeMarkerShowEnum(index, !tabLane.freeMarkerShowEnum);
m_dragMode = PlotDragMode::NONE;
return true;
}
// 「标记透明度」滑块:按下即写入该处数值并进入拖动。
if (layout.opacityTrack.Width > 0.0f &&
WeakPeakSliderHit(layout.opacityTrack, m_scale, fx, fy))
{
SetLaneFreeMarkerOpacity(index,
static_cast<float>(UiSliderRatioFromX(
layout.opacityTrack, fx)));
m_dragMode = PlotDragMode::MARKER_OPACITY;
m_markerOpacityDragLane = index;
return true;
}
// 内容滚动条:按在滑块上就带上抓取偏移,按在轨道上则直接跳转。
if (layout.scrollTrack.Width > 0.0f &&
UiPointInRect(layout.scrollTrack, fx, fy))
{
const float travel = layout.scrollTrack.Height - layout.scrollThumb.Height;
const float maxScroll =
std::max(0.0f, layout.markerContentHeight - layout.content.Height);
if (travel > 0.0f && maxScroll > 0.0f)
{
m_dragMode = PlotDragMode::MARKER_SCROLL;
m_markerScrollDragLane = index;
const bool onThumb = UiPointInRect(layout.scrollThumb, fx, fy);
m_markerScrollGrabOffset =
onThumb ? (fy - layout.scrollThumb.Y)
: (layout.scrollThumb.Height * 0.5f);
const float thumbTop =
std::max(layout.scrollTrack.Y,
std::min(fy - m_markerScrollGrabOffset,
layout.scrollTrack.Y + travel));
SetLaneFreeMarkerScroll(
index, ((thumbTop - layout.scrollTrack.Y) / travel) * maxScroll);
return true;
}
}
// 分组栏:✕ → 清空该信号全部固定点;其它位置(含三角)→ 展开 / 收起。
// ⚠️ 只认内容区内的命中:滚动出去的条目不得抢到底部按钮 / 滑块的点击。
const bool insideContent = UiPointInRect(layout.content, fx, fy);
for (const EdgePanelLayout::MarkerGroup& group : layout.markerGroups)
{
if (insideContent && group.close.Width > 0.0f && UiPointInRect(group.close, fx, fy))
{
tabLane.RemoveFreeMarkersOf(group.ref);
m_dragMode = PlotDragMode::NONE;
m_freeMarkerPanelHoverLane = -1;
m_freeMarkerPanelHoverItem = static_cast<std::size_t>(-1);
m_freeMarkerPanelHoverSignal = SignalRef();
return true;
}
if (insideContent && UiPointInRect(group.header, fx, fy))
{
ToggleLaneFreeMarkerGroup(index, group.ref);
m_dragMode = PlotDragMode::NONE;
return true;
}
}
for (const EdgePanelLayout::MarkerRow& row : layout.markerRows)
{
if (insideContent && row.close.Width > 0.0f && UiPointInRect(row.close, fx, fy))
{
tabLane.RemoveFreeMarkerAt(row.itemIndex);
m_dragMode = PlotDragMode::NONE;
m_freeMarkerPanelHoverLane = -1;
m_freeMarkerPanelHoverItem = static_cast<std::size_t>(-1);
m_freeMarkerPanelHoverSignal = SignalRef();
return true;
}
// 行的**数据区域**(✕ 之外):单击选中该条目(再次单击取消选中)。
// 选中行在面板里高亮;与网格内的固定点一一对应。
if (insideContent && row.rect.Width > 0.0f && UiPointInRect(row.rect, fx, fy))
{
SelectLaneFreeMarker(
index, (tabLane.freeMarkerSelected == static_cast<int>(row.itemIndex))
? -1
: static_cast<int>(row.itemIndex));
m_dragMode = PlotDragMode::NONE;
return true;
}
}
}
if (UiPointInRect(layout.title, fx, fy))
{
m_dragMode = PlotDragMode::EDGE_PANEL_MOVE;
m_edgePanelDragLane = index;
m_edgePanelDragKind = kind;
m_edgePanelDragOffset.X = fx - layout.panel.X;
m_edgePanelDragOffset.Y = fy - layout.panel.Y;
return true;
}
// 面板主体:消费该次点击,避免误触发底层的平移/选区/信号拖拽。
m_dragMode = PlotDragMode::NONE;
return true;
}
}
// 「边沿计数」横向光标:上下拖动改变幅值。
{
int edgeLane = -1;
if (IsOnEdgeCursorLine(x, y, edgeLane))
{
m_dragMode = PlotDragMode::EDGE_MOVE;
m_edgeDragLane = edgeLane;
return true;
}
}
if (!UiPointInRect(m_plotRect, fx, fy))
{
m_dragMode = PlotDragMode::NONE;
return false;
}
// ---- 「自由标记」----
// ⚠️ 鼠标压在浮动面板上时**一律不参与**自由标记的命中与固定
//(面板自身的交互已在上面消费,这里再兜一层,确保不穿透到网格)。
const bool freeOverPanel = TopPanelAtPoint(fx, fy, guardLane, guardKind);
// (1) 网格内固定点的 ✕:按下即移除该固定点(矩形由绘制时登记)。
for (const FreeMarkerHit& hit : m_freeMarkerHits)
{
if (!freeOverPanel && hit.close.Width > 0.0f && UiPointInRect(hit.close, fx, fy) &&
m_tab != nullptr && hit.laneIndex >= 0 &&
hit.laneIndex < static_cast<int>(m_tab->Lanes().size()))
{
Lane& tabLane = m_tab->Lanes()[static_cast<std::size_t>(hit.laneIndex)];
tabLane.RemoveFreeMarkerAt(hit.itemIndex);
m_dragMode = PlotDragMode::NONE;
return true;
}
}
// (2) 在开启「自由标记点选取」的网格内按下:单击固定数据点、拖动仍是原有语义
// (常规模式 = 平移;选区放大模式 = 框选)。
// ⚠️ 用户明确要求:「仅单击时」才算固定数据点 —— 因此这里**只登记意图**,
// 真正固定发生在 `OnLButtonUp` 且整段操作没有发生过拖动。
// ⚠️⚠️ 只有「自由标记点选取」已开启才参与(用户 2026-10-23 要求):
// 未开启时鼠标是普通鼠标,按下/拖动/单击完全是常规语义。
if (!freeOverPanel)
{
const int freeLane = LaneAtY(y);
if (freeLane >= 0 && freeLane < static_cast<int>(m_tab ? m_tab->Lanes().size() : 0) &&
m_tab->Lanes()[static_cast<std::size_t>(freeLane)].freeMarker &&
m_tab->Lanes()[static_cast<std::size_t>(freeLane)].freeMarkerCursor)
{
const FreeMarkerProbe probe = ProbeFreeMarker(x, y);
if (probe.valid && probe.ref.IsValid())
{
m_freeMarkerPendingFix = true;
m_freeMarkerPendingLane = freeLane;
m_dragLane = freeLane;
// 拖动时保持原有语义:选区放大模式下走 RUBBER_SELECT,否则平移。
m_dragMode = (Tab().ZoomMode() == PlotZoomMode::SELECT) ? PlotDragMode::RUBBER_SELECT
: PlotDragMode::PAN;
return true;
}
}
}
const PlotZoomMode mode = Tab().ZoomMode();
if (mode == PlotZoomMode::SELECT)
{
// 选区放大:轴向由鼠标移动方向决定,因此按下时先记录网格并置为未定。
// 按下本身**不显示提示**(拖动开始后才显示"右键取消选区")。
m_dragLane = LaneAtY(y);
m_rubberAxis = PlotRubberAxis::UNDECIDED;
m_dragMode = PlotDragMode::RUBBER_SELECT;
return true;
}
// 常规模式下:先检测是否落在信号曲线上;只有命中提示时才允许拖拽该信号。
const SignalRef hit = HitTestSignal(x, y);
if (hit.IsValid())
{
m_dragSignal = hit;
m_dragTargetLane = Tab().LaneOfSignal(hit);
SelectSignal(hit);
m_dragMouseX = x;
m_dragMouseY = y;
m_dragMode = PlotDragMode::MOVE_SIGNAL;
return true;
}
m_dragLane = LaneAtY(y);
m_dragMode = PlotDragMode::PAN;
return true;
}
bool PlotView::OnMButtonDown(int x, int y)
{
if (!HasDrawableContent())
{
return false;
}
// 浮动面板压在波形之上:中键落在面板上时**不穿透**为平移,直接消费掉。
// (否则在面板上按住中键拖动会把整个绘图区拖走,用户 2026-10-27 报告的交互穿透。)
{
int panelLane = -1;
int panelKind = 0;
if (TopPanelAtPoint(static_cast<float>(x), static_cast<float>(y), panelLane, panelKind))
{
BringPanelToFront(panelLane, panelKind);
m_dragMode = PlotDragMode::NONE;
return true;
}
}
// 中键:无论当前是否处于选区放大模式,都直接进入自由平移
// (不改变选区放大模式本身,松手后仍可继续框选)。
m_dragStartX = x;
m_dragStartY = y;
m_dragLastX = x;
m_dragLastY = y;
m_dragMoved = false;
m_rubberRect = gd::RectF();
m_rubberAxis = PlotRubberAxis::UNDECIDED;
m_cursorGrabOffset = 0;
m_edgeDragLane = -1;
m_edgePanelDragLane = -1;
m_edgePanelDragKind = EdgePanelKind::COUNT;
SetHint(std::wstring());
m_dragLane = LaneAtY(y);
m_dragMode = PlotDragMode::PAN;
return true;
}
bool PlotView::OnMButtonUp()
{
const bool changed = (m_dragMode == PlotDragMode::PAN);
if (changed)
{
m_dragMode = PlotDragMode::NONE;
m_dragLane = -1;
m_dragMoved = false;
ReleaseHint();
}
return changed;
}
bool PlotView::OnLButtonUp(int x, int y)
{
bool changed = (m_dragMode != PlotDragMode::NONE);
// 「自由标记」:整段操作**没有拖动过**(原地单击)才把当时标注的数据点固定下来。
// ⚠️ 必须在下面的 `switch (m_dragMode)` **之前**处理,并跳过选区放大的收尾逻辑:
// 用户明确要求"仅单击时为标记固定数据点,按下后拖动才进入选区放大"。
if (m_freeMarkerPendingFix)
{
const int laneIndex = m_freeMarkerPendingLane;
const bool plainClick = !m_dragMoved && m_rubberAxis == PlotRubberAxis::UNDECIDED;
m_freeMarkerPendingFix = false;
m_freeMarkerPendingLane = -1;
if (plainClick && laneIndex >= 0 && m_tab != nullptr &&
laneIndex < static_cast<int>(m_tab->Lanes().size()))
{
const FreeMarkerProbe probe = ProbeFreeMarker(x, y);
Lane& tabLane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
if (probe.valid && probe.laneIndex == laneIndex && probe.ref.IsValid())
{
tabLane.AddFreeMarker(probe.ref, probe.time);
changed = true;
}
}
// 原地单击时**不要**落到 RUBBER_SELECT 分支(否则会以 0 宽选区收尾)。
if (plainClick)
{
m_dragMode = PlotDragMode::NONE;
m_rubberAxis = PlotRubberAxis::UNDECIDED;
m_dragLane = -1;
m_dragMoved = false;
m_rubberRect = gd::RectF();
ReleaseHint();
return true;
}
}
// 右下角「回到最上方」按钮:按下后仍在按钮上抬起 ⇒ 平滑滚回最上方。
if (m_backToTopPressed)
{
m_backToTopPressed = false;
const gd::RectF backToTop = BackToTopRect();
if (backToTop.Width > 0.0f &&
UiPointInRect(backToTop, static_cast<float>(x), static_cast<float>(y)))
{
ScrollLanesToTop();
}
return true;
}
// 实验性功能面板的 ✕:只有"按下后仍在同一个 ✕ 上抬起"才真正关闭该功能。
if (m_pendingPanelClose)
{
const int lane = m_pendingPanelCloseLane;
const EdgePanelKind kind = m_pendingPanelCloseKind;
m_pendingPanelClose = false;
m_pendingPanelCloseLane = -1;
if (lane >= 0 && lane < static_cast<int>(m_lanes.size()))
{
const EdgePanelLayout layout = EdgePanelLayoutFor(lane, kind);
if (layout.close.Width > 0.0f &&
UiPointInRect(layout.close, static_cast<float>(x), static_cast<float>(y)))
{
switch (kind)
{
case EdgePanelKind::CENTER:
SetLaneEdgeCenterCount(lane, false);
break;
case EdgePanelKind::MEAN:
SetLaneMeanFilter(lane, false);
break;
case EdgePanelKind::PEAK:
SetLaneWeakPeak(lane, false);
break;
case EdgePanelKind::MARK:
SetLaneFreeMarker(lane, false);
break;
case EdgePanelKind::COUNT:
default:
SetLaneEdgeCount(lane, false);
break;
}
changed = true;
}
}
}
switch (m_dragMode)
{
case PlotDragMode::RUBBER_SELECT:
{
// 按拖拽方向应用对应的缩放(横向 → 时间轴;纵向 → 当前网格幅值轴)。
if (m_rubberAxis == PlotRubberAxis::TIME)
{
if (m_dragMoved &&
ApplyTimeSelection(m_rubberRect.X, m_rubberRect.X + m_rubberRect.Width))
{
changed = true;
}
}
else if (m_rubberAxis == PlotRubberAxis::VALUE)
{
if (m_dragMoved &&
ApplyValueSelection(m_dragLane, m_rubberRect.Y, m_rubberRect.Y + m_rubberRect.Height))
{
changed = true;
}
}
break;
}
case PlotDragMode::CURSOR_MOVE:
m_cursorHover = true;
break;
case PlotDragMode::CURSOR_SPAN_MOVE:
break;
case PlotDragMode::LANE_REORDER:
// 网格次序拖拽结束:真正重排并重编号(GX 徽标随之同步)。
FinishLaneReorder();
changed = true;
break;
case PlotDragMode::MOVE_SIGNAL:
{
const int target = LaneAtY(y);
const int source = (m_tab != nullptr) ? m_tab->LaneOfSignal(m_dragSignal) : -1;
if (m_dataSet != nullptr && m_tab != nullptr && m_dragSignal.IsValid() && target >= 0 &&
target != source && m_dragMoved)
{
m_tab->MoveSignalToLane(*m_dataSet, m_dragSignal, target);
changed = true;
}
m_dragSignal = SignalRef();
m_dragTargetLane = -1;
break;
}
default:
break;
}
m_dragMode = PlotDragMode::NONE;
m_rubberAxis = PlotRubberAxis::UNDECIDED;
m_dragLane = -1;
m_dragMoved = false;
m_rubberRect = gd::RectF();
m_cursorGrabOffset = 0;
m_edgeDragLane = -1;
m_edgePanelDragLane = -1;
m_edgePanelDragKind = EdgePanelKind::COUNT;
// 弱峰滑块拖拽结束:清掉目标(避免留下上一轮的信号引用)。
m_peakDragLane = -1;
m_peakDragRef = SignalRef();
// 「均值滤波 / 中心轴边沿计数」参数滑块拖拽结束:同样清掉目标。
m_meanDragLane = -1;
m_meanDragRef = SignalRef();
m_meanDragKind = EdgePanelKind::MEAN;
// 「标记透明度」滑块 / 面板内容滚动条拖拽结束。
m_markerOpacityDragLane = -1;
m_markerScrollDragLane = -1;
m_markerScrollGrabOffset = 0.0f;
// 松开左键:粘滞提示结束计时,开始"停留后淡出"。
ReleaseHint();
return changed;
}
bool PlotView::OnRButtonDown(int x, int y)
{
if (!HasDrawableContent())
{
return false;
}
// 浮动面板是绘图区内**最上层**的元素(与左键、双击的层序完全一致):
// 右键落在面板上必须被面板消费,绝不能穿透到下面的网格 —— 否则用户在面板上
// 右键会在面板底下弹出一整个网格的 Y 轴菜单(用户 2026-10-27 报告)。
int panelLane = -1;
int panelKind = 0;
if (!TopPanelAtPoint(static_cast<float>(x), static_cast<float>(y), panelLane, panelKind))
{
return false;
}
// 与左键一致:点中哪个面板就把它置顶(重叠时右键总是作用在用户看到的最上层那个)。
BringPanelToFront(panelLane, panelKind);
m_dragMode = PlotDragMode::NONE;
return true;
}
bool PlotView::OnMouseWheel(int delta, bool shift)
{
if (!HasDrawableContent() || delta == 0)
{
return false;
}
const double notches = static_cast<double>(delta) / 120.0;
// 鼠标位于「自由标记」面板的可滚动内容区上:滚轮翻页该面板的内容(不滚动网格)。
// ⚠️ 只认内容区:底部的切换按钮与「标记透明度」滑块不参与滚动,
// 因此鼠标压在它们上面时仍然滚动网格(与“它们不属于内容”的语义一致)。
if (m_tab != nullptr)
{
int panelLane = -1;
int panelKind = 0;
if (TopPanelAtPoint(static_cast<float>(m_mouseX), static_cast<float>(m_mouseY),
panelLane, panelKind))
{
const EdgePanelKind hoveredKind = static_cast<EdgePanelKind>(panelKind);
// 「均值滤波 / 中心轴边沿计数 / 弱峰调整 / 边沿计数」面板:内容区超高时用滚轮翻页
//(上百个信号合并时必备)。
if (hoveredKind == EdgePanelKind::MEAN || hoveredKind == EdgePanelKind::CENTER ||
hoveredKind == EdgePanelKind::PEAK || hoveredKind == EdgePanelKind::COUNT)
{
const EdgePanelLayout layout = EdgePanelLayoutFor(panelLane, hoveredKind);
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 Lane& wheelLane =
Tab().Lanes()[static_cast<std::size_t>(panelLane)];
const float current = (hoveredKind == EdgePanelKind::CENTER)
? wheelLane.edgeCenterScroll
: (hoveredKind == EdgePanelKind::PEAK)
? wheelLane.weakPeakScroll
: (hoveredKind == EdgePanelKind::COUNT)
? wheelLane.edgeCountScroll
: wheelLane.meanFilterScroll;
const float next = current - static_cast<float>(notches * 40.0 * m_scale);
const float target = std::max(0.0f, std::min(next, maxScroll));
switch (hoveredKind)
{
case EdgePanelKind::CENTER:
SetLaneEdgeCenterScroll(panelLane, target);
break;
case EdgePanelKind::PEAK:
SetLaneWeakPeakScroll(panelLane, target);
break;
case EdgePanelKind::COUNT:
SetLaneEdgeCountScroll(panelLane, target);
break;
default:
SetLaneMeanFilterScroll(panelLane, target);
break;
}
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;
}
}
}
}
}
// 滚轮不再承担缩放:默认纵向滚动网格,按住 Shift 时水平平移时间轴。
if (shift)
{
const double span = Tab().ViewMax() - Tab().ViewMin();
const double sweep = -notches * span * 0.15;
// ⚠️ 同样必须走 `SetTimeView`(视界唯一写入口),理由见 PAN 分支。
SetTimeView(Tab().ViewMin() + sweep, Tab().ViewMax() + sweep);
return true;
}
if (m_plotRect.Height <= 0.0f || m_laneContentHeight <= m_plotRect.Height)
{
return false;
}
// 用户自己滚轮滚动 ⇒ 立即取消自动滚入可视区的动画。
CancelLaneScrollAnimation();
Tab().LaneScroll() -= static_cast<float>(notches * 48.0 * m_scale);
BuildLaneVisuals();
return true;
}
bool PlotView::OnDoubleClick(int x, int y)
{
if (!UiPointInRect(m_area, static_cast<float>(x), static_cast<float>(y)))
{
return false;
}
// ⚠️ **上层元素的交互绝不能穿透到下层**:实验性功能的浮动面板浮在网格之上(且绘制在
// 光标标签 / 图例之上),因此在它上面双击必须由它消费掉。旧实现在所有下层命中都不成
// 立时无条件 `FitAll()`,于是"在面板上双击任意位置"会让网格莫名其妙地自适应一次
// (用户 2026-10-15 报告)。面板自身双击没有额外语义,直接消费即可。
{
int panelLane = -1;
int panelKind = 0;
if (TopPanelAtPoint(static_cast<float>(x), static_cast<float>(y), panelLane, panelKind))
{
// 标题栏双击 = 把该面板所属网格**滚动到视界内**(与单击 GX 徽标完全同效)。
// 用户 2026-10-21 明确要求:**只有双击标题栏**才触发这个联动,单击标题栏不触发
//(单击标题栏只是准备拖拽面板);面板的折起/展开只由左侧三角指示器负责。
{
const EdgePanelLayout titleLayout =
EdgePanelLayoutFor(panelLane, static_cast<EdgePanelKind>(panelKind));
if (titleLayout.title.Width > 0.0f &&
UiPointInRect(titleLayout.title, static_cast<float>(x), static_cast<float>(y)) &&
!(titleLayout.badge.Width > 0.0f &&
UiPointInRect(titleLayout.badge, static_cast<float>(x),
static_cast<float>(y))) &&
!(titleLayout.close.Width > 0.0f &&
UiPointInRect(titleLayout.close, static_cast<float>(x),
static_cast<float>(y))) &&
!(titleLayout.disclosure.Width > 0.0f &&
UiPointInRect(titleLayout.disclosure, static_cast<float>(x),
static_cast<float>(y))))
{
m_dragMode = PlotDragMode::NONE;
if (ScrollLaneIntoView(panelLane))
{
SetHint(L"正在滚动到 G" + std::to_wstring(panelLane + 1) + L" 网格…");
}
return true;
}
}
// 「自由标记」面板:双击某一固定点行 → 编辑它的时间(左侧是自身时刻、
// 右侧是相对前一固定点的间隔,对应行内两列的位置)。
if (static_cast<EdgePanelKind>(panelKind) == EdgePanelKind::MARK && m_tab != nullptr &&
panelLane >= 0 && panelLane < static_cast<int>(m_tab->Lanes().size()))
{
const EdgePanelLayout layout =
EdgePanelLayoutFor(panelLane, EdgePanelKind::MARK);
Lane& tabLane = m_tab->Lanes()[static_cast<std::size_t>(panelLane)];
for (const EdgePanelLayout::MarkerRow& row : layout.markerRows)
{
if (row.rect.Width <= 0.0f ||
!UiPointInRect(layout.content, static_cast<float>(x), static_cast<float>(y)) ||
!UiPointInRect(row.rect, static_cast<float>(x), static_cast<float>(y)) ||
row.itemIndex >= tabLane.freeMarkerItems.size())
{
continue;
}
const Lane::FreeMarkerItem& item = tabLane.freeMarkerItems[row.itemIndex];
// 分隔点:直接用布局里的列边界,不再"反推"列位置。
// 右侧(③ 时刻列 或 ④ 间隔列)→ 编辑间隔;① 取值 / ② 增量列 → 编辑自身时刻。
const float deltaColumnLeft =
layout.markerColumns[EdgePanelLayout::MARKER_COL_DT].left;
const float timeColumnLeft =
layout.markerColumns[EdgePanelLayout::MARKER_COL_TIME].left;
m_editMarkerIndex = row.itemIndex;
if (static_cast<float>(x) >= deltaColumnLeft)
{
// 间隔编辑:初始值 = 当前间隔(秒)。最左边的点没有前一项 ⇒ 不允许编辑。
std::vector<std::size_t> order;
SortedFreeMarkerIndices(tabLane, item.ref, order);
if (order.empty() || order.front() == row.itemIndex)
{
return true; // 最左边的固定点没有"相对前一个"的间隔
}
double previousTime = 0.0;
for (std::size_t index = 0; index < order.size(); ++index)
{
if (order[index] == row.itemIndex && index > 0)
{
previousTime = tabLane.freeMarkerItems[order[index - 1]].time;
break;
}
}
const gd::RectF anchor(
deltaColumnLeft, row.rect.Y,
std::max(10.0f, layout.markerColumns[EdgePanelLayout::MARKER_COL_DT].width),
row.rect.Height);
BeginEdit(PlotEditTarget::FREE_MARKER_DELTA, panelLane,
FormatValue(item.time - previousTime), anchor);
m_editFieldRect = row.rect;
return true;
}
// 时刻编辑:输入框覆盖「③ 时刻」列(列起点由布局给出)。
const gd::RectF timeAnchor(
timeColumnLeft, row.rect.Y,
std::max(10.0f, layout.markerColumns[EdgePanelLayout::MARKER_COL_TIME].width +
layout.markerColumns[EdgePanelLayout::MARKER_COL_DT].width),
row.rect.Height);
BeginEdit(PlotEditTarget::FREE_MARKER_TIME, panelLane, FormatValue(item.time),
timeAnchor);
m_editFieldRect = row.rect;
return true;
}
}
return true;
}
}
// 双光标差值标签:双击进入差值编辑(输入值的量级始终为秒)。
// 注意:差值标签在"两光标过近"时会落到主时间轴内部,因此必须先于区域判定。
if (IsOnCursorSpanLabel(x, y))
{
return BeginEdit(PlotEditTarget::CURSOR_SPAN, -1, FormatDurationSeconds(CursorSpan()));
}
// 命中光标时间读数标签:编辑该条光标的时间(优先于光标线,避免两条过近时选错)。
{
int labelWhich = 0;
if (IsOnCursorTimeLabel(x, y, labelWhich))
{
m_editCursor = labelWhich;
m_activeCursor = labelWhich;
const double time = (labelWhich == 2) ? Tab().CursorTimeAlt() : Tab().CursorTime();
return BeginEdit(PlotEditTarget::CURSOR_TIME, -1, FormatValue(time));
}
}
// 命中时间光标:进入精确时间输入(交互与线宽输入框一致)。
{
int cursorWhich = 0;
if (IsOnCursorLine(x, cursorWhich))
{
m_editCursor = (cursorWhich == 2) ? 2 : 1;
m_activeCursor = m_editCursor;
const double time =
(m_editCursor == 2) ? Tab().CursorTimeAlt() : Tab().CursorTime();
return BeginEdit(PlotEditTarget::CURSOR_TIME, -1, FormatValue(time));
}
}
// 命中图例条目:进入信号改名(与左侧列表双击改名的交互一致)。
const SignalRef legendHit = LegendAtPoint(x, y);
if (legendHit.IsValid())
{
const ResolvedSignal resolved = ResolveSignal(legendHit);
if (resolved.data != nullptr)
{
SelectSignal(legendHit);
m_editSignal = legendHit;
// 先把输入框位置落在被双击的条目上,再进入编辑态。
for (const LegendHit& hit : m_legendHits)
{
if (hit.ref == legendHit)
{
m_editFieldRect = hit.rect;
break;
}
}
return BeginEdit(PlotEditTarget::SIGNAL_NAME, -1, resolved.data->name);
}
}
// 其它位置:回到初始状态(自适应 + 退出缩放模式)。
FitAll();
return true;
}
bool PlotView::DoubleClickIsPlainPress(int x, int y) const
{
const float fx = static_cast<float>(x);
const float fy = static_cast<float>(y);
if (!UiPointInRect(m_area, fx, fy))
{
return false;
}
// 实验性功能的浮动面板(含其上的复选框 / 滑块 / 复位按钮 / 关闭 ✕ / 标题栏):
// 只有「按下」语义 ⇒ 快速连点必须当作一次次按下(否则连点丢失)。
{
int panelLane = -1;
int panelKind = 0;
if (TopPanelAtPoint(fx, fy, panelLane, panelKind))
{
// ⚠️ 例外:**标题栏**(排除徽标 / ✕ / 折起三角)双击有明确语义 ——
// 把该面板所属网格滚动到视界内(用户 2026-10-21 要求,与单击 GX 徽标一致)。
// 其余落点仍然只有“按下”语义。
const EdgePanelLayout layout =
EdgePanelLayoutFor(panelLane, static_cast<EdgePanelKind>(panelKind));
const bool onTitle = layout.title.Width > 0.0f &&
UiPointInRect(layout.title, fx, fy) &&
!(layout.badge.Width > 0.0f && UiPointInRect(layout.badge, fx, fy)) &&
!(layout.close.Width > 0.0f && UiPointInRect(layout.close, fx, fy)) &&
!(layout.disclosure.Width > 0.0f &&
UiPointInRect(layout.disclosure, fx, fy));
return !onTitle;
}
}
// 右下角「回到最上方」浮动按钮:按下只登记、抬起才生效 ⇒ 只有单击语义。
if (m_backToTopAlpha > 0.01f)
{
const gd::RectF backToTop = BackToTopRect();
if (backToTop.Width > 0.0f && UiPointInRect(backToTop, fx, fy))
{
return true;
}
}
// 绘图区右侧滚动条:拖动语义。
if (m_scrollBarVisible && UiPointInRect(m_scrollBarRect, fx, fy))
{
return true;
}
// 网格 GX 徽标:按下即(抬起时)弹出「所属网格」菜单,双击没有额外含义。
if (LaneBadgeAtPoint(x, y) >= 0)
{
return true;
}
return false;
}
bool PlotView::OnMouseLeave()
{
bool changed = false;
if (m_hoverLane != -1)
{
m_hoverLane = -1;
changed = true;
}
if (m_hoverBadge != -1)
{
m_hoverBadge = -1;
changed = true;
}
if (m_hoverPanelBadgeLane != -1 || m_hoverPanelBadgeKind != -1)
{
// 浮动面板的「GX」徽标悬停提示必须随鼠标离开清除(否则提示气泡残留)。
m_hoverPanelBadgeLane = -1;
m_hoverPanelBadgeKind = -1;
changed = true;
}
if (m_hoverSignal.IsValid())
{
m_hoverSignal = SignalRef();
changed = true;
}
// 图例悬停半高亮同样随鼠标离开而消失(否则右键弹菜单/移出绘图区后残留在原地)。
if (m_hoverLegend.IsValid())
{
m_hoverLegend = SignalRef();
changed = true;
}
// 「自由标记」的跟随光标随鼠标离开绘图区而消失(已固定的点仍然保留)。
if (m_freeMarker.valid)
{
m_freeMarker = FreeMarkerProbe();
changed = true;
}
if (m_freeMarkerHoverLane != -1 || m_freeMarkerHoverItem != static_cast<std::size_t>(-1))
{
m_freeMarkerHoverLane = -1;
m_freeMarkerHoverItem = static_cast<std::size_t>(-1);
changed = true;
}
if (m_freeMarkerPanelHoverLane != -1)
{
m_freeMarkerPanelHoverLane = -1;
m_freeMarkerPanelHoverItem = static_cast<std::size_t>(-1);
m_freeMarkerPanelHoverSignal = SignalRef();
changed = true;
}
if (m_freeMarkerRowHoverLane != -1 ||
m_freeMarkerRowHoverItem != static_cast<std::size_t>(-1))
{
m_freeMarkerRowHoverLane = -1;
m_freeMarkerRowHoverItem = static_cast<std::size_t>(-1);
changed = true;
}
// 「均值滤波 / 弱峰调整」条目栏的悬停高亮同样随鼠标离开清除(否则预览曲线一直亮着)。
if (m_meanRowHoverLane != -1 || m_meanRowHoverRef.IsValid())
{
m_meanRowHoverLane = -1;
m_meanRowHoverRef = SignalRef();
changed = true;
}
if (m_cursorHover)
{
m_cursorHover = false;
changed = true;
}
if (m_hoverCursor != 0 && m_dragMode != PlotDragMode::CURSOR_MOVE)
{
m_hoverCursor = 0;
changed = true;
}
if (m_spanHover)
{
m_spanHover = false;
changed = true;
}
return changed;
}
int PlotView::LaneBadgeAtPoint(int x, int y) const
{
const float fx = static_cast<float>(x);
const float fy = static_cast<float>(y);
for (std::size_t index = 0; index < m_lanes.size(); ++index)
{
const gd::RectF badge = LaneBadgeRect(static_cast<int>(index));
if (badge.Width > 0.0f && UiPointInRect(badge, fx, fy))
{
return static_cast<int>(index);
}
}
return -1;
}
float PlotView::LaneDragOffset(int laneIndex) const
{
if (m_dragMode != PlotDragMode::LANE_REORDER || m_laneDragFrom < 0 ||
laneIndex < 0 || laneIndex >= static_cast<int>(m_lanes.size()))
{
return 0.0f;
}
const float laneHeight = m_lanes[static_cast<std::size_t>(m_laneDragFrom)].rect.Height;
if (laneIndex == m_laneDragFrom)
{
// 被拖拽的网格自由跟随鼠标(整体绘制区域跟着走)。
const float baseTop = m_lanes[static_cast<std::size_t>(laneIndex)].rect.Y;
return static_cast<float>(m_laneDragMouseY) - m_laneDragGrabOffset - baseTop;
}
const int target = (m_laneDragTarget >= 0) ? m_laneDragTarget : m_laneDragFrom;
if (target > m_laneDragFrom && laneIndex > m_laneDragFrom && laneIndex < target)
{
// 向下拖:被跨越的网格整体上移一格让出位置(大小不变)。
// 移动到插入位置 target 的结果是 [1,2,...,A,target,...],
// 因此需要上移的是 (from, target) 区间内的网格,不含 target 本身。
return -laneHeight;
}
if (target < m_laneDragFrom && laneIndex >= target && laneIndex < m_laneDragFrom)
{
// 向上拖:中间的网格整体下移一格。
return laneHeight;
}
return 0.0f;
}
int PlotView::LaneDragTargetAt(int mouseY) const
{
if (m_lanes.empty())
{
return 0;
}
const int count = static_cast<int>(m_lanes.size());
const float laneHeight = m_lanes.front().rect.Height;
if (!(laneHeight > 0.0f))
{
return m_laneDragFrom;
}
// 判据 = **被拖网格自己的上下边界**(用户明确要求):
// * 上边界越过"上方第 m 个网格"的中心水平线 → 让位 m 个位置(插入位置 = from - m);
// * 下边界越过"下方第 m 个网格"的中心水平线 → 让位 m 个位置(插入位置 = from + m + 1,
// 因为被拖网格会先从原位置摘除,越过后应落在该网格之后)。
//
// ⚠️ 旧判据只看**鼠标指针**落在哪个网格的哪一半。因为被拖网格整体跟随指针,而指针通常
// 按在网格上沿的 GX 徽标上,于是"向下拖必须把整个网格拖过下方网格才吸附、
// 向上拖只要半个网格" —— 上下不对称(用户报告)。改用网格边界后上下完全对称。
//
// 数学:令 shift = 被拖网格相对原槽位的位移,H = 网格高。
// 上边界 = baseTop + shift,上方第 m 个网格的中心线 = baseTop - (m - 0.5)H
// ⇒ 满足 shift < (0.5 - m)H 的最大 m 即让位格数(m < 0.5 - shift/H);
// 下边界 = baseTop + shift + H,下方第 m 个网格的中心线 = baseTop + (m + 0.5)H
// ⇒ 满足 shift > (m - 0.5)H 的最大 m(m < shift/H + 0.5)。
// ⚠️ 两侧条件互斥(不可能同时成立),因此最多只会向一侧吸附。
const float baseTop = m_lanes[static_cast<std::size_t>(m_laneDragFrom)].rect.Y;
const float shift =
static_cast<float>(mouseY) - m_laneDragGrabOffset - baseTop;
const float upRatio = 0.5f - shift / laneHeight; // m < upRatio
const float downRatio = shift / laneHeight + 0.5f; // m < downRatio
// "严格小于" ⇒ 最大整数 m = ceil(ratio) - 1。
int up = static_cast<int>(std::ceil(upRatio)) - 1;
int down = static_cast<int>(std::ceil(downRatio)) - 1;
up = std::max(0, std::min(up, m_laneDragFrom));
down = std::max(0, std::min(down, count - 1 - m_laneDragFrom));
if (up > 0)
{
return m_laneDragFrom - up;
}
if (down > 0)
{
return m_laneDragFrom + down + 1;
}
return m_laneDragFrom;
}
void PlotView::FinishLaneReorder()
{
const int from = m_laneDragFrom;
const int target = m_laneDragTarget;
m_laneDragFrom = -1;
m_laneDragTarget = -1;
m_laneDragGrabOffset = 0.0f;
m_dragMode = PlotDragMode::NONE;
if (m_tab == nullptr || from < 0 || target < 0)
{
return;
}
if (!m_tab->MoveLane(from, target))
{
return;
}
// 网格次序变化 → 几何、派生缓存与静态场景缓存全部失效。
BuildLaneVisuals();
InvalidateDerivedCaches();
}
SignalRef PlotView::LegendAtPoint(int x, int y) const
{
const float fx = static_cast<float>(x);
const float fy = static_cast<float>(y);
for (const LegendHit& hit : m_legendHits)
{
if (UiPointInRect(hit.rect, fx, fy))
{
return hit.ref;
}
}
return SignalRef();
}
gd::RectF PlotView::CursorSpanRect(bool& insideAxis) const
{
insideAxis = false;
if (m_tab == nullptr || Tab().CursorMode() != PlotCursorMode::DOUBLE ||
m_fonts == nullptr || m_fonts->monoSmall == nullptr)
{
return gd::RectF();
}
const std::wstring text = FormatSpanText(CursorSpan());
const float width = std::max(
72.0f * m_scale, UiMeasureTextWidth(m_fonts->monoSmall, text) + 18.0f * m_scale);
const float height = 20.0f * m_scale;
// 水平位置取两条光标时间读数的中点(与它们同一行,位于时间轴上方)。
// 注意用"显示横坐标":光标滞留在可视区外时会被钉在网格边缘,
// 此时二者在屏幕上必然很近,差值标签应改放到主时间轴内部。
const gd::RectF first = CursorLabelRect(1);
const gd::RectF second = CursorLabelRect(2);
float center = (CursorDisplayX(1) + CursorDisplayX(2)) * 0.5f;
if (first.Width > 0.0f && second.Width > 0.0f)
{
center = ((first.X + first.Width * 0.5f) + (second.X + second.Width * 0.5f)) * 0.5f;
}
float left = center - width * 0.5f;
left = std::max(m_plotRect.X + 2.0f * m_scale,
std::min(left, m_plotRect.X + m_plotRect.Width - width - 2.0f * m_scale));
// 两条**时间读数标签**之间的净空不足时,同一行放不下第三个标签
// → 改放到主时间轴内部(时间轴上方已无空间)。
const gd::RectF& leftRect = (first.X <= second.X) ? first : second;
const gd::RectF& rightRect = (first.X <= second.X) ? second : first;
const float freeStart = leftRect.X + leftRect.Width;
const float freeEnd = rightRect.X;
const bool fits = (freeStart <= freeEnd) && ((freeEnd - freeStart) >= (width + 2.0f * m_scale));
// 双光标都滞留在**同一侧**视界之外时,两条时间读数会被钉在同一条边上并叠成两行;
// 此时差值标签也跟着它们排成一列(用户要求"垂直排列要对齐"),
// 而不是孤零零地留在时间轴上方。
const float pinLeftEdge = m_plotRect.X + 1.0f * m_scale;
const float pinRightEdge = m_plotRect.X + m_plotRect.Width - 1.0f * m_scale;
const bool offLeft1 = CursorX(1) < pinLeftEdge;
const bool offRight1 = CursorX(1) > pinRightEdge;
const bool offLeft2 = CursorX(2) < pinLeftEdge;
const bool offRight2 = CursorX(2) > pinRightEdge;
const bool bothOffSameSide = (offLeft1 && offLeft2 && !offRight1 && !offRight2) ||
(offRight1 && offRight2 && !offLeft1 && !offLeft2);
if (bothOffSameSide)
{
// 与两条时间读数同一列、叠在它们下方(更靠下的一行之下)。
const float columnLeft = std::max(m_plotRect.X, first.X);
const float stackBottom = std::max(first.Y, second.Y) + first.Height + 2.0f * m_scale;
insideAxis = true;
const float axisHeight = std::max(14.0f * m_scale, m_timeAxisRect.Height - 4.0f * m_scale);
return gd::RectF(columnLeft, stackBottom, width, std::min(height, axisHeight));
}
if (!fits)
{
insideAxis = true;
const float top = m_timeAxisRect.Y + 2.0f * m_scale;
const float axisHeight = std::max(14.0f * m_scale, m_timeAxisRect.Height - 4.0f * m_scale);
return gd::RectF(left, top, width, std::min(height, axisHeight));
}
return gd::RectF(left, m_plotRect.Y + m_plotRect.Height - height - 2.0f * m_scale, width, height);
}
bool PlotView::CursorOffViewSide(int which, bool& offRight) const
{
offRight = false;
if (m_tab == nullptr || !Tab().CursorEnabled())
{
return false;
}
const float pinLeft = m_plotRect.X + 1.0f * m_scale;
const float pinRight = m_plotRect.X + m_plotRect.Width - 1.0f * m_scale;
const float raw = CursorX(which);
if (raw < pinLeft)
{
return true;
}
if (raw > pinRight)
{
offRight = true;
return true;
}
return false;
}
void PlotView::SyncPanelOrder() const
{
// 收集当前"可见"(已启用且已展开、且网格内有可见信号)的面板集合。
std::vector<PanelRef> visible;
for (int index = 0; index < static_cast<int>(m_lanes.size()); ++index)
{
for (int pass = 0; pass < kEdgePanelKindCount; ++pass)
{
const EdgePanelKind kind = kEdgePanelKinds[pass];
if (EdgePanelLayoutFor(index, kind).panel.Width > 0.0f)
{
PanelRef ref;
ref.laneIndex = index;
ref.kind = static_cast<int>(kind);
visible.push_back(ref);
}
}
}
// 保留已有层序(剔除已不可见的),并把**新出现**的面板追加到队尾(= 最上层),
// 这样新打开的面板不会被已有面板盖住。
std::vector<PanelRef> next;
next.reserve(visible.size());
for (const PanelRef& existing : m_panelOrder)
{
for (const PanelRef& candidate : visible)
{
if (candidate == existing)
{
next.push_back(existing);
break;
}
}
}
for (const PanelRef& candidate : visible)
{
bool present = false;
for (const PanelRef& kept : next)
{
if (kept == candidate)
{
present = true;
break;
}
}
if (!present)
{
next.push_back(candidate);
}
}
m_panelOrder = std::move(next);
}
void PlotView::BringPanelToFront(int laneIndex, int kind)
{
SyncPanelOrder();
PanelRef target;
target.laneIndex = laneIndex;
target.kind = kind;
for (std::size_t index = 0; index < m_panelOrder.size(); ++index)
{
if (!(m_panelOrder[index] == target))
{
continue;
}
if (index + 1 == m_panelOrder.size())
{
return; // 已经在最上层
}
m_panelOrder.erase(m_panelOrder.begin() + static_cast<std::ptrdiff_t>(index));
m_panelOrder.push_back(target);
return;
}
}
bool PlotView::TopPanelAtPoint(float x, float y, int& laneIndex, int& kind) const
{
laneIndex = -1;
kind = 0;
SyncPanelOrder();
// `m_panelOrder` 队尾 = 最上层:从后往前找第一个命中的面板。
for (std::size_t index = m_panelOrder.size(); index-- > 0;)
{
const PanelRef& ref = m_panelOrder[index];
const EdgePanelLayout layout =
EdgePanelLayoutFor(ref.laneIndex, static_cast<EdgePanelKind>(ref.kind));
if (layout.panel.Width <= 0.0f)
{
continue;
}
if (UiPointInRect(layout.panel, x, y))
{
laneIndex = ref.laneIndex;
kind = ref.kind;
return true;
}
}
return false;
}
int PlotView::PeakRowIndexOf(const EdgePanelLayout& layout, const SignalRef& ref) const
{
for (std::size_t index = 0; index < layout.rowRefs.size(); ++index)
{
if (layout.rowRefs[index] == ref)
{
return static_cast<int>(index);
}
}
return -1;
}
bool PlotView::IsOnEdgeCursorLine(int x, int y, int& laneIndex) const
{
laneIndex = -1;
if (m_tab == nullptr)
{
return false;
}
const std::vector<Lane>& tabLanes = m_tab->Lanes();
const float fx = static_cast<float>(x);
const float fy = static_cast<float>(y);
const float tolerance = 5.0f * m_scale;
for (std::size_t index = 0; index < m_lanes.size() && index < tabLanes.size(); ++index)
{
if (!tabLanes[index].edgeCount)
{
continue;
}
const LaneVisual& visual = m_lanes[index];
if (fx < visual.rect.X || fx > visual.rect.X + visual.rect.Width)
{
continue;
}
const float clipTop = std::max(visual.rect.Y, m_plotRect.Y);
const float clipBottom =
std::min(visual.rect.Y + visual.rect.Height, m_plotRect.Y + m_plotRect.Height);
if (clipBottom <= clipTop)
{
continue;
}
const float lineY = std::max(
clipTop, std::min(ValueToY(tabLanes[index].edgeValue, visual), clipBottom));
if (std::fabs(fy - lineY) <= tolerance)
{
laneIndex = static_cast<int>(index);
return true;
}
}
return false;
}
PlotView::DataMarker PlotView::MakeDataMarker(float rawX, float rawY, float pinX, float clipTop,
float clipBottom, float topReserve) const
{
DataMarker marker;
// 形状**只**由“数据点本身是否越出绘制边界”决定:
// 旧实现拿“夹取之后”的位置反推越界方向,于是贴近边界、其实仍在视界内的数据点
// 也会被误画成三角(用户报告的“数值显示超出边界就改变数据点形状”)。
const float left = m_plotRect.X;
const float right = m_plotRect.X + m_plotRect.Width;
marker.offLeft = (rawX < left);
marker.offRight = (rawX > right);
marker.offUp = (rawY < clipTop);
marker.offDown = (rawY > clipBottom);
marker.x = pinX;
// 左右越界时三角沿水平方向**向外**伸出 `kMarkerTriangleDepth`:
// 光标贴边时 pinX 只离边界 1px,若直接把锚点放在那里,三角会有一大半画到
// 网格绘制区之外(被裁剪后只看到残缺的一角,描边也随之断开)。
// 因此把锚点沿水平方向**向内**推 keepIn,保证三角完整落在绘制区内。
const float keepIn = (kMarkerTriangleDepth + 1.0f) * m_scale;
if (marker.offLeft)
{
marker.x = std::max(pinX, left + keepIn);
}
else if (marker.offRight)
{
marker.x = std::min(pinX, right - keepIn);
}
if (marker.offUp)
{
// 上越界:为方向三角与顶部把手预留高度,避免压住把手。
marker.y = clipTop + kMarkerEdgeInset * m_scale + topReserve;
}
else if (marker.offDown)
{
marker.y = clipBottom - kMarkerEdgeInset * m_scale;
}
else
{
// 视界内:保留数据点的真实纵坐标(越界判定已在上面完成,这里夹取只为了让
// 圆点整体可见,不会影响形状)。
marker.y = std::max(clipTop + kMarkerInsideMargin * m_scale,
std::min(rawY, clipBottom - kMarkerInsideMargin * m_scale));
}
return marker;
}
bool PlotView::MarkerCoveredByCursorLabel(const DataMarker& marker, float radius,
gd::RectF& hit) const
{
const gd::RectF circle(marker.x - radius, marker.y - radius, radius * 2.0f, radius * 2.0f);
for (const gd::RectF& rect : m_cursorLabelRects)
{
if (rect.Width <= 0.0f || rect.Height <= 0.0f)
{
continue;
}
// 圆心落在面板内 ⇒ 肯定被遮住(最常见:圆点正好压在时间读数上)。
if (UiPointInRect(rect, marker.x, marker.y))
{
hit = rect;
return true;
}
// 否则看重叠面积:超过圆点外接矩形的一半也算“基本看不见”。
gd::RectF overlap = circle;
overlap.Intersect(rect);
if (overlap.Width > 0.0f && overlap.Height > 0.0f &&
overlap.Width * overlap.Height >= circle.Width * circle.Height * 0.5f)
{
hit = rect;
return true;
}
}
return false;
}
void PlotView::CoverHiddenMarker(DataMarker& marker, float radius, float clipTop) const
{
if (marker.offUp || marker.offDown || marker.offLeft || marker.offRight)
{
return; // 已经是越界三角:保持原语义
}
gd::RectF covering;
if (!MarkerCoveredByCursorLabel(marker, radius, covering))
{
return;
}
marker.offDown = true;
marker.y = std::max(clipTop + kMarkerEdgeInset * m_scale,
covering.Y - (kMarkerTriangleDepth * 0.5f + 1.0f * m_scale));
}
void PlotView::PlaceCursorLabel(gd::RectF& rect, float preferredTop, float textHeight,
float clipTop, float clipBottom)
{
const float gap = 2.0f * m_scale;
const float minTop = clipTop + 1.0f * m_scale;
const float maxTop = std::max(minTop, clipBottom - textHeight - 1.0f * m_scale);
rect.Y = std::max(minTop, std::min(preferredTop, maxTop));
const auto overlaps = [this](const gd::RectF& candidate, const gd::RectF*& hit) -> bool
{
for (const gd::RectF& occupied : m_cursorLabelRects)
{
if (candidate.IntersectsWith(occupied))
{
hit = &occupied;
return true;
}
}
return false;
};
bool resolved = false;
for (int step = 0; step < 16; ++step)
{
const gd::RectF* hit = nullptr;
if (!overlaps(rect, hit))
{
resolved = true;
break;
}
// 向下推一格;超出网格底部则改由原始位置向上避让。
const float next = hit->Y + hit->Height + gap;
if (next > maxTop)
{
break;
}
rect.Y = next;
}
if (!resolved)
{
rect.Y = std::max(minTop, std::min(preferredTop, maxTop));
for (int step = 0; step < 16; ++step)
{
const gd::RectF* hit = nullptr;
if (!overlaps(rect, hit))
{
break;
}
rect.Y = hit->Y - rect.Height - gap;
if (rect.Y < minTop)
{
break;
}
}
}
rect.Y = std::max(minTop, std::min(rect.Y, maxTop));
m_cursorLabelRects.push_back(rect);
}
} // namespace jdi