// info-card.js - Unified info card module import { showStatusMessage } from './ui.js'; let currentType = null; let cardMounted = false; // ── Mobile popup ───────────────────────────────────────────── function getMobilePopupTitle(type, data) { switch (type) { case 'cable': return data.name || '海缆'; case 'landing_point': return data.name || '登陆点'; case 'satellite': return data.name || '卫星'; case 'bgp': return data.anomaly_type || 'BGP事件'; case 'bgp_collector': return data.collector || 'BGP观测站'; case 'supercomputer': return data.name || '超算'; case 'gpu_cluster': return data.name || 'GPU集群'; default: return '详情'; } } function getMobilePopupSubtitle(type, data) { switch (type) { case 'cable': return data.owner || data.status || '海缆'; case 'landing_point': return data.country || '登陆点'; case 'satellite': return data.norad_id ? `NORAD ${data.norad_id}` : '卫星'; case 'bgp': return data.severity || 'BGP路由异常'; case 'bgp_collector': return data.location || 'BGP观测站'; case 'supercomputer': return data.country || '超级计算机'; case 'gpu_cluster': return data.country || 'GPU集群'; default: return ''; } } function positionMobilePopup(popup, touchX, touchY) { const margin = 14; const drawerClearance = 52; const vpW = window.innerWidth; const vpH = window.innerHeight; const safeBottom = parseFloat( getComputedStyle(document.documentElement).getPropertyValue('--safe-bottom') ) || 0; const bottomBound = vpH - drawerClearance - safeBottom; // Measure actual popup size (it's rendered but invisible via opacity) const popW = popup.offsetWidth || 200; const popH = popup.offsetHeight || 68; const gap = 22; const spaceRight = vpW - touchX; const spaceLeft = touchX; const spaceBottom = bottomBound - touchY; const spaceTop = touchY; let left, top; // Horizontal: side with more room if (spaceRight >= popW + gap + margin) { left = touchX + gap; } else if (spaceLeft >= popW + gap + margin) { left = touchX - gap - popW; } else { left = Math.max(margin, Math.min(touchX - popW / 2, vpW - popW - margin)); } // Vertical: prefer above touch, then below if (spaceTop >= popH + gap + margin) { top = touchY - gap - popH; } else if (spaceBottom >= popH + gap + margin) { top = touchY + gap; } else { top = Math.max(margin, Math.min(touchY - popH / 2, bottomBound - popH - margin)); } left = Math.max(margin, Math.min(left, vpW - popW - margin)); top = Math.max(margin, Math.min(top, bottomBound - popH - margin)); popup.style.left = `${left}px`; popup.style.top = `${top}px`; } let popupShowToken = 0; function showMobilePopup(type, data, x, y) { // Require coordinates — skip if called without position (e.g. from handleCableClick) if (x == null || y == null) return; const popup = document.getElementById('earth-mobile-popup'); const iconEl = document.getElementById('earth-mobile-popup-icon'); const titleEl = document.getElementById('earth-mobile-popup-title'); const subEl = document.getElementById('earth-mobile-popup-sub'); if (!popup || !iconEl || !titleEl || !subEl) return; const config = CARD_CONFIG[type]; if (!config) return; iconEl.textContent = config.icon; titleEl.textContent = getMobilePopupTitle(type, data); subEl.textContent = getMobilePopupSubtitle(type, data); // Invalidate any in-flight hide listener popupShowToken += 1; const token = popupShowToken; popup.removeAttribute('hidden'); popup.classList.remove('is-visible'); requestAnimationFrame(() => { positionMobilePopup(popup, x, y); requestAnimationFrame(() => { if (token !== popupShowToken) return; // superseded popup.classList.add('is-visible'); }); }); } function hideMobilePopup() { const popup = document.getElementById('earth-mobile-popup'); if (!popup) return; popupShowToken += 1; // invalidate any pending show popup.classList.remove('is-visible'); popup.addEventListener('transitionend', () => { if (!popup.classList.contains('is-visible')) { popup.setAttribute('hidden', ''); } }, { once: true }); } let popupClickBound = false; function ensurePopupClickHandler() { if (popupClickBound) return; popupClickBound = true; const popup = document.getElementById('earth-mobile-popup'); if (!popup) return; let dragPointerId = null; let startX = 0, startY = 0; let startLeft = 0, startTop = 0; let dragged = false; const DRAG_THRESHOLD = 10; popup.addEventListener('pointerdown', (e) => { if (e.button > 0) return; e.stopPropagation(); dragPointerId = e.pointerId; startX = e.clientX; startY = e.clientY; const rect = popup.getBoundingClientRect(); startLeft = rect.left; startTop = rect.top; dragged = false; }); // Track drag at document level so pointer can leave popup bounds document.addEventListener('pointermove', (e) => { if (e.pointerId !== dragPointerId) return; const dx = e.clientX - startX; const dy = e.clientY - startY; if (Math.hypot(dx, dy) < DRAG_THRESHOLD) return; dragged = true; e.stopPropagation(); const margin = 8; const left = Math.max(margin, Math.min(startLeft + dx, window.innerWidth - popup.offsetWidth - margin)); const top = Math.max(margin, Math.min(startTop + dy, window.innerHeight - popup.offsetHeight - margin)); popup.style.left = `${left}px`; popup.style.top = `${top}px`; }); document.addEventListener('pointerup', (e) => { if (e.pointerId !== dragPointerId) return; const wasDragged = dragged; dragPointerId = null; dragged = false; if (!wasDragged) { window.dispatchEvent(new CustomEvent('earth:open-details-tab')); } }); document.addEventListener('pointercancel', (e) => { if (e.pointerId === dragPointerId) { dragPointerId = null; dragged = false; } }); // Block click from bubbling to document (which would close the drawer) popup.addEventListener('click', (e) => e.stopPropagation()); } 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: '投入使用' } ] }, landing_point: { icon: '📍', title: '登陆点详情', className: 'cable', fields: [ { key: 'name', label: '名称' }, { key: 'country', label: '国家' }, { key: 'status', label: '状态' }, { key: 'cable_count', label: '关联海缆数' }, { key: 'cables', 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: 'site_type_label', label: '类型' }, { key: 'rank', label: '排名' }, { key: 'capacity', label: '实测算力' }, { key: 'vendor', label: '厂商' }, { key: 'operator', label: '运营方' }, { key: 'cores', label: '核心数' }, { key: 'power', label: '功耗', unit: 'kW' }, { key: 'country', label: '国家' }, { key: 'city', label: '城市' }, { key: 'location_precision_label', label: '位置精度' }, { key: 'source', label: '来源' }, { key: 'updated_at', label: '更新时间' } ] }, gpu_cluster: { icon: '🎮', title: 'GPU集群详情', className: 'gpu_cluster', fields: [ { key: 'name', label: '名称' }, { key: 'site_type_label', label: '类型' }, { key: 'capacity', label: '估算算力' }, { key: 'gpu_count', label: 'GPU 数量' }, { key: 'gpu_type', label: 'GPU 型号' }, { key: 'vendor', label: '芯片/平台' }, { key: 'operator', label: '运营方' }, { key: 'country', label: '国家' }, { key: 'city', label: '城市' }, { key: 'location_precision_label', label: '位置精度' }, { key: 'source', label: '来源' }, { key: 'updated_at', 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 activePointerId = null; let startPointerX = 0; let startPointerY = 0; let startLeft = 0; let startTop = 0; const stopDragging = (event) => { if ( event && activePointerId !== null && "pointerId" in event && event.pointerId !== activePointerId ) { return; } isDragging = false; activePointerId = null; panel.classList.remove('is-dragging'); document.body.style.userSelect = ''; }; const onMove = (event) => { if (!isDragging) return; if (activePointerId !== null && event.pointerId !== activePointerId) return; event.preventDefault(); 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; event.preventDefault(); isDragging = true; activePointerId = event.pointerId; 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); }); // Listen in capture phase so card-level stopPropagation used to shield the // globe canvas does not swallow the drag stream before we can reposition. window.addEventListener('pointermove', onMove, { passive: false, capture: true }); window.addEventListener('pointerup', stopDragging, { capture: true }); window.addEventListener('pointercancel', stopDragging, { capture: true }); 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 = `