① 主题自适应(`Theme.cpp`):新增 `SrgbLuminance` / `ContrastRatio` / `EnsureContrast` /
`MixToLuminance` / `ThemeLaneBackground`;四套主题的结构色(gridWeak 1.30 / gridStrong 1.65 /
axisLine 2.10 / borderStrong 1.70)按底色自动校正,Solarised Light 逐位不变;网格叠底改用
`ThemeLaneBackground()`(4 处),深色主题下网格层与绘图区底色可辨。
② 派生预览曲线(`HighContrastDerived`)重写为**连续**方案:方向沿信号所在侧远离底色,
目标亮度 = max(与底色对比度 3.2, 信号亮度 ± 0.18),再按亮度平滑混色;调用点传入真实网格底色
(旧实现默认写死 Solarised Light 底色 ⇒ 深色主题永远选到更暗的那一侧、几乎看不见)。
③ 信号样式区「线宽 / 透明度」两行右侧新增**复位按钮**(`UiPaintResetIcon` 成为唯一实现,
菜单/弱峰面板/样式区共用同一套笔画);默认线宽统一到 `DefaultSignalLineWidth(finiteCount)`
(单点信号取 10.0)。按钮右边界与内容列对齐,内容列过窄时整列不显示。
④ 「均值滤波」面板重做:信号色叠底标题栏 + 复选框 + 展开三角 + 「均值点数 / 迭代次数」两行滑块
(点击数值可改滑块上限、右侧复位)+ 内容区滚动;新增 `MeanFilterItem::{maxPoints,maxIterations,
collapsed}` 与 `Lane::meanFilterScroll`,缓存键同步补全。
⑤ 浮动面板命中像素缓存时只贴**面板圆角形状之内**的像素(`CreateRoundRectRgn` 裁剪)并按本帧重画投影
⇒ 中键移视界后面板周围不再出现一圈旧波形。
⑥ 显示布局边界:每个浮动面板**独占一个绘制层**(按 `m_panelOrder` 递增),绘制侧
`UiSetTextLayer` 在同一循环内同步推进 ⇒ 下层面板的边框/文字不再画到上层面板上。
⑦ 自由标记:采样候选距离改为**二维欧氏距离**(单点信号不再在整幅宽度内吸住光标),
纵向光标画在鼠标位置、圆点落在真实采样点(新增 `FreeMarkerProbe::pointX`);
`MinorCellSparse` 只对胜出信号算一次;折线缓存容量改为 `max(64, 信号数×2)` 且查找改为
单键哈希 ⇒ MF4 的 285 信号合并网格中 `ProbeFreeMarker` 平均 0.11ms。
验证:`.preflight` 探针重建(`panelcheck` 71/71、`markercheck` 13/13、`ringcheck` 6/6,合计 90 条,
LTO 目标文件上复跑同样全绿);`make release` / `make debug` 零警告;产物 815,104 B。
文档:`agent_mem.md` 第十六轮(含补丁脚本与编码/行尾踩坑)。
3951 lines
142 KiB
C++
3951 lines
142 KiB
C++
// 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)];
|
||
if (lane.FindMeanFilterItem(m_editMeanRef) == nullptr)
|
||
{
|
||
return true;
|
||
}
|
||
if (parsedOk)
|
||
{
|
||
const bool isPoints = (target == PlotEditTarget::MEAN_POINTS_MAX);
|
||
const int minimum = isPoints ? kMeanFilterPointsMin : kMeanFilterIterationsMin;
|
||
const int limit = isPoints ? kMeanFilterPointsMaxLimit : kMeanFilterIterationsMaxLimit;
|
||
const long long value = static_cast<long long>(std::llround(parsed));
|
||
if (value >= minimum && value <= limit)
|
||
{
|
||
Lane::MeanFilterItem& item = lane.EnsureMeanFilterItem(m_editMeanRef);
|
||
if (isPoints)
|
||
{
|
||
item.maxPoints = static_cast<int>(value);
|
||
item.points = std::max(minimum, std::min(item.maxPoints, item.points));
|
||
}
|
||
else
|
||
{
|
||
item.maxIterations = static_cast<int>(value);
|
||
item.iterations =
|
||
std::max(minimum, std::min(item.maxIterations, item.iterations));
|
||
}
|
||
m_meanCurveCache.clear();
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
|
||
// 「均值点数 X」/「迭代次数 N」:作用于被勾选信号的均值滤波曲线,夹在合理范围内。
|
||
// 「中心轴边沿计数」用**完全相同的参数与语义**,只是作用于自己的条目列表。
|
||
const bool centerTarget =
|
||
(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 次迭代等于不做滤波);均值点数可以为 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<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);
|
||
}
|
||
}
|
||
}
|
||
// 「弱峰调整」的逐信号滑块:轨道 → 左右箭头;迭代次数右侧数值 → 手型(可点击改上限)。
|
||
if (static_cast<EdgePanelKind>(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<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.rowIterTracks.size() &&
|
||
WeakPeakSliderHit(layout.rowIterTracks[rowIndex], m_scale,
|
||
static_cast<float>(x), static_cast<float>(y)))
|
||
{
|
||
return LoadCursorW(nullptr, IDC_SIZEWE);
|
||
}
|
||
if (rowIndex < layout.rowLevelTracks.size() &&
|
||
WeakPeakSliderHit(layout.rowLevelTracks[rowIndex], m_scale,
|
||
static_cast<float>(x), static_cast<float>(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<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::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<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)];
|
||
if (lane.FindMeanFilterItem(m_meanDragRef) == nullptr)
|
||
{
|
||
break;
|
||
}
|
||
Lane::MeanFilterItem& item = lane.EnsureMeanFilterItem(m_meanDragRef);
|
||
const bool pointsMode = (m_dragMode == PlotDragMode::MEAN_POINTS);
|
||
const EdgePanelLayout layout =
|
||
EdgePanelLayoutFor(m_meanDragLane, EdgePanelKind::MEAN);
|
||
int rowIndex = -1;
|
||
for (std::size_t candidate = 0; candidate < layout.rowRefs.size(); ++candidate)
|
||
{
|
||
if (layout.rowRefs[candidate] == m_meanDragRef)
|
||
{
|
||
rowIndex = static_cast<int>(candidate);
|
||
break;
|
||
}
|
||
}
|
||
if (rowIndex < 0)
|
||
{
|
||
break;
|
||
}
|
||
const std::vector<gd::RectF>& tracks =
|
||
pointsMode ? layout.rowLevelTracks : layout.rowIterTracks;
|
||
if (static_cast<std::size_t>(rowIndex) >= tracks.size() ||
|
||
tracks[static_cast<std::size_t>(rowIndex)].Width <= 0.0f)
|
||
{
|
||
break;
|
||
}
|
||
const double ratio =
|
||
UiSliderRatioFromX(tracks[static_cast<std::size_t>(rowIndex)], fx);
|
||
if (pointsMode)
|
||
{
|
||
const int maximum = std::max(
|
||
kMeanFilterPointsMin, std::min(kMeanFilterPointsMaxLimit, item.maxPoints));
|
||
item.points = std::max(
|
||
kMeanFilterPointsMin,
|
||
std::min(maximum,
|
||
kMeanFilterPointsMin +
|
||
static_cast<int>(std::lround(ratio * (maximum - kMeanFilterPointsMin)))));
|
||
}
|
||
else
|
||
{
|
||
const int maximum =
|
||
std::max(kMeanFilterIterationsMin,
|
||
std::min(kMeanFilterIterationsMaxLimit, item.maxIterations));
|
||
item.iterations = std::max(
|
||
kMeanFilterIterationsMin,
|
||
std::min(maximum,
|
||
kMeanFilterIterationsMin +
|
||
static_cast<int>(std::lround(
|
||
ratio * (maximum - kMeanFilterIterationsMin)))));
|
||
}
|
||
m_meanCurveCache.clear();
|
||
m_dragMoved = true;
|
||
return true;
|
||
}
|
||
case PlotDragMode::LANE_REORDER:
|
||
{
|
||
// 被拖拽的网格自由跟随鼠标;目标位置变化时其它网格实时让位(吸附)。
|
||
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, EdgePanelKind::MEAN);
|
||
const float travel = layout.scrollTrack.Height - layout.scrollThumb.Height;
|
||
const float maxScroll =
|
||
std::max(0.0f, layout.markerContentHeight - layout.content.Height);
|
||
if (layout.scrollTrack.Width <= 0.0f || travel <= 0.0f || maxScroll <= 0.0f)
|
||
{
|
||
break;
|
||
}
|
||
const float thumbTop =
|
||
std::max(layout.scrollTrack.Y,
|
||
std::min(static_cast<float>(y) - m_markerScrollGrabOffset,
|
||
layout.scrollTrack.Y + travel));
|
||
const float ratio = (thumbTop - layout.scrollTrack.Y) / travel;
|
||
if (SetLaneMeanFilterScroll(m_markerScrollDragLane, ratio * maxScroll))
|
||
{
|
||
m_dragMoved = true;
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
case PlotDragMode::NONE:
|
||
default:
|
||
break;
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
// 「弱峰调整」每行滑块右侧的**复位控件**:悬停时给出一次性提示(进入时设置、离开时清除)。
|
||
// 与「系数/偏移」二级面板标题栏的还原图标同一交互语义(悬停高亮 + 手型 + 提示)。
|
||
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) &&
|
||
m_tab != nullptr)
|
||
{
|
||
const bool meanMode = (kind == EdgePanelKind::MEAN);
|
||
const bool boostMode = (kind == EdgePanelKind::PEAK);
|
||
// 「均值滤波」:组头三角(展开 / 收起参数行)+ 两行参数滑块
|
||
//(轨道拖动 / 点击数值改上限 / 右侧复位按钮)。
|
||
// ⚠️ 所有命中都要求落在**内容区**之内:滚动到可视范围之外的行仍保留矩形,
|
||
// 若不加这条限制,它们会抢走底部「构建为新信号」按钮的点击。
|
||
if (meanMode)
|
||
{
|
||
Lane& meanLane = m_tab->Lanes()[static_cast<std::size_t>(index)];
|
||
// 内容区右侧的滚动条滑块:按下即进入拖动(与「自由标记」面板同一交互)。
|
||
if (layout.scrollThumb.Width > 0.0f &&
|
||
UiPointInRect(layout.scrollThumb, fx, fy))
|
||
{
|
||
m_dragMode = PlotDragMode::MEAN_SCROLL;
|
||
m_markerScrollDragLane = index;
|
||
m_markerScrollGrabOffset = fy - layout.scrollThumb.Y;
|
||
return true;
|
||
}
|
||
const bool insideContent =
|
||
UiPointInRect(layout.content, fx, fy);
|
||
for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex)
|
||
{
|
||
const SignalRef& ref = layout.rowRefs[rowIndex];
|
||
if (!insideContent)
|
||
{
|
||
break;
|
||
}
|
||
// 只允许命中**完全落在内容区里**的行(部分滚出的行其矩形会与内容区
|
||
// 边界重叠,放行会造出"看得见一半却能点"的幽灵热区)。
|
||
if (rowIndex < layout.rowHeaders.size())
|
||
{
|
||
const gd::RectF& headerRect = layout.rowHeaders[rowIndex];
|
||
if (headerRect.Y < layout.content.Y - 0.5f ||
|
||
headerRect.Y + headerRect.Height >
|
||
layout.content.Y + layout.content.Height + 0.5f)
|
||
{
|
||
continue;
|
||
}
|
||
}
|
||
const bool hasParams =
|
||
rowIndex < layout.rowLevelTracks.size() &&
|
||
layout.rowLevelTracks[rowIndex].Width > 0.0f &&
|
||
meanLane.FindMeanFilterItem(ref) != nullptr;
|
||
if (hasParams)
|
||
{
|
||
Lane::MeanFilterItem& item = meanLane.EnsureMeanFilterItem(ref);
|
||
// 复位:均值点数 → 默认值(同时把滑块上限恢复为默认上限)。
|
||
if (rowIndex < layout.rowLevelResets.size() &&
|
||
layout.rowLevelResets[rowIndex].Width > 0.0f &&
|
||
UiPointInRect(layout.rowLevelResets[rowIndex], fx, fy))
|
||
{
|
||
item.points = kMeanFilterPointsDefault;
|
||
item.maxPoints = kMeanFilterPointsDefaultMax;
|
||
m_meanCurveCache.clear();
|
||
m_dragMode = PlotDragMode::NONE;
|
||
return true;
|
||
}
|
||
// 复位:迭代次数 → 1(上限恢复为默认上限)。
|
||
if (rowIndex < layout.rowIterResets.size() &&
|
||
layout.rowIterResets[rowIndex].Width > 0.0f &&
|
||
UiPointInRect(layout.rowIterResets[rowIndex], fx, fy))
|
||
{
|
||
item.iterations = kMeanFilterIterationsMin;
|
||
item.maxIterations = kMeanFilterIterationsDefaultMax;
|
||
m_meanCurveCache.clear();
|
||
m_dragMode = PlotDragMode::NONE;
|
||
return true;
|
||
}
|
||
// 点击「均值点数」右侧数值 → 输入滑块上限。
|
||
if (rowIndex < layout.rowLevelValues.size() &&
|
||
layout.rowLevelValues[rowIndex].Width > 0.0f &&
|
||
UiPointInRect(layout.rowLevelValues[rowIndex], fx, fy))
|
||
{
|
||
m_editMeanRef = ref;
|
||
m_meanEditCurrentMax = item.maxPoints;
|
||
const gd::RectF& track = layout.rowLevelTracks[rowIndex];
|
||
const gd::RectF& value = layout.rowLevelValues[rowIndex];
|
||
const gd::RectF rowRect(track.X, value.Y,
|
||
value.X + value.Width - track.X,
|
||
value.Height);
|
||
BeginEdit(PlotEditTarget::MEAN_POINTS_MAX, index, L"", rowRect);
|
||
m_edit.Begin(L"", false, EditFilter::BYTE, 0u);
|
||
m_dragMode = PlotDragMode::NONE;
|
||
return true;
|
||
}
|
||
// 点击「迭代次数」右侧数值 → 输入滑块上限。
|
||
if (rowIndex < layout.rowIterValues.size() &&
|
||
layout.rowIterValues[rowIndex].Width > 0.0f &&
|
||
UiPointInRect(layout.rowIterValues[rowIndex], fx, fy))
|
||
{
|
||
m_editMeanRef = ref;
|
||
m_meanEditCurrentMax = item.maxIterations;
|
||
const gd::RectF& track = layout.rowIterTracks[rowIndex];
|
||
const gd::RectF& value = layout.rowIterValues[rowIndex];
|
||
const gd::RectF rowRect(track.X, value.Y,
|
||
value.X + value.Width - track.X,
|
||
value.Height);
|
||
BeginEdit(PlotEditTarget::MEAN_ITERATIONS_MAX, index, L"", rowRect);
|
||
m_edit.Begin(L"", false, EditFilter::BYTE, 0u);
|
||
m_dragMode = PlotDragMode::NONE;
|
||
return true;
|
||
}
|
||
// 拖动「均值点数」滑块。
|
||
if (rowIndex < layout.rowLevelTracks.size() &&
|
||
WeakPeakSliderHit(layout.rowLevelTracks[rowIndex], m_scale, fx, fy))
|
||
{
|
||
const int maximum = std::max(
|
||
kMeanFilterPointsMin,
|
||
std::min(kMeanFilterPointsMaxLimit, item.maxPoints));
|
||
m_dragMode = PlotDragMode::MEAN_POINTS;
|
||
m_meanDragLane = index;
|
||
m_meanDragRef = ref;
|
||
const double ratio = UiSliderRatioFromX(layout.rowLevelTracks[rowIndex], fx);
|
||
item.points = std::max(
|
||
kMeanFilterPointsMin,
|
||
std::min(maximum,
|
||
kMeanFilterPointsMin +
|
||
static_cast<int>(std::lround(
|
||
ratio *
|
||
(maximum - kMeanFilterPointsMin)))));
|
||
m_meanCurveCache.clear();
|
||
return true;
|
||
}
|
||
// 拖动「迭代次数」滑块。
|
||
if (rowIndex < layout.rowIterTracks.size() &&
|
||
WeakPeakSliderHit(layout.rowIterTracks[rowIndex], m_scale, fx, fy))
|
||
{
|
||
const int maximum = std::max(
|
||
kMeanFilterIterationsMin,
|
||
std::min(kMeanFilterIterationsMaxLimit, item.maxIterations));
|
||
m_dragMode = PlotDragMode::MEAN_ITERATIONS;
|
||
m_meanDragLane = index;
|
||
m_meanDragRef = ref;
|
||
const double ratio = UiSliderRatioFromX(layout.rowIterTracks[rowIndex], fx);
|
||
item.iterations = std::max(
|
||
kMeanFilterIterationsMin,
|
||
std::min(maximum,
|
||
kMeanFilterIterationsMin +
|
||
static_cast<int>(std::lround(
|
||
ratio *
|
||
(maximum - kMeanFilterIterationsMin)))));
|
||
m_meanCurveCache.clear();
|
||
return true;
|
||
}
|
||
}
|
||
// 组头左侧三角:切换参数行的收起 / 展开(只有勾选后才有三角)。
|
||
if (rowIndex < layout.rowTriangles.size() &&
|
||
layout.rowTriangles[rowIndex].Width > 0.0f &&
|
||
UiPointInRect(layout.rowTriangles[rowIndex], fx, fy))
|
||
{
|
||
Lane::MeanFilterItem& item = meanLane.EnsureMeanFilterItem(ref);
|
||
item.collapsed = !item.collapsed;
|
||
m_dragMode = PlotDragMode::NONE;
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
Lane& tabLane = m_tab->Lanes()[static_cast<std::size_t>(index)];
|
||
|
||
// 「弱峰调整」:先判两行滑块(数值文本 → 改上限;轨道 → 拖动)。
|
||
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<int>(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<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;
|
||
}
|
||
}
|
||
}
|
||
|
||
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<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;
|
||
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<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_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)
|
||
{
|
||
const EdgePanelLayout layout = EdgePanelLayoutFor(panelLane, EdgePanelKind::MEAN);
|
||
if (layout.content.Width > 0.0f &&
|
||
UiPointInRect(layout.content, static_cast<float>(m_mouseX),
|
||
static_cast<float>(m_mouseY)))
|
||
{
|
||
const float maxScroll =
|
||
std::max(0.0f, layout.markerContentHeight - layout.content.Height);
|
||
if (maxScroll > 0.0f)
|
||
{
|
||
const float current =
|
||
Tab().Lanes()[static_cast<std::size_t>(panelLane)].meanFilterScroll;
|
||
const float next = current - static_cast<float>(notches * 40.0 * m_scale);
|
||
SetLaneMeanFilterScroll(panelLane,
|
||
std::max(0.0f, std::min(next, maxScroll)));
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
if (hoveredKind == EdgePanelKind::MARK)
|
||
{
|
||
const EdgePanelLayout layout = EdgePanelLayoutFor(panelLane, EdgePanelKind::MARK);
|
||
if (layout.content.Width > 0.0f &&
|
||
UiPointInRect(layout.content, static_cast<float>(m_mouseX),
|
||
static_cast<float>(m_mouseY)))
|
||
{
|
||
const float maxScroll =
|
||
std::max(0.0f, layout.markerContentHeight - layout.content.Height);
|
||
if (maxScroll > 0.0f)
|
||
{
|
||
const float next = static_cast<float>(Tab().Lanes()[static_cast<std::size_t>(
|
||
panelLane)]
|
||
.freeMarkerScroll -
|
||
notches * 40.0 * m_scale);
|
||
SetLaneFreeMarkerScroll(panelLane,
|
||
std::max(0.0f, std::min(next, maxScroll)));
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 滚轮不再承担缩放:默认纵向滚动网格,按住 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_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
|
||
|