Files
Just-DataInspector/Components/PlotView/Src/PlotViewInteraction.cpp
T
DeepSeek V4.1 Flash 75f0fafd41 .mat 改为流式解码(内存映射 + 逐 chunk 填充);MF4 支持 ##DL/##DZ/##HL;光标数据点被时间面板遮住时改三角
1) .mat(BIG_MAT.mat 1.29GB / 5 信号 x 16,142,471 采样)
   旧链路同时驻留三份内存:整文件 fread 进 vector、整个数据集解压成字节数组、再 memcpy 成
   double;外加 streams 缓存与每条信号各一份时间列拷贝。实测私有内存 +1233.9MB、117s。
   现改为:
   - MatHdf5::Open 用 CreateFileW + CreateFileMappingW + MapViewOfFile(MappedBytes 只读视图,
     解析代码零改动;虚拟地址不等于物理内存,未访问页不驻留);
   - 新增 ReadDatasetInfo(只读元信息)+ StreamDataset(ByteSink)(连续布局 4MB 切片、
     分块布局逐 chunk inflate)+ ReadDoubleStream(SDI #data# 字节流按 8 字节一组直接写 double,
     7 字节尾巴处理非 8 对齐 chunk);ReadNumbers 改为流式转换;
   - MatLoader 先收集信号清单,再按(分组,时间流)合并为一个 TableBlock:时间列按内容去重、
     数据列直接 move;进度改成 0-0.02 映射 / 0.02-0.98 逐流解码 / 1.0。
   结果:117.3s -> 12.5s,私有内存 +1233.9MB -> +740.4MB(最终列数据 738.9MB)。
   关键性能修正:SDI 的 #data# 是 dims=[N]、chunk=[N,1],归一化后每行只有 1 字节;旧的逐行回调
   (且每行分配 coordinate 向量)只有 11MB/s。把目标连续的行合并成一次回调后约 110MB/s。
   顺带把 inflate 的 Huffman 解码改成 9 位快查表,并抽到新的 Deflate.hpp/.cpp 供 MF4 复用。

2) MDF4 数据块
   Problem-MF4.mf4 的第二个数据组把记录写在 ##DL(数据列表,两个 ##DT:131040+504 字节)
   里,旧实现只认 ##DT,整组被跳过,于是 8 个信号只读出 4 个。
   新增 CollectDataSegments(递归展开 ##DL / ##HL,叶子识别 ##DT / ##DV / ##DZ)与
   ReadRecordSegments(跨段拼接、压缩段先 inflate、非压缩段仍按 4MB 分块读),
   压缩段的记录长度改用 max(cg_record_id_len*8, 各通道 bitOffset+bitCount) 推算(载荷是压缩长度)。
   实测 8 个信号:gVP_WindowLearnSt[0] 94 行、gVR_WinOpenReq[0] 9396 行,时间列单调。
   ##SD(已排序数据)仍未实现,这类数据组会被跳过。

   另按要求只做检查、不改代码:该文件中 gVP_WindowLearnSt[0] 与 gVR_WinOpenReq[0] 都存在
   值枚举文本(##CC,cc_type=7;links[4+i] 对应 cc_val[i]),清单已写入 Documents/agent_mem.md。

3) 光标数据点标记
   光标的时间读数与差值胶囊绘制在绘图区底部、且在取值标注之后绘制,靠近底部的数据点圆点
   会被完全盖住。新增 MarkerCoveredByCursorLabel(圆心落入面板或重叠面积达到半个圆)
   与 CoverHiddenMarker(改成向下的三角、锚点放到面板上沿上方),在原始信号与派生曲线胶囊
   两处调用,语义与“数据点在视界下方”一致。

验证(全部离屏:SW_HIDE 窗口 + 只注入合成消息 + BELOW_NORMAL,不占用鼠标键盘屏幕):
- 新增 .preflight/round4check 34/34;A/B:临时禁用 ##DL 支持 => 2 项如实失败;
  临时禁用 CoverHiddenMarker => 3 项如实失败(含像素断言为 0);
- 回归:.preflight/round3check 31/31、editcheck 20/20、check 137/137;
- make release / make debug 零警告;Build/Release/Target.exe 785,920 字节(< 800KB)。
2026-10-19 20:13:17 +08:00

2912 lines
99 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// PlotView 实现分片 —— 本文件只包含 PlotView 的**成员函数定义**(纯代码搬移,行为与拆分前完全一致)。
//
// 共用的常量与工具函数在 PlotViewInternal.hpp(命名空间 `pvdetail`,全部 inline)。
// 四个分片的职责划分(按功能):
// core 视图核心:构造/析构、上下文绑定、布局、坐标映射、视界与平移、网格结构与归属、
// 选区/网格划分、拖动取消
// paint 渲染与像素缓存:全部绘制(Paint*/Render*)、场景缓存、装饰缓存、每网格图层、
// 宽基底与滚动补条、并行图层重建
// interaction 交互与编辑:命中测试、鼠标与键盘处理、光标形状、内联输入框、浮动面板层序与命中
// features 视图特性与派生数据:光标状态与读数、提示浮层、缩放模式、自适应、光标吸附、
// 单点信号、派生曲线线程池、边沿统计、实验性功能(均值滤波 / 弱峰调整)参数
#include "PlotViewInternal.hpp"
namespace jdi
{
const SignalRef& PlotView::SelectedRef() const
{
static const SignalRef fallback;
return (m_dataSet != nullptr) ? m_dataSet->Selected() : fallback;
}
void PlotView::SelectSignal(const SignalRef& ref)
{
if (m_dataSetWritable != nullptr)
{
m_dataSetWritable->SetSelected(ref);
}
// 联动(用户 2026-10-15 要求):绘图区内把某条信号选为当前目标时,
// 请求宿主把信号列表**平滑滚动**到它,保证在长列表里也能看到它。
if (ref.IsValid())
{
m_pendingPanelScroll = ref;
m_pendingPanelScrollFlag = true;
}
}
void PlotView::SetExternalHoverSignal(const SignalRef& ref)
{
if (m_externalHoverSignal == ref)
{
return;
}
m_externalHoverSignal = ref;
}
int PlotView::EffectiveHoverLane() const
{
if (m_hoverLane >= 0)
{
return m_hoverLane;
}
// 外部悬停信号(信号列表 / 样式区「目标」行):只在该信号确实绘制到某个网格时生效。
if (m_tab != nullptr && m_externalHoverSignal.IsValid())
{
return m_tab->LaneOfSignal(m_externalHoverSignal);
}
return -1;
}
int PlotView::LaneAtY(int y) const
{
const float fy = static_cast<float>(y);
for (std::size_t index = 0; index < m_lanes.size(); ++index)
{
const gd::RectF& rect = m_lanes[index].rect;
if (fy >= rect.Y && fy <= rect.Y + rect.Height)
{
return static_cast<int>(index);
}
}
return -1;
}
bool PlotView::CancelDrag(bool showHint)
{
if (m_dragMode == PlotDragMode::NONE)
{
return false;
}
// 放弃当前拖拽:清空拖拽状态与尚未生效的缩放选区,不改动任何视图范围。
// 调用方仍持有鼠标捕获,松开左键时会走 OnLButtonUp,
// 此时拖拽模式已是 NONE,选区不会被应用到时间轴/幅值轴上。
const bool wasRubber = (m_dragMode == PlotDragMode::RUBBER_SELECT);
m_dragMode = PlotDragMode::NONE;
m_rubberAxis = PlotRubberAxis::UNDECIDED;
m_dragLane = -1;
m_dragMoved = false;
m_rubberRect = gd::RectF();
m_dragSignal = SignalRef();
m_dragTargetLane = -1;
if (showHint && wasRubber)
{
SetHint(L"已放弃当前选区(右键放弃选区,Esc 退出选区放大)");
}
return true;
}
bool PlotView::ExitZoomModes()
{
if (m_tab == nullptr)
{
return false;
}
// 退出缩放模式:工具栏的「选区放大」按钮会在 Application::Render 中同步为未选中态。
if (Tab().ZoomMode() == PlotZoomMode::NONE)
{
return false;
}
Tab().ZoomMode() = PlotZoomMode::NONE;
SetHint(std::wstring());
return true;
}
bool PlotView::IsOnCursorLine(int x, int& which) const
{
which = 0;
if (!Tab().CursorEnabled() || m_plotRect.Width <= 0.0f)
{
return false;
}
const float tolerance = 6.0f * m_scale;
const float fx = static_cast<float>(x);
// 双光标重叠时优先命中光标 1,避免出现“抓不起来第二条”的情况。
// 使用"显示横坐标":滞留在可视区外的光标被钉在边缘,也应该可以被抓住拖回来。
if (std::fabs(fx - CursorDisplayX(1)) <= tolerance)
{
which = 1;
return true;
}
if (Tab().CursorMode() == PlotCursorMode::DOUBLE && std::fabs(fx - CursorDisplayX(2)) <= tolerance)
{
which = 2;
return true;
}
return false;
}
bool PlotView::IsOnCursorTimeLabel(int x, int y, int& which) const
{
which = 0;
if (!Tab().CursorEnabled())
{
return false;
}
const float fx = static_cast<float>(x);
const float fy = static_cast<float>(y);
// 双光标重叠时优先命中光标 1(与光标线的命中顺序一致)。
const int count = (Tab().CursorMode() == PlotCursorMode::DOUBLE) ? 2 : 1;
for (int index = 1; index <= count; ++index)
{
const gd::RectF rect = CursorLabelRect(index);
if (rect.Width > 0.0f && UiPointInRect(rect, fx, fy))
{
which = index;
return true;
}
}
return false;
}
bool PlotView::IsOnCursorSpanLabel(int x, int y) const
{
if (Tab().CursorMode() != PlotCursorMode::DOUBLE || m_cursorSpanRect.Width <= 0.0f)
{
return false;
}
return UiPointInRect(m_cursorSpanRect, static_cast<float>(x), static_cast<float>(y));
}
int PlotView::LaneIndexAt(int x, int y) const
{
if (!UiPointInRect(m_plotRect, static_cast<float>(x), static_cast<float>(y)))
{
return -1;
}
return LaneAtY(y);
}
SignalRef PlotView::HitTestSignal(int x, int y) const
{
SignalRef empty;
if (m_tab == nullptr || m_dataSet == nullptr || !HasDrawableContent())
{
return empty;
}
if (!UiPointInRect(m_plotRect, static_cast<float>(x), static_cast<float>(y)))
{
return empty;
}
const int laneIndex = LaneAtY(y);
if (laneIndex < 0 || laneIndex >= static_cast<int>(m_lanes.size()))
{
return empty;
}
const LaneVisual& lane = m_lanes[static_cast<std::size_t>(laneIndex)];
const float tolerance = kSignalHitTolerance * m_scale;
const float fx = static_cast<float>(x);
const float fy = static_cast<float>(y);
SignalRef best;
float bestDistance = tolerance;
// 只为当前网格内的可见信号做精确的点到线段距离检测,避免跨网格误命中。
for (const SignalRef& ref : m_tab->Lanes()[static_cast<std::size_t>(laneIndex)].signals)
{
const ResolvedSignal resolved = ResolveSignal(ref);
if (resolved.data == nullptr || resolved.style == nullptr || !resolved.visible)
{
continue;
}
// ⚠️ **只有单个有效数据点**的信号没有“折线”可测距(`AcquirePolyline` 给不出两点的
// 线段),必须直接用数据点的屏幕坐标判定。否则用户永远无法悬停/拖动单点信号
//(用户 2026-10-15 报告:“光标瞄到数据点没有高亮、也不能拖拽换网格”)。
if (resolved.data->finiteCount == 1)
{
gd::PointF dot;
if (!SinglePointScreenPos(resolved, lane, dot))
{
continue;
}
const float dx = dot.X - fx;
const float dy = dot.Y - fy;
const float distance = std::sqrt(dx * dx + dy * dy);
// 容差取“常规命中容差”与“该点绘制半径”的较大者:点本身很粗时更好点中。
const float dotRadius = SinglePointDotRadius(resolved);
const float limit = std::max(tolerance, dotRadius);
if (distance <= limit && distance <= bestDistance + dotRadius)
{
bestDistance = distance;
best = ref;
}
continue;
}
// 复用绘制阶段的折线顶点:悬停检测通常与上一帧几何完全相同,
// 不再为“鼠标移动但命中结果未变”重复扫描全量采样点。
const PolylineCacheEntry* cached = AcquirePolyline(ref, resolved, lane);
if (cached == nullptr)
{
continue;
}
const std::vector<gd::PointF>& points = cached->points;
const std::vector<std::size_t>& breaks = cached->breaks;
if (points.size() < 2)
{
continue;
}
// 横向先行裁剪:点到线段的距离必然不小于横向距离本身,
// 因此只有横向落在容差范围内的顶点才可能命中。
// 时间单调时折线顶点按 x 递增,可二分定位后只扫描这一小段;
// 否则每次鼠标移动都要遍历上万个顶点(悬停卡顿的来源之一)。
std::size_t windowFirst = 0;
std::size_t windowLast = points.size();
if (resolved.axis->monotonic)
{
const auto byX = [](const gd::PointF& point, float limit) { return point.X < limit; };
windowFirst = static_cast<std::size_t>(
std::lower_bound(points.begin(), points.end(), fx - tolerance, byX) - points.begin());
windowLast = static_cast<std::size_t>(
std::upper_bound(points.begin(), points.end(), fx + tolerance,
[](float limit, const gd::PointF& point) { return limit < point.X; }) -
points.begin());
}
// 散点模式下把采样点本身也视为可命中目标。
// ⚠️ 窗口两侧各多含一个顶点:折线是**连续**的,鼠标落在两顶点之间时
// 两个端点都可能落在容差窗口之外(深放大 / 点线模式下相邻点相距几十像素),
// 旧实现因此完全扫不到这段连线(用户 2026-10-17 报告:只有数据点附近能高亮)。
if (resolved.style->mode == DrawMode::SCATTER)
{
const std::size_t scatterBegin = (windowFirst > 0) ? (windowFirst - 1) : 0;
const std::size_t scatterEnd = std::min(points.size(), windowLast + 1);
for (std::size_t index = scatterBegin; index < scatterEnd; ++index)
{
const gd::PointF& point = points[index];
const float dx = point.X - fx;
const float dy = point.Y - fy;
const float distance = std::sqrt(dx * dx + dy * dy);
if (distance <= bestDistance)
{
bestDistance = distance;
best = ref;
}
}
continue;
}
for (std::size_t k = 0; k < breaks.size(); ++k)
{
const std::size_t start = breaks[k];
const std::size_t stop = (k + 1 < breaks.size()) ? breaks[k + 1] : points.size();
const std::size_t scanBegin = std::max(start + 1, windowFirst);
// ⚠️ `windowLast + 1`:见上方说明 —— 需要包含"左端点在窗口内、右端点在窗口外"
// 以及“左端点在窗口外、右端点在窗口内”的跨窗口线段(正是鼠标所在的那一段)。
const std::size_t scanEnd = std::min(stop, windowLast + 1);
for (std::size_t index = scanBegin; index < scanEnd; ++index)
{
const gd::PointF& a = points[index - 1];
const gd::PointF& b = points[index];
const float vx = b.X - a.X;
const float vy = b.Y - a.Y;
const float wx = fx - a.X;
const float wy = fy - a.Y;
const float lengthSquared = vx * vx + vy * vy;
float t = 0.0f;
if (lengthSquared > 1.0e-6f)
{
t = (wx * vx + wy * vy) / lengthSquared;
t = std::max(0.0f, std::min(1.0f, t));
}
const float dx = wx - t * vx;
const float dy = wy - t * vy;
const float distance = std::sqrt(dx * dx + dy * dy);
if (distance < bestDistance)
{
bestDistance = distance;
best = ref;
}
}
}
}
return best;
}
gd::RectF PlotView::EditFieldRect() const
{
if (m_editTarget == PlotEditTarget::NONE || m_fonts == nullptr || m_fonts->monoSmall == nullptr)
{
return gd::RectF();
}
const float height = 22.0f * m_scale;
float width = std::max(90.0f * m_scale,
UiMeasureTextWidth(m_fonts->monoSmall, m_edit.Text()) + 24.0f * m_scale);
if (m_editTarget == PlotEditTarget::CURSOR_TIME)
{
// 与显示态标签取相同宽度与位置(底部),避免进入编辑时框体跳动。
const int which = (m_editCursor == 2) ? 2 : 1;
const gd::RectF displayRect = CursorLabelRect(which);
width = std::max(width, displayRect.Width);
float left = CursorX(which) - width * 0.5f;
left = std::max(m_plotRect.X, std::min(left, m_plotRect.X + m_plotRect.Width - width));
return gd::RectF(left, m_plotRect.Y + m_plotRect.Height - height - 2.0f * m_scale, width,
height);
}
if (m_editTarget == PlotEditTarget::CURSOR_SPAN)
{
// 差值输入框落在差值标签处(时间轴上方或时间轴内部)。
bool insideAxis = false;
const gd::RectF displayRect = CursorSpanRect(insideAxis);
if (displayRect.Width <= 0.0f)
{
return gd::RectF();
}
width = std::max(width, displayRect.Width);
float left = displayRect.X + displayRect.Width * 0.5f - width * 0.5f;
left = std::max(m_plotRect.X, std::min(left, m_plotRect.X + m_plotRect.Width - width));
return gd::RectF(left, displayRect.Y, width, height);
}
if (m_editTarget == PlotEditTarget::SIGNAL_NAME)
{
// 图例改名:输入框直接落在被双击的图例条目位置上。
if (m_editFieldRect.Width <= 0.0f)
{
return gd::RectF();
}
const float nameWidth = std::max(width, m_editFieldRect.Width);
return gd::RectF(m_editFieldRect.X, m_editFieldRect.Y, nameWidth, height);
}
if (m_editTarget == PlotEditTarget::EDGE_CENTER_POINTS ||
m_editTarget == PlotEditTarget::EDGE_CENTER_ITERATIONS ||
m_editTarget == PlotEditTarget::MEAN_FILTER_POINTS ||
m_editTarget == PlotEditTarget::MEAN_FILTER_ITERATIONS ||
m_editTarget == PlotEditTarget::PEAK_ITERATIONS_MAX)
{
// 「均值点数 X」/「迭代次数 N」(均值滤波、中心轴)与「迭代次数 N 上限」(弱峰调整):
// 输入框都落在对应的字段/滑块行矩形上。
if (m_editFieldRect.Width <= 0.0f)
{
return gd::RectF();
}
const float fieldHeight = std::max(height, m_editFieldRect.Height - 4.0f * m_scale);
return gd::RectF(m_editFieldRect.X, m_editFieldRect.Y + 2.0f * m_scale,
m_editFieldRect.Width, fieldHeight);
}
// 网格 Y 轴上下限输入框:优先落在被点击的菜单条目位置,保证"就地编辑"的观感。
if (m_editFieldRect.Width > 0.0f && m_editFieldRect.Height > 0.0f)
{ float left = m_editFieldRect.X;
float top = m_editFieldRect.Y;
left = std::max(m_area.X + 2.0f * m_scale,
std::min(left, m_area.X + m_area.Width - width - 2.0f * m_scale));
top = std::max(m_area.Y + 2.0f * m_scale,
std::min(top, m_area.Y + m_area.Height - height - 2.0f * m_scale));
return gd::RectF(left, top, width, height);
}
if (m_editLane < 0 || m_editLane >= static_cast<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;
}
// 「弱峰调整」迭代次数滑块的**上限**:用户可直接输入一个正整数作为新的量程上限。
// 规则(用户明确要求):
// * 输入框只允许输入正整数(过滤策略为 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;
}
// 「均值点数 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;
}
return UiEditFieldMouseDown(m_fonts->uiSmall, 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->uiSmall, 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:
// 「弱峰调整」滑块拖拽 → 左右箭头。
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」编号徽标:悬停给出提示 + 手型光标;点击把该网格滚动到视界内。
// ⚠️ 必须排在标题栏之前(徽标位于标题栏矩形内部,否则会被判成“可拖拽”)。
if (layout.badge.Width > 0.0f &&
UiPointInRect(layout.badge, 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)))
{
return LoadCursorW(nullptr, IDC_SIZEALL);
}
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::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;
}
// 光标时间读数标签与双光标差值标签:**悬停(未按下)时不改变鼠标形状**。
// 两个标签都叠在光标线的正上方,因此必须先于"光标线"判定返回 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)
{
Tab().ViewMin() += delta;
Tab().ViewMax() += delta;
ClampTimeView();
}
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::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::COUNT:
default:
lane.edgePanel.x = left;
lane.edgePanel.y = top;
break;
}
m_dragMoved = true;
return true;
}
case PlotDragMode::NONE:
default:
break;
}
bool changed = false;
// 鼠标压在实验性功能面板上时不再触发信号悬停提示(面板本来就盖住波形),
// 但该面板所属的网格仍然要高亮(与鼠标直接停在网格上一致)。
// 按**层序**取最上层的面板(与点击命中保持一致,重叠时以最上层为准)。
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 ((kind == EdgePanelKind::MEAN || kind == EdgePanelKind::CENTER ||
kind == EdgePanelKind::PEAK) &&
m_tab != nullptr)
{
const bool meanMode = (kind == EdgePanelKind::MEAN);
const bool boostMode = (kind == EdgePanelKind::PEAK);
Lane& tabLane = m_tab->Lanes()[static_cast<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 (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 PlotZoomMode mode = Tab().ZoomMode();
if (mode == PlotZoomMode::SELECT)
{
// 选区放大:轴向由鼠标移动方向决定,因此按下时先记录网格并置为未定。
// 按下本身**不显示提示**(拖动开始后才显示"右键取消选区")。
m_dragLane = LaneAtY(y);
m_rubberAxis = PlotRubberAxis::UNDECIDED;
m_dragMode = PlotDragMode::RUBBER_SELECT;
return true;
}
// 常规模式下:先检测是否落在信号曲线上;只有命中提示时才允许拖拽该信号。
const SignalRef hit = HitTestSignal(x, y);
if (hit.IsValid())
{
m_dragSignal = hit;
m_dragTargetLane = Tab().LaneOfSignal(hit);
SelectSignal(hit);
m_dragMouseX = x;
m_dragMouseY = y;
m_dragMode = PlotDragMode::MOVE_SIGNAL;
return true;
}
m_dragLane = LaneAtY(y);
m_dragMode = PlotDragMode::PAN;
return true;
}
bool PlotView::OnMButtonDown(int x, int y)
{
if (!HasDrawableContent())
{
return false;
}
// 中键:无论当前是否处于选区放大模式,都直接进入自由平移
// (不改变选区放大模式本身,松手后仍可继续框选)。
m_dragStartX = x;
m_dragStartY = y;
m_dragLastX = x;
m_dragLastY = y;
m_dragMoved = false;
m_rubberRect = gd::RectF();
m_rubberAxis = PlotRubberAxis::UNDECIDED;
m_cursorGrabOffset = 0;
m_edgeDragLane = -1;
m_edgePanelDragLane = -1;
m_edgePanelDragKind = EdgePanelKind::COUNT;
SetHint(std::wstring());
m_dragLane = LaneAtY(y);
m_dragMode = PlotDragMode::PAN;
return true;
}
bool PlotView::OnMButtonUp()
{
const bool changed = (m_dragMode == PlotDragMode::PAN);
if (changed)
{
m_dragMode = PlotDragMode::NONE;
m_dragLane = -1;
m_dragMoved = false;
ReleaseHint();
}
return changed;
}
bool PlotView::OnLButtonUp(int x, int y)
{
bool changed = (m_dragMode != PlotDragMode::NONE);
// 右下角「回到最上方」按钮:按下后仍在按钮上抬起 ⇒ 平滑滚回最上方。
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::COUNT:
default:
SetLaneEdgeCount(lane, false);
break;
}
changed = true;
}
}
}
switch (m_dragMode)
{
case PlotDragMode::RUBBER_SELECT:
{
// 按拖拽方向应用对应的缩放(横向 → 时间轴;纵向 → 当前网格幅值轴)。
if (m_rubberAxis == PlotRubberAxis::TIME)
{
if (m_dragMoved &&
ApplyTimeSelection(m_rubberRect.X, m_rubberRect.X + m_rubberRect.Width))
{
changed = true;
}
}
else if (m_rubberAxis == PlotRubberAxis::VALUE)
{
if (m_dragMoved &&
ApplyValueSelection(m_dragLane, m_rubberRect.Y, m_rubberRect.Y + m_rubberRect.Height))
{
changed = true;
}
}
break;
}
case PlotDragMode::CURSOR_MOVE:
m_cursorHover = true;
break;
case PlotDragMode::CURSOR_SPAN_MOVE:
break;
case PlotDragMode::LANE_REORDER:
// 网格次序拖拽结束:真正重排并重编号(GX 徽标随之同步)。
FinishLaneReorder();
changed = true;
break;
case PlotDragMode::MOVE_SIGNAL:
{
const int target = LaneAtY(y);
const int source = (m_tab != nullptr) ? m_tab->LaneOfSignal(m_dragSignal) : -1;
if (m_dataSet != nullptr && m_tab != nullptr && m_dragSignal.IsValid() && target >= 0 &&
target != source && m_dragMoved)
{
m_tab->MoveSignalToLane(*m_dataSet, m_dragSignal, target);
changed = true;
}
m_dragSignal = SignalRef();
m_dragTargetLane = -1;
break;
}
default:
break;
}
m_dragMode = PlotDragMode::NONE;
m_rubberAxis = PlotRubberAxis::UNDECIDED;
m_dragLane = -1;
m_dragMoved = false;
m_rubberRect = gd::RectF();
m_cursorGrabOffset = 0;
m_edgeDragLane = -1;
m_edgePanelDragLane = -1;
m_edgePanelDragKind = EdgePanelKind::COUNT;
// 弱峰滑块拖拽结束:清掉目标(避免留下上一轮的信号引用)。
m_peakDragLane = -1;
m_peakDragRef = SignalRef();
// 松开左键:粘滞提示结束计时,开始"停留后淡出"。
ReleaseHint();
return changed;
}
bool PlotView::OnMouseWheel(int delta, bool shift)
{
if (!HasDrawableContent() || delta == 0)
{
return false;
}
const double notches = static_cast<double>(delta) / 120.0;
// 滚轮不再承担缩放:默认纵向滚动网格,按住 Shift 时水平平移时间轴。
if (shift)
{
const double span = Tab().ViewMax() - Tab().ViewMin();
const double sweep = -notches * span * 0.15;
Tab().ViewMin() += sweep;
Tab().ViewMax() += sweep;
ClampTimeView();
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))
{
return true;
}
}
// 双光标差值标签:双击进入差值编辑(输入值的量级始终为秒)。
// 注意:差值标签在"两光标过近"时会落到主时间轴内部,因此必须先于区域判定。
if (IsOnCursorSpanLabel(x, y))
{
return BeginEdit(PlotEditTarget::CURSOR_SPAN, -1, FormatDurationSeconds(CursorSpan()));
}
if (!UiPointInRect(m_area, static_cast<float>(x), static_cast<float>(y)))
{
return false;
}
// 命中光标时间读数标签:编辑该条光标的时间(优先于光标线,避免两条过近时选错)。
{
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))
{
return true;
}
}
// 右下角「回到最上方」浮动按钮:按下只登记、抬起才生效 ⇒ 只有单击语义。
if (m_backToTopAlpha > 0.01f)
{
const gd::RectF backToTop = BackToTopRect();
if (backToTop.Width > 0.0f && UiPointInRect(backToTop, fx, fy))
{
return true;
}
}
// 绘图区右侧滚动条:拖动语义。
if (m_scrollBarVisible && UiPointInRect(m_scrollBarRect, fx, fy))
{
return true;
}
// 网格 GX 徽标:按下即(抬起时)弹出「所属网格」菜单,双击没有额外含义。
if (LaneBadgeAtPoint(x, y) >= 0)
{
return true;
}
return false;
}
bool PlotView::OnMouseLeave()
{
bool changed = false;
if (m_hoverLane != -1)
{
m_hoverLane = -1;
changed = true;
}
if (m_hoverBadge != -1)
{
m_hoverBadge = -1;
changed = true;
}
if (m_hoverPanelBadgeLane != -1 || m_hoverPanelBadgeKind != -1)
{
// 浮动面板的「GX」徽标悬停提示必须随鼠标离开清除(否则提示气泡残留)。
m_hoverPanelBadgeLane = -1;
m_hoverPanelBadgeKind = -1;
changed = true;
}
if (m_hoverSignal.IsValid())
{
m_hoverSignal = SignalRef();
changed = true;
}
// 图例悬停半高亮同样随鼠标离开而消失(否则右键弹菜单/移出绘图区后残留在原地)。
if (m_hoverLegend.IsValid())
{
m_hoverLegend = SignalRef();
changed = true;
}
if (m_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