边沿计数改用「绘制值」+ 派生信号烘入时间偏移 + 图例托底统一为 GX 徽标款 + 调整二级面板向上展开

一、两个「边沿计数」按「画出来的曲线」判定(原先按原始采样值 → 应用过「调整」的信号恒为 0)
- 用户复述的权威定义:设定一个横向(Y 轴)光标,信号绘制出来的曲线从下往上穿过该幅值 = 上升沿,
  从上往下穿过 = 下降沿。
- 症状:VERY_BIG_DATA 的「编码器B相」设系数 50 + 时间轴偏移 -20s,把光标拖到画面中间,
  很规整的方波却统计出 0/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 超过阈值;双光标下一个光标正好在 B 点,旧实现把 B 算成一个上升沿。
- 根因:EdgeIndexRange 在左端多取了一个采样点(if (begin > 0) --begin;)。
- 修法:删掉该外扩,收紧为「只有两个端点都落在 [begin, end) 内的采样对才计入」
  (begin = lower_bound(rangeMin)、end = upper_bound(rangeMax),调用方从 begin+1 枚举到 end-1)。
- 结果:把区间起点从 times[crossing-1] 移到 times[crossing] 恰好少一个上升沿。

三、派生信号把 offsetTime 也烘进采样值(推翻 §9.19 的「唯一例外」)
- 用户要求:新信号默认不启用任何「调整」时就必须与网格里的预览逐点重合。
- 修法(Application::BuildDerivedSignal,在「值变换 + 水平镜像反序」之后追加一步):
  新信号第 i 个采样 = 已变换的曲线在 time[i] - offsetTime 处的取值(取最近的采样点);
  单调时间轴用只前进的游标(整段 O(n));平移后越界的采样置 NaN(与预览一致);
  新信号样式全部保持默认(gain=1 / absolute=false / 两个镜像=false / offsetY=0 / offsetTime=0)。
- 退化路径:时间轴非单调或时间列缺失时无法按下标推进取样,退回把 offsetTime 带在样式上
  (两条路径的显示效果相同)。

四、图例托底统一为 GX 编号徽标同款 + 新增更小字体 uiTiny
- 托底高度 = kLaneBadgeHeight、上下边界与 LaneBadgeRect() 完全一致、圆角 = 高度的一半(胶囊形)、
  配色与描边与徽标相同(选中态 accentSoft/accent)、水平内边距 = 胶囊半径。
- 文本改用新增字体 UiFonts::uiTiny(10 逻辑 px):原 uiSmall(12px) 几乎顶满 17px 高的胶囊。
- LegendItem 拆出 plateWidth(托底宽 = 内容宽 + 2×内边距),条目步进 = plateWidth + kLegendItemGap;
  kLegendPlateRadius 已删除(半径一律由徽标高度推出)。

五、「系数/偏移」「镜像」二级面板改为向上展开
- PopupMenu::Open(..., bool expandUp = false):true 时 (x, y) 是菜单的左下角、菜单向上展开;
  BuildLayout 增加同参数并记录 m_buildExpandUp(RebuildLayout 必须复用,否则数值回写触发的重排
  会让面板跳回向下展开)。
- SignalPanel::OpenGainMenu / OpenMirrorMenu:锚点 = (button.X, button.Y - kAdjustMenuGap),
  即「面板左下角对准按钮左上角、再略微上去一点」(kAdjustMenuGap = 4 逻辑像素)。

六、验证(全部离屏,不占用用户的鼠标 / 键盘 / 屏幕)
- .preflight/uitest 新增 [AR]:
  * 边沿计数 plain / gain=50 / offsetY / 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] 「图例带无托底(网格线可透出)」→ 「装饰矩形逐块、无一块跨越整条带、未被托底覆盖的列
  仍能看到底层内容」。
- 合计 722/722 通过;make release 零警告(本机 GCC 15.2.0 UCRT;Target.exe 被占用时链接会报
  Permission denied,已用临时 -o 名字验证链接正确性)。

规则同步:Documents/agent_mem.md 新增 §9.21,§9.19 的「offsetTime 仍需继承」标注为已作废,
新增 §11.2 第 21–25 条硬约束,并补变更日志一行。
This commit is contained in:
DeepSeek V4.1 Flash committed 2026-10-14 11:40:49 +08:00
1 parent 1b3ab4ac09
commit a2a38236fe
9 files changed
+434 -118

No files matched your search

+57 -7
View File
@@ -1535,9 +1535,10 @@ namespace jdi
// * 数值域变换(系数 → 绝对值 → 垂直镜像 → Y 偏移)**与**水平镜像(数据反序) // * 数值域变换(系数 → 绝对值 → 垂直镜像 → Y 偏移)**与**水平镜像(数据反序)
// 全部作用在派生采样值上; // 全部作用在派生采样值上;
// * 于是新信号**用默认外观**绘制出来就与预览曲线逐位一致。 // * 于是新信号**用默认外观**绘制出来就与预览曲线逐位一致。
// * ⚠️ **时间轴偏移是唯一的例外**:它改变的是"绘制在哪个时刻",而派生信号与源信号 // * ⚠️ **时间轴偏移也一并烘进数据**(见 2c:按 `time[i] - offsetTime` 重采样)。
// 共用同一个文件时间轴,无法把时间平移烘进采样数据,因此仍由新信号的样式携带 // 新信号与源信号共用同一个文件时间轴,因此时间平移只能写进采样值 —— 这样
// (否则新信号会相对预览整体横向错位)。 // 新信号**不启用任何「调整」**就与预览逐点重合(用户明确要求)。
// 只有"无法烘值"的退化情形才退回把 `offsetTime` 带在样式上(见 3b)。
SignalStyle sourceTransform; SignalStyle sourceTransform;
bool hasSourceStyle = false; 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<double>& times = file->time;
const std::size_t total = derived.values.size();
if (total > 1 && times.size() >= total)
{
const double shift = sourceTransform.offsetTime;
std::vector<double> shifted(total, std::numeric_limits<double>::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. 插入到原信号的下方(数据层 + 各标签页引用重映射)。 // 3. 插入到原信号的下方(数据层 + 各标签页引用重映射)。
int newIndex = -1; int newIndex = -1;
// 插入派生信号会移动该文件的信号存储:先暂停后台计算,避免其读取已移动的数据。 // 插入派生信号会移动该文件的信号存储:先暂停后台计算,避免其读取已移动的数据。
@@ -1611,10 +1661,10 @@ namespace jdi
tab.RemapAfterSignalInsert(request.fileIndex, newIndex); tab.RemapAfterSignalInsert(request.fileIndex, newIndex);
} }
// 3b. 仅把**时间轴偏移**带到新信号上(唯一无法烘进数据的一项,见上面第 0 步说明)。 // 3b. 时间轴偏移正常情况下已烘进采样值(见 2c),新信号的「调整」全部保持默认。
// 其余「调整」项(系数 / 绝对值 / 镜像 / Y 偏移)保持在默认值 —— 它们已经 // ⚠️ 只有"无法烘值"的退化情形(时间轴非单调 / 时间列缺失)才退回到把
// 烘进了采样数据,因此新信号不经过任何"调整"就与预览曲线完全一致。 // `offsetTime` 带在新信号样式上 —— 无论如何都要让**显示**与预览一致。
if (hasSourceStyle && sourceTransform.offsetTime != 0.0) if (hasSourceStyle && sourceTransform.offsetTime != 0.0 && !timeShiftBaked)
{ {
if (SignalStyle* derivedStyle = m_dataSet.Style(SignalRef{request.fileIndex, newIndex})) if (SignalStyle* derivedStyle = m_dataSet.Style(SignalRef{request.fileIndex, newIndex}))
{ {
+23 -6
View File
@@ -1307,8 +1307,12 @@ namespace jdi
* *
* 曲线由「均值点数 X + 迭代次数 N」确定,与「实验性功能 → 均值滤波」的预览曲线逐位一致。 * 曲线由「均值点数 X + 迭代次数 N」确定,与「实验性功能 → 均值滤波」的预览曲线逐位一致。
* *
* ⚠️ 判定对象是**画出来的两条曲线**:信号与中心曲线都要先按 `style` 做水平镜像映射、
* 再套用 `ApplyDrawTransform`(系数 → 绝对值 → 垂直镜像 → Y 偏移)。
*
* @param [IN] file 信号所属文件。 * @param [IN] file 信号所属文件。
* @param [IN] signal 目标信号。 * @param [IN] signal 目标信号。
* @param [IN] style 该信号的外观(含水平镜像与「调整」变换)。
* @param [IN] points 均值点数 X。 * @param [IN] points 均值点数 X。
* @param [IN] iterations 迭代次数 N。 * @param [IN] iterations 迭代次数 N。
* @param [IN] hasRange 是否只统计某个时间区间(双光标模式下为光标区间)。 * @param [IN] hasRange 是否只统计某个时间区间(双光标模式下为光标区间)。
@@ -1317,9 +1321,9 @@ namespace jdi
* @param [OUT] rising 上升沿数量(信号从下往上越过均值滤波曲线)。 * @param [OUT] rising 上升沿数量(信号从下往上越过均值滤波曲线)。
* @param [OUT] falling 下降沿数量(信号从上往下穿过均值滤波曲线)。 * @param [OUT] falling 下降沿数量(信号从上往下穿过均值滤波曲线)。
*/ */
void EdgeCenterCountsFor(const DataFile& file, const SignalData& signal, int points, int iterations, void EdgeCenterCountsFor(const DataFile& file, const SignalData& signal, const SignalStyle& style,
bool hasRange, double rangeMin, double rangeMax, std::size_t& rising, int points, int iterations, bool hasRange, double rangeMin,
std::size_t& falling) const; double rangeMax, std::size_t& rising, std::size_t& falling) const;
/** /**
* @brief 切换某个信号在「中心轴边沿计数」面板中的勾选状态。 * @brief 切换某个信号在「中心轴边沿计数」面板中的勾选状态。
@@ -1591,8 +1595,19 @@ namespace jdi
/** /**
* @brief 统计某信号穿越横向光标幅值的上/下边沿数量,结果带缓存。 * @brief 统计某信号穿越横向光标幅值的上/下边沿数量,结果带缓存。
*
* 判定定义(用户明确要求):把信号**绘制出来的曲线**与横向光标所在的幅值比较,
* 曲线从**下往上**穿过该幅值 → 上升沿;从**上往下**穿过 → 下降沿。
*
* ⚠️ 因此判定值必须是**画出来的值**:先按 `style` 做水平镜像映射,再套用
* `ApplyDrawTransform`(系数 → 绝对值 → 垂直镜像 → Y 偏移)。
* 只比较原始采样值会导致「应用过调整的信号数不到任何边沿」
*(横向光标读的是屏幕上的值,与原始值域完全不同)。
* ⚠️ 时间区间**之外**的采样一律不参与计数:只有两个端点都在区间内的采样对才计入。
*
* @param [IN] file 信号所属文件。 * @param [IN] file 信号所属文件。
* @param [IN] signal 目标信号。 * @param [IN] signal 目标信号。
* @param [IN] style 该信号的外观(含水平镜像与「调整」变换)。
* @param [IN] edgeValue 横向光标所在的幅值。 * @param [IN] edgeValue 横向光标所在的幅值。
* @param [IN] hasRange 是否只统计某个时间区间(双光标模式下为光标区间)。 * @param [IN] hasRange 是否只统计某个时间区间(双光标模式下为光标区间)。
* @param [IN] rangeMin 区间左端点时间。 * @param [IN] rangeMin 区间左端点时间。
@@ -1600,9 +1615,9 @@ namespace jdi
* @param [OUT] rising 上升沿数量(从下往上穿越)。 * @param [OUT] rising 上升沿数量(从下往上穿越)。
* @param [OUT] falling 下降沿数量(从上往下穿越)。 * @param [OUT] falling 下降沿数量(从上往下穿越)。
*/ */
void EdgeCountsFor(const DataFile& file, const SignalData& signal, double edgeValue, void EdgeCountsFor(const DataFile& file, const SignalData& signal, const SignalStyle& style,
bool hasRange, double rangeMin, double rangeMax, std::size_t& rising, double edgeValue, bool hasRange, double rangeMin, double rangeMax,
std::size_t& falling) const; std::size_t& rising, std::size_t& falling) const;
/** /**
* @brief 计算实验性功能面板所需的宽度(排版与绘制共用,保证文字完整不被省略)。 * @brief 计算实验性功能面板所需的宽度(排版与绘制共用,保证文字完整不被省略)。
@@ -1816,6 +1831,7 @@ namespace jdi
{ {
const DataFile* file = nullptr; /**< 所属数据分组。 */ const DataFile* file = nullptr; /**< 所属数据分组。 */
const SignalData* signal = nullptr; /**< 目标信号。 */ const SignalData* signal = nullptr; /**< 目标信号。 */
std::uint64_t styleKey = 0; /**< 「调整」变换 + 水平镜像的指纹(变了必须整段重算)。 */
double edgeValue = 0.0; /**< 判定阈值(横向光标幅值)。 */ double edgeValue = 0.0; /**< 判定阈值(横向光标幅值)。 */
bool hasRange = false; /**< 是否限定在时间区间内统计。 */ bool hasRange = false; /**< 是否限定在时间区间内统计。 */
bool reusable = false; /**< 计数是否完全由 [begin, end) 决定。 */ bool reusable = false; /**< 计数是否完全由 [begin, end) 决定。 */
@@ -1867,6 +1883,7 @@ namespace jdi
{ {
const DataFile* file = nullptr; /**< 所属数据分组。 */ const DataFile* file = nullptr; /**< 所属数据分组。 */
const SignalData* signal = nullptr; /**< 目标信号。 */ const SignalData* signal = nullptr; /**< 目标信号。 */
std::uint64_t styleKey = 0; /**< 「调整」变换 + 水平镜像的指纹(变了必须整段重算)。 */
int points = 5; /**< 均值点数 X。 */ int points = 5; /**< 均值点数 X。 */
int iterations = 1; /**< 迭代次数 N。 */ int iterations = 1; /**< 迭代次数 N。 */
bool hasRange = false; /**< 是否限定在时间区间内统计。 */ bool hasRange = false; /**< 是否限定在时间区间内统计。 */
+188 -80
View File
@@ -80,7 +80,6 @@ namespace jdi
const float kLegendSwatchSize = 8.0f; /**< 图例色块尺寸。 */ const float kLegendSwatchSize = 8.0f; /**< 图例色块尺寸。 */
const float kLegendItemGap = 14.0f; /**< 图例条目之间的间距。 */ const float kLegendItemGap = 14.0f; /**< 图例条目之间的间距。 */
const float kLegendMaxTextWidth = 200.0f; /**< 图例条目的文本宽度上限(超出则省略)。 */ const float kLegendMaxTextWidth = 200.0f; /**< 图例条目的文本宽度上限(超出则省略)。 */
const float kLegendPlateRadius = 4.0f; /**< 图例条目托底的圆角半径(逻辑像素)。 */
const float kLaneBadgeWidth = 30.0f; /**< 网格编号徽标宽度(与信号面板保持一致)。 */ const float kLaneBadgeWidth = 30.0f; /**< 网格编号徽标宽度(与信号面板保持一致)。 */
const float kLaneBadgeHeight = 17.0f; /**< 网格编号徽标高度。 */ const float kLaneBadgeHeight = 17.0f; /**< 网格编号徽标高度。 */
const float kLaneBadgeGap = 8.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::uint64_t>(
std::llround(static_cast<double>(style.gain) * 1.0e6)));
mix(static_cast<std::uint64_t>(
std::llround(static_cast<double>(style.offsetY) * 1.0e6)));
mix(style.absolute ? 1u : 0u);
mix(style.mirrorVertical ? 2u : 0u);
mix(style.mirrorHorizontal ? 4u : 0u);
return key;
}
/** /**
* @brief 计算统计区间对应的采样下标区间 [begin, end)。 * @brief 计算统计区间对应的采样下标区间 [begin, end)。
* *
* 时间轴单调时用二分定位,并在区间左侧多取一个采样点, * 时间轴单调时用二分定位。⚠️ 区间**之外**的采样一律不参与计数,
* 使跨越区间边界的边沿也能被计入。 * 因此这里**不做任何外扩**:只有两端都落在 `[begin, end)` 内的采样对才会被累计
*(调用方从 `begin + 1` 开始枚举采样对)。
* *
* @param [IN] file 所属数据分组。 * @param [IN] file 所属数据分组。
* @param [IN] count 采样点数量。 * @param [IN] count 采样点数量。
@@ -333,14 +360,14 @@ namespace jdi
std::lower_bound(times.begin(), times.begin() + static_cast<std::ptrdiff_t>(timeCount), std::lower_bound(times.begin(), times.begin() + static_cast<std::ptrdiff_t>(timeCount),
rangeMin) - rangeMin) -
times.begin()); times.begin());
// ⚠️ **不要**在左端多取一个采样点:那会让“跨越区间边界的边沿”也算进来,
// 而用户明确要求“在光标选区外的点不要算入计数规则”。
// (例:光标恰好落在越过阈值的第一个采样点 B 上时,A→B 这一段处于选区之外,
// 不计入;旧实现在左端多取一个点,于是 B 被错认成上升沿。)
end = static_cast<std::size_t>( end = static_cast<std::size_t>(
std::upper_bound(times.begin(), times.begin() + static_cast<std::ptrdiff_t>(timeCount), std::upper_bound(times.begin(), times.begin() + static_cast<std::ptrdiff_t>(timeCount),
rangeMax) - rangeMax) -
times.begin()); times.begin());
if (begin > 0)
{
--begin;
}
end = std::min(end, timeCount); end = std::min(end, timeCount);
begin = std::min(begin, end); begin = std::min(begin, end);
return true; return true;
@@ -2673,8 +2700,9 @@ namespace jdi
return true; return true;
} }
void PlotView::EdgeCenterCountsFor(const DataFile& file, const SignalData& signal, int points, void PlotView::EdgeCenterCountsFor(const DataFile& file, const SignalData& signal,
int iterations, bool hasRange, double rangeMin, double rangeMax, const SignalStyle& style, int points, int iterations,
bool hasRange, double rangeMin, double rangeMax,
std::size_t& rising, std::size_t& falling) const std::size_t& rising, std::size_t& falling) const
{ {
rising = 0; rising = 0;
@@ -2687,14 +2715,21 @@ namespace jdi
std::size_t end = 0; std::size_t end = 0;
const bool indexed = EdgeIndexRange(file, count, hasRange, rangeMin, rangeMax, begin, end); 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; EdgeCenterCountEntry* entry = nullptr;
for (EdgeCenterCountEntry& candidate : m_edgeCenterCountCache) for (EdgeCenterCountEntry& candidate : m_edgeCenterCountCache)
{ {
if (candidate.file == &file && candidate.signal == &signal && if (candidate.file == &file && candidate.signal == &signal &&
candidate.hasRange == hasRange && candidate.points == radius && candidate.styleKey == styleKey && candidate.hasRange == hasRange &&
candidate.iterations == stages) candidate.points == radius && candidate.iterations == stages)
{ {
entry = &candidate; entry = &candidate;
break; break;
@@ -2709,6 +2744,7 @@ namespace jdi
EdgeCenterCountEntry created; EdgeCenterCountEntry created;
created.file = &file; created.file = &file;
created.signal = &signal; created.signal = &signal;
created.styleKey = styleKey;
created.points = radius; created.points = radius;
created.iterations = stages; created.iterations = stages;
created.hasRange = hasRange; 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; std::size_t spanBegin = 0;
const std::vector<float>* curvePtr = CurveSpanFor( const std::vector<float>* 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) if (curvePtr == nullptr)
{ {
return; return;
} }
const std::vector<float>& curve = *curvePtr; const std::vector<float>& curve = *curvePtr;
// 判定基准:信号与中心平均值曲线之差(区间之外的采样返回 NaN)。 // 判定基准:**绘制后**的信号与**绘制后**的中心曲线之差(区间之外的采样返回 NaN)。
const auto deltaAt = [&signal, &file, &curve, spanBegin, count, hasRange, rangeMin, // ⚠️ 两侧必须套用**同一套**变换,否则比较的不是屏幕上看到的两条曲线。
rangeMax](std::size_t index) -> double const auto deltaAt = [&signal, &file, &curve, &style, spanBegin, count, mirrored, transform,
hasRange, rangeMin, rangeMax](std::size_t index) -> double
{ {
if (index >= count) if (index >= count)
{ {
return std::numeric_limits<double>::quiet_NaN(); return std::numeric_limits<double>::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<double>::quiet_NaN(); return std::numeric_limits<double>::quiet_NaN();
} }
const double value = signal.values[index]; if (sourceIndex < spanBegin || (sourceIndex - spanBegin) >= curve.size())
const float center = curve[index - spanBegin]; {
return std::numeric_limits<double>::quiet_NaN();
}
const double value = signal.values[sourceIndex];
const float center = curve[sourceIndex - spanBegin];
if (!std::isfinite(value) || !std::isfinite(center)) if (!std::isfinite(value) || !std::isfinite(center))
{ {
return std::numeric_limits<double>::quiet_NaN(); return std::numeric_limits<double>::quiet_NaN();
@@ -2810,7 +2857,15 @@ namespace jdi
return std::numeric_limits<double>::quiet_NaN(); return std::numeric_limits<double>::quiet_NaN();
} }
} }
return value - static_cast<double>(center); const double drawn = transform ? ApplyDrawTransform(style, value) : value;
const double drawnCenter =
transform ? ApplyDrawTransform(style, static_cast<double>(center))
: static_cast<double>(center);
if (!std::isfinite(drawn) || !std::isfinite(drawnCenter))
{
return std::numeric_limits<double>::quiet_NaN();
}
return drawn - drawnCenter;
}; };
std::size_t deltaRising = 0; std::size_t deltaRising = 0;
@@ -2839,24 +2894,43 @@ namespace jdi
entry->falling = falling; entry->falling = falling;
} }
void PlotView::EdgeCountsFor(const DataFile& file, const SignalData& signal, double edgeValue, void PlotView::EdgeCountsFor(const DataFile& file, const SignalData& signal, const SignalStyle& style,
bool hasRange, double rangeMin, double rangeMax, std::size_t& rising, double edgeValue, bool hasRange, double rangeMin, double rangeMax,
std::size_t& falling) const std::size_t& rising, std::size_t& falling) const
{ {
rising = 0; rising = 0;
falling = 0; falling = 0;
const std::vector<double>& values = signal.values; const std::vector<double>& 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 begin = 0;
std::size_t end = 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 (index >= count)
if (!std::isfinite(value)) {
return std::numeric_limits<double>::quiet_NaN();
}
const std::size_t sourceIndex = mirrored ? ((count - 1) - index) : index;
if (sourceIndex >= count)
{
return std::numeric_limits<double>::quiet_NaN();
}
const double raw = values[sourceIndex];
if (!std::isfinite(raw))
{ {
return std::numeric_limits<double>::quiet_NaN(); return std::numeric_limits<double>::quiet_NaN();
} }
@@ -2868,15 +2942,17 @@ namespace jdi
return std::numeric_limits<double>::quiet_NaN(); return std::numeric_limits<double>::quiet_NaN();
} }
} }
return value; const double drawn = transform ? ApplyDrawTransform(style, raw) : raw;
return std::isfinite(drawn) ? drawn : std::numeric_limits<double>::quiet_NaN();
}; };
// 按「文件 + 信号 + 阈值 + 是否限定区间」定位缓存项(**时间区间不进缓存键**)。 // 按「文件 + 信号 + 样式指纹 + 阈值 + 是否限定区间」定位缓存项(**时间区间不进缓存键**)。
EdgeCountEntry* entry = nullptr; EdgeCountEntry* entry = nullptr;
for (EdgeCountEntry& candidate : m_edgeCountCache) for (EdgeCountEntry& candidate : m_edgeCountCache)
{ {
if (candidate.file == &file && candidate.signal == &signal && if (candidate.file == &file && candidate.signal == &signal &&
candidate.hasRange == hasRange && candidate.edgeValue == edgeValue) candidate.styleKey == styleKey && candidate.hasRange == hasRange &&
candidate.edgeValue == edgeValue)
{ {
entry = &candidate; entry = &candidate;
break; break;
@@ -2891,6 +2967,7 @@ namespace jdi
EdgeCountEntry created; EdgeCountEntry created;
created.file = &file; created.file = &file;
created.signal = &signal; created.signal = &signal;
created.styleKey = styleKey;
created.edgeValue = edgeValue; created.edgeValue = edgeValue;
created.hasRange = hasRange; created.hasRange = hasRange;
m_edgeCountCache.push_back(created); m_edgeCountCache.push_back(created);
@@ -3004,13 +3081,14 @@ namespace jdi
{ {
continue; continue;
} }
EdgeCenterCountsFor(*resolved.file, *resolved.data, item->points, item->iterations, EdgeCenterCountsFor(*resolved.file, *resolved.data, *resolved.style, item->points,
EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), rising, falling); item->iterations, EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(),
rising, falling);
} }
else else
{ {
EdgeCountsFor(*resolved.file, *resolved.data, lane.edgeValue, EdgeRangeActive(), EdgeCountsFor(*resolved.file, *resolved.data, *resolved.style, lane.edgeValue,
EdgeRangeMin(), EdgeRangeMax(), rising, falling); EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), rising, falling);
} }
const float countWidth = const float countWidth =
UiMeasureTextWidth(m_fonts->monoSmall, UiMeasureTextWidth(m_fonts->monoSmall,
@@ -6373,11 +6451,11 @@ namespace jdi
{ {
if (hit.ref == m_hoverLegend) 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); UiFillRoundRect(g, hit.rect, radius, m_theme->accentSoft);
UiDrawRoundRect(g, hit.rect, radius, m_theme->accent, 1.0f); UiDrawRoundRect(g, hit.rect, radius, m_theme->accent, 1.0f);
UiDrawSwatch(g, hit.swatch, hit.swatchColor, hit.swatchOpacity, 1.5f * m_scale, m_scale); 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; break;
} }
} }
@@ -6935,19 +7013,40 @@ namespace jdi
return m_laneDragFrom; return m_laneDragFrom;
} }
// 判据 = **鼠标指针**落在哪个网格的哪一半:指针进入某网格的"下半"即把插入位置 // 判据 = **被拖网格自己的上下边界**(用户明确要求):
// 定在它之后(`index` = 该网格下标 + 1)。 // * 上边界越过"上方第 m 个网格"的中心水平线 → 让位 m 个位置(插入位置 = from - m);
// * 下边界越过"下方第 m 个网格"的中心水平线 → 让位 m 个位置(插入位置 = from + m + 1,
// 因为被拖网格会先从原位置摘除,越过后应落在该网格之后)。
// //
// ⚠️ 旧实现用"被拖网格的**中心**"(`mouseY - grabOffset + laneHeight/2`)做判据, // ⚠️ 旧判据只看**鼠标指针**落在哪个网格的哪一半。因为被拖网格整体跟随指针,而指针通常
// 等价于要求把整个网格拖过去一格才会吸附 —— 例如只有两个网格时,把 G2 往上拖 // 按在网格上沿的 GX 徽标上,于是"向下拖必须把整个网格拖过下方网格才吸附、
// 必须一直拖到鼠标接近 G1 的最上沿才会让位(用户报告"要拖到另一个网格的位置附近 // 向上拖只要半个网格" —— 上下不对称(用户报告)。改用网格边界后上下完全对称。
// 才有吸附提示")。改为与鼠标位置挂钩后:**鼠标越过 G1 的中心水平线,G1 立刻让位 //
// 并出现吸附提示**(依次类推对任意网格数成立)。 // 数学:令 shift = 被拖网格相对原槽位的位移,H = 网格高。
const float firstTop = m_lanes.front().rect.Y; // 上边界 = baseTop + shift,上方第 m 个网格的中心线 = baseTop - (m - 0.5)H
const float mouse = static_cast<float>(mouseY); // ⇒ 满足 shift < (0.5 - m)H 的最大 m 即让位格数(m < 0.5 - shift/H);
int index = static_cast<int>(std::floor((mouse - firstTop) / laneHeight + 0.5f)); // 下边界 = baseTop + shift + H,下方第 m 个网格的中心线 = baseTop + (m + 0.5)H
index = std::max(0, std::min(index, count)); // ⇒ 满足 shift > (m - 0.5)H 的最大 m(m < shift/H + 0.5)。
return index; // ⚠️ 两侧条件互斥(不可能同时成立),因此最多只会向一侧吸附。
const float baseTop = m_lanes[static_cast<std::size_t>(m_laneDragFrom)].rect.Y;
const float shift =
static_cast<float>(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<int>(std::ceil(upRatio)) - 1;
int down = static_cast<int>(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() void PlotView::FinishLaneReorder()
@@ -9173,9 +9272,16 @@ namespace jdi
const float badgeHeight = kLaneBadgeHeight * m_scale; const float badgeHeight = kLaneBadgeHeight * m_scale;
float cursorX = badgeRect.X + badgeRect.Width + kLaneBadgeGap * m_scale; float cursorX = badgeRect.X + badgeRect.Width + kLaneBadgeGap * m_scale;
// 图例条目托底的内缩外扩量:托底矩形**同时就是命中矩形**(看到的与点得到的严格一致)。 // 图例条目的托底 = **与 GX 编号徽标完全同款**(用户明确要求):
const float platePadX = 3.0f * m_scale; // * 高度与徽标相同(`bgadeRect` 的上下边界一模一样,因此"上下边距相同");
const float platePadY = 2.0f * m_scale; // * 圆角 = 高度的一半(胶囊形),与徽标的 `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) 几何:算出"放得下"的条目及其矩形(**唯一来源**,命中 / 装饰 / 绘制共用)。 // 1) 几何:算出"放得下"的条目及其矩形(**唯一来源**,命中 / 装饰 / 绘制共用)。
@@ -9184,7 +9290,8 @@ namespace jdi
{ {
SignalRef ref; SignalRef ref;
float left = 0.0f; // 色块左缘 float left = 0.0f; // 色块左缘
float width = 0.0f; // 条目内容宽度 float width = 0.0f; // 条目**内容**宽度(色块 + 间距 + 文本)
float plateWidth = 0.0f; // 托底宽度(内容 + 两侧内边距)
float textWidth = 0.0f; float textWidth = 0.0f;
std::wstring label; std::wstring label;
bool selected = false; bool selected = false;
@@ -9208,35 +9315,35 @@ namespace jdi
label += L" · " + resolved.file->Title(); label += L" · " + resolved.file->Title();
} }
const float maxTextWidth = kLegendMaxTextWidth * m_scale; 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 textWidth = std::min(fullTextWidth, maxTextWidth);
const float itemWidth = swatchSize + 5.0f * m_scale + textWidth + kLegendItemGap * m_scale; const float contentWidth = swatchSize + 5.0f * m_scale + textWidth;
if (probeX + itemWidth > maxX) const float plateWidth = contentWidth + platePadX * 2.0f;
if (probeX + plateWidth > maxX)
{ {
break; // 空间不足:其余条目省略,下方用 "+N" 提示 break; // 空间不足:其余条目省略,下方用 "+N" 提示
} }
LegendItem item; LegendItem item;
item.ref = ref; item.ref = ref;
item.left = probeX; item.left = probeX + platePadX; // 色块左缘(托底内缩一个内边距)
item.width = itemWidth; item.width = contentWidth;
item.plateWidth = plateWidth;
item.textWidth = textWidth; item.textWidth = textWidth;
item.label = (fullTextWidth > maxTextWidth) item.label = (fullTextWidth > maxTextWidth)
? UiEllipsize(label, m_fonts->uiSmall, maxTextWidth) ? UiEllipsize(label, m_fonts->uiTiny, maxTextWidth)
: label; : label;
item.selected = (SelectedRef() == ref); item.selected = (SelectedRef() == ref);
items.push_back(std::move(item)); items.push_back(std::move(item));
probeX += itemWidth; probeX += plateWidth + kLegendItemGap * m_scale;
} }
contentRight = probeX; contentRight = probeX;
} }
const auto PlateRect = [&](const LegendItem& item) const auto PlateRect = [&](const LegendItem& item)
{ {
return gd::RectF(item.left - platePadX, top - platePadY, item.width + platePadX * 2.0f, return gd::RectF(item.left - platePadX, plateTop, item.plateWidth, plateHeight);
height + platePadY * 2.0f);
}; };
// 条目托底 = 与 GX 编号徽标同一套风格的**圆角矩形**(用户明确要求: // 条目托底 = 与 GX 编号徽标同一套风格的**胶囊形圆角矩形**(半径 = 高度的一半)。
// "相当于一个 GX 标签,只是内容换成了图例信息")。 const float plateRadius = plateHeight * 0.5f;
const float plateRadius = kLegendPlateRadius * m_scale;
// 「+N」(图例空间不足)与「(无可见信号)」提示的矩形。 // 「+N」(图例空间不足)与「(无可见信号)」提示的矩形。
// ⚠️ 它们同样属于装饰层、必须登记进 `m_decorRects`:否则会被画进装饰位图 // ⚠️ 它们同样属于装饰层、必须登记进 `m_decorRects`:否则会被画进装饰位图
// 却因不在贴回列表里而永远看不见。 // 却因不在贴回列表里而永远看不见。
@@ -9271,9 +9378,9 @@ namespace jdi
hit.rect = PlateRect(item); hit.rect = PlateRect(item);
hit.radius = plateRadius; hit.radius = plateRadius;
hit.swatch = hit.swatch =
gd::RectF(item.left, top + (height - swatchSize) * 0.5f, swatchSize, swatchSize); gd::RectF(item.left, plateTop + (plateHeight - swatchSize) * 0.5f, swatchSize, swatchSize);
hit.text = gd::RectF(item.left + swatchSize + 5.0f * m_scale, top, hit.text = gd::RectF(item.left + swatchSize + 5.0f * m_scale, plateTop,
std::max(8.0f, item.textWidth + 2.0f), height); std::max(8.0f, item.textWidth + 2.0f), plateHeight);
hit.label = item.label; hit.label = item.label;
// 「选中」与「悬停」用同一套配色(用户明确要求),因此文字色在这里也按选中态取。 // 「选中」与「悬停」用同一套配色(用户明确要求),因此文字色在这里也按选中态取。
hit.textColor = item.selected ? m_theme->textStrong : m_theme->text; 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) 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; continue;
} }
UiDrawSwatch(g, 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); resolved.style->color, resolved.style->opacity, 1.5f * m_scale, m_scale);
UiText(g, item.label, m_fonts->uiSmall, UiText(g, item.label, m_fonts->uiTiny,
gd::RectF(item.left + swatchSize + 5.0f * m_scale, top, gd::RectF(item.left + swatchSize + 5.0f * m_scale, plateTop,
std::max(8.0f, item.textWidth + 2.0f), height), std::max(8.0f, item.textWidth + 2.0f), plateHeight),
item.selected ? m_theme->textStrong : m_theme->text, TextAlign::LEFT); item.selected ? m_theme->textStrong : m_theme->text, TextAlign::LEFT);
} }
} }
if (hasEmptyText) if (hasEmptyText)
{ {
UiText(g, L"(无可见信号)", m_fonts->uiSmall, emptyTextRect, m_theme->textMuted, UiText(g, L"(无可见信号)", m_fonts->uiTiny, emptyTextRect, m_theme->textMuted,
TextAlign::LEFT); TextAlign::LEFT);
} }
else if (hasMoreText) else if (hasMoreText)
@@ -9367,7 +9475,7 @@ namespace jdi
// 图例空间不足时提示剩余信号数量,避免误认为信号丢失。 // 图例空间不足时提示剩余信号数量,避免误认为信号丢失。
const std::wstring moreText = const std::wstring moreText =
L"+" + std::to_wstring(visibleCount - static_cast<int>(items.size())); L"+" + std::to_wstring(visibleCount - static_cast<int>(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; const int statsOrder = (item != nullptr) ? item->iterations : 1;
std::size_t rising = 0; std::size_t rising = 0;
std::size_t falling = 0; std::size_t falling = 0;
EdgeCenterCountsFor(*resolved.file, *resolved.data, statsPoints, statsOrder, EdgeCenterCountsFor(*resolved.file, *resolved.data, *resolved.style,
EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), rising, statsPoints, statsOrder, EdgeRangeActive(),
falling); EdgeRangeMin(), EdgeRangeMax(), rising, falling);
const std::wstring counts = L"上升沿 " + std::to_wstring(rising) + const std::wstring counts = L"上升沿 " + std::to_wstring(rising) +
L" 下降沿 " + std::to_wstring(falling); L" 下降沿 " + std::to_wstring(falling);
UiText(g, counts, m_fonts->monoSmall, statsRect, m_theme->textMuted, UiText(g, counts, m_fonts->monoSmall, statsRect, m_theme->textMuted,
@@ -11557,8 +11665,8 @@ namespace jdi
std::size_t rising = 0; std::size_t rising = 0;
std::size_t falling = 0; std::size_t falling = 0;
EdgeCountsFor(*resolved.file, *resolved.data, tabLane.edgeValue, EdgeRangeActive(), EdgeCountsFor(*resolved.file, *resolved.data, *resolved.style, tabLane.edgeValue,
EdgeRangeMin(), EdgeRangeMax(), rising, falling); EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), rising, falling);
const std::wstring counts = L"上升沿 " + std::to_wstring(rising) + L" 下降沿 " + const std::wstring counts = L"上升沿 " + std::to_wstring(rising) + L" 下降沿 " +
std::to_wstring(falling); std::to_wstring(falling);
UiText(g, counts, m_fonts->monoSmall, UiText(g, counts, m_fonts->monoSmall,
+13 -5
View File
@@ -106,6 +106,7 @@ namespace jdi
const float kSliderValueGap = 8.0f; /**< 滑块轨道与数值文本间距。 */ const float kSliderValueGap = 8.0f; /**< 滑块轨道与数值文本间距。 */
const float kTrackHeight = 6.0f; /**< 滑块轨道高度。 */ const float kTrackHeight = 6.0f; /**< 滑块轨道高度。 */
const float kAdjustGap = 6.0f; /**< 「调整」行三个按钮之间的间距。 */ const float kAdjustGap = 6.0f; /**< 「调整」行三个按钮之间的间距。 */
const float kAdjustMenuGap = 4.0f; /**< 「系数/偏移」「镜像」二级面板与按钮左上角的额外间距。 */
/** 「调整」行的按钮下标(与 `m_adjustRects` 同序)。 */ /** 「调整」行的按钮下标(与 `m_adjustRects` 同序)。 */
const int kAdjustGain = 0; /**< 系数(点击展开系数二级面板)。 */ const int kAdjustGain = 0; /**< 系数(点击展开系数二级面板)。 */
@@ -2050,11 +2051,15 @@ namespace jdi
offsetTime.filter = EditFilter::NUMBER; offsetTime.filter = EditFilter::NUMBER;
items.push_back(offsetTime); items.push_back(offsetTime);
// 锚定在按钮下沿:面板紧贴按钮展开(与拾色器"以触发器矩形为锚点"的观感一致)。 // 锚定在按钮**左上角的上方一点**:二级面板**向上展开**,其左下角对准按钮左上角,
// 并再往上让出 `kAdjustMenuGap`(用户明确要求)。
//(旧实现在按钮下沿向右下展开,面板会盖住下方内容。)
const gd::RectF& button = m_adjustRects[kAdjustGain]; const gd::RectF& button = m_adjustRects[kAdjustGain];
const gd::RectF bounds = (m_clientBounds.Width > 1.0f) ? m_clientBounds : m_area; const gd::RectF bounds = (m_clientBounds.Width > 1.0f) ? m_clientBounds : m_area;
m_menu->Open(MenuScope::SIGNAL_PANEL, bounds, static_cast<int>(button.X), const int anchorY =
static_cast<int>(button.Y + button.Height), items); static_cast<int>(std::lround(button.Y - kAdjustMenuGap * m_scale));
m_menu->Open(MenuScope::SIGNAL_PANEL, bounds, static_cast<int>(button.X), anchorY, items,
true);
// ⚠️ 菜单一旦打开就会接管鼠标事件,样式区不会再收到 WM_MOUSEMOVE → // ⚠️ 菜单一旦打开就会接管鼠标事件,样式区不会再收到 WM_MOUSEMOVE →
// 必须在这里主动清掉按钮的悬停态,否则按钮会"像鼠标一直按着"一样持续高亮。 // 必须在这里主动清掉按钮的悬停态,否则按钮会"像鼠标一直按着"一样持续高亮。
ClearStyleHover(); ClearStyleHover();
@@ -2094,8 +2099,11 @@ namespace jdi
const gd::RectF& button = m_adjustRects[kAdjustMirror]; const gd::RectF& button = m_adjustRects[kAdjustMirror];
const gd::RectF bounds = (m_clientBounds.Width > 1.0f) ? m_clientBounds : m_area; const gd::RectF bounds = (m_clientBounds.Width > 1.0f) ? m_clientBounds : m_area;
m_menu->Open(MenuScope::SIGNAL_PANEL, bounds, static_cast<int>(button.X), // 与「系数/偏移」面板同一套锛定:向上展开,左下角 = 按钮左上角再上移一点。
static_cast<int>(button.Y + button.Height), items); const int anchorY =
static_cast<int>(std::lround(button.Y - kAdjustMenuGap * m_scale));
m_menu->Open(MenuScope::SIGNAL_PANEL, bounds, static_cast<int>(button.X), anchorY, items,
true);
ClearStyleHover(); ClearStyleHover();
return true; return true;
} }
+129 -9
View File
@@ -735,11 +735,14 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸(
3. **水平镜像 = 数据反序**:把派生采样数组整体反转(`ApplyDrawTransform` 是逐点函数, 3. **水平镜像 = 数据反序**:把派生采样数组整体反转(`ApplyDrawTransform` 是逐点函数,
"先变换再反转"与"先反转再变换"等价); "先变换再反转"与"先反转再变换"等价);
4. 新信号样式一律**保持默认**(`gain=1 / absolute=false / 两个镜像=false / offsetY=0`)。 4. 新信号样式一律**保持默认**(`gain=1 / absolute=false / 两个镜像=false / offsetY=0`)。
- ⚠️ **唯一例外:时间轴偏移 `offsetTime` 仍由样式携带**——它改变的是"绘制在哪个时刻", - ⚠️⚠️ **【已作废,见 §9.21 第三节】原"唯一例外:时间轴偏移 `offsetTime` 仍由样式携带"** ——
而派生信号与源信号**共用同一个文件时间轴**,无法把时间平移烘进采样数据; 用户明确要求"新信号**默认不启用任何「调整」**就必须与预览逐点重合",因此 `offsetTime` 现在也
若不继承,新信号会相对预览整体横向错位。已在该函数注释中写明。 **按 `time[i] - offsetTime` 重采样烘进采样值**(新信号 `offsetTime` 同样保持 0);
- 验证(本轮 `verify` harness):`derived.values[i] == ApplyDrawTransform(tf, float(source[count-1-i]))` 只有"无法烘值"的退化情形(时间轴非单调 / 时间列缺失)才退回把 `offsetTime` 带在样式上。
逐点一致;新样式各项均为默认;`offsetTime` 保留。 仅作历史保留:~~它改变的是"绘制在哪个时刻",而派生信号与源信号共用同一个文件时间轴,
无法把时间平移烘进采样数据;若不继承,新信号会相对预览整体横向错位。~~
- 验证(`1b3ab4a` 轮 `verify` harness):`derived.values[i] == ApplyDrawTransform(tf, float(source[count-1-i]))`
逐点一致;新样式各项均为默认。
#### 四、网格 GX 拖拽让位阈值 + 徽标悬停 #### 四、网格 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. 变更日志(本文件维护记录) ## 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(**另一台设备**) | **交互流畅性三项修复**(详见 §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-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 | **装饰层改为带 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 的那条**)都必须**先重建命中矩形**再决定是否跳过绘制 —— (**包括装饰缓存命中、提前 return 的那条**)都必须**先重建命中矩形**再决定是否跳过绘制 ——
否则清空后不再填充,图例的悬停高亮 / 点击选中 / 双击改名会**永久失效** 否则清空后不再填充,图例的悬停高亮 / 点击选中 / 双击改名会**永久失效**
(用户报告"用中键平移过视界后图例点不动")。同理 `m_editFieldRect` 也必须在几何段设置。 (用户报告"用中键平移过视界后图例点不动")。同理 `m_editFieldRect` 也必须在几何段设置。
18. ⚠️ **装饰缓存的像素必须与时间视界无关**:`CaptureDecorCache` 的捕获边界由 `floor/ceil(rect)` 得出, 18. ⚠️ **装饰缓存的像素必须与时间视界无关**:装饰位图的贴回边界由 `floor/ceil(rect)` 得出,
因此每块装饰(GX 徽标 / 图例托底 / Y 轴胶囊)都必须**先用不透明底色铺满"外接整数像素再外扩 1px"的矩形** 因此每块装饰(GX 徽标 / 图例托底 / Y 轴胶囊)都必须画成**不透明的圆角形状本身**
再画圆角。否则圆角之外的那几个像素仍是波形 → 平移贴回时表现为"GX/图例周边旧波形像素残留"。 (`RenderDecorCache` + `AlphaBlend` 方案下圆角之外**保持全透明**,不要再用外扩的直角不透明底色)。
装饰矩形也必须**逐块**(禁止覆盖整条图例带)。验证方法:宽基底实时帧 vs 同视界从零重绘, 装饰矩形也必须**逐块**(禁止覆盖整条图例带)。验证方法:宽基底实时帧 vs 同视界从零重绘,
**装饰矩形内必须 0 差异**。 **装饰矩形内必须 0 差异**。
(历史:早期实现是 `CaptureDecorCache` 从后备缓冲区抓不透明矩形 —— 该函数**已删除**。)
19. ⚠️ **逐采样循环里禁止调用 `DataSet::Style(ref)` / `Resolve()` 等按下标解析的函数**: 19. ⚠️ **逐采样循环里禁止调用 `DataSet::Style(ref)` / `Resolve()` 等按下标解析的函数**:
`AutoRangeLaneToView` 曾因此把全段自适应从 147ms 劣化到 **1200ms(8 倍)**。 `AutoRangeLaneToView` 曾因此把全段自适应从 147ms 劣化到 **1200ms(8 倍)**。
一律把指针提到循环外。整段(`begin==0 && end==count`)且无变换/无水平镜像时, 一律把指针提到循环外。整段(`begin==0 && end==count`)且无变换/无水平镜像时,
@@ -990,6 +1093,23 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸(
20. ⚠️ **"整批图层重建"绝不允许发生在交互帧里**:新增 `WarmLaneLayersStep()`, 20. ⚠️ **"整批图层重建"绝不允许发生在交互帧里**:新增 `WarmLaneLayersStep()`,
在**命中静态场景缓存**(视界未变、非拖拽)时用既有预算机制**每帧至多补 1 个网格**并自请求下一帧。 在**命中静态场景缓存**(视界未变、非拖拽)时用既有预算机制**每帧至多补 1 个网格**并自请求下一帧。
否则"加载后的第一次平移 / 选区放大"那一帧会同步重建全部网格图层(首帧 64ms → 预热后 13ms)。 否则"加载后的第一次平移 / 选区放大"那一帧会同步重建全部网格图层(首帧 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 离屏验证方法学(本机偏好,强烈建议沿用) ### 11.3 离屏验证方法学(本机偏好,强烈建议沿用)
+1
View File
@@ -46,6 +46,7 @@ namespace jdi
HFONT uiBold = nullptr; /**< 加粗界面字体。 */ HFONT uiBold = nullptr; /**< 加粗界面字体。 */
HFONT uiSmall = nullptr; /**< 小号界面字体。 */ HFONT uiSmall = nullptr; /**< 小号界面字体。 */
HFONT uiSmallBold = nullptr;/**< 小号加粗界面字体。 */ HFONT uiSmallBold = nullptr;/**< 小号加粗界面字体。 */
HFONT uiTiny = nullptr; /**< 微型界面字体(图例文字:必须能放进 GX 徽标同款的托底)。 */
HFONT title = nullptr; /**< 标题字体。 */ HFONT title = nullptr; /**< 标题字体。 */
HFONT mono = nullptr; /**< 等宽数值字体。 */ HFONT mono = nullptr; /**< 等宽数值字体。 */
HFONT monoSmall = nullptr; /**< 小号等宽字体。 */ HFONT monoSmall = nullptr; /**< 小号等宽字体。 */
+8 -2
View File
@@ -124,8 +124,12 @@ namespace jdi
* @param [IN] x 锚点横坐标(客户区坐标)。 * @param [IN] x 锚点横坐标(客户区坐标)。
* @param [IN] y 锚点纵坐标(客户区坐标)。 * @param [IN] y 锚点纵坐标(客户区坐标)。
* @param [IN] items 菜单项集合。 * @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<MenuItem>& items); void Open(MenuScope scope, const gd::RectF& bounds, int x, int y,
const std::vector<MenuItem>& items, bool expandUp = false);
/** /**
* @brief 关闭菜单。 * @brief 关闭菜单。
@@ -276,8 +280,9 @@ namespace jdi
* @param [IN] bounds 宿主客户区矩形。 * @param [IN] bounds 宿主客户区矩形。
* @param [IN] x 锚点横坐标。 * @param [IN] x 锚点横坐标。
* @param [IN] y 锚点纵坐标。 * @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 查询坐标命中的菜单项下标。 * @brief 查询坐标命中的菜单项下标。
@@ -420,6 +425,7 @@ namespace jdi
gd::RectF m_buildBounds; /**< 最近一次排布使用的宿主矩形。 */ gd::RectF m_buildBounds; /**< 最近一次排布使用的宿主矩形。 */
int m_buildX = 0; /**< 最近一次排布使用的锚点横坐标。 */ int m_buildX = 0; /**< 最近一次排布使用的锚点横坐标。 */
int m_buildY = 0; /**< 最近一次排布使用的锚点纵坐标。 */ int m_buildY = 0; /**< 最近一次排布使用的锚点纵坐标。 */
bool m_buildExpandUp = false; /**< 最近一次排布是否为“向上展开(锚点为左下角)”。 */
bool m_subOpen = false; /**< 二级子菜单是否展开。 */ bool m_subOpen = false; /**< 二级子菜单是否展开。 */
int m_subParentIndex = -1; /**< 展开子菜单的父项下标。 */ int m_subParentIndex = -1; /**< 展开子菜单的父项下标。 */
+6 -2
View File
@@ -145,6 +145,7 @@ namespace jdi
const double scale = static_cast<double>(dpi) / 96.0; const double scale = static_cast<double>(dpi) / 96.0;
const int uiHeight = static_cast<int>(std::lround(14.0 * scale)); const int uiHeight = static_cast<int>(std::lround(14.0 * scale));
const int smallHeight = static_cast<int>(std::lround(12.0 * scale)); const int smallHeight = static_cast<int>(std::lround(12.0 * scale));
const int tinyHeight = static_cast<int>(std::lround(10.0 * scale));
const int titleHeight = static_cast<int>(std::lround(15.0 * scale)); const int titleHeight = static_cast<int>(std::lround(15.0 * scale));
const int monoHeight = static_cast<int>(std::lround(13.0 * scale)); const int monoHeight = static_cast<int>(std::lround(13.0 * scale));
@@ -152,16 +153,18 @@ namespace jdi
uiBold = CreateUiFont(L"Segoe UI Semibold", uiHeight, true); uiBold = CreateUiFont(L"Segoe UI Semibold", uiHeight, true);
uiSmall = CreateUiFont(L"Segoe UI", smallHeight, false); uiSmall = CreateUiFont(L"Segoe UI", smallHeight, false);
uiSmallBold = CreateUiFont(L"Segoe UI Semibold", smallHeight, true); uiSmallBold = CreateUiFont(L"Segoe UI Semibold", smallHeight, true);
uiTiny = CreateUiFont(L"Segoe UI", tinyHeight, false);
title = CreateUiFont(L"Segoe UI Semibold", titleHeight, true); title = CreateUiFont(L"Segoe UI Semibold", titleHeight, true);
mono = CreateUiFont(L"Consolas", monoHeight, false); mono = CreateUiFont(L"Consolas", monoHeight, false);
monoSmall = CreateUiFont(L"Consolas", smallHeight, 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() 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) for (const HFONT font : resources)
{ {
if (font != nullptr) if (font != nullptr)
@@ -173,6 +176,7 @@ namespace jdi
uiBold = nullptr; uiBold = nullptr;
uiSmall = nullptr; uiSmall = nullptr;
uiSmallBold = nullptr; uiSmallBold = nullptr;
uiTiny = nullptr;
title = nullptr; title = nullptr;
mono = nullptr; mono = nullptr;
monoSmall = nullptr; monoSmall = nullptr;
+9 -7
View File
@@ -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_itemRects.assign(m_items.size(), gd::RectF());
m_swatchRects.assign(m_items.size(), std::vector<gd::RectF>()); m_swatchRects.assign(m_items.size(), std::vector<gd::RectF>());
@@ -197,14 +197,15 @@ namespace jdi
m_editFieldWidth = labeledFieldWidth; m_editFieldWidth = labeledFieldWidth;
float left = static_cast<float>(x); float left = static_cast<float>(x);
float top = static_cast<float>(y); // `expandUp`:锚点是菜单的**左下角**(向上展开);否则锚点是左上角(向右下展开)。
float top = expandUp ? (static_cast<float>(y) - height) : static_cast<float>(y);
// 优先向右下展开;越界时向左或向上翻转,最后做边界收敛。 // 优先向右展开(向左下/右下);越界时向左或向上翻转,最后做边界收敛。
if (left + width > bounds.X + bounds.Width) if (left + width > bounds.X + bounds.Width)
{ {
left = static_cast<float>(x) - width; left = static_cast<float>(x) - width;
} }
if (top + height > bounds.Y + bounds.Height) if (!expandUp && top + height > bounds.Y + bounds.Height)
{ {
top = static_cast<float>(y) - height; top = static_cast<float>(y) - height;
} }
@@ -511,7 +512,7 @@ namespace jdi
return; return;
} }
const int subParent = m_subParentIndex; const int subParent = m_subParentIndex;
BuildLayout(m_buildBounds, m_buildX, m_buildY); BuildLayout(m_buildBounds, m_buildX, m_buildY, m_buildExpandUp);
// 主菜单重新排布后子菜单位置会失效:若之前是展开的则按新几何重建。 // 主菜单重新排布后子菜单位置会失效:若之前是展开的则按新几何重建。
if (subParent >= 0) if (subParent >= 0)
{ {
@@ -631,7 +632,7 @@ namespace jdi
} }
void PopupMenu::Open(MenuScope scope, const gd::RectF& bounds, int x, int y, void PopupMenu::Open(MenuScope scope, const gd::RectF& bounds, int x, int y,
const std::vector<MenuItem>& items) const std::vector<MenuItem>& items, bool expandUp)
{ {
m_items = items; m_items = items;
if (m_items.empty()) if (m_items.empty())
@@ -650,10 +651,11 @@ namespace jdi
m_buildBounds = bounds; m_buildBounds = bounds;
m_buildX = x; m_buildX = x;
m_buildY = y; m_buildY = y;
m_buildExpandUp = expandUp;
CloseSubmenu(); CloseSubmenu();
CloseEditState(); CloseEditState();
BuildLayout(bounds, x, y); BuildLayout(bounds, x, y, expandUp);
} }
void PopupMenu::Close() void PopupMenu::Close()