From df715a035e82fd5fa3a1d07bd4bef87112f8a233 Mon Sep 17 00:00:00 2001 From: "DeepSeek V4.1 Flash" <> Date: Tue, 27 Oct 2026 20:14:49 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=B5=AE=E5=8A=A8=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF=E4=BA=8B=E4=BB=B6=E7=A9=BF=E9=80=8F=20/=20=E8=87=AA?= =?UTF-8?q?=E7=94=B1=E6=A0=87=E8=AE=B0=E6=80=BB=E6=98=AF=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E6=9C=80=E8=BF=91=E4=BF=A1=E5=8F=B7=20/=20=E5=9B=BA=E5=AE=9A?= =?UTF-8?q?=E7=82=B9=E4=BD=8D=E4=BA=8EGX=E4=B8=8E=E5=9B=BE=E4=BE=8B?= =?UTF-8?q?=E4=B9=8B=E4=B8=8B=20/=20=E9=A2=84=E8=A7=88=E6=9B=B2=E7=BA=BF?= =?UTF-8?q?=E5=8F=96=E5=80=BC=E6=8F=92=E5=80=BC=EF=BC=9B=E4=BD=93=E7=A7=AF?= =?UTF-8?q?=E7=BA=A2=E7=BA=BF=E6=94=BE=E5=AE=BD=E5=88=B0900KB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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)。 --- Components/Application/Src/Application.cpp | 8 + Components/PlotView/Inc/PlotView.hpp | 18 +- .../PlotView/Src/PlotViewInteraction.cpp | 35 ++ Components/PlotView/Src/PlotViewPaint.cpp | 349 +++++++++++++++--- Documents/agent_mem.md | 88 +++++ Makefile | 4 +- 6 files changed, 442 insertions(+), 60 deletions(-) diff --git a/Components/Application/Src/Application.cpp b/Components/Application/Src/Application.cpp index f4080b6..f4de91c 100644 --- a/Components/Application/Src/Application.cpp +++ b/Components/Application/Src/Application.cpp @@ -1346,6 +1346,14 @@ namespace jdi return 0; } + // 浮动面板:右键属于面板自身,**不得穿透**到下层网格(否则会在面板上弹出 + // 网格的 Y 轴菜单)。必须先于下方的"绘图区右键 → 网格菜单"判定。 + if (m_plot.OnRButtonDown(x, y)) + { + InvalidateRect(hwnd, nullptr, FALSE); + return 0; + } + // 绘图区右键:弹出该网格的 Y 轴设置菜单(含上下限输入)。 if (m_plot.HitTest(x, y)) { diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index 60f66b4..705dd60 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -224,6 +224,18 @@ namespace jdi */ bool OnLButtonUp(int x, int y); + /** + * @brief 处理鼠标右键按下(**只处理浮动面板**)。 + * + * 浮动面板压在波形之上,属最上层元素:右键落在面板上必须被面板消费, + * 不得穿透到下面的网格(否则会在面板上弹出网格的 Y 轴菜单)。 + * + * @param [IN] x 客户区横坐标。 + * @param [IN] y 客户区纵坐标。 + * @return 命中浮动面板(已消费该次右键)返回 true;否则返回 false,由宿主继续处理。 + */ + bool OnRButtonDown(int x, int y); + /** * @brief 处理鼠标中键按下:与左键在常规模式下一致,进入自由平移(水平平移时间轴 + * 纵向偏移当前网格幅值)。**任何缩放模式下都可用**,用于在不改变当前模式的 @@ -1969,10 +1981,14 @@ namespace jdi * @param [IN] close ✕ 按钮矩形(宽度为 0 表示不画)。 * @param [IN] closeHot ✕ 是否处于悬停态。 * @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, std::uint32_t color, const gd::RectF& close, bool closeHot, - float alphaScale = 1.0f); + float alphaScale = 1.0f, bool vectorText = false); /** * @brief 计算某个固定点相对**同信号**前一个固定点的增量与其时间间隔。 diff --git a/Components/PlotView/Src/PlotViewInteraction.cpp b/Components/PlotView/Src/PlotViewInteraction.cpp index 424e558..d5c8c7f 100644 --- a/Components/PlotView/Src/PlotViewInteraction.cpp +++ b/Components/PlotView/Src/PlotViewInteraction.cpp @@ -2511,6 +2511,19 @@ namespace jdi return false; } + // 浮动面板压在波形之上:中键落在面板上时**不穿透**为平移,直接消费掉。 + // (否则在面板上按住中键拖动会把整个绘图区拖走,用户 2026-10-27 报告的交互穿透。) + { + int panelLane = -1; + int panelKind = 0; + if (TopPanelAtPoint(static_cast(x), static_cast(y), panelLane, panelKind)) + { + BringPanelToFront(panelLane, panelKind); + m_dragMode = PlotDragMode::NONE; + return true; + } + } + // 中键:无论当前是否处于选区放大模式,都直接进入自由平移 // (不改变选区放大模式本身,松手后仍可继续框选)。 m_dragStartX = x; @@ -2703,6 +2716,28 @@ namespace jdi 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(x), static_cast(y), panelLane, panelKind)) + { + return false; + } + // 与左键一致:点中哪个面板就把它置顶(重叠时右键总是作用在用户看到的最上层那个)。 + BringPanelToFront(panelLane, panelKind); + m_dragMode = PlotDragMode::NONE; + return true; + } + bool PlotView::OnMouseWheel(int delta, bool shift) { if (!HasDrawableContent() || delta == 0) diff --git a/Components/PlotView/Src/PlotViewPaint.cpp b/Components/PlotView/Src/PlotViewPaint.cpp index 62cd70d..1235313 100644 --- a/Components/PlotView/Src/PlotViewPaint.cpp +++ b/Components/PlotView/Src/PlotViewPaint.cpp @@ -5109,40 +5109,122 @@ namespace jdi int iterations, int amountPercent, 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; } - 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 int sourceSample = static_cast( - SourceIndexFor(*resolved.style, sourceCount, static_cast(sampleIndex))); - const int sourceOther = - (otherIndex >= 0) - ? static_cast(SourceIndexFor(*resolved.style, sourceCount, - static_cast(otherIndex))) - : -1; - // 本函数**只需要光标所在采样点处的派生取值**(以及双光标另一端的取值), - // 因此把请求窗口收窄到「需要的那几个下标」即可,均值滤波半径造成的取值 - // 依赖由 `CurveSpanFor` 自身的保护带负责覆盖。 + + /** 某个显示时刻两侧的采样点(时间序下标)与插值比例。 */ + struct CurveSampleRef + { + int first = -1; + int second = -1; + double fraction = 0.0; + }; + const auto locate = [&](double displayTime) -> CurveSampleRef + { + 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(total), + target); + const std::size_t hi = static_cast( + lower - resolved.axis->time.begin()); + if (hi >= total) + { + ref.first = ref.second = static_cast(total - 1); + return ref; + } + if (hi == 0) + { + ref.first = ref.second = 0; + return ref; + } + ref.first = static_cast(hi - 1); + ref.second = static_cast(hi); + const double t0 = + resolved.axis->time[static_cast(ref.first)]; + const double t1 = + resolved.axis->time[static_cast(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(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(timeIndex) >= sourceCount) + { + return -1; + } + return static_cast(SourceIndexFor( + *resolved.style, sourceCount, static_cast(timeIndex))); + }; + + const CurveSampleRef here = locate(time); + if (here.first < 0) + { + return; + } + const CurveSampleRef other = showDelta ? locate(otherTime) : CurveSampleRef(); + + // 本函数**只需要光标附近那几个下标的派生取值**,因此把请求窗口收窄到 + // 「需要的那几个下标」即可,均值滤波半径造成的取值依赖由 `CurveSpanFor` + // 自身的保护带负责覆盖。 // // ⚠️ 绝不能把窗口扩成「光标 ↔ 视界边缘」的并集:光标停在视界之外(例如 // 平移视界把光标甩到很远,或光标远离当前缩放区间)时会得到一个上千万采样的 // 巨大窗口,超过缓存上限后走一次性缓冲 → **每帧重算整段滤波**, // 实测单帧 300ms+(用户报告的"平移/拖动光标异常卡顿"的直接原因)。 - std::size_t begin = static_cast(sourceSample); - std::size_t end = begin + 1; - if (sourceOther >= 0) + std::size_t begin = sourceCount; + std::size_t end = 0; + const auto includeTime = [&](int timeIndex) { - const std::size_t other = static_cast(sourceOther); - begin = std::min(begin, other); - end = std::max(end, other + 1); + const int source = sourceIndexAt(timeIndex); + if (source < 0) + { + return; + } + const std::size_t index = static_cast(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; const std::vector* curvePtr = @@ -5162,8 +5244,51 @@ namespace jdi return static_cast((*curvePtr)[static_cast(index) - 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; } @@ -5181,8 +5306,8 @@ namespace jdi centerText += L"("; if (showDelta) { - const double otherDerived = curveValueAt(sourceOther); - if (std::isfinite(otherDerived)) + double otherDerived = 0.0; + if (curveDisplayedValue(other, otherDerived)) { const double delta = derivedValue - otherDerived; centerText += (delta < 0.0) ? L"-" : L"+"; @@ -6963,7 +7088,8 @@ namespace jdi void PlotView::PaintFreeMarkerCapsule(gd::Graphics& g, const gd::RectF& label, 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 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)); 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); } else { - // GDI 文本忽略 alpha ⇒ 半透明时必须改用 GDI+ 才能真的淡出(见上面说明)。 + // GDI 文本忽略 alpha **也不认 GDI+ 裁剪区** ⇒ 半透明、或压在 GX 标签/图例上时 + // 必须改用 GDI+ 才能真的淡出 / 被裁剪(见 `vectorText` 参数说明)。 DrawFreeMarkerAlphaText(g, text, m_fonts->monoSmall, label, scaled(ink.text, 255.0f)); } if (close.Width <= 0.0f) @@ -7017,6 +7144,70 @@ namespace jdi const std::vector& tabLanes = m_tab->Lanes(); 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 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(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(std::floor(rect.X)); + const int y0 = static_cast(std::floor(rect.Y)); + const int x1 = static_cast(std::ceil(rect.X + rect.Width)); + const int y1 = static_cast(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) 已固定的数据点(含各自的 ✕)---- // ⚠️ 固定点**不画纵向细线**(2026-10-21 要求),且取值胶囊**就在原位显示**、 // 不做重叠避让(用户 2026-10-22 明确要求恢复初版方案:允许遮挡)。 @@ -7108,7 +7299,9 @@ namespace jdi { 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) { 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, std::min(top, clipBottom - textHeight - 1.0f * m_scale)); 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(tolerance)); - // ⚠️ 只有**贴着曲线**的那根信号才是可标注目标:否则整列预览时会把“离鼠标很远 - // 的那根曲线的采样值”当成预览值(`probe.ref` 会一直有效 ⇒ 任意位置单击都能固定)。 - if (!hitCurve) + // ---- ② 决定标注时刻 ---- + // ⚠️⚠️ 用户 2026-10-27 明确要求:**总是**预览「网格内离鼠标较近的那个信号」的数据点 + //(同一时刻下 A 信号比 B 信号离鼠标更近 ⇒ 预览 A、单击固定的也是 A), + // **不要求鼠标贴近曲线**。 + // 旧实现在两种距离都超出容差时直接 `continue`,于是「网格里只有一条信号、鼠标离曲线 + // 较远」时既没有预览点,也无法用左键固定(用户报告的核心问题)。 + // + // 因此这里准备**两个候选**,取更近的那一个: + // * 曲线候选 = 鼠标到**画出来的折线**的垂直距离(阶跃的竖直跳变段只有它能命中); + // * 采样候选 = 同一时刻(鼠标时刻吸附到最近采样)该采样点在屏幕上的**纵向距离**。 + // 两者都不再设“必须命中”的门槛;门槛(`tolerance`)只用来决定是否画「已吸附」圆环。 + const bool interpolate = (resolved.style->mode != DrawMode::SCATTER); + double time = 0.0; + double sampled = 0.0; + bool candidateOk = false; + // (a) 曲线候选 { - continue; - } - double time = XToTime(nearestX); - // ⚠️ **命中曲线时始终吸附到最近的采样点**:固定点必须落在真实采样时刻上 - //(第四轮的硬约束;否则面板里的「标记值」会变成插值出来的中间值)。 - // ⚠️ 但表示“已吸附”的那个圆环**只在「自动点线」判据成立(`sparse`)时**才画: - // 密采样信号里吸附量远小于 1 像素,圆环会一直挂在那里,反而像是画错了。 - // ⚠️ 鼠标**没有**命中任何曲线时(上面已 `continue`)不存在吸附问题:那时的纵向 - // 光标由 `probe.time`(鼠标横坐标)驱动,光标不能自己跳到采样点上去。 - bool snapped = false; - { - double candidate = 0.0; - if (snapToNearestSample(*resolved.axis, *resolved.data, *resolved.style, time, candidate)) + double candidateTime = XToTime(nearestX); + if (snapToNearestSample(*resolved.axis, *resolved.data, *resolved.style, candidateTime, + candidateTime)) { - time = candidate; - snapped = sparse; + double candidateSample = 0.0; + 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); - double sampled = 0.0; - if (!SampleDisplayedSignalAt(*resolved.axis, *resolved.data, *resolved.style, time, - interpolate, sampled) || - !std::isfinite(sampled)) + // (b) 采样候选:同一时刻的纵向距离(“哪个信号离鼠标更近”的主判据) + { + double candidateTime = probe.time; + if (snapToNearestSample(*resolved.axis, *resolved.data, *resolved.style, candidateTime, + 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(ValueToY(candidateValue, visual)) - + static_cast(fy)); + if (!candidateOk || vertical < distance) + { + distance = vertical; + nearestX = fx; + time = candidateTime; + sampled = candidateSample; + candidateOk = true; + } + } + } + } + } + if (!candidateOk) { continue; } + // 固定点必须落在真实采样时刻上(两个候选都已吸附到采样点)。 + // 表示“已吸附”的圆环仍只在「自动点线」判据成立(`sparse`)时画: + // 密采样信号里吸附量远小于 1 像素,圆环会一直挂着,反而像是画错了。 + const bool snapped = sparse; const double value = ApplyDrawTransform(*resolved.style, sampled); if (!std::isfinite(value)) { diff --git a/Documents/agent_mem.md b/Documents/agent_mem.md index 484f481..0359a1b 100644 --- a/Documents/agent_mem.md +++ b/Documents/agent_mem.md @@ -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-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. 跨设备协作须知(起源设备视角) > **本文件是唯一权威记忆**。所有设备 / Agent 共用它,**不要再新建 `agent_mem_origin.md` 等旁支记忆文件**。 @@ -4057,6 +4059,27 @@ else if (panelOnly) { /* 只回送“本帧重绘过的区域并集” */ } 229. ⚠️ **固定点必须落在真实采样时刻上**:`ProbeFreeMarker` 命中曲线时**始终**吸附到最近采样点 (`snapToNearestSample`);只有“已吸附”圆环(`probe.snapped`)仍只在稀疏判据 `sparse` 成立时才显示 (密采样时吸附量 < 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 离屏验证方法学(本机偏好,强烈建议沿用) @@ -7794,3 +7817,68 @@ DG3 data=0 cycle=0 (空组,一个字符串通道) 判断“是否命中曲线/是否有可固定的点”必须看 `probe.ref.IsValid()`; 在固定点上做像素 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 一致。 diff --git a/Makefile b/Makefile index 6d49780..aed4884 100644 --- a/Makefile +++ b/Makefile @@ -12,7 +12,9 @@ SUB_DIR_DEBUG = Debug # Release 优化等级 # ⚠️ 用 `-Oz` 而不是 `-Os`:本项目热点全在 GDI/GDI+ 绘制上,`-Oz` 与 `-Os` 的实测帧耗时 -# 没有可辨识差异,但产物小 ~6KB(用户 2026-10-19 要求 Release 二进制 < 800KB)。 +# 没有可辨识差异,但产物小 ~6KB。 +# ⚠️ 产物体积上限(用户 2026-10-27 更新):**900KB**。这不是"不许超过",而是**到达 900KB +# 就必须提醒用户**,以便确认功能带来的体积膨胀是否值得(旧红线 800KB 已作废)。 RELEASE_OPT = -Oz # Debug 优化等级