// info-card.js - Unified info card module import { showStatusMessage } from './ui.js'; import { getNewsDisplaySummary, getNewsDisplayTitle, } from './news-locale.js'; let currentType = null; let cardMounted = false; let typewriterTimerId = null; let typewriterToken = 0; let pendingMobileDetailState = null; let mobileDetailsListenerBound = false; let renderedMobileDetailKey = null; const locationCollectStateCache = new Map(); const locationCollectContextCache = new Map(); // Latest candidate list per cache-key. Populated whenever state.candidates is // updated, and read by the click handler via `data-candidate-index` so we // never have to round-trip a candidate object through an HTML attribute. const locationCollectCandidatesByKey = new Map(); const IDENTIFIER_FIELD_KEYS = new Set([ 'mmsi', 'mmsi_display', 'imo', 'imo_display', 'callsign', ]); const MAX_VESSEL_MEDIA_TILES = 4; function getLocationCollectCacheKey(context) { const entityType = context?.entityType || 'unknown'; const entityId = context?.entityId || context?.sourceId || ''; if (!entityId) return ''; return `${entityType}:${entityId}`; } function getLocationCollectState(contextOrKey) { const key = typeof contextOrKey === 'string' ? contextOrKey : getLocationCollectCacheKey(contextOrKey); return key ? locationCollectStateCache.get(key) || null : null; } function setLocationCollectState(contextOrKey, patch = {}) { const key = typeof contextOrKey === 'string' ? contextOrKey : getLocationCollectCacheKey(contextOrKey); if (!key) return null; if (typeof contextOrKey !== 'string') { locationCollectContextCache.set(key, contextOrKey); } if (Array.isArray(patch.candidates)) { locationCollectCandidatesByKey.set(key, patch.candidates); } const previous = locationCollectStateCache.get(key) || {}; const next = { ...previous, ...patch, updatedAt: Date.now(), }; locationCollectStateCache.set(key, next); updateLocationCollectDomFromState(key); return next; } function clearLocationCollectState(contextOrKey) { const key = typeof contextOrKey === 'string' ? contextOrKey : getLocationCollectCacheKey(contextOrKey); if (!key) return; locationCollectStateCache.delete(key); locationCollectContextCache.delete(key); locationCollectCandidatesByKey.delete(key); updateLocationCollectDomFromState(key); } function getCandidateForButton(button) { if (!(button instanceof HTMLElement)) return null; const root = button.closest('[data-collect-cache-key]'); if (!(root instanceof HTMLElement)) return null; const key = root.dataset.collectCacheKey || ''; const list = locationCollectCandidatesByKey.get(key) || []; const index = Number(button.dataset.candidateIndex); if (!Number.isFinite(index) || index < 0 || index >= list.length) return null; return list[index]; } function updateLocationCollectDomFromState(key) { if (!key) return; const state = getLocationCollectState(key); document.querySelectorAll(`[data-collect-cache-key="${escapeCssIdentifier(key)}"]`).forEach((root) => { hydrateLocationCollectRoot(root, state); ensureCandidateActionBindings(root, locationCollectContextCache.get(key)); }); } 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 escapeInfoCardHtml(value) { return String(value ?? '').replace(/[&<>"']/g, (char) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', }[char])); } function escapeCssIdentifier(value) { if (window.CSS && typeof window.CSS.escape === 'function') { return window.CSS.escape(String(value)); } return String(value).replace(/["\\]/g, '\\$&'); } function getNewsSummaryText(data) { return getNewsDisplaySummary(data); } 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); const title = getNewsDisplayTitle(data); content.innerHTML = `
新闻信号
${escapeInfoCardHtml(title)}
概要
`; const summaryEl = content.querySelector('[data-news-summary]'); startTypewriterAnimation(summaryEl, summary); } function renderMobileNewsCardContent(content, data) { if (!(content instanceof HTMLElement)) return; const summary = getNewsSummaryText(data); const title = getNewsDisplayTitle(data); content.innerHTML = `
新闻信号
${escapeInfoCardHtml(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; } if (config.className === 'compute_unresolved') { renderComputeCenterUnresolvedContent(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) { if (config.className === 'compute_unresolved') { renderComputeCenterUnresolvedContent(content, data); return; } 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); } const collectContext = buildLocationCollectContext(config, data); if (collectContext) { html += renderLocationCollectSection(collectContext); } content.innerHTML = html; if (collectContext) { bindLocationCollectControls(content, collectContext); } } // Resolve which entity (if any) supports the shared "collect candidate location" // flow on this info card. Returns a context object the renderer / binder both // consume, or null when the entity has no location-collection backend. function buildLocationCollectContext(config, data) { if (!data || typeof data !== 'object') return null; if (config.className === 'supercomputer' || config.className === 'gpu_cluster') { if (!data.source_id) return null; return { entityType: 'compute_center', entityId: data.source_id, data, needsConfirmation: data.needs_confirmation === true || data.location_source === 'nominatim_online_geocode', collect: async () => { const mod = await import('./compute-centers.js'); return mod.collectComputeCenterLocation(data.source_id, { name: data.name, operator: data.operator, site: data.site || data.metadata?.site, organization: data.metadata?.organization, city: data.city, country: data.country, source: data.source, record_id: data.id, }); }, save: async (candidate) => { const mod = await import('./compute-centers.js'); return mod.saveComputeCenterLocation(data.source_id, candidate, { name: data.name, operator: data.operator, site: data.site || data.metadata?.site, city: data.city, country: data.country, source: data.source, }); }, }; } if (config.className === 'bgp') { const collectorId = data.collector; if (!collectorId) return null; return { entityType: 'bgp_collector', entityId: collectorId, data, needsConfirmation: data.needs_confirmation === true || (data.location_source && data.location_source !== 'source_coordinates'), collect: async () => { const mod = await import('./compute-centers.js'); return mod.collectLocationCandidates( `/api/v1/bgp/collectors/${encodeURIComponent(collectorId)}/collect-location`, { site: data.site || data.matched_location_name, city: data.city, country: data.country, operator: data.operator, }, ); }, }; } return null; } function renderLocationCollectSection(context) { const buttonLabel = context.needsConfirmation ? '重新自动采集坐标' : '自动采集坐标候选'; const cacheKey = getLocationCollectCacheKey(context); const cached = getLocationCollectState(cacheKey); return `
${escapeInfoCardHtml(cached?.statusText || '')}
${renderCachedCollectCandidates(cached)}
`; } function renderCachedCollectCandidates(state) { const candidates = Array.isArray(state?.candidates) ? state.candidates : []; if (!candidates.length) return ''; return candidates .slice(0, 5) .map((candidate, index) => renderCollectCandidateRow(candidate, index === 0, index)) .join(''); } function hydrateLocationCollectRoot(root, state) { if (!(root instanceof HTMLElement)) return; const statusEl = root.querySelector('[data-collect-status], [data-unresolved-status]'); const candidatesEl = root.querySelector('[data-collect-candidates], [data-unresolved-candidates]'); const button = root.querySelector('[data-collect-action="run"], [data-unresolved-collect]'); if (statusEl) statusEl.textContent = state?.statusText || ''; if (candidatesEl) candidatesEl.innerHTML = renderCachedCollectCandidates(state); if (button instanceof HTMLButtonElement) button.disabled = state?.loading === true; } function rememberLocationCollectContext(context) { const key = getLocationCollectCacheKey(context); if (!key) return ''; locationCollectContextCache.set(key, context); return key; } function getLocationCollectContextForRoot(root, fallbackContext) { const key = root?.dataset?.collectCacheKey || getLocationCollectCacheKey(fallbackContext); if (key && locationCollectContextCache.has(key)) { return locationCollectContextCache.get(key); } if (fallbackContext) { rememberLocationCollectContext(fallbackContext); return fallbackContext; } const collectButton = root?.querySelector?.('[data-unresolved-collect]'); try { const parsed = JSON.parse(collectButton?.dataset?.contextJson || '{}'); if (!parsed?.sourceId) return null; return { ...parsed, entityType: 'compute_center', entityId: parsed.sourceId, isUnresolved: true, save: async (candidate) => { const mod = await import('./compute-centers.js'); return mod.saveComputeCenterLocation(parsed.sourceId, candidate, parsed); }, }; } catch { return null; } } // Single delegated click handler attached once per cache-key root. // Lookup model: button -> closest('[data-collect-cache-key]') -> map by key. // Candidates live in `locationCollectCandidatesByKey`, indexed by // `data-candidate-index` on the button -- no JSON round-tripped through HTML. function ensureCandidateActionBindings(rootOrChild, context) { const root = rootOrChild instanceof Element ? (rootOrChild.closest?.('[data-collect-cache-key]') || rootOrChild) : null; if (!(root instanceof HTMLElement)) return; const key = rememberLocationCollectContext(context) || root.dataset.collectCacheKey || ''; if (key) root.dataset.collectCacheKey = key; if (root.dataset.candidateActionsBound === 'true') return; root.dataset.candidateActionsBound = 'true'; root.addEventListener('click', async (event) => { const target = event.target instanceof Element ? event.target : null; if (!target) return; const previewButton = target.closest('[data-preview-candidate]'); const saveButton = target.closest('[data-save-candidate]'); const button = previewButton || saveButton; if (!(button instanceof HTMLElement) || !root.contains(button)) return; event.stopPropagation(); const actionContext = getLocationCollectContextForRoot(root, context); if (!actionContext) return; const candidate = getCandidateForButton(button); if (!candidate) return; if (previewButton) { const lat = Number(candidate.latitude); const lon = Number(candidate.longitude); if (!Number.isFinite(lat) || !Number.isFinite(lon)) return; window.dispatchEvent( new CustomEvent('earth:preview-location-candidate', { detail: { latitude: lat, longitude: lon, entityType: actionContext.entityType, entityId: actionContext.entityId, candidate, }, }), ); return; } if (typeof actionContext.save !== 'function') return; const statusEl = root.querySelector('[data-collect-status], [data-unresolved-status]'); button.disabled = true; if (statusEl) statusEl.textContent = '正在保存所选坐标...'; try { const saveResult = await actionContext.save(candidate); setLocationCollectState(actionContext, { loading: false, statusText: '坐标已保存', candidates: [], }); if (statusEl) statusEl.textContent = '坐标已保存'; window.dispatchEvent( new CustomEvent('earth:compute-center-location-saved', { detail: { entityType: actionContext.entityType, entityId: actionContext.entityId, candidate, context: actionContext, result: saveResult, }, }), ); if (actionContext.entityType === 'compute_center' && actionContext.isUnresolved === true) { const itemRoot = root.closest('[data-unresolved-item]'); if (itemRoot) { removeResolvedUnresolvedItem( itemRoot.closest('#info-card-content') || document, itemRoot, ); } } } catch (error) { console.error('save compute-center location failed', error); if (statusEl) statusEl.textContent = `保存失败:${error?.message || error}`; button.disabled = false; } }); } function formatLocationCollectFailure(result) { const regularReason = result?.failure_reason || '常规来源没有可用坐标候选'; const llmReason = result?.llm_failure_reason; if (llmReason) { return `常规来源无结果;LLM 兜底未生成可用候选:${llmReason}`; } const attempted = Array.isArray(result?.attempted_queries) ? result.attempted_queries : []; const attemptedLlm = attempted.some((query) => String(query || '').startsWith('llm_factcheck:')); if (attemptedLlm) { return `常规来源无结果;LLM 兜底已尝试但没有返回可用候选。${regularReason}`; } return regularReason; } function bindLocationCollectControls(content, context) { const collectRoot = content.querySelector('[data-collect-entity-id]'); if (!collectRoot) return; const button = collectRoot.querySelector('[data-collect-action="run"]'); if (!button) return; // Bind the delegated preview/save handler once -- works both before and // after the user has run "采集", because innerHTML replacement of the // candidates container does not detach handlers higher up the tree. ensureCandidateActionBindings(collectRoot, context); const cachedState = getLocationCollectState(context); if (cachedState) { hydrateLocationCollectRoot(collectRoot, cachedState); } button.addEventListener('click', async (event) => { event.stopPropagation(); button.disabled = true; setLocationCollectState(context, { loading: true, statusText: '正在采集坐标候选...', candidates: [], }); try { const result = await context.collect(); if (!result?.success) { setLocationCollectState(context, { loading: false, statusText: `未能采集到坐标:${formatLocationCollectFailure(result)}`, candidates: [], result, }); return; } const candidates = Array.isArray(result.candidates) ? result.candidates : []; setLocationCollectState(context, { loading: false, statusText: `共找到 ${candidates.length} 个候选位置`, candidates, result, }); } catch (error) { console.error('collect-location failed', error); setLocationCollectState(context, { loading: false, statusText: `采集失败:${error?.message || error}`, candidates: [], }); } finally { button.disabled = false; updateLocationCollectDomFromState(getLocationCollectCacheKey(context)); } }, { once: false }); } function renderCollectCandidateRow(candidate, isBest, index) { const precisionLabel = { precise: '精确', site: '站点', city: '城市', }[candidate.precision] || candidate.precision || '未知'; const confidence = Number.isFinite(Number(candidate.confidence)) ? `${Math.round(Number(candidate.confidence) * 100)}%` : '-'; const safeIndex = Number.isFinite(Number(index)) ? Number(index) : 0; const name = escapeInfoCardHtml(candidate.matched_location_name || candidate.display_name || '候选'); const sourceLabel = escapeInfoCardHtml(candidate.source || ''); return `
${name} ${escapeInfoCardHtml(precisionLabel)}
${sourceLabel} 置信 ${escapeInfoCardHtml(confidence)}
${Number(candidate.latitude).toFixed(4)}, ${Number(candidate.longitude).toFixed(4)}
`; } function getUnresolvedComputeCenterContext(item) { const metadata = item?.metadata && typeof item.metadata === 'object' ? item.metadata : {}; return { entityType: 'compute_center', entityId: item?.source_id || item?.id || '', sourceId: item?.source_id || item?.id || '', recordId: item?.id || item?.record_id || '', name: item?.name || item?.title || '未命名算力中心', site_type: item?.site_type || metadata.site_type || '', operator: item?.operator || item?.vendor || metadata.operator || '', site: item?.site || metadata.site || metadata.organization || '', city: item?.city || metadata.city || '', country: item?.country || metadata.country || '', source: item?.source || metadata.source || '', }; } function renderComputeCenterUnresolvedContent(content, data) { const items = Array.isArray(data?.items) ? data.items : []; if (!items.length) { content.innerHTML = `
当前没有待定位算力中心
`; return; } const rows = items .map((item, index) => { const context = getUnresolvedComputeCenterContext(item); const contextJson = JSON.stringify(context).replace(/"/g, '"'); const cacheKey = getLocationCollectCacheKey(context); const cached = getLocationCollectState(cacheKey); const meta = [context.site || context.operator, context.city, context.country] .filter(Boolean) .join(' · ') || '缺少可用地址字段'; return `
${index + 1}
${escapeInfoCardHtml(context.name)}
${escapeInfoCardHtml(meta)}
${escapeInfoCardHtml(cached?.statusText || '')}
${renderCachedCollectCandidates(cached)}
`; }) .join(''); content.innerHTML = `
${items.length} 个算力中心没有可信坐标
${rows}
`; bindComputeCenterUnresolvedControls(content); } function updateUnresolvedSummary(content) { const remainingCount = content.querySelectorAll('[data-unresolved-item]').length; const summaryText = content.querySelector('[data-unresolved-summary-text]'); if (summaryText) { summaryText.textContent = remainingCount > 0 ? `${remainingCount} 个算力中心没有可信坐标` : '当前没有待定位算力中心'; } const adoptAllButton = content.querySelector('[data-unresolved-adopt-all]'); if (adoptAllButton instanceof HTMLButtonElement) { adoptAllButton.hidden = remainingCount <= 0; } window.dispatchEvent( new CustomEvent('earth:compute-center-unresolved-count-change', { detail: { unresolvedCount: remainingCount }, }), ); return remainingCount; } function renumberUnresolvedItems(content) { content.querySelectorAll('[data-unresolved-item]').forEach((item, index) => { const indexEl = item.querySelector('.info-card-unresolved-index'); if (indexEl) indexEl.textContent = String(index + 1); }); } function removeResolvedUnresolvedItem(content, itemRoot) { itemRoot?.remove(); renumberUnresolvedItems(content); return updateUnresolvedSummary(content); } async function collectUnresolvedComputeCenterCandidates(context, options = {}) { const cached = getLocationCollectState(context); if (options.useCached === true && Array.isArray(cached?.candidates) && cached.candidates.length) { const mod = await import('./compute-centers.js'); return { mod, result: cached.result || { success: true, candidates: cached.candidates }, candidates: cached.candidates, fromCache: true, }; } const mod = await import('./compute-centers.js'); const result = await mod.collectComputeCenterLocation(context.sourceId, { name: context.name, operator: context.operator, site: context.site, city: context.city, country: context.country, source: context.source, record_id: context.recordId, }); return { mod, result, candidates: Array.isArray(result?.candidates) ? result.candidates : [], fromCache: false, }; } function getBestLocationCandidate(candidates) { return candidates .filter((candidate) => ( Number.isFinite(Number(candidate?.latitude)) && Number.isFinite(Number(candidate?.longitude)) )) .slice() .sort((a, b) => { const confidenceA = Number.isFinite(Number(a?.confidence)) ? Number(a.confidence) : -1; const confidenceB = Number.isFinite(Number(b?.confidence)) ? Number(b.confidence) : -1; return confidenceB - confidenceA; })[0] || null; } function bindComputeCenterUnresolvedControls(content) { content.querySelectorAll('[data-unresolved-item]').forEach((itemRoot) => { const collectButton = itemRoot.querySelector('[data-unresolved-collect]'); const candidatesEl = itemRoot.querySelector('[data-unresolved-candidates]'); const context = JSON.parse(collectButton?.dataset.contextJson || '{}'); if (!context.sourceId || !candidatesEl) return; const actionContext = { ...context, entityType: 'compute_center', entityId: context.sourceId, isUnresolved: true, save: async (candidate) => { const mod = await import('./compute-centers.js'); return mod.saveComputeCenterLocation(context.sourceId, candidate, context); }, }; ensureCandidateActionBindings(itemRoot, actionContext); }); content.querySelectorAll('[data-unresolved-collect]').forEach((button) => { button.addEventListener('click', async (event) => { event.stopPropagation(); const itemRoot = button.closest('[data-unresolved-item]'); const statusEl = itemRoot?.querySelector('[data-unresolved-status]'); const candidatesEl = itemRoot?.querySelector('[data-unresolved-candidates]'); const context = JSON.parse(button.dataset.contextJson || '{}'); if (!context.sourceId || !statusEl || !candidatesEl) return; button.disabled = true; setLocationCollectState(context, { loading: true, statusText: '正在采集坐标候选...', candidates: [], }); try { const { mod, result, candidates } = await collectUnresolvedComputeCenterCandidates(context); if (!result?.success) { setLocationCollectState(context, { loading: false, statusText: `未能采集到坐标:${formatLocationCollectFailure(result)}`, candidates: [], result, }); return; } setLocationCollectState(context, { loading: false, statusText: `共找到 ${candidates.length} 个候选位置`, candidates, result, }); const actionContext = { ...context, entityType: 'compute_center', entityId: context.sourceId, isUnresolved: true, save: (candidate) => mod.saveComputeCenterLocation(context.sourceId, candidate, context), }; ensureCandidateActionBindings(itemRoot, actionContext); } catch (error) { console.error('collect unresolved compute-center location failed', error); setLocationCollectState(context, { loading: false, statusText: `采集失败:${error?.message || error}`, candidates: [], }); } finally { button.disabled = false; updateLocationCollectDomFromState(getLocationCollectCacheKey(context)); } }); }); const adoptAllButton = content.querySelector('[data-unresolved-adopt-all]'); if (adoptAllButton instanceof HTMLButtonElement) { adoptAllButton.addEventListener('click', async (event) => { event.stopPropagation(); const statusEl = content.querySelector('[data-unresolved-batch-status]'); const buttons = Array.from(content.querySelectorAll('button')); const pendingItems = Array.from(content.querySelectorAll('[data-unresolved-item]')) .map((itemRoot) => { const collectButton = itemRoot.querySelector('[data-unresolved-collect]'); const context = JSON.parse(collectButton?.dataset.contextJson || '{}'); return { itemRoot, context }; }) .filter(({ context }) => context.sourceId); if (!pendingItems.length) return; buttons.forEach((button) => { button.disabled = true; }); let savedCount = 0; let missedCount = 0; try { for (const [index, { itemRoot, context }] of pendingItems.entries()) { const itemStatusEl = itemRoot.querySelector('[data-unresolved-status]'); if (statusEl) { statusEl.textContent = `正在采用最高置信候选 ${index + 1}/${pendingItems.length}...`; } try { const { mod, result, candidates, fromCache } = await collectUnresolvedComputeCenterCandidates( context, { useCached: true }, ); if (!result?.success) { if (itemStatusEl) { itemStatusEl.textContent = `未找到可采用候选:${formatLocationCollectFailure(result)}`; } missedCount += 1; continue; } const bestCandidate = getBestLocationCandidate(candidates); if (!bestCandidate) { if (itemStatusEl) { itemStatusEl.textContent = '未找到包含有效经纬度的候选'; } missedCount += 1; continue; } await mod.saveComputeCenterLocation(context.sourceId, bestCandidate, context); if (fromCache) { clearLocationCollectState(context); } savedCount += 1; removeResolvedUnresolvedItem(content, itemRoot); } catch (error) { console.error('adopt unresolved compute-center location failed', error); if (itemStatusEl) { itemStatusEl.textContent = `一键采用失败:${error?.message || error}`; } missedCount += 1; } } if (statusEl) { statusEl.textContent = savedCount > 0 ? `已采用 ${savedCount} 个最高置信候选${missedCount ? `,${missedCount} 个仍需手动处理` : ''}` : `${missedCount} 个都没有可自动采用的候选,需要手动处理`; } if (savedCount > 0) { window.dispatchEvent( new CustomEvent('earth:compute-center-location-saved', { detail: { entityType: 'compute_center', entityId: 'batch', savedCount, missedCount, }, }), ); } } finally { buttons.forEach((button) => { button.disabled = false; }); } }); } } 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) => `vessel media`) .join(''); inner += `
${tiles}
`; } inner += renderEnrichmentMeta('媒体', media); } if (!inner) { inner = '
资料缓存中
'; } return `
船舶资料
${inner}
`; } 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 getNewsDisplayTitle(data); case 'bgp_collector': return data.collector || 'BGP观测站'; case 'compute_center_unresolved': return '待定位算力中心'; 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 'compute_center_unresolved': return `${data?.totalCount || 0} 个待定位`; 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: '状态' } ] }, compute_center_unresolved: { icon: '📍', title: '待定位算力中心', className: 'compute_unresolved', fields: [] }, 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: 'location_source_label', label: '位置来源' }, { key: 'location_confidence', label: '位置置信度' }, { key: 'location_status_label', label: '核验状态' }, { key: 'estimated_reason', label: '解析依据' }, { key: 'location_source_note', label: '位置来源说明' }, { key: 'matched_location_name', label: '匹配的位置名称' }, { key: 'location_verified_at', 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: 'location_source_label', label: '位置来源' }, { key: 'location_confidence', label: '位置置信度' }, { key: 'location_status_label', label: '核验状态' }, { key: 'estimated_reason', label: '解析依据' }, { key: 'location_source_note', label: '位置来源说明' }, { key: 'matched_location_name', label: '匹配的位置名称' }, { key: 'location_verified_at', 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.dataset.sticky = options.sticky === true ? 'true' : 'false'; 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'); delete panel.dataset.sticky; 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 isInfoCardSticky() { return getPanel()?.dataset.sticky === 'true'; } 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' ? getNewsDisplayTitle(data) : config.title; } if (typeLabel) { typeLabel.textContent = type === 'news' ? '新闻信号' : 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' ? getNewsDisplayTitle(data) : 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; }