357 lines
11 KiB
JavaScript
357 lines
11 KiB
JavaScript
// info-card.js - Unified info card module
|
||
import { showStatusMessage } from './ui.js';
|
||
|
||
let currentType = null;
|
||
let cardMounted = false;
|
||
|
||
const CARD_CONFIG = {
|
||
cable: {
|
||
icon: '🛥️',
|
||
title: '电缆详情',
|
||
className: 'cable',
|
||
fields: [
|
||
{ key: 'name', label: '名称' },
|
||
{ key: 'owner', label: '所有者' },
|
||
{ key: 'status', label: '状态' },
|
||
{ key: 'length', label: '长度' },
|
||
{ key: 'coords', label: '经纬度' },
|
||
{ key: 'rfs', label: '投入使用' }
|
||
]
|
||
},
|
||
satellite: {
|
||
icon: '🛰️',
|
||
title: '卫星详情',
|
||
className: 'satellite',
|
||
fields: [
|
||
{ key: 'name', label: '名称' },
|
||
{ key: 'norad_id', label: 'NORAD ID' },
|
||
{ key: 'inclination', label: '倾角', unit: '°' },
|
||
{ key: 'period', label: '周期', unit: '分钟' },
|
||
{ key: 'perigee', label: '近地点', unit: 'km' },
|
||
{ key: 'apogee', label: '远地点', unit: 'km' }
|
||
]
|
||
},
|
||
bgp: {
|
||
icon: '📡',
|
||
title: 'BGP事件详情',
|
||
className: 'bgp',
|
||
fields: [
|
||
{ key: 'anomaly_type', label: '事件类型' },
|
||
{ key: 'severity', label: '严重度' },
|
||
{ key: 'status', label: '状态' },
|
||
{ key: 'route_change', label: '事件特征' },
|
||
{ key: 'prefix', label: '前缀' },
|
||
{ key: 'as_path_display', label: '传播路径' },
|
||
{ key: 'origin_asn', label: '涉及 ASN' },
|
||
{ key: 'new_origin_asn', label: '关联 ASN' },
|
||
{ key: 'confidence', label: '置信度' },
|
||
{ key: 'collector', label: '主观测站' },
|
||
{ key: 'observed_by', label: '观测范围' },
|
||
{ key: 'impacted_scope', label: '影响区域' },
|
||
{ key: 'related_cables', label: '附近基础设施' },
|
||
{ key: 'related_satellites', label: '附近卫星' },
|
||
{ key: 'location', label: '观测位置' },
|
||
{ key: 'created_at', label: '事件时间' },
|
||
{ key: 'summary', label: '摘要' }
|
||
]
|
||
},
|
||
bgp_collector: {
|
||
icon: '📍',
|
||
title: 'BGP观测站详情',
|
||
className: 'bgp',
|
||
fields: [
|
||
{ key: 'collector', label: '采集器' },
|
||
{ key: 'location', label: '观测位置' },
|
||
{ key: 'anomaly_count', label: '当前事件数' },
|
||
{ key: 'observation_count', label: '观测事件数' },
|
||
{ key: 'recent_24h_observation_count', label: '近24h事件数' },
|
||
{ key: 'recent_7d_observation_count', label: '近7d事件数' },
|
||
{ key: 'prefix_count', label: '观测前缀数' },
|
||
{ key: 'origin_asn_count', label: '观测 ASN 数' },
|
||
{ key: 'top_event_types', label: '主要事件类型' },
|
||
{ key: 'coverage_halo', label: '日常活跃度' },
|
||
{ key: 'related_satellites', label: '附近卫星' },
|
||
{ key: 'latest_event_type', label: '最近事件类型' },
|
||
{ key: 'latest_observed_at', label: '最近活跃时间' },
|
||
{ key: 'baseline_scope', label: '日常覆盖范围' },
|
||
{ key: 'status', label: '状态' }
|
||
]
|
||
},
|
||
supercomputer: {
|
||
icon: '🖥️',
|
||
title: '超算详情',
|
||
className: 'supercomputer',
|
||
fields: [
|
||
{ key: 'name', label: '名称' },
|
||
{ key: 'rank', label: '排名' },
|
||
{ key: 'r_max', label: 'Rmax', unit: 'GFlops' },
|
||
{ key: 'r_peak', label: 'Rpeak', unit: 'GFlops' },
|
||
{ key: 'country', label: '国家' },
|
||
{ key: 'city', label: '城市' }
|
||
]
|
||
},
|
||
gpu_cluster: {
|
||
icon: '🎮',
|
||
title: 'GPU集群详情',
|
||
className: 'gpu_cluster',
|
||
fields: [
|
||
{ key: 'name', label: '名称' },
|
||
{ key: 'country', label: '国家' },
|
||
{ key: 'city', label: '城市' }
|
||
]
|
||
}
|
||
};
|
||
|
||
function getPanel() {
|
||
return document.getElementById('info-panel');
|
||
}
|
||
|
||
function setupInfoCardDrag(panel) {
|
||
const app = document.getElementById('container');
|
||
if (!app) return;
|
||
|
||
const handle = panel.querySelector('.hud-panel-drag-handle');
|
||
if (!handle) return;
|
||
|
||
let isDragging = false;
|
||
let startPointerX = 0;
|
||
let startPointerY = 0;
|
||
let startLeft = 0;
|
||
let startTop = 0;
|
||
|
||
const stopDragging = () => {
|
||
isDragging = false;
|
||
panel.classList.remove('is-dragging');
|
||
document.body.style.userSelect = '';
|
||
};
|
||
|
||
const onMove = (event) => {
|
||
if (!isDragging) return;
|
||
const appRect = app.getBoundingClientRect();
|
||
const panelRect = panel.getBoundingClientRect();
|
||
const nextLeft = Math.min(
|
||
Math.max(startLeft + (event.clientX - startPointerX), 0),
|
||
appRect.width - panelRect.width,
|
||
);
|
||
const nextTop = Math.min(
|
||
Math.max(startTop + (event.clientY - startPointerY), 0),
|
||
appRect.height - panelRect.height,
|
||
);
|
||
panel.style.left = `${nextLeft}px`;
|
||
panel.style.top = `${nextTop}px`;
|
||
};
|
||
|
||
handle.addEventListener('pointerdown', (event) => {
|
||
if (event.target.closest('.hud-panel-close, .info-card-close')) return;
|
||
isDragging = true;
|
||
startPointerX = event.clientX;
|
||
startPointerY = event.clientY;
|
||
const appRect = app.getBoundingClientRect();
|
||
const panelRect = panel.getBoundingClientRect();
|
||
startLeft = panelRect.left - appRect.left;
|
||
startTop = panelRect.top - appRect.top;
|
||
panel.style.left = `${startLeft}px`;
|
||
panel.style.top = `${startTop}px`;
|
||
panel.style.right = 'auto';
|
||
panel.style.bottom = 'auto';
|
||
panel.classList.add('is-dragging');
|
||
document.body.style.userSelect = 'none';
|
||
handle.setPointerCapture?.(event.pointerId);
|
||
});
|
||
|
||
handle.addEventListener('pointermove', onMove);
|
||
handle.addEventListener('pointerup', stopDragging);
|
||
handle.addEventListener('pointercancel', stopDragging);
|
||
handle.addEventListener('lostpointercapture', stopDragging);
|
||
}
|
||
|
||
function mountCard() {
|
||
if (cardMounted) return;
|
||
|
||
const container = document.getElementById('container');
|
||
if (!container) return;
|
||
|
||
const panel = document.createElement('div');
|
||
panel.id = 'info-panel';
|
||
panel.className = 'hud-panel hud-panel-info';
|
||
panel.setAttribute('aria-live', 'polite');
|
||
panel.innerHTML = `
|
||
<div id="info-card" class="info-card">
|
||
<div class="info-card-header hud-panel-drag-handle">
|
||
<span class="info-card-icon" id="info-card-icon">🛰️</span>
|
||
<h3 id="info-card-title">详情</h3>
|
||
<button class="info-card-close hud-panel-close" type="button" aria-label="关闭详情">
|
||
<span class="material-symbols-rounded">close</span>
|
||
</button>
|
||
</div>
|
||
<div id="info-card-content" class="info-card-content"></div>
|
||
</div>
|
||
`;
|
||
|
||
container.appendChild(panel);
|
||
|
||
const card = panel.querySelector('#info-card');
|
||
const content = panel.querySelector('#info-card-content');
|
||
|
||
// Prevent pointer events from reaching the earth canvas
|
||
const stopEvent = (event) => { event.stopPropagation(); };
|
||
[
|
||
'mousemove', 'mousedown', 'mouseup', 'click', 'dblclick', 'wheel',
|
||
'pointerdown', 'pointerup', 'pointermove',
|
||
'touchstart', 'touchmove', 'touchend',
|
||
].forEach((evt) => card.addEventListener(evt, stopEvent, { passive: false }));
|
||
|
||
// Close button
|
||
const closeBtn = card.querySelector('.info-card-close');
|
||
if (closeBtn) {
|
||
closeBtn.addEventListener('click', (event) => {
|
||
event.stopPropagation();
|
||
hideInfoCard();
|
||
});
|
||
}
|
||
|
||
// Copy value on label click
|
||
content.addEventListener('click', async (event) => {
|
||
const label = event.target.closest('.info-card-label');
|
||
if (!label) return;
|
||
|
||
const property = label.closest('.info-card-property');
|
||
const valueEl = property?.querySelector('.info-card-value');
|
||
const value = valueEl?.textContent?.trim();
|
||
|
||
if (!value || value === '-') {
|
||
showStatusMessage('无可复制内容', 'warning');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await navigator.clipboard.writeText(value);
|
||
showStatusMessage(`已复制${label.textContent}:${value}`, 'success');
|
||
} catch (error) {
|
||
console.error('Copy failed:', error);
|
||
showStatusMessage('复制失败', 'error');
|
||
}
|
||
});
|
||
|
||
setupInfoCardDrag(panel);
|
||
|
||
cardMounted = true;
|
||
}
|
||
|
||
function positionPanel(panel, x, y, options = {}) {
|
||
if (!panel) return;
|
||
const margin = 12;
|
||
const offset = 14;
|
||
const vpW = window.innerWidth;
|
||
const vpH = window.innerHeight;
|
||
|
||
const scale = parseFloat(
|
||
getComputedStyle(document.documentElement).getPropertyValue('--hud-scale')
|
||
) || 1;
|
||
const estW = Math.min(300 * scale, vpW - 32);
|
||
const estH = Math.min(420 * scale, vpH * 0.7);
|
||
|
||
if (options.absolute === true) {
|
||
const clampedLeft = Math.min(
|
||
Math.max(margin, x),
|
||
Math.max(margin, vpW - estW - margin),
|
||
);
|
||
const clampedTop = Math.min(
|
||
Math.max(margin, y),
|
||
Math.max(margin, vpH - estH - margin),
|
||
);
|
||
panel.style.left = `${clampedLeft}px`;
|
||
panel.style.top = `${clampedTop}px`;
|
||
panel.style.right = 'auto';
|
||
panel.style.bottom = 'auto';
|
||
return;
|
||
}
|
||
|
||
let left = x + offset;
|
||
let top = y + offset;
|
||
|
||
if (left + estW > vpW - margin) left = x - estW - offset;
|
||
if (top + estH > vpH - margin) top = Math.max(margin, vpH - estH - margin);
|
||
|
||
panel.style.left = `${Math.max(margin, left)}px`;
|
||
panel.style.top = `${Math.max(margin, top)}px`;
|
||
panel.style.right = 'auto';
|
||
panel.style.bottom = 'auto';
|
||
}
|
||
|
||
function showPanel(x, y, options = {}) {
|
||
const panel = getPanel();
|
||
if (!panel) return;
|
||
if (x != null && y != null) positionPanel(panel, x, y, options);
|
||
panel.classList.add('is-visible');
|
||
}
|
||
|
||
function hidePanel() {
|
||
const panel = getPanel();
|
||
if (panel) panel.classList.remove('is-visible');
|
||
}
|
||
|
||
// No-op: event binding now happens lazily in mountCard()
|
||
export function initInfoCard() {}
|
||
|
||
export function setInfoCardNoBorder(noBorder = true) {
|
||
const card = document.getElementById('info-card');
|
||
if (card) {
|
||
card.classList.toggle('no-border', noBorder);
|
||
}
|
||
}
|
||
|
||
export function showInfoCard(type, data, options = {}) {
|
||
const config = CARD_CONFIG[type];
|
||
if (!config) {
|
||
console.warn('Unknown info card type:', type);
|
||
return;
|
||
}
|
||
|
||
mountCard();
|
||
|
||
currentType = type;
|
||
const card = document.getElementById('info-card');
|
||
const icon = document.getElementById('info-card-icon');
|
||
const title = document.getElementById('info-card-title');
|
||
const content = document.getElementById('info-card-content');
|
||
|
||
card.className = 'info-card ' + config.className;
|
||
icon.textContent = config.icon;
|
||
title.textContent = config.title;
|
||
|
||
let html = '';
|
||
for (const field of config.fields) {
|
||
let value = data[field.key];
|
||
|
||
if (value === undefined || value === null || value === '') {
|
||
value = '-';
|
||
} else if (typeof value === 'number') {
|
||
value = value.toLocaleString();
|
||
}
|
||
|
||
if (field.unit && value !== '-') {
|
||
value = value + ' ' + field.unit;
|
||
}
|
||
|
||
html += `
|
||
<div class="info-card-property">
|
||
<span class="info-card-label">${field.label}</span>
|
||
<span class="info-card-value">${value}</span>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
content.innerHTML = html;
|
||
showPanel(options.x, options.y, options);
|
||
}
|
||
|
||
export function hideInfoCard() {
|
||
hidePanel();
|
||
currentType = null;
|
||
}
|
||
|
||
export function getCurrentType() {
|
||
return currentType;
|
||
}
|