Files
2026-07-13 12:35:57 +08:00

4683 lines
164 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 知识库测试页面 JavaScript
// =====
// 性能优化: 防抖计时器
// =====
let heatmapUpdateTimer = null;
let statsUpdateTimer = null;
let dynamicDebounceDelay = 5; // 动态防抖延迟,基于上一次zOnly更新耗时,最小5ms
const MIN_DEBOUNCE_DELAY = 5; // 最小防抖延迟
const MAX_DEBOUNCE_DELAY = 200; // 最大防抖延迟
// =====
// 动态侧边栏配置与逻辑
// =====
// 侧边栏内容数据 - 定义6个功能模块
const sidebarData = {
dataSource: {
title: '数据源配置',
titleButton: '<button class="title-icon-btn" onclick="loadCollections()" title="刷新Collections"><i class="bi bi-arrow-clockwise"></i></button>',
templateId: 'tpl-dataSource'
},
operations: {
title: '结果操作',
templateId: 'tpl-operations'
},
chartControl: {
title: '图表选择与控制',
titleButton: '<button class="title-icon-btn" onclick="triggerImportJSON()" title="导入数据"><i class="bi bi-file-earmark-arrow-up"></i></button>',
templateId: 'tpl-chartControl'
},
statistics: {
title: '统计信息',
templateId: 'tpl-statistics'
},
filters: {
title: '筛选器控制',
templateId: 'tpl-filters'
}
};
// 当前激活的按钮 - 分为上下两个区域
let activeButtons = {
left: {
top: null,
bottom: null
},
right: {
top: null,
bottom: null
}
};
// 保存侧边栏的自定义宽度
let sidebarWidths = {
left: null,
right: null
};
// 保存上下分区的高度比例(存储上部分的像素高度)
let splitHeights = {
left: null,
right: null
};
// 从localStorage加载保存的宽度和高度
try {
const savedWidths = localStorage.getItem('sidebarWidths_testPage');
if (savedWidths) {
sidebarWidths = JSON.parse(savedWidths);
}
const savedHeights = localStorage.getItem('splitHeights_testPage');
if (savedHeights) {
splitHeights = JSON.parse(savedHeights);
}
} catch (e) {
console.error('加载侧边栏配置失败', e);
}
// 拖动状态
let dragState = {
isDragging: false,
draggedButton: null,
dragGhost: null,
dropIndicator: null,
startX: 0,
startY: 0,
dragThreshold: 5,
hasMoved: false
};
// 预加载所有侧边栏面板内容到统一的隐藏容器
function preloadAllPanels() {
console.log('[侧边栏] 预加载所有面板内容');
// 创建或获取全局隐藏容器
let globalPanelContainer = document.getElementById('globalPanelContainer');
if (!globalPanelContainer) {
globalPanelContainer = document.createElement('div');
globalPanelContainer.id = 'globalPanelContainer';
globalPanelContainer.style.display = 'none';
document.body.appendChild(globalPanelContainer);
}
// 为每个面板创建DOM,全部存储在隐藏容器中
Object.keys(sidebarData).forEach(panelName => {
const data = sidebarData[panelName];
if (!data) {
console.warn(`面板 "${panelName}" 配置不存在`);
return;
}
const panelDiv = document.createElement('div');
panelDiv.id = `panel-${panelName}`;
panelDiv.className = 'sidebar-panel';
panelDiv.style.display = 'none';
// 构建标题HTML,如果有titleButton则添加
const titleHTML = data.titleButton
? `<div class="panel-title-wrapper">
<h3 class="panel-title">${data.title}</h3>
${data.titleButton}
</div>`
: `<h3 style="font-size: 14px; font-weight: 600; margin-bottom: 15px; color: #404040; padding-bottom: 10px; border-bottom: 2px solid #6666FF;">${data.title}</h3>`;
// 获取模板内容
let contentHTML = '';
if (data.templateId) {
const tpl = document.getElementById(data.templateId);
if (tpl) {
contentHTML = tpl.innerHTML;
} else {
console.error(`Template #${data.templateId} not found for panel ${panelName}`);
}
} else if (data.content) {
contentHTML = data.content;
}
panelDiv.innerHTML = titleHTML + contentHTML;
globalPanelContainer.appendChild(panelDiv);
});
// 一次性初始化所有面板的事件处理器
console.log('[侧边栏] 初始化所有面板的事件处理器');
Object.keys(sidebarData).forEach(panelName => {
rebindEventHandlers(panelName);
});
}
// 更新侧边栏内容 - 动态移动面板DOM到目标位置
function updateSidebarContent(side, position, panel) {
const contentId = `${side}Content${position.charAt(0).toUpperCase() + position.slice(1)}`;
const content = document.getElementById(contentId);
if (!content) {
console.warn(`容器 #${contentId} 不存在`);
return;
}
// 隐藏该区域当前的所有面板
content.querySelectorAll('.sidebar-panel').forEach(p => {
p.style.display = 'none';
});
// 从全局容器或其他位置获取面板
const panelDiv = document.getElementById(`panel-${panel}`);
if (!panelDiv) {
console.warn(`面板 #panel-${panel} 不存在`);
return;
}
// 将面板移动到目标容器(如果不在的话)
if (panelDiv.parentElement !== content) {
content.appendChild(panelDiv);
}
// 显示面板
panelDiv.style.display = 'block';
console.log(`[侧边栏] 面板 ${panel} 移动到 ${contentId} 并显示`);
// 如果是筛选器面板,在显示后更新相似度滑块轨道
if (panel === 'filters' && typeof window.updateSimilarityTrack === 'function') {
setTimeout(() => {
window.updateSimilarityTrack();
}, 50);
}
}
// 绑定面板事件处理器(只在预加载时调用一次)
function rebindEventHandlers(panel) {
console.log(`[事件绑定] 初始化面板: ${panel}`);
// 根据不同的面板绑定相应的事件处理器
switch (panel) {
case 'filters':
// 初始化筛选器控件
if (typeof initRangeSlider === 'function') {
initRangeSlider();
}
if (typeof initTopkSlider === 'function') {
initTopkSlider();
}
// 初始化显示字段选择器(现在在筛选器面板中)
if (typeof initDisplayFieldSelectors === 'function') {
initDisplayFieldSelectors();
}
break;
case 'dataSource':
// 加载collections数据
if (typeof loadCollections === 'function') {
loadCollections();
}
// 为 collection 选择框添加事件委托
const xContainer = document.getElementById('xCollectionContainer');
const yContainer = document.getElementById('yCollectionContainer');
if (xContainer) {
xContainer.addEventListener('change', function (e) {
if (e.target.classList.contains('x-collection-select')) {
const errorEl = document.getElementById('xCollectionError');
if (errorEl) errorEl.style.display = 'none';
}
});
}
if (yContainer) {
yContainer.addEventListener('change', function (e) {
if (e.target.classList.contains('y-collection-select')) {
const errorEl = document.getElementById('yCollectionError');
if (errorEl) errorEl.style.display = 'none';
}
});
}
break;
case 'chartControl':
// 图表控制面板已简化,不需要额外的事件绑定
break;
case 'statistics':
// 统计信息是动态填充的,不需要特别的事件绑定
break;
case 'operations':
// 导出操作的事件已经通过全局绑定
break;
}
}
// 更新侧边栏整体状态
function updateSidebarState(side) {
const sidebar = document.getElementById(`${side}Sidebar`);
const contentTop = document.getElementById(`${side}ContentTop`);
const contentBottom = document.getElementById(`${side}ContentBottom`);
const verticalHandle = sidebar.querySelector('.vertical-resize-handle');
const hasTop = activeButtons[side].top !== null;
const hasBottom = activeButtons[side].bottom !== null;
// 记录当前状态以检测变化
const wasOpen = sidebar.classList.contains('open');
const shouldBeOpen = hasTop || hasBottom;
const stateChanged = wasOpen !== shouldBeOpen;
if (!hasTop && !hasBottom) {
sidebar.classList.remove('open');
sidebar.style.width = '0';
contentTop.classList.remove('active', 'full-height');
contentBottom.classList.remove('active', 'full-height');
verticalHandle.classList.remove('active');
// 只在侧边栏状态真正改变时调整热力图大小
if (stateChanged) {
resizeHeatmap();
}
return;
}
sidebar.classList.add('open');
if (sidebarWidths[side]) {
sidebar.style.width = sidebarWidths[side] + 'px';
} else {
sidebar.style.width = '320px';
}
// 只在侧边栏状态真正改变时调整热力图大小
if (stateChanged) {
resizeHeatmap();
}
if (hasTop && hasBottom) {
contentTop.classList.add('active');
contentTop.classList.remove('full-height');
contentBottom.classList.add('active');
contentBottom.classList.remove('full-height');
verticalHandle.classList.add('active');
if (splitHeights[side]) {
contentTop.style.height = splitHeights[side] + 'px';
contentBottom.style.flex = '1';
} else {
contentTop.style.height = '50%';
contentBottom.style.flex = '1';
}
} else if (hasTop) {
contentTop.classList.add('active', 'full-height');
contentBottom.classList.remove('active', 'full-height');
verticalHandle.classList.remove('active');
contentTop.style.height = '';
contentTop.style.flex = '';
contentBottom.style.flex = '';
} else {
contentTop.classList.remove('active', 'full-height');
contentBottom.classList.add('active', 'full-height');
verticalHandle.classList.remove('active');
contentBottom.style.height = '';
contentTop.style.flex = '';
contentBottom.style.flex = '';
}
}
// 初始化侧边栏
function initializeSidebar() {
// *** 关键改动:首先预加载所有面板内容 ***
preloadAllPanels();
const buttons = document.querySelectorAll('.icon-btn');
buttons.forEach(button => {
button.addEventListener('mousedown', function (e) {
dragState.draggedButton = this;
dragState.startX = e.clientX;
dragState.startY = e.clientY;
dragState.hasMoved = false;
e.preventDefault();
});
button.addEventListener('click', function () {
if (dragState.hasMoved) {
return;
}
const side = this.dataset.side;
const position = this.dataset.position;
const panel = this.dataset.panel;
if (activeButtons[side][position] === this) {
this.classList.remove('active');
activeButtons[side][position] = null;
} else {
if (activeButtons[side][position]) {
activeButtons[side][position].classList.remove('active');
}
this.classList.add('active');
activeButtons[side][position] = this;
updateSidebarContent(side, position, panel);
}
updateSidebarState(side);
});
});
document.addEventListener('mousemove', function (e) {
if (!dragState.draggedButton) return;
const deltaX = Math.abs(e.clientX - dragState.startX);
const deltaY = Math.abs(e.clientY - dragState.startY);
if (!dragState.isDragging && (deltaX > dragState.dragThreshold || deltaY > dragState.dragThreshold)) {
startDragging(e);
}
if (dragState.isDragging) {
updateDragGhost(e);
updateDropIndicator(e);
}
});
document.addEventListener('mouseup', function (e) {
if (dragState.isDragging) {
finishDragging(e);
} else {
dragState.draggedButton = null;
}
});
// 初始化调整大小逻辑
initializeResizeHandles();
// 触发默认激活的按钮
triggerDefaultActiveButtons();
}
// 初始化调整大小手柄
function initializeResizeHandles() {
const resizeHandles = document.querySelectorAll('.resize-handle');
resizeHandles.forEach(handle => {
let isResizing = false;
let sidebar = null;
let startX = 0;
let startWidth = 0;
let resizeTimer = null;
const side = handle.dataset.side;
handle.addEventListener('mousedown', function (e) {
sidebar = document.getElementById(`${side}Sidebar`);
if (!sidebar.classList.contains('open')) return;
isResizing = true;
startX = e.clientX;
startWidth = sidebar.offsetWidth;
handle.classList.add('dragging');
e.preventDefault();
});
document.addEventListener('mousemove', function (e) {
if (!isResizing) return;
let newWidth;
if (side === 'left') {
newWidth = startWidth + (e.clientX - startX);
} else {
newWidth = startWidth - (e.clientX - startX);
}
newWidth = Math.max(250, Math.min(newWidth, 600));
sidebar.style.width = newWidth + 'px';
// 节流resize调用,避免过于频繁
if (resizeTimer) {
clearTimeout(resizeTimer);
}
resizeTimer = setTimeout(() => {
resizeHeatmap();
}, 50);
});
document.addEventListener('mouseup', function () {
if (isResizing) {
isResizing = false;
handle.classList.remove('dragging');
if (sidebar) {
sidebarWidths[side] = sidebar.offsetWidth;
try {
localStorage.setItem('sidebarWidths_testPage', JSON.stringify(sidebarWidths));
} catch (e) {
console.error('保存侧边栏宽度失败', e);
}
// 调整完成后,resize热力图
resizeHeatmap();
}
}
});
});
// 垂直调整大小
const verticalResizeHandles = document.querySelectorAll('.vertical-resize-handle');
verticalResizeHandles.forEach(handle => {
let isResizing = false;
let contentTop = null;
let startY = 0;
let startHeight = 0;
const side = handle.dataset.side;
handle.addEventListener('mousedown', function (e) {
if (!handle.classList.contains('active')) return;
contentTop = document.getElementById(`${side}ContentTop`);
isResizing = true;
startY = e.clientY;
startHeight = contentTop.offsetHeight;
handle.classList.add('dragging');
e.preventDefault();
});
document.addEventListener('mousemove', function (e) {
if (!isResizing) return;
const deltaY = e.clientY - startY;
const newHeight = startHeight + deltaY;
const sidebar = document.getElementById(`${side}Sidebar`);
const totalHeight = sidebar.offsetHeight;
const minHeight = 100;
const maxHeight = totalHeight - minHeight - 4;
const clampedHeight = Math.max(minHeight, Math.min(newHeight, maxHeight));
contentTop.style.height = clampedHeight + 'px';
});
document.addEventListener('mouseup', function () {
if (isResizing) {
isResizing = false;
handle.classList.remove('dragging');
if (contentTop) {
splitHeights[side] = contentTop.offsetHeight;
try {
localStorage.setItem('splitHeights_testPage', JSON.stringify(splitHeights));
} catch (e) {
console.error('保存分区高度失败', e);
}
}
}
});
});
}
// 触发默认激活的按钮
function triggerDefaultActiveButtons() {
// 左上默认激活: dataSource
const leftTopBtn = document.querySelector('[data-side="left"][data-position="top"][data-panel="dataSource"]');
if (leftTopBtn) {
leftTopBtn.click();
}
// 左下默认激活: chartControl
const leftBottomBtn = document.querySelector('[data-side="left"][data-position="bottom"][data-panel="chartControl"]');
if (leftBottomBtn) {
leftBottomBtn.click();
}
// 右上默认激活: statistics(统计信息)
const statisticsBtn = document.querySelector('[data-side="right"][data-position="top"][data-panel="statistics"]');
if (statisticsBtn) {
statisticsBtn.click();
}
// 右下默认激活: filters(筛选器控制)
const filtersBtn = document.querySelector('[data-side="right"][data-position="bottom"][data-panel="filters"]');
if (filtersBtn) {
filtersBtn.click();
}
}
// =====
// 拖动功能相关函数
// =====
// 开始拖动
function startDragging(e) {
dragState.isDragging = true;
dragState.hasMoved = true;
// 添加拖动样式
dragState.draggedButton.classList.add('dragging');
// 创建幽灵图标
const ghost = document.createElement('div');
ghost.className = 'drag-ghost';
ghost.innerHTML = dragState.draggedButton.innerHTML;
document.body.appendChild(ghost);
dragState.dragGhost = ghost;
// 创建放置指示器
const indicator = document.createElement('div');
indicator.className = 'drop-indicator';
dragState.dropIndicator = indicator;
updateDragGhost(e);
}
// 更新幽灵图标位置
function updateDragGhost(e) {
if (dragState.dragGhost) {
dragState.dragGhost.style.left = e.clientX + 'px';
dragState.dragGhost.style.top = e.clientY + 'px';
}
}
// 更新放置指示器位置
function updateDropIndicator(e) {
if (!dragState.dropIndicator) return;
// 获取鼠标位置下的目标位置
const dropTarget = getDropTarget(e);
// 移除现有的指示器
if (dragState.dropIndicator.parentNode) {
dragState.dropIndicator.remove();
}
if (dropTarget) {
if (dropTarget.isEmpty) {
// 空分组: 将指示器放在分组中间
dropTarget.element.appendChild(dragState.dropIndicator);
} else {
// 有按钮的分组: 在目标按钮前后插入指示器
if (dropTarget.before) {
dropTarget.element.parentNode.insertBefore(dragState.dropIndicator, dropTarget.element);
} else {
dropTarget.element.parentNode.insertBefore(dragState.dropIndicator, dropTarget.element.nextSibling);
}
}
}
}
// 获取放置目标位置
function getDropTarget(e) {
// 获取所有图标栏(左右两侧)
const leftIconBar = document.querySelector('.left-icon-bar');
const rightIconBar = document.querySelector('.right-icon-bar');
const iconBars = [leftIconBar, rightIconBar];
let closestTarget = null;
let closestDistance = Infinity;
iconBars.forEach(iconBar => {
const barRect = iconBar.getBoundingClientRect();
// 扩大横轴的有效范围: 左右各扩展 100px
const expandedLeft = barRect.left - 100;
const expandedRight = barRect.right + 100;
// 检查鼠标横轴是否在扩展后的图标栏范围内
if (e.clientX >= expandedLeft && e.clientX <= expandedRight) {
// 获取该图标栏的上下两个分组
const topGroup = iconBar.querySelector('.icon-group-top');
const bottomGroup = iconBar.querySelector('.icon-group-bottom');
const groups = [
{ element: topGroup, position: 'top' },
{ element: bottomGroup, position: 'bottom' }
];
groups.forEach(({ element: group }) => {
if (!group) return;
const groupRect = group.getBoundingClientRect();
const buttons = group.querySelectorAll('.icon-btn:not(.dragging)');
if (buttons.length === 0) {
// 空分组处理
// 计算鼠标到分组区域的距离
let distanceToGroup;
if (e.clientY < groupRect.top) {
distanceToGroup = groupRect.top - e.clientY;
} else if (e.clientY > groupRect.bottom) {
distanceToGroup = e.clientY - groupRect.bottom;
} else {
distanceToGroup = 0; // 鼠标在分组内
}
// 为空分组增加更大的容错范围(纵轴方向150px)
if (distanceToGroup <= 150) {
if (distanceToGroup < closestDistance) {
closestDistance = distanceToGroup;
closestTarget = {
element: group,
before: false,
isEmpty: true
};
}
}
} else {
// 有按钮的分组
buttons.forEach(btn => {
const btnRect = btn.getBoundingClientRect();
const btnCenterY = btnRect.top + btnRect.height / 2;
const distance = Math.abs(e.clientY - btnCenterY);
if (distance < closestDistance) {
closestDistance = distance;
closestTarget = {
element: btn,
before: e.clientY < btnCenterY,
isEmpty: false
};
}
});
}
});
}
});
return closestTarget;
}
// 完成拖动
function finishDragging(e) {
const dropTarget = getDropTarget(e);
if (dropTarget) {
// 执行图标重新排列
moveButton(dragState.draggedButton, dropTarget);
}
// 清理拖动状态
if (dragState.dragGhost) {
dragState.dragGhost.remove();
dragState.dragGhost = null;
}
if (dragState.dropIndicator && dragState.dropIndicator.parentNode) {
dragState.dropIndicator.remove();
}
dragState.dropIndicator = null;
if (dragState.draggedButton) {
dragState.draggedButton.classList.remove('dragging');
}
dragState.isDragging = false;
dragState.draggedButton = null;
}
// 移动按钮到新位置
function moveButton(button, dropTarget) {
// 获取按钮原来的信息
const oldSide = button.dataset.side;
const oldPosition = button.dataset.position;
const wasActive = button.classList.contains('active');
const panel = button.dataset.panel;
// 确定新的side和position
let newSide, newPosition;
if (dropTarget.isEmpty) {
// 空分组
const group = dropTarget.element;
const iconBar = group.closest('.left-icon-bar, .right-icon-bar');
newSide = iconBar.classList.contains('left-icon-bar') ? 'left' : 'right';
newPosition = group.classList.contains('icon-group-top') ? 'top' : 'bottom';
// 移动按钮到空分组
group.appendChild(button);
} else {
// 有按钮的分组
const targetButton = dropTarget.element;
const group = targetButton.closest('.icon-group-top, .icon-group-bottom');
const iconBar = group.closest('.left-icon-bar, .right-icon-bar');
newSide = iconBar.classList.contains('left-icon-bar') ? 'left' : 'right';
newPosition = group.classList.contains('icon-group-top') ? 'top' : 'bottom';
// 移动按钮
if (dropTarget.before) {
group.insertBefore(button, targetButton);
} else {
group.insertBefore(button, targetButton.nextSibling);
}
}
// 更新按钮的数据属性
button.dataset.side = newSide;
button.dataset.position = newPosition;
// 处理激活状态变化
if (wasActive) {
// 清除旧位置的激活状态
activeButtons[oldSide][oldPosition] = null;
// 检查新位置是否已有激活按钮
if (activeButtons[newSide][newPosition]) {
activeButtons[newSide][newPosition].classList.remove('active');
}
// 设置新位置的激活状态
activeButtons[newSide][newPosition] = button;
// 更新内容
updateSidebarContent(newSide, newPosition, panel);
// 更新两侧的侧边栏状态
updateSidebarState(oldSide);
updateSidebarState(newSide);
}
// 保存按钮布局
saveButtonLayout();
}
// 保存按钮布局到localStorage
function saveButtonLayout() {
const layout = {
left: { top: [], bottom: [] },
right: { top: [], bottom: [] }
};
// 收集所有按钮的布局信息
document.querySelectorAll('.icon-btn').forEach(btn => {
const side = btn.dataset.side;
const position = btn.dataset.position;
const panel = btn.dataset.panel;
layout[side][position].push(panel);
});
try {
localStorage.setItem('buttonLayout_testPage', JSON.stringify(layout));
} catch (e) {
console.error('保存按钮布局失败', e);
}
}
// 从localStorage恢复按钮布局
function restoreButtonLayout() {
try {
const savedLayout = localStorage.getItem('buttonLayout_testPage');
if (!savedLayout) return;
const layout = JSON.parse(savedLayout);
// 重新组织按钮
Object.keys(layout).forEach(side => {
Object.keys(layout[side]).forEach(position => {
const panels = layout[side][position];
const group = document.querySelector(`.${side}-icon-bar .icon-group-${position}`);
panels.forEach(panel => {
const button = document.querySelector(`.icon-btn[data-panel="${panel}"]`);
if (button && group) {
button.dataset.side = side;
button.dataset.position = position;
group.appendChild(button);
}
});
});
});
} catch (e) {
console.error('恢复按钮布局失败', e);
}
}
// 页面加载时初始化侧边栏
document.addEventListener('DOMContentLoaded', function () {
restoreButtonLayout(); // 先恢复布局
initializeSidebar();
});
// =====
// 全局变量定义区
// =====
// --- 原始数据相关 ---
let filteredMatrix = null;
let currentXData = [];
let currentYData = [];
let currentXLabels = [];
let currentYLabels = [];
let xAvailableFields = [];
let yAvailableFields = [];
let availableCollections = [];
// --- 多图管理相关 ---
let allSimilarityResults = []; // 存储所有相似度矩阵结果,每个元素包含 visualConfig
// --- 全局默认配色(非可视化控制值的一部分,是全局UI设置) ---
let currentColorScheme = 'viridis';
let differenceMatrices = {}; // 存储差值矩阵的缓存 key格式: "idx1-idx2"
let currentMatrixIndex = 0; // 当前显示的矩阵索引
// =====
// 新架构:全局UI状态管理(与图表配置完全分离)
// =====
// 全局UI状态(独立于任何图表)
let globalUIState = {
// 数值来源:应用数据按钮控制
dataSource: {
primaryIndex: null, // 主数据源索引(第一个按下应用数据的图)
subtractIndex: null, // 减数索引(第二个按下应用数据的图,差值模式)
currentMatrix: null, // 当前显示的矩阵数据(原始或差值)
currentXData: [],
currentYData: [],
currentXLabels: [],
currentYLabels: [],
xAvailableFields: [],
yAvailableFields: []
},
// 字段配置:跟随主数据源,可独立修改
displayFields: {
xField: null,
yField: null
},
// 筛选器:可以来自多个图(或逻辑)
filters: {
activeFilterIndices: [], // 应用筛选器按钮选中的图表索引
// 当前UI显示的筛选器值(独占模式下可编辑)
uiState: {
similarityRange: { min: 0, max: 1 },
topK: { value: 0, axis: 'x' }
}
},
// 临时筛选器:非独占模式下的UI控件值,与已启用筛选器做AND运算
// 进入独占模式或差值模式时重置
temporaryFilter: {
enabled: false, // 是否启用临时筛选器(当用户调整UI控件时自动启用)
similarityRange: { min: 0, max: 1 },
topK: { value: 0, axis: 'x' }
},
// 排序:跟随字段配置,可独立修改
sorting: {
order: 'none'
},
// 独占模式
exclusiveMode: {
active: false,
editingIndex: null // 正在编辑的图表索引
},
// 筛选器标签页(减数模式下使用)
filterTab: {
activeTab: 'main' // 'main' | 'subtract'
}
};
// 每张图的按钮状态
let matrixButtonStates = [];
// 结构示例:
// {
// index: 0,
// applyData: false, // 应用数据按钮
// applyFilter: false, // 应用筛选器按钮
// exclusive: false // 独占模式按钮
// }
const DEFAULT_FIELD_NAMES = ['document', 'text', 'name'];
// API配置 - 直接使用当前系统的API端点
const API_BASE_URL = '/api/knowledge/similarity';
// =====
// 可视化配置管理
// =====
/**
* 创建默认的可视化配置对象
* 每张图都有独立的可视化控制值
*/
function createDefaultVisualConfig(xAvailableFields, yAvailableFields) {
const defaultXField = getDefaultDisplayField(xAvailableFields);
const defaultYField = getDefaultDisplayField(yAvailableFields);
return {
// 1. 显示字段配置
displayFields: {
xField: defaultXField, // 横坐标显示字段
yField: defaultYField // 纵坐标显示字段
},
// 2. 显示值范围配置(相似度阈值)
similarityRange: {
min: 0,
max: 1
},
// 3. 筛选器配置
filters: {
topK: {
value: 0, // Top-K值,0表示显示全部
axis: 'x' // 'x' 或 'y'
}
// 注意:阈值范围本身也起到筛选作用,已在 similarityRange 中定义
},
// 4. 排序配置
sorting: {
order: 'none' // 'none', 'asc', 'desc', 'x_asc', 'x_desc', 'y_asc', 'y_desc'
},
// 5. 布尔矩阵缓存(新增)
cachedMasks: {
thresholdMask: null, // 阈值筛选的布尔矩阵
topKMask: null, // Top-K筛选的布尔矩阵
finalMask: null // AND合并后的最终遮罩
}
};
}
// =====
// 常量配置
// =====
// 颜色方案配置
const colorSchemes = {
viridis: 'Viridis',
plasma: 'Plasma',
cividis: 'Cividis',
hot: 'Hot',
YlGnBu: 'YlGnBu',
};
// =====
// UI辅助函数
// =====
// 消息显示函数
/**
* 显示右上角临时消息
* @param {string} type - 消息类型: 'error', 'success', 'warning', 'info'
* @param {string} message - 消息内容
* @param {number} duration - 显示时长(毫秒),默认根据类型自动设置
*/
function showMessage(type, message, duration = 0) {
// 根据类型设置默认显示时长
if (duration === 0) {
const defaultDurations = {
'error': 5000,
'success': 3000,
'warning': 4000,
'info': 3000
};
duration = defaultDurations[type] || 3000;
}
// 创建消息元素
const messageEl = document.createElement('div');
messageEl.className = `temp-message ${type}`;
messageEl.textContent = message;
// 添加到页面
document.body.appendChild(messageEl);
// 自动移除
setTimeout(() => {
messageEl.style.animation = 'slideOut 0.3s ease';
setTimeout(() => {
if (document.body.contains(messageEl)) {
document.body.removeChild(messageEl);
}
}, 300);
}, duration);
}
function showError(message, duration = 5000) { showMessage('error', message, duration); }
function showSuccess(message, duration = 3000) { showMessage('success', message, duration); }
function showWarning(message, duration = 4000) { showMessage('warning', message, duration); }
function showInfo(message, duration = 3000) { showMessage('info', message, duration); }
/**
* 截断collection名称
* @param {string} name - collection名称
* @param {number} maxLength - 最大长度
* @returns {string} 截断后的名称
*/
function truncateCollectionName(name, maxLength = 20) {
if (!name) return '';
if (name.length <= maxLength) return name;
return name.substring(0, maxLength - 3) + '...';
}
// 显示加载状态
function showLoading(show = true, text = '正在加载...') {
document.getElementById('loading').style.display = show ? 'block' : 'none';
document.getElementById('loadingText').textContent = text;
}
// 显示必要字段验证错误
function showFieldValidationError(fieldId, show = true) {
const field = document.getElementById(fieldId);
const errorDiv = document.getElementById(fieldId + 'Error');
if (show) {
if (field) field.classList.add('required-empty');
if (errorDiv) errorDiv.style.display = 'block';
} else {
if (field) field.classList.remove('required-empty');
if (errorDiv) errorDiv.style.display = 'none';
}
}
// 清除所有验证错误状态
function clearValidationErrors() {
const xError = document.getElementById('xCollectionError');
const yError = document.getElementById('yCollectionError');
if (xError) xError.style.display = 'none';
if (yError) yError.style.display = 'none';
}
// 获取默认显示字段函数
function getDefaultDisplayField(availableFields) {
// 遍历优先级列表,返回第一个匹配的字段
for (const defaultField of DEFAULT_FIELD_NAMES) {
if (availableFields.includes(defaultField)) {
return defaultField;
}
}
// 如果没有匹配的默认字段,返回 'order_id'
return 'order_id';
}
/**
* 文本自动换行函数 - 用于热力图悬浮提示
* @param {string} text - 需要换行的文本
* @param {number} maxCharsPerLine - 每行最大字符数(默认50)
* @returns {string} - 换行后的文本
*/
function wrapTextForTooltip(text, maxCharsPerLine = 50) {
if (!text || typeof text !== 'string') {
return 'N/A';
}
// 如果文本长度小于等于最大字符数,直接返回
if (text.length <= maxCharsPerLine) {
return text;
}
const lines = [];
let currentPos = 0;
while (currentPos < text.length) {
// 如果剩余文本小于最大字符数,直接添加
if (currentPos + maxCharsPerLine >= text.length) {
lines.push(text.substring(currentPos));
break;
}
// 尝试在最大字符数附近找到合适的断点(空格、标点符号等)
let breakPos = currentPos + maxCharsPerLine;
const searchStart = Math.max(currentPos, breakPos - 10); // 向前搜索10个字符
// 查找最近的空格或标点符号作为断点
const breakChars = [' ', '', '。', '、', '', '', '', '', ',', '.', ';', ':', '!', '?'];
let foundBreak = false;
for (let i = breakPos; i >= searchStart; i--) {
if (breakChars.includes(text[i])) {
breakPos = i + 1; // 在标点符号后断开
foundBreak = true;
break;
}
}
// 如果没找到合适的断点,就在最大字符数处强制断开
if (!foundBreak) {
breakPos = currentPos + maxCharsPerLine;
}
lines.push(text.substring(currentPos, breakPos));
currentPos = breakPos;
}
// 使用 HTML <br> 标签连接各行
return lines.join('<br>');
}
// =====
// API调用与数据加载
// =====
// API调用函数
async function apiCall(endpoint, options = {}) {
const url = `${API_BASE_URL}${endpoint}`;
const defaultOptions = {
headers: { 'Content-Type': 'application/json' }
};
const finalOptions = { ...defaultOptions, ...options };
try {
const response = await fetch(url, finalOptions);
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || data.detail || `HTTP ${response.status}`);
}
if (data.success === false) {
throw new Error(data.error || '未知错误');
}
return data;
} catch (error) {
console.error('API调用失败:', error);
throw error;
}
}
// 修改loadCollections函数
async function loadCollections() {
try {
showLoading(true, '正在加载Collections...');
const data = await apiCall('/collections');
const collections = data.collections || [];
// 更新全局变量
availableCollections = collections;
// 更新所有现有的选择框
updateAllCollectionSelects();
showSuccess(`成功加载 ${collections.length} 个Collections`);
} catch (error) {
showError('加载Collections失败: ' + error.message);
} finally {
showLoading(false);
}
}
// 新增:更新所有collection选择框
function updateAllCollectionSelects() {
// 更新所有X轴选择框
const xSelects = document.querySelectorAll('.x-collection-select');
xSelects.forEach(select => {
updateSingleCollectionSelect(select);
});
// 更新所有Y轴选择框
const ySelects = document.querySelectorAll('.y-collection-select');
ySelects.forEach(select => {
updateSingleCollectionSelect(select);
});
}
// 新增:更新单个collection选择框
function updateSingleCollectionSelect(select) {
const currentValue = select.value;
select.innerHTML = '<option value="">请选择...</option>';
availableCollections.forEach(collection => {
const option = new Option(collection, collection);
select.add(option);
});
// 如果之前有选中值且该值仍然存在,保持选中
if (currentValue && availableCollections.includes(currentValue)) {
select.value = currentValue;
}
}
// 修改addCollectionSelect函数
function addCollectionSelect(axis) {
const containerId = axis === 'x' ? 'xCollectionContainer' : 'yCollectionContainer';
const container = document.getElementById(containerId);
const selectClass = axis === 'x' ? 'x-collection-select' : 'y-collection-select';
// 创建新的选择框行
const newRow = document.createElement('div');
newRow.className = 'collection-select-row';
// 创建select元素
const newSelect = document.createElement('select');
newSelect.className = selectClass;
// 使用全局变量填充options
updateSingleCollectionSelect(newSelect);
// 创建删除按钮
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'btn-remove-collection';
removeBtn.textContent = '-';
removeBtn.title = '删除';
removeBtn.onclick = function () {
removeCollectionSelect(this);
};
newRow.appendChild(newSelect);
newRow.appendChild(removeBtn);
container.appendChild(newRow);
// 清除验证错误
const fieldId = axis === 'x' ? 'xCollection' : 'yCollection';
showFieldValidationError(fieldId, false);
}
// 删除collection选择框
function removeCollectionSelect(button) {
const row = button.closest('.collection-select-row');
row.remove();
}
// =====
// 布尔矩阵计算函数(新架构核心)
// =====
/**
* 计算阈值筛选的布尔矩阵
* @param {Array<Array<number>>} matrix - 原始相似度矩阵
* @param {number} minSim - 最小相似度阈值
* @param {number} maxSim - 最大相似度阈值
* @returns {Array<Array<boolean>>} - 布尔矩阵,true表示通过筛选
*/
function computeThresholdMask(matrix, minSim, maxSim) {
const startTime = performance.now();
console.log(`[布尔矩阵] 计算阈值遮罩: ${minSim.toFixed(2)} - ${maxSim.toFixed(2)}`);
const result = matrix.map(row =>
row.map(val => {
if (val === null || val === undefined) return false;
return val >= minSim && val <= maxSim;
})
);
console.log(`[DEBUG] 计算阈值遮罩 耗时: ${((performance.now() - startTime) / 1000).toFixed(3)}s`);
return result;
}
/**
* 计算Top-K筛选的布尔矩阵
* @param {Array<Array<number>>} matrix - 原始相似度矩阵
* @param {number} topK - Top-K值,0表示全部显示
* @param {string} axis - 'x' 或 'y'
* @returns {Array<Array<boolean>>} - 布尔矩阵,true表示是Top-K
*/
function computeTopKMask(matrix, topK, axis) {
const startTime = performance.now();
console.log(`[布尔矩阵] 计算Top-K遮罩: Top-${topK}, 轴: ${axis}`);
if (topK === 0) {
// Top-K为0,全部通过
const result = matrix.map(row => row.map(() => true));
console.log(`[DEBUG] 计算Top-K遮罩 耗时: ${((performance.now() - startTime) / 1000).toFixed(3)}s`);
return result;
}
// 初始化布尔矩阵为false
const mask = matrix.map(row => row.map(() => false));
if (axis === 'x') {
// 横轴Top-K:对每一行(Y轴的每个项目),找出相似度最高的K个X轴项目
for (let i = 0; i < matrix.length; i++) {
const row = matrix[i];
const validPairs = [];
for (let j = 0; j < row.length; j++) {
const val = row[j];
if (val !== null && val !== undefined) {
validPairs.push({ index: j, similarity: val });
}
}
// 按相似度降序排序,取前K个
validPairs.sort((a, b) => b.similarity - a.similarity);
const topKPairs = validPairs.slice(0, topK);
// 在布尔矩阵中标记Top-K位置为true
topKPairs.forEach(pair => {
mask[i][pair.index] = true;
});
}
} else {
// 纵轴Top-K:对每一列(X轴的每个项目),找出相似度最高的K个Y轴项目
for (let j = 0; j < matrix[0].length; j++) {
const validPairs = [];
for (let i = 0; i < matrix.length; i++) {
const val = matrix[i][j];
if (val !== null && val !== undefined) {
validPairs.push({ index: i, similarity: val });
}
}
// 按相似度降序排序,取前K个
validPairs.sort((a, b) => b.similarity - a.similarity);
const topKPairs = validPairs.slice(0, topK);
// 在布尔矩阵中标记Top-K位置为true
topKPairs.forEach(pair => {
mask[pair.index][j] = true;
});
}
}
console.log(`[DEBUG] 计算Top-K遮罩 耗时: ${((performance.now() - startTime) / 1000).toFixed(3)}s`);
return mask;
}
/**
* 单图内AND合并:阈值遮罩 AND Top-K遮罩
* @param {Array<Array<boolean>>} mask1 - 第一个布尔矩阵
* @param {Array<Array<boolean>>} mask2 - 第二个布尔矩阵
* @returns {Array<Array<boolean>>} - AND运算后的布尔矩阵
*/
function combineWithAND(mask1, mask2) {
const startTime = performance.now();
console.log('[布尔矩阵] 执行AND合并');
const result = mask1.map((row, i) =>
row.map((val, j) => val && mask2[i][j])
);
console.log(`[DEBUG] AND合并 耗时: ${((performance.now() - startTime) / 1000).toFixed(3)}s`);
return result;
}
/**
* 多图间OR合并:多个最终遮罩的OR运算
* @param {Array<Array<Array<boolean>>>} masks - 多个布尔矩阵
* @returns {Array<Array<boolean>>} - OR运算后的布尔矩阵
*/
function combineWithOR(masks) {
const startTime = performance.now();
if (masks.length === 0) {
console.warn('[布尔矩阵] OR合并:没有输入遮罩');
return null;
}
if (masks.length === 1) {
console.log('[布尔矩阵] OR合并:只有一个遮罩,直接返回');
console.log(`[DEBUG] OR合并 耗时: ${((performance.now() - startTime) / 1000).toFixed(3)}s`);
return masks[0];
}
console.log(`[布尔矩阵] 执行OR合并:${masks.length}个遮罩`);
const result = masks.reduce((result, mask) =>
result.map((row, i) =>
row.map((val, j) => val || mask[i][j])
)
);
console.log(`[DEBUG] OR合并 耗时: ${((performance.now() - startTime) / 1000).toFixed(3)}s`);
return result;
}
/**
* 应用布尔遮罩到原始矩阵
* @param {Array<Array<number>>} originalMatrix - 原始数据矩阵
* @param {Array<Array<boolean>>} mask - 布尔遮罩
* @returns {Array<Array<number|null>>} - 应用遮罩后的矩阵
*/
function applyMask(originalMatrix, mask) {
const startTime = performance.now();
console.log('[布尔矩阵] 应用遮罩到原始数据');
const result = originalMatrix.map((row, i) =>
row.map((val, j) => mask[i][j] ? val : null)
);
console.log(`[DEBUG] 应用遮罩 耗时: ${((performance.now() - startTime) / 1000).toFixed(3)}s`);
return result;
}
/**
* 获取矩阵的尺寸信息
* @param {number} index - 图表索引
* @returns {Object|null} - 返回 {rows, cols} 或 null
*/
function getMatrixSize(index) {
if (index < 0 || index >= allSimilarityResults.length) {
return null;
}
const matrix = allSimilarityResults[index].matrix;
return {
rows: matrix.length,
cols: matrix.length > 0 ? matrix[0].length : 0
};
}
/**
* 检查指定图表的矩阵大小是否与当前已启用的图表一致
* @param {number} newIndex - 要检查的图表索引
* @param {string} buttonType - 按钮类型: 'applyData' 或 'applyFilter'
* @returns {Object} - {isValid: boolean, message: string, conflictIndices: Array}
*/
function checkMatrixSizeConsistency(newIndex, buttonType) {
const newSize = getMatrixSize(newIndex);
if (!newSize) {
return { isValid: false, message: '无效的图表索引', conflictIndices: [] };
}
let activeIndices = [];
// 根据按钮类型确定需要检查的已启用图表
if (buttonType === 'applyData') {
// 检查"应用数据"按钮:需要与已启用的应用数据按钮一致
if (globalUIState.dataSource.primaryIndex !== null) {
activeIndices.push(globalUIState.dataSource.primaryIndex);
}
if (globalUIState.dataSource.subtractIndex !== null) {
activeIndices.push(globalUIState.dataSource.subtractIndex);
}
} else if (buttonType === 'applyFilter') {
// 检查"应用筛选器"按钮:需要与当前数据源一致(如果有数据源)
// 同时也需要与其他已启用的筛选器一致
if (globalUIState.dataSource.primaryIndex !== null) {
activeIndices.push(globalUIState.dataSource.primaryIndex);
}
if (globalUIState.dataSource.subtractIndex !== null) {
activeIndices.push(globalUIState.dataSource.subtractIndex);
}
// 添加其他已启用的筛选器
globalUIState.filters.activeFilterIndices.forEach(idx => {
if (!activeIndices.includes(idx)) {
activeIndices.push(idx);
}
});
}
// 如果没有已启用的图表,直接允许
if (activeIndices.length === 0) {
return { isValid: true, message: '', conflictIndices: [] };
}
// 检查是否与所有已启用的图表尺寸一致
const conflictIndices = [];
for (const activeIdx of activeIndices) {
const activeSize = getMatrixSize(activeIdx);
if (activeSize && (activeSize.rows !== newSize.rows || activeSize.cols !== newSize.cols)) {
conflictIndices.push(activeIdx);
}
}
if (conflictIndices.length > 0) {
const conflictList = conflictIndices.map(idx => `图表${idx + 1}`).join('、');
return {
isValid: false,
message: `矩阵大小不一致 (当前图: ${newSize.rows}×${newSize.cols},已启用的${conflictList}有不同尺寸),请先松开原按钮`,
conflictIndices: conflictIndices
};
}
return { isValid: true, message: '', conflictIndices: [] };
}
/**
* 计算单张图的最终遮罩(阈值 AND Top-K)
* @param {number} index - 图表索引
* @returns {Array<Array<boolean>>|null} - 最终布尔遮罩
*/
function computeFinalMaskForMatrix(index) {
const startTime = performance.now();
if (index < 0 || index >= allSimilarityResults.length) {
console.error(`[布尔矩阵] 无效的图表索引: ${index}`);
return null;
}
const matrixData = allSimilarityResults[index];
const config = matrixData.visualConfig;
const originalMatrix = matrixData.matrix;
console.log(`[布尔矩阵] 计算图 ${index} 的最终遮罩`);
// 1. 计算阈值遮罩
const thresholdMask = computeThresholdMask(
originalMatrix,
config.similarityRange.min,
config.similarityRange.max
);
// 2. 计算Top-K遮罩
const topKMask = computeTopKMask(
originalMatrix,
config.filters.topK.value,
config.filters.topK.axis
);
// 3. AND合并
const finalMask = combineWithAND(thresholdMask, topKMask);
// 4. 缓存到配置中
if (!config.cachedMasks) {
config.cachedMasks = {};
}
config.cachedMasks.thresholdMask = thresholdMask;
config.cachedMasks.topKMask = topKMask;
config.cachedMasks.finalMask = finalMask;
console.log(`[DEBUG] 计算图 ${index} 最终遮罩 耗时: ${((performance.now() - startTime) / 1000).toFixed(3)}s`);
return finalMask;
}
/**
* 计算临时筛选器的布尔遮罩
* 使用当前显示的矩阵数据和临时筛选器配置
* @returns {Array<Array<boolean>>|null} - 临时筛选器的布尔遮罩
*/
function computeTemporaryFilterMask() {
if (!globalUIState.temporaryFilter.enabled) {
return null;
}
const currentMatrix = globalUIState.dataSource.currentMatrix;
if (!currentMatrix) {
return null;
}
const tempFilter = globalUIState.temporaryFilter;
console.log(`[临时筛选器] 计算遮罩 - 阈值: ${tempFilter.similarityRange.min.toFixed(2)}-${tempFilter.similarityRange.max.toFixed(2)}, Top-K: ${tempFilter.topK.value} (${tempFilter.topK.axis}轴)`);
// 1. 计算阈值遮罩
const thresholdMask = computeThresholdMask(
currentMatrix,
tempFilter.similarityRange.min,
tempFilter.similarityRange.max
);
// 2. 计算Top-K遮罩
const topKMask = computeTopKMask(
currentMatrix,
tempFilter.topK.value,
tempFilter.topK.axis
);
// 3. AND合并
return combineWithAND(thresholdMask, topKMask);
}
/**
* 重置临时筛选器
* @param {boolean} isInDifferenceMode - 是否为差值模式
*/
function resetTemporaryFilter(isInDifferenceMode = false) {
const range = isInDifferenceMode ? { min: -1, max: 1 } : { min: 0, max: 1 };
globalUIState.temporaryFilter.enabled = false;
globalUIState.temporaryFilter.similarityRange = { ...range };
globalUIState.temporaryFilter.topK = { value: 0, axis: 'x' };
console.log(`[临时筛选器] 已重置 - 差值模式: ${isInDifferenceMode}, 范围: ${range.min} - ${range.max}`);
}
/**
* 将临时筛选器的值应用到UI控件
*/
function applyTemporaryFilterToUI() {
const tempFilter = globalUIState.temporaryFilter;
// 注意:这里不更新UI控件的值,因为UI控件本身就是用户交互的源头
// 这个函数主要用于重置后,确保UI状态同步
// 实际的UI更新在applyFilterStateToUI中完成
console.log(`[临时筛选器] UI已同步 - enabled: ${tempFilter.enabled}`);
}
// =====
// 数据处理与标签生成
// =====
// 生成唯一标签函数,使用零宽字符和不间断空格确保唯一性
// 刻度标签超过 MAX_LABEL_LENGTH 字符时截断,悬浮提示仍显示完整内容
function generateUniqueLabels(data, field) {
const displayValueCountMap = new Map(); // 跟踪截断后的显示值,确保 Plotly 收到的标签唯一
const MAX_LABEL_LENGTH = 10;
return data.map((item) => {
let baseValue;
if (field === 'order_id') {
baseValue = `ID-${item[field]}`;
} else {
baseValue = String(item[field] || 'N/A');
}
// 截断显示值(用于刻度标签)
let displayValue = baseValue;
if (baseValue.length > MAX_LABEL_LENGTH) {
displayValue = baseValue.slice(0, MAX_LABEL_LENGTH) + '...';
}
// 跟踪截断后显示值的出现次数(不同原始值截断后可能相同,需要区分)
const currentCount = displayValueCountMap.get(displayValue) || 0;
displayValueCountMap.set(displayValue, currentCount + 1);
// 如果显示值重复,添加零宽字符和不间断空格来确保唯一性
if (currentCount > 0) {
// 零宽字符:\u200B (零宽空格)
// 不间断空格:\u00A0
// 根据重复次数添加不同数量的零宽字符
const uniqueSuffix = '\u200B'.repeat(currentCount) + '\u00A0';
return displayValue + uniqueSuffix;
} else {
return displayValue;
}
});
}
// 生成完整标签(用于 hover 显示),最多 maxLength 字符
function generateFullLabels(data, field, maxLength = 300) {
return data.map(item => {
let text = field === 'order_id'
? `ID-${item[field]}`
: String(item[field] || 'N/A');
return text.length > maxLength ? text.slice(0, maxLength) + '...' : text;
});
}
// =====
// 可视化控制与筛选
// =====
// 设置Top-K轴选择
function setTopkAxis(axis) {
currentTopkAxis = axis;
// 更新全局筛选器状态
globalUIState.filters.uiState.topK.axis = axis;
// *** 根据当前标签页决定保存位置 ***
if (globalUIState.filterTab.activeTab === 'subtract' && globalUIState.dataSource.subtractIndex !== null) {
// 减数图筛选器标签:保存到减数图的visualConfig
const subtractIndex = globalUIState.dataSource.subtractIndex;
const config = allSimilarityResults[subtractIndex].visualConfig;
config.filters.topK.axis = axis;
// 标记布尔矩阵缓存失效(轴变化会改变Top-K结果)
if (config.cachedMasks) {
config.cachedMasks.topKMask = null;
config.cachedMasks.finalMask = null;
}
console.log(`[减数图筛选器] 保存Top-K轴: ${axis},缓存已失效`);
} else if (globalUIState.exclusiveMode.active && globalUIState.exclusiveMode.editingIndex !== null) {
// 独占模式:保存到编辑中的图表配置
const config = allSimilarityResults[globalUIState.exclusiveMode.editingIndex].visualConfig;
config.filters.topK.axis = axis;
// 标记布尔矩阵缓存失效(轴变化会改变Top-K结果)
if (config.cachedMasks) {
config.cachedMasks.topKMask = null;
config.cachedMasks.finalMask = null;
}
console.log(`[独占模式] 保存Top-K轴: ${axis},缓存已失效`);
} else {
// *** 非独占模式:更新临时筛选器 ***
globalUIState.temporaryFilter.enabled = true;
globalUIState.temporaryFilter.topK.axis = axis;
console.log(`[临时筛选器] 更新Top-K轴: ${axis}`);
}
// 更新按钮状态
document.getElementById('xAxisBtn').classList.toggle('active', axis === 'x');
document.getElementById('yAxisBtn').classList.toggle('active', axis === 'y');
// 更新Top-K滑块的最大值
updateTopkSliderMax();
// 更新热力图
if (filteredMatrix || globalUIState.dataSource.currentMatrix) {
updateHeatmap();
// 更新统计信息
if (globalUIState.dataSource.subtractIndex !== null) {
showDifferenceStatistics(false);
} else {
showStatistics(false);
}
}
}
// =====
// 相似度计算与矩阵管理
// =====
// 计算相似度矩阵
async function calculateSimilarity() {
// 获取所有选中的X轴collections
const xSelects = document.querySelectorAll('.x-collection-select');
const xCollections = Array.from(xSelects)
.map(select => select.value)
.filter(value => value !== '');
// 获取所有选中的Y轴collections
const ySelects = document.querySelectorAll('.y-collection-select');
const yCollections = Array.from(ySelects)
.map(select => select.value)
.filter(value => value !== '');
const xMaxItemsEl = document.getElementById('xMaxItems');
const yMaxItemsEl = document.getElementById('yMaxItems');
if (!xMaxItemsEl || !yMaxItemsEl) {
showError('无法找到配置元素,请确保数据源配置面板已打开');
return;
}
const xMaxItems = parseInt(xMaxItemsEl.value) || 30;
const yMaxItems = parseInt(yMaxItemsEl.value) || 30;
// 先清除之前的验证错误
clearValidationErrors();
// 检查必要项是否填写
if (xCollections.length === 0) {
const xErrorEl = document.getElementById('xCollectionError');
if (xErrorEl) xErrorEl.style.display = 'block';
showError('请至少选择一个横坐标 Collection');
return;
}
if (yCollections.length === 0) {
const yErrorEl = document.getElementById('yCollectionError');
if (yErrorEl) yErrorEl.style.display = 'block';
showError('请至少选择一个纵坐标 Collection');
return;
}
try {
// 清空之前的结果
allSimilarityResults = [];
currentMatrixIndex = 0;
// 计算总的请求数
const totalRequests = xCollections.length * yCollections.length;
let completedRequests = 0;
showLoading(true, `正在计算相似度矩阵... (0/${totalRequests})`);
// 遍历所有X和Y的组合
for (let i = 0; i < xCollections.length; i++) {
for (let j = 0; j < yCollections.length; j++) {
const xCollection = xCollections[i];
const yCollection = yCollections[j];
try {
// 更新进度
completedRequests++;
showLoading(true, `正在计算相似度矩阵... (${completedRequests}/${totalRequests})`);
const data = await apiCall('/calculate', {
method: 'POST',
body: JSON.stringify({
x_collection: xCollection,
y_collection: yCollection,
x_max_items: xMaxItems,
y_max_items: yMaxItems
})
});
// 保存结果,并为每张图创建独立的可视化配置
const visualConfig = createDefaultVisualConfig(
data.result.x_available_fields,
data.result.y_available_fields
);
allSimilarityResults.push({
xCollection: xCollection,
yCollection: yCollection,
result: data.result,
matrix: data.result.matrix,
xData: data.result.x_data.slice(0, xMaxItems),
yData: data.result.y_data.slice(0, yMaxItems),
xAvailableFields: data.result.x_available_fields,
yAvailableFields: data.result.y_available_fields,
stats: data.result.stats,
visualConfig: visualConfig // 每张图独立的可视化配置
});
} catch (error) {
// 单个矩阵计算失败,显示警告并继续下一组
console.warn(`[相似度计算] ${xCollection} vs ${yCollection} 失败:`, error);
showWarning(`跳过 ${xCollection} vs ${yCollection}: ${error.message}`, 4000);
// 不添加到结果中,继续下一个
}
}
}
if (allSimilarityResults.length === 0) {
throw new Error('所有相似度计算都失败了');
}
// *** 新架构:初始化按钮状态数组 ***
initializeButtonStates();
// 重置全局UI状态
globalUIState.dataSource.primaryIndex = null;
globalUIState.dataSource.subtractIndex = null;
globalUIState.dataSource.currentMatrix = null;
globalUIState.filters.activeFilterIndices = [];
globalUIState.exclusiveMode.active = false;
globalUIState.exclusiveMode.editingIndex = null;
// 隐藏筛选器标签切换(重置时确保是主筛选器标签)
hideFilterTabs();
// *** 新架构:更新图表列表UI ***
updateMatrixListUI();
updateExportMatrixSelector();
// 显示图表选择控制区域 (添加空值检查)
const matrixSelectorControl = document.getElementById('matrixSelectorControl');
if (matrixSelectorControl) {
matrixSelectorControl.style.display = 'block';
}
// *** 新特性:首次计算相似度时自动启用第一张图的独占模式 ***
if (allSimilarityResults.length > 0) {
console.log('[自动独占] 首次计算相似度,自动启用图0的独占模式');
await enterExclusiveMode(0);
updateMatrixListUI(); // 再次更新UI以反映独占模式状态
}
showSuccess(`成功计算 ${allSimilarityResults.length} 个相似度矩阵! 已自动启用第一张图的编辑模式`);
} catch (error) {
showError('计算相似度失败: ' + error.message);
} finally {
showLoading(false);
}
}
// =====
// 可视化更新与渲染
// =====
// 更新Top-K滑块的最大值
function updateTopkSliderMax() {
if (!filteredMatrix) return;
const maxX = filteredMatrix[0] ? filteredMatrix[0].length : 0;
const maxY = filteredMatrix.length;
// 根据当前选择的轴确定最大值
const maxTopk = currentTopkAxis === 'x' ? maxX : maxY;
const topkSlider = document.getElementById('topkSlider');
if (!topkSlider) return;
topkSlider.max = maxTopk;
// 如果当前值超过最大值,重置为最大值
if (parseInt(topkSlider.value) > maxTopk) {
topkSlider.value = maxTopk;
updateTopkDisplay();
}
// 更新增减按钮状态
updateTopkButtons();
}
// 更新Top-K增减按钮状态
function updateTopkButtons() {
// 移除所有禁用逻辑,按钮始终可用
const decBtn = document.getElementById('topkDecBtn');
const incBtn = document.getElementById('topkIncBtn');
decBtn.disabled = false;
incBtn.disabled = false;
}
// 调整Top-K值
function adjustTopk(delta) {
const topkSlider = document.getElementById('topkSlider');
const currentValue = parseInt(topkSlider.value);
const minValue = parseInt(topkSlider.min);
const maxValue = parseInt(topkSlider.max);
const newValue = Math.max(minValue, Math.min(maxValue, currentValue + delta));
if (newValue !== currentValue) {
topkSlider.value = newValue;
// 手动触发 input 事件,以确保状态同步和缓存失效
topkSlider.dispatchEvent(new Event('input'));
}
// 确保按钮状态正确更新
updateTopkButtons();
}
// 调整最小相似度值
function adjustMinSimilarity(delta) {
const minSlider = document.getElementById('minSimilaritySlider');
const maxSlider = document.getElementById('maxSimilaritySlider');
const currentValue = parseFloat(minSlider.value);
const minValue = parseFloat(minSlider.min);
const maxValue = parseFloat(maxSlider.value);
const newValue = Math.max(minValue, Math.min(maxValue, currentValue + delta));
// 保留两位小数避免浮点精度问题
const roundedValue = Math.round(newValue * 100) / 100;
if (roundedValue !== currentValue) {
minSlider.value = roundedValue;
// 手动触发 input 事件,以确保状态同步和缓存失效
minSlider.dispatchEvent(new Event('input'));
}
}
// 调整最大相似度值
function adjustMaxSimilarity(delta) {
const minSlider = document.getElementById('minSimilaritySlider');
const maxSlider = document.getElementById('maxSimilaritySlider');
const currentValue = parseFloat(maxSlider.value);
const minValue = parseFloat(minSlider.value);
const maxValue = parseFloat(maxSlider.max);
const newValue = Math.max(minValue, Math.min(maxValue, currentValue + delta));
// 保留两位小数避免浮点精度问题
const roundedValue = Math.round(newValue * 100) / 100;
if (roundedValue !== currentValue) {
maxSlider.value = roundedValue;
// 手动触发 input 事件,以确保状态同步和缓存失效
maxSlider.dispatchEvent(new Event('input'));
}
}
/**
* 计算当前应用的最终布尔矩阵遮罩
* @param {boolean} needTopKAxis - 是否需要返回topKAxis信息(用于统计面板显示缺失匹配)
* @returns {Object|Array} - 如果needTopKAxis=true返回{finalMask, topKAxis}, 否则只返回finalMask
*/
function computeCurrentFinalMask(needTopKAxis = false) {
if (!filteredMatrix) {
return needTopKAxis ? { finalMask: null, topKAxis: 'none' } : null;
}
let finalMask = null;
let topKAxis = 'none';
// 情况1: 独占模式
if (globalUIState.exclusiveMode.active && globalUIState.exclusiveMode.editingIndex !== null) {
const editingIndex = globalUIState.exclusiveMode.editingIndex;
const config = allSimilarityResults[editingIndex].visualConfig;
finalMask = config.cachedMasks?.finalMask || computeFinalMaskForMatrix(editingIndex);
if (needTopKAxis) {
topKAxis = config.filters.topK.value > 0 ? config.filters.topK.axis : 'none';
}
}
// 情况2: 应用筛选器模式
else if (globalUIState.filters.activeFilterIndices.length > 0) {
const masks = globalUIState.filters.activeFilterIndices.map(index => {
const config = allSimilarityResults[index].visualConfig;
return config.cachedMasks?.finalMask || computeFinalMaskForMatrix(index);
}).filter(mask => mask !== null);
if (masks.length > 0) {
finalMask = combineWithOR(masks);
}
// 应用临时筛选器 (AND运算)
const tempFilterMask = computeTemporaryFilterMask();
if (tempFilterMask) {
finalMask = finalMask ? combineWithAND(finalMask, tempFilterMask) : tempFilterMask;
if (needTopKAxis) {
topKAxis = globalUIState.temporaryFilter.topK.value > 0
? globalUIState.temporaryFilter.topK.axis
: 'none';
}
}
}
// 情况3: 默认模式
else {
const tempFilterMask = computeTemporaryFilterMask();
if (tempFilterMask) {
finalMask = tempFilterMask;
if (needTopKAxis) {
topKAxis = globalUIState.temporaryFilter.topK.value > 0
? globalUIState.temporaryFilter.topK.axis
: 'none';
}
} else {
const minSim = parseFloat(document.getElementById('minSimilaritySlider')?.value || 0);
const maxSim = parseFloat(document.getElementById('maxSimilaritySlider')?.value || 1);
const topK = parseInt(document.getElementById('topkSlider')?.value || 0);
const axis = currentTopkAxis || 'x';
const thresholdMask = computeThresholdMask(filteredMatrix, minSim, maxSim);
const topKMask = computeTopKMask(filteredMatrix, topK, axis);
finalMask = combineWithAND(thresholdMask, topKMask);
if (needTopKAxis) {
topKAxis = topK > 0 ? axis : 'none';
}
}
}
return needTopKAxis ? { finalMask, topKAxis } : finalMask;
}
// 计算当前显示的对比数 - 新架构:使用布尔矩阵
function getCurrentDisplayCount() {
const finalMask = computeCurrentFinalMask(false);
if (!finalMask) return 0;
// 统计true的数量
let count = 0;
finalMask.forEach(row => {
row.forEach(val => {
if (val === true) {
count++;
}
});
});
return count;
}
// 更新热力图(实时响应控制变化)- 新架构:使用布尔矩阵
// zOnly: 为true时只更新z矩阵(用于筛选器变化),false时更新全部(用于显示字段切换等)
function updateHeatmap(zOnly = true) {
const startTime = performance.now();
if (!filteredMatrix) {
return;
}
console.log('[updateHeatmap] 开始更新 - 使用新布尔矩阵架构' + (zOnly ? ' (zOnly模式)' : ' (完整模式)'));
// 保存当前的缩放和选中状态
let currentLayout = null;
const heatmapDiv = document.getElementById('heatmap');
if (heatmapDiv && heatmapDiv.layout) {
currentLayout = {
xaxis: {
range: heatmapDiv.layout.xaxis.range,
autorange: heatmapDiv.layout.xaxis.autorange
},
yaxis: {
range: heatmapDiv.layout.yaxis.range,
autorange: heatmapDiv.layout.yaxis.autorange
}
};
}
// =====
// 新架构核心逻辑 - 使用公共函数计算最终遮罩
// =====
// 添加日志以保持调试能力
if (globalUIState.exclusiveMode.active && globalUIState.exclusiveMode.editingIndex !== null) {
console.log(`[updateHeatmap] 独占模式 - 使用图 ${globalUIState.exclusiveMode.editingIndex} 的遮罩`);
} else if (globalUIState.filters.activeFilterIndices.length > 0) {
console.log(`[updateHeatmap] 应用筛选器模式 - 合并 ${globalUIState.filters.activeFilterIndices.length} 个图的遮罩`);
} else {
console.log('[updateHeatmap] 默认模式 - 使用临时筛选器');
}
// 使用公共函数计算最终遮罩
const finalMask = computeCurrentFinalMask(false);
// 应用遮罩到原始数据
let displayMatrix = filteredMatrix;
if (finalMask) {
displayMatrix = applyMask(filteredMatrix, finalMask);
}
let displayXLabels = [...currentXLabels];
let displayYLabels = [...currentYLabels];
// 更新热力图数据,但保持当前的缩放状态
updateHeatmapData(displayMatrix, displayXLabels, displayYLabels, currentLayout, zOnly);
// 更新统计信息中的当前显示对比数
updateCurrentDisplayStat();
const elapsed = performance.now() - startTime;
console.log(`[DEBUG] updateHeatmap 总耗时: ${(elapsed / 1000).toFixed(3)}s`);
// zOnly模式时,更新动态防抖延迟
if (zOnly) {
dynamicDebounceDelay = Math.max(MIN_DEBOUNCE_DELAY, Math.ceil(elapsed));
dynamicDebounceDelay = Math.min(MAX_DEBOUNCE_DELAY, Math.ceil(elapsed));
console.log(`[DEBUG] 动态防抖延迟更新为: ${dynamicDebounceDelay}ms`);
}
}
// 更新热力图数据但保持缩放状态
// zOnly: 为true时只更新z矩阵(用于筛选器变化),性能更好
function updateHeatmapData(matrix, xLabels, yLabels, preserveLayout = null, zOnly = true) {
const startTime = performance.now();
let stepTime = performance.now();
if (preserveLayout && zOnly) {
// 快速模式:只更新z矩阵数据
Plotly.restyle('heatmap', {
z: [matrix]
});
console.log(`[DEBUG] updateHeatmapData - Plotly.restyle(zOnly) 耗时: ${((performance.now() - stepTime) / 1000).toFixed(3)}s`);
console.log(`[DEBUG] updateHeatmapData 总耗时: ${((performance.now() - startTime) / 1000).toFixed(3)}s`);
return;
}
// 获取当前模式的相似度范围
const range = getCurrentSimilarityRange();
// 获取显示字段用于坐标轴标题
const xDisplayField = globalUIState.displayFields.xField || 'order_id';
const yDisplayField = globalUIState.displayFields.yField || 'order_id';
// 更新全局完整标签(供 hover 事件使用)
window._heatmapFullLabels = {
x: generateFullLabels(currentXData, xDisplayField),
y: generateFullLabels(currentYData, yDisplayField)
};
if (preserveLayout) {
// 使用 Plotly.restyle 只更新数据,不影响布局
Plotly.restyle('heatmap', {
z: [matrix],
x: [xLabels], // 使用截断标签
y: [yLabels], // 使用截断标签
colorscale: [colorSchemes[currentColorScheme]],
zmin: [range.min],
zmax: [range.max]
});
console.log(`[DEBUG] updateHeatmapData - Plotly.restyle(full) 耗时: ${((performance.now() - stepTime) / 1000).toFixed(3)}s`);
stepTime = performance.now();
// 更新坐标轴标题(动态生成)
// 使用主数据源的 collection 名称
const primaryIndex = globalUIState.dataSource.primaryIndex;
const matrixData = (primaryIndex !== null) ? allSimilarityResults[primaryIndex] : null;
const xCollectionName = matrixData ? matrixData.xCollection : '';
const yCollectionName = matrixData ? matrixData.yCollection : '';
const xFieldName = xDisplayField === 'order_id' ? '顺序ID' : xDisplayField;
const yFieldName = yDisplayField === 'order_id' ? '顺序ID' : yDisplayField;
// 如果需要恢复特定的缩放范围,使用 relayout
const layoutUpdate = {
'xaxis.title': `${xCollectionName} (${xFieldName})`,
'yaxis.title': `${yCollectionName} (${yFieldName})`,
'xaxis.type': 'category', // 强制保持分类类型
'yaxis.type': 'category' // 强制保持分类类型
};
if (preserveLayout.xaxis.range) {
layoutUpdate['xaxis.range'] = preserveLayout.xaxis.range;
layoutUpdate['xaxis.autorange'] = false;
}
if (preserveLayout.yaxis.range) {
layoutUpdate['yaxis.range'] = preserveLayout.yaxis.range;
layoutUpdate['yaxis.autorange'] = false;
}
Plotly.relayout('heatmap', layoutUpdate);
console.log(`[DEBUG] updateHeatmapData - Plotly.relayout 耗时: ${((performance.now() - stepTime) / 1000).toFixed(3)}s`);
} else {
// 如果没有保存的布局状态,使用完整的重新绘制
createHeatmap(matrix, xLabels, yLabels);
console.log(`[DEBUG] updateHeatmapData - createHeatmap 耗时: ${((performance.now() - stepTime) / 1000).toFixed(3)}s`);
}
console.log(`[DEBUG] updateHeatmapData 总耗时: ${((performance.now() - startTime) / 1000).toFixed(3)}s`);
}
// 更新当前显示对比数统计
function updateCurrentDisplayStat() {
const currentDisplayElement = document.getElementById('currentDisplayCount');
if (currentDisplayElement) {
const currentCount = getCurrentDisplayCount();
// 获取总对比数
let totalCount = 0;
if (filteredMatrix && filteredMatrix.length > 0) {
totalCount = filteredMatrix.length * filteredMatrix[0].length;
} else if (globalUIState.dataSource.currentMatrix && globalUIState.dataSource.currentMatrix.length > 0) {
totalCount = globalUIState.dataSource.currentMatrix.length * globalUIState.dataSource.currentMatrix[0].length;
}
// 统一格式: 当前/总数
currentDisplayElement.textContent = `${currentCount} / ${totalCount}`;
}
}
// 获取当前模式下的相似度范围
function getCurrentSimilarityRange() {
return isInDifferenceMode() ? { min: -1, max: 1 } : { min: 0, max: 1 };
}
// 创建热力图
function createHeatmap(matrix = filteredMatrix, xLabels = currentXLabels, yLabels = currentYLabels) {
if (!matrix) {
showError('没有相似度数据');
return;
}
// 获取当前模式的相似度范围
const range = getCurrentSimilarityRange();
// 生成完整标签数组(一维),存入全局变量供 hover 事件使用
const xDisplayField = globalUIState.displayFields.xField || 'order_id';
const yDisplayField = globalUIState.displayFields.yField || 'order_id';
window._heatmapFullLabels = {
x: generateFullLabels(currentXData, xDisplayField),
y: generateFullLabels(currentYData, yDisplayField)
};
const trace = {
z: matrix,
x: xLabels, // 使用截断标签,保持坐标轴自动合并
y: yLabels, // 使用截断标签,保持坐标轴自动合并
type: 'heatmap',
colorscale: colorSchemes[currentColorScheme],
hoverongaps: false,
hoverinfo: 'none', // 禁用默认 hover,使用自定义
colorbar: {
title: isInDifferenceMode() ? '差值' : '相似度',
titleside: 'right',
tickmode: 'linear',
tick0: range.min,
dtick: isInDifferenceMode() ? 0.2 : 0.1
},
showscale: true,
zmin: range.min, // 固定最小值
zmax: range.max // 固定最大值
};
// 获取容器实际尺寸
const container = document.querySelector('.heatmap-container');
const containerRect = container.getBoundingClientRect();
// 计算可用空间(减去padding和controls的高度)
const availableWidth = containerRect.width;
const availableHeight = containerRect.height;
// 动态生成坐标轴标题
// 使用主数据源的 collection 名称
const primaryIndex = globalUIState.dataSource.primaryIndex;
const matrixData = (primaryIndex !== null) ? allSimilarityResults[primaryIndex] : null;
const xCollectionName = matrixData ? matrixData.xCollection : '';
const yCollectionName = matrixData ? matrixData.yCollection : '';
// 注意:xDisplayField 和 yDisplayField 已在函数开头声明,这里直接使用
const xFieldName = xDisplayField === 'order_id' ? '顺序ID' : xDisplayField;
const yFieldName = yDisplayField === 'order_id' ? '顺序ID' : yDisplayField;
const layout = {
title: {
text: '相似度热力图',
font: { size: 14, color: '#404040' },
x: 0.5
},
xaxis: {
title: `${xCollectionName} (${xFieldName})`,
tickangle: -45,
side: 'bottom',
tickfont: { size: 9 },
titlefont: { color: '#404040' },
type: 'category' // 强制使用分类类型,防止Plotly将数字开头的标签误识别为数值/日期
},
yaxis: {
title: `${yCollectionName} (${yFieldName})`,
autorange: 'reversed',
tickfont: { size: 9 },
titlefont: { color: '#404040' },
type: 'category' // 强制使用分类类型,防止Plotly将数字开头的标签误识别为数值/日期
},
hoverlabel: {
bgcolor: 'rgba(255, 255, 255, 0.2)', // 白色背景,透明度90%
bordercolor: '#404040',
font: {
size: 12,
color: '#404040'
}
},
margin: { l: 70, r: 50, t: 50, b: 80 },
width: availableWidth,
height: availableHeight,
autosize: false,
plot_bgcolor: 'white',
paper_bgcolor: 'white'
};
const config = {
responsive: false, // 改为false,使用手动控制尺寸
displayModeBar: true,
modeBarButtonsToRemove: ['pan2d', 'lasso2d', 'select2d'],
displaylogo: false,
scrollZoom: true
};
// 调试:详细检查所有labels
console.log('[DEBUG] createHeatmap - Total labels:', xLabels.length, 'x', yLabels.length);
let invalidXLabels = [];
xLabels.forEach((label, i) => {
if (label === undefined || label === null || (typeof label === 'number' && isNaN(label)) || label === 'NaN') {
invalidXLabels.push({ index: i, value: label, type: typeof label });
}
});
let invalidYLabels = [];
yLabels.forEach((label, i) => {
if (label === undefined || label === null || (typeof label === 'number' && isNaN(label)) || label === 'NaN') {
invalidYLabels.push({ index: i, value: label, type: typeof label });
}
});
if (invalidXLabels.length > 0) {
console.error('[ERROR] Found', invalidXLabels.length, 'invalid xLabels:', invalidXLabels);
}
if (invalidYLabels.length > 0) {
console.error('[ERROR] Found', invalidYLabels.length, 'invalid yLabels:', invalidYLabels);
}
console.log('[DEBUG] xLabels sample (first 5):', xLabels.slice(0, 5));
console.log('[DEBUG] xLabels sample (last 5):', xLabels.slice(-5));
console.log('[DEBUG] yLabels sample (first 5):', yLabels.slice(0, 5));
console.log('[DEBUG] yLabels sample (last 5):', yLabels.slice(-5));
// 特别检查570-580范围
if (xLabels.length > 575) {
console.log('[DEBUG] xLabels[570-580]:', xLabels.slice(570, 581));
}
if (yLabels.length > 575) {
console.log('[DEBUG] yLabels[570-580]:', yLabels.slice(570, 581));
}
console.log('[DEBUG] xaxis type:', layout.xaxis.type);
console.log('[DEBUG] yaxis type:', layout.yaxis.type);
// 使用 Plotly.newPlot 重新创建图表
Plotly.newPlot('heatmap', [trace], layout, config);
// 创建或获取自定义 tooltip 元素
let customTooltip = document.getElementById('heatmap-custom-tooltip');
if (!customTooltip) {
customTooltip = document.createElement('div');
customTooltip.id = 'heatmap-custom-tooltip';
customTooltip.style.cssText = `
position: fixed;
background: rgba(255, 255, 255, 0.95);
border: 1px solid #404040;
border-radius: 4px;
padding: 8px 12px;
font-size: 12px;
color: #404040;
pointer-events: none;
z-index: 10000;
max-width: 400px;
word-wrap: break-word;
display: none;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
`;
document.body.appendChild(customTooltip);
}
// 添加自定义 hover 事件处理
const heatmapDiv = document.getElementById('heatmap');
heatmapDiv.on('plotly_hover', function(data) {
const pt = data.points[0];
const xIdx = pt.pointNumber[1]; // 列索引
const yIdx = pt.pointNumber[0]; // 行索引
const fullLabels = window._heatmapFullLabels;
const xText = fullLabels?.x?.[xIdx] || pt.x;
const yText = fullLabels?.y?.[yIdx] || pt.y;
const similarity = pt.z?.toFixed(4) || 'N/A';
customTooltip.innerHTML = `<b>Y:</b> ${yText}<br><b>X:</b> ${xText}<br><b>相似度:</b> ${similarity}`;
customTooltip.style.display = 'block';
// 定位 tooltip
const rect = heatmapDiv.getBoundingClientRect();
customTooltip.style.left = (data.event.clientX + 15) + 'px';
customTooltip.style.top = (data.event.clientY + 15) + 'px';
});
heatmapDiv.on('plotly_unhover', function() {
customTooltip.style.display = 'none';
});
}
// 调整热力图大小以适应容器变化
function resizeHeatmap() {
const heatmapDiv = document.getElementById('heatmap');
if (!heatmapDiv || !heatmapDiv.data) {
return;
}
// 使用requestAnimationFrame确保在下一次重绘前执行
requestAnimationFrame(() => {
// 获取heatmap-container的实际尺寸
const container = document.querySelector('.heatmap-container');
if (!container) return;
const containerRect = container.getBoundingClientRect();
// 计算可用空间(减去padding和controls的高度)
const availableWidth = containerRect.width;
document.querySelector('.heatmap-controls');
const availableHeight = containerRect.height;
console.log(`[热力图Resize] 容器尺寸: ${containerRect.width}x${containerRect.height}, 可用尺寸: ${availableWidth}x${availableHeight}`);
// 使用update强制更新图表尺寸(同时更新data和layout,解决缩小时不生效的问题)
Plotly.update(heatmapDiv, {}, {
width: availableWidth,
height: availableHeight
}).catch(err => {
console.warn('[热力图Resize] update失败', err);
});
});
}
/**
* 计算非差值模式下的统计信息
* @param {Array<Array<boolean>>} booleanMask - 当前作用于画面的布尔矩阵
* @param {string} topKAxis - Top-K轴向 ('x' 或 'y' 或 'none')
* @returns {Object} - 统计信息对象
*/
function calculateNormalModeStatistics(booleanMask, topKAxis = 'none') {
if (!booleanMask || booleanMask.length === 0) {
return {
totalCount: 0,
currentDisplayCount: 0,
diagonalTrueCount: 0,
diagonalTotalCount: 0,
missingMatchCount: 0
};
}
const rows = booleanMask.length;
const cols = booleanMask[0].length;
// 1. 统计布尔矩阵中true的总数
let totalTrueCount = 0;
for (let i = 0; i < rows; i++) {
for (let j = 0; j < cols; j++) {
if (booleanMask[i][j]) {
totalTrueCount++;
}
}
}
// 2. 统计斜对角线上的true数量 (左上到右下的对角线)
let diagonalTrueCount = 0;
const diagonalTotalCount = Math.min(rows, cols); // 斜对角线总格数
for (let i = 0; i < diagonalTotalCount; i++) {
if (booleanMask[i][i]) {
diagonalTrueCount++;
}
}
// 3. 统计缺失匹配数量
let missingMatchCount = 0;
if (topKAxis === 'x') {
// 横轴Top-K: 统计有几行全是false
for (let i = 0; i < rows; i++) {
let hasTrue = false;
for (let j = 0; j < cols; j++) {
if (booleanMask[i][j]) {
hasTrue = true;
break;
}
}
if (!hasTrue) {
missingMatchCount++;
}
}
} else if (topKAxis === 'y') {
// 纵轴Top-K: 统计有几列全是false
for (let j = 0; j < cols; j++) {
let hasTrue = false;
for (let i = 0; i < rows; i++) {
if (booleanMask[i][j]) {
hasTrue = true;
break;
}
}
if (!hasTrue) {
missingMatchCount++;
}
}
}
const totalCount = rows * cols;
return {
totalCount: totalCount,
currentDisplayCount: totalTrueCount,
diagonalTrueCount: diagonalTrueCount,
diagonalTotalCount: diagonalTotalCount,
missingMatchCount: missingMatchCount
};
}
/**
* 计算差值模式下的统计信息
* @param {Array<Array<boolean>>} groundTruthMask - 被减数图的布尔矩阵(ground truth)
* @param {Array<Array<boolean>>} currentMask - 当前作用于画面的布尔矩阵
* @returns {Object} - 统计信息对象
*/
function calculateDifferenceModeStatistics(groundTruthMask, currentMask) {
if (!groundTruthMask || !currentMask ||
groundTruthMask.length === 0 || currentMask.length === 0) {
return {
truePositive: 0,
trueNegative: 0,
falsePositive: 0,
falseNegative: 0,
contextRecall: 0,
contextPrecision: 0
};
}
const rows = groundTruthMask.length;
const cols = groundTruthMask[0].length;
let truePositive = 0; // ground_truth为1, 当前数据为1
let trueNegative = 0; // ground_truth为0, 当前数据为0
let falsePositive = 0; // ground_truth为0, 当前数据为1
let falseNegative = 0; // ground_truth为1, 当前数据为0
for (let i = 0; i < rows; i++) {
for (let j = 0; j < cols; j++) {
const gtValue = groundTruthMask[i][j];
const currentValue = currentMask[i][j];
if (gtValue && currentValue) {
truePositive++;
} else if (!gtValue && !currentValue) {
trueNegative++;
} else if (!gtValue && currentValue) {
falsePositive++;
} else if (gtValue && !currentValue) {
falseNegative++;
}
}
}
// 计算上下文召回率 = TP / (TP + FN)
const contextRecall = (truePositive + falseNegative) > 0
? truePositive / (truePositive + falseNegative)
: 0;
// 计算上下文精度 = TP / (TP + FP)
const contextPrecision = (truePositive + falsePositive) > 0
? truePositive / (truePositive + falsePositive)
: 0;
return {
truePositive: truePositive,
trueNegative: trueNegative,
falsePositive: falsePositive,
falseNegative: falseNegative,
contextRecall: contextRecall,
contextPrecision: contextPrecision
};
}
// 显示统计信息
function showStatistics(activatePanel = true) {
const statsGrid = document.getElementById('statsGrid');
if (!statsGrid) {
console.warn('statsGrid元素不存在,统计信息面板可能未加载');
return;
}
// 使用公共函数获取当前应用的最终布尔矩阵和topKAxis
const { finalMask, topKAxis } = computeCurrentFinalMask(true);
if (!finalMask) {
statsGrid.innerHTML = '<div style="padding: 10px; text-align: center; color: #999;">暂无统计数据</div>';
return;
}
// 计算统计信息
const stats = calculateNormalModeStatistics(finalMask, topKAxis);
// 构建HTML
let statsHTML = '';
// 1. 当前显示对比数/总对比数
statsHTML += `
<div class="stat-item">
<div class="icon"><i class="bi bi-eyeglasses"></i></div>
<div class="label">当前显示对比数</div>
<div class="value" id="currentDisplayCount">${stats.currentDisplayCount} / ${stats.totalCount}</div>
</div>
`;
// 2. 斜对角线上对比数/斜对角线总格数
statsHTML += `
<div class="stat-item">
<div class="icon"><i class="bi bi-shadows"></i></div>
<div class="label">斜对角线对比数</div>
<div class="value">${stats.diagonalTrueCount} / ${stats.diagonalTotalCount}</div>
</div>
`;
// 3. 缺失匹配 (仅在激活Top-K时显示)
if (topKAxis === 'x' || topKAxis === 'y') {
const axisLabel = topKAxis === 'x' ? '横轴' : '纵轴';
statsHTML += `
<div class="stat-item">
<div class="icon"><i class="bi bi-exclamation-triangle-fill"></i></div>
<div class="label">缺失匹配(${axisLabel})</div>
<div class="value">${stats.missingMatchCount}</div>
</div>
`;
}
statsGrid.innerHTML = statsHTML;
if (activatePanel) {
// 激活统计信息按钮(使用新架构)
const statisticsBtn = document.querySelector('[data-side="right"][data-position="top"][data-panel="statistics"]');
if (statisticsBtn && !statisticsBtn.classList.contains('active')) {
// 取消同位置其他按钮的激活状态
if (activeButtons.right.top && activeButtons.right.top !== statisticsBtn) {
activeButtons.right.top.classList.remove('active');
}
statisticsBtn.classList.add('active');
activeButtons.right.top = statisticsBtn;
// 关键:调用 updateSidebarContent 来移动面板
updateSidebarContent('right', 'top', 'statistics');
}
// 激活筛选器控制按钮(使用新架构)
const filtersBtn = document.querySelector('[data-side="right"][data-position="bottom"][data-panel="filters"]');
if (filtersBtn && !filtersBtn.classList.contains('active')) {
// 取消同位置其他按钮的激活状态
if (activeButtons.right.bottom && activeButtons.right.bottom !== filtersBtn) {
activeButtons.right.bottom.classList.remove('active');
}
filtersBtn.classList.add('active');
activeButtons.right.bottom = filtersBtn;
// 关键:调用 updateSidebarContent 来移动面板
updateSidebarContent('right', 'bottom', 'filters');
}
// 更新右侧边栏状态(打开侧边栏)
updateSidebarState('right');
}
}
// =====
// UI控件初始化
// =====
// 计算上色条的位置和宽度(考虑thumb宽度)
function calculateTrackPosition(minValue, maxValue, minSlider) {
const sliderMin = parseFloat(minSlider.min);
const sliderMax = parseFloat(minSlider.max);
const sliderWidth = minSlider.offsetWidth;
const thumbWidth = 16; // 滑块宽度
const thumbRadius = thumbWidth / 2;
// 如果元素还没有渲染(offsetWidth为0),返回默认值
if (sliderWidth === 0) {
console.warn('Slider width is 0, element not yet rendered');
return {
left: 0,
width: 0
};
}
// 计算值的百分比
const percent1 = (minValue - sliderMin) / (sliderMax - sliderMin);
const percent2 = (maxValue - sliderMin) / (sliderMax - sliderMin);
// 计算thumb中心点的实际像素位置
const availableWidth = sliderWidth - thumbWidth * 2;
const leftPos = thumbRadius + availableWidth * percent1;
const rightPos = thumbRadius + availableWidth * percent2;
return {
left: leftPos + 2,
width: rightPos - leftPos
};
}
// 初始化双滑块
function initRangeSlider() {
const minSlider = document.getElementById('minSimilaritySlider');
const maxSlider = document.getElementById('maxSimilaritySlider');
const minInput = document.getElementById('minSimilarityInput');
const maxInput = document.getElementById('maxSimilarityInput');
const track = document.getElementById('similarityTrack');
// 如果任何必需元素不存在,退出初始化
if (!minSlider || !maxSlider || !minInput || !maxInput || !track) {
console.warn('Range slider elements not found, skipping initialization');
return;
}
function updateTrack() {
const min = parseFloat(minSlider.value);
const max = parseFloat(maxSlider.value);
// 使用辅助函数计算上色条位置
const pos = calculateTrackPosition(min, max, minSlider);
track.style.left = pos.left + 'px';
track.style.width = pos.width + 'px';
minInput.value = min;
maxInput.value = max;
// 更新全局筛选器状态
globalUIState.filters.uiState.similarityRange.min = min;
globalUIState.filters.uiState.similarityRange.max = max;
// *** 根据当前标签页决定保存位置 ***
if (globalUIState.filterTab.activeTab === 'subtract' && globalUIState.dataSource.subtractIndex !== null) {
// 减数图筛选器标签:保存到减数图的visualConfig
const subtractIndex = globalUIState.dataSource.subtractIndex;
const config = allSimilarityResults[subtractIndex].visualConfig;
config.similarityRange.min = min;
config.similarityRange.max = max;
// 标记布尔矩阵缓存失效
if (config.cachedMasks) {
config.cachedMasks.thresholdMask = null;
config.cachedMasks.finalMask = null;
}
console.log(`[减数图筛选器] 保存阈值范围: ${min.toFixed(2)} - ${max.toFixed(2)},缓存已失效`);
} else if (globalUIState.exclusiveMode.active && globalUIState.exclusiveMode.editingIndex !== null) {
// 独占模式:保存到编辑中的图表配置
const config = allSimilarityResults[globalUIState.exclusiveMode.editingIndex].visualConfig;
config.similarityRange.min = min;
config.similarityRange.max = max;
// 标记布尔矩阵缓存失效
if (config.cachedMasks) {
config.cachedMasks.thresholdMask = null;
config.cachedMasks.finalMask = null;
}
console.log(`[独占模式] 保存阈值范围: ${min.toFixed(2)} - ${max.toFixed(2)},缓存已失效`);
} else {
// *** 非独占模式:更新临时筛选器 ***
globalUIState.temporaryFilter.enabled = true;
globalUIState.temporaryFilter.similarityRange.min = min;
globalUIState.temporaryFilter.similarityRange.max = max;
console.log(`[临时筛选器] 更新阈值范围: ${min.toFixed(2)} - ${max.toFixed(2)}`);
}
// 防抖: 延迟100ms更新热力图和统计信息
clearTimeout(heatmapUpdateTimer);
heatmapUpdateTimer = setTimeout(() => {
if (filteredMatrix || globalUIState.dataSource.currentMatrix) {
updateHeatmap();
// 统计信息也使用防抖,在热力图更新后执行
clearTimeout(statsUpdateTimer);
statsUpdateTimer = setTimeout(() => {
if (globalUIState.dataSource.subtractIndex !== null) {
showDifferenceStatistics(false);
} else {
showStatistics(false);
}
}, 50);
}
}, dynamicDebounceDelay);
}
minSlider.addEventListener('input', function () {
if (parseFloat(this.value) > parseFloat(maxSlider.value)) {
this.value = maxSlider.value;
}
updateTrack();
});
maxSlider.addEventListener('input', function () {
if (parseFloat(this.value) < parseFloat(minSlider.value)) {
this.value = minSlider.value;
}
updateTrack();
});
minInput.addEventListener('change', function () {
// 修复:使用 ?? 而非 ||,避免将 0 当作 falsy 值
const inputValue = parseFloat(this.value);
const defaultValue = parseFloat(minSlider.min);
minSlider.value = Math.max(parseFloat(minSlider.min), Math.min(parseFloat(minSlider.max), isNaN(inputValue) ? defaultValue : inputValue));
updateTrack();
});
maxInput.addEventListener('change', function () {
// 修复:使用 ?? 而非 ||,避免将 0 当作 falsy 值
const inputValue = parseFloat(this.value);
const defaultValue = parseFloat(maxSlider.max);
maxSlider.value = Math.max(parseFloat(minSlider.min), Math.min(parseFloat(maxSlider.max), isNaN(inputValue) ? defaultValue : inputValue));
updateTrack();
});
// 初始更新
updateTrack();
// 延迟更新,确保元素完全渲染后重新计算
setTimeout(() => {
if (minSlider.offsetWidth > 0) {
updateTrack();
}
}, 100);
// 暴露updateTrack到全局,以便在侧边栏打开时调用
window.updateSimilarityTrack = updateTrack;
}
// 初始化Top-K滑块
function initTopkSlider() {
const topkSlider = document.getElementById('topkSlider');
const topkValue = document.getElementById('topkValue');
const topkStatus = document.getElementById('topkStatus');
// 如果必需元素不存在,退出初始化
if (!topkSlider || !topkValue || !topkStatus) {
console.warn('Top-K slider elements not found, skipping initialization');
return;
}
function updateTopkDisplayInternal() {
const topkSlider = document.getElementById('topkSlider');
const topkValueEl = document.getElementById('topkValue');
const topkStatusEl = document.getElementById('topkStatus');
if (!topkSlider || !topkValueEl || !topkStatusEl) return;
const topkVal = parseInt(topkSlider.value);
topkValueEl.textContent = topkVal;
topkStatusEl.textContent = topkVal === 0 ? '显示全部' : `显示Top-${topkVal}`;
// 更新全局筛选器状态
globalUIState.filters.uiState.topK.value = topkVal;
// *** 根据当前标签页决定保存位置 ***
if (globalUIState.filterTab.activeTab === 'subtract' && globalUIState.dataSource.subtractIndex !== null) {
// 减数图筛选器标签:保存到减数图的visualConfig
const subtractIndex = globalUIState.dataSource.subtractIndex;
const config = allSimilarityResults[subtractIndex].visualConfig;
config.filters.topK.value = topkVal;
// 标记布尔矩阵缓存失效
if (config.cachedMasks) {
config.cachedMasks.topKMask = null;
config.cachedMasks.finalMask = null;
}
console.log(`[减数图筛选器] 保存Top-K值: ${topkVal},缓存已失效`);
} else if (globalUIState.exclusiveMode.active && globalUIState.exclusiveMode.editingIndex !== null) {
// 独占模式:保存到编辑中的图表配置
const config = allSimilarityResults[globalUIState.exclusiveMode.editingIndex].visualConfig;
config.filters.topK.value = topkVal;
// 标记布尔矩阵缓存失效
if (config.cachedMasks) {
config.cachedMasks.topKMask = null;
config.cachedMasks.finalMask = null;
}
console.log(`[独占模式] 保存Top-K值: ${topkVal},缓存已失效`);
} else {
// *** 非独占模式:更新临时筛选器 ***
globalUIState.temporaryFilter.enabled = true;
globalUIState.temporaryFilter.topK.value = topkVal;
console.log(`[临时筛选器] 更新Top-K值: ${topkVal}`);
}
// 更新按钮状态
updateTopkButtons();
}
// 添加input事件监听器,实现拖动时实时更新热力图(使用防抖优化)
topkSlider.addEventListener('input', function () {
updateTopkDisplayInternal();
// 防抖: 延迟100ms更新热力图和统计信息
clearTimeout(heatmapUpdateTimer);
heatmapUpdateTimer = setTimeout(() => {
if (filteredMatrix || globalUIState.dataSource.currentMatrix) {
updateHeatmap();
// 统计信息也使用防抖,在热力图更新后执行
// 传递 activatePanel=false,只更新数据不打开面板
clearTimeout(statsUpdateTimer);
statsUpdateTimer = setTimeout(() => {
if (globalUIState.dataSource.subtractIndex !== null) {
showDifferenceStatistics(false);
} else {
showStatistics(false);
}
}, 50);
}
}, dynamicDebounceDelay);
});
updateTopkDisplayInternal();
}
// 更新Top-K显示(供外部调用)
function updateTopkDisplay() {
const topkSlider = document.getElementById('topkSlider');
const topkValue = parseInt(topkSlider.value);
document.getElementById('topkValue').textContent = topkValue;
document.getElementById('topkStatus').textContent = topkValue === 0 ? '显示全部' : `显示Top-${topkValue}`;
}
// 初始化颜色方案选择器
function initColorSchemeSelector() {
const colorBtns = document.querySelectorAll('.colorscheme-btn');
colorBtns.forEach(btn => {
btn.addEventListener('click', function () {
// 移除所有active类
colorBtns.forEach(b => b.classList.remove('active'));
// 添加当前active类
this.classList.add('active');
currentColorScheme = this.dataset.scheme;
// 重新绘制热力图
if (filteredMatrix) {
updateHeatmap(false);
}
});
});
}
/**
* 初始化显示字段选择器
*/
function initDisplayFieldSelectors() {
const xDisplayFieldEl = document.getElementById('xDisplayField');
const yDisplayFieldEl = document.getElementById('yDisplayField');
if (!xDisplayFieldEl || !yDisplayFieldEl) {
console.log('[显示字段选择器] 元素不存在,跳过初始化');
return;
}
// X轴显示字段选择器
xDisplayFieldEl.addEventListener('change', function () {
const xField = this.value;
console.log(`[显示字段] X轴字段切换为: ${xField}`);
// 更新全局状态
globalUIState.displayFields.xField = xField;
// *** 只在独占模式下才保存到图表配置 ***
if (globalUIState.exclusiveMode.active && globalUIState.exclusiveMode.editingIndex !== null) {
const config = allSimilarityResults[globalUIState.exclusiveMode.editingIndex].visualConfig;
config.displayFields.xField = xField;
console.log(`[独占模式] 保存X轴显示字段: ${xField}`);
}
// 重新生成X轴标签并更新热力图
if (globalUIState.dataSource.currentXData.length > 0) {
globalUIState.dataSource.currentXLabels = generateUniqueLabels(
globalUIState.dataSource.currentXData,
xField
);
updateHeatmapFromGlobalState(false, false); // zOnly=false 因为需要更新坐标轴标签
}
});
// Y轴显示字段选择器
yDisplayFieldEl.addEventListener('change', function () {
const yField = this.value;
console.log(`[显示字段] Y轴字段切换为: ${yField}`);
// 更新全局状态
globalUIState.displayFields.yField = yField;
// *** 只在独占模式下才保存到图表配置 ***
if (globalUIState.exclusiveMode.active && globalUIState.exclusiveMode.editingIndex !== null) {
const config = allSimilarityResults[globalUIState.exclusiveMode.editingIndex].visualConfig;
config.displayFields.yField = yField;
console.log(`[独占模式] 保存Y轴显示字段: ${yField}`);
}
// 重新生成Y轴标签并更新热力图
if (globalUIState.dataSource.currentYData.length > 0) {
globalUIState.dataSource.currentYLabels = generateUniqueLabels(
globalUIState.dataSource.currentYData,
yField
);
updateHeatmapFromGlobalState(false, false); // zOnly=false 因为需要更新坐标轴标签
}
});
}
// =====
// 导出功能
// =====
// 导出JSON功能
async function exportToJSON() {
if (!allSimilarityResults || allSimilarityResults.length === 0) {
showError('没有可导出的数据,请先计算相似度');
return;
}
try {
// 显示导出状态
const exportBtn = document.getElementById('exportJsonBtn');
exportBtn.textContent = '导出中...';
exportBtn.disabled = true;
// 获取选中要导出的图表索引
const exportSelect = document.getElementById('exportMatrixSelect');
const selectedIndex = parseInt(exportSelect.value);
if (isNaN(selectedIndex) || selectedIndex < 0 || selectedIndex >= allSimilarityResults.length) {
showError('请选择要导出的图表');
return;
}
// 获取选中的图表数据
const selectedResult = allSimilarityResults[selectedIndex];
// 构建导出数据结构
// 注意: 这里导出的是 visualConfig 中的原始数值配置,而不是布尔矩阵
const exportData = {
version: "1.0",
timestamp: new Date().toISOString(),
exportedMatrix: {
index: selectedIndex,
xCollection: selectedResult.xCollection,
yCollection: selectedResult.yCollection,
matrix: selectedResult.matrix, // 原始相似度矩阵
xData: selectedResult.xData,
yData: selectedResult.yData,
xAvailableFields: selectedResult.xAvailableFields,
yAvailableFields: selectedResult.yAvailableFields
// stats字段已移除,统计信息现在实时计算
},
// 导出该图的可视化配置(包含原始的数值配置)
visualConfig: {
displayFields: {
xField: selectedResult.visualConfig.displayFields.xField,
yField: selectedResult.visualConfig.displayFields.yField
},
// 导出原始阈值配置(数值形式)
similarityRange: {
min: selectedResult.visualConfig.similarityRange.min,
max: selectedResult.visualConfig.similarityRange.max
},
// 导出原始 Top-K 配置(数值形式)
filters: {
topK: {
value: selectedResult.visualConfig.filters.topK.value,
axis: selectedResult.visualConfig.filters.topK.axis
}
},
// 导出排序配置
sorting: {
order: selectedResult.visualConfig.sorting.order
}
// 注意: 不导出 cachedMasks (布尔矩阵缓存),因为它们是计算中间结果
}
};
// 转换为JSON字符串
const jsonString = JSON.stringify(exportData, null, 2);
// 生成文件名
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const filename = `相似度分析_图${selectedIndex + 1}_${selectedResult.xCollection}_vs_${selectedResult.yCollection}_${timestamp}.json`;
// 创建Blob并下载
const blob = new Blob([jsonString], { type: 'application/json' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
showSuccess(`JSON文件导出成功:${filename}`);
} catch (error) {
console.error('导出JSON时出错:', error);
showError('导出JSON失败: ' + error.message);
} finally {
// 恢复按钮状态
const exportBtn = document.getElementById('exportJsonBtn');
exportBtn.textContent = '导出JSON';
exportBtn.disabled = false;
}
}
// 导出所有JSON功能
async function exportAllToJSON() {
if (!allSimilarityResults || allSimilarityResults.length === 0) {
showError('没有可导出的数据,请先计算相似度');
return;
}
const exportBtn = document.getElementById('exportAllJsonBtn');
const totalCount = allSimilarityResults.length;
try {
exportBtn.disabled = true;
for (let i = 0; i < totalCount; i++) {
exportBtn.textContent = `导出中 (${i + 1}/${totalCount})...`;
const selectedResult = allSimilarityResults[i];
// 构建导出数据结构(与单个导出一致)
const exportData = {
version: "1.0",
timestamp: new Date().toISOString(),
exportedMatrix: {
index: i,
xCollection: selectedResult.xCollection,
yCollection: selectedResult.yCollection,
matrix: selectedResult.matrix,
xData: selectedResult.xData,
yData: selectedResult.yData,
xAvailableFields: selectedResult.xAvailableFields,
yAvailableFields: selectedResult.yAvailableFields
},
visualConfig: {
displayFields: {
xField: selectedResult.visualConfig.displayFields.xField,
yField: selectedResult.visualConfig.displayFields.yField
},
similarityRange: {
min: selectedResult.visualConfig.similarityRange.min,
max: selectedResult.visualConfig.similarityRange.max
},
filters: {
topK: {
value: selectedResult.visualConfig.filters.topK.value,
axis: selectedResult.visualConfig.filters.topK.axis
}
},
sorting: {
order: selectedResult.visualConfig.sorting.order
}
}
};
const jsonString = JSON.stringify(exportData, null, 2);
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const filename = `相似度分析_图${i + 1}_${selectedResult.xCollection}_vs_${selectedResult.yCollection}_${timestamp}.json`;
const blob = new Blob([jsonString], { type: 'application/json' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
// 下载间隔,避免浏览器拦截
if (i < totalCount - 1) {
await new Promise(resolve => setTimeout(resolve, 300));
}
}
showSuccess(`已成功导出 ${totalCount} 个JSON文件`);
} catch (error) {
console.error('导出所有JSON时出错:', error);
showError('导出JSON失败: ' + error.message);
} finally {
exportBtn.textContent = '导出所有JSON';
exportBtn.disabled = false;
}
}
// =====
// 导入功能
// =====
/**
* 触发文件选择器
*/
function triggerImportJSON() {
const fileInput = document.getElementById('importFileInput');
if (fileInput) {
// 重置文件输入,允许重复导入同一个文件
fileInput.value = '';
fileInput.click();
}
}
/**
* 处理导入的文件(支持多文件)
*/
async function handleImportFile(event) {
const files = Array.from(event.target.files);
if (!files || files.length === 0) {
return;
}
// 检查所有文件类型
const invalidFiles = files.filter(file => !file.name.endsWith('.json'));
if (invalidFiles.length > 0) {
showError(`以下文件不是JSON格式: ${invalidFiles.map(f => f.name).join(', ')}`);
return;
}
try {
// *** 记录导入前的图表数量,用于判断是否首次导入 ***
const isFirstImport = allSimilarityResults.length === 0;
// 用于收集导入结果
const importResults = {
success: [],
failed: []
};
// 逐个处理文件
for (let i = 0; i < files.length; i++) {
const file = files[i];
const fileIndex = i + 1;
const totalFiles = files.length;
try {
showLoading(true, `正在导入第 ${fileIndex}/${totalFiles} 个文件: ${file.name}...`);
// 读取文件内容
const fileContent = await readFileAsText(file);
// 解析JSON
let importedData;
try {
importedData = JSON.parse(fileContent);
} catch (e) {
throw new Error(`JSON格式错误: ${e.message}`);
}
// 验证数据格式
const validationError = validateImportedData(importedData);
if (validationError) {
throw new Error(`数据验证失败: ${validationError}`);
}
// 导入数据到allSimilarityResults
importDataToResults(importedData);
// 初始化新图表的按钮状态
const newIndex = allSimilarityResults.length - 1;
if (!matrixButtonStates[newIndex]) {
matrixButtonStates[newIndex] = {
index: newIndex,
applyData: false,
applyFilter: false,
exclusive: false
};
}
// 记录成功
importResults.success.push({
fileName: file.name,
xCollection: importedData.exportedMatrix.xCollection,
yCollection: importedData.exportedMatrix.yCollection
});
} catch (error) {
console.error(`导入文件 ${file.name} 时出错:`, error);
importResults.failed.push({
fileName: file.name,
error: error.message
});
}
}
// 更新UI (使用新架构的函数)
updateMatrixListUI();
updateExportMatrixSelector();
// 显示图表选择控制区域 (添加空值检查)
const matrixSelectorControl = document.getElementById('matrixSelectorControl');
const displayFieldControls = document.getElementById('displayFieldControls');
const yDisplayFieldControls = document.getElementById('yDisplayFieldControls');
const statsSection = document.getElementById('statsSection');
const operationsSection = document.getElementById('operationsSection');
if (matrixSelectorControl) matrixSelectorControl.style.display = 'block';
if (displayFieldControls) displayFieldControls.style.display = 'block';
if (yDisplayFieldControls) yDisplayFieldControls.style.display = 'block';
if (statsSection) statsSection.style.display = 'block';
if (operationsSection) operationsSection.style.display = 'block';
// *** 首次导入数据时自动启用第一张图的独占模式 ***
if (isFirstImport && allSimilarityResults.length > 0) {
console.log('[自动独占] 首次导入数据,自动启用图0的独占模式');
await enterExclusiveMode(0);
updateMatrixListUI(); // 再次更新UI以反映独占模式状态
}
// 显示导入结果
showLoading(false);
let resultMessage = '';
if (importResults.success.length > 0) {
resultMessage += `成功导入 ${importResults.success.length} 个文件`;
if (importResults.success.length === 1) {
const item = importResults.success[0];
resultMessage = `成功导入数据: ${item.xCollection} vs ${item.yCollection}`;
}
if (isFirstImport) {
resultMessage += ',已自动启用编辑模式';
}
}
if (importResults.failed.length > 0) {
const failedList = importResults.failed.map(item =>
`${item.fileName}: ${item.error}`
).join('\n');
if (importResults.success.length > 0) {
showError(`部分文件导入失败:\n${failedList}\n\n${resultMessage}`);
} else {
showError(`所有文件导入失败:\n${failedList}`);
}
} else {
showSuccess(resultMessage);
}
} catch (error) {
console.error('导入数据时出错:', error);
showError('导入出错: ' + error.message);
showLoading(false);
}
}
/**
* 读取文件为文本
*/
function readFileAsText(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => resolve(e.target.result);
reader.onerror = () => reject(new Error('文件读取失败'));
reader.readAsText(file);
});
}
/**
* 验证导入的数据格式
*/
function validateImportedData(data) {
// 检查版本
if (!data.version) {
return '缺少版本信息';
}
// 检查exportedMatrix
if (!data.exportedMatrix) {
return '缺少exportedMatrix字段';
}
const matrix = data.exportedMatrix;
// 检查必要字段
const requiredFields = [
'xCollection', 'yCollection', 'matrix',
'xData', 'yData',
'xAvailableFields', 'yAvailableFields'
// 注意: stats字段已改为实时计算,不再作为必需字段
];
for (const field of requiredFields) {
if (!matrix[field]) {
return `缺少必要字段: ${field}`;
}
}
// 检查数组类型
if (!Array.isArray(matrix.matrix)) {
return 'matrix必须是数组';
}
if (!Array.isArray(matrix.xData)) {
return 'xData必须是数组';
}
if (!Array.isArray(matrix.yData)) {
return 'yData必须是数组';
}
if (!Array.isArray(matrix.xAvailableFields)) {
return 'xAvailableFields必须是数组';
}
if (!Array.isArray(matrix.yAvailableFields)) {
return 'yAvailableFields必须是数组';
}
// 检查矩阵维度
if (matrix.matrix.length !== matrix.yData.length) {
return `矩阵行数(${matrix.matrix.length})与yData长度(${matrix.yData.length})不匹配`;
}
if (matrix.matrix.length > 0 && matrix.matrix[0].length !== matrix.xData.length) {
return `矩阵列数(${matrix.matrix[0].length})与xData长度(${matrix.xData.length})不匹配`;
}
// 检查visualConfig (可选,如果不存在则使用默认值)
if (data.visualConfig) {
if (!data.visualConfig.displayFields) {
return 'visualConfig缺少displayFields';
}
}
return null; // 验证通过
}
/**
* 将导入的数据添加到allSimilarityResults
*/
function importDataToResults(importedData) {
const matrix = importedData.exportedMatrix;
// 如果有visualConfig就使用,否则创建默认配置
let visualConfig;
if (importedData.visualConfig) {
// 使用导入的配置,但需要补充cachedMasks
visualConfig = {
displayFields: {
xField: importedData.visualConfig.displayFields.xField,
yField: importedData.visualConfig.displayFields.yField
},
similarityRange: {
min: importedData.visualConfig.similarityRange?.min ?? 0,
max: importedData.visualConfig.similarityRange?.max ?? 1
},
filters: {
topK: {
value: importedData.visualConfig.filters?.topK?.value ?? 0,
axis: importedData.visualConfig.filters?.topK?.axis ?? 'x'
}
},
sorting: {
order: importedData.visualConfig.sorting?.order ?? 'none'
},
cachedMasks: {
thresholdMask: null,
topKMask: null,
finalMask: null
}
};
} else {
// 创建默认配置
visualConfig = createDefaultVisualConfig(
matrix.xAvailableFields,
matrix.yAvailableFields
);
}
// 构建结果对象并添加到数组
const resultObject = {
xCollection: matrix.xCollection,
yCollection: matrix.yCollection,
matrix: matrix.matrix,
xData: matrix.xData,
yData: matrix.yData,
xAvailableFields: matrix.xAvailableFields,
yAvailableFields: matrix.yAvailableFields,
// stats字段已移除,统计信息现在实时计算
visualConfig: visualConfig
};
allSimilarityResults.push(resultObject);
}
// =====
// 事件监听器
// =====
// 监听窗口大小变化,重新调整图表大小
window.addEventListener('resize', function () {
if (document.getElementById('heatmap').innerHTML && filteredMatrix) {
// 延迟执行以确保容器尺寸已更新
setTimeout(() => {
resizeHeatmap();
}, 150);
}
});
// =====
// 新架构:核心状态管理函数
// =====
/**
* 初始化按钮状态数组
*/
function initializeButtonStates() {
matrixButtonStates = allSimilarityResults.map((_, index) => ({
index: index,
applyData: false,
applyFilter: false,
exclusive: false
}));
}
/**
* 切换"应用数据"按钮
* @param {number} index - 图表索引
*/
function toggleApplyDataButton(index) {
console.log(`[应用数据] 切换按钮 ${index}`);
const currentState = matrixButtonStates[index].applyData;
if (currentState) {
// 当前已启用,要关闭
// 如果当前是独占模式,先退出
if (globalUIState.exclusiveMode.active) {
exitExclusiveMode();
}
if (globalUIState.dataSource.primaryIndex === index) {
// 关闭主数据源
globalUIState.dataSource.primaryIndex = null;
globalUIState.dataSource.currentMatrix = null;
matrixButtonStates[index].applyData = false;
// 如果有减数,提升为主数据源
if (globalUIState.dataSource.subtractIndex !== null) {
const subtractIdx = globalUIState.dataSource.subtractIndex;
globalUIState.dataSource.primaryIndex = subtractIdx;
globalUIState.dataSource.subtractIndex = null;
loadDataFromMatrix(subtractIdx, false);
// 退出减数模式,隐藏筛选器标签切换
hideFilterTabs();
}
} else if (globalUIState.dataSource.subtractIndex === index) {
// 关闭减数
globalUIState.dataSource.subtractIndex = null;
matrixButtonStates[index].applyData = false;
// 重新加载主数据源(退出差值模式)
loadDataFromMatrix(globalUIState.dataSource.primaryIndex, false);
// 退出减数模式,隐藏筛选器标签切换
hideFilterTabs();
}
} else {
// 当前未启用,要开启 - 先检查矩阵大小一致性
const sizeCheck = checkMatrixSizeConsistency(index, 'applyData');
if (!sizeCheck.isValid) {
console.warn(`[应用数据] 矩阵大小检查失败: ${sizeCheck.message}`);
showWarning(sizeCheck.message, 4000);
return; // 阻止按钮切换,不退出独占模式
}
// 矩阵大小检查通过后,才退出独占模式
if (globalUIState.exclusiveMode.active) {
exitExclusiveMode();
}
if (globalUIState.dataSource.primaryIndex === null) {
// 没有主数据源,设为主数据源
globalUIState.dataSource.primaryIndex = index;
matrixButtonStates[index].applyData = true;
loadDataFromMatrix(index, false);
} else if (globalUIState.dataSource.subtractIndex === null) {
// 已有主数据源,没有减数,设为减数(差值模式)
globalUIState.dataSource.subtractIndex = index;
matrixButtonStates[index].applyData = true;
// 同时激活减数图的筛选器
matrixButtonStates[index].applyFilter = true;
if (!globalUIState.filters.activeFilterIndices.includes(index)) {
globalUIState.filters.activeFilterIndices.push(index);
}
loadDifferenceData(globalUIState.dataSource.primaryIndex, index);
} else {
// 已有主数据源和减数,替换减数
const oldSubtract = globalUIState.dataSource.subtractIndex;
matrixButtonStates[oldSubtract].applyData = false;
// 关闭旧减数的筛选器
matrixButtonStates[oldSubtract].applyFilter = false;
globalUIState.filters.activeFilterIndices = globalUIState.filters.activeFilterIndices.filter(i => i !== oldSubtract);
globalUIState.dataSource.subtractIndex = index;
matrixButtonStates[index].applyData = true;
// 同时激活新减数图的筛选器
matrixButtonStates[index].applyFilter = true;
if (!globalUIState.filters.activeFilterIndices.includes(index)) {
globalUIState.filters.activeFilterIndices.push(index);
}
loadDifferenceData(globalUIState.dataSource.primaryIndex, index);
}
}
updateMatrixListUI();
// 切换图表时只更新数据,不主动打开统计面板
updateHeatmapFromGlobalState(false, false); // zOnly=false 因为切换图表需要更新坐标轴等
}
/**
* 从矩阵加载数据到全局状态
* @param {number} index - 图表索引
* @param {boolean} isDifference - 是否为差值模式
*/
function loadDataFromMatrix(index, isDifference = false) {
console.log(`[加载数据] 从图 ${index} 加载,差值模式: ${isDifference}`);
const matrixData = allSimilarityResults[index];
// 加载原始数据
globalUIState.dataSource.currentMatrix = matrixData.matrix;
globalUIState.dataSource.currentXData = matrixData.xData;
globalUIState.dataSource.currentYData = matrixData.yData;
globalUIState.dataSource.xAvailableFields = matrixData.xAvailableFields;
globalUIState.dataSource.yAvailableFields = matrixData.yAvailableFields;
// 更新显示字段(使用该图的配置)
const defaultXField = matrixData.visualConfig.displayFields.xField;
const defaultYField = matrixData.visualConfig.displayFields.yField;
globalUIState.displayFields.xField = defaultXField;
globalUIState.displayFields.yField = defaultYField;
// 生成标签
globalUIState.dataSource.currentXLabels = generateUniqueLabels(
globalUIState.dataSource.currentXData,
globalUIState.displayFields.xField
);
globalUIState.dataSource.currentYLabels = generateUniqueLabels(
globalUIState.dataSource.currentYData,
globalUIState.displayFields.yField
);
// 更新显示字段选择器
updateDisplayFieldSelectorsFromGlobalState();
}
/**
* 加载差值数据
* @param {number} primaryIndex - 主数据源索引
* @param {number} subtractIndex - 减数索引
*/
function loadDifferenceData(primaryIndex, subtractIndex) {
console.log(`[差值模式] 主图: ${primaryIndex}, 减数图: ${subtractIndex}`);
const cacheKey = `${primaryIndex}-${subtractIndex}`;
// 检查缓存
if (!differenceMatrices[cacheKey]) {
const matrix1 = allSimilarityResults[primaryIndex].matrix;
const matrix2 = allSimilarityResults[subtractIndex].matrix;
// 检查矩阵大小是否一致
if (matrix1.length !== matrix2.length || matrix1[0].length !== matrix2[0].length) {
showError('无法计算差值:两个矩阵大小不一致');
return;
}
differenceMatrices[cacheKey] = matrix1.map((row, i) =>
row.map((val, j) => val - matrix2[i][j])
);
}
// 使用主数据源的元数据,但矩阵是差值
const primaryData = allSimilarityResults[primaryIndex];
globalUIState.dataSource.currentMatrix = differenceMatrices[cacheKey];
globalUIState.dataSource.currentXData = primaryData.xData;
globalUIState.dataSource.currentYData = primaryData.yData;
globalUIState.dataSource.xAvailableFields = primaryData.xAvailableFields;
globalUIState.dataSource.yAvailableFields = primaryData.yAvailableFields;
// 更新显示字段(使用主图的配置)
const primaryXField = primaryData.visualConfig.displayFields.xField;
const primaryYField = primaryData.visualConfig.displayFields.yField;
globalUIState.displayFields.xField = primaryXField;
globalUIState.displayFields.yField = primaryYField;
// 生成标签
globalUIState.dataSource.currentXLabels = generateUniqueLabels(
globalUIState.dataSource.currentXData,
globalUIState.displayFields.xField
);
globalUIState.dataSource.currentYLabels = generateUniqueLabels(
globalUIState.dataSource.currentYData,
globalUIState.displayFields.yField
);
// 更新显示字段选择器
updateDisplayFieldSelectorsFromGlobalState();
// *** 重置临时筛选器(差值模式)***
resetTemporaryFilter(true);
applyTemporaryFilterToUI();
// *** 差值模式:将阈值重置为 -1 到 1 ***
globalUIState.filters.uiState.similarityRange.min = -1;
globalUIState.filters.uiState.similarityRange.max = 1;
// 获取UI控件
const minSlider = document.getElementById('minSimilaritySlider');
const maxSlider = document.getElementById('maxSimilaritySlider');
const minInput = document.getElementById('minSimilarityInput');
const maxInput = document.getElementById('maxSimilarityInput');
// *** 重要:先更新滑块的min/max范围,再设置value ***
minSlider.min = -1;
minSlider.max = 1;
maxSlider.min = -1;
maxSlider.max = 1;
minInput.min = -1;
minInput.max = 1;
maxInput.min = -1;
maxInput.max = 1;
// 然后再设置滑块的值
minSlider.value = -1;
maxSlider.value = 1;
minInput.value = -1;
maxInput.value = 1;
// 更新滑块轨道
const track = document.getElementById('similarityTrack');
const pos = calculateTrackPosition(-1, 1, minSlider);
track.style.left = pos.left + 'px';
track.style.width = pos.width + 'px';
console.log('[差值模式] 阈值已重置为 -1.00 到 1.00');
// 显示筛选器标签切换
showFilterTabs();
}
// ============================================================================
// 筛选器标签页切换功能
// ============================================================================
/**
* 显示筛选器标签页切换按钮(在减数模式下调用)
*/
function showFilterTabs() {
const header = document.getElementById('filterTabsHeader');
if (header) {
header.style.display = '';
console.log('[筛选器标签] 显示标签切换');
}
}
/**
* 隐藏筛选器标签页切换按钮并切回主筛选器
*/
function hideFilterTabs() {
const header = document.getElementById('filterTabsHeader');
if (header) {
header.style.display = 'none';
console.log('[筛选器标签] 隐藏标签切换');
}
// 切回主筛选器标签
if (globalUIState.filterTab.activeTab !== 'main') {
switchFilterTab('main');
}
}
/**
* 切换筛选器标签页
* @param {'main' | 'subtract'} tab - 目标标签
*/
function switchFilterTab(tab) {
console.log(`[筛选器标签] 切换到: ${tab}`);
globalUIState.filterTab.activeTab = tab;
// 更新按钮样式
const mainBtn = document.getElementById('mainFilterTab');
const subtractBtn = document.getElementById('subtractFilterTab');
if (mainBtn) mainBtn.classList.toggle('active', tab === 'main');
if (subtractBtn) subtractBtn.classList.toggle('active', tab === 'subtract');
// 更新提示文字
const hint = document.getElementById('filterTabHint');
if (hint) {
if (tab === 'main') {
hint.textContent = '当前作用于: 显示数据';
} else {
const subtractIndex = globalUIState.dataSource.subtractIndex;
const matrixName = allSimilarityResults[subtractIndex]?.name || `图表 ${subtractIndex + 1}`;
hint.textContent = `当前作用于: ${matrixName} (减数图)`;
}
}
// 控制显示字段选择器的显隐(减数图标签下隐藏)
const displayFieldControls = document.getElementById('displayFieldControls');
const yDisplayFieldControls = document.getElementById('yDisplayFieldControls');
if (tab === 'main') {
// 主筛选器:恢复显示字段选择器(如果有数据)
if (displayFieldControls && globalUIState.dataSource.xAvailableFields?.length > 0) {
displayFieldControls.style.display = '';
}
if (yDisplayFieldControls && globalUIState.dataSource.yAvailableFields?.length > 0) {
yDisplayFieldControls.style.display = '';
}
} else {
// 减数图筛选器:隐藏显示字段选择器
if (displayFieldControls) displayFieldControls.style.display = 'none';
if (yDisplayFieldControls) yDisplayFieldControls.style.display = 'none';
}
// 加载对应标签的筛选器值到UI
loadFilterValuesToUI(tab);
}
/**
* 根据当前标签加载筛选器值到UI控件
* @param {'main' | 'subtract'} tab
*/
function loadFilterValuesToUI(tab) {
let similarityRange, topK;
if (tab === 'main') {
// 主筛选器:使用当前逻辑(独占模式用编辑图配置,否则用临时筛选器或uiState)
if (globalUIState.exclusiveMode.active && globalUIState.exclusiveMode.editingIndex !== null) {
const config = allSimilarityResults[globalUIState.exclusiveMode.editingIndex].visualConfig;
similarityRange = config.similarityRange;
topK = config.filters?.topK || { value: 0, axis: 'x' };
} else {
// 使用临时筛选器状态
similarityRange = globalUIState.temporaryFilter.similarityRange;
topK = globalUIState.temporaryFilter.topK;
}
// 差值模式下,主筛选器使用 -1 到 1 范围
const isDiff = globalUIState.dataSource.subtractIndex !== null;
updateSimilaritySliderRange(isDiff ? -1 : 0, 1);
} else {
// 减数图筛选器:使用减数图的visualConfig
const subtractIndex = globalUIState.dataSource.subtractIndex;
if (subtractIndex === null) {
console.warn('[筛选器标签] 减数图标签下没有减数图');
return;
}
const config = allSimilarityResults[subtractIndex].visualConfig;
similarityRange = config.similarityRange;
topK = config.filters?.topK || { value: 0, axis: 'x' };
// 减数图筛选器使用 0 到 1 范围
updateSimilaritySliderRange(0, 1);
}
// 更新UI控件值
const minSlider = document.getElementById('minSimilaritySlider');
const maxSlider = document.getElementById('maxSimilaritySlider');
const minInput = document.getElementById('minSimilarityInput');
const maxInput = document.getElementById('maxSimilarityInput');
const topkSlider = document.getElementById('topkSlider');
const topkValueSpan = document.getElementById('topkValue');
const topkStatus = document.getElementById('topkStatus');
if (minSlider && maxSlider && minInput && maxInput) {
minSlider.value = similarityRange.min;
maxSlider.value = similarityRange.max;
minInput.value = similarityRange.min;
maxInput.value = similarityRange.max;
// 更新轨道
const track = document.getElementById('similarityTrack');
if (track) {
const pos = calculateTrackPosition(similarityRange.min, similarityRange.max, minSlider);
track.style.left = pos.left + 'px';
track.style.width = pos.width + 'px';
}
}
if (topkSlider && topkValueSpan) {
const topkVal = topK.value ?? 0;
topkSlider.value = topkVal;
topkValueSpan.textContent = topkVal;
if (topkStatus) {
topkStatus.textContent = topkVal === 0 ? '显示全部' : `每行/列前${topkVal}项`;
}
}
// 更新Top-K轴按钮
updateTopKAxisButtons(topK.axis ?? 'x');
}
/**
* 更新相似度滑块的范围
* @param {number} minRange - 最小值范围(如 0 或 -1)
* @param {number} maxRange - 最大值范围(如 1
*/
function updateSimilaritySliderRange(minRange, maxRange) {
const minSlider = document.getElementById('minSimilaritySlider');
const maxSlider = document.getElementById('maxSimilaritySlider');
const minInput = document.getElementById('minSimilarityInput');
const maxInput = document.getElementById('maxSimilarityInput');
if (minSlider) {
minSlider.min = minRange;
minSlider.max = maxRange;
}
if (maxSlider) {
maxSlider.min = minRange;
maxSlider.max = maxRange;
}
if (minInput) {
minInput.min = minRange;
minInput.max = maxRange;
}
if (maxInput) {
maxInput.min = minRange;
maxInput.max = maxRange;
}
}
/**
* 更新Top-K轴按钮的激活状态
* @param {'x' | 'y'} axis
*/
function updateTopKAxisButtons(axis) {
const xBtn = document.getElementById('xAxisBtn');
const yBtn = document.getElementById('yAxisBtn');
if (xBtn) xBtn.classList.toggle('active', axis === 'x');
if (yBtn) yBtn.classList.toggle('active', axis === 'y');
}
/**
* 切换"应用筛选器"按钮
* @param {number} index - 图表索引
*/
function toggleApplyFilterButton(index) {
console.log(`[应用筛选器] 切换按钮 ${index}`);
const currentState = matrixButtonStates[index].applyFilter;
if (currentState) {
// 当前已启用,要关闭
// 如果当前是独占模式,先退出
if (globalUIState.exclusiveMode.active) {
exitExclusiveMode();
}
matrixButtonStates[index].applyFilter = false;
globalUIState.filters.activeFilterIndices = globalUIState.filters.activeFilterIndices.filter(i => i !== index);
} else {
// 当前未启用,要开启 - 先检查矩阵大小一致性
const sizeCheck = checkMatrixSizeConsistency(index, 'applyFilter');
if (!sizeCheck.isValid) {
console.warn(`[应用筛选器] 矩阵大小检查失败: ${sizeCheck.message}`);
showWarning(sizeCheck.message, 4000);
return; // 阻止按钮切换,不退出独占模式
}
// 矩阵大小检查通过后,才退出独占模式
if (globalUIState.exclusiveMode.active) {
exitExclusiveMode();
}
matrixButtonStates[index].applyFilter = true;
globalUIState.filters.activeFilterIndices.push(index);
}
// 合并筛选器(或逻辑)
mergeFilters();
updateMatrixListUI();
// 切换图表时只更新数据,不主动打开统计面板
updateHeatmapFromGlobalState(false, false); // zOnly=false 因为切换图表需要更新坐标轴等
}
/**
* 合并多个筛选器(或逻辑)- 新架构:基于布尔矩阵
*
* 注意:这个函数现在主要用于更新UI显示
* 实际的OR合并在updateHeatmap()中通过combineWithOR()完成
*/
function mergeFilters() {
console.log(`[合并筛选器] 活动筛选器数量: ${globalUIState.filters.activeFilterIndices.length}`);
if (globalUIState.filters.activeFilterIndices.length === 0) {
// 没有活动筛选器,使用默认值
globalUIState.filters.uiState.similarityRange = { min: 0, max: 1 };
globalUIState.filters.uiState.topK = { value: 0, axis: 'x' };
} else if (globalUIState.filters.activeFilterIndices.length === 1) {
// 只有一个筛选器,直接使用
const index = globalUIState.filters.activeFilterIndices[0];
const config = allSimilarityResults[index].visualConfig;
globalUIState.filters.uiState.similarityRange = { ...config.similarityRange };
globalUIState.filters.uiState.topK = { ...config.filters.topK };
} else {
// 多个筛选器:为了UI显示友好,显示合并后的范围
// 但实际筛选逻辑在updateHeatmap()中基于布尔矩阵OR运算完成
let minRange = 1, maxRange = 0;
let maxTopK = 0;
let topKAxis = 'x';
globalUIState.filters.activeFilterIndices.forEach(index => {
const config = allSimilarityResults[index].visualConfig;
minRange = Math.min(minRange, config.similarityRange.min);
maxRange = Math.max(maxRange, config.similarityRange.max);
if (config.filters.topK.value > maxTopK) {
maxTopK = config.filters.topK.value;
topKAxis = config.filters.topK.axis;
}
});
globalUIState.filters.uiState.similarityRange = { min: minRange, max: maxRange };
globalUIState.filters.uiState.topK = { value: maxTopK, axis: topKAxis };
console.log(`[合并筛选器] UI显示范围: 阈值 ${minRange.toFixed(2)}-${maxRange.toFixed(2)}, Top-K ${maxTopK}`);
}
// 更新UI控件(仅用于显示)
applyFilterStateToUI();
}
/**
* 切换"独占模式"按钮
* @param {number} index - 图表索引
*/
async function toggleExclusiveModeButton(index) {
console.log(`[独占模式] 切换按钮 ${index}`);
const currentState = matrixButtonStates[index].exclusive;
if (currentState) {
// 当前已是独占模式,退出
exitExclusiveMode();
} else {
// 进入独占模式
await enterExclusiveMode(index);
}
updateMatrixListUI();
}
/**
* 进入独占模式
* @param {number} index - 图表索引
*/
async function enterExclusiveMode(index) {
console.log(`[独占模式] 进入,编辑图 ${index}`);
// 关闭所有其他按钮
matrixButtonStates.forEach((state, i) => {
if (i !== index) {
state.applyData = false;
state.applyFilter = false;
state.exclusive = false;
}
});
// 启用当前图的两个按钮
matrixButtonStates[index].applyData = true;
matrixButtonStates[index].applyFilter = true;
matrixButtonStates[index].exclusive = true;
// 设置全局状态
globalUIState.exclusiveMode.active = true;
globalUIState.exclusiveMode.editingIndex = index;
// 清空数据源和筛选器
globalUIState.dataSource.primaryIndex = index;
globalUIState.dataSource.subtractIndex = null;
globalUIState.filters.activeFilterIndices = [index];
// 进入独占模式时隐藏筛选器标签切换(退出减数模式)
hideFilterTabs();
// 加载该图的所有配置
loadDataFromMatrix(index, false);
// *** 重置临时筛选器(单图模式)***
resetTemporaryFilter(false);
applyTemporaryFilterToUI();
// 应用该图的筛选器配置
const config = allSimilarityResults[index].visualConfig;
globalUIState.filters.uiState.similarityRange = { ...config.similarityRange };
globalUIState.filters.uiState.topK = { ...config.filters.topK };
globalUIState.sorting.order = config.sorting.order;
// 更新UI
applyFilterStateToUI();
// 自动选中导出下拉框中的对应图表
const exportSelect = document.getElementById('exportMatrixSelect');
if (exportSelect) {
exportSelect.value = index.toString();
}
// 显示提示
showInfo('已进入独占编辑模式,您的修改将保存到此图的配置中', 3000);
// *** 修复Bug: 等待热力图更新完成 ***
// 进入独占模式时只更新数据,不主动打开统计面板
await updateHeatmapFromGlobalState(false, false); // zOnly=false 因为进入独占模式需要更新坐标轴等
}
/**
* 退出独占模式
*/
function exitExclusiveMode() {
if (!globalUIState.exclusiveMode.active) return;
const editingIndex = globalUIState.exclusiveMode.editingIndex;
console.log(`[独占模式] 退出,保存图 ${editingIndex} 的配置`);
// 保存当前UI状态到图表配置
if (editingIndex !== null && allSimilarityResults[editingIndex]) {
const config = allSimilarityResults[editingIndex].visualConfig;
// 保存显示字段
config.displayFields.xField = globalUIState.displayFields.xField;
config.displayFields.yField = globalUIState.displayFields.yField;
// 保存筛选器
config.similarityRange = { ...globalUIState.filters.uiState.similarityRange };
config.filters.topK = { ...globalUIState.filters.uiState.topK };
// 保存排序
config.sorting.order = globalUIState.sorting.order;
console.log(`[独占模式] 已保存配置:`, config);
}
// 重置独占模式状态
globalUIState.exclusiveMode.active = false;
globalUIState.exclusiveMode.editingIndex = null;
matrixButtonStates.forEach(state => {
state.exclusive = false;
});
showSuccess('已保存配置并退出独占模式', 2000);
}
/**
* 应用筛选器状态到UI控件
*/
function applyFilterStateToUI() {
const range = globalUIState.filters.uiState.similarityRange;
const topK = globalUIState.filters.uiState.topK;
// 更新相似度范围
const minSlider = document.getElementById('minSimilaritySlider');
const maxSlider = document.getElementById('maxSimilaritySlider');
const minInput = document.getElementById('minSimilarityInput');
const maxInput = document.getElementById('maxSimilarityInput');
minSlider.value = range.min;
maxSlider.value = range.max;
minInput.value = range.min;
maxInput.value = range.max;
// 更新滑块轨道
const track = document.getElementById('similarityTrack');
const pos = calculateTrackPosition(range.min, range.max, minSlider);
track.style.left = pos.left + 'px';
track.style.width = pos.width + 'px';
// 更新Top-K
document.getElementById('topkSlider').value = topK.value;
currentTopkAxis = topK.axis;
updateTopkDisplay();
// 更新轴选择按钮
document.getElementById('xAxisBtn').classList.toggle('active', topK.axis === 'x');
document.getElementById('yAxisBtn').classList.toggle('active', topK.axis === 'y');
}
/**
* 更新显示字段选择器
*/
function updateDisplayFieldSelectorsFromGlobalState() {
const xFields = globalUIState.dataSource.xAvailableFields;
const yFields = globalUIState.dataSource.yAvailableFields;
// 显示控件(添加空值检查)
const displayFieldControls = document.getElementById('displayFieldControls');
const yDisplayFieldControls = document.getElementById('yDisplayFieldControls');
if (displayFieldControls) displayFieldControls.style.display = 'block';
if (yDisplayFieldControls) yDisplayFieldControls.style.display = 'block';
// 更新X轴选择器
const xSelect = document.getElementById('xDisplayField');
xSelect.innerHTML = '';
xFields.forEach(field => {
const option = new Option(
field === 'order_id' ? '顺序ID' : field,
field
);
xSelect.add(option);
});
xSelect.value = globalUIState.displayFields.xField;
// 更新Y轴选择器
const ySelect = document.getElementById('yDisplayField');
ySelect.innerHTML = '';
yFields.forEach(field => {
const option = new Option(
field === 'order_id' ? '顺序ID' : field,
field
);
ySelect.add(option);
});
ySelect.value = globalUIState.displayFields.yField;
}
// 新增:显示差值统计信息
function showDifferenceStatistics(activatePanel = true) {
const statsGrid = document.getElementById('statsGrid');
if (!statsGrid) {
console.warn('statsGrid元素不存在,统计信息面板可能未加载');
return;
}
// 获取被减数(ground truth)和减数的布尔矩阵
const primaryIndex = globalUIState.dataSource.primaryIndex;
const subtractIndex = globalUIState.dataSource.subtractIndex;
if (primaryIndex === null || subtractIndex === null) {
statsGrid.innerHTML = '<div style="padding: 10px; text-align: center; color: #999;">差值模式数据不完整</div>';
return;
}
// 获取被减数的最终遮罩(ground truth)
const primaryConfig = allSimilarityResults[primaryIndex].visualConfig;
const groundTruthMask = primaryConfig.cachedMasks?.finalMask || computeFinalMaskForMatrix(primaryIndex);
// 获取当前应用的最终布尔矩阵
let currentMask;
// 如果启用了临时筛选器,应用临时筛选器
const tempFilterMask = computeTemporaryFilterMask();
if (tempFilterMask) {
currentMask = tempFilterMask;
} else {
// 否则使用减数的最终遮罩
const subtractConfig = allSimilarityResults[subtractIndex].visualConfig;
currentMask = subtractConfig.cachedMasks?.finalMask || computeFinalMaskForMatrix(subtractIndex);
}
if (!groundTruthMask || !currentMask) {
statsGrid.innerHTML = '<div style="padding: 10px; text-align: center; color: #999;">无法计算统计数据</div>';
return;
}
// 计算差值模式统计信息
const stats = calculateDifferenceModeStatistics(groundTruthMask, currentMask);
// 构建HTML
let statsHTML = '';
// 1. True Positive
statsHTML += `
<div class="stat-item">
<div class="icon"><i class="bi bi-check-circle-fill text-success"></i></div>
<div class="label">True Positive</div>
<div class="value">${stats.truePositive}</div>
</div>
`;
// 2. True Negative
statsHTML += `
<div class="stat-item">
<div class="icon"><i class="bi bi-circle text-secondary"></i></div>
<div class="label">True Negative</div>
<div class="value">${stats.trueNegative}</div>
</div>
`;
// 3. False Positive
statsHTML += `
<div class="stat-item">
<div class="icon"><i class="bi bi-exclamation-circle-fill text-warning"></i></div>
<div class="label">False Positive</div>
<div class="value">${stats.falsePositive}</div>
</div>
`;
// 4. False Negative
statsHTML += `
<div class="stat-item">
<div class="icon"><i class="bi bi-x-circle-fill text-danger"></i></div>
<div class="label">False Negative</div>
<div class="value">${stats.falseNegative}</div>
</div>
`;
// 5. 上下文召回率
statsHTML += `
<div class="stat-item">
<div class="icon"><i class="bi bi-arrow-left-circle-fill"></i></div>
<div class="label">上下文召回率</div>
<div class="value">${(stats.contextRecall * 100).toFixed(2)}%</div>
</div>
`;
// 6. 上下文精度
statsHTML += `
<div class="stat-item">
<div class="icon"><i class="bi bi-crosshair2"></i></div>
<div class="label">上下文精度</div>
<div class="value">${(stats.contextPrecision * 100).toFixed(2)}%</div>
</div>
`;
statsGrid.innerHTML = statsHTML;
if (activatePanel) {
// 激活统计信息按钮(使用新架构)
const statisticsBtn = document.querySelector('[data-side="right"][data-position="top"][data-panel="statistics"]');
if (statisticsBtn && !statisticsBtn.classList.contains('active')) {
// 取消同位置其他按钮的激活状态
if (activeButtons.right.top && activeButtons.right.top !== statisticsBtn) {
activeButtons.right.top.classList.remove('active');
}
statisticsBtn.classList.add('active');
activeButtons.right.top = statisticsBtn;
// 关键:调用 updateSidebarContent 来移动面板
updateSidebarContent('right', 'top', 'statistics');
}
// 激活筛选器控制按钮(使用新架构)
const filtersBtn = document.querySelector('[data-side="right"][data-position="bottom"][data-panel="filters"]');
if (filtersBtn && !filtersBtn.classList.contains('active')) {
// 取消同位置其他按钮的激活状态
if (activeButtons.right.bottom && activeButtons.right.bottom !== filtersBtn) {
activeButtons.right.bottom.classList.remove('active');
}
filtersBtn.classList.add('active');
activeButtons.right.bottom = filtersBtn;
// 关键:调用 updateSidebarContent 来移动面板
updateSidebarContent('right', 'bottom', 'filters');
}
// 更新右侧边栏状态(打开侧边栏)
updateSidebarState('right');
}
}
/**
* 从全局状态更新热力图
* @param {boolean} activatePanel - 是否激活面板
* @param {boolean} zOnly - 是否只更新z矩阵(用于筛选器变化),false时更新全部(用于显示字段切换等)
*/
async function updateHeatmapFromGlobalState(activatePanel = true, zOnly = true) {
if (!globalUIState.dataSource.currentMatrix) {
console.log('[热力图] 没有数据源,跳过更新');
return;
}
console.log('[热力图] 从全局状态更新');
// 使用全局状态的数据
filteredMatrix = globalUIState.dataSource.currentMatrix;
currentXData = globalUIState.dataSource.currentXData;
currentYData = globalUIState.dataSource.currentYData;
currentXLabels = globalUIState.dataSource.currentXLabels;
currentYLabels = globalUIState.dataSource.currentYLabels;
xAvailableFields = globalUIState.dataSource.xAvailableFields;
yAvailableFields = globalUIState.dataSource.yAvailableFields;
// 更新相似度滑块范围(差值模式需要 -1 到 1)
updateSimilaritySliderRangeForGlobalState();
// 切换图表时更新Top-K滑块最大值
if (!zOnly) {
updateTopkSliderMax();
}
// *** 修复Bug1: 先显示统计信息和激活右侧栏,再绘制热力图 ***
if (globalUIState.dataSource.primaryIndex !== null) {
if (globalUIState.dataSource.subtractIndex !== null) {
showDifferenceStatistics(activatePanel);
} else {
showStatistics(activatePanel);
}
}
// 等待DOM更新完成(右侧栏动画完成)
await new Promise(resolve => setTimeout(resolve, 50));
// 创建或更新热力图(此时容器大小已经是正确的)
const isFirstTimeCreating = document.getElementById('heatmap').innerHTML === '';
if (isFirstTimeCreating) {
createHeatmap();
// *** 修复Bug: 首次创建热力图后,立即调用updateHeatmap以应用筛选器 ***
// createHeatmap()只是绘制完整图形,筛选器需要通过updateHeatmap()应用
console.log('[热力图] 首次创建完成,立即应用筛选器');
updateHeatmap(zOnly);
} else {
updateHeatmap(zOnly);
}
// 显示操作区域 - 面板会在showStatistics中自动打开
// 不需要显式控制section的display,因为它们在面板内部
}
/**
* 更新相似度滑块范围(根据是否差值模式)
*/
function updateSimilaritySliderRangeForGlobalState() {
const isDiff = globalUIState.dataSource.subtractIndex !== null;
const range = isDiff ? { min: -1, max: 1 } : { min: 0, max: 1 };
const minSlider = document.getElementById('minSimilaritySlider');
const maxSlider = document.getElementById('maxSimilaritySlider');
const minInput = document.getElementById('minSimilarityInput');
const maxInput = document.getElementById('maxSimilarityInput');
minSlider.min = range.min;
minSlider.max = range.max;
maxSlider.min = range.min;
maxSlider.max = range.max;
minInput.min = range.min;
minInput.max = range.max;
maxInput.min = range.min;
maxInput.max = range.max;
}
/**
* 检查是否为差值模式
*/
function isInDifferenceMode() {
return globalUIState.dataSource.subtractIndex !== null;
}
/**
* 更新图表列表UI(新架构:竖排列表 + 三按钮)
*/
function updateMatrixListUI() {
const container = document.getElementById('matrixButtonTable');
if (!container) return;
// 清空现有内容
container.innerHTML = '';
// 生成竖排列表
allSimilarityResults.forEach((result, index) => {
const state = matrixButtonStates[index];
// 创建行容器
const row = document.createElement('div');
row.className = 'matrix-list-item';
row.dataset.index = index;
// 确定边框颜色和状态图标
let borderClass = '';
let badgeIcon;
let badgeClass;
if (state.exclusive) {
// 独占模式 - 紫色边框 + 准星角标
borderClass = 'border-exclusive';
badgeIcon = 'bi-crosshair';
badgeClass = 'badge-crosshair';
} else if (globalUIState.dataSource.primaryIndex === index && globalUIState.dataSource.subtractIndex === null) {
// 主数据源 - 绿色边框 + 实心圆圈角标
borderClass = 'border-primary2';
badgeIcon = 'bi-circle-fill';
badgeClass = 'badge-circle';
} else if (globalUIState.dataSource.primaryIndex === index && globalUIState.dataSource.subtractIndex !== null) {
// 被减数 - 绿色边框 + 实心加号角标
borderClass = 'border-primary2';
badgeIcon = 'bi-plus-circle-fill';
badgeClass = 'badge-plus';
} else if (globalUIState.dataSource.subtractIndex === index) {
// 减数 - 红色边框 + 实心减号角标
borderClass = 'border-subtract';
badgeIcon = 'bi-dash-circle-fill';
badgeClass = 'badge-minus';
} else {
// 无状态 - 默认边框 + 灰色方形角标
badgeIcon = 'bi-bookmark';
badgeClass = 'badge-none';
}
if (borderClass) row.classList.add(borderClass);
// 创建header容器(包含状态图标、标题和按钮)
const header = document.createElement('div');
header.className = 'matrix-item-header';
// 左侧容器(状态图标 + 标题)
const leftContainer = document.createElement('div');
leftContainer.className = 'matrix-item-left';
// 状态图标
const badge = document.createElement('i');
badge.className = `matrix-item-badge ${badgeClass} ${badgeIcon}`;
leftContainer.appendChild(badge);
// 图表标题 - 三行结构
const title = document.createElement('div');
title.className = 'matrix-item-title';
const xName = document.createElement('div');
xName.className = 'collection-name';
xName.textContent = truncateCollectionName(result.xCollection, 20);
xName.title = result.xCollection; // 完整名称作为tooltip
const vsText = document.createElement('div');
vsText.className = 'vs-text';
vsText.textContent = 'vs';
const yName = document.createElement('div');
yName.className = 'collection-name';
yName.textContent = truncateCollectionName(result.yCollection, 20);
yName.title = result.yCollection; // 完整名称作为tooltip
title.appendChild(xName);
title.appendChild(vsText);
title.appendChild(yName);
leftContainer.appendChild(title);
// 按钮容器
const buttonsContainer = document.createElement('div');
buttonsContainer.className = 'matrix-item-buttons';
// 按钮1:应用数据 - 实心眼睛图标
const dataBtn = document.createElement('button');
dataBtn.className = 'matrix-control-btn btn-apply-data';
dataBtn.innerHTML = '<i class="bi-eye-fill"></i>';
dataBtn.title = '应用数据';
if (state.applyData) dataBtn.classList.add('active');
dataBtn.onclick = () => toggleApplyDataButton(index);
// 按钮2:应用筛选器 - 筛选图标
const filterBtn = document.createElement('button');
filterBtn.className = 'matrix-control-btn btn-apply-filter';
filterBtn.innerHTML = '<i class="bi-funnel-fill"></i>';
filterBtn.title = '应用筛选器';
if (state.applyFilter) filterBtn.classList.add('active');
filterBtn.onclick = () => toggleApplyFilterButton(index);
// 按钮3:独占模式 - 准星图标
const exclusiveBtn = document.createElement('button');
exclusiveBtn.className = 'matrix-control-btn btn-exclusive';
exclusiveBtn.innerHTML = '<i class="bi-crosshair"></i>';
exclusiveBtn.title = '独占模式';
if (state.exclusive) exclusiveBtn.classList.add('active');
exclusiveBtn.onclick = () => toggleExclusiveModeButton(index);
// 组装
buttonsContainer.appendChild(dataBtn);
buttonsContainer.appendChild(filterBtn);
buttonsContainer.appendChild(exclusiveBtn);
header.appendChild(leftContainer);
header.appendChild(buttonsContainer);
row.appendChild(header);
container.appendChild(row);
});
}
/**
* 更新导出图表选择器
*/
function updateExportMatrixSelector() {
const select = document.getElementById('exportMatrixSelect');
if (!select) return;
// 清空现有选项
select.innerHTML = '';
if (!allSimilarityResults || allSimilarityResults.length === 0) {
const option = document.createElement('option');
option.value = '';
option.textContent = '请先计算相似度';
select.appendChild(option);
return;
}
// 为每个图表添加选项
allSimilarityResults.forEach((result, index) => {
const option = document.createElement('option');
option.value = index;
option.textContent = `图 ${index + 1}: ${result.xCollection} vs ${result.yCollection}`;
select.appendChild(option);
});
// 默认选中第一个
if (allSimilarityResults.length > 0) {
select.value = '0';
}
}
// 页面加载时初始化
// 注意: 大部分控件初始化已经移到rebindEventHandlers中,在侧边栏内容加载时才执行
document.addEventListener('DOMContentLoaded', function () {
// 仅初始化全局控件(不在侧边栏中的)
initColorSchemeSelector();
// 注意: 以下初始化已移到rebindEventHandlers中:
// - initRangeSlider() -> 在'filters'面板加载时
// - initTopkSlider() -> 在'filters'面板加载时
// - initSortSelector() -> 在'chartControl'面板加载时
// - initDisplayFieldSelectors() -> 在'chartControl'面板加载时
// - loadCollections() -> 在'dataSource'面板加载时
});