diff --git a/Components/Application/Src/Application.cpp b/Components/Application/Src/Application.cpp index b0e2618..98a45f0 100644 --- a/Components/Application/Src/Application.cpp +++ b/Components/Application/Src/Application.cpp @@ -1451,6 +1451,12 @@ namespace jdi edgeCount.id = PLOT_CMD_LANE_EDGE_COUNT; edgeCount.checked = m_plot.LaneEdgeCount(laneIndex); experimental.submenu.push_back(edgeCount); + + MenuItem edgeCenter; + edgeCenter.label = L"边沿计数(中心轴)"; + edgeCenter.id = PLOT_CMD_LANE_EDGE_CENTER; + edgeCenter.checked = m_plot.LaneEdgeCenterCount(laneIndex); + experimental.submenu.push_back(edgeCenter); } items.push_back(experimental); @@ -1557,6 +1563,10 @@ namespace jdi // 「实验性功能 → 边沿计数」:切换本网格的横向光标与统计面板。 m_plot.ToggleLaneEdgeCount(laneIndex); break; + case PLOT_CMD_LANE_EDGE_CENTER: + // 「实验性功能 → 边沿计数(中心轴)」:切换中心平均值曲线与统计面板。 + m_plot.ToggleLaneEdgeCenterCount(laneIndex); + break; case PLOT_CMD_FIT_ALL: m_plot.FitAll(); break; diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index 63ef8c8..d11b447 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -52,7 +52,8 @@ namespace jdi CURSOR_SPAN, /**< 编辑双光标之间的时间差(前方光标不动,后方光标随之移动)。 */ LANE_Y_MAX, /**< 编辑某网格的 Y 轴最大值。 */ LANE_Y_MIN, /**< 编辑某网格的 Y 轴最小值。 */ - SIGNAL_NAME /**< 编辑图例中某信号的显示名称。 */ + SIGNAL_NAME, /**< 编辑图例中某信号的显示名称。 */ + EDGE_CENTER_SMOOTH /**< 编辑「中心轴边沿计数」的平滑点数 N。 */ }; /** @@ -66,7 +67,8 @@ namespace jdi PLOT_CMD_FIT_ALL = 4, /**< 全部网格与时间轴自适应。 */ PLOT_CMD_LANE_FIT_TIME = 5, /**< 时间轴自适应到本网格信号范围(按网格时间轴展开)。 */ PLOT_CMD_LANE_AUTO_VIEW = 6, /**< 切换本网格的「选区 Y 轴自适应」开关。 */ - PLOT_CMD_LANE_EDGE_COUNT = 7 /**< 切换本网格的「实验性功能 → 边沿计数」开关。 */ + PLOT_CMD_LANE_EDGE_COUNT = 7, /**< 切换本网格的「实验性功能 → 边沿计数」开关。 */ + PLOT_CMD_LANE_EDGE_CENTER = 8 /**< 切换本网格的「实验性功能 → 边沿计数(中心轴)」开关。 */ }; /** @@ -407,6 +409,28 @@ namespace jdi */ bool ToggleLaneEdgeCount(int laneIndex); + /** + * @brief 判断某网格是否开启了「实验性功能 → 边沿计数(中心轴)」。 + * @param [IN] laneIndex 网格下标。 + * @return 开启返回 true。 + */ + bool LaneEdgeCenterCount(int laneIndex) const; + + /** + * @brief 设置某网格的「边沿计数(中心轴)」开关。 + * @param [IN] laneIndex 网格下标。 + * @param [IN] enabled 目标状态。 + * @return 状态发生变化返回 true。 + */ + bool SetLaneEdgeCenterCount(int laneIndex, bool enabled); + + /** + * @brief 切换某网格的「边沿计数(中心轴)」开关。 + * @param [IN] laneIndex 网格下标。 + * @return 切换后的状态。 + */ + bool ToggleLaneEdgeCenterCount(int laneIndex); + /** * @brief 进入某网格 Y 轴上限/下限的内联输入状态。 * @param [IN] laneIndex 网格下标。 @@ -855,6 +879,90 @@ namespace jdi */ bool IsOnEdgeCursorLine(int x, int y, int& laneIndex) const; + /** + * @brief 实验性功能浮动面板的几何布局(边沿计数 / 中心轴边沿计数共用同一套样式)。 + */ + struct EdgePanelLayout + { + gd::RectF panel; /**< 面板整体矩形。 */ + gd::RectF title; /**< 标题栏(**唯一可拖拽区域**)。 */ + gd::RectF close; /**< ✕ 关闭按钮。 */ + gd::RectF edit; /**< 面板底部编辑行(无编辑内容时宽度为 0)。 */ + int rows = 0; /**< 统计行数(每条可见信号一行)。 */ + }; + + /** + * @brief 计算某个实验性功能面板的布局。 + * @param [IN] laneIndex 网格下标。 + * @param [IN] centerAxis 为 true 时取「边沿计数(中心轴)」面板,否则取「边沿计数」。 + * @return 布局;面板未展开时各项均为空矩形。 + */ + EdgePanelLayout EdgePanelLayoutFor(int laneIndex, bool centerAxis) const; + + /** + * @brief 获取「边沿计数(中心轴)」浮动面板的矩形。 + * @param [IN] laneIndex 网格下标。 + * @return 面板矩形(未展开时为空矩形)。 + */ + gd::RectF EdgeCenterPanelRect(int laneIndex) const; + + /** + * @brief 获取中心平均值曲线(每个数据点 = 自身与左右各 N 点的平均值),结果带缓存。 + * @param [IN] signal 目标信号。 + * @param [IN] smooth 平滑半径 N(0 表示不平滑)。 + * @return 曲线数值数组(长度与 `signal.values` 相同;缺失处为 NaN)。 + */ + const std::vector& CenterCurveFor(const SignalData& signal, int smooth) const; + + /** + * @brief 取某个时刻的中心平均值曲线取值(用于纵向光标的取值标注)。 + * @param [IN] file 信号所属文件。 + * @param [IN] signal 目标信号。 + * @param [IN] smooth 平滑半径 N。 + * @param [IN] time 采样时刻。 + * @param [OUT] value 输出取值。 + * @return 取到有效值返回 true。 + */ + bool CenterValueAt(const DataFile& file, const SignalData& signal, int smooth, double time, + double& value) const; + + /** + * @brief 统计信号穿越“中心平均值曲线”的上/下边沿数量,结果带缓存。 + * @param [IN] file 信号所属文件。 + * @param [IN] signal 目标信号。 + * @param [IN] smooth 平滑半径 N。 + * @param [IN] hasRange 是否只统计某个时间区间(双光标模式下为光标区间)。 + * @param [IN] rangeMin 区间左端点时间。 + * @param [IN] rangeMax 区间右端点时间。 + * @param [OUT] rising 上升沿数量(信号从下往上越过中心平均值曲线)。 + * @param [OUT] falling 下降沿数量(信号从上往下穿过中心平均值曲线)。 + */ + void EdgeCenterCountsFor(const DataFile& file, const SignalData& signal, int smooth, + bool hasRange, double rangeMin, double rangeMax, std::size_t& rising, + std::size_t& falling) const; + + /** + * @brief 绘制某条信号的中心平均值曲线(叠加在原信号所在网格上)。 + * @param [IN,OUT] g 绘图上下文。 + * @param [IN] resolved 信号视图。 + * @param [IN] lane 网格绘制信息。 + * @param [IN] smooth 平滑半径 N。 + */ + void PaintCenterCurve(gd::Graphics& g, const ResolvedSignal& resolved, const LaneVisual& lane, + int smooth) const; + + /** + * @brief 放置一个光标取值胶囊:先夹到网格可见范围内,再向下(必要时向上)避让 + * 已占用的标注矩形,最后登记到 `m_cursorLabelRects`。 + * @param [IN,OUT] rect 胶囊矩形(Y 会被调整)。 + * @param [IN] preferredTop 期望的顶边纵坐标(避让前)。 + * @param [IN] textHeight 胶囊高度。 + * @param [IN] clipTop 网格可见区上边界。 + * @param [IN] clipBottom 网格可见区下边界。 + */ + void PlaceCursorLabel(gd::RectF& rect, float preferredTop, float textHeight, float clipTop, + float clipBottom); + /** * @brief 获取边沿统计面板的矩形(未展开时返回空矩形)。 * @param [IN] laneIndex 网格下标。 @@ -885,11 +993,12 @@ namespace jdi std::size_t& falling) const; /** - * @brief 计算边沿统计面板所需的宽度(排版与绘制共用,保证文字完整不被省略)。 + * @brief 计算实验性功能面板所需的宽度(排版与绘制共用,保证文字完整不被省略)。 * @param [IN] laneIndex 网格下标。 + * @param [IN] centerAxis 为 true 时取「边沿计数(中心轴)」面板。 * @return 面板宽度(像素)。 */ - float EdgePanelWidth(int laneIndex) const; + float EdgePanelWidth(int laneIndex, bool centerAxis) const; /** * @brief 判断边沿统计是否限定在双光标区间内(双光标模式)。 @@ -1050,9 +1159,34 @@ namespace jdi }; mutable std::vector m_edgeCountCache; /**< 边沿统计缓存(纯缓存,逻辑上不影响对象状态)。 */ + /** 中心平均值曲线缓存项(按「信号指针 + 平滑半径」标识)。 */ + struct CenterCurveEntry + { + const SignalData* signal = nullptr; + int smooth = 0; + std::vector values; /**< 与 signal->values 等长;缺失处为 NaN。 */ + }; + /** 中心平均值曲线缓存:**deque** 保证 push_back 后已有元素引用仍然有效。 */ + mutable std::deque m_centerCurveCache; + + /** 中心轴边沿统计缓存项。 */ + struct EdgeCenterCountEntry + { + const DataFile* file = nullptr; + const SignalData* signal = nullptr; + int smooth = 0; + bool hasRange = false; + double rangeMin = 0.0; + double rangeMax = 0.0; + std::size_t rising = 0; + std::size_t falling = 0; + }; + mutable std::vector m_edgeCenterCountCache; /**< 中心轴边沿统计缓存。 */ + // 「边沿计数」横向光标 / 面板的拖拽状态。 int m_edgeDragLane = -1; /**< 正在拖动的横向光标所属网格。 */ int m_edgePanelDragLane = -1; /**< 正在拖动的统计面板所属网格。 */ + bool m_edgePanelDragCenter = false; /**< 正在拖动的面板是否为「中心轴边沿计数」面板。 */ gd::PointF m_edgePanelDragOffset; /**< 抓取面板时的相对偏移(保持不跳变)。 */ /** diff --git a/Components/PlotView/Src/PlotView.cpp b/Components/PlotView/Src/PlotView.cpp index 7cee008..be85697 100644 --- a/Components/PlotView/Src/PlotView.cpp +++ b/Components/PlotView/Src/PlotView.cpp @@ -51,6 +51,9 @@ namespace jdi const float kEdgePanelTitleHeight = 26.0f; /**< 面板标题栏高度。 */ const float kEdgePanelRowHeight = 18.0f; /**< 面板内单行文本高度。 */ const float kEdgePanelPad = 7.0f; /**< 面板内边距。 */ + const float kEdgePanelEditHeight = 30.0f; /**< 中心轴面板底部编辑行高度。 */ + const float kEdgePanelEditFieldWidth = 64.0f; /**< 编辑行数值框的最小宽度。 */ + const float kEdgePanelEditFieldGap = 8.0f; /**< 编辑行说明文字与数值框之间的间隔。 */ const std::uint64_t kHintHoldMs = 1300; /**< 提示浮层完全显示的停留时长(毫秒)。 */ const std::uint64_t kHintFadeMs = 500; /**< 提示浮层的淡出时长(毫秒)。 */ @@ -906,8 +909,10 @@ namespace jdi void PlotView::InvalidatePolylineCache() { m_polylineCache.clear(); - // 数据层结构变化后边沿统计结果也会失效。 + // 数据层结构变化后边沿统计与中心平均值曲线的派生结果都会失效。 m_edgeCountCache.clear(); + m_edgeCenterCountCache.clear(); + m_centerCurveCache.clear(); InvalidateSceneCache(); } @@ -1348,6 +1353,252 @@ namespace jdi 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.edgeCenterSmooth < 0) + { + lane.edgeCenterSmooth = 0; + } + lane.edgeCenterPanelOpen = true; + } + else + { + lane.edgeCenterPanelOpen = false; + } + return true; + } + + bool PlotView::ToggleLaneEdgeCenterCount(int laneIndex) + { + const bool enabled = !LaneEdgeCenterCount(laneIndex); + SetLaneEdgeCenterCount(laneIndex, enabled); + return enabled; + } + + const std::vector& PlotView::CenterCurveFor(const SignalData& signal, int smooth) const + { + const int radius = std::max(0, smooth); + for (const CenterCurveEntry& entry : m_centerCurveCache) + { + if (entry.signal == &signal && entry.smooth == radius) + { + return entry.values; + } + } + + CenterCurveEntry entry; + entry.signal = &signal; + entry.smooth = radius; + entry.values.assign(signal.values.size(), std::numeric_limits::quiet_NaN()); + + const std::size_t count = signal.values.size(); + if (count > 0) + { + // 滑动窗口求和:窗口 = [max(0,i-N), min(count-1,i+N)], + // 每前进一步只增减边界处的两个采样点,整体 O(n),不额外占用前缀和内存。 + double sum = 0.0; + std::size_t finiteCount = 0; + std::size_t windowLow = 0; + std::size_t windowHigh = 0; // 闭区间右端(尚未计入时为 0)。 + + const auto addValue = [&sum, &finiteCount](double value) + { + if (std::isfinite(value)) + { + sum += value; + ++finiteCount; + } + }; + const auto removeValue = [&sum, &finiteCount](double value) + { + if (std::isfinite(value)) + { + sum -= value; + --finiteCount; + } + }; + + for (std::size_t index = 0; index < count; ++index) + { + const std::size_t low = (index > static_cast(radius)) + ? (index - static_cast(radius)) + : 0; + const std::size_t high = std::min(count - 1, index + static_cast(radius)); + + if (index == 0) + { + windowLow = low; + windowHigh = high; + for (std::size_t i = windowLow; i <= windowHigh; ++i) + { + addValue(signal.values[i]); + } + } + else + { + // 左端前移:移出旧左端(若已不在窗口内)。 + while (windowLow < low) + { + removeValue(signal.values[windowLow]); + ++windowLow; + } + // 右端前移:加入新右端。 + while (windowHigh < high) + { + ++windowHigh; + addValue(signal.values[windowHigh]); + } + } + + // 中心点本身无效(NaN)时该处不绘制曲线。 + if (finiteCount > 0 && std::isfinite(signal.values[index])) + { + entry.values[index] = static_cast(sum / static_cast(finiteCount)); + } + } + } + + m_centerCurveCache.push_back(std::move(entry)); + return m_centerCurveCache.back().values; + } + + bool PlotView::CenterValueAt(const DataFile& file, const SignalData& signal, int smooth, + double time, double& value) const + { + const int index = FindNearestSample(file, time); + if (index < 0) + { + return false; + } + const std::vector& curve = CenterCurveFor(signal, smooth); + 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::EdgeCenterCountsFor(const DataFile& file, const SignalData& signal, int smooth, + bool hasRange, double rangeMin, double rangeMax, + std::size_t& rising, std::size_t& falling) const + { + rising = 0; + falling = 0; + + for (const EdgeCenterCountEntry& entry : m_edgeCenterCountCache) + { + if (entry.file == &file && entry.signal == &signal && entry.smooth == smooth && + entry.hasRange == hasRange && entry.rangeMin == rangeMin && entry.rangeMax == rangeMax) + { + rising = entry.rising; + falling = entry.falling; + return; + } + } + + const std::vector& curve = CenterCurveFor(signal, smooth); + const std::size_t count = std::min(curve.size(), signal.values.size()); + + std::size_t begin = 0; + std::size_t end = count; + if (hasRange && file.timeMonotonic) + { + 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); + + // 边沿判定:比较信号本身与其中心平均值曲线。 + // 信号由下向上越过中心曲线 → 上升沿;由上向下穿过 → 下降沿。 + bool hasPrevious = false; + double previousDelta = 0.0; + for (std::size_t index = begin; index < end; ++index) + { + const double value = signal.values[index]; + const float center = curve[index]; + if (!std::isfinite(value) || !std::isfinite(center)) + { + hasPrevious = false; + continue; + } + if (hasRange && !file.timeMonotonic && index < file.time.size()) + { + const double time = file.time[index]; + if (time < rangeMin || time > rangeMax) + { + hasPrevious = false; + continue; + } + } + + const double delta = value - static_cast(center); + if (hasPrevious) + { + if (previousDelta < 0.0 && delta >= 0.0) + { + ++rising; + } + else if (previousDelta > 0.0 && delta <= 0.0) + { + ++falling; + } + } + previousDelta = delta; + hasPrevious = true; + } + + EdgeCenterCountEntry entry; + entry.file = &file; + entry.signal = &signal; + entry.smooth = smooth; + entry.hasRange = hasRange; + entry.rangeMin = rangeMin; + entry.rangeMax = rangeMax; + entry.rising = rising; + entry.falling = falling; + m_edgeCenterCountCache.push_back(entry); + } + 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 @@ -1447,7 +1698,7 @@ namespace jdi m_edgeCountCache.push_back(entry); } - float PlotView::EdgePanelWidth(int laneIndex) const + float PlotView::EdgePanelWidth(int laneIndex, bool centerAxis) const { if (m_tab == nullptr || m_fonts == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) @@ -1456,8 +1707,8 @@ namespace jdi } const Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; - // 标题行 = “GX” 徽标 + “边沿计数(光标区间内)”。 - const std::wstring title = L"边沿计数" + EdgeRangeSuffix(); + // 标题行 = “GX” 徽标 + 标题(“边沿计数” / “中心轴边沿计数” + 光标区间尾缀)。 + const std::wstring title = (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; @@ -1474,8 +1725,16 @@ namespace jdi const float nameWidth = UiMeasureTextWidth(m_fonts->uiSmall, resolved.data->Title()); std::size_t rising = 0; std::size_t falling = 0; - EdgeCountsFor(*resolved.file, *resolved.data, lane.edgeValue, EdgeRangeActive(), - EdgeRangeMin(), EdgeRangeMax(), rising, falling); + if (centerAxis) + { + EdgeCenterCountsFor(*resolved.file, *resolved.data, lane.edgeCenterSmooth, + 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" 下降沿 " + @@ -1483,6 +1742,17 @@ namespace jdi width = std::max(width, kEdgePanelPad * 2.0f * m_scale + std::max(nameWidth, countWidth)); } + if (centerAxis) + { + // 底部编辑行 = “平滑点数”说明文字 + 数值框(与菜单内联编辑同一套排版习惯)。 + const float editWidth = + UiMeasureTextWidth(m_fonts->uiSmall, L"平滑点数 N") + kEdgePanelEditFieldGap * m_scale + + std::max(kEdgePanelEditFieldWidth * m_scale, + UiMeasureTextWidth(m_fonts->monoSmall, std::to_wstring(lane.edgeCenterSmooth)) + + 20.0f * m_scale); + width = std::max(width, kEdgePanelPad * 2.0f * m_scale + editWidth); + } + // 夹在绘图区宽度之内(过窄的网格也不至于把面板挤到外面)。 return std::min(width, std::max(120.0f * m_scale, m_plotRect.Width - 8.0f * m_scale)); } @@ -2051,10 +2321,22 @@ namespace jdi return gd::RectF(m_editFieldRect.X, m_editFieldRect.Y, nameWidth, height); } + if (m_editTarget == PlotEditTarget::EDGE_CENTER_SMOOTH) + { + // 「平滑点数 N」:输入框落在中心轴面板底部的编辑行内(靠右,与显示态对齐)。 + if (m_editFieldRect.Width <= 0.0f) + { + return gd::RectF(); + } + const float fieldWidth = std::max(width, kEdgePanelEditFieldWidth * m_scale); + const float fieldHeight = std::max(height, m_editFieldRect.Height - 6.0f * m_scale); + return gd::RectF(m_editFieldRect.X + m_editFieldRect.Width - fieldWidth, + m_editFieldRect.Y + 3.0f * m_scale, fieldWidth, fieldHeight); + } + // 网格 Y 轴上下限输入框:优先落在被点击的菜单条目位置,保证"就地编辑"的观感。 if (m_editFieldRect.Width > 0.0f && m_editFieldRect.Height > 0.0f) - { - float left = m_editFieldRect.X; + { 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)); @@ -2208,6 +2490,19 @@ namespace jdi return true; } + // 「平滑点数 N」:只作用于中心平均值曲线的计算,夹在合理范围内(0–1000000)。 + if (target == PlotEditTarget::EDGE_CENTER_SMOOTH) + { + int smooth = static_cast(std::lround(parsed)); + smooth = std::max(0, std::min(smooth, 1000000)); + m_tab->Lanes()[static_cast(laneIndex)].edgeCenterSmooth = smooth; + // 平滑半径变化会改变曲线与统计结果:清掉派生缓存后重绘。 + m_centerCurveCache.clear(); + m_edgeCenterCountCache.clear(); + InvalidateSceneCache(); + return true; + } + // 直接输入上下限:先固化当前范围,再用新值替换对应端点(保证 max > min)。 if (laneIndex >= static_cast(m_lanes.size())) { @@ -2420,16 +2715,32 @@ namespace jdi return LoadCursorW(nullptr, IDC_SIZENS); } - // 边沿统计面板与横向光标优先于信号悬停提示判定(面板会盖住波形)。 + // 实验性功能面板与横向光标优先于信号悬停提示判定(面板会盖住波形)。 + // 面板**只有标题栏**可以拖拽(显示抓握光标);✕ 与面板主体一律保持原始箭头。 if (m_tab != nullptr) { - for (int index = 0; index < static_cast(m_lanes.size()); ++index) + for (int pass = 0; pass < 2; ++pass) { - const gd::RectF panel = EdgePanelRect(index); - if (panel.Width > 0.0f && - UiPointInRect(panel, static_cast(x), static_cast(y))) + const bool centerAxis = (pass == 1); + for (int index = 0; index < static_cast(m_lanes.size()); ++index) { - return LoadCursorW(nullptr, IDC_SIZEALL); + const EdgePanelLayout layout = EdgePanelLayoutFor(index, centerAxis); + 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 nullptr; + } + if (UiPointInRect(layout.title, static_cast(x), static_cast(y))) + { + return LoadCursorW(nullptr, IDC_SIZEALL); + } + // 面板主体(含底部编辑行):保持原始箭头。 + return nullptr; } } int edgeLane = -1; @@ -2649,13 +2960,14 @@ namespace jdi } 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 = EdgePanelRect(m_edgePanelDragLane); + const gd::RectF panel = + EdgePanelLayoutFor(m_edgePanelDragLane, m_edgePanelDragCenter).panel; if (panel.Width <= 0.0f) { break; @@ -2667,8 +2979,16 @@ namespace jdi 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)]; - lane.edgePanelX = left; - lane.edgePanelY = top; + if (m_edgePanelDragCenter) + { + lane.edgeCenterPanelX = left; + lane.edgeCenterPanelY = top; + } + else + { + lane.edgePanelX = left; + lane.edgePanelY = top; + } m_dragMoved = true; return true; } @@ -2679,16 +2999,20 @@ namespace jdi bool changed = false; - // 鼠标压在边沿统计面板上时不再触发信号悬停提示(面板本来就盖住波形)。 + // 鼠标压在实验性功能面板上时不再触发信号悬停提示(面板本来就盖住波形)。 bool overEdgePanel = false; - for (int index = 0; index < static_cast(m_lanes.size()); ++index) + for (int pass = 0; pass < 2 && !overEdgePanel; ++pass) { - const gd::RectF panel = EdgePanelRect(index); - if (panel.Width > 0.0f && - UiPointInRect(panel, static_cast(x), static_cast(y))) + const bool centerAxis = (pass == 1); + for (int index = 0; index < static_cast(m_lanes.size()); ++index) { - overEdgePanel = true; - break; + const gd::RectF panel = EdgePanelLayoutFor(index, centerAxis).panel; + if (panel.Width > 0.0f && + UiPointInRect(panel, static_cast(x), static_cast(y))) + { + overEdgePanel = true; + break; + } } } @@ -2830,29 +3154,68 @@ namespace jdi } } - // 边沿统计浮动面板:关闭按钮 / 面板拖动(面板压在波形之上,必须先于信号拖拽判定)。 - for (int index = 0; index < static_cast(m_lanes.size()); ++index) + // 实验性功能浮动面板(面板压在波形之上,必须先于信号拖拽判定): + // * ✕ → 关闭该网格对应的功能; + // * 底部编辑行 → 进入「平滑点数 N」内联编辑; + // * 标题栏 → 拖动面板; + // * 面板主体 → 消费点击,但**不拖动也不穿透**(用户明确要求只有标题栏可拖拽)。 + for (int pass = 0; pass < 2; ++pass) { - const gd::RectF panel = EdgePanelRect(index); - if (panel.Width <= 0.0f || !UiPointInRect(panel, fx, fy)) + const bool centerAxis = (pass == 1); + bool handledPanel = false; + for (int index = 0; index < static_cast(m_lanes.size()); ++index) { - continue; - } - if (UiPointInRect(EdgePanelCloseRect(index), fx, fy)) - { - // ✕ 关闭面板的同时关闭该网格的边沿计数功能(横向光标一并消失)。 - if (m_tab != nullptr) + const EdgePanelLayout layout = EdgePanelLayoutFor(index, centerAxis); + if (layout.panel.Width <= 0.0f || + !UiPointInRect(layout.panel, fx, fy)) { - SetLaneEdgeCount(index, false); + continue; } + handledPanel = true; + + if (UiPointInRect(layout.close, fx, fy)) + { + // ✕ 关闭面板的同时关闭该网格对应的实验性功能。 + if (centerAxis) + { + SetLaneEdgeCenterCount(index, false); + } + else + { + SetLaneEdgeCount(index, false); + } + m_dragMode = PlotDragMode::NONE; + return true; + } + + if (centerAxis && layout.edit.Width > 0.0f && UiPointInRect(layout.edit, fx, fy) && + m_tab != nullptr) + { + // 底部编辑行 → 就地编辑平滑点数 N(输入框落在该行内)。 + const int smooth = m_tab->Lanes()[static_cast(index)].edgeCenterSmooth; + BeginEdit(PlotEditTarget::EDGE_CENTER_SMOOTH, index, std::to_wstring(smooth), + layout.edit); + return true; + } + + if (UiPointInRect(layout.title, fx, fy)) + { + m_dragMode = PlotDragMode::EDGE_PANEL_MOVE; + m_edgePanelDragLane = index; + m_edgePanelDragCenter = centerAxis; + m_edgePanelDragOffset.X = fx - layout.panel.X; + m_edgePanelDragOffset.Y = fy - layout.panel.Y; + return true; + } + + // 面板主体:消费该次点击,避免误触发底层的平移/选区/信号拖拽。 m_dragMode = PlotDragMode::NONE; return true; } - m_dragMode = PlotDragMode::EDGE_PANEL_MOVE; - m_edgePanelDragLane = index; - m_edgePanelDragOffset.X = fx - panel.X; - m_edgePanelDragOffset.Y = fy - panel.Y; - return true; + if (handledPanel) + { + break; + } } // 「边沿计数」横向光标:上下拖动改变幅值。 @@ -2971,6 +3334,7 @@ namespace jdi m_cursorGrabOffset = 0; m_edgeDragLane = -1; m_edgePanelDragLane = -1; + m_edgePanelDragCenter = false; // 松开左键:粘滞提示结束计时,开始"停留后淡出"。 ReleaseHint(); (void)x; @@ -3734,6 +4098,9 @@ namespace jdi mixFloat(static_cast(visual.yMax)); const std::vector& signals = Tab().Lanes()[laneIndex].signals; + // 「边沿计数(中心轴)」的开关与平滑点数会改变网格内的曲线绘制 → 参与指纹。 + mix(Tab().Lanes()[laneIndex].edgeCenter ? 1ull : 0ull); + mix(static_cast(Tab().Lanes()[laneIndex].edgeCenterSmooth)); mix(signals.size()); for (const SignalRef& ref : signals) { @@ -4084,6 +4451,12 @@ namespace jdi // 只有拖拽中的信号光晕可以进入静态场景缓存(悬停光晕见 PaintHoverHighlight)。 const SignalRef haloRef = SceneHaloRef(); + // 「边沿计数(中心轴)」开启时,为本网格每条信号额外叠加一条中心平均值曲线。 + // 该曲线属于静态场景(只在开关/平滑点数变化时改变),因此参与场景缓存指纹。 + const int centerSmooth = + Tab().Lanes()[static_cast(laneIndex)].edgeCenter + ? Tab().Lanes()[static_cast(laneIndex)].edgeCenterSmooth + : -1; for (const SignalRef& ref : laneSignals) { @@ -4162,6 +4535,13 @@ namespace jdi g.FillPath(&brush, &scatterPath); } } + + // 中心平均值曲线(「实验性功能 → 边沿计数(中心轴)」)叠加绘制在原信号之上。 + // 它只是叠加显示的派生曲线,**不会**加入左侧信号列表。 + if (centerSmooth >= 0) + { + PaintCenterCurve(g, resolved, lane, centerSmooth); + } } // g.Restore 也会还原绘制状态,此处直接恢复图形状态即可。 @@ -4754,71 +5134,59 @@ namespace jdi // 多个信号的取值标注可能落在同一位置(数据点重合或过近): // 依次向下(再向上)避让已占用的标注,保证不会互相覆盖。 - { - 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); - bool resolved = false; - for (int step = 0; step < 16; ++step) - { - const gd::RectF* hit = nullptr; - for (const gd::RectF& occupied : m_cursorLabelRects) - { - if (labelRect.IntersectsWith(occupied)) - { - hit = &occupied; - break; - } - } - if (hit == nullptr) - { - resolved = true; - break; - } - // 向下推一格;超出网格底部则改由原始位置向上避让。 - const float next = hit->Y + hit->Height + gap; - if (next > maxTop) - { - break; - } - labelRect.Y = next; - } - if (!resolved) - { - labelRect.Y = labelTop; - for (int step = 0; step < 16; ++step) - { - const gd::RectF* hit = nullptr; - for (const gd::RectF& occupied : m_cursorLabelRects) - { - if (labelRect.IntersectsWith(occupied)) - { - hit = &occupied; - break; - } - } - if (hit == nullptr) - { - resolved = true; - break; - } - labelRect.Y = hit->Y - labelRect.Height - gap; - if (labelRect.Y < minTop) - { - break; - } - } - } - labelRect.Y = std::max(minTop, std::min(labelRect.Y, maxTop)); - m_cursorLabelRects.push_back(labelRect); - } + PlaceCursorLabel(labelRect, labelTop, textHeight, clipTop, clipBottom); UiFillRoundRect(g, labelRect, 3.0f * m_scale, WithAlpha(signalColor, 0xF0)); const bool dark = IsDarkColor(signalColor); DrawLabelOutline(g, labelRect, 3.0f * m_scale, dark); UiText(g, text, m_fonts->monoSmall, labelRect, dark ? 0xFFFDF6E3u : 0xFF073642u, TextAlign::CENTER); + + // 「边沿计数(中心轴)」开启时,纵向光标还要额外标注**中心平均值曲线** + // 在该时刻的取值(它不是信号,因此单独标注,不并入上面的取值文案)。 + if (m_tab->Lanes()[laneIndex].edgeCenter) + { + const int smooth = m_tab->Lanes()[laneIndex].edgeCenterSmooth; + 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) + { + 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(&fill, pinX - radius, centerY - radius, radius * 2.0f, + radius * 2.0f); + + const std::wstring centerText = L"中 " + FormatValue(centerValue); + const float centerTextWidth = + UiTextWidth(g, centerText, m_fonts->monoSmall) + 10.0f * m_scale; + float 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 = centerY - 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); + UiFillRoundRect(g, centerRect, 3.0f * m_scale, + WithAlpha(signalColor, 0xB8)); + DrawLabelOutline(g, centerRect, 3.0f * m_scale, dark); + UiText(g, centerText, m_fonts->monoSmall, centerRect, + dark ? 0xFFFDF6E3u : 0xFF073642u, TextAlign::CENTER); + } + } + } } } } @@ -4874,18 +5242,21 @@ namespace jdi } } - gd::RectF PlotView::EdgePanelRect(int laneIndex) const + PlotView::EdgePanelLayout PlotView::EdgePanelLayoutFor(int laneIndex, bool centerAxis) const { - if (m_tab == nullptr || laneIndex < 0 || + 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 gd::RectF(); + return layout; } const Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; - if (!lane.edgeCount || !lane.edgePanelOpen) + const bool enabled = centerAxis ? lane.edgeCenter : lane.edgeCount; + const bool open = centerAxis ? lane.edgeCenterPanelOpen : lane.edgePanelOpen; + if (!enabled || !open) { - return gd::RectF(); + return layout; } int rowCount = 0; @@ -4899,21 +5270,27 @@ namespace jdi } if (rowCount <= 0) { - return gd::RectF(); + return layout; } + layout.rows = rowCount; - const float width = EdgePanelWidth(laneIndex); + const float width = EdgePanelWidth(laneIndex, centerAxis); const float titleHeight = kEdgePanelTitleHeight * m_scale; const float rowHeight = kEdgePanelRowHeight * m_scale; const float pad = kEdgePanelPad * m_scale; - const float height = titleHeight + pad + static_cast(rowCount) * rowHeight * 2.0f + pad; + // 中心轴面板在底部固定多出一行「平滑点数」编辑行。 + const float editHeight = centerAxis ? (kEdgePanelEditHeight * m_scale) : 0.0f; + const float height = titleHeight + pad + static_cast(rowCount) * rowHeight * 2.0f + pad + + editHeight; // 首次使用(位置为负)时自动放到绘图区右侧,并各网格依次错开,避免完全重叠。 - float left = (lane.edgePanelX >= 0.0) - ? static_cast(lane.edgePanelX) + const double storedX = centerAxis ? lane.edgeCenterPanelX : lane.edgePanelX; + const double storedY = 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 = (lane.edgePanelY >= 0.0) - ? static_cast(lane.edgePanelY) + float top = (storedY >= 0.0) + ? static_cast(storedY) : (m_plotRect.Y + 16.0f * m_scale + static_cast(laneIndex) * 28.0f * m_scale); @@ -4923,18 +5300,33 @@ namespace jdi top = std::max(m_plotRect.Y + 2.0f * m_scale, std::min(top, m_plotRect.Y + m_plotRect.Height - height - 2.0f * m_scale)); - return gd::RectF(left, top, width, height); + 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 (centerAxis) + { + const float editRowHeight = kEdgePanelEditHeight * m_scale; + layout.edit = gd::RectF(left + pad, top + height - editRowHeight, + std::max(0.0f, width - pad * 2.0f), editRowHeight); + } + return layout; + } + + gd::RectF PlotView::EdgePanelRect(int laneIndex) const + { + return EdgePanelLayoutFor(laneIndex, false).panel; } gd::RectF PlotView::EdgePanelCloseRect(int laneIndex) const { - const gd::RectF rect = EdgePanelRect(laneIndex); - if (rect.Width <= 0.0f) - { - return gd::RectF(); - } - const float size = kEdgePanelTitleHeight * m_scale; - return gd::RectF(rect.X + rect.Width - size, rect.Y, size, size); + return EdgePanelLayoutFor(laneIndex, false).close; + } + + gd::RectF PlotView::EdgeCenterPanelRect(int laneIndex) const + { + return EdgePanelLayoutFor(laneIndex, true).panel; } bool PlotView::IsOnEdgeCursorLine(int x, int y, int& laneIndex) const @@ -4978,6 +5370,169 @@ namespace jdi return false; } + 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::PaintCenterCurve(gd::Graphics& g, const ResolvedSignal& resolved, + const LaneVisual& lane, int smooth) const + { if (resolved.data == nullptr || resolved.file == nullptr || resolved.style == nullptr) + { + return; + } + + const std::vector& time = resolved.file->time; + const std::vector& curve = CenterCurveFor(*resolved.data, smooth); + const std::size_t count = std::min(time.size(), curve.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) + { + begin = static_cast( + std::lower_bound(time.begin(), time.end(), viewMin) - time.begin()); + if (begin > 0) + { + --begin; + } + end = static_cast( + std::lower_bound(time.begin(), time.end(), viewMax) - time.begin()); + if (end < count) + { + ++end; + } + } + if (end <= begin) + { + return; + } + + // 平滑曲线本身没有尖峰,等间隔抽稀即可(不需要包络层),顶点预算与波形一致。 + std::size_t budget = + static_cast(std::max(1.0f, m_plotRect.Width)) * kPolylinePointsPerPixel; + budget = std::max(budget, kMinPolylinePoints); + budget = std::min(budget, kMaxPolylinePoints); + const std::size_t visibleCount = end - begin; + 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; + + const float limitTop = lane.rect.Y - lane.rect.Height * 4.0f; + const float limitBottom = lane.rect.Y + lane.rect.Height * 5.0f; + + for (std::size_t index = begin; index < end; index += stride) + { + const float sampled = curve[index]; + 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 + { + points.push_back(gd::PointF(x, y)); + } + } + + if (breaks.empty()) + { + return; + } + + // 中心平均值曲线与原信号同色,若直接用原色会被波形洩没; + // 根据背景明暗把信号色向黑/白方向拉远,保证叠加后清晰可辨。 + const std::uint32_t derived = + IsDarkColor(m_theme->plotBg) ? MixColor(StyleColor(*resolved.style), 0xFFFFFFFFu, 0.62f) + : MixColor(StyleColor(*resolved.style), 0xFF000000u, 0.58f); + gd::Pen pen(ToGdiColor(derived), std::max(1.8f, resolved.style->lineWidth + 0.8f) * 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)); + } + } + void PlotView::PaintEdgeCursor(gd::Graphics& g, const std::vector& lanes) { if (m_tab == nullptr || m_dataSet == nullptr) @@ -5051,94 +5606,149 @@ namespace jdi } // ---- 统计浮动面板(最后绘制,覆盖在波形之上)---- - for (std::size_t index = 0; index < count; ++index) + // 「边沿计数」与「边沿计数(中心轴)」共用同一套样式,仅标题、统计口径与底部编辑行不同。 + for (int pass = 0; pass < 2; ++pass) { - const Lane& tabLane = tabLanes[index]; - if (!tabLane.edgeCount || !tabLane.edgePanelOpen) + const bool centerAxis = (pass == 1); + for (std::size_t index = 0; index < count; ++index) { - continue; - } - const gd::RectF panel = EdgePanelRect(static_cast(index)); - if (panel.Width <= 0.0f || panel.Height <= 0.0f) - { - continue; - } - - 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); - UiText(g, L"边沿计数" + EdgeRangeSuffix(), 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 = EdgePanelCloseRect(static_cast(index)); - 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); - - // 每个信号两行:名称(信号色)+ 上升沿/下降沿数量。 - 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) + const Lane& tabLane = tabLanes[index]; + const EdgePanelLayout layout = EdgePanelLayoutFor(static_cast(index), centerAxis); + const gd::RectF& panel = layout.panel; + if (panel.Width <= 0.0f || panel.Height <= 0.0f) { 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; + 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 = + (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); + + // 每个信号两行:名称(信号色)+ 上升沿/下降沿数量。 + 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; + } + 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; + if (centerAxis) + { + EdgeCenterCountsFor(*resolved.file, *resolved.data, tabLane.edgeCenterSmooth, + EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), rising, + falling); + } + else + { + 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 (centerAxis) + { + const gd::RectF& editRect = layout.edit; + if (editRect.Width > 0.0f) + { + const float fieldGap = kEdgePanelEditFieldGap * m_scale; + const std::wstring label = L"平滑点数 N"; + const float labelWidth = UiMeasureTextWidth(m_fonts->uiSmall, label); + UiText(g, label, m_fonts->uiSmall, + gd::RectF(editRect.X, editRect.Y, labelWidth, editRect.Height), + m_theme->text, TextAlign::LEFT); + + const float fieldWidth = std::max( + kEdgePanelEditFieldWidth * m_scale, + UiMeasureTextWidth(m_fonts->monoSmall, + std::to_wstring(tabLane.edgeCenterSmooth)) + + 20.0f * m_scale); + const float fieldLeft = editRect.X + editRect.Width - fieldWidth; + const float fieldHeight = editRect.Height - 6.0f * m_scale; + const gd::RectF fieldRect(fieldLeft, editRect.Y + 3.0f * m_scale, fieldWidth, + fieldHeight); + + const bool editingThis = + (m_editTarget == PlotEditTarget::EDGE_CENTER_SMOOTH && + m_editLane == static_cast(index)); + 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(tabLane.edgeCenterSmooth), m_fonts->monoSmall, + fieldRect, m_theme->textStrong, TextAlign::CENTER); + } + (void)labelWidth; + (void)fieldGap; + } + } } } } diff --git a/Utilities/Inc/DataModel.hpp b/Utilities/Inc/DataModel.hpp index 4b0e76b..8f4d420 100644 --- a/Utilities/Inc/DataModel.hpp +++ b/Utilities/Inc/DataModel.hpp @@ -370,6 +370,27 @@ namespace jdi /** 统计面板左上角纵坐标(<0 表示自动定位)。 */ double edgePanelY = -1.0; + + /** + * @brief 「实验性功能 → 边沿计数(中心轴)」开关。 + * + * 开启后为本网格内每条信号额外绘制一条"中心平均值曲线"(每个数据点取自身与左右各 + * `edgeCenterSmooth` 个点的平均值),并按“信号穿越中心平均值曲线”统计上/下边沿。 + * 这条曲线只是叠加显示,不会作为信号加入左侧列表。 + */ + bool edgeCenter = false; + + /** 中心平均值的平滑半径 N(左右各取 N 个数据点,窗口大小 2N+1)。 */ + int edgeCenterSmooth = 5; + + /** 中心轴统计浮动面板是否展开。 */ + bool edgeCenterPanelOpen = true; + + /** 中心轴统计面板左上角位置(客户区像素;<0 表示自动定位)。 */ + double edgeCenterPanelX = -1.0; + + /** 中心轴统计面板左上角纵坐标(<0 表示自动定位)。 */ + double edgeCenterPanelY = -1.0; }; /** diff --git a/Utilities/Src/DataModel.cpp b/Utilities/Src/DataModel.cpp index 10d897c..746d620 100644 --- a/Utilities/Src/DataModel.cpp +++ b/Utilities/Src/DataModel.cpp @@ -121,6 +121,11 @@ namespace jdi bool edgePanelOpen = true; double edgePanelX = -1.0; double edgePanelY = -1.0; + bool edgeCenter = false; + int edgeCenterSmooth = 5; + bool edgeCenterPanelOpen = true; + double edgeCenterPanelX = -1.0; + double edgeCenterPanelY = -1.0; }; // 先记下每个信号原来所在网格的网格自身状态: @@ -136,6 +141,11 @@ namespace jdi carry.edgePanelOpen = lane.edgePanelOpen; carry.edgePanelX = lane.edgePanelX; carry.edgePanelY = lane.edgePanelY; + carry.edgeCenter = lane.edgeCenter; + carry.edgeCenterSmooth = lane.edgeCenterSmooth; + carry.edgeCenterPanelOpen = lane.edgeCenterPanelOpen; + carry.edgeCenterPanelX = lane.edgeCenterPanelX; + carry.edgeCenterPanelY = lane.edgeCenterPanelY; for (const SignalRef& ref : lane.signals) { previousCarry.emplace_back(ref, carry); @@ -162,6 +172,11 @@ namespace jdi lane.edgePanelOpen = carry.edgePanelOpen; lane.edgePanelX = carry.edgePanelX; lane.edgePanelY = carry.edgePanelY; + lane.edgeCenter = carry.edgeCenter; + lane.edgeCenterSmooth = carry.edgeCenterSmooth; + lane.edgeCenterPanelOpen = carry.edgeCenterPanelOpen; + lane.edgeCenterPanelX = carry.edgeCenterPanelX; + lane.edgeCenterPanelY = carry.edgeCenterPanelY; }; // 收集全部可见信号引用。