diff --git a/Components/Application/Src/Application.cpp b/Components/Application/Src/Application.cpp index 646ea94..2b59902 100644 --- a/Components/Application/Src/Application.cpp +++ b/Components/Application/Src/Application.cpp @@ -1442,10 +1442,14 @@ namespace jdi items.push_back(fitLaneTime); // 「拆分当前网格」:把该网格内的信号按个拆成独立网格,插到其下方依次排列。 + // 可点击时在后方追加「(N个信号)」说明将拆出的网格数;只有 1 条信号时置灰且不加尾缀。 MenuItem splitLane; - splitLane.label = L"拆分当前网格"; + const std::size_t laneSignalCount = m_plot.LaneSignalCount(laneIndex); + splitLane.label = (laneSignalCount > 1) + ? (L"拆分当前网格(" + std::to_wstring(laneSignalCount) + L"个信号)") + : L"拆分当前网格"; splitLane.id = PLOT_CMD_LANE_SPLIT; - splitLane.enabled = m_plot.LaneSignalCount(laneIndex) > 1; + splitLane.enabled = laneSignalCount > 1; items.push_back(splitLane); // 实验性功能(二级菜单):横线上方,鼠标悬停即向右展开。 diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index f032cd8..b279abf 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -867,7 +867,9 @@ namespace jdi * @param [OUT] breaks 输出每段折线的起始下标(用于处理缺失值断线)。 */ void BuildPolyline(const ResolvedSignal& resolved, const LaneVisual& lane, - std::vector& points, std::vector& breaks) const; + std::vector& points, std::vector& breaks, + std::vector& band, std::vector& bandBreaks, + bool& bandMode) const; /** * @brief 绘制单个网格。 @@ -982,6 +984,42 @@ namespace jdi void PaintCenterCurve(gd::Graphics& g, const ResolvedSignal& resolved, const LaneVisual& lane, int smooth) const; + /** + * @brief 数据点标记的位置与"越界方向"(用于决定画圆点还是贴边三角)。 + */ + struct DataMarker + { + float x = 0.0f; /**< 标记锚点横坐标(已夹在可视区内)。 */ + float y = 0.0f; /**< 标记锚点纵坐标(已夹在可视区内)。 */ + bool offLeft = false; /**< 数据点位于左侧视界之外。 */ + bool offRight = false; /**< 数据点位于右侧视界之外。 */ + bool offUp = false; /**< 数据点位于上方视界之外。 */ + bool offDown = false; /**< 数据点位于下方视界之外。 */ + }; + + /** + * @brief 计算数据点标记:把越界的数据点"钉"在网格可见边缘,并记录越界方向。 + * @param [IN] rawX 数据点的原始横坐标。 + * @param [IN] rawY 数据点的原始纵坐标。 + * @param [IN] pinX 横向贴边位置(光标在视界外时的钉住横坐标)。 + * @param [IN] clipTop 网格可见区上边界。 + * @param [IN] clipBottom 网格可见区下边界。 + * @return 标记几何。 + */ + DataMarker MakeDataMarker(float rawX, float rawY, float pinX, float clipTop, + float clipBottom) const; + + /** + * @brief 绘制数据点标记:视界内为圆点,越界时在对应边缘绘制指示方向的三角。 + * @param [IN,OUT] g 绘图上下文。 + * @param [IN] marker 标记几何。 + * @param [IN] radius 圆点半径。 + * @param [IN] fillColor 前景色(圆点/三角内部)。 + * @param [IN] ringColor 描边色(圆环/三角外框)。 + */ + void PaintDataMarker(gd::Graphics& g, const DataMarker& marker, float radius, + std::uint32_t fillColor, std::uint32_t ringColor) const; + /** * @brief 放置一个光标取值胶囊:先夹到网格可见范围内,再向下(必要时向上)避让 * 已占用的标注矩形,最后登记到 `m_cursorLabelRects`。 @@ -1254,6 +1292,21 @@ namespace jdi double laneYMax = 0.0; /**< 网格幅值上界。 */ std::vector points; /**< 折线顶点。 */ std::vector breaks; /**< 每段折线在 points 中的起始下标。 */ + + /** + * @brief 是否处于「稠密包络」模式(采样点密度超过像素分辨率)。 + * + * 此时 `points` 是每列 min/max 交替的锯齿,逐段描边的光栅化开销极大 + * (实测每网格约 14ms);改用下面的「包络带」一次填充后约 1.7ms, + * 且保留抗锯齿,静态与拖拽帧的观感完全一致。 + */ + bool bandMode = false; + + /** 包络带顶点:每段为「上包络正向 + 下包络反向」,闭合后即为该段的填充多边形。 */ + std::vector band; + + /** 每段包络带在 `band` 中的起始下标。 */ + std::vector bandBreaks; }; /** diff --git a/Components/PlotView/Src/PlotView.cpp b/Components/PlotView/Src/PlotView.cpp index 95a205d..0a08433 100644 --- a/Components/PlotView/Src/PlotView.cpp +++ b/Components/PlotView/Src/PlotView.cpp @@ -295,7 +295,6 @@ namespace jdi return WithAlpha(style.color, static_cast(opacity * 255.0 + 0.5)); } } - PlotView::PlotView() = default; PlotView::~PlotView() @@ -984,8 +983,7 @@ namespace jdi } std::size_t PlotView::LaneSignalCount(int laneIndex) const - { - if (m_tab == nullptr || laneIndex < 0 || + { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return 0; @@ -3596,16 +3594,22 @@ namespace jdi entry.laneYMin = lane.yMin; entry.laneYMax = lane.yMax; - BuildPolyline(resolved, lane, entry.points, entry.breaks); + BuildPolyline(resolved, lane, entry.points, entry.breaks, entry.band, entry.bandBreaks, + entry.bandMode); // BuildPolyline 通过引用填充,push_back 过程中不会再次进入本函数,引用安全。 return &m_polylineCache.back(); } void PlotView::BuildPolyline(const ResolvedSignal& resolved, const LaneVisual& lane, - std::vector& points, std::vector& breaks) const + std::vector& points, std::vector& breaks, + std::vector& band, std::vector& bandBreaks, + bool& bandMode) const { points.clear(); breaks.clear(); + band.clear(); + bandBreaks.clear(); + bandMode = false; if (resolved.data == nullptr || resolved.file == nullptr) { @@ -3683,6 +3687,38 @@ namespace jdi (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 segmentTop; + std::vector segmentBottom; + const auto flushBandSegment = [&band, &bandBreaks, &segmentTop, &segmentBottom]() + { + if (segmentTop.size() < 2) + { + segmentTop.clear(); + segmentBottom.clear(); + return; + } + bandBreaks.push_back(band.size()); + for (const gd::PointF& point : segmentTop) + { + band.push_back(point); + } + // 下包络反向,形成闭合环。 + for (std::size_t index = segmentBottom.size(); index-- > 0;) + { + band.push_back(segmentBottom[index]); + } + segmentTop.clear(); + segmentBottom.clear(); + }; + std::size_t searchFrom = begin; for (std::size_t column = 0; column < columns; ++column) { @@ -3706,6 +3742,7 @@ namespace jdi { // 该列没有采样点:说明数据在此处存在时间间隙,断开当前折线。 inSegment = false; + flushBandSegment(); continue; } @@ -3771,6 +3808,7 @@ namespace jdi { // 本列全是无效值。 inSegment = false; + flushBandSegment(); continue; } @@ -3780,6 +3818,10 @@ namespace jdi const float yBottom = static_cast(ClampDouble(ValueToY(low, lane), limitTop, limitBottom)); + // 包络带每列都记录(上下各外扩半个线宽,保证细波动也有可见粗细)。 + segmentTop.push_back(gd::PointF(x, yTop - strokeHalf)); + segmentBottom.push_back(gd::PointF(x, yBottom + strokeHalf)); + if (yBottom - yTop < kEnvelopeMinPixelSpan) { // 波动不足半个像素:只留一个顶点,避免无意义的重复顶点。 @@ -3813,9 +3855,11 @@ namespace jdi if (broken) { inSegment = false; + flushBandSegment(); } } + flushBandSegment(); return; } @@ -4431,6 +4475,7 @@ namespace jdi const int saveState = g.Save(); g.SetClip(gd::RectF(m_area.X, clipTop, m_area.Width, clipBottom - clipTop)); + // 幅值网格线(先画线,标尺稍后作为覆盖层绘制,避免被波形遮挡)。 // 刻度密度随网格高度自适应:所有网格高度相同 → 各网格的刻度数量一致, // 不会出现“有的网格只有 1 条标签、有的有 3 条”的不一致观感。 @@ -4561,21 +4606,55 @@ namespace jdi if (drawLine && !breaks.empty()) { - gd::Pen pen(ToGdiColor(StyleColor(*resolved.style)), - 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::uint32_t lineColor = StyleColor(*resolved.style); + if (cached->bandMode && !cached->bandBreaks.empty()) { - 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) + // 稠密包络:一次 FillPath 填充包络带。 + // **必须显式设为缠绕填充规则**:包络带自交(相邻列上下包络交叉)时, + // 交替(奇偶)规则会在交叠处挖出空洞。 + gd::GraphicsPath bandPath; + bandPath.SetFillMode(gd::FillModeWinding); + for (std::size_t k = 0; k < cached->bandBreaks.size(); ++k) { - continue; + 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); + } + } + 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)); } - g.DrawLines(&pen, points.data() + start, static_cast(stop - start)); } } @@ -5106,9 +5185,9 @@ namespace jdi const bool showDelta = doubleMode && (which == rightCursor); // 光标在可视区外时,把标注"钉"在对应的网格边上,保证取值仍然可见。 + // 具体是否越界(左右/上下)由 MakeDataMarker 逐点判定。 const float pinX = CursorDisplayX(which); const float rawX = TimeToX(time); - const bool pinned = (std::fabs(rawX - pinX) > 0.5f); for (std::size_t laneIndex = 0; laneIndex < lanes.size(); ++laneIndex) { @@ -5137,40 +5216,15 @@ namespace jdi continue; } - const float y = ValueToY(value, lane); - if (y < clipTop - 2.0f || y > clipBottom + 2.0f) - { - continue; - } + const float rawY = ValueToY(value, lane); + // 上下越界的数据点不再被丢弃:与左右越界一致,贴边绘制指示方向的三角。 + const DataMarker marker = MakeDataMarker(rawX, rawY, pinX, clipTop, clipBottom); const std::uint32_t signalColor = StyleColor(*resolved.style); - // 数据点:底色环 + 信号色实心点(贴边时改画成三角指示方向)。 + // 数据点:视界内为圆点,任一方向越界时改为贴边三角。 const float radius = 3.4f * m_scale; - gd::SolidBrush ring(ToGdiColor(m_theme->cardBg)); - gd::SolidBrush fill(ToGdiColor(signalColor)); - if (pinned) - { - const float arm = 5.0f * m_scale; - const bool offLeft = (rawX < pinX); - const float baseX = offLeft ? (pinX - arm) : (pinX + arm); - gd::PointF tri[3]; - tri[0] = gd::PointF(baseX, y - arm); - tri[1] = gd::PointF(baseX, y + arm); - tri[2] = gd::PointF(pinX, y); - g.FillPolygon(&ring, tri, 3); - gd::PointF inner[3]; - inner[0] = gd::PointF(baseX + (offLeft ? 1.4f : -1.4f) * m_scale, y - arm * 0.72f); - inner[1] = gd::PointF(baseX + (offLeft ? 1.4f : -1.4f) * m_scale, y + arm * 0.72f); - inner[2] = gd::PointF(pinX - (offLeft ? -1.4f : 1.4f) * m_scale, y); - g.FillPolygon(&fill, inner, 3); - } - else - { - g.FillEllipse(&ring, pinX - radius - 1.2f * m_scale, y - radius - 1.2f * m_scale, - (radius + 1.2f * m_scale) * 2.0f, (radius + 1.2f * m_scale) * 2.0f); - g.FillEllipse(&fill, pinX - radius, y - radius, radius * 2.0f, radius * 2.0f); - } + PaintDataMarker(g, marker, radius, signalColor, m_theme->cardBg); // 取值标注:底色用信号色、文字用反色,并加一层细描边提升对比度。 std::wstring text = FormatValue(value); @@ -5198,7 +5252,7 @@ namespace jdi 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 = y - textHeight * 0.5f; + 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); @@ -5221,40 +5275,14 @@ namespace jdi double centerValue = 0.0; if (CenterValueAt(*resolved.file, *resolved.data, smooth, time, centerValue)) { - const float centerY = ValueToY(centerValue, lane); - if (centerY >= clipTop - 2.0f && centerY <= clipBottom + 2.0f) + const float rawCenterY = ValueToY(centerValue, lane); + const DataMarker centerMarker = + MakeDataMarker(rawX, rawCenterY, pinX, clipTop, clipBottom); { - // 中心轴数据点与常规数据点一样:在可视区内是圆点, - // **光标在可视区之外(贴边显示)时同样改为三角**指示方向。 - const std::uint32_t centerColor = CursorColor(1); - gd::SolidBrush centerFill(ToGdiColor(centerColor)); - if (pinned) - { - const float arm = 5.0f * m_scale; - const bool offLeft = (rawX < pinX); - const float baseX = offLeft ? (pinX - arm) : (pinX + arm); - gd::PointF tri[3]; - tri[0] = gd::PointF(baseX, centerY - arm); - tri[1] = gd::PointF(baseX, centerY + arm); - tri[2] = gd::PointF(pinX, centerY); - g.FillPolygon(&ring, tri, 3); - gd::PointF inner[3]; - inner[0] = gd::PointF(baseX + (offLeft ? 1.4f : -1.4f) * m_scale, - centerY - arm * 0.72f); - inner[1] = gd::PointF(baseX + (offLeft ? 1.4f : -1.4f) * m_scale, - centerY + arm * 0.72f); - inner[2] = gd::PointF(pinX - (offLeft ? -1.4f : 1.4f) * m_scale, centerY); - g.FillPolygon(¢erFill, inner, 3); - } - else - { - g.FillEllipse(&ring, pinX - radius - 1.2f * m_scale, - centerY - radius - 1.2f * m_scale, - (radius + 1.2f * m_scale) * 2.0f, - (radius + 1.2f * m_scale) * 2.0f); - g.FillEllipse(¢erFill, pinX - radius, centerY - radius, - radius * 2.0f, radius * 2.0f); - } + // 中心轴数据点的配色与原始信号一致(便于一一对应); + // 但**始终按线段模式**绘制(不跟随原信号的散点模式)。 + const std::uint32_t centerColor = StyleColor(*resolved.style); + PaintDataMarker(g, centerMarker, radius, centerColor, m_theme->cardBg); // 取值文本:数值在前,尾缀「(中心轴)」;双光标的右侧光标还要 // 附上增量,形如「(+0.0008 中心轴)」(参考常规信号的增量显示)。 @@ -5286,13 +5314,13 @@ namespace jdi m_plotRect.X + 2.0f * m_scale, std::min(centerLeft, m_plotRect.X + m_plotRect.Width - centerTextWidth - 2.0f * m_scale)); - float centerTop = centerY - textHeight * 0.5f; + 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); - // 颜色样式与光标一致(同底色/同描边/同文字配色)。 + // 颜色样式与原始信号一致(同底色/同描边/同文字配色)。 const bool centerDark = IsDarkColor(centerColor); UiFillRoundRect(g, centerRect, 3.0f * m_scale, WithAlpha(centerColor, 0xE0)); @@ -5485,6 +5513,73 @@ namespace jdi return false; } + PlotView::DataMarker PlotView::MakeDataMarker(float rawX, float rawY, float pinX, float clipTop, + float clipBottom) const + { + DataMarker marker; + // 上下越界时同样"钉"在网格可见边缘(留出三角形的高度,保证整体可见)。 + const float margin = 7.0f * m_scale; + marker.x = pinX; + marker.y = std::max(clipTop + margin, std::min(rawY, clipBottom - margin)); + marker.offLeft = (rawX < marker.x - 0.5f); + marker.offRight = (rawX > marker.x + 0.5f); + marker.offUp = (rawY < marker.y - 0.5f); + marker.offDown = (rawY > marker.y + 0.5f); + 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 = 5.0f * m_scale; + const float inset = 1.4f * m_scale; + + // 左右越界:三角底边在外侧、尖端朝向网格内部。 + if (marker.offLeft || marker.offRight) + { + const bool offLeft = marker.offLeft; + const float baseX = offLeft ? (marker.x - arm) : (marker.x + arm); + const float inward = offLeft ? inset : -inset; + gd::PointF outer[3]; + outer[0] = gd::PointF(baseX, marker.y - arm); + outer[1] = gd::PointF(baseX, marker.y + arm); + outer[2] = gd::PointF(marker.x, marker.y); + g.FillPolygon(&ring, outer, 3); + gd::PointF inner[3]; + inner[0] = gd::PointF(baseX + inward, marker.y - arm * 0.72f); + inner[1] = gd::PointF(baseX + inward, marker.y + arm * 0.72f); + inner[2] = gd::PointF(marker.x + inward, marker.y); + g.FillPolygon(&fill, inner, 3); + return; + } + + // 上下越界:三角底边在上/下方、尖端朝向网格内部(与左右越界同一套观感)。 + if (marker.offUp || marker.offDown) + { + const bool offUp = marker.offUp; + const float baseY = offUp ? (marker.y - arm) : (marker.y + arm); + const float inward = offUp ? inset : -inset; + 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, marker.y); + g.FillPolygon(&ring, outer, 3); + gd::PointF inner[3]; + inner[0] = gd::PointF(marker.x - arm * 0.72f, baseY + inward); + inner[1] = gd::PointF(marker.x + arm * 0.72f, baseY + inward); + inner[2] = gd::PointF(marker.x, marker.y + inward); + g.FillPolygon(&fill, inner, 3); + 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) { @@ -5627,10 +5722,14 @@ namespace jdi return; } - // 中心平均值曲线使用与光标(「边沿计数」横向光标、中心轴取值胶囊)一致的颜色样式: - // 即光标的强调色。曲线与其取值标注、光标本身因此形成统一的视觉语言。 - const std::uint32_t derived = CursorColor(1); - gd::Pen pen(ToGdiColor(derived), std::max(1.8f, resolved.style->lineWidth + 0.8f) * m_scale); + // 中心平均值曲线:与原始信号**同色**,只是更透明一些;线宽等其它样式同样跟随原信号, + // 因此原信号改颜色/线宽/透明度时曲线会自动同步。 + 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(StyleColor(*resolved.style), + static_cast(static_cast(baseAlpha) * 0.55 + 0.5)); + 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); @@ -5933,20 +6032,52 @@ namespace jdi if (drawLine && !breaks.empty()) { - 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) + if (ghostSource->bandMode && !ghostSource->bandBreaks.empty()) { - 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) + // 稠密包络虚影同样用一次 FillPath(见 PaintLane 的说明)。 + gd::GraphicsPath bandPath; + bandPath.SetFillMode(gd::FillModeWinding); + for (std::size_t k = 0; k < ghostSource->bandBreaks.size(); ++k) { - continue; + 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)); } - g.DrawLines(&ghost, points.data() + start, static_cast(stop - start)); } } diff --git a/Components/SignalPanel/Inc/SignalPanel.hpp b/Components/SignalPanel/Inc/SignalPanel.hpp index 00fa658..45cb5c5 100644 --- a/Components/SignalPanel/Inc/SignalPanel.hpp +++ b/Components/SignalPanel/Inc/SignalPanel.hpp @@ -166,7 +166,38 @@ namespace jdi * @brief 判断面板是否处于内联文本输入状态。 * @return 正在输入返回 true。 */ - bool IsEditing() const { return m_editField != StyleEditField::NONE; } + bool IsEditing() const { return m_editField != StyleEditField::NONE || m_filterEditing; } + + /** + * @brief 判断坐标是否落在列表上方的「筛选信号」输入框内。 + * @param [IN] x 客户区横坐标。 + * @param [IN] y 客户区纵坐标。 + * @return 落在输入框内返回 true。 + */ + bool IsOnFilterBox(int x, int y) const; + + /** + * @brief 进入筛选输入框的编辑状态(单击输入框时调用)。 + */ + void BeginFilterEdit(); + + /** + * @brief 结束筛选输入框的编辑状态(点击别处或回车时调用)。 + * @return 状态发生变化返回 true。 + */ + bool EndFilterEdit(); + + /** + * @brief 筛选框是否处于编辑状态。 + * @return 编辑中返回 true。 + */ + bool IsFilterEditing() const { return m_filterEditing; } + + /** + * @brief 获取当前筛选文本。 + * @return 筛选文本(未输入时为空串)。 + */ + const std::wstring& FilterText() const { return m_filterText; } /** * @brief 向当前输入框送入一个字符。 @@ -493,6 +524,12 @@ namespace jdi */ gd::RectF EditFieldRect() const; + /** + * @brief 计算「筛选信号」输入框的内容矩形(编辑与显示共用,保证不跳动)。 + * @return 内容矩形。 + */ + gd::RectF FilterFieldRect() const; + /** * @brief 计算指定行中名称区域的矩形(列表行内联编辑使用)。 * @param [IN] row 行下标。 @@ -534,6 +571,7 @@ namespace jdi gd::RectF m_area; /**< 面板整体区域。 */ gd::RectF m_headerRect; /**< 标题区域。 */ + gd::RectF m_filterRect; /**< 列表上方的「筛选信号」输入框区域。 */ gd::RectF m_listRect; /**< 列表区域。 */ gd::RectF m_styleRect; /**< 样式编辑区域。 */ gd::RectF m_toggleAllRect; /**< 全部显示/隐藏按钮。 */ @@ -573,6 +611,23 @@ namespace jdi int m_editRow = -1; /**< 行内输入所作用的列表行下标。 */ TextEdit m_edit; /**< 输入框编辑模型(光标/选区/剪贴板)。 */ + bool m_filterEditing = false; /**< 「筛选信号」输入框是否处于编辑状态。 */ + std::wstring m_filterText; /**< 当前筛选文本(空串表示不过滤)。 */ + TextEdit m_filterEdit; /**< 筛选输入框的编辑模型。 */ + + /** + * @brief 设置筛选文本并立即生效(重建行集合、回到顶部)。 + * @param [IN] text 新的筛选文本(空串表示不过滤)。 + */ + void ApplyFilterText(const std::wstring& text); + + /** + * @brief 判断某信号的显示名是否匹配当前筛选表达式。 + * @param [IN] signal 信号数据。支持 `*` 通配符,忽略大小写。 + * @return 匹配(或未设置筛选)返回 true。 + */ + bool FilterMatchesSignal(const SignalData& signal) const; + bool m_pendingLaneMenu = false; /**< 左键抬起时是否弹出网格归属菜单。 */ SignalRef m_pendingLaneRef; /**< 待弹出菜单的目标信号。 */ bool m_pendingColorPicker = false; /**< 左键抬起时是否弹出拾色器。 */ diff --git a/Components/SignalPanel/Src/SignalPanel.cpp b/Components/SignalPanel/Src/SignalPanel.cpp index 410acc6..950b358 100644 --- a/Components/SignalPanel/Src/SignalPanel.cpp +++ b/Components/SignalPanel/Src/SignalPanel.cpp @@ -26,6 +26,65 @@ namespace jdi const float kCloseButtonSize = 18.0f; /**< 文件行关闭按钮尺寸。 */ const float kGroupSeparatorGap = 7.0f; /**< 分组标签与分隔线/名称的间距。 */ const float kGroupSeparatorWidth = 1.0f; /**< 分组标签右侧分隔线宽度。 */ + const float kFilterHeight = 26.0f; /**< 「筛选信号」输入框高度。 */ + const float kFilterPadV = 5.0f; /**< 筛选框上下留白。 */ + + /** + * @brief 大小写无关的通配符匹配(`*` 匹配任意长度的任意字符序列)。 + * @param [IN] text 待匹配文本。 + * @param [IN] pattern 模式(可含多个 `*`)。 + * @return 匹配返回 true。 + */ + bool WildcardMatchNoCase(const std::wstring& text, const std::wstring& pattern) + { + const auto fold = [](wchar_t character) -> wchar_t + { + // 仅对基本拉丁字母做大小写折叠(中文等其它字符原样比较)。 + if (character >= L'A' && character <= L'Z') + { + return static_cast(character - L'A' + L'a'); + } + return character; + }; + + // 经典双指针回溯:`star` 记录最近一个 `*` 的位置,`resume` 记录当时尝试的文本位置。 + std::size_t t = 0; + std::size_t p = 0; + std::size_t star = std::wstring::npos; + std::size_t resume = 0; + + while (t < text.size()) + { + if (p < pattern.size() && pattern[p] == L'*') + { + star = p; + resume = t; + ++p; + } + else if (p < pattern.size() && fold(pattern[p]) == fold(text[t])) + { + ++p; + ++t; + } + else if (star != std::wstring::npos) + { + p = star + 1; + ++resume; + t = resume; + } + else + { + return false; + } + } + + // 文本已耗尽:模式剩余部分必须全是 `*`。 + while (p < pattern.size() && pattern[p] == L'*') + { + ++p; + } + return p == pattern.size(); + } // 样式编辑区:固定四行网格(颜色 / 样式 / 透明度 / 线宽),行高、间距与内容列宽度统一, // 保证面板宽度变化时四行控件始终对齐,且不会被无限制拉伸。 @@ -154,9 +213,69 @@ namespace jdi return dirty; } - void SignalPanel::BuildRows() + bool SignalPanel::IsOnFilterBox(int x, int y) const { - m_rows.clear(); + return UiPointInRect(m_filterRect, static_cast(x), static_cast(y)); + } + + void SignalPanel::BeginFilterEdit() + { + if (m_filterEditing) + { + return; + } + m_filterEditing = true; + // 单击进入编辑:光标落在末尾,不做全选(用户可以继续追加筛选词)。 + m_filterEdit.Begin(m_filterText, false, EditFilter::ANY, 0u); + } + + bool SignalPanel::EndFilterEdit() + { + if (!m_filterEditing) + { + return false; + } + // **必须**在 `End()` 之前取出文本:`TextEdit::End()` 会清空内部缓冲区。 + const std::wstring finalText = Trim(m_filterEdit.Text()); + m_filterEditing = false; + m_filterEdit.End(); + // 文本在编辑过程中已实时生效,这里只需把最终值再同步一次并收起插入符。 + m_filterText = finalText; + return true; + } + + void SignalPanel::ApplyFilterText(const std::wstring& text) + { + if (m_filterText == text) + { + return; + } + m_filterText = text; + // 筛选结果变化 → 重建行集合、回到列表顶部并重新计算滚动条。 + m_rowsDirty = true; + m_scrollOffset = 0.0f; + m_layoutDirty = true; + } + + bool SignalPanel::FilterMatchesSignal(const SignalData& signal) const + { + if (m_filterText.empty()) + { + return true; + } + // 以 `*` 为通配符、忽略大小写,匹配"信号名中是否出现过符合该模式的片段"。 + // 两侧补 `*` 使模式等价于子串匹配,因此下面这些写法都能按直觉工作: + // `编码器` → 名称包含"编码器" + // `编码器*相` → 名称中出现以"编码器"开头、"相"结尾的片段 + // `*x10` / `ABS*` → 尾部/首部片段匹配(无需用户自己补另一侧的 `*`) + std::wstring pattern = L"*"; + pattern += m_filterText; + pattern += L'*'; + return WildcardMatchNoCase(signal.Title(), pattern); + } + + void SignalPanel::BuildRows() + { m_rows.clear(); m_rowsDirty = false; if (m_dataSet == nullptr || m_tab == nullptr) @@ -167,21 +286,55 @@ namespace jdi const float fileHeight = kFileRowHeight * m_scale; const float signalHeight = kSignalRowHeight * m_scale; + const bool filtering = !m_filterText.empty(); const std::vector& files = m_dataSet->Files(); for (std::size_t fileIndex = 0; fileIndex < files.size(); ++fileIndex) { + // 筛选时先挑出本组内匹配的信号;整组都没有匹配则**不显示该组**。 + std::vector matched; + if (filtering) + { + const std::size_t signalCount = files[fileIndex].signals.size(); + for (std::size_t signalIndex = 0; signalIndex < signalCount; ++signalIndex) + { + if (FilterMatchesSignal(files[fileIndex].signals[signalIndex])) + { + matched.push_back(static_cast(signalIndex)); + } + } + if (matched.empty()) + { + continue; + } + } + ListRow fileRow; fileRow.kind = RowKind::FILE; fileRow.fileIndex = static_cast(fileIndex); fileRow.height = fileHeight; m_rows.push_back(fileRow); - if (m_tab->IsCollapsed(static_cast(fileIndex))) + // 筛选结果**默认展开**(忽略折叠状态),便于直接看到匹配项。 + if (!filtering && m_tab->IsCollapsed(static_cast(fileIndex))) { continue; } + if (filtering) + { + for (const int signalIndex : matched) + { + ListRow signalRow; + signalRow.kind = RowKind::SIGNAL; + signalRow.fileIndex = static_cast(fileIndex); + signalRow.signalIndex = signalIndex; + signalRow.height = signalHeight; + m_rows.push_back(signalRow); + } + continue; + } + const std::size_t signalCount = files[fileIndex].signals.size(); for (std::size_t signalIndex = 0; signalIndex < signalCount; ++signalIndex) { @@ -498,10 +651,17 @@ namespace jdi m_headerRect = gd::RectF(area.X, area.Y, area.Width, kHeaderHeight * m_scale); + // 标题下方的「筛选信号」输入框(列表内容过滤)。 + const float filterHeight = kFilterHeight * m_scale; + m_filterRect = gd::RectF(area.X + kPadding * m_scale, + m_headerRect.Y + m_headerRect.Height + kFilterPadV * m_scale, + std::max(20.0f, area.Width - kPadding * 2.0f * m_scale), filterHeight); + const float styleHeight = DesiredStyleHeight() * m_scale; m_styleRect = gd::RectF(area.X, area.Y + area.Height - styleHeight, area.Width, styleHeight); - m_listRect = gd::RectF(area.X, m_headerRect.Y + m_headerRect.Height, area.Width, - std::max(0.0f, m_styleRect.Y - m_headerRect.Y - m_headerRect.Height)); + const float listTop = m_filterRect.Y + m_filterRect.Height + kFilterPadV * m_scale; + m_listRect = gd::RectF(area.X, listTop, area.Width, + std::max(0.0f, m_styleRect.Y - listTop)); // 标题区右侧的“全部显示/隐藏”按钮。 const float toggleWidth = 68.0f * m_scale; @@ -557,6 +717,34 @@ namespace jdi enabled ? m_theme->text : m_theme->textMuted, TextAlign::CENTER); } + // 「筛选信号」输入框:未输入内容时显示占位提示,单击进入编辑,输入实时过滤下方列表。 + { + UiFillRoundRect(g, m_filterRect, kCorner * m_scale, m_theme->cardBg); + UiDrawRoundRect(g, m_filterRect, kCorner * m_scale, + m_filterEditing ? m_theme->accent : m_theme->border, + m_filterEditing ? 1.6f : 1.0f); + if (m_filterEditing) + { + UiDrawEditField(g, FilterFieldRect(), m_filterEdit, m_fonts->uiSmall, m_scale); + } + else if (m_filterText.empty()) + { + UiText(g, L"筛选信号", m_fonts->uiSmall, + gd::RectF(m_filterRect.X + 8.0f * m_scale, m_filterRect.Y, + m_filterRect.Width - 16.0f * m_scale, m_filterRect.Height), + m_theme->textMuted, TextAlign::LEFT); + } + else + { + UiText(g, UiEllipsize(g, m_filterText, m_fonts->uiSmall, + m_filterRect.Width - 16.0f * m_scale), + m_fonts->uiSmall, + gd::RectF(m_filterRect.X + 8.0f * m_scale, m_filterRect.Y, + m_filterRect.Width - 16.0f * m_scale, m_filterRect.Height), + m_theme->text, TextAlign::LEFT); + } + } + // 列表区。 { const int saveState = g.Save(); @@ -1203,6 +1391,15 @@ namespace jdi return true; } + gd::RectF SignalPanel::FilterFieldRect() const + { + // 输入框内容区:与占位提示/显示文本使用同一内缩,保证编辑时不跳动。 + const float inset = 7.0f * m_scale; + return gd::RectF(m_filterRect.X + inset, m_filterRect.Y + 2.0f * m_scale, + std::max(8.0f, m_filterRect.Width - inset * 2.0f), + std::max(8.0f, m_filterRect.Height - 4.0f * m_scale)); + } + gd::RectF SignalPanel::EditFieldRect() const { switch (m_editField) @@ -1222,6 +1419,45 @@ namespace jdi bool SignalPanel::OnEditChar(wchar_t character) { + // 「筛选信号」输入框:字符实时进入筛选(无需回车即可看到结果变化)。 + if (m_filterEditing) + { + switch (character) + { + case 0x03: // Ctrl+C + m_filterEdit.CopyToClipboard(m_host); + return false; + case 0x18: // Ctrl+X + m_filterEdit.CutToClipboard(m_host); + ApplyFilterText(m_filterEdit.Text()); + return true; + case 0x16: // Ctrl+V + m_filterEdit.PasteFromClipboard(m_host); + ApplyFilterText(m_filterEdit.Text()); + return true; + case 0x01: // Ctrl+A + m_filterEdit.SelectAll(); + return true; + default: + break; + } + if (character == L'\r' || character == L'\n') + { + return EndFilterEdit(); + } + // 退格/删除已由 WM_KEYDOWN 的 OnEditKey 处理,运里忽略控制字符避免重复删除。 + if (character < 0x20 || character == 0x7F) + { + return false; + } + if (!m_filterEdit.Insert(character)) + { + return false; + } + ApplyFilterText(m_filterEdit.Text()); + return true; + } + if (m_editField == StyleEditField::NONE) { return false; @@ -1264,6 +1500,60 @@ namespace jdi bool SignalPanel::OnEditKey(WPARAM key) { + // 「筛选信号」输入框的键盘处理(回车结束编辑,Esc 清空并结束)。 + if (m_filterEditing) + { + const bool shift = (GetKeyState(VK_SHIFT) & 0x8000) != 0; + const bool control = (GetKeyState(VK_CONTROL) & 0x8000) != 0; + switch (key) + { + case VK_RETURN: + return EndFilterEdit(); + case VK_ESCAPE: + { + const bool changed = m_filterEdit.Text() != m_filterText; + EndFilterEdit(); + return changed; + } + case VK_BACK: + { + if (!m_filterEdit.Backspace()) + { + return false; + } + ApplyFilterText(m_filterEdit.Text()); + return true; + } + case VK_DELETE: + { + if (!m_filterEdit.DeleteForward()) + { + return false; + } + ApplyFilterText(m_filterEdit.Text()); + return true; + } + case VK_LEFT: + return m_filterEdit.MoveCaret(-1, shift); + case VK_RIGHT: + return m_filterEdit.MoveCaret(1, shift); + case VK_HOME: + return m_filterEdit.MoveHome(shift); + case VK_END: + return m_filterEdit.MoveEnd(shift); + case 'A': + if (control) + { + m_filterEdit.SelectAll(); + return true; + } + break; + default: + break; + } + return true; + } + if (m_editField == StyleEditField::NONE) { return false; @@ -1305,6 +1595,10 @@ namespace jdi bool SignalPanel::CollapseEditSelectionToEnd() { + if (m_filterEditing) + { + return m_filterEdit.DeselectToEnd(); + } if (m_editField == StyleEditField::NONE) { return false; @@ -1314,6 +1608,10 @@ namespace jdi bool SignalPanel::OnEditMouseDown(int x, int y) { + if (m_filterEditing) + { + return UiEditFieldMouseDown(m_fonts->uiSmall, FilterFieldRect(), m_filterEdit, m_scale, x, y); + } if (m_editField == StyleEditField::NONE) { return false; @@ -1323,6 +1621,10 @@ namespace jdi bool SignalPanel::OnEditMouseMove(int x) { + if (m_filterEditing) + { + return UiEditFieldMouseMove(m_fonts->uiSmall, FilterFieldRect(), m_filterEdit, m_scale, x); + } if (m_editField == StyleEditField::NONE) { return false; @@ -1332,6 +1634,15 @@ namespace jdi bool SignalPanel::OnEditMouseUp() { + if (m_filterEditing) + { + if (!m_filterEdit.Dragging()) + { + return false; + } + m_filterEdit.EndDrag(); + return true; + } if (!m_edit.Dragging()) { return false; @@ -1424,6 +1735,11 @@ namespace jdi { changed = EndEdit(true) || changed; } + // 点击面板之外也要结束「筛选信号」的编辑状态。 + if (m_filterEditing) + { + changed = EndFilterEdit() || changed; + } if (m_colorPicker != nullptr && m_colorPicker->IsOpen()) { m_colorPicker->Close(); @@ -1540,6 +1856,18 @@ namespace jdi const float fx = static_cast(x); const float fy = static_cast(y); + // 「筛选信号」输入框:单击进入编辑;点击其它位置则先结束筛选编辑(文本已实时生效)。 + if (IsOnFilterBox(x, y)) + { + BeginFilterEdit(); + return true; + } + if (m_filterEditing) + { + // 文本已实时生效,这里只需收起编辑态,随后继续处理本次点击。 + EndFilterEdit(); + } + // 滚动条拖拽优先于其它命中测试。 if (m_scrollBarVisible && UiPointInRect(m_scrollBarRect, fx, fy)) { diff --git a/Utilities/Src/PaintUtil.cpp b/Utilities/Src/PaintUtil.cpp index f1f782d..36d6d90 100644 --- a/Utilities/Src/PaintUtil.cpp +++ b/Utilities/Src/PaintUtil.cpp @@ -16,6 +16,14 @@ namespace jdi */ const float kSwatchOutlineOpacity = 0.45f; + /** + * @brief 矩形面积超过该阈值(逻辑像素²)且为不透明色时,改用 GDI 填充。 + * + * GDI+ 的 FillRectangle 约 2.2ns/像素;面积过小时 GDI 的 GetHDC/ReleaseHDC + * 固定开销反而不划算,因此在阈值以下仍走 GDI+。 + */ + const float kGdiFillAreaThreshold = 20000.0f; + /** * @brief 构造圆角矩形路径。 * @param [IN,OUT] path 目标路径对象。 @@ -130,6 +138,34 @@ namespace jdi { return; } + + // 大面积且**不透明**的填充走 GDI 路线:GDI+ 的 FillRectangle 实测约 2.2ns/像素 + // (整个窗口背景 1.7M 像素约 0.9ms,信号面板背景约 1.9ms), + // 而 GDI 的 FillRect 快一个数量级。半透明填充必须留在 GDI+(GDI 不做 alpha 混色)。 + // 顺序安全:GetHDC() 会先把已排队的 GDI+ 内容刷入 DC,本次填充之后再发生的 GDI+ 绘制 + // 会在 ReleaseHDC() 之后刷出、正确地覆盖在上面。 + const float area = rect.Width * rect.Height; + if ((argb & 0xFF000000u) == 0xFF000000u && area >= kGdiFillAreaThreshold) + { + HDC hdc = g.GetHDC(); + if (hdc != nullptr) + { + RECT rc; + rc.left = static_cast(std::floor(rect.X)); + rc.top = static_cast(std::floor(rect.Y)); + rc.right = static_cast(std::ceil(rect.X + rect.Width)); + rc.bottom = static_cast(std::ceil(rect.Y + rect.Height)); + HBRUSH brush = CreateSolidBrush(ToColorRef(argb)); + if (brush != nullptr) + { + FillRect(hdc, &rc, brush); + DeleteObject(brush); + } + g.ReleaseHDC(hdc); + return; + } + } + gd::SolidBrush brush(ToGdiColor(argb)); g.FillRectangle(&brush, rect); } @@ -215,24 +251,12 @@ namespace jdi float UiTextWidth(gd::Graphics& g, const std::wstring& text, HFONT font) { - if (text.empty() || font == nullptr) - { - return 0.0f; - } - - HDC hdc = g.GetHDC(); - if (hdc == nullptr) - { - return 0.0f; - } - - HGDIOBJ oldFont = SelectObject(hdc, font); - SIZE size = {}; - GetTextExtentPoint32W(hdc, text.c_str(), static_cast(text.size()), &size); - SelectObject(hdc, oldFont); - g.ReleaseHDC(hdc); - - return static_cast(size.cx); + // 直接复用 UiMeasureTextWidth:后者用 GetDC(nullptr) 取度量 HDC, + // 不会触发 **Graphics::GetHDC() 的 GDI+ 批次刷新**(实测每次约 5µs)。 + // UiEllipsize 会反复调用本函数(逐字符回退时每步一次), + // 若走 GetHDC 路线,一个长名称就要刷新十几次,是拖拽掉帧的隐性来源之一。 + (void)g; + return UiMeasureTextWidth(font, text); } float UiMeasureTextWidth(HFONT font, const std::wstring& text)