修复浮动面板事件穿透 / 自由标记总是预览最近信号 / 固定点位于GX与图例之下 / 预览曲线取值插值;体积红线放宽到900KB

1) 浮动面板不得被右键与中键穿透(用户报告:在面板上右键会弹出下面网格的右键菜单)
   - 新增 PlotView::OnRButtonDown:TopPanelAtPoint 命中即置顶并消费该次右键;
     Application::WM_RBUTTONDOWN 在「绘图区右键 → 网格菜单」之前调用它。
   - OnMButtonDown 开头同样判定面板:命中即消费,不再进入平移。

2) 「自由标记点选取」总是预览网格内离鼠标较近的信号(用户报告:单信号时也必须贴近曲线)
   - ProbeFreeMarker 删除 `if (!hitCurve) continue;`,改为两候选取更近:
     ① 鼠标到画出来的折线的垂直距离(阶跃竖直段只有它能命中);
     ② 同一时刻(鼠标时刻吸附最近采样)采样点的屏幕纵向距离。
   - 门槛 tolerance 只用于「已吸附」圆环;固定点仍恒吸附到真实采样时刻。

3) 固定标记点位于 GX 编号徽标与图例之下(用户要求:数据显示不得遮挡它们)
   - PaintFreeMarker 把 m_legendHits 托底矩形与 LaneBadgeRect 用 gd::Region::Exclude
     排除出绘制区;仅在确有固定点/预览时才计算。
   - PaintFreeMarkerCapsule 新增 vectorText:胶囊与遮挡矩形相交时文字改走 GDI+
     (GDI DrawTextW 不认 GDI+ 裁剪区)。

4) 预览曲线(中心轴/均值滤波/弱峰调整)的光标取值点落回曲线
   - paintDerivedCapsule 改为在光标准确时刻按时间线性插值,且先 ApplyDrawTransform 再插值
     (绝对值/垂直镜像非线性,先插值会偏离折线);双光标增量同样插值。

5) 产物体积红线由 800KB 放宽到 900KB(到达阈值时提醒,不是禁止超过)。

验证(全离屏,未占用鼠标/键盘/屏幕):仓库外临时探针 25/25(A 面板右键/中键不穿透且面板外仍生效;
B 远离曲线可预览与单击固定、两信号按纵向距离择优;C 图例与 GX 区域像素差 0、整幅差异 5 万像素;
D 阶跃区标注点与曲线差 0.0px,插值点与最近采样点相差 39px/197px);A/B 恢复旧门槛 ⇒ 5 项失败,
改回最近采样取值 ⇒ 均值/中心轴各偏 36px 失败。make release / make debug 零警告;
Target.exe 860,672 字节(840.5 KiB)。
This commit is contained in:
DeepSeek V4.1 Flash committed 2026-10-27 20:14:49 +08:00
1 parent 14b18d5c39
commit df715a035e
6 files changed
+442 -60

No files matched your search

@@ -1346,6 +1346,14 @@ namespace jdi
return 0; return 0;
} }
// 浮动面板:右键属于面板自身,**不得穿透**到下层网格(否则会在面板上弹出
// 网格的 Y 轴菜单)。必须先于下方的"绘图区右键 → 网格菜单"判定。
if (m_plot.OnRButtonDown(x, y))
{
InvalidateRect(hwnd, nullptr, FALSE);
return 0;
}
// 绘图区右键:弹出该网格的 Y 轴设置菜单(含上下限输入)。 // 绘图区右键:弹出该网格的 Y 轴设置菜单(含上下限输入)。
if (m_plot.HitTest(x, y)) if (m_plot.HitTest(x, y))
{ {
+17 -1
View File
@@ -224,6 +224,18 @@ namespace jdi
*/ */
bool OnLButtonUp(int x, int y); bool OnLButtonUp(int x, int y);
/**
* @brief 处理鼠标右键按下(**只处理浮动面板**)。
*
* 浮动面板压在波形之上,属最上层元素:右键落在面板上必须被面板消费,
* 不得穿透到下面的网格(否则会在面板上弹出网格的 Y 轴菜单)。
*
* @param [IN] x 客户区横坐标。
* @param [IN] y 客户区纵坐标。
* @return 命中浮动面板(已消费该次右键)返回 true;否则返回 false,由宿主继续处理。
*/
bool OnRButtonDown(int x, int y);
/** /**
* @brief 处理鼠标中键按下:与左键在常规模式下一致,进入自由平移(水平平移时间轴 + * @brief 处理鼠标中键按下:与左键在常规模式下一致,进入自由平移(水平平移时间轴 +
* 纵向偏移当前网格幅值)。**任何缩放模式下都可用**,用于在不改变当前模式的 * 纵向偏移当前网格幅值)。**任何缩放模式下都可用**,用于在不改变当前模式的
@@ -1969,10 +1981,14 @@ namespace jdi
* @param [IN] close ✕ 按钮矩形(宽度为 0 表示不画)。 * @param [IN] close ✕ 按钮矩形(宽度为 0 表示不画)。
* @param [IN] closeHot ✕ 是否处于悬停态。 * @param [IN] closeHot ✕ 是否处于悬停态。
* @param [IN] alphaScale 整体不透明度系数(1 = 不透明;固定点受「标记透明度」控制)。 * @param [IN] alphaScale 整体不透明度系数(1 = 不透明;固定点受「标记透明度」控制)。
* @param [IN] vectorText 强制用 GDI+(`DrawString`)画文字,即使完全不透明。
* ⚠️ GDI `DrawTextW`(`UiText`)**不认 GDI+ 裁剪区**;胶囊压在网格的 GX 标签 /
* 图例上时必须走 GDI+,否则文字会“穿透”到图例上方(用户 2026-10-27 要求标记点
* 显示在 GX 标签与图例之下)。正常情形仍用 `UiText`(与既有像素保持一致)。
*/ */
void PaintFreeMarkerCapsule(gd::Graphics& g, const gd::RectF& label, const std::wstring& text, void PaintFreeMarkerCapsule(gd::Graphics& g, const gd::RectF& label, const std::wstring& text,
std::uint32_t color, const gd::RectF& close, bool closeHot, std::uint32_t color, const gd::RectF& close, bool closeHot,
float alphaScale = 1.0f); float alphaScale = 1.0f, bool vectorText = false);
/** /**
* @brief 计算某个固定点相对**同信号**前一个固定点的增量与其时间间隔。 * @brief 计算某个固定点相对**同信号**前一个固定点的增量与其时间间隔。
@@ -2511,6 +2511,19 @@ namespace jdi
return false; return false;
} }
// 浮动面板压在波形之上:中键落在面板上时**不穿透**为平移,直接消费掉。
// (否则在面板上按住中键拖动会把整个绘图区拖走,用户 2026-10-27 报告的交互穿透。)
{
int panelLane = -1;
int panelKind = 0;
if (TopPanelAtPoint(static_cast<float>(x), static_cast<float>(y), panelLane, panelKind))
{
BringPanelToFront(panelLane, panelKind);
m_dragMode = PlotDragMode::NONE;
return true;
}
}
// 中键:无论当前是否处于选区放大模式,都直接进入自由平移 // 中键:无论当前是否处于选区放大模式,都直接进入自由平移
// (不改变选区放大模式本身,松手后仍可继续框选)。 // (不改变选区放大模式本身,松手后仍可继续框选)。
m_dragStartX = x; m_dragStartX = x;
@@ -2703,6 +2716,28 @@ namespace jdi
return changed; return changed;
} }
bool PlotView::OnRButtonDown(int x, int y)
{
if (!HasDrawableContent())
{
return false;
}
// 浮动面板是绘图区内**最上层**的元素(与左键、双击的层序完全一致):
// 右键落在面板上必须被面板消费,绝不能穿透到下面的网格 —— 否则用户在面板上
// 右键会在面板底下弹出一整个网格的 Y 轴菜单(用户 2026-10-27 报告)。
int panelLane = -1;
int panelKind = 0;
if (!TopPanelAtPoint(static_cast<float>(x), static_cast<float>(y), panelLane, panelKind))
{
return false;
}
// 与左键一致:点中哪个面板就把它置顶(重叠时右键总是作用在用户看到的最上层那个)。
BringPanelToFront(panelLane, panelKind);
m_dragMode = PlotDragMode::NONE;
return true;
}
bool PlotView::OnMouseWheel(int delta, bool shift) bool PlotView::OnMouseWheel(int delta, bool shift)
{ {
if (!HasDrawableContent() || delta == 0) if (!HasDrawableContent() || delta == 0)
+291 -58
View File
@@ -5109,40 +5109,122 @@ namespace jdi
int iterations, int amountPercent, int iterations, int amountPercent,
const std::wstring& suffix) const std::wstring& suffix)
{ {
const int sampleIndex = FindNearestSample(*resolved.axis, time); // ⚠️⚠️ 取派生曲线在**光标准确时刻**的取值(相邻两个采样点之间按时间线性插值),
if (sampleIndex < 0) // 而不是“最近采样点”的曲线值。旧实现取最近采样点 ⇒ 光标落在两个采样点
// 之间时,标注点会脱离画出来的折线(用户 2026-10-27 报告的“预览曲线数值点悬空”)。
// ⚠️ 插值必须在**屏幕空间**(即先套用「调整」变换再线性插值):
// `PaintDerivedCurve` 逐顶点取 `ApplyDrawTransform` 后用直线连接,
// 而绝对值 / 垂直镜像不是线性变换,先插值再变换会偏离折线。
const std::size_t total =
std::min(resolved.axis->time.size(), resolved.data->values.size());
if (total == 0)
{ {
return; return;
} }
const int otherIndex =
showDelta ? FindNearestSample(*resolved.axis, otherTime) : -1;
// ⚠️ 派生曲线是在 `signal.values` 的**源下标顺序**上计算的(`ComputeDerivedWindow`
// 直接切 `values[windowBegin..windowEnd)`),而上面拿到的是**时间轴/X 方向**下标。
// 水平镜像(数据反序)时两者相反 ⇒ 必须经 `SourceIndexFor` 映射,否则光标
// 会读到镜像另一端的派生取值(曲线与信号对不上)。
const std::size_t sourceCount = resolved.data->values.size(); const std::size_t sourceCount = resolved.data->values.size();
const int sourceSample = static_cast<int>(
SourceIndexFor(*resolved.style, sourceCount, static_cast<std::size_t>(sampleIndex))); /** 某个显示时刻两侧的采样点(时间序下标)与插值比例。 */
const int sourceOther = struct CurveSampleRef
(otherIndex >= 0) {
? static_cast<int>(SourceIndexFor(*resolved.style, sourceCount, int first = -1;
static_cast<std::size_t>(otherIndex))) int second = -1;
: -1; double fraction = 0.0;
// 本函数**只需要光标所在采样点处的派生取值**(以及双光标另一端的取值), };
// 因此把请求窗口收窄到「需要的那几个下标」即可,均值滤波半径造成的取值 const auto locate = [&](double displayTime) -> CurveSampleRef
// 依赖由 `CurveSpanFor` 自身的保护带负责覆盖。 {
CurveSampleRef ref;
const double target = displayTime - resolved.style->offsetTime;
if (resolved.axis->monotonic)
{
const auto lower = std::lower_bound(
resolved.axis->time.begin(),
resolved.axis->time.begin() + static_cast<std::ptrdiff_t>(total),
target);
const std::size_t hi = static_cast<std::size_t>(
lower - resolved.axis->time.begin());
if (hi >= total)
{
ref.first = ref.second = static_cast<int>(total - 1);
return ref;
}
if (hi == 0)
{
ref.first = ref.second = 0;
return ref;
}
ref.first = static_cast<int>(hi - 1);
ref.second = static_cast<int>(hi);
const double t0 =
resolved.axis->time[static_cast<std::size_t>(ref.first)];
const double t1 =
resolved.axis->time[static_cast<std::size_t>(ref.second)];
ref.fraction =
(t1 > t0)
? std::max(0.0, std::min(1.0, (target - t0) / (t1 - t0)))
: 0.0;
return ref;
}
// 非单调时间轴:无法按时间插值,退回最近采样点。
const int nearest = FindNearestSample(*resolved.axis, target);
if (nearest < 0 || static_cast<std::size_t>(nearest) >= total)
{
return CurveSampleRef();
}
ref.first = ref.second = nearest;
return ref;
};
// 时间序下标 → 源下标。派生曲线是在 `signal.values` 的**源下标顺序**上计算的
//(`ComputeDerivedWindow` 直接切 `values[windowBegin..windowEnd)`),
// 水平镜像(数据反序)时两者相反 ⇒ 必须经 `SourceIndexFor` 映射。
const auto sourceIndexAt = [&](int timeIndex) -> int
{
if (timeIndex < 0 ||
static_cast<std::size_t>(timeIndex) >= sourceCount)
{
return -1;
}
return static_cast<int>(SourceIndexFor(
*resolved.style, sourceCount, static_cast<std::size_t>(timeIndex)));
};
const CurveSampleRef here = locate(time);
if (here.first < 0)
{
return;
}
const CurveSampleRef other = showDelta ? locate(otherTime) : CurveSampleRef();
// 本函数**只需要光标附近那几个下标的派生取值**,因此把请求窗口收窄到
// 「需要的那几个下标」即可,均值滤波半径造成的取值依赖由 `CurveSpanFor`
// 自身的保护带负责覆盖。
// //
// ⚠️ 绝不能把窗口扩成「光标 ↔ 视界边缘」的并集:光标停在视界之外(例如 // ⚠️ 绝不能把窗口扩成「光标 ↔ 视界边缘」的并集:光标停在视界之外(例如
// 平移视界把光标甩到很远,或光标远离当前缩放区间)时会得到一个上千万采样的 // 平移视界把光标甩到很远,或光标远离当前缩放区间)时会得到一个上千万采样的
// 巨大窗口,超过缓存上限后走一次性缓冲 → **每帧重算整段滤波**, // 巨大窗口,超过缓存上限后走一次性缓冲 → **每帧重算整段滤波**,
// 实测单帧 300ms+(用户报告的"平移/拖动光标异常卡顿"的直接原因)。 // 实测单帧 300ms+(用户报告的"平移/拖动光标异常卡顿"的直接原因)。
std::size_t begin = static_cast<std::size_t>(sourceSample); std::size_t begin = sourceCount;
std::size_t end = begin + 1; std::size_t end = 0;
if (sourceOther >= 0) const auto includeTime = [&](int timeIndex)
{ {
const std::size_t other = static_cast<std::size_t>(sourceOther); const int source = sourceIndexAt(timeIndex);
begin = std::min(begin, other); if (source < 0)
end = std::max(end, other + 1); {
return;
}
const std::size_t index = static_cast<std::size_t>(source);
begin = std::min(begin, index);
end = std::max(end, index + 1);
};
includeTime(here.first);
includeTime(here.second);
if (other.first >= 0)
{
includeTime(other.first);
includeTime(other.second);
}
if (end <= begin)
{
return;
} }
std::size_t spanBegin = 0; std::size_t spanBegin = 0;
const std::vector<float>* curvePtr = const std::vector<float>* curvePtr =
@@ -5162,8 +5244,51 @@ namespace jdi
return static_cast<double>((*curvePtr)[static_cast<std::size_t>(index) - return static_cast<double>((*curvePtr)[static_cast<std::size_t>(index) -
spanBegin]); spanBegin]);
}; };
const double derivedValue = curveValueAt(sourceSample); // 求某个显示时刻的「画出来的曲线值」(已套用「调整」变换,与折线逐点一致)。
if (!std::isfinite(derivedValue)) const auto curveDisplayedValue = [&](const CurveSampleRef& ref,
double& out) -> bool
{
const int source0 = sourceIndexAt(ref.first);
if (source0 < 0)
{
return false;
}
double value0 = curveValueAt(source0);
if (!std::isfinite(value0))
{
return false;
}
value0 = ApplyDrawTransform(*resolved.style, value0);
if (!std::isfinite(value0))
{
return false;
}
if (ref.second == ref.first || ref.fraction <= 0.0)
{
out = value0;
return true;
}
const int source1 = sourceIndexAt(ref.second);
if (source1 < 0)
{
return false;
}
double value1 = curveValueAt(source1);
if (!std::isfinite(value1))
{
return false;
}
value1 = ApplyDrawTransform(*resolved.style, value1);
if (!std::isfinite(value1))
{
return false;
}
out = value0 + (value1 - value0) * ref.fraction;
return true;
};
double derivedValue = 0.0;
if (!curveDisplayedValue(here, derivedValue))
{ {
return; return;
} }
@@ -5181,8 +5306,8 @@ namespace jdi
centerText += L"("; centerText += L"(";
if (showDelta) if (showDelta)
{ {
const double otherDerived = curveValueAt(sourceOther); double otherDerived = 0.0;
if (std::isfinite(otherDerived)) if (curveDisplayedValue(other, otherDerived))
{ {
const double delta = derivedValue - otherDerived; const double delta = derivedValue - otherDerived;
centerText += (delta < 0.0) ? L"-" : L"+"; centerText += (delta < 0.0) ? L"-" : L"+";
@@ -6963,7 +7088,8 @@ namespace jdi
void PlotView::PaintFreeMarkerCapsule(gd::Graphics& g, const gd::RectF& label, void PlotView::PaintFreeMarkerCapsule(gd::Graphics& g, const gd::RectF& label,
const std::wstring& text, std::uint32_t color, const std::wstring& text, std::uint32_t color,
const gd::RectF& close, bool closeHot, float alphaScale) const gd::RectF& close, bool closeHot, float alphaScale,
bool vectorText)
{ {
const float scale = std::max(0.0f, std::min(1.0f, alphaScale)); const float scale = std::max(0.0f, std::min(1.0f, alphaScale));
const auto scaled = [scale](std::uint32_t argb, float base) -> std::uint32_t { const auto scaled = [scale](std::uint32_t argb, float base) -> std::uint32_t {
@@ -6977,13 +7103,14 @@ namespace jdi
} }
UiFillRoundRect(g, label, 3.0f * m_scale, scaled(color, 0xF0)); UiFillRoundRect(g, label, 3.0f * m_scale, scaled(color, 0xF0));
DrawLabelOutline(g, label, 3.0f * m_scale, scaled(ink.outline, 255.0f)); DrawLabelOutline(g, label, 3.0f * m_scale, scaled(ink.outline, 255.0f));
if (scale >= 0.999f) if (!vectorText && scale >= 0.999f)
{ {
UiText(g, text, m_fonts->monoSmall, label, ink.text, TextAlign::CENTER); UiText(g, text, m_fonts->monoSmall, label, ink.text, TextAlign::CENTER);
} }
else else
{ {
// GDI 文本忽略 alpha ⇒ 半透明时必须改用 GDI+ 才能真的淡出(见上面说明)。 // GDI 文本忽略 alpha **也不认 GDI+ 裁剪区** ⇒ 半透明、或压在 GX 标签/图例上时
// 必须改用 GDI+ 才能真的淡出 / 被裁剪(见 `vectorText` 参数说明)。
DrawFreeMarkerAlphaText(g, text, m_fonts->monoSmall, label, scaled(ink.text, 255.0f)); DrawFreeMarkerAlphaText(g, text, m_fonts->monoSmall, label, scaled(ink.text, 255.0f));
} }
if (close.Width <= 0.0f) if (close.Width <= 0.0f)
@@ -7017,6 +7144,70 @@ namespace jdi
const std::vector<Lane>& tabLanes = m_tab->Lanes(); const std::vector<Lane>& tabLanes = m_tab->Lanes();
const std::size_t count = std::min(lanes.size(), tabLanes.size()); const std::size_t count = std::min(lanes.size(), tabLanes.size());
// ---- (0) **图层遮挡**:GX 编号徽标与图例条目必须压在所有标记点之上 ----
// 用户 2026-10-27 明确要求:固定标记点(数据点 + 取值胶囊)不得遮挡网格的 GX 标签与图例。
// 图例与徽标画在静态场景 / 装饰缓存里,而标记点画在贴图之后,因此这里从**绘制区域**里
// 把这些矩形整体排除掉(GDI+ 区域裁剪):标记点真正位于它们“下方”,被盖住的部分不画,
// 图例/徽标像素保持原样(连圆角之外的抗锯齿边缘都不会被标记点渗出)。
// ⚠️ GDI 文字(`UiText`)不认 GDI+ 裁剪区 ⇒ 胶囊文字与遮挡矩形相交时改走 GDI+
//(`PaintFreeMarkerCapsule(..., vectorText=true)`)。
// ⚠️ 只为**确实有标记点/预览**的网格做排除,避免每帧对全部网格做区域运算。
std::vector<gd::RectF> occluders;
{
bool needExclusion = (m_freeMarker.valid && m_freeMarker.ref.IsValid());
for (std::size_t index = 0; !needExclusion && index < count; ++index)
{
needExclusion = !tabLanes[index].freeMarkerItems.empty();
}
if (needExclusion)
{
for (const LegendHit& hit : m_legendHits)
{
occluders.push_back(hit.rect);
}
for (std::size_t index = 0; index < count; ++index)
{
const gd::RectF badge = LaneBadgeRect(static_cast<int>(index));
if (badge.Width > 0.0f && badge.Height > 0.0f)
{
occluders.push_back(badge);
}
}
gd::Region clip;
if (!occluders.empty() && g.GetClip(&clip) == gd::Ok)
{
for (const gd::RectF& rect : occluders)
{
const int x0 = static_cast<int>(std::floor(rect.X));
const int y0 = static_cast<int>(std::floor(rect.Y));
const int x1 = static_cast<int>(std::ceil(rect.X + rect.Width));
const int y1 = static_cast<int>(std::ceil(rect.Y + rect.Height));
if (x1 <= x0 || y1 <= y0)
{
continue;
}
gd::Region exclude(gd::Rect(x0, y0, x1 - x0, y1 - y0));
clip.Exclude(&exclude);
}
g.SetClip(&clip, gd::CombineModeReplace);
}
}
}
// 某个矩形是否与任一遮挡矩形相交(用于决定胶囊文字是否必须走可裁剪的 GDI+ 路径)。
const auto overlapsOccluder = [&occluders](const gd::RectF& rect) -> bool
{
for (const gd::RectF& occluder : occluders)
{
if (rect.X < occluder.X + occluder.Width && occluder.X < rect.X + rect.Width &&
rect.Y < occluder.Y + occluder.Height && occluder.Y < rect.Y + rect.Height)
{
return true;
}
}
return false;
};
// ---- (1) 已固定的数据点(含各自的 ✕)---- // ---- (1) 已固定的数据点(含各自的 ✕)----
// ⚠️ 固定点**不画纵向细线**(2026-10-21 要求),且取值胶囊**就在原位显示**、 // ⚠️ 固定点**不画纵向细线**(2026-10-21 要求),且取值胶囊**就在原位显示**、
// 不做重叠避让(用户 2026-10-22 明确要求恢复初版方案:允许遮挡)。 // 不做重叠避让(用户 2026-10-22 明确要求恢复初版方案:允许遮挡)。
@@ -7108,7 +7299,9 @@ namespace jdi
{ {
capsuleAlpha = 1.0f; capsuleAlpha = 1.0f;
} }
PaintFreeMarkerCapsule(g, label, text, signalColor, close, hot, capsuleAlpha); // 胶囊压在 GX 标签 / 图例上时文字必须走 GDI+(GDI 文字不认裁剪区)。
PaintFreeMarkerCapsule(g, label, text, signalColor, close, hot, capsuleAlpha,
overlapsOccluder(label));
if (rowHot) if (rowHot)
{ {
UiDrawRoundRect(g, label, 3.0f * m_scale, m_theme->accent, 2.0f * m_scale); UiDrawRoundRect(g, label, 3.0f * m_scale, m_theme->accent, 2.0f * m_scale);
@@ -7185,7 +7378,9 @@ namespace jdi
top = std::max(clipTop + 1.0f * m_scale, top = std::max(clipTop + 1.0f * m_scale,
std::min(top, clipBottom - textHeight - 1.0f * m_scale)); std::min(top, clipBottom - textHeight - 1.0f * m_scale));
const gd::RectF label(left, top, textWidth, textHeight); const gd::RectF label(left, top, textWidth, textHeight);
PaintFreeMarkerCapsule(g, label, text, signalColor, gd::RectF(), false); // 同样:压在 GX 标签 / 图例上时文字必须走 GDI+(否则会盖在图例上)。
PaintFreeMarkerCapsule(g, label, text, signalColor, gd::RectF(), false, 1.0f,
overlapsOccluder(label));
// 吸附提示:命中自动点线判据时数据点被吸附到采样点,用一圈描边示意 // 吸附提示:命中自动点线判据时数据点被吸附到采样点,用一圈描边示意
//(与"可拖拽"的光晕区分:这里只是单点圆环,不描整条曲线)。 //(与"可拖拽"的光晕区分:这里只是单点圆环,不描整条曲线)。
@@ -7387,38 +7582,76 @@ namespace jdi
} }
} }
// ---- ② 决定标注时刻:先取曲线上的最近点,再吸附到采样点 ---- // ---- ② 决定标注时刻 ----
const bool hitCurve = (distance <= static_cast<double>(tolerance)); // ⚠️⚠️ 用户 2026-10-27 明确要求:**总是**预览「网格内离鼠标较近的那个信号」的数据点
// ⚠️ 只有**贴着曲线**的那根信号才是可标注目标:否则整列预览时会把“离鼠标很远 //(同一时刻下 A 信号比 B 信号离鼠标更近 ⇒ 预览 A、单击固定的也是 A),
// 的那根曲线的采样值”当成预览值(`probe.ref` 会一直有效 ⇒ 任意位置单击都能固定)。 // **不要求鼠标贴近曲线**。
if (!hitCurve) // 旧实现在两种距离都超出容差时直接 `continue`,于是「网格里只有一条信号、鼠标离曲线
// 较远」时既没有预览点,也无法用左键固定(用户报告的核心问题)。
//
// 因此这里准备**两个候选**,取更近的那一个:
// * 曲线候选 = 鼠标到**画出来的折线**的垂直距离(阶跃的竖直跳变段只有它能命中);
// * 采样候选 = 同一时刻(鼠标时刻吸附到最近采样)该采样点在屏幕上的**纵向距离**。
// 两者都不再设“必须命中”的门槛;门槛(`tolerance`)只用来决定是否画「已吸附」圆环。
const bool interpolate = (resolved.style->mode != DrawMode::SCATTER);
double time = 0.0;
double sampled = 0.0;
bool candidateOk = false;
// (a) 曲线候选
{ {
continue; double candidateTime = XToTime(nearestX);
} if (snapToNearestSample(*resolved.axis, *resolved.data, *resolved.style, candidateTime,
double time = XToTime(nearestX); candidateTime))
// ⚠️ **命中曲线时始终吸附到最近的采样点**:固定点必须落在真实采样时刻上
//(第四轮的硬约束;否则面板里的「标记值」会变成插值出来的中间值)。
// ⚠️ 但表示“已吸附”的那个圆环**只在「自动点线」判据成立(`sparse`)时**才画:
// 密采样信号里吸附量远小于 1 像素,圆环会一直挂在那里,反而像是画错了。
// ⚠️ 鼠标**没有**命中任何曲线时(上面已 `continue`)不存在吸附问题:那时的纵向
// 光标由 `probe.time`(鼠标横坐标)驱动,光标不能自己跳到采样点上去。
bool snapped = false;
{
double candidate = 0.0;
if (snapToNearestSample(*resolved.axis, *resolved.data, *resolved.style, time, candidate))
{ {
time = candidate; double candidateSample = 0.0;
snapped = sparse; if (SampleDisplayedSignalAt(*resolved.axis, *resolved.data, *resolved.style,
candidateTime, interpolate, candidateSample) &&
std::isfinite(candidateSample))
{
time = candidateTime;
sampled = candidateSample;
candidateOk = true;
}
} }
} }
const bool interpolate = (resolved.style->mode != DrawMode::SCATTER); // (b) 采样候选:同一时刻的纵向距离(“哪个信号离鼠标更近”的主判据)
double sampled = 0.0; {
if (!SampleDisplayedSignalAt(*resolved.axis, *resolved.data, *resolved.style, time, double candidateTime = probe.time;
interpolate, sampled) || if (snapToNearestSample(*resolved.axis, *resolved.data, *resolved.style, candidateTime,
!std::isfinite(sampled)) candidateTime))
{
double candidateSample = 0.0;
if (SampleDisplayedSignalAt(*resolved.axis, *resolved.data, *resolved.style,
candidateTime, interpolate, candidateSample) &&
std::isfinite(candidateSample))
{
const double candidateValue =
ApplyDrawTransform(*resolved.style, candidateSample);
if (std::isfinite(candidateValue))
{
const double vertical = std::fabs(
static_cast<double>(ValueToY(candidateValue, visual)) -
static_cast<double>(fy));
if (!candidateOk || vertical < distance)
{
distance = vertical;
nearestX = fx;
time = candidateTime;
sampled = candidateSample;
candidateOk = true;
}
}
}
}
}
if (!candidateOk)
{ {
continue; continue;
} }
// 固定点必须落在真实采样时刻上(两个候选都已吸附到采样点)。
// 表示“已吸附”的圆环仍只在「自动点线」判据成立(`sparse`)时画:
// 密采样信号里吸附量远小于 1 像素,圆环会一直挂着,反而像是画错了。
const bool snapped = sparse;
const double value = ApplyDrawTransform(*resolved.style, sampled); const double value = ApplyDrawTransform(*resolved.style, sampled);
if (!std::isfinite(value)) if (!std::isfinite(value))
{ {
+88
View File
@@ -3021,6 +3021,8 @@ else if (panelOnly) { /* 只回送“本帧重绘过的区域并集” */ }
| 2026-10-23 | DeepSeek-V4.1-Flash(**本机**) | **目录规范化(Inc/Src)+「自由标记」常规模式回归普通鼠标 + 两个显示开关 + 序号列/列分隔线 + 行悬停压暗(详见 §12 末尾「2026-10-23 第五轮」与 §11.2 第 221–229 条)**。① **目录规范化**:四个 `*Internal.hpp` 从 `Src` 迁到 `Inc`(`DataModelInternal` / `ApplicationInternal` / `PlotViewInternal` / `SignalPanelInternal`),并在 `Makefile` 固化「`Inc` 只放头文件、`Src` 只放 `.cpp`」;因 include 全是扁平形式,无需改任何 include。② **常规模式 = 普通鼠标**:`ProbeFreeMarker` 在未开启「自由标记点选取」时直接早退(不预览/不画纵向光标/单击不固定),并补上 `if (!hitCurve) continue;`(否则纵向光标整列都在时 `probe.ref` 恒有效 ⇒ 任意位置都能固定);固定点仍恒吸附到真实采样时刻,但“已吸附”圆环只在稀疏判据成立时画。③ **两个显示开关**:「自由标记点选取」下方新增「显示增量 | 显示枚举文本」(默认关闭,半宽同行),只影响已固定点胶囊,预览胶囊恒为“值 = 枚举”。④ **面板六列**:新增恒存在的「序号」列(组内 1 起),枚举列存在时无枚举/对不上的点显示 `N/A`,列间画竖线;悬停某行时其它固定点(数据点 + 胶囊)封顶 50% 不透明度、移出恢复。⑤ **移除固定点 ✕ 的悬停浮动提示**(`PaintFreeMarkerTooltip` 实现与声明全部删除);开启选取模式的网格上信号悬停提示只显示信号名。⑥ **两处缓存键补齐**:`FreeMarkerContents` 混入枚举表指纹(旧键会让加上枚举表后仍显示 `N/A`)、`PanelSlotKey` 混入两个新按钮矩形与开关状态。验证:新增离屏 `.preflight/mark5check` **52/52**(含“预览胶囊像素差 = 0”“行悬停压暗 848px → 移出恢复 0”“选取模式气泡变窄 1023→830”);回归 `markcheck` 67/67、`mark4check` 40/40、`fixcheck` 37/37、`round3check` 31/31、`round4check` 34/34、`round5check` 5/5、`editcheck` 20/20、`convcheck` 18/18、`diag` 19/19,`check` 136/137(唯一失败项为该探针既有的不稳定项,`drift=15818` 与改动前逐位相同);`make release`/`make debug` 零警告;`TargetCheck.exe` **857,600 字节(837.5 KiB)**。 | | 2026-10-23 | DeepSeek-V4.1-Flash(**本机**) | **目录规范化(Inc/Src)+「自由标记」常规模式回归普通鼠标 + 两个显示开关 + 序号列/列分隔线 + 行悬停压暗(详见 §12 末尾「2026-10-23 第五轮」与 §11.2 第 221–229 条)**。① **目录规范化**:四个 `*Internal.hpp` 从 `Src` 迁到 `Inc`(`DataModelInternal` / `ApplicationInternal` / `PlotViewInternal` / `SignalPanelInternal`),并在 `Makefile` 固化「`Inc` 只放头文件、`Src` 只放 `.cpp`」;因 include 全是扁平形式,无需改任何 include。② **常规模式 = 普通鼠标**:`ProbeFreeMarker` 在未开启「自由标记点选取」时直接早退(不预览/不画纵向光标/单击不固定),并补上 `if (!hitCurve) continue;`(否则纵向光标整列都在时 `probe.ref` 恒有效 ⇒ 任意位置都能固定);固定点仍恒吸附到真实采样时刻,但“已吸附”圆环只在稀疏判据成立时画。③ **两个显示开关**:「自由标记点选取」下方新增「显示增量 | 显示枚举文本」(默认关闭,半宽同行),只影响已固定点胶囊,预览胶囊恒为“值 = 枚举”。④ **面板六列**:新增恒存在的「序号」列(组内 1 起),枚举列存在时无枚举/对不上的点显示 `N/A`,列间画竖线;悬停某行时其它固定点(数据点 + 胶囊)封顶 50% 不透明度、移出恢复。⑤ **移除固定点 ✕ 的悬停浮动提示**(`PaintFreeMarkerTooltip` 实现与声明全部删除);开启选取模式的网格上信号悬停提示只显示信号名。⑥ **两处缓存键补齐**:`FreeMarkerContents` 混入枚举表指纹(旧键会让加上枚举表后仍显示 `N/A`)、`PanelSlotKey` 混入两个新按钮矩形与开关状态。验证:新增离屏 `.preflight/mark5check` **52/52**(含“预览胶囊像素差 = 0”“行悬停压暗 848px → 移出恢复 0”“选取模式气泡变窄 1023→830”);回归 `markcheck` 67/67、`mark4check` 40/40、`fixcheck` 37/37、`round3check` 31/31、`round4check` 34/34、`round5check` 5/5、`editcheck` 20/20、`convcheck` 18/18、`diag` 19/19,`check` 136/137(唯一失败项为该探针既有的不稳定项,`drift=15818` 与改动前逐位相同);`make release`/`make debug` 零警告;`TargetCheck.exe` **857,600 字节(837.5 KiB)**。 |
| 2026-10-27 | DeepSeek-V4.1-Flash(**本机**) | **浮动面板事件穿透 + 「自由标记」总是预览最近信号 + 固定点位于 GX/图例之下 + 预览曲线取值插值 + 体积红线 900KB(详见 §12 末尾「2026-10-27 第六轮」与 §11.2 第 230–234 条)**。① **面板右键/中键穿透**:新增 `PlotView::OnRButtonDown`(`TopPanelAtPoint` 命中即置顶并消费,宿主不再弹网格 Y 轴菜单);`OnMButtonDown` 在面板上不再进入平移。② **自由标记探测去掉“必须贴近曲线”的门槛**:两个候选(折线垂直距离 / 同一时刻采样点纵向距离)取更近者 ⇒ 网格内只有一条信号、鼠标离曲线很远时也能预览并单击固定(A/B:恢复旧门限 ⇒ 5 项断言失败)。③ **固定标记点必须位于 GX 徽标与图例之下**:用 `gd::Region::Exclude` 把图例托底与徽标矩形从 `PaintFreeMarker` 的绘制区里排除,胶囊文字在相交时改走 GDI+(GDI 文字不认裁剪);实测图例区域像素差 **0**(旧的“画完再重绘图例”方案残留 240px 抗锯齿差)。④ **预览曲线取值点悬空**:`paintDerivedCapsule` 改为在光标时刻对派生曲线**按时间插值**(先 `ApplyDrawTransform` 再线性插值,与折线逐点一致);阶跃区实测标注点与曲线差 **0.0px**(旧实现偏 36–39px),中心轴/均值滤波/弱峰调整共用同一段代码。⑤ **体积红线由 800KB 放宽到 900KB**(到达阈值时提醒用户即可,不是禁止超过)。验证:离屏临时探针 **25/25**(已删除,不留在仓库);`make release`/`make debug` 零警告;`Target.exe` **860,672 字节(840.5 KiB)**。 |
## 11. 跨设备协作须知(起源设备视角) ## 11. 跨设备协作须知(起源设备视角)
> **本文件是唯一权威记忆**。所有设备 / Agent 共用它,**不要再新建 `agent_mem_origin.md` 等旁支记忆文件**。 > **本文件是唯一权威记忆**。所有设备 / Agent 共用它,**不要再新建 `agent_mem_origin.md` 等旁支记忆文件**。
@@ -4057,6 +4059,27 @@ else if (panelOnly) { /* 只回送“本帧重绘过的区域并集” */ }
229. ⚠️ **固定点必须落在真实采样时刻上**:`ProbeFreeMarker` 命中曲线时**始终**吸附到最近采样点 229. ⚠️ **固定点必须落在真实采样时刻上**:`ProbeFreeMarker` 命中曲线时**始终**吸附到最近采样点
(`snapToNearestSample`);只有“已吸附”圆环(`probe.snapped`)仍只在稀疏判据 `sparse` 成立时才显示 (`snapToNearestSample`);只有“已吸附”圆环(`probe.snapped`)仍只在稀疏判据 `sparse` 成立时才显示
(密采样时吸附量 < 1 像素,圆环常驻会像是画错了)。 (密采样时吸附量 < 1 像素,圆环常驻会像是画错了)。
230. ⚠️⚠️ **浮动面板必须消费右键 / 中键**:`PlotView::OnRButtonDown` 用 `TopPanelAtPoint` 判定,
命中即 `BringPanelToFront` 并返回 true(宿主**不得**再弹网格的 Y 轴菜单);`OnMButtonDown`
在面板上**不得**进入平移(`m_dragMode = NONE` 后消费)。层序与左键 / 双击完全一致。
⚠️ 任何“上层元素”都必须在宿主分发前判定,否则会“在面板上右键却弹出下面网格的菜单”。
231. ⚠️⚠️ **「自由标记点选取」的探测没有“必须贴近曲线”的门槛**(用户 2026-10-27 明确要求):
总是预览**网格内离鼠标较近的那个信号**的数据点(同一时刻下纵向更近者优先)。
实现 = 两个候选取更近:① 鼠标到画出来的折线的垂直距离(阶跃竖直段只有它能命中);
② 同一时刻(鼠标时刻吸附到最近采样)该采样点的**纵向距离**。旧实现 `if (!hitCurve) continue;`
⇒ 网格里只有一条信号且鼠标离曲线较远时既无预览也无法单击固定。
232. ⚠️⚠️ **固定标记点必须位于 GX 编号徽标与图例“之下”**:做法是把 `m_legendHits` 的托底矩形与
`LaneBadgeRect` 全部 `gd::Region::Exclude` 出绘制区(`PaintFreeMarker` 开头),
**不要**改用“画完标记点再把图例重画一遍”—— 重画的抗锯齿边缘与装饰缓存 `AlphaBlend`
的结果不完全一致(实测图例胶囊圆角处仍有 240 像素差)。
⚠️ **GDI 文字不认 GDI+ 裁剪**:胶囊文字与遮挡矩形相交时必须走 GDI+(`PaintFreeMarkerCapsule`
的 `vectorText` 参数),否则文字会“穿透”到图例上面。
233. ⚠️⚠️ **预览曲线的取值必须按时间在相邻采样点之间插值**,且插值要在**套用「调整」变换之后**
(`PaintDerivedCurve` 逐顶点 `ApplyDrawTransform` 后用直线连接;绝对值/垂直镜像不是线性的)。
只取“最近采样点”的曲线值会让标注点离开曲线(用户 2026-10-27 报的“预览曲线数值点悬空”,
实测阶跃区偏 36~39 像素)。`probe` / 中心轴 / 均值滤波 / 弱峰调整共用 `paintDerivedCapsule`。
234. ⚠️ **产物体积红线已放宽到 900KB**(用户 2026-10-27):**到达 900KB 必须提醒用户**,
以便确认功能带来的体积膨胀是否值得;这不是“不许超过”,旧 800KB 红线作废。
### 11.3 离屏验证方法学(本机偏好,强烈建议沿用) ### 11.3 离屏验证方法学(本机偏好,强烈建议沿用)
@@ -7794,3 +7817,68 @@ DG3 data=0 cycle=0 (空组,一个字符串通道)
判断“是否命中曲线/是否有可固定的点”必须看 `probe.ref.IsValid()`; 判断“是否命中曲线/是否有可固定的点”必须看 `probe.ref.IsValid()`;
在固定点上做像素 A/B 时,固定点若落在悬浮面板下方(绘图区右侧)像素永远不会变 ⇒ 固定点的时间要放在左侧。 在固定点上做像素 A/B 时,固定点若落在悬浮面板下方(绘图区右侧)像素永远不会变 ⇒ 固定点的时间要放在左侧。
## 本轮(2026-10-27 第六轮,本机):浮动面板事件穿透 + 自由标记“总是预览最近信号” + 固定点位于 GX/图例之下 + 预览曲线取值插值 + 体积红线 900KB
> 用户需求(逐条):
> ① 浮动面板上右键会触发下方网格的右键菜单;面板上的点击 / 右键 / 拖拽等操作不得穿透到下层。
> ② 「自由标记点选取」应**总是**预览网格内离鼠标较近的那条信号的数据点(同一时刻 A 比 B 更近 ⇒ 预览 A),
> 现状是“即使只有一个信号,也必须把鼠标贴到曲线附近才会出现预览点”,远离曲线时无法预览、也无法左键固定。
> ③ 固定数据标记点不得遮挡网格的 GX 标签与图例:数据点与数据显示应位于它们**下方**。
> ④ 预览曲线(均值滤波等)的光标数值点会“悬空”(光标落在两个采样点之间时不落在曲线上),要求在所有带预览曲线的功能中修好。
> ⑤ 产物体积红线由 800KB 放宽到 **900KB**(到达阈值提醒即可,不是禁止超过)。
### A. 浮动面板不得被右键 / 中键穿透(§11.2 第 230 条)
- 新增 `PlotView::OnRButtonDown(int x, int y)`:`TopPanelAtPoint` 命中即 `BringPanelToFront` + `m_dragMode = NONE`,
返回 true(该次右键被面板消费)。`Application::WM_RBUTTONDOWN` 在“绘图区右键 → 网格菜单”**之前**调用它。
- `PlotView::OnMButtonDown` 开头同样判定浮动面板:命中即消费,**不进入平移**(否则会在面板上按住中键拖走整个绘图区)。
- 左键 / 双击的层序原本就正确(`OnLButtonDown` 的面板分支、`OnDoubleClick` 的面板分支),本轮未改。
### B. 「自由标记」总是预览网格内离鼠标较近的信号(§11.2 第 231 条)
- `ProbeFreeMarker` 删掉 `if (!hitCurve) continue;`,改成**两个候选取更近**:
* 曲线候选 = 鼠标到画出来的折线的垂直距离(阶跃竖直段只有它能命中);
* 采样候选 = 鼠标时刻吸附到最近采样后,该采样点的**屏幕纵向距离**。
两者都不设“必须命中”的门槛,`tolerance` 只用于决定是否画“已吸附”圆环(`snapped = sparse`)。
- 逐信号的 `distance` 取两者的较小值,因此“同一时刻下谁离鼠标更近就预览谁”在单信号与多信号下都成立。
- `snapToNearestSample` 仍然对两个候选生效 ⇒ 固定点始终落在真实采样时刻上(第 229 条不变)。
### C. 固定标记点位于 GX 徽标与图例之下(§11.2 第 232 条)
- `PaintFreeMarker` 开头(在 `g.SetClip(m_plotRect, Intersect)` 之后)收集遮挡矩形:`m_legendHits` 的全部托底 +
每个网格的 `LaneBadgeRect`,逐个 `gd::Region::Exclude` 出当前裁剪区,再 `SetClip(..., CombineModeReplace)`。
标记点(圆点 / 胶囊 / ✕ / 预览胶囊)因此**完全画不进**这些区域,图例与徽标的像素(含胶囊圆角之外的抗锯齿边)保持原样。
- ⚠️ **GDI 文字不认 GDI+ 裁剪**:`PaintFreeMarkerCapsule` 新增 `vectorText` 参数,胶囊矩形与任一遮挡矩形相交时
强制走 GDI+(`DrawFreeMarkerAlphaText`),否则文字会盖到图例上面。
- ⚠️ 试过并放弃的替代方案:画完标记点后**把图例原样重画一遍**(`PaintLegendOverlay`)。
它与装饰缓存的 `AlphaBlend` 结果不完全一致,实测图例胶囊圆角处残留 **240 像素**差;纯裁剪方案为 **0**。
- 只在“本网格确实有固定点 / 探针正停在本网格”时才做区域运算,没有标记时零开销。
### D. 预览曲线取值点落回曲线(§11.2 第 233 条)
- `paintDerivedCapsule` 不再取“最近采样点”的曲线值,改为在光标准确时刻对派生曲线**按时间线性插值**:
`locate(displayTime)` 用二分求两侧采样下标与比例(非单调时间轴退回最近采样),
再**先 `ApplyDrawTransform` 后插值**(绝对值 / 垂直镜像不是线性变换,先插值会偏离折线)。
- 请求窗口由“用到的那些源下标”拼出(仍收窄,不会因光标远离视界而重算整条曲线)。
- 双光标增量(`(+Δ 中心轴)`)同样走插值;显示数值现在也是**变换后**的值(与画出来的曲线一致)。
- 中心轴 / 均值滤波 / 弱峰调整共用同一段代码,因此三处一起修好。
### E. 体积红线 900KB(§11.2 第 234 条)
- `Makefile` 的 `RELEASE_OPT` 注释更新:**到达 900KB 必须提醒用户**(不是禁止超过),旧 800KB 红线作废。
### F. 验证(全离屏,未占用鼠标 / 键盘 / 屏幕)
- 临时离屏探针(仓库外 `%TEMP%\jdi_probe`,链接 `Build/Release/*.o` 去掉 `Main.o`)**25/25**:
* [A] 面板上右键被 `PlotView` 消费且**不弹菜单**、面板外右键仍弹菜单;面板上中键不进入平移、面板外仍平移;
* [B] 单信号 + 鼠标远离曲线 ⇒ `probe.ref` 有效、单击可固定、时间吸附到采样;两信号同网格 ⇒ 视鼠标高度分别选中 hold / step;
* [C] 加一个会压在图例上的固定点前后两帧:图例托底 + GX 徽标矩形内像素差 **0**,同时整幅差异 > 5 万像素(证明标记点确实画了);
* [D] `SCATTER_TEST.csv` 的 step、视界 0.998056–1.00091、光标 0.999277、均值点数 2 / 迭代 1:
均值滤波 0.0px、中心轴 0.0px、弱峰调整 0.2px(与曲线逐像素贴合);插值点与最近采样点相差 39px / 197px。
- **A/B 判别力**:临时恢复 `if (!hitCurve) continue;` ⇒ [B] 5 项如实失败;临时把插值改回“最近采样点” ⇒ 均值/中心轴各自偏 36px 并如实失败。
- `make release` / `make debug` **零警告**;`Target.exe` **860,672 字节(840.5 KiB < 900KB)**,比上一轮 +3 KB。
- ⚠️ 探针坑:`CycleCursorMode()` 是**循环**(无→单→双→无),在多组用例里每组都调一次会让后几组“没有光标”而误判为失败;
面板 / `LaneVisual` / `LegendHit` / `EdgePanelLayout` 都是 `PlotView` 的嵌套类型,探针里必须写 `PlotView::` 前缀。
- ⚠️ 本轮开始前发现工作区里 `PlotViewPaint.cpp` 与 `Documents/agent_mem.md` 被**回退到 `8f735d5`**(丢失第四/五轮全部改动),
已 `git checkout --` 恢复到 `HEAD` 后再施工;提交前务必确认这两份文件与 HEAD 一致。
+3 -1
View File
@@ -12,7 +12,9 @@ SUB_DIR_DEBUG = Debug
# Release 优化等级 # Release 优化等级
# ⚠️ 用 `-Oz` 而不是 `-Os`:本项目热点全在 GDI/GDI+ 绘制上,`-Oz` 与 `-Os` 的实测帧耗时 # ⚠️ 用 `-Oz` 而不是 `-Os`:本项目热点全在 GDI/GDI+ 绘制上,`-Oz` 与 `-Os` 的实测帧耗时
# 没有可辨识差异,但产物小 ~6KB(用户 2026-10-19 要求 Release 二进制 < 800KB)。 # 没有可辨识差异,但产物小 ~6KB。
# ⚠️ 产物体积上限(用户 2026-10-27 更新):**900KB**。这不是"不许超过",而是**到达 900KB
# 就必须提醒用户**,以便确认功能带来的体积膨胀是否值得(旧红线 800KB 已作废)。
RELEASE_OPT = -Oz RELEASE_OPT = -Oz
# Debug 优化等级 # Debug 优化等级