Files
planet/frontend/public/earth/js/info-card.js
linkong 5bdb55f3f1
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / delivery (push) Has been cancelled
release / images (push) Has been cancelled
release: bump version to 0.74.0
2026-06-30 13:52:52 +08:00

2097 lines
75 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';
import {
earthMessage,
getEarthLocale,
hasCjkText,
localizeCountryName,
translateText,
} from './i18n.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();
let computeCenterLocationCapability = null;
let computeCenterLocationCapabilityPromise = null;
let computeCenterUnresolvedBatchState = {
running: false,
statusText: '',
total: 0,
processed: 0,
saved: 0,
missed: 0,
};
// 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 getWebSearchDisabledTitle(capability = computeCenterLocationCapability) {
if (!capability) return infoText('正在检查 WebSearch 状态,稍候即可定位。');
return infoText(capability.reason || 'WebSearch 未开启,无法进行事实核查定位。');
}
function isComputeCenterLocationBlocked() {
return computeCenterLocationCapability?.enabled !== true;
}
async function ensureComputeCenterLocationCapability() {
if (computeCenterLocationCapability) return computeCenterLocationCapability;
if (!computeCenterLocationCapabilityPromise) {
computeCenterLocationCapabilityPromise = import('./compute-centers.js')
.then((mod) => mod.getComputeCenterLocationCapability())
.then((capability) => {
computeCenterLocationCapability = capability;
updateComputeCenterLocationCapabilityDom();
return capability;
})
.catch((error) => {
computeCenterLocationCapability = {
enabled: false,
reason: `WebSearch 状态检查失败:${error?.message || error}`,
};
updateComputeCenterLocationCapabilityDom();
return computeCenterLocationCapability;
});
}
return computeCenterLocationCapabilityPromise;
}
function updateComputeCenterLocationCapabilityDom() {
const blocked = isComputeCenterLocationBlocked();
const title = getWebSearchDisabledTitle();
document
.querySelectorAll('[data-requires-web-search="true"]')
.forEach((button) => {
if (!(button instanceof HTMLButtonElement)) return;
button.disabled = blocked || button.classList.contains('is-loading');
button.classList.toggle('is-websearch-blocked', blocked);
button.title = blocked ? title : button.dataset.readyTitle || button.title || '';
button.setAttribute('aria-disabled', blocked ? 'true' : 'false');
});
}
function setLocationButtonLoading(button, loading, label) {
if (!(button instanceof HTMLButtonElement)) return;
button.classList.toggle('is-loading', Boolean(loading));
button.toggleAttribute('aria-busy', Boolean(loading));
if (label) {
button.dataset.loadingLabel = infoText(label);
}
updateComputeCenterLocationCapabilityDom();
}
function updateUnresolvedBatchDom() {
document.querySelectorAll('[data-unresolved-batch-status]').forEach((statusEl) => {
statusEl.textContent = computeCenterUnresolvedBatchState.statusText || '';
});
document.querySelectorAll('[data-unresolved-adopt-all]').forEach((button) => {
if (!(button instanceof HTMLButtonElement)) return;
button.classList.toggle('is-loading', computeCenterUnresolvedBatchState.running);
button.toggleAttribute('aria-busy', computeCenterUnresolvedBatchState.running);
});
updateComputeCenterLocationCapabilityDom();
}
function setUnresolvedBatchState(patch = {}) {
computeCenterUnresolvedBatchState = {
...computeCenterUnresolvedBatchState,
...patch,
};
updateUnresolvedBatchDom();
return computeCenterUnresolvedBatchState;
}
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(getEarthLocale());
}
if (field.key === 'country') {
value = localizeCountryName(value) || value;
}
if (field.unit && value !== '-') {
value = value + ' ' + translateText(field.unit);
} else if (typeof value === 'string') {
value = translateText(value);
}
return value;
}
function infoText(value, fallback = '') {
const translated = translateText(value);
if (getEarthLocale() === 'en-US' && hasCjkText(translated) && fallback) {
return fallback;
}
return translated;
}
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 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 getNewsCardTitle(data) {
const directTitle = String(data?.title ?? '').replace(/\s+/g, ' ').trim();
return directTitle || getNewsDisplayTitle(data);
}
function getNewsCardSummary(data) {
const directSummary = String(data?.summary ?? '').replace(/\s+/g, ' ').trim();
return directSummary || getNewsSummaryText(data);
}
function getNewsCardSummaryPreview(data, maxLength = 34) {
const text = getNewsCardSummary(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 = getNewsCardSummary(data);
const title = getNewsCardTitle(data);
const metaItems = [
['媒体来源', data?.source],
['RSS 来源', data?.feedName],
['源类型', data?.feedSourceTypeLabel],
['抓取通道', data?.fetchChannelLabel],
['新闻类型', data?.categoryLabel],
['区域', data?.regionLabel],
['发布时间', data?.publishedAtDisplay],
].filter(([, value]) => String(value ?? '').trim());
const metaHtml = metaItems.length
? `<div class="info-card-news-meta-grid">${metaItems.map(([label, value]) => `
<div class="info-card-news-meta-item">
<span>${escapeInfoCardHtml(infoText(label))}</span>
<strong>${escapeInfoCardHtml(value)}</strong>
</div>
`).join('')}</div>`
: '';
content.innerHTML = `
<div class="info-card-news-layout">
<div class="info-card-news-kicker">${escapeInfoCardHtml(infoText('新闻信号'))}</div>
<div class="info-card-news-title">${escapeInfoCardHtml(title)}</div>
${metaHtml}
<div class="info-card-news-summary-shell">
<div class="info-card-news-summary-label">${escapeInfoCardHtml(infoText('概要'))}</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 = getNewsCardSummary(data);
const title = getNewsCardTitle(data);
const metaItems = [
['来源', data?.source],
['RSS', data?.feedName],
['类型', data?.categoryLabel],
['区域', data?.regionLabel],
].filter(([, value]) => String(value ?? '').trim());
const metaHtml = metaItems.length
? `<div class="info-card-news-meta-grid info-card-news-meta-grid--mobile">${metaItems.map(([label, value]) => `
<div class="info-card-news-meta-item">
<span>${escapeInfoCardHtml(infoText(label))}</span>
<strong>${escapeInfoCardHtml(value)}</strong>
</div>
`).join('')}</div>`
: '';
content.innerHTML = `
<div class="earth-mobile-news-detail">
<div class="earth-mobile-news-detail-kicker">${escapeInfoCardHtml(infoText('新闻信号'))}</div>
<div class="earth-mobile-news-detail-title">${escapeInfoCardHtml(title)}</div>
${metaHtml}
<div class="earth-mobile-news-detail-summary-shell">
<div class="earth-mobile-news-detail-summary-label">${escapeInfoCardHtml(infoText('概要'))}</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">${escapeInfoCardHtml(infoText(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">${escapeInfoCardHtml(infoText(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
? infoText('重新自动采集坐标')
: infoText('自动采集坐标候选');
const cacheKey = getLocationCollectCacheKey(context);
const cached = getLocationCollectState(cacheKey);
ensureComputeCenterLocationCapability();
const blocked = isComputeCenterLocationBlocked();
const disabledTitle = getWebSearchDisabledTitle();
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"
data-requires-web-search="true"
data-ready-title="${escapeInfoCardHtml(buttonLabel)}"
title="${escapeInfoCardHtml(blocked ? disabledTitle : buttonLabel)}"
${blocked ? 'disabled aria-disabled="true"' : ''}>
<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 = infoText(state?.statusText || '');
if (candidatesEl) candidatesEl.innerHTML = renderCachedCollectCandidates(state);
if (button instanceof HTMLButtonElement) {
button.classList.toggle('is-loading', state?.loading === true);
button.toggleAttribute('aria-busy', state?.loading === true);
}
root.classList.toggle('is-locating', state?.loading === true);
updateComputeCenterLocationCapabilityDom();
}
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 = infoText('正在保存所选坐标...');
try {
const saveResult = await actionContext.save(candidate);
setLocationCollectState(actionContext, {
loading: false,
statusText: infoText('坐标已保存'),
candidates: [],
});
if (statusEl) statusEl.textContent = infoText('坐标已保存');
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 = infoText(`保存失败:${error?.message || error}`);
button.disabled = false;
}
});
}
function formatLocationCollectFailure(result) {
const regularReason = result?.failure_reason || '常规来源没有可用坐标候选';
const llmReason = result?.llm_failure_reason;
if (llmReason) {
return infoText(`常规来源无结果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 infoText(`常规来源无结果LLM 兜底已尝试但没有返回可用候选。${regularReason}`);
}
return infoText(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();
const capability = await ensureComputeCenterLocationCapability();
if (!capability?.enabled) {
button.title = getWebSearchDisabledTitle(capability);
return;
}
setLocationButtonLoading(button, true, '正在定位');
setLocationCollectState(context, {
loading: true,
statusText: infoText('正在采集坐标候选...'),
candidates: [],
});
try {
const result = await context.collect();
if (!result?.success) {
setLocationCollectState(context, {
loading: false,
statusText: infoText(`未能采集到坐标:${formatLocationCollectFailure(result)}`),
candidates: [],
result,
});
return;
}
const candidates = Array.isArray(result.candidates) ? result.candidates : [];
setLocationCollectState(context, {
loading: false,
statusText: infoText(`共找到 ${candidates.length} 个候选位置`),
candidates,
result,
});
} catch (error) {
console.error('collect-location failed', error);
setLocationCollectState(context, {
loading: false,
statusText: infoText(`采集失败:${error?.message || error}`),
candidates: [],
});
} finally {
setLocationButtonLoading(button, 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 || infoText('候选'));
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(infoText(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(infoText(`置信 ${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}">${escapeInfoCardHtml(infoText('预览'))}</button>
<button type="button" class="info-card-compute-candidate-preview" data-save-candidate data-candidate-index="${safeIndex}">${escapeInfoCardHtml(infoText('保存'))}</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 || infoText('未命名算力中心'),
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 : [];
ensureComputeCenterLocationCapability();
const blocked = isComputeCenterLocationBlocked();
const disabledTitle = getWebSearchDisabledTitle();
if (!items.length) {
content.innerHTML = `
<div class="info-card-unresolved-empty">
${escapeInfoCardHtml(infoText('当前没有待定位算力中心'))}
</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(' · ') || infoText('缺少可用地址字段');
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-requires-web-search="true"
data-ready-title="${escapeInfoCardHtml(infoText('采集坐标候选'))}"
title="${escapeInfoCardHtml(blocked ? disabledTitle : infoText('采集坐标候选'))}"
${blocked ? 'disabled aria-disabled="true"' : ''}
data-context-json="${contextJson}">${escapeInfoCardHtml(getEarthLocale() === 'en-US' ? 'Collect' : '采集')}</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>${escapeInfoCardHtml(infoText(`${items.length} 个算力中心没有可信坐标`))}</span>
<button type="button" class="info-card-compute-candidate-preview info-card-unresolved-adopt"
data-unresolved-adopt-all
data-requires-web-search="true"
data-ready-title="${escapeInfoCardHtml(infoText('一键定位并采用最高置信候选'))}"
title="${escapeInfoCardHtml(blocked ? disabledTitle : infoText('一键定位并采用最高置信候选'))}"
${blocked ? 'disabled aria-disabled="true"' : ''}>
${escapeInfoCardHtml(infoText('一键定位'))}
</button>
</div>
<div class="info-card-compute-collect-status" data-unresolved-batch-status>${escapeInfoCardHtml(computeCenterUnresolvedBatchState.statusText || '')}</div>
<div class="info-card-unresolved-list">
${rows}
</div>
`;
updateUnresolvedBatchDom();
updateComputeCenterLocationCapabilityDom();
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
? infoText(`${remainingCount} 个算力中心没有可信坐标`)
: infoText('当前没有待定位算力中心');
}
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,
};
}
async function saveBestUnresolvedComputeCenterCandidate(context, progressLabel = '') {
setLocationCollectState(context, {
loading: true,
statusText: infoText(progressLabel || '正在一键定位并采用最高置信候选...'),
candidates: [],
});
const { mod, result, candidates } = await collectUnresolvedComputeCenterCandidates(
context,
{ useCached: true },
);
if (!result?.success) {
setLocationCollectState(context, {
loading: false,
statusText: infoText(`未找到可采用候选:${formatLocationCollectFailure(result)}`),
candidates: [],
result,
});
return { saved: false, result, reason: 'no_result' };
}
setLocationCollectState(context, {
loading: true,
statusText: infoText(`找到 ${candidates.length} 个候选,正在保存最高置信位置...`),
candidates,
result,
});
const bestCandidate = getBestLocationCandidate(candidates);
if (!bestCandidate) {
setLocationCollectState(context, {
loading: false,
statusText: infoText('未找到包含有效经纬度的候选'),
candidates,
result,
});
return { saved: false, result, reason: 'no_valid_candidate' };
}
const saveResult = await mod.saveComputeCenterLocation(context.sourceId, bestCandidate, context);
setLocationCollectState(context, {
loading: false,
statusText: infoText('坐标已保存,等待图层刷新'),
candidates,
result,
savedCandidate: bestCandidate,
saveResult,
});
return {
saved: true,
candidate: bestCandidate,
result,
saveResult,
};
}
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 capability = await ensureComputeCenterLocationCapability();
if (!capability?.enabled) {
button.title = getWebSearchDisabledTitle(capability);
return;
}
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;
itemRoot?.classList.add('is-locating');
setLocationButtonLoading(button, true, '正在定位');
setLocationCollectState(context, {
loading: true,
statusText: infoText('正在采集坐标候选...'),
candidates: [],
});
try {
const { mod, result, candidates } = await collectUnresolvedComputeCenterCandidates(context);
if (!result?.success) {
setLocationCollectState(context, {
loading: false,
statusText: infoText(`未能采集到坐标:${formatLocationCollectFailure(result)}`),
candidates: [],
result,
});
return;
}
setLocationCollectState(context, {
loading: false,
statusText: infoText(`共找到 ${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: infoText(`采集失败:${error?.message || error}`),
candidates: [],
});
} finally {
itemRoot?.classList.remove('is-locating');
setLocationButtonLoading(button, false);
updateLocationCollectDomFromState(getLocationCollectCacheKey(context));
}
});
});
const adoptAllButton = content.querySelector('[data-unresolved-adopt-all]');
if (adoptAllButton instanceof HTMLButtonElement) {
adoptAllButton.addEventListener('click', async (event) => {
event.stopPropagation();
const capability = await ensureComputeCenterLocationCapability();
if (!capability?.enabled) {
adoptAllButton.title = getWebSearchDisabledTitle(capability);
return;
}
if (computeCenterUnresolvedBatchState.running) {
updateUnresolvedBatchDom();
return;
}
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; });
setLocationButtonLoading(adoptAllButton, true, '正在定位');
setUnresolvedBatchState({
running: true,
total: pendingItems.length,
processed: 0,
saved: 0,
missed: 0,
statusText: infoText(`一键定位进行中 0/${pendingItems.length}...`),
});
let savedCount = 0;
let missedCount = 0;
try {
for (const [index, { itemRoot, context }] of pendingItems.entries()) {
const itemStatusEl = itemRoot.querySelector('[data-unresolved-status]');
itemRoot.classList.add('is-locating');
const progressText = infoText(`正在定位并采用最高置信候选 ${index + 1}/${pendingItems.length}...`);
setUnresolvedBatchState({
statusText: progressText,
processed: index,
saved: savedCount,
missed: missedCount,
});
if (statusEl) statusEl.textContent = progressText;
try {
const saveOutcome = await saveBestUnresolvedComputeCenterCandidate(context, progressText);
if (!saveOutcome.saved) {
if (itemStatusEl) itemStatusEl.textContent = getLocationCollectState(context)?.statusText || infoText('未找到可采用候选');
missedCount += 1;
continue;
}
savedCount += 1;
removeResolvedUnresolvedItem(content, itemRoot);
} catch (error) {
console.error('adopt unresolved compute-center location failed', error);
setLocationCollectState(context, {
loading: false,
statusText: infoText(`一键定位失败:${error?.message || error}`),
candidates: [],
});
if (itemStatusEl) {
itemStatusEl.textContent = infoText(`一键采用失败:${error?.message || error}`);
}
missedCount += 1;
} finally {
itemRoot.classList.remove('is-locating');
setUnresolvedBatchState({
processed: index + 1,
saved: savedCount,
missed: missedCount,
statusText: infoText(`一键定位进行中 ${index + 1}/${pendingItems.length},已保存 ${savedCount}${missedCount ? `,失败 ${missedCount}` : ''}`),
});
}
}
const finalStatus = savedCount > 0
? infoText(`已定位并采用 ${savedCount} 个最高置信候选${missedCount ? `${missedCount} 个仍需手动处理` : ''}`)
: infoText(`${missedCount} 个都没有可自动采用的候选,需要手动处理`);
if (statusEl) statusEl.textContent = finalStatus;
setUnresolvedBatchState({
running: false,
processed: pendingItems.length,
saved: savedCount,
missed: missedCount,
statusText: finalStatus,
});
if (savedCount > 0) {
window.dispatchEvent(
new CustomEvent('earth:compute-center-location-saved', {
detail: {
entityType: 'compute_center',
entityId: 'batch',
savedCount,
missedCount,
},
}),
);
}
} finally {
setUnresolvedBatchState({ running: false });
setLocationButtonLoading(adoptAllButton, false);
buttons.forEach((button) => { button.disabled = false; });
updateComputeCenterLocationCapabilityDom();
}
});
}
}
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="${escapeInfoCardHtml(infoText('字段来源'))}">${escapeInfoCardHtml(infoText(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">${escapeInfoCardHtml(infoText('船舶资料'))}</div>
<div class="info-card-enrichment-status">${escapeInfoCardHtml(infoText('资料缓存中'))}</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">${escapeInfoCardHtml(infoText('资料缓存中'))}</div>`;
}
return `
<div class="info-card-enrichment">
<div class="info-card-enrichment-title">${escapeInfoCardHtml(infoText('船舶资料'))}</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">${escapeInfoCardHtml(infoText(key))}</span>
<span class="info-card-value">${escapeInfoCardHtml(infoText(String(value)))}</span>
</div>
`;
}
return rows;
}
function renderEnrichmentMeta(label, record) {
const parts = [];
if (record.source) parts.push(infoText(`来源 ${record.source}`));
if (record.fetched_at) parts.push(infoText(`更新 ${record.fetched_at}`));
if (record.confidence !== null && record.confidence !== undefined) {
parts.push(infoText(`置信 ${Number(record.confidence).toFixed(2)}`));
}
if (!parts.length) return '';
return `<div class="info-card-enrichment-meta">${escapeInfoCardHtml(infoText(label))}: ${escapeInfoCardHtml(parts.join(' · '))}</div>`;
}
// ── Mobile popup ─────────────────────────────────────────────
function getMobilePopupTitle(type, data) {
switch (type) {
case 'cable': return data.name || infoText('海缆');
case 'landing_point': return data.name || infoText('登陆点');
case 'satellite': return data.name || infoText('卫星');
case 'bgp': return data.anomaly_type || infoText('BGP事件');
case 'news': return getNewsCardTitle(data);
case 'bgp_collector': return data.collector || infoText('BGP观测站');
case 'compute_center_unresolved': return infoText('待定位算力中心');
case 'supercomputer': return data.name || infoText('超算');
case 'gpu_cluster': return data.name || infoText('GPU集群');
case 'vessel': return data.name || infoText('船只');
default: return infoText('详情');
}
}
function getMobilePopupSubtitle(type, data) {
switch (type) {
case 'cable': return infoText(data.owner || data.status || '海缆');
case 'landing_point': return localizeCountryName(data.country) || infoText('登陆点');
case 'satellite': return data.norad_id ? `NORAD ${data.norad_id}` : infoText('卫星');
case 'bgp': return infoText(data.severity || 'BGP路由异常');
case 'news': return getNewsCardSummaryPreview(data, 30) || infoText('态势新闻');
case 'bgp_collector': return data.location || infoText('BGP观测站');
case 'compute_center_unresolved': return infoText(`${data?.totalCount || 0} 个待定位`);
case 'supercomputer': return localizeCountryName(data.country) || infoText('超级计算机');
case 'gpu_cluster': return localizeCountryName(data.country) || infoText('GPU集群');
case 'vessel': return data.vessel_type || infoText('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: '更新时间' }
]
},
earth_interactable: {
icon: '📍',
title: '交互点详情',
className: 'earth_interactable',
fields: [
{ key: 'label', label: '名称' },
{ key: 'kind', label: '类型' },
{ key: 'id', label: '标识' },
{ key: 'latitude', label: '纬度' },
{ key: 'longitude', label: '经度' },
{ key: 'description', label: '说明' },
{ key: 'source', label: '来源' },
{ key: 'status', label: '状态' },
{ key: 'updated_at', label: '更新时间' }
]
}
};
function getPanel() {
return document.getElementById('info-panel');
}
function setupInfoCardDrag(panel) {
const app = document.getElementById('container');
if (!app) return;
const handle = panel.querySelector('.hud-panel-drag-handle');
if (!handle) return;
let isDragging = false;
let activePointerId = null;
let startPointerX = 0;
let startPointerY = 0;
let startLeft = 0;
let startTop = 0;
const 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">${escapeInfoCardHtml(infoText('详情'))}</h3>
<button class="info-card-close hud-panel-close" type="button" aria-label="${escapeInfoCardHtml(infoText('关闭详情'))}">
<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(earthMessage("status.copyEmpty"), 'warning');
return;
}
try {
await navigator.clipboard.writeText(value);
showStatusMessage(
earthMessage("status.copyValue", { label: label.textContent, value }),
'success',
);
} catch (error) {
console.error('Copy failed:', error);
showStatusMessage(earthMessage("status.copyFailed"), '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(340 * 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.dataset.preserveOnCruiseInterrupt =
options.preserveOnCruiseInterrupt === 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;
delete panel.dataset.preserveOnCruiseInterrupt;
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 shouldPreserveInfoCardOnCruiseInterrupt() {
const panel = getPanel();
return (
panel?.dataset.preserveOnCruiseInterrupt === 'true' &&
panel?.getAttribute('aria-hidden') !== 'true' &&
!panel?.hasAttribute('hidden')
);
}
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'
? getNewsCardTitle(data)
: infoText(config.title);
}
if (typeLabel) {
typeLabel.textContent = type === 'news'
? infoText('新闻信号')
: 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'
? getNewsCardTitle(data)
: infoText(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;
}