边沿计数改用「绘制值」+ 派生信号烘入时间偏移 + 图例托底统一为 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

+188 -80
View File
@@ -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::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)。
*
* 时间轴单调时用二分定位,并在区间左侧多取一个采样点,
* 使跨越区间边界的边沿也能被计入。
* 时间轴单调时用二分定位。⚠️ 区间**之外**的采样一律不参与计数,
* 因此这里**不做任何外扩**:只有两端都落在 `[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<std::ptrdiff_t>(timeCount),
rangeMin) -
times.begin());
// ⚠️ **不要**在左端多取一个采样点:那会让“跨越区间边界的边沿”也算进来,
// 而用户明确要求“在光标选区外的点不要算入计数规则”。
// (例:光标恰好落在越过阈值的第一个采样点 B 上时,A→B 这一段处于选区之外,
// 不计入;旧实现在左端多取一个点,于是 B 被错认成上升沿。)
end = static_cast<std::size_t>(
std::upper_bound(times.begin(), times.begin() + static_cast<std::ptrdiff_t>(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<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)
{
return;
}
const std::vector<float>& 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<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();
}
const double value = signal.values[index];
const float center = curve[index - spanBegin];
if (sourceIndex < spanBegin || (sourceIndex - spanBegin) >= curve.size())
{
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))
{
return std::numeric_limits<double>::quiet_NaN();
@@ -2810,7 +2857,15 @@ namespace jdi
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;
@@ -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<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 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<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();
}
@@ -2868,15 +2942,17 @@ namespace jdi
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;
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<float>(mouseY);
int index = static_cast<int>(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<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()
@@ -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<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;
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,