1790 lines
62 KiB
JavaScript
1790 lines
62 KiB
JavaScript
// info-card.js - Unified info card module
|
||
import { showStatusMessage } from './ui.js';
|
||
import {
|
||
getNewsDisplaySummary,
|
||
getNewsDisplayTitle,
|
||
} from './news-locale.js';
|
||
|
||
let currentType = null;
|
||
let cardMounted = false;
|
||
let typewriterTimerId = null;
|
||
let typewriterToken = 0;
|
||
let pendingMobileDetailState = null;
|
||
let mobileDetailsListenerBound = false;
|
||
let renderedMobileDetailKey = null;
|
||
const locationCollectStateCache = new Map();
|
||
const locationCollectContextCache = new Map();
|
||
// Latest candidate list per cache-key. Populated whenever state.candidates is
|
||
// updated, and read by the click handler via `data-candidate-index` so we
|
||
// never have to round-trip a candidate object through an HTML attribute.
|
||
const locationCollectCandidatesByKey = new Map();
|
||
const IDENTIFIER_FIELD_KEYS = new Set([
|
||
'mmsi',
|
||
'mmsi_display',
|
||
'imo',
|
||
'imo_display',
|
||
'callsign',
|
||
]);
|
||
const MAX_VESSEL_MEDIA_TILES = 4;
|
||
|
||
function getLocationCollectCacheKey(context) {
|
||
const entityType = context?.entityType || 'unknown';
|
||
const entityId = context?.entityId || context?.sourceId || '';
|
||
if (!entityId) return '';
|
||
return `${entityType}:${entityId}`;
|
||
}
|
||
|
||
function getLocationCollectState(contextOrKey) {
|
||
const key = typeof contextOrKey === 'string'
|
||
? contextOrKey
|
||
: getLocationCollectCacheKey(contextOrKey);
|
||
return key ? locationCollectStateCache.get(key) || null : null;
|
||
}
|
||
|
||
function setLocationCollectState(contextOrKey, patch = {}) {
|
||
const key = typeof contextOrKey === 'string'
|
||
? contextOrKey
|
||
: getLocationCollectCacheKey(contextOrKey);
|
||
if (!key) return null;
|
||
if (typeof contextOrKey !== 'string') {
|
||
locationCollectContextCache.set(key, contextOrKey);
|
||
}
|
||
if (Array.isArray(patch.candidates)) {
|
||
locationCollectCandidatesByKey.set(key, patch.candidates);
|
||
}
|
||
const previous = locationCollectStateCache.get(key) || {};
|
||
const next = {
|
||
...previous,
|
||
...patch,
|
||
updatedAt: Date.now(),
|
||
};
|
||
locationCollectStateCache.set(key, next);
|
||
updateLocationCollectDomFromState(key);
|
||
return next;
|
||
}
|
||
|
||
function clearLocationCollectState(contextOrKey) {
|
||
const key = typeof contextOrKey === 'string'
|
||
? contextOrKey
|
||
: getLocationCollectCacheKey(contextOrKey);
|
||
if (!key) return;
|
||
locationCollectStateCache.delete(key);
|
||
locationCollectContextCache.delete(key);
|
||
locationCollectCandidatesByKey.delete(key);
|
||
updateLocationCollectDomFromState(key);
|
||
}
|
||
|
||
function getCandidateForButton(button) {
|
||
if (!(button instanceof HTMLElement)) return null;
|
||
const root = button.closest('[data-collect-cache-key]');
|
||
if (!(root instanceof HTMLElement)) return null;
|
||
const key = root.dataset.collectCacheKey || '';
|
||
const list = locationCollectCandidatesByKey.get(key) || [];
|
||
const index = Number(button.dataset.candidateIndex);
|
||
if (!Number.isFinite(index) || index < 0 || index >= list.length) return null;
|
||
return list[index];
|
||
}
|
||
|
||
function updateLocationCollectDomFromState(key) {
|
||
if (!key) return;
|
||
const state = getLocationCollectState(key);
|
||
document.querySelectorAll(`[data-collect-cache-key="${escapeCssIdentifier(key)}"]`).forEach((root) => {
|
||
hydrateLocationCollectRoot(root, state);
|
||
ensureCandidateActionBindings(root, locationCollectContextCache.get(key));
|
||
});
|
||
}
|
||
|
||
function formatInfoCardValue(field, rawValue) {
|
||
if (rawValue === undefined || rawValue === null || rawValue === '') {
|
||
return '-';
|
||
}
|
||
let value = rawValue;
|
||
if (IDENTIFIER_FIELD_KEYS.has(field.key)) {
|
||
value = String(value);
|
||
} else if (typeof value === 'number') {
|
||
value = value.toLocaleString();
|
||
}
|
||
if (field.unit && value !== '-') {
|
||
value = value + ' ' + field.unit;
|
||
}
|
||
return value;
|
||
}
|
||
|
||
function escapeInfoCardHtml(value) {
|
||
return String(value ?? '').replace(/[&<>"']/g, (char) => ({
|
||
'&': '&',
|
||
'<': '<',
|
||
'>': '>',
|
||
'"': '"',
|
||
"'": ''',
|
||
}[char]));
|
||
}
|
||
|
||
function escapeCssIdentifier(value) {
|
||
if (window.CSS && typeof window.CSS.escape === 'function') {
|
||
return window.CSS.escape(String(value));
|
||
}
|
||
return String(value).replace(/["\\]/g, '\\$&');
|
||
}
|
||
|
||
function getNewsSummaryText(data) {
|
||
return getNewsDisplaySummary(data);
|
||
}
|
||
|
||
function getNewsSummaryPreview(data, maxLength = 34) {
|
||
const text = getNewsSummaryText(data).replace(/\s+/g, ' ').trim();
|
||
if (text.length <= maxLength) return text;
|
||
return `${text.slice(0, Math.max(0, maxLength - 1))}…`;
|
||
}
|
||
|
||
function stopTypewriterAnimation() {
|
||
typewriterToken += 1;
|
||
if (typewriterTimerId) {
|
||
window.clearTimeout(typewriterTimerId);
|
||
typewriterTimerId = null;
|
||
}
|
||
}
|
||
|
||
function startTypewriterAnimation(target, text, options = {}) {
|
||
if (!(target instanceof HTMLElement)) return;
|
||
stopTypewriterAnimation();
|
||
|
||
const content = typeof text === 'string' ? text : '';
|
||
const token = typewriterToken;
|
||
const stepMs = Number.isFinite(options.stepMs) ? options.stepMs : 22;
|
||
const startDelayMs = Number.isFinite(options.startDelayMs) ? options.startDelayMs : 90;
|
||
|
||
target.textContent = '';
|
||
target.classList.add('is-typing');
|
||
|
||
let index = 0;
|
||
const tick = () => {
|
||
if (token !== typewriterToken) return;
|
||
index += 1;
|
||
target.textContent = content.slice(0, index);
|
||
if (index < content.length) {
|
||
typewriterTimerId = window.setTimeout(tick, stepMs);
|
||
return;
|
||
}
|
||
target.classList.remove('is-typing');
|
||
typewriterTimerId = null;
|
||
};
|
||
|
||
typewriterTimerId = window.setTimeout(() => {
|
||
if (token !== typewriterToken) return;
|
||
if (!content) {
|
||
target.classList.remove('is-typing');
|
||
typewriterTimerId = null;
|
||
return;
|
||
}
|
||
tick();
|
||
}, startDelayMs);
|
||
}
|
||
|
||
function renderNewsCardContent(content, data) {
|
||
if (!(content instanceof HTMLElement)) return;
|
||
const summary = getNewsSummaryText(data);
|
||
const title = getNewsDisplayTitle(data);
|
||
content.innerHTML = `
|
||
<div class="info-card-news-layout">
|
||
<div class="info-card-news-kicker">新闻信号</div>
|
||
<div class="info-card-news-title">${escapeInfoCardHtml(title)}</div>
|
||
<div class="info-card-news-summary-shell">
|
||
<div class="info-card-news-summary-label">概要</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);
|
||
const title = getNewsDisplayTitle(data);
|
||
content.innerHTML = `
|
||
<div class="earth-mobile-news-detail">
|
||
<div class="earth-mobile-news-detail-kicker">新闻信号</div>
|
||
<div class="earth-mobile-news-detail-title">${escapeInfoCardHtml(title)}</div>
|
||
<div class="earth-mobile-news-detail-summary-shell">
|
||
<div class="earth-mobile-news-detail-summary-label">概要</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, '"');
|
||
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 getNewsDisplayTitle(data);
|
||
case 'bgp_collector': return data.collector || 'BGP观测站';
|
||
case 'compute_center_unresolved': return '待定位算力中心';
|
||
case 'supercomputer': return data.name || '超算';
|
||
case 'gpu_cluster': return data.name || 'GPU集群';
|
||
case 'vessel': return data.name || '船只';
|
||
default: return '详情';
|
||
}
|
||
}
|
||
|
||
function getMobilePopupSubtitle(type, data) {
|
||
switch (type) {
|
||
case 'cable': return data.owner || data.status || '海缆';
|
||
case 'landing_point': return data.country || '登陆点';
|
||
case 'satellite': return data.norad_id ? `NORAD ${data.norad_id}` : '卫星';
|
||
case 'bgp': return data.severity || 'BGP路由异常';
|
||
case 'news': return getNewsSummaryPreview(data, 30) || '态势新闻';
|
||
case 'bgp_collector': return data.location || 'BGP观测站';
|
||
case 'compute_center_unresolved': return `${data?.totalCount || 0} 个待定位`;
|
||
case 'supercomputer': return data.country || '超级计算机';
|
||
case 'gpu_cluster': return data.country || 'GPU集群';
|
||
case 'vessel': return data.vessel_type || 'AIS 船只';
|
||
default: return '';
|
||
}
|
||
}
|
||
|
||
function positionMobilePopup(popup, touchX, touchY, options = {}) {
|
||
const margin = 14;
|
||
const drawerClearance = 52;
|
||
const vpW = window.innerWidth;
|
||
const vpH = window.innerHeight;
|
||
const safeBottom = parseFloat(
|
||
getComputedStyle(document.documentElement).getPropertyValue('--safe-bottom')
|
||
) || 0;
|
||
const bottomBound = vpH - drawerClearance - safeBottom;
|
||
|
||
// Measure actual popup size (it's rendered but invisible via opacity)
|
||
const popW = popup.offsetWidth || 200;
|
||
const popH = popup.offsetHeight || 68;
|
||
|
||
if (options.absolute === true) {
|
||
const left = Math.max(margin, Math.min(touchX, vpW - popW - margin));
|
||
const top = Math.max(margin, Math.min(touchY, bottomBound - popH - margin));
|
||
popup.style.left = `${left}px`;
|
||
popup.style.top = `${top}px`;
|
||
return;
|
||
}
|
||
|
||
const gap = 22;
|
||
const spaceRight = vpW - touchX;
|
||
const spaceLeft = touchX;
|
||
const spaceBottom = bottomBound - touchY;
|
||
const spaceTop = touchY;
|
||
|
||
let left, top;
|
||
|
||
// Horizontal: side with more room
|
||
if (spaceRight >= popW + gap + margin) {
|
||
left = touchX + gap;
|
||
} else if (spaceLeft >= popW + gap + margin) {
|
||
left = touchX - gap - popW;
|
||
} else {
|
||
left = Math.max(margin, Math.min(touchX - popW / 2, vpW - popW - margin));
|
||
}
|
||
|
||
// Vertical: prefer above touch, then below
|
||
if (spaceTop >= popH + gap + margin) {
|
||
top = touchY - gap - popH;
|
||
} else if (spaceBottom >= popH + gap + margin) {
|
||
top = touchY + gap;
|
||
} else {
|
||
top = Math.max(margin, Math.min(touchY - popH / 2, bottomBound - popH - margin));
|
||
}
|
||
|
||
left = Math.max(margin, Math.min(left, vpW - popW - margin));
|
||
top = Math.max(margin, Math.min(top, bottomBound - popH - margin));
|
||
|
||
popup.style.left = `${left}px`;
|
||
popup.style.top = `${top}px`;
|
||
}
|
||
|
||
let popupShowToken = 0;
|
||
|
||
function showMobilePopup(type, data, x, y, options = {}) {
|
||
// Require coordinates — skip if called without position (e.g. from handleCableClick)
|
||
if (x == null || y == null) return;
|
||
|
||
const popup = document.getElementById('earth-mobile-popup');
|
||
const iconEl = document.getElementById('earth-mobile-popup-icon');
|
||
const titleEl = document.getElementById('earth-mobile-popup-title');
|
||
const subEl = document.getElementById('earth-mobile-popup-sub');
|
||
if (!popup || !iconEl || !titleEl || !subEl) return;
|
||
|
||
const config = CARD_CONFIG[type];
|
||
if (!config) return;
|
||
|
||
iconEl.textContent = config.icon;
|
||
titleEl.textContent = getMobilePopupTitle(type, data);
|
||
subEl.textContent = getMobilePopupSubtitle(type, data);
|
||
|
||
// Invalidate any in-flight hide listener
|
||
popupShowToken += 1;
|
||
const token = popupShowToken;
|
||
|
||
popup.dataset.dockSide = options.dockSide === 'right' ? 'right' : 'left';
|
||
popup.classList.toggle('earth-mobile-popup--anchor-stable', options.anchorStable === true);
|
||
popup.removeAttribute('hidden');
|
||
popup.classList.remove('is-visible');
|
||
|
||
requestAnimationFrame(() => {
|
||
positionMobilePopup(popup, x, y, options);
|
||
if (options.reveal === false) {
|
||
return;
|
||
}
|
||
if (token !== popupShowToken) return; // superseded
|
||
void popup.getBoundingClientRect();
|
||
popup.classList.add('is-visible');
|
||
});
|
||
}
|
||
|
||
function hideMobilePopup() {
|
||
const popup = document.getElementById('earth-mobile-popup');
|
||
if (!popup) return;
|
||
popupShowToken += 1; // invalidate any pending show
|
||
popup.classList.remove('is-visible');
|
||
popup.classList.remove('earth-mobile-popup--anchor-stable');
|
||
delete popup.dataset.dockSide;
|
||
popup.addEventListener('transitionend', () => {
|
||
if (!popup.classList.contains('is-visible')) {
|
||
popup.setAttribute('hidden', '');
|
||
}
|
||
}, { once: true });
|
||
}
|
||
|
||
let popupClickBound = false;
|
||
function ensurePopupClickHandler() {
|
||
if (popupClickBound) return;
|
||
popupClickBound = true;
|
||
const popup = document.getElementById('earth-mobile-popup');
|
||
if (!popup) return;
|
||
|
||
let dragPointerId = null;
|
||
let startX = 0, startY = 0;
|
||
let startLeft = 0, startTop = 0;
|
||
let dragged = false;
|
||
const DRAG_THRESHOLD = 10;
|
||
|
||
const emitDragEvent = (dragging) => {
|
||
const rect = popup.getBoundingClientRect();
|
||
window.dispatchEvent(new CustomEvent('earth:info-card-drag', {
|
||
detail: {
|
||
left: rect.left,
|
||
top: rect.top,
|
||
width: rect.width,
|
||
height: rect.height,
|
||
dragging,
|
||
},
|
||
}));
|
||
};
|
||
|
||
popup.addEventListener('pointerdown', (e) => {
|
||
if (e.button > 0) return;
|
||
e.stopPropagation();
|
||
dragPointerId = e.pointerId;
|
||
startX = e.clientX;
|
||
startY = e.clientY;
|
||
const rect = popup.getBoundingClientRect();
|
||
startLeft = rect.left;
|
||
startTop = rect.top;
|
||
dragged = false;
|
||
});
|
||
|
||
// Track drag at document level so pointer can leave popup bounds
|
||
document.addEventListener('pointermove', (e) => {
|
||
if (e.pointerId !== dragPointerId) return;
|
||
const dx = e.clientX - startX;
|
||
const dy = e.clientY - startY;
|
||
if (Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
|
||
dragged = true;
|
||
e.stopPropagation();
|
||
const margin = 8;
|
||
const left = Math.max(margin, Math.min(startLeft + dx, window.innerWidth - popup.offsetWidth - margin));
|
||
const top = Math.max(margin, Math.min(startTop + dy, window.innerHeight - popup.offsetHeight - margin));
|
||
popup.style.left = `${left}px`;
|
||
popup.style.top = `${top}px`;
|
||
emitDragEvent(true);
|
||
});
|
||
|
||
document.addEventListener('pointerup', (e) => {
|
||
if (e.pointerId !== dragPointerId) return;
|
||
const wasDragged = dragged;
|
||
dragPointerId = null;
|
||
dragged = false;
|
||
emitDragEvent(false);
|
||
if (!wasDragged) {
|
||
window.dispatchEvent(new CustomEvent('earth:open-details-tab'));
|
||
}
|
||
});
|
||
|
||
document.addEventListener('pointercancel', (e) => {
|
||
if (e.pointerId === dragPointerId) {
|
||
dragPointerId = null;
|
||
dragged = false;
|
||
emitDragEvent(false);
|
||
}
|
||
});
|
||
|
||
// Block click from bubbling to document (which would close the drawer)
|
||
popup.addEventListener('click', (e) => e.stopPropagation());
|
||
}
|
||
|
||
const CARD_CONFIG = {
|
||
cable: {
|
||
icon: '🛥️',
|
||
title: '电缆详情',
|
||
className: 'cable',
|
||
fields: [
|
||
{ key: 'name', label: '名称' },
|
||
{ key: 'owner', label: '所有者' },
|
||
{ key: 'status', label: '状态' },
|
||
{ key: 'length', label: '长度' },
|
||
{ key: 'coords', label: '经纬度' },
|
||
{ key: 'rfs', label: '投入使用' }
|
||
]
|
||
},
|
||
landing_point: {
|
||
icon: '📍',
|
||
title: '登陆点详情',
|
||
className: 'cable',
|
||
fields: [
|
||
{ key: 'name', label: '名称' },
|
||
{ key: 'country', label: '国家' },
|
||
{ key: 'status', label: '状态' },
|
||
{ key: 'cable_count', label: '关联海缆数' },
|
||
{ key: 'cables', label: '关联海缆' }
|
||
]
|
||
},
|
||
satellite: {
|
||
icon: '🛰️',
|
||
title: '卫星详情',
|
||
className: 'satellite',
|
||
fields: [
|
||
{ key: 'name', label: '名称' },
|
||
{ key: 'norad_id', label: 'NORAD ID' },
|
||
{ key: 'constellation', label: '星座/分组' },
|
||
{ key: 'footprint_capability', label: '覆盖能力' },
|
||
{ key: 'current_display', label: '当前显示' },
|
||
{ key: 'footprint_model', label: '覆盖模型' },
|
||
{ key: 'inclination', label: '倾角', unit: '°' },
|
||
{ key: 'period', label: '周期', unit: '分钟' },
|
||
{ key: 'perigee', label: '近地点高度', unit: 'km' },
|
||
{ key: 'apogee', label: '远地点高度', unit: 'km' }
|
||
]
|
||
},
|
||
bgp: {
|
||
icon: '📡',
|
||
title: 'BGP事件详情',
|
||
className: 'bgp',
|
||
fields: [
|
||
{ key: 'anomaly_type', label: '事件类型' },
|
||
{ key: 'severity', label: '严重度' },
|
||
{ key: 'status', label: '状态' },
|
||
{ key: 'route_change', label: '事件特征' },
|
||
{ key: 'prefix', label: '前缀' },
|
||
{ key: 'as_path_display', label: '传播路径' },
|
||
{ key: 'origin_asn', label: '涉及 ASN' },
|
||
{ key: 'new_origin_asn', label: '关联 ASN' },
|
||
{ key: 'confidence', label: '置信度' },
|
||
{ key: 'collector', label: '主观测站' },
|
||
{ key: 'observed_by', label: '观测范围' },
|
||
{ key: 'impacted_scope', label: '影响区域' },
|
||
{ key: 'related_cables', label: '附近基础设施' },
|
||
{ key: 'related_satellites', label: '附近卫星' },
|
||
{ key: 'location', label: '观测位置' },
|
||
{ key: 'created_at', label: '事件时间' },
|
||
{ key: 'summary', label: '摘要' }
|
||
]
|
||
},
|
||
news: {
|
||
icon: '📰',
|
||
title: '新闻事件详情',
|
||
className: 'news',
|
||
fields: [
|
||
{ key: 'source', label: '来源' },
|
||
{ key: 'published_at_display', label: '发布时间' },
|
||
{ key: 'location_label', label: '发生地' },
|
||
{ key: 'region_label', label: '区域' },
|
||
{ key: 'feed_name', label: '聚合源' },
|
||
{ key: 'summary', label: '摘要' },
|
||
{ key: 'url', label: '原文链接' }
|
||
]
|
||
},
|
||
bgp_collector: {
|
||
icon: '📍',
|
||
title: 'BGP观测站详情',
|
||
className: 'bgp',
|
||
fields: [
|
||
{ key: 'collector', label: '采集器' },
|
||
{ key: 'location', label: '观测位置' },
|
||
{ key: 'anomaly_count', label: '当前事件数' },
|
||
{ key: 'observation_count', label: '观测事件数' },
|
||
{ key: 'recent_24h_observation_count', label: '近24h事件数' },
|
||
{ key: 'recent_7d_observation_count', label: '近7d事件数' },
|
||
{ key: 'prefix_count', label: '观测前缀数' },
|
||
{ key: 'origin_asn_count', label: '观测 ASN 数' },
|
||
{ key: 'top_event_types', label: '主要事件类型' },
|
||
{ key: 'coverage_halo', label: '日常活跃度' },
|
||
{ key: 'related_satellites', label: '附近卫星' },
|
||
{ key: 'latest_event_type', label: '最近事件类型' },
|
||
{ key: 'latest_observed_at', label: '最近活跃时间' },
|
||
{ key: 'baseline_scope', label: '日常覆盖范围' },
|
||
{ key: 'status', label: '状态' }
|
||
]
|
||
},
|
||
compute_center_unresolved: {
|
||
icon: '📍',
|
||
title: '待定位算力中心',
|
||
className: 'compute_unresolved',
|
||
fields: []
|
||
},
|
||
supercomputer: {
|
||
icon: '🖥️',
|
||
title: '超算中心详情',
|
||
className: 'supercomputer',
|
||
fields: [
|
||
{ key: 'name', label: '名称' },
|
||
{ key: 'site_type_label', label: '类型' },
|
||
{ key: 'rank', label: '排名' },
|
||
{ key: 'capacity', label: '实测算力' },
|
||
{ key: 'vendor', label: '厂商' },
|
||
{ key: 'operator', label: '运营方' },
|
||
{ key: 'cores', label: '核心数' },
|
||
{ key: 'power', label: '功耗', unit: 'kW' },
|
||
{ key: 'country', label: '国家' },
|
||
{ key: 'city', label: '城市' },
|
||
{ key: 'location_precision_label', label: '位置精度' },
|
||
{ key: 'location_source_label', label: '位置来源' },
|
||
{ key: 'location_confidence', label: '位置置信度' },
|
||
{ key: 'location_status_label', label: '核验状态' },
|
||
{ key: 'estimated_reason', label: '解析依据' },
|
||
{ key: 'location_source_note', label: '位置来源说明' },
|
||
{ key: 'matched_location_name', label: '匹配的位置名称' },
|
||
{ key: 'location_verified_at', label: '位置核验时间' },
|
||
{ key: 'source', label: '来源' },
|
||
{ key: 'updated_at', label: '更新时间' }
|
||
]
|
||
},
|
||
gpu_cluster: {
|
||
icon: '🎮',
|
||
title: 'GPU集群详情',
|
||
className: 'gpu_cluster',
|
||
fields: [
|
||
{ key: 'name', label: '名称' },
|
||
{ key: 'site_type_label', label: '类型' },
|
||
{ key: 'capacity', label: '估算算力' },
|
||
{ key: 'gpu_count', label: 'GPU 数量' },
|
||
{ key: 'gpu_type', label: 'GPU 型号' },
|
||
{ key: 'vendor', label: '芯片/平台' },
|
||
{ key: 'operator', label: '运营方' },
|
||
{ key: 'country', label: '国家' },
|
||
{ key: 'city', label: '城市' },
|
||
{ key: 'location_precision_label', label: '位置精度' },
|
||
{ key: 'location_source_label', label: '位置来源' },
|
||
{ key: 'location_confidence', label: '位置置信度' },
|
||
{ key: 'location_status_label', label: '核验状态' },
|
||
{ key: 'estimated_reason', label: '解析依据' },
|
||
{ key: 'location_source_note', label: '位置来源说明' },
|
||
{ key: 'matched_location_name', label: '匹配的位置名称' },
|
||
{ key: 'location_verified_at', label: '位置核验时间' },
|
||
{ key: 'source', label: '来源' },
|
||
{ key: 'updated_at', label: '更新时间' }
|
||
]
|
||
},
|
||
vessel: {
|
||
icon: '🚢',
|
||
title: '船只详情',
|
||
className: 'vessel',
|
||
fields: [
|
||
{ key: 'name', label: '名称' },
|
||
{ key: 'mmsi', label: 'MMSI' },
|
||
{ key: 'imo', label: 'IMO' },
|
||
{ key: 'flag', label: '旗帜' },
|
||
{ key: 'vessel_type', label: '船型' },
|
||
{ key: 'speed', label: '当前航速', unit: 'kn' },
|
||
{ key: 'course', label: '航向', unit: '°' },
|
||
{ key: 'status', label: '状态' },
|
||
{ key: 'length', label: '船长', unit: 'm' },
|
||
{ key: 'received_at', label: '更新时间' }
|
||
]
|
||
}
|
||
};
|
||
|
||
function getPanel() {
|
||
return document.getElementById('info-panel');
|
||
}
|
||
|
||
function setupInfoCardDrag(panel) {
|
||
const app = document.getElementById('container');
|
||
if (!app) return;
|
||
|
||
const handle = panel.querySelector('.hud-panel-drag-handle');
|
||
if (!handle) return;
|
||
|
||
let isDragging = false;
|
||
let activePointerId = null;
|
||
let startPointerX = 0;
|
||
let startPointerY = 0;
|
||
let startLeft = 0;
|
||
let startTop = 0;
|
||
|
||
const emitDragEvent = () => {
|
||
const rect = panel.getBoundingClientRect();
|
||
window.dispatchEvent(
|
||
new CustomEvent('earth:info-card-drag', {
|
||
detail: {
|
||
left: rect.left,
|
||
top: rect.top,
|
||
width: rect.width,
|
||
height: rect.height,
|
||
dragging: isDragging,
|
||
},
|
||
})
|
||
);
|
||
};
|
||
|
||
const stopDragging = (event) => {
|
||
if (
|
||
event &&
|
||
activePointerId !== null &&
|
||
"pointerId" in event &&
|
||
event.pointerId !== activePointerId
|
||
) {
|
||
return;
|
||
}
|
||
isDragging = false;
|
||
activePointerId = null;
|
||
panel.classList.remove('is-dragging');
|
||
document.body.style.userSelect = '';
|
||
emitDragEvent();
|
||
};
|
||
|
||
const onMove = (event) => {
|
||
if (!isDragging) return;
|
||
if (activePointerId !== null && event.pointerId !== activePointerId) return;
|
||
event.preventDefault();
|
||
const appRect = app.getBoundingClientRect();
|
||
const panelRect = panel.getBoundingClientRect();
|
||
const nextLeft = Math.min(
|
||
Math.max(startLeft + (event.clientX - startPointerX), 0),
|
||
appRect.width - panelRect.width,
|
||
);
|
||
const nextTop = Math.min(
|
||
Math.max(startTop + (event.clientY - startPointerY), 0),
|
||
appRect.height - panelRect.height,
|
||
);
|
||
panel.style.left = `${nextLeft}px`;
|
||
panel.style.top = `${nextTop}px`;
|
||
emitDragEvent();
|
||
};
|
||
|
||
handle.addEventListener('pointerdown', (event) => {
|
||
if (event.target.closest('.hud-panel-close, .info-card-close')) return;
|
||
event.preventDefault();
|
||
isDragging = true;
|
||
activePointerId = event.pointerId;
|
||
startPointerX = event.clientX;
|
||
startPointerY = event.clientY;
|
||
const appRect = app.getBoundingClientRect();
|
||
const panelRect = panel.getBoundingClientRect();
|
||
startLeft = panelRect.left - appRect.left;
|
||
startTop = panelRect.top - appRect.top;
|
||
panel.style.left = `${startLeft}px`;
|
||
panel.style.top = `${startTop}px`;
|
||
panel.style.right = 'auto';
|
||
panel.style.bottom = 'auto';
|
||
panel.classList.add('is-dragging');
|
||
document.body.style.userSelect = 'none';
|
||
handle.setPointerCapture?.(event.pointerId);
|
||
emitDragEvent();
|
||
});
|
||
|
||
// Listen in capture phase so card-level stopPropagation used to shield the
|
||
// globe canvas does not swallow the drag stream before we can reposition.
|
||
window.addEventListener('pointermove', onMove, { passive: false, capture: true });
|
||
window.addEventListener('pointerup', stopDragging, { capture: true });
|
||
window.addEventListener('pointercancel', stopDragging, { capture: true });
|
||
handle.addEventListener('lostpointercapture', stopDragging);
|
||
}
|
||
|
||
function mountCard() {
|
||
if (cardMounted) return;
|
||
|
||
const container = document.getElementById('container');
|
||
if (!container) return;
|
||
|
||
const panel = document.createElement('div');
|
||
panel.id = 'info-panel';
|
||
panel.className = 'hud-panel hud-panel-info';
|
||
panel.setAttribute('aria-live', 'polite');
|
||
panel.setAttribute('aria-hidden', 'true');
|
||
panel.setAttribute('hidden', '');
|
||
panel.innerHTML = `
|
||
<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'
|
||
? getNewsDisplayTitle(data)
|
||
: config.title;
|
||
}
|
||
if (typeLabel) {
|
||
typeLabel.textContent = type === 'news'
|
||
? '新闻信号'
|
||
: type.replaceAll('_', ' ');
|
||
}
|
||
|
||
if (content && type !== 'news') {
|
||
renderMobileDetailContent(type, config, data);
|
||
renderedMobileDetailKey = null;
|
||
} else if (content && type === 'news' && isMobileDetailsDrawerActive()) {
|
||
renderMobileNewsCardContent(content, data);
|
||
renderedMobileDetailKey = getMobileDetailRenderKey(type, data);
|
||
}
|
||
|
||
// Show the floating mini popup near the touch point (requires coordinates)
|
||
if (options.x != null && options.y != null) {
|
||
ensurePopupClickHandler();
|
||
showMobilePopup(type, data, options.x, options.y, options);
|
||
document.body.classList.add('earth-info-open');
|
||
window.dispatchEvent(
|
||
new CustomEvent('earth:info-card-visibility-change', { detail: { visible: true } })
|
||
);
|
||
}
|
||
return;
|
||
}
|
||
|
||
mountCard();
|
||
|
||
currentType = type;
|
||
const card = document.getElementById('info-card');
|
||
const icon = document.getElementById('info-card-icon');
|
||
const title = document.getElementById('info-card-title');
|
||
const content = document.getElementById('info-card-content');
|
||
|
||
stopTypewriterAnimation();
|
||
card.className = 'info-card ' + config.className;
|
||
icon.textContent = config.icon;
|
||
title.textContent = type === 'news'
|
||
? getNewsDisplayTitle(data)
|
||
: config.title;
|
||
|
||
if (type === 'news') {
|
||
renderNewsCardContent(content, data);
|
||
} else {
|
||
renderDefaultCardContent(content, config, data);
|
||
}
|
||
showPanel(options.x, options.y, options);
|
||
}
|
||
|
||
export function hideInfoCard() {
|
||
stopTypewriterAnimation();
|
||
if (document.body.classList.contains('layout-mode-mobile')) {
|
||
hideMobilePopup();
|
||
document.body.classList.remove('earth-info-open');
|
||
window.dispatchEvent(
|
||
new CustomEvent('earth:info-card-visibility-change', { detail: { visible: false } })
|
||
);
|
||
currentType = null;
|
||
pendingMobileDetailState = null;
|
||
renderedMobileDetailKey = null;
|
||
return;
|
||
}
|
||
hidePanel();
|
||
currentType = null;
|
||
}
|
||
|
||
export function getCurrentType() {
|
||
return currentType;
|
||
}
|