first commit
This commit is contained in:
975
utils/mediaModel.js
Normal file
975
utils/mediaModel.js
Normal file
@@ -0,0 +1,975 @@
|
||||
// mediaModel.js
|
||||
const { pool } = require('../config/db'); // 假设你的db配置路径
|
||||
|
||||
// --- 内部辅助函数:构建 WHERE 子句 ---
|
||||
// 这个函数被 browse, sunburst, sankey 多个接口复用
|
||||
const buildWhereClause = (filters) => {
|
||||
let whereConditions = [];
|
||||
let queryParams = [];
|
||||
const { search, ph, temperature, o2, taxonomy, taxonomyValue, microbial, cultured_type } = filters;
|
||||
|
||||
// 1. Cultured Type
|
||||
if (cultured_type) {
|
||||
whereConditions.push('m.cultured_type = ?');
|
||||
queryParams.push(cultured_type);
|
||||
}
|
||||
|
||||
// 2. Search (通用搜索)
|
||||
if (search) {
|
||||
whereConditions.push('(m.microbial_name LIKE ? OR m.taxonomy LIKE ?)');
|
||||
queryParams.push(`%${search}%`, `%${search}%`);
|
||||
}
|
||||
|
||||
// 3. pH Range (处理预测值逻辑)
|
||||
if (ph) {
|
||||
const phExpr = `TRIM(CASE
|
||||
WHEN m.predict_pH IS NOT NULL AND m.predict_pH != '' THEN
|
||||
SUBSTRING(m.predict_pH, 1, CASE WHEN LOCATE(' ', REVERSE(m.predict_pH)) > 0 THEN LENGTH(m.predict_pH) - LOCATE(' ', REVERSE(m.predict_pH)) ELSE LENGTH(m.predict_pH) END)
|
||||
ELSE NULL
|
||||
END)`;
|
||||
if (ph.includes('-')) {
|
||||
const [min, max] = ph.split('-').map(Number);
|
||||
whereConditions.push(`${phExpr} IS NOT NULL AND CAST(${phExpr} AS DECIMAL(4,2)) >= ? AND CAST(${phExpr} AS DECIMAL(4,2)) <= ?`);
|
||||
queryParams.push(min, max);
|
||||
} else {
|
||||
const val = parseFloat(ph);
|
||||
whereConditions.push(`${phExpr} IS NOT NULL AND CAST(${phExpr} AS DECIMAL(4,2)) >= ? AND CAST(${phExpr} AS DECIMAL(4,2)) <= ?`);
|
||||
queryParams.push(val - 0.5, val + 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Temperature (处理预测值逻辑)
|
||||
if (temperature) {
|
||||
const tempExpr = `TRIM(CASE
|
||||
WHEN m.predict_temperature IS NOT NULL AND m.predict_temperature != '' THEN
|
||||
SUBSTRING(m.predict_temperature, 1, CASE WHEN LOCATE(' ', REVERSE(m.predict_temperature)) > 0 THEN LENGTH(m.predict_temperature) - LOCATE(' ', REVERSE(m.predict_temperature)) ELSE LENGTH(m.predict_temperature) END)
|
||||
ELSE NULL
|
||||
END)`;
|
||||
whereConditions.push(`${tempExpr} = ?`);
|
||||
queryParams.push(temperature);
|
||||
}
|
||||
|
||||
// 5. O2 (处理预测值逻辑)
|
||||
if (o2) {
|
||||
const o2Expr = `TRIM(CASE
|
||||
WHEN m.predict_o2 IS NOT NULL AND m.predict_o2 != '' THEN
|
||||
SUBSTRING(m.predict_o2, 1, CASE WHEN LOCATE(' ', REVERSE(m.predict_o2)) > 0 THEN LENGTH(m.predict_o2) - LOCATE(' ', REVERSE(m.predict_o2)) ELSE LENGTH(m.predict_o2) END)
|
||||
ELSE NULL
|
||||
END)`;
|
||||
whereConditions.push(`${o2Expr} LIKE ?`);
|
||||
queryParams.push(`%${o2}%`);
|
||||
}
|
||||
|
||||
// 6. Taxonomy
|
||||
if (taxonomy && taxonomy !== 'ALL' && taxonomy !== '') {
|
||||
const validLevels = ['Bacteria', 'Domain', 'Phylum', 'Class', 'Order', 'Family', 'Genus', 'Species'];
|
||||
if (validLevels.includes(taxonomy)) {
|
||||
let field = taxonomy === 'Bacteria' ? 'm.domain' : `m.${taxonomy.toLowerCase()}`;
|
||||
// 特殊处理 Order 关键字
|
||||
if (taxonomy.toLowerCase() === 'order') field = 'm.`order`';
|
||||
|
||||
if (taxonomyValue && taxonomyValue.trim() !== '') {
|
||||
whereConditions.push(`${field} LIKE ?`);
|
||||
queryParams.push(`%${taxonomyValue.trim()}%`);
|
||||
} else {
|
||||
whereConditions.push(`${field} IS NOT NULL AND ${field} != ''`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Microbial Name
|
||||
if (microbial && microbial.trim() !== '') {
|
||||
whereConditions.push('m.microbial_name LIKE ?');
|
||||
queryParams.push(`%${microbial.trim()}%`);
|
||||
}
|
||||
|
||||
return {
|
||||
clause: whereConditions.length > 0 ? `WHERE ${whereConditions.join(' AND ')}` : '',
|
||||
params: queryParams
|
||||
};
|
||||
};
|
||||
|
||||
// --- 数据清洗辅助函数 ---
|
||||
const extractTemperatureCategory = (str) => str ? str.split(',')[0].trim() : '';
|
||||
const extractO2Category = (str) => str ? str.split(' ')[0] : '';
|
||||
|
||||
// --- 核心 Model 对象 ---
|
||||
const mediaModel = {
|
||||
// 1. 获取总数
|
||||
async getTotalCount(filters) {
|
||||
const { clause, params } = buildWhereClause(filters);
|
||||
const query = `SELECT COUNT(DISTINCT m.species_dive_id) as total FROM cultured_data m ${clause}`;
|
||||
const [result] = await pool.execute(query, params);
|
||||
return result[0].total;
|
||||
},
|
||||
|
||||
// 2. 获取浏览列表数据
|
||||
async getBrowseData(filters, page, pageSize) {
|
||||
const { clause, params } = buildWhereClause(filters);
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
// 处理排序参数
|
||||
const { sortBy, sortOrder } = filters;
|
||||
let orderByClause = 'ORDER BY m.species_dive_id';
|
||||
|
||||
if (sortBy && sortOrder) {
|
||||
// 定义允许排序的字段映射
|
||||
const sortFieldMap = {
|
||||
'microbial': 'm.microbial_name',
|
||||
'nutrition': 'm.nutrition',
|
||||
'domain': 'm.domain',
|
||||
'ph': 'm.pH',
|
||||
'temperature': 'm.temperature',
|
||||
'o2': 'm.o2'
|
||||
};
|
||||
|
||||
const sortField = sortFieldMap[sortBy.toLowerCase()];
|
||||
if (sortField) {
|
||||
const direction = sortOrder.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
|
||||
orderByClause = `ORDER BY ${sortField} ${direction}`;
|
||||
}
|
||||
}
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
m.species_dive_id,
|
||||
m.microbial_name,
|
||||
m.nutrition,
|
||||
m.domain as domain,
|
||||
m.pH as pH,
|
||||
m.temperature as temperature,
|
||||
m.o2 as o2,
|
||||
m.predict_pH as ppH_raw,
|
||||
m.predict_temperature as ptemperature_raw,
|
||||
m.predict_o2 as po2_raw,
|
||||
-- Extract predicted value of predict_pH (before the last space)
|
||||
CASE
|
||||
WHEN m.predict_pH IS NOT NULL AND m.predict_pH != '' THEN
|
||||
TRIM(SUBSTRING(m.predict_pH, 1, CASE WHEN LOCATE(' ', REVERSE(m.predict_pH)) > 0 THEN LENGTH(m.predict_pH) - LOCATE(' ', REVERSE(m.predict_pH)) ELSE LENGTH(m.predict_pH) END))
|
||||
ELSE NULL
|
||||
END as ppH,
|
||||
-- Extract probability of predict_pH (after the last space)
|
||||
CASE
|
||||
WHEN m.predict_pH IS NOT NULL AND m.predict_pH != '' AND LOCATE(' ', REVERSE(m.predict_pH)) > 0 THEN
|
||||
TRIM(SUBSTRING(m.predict_pH, LENGTH(m.predict_pH) - LOCATE(' ', REVERSE(m.predict_pH)) + 2))
|
||||
ELSE NULL
|
||||
END as ppH_probability,
|
||||
-- Extract predicted value of predict_temperature (before the last space)
|
||||
CASE
|
||||
WHEN m.predict_temperature IS NOT NULL AND m.predict_temperature != '' THEN
|
||||
TRIM(SUBSTRING(m.predict_temperature, 1, CASE WHEN LOCATE(' ', REVERSE(m.predict_temperature)) > 0 THEN LENGTH(m.predict_temperature) - LOCATE(' ', REVERSE(m.predict_temperature)) ELSE LENGTH(m.predict_temperature) END))
|
||||
ELSE NULL
|
||||
END as ptemperature,
|
||||
-- Extract probability of predict_temperature (after the last space)
|
||||
CASE
|
||||
WHEN m.predict_temperature IS NOT NULL AND m.predict_temperature != '' AND LOCATE(' ', REVERSE(m.predict_temperature)) > 0 THEN
|
||||
TRIM(SUBSTRING(m.predict_temperature, LENGTH(m.predict_temperature) - LOCATE(' ', REVERSE(m.predict_temperature)) + 2))
|
||||
ELSE NULL
|
||||
END as ptemperature_probability,
|
||||
-- Extract predicted value of predict_o2 (before the last space)
|
||||
CASE
|
||||
WHEN m.predict_o2 IS NOT NULL AND m.predict_o2 != '' THEN
|
||||
TRIM(SUBSTRING(m.predict_o2, 1, CASE WHEN LOCATE(' ', REVERSE(m.predict_o2)) > 0 THEN LENGTH(m.predict_o2) - LOCATE(' ', REVERSE(m.predict_o2)) ELSE LENGTH(m.predict_o2) END))
|
||||
ELSE NULL
|
||||
END as po2,
|
||||
-- Extract probability of predict_o2 (after the last space)
|
||||
CASE
|
||||
WHEN m.predict_o2 IS NOT NULL AND m.predict_o2 != '' AND LOCATE(' ', REVERSE(m.predict_o2)) > 0 THEN
|
||||
TRIM(SUBSTRING(m.predict_o2, LENGTH(m.predict_o2) - LOCATE(' ', REVERSE(m.predict_o2)) + 2))
|
||||
ELSE NULL
|
||||
END as po2_probability,
|
||||
m.genome
|
||||
FROM cultured_data m
|
||||
${clause}
|
||||
GROUP BY m.species_dive_id, m.microbial_name, m.nutrition, m.domain, m.pH, m.temperature, m.o2, m.predict_pH, m.predict_temperature, m.predict_o2, m.genome
|
||||
${orderByClause}
|
||||
LIMIT ${parseInt(pageSize)} OFFSET ${parseInt(offset)}
|
||||
`;
|
||||
|
||||
const [rows] = await pool.execute(query, params);
|
||||
|
||||
// 格式化返回
|
||||
return rows.map(row => ({
|
||||
id: row.species_dive_id,
|
||||
microbial_name: row.microbial_name || '',
|
||||
nutrition: row.nutrition || '',
|
||||
domain: row.domain || '',
|
||||
pH: row.ppH ? row.ppH.toString() : (row.pH ? row.pH.toString() : ''),
|
||||
pH_probability: row.ppH_probability || null,
|
||||
temperature: row.ptemperature ? extractTemperatureCategory(row.ptemperature.split(',')[0].trim()) : (row.temperature ? extractTemperatureCategory(row.temperature.split(',')[0].trim()) : ''),
|
||||
temperature_probability: row.ptemperature_probability || null,
|
||||
o2: row.po2 ? extractO2Category(row.po2) : (row.o2 ? extractO2Category(row.o2) : ''),
|
||||
o2_probability: row.po2_probability || null,
|
||||
ppH: row.ppH || null,
|
||||
ptemperature: row.ptemperature || null,
|
||||
po2: row.po2 || null
|
||||
}));
|
||||
},
|
||||
|
||||
// 3. 微生物详情
|
||||
async getMicrobialDetail(name, level = 'genus') {
|
||||
if (!name) {
|
||||
throw new Error('Microbial name cannot be empty');
|
||||
}
|
||||
|
||||
console.log('Get microbial detail information:', name, 'Taxonomy Level:', level);
|
||||
|
||||
// 定义允许的层级
|
||||
const allowedLevels = ['genus', 'family', 'order', 'class', 'phylum'];
|
||||
const safeLevel = allowedLevels.includes(level.toLowerCase()) ? level.toLowerCase() : 'genus';
|
||||
|
||||
// 构建基础查询字段
|
||||
const baseFields = `
|
||||
m.species_dive_id,
|
||||
m.microbial_name,
|
||||
m.domain,
|
||||
m.phylum,
|
||||
m.class,
|
||||
m.\`order\`,
|
||||
m.family,
|
||||
m.genus,
|
||||
m.species,
|
||||
m.taxonomy,
|
||||
m.pH as pH,
|
||||
m.temperature as temperature,
|
||||
m.o2 as o2,
|
||||
m.gram_stain,
|
||||
m.synonym,
|
||||
m.genome,
|
||||
m.nutrition,
|
||||
m.cultured_type,
|
||||
TRIM(CASE
|
||||
WHEN m.predict_pH IS NOT NULL AND m.predict_pH != '' THEN
|
||||
SUBSTRING(m.predict_pH, 1, CASE WHEN LOCATE(' ', REVERSE(m.predict_pH)) > 0 THEN LENGTH(m.predict_pH) - LOCATE(' ', REVERSE(m.predict_pH)) ELSE LENGTH(m.predict_pH) END)
|
||||
ELSE NULL
|
||||
END) as ppH,
|
||||
CASE
|
||||
WHEN m.predict_pH IS NOT NULL AND m.predict_pH != '' AND LOCATE(' ', REVERSE(m.predict_pH)) > 0 THEN
|
||||
TRIM(SUBSTRING(m.predict_pH, LENGTH(m.predict_pH) - LOCATE(' ', REVERSE(m.predict_pH)) + 2))
|
||||
ELSE NULL
|
||||
END as ppH_probability,
|
||||
TRIM(CASE
|
||||
WHEN m.predict_temperature IS NOT NULL AND m.predict_temperature != '' THEN
|
||||
SUBSTRING(m.predict_temperature, 1, CASE WHEN LOCATE(' ', REVERSE(m.predict_temperature)) > 0 THEN LENGTH(m.predict_temperature) - LOCATE(' ', REVERSE(m.predict_temperature)) ELSE LENGTH(m.predict_temperature) END)
|
||||
ELSE NULL
|
||||
END) as ptemperature,
|
||||
CASE
|
||||
WHEN m.predict_temperature IS NOT NULL AND m.predict_temperature != '' AND LOCATE(' ', REVERSE(m.predict_temperature)) > 0 THEN
|
||||
TRIM(SUBSTRING(m.predict_temperature, LENGTH(m.predict_temperature) - LOCATE(' ', REVERSE(m.predict_temperature)) + 2))
|
||||
ELSE NULL
|
||||
END as ptemperature_probability,
|
||||
TRIM(CASE
|
||||
WHEN m.predict_o2 IS NOT NULL AND m.predict_o2 != '' THEN
|
||||
SUBSTRING(m.predict_o2, 1, CASE WHEN LOCATE(' ', REVERSE(m.predict_o2)) > 0 THEN LENGTH(m.predict_o2) - LOCATE(' ', REVERSE(m.predict_o2)) ELSE LENGTH(m.predict_o2) END)
|
||||
ELSE NULL
|
||||
END) as po2,
|
||||
CASE
|
||||
WHEN m.predict_o2 IS NOT NULL AND m.predict_o2 != '' AND LOCATE(' ', REVERSE(m.predict_o2)) > 0 THEN
|
||||
TRIM(SUBSTRING(m.predict_o2, LENGTH(m.predict_o2) - LOCATE(' ', REVERSE(m.predict_o2)) + 2))
|
||||
ELSE NULL
|
||||
END as po2_probability
|
||||
`;
|
||||
|
||||
// 首先尝试精确匹配
|
||||
let detailQuery = `
|
||||
SELECT ${baseFields}
|
||||
FROM cultured_data m
|
||||
WHERE m.microbial_name = ?
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
console.log('Execute exact match query:', detailQuery, 'Parameters:', [name]);
|
||||
let [rows] = await pool.execute(detailQuery, [name]);
|
||||
console.log('Exact match query result:', rows.length, 'records');
|
||||
|
||||
// 如果精确匹配失败,尝试模糊匹配
|
||||
if (rows.length === 0) {
|
||||
detailQuery = `
|
||||
SELECT ${baseFields}
|
||||
FROM cultured_data m
|
||||
WHERE m.microbial_name LIKE ?
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
console.log('Execute fuzzy match query:', detailQuery, 'Parameters:', [`%${name}%`]);
|
||||
[rows] = await pool.execute(detailQuery, [`%${name}%`]);
|
||||
console.log('Fuzzy match query result:', rows.length, 'records');
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
return null; // 返回 null 表示未找到
|
||||
}
|
||||
|
||||
const microbialData = rows[0];
|
||||
console.log('Found microbial data:', microbialData.microbial_name, 'Genus:', microbialData.genus);
|
||||
|
||||
// 获取 nutrition 信息
|
||||
let nutritionInfo = microbialData.nutrition || '';
|
||||
|
||||
// 查询相关微生物(基于指定的层级)
|
||||
let relatedRows = [];
|
||||
const levelValue = microbialData[safeLevel]; // 获取指定层级的值
|
||||
|
||||
if (levelValue) {
|
||||
try {
|
||||
// 处理 'order' 关键字
|
||||
const columnRef = safeLevel === 'order' ? '`order`' : safeLevel;
|
||||
|
||||
const relatedQuery = `
|
||||
SELECT ${baseFields}
|
||||
FROM cultured_data m
|
||||
WHERE m.${columnRef} = ? AND m.microbial_name != ?
|
||||
ORDER BY m.microbial_name
|
||||
`;
|
||||
|
||||
console.log('Execute related microbes query:', relatedQuery, 'Parameters:', [levelValue, microbialData.microbial_name]);
|
||||
const [relatedResult] = await pool.execute(relatedQuery, [levelValue, microbialData.microbial_name]);
|
||||
relatedRows = relatedResult;
|
||||
console.log('Related microbes query result:', relatedRows.length, 'records');
|
||||
} catch (relatedError) {
|
||||
console.error('Related microbes query failed:', relatedError);
|
||||
// 继续执行,使用空数组
|
||||
}
|
||||
}
|
||||
|
||||
// 构建并返回格式化数据
|
||||
const responseData = {
|
||||
...microbialData,
|
||||
// 保存原始值
|
||||
pH: microbialData.pH,
|
||||
temperature: microbialData.temperature,
|
||||
o2: microbialData.o2,
|
||||
// 保存预测值(如果存在)
|
||||
ppH: microbialData.ppH || null,
|
||||
ptemperature: microbialData.ptemperature || null,
|
||||
po2: microbialData.po2 || null,
|
||||
// 保存预测概率(如果存在)
|
||||
ppH_probability: microbialData.ppH_probability || null,
|
||||
ptemperature_probability: microbialData.ptemperature_probability || null,
|
||||
po2_probability: microbialData.po2_probability || null,
|
||||
nutrition: nutritionInfo,
|
||||
culture_type: microbialData.cultured_type,
|
||||
related_microbes: relatedRows.map(row => ({
|
||||
...row,
|
||||
// 保存原始值
|
||||
pH: row.pH,
|
||||
temperature: row.temperature,
|
||||
o2: row.o2,
|
||||
// 保存预测值(如果存在)
|
||||
ppH: row.ppH || null,
|
||||
ptemperature: row.ptemperature || null,
|
||||
po2: row.po2 || null,
|
||||
// 保存预测概率(如果存在)
|
||||
ppH_probability: row.ppH_probability || null,
|
||||
ptemperature_probability: row.ptemperature_probability || null,
|
||||
po2_probability: row.po2_probability || null
|
||||
}))
|
||||
};
|
||||
|
||||
console.log('Return data:', {
|
||||
microbial_name: responseData.microbial_name,
|
||||
level: safeLevel,
|
||||
levelValue: levelValue,
|
||||
related_count: responseData.related_microbes.length
|
||||
});
|
||||
|
||||
return responseData;
|
||||
},
|
||||
|
||||
// 4. 获取 Sankey 数据
|
||||
|
||||
async getSankeyData(filters) {
|
||||
console.log('开始构建6层桑基图数据...');
|
||||
const { clause, params } = buildWhereClause(filters);
|
||||
try {
|
||||
// 查询完整的6层分类层级数据
|
||||
const hierarchyQuery = `
|
||||
SELECT
|
||||
m.domain,
|
||||
m.phylum,
|
||||
m.class,
|
||||
m.\`order\`,
|
||||
m.family,
|
||||
m.genus,
|
||||
COUNT(DISTINCT m.species_dive_id) as species_count
|
||||
FROM cultured_data m
|
||||
${clause ? clause + ' AND' : 'WHERE'}
|
||||
m.domain IS NOT NULL AND m.domain != '' AND
|
||||
m.phylum IS NOT NULL AND m.phylum != '' AND
|
||||
m.class IS NOT NULL AND m.class != '' AND
|
||||
m.\`order\` IS NOT NULL AND m.\`order\` != '' AND
|
||||
m.family IS NOT NULL AND m.family != '' AND
|
||||
m.genus IS NOT NULL AND m.genus != ''
|
||||
GROUP BY m.domain, m.phylum, m.class, m.\`order\`, m.family, m.genus
|
||||
ORDER BY species_count DESC
|
||||
`;
|
||||
|
||||
console.log('执行6层分类查询:', hierarchyQuery);
|
||||
const [hierarchyRows] = await pool.execute(hierarchyQuery, params);
|
||||
console.log('查询结果:', hierarchyRows.length, '条记录');
|
||||
|
||||
if (hierarchyRows.length === 0) {
|
||||
console.log('没有找到数据,返回空桑基图');
|
||||
return { nodes: [], links: [] };
|
||||
}
|
||||
|
||||
// 定义层级顺序
|
||||
const levelOrder = ['domain', 'phylum', 'class', 'order', 'family', 'genus'];
|
||||
|
||||
// 构建每个层级的分类统计
|
||||
const levelCounts = new Map(); // 存储每个分类的物种总数
|
||||
const parentChildMap = new Map(); // 存储父子关系映射
|
||||
|
||||
// 处理每条完整的分类路径
|
||||
for (const row of hierarchyRows) {
|
||||
const speciesCount = parseInt(row.species_count);
|
||||
|
||||
// 为每个层级的分类累加物种数量
|
||||
for (let i = 0; i < levelOrder.length; i++) {
|
||||
const level = levelOrder[i];
|
||||
const categoryName = row[level];
|
||||
|
||||
if (categoryName) {
|
||||
const key = `${level}:${categoryName}`;
|
||||
levelCounts.set(key, (levelCounts.get(key) || 0) + speciesCount);
|
||||
|
||||
// 建立父子关系
|
||||
if (i > 0) {
|
||||
const parentLevel = levelOrder[i - 1];
|
||||
const parentName = row[parentLevel];
|
||||
if (parentName) {
|
||||
const parentKey = `${parentLevel}:${parentName}`;
|
||||
const childKey = `${level}:${categoryName}`;
|
||||
|
||||
if (!parentChildMap.has(parentKey)) {
|
||||
parentChildMap.set(parentKey, new Set());
|
||||
}
|
||||
parentChildMap.get(parentKey).add(childKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('层级统计完成:', {
|
||||
levelCountsSize: levelCounts.size,
|
||||
parentChildMapSize: parentChildMap.size
|
||||
});
|
||||
|
||||
// 构建节点 - 每层最多10个节点,确保节点名称唯一
|
||||
const nodes = [];
|
||||
const nodeMap = new Map();
|
||||
let nodeIndex = 0;
|
||||
|
||||
// 计算总物种数量
|
||||
const totalSpeciesCount = hierarchyRows.reduce((sum, row) => sum + parseInt(row.species_count), 0);
|
||||
|
||||
// 添加Total节点
|
||||
nodes.push({
|
||||
name: "Total",
|
||||
level: "total",
|
||||
levelName: "总计",
|
||||
depth: 0, // Total节点在第0层
|
||||
itemStyle: {
|
||||
color: '#5470c6'
|
||||
}
|
||||
});
|
||||
nodeMap.set("Total", nodeIndex++);
|
||||
|
||||
// 为每个层级选择前10个最多的分类
|
||||
const selectedCategories = new Map(); // 存储每层选中的分类
|
||||
|
||||
// 颜色方案和层级中文名称
|
||||
const levelColors = {
|
||||
'domain': '#91cc75',
|
||||
'phylum': '#fac858',
|
||||
'class': '#ee6666',
|
||||
'order': '#73c0de',
|
||||
'family': '#3ba272',
|
||||
'genus': '#fc8452'
|
||||
};
|
||||
|
||||
const levelNames = {
|
||||
'domain': 'domain',
|
||||
'phylum': 'phylum',
|
||||
'class': 'class',
|
||||
'order': 'order',
|
||||
'family': 'family',
|
||||
'genus': 'genus'
|
||||
};
|
||||
|
||||
// 层级深度映射
|
||||
const levelDepth = {
|
||||
'domain': 1,
|
||||
'phylum': 2,
|
||||
'class': 3,
|
||||
'order': 4,
|
||||
'family': 5,
|
||||
'genus': 6
|
||||
};
|
||||
|
||||
for (const level of levelOrder) {
|
||||
// 获取当前层级的所有分类,按物种数量排序
|
||||
const levelCategories = [];
|
||||
for (const [key, count] of levelCounts) {
|
||||
if (key.startsWith(`${level}:`)) {
|
||||
const categoryName = key.substring(level.length + 1);
|
||||
levelCategories.push({ name: categoryName, count: count, key: key });
|
||||
}
|
||||
}
|
||||
|
||||
// 按物种数量降序排序,根据层级决定选择数量
|
||||
levelCategories.sort((a, b) => b.count - a.count);
|
||||
|
||||
// family和genus层级选择前20个,其他层级选择前10个
|
||||
const maxNodes = (level === 'family' || level === 'genus') ? 20 : 10;
|
||||
const topCategories = levelCategories.slice(0, maxNodes);
|
||||
|
||||
console.log(`${level}层级: 总共${levelCategories.length}个分类,选择前${topCategories.length}个`);
|
||||
|
||||
// 存储选中的分类
|
||||
const selectedKeys = new Set();
|
||||
for (const category of topCategories) {
|
||||
selectedKeys.add(category.key);
|
||||
// 为节点添加层级信息,包括depth属性
|
||||
nodes.push({
|
||||
name: category.name,
|
||||
level: level,
|
||||
levelName: levelNames[level],
|
||||
depth: levelDepth[level], // 添加depth属性指定层级
|
||||
count: category.count,
|
||||
itemStyle: {
|
||||
color: levelColors[level] || '#5470c6'
|
||||
}
|
||||
});
|
||||
nodeMap.set(category.key, nodeIndex++);
|
||||
}
|
||||
selectedCategories.set(level, selectedKeys);
|
||||
}
|
||||
|
||||
console.log('节点构建完成:', {
|
||||
totalNodes: nodes.length,
|
||||
nodesByLevel: Object.fromEntries(
|
||||
levelOrder.map(level => [level, selectedCategories.get(level).size])
|
||||
)
|
||||
});
|
||||
|
||||
// 对每个depth层级的节点value进行归一化
|
||||
// 先计算每个节点的初始value(基于count),再按层级归一化
|
||||
const BASE_VALUE = 1000; // 每层的基准总值
|
||||
|
||||
const depthGroups = {}; // 按depth分组节点
|
||||
|
||||
// 将节点按depth分组,并初始化value
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const node = nodes[i];
|
||||
const depth = node.depth;
|
||||
if (!depthGroups[depth]) {
|
||||
depthGroups[depth] = [];
|
||||
}
|
||||
depthGroups[depth].push(i); // 存储节点索引
|
||||
// 初始化value为count
|
||||
nodes[i].value = nodes[i].count || 0;
|
||||
}
|
||||
|
||||
// 设置Total节点的value为BASE_VALUE
|
||||
nodes[0].value = BASE_VALUE;
|
||||
|
||||
// 构建连接 - 确保连接完整性
|
||||
const links = [];
|
||||
|
||||
// 1. Total到domain层的连接(使用比例关系)
|
||||
let domainTotalCount = 0;
|
||||
const domainNodes = [];
|
||||
|
||||
for (const [key, count] of levelCounts) {
|
||||
if (key.startsWith('domain:') && nodeMap.has(key)) {
|
||||
const targetIndex = nodeMap.get(key);
|
||||
domainNodes.push({ index: targetIndex, count: count });
|
||||
domainTotalCount += count;
|
||||
}
|
||||
}
|
||||
|
||||
// 按比例从Total分配到domain
|
||||
for (const domainNode of domainNodes) {
|
||||
const ratio = domainNode.count / domainTotalCount;
|
||||
links.push({
|
||||
source: 0, // Total的索引
|
||||
target: domainNode.index,
|
||||
value: BASE_VALUE * ratio
|
||||
});
|
||||
}
|
||||
|
||||
// 2. 相邻层级间的连接
|
||||
for (let i = 0; i < levelOrder.length - 1; i++) {
|
||||
const currentLevel = levelOrder[i];
|
||||
const nextLevel = levelOrder[i + 1];
|
||||
|
||||
const currentSelected = selectedCategories.get(currentLevel);
|
||||
const nextSelected = selectedCategories.get(nextLevel);
|
||||
|
||||
// 为每个父子关系创建连接
|
||||
for (const [parentKey, children] of parentChildMap) {
|
||||
if (parentKey.startsWith(`${currentLevel}:`) &&
|
||||
currentSelected.has(parentKey) &&
|
||||
nodeMap.has(parentKey)) {
|
||||
|
||||
const parentIndex = nodeMap.get(parentKey);
|
||||
|
||||
// 计算该父节点下所有选中子节点的count总和
|
||||
let childrenTotalCount = 0;
|
||||
const validChildren = [];
|
||||
|
||||
for (const childKey of children) {
|
||||
if (childKey.startsWith(`${nextLevel}:`) &&
|
||||
nextSelected.has(childKey) &&
|
||||
nodeMap.has(childKey)) {
|
||||
const childCount = levelCounts.get(childKey) || 0;
|
||||
childrenTotalCount += childCount;
|
||||
validChildren.push({
|
||||
key: childKey,
|
||||
count: childCount,
|
||||
index: nodeMap.get(childKey)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 存储父节点的流出比例信息
|
||||
for (const child of validChildren) {
|
||||
const ratio = childrenTotalCount > 0 ? child.count / childrenTotalCount : 0;
|
||||
links.push({
|
||||
source: parentIndex,
|
||||
target: child.index,
|
||||
ratio: ratio, // 暂存比例,后续计算value
|
||||
childCount: child.count
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('连接关系构建完成:', links.length);
|
||||
|
||||
// 3. 从Total开始,逐层计算value
|
||||
// 先计算每个节点的流入value
|
||||
const nodeInflow = new Array(nodes.length).fill(0);
|
||||
|
||||
// 第一层:Total -> domain
|
||||
for (const link of links) {
|
||||
if (link.source === 0 && link.value !== undefined) {
|
||||
nodeInflow[link.target] += link.value;
|
||||
}
|
||||
}
|
||||
|
||||
// 更新domain节点的value
|
||||
for (const domainNode of domainNodes) {
|
||||
nodes[domainNode.index].value = nodeInflow[domainNode.index];
|
||||
}
|
||||
|
||||
// 后续层级:根据父节点的value和比例计算连接value
|
||||
for (const link of links) {
|
||||
if (link.source !== 0 && link.ratio !== undefined) {
|
||||
const parentValue = nodes[link.source].value;
|
||||
link.value = parentValue * link.ratio;
|
||||
nodeInflow[link.target] += link.value;
|
||||
// 更新目标节点的value
|
||||
nodes[link.target].value = nodeInflow[link.target];
|
||||
}
|
||||
}
|
||||
|
||||
// 清理ratio字段
|
||||
for (const link of links) {
|
||||
delete link.ratio;
|
||||
delete link.childCount;
|
||||
}
|
||||
|
||||
// 4. 对每层进行归一化调整
|
||||
for (const depth in depthGroups) {
|
||||
if (depth === '0') continue; // 跳过Total
|
||||
|
||||
const nodeIndices = depthGroups[depth];
|
||||
let currentTotal = 0;
|
||||
|
||||
for (const idx of nodeIndices) {
|
||||
currentTotal += nodes[idx].value || 0;
|
||||
}
|
||||
|
||||
// 归一化系数
|
||||
const normalizationFactor = currentTotal > 0 ? BASE_VALUE / currentTotal : 1;
|
||||
|
||||
// 调整该层所有节点的value
|
||||
for (const idx of nodeIndices) {
|
||||
nodes[idx].value = (nodes[idx].value || 0) * normalizationFactor;
|
||||
}
|
||||
|
||||
// 同时调整流入该层节点的连接value
|
||||
for (const link of links) {
|
||||
if (nodeIndices.includes(link.target)) {
|
||||
link.value = (link.value || 0) * normalizationFactor;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Depth ${depth} 归一化: 原始total=${currentTotal.toFixed(2)}, 归一化后=${BASE_VALUE}, 系数=${normalizationFactor.toFixed(4)}`);
|
||||
}
|
||||
|
||||
// 验证每层的总value
|
||||
const depthTotals = {};
|
||||
for (const node of nodes) {
|
||||
const depth = node.depth;
|
||||
if (!depthTotals[depth]) {
|
||||
depthTotals[depth] = 0;
|
||||
}
|
||||
depthTotals[depth] += node.value;
|
||||
}
|
||||
|
||||
console.log('最终每层节点value总和:', depthTotals);
|
||||
|
||||
// 验证数据完整性
|
||||
const invalidLinks = links.filter(link =>
|
||||
link.source === undefined ||
|
||||
link.target === undefined ||
|
||||
link.value === undefined ||
|
||||
isNaN(link.source) ||
|
||||
isNaN(link.target) ||
|
||||
isNaN(link.value) ||
|
||||
link.source >= nodes.length ||
|
||||
link.target >= nodes.length ||
|
||||
link.source < 0 ||
|
||||
link.target < 0
|
||||
);
|
||||
|
||||
if (invalidLinks.length > 0) {
|
||||
console.error('发现无效连接:', invalidLinks);
|
||||
// 过滤掉无效连接
|
||||
const validLinks = links.filter(link =>
|
||||
link.source !== undefined &&
|
||||
link.target !== undefined &&
|
||||
link.value !== undefined &&
|
||||
!isNaN(link.source) &&
|
||||
!isNaN(link.target) &&
|
||||
!isNaN(link.value) &&
|
||||
link.source >= 0 &&
|
||||
link.target >= 0 &&
|
||||
link.source < nodes.length &&
|
||||
link.target < nodes.length
|
||||
);
|
||||
|
||||
console.log('过滤后的有效连接数量:', validLinks.length);
|
||||
return {
|
||||
nodes: nodes,
|
||||
links: validLinks
|
||||
};
|
||||
}
|
||||
|
||||
console.log('6层桑基图数据构建完成:', {
|
||||
nodesCount: nodes.length,
|
||||
linksCount: links.length,
|
||||
totalSpeciesCount: totalSpeciesCount,
|
||||
sampleNodes: nodes.slice(0, 3).map((n, i) => ({index: i, ...n})),
|
||||
sampleLinks: links.slice(0, 3)
|
||||
});
|
||||
|
||||
// 详细验证数据结构
|
||||
console.log('=== Data validation ===');
|
||||
console.log('All nodes:', nodes.map((n, i) =>
|
||||
`[${i}] ${n.name} (${n.levelName || n.level}) - counts:${n.count || 'N/A'}`
|
||||
).slice(0, 20).join(', ') + (nodes.length > 20 ? ` ... ${nodes.length}nodes in total` : ''));
|
||||
console.log('Top 10 connections:', links.slice(0, 10).map(l => {
|
||||
const source = nodes[l.source];
|
||||
const target = nodes[l.target];
|
||||
return `${source?.name}(${source?.levelName})[${l.source}] -> ${target?.name}(${target?.levelName})[${l.target}] (${(l.value * 100).toFixed(2)}%)`;
|
||||
}));
|
||||
|
||||
// 检查是否有重复的节点名称
|
||||
const nodeNames = nodes.map(n => n.name);
|
||||
const duplicates = nodeNames.filter((name, index) => nodeNames.indexOf(name) !== index);
|
||||
if (duplicates.length > 0) {
|
||||
console.warn('Warning: Duplicate node names detected:', [...new Set(duplicates)]);
|
||||
}
|
||||
|
||||
return {
|
||||
nodes: nodes,
|
||||
links: links
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('An error occurred while constructing the Sankey chart data:', error);
|
||||
return { nodes: [], links: [] };
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
|
||||
|
||||
// 5. 统计数据 (Taxonomy)
|
||||
async getTaxonomyStats(level, cultured_type) {
|
||||
// 先获取总数
|
||||
const countQuery = `
|
||||
SELECT COUNT(DISTINCT \`${level}\`) as total_count
|
||||
FROM cultured_data m
|
||||
WHERE \`${level}\` IS NOT NULL AND \`${level}\` != '' AND m.cultured_type = ?
|
||||
`;
|
||||
const [countResult] = await pool.execute(countQuery, [cultured_type]);
|
||||
const total = countResult[0].total_count;
|
||||
|
||||
// 获取详细数据
|
||||
const query = `
|
||||
SELECT \`${level}\` as name, COUNT(DISTINCT m.species_dive_id) as value
|
||||
FROM cultured_data m
|
||||
WHERE \`${level}\` IS NOT NULL AND \`${level}\` != '' AND m.cultured_type = ?
|
||||
GROUP BY \`${level}\`
|
||||
ORDER BY value DESC
|
||||
`;
|
||||
const [rows] = await pool.execute(query, [cultured_type]);
|
||||
const data = rows.map(r => ({ name: r.name, value: parseInt(r.value) }));
|
||||
|
||||
return { data, total };
|
||||
},
|
||||
|
||||
// 6. 营养统计 (包含复杂的 Python List 解析)
|
||||
async getNutritionStats(cultured_type) {
|
||||
const query = `SELECT m.nutrition, COUNT(DISTINCT m.species_dive_id) as count FROM cultured_data m WHERE m.nutrition IS NOT NULL AND m.nutrition != '' AND m.cultured_type = ? GROUP BY m.nutrition`;
|
||||
const [rows] = await pool.execute(query, [cultured_type]);
|
||||
|
||||
const frequency = {};
|
||||
let totalSpecies = 0;
|
||||
|
||||
rows.forEach(row => {
|
||||
totalSpecies += parseInt(row.count);
|
||||
let str = row.nutrition.trim().replace(/^\[|\]$/g, '');
|
||||
if(str) {
|
||||
const list = str.split(',').map(i => i.trim().replace(/^['"]|['"]$/g, '')).filter(i => i);
|
||||
list.forEach(nut => {
|
||||
frequency[nut] = (frequency[nut] || 0) + parseInt(row.count);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const data = Object.entries(frequency)
|
||||
.map(([name, value]) => ({ name, value }))
|
||||
.sort((a, b) => b.value - a.value);
|
||||
|
||||
return { data, total: data.length, totalSpecies };
|
||||
},
|
||||
|
||||
// 7. 理化性质统计
|
||||
async getPhyschemStats(type, cultured_type) {
|
||||
let data = {};
|
||||
|
||||
if (type === 'o2') {
|
||||
// O2 类型统计
|
||||
const query = `
|
||||
SELECT
|
||||
CASE
|
||||
WHEN m.o2 IS NOT NULL AND m.o2 != '' THEN
|
||||
SUBSTRING(m.o2, 1, CASE WHEN LOCATE(' ', m.o2) > 0 THEN LOCATE(' ', m.o2) - 1 ELSE LENGTH(m.o2) END)
|
||||
ELSE NULL
|
||||
END as name,
|
||||
COUNT(DISTINCT m.species_dive_id) as count
|
||||
FROM cultured_data m
|
||||
WHERE m.o2 IS NOT NULL AND m.o2 != '' AND m.cultured_type = ?
|
||||
GROUP BY
|
||||
CASE
|
||||
WHEN m.o2 IS NOT NULL AND m.o2 != '' THEN
|
||||
SUBSTRING(m.o2, 1, CASE WHEN LOCATE(' ', m.o2) > 0 THEN LOCATE(' ', m.o2) - 1 ELSE LENGTH(m.o2) END)
|
||||
ELSE NULL
|
||||
END
|
||||
ORDER BY count DESC
|
||||
`;
|
||||
|
||||
const [rows] = await pool.execute(query, [cultured_type]);
|
||||
const total = rows.reduce((sum, row) => sum + parseInt(row.count || 0), 0);
|
||||
|
||||
data = {
|
||||
aerobe: 0,
|
||||
anaerobe: 0,
|
||||
facultative: 0,
|
||||
total: total
|
||||
};
|
||||
|
||||
rows.forEach(row => {
|
||||
if (!row.name) return; // 跳过空值
|
||||
const name = row.name.toLowerCase();
|
||||
const count = parseInt(row.count || 0);
|
||||
|
||||
if (name.includes('aerobe') && !name.includes('anaerobe')) {
|
||||
data.aerobe += count;
|
||||
} else if (name.includes('anaerobe')) {
|
||||
data.anaerobe += count;
|
||||
} else if (name.includes('facultative')) {
|
||||
data.facultative += count;
|
||||
}
|
||||
});
|
||||
|
||||
} else if (type === 'ph') {
|
||||
// pH 分布统计
|
||||
const query = `
|
||||
SELECT
|
||||
CASE
|
||||
WHEN CAST(m.pH AS DECIMAL(4,2)) BETWEEN 1 AND 2 THEN 1
|
||||
WHEN CAST(m.pH AS DECIMAL(4,2)) BETWEEN 2 AND 3 THEN 2
|
||||
WHEN CAST(m.pH AS DECIMAL(4,2)) BETWEEN 3 AND 4 THEN 3
|
||||
WHEN CAST(m.pH AS DECIMAL(4,2)) BETWEEN 4 AND 5 THEN 4
|
||||
WHEN CAST(m.pH AS DECIMAL(4,2)) BETWEEN 5 AND 6 THEN 5
|
||||
WHEN CAST(m.pH AS DECIMAL(4,2)) BETWEEN 6 AND 7 THEN 6
|
||||
WHEN CAST(m.pH AS DECIMAL(4,2)) BETWEEN 7 AND 8 THEN 7
|
||||
WHEN CAST(m.pH AS DECIMAL(4,2)) BETWEEN 8 AND 9 THEN 8
|
||||
WHEN CAST(m.pH AS DECIMAL(4,2)) BETWEEN 9 AND 10 THEN 9
|
||||
WHEN CAST(m.pH AS DECIMAL(4,2)) BETWEEN 10 AND 11 THEN 10
|
||||
WHEN CAST(m.pH AS DECIMAL(4,2)) BETWEEN 11 AND 12 THEN 11
|
||||
ELSE 12
|
||||
END as ph_range,
|
||||
COUNT(DISTINCT m.species_dive_id) as count
|
||||
FROM cultured_data m
|
||||
WHERE m.pH IS NOT NULL AND m.pH != '' AND m.pH != 'Unknown' AND m.cultured_type = ?
|
||||
GROUP BY ph_range
|
||||
ORDER BY ph_range
|
||||
`;
|
||||
|
||||
const [rows] = await pool.execute(query, [cultured_type]);
|
||||
data = rows.map(row => ({
|
||||
x: parseInt(row.ph_range),
|
||||
count: parseInt(row.count)
|
||||
}));
|
||||
|
||||
} else if (type === 'temperature') {
|
||||
// 温度类别分布统计
|
||||
const query = `
|
||||
SELECT
|
||||
CASE
|
||||
WHEN m.temperature IS NOT NULL AND m.temperature != '' THEN
|
||||
SUBSTRING(m.temperature, 1, CASE WHEN LOCATE(' ', m.temperature) > 0 THEN LOCATE(' ', m.temperature) - 1 ELSE LENGTH(m.temperature) END)
|
||||
ELSE NULL
|
||||
END as temp_category,
|
||||
COUNT(DISTINCT m.species_dive_id) as count
|
||||
FROM cultured_data m
|
||||
WHERE m.temperature IS NOT NULL AND m.temperature != '' AND m.temperature != 'Unknown' AND m.cultured_type = ?
|
||||
GROUP BY
|
||||
CASE
|
||||
WHEN m.temperature IS NOT NULL AND m.temperature != '' THEN
|
||||
SUBSTRING(m.temperature, 1, CASE WHEN LOCATE(' ', m.temperature) > 0 THEN LOCATE(' ', m.temperature) - 1 ELSE LENGTH(m.temperature) END)
|
||||
ELSE NULL
|
||||
END
|
||||
ORDER BY count DESC
|
||||
`;
|
||||
|
||||
const [rows] = await pool.execute(query, [cultured_type]);
|
||||
data = rows.map(row => ({
|
||||
x: row.temp_category || '',
|
||||
count: parseInt(row.count || 0)
|
||||
}));
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
|
||||
// 8. 健康检查
|
||||
async checkHealth() {
|
||||
const conn = await pool.getConnection();
|
||||
await conn.ping();
|
||||
conn.release();
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = mediaModel;
|
||||
Reference in New Issue
Block a user