// 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 = `
🛰️

详情

`; 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 += `
${field.label} ${value}
`; } content.innerHTML = html; showPanel(options.x, options.y, options); } export function hideInfoCard() { hidePanel(); currentType = null; } export function getCurrentType() { return currentType; }