// info-card.js - Unified info card module import { showStatusMessage } from './ui.js'; let currentType = null; let cardMounted = false; let typewriterTimerId = null; let typewriterToken = 0; let pendingMobileDetailState = null; let mobileDetailsListenerBound = false; let renderedMobileDetailKey = null; function getNewsSummaryText(data) { return (data?.summary || data?.title || '').trim() || '暂无摘要'; } function getNewsSummaryPreview(data, maxLength = 34) { const text = getNewsSummaryText(data).replace(/\s+/g, ' ').trim(); if (text.length <= maxLength) return text; return `${text.slice(0, Math.max(0, maxLength - 1))}…`; } function stopTypewriterAnimation() { typewriterToken += 1; if (typewriterTimerId) { window.clearTimeout(typewriterTimerId); typewriterTimerId = null; } } function startTypewriterAnimation(target, text, options = {}) { if (!(target instanceof HTMLElement)) return; stopTypewriterAnimation(); const content = typeof text === 'string' ? text : ''; const token = typewriterToken; const stepMs = Number.isFinite(options.stepMs) ? options.stepMs : 22; const startDelayMs = Number.isFinite(options.startDelayMs) ? options.startDelayMs : 90; target.textContent = ''; target.classList.add('is-typing'); let index = 0; const tick = () => { if (token !== typewriterToken) return; index += 1; target.textContent = content.slice(0, index); if (index < content.length) { typewriterTimerId = window.setTimeout(tick, stepMs); return; } target.classList.remove('is-typing'); typewriterTimerId = null; }; typewriterTimerId = window.setTimeout(() => { if (token !== typewriterToken) return; if (!content) { target.classList.remove('is-typing'); typewriterTimerId = null; return; } tick(); }, startDelayMs); } function renderNewsCardContent(content, data) { if (!(content instanceof HTMLElement)) return; const summary = getNewsSummaryText(data); content.innerHTML = `
NEWS SIGNAL
${data?.title || '新闻事件'}
SUMMARY
`; const summaryEl = content.querySelector('[data-news-summary]'); startTypewriterAnimation(summaryEl, summary); } function renderMobileNewsCardContent(content, data) { if (!(content instanceof HTMLElement)) return; const summary = getNewsSummaryText(data); content.innerHTML = `
NEWS SIGNAL
${data?.title || '新闻事件'}
SUMMARY
`; const summaryEl = content.querySelector('[data-news-summary]'); startTypewriterAnimation(summaryEl, summary, { stepMs: 20, startDelayMs: 70 }); } function renderMobileDetailContent(type, config, data) { const content = document.getElementById('mobile-info-card-content'); if (!(content instanceof HTMLElement)) return; stopTypewriterAnimation(); if (type === 'news') { renderMobileNewsCardContent(content, data); return; } 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; } function getMobileDetailRenderKey(type, data) { if (type !== 'news') return null; return [ type, data?.id ?? '', data?.url ?? '', data?.published_at ?? '', data?.title ?? '', ].join('|'); } function isMobileDetailsDrawerActive() { const detailsSlot = document.querySelector('[data-drawer-slot="details"]'); return detailsSlot instanceof HTMLElement && detailsSlot.classList.contains('is-active'); } function ensureMobileDetailsListener() { if (mobileDetailsListenerBound) return; mobileDetailsListenerBound = true; window.addEventListener('earth:open-details-tab', () => { if (!document.body.classList.contains('layout-mode-mobile')) return; if (!pendingMobileDetailState) return; const nextKey = getMobileDetailRenderKey( pendingMobileDetailState.type, pendingMobileDetailState.data, ); if (nextKey && nextKey === renderedMobileDetailKey) return; renderMobileDetailContent( pendingMobileDetailState.type, pendingMobileDetailState.config, pendingMobileDetailState.data, ); renderedMobileDetailKey = nextKey; }); } function renderDefaultCardContent(content, config, data) { 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; } // ── 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 'news': return data.title || '新闻事件'; case 'bgp_collector': return data.collector || 'BGP观测站'; case 'supercomputer': return data.name || '超算'; case 'gpu_cluster': return data.name || 'GPU集群'; case 'vessel': return data.name || '船只'; 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 'news': return getNewsSummaryPreview(data, 30) || '态势新闻'; case 'bgp_collector': return data.location || 'BGP观测站'; case 'supercomputer': return data.country || '超级计算机'; case 'gpu_cluster': return data.country || 'GPU集群'; case 'vessel': return data.vessel_type || 'AIS 船只'; default: return ''; } } function positionMobilePopup(popup, touchX, touchY, options = {}) { 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; if (options.absolute === true) { const left = Math.max(margin, Math.min(touchX, vpW - popW - margin)); const top = Math.max(margin, Math.min(touchY, bottomBound - popH - margin)); popup.style.left = `${left}px`; popup.style.top = `${top}px`; return; } 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, options = {}) { // 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.dataset.dockSide = options.dockSide === 'right' ? 'right' : 'left'; popup.classList.toggle('earth-mobile-popup--anchor-stable', options.anchorStable === true); popup.removeAttribute('hidden'); popup.classList.remove('is-visible'); requestAnimationFrame(() => { positionMobilePopup(popup, x, y, options); if (options.reveal === false) { return; } if (token !== popupShowToken) return; // superseded void popup.getBoundingClientRect(); 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.classList.remove('earth-mobile-popup--anchor-stable'); delete popup.dataset.dockSide; 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; const emitDragEvent = (dragging) => { const rect = popup.getBoundingClientRect(); window.dispatchEvent(new CustomEvent('earth:info-card-drag', { detail: { left: rect.left, top: rect.top, width: rect.width, height: rect.height, dragging, }, })); }; 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`; emitDragEvent(true); }); document.addEventListener('pointerup', (e) => { if (e.pointerId !== dragPointerId) return; const wasDragged = dragged; dragPointerId = null; dragged = false; emitDragEvent(false); if (!wasDragged) { window.dispatchEvent(new CustomEvent('earth:open-details-tab')); } }); document.addEventListener('pointercancel', (e) => { if (e.pointerId === dragPointerId) { dragPointerId = null; dragged = false; emitDragEvent(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: 'constellation', label: '星座/分组' }, { key: 'footprint_capability', label: '覆盖能力' }, { key: 'current_display', label: '当前显示' }, { key: 'footprint_model', label: '覆盖模型' }, { 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: '摘要' } ] }, news: { icon: '📰', title: '新闻事件详情', className: 'news', fields: [ { key: 'source', label: '来源' }, { key: 'published_at_display', label: '发布时间' }, { key: 'location_label', label: '发生地' }, { key: 'region_label', label: '区域' }, { key: 'feed_name', label: '聚合源' }, { key: 'summary', label: '摘要' }, { key: 'url', 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: '更新时间' } ] }, vessel: { icon: '🚢', title: '船只详情', className: 'vessel', fields: [ { key: 'name', label: '名称' }, { key: 'mmsi', label: 'MMSI' }, { key: 'imo', label: 'IMO' }, { key: 'flag', label: '旗帜' }, { key: 'vessel_type', label: '船型' }, { key: 'speed', label: '当前航速', unit: 'kn' }, { key: 'course', label: '航向', unit: '°' }, { key: 'status', label: '状态' }, { key: 'length', label: '船长', unit: 'm' }, { key: 'received_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 emitDragEvent = () => { const rect = panel.getBoundingClientRect(); window.dispatchEvent( new CustomEvent('earth:info-card-drag', { detail: { left: rect.left, top: rect.top, width: rect.width, height: rect.height, dragging: isDragging, }, }) ); }; 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 = ''; emitDragEvent(); }; 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`; emitDragEvent(); }; 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); emitDragEvent(); }); // 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.setAttribute('aria-hidden', 'true'); panel.setAttribute('hidden', ''); 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; if (document.body.classList.contains('layout-mode-mobile')) { panel.style.left = '8px'; panel.style.right = '8px'; panel.style.top = 'auto'; panel.style.bottom = 'calc(84px + env(safe-area-inset-bottom, 0px))'; 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; panel.classList.toggle('hud-panel-info--anchor-stable', options.anchorStable === true); panel.removeAttribute('hidden'); panel.setAttribute('aria-hidden', 'false'); if (x != null && y != null) positionPanel(panel, x, y, options); if (options.reveal === false) { panel.classList.remove('is-visible'); return; } requestAnimationFrame(() => { panel.classList.add('is-visible'); }); document.body.classList.add('earth-info-open'); window.dispatchEvent( new CustomEvent('earth:info-card-visibility-change', { detail: { visible: true } }) ); } function hidePanel() { const panel = getPanel(); if (panel) { panel.classList.remove('is-visible'); panel.classList.remove('hud-panel-info--anchor-stable'); panel.setAttribute('aria-hidden', 'true'); panel.setAttribute('hidden', ''); } document.body.classList.remove('earth-info-open'); window.dispatchEvent( new CustomEvent('earth:info-card-visibility-change', { detail: { visible: false } }) ); } // 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; } if (document.body.classList.contains('layout-mode-mobile')) { currentType = type; pendingMobileDetailState = { type, config, data }; ensureMobileDetailsListener(); // Fill drawer details slot (accessible when user taps popup → opens details tab) const icon = document.getElementById('mobile-info-card-icon'); const title = document.getElementById('mobile-info-card-title'); const typeLabel = document.getElementById('mobile-info-card-type'); const content = document.getElementById('mobile-info-card-content'); if (icon) icon.textContent = config.icon; if (title) { title.textContent = type === 'news' ? (data?.title || '新闻事件') : config.title; } if (typeLabel) { typeLabel.textContent = type === 'news' ? 'news signal' : type.replaceAll('_', ' '); } if (content && type !== 'news') { renderMobileDetailContent(type, config, data); renderedMobileDetailKey = null; } else if (content && type === 'news' && isMobileDetailsDrawerActive()) { renderMobileNewsCardContent(content, data); renderedMobileDetailKey = getMobileDetailRenderKey(type, data); } // Show the floating mini popup near the touch point (requires coordinates) if (options.x != null && options.y != null) { ensurePopupClickHandler(); showMobilePopup(type, data, options.x, options.y, options); document.body.classList.add('earth-info-open'); window.dispatchEvent( new CustomEvent('earth:info-card-visibility-change', { detail: { visible: true } }) ); } 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'); stopTypewriterAnimation(); card.className = 'info-card ' + config.className; icon.textContent = config.icon; title.textContent = type === 'news' ? (data?.title || '新闻事件') : config.title; if (type === 'news') { renderNewsCardContent(content, data); } else { renderDefaultCardContent(content, config, data); } showPanel(options.x, options.y, options); } export function hideInfoCard() { stopTypewriterAnimation(); if (document.body.classList.contains('layout-mode-mobile')) { hideMobilePopup(); document.body.classList.remove('earth-info-open'); window.dispatchEvent( new CustomEvent('earth:info-card-visibility-change', { detail: { visible: false } }) ); currentType = null; pendingMobileDetailState = null; renderedMobileDetailKey = null; return; } hidePanel(); currentType = null; } export function getCurrentType() { return currentType; }