调色性能优化 + 新增色域方块拾色 + 取值胶囊高对比配色 + 数据点标记修复

1. 修复自定义颜色滑动卡顿(VERY_BIG_DATA 全信号可见时 36ms/步 → 约 12ms/步)
   - 根因:场景缓存的内容指纹把逐信号的颜色/不透明度/线宽/模式都算了进去,
     改一次颜色就整幅失效,于是所有网格的波形都要重新光栅化。
   - 新增"不含外观样式"的几何指纹与样式快照:只有在网格结构、视图几何、信号集合与
     文字都不变、仅外观样式变化时,走"局部重画"快路径 —— 只重画颜色变了的那个网格,
     再把结果写回场景缓存(下一帧回到纯贴图)。
   - 补丁矩形向内对齐到整数像素,避免把属于相邻网格的一行擦掉而留下 1px 接缝;
     底色填充按补丁矩形本身进行(大面积不透明填充会走 GDI 快路径,不受 GDI+ 裁剪约束)。
   - 该优化对调色板点击、线宽/不透明度滑块、绘制模式切换同样生效。

2. 自定义颜色新增"方块色域图"拾色
   - 新增饱和度-明度方块(横轴饱和度、纵轴明度)与纵向色相条,可直接在色域图上取色。
   - 与面板内其它控件完全联动:RGB 滑块、十六进制输入、预览色块同源(RGB 为唯一数据源),
     并记忆色相,消色差时仍可用色相条换回颜色。
   - 色域方块按当前色相预渲染缓存,色相不变时不重复绘制;色域内用十字光标、色相条用纵向光标。

3. 取值胶囊的对比度描边与文本色改为对信号配色线性响应
   - 原实现只有一个亮度阈值,文字/描边只有黑、白两套固定取值。现按底色亮度选定亮/暗一侧,
     再与底色的互补色按固定比例线性混合(互补色亮度恰为 1−底色亮度),
     因此文字与描边的亮度随信号配色线性变化,且任意底色下都保持高对比。

4. 修复光标数据点越界三角的判定与绘制
   - 是否改用三角,以"数据点本身是否超出绘制边界"为准,不再因为数值标注被贴边而改变形状。
   - 三角方向修正为尖端朝外(此前上越界画下三角、左越界画右三角,方向完全相反),
     内三角改按质心收缩,描边不再是歪的;上下越界时三角与显示的数值纵向居中对齐。
This commit is contained in:
DeepSeek V4.1 Flash committed 2026-10-10 14:02:37 +08:00
1 parent d3c83237f1
commit 40b12d6e8b
7 files changed
+877 -124

No files matched your search

+65 -4
View File
@@ -835,12 +835,13 @@ namespace jdi
bool IsOnCursorSpanLabel(int x, int y) const;
/**
* @brief 画文字时给底色胶囊加一层与文字反色的细描边,提升与背景/曲线的对比度。
* @brief 画文字时给底色胶囊加一层细描边,提升与背景/曲线的对比度。
* @param [IN] rect 胶囊矩形。
* @param [IN] radius 圆角半径。
* @param [IN] textDark 文字是否用深色。
* @param [IN] outlineColor 描边色(由 `LabelInkFor` 与文字色一并算出的反相色)。
*/
void DrawLabelOutline(gd::Graphics& g, const gd::RectF& rect, float radius, bool textDark);
void DrawLabelOutline(gd::Graphics& g, const gd::RectF& rect, float radius,
std::uint32_t outlineColor);
/**
* @brief 精确检测鼠标位置是否命中某条可见信号曲线。
@@ -1404,9 +1405,65 @@ namespace jdi
* 视图几何以外的任何内容变化(改颜色/线宽/透明度/样式、重命名、增删信号)
* 都会改变该指纹,从而让静态场景缓存自动失效,无需在各处手工调用失效接口。
*
* @param [IN] includeStyles 是否把逐信号外观样式(颜色/不透明度/线宽/模式)计入指纹。
* @return 指纹值。
*/
std::uint64_t SceneContentHash() const;
std::uint64_t SceneContentHash(bool includeStyles = true) const;
/**
* @brief 场景内容中**不含逐信号外观样式**的指纹。
*
* 只包含网格结构、视图几何、网格幅值、信号集合与可见性、图例文案、选中项等
* "变化后必须整幅重建"的内容;逐信号的 `color / opacity / lineWidth / mode`
* 一律不参与。
*
* 用途:外观样式变化时(典型场景是拖动自定义颜色的滑块)波形几何完全不变,
* 只要该指纹一致,就可以只重画"颜色变了的那个网格"再写回缓存,
* 而不必整幅重绘全部网格(VERY_BIG_DATA 实测整幅 30ms+、单网格约 4ms)。
*
* @return 指纹值。
*/
std::uint64_t SceneGeometryHash() const;
/**
* @brief 静态场景缓存中"网格内信号外观样式"的快照条目。
*/
struct SceneStyleEntry
{
std::uint32_t color = 0; /**< 信号色(未叠加不透明度)。 */
float opacity = 1.0f; /**< 不透明度。 */
float lineWidth = 1.0f; /**< 线宽。 */
int mode = 0; /**< 绘制模式。 */
};
/**
* @brief 采集当前各网格内可见信号的外观样式快照。
* @param [OUT] out 采集结果(按网格分组)。
*/
void CollectSceneStyles(std::vector<std::vector<SceneStyleEntry>>& out) const;
/**
* @brief 判断当前相对缓存是否"只有外观样式发生了变化"。
*
* 成立条件:`SceneGeometryHash()` 与缓存一致(网格结构、视图几何、信号集合
* 与文字都没变),且至少有一个网格内信号的外观样式不同。
*
* @param [OUT] lanes 外观发生变化的网格下标(升序)。
* @return 可以走"局部重画"快路径返回 true。
*/
bool FindStyleOnlyLanes(std::vector<int>& lanes) const;
/**
* @brief 只重画指定网格并把结果写回静态场景缓存。
*
* 必须在 `BlitSceneCache()` 之后调用:贴图已经完成,这里把受影响网格的
* "底色 + 网格线 + 波形 + 标尺 + 图例"重画到画布上,再整块拷回缓存位图,
* 使缓存内容与当前样式一致(下一帧即可重新走"完全命中"快路径)。
*
* @param [IN,OUT] g 绘图上下文。
* @param [IN] lanes 需要重画的网格下标。
*/
void RepaintSceneCachePatch(gd::Graphics& g, const std::vector<int>& lanes);
/**
* @brief 当前应当绘入静态场景缓存的信号光晕(仅拖拽中的信号)。
@@ -1485,6 +1542,10 @@ namespace jdi
int m_sceneY = 0; /**< 缓存对应的客户区纵坐标(绘图区上边界)。 */
bool m_sceneValid = false; /**< 缓存内容是否有效。 */
SceneCacheKey m_sceneKey; /**< 缓存对应的场景参数。 */
/** 缓存捕获时"不含逐信号外观样式"的内容指纹(用于识别仅样式变化)。 */
std::uint64_t m_sceneGeometryHash = 0;
/** 缓存捕获时各网格内信号的外观样式快照(按网格分组,与 m_sceneStyles 同序)。 */
std::vector<std::vector<SceneStyleEntry>> m_sceneStyles;
/**
* @brief 查询坐标命中的图例条目。
+337 -103
View File
@@ -29,6 +29,8 @@ namespace jdi
const size_t kMinPolylinePoints = 512; /**< 折线最少顶点数(窗口很窄时仍保留细节)。 */
const float kEnvelopeMinPixelSpan = 0.5f; /**< 包络纵向跨度小于该像素值时合并为单个顶点。 */
const float kCursorHandleReserve = 9.0f; /**< 光标顶部三角把手占用的高度(数据点贴边时需避让)。 */
const float kMarkerEdgeInset = 7.0f; /**< 数据点越界贴边时距网格可见边界的间距。 */
const float kMarkerInsideMargin = 2.0f; /**< 数据点在视界内时允许贴到边界的最小余量。 */
const size_t kMaxScatterPoints = 2500; /**< 单条散点最大绘制点数。 */
const size_t kPolylineCacheLimit = 64; /**< 折线顶点缓存条目上限。 */
const float kMinScatterRadius = 1.6f; /**< 散点最小半径(逻辑像素)。 */
@@ -407,19 +409,6 @@ namespace jdi
return (argb & 0x00FFFFFFu) | (a << 24);
}
/**
* @brief 判断颜色是否偏暗(用于在信号色底板上选择可读的文字颜色)。
* @param [IN] color 0xAARRGGBB 颜色值。
* @return 相对亮度低于阈值返回 true。
*/
bool IsDarkColor(std::uint32_t color)
{
const double r = static_cast<double>((color >> 16) & 0xFFu);
const double g = static_cast<double>((color >> 8) & 0xFFu);
const double b = static_cast<double>(color & 0xFFu);
return (0.299 * r + 0.587 * g + 0.114 * b) < 140.0;
}
/**
* @brief 将数值限制到合法区间。
* @param [IN] value 输入值。
@@ -4234,12 +4223,9 @@ namespace jdi
return;
}
// 静态场景复用(不再局限于拖拽):只要视图几何、信号集合与显示样式都没变,
// "绘图区底色 + 全部网格 + 时间轴"就逐像素相同,直接贴图即可。
// 大文件下整幅重绘约 80ms,贴图约 1ms —— 鼠标悬停、拖拽选区这些
// "只改一小块叠加层"的交互因此不再重绘波形。
// 随鼠标变化的内容(悬停高亮、读数框、选区、光标、拖拽虚影、输入框)一律画在贴图之后。
if (SceneCacheMatches() && BlitSceneCache(g))
// 贴图之后、静态场景之外的全部内容(随鼠标变化或位于最上层)。
// 三个分支(完全命中 / 局部重画 / 整幅重绘)共用同一段,保证观感完全一致。
const auto paintOverlays = [this, &g]()
{
PaintHoverHighlight(g);
PaintLegendHighlight(g);
@@ -4248,6 +4234,7 @@ namespace jdi
PaintEdgeCursor(g, m_lanes);
PaintSignalInteraction(g);
PaintHint(g);
// 网格 Y 轴上下限的输入框绘制在最上层。
if (m_editTarget == PlotEditTarget::LANE_Y_MAX ||
m_editTarget == PlotEditTarget::LANE_Y_MIN)
{
@@ -4258,9 +4245,32 @@ namespace jdi
// 绘图区外框(比网格内部分界线更重,形成明确整体边界)。
gd::Pen borderPen(ToGdiColor(m_theme->axisLine), 1.6f);
g.DrawRectangle(&borderPen, m_plotRect.X, m_plotRect.Y, m_plotRect.Width, m_plotRect.Height);
};
// 静态场景复用(不再局限于拖拽):只要视图几何、信号集合与显示样式都没变,
// "绘图区底色 + 全部网格 + 时间轴"就逐像素相同,直接贴图即可。
// 大文件下整幅重绘约 80ms,贴图约 1ms —— 鼠标悬停、拖拽选区这些
// "只改一小块叠加层"的交互因此不再重绘波形。
// 随鼠标变化的内容(悬停高亮、读数框、选区、光标、拖拽虚影、输入框)一律画在贴图之后。
if (SceneCacheMatches() && BlitSceneCache(g))
{
paintOverlays();
return;
}
// 只有"外观样式"变化(典型场景:拖动自定义颜色滑块实时调色):网格结构、视图几何、
// 波形采样都没变,只是颜色/不透明度/线宽/模式不同 —— 此时只重画受影响的网格,
// 再写回缓存。(VERY_BIG_DATA 实测:整幅重绘 30ms+,单网格重画约 4ms。)
{
std::vector<int> patchLanes;
if (FindStyleOnlyLanes(patchLanes) && BlitSceneCache(g))
{
RepaintSceneCachePatch(g, patchLanes);
paintOverlays();
return;
}
}
const double viewMin = Tab().ViewMin();
const double viewMax = Tab().ViewMax();
const double span = viewMax - viewMin;
@@ -4288,25 +4298,7 @@ namespace jdi
// 因此是捕获静态场景缓存的最佳时点(必须在裁剪区已全部恢复之后执行)。
CaptureSceneCache(g);
PaintHoverHighlight(g);
PaintLegendHighlight(g);
PaintRubberBand(g);
PaintCursor(g, m_lanes);
PaintEdgeCursor(g, m_lanes);
PaintSignalInteraction(g);
PaintHint(g);
// 网格 Y 轴上下限的输入框绘制在最上层。
if (m_editTarget == PlotEditTarget::LANE_Y_MAX || m_editTarget == PlotEditTarget::LANE_Y_MIN)
{
PaintEditField(g);
}
PaintScrollBar(g);
// 绘图区外框(比网格内部分界线更重,形成明确整体边界)。
gd::Pen borderPen(ToGdiColor(m_theme->axisLine), 1.6f);
g.DrawRectangle(&borderPen, m_plotRect.X, m_plotRect.Y, m_plotRect.Width, m_plotRect.Height);
paintOverlays();
}
void PlotView::PaintSignalHalo(gd::Graphics& g, const ResolvedSignal& resolved,
@@ -4479,11 +4471,13 @@ namespace jdi
return SignalRef();
}
std::uint64_t PlotView::SceneContentHash() const
std::uint64_t PlotView::SceneContentHash(bool includeStyles) const
{
// FNV-1a:把“视图几何之外的一切影响绘图区像素的内容”折叠成一个指纹。
// 这样颜色/线宽/透明度/样式/重命名/信号增删等变化都会自动让缓存失效,
// 不需要在各处手工调用 InvalidateSceneCache(容易漏)。
// `includeStyles == false` 时跳过逐信号的外观样式(颜色/不透明度/线宽/模式),
// 得到的就是几何指纹:它一致说明“只有外观样式变了”。
std::uint64_t hash = 1469598103934665603ull;
const auto mix = [&hash](std::uint64_t value)
{
@@ -4547,10 +4541,16 @@ namespace jdi
{
continue;
}
mix(static_cast<std::uint64_t>(resolved.style->color));
mixFloat(resolved.style->opacity);
mixFloat(resolved.style->lineWidth);
mix(static_cast<std::uint64_t>(resolved.style->mode));
// 以下四项是“只影响外观”的样式:仅在 includeStyles 时计入指纹。
// (不参与时得到的就是 SceneGeometryHash:它一致说明只有外观变了,
// 于是可以只重画颜色变了的网格,而不必整幅重绘。)
if (includeStyles)
{
mix(static_cast<std::uint64_t>(resolved.style->color));
mixFloat(resolved.style->opacity);
mixFloat(resolved.style->lineWidth);
mix(static_cast<std::uint64_t>(resolved.style->mode));
}
// 图例文案参与绘制,重命名必须让缓存失效。
mixText(resolved.data->Title());
if (resolved.file != nullptr)
@@ -4562,6 +4562,190 @@ namespace jdi
return hash;
}
std::uint64_t PlotView::SceneGeometryHash() const
{
return SceneContentHash(false);
}
void PlotView::CollectSceneStyles(std::vector<std::vector<SceneStyleEntry>>& out) const
{
out.clear();
out.resize(m_lanes.size());
for (std::size_t laneIndex = 0; laneIndex < m_lanes.size(); ++laneIndex)
{
const std::vector<SignalRef>& signals = Tab().Lanes()[laneIndex].signals;
std::vector<SceneStyleEntry>& lane = out[laneIndex];
lane.reserve(signals.size());
for (const SignalRef& ref : signals)
{
const ResolvedSignal resolved = ResolveSignal(ref);
SceneStyleEntry entry;
if (resolved.style != nullptr)
{
entry.color = resolved.style->color;
entry.opacity = resolved.style->opacity;
entry.lineWidth = resolved.style->lineWidth;
entry.mode = static_cast<int>(resolved.style->mode);
}
lane.push_back(entry);
}
}
}
bool PlotView::FindStyleOnlyLanes(std::vector<int>& lanes) const
{
lanes.clear();
if (!m_sceneValid || m_sceneGeometryHash != SceneGeometryHash())
{
// 网格结构 / 视图几何 / 信号集合 / 图例文案有变化:必须整幅重绘。
return false;
}
std::vector<std::vector<SceneStyleEntry>> current;
CollectSceneStyles(current);
if (current.size() != m_sceneStyles.size())
{
return false;
}
for (std::size_t laneIndex = 0; laneIndex < current.size(); ++laneIndex)
{
const std::vector<SceneStyleEntry>& now = current[laneIndex];
const std::vector<SceneStyleEntry>& before = m_sceneStyles[laneIndex];
if (now.size() != before.size())
{
return false;
}
bool differs = false;
for (std::size_t index = 0; index < now.size() && !differs; ++index)
{
differs = (now[index].color != before[index].color) ||
(now[index].opacity != before[index].opacity) ||
(now[index].lineWidth != before[index].lineWidth) ||
(now[index].mode != before[index].mode);
}
if (differs)
{
lanes.push_back(static_cast<int>(laneIndex));
}
}
return !lanes.empty();
}
void PlotView::RepaintSceneCachePatch(gd::Graphics& g, const std::vector<int>& lanes)
{
if (lanes.empty() || m_sceneDc == nullptr)
{
return;
}
// 重画范围 = 受影响网格与绘图区的交集(并且不超出缓存区域)。
float top = 1.0e9f;
float bottom = -1.0e9f;
for (const int laneIndex : lanes)
{
if (laneIndex < 0 || laneIndex >= static_cast<int>(m_lanes.size()))
{
continue;
}
const gd::RectF& rect = m_lanes[static_cast<std::size_t>(laneIndex)].rect;
top = std::min(top, rect.Y);
bottom = std::max(bottom, rect.Y + rect.Height);
}
if (bottom <= top)
{
return;
}
top = std::max(top, m_plotRect.Y);
bottom = std::min(bottom, m_plotRect.Y + m_plotRect.Height);
if (bottom <= top)
{
return;
}
// ⚠️ 补丁矩形必须**向内**对齐到整数像素(ceil 上边界、floor 下边界):
// 网格矩形是浮点数,而 GDI `FillRect`/`BitBlt` 会按 floor/ceil 向外取整,
// "向外"会多擦掉/多写回一整行,那一行属于相邻网格(例如它的分界线),
// 于是补丁边界上会留下一像素接缝。向内对齐后,边界外的行一律由缓存提供
// (缓存内容与整幅重绘逐像素一致,已由对照帧验证)。
const int x0 = std::max(static_cast<int>(std::ceil(m_area.X)), static_cast<int>(std::ceil(m_area.X)));
const int y0 = std::max(static_cast<int>(std::ceil(top)), static_cast<int>(std::ceil(m_area.Y)));
const int x1 = std::min(static_cast<int>(std::floor(m_area.X + m_area.Width)),
static_cast<int>(std::floor(m_area.X + m_area.Width)));
const int y1 = std::min(static_cast<int>(std::floor(bottom)),
static_cast<int>(std::floor(m_area.Y + m_area.Height)));
if (x1 <= x0 || y1 <= y0)
{
return;
}
const gd::RectF patch(static_cast<float>(x0), static_cast<float>(y0),
static_cast<float>(x1 - x0), static_cast<float>(y1 - y0));
// 补丁区域必须被**完整**重画:凡是与它相交的网格都要重画一遍
// (补丁已向内对齐,正常只会命中受影响的那几个网格)。
std::vector<int> repaint;
for (std::size_t laneIndex = 0; laneIndex < m_lanes.size(); ++laneIndex)
{
const gd::RectF& rect = m_lanes[laneIndex].rect;
if (rect.Y < static_cast<float>(y1) && rect.Y + rect.Height > static_cast<float>(y0))
{
repaint.push_back(static_cast<int>(laneIndex));
}
}
if (repaint.empty())
{
return;
}
// 与整幅重绘保持完全相同的绘制顺序:底色 → 逐网格内容(PaintLane 会与当前裁剪区求交)。
{
const int saveState = g.Save();
g.SetClip(patch, gd::CombineModeIntersect);
// ⚠️ 底色填充必须**按补丁矩形本身**填充,不能用 m_area / m_plotRect:
// `UiFillRect` 对大面积不透明矩形会走 GDI `FillRect` 快路径,而 GDI 绘制
// **不受 GDI+ 裁剪区约束**,会把已经贴好的缓存内容整片覆盖掉。
UiFillRect(g, patch, m_theme->plotBg);
const float cardTop = std::max(patch.Y, m_plotRect.Y);
const float cardBottom = std::min(patch.Y + patch.Height, m_plotRect.Y + m_plotRect.Height);
if (cardBottom > cardTop)
{
UiFillRect(g, gd::RectF(patch.X, cardTop, patch.Width, cardBottom - cardTop),
m_theme->cardBg);
}
const double viewMin = Tab().ViewMin();
const double viewMax = Tab().ViewMax();
const double span = viewMax - viewMin;
const double timeStep = NiceStep((span > 0.0) ? span : 1.0, kTimeTickTarget);
std::vector<double> timeTicks;
BuildTicks(viewMin, viewMax, timeStep, timeTicks);
for (const int laneIndex : repaint)
{
if (laneIndex >= 0 && laneIndex < static_cast<int>(m_lanes.size()))
{
PaintLane(g, laneIndex, m_lanes[static_cast<std::size_t>(laneIndex)], timeTicks);
}
}
g.Restore(saveState);
}
// 把重画结果写回缓存位图,使缓存与当前样式一致(下一帧即回到"完全命中"快路径)。
{
HDC target = g.GetHDC();
if (target != nullptr)
{
BitBlt(m_sceneDc, x0 - m_sceneX, y0 - m_sceneY, x1 - x0, y1 - y0, target, x0, y0, SRCCOPY);
g.ReleaseHDC(target);
}
}
m_sceneKey = MakeSceneCacheKey();
m_sceneGeometryHash = SceneGeometryHash();
CollectSceneStyles(m_sceneStyles);
}
bool PlotView::EnsureSceneCache(int width, int height)
{
if (width <= 0 || height <= 0)
@@ -4638,6 +4822,8 @@ namespace jdi
}
m_sceneKey = MakeSceneCacheKey();
m_sceneGeometryHash = SceneGeometryHash();
CollectSceneStyles(m_sceneStyles);
m_sceneValid = true;
return true;
}
@@ -4663,6 +4849,8 @@ namespace jdi
void PlotView::InvalidateSceneCache()
{
m_sceneValid = false;
m_sceneStyles.clear();
m_sceneGeometryHash = 0;
}
void PlotView::ReleaseSceneCache()
@@ -4793,7 +4981,11 @@ namespace jdi
}
const int saveState = g.Save();
g.SetClip(gd::RectF(m_area.X, clipTop, m_area.Width, clipBottom - clipTop));
// 与当前裁剪区求交(而不是替换):局部重画(RepaintSceneCachePatch)时
// 外层已经按“需要重画的网格范围”设过裁剪区,这里必须保留,否则会重画到
// 其他网格上。整幅重绘时外层没有裁剪区,等同于原来的行为。
g.SetClip(gd::RectF(m_area.X, clipTop, m_area.Width, clipBottom - clipTop),
gd::CombineModeIntersect);
// 幅值网格线(先画线,标尺稍后作为覆盖层绘制,避免被波形遮挡)。
@@ -5383,12 +5575,12 @@ namespace jdi
}
void PlotView::DrawLabelOutline(gd::Graphics& g, const gd::RectF& rect, float radius, bool textDark)
void PlotView::DrawLabelOutline(gd::Graphics& g, const gd::RectF& rect, float radius,
std::uint32_t outlineColor)
{
// 用与文字同色的半透明细描边:深色文字 → 浅色描边,浅色文字 → 深色描边。
// 描边色由 `UiContrastInk` 与文字色一起算出(恒与文字色反相、且随信号配色线性变化)。
// 这样胶囊压在同类色背景或信号曲线上时仍有清晰的边界。
const std::uint32_t outline = textDark ? 0x99FDF6E3u : 0x99073642u;
UiDrawRoundRect(g, rect, radius, outline, 1.0f);
UiDrawRoundRect(g, rect, radius, outlineColor, 1.0f);
}
gd::RectF PlotView::CursorSpanRect(bool& insideAxis) const
@@ -5549,16 +5741,18 @@ namespace jdi
const float rawY = ValueToY(value, lane);
// 上下越界的数据点不再被丢弃:与左右越界一致,贴边绘制指示方向的三角。
// 上边界额外预留光标顶部把手的高度,避免数据点三角压住光标把手。
const DataMarker marker =
// 非 const:上下越界时下面还要把方向三角与取值标注纵向居中对齐。
DataMarker marker =
MakeDataMarker(rawX, rawY, pinX, clipTop, clipBottom, kCursorHandleReserve * m_scale);
const std::uint32_t signalColor = StyleColor(*resolved.style);
// 数据点:视界内为圆点,任一方向越界时改为贴边三角。
// **先算好取值标注、再画数据点**:上下越界时的方向三角需要与标注
// 纵向居中对齐,而标注的位置要经过避让处理才能最终确定。
const float radius = 3.4f * m_scale;
PaintDataMarker(g, marker, radius, signalColor, m_theme->cardBg);
// 取值标注:底色用信号色、文字用反色,并加一层细描边提升对比度。
// 取值标注:底色用信号色,文字/描边用随信号配色线性相关的高对比度配色。
std::wstring text = FormatValue(value);
if (showDelta)
{
@@ -5593,11 +5787,18 @@ namespace jdi
// 依次向下(再向上)避让已占用的标注,保证不会互相覆盖。
PlaceCursorLabel(labelRect, labelTop, textHeight, clipTop, clipBottom);
// 上下越界的方向三角与最终落位的取值标注纵向居中对齐
// (用户明确要求“超过上下视界时三角需要与显示的数值居中对齐”)。
if (marker.offUp || marker.offDown)
{
marker.y = labelRect.Y + labelRect.Height * 0.5f;
}
PaintDataMarker(g, marker, radius, signalColor, m_theme->cardBg);
const UiInk ink = UiContrastInk(signalColor);
UiFillRoundRect(g, labelRect, 3.0f * m_scale, WithAlpha(signalColor, 0xF0));
const bool dark = IsDarkColor(signalColor);
DrawLabelOutline(g, labelRect, 3.0f * m_scale, dark);
UiText(g, text, m_fonts->monoSmall, labelRect,
dark ? 0xFFFDF6E3u : 0xFF073642u, TextAlign::CENTER);
DrawLabelOutline(g, labelRect, 3.0f * m_scale, ink.outline);
UiText(g, text, m_fonts->monoSmall, labelRect, ink.text, TextAlign::CENTER);
// 「边沿计数(中心轴)」/「均值滤波」开启时,纵向光标还要额外标注**派生曲线**
// 在该时刻的取值(它不是信号,因此单独标注,不并入上面的取值文案)。
@@ -5617,12 +5818,11 @@ namespace jdi
}
const float rawCenterY = ValueToY(derivedValue, lane);
const DataMarker centerMarker = MakeDataMarker(
DataMarker centerMarker = MakeDataMarker(
rawX, rawCenterY, pinX, clipTop, clipBottom, kCursorHandleReserve * m_scale);
// 派生数据点的配色与原始信号一致(便于一一对应);
// 但**始终按线段模式**绘制(不跟随原信号的散点模式)。
const std::uint32_t centerColor = StyleColor(*resolved.style);
PaintDataMarker(g, centerMarker, radius, centerColor, m_theme->cardBg);
// 取值文本:数值在前,尾缀「(中心轴)/(均值滤波)」;双光标的右侧光标
// 还要附上增量,形如「(+0.0008 中心轴)」(参考常规信号的增量显示)。
@@ -5665,12 +5865,18 @@ namespace jdi
std::min(centerTop, clipBottom - textHeight - 1.0f * m_scale));
gd::RectF centerRect(centerLeft, centerTop, centerTextWidth, textHeight);
PlaceCursorLabel(centerRect, centerTop, textHeight, clipTop, clipBottom);
// 与原始信号同一规则:上下越界的方向三角与取值标注纵向居中对齐。
if (centerMarker.offUp || centerMarker.offDown)
{
centerMarker.y = centerRect.Y + centerRect.Height * 0.5f;
}
PaintDataMarker(g, centerMarker, radius, centerColor, m_theme->cardBg);
// 颜色样式与原始信号一致(同底色/同描边/同文字配色)。
const bool centerDark = IsDarkColor(centerColor);
const UiInk centerInk = UiContrastInk(centerColor);
UiFillRoundRect(g, centerRect, 3.0f * m_scale, WithAlpha(centerColor, 0xE0));
DrawLabelOutline(g, centerRect, 3.0f * m_scale, centerDark);
UiText(g, centerText, m_fonts->monoSmall, centerRect,
centerDark ? 0xFFFDF6E3u : 0xFF073642u, TextAlign::CENTER);
DrawLabelOutline(g, centerRect, 3.0f * m_scale, centerInk.outline);
UiText(g, centerText, m_fonts->monoSmall, centerRect, centerInk.text,
TextAlign::CENTER);
};
if (m_tab->Lanes()[laneIndex].edgeCenter)
@@ -5705,11 +5911,10 @@ namespace jdi
const std::uint32_t color = CursorColor();
UiFillRoundRect(g, labelRect, 3.0f * m_scale,
highlighted ? color : WithAlpha(color, 0xE0));
const bool dark = IsDarkColor(color);
DrawLabelOutline(g, labelRect, 3.0f * m_scale, dark);
const UiInk ink = UiContrastInk(color);
DrawLabelOutline(g, labelRect, 3.0f * m_scale, ink.outline);
UiText(g, FormatCursorTime(which == 2 ? Tab().CursorTimeAlt() : Tab().CursorTime()),
m_fonts->monoSmall, labelRect,
dark ? 0xFFFDF6E3u : 0xFF073642u, TextAlign::CENTER);
m_fonts->monoSmall, labelRect, ink.text, TextAlign::CENTER);
}
// 双光标差值:显示在两条时间读数中间(时间轴上方;过近时落到主时间轴上)。
@@ -5724,10 +5929,10 @@ namespace jdi
const std::uint32_t color = CursorColor();
UiFillRoundRect(g, spanRect, 3.0f * m_scale,
m_spanHover ? color : WithAlpha(color, 0xE0));
const bool dark = IsDarkColor(color);
DrawLabelOutline(g, spanRect, 3.0f * m_scale, dark);
UiText(g, FormatSpanText(CursorSpan()), m_fonts->monoSmall, spanRect,
dark ? 0xFFFDF6E3u : 0xFF073642u, TextAlign::CENTER);
const UiInk ink = UiContrastInk(color);
DrawLabelOutline(g, spanRect, 3.0f * m_scale, ink.outline);
UiText(g, FormatSpanText(CursorSpan()), m_fonts->monoSmall, spanRect, ink.text,
TextAlign::CENTER);
}
}
else
@@ -5868,15 +6073,33 @@ namespace jdi
float clipBottom, float topReserve) const
{
DataMarker marker;
// 上下越界时"钉"在网格可见边缘(留出三角形的高度,保证整体可见);
// 另外预留 topReserve(时间光标的顶部三角把手),避免数据点三角压住把手。
const float margin = 7.0f * m_scale;
// 形状**只**由“数据点本身是否越出绘制边界”决定:
// 旧实现拿“夹取之后”的位置反推越界方向,于是贴近边界、其实仍在视界内的数据点
// 也会被误画成三角(用户报告的“数值显示超出边界就改变数据点形状”)。
const float left = m_plotRect.X;
const float right = m_plotRect.X + m_plotRect.Width;
marker.offLeft = (rawX < left);
marker.offRight = (rawX > right);
marker.offUp = (rawY < clipTop);
marker.offDown = (rawY > clipBottom);
marker.x = pinX;
marker.y = std::max(clipTop + margin + topReserve, std::min(rawY, clipBottom - margin));
marker.offLeft = (rawX < marker.x - 0.5f);
marker.offRight = (rawX > marker.x + 0.5f);
marker.offUp = (rawY < marker.y - 0.5f);
marker.offDown = (rawY > marker.y + 0.5f);
if (marker.offUp)
{
// 上越界:为方向三角与顶部把手预留高度,避免压住把手。
marker.y = clipTop + kMarkerEdgeInset * m_scale + topReserve;
}
else if (marker.offDown)
{
marker.y = clipBottom - kMarkerEdgeInset * m_scale;
}
else
{
// 视界内:保留数据点的真实纵坐标(越界判定已在上面完成,这里夹取只为了让
// 圆点整体可见,不会影响形状)。
marker.y = std::max(clipTop + kMarkerInsideMargin * m_scale,
std::min(rawY, clipBottom - kMarkerInsideMargin * m_scale));
}
return marker;
}
@@ -5885,44 +6108,56 @@ namespace jdi
{
gd::SolidBrush ring(ToGdiColor(ringColor));
gd::SolidBrush fill(ToGdiColor(fillColor));
const float arm = 5.0f * m_scale;
const float inset = 1.4f * m_scale;
const float arm = 5.0f * m_scale; /**< 三角“半宽/半高”(垂直于朝向的半边长)。 */
const float depth = arm * 1.4f; /**< 三角沿朝向的深度(与光标把手同比例)。 */
// 左右越界:三角底边在外侧、尖端朝向网格内部。
// 越界三角:**尖端朝外**(指向数据点所在的方向,即视界之外),底边在网格内侧;
// 三角外接矩形在横/纵两个方向都与锚点**居中**(上下越界时因此与取值标注纵向居中)。
// 旧实现方向是反的(上越界画下三角、左越界画右三角),而且内三角按“平移”而非
// “向质心收缩”构造,填充色会从三角尖端戳出来。
const auto paintTriangle = [&](const gd::PointF* outer)
{
g.FillPolygon(&ring, outer, 3);
const float centerX = (outer[0].X + outer[1].X + outer[2].X) / 3.0f;
const float centerY = (outer[0].Y + outer[1].Y + outer[2].Y) / 3.0f;
// 按质心等比收缩得到内三角:几何上恒在外三角内部,描边宽度也就均匀了。
const float shrink = 0.58f;
gd::PointF inner[3];
for (int index = 0; index < 3; ++index)
{
inner[index] = gd::PointF(centerX + (outer[index].X - centerX) * shrink,
centerY + (outer[index].Y - centerY) * shrink);
}
g.FillPolygon(&fill, inner, 3);
};
// 左右越界:三角指向左/右(视界之外)。
if (marker.offLeft || marker.offRight)
{
const bool offLeft = marker.offLeft;
const float baseX = offLeft ? (marker.x - arm) : (marker.x + arm);
const float inward = offLeft ? inset : -inset;
const float tipX = offLeft ? (marker.x - depth) : (marker.x + depth);
const float baseX = marker.x;
gd::PointF outer[3];
outer[0] = gd::PointF(baseX, marker.y - arm);
outer[1] = gd::PointF(baseX, marker.y + arm);
outer[2] = gd::PointF(marker.x, marker.y);
g.FillPolygon(&ring, outer, 3);
gd::PointF inner[3];
inner[0] = gd::PointF(baseX + inward, marker.y - arm * 0.72f);
inner[1] = gd::PointF(baseX + inward, marker.y + arm * 0.72f);
inner[2] = gd::PointF(marker.x + inward, marker.y);
g.FillPolygon(&fill, inner, 3);
outer[2] = gd::PointF(tipX, marker.y);
paintTriangle(outer);
return;
}
// 上下越界:三角底边在上/下方、尖端朝向网格内部(与左右越界同一套观感)。
// 上下越界:三角指向上/下(视界之外),外接矩形以 marker.y 为纵向中心
// → 与取值标注的纵向中心对齐。
if (marker.offUp || marker.offDown)
{
const bool offUp = marker.offUp;
const float baseY = offUp ? (marker.y - arm) : (marker.y + arm);
const float inward = offUp ? inset : -inset;
const float half = depth * 0.5f;
const float tipY = offUp ? (marker.y - half) : (marker.y + half);
const float baseY = offUp ? (marker.y + half) : (marker.y - half);
gd::PointF outer[3];
outer[0] = gd::PointF(marker.x - arm, baseY);
outer[1] = gd::PointF(marker.x + arm, baseY);
outer[2] = gd::PointF(marker.x, marker.y);
g.FillPolygon(&ring, outer, 3);
gd::PointF inner[3];
inner[0] = gd::PointF(marker.x - arm * 0.72f, baseY + inward);
inner[1] = gd::PointF(marker.x + arm * 0.72f, baseY + inward);
inner[2] = gd::PointF(marker.x, marker.y + inward);
g.FillPolygon(&fill, inner, 3);
outer[2] = gd::PointF(marker.x, tipY);
paintTriangle(outer);
return;
}
@@ -6115,7 +6350,7 @@ namespace jdi
std::vector<Lane>& tabLanes = m_tab->Lanes();
const std::size_t count = std::min(lanes.size(), tabLanes.size());
const std::uint32_t color = CursorColor();
const bool dark = IsDarkColor(color);
const UiInk ink = UiContrastInk(color);
// ---- 横向光标(线与两端把手 + 左侧幅值读数)----
for (std::size_t index = 0; index < count; ++index)
@@ -6172,9 +6407,8 @@ namespace jdi
clipBottom - textHeight - 1.0f * m_scale));
const gd::RectF labelRect(labelLeft, labelTop, textWidth, textHeight);
UiFillRoundRect(g, labelRect, 3.0f * m_scale, WithAlpha(color, 0xE0));
DrawLabelOutline(g, labelRect, 3.0f * m_scale, dark);
UiText(g, text, m_fonts->monoSmall, labelRect, dark ? 0xFFFDF6E3u : 0xFF073642u,
TextAlign::CENTER);
DrawLabelOutline(g, labelRect, 3.0f * m_scale, ink.outline);
UiText(g, text, m_fonts->monoSmall, labelRect, ink.text, TextAlign::CENTER);
}
// ---- 统计浮动面板(最后绘制,覆盖在波形之上)----