diff --git a/Components/Application/Src/Application.cpp b/Components/Application/Src/Application.cpp index 832197b..c511a20 100644 --- a/Components/Application/Src/Application.cpp +++ b/Components/Application/Src/Application.cpp @@ -1535,9 +1535,10 @@ namespace jdi // * 数值域变换(系数 → 绝对值 → 垂直镜像 → Y 偏移)**与**水平镜像(数据反序) // 全部作用在派生采样值上; // * 于是新信号**用默认外观**绘制出来就与预览曲线逐位一致。 - // * ⚠️ **时间轴偏移是唯一的例外**:它改变的是"绘制在哪个时刻",而派生信号与源信号 - // 共用同一个文件时间轴,无法把时间平移烘进采样数据,因此仍由新信号的样式携带 - // (否则新信号会相对预览整体横向错位)。 + // * ⚠️ **时间轴偏移也一并烘进数据**(见 2c:按 `time[i] - offsetTime` 重采样)。 + // 新信号与源信号共用同一个文件时间轴,因此时间平移只能写进采样值 —— 这样 + // 新信号**不启用任何「调整」**就与预览逐点重合(用户明确要求)。 + // 只有"无法烘值"的退化情形才退回把 `offsetTime` 带在样式上(见 3b)。 SignalStyle sourceTransform; bool hasSourceStyle = false; { @@ -1597,6 +1598,55 @@ namespace jdi } } + // 2c. 时间轴偏移:把 `offsetTime` 也**重采样烘进采样值**。 + // + // 预览曲线是按「源信号样式」绘制的,它的时间轴偏移会把曲线整体横向平移。 + // 派生信号与源信号**共用同一个文件时间轴**,所以时间平移本无法写进"时间列"; + // 但用户明确要求:**新信号不启用任何「调整」也要与预览逐点重合** + //(新信号的「调整」面板必须是默认值,不允许靠"继承 offsetTime"来表达)。 + // 因此这里把平移直接写进数据: + // * 新信号第 i 个采样 = 预览曲线在 `time[i]` 时刻显示的值 + // = 已变换的曲线在 `time[i] - offsetTime` 处的取值(取最近的采样点); + // * 平移后落在数据范围之外的采样置 NaN —— 与预览"该处没有曲线"完全一致。 + // ⚠️ 一次整段重采样需要一份等长的临时缓冲(千万级采样 ≈ 数十 MB),用完立即释放; + // 这是"构建为新信号"的一次性操作,不在交互路径上。 + // ⚠️ 时间轴非单调时无法按下标单调推进取样(只能整表扫描),此时退回"继承 + // `offsetTime`"(见 3b):两条路径的**显示效果相同**,只是偏移留在样式里。 + bool timeShiftBaked = false; + if (hasSourceStyle && sourceTransform.offsetTime != 0.0 && file->timeMonotonic) + { + const std::vector& times = file->time; + const std::size_t total = derived.values.size(); + if (total > 1 && times.size() >= total) + { + const double shift = sourceTransform.offsetTime; + std::vector shifted(total, std::numeric_limits::quiet_NaN()); + // 目标时刻 `times[index] - shift` 随 index 单调不减 → 游标只前进,整体 O(n)。 + std::size_t probe = 0; + for (std::size_t index = 0; index < total; ++index) + { + const double target = times[index] - shift; + if (!std::isfinite(target) || target < times[0] || target > times[total - 1]) + { + continue; // 平移后越界:该处没有曲线 + } + while (probe + 1 < total && times[probe + 1] < target) + { + ++probe; + } + std::size_t nearest = probe; + if (probe + 1 < total && + std::fabs(times[probe + 1] - target) < std::fabs(times[probe] - target)) + { + nearest = probe + 1; + } + shifted[index] = derived.values[nearest]; + } + derived.values.swap(shifted); + timeShiftBaked = true; + } + } + // 3. 插入到原信号的下方(数据层 + 各标签页引用重映射)。 int newIndex = -1; // 插入派生信号会移动该文件的信号存储:先暂停后台计算,避免其读取已移动的数据。 @@ -1611,10 +1661,10 @@ namespace jdi tab.RemapAfterSignalInsert(request.fileIndex, newIndex); } - // 3b. 仅把**时间轴偏移**带到新信号上(唯一无法烘进数据的一项,见上面第 0 步说明)。 - // 其余「调整」项(系数 / 绝对值 / 镜像 / Y 偏移)保持在默认值 —— 它们已经 - // 烘进了采样数据,因此新信号不经过任何"调整"就与预览曲线完全一致。 - if (hasSourceStyle && sourceTransform.offsetTime != 0.0) + // 3b. 时间轴偏移正常情况下已烘进采样值(见 2c),新信号的「调整」全部保持默认。 + // ⚠️ 只有"无法烘值"的退化情形(时间轴非单调 / 时间列缺失)才退回到把 + // `offsetTime` 带在新信号样式上 —— 无论如何都要让**显示**与预览一致。 + if (hasSourceStyle && sourceTransform.offsetTime != 0.0 && !timeShiftBaked) { if (SignalStyle* derivedStyle = m_dataSet.Style(SignalRef{request.fileIndex, newIndex})) { diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index 224bccd..f491eed 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -1307,8 +1307,12 @@ namespace jdi * * 曲线由「均值点数 X + 迭代次数 N」确定,与「实验性功能 → 均值滤波」的预览曲线逐位一致。 * + * ⚠️ 判定对象是**画出来的两条曲线**:信号与中心曲线都要先按 `style` 做水平镜像映射、 + * 再套用 `ApplyDrawTransform`(系数 → 绝对值 → 垂直镜像 → Y 偏移)。 + * * @param [IN] file 信号所属文件。 * @param [IN] signal 目标信号。 + * @param [IN] style 该信号的外观(含水平镜像与「调整」变换)。 * @param [IN] points 均值点数 X。 * @param [IN] iterations 迭代次数 N。 * @param [IN] hasRange 是否只统计某个时间区间(双光标模式下为光标区间)。 @@ -1317,9 +1321,9 @@ namespace jdi * @param [OUT] rising 上升沿数量(信号从下往上越过均值滤波曲线)。 * @param [OUT] falling 下降沿数量(信号从上往下穿过均值滤波曲线)。 */ - void EdgeCenterCountsFor(const DataFile& file, const SignalData& signal, int points, int iterations, - bool hasRange, double rangeMin, double rangeMax, std::size_t& rising, - std::size_t& falling) const; + void EdgeCenterCountsFor(const DataFile& file, const SignalData& signal, const SignalStyle& style, + int points, int iterations, bool hasRange, double rangeMin, + double rangeMax, std::size_t& rising, std::size_t& falling) const; /** * @brief 切换某个信号在「中心轴边沿计数」面板中的勾选状态。 @@ -1591,8 +1595,19 @@ namespace jdi /** * @brief 统计某信号穿越横向光标幅值的上/下边沿数量,结果带缓存。 + * + * 判定定义(用户明确要求):把信号**绘制出来的曲线**与横向光标所在的幅值比较, + * 曲线从**下往上**穿过该幅值 → 上升沿;从**上往下**穿过 → 下降沿。 + * + * ⚠️ 因此判定值必须是**画出来的值**:先按 `style` 做水平镜像映射,再套用 + * `ApplyDrawTransform`(系数 → 绝对值 → 垂直镜像 → Y 偏移)。 + * 只比较原始采样值会导致「应用过调整的信号数不到任何边沿」 + *(横向光标读的是屏幕上的值,与原始值域完全不同)。 + * ⚠️ 时间区间**之外**的采样一律不参与计数:只有两个端点都在区间内的采样对才计入。 + * * @param [IN] file 信号所属文件。 * @param [IN] signal 目标信号。 + * @param [IN] style 该信号的外观(含水平镜像与「调整」变换)。 * @param [IN] edgeValue 横向光标所在的幅值。 * @param [IN] hasRange 是否只统计某个时间区间(双光标模式下为光标区间)。 * @param [IN] rangeMin 区间左端点时间。 @@ -1600,9 +1615,9 @@ namespace jdi * @param [OUT] rising 上升沿数量(从下往上穿越)。 * @param [OUT] falling 下降沿数量(从上往下穿越)。 */ - void EdgeCountsFor(const DataFile& file, const SignalData& signal, double edgeValue, - bool hasRange, double rangeMin, double rangeMax, std::size_t& rising, - std::size_t& falling) const; + void EdgeCountsFor(const DataFile& file, const SignalData& signal, const SignalStyle& style, + double edgeValue, bool hasRange, double rangeMin, double rangeMax, + std::size_t& rising, std::size_t& falling) const; /** * @brief 计算实验性功能面板所需的宽度(排版与绘制共用,保证文字完整不被省略)。 @@ -1816,6 +1831,7 @@ namespace jdi { const DataFile* file = nullptr; /**< 所属数据分组。 */ const SignalData* signal = nullptr; /**< 目标信号。 */ + std::uint64_t styleKey = 0; /**< 「调整」变换 + 水平镜像的指纹(变了必须整段重算)。 */ double edgeValue = 0.0; /**< 判定阈值(横向光标幅值)。 */ bool hasRange = false; /**< 是否限定在时间区间内统计。 */ bool reusable = false; /**< 计数是否完全由 [begin, end) 决定。 */ @@ -1867,6 +1883,7 @@ namespace jdi { const DataFile* file = nullptr; /**< 所属数据分组。 */ const SignalData* signal = nullptr; /**< 目标信号。 */ + std::uint64_t styleKey = 0; /**< 「调整」变换 + 水平镜像的指纹(变了必须整段重算)。 */ int points = 5; /**< 均值点数 X。 */ int iterations = 1; /**< 迭代次数 N。 */ bool hasRange = false; /**< 是否限定在时间区间内统计。 */ diff --git a/Components/PlotView/Src/PlotView.cpp b/Components/PlotView/Src/PlotView.cpp index 4e7ec70..48316c2 100644 --- a/Components/PlotView/Src/PlotView.cpp +++ b/Components/PlotView/Src/PlotView.cpp @@ -80,7 +80,6 @@ namespace jdi const float kLegendSwatchSize = 8.0f; /**< 图例色块尺寸。 */ const float kLegendItemGap = 14.0f; /**< 图例条目之间的间距。 */ const float kLegendMaxTextWidth = 200.0f; /**< 图例条目的文本宽度上限(超出则省略)。 */ - const float kLegendPlateRadius = 4.0f; /**< 图例条目托底的圆角半径(逻辑像素)。 */ const float kLaneBadgeWidth = 30.0f; /**< 网格编号徽标宽度(与信号面板保持一致)。 */ const float kLaneBadgeHeight = 17.0f; /**< 网格编号徽标高度。 */ const float kLaneBadgeGap = 8.0f; /**< 编号徽标与图例条目之间的间距。 */ @@ -297,11 +296,39 @@ namespace jdi } } + /** + * @brief 「调整」变换 + 水平镜像的指纹(用于边沿统计缓存键)。 + * + * 边沿判定基于**画出来的曲线**,因此任何一个变换项改变都可能改变计数结果; + * 把这些项折叠成一个指纹后,样式一变就能立刻失效缓存项(否则会沿用变换前的旧计数)。 + * + * @param [IN] style 信号外观。 + * @return 指纹值。 + */ + std::uint64_t EdgeStyleKey(const SignalStyle& style) + { + std::uint64_t key = 1469598103934665603ull; + const auto mix = [&key](std::uint64_t value) + { + key ^= value; + key *= 1099511628211ull; + }; + mix(static_cast( + std::llround(static_cast(style.gain) * 1.0e6))); + mix(static_cast( + std::llround(static_cast(style.offsetY) * 1.0e6))); + mix(style.absolute ? 1u : 0u); + mix(style.mirrorVertical ? 2u : 0u); + mix(style.mirrorHorizontal ? 4u : 0u); + return key; + } + /** * @brief 计算统计区间对应的采样下标区间 [begin, end)。 * - * 时间轴单调时用二分定位,并在区间左侧多取一个采样点, - * 使跨越区间边界的边沿也能被计入。 + * 时间轴单调时用二分定位。⚠️ 区间**之外**的采样一律不参与计数, + * 因此这里**不做任何外扩**:只有两端都落在 `[begin, end)` 内的采样对才会被累计 + *(调用方从 `begin + 1` 开始枚举采样对)。 * * @param [IN] file 所属数据分组。 * @param [IN] count 采样点数量。 @@ -333,14 +360,14 @@ namespace jdi std::lower_bound(times.begin(), times.begin() + static_cast(timeCount), rangeMin) - times.begin()); + // ⚠️ **不要**在左端多取一个采样点:那会让“跨越区间边界的边沿”也算进来, + // 而用户明确要求“在光标选区外的点不要算入计数规则”。 + // (例:光标恰好落在越过阈值的第一个采样点 B 上时,A→B 这一段处于选区之外, + // 不计入;旧实现在左端多取一个点,于是 B 被错认成上升沿。) end = static_cast( std::upper_bound(times.begin(), times.begin() + static_cast(timeCount), rangeMax) - times.begin()); - if (begin > 0) - { - --begin; - } end = std::min(end, timeCount); begin = std::min(begin, end); return true; @@ -2673,8 +2700,9 @@ namespace jdi return true; } - void PlotView::EdgeCenterCountsFor(const DataFile& file, const SignalData& signal, int points, - int iterations, bool hasRange, double rangeMin, double rangeMax, + void PlotView::EdgeCenterCountsFor(const DataFile& file, const SignalData& signal, + const SignalStyle& style, int points, int iterations, + bool hasRange, double rangeMin, double rangeMax, std::size_t& rising, std::size_t& falling) const { rising = 0; @@ -2687,14 +2715,21 @@ namespace jdi std::size_t end = 0; const bool indexed = EdgeIndexRange(file, count, hasRange, rangeMin, rangeMax, begin, end); - // 按「文件 + 信号 + 均值点数 X + 迭代次数 N + 是否限定区间」定位缓存项 + // ⚠️ 判定基于**画出来的两条曲线**:信号与中心曲线都要先按下标映射(水平镜像) + // 再套用「调整」变换 —— 否则应用过调整的信号会按原始值域判定,统计结果完全错误。 + const bool mirrored = style.mirrorHorizontal; + const bool transform = (style.gain != 1.0f || style.absolute || style.mirrorVertical || + style.offsetY != 0.0f); + const std::uint64_t styleKey = EdgeStyleKey(style); + + // 按「文件 + 信号 + 样式指纹 + 均值点数 X + 迭代次数 N + 是否限定区间」定位缓存项 //(**时间区间不进缓存键**)。 EdgeCenterCountEntry* entry = nullptr; for (EdgeCenterCountEntry& candidate : m_edgeCenterCountCache) { if (candidate.file == &file && candidate.signal == &signal && - candidate.hasRange == hasRange && candidate.points == radius && - candidate.iterations == stages) + candidate.styleKey == styleKey && candidate.hasRange == hasRange && + candidate.points == radius && candidate.iterations == stages) { entry = &candidate; break; @@ -2709,6 +2744,7 @@ namespace jdi EdgeCenterCountEntry created; created.file = &file; created.signal = &signal; + created.styleKey = styleKey; created.points = radius; created.iterations = stages; created.hasRange = hasRange; @@ -2775,29 +2811,40 @@ namespace jdi // 与「实验性功能 → 均值滤波」共用同一条曲线(含缓存), // 但**只取必需区间 ± 保护带**的窗口。 + // ⚠️ 曲线数组按**源下标**存储(`signal.values` 的顺序),而上面算出的区间是**显示下标**: + // 水平镜像时显示下标 i 对应源下标 count-1-i,必须把窗口映射过去再请求, + // 否则会取到完全无关的一段(甚至什么都不画)。 + const std::size_t windowBegin = mirrored ? (count - needEnd) : needFirst; + const std::size_t windowEnd = mirrored ? (count - needFirst) : needEnd; std::size_t spanBegin = 0; const std::vector* curvePtr = CurveSpanFor( - DerivedCurveKind::MEAN_FILTER, signal, radius, stages, 0, needFirst, needEnd, spanBegin); + DerivedCurveKind::MEAN_FILTER, signal, radius, stages, 0, windowBegin, windowEnd, spanBegin); if (curvePtr == nullptr) { return; } const std::vector& curve = *curvePtr; - // 判定基准:信号与中心平均值曲线之差(区间之外的采样返回 NaN)。 - const auto deltaAt = [&signal, &file, &curve, spanBegin, count, hasRange, rangeMin, - rangeMax](std::size_t index) -> double + // 判定基准:**绘制后**的信号与**绘制后**的中心曲线之差(区间之外的采样返回 NaN)。 + // ⚠️ 两侧必须套用**同一套**变换,否则比较的不是屏幕上看到的两条曲线。 + const auto deltaAt = [&signal, &file, &curve, &style, spanBegin, count, mirrored, transform, + hasRange, rangeMin, rangeMax](std::size_t index) -> double { if (index >= count) { return std::numeric_limits::quiet_NaN(); } - if (index < spanBegin || (index - spanBegin) >= curve.size()) + const std::size_t sourceIndex = mirrored ? ((count - 1) - index) : index; + if (sourceIndex >= count) { return std::numeric_limits::quiet_NaN(); } - const double value = signal.values[index]; - const float center = curve[index - spanBegin]; + if (sourceIndex < spanBegin || (sourceIndex - spanBegin) >= curve.size()) + { + return std::numeric_limits::quiet_NaN(); + } + const double value = signal.values[sourceIndex]; + const float center = curve[sourceIndex - spanBegin]; if (!std::isfinite(value) || !std::isfinite(center)) { return std::numeric_limits::quiet_NaN(); @@ -2810,7 +2857,15 @@ namespace jdi return std::numeric_limits::quiet_NaN(); } } - return value - static_cast(center); + const double drawn = transform ? ApplyDrawTransform(style, value) : value; + const double drawnCenter = + transform ? ApplyDrawTransform(style, static_cast(center)) + : static_cast(center); + if (!std::isfinite(drawn) || !std::isfinite(drawnCenter)) + { + return std::numeric_limits::quiet_NaN(); + } + return drawn - drawnCenter; }; std::size_t deltaRising = 0; @@ -2839,24 +2894,43 @@ namespace jdi entry->falling = falling; } - void PlotView::EdgeCountsFor(const DataFile& file, const SignalData& signal, double edgeValue, - bool hasRange, double rangeMin, double rangeMax, std::size_t& rising, - std::size_t& falling) const + void PlotView::EdgeCountsFor(const DataFile& file, const SignalData& signal, const SignalStyle& style, + double edgeValue, bool hasRange, double rangeMin, double rangeMax, + std::size_t& rising, std::size_t& falling) const { rising = 0; falling = 0; const std::vector& values = signal.values; + // ⚠️ 阈值(横向光标幅值)是**屏幕上的值**,因此判定必须基于**画出来的曲线**: + // 先按下标映射(水平镜像)取源采样,再套用「调整」变换(系数 → 绝对值 → 垂直镜像 → + // Y 偏移)。只比较原始采样值时,“系数 50”这类信号的原始值域与阈值完全不相交, + // 表现就是“明明是很规整的方波,统计出来却全是 0”(用户报告)。 + const std::size_t count = values.size(); + const bool mirrored = style.mirrorHorizontal; + const bool transform = (style.gain != 1.0f || style.absolute || style.mirrorVertical || + style.offsetY != 0.0f); + const std::uint64_t styleKey = EdgeStyleKey(style); std::size_t begin = 0; std::size_t end = 0; - const bool indexed = EdgeIndexRange(file, values.size(), hasRange, rangeMin, rangeMax, begin, end); + const bool indexed = EdgeIndexRange(file, count, hasRange, rangeMin, rangeMax, begin, end); - // 判定基准:采样值相对阈值的偏差。区间之外的采样返回 NaN(等价于原实现 + // 判定基准:**绘制值**相对阈值的偏差。区间之外的采样返回 NaN(等价于原实现 // 「区间外采样不参与判定且打断连续」的语义)。 - const auto deltaAt = [&values, &file, hasRange, rangeMin, rangeMax](std::size_t index) -> double + const auto deltaAt = [&values, &file, &style, mirrored, transform, count, hasRange, rangeMin, + rangeMax](std::size_t index) -> double { - const double value = values[index]; - if (!std::isfinite(value)) + if (index >= count) + { + return std::numeric_limits::quiet_NaN(); + } + const std::size_t sourceIndex = mirrored ? ((count - 1) - index) : index; + if (sourceIndex >= count) + { + return std::numeric_limits::quiet_NaN(); + } + const double raw = values[sourceIndex]; + if (!std::isfinite(raw)) { return std::numeric_limits::quiet_NaN(); } @@ -2868,15 +2942,17 @@ namespace jdi return std::numeric_limits::quiet_NaN(); } } - return value; + const double drawn = transform ? ApplyDrawTransform(style, raw) : raw; + return std::isfinite(drawn) ? drawn : std::numeric_limits::quiet_NaN(); }; - // 按「文件 + 信号 + 阈值 + 是否限定区间」定位缓存项(**时间区间不进缓存键**)。 + // 按「文件 + 信号 + 样式指纹 + 阈值 + 是否限定区间」定位缓存项(**时间区间不进缓存键**)。 EdgeCountEntry* entry = nullptr; for (EdgeCountEntry& candidate : m_edgeCountCache) { if (candidate.file == &file && candidate.signal == &signal && - candidate.hasRange == hasRange && candidate.edgeValue == edgeValue) + candidate.styleKey == styleKey && candidate.hasRange == hasRange && + candidate.edgeValue == edgeValue) { entry = &candidate; break; @@ -2891,6 +2967,7 @@ namespace jdi EdgeCountEntry created; created.file = &file; created.signal = &signal; + created.styleKey = styleKey; created.edgeValue = edgeValue; created.hasRange = hasRange; m_edgeCountCache.push_back(created); @@ -3004,13 +3081,14 @@ namespace jdi { continue; } - EdgeCenterCountsFor(*resolved.file, *resolved.data, item->points, item->iterations, - EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), rising, falling); + EdgeCenterCountsFor(*resolved.file, *resolved.data, *resolved.style, item->points, + item->iterations, EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), + rising, falling); } else { - EdgeCountsFor(*resolved.file, *resolved.data, lane.edgeValue, EdgeRangeActive(), - EdgeRangeMin(), EdgeRangeMax(), rising, falling); + EdgeCountsFor(*resolved.file, *resolved.data, *resolved.style, lane.edgeValue, + EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), rising, falling); } const float countWidth = UiMeasureTextWidth(m_fonts->monoSmall, @@ -6373,11 +6451,11 @@ namespace jdi { if (hit.ref == m_hoverLegend) { - const float radius = (hit.radius > 0.0f) ? hit.radius : kLegendPlateRadius * m_scale; + const float radius = (hit.radius > 0.0f) ? hit.radius : (kLaneBadgeHeight * 0.5f * m_scale); UiFillRoundRect(g, hit.rect, radius, m_theme->accentSoft); UiDrawRoundRect(g, hit.rect, radius, m_theme->accent, 1.0f); UiDrawSwatch(g, hit.swatch, hit.swatchColor, hit.swatchOpacity, 1.5f * m_scale, m_scale); - UiText(g, hit.label, m_fonts->uiSmall, hit.text, m_theme->textStrong, TextAlign::LEFT); + UiText(g, hit.label, m_fonts->uiTiny, hit.text, m_theme->textStrong, TextAlign::LEFT); break; } } @@ -6935,19 +7013,40 @@ namespace jdi return m_laneDragFrom; } - // 判据 = **鼠标指针**落在哪个网格的哪一半:指针进入某网格的"下半"即把插入位置 - // 定在它之后(`index` = 该网格下标 + 1)。 + // 判据 = **被拖网格自己的上下边界**(用户明确要求): + // * 上边界越过"上方第 m 个网格"的中心水平线 → 让位 m 个位置(插入位置 = from - m); + // * 下边界越过"下方第 m 个网格"的中心水平线 → 让位 m 个位置(插入位置 = from + m + 1, + // 因为被拖网格会先从原位置摘除,越过后应落在该网格之后)。 // - // ⚠️ 旧实现用"被拖网格的**中心**"(`mouseY - grabOffset + laneHeight/2`)做判据, - // 等价于要求把整个网格拖过去一格才会吸附 —— 例如只有两个网格时,把 G2 往上拖 - // 必须一直拖到鼠标接近 G1 的最上沿才会让位(用户报告"要拖到另一个网格的位置附近 - // 才有吸附提示")。改为与鼠标位置挂钩后:**鼠标越过 G1 的中心水平线,G1 立刻让位 - // 并出现吸附提示**(依次类推对任意网格数成立)。 - const float firstTop = m_lanes.front().rect.Y; - const float mouse = static_cast(mouseY); - int index = static_cast(std::floor((mouse - firstTop) / laneHeight + 0.5f)); - index = std::max(0, std::min(index, count)); - return index; + // ⚠️ 旧判据只看**鼠标指针**落在哪个网格的哪一半。因为被拖网格整体跟随指针,而指针通常 + // 按在网格上沿的 GX 徽标上,于是"向下拖必须把整个网格拖过下方网格才吸附、 + // 向上拖只要半个网格" —— 上下不对称(用户报告)。改用网格边界后上下完全对称。 + // + // 数学:令 shift = 被拖网格相对原槽位的位移,H = 网格高。 + // 上边界 = baseTop + shift,上方第 m 个网格的中心线 = baseTop - (m - 0.5)H + // ⇒ 满足 shift < (0.5 - m)H 的最大 m 即让位格数(m < 0.5 - shift/H); + // 下边界 = baseTop + shift + H,下方第 m 个网格的中心线 = baseTop + (m + 0.5)H + // ⇒ 满足 shift > (m - 0.5)H 的最大 m(m < shift/H + 0.5)。 + // ⚠️ 两侧条件互斥(不可能同时成立),因此最多只会向一侧吸附。 + const float baseTop = m_lanes[static_cast(m_laneDragFrom)].rect.Y; + const float shift = + static_cast(mouseY) - m_laneDragGrabOffset - baseTop; + const float upRatio = 0.5f - shift / laneHeight; // m < upRatio + const float downRatio = shift / laneHeight + 0.5f; // m < downRatio + // "严格小于" ⇒ 最大整数 m = ceil(ratio) - 1。 + int up = static_cast(std::ceil(upRatio)) - 1; + int down = static_cast(std::ceil(downRatio)) - 1; + up = std::max(0, std::min(up, m_laneDragFrom)); + down = std::max(0, std::min(down, count - 1 - m_laneDragFrom)); + if (up > 0) + { + return m_laneDragFrom - up; + } + if (down > 0) + { + return m_laneDragFrom + down + 1; + } + return m_laneDragFrom; } void PlotView::FinishLaneReorder() @@ -9173,9 +9272,16 @@ namespace jdi const float badgeHeight = kLaneBadgeHeight * m_scale; float cursorX = badgeRect.X + badgeRect.Width + kLaneBadgeGap * m_scale; - // 图例条目托底的内缩外扩量:托底矩形**同时就是命中矩形**(看到的与点得到的严格一致)。 - const float platePadX = 3.0f * m_scale; - const float platePadY = 2.0f * m_scale; + // 图例条目的托底 = **与 GX 编号徽标完全同款**(用户明确要求): + // * 高度与徽标相同(`bgadeRect` 的上下边界一模一样,因此"上下边距相同"); + // * 圆角 = 高度的一半(胶囊形),与徽标的 `badgeHeight * 0.5f` 一致; + // * 配色与描边宽度也与徽标一致(`cardBg/border`,选中态 `accentSoft/accent`); + // * 水平内边距 = 胶囊半径(若小于半径,内容会溢出圆角之外)。 + // * 文本改用**更小的字体**(`uiTiny`):原用 `uiSmall` 时文字几乎顶满胶囊高度。 + // ⚠️ 托底矩形**同时就是命中矩形**(看到的与点得到的严格一致)。 + const float plateHeight = badgeHeight; + const float plateTop = badgeRect.Y; + const float platePadX = plateHeight * 0.5f; // ------------------------------------------------------------------ // 1) 几何:算出"放得下"的条目及其矩形(**唯一来源**,命中 / 装饰 / 绘制共用)。 @@ -9184,7 +9290,8 @@ namespace jdi { SignalRef ref; float left = 0.0f; // 色块左缘 - float width = 0.0f; // 条目内容宽度 + float width = 0.0f; // 条目**内容**宽度(色块 + 间距 + 文本) + float plateWidth = 0.0f; // 托底宽度(内容 + 两侧内边距) float textWidth = 0.0f; std::wstring label; bool selected = false; @@ -9208,35 +9315,35 @@ namespace jdi label += L" · " + resolved.file->Title(); } const float maxTextWidth = kLegendMaxTextWidth * m_scale; - const float fullTextWidth = UiMeasureTextWidth(m_fonts->uiSmall, label); + const float fullTextWidth = UiMeasureTextWidth(m_fonts->uiTiny, label); const float textWidth = std::min(fullTextWidth, maxTextWidth); - const float itemWidth = swatchSize + 5.0f * m_scale + textWidth + kLegendItemGap * m_scale; - if (probeX + itemWidth > maxX) + const float contentWidth = swatchSize + 5.0f * m_scale + textWidth; + const float plateWidth = contentWidth + platePadX * 2.0f; + if (probeX + plateWidth > maxX) { break; // 空间不足:其余条目省略,下方用 "+N" 提示 } LegendItem item; item.ref = ref; - item.left = probeX; - item.width = itemWidth; + item.left = probeX + platePadX; // 色块左缘(托底内缩一个内边距) + item.width = contentWidth; + item.plateWidth = plateWidth; item.textWidth = textWidth; item.label = (fullTextWidth > maxTextWidth) - ? UiEllipsize(label, m_fonts->uiSmall, maxTextWidth) + ? UiEllipsize(label, m_fonts->uiTiny, maxTextWidth) : label; item.selected = (SelectedRef() == ref); items.push_back(std::move(item)); - probeX += itemWidth; + probeX += plateWidth + kLegendItemGap * m_scale; } contentRight = probeX; } const auto PlateRect = [&](const LegendItem& item) { - return gd::RectF(item.left - platePadX, top - platePadY, item.width + platePadX * 2.0f, - height + platePadY * 2.0f); + return gd::RectF(item.left - platePadX, plateTop, item.plateWidth, plateHeight); }; - // 条目托底 = 与 GX 编号徽标同一套风格的**圆角矩形**(用户明确要求: - // "相当于一个 GX 标签,只是内容换成了图例信息")。 - const float plateRadius = kLegendPlateRadius * m_scale; + // 条目托底 = 与 GX 编号徽标同一套风格的**胶囊形圆角矩形**(半径 = 高度的一半)。 + const float plateRadius = plateHeight * 0.5f; // 「+N」(图例空间不足)与「(无可见信号)」提示的矩形。 // ⚠️ 它们同样属于装饰层、必须登记进 `m_decorRects`:否则会被画进装饰位图 // 却因不在贴回列表里而永远看不见。 @@ -9271,9 +9378,9 @@ namespace jdi hit.rect = PlateRect(item); hit.radius = plateRadius; hit.swatch = - gd::RectF(item.left, top + (height - swatchSize) * 0.5f, swatchSize, swatchSize); - hit.text = gd::RectF(item.left + swatchSize + 5.0f * m_scale, top, - std::max(8.0f, item.textWidth + 2.0f), height); + gd::RectF(item.left, plateTop + (plateHeight - swatchSize) * 0.5f, swatchSize, swatchSize); + hit.text = gd::RectF(item.left + swatchSize + 5.0f * m_scale, plateTop, + std::max(8.0f, item.textWidth + 2.0f), plateHeight); hit.label = item.label; // 「选中」与「悬停」用同一套配色(用户明确要求),因此文字色在这里也按选中态取。 hit.textColor = item.selected ? m_theme->textStrong : m_theme->text; @@ -9343,23 +9450,24 @@ namespace jdi // 改名编辑态:整块托底变成输入框(与左侧列表双击改名一致)。 if (m_editTarget == PlotEditTarget::SIGNAL_NAME && m_editSignal == item.ref) { - UiDrawEditField(g, plate, m_edit, m_fonts->uiSmall, m_scale); + UiDrawEditField(g, plate, m_edit, m_fonts->uiTiny, m_scale); continue; } UiDrawSwatch(g, - gd::RectF(item.left, top + (height - swatchSize) * 0.5f, swatchSize, swatchSize), + gd::RectF(item.left, plateTop + (plateHeight - swatchSize) * 0.5f, + swatchSize, swatchSize), resolved.style->color, resolved.style->opacity, 1.5f * m_scale, m_scale); - UiText(g, item.label, m_fonts->uiSmall, - gd::RectF(item.left + swatchSize + 5.0f * m_scale, top, - std::max(8.0f, item.textWidth + 2.0f), height), + UiText(g, item.label, m_fonts->uiTiny, + gd::RectF(item.left + swatchSize + 5.0f * m_scale, plateTop, + std::max(8.0f, item.textWidth + 2.0f), plateHeight), item.selected ? m_theme->textStrong : m_theme->text, TextAlign::LEFT); } } if (hasEmptyText) { - UiText(g, L"(无可见信号)", m_fonts->uiSmall, emptyTextRect, m_theme->textMuted, + UiText(g, L"(无可见信号)", m_fonts->uiTiny, emptyTextRect, m_theme->textMuted, TextAlign::LEFT); } else if (hasMoreText) @@ -9367,7 +9475,7 @@ namespace jdi // 图例空间不足时提示剩余信号数量,避免误认为信号丢失。 const std::wstring moreText = L"+" + std::to_wstring(visibleCount - static_cast(items.size())); - UiText(g, moreText, m_fonts->uiSmall, moreTextRect, m_theme->textMuted, TextAlign::LEFT); + UiText(g, moreText, m_fonts->uiTiny, moreTextRect, m_theme->textMuted, TextAlign::LEFT); } } @@ -11524,9 +11632,9 @@ namespace jdi const int statsOrder = (item != nullptr) ? item->iterations : 1; std::size_t rising = 0; std::size_t falling = 0; - EdgeCenterCountsFor(*resolved.file, *resolved.data, statsPoints, statsOrder, - EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), rising, - falling); + EdgeCenterCountsFor(*resolved.file, *resolved.data, *resolved.style, + 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, @@ -11557,8 +11665,8 @@ namespace jdi std::size_t rising = 0; std::size_t falling = 0; - EdgeCountsFor(*resolved.file, *resolved.data, tabLane.edgeValue, EdgeRangeActive(), - EdgeRangeMin(), EdgeRangeMax(), rising, falling); + EdgeCountsFor(*resolved.file, *resolved.data, *resolved.style, 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, diff --git a/Components/SignalPanel/Src/SignalPanel.cpp b/Components/SignalPanel/Src/SignalPanel.cpp index 6e48cdd..f4da1ee 100644 --- a/Components/SignalPanel/Src/SignalPanel.cpp +++ b/Components/SignalPanel/Src/SignalPanel.cpp @@ -106,6 +106,7 @@ namespace jdi const float kSliderValueGap = 8.0f; /**< 滑块轨道与数值文本间距。 */ const float kTrackHeight = 6.0f; /**< 滑块轨道高度。 */ const float kAdjustGap = 6.0f; /**< 「调整」行三个按钮之间的间距。 */ + const float kAdjustMenuGap = 4.0f; /**< 「系数/偏移」「镜像」二级面板与按钮左上角的额外间距。 */ /** 「调整」行的按钮下标(与 `m_adjustRects` 同序)。 */ const int kAdjustGain = 0; /**< 系数(点击展开系数二级面板)。 */ @@ -2050,11 +2051,15 @@ namespace jdi offsetTime.filter = EditFilter::NUMBER; items.push_back(offsetTime); - // 锚定在按钮下沿:面板紧贴按钮展开(与拾色器"以触发器矩形为锚点"的观感一致)。 + // 锚定在按钮**左上角的上方一点**:二级面板**向上展开**,其左下角对准按钮左上角, + // 并再往上让出 `kAdjustMenuGap`(用户明确要求)。 + //(旧实现在按钮下沿向右下展开,面板会盖住下方内容。) const gd::RectF& button = m_adjustRects[kAdjustGain]; const gd::RectF bounds = (m_clientBounds.Width > 1.0f) ? m_clientBounds : m_area; - m_menu->Open(MenuScope::SIGNAL_PANEL, bounds, static_cast(button.X), - static_cast(button.Y + button.Height), items); + const int anchorY = + static_cast(std::lround(button.Y - kAdjustMenuGap * m_scale)); + m_menu->Open(MenuScope::SIGNAL_PANEL, bounds, static_cast(button.X), anchorY, items, + true); // ⚠️ 菜单一旦打开就会接管鼠标事件,样式区不会再收到 WM_MOUSEMOVE → // 必须在这里主动清掉按钮的悬停态,否则按钮会"像鼠标一直按着"一样持续高亮。 ClearStyleHover(); @@ -2094,8 +2099,11 @@ namespace jdi const gd::RectF& button = m_adjustRects[kAdjustMirror]; const gd::RectF bounds = (m_clientBounds.Width > 1.0f) ? m_clientBounds : m_area; - m_menu->Open(MenuScope::SIGNAL_PANEL, bounds, static_cast(button.X), - static_cast(button.Y + button.Height), items); + // 与「系数/偏移」面板同一套锛定:向上展开,左下角 = 按钮左上角再上移一点。 + const int anchorY = + static_cast(std::lround(button.Y - kAdjustMenuGap * m_scale)); + m_menu->Open(MenuScope::SIGNAL_PANEL, bounds, static_cast(button.X), anchorY, items, + true); ClearStyleHover(); return true; } diff --git a/Documents/agent_mem.md b/Documents/agent_mem.md index ea81d69..6889d47 100644 --- a/Documents/agent_mem.md +++ b/Documents/agent_mem.md @@ -735,11 +735,14 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸( 3. **水平镜像 = 数据反序**:把派生采样数组整体反转(`ApplyDrawTransform` 是逐点函数, "先变换再反转"与"先反转再变换"等价); 4. 新信号样式一律**保持默认**(`gain=1 / absolute=false / 两个镜像=false / offsetY=0`)。 -- ⚠️ **唯一例外:时间轴偏移 `offsetTime` 仍由样式携带**——它改变的是"绘制在哪个时刻", - 而派生信号与源信号**共用同一个文件时间轴**,无法把时间平移烘进采样数据; - 若不继承,新信号会相对预览整体横向错位。已在该函数注释中写明。 -- 验证(本轮 `verify` harness):`derived.values[i] == ApplyDrawTransform(tf, float(source[count-1-i]))` - 逐点一致;新样式各项均为默认;`offsetTime` 保留。 +- ⚠️⚠️ **【已作废,见 §9.21 第三节】原"唯一例外:时间轴偏移 `offsetTime` 仍由样式携带"** —— + 用户明确要求"新信号**默认不启用任何「调整」**就必须与预览逐点重合",因此 `offsetTime` 现在也 + **按 `time[i] - offsetTime` 重采样烘进采样值**(新信号 `offsetTime` 同样保持 0); + 只有"无法烘值"的退化情形(时间轴非单调 / 时间列缺失)才退回把 `offsetTime` 带在样式上。 + 仅作历史保留:~~它改变的是"绘制在哪个时刻",而派生信号与源信号共用同一个文件时间轴, + 无法把时间平移烘进采样数据;若不继承,新信号会相对预览整体横向错位。~~ +- 验证(`1b3ab4a` 轮 `verify` harness):`derived.values[i] == ApplyDrawTransform(tf, float(source[count-1-i]))` + 逐点一致;新样式各项均为默认。 #### 四、网格 GX 拖拽让位阈值 + 徽标悬停 @@ -897,6 +900,104 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸( --- +### 9.21 边沿计数改用「绘制值」+ 派生信号烘入时间偏移 + 图例托底统一为 GX 徽标款 + 调整二级面板向上展开(本轮) + +> 接手背景:基于 `1b3ab4a`。本轮修的四处都是**交互/统计语义**问题,另有**一处推翻了 §9.19 的结论** +>("派生信号仅 `offsetTime` 仍需继承" —— **已作废**,见下)。 + +#### 一、两个「边沿计数」必须按**画出来的曲线**判定(原实现按原始采样值 → 恒为 0) + +- **定义(用户明确复述,作为权威)**:设定一个横向(Y 轴)光标,信号**绘制出来的曲线** + 从**下往上**穿过该幅值 = **上升沿**;从**上往下**穿过 = **下降沿**。 +- **症状**:对应用过「调整」的曲线统计恒为 0。例:`VERY_BIG_DATA.CSV` 的「编码器B相」设 + 系数 50、时间轴偏移 -20s,把横向光标拖到画面中间(方波很规整),**上升沿/下降沿都是 0**。 +- **根因**:`EdgeCountsFor` / `EdgeCenterCountsFor` 只比较 **`signal.values` 的原始采样值**, + 而横向光标读的是**屏幕上的值**(已乘系数、已加偏移)→ 原始值域与阈值完全不相交。 +- **修法**:判定值一律取**绘制值** + `ApplyDrawTransform(style, values[SourceIndexFor(style, count, index)])`; + * 中心轴版同构:两侧都套用同一套变换(`drawn - drawnCenter`),否则比较的不是屏幕上的两条曲线; + * **水平镜像**时曲线数组按**源下标**存储、而区间是**显示下标** → 请求曲线窗口前必须把 + `[needFirst, needEnd)` 映射为 `[count-needEnd, count-needFirst)`,否则取到完全无关的一段。 +- ⚠️ **缓存必须带样式指纹**:`EdgeCountEntry` / `EdgeCenterCountEntry` 新增 `styleKey` + (`EdgeStyleKey()`:`gain`/`offsetY` 按 1e6 定点 + `absolute`/`mirrorVertical`/`mirrorHorizontal` 位), + 否则改完「调整」后仍会命中变换前的旧计数。 + +#### 二、时间区间**之外**的采样不得计入(区间边界点) + +- **症状(用户报告)**:A 在阈值以下、B 超过阈值、C 比 B 高;双光标下**一个光标正好在 B 点**、 + 另一个在 C 之后 → 计数区间应是"B 之后",**B 本身不该算作一个上升沿**,但旧实现把它算进去了。 +- **根因**:`EdgeIndexRange`(时间轴单调时的下标区间)在**左端多取了一个采样点** + (`if (begin > 0) --begin;`,注释写着"使跨越区间边界的边沿也能被计入")→ 采样对 `(A, B)` + 的两个端点里有一个在选区之外,却被计入。 +- **修法**:**删掉该外扩**,并把语义收紧为「**只有两个端点都落在 `[begin, end)` 内的采样对** + 才计入」(`begin = lower_bound(rangeMin)`、`end = upper_bound(rangeMax)`,调用方从 `begin + 1` + 枚举到 `end - 1`;增量路径的差集公式不变)。非单调时间轴的逐点 NaN 过滤本来就是这个语义。 +- 结果:把区间起点从 `times[crossing-1]` 移到 `times[crossing]` 恰好**少一个上升沿**(已断言)。 + +#### 三、派生信号把 `offsetTime` **也烘进采样值**(**推翻 §9.19 的"仍需继承"**) + +- **用户要求原文**:"派生信号与显示的预览信号需要相同,且烘焙出来的新信号**默认不启用任何「调整」** + 时就应该与预览时相同"。→ §9.19 写的"时间轴偏移是唯一的例外,仍由新信号的样式携带"**已作废**。 +- **修法**(`Application::BuildDerivedSignal`,在原有「值变换 + 水平镜像反序」之后追加一步): + * 新信号第 i 个采样 = **预览曲线在 `time[i]` 时刻显示的值** + = 已变换的曲线在 `time[i] - offsetTime` 处的取值(**取最近的采样点**); + * 单调时间轴的 `target = time[i] - offsetTime` 随 i 单调不减 → 用**只前进的游标**,整段 O(n); + * 平移后越界的采样置 **NaN**(与预览"该处没有曲线"一致); + * 新信号样式**全部保持默认**(`gain=1 / absolute=false / 两个镜像=false / offsetY=0 / offsetTime=0`)。 +- ⚠️ **退化路径**:时间轴非单调或时间列缺失时无法按下标推进取样 → 退回"把 `offsetTime` 带在 + 新信号样式上"(两条路径的**显示效果相同**,只是一条把偏移留在样式里)。 +- 代价:整段重采样需要一份等长临时缓冲(千万级采样 ≈ 数十 MB),用完即释放;属一次性构建操作。 + +#### 四、图例托底统一为 **GX 编号徽标同款**(并新增更小的 `uiTiny` 字体) + +- **用户要求**:"修改图例的托底为 GX 编号同款,大概可以直接复用 GX 编号的样式,只不过「GX」部分 + 变成图例色块和文本;注意现在的图例文本高度太大了可能放不下 GX 标签同款的托底,这个需要调整 + 字符大小,并且左右边距和上下边距与 GX 标签的样式相同"。 +- **实现**(`PaintLaneLegend`): + * 托底**高度 = `kLaneBadgeHeight`**、上下边界与 `LaneBadgeRect()` **完全一致**(同一 legendBand 内垂直居中); + * 圆角 = **高度的一半**(胶囊形,与徽标的 `badgeHeight * 0.5f` 一致);配色/描边与徽标相同 + (`cardBg`/`border`,选中态 `accentSoft`/`accent`); + * **水平内边距 = 胶囊半径**(小于半径时内容会溢出圆角之外); + * 文本改用**新增字体 `UiFonts::uiTiny`(10 逻辑 px)**:原 `uiSmall`(12px)几乎顶满 17px 高的胶囊; + * `LegendItem` 拆出 `plateWidth`(= 内容宽 + 2×内边距),条目步进 = `plateWidth + kLegendItemGap`; + `kLegendPlateRadius` **已删除**(半径一律由徽标高度推出)。 +- ⚠️ `LegendHit.rect` **同时是命中矩形与悬停重绘的托底** —— 看到的必须严格等于点得到的; + `LegendHit` 里保存的 `swatch/text/label/颜色/radius` 也必须与绘制用**同一套**(悬停时在不透明托底上原样重绘)。 + +#### 五、「系数/偏移」「镜像」二级面板改为**向上展开** + +- `PopupMenu::Open(scope, bounds, x, y, items, bool expandUp = false)` 新增末位参数: + `expandUp = true` 时 `(x, y)` 是菜单的**左下角**、菜单**向上展开**(默认仍是原来的"向右下展开、 + `(x, y)` = 左上角");`BuildLayout` 增加同参数并记录 `m_buildExpandUp`(`RebuildLayout` 复用它, + 否则数值回写导致的重新排布会让面板跳回向下展开)。 +- `SignalPanel::OpenGainMenu` / `OpenMirrorMenu`:锚点 = `(button.X, button.Y - kAdjustMenuGap)` + (新增常量 `kAdjustMenuGap = 4` 逻辑像素)→ **面板左下角对准按钮左上角、再略微上去一点**; + 超出窗口时仍会向左翻转并做边界收敛。 + +#### 六、验证(全部离屏,不占用鼠标/键盘/屏幕) + +- `.preflight/uitest`(窗口 `SW_HIDE` + 直接注入消息 + 从 `m_backDc` 截图)新增 `[AR]`: + * 边沿计数:`plain` / `gain=50`(阈值在**绘制刻度**上)/ `offsetY`(整条曲线被抬到光标之上 → 0 个边沿)/ + `mirrorVertical` / `mirrorHorizontal` **逐个与独立参考实现(按定义逐点重算)逐位一致**; + `offsetY` 用例同时给出 A/B:**按原始采样值算会得到 7/6 个边沿**(正是用户报的缺陷); + * 区间边界:把区间起点从 `crossing-1` 移到 `crossing` **恰好少一个上升沿**,且与参考实现一致; + * 中心轴:`absolute + offsetY` 的样式下与参考实现**逐位一致**(32234 个边沿量级); + * 派生信号:无偏移时**全部 128 万采样**逐点等于 `transform(滤波)`;带 +10 个平均采样周期的偏移时, + 抽样点等于 `transform(滤波)` 在 `time[i] - offsetTime`(最近采样)处的取值;**样式全默认**; + * 图例托底:`height == 徽标高度`、`Y == 徽标 Y`、`radius == height/2`、色块/文本都在托底之内; + * 调整面板:菜单左边界 == 按钮左边界、`菜单底 == 按钮顶 - 4*scale`、且**不覆盖按钮**(两个按钮各测一次); + * GX 拖拽:`±0.6H` 分别让位 1 格(目标 0 / 3),`±0.4H` 与**恰好 ±0.5H 都不让位**(严格"超过")。 +- ⚠️ **同时把 harness 里两处"旧规则"断言改成新规则**(否则它们必然 FAIL,且会误导后来者): + `[AP]` 原断言"派生信号继承调整变换(与预览一致)"→ 改为「样式默认 + 采样值按 `time[i]-offsetTime` 烘焙」; + `[AN]` 原断言"图例带无托底(网格线可透出)"→ 改为「装饰矩形**逐块**、无一块跨越整条带、 + 未被托底覆盖的列仍能看到底层内容(非网格底色像素 > 0)」。 +- 合计 **722/722 通过**;`make release` 零警告。 +- ⚠️ **本机提交时 `Target.exe` 常被用户占用**(链接报 `cannot open output file ... Permission denied`): + 编译不受影响,**不要 `make clean`**、也不要杀用户进程;用临时 `-o Build/Release/Target_check.exe` + 手工链接一次即可验证链接正确性。 + +--- + ## 10. 变更日志(本文件维护记录) | 日期 | 设备/模型 | 摘要 | @@ -929,7 +1030,8 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸( | 2026-10-13 | DeepSeek-V4.1-Flash(**另一台设备**) | **交互流畅性三项修复**(详见 §9.17):① **光标不再跟随视界**——删除 `ClampCursorsAfterPan()` 及其在平移路径(中键/左键拖拽、Shift+滚轮、方向键)的三处调用,视图变化时光标时间位置**完全不变**(可滑出视口),`ClampCursorToView()` 只在拖拽光标时使用(mode 14:平移 20 帧后 `CursorTime` 不变且 `offViewport=1`)。② **缩放首帧不再卡顿**——根因是跨度变化时 `Paint` 会**同步重建全部 5 个网格图层**(实测 70~80ms)+ 随后整幅结算(40ms)=单次缩放 ~110ms 卡顿;修法:`BlitWideBuffer` 对跨度过期的图层改用**旧像素 `StretchBlt` 拉伸占位**(不再跳过导致空白)、`Paint` 只在"图层数量不符"时同步重建(新增 `LayersAllocated()`)、`RenderPendingLaneRebuilds` 允许按新跨度重建、且**只在视界稳定后**才花预算精细重绘(`viewStopped` 门控);实测首帧 **70~80ms → ~10ms**。保留"结算"帧(实测宽基底路径与整幅重绘有 17 万+ 像素列相位差,非同源)。③ **超大信号拖预览滑块**——量化发现开销**不在绘制**(`PaintLaneDerivedOverlays` 仅 0.375ms),而在"参数每帧变化"触发的**整条信号**(2576 万采样)预览重算调度;新增 `kHugeSignalPreviewSamples=4000000`,超大信号拖拽期间不再每帧排队重算(15.0→13.7ms,**剩余 ~8ms 根因未完全定位**)。验证:`pancheck` 7 场景全 `dy=0`、`zoomcheck` mode 0/13/14、`make clean && make all` 零警告。 | | 2026-10-13 | DeepSeek-V4.1-Flash(**另一台设备**) | **深放大中键拖拽卡顿(根因=GDI+ 抗锯齿"满幅锯齿"病态)+ 横向补条裁剪 + 并行网格图层(B/C,详见 §9.18)**。① **B 拆解**:该视界(`[10.2719,10.2831]s`)下 `RollWideBuffer` 补条重绘 **13.08ms**,其余 ≤0.8ms —— 瓶颈全在补条重绘。② **补条裁剪**:横向补条只覆盖窄竖带,却按整条宽窗口构建/描边整段折线;改为按条带 x 范围裁剪采样区间(旁路折线缓存),`RollWideBuffer` **13.08ms → 1.73ms**,拖拽整帧 13~24ms → ~3ms,且**逐像素 0 差异**。③ **病态根因**:拖拽超出预渲染余量时走整块重画,**整宽单网格渲染 655ms** —— 同 4481 点 `DrawLines` 实测 **AA 694ms / 无 AA 29ms(23 倍)**(编码器方波在 1~2 采样/像素形成每列整幅跳变的锯齿,原实现此时既不走包络也不关 AA);修法:**包络带阈值从 2 采样/像素降到 1**,并扩展"梳状(关 AA)"判据(多数列列内跨幅 >0.5×网格高 / 相邻列整幅跳变)——**几何一字未改**;整宽渲染 **655→47.7ms**、补条 6.9→2.4ms、拖拽 200 帧 **avg 4.5 / max 5.8ms / 0 帧 >8ms**。④ **C(用户解除多线程限制)**:实现 `RebuildLaneLayersParallel` —— 每线程一个**隔离 `PlotView`**(自有 `m_workerTab`/`m_area`/`m_plotRect`/`m_lanes`)渲染不同网格,主线程 join 后按真实下标重算 key;并行 vs 顺序**逐像素 0 差异**;⚠️ 踩坑:`BuildLaneVisuals` 在 `m_tab==nullptr` 时清空 `m_lanes`,故**必须给 worker 一个非空的自有 tab**。**诚实结论:并行仅 1.03x**(同机 4 线程 CPU 真并行 3.98x → GDI+ 光栅化被串行化),故**并行对光栅化无收益**,仍保持"视界停后每帧补 1 个网格"。验证:`dragperf` mode 0/1/3/5/6/8/9、`pancheck` 全 `dy=0`、`zoomcheck` 无回归、`make clean && make all` 零警告。 | | 2026-10-14 | DeepSeek-V4.1-Flash | **装饰层改为带 alpha 的独立图层(消除 GX/图例外围"直角白边")+ 图例圆角托底与悬停/选中配色统一 + 预览曲线内存释放(详见 §9.20)**。① **白边根因**:装饰层原为"从后备缓冲区抓取一块**不透明矩形**",圆角之外(属于波形)的像素被一起烘进缓存,为让其跨视界可复用又在圆角外铺了直角不透明底色 ⇒ 密集波形上就是一圈直角白边。**修法**:`m_decorDc` 改 **32bpp DIBSection**、新增 `RenderDecorCache()` 把装饰**单独绘制**进全透明位图(不再抓取)、`BlitDecorCache()` 改用 **`AlphaBlend`**(`AC_SRC_ALPHA`,`GetProcAddress(gdi32!GdiAlphaBlend)` 解析、不新增链接依赖)⇒ 圆角外透出**当前**波形;并做 **alpha 修补**(GDI 文本会把写入像素的 alpha 清 0,不修补文字会变透明洞);删除全部外扩直角底色。② **图例**:逐条目圆角托底(与 GX 徽标同风格),**选中态 = 悬停态配色**(`accentSoft`+`accent`+`textStrong`),悬停在不透明托底上重绘色块/文字并保留曲线光晕;`LegendHit` 新增 `radius`;补齐「+N」/「(无可见信号)」提示的装饰矩形。③ **内存**:`thread_local` 工作缓冲**保留容量封顶**(`kReuseBufferMaxElements` + `ReuseBufferScope`,超大计算用完即释放)+ 关闭最后一个预览功能时 `TrimDerivedCurveDataIfIdle()`(`PauseDerivedAsync` 让线程退出、thread_local 析构归还内存)—— 实测 1792MB → **关闭后 1203MB**(修复前不下降)。④ 性能:`BlitDecorCache` **0.19ms/帧**、`RenderDecorCache` 2.4ms(仅内容变化时);`zoomcheck` mode 13 收敛帧与整幅重绘**逐像素 0 差异**(此前 3503)。验证:离屏 `verify.exe` **49/49 通过**、`make release` 零警告。 | -| 2026-10-14 | DeepSeek-V4.1-Flash | **修复远端「调整」功能引入的性能回归 + 根治首帧卡顿 + 图例交互/托底重做(详见 §9.19)**。① **8 倍回归**:远端把 `DataSet::Style(ref)` 写进了 `AutoRangeLaneToView` 的逐采样循环(亿级解析调用)→ 工具栏「Y 轴自适应」在全视图下 **1200ms**(旧提交 147ms);修法:样式指针提循环外 + **整段自适应取已缓存 min/max(O(1),结果逐位一致)** + 无变换走紧凑循环 → **0.02ms**。② **首帧卡顿根治**:加载后宽基底图层从未分配,用户第一次平移/缩放时 `Paint` 在那一帧**同步重建全部 5 个网格图层**(首帧 **64.3 → 13.0ms**);修法:新增 `WarmLaneLayersStep()`,在**命中静态场景缓存**(视界未变、非拖拽)时用预算机制**每帧至多补 1 个网格**,把整批重建摊到静止帧。③ **派生信号把「调整」烘进数据**(不再继承样式指纹),仅 `offsetTime` 因共用文件时间轴仍需继承。④ **GX 拖拽让位判据改为鼠标指针**(鼠标越过相邻网格中心水平线即让位),并给 GX 徽标加悬停高亮(叠加层 + `IDC_HAND`,与信号列表 GX 徽标一致)。⑤ **图例命中矩形在装饰缓存路径下必须重建**(原先命中缓存时提前 return → `m_legendHits` 被清空且不重建 → 中键平移后图例悬停/点选全失效)。⑥ **每个图例条目加圆角托底 + 装饰矩形改为逐块 + 托底不透明铺满外接矩形** → 消除「GX/图例周边旧波形像素残留」(逐像素验证装饰矩形内 **0 差异**);悬停高亮为不透明托底并**重绘色块与文字**(否则内容被盖住)。验证:离屏 Application harness **40/40 通过**,`zoomcheck` mode 20–26 量化,`make release` 零警告。 | +| 2026-10-14 | DeepSeek-V4.1-Flash | **修复远端「调整」功能引入的性能回归 + 根治首帧卡顿 + 图例交互/托底重做(详见 §9.19)**。① **8 倍回归**:远端把 `DataSet::Style(ref)` 写进了 `AutoRangeLaneToView` 的逐采样循环(亿级解析调用)→ 工具栏「Y 轴自适应」在全视图下 **1200ms**(旧提交 147ms);修法:样式指针提循环外 + **整段自适应取已缓存 min/max(O(1),结果逐位一致)** + 无变换走紧凑循环 → **0.02ms**。② **首帧卡顿根治**:加载后宽基底图层从未分配,用户第一次平移/缩放时 `Paint` 在那一帧**同步重建全部 5 个网格图层**(首帧 **64.3 → 13.0ms**);修法:新增 `WarmLaneLayersStep()`,在**命中静态场景缓存**(视界未变、非拖拽)时用预算机制**每帧至多补 1 个网格**,把整批重建摊到静止帧。③ **派生信号把「调整」烘进数据**(不再继承样式指纹);⚠️ 当时写的“仅 `offsetTime` 因共用文件时间轴仍需继承”**已被 §9.21 推翻**(现在 `offsetTime` 也按 `time[i]-offsetTime` 重采样烘进采样值)。④ **GX 拖拽让位判据改为鼠标指针**(鼠标越过相邻网格中心水平线即让位),并给 GX 徽标加悬停高亮(叠加层 + `IDC_HAND`,与信号列表 GX 徽标一致)。⑤ **图例命中矩形在装饰缓存路径下必须重建**(原先命中缓存时提前 return → `m_legendHits` 被清空且不重建 → 中键平移后图例悬停/点选全失效)。⑥ **每个图例条目加圆角托底 + 装饰矩形改为逐块 + 托底不透明铺满外接矩形** → 消除「GX/图例周边旧波形像素残留」(逐像素验证装饰矩形内 **0 差异**);悬停高亮为不透明托底并**重绘色块与文字**(否则内容被盖住)。验证:离屏 Application harness **40/40 通过**,`zoomcheck` mode 20–26 量化,`make release` 零警告。 | +| 2026-10-14 | DeepSeek-V4.1-Flash(**起源设备**) | **边沿计数改用「绘制值」+ 派生信号烘入时间偏移 + 图例托底统一为 GX 徽标款 + 调整二级面板向上展开(详见 §9.21)**。① **边沿计数恒为 0**:`EdgeCountsFor`/`EdgeCenterCountsFor` 只比原始采样值,而阈值是屏幕上的值 → 「系数 50」的信号统计恒为 0;修法:判定值改为 `ApplyDrawTransform(style, values[SourceIndexFor(...)])`(中心轴两侧同套变换;水平镜像时曲线窗口也要先映射下标);缓存新增 **`styleKey`**(否则改完「调整」仍命中旧计数)。② **区间边界点被误计**:`EdgeIndexRange` 左端“多取一个采样点”的外扩**已删除**,收紧为“**两个端点都在区间内**才计入”(光标恰落在越过阈值的点 B 上时 B 不再被算作上升沿)。③ **`offsetTime` 也烘进派生采样值**(**推翻 §9.19 的“唯一例外”**):按 `time[i]-offsetTime` 取最近采样点重采样(单调时间轴用只前进的游标,O(n);越界置 NaN),新信号样式**全默认**;非单调时间轴退回到把 `offsetTime` 带在样式上。④ **图例托底 = GX 徽标同款**(同高 / 圆角 = 高度一半 / 同配色 / 水平内边距 = 胶囊半径),文本改用新增字体 **`uiTiny`(10 逻辑 px)**;`kLegendPlateRadius` 已删。⑤ **`PopupMenu::Open(..., expandUp=true)`**(锤点为**左下角**、向上展开,`m_buildExpandUp` 供 `RebuildLayout` 复用)+ 「系数/偏移」「镜像」面板锤在按钮左上角再上移 `kAdjustMenuGap`。验证:`.preflight/uitest` 新增 `[AR]`(五组断言全部与**独立参考实现**逐位比对,含 A/B:“按原始采样值算会得到 7/6 个边沿”);**并同步修正 harness 里两处旧规则断言**(`[AP]` 派生信号继承 → 默认样式 + 烘值;`[AN]` 图例带无托底 → 装饰矩形逐块且暴露列可见底层内容);合计 **722/722 通过**,`make release` 零警告。 | --- @@ -978,11 +1080,12 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸( (**包括装饰缓存命中、提前 return 的那条**)都必须**先重建命中矩形**再决定是否跳过绘制 —— 否则清空后不再填充,图例的悬停高亮 / 点击选中 / 双击改名会**永久失效** (用户报告"用中键平移过视界后图例点不动")。同理 `m_editFieldRect` 也必须在几何段设置。 -18. ⚠️ **装饰缓存的像素必须与时间视界无关**:`CaptureDecorCache` 的捕获边界由 `floor/ceil(rect)` 得出, - 因此每块装饰(GX 徽标 / 图例托底 / Y 轴胶囊)都必须**先用不透明底色铺满"外接整数像素再外扩 1px"的矩形** - 再画圆角。否则圆角之外的那几个像素仍是波形 → 平移贴回时表现为"GX/图例周边旧波形像素残留"。 +18. ⚠️ **装饰缓存的像素必须与时间视界无关**:装饰位图的贴回边界由 `floor/ceil(rect)` 得出, + 因此每块装饰(GX 徽标 / 图例托底 / Y 轴胶囊)都必须画成**不透明的圆角形状本身** + (`RenderDecorCache` + `AlphaBlend` 方案下圆角之外**保持全透明**,不要再用外扩的直角不透明底色)。 装饰矩形也必须**逐块**(禁止覆盖整条图例带)。验证方法:宽基底实时帧 vs 同视界从零重绘, **装饰矩形内必须 0 差异**。 + (历史:早期实现是 `CaptureDecorCache` 从后备缓冲区抓不透明矩形 —— 该函数**已删除**。) 19. ⚠️ **逐采样循环里禁止调用 `DataSet::Style(ref)` / `Resolve()` 等按下标解析的函数**: `AutoRangeLaneToView` 曾因此把全段自适应从 147ms 劣化到 **1200ms(8 倍)**。 一律把指针提到循环外。整段(`begin==0 && end==count`)且无变换/无水平镜像时, @@ -990,6 +1093,23 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸( 20. ⚠️ **"整批图层重建"绝不允许发生在交互帧里**:新增 `WarmLaneLayersStep()`, 在**命中静态场景缓存**(视界未变、非拖拽)时用既有预算机制**每帧至多补 1 个网格**并自请求下一帧。 否则"加载后的第一次平移 / 选区放大"那一帧会同步重建全部网格图层(首帧 64ms → 预热后 13ms)。 +21. ⚠️ **派生信号必须"烘值 + 默认样式"**(推翻 §9.19 的"`offsetTime` 仍需继承",见 §9.21): + 「调整」的**全部**项(含 `offsetTime`)都写进派生采样值,新信号样式一律默认; + 否则「调整」面板会带着一整套继承来的指纹,或新信号与预览横向错位。 + 只有"无法烘值"的退化情形(时间轴非单调 / 时间列缺失)才退回把 `offsetTime` 带在样式上。 +22. ⚠️ **一切"按屏幕上的值"做的统计/判定都必须用 `ApplyDrawTransform` 之后的绘制值**: + 横向光标阈值、边沿计数(`EdgeCountsFor` / `EdgeCenterCountsFor`)、Y 轴自适应都属此类。 + 用原始采样值判定会得到"应用过「调整」的信号统计恒为 0"这类完全错误的结论。 + 这类**缓存**的键必须带**样式指纹**(`EdgeStyleKey()`),否则改完「调整」仍命中旧结果。 +23. ⚠️ **限定统计区间时,"区间之外"的采样一律不参与**:只有**两个端点都在区间内**的采样对才计入。 + 不要在区间左端"多取一个采样点"来照顾跨边界的边沿 —— 那会把选区之外的那一段也算进来 + (用户报告:"光标正好落在越过阈值的点 B 上时,B 被错认成上升沿")。 +24. ⚠️ **图例托底 = GX 编号徽标同款**(同高 / 圆角 = 高度的一半 / 同配色 / 水平内边距 = 胶囊半径), + 文本用更小的 **`uiTiny`(10 逻辑 px)**;`LegendHit.rect` 同时是命中矩形与悬停重绘的托底, + 看到的必须严格等于点得到的。 +25. ⚠️ **`PopupMenu::Open(..., expandUp = true)`** 表示"锚点是菜单**左下角**、向**上**展开"。 + 给按钮挂"二级面板"时用它(并把锚点设在按钮左上角再上移 `kAdjustMenuGap`); + `RebuildLayout()` **必须复用 `m_buildExpandUp`**,否则数值回写触发的重排会让面板跳回向下展开。 ### 11.3 离屏验证方法学(本机偏好,强烈建议沿用) diff --git a/Utilities/Inc/PaintUtil.hpp b/Utilities/Inc/PaintUtil.hpp index bc4c452..1aa4f16 100644 --- a/Utilities/Inc/PaintUtil.hpp +++ b/Utilities/Inc/PaintUtil.hpp @@ -46,6 +46,7 @@ namespace jdi HFONT uiBold = nullptr; /**< 加粗界面字体。 */ HFONT uiSmall = nullptr; /**< 小号界面字体。 */ HFONT uiSmallBold = nullptr;/**< 小号加粗界面字体。 */ + HFONT uiTiny = nullptr; /**< 微型界面字体(图例文字:必须能放进 GX 徽标同款的托底)。 */ HFONT title = nullptr; /**< 标题字体。 */ HFONT mono = nullptr; /**< 等宽数值字体。 */ HFONT monoSmall = nullptr; /**< 小号等宽字体。 */ diff --git a/Utilities/Inc/PopupMenu.hpp b/Utilities/Inc/PopupMenu.hpp index 68a8834..8c20c43 100644 --- a/Utilities/Inc/PopupMenu.hpp +++ b/Utilities/Inc/PopupMenu.hpp @@ -124,8 +124,12 @@ namespace jdi * @param [IN] x 锚点横坐标(客户区坐标)。 * @param [IN] y 锚点纵坐标(客户区坐标)。 * @param [IN] items 菜单项集合。 + * @param [IN] expandUp 为 true 时菜单**向上展开**,且 `(x, y)` 是菜单的**左下角** + * (用于“二级面板贴在按钮左上方”这类锚定);为 false(默认)时向右下展开, + * `(x, y)` 是菜单的左上角。 */ - void Open(MenuScope scope, const gd::RectF& bounds, int x, int y, const std::vector& items); + void Open(MenuScope scope, const gd::RectF& bounds, int x, int y, + const std::vector& items, bool expandUp = false); /** * @brief 关闭菜单。 @@ -276,8 +280,9 @@ namespace jdi * @param [IN] bounds 宿主客户区矩形。 * @param [IN] x 锚点横坐标。 * @param [IN] y 锚点纵坐标。 + * @param [IN] expandUp 为 true 时以 `(x, y)` 为**左下角**向上展开。 */ - void BuildLayout(const gd::RectF& bounds, int x, int y); + void BuildLayout(const gd::RectF& bounds, int x, int y, bool expandUp); /** * @brief 查询坐标命中的菜单项下标。 @@ -420,6 +425,7 @@ namespace jdi gd::RectF m_buildBounds; /**< 最近一次排布使用的宿主矩形。 */ int m_buildX = 0; /**< 最近一次排布使用的锚点横坐标。 */ int m_buildY = 0; /**< 最近一次排布使用的锚点纵坐标。 */ + bool m_buildExpandUp = false; /**< 最近一次排布是否为“向上展开(锚点为左下角)”。 */ bool m_subOpen = false; /**< 二级子菜单是否展开。 */ int m_subParentIndex = -1; /**< 展开子菜单的父项下标。 */ diff --git a/Utilities/Src/PaintUtil.cpp b/Utilities/Src/PaintUtil.cpp index 853aaff..7be19b9 100644 --- a/Utilities/Src/PaintUtil.cpp +++ b/Utilities/Src/PaintUtil.cpp @@ -145,6 +145,7 @@ namespace jdi const double scale = static_cast(dpi) / 96.0; const int uiHeight = static_cast(std::lround(14.0 * scale)); const int smallHeight = static_cast(std::lround(12.0 * scale)); + const int tinyHeight = static_cast(std::lround(10.0 * scale)); const int titleHeight = static_cast(std::lround(15.0 * scale)); const int monoHeight = static_cast(std::lround(13.0 * scale)); @@ -152,16 +153,18 @@ namespace jdi uiBold = CreateUiFont(L"Segoe UI Semibold", uiHeight, true); uiSmall = CreateUiFont(L"Segoe UI", smallHeight, false); uiSmallBold = CreateUiFont(L"Segoe UI Semibold", smallHeight, true); + uiTiny = CreateUiFont(L"Segoe UI", tinyHeight, false); title = CreateUiFont(L"Segoe UI Semibold", titleHeight, true); mono = CreateUiFont(L"Consolas", monoHeight, false); monoSmall = CreateUiFont(L"Consolas", smallHeight, false); - return ui != nullptr && uiBold != nullptr && uiSmall != nullptr && title != nullptr && mono != nullptr; + return ui != nullptr && uiBold != nullptr && uiSmall != nullptr && uiTiny != nullptr && + title != nullptr && mono != nullptr; } void UiFonts::Destroy() { - const HFONT resources[] = {ui, uiBold, uiSmall, uiSmallBold, title, mono, monoSmall}; + const HFONT resources[] = {ui, uiBold, uiSmall, uiSmallBold, uiTiny, title, mono, monoSmall}; for (const HFONT font : resources) { if (font != nullptr) @@ -173,6 +176,7 @@ namespace jdi uiBold = nullptr; uiSmall = nullptr; uiSmallBold = nullptr; + uiTiny = nullptr; title = nullptr; mono = nullptr; monoSmall = nullptr; diff --git a/Utilities/Src/PopupMenu.cpp b/Utilities/Src/PopupMenu.cpp index 3ffc624..2b5a38c 100644 --- a/Utilities/Src/PopupMenu.cpp +++ b/Utilities/Src/PopupMenu.cpp @@ -110,7 +110,7 @@ namespace jdi } } - void PopupMenu::BuildLayout(const gd::RectF& bounds, int x, int y) + void PopupMenu::BuildLayout(const gd::RectF& bounds, int x, int y, bool expandUp) { m_itemRects.assign(m_items.size(), gd::RectF()); m_swatchRects.assign(m_items.size(), std::vector()); @@ -197,14 +197,15 @@ namespace jdi m_editFieldWidth = labeledFieldWidth; float left = static_cast(x); - float top = static_cast(y); + // `expandUp`:锚点是菜单的**左下角**(向上展开);否则锚点是左上角(向右下展开)。 + float top = expandUp ? (static_cast(y) - height) : static_cast(y); - // 优先向右下展开;越界时向左或向上翻转,最后做边界收敛。 + // 优先向右展开(向左下/右下);越界时向左或向上翻转,最后做边界收敛。 if (left + width > bounds.X + bounds.Width) { left = static_cast(x) - width; } - if (top + height > bounds.Y + bounds.Height) + if (!expandUp && top + height > bounds.Y + bounds.Height) { top = static_cast(y) - height; } @@ -511,7 +512,7 @@ namespace jdi return; } const int subParent = m_subParentIndex; - BuildLayout(m_buildBounds, m_buildX, m_buildY); + BuildLayout(m_buildBounds, m_buildX, m_buildY, m_buildExpandUp); // 主菜单重新排布后子菜单位置会失效:若之前是展开的则按新几何重建。 if (subParent >= 0) { @@ -631,7 +632,7 @@ namespace jdi } void PopupMenu::Open(MenuScope scope, const gd::RectF& bounds, int x, int y, - const std::vector& items) + const std::vector& items, bool expandUp) { m_items = items; if (m_items.empty()) @@ -650,10 +651,11 @@ namespace jdi m_buildBounds = bounds; m_buildX = x; m_buildY = y; + m_buildExpandUp = expandUp; CloseSubmenu(); CloseEditState(); - BuildLayout(bounds, x, y); + BuildLayout(bounds, x, y, expandUp); } void PopupMenu::Close()