// 知识库测试页面 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: '',
templateId: 'tpl-dataSource'
},
operations: {
title: '结果操作',
templateId: 'tpl-operations'
},
chartControl: {
title: '图表选择与控制',
titleButton: '',
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
? `
${data.title}
${data.titleButton}
`
: `${data.title}
`;
// 获取模板内容
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
标签连接各行
return lines.join('
');
}
// =====
// 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 = '';
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>} matrix - 原始相似度矩阵
* @param {number} minSim - 最小相似度阈值
* @param {number} maxSim - 最大相似度阈值
* @returns {Array>} - 布尔矩阵,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>} matrix - 原始相似度矩阵
* @param {number} topK - Top-K值,0表示全部显示
* @param {string} axis - 'x' 或 'y'
* @returns {Array>} - 布尔矩阵,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>} mask1 - 第一个布尔矩阵
* @param {Array>} mask2 - 第二个布尔矩阵
* @returns {Array>} - 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>>} masks - 多个布尔矩阵
* @returns {Array>} - 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>} originalMatrix - 原始数据矩阵
* @param {Array>} mask - 布尔遮罩
* @returns {Array>} - 应用遮罩后的矩阵
*/
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>|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>|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 = `Y: ${yText}
X: ${xText}
相似度: ${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>} 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>} groundTruthMask - 被减数图的布尔矩阵(ground truth)
* @param {Array>} 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 = '暂无统计数据
';
return;
}
// 计算统计信息
const stats = calculateNormalModeStatistics(finalMask, topKAxis);
// 构建HTML
let statsHTML = '';
// 1. 当前显示对比数/总对比数
statsHTML += `
当前显示对比数
${stats.currentDisplayCount} / ${stats.totalCount}
`;
// 2. 斜对角线上对比数/斜对角线总格数
statsHTML += `
斜对角线对比数
${stats.diagonalTrueCount} / ${stats.diagonalTotalCount}
`;
// 3. 缺失匹配 (仅在激活Top-K时显示)
if (topKAxis === 'x' || topKAxis === 'y') {
const axisLabel = topKAxis === 'x' ? '横轴' : '纵轴';
statsHTML += `
缺失匹配(${axisLabel})
${stats.missingMatchCount}
`;
}
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 = '差值模式数据不完整
';
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 = '无法计算统计数据
';
return;
}
// 计算差值模式统计信息
const stats = calculateDifferenceModeStatistics(groundTruthMask, currentMask);
// 构建HTML
let statsHTML = '';
// 1. True Positive
statsHTML += `
True Positive
${stats.truePositive}
`;
// 2. True Negative
statsHTML += `
True Negative
${stats.trueNegative}
`;
// 3. False Positive
statsHTML += `
False Positive
${stats.falsePositive}
`;
// 4. False Negative
statsHTML += `
False Negative
${stats.falseNegative}
`;
// 5. 上下文召回率
statsHTML += `
上下文召回率
${(stats.contextRecall * 100).toFixed(2)}%
`;
// 6. 上下文精度
statsHTML += `
上下文精度
${(stats.contextPrecision * 100).toFixed(2)}%
`;
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 = '';
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 = '';
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 = '';
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'面板加载时
});