#include "PlotView.hpp" #include #include #include #include #include "Utf8Util.hpp" namespace jdi { namespace { const int kTimeTickTarget = 10; /**< 时间轴期望刻度数。 */ const int kYTickTarget = 6; /**< 幅值轴期望刻度数(网格较高时)。 */ const int kYTickTargetMin = 3; /**< 幅值轴最少刻度数(网格很矮时也要保证可读数)。 */ const int kYTickTargetMax = 8; /**< 幅值轴最多刻度数。 */ const float kYTickSpacing = 28.0f; /**< 幅值刻度之间的理想间距(逻辑像素)。 */ const float kYLabelMinGap = 15.0f; /**< 幅值标签的最小行距(逻辑像素)。 */ const int kMinorDivisions = 5; /**< 相邻主刻度之间的等分数(用于估读)。 */ const unsigned int kMinorLineAlpha = 0x9Au; /**< 辅助等分线的透明度(越高越清晰)。 */ const float kScrollBarThickness = 9.0f; /**< 滚动条宽度(逻辑像素)。 */ const float kLanePadding = 8.0f; /**< 网格内边距。 */ const size_t kMaxPolylinePoints = 20000; /**< 单条折线最大顶点数(上限保护)。 */ const size_t kPolylinePointsPerPixel = 2; /**< 每个像素列允许的折线顶点数(上/下包络两顶点)。 */ const size_t kMinPolylinePoints = 512; /**< 折线最少顶点数(窗口很窄时仍保留细节)。 */ const float kEnvelopeMinPixelSpan = 0.5f; /**< 包络纵向跨度小于该像素值时合并为单个顶点。 */ const float kCursorHandleReserve = 9.0f; /**< 光标顶部三角把手占用的高度(数据点贴边时需避让)。 */ const float kMarkerEdgeInset = 7.0f; /**< 数据点越界贴边时距网格可见边界的间距。 */ const float kMarkerInsideMargin = 2.0f; /**< 数据点在视界内时允许贴到边界的最小余量。 */ const float kMarkerTriangleArm = 5.0f; /**< 越界方向三角"半宽/半高"(垂直于朝向的半边长)。 */ /** 越界方向三角沿朝向的深度(与光标把手同比例)。 */ const float kMarkerTriangleDepth = kMarkerTriangleArm * 1.4f; /** * 越界方向三角与数值/时间标注之间的间隙(逻辑像素)。 * * 三角与胶囊之间**必须留出这段空白**:贴在一起时三角看起来像胶囊上多出来的 * 一个尖角(用户报告的"三角形与数值显示部分没有间隔")。 */ const float kMarkerTriangleGap = 4.0f; const size_t kMaxScatterPoints = 2500; /**< 单条散点最大绘制点数。 */ const size_t kPolylineCacheLimit = 64; /**< 折线顶点缓存条目上限。 */ const float kMinScatterRadius = 1.6f; /**< 散点最小半径(逻辑像素)。 */ const float kMaxScatterRadius = 12.0f; /**< 散点最大半径(逻辑像素)。 */ const float kScatterRadiusScale = 1.5f; /**< 散点半径相对线宽的增长系数。 */ const double kMinTimeSpan = 1.0e-12; /**< 允许的最小时间跨度。 */ const float kPlotSidePadding = 8.0f; /**< 绘图区左右留白(逻辑像素)。 */ const float kLaneDividerWidth = 2.0f; /**< 网格之间分界线的宽度。 */ const float kSignalHitTolerance = 6.0f; /**< 信号曲线命中容差(逻辑像素)。 */ const float kCursorSnapMinPixels = 6.0f; /**< 光标吸附采样点的最小采样间隔(逻辑像素)。 */ const float kLaneLabelPad = 6.0f; /**< 网格内侧标尺带的内边距。 */ const float kLegendHeight = 19.0f; /**< 图例条目高度(逻辑像素)。 */ const float kLegendPad = 5.0f; /**< 图例相对网格左上角的内缩。 */ const float kLegendSwatchSize = 8.0f; /**< 图例色块尺寸。 */ const float kLegendItemGap = 14.0f; /**< 图例条目之间的间距。 */ const float kLegendMaxTextWidth = 200.0f; /**< 图例条目的文本宽度上限(超出则省略)。 */ const float kLaneBadgeWidth = 30.0f; /**< 网格编号徽标宽度(与信号面板保持一致)。 */ const float kLaneBadgeHeight = 17.0f; /**< 网格编号徽标高度。 */ const float kLaneBadgeGap = 8.0f; /**< 编号徽标与图例条目之间的间距。 */ const float kEdgePanelWidth = 230.0f; /**< 边沿统计浮动面板宽度。 */ const float kEdgePanelTitleHeight = 26.0f; /**< 面板标题栏高度。 */ const float kEdgePanelRowHeight = 18.0f; /**< 面板内单行文本高度。 */ const float kEdgePanelPad = 7.0f; /**< 面板内边距。 */ const float kEdgePanelEditFieldWidth = 64.0f; /**< 数值框的最小宽度。 */ const float kEdgePanelButtonHeight = 30.0f; /**< 均值滤波面板底部按钮行高度。 */ const float kEdgePanelMeanRowHeight = 22.0f; /**< 逐条目面板中每条信号的名称行高度。 */ const float kEdgePanelMeanEditHeight = 24.0f; /**< 逐条目面板中每条信号的参数行高度。 */ const float kEdgePanelMeanRowGap = 2.0f; /**< 名称行与参数行之间的间距。 */ const float kEdgePanelMeanCheckSize = 15.0f; /**< 逐条目面板中条目复选框尺寸。 */ const float kWeakBoostSliderRowHeight = 26.0f; /**< 弱波增强面板中每个滑块行的高度。 */ const float kWeakBoostTrackHeight = 6.0f; /**< 弱波增强滑块的轨道高度。 */ const float kWeakBoostTrackMinWidth = 120.0f; /**< 弱波增强滑块轨道的最小宽度。 */ const int kWeakBoostIterationsMin = 1; /**< 迭代次数 N 的下限。 */ const int kWeakBoostIterationsMax = 10; /**< 迭代次数 N 的上限。 */ /** * @brief 派生曲线「区间窗口」缓存的保护带上限(采样点数)。 * * 窗口只覆盖可见区间 ± 保护带,可见区间内的取值与整条曲线一致(见 `CurveSpanFor`)。 * 上限避免在完全缩小时把保护带放大到整条信号(那就退化成重算整条曲线)。 */ const std::size_t kDerivedCurveGuardMax = 65536; /** 派生曲线缓存条目上限(超出时按「最旧优先」淘汰;它只是加速与内存控制手段)。 */ const std::size_t kCurveSpanCacheLimit = 32; /** 派生曲线缓存允许占用的总字节数(约 768MB):超出时按「最旧优先」淘汰。 * 取足够大是为了让「完全放大时 5 个网格各缓存一条整段曲线」可以同时驻留。 */ const std::size_t kCurveSpanCacheMaxBytes = 768ull * 1024ull * 1024ull; /** * @brief 派生(预览)曲线走「后台异步计算」的窗口采样数门槛。 * * 拖动滑块 / 移动视界 / 缩放每帧都会请求预览曲线。窗口不超过本门槛时同步重算足够快 * (弱波增强 ≈0.9ms/6.5 万采样),预览曲线能**实时跟随控件**;一旦窗口超过门槛 * (大时间跨度、整条信号),同步重算会阻塞交互(弱波增强整条信号单条 ≈368ms), * 此时改为交后台线程计算、本帧先用旧曲线顶替(双缓冲),保证控件反馈始终跟手。 */ const std::size_t kDerivedAsyncWindowThreshold = 65536; const wchar_t* const kWeakBoostLevelLabel = L"弱波增强等级"; /**< 等级滑块行左侧的说明文字。 */ const wchar_t* const kWeakBoostIterationsLabel = L"迭代次数 N"; /**< 迭代次数滑块行左侧的说明文字。 */ const wchar_t* const kWeakBoostTitle = L"弱波增强"; /**< 弱波增强面板标题。 */ const wchar_t* const kBuildSignalLabel = L"构建为新信号"; /**< 逐条目面板底部按钮文案(均值滤波 / 弱波增强共用)。 */ const std::size_t kEdgeCountCacheLimit = 48; /**< 边沿统计缓存条目上限(超出时淘汰最早的一条)。 */ /** * @brief 判断坐标是否命中「弱波增强」的等级滑块。 * * 轨道本身只有几个像素高,直接按轨道矩形命中很难抓住,因此把命中区在纵向上下各放宽 * (至少一个轨道高度),与样式区滑块的交互一致。 * * @param [IN] track 滑块轨道矩形(宽度为 0 表示当前没有滑块)。 * @param [IN] scale DPI 缩放比例。 * @param [IN] x 客户区横坐标。 * @param [IN] y 客户区纵坐标。 * @return 命中返回 true。 */ bool WeakBoostSliderHit(const gd::RectF& track, float scale, float x, float y) { if (track.Width <= 0.0f) { return false; } gd::RectF hit = track; const float pad = std::max(8.0f * scale, track.Height); hit.Y -= pad; hit.Height += pad * 2.0f; return UiPointInRect(hit, x, y); } /** * @brief 取折线(相邻采样点之间线性连接)在给定时刻的取值。 * * 包络是按像素列聚合的,因此某一段折线可能**跨越列边界**:跨越边界的那一小段 * 在两个列里都不属于"落在本列内的采样点",只看列内采样点就会把它整段丢掉。 * 该函数用于在列边界处取回这段折线的取值,使相邻两列的包络严丝合缝。 * * @param [IN] time 时间轴。 * @param [IN] values 采样值。 * @param [IN] count 可用采样点数量。 * @param [IN] firstIndex 第一个时间不小于查询时刻的采样下标(相交线段为 firstIndex-1 → firstIndex)。 * @param [IN] t 查询时刻。 * @param [OUT] out 输出取值。 * @return 折线覆盖该时刻且取到有效值返回 true。 */ bool PolylineValueAt(const std::vector& time, const std::vector& values, std::size_t count, std::size_t firstIndex, double t, double& out) { if (firstIndex == 0 || firstIndex >= count) { // 该时刻之前(或之后)没有采样点,折线并未延伸到这里。 return false; } const double t0 = time[firstIndex - 1]; const double t1 = time[firstIndex]; const double v0 = values[firstIndex - 1]; const double v1 = values[firstIndex]; if (!std::isfinite(t0) || !std::isfinite(t1) || !std::isfinite(v0) || !std::isfinite(v1) || !(t1 > t0)) { return false; } double ratio = (t - t0) / (t1 - t0); if (ratio < 0.0) { ratio = 0.0; } else if (ratio > 1.0) { ratio = 1.0; } out = v0 + (v1 - v0) * ratio; return true; } /** * @brief 统计相邻采样对 (k-1, k) 中穿越判定基准的边沿数量。 * * `deltaAt(k)` 返回第 k 个采样相对判定基准的偏差;返回非有限值(NaN)表示该采样 * 不参与判定,并**打断连续性**(与前一点之间不计边沿)——这正好覆盖 * 「采样缺失」与「采样落在统计时间区间之外」两种情况。 * * 统计的是采样对 `k ∈ [pairFirst, pairLast]`(闭区间),每对的「前一点」为 `k-1`, * 因此起点对的连续性由 `deltaAt(pairFirst - 1)` 决定(它无效时从下一个有效点重新起算)。 * * 该函数是增量统计的基本单位:只要把区间切成互不相交的若干段分别累加(或从总量中减去), * 结果与一次性扫描整段完全一致,因此拖动光标时可以只扫描两端差集。 * * @param [IN] pairFirst 起始采样对的下标 k(含),必须 ≥ 1。 * @param [IN] pairLast 结束采样对的下标 k(含);小于起点时不做任何事。 * @param [IN] deltaAt 偏差取值仿函数。 * @param [IN,OUT] rising 累加的上升沿数量。 * @param [IN,OUT] falling 累加的下降沿数量。 */ template void AccumulateEdgeCrossings(std::size_t pairFirst, std::size_t pairLast, DeltaAt deltaAt, std::size_t& rising, std::size_t& falling) { if (pairFirst == 0 || pairFirst > pairLast) { return; } double previousDelta = deltaAt(pairFirst - 1); bool hasPrevious = std::isfinite(previousDelta); for (std::size_t index = pairFirst; index <= pairLast; ++index) { const double current = deltaAt(index); if (!std::isfinite(current)) { hasPrevious = false; continue; } if (hasPrevious) { if (previousDelta < 0.0 && current >= 0.0) { ++rising; } else if (previousDelta > 0.0 && current <= 0.0) { ++falling; } } previousDelta = current; hasPrevious = true; } } /** * @brief 计算统计区间对应的采样下标区间 [begin, end)。 * * 时间轴单调时用二分定位,并在区间左侧多取一个采样点, * 使跨越区间边界的边沿也能被计入。 * * @param [IN] file 所属数据分组。 * @param [IN] count 采样点数量。 * @param [IN] hasRange 是否限定时间区间。 * @param [IN] rangeMin 区间左端点时间。 * @param [IN] rangeMax 区间右端点时间。 * @param [OUT] begin 输出区间起点(含)。 * @param [OUT] end 输出区间终点(不含)。 * @return 计数是否完全由该下标区间决定(时间轴单调时为真)。 */ bool EdgeIndexRange(const DataFile& file, std::size_t count, bool hasRange, double rangeMin, double rangeMax, std::size_t& begin, std::size_t& end) { begin = 0; end = count; if (!hasRange) { return true; } if (!file.timeMonotonic) { // 时间轴非单调时无法用下标区间表达统计范围(需逐点按时间过滤)。 return false; } const std::vector& times = file.time; const std::size_t timeCount = std::min(times.size(), count); begin = static_cast( std::lower_bound(times.begin(), times.begin() + static_cast(timeCount), rangeMin) - times.begin()); end = static_cast( std::upper_bound(times.begin(), times.begin() + static_cast(timeCount), rangeMax) - times.begin()); if (begin > 0) { --begin; } end = std::min(end, timeCount); begin = std::min(begin, end); return true; } const std::uint64_t kHintHoldMs = 1300; /**< 提示浮层完全显示的停留时长(毫秒)。 */ const std::uint64_t kHintFadeMs = 500; /**< 提示浮层的淡出时长(毫秒)。 */ /** * @brief 实验性功能浮动面板的遍历顺序(命中测试与绘制共用,保证层序一致)。 * * 顺序即绘制层序:先绘制的在下方,最后绘制的在最上方。 */ const EdgePanelKind kEdgePanelKinds[] = {EdgePanelKind::COUNT, EdgePanelKind::CENTER, EdgePanelKind::MEAN, EdgePanelKind::BOOST}; /** 实验性功能浮动面板的种类数(遍历与命中测试统一用它,避免新增功能时漏改)。 */ const int kEdgePanelKindCount = static_cast(sizeof(kEdgePanelKinds) / sizeof(kEdgePanelKinds[0])); /** * @brief 计算一条派生曲线在给定采样窗口内的取值(同步路径与后台线程共用)。 * * 只拷贝所需的窗口,再调用与「构建为新信号」完全相同的自由函数,因此 * 预览曲线与构建出的采样值逐位一致。本函数**只读**入参、不接触任何成员状态, * 因此可以安全地在后台线程调用。 * * @param [IN] signal 目标信号(数据只读)。 * @param [IN] windowBegin 窗口起点(采样下标)。 * @param [IN] windowEnd 窗口终点(不含)。 * @param [IN] kind `DerivedCurveKind` 的整型值。 * @param [IN] points 均值点数 X。 * @param [IN] iterations 迭代次数 N。 * @param [IN] levelPercent 弱波增强等级(0–100)。 * @return 曲线数值(长度 = 有效窗口长度)。 */ std::vector ComputeDerivedWindow(const SignalData& signal, std::size_t windowBegin, std::size_t windowEnd, int kind, int points, int iterations, int levelPercent) { std::vector out; const std::vector& values = signal.values; const std::size_t hi = std::min(windowEnd, values.size()); const std::size_t lo = std::min(windowBegin, hi); if (hi <= lo) { return out; } const std::vector window(values.begin() + static_cast(lo), values.begin() + static_cast(hi)); if (kind == static_cast(DerivedCurveKind::WEAK_BOOST)) { ComputeWeakWaveBoost(window, static_cast(levelPercent) / 100.0, iterations, out); } else { ComputeMovingAverageIterated(window, points, iterations, out); } return out; } /** * @brief 包络带的单个像素列(已映射到屏幕坐标)。 */ struct BandColumn { float x = 0.0f; /**< 列中心横坐标。 */ float top = 0.0f; /**< 本列的**上**包络(较小的纵坐标)。 */ float bottom = 0.0f; /**< 本列的**下**包络(较大的纵坐标)。 */ }; /** * @brief 把逐列极值拼成闭合的包络带多边形。 * * 输出格式为「上包络正向 + 下包络反向」,调用方(`PaintLane`)依赖这个顺序把 * 它拆成上下两条折线画成一个闭合环。 * * ⚠️ **几何必须是精确的**:这里**不做任何**列窗口加宽。曾经为了让填充更快而做过 * ±1 列的极值扩展,结果把"梳状"振荡糊成了实心块,波形细节整体丢失 * (用户反馈"显示效果没有之前好了")。性能问题改由**绘制侧**解决:把梳状带 * 交给无抗锯齿填充(见 `spiky` 输出与 `PlotView::m_fastFrame`)。 * * @param [IN] columns 逐列极值(同一段内按列顺序排列)。 * @param [OUT] band 追加写入的包络顶点(上包络正向 + 下包络反向)。 * @param [OUT] bandBreaks 追加写入的各段起点(相对 band 的下标)。 * @param [OUT] spiky 该段是否为「梳状」(相邻列包络几乎不相交)。 */ void EmitEnvelopeBand(const std::vector& columns, std::vector& band, std::vector& bandBreaks, bool& spiky) { if (columns.size() < 2) { return; } const std::size_t count = columns.size(); bandBreaks.push_back(band.size()); // 「梳状」判定:相邻列的上下包络几乎不相交(振荡周期与像素列宽相当时出现)。 // 这种形状下抗锯齿填充的扫描线代价暴涨,而抗锯齿对它的平滑作用几乎看不出来。 std::size_t inverted = 0; const float slack = kEnvelopeMinPixelSpan; for (std::size_t index = 1; index < count; ++index) { if (columns[index].top > columns[index - 1].bottom + slack || columns[index].bottom < columns[index - 1].top - slack) { ++inverted; } } if (inverted * 8 > count) { spiky = true; } for (std::size_t index = 0; index < count; ++index) { band.push_back(gd::PointF(columns[index].x, columns[index].top)); } for (std::size_t index = count; index-- > 0;) { band.push_back(gd::PointF(columns[index].x, columns[index].bottom)); } } /** * @brief 时间间隔的显示单位。 */ struct DurationUnit { double scale = 1.0; /**< 1 个该单位等于多少秒。 */ const wchar_t* name = L"s"; /**< 单位名称。 */ }; /** * @brief 把时间间隔格式化为"Δt 语义"的复合单位文本。 * * 规则(用户明确要求): * - 顶级单位高于 s(即 h / min)时**一路整合到 s**,例如 3662s → "1h 1min 2s"; * - 顶级单位为 s 及以下时,若不是该单位的整数,则**向下一级递进**, * 例如 5.06s → "5s 60ms";整秒数不加下级单位,例如 5s → "5s"; * - 顶级单位为 ps 时**不再递进次级单位**,例如 5.123ps → "5.123 ps"。 * * @param [IN] seconds 时间间隔(秒)。 * @return 不含 "Δt" 前缀的间隔文本。 */ std::wstring FormatDurationCompound(double seconds) { if (!std::isfinite(seconds)) { return L"—"; } const double value = std::fabs(seconds); if (value <= 0.0) { return L"0s"; } // ---- 顶级单位高于 s(h / min):整合为 h/min/s(整数秒)---- // 注意条件必须是 >= 60s:1~60s 属于"顶级单位为 s"的情况,需按下一条规则处理 // (非整数秒要递进到 ms,例如 5.06s → "5s 60ms")。 if (value >= 60.0) { const long long totalSeconds = static_cast(std::llround(value)); long long hours = 0; long long minutes = 0; long long secs = totalSeconds; if (totalSeconds >= 3600) { hours = totalSeconds / 3600; secs = totalSeconds % 3600; } if (secs >= 60) { minutes = secs / 60; secs = secs % 60; } std::wstring text; const auto append = [&text](long long amount, const wchar_t* unit) { if (amount <= 0) { return; } if (!text.empty()) { text += L" "; } text += std::to_wstring(amount); text += unit; }; append(hours, L"h"); append(minutes, L"min"); append(secs, L"s"); if (!text.empty()) { return text; } } // ---- 顶级单位为 s:整数秒 + 可选 ms ---- if (value >= 1.0) { const double whole = std::floor(value); const double fraction = value - whole; std::wstring text = FormatValue(whole) + L"s"; const double remainderMs = fraction * 1000.0; if (remainderMs >= 1.0e-6) { text += L" "; text += FormatValue(remainderMs); text += L"ms"; } return text; } // ---- 顶级单位低于 s:ms / us / ns / ps ---- const DurationUnit subUnits[] = { {1.0e-3, L"ms"}, {1.0e-6, L"us"}, {1.0e-9, L"ns"}, {1.0e-12, L"ps"}, }; const std::size_t subCount = sizeof(subUnits) / sizeof(subUnits[0]); std::size_t index = subCount - 1; for (std::size_t i = 0; i < subCount; ++i) { if (value >= subUnits[i].scale) { index = i; break; } } // 顶级单位为 ps:不再递进次级单位(保留小数)。 if (index + 1 >= subCount) { return FormatValue(value / subUnits[index].scale) + L" " + subUnits[index].name; } const double scaled = value / subUnits[index].scale; double whole = std::floor(scaled); double fraction = scaled - whole; // 浮点误差可能导致 fraction 极接近 1(例如 4.9999999),归一化一下。 if (fraction >= 1.0 - 1.0e-9) { whole += 1.0; fraction = 0.0; } std::wstring text = FormatValue(whole); text += subUnits[index].name; // 仍是该单位的整数 → 不显示下级单位。 const double remainder = fraction * subUnits[index].scale; const double inNext = remainder / subUnits[index + 1].scale; if (inNext >= 1.0e-6) { text += L" "; text += FormatValue(inNext); text += subUnits[index + 1].name; } return text; } /** * @brief 双光标差值的完整显示文本("Δt:" + 复合单位间隔)。 * @param [IN] seconds 时间间隔(秒)。 * @return 形如 "Δt:1h 1min 2s" 的文本。 */ std::wstring FormatSpanText(double seconds) { return L"Δt:" + FormatDurationCompound(seconds); } /** * @brief 时间光标的读数文本("数值 + s 后缀")。 * * 时间轴本身就是秒,但与幅值读数放在一起时没有单位容易误读, * 因此读数恒带 "s" 后缀;内联编辑仍使用不带单位的纯数值(见 BeginEdit 调用处)。 * * @param [IN] seconds 光标所在时刻(秒)。 * @return 形如 "1.25s" 的文本。 */ std::wstring FormatCursorTime(double seconds) { return FormatValue(seconds) + L"s"; } /** * @brief 把时间间隔格式化为"秒"数值文本(不含单位),用于内联编辑。 * * 编辑时的量级**始终是秒**:即使显示为 "1.5min",双击编辑的内容也是 90。 * * @param [IN] seconds 时间间隔(秒)。 * @return 形如 "60" 的文本。 */ std::wstring FormatDurationSeconds(double seconds) { if (!std::isfinite(seconds)) { return L"0"; } return FormatValue(std::fabs(seconds)); } /** * @brief 按比例缩放颜色的 alpha 通道(用于提示浮层淡出)。 * @param [IN] argb 原始 0xAARRGGBB 颜色。 * @param [IN] alpha 新的 alpha(0-255)。 * @return 缩放后的颜色。 */ std::uint32_t ScaleAlpha(std::uint32_t argb, unsigned int alpha) { const unsigned int a = std::min(255u, alpha); return (argb & 0x00FFFFFFu) | (a << 24); } /** * @brief 将数值限制到合法区间。 * @param [IN] value 输入值。 * @param [IN] low 下限。 * @param [IN] high 上限。 * @return 限制后的数值。 */ double ClampDouble(double value, double low, double high) { if (value < low) { return low; } if (value > high) { return high; } return value; } /** * @brief 组装图例文本:使用信号当前显示名(用户重命名后立即生效)。 * @param [IN] data 信号数据。 * @return 图例文本。 */ std::wstring BuildLegendText(const SignalData& data) { return data.Title(); } /** * @brief 将信号样式颜色按不透明度换算为实际绘制颜色。 * @param [IN] style 信号样式。 * @return 叠加不透明度后的 0xAARRGGBB 颜色值。 */ std::uint32_t StyleColor(const SignalStyle& style) { double opacity = static_cast(style.opacity); if (opacity < 0.0) { opacity = 0.0; } if (opacity > 1.0) { opacity = 1.0; } return WithAlpha(style.color, static_cast(opacity * 255.0 + 0.5)); } /** * @brief 由信号色派生一个「高对比度」颜色:保持色相、大幅拉开明度。 * * 用途:「边沿计数(中心轴)」的平均值曲线叠加在原始波形之上, * 若直接用信号色会被波形淹没。这里把信号色向黑或白方向大幅偏移, * 并**同时考虑与原始波形色以及与绘图区底色的距离**,取对比更强的一侧, * 保证曲线既能与波形区分、又能压在底图上看得清。 * * 结果是从信号色派生的,因此原信号改颜色时曲线颜色会自动跟着变。 * * @param [IN] color 原始信号色(0xAARRGGBB)。 * @param [IN] background 绘图区底色,用于评估可见度。 * @return 派生出的高对比度颜色(alpha 与输入一致,由调用方再覆盖)。 */ std::uint32_t HighContrastDerived(std::uint32_t color, std::uint32_t background = 0xFFFDF6E3u) { const auto squaredDistance = [](std::uint32_t a, std::uint32_t b) { const double dr = static_cast((a >> 16) & 0xFFu) - static_cast((b >> 16) & 0xFFu); const double dg = static_cast((a >> 8) & 0xFFu) - static_cast((b >> 8) & 0xFFu); const double db = static_cast(a & 0xFFu) - static_cast(b & 0xFFu); return dr * dr + dg * dg + db * db; }; const std::uint32_t darker = MixColor(color, 0xFF000000u, 0.72); const std::uint32_t lighter = MixColor(color, 0xFFFFFFFFu, 0.72); // 评分:与原始波形色的区分度为主(权重 1.0),与底色的可见度为辅(权重 0.6)。 const double scoreDarker = squaredDistance(darker, color) + 0.6 * squaredDistance(darker, background); const double scoreLighter = squaredDistance(lighter, color) + 0.6 * squaredDistance(lighter, background); return (scoreDarker >= scoreLighter) ? darker : lighter; } } PlotView::PlotView() = default; PlotView::~PlotView() { // 先停掉后台计算线程:它可能正在读取采样数据,必须在释放其它资源前回收。 StopCurveWorker(); ReleaseSceneCache(); ReleasePanelCache(); } void PlotView::Setup(const UiFonts* fonts, const Theme* theme) { m_fonts = fonts; m_theme = theme; } void PlotView::SetContext(DataSet* dataSet, TabConfig* tab) { m_dataSet = dataSet; m_dataSetWritable = dataSet; m_tab = tab; m_hoverLane = -1; m_dragMode = PlotDragMode::NONE; // 重新绑定数据源后旧的几何键不再有意义,必须清空折线缓存。 InvalidateDerivedCaches(); InvalidateSceneCache(); RefreshLanes(); } void PlotView::SetScale(float scale) { m_scale = (scale > 0.1f) ? scale : 1.0f; InvalidateSceneCache(); } bool PlotView::CursorEnabled() const { return Tab().CursorEnabled(); } double PlotView::CursorTime() const { return Tab().CursorTime(); } PlotCursorMode PlotView::CursorMode() const { return Tab().CursorMode(); } bool PlotView::HasDrawableContent() const { return (m_dataSet != nullptr) && !m_dataSet->Empty() && (m_tab != nullptr) && m_tab->AnyVisible(); } void PlotView::TimeBounds(double& low, double& high) const { low = 0.0; high = 1.0; if (m_dataSet == nullptr || m_dataSet->Empty()) { return; } // 优先使用可见信号所覆盖的范围,使视图随勾选内容自适应。 if (m_tab != nullptr && m_tab->ComputeVisibleTimeBounds(*m_dataSet, low, high)) { return; } low = m_dataSet->MinTime(); high = m_dataSet->MaxTime(); if (!(high > low)) { high = low + 1.0; } } PlotView::ResolvedSignal PlotView::ResolveSignal(const SignalRef& ref) const { ResolvedSignal result; if (m_dataSet == nullptr) { return result; } result.data = m_dataSet->Resolve(ref); result.file = m_dataSet->FileAt(ref.fileIndex); result.style = m_dataSet->Style(ref); result.visible = (m_tab != nullptr) && m_tab->IsVisible(ref); return result; } void PlotView::Layout(const gd::RectF& area) { m_area = area; // 布局变化(窗口缩放、面板宽度调整、网格数量变化)后静态场景不再有效。 InvalidateSceneCache(); m_labelBandWidth = 66.0f * m_scale; m_timeAxisHeight = 34.0f * m_scale; m_minLaneHeight = 54.0f * m_scale; // 先为时间轴保留固定高度,剩余空间才交给网格,保证时间轴不会被挤压。 const float topPadding = 6.0f * m_scale; const float availHeight = std::max(0.0f, area.Height - m_timeAxisHeight - topPadding); // 预估是否需要纵向滚动条:仅取决于网格数量与最小网格高度(网格之间不再留间隔)。 std::size_t laneCount = (m_tab != nullptr) ? m_tab->Lanes().size() : 0; if (laneCount > 0) { const float needed = static_cast(laneCount) * m_minLaneHeight; m_scrollBarVisible = (needed > availHeight); } else { m_scrollBarVisible = false; } const float sidePadding = kPlotSidePadding * m_scale; const float rightReserve = m_scrollBarVisible ? (kScrollBarThickness * m_scale + sidePadding) : sidePadding; const float plotWidth = std::max(0.0f, area.Width - sidePadding - rightReserve); m_plotRect = gd::RectF(area.X + sidePadding, area.Y + topPadding, plotWidth, availHeight); m_timeAxisRect = gd::RectF(m_plotRect.X, m_plotRect.Y + m_plotRect.Height, m_plotRect.Width, m_timeAxisHeight); m_scrollBarRect = gd::RectF(area.X + area.Width - kScrollBarThickness * m_scale - 3.0f * m_scale, m_plotRect.Y, kScrollBarThickness * m_scale, m_plotRect.Height); BuildLaneVisuals(); } void PlotView::RefreshLanes() { BuildLaneVisuals(); } 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); } } void PlotView::ClampLaneScroll() { const float maxOffset = std::max(0.0f, m_laneContentHeight - m_plotRect.Height); Tab().LaneScroll() = static_cast(ClampDouble(Tab().LaneScroll(), 0.0, maxOffset)); } bool PlotView::LaneSpansMultipleFiles(int laneIndex) const { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return false; } int firstFile = -1; for (const SignalRef& ref : m_tab->Lanes()[static_cast(laneIndex)].signals) { const SignalStyle* style = m_dataSet->Style(ref); if (style == nullptr || !m_tab->IsVisible(ref)) { continue; } if (firstFile < 0) { firstFile = ref.fileIndex; } else if (ref.fileIndex != firstFile) { return true; } } return false; } void PlotView::BuildLaneVisuals() { m_lanes.clear(); if (m_tab == nullptr || m_dataSet == nullptr || !HasDrawableContent()) { m_laneContentHeight = 0.0f; return; } const std::vector& lanes = m_tab->Lanes(); const std::size_t count = lanes.size(); if (count == 0) { m_laneContentHeight = 0.0f; return; } const float gap = 0.0f; float laneHeight = m_plotRect.Height / static_cast(count); if (laneHeight < m_minLaneHeight * m_scale) { laneHeight = m_minLaneHeight * m_scale; } m_laneContentHeight = laneHeight * static_cast(count); ClampLaneScroll(); const float scrollOffset = Tab().LaneScroll(); m_lanes.resize(count); for (std::size_t index = 0; index < count; ++index) { LaneVisual& visual = m_lanes[index]; visual.rect = gd::RectF( m_plotRect.X, m_plotRect.Y + static_cast(index) * (laneHeight + gap) - scrollOffset, m_plotRect.Width, laneHeight); m_tab->ComputeLaneRange(*m_dataSet, static_cast(index), visual.yMin, visual.yMax); if (!(visual.yMax > visual.yMin)) { visual.yMin = 0.0; visual.yMax = 1.0; } } } float PlotView::TimeToX(double time) const { const double viewMin = Tab().ViewMin(); const double viewMax = Tab().ViewMax(); const double span = viewMax - viewMin; if (!(span > 0.0) || m_plotRect.Width <= 0.0f) { return m_plotRect.X; } return m_plotRect.X + static_cast((time - viewMin) / span * m_plotRect.Width); } double PlotView::XToTime(float x) const { const double viewMin = Tab().ViewMin(); if (m_plotRect.Width <= 0.0f) { return viewMin; } const double ratio = static_cast(x - m_plotRect.X) / static_cast(m_plotRect.Width); return viewMin + ratio * (Tab().ViewMax() - viewMin); } float PlotView::ValueToY(double value, const LaneVisual& lane) const { const double span = lane.yMax - lane.yMin; if (!(span > 0.0)) { return lane.rect.Y + lane.rect.Height * 0.5f; } double ratio = (value - lane.yMin) / span; ratio = ClampDouble(ratio, -50.0, 50.0); return lane.rect.Y + lane.rect.Height * static_cast(1.0 - ratio); } double PlotView::YToValue(float y, const LaneVisual& lane) const { if (lane.rect.Height <= 0.0f) { return lane.yMin; } const double ratio = 1.0 - static_cast(y - lane.rect.Y) / static_cast(lane.rect.Height); return lane.yMin + ratio * (lane.yMax - lane.yMin); } int PlotView::LaneAtY(int y) const { const float fy = static_cast(y); for (std::size_t index = 0; index < m_lanes.size(); ++index) { const gd::RectF& rect = m_lanes[index].rect; if (fy >= rect.Y && fy <= rect.Y + rect.Height) { return static_cast(index); } } return -1; } void PlotView::ClampTimeView() { if (m_dataSet == nullptr || m_dataSet->Empty()) { return; } double span = Tab().ViewMax() - Tab().ViewMin(); if (!(span > 0.0)) { span = kMinTimeSpan; } double dataMin = 0.0; double dataMax = 1.0; TimeBounds(dataMin, dataMax); const double pad = span * 2.0; const double minAllowed = dataMin - pad; const double maxAllowed = dataMax + pad; if (Tab().ViewMin() < minAllowed) { Tab().ViewMin() = minAllowed; Tab().ViewMax() = Tab().ViewMin() + span; } if (Tab().ViewMax() > maxAllowed) { Tab().ViewMax() = maxAllowed; Tab().ViewMin() = Tab().ViewMax() - span; } } void PlotView::ZoomTimeAt(double factor, double anchorTime) { if (m_dataSet == nullptr || m_dataSet->Empty() || !(factor > 0.0)) { return; } double dataMin = 0.0; double dataMax = 1.0; TimeBounds(dataMin, dataMax); const double dataSpan = std::max(dataMax - dataMin, kMinTimeSpan); const double span = Tab().ViewMax() - Tab().ViewMin(); double newSpan = ClampDouble(span / factor, dataSpan * 1.0e-9, dataSpan * 100.0); const double ratio = (span > 0.0) ? (anchorTime - Tab().ViewMin()) / span : 0.5; Tab().ViewMin() = anchorTime - ratio * newSpan; Tab().ViewMax() = Tab().ViewMin() + newSpan; ClampTimeView(); } void PlotView::ZoomTimeStep(double factor) { const double anchor = (Tab().ViewMin() + Tab().ViewMax()) * 0.5; ZoomTimeAt(factor, anchor); } void PlotView::PanTimeByFraction(double fraction) { const double span = Tab().ViewMax() - Tab().ViewMin(); const double delta = span * fraction; Tab().ViewMin() += delta; Tab().ViewMax() += delta; ClampTimeView(); } void PlotView::ScrollLanesByFraction(double fraction) { Tab().LaneScroll() += static_cast(fraction * static_cast(m_laneContentHeight)); // 立即重建几何,避免命中测试使用旧位置。 BuildLaneVisuals(); } void PlotView::FitAll() { if (m_dataSet == nullptr || m_dataSet->Empty()) { Tab().ViewMin() = 0.0; Tab().ViewMax() = 1.0; Tab().ViewInitialized() = true; return; } double low = 0.0; double high = 1.0; TimeBounds(low, high); if (!(high > low)) { const double padding = std::max(1.0e-6, std::fabs(low) * 0.01); low -= padding; high += padding; } Tab().ViewMin() = low; Tab().ViewMax() = high; Tab().ViewInitialized() = true; Tab().LaneScroll() = 0.0f; // 只放下进行中的选区与拖拽,**不改变「选区放大」等缩放模式的启用状态** // (用户明确要求:点适应视图后仍保持已启用的缩放模式)。 m_rubberRect = gd::RectF(); m_dragMode = PlotDragMode::NONE; m_dragSignal = SignalRef(); m_dragTargetLane = -1; m_hoverSignal = SignalRef(); SetHint(Tab().ZoomMode() == PlotZoomMode::NONE ? std::wstring() : L"横向或纵向拖动以放大选择区域,ESC退出选区模式"); if (m_tab != nullptr) { // 适应视图 = 回到初始状态:恢复整段数据的自动自适应。 // 但**开启了「选区 Y 轴自适应」的网格必须继续跟随时间视图**: // 直接置 autoRange 会让 ComputeLaneRange 忽略已经算好的时间段幅值, // 表现为"点一下适应视图就把 Y 轴自适应关掉了"。这里改为按新的 // (整段)时间视图重新匹配一次,开关状态与视觉效果都保持。 const int laneCount = static_cast(m_tab->Lanes().size()); for (int index = 0; index < laneCount; ++index) { if (m_tab->Lanes()[static_cast(index)].fitYToSelection) { AutoRangeLaneToView(index); } else { m_tab->Lanes()[static_cast(index)].autoRange = true; } } } // 适应视图已回到初始状态,这里不再移动光标(光标允许停留在可视区外)。 BuildLaneVisuals(); } void PlotView::DisableCursor() { Tab().CursorMode() = PlotCursorMode::NONE; m_cursorHover = false; m_hoverCursor = 0; if (m_editTarget == PlotEditTarget::CURSOR_TIME || m_editTarget == PlotEditTarget::CURSOR_SPAN) { EndEdit(false); } if (m_dragMode == PlotDragMode::CURSOR_MOVE || m_dragMode == PlotDragMode::CURSOR_SPAN_MOVE) { m_dragMode = PlotDragMode::NONE; } } bool PlotView::CycleCursorMode() { // 三态循环:无 → 单光标 → 双光标 → 无。光标模式本身不显示提示。 switch (Tab().CursorMode()) { case PlotCursorMode::NONE: { Tab().CursorMode() = PlotCursorMode::SINGLE; // 首次启用时置于视图中心,随后可用鼠标靠近拖拽或双击指定精确时间。 const double center = (Tab().ViewMin() + Tab().ViewMax()) * 0.5; Tab().CursorTime() = center; Tab().CursorTimeAlt() = center; m_cursorHover = true; m_hoverCursor = 1; m_activeCursor = 1; break; } case PlotCursorMode::SINGLE: { Tab().CursorMode() = PlotCursorMode::DOUBLE; // 光标 2 首次出现:紧邻光标 1 且必须落在当前可见时间范围内, // 这样无需额外操作就能立即看到两个光标的对比读数。 const double low = Tab().ViewMin(); const double high = Tab().ViewMax(); const double span = (high > low) ? (high - low) : 1.0; const double offset = span * 0.12; double second = Tab().CursorTime() + offset; if (second > high - span * 0.02) { second = Tab().CursorTime() - offset; } if (second < low + span * 0.02 || second > high - span * 0.02) { second = low + span * 0.5; } Tab().CursorTimeAlt() = second; ClampCursorToView(2); m_cursorHover = true; m_hoverCursor = 2; m_activeCursor = 2; break; } case PlotCursorMode::DOUBLE: default: DisableCursor(); break; } return true; } void PlotView::SetHint(const std::wstring& text) { m_hintText = text; m_hintSticky = false; m_hintShownAt = (text.empty()) ? 0 : GetTickCount64(); } void PlotView::SetHintSticky(const std::wstring& text) { m_hintText = text; m_hintSticky = !text.empty(); m_hintShownAt = (text.empty()) ? 0 : GetTickCount64(); } void PlotView::ReleaseHint() { // 粘滞结束:从这里开始重新计时(先停留再淡出)。 m_hintSticky = false; if (!m_hintText.empty()) { m_hintShownAt = GetTickCount64(); } } bool PlotView::HintNeedsAnimation() const { if (m_hintText.empty() || m_hintShownAt == 0) { return false; } if (m_hintSticky) { return true; } const std::uint64_t elapsed = GetTickCount64() - m_hintShownAt; return elapsed < (kHintHoldMs + kHintFadeMs); } double PlotView::CursorSpan() const { if (m_tab == nullptr || Tab().CursorMode() != PlotCursorMode::DOUBLE) { return 0.0; } return std::fabs(Tab().CursorTimeAlt() - Tab().CursorTime()); } void PlotView::ClampCursorToView(int which) { if (m_tab == nullptr || m_plotRect.Width <= 0.0f) { return; } const double low = Tab().ViewMin(); const double high = Tab().ViewMax(); if (!(high > low)) { return; } // 只在拖拽时夹取;留一点内缩,保证光标线及把手完整可见。 // 视图变化(平移/缩放/自适应)**不**调用本函数:光标允许滞留在网格可视区域之外。 const double inset = (high - low) * 0.002; if (which == 2) { Tab().CursorTimeAlt() = ClampDouble(Tab().CursorTimeAlt(), low + inset, high - inset); } else { Tab().CursorTime() = ClampDouble(Tab().CursorTime(), low + inset, high - inset); } } std::uint32_t PlotView::CursorColor() const { // 两条光标统一使用同一样式与颜色,不按序号区分配色。 if (m_theme == nullptr) { return 0xFF000000u; } return m_theme->cursor; } 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; } void PlotView::InvalidateDerivedCaches() { // 后台线程可能在读取采样数据:先暂停并作废异步结果,再清同步缓存。 PauseDerivedAsync(); m_polylineCache.clear(); // 数据层结构变化后边沿统计与均值滤波曲线的派生结果都会失效。 m_edgeCountCache.clear(); m_edgeCenterCountCache.clear(); m_meanCurveCache.clear(); m_weakBoostCurveCache.clear(); // 信号下标可能已经整体位移:清掉按引用缓存的悬停/拖拽目标,避免指向错误的信号。 m_hoverSignal = SignalRef(); m_hoverLegend = SignalRef(); m_dragSignal = SignalRef(); m_dragTargetLane = -1; InvalidateSceneCache(); } void PlotView::PauseDerivedAsync() { // 等待在算任务结束,然后清空队列与缓存,并自增代次使任何迟到结果作废。 StopCurveWorker(); std::lock_guard lock(m_curveMutex); ++m_curveGeneration; m_curveJobs.clear(); m_curveEntries.clear(); m_curvePins.clear(); m_curveSceneDirty.store(false, std::memory_order_relaxed); } bool PlotView::FitTimeToLane(int laneIndex) { if (m_dataSet == nullptr || m_tab == nullptr) { return false; } const std::vector& lanes = m_tab->Lanes(); if (laneIndex < 0 || laneIndex >= static_cast(lanes.size())) { return false; } double low = std::numeric_limits::infinity(); double high = -std::numeric_limits::infinity(); for (const SignalRef& ref : lanes[static_cast(laneIndex)].signals) { const DataFile* file = m_dataSet->FileAt(ref.fileIndex); if (file == nullptr) { continue; } low = std::min(low, file->minTime); high = std::max(high, file->maxTime); } if (!std::isfinite(low) || !std::isfinite(high) || !(high > low)) { return false; } Tab().ViewMin() = low; Tab().ViewMax() = high; Tab().ViewInitialized() = true; // 与“适应视图”保持一致:退出选区放大模式,避免模式按钮与实际状态不符。 Tab().ZoomMode() = PlotZoomMode::NONE; m_rubberRect = gd::RectF(); m_dragMode = PlotDragMode::NONE; m_rubberAxis = PlotRubberAxis::UNDECIDED; // 针对本网格信号范围展开时间轴后,同时把该网格的 Y 轴自适应一次, // 否则展开后会看到“时间对了但幅值区间不合适”的波形。 AutoRangeLaneToView(laneIndex); // 其它开启了「选区 Y 轴自适应」的网格同样要跟新的时间视图重新匹配: // 时间视图变了,只让被展开的那个网格合适、其余网格停留在旧幅值区间并不符合预期。 ApplySelectionAutoFit(); // 注意:不在这里移动光标——光标允许滞留在网格可视区域之外。 BuildLaneVisuals(); return true; } bool PlotView::SplitLane(int laneIndex) { if (m_tab == nullptr) { return false; } // 拆分由 TabConfig 完成(含网格自身状态的继承与编号刷新)。 const int first = m_tab->SplitLane(laneIndex); if (first < 0) { return false; } // 网格数量变化 → 几何与滚动范围需要重建,静态场景缓存也要失效。 BuildLaneVisuals(); InvalidateSceneCache(); return true; } std::size_t PlotView::LaneSignalCount(int laneIndex) const { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return 0; } return m_tab->Lanes()[static_cast(laneIndex)].signals.size(); } bool PlotView::SampleSpacingPixels(const DataFile& file, double time, double& pixels) const { pixels = 0.0; const std::vector& samples = file.time; if (samples.size() < 2 || m_plotRect.Width <= 1.0f) { return false; } const double viewSpan = Tab().ViewMax() - Tab().ViewMin(); if (!(viewSpan > 0.0)) { return false; } const int index = FindNearestSample(file, time); if (index < 0) { return false; } double spacing = 0.0; if (index > 0) { spacing = samples[static_cast(index)] - samples[static_cast(index) - 1]; } else { spacing = samples[1] - samples[0]; } // 时间轴非单调时前后差值可能为负,此时不做吸附。 if (!(spacing > 0.0) || !std::isfinite(spacing)) { return false; } pixels = spacing / viewSpan * static_cast(m_plotRect.Width); return true; } bool PlotView::SnapTimeToSample(double time, double& snapped) const { if (m_dataSet == nullptr) { return false; } const double minPixels = static_cast(kCursorSnapMinPixels) * static_cast(m_scale); bool found = false; double bestDistance = std::numeric_limits::infinity(); double bestTime = 0.0; for (const DataFile& file : m_dataSet->Files()) { // 只有采样点在屏幕上足够稀疏时才吸附,否则缩小视图后光标会“跳格”。 double pixels = 0.0; if (!SampleSpacingPixels(file, time, pixels) || pixels < minPixels) { continue; } const int index = FindNearestSample(file, time); if (index < 0) { continue; } const double candidate = file.time[static_cast(index)]; if (!std::isfinite(candidate)) { continue; } const double distance = std::fabs(candidate - time); if (distance < bestDistance) { bestDistance = distance; bestTime = candidate; found = true; } } if (found) { snapped = bestTime; } return found; } PlotZoomMode PlotView::CurrentZoomMode() const { return Tab().ZoomMode(); } bool PlotView::ToggleZoomMode(PlotZoomMode mode) { const PlotZoomMode current = Tab().ZoomMode(); Tab().ZoomMode() = (current == mode) ? PlotZoomMode::NONE : mode; m_rubberAxis = PlotRubberAxis::UNDECIDED; m_rubberRect = gd::RectF(); SetHint(Tab().ZoomMode() == PlotZoomMode::NONE ? std::wstring() : L"横向或纵向拖动以放大选择区域,ESC退出选区模式"); return true; } float PlotView::CursorX(int which) const { const double time = (which == 2) ? Tab().CursorTimeAlt() : Tab().CursorTime(); return TimeToX(time); } float PlotView::CursorDisplayX(int which) const { // 光标允许滞留在可视区之外:此时把它"钉"在最近的网格边缘上显示, // 保证它的取值与时间读数始终可见。 const float raw = CursorX(which); return std::max(m_plotRect.X + 1.0f * m_scale, std::min(raw, m_plotRect.X + m_plotRect.Width - 1.0f * m_scale)); } gd::RectF PlotView::CursorLabelRect(int which) const { if (m_fonts == nullptr || m_fonts->monoSmall == nullptr) { return gd::RectF(); } // 未经叠放处理的"原始"位置:单条标签独占底部一行。 const auto rawRect = [this](int index) -> gd::RectF { const double time = (index == 2) ? Tab().CursorTimeAlt() : Tab().CursorTime(); const std::wstring text = FormatCursorTime(time); const float width = std::max(64.0f * m_scale, UiMeasureTextWidth(m_fonts->monoSmall, text) + 16.0f * m_scale); const float height = 20.0f * m_scale; // 时间读数改到绘图区**底部**:顶部留给网格图例与幅值标尺,避免互相遮挡。 float left = CursorDisplayX(index) - width * 0.5f; left = std::max(m_plotRect.X, std::min(left, m_plotRect.X + m_plotRect.Width - width)); // 光标滞留在视界之外时,胶囊外侧还要放一个方向三角(与网格内数据点越界同一套画法): // 把胶囊整体**向内**让出三角 + 间隙的宽度,否则贴边时三角没有落脚之处、 // 只能压在胶囊上(用户报告的"没有间隔、样式不统一")。 bool offRight = false; if (CursorOffViewSide(index, offRight)) { const float reserved = (kMarkerTriangleDepth + kMarkerTriangleGap) * m_scale; left = offRight ? (left - reserved) : (left + reserved); left = std::max(m_plotRect.X, std::min(left, m_plotRect.X + m_plotRect.Width - width)); } const float top = m_plotRect.Y + m_plotRect.Height - height - 2.0f * m_scale; return gd::RectF(left, top, width, height); }; gd::RectF rect = rawRect(which); // 双光标靠得很近时,两条时间读数标签会互相覆盖:把它们分两行叠放。 // **小值在上、大值在下**(用户明确要求)——按时间值决定行序, // 而不是固定让光标 2 上移。左/右光标被拖动交换后排序依然正确。 if (Tab().CursorMode() == PlotCursorMode::DOUBLE) { const gd::RectF first = rawRect(1); const gd::RectF second = rawRect(2); // 方向三角已移到胶囊**外侧**,胶囊之间不再需要额外留白。 const float gap = 2.0f * m_scale; const bool overlapX = (first.X < second.X + second.Width + gap) && (second.X < first.X + first.Width + gap); if (overlapX) { const bool firstIsSmaller = (Tab().CursorTime() <= Tab().CursorTimeAlt()); // which == 1 是否属于"小值"。 const bool whichIsSmaller = (which == 1) ? firstIsSmaller : !firstIsSmaller; const float bottomRow = rawRect(1).Y; const float topRow = std::max(m_plotRect.Y + 2.0f * m_scale, bottomRow - rect.Height - gap); rect.Y = whichIsSmaller ? topRow : bottomRow; } } return rect; } bool PlotView::IsOnCursorLine(int x, int& which) const { which = 0; if (!Tab().CursorEnabled() || m_plotRect.Width <= 0.0f) { return false; } const float tolerance = 6.0f * m_scale; const float fx = static_cast(x); // 双光标重叠时优先命中光标 1,避免出现“抓不起来第二条”的情况。 // 使用"显示横坐标":滞留在可视区外的光标被钉在边缘,也应该可以被抓住拖回来。 if (std::fabs(fx - CursorDisplayX(1)) <= tolerance) { which = 1; return true; } if (Tab().CursorMode() == PlotCursorMode::DOUBLE && std::fabs(fx - CursorDisplayX(2)) <= tolerance) { which = 2; return true; } return false; } bool PlotView::IsOnCursorTimeLabel(int x, int y, int& which) const { which = 0; if (!Tab().CursorEnabled()) { return false; } const float fx = static_cast(x); const float fy = static_cast(y); // 双光标重叠时优先命中光标 1(与光标线的命中顺序一致)。 const int count = (Tab().CursorMode() == PlotCursorMode::DOUBLE) ? 2 : 1; for (int index = 1; index <= count; ++index) { const gd::RectF rect = CursorLabelRect(index); if (rect.Width > 0.0f && UiPointInRect(rect, fx, fy)) { which = index; return true; } } return false; } bool PlotView::IsOnCursorSpanLabel(int x, int y) const { if (Tab().CursorMode() != PlotCursorMode::DOUBLE || m_cursorSpanRect.Width <= 0.0f) { return false; } return UiPointInRect(m_cursorSpanRect, static_cast(x), static_cast(y)); } bool PlotView::ApplyTimeSelection(float x0, float x1) { if (m_plotRect.Width <= 0.0f) { return false; } float left = std::min(x0, x1); float right = std::max(x0, x1); left = std::max(left, m_plotRect.X); right = std::min(right, m_plotRect.X + m_plotRect.Width); if (right - left < 8.0f * m_scale) { return false; } double timeA = XToTime(left); double timeB = XToTime(right); if (timeB < timeA) { std::swap(timeA, timeB); } if (!(timeB - timeA > 0.0)) { return false; } Tab().ViewMin() = timeA; Tab().ViewMax() = timeB; ClampTimeView(); // 注意:不在这里移动光标——即使选区不包含光标,也只把它的读数贴在网格边缘显示。 // 「选区 Y 轴自适应」开关开启的网格:时间选区放大后自动把 Y 轴匹配到新的可见区间。 ApplySelectionAutoFit(); return true; } bool PlotView::ApplyValueSelection(int laneIndex, float y0, float y1) { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) { return false; } const LaneVisual& visual = m_lanes[static_cast(laneIndex)]; if (visual.rect.Height <= 0.0f) { return false; } float top = std::min(y0, y1); float bottom = std::max(y0, y1); top = std::max(top, visual.rect.Y); bottom = std::min(bottom, visual.rect.Y + visual.rect.Height); if (bottom - top < 10.0f * m_scale) { return false; } const double valueTop = YToValue(top, visual); const double valueBottom = YToValue(bottom, visual); if (!(std::fabs(valueTop - valueBottom) > 0.0)) { return false; } Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; lane.autoRange = false; // 手动 Y 轴选区放大:关闭本网格的「选区 Y 轴自适应」。 lane.fitYToSelection = false; lane.yMin = std::min(valueTop, valueBottom); lane.yMax = std::max(valueTop, valueBottom); return true; } int PlotView::LaneIndexAt(int x, int y) const { if (!UiPointInRect(m_plotRect, static_cast(x), static_cast(y))) { return -1; } return LaneAtY(y); } bool PlotView::LaneAutoFitToSelection(int laneIndex) const { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return false; } return m_tab->Lanes()[static_cast(laneIndex)].fitYToSelection; } bool PlotView::AllLanesAutoFitToSelection() const { if (m_tab == nullptr) { return false; } const std::vector& lanes = m_tab->Lanes(); if (lanes.empty()) { return false; } for (const Lane& lane : lanes) { if (!lane.fitYToSelection) { // 任一网格未开启 → 全局项就不是勾选状态。 return false; } } return true; } bool PlotView::LaneEdgeCount(int laneIndex) const { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return false; } return m_tab->Lanes()[static_cast(laneIndex)].edgeCount; } bool PlotView::SetLaneEdgeCount(int laneIndex, bool enabled) { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return false; } Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; if (lane.edgeCount == enabled) { return false; } lane.edgeCount = enabled; if (enabled) { // 首次开启:把横向光标放到可见幅值范围的中点,并让面板重新展开。 if (!lane.edgeValueValid) { double yMin = 0.0; double yMax = 1.0; LaneRange(laneIndex, yMin, yMax); lane.edgeValue = (yMin + yMax) * 0.5; lane.edgeValueValid = true; } lane.edgePanelOpen = true; } else { lane.edgePanelOpen = false; } return true; } bool PlotView::ToggleLaneEdgeCount(int laneIndex) { const bool enabled = !LaneEdgeCount(laneIndex); SetLaneEdgeCount(laneIndex, enabled); return enabled; } bool PlotView::LaneEdgeCenterCount(int laneIndex) const { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return false; } return m_tab->Lanes()[static_cast(laneIndex)].edgeCenter; } bool PlotView::SetLaneEdgeCenterCount(int laneIndex, bool enabled) { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return false; } Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; if (lane.edgeCenter == enabled) { return false; } lane.edgeCenter = enabled; if (enabled) { // 与「均值滤波」完全一致:默认只勾选网格内**第一个可见信号**。 if (lane.edgeCenterItems.empty()) { for (const SignalRef& ref : lane.signals) { const ResolvedSignal resolved = ResolveSignal(ref); if (resolved.data != nullptr && resolved.visible) { lane.EnsureEdgeCenterItem(ref); break; } } } lane.edgeCenterPanelOpen = true; } else { lane.edgeCenterPanelOpen = false; } return true; } bool PlotView::ToggleLaneEdgeCenterCount(int laneIndex) { const bool enabled = !LaneEdgeCenterCount(laneIndex); SetLaneEdgeCenterCount(laneIndex, enabled); return enabled; } bool PlotView::ToggleLaneEdgeCenterSignal(int laneIndex, const SignalRef& ref) { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return false; } Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; if (lane.EdgeCenterEnabled(ref)) { lane.RemoveEdgeCenterItem(ref); return false; } lane.EnsureEdgeCenterItem(ref); return true; } bool PlotView::LaneEdgeCenterSignalEnabled(int laneIndex, const SignalRef& ref) const { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return false; } return m_tab->Lanes()[static_cast(laneIndex)].EdgeCenterEnabled(ref); } bool PlotView::LaneMeanFilter(int laneIndex) const { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return false; } return m_tab->Lanes()[static_cast(laneIndex)].meanFilter; } bool PlotView::LaneWeakBoost(int laneIndex) const { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return false; } return m_tab->Lanes()[static_cast(laneIndex)].weakBoost; } double PlotView::LaneWeakBoostLevel(int laneIndex) const { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return 0.5; } return m_tab->Lanes()[static_cast(laneIndex)].weakBoostLevel; } int PlotView::LaneWeakBoostIterations(int laneIndex) const { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return kWeakBoostIterationsMin; } return m_tab->Lanes()[static_cast(laneIndex)].weakBoostIterations; } bool PlotView::SetLaneWeakBoostIterations(int laneIndex, int iterations) { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return false; } Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; const int clamped = std::max(kWeakBoostIterationsMin, std::min(kWeakBoostIterationsMax, iterations)); if (lane.weakBoostIterations == clamped) { return false; } lane.weakBoostIterations = clamped; // 迭代次数变化会改变预览曲线:清掉整条曲线的缓存即可。 // ⚠️ **不要**再 `InvalidateSceneCache()`:那会整幅重绘(5 个网格、上万个顶点), // 拖滑块时每帧都发生;改为依赖 `m_sceneDerived` 快照 → 只重画受影响的网格。 m_weakBoostCurveCache.clear(); return true; } bool PlotView::SetLaneWeakBoostLevel(int laneIndex, double level) { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return false; } Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; const double clamped = std::max(0.0, std::min(1.0, level)); if (lane.weakBoostLevel == clamped) { return false; } lane.weakBoostLevel = clamped; // 等级变化会改变预览曲线:清掉整条曲线的缓存即可(同 `SetLaneWeakBoostIterations`, // 故意**不**作废静态场景缓存,以便走"只重画受影响网格"的快路径)。 m_weakBoostCurveCache.clear(); return true; } bool PlotView::SetLaneWeakBoost(int laneIndex, bool enabled) { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return false; } Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; if (lane.weakBoost == enabled) { return false; } lane.weakBoost = enabled; if (enabled) { // 与「均值滤波」完全一致:默认只勾选网格内**第一个可见信号**。 if (lane.weakBoostItems.empty()) { for (const SignalRef& ref : lane.signals) { const ResolvedSignal resolved = ResolveSignal(ref); if (resolved.data != nullptr && resolved.visible) { lane.EnsureWeakBoostItem(ref); break; } } } lane.weakBoostPanelOpen = true; } else { lane.weakBoostPanelOpen = false; } return true; } bool PlotView::ToggleLaneWeakBoost(int laneIndex) { const bool enabled = !LaneWeakBoost(laneIndex); SetLaneWeakBoost(laneIndex, enabled); return enabled; } bool PlotView::ToggleLaneWeakBoostSignal(int laneIndex, const SignalRef& ref) { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return false; } Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; if (lane.WeakBoostEnabled(ref)) { lane.RemoveWeakBoostItem(ref); return false; } lane.EnsureWeakBoostItem(ref); return true; } bool PlotView::LaneWeakBoostSignalEnabled(int laneIndex, const SignalRef& ref) const { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return false; } return m_tab->Lanes()[static_cast(laneIndex)].WeakBoostEnabled(ref); } bool PlotView::TakePendingWeakBoostBuild(int& laneIndex, std::vector& items) { if (m_pendingWeakBoostLane < 0) { return false; } laneIndex = m_pendingWeakBoostLane; items = m_pendingWeakBoostItems; m_pendingWeakBoostLane = -1; m_pendingWeakBoostItems.clear(); return true; } bool PlotView::SetLaneMeanFilter(int laneIndex, bool enabled) { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return false; } Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; if (lane.meanFilter == enabled) { return false; } lane.meanFilter = enabled; if (enabled) { // 默认只勾选网格内**第一个可见信号**(用户明确要求),其余保持未勾选。 if (lane.meanFilterItems.empty()) { for (const SignalRef& ref : lane.signals) { const ResolvedSignal resolved = ResolveSignal(ref); if (resolved.data != nullptr && resolved.visible) { lane.EnsureMeanFilterItem(ref); break; } } } lane.meanFilterPanelOpen = true; } else { lane.meanFilterPanelOpen = false; } return true; } bool PlotView::ToggleLaneMeanFilterSignal(int laneIndex, const SignalRef& ref) { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return false; } Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; if (lane.MeanFilterEnabled(ref)) { lane.RemoveMeanFilterItem(ref); return false; } lane.EnsureMeanFilterItem(ref); return true; } bool PlotView::LaneMeanFilterSignalEnabled(int laneIndex, const SignalRef& ref) const { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return false; } return m_tab->Lanes()[static_cast(laneIndex)].MeanFilterEnabled(ref); } bool PlotView::ToggleLaneMeanFilter(int laneIndex) { const bool enabled = !LaneMeanFilter(laneIndex); SetLaneMeanFilter(laneIndex, enabled); return enabled; } bool PlotView::TakePendingMeanFilterBuild(int& laneIndex, std::vector& items) { if (m_pendingMeanFilterLane < 0) { return false; } laneIndex = m_pendingMeanFilterLane; items = m_pendingMeanFilterItems; m_pendingMeanFilterLane = -1; m_pendingMeanFilterItems.clear(); return true; } bool PlotView::MeanFilterValueAt(const DataFile& file, const SignalData& signal, int points, int iterations, double time, double& value) const { const int index = FindNearestSample(file, time); if (index < 0) { return false; } const std::vector& curve = MeanFilterCurveFor(signal, points, iterations); if (static_cast(index) >= curve.size()) { return false; } const float sampled = curve[static_cast(index)]; if (!std::isfinite(sampled)) { return false; } value = static_cast(sampled); return true; } void PlotView::VisibleIndexRange(const DataFile& file, std::size_t count, std::size_t& begin, std::size_t& end) const { begin = 0; end = count; if (!file.timeMonotonic || count == 0) { return; } const std::vector& time = file.time; begin = static_cast( std::lower_bound(time.begin(), time.end(), Tab().ViewMin()) - time.begin()); if (begin > 0) { --begin; } end = static_cast( std::lower_bound(time.begin(), time.end(), Tab().ViewMax()) - time.begin()); if (end < count) { ++end; } end = std::max(end, begin); begin = std::min(begin, count); end = std::min(end, count); } const std::vector* PlotView::CurveSpanFor(DerivedCurveKind kind, const SignalData& signal, int points, int iterations, int levelPercent, std::size_t begin, std::size_t end, std::size_t& spanBegin, bool allowAsyncCompute) const { spanBegin = 0; const std::size_t count = signal.values.size(); if (count == 0) { return nullptr; } begin = std::min(begin, count); end = std::min(std::max(end, begin), count); if (end <= begin) { return nullptr; } const int kindValue = static_cast(kind); const int safePoints = std::max(0, std::min(points, 1000000)); const int safeIterations = std::max(1, std::min(iterations, 1000)); const int safeLevel = std::max(0, std::min(100, levelPercent)); // 对齐到粗网格:轻微平移/缩放时可直接复用同一条窗口,避免每帧重算。 const std::size_t grid = std::max(4096, (end - begin) / 4); const std::size_t alignedBegin = (begin / grid) * grid; const std::size_t alignedEnd = std::min(count, ((end + grid - 1) / grid) * grid); CurveKey key; key.signal = &signal; key.kind = kindValue; key.points = safePoints; key.iterations = safeIterations; key.levelPercent = safeLevel; // 1) 已有覆盖请求区间的最新条目 → 直接复用(钉住,防止本帧被回收)。 if (const CurveEntry* hit = AcquireCoveringCurve(key, begin, end, spanBegin)) { return &hit->values; } // 2) 未就绪且窗口很大:预览曲线交后台计算,本帧先用最新旧曲线顶替(双缓冲)。 // 这样绘制线程永远不做重计算,拖动滑块/光标/视界/缩放的反馈因此始终跟手。 if (allowAsyncCompute && (end - begin) > kDerivedAsyncWindowThreshold) { ScheduleCurveJob(key, alignedBegin, alignedEnd, count); if (const CurveEntry* stale = AcquireNewestCurve(key, spanBegin)) { return &stale->values; } return nullptr; // 尚无任何可显示曲线:本帧跳过该预览曲线(不阻塞绘制) } // 3) 窗口较小或需要精确取值 → 同步计算并发布(代价可接受,且保证取值精确)。 std::size_t computedBegin = 0; std::size_t computedEnd = 0; DerivedGuardWindow(kind, alignedBegin, alignedEnd, safePoints, safeIterations, count, computedBegin, computedEnd); if (computedEnd <= computedBegin) { return nullptr; } std::vector values = ComputeDerivedWindow(signal, computedBegin, computedEnd, kindValue, safePoints, safeIterations, safeLevel); const CurveEntry* published = PublishCurve(key, computedBegin, computedEnd, std::move(values), spanBegin); return (published != nullptr) ? &published->values : nullptr; } void PlotView::DerivedGuardWindow(DerivedCurveKind kind, std::size_t alignedBegin, std::size_t alignedEnd, int points, int iterations, std::size_t count, std::size_t& begin, std::size_t& end) const { // 保护带取窗口长度的 1/8(夹在 4096~65536):弱波增强的修正沿相邻极值向内传播, // 1/8 个可见宽度足以覆盖真实信号的极值间距。 // 均值滤波必须额外**覆盖滤波半径**:N 阶共向外传播 X*N 个采样,否则窗口边界取值 // 会与「整条曲线」不同(边界上可用采样数更少)。 std::size_t guard = std::clamp((alignedEnd - alignedBegin) / 8, std::size_t(4096), kDerivedCurveGuardMax); if (kind == DerivedCurveKind::MEAN_FILTER) { const std::size_t reach = static_cast(points + 1) * static_cast(iterations) + 64u; guard = std::clamp(std::max(guard, reach), std::size_t(4096), kDerivedCurveGuardMax); } begin = (alignedBegin > guard) ? (alignedBegin - guard) : 0; end = std::min(count, alignedEnd + guard); } const PlotView::CurveEntry* PlotView::AcquireCoveringCurve(const CurveKey& key, std::size_t begin, std::size_t end, std::size_t& spanBegin) const { std::lock_guard lock(m_curveMutex); const CurveEntryPtr* best = nullptr; for (const CurveEntryPtr& entry : m_curveEntries) { if (entry->key == key && entry->begin <= begin && entry->end >= end) { if (best == nullptr || entry->serial > (*best)->serial) { best = &entry; } } } if (best == nullptr) { return nullptr; } m_curvePins.push_back(*best); // 钉住:保证本帧内指针有效 spanBegin = (*best)->begin; return best->get(); } const PlotView::CurveEntry* PlotView::AcquireNewestCurve(const CurveKey& key, std::size_t& spanBegin) const { std::lock_guard lock(m_curveMutex); const CurveEntryPtr* newest = nullptr; for (const CurveEntryPtr& entry : m_curveEntries) { if (entry->key == key) { if (newest == nullptr || entry->serial > (*newest)->serial) { newest = &entry; } } } if (newest == nullptr) { return nullptr; } m_curvePins.push_back(*newest); spanBegin = (*newest)->begin; return newest->get(); } const PlotView::CurveEntry* PlotView::PublishCurve(const CurveKey& key, std::size_t begin, std::size_t end, std::vector&& values, std::size_t& spanBegin) const { std::shared_ptr entry = std::make_shared(); entry->key = key; entry->begin = begin; entry->end = end; entry->values = std::move(values); { std::lock_guard lock(m_curveMutex); entry->serial = ++m_curveSerial; m_curveEntries.push_back(entry); } m_curvePins.push_back(entry); spanBegin = begin; return entry.get(); } void PlotView::ScheduleCurveJob(const CurveKey& key, std::size_t alignedBegin, std::size_t alignedEnd, std::size_t count) const { std::size_t windowBegin = 0; std::size_t windowEnd = 0; DerivedGuardWindow(static_cast(key.kind), alignedBegin, alignedEnd, key.points, key.iterations, count, windowBegin, windowEnd); if (windowEnd <= windowBegin) { return; } { std::lock_guard lock(m_curveMutex); for (const CurveJob& job : m_curveJobs) { if (job.key == key) { return; // 该曲线已有在算任务,避免任务堆积 } } CurveJob job; job.key = key; job.windowBegin = windowBegin; job.windowEnd = windowEnd; job.generation = m_curveGeneration; m_curveJobs.push_back(job); } EnsureCurveWorker(); m_curveCv.notify_one(); } void PlotView::EnsureCurveWorker() const { if (!m_curveWorkers.empty()) { return; } // **多核并行**:后台线程池并发计算多条派生曲线(5 个网格各一条时, // 串行要 5×368ms,并行只要约 1×368ms),预览曲线因此能很快出现。 // 线程数 = 硬件并发 - 1(给 UI/绘制线程留一个核),夹在 1~4。 unsigned int hardware = std::thread::hardware_concurrency(); int workers = static_cast(hardware == 0 ? 2u : hardware) - 1; workers = std::max(1, std::min(workers, 4)); m_curveWorkerStop = false; m_curveWorkers.reserve(static_cast(workers)); for (int index = 0; index < workers; ++index) { m_curveWorkers.emplace_back([this]() { CurveWorkerLoop(); }); } } void PlotView::StopCurveWorker() const { if (m_curveWorkers.empty()) { return; } { std::lock_guard lock(m_curveMutex); m_curveWorkerStop = true; m_curveJobs.clear(); } m_curveCv.notify_all(); for (std::thread& worker : m_curveWorkers) { if (worker.joinable()) { worker.join(); } } m_curveWorkers.clear(); } void PlotView::CurveWorkerLoop() const { // 后台计算线程的优先级降到「低于正常」:派生曲线计算是**可延迟的后台工作**, // 绝不能让它在拖动时与 UI / 绘制线程抢 CPU。降低优先级后,只要 UI 线程要运行, // 系统就会优先调度 UI;计算只在 UI 空闲时占用 CPU,从而保证交互始终跟手 //(用户明确要求"无论如何 UI 及控件的响应需要是跟手的")。 SetThreadPriority(GetCurrentThread(), THREAD_PRIORITY_BELOW_NORMAL); for (;;) { CurveJob job; { std::unique_lock lock(m_curveMutex); m_curveCv.wait(lock, [this]() { return m_curveWorkerStop || !m_curveJobs.empty(); }); if (m_curveJobs.empty()) { return; // 仅在 stop 且队列为空时到达 } job = m_curveJobs.front(); m_curveJobs.pop_front(); if (job.generation != m_curveGeneration) { continue; // 代次已变化:丢弃过期任务 } } // 计算在锁外进行:读取的采样在「结构未变」期间只读、且地址稳定 //(结构变化前宿主会先 `PauseDerivedAsync`)。 std::vector values = ComputeDerivedWindow(*job.key.signal, job.windowBegin, job.windowEnd, job.key.kind, job.key.points, job.key.iterations, job.key.levelPercent); { std::lock_guard lock(m_curveMutex); if (job.generation != m_curveGeneration) { continue; // 计算期间发生了结构变化:丢弃,避免发布过期结果 } std::shared_ptr entry = std::make_shared(); entry->key = job.key; entry->begin = job.windowBegin; entry->end = job.windowEnd; entry->serial = ++m_curveSerial; entry->values = std::move(values); m_curveEntries.push_back(std::move(entry)); } // 通知宿主重绘:下一帧会取到刚发布的曲线(双缓冲切换)。 m_curveSceneDirty.store(true, std::memory_order_relaxed); const HWND host = m_host; if (host != nullptr) { PostMessageW(host, kMessagePlotDerivedReady, 0, 0); } } } void PlotView::PruneCurveEntriesLocked() const { // 1) 去掉被「同键、更新、且完全覆盖」的条目取代的旧条目。 std::deque kept; for (const CurveEntryPtr& entry : m_curveEntries) { bool superseded = false; for (const CurveEntryPtr& other : m_curveEntries) { if (other.get() == entry.get()) { continue; } if (other->key == entry->key && other->serial > entry->serial && other->begin <= entry->begin && other->end >= entry->end) { superseded = true; break; } } if (!superseded) { kept.push_back(entry); } } m_curveEntries.swap(kept); // 2) 条目数与总字节数双重约束(最旧优先淘汰)。 const auto bytes = [this]() -> std::size_t { std::size_t total = 0; for (const CurveEntryPtr& entry : m_curveEntries) { total += entry->values.size() * sizeof(float); } return total; }; while (!m_curveEntries.empty() && (m_curveEntries.size() > kCurveSpanCacheLimit || bytes() > kCurveSpanCacheMaxBytes)) { m_curveEntries.pop_front(); } } const std::vector& PlotView::MeanFilterCurveFor(const SignalData& signal, int points, int iterations) const { const int radius = std::max(0, points); const int stages = std::max(1, iterations); for (const MeanCurveEntry& entry : m_meanCurveCache) { if (entry.signal == &signal && entry.points == radius && entry.iterations == stages) { return entry.values; } } MeanCurveEntry entry; entry.signal = &signal; entry.points = radius; entry.iterations = stages; // 与「构建为新信号」共用同一实现(Utilities 内的自由函数), // 因此预览曲线与构建出的采样值逐位一致。 ComputeMovingAverageIterated(signal.values, radius, stages, entry.values); m_meanCurveCache.push_back(std::move(entry)); return m_meanCurveCache.back().values; } const std::vector& PlotView::WeakBoostCurveFor(const SignalData& signal, int levelPercent, int iterations) const { const int level = std::max(0, std::min(100, levelPercent)); const int stages = std::max(kWeakBoostIterationsMin, std::min(kWeakBoostIterationsMax, iterations)); for (const WeakBoostCurveEntry& entry : m_weakBoostCurveCache) { if (entry.signal == &signal && entry.levelPercent == level && entry.iterations == stages) { return entry.values; } } WeakBoostCurveEntry entry; entry.signal = &signal; entry.levelPercent = level; entry.iterations = stages; // 与「构建为新信号」共用同一实现(Utilities 内的自由函数), // 因此预览曲线与构建出的采样值逐位一致。 ComputeWeakWaveBoost(signal.values, static_cast(level) / 100.0, stages, entry.values); m_weakBoostCurveCache.push_back(std::move(entry)); return m_weakBoostCurveCache.back().values; } bool PlotView::WeakBoostValueAt(const DataFile& file, const SignalData& signal, int levelPercent, int iterations, double time, double& value) const { const int index = FindNearestSample(file, time); if (index < 0) { return false; } std::size_t spanBegin = 0; const std::vector* curve = CurveSpanFor( DerivedCurveKind::WEAK_BOOST, signal, 0, iterations, levelPercent, static_cast(index), static_cast(index) + 1, spanBegin); if (curve == nullptr || static_cast(index) < spanBegin || (static_cast(index) - spanBegin) >= curve->size()) { return false; } const float sampled = (*curve)[static_cast(index) - spanBegin]; if (!std::isfinite(sampled)) { return false; } value = static_cast(sampled); return true; } void PlotView::EdgeCenterCountsFor(const DataFile& file, const SignalData& signal, int points, int iterations, bool hasRange, double rangeMin, double rangeMax, std::size_t& rising, std::size_t& falling) const { rising = 0; falling = 0; const int radius = std::max(0, points); const int stages = std::max(1, iterations); const std::size_t count = signal.values.size(); std::size_t begin = 0; std::size_t end = 0; const bool indexed = EdgeIndexRange(file, count, hasRange, rangeMin, rangeMax, begin, end); // 按「文件 + 信号 + 均值点数 X + 迭代次数 N + 是否限定区间」定位缓存项 //(**时间区间不进缓存键**)。 EdgeCenterCountEntry* entry = nullptr; for (EdgeCenterCountEntry& candidate : m_edgeCenterCountCache) { if (candidate.file == &file && candidate.signal == &signal && candidate.hasRange == hasRange && candidate.points == radius && candidate.iterations == stages) { entry = &candidate; break; } } if (entry == nullptr) { if (m_edgeCenterCountCache.size() >= kEdgeCountCacheLimit) { m_edgeCenterCountCache.erase(m_edgeCenterCountCache.begin()); } EdgeCenterCountEntry created; created.file = &file; created.signal = &signal; created.points = radius; created.iterations = stages; created.hasRange = hasRange; m_edgeCenterCountCache.push_back(created); entry = &m_edgeCenterCountCache.back(); } // 区间完全一致 → 直接命中。**必须放在请求曲线之前**:面板每帧都会调用本函数, // 而「没有限定光标区间」时的统计区间就是整条信号 —— 若先请求曲线, // 每一帧都会去取(并可能重算)整条曲线,实测单帧 30ms 以上。 if (indexed && entry->reusable && entry->begin == begin && entry->end == end) { rising = entry->rising; falling = entry->falling; return; } // 与上一帧共享一个端点(拖动光标时必然如此)→ 只扫描两端差集并增量加减。 const bool shareBegin = indexed && entry->reusable && entry->begin == begin; const bool shareEnd = indexed && entry->reusable && entry->end == end; const std::size_t oldBegin = entry->begin; const std::size_t oldEnd = entry->end; // 先确定需要比较的「相邻采样对」下标区间 [pairFirst, pairLast](比较 index-1 与 index), // 再据此只请求**必需的那一小段**曲线窗口(增量路径下通常只有几十个采样)。 std::size_t pairFirst = 0; std::size_t pairLast = 0; int mode = 0; // 0 = 全量重算,1 = 仅尾部差集,2 = 仅头部差集 if (shareBegin && !shareEnd) { const std::size_t low = std::min(oldEnd, end); const std::size_t high = std::max(oldEnd, end); pairFirst = std::max(1, low); pairLast = (high > 0) ? (high - 1) : 0; mode = 1; } else if (shareEnd && !shareBegin) { const std::size_t low = std::min(oldBegin, begin); const std::size_t high = std::max(oldBegin, begin); pairFirst = low + 1; pairLast = high; mode = 2; } else { pairFirst = (end > begin) ? (begin + 1) : 1; pairLast = (end > begin) ? (end - 1) : 0; mode = 0; } // 需要的偏差下标区间 = [pairFirst - 1, pairLast];请求曲线窗口需包含它。 const std::size_t needFirst = (pairFirst > 0) ? (pairFirst - 1) : 0; const std::size_t needEnd = std::min(count, pairLast + 1); if (needEnd <= needFirst) { entry->reusable = indexed; entry->begin = begin; entry->end = end; entry->rising = 0; entry->falling = 0; return; } // 与「实验性功能 → 均值滤波」共用同一条曲线(含缓存), // 但**只取必需区间 ± 保护带**的窗口。 std::size_t spanBegin = 0; const std::vector* curvePtr = CurveSpanFor( DerivedCurveKind::MEAN_FILTER, signal, radius, stages, 0, needFirst, needEnd, spanBegin); if (curvePtr == nullptr) { return; } const std::vector& curve = *curvePtr; // 判定基准:信号与中心平均值曲线之差(区间之外的采样返回 NaN)。 const auto deltaAt = [&signal, &file, &curve, spanBegin, count, hasRange, rangeMin, rangeMax](std::size_t index) -> double { if (index >= count) { return std::numeric_limits::quiet_NaN(); } if (index < spanBegin || (index - spanBegin) >= curve.size()) { return std::numeric_limits::quiet_NaN(); } const double value = signal.values[index]; const float center = curve[index - spanBegin]; if (!std::isfinite(value) || !std::isfinite(center)) { return std::numeric_limits::quiet_NaN(); } if (hasRange && !file.timeMonotonic && index < file.time.size()) { const double time = file.time[index]; if (time < rangeMin || time > rangeMax) { return std::numeric_limits::quiet_NaN(); } } return value - static_cast(center); }; std::size_t deltaRising = 0; std::size_t deltaFalling = 0; if (mode == 1) { AccumulateEdgeCrossings(pairFirst, pairLast, deltaAt, deltaRising, deltaFalling); rising = (end > oldEnd) ? (entry->rising + deltaRising) : (entry->rising - deltaRising); falling = (end > oldEnd) ? (entry->falling + deltaFalling) : (entry->falling - deltaFalling); } else if (mode == 2) { AccumulateEdgeCrossings(pairFirst, pairLast, deltaAt, deltaRising, deltaFalling); rising = (begin < oldBegin) ? (entry->rising + deltaRising) : (entry->rising - deltaRising); falling = (begin < oldBegin) ? (entry->falling + deltaFalling) : (entry->falling - deltaFalling); } else { AccumulateEdgeCrossings(pairFirst, pairLast, deltaAt, rising, falling); } entry->reusable = indexed; entry->begin = begin; entry->end = end; entry->rising = rising; entry->falling = falling; } void PlotView::EdgeCountsFor(const DataFile& file, const SignalData& signal, double edgeValue, bool hasRange, double rangeMin, double rangeMax, std::size_t& rising, std::size_t& falling) const { rising = 0; falling = 0; const std::vector& values = signal.values; std::size_t begin = 0; std::size_t end = 0; const bool indexed = EdgeIndexRange(file, values.size(), hasRange, rangeMin, rangeMax, begin, end); // 判定基准:采样值相对阈值的偏差。区间之外的采样返回 NaN(等价于原实现 // 「区间外采样不参与判定且打断连续」的语义)。 const auto deltaAt = [&values, &file, hasRange, rangeMin, rangeMax](std::size_t index) -> double { const double value = values[index]; if (!std::isfinite(value)) { return std::numeric_limits::quiet_NaN(); } if (hasRange && !file.timeMonotonic && index < file.time.size()) { const double time = file.time[index]; if (time < rangeMin || time > rangeMax) { return std::numeric_limits::quiet_NaN(); } } return value; }; // 按「文件 + 信号 + 阈值 + 是否限定区间」定位缓存项(**时间区间不进缓存键**)。 EdgeCountEntry* entry = nullptr; for (EdgeCountEntry& candidate : m_edgeCountCache) { if (candidate.file == &file && candidate.signal == &signal && candidate.hasRange == hasRange && candidate.edgeValue == edgeValue) { entry = &candidate; break; } } if (entry == nullptr) { if (m_edgeCountCache.size() >= kEdgeCountCacheLimit) { m_edgeCountCache.erase(m_edgeCountCache.begin()); } EdgeCountEntry created; created.file = &file; created.signal = &signal; created.edgeValue = edgeValue; created.hasRange = hasRange; m_edgeCountCache.push_back(created); entry = &m_edgeCountCache.back(); } // 阈值参与判定,因此仿函数要在累加时一并带入。 const auto crossingAt = [&deltaAt, edgeValue](std::size_t index) -> double { return deltaAt(index) - edgeValue; }; // 区间完全一致 → 直接命中。 if (indexed && entry->reusable && entry->begin == begin && entry->end == end) { rising = entry->rising; falling = entry->falling; return; } // 与上一帧共享一个端点(拖动光标时必然如此)→ 只扫描两端差集并增量加减。 const bool shareBegin = indexed && entry->reusable && entry->begin == begin; const bool shareEnd = indexed && entry->reusable && entry->end == end; const std::size_t oldBegin = entry->begin; const std::size_t oldEnd = entry->end; std::size_t deltaRising = 0; std::size_t deltaFalling = 0; if (shareBegin && !shareEnd) { // 右端移动:变动的是采样对 k ∈ [min(oldEnd, end), max(oldEnd, end) - 1]。 const std::size_t low = std::min(oldEnd, end); const std::size_t high = std::max(oldEnd, end); AccumulateEdgeCrossings(std::max(1, low), high - 1, crossingAt, deltaRising, deltaFalling); rising = (end > oldEnd) ? (entry->rising + deltaRising) : (entry->rising - deltaRising); falling = (end > oldEnd) ? (entry->falling + deltaFalling) : (entry->falling - deltaFalling); } else if (shareEnd && !shareBegin) { // 左端移动:变动的是采样对 k ∈ [min(oldBegin, begin) + 1, max(oldBegin, begin)]。 const std::size_t low = std::min(oldBegin, begin); const std::size_t high = std::max(oldBegin, begin); AccumulateEdgeCrossings(low + 1, high, crossingAt, deltaRising, deltaFalling); rising = (begin < oldBegin) ? (entry->rising + deltaRising) : (entry->rising - deltaRising); falling = (begin < oldBegin) ? (entry->falling + deltaFalling) : (entry->falling - deltaFalling); } else { // 两端都变了(或首次统计):整段重算。 const std::size_t pairFirst = (end > begin) ? (begin + 1) : 1; const std::size_t pairLast = (end > begin) ? (end - 1) : 0; AccumulateEdgeCrossings(pairFirst, pairLast, crossingAt, rising, falling); } entry->reusable = indexed; entry->begin = begin; entry->end = end; entry->rising = rising; entry->falling = falling; } float PlotView::EdgePanelWidth(int laneIndex, EdgePanelKind kind) const { if (m_tab == nullptr || m_fonts == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return kEdgePanelWidth * m_scale; } const Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; const bool centerAxis = (kind == EdgePanelKind::CENTER); const bool meanFilter = (kind == EdgePanelKind::MEAN); const bool weakBoost = (kind == EdgePanelKind::BOOST); // 「均值滤波」「中心轴边沿计数」「弱波增强」都是逐条目面板(名称 + 复选框)。 const bool itemized = (centerAxis || meanFilter || weakBoost); // 标题行 = “GX” 徽标 + 标题(“边沿计数” / “中心轴边沿计数” / “均值滤波” / “弱波增强” // + 光标区间尾缀)。 const std::wstring title = meanFilter ? std::wstring(L"均值滤波") : (weakBoost ? std::wstring(kWeakBoostTitle) : ((centerAxis ? L"中心轴边沿计数" : L"边沿计数") + EdgeRangeSuffix())); float width = kEdgePanelPad * 2.0f * m_scale + kLaneBadgeWidth * m_scale + 8.0f * m_scale + UiMeasureTextWidth(m_fonts->uiSmall, title) + kEdgePanelTitleHeight * m_scale; // 数据行 = 名称与“上升沿 N / 下降沿 M”两行,取较宽者。 // 用 UiMeasureTextWidth(无省略号)测量,保证数字完整显示。 for (const SignalRef& ref : lane.signals) { const ResolvedSignal resolved = ResolveSignal(ref); if (resolved.data == nullptr || !resolved.visible) { continue; } const float nameWidth = UiMeasureTextWidth(m_fonts->uiSmall, resolved.data->Title()); if (itemized) { // 逐条目面板:第一排 = 名称 + 复选框;第三排(中心轴)= 统计结果。 width = std::max(width, kEdgePanelPad * 2.0f * m_scale + nameWidth + kEdgePanelMeanCheckSize * m_scale + 6.0f * m_scale); } std::size_t rising = 0; std::size_t falling = 0; if (meanFilter || weakBoost) { continue; } if (centerAxis) { const Lane::EdgeCenterItem* item = lane.FindEdgeCenterItem(ref); if (item == nullptr) { continue; } EdgeCenterCountsFor(*resolved.file, *resolved.data, item->points, item->iterations, EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), rising, falling); } else { EdgeCountsFor(*resolved.file, *resolved.data, lane.edgeValue, EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), rising, falling); } const float countWidth = UiMeasureTextWidth(m_fonts->monoSmall, L"上升沿 " + std::to_wstring(rising) + L" 下降沿 " + std::to_wstring(falling)); width = std::max(width, kEdgePanelPad * 2.0f * m_scale + std::max(nameWidth, countWidth)); } if (centerAxis || meanFilter) { // 参数行 = 两个「说明文字 + 数值框」,必须保证文字不被数值框压住。 // (「均值点数 X」/「迭代次数 N」的布局对两个逐条目面板通用:标签与数值框各占半行。) const float labelX = UiMeasureTextWidth(m_fonts->uiSmall, L"均值点数 X"); const float labelN = UiMeasureTextWidth(m_fonts->uiSmall, L"迭代次数 N"); const float fieldWidth = std::max(kEdgePanelEditFieldWidth * m_scale * 0.75f, UiMeasureTextWidth(m_fonts->monoSmall, L"1000000") + 18.0f * m_scale); const float halfWidth = std::max(labelX, labelN) + 6.0f * m_scale + fieldWidth; const float rowWidth = halfWidth * 2.0f + 8.0f * m_scale; width = std::max(width, kEdgePanelPad * 2.0f * m_scale + rowWidth); } if (weakBoost) { // 两个滑块行(等级 / 迭代次数 N)= 「说明文字 + 轨道 + 数值」。 const float labelWidth = std::max(UiMeasureTextWidth(m_fonts->uiSmall, kWeakBoostLevelLabel), UiMeasureTextWidth(m_fonts->uiSmall, kWeakBoostIterationsLabel)); const float valueWidth = std::max(UiMeasureTextWidth(m_fonts->monoSmall, L"100%"), UiMeasureTextWidth(m_fonts->monoSmall, L"10")); const float rowWidth = labelWidth + 6.0f * m_scale + kWeakBoostTrackMinWidth * m_scale + 6.0f * m_scale + valueWidth; width = std::max(width, kEdgePanelPad * 2.0f * m_scale + rowWidth); } if (meanFilter || weakBoost) { // 底部按钮行 = 「构建为新信号」,按按钮文字实测宽度确定下限。 const float buttonWidth = UiMeasureTextWidth(m_fonts->uiSmall, kBuildSignalLabel) + 28.0f * m_scale; width = std::max(width, kEdgePanelPad * 2.0f * m_scale + buttonWidth); } // 夹在绘图区宽度之内(过窄的网格也不至于把面板挤到外面)。 return std::min(width, std::max(120.0f * m_scale, m_plotRect.Width - 8.0f * m_scale)); } bool PlotView::EdgeRangeActive() const { return m_tab != nullptr && Tab().CursorMode() == PlotCursorMode::DOUBLE; } double PlotView::EdgeRangeMin() const { if (m_tab == nullptr) { return 0.0; } // 左右光标可能被拖动交换:始终取较小者为区间左端。 return std::min(Tab().CursorTime(), Tab().CursorTimeAlt()); } double PlotView::EdgeRangeMax() const { if (m_tab == nullptr) { return 0.0; } return std::max(Tab().CursorTime(), Tab().CursorTimeAlt()); } std::wstring PlotView::EdgeRangeSuffix() const { return EdgeRangeActive() ? std::wstring(L"(光标区间内)") : std::wstring(); } bool PlotView::ToggleLaneAutoFitToSelection(int laneIndex) { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return false; } Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; lane.fitYToSelection = !lane.fitYToSelection; if (lane.fitYToSelection) { // 打开时立即按当前视图自适应一次,否则当前量程不合适时看不到任何变化。 AutoRangeLaneToView(laneIndex); } return lane.fitYToSelection; } void PlotView::ApplyAutoFitToSelectionNow(int laneIndex) { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return; } if (!m_tab->Lanes()[static_cast(laneIndex)].fitYToSelection) { return; } AutoRangeLaneToView(laneIndex); } void PlotView::ApplyAllAutoFitToSelectionNow() { if (m_tab == nullptr) { return; } const std::size_t count = m_tab->Lanes().size(); for (std::size_t index = 0; index < count; ++index) { ApplyAutoFitToSelectionNow(static_cast(index)); } } bool PlotView::SetAllLanesAutoFitToSelection(bool enabled, bool applyNow) { if (m_tab == nullptr) { return false; } bool changed = false; for (Lane& lane : m_tab->Lanes()) { if (lane.fitYToSelection != enabled) { lane.fitYToSelection = enabled; changed = true; } } if (enabled && applyNow) { // 全局打开时立刻让每个网格都自适应一次,保证界面立刻反映开关状态。 ApplyAllAutoFitToSelectionNow(); } return changed; } bool PlotView::AutoRangeLaneToView(int laneIndex) { if (m_tab == nullptr || m_dataSet == nullptr) { return false; } const std::vector& lanes = m_tab->Lanes(); if (laneIndex < 0 || laneIndex >= static_cast(lanes.size())) { return false; } const double viewMin = Tab().ViewMin(); const double viewMax = Tab().ViewMax(); if (!(viewMax > viewMin)) { return false; } // 只统计时间轴当前可见区间内的采样点,因此放大局部波形后可以快速把该段铺满网格。 double low = std::numeric_limits::infinity(); double high = -std::numeric_limits::infinity(); for (const SignalRef& ref : lanes[static_cast(laneIndex)].signals) { if (!Tab().IsVisible(ref)) { continue; } const SignalData* signal = m_dataSet->Resolve(ref); const DataFile* file = m_dataSet->FileAt(ref.fileIndex); if (signal == nullptr || file == nullptr) { continue; } const std::size_t count = std::min(file->time.size(), signal->values.size()); std::size_t begin = 0; std::size_t end = count; if (file->timeMonotonic) { // 时间单调时直接二分定位区间,百万级采样也只需扫过可见段。 begin = static_cast( std::lower_bound(file->time.begin(), file->time.end(), viewMin) - file->time.begin()); if (begin > 0) { --begin; } end = static_cast( std::lower_bound(file->time.begin(), file->time.end(), viewMax) - file->time.begin()); if (end < count) { ++end; } } for (std::size_t index = begin; index < end; ++index) { if (!file->timeMonotonic) { const double t = file->time[index]; if (!std::isfinite(t) || t < viewMin || t > viewMax) { continue; } } const double value = signal->values[index]; if (std::isfinite(value)) { low = std::min(low, value); high = std::max(high, value); } } } if (!std::isfinite(low) || !std::isfinite(high)) { return false; } // 与自动自适应保持一致的上下留白,避免波形顶到网格边界。 if (high > low) { const double pad = (high - low) * 0.05; low -= pad; high += pad; } else { const double pad = std::max(1.0e-9, std::fabs(low) * 0.05); low -= pad; high += pad; } Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; lane.autoRange = false; lane.yMin = low; lane.yMax = high; BuildLaneVisuals(); return true; } void PlotView::ApplySelectionAutoFit() { if (m_tab == nullptr || m_dataSet == nullptr) { return; } const std::vector& lanes = m_tab->Lanes(); bool any = false; for (const Lane& lane : lanes) { if (lane.fitYToSelection) { any = true; break; } } if (!any) { return; } // 只对开启了「选区 Y 轴自适应」的网格重新匹配 Y 轴; // 重新匹配依据的是刚刚更新过的时间视图范围。 for (std::size_t index = 0; index < lanes.size(); ++index) { if (m_tab->Lanes()[index].fitYToSelection) { AutoRangeLaneToView(static_cast(index)); } } } void PlotView::ShiftLaneRange(int laneIndex, double deltaValue) { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_lanes.size()) || !std::isfinite(deltaValue) || deltaValue == 0.0) { return; } // 首次纵向拖动时先把当前自适应范围固化下来,再进行偏移。 const LaneVisual& visual = m_lanes[static_cast(laneIndex)]; Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; // 手动纵向平移属于手动调整幅值:关闭本网格的「选区 Y 轴自适应」。 lane.fitYToSelection = false; if (lane.autoRange) { lane.autoRange = false; lane.yMin = visual.yMin; lane.yMax = visual.yMax; } lane.yMin += deltaValue; lane.yMax += deltaValue; BuildLaneVisuals(); } bool PlotView::ApplyLaneValueText(int laneIndex, bool editMax, const std::wstring& text) { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return false; } if (m_lanes.empty()) { BuildLaneVisuals(); } if (laneIndex >= static_cast(m_lanes.size())) { return false; } const std::wstring trimmed = Trim(text); if (trimmed.empty()) { return false; } wchar_t* stop = nullptr; const double parsed = std::wcstod(trimmed.c_str(), &stop); if (stop == trimmed.c_str() || !std::isfinite(parsed)) { return false; } const LaneVisual& visual = m_lanes[static_cast(laneIndex)]; const double span = visual.yMax - visual.yMin; const double minimumGap = (span > 0.0) ? span * 0.01 : std::max(1.0e-9, std::fabs(parsed) * 0.01); Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; // 手工输入即视为取消自动自适应,与拖拽框选缩放的语义一致。 // 必须先把当前(自适应计算出的)显示范围固化到 lane 里,再只改动被编辑的一端, // 否则另一端会回退到 Lane 的初始占位值,导致量程被莫名其妙地放大。 lane.fitYToSelection = false; if (lane.autoRange) { lane.autoRange = false; lane.yMin = visual.yMin; lane.yMax = visual.yMax; } if (editMax) { lane.yMax = parsed; if (!(lane.yMax > lane.yMin)) { lane.yMin = lane.yMax - minimumGap; } } else { lane.yMin = parsed; if (!(lane.yMax > lane.yMin)) { lane.yMax = lane.yMin + minimumGap; } } BuildLaneVisuals(); return true; } bool PlotView::LaneRange(int laneIndex, double& yMin, double& yMax) const { if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) { // 菜单弹出时几何可能尚未建立,回退到配置内的自适应范围。 if (m_tab == nullptr || m_dataSet == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return false; } m_tab->ComputeLaneRange(*m_dataSet, laneIndex, yMin, yMax); return true; } const LaneVisual& visual = m_lanes[static_cast(laneIndex)]; yMin = visual.yMin; yMax = visual.yMax; return true; } SignalRef PlotView::HitTestSignal(int x, int y) const { SignalRef empty; if (m_tab == nullptr || m_dataSet == nullptr || !HasDrawableContent()) { return empty; } if (!UiPointInRect(m_plotRect, static_cast(x), static_cast(y))) { return empty; } const int laneIndex = LaneAtY(y); if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) { return empty; } const LaneVisual& lane = m_lanes[static_cast(laneIndex)]; const float tolerance = kSignalHitTolerance * m_scale; const float fx = static_cast(x); const float fy = static_cast(y); SignalRef best; float bestDistance = tolerance; // 只为当前网格内的可见信号做精确的点到线段距离检测,避免跨网格误命中。 for (const SignalRef& ref : m_tab->Lanes()[static_cast(laneIndex)].signals) { const ResolvedSignal resolved = ResolveSignal(ref); if (resolved.data == nullptr || resolved.style == nullptr || !resolved.visible) { continue; } // 复用绘制阶段的折线顶点:悬停检测通常与上一帧几何完全相同, // 不再为“鼠标移动但命中结果未变”重复扫描全量采样点。 const PolylineCacheEntry* cached = AcquirePolyline(ref, resolved, lane); if (cached == nullptr) { continue; } const std::vector& points = cached->points; const std::vector& breaks = cached->breaks; if (points.size() < 2) { continue; } // 横向先行裁剪:点到线段的距离必然不小于横向距离本身, // 因此只有横向落在容差范围内的顶点才可能命中。 // 时间单调时折线顶点按 x 递增,可二分定位后只扫描这一小段; // 否则每次鼠标移动都要遍历上万个顶点(悬停卡顿的来源之一)。 std::size_t windowFirst = 0; std::size_t windowLast = points.size(); if (resolved.file->timeMonotonic) { const auto byX = [](const gd::PointF& point, float limit) { return point.X < limit; }; windowFirst = static_cast( std::lower_bound(points.begin(), points.end(), fx - tolerance, byX) - points.begin()); windowLast = static_cast( std::upper_bound(points.begin(), points.end(), fx + tolerance, [](float limit, const gd::PointF& point) { return limit < point.X; }) - points.begin()); } if (windowFirst >= windowLast) { continue; } // 散点模式下把采样点本身也视为可命中目标。 if (resolved.style->mode == DrawMode::SCATTER) { for (std::size_t index = windowFirst; index < windowLast; ++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); const std::size_t scanEnd = std::min(stop, windowLast); 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) { // 「均值点数 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(m_lanes.size())) { return gd::RectF(); } const LaneVisual& lane = m_lanes[static_cast(m_editLane)]; const float left = lane.rect.X + kLaneLabelPad * m_scale; const float top = lane.rect.Y + kLaneLabelPad * m_scale; return gd::RectF(left, top, std::min(width, lane.rect.Width - 2.0f * kLaneLabelPad * m_scale), height); } bool PlotView::BeginEdit(PlotEditTarget target, int laneIndex, const std::wstring& initialText, const gd::RectF& anchorRect) { if (target == PlotEditTarget::NONE) { return false; } // 切换编辑目标时清掉上一轮残留(图例改名需由调用方在进入前先设置 m_editSignal)。 if (target != PlotEditTarget::SIGNAL_NAME) { m_editSignal = SignalRef(); } // 调用方可以用锚点矩形指定输入框位置(如菜单条目、图例条目); // 未提供锚点时由 EditFieldRect() 按目标类型自动推导。 if (anchorRect.Width > 0.0f && anchorRect.Height > 0.0f) { m_editFieldRect = anchorRect; } else if (target != PlotEditTarget::SIGNAL_NAME) { m_editFieldRect = gd::RectF(); } m_editTarget = target; m_editLane = laneIndex; // 名称与数值均不限长度(数值在提交时会做范围夹取)。 const bool nameField = (target == PlotEditTarget::SIGNAL_NAME); m_edit.Begin(initialText, true, nameField ? EditFilter::ANY : EditFilter::NUMBER, 0u); return true; } bool PlotView::TakePendingRename(SignalRef& ref, std::wstring& name) { if (!m_pendingRename) { return false; } ref = m_pendingRenameRef; name = m_pendingRenameName; m_pendingRename = false; m_pendingRenameRef = SignalRef(); m_pendingRenameName.clear(); return true; } bool PlotView::EndEdit(bool commit) { if (m_editTarget == PlotEditTarget::NONE) { return false; } const PlotEditTarget target = m_editTarget; const int laneIndex = m_editLane; const SignalRef editSignal = m_editSignal; const std::wstring text = Trim(m_edit.Text()); m_editTarget = PlotEditTarget::NONE; m_editLane = -1; m_editSignal = SignalRef(); m_editFieldRect = gd::RectF(); m_edit.End(); // 信号改名:交给宿主窗口落到数据层,绘图区只持有只读数据。 if (target == PlotEditTarget::SIGNAL_NAME) { if (commit && !text.empty() && editSignal.IsValid()) { m_pendingRenameRef = editSignal; m_pendingRenameName = text; m_pendingRename = true; } return true; } double parsed = 0.0; bool parsedOk = false; if (commit && !text.empty()) { wchar_t* stop = nullptr; parsed = std::wcstod(text.c_str(), &stop); parsedOk = (stop != text.c_str()) && (parsed == parsed) && std::isfinite(parsed); } if (!parsedOk) { return true; } if (target == PlotEditTarget::CURSOR_TIME) { double low = 0.0; double high = 1.0; TimeBounds(low, high); const double span = std::max(high - low, kMinTimeSpan); const double clamped = ClampDouble(parsed, low - span * 10.0, high + span * 10.0); if (m_editCursor == 2 && Tab().CursorMode() == PlotCursorMode::DOUBLE) { Tab().CursorTimeAlt() = clamped; } else { Tab().CursorTime() = clamped; } m_activeCursor = (m_editCursor == 2) ? 2 : 1; return true; } if (target == PlotEditTarget::CURSOR_SPAN) { // 差值编辑:输入值**始终以秒为单位**(即使界面上显示为 1.5min,编辑内容也是 90)。 // **前方(时间较小)的光标不动**,后方光标向后移动到"前方 + 输入差值"。 const double span = std::fabs(parsed); const double front = std::min(Tab().CursorTime(), Tab().CursorTimeAlt()); const bool secondIsBack = (Tab().CursorTimeAlt() >= Tab().CursorTime()); const double moved = front + span; if (secondIsBack) { Tab().CursorTime() = front; Tab().CursorTimeAlt() = moved; } else { Tab().CursorTimeAlt() = front; Tab().CursorTime() = moved; } m_activeCursor = secondIsBack ? 2 : 1; return true; } if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return true; } // 「均值点数 X」/「迭代次数 N」:作用于被勾选信号的均值滤波曲线,夹在合理范围内。 // 「中心轴边沿计数」用**完全相同的参数与语义**,只是作用于自己的条目列表。 const bool centerTarget = (target == PlotEditTarget::EDGE_CENTER_POINTS || target == PlotEditTarget::EDGE_CENTER_ITERATIONS); if (target == PlotEditTarget::MEAN_FILTER_POINTS || target == PlotEditTarget::MEAN_FILTER_ITERATIONS || centerTarget) { Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; const bool isPoints = (target == PlotEditTarget::MEAN_FILTER_POINTS || target == PlotEditTarget::EDGE_CENTER_POINTS); const Lane::MeanFilterItem* existing = centerTarget ? lane.FindEdgeCenterItem(m_editMeanRef) : lane.FindMeanFilterItem(m_editMeanRef); if (existing == nullptr) { return true; } const int maximum = isPoints ? 1000000 : 1000; int value = static_cast(std::lround(parsed)); // 迭代次数至少为 1(1 次迭代等于不做滤波);均值点数可以为 0。 value = isPoints ? std::max(0, std::min(value, maximum)) : std::max(1, std::min(value, maximum)); Lane::MeanFilterItem& item = centerTarget ? lane.EnsureEdgeCenterItem(m_editMeanRef) : lane.EnsureMeanFilterItem(m_editMeanRef); if (isPoints) { item.points = value; } else { item.iterations = value; } // 参数变化会改变曲线:清掉派生缓存即可(局部重画由 `m_sceneDerived` 快照驱动)。 m_meanCurveCache.clear(); m_edgeCenterCountCache.clear(); return true; } // 直接输入上下限:先固化当前范围,再用新值替换对应端点(保证 max > min)。 if (laneIndex >= static_cast(m_lanes.size())) { return true; } const LaneVisual& visual = m_lanes[static_cast(laneIndex)]; Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; // 直接输入上下限属于手动调整幅值:关闭本网格的「选区 Y 轴自适应」。 lane.fitYToSelection = false; if (lane.autoRange) { lane.autoRange = false; lane.yMin = visual.yMin; lane.yMax = visual.yMax; } const double magnitude = std::max(std::fabs(lane.yMin), std::fabs(lane.yMax)); const double minimumGap = std::max(magnitude * 1.0e-6, 1.0e-12); if (target == PlotEditTarget::LANE_Y_MAX) { if (parsed <= lane.yMin) { parsed = lane.yMin + minimumGap; } lane.yMax = parsed; } else { if (parsed >= lane.yMax) { parsed = lane.yMax - minimumGap; } lane.yMin = parsed; } BuildLaneVisuals(); return true; } bool PlotView::OnEditChar(wchar_t character) { if (m_editTarget == PlotEditTarget::NONE) { return false; } // 剪贴板与全选快捷键经 WM_CHAR 送达(Ctrl+C/X/V/A 分别对应 3/24/22/1)。 switch (character) { case 0x03: // Ctrl+C m_edit.CopyToClipboard(m_host); return false; case 0x18: // Ctrl+X return m_edit.CutToClipboard(m_host); case 0x16: // Ctrl+V return m_edit.PasteFromClipboard(m_host); case 0x01: // Ctrl+A m_edit.SelectAll(); return true; default: break; } if (character == L'\r' || character == L'\n') { return EndEdit(true); } // 退格/删除已由 WM_KEYDOWN 的 OnEditKey 处理,此处忽略控制字符避免重复删除。 if (character < 0x20 || character == 0x7F) { return false; } return m_edit.Insert(character); } bool PlotView::CollapseEditSelectionToEnd() { if (m_editTarget == PlotEditTarget::NONE) { return false; } return m_edit.DeselectToEnd(); } bool PlotView::OnEditMouseDown(int x, int y) { if (m_editTarget == PlotEditTarget::NONE) { return false; } const gd::RectF rect = EditFieldRect(); if (rect.Width <= 0.0f) { return false; } 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(x), static_cast(y)); } HCURSOR PlotView::QueryCursor(int x, int y) const { switch (m_dragMode) { case PlotDragMode::PAN: return LoadCursorW(nullptr, IDC_SIZEALL); case PlotDragMode::RUBBER_SELECT: // 选区放大:轴向未定时为十字,确定后按方向给出上下/左右箭头。 if (m_rubberAxis == PlotRubberAxis::VALUE) { return LoadCursorW(nullptr, IDC_SIZENS); } if (m_rubberAxis == PlotRubberAxis::TIME) { return LoadCursorW(nullptr, IDC_SIZEWE); } return LoadCursorW(nullptr, IDC_CROSS); case PlotDragMode::SCROLL_LANES: return LoadCursorW(nullptr, IDC_SIZENS); case PlotDragMode::CURSOR_MOVE: // 光标拖拽(无论是抓住光标线还是抓住时间读数标签)都沿时间轴移动 → 左右箭头。 return LoadCursorW(nullptr, IDC_SIZEWE); case PlotDragMode::CURSOR_SPAN_MOVE: // 差值标签拖拽:两条光标整体沿时间轴平移 → 左右箭头。 return LoadCursorW(nullptr, IDC_SIZEWE); case PlotDragMode::BOOST_LEVEL: // 「弱波增强」等级滑块拖拽 → 左右箭头。 return LoadCursorW(nullptr, IDC_SIZEWE); case PlotDragMode::BOOST_ITERATIONS: // 「弱波增强」迭代次数 N 滑块拖拽 → 左右箭头。 return LoadCursorW(nullptr, IDC_SIZEWE); case PlotDragMode::MOVE_SIGNAL: // 拖动信号(参考 Photoshop 平移元素的抓握光标)。 return LoadCursorW(nullptr, IDC_SIZEALL); case PlotDragMode::EDGE_MOVE: // 「边沿计数」横向光标:上下移动幅值。 return LoadCursorW(nullptr, IDC_SIZENS); case PlotDragMode::EDGE_PANEL_MOVE: // 边沿统计浮动面板:可自由拖动。 return LoadCursorW(nullptr, IDC_SIZEALL); case PlotDragMode::LANE_REORDER: // 网格次序拖拽:上下箭头(只能上下移动)。 return LoadCursorW(nullptr, IDC_SIZENS); default: break; } if (m_scrollBarVisible && UiPointInRect(m_scrollBarRect, static_cast(x), static_cast(y))) { return LoadCursorW(nullptr, IDC_SIZENS); } // 实验性功能面板与横向光标优先于信号悬停提示判定(面板会盖住波形)。 // 面板**只有标题栏**可以拖拽(显示抓握光标);✕ 与面板主体一律保持原始箭头。 if (m_tab != nullptr) { for (const EdgePanelKind kind : kEdgePanelKinds) { for (int index = 0; index < static_cast(m_lanes.size()); ++index) { const EdgePanelLayout layout = EdgePanelLayoutFor(index, kind); if (layout.panel.Width <= 0.0f || !UiPointInRect(layout.panel, static_cast(x), static_cast(y))) { continue; } if (UiPointInRect(layout.close, static_cast(x), static_cast(y))) { // ✕ 按钮:与面板/标签栏的其它 ✕ 一致,用手型光标作为可点击提示。 return LoadCursorW(nullptr, IDC_HAND); } if (UiPointInRect(layout.title, static_cast(x), static_cast(y))) { return LoadCursorW(nullptr, IDC_SIZEALL); } if (kind == EdgePanelKind::BOOST && (WeakBoostSliderHit(layout.slider, m_scale, static_cast(x), static_cast(y)) || WeakBoostSliderHit(layout.sliderIterations, m_scale, static_cast(x), static_cast(y)))) { // 等级 / 迭代次数滑块:左右箭头表示可左右拖动。 return LoadCursorW(nullptr, IDC_SIZEWE); } if ((kind == EdgePanelKind::MEAN || kind == EdgePanelKind::BOOST) && layout.button.Width > 0.0f && UiPointInRect(layout.button, static_cast(x), static_cast(y))) { // 「构建为新信号」按钮:手型光标作为可点击提示。 return LoadCursorW(nullptr, IDC_HAND); } // 面板主体(含底部编辑行与按钮):保持原始箭头。 return nullptr; } } int edgeLane = -1; if (IsOnEdgeCursorLine(x, y, edgeLane)) { return LoadCursorW(nullptr, IDC_SIZENS); } } // 图例中的 GX 编号徽标**悬停时不改变光标样式**(与其它可拖拽区域保持一致); // 只有真正按下拖拽时才切为上下箭头(见 LANE_REORDER 分支)。 // 光标时间读数标签与双光标差值标签:**悬停(未按下)时不改变鼠标形状**。 // 两个标签都叠在光标线的正上方,因此必须先于"光标线"判定返回 nullptr, // 由调用方回退成箭头;只有真正拖拽时才切成左右箭头(见 CURSOR_MOVE / CURSOR_SPAN_MOVE)。 if (IsOnCursorSpanLabel(x, y)) { return nullptr; } { int labelWhich = 0; if (IsOnCursorTimeLabel(x, y, labelWhich)) { return nullptr; } } { int cursorWhich = 0; if (IsOnCursorLine(x, cursorWhich) && UiPointInRect(m_area, static_cast(x), static_cast(y))) { return LoadCursorW(nullptr, IDC_SIZEWE); } } if (UiPointInRect(m_plotRect, static_cast(x), static_cast(y))) { // 悬停命中可拖拽信号时**不改变鼠标形状**(维持原始箭头); // 只有真正按下拖动(MOVE_SIGNAL)才切换为抓握光标。 // 悬停图例时同样保持箭头,仅做半高亮提示。 // 选区放大模式下**未开始框选时保持默认箭头**(不改变鼠标形状); // 只有真正按下并拖动后才切成十字/方向箭头。 // (m_dragMode == RUBBER_SELECT 的情况已在上面处理。) } return nullptr; } bool PlotView::OnMouseMove(int x, int y) { const bool mouseMoved = (x != m_mouseX) || (y != m_mouseY); m_mouseX = x; m_mouseY = y; if (!HasDrawableContent()) { return false; } const float fx = static_cast(x); switch (m_dragMode) { case PlotDragMode::PAN: { if (m_plotRect.Width <= 0.0f) { return false; } // 横向拖动平移时间轴;纵向拖动偏移当前网格的幅值显示位置。 const double span = Tab().ViewMax() - Tab().ViewMin(); const double delta = -static_cast(x - m_dragLastX) / static_cast(m_plotRect.Width) * span; if (delta != 0.0) { Tab().ViewMin() += delta; Tab().ViewMax() += delta; ClampTimeView(); } const int laneIndex = m_dragLane; if (laneIndex >= 0 && laneIndex < static_cast(m_lanes.size()) && y != m_dragLastY) { const LaneVisual& visual = m_lanes[static_cast(laneIndex)]; if (visual.rect.Height > 0.0f) { const double valueSpan = visual.yMax - visual.yMin; const double deltaValue = static_cast(y - m_dragLastY) / static_cast(visual.rect.Height) * valueSpan; ShiftLaneRange(laneIndex, deltaValue); } } m_dragLastX = x; m_dragLastY = y; if (std::abs(x - m_dragStartX) > 3 || std::abs(y - m_dragStartY) > 3) { m_dragMoved = true; } return true; } case PlotDragMode::RUBBER_SELECT: { const float dx = static_cast(x - m_dragStartX); const float dy = static_cast(y - m_dragStartY); // 轴向由首次明显位移决定,之后固定不变(避免拖拽中途选区形态跳变)。 if (m_rubberAxis == PlotRubberAxis::UNDECIDED) { const float threshold = 4.0f * m_scale; if (std::fabs(dx) < threshold && std::fabs(dy) < threshold) { return true; } m_rubberAxis = (std::fabs(dx) >= std::fabs(dy)) ? PlotRubberAxis::TIME : PlotRubberAxis::VALUE; } if (m_rubberAxis == PlotRubberAxis::TIME) { // 竖向选区贯穿整个绘图区高度,只取横向区间用于时间轴缩放。 // **横向必须夹在绘图区内**:鼠标被捕获时可能移动到工具栏 / 信号面板等处, // 若不夹取,选区会画到绘图区之外(那些区域只会被局部失效,于是残留成"绘制错误")。 const float plotLeft = m_plotRect.X; const float plotRight = m_plotRect.X + m_plotRect.Width; const float from = ClampDouble(static_cast(m_dragStartX), static_cast(plotLeft), static_cast(plotRight)); const float to = ClampDouble(static_cast(x), static_cast(plotLeft), static_cast(plotRight)); const float left = std::min(from, to); const float width = std::fabs(to - from); m_rubberRect = gd::RectF(left, m_plotRect.Y, width, m_plotRect.Height); } else { // 横向选区限定在按下时的网格内(纵向同样夹在该网格内),只取纵向区间用于幅值轴缩放。 if (m_dragLane < 0 || m_dragLane >= static_cast(m_lanes.size())) { return false; } const gd::RectF& laneRect = m_lanes[static_cast(m_dragLane)].rect; const float from = ClampDouble(static_cast(m_dragStartY), static_cast(laneRect.Y), static_cast(laneRect.Y + laneRect.Height)); const float to = ClampDouble(static_cast(y), static_cast(laneRect.Y), static_cast(laneRect.Y + laneRect.Height)); const float top = std::min(from, to); const float bottom = std::max(from, to); m_rubberRect = gd::RectF(laneRect.X, top, laneRect.Width, bottom - top); } m_dragMoved = true; // 拖动过程中维持显示"右键取消选区"说明(粘滞,直到松开左键后才淡出)。 SetHintSticky(L"右键取消选区,ESC退出选区模式"); return true; } case PlotDragMode::SCROLL_LANES: { if (m_plotRect.Height <= 0.0f || m_laneContentHeight <= m_plotRect.Height) { return false; } const double travel = static_cast(y - m_dragLastY) / static_cast(m_plotRect.Height); Tab().LaneScroll() += static_cast(travel * m_laneContentHeight); m_dragLastY = y; m_dragMoved = true; BuildLaneVisuals(); return true; } case PlotDragMode::CURSOR_MOVE: { // 从时间读数标签上抓起时保留横向抓取偏移,避免一按下光标就跳到鼠标中心。 double target = XToTime(fx - static_cast(m_cursorGrabOffset)); double snapped = target; if (SnapTimeToSample(target, snapped)) { target = snapped; } const bool second = (m_hoverCursor == 2 && Tab().CursorMode() == PlotCursorMode::DOUBLE); if (second) { Tab().CursorTimeAlt() = target; } else { Tab().CursorTime() = target; } // 抓取读数标签时可能带有一个较大的横向抓取偏移(放大到一定程度后该偏移 // 换算成的时间可能落到视界之外)——拖拽结束前必须把光标夹回当前可见区间, // 否则用户会看到光标线"跑到视界外"而无法继续操作。 ClampCursorToView(second ? 2 : 1); m_dragMoved = true; m_cursorHover = true; return true; } case PlotDragMode::CURSOR_SPAN_MOVE: { // 拖拽差值标签:两条光标整体平移,间隔保持不变。 const double startTime = XToTime(static_cast(m_spanDragStartX)); const double delta = XToTime(fx) - startTime; double first = m_spanDragTime1 + delta; double second = m_spanDragTime2 + delta; // 整体平移后同样不能越出可视区:先尝试保持间隔地整体夹取, // 两光标间隔本身就大于可视跨度时退化为各自夹取。 const double low = Tab().ViewMin(); const double high = Tab().ViewMax(); if (high > low) { const double inset = (high - low) * 0.002; const double lo = low + inset; const double hi = high - inset; const double pairLow = std::min(first, second); const double pairHigh = std::max(first, second); if (pairHigh - pairLow <= hi - lo) { if (pairLow < lo) { first += lo - pairLow; second += lo - pairLow; } else if (pairHigh > hi) { first -= pairHigh - hi; second -= pairHigh - hi; } } else { first = ClampDouble(first, lo, hi); second = ClampDouble(second, lo, hi); } } Tab().CursorTime() = first; Tab().CursorTimeAlt() = second; m_dragMoved = true; return true; } case PlotDragMode::BOOST_LEVEL: { // 「弱波增强」等级滑块:按滑轨上的横向位置换算 0–100%。 if (m_tab == nullptr || m_boostDragLane < 0 || m_boostDragLane >= static_cast(m_lanes.size())) { break; } const EdgePanelLayout layout = EdgePanelLayoutFor(m_boostDragLane, EdgePanelKind::BOOST); if (layout.slider.Width <= 0.0f) { break; } const double ratio = UiSliderRatioFromX(layout.slider, fx); SetLaneWeakBoostLevel(m_boostDragLane, ratio); m_dragMoved = true; return true; } case PlotDragMode::BOOST_ITERATIONS: { // 「弱波增强」迭代次数 N 滑块:按滑轨上的横向位置换算 1–10(整数)。 if (m_tab == nullptr || m_boostDragLane < 0 || m_boostDragLane >= static_cast(m_lanes.size())) { break; } const EdgePanelLayout layout = EdgePanelLayoutFor(m_boostDragLane, EdgePanelKind::BOOST); if (layout.sliderIterations.Width <= 0.0f) { break; } const double ratio = UiSliderRatioFromX(layout.sliderIterations, fx); const int iterations = kWeakBoostIterationsMin + static_cast(std::lround( ratio * (kWeakBoostIterationsMax - kWeakBoostIterationsMin))); SetLaneWeakBoostIterations(m_boostDragLane, iterations); m_dragMoved = true; return true; } case PlotDragMode::MOVE_SIGNAL: { const int target = LaneAtY(y); m_dragMoved = true; m_dragMouseX = x; m_dragMouseY = y; if (target != m_dragTargetLane) { m_dragTargetLane = target; } return true; } case PlotDragMode::LANE_REORDER: { // 被拖拽的网格自由跟随鼠标;目标位置变化时其它网格实时让位(吸附)。 m_laneDragMouseY = y; const int target = LaneDragTargetAt(y); if (target != m_laneDragTarget) { m_laneDragTarget = target; } m_dragMoved = true; return true; } case PlotDragMode::EDGE_MOVE: { // 横向光标:上下移动幅值(夹在该网格的可见幅值范围内)。 if (m_tab == nullptr || m_edgeDragLane < 0 || m_edgeDragLane >= static_cast(m_lanes.size()) || m_edgeDragLane >= static_cast(m_tab->Lanes().size())) { break; } const LaneVisual& visual = m_lanes[static_cast(m_edgeDragLane)]; Lane& lane = m_tab->Lanes()[static_cast(m_edgeDragLane)]; double value = YToValue(static_cast(y), visual); const double low = std::min(visual.yMin, visual.yMax); const double high = std::max(visual.yMin, visual.yMax); value = std::max(low, std::min(value, high)); lane.edgeValue = value; lane.edgeValueValid = true; m_dragMoved = true; return true; } case PlotDragMode::EDGE_PANEL_MOVE: { // 浮动面板:**只能从标题栏拖动**(按下时已限制),整体限制在绘图区内。 if (m_tab == nullptr || m_edgePanelDragLane < 0 || m_edgePanelDragLane >= static_cast(m_tab->Lanes().size())) { break; } const gd::RectF panel = EdgePanelLayoutFor(m_edgePanelDragLane, m_edgePanelDragKind).panel; if (panel.Width <= 0.0f) { break; } float left = static_cast(x) - m_edgePanelDragOffset.X; float top = static_cast(y) - m_edgePanelDragOffset.Y; left = std::max(m_plotRect.X + 2.0f * m_scale, std::min(left, m_plotRect.X + m_plotRect.Width - panel.Width - 2.0f * m_scale)); top = std::max(m_plotRect.Y + 2.0f * m_scale, std::min(top, m_plotRect.Y + m_plotRect.Height - panel.Height - 2.0f * m_scale)); Lane& lane = m_tab->Lanes()[static_cast(m_edgePanelDragLane)]; switch (m_edgePanelDragKind) { case EdgePanelKind::CENTER: lane.edgeCenterPanelX = left; lane.edgeCenterPanelY = top; break; case EdgePanelKind::MEAN: lane.meanFilterPanelX = left; lane.meanFilterPanelY = top; break; case EdgePanelKind::BOOST: lane.weakBoostPanelX = left; lane.weakBoostPanelY = top; break; case EdgePanelKind::COUNT: default: lane.edgePanelX = left; lane.edgePanelY = top; break; } m_dragMoved = true; return true; } case PlotDragMode::NONE: default: break; } bool changed = false; // 鼠标压在实验性功能面板上时不再触发信号悬停提示(面板本来就盖住波形), // 但该面板所属的网格仍然要高亮(与鼠标直接停在网格上一致)。 bool overEdgePanel = false; int panelLane = -1; for (long kindIndex = 0; kindIndex < static_cast(sizeof(kEdgePanelKinds) / sizeof(kEdgePanelKinds[0])) && !overEdgePanel; ++kindIndex) { for (int index = 0; index < static_cast(m_lanes.size()); ++index) { const gd::RectF panel = EdgePanelLayoutFor(index, kEdgePanelKinds[kindIndex]).panel; if (panel.Width > 0.0f && UiPointInRect(panel, static_cast(x), static_cast(y))) { overEdgePanel = true; panelLane = index; break; } } } // 悬停网格:鼠标在面板上时高亮面板所属网格(用户明确要求)。 const int lane = overEdgePanel ? panelLane : LaneAtY(y); if (lane != m_hoverLane) { m_hoverLane = lane; 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); if (!hoverLegend.IsValid() && Tab().ZoomMode() == PlotZoomMode::NONE) { hoverSignal = HitTestSignal(x, y); } } if (hoverSignal != m_hoverSignal) { m_hoverSignal = hoverSignal; changed = true; } if (hoverLegend != m_hoverLegend) { m_hoverLegend = hoverLegend; changed = true; } // 双光标差值标签的悬停状态(用于区分"抓握/悬停"光标与普通状态)。 const bool spanHover = IsOnCursorSpanLabel(x, y); if (spanHover != m_spanHover) { m_spanHover = spanHover; changed = true; } // 悬停提示跟随鼠标:只要提示可见,鼠标移动本身就要触发重绘; // 否则鼠标在可拖拽区域内继续移动时,提示会停在上一帧的位置。 if (m_hoverSignal.IsValid() && mouseMoved) { changed = true; } return changed; } bool PlotView::OnLButtonDown(int x, int y) { if (!HasDrawableContent()) { return false; } const float fx = static_cast(x); const float fy = static_cast(y); m_dragStartX = x; m_dragStartY = y; m_dragLastX = x; m_dragLastY = y; m_dragMoved = false; m_rubberRect = gd::RectF(); m_rubberAxis = PlotRubberAxis::UNDECIDED; m_cursorGrabOffset = 0; m_boostDragLane = -1; // 新的交互开始:清掉上一次留下的提示(拖动过程中会重新设置粘滞提示)。 SetHint(std::wstring()); // 上一轮尚未抬起确认的面板关闭请求一律作废。 m_pendingPanelClose = false; m_pendingPanelCloseLane = -1; if (m_scrollBarVisible && UiPointInRect(m_scrollBarRect, static_cast(x), static_cast(y))) { m_dragMode = PlotDragMode::SCROLL_LANES; return true; } // 内联输入中:输入框之外的点击视为提交(与线宽输入一致)。 if (IsEditing()) { if (!UiPointInRect(EditFieldRect(), fx, fy)) { EndEdit(true); } return true; } // 双光标模式下先检查差值标签:它可拖拽(两条光标整体平移,间隔不变)。 if (IsOnCursorSpanLabel(x, y)) { m_dragMode = PlotDragMode::CURSOR_SPAN_MOVE; m_spanDragStartX = x; m_spanDragTime1 = Tab().CursorTime(); m_spanDragTime2 = Tab().CursorTimeAlt(); return true; } // 光标时间读数标签:可直接抓住拖动(与抓住光标线等价)。 { int labelWhich = 0; if (IsOnCursorTimeLabel(x, y, labelWhich)) { m_dragMode = PlotDragMode::CURSOR_MOVE; m_hoverCursor = labelWhich; m_activeCursor = labelWhich; m_cursorHover = true; m_cursorGrabOffset = x - static_cast(std::lround(CursorDisplayX(labelWhich))); return true; } } // 靠近时间光标线时优先进入拖动,避免误触发平移或缩放选区。 { 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; } } // 网格次序拖拽:按在图例的 GX 编号徽标上即可整块拎起该网格上下移动。 // 位置在浮动面板之后(面板压在波形之上,应优先命中),但在信号拖拽之前。 { const int badgeLane = LaneBadgeAtPoint(x, y); if (badgeLane >= 0) { m_dragMode = PlotDragMode::LANE_REORDER; m_laneDragFrom = badgeLane; m_laneDragTarget = badgeLane; m_laneDragMouseY = y; m_laneDragGrabOffset = static_cast(y) - m_lanes[static_cast(badgeLane)].rect.Y; // 拖动期间保持静态场景缓存有效:内容按网格条带平移贴回, // 因此不必重绘百万级采样的波形(见 PaintLaneReorder)。 return true; } } // 实验性功能浮动面板(面板压在波形之上,必须先于信号拖拽判定): // * ✕ → 关闭该网格对应的功能; // * 底部编辑行 → 进入「平滑点数 / 均值点数 N」内联编辑; // * 底部按钮 → 触发「构建为新信号」请求; // * 标题栏 → 拖动面板; // * 面板主体 → 消费点击,但**不拖动也不穿透**(用户明确要求只有标题栏可拖拽)。 for (int pass = 0; pass < kEdgePanelKindCount; ++pass) { const EdgePanelKind kind = kEdgePanelKinds[pass]; bool handledPanel = false; for (int index = 0; index < static_cast(m_lanes.size()); ++index) { const EdgePanelLayout layout = EdgePanelLayoutFor(index, kind); if (layout.panel.Width <= 0.0f || !UiPointInRect(layout.panel, fx, fy)) { continue; } handledPanel = true; if (UiPointInRect(layout.close, fx, fy)) { // ✕:**抬起**时才关闭该网格对应的实验性功能,这里只记录目标; // 按住后移开再松开视为放弃(与面板/标签栏的关闭 ✕ 语义一致)。 // ⚠️ 必须在此**直接消费**该次按下:✕ 位于标题栏矩形内部, // 若继续往下走会被“标题栏 → 拖动面板”分支命中,表现为“按下 ✕ 却拖走了面板”。 m_pendingPanelClose = true; m_pendingPanelCloseLane = index; m_pendingPanelCloseKind = kind; m_dragMode = PlotDragMode::NONE; return true; } // 「中心轴边沿计数」/「均值滤波」/「弱波增强」面板:逐信号条目 // (同一套「名称 + 复选框」排布,只是复选框绑定的条目列表不同)。 if ((kind == EdgePanelKind::MEAN || kind == EdgePanelKind::CENTER || kind == EdgePanelKind::BOOST) && m_tab != nullptr) { const bool meanMode = (kind == EdgePanelKind::MEAN); const bool boostMode = (kind == EdgePanelKind::BOOST); Lane& tabLane = m_tab->Lanes()[static_cast(index)]; // 弱波增强的两个滑块:按下即开始拖动(拖动过程中实时更新)。 if (boostMode && WeakBoostSliderHit(layout.slider, m_scale, fx, fy)) { m_dragMode = PlotDragMode::BOOST_LEVEL; m_boostDragLane = index; SetLaneWeakBoostLevel(index, UiSliderRatioFromX(layout.slider, fx)); return true; } if (boostMode && WeakBoostSliderHit(layout.sliderIterations, m_scale, fx, fy)) { m_dragMode = PlotDragMode::BOOST_ITERATIONS; m_boostDragLane = index; const double ratio = UiSliderRatioFromX(layout.sliderIterations, fx); SetLaneWeakBoostIterations(index, kWeakBoostIterationsMin + static_cast(std::lround( ratio * (kWeakBoostIterationsMax - kWeakBoostIterationsMin)))); return true; } for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex) { const SignalRef& ref = layout.rowRefs[rowIndex]; const auto findItem = [&]() -> const Lane::MeanFilterItem* { return meanMode ? tabLane.FindMeanFilterItem(ref) : (boostMode ? tabLane.FindWeakBoostItem(ref) : tabLane.FindEdgeCenterItem(ref)); }; // 第一排右侧的复选框 → 切换该信号的预览勾选状态。 if (layout.rowChecks[rowIndex].Width > 0.0f && UiPointInRect(layout.rowChecks[rowIndex], fx, fy)) { if (meanMode) { ToggleLaneMeanFilterSignal(index, ref); } else if (boostMode) { ToggleLaneWeakBoostSignal(index, ref); } else { ToggleLaneEdgeCenterSignal(index, ref); } m_meanCurveCache.clear(); m_edgeCenterCountCache.clear(); m_weakBoostCurveCache.clear(); // 勾选变化只影响该网格的内容:交给 `m_sceneDerived` 快照驱动局部重画, // 这里**不**整体作废场景缓存(否则每次勾选都要重绘全部网格)。 m_dragMode = PlotDragMode::NONE; return true; } // 第二排的两个输入框 → 就地编辑该信号的均值点数 X / 迭代次数 N //(弱波增强面板没有参数输入框,等级由滑块统一控制)。 if (layout.rowPoints[rowIndex].Width > 0.0f && UiPointInRect(layout.rowPoints[rowIndex], fx, fy)) { const Lane::MeanFilterItem* item = findItem(); m_editMeanRef = 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)) { const Lane::MeanFilterItem* item = findItem(); m_editMeanRef = ref; BeginEdit(meanMode ? PlotEditTarget::MEAN_FILTER_ITERATIONS : PlotEditTarget::EDGE_CENTER_ITERATIONS, index, std::to_wstring(item != nullptr ? item->iterations : 1), layout.rowIterations[rowIndex]); return true; } } if (boostMode && layout.button.Width > 0.0f && UiPointInRect(layout.button, fx, fy)) { // 「弱波增强 → 构建为新信号」:与均值滤波同一套请求机制 //(等级与迭代次数属于整个网格,网格内各被勾选信号共用同一组参数)。 const Lane& lane = m_tab->Lanes()[static_cast(index)]; const int levelPercent = static_cast( std::lround(std::max(0.0, std::min(1.0, lane.weakBoostLevel)) * 100.0)); const int iterations = std::max(kWeakBoostIterationsMin, std::min(kWeakBoostIterationsMax, lane.weakBoostIterations)); m_pendingWeakBoostItems.clear(); for (const SignalRef& ref : lane.signals) { const ResolvedSignal resolved = ResolveSignal(ref); if (resolved.data == nullptr || !resolved.visible) { continue; } if (lane.FindWeakBoostItem(ref) == nullptr) { continue; } WeakBoostBuild build; build.fileIndex = ref.fileIndex; build.signalIndex = ref.signalIndex; build.levelPercent = levelPercent; build.iterations = iterations; m_pendingWeakBoostItems.push_back(build); } if (!m_pendingWeakBoostItems.empty()) { SetHint(L"正在构建弱波增强信号(等级 " + std::to_wstring(levelPercent) + L"% / " + std::to_wstring(iterations) + L" 次迭代,共 " + std::to_wstring(m_pendingWeakBoostItems.size()) + L" 条)…"); m_pendingWeakBoostLane = index; } m_dragMode = PlotDragMode::NONE; return true; } if (meanMode && layout.button.Width > 0.0f && UiPointInRect(layout.button, fx, fy)) { // 「构建为新信号」:只登记请求(含每个信号自己的 X/N), // 真正的信号构建由宿主(Application)完成。 const Lane& lane = m_tab->Lanes()[static_cast(index)]; m_pendingMeanFilterItems.clear(); for (const SignalRef& ref : lane.signals) { const ResolvedSignal resolved = ResolveSignal(ref); if (resolved.data == nullptr || !resolved.visible) { continue; } const Lane::MeanFilterItem* item = lane.FindMeanFilterItem(ref); if (item == nullptr) { continue; } MeanFilterBuild build; build.fileIndex = ref.fileIndex; 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; } if (handledPanel) { break; } } // 「边沿计数」横向光标:上下拖动改变幅值。 { 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 SignalRef legendHit = LegendAtPoint(x, y); if (legendHit.IsValid()) { SelectSignal(legendHit); m_dragMode = PlotDragMode::NONE; return true; } } const PlotZoomMode mode = Tab().ZoomMode(); if (mode == PlotZoomMode::SELECT) { // 选区放大:轴向由鼠标移动方向决定,因此按下时先记录网格并置为未定。 // 按下本身**不显示提示**(拖动开始后才显示"右键取消选区")。 m_dragLane = LaneAtY(y); m_rubberAxis = PlotRubberAxis::UNDECIDED; m_dragMode = PlotDragMode::RUBBER_SELECT; return true; } // 常规模式下:先检测是否落在信号曲线上;只有命中提示时才允许拖拽该信号。 const SignalRef hit = HitTestSignal(x, y); if (hit.IsValid()) { m_dragSignal = hit; m_dragTargetLane = Tab().LaneOfSignal(hit); SelectSignal(hit); m_dragMouseX = x; m_dragMouseY = y; m_dragMode = PlotDragMode::MOVE_SIGNAL; return true; } m_dragLane = LaneAtY(y); m_dragMode = PlotDragMode::PAN; return true; } bool PlotView::OnMButtonDown(int x, int y) { if (!HasDrawableContent()) { return false; } // 中键:无论当前是否处于选区放大模式,都直接进入自由平移 // (不改变选区放大模式本身,松手后仍可继续框选)。 m_dragStartX = x; m_dragStartY = y; m_dragLastX = x; m_dragLastY = y; m_dragMoved = false; m_rubberRect = gd::RectF(); m_rubberAxis = PlotRubberAxis::UNDECIDED; m_cursorGrabOffset = 0; m_edgeDragLane = -1; m_edgePanelDragLane = -1; m_edgePanelDragKind = EdgePanelKind::COUNT; m_boostDragLane = -1; 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_pendingPanelClose) { const int lane = m_pendingPanelCloseLane; const EdgePanelKind kind = m_pendingPanelCloseKind; m_pendingPanelClose = false; m_pendingPanelCloseLane = -1; if (lane >= 0 && lane < static_cast(m_lanes.size())) { const EdgePanelLayout layout = EdgePanelLayoutFor(lane, kind); if (layout.close.Width > 0.0f && UiPointInRect(layout.close, static_cast(x), static_cast(y))) { switch (kind) { case EdgePanelKind::CENTER: SetLaneEdgeCenterCount(lane, false); break; case EdgePanelKind::MEAN: SetLaneMeanFilter(lane, false); break; case EdgePanelKind::BOOST: SetLaneWeakBoost(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_boostDragLane = -1; // 松开左键:粘滞提示结束计时,开始"停留后淡出"。 ReleaseHint(); return changed; } bool PlotView::OnMouseWheel(int delta, bool shift) { if (!HasDrawableContent() || delta == 0) { return false; } const double notches = static_cast(delta) / 120.0; // 滚轮不再承担缩放:默认纵向滚动网格,按住 Shift 时水平平移时间轴。 if (shift) { const double span = Tab().ViewMax() - Tab().ViewMin(); const double sweep = -notches * span * 0.15; Tab().ViewMin() += sweep; Tab().ViewMax() += sweep; ClampTimeView(); return true; } if (m_plotRect.Height <= 0.0f || m_laneContentHeight <= m_plotRect.Height) { return false; } Tab().LaneScroll() -= static_cast(notches * 48.0 * m_scale); BuildLaneVisuals(); return true; } bool PlotView::OnDoubleClick(int x, int y) { if (!UiPointInRect(m_area, static_cast(x), static_cast(y))) { return false; } // 双光标差值标签:双击进入差值编辑(输入值的量级始终为秒)。 // 注意:差值标签在"两光标过近"时会落到主时间轴内部,因此必须先于区域判定。 if (IsOnCursorSpanLabel(x, y)) { return BeginEdit(PlotEditTarget::CURSOR_SPAN, -1, FormatDurationSeconds(CursorSpan())); } if (!UiPointInRect(m_area, static_cast(x), static_cast(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->Title()); } } // 其它位置:回到初始状态(自适应 + 退出缩放模式)。 FitAll(); return true; } bool PlotView::OnMouseLeave() { bool changed = false; if (m_hoverLane != -1) { m_hoverLane = -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; } const PlotView::PolylineCacheEntry* PlotView::AcquirePolyline(const SignalRef& ref, const ResolvedSignal& resolved, const LaneVisual& lane) const { if (resolved.data == nullptr || resolved.file == nullptr) { return nullptr; } // 键 = 构建折线时实际读取的全部输入: // 信号(引用 + 文件内容 + 采样点数)、时间轴显示范围、绘图区横向几何、网格纵向几何。 // 这些值全部相同时,BuildPolyline 的输出必然完全一致,可直接复用。 const std::size_t sampleCount = std::min(resolved.file->time.size(), resolved.data->values.size()); const double viewMin = Tab().ViewMin(); const double viewMax = Tab().ViewMax(); for (const PolylineCacheEntry& entry : m_polylineCache) { if (entry.ref == ref && entry.file == resolved.file && entry.sampleCount == sampleCount && entry.viewMin == viewMin && entry.viewMax == viewMax && entry.plotX == m_plotRect.X && entry.plotWidth == m_plotRect.Width && entry.laneY == lane.rect.Y && entry.laneHeight == lane.rect.Height && entry.laneYMin == lane.yMin && entry.laneYMax == lane.yMax) { return &entry; } } // 未命中:重建并写入缓存(deque 保证已有元素的引用在追加后依然有效)。 // 缓存只用于消除重复计算,容量上限约等于“网格数 × 每网格信号数”的两倍。 if (m_polylineCache.size() >= kPolylineCacheLimit) { m_polylineCache.clear(); } m_polylineCache.push_back(PolylineCacheEntry()); PolylineCacheEntry& entry = m_polylineCache.back(); entry.ref = ref; entry.file = resolved.file; entry.sampleCount = sampleCount; entry.viewMin = viewMin; entry.viewMax = viewMax; entry.plotX = m_plotRect.X; entry.plotWidth = m_plotRect.Width; entry.laneY = lane.rect.Y; entry.laneHeight = lane.rect.Height; entry.laneYMin = lane.yMin; entry.laneYMax = lane.yMax; BuildPolyline(resolved, lane, entry.points, entry.breaks, entry.band, entry.bandBreaks, entry.bandMode, entry.bandSpiky); // BuildPolyline 通过引用填充,push_back 过程中不会再次进入本函数,引用安全。 return &m_polylineCache.back(); } void PlotView::BuildPolyline(const ResolvedSignal& resolved, const LaneVisual& lane, std::vector& points, std::vector& breaks, std::vector& band, std::vector& bandBreaks, bool& bandMode, bool& bandSpiky) const { points.clear(); breaks.clear(); band.clear(); bandBreaks.clear(); bandMode = false; bandSpiky = false; if (resolved.data == nullptr || resolved.file == nullptr) { return; } const std::vector& time = resolved.file->time; const std::vector& values = resolved.data->values; const std::size_t count = std::min(time.size(), values.size()); if (count == 0 || m_plotRect.Width <= 1.0f) { return; } const double viewMin = Tab().ViewMin(); const double viewMax = Tab().ViewMax(); std::size_t begin = 0; std::size_t end = count; if (resolved.file->timeMonotonic) { const std::vector::const_iterator first = std::lower_bound(time.begin(), time.end(), viewMin); begin = static_cast(first - time.begin()); if (begin > 0) { --begin; } const std::vector::const_iterator last = std::lower_bound(time.begin(), time.end(), viewMax); end = static_cast(last - time.begin()); if (end < count) { ++end; } } if (end <= begin) { return; } const float limitTop = lane.rect.Y - lane.rect.Height * 4.0f; const float limitBottom = lane.rect.Y + lane.rect.Height * 5.0f; const std::size_t visibleCount = end - begin; // 顶点预算由绘图区宽度决定(而不是固定值):折线顶点数直接决定 // GDI+ 抗锯齿折线的绘制耗时,百万级采样文件若按固定 20000 顶点绘制, // 会把大量顶点堆在同一个像素列上(画出来完全重合),纯粹浪费 CPU。 std::size_t budget = static_cast(std::max(1.0f, m_plotRect.Width)) * kPolylinePointsPerPixel; budget = std::max(budget, kMinPolylinePoints); budget = std::min(budget, kMaxPolylinePoints); // 采样点密度远超像素分辨率时改用“像素列上下包络”抽稀: // 每个像素列只保留最小/最大两个顶点(按出现先后排序), // 既不会漏掉尖峰(等间隔抽样会整段跳过),又把顶点数压到像素量级。 if (visibleCount > budget && resolved.file->timeMonotonic && m_plotRect.Width > 1.0f) { const double viewSpan = viewMax - viewMin; const float plotLeft = m_plotRect.X; const float plotWidth = m_plotRect.Width; const std::size_t columns = std::max(1, budget / 2); const float columnWidth = plotWidth / static_cast(columns); const std::vector::const_iterator rangeEnd = time.begin() + static_cast(end); // 粗粒度包络层(若已构建):每列只需读取覆盖到的分组, // 数据量从“可见采样数”降到“像素列数 × 少量分组”, // 且分组内存的就是真实极值,绘制结果与逐点扫描完全一致。 const std::vector& coarseMin = resolved.data->coarseMin; const std::vector& coarseMax = resolved.data->coarseMax; const std::size_t coarseStride = resolved.data->coarseStride; const bool hasCoarse = (coarseStride > 0) && !coarseMin.empty() && (coarseMin.size() == coarseMax.size()); bool inSegment = false; // 稠密包络带:逐列记录上/下包络,收段时拼成「上包络正向 + 下包络反向」的 // 闭合多边形。绘制时一次 FillPath 即可,实测比逐段描边快约 8 倍 // (1224 列:14.3ms → 1.7ms),且**保留抗锯齿**, // 因此静态帧与拖拽帧的观感完全一致(不是"交互降级")。 bandMode = true; const float strokeHalf = std::max(1.0f, resolved.style->lineWidth) * m_scale * 0.5f; std::vector segmentColumns; const auto flushBandSegment = [&band, &bandBreaks, &segmentColumns, &bandSpiky]() { bool spiky = false; EmitEnvelopeBand(segmentColumns, band, bandBreaks, spiky); bandSpiky = bandSpiky || spiky; segmentColumns.clear(); }; std::size_t searchFrom = begin; for (std::size_t column = 0; column < columns; ++column) { const double t0 = viewMin + viewSpan * (static_cast(column) / static_cast(columns)); const double t1 = viewMin + viewSpan * (static_cast(column + 1) / static_cast(columns)); // 列边界随 column 单调递增,从上一列的位置继续二分, // 把每次搜索的范围压到剩余区间,避免 2400 次全区间二分带来的缓存缺失。 const std::size_t i0 = static_cast( std::lower_bound(time.begin() + static_cast(searchFrom), rangeEnd, t0) - time.begin()); searchFrom = i0; const std::size_t i1 = (column + 1 == columns) ? end : static_cast( std::lower_bound(time.begin() + static_cast(i0), rangeEnd, t1) - time.begin()); double low = std::numeric_limits::infinity(); double high = -std::numeric_limits::infinity(); std::size_t lowOrder = 0; std::size_t highOrder = 0; bool broken = false; // (一)落在本列区间内的采样点。 if (i1 > i0 && hasCoarse && (i1 - i0) >= 2 * coarseStride) { const std::size_t firstBucket = i0 / coarseStride; const std::size_t lastBucket = std::min((i1 - 1) / coarseStride, coarseMin.size() - 1); for (std::size_t bucket = firstBucket; bucket <= lastBucket; ++bucket) { const double bucketLow = coarseMin[bucket]; const double bucketHigh = coarseMax[bucket]; if (!std::isfinite(bucketLow)) { // 该分组全部是无效值:包络到此断开,后续另起一段。 broken = true; continue; } if (bucketLow < low) { low = bucketLow; lowOrder = bucket; } if (bucketHigh > high) { high = bucketHigh; highOrder = bucket; } } } else if (i1 > i0) { for (std::size_t index = i0; index < i1; ++index) { const double value = values[index]; if (!std::isfinite(value)) { // 该列含无效值:包络绘制到本列结束,后续另起一段。 broken = true; continue; } if (value < low) { low = value; lowOrder = index; } if (value > high) { high = value; highOrder = index; } } } // (二)跨越列边界的两段折线在边界处的取值。 // // 波形按相邻采样点之间的**直线**绘制,而包络是按像素列聚合的。采样间隔 // 可能大于一个像素列(本仓库的 Problem_Data.csv 相邻采样间隔约 27µs 且不固定, // 放大到 10ms/大格时一个像素列约 60µs),此时一次跃变的两个端点会分别落在 // 相邻两列里:对每一列来说"落在本列内的采样点"都在同一边,于是这段竖向折线 // 在两侧的包络里都不出现 → 波形在这些跃变处被整段丢掉,看上去就是"断开"。 // // 把列边界处的折线取值计入本列包络即可消除该现象:折线在列内是线性的, // 其极值必然出现在采样点或列边界上,因此「列内采样点 + 两侧边界取值」就是 // 该列包络的精确解,且相邻两列在边界处取到同一个值,包络带严丝合缝。 double boundary = 0.0; if (PolylineValueAt(time, values, count, i0, t0, boundary)) { if (boundary < low) { low = boundary; lowOrder = (i0 > 0) ? (i0 - 1) : 0; } if (boundary > high) { high = boundary; highOrder = (i0 > 0) ? (i0 - 1) : 0; } } if (PolylineValueAt(time, values, count, i1, t1, boundary)) { if (boundary < low) { low = boundary; lowOrder = i1; } if (boundary > high) { high = boundary; highOrder = i1; } } if (!(low <= high)) { // 本列没有任何有效取值:折线尚未开始、已经结束,或该处数据缺失。 inSegment = false; flushBandSegment(); continue; } const float x = plotLeft + (static_cast(column) + 0.5f) * columnWidth; const float yTop = static_cast(ClampDouble(ValueToY(high, lane), limitTop, limitBottom)); const float yBottom = static_cast(ClampDouble(ValueToY(low, lane), limitTop, limitBottom)); // 包络带每列都记录(上下各外扩半个线宽,保证细波动也有可见粗细)。 BandColumn bandColumn; bandColumn.x = x; bandColumn.top = yTop - strokeHalf; bandColumn.bottom = yBottom + strokeHalf; segmentColumns.push_back(bandColumn); if (yBottom - yTop < kEnvelopeMinPixelSpan) { // 波动不足半个像素:只留一个顶点,避免无意义的重复顶点。 if (!inSegment) { breaks.push_back(points.size()); inSegment = true; } points.push_back(gd::PointF(x, yTop)); } else { if (!inSegment) { breaks.push_back(points.size()); inSegment = true; } // 按采样点出现的先后顺序连接上下包络,保持走线方向一致。 if (lowOrder <= highOrder) { points.push_back(gd::PointF(x, yBottom)); points.push_back(gd::PointF(x, yTop)); } else { points.push_back(gd::PointF(x, yTop)); points.push_back(gd::PointF(x, yBottom)); } } if (broken) { inSegment = false; flushBandSegment(); } } flushBandSegment(); return; } std::size_t stride = 1; if (visibleCount > budget) { stride = (visibleCount + budget - 1) / budget; } bool inSegment = false; for (std::size_t index = begin; index < end; index += stride) { const double t = time[index]; const double value = values[index]; if (!std::isfinite(t) || !std::isfinite(value)) { inSegment = false; continue; } const float x = TimeToX(t); float y = ValueToY(value, lane); y = static_cast(ClampDouble(y, limitTop, limitBottom)); if (!inSegment) { breaks.push_back(points.size()); points.push_back(gd::PointF(x, y)); inSegment = true; } else { points.push_back(gd::PointF(x, y)); } } } void PlotView::PaintEmptyState(gd::Graphics& g) { std::wstring message; if (m_dataSet == nullptr || m_dataSet->Empty()) { message = L"打开 CSV 文件以查看仿真数据波形"; } else { message = L"请在左侧勾选需要查看的信号(可同时加载多个文件进行对比)"; } UiText(g, message, m_fonts->ui, m_area, m_theme->textMuted, TextAlign::CENTER); } void PlotView::Paint(gd::Graphics& g) { if (m_theme == nullptr || m_fonts == nullptr) { return; } BuildLaneVisuals(); // 双缓冲:清掉上一帧钉住的曲线条目(本帧会按需重新钉住),并清理已被更新条目覆盖 / // 超出内存预算的旧条目。此刻没有任何条目被钉住,回收是安全的。 m_curvePins.clear(); { std::lock_guard lock(m_curveMutex); PruneCurveEntriesLocked(); } // 后台算好了新曲线:静态场景缓存里仍是旧曲线(或还没有曲线),作废一次以纳入最新结果。 if (m_curveSceneDirty.exchange(false, std::memory_order_relaxed)) { InvalidateSceneCache(); } if (m_lanes.empty()) { InvalidateSceneCache(); PaintEmptyState(g); return; } // 「快速绘制(关闭抗锯齿)」只在**会改变画面主体(波形/网格几何)**的拖拽期间启用: // 这些拖拽每帧都要重画波形,关掉抗锯齿能显著加速(同一条包络带 27.6ms → 3.2ms)。 // ⚠️ 像「拖时间光标」「选区框选」「拖浮动面板」这类**只改叠加层、画面主体不变**的交互, // 一旦启用快速帧就会给静态场景缓存打上「已降级」标记 → 缓存失效 → 每帧整幅重画 //(实测拖光标从约 1ms 骤升到十几毫秒)。因此这里**不**把它们算作快速帧, // 让它们直接复用静态场景缓存(贴图 ~1ms)+ 叠加层,做到真正跟手。 const bool sceneChangingDrag = (m_dragMode == PlotDragMode::PAN) || (m_dragMode == PlotDragMode::SCROLL_LANES) || (m_dragMode == PlotDragMode::LANE_REORDER) || (m_dragMode == PlotDragMode::MOVE_SIGNAL) || (m_dragMode == PlotDragMode::BOOST_LEVEL) || (m_dragMode == PlotDragMode::BOOST_ITERATIONS); m_fastFrame = sceneChangingDrag; // 贴图之后、静态场景之外的全部内容(随鼠标变化或位于最上层)。 // 三个分支(完全命中 / 局部重画 / 整幅重绘)共用同一段,保证观感完全一致。 const auto paintOverlays = [this, &g]() { PaintHoverHighlight(g); PaintLegendHighlight(g); PaintRubberBand(g); PaintCursor(g, m_lanes); PaintEdgeCursor(g, m_lanes); PaintSignalInteraction(g); PaintHint(g); // 网格 Y 轴上下限的输入框绘制在最上层。 if (m_editTarget == PlotEditTarget::LANE_Y_MAX || m_editTarget == PlotEditTarget::LANE_Y_MIN) { PaintEditField(g); } PaintScrollBar(g); // 绘图区外框(比网格内部分界线更重,形成明确整体边界)。 // 用 GDI 画 4 条线:GDI+ 的 DrawRectangle 带抗锯齿时单帧要花掉 1ms 以上。 { const float left = m_plotRect.X; const float top = m_plotRect.Y; const float right = m_plotRect.X + m_plotRect.Width; const float bottom = m_plotRect.Y + m_plotRect.Height; const float border[16] = {left, top, right, top, right, top, right, bottom, right, bottom, left, bottom, left, bottom, left, top}; UiDrawSolidLines(g, gd::RectF(left - 2.0f, top - 2.0f, m_plotRect.Width + 4.0f, m_plotRect.Height + 4.0f), border, 4, m_theme->axisLine | 0xFF000000u, 2); } }; // 网格次序拖拽:静态场景缓存仍然有效,但各网格要按位移重新"摆放"。 // 这里逐网格把缓存中的横向条带平移贴回(几十微秒), // 因此拖动百万级采样的网格时依然流畅,且被拖网格的波形随鼠标自由移动。 if (m_dragMode == PlotDragMode::LANE_REORDER && m_laneDragFrom >= 0 && m_sceneValid) { PaintLaneReorder(g); paintOverlays(); return; } // 静态场景复用(不再局限于拖拽):只要视图几何、信号集合与显示样式都没变, // "绘图区底色 + 全部网格 + 时间轴"就逐像素相同,直接贴图即可。 // 大文件下整幅重绘约 80ms,贴图约 1ms —— 鼠标悬停、拖拽选区这些 // "只改一小块叠加层"的交互因此不再重绘波形。 // 随鼠标变化的内容(悬停高亮、读数框、选区、光标、拖拽虚影、输入框)一律画在贴图之后。 if (SceneCacheMatches() && BlitSceneCache(g)) { paintOverlays(); return; } // 只有"外观样式"变化(典型场景:拖动自定义颜色滑块实时调色):网格结构、视图几何、 // 波形采样都没变,只是颜色/不透明度/线宽/模式不同 —— 此时只重画受影响的网格, // 再写回缓存。(VERY_BIG_DATA 实测:整幅重绘 30ms+,单网格重画约 4ms。) { std::vector patchLanes; if (FindStyleOnlyLanes(patchLanes) && BlitSceneCache(g)) { RepaintSceneCachePatch(g, patchLanes); paintOverlays(); return; } } const double viewMin = Tab().ViewMin(); const double viewMax = Tab().ViewMax(); const double span = viewMax - viewMin; const double timeStep = NiceStep((span > 0.0) ? span : 1.0, kTimeTickTarget); std::vector timeTicks; BuildTicks(viewMin, viewMax, timeStep, timeTicks); // 绘图区整体底色。 UiFillRect(g, m_area, m_theme->plotBg); // 波形区域底色。 UiFillRect(g, m_plotRect, m_theme->cardBg); // 图例命中矩形每帧重建,避免残留已消失的信号。 m_legendHits.clear(); for (std::size_t index = 0; index < m_lanes.size(); ++index) { PaintLane(g, static_cast(index), m_lanes[index], timeTicks); } PaintTimeAxis(g, timeTicks, timeStep); // 此刻画布上正好是"绘图区底色 + 全部网格 + 时间轴",尚未绘制任何随鼠标变化的内容, // 因此是捕获静态场景缓存的最佳时点(必须在裁剪区已全部恢复之后执行)。 // ⚠️ 快速帧(交互期间,关闭了抗锯齿)**仍然捕获**(这样拖动参数滑块时能走 // "只重画受影响网格"的快路径),但打上 `m_sceneDegraded` 标记 → // `SceneCacheMatches()` 会拒绝命中,交互结束后的下一帧必定重画全质量版本。 CaptureSceneCache(g); m_sceneDegraded = m_fastFrame; paintOverlays(); } void PlotView::PaintSignalHaloFor(gd::Graphics& g, const ResolvedSignal& resolved, const PolylineCacheEntry& cached) { if (resolved.style == nullptr) { return; } // 光晕一律用折线描边(与"之前"的画法一致)。曾经在包络带模式下改成"带外扩填充", // 虽然更快但观感变了(用户反馈显示效果下降),因此恢复。 PaintSignalHalo(g, resolved, cached.points, cached.breaks); } void PlotView::PaintSignalHalo(gd::Graphics& g, const ResolvedSignal& resolved, const std::vector& points, const std::vector& breaks) { const bool drawLine = (resolved.style->mode != DrawMode::SCATTER); if (!drawLine || breaks.empty()) { // 散点模式没有光晕(原实现同样只对折线绘制光晕)。 return; } gd::Pen halo(ToGdiColor(WithAlpha(m_theme->accent, 0x66)), (resolved.style->lineWidth + 4.0f) * m_scale); halo.SetLineJoin(gd::LineJoinRound); halo.SetStartCap(gd::LineCapRound); halo.SetEndCap(gd::LineCapRound); 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(); if (stop <= start + 1) { continue; } g.DrawLines(&halo, points.data() + start, static_cast(stop - start)); } } void PlotView::PaintHoverHighlight(gd::Graphics& g) { if (m_tab == nullptr || m_dragMode != PlotDragMode::NONE) { // 拖拽过程中不显示悬停提示(拖拽中的信号光晕已绘入静态场景缓存)。 return; } const int saveState = g.Save(); g.SetClip(m_area, gd::CombineModeIntersect); // 悬停网格:一层很淡的强调色叠加。为了能画在静态场景之上, // 这里用半透明而非不透明混色,观感与原实现基本一致。 if (m_hoverLane >= 0 && m_hoverLane < static_cast(m_lanes.size())) { const gd::RectF& laneRect = m_lanes[static_cast(m_hoverLane)].rect; const float clipTop = std::max(laneRect.Y, m_plotRect.Y); const float clipBottom = std::min(laneRect.Y + laneRect.Height, m_plotRect.Y + m_plotRect.Height); if (clipBottom > clipTop) { // alpha 0x0A ≈ 4%,与原「不透明混色 3.5%」的观感基本一致。 UiFillRect(g, gd::RectF(laneRect.X, clipTop, laneRect.Width, clipBottom - clipTop), WithAlpha(m_theme->accent, 0x0A)); } } // 悬停信号:外扩一圈半透明强调色作为"可拖拽"提示。 if (m_hoverSignal.IsValid()) { const ResolvedSignal resolved = ResolveSignal(m_hoverSignal); if (resolved.data != nullptr && resolved.style != nullptr && resolved.visible) { const int laneIndex = m_tab->LaneOfSignal(m_hoverSignal); if (laneIndex >= 0 && laneIndex < static_cast(m_lanes.size())) { const LaneVisual& lane = m_lanes[static_cast(laneIndex)]; const PolylineCacheEntry* cached = AcquirePolyline(m_hoverSignal, resolved, lane); if (cached != nullptr) { PaintSignalHaloFor(g, resolved, *cached); } } } } g.Restore(saveState); } void PlotView::PaintLegendHighlight(gd::Graphics& g) { if (m_tab == nullptr || m_dragMode != PlotDragMode::NONE || !m_hoverLegend.IsValid()) { return; } // 图例条目的底色绘制在静态场景缓存里;悬停态随鼠标变化,不能进缓存, // 因此在贴图之后用半透明强调色在本条目范围内叠一层,形成“半高亮”提示。 const int laneIndex = m_tab->LaneOfSignal(m_hoverLegend); if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) { return; } const gd::RectF laneRect = m_lanes[static_cast(laneIndex)].rect; if (laneRect.Y > m_plotRect.Y + m_plotRect.Height || laneRect.Y + laneRect.Height < m_plotRect.Y) { return; } const int saveState = g.Save(); g.SetClip(m_area, gd::CombineModeIntersect); // 除了图例条目本身,还要把**对应的信号曲线**一并高亮:用户在网格图例上移动时 // 一眼就能确认这条条目对应哪条曲线(与"鼠标贴近曲线"时的光晕提示一致)。 const ResolvedSignal resolved = ResolveSignal(m_hoverLegend); if (resolved.data != nullptr && resolved.style != nullptr && resolved.visible) { const LaneVisual& lane = m_lanes[static_cast(laneIndex)]; if (const PolylineCacheEntry* cached = AcquirePolyline(m_hoverLegend, resolved, lane)) { PaintSignalHaloFor(g, resolved, *cached); } } for (const LegendHit& hit : m_legendHits) { if (hit.ref == m_hoverLegend) { UiFillRoundRect(g, hit.rect, 3.0f * m_scale, WithAlpha(m_theme->accent, 0x38)); break; } } g.Restore(saveState); } PlotView::SceneCacheKey PlotView::MakeSceneCacheKey() const { SceneCacheKey key; key.viewMin = Tab().ViewMin(); key.viewMax = Tab().ViewMax(); key.laneScroll = Tab().LaneScroll(); key.laneCount = static_cast(m_lanes.size()); key.areaX = m_area.X; key.areaY = m_area.Y; key.areaW = m_area.Width; key.areaH = m_area.Height; key.scale = m_scale; const SignalRef halo = SceneHaloRef(); key.haloFile = halo.fileIndex; key.haloSignal = halo.signalIndex; key.contentHash = SceneContentHash(); return key; } bool PlotView::SceneCacheMatches() const { if (!m_sceneValid) { return false; } // 缓存里存的是「快速帧」(关掉了抗锯齿)→ 不能直接命中: // 必须重画一次全质量版本,否则降级的画面会一直留在屏幕上。 if (m_sceneDegraded) { return false; } const SceneCacheKey& cached = m_sceneKey; const SceneCacheKey key = MakeSceneCacheKey(); return (key.viewMin == cached.viewMin) && (key.viewMax == cached.viewMax) && (key.laneScroll == cached.laneScroll) && (key.laneCount == cached.laneCount) && (key.areaX == cached.areaX) && (key.areaY == cached.areaY) && (key.areaW == cached.areaW) && (key.areaH == cached.areaH) && (key.scale == cached.scale) && (key.haloFile == cached.haloFile) && (key.haloSignal == cached.haloSignal) && (key.contentHash == cached.contentHash); } SignalRef PlotView::SceneHaloRef() const { // 只有拖拽中的信号光晕是“整个拖拽过程都不变”的,可以安全地绘入静态场景缓存; // 悬停光晕随鼠标移动,必须画在缓存之外(见 PaintHoverHighlight)。 if (m_dragMode == PlotDragMode::MOVE_SIGNAL && m_dragSignal.IsValid()) { return m_dragSignal; } return SignalRef(); } std::uint64_t PlotView::SceneContentHash(bool includeStyles) const { // FNV-1a:把“视图几何之外的一切影响绘图区像素的内容”折叠成一个指纹。 // 这样颜色/线宽/透明度/样式/重命名/信号增删等变化都会自动让缓存失效, // 不需要在各处手工调用 InvalidateSceneCache(容易漏)。 // `includeStyles == false` 时跳过逐信号的外观样式(颜色/不透明度/线宽/模式), // 得到的就是几何指纹:它一致说明“只有外观样式变了”。 std::uint64_t hash = 1469598103934665603ull; const auto mix = [&hash](std::uint64_t value) { hash ^= value; hash *= 1099511628211ull; }; const auto mixFloat = [&mix](float value) { std::uint32_t bits = 0; std::memcpy(&bits, &value, sizeof(bits)); mix(bits); }; const auto mixText = [&mix](const std::wstring& text) { mix(text.size()); for (const wchar_t character : text) { mix(static_cast(character)); } }; mixFloat(m_plotRect.X); mixFloat(m_plotRect.Y); mixFloat(m_plotRect.Width); mixFloat(m_plotRect.Height); mixFloat(m_laneContentHeight); mix(m_scrollBarVisible ? 1ull : 0ull); // 选中信号会改变图例条目的底色与文字颜色(绘制在缓存的静态场景里), // 因此必须参与指纹:否则在左侧信号面板点选后图例不会高亮联动。 { const SignalRef selected = SelectedRef(); mix(selected.IsValid() ? 1ull : 0ull); mix(static_cast(selected.fileIndex)); mix(static_cast(selected.signalIndex)); } for (std::size_t laneIndex = 0; laneIndex < m_lanes.size(); ++laneIndex) { const LaneVisual& visual = m_lanes[laneIndex]; mixFloat(visual.rect.Y); mixFloat(visual.rect.Height); mixFloat(static_cast(visual.yMin)); mixFloat(static_cast(visual.yMax)); const std::vector& signals = Tab().Lanes()[laneIndex].signals; // 「边沿计数(中心轴)」「均值滤波」「弱波增强」的开关与每条被勾选信号的参数 // 会改变网格内的曲线绘制。它们**只影响网格内容**(不影响网格结构/几何), // 因此只在 includeStyles 时计入 —— 于是拖参数滑块可以走“局部重画”快路径, // 而不必整幅重绘(`FindStyleOnlyLanes` 用 `m_sceneDerived` 定位受影响的网格)。 if (includeStyles) { mix(Tab().Lanes()[laneIndex].edgeCenter ? 1ull : 0ull); mix(static_cast(Tab().Lanes()[laneIndex].edgeCenterItems.size())); for (const Lane::EdgeCenterItem& item : Tab().Lanes()[laneIndex].edgeCenterItems) { mix(static_cast(item.ref.fileIndex)); mix(static_cast(item.ref.signalIndex)); mix(static_cast(item.points)); mix(static_cast(item.iterations)); } mix(Tab().Lanes()[laneIndex].meanFilter ? 1ull : 0ull); mix(static_cast(Tab().Lanes()[laneIndex].meanFilterItems.size())); for (const Lane::MeanFilterItem& item : Tab().Lanes()[laneIndex].meanFilterItems) { mix(static_cast(item.ref.fileIndex)); mix(static_cast(item.ref.signalIndex)); mix(static_cast(item.points)); mix(static_cast(item.iterations)); } mix(Tab().Lanes()[laneIndex].weakBoost ? 1ull : 0ull); mixFloat(static_cast(Tab().Lanes()[laneIndex].weakBoostLevel)); mix(static_cast(Tab().Lanes()[laneIndex].weakBoostIterations)); mix(static_cast(Tab().Lanes()[laneIndex].weakBoostItems.size())); for (const Lane::WeakBoostItem& item : Tab().Lanes()[laneIndex].weakBoostItems) { mix(static_cast(item.ref.fileIndex)); mix(static_cast(item.ref.signalIndex)); } } mix(signals.size()); for (const SignalRef& ref : signals) { const ResolvedSignal resolved = ResolveSignal(ref); mix(static_cast(ref.fileIndex)); mix(static_cast(ref.signalIndex)); mix(resolved.visible ? 1ull : 0ull); if (resolved.data == nullptr || resolved.style == nullptr) { continue; } // 以下四项是“只影响外观”的样式:仅在 includeStyles 时计入指纹。 // (不参与时得到的就是 SceneGeometryHash:它一致说明只有外观变了, // 于是可以只重画颜色变了的网格,而不必整幅重绘。) if (includeStyles) { mix(static_cast(resolved.style->color)); mixFloat(resolved.style->opacity); mixFloat(resolved.style->lineWidth); mix(static_cast(resolved.style->mode)); } // 图例文案参与绘制,重命名必须让缓存失效。 mixText(resolved.data->Title()); if (resolved.file != nullptr) { mixText(resolved.file->Title()); } } } return hash; } std::uint64_t PlotView::SceneGeometryHash() const { return SceneContentHash(false); } void PlotView::CollectSceneStyles(std::vector>& out) const { out.clear(); out.resize(m_lanes.size()); for (std::size_t laneIndex = 0; laneIndex < m_lanes.size(); ++laneIndex) { const std::vector& signals = Tab().Lanes()[laneIndex].signals; std::vector& lane = out[laneIndex]; lane.reserve(signals.size()); for (const SignalRef& ref : signals) { const ResolvedSignal resolved = ResolveSignal(ref); SceneStyleEntry entry; if (resolved.style != nullptr) { entry.color = resolved.style->color; entry.opacity = resolved.style->opacity; entry.lineWidth = resolved.style->lineWidth; entry.mode = static_cast(resolved.style->mode); } lane.push_back(entry); } } } bool PlotView::FindStyleOnlyLanes(std::vector& lanes) const { lanes.clear(); if (!m_sceneValid || m_sceneGeometryHash != SceneGeometryHash()) { // 网格结构 / 视图几何 / 信号集合 / 图例文案有变化:必须整幅重绘。 return false; } std::vector> current; CollectSceneStyles(current); if (current.size() != m_sceneStyles.size()) { return false; } // 叠加预览曲线(均值滤波 / 中心轴 / 弱波增强)的参数变化同样只需局部重画。 std::vector currentDerived; CollectSceneDerived(currentDerived); const bool derivedComparable = (currentDerived.size() == m_sceneDerived.size()); for (std::size_t laneIndex = 0; laneIndex < current.size(); ++laneIndex) { const std::vector& now = current[laneIndex]; const std::vector& before = m_sceneStyles[laneIndex]; if (now.size() != before.size()) { return false; } bool differs = false; for (std::size_t index = 0; index < now.size() && !differs; ++index) { differs = (now[index].color != before[index].color) || (now[index].opacity != before[index].opacity) || (now[index].lineWidth != before[index].lineWidth) || (now[index].mode != before[index].mode); } if (!differs && derivedComparable) { const SceneDerivedEntry& nowDerived = currentDerived[laneIndex]; const SceneDerivedEntry& beforeDerived = m_sceneDerived[laneIndex]; differs = (nowDerived.edgeCenter != beforeDerived.edgeCenter) || (nowDerived.meanFilter != beforeDerived.meanFilter) || (nowDerived.weakBoost != beforeDerived.weakBoost) || (nowDerived.weakBoostLevelPercent != beforeDerived.weakBoostLevelPercent) || (nowDerived.weakBoostIterations != beforeDerived.weakBoostIterations) || (nowDerived.edgeCenterItems != beforeDerived.edgeCenterItems) || (nowDerived.meanFilterItems != beforeDerived.meanFilterItems) || (nowDerived.weakBoostItems != beforeDerived.weakBoostItems); } if (differs) { lanes.push_back(static_cast(laneIndex)); } } return !lanes.empty(); } void PlotView::CollectSceneDerived(std::vector& out) const { out.clear(); out.resize(m_lanes.size()); for (std::size_t laneIndex = 0; laneIndex < m_lanes.size(); ++laneIndex) { const Lane& lane = Tab().Lanes()[laneIndex]; SceneDerivedEntry entry; entry.edgeCenter = lane.edgeCenter ? 1 : 0; entry.meanFilter = lane.meanFilter ? 1 : 0; entry.weakBoost = lane.weakBoost ? 1 : 0; entry.weakBoostLevelPercent = static_cast( std::lround(std::max(0.0, std::min(1.0, lane.weakBoostLevel)) * 100.0)); entry.weakBoostIterations = lane.weakBoostIterations; const auto flatten = [](const auto& items, std::vector& target) { target.clear(); target.reserve(items.size() * 4); for (const auto& item : items) { target.push_back(item.ref.fileIndex); target.push_back(item.ref.signalIndex); target.push_back(item.points); target.push_back(item.iterations); } }; flatten(lane.edgeCenterItems, entry.edgeCenterItems); flatten(lane.meanFilterItems, entry.meanFilterItems); flatten(lane.weakBoostItems, entry.weakBoostItems); out[laneIndex] = std::move(entry); } } void PlotView::RepaintSceneCachePatch(gd::Graphics& g, const std::vector& lanes) { if (lanes.empty() || m_sceneDc == nullptr) { return; } // 重画范围 = 受影响网格与绘图区的交集(并且不超出缓存区域)。 float top = 1.0e9f; float bottom = -1.0e9f; for (const int laneIndex : lanes) { if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) { continue; } const gd::RectF& rect = m_lanes[static_cast(laneIndex)].rect; top = std::min(top, rect.Y); bottom = std::max(bottom, rect.Y + rect.Height); } if (bottom <= top) { return; } top = std::max(top, m_plotRect.Y); bottom = std::min(bottom, m_plotRect.Y + m_plotRect.Height); if (bottom <= top) { return; } // ⚠️ 补丁矩形必须**向内**对齐到整数像素(ceil 上边界、floor 下边界): // 网格矩形是浮点数,而 GDI `FillRect`/`BitBlt` 会按 floor/ceil 向外取整, // "向外"会多擦掉/多写回一整行,那一行属于相邻网格(例如它的分界线), // 于是补丁边界上会留下一像素接缝。向内对齐后,边界外的行一律由缓存提供 // (缓存内容与整幅重绘逐像素一致,已由对照帧验证)。 const int x0 = std::max(static_cast(std::ceil(m_area.X)), static_cast(std::ceil(m_area.X))); const int y0 = std::max(static_cast(std::ceil(top)), static_cast(std::ceil(m_area.Y))); const int x1 = std::min(static_cast(std::floor(m_area.X + m_area.Width)), static_cast(std::floor(m_area.X + m_area.Width))); const int y1 = std::min(static_cast(std::floor(bottom)), static_cast(std::floor(m_area.Y + m_area.Height))); if (x1 <= x0 || y1 <= y0) { return; } const gd::RectF patch(static_cast(x0), static_cast(y0), static_cast(x1 - x0), static_cast(y1 - y0)); // 补丁区域必须被**完整**重画:凡是与它相交的网格都要重画一遍 // (补丁已向内对齐,正常只会命中受影响的那几个网格)。 std::vector repaint; for (std::size_t laneIndex = 0; laneIndex < m_lanes.size(); ++laneIndex) { const gd::RectF& rect = m_lanes[laneIndex].rect; if (rect.Y < static_cast(y1) && rect.Y + rect.Height > static_cast(y0)) { repaint.push_back(static_cast(laneIndex)); } } if (repaint.empty()) { return; } // 与整幅重绘保持完全相同的绘制顺序:底色 → 逐网格内容(PaintLane 会与当前裁剪区求交)。 { const int saveState = g.Save(); g.SetClip(patch, gd::CombineModeIntersect); // ⚠️ 底色填充必须**按补丁矩形本身**填充,不能用 m_area / m_plotRect: // `UiFillRect` 对大面积不透明矩形会走 GDI `FillRect` 快路径,而 GDI 绘制 // **不受 GDI+ 裁剪区约束**,会把已经贴好的缓存内容整片覆盖掉。 UiFillRect(g, patch, m_theme->plotBg); const float cardTop = std::max(patch.Y, m_plotRect.Y); const float cardBottom = std::min(patch.Y + patch.Height, m_plotRect.Y + m_plotRect.Height); if (cardBottom > cardTop) { UiFillRect(g, gd::RectF(patch.X, cardTop, patch.Width, cardBottom - cardTop), m_theme->cardBg); } const double viewMin = Tab().ViewMin(); const double viewMax = Tab().ViewMax(); const double span = viewMax - viewMin; const double timeStep = NiceStep((span > 0.0) ? span : 1.0, kTimeTickTarget); std::vector timeTicks; BuildTicks(viewMin, viewMax, timeStep, timeTicks); for (const int laneIndex : repaint) { if (laneIndex >= 0 && laneIndex < static_cast(m_lanes.size())) { PaintLane(g, laneIndex, m_lanes[static_cast(laneIndex)], timeTicks); } } g.Restore(saveState); } // 把重画结果写回缓存位图,使缓存与当前样式一致(下一帧即回到"完全命中"快路径)。 { HDC target = g.GetHDC(); if (target != nullptr) { BitBlt(m_sceneDc, x0 - m_sceneX, y0 - m_sceneY, x1 - x0, y1 - y0, target, x0, y0, SRCCOPY); g.ReleaseHDC(target); } } m_sceneKey = MakeSceneCacheKey(); m_sceneGeometryHash = SceneGeometryHash(); CollectSceneStyles(m_sceneStyles); CollectSceneDerived(m_sceneDerived); m_sceneDegraded = m_sceneDegraded || m_fastFrame; } gd::RectF PlotView::LaneBadgeRect(int laneIndex) const { if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) { return gd::RectF(); } const LaneVisual& lane = m_lanes[static_cast(laneIndex)]; const gd::RectF legendBand(lane.rect.X + kLegendPad * m_scale, lane.rect.Y + kLegendPad * m_scale, lane.rect.Width, kLegendHeight * m_scale); // 网格被滚动出可视区时没有徽标可点。 const float visibleTop = std::max(lane.rect.Y, m_plotRect.Y); const float visibleBottom = std::min(lane.rect.Y + lane.rect.Height, m_plotRect.Y + m_plotRect.Height); if (!IsFullyVisible(legendBand, visibleTop, visibleBottom)) { return gd::RectF(); } const float height = kLegendHeight * m_scale; const float badgeHeight = kLaneBadgeHeight * m_scale; return gd::RectF(legendBand.X, legendBand.Y + (height - badgeHeight) * 0.5f, kLaneBadgeWidth * m_scale, badgeHeight); } int PlotView::LaneBadgeAtPoint(int x, int y) const { const float fx = static_cast(x); const float fy = static_cast(y); for (std::size_t index = 0; index < m_lanes.size(); ++index) { const gd::RectF badge = LaneBadgeRect(static_cast(index)); if (badge.Width > 0.0f && UiPointInRect(badge, fx, fy)) { return static_cast(index); } } return -1; } float PlotView::LaneDragOffset(int laneIndex) const { if (m_dragMode != PlotDragMode::LANE_REORDER || m_laneDragFrom < 0 || laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) { return 0.0f; } const float laneHeight = m_lanes[static_cast(m_laneDragFrom)].rect.Height; if (laneIndex == m_laneDragFrom) { // 被拖拽的网格自由跟随鼠标(整体绘制区域跟着走)。 const float baseTop = m_lanes[static_cast(laneIndex)].rect.Y; return static_cast(m_laneDragMouseY) - m_laneDragGrabOffset - baseTop; } const int target = (m_laneDragTarget >= 0) ? m_laneDragTarget : m_laneDragFrom; if (target > m_laneDragFrom && laneIndex > m_laneDragFrom && laneIndex < target) { // 向下拖:被跨越的网格整体上移一格让出位置(大小不变)。 // 移动到插入位置 target 的结果是 [1,2,...,A,target,...], // 因此需要上移的是 (from, target) 区间内的网格,不含 target 本身。 return -laneHeight; } if (target < m_laneDragFrom && laneIndex >= target && laneIndex < m_laneDragFrom) { // 向上拖:中间的网格整体下移一格。 return laneHeight; } return 0.0f; } int PlotView::LaneDragTargetAt(int mouseY) const { if (m_lanes.empty()) { return 0; } const int count = static_cast(m_lanes.size()); const float laneHeight = m_lanes.front().rect.Height; if (!(laneHeight > 0.0f)) { return m_laneDragFrom; } // 以"被拖网格的中心"为判据:中心越过某条网格的中线即认为目标变为该位置。 const float centerY = static_cast(mouseY) - m_laneDragGrabOffset + laneHeight * 0.5f; const float firstTop = m_lanes.front().rect.Y; int index = static_cast(std::floor((centerY - firstTop) / laneHeight + 0.5f)); index = std::max(0, std::min(index, count)); return index; } 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(); } void PlotView::PaintLaneReorder(gd::Graphics& g) { if (m_dragMode != PlotDragMode::LANE_REORDER || m_laneDragFrom < 0 || m_sceneDc == nullptr || m_lanes.empty()) { return; } const float laneHeight = m_lanes.front().rect.Height; if (!(laneHeight > 0.0f)) { return; } // 1) 先把整个绘图区(含时间轴)从静态场景缓存贴回:网格次序拖拽只改变网格内部的 // 条带位置,时间轴/边框等内容必须原样保留,否则鼠标拖到工具栏或时间轴上时 // 那一区域会残留上一帧的内容。 if (!BlitSceneCache(g)) { return; } // 2) 把"网格区域"整片铺成绘图区底色:让位留下的空档就是它的原色, // 网格线/波形随后按各自位移贴回。 UiFillRect(g, m_plotRect, m_theme->cardBg); HDC target = g.GetHDC(); if (target == nullptr) { return; } // 逐网格把缓存中该网格的横向条带按位移贴到屏幕上(非常廉价,整幅波形不必重绘)。 // 被拖拽的网格最后贴,因此始终压在最上层,形成"被拎起来"的观感。 std::vector laneOrder; for (int index = 0; index < static_cast(m_lanes.size()); ++index) { if (index != m_laneDragFrom) { laneOrder.push_back(index); } } if (m_laneDragFrom < static_cast(m_lanes.size())) { laneOrder.push_back(m_laneDragFrom); } for (const int index : laneOrder) { const gd::RectF& rect = m_lanes[static_cast(index)].rect; const float offset = LaneDragOffset(index); // 源:缓存中该网格的条带;目标:屏幕上的同一条带平移 offset。 const float sourceTop = rect.Y; const float destTop = rect.Y + offset; // 与绘图区求交(并夹进缓存区域),避免把波形贴到工具栏或时间轴上。 const float clipTop = std::max(std::max(destTop, m_plotRect.Y), m_plotRect.Y); const float clipBottom = std::min(std::min(destTop + rect.Height, m_plotRect.Y + m_plotRect.Height), m_plotRect.Y + m_plotRect.Height); if (clipBottom - clipTop < 1.0f) { continue; } const int x = static_cast(std::floor(rect.X)); const int width = static_cast(std::ceil(rect.X + rect.Width)) - x; const int y0 = static_cast(std::ceil(clipTop)); const int y1 = static_cast(std::floor(clipBottom)); if (y1 <= y0 || width <= 0) { continue; } const int sourceY = static_cast(std::lround(sourceTop + (static_cast(y0) - destTop))); BitBlt(target, x, y0, width, y1 - y0, m_sceneDc, x - m_sceneX, sourceY - m_sceneY, SRCCOPY); } g.ReleaseHDC(target); // 3) 目标位置的高亮提示与被拖网格的轮廓都**必须夹在网格区域内**绘制: // 鼠标拖到工具栏/时间轴上时,若不裁剪,强调色边框会画到那些区域上。 { const int clipState = g.Save(); g.SetClip(m_plotRect, gd::CombineModeIntersect); // 目标位置的高亮提示(吸附提示):在被拖网格将要落到的槽位上画一层强调色底 + 边框。 const int dropSlot = (m_laneDragTarget >= 0) ? m_laneDragTarget : m_laneDragFrom; float slotTop = m_lanes.front().rect.Y + static_cast(dropSlot) * laneHeight; if (dropSlot > m_laneDragFrom) { slotTop -= laneHeight; } const gd::RectF slot(m_plotRect.X, slotTop, m_plotRect.Width, laneHeight); if (slot.Height > 0.0f) { UiFillRect(g, slot, WithAlpha(m_theme->accent, 0x1E)); UiDrawRoundRect(g, gd::RectF(slot.X + 1.0f * m_scale, slot.Y + 1.0f * m_scale, slot.Width - 2.0f * m_scale, slot.Height - 2.0f * m_scale), 3.0f * m_scale, m_theme->accent, 1.6f * m_scale); } // 被拖网格的轮廓:加强"整块网格被拎起"的观感。 const gd::RectF& dragged = m_lanes[static_cast(m_laneDragFrom)].rect; const float destTop = dragged.Y + LaneDragOffset(m_laneDragFrom); const gd::RectF outline(dragged.X + 1.0f * m_scale, destTop + 1.0f * m_scale, dragged.Width - 2.0f * m_scale, dragged.Height - 2.0f * m_scale); UiFillRoundRect(g, gd::RectF(outline.X + 2.0f * m_scale, outline.Y + 3.0f * m_scale, outline.Width, outline.Height), 3.0f * m_scale, 0x22000000u); UiDrawRoundRect(g, outline, 3.0f * m_scale, m_theme->accent, 1.8f * m_scale); g.Restore(clipState); } } bool PlotView::EnsureSceneCache(int width, int height) { if (width <= 0 || height <= 0) { return false; } // 位图尺寸必须与当前缓存区域**严格一致**:贴图时按位图尺寸整块拷贝, // 若沿用更大的旧位图,缩小窗口后会把旧像素画到绘图区之外。 if (m_sceneDc != nullptr && m_sceneWidth == width && m_sceneHeight == height) { return true; } ReleaseSceneCache(); // 场景位图重建 → 面板图层里缓存的"面板背后的场景"随之失效。 m_panelValid = false; HDC screen = GetDC(nullptr); if (screen == nullptr) { return false; } m_sceneDc = CreateCompatibleDC(screen); m_sceneBitmap = CreateCompatibleBitmap(screen, width, height); ReleaseDC(nullptr, screen); if (m_sceneDc == nullptr || m_sceneBitmap == nullptr) { ReleaseSceneCache(); return false; } m_sceneOldBitmap = SelectObject(m_sceneDc, m_sceneBitmap); m_sceneWidth = width; m_sceneHeight = height; return true; } bool PlotView::CaptureSceneCache(gd::Graphics& g) { // 缓存区域严格限定为绘图区本身(m_area):工具栏/标签栏/面板由宿主每帧绘制, // 因为它们各自带有悬停态;绘图区的波形才是真正昂贵的部分(大文件约 80ms/帧)。 const int x = static_cast(std::floor(m_area.X)); const int y = static_cast(std::floor(m_area.Y)); const int right = static_cast(std::ceil(m_area.X + m_area.Width)); const int bottom = static_cast(std::ceil(m_area.Y + m_area.Height)); const int width = right - x; const int height = bottom - y; if (width <= 0 || height <= 0) { m_sceneValid = false; return false; } m_sceneX = x; m_sceneY = y; if (!EnsureSceneCache(width, height)) { m_sceneValid = false; return false; } HDC source = g.GetHDC(); if (source == nullptr) { m_sceneValid = false; return false; } const BOOL ok = BitBlt(m_sceneDc, 0, 0, width, height, source, m_sceneX, m_sceneY, SRCCOPY); g.ReleaseHDC(source); if (ok == FALSE) { m_sceneValid = false; return false; } m_sceneKey = MakeSceneCacheKey(); m_sceneGeometryHash = SceneGeometryHash(); CollectSceneStyles(m_sceneStyles); CollectSceneDerived(m_sceneDerived); m_sceneDegraded = false; m_sceneValid = true; return true; } bool PlotView::BlitSceneCache(gd::Graphics& g) { if (!m_sceneValid || m_sceneDc == nullptr) { return false; } HDC target = g.GetHDC(); if (target == nullptr) { return false; } const BOOL ok = BitBlt(target, m_sceneX, m_sceneY, m_sceneWidth, m_sceneHeight, m_sceneDc, 0, 0, SRCCOPY); g.ReleaseHDC(target); return ok != FALSE; } void PlotView::InvalidateSceneCache() { m_sceneValid = false; m_sceneStyles.clear(); m_sceneDerived.clear(); m_sceneDegraded = false; m_sceneGeometryHash = 0; // 面板像素图层与场景缓存同源(面板像素里含"面板背后的场景"): // 信号增删、重命名、参数变化等一律连带作废,避免面板里残留旧波形。 m_panelValid = false; } void PlotView::ReleaseSceneCache() { m_sceneValid = false; if (m_sceneDc != nullptr) { if (m_sceneOldBitmap != nullptr) { SelectObject(m_sceneDc, m_sceneOldBitmap); m_sceneOldBitmap = nullptr; } DeleteDC(m_sceneDc); m_sceneDc = nullptr; } if (m_sceneBitmap != nullptr) { DeleteObject(m_sceneBitmap); m_sceneBitmap = nullptr; } m_sceneWidth = 0; m_sceneHeight = 0; } void PlotView::PaintRubberBand(gd::Graphics& g) { if (!m_dragMoved || m_rubberRect.Width <= 0.0f || m_rubberRect.Height <= 0.0f) { return; } if (m_dragMode != PlotDragMode::RUBBER_SELECT) { return; } // 轴向未定时不绘制选区,避免刚按下就闪出一个奇怪的方框。 const bool timeMode = (m_rubberAxis == PlotRubberAxis::TIME); const bool valueMode = (m_rubberAxis == PlotRubberAxis::VALUE); if (!timeMode && !valueMode) { return; } // 选区只应出现在绘图区内:即使内部矩形意外越界(例如鼠标被捕获到工具栏 / // 信号面板上),也在这里再夹一次,绝不把选区块画到绘图区之外。 const float clipLeft = std::max(m_rubberRect.X, m_plotRect.X); const float clipTop = std::max(m_rubberRect.Y, m_plotRect.Y); const float clipRight = std::min(m_rubberRect.X + m_rubberRect.Width, m_plotRect.X + m_plotRect.Width); const float clipBottom = std::min(m_rubberRect.Y + m_rubberRect.Height, m_plotRect.Y + m_plotRect.Height); if (clipRight <= clipLeft || clipBottom <= clipTop) { return; } const gd::RectF bandRect(clipLeft, clipTop, clipRight - clipLeft, clipBottom - clipTop); // 两种方向的选区使用**同一种强调色**,保持视觉一致。 const std::uint32_t band = m_theme->accent; UiFillRect(g, bandRect, WithAlpha(band, 0x28)); UiDrawRoundRect(g, bandRect, 2.0f * m_scale, band, 1.4f); // 选区跨度提示(时间用秒/数值用幅度量纲),放在选区顶部。 std::wstring text; if (timeMode) { text = FormatValue(std::fabs(XToTime(m_rubberRect.X + m_rubberRect.Width) - XToTime(m_rubberRect.X))); } else if (m_dragLane >= 0 && m_dragLane < static_cast(m_lanes.size())) { const LaneVisual& lane = m_lanes[static_cast(m_dragLane)]; text = FormatValue(std::fabs(YToValue(m_rubberRect.Y + m_rubberRect.Height, lane) - YToValue(m_rubberRect.Y, lane))); } if (text.empty()) { return; } const float textWidth = UiMeasureTextWidth(m_fonts->monoSmall, text) + 12.0f * m_scale; const float textHeight = 18.0f * m_scale; float labelX = bandRect.X + bandRect.Width * 0.5f - textWidth * 0.5f; labelX = std::max(m_plotRect.X, std::min(labelX, m_plotRect.X + m_plotRect.Width - textWidth)); const gd::RectF labelRect(labelX, bandRect.Y + 3.0f * m_scale, textWidth, textHeight); UiFillRoundRect(g, labelRect, 3.0f * m_scale, band); UiText(g, text, m_fonts->monoSmall, labelRect, m_theme->textOnAccent, TextAlign::CENTER); } void PlotView::PaintHint(gd::Graphics& g) { if (m_hintText.empty() || m_fonts == nullptr || m_fonts->ui == nullptr || m_hintShownAt == 0) { return; } const std::uint64_t elapsed = GetTickCount64() - m_hintShownAt; // 粘滞状态下不淡出(用于"拖动过程中维持显示")。 if (!m_hintSticky && elapsed >= (kHintHoldMs + kHintFadeMs)) { return; } // 停留结束后在 kHintFadeMs 内线性淡出(颜色 alpha 按比例缩放)。 const double fade = (m_hintSticky || elapsed <= kHintHoldMs) ? 1.0 : (1.0 - static_cast(elapsed - kHintHoldMs) / static_cast(kHintFadeMs)); const unsigned int alpha = static_cast(std::max(0.0, std::min(1.0, fade)) * 255.0); // 提示条贴在绘图区底部居中;时间光标的时间读数也在底部, // 因此光标启用时把提示条上移一行,避免两者互相遮挡。 const float reserved = (m_tab != nullptr && Tab().CursorEnabled()) ? 26.0f * m_scale : 0.0f; const float width = UiMeasureTextWidth(m_fonts->ui, m_hintText) + 30.0f * m_scale; const float height = 30.0f * m_scale; float left = m_plotRect.X + (m_plotRect.Width - 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 pill(left, m_plotRect.Y + m_plotRect.Height - height - 4.0f * m_scale - reserved, width, height); UiFillRoundRect(g, pill, 4.0f * m_scale, ScaleAlpha(m_theme->readoutBg, alpha)); UiDrawRoundRect(g, pill, 4.0f * m_scale, ScaleAlpha(m_theme->borderStrong, alpha), 1.2f); // 文字走 GDI(不支持 alpha 通道),因此把文字颜色向胶囊底色**混色**来实现淡出。 const std::uint32_t textColor = MixColor(m_theme->textStrong, m_theme->readoutBg, 1.0f - static_cast(fade)); UiText(g, m_hintText, m_fonts->ui, pill, textColor, TextAlign::CENTER); } void PlotView::PaintLane(gd::Graphics& g, int laneIndex, const LaneVisual& lane, const std::vector& timeTicks) { // 网格内容必须裁剪在绘图区之内:纵向滚动时网格会部分移出可视范围, // 若不与 m_plotRect 求交,波形就会溢出到工具栏与时间轴区域。 const float clipTop = std::max(lane.rect.Y, m_plotRect.Y); const float clipBottom = std::min(lane.rect.Y + lane.rect.Height, m_plotRect.Y + m_plotRect.Height); if (clipBottom <= clipTop) { return; } const int saveState = g.Save(); // 与当前裁剪区求交(而不是替换):局部重画(RepaintSceneCachePatch)时 // 外层已经按“需要重画的网格范围”设过裁剪区,这里必须保留,否则会重画到 // 其他网格上。整幅重绘时外层没有裁剪区,等同于原来的行为。 g.SetClip(gd::RectF(m_area.X, clipTop, m_area.Width, clipBottom - clipTop), gd::CombineModeIntersect); // 幅值网格线(先画线,标尺稍后作为覆盖层绘制,避免被波形遮挡)。 // 刻度密度随网格高度自适应:所有网格高度相同 → 各网格的刻度数量一致, // 不会出现“有的网格只有 1 条标签、有的有 3 条”的不一致观感。 // // ⚠️ 网格线一律用 **GDI** 批量绘制:GDI+ 的 DrawLine 每条约 0.1–0.19ms, // 而一张网格有几十条网格线(5 个网格就是数百条),逐条调用会让单帧多花十几毫秒 // —— 这正是"放大后拖动网格视界很卡"的主因。GDI 绘制不受 GDI+ 裁剪约束, // 因此显式传入裁剪矩形;半透明辅助线先与网格底色混合成不透明色。 const gd::RectF gridClip = gd::RectF(m_area.X, clipTop, m_area.Width, clipBottom - clipTop); /** 标尺数值文本的裁剪矩形(GDI 文本需要显式裁剪,否则会越过绘图区)。 */ const gd::RectF clipRectForLabels = gridClip; const std::uint32_t minorLineColor = UiBlendOver(m_theme->cardBg, WithAlpha(m_theme->gridWeak, kMinorLineAlpha)); const std::uint32_t weakLineColor = m_theme->gridWeak | 0xFF000000u; const std::uint32_t strongLineColor = m_theme->gridStrong | 0xFF000000u; int yTickTarget = static_cast(std::lround(lane.rect.Height / (kYTickSpacing * m_scale))); yTickTarget = std::max(kYTickTargetMin, std::min(yTickTarget, kYTickTargetMax)); const double yStep = NiceStep(lane.yMax - lane.yMin, yTickTarget); std::vector yTicks; BuildTicks(lane.yMin, lane.yMax, yStep, yTicks); // 五等分估读线:在相邻主刻度之间再细分,便于目视读取中间值。 const double yMinorStep = yStep / static_cast(kMinorDivisions); std::vector yMinorTicks; BuildTicks(lane.yMin, lane.yMax, yMinorStep, yMinorTicks); std::vector minorSegments; std::vector weakSegments; std::vector strongSegments; minorSegments.reserve(yMinorTicks.size() * 4); weakSegments.reserve(yTicks.size() * 4); for (const double tick : yMinorTicks) { // 与主刻度重合的位置跳过(主刻度会以更重的线条绘制)。 const double majorIndex = tick / yStep; if (std::fabs(majorIndex - std::floor(majorIndex + 0.5)) < 1.0e-6) { continue; } const float y = ValueToY(tick, lane); if (y < lane.rect.Y - 1.0f || y > lane.rect.Y + lane.rect.Height + 1.0f) { continue; } minorSegments.insert(minorSegments.end(), {lane.rect.X, y, lane.rect.X + lane.rect.Width, y}); } for (const double tick : yTicks) { const float y = ValueToY(tick, lane); if (y < lane.rect.Y - 1.0f || y > lane.rect.Y + lane.rect.Height + 1.0f) { continue; } // 零线用更重的颜色(单独一批,避免为每段颜色切换画笔)。 std::vector& target = (tick == 0.0) ? strongSegments : weakSegments; target.insert(target.end(), {lane.rect.X, y, lane.rect.X + lane.rect.Width, y}); } // 时间网格线(同样补充五等分估读线)。 const double timeSpan = Tab().ViewMax() - Tab().ViewMin(); const double timeMajorStep = NiceStep((timeSpan > 0.0) ? timeSpan : 1.0, kTimeTickTarget); const double timeMinorStep = timeMajorStep / static_cast(kMinorDivisions); std::vector timeMinorTicks; BuildTicks(Tab().ViewMin(), Tab().ViewMax(), timeMinorStep, timeMinorTicks); for (const double tick : timeMinorTicks) { const double majorIndex = tick / timeMajorStep; if (std::fabs(majorIndex - std::floor(majorIndex + 0.5)) < 1.0e-6) { continue; } const float x = TimeToX(tick); if (x < lane.rect.X || x > lane.rect.X + lane.rect.Width) { continue; } minorSegments.insert(minorSegments.end(), {x, lane.rect.Y, x, lane.rect.Y + lane.rect.Height}); } for (const double tick : timeTicks) { const float x = TimeToX(tick); if (x < lane.rect.X || x > lane.rect.X + lane.rect.Width) { continue; } weakSegments.insert(weakSegments.end(), {x, lane.rect.Y, x, lane.rect.Y + lane.rect.Height}); } if (!minorSegments.empty()) { UiDrawSolidLines(g, gridClip, minorSegments.data(), minorSegments.size() / 4, minorLineColor); } if (!weakSegments.empty()) { UiDrawSolidLines(g, gridClip, weakSegments.data(), weakSegments.size() / 4, weakLineColor); } if (!strongSegments.empty()) { UiDrawSolidLines(g, gridClip, strongSegments.data(), strongSegments.size() / 4, strongLineColor); } // 波形绘制。 const std::vector& laneSignals = m_tab->Lanes()[static_cast(laneIndex)].signals; if (laneSignals.empty()) { const gd::RectF visibleRect(lane.rect.X, clipTop, lane.rect.Width, clipBottom - clipTop); UiText(g, L"(空网格)", m_fonts->uiSmall, visibleRect, m_theme->textMuted, TextAlign::CENTER); } else { const int waveSave = g.Save(); g.SetClip(lane.rect, gd::CombineModeIntersect); // 只有拖拽中的信号光晕可以进入静态场景缓存(悬停光晕见 PaintHoverHighlight)。 const SignalRef haloRef = SceneHaloRef(); // 「边沿计数(中心轴)」开启时,为**被勾选**的信号各叠加一条均值滤波曲线 //(参数 = 该信号自己的条目:均值点数 X + 迭代次数 N,与「均值滤波」完全同一套语义)。 const bool centerOn = Tab().Lanes()[static_cast(laneIndex)].edgeCenter; // 「均值滤波」开启时,为**被勾选**的信号各叠加一条多次迭代滑动平均曲线 //(均值点数 X、迭代次数 N 由该信号自己的条目给出)。 const bool meanFilterOn = Tab().Lanes()[static_cast(laneIndex)].meanFilter; // 「弱波增强」开启时同样只为**被勾选**的信号叠加曲线 //(增强等级取整个网格共用的 `weakBoostLevel`)。 const bool weakBoostOn = Tab().Lanes()[static_cast(laneIndex)].weakBoost; for (const SignalRef& ref : laneSignals) { const ResolvedSignal resolved = ResolveSignal(ref); if (resolved.data == nullptr || resolved.style == nullptr || !resolved.visible) { continue; } const DrawMode mode = resolved.style->mode; // 顶点由“几何键”缓存提供:同一视图下重复绘制不再重算全量采样。 const PolylineCacheEntry* cached = AcquirePolyline(ref, resolved, lane); if (cached == nullptr) { continue; } const std::vector& points = cached->points; const std::vector& breaks = cached->breaks; const bool drawLine = (mode != DrawMode::SCATTER); const bool drawScatter = (mode == DrawMode::SCATTER || mode == DrawMode::LINE_SCATTER); // 悬停命中的信号先画一层浅色光晕,作为“可拖拽”的视觉提示。 // 这里只处理拖拽中的信号(其光晕在拖拽期间不变,可以进入静态场景缓存); // 悬停光晕随鼠标变化,改由 PaintHoverHighlight 在缓存贴图之后绘制。 const bool highlighted = (ref == haloRef); if (highlighted) { PaintSignalHaloFor(g, resolved, *cached); } if (drawLine && !breaks.empty()) { const std::uint32_t lineColor = StyleColor(*resolved.style); if (cached->bandMode && !cached->bandBreaks.empty()) { // 稠密包络:一次 FillPath 填充包络带。 // **必须显式设为缠绕填充规则**:包络带自交(相邻列上下包络交叉)时, // 交替(奇偶)规则会在交叠处挖出空洞。 // // 抗锯齿开关:几何保持不变,只在"交互期间"或"梳状带"时关闭。 // 梳状带(振荡周期≈像素列宽)下抗锯齿的扫描线代价暴涨而观感几乎无差别, // 实测同一条 2448 顶点的路径 27.6ms → 3.2ms。 const int savedSmoothing = g.GetSmoothingMode(); if (m_fastFrame || cached->bandSpiky) { g.SetSmoothingMode(gd::SmoothingModeNone); } gd::GraphicsPath bandPath; bandPath.SetFillMode(gd::FillModeWinding); for (std::size_t k = 0; k < cached->bandBreaks.size(); ++k) { const std::size_t start = cached->bandBreaks[k]; const std::size_t stop = (k + 1 < cached->bandBreaks.size()) ? cached->bandBreaks[k + 1] : cached->band.size(); const std::size_t total = stop - start; if (total < 4 || (total % 2) != 0) { continue; } const std::size_t half = total / 2; bandPath.StartFigure(); bandPath.AddLines(cached->band.data() + start, static_cast(half)); bandPath.AddLines(cached->band.data() + start + half, static_cast(half)); bandPath.CloseFigure(); } if (bandPath.GetPointCount() > 0) { gd::SolidBrush brush(ToGdiColor(lineColor)); g.FillPath(&brush, &bandPath); } g.SetSmoothingMode(static_cast(savedSmoothing)); } else { gd::Pen pen(ToGdiColor(lineColor), resolved.style->lineWidth * m_scale); pen.SetLineJoin(gd::LineJoinRound); pen.SetStartCap(gd::LineCapRound); pen.SetEndCap(gd::LineCapRound); 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(); if (stop <= start + 1) { continue; } g.DrawLines(&pen, points.data() + start, static_cast(stop - start)); } } } if (drawScatter && !points.empty()) { // 散点半径随线宽线性增长(达到上限前不过早触顶),使“线宽”直观控制数据点大小。 const float radius = static_cast( ClampDouble(static_cast(resolved.style->lineWidth) * kScatterRadiusScale, kMinScatterRadius, kMaxScatterRadius) * m_scale); const std::size_t scatterStride = std::max(1, points.size() / kMaxScatterPoints); // 所有散点合并到一条 GraphicsPath 后只调用一次 FillPath: // 逐点 FillEllipse 每次都要完整走一遍 GDI+ 的状态检查与光栅化入口, // 实测 2500 点需要约 51ms;合并后约 1/9(实测基准 63ms → 7ms)。 // **必须显式设为缠绕填充规则**:GraphicsPath 默认是交替(奇偶)规则, // 两个重叠的圆在交集处会互相抵消形成空洞,表现为"数据点重叠处出现异常白边"。 gd::GraphicsPath scatterPath; scatterPath.SetFillMode(gd::FillModeWinding); for (std::size_t k = 0; k < points.size(); k += scatterStride) { scatterPath.AddEllipse(points[k].X - radius, points[k].Y - radius, radius * 2.0f, radius * 2.0f); } gd::SolidBrush brush(ToGdiColor(StyleColor(*resolved.style))); if (scatterPath.GetPointCount() > 0) { g.FillPath(&brush, &scatterPath); } } } // 第二遍:把所有**派生预览曲线**统一绘制在**本网格全部原信号之上**。 // 若在同一个信号循环里就地叠加,后绘制的原信号会把先前信号的预览曲线盖住 //(用户明确要求"预览信号处于原信号上层不要被覆盖")。 for (const SignalRef& ref : laneSignals) { const ResolvedSignal resolved = ResolveSignal(ref); if (resolved.data == nullptr || resolved.style == nullptr || !resolved.visible) { continue; } // 中心轴均值滤波曲线(「实验性功能 → 边沿计数(中心轴)」):与「均值滤波」同源, // 只有被勾选的信号才绘制。 if (centerOn) { const Lane::EdgeCenterItem* centerItem = Tab().Lanes()[static_cast(laneIndex)].FindEdgeCenterItem(ref); if (centerItem != nullptr) { PaintMeanFilterCurve(g, resolved, lane, centerItem->points, centerItem->iterations); } } // 均值滤波预览曲线:只有被勾选的信号才绘制(参数取自它自己的条目)。 if (meanFilterOn) { const Lane::MeanFilterItem* item = Tab().Lanes()[static_cast(laneIndex)].FindMeanFilterItem(ref); if (item != nullptr) { PaintMeanFilterCurve(g, resolved, lane, item->points, item->iterations); } } // 弱波增强预览曲线:只有被勾选的信号才绘制(增强等级取自整个网格)。 if (weakBoostOn) { const Lane::WeakBoostItem* item = Tab().Lanes()[static_cast(laneIndex)].FindWeakBoostItem(ref); if (item != nullptr) { const int levelPercent = static_cast(std::lround( std::max(0.0, std::min(1.0, Tab().Lanes()[static_cast(laneIndex)] .weakBoostLevel)) * 100.0)); const int iterations = std::max( kWeakBoostIterationsMin, std::min(kWeakBoostIterationsMax, Tab().Lanes()[static_cast(laneIndex)].weakBoostIterations)); PaintWeakBoostCurve(g, resolved, lane, levelPercent, iterations); } } } // g.Restore 也会还原绘制状态,此处直接恢复图形状态即可。 g.Restore(waveSave); } // 网格分界线:网格之间不再留间隔,改用较重的横线明确分隔相邻网格。 const float dividerY = lane.rect.Y; if (dividerY >= m_plotRect.Y - 1.0f && dividerY <= m_plotRect.Y + m_plotRect.Height) { const float dividerSegments[4] = {lane.rect.X, dividerY, lane.rect.X + lane.rect.Width, dividerY}; const std::uint32_t dividerColor = ((laneIndex == 0) ? m_theme->axisLine : m_theme->borderStrong) | 0xFF000000u; UiDrawSolidLines(g, gridClip, dividerSegments, 1, dividerColor, (laneIndex == 0) ? 2 : static_cast(kLaneDividerWidth)); } // 内侧纵轴标尺作为覆盖层绘制:每个刻度数值带半透明底,保证在波形之上仍清晰可读, // 同时只在数值背后遮挡波形,不牺牲波形本身的可见范围。 { const bool showLabels = (lane.rect.Height >= 34.0f * m_scale); float lastLabelY = -1.0e9f; // 网格较矮时同步收紧标签行距,让密度随可用高度提升而不是整片被过滤掉。 const float labelMinGap = std::min(kYLabelMinGap * m_scale, lane.rect.Height / static_cast(yTickTarget + 1)); // 先收集需要绘制的数值底板:合并成一次 FillPath(几十个小圆角矩形逐个填充 // 会带来明显的 GDI+ 调用开销),文字再逐个绘制(GDI 文本无法合批)。 std::vector pills; std::vector pillTexts; std::vector pillZero; for (const double tick : yTicks) { if (!showLabels) { break; } const float y = ValueToY(tick, lane); if (y < lane.rect.Y - 1.0f || y > lane.rect.Y + lane.rect.Height + 1.0f) { continue; } if (std::fabs(y - lastLabelY) < labelMinGap) { continue; } lastLabelY = y; const std::wstring raw = FormatTick(tick, yStep); const float textWidth = UiMeasureTextWidth(m_fonts->monoSmall, raw); const float pillHeight = 17.0f * m_scale; const gd::RectF pill(lane.rect.X + kLaneLabelPad * m_scale, y - pillHeight * 0.5f, std::min(textWidth + 8.0f * m_scale, lane.rect.Width - 2.0f * kLaneLabelPad * m_scale), pillHeight); if (pill.Width <= 0.0f) { continue; } // 与图例重叠时不再跳过:标尺值先绘制,随后绘制的图例会盖在其上层 // (顶层始终为图例),因此两者都不丢失。 // 数值**完全**移出可见区时才不绘制;只要还有一部分在区内就绘制未被遮挡的部分 // (裁剪交给 GDI 文本的 ETO_CLIPPED 与下面的底板夹取共同保证)。 const float visibleTop = std::max(pill.Y, clipTop); const float visibleBottom = std::min(pill.Y + pill.Height, clipBottom); if (visibleBottom <= visibleTop) { continue; } pills.push_back(gd::RectF(pill.X, visibleTop, pill.Width, visibleBottom - visibleTop)); pillTexts.push_back( UiEllipsize(raw, m_fonts->monoSmall, pill.Width - 6.0f * m_scale)); pillZero.push_back(tick == 0.0); } if (!pills.empty()) { UiFillRoundRects(g, pills, 3.0f * m_scale, WithAlpha(m_theme->cardBg, 0xF0)); } for (std::size_t index = 0; index < pills.size(); ++index) { const gd::RectF& pill = pills[index]; // 用 UiTextAtClipped 保证长文本不越过绘图区(GDI 文本不受 GDI+ 裁剪约束)。 UiTextAtClipped(g, pillTexts[index], m_fonts->monoSmall, pill.X + 4.0f * m_scale, gd::RectF(pill.X + 4.0f * m_scale, pill.Y, pill.Width - 8.0f * m_scale, pill.Height), pillZero[index] ? m_theme->text : m_theme->textMuted, clipRectForLabels); } } if (lane.rect.Height >= 24.0f * m_scale) { PaintLaneLegend(g, laneIndex, lane, clipTop, clipBottom); } g.Restore(saveState); } SignalRef PlotView::LegendAtPoint(int x, int y) const { const float fx = static_cast(x); const float fy = static_cast(y); for (const LegendHit& hit : m_legendHits) { if (UiPointInRect(hit.rect, fx, fy)) { return hit.ref; } } return SignalRef(); } void PlotView::PaintLaneLegend(gd::Graphics& g, int laneIndex, const LaneVisual& lane, float visibleTop, float visibleBottom) { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return; } // 图例固定在网格左上角:网格被滚动出可视区时整体跳过,避免文字溢出到工具栏。 const gd::RectF legendBand(lane.rect.X + kLegendPad * m_scale, lane.rect.Y + kLegendPad * m_scale, lane.rect.Width, kLegendHeight * m_scale); if (!IsFullyVisible(legendBand, visibleTop, visibleBottom)) { return; } const std::vector& laneSignals = m_tab->Lanes()[static_cast(laneIndex)].signals; const bool multiFile = LaneSpansMultipleFiles(laneIndex); const float top = legendBand.Y; const float height = kLegendHeight * m_scale; float cursorX = legendBand.X; const float maxX = lane.rect.X + lane.rect.Width - kLanePadding * m_scale; // 网格编号徽标(图例左侧):与左侧信号面板每行末尾的 “GX” 徽标样式完全一致。 // 矩形由 LaneBadgeRect 统一计算,绘制与命中测试因此永远一致。 const gd::RectF badgeRect = LaneBadgeRect(laneIndex); const float badgeHeight = kLaneBadgeHeight * m_scale; cursorX = badgeRect.X + badgeRect.Width + kLaneBadgeGap * m_scale; bool anyVisible = false; int visibleCount = 0; int drawnCount = 0; // 先测量图例总宽度,再用半透明底板掠开底下的网格线,提升可读性。 float legendWidth = 0.0f; for (const SignalRef& ref : laneSignals) { const ResolvedSignal resolved = ResolveSignal(ref); if (resolved.data == nullptr || resolved.style == nullptr || !resolved.visible) { continue; } std::wstring label = BuildLegendText(*resolved.data); if (multiFile && resolved.file != nullptr) { label += L" · " + resolved.file->Title(); } const float itemWidth = kLegendSwatchSize * m_scale + 5.0f * m_scale + std::min(UiMeasureTextWidth(m_fonts->uiSmall, label), kLegendMaxTextWidth * m_scale) + kLegendItemGap * m_scale; if (cursorX + legendWidth + itemWidth > maxX) { break; } legendWidth += itemWidth; } { // 底板从编号徽标开始一直盖到最后一个图例条目(编号徽标一并被掠开底下的网格线)。 const float plateRight = cursorX + legendWidth + (legendWidth > 0.0f ? 2.0f * m_scale : 0.0f); UiFillRoundRect(g, gd::RectF(legendBand.X - 4.0f * m_scale, top - 2.0f * m_scale, plateRight - legendBand.X + 4.0f * m_scale, height + 4.0f * m_scale), 3.0f * m_scale, WithAlpha(m_theme->cardBg, 0xE8)); } // 编号徽标绘制在底板之上(与信号面板的 GX 徽标使用同一套配色与圆角)。 // 拖拽重排时把被拖网格的徽标高亮,给出明确的"已抓住"反馈。 const bool badgeActive = (m_dragMode == PlotDragMode::LANE_REORDER && m_laneDragFrom == laneIndex); const bool badgeHover = (m_dragMode == PlotDragMode::NONE && m_hoverLane == laneIndex && UiPointInRect(badgeRect, static_cast(m_mouseX), static_cast(m_mouseY))); UiFillRoundRect(g, badgeRect, badgeHeight * 0.5f, (badgeActive || badgeHover) ? WithAlpha(m_theme->accent, 0x4A) : m_theme->cardBg); UiDrawRoundRect(g, badgeRect, badgeHeight * 0.5f, (badgeActive || badgeHover) ? m_theme->accent : m_theme->border, 1.0f); UiText(g, L"G" + std::to_wstring(laneIndex + 1), m_fonts->monoSmall, badgeRect, m_theme->text, TextAlign::CENTER); // 记录本次绘制命中的条目矩形,供点击选中与双击改名使用。 for (auto iterator = m_legendHits.begin(); iterator != m_legendHits.end();) { if (m_tab->LaneOfSignal(iterator->ref) == laneIndex) { iterator = m_legendHits.erase(iterator); } else { ++iterator; } } // 绘制图例条目(与测量阶段保持一致的裁剪条件)。 float drawnWidth = 0.0f; for (const SignalRef& ref : laneSignals) { const ResolvedSignal resolved = ResolveSignal(ref); if (resolved.data == nullptr || resolved.style == nullptr || !resolved.visible) { continue; } anyVisible = true; ++visibleCount; std::wstring label = BuildLegendText(*resolved.data); if (multiFile && resolved.file != nullptr) { label += L" · " + resolved.file->Title(); } // 文本宽度按上限夹取,超出部分用省略号(与测量阶段保持一致)。 const float maxTextWidth = kLegendMaxTextWidth * m_scale; const float fullTextWidth = UiMeasureTextWidth(m_fonts->uiSmall, label); const float textWidth = std::min(fullTextWidth, maxTextWidth); const std::wstring drawLabel = (fullTextWidth > maxTextWidth) ? UiEllipsize(label, m_fonts->uiSmall, maxTextWidth) : label; const float itemWidth = kLegendSwatchSize * m_scale + 5.0f * m_scale + textWidth + kLegendItemGap * m_scale; if (drawnWidth + itemWidth > legendWidth + 0.5f) { break; } drawnWidth += itemWidth; const float itemLeft = cursorX; const float swatchSize = kLegendSwatchSize * m_scale; // 选中态(与左侧列表呼应)绘制在静态场景里:选中信号参与场景指纹, // 因此选中变化会让缓存失效并重绘。**悬停半高亮不在此处**(随鼠标变化, // 画在缓存贴图之后,见 PaintLegendHighlight)。 const bool selected = (SelectedRef() == ref); if (selected) { UiFillRoundRect(g, gd::RectF(itemLeft - 3.0f * m_scale, top - 1.0f * m_scale, itemWidth + 2.0f * m_scale, height + 2.0f * m_scale), 3.0f * m_scale, m_theme->selection); } UiDrawSwatch(g, gd::RectF(cursorX, top + (height - swatchSize) * 0.5f, swatchSize, swatchSize), resolved.style->color, resolved.style->opacity, 1.5f * m_scale, m_scale); // 改名编辑态:在原位置绘制输入框。 LegendHit hit; hit.ref = ref; hit.rect = gd::RectF(itemLeft - 3.0f * m_scale, top - 2.0f * m_scale, itemWidth + 2.0f * m_scale, height + 4.0f * m_scale); m_legendHits.push_back(hit); if (m_editTarget == PlotEditTarget::SIGNAL_NAME && m_editSignal == ref) { m_editFieldRect = hit.rect; UiDrawEditField(g, hit.rect, m_edit, m_fonts->uiSmall, m_scale); cursorX += itemWidth; ++drawnCount; continue; } cursorX += swatchSize + 5.0f * m_scale; UiText(g, drawLabel, m_fonts->uiSmall, gd::RectF(cursorX, top, std::max(8.0f, textWidth + 2.0f), height), selected ? m_theme->textStrong : m_theme->text, TextAlign::LEFT); cursorX += textWidth + kLegendItemGap * m_scale; ++drawnCount; } if (!anyVisible) { UiText(g, L"(无可见信号)", m_fonts->uiSmall, gd::RectF(legendBand.X, top, lane.rect.Width, height), m_theme->textMuted, TextAlign::LEFT); } else if (drawnCount < visibleCount) { // 图例空间不足时提示剩余信号数量,避免误认为信号丢失。 const std::wstring moreText = L"+" + std::to_wstring(visibleCount - drawnCount); if (cursorX + 20.0f * m_scale <= maxX) { UiText(g, moreText, m_fonts->uiSmall, gd::RectF(cursorX, top, 24.0f * m_scale, height), m_theme->textMuted, TextAlign::LEFT); } } } bool PlotView::IsFullyVisible(const gd::RectF& rect, float visibleTop, float visibleBottom) { return (rect.Y >= visibleTop - 0.5f) && (rect.Y + rect.Height <= visibleBottom + 0.5f); } void PlotView::PaintTimeAxis(gd::Graphics& g, const std::vector& timeTicks, double timeStep) { const float axisY = m_plotRect.Y + m_plotRect.Height; // 时间轴的轴线与刻度线同样走 GDI 批量绘制(理由见 PaintLane 中的说明)。 const gd::RectF axisClip(m_area.X, m_area.Y, m_area.Width, m_area.Height); const std::uint32_t axisColor = m_theme->axisLine | 0xFF000000u; { const float axisSegment[4] = {m_plotRect.X, axisY, m_plotRect.X + m_plotRect.Width, axisY}; UiDrawSolidLines(g, axisClip, axisSegment, 1, axisColor); } // 时间轴列名(取自首个可见信号所属文件),贴左侧绘制。 std::wstring axisName = L"时间"; if (m_dataSet != nullptr && m_tab != nullptr) { for (std::size_t fileIndex = 0; fileIndex < m_dataSet->FileCount(); ++fileIndex) { if (!m_tab->FileHasVisible(static_cast(fileIndex))) { continue; } const DataFile* file = m_dataSet->FileAt(static_cast(fileIndex)); if (file != nullptr && !file->timeColumnName.empty()) { axisName = file->timeColumnName; // CSV 中该列通常直接叫 "time",这里补上时间单位秒便于阅读; // 若列名自带单位描述(以右括号结尾)则不重复追加。 if (axisName.back() != L')') { axisName += L" (s)"; } } break; } } const float nameWidth = std::min(UiMeasureTextWidth(m_fonts->monoSmall, axisName) + 8.0f * m_scale, m_plotRect.Width * 0.4f); const gd::RectF nameRect(m_plotRect.X + 2.0f * m_scale, axisY + 3.0f * m_scale, nameWidth, std::max(0.0f, m_timeAxisRect.Height - 5.0f * m_scale)); UiText(g, UiEllipsize(axisName, m_fonts->monoSmall, nameWidth), m_fonts->monoSmall, nameRect, m_theme->textMuted, TextAlign::LEFT); const float labelWidth = 96.0f * m_scale; // 五等分子刻度线:在相邻主刻度之间插入 4 条短刻度(更长的主刻度在最前绘制, // 短刻度与主刻度重合的位置跳过),便于目视读取中间时刻。 const double minorStep = timeStep / static_cast(kMinorDivisions); std::vector minorTicks; BuildTicks(Tab().ViewMin(), Tab().ViewMax(), minorStep, minorTicks); std::vector minorSegments; std::vector majorSegments; minorSegments.reserve(minorTicks.size() * 4); majorSegments.reserve(timeTicks.size() * 4); for (const double tick : minorTicks) { const double majorIndex = tick / timeStep; if (std::fabs(majorIndex - std::floor(majorIndex + 0.5)) < 1.0e-6) { continue; } const float x = TimeToX(tick); if (x < m_plotRect.X - 1.0f || x > m_plotRect.X + m_plotRect.Width + 1.0f) { continue; } minorSegments.insert(minorSegments.end(), {x, axisY, x, axisY + 3.0f * m_scale}); } for (const double tick : timeTicks) { const float x = TimeToX(tick); if (x < m_plotRect.X - 1.0f || x > m_plotRect.X + m_plotRect.Width + 1.0f) { continue; } majorSegments.insert(majorSegments.end(), {x, axisY, x, axisY + 4.0f * m_scale}); } if (!minorSegments.empty()) { UiDrawSolidLines(g, axisClip, minorSegments.data(), minorSegments.size() / 4, axisColor); } if (!majorSegments.empty()) { UiDrawSolidLines(g, axisClip, majorSegments.data(), majorSegments.size() / 4, axisColor); } for (const double tick : timeTicks) { const float x = TimeToX(tick); if (x < m_plotRect.X - 1.0f || x > m_plotRect.X + m_plotRect.Width + 1.0f) { continue; } // 刻度标签夹在绘图区横向范围内,并与左侧列名互不重叠。 float labelX = x - labelWidth * 0.5f; labelX = std::max(m_plotRect.X, std::min(labelX, m_plotRect.X + m_plotRect.Width - labelWidth)); const gd::RectF labelRect(labelX, axisY + 4.0f * m_scale, labelWidth, std::max(0.0f, m_timeAxisRect.Height - 6.0f * m_scale)); if (labelRect.X < nameRect.X + nameRect.Width) { continue; } UiText(g, FormatTick(tick, timeStep), m_fonts->monoSmall, labelRect, m_theme->textMuted, TextAlign::CENTER); } } void PlotView::DrawLabelOutline(gd::Graphics& g, const gd::RectF& rect, float radius, std::uint32_t outlineColor) { // 描边色由 `UiContrastInk` 与文字色一起算出(恒与文字色反相、且随信号配色线性变化)。 // 这样胶囊压在同类色背景或信号曲线上时仍有清晰的边界。 UiDrawRoundRect(g, rect, radius, outlineColor, 1.0f); } 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::PaintCursorOffViewTip(gd::Graphics& g, const gd::RectF& labelRect, bool offRight, std::uint32_t color) { // 三角画在读数胶囊的**外侧**(尖端朝外、指向光标实际所在的那一侧), // 与胶囊之间留出 `kMarkerTriangleGap` 的空白。 // // 旧实现把三角压在胶囊**上沿**且水平居中:既没有间隙(看起来像胶囊长了个尖角), // 又与网格内"数据点越界"的画法(左右三角)不统一。现在两处共用同一套画法。 const float arm = kMarkerTriangleArm * m_scale; const float depth = kMarkerTriangleDepth * m_scale; const float gap = kMarkerTriangleGap * m_scale; const float centerY = labelRect.Y + labelRect.Height * 0.5f; gd::PointF outer[3]; if (offRight) { const float baseX = labelRect.X + labelRect.Width + gap; outer[0] = gd::PointF(baseX, centerY - arm); outer[1] = gd::PointF(baseX, centerY + arm); outer[2] = gd::PointF(baseX + depth, centerY); } else { const float baseX = labelRect.X - gap; outer[0] = gd::PointF(baseX, centerY - arm); outer[1] = gd::PointF(baseX, centerY + arm); outer[2] = gd::PointF(baseX - depth, centerY); } gd::SolidBrush brush(ToGdiColor(color)); g.FillPolygon(&brush, outer, 3); } void PlotView::PaintCursor(gd::Graphics& g, const std::vector& lanes) { if (!Tab().CursorEnabled() || m_tab == nullptr) { return; } const bool doubleMode = (Tab().CursorMode() == PlotCursorMode::DOUBLE); const int cursorCount = doubleMode ? 2 : 1; // 本次绘制重新统计已占用的取值标注矩形(用于避免多个信号/光标的内容互相覆盖)。 // 先把底部的时间读数/差值标签也登记进去:它们在取值标注之后绘制、会盖在上面, // 因此取值标注必须主动避开它们,否则会被遮住半个字。 m_cursorLabelRects.clear(); for (int which = 1; which <= cursorCount; ++which) { const gd::RectF timeRect = CursorLabelRect(which); if (timeRect.Width > 0.0f) { m_cursorLabelRects.push_back(timeRect); } } if (doubleMode) { bool insideAxis = false; const gd::RectF spanRect = CursorSpanRect(insideAxis); if (spanRect.Width > 0.0f) { m_cursorLabelRects.push_back(spanRect); } } // 重叠时把"最近操作过的那条光标"的内容画在上层,因此先画另一条。 const int iterations[2] = { (m_activeCursor == 2) ? 1 : 2, (m_activeCursor == 2) ? 2 : 1 }; // 增量差值**只**显示在"右侧光标"上。右侧 = 时间较大的那条,随拖拽实时变化: // 把右光标拖到左边后,增量会自动改由新的右侧光标显示。 const int rightCursor = (Tab().CursorTime() <= Tab().CursorTimeAlt()) ? 2 : 1; // ---- 光标竖线 + 顶部把手 ---- for (int pass = 0; pass < cursorCount; ++pass) { const int which = doubleMode ? iterations[pass] : 1; const double time = (which == 2) ? Tab().CursorTimeAlt() : Tab().CursorTime(); const float x = TimeToX(time); if (x < m_plotRect.X - 2.0f || x > m_plotRect.X + m_plotRect.Width + 2.0f) { // 光标滞留在可视区外:不画竖线(其取值/时间读数会在下面贴边显示)。 continue; } const bool hot = (m_hoverCursor == which); const std::uint32_t color = CursorColor(); const float lineTop = m_plotRect.Y; const float lineBottom = m_timeAxisRect.Y + m_timeAxisRect.Height; UiDrawLine(g, x, lineTop, x, lineBottom, color, (hot ? 2.0f : 1.4f) * m_scale); // 顶部三角把手(两条光标样式一致,靠颜色区分)。 const float arm = 5.0f * m_scale; gd::PointF points[3]; points[0] = gd::PointF(x - arm, lineTop); points[1] = gd::PointF(x + arm, lineTop); points[2] = gd::PointF(x, lineTop + arm * 1.4f); gd::SolidBrush handle(ToGdiColor(color)); g.FillPolygon(&handle, points, 3); } // ---- 数据点取值标注 ---- for (int pass = 0; pass < cursorCount; ++pass) { const int which = doubleMode ? iterations[pass] : 1; const double time = (which == 2) ? Tab().CursorTimeAlt() : Tab().CursorTime(); const double otherTime = (which == 2) ? Tab().CursorTime() : Tab().CursorTimeAlt(); // 只有右侧光标显示增量差值(左光标只显示自身取值)。 const bool showDelta = doubleMode && (which == rightCursor); // 光标在可视区外时,把标注"钉"在对应的网格边上,保证取值仍然可见。 // 具体是否越界(左右/上下)由 MakeDataMarker 逐点判定。 const float pinX = CursorDisplayX(which); const float rawX = TimeToX(time); for (std::size_t laneIndex = 0; laneIndex < lanes.size(); ++laneIndex) { const LaneVisual& lane = lanes[laneIndex]; const float clipTop = std::max(lane.rect.Y, m_plotRect.Y); const float clipBottom = std::min(lane.rect.Y + lane.rect.Height, m_plotRect.Y + m_plotRect.Height); if (clipBottom <= clipTop) { continue; } for (const SignalRef& ref : m_tab->Lanes()[laneIndex].signals) { const ResolvedSignal resolved = ResolveSignal(ref); if (resolved.data == nullptr || resolved.style == nullptr || resolved.file == nullptr || !resolved.visible) { continue; } const bool interpolate = (resolved.style->mode != DrawMode::SCATTER); double value = 0.0; if (!SampleSignalAt(*resolved.file, *resolved.data, time, interpolate, value)) { continue; } const float rawY = ValueToY(value, lane); // 上下越界的数据点不再被丢弃:与左右越界一致,贴边绘制指示方向的三角。 // 上边界额外预留光标顶部把手的高度,避免数据点三角压住光标把手。 // 非 const:上下越界时下面还要把方向三角与取值标注纵向居中对齐。 DataMarker marker = MakeDataMarker(rawX, rawY, pinX, clipTop, clipBottom, kCursorHandleReserve * m_scale); const std::uint32_t signalColor = StyleColor(*resolved.style); // 数据点:视界内为圆点,任一方向越界时改为贴边三角。 // **先算好取值标注、再画数据点**:上下越界时的方向三角需要与标注 // 纵向居中对齐,而标注的位置要经过避让处理才能最终确定。 const float radius = 3.4f * m_scale; // 取值标注:底色用信号色,文字/描边用随信号配色线性相关的高对比度配色。 std::wstring text = FormatValue(value); if (showDelta) { double otherValue = 0.0; if (SampleSignalAt(*resolved.file, *resolved.data, otherTime, interpolate, otherValue)) { const double delta = value - otherValue; // 增量 = 右侧光标取值 − 左侧光标取值,包在圆括号里并按差值显式给出正负号。 text += L" ("; text += (delta < 0.0) ? L"-" : L"+"; text += FormatValue(std::fabs(delta)); text += L")"; } } const float textWidth = UiMeasureTextWidth(m_fonts->monoSmall, text) + 10.0f * m_scale; const float textHeight = 17.0f * m_scale; // 左右越界时三角贴在视界内侧、尖端朝外;数值标注必须放在三角的**内侧** // 并留出 `kMarkerTriangleGap` 的空白,否则三角会像"多出来的一个尖角" // (用户报告的"三角形与数值显示部分没有间隔")。 float labelLeft; if (marker.offLeft) { labelLeft = marker.x + kMarkerTriangleGap * m_scale; } else if (marker.offRight) { labelLeft = marker.x - kMarkerTriangleGap * m_scale - textWidth; } else { labelLeft = pinX + radius + 4.0f * m_scale; if (labelLeft + textWidth > m_plotRect.X + m_plotRect.Width - 2.0f * m_scale) { labelLeft = pinX - radius - 4.0f * m_scale - textWidth; } } labelLeft = std::max( m_plotRect.X + 2.0f * m_scale, std::min(labelLeft, m_plotRect.X + m_plotRect.Width - textWidth - 2.0f * m_scale)); float labelTop = marker.y - textHeight * 0.5f; labelTop = std::max(clipTop + 1.0f * m_scale, std::min(labelTop, clipBottom - textHeight - 1.0f * m_scale)); gd::RectF labelRect(labelLeft, labelTop, textWidth, textHeight); // 多个信号的取值标注可能落在同一位置(数据点重合或过近): // 依次向下(再向上)避让已占用的标注,保证不会互相覆盖。 PlaceCursorLabel(labelRect, labelTop, textHeight, clipTop, clipBottom); // 越界方向三角与最终落位的取值标注纵向居中对齐 // (用户明确要求“超过上下视界时三角需要与显示的数值居中对齐”; // 左右越界时同样对齐,避免三角与数值错行、看起来不协调)。 if (marker.offUp || marker.offDown || marker.offLeft || marker.offRight) { marker.y = labelRect.Y + labelRect.Height * 0.5f; } PaintDataMarker(g, marker, radius, signalColor, m_theme->cardBg); const UiInk ink = UiContrastInk(signalColor); UiFillRoundRect(g, labelRect, 3.0f * m_scale, WithAlpha(signalColor, 0xF0)); DrawLabelOutline(g, labelRect, 3.0f * m_scale, ink.outline); UiText(g, text, m_fonts->monoSmall, labelRect, ink.text, TextAlign::CENTER); // 「边沿计数(中心轴)」/「均值滤波」/「弱波增强」开启时,纵向光标还要额外标注 // **派生曲线**在该时刻的取值(它不是信号,因此单独标注,不并入上面的取值文案)。 // 三条曲线只是曲线来源不同,标注规则完全一致,故共用同一段绘制逻辑。 // ⚠️ 曲线只取「可见区间 ∪ 两个光标时刻 ± 保护带」的窗口(与绘制共用缓存), // 因此拖动光标/拖动参数滑块都不会触发整条曲线的重算。 const auto paintDerivedCapsule = [&](DerivedCurveKind kind, int points, int iterations, int levelPercent, const std::wstring& suffix) { const int sampleIndex = FindNearestSample(*resolved.file, time); if (sampleIndex < 0) { return; } const int otherIndex = showDelta ? FindNearestSample(*resolved.file, otherTime) : -1; // 本函数**只需要光标所在采样点处的派生取值**(以及双光标另一端的取值), // 因此把请求窗口收窄到「需要的那几个下标」即可,均值滤波半径造成的取值 // 依赖由 `CurveSpanFor` 自身的保护带负责覆盖。 // // ⚠️ 绝不能把窗口扩成「光标 ↔ 视界边缘」的并集:光标停在视界之外(例如 // 平移视界把光标甩到很远,或光标远离当前缩放区间)时会得到一个上千万采样的 // 巨大窗口,超过缓存上限后走一次性缓冲 → **每帧重算整段滤波**, // 实测单帧 300ms+(用户报告的"平移/拖动光标异常卡顿"的直接原因)。 std::size_t begin = static_cast(sampleIndex); std::size_t end = begin + 1; if (otherIndex >= 0) { const std::size_t other = static_cast(otherIndex); begin = std::min(begin, other); end = std::max(end, other + 1); } std::size_t spanBegin = 0; const std::vector* curvePtr = CurveSpanFor(kind, *resolved.data, points, iterations, levelPercent, begin, end, spanBegin, true); if (curvePtr == nullptr) { return; } const auto curveValueAt = [curvePtr, spanBegin](int index) -> double { if (index < 0 || static_cast(index) < spanBegin || (static_cast(index) - spanBegin) >= curvePtr->size()) { return std::numeric_limits::quiet_NaN(); } return static_cast((*curvePtr)[static_cast(index) - spanBegin]); }; const double derivedValue = curveValueAt(sampleIndex); if (!std::isfinite(derivedValue)) { return; } const float rawCenterY = ValueToY(derivedValue, lane); DataMarker centerMarker = MakeDataMarker( rawX, rawCenterY, pinX, clipTop, clipBottom, kCursorHandleReserve * m_scale); // 派生数据点的配色与原始信号一致(便于一一对应); // 但**始终按线段模式**绘制(不跟随原信号的散点模式)。 const std::uint32_t centerColor = StyleColor(*resolved.style); // 取值文本:数值在前,尾缀「(中心轴)/(均值滤波)」;双光标的右侧光标 // 还要附上增量,形如「(+0.0008 中心轴)」(参考常规信号的增量显示)。 std::wstring centerText = FormatValue(derivedValue); centerText += L"("; if (showDelta) { const double otherDerived = curveValueAt(otherIndex); if (std::isfinite(otherDerived)) { const double delta = derivedValue - otherDerived; centerText += (delta < 0.0) ? L"-" : L"+"; centerText += FormatValue(std::fabs(delta)); centerText += L" "; } } centerText += suffix; centerText += L")"; const float centerTextWidth = UiMeasureTextWidth(m_fonts->monoSmall, centerText) + 10.0f * m_scale; // 与常规取值标注同一规则:左右越界时标注要让开外侧的方向三角。 float centerLeft; if (centerMarker.offLeft) { centerLeft = centerMarker.x + kMarkerTriangleGap * m_scale; } else if (centerMarker.offRight) { centerLeft = centerMarker.x - kMarkerTriangleGap * m_scale - centerTextWidth; } else { centerLeft = pinX + radius + 4.0f * m_scale; if (centerLeft + centerTextWidth > m_plotRect.X + m_plotRect.Width - 2.0f * m_scale) { centerLeft = pinX - radius - 4.0f * m_scale - centerTextWidth; } } centerLeft = std::max( m_plotRect.X + 2.0f * m_scale, std::min(centerLeft, m_plotRect.X + m_plotRect.Width - centerTextWidth - 2.0f * m_scale)); float centerTop = centerMarker.y - textHeight * 0.5f; centerTop = std::max(clipTop + 1.0f * m_scale, std::min(centerTop, clipBottom - textHeight - 1.0f * m_scale)); gd::RectF centerRect(centerLeft, centerTop, centerTextWidth, textHeight); PlaceCursorLabel(centerRect, centerTop, textHeight, clipTop, clipBottom); // 与原始信号同一规则:越界方向三角与取值标注纵向居中对齐。 if (centerMarker.offUp || centerMarker.offDown || centerMarker.offLeft || centerMarker.offRight) { centerMarker.y = centerRect.Y + centerRect.Height * 0.5f; } PaintDataMarker(g, centerMarker, radius, centerColor, m_theme->cardBg); // 颜色样式与原始信号一致(同底色/同描边/同文字配色)。 const UiInk centerInk = UiContrastInk(centerColor); UiFillRoundRect(g, centerRect, 3.0f * m_scale, WithAlpha(centerColor, 0xE0)); DrawLabelOutline(g, centerRect, 3.0f * m_scale, centerInk.outline); UiText(g, centerText, m_fonts->monoSmall, centerRect, centerInk.text, TextAlign::CENTER); }; if (m_tab->Lanes()[laneIndex].edgeCenter) { // 只标注被勾选(即真正绘制了曲线)的信号。 const Lane::EdgeCenterItem* centerItem = m_tab->Lanes()[laneIndex].FindEdgeCenterItem(ref); if (centerItem != nullptr) { paintDerivedCapsule(DerivedCurveKind::MEAN_FILTER, centerItem->points, centerItem->iterations, 0, L"中心轴"); } } if (m_tab->Lanes()[laneIndex].meanFilter) { // 只标注被勾选(即真正绘制了预览曲线)的信号。 const Lane::MeanFilterItem* meanItem = m_tab->Lanes()[laneIndex].FindMeanFilterItem(ref); if (meanItem != nullptr) { paintDerivedCapsule(DerivedCurveKind::MEAN_FILTER, meanItem->points, meanItem->iterations, 0, L"均值滤波"); } } if (m_tab->Lanes()[laneIndex].weakBoost) { // 弱波增强曲线:同样是只标注被勾选的信号,尾缀为「弱波增强」。 const Lane::WeakBoostItem* boostItem = m_tab->Lanes()[laneIndex].FindWeakBoostItem(ref); if (boostItem != nullptr) { const int levelPercent = static_cast(std::lround( std::max(0.0, std::min(1.0, m_tab->Lanes()[laneIndex].weakBoostLevel)) * 100.0)); const int iterations = std::max( kWeakBoostIterationsMin, std::min(kWeakBoostIterationsMax, m_tab->Lanes()[laneIndex].weakBoostIterations)); paintDerivedCapsule(DerivedCurveKind::WEAK_BOOST, 0, iterations, levelPercent, L"弱波增强"); } } } } } // ---- 底部:时间读数 + 双光标差值 ---- if (m_editTarget == PlotEditTarget::CURSOR_TIME || m_editTarget == PlotEditTarget::CURSOR_SPAN) { PaintEditField(g); return; } for (int pass = 0; pass < cursorCount; ++pass) { const int which = doubleMode ? iterations[pass] : 1; const gd::RectF labelRect = CursorLabelRect(which); if (labelRect.Width <= 0.0f) { continue; } const bool highlighted = (m_cursorHover && m_hoverCursor == which); const std::uint32_t color = CursorColor(); bool offRight = false; const bool offView = CursorOffViewSide(which, offRight); if (offView) { PaintCursorOffViewTip(g, labelRect, offRight, color); } UiFillRoundRect(g, labelRect, 3.0f * m_scale, highlighted ? color : WithAlpha(color, 0xE0)); const UiInk ink = UiContrastInk(color); DrawLabelOutline(g, labelRect, 3.0f * m_scale, ink.outline); UiText(g, FormatCursorTime(which == 2 ? Tab().CursorTimeAlt() : Tab().CursorTime()), m_fonts->monoSmall, labelRect, ink.text, TextAlign::CENTER); } // 双光标差值:显示在两条时间读数中间(时间轴上方;过近时落到主时间轴上)。 if (doubleMode) { bool insideAxis = false; const gd::RectF spanRect = CursorSpanRect(insideAxis); m_cursorSpanRect = spanRect; if (spanRect.Width > 0.0f && spanRect.Height > 0.0f) { // 样式与光标时间读数完全一致(同底色/同描边/同文字配色)。 const std::uint32_t color = CursorColor(); // 双光标同时越界到同一侧时,差值标签也在视界之外 → 同样画方向三角。 bool offRight1 = false, offRight2 = false; const bool off1 = CursorOffViewSide(1, offRight1); const bool off2 = CursorOffViewSide(2, offRight2); if (off1 && off2 && offRight1 == offRight2) { PaintCursorOffViewTip(g, spanRect, offRight1, color); } UiFillRoundRect(g, spanRect, 3.0f * m_scale, m_spanHover ? color : WithAlpha(color, 0xE0)); const UiInk ink = UiContrastInk(color); DrawLabelOutline(g, spanRect, 3.0f * m_scale, ink.outline); UiText(g, FormatSpanText(CursorSpan()), m_fonts->monoSmall, spanRect, ink.text, TextAlign::CENTER); } } else { m_cursorSpanRect = gd::RectF(); } } PlotView::EdgePanelLayout PlotView::EdgePanelLayoutFor(int laneIndex, EdgePanelKind kind) const { EdgePanelLayout layout; if (m_tab == nullptr || m_fonts == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size()) || laneIndex >= static_cast(m_lanes.size())) { return layout; } const Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; const bool centerAxis = (kind == EdgePanelKind::CENTER); const bool meanFilter = (kind == EdgePanelKind::MEAN); const bool weakBoost = (kind == EdgePanelKind::BOOST); // 「均值滤波」与「弱波增强」共用同一套「逐条目 + 底部按钮」排布。 const bool itemized = (centerAxis || meanFilter || weakBoost); const bool hasButton = (meanFilter || weakBoost); const bool enabled = meanFilter ? lane.meanFilter : (weakBoost ? lane.weakBoost : (centerAxis ? lane.edgeCenter : lane.edgeCount)); const bool open = meanFilter ? lane.meanFilterPanelOpen : (weakBoost ? lane.weakBoostPanelOpen : (centerAxis ? lane.edgeCenterPanelOpen : lane.edgePanelOpen)); if (!enabled || !open) { return layout; } int rowCount = 0; for (const SignalRef& ref : lane.signals) { const ResolvedSignal resolved = ResolveSignal(ref); if (resolved.data != nullptr && resolved.visible) { ++rowCount; } } if (rowCount <= 0) { return layout; } layout.rows = rowCount; const float width = EdgePanelWidth(laneIndex, kind); const float titleHeight = kEdgePanelTitleHeight * m_scale; const float rowHeight = kEdgePanelRowHeight * m_scale; const float pad = kEdgePanelPad * m_scale; // 「中心轴边沿计数」「均值滤波」「弱波增强」共用同一套「逐条目」排布, // 因此不再有独立的底部编辑行;只有「边沿计数」保留两行文本式排版。 const float buttonHeight = hasButton ? (kEdgePanelButtonHeight * m_scale) : 0.0f; const float rowsPerSignal = meanFilter ? 1.0f : 2.0f; // 「弱波增强」在条目与底部按钮之间额外有两行滑块(等级 / 迭代次数 N),其余逐条目面板没有。 const float sliderRowHeight = weakBoost ? (kWeakBoostSliderRowHeight * m_scale) : 0.0f; const float sliderRowsHeight = weakBoost ? (sliderRowHeight * 2.0f) : 0.0f; // 逐条目面板:每个条目 = 第一排(信号名 + 复选框) // + 第二排(均值点数 X / 迭代次数 N)+(中心轴)第三排(统计结果); // 后两排**只在该条目被勾选时才占位**,因此面板高度随勾选数量动态变化。 float meanRowHeight = 0.0f; float itemsHeight = 0.0f; float height = 0.0f; if (itemized) { meanRowHeight = kEdgePanelMeanRowHeight * m_scale; for (const SignalRef& ref : lane.signals) { const ResolvedSignal resolved = ResolveSignal(ref); if (resolved.data == nullptr || !resolved.visible) { continue; } itemsHeight += meanRowHeight; const bool checked = meanFilter ? lane.MeanFilterEnabled(ref) : (weakBoost ? lane.WeakBoostEnabled(ref) : lane.EdgeCenterEnabled(ref)); if (checked && (centerAxis || meanFilter)) { itemsHeight += (kEdgePanelMeanEditHeight + kEdgePanelMeanRowGap) * m_scale; if (centerAxis) { itemsHeight += kEdgePanelMeanRowHeight * m_scale; } } } // 底部按钮行:上方与条目之间、下方与面板下边界之间各留一个 `pad`, // 与面板的左右内边距完全一致(原先按钮直接贴着下边框,看起来很不协调)。 height = titleHeight + pad + itemsHeight + sliderRowsHeight + pad + buttonHeight + (hasButton ? pad : 0.0f); } else { height = titleHeight + pad + static_cast(rowCount) * rowHeight * rowsPerSignal + pad; } // 首次使用(位置为负)时自动放到绘图区右侧,并各网格依次错开,避免完全重叠。 const double storedX = meanFilter ? lane.meanFilterPanelX : (weakBoost ? lane.weakBoostPanelX : (centerAxis ? lane.edgeCenterPanelX : lane.edgePanelX)); const double storedY = meanFilter ? lane.meanFilterPanelY : (weakBoost ? lane.weakBoostPanelY : (centerAxis ? lane.edgeCenterPanelY : lane.edgePanelY)); float left = (storedX >= 0.0) ? static_cast(storedX) : (m_plotRect.X + m_plotRect.Width - width - 16.0f * m_scale); float top = (storedY >= 0.0) ? static_cast(storedY) : (m_plotRect.Y + 16.0f * m_scale + static_cast(laneIndex) * 28.0f * m_scale); // 面板始终限制在绘图区内(用户明确要求"可以在右侧的整个绘制区域拖动")。 left = std::max(m_plotRect.X + 2.0f * m_scale, std::min(left, m_plotRect.X + m_plotRect.Width - width - 2.0f * m_scale)); top = std::max(m_plotRect.Y + 2.0f * m_scale, std::min(top, m_plotRect.Y + m_plotRect.Height - height - 2.0f * m_scale)); layout.panel = gd::RectF(left, top, width, height); // 只有标题栏可以拖拽;✕ 位于标题栏右端,命中时优先按“关闭按钮”处理。 layout.title = gd::RectF(left, top, width, titleHeight); const float closeSize = titleHeight; layout.close = gd::RectF(left + width - closeSize, top, closeSize, closeSize); if (itemized) { const float buttonRowHeight = kEdgePanelButtonHeight * m_scale; if (hasButton) { // 底部按钮与面板下边界之间留出与左右、与内容相同的 `pad` 间距 //(原先按钮行直接贴着下边框,视觉上与其它元素不协调); // 面板高度已含这份 pad,因此这里只上移按钮行即可。 layout.button = gd::RectF(left + pad, top + height - pad - buttonRowHeight, std::max(0.0f, width - pad * 2.0f), buttonRowHeight); } // 弱波增强的两个滑块行(等级 / 迭代次数 N,位于信号条目与底部按钮之间)。 if (weakBoost) { const float labelWidth = std::max(UiMeasureTextWidth(m_fonts->uiSmall, kWeakBoostLevelLabel), UiMeasureTextWidth(m_fonts->uiSmall, kWeakBoostIterationsLabel)); const float valueWidth = std::max(UiMeasureTextWidth(m_fonts->monoSmall, L"100%"), UiMeasureTextWidth(m_fonts->monoSmall, L"10")); const float trackHeight = kWeakBoostTrackHeight * m_scale; const float firstRowTop = top + titleHeight + pad + itemsHeight; const float trackLeft = left + pad + labelWidth + 6.0f * m_scale; const float trackRight = left + width - pad - valueWidth - 6.0f * m_scale; const float trackWidth = std::max(10.0f * m_scale, trackRight - trackLeft); layout.slider = gd::RectF(trackLeft, firstRowTop + (sliderRowHeight - trackHeight) * 0.5f, trackWidth, trackHeight); layout.sliderIterations = gd::RectF(trackLeft, firstRowTop + sliderRowHeight + (sliderRowHeight - trackHeight) * 0.5f, trackWidth, trackHeight); } // 逐信号条目:第一排 = 名称 + 复选框(复选框靠右); // 第二排 = 「均值点数 X」与「迭代次数 N」两个输入框(仅勾选时存在); // 第三排(中心轴)= 上升沿/下降沿统计结果(仅勾选时存在)。 const float checkSize = kEdgePanelMeanCheckSize * m_scale; const float fieldHeight = kEdgePanelMeanEditHeight * m_scale; const float halfWidth = std::max(40.0f, (width - pad * 2.0f - 8.0f * m_scale) * 0.5f); float rowY = top + titleHeight + pad; for (const SignalRef& ref : lane.signals) { const ResolvedSignal resolved = ResolveSignal(ref); if (resolved.data == nullptr || !resolved.visible) { continue; } layout.rowRefs.push_back(ref); layout.rowChecks.push_back(gd::RectF( left + width - pad - checkSize, rowY + (meanRowHeight - checkSize) * 0.5f, checkSize, checkSize)); const bool checked = meanFilter ? lane.MeanFilterEnabled(ref) : (weakBoost ? lane.WeakBoostEnabled(ref) : lane.EdgeCenterEnabled(ref)); if (checked && (centerAxis || meanFilter)) { const float editTop = rowY + meanRowHeight; layout.rowPoints.push_back( gd::RectF(left + pad, editTop, halfWidth, fieldHeight)); layout.rowIterations.push_back( gd::RectF(left + pad + halfWidth + 8.0f * m_scale, editTop, halfWidth, fieldHeight)); if (centerAxis) { layout.rowStats.push_back( gd::RectF(left + pad, editTop + fieldHeight, width - pad * 2.0f, kEdgePanelMeanRowHeight * m_scale)); } else { layout.rowStats.push_back(gd::RectF()); } } else { layout.rowPoints.push_back(gd::RectF()); layout.rowIterations.push_back(gd::RectF()); layout.rowStats.push_back(gd::RectF()); } rowY += meanRowHeight; if (checked && (centerAxis || meanFilter)) { rowY += (kEdgePanelMeanEditHeight + kEdgePanelMeanRowGap) * m_scale; if (centerAxis) { rowY += kEdgePanelMeanRowHeight * m_scale; } } } } return layout; } bool PlotView::IsOnEdgeCursorLine(int x, int y, int& laneIndex) const { laneIndex = -1; if (m_tab == nullptr) { return false; } const std::vector& tabLanes = m_tab->Lanes(); const float fx = static_cast(x); const float fy = static_cast(y); const float tolerance = 5.0f * m_scale; for (std::size_t index = 0; index < m_lanes.size() && index < tabLanes.size(); ++index) { if (!tabLanes[index].edgeCount) { continue; } const LaneVisual& visual = m_lanes[index]; if (fx < visual.rect.X || fx > visual.rect.X + visual.rect.Width) { continue; } const float clipTop = std::max(visual.rect.Y, m_plotRect.Y); const float clipBottom = std::min(visual.rect.Y + visual.rect.Height, m_plotRect.Y + m_plotRect.Height); if (clipBottom <= clipTop) { continue; } const float lineY = std::max( clipTop, std::min(ValueToY(tabLanes[index].edgeValue, visual), clipBottom)); if (std::fabs(fy - lineY) <= tolerance) { laneIndex = static_cast(index); return true; } } return false; } PlotView::DataMarker PlotView::MakeDataMarker(float rawX, float rawY, float pinX, float clipTop, float clipBottom, float topReserve) const { DataMarker marker; // 形状**只**由“数据点本身是否越出绘制边界”决定: // 旧实现拿“夹取之后”的位置反推越界方向,于是贴近边界、其实仍在视界内的数据点 // 也会被误画成三角(用户报告的“数值显示超出边界就改变数据点形状”)。 const float left = m_plotRect.X; const float right = m_plotRect.X + m_plotRect.Width; marker.offLeft = (rawX < left); marker.offRight = (rawX > right); marker.offUp = (rawY < clipTop); marker.offDown = (rawY > clipBottom); marker.x = pinX; // 左右越界时三角沿水平方向**向外**伸出 `kMarkerTriangleDepth`: // 光标贴边时 pinX 只离边界 1px,若直接把锚点放在那里,三角会有一大半画到 // 网格绘制区之外(被裁剪后只看到残缺的一角,描边也随之断开)。 // 因此把锚点沿水平方向**向内**推 keepIn,保证三角完整落在绘制区内。 const float keepIn = (kMarkerTriangleDepth + 1.0f) * m_scale; if (marker.offLeft) { marker.x = std::max(pinX, left + keepIn); } else if (marker.offRight) { marker.x = std::min(pinX, right - keepIn); } if (marker.offUp) { // 上越界:为方向三角与顶部把手预留高度,避免压住把手。 marker.y = clipTop + kMarkerEdgeInset * m_scale + topReserve; } else if (marker.offDown) { marker.y = clipBottom - kMarkerEdgeInset * m_scale; } else { // 视界内:保留数据点的真实纵坐标(越界判定已在上面完成,这里夹取只为了让 // 圆点整体可见,不会影响形状)。 marker.y = std::max(clipTop + kMarkerInsideMargin * m_scale, std::min(rawY, clipBottom - kMarkerInsideMargin * m_scale)); } return marker; } void PlotView::PaintDataMarker(gd::Graphics& g, const DataMarker& marker, float radius, std::uint32_t fillColor, std::uint32_t ringColor) const { gd::SolidBrush ring(ToGdiColor(ringColor)); gd::SolidBrush fill(ToGdiColor(fillColor)); const float arm = kMarkerTriangleArm * m_scale; /**< 三角“半宽/半高”(垂直于朝向的半边长)。 */ const float depth = kMarkerTriangleDepth * m_scale; /**< 三角沿朝向的深度(与光标把手同比例)。 */ // 越界三角:**尖端朝外**(指向数据点所在的方向,即视界之外),底边在网格内侧; // 三角外接矩形在横/纵两个方向都与锚点**居中**(上下越界时因此与取值标注纵向居中)。 // 旧实现方向是反的(上越界画下三角、左越界画右三角),而且内三角按“平移”而非 // “向质心收缩”构造,填充色会从三角尖端戳出来。 const auto paintTriangle = [&](const gd::PointF* outer) { g.FillPolygon(&ring, outer, 3); const float centerX = (outer[0].X + outer[1].X + outer[2].X) / 3.0f; const float centerY = (outer[0].Y + outer[1].Y + outer[2].Y) / 3.0f; // 按质心等比收缩得到内三角:几何上恒在外三角内部,描边宽度也就均匀了。 const float shrink = 0.58f; gd::PointF inner[3]; for (int index = 0; index < 3; ++index) { inner[index] = gd::PointF(centerX + (outer[index].X - centerX) * shrink, centerY + (outer[index].Y - centerY) * shrink); } g.FillPolygon(&fill, inner, 3); }; // 左右越界:三角指向左/右(视界之外)。 if (marker.offLeft || marker.offRight) { const bool offLeft = marker.offLeft; const float tipX = offLeft ? (marker.x - depth) : (marker.x + depth); const float baseX = marker.x; gd::PointF outer[3]; outer[0] = gd::PointF(baseX, marker.y - arm); outer[1] = gd::PointF(baseX, marker.y + arm); outer[2] = gd::PointF(tipX, marker.y); paintTriangle(outer); return; } // 上下越界:三角指向上/下(视界之外),外接矩形以 marker.y 为纵向中心 // → 与取值标注的纵向中心对齐。 if (marker.offUp || marker.offDown) { const bool offUp = marker.offUp; const float half = depth * 0.5f; const float tipY = offUp ? (marker.y - half) : (marker.y + half); const float baseY = offUp ? (marker.y + half) : (marker.y - half); gd::PointF outer[3]; outer[0] = gd::PointF(marker.x - arm, baseY); outer[1] = gd::PointF(marker.x + arm, baseY); outer[2] = gd::PointF(marker.x, tipY); paintTriangle(outer); return; } // 视界内:常规圆点。 g.FillEllipse(&ring, marker.x - radius - 1.2f * m_scale, marker.y - radius - 1.2f * m_scale, (radius + 1.2f * m_scale) * 2.0f, (radius + 1.2f * m_scale) * 2.0f); g.FillEllipse(&fill, marker.x - radius, marker.y - radius, radius * 2.0f, radius * 2.0f); } 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); } void PlotView::PaintDerivedCurve(gd::Graphics& g, const ResolvedSignal& resolved, const LaneVisual& lane, const std::vector& curve, std::size_t curveBegin) const { if (resolved.data == nullptr || resolved.file == nullptr || resolved.style == nullptr) { return; } const std::vector& time = resolved.file->time; const std::size_t count = std::min(time.size(), resolved.data->values.size()); if (count == 0 || m_plotRect.Width <= 1.0f || curve.empty()) { return; } std::size_t begin = 0; std::size_t end = 0; VisibleIndexRange(*resolved.file, count, begin, end); if (end <= begin) { return; } const std::size_t visibleCount = end - begin; const float limitTop = lane.rect.Y - lane.rect.Height * 4.0f; const float limitBottom = lane.rect.Y + lane.rect.Height * 5.0f; // 派生曲线的颜色:与原信号**同色相**但大幅拉开明度,使其与原始波形形成高对比 // (原信号改颜色时曲线随之改变,因为它是从信号色派生出来的);透明度与原信号一致。 const unsigned int baseAlpha = static_cast( std::max(0.0, std::min(1.0, static_cast(resolved.style->opacity))) * 255.0 + 0.5); const std::uint32_t derived = WithAlpha(HighContrastDerived(StyleColor(*resolved.style)), baseAlpha); // 顶点预算与波形一致(每像素列两个顶点)。 // 交互期间进一步降到 1/2:派生曲线往往有 10 条(5 网格 × 均值滤波 + 弱波增强), // 顶点数就是绘制耗时;降低密度能让拖动参数滑块保持流畅, // 松手后下一帧立刻恢复全密度(用户要求的"低分辨率 → 高分辨率")。 std::size_t budget = static_cast(std::max(1.0f, m_plotRect.Width)) * kPolylinePointsPerPixel; budget = std::max(budget, kMinPolylinePoints); budget = std::min(budget, kMaxPolylinePoints); if (m_fastFrame) { budget = std::max(kMinPolylinePoints, budget / 2); } const std::size_t stride = std::max(1, visibleCount / std::max(1, budget)); std::vector points; std::vector breaks; points.reserve(std::min(visibleCount, budget) + 1); bool inSegment = false; // 「陡峭段」计数:用于判断抗锯齿是否值得付出(见下)。 std::size_t tallSegments = 0; std::size_t totalSegments = 0; for (std::size_t index = begin; index < end; index += stride) { if (index < curveBegin || (index - curveBegin) >= curve.size()) { inSegment = false; continue; } const float sampled = curve[index - curveBegin]; if (!std::isfinite(sampled)) { inSegment = false; continue; } float y = ValueToY(static_cast(sampled), lane); y = static_cast(ClampDouble(y, limitTop, limitBottom)); const float x = TimeToX(time[index]); if (!inSegment) { breaks.push_back(points.size()); points.push_back(gd::PointF(x, y)); inSegment = true; } else { if (std::fabs(y - points.back().Y) > lane.rect.Height * 0.5f) { ++tallSegments; } ++totalSegments; points.push_back(gd::PointF(x, y)); } } if (breaks.empty()) { return; } // 派生曲线**恢复为抗锯齿折线**(与"之前"的显示效果一致)。 // 但抗锯齿在"陡峭段很多"的形状上代价极高(实测 2448 顶点 26~37ms,无抗锯齿仅 3.5ms), // 而那种形状下抗锯齿的平滑作用几乎看不出来 —— 因此: // * 交互期间(`m_fastFrame`)一律关闭; // * 陡峭段占比过高时(方波/编码器这类信号)也关闭。 const bool spiky = (totalSegments > 0) && (tallSegments * 8 > totalSegments); const int savedSmoothing = g.GetSmoothingMode(); if (m_fastFrame || spiky) { g.SetSmoothingMode(gd::SmoothingModeNone); } gd::Pen pen(ToGdiColor(derived), std::max(1.0f, resolved.style->lineWidth) * m_scale); pen.SetLineJoin(gd::LineJoinRound); pen.SetStartCap(gd::LineCapRound); pen.SetEndCap(gd::LineCapRound); 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(); if (stop <= start + 1) { continue; } g.DrawLines(&pen, points.data() + start, static_cast(stop - start)); } g.SetSmoothingMode(static_cast(savedSmoothing)); } void PlotView::PaintMeanFilterCurve(gd::Graphics& g, const ResolvedSignal& resolved, const LaneVisual& lane, int points, int iterations) const { if (resolved.data == nullptr || resolved.file == nullptr || resolved.style == nullptr) { return; } // 只取「可见区间 ± 保护带」的窗口(见 `CurveSpanFor`):拖参数滑块不会重算整条曲线。 // 与「边沿计数(中心轴)」共用同一段绘制逻辑 //(同色相高对比度的派生配色 + 同透明度),只是曲线本身按多次迭代滤波计算。 const std::size_t count = std::min(resolved.file->time.size(), resolved.data->values.size()); std::size_t begin = 0; std::size_t end = 0; VisibleIndexRange(*resolved.file, count, begin, end); if (end <= begin) { return; } std::size_t spanBegin = 0; const std::vector* curve = CurveSpanFor(DerivedCurveKind::MEAN_FILTER, *resolved.data, points, iterations, 0, begin, end, spanBegin, true); if (curve == nullptr) { return; } PaintDerivedCurve(g, resolved, lane, *curve, spanBegin); } void PlotView::PaintWeakBoostCurve(gd::Graphics& g, const ResolvedSignal& resolved, const LaneVisual& lane, int levelPercent, int iterations) const { if (resolved.data == nullptr || resolved.file == nullptr || resolved.style == nullptr) { return; } const std::size_t count = std::min(resolved.file->time.size(), resolved.data->values.size()); std::size_t begin = 0; std::size_t end = 0; VisibleIndexRange(*resolved.file, count, begin, end); if (end <= begin) { return; } std::size_t spanBegin = 0; const std::vector* curve = CurveSpanFor(DerivedCurveKind::WEAK_BOOST, *resolved.data, 0, iterations, levelPercent, begin, end, spanBegin, true); if (curve == nullptr) { return; } // 派生曲线一律使用同一段绘制逻辑与配色规则(弱波增强曲线同样如此)。 PaintDerivedCurve(g, resolved, lane, *curve, spanBegin); } namespace { /** @brief FNV-1a 混合(面板像素缓存键共用)。 */ inline void MixPanelKey(std::uint64_t& hash, std::uint64_t value) { hash ^= value; hash *= 1099511628211ull; } } // namespace bool PlotView::EnsurePanelCache() { // 图层只需覆盖绘图区(面板一律被夹在 m_plotRect 内),且**原点必须与客户区一致**: // 面板绘制代码用的是客户区绝对坐标,而 `UiText` 走 GDI(不受 GDI+ 变换影响), // 因此不能靠平移变换把坐标搬进一张小位图里。 const int width = static_cast(std::ceil(m_area.X + m_area.Width)); const int height = static_cast(std::ceil(m_area.Y + m_area.Height)); if (width <= 0 || height <= 0) { return false; } if (m_panelDc != nullptr && m_panelWidth == width && m_panelHeight == height) { return true; } ReleasePanelCache(); HDC screen = GetDC(nullptr); if (screen == nullptr) { return false; } m_panelDc = CreateCompatibleDC(screen); m_panelBitmap = CreateCompatibleBitmap(screen, width, height); ReleaseDC(nullptr, screen); if (m_panelDc == nullptr || m_panelBitmap == nullptr) { ReleasePanelCache(); return false; } m_panelOldBitmap = SelectObject(m_panelDc, m_panelBitmap); m_panelWidth = width; m_panelHeight = height; m_panelValid = false; return true; } void PlotView::ReleasePanelCache() { m_panelValid = false; m_panelSlots.clear(); if (m_panelDc != nullptr) { if (m_panelOldBitmap != nullptr) { SelectObject(m_panelDc, m_panelOldBitmap); m_panelOldBitmap = nullptr; } DeleteDC(m_panelDc); m_panelDc = nullptr; } if (m_panelBitmap != nullptr) { DeleteObject(m_panelBitmap); m_panelBitmap = nullptr; } m_panelWidth = 0; m_panelHeight = 0; } bool PlotView::PanelRectInsideLayer(const gd::RectF& rect) const { if (m_panelDc == nullptr) { return false; } return rect.X >= 0.0f && rect.Y >= 0.0f && rect.X + rect.Width <= static_cast(m_panelWidth) && rect.Y + rect.Height <= static_cast(m_panelHeight); } std::uint64_t PlotView::PanelGlobalKey() const { std::uint64_t hash = 1469598103934665603ull; const auto mix = [&hash](std::uint64_t value) { MixPanelKey(hash, value); }; const auto mixFloat = [&mix](float value) { std::uint32_t bits = 0; std::memcpy(&bits, &value, sizeof(bits)); mix(bits); }; const auto mixDouble = [&mix](double value) { std::uint64_t bits = 0; std::memcpy(&bits, &value, sizeof(bits)); mix(bits); }; const auto mixRect = [&mixFloat](const gd::RectF& rect) { mixFloat(rect.X); mixFloat(rect.Y); mixFloat(rect.Width); mixFloat(rect.Height); }; const auto mixRef = [&mix](const SignalRef& ref) { mix(ref.IsValid() ? 1ull : 0ull); mix(static_cast(ref.fileIndex)); mix(static_cast(ref.signalIndex)); }; mixFloat(m_scale); mix(reinterpret_cast(m_theme)); mix(reinterpret_cast(m_fonts)); mixRect(m_plotRect); mixRect(m_area); mix(static_cast(m_dragMode)); mix(static_cast(m_laneDragFrom + 2)); mix(static_cast(m_laneDragTarget + 2)); mix(static_cast(m_laneDragMouseY + 4096)); mix(static_cast(m_hoverLane + 2)); mix(m_cursorHover ? 1ull : 0ull); mix(static_cast(m_hoverCursor + 2)); mix(static_cast(m_activeCursor)); mix(m_dragMoved ? 1ull : 0ull); mixRect(m_rubberRect); mixRef(m_hoverSignal); mixRef(m_hoverLegend); mixRef(m_dragSignal); mixRef(SelectedRef()); mix(static_cast(m_boostDragLane + 2)); mix(static_cast(m_edgePanelDragLane + 2)); mix(static_cast(m_edgePanelDragKind)); // 时间光标与统计区间:面板标题的时间后缀、以及「中心轴」行的上升/下降沿数字都依赖它们。 mix(static_cast(Tab().CursorMode())); mixDouble(Tab().CursorTime()); mixDouble(Tab().CursorTimeAlt()); mix(EdgeRangeActive() ? 1ull : 0ull); mixDouble(EdgeRangeMin()); mixDouble(EdgeRangeMax()); // 跟随鼠标的叠加层(悬停光晕 / 图例半高亮 / 信号拖拽虚影 / 提示动画)位置随时在变: // 只要它存在,就把所有面板缓存作废,避免它的残影被一起缓存进面板像素。 const bool mouseDrivenOverlay = m_hoverSignal.IsValid() || m_hoverLegend.IsValid() || m_dragMode == PlotDragMode::MOVE_SIGNAL || m_dragMode == PlotDragMode::RUBBER_SELECT || HintNeedsAnimation(); mix(mouseDrivenOverlay ? 1ull : 0ull); if (mouseDrivenOverlay) { mix(static_cast(m_mouseX + 4096)); mix(static_cast(m_mouseY + 4096)); } for (const LaneVisual& visual : m_lanes) { mixRect(visual.rect); mixFloat(static_cast(visual.yMin)); mixFloat(static_cast(visual.yMax)); } if (m_tab != nullptr) { const std::vector& lanes = m_tab->Lanes(); for (const Lane& lane : lanes) { mixFloat(static_cast(lane.edgePanelX)); mixFloat(static_cast(lane.edgePanelY)); mixFloat(static_cast(lane.edgeCenterPanelX)); mixFloat(static_cast(lane.edgeCenterPanelY)); mixFloat(static_cast(lane.meanFilterPanelX)); mixFloat(static_cast(lane.meanFilterPanelY)); mixFloat(static_cast(lane.weakBoostPanelX)); mixFloat(static_cast(lane.weakBoostPanelY)); mix(lane.edgePanelOpen ? 1ull : 0ull); mix(lane.edgeCenterPanelOpen ? 1ull : 0ull); mix(lane.meanFilterPanelOpen ? 1ull : 0ull); mix(lane.weakBoostPanelOpen ? 1ull : 0ull); mix(lane.edgeValueValid ? 1ull : 0ull); mixDouble(lane.edgeValue); } } return hash; } std::uint64_t PlotView::PanelSlotKey(int laneIndex, EdgePanelKind kind, const EdgePanelLayout& layout) const { std::uint64_t hash = 1469598103934665603ull; const auto mix = [&hash](std::uint64_t value) { MixPanelKey(hash, value); }; const auto mixFloat = [&mix](float value) { std::uint32_t bits = 0; std::memcpy(&bits, &value, sizeof(bits)); mix(bits); }; const auto mixRect = [&mixFloat](const gd::RectF& rect) { mixFloat(rect.X); mixFloat(rect.Y); mixFloat(rect.Width); mixFloat(rect.Height); }; mix(static_cast(laneIndex + 2)); mix(static_cast(static_cast(kind) + 2)); mixRect(layout.panel); mixRect(layout.title); mixRect(layout.close); mixRect(layout.edit); mixRect(layout.button); mixRect(layout.slider); mixRect(layout.sliderIterations); mix(static_cast(layout.rows)); for (const gd::RectF& rect : layout.rowChecks) { mixRect(rect); } for (const gd::RectF& rect : layout.rowPoints) { mixRect(rect); } for (const gd::RectF& rect : layout.rowIterations) { mixRect(rect); } for (const gd::RectF& rect : layout.rowStats) { mixRect(rect); } const Lane& lane = Tab().Lanes()[static_cast(laneIndex)]; for (const SignalRef& ref : layout.rowRefs) { mix(ref.IsValid() ? 1ull : 0ull); mix(static_cast(ref.fileIndex)); mix(static_cast(ref.signalIndex)); // 勾选状态与参数直接决定行文本与行高。 mix(lane.MeanFilterEnabled(ref) ? 1ull : 0ull); mix(lane.EdgeCenterEnabled(ref) ? 1ull : 0ull); mix(lane.WeakBoostEnabled(ref) ? 1ull : 0ull); const Lane::MeanFilterItem* meanItem = lane.FindMeanFilterItem(ref); mix(static_cast(meanItem != nullptr ? meanItem->points + 2 : 0)); mix(static_cast(meanItem != nullptr ? meanItem->iterations + 2 : 0)); const Lane::EdgeCenterItem* centerItem = lane.FindEdgeCenterItem(ref); mix(static_cast(centerItem != nullptr ? centerItem->points + 2 : 0)); mix(static_cast(centerItem != nullptr ? centerItem->iterations + 2 : 0)); } // 滑块数值(等级百分比 + 迭代次数)只显示在「弱波增强」面板上: // ⚠️ 必须限定 kind,否则拖等级滑块会把同一网格的均值滤波 / 中心轴面板 // 也判为脏,再经"重叠级联"扩散到其他网格,等于所有面板都重画(实测 40ms/帧)。 if (kind == EdgePanelKind::BOOST) { mixFloat(static_cast(lane.weakBoostLevel)); mix(static_cast(lane.weakBoostIterations + 2)); } // 鼠标落在面板内时,✕ / 复选框 / 滑块 / 底部按钮的悬停态都依赖精确坐标。 const float margin = 6.0f * m_scale; const gd::RectF hot(layout.panel.X - margin, layout.panel.Y - margin, layout.panel.Width + margin * 2.0f, layout.panel.Height + margin * 2.0f); if (UiPointInRect(hot, static_cast(m_mouseX), static_cast(m_mouseY))) { mix(static_cast(m_mouseX + 4096)); mix(static_cast(m_mouseY + 4096)); } return hash; } void PlotView::PaintEdgeCursor(gd::Graphics& g, const std::vector& lanes) { if (m_tab == nullptr || m_dataSet == nullptr) { return; } std::vector& tabLanes = m_tab->Lanes(); const std::size_t count = std::min(lanes.size(), tabLanes.size()); const std::uint32_t color = CursorColor(); const UiInk ink = UiContrastInk(color); // ---- 横向光标(线与两端把手 + 左侧幅值读数)---- for (std::size_t index = 0; index < count; ++index) { Lane& tabLane = tabLanes[index]; if (!tabLane.edgeCount) { continue; } const LaneVisual& visual = lanes[index]; 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; } // 首次绘制时把横向光标落在可见幅值范围的中点。 if (!tabLane.edgeValueValid) { tabLane.edgeValue = (visual.yMin + visual.yMax) * 0.5; tabLane.edgeValueValid = true; } const float lineY = std::max(clipTop, std::min(ValueToY(tabLane.edgeValue, visual), clipBottom)); const float left = visual.rect.X; const float right = visual.rect.X + visual.rect.Width; // 横线与纵向光标同色同宽;两端各画一个尖端朝内的三角把手。 UiDrawLine(g, left, lineY, right, lineY, color, 1.4f * m_scale); const float arm = 5.0f * m_scale; gd::SolidBrush handle(ToGdiColor(color)); gd::PointF leftHandle[3]; leftHandle[0] = gd::PointF(left, lineY - arm); leftHandle[1] = gd::PointF(left, lineY + arm); leftHandle[2] = gd::PointF(left + arm * 1.4f, lineY); g.FillPolygon(&handle, leftHandle, 3); gd::PointF rightHandle[3]; rightHandle[0] = gd::PointF(right, lineY - arm); rightHandle[1] = gd::PointF(right, lineY + arm); rightHandle[2] = gd::PointF(right - arm * 1.4f, lineY); g.FillPolygon(&handle, rightHandle, 3); // 幅值读数:靠网格左侧、纵向居中于横线(与纵向光标的时间读数同款胶囊)。 // **必须让开左端的三角把手**(把手横向占 [left, left + arm*1.4]), // 否则数值胶囊会盖住把手(用户明确要求向右挪一些)。 const std::wstring text = FormatValue(tabLane.edgeValue); const float textWidth = std::max( 56.0f * m_scale, UiMeasureTextWidth(m_fonts->monoSmall, text) + 16.0f * m_scale); const float textHeight = 18.0f * m_scale; const float labelLeft = left + (arm * 1.4f + 4.0f * m_scale); const float labelTop = std::max(clipTop + 1.0f * m_scale, std::min(lineY - textHeight * 0.5f, clipBottom - textHeight - 1.0f * m_scale)); const gd::RectF labelRect(labelLeft, labelTop, textWidth, textHeight); UiFillRoundRect(g, labelRect, 3.0f * m_scale, WithAlpha(color, 0xE0)); DrawLabelOutline(g, labelRect, 3.0f * m_scale, ink.outline); UiText(g, text, m_fonts->monoSmall, labelRect, ink.text, TextAlign::CENTER); // 幅值超出视界时(横线被贴边显示),在数值胶囊的**上方/下方**画一个方向三角, // 与常规纵向光标数据点越界的处理方式一致;三角与胶囊**水平居中对齐**。 const float rawLineY = ValueToY(tabLane.edgeValue, visual); const bool offUp = (rawLineY < clipTop - 0.5f); const bool offDown = (rawLineY > clipBottom + 0.5f); if (offUp || offDown) { const float tipArm = 5.0f * m_scale; const float depth = tipArm * 1.4f; const float centerX = labelRect.X + labelRect.Width * 0.5f; // 三角整体贴在胶囊外侧(上方越界 → 胶囊上方的向上三角)。 const float anchorY = offUp ? (labelRect.Y - depth * 0.5f - 1.0f * m_scale) : (labelRect.Y + labelRect.Height + depth * 0.5f + 1.0f * m_scale); gd::PointF outer[3]; if (offUp) { outer[0] = gd::PointF(centerX - tipArm, anchorY + depth * 0.5f); outer[1] = gd::PointF(centerX + tipArm, anchorY + depth * 0.5f); outer[2] = gd::PointF(centerX, anchorY - depth * 0.5f); } else { outer[0] = gd::PointF(centerX - tipArm, anchorY - depth * 0.5f); outer[1] = gd::PointF(centerX + tipArm, anchorY - depth * 0.5f); outer[2] = gd::PointF(centerX, anchorY + depth * 0.5f); } gd::SolidBrush tipBrush(ToGdiColor(color)); g.FillPolygon(&tipBrush, outer, 3); } } // ---- 统计浮动面板(最后绘制,覆盖在波形之上)---- // 「边沿计数」「边沿计数(中心轴)」「均值滤波」「弱波增强」共用同一套样式, // 仅标题、内容行与底部按钮不同。 // // 面板是**静态**内容:只有被拖动 / 悬停 / 参数变化的那一个真正重画, // 其余直接贴上一帧缓存好的像素(15 个面板全画一遍实测约 33ms, // 而贴图 <1ms —— 拖动弱波增强滑块时的主要卡顿就来自这一层)。 std::vector nextSlots; std::vector dirtyRects; const std::uint64_t panelGlobalKey = PanelGlobalKey(); // 编辑态(输入框 + 插入符)逐个面板变化太细碎,直接整体不缓存。 const bool panelCacheReady = (m_editTarget == PlotEditTarget::NONE) && EnsurePanelCache(); const bool panelGlobalSame = panelCacheReady && m_panelValid && panelGlobalKey == m_panelGlobalKey; for (int pass = 0; pass < kEdgePanelKindCount; ++pass) { const EdgePanelKind kind = kEdgePanelKinds[pass]; const bool centerAxis = (kind == EdgePanelKind::CENTER); const bool meanFilter = (kind == EdgePanelKind::MEAN); const bool weakBoost = (kind == EdgePanelKind::BOOST); // 「均值滤波」与「弱波增强」共用「逐条目 + 底部按钮」排布。 const bool itemized = (centerAxis || meanFilter || weakBoost); const bool hasButton = (meanFilter || weakBoost); for (std::size_t index = 0; index < count; ++index) { const Lane& tabLane = tabLanes[index]; const EdgePanelLayout layout = EdgePanelLayoutFor(static_cast(index), kind); const gd::RectF& panel = layout.panel; if (panel.Width <= 0.0f || panel.Height <= 0.0f) { continue; } // 面板自身的内容指纹;命中缓存时直接贴图,本帧不再重画这个面板。 // ⚠️ 缓存像素里含**它与它下面的一切**(面板背后的波形、被它遮住的其他面板), // 因此只要有一个面板重画,所有「在它之后绘制、且与它重叠」的面板也必须重画, // 否则后画者会把旧像素重新盖回去(面板默认位置本来就常常叠在一起)。 const float cacheMargin = 4.0f * m_scale; const gd::RectF cachedRect(panel.X - cacheMargin, panel.Y - cacheMargin, panel.Width + cacheMargin * 2.0f, panel.Height + cacheMargin * 2.0f); bool forcedDirty = false; for (const gd::RectF& dirtyRect : dirtyRects) { if (dirtyRect.IntersectsWith(cachedRect)) { forcedDirty = true; break; } } std::uint64_t slotKey = 0; const std::size_t slotIndex = nextSlots.size(); if (panelCacheReady) { slotKey = PanelSlotKey(static_cast(index), kind, layout); if (panelGlobalSame && !forcedDirty) { const PanelSlot* hit = nullptr; for (const PanelSlot& slot : m_panelSlots) { if (slot.laneIndex == static_cast(index) && slot.kind == static_cast(kind)) { hit = &slot; break; } } if (hit != nullptr && hit->key != 0 && hit->key == slotKey) { HDC target = g.GetHDC(); if (target != nullptr) { BitBlt(target, hit->x0, hit->y0, hit->x1 - hit->x0, hit->y1 - hit->y0, m_panelDc, hit->x0, hit->y0, SRCCOPY); g.ReleaseHDC(target); } nextSlots.push_back(*hit); continue; } } } nextSlots.push_back(PanelSlot()); dirtyRects.push_back(cachedRect); const float titleHeight = kEdgePanelTitleHeight * m_scale; const float rowHeight = kEdgePanelRowHeight * m_scale; const float pad = kEdgePanelPad * m_scale; // 面板主体 + 标题栏。 UiFillRoundRect(g, gd::RectF(panel.X + 1.0f * m_scale, panel.Y + 2.0f * m_scale, panel.Width, panel.Height), 5.0f * m_scale, 0x14000000u); UiFillRoundRect(g, panel, 5.0f * m_scale, WithAlpha(m_theme->cardBg, 0xF4)); UiDrawRoundRect(g, panel, 5.0f * m_scale, m_theme->borderStrong, 1.0f); UiFillRoundRect(g, gd::RectF(panel.X, panel.Y, panel.Width, titleHeight), 5.0f * m_scale, WithAlpha(m_theme->accent, 0x2A)); // 标题:与左侧信号面板一致的 “GX” 徽标。 const float badgeWidth = kLaneBadgeWidth * m_scale; const float badgeHeight = kLaneBadgeHeight * m_scale; const gd::RectF badgeRect(panel.X + pad, panel.Y + (titleHeight - badgeHeight) * 0.5f, badgeWidth, badgeHeight); UiFillRoundRect(g, badgeRect, badgeHeight * 0.5f, m_theme->cardBg); UiDrawRoundRect(g, badgeRect, badgeHeight * 0.5f, m_theme->border, 1.0f); UiText(g, L"G" + std::to_wstring(index + 1), m_fonts->monoSmall, badgeRect, m_theme->text, TextAlign::CENTER); const std::wstring title = meanFilter ? std::wstring(L"均值滤波") : (weakBoost ? std::wstring(kWeakBoostTitle) : ((centerAxis ? L"中心轴边沿计数" : L"边沿计数") + EdgeRangeSuffix())); UiText(g, title, m_fonts->uiSmall, gd::RectF(badgeRect.X + badgeRect.Width + 8.0f * m_scale, panel.Y, panel.Width - badgeRect.Width - 8.0f * m_scale - titleHeight, titleHeight), m_theme->textStrong, TextAlign::LEFT); // 关闭按钮 ✕:悬停时高亮提示(鼠标光标本身不改变样式)。 const gd::RectF& closeRect = layout.close; const bool closeHot = (m_dragMode != PlotDragMode::EDGE_PANEL_MOVE) && UiPointInRect(closeRect, static_cast(m_mouseX), static_cast(m_mouseY)); if (closeHot) { UiFillRoundRect(g, gd::RectF(closeRect.X + 3.0f * m_scale, closeRect.Y + 3.0f * m_scale, closeRect.Width - 6.0f * m_scale, closeRect.Height - 6.0f * m_scale), 3.0f * m_scale, WithAlpha(m_theme->danger, 0x33)); } const float crossPad = 8.0f * m_scale; const std::uint32_t crossColor = closeHot ? m_theme->danger : m_theme->textMuted; UiDrawLine(g, closeRect.X + crossPad, closeRect.Y + crossPad, closeRect.X + closeRect.Width - crossPad, closeRect.Y + closeRect.Height - crossPad, crossColor, 1.6f * m_scale); UiDrawLine(g, closeRect.X + closeRect.Width - crossPad, closeRect.Y + crossPad, closeRect.X + crossPad, closeRect.Y + closeRect.Height - crossPad, crossColor, 1.6f * m_scale); // 「中心轴边沿计数」/「均值滤波」/「弱波增强」面板都采用「逐条目」排布: // 第一排 = 名称 + 复选框,第二排 = 两个参数输入框(仅勾选的条目, // 弱波增强没有参数输入框),中心轴再多第三排 = 对应的统计结果。 if (itemized) { for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex) { const SignalRef& ref = layout.rowRefs[rowIndex]; const ResolvedSignal resolved = ResolveSignal(ref); if (resolved.data == nullptr || resolved.style == nullptr) { continue; } const std::uint32_t signalColor = StyleColor(*resolved.style); const gd::RectF& checkRect = layout.rowChecks[rowIndex]; const bool checked = meanFilter ? tabLane.MeanFilterEnabled(ref) : (weakBoost ? tabLane.WeakBoostEnabled(ref) : tabLane.EdgeCenterEnabled(ref)); // 名称:第一排左侧,避开右侧复选框。 const float nameWidth = std::max(20.0f, checkRect.X - (panel.X + pad) - 6.0f * m_scale); UiText(g, UiEllipsize(resolved.data->Title(), m_fonts->uiSmall, nameWidth), m_fonts->uiSmall, gd::RectF(panel.X + pad, checkRect.Y - 3.0f * m_scale, nameWidth, checkRect.Height + 6.0f * m_scale), signalColor, TextAlign::LEFT); UiDrawCheckBox(g, checkRect, checked ? 1 : 0, 3.0f * m_scale); if (!checked) { continue; } // 第二排:两个输入框(「均值点数 X / 迭代次数 N」;弱波增强没有参数输入框)。 const Lane::MeanFilterItem* item = meanFilter ? tabLane.FindMeanFilterItem(ref) : tabLane.FindEdgeCenterItem(ref); const int shownPoints = (item != nullptr) ? item->points : 5; const int shownOrder = (item != nullptr) ? item->iterations : 1; const PlotEditTarget pointsTarget = meanFilter ? PlotEditTarget::MEAN_FILTER_POINTS : PlotEditTarget::EDGE_CENTER_POINTS; const PlotEditTarget orderTarget = meanFilter ? PlotEditTarget::MEAN_FILTER_ITERATIONS : PlotEditTarget::EDGE_CENTER_ITERATIONS; const auto paintField = [&](const gd::RectF& rect, const wchar_t* label, int value, PlotEditTarget target) { if (rect.Width <= 0.0f) { return; } const float labelWidth = UiMeasureTextWidth(m_fonts->uiSmall, label); UiText(g, label, m_fonts->uiSmall, gd::RectF(rect.X, rect.Y, labelWidth, rect.Height), m_theme->textMuted, TextAlign::LEFT); const float fieldWidth = std::max(kEdgePanelEditFieldWidth * m_scale * 0.75f, UiMeasureTextWidth(m_fonts->monoSmall, std::to_wstring(value)) + 18.0f * m_scale); const float fieldLeft = rect.X + rect.Width - fieldWidth; const float fieldHeight = rect.Height - 4.0f * m_scale; const gd::RectF fieldRect(fieldLeft, rect.Y + 2.0f * m_scale, fieldWidth, fieldHeight); const bool editingThis = (m_editTarget == target) && (m_editLane == static_cast(index)) && (m_editMeanRef == ref); if (editingThis) { UiDrawEditField(g, fieldRect, m_edit, m_fonts->monoSmall, m_scale); } else { UiFillRoundRect(g, fieldRect, 3.0f * m_scale, m_theme->pressed); UiDrawRoundRect(g, fieldRect, 3.0f * m_scale, m_theme->border, 1.0f); UiText(g, std::to_wstring(value), m_fonts->monoSmall, fieldRect, m_theme->textStrong, TextAlign::CENTER); } }; paintField(layout.rowPoints[rowIndex], L"均值点数 X", shownPoints, pointsTarget); paintField(layout.rowIterations[rowIndex], L"迭代次数 N", shownOrder, orderTarget); // 第三排(中心轴):该信号在当前参数下的上升沿/下降沿统计结果。 if (centerAxis && rowIndex < layout.rowStats.size() && layout.rowStats[rowIndex].Width > 0.0f) { const gd::RectF& statsRect = layout.rowStats[rowIndex]; const int statsPoints = (item != nullptr) ? item->points : 5; const int statsOrder = (item != nullptr) ? item->iterations : 1; std::size_t rising = 0; std::size_t falling = 0; EdgeCenterCountsFor(*resolved.file, *resolved.data, statsPoints, statsOrder, EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), rising, falling); const std::wstring counts = L"上升沿 " + std::to_wstring(rising) + L" 下降沿 " + std::to_wstring(falling); UiText(g, counts, m_fonts->monoSmall, statsRect, m_theme->textMuted, TextAlign::LEFT); } } // 「弱波增强」的两个滑块(等级 0–100% / 迭代次数 N 1–10): // 左侧说明文字 + 轨道 + 右侧数值。 if (weakBoost && layout.slider.Width > 0.0f) { const float rowHeight = std::max(kWeakBoostSliderRowHeight * m_scale, layout.slider.Height); // 两行滑块的说明文字必须**左对齐到同一列**(用户明确要求): // 列宽取两条标签中较宽者,与 `EdgePanelLayoutFor` 里定位轨道的方式一致。 const float labelColumnWidth = std::max(UiMeasureTextWidth(m_fonts->uiSmall, kWeakBoostLevelLabel), UiMeasureTextWidth(m_fonts->uiSmall, kWeakBoostIterationsLabel)); const auto paintSliderRow = [&](const gd::RectF& track, const wchar_t* label, double ratio, const std::wstring& valueText, bool active) { if (track.Width <= 0.0f) { return; } const float labelLeft = track.X - 6.0f * m_scale - labelColumnWidth; UiText(g, label, m_fonts->uiSmall, gd::RectF(labelLeft, track.Y + track.Height * 0.5f - rowHeight * 0.5f, labelColumnWidth, rowHeight), m_theme->textMuted, TextAlign::LEFT); // 与样式区/拾色器共用同一套滑块绘制(轨道 + 已填充部分 + 圆钮)。 UiDrawSlider(g, track, ratio, m_theme->accent, active, m_scale); const float valueWidth = UiMeasureTextWidth(m_fonts->monoSmall, valueText); UiText(g, valueText, m_fonts->monoSmall, gd::RectF(track.X + track.Width + 6.0f * m_scale, track.Y + track.Height * 0.5f - rowHeight * 0.5f, valueWidth, rowHeight), m_theme->textStrong, TextAlign::LEFT); }; const double level = std::max(0.0, std::min(1.0, tabLane.weakBoostLevel)); const bool levelActive = (m_dragMode == PlotDragMode::BOOST_LEVEL) && (m_boostDragLane == static_cast(index)); paintSliderRow(layout.slider, kWeakBoostLevelLabel, level, std::to_wstring(static_cast(std::lround(level * 100.0))) + L"%", levelActive); const int iterations = std::max(kWeakBoostIterationsMin, std::min(kWeakBoostIterationsMax, tabLane.weakBoostIterations)); const double orderRatio = static_cast(iterations - kWeakBoostIterationsMin) / static_cast(kWeakBoostIterationsMax - kWeakBoostIterationsMin); const bool orderActive = (m_dragMode == PlotDragMode::BOOST_ITERATIONS) && (m_boostDragLane == static_cast(index)); paintSliderRow(layout.sliderIterations, kWeakBoostIterationsLabel, orderRatio, std::to_wstring(iterations), orderActive); } } float rowY = panel.Y + titleHeight + pad; for (const SignalRef& ref : tabLane.signals) { const ResolvedSignal resolved = ResolveSignal(ref); if (resolved.data == nullptr || resolved.style == nullptr || !resolved.visible) { continue; } if (itemized) { // 逐条目绘制已在上面完成。 continue; } const std::uint32_t signalColor = StyleColor(*resolved.style); const float textWidth = panel.Width - pad * 2.0f; // 名称与统计数字一律完整绘制(不省略):面板宽度已按文本实测宽度确定。 UiText(g, resolved.data->Title(), m_fonts->uiSmall, gd::RectF(panel.X + pad, rowY, textWidth, rowHeight), signalColor, TextAlign::LEFT); std::size_t rising = 0; std::size_t falling = 0; EdgeCountsFor(*resolved.file, *resolved.data, tabLane.edgeValue, EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), rising, falling); const std::wstring counts = L"上升沿 " + std::to_wstring(rising) + L" 下降沿 " + std::to_wstring(falling); UiText(g, counts, m_fonts->monoSmall, gd::RectF(panel.X + pad, rowY + rowHeight, textWidth, rowHeight), m_theme->textMuted, TextAlign::LEFT); rowY += rowHeight * 2.0f; } // 逐条目面板底部按钮行:「构建为新信号」(悬停高亮)。 if (hasButton) { const gd::RectF& buttonRect = layout.button; if (buttonRect.Width > 0.0f) { const gd::RectF inner(buttonRect.X, buttonRect.Y + 2.0f * m_scale, buttonRect.Width, buttonRect.Height - 4.0f * m_scale); const bool hot = (m_dragMode != PlotDragMode::EDGE_PANEL_MOVE) && UiPointInRect(inner, static_cast(m_mouseX), static_cast(m_mouseY)); UiFillRoundRect(g, inner, 4.0f * m_scale, hot ? WithAlpha(m_theme->accent, 0x3A) : m_theme->cardBg); UiDrawRoundRect(g, inner, 4.0f * m_scale, m_theme->accent, 1.2f); UiText(g, kBuildSignalLabel, m_fonts->uiSmall, inner, m_theme->accent, TextAlign::CENTER); } } // 面板画完了:把它刚画进后备缓冲区的像素(连同缓存键)存进面板图层, // 下一帧只要键不变就直接贴图。矩形外的 4 逻辑像素边距用于容纳投影。 PanelSlot& stored = nextSlots[slotIndex]; stored.laneIndex = static_cast(index); stored.kind = static_cast(kind); stored.key = 0; if (panelCacheReady && slotKey != 0) { const int x0 = std::max(0, static_cast(std::floor(cachedRect.X))); const int y0 = std::max(0, static_cast(std::floor(cachedRect.Y))); const int x1 = std::max(x0, static_cast(std::ceil(cachedRect.X + cachedRect.Width))); const int y1 = std::max(y0, static_cast(std::ceil(cachedRect.Y + cachedRect.Height))); const gd::RectF layerRect(static_cast(x0), static_cast(y0), static_cast(x1 - x0), static_cast(y1 - y0)); if (x1 > x0 && y1 > y0 && PanelRectInsideLayer(layerRect)) { HDC source = g.GetHDC(); if (source != nullptr) { const BOOL ok = BitBlt(m_panelDc, x0, y0, x1 - x0, y1 - y0, source, x0, y0, SRCCOPY); g.ReleaseHDC(source); if (ok != FALSE) { stored.key = slotKey; stored.x0 = x0; stored.y0 = y0; stored.x1 = x1; stored.y1 = y1; } } } } } } m_panelSlots = std::move(nextSlots); m_panelGlobalKey = panelGlobalKey; m_panelValid = panelCacheReady; } void PlotView::PaintEditField(gd::Graphics& g) { const gd::RectF fieldRect = EditFieldRect(); if (fieldRect.Width <= 0.0f || fieldRect.Height <= 0.0f) { return; } // 编辑态:强调色外框 + 选区高亮 + 插入符,首次键入即整体替换(与常规输入框一致)。 UiDrawEditField(g, fieldRect, m_edit, m_fonts->monoSmall, m_scale); } void PlotView::PaintSignalInteraction(gd::Graphics& g) { if (m_tab == nullptr || m_dataSet == nullptr || m_lanes.empty()) { return; } // 拖拽中:虚影跟随鼠标纵向位移,同时在目标网格内高亮落点。 if (m_dragMode == PlotDragMode::MOVE_SIGNAL && m_dragSignal.IsValid()) { const ResolvedSignal resolved = ResolveSignal(m_dragSignal); const int sourceLane = m_tab->LaneOfSignal(m_dragSignal); if (resolved.data == nullptr || resolved.style == nullptr || sourceLane < 0 || sourceLane >= static_cast(m_lanes.size())) { return; } // 虚影:以源网格的幅值映射构建波形,再按鼠标位移整体平移。 // 第一帧位移为 0,因此虚影必然精确落在鼠标下方(曲线形态与抓住时完全一致); // 之后纵向与横向均自由跟随,形成“拖着曲线走”的观感。 const LaneVisual& sourceVisual = m_lanes[static_cast(sourceLane)]; const float offsetX = static_cast(m_dragMouseX - m_dragStartX); const float offsetY = static_cast(m_dragMouseY - m_dragStartY); { const int saveState = g.Save(); g.SetClip(gd::RectF(m_plotRect.X, m_plotRect.Y, m_plotRect.Width, m_plotRect.Height), gd::CombineModeIntersect); // 虚影的几何与源网格里的信号完全一致,直接复用折线顶点缓存, // 再用平移变换让整体跟随鼠标:避免每帧重新构建并逐点搬运上万顶点。 const PolylineCacheEntry* ghostSource = AcquirePolyline(m_dragSignal, resolved, sourceVisual); if (ghostSource == nullptr) { g.Restore(saveState); return; } const std::vector& points = ghostSource->points; const std::vector& breaks = ghostSource->breaks; g.TranslateTransform(offsetX, offsetY); const DrawMode mode = resolved.style->mode; const bool drawLine = (mode != DrawMode::SCATTER); const bool drawScatter = (mode == DrawMode::SCATTER || mode == DrawMode::LINE_SCATTER); // 虚影保持原信号的颜色与样式(不再是虚线),仅以轻微透明区分“尚未落地”。 const std::uint32_t ghostColor = WithAlpha(resolved.style->color, static_cast( std::max(0.35, static_cast(resolved.style->opacity)) * 255.0 + 0.5)); if (drawLine && !breaks.empty()) { if (ghostSource->bandMode && !ghostSource->bandBreaks.empty()) { // 稠密包络虚影同样用一次 FillPath(见 PaintLane 的说明)。 gd::GraphicsPath bandPath; bandPath.SetFillMode(gd::FillModeWinding); for (std::size_t k = 0; k < ghostSource->bandBreaks.size(); ++k) { const std::size_t start = ghostSource->bandBreaks[k]; const std::size_t stop = (k + 1 < ghostSource->bandBreaks.size()) ? ghostSource->bandBreaks[k + 1] : ghostSource->band.size(); const std::size_t total = stop - start; if (total < 4 || (total % 2) != 0) { continue; } const std::size_t half = total / 2; bandPath.StartFigure(); bandPath.AddLines(ghostSource->band.data() + start, static_cast(half)); bandPath.AddLines(ghostSource->band.data() + start + half, static_cast(half)); bandPath.CloseFigure(); } if (bandPath.GetPointCount() > 0) { gd::SolidBrush brush(ToGdiColor(ghostColor)); g.FillPath(&brush, &bandPath); } } else { gd::Pen ghost(ToGdiColor(ghostColor), std::max(1.0f, resolved.style->lineWidth) * m_scale); ghost.SetLineJoin(gd::LineJoinRound); ghost.SetStartCap(gd::LineCapRound); ghost.SetEndCap(gd::LineCapRound); 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(); if (stop <= start + 1) { continue; } g.DrawLines(&ghost, points.data() + start, static_cast(stop - start)); } } } if (drawScatter && !points.empty()) { const float radius = static_cast( ClampDouble(static_cast(resolved.style->lineWidth) * kScatterRadiusScale, kMinScatterRadius, kMaxScatterRadius) * m_scale); const std::size_t scatterStride = std::max(1, points.size() / kMaxScatterPoints); // 与主绘制一致:合并成一条路径一次填充,并显式使用缠绕规则避免重叠处出现空洞。 gd::GraphicsPath ghostPath; ghostPath.SetFillMode(gd::FillModeWinding); for (std::size_t k = 0; k < points.size(); k += scatterStride) { ghostPath.AddEllipse(points[k].X - radius, points[k].Y - radius, radius * 2.0f, radius * 2.0f); } gd::SolidBrush brush(ToGdiColor(ghostColor)); if (ghostPath.GetPointCount() > 0) { g.FillPath(&brush, &ghostPath); } } g.Restore(saveState); } // 落点提示:高亮鼠标所在网格并给出文字说明。 const int target = (m_dragTargetLane >= 0) ? m_dragTargetLane : sourceLane; if (target >= 0 && target < static_cast(m_lanes.size())) { const gd::RectF& targetRect = m_lanes[static_cast(target)].rect; const float visibleTop = std::max(targetRect.Y, m_plotRect.Y); const float visibleBottom = std::min(targetRect.Y + targetRect.Height, m_plotRect.Y + m_plotRect.Height); if (visibleBottom > visibleTop) { gd::Pen frame(ToGdiColor(m_theme->accent), 2.0f); g.DrawRectangle(&frame, targetRect.X, visibleTop, targetRect.Width, visibleBottom - visibleTop); const std::wstring hint = (target == sourceLane) ? std::wstring(L"松开左键放回本网格") : (L"移动到网格 " + std::to_wstring(target + 1) + L":松开左键确认"); const float textWidth = UiMeasureTextWidth(m_fonts->uiSmall, hint) + 16.0f * m_scale; const float height = 20.0f * m_scale; float left = targetRect.X + (targetRect.Width - textWidth) * 0.5f; left = std::max(m_plotRect.X, std::min(left, m_plotRect.X + m_plotRect.Width - textWidth)); const gd::RectF pill(left, visibleTop + 4.0f * m_scale, textWidth, height); UiFillRoundRect(g, pill, 3.0f * m_scale, m_theme->accent); UiText(g, hint, m_fonts->uiSmall, pill, m_theme->textOnAccent, TextAlign::CENTER); } } return; } // 其它拖拽(光标/平移/中键/横向光标/面板)期间不显示信号悬停提示: // 这些操作不会更新 m_hoverSignal,按下前的值会残留,表现为提示气泡"挂在原地"。 if (m_dragMode != PlotDragMode::NONE) { return; } // 悬停提示:光标贴近曲线时给出可拖拽说明(与时间光标的提示方式一致)。 if (!m_hoverSignal.IsValid()) { return; } const int laneIndex = m_tab->LaneOfSignal(m_hoverSignal); if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) { return; } const ResolvedSignal resolved = ResolveSignal(m_hoverSignal); if (resolved.data == nullptr) { return; } const LaneVisual& lane = m_lanes[static_cast(laneIndex)]; const std::wstring text = resolved.data->Title() + L" · 按住拖拽可移入其它网格"; // 提示跟随鼠标:在可拖拽区域内持续移动时,提示条也要跟着走。 const float textWidth = UiMeasureTextWidth(m_fonts->uiSmall, text) + 16.0f * m_scale; const float height = 20.0f * m_scale; float left = static_cast(m_mouseX) + 12.0f * m_scale; if (left + textWidth > m_plotRect.X + m_plotRect.Width) { left = m_plotRect.X + m_plotRect.Width - textWidth; } left = std::max(m_plotRect.X, left); float top = static_cast(m_mouseY) + 14.0f * m_scale; top = std::min(top, m_plotRect.Y + m_plotRect.Height - height - 2.0f * m_scale); top = std::max(top, lane.rect.Y + 2.0f * m_scale); const gd::RectF pill(left, top, textWidth, height); UiFillRoundRect(g, pill, 3.0f * m_scale, WithAlpha(m_theme->readoutBg, 0xF2)); UiDrawRoundRect(g, pill, 3.0f * m_scale, m_theme->accent, 1.0f); UiText(g, text, m_fonts->uiSmall, pill, m_theme->textStrong, TextAlign::CENTER); } void PlotView::PaintScrollBar(gd::Graphics& g) { if (!m_scrollBarVisible || m_laneContentHeight <= m_plotRect.Height) { return; } UiFillRoundRect(g, m_scrollBarRect, kScrollBarThickness * m_scale * 0.5f, m_theme->gridWeak); const float ratio = std::min(1.0f, m_plotRect.Height / m_laneContentHeight); const float thumbHeight = std::max(24.0f * m_scale, m_scrollBarRect.Height * ratio); const float maxScroll = m_laneContentHeight - m_plotRect.Height; const float progress = (maxScroll > 0.0f) ? (Tab().LaneScroll() / maxScroll) : 0.0f; const float thumbY = m_scrollBarRect.Y + progress * (m_scrollBarRect.Height - thumbHeight); const gd::RectF thumb(m_scrollBarRect.X, thumbY, m_scrollBarRect.Width, thumbHeight); UiFillRoundRect(g, thumb, kScrollBarThickness * m_scale * 0.5f, m_theme->borderStrong); } } // namespace jdi