Files
labweb/public/js/index.js
2025-12-16 11:39:15 +08:00

765 lines
26 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// API Base URL (统一使用相对路径,避免跨域问题)
const API_BASE_URL = '/api';
// 数据存储
let taxonomyData = {};
let physchemData = {};
let nutritionData = [];
let cultureData = [];
let currentTab = 'phylum';
let maxValue = 50000;
let currentPhyschemTab = 'o2';
// ECharts 实例存储
let pieChart = null;
let phChart = null;
let tempChart = null;
// --- 数据加载函数 ---
/**
// * 加载分类学数据
// * @param {string} level - 分类级别 (如 phylum, class 等)
// * @returns {Array} 处理后的分类学数据
// */
// async function loadTaxonomyData(level) {
// try {
// const response = await fetch(`${API_BASE_URL}/taxonomy-stats?level=${level}`);
// if (!response.ok) {
// throw new Error(`加载失败: HTTP 状态码 ${response.status}`);
// }
// const result = await response.json();
// if (result.success) {
// taxonomyData[level] = {
// data: result.data,
// total: result.total
// };
// return result.data;
// } else {
// console.error('加载分类学数据失败:', result.message);
// showErrorNotification('分类学数据加载失败');
// return [];
// }
// } catch (error) {
// console.error('加载分类学数据时出错:', error);
// showErrorNotification('加载数据时发生错误,请稍后重试');
// return [];
// }
// }
/**
* 加载分类学数据
* @param {string} level - 分类级别 (如 phylum, class 等)
* @param {string} culturedType - 培养类型 (默认: cultured可选: uncultured 等)
* @returns {Array} 处理后的分类学数据
*/
async function loadTaxonomyData(level, culturedType = 'cultured') {
try {
// 1. 拼接完整请求URL补充 cultured_type 参数(和后端默认值一致)
const params = new URLSearchParams({
level: level || 'phylum', // 后端默认 phylum
cultured_type: culturedType // 后端默认 cultured
});
const response = await fetch(`${API_BASE_URL}/taxonomy-stats?${params.toString()}`);
if (!response.ok) {
throw new Error(`加载失败: HTTP 状态码 ${response.status}`);
}
const result = await response.json();
// 2. 处理后端返回:区分成功/失败场景
if (result.success) {
// 后端返回 { success: true, data: [], total: 0 }
taxonomyData[level] = {
data: result.data || [], // 兜底空数组避免undefined
total: result.total || 0 // 兜底0避免NaN
};
return result.data || [];
} else if (result.error) {
// 后端返回 { error: "xxx" }500错误时
console.error('加载分类学数据失败:', result.error);
showErrorNotification(`分类学数据加载失败: ${result.error}`);
taxonomyData[level] = { data: [], total: 0 }; // 兜底空数据
return [];
} else {
// 未知返回格式
console.error('加载分类学数据失败: 未知返回格式', result);
showErrorNotification('分类学数据加载失败: 返回格式异常');
taxonomyData[level] = { data: [], total: 0 };
return [];
}
} catch (error) {
// 网络错误/HTTP状态码错误如404、500
console.error('加载分类学数据时出错:', error);
showErrorNotification(`加载数据时发生错误: ${error.message},请稍后重试`);
taxonomyData[level] = { data: [], total: 0 }; // 兜底空数据
return [];
}
}
/**
* 加载理化数据
* @param {string} type - 数据类型 (o2, ph, temperature)
* @returns {Object|null} 处理后的理化数据
*/
async function loadPhyschemData(type) {
try {
const response = await fetch(`${API_BASE_URL}/physchem-stats?type=${type}`);
if (!response.ok) {
throw new Error(`加载失败: HTTP 状态码 ${response.status}`);
}
const result = await response.json();
if (result.success) {
physchemData[type] = result.data;
return result.data;
} else {
console.error('加载理化数据失败:', result.message);
showErrorNotification('理化数据加载失败');
return null;
}
} catch (error) {
console.error('加载理化数据时出错:', error);
showErrorNotification('加载数据时发生错误,请稍后重试');
return null;
}
}
/**
* 加载营养数据
* @returns {Array} 处理后的营养数据
*/
async function loadNutritionData() {
try {
const response = await fetch(`${API_BASE_URL}/nutrition-stats`);
if (!response.ok) {
throw new Error(`加载失败: HTTP 状态码 ${response.status}`);
}
const result = await response.json();
if (result.success) {
nutritionData = result.data || [];
return result.data || [];
} else {
console.error('加载营养数据失败:', result.message);
showErrorNotification('营养数据加载失败');
return [];
}
} catch (error) {
console.error('加载营养数据时出错:', error);
showErrorNotification('加载数据时发生错误,请稍后重试');
return [];
}
}
// --- 图表渲染函数 ---
/**
* 更新分类学图表
* @param {string} tabName - 分类级别
*/
async function updateTaxonomyChart(tabName) {
currentTab = tabName;
const badge = document.getElementById('taxonomyTotalBadge');
const content = document.getElementById('taxonomyChartContent');
// 显示加载状态
if (badge) badge.textContent = 'Loading...';
if (content) content.innerHTML = '<div style="text-align: center; padding: 50px; color: #666;">Loading data...</div>';
// 加载数据(如果未缓存)
if (!taxonomyData[tabName]) {
await loadTaxonomyData(tabName);
}
const data = taxonomyData[tabName]?.data || [];
const total = taxonomyData[tabName]?.total || 0;
// 处理无数据情况
if (!data || data.length === 0) {
console.warn(`分类级别 ${tabName} 无数据`);
if (badge) badge.textContent = 'Total taxa: 0';
if (content) content.innerHTML = '<div style="text-align: center; padding: 50px; color: #666;">No data available</div>';
return;
}
// 更新总数显示
if (badge) badge.textContent = `Total taxa: ${total}`;
maxValue = Math.max(...data.map(item => item.value)) * 1.1;
// 渲染图表内容
if (content) {
const availableWidth = Math.max(content.offsetWidth - 220, 600);
content.innerHTML = data.map(item => {
const percentage = (item.value / maxValue) * 100;
const lineWidth = Math.max(1, (percentage / 100) * availableWidth);
return `
<div class="taxonomy-chart-row">
<div class="taxonomy-taxa-name" title="${item.name}">${item.name}</div>
<div class="taxonomy-chart-area">
<div class="taxonomy-chart-line" style="width: ${lineWidth}px;"></div>
<div class="taxonomy-chart-dot"
style="left: ${lineWidth}px;"
data-name="${item.name}"
data-value="${item.value.toLocaleString()}"></div>
</div>
</div>
`;
}).join('');
}
// 使用 requestAnimationFrame 确保 DOM 已更新
requestAnimationFrame(() => {
generateTaxonomyGridLines();
bindTaxonomyTooltipEvents();
});
}
/**
* 生成分类学图表网格线
*/
function generateTaxonomyGridLines() {
const gridLines = document.getElementById('taxonomyGridLines');
const xAxisLabels = document.getElementById('taxonomyXAxisLabels');
if (!gridLines || !xAxisLabels) return;
const firstChartArea = document.querySelector('.taxonomy-chart-area');
const chartWidth = firstChartArea ? firstChartArea.offsetWidth : 600;
gridLines.innerHTML = '';
xAxisLabels.innerHTML = '';
const maxVal = Math.round(maxValue);
let gridCount = 5;
let step;
// 动态计算步长,确保坐标轴标签合理
if (maxVal <= 100) {
step = Math.ceil(maxVal / 5);
} else if (maxVal <= 1000) {
step = Math.ceil(maxVal / 5 / 10) * 10;
} else if (maxVal <= 10000) {
step = Math.ceil(maxVal / 5 / 100) * 100;
} else {
step = Math.ceil(maxVal / 5 / 1000) * 1000;
}
// 生成网格线和标签
for (let i = 0; i <= gridCount; i++) {
const position = (i / gridCount) * chartWidth;
const gridLine = document.createElement('div');
gridLine.className = 'taxonomy-grid-line';
gridLine.style.left = `${position}px`;
gridLines.appendChild(gridLine);
const label = document.createElement('span');
label.textContent = (i * step).toLocaleString();
xAxisLabels.appendChild(label);
}
}
/**
* 绑定分类学图表提示框事件
*/
function bindTaxonomyTooltipEvents() {
const dots = document.querySelectorAll('.taxonomy-chart-dot');
const tooltip = document.getElementById('taxonomyTooltip');
if (!tooltip) return;
dots.forEach(dot => {
dot.addEventListener('mouseenter', (e) => {
const name = e.target.getAttribute('data-name');
const value = e.target.getAttribute('data-value');
tooltip.innerHTML = `${name}<br>Strains: ${value}`;
tooltip.style.display = 'block';
});
dot.addEventListener('mousemove', (e) => {
tooltip.style.left = `${e.pageX + 10}px`;
tooltip.style.top = `${e.pageY - 40}px`;
});
dot.addEventListener('mouseleave', () => {
tooltip.style.display = 'none';
});
});
}
/**
* 初始化分类学标签切换事件
*/
function initializeTaxonomyTabEvents() {
document.querySelectorAll('.taxonomy-container .taxonomy-tab-item').forEach(tab => {
tab.addEventListener('click', async (e) => {
// 防止重复点击同一标签
if (e.target.classList.contains('active')) return;
document.querySelectorAll('.taxonomy-container .taxonomy-tab-item')
.forEach(t => t.classList.remove('active'));
e.target.classList.add('active');
const tabName = e.target.getAttribute('data-tab');
await updateTaxonomyChart(tabName);
});
});
}
/**
* 初始化分类学图表
*/
async function initializeTaxonomy() {
initializeTaxonomyTabEvents();
await updateTaxonomyChart('phylum');
}
/**
* 更新理化性质图表
* @param {string} tabName - 图表类型 (o2, ph, temperature)
*/
async function updatePhyschemChart(tabName) {
currentPhyschemTab = tabName;
const badge = document.getElementById('physchem-total-badge');
// 显示加载状态
if (badge) badge.textContent = 'Loading...';
// 加载数据(如果未缓存)
if (!physchemData[tabName]) {
await loadPhyschemData(tabName);
}
const data = physchemData[tabName];
// 处理无数据情况
if (!data) {
if (badge) badge.textContent = 'No data available';
return;
}
// 隐藏所有图表区域,显示当前激活的
document.querySelectorAll('.physchem-container .chart-area')
.forEach(area => area.classList.remove('active'));
// 氧气需求饼图
if (tabName === 'o2') {
const chartArea = document.getElementById('chart-oxygen-pie');
if (chartArea) chartArea.classList.add('active');
if (badge) badge.textContent = `Total count: ${data.total || 0}`;
// 初始化 ECharts 实例(确保 echarts 存在且 DOM 元素有效)
if (window.echarts && !pieChart) {
const dom = document.getElementById('chart-oxygen-pie');
if (dom) {
pieChart = echarts.init(dom);
// 监听窗口大小变化,自动调整图表
window.addEventListener('resize', () => pieChart?.resize());
}
}
if (pieChart) {
const pieOption = {
tooltip: {
trigger: 'item',
formatter: '{b}: {d}%',
renderMode: 'html'
},
legend: {
orient: 'horizontal',
left: 'center',
top: '2%',
data: ['Aerobe', 'Anaerobe', 'Facultative anaerobe'],
textStyle: { fontSize: 18 }
},
series: [{
name: 'O2 Requirements',
type: 'pie',
radius: ['40%', '70%'],
center: ['45%', '50%'],
label: {
show: true,
formatter: '{d}%',
fontSize: 18,
fontWeight: 'bold'
},
labelLine: {
show: true,
length: 1,
length2: 10,
smooth: true
},
data: [
{ value: data.aerobe, name: 'Aerobe', itemStyle: { color: '#CCCC99' } },
{ value: data.anaerobe, name: 'Anaerobe', itemStyle: { color: '#336666' } },
{ value: data.facultative, name: 'Facultative anaerobe', itemStyle: { color: '#669999' } }
]
}]
};
pieChart.setOption(pieOption);
requestAnimationFrame(() => pieChart.resize());
}
}
// pH 直方图
else if (tabName === 'ph') {
const chartArea = document.getElementById('chart-ph-hist');
if (chartArea) chartArea.classList.add('active');
const totalCount = data.reduce((sum, item) => sum + item.count, 0);
if (badge) badge.textContent = `Total count: ${totalCount}`;
// 初始化 ECharts 实例
if (window.echarts && !phChart) {
const dom = document.getElementById('chart-ph-hist');
if (dom) {
phChart = echarts.init(dom);
window.addEventListener('resize', () => phChart?.resize());
}
}
if (phChart) {
const phOption = {
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
grid: {
left: '3%',
right: '4%',
bottom: '15%',
top: '8%',
containLabel: true
},
xAxis: {
type: 'category',
data: data.map(item => item.x),
name: 'pH',
nameLocation: 'middle',
nameGap: 40
},
yAxis: { type: 'value', name: 'Count' },
series: [{
type: 'bar',
data: data.map(item => item.count),
itemStyle: { color: '#669999', borderRadius: [4, 4, 0, 0] }
}]
};
phChart.setOption(phOption);
requestAnimationFrame(() => phChart.resize());
}
}
// 温度直方图
else if (tabName === 'temperature') {
const chartArea = document.getElementById('chart-temp-hist');
if (chartArea) chartArea.classList.add('active');
const totalCount = data.reduce((sum, item) => sum + item.count, 0);
if (badge) badge.textContent = `Total count: ${totalCount}`;
// 初始化 ECharts 实例
if (window.echarts && !tempChart) {
const dom = document.getElementById('chart-temp-hist');
if (dom) {
tempChart = echarts.init(dom);
window.addEventListener('resize', () => tempChart?.resize());
}
}
if (tempChart) {
const tempOption = {
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
grid: {
left: '3%',
right: '4%',
bottom: '18%',
top: '8%',
containLabel: true
},
xAxis: {
type: 'category',
data: data.map(item => item.x),
name: 'Temperature (°C)',
nameLocation: 'middle',
nameGap: 30
},
yAxis: { type: 'value', name: 'Count' },
series: [{
type: 'bar',
data: data.map(item => item.count),
itemStyle: { color: '#CCCC99', borderRadius: [4, 4, 0, 0] }
}]
};
tempChart.setOption(tempOption);
requestAnimationFrame(() => tempChart.resize());
}
}
}
/**
* 初始化理化性质标签切换事件
*/
function initializePhyschemTabEvents() {
document.querySelectorAll('#physchem-tabs .taxonomy-tab-item').forEach(tab => {
tab.addEventListener('click', async (e) => {
if (e.target.classList.contains('active')) return;
document.querySelectorAll('#physchem-tabs .taxonomy-tab-item')
.forEach(t => t.classList.remove('active'));
e.target.classList.add('active');
const tabName = e.target.getAttribute('data-tab');
await updatePhyschemChart(tabName);
});
});
}
/**
* 更新营养图表
*/
async function updateNutritionChart() {
const badge = document.getElementById('nutrition-total-badge');
const content = document.getElementById('nutrition-chart-content');
// 显示加载状态
if (badge) badge.textContent = 'Loading...';
if (content) content.innerHTML = '<div style="text-align: center; padding: 50px; color: #666;">Loading data...</div>';
// 加载数据(如果未缓存)
if (nutritionData.length === 0) {
await loadNutritionData();
}
const data = nutritionData || [];
const total = data.length;
// 更新总数显示
if (badge) badge.textContent = `Total nutrients: ${total}`;
// 处理无数据情况
if (!data || !Array.isArray(data) || data.length === 0) {
if (content) content.innerHTML = '<div style="text-align: center; padding: 50px; color: #666;">No data available</div>';
return;
}
const maxValue = data.length > 0 && data.every(item => item && typeof item.value !== 'undefined')
? Math.max(...data.map(item => item.value || 0)) * 1.1
: 1;
// 渲染图表内容
if (content) {
const availableWidth = Math.max(content.offsetWidth - 220, 600);
content.innerHTML = data.map(item => {
const percentage = (item.value / maxValue) * 100;
const lineWidth = Math.max(1, (percentage / 100) * availableWidth);
return `
<div class="nutrition-chart-row">
<div class="nutrition-taxa-name" title="${item.name}">${item.name}</div>
<div class="nutrition-chart-area">
<div class="nutrition-chart-line" style="width: ${lineWidth}px;"></div>
<div class="nutrition-chart-dot"
style="left: ${lineWidth}px;"
data-name="${item.name}"
data-value="${item.value.toLocaleString()}"></div>
</div>
</div>
`;
}).join('');
}
requestAnimationFrame(() => {
generateNutritionGridLines();
bindNutritionTooltipEvents();
});
}
/**
* 生成营养图表网格线
*/
function generateNutritionGridLines() {
const gridLines = document.getElementById('nutrition-grid-lines');
const xAxisLabels = document.getElementById('nutrition-x-axis-labels');
if (!gridLines || !xAxisLabels) return;
const firstChartArea = document.querySelector('.nutrition-chart-area');
const chartWidth = firstChartArea ? firstChartArea.offsetWidth : 600;
const maxNutritionValue = nutritionData.length > 0
? Math.max(...nutritionData.map(item => item.value)) * 1.001
: 100;
const maxVal = Math.round(maxNutritionValue);
gridLines.innerHTML = '';
xAxisLabels.innerHTML = '';
const gridCount = 5;
const rawStep = maxVal / gridCount;
const magnitude = Math.pow(10, Math.floor(Math.log10(rawStep)));
const normalizedStep = rawStep / magnitude;
// 动态计算步长,确保坐标轴标签美观
let multiplier;
if (normalizedStep <= 1) multiplier = 1;
else if (normalizedStep <= 2) multiplier = 2;
else if (normalizedStep <= 5) multiplier = 5;
else multiplier = 10;
let step = multiplier * magnitude;
const minStep = Math.pow(10, Math.floor(Math.log10(maxVal)) - 1);
if (step < minStep) step = minStep;
// 生成网格线和标签
for (let i = 0; i <= gridCount; i++) {
const position = (i / gridCount) * chartWidth;
const gridLine = document.createElement('div');
gridLine.className = 'nutrition-grid-line';
gridLine.style.left = `${position}px`;
gridLines.appendChild(gridLine);
const label = document.createElement('span');
label.textContent = (i * step).toLocaleString();
xAxisLabels.appendChild(label);
}
}
/**
* 绑定营养图表提示框事件
*/
function bindNutritionTooltipEvents() {
const dots = document.querySelectorAll('.nutrition-chart-dot');
const tooltip = document.getElementById('nutrition-tooltip');
if (!tooltip) return;
dots.forEach(dot => {
dot.addEventListener('mouseenter', (e) => {
const name = e.target.getAttribute('data-name');
const value = e.target.getAttribute('data-value');
tooltip.innerHTML = `${name}<br>Frequency: ${value}`;
tooltip.style.display = 'block';
});
dot.addEventListener('mousemove', (e) => {
tooltip.style.left = `${e.pageX + 10}px`;
tooltip.style.top = `${e.pageY - 40}px`;
});
dot.addEventListener('mouseleave', () => {
tooltip.style.display = 'none';
});
});
}
/**
* 显示错误通知
* @param {string} message - 错误信息
*/
function showErrorNotification(message) {
// 检查是否已有错误通知元素
let notification = document.getElementById('error-notification');
if (!notification) {
// 创建错误通知元素
notification = document.createElement('div');
notification.id = 'error-notification';
notification.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
padding: 15px 20px;
background-color: #e74c3c;
color: white;
border-radius: 4px;
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
z-index: 1000;
transition: opacity 0.3s ease;
`;
document.body.appendChild(notification);
}
// 设置错误信息并显示
notification.textContent = message;
notification.style.opacity = '1';
// 3秒后自动隐藏
setTimeout(() => {
notification.style.opacity = '0';
}, 3000);
}
// --- 初始化函数 ---
/**
* 初始化所有图表和事件
*/
async function initializeAll() {
try {
await Promise.all([
initializeTaxonomy(),
(async () => {
initializePhyschemTabEvents();
await updatePhyschemChart('o2');
})(),
updateNutritionChart()
]);
} catch (error) {
console.error('初始化失败:', error);
showErrorNotification('页面初始化失败,请刷新页面重试');
}
}
/**
* 执行 banner 搜索
*/
function performBannerSearch() {
const searchInput = document.getElementById('banner-search-input');
if (!searchInput) {
showErrorNotification('搜索框未找到');
return;
}
const searchValue = searchInput.value.trim();
if (searchValue) {
const encodedValue = encodeURIComponent(searchValue);
window.location.href = `/Search_result.html?microbial=${encodedValue}`;
} else {
alert('Please enter a microbial name to search.');
}
}
// --- 事件监听 ---
document.addEventListener('DOMContentLoaded', () => {
// 初始化仪表盘逻辑
initializeAll();
// 初始化搜索逻辑
const searchBtn = document.getElementById('banner-search-btn');
if (searchBtn) {
searchBtn.addEventListener('click', performBannerSearch);
}
// 为搜索框添加回车事件
const searchInput = document.getElementById('banner-search-input');
if (searchInput) {
searchInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
performBannerSearch();
}
});
}
});