diff --git a/Components/Application/Inc/Application.hpp b/Components/Application/Inc/Application.hpp index a776dcf..07d1a89 100644 --- a/Components/Application/Inc/Application.hpp +++ b/Components/Application/Inc/Application.hpp @@ -396,6 +396,8 @@ namespace jdi bool m_loading = false; /**< 是否正在显示加载过渡动画。 */ std::wstring m_loadingText; /**< 加载遮罩显示的文件名。 */ float m_loadingProgress = 0.0f; /**< 加载进度(0-1)。 */ + unsigned long long m_loadingBytesRead = 0; /**< 已读取字节数(用于进度浮层)。 */ + unsigned long long m_loadingTotalBytes = 0; /**< 文件总字节数(0 表示未知)。 */ float m_loadingSpin = 0.0f; /**< 加载动画旋转相位(度)。 */ unsigned long long m_loadingBytes = 0; /**< 当前文件字节数(用于展示体积)。 */ diff --git a/Components/Application/Src/Application.cpp b/Components/Application/Src/Application.cpp index 6647108..7707f65 100644 --- a/Components/Application/Src/Application.cpp +++ b/Components/Application/Src/Application.cpp @@ -101,6 +101,8 @@ namespace jdi std::atomic done{false}; /**< 工作线程是否已结束。 */ std::atomic cancelled{false}; /**< 是否请求取消。 */ std::atomic progress{0.0f}; /**< 0-1 进度。 */ + std::atomic bytesRead{0}; /**< 已读取字节数(解析阶段)。 */ + std::atomic totalBytes{0}; /**< 文件总字节数(0 表示未知)。 */ bool ok = false; /**< 加载是否成功。 */ std::wstring error; /**< 失败原因。 */ DataFile file; /**< 构建结果。 */ @@ -115,9 +117,14 @@ namespace jdi void RunAsyncLoad(AsyncLoadState* state, std::wstring path, std::size_t paletteBase) { // 解析阶段占总进度的 0-75%,列重组与统计占 75-100%。 - CsvProgress parseProgress = [state](double ratio) + CsvProgress parseProgress = [state](const CsvProgressInfo& info) { - state->progress.store(static_cast(ratio * 0.75), std::memory_order_relaxed); + state->progress.store(static_cast(info.ratio * 0.75), std::memory_order_relaxed); + if (info.hasBytes) + { + state->bytesRead.store(info.bytesRead, std::memory_order_relaxed); + state->totalBytes.store(info.totalBytes, std::memory_order_relaxed); + } return !state->cancelled.load(std::memory_order_relaxed); }; @@ -130,9 +137,10 @@ namespace jdi return; } - CsvProgress buildProgress = [state](double ratio) + CsvProgress buildProgress = [state](const CsvProgressInfo& info) { - state->progress.store(static_cast(0.75 + ratio * 0.25), std::memory_order_relaxed); + state->progress.store(static_cast(0.75 + info.ratio * 0.25), + std::memory_order_relaxed); return !state->cancelled.load(std::memory_order_relaxed); }; @@ -966,6 +974,8 @@ namespace jdi m_loading = true; m_loadingProgress = 0.0f; + m_loadingBytesRead = 0; + m_loadingTotalBytes = 0; const std::size_t slashPos = path.find_last_of(L"\\/"); m_loadingText = (slashPos == std::wstring::npos) ? path : path.substr(slashPos + 1); @@ -1004,12 +1014,15 @@ namespace jdi } m_loadingProgress = state.progress.load(std::memory_order_relaxed); + m_loadingBytesRead = state.bytesRead.load(std::memory_order_relaxed); + m_loadingTotalBytes = state.totalBytes.load(std::memory_order_relaxed); InvalidateRect(m_hwnd, nullptr, FALSE); UpdateWindow(m_hwnd); Sleep(15); } m_loadingProgress = 1.0f; + m_loadingBytesRead = m_loadingTotalBytes; worker.join(); m_loading = false; @@ -1090,10 +1103,22 @@ namespace jdi } wchar_t buffer[64]; - swprintf(buffer, sizeof(buffer) / sizeof(buffer[0]), L"%d%%", static_cast(ratio * 100.0f + 0.5f)); - UiText(g, buffer, m_fonts.monoSmall, - gd::RectF(track.X, card.Y + 92.0f * m_scale, track.Width, 20.0f * m_scale), m_theme.text, - TextAlign::LEFT); + swprintf(buffer, sizeof(buffer) / sizeof(buffer[0]), L"%.2f%%", ratio * 100.0); + + // 已读取 / 总大小:两者都只显示数字与自适应单位(GB / MB / KB),均保留两位小数; + // 百分比同样是两位小数。总大小未知(如列重组阶段)时只显示百分比。 + { + std::wstring statusText = buffer; + if (m_loadingTotalBytes > 0) + { + statusText = FormatByteSize(m_loadingBytesRead) + L" / " + + FormatByteSize(m_loadingTotalBytes) + L" " + buffer; + } + UiText(g, statusText, m_fonts.monoSmall, + gd::RectF(track.X, card.Y + 92.0f * m_scale, track.Width, 20.0f * m_scale), + m_theme.text, TextAlign::LEFT); + } + UiText(g, L"按 Esc 取消", m_fonts.uiSmall, gd::RectF(track.X, card.Y + 92.0f * m_scale, track.Width, 20.0f * m_scale), m_theme.textMuted, TextAlign::RIGHT); diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index 54ebbc1..139779b 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -54,7 +54,8 @@ namespace jdi LANE_Y_MAX, /**< 编辑某网格的 Y 轴最大值。 */ LANE_Y_MIN, /**< 编辑某网格的 Y 轴最小值。 */ SIGNAL_NAME, /**< 编辑图例中某信号的显示名称。 */ - EDGE_CENTER_SMOOTH, /**< 编辑「中心轴边沿计数」的平滑点数 N。 */ + EDGE_CENTER_POINTS, /**< 编辑「中心轴边沿计数」的均值点数 X。 */ + EDGE_CENTER_ORDER, /**< 编辑「中心轴边沿计数」的阶数 N。 */ MEAN_FILTER_POINTS, /**< 编辑「均值滤波」的均值点数 X。 */ MEAN_FILTER_ORDER /**< 编辑「均值滤波」的阶数 N。 */ }; @@ -797,6 +798,23 @@ namespace jdi */ double CursorSpan() const; + /** + * @brief 判断指定光标是否滞留在可视区之外(时间读数被钉在网格边缘)。 + * @param [IN] which 光标序号(1 或 2)。 + * @param [OUT] offRight true 表示偏出**右**侧视界,false 表示偏出左侧。 + * @return 越界返回 true;光标在视界内返回 false。 + */ + bool CursorOffViewSide(int which, bool& offRight) const; + + /** + * @brief 在读数胶囊上方绘制"数据在视界之外"的方向三角指示。 + * @param [IN] labelRect 胶囊矩形(三角与其水平居中对齐)。 + * @param [IN] offRight true 表示指向右侧(光标在右视界之外)。 + * @param [IN] color 三角颜色(与胶囊同色)。 + */ + void PaintCursorOffViewTip(gd::Graphics& g, const gd::RectF& labelRect, bool offRight, + std::uint32_t color); + /** * @brief 计算双光标差值标签的矩形(时间轴上方、两条时间读数的中间位置)。 * @param [OUT] insideAxis 为 true 时表示两条光标过近,标签落在主时间轴内部。 @@ -952,15 +970,17 @@ namespace jdi gd::RectF button; /**< 面板底部按钮(无按钮时宽度为 0)。 */ int rows = 0; /**< 统计行数(每条可见信号一行)。 */ /** - * @brief 均值滤波面板的逐信号条目矩形(仅 MEAN 面板使用)。 + * @brief 「逐条目」面板的矩形(MEAN 与 CENTER 面板共用同一套排布)。 * * 索引与 `rowRefs` 一一对应(即网格内可见信号的顺序): - * `rowChecks` 为第一排右侧的复选框;`rowPoints` / `rowOrder` 为第二排的两个输入框 - * (未勾选的条目两个矩形均为空)。 + * `rowChecks` 为第一排右侧的复选框;`rowPoints` / `rowOrder` 为第二排的两个输入框; + * `rowStats` 为第三排的统计结果(中心轴边沿计数专用)。 + * 未勾选的条目对应的矩形均为空。 */ std::vector rowChecks; std::vector rowPoints; std::vector rowOrder; + std::vector rowStats; std::vector rowRefs; }; @@ -975,8 +995,8 @@ namespace jdi /** * @brief 获取多阶(迭代)均值滤波曲线,结果带缓存。 * - * 语义:以「均值点数 X」作滑平半径,对输入重复做 `order` 次滑动平均; - * `order == 1` 时与 `CenterCurveFor` 完全一致。 + * 语义:以「均值点数 X」作滑平半径,对输入重复做 `order` 次滑动平均。 + * 「实验性功能 → 均值滤波」与「中心轴边沿计数」共用本函数,二者曲线因此逐位一致。 * * @param [IN] signal 目标信号。 * @param [IN] points 均值点数 X(滑平半径)。 @@ -1016,52 +1036,42 @@ namespace jdi bool LaneMeanFilterSignalEnabled(int laneIndex, const SignalRef& ref) const; /** - * @brief 获取多阶(迭代)均值滤波曲线,结果带缓存。 - * @param [IN] signal 目标信号。 - * @param [IN] smooth 平滑半径 N(0 表示不平滑)。 - * @return 曲线数值数组(长度与 `signal.values` 相同;缺失处为 NaN)。 - */ - const std::vector& CenterCurveFor(const SignalData& signal, int smooth) const; - - /** - * @brief 取某个时刻的中心平均值曲线取值(用于纵向光标的取值标注)。 + * @brief 统计信号穿越“均值滤波曲线”的上/下边沿数量,结果带缓存。 + * + * 曲线由「均值点数 X + 阶数 N」确定,与「实验性功能 → 均值滤波」的预览曲线逐位一致。 + * * @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] points 均值点数 X。 + * @param [IN] order 阶数 N。 * @param [IN] hasRange 是否只统计某个时间区间(双光标模式下为光标区间)。 * @param [IN] rangeMin 区间左端点时间。 * @param [IN] rangeMax 区间右端点时间。 - * @param [OUT] rising 上升沿数量(信号从下往上越过中心平均值曲线)。 - * @param [OUT] falling 下降沿数量(信号从上往下穿过中心平均值曲线)。 + * @param [OUT] rising 上升沿数量(信号从下往上越过均值滤波曲线)。 + * @param [OUT] falling 下降沿数量(信号从上往下穿过均值滤波曲线)。 */ - void EdgeCenterCountsFor(const DataFile& file, const SignalData& signal, int smooth, + void EdgeCenterCountsFor(const DataFile& file, const SignalData& signal, int points, int order, 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。 + * @brief 切换某个信号在「中心轴边沿计数」面板中的勾选状态。 + * @param [IN] laneIndex 网格下标。 + * @param [IN] ref 目标信号。 + * @return 切换后的勾选状态。 */ - void PaintCenterCurve(gd::Graphics& g, const ResolvedSignal& resolved, const LaneVisual& lane, - int smooth) const; + bool ToggleLaneEdgeCenterSignal(int laneIndex, const SignalRef& ref); /** - * @brief 绘制一段派生曲线(中心平均值 / 多阶均值滤波共用)。 + * @brief 查询某个信号在「中心轴边沿计数」面板中的勾选状态。 + * @param [IN] laneIndex 网格下标。 + * @param [IN] ref 目标信号。 + * @return 已勾选返回 true。 + */ + bool LaneEdgeCenterSignalEnabled(int laneIndex, const SignalRef& ref) const; + + /** + * @brief 绘制一段派生曲线(中心轴均值滤波 / 多阶均值滤波共用同一实现)。 * @param [IN,OUT] g 绘图上下文。 * @param [IN] resolved 信号视图。 * @param [IN] lane 网格绘制信息。 @@ -1341,16 +1351,6 @@ 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 MeanCurveEntry { @@ -1366,7 +1366,8 @@ namespace jdi { const DataFile* file = nullptr; /**< 所属数据分组。 */ const SignalData* signal = nullptr; /**< 目标信号。 */ - int smooth = 0; /**< 中心平均值曲线的平滑半径 N。 */ + int points = 5; /**< 均值点数 X。 */ + int order = 1; /**< 阶数 N。 */ bool hasRange = false; /**< 是否限定在时间区间内统计。 */ bool reusable = false; /**< 计数是否完全由 [begin, end) 决定。 */ std::size_t begin = 0; /**< 已统计的采样区间起点(含)。 */ diff --git a/Components/PlotView/Src/PlotView.cpp b/Components/PlotView/Src/PlotView.cpp index 4211213..992b464 100644 --- a/Components/PlotView/Src/PlotView.cpp +++ b/Components/PlotView/Src/PlotView.cpp @@ -34,6 +34,13 @@ namespace jdi const float kMarkerTriangleArm = 5.0f; /**< 越界方向三角"半宽/半高"(垂直于朝向的半边长)。 */ /** 越界方向三角沿朝向的深度(与光标把手同比例)。 */ const float kMarkerTriangleDepth = kMarkerTriangleArm * 1.4f; + /** + * 视界之外时,堆叠的光标读数之间要留出的间距(逻辑像素)。 + * + * 每个读数胶囊上方都要画一个方向三角(深度 = arm * 1.4 = 7), + * 间距若只有 2 像素,三角就会压在相邻胶囊上 —— 因此单独给一个更大的间距。 + */ + const float kCursorTipStackGap = kMarkerTriangleArm * 1.4f + 4.0f; const size_t kMaxScatterPoints = 2500; /**< 单条散点最大绘制点数。 */ const size_t kPolylineCacheLimit = 64; /**< 折线顶点缓存条目上限。 */ const float kMinScatterRadius = 1.6f; /**< 散点最小半径(逻辑像素)。 */ @@ -57,14 +64,12 @@ 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 float kEdgePanelEditFieldWidth = 64.0f; /**< 数值框的最小宽度。 */ const float kEdgePanelButtonHeight = 30.0f; /**< 均值滤波面板底部按钮行高度。 */ - const float kEdgePanelMeanRowHeight = 22.0f; /**< 均值滤波面板每条信号的名称行高度。 */ - const float kEdgePanelMeanEditHeight = 24.0f; /**< 均值滤波面板每条信号的参数行高度。 */ + const float kEdgePanelMeanRowHeight = 22.0f; /**< 逐条目面板中每条信号的名称行高度。 */ + const float kEdgePanelMeanEditHeight = 24.0f; /**< 逐条目面板中每条信号的参数行高度。 */ const float kEdgePanelMeanRowGap = 2.0f; /**< 名称行与参数行之间的间距。 */ - const float kEdgePanelMeanCheckSize = 15.0f; /**< 均值滤波面板条目复选框尺寸。 */ + const float kEdgePanelMeanCheckSize = 15.0f; /**< 逐条目面板中条目复选框尺寸。 */ const wchar_t* const kMeanFilterBuildLabel = L"构建为新信号"; /**< 均值滤波面板底部按钮文案。 */ const std::size_t kEdgeCountCacheLimit = 48; /**< 边沿统计缓存条目上限(超出时淘汰最早的一条)。 */ @@ -1117,10 +1122,10 @@ namespace jdi void PlotView::InvalidateDerivedCaches() { m_polylineCache.clear(); - // 数据层结构变化后边沿统计与中心平均值曲线的派生结果都会失效。 + // 数据层结构变化后边沿统计与均值滤波曲线的派生结果都会失效。 m_edgeCountCache.clear(); m_edgeCenterCountCache.clear(); - m_centerCurveCache.clear(); + m_meanCurveCache.clear(); // 信号下标可能已经整体位移:清掉按引用缓存的悬停/拖拽目标,避免指向错误的信号。 m_hoverSignal = SignalRef(); m_hoverLegend = SignalRef(); @@ -1356,17 +1361,30 @@ namespace jdi gd::RectF rect = rawRect(which); - // 双光标靠得很近时,两条时间读数标签会互相覆盖: - // 把光标 2 的标签上移一层叠放,保证两侧读数都完整可见(不会被覆盖)。 - if (which == 2 && Tab().CursorMode() == PlotCursorMode::DOUBLE) + // 双光标靠得很近时,两条时间读数标签会互相覆盖:把它们分两行叠放。 + // **小值在上、大值在下**(用户明确要求)——按时间值决定行序, + // 而不是固定让光标 2 上移。左/右光标被拖动交换后排序依然正确。 + if (Tab().CursorMode() == PlotCursorMode::DOUBLE) { const gd::RectF first = rawRect(1); - const float gap = 2.0f * m_scale; - const bool overlapX = (rect.X < first.X + first.Width + gap) && - (first.X < rect.X + rect.Width + gap); + const gd::RectF second = rawRect(2); + // 光标滞留在视界之外时,读数胶囊上方还要放一个方向三角; + // 叠放间距必须容得下它,否则三角会压在相邻胶囊上(看起来很脏)。 + bool offRightA = false; + bool offRightB = false; + const bool offViewAny = CursorOffViewSide(1, offRightA) || CursorOffViewSide(2, offRightB); + const float gap = offViewAny ? kCursorTipStackGap * m_scale : 2.0f * m_scale; + const bool overlapX = (first.X < second.X + second.Width + gap) && + (second.X < first.X + first.Width + gap); if (overlapX) { - rect.Y = std::max(m_plotRect.Y + 2.0f * m_scale, first.Y - rect.Height - gap); + 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; } } @@ -1619,9 +1637,18 @@ namespace jdi lane.edgeCenter = enabled; if (enabled) { - if (lane.edgeCenterSmooth < 0) + // 与「均值滤波」完全一致:默认只勾选网格内**第一个可见信号**。 + if (lane.edgeCenterItems.empty()) { - lane.edgeCenterSmooth = 0; + for (const SignalRef& ref : lane.signals) + { + const ResolvedSignal resolved = ResolveSignal(ref); + if (resolved.data != nullptr && resolved.visible) + { + lane.EnsureEdgeCenterItem(ref); + break; + } + } } lane.edgeCenterPanelOpen = true; } @@ -1639,6 +1666,31 @@ namespace jdi 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())) @@ -1729,50 +1781,6 @@ namespace jdi return true; } - 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; - // 中心平均值曲线与「均值滤波」共用同一实现(DataSet 内的自由函数), - // 因此预览曲线与"构建为新信号"得到的采样值逐位一致。 - ComputeMovingAverage(signal.values, radius, entry.values); - - 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; - } - bool PlotView::MeanFilterValueAt(const DataFile& file, const SignalData& signal, int points, int order, double time, double& value) const { @@ -1820,14 +1828,17 @@ namespace jdi return m_meanCurveCache.back().values; } - void PlotView::EdgeCenterCountsFor(const DataFile& file, const SignalData& signal, int smooth, - bool hasRange, double rangeMin, double rangeMax, + void PlotView::EdgeCenterCountsFor(const DataFile& file, const SignalData& signal, int points, + int order, bool hasRange, double rangeMin, double rangeMax, std::size_t& rising, std::size_t& falling) const { rising = 0; falling = 0; - const std::vector& curve = CenterCurveFor(signal, smooth); + const int radius = std::max(0, points); + const int stages = std::max(1, order); + // 与「实验性功能 → 均值滤波」共用同一条曲线(含缓存)。 + const std::vector& curve = MeanFilterCurveFor(signal, radius, stages); const std::size_t count = std::min(curve.size(), signal.values.size()); std::size_t begin = 0; std::size_t end = 0; @@ -1858,12 +1869,14 @@ namespace jdi return value - static_cast(center); }; - // 按「文件 + 信号 + 平滑半径 + 是否限定区间」定位缓存项(**时间区间不进缓存键**)。 + // 按「文件 + 信号 + 均值点数 X + 阶数 N + 是否限定区间」定位缓存项 + //(**时间区间不进缓存键**)。 EdgeCenterCountEntry* entry = nullptr; for (EdgeCenterCountEntry& candidate : m_edgeCenterCountCache) { if (candidate.file == &file && candidate.signal == &signal && - candidate.hasRange == hasRange && candidate.smooth == smooth) + candidate.hasRange == hasRange && candidate.points == radius && + candidate.order == stages) { entry = &candidate; break; @@ -1878,7 +1891,8 @@ namespace jdi EdgeCenterCountEntry created; created.file = &file; created.signal = &signal; - created.smooth = smooth; + created.points = radius; + created.order = stages; created.hasRange = hasRange; m_edgeCenterCountCache.push_back(created); entry = &m_edgeCenterCountCache.back(); @@ -2072,16 +2086,26 @@ namespace jdi continue; } const float nameWidth = UiMeasureTextWidth(m_fonts->uiSmall, resolved.data->Title()); - if (meanFilter) + if (meanFilter || centerAxis) { - width = std::max(width, kEdgePanelPad * 2.0f * m_scale + nameWidth); - continue; + // 逐条目面板:第一排 = 名称 + 复选框;第三排(中心轴)= 统计结果。 + 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) + { + continue; + } if (centerAxis) { - EdgeCenterCountsFor(*resolved.file, *resolved.data, lane.edgeCenterSmooth, + const Lane::EdgeCenterItem* item = lane.FindEdgeCenterItem(ref); + if (item == nullptr) + { + continue; + } + EdgeCenterCountsFor(*resolved.file, *resolved.data, item->points, item->order, EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), rising, falling); } else @@ -2096,16 +2120,18 @@ namespace jdi width = std::max(width, kEdgePanelPad * 2.0f * m_scale + std::max(nameWidth, countWidth)); } - if (centerAxis) + if (centerAxis || meanFilter) { - // 底部编辑行 = “平滑点数 N”说明文字 + 数值框(与菜单内联编辑同一套排版习惯)。 - const int shown = lane.edgeCenterSmooth; - const std::wstring label = L"平滑点数 N"; - const float editWidth = - UiMeasureTextWidth(m_fonts->uiSmall, label) + kEdgePanelEditFieldGap * m_scale + - std::max(kEdgePanelEditFieldWidth * m_scale, - UiMeasureTextWidth(m_fonts->monoSmall, std::to_wstring(shown)) + 20.0f * m_scale); - width = std::max(width, kEdgePanelPad * 2.0f * m_scale + editWidth); + // 参数行 = 两个「说明文字 + 数值框」,必须保证文字不被数值框压住。 + // (「均值点数 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 (meanFilter) @@ -2114,17 +2140,6 @@ namespace jdi const float buttonWidth = UiMeasureTextWidth(m_fonts->uiSmall, kMeanFilterBuildLabel) + 28.0f * m_scale; width = std::max(width, kEdgePanelPad * 2.0f * m_scale + buttonWidth); - - // 参数行 = 两个「说明文字 + 数值框」,必须保证文字不被数值框压住。 - 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); } // 夹在绘图区宽度之内(过窄的网格也不至于把面板挤到外面)。 @@ -2643,22 +2658,18 @@ namespace jdi return gd::RectF(m_editFieldRect.X, m_editFieldRect.Y, nameWidth, height); } - if (m_editTarget == PlotEditTarget::EDGE_CENTER_SMOOTH || + if (m_editTarget == PlotEditTarget::EDGE_CENTER_POINTS || + m_editTarget == PlotEditTarget::EDGE_CENTER_ORDER || m_editTarget == PlotEditTarget::MEAN_FILTER_POINTS || m_editTarget == PlotEditTarget::MEAN_FILTER_ORDER) { - // 「平滑点数 N」/「均值点数 X」/「阶数 N」:输入框就落在对应的字段矩形上。 + // 「均值点数 X」/「阶数 N」:输入框就落在对应的字段矩形上 + //(中心轴面板与均值滤波面板的字段矩形含义相同)。 if (m_editFieldRect.Width <= 0.0f) { return gd::RectF(); } const float fieldHeight = std::max(height, m_editFieldRect.Height - 4.0f * m_scale); - if (m_editTarget == PlotEditTarget::EDGE_CENTER_SMOOTH) - { - const float fieldWidth = std::max(width, kEdgePanelEditFieldWidth * m_scale); - return gd::RectF(m_editFieldRect.X + m_editFieldRect.Width - fieldWidth, - m_editFieldRect.Y + 3.0f * m_scale, fieldWidth, fieldHeight); - } return gd::RectF(m_editFieldRect.X, m_editFieldRect.Y + 2.0f * m_scale, m_editFieldRect.Width, fieldHeight); } @@ -2819,43 +2830,32 @@ 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; - } - // 「均值点数 X」/「阶数 N」:作用于被勾选信号的均值滤波曲线,夹在合理范围内。 - if (target == PlotEditTarget::MEAN_FILTER_POINTS || target == PlotEditTarget::MEAN_FILTER_ORDER) + // 「中心轴边沿计数」用**完全相同的参数与语义**,只是作用对象是它自己的条目列表。 + const bool centerTarget = + (target == PlotEditTarget::EDGE_CENTER_POINTS || target == PlotEditTarget::EDGE_CENTER_ORDER); + if (target == PlotEditTarget::MEAN_FILTER_POINTS || target == PlotEditTarget::MEAN_FILTER_ORDER || + 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 = - m_tab->Lanes()[static_cast(laneIndex)].FindMeanFilterItem(m_editMeanRef); + centerTarget ? lane.FindEdgeCenterItem(m_editMeanRef) : lane.FindMeanFilterItem(m_editMeanRef); if (existing == nullptr) { return true; } - const int maximum = (target == PlotEditTarget::MEAN_FILTER_POINTS) ? 1000000 : 1000; + const int maximum = isPoints ? 1000000 : 1000; int value = static_cast(std::lround(parsed)); - if (target == PlotEditTarget::MEAN_FILTER_ORDER) - { - // 阶数至少为 1(0 阶等于不做滤波,没有意义)。 - value = std::max(1, std::min(value, maximum)); - } - else - { - value = std::max(0, std::min(value, maximum)); - } + // 阶数至少为 1(0 阶等于不做滤波,没有意义);均值点数可以为 0(不平滑)。 + value = isPoints ? std::max(0, std::min(value, maximum)) + : std::max(1, std::min(value, maximum)); Lane::MeanFilterItem& item = - m_tab->Lanes()[static_cast(laneIndex)].EnsureMeanFilterItem(m_editMeanRef); - if (target == PlotEditTarget::MEAN_FILTER_POINTS) + centerTarget ? lane.EnsureEdgeCenterItem(m_editMeanRef) + : lane.EnsureMeanFilterItem(m_editMeanRef); + if (isPoints) { item.points = value; } @@ -2865,6 +2865,7 @@ namespace jdi } // 参数变化会改变曲线:清掉派生缓存后重绘。 m_meanCurveCache.clear(); + m_edgeCenterCountCache.clear(); InvalidateSceneCache(); return true; } @@ -3072,8 +3073,8 @@ namespace jdi // 边沿统计浮动面板:可自由拖动。 return LoadCursorW(nullptr, IDC_SIZEALL); case PlotDragMode::LANE_REORDER: - // 网格次序拖拽:整块网格跟随鼠标上下移动。 - return LoadCursorW(nullptr, IDC_SIZEALL); + // 网格次序拖拽:上下箭头(只能上下移动)。 + return LoadCursorW(nullptr, IDC_SIZENS); default: break; } @@ -3124,11 +3125,8 @@ namespace jdi } } - // 图例中的 GX 编号徽标:悬停即可上下拖动调整网格次序 → 上下移动光标提示。 - if (LaneBadgeAtPoint(x, y) >= 0) - { - return LoadCursorW(nullptr, IDC_SIZEALL); - } + // 图例中的 GX 编号徽标**悬停时不改变光标样式**(与其它可拖拽区域保持一致); + // 只有真正按下拖拽时才切为上下箭头(见 LANE_REORDER 分支)。 // 光标时间读数标签与双光标差值标签:**悬停(未按下)时不改变鼠标形状**。 // 两个标签都叠在光标线的正上方,因此必须先于"光标线"判定返回 nullptr, @@ -3609,19 +3607,32 @@ namespace jdi m_pendingPanelCloseKind = kind; } - // 均值滤波面板:逐信号条目。 - if (kind == EdgePanelKind::MEAN && m_tab != nullptr) + // 「中心轴边沿计数」/「均值滤波」面板:逐信号条目(同一套排布与交互)。 + if ((kind == EdgePanelKind::MEAN || kind == EdgePanelKind::CENTER) && m_tab != nullptr) { + const bool meanMode = (kind == EdgePanelKind::MEAN); Lane& tabLane = m_tab->Lanes()[static_cast(index)]; 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) : tabLane.FindEdgeCenterItem(ref); + }; // 第一排右侧的复选框 → 切换该信号的预览勾选状态。 if (layout.rowChecks[rowIndex].Width > 0.0f && UiPointInRect(layout.rowChecks[rowIndex], fx, fy)) { - ToggleLaneMeanFilterSignal(index, ref); + if (meanMode) + { + ToggleLaneMeanFilterSignal(index, ref); + } + else + { + ToggleLaneEdgeCenterSignal(index, ref); + } m_meanCurveCache.clear(); + m_edgeCenterCountCache.clear(); InvalidateSceneCache(); m_dragMode = PlotDragMode::NONE; return true; @@ -3630,26 +3641,28 @@ namespace jdi if (layout.rowPoints[rowIndex].Width > 0.0f && UiPointInRect(layout.rowPoints[rowIndex], fx, fy)) { - const Lane::MeanFilterItem* item = tabLane.FindMeanFilterItem(ref); + const Lane::MeanFilterItem* item = findItem(); m_editMeanRef = ref; - BeginEdit(PlotEditTarget::MEAN_FILTER_POINTS, index, - std::to_wstring(item != nullptr ? item->points : 5), + 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.rowOrder[rowIndex].Width > 0.0f && UiPointInRect(layout.rowOrder[rowIndex], fx, fy)) { - const Lane::MeanFilterItem* item = tabLane.FindMeanFilterItem(ref); + const Lane::MeanFilterItem* item = findItem(); m_editMeanRef = ref; - BeginEdit(PlotEditTarget::MEAN_FILTER_ORDER, index, - std::to_wstring(item != nullptr ? item->order : 1), + BeginEdit(meanMode ? PlotEditTarget::MEAN_FILTER_ORDER + : PlotEditTarget::EDGE_CENTER_ORDER, + index, std::to_wstring(item != nullptr ? item->order : 1), layout.rowOrder[rowIndex]); return true; } } - if (layout.button.Width > 0.0f && UiPointInRect(layout.button, fx, fy)) + if (meanMode && layout.button.Width > 0.0f && UiPointInRect(layout.button, fx, fy)) { // 「构建为新信号」:只登记请求(含每个信号自己的 X/N), // 真正的信号构建由宿主(Application)完成。 @@ -3687,16 +3700,6 @@ namespace jdi } } - if (kind == EdgePanelKind::CENTER && layout.edit.Width > 0.0f && - UiPointInRect(layout.edit, fx, fy) && m_tab != nullptr) - { - // 中心轴面板底部编辑行 → 就地编辑平滑点数 N。 - const Lane& tabLane = m_tab->Lanes()[static_cast(index)]; - BeginEdit(PlotEditTarget::EDGE_CENTER_SMOOTH, index, - std::to_wstring(tabLane.edgeCenterSmooth), layout.edit); - return true; - } - if (UiPointInRect(layout.title, fx, fy)) { m_dragMode = PlotDragMode::EDGE_PANEL_MOVE; @@ -4484,8 +4487,18 @@ namespace jdi PaintScrollBar(g); // 绘图区外框(比网格内部分界线更重,形成明确整体边界)。 - gd::Pen borderPen(ToGdiColor(m_theme->axisLine), 1.6f); - g.DrawRectangle(&borderPen, m_plotRect.X, m_plotRect.Y, m_plotRect.Width, m_plotRect.Height); + // 用 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); + } }; // 网格次序拖拽:静态场景缓存仍然有效,但各网格要按位移重新"摆放"。 @@ -4775,9 +4788,17 @@ namespace jdi mixFloat(static_cast(visual.yMax)); const std::vector& signals = Tab().Lanes()[laneIndex].signals; - // 「边沿计数(中心轴)」的开关与平滑点数会改变网格内的曲线绘制 → 参与指纹。 + // 「边沿计数(中心轴)」的开关与每个被勾选信号各自的均值点数 X / 阶数 N + // 会改变网格内的曲线绘制 → 参与指纹。 mix(Tab().Lanes()[laneIndex].edgeCenter ? 1ull : 0ull); - mix(static_cast(Tab().Lanes()[laneIndex].edgeCenterSmooth)); + 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.order)); + } // 「均值滤波」的开关、每个被勾选信号各自的均值点数 X 与阶数 N 同样会改变 // 网格内的曲线绘制 → 参与指纹(面板高度也随勾选数量变化)。 mix(Tab().Lanes()[laneIndex].meanFilter ? 1ull : 0ull); @@ -5133,7 +5154,15 @@ namespace jdi return; } - // 1) 先把"网格区域"整片铺成绘图区底色:让位留下的空档就是它的原色, + // 1) 先把整个绘图区(含时间轴)从静态场景缓存贴回:网格次序拖拽只改变网格内部的 + // 条带位置,时间轴/边框等内容必须原样保留,否则鼠标拖到工具栏或时间轴上时 + // 那一区域会残留上一帧的内容。 + if (!BlitSceneCache(g)) + { + return; + } + + // 2) 把"网格区域"整片铺成绘图区底色:让位留下的空档就是它的原色, // 网格线/波形随后按各自位移贴回。 UiFillRect(g, m_plotRect, m_theme->cardBg); @@ -5189,33 +5218,39 @@ namespace jdi } g.ReleaseHDC(target); - // 2) 目标位置的高亮提示(吸附提示):在被拖网格将要落到的槽位上画一层强调色底 + 边框。 - 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) + // 3) 目标位置的高亮提示与被拖网格的轮廓都**必须夹在网格区域内**绘制: + // 鼠标拖到工具栏/时间轴上时,若不裁剪,强调色边框会画到那些区域上。 { - slotTop -= laneHeight; - } - slotTop = std::max(slotTop, m_plotRect.Y); - const gd::RectF slot(m_plotRect.X, slotTop, m_plotRect.Width, - std::min(laneHeight, m_plotRect.Y + m_plotRect.Height - slotTop)); - 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 int clipState = g.Save(); + g.SetClip(m_plotRect, gd::CombineModeIntersect); - // 3) 被拖网格的轮廓:加强"整块网格被拎起"的观感。 - 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); + // 目标位置的高亮提示(吸附提示):在被拖网格将要落到的槽位上画一层强调色底 + 边框。 + 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) @@ -5463,6 +5498,18 @@ namespace jdi // 幅值网格线(先画线,标尺稍后作为覆盖层绘制,避免被波形遮挡)。 // 刻度密度随网格高度自适应:所有网格高度相同 → 各网格的刻度数量一致, // 不会出现“有的网格只有 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); @@ -5473,6 +5520,11 @@ namespace jdi 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) { // 与主刻度重合的位置跳过(主刻度会以更重的线条绘制)。 @@ -5487,8 +5539,8 @@ namespace jdi { continue; } - UiDrawLine(g, lane.rect.X, y, lane.rect.X + lane.rect.Width, y, - WithAlpha(m_theme->gridWeak, kMinorLineAlpha), 1.0f); + minorSegments.insert(minorSegments.end(), + {lane.rect.X, y, lane.rect.X + lane.rect.Width, y}); } for (const double tick : yTicks) @@ -5499,9 +5551,9 @@ namespace jdi continue; } - const bool zeroLine = (tick == 0.0); - UiDrawLine(g, lane.rect.X, y, lane.rect.X + lane.rect.Width, y, - zeroLine ? m_theme->gridStrong : m_theme->gridWeak, 1.0f); + // 零线用更重的颜色(单独一批,避免为每段颜色切换画笔)。 + std::vector& target = (tick == 0.0) ? strongSegments : weakSegments; + target.insert(target.end(), {lane.rect.X, y, lane.rect.X + lane.rect.Width, y}); } // 时间网格线(同样补充五等分估读线)。 @@ -5523,8 +5575,8 @@ namespace jdi { continue; } - UiDrawLine(g, x, lane.rect.Y, x, lane.rect.Y + lane.rect.Height, - WithAlpha(m_theme->gridWeak, kMinorLineAlpha), 1.0f); + minorSegments.insert(minorSegments.end(), + {x, lane.rect.Y, x, lane.rect.Y + lane.rect.Height}); } for (const double tick : timeTicks) @@ -5534,7 +5586,20 @@ namespace jdi { continue; } - UiDrawLine(g, x, lane.rect.Y, x, lane.rect.Y + lane.rect.Height, m_theme->gridWeak, 1.0f); + 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); } // 波形绘制。 @@ -5551,12 +5616,9 @@ namespace jdi // 只有拖拽中的信号光晕可以进入静态场景缓存(悬停光晕见 PaintHoverHighlight)。 const SignalRef haloRef = SceneHaloRef(); - // 「边沿计数(中心轴)」开启时,为本网格每条信号额外叠加一条中心平均值曲线。 - // 该曲线属于静态场景(只在开关/平滑点数变化时改变),因此参与场景缓存指纹。 - const int centerSmooth = - Tab().Lanes()[static_cast(laneIndex)].edgeCenter - ? Tab().Lanes()[static_cast(laneIndex)].edgeCenterSmooth - : -1; + // 「边沿计数(中心轴)」开启时,为**被勾选**的信号各叠加一条均值滤波曲线 + //(参数 = 该信号自己的条目:均值点数 X + 阶数 N,与「均值滤波」完全同一套语义)。 + const bool centerOn = Tab().Lanes()[static_cast(laneIndex)].edgeCenter; // 「均值滤波」开启时,为**被勾选**的信号各叠加一条多阶滑动平均曲线 //(均值点数 X、阶数 N 由该信号自己的条目给出)。 const bool meanFilterOn = Tab().Lanes()[static_cast(laneIndex)].meanFilter; @@ -5673,11 +5735,17 @@ namespace jdi } } - // 中心平均值曲线(「实验性功能 → 边沿计数(中心轴)」)叠加绘制在原信号之上。 - // 它只是叠加显示的派生曲线,**不会**加入左侧信号列表。 - if (centerSmooth >= 0) + // 中心轴均值滤波曲线(「实验性功能 → 边沿计数(中心轴)」)叠加绘制在原信号之上。 + // 它只是叠加显示的派生曲线,**不会**加入左侧信号列表; + // **只有该信号被勾选时**才绘制(参数与「均值滤波」同源,曲线逐位一致)。 + if (centerOn) { - PaintCenterCurve(g, resolved, lane, centerSmooth); + const Lane::EdgeCenterItem* centerItem = + Tab().Lanes()[static_cast(laneIndex)].FindEdgeCenterItem(ref); + if (centerItem != nullptr) + { + PaintMeanFilterCurve(g, resolved, lane, centerItem->points, centerItem->order); + } } // 均值滤波曲线(「实验性功能 → 均值滤波」):同样是仅用于预览的叠加曲线, // 但**只有该信号被勾选时**才绘制(参数取自它自己的条目)。 @@ -5700,9 +5768,11 @@ namespace jdi const float dividerY = lane.rect.Y; if (dividerY >= m_plotRect.Y - 1.0f && dividerY <= m_plotRect.Y + m_plotRect.Height) { - UiDrawLine(g, lane.rect.X, dividerY, lane.rect.X + lane.rect.Width, dividerY, - (laneIndex == 0) ? m_theme->axisLine : m_theme->borderStrong, - (laneIndex == 0) ? 1.6f : kLaneDividerWidth); + 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)); } // 内侧纵轴标尺作为覆盖层绘制:每个刻度数值带半透明底,保证在波形之上仍清晰可读, @@ -5716,6 +5786,11 @@ namespace jdi 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) @@ -5747,18 +5822,33 @@ namespace jdi } // 与图例重叠时不再跳过:标尺值先绘制,随后绘制的图例会盖在其上层 // (顶层始终为图例),因此两者都不丢失。 - // 但仍要求数值完整落在可见区内——否则会在网格分界处露出被截断的半行数字, - // 且 GDI 文本不受 GDI+ 裁剪约束会越过绘图区(改用 UiTextAtClipped 兜底)。 - if (!IsFullyVisible(pill, clipTop, clipBottom)) + // 数值**完全**移出可见区时才不绘制;只要还有一部分在区内就绘制未被遮挡的部分 + // (裁剪交给 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; } - UiFillRoundRect(g, pill, 3.0f * m_scale, WithAlpha(m_theme->cardBg, 0xF0)); - const std::wstring text = UiEllipsize(raw, m_fonts->monoSmall, pill.Width - 6.0f * m_scale); - UiText(g, text, m_fonts->monoSmall, - gd::RectF(pill.X + 4.0f * m_scale, pill.Y, pill.Width - 8.0f * m_scale, pill.Height), - (tick == 0.0) ? m_theme->text : m_theme->textMuted, TextAlign::LEFT); + 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); } } @@ -5976,7 +6066,13 @@ namespace jdi void PlotView::PaintTimeAxis(gd::Graphics& g, const std::vector& timeTicks, double timeStep) { const float axisY = m_plotRect.Y + m_plotRect.Height; - UiDrawLine(g, m_plotRect.X, axisY, m_plotRect.X + m_plotRect.Width, axisY, m_theme->axisLine, 1.0f); + // 时间轴的轴线与刻度线同样走 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"时间"; @@ -6018,6 +6114,10 @@ namespace jdi 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; @@ -6030,7 +6130,7 @@ namespace jdi { continue; } - UiDrawLine(g, x, axisY, x, axisY + 3.0f * m_scale, m_theme->axisLine, 1.0f); + minorSegments.insert(minorSegments.end(), {x, axisY, x, axisY + 3.0f * m_scale}); } for (const double tick : timeTicks) @@ -6040,8 +6140,24 @@ namespace jdi { 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); + } - UiDrawLine(g, x, axisY, x, axisY + 4.0f * m_scale, m_theme->axisLine, 1.0f); + 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; @@ -6102,6 +6218,31 @@ namespace jdi 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 + kCursorTipStackGap * 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; @@ -6113,6 +6254,53 @@ namespace jdi 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) + { + // 三角画在读数胶囊的**上方**、与胶囊水平居中对齐,尖端指向光标所在的那一侧。 + const float arm = 5.0f * m_scale; + const float depth = arm * 1.4f; + const float centerX = labelRect.X + labelRect.Width * 0.5f; + const float anchorY = std::max(m_plotRect.Y + depth * 0.5f, labelRect.Y - depth * 0.5f - 1.0f * m_scale); + gd::PointF outer[3]; + if (offRight) + { + outer[0] = gd::PointF(centerX - arm, anchorY - depth * 0.5f); + outer[1] = gd::PointF(centerX - arm, anchorY + depth * 0.5f); + outer[2] = gd::PointF(centerX + arm, anchorY); + } + else + { + outer[0] = gd::PointF(centerX + arm, anchorY - depth * 0.5f); + outer[1] = gd::PointF(centerX + arm, anchorY + depth * 0.5f); + outer[2] = gd::PointF(centerX - arm, anchorY); + } + 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) @@ -6286,17 +6474,12 @@ namespace jdi // 「边沿计数(中心轴)」/「均值滤波」开启时,纵向光标还要额外标注**派生曲线** // 在该时刻的取值(它不是信号,因此单独标注,不并入上面的取值文案)。 - // 两条曲线的标注规则完全一致,故共用同一段绘制逻辑。 - const auto paintDerivedCapsule = [&](int first, int second, const std::wstring& suffix, - bool meanFilter) + // 两条曲线的参数与语义完全一致,故共用同一段绘制逻辑。 + const auto paintDerivedCapsule = [&](int first, int second, const std::wstring& suffix) { double derivedValue = 0.0; - const bool ok = - meanFilter ? MeanFilterValueAt(*resolved.file, *resolved.data, first, second, - time, derivedValue) - : CenterValueAt(*resolved.file, *resolved.data, first, time, - derivedValue); - if (!ok) + if (!MeanFilterValueAt(*resolved.file, *resolved.data, first, second, time, + derivedValue)) { return; } @@ -6315,13 +6498,8 @@ namespace jdi if (showDelta) { double otherDerived = 0.0; - const bool otherOk = - meanFilter - ? MeanFilterValueAt(*resolved.file, *resolved.data, first, second, - otherTime, otherDerived) - : CenterValueAt(*resolved.file, *resolved.data, first, otherTime, - otherDerived); - if (otherOk) + if (MeanFilterValueAt(*resolved.file, *resolved.data, first, second, + otherTime, otherDerived)) { const double delta = derivedValue - otherDerived; centerText += (delta < 0.0) ? L"-" : L"+"; @@ -6366,8 +6544,13 @@ namespace jdi if (m_tab->Lanes()[laneIndex].edgeCenter) { - paintDerivedCapsule(m_tab->Lanes()[laneIndex].edgeCenterSmooth, 0, - L"中心轴", false); + // 只标注被勾选(即真正绘制了曲线)的信号。 + const Lane::EdgeCenterItem* centerItem = + m_tab->Lanes()[laneIndex].FindEdgeCenterItem(ref); + if (centerItem != nullptr) + { + paintDerivedCapsule(centerItem->points, centerItem->order, L"中心轴"); + } } if (m_tab->Lanes()[laneIndex].meanFilter) { @@ -6376,7 +6559,7 @@ namespace jdi m_tab->Lanes()[laneIndex].FindMeanFilterItem(ref); if (meanItem != nullptr) { - paintDerivedCapsule(meanItem->points, meanItem->order, L"均值滤波", true); + paintDerivedCapsule(meanItem->points, meanItem->order, L"均值滤波"); } } } @@ -6401,6 +6584,12 @@ namespace jdi } 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); @@ -6419,6 +6608,14 @@ namespace jdi { // 样式与光标时间读数完全一致(同底色/同描边/同文字配色)。 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); @@ -6472,17 +6669,18 @@ namespace jdi const float titleHeight = kEdgePanelTitleHeight * m_scale; const float rowHeight = kEdgePanelRowHeight * m_scale; const float pad = kEdgePanelPad * m_scale; - // 中心轴 / 均值滤波面板在底部固定多出一行编辑行;均值滤波再多一个按钮行。 - const float editHeight = - (centerAxis || meanFilter) ? (kEdgePanelEditHeight * m_scale) : 0.0f; + // 「中心轴边沿计数」与「均值滤波」共用同一套「逐条目」排布(三排/两排), + // 因此不再有独立的底部编辑行;只有「边沿计数」保留两行文本式排版。 const float buttonHeight = meanFilter ? (kEdgePanelButtonHeight * m_scale) : 0.0f; const float rowsPerSignal = meanFilter ? 1.0f : 2.0f; + const bool itemized = meanFilter || centerAxis; - // 均值滤波面板:每个条目 = 第一排(信号名 + 复选框)+ 第二排(均值点数 X / 阶数 N), - // 第二排**只在该条目被勾选时才占位**,因此面板高度随勾选数量动态变化。 + // 逐条目面板:每个条目 = 第一排(信号名 + 复选框)+ 第二排(均值点数 X / 阶数 N) + // + (中心轴)第三排(统计结果);后两排**只在该条目被勾选时才占位**, + // 因此面板高度随勾选数量动态变化。 float meanRowHeight = 0.0f; float height = 0.0f; - if (meanFilter) + if (itemized) { meanRowHeight = kEdgePanelMeanRowHeight * m_scale; float itemsHeight = 0.0f; @@ -6494,17 +6692,21 @@ namespace jdi continue; } itemsHeight += meanRowHeight; - if (lane.MeanFilterEnabled(ref)) + const bool checked = meanFilter ? lane.MeanFilterEnabled(ref) : lane.EdgeCenterEnabled(ref); + if (checked) { itemsHeight += (kEdgePanelMeanEditHeight + kEdgePanelMeanRowGap) * m_scale; + if (centerAxis) + { + itemsHeight += kEdgePanelMeanRowHeight * m_scale; + } } } height = titleHeight + pad + itemsHeight + pad + buttonHeight; } else { - height = titleHeight + pad + static_cast(rowCount) * rowHeight * rowsPerSignal + pad + - editHeight; + height = titleHeight + pad + static_cast(rowCount) * rowHeight * rowsPerSignal + pad; } // 首次使用(位置为负)时自动放到绘图区右侧,并各网格依次错开,避免完全重叠。 @@ -6531,21 +6733,18 @@ namespace jdi layout.title = gd::RectF(left, top, width, titleHeight); const float closeSize = titleHeight; layout.close = gd::RectF(left + width - closeSize, top, closeSize, closeSize); - if (centerAxis || meanFilter) - { - const float editRowHeight = kEdgePanelEditHeight * m_scale; - const float editTop = top + height - buttonHeight - editRowHeight; - layout.edit = gd::RectF(left + pad, editTop, std::max(0.0f, width - pad * 2.0f), - editRowHeight); - } - if (meanFilter) + if (itemized) { const float buttonRowHeight = kEdgePanelButtonHeight * m_scale; - layout.button = gd::RectF(left + pad, top + height - buttonRowHeight, - std::max(0.0f, width - pad * 2.0f), buttonRowHeight); + if (meanFilter) + { + layout.button = gd::RectF(left + pad, top + height - buttonRowHeight, + std::max(0.0f, width - pad * 2.0f), buttonRowHeight); + } // 逐信号条目:第一排 = 名称 + 复选框(复选框靠右); - // 第二排 = 「均值点数 X」与「阶数 N」两个输入框(仅勾选时存在)。 + // 第二排 = 「均值点数 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); @@ -6562,7 +6761,8 @@ namespace jdi layout.rowChecks.push_back(gd::RectF( left + width - pad - checkSize, rowY + (meanRowHeight - checkSize) * 0.5f, checkSize, checkSize)); - const bool checked = lane.MeanFilterEnabled(ref); + const bool checked = meanFilter ? lane.MeanFilterEnabled(ref) + : lane.EdgeCenterEnabled(ref); if (checked) { const float editTop = rowY + meanRowHeight; @@ -6570,17 +6770,32 @@ namespace jdi gd::RectF(left + pad, editTop, halfWidth, fieldHeight)); layout.rowOrder.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.rowOrder.push_back(gd::RectF()); + layout.rowStats.push_back(gd::RectF()); } rowY += meanRowHeight; if (checked) { rowY += (kEdgePanelMeanEditHeight + kEdgePanelMeanRowGap) * m_scale; + if (centerAxis) + { + rowY += kEdgePanelMeanRowHeight * m_scale; + } } } } @@ -6799,16 +7014,6 @@ namespace jdi 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; - } - PaintDerivedCurve(g, resolved, lane, CenterCurveFor(*resolved.data, smooth)); - } - void PlotView::PaintDerivedCurve(gd::Graphics& g, const ResolvedSignal& resolved, const LaneVisual& lane, const std::vector& curve) const { @@ -6984,11 +7189,13 @@ namespace jdi 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 + 3.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)); @@ -6996,6 +7203,36 @@ namespace jdi 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); + } } // ---- 统计浮动面板(最后绘制,覆盖在波形之上)---- @@ -7068,10 +7305,10 @@ namespace jdi closeRect.X + crossPad, closeRect.Y + closeRect.Height - crossPad, crossColor, 1.6f * m_scale); - // 每个信号两行:名称(信号色)+ 上升沿/下降沿数量; - // 均值滤波面板改为「逐条目」:第一排 = 名称 + 复选框, - // 第二排 = 均值点数 X / 阶数 N(仅勾选的条目才有第二排)。 - if (meanFilter) + // 「中心轴边沿计数」/「均值滤波」面板都采用「逐条目」排布: + // 第一排 = 名称 + 复选框,第二排 = 均值点数 X / 阶数 N(仅勾选的条目), + // 中心轴再多第三排 = 对应的统计结果(同样仅勾选的条目)。 + if (meanFilter || centerAxis) { for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex) { @@ -7083,7 +7320,8 @@ namespace jdi } const std::uint32_t signalColor = StyleColor(*resolved.style); const gd::RectF& checkRect = layout.rowChecks[rowIndex]; - const bool checked = tabLane.MeanFilterEnabled(ref); + const bool checked = meanFilter ? tabLane.MeanFilterEnabled(ref) + : tabLane.EdgeCenterEnabled(ref); // 名称:第一排左侧,避开右侧复选框。 const float nameWidth = @@ -7101,9 +7339,14 @@ namespace jdi } // 第二排:两个输入框,左侧标“均值点数 X”,右侧标“阶数 N”。 - const Lane::MeanFilterItem* item = tabLane.FindMeanFilterItem(ref); + 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->order : 1; + const PlotEditTarget pointsTarget = meanFilter ? PlotEditTarget::MEAN_FILTER_POINTS + : PlotEditTarget::EDGE_CENTER_POINTS; + const PlotEditTarget orderTarget = meanFilter ? PlotEditTarget::MEAN_FILTER_ORDER + : PlotEditTarget::EDGE_CENTER_ORDER; const auto paintField = [&](const gd::RectF& rect, const wchar_t* label, int value, PlotEditTarget target) { @@ -7140,10 +7383,26 @@ namespace jdi m_theme->textStrong, TextAlign::CENTER); } }; - paintField(layout.rowPoints[rowIndex], L"均值点数 X", shownPoints, - PlotEditTarget::MEAN_FILTER_POINTS); - paintField(layout.rowOrder[rowIndex], L"阶数 N", shownOrder, - PlotEditTarget::MEAN_FILTER_ORDER); + paintField(layout.rowPoints[rowIndex], L"均值点数 X", shownPoints, pointsTarget); + paintField(layout.rowOrder[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->order : 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); + } } } @@ -7155,9 +7414,9 @@ namespace jdi { continue; } - if (meanFilter) + if (meanFilter || centerAxis) { - // 均值滤波的逐条目绘制已在上面完成。 + // 逐条目绘制已在上面完成。 continue; } const std::uint32_t signalColor = StyleColor(*resolved.style); @@ -7169,17 +7428,8 @@ namespace jdi 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); - } + 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, @@ -7188,44 +7438,6 @@ namespace jdi rowY += rowHeight * 2.0f; } - // 中心轴面板底部固定一行参数编辑设置(始终位于面板最下方)。 - if (centerAxis) - { - const gd::RectF& editRect = layout.edit; - if (editRect.Width > 0.0f) - { - const int shownValue = tabLane.edgeCenterSmooth; - 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(shownValue)) + - 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(shownValue), m_fonts->monoSmall, fieldRect, - m_theme->textStrong, TextAlign::CENTER); - } - } - } - // 均值滤波面板底部按钮行:「构建为新信号」(悬停高亮)。 if (meanFilter) { diff --git a/Utilities/Inc/CsvTable.hpp b/Utilities/Inc/CsvTable.hpp index 9412c24..9012f31 100644 --- a/Utilities/Inc/CsvTable.hpp +++ b/Utilities/Inc/CsvTable.hpp @@ -35,13 +35,26 @@ namespace jdi } }; + /** + * @brief 加载进度信息。 + * + * 解析阶段会携带真实的"已读 / 总字节数";列重组与统计阶段没有字节可计, + * 此时 `hasBytes` 为 false,界面应只展示百分比。 + */ + struct CsvProgressInfo + { + double ratio = 0.0; /**< 0-1 总体完成比例。 */ + unsigned long long bytesRead = 0; /**< 已读取字节数。 */ + unsigned long long totalBytes = 0; /**< 文件总字节数。 */ + bool hasBytes = false; /**< 上述字节数字段是否有效。 */ + }; + /** * @brief 解析进度回调。 * - * 在解析过程中按块回调,ratio 为 0.0-1.0 的完成比例; - * 返回 false 表示请求取消,解析会尽快停止并返回失败。 + * 在解析过程中按块回调;返回 false 表示请求取消,解析会尽快停止并返回失败。 */ - using CsvProgress = std::function; + using CsvProgress = std::function; /** * @brief 读取并解析一个 CSV 文件。 diff --git a/Utilities/Inc/DataModel.hpp b/Utilities/Inc/DataModel.hpp index e962209..a20fd82 100644 --- a/Utilities/Inc/DataModel.hpp +++ b/Utilities/Inc/DataModel.hpp @@ -360,6 +360,8 @@ namespace jdi * * 仅参与列表中的信号才会被勾选预览;参数按信号保存, * 因此同一条网格内不同信号可以使用不同的均值点数与阶数。 + * 「边沿计数(中心轴)」的平滑参数与它**完全同构**(同一套命名与作用), + * 见 `EdgeCenterItem`。 */ struct MeanFilterItem { @@ -368,6 +370,9 @@ namespace jdi int order = 1; /**< 阶数 N(重复滤波次数,至少 1)。 */ }; + /** 中心轴边沿计数的平滑参数:与均值滤波共用同一结构与同一套语义。 */ + using EdgeCenterItem = MeanFilterItem; + std::wstring name; /**< 网格名称。 */ std::vector signals; /**< 该网格包含的信号(按加入顺序)。 */ bool autoRange = true; /**< 是否依据信号取值范围自动计算 Y 轴。 */ @@ -408,14 +413,34 @@ namespace jdi /** * @brief 「实验性功能 → 边沿计数(中心轴)」开关。 * - * 开启后为本网格内每条信号额外绘制一条"中心平均值曲线"(每个数据点取自身与左右各 - * `edgeCenterSmooth` 个点的平均值),并按“信号穿越中心平均值曲线”统计上/下边沿。 + * 开启后为本网格内**被勾选**的信号额外绘制一条均值滤波曲线(与「实验性功能 → 均值滤波」 + * 用的是同一套参数:均值点数 X + 阶数 N),并按“信号穿越该滤波曲线”统计上/下边沿。 * 这条曲线只是叠加显示,不会作为信号加入左侧列表。 */ bool edgeCenter = false; - /** 中心平均值的平滑半径 N(左右各取 N 个数据点,窗口大小 2N+1)。 */ - int edgeCenterSmooth = 5; + /** + * @brief 参与中心轴边沿计数的信号及其平滑参数(只包含被勾选的信号)。 + * + * 参数语义与 `meanFilterItems` **完全一致**(`points` = 均值点数 X, + * `order` = 阶数 N),因此两条曲线的数值逐位相同。 + */ + std::vector edgeCenterItems; + + /** 取某个信号的中心轴平滑参数(未勾选时返回空指针)。 */ + const EdgeCenterItem* FindEdgeCenterItem(const SignalRef& ref) const; + + /** 取得或创建某信号的中心轴平滑参数(用于勾选)。 */ + EdgeCenterItem& EnsureEdgeCenterItem(const SignalRef& ref); + + /** 取消某信号的中心轴边沿计数勾选。 */ + void RemoveEdgeCenterItem(const SignalRef& ref); + + /** 取某信号的中心轴边沿计数勾选状态。 */ + bool EdgeCenterEnabled(const SignalRef& ref) const + { + return FindEdgeCenterItem(ref) != nullptr; + } /** 中心轴统计浮动面板是否展开。 */ bool edgeCenterPanelOpen = true; diff --git a/Utilities/Inc/PaintUtil.hpp b/Utilities/Inc/PaintUtil.hpp index 6182bc4..af84764 100644 --- a/Utilities/Inc/PaintUtil.hpp +++ b/Utilities/Inc/PaintUtil.hpp @@ -5,8 +5,10 @@ #include #include +#include #include #include +#include #include "TextEdit.hpp" #include "Theme.hpp" @@ -224,6 +226,50 @@ namespace jdi */ void UiDrawPlusIcon(gd::Graphics& g, const gd::RectF& rect, std::uint32_t argb, float strokeWidth); + /** + * @brief 用 GDI 一次性绘制若干条**不透明 1px 实线**(每条线段相互独立)。 + * + * 性能关键:GDI+ 的 `DrawLine` 实测每条约 0.1–0.19 ms(带抗锯齿的长线更慢), + * 而一张网格通常要画几十条网格线 —— 逐条调用会让单帧多花十几毫秒。 + * 这里改用 GDI(一次取 HDC + 一支画笔 + 多个 LineTo),快一两个数量级, + * 而且像素级网格线本来就不需要抗锯齿,观感反而更清晰。 + * + * ⚠️ GDI 绘制**不受 GDI+ 裁剪区约束**,因此必须显式传入裁剪矩形 + * (内部用 `SaveDC` + `IntersectClipRect` 保证不越界)。 + * ⚠️ 只能用于**不透明**颜色(GDI 不做 alpha 混合);半透明色需调用方先与底色混合。 + * + * @param [IN,OUT] g 绘图上下文。 + * @param [IN] clip 裁剪矩形(客户区坐标)。 + * @param [IN] segments 线段数组,每 4 个 float 为一条(x1,y1,x2,y2)。 + * @param [IN] segmentCount 线段数量。 + * @param [IN] argb 颜色(Alpha 必须为 0xFF,否则不做任何绘制)。 + * @param [IN] width 线宽(像素,>= 1;仅精确支持 1,更宽时按整数宽画)。 + */ + void UiDrawSolidLines(gd::Graphics& g, const gd::RectF& clip, const float* segments, + std::size_t segmentCount, std::uint32_t argb, int width = 1); + + /** + * @brief 把半透明颜色与已知不透明底色混合,得到可直接用于 GDI 的不透明颜色。 + * @param [IN] background 不透明底色(0xFFRRGGBB)。 + * @param [IN] argb 待叠加颜色(含 alpha)。 + * @return 混合后的不透明颜色。 + */ + std::uint32_t UiBlendOver(std::uint32_t background, std::uint32_t argb); + + /** + * @brief 一次性填充多个圆角矩形(同色同圆角)。 + * + * 每帧要画几十个刻度数值底板时,逐个调用 `UiFillRoundRect` 会带来可观的 GDI+ 调用开销; + * 合并成一条 GraphicsPath 后只 FillPath 一次(实测约快 3 倍)。 + * + * @param [IN,OUT] g 绘图上下文。 + * @param [IN] rects 矩形集合。 + * @param [IN] radius 圆角半径。 + * @param [IN] argb 填充色。 + */ + void UiFillRoundRects(gd::Graphics& g, const std::vector& rects, float radius, + std::uint32_t argb); + /** * @brief 在矩形中心绘制一个叉号(关闭)图标。 * @param [IN,OUT] g 绘图上下文。 diff --git a/Utilities/Inc/Utf8Util.hpp b/Utilities/Inc/Utf8Util.hpp index e38508b..5e3ab18 100644 --- a/Utilities/Inc/Utf8Util.hpp +++ b/Utilities/Inc/Utf8Util.hpp @@ -64,6 +64,17 @@ namespace jdi */ std::wstring FormatNumber(double value, int decimals); + /** + * @brief 把字节数格式化为自适应单位的文本(GB / MB / KB),保留两位小数。 + * + * 单位阀值:1 GB = 1024 MB、1 MB = 1024 KB;小于 1 KB 时也按 KB 显示。 + * 只输出"数字 + 单位"(如 "1.69 GB"),便于拼接为"已读取 / 总量"。 + * + * @param [IN] bytes 字节数。 + * @return 形如 "812.34 MB" 的文本。 + */ + std::wstring FormatByteSize(unsigned long long bytes); + /** * @brief 以自适应有效位格式化数值(NaN/Inf 会被转换为可读文本)。 * @param [IN] value 待格式化数值。 diff --git a/Utilities/Src/CsvTable.cpp b/Utilities/Src/CsvTable.cpp index b9ad67d..4e26f93 100644 --- a/Utilities/Src/CsvTable.cpp +++ b/Utilities/Src/CsvTable.cpp @@ -397,11 +397,18 @@ namespace jdi if (progress) { - const double ratio = (fileSize > 0) - ? static_cast(state.bytesConsumed) / - static_cast(fileSize) - : 0.0; - if (!progress(ratio > 1.0 ? 1.0 : ratio)) + CsvProgressInfo info; + info.bytesRead = state.bytesConsumed; + info.totalBytes = fileSize; + info.hasBytes = true; + info.ratio = (fileSize > 0) ? static_cast(state.bytesConsumed) / + static_cast(fileSize) + : 0.0; + if (info.ratio > 1.0) + { + info.ratio = 1.0; + } + if (!progress(info)) { cancelled = true; } diff --git a/Utilities/Src/DataModel.cpp b/Utilities/Src/DataModel.cpp index 91f139f..eb52db0 100644 --- a/Utilities/Src/DataModel.cpp +++ b/Utilities/Src/DataModel.cpp @@ -122,7 +122,7 @@ namespace jdi double edgePanelX = -1.0; double edgePanelY = -1.0; bool edgeCenter = false; - int edgeCenterSmooth = 5; + std::vector edgeCenterItems; bool edgeCenterPanelOpen = true; double edgeCenterPanelX = -1.0; double edgeCenterPanelY = -1.0; @@ -147,7 +147,7 @@ namespace jdi carry.edgePanelX = lane.edgePanelX; carry.edgePanelY = lane.edgePanelY; carry.edgeCenter = lane.edgeCenter; - carry.edgeCenterSmooth = lane.edgeCenterSmooth; + carry.edgeCenterItems = lane.edgeCenterItems; carry.edgeCenterPanelOpen = lane.edgeCenterPanelOpen; carry.edgeCenterPanelX = lane.edgeCenterPanelX; carry.edgeCenterPanelY = lane.edgeCenterPanelY; @@ -183,7 +183,7 @@ namespace jdi lane.edgePanelX = carry.edgePanelX; lane.edgePanelY = carry.edgePanelY; lane.edgeCenter = carry.edgeCenter; - lane.edgeCenterSmooth = carry.edgeCenterSmooth; + lane.edgeCenterItems = carry.edgeCenterItems; lane.edgeCenterPanelOpen = carry.edgeCenterPanelOpen; lane.edgeCenterPanelX = carry.edgeCenterPanelX; lane.edgeCenterPanelY = carry.edgeCenterPanelY; @@ -571,11 +571,15 @@ namespace jdi file.signals.push_back(std::move(signal)); - if (progress && !progress(0.75 + 0.25 * static_cast(column + 1) / - static_cast(columnCount))) + if (progress) { - error = L"已取消加载。"; - return false; + CsvProgressInfo info; + info.ratio = 0.75 + 0.25 * static_cast(column + 1) / static_cast(columnCount); + if (!progress(info)) + { + error = L"已取消加载。"; + return false; + } } } @@ -1030,6 +1034,41 @@ namespace jdi meanFilterItems.end()); } + const Lane::EdgeCenterItem* Lane::FindEdgeCenterItem(const SignalRef& ref) const + { + for (const EdgeCenterItem& item : edgeCenterItems) + { + if (item.ref == ref) + { + return &item; + } + } + return nullptr; + } + + Lane::EdgeCenterItem& Lane::EnsureEdgeCenterItem(const SignalRef& ref) + { + for (EdgeCenterItem& item : edgeCenterItems) + { + if (item.ref == ref) + { + return item; + } + } + EdgeCenterItem item; + item.ref = ref; + edgeCenterItems.push_back(item); + return edgeCenterItems.back(); + } + + void Lane::RemoveEdgeCenterItem(const SignalRef& ref) + { + edgeCenterItems.erase( + std::remove_if(edgeCenterItems.begin(), edgeCenterItems.end(), + [&ref](const EdgeCenterItem& item) { return item.ref == ref; }), + edgeCenterItems.end()); + } + int FindNearestSample(const DataFile& file, double time) { const std::size_t count = file.time.size(); @@ -1254,22 +1293,26 @@ namespace jdi } } - // 均值滤波条目同样持有信号引用,必须一起删除/前移。 - std::vector& items = lane.meanFilterItems; - items.erase(std::remove_if(items.begin(), items.end(), - [fileIndex, signalIndex](const Lane::MeanFilterItem& item) - { - return item.ref.fileIndex == fileIndex && - item.ref.signalIndex == signalIndex; - }), - items.end()); - for (Lane::MeanFilterItem& item : items) + // 均值滤波 / 中心轴边沿计数的条目同样持有信号引用,必须一起删除/前移。 + const auto purgeRefs = [fileIndex, signalIndex](std::vector& items) { - if (item.ref.fileIndex == fileIndex && item.ref.signalIndex > signalIndex) + items.erase(std::remove_if(items.begin(), items.end(), + [fileIndex, signalIndex](const Lane::MeanFilterItem& item) + { + return item.ref.fileIndex == fileIndex && + item.ref.signalIndex == signalIndex; + }), + items.end()); + for (Lane::MeanFilterItem& item : items) { - --item.ref.signalIndex; + if (item.ref.fileIndex == fileIndex && item.ref.signalIndex > signalIndex) + { + --item.ref.signalIndex; + } } - } + }; + purgeRefs(lane.meanFilterItems); + purgeRefs(lane.edgeCenterItems); } // 被移除信号若独占了某个网格,该网格随之消失(与取消勾选的行为一致)。 m_lanes.erase(std::remove_if(m_lanes.begin(), m_lanes.end(), @@ -1558,13 +1601,13 @@ namespace jdi lane.edgePanelX = source.edgePanelX; lane.edgePanelY = source.edgePanelY; lane.edgeCenter = source.edgeCenter; - lane.edgeCenterSmooth = source.edgeCenterSmooth; lane.edgeCenterPanelOpen = source.edgeCenterPanelOpen; lane.edgeCenterPanelX = source.edgeCenterPanelX; lane.edgeCenterPanelY = source.edgeCenterPanelY; lane.meanFilter = source.meanFilter; - // 拆分后每个子网格只保留自己那个信号的均值滤波条目。 + // 拆分后每个子网格只保留自己那个信号的均值滤波 / 中心轴条目。 lane.meanFilterItems.clear(); + lane.edgeCenterItems.clear(); for (const Lane::MeanFilterItem& item : source.meanFilterItems) { if (item.ref == ref) @@ -1572,6 +1615,13 @@ namespace jdi lane.meanFilterItems.push_back(item); } } + for (const Lane::EdgeCenterItem& item : source.edgeCenterItems) + { + if (item.ref == ref) + { + lane.edgeCenterItems.push_back(item); + } + } lane.meanFilterPanelOpen = source.meanFilterPanelOpen; lane.meanFilterPanelX = source.meanFilterPanelX; lane.meanFilterPanelY = source.meanFilterPanelY; @@ -1615,7 +1665,7 @@ namespace jdi ++ref.signalIndex; } } - // 均值滤波条目同样持有信号引用,必须一起后移。 + // 均值滤波 / 中心轴条目同样持有信号引用,必须一起后移。 for (Lane::MeanFilterItem& item : lane.meanFilterItems) { if (item.ref.fileIndex == fileIndex && item.ref.signalIndex >= insertPos) @@ -1623,6 +1673,13 @@ namespace jdi ++item.ref.signalIndex; } } + for (Lane::EdgeCenterItem& item : lane.edgeCenterItems) + { + if (item.ref.fileIndex == fileIndex && item.ref.signalIndex >= insertPos) + { + ++item.ref.signalIndex; + } + } } } diff --git a/Utilities/Src/PaintUtil.cpp b/Utilities/Src/PaintUtil.cpp index d2ab49b..b8d5712 100644 --- a/Utilities/Src/PaintUtil.cpp +++ b/Utilities/Src/PaintUtil.cpp @@ -194,6 +194,109 @@ namespace jdi g.DrawPath(&pen, &path); } + void UiFillRoundRects(gd::Graphics& g, const std::vector& rects, float radius, + std::uint32_t argb) + { + if (rects.empty()) + { + return; + } + gd::GraphicsPath path; + for (const gd::RectF& rect : rects) + { + if (rect.Width <= 0.0f || rect.Height <= 0.0f) + { + continue; + } + BuildRoundRectPath(path, rect, radius); + } + if (path.GetPointCount() > 0) + { + gd::SolidBrush brush(ToGdiColor(argb)); + g.FillPath(&brush, &path); + } + } + + std::uint32_t UiBlendOver(std::uint32_t background, std::uint32_t argb) + { + const unsigned int alpha = (argb >> 24) & 0xFFu; + if (alpha >= 0xFFu) + { + return argb | 0xFF000000u; + } + if (alpha == 0u) + { + return background | 0xFF000000u; + } + const double ratio = static_cast(alpha) / 255.0; + std::uint32_t result = 0xFF000000u; + for (int shift = 0; shift <= 16; shift += 8) + { + const double top = static_cast((argb >> shift) & 0xFFu); + const double bottom = static_cast((background >> shift) & 0xFFu); + const double mixed = bottom + (top - bottom) * ratio; + result |= (static_cast(mixed + 0.5) & 0xFFu) << shift; + } + return result; + } + + void UiDrawSolidLines(gd::Graphics& g, const gd::RectF& clip, const float* segments, + std::size_t segmentCount, std::uint32_t argb, int width) + { + if (segments == nullptr || segmentCount == 0 || (argb & 0xFF000000u) != 0xFF000000u) + { + return; + } + if (width < 1) + { + width = 1; + } + + const int clipLeft = static_cast(std::floor(clip.X)); + const int clipTop = static_cast(std::floor(clip.Y)); + const int clipRight = static_cast(std::ceil(clip.X + clip.Width)); + const int clipBottom = static_cast(std::ceil(clip.Y + clip.Height)); + if (clipRight <= clipLeft || clipBottom <= clipTop) + { + return; + } + + HDC hdc = g.GetHDC(); + if (hdc == nullptr) + { + return; + } + + // GDI 绘制不受 GDI+ 裁剪约束,这里显式设置并随后完整还原 DC 状态。 + const int saved = SaveDC(hdc); + if (saved != 0) + { + IntersectClipRect(hdc, clipLeft, clipTop, clipRight, clipBottom); + } + + HPEN pen = CreatePen(PS_SOLID, width, ToColorRef(argb)); + if (pen != nullptr) + { + HGDIOBJ oldPen = SelectObject(hdc, pen); + for (std::size_t index = 0; index < segmentCount; ++index) + { + const float* segment = segments + index * 4; + MoveToEx(hdc, static_cast(std::lround(segment[0])), + static_cast(std::lround(segment[1])), nullptr); + LineTo(hdc, static_cast(std::lround(segment[2])), + static_cast(std::lround(segment[3]))); + } + SelectObject(hdc, oldPen); + DeleteObject(pen); + } + + if (saved != 0) + { + RestoreDC(hdc, saved); + } + g.ReleaseHDC(hdc); + } + void UiDrawLine(gd::Graphics& g, float x1, float y1, float x2, float y2, std::uint32_t argb, float width) { gd::Pen pen(ToGdiColor(argb), width); diff --git a/Utilities/Src/Utf8Util.cpp b/Utilities/Src/Utf8Util.cpp index ea1467c..b5bb3f9 100644 --- a/Utilities/Src/Utf8Util.cpp +++ b/Utilities/Src/Utf8Util.cpp @@ -339,6 +339,35 @@ namespace jdi return std::wstring(buffer); } + std::wstring FormatByteSize(unsigned long long bytes) + { + // 单位自适应:GB / MB / KB(阈值按 1024 进制)。 + constexpr double kKilo = 1024.0; + constexpr double kMega = kKilo * 1024.0; + constexpr double kGiga = kMega * 1024.0; + + const double value = static_cast(bytes); + const wchar_t* unit = L"KB"; + double scaled = value / kKilo; + if (value >= kGiga) + { + unit = L"GB"; + scaled = value / kGiga; + } + else if (value >= kMega) + { + unit = L"MB"; + scaled = value / kMega; + } + + wchar_t buffer[64]; + // ⚠️ 宽字符格式化必须用 `%ls`:MinGW 的 swprintf 按 C99 语义处理 `%s` + //(在宽格式串里 `%s` 表示**窄**字符串),写成 `%s` 会把宽字符串按单字节读取, + // 于是 "KB" 只打印出 "K"。 + swprintf(buffer, sizeof(buffer) / sizeof(buffer[0]), L"%.2f %ls", scaled, unit); + return std::wstring(buffer); + } + std::wstring FormatValue(double value) { if (std::isnan(value))