绘图交互完善 + 稠密区域拖拽性能修复(VERY_BIG_DATA 2ms/大格不再卡顿)
一、功能修正 - 修复「光标数据点在上下视界外不绘制」的问题,并让上下越界与左右越界一样用三角表示: 新增 `DataMarker` / `PaintDataMarker()`,统一处理「可视区内画圆点、越界则钉在网格边上 画三角指示方向」的四向情形(左/右/上/下)。光标取值与中心轴取值都改走这一套, 因此纵向被裁掉的数据点不再消失。 - 「边沿计数(中心轴)」的中心平均值曲线改为**跟随原信号的样式**: 与原始信号同色但更透明,线宽同样跟随(lineWidth + 0.6,下限 1.4), 原信号改颜色/线宽/透明度后曲线同步变化(曲线参与场景指纹,样式变化即失效重绘)。 光标显示中心值时的胶囊也改用**原始信号颜色**(一一对应),并**始终按线段模式**着色, 不再使用光标色。 - 「拆分当前网格」在可点击时追加尾缀「(N个信号)」(N = 该网格内信号数), 只有 1 条信号(不可点击)时不加尾缀。 二、新增:左侧信号筛选框 - 信号列表上方新增「筛选信号」输入框:空内容时显示灰色占位提示,单击进入编辑, 点击别处或回车结束编辑;列表内容随输入**实时**变化。 - 支持 `*` 通配符(`*` 等价于"任意字符",因此 `*` 单独出现等价于不过滤),忽略大小写。 - 筛选结果**仍以组为单位**,组默认展开,只显示匹配的信号;组内无匹配信号时整组不显示。 - 复用统一的 `TextEdit` 编辑模型,因此光标/选区/Ctrl+A/C/X/V 等行为与其它输入框一致。 三、性能:修复 VERY_BIG_DATA.CSV 在 2ms/大格密集区域拖拽卡顿 以「显示全部 5 条信号 + 时间轴缩放到 2ms 一大格」为场景逐段打点定位后修复: - **主因:稠密包络的锯齿描边光栅化**。抽稀时每列产生 min/max 两个顶点交替的锯齿, 2000+ 条近乎满幅的斜线段在 GDI+ 抗锯齿下要光栅化几十万像素(实测每网格 14ms, 5 网格约 70ms/帧)。 改为**包络带一次填充**:逐列记录上/下包络,收段时拼成「上包络正向 + 下包络反向」的 闭合多边形,用一条 `GraphicsPath` + `FillPath` 绘制(显式 `FillModeWinding`, 自交包络不会挖洞)。基准测试(1224 列):14.26ms → **1.69ms(约 8.4 倍)**, 且**保留抗锯齿**、静态帧与拖拽帧完全一致(不是"交互降级")。 逐像素对比确认观感等价(墨量覆盖率 90.8% vs 91.3%)。 包络带同时用于静态绘制与 MOVE_SIGNAL 的拖拽虚影。 - **大面积不透明填充改走 GDI**:`UiFillRect` 在面积 ≥ 20000px² 且颜色不透明时使用 `GetHDC + FillRect`(GDI+ FillRectangle 约 2.2ns/像素,整窗背景 1.7M 像素要 0.9ms、 面板背景 1.9ms)。半透明填充仍走 GDI+(GDI 不做 alpha 混色);小面积仍走 GDI+ (避免 GetHDC 固定开销)。顺序安全:GetHDC 会先刷入已排队的 GDI+ 内容。 - **`UiTextWidth` 不再触发 GDI+ 批次刷新**:改为复用 `UiMeasureTextWidth` (`GetDC(nullptr)`),`UiEllipsize` 逐字符回退时的十几次刷新被完全消除。 实测(125% 缩放、1725x996、5 信号、2ms 一大格): 绘图区绘制 47~87ms → **3.6~4.5ms**;整帧最大 196.8ms → **约 17~21ms(≈50~60fps)**, 与空闲帧基本持平,即"拖拽与不拖拽一样流畅"。 验证(VERY_BIG_DATA.CSV;面板行坐标因新增筛选框整体下移,复选框客户区 (61,250) 起、行距 33): - 筛选框:输入 `B` 后只剩「编码器B相」「电流钳采集(ABSx10)」,组仍展开;清空即恢复。 - 拆分:合并 3 条信号后右键显示「拆分当前网格(3个信号)」;单信号网格该项置灰无尾缀。 - 光标/中心轴:竖线光标取值与中心值胶囊均按四向越界正确显示三角/圆点。 - 稠密区域:5 信号全选 + 缩放到 2ms 一大格,左键拖拽信号、左键平移、中键平移均流畅; 渲染观感与修改前等价(叠图对比)。 - make clean && make all 无警告无错误。
This commit is contained in:
1 parent
82a29362eb
commit
fe72afc83c
6 files changed
+721
-126
No files matched your search
@@ -26,6 +26,65 @@ namespace jdi
|
||||
const float kCloseButtonSize = 18.0f; /**< 文件行关闭按钮尺寸。 */
|
||||
const float kGroupSeparatorGap = 7.0f; /**< 分组标签与分隔线/名称的间距。 */
|
||||
const float kGroupSeparatorWidth = 1.0f; /**< 分组标签右侧分隔线宽度。 */
|
||||
const float kFilterHeight = 26.0f; /**< 「筛选信号」输入框高度。 */
|
||||
const float kFilterPadV = 5.0f; /**< 筛选框上下留白。 */
|
||||
|
||||
/**
|
||||
* @brief 大小写无关的通配符匹配(`*` 匹配任意长度的任意字符序列)。
|
||||
* @param [IN] text 待匹配文本。
|
||||
* @param [IN] pattern 模式(可含多个 `*`)。
|
||||
* @return 匹配返回 true。
|
||||
*/
|
||||
bool WildcardMatchNoCase(const std::wstring& text, const std::wstring& pattern)
|
||||
{
|
||||
const auto fold = [](wchar_t character) -> wchar_t
|
||||
{
|
||||
// 仅对基本拉丁字母做大小写折叠(中文等其它字符原样比较)。
|
||||
if (character >= L'A' && character <= L'Z')
|
||||
{
|
||||
return static_cast<wchar_t>(character - L'A' + L'a');
|
||||
}
|
||||
return character;
|
||||
};
|
||||
|
||||
// 经典双指针回溯:`star` 记录最近一个 `*` 的位置,`resume` 记录当时尝试的文本位置。
|
||||
std::size_t t = 0;
|
||||
std::size_t p = 0;
|
||||
std::size_t star = std::wstring::npos;
|
||||
std::size_t resume = 0;
|
||||
|
||||
while (t < text.size())
|
||||
{
|
||||
if (p < pattern.size() && pattern[p] == L'*')
|
||||
{
|
||||
star = p;
|
||||
resume = t;
|
||||
++p;
|
||||
}
|
||||
else if (p < pattern.size() && fold(pattern[p]) == fold(text[t]))
|
||||
{
|
||||
++p;
|
||||
++t;
|
||||
}
|
||||
else if (star != std::wstring::npos)
|
||||
{
|
||||
p = star + 1;
|
||||
++resume;
|
||||
t = resume;
|
||||
}
|
||||
else
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 文本已耗尽:模式剩余部分必须全是 `*`。
|
||||
while (p < pattern.size() && pattern[p] == L'*')
|
||||
{
|
||||
++p;
|
||||
}
|
||||
return p == pattern.size();
|
||||
}
|
||||
|
||||
// 样式编辑区:固定四行网格(颜色 / 样式 / 透明度 / 线宽),行高、间距与内容列宽度统一,
|
||||
// 保证面板宽度变化时四行控件始终对齐,且不会被无限制拉伸。
|
||||
@@ -154,9 +213,69 @@ namespace jdi
|
||||
return dirty;
|
||||
}
|
||||
|
||||
void SignalPanel::BuildRows()
|
||||
bool SignalPanel::IsOnFilterBox(int x, int y) const
|
||||
{
|
||||
m_rows.clear();
|
||||
return UiPointInRect(m_filterRect, static_cast<float>(x), static_cast<float>(y));
|
||||
}
|
||||
|
||||
void SignalPanel::BeginFilterEdit()
|
||||
{
|
||||
if (m_filterEditing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
m_filterEditing = true;
|
||||
// 单击进入编辑:光标落在末尾,不做全选(用户可以继续追加筛选词)。
|
||||
m_filterEdit.Begin(m_filterText, false, EditFilter::ANY, 0u);
|
||||
}
|
||||
|
||||
bool SignalPanel::EndFilterEdit()
|
||||
{
|
||||
if (!m_filterEditing)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// **必须**在 `End()` 之前取出文本:`TextEdit::End()` 会清空内部缓冲区。
|
||||
const std::wstring finalText = Trim(m_filterEdit.Text());
|
||||
m_filterEditing = false;
|
||||
m_filterEdit.End();
|
||||
// 文本在编辑过程中已实时生效,这里只需把最终值再同步一次并收起插入符。
|
||||
m_filterText = finalText;
|
||||
return true;
|
||||
}
|
||||
|
||||
void SignalPanel::ApplyFilterText(const std::wstring& text)
|
||||
{
|
||||
if (m_filterText == text)
|
||||
{
|
||||
return;
|
||||
}
|
||||
m_filterText = text;
|
||||
// 筛选结果变化 → 重建行集合、回到列表顶部并重新计算滚动条。
|
||||
m_rowsDirty = true;
|
||||
m_scrollOffset = 0.0f;
|
||||
m_layoutDirty = true;
|
||||
}
|
||||
|
||||
bool SignalPanel::FilterMatchesSignal(const SignalData& signal) const
|
||||
{
|
||||
if (m_filterText.empty())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
// 以 `*` 为通配符、忽略大小写,匹配"信号名中是否出现过符合该模式的片段"。
|
||||
// 两侧补 `*` 使模式等价于子串匹配,因此下面这些写法都能按直觉工作:
|
||||
// `编码器` → 名称包含"编码器"
|
||||
// `编码器*相` → 名称中出现以"编码器"开头、"相"结尾的片段
|
||||
// `*x10` / `ABS*` → 尾部/首部片段匹配(无需用户自己补另一侧的 `*`)
|
||||
std::wstring pattern = L"*";
|
||||
pattern += m_filterText;
|
||||
pattern += L'*';
|
||||
return WildcardMatchNoCase(signal.Title(), pattern);
|
||||
}
|
||||
|
||||
void SignalPanel::BuildRows()
|
||||
{ m_rows.clear();
|
||||
m_rowsDirty = false;
|
||||
|
||||
if (m_dataSet == nullptr || m_tab == nullptr)
|
||||
@@ -167,21 +286,55 @@ namespace jdi
|
||||
|
||||
const float fileHeight = kFileRowHeight * m_scale;
|
||||
const float signalHeight = kSignalRowHeight * m_scale;
|
||||
const bool filtering = !m_filterText.empty();
|
||||
|
||||
const std::vector<DataFile>& files = m_dataSet->Files();
|
||||
for (std::size_t fileIndex = 0; fileIndex < files.size(); ++fileIndex)
|
||||
{
|
||||
// 筛选时先挑出本组内匹配的信号;整组都没有匹配则**不显示该组**。
|
||||
std::vector<int> matched;
|
||||
if (filtering)
|
||||
{
|
||||
const std::size_t signalCount = files[fileIndex].signals.size();
|
||||
for (std::size_t signalIndex = 0; signalIndex < signalCount; ++signalIndex)
|
||||
{
|
||||
if (FilterMatchesSignal(files[fileIndex].signals[signalIndex]))
|
||||
{
|
||||
matched.push_back(static_cast<int>(signalIndex));
|
||||
}
|
||||
}
|
||||
if (matched.empty())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
ListRow fileRow;
|
||||
fileRow.kind = RowKind::FILE;
|
||||
fileRow.fileIndex = static_cast<int>(fileIndex);
|
||||
fileRow.height = fileHeight;
|
||||
m_rows.push_back(fileRow);
|
||||
|
||||
if (m_tab->IsCollapsed(static_cast<int>(fileIndex)))
|
||||
// 筛选结果**默认展开**(忽略折叠状态),便于直接看到匹配项。
|
||||
if (!filtering && m_tab->IsCollapsed(static_cast<int>(fileIndex)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (filtering)
|
||||
{
|
||||
for (const int signalIndex : matched)
|
||||
{
|
||||
ListRow signalRow;
|
||||
signalRow.kind = RowKind::SIGNAL;
|
||||
signalRow.fileIndex = static_cast<int>(fileIndex);
|
||||
signalRow.signalIndex = signalIndex;
|
||||
signalRow.height = signalHeight;
|
||||
m_rows.push_back(signalRow);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const std::size_t signalCount = files[fileIndex].signals.size();
|
||||
for (std::size_t signalIndex = 0; signalIndex < signalCount; ++signalIndex)
|
||||
{
|
||||
@@ -498,10 +651,17 @@ namespace jdi
|
||||
|
||||
m_headerRect = gd::RectF(area.X, area.Y, area.Width, kHeaderHeight * m_scale);
|
||||
|
||||
// 标题下方的「筛选信号」输入框(列表内容过滤)。
|
||||
const float filterHeight = kFilterHeight * m_scale;
|
||||
m_filterRect = gd::RectF(area.X + kPadding * m_scale,
|
||||
m_headerRect.Y + m_headerRect.Height + kFilterPadV * m_scale,
|
||||
std::max(20.0f, area.Width - kPadding * 2.0f * m_scale), filterHeight);
|
||||
|
||||
const float styleHeight = DesiredStyleHeight() * m_scale;
|
||||
m_styleRect = gd::RectF(area.X, area.Y + area.Height - styleHeight, area.Width, styleHeight);
|
||||
m_listRect = gd::RectF(area.X, m_headerRect.Y + m_headerRect.Height, area.Width,
|
||||
std::max(0.0f, m_styleRect.Y - m_headerRect.Y - m_headerRect.Height));
|
||||
const float listTop = m_filterRect.Y + m_filterRect.Height + kFilterPadV * m_scale;
|
||||
m_listRect = gd::RectF(area.X, listTop, area.Width,
|
||||
std::max(0.0f, m_styleRect.Y - listTop));
|
||||
|
||||
// 标题区右侧的“全部显示/隐藏”按钮。
|
||||
const float toggleWidth = 68.0f * m_scale;
|
||||
@@ -557,6 +717,34 @@ namespace jdi
|
||||
enabled ? m_theme->text : m_theme->textMuted, TextAlign::CENTER);
|
||||
}
|
||||
|
||||
// 「筛选信号」输入框:未输入内容时显示占位提示,单击进入编辑,输入实时过滤下方列表。
|
||||
{
|
||||
UiFillRoundRect(g, m_filterRect, kCorner * m_scale, m_theme->cardBg);
|
||||
UiDrawRoundRect(g, m_filterRect, kCorner * m_scale,
|
||||
m_filterEditing ? m_theme->accent : m_theme->border,
|
||||
m_filterEditing ? 1.6f : 1.0f);
|
||||
if (m_filterEditing)
|
||||
{
|
||||
UiDrawEditField(g, FilterFieldRect(), m_filterEdit, m_fonts->uiSmall, m_scale);
|
||||
}
|
||||
else if (m_filterText.empty())
|
||||
{
|
||||
UiText(g, L"筛选信号", m_fonts->uiSmall,
|
||||
gd::RectF(m_filterRect.X + 8.0f * m_scale, m_filterRect.Y,
|
||||
m_filterRect.Width - 16.0f * m_scale, m_filterRect.Height),
|
||||
m_theme->textMuted, TextAlign::LEFT);
|
||||
}
|
||||
else
|
||||
{
|
||||
UiText(g, UiEllipsize(g, m_filterText, m_fonts->uiSmall,
|
||||
m_filterRect.Width - 16.0f * m_scale),
|
||||
m_fonts->uiSmall,
|
||||
gd::RectF(m_filterRect.X + 8.0f * m_scale, m_filterRect.Y,
|
||||
m_filterRect.Width - 16.0f * m_scale, m_filterRect.Height),
|
||||
m_theme->text, TextAlign::LEFT);
|
||||
}
|
||||
}
|
||||
|
||||
// 列表区。
|
||||
{
|
||||
const int saveState = g.Save();
|
||||
@@ -1203,6 +1391,15 @@ namespace jdi
|
||||
return true;
|
||||
}
|
||||
|
||||
gd::RectF SignalPanel::FilterFieldRect() const
|
||||
{
|
||||
// 输入框内容区:与占位提示/显示文本使用同一内缩,保证编辑时不跳动。
|
||||
const float inset = 7.0f * m_scale;
|
||||
return gd::RectF(m_filterRect.X + inset, m_filterRect.Y + 2.0f * m_scale,
|
||||
std::max(8.0f, m_filterRect.Width - inset * 2.0f),
|
||||
std::max(8.0f, m_filterRect.Height - 4.0f * m_scale));
|
||||
}
|
||||
|
||||
gd::RectF SignalPanel::EditFieldRect() const
|
||||
{
|
||||
switch (m_editField)
|
||||
@@ -1222,6 +1419,45 @@ namespace jdi
|
||||
|
||||
bool SignalPanel::OnEditChar(wchar_t character)
|
||||
{
|
||||
// 「筛选信号」输入框:字符实时进入筛选(无需回车即可看到结果变化)。
|
||||
if (m_filterEditing)
|
||||
{
|
||||
switch (character)
|
||||
{
|
||||
case 0x03: // Ctrl+C
|
||||
m_filterEdit.CopyToClipboard(m_host);
|
||||
return false;
|
||||
case 0x18: // Ctrl+X
|
||||
m_filterEdit.CutToClipboard(m_host);
|
||||
ApplyFilterText(m_filterEdit.Text());
|
||||
return true;
|
||||
case 0x16: // Ctrl+V
|
||||
m_filterEdit.PasteFromClipboard(m_host);
|
||||
ApplyFilterText(m_filterEdit.Text());
|
||||
return true;
|
||||
case 0x01: // Ctrl+A
|
||||
m_filterEdit.SelectAll();
|
||||
return true;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
if (character == L'\r' || character == L'\n')
|
||||
{
|
||||
return EndFilterEdit();
|
||||
}
|
||||
// 退格/删除已由 WM_KEYDOWN 的 OnEditKey 处理,运里忽略控制字符避免重复删除。
|
||||
if (character < 0x20 || character == 0x7F)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (!m_filterEdit.Insert(character))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
ApplyFilterText(m_filterEdit.Text());
|
||||
return true;
|
||||
}
|
||||
|
||||
if (m_editField == StyleEditField::NONE)
|
||||
{
|
||||
return false;
|
||||
@@ -1264,6 +1500,60 @@ namespace jdi
|
||||
|
||||
bool SignalPanel::OnEditKey(WPARAM key)
|
||||
{
|
||||
// 「筛选信号」输入框的键盘处理(回车结束编辑,Esc 清空并结束)。
|
||||
if (m_filterEditing)
|
||||
{
|
||||
const bool shift = (GetKeyState(VK_SHIFT) & 0x8000) != 0;
|
||||
const bool control = (GetKeyState(VK_CONTROL) & 0x8000) != 0;
|
||||
switch (key)
|
||||
{
|
||||
case VK_RETURN:
|
||||
return EndFilterEdit();
|
||||
case VK_ESCAPE:
|
||||
{
|
||||
const bool changed = m_filterEdit.Text() != m_filterText;
|
||||
EndFilterEdit();
|
||||
return changed;
|
||||
}
|
||||
case VK_BACK:
|
||||
{
|
||||
if (!m_filterEdit.Backspace())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
ApplyFilterText(m_filterEdit.Text());
|
||||
return true;
|
||||
}
|
||||
case VK_DELETE:
|
||||
{
|
||||
if (!m_filterEdit.DeleteForward())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
ApplyFilterText(m_filterEdit.Text());
|
||||
return true;
|
||||
}
|
||||
case VK_LEFT:
|
||||
return m_filterEdit.MoveCaret(-1, shift);
|
||||
case VK_RIGHT:
|
||||
return m_filterEdit.MoveCaret(1, shift);
|
||||
case VK_HOME:
|
||||
return m_filterEdit.MoveHome(shift);
|
||||
case VK_END:
|
||||
return m_filterEdit.MoveEnd(shift);
|
||||
case 'A':
|
||||
if (control)
|
||||
{
|
||||
m_filterEdit.SelectAll();
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (m_editField == StyleEditField::NONE)
|
||||
{
|
||||
return false;
|
||||
@@ -1305,6 +1595,10 @@ namespace jdi
|
||||
|
||||
bool SignalPanel::CollapseEditSelectionToEnd()
|
||||
{
|
||||
if (m_filterEditing)
|
||||
{
|
||||
return m_filterEdit.DeselectToEnd();
|
||||
}
|
||||
if (m_editField == StyleEditField::NONE)
|
||||
{
|
||||
return false;
|
||||
@@ -1314,6 +1608,10 @@ namespace jdi
|
||||
|
||||
bool SignalPanel::OnEditMouseDown(int x, int y)
|
||||
{
|
||||
if (m_filterEditing)
|
||||
{
|
||||
return UiEditFieldMouseDown(m_fonts->uiSmall, FilterFieldRect(), m_filterEdit, m_scale, x, y);
|
||||
}
|
||||
if (m_editField == StyleEditField::NONE)
|
||||
{
|
||||
return false;
|
||||
@@ -1323,6 +1621,10 @@ namespace jdi
|
||||
|
||||
bool SignalPanel::OnEditMouseMove(int x)
|
||||
{
|
||||
if (m_filterEditing)
|
||||
{
|
||||
return UiEditFieldMouseMove(m_fonts->uiSmall, FilterFieldRect(), m_filterEdit, m_scale, x);
|
||||
}
|
||||
if (m_editField == StyleEditField::NONE)
|
||||
{
|
||||
return false;
|
||||
@@ -1332,6 +1634,15 @@ namespace jdi
|
||||
|
||||
bool SignalPanel::OnEditMouseUp()
|
||||
{
|
||||
if (m_filterEditing)
|
||||
{
|
||||
if (!m_filterEdit.Dragging())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
m_filterEdit.EndDrag();
|
||||
return true;
|
||||
}
|
||||
if (!m_edit.Dragging())
|
||||
{
|
||||
return false;
|
||||
@@ -1424,6 +1735,11 @@ namespace jdi
|
||||
{
|
||||
changed = EndEdit(true) || changed;
|
||||
}
|
||||
// 点击面板之外也要结束「筛选信号」的编辑状态。
|
||||
if (m_filterEditing)
|
||||
{
|
||||
changed = EndFilterEdit() || changed;
|
||||
}
|
||||
if (m_colorPicker != nullptr && m_colorPicker->IsOpen())
|
||||
{
|
||||
m_colorPicker->Close();
|
||||
@@ -1540,6 +1856,18 @@ namespace jdi
|
||||
const float fx = static_cast<float>(x);
|
||||
const float fy = static_cast<float>(y);
|
||||
|
||||
// 「筛选信号」输入框:单击进入编辑;点击其它位置则先结束筛选编辑(文本已实时生效)。
|
||||
if (IsOnFilterBox(x, y))
|
||||
{
|
||||
BeginFilterEdit();
|
||||
return true;
|
||||
}
|
||||
if (m_filterEditing)
|
||||
{
|
||||
// 文本已实时生效,这里只需收起编辑态,随后继续处理本次点击。
|
||||
EndFilterEdit();
|
||||
}
|
||||
|
||||
// 滚动条拖拽优先于其它命中测试。
|
||||
if (m_scrollBarVisible && UiPointInRect(m_scrollBarRect, fx, fy))
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user