// 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;
const IDENTIFIER_FIELD_KEYS = new Set([
'mmsi',
'mmsi_display',
'imo',
'imo_display',
'callsign',
]);
const MAX_VESSEL_MEDIA_TILES = 4;
function formatInfoCardValue(field, rawValue) {
if (rawValue === undefined || rawValue === null || rawValue === '') {
return '-';
}
let value = rawValue;
if (IDENTIFIER_FIELD_KEYS.has(field.key)) {
value = String(value);
} else if (typeof value === 'number') {
value = value.toLocaleString();
}
if (field.unit && value !== '-') {
value = value + ' ' + field.unit;
}
return value;
}
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 || '新闻事件'}
`;
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 || '新闻事件'}
`;
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) {
const value = formatInfoCardValue(field, data[field.key]);
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) {
const value = formatInfoCardValue(field, data[field.key]);
const sourceLabel = getFieldSourceLabel(data, field.key);
html += `
${field.label}
${value}${sourceLabel}
`;
}
if (config.className === 'vessel') {
html += renderVesselEnrichmentSection(data?.enrichment);
}
content.innerHTML = html;
}
function getFieldSourceLabel(data, fieldKey) {
const sources = data && typeof data === 'object' ? data.field_sources : null;
if (!sources || typeof sources !== 'object') return '';
const source = sources[fieldKey];
if (!source) return '';
return ` ${source}`;
}
function renderVesselEnrichmentSection(enrichment) {
if (!enrichment || typeof enrichment !== 'object') return '';
const profile = enrichment.profile;
const media = enrichment.media;
if (!profile && !media) {
return `
`;
}
let inner = '';
if (profile?.payload && typeof profile.payload === 'object') {
inner += renderEnrichmentPayloadRows(profile.payload);
inner += renderEnrichmentMeta('资料', profile);
}
if (media?.payload && typeof media.payload === 'object') {
if (Array.isArray(media.payload.images) && media.payload.images.length > 0) {
const tiles = media.payload.images
.slice(0, MAX_VESSEL_MEDIA_TILES)
.map((url) => `
`)
.join('');
inner += `${tiles}
`;
}
inner += renderEnrichmentMeta('媒体', media);
}
if (!inner) {
inner = '资料缓存中
';
}
return `
`;
}
function renderEnrichmentPayloadRows(payload) {
let rows = '';
for (const [key, value] of Object.entries(payload)) {
if (value === null || value === undefined || value === '') continue;
if (typeof value === 'object') continue;
rows += `
${key}
${String(value)}
`;
}
return rows;
}
function renderEnrichmentMeta(label, record) {
const parts = [];
if (record.source) parts.push(`来源 ${record.source}`);
if (record.fetched_at) parts.push(`更新 ${record.fetched_at}`);
if (record.confidence !== null && record.confidence !== undefined) {
parts.push(`置信 ${Number(record.confidence).toFixed(2)}`);
}
if (!parts.length) return '';
return `${label}:${parts.join(' · ')}
`;
}
// ── 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;
}