// 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(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(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(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(x); const float fy = static_cast(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(x), static_cast(y)); } int PlotView::LaneIndexAt(int x, int y) const { if (!UiPointInRect(m_plotRect, static_cast(x), static_cast(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(x), static_cast(y))) { return empty; } const int laneIndex = LaneAtY(y); if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) { return empty; } const LaneVisual& lane = m_lanes[static_cast(laneIndex)]; const float tolerance = kSignalHitTolerance * m_scale; const float fx = static_cast(x); const float fy = static_cast(y); SignalRef best; float bestDistance = tolerance; // 只为当前网格内的可见信号做精确的点到线段距离检测,避免跨网格误命中。 for (const SignalRef& ref : m_tab->Lanes()[static_cast(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& points = cached->points; const std::vector& 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::lower_bound(points.begin(), points.end(), fx - tolerance, byX) - points.begin()); windowLast = static_cast( 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) { // 「均值点数 X」/「迭代次数 N」(均值滤波、中心轴)与「迭代次数 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(m_lanes.size())) { return gd::RectF(); } const LaneVisual& lane = m_lanes[static_cast(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(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(-1); Lane& lane = m_tab->Lanes()[static_cast(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 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(laneIndex)]; const Lane::WeakPeakItem* existing = lane.FindWeakPeakItem(m_editMeanRef); if (existing == nullptr) { return true; } if (parsedOk) { const long long value = static_cast(std::llround(parsed)); if (value >= kWeakPeakIterationsMin && value <= kWeakPeakIterationsMaxLimit) { Lane::WeakPeakItem& item = lane.EnsureWeakPeakItem(m_editMeanRef); item.maxIterations = static_cast(value); // 当前值超出新上限 → 自动替换为上界。 item.iterations = std::max(kWeakPeakIterationsMin, std::min(item.maxIterations, item.iterations)); // 曲线可能因迭代次数被夹取而变化,缓存整体作废最稳妥。 m_weakPeakCurveCache.clear(); } } 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(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(std::lround(parsed)); // 迭代次数至少为 1(1 次迭代等于不做滤波);均值点数可以为 0。 value = isPoints ? std::max(0, 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(m_lanes.size())) { return true; } const LaneVisual& visual = m_lanes[static_cast(laneIndex)]; Lane& lane = m_tab->Lanes()[static_cast(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(x), static_cast(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: // 「弱峰调整」滑块拖拽 → 左右箭头。 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(x), static_cast(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(x), static_cast(y))) { return LoadCursorW(nullptr, IDC_HAND); } } // 实验性功能面板与横向光标优先于信号悬停提示判定(面板会盖住波形)。 // 面板**只有标题栏**可以拖拽(显示抓握光标);✕ 与面板主体一律保持原始箭头。 if (m_tab != nullptr) { // 按**层序从上层往下**判定,重叠时以最上层的面板为准。 int panelLane = -1; int panelKind = 0; if (TopPanelAtPoint(static_cast(x), static_cast(y), panelLane, panelKind)) { const EdgePanelLayout layout = EdgePanelLayoutFor(panelLane, static_cast(panelKind)); if (UiPointInRect(layout.close, static_cast(x), static_cast(y))) { // ✕ 按钮:与面板/标签栏的其它 ✕ 一致,用手型光标作为可点击提示。 return LoadCursorW(nullptr, IDC_HAND); } // 「GX」编号徽标:悬停给出提示 + 手型光标;点击把该网格滚动到视界内。 // ⚠️ 必须排在标题栏之前(徽标位于标题栏矩形内部,否则会被判成“可拖拽”)。 if (layout.badge.Width > 0.0f && UiPointInRect(layout.badge, static_cast(x), static_cast(y))) { return LoadCursorW(nullptr, IDC_HAND); } if (UiPointInRect(layout.title, static_cast(x), static_cast(y))) { return LoadCursorW(nullptr, IDC_SIZEALL); } if ((static_cast(panelKind) == EdgePanelKind::MEAN || static_cast(panelKind) == EdgePanelKind::PEAK) && layout.button.Width > 0.0f && UiPointInRect(layout.button, static_cast(x), static_cast(y))) { // 「构建为新信号」按钮:手型光标作为可点击提示。 return LoadCursorW(nullptr, IDC_HAND); } // 「自由标记」面板:行尾 ✕ 与信号名右侧 ✕ 都是可点击控件(手型)。 if (static_cast(panelKind) == EdgePanelKind::MARK) { for (const EdgePanelLayout::MarkerRow& row : layout.markerRows) { if (row.close.Width > 0.0f && UiPointInRect(row.close, static_cast(x), static_cast(y))) { return LoadCursorW(nullptr, IDC_HAND); } } for (const gd::RectF& rect : layout.rowNameCloses) { if (rect.Width > 0.0f && UiPointInRect(rect, static_cast(x), static_cast(y))) { return LoadCursorW(nullptr, IDC_HAND); } } } // 「弱峰调整」的逐信号滑块:轨道 → 左右箭头;迭代次数右侧数值 → 手型(可点击改上限)。 if (static_cast(panelKind) == EdgePanelKind::PEAK) { for (std::size_t rowIndex = 0; rowIndex < layout.rowIterValues.size(); ++rowIndex) { if (layout.rowIterValues[rowIndex].Width > 0.0f && UiPointInRect(layout.rowIterValues[rowIndex], static_cast(x), static_cast(y))) { return LoadCursorW(nullptr, IDC_HAND); } // 两行滑块右侧的**复位控件**:与其它可点击图标一致,用手型光标提示可点击。 if (rowIndex < layout.rowLevelResets.size() && layout.rowLevelResets[rowIndex].Width > 0.0f && UiPointInRect(layout.rowLevelResets[rowIndex], static_cast(x), static_cast(y))) { return LoadCursorW(nullptr, IDC_HAND); } if (rowIndex < layout.rowIterResets.size() && layout.rowIterResets[rowIndex].Width > 0.0f && UiPointInRect(layout.rowIterResets[rowIndex], static_cast(x), static_cast(y))) { return LoadCursorW(nullptr, IDC_HAND); } if (rowIndex < layout.rowIterTracks.size() && WeakPeakSliderHit(layout.rowIterTracks[rowIndex], m_scale, static_cast(x), static_cast(y))) { return LoadCursorW(nullptr, IDC_SIZEWE); } if (rowIndex < layout.rowLevelTracks.size() && WeakPeakSliderHit(layout.rowLevelTracks[rowIndex], m_scale, static_cast(x), static_cast(y))) { return LoadCursorW(nullptr, IDC_SIZEWE); } } } // 面板主体(含底部编辑行与参数输入框):保持原始箭头。 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(x), static_cast(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(x), static_cast(y))) { return LoadCursorW(nullptr, IDC_SIZEWE); } } if (UiPointInRect(m_plotRect, static_cast(x), static_cast(y))) { // 悬停命中可拖拽信号时**不改变鼠标形状**(维持原始箭头); // 只有真正按下拖动(MOVE_SIGNAL)才切换为抓握光标。 // 悬停图例时同样保持箭头,仅做半高亮提示。 // 选区放大模式下**未开始框选时保持默认箭头**(不改变鼠标形状); // 只有真正按下并拖动后才切成十字/方向箭头。 // (m_dragMode == RUBBER_SELECT 的情况已在上面处理。) } return nullptr; } bool PlotView::OnMouseMove(int x, int y) { const bool mouseMoved = (x != m_mouseX) || (y != m_mouseY); m_mouseX = x; m_mouseY = y; if (!HasDrawableContent()) { return false; } const float fx = static_cast(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(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(x - m_dragLastX) / static_cast(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(m_lanes.size()) && y != m_dragLastY) { const LaneVisual& visual = m_lanes[static_cast(laneIndex)]; if (visual.rect.Height > 0.0f) { const double valueSpan = visual.yMax - visual.yMin; const double deltaValue = static_cast(y - m_dragLastY) / static_cast(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(x - m_dragStartX); const float dy = static_cast(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(m_dragStartX), static_cast(plotLeft), static_cast(plotRight)); const float to = ClampDouble(static_cast(x), static_cast(plotLeft), static_cast(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(m_lanes.size())) { return false; } const gd::RectF& laneRect = m_lanes[static_cast(m_dragLane)].rect; const float from = ClampDouble(static_cast(m_dragStartY), static_cast(laneRect.Y), static_cast(laneRect.Y + laneRect.Height)); const float to = ClampDouble(static_cast(y), static_cast(laneRect.Y), static_cast(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(y - m_dragLastY) / static_cast(m_plotRect.Height); // 用户自己拖滚动条 ⇒ 立即取消“点点信号后自动滚入可视区”的动画(否则两者会抢滚动位置)。 CancelLaneScrollAnimation(); Tab().LaneScroll() += static_cast(travel * m_laneContentHeight); m_dragLastY = y; m_dragMoved = true; BuildLaneVisuals(); return true; } case PlotDragMode::CURSOR_MOVE: { // 从时间读数标签上抓起时保留横向抓取偏移,避免一按下光标就跳到鼠标中心。 const double target = XToTime(fx - static_cast(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(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(m_tab->Lanes().size())) { break; } const EdgePanelLayout layout = EdgePanelLayoutFor(m_peakDragLane, EdgePanelKind::PEAK); Lane& lane = m_tab->Lanes()[static_cast(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(layout.rowLevelTracks.size()) || layout.rowLevelTracks[static_cast(rowIndex)].Width <= 0.0f) { break; } const double ratio = UiSliderRatioFromX(layout.rowLevelTracks[static_cast(rowIndex)], fx); // 滑块量程 -200% – +200%:0 在正中间。 // 滑块量程 -400% – +400%(三组规则的梯度区间):0 在正中间。 item.amountPercent = std::max( kWeakPeakAmountMin, std::min(kWeakPeakAmountMax, static_cast(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(m_tab->Lanes().size())) { break; } const EdgePanelLayout layout = EdgePanelLayoutFor(m_peakDragLane, EdgePanelKind::PEAK); Lane& lane = m_tab->Lanes()[static_cast(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(layout.rowIterTracks.size()) || layout.rowIterTracks[static_cast(rowIndex)].Width <= 0.0f) { break; } const double ratio = UiSliderRatioFromX(layout.rowIterTracks[static_cast(rowIndex)], fx); const int peakMax = std::max(kWeakPeakIterationsMin, std::min(kWeakPeakIterationsMaxLimit, item.maxIterations)); item.iterations = std::max( kWeakPeakIterationsMin, std::min(peakMax, kWeakPeakIterationsMin + static_cast(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::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(m_lanes.size()) || m_edgeDragLane >= static_cast(m_tab->Lanes().size())) { break; } const LaneVisual& visual = m_lanes[static_cast(m_edgeDragLane)]; Lane& lane = m_tab->Lanes()[static_cast(m_edgeDragLane)]; double value = YToValue(static_cast(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(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(x) - m_edgePanelDragOffset.X; float top = static_cast(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(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::COUNT: default: lane.edgePanel.x = left; lane.edgePanel.y = top; break; } m_dragMoved = true; return true; } case PlotDragMode::NONE: default: break; } bool changed = false; // ---- 「自由标记」:跟随光标解析 + 两处 ✕ 的悬停态 ---- // 跟随光标是**每帧随鼠标变化**的叠加层(不进任何缓存),因此只要鼠标位置变了就得重绘。 { const FreeMarkerProbe probe = ProbeFreeMarker(x, y); const bool probeChanged = (probe.valid != m_freeMarker.valid) || (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(-1); for (const FreeMarkerHit& hit : m_freeMarkerHits) { if (hit.close.Width > 0.0f && UiPointInRect(hit.close, static_cast(x), static_cast(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(-1); SignalRef hoverSignal; int markLane = -1; int markKind = 0; if (m_tab != nullptr && TopPanelAtPoint(static_cast(x), static_cast(y), markLane, markKind) && static_cast(markKind) == EdgePanelKind::MARK) { const EdgePanelLayout layout = EdgePanelLayoutFor(markLane, EdgePanelKind::MARK); // 固定点行 ✕ 优先(更具体)。 for (const EdgePanelLayout::MarkerRow& row : layout.markerRows) { if (row.close.Width > 0.0f && UiPointInRect(row.close, static_cast(x), static_cast(y))) { hoverLane = markLane; hoverItem = row.itemIndex; break; } } if (hoverLane < 0) { for (std::size_t rowIndex = 0; rowIndex < layout.rowNameCloses.size(); ++rowIndex) { const gd::RectF& rect = layout.rowNameCloses[rowIndex]; if (rect.Width > 0.0f && UiPointInRect(rect, static_cast(x), static_cast(y)) && rowIndex < layout.rowRefs.size()) { hoverLane = markLane; hoverItem = static_cast(-1); hoverSignal = layout.rowRefs[rowIndex]; break; } } } } if (hoverLane != m_freeMarkerPanelHoverLane || hoverItem != m_freeMarkerPanelHoverItem || !(hoverSignal == m_freeMarkerPanelHoverSignal)) { m_freeMarkerPanelHoverLane = hoverLane; m_freeMarkerPanelHoverItem = hoverItem; m_freeMarkerPanelHoverSignal = hoverSignal; changed = true; } } // 鼠标压在实验性功能面板上时不再触发信号悬停提示(面板本来就盖住波形), // 但该面板所属的网格仍然要高亮(与鼠标直接停在网格上一致)。 // 按**层序**取最上层的面板(与点击命中保持一致,重叠时以最上层为准)。 bool overEdgePanel = false; int panelLane = -1; int hoverPanelKind = 0; { overEdgePanel = TopPanelAtPoint(static_cast(x), static_cast(y), panelLane, hoverPanelKind); } // 「弱峰调整」每行滑块右侧的**复位控件**:悬停时给出一次性提示(进入时设置、离开时清除)。 // 与「系数/偏移」二级面板标题栏的还原图标同一交互语义(悬停高亮 + 手型 + 提示)。 bool overPeakReset = false; if (overEdgePanel && panelLane >= 0 && m_tab != nullptr && static_cast(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(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(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(hoverPanelKind)); if (badgeLayout.badge.Width > 0.0f && UiPointInRect(badgeLayout.badge, fx, static_cast(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(x), static_cast(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(x); const float fy = static_cast(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(x), static_cast(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(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(y) - m_lanes[static_cast(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(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 ((kind == EdgePanelKind::MEAN || kind == EdgePanelKind::CENTER || kind == EdgePanelKind::PEAK) && m_tab != nullptr) { const bool meanMode = (kind == EdgePanelKind::MEAN); const bool boostMode = (kind == EdgePanelKind::PEAK); Lane& tabLane = m_tab->Lanes()[static_cast(index)]; // 「弱峰调整」:先判两行滑块(数值文本 → 改上限;轨道 → 拖动)。 if (boostMode) { for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex) { const SignalRef& ref = layout.rowRefs[rowIndex]; const bool hasLevel = rowIndex < layout.rowLevelTracks.size() && layout.rowLevelTracks[rowIndex].Width > 0.0f; if (!hasLevel) { continue; } if (tabLane.FindWeakPeakItem(ref) == nullptr) { continue; } Lane::WeakPeakItem& item = tabLane.EnsureWeakPeakItem(ref); // 两行滑块右侧的**复位控件**:点击恢复该行参数的默认值 //(幅度 → 0;迭代次数 → 1 且上限回到初始上限 `kWeakPeakIterationsDefaultMax`)。 // 与「系数/偏移」二级面板里的还原图标同一交互语义(面板保持打开、就地回显)。 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; } 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 (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); // `BeginEdit` 默认用 NUMBER 过滤(允许负号/小数点);这里改为**纯数字**, // 从源头保证"只允许输入正整数"(负数、小数根本无法键入,粘贴同样被清洗)。 m_edit.Begin(L"", false, EditFilter::BYTE, 0u); m_dragMode = PlotDragMode::NONE; return true; } if (layout.rowIterTracks[rowIndex].Width > 0.0f && 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(std::lround( ratio * (peakMax - kWeakPeakIterationsMin))))); m_weakPeakCurveCache.clear(); return true; } if (layout.rowLevelTracks[rowIndex].Width > 0.0f && WeakPeakSliderHit(layout.rowLevelTracks[rowIndex], m_scale, fx, fy)) { m_dragMode = PlotDragMode::PEAK_LEVEL; m_peakDragLane = index; m_peakDragRef = ref; // 单击轨道:直接写入该处对应的幅度(-400% … +400%,0 在正中间)。 item.amountPercent = static_cast(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; } } } for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex) { const SignalRef& ref = layout.rowRefs[rowIndex]; // 第一排右侧的复选框 → 切换该信号的预览勾选状态。 if (layout.rowChecks[rowIndex].Width > 0.0f && UiPointInRect(layout.rowChecks[rowIndex], fx, fy)) { if (meanMode) { ToggleLaneMeanFilterSignal(index, ref); } else if (boostMode) { ToggleLaneWeakPeakSignal(index, ref); } else { ToggleLaneEdgeCenterSignal(index, ref); } // 勾选变化只改变该信号“是否显示”,它的统计状态(与参数相关)依然有效, // 其它信号更是完全无关 ⇒ **不要**清任何缓存(清掉就会让所有统计重算)。 // 这里**不**整体作废场景缓存(交给 `m_sceneDerived` 快照驱动局部重画)。 m_dragMode = PlotDragMode::NONE; return true; } // 第二排的两个输入框 → 就地编辑该信号的参数(仅 MEAN / CENTER 面板; // 弱峰调整用的是滑块,已在上面处理)。 if (layout.rowPoints[rowIndex].Width > 0.0f && UiPointInRect(layout.rowPoints[rowIndex], fx, fy)) { m_editMeanRef = ref; const Lane::MeanFilterItem* item = meanMode ? tabLane.FindMeanFilterItem(ref) : tabLane.FindEdgeCenterItem(ref); BeginEdit(meanMode ? PlotEditTarget::MEAN_FILTER_POINTS : PlotEditTarget::EDGE_CENTER_POINTS, index, std::to_wstring(item != nullptr ? item->points : 5), layout.rowPoints[rowIndex]); return true; } if (layout.rowIterations[rowIndex].Width > 0.0f && UiPointInRect(layout.rowIterations[rowIndex], fx, fy)) { m_editMeanRef = ref; const Lane::MeanFilterItem* item = meanMode ? tabLane.FindMeanFilterItem(ref) : tabLane.FindEdgeCenterItem(ref); BeginEdit(meanMode ? PlotEditTarget::MEAN_FILTER_ITERATIONS : PlotEditTarget::EDGE_CENTER_ITERATIONS, index, std::to_wstring(item != nullptr ? item->iterations : 1), layout.rowIterations[rowIndex]); return true; } } if (boostMode && layout.button.Width > 0.0f && UiPointInRect(layout.button, fx, fy)) { // 「弱峰调整 → 构建为新信号」:与均值滤波同一套请求机制, // **每个信号用自己的等级与迭代次数**(与预览曲线逐位一致)。 const Lane& lane = m_tab->Lanes()[static_cast(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(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; build.points = item->points; build.iterations = 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(index)]; for (const EdgePanelLayout::MarkerRow& row : layout.markerRows) { if (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(-1); m_freeMarkerPanelHoverSignal = SignalRef(); return true; } } for (std::size_t rowIndex = 0; rowIndex < layout.rowNameCloses.size(); ++rowIndex) { const gd::RectF& rect = layout.rowNameCloses[rowIndex]; if (rect.Width > 0.0f && UiPointInRect(rect, fx, fy) && rowIndex < layout.rowRefs.size()) { tabLane.RemoveFreeMarkersOf(layout.rowRefs[rowIndex]); m_dragMode = PlotDragMode::NONE; m_freeMarkerPanelHoverLane = -1; m_freeMarkerPanelHoverItem = static_cast(-1); m_freeMarkerPanelHoverSignal = SignalRef(); 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; } // ---- 「自由标记」---- // (1) 网格内固定点的 ✕:按下即移除该固定点(矩形由绘制时登记)。 for (const FreeMarkerHit& hit : m_freeMarkerHits) { if (hit.close.Width > 0.0f && UiPointInRect(hit.close, fx, fy) && m_tab != nullptr && hit.laneIndex >= 0 && hit.laneIndex < static_cast(m_tab->Lanes().size())) { Lane& tabLane = m_tab->Lanes()[static_cast(hit.laneIndex)]; tabLane.RemoveFreeMarkerAt(hit.itemIndex); m_dragMode = PlotDragMode::NONE; return true; } } // (2) 在开启「自由标记」的网格内按下:单击固定数据点、拖动仍是原有语义 // (常规模式 = 平移;选区放大模式 = 框选)。 // ⚠️ 用户明确要求:「仅单击时」才算固定数据点 —— 因此这里**只登记意图**, // 真正固定发生在 `OnLButtonUp` 且整段操作没有发生过拖动。 { const int freeLane = LaneAtY(y); if (freeLane >= 0 && freeLane < static_cast(m_tab ? m_tab->Lanes().size() : 0) && m_tab->Lanes()[static_cast(freeLane)].freeMarker) { 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; } // 中键:无论当前是否处于选区放大模式,都直接进入自由平移 // (不改变选区放大模式本身,松手后仍可继续框选)。 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(m_tab->Lanes().size())) { const FreeMarkerProbe probe = ProbeFreeMarker(x, y); Lane& tabLane = m_tab->Lanes()[static_cast(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(x), static_cast(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(m_lanes.size())) { const EdgePanelLayout layout = EdgePanelLayoutFor(lane, kind); if (layout.close.Width > 0.0f && UiPointInRect(layout.close, static_cast(x), static_cast(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(); // 松开左键:粘滞提示结束计时,开始"停留后淡出"。 ReleaseHint(); return changed; } bool PlotView::OnMouseWheel(int delta, bool shift) { if (!HasDrawableContent() || delta == 0) { return false; } const double notches = static_cast(delta) / 120.0; // 滚轮不再承担缩放:默认纵向滚动网格,按住 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(notches * 48.0 * m_scale); BuildLaneVisuals(); return true; } bool PlotView::OnDoubleClick(int x, int y) { if (!UiPointInRect(m_area, static_cast(x), static_cast(y))) { return false; } // ⚠️ **上层元素的交互绝不能穿透到下层**:实验性功能的浮动面板浮在网格之上(且绘制在 // 光标标签 / 图例之上),因此在它上面双击必须由它消费掉。旧实现在所有下层命中都不成 // 立时无条件 `FitAll()`,于是"在面板上双击任意位置"会让网格莫名其妙地自适应一次 // (用户 2026-10-15 报告)。面板自身双击没有额外语义,直接消费即可。 { int panelLane = -1; int panelKind = 0; if (TopPanelAtPoint(static_cast(x), static_cast(y), panelLane, panelKind)) { // 「自由标记」面板:双击某一固定点行 → 编辑它的时间(左侧是自身时刻、 // 右侧是相对前一固定点的间隔,对应行内两列的位置)。 if (static_cast(panelKind) == EdgePanelKind::MARK && m_tab != nullptr && panelLane >= 0 && panelLane < static_cast(m_tab->Lanes().size())) { const EdgePanelLayout layout = EdgePanelLayoutFor(panelLane, EdgePanelKind::MARK); Lane& tabLane = m_tab->Lanes()[static_cast(panelLane)]; for (const EdgePanelLayout::MarkerRow& row : layout.markerRows) { if (row.rect.Width <= 0.0f || !UiPointInRect(row.rect, static_cast(x), static_cast(y)) || row.itemIndex >= tabLane.freeMarkerItems.size()) { continue; } const Lane::FreeMarkerItem& item = tabLane.freeMarkerItems[row.itemIndex]; // 分隔点:时间列左边界(`markerTimeRight` 减去最宽文本的近似值)。 // 右侧(Δt 列)→ 编辑间隔;左侧 → 编辑自身时刻。 const float splitX = layout.markerTimeRight - 40.0f * m_scale; m_editMarkerIndex = row.itemIndex; if (static_cast(x) >= splitX) { // 间隔编辑:初始值 = 当前间隔(秒)。最左边的点没有前一项 ⇒ 不允许编辑。 std::vector 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(layout.markerTimeRight, row.rect.Y, std::max(10.0f, layout.markerDeltaRight - layout.markerTimeRight), row.rect.Height); BeginEdit(PlotEditTarget::FREE_MARKER_DELTA, panelLane, FormatValue(item.time - previousTime), anchor); m_editFieldRect = row.rect; return true; } BeginEdit(PlotEditTarget::FREE_MARKER_TIME, panelLane, FormatValue(item.time), row.rect); 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(x); const float fy = static_cast(y); if (!UiPointInRect(m_area, fx, fy)) { return false; } // 实验性功能的浮动面板(含其上的复选框 / 滑块 / 复位按钮 / 关闭 ✕ / 标题栏): // 只有「按下」语义 ⇒ 快速连点必须当作一次次按下(否则连点丢失)。 { int panelLane = -1; int panelKind = 0; if (TopPanelAtPoint(fx, fy, panelLane, panelKind)) { return true; } } // 右下角「回到最上方」浮动按钮:按下只登记、抬起才生效 ⇒ 只有单击语义。 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(-1)) { m_freeMarkerHoverLane = -1; m_freeMarkerHoverItem = static_cast(-1); changed = true; } if (m_freeMarkerPanelHoverLane != -1) { m_freeMarkerPanelHoverLane = -1; m_freeMarkerPanelHoverItem = static_cast(-1); m_freeMarkerPanelHoverSignal = 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(x); const float fy = static_cast(y); for (std::size_t index = 0; index < m_lanes.size(); ++index) { const gd::RectF badge = LaneBadgeRect(static_cast(index)); if (badge.Width > 0.0f && UiPointInRect(badge, fx, fy)) { return static_cast(index); } } return -1; } float PlotView::LaneDragOffset(int laneIndex) const { if (m_dragMode != PlotDragMode::LANE_REORDER || m_laneDragFrom < 0 || laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) { return 0.0f; } const float laneHeight = m_lanes[static_cast(m_laneDragFrom)].rect.Height; if (laneIndex == m_laneDragFrom) { // 被拖拽的网格自由跟随鼠标(整体绘制区域跟着走)。 const float baseTop = m_lanes[static_cast(laneIndex)].rect.Y; return static_cast(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(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(m_laneDragFrom)].rect.Y; const float shift = static_cast(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(std::ceil(upRatio)) - 1; int down = static_cast(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(x); const float fy = static_cast(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 visible; for (int index = 0; index < static_cast(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(kind); visible.push_back(ref); } } } // 保留已有层序(剔除已不可见的),并把**新出现**的面板追加到队尾(= 最上层), // 这样新打开的面板不会被已有面板盖住。 std::vector 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(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(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(index); } } return -1; } bool PlotView::IsOnEdgeCursorLine(int x, int y, int& laneIndex) const { laneIndex = -1; if (m_tab == nullptr) { return false; } const std::vector& tabLanes = m_tab->Lanes(); const float fx = static_cast(x); const float fy = static_cast(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(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