样式区「调整」行改为 系数/镜像/绝对值:系数二级面板(还原图标 + 整行输入框)、镜像二级面板(水平/垂直复选框);Y 轴自适应按变换后曲线
- 「调整」行三个按钮改为「系数」「镜像」「绝对值」(顺序即结算顺序:系数 → 绝对值 → 镜像)。 「系数」「镜像」点击后在按钮下沿展开二级面板(按下只登记、抬起命中同一按钮才展开, 与自定义颜色入口一致),「绝对值」仍是直接切换的按钮。 打开面板时主动清除样式区悬停态 —— 面板会接管鼠标事件,否则该按钮会一直保持"鼠标按住"般的高亮。 - 数据层:`SignalStyle::inverted` 拆成 `mirrorVertical`(Y=0 上下翻转)与 `mirrorHorizontal`(水平镜像 = 数据反序);新增 `Mirrored()`、 `SourceIndexFor`(水平镜像的遍历下标 → 源下标映射)、`TransformValueRange`(区间映射)。 `ApplyDrawTransform` 顺序固定为 系数 → 绝对值 → 垂直镜像;水平镜像在采样遍历层处理。 - 绘制链路:`BuildPolyline`(逐点描边与像素列包络两条路径、跨列边界插值、粗粒度分组层)、 派生预览曲线、光标取值读数(含 Δt)全部按同一变换取值;水平镜像时按源下标区间反序读取, 包络层仍复用粗粒度分组;折线缓存键、`SceneContentHash`、样式快照都带上两个镜像位。 - 系数二级面板:标题「系数调整」,标题栏最右端是一个可点击的「还原」自绘图标按钮 (悬停有高亮反馈,点击后系数重置为 1,面板保持打开);下面是一整行输入框 (不再显示「绘制系数」说明文字),数值至少显示两位小数、精度更高时按实际精度显示 (1 → 1.00、2.5 → 2.50、1.2345678 原样)。 - 镜像二级面板:两行带复选框的切换项「水平镜像」「垂直镜像」,点击后**面板保持打开** (`MenuItem::keepOpen`),勾选状态就地回写;任一项勾选时「镜像」按钮显示为激活态。 - Y 轴自适应修复:整段自适应(`TabConfig::ComputeLaneRange`)按区间映射(`TransformValueRange`) 计算,不再直接用原始 min/max;可见区间自适应(`AutoRangeLaneToView`)逐点套用绘制变换 (含水平镜像的源下标映射)。用户报告的"调整系数/镜像后自适应区域错误、仍按原始数据"由此修复。 - `PopupMenu`:新增 `MenuItemKind::HEADER_ACTION`(标题 + 右侧可点击图标,图标有悬停高亮)、 `MenuItem::keepOpen`(切换项点击后不关闭菜单)、`HeaderIconRect()`, 以及无说明文字的整行输入框(占满内容区)。 验证(离屏 harness,SW_HIDE、不动鼠标键盘屏幕): - [AL] 重写/扩充:变换顺序与区间映射(含水平镜像的源下标反序)、调整行三按钮的顺序/等宽/标签不裁切、 「系数」按住不弹出 / 移开取消 / 单击展开(标题 HEADER_ACTION、无标题输入框占满整行、初始 1.00、 两位小数与更高精度回显、提交后面板保持打开、折线缓存键携带系数)、 「还原」图标位置/悬停像素变化/点击后系数回到 1.00 且面板保持打开、 「镜像」面板两行复选框(初始未勾选、keepOpen、勾选后按钮激活、取消其一保留另一)、 打开面板后按钮不再停留高亮、系数/镜像按钮的激活填充与其它启用态切换按钮逐像素同色; `BuildPolyline` 顶点范围与变换后数据范围逐项对齐(默认/系数 2/绝对值/垂直镜像/三者叠加), 水平镜像用窄视窗逐顶点核对"取的是对面端点的采样"(8/8)+ 读数跟随; 以及 Y 轴自适应:整段/可见区间自适应在系数、绝对值、垂直镜像下均跟随变换后的曲线。 - 合计 594/594 通过;make clean && make all 零警告。
This commit is contained in:
1 parent
2f243dd557
commit
2b02518f85
8 files changed
+608
-110
No files matched your search
@@ -1583,6 +1583,81 @@ namespace jdi
|
||||
return true;
|
||||
}
|
||||
|
||||
bool SampleDisplayedSignalAt(const DataFile& file, const SignalData& signal,
|
||||
const SignalStyle& style, double time, bool interpolate, double& value)
|
||||
{
|
||||
// 未启用水平镜像时与 SampleSignalAt 完全等价(避免两套实现产生细微差异)。
|
||||
if (!style.mirrorHorizontal)
|
||||
{
|
||||
return SampleSignalAt(file, signal, time, interpolate, value);
|
||||
}
|
||||
|
||||
value = kMissing;
|
||||
if (file.time.empty() || signal.values.empty())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (time < file.minTime || time > file.maxTime)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
const std::vector<double>& values = signal.values;
|
||||
const std::size_t count = std::min(file.time.size(), values.size());
|
||||
if (count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// 画面上第 i 个位置用源下标 srcIndex(i) 的值(水平镜像 = 数据反序)。
|
||||
const auto sample = [&](std::size_t index) -> double
|
||||
{
|
||||
return values[SourceIndexFor(style, count, index)];
|
||||
};
|
||||
|
||||
if (!file.timeMonotonic)
|
||||
{
|
||||
const int nearest = FindNearestSample(file, time);
|
||||
if (nearest < 0 || static_cast<std::size_t>(nearest) >= count)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
value = sample(static_cast<std::size_t>(nearest));
|
||||
return std::isfinite(value);
|
||||
}
|
||||
|
||||
const std::vector<double>::const_iterator lower =
|
||||
std::lower_bound(file.time.begin(), file.time.end(), time);
|
||||
const std::size_t rightIndex = static_cast<std::size_t>(lower - file.time.begin());
|
||||
if (rightIndex == 0)
|
||||
{
|
||||
value = sample(0);
|
||||
return std::isfinite(value);
|
||||
}
|
||||
if (rightIndex >= count)
|
||||
{
|
||||
value = sample(count - 1);
|
||||
return std::isfinite(value);
|
||||
}
|
||||
|
||||
const std::size_t leftIndex = rightIndex - 1;
|
||||
const double t0 = file.time[leftIndex];
|
||||
const double t1 = file.time[rightIndex];
|
||||
const double v0 = sample(leftIndex);
|
||||
const double v1 = sample(rightIndex);
|
||||
|
||||
if (!interpolate || !std::isfinite(v0) || !std::isfinite(v1) || !(t1 > t0))
|
||||
{
|
||||
const double leftDistance = std::fabs(time - t0);
|
||||
const double rightDistance = std::fabs(t1 - time);
|
||||
value = (leftDistance <= rightDistance) ? v0 : v1;
|
||||
return std::isfinite(value);
|
||||
}
|
||||
|
||||
const double ratio = (time - t0) / (t1 - t0);
|
||||
value = v0 + (v1 - v0) * ratio;
|
||||
return true;
|
||||
}
|
||||
|
||||
// ============================ TabConfig ============================
|
||||
|
||||
TabConfig::TabConfig()
|
||||
@@ -2234,8 +2309,17 @@ namespace jdi
|
||||
{
|
||||
continue;
|
||||
}
|
||||
low = std::min(low, signal->minValue);
|
||||
high = std::max(high, signal->maxValue);
|
||||
// ⚠️ 自适应必须基于**画出来的曲线**:先按绘制变换(系数 → 绝对值 → 镜像)把该信号的
|
||||
// 原始值域映射成显示值域,否则调整系数/镜像后自适应范围仍按原始数据算(范围错误)。
|
||||
const SignalStyle* style = dataSet.Style(ref);
|
||||
double signalLow = signal->minValue;
|
||||
double signalHigh = signal->maxValue;
|
||||
if (style != nullptr)
|
||||
{
|
||||
TransformValueRange(*style, signalLow, signalHigh);
|
||||
}
|
||||
low = std::min(low, signalLow);
|
||||
high = std::max(high, signalHigh);
|
||||
}
|
||||
|
||||
if (!std::isfinite(low) || !std::isfinite(high))
|
||||
|
||||
+158
-9
@@ -27,6 +27,54 @@ namespace jdi
|
||||
const float kEditFieldPadV = 5.0f; /**< 输入框上下内缩。 */
|
||||
const float kSubArrowSize = 4.5f; /**< 子菜单指示箭头尺寸。 */
|
||||
const float kSubOverlap = 2.0f; /**< 子菜单与主菜单重叠的宽度(避免中间露出缝隙)。 */
|
||||
const float kHeaderIconSize = 16.0f; /**< `HEADER_ACTION` 右侧图标按钮的边长。 */
|
||||
const float kHeaderIconGap = 6.0f; /**< 标题文字与图标按钮之间的最小间隔。 */
|
||||
const float kRowFieldWidth = 132.0f; /**< 整行输入框(无说明文字)的默认宽度。 */
|
||||
|
||||
/**
|
||||
* @brief 绘制「还原/重置」图标(逆时针回环箭头)。
|
||||
*
|
||||
* 用一段圆弧 + 一个三角箭头拼成,与界面里其它自绘图标(加号、叉号)保持同一笔画风格。
|
||||
* 不依赖任何字体或资源,因此在任何 DPI 下都清晰。
|
||||
*
|
||||
* @param [IN,OUT] g 绘图上下文。
|
||||
* @param [IN] rect 图标外接矩形(正方形)。
|
||||
* @param [IN] color 笔画颜色。
|
||||
* @param [IN] scale DPI 缩放比例。
|
||||
*/
|
||||
void PaintResetIcon(gd::Graphics& g, const gd::RectF& rect, std::uint32_t color, float scale)
|
||||
{
|
||||
const float inset = 2.5f * scale;
|
||||
gd::RectF box(rect.X + inset, rect.Y + inset, rect.Width - inset * 2.0f,
|
||||
rect.Height - inset * 2.0f);
|
||||
if (box.Width <= 1.0f || box.Height <= 1.0f)
|
||||
{
|
||||
return;
|
||||
}
|
||||
gd::Pen pen(gd::Color((color >> 24) & 0xFFu, (color >> 16) & 0xFFu, (color >> 8) & 0xFFu,
|
||||
color & 0xFFu),
|
||||
std::max(1.0f, 1.5f * scale));
|
||||
pen.SetStartCap(gd::LineCapRound);
|
||||
pen.SetEndCap(gd::LineCapRound);
|
||||
// 缺口留在右上角(从 300° 到 20° 处断开),与常见的"重置/刷新"图标一致。
|
||||
g.DrawArc(&pen, box.X, box.Y, box.Width, box.Height, 300.0f, 300.0f);
|
||||
|
||||
// 箭头:位于缺口末端(约 300° 方向),指向前进方向。
|
||||
const float cx = box.X + box.Width * 0.5f;
|
||||
const float cy = box.Y + box.Height * 0.5f;
|
||||
const float radius = box.Width * 0.5f;
|
||||
const float angle = 300.0f * 3.14159265f / 180.0f;
|
||||
const float tipX = cx + std::cos(angle) * radius;
|
||||
const float tipY = cy + std::sin(angle) * radius;
|
||||
const float size = std::max(2.5f, 3.2f * scale);
|
||||
gd::PointF arrow[3];
|
||||
arrow[0] = gd::PointF(tipX, tipY);
|
||||
arrow[1] = gd::PointF(tipX - size, tipY - size * 0.25f);
|
||||
arrow[2] = gd::PointF(tipX - size * 0.25f, tipY - size);
|
||||
gd::SolidBrush brush(gd::Color((color >> 24) & 0xFFu, (color >> 16) & 0xFFu,
|
||||
(color >> 8) & 0xFFu, color & 0xFFu));
|
||||
g.FillPolygon(&brush, arrow, 3);
|
||||
}
|
||||
}
|
||||
|
||||
PopupMenu::PopupMenu() = default;
|
||||
@@ -49,6 +97,7 @@ namespace jdi
|
||||
case MenuItemKind::SEPARATOR:
|
||||
return kSeparatorHeight * m_scale;
|
||||
case MenuItemKind::HEADER:
|
||||
case MenuItemKind::HEADER_ACTION:
|
||||
return kHeaderHeight * m_scale;
|
||||
case MenuItemKind::SWATCHES:
|
||||
return kSwatchRowHeight * m_scale;
|
||||
@@ -88,11 +137,14 @@ namespace jdi
|
||||
static_cast<float>(std::max(0, count - 1)) * kSwatchGap * m_scale;
|
||||
contentWidth = std::max(contentWidth, width);
|
||||
}
|
||||
else if (item.kind == MenuItemKind::HEADER)
|
||||
else if (item.kind == MenuItemKind::HEADER || item.kind == MenuItemKind::HEADER_ACTION)
|
||||
{
|
||||
// 标题与普通项共用同一文字列与右侧标记列,保证整个菜单的左右边距一致。
|
||||
const float iconExtra = (item.kind == MenuItemKind::HEADER_ACTION)
|
||||
? (kHeaderIconSize + kHeaderIconGap) * m_scale
|
||||
: 0.0f;
|
||||
const float width = (kPadH + kItemPad) * 2.0f * m_scale + kMarkerColumn * m_scale +
|
||||
kMarkerGap * m_scale +
|
||||
kMarkerGap * m_scale + iconExtra +
|
||||
UiMeasureTextWidth(m_fonts->uiSmallBold, item.label);
|
||||
contentWidth = std::max(contentWidth, width);
|
||||
}
|
||||
@@ -163,8 +215,10 @@ namespace jdi
|
||||
|
||||
float PopupMenu::NaturalEditFieldWidth(const MenuItem& item) const
|
||||
{
|
||||
return std::max(kEditFieldWidth * m_scale,
|
||||
UiMeasureTextWidth(m_fonts->uiSmall, item.value) + 20.0f * m_scale);
|
||||
const float natural = std::max(kEditFieldWidth * m_scale,
|
||||
UiMeasureTextWidth(m_fonts->uiSmall, item.value) + 20.0f * m_scale);
|
||||
// 无说明文字 = 整行输入框(如「系数调整」面板):给一个更宽的默认值,视觉上独占整行。
|
||||
return item.label.empty() ? std::max(natural, kRowFieldWidth * m_scale) : natural;
|
||||
}
|
||||
|
||||
void PopupMenu::CloseSubmenu()
|
||||
@@ -306,23 +360,49 @@ namespace jdi
|
||||
|
||||
// 输入框靠右对齐到条目内容区右边界;宽度取自然宽度,但必须给说明文字留出自然宽度,
|
||||
// 避免菜单被最大宽度夹取后输入框把左侧标签挤成省略号。
|
||||
// ⚠️ 说明文字为空(例如「系数调整」面板的整行输入框)时不设 3/4 上限,
|
||||
// 让输入框独占整行。
|
||||
const gd::RectF& rect = m_itemRects[static_cast<std::size_t>(index)];
|
||||
const float contentWidth = std::max(40.0f * m_scale, rect.Width - 2.0f * kItemPad * m_scale);
|
||||
const float labelWidth = UiMeasureTextWidth(m_fonts->uiSmall, item.label) + kEditFieldGap * m_scale;
|
||||
float width = NaturalEditFieldWidth(item);
|
||||
width = std::min(width, std::max(40.0f * m_scale, contentWidth - labelWidth));
|
||||
width = std::min(width, contentWidth * 0.75f);
|
||||
if (item.label.empty())
|
||||
{
|
||||
// 整行输入框(无说明文字):占满内容区,视觉上就是一个独立的一行输入框。
|
||||
width = contentWidth;
|
||||
}
|
||||
else
|
||||
{
|
||||
width = std::min(width, contentWidth * 0.75f);
|
||||
}
|
||||
|
||||
const float height = rect.Height - kEditFieldPadV * 2.0f * m_scale;
|
||||
return gd::RectF(rect.X + rect.Width - kItemPad * m_scale - width,
|
||||
rect.Y + (rect.Height - height) * 0.5f, width, height);
|
||||
}
|
||||
|
||||
gd::RectF PopupMenu::HeaderIconRect(int index) const
|
||||
{
|
||||
if (index < 0 || index >= static_cast<int>(m_items.size()))
|
||||
{
|
||||
return gd::RectF();
|
||||
}
|
||||
const MenuItem& item = m_items[static_cast<std::size_t>(index)];
|
||||
if (item.kind != MenuItemKind::HEADER_ACTION)
|
||||
{
|
||||
return gd::RectF();
|
||||
}
|
||||
const gd::RectF& rect = m_itemRects[static_cast<std::size_t>(index)];
|
||||
const float size = std::min(kHeaderIconSize * m_scale, std::max(6.0f, rect.Height - 4.0f * m_scale));
|
||||
return gd::RectF(rect.X + rect.Width - kItemPad * m_scale - size,
|
||||
rect.Y + (rect.Height - size) * 0.5f, size, size);
|
||||
}
|
||||
|
||||
bool PopupMenu::IsOnEditField(int x, int y) const
|
||||
{
|
||||
if (m_editIndex < 0)
|
||||
{
|
||||
return false;
|
||||
{ return false;
|
||||
}
|
||||
const gd::RectF rect = EditFieldRect(m_editIndex);
|
||||
return UiPointInRect(rect, static_cast<float>(x), static_cast<float>(y));
|
||||
@@ -509,6 +589,7 @@ namespace jdi
|
||||
m_scope = scope;
|
||||
m_open = true;
|
||||
m_hoverIndex = -1;
|
||||
m_hoverIconIndex = -1;
|
||||
m_hoverSwatch = -1;
|
||||
m_pressedIndex = -1;
|
||||
m_pressedSwatch = -1;
|
||||
@@ -529,6 +610,7 @@ namespace jdi
|
||||
m_itemRects.clear();
|
||||
m_swatchRects.clear();
|
||||
m_hoverIndex = -1;
|
||||
m_hoverIconIndex = -1;
|
||||
m_hoverSwatch = -1;
|
||||
m_pressedIndex = -1;
|
||||
m_pressedSwatch = -1;
|
||||
@@ -581,8 +663,7 @@ namespace jdi
|
||||
}
|
||||
if (!UiPointInRect(m_itemRects[index], fx, fy))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
continue; }
|
||||
|
||||
if (item.kind == MenuItemKind::SWATCHES)
|
||||
{
|
||||
@@ -723,6 +804,29 @@ namespace jdi
|
||||
changed = true;
|
||||
}
|
||||
|
||||
// `HEADER_ACTION` 的图标按钮单独跟踪悬停状态(用于高亮,其余标题区域不响应)。
|
||||
{
|
||||
int iconHover = -1;
|
||||
for (std::size_t itemIndex = 0; itemIndex < m_items.size(); ++itemIndex)
|
||||
{
|
||||
if (m_items[itemIndex].kind != MenuItemKind::HEADER_ACTION)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
const gd::RectF icon = HeaderIconRect(static_cast<int>(itemIndex));
|
||||
if (icon.Width > 0.0f && UiPointInRect(icon, fx, fy))
|
||||
{
|
||||
iconHover = static_cast<int>(itemIndex);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (iconHover != m_hoverIconIndex)
|
||||
{
|
||||
m_hoverIconIndex = iconHover;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 悬停到带子菜单的项 → 向右展开;移开到其它条目或离开菜单 → 收起。
|
||||
if (onSubmenuParent)
|
||||
{
|
||||
@@ -857,6 +961,22 @@ namespace jdi
|
||||
return true;
|
||||
}
|
||||
|
||||
// `HEADER_ACTION`:只有**点在右侧图标按钮上**才下发命令(标题区域不响应);
|
||||
// 菜单保持打开(这是一个可反复使用的小按钮,如「还原系数」)。
|
||||
if (item.kind == MenuItemKind::HEADER_ACTION)
|
||||
{
|
||||
const gd::RectF icon = HeaderIconRect(index);
|
||||
if (icon.Width > 0.0f && UiPointInRect(icon, static_cast<float>(x), static_cast<float>(y)))
|
||||
{
|
||||
result.valid = true;
|
||||
result.scope = m_scope;
|
||||
result.id = item.id;
|
||||
result.swatchIndex = -1;
|
||||
result.itemRect = icon;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// 编辑项:单击进入菜单内联编辑,菜单保持打开。
|
||||
if (item.kind == MenuItemKind::EDIT)
|
||||
{
|
||||
@@ -880,6 +1000,12 @@ namespace jdi
|
||||
result.swatchIndex = swatchIndex;
|
||||
}
|
||||
|
||||
// 带复选框的切换项(如镜像的水平/垂直)选择后**保持菜单打开**,便于连续勾选。
|
||||
if (item.keepOpen)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
Close();
|
||||
return true;
|
||||
}
|
||||
@@ -1043,6 +1169,29 @@ namespace jdi
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.kind == MenuItemKind::HEADER_ACTION)
|
||||
{
|
||||
// 标题 + 右侧「还原」图标按钮:文字与普通标题同一列;图标可点击(悬停高亮)。
|
||||
const gd::RectF icon = HeaderIconRect(static_cast<int>(index));
|
||||
const float labelWidth =
|
||||
std::max(8.0f, icon.X - rect.X - kItemPad * m_scale - kHeaderIconGap * m_scale);
|
||||
UiText(g, UiEllipsize(item.label, m_fonts->uiSmallBold, labelWidth), m_fonts->uiSmallBold,
|
||||
gd::RectF(rect.X + kItemPad * m_scale, rect.Y, labelWidth, rect.Height),
|
||||
m_theme->textMuted, TextAlign::LEFT);
|
||||
if (icon.Width > 0.0f)
|
||||
{
|
||||
const bool iconHovered = (static_cast<int>(index) == m_hoverIconIndex);
|
||||
if (iconHovered)
|
||||
{
|
||||
UiFillRoundRect(g, gd::RectF(icon.X - 2.0f * m_scale, icon.Y - 2.0f * m_scale,
|
||||
icon.Width + 4.0f * m_scale, icon.Height + 4.0f * m_scale),
|
||||
4.0f * m_scale, WithAlpha(m_theme->accent, 0x33));
|
||||
}
|
||||
PaintResetIcon(g, icon, iconHovered ? m_theme->accent : m_theme->text, m_scale);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const bool hovered = (static_cast<int>(index) == m_hoverIndex) &&
|
||||
(item.kind != MenuItemKind::SWATCHES || m_hoverSwatch < 0);
|
||||
|
||||
|
||||
Reference in new issue
Block a user