release: bump version to 0.34.0
This commit is contained in:
@@ -453,6 +453,246 @@
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.earth-search-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 255;
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease, visibility 0.2s ease;
|
||||
}
|
||||
|
||||
.earth-search-modal.is-open {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.earth-search-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(2, 8, 20, 0.38);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.earth-search-modal.is-open .earth-search-backdrop {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.earth-search-sheet {
|
||||
position: fixed;
|
||||
top: max(calc(28px * var(--hud-scale)), 8vh);
|
||||
left: 50%;
|
||||
width: min(calc(680px * var(--hud-scale)), calc(100vw - (32px * var(--hud-scale))));
|
||||
max-height: min(calc(720px * var(--hud-scale)), calc(100vh - (56px * var(--hud-scale))));
|
||||
transform: translateX(-50%) scale(0.98);
|
||||
transform-origin: top center;
|
||||
padding: calc(var(--hud-panel-padding) * var(--hud-scale));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(var(--hud-gap-md) * var(--hud-scale));
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
filter: blur(10px);
|
||||
transition:
|
||||
transform 0.22s cubic-bezier(0.2, 0.8, 0.2, 1),
|
||||
opacity 0.22s ease,
|
||||
filter 0.22s ease;
|
||||
}
|
||||
|
||||
.earth-search-modal.is-open .earth-search-sheet {
|
||||
transform: translateX(-50%) scale(1);
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
}
|
||||
|
||||
.earth-search-header,
|
||||
.earth-search-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.earth-search-kicker {
|
||||
color: var(--hud-text-soft);
|
||||
font-size: calc(0.72rem * var(--hud-scale));
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.earth-search-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(var(--hud-gap-sm) * var(--hud-scale));
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.earth-search-input-shell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(8px * var(--hud-scale));
|
||||
min-height: calc(48px * var(--hud-scale));
|
||||
padding: calc(8px * var(--hud-scale)) calc(12px * var(--hud-scale));
|
||||
border: 1px solid rgba(214, 230, 247, 0.12);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.05), transparent),
|
||||
rgba(255, 255, 255, 0.03);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.earth-search-input-shell:focus-within {
|
||||
border-color: rgba(215, 230, 249, 0.22);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.08),
|
||||
0 0 0 1px rgba(126, 174, 236, 0.1);
|
||||
}
|
||||
|
||||
.earth-search-input-icon {
|
||||
color: var(--hud-text-muted);
|
||||
font-size: calc(20px * var(--hud-scale));
|
||||
}
|
||||
|
||||
.earth-search-input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: var(--hud-title);
|
||||
font: inherit;
|
||||
font-size: calc(0.9rem * var(--hud-scale));
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.earth-search-input::placeholder {
|
||||
color: rgba(190, 208, 227, 0.46);
|
||||
}
|
||||
|
||||
.earth-search-clear[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.earth-search-meta {
|
||||
min-height: calc(18px * var(--hud-scale));
|
||||
color: var(--hud-text-muted);
|
||||
font-size: calc(0.7rem * var(--hud-scale));
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.earth-search-results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(8px * var(--hud-scale));
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding-right: calc(4px * var(--hud-scale));
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(160, 186, 216, 0.34) transparent;
|
||||
}
|
||||
|
||||
.earth-search-results::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.earth-search-results::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.earth-search-results::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(180deg, rgba(210, 225, 242, 0.2), rgba(126, 154, 185, 0.28));
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.earth-search-empty {
|
||||
color: var(--hud-text-muted);
|
||||
font-size: calc(0.76rem * var(--hud-scale));
|
||||
line-height: 1.55;
|
||||
padding: calc(8px * var(--hud-scale)) calc(2px * var(--hud-scale));
|
||||
}
|
||||
|
||||
.earth-search-result {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: calc(12px * var(--hud-scale));
|
||||
width: 100%;
|
||||
padding: calc(12px * var(--hud-scale)) calc(14px * var(--hud-scale));
|
||||
border: 1px solid rgba(212, 227, 244, 0.09);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.05), transparent),
|
||||
rgba(255, 255, 255, 0.025);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.18s ease,
|
||||
border-color 0.18s ease,
|
||||
transform 0.18s ease;
|
||||
}
|
||||
|
||||
.earth-search-result:hover,
|
||||
.earth-search-result.is-active {
|
||||
border-color: rgba(224, 236, 249, 0.16);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.06), transparent),
|
||||
rgba(255, 255, 255, 0.04);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.earth-search-result-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: calc(32px * var(--hud-scale));
|
||||
height: calc(32px * var(--hud-scale));
|
||||
border-radius: calc(10px * var(--hud-scale));
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(214, 230, 247, 0.1);
|
||||
color: var(--hud-accent-strong);
|
||||
}
|
||||
|
||||
.earth-search-result-icon .material-symbols-rounded {
|
||||
font-size: calc(18px * var(--hud-scale));
|
||||
}
|
||||
|
||||
.earth-search-result-copy {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.earth-search-result-title {
|
||||
color: var(--hud-title);
|
||||
font-size: calc(0.86rem * var(--hud-scale));
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.earth-search-result-subtitle {
|
||||
color: var(--hud-text-soft);
|
||||
font-size: calc(0.72rem * var(--hud-scale));
|
||||
line-height: 1.45;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.earth-search-result-type {
|
||||
color: var(--hud-text-muted);
|
||||
font-size: calc(0.68rem * var(--hud-scale));
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.earth-settings-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
|
||||
@@ -156,11 +156,11 @@
|
||||
<div id="control-toolbar" class="earth-toolbar">
|
||||
<div id="toolbar-cluster" class="earth-toolbar-cluster is-collapsed">
|
||||
<div class="earth-toolbar-orb" data-orb-index="0" style="--orb-delay: 0s;">
|
||||
<button id="search-action" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="搜索功能(待开发)">
|
||||
<button id="search-action" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="搜索">
|
||||
<span class="icon" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">search</span>
|
||||
</span>
|
||||
<span class="tooltip earth-toolbar-tooltip">搜索功能(待开发)</span>
|
||||
<span class="tooltip earth-toolbar-tooltip">搜索</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="earth-toolbar-orb" data-orb-index="1" style="--orb-delay: 0.18s;">
|
||||
@@ -412,6 +412,41 @@
|
||||
|
||||
<div id="status-message" class="earth-status-message" aria-live="polite" aria-atomic="true"></div>
|
||||
<div id="tooltip" class="earth-tooltip"></div>
|
||||
<div id="search-modal" class="earth-search-modal" aria-hidden="true">
|
||||
<div id="search-backdrop" class="earth-search-backdrop"></div>
|
||||
<div class="earth-search-sheet hud-panel" role="dialog" aria-modal="true" aria-label="搜索">
|
||||
<div class="earth-search-header hud-panel__header">
|
||||
<div class="hud-panel__title-group">
|
||||
<div class="earth-search-kicker">搜索</div>
|
||||
</div>
|
||||
<div class="hud-panel__actions">
|
||||
<button id="search-close" class="earth-search-close hud-panel__action hud-panel__action--close" type="button" aria-label="关闭搜索">
|
||||
<span class="material-symbols-rounded">close</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="earth-search-content hud-panel__body">
|
||||
<div class="earth-search-input-shell">
|
||||
<span class="material-symbols-rounded earth-search-input-icon" aria-hidden="true">search</span>
|
||||
<input
|
||||
id="earth-search-input"
|
||||
class="earth-search-input"
|
||||
type="text"
|
||||
inputmode="search"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
placeholder="搜索海缆、登陆点、卫星、BGP 事件..."
|
||||
>
|
||||
<button id="earth-search-clear" class="earth-search-clear hud-panel__action" type="button" aria-label="清除搜索" hidden>
|
||||
<span class="material-symbols-rounded">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="earth-search-meta" class="earth-search-meta">输入关键词以搜索当前地球对象</div>
|
||||
<div id="earth-search-results" class="earth-search-results" role="listbox" aria-label="搜索结果"></div>
|
||||
<div id="earth-search-empty" class="earth-search-empty">支持搜索海缆、登陆点、卫星、BGP 事件与观测站。</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="settings-modal" class="earth-settings-modal" aria-hidden="true">
|
||||
<div id="settings-backdrop" class="earth-settings-backdrop"></div>
|
||||
<div class="earth-settings-sheet hud-panel" role="dialog" aria-modal="true" aria-label="设置">
|
||||
|
||||
3
frontend/public/earth/js/controls.js
vendored
3
frontend/public/earth/js/controls.js
vendored
@@ -32,6 +32,7 @@ import {
|
||||
ensureNewsPanelReady,
|
||||
updateNewsToggleUI,
|
||||
} from "./news.js";
|
||||
import { openSearchPanel } from "./search.js";
|
||||
import {
|
||||
setButtonTooltip,
|
||||
setLayerButtonState,
|
||||
@@ -1797,7 +1798,7 @@ function setupTerrainControls() {
|
||||
setupLayerPanel();
|
||||
|
||||
bindListener(searchBtn, "click", () => {
|
||||
showStatusMessage("搜索功能待开发", "info");
|
||||
openSearchPanel();
|
||||
});
|
||||
|
||||
bindListener(terrainBtn, "pointerenter", () => {
|
||||
|
||||
@@ -18,6 +18,18 @@ const CARD_CONFIG = {
|
||||
{ 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: '卫星详情',
|
||||
|
||||
@@ -72,6 +72,7 @@ import {
|
||||
toggleSatellites,
|
||||
getShowSatellites,
|
||||
getSatelliteLegendItems,
|
||||
getSatelliteData,
|
||||
setSelectedSatelliteLegend,
|
||||
clearSelectedSatelliteLegend,
|
||||
getSatelliteCount,
|
||||
@@ -162,6 +163,7 @@ import {
|
||||
import { mountBrand } from "./brand.js";
|
||||
import { initTVPanel } from "./tv.js";
|
||||
import { initNewsPanel, updateNewsViewFocus } from "./news.js";
|
||||
import { initSearchPanel } from "./search.js";
|
||||
|
||||
export let scene;
|
||||
export let camera;
|
||||
@@ -609,6 +611,391 @@ function getBGPCollectorBriefHtml(marker) {
|
||||
return `<strong>${name}</strong><br>${count} 条事件`;
|
||||
}
|
||||
|
||||
function getSearchCardCoords() {
|
||||
return {
|
||||
x: Math.round(window.innerWidth * SEARCH_CARD_X_RATIO),
|
||||
y: Math.round(window.innerHeight * SEARCH_CARD_Y_RATIO),
|
||||
absolute: true,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSearchString(...parts) {
|
||||
return parts
|
||||
.flat()
|
||||
.filter((part) => part !== undefined && part !== null && part !== false)
|
||||
.map((part) => String(part).trim())
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function computeSearchScore(query, ...parts) {
|
||||
const text = normalizeSearchString(...parts);
|
||||
if (!text) return -1;
|
||||
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
if (!normalizedQuery) return -1;
|
||||
|
||||
if (text === normalizedQuery) return 240;
|
||||
if (text.startsWith(normalizedQuery)) return 180;
|
||||
if (text.includes(normalizedQuery)) return 120;
|
||||
|
||||
const tokens = normalizedQuery.split(/\s+/).filter(Boolean);
|
||||
if (tokens.length === 0) return -1;
|
||||
|
||||
let score = 0;
|
||||
for (const token of tokens) {
|
||||
if (text.startsWith(token)) {
|
||||
score += 60;
|
||||
continue;
|
||||
}
|
||||
if (text.includes(token)) {
|
||||
score += 36;
|
||||
continue;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
function getCableFocusCoords(cable) {
|
||||
if (!cable?.userData?.localCenter) return null;
|
||||
return vector3ToLatLon(cable.userData.localCenter);
|
||||
}
|
||||
|
||||
function getLandingPointFocusCoords(point) {
|
||||
if (!point?.position) return null;
|
||||
return vector3ToLatLon(point.position);
|
||||
}
|
||||
|
||||
function getSatelliteFocusCoords(index) {
|
||||
const positions = getSatellitePositions();
|
||||
const vector = positions?.[index]?.current;
|
||||
if (!vector) return null;
|
||||
return vector3ToLatLon(vector);
|
||||
}
|
||||
|
||||
function getBGPFocusCoords(marker) {
|
||||
const lat = marker?.userData?.displayLatitude ?? marker?.userData?.latitude;
|
||||
const lon = marker?.userData?.displayLongitude ?? marker?.userData?.longitude;
|
||||
if (typeof lat !== "number" || typeof lon !== "number") return null;
|
||||
return { lat, lon };
|
||||
}
|
||||
|
||||
async function focusSearchTarget(coords, zoom = Math.max(getZoomLevel(), 1.12)) {
|
||||
if (!coords || !camera) return;
|
||||
await focusEarthView(camera, {
|
||||
lat: coords.lat,
|
||||
lon: coords.lon,
|
||||
zoom,
|
||||
duration: 950,
|
||||
suppressStatus: true,
|
||||
});
|
||||
}
|
||||
|
||||
function showLandingPointInfo(point, coords) {
|
||||
const cableNames = Array.isArray(point?.userData?.cableNames)
|
||||
? point.userData.cableNames
|
||||
: [];
|
||||
setLegendMode("cables");
|
||||
showInfoCard(
|
||||
"landing_point",
|
||||
{
|
||||
name: point?.userData?.name || "-",
|
||||
country: point?.userData?.country || "-",
|
||||
status: point?.userData?.status || "-",
|
||||
cable_count: cableNames.length,
|
||||
cables: cableNames.length > 0 ? cableNames.join(" / ") : "-",
|
||||
},
|
||||
coords,
|
||||
);
|
||||
}
|
||||
|
||||
async function focusSearchCable(cable) {
|
||||
await setCablesEnabled(true, {
|
||||
suppressStatus: true,
|
||||
suppressLoadingUi: true,
|
||||
});
|
||||
interruptCruisePresentation({ resetLoop: true });
|
||||
clearLockedObject();
|
||||
setAutoRotate(false);
|
||||
|
||||
const coords = getCableFocusCoords(cable);
|
||||
if (coords) {
|
||||
await focusSearchTarget(coords, Math.max(getZoomLevel(), 1.14));
|
||||
}
|
||||
|
||||
const cableId = cable?.userData?.cableId;
|
||||
if (cableId !== undefined) {
|
||||
setCableState(cableId, CABLE_STATE.LOCKED);
|
||||
}
|
||||
lockedObject = cable;
|
||||
lockedObjectType = "cable";
|
||||
handleCableClick(cable);
|
||||
showCableInfo(cable, getSearchCardCoords());
|
||||
}
|
||||
|
||||
async function focusSearchLandingPoint(point) {
|
||||
await setCablesEnabled(true, {
|
||||
suppressStatus: true,
|
||||
suppressLoadingUi: true,
|
||||
});
|
||||
interruptCruisePresentation({ resetLoop: true });
|
||||
clearLockedObject();
|
||||
setAutoRotate(false);
|
||||
|
||||
const coords = getLandingPointFocusCoords(point);
|
||||
if (coords) {
|
||||
await focusSearchTarget(coords, Math.max(getZoomLevel(), 1.22));
|
||||
}
|
||||
|
||||
const relatedCableNames = Array.isArray(point?.userData?.cableNames)
|
||||
? point.userData.cableNames
|
||||
: [];
|
||||
clearAllCableStates();
|
||||
getCableLines().forEach((cable) => {
|
||||
if (relatedCableNames.includes(cable.userData?.name)) {
|
||||
setCableState(cable.userData.cableId, CABLE_STATE.LOCKED);
|
||||
}
|
||||
});
|
||||
applyLandingPointVisualState(relatedCableNames, relatedCableNames.length === 0, camera);
|
||||
showLandingPointInfo(point, getSearchCardCoords());
|
||||
showStatusMessage(`已定位登陆点:${point.userData?.name || "未知登陆点"}`, "info");
|
||||
}
|
||||
|
||||
async function focusSearchSatellite(index) {
|
||||
await setSatellitesEnabled(true, {
|
||||
suppressStatus: true,
|
||||
suppressLoadingUi: true,
|
||||
});
|
||||
interruptCruisePresentation({ resetLoop: true });
|
||||
clearLockedObject();
|
||||
setAutoRotate(false);
|
||||
|
||||
const sat = selectSatellite(index);
|
||||
if (!sat?.properties) return;
|
||||
|
||||
const coords = getSatelliteFocusCoords(index);
|
||||
if (coords) {
|
||||
await focusSearchTarget(coords, Math.max(getZoomLevel(), 1.18));
|
||||
}
|
||||
|
||||
lockedObject = sat;
|
||||
lockedObjectType = "satellite";
|
||||
lockedSatellite = sat;
|
||||
lockedSatelliteIndex = index;
|
||||
setLockedSatelliteIndex(index);
|
||||
showPredictedOrbit(sat);
|
||||
const satPositions = getSatellitePositions();
|
||||
if (satPositions?.[index]) {
|
||||
setSatelliteRingState(index, "locked", satPositions[index].current);
|
||||
}
|
||||
showSatelliteInfo(sat.properties, getSearchCardCoords());
|
||||
showStatusMessage(`已定位卫星:${sat.properties.name || sat.properties.norad_cat_id || "未知卫星"}`, "info");
|
||||
}
|
||||
|
||||
async function focusSearchBGPMarker(marker) {
|
||||
if (!getShowBGP()) {
|
||||
toggleBGP(true);
|
||||
}
|
||||
interruptCruisePresentation({ resetLoop: true });
|
||||
clearLockedObject();
|
||||
setAutoRotate(false);
|
||||
|
||||
const coords = getBGPFocusCoords(marker);
|
||||
if (coords) {
|
||||
await focusSearchTarget(coords, Math.max(getZoomLevel(), 1.2));
|
||||
}
|
||||
|
||||
const earth = getEarth();
|
||||
if (marker?.userData?.type === "bgp") {
|
||||
setBGPMarkerState(marker, "locked");
|
||||
lockedObject = marker;
|
||||
lockedObjectType = "bgp";
|
||||
showBGPEventOverlay(marker, earth);
|
||||
applyBGPEventSatelliteHighlights(marker);
|
||||
showBGPInfo(marker, getSearchCardCoords());
|
||||
showStatusMessage(`已定位 BGP 事件:${marker.userData?.collector || "未知观测站"}`, "info");
|
||||
return;
|
||||
}
|
||||
|
||||
if (marker?.userData?.type === "bgp_collector") {
|
||||
setBGPMarkerState(marker, "locked");
|
||||
lockedObject = marker;
|
||||
lockedObjectType = "bgp_collector";
|
||||
showBGPCollectorCoverageOverlay(marker, earth);
|
||||
showBGPCollectorInfo(marker, getSearchCardCoords());
|
||||
showStatusMessage(`已定位观测站:${marker.userData?.collector || "未知观测站"}`, "info");
|
||||
}
|
||||
}
|
||||
|
||||
function resolveEarthSearchResults(query) {
|
||||
const results = [];
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
if (!normalizedQuery) return results;
|
||||
|
||||
getCableLines().forEach((cable) => {
|
||||
const score = computeSearchScore(
|
||||
normalizedQuery,
|
||||
cable.userData?.name,
|
||||
cable.userData?.owner,
|
||||
cable.userData?.status,
|
||||
cable.userData?.length,
|
||||
"海缆 电缆 cable",
|
||||
);
|
||||
if (score < 0) return;
|
||||
results.push({
|
||||
id: `cable:${cable.userData?.cableId || cable.uuid}`,
|
||||
kind: "cable",
|
||||
icon: "cable",
|
||||
typeLabel: "海缆",
|
||||
title: cable.userData?.name || "未知海缆",
|
||||
subtitle: [cable.userData?.owner, cable.userData?.status].filter(Boolean).join(" · ") || "海底光缆系统",
|
||||
score,
|
||||
entity: cable,
|
||||
});
|
||||
});
|
||||
|
||||
getLandingPoints().forEach((point, index) => {
|
||||
const score = computeSearchScore(
|
||||
normalizedQuery,
|
||||
point.userData?.name,
|
||||
point.userData?.country,
|
||||
point.userData?.status,
|
||||
point.userData?.cableNames,
|
||||
"登陆点 landing point",
|
||||
);
|
||||
if (score < 0) return;
|
||||
results.push({
|
||||
id: `landing:${point.uuid || index}`,
|
||||
kind: "landing_point",
|
||||
icon: "location_on",
|
||||
typeLabel: "登陆点",
|
||||
title: point.userData?.name || "未知登陆点",
|
||||
subtitle:
|
||||
[point.userData?.country, Array.isArray(point.userData?.cableNames) ? `${point.userData.cableNames.length} 条海缆` : ""]
|
||||
.filter(Boolean)
|
||||
.join(" · ") || "海缆登陆点",
|
||||
score,
|
||||
entity: point,
|
||||
});
|
||||
});
|
||||
|
||||
getSatelliteData().forEach((satellite, index) => {
|
||||
const props = satellite?.properties;
|
||||
const score = computeSearchScore(
|
||||
normalizedQuery,
|
||||
props?.name,
|
||||
props?.norad_cat_id,
|
||||
props?.inclination,
|
||||
"卫星 satellite norad",
|
||||
);
|
||||
if (score < 0) return;
|
||||
results.push({
|
||||
id: `sat:${props?.norad_cat_id || index}`,
|
||||
kind: "satellite",
|
||||
icon: "satellite_alt",
|
||||
typeLabel: "卫星",
|
||||
title: props?.name || `NORAD ${props?.norad_cat_id || index}`,
|
||||
subtitle: props?.norad_cat_id ? `NORAD ${props.norad_cat_id}` : "在轨卫星",
|
||||
score,
|
||||
entity: { index },
|
||||
});
|
||||
});
|
||||
|
||||
getBGPAnomalyMarkers().forEach((marker) => {
|
||||
const score = computeSearchScore(
|
||||
normalizedQuery,
|
||||
marker.userData?.collector,
|
||||
marker.userData?.prefix,
|
||||
marker.userData?.city,
|
||||
marker.userData?.country,
|
||||
marker.userData?.anomaly_type,
|
||||
marker.userData?.incident_type,
|
||||
marker.userData?.origin_asn,
|
||||
marker.userData?.new_origin_asn,
|
||||
"bgp 事件 anomaly prefix asn",
|
||||
);
|
||||
if (score < 0) return;
|
||||
results.push({
|
||||
id: `bgp:${marker.userData?.id || marker.uuid}`,
|
||||
kind: "bgp",
|
||||
icon: "hub",
|
||||
typeLabel: "BGP事件",
|
||||
title:
|
||||
formatBGPAnomalyTypeLabel(
|
||||
marker.userData?.incident_type || marker.userData?.anomaly_type,
|
||||
) || "BGP 事件",
|
||||
subtitle:
|
||||
[
|
||||
marker.userData?.collector,
|
||||
marker.userData?.prefix,
|
||||
formatBGPLocation(marker.userData?.city, marker.userData?.country),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ") || "BGP 异常事件",
|
||||
score,
|
||||
entity: marker,
|
||||
});
|
||||
});
|
||||
|
||||
getBGPCollectorMarkers().forEach((marker) => {
|
||||
const score = computeSearchScore(
|
||||
normalizedQuery,
|
||||
marker.userData?.collector,
|
||||
marker.userData?.city,
|
||||
marker.userData?.country,
|
||||
marker.userData?.status,
|
||||
"bgp collector 观测站",
|
||||
);
|
||||
if (score < 0) return;
|
||||
results.push({
|
||||
id: `collector:${marker.userData?.collector || marker.uuid}`,
|
||||
kind: "bgp_collector",
|
||||
icon: "travel_explore",
|
||||
typeLabel: "观测站",
|
||||
title: marker.userData?.collector || "未知观测站",
|
||||
subtitle:
|
||||
[
|
||||
formatBGPLocation(marker.userData?.city, marker.userData?.country),
|
||||
formatBGPCollectorStatus(marker.userData?.status || "online"),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ") || "BGP 观测站",
|
||||
score,
|
||||
entity: marker,
|
||||
});
|
||||
});
|
||||
|
||||
return results
|
||||
.sort((left, right) => {
|
||||
if (right.score !== left.score) return right.score - left.score;
|
||||
return left.title.localeCompare(right.title, "zh-CN");
|
||||
})
|
||||
.slice(0, SEARCH_RESULT_LIMIT);
|
||||
}
|
||||
|
||||
async function handleSearchSelection(result) {
|
||||
if (!result) return;
|
||||
|
||||
if (result.kind === "cable") {
|
||||
await focusSearchCable(result.entity);
|
||||
return;
|
||||
}
|
||||
if (result.kind === "landing_point") {
|
||||
await focusSearchLandingPoint(result.entity);
|
||||
return;
|
||||
}
|
||||
if (result.kind === "satellite") {
|
||||
await focusSearchSatellite(result.entity.index);
|
||||
return;
|
||||
}
|
||||
if (result.kind === "bgp" || result.kind === "bgp_collector") {
|
||||
await focusSearchBGPMarker(result.entity);
|
||||
}
|
||||
}
|
||||
|
||||
function getBGPStatusText(bgpResult) {
|
||||
if (bgpResult.totalCount > 0) {
|
||||
return `${bgpResult.totalCount} 起活跃事件`;
|
||||
@@ -1188,6 +1575,10 @@ export function init() {
|
||||
mountBrand(brandRoot, HUD_CONFIG.brandLanguage);
|
||||
initTVPanel();
|
||||
initNewsPanel();
|
||||
initSearchPanel({
|
||||
resolveResults: resolveEarthSearchResults,
|
||||
onSelectResult: handleSearchSelection,
|
||||
});
|
||||
|
||||
scene = new THREE.Scene();
|
||||
camera = new THREE.PerspectiveCamera(
|
||||
@@ -1474,6 +1865,9 @@ async function loadData() {
|
||||
}
|
||||
|
||||
const POSITION_UPDATE_FORCE_DELTA = 250;
|
||||
const SEARCH_RESULT_LIMIT = 28;
|
||||
const SEARCH_CARD_X_RATIO = 0.68;
|
||||
const SEARCH_CARD_Y_RATIO = 0.18;
|
||||
|
||||
export async function reloadData() {
|
||||
await loadData();
|
||||
|
||||
227
frontend/public/earth/js/search.js
Normal file
227
frontend/public/earth/js/search.js
Normal file
@@ -0,0 +1,227 @@
|
||||
let initialized = false;
|
||||
let resolveResultsFn = null;
|
||||
let onSelectResultFn = null;
|
||||
let currentResults = [];
|
||||
let activeIndex = -1;
|
||||
let searchTimerId = null;
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function getElements() {
|
||||
return {
|
||||
modal: document.getElementById("search-modal"),
|
||||
backdrop: document.getElementById("search-backdrop"),
|
||||
input: document.getElementById("earth-search-input"),
|
||||
clear: document.getElementById("earth-search-clear"),
|
||||
meta: document.getElementById("earth-search-meta"),
|
||||
results: document.getElementById("earth-search-results"),
|
||||
empty: document.getElementById("earth-search-empty"),
|
||||
close: document.getElementById("search-close"),
|
||||
};
|
||||
}
|
||||
|
||||
function setMeta(text) {
|
||||
const { meta } = getElements();
|
||||
if (meta) meta.textContent = text;
|
||||
}
|
||||
|
||||
function updateEmptyState(query) {
|
||||
const { empty } = getElements();
|
||||
if (!empty) return;
|
||||
if (!query) {
|
||||
empty.textContent = "支持搜索海缆、登陆点、卫星、BGP 事件与观测站。";
|
||||
return;
|
||||
}
|
||||
empty.textContent = "未找到匹配对象,可尝试名称、地点、NORAD、ASN、前缀等关键词。";
|
||||
}
|
||||
|
||||
function renderResults(query) {
|
||||
const { results, empty } = getElements();
|
||||
if (!results || !empty) return;
|
||||
|
||||
results.innerHTML = "";
|
||||
const hasResults = currentResults.length > 0;
|
||||
empty.hidden = hasResults;
|
||||
updateEmptyState(query);
|
||||
|
||||
if (!hasResults) return;
|
||||
|
||||
currentResults.forEach((result, index) => {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "earth-search-result";
|
||||
button.setAttribute("role", "option");
|
||||
button.dataset.index = String(index);
|
||||
button.innerHTML = `
|
||||
<span class="earth-search-result-icon" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">${escapeHtml(result.icon || "search")}</span>
|
||||
</span>
|
||||
<span class="earth-search-result-copy">
|
||||
<span class="earth-search-result-title">${escapeHtml(result.title)}</span>
|
||||
<span class="earth-search-result-subtitle">${escapeHtml(result.subtitle || "")}</span>
|
||||
</span>
|
||||
<span class="earth-search-result-type">${escapeHtml(result.typeLabel || "")}</span>
|
||||
`;
|
||||
button.addEventListener("click", async () => {
|
||||
await selectResult(index);
|
||||
});
|
||||
results.appendChild(button);
|
||||
});
|
||||
|
||||
syncActiveResult();
|
||||
}
|
||||
|
||||
function syncActiveResult() {
|
||||
const { results } = getElements();
|
||||
if (!results) return;
|
||||
Array.from(results.children).forEach((node, index) => {
|
||||
node.classList.toggle("is-active", index === activeIndex);
|
||||
});
|
||||
}
|
||||
|
||||
function moveActiveResult(delta) {
|
||||
if (currentResults.length === 0) return;
|
||||
activeIndex =
|
||||
((activeIndex < 0 ? 0 : activeIndex) + delta + currentResults.length) %
|
||||
currentResults.length;
|
||||
syncActiveResult();
|
||||
const { results } = getElements();
|
||||
const activeNode = results?.children?.[activeIndex];
|
||||
activeNode?.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
|
||||
async function selectResult(index) {
|
||||
const result = currentResults[index];
|
||||
if (!result || typeof onSelectResultFn !== "function") return;
|
||||
closeSearchPanel();
|
||||
try {
|
||||
await onSelectResultFn(result);
|
||||
} catch (error) {
|
||||
console.error("Search selection failed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function runSearch() {
|
||||
const { input, clear } = getElements();
|
||||
if (!input) return;
|
||||
|
||||
const query = input.value.trim();
|
||||
if (clear) {
|
||||
clear.hidden = query.length === 0;
|
||||
}
|
||||
|
||||
if (!query) {
|
||||
currentResults = [];
|
||||
activeIndex = -1;
|
||||
setMeta("输入关键词以搜索当前地球对象");
|
||||
renderResults("");
|
||||
return;
|
||||
}
|
||||
|
||||
setMeta("正在检索…");
|
||||
|
||||
try {
|
||||
const nextResults = await resolveResultsFn?.(query);
|
||||
currentResults = Array.isArray(nextResults) ? nextResults : [];
|
||||
activeIndex = currentResults.length > 0 ? 0 : -1;
|
||||
setMeta(`找到 ${currentResults.length} 个结果`);
|
||||
renderResults(query);
|
||||
} catch (error) {
|
||||
console.error("Search failed:", error);
|
||||
currentResults = [];
|
||||
activeIndex = -1;
|
||||
setMeta("搜索失败");
|
||||
renderResults(query);
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleSearch() {
|
||||
if (searchTimerId) {
|
||||
clearTimeout(searchTimerId);
|
||||
}
|
||||
searchTimerId = window.setTimeout(() => {
|
||||
searchTimerId = null;
|
||||
runSearch();
|
||||
}, 120);
|
||||
}
|
||||
|
||||
function handleKeydown(event) {
|
||||
const { modal, input } = getElements();
|
||||
if (!modal?.classList.contains("is-open")) return;
|
||||
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
closeSearchPanel();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.target !== input) return;
|
||||
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
moveActiveResult(1);
|
||||
} else if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
moveActiveResult(-1);
|
||||
} else if (event.key === "Enter" && activeIndex >= 0) {
|
||||
event.preventDefault();
|
||||
selectResult(activeIndex).catch((error) => {
|
||||
console.warn("Selecting search result failed:", error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function initSearchPanel({ resolveResults, onSelectResult } = {}) {
|
||||
resolveResultsFn = resolveResults;
|
||||
onSelectResultFn = onSelectResult;
|
||||
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
|
||||
const { input, clear, close, backdrop } = getElements();
|
||||
input?.addEventListener("input", scheduleSearch);
|
||||
input?.addEventListener("keydown", handleKeydown);
|
||||
clear?.addEventListener("click", () => {
|
||||
if (!input) return;
|
||||
input.value = "";
|
||||
input.focus();
|
||||
runSearch().catch((error) => {
|
||||
console.warn("Clearing search failed:", error);
|
||||
});
|
||||
});
|
||||
close?.addEventListener("click", () => {
|
||||
closeSearchPanel();
|
||||
});
|
||||
backdrop?.addEventListener("click", () => {
|
||||
closeSearchPanel();
|
||||
});
|
||||
document.addEventListener("keydown", handleKeydown);
|
||||
}
|
||||
|
||||
export function openSearchPanel() {
|
||||
const { modal, input } = getElements();
|
||||
if (!modal) return;
|
||||
modal.classList.add("is-open");
|
||||
modal.setAttribute("aria-hidden", "false");
|
||||
window.setTimeout(() => {
|
||||
input?.focus();
|
||||
input?.select();
|
||||
runSearch().catch((error) => {
|
||||
console.warn("Running search failed:", error);
|
||||
});
|
||||
}, 16);
|
||||
}
|
||||
|
||||
export function closeSearchPanel() {
|
||||
const { modal } = getElements();
|
||||
if (!modal) return;
|
||||
modal.classList.remove("is-open");
|
||||
modal.setAttribute("aria-hidden", "true");
|
||||
}
|
||||
Reference in New Issue
Block a user