Files
planet/frontend/public/earth/js/info-card.js
2026-05-11 09:49:08 +08:00

1784 lines
62 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 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) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
}[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 (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 = `
<div class="info-card-news-layout">
<div class="info-card-news-kicker">NEWS SIGNAL</div>
<div class="info-card-news-title">${data?.title || '新闻事件'}</div>
<div class="info-card-news-summary-shell">
<div class="info-card-news-summary-label">SUMMARY</div>
<div class="info-card-news-summary" data-news-summary></div>
</div>
</div>
`;
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 = `
<div class="earth-mobile-news-detail">
<div class="earth-mobile-news-detail-kicker">NEWS SIGNAL</div>
<div class="earth-mobile-news-detail-title">${data?.title || '新闻事件'}</div>
<div class="earth-mobile-news-detail-summary-shell">
<div class="earth-mobile-news-detail-summary-label">SUMMARY</div>
<div class="earth-mobile-news-detail-summary" data-news-summary></div>
</div>
</div>
`;
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 += `
<div class="earth-mobile-detail-row">
<span class="earth-mobile-detail-row-label">${field.label}</span>
<span class="earth-mobile-detail-row-value">${value}</span>
</div>
`;
}
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 += `
<div class="info-card-property">
<span class="info-card-label">${field.label}</span>
<span class="info-card-value">${value}${sourceLabel}</span>
</div>
`;
}
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 `
<div class="info-card-compute-collect" data-collect-entity-id="${context.entityId}" data-collect-entity-type="${context.entityType}" data-collect-cache-key="${escapeInfoCardHtml(cacheKey)}">
<button type="button" class="info-card-compute-collect-button" data-collect-action="run">
<span class="material-symbols-rounded" aria-hidden="true">explore</span>
<span>${buttonLabel}</span>
</button>
<div class="info-card-compute-collect-status" data-collect-status>${escapeInfoCardHtml(cached?.statusText || '')}</div>
<div class="info-card-compute-collect-candidates" data-collect-candidates>
${renderCachedCollectCandidates(cached)}
</div>
</div>
`;
}
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 `
<div class="info-card-compute-candidate ${isBest ? 'is-best' : ''}">
<div class="info-card-compute-candidate-line">
<span class="info-card-compute-candidate-name">${name}</span>
<span class="info-card-compute-candidate-precision">${escapeInfoCardHtml(precisionLabel)}</span>
</div>
<div class="info-card-compute-candidate-line">
<span class="info-card-compute-candidate-source">${sourceLabel}</span>
<span class="info-card-compute-candidate-confidence">置信 ${escapeInfoCardHtml(confidence)}</span>
</div>
<div class="info-card-compute-candidate-line">
<span class="info-card-compute-candidate-coords">${Number(candidate.latitude).toFixed(4)}, ${Number(candidate.longitude).toFixed(4)}</span>
<button type="button" class="info-card-compute-candidate-preview" data-preview-candidate data-candidate-index="${safeIndex}">预览</button>
<button type="button" class="info-card-compute-candidate-preview" data-save-candidate data-candidate-index="${safeIndex}">保存</button>
</div>
</div>
`;
}
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 = `
<div class="info-card-unresolved-empty">
当前没有待定位算力中心
</div>
`;
return;
}
const rows = items
.map((item, index) => {
const context = getUnresolvedComputeCenterContext(item);
const contextJson = JSON.stringify(context).replace(/"/g, '&quot;');
const cacheKey = getLocationCollectCacheKey(context);
const cached = getLocationCollectState(cacheKey);
const meta = [context.site || context.operator, context.city, context.country]
.filter(Boolean)
.join(' · ') || '缺少可用地址字段';
return `
<div class="info-card-unresolved-item" data-unresolved-item data-collect-cache-key="${escapeInfoCardHtml(cacheKey)}">
<div class="info-card-unresolved-main">
<div class="info-card-unresolved-index">${index + 1}</div>
<div class="info-card-unresolved-copy">
<div class="info-card-unresolved-name">${escapeInfoCardHtml(context.name)}</div>
<div class="info-card-unresolved-meta">${escapeInfoCardHtml(meta)}</div>
</div>
<button type="button" class="info-card-compute-candidate-preview" data-unresolved-collect
data-context-json="${contextJson}">采集</button>
</div>
<div class="info-card-compute-collect-status" data-unresolved-status>${escapeInfoCardHtml(cached?.statusText || '')}</div>
<div class="info-card-compute-collect-candidates" data-unresolved-candidates>
${renderCachedCollectCandidates(cached)}
</div>
</div>
`;
})
.join('');
content.innerHTML = `
<div class="info-card-unresolved-summary">
<span data-unresolved-summary-text>${items.length} 个算力中心没有可信坐标</span>
<button type="button" class="info-card-compute-candidate-preview info-card-unresolved-adopt" data-unresolved-adopt-all>
一键采用
</button>
</div>
<div class="info-card-compute-collect-status" data-unresolved-batch-status></div>
<div class="info-card-unresolved-list">
${rows}
</div>
`;
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 ` <span class="info-card-source-tag" title="字段来源">${source}</span>`;
}
function renderVesselEnrichmentSection(enrichment) {
if (!enrichment || typeof enrichment !== 'object') return '';
const profile = enrichment.profile;
const media = enrichment.media;
if (!profile && !media) {
return `
<div class="info-card-enrichment info-card-enrichment--empty">
<div class="info-card-enrichment-title">船舶资料</div>
<div class="info-card-enrichment-status">资料缓存中</div>
</div>
`;
}
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) => `<img class="info-card-enrichment-thumb" src="${String(url)}" alt="vessel media" />`)
.join('');
inner += `<div class="info-card-enrichment-media">${tiles}</div>`;
}
inner += renderEnrichmentMeta('媒体', media);
}
if (!inner) {
inner = '<div class="info-card-enrichment-status">资料缓存中</div>';
}
return `
<div class="info-card-enrichment">
<div class="info-card-enrichment-title">船舶资料</div>
${inner}
</div>
`;
}
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 += `
<div class="info-card-property">
<span class="info-card-label">${key}</span>
<span class="info-card-value">${String(value)}</span>
</div>
`;
}
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 `<div class="info-card-enrichment-meta">${label}${parts.join(' · ')}</div>`;
}
// ── 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 '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 = `
<div id="info-card" class="info-card">
<div class="info-card-header hud-panel-drag-handle">
<span class="info-card-icon" id="info-card-icon">🛰️</span>
<h3 id="info-card-title">详情</h3>
<button class="info-card-close hud-panel-close" type="button" aria-label="关闭详情">
<span class="material-symbols-rounded">close</span>
</button>
</div>
<div id="info-card-content" class="info-card-content"></div>
</div>
`;
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'
? (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;
}