release: bump version to 0.49.0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "planet-frontend",
|
||||
"version": "0.48.0",
|
||||
"version": "0.49.0",
|
||||
"private": true,
|
||||
"packageManager": "bun@1",
|
||||
"dependencies": {
|
||||
|
||||
@@ -307,6 +307,10 @@
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.info-card.compute_unresolved .info-card-content {
|
||||
max-height: min(calc(330px * var(--hud-scale)), calc(100vh - 180px));
|
||||
}
|
||||
|
||||
.info-card-content::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
@@ -529,3 +533,180 @@
|
||||
max-width: none;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.info-card-compute-collect {
|
||||
margin-top: calc(8px * var(--hud-scale));
|
||||
padding-top: calc(8px * var(--hud-scale));
|
||||
border-top: 1px solid rgba(214, 229, 245, 0.06);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.info-card-compute-collect-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: calc(3px * var(--hud-scale)) calc(8px * var(--hud-scale));
|
||||
background: transparent;
|
||||
color: var(--hud-text-soft);
|
||||
border: 1px solid rgba(214, 229, 245, 0.18);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: calc(0.7rem * var(--hud-scale));
|
||||
letter-spacing: 0.04em;
|
||||
transition: color 0.15s ease, border-color 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
|
||||
.info-card-compute-collect-button:hover:not(:disabled) {
|
||||
color: #c9dcff;
|
||||
background: rgba(72, 138, 255, 0.14);
|
||||
border-color: rgba(72, 138, 255, 0.45);
|
||||
}
|
||||
|
||||
.info-card-compute-collect-button:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
.info-card-compute-collect-button .material-symbols-rounded {
|
||||
font-size: calc(13px * var(--hud-scale));
|
||||
}
|
||||
|
||||
.info-card-compute-collect-status {
|
||||
margin-top: calc(8px * var(--hud-scale));
|
||||
color: var(--hud-text-soft);
|
||||
font-size: calc(0.7rem * var(--hud-scale));
|
||||
}
|
||||
|
||||
.info-card-compute-collect-candidates {
|
||||
margin-top: calc(6px * var(--hud-scale));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(6px * var(--hud-scale));
|
||||
}
|
||||
|
||||
.info-card-compute-candidate {
|
||||
background: rgba(214, 229, 245, 0.04);
|
||||
border: 1px solid rgba(214, 229, 245, 0.08);
|
||||
border-radius: 6px;
|
||||
padding: calc(6px * var(--hud-scale)) calc(8px * var(--hud-scale));
|
||||
font-size: calc(0.7rem * var(--hud-scale));
|
||||
}
|
||||
|
||||
.info-card-compute-candidate.is-best {
|
||||
border-color: rgba(72, 138, 255, 0.5);
|
||||
background: rgba(72, 138, 255, 0.1);
|
||||
}
|
||||
|
||||
.info-card-compute-candidate-line {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.info-card-compute-candidate-precision {
|
||||
color: #cfe1ff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.info-card-compute-candidate-preview {
|
||||
background: transparent;
|
||||
color: #c9dcff;
|
||||
border: 1px solid rgba(214, 229, 245, 0.18);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
font-size: calc(0.68rem * var(--hud-scale));
|
||||
}
|
||||
|
||||
.info-card-compute-candidate-preview:hover {
|
||||
background: rgba(72, 138, 255, 0.18);
|
||||
}
|
||||
|
||||
.info-card-unresolved-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: calc(8px * var(--hud-scale));
|
||||
padding: calc(4px * var(--hud-scale)) 0 calc(8px * var(--hud-scale));
|
||||
color: var(--hud-text-soft);
|
||||
font-size: calc(0.72rem * var(--hud-scale));
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.info-card-unresolved-summary > span {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.info-card-unresolved-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(7px * var(--hud-scale));
|
||||
}
|
||||
|
||||
.info-card-unresolved-item {
|
||||
padding: calc(7px * var(--hud-scale)) calc(8px * var(--hud-scale));
|
||||
border: 1px solid rgba(214, 229, 245, 0.08);
|
||||
border-radius: 6px;
|
||||
background: rgba(214, 229, 245, 0.035);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.info-card-unresolved-main {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: calc(8px * var(--hud-scale));
|
||||
}
|
||||
|
||||
.info-card-unresolved-index {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: calc(18px * var(--hud-scale));
|
||||
height: calc(18px * var(--hud-scale));
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 171, 81, 0.14);
|
||||
color: #ffd59b;
|
||||
font-size: calc(0.62rem * var(--hud-scale));
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.info-card-unresolved-copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.info-card-unresolved-name {
|
||||
overflow: hidden;
|
||||
color: var(--hud-title);
|
||||
font-size: calc(0.78rem * var(--hud-scale));
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.info-card-unresolved-meta {
|
||||
overflow: hidden;
|
||||
color: var(--hud-text-soft);
|
||||
font-size: calc(0.64rem * var(--hud-scale));
|
||||
line-height: 1.3;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.info-card-unresolved-adopt {
|
||||
color: #ffe0aa;
|
||||
border-color: rgba(255, 171, 81, 0.32);
|
||||
}
|
||||
|
||||
.info-card-unresolved-adopt:hover {
|
||||
background: rgba(255, 171, 81, 0.16);
|
||||
}
|
||||
|
||||
.info-card-unresolved-empty {
|
||||
padding: calc(10px * var(--hud-scale)) 0;
|
||||
color: var(--hud-text-soft);
|
||||
font-size: calc(0.74rem * var(--hud-scale));
|
||||
}
|
||||
|
||||
@@ -180,6 +180,7 @@
|
||||
}
|
||||
|
||||
.layer-row {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(8px * var(--hud-scale));
|
||||
@@ -324,6 +325,42 @@
|
||||
transform: translateX(calc(14px * var(--hud-scale)));
|
||||
}
|
||||
|
||||
.layer-row-notification-badge {
|
||||
appearance: none;
|
||||
position: absolute;
|
||||
top: calc(4px * var(--hud-scale));
|
||||
left: calc(19px * var(--hud-scale));
|
||||
z-index: 2;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: calc(16px * var(--hud-scale));
|
||||
height: calc(16px * var(--hud-scale));
|
||||
padding: 0 calc(4px * var(--hud-scale));
|
||||
border: 1px solid rgba(255, 226, 186, 0.62);
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(180deg, rgba(255, 171, 81, 0.96), rgba(213, 78, 54, 0.96));
|
||||
box-shadow:
|
||||
0 calc(2px * var(--hud-scale)) calc(6px * var(--hud-scale)) rgba(2, 8, 20, 0.42),
|
||||
0 0 calc(10px * var(--hud-scale)) rgba(255, 123, 67, 0.34);
|
||||
color: #fff8e8;
|
||||
font-size: calc(0.5rem * var(--hud-scale));
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
filter 0.16s ease,
|
||||
transform 0.16s ease,
|
||||
border-color 0.16s ease;
|
||||
}
|
||||
|
||||
.layer-row-notification-badge:hover {
|
||||
filter: brightness(1.08);
|
||||
transform: translateY(calc(-1px * var(--hud-scale)));
|
||||
border-color: rgba(255, 238, 205, 0.78);
|
||||
}
|
||||
|
||||
@keyframes layer-toggle-loading-track {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import * as THREE from "three";
|
||||
|
||||
import { BGP_CONFIG, CONFIG, PATHS } from "./constants.js";
|
||||
import { createInteractableLayer } from "./interactable.js";
|
||||
import {
|
||||
createInteractableLayer,
|
||||
SURFACE_AVOIDANCE_PROFILES,
|
||||
} from "./interactable.js";
|
||||
import { getSurfaceMarkerCameraScale, latLonToVector3 } from "./utils.js";
|
||||
|
||||
const bgpGroup = new THREE.Group();
|
||||
@@ -397,6 +400,7 @@ const bgpCollectorIconLayer = createInteractableLayer({
|
||||
activity,
|
||||
};
|
||||
},
|
||||
avoidance: SURFACE_AVOIDANCE_PROFILES.city,
|
||||
});
|
||||
|
||||
function clamp(value, min, max) {
|
||||
@@ -1480,20 +1484,28 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera, cru
|
||||
scale *= 1 + 0.05 * pulse;
|
||||
}
|
||||
|
||||
// When this collector shares a city-level avoidance bucket with another
|
||||
// interactable layer (e.g. compute centers), the icon is fanned out by
|
||||
// ~1.4u but the decorative halos extend 11–40u and would still cover the
|
||||
// neighbour. Shrink+fade them so the other layer remains visible.
|
||||
const crossLayer = Boolean(marker.userData.icon_avoidance_cross_layer);
|
||||
const haloOpacityMul = crossLayer && !isLocked && !isHovered ? 0.18 : 1;
|
||||
const haloScaleMul = crossLayer && !isLocked && !isHovered ? 0.45 : 1;
|
||||
|
||||
if (marker.userData.heatHalo) {
|
||||
marker.userData.heatHalo.position.copy(marker.position);
|
||||
marker.userData.heatHalo.material.opacity = haloOpacity;
|
||||
marker.userData.heatHalo.material.opacity = haloOpacity * haloOpacityMul;
|
||||
marker.userData.heatHalo.material.color.setHex(marker.userData.baseColor || BGP_CONFIG.collectorColor);
|
||||
marker.userData.heatHalo.scale.setScalar(
|
||||
marker.userData.activity?.haloScale * 0.58 * (1 + pulse * 0.01),
|
||||
marker.userData.activity?.haloScale * 0.58 * (1 + pulse * 0.01) * haloScaleMul,
|
||||
);
|
||||
}
|
||||
if (marker.userData.pulseHalo) {
|
||||
marker.userData.pulseHalo.position.copy(marker.position);
|
||||
marker.userData.pulseHalo.material.opacity = pulseOpacity;
|
||||
marker.userData.pulseHalo.material.opacity = pulseOpacity * haloOpacityMul;
|
||||
marker.userData.pulseHalo.material.color.setHex(marker.userData.baseColor || BGP_CONFIG.collectorColor);
|
||||
marker.userData.pulseHalo.scale.setScalar(
|
||||
marker.userData.activity?.pulseHaloScale * 0.48 * (1 + pulse * 0.02),
|
||||
marker.userData.activity?.pulseHaloScale * 0.48 * (1 + pulse * 0.02) * haloScaleMul,
|
||||
);
|
||||
}
|
||||
if (marker.userData.statusCore) {
|
||||
@@ -1511,10 +1523,10 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera, cru
|
||||
}
|
||||
if (marker.userData.coverageHalo) {
|
||||
marker.userData.coverageHalo.position.copy(marker.position);
|
||||
marker.userData.coverageHalo.material.opacity = coverageOpacity;
|
||||
marker.userData.coverageHalo.material.opacity = coverageOpacity * haloOpacityMul;
|
||||
marker.userData.coverageHalo.scale.set(
|
||||
marker.userData.activity?.coverageHaloScale * 0.82 * (1 + pulse * 0.012),
|
||||
marker.userData.activity?.coverageHaloScale * 0.56 * (1 + pulse * 0.012),
|
||||
marker.userData.activity?.coverageHaloScale * 0.82 * (1 + pulse * 0.012) * haloScaleMul,
|
||||
marker.userData.activity?.coverageHaloScale * 0.56 * (1 + pulse * 0.012) * haloScaleMul,
|
||||
1,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { COMPUTE_CENTER_CONFIG, PATHS } from "./constants.js";
|
||||
import { createInteractableLayer } from "./interactable.js";
|
||||
import {
|
||||
createInteractableLayer,
|
||||
SURFACE_AVOIDANCE_PROFILES,
|
||||
} from "./interactable.js";
|
||||
|
||||
const COMPUTE_CENTER_RENDER_ORDER = 4.5;
|
||||
const COMPUTE_CENTER_POINT_SIZE = 36;
|
||||
@@ -13,6 +16,9 @@ const COMPUTE_CENTER_ICON_SOURCES = {
|
||||
let showComputeCenters = true;
|
||||
let supercomputerCount = 0;
|
||||
let gpuClusterCount = 0;
|
||||
let unresolvedComputeCenters = [];
|
||||
|
||||
const COLLECT_LOCATION_API_BASE = "/api/v1/visualization/compute-centers";
|
||||
|
||||
function buildComputeCenterMarkerData(feature) {
|
||||
const props = feature?.properties || {};
|
||||
@@ -88,6 +94,13 @@ function drawComputeCenterEstimatedBadge(context, isEstimated = false) {
|
||||
}
|
||||
}
|
||||
|
||||
function shouldShowComputeCenterEstimatedBadge(data) {
|
||||
if (!data) return false;
|
||||
if (data.needs_confirmation === true) return true;
|
||||
if (data.location_source === "nominatim_online_geocode") return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function normalizeSiteType(siteType) {
|
||||
return siteType === "supercomputer" ? "supercomputer" : "gpu_cluster";
|
||||
}
|
||||
@@ -133,9 +146,10 @@ const computeCenterIconLayer = createInteractableLayer({
|
||||
);
|
||||
},
|
||||
afterDraw(context, { marker, item }) {
|
||||
const data = marker?.userData || item;
|
||||
drawComputeCenterEstimatedBadge(
|
||||
context,
|
||||
Boolean(marker?.userData?.is_estimated ?? item?.is_estimated),
|
||||
shouldShowComputeCenterEstimatedBadge(data),
|
||||
);
|
||||
},
|
||||
},
|
||||
@@ -147,12 +161,13 @@ const computeCenterIconLayer = createInteractableLayer({
|
||||
getBucketKey: (marker) =>
|
||||
[
|
||||
marker.userData?.site_type || "gpu_cluster",
|
||||
marker.userData?.is_estimated ? "estimated" : "precise",
|
||||
shouldShowComputeCenterEstimatedBadge(marker.userData) ? "estimated" : "verified",
|
||||
].join(":"),
|
||||
getUserData: (item) => ({
|
||||
...item,
|
||||
pulseOffset: Math.random() * Math.PI * 2,
|
||||
}),
|
||||
avoidance: SURFACE_AVOIDANCE_PROFILES.city,
|
||||
});
|
||||
|
||||
export function formatComputeCenterTypeLabel(siteType) {
|
||||
@@ -176,9 +191,33 @@ export function formatComputeCenterUpdatedAt(value) {
|
||||
export function formatComputeCenterLocationPrecision(markerData) {
|
||||
const precision = markerData?.location_precision;
|
||||
if (precision === "precise") return "精确坐标";
|
||||
if (precision === "estimated_site") return "估算位置(站点级)";
|
||||
if (precision === "estimated_country") return "估算位置(国家级)";
|
||||
return "位置未知";
|
||||
if (precision === "site") return "站点级位置";
|
||||
if (precision === "city") return "城市级位置";
|
||||
return "位置未确认";
|
||||
}
|
||||
|
||||
const COMPUTE_CENTER_LOCATION_SOURCE_LABELS = {
|
||||
source_coordinates: "源数据自带坐标",
|
||||
ror_organization_registry: "ROR 组织注册 API",
|
||||
nominatim_online_geocode: "Nominatim 在线搜索",
|
||||
};
|
||||
|
||||
export function formatComputeCenterLocationSource(markerData) {
|
||||
const source = markerData?.location_source;
|
||||
if (!source) return "未知来源";
|
||||
return COMPUTE_CENTER_LOCATION_SOURCE_LABELS[source] || source;
|
||||
}
|
||||
|
||||
export function formatComputeCenterNeedsConfirmation(markerData) {
|
||||
if (markerData?.needs_confirmation === true) return "待人工核验";
|
||||
if (markerData?.is_estimated === true) return "估算位置";
|
||||
return "已确认";
|
||||
}
|
||||
|
||||
export function formatComputeCenterLocationConfidence(markerData) {
|
||||
const confidence = Number(markerData?.location_confidence);
|
||||
if (!Number.isFinite(confidence)) return "-";
|
||||
return `${Math.round(confidence * 100)}%`;
|
||||
}
|
||||
|
||||
export function getComputeCenterLegendItems() {
|
||||
@@ -226,9 +265,92 @@ export function clearComputeCenterSelection() {
|
||||
export function clearComputeCenterData(earth) {
|
||||
supercomputerCount = 0;
|
||||
gpuClusterCount = 0;
|
||||
unresolvedComputeCenters = [];
|
||||
computeCenterIconLayer.clearData(earth);
|
||||
}
|
||||
|
||||
export function getUnresolvedComputeCenters() {
|
||||
return unresolvedComputeCenters.slice();
|
||||
}
|
||||
|
||||
// Generic helper used by every entity type that wires the shared
|
||||
// /collect-location backend pipeline. The endpoint shape (sources, payload
|
||||
// keys) is domain-specific; the request/response envelope is unified
|
||||
// (success / candidates / attempted_queries / failure_reason / context).
|
||||
export async function collectLocationCandidates(endpoint, payload = {}) {
|
||||
if (!endpoint) throw new Error("endpoint is required");
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload || {}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => "");
|
||||
throw new Error(
|
||||
`Collect location failed: HTTP ${response.status} ${text}`.trim(),
|
||||
);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function collectComputeCenterLocation(sourceId, context = {}) {
|
||||
if (!sourceId) {
|
||||
throw new Error("sourceId is required");
|
||||
}
|
||||
const url = `${COLLECT_LOCATION_API_BASE}/${encodeURIComponent(sourceId)}/collect-location`;
|
||||
return collectLocationCandidates(url, {
|
||||
name: context?.name ?? null,
|
||||
operator: context?.operator ?? null,
|
||||
site: context?.site ?? null,
|
||||
organization: context?.organization ?? null,
|
||||
city: context?.city ?? null,
|
||||
country: context?.country ?? null,
|
||||
source: context?.source ?? null,
|
||||
id: context?.record_id ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function saveComputeCenterLocation(sourceId, candidate = {}, context = {}) {
|
||||
if (!sourceId) {
|
||||
throw new Error("sourceId is required");
|
||||
}
|
||||
const latitude = Number(candidate?.latitude);
|
||||
const longitude = Number(candidate?.longitude);
|
||||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
|
||||
throw new Error("candidate latitude/longitude are required");
|
||||
}
|
||||
const url = `${COLLECT_LOCATION_API_BASE}/${encodeURIComponent(sourceId)}/location`;
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
source: context?.source ?? null,
|
||||
name: context?.name ?? candidate?.matched_location_name ?? candidate?.display_name ?? null,
|
||||
operator: context?.operator ?? null,
|
||||
site: context?.site ?? null,
|
||||
city: candidate?.city ?? context?.city ?? null,
|
||||
country: candidate?.country ?? context?.country ?? null,
|
||||
latitude,
|
||||
longitude,
|
||||
precision: candidate?.precision ?? "city",
|
||||
confidence: candidate?.confidence ?? null,
|
||||
location_source: candidate?.source ?? "manual_selection",
|
||||
source_url: candidate?.source_url ?? null,
|
||||
source_note: candidate?.source_note ?? null,
|
||||
raw_payload: candidate || {},
|
||||
needs_confirmation: false,
|
||||
verification_status: "verified",
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => "");
|
||||
throw new Error(
|
||||
`Save compute center location failed: HTTP ${response.status} ${text}`.trim(),
|
||||
);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export function toggleComputeCenters(show) {
|
||||
showComputeCenters = Boolean(show);
|
||||
computeCenterIconLayer.setVisible(showComputeCenters);
|
||||
@@ -245,8 +367,10 @@ export async function loadComputeCenters(_scene, earth) {
|
||||
}
|
||||
const payload = await response.json();
|
||||
const features = Array.isArray(payload?.features) ? payload.features : [];
|
||||
const unresolved = Array.isArray(payload?.unresolved) ? payload.unresolved : [];
|
||||
|
||||
clearComputeCenterData(earth);
|
||||
unresolvedComputeCenters = unresolved;
|
||||
|
||||
const markerData = spreadComputeCenterPositions(
|
||||
features
|
||||
@@ -271,6 +395,8 @@ export async function loadComputeCenters(_scene, earth) {
|
||||
totalCount: getComputeCenterCount(),
|
||||
supercomputerCount,
|
||||
gpuClusterCount,
|
||||
unresolvedCount: unresolvedComputeCenters.length,
|
||||
unresolved: unresolvedComputeCenters.slice(),
|
||||
summary: getComputeCenterStatusSummary(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -160,6 +160,7 @@ export const TERRAIN_CONFIG = {
|
||||
exaggeration: 34,
|
||||
landRevealFadeMeters: 220,
|
||||
maxConcurrentRequests: 10,
|
||||
batchRequestSize: 64,
|
||||
opacity: 0.68,
|
||||
color: 0x8aa884,
|
||||
emissive: 0x030704,
|
||||
@@ -167,6 +168,7 @@ export const TERRAIN_CONFIG = {
|
||||
shininess: 16,
|
||||
urlTemplate:
|
||||
"/api/v1/visualization/terrain/terrarium/{z}/{x}/{y}.png",
|
||||
batchUrl: "/api/v1/visualization/terrain/terrarium/batch",
|
||||
};
|
||||
|
||||
export const COUNTRY_BOUNDARY_CONFIG = {
|
||||
|
||||
50
frontend/public/earth/js/controls.js
vendored
50
frontend/public/earth/js/controls.js
vendored
@@ -132,7 +132,8 @@ let settingsModalTimer = null;
|
||||
let settingsSheetAnimation = null;
|
||||
let terrainToggleToken = 0;
|
||||
let terrainPrefetchStarted = false;
|
||||
let terrainPrefetchScheduled = false;
|
||||
let terrainPrefetchTimer = null;
|
||||
let terrainPrefetchIdleHandle = null;
|
||||
let focusViewAnimationToken = 0;
|
||||
let earthSettingsDefaults = null;
|
||||
let lastZoomStatusUpdateTime = 0;
|
||||
@@ -1852,7 +1853,7 @@ function applyTerrainUiState(button, enabled) {
|
||||
}
|
||||
|
||||
function prewarmTerrainIfNeeded() {
|
||||
if (terrainPrefetchStarted || isTerrainReady()) return;
|
||||
if (!getHighResTextureEnabled() || terrainPrefetchStarted || isTerrainReady()) return;
|
||||
terrainPrefetchStarted = true;
|
||||
ensureTerrainReady().catch((error) => {
|
||||
terrainPrefetchStarted = false;
|
||||
@@ -1860,23 +1861,45 @@ function prewarmTerrainIfNeeded() {
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleTerrainPrefetch() {
|
||||
if (terrainPrefetchScheduled || terrainPrefetchStarted || isTerrainReady()) {
|
||||
function clearScheduledTerrainPrefetch() {
|
||||
if (terrainPrefetchTimer !== null) {
|
||||
window.clearTimeout(terrainPrefetchTimer);
|
||||
terrainPrefetchTimer = null;
|
||||
}
|
||||
if (
|
||||
terrainPrefetchIdleHandle !== null &&
|
||||
typeof window !== "undefined" &&
|
||||
"cancelIdleCallback" in window
|
||||
) {
|
||||
window.cancelIdleCallback(terrainPrefetchIdleHandle);
|
||||
}
|
||||
terrainPrefetchIdleHandle = null;
|
||||
}
|
||||
|
||||
export function scheduleTerrainPrefetch({ delayMs = 4500, idleTimeoutMs = 6000 } = {}) {
|
||||
clearScheduledTerrainPrefetch();
|
||||
if (!getHighResTextureEnabled() || terrainPrefetchStarted || isTerrainReady()) {
|
||||
return;
|
||||
}
|
||||
terrainPrefetchScheduled = true;
|
||||
|
||||
const runPrefetch = () => {
|
||||
terrainPrefetchScheduled = false;
|
||||
terrainPrefetchIdleHandle = null;
|
||||
prewarmTerrainIfNeeded();
|
||||
};
|
||||
|
||||
if (typeof window !== "undefined" && "requestIdleCallback" in window) {
|
||||
window.requestIdleCallback(runPrefetch, { timeout: 2200 });
|
||||
return;
|
||||
}
|
||||
|
||||
window.setTimeout(runPrefetch, 1400);
|
||||
terrainPrefetchTimer = window.setTimeout(() => {
|
||||
terrainPrefetchTimer = null;
|
||||
if (!getHighResTextureEnabled() || terrainPrefetchStarted || isTerrainReady()) {
|
||||
return;
|
||||
}
|
||||
if (typeof window !== "undefined" && "requestIdleCallback" in window) {
|
||||
terrainPrefetchIdleHandle = window.requestIdleCallback(runPrefetch, {
|
||||
timeout: idleTimeoutMs,
|
||||
});
|
||||
return;
|
||||
}
|
||||
runPrefetch();
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
export function applyImmediateView(targetEarthObj, camera, options = {}) {
|
||||
@@ -3154,8 +3177,6 @@ function setupTerrainControls() {
|
||||
prewarmTerrainIfNeeded();
|
||||
});
|
||||
|
||||
scheduleTerrainPrefetch();
|
||||
|
||||
bindListener(reloadBtn, "click", async () => {
|
||||
await reloadData();
|
||||
});
|
||||
@@ -3614,6 +3635,7 @@ function setupToolbarHubCluster() {
|
||||
}
|
||||
|
||||
export function teardownControls() {
|
||||
clearScheduledTerrainPrefetch();
|
||||
resetCleanup();
|
||||
activeCamera = null;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,16 @@ function formatInfoCardValue(field, rawValue) {
|
||||
return value;
|
||||
}
|
||||
|
||||
function escapeInfoCardHtml(value) {
|
||||
return String(value ?? '').replace(/[&<>"']/g, (char) => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
}[char]));
|
||||
}
|
||||
|
||||
function getNewsSummaryText(data) {
|
||||
return (data?.summary || data?.title || '').trim() || '暂无摘要';
|
||||
}
|
||||
@@ -130,6 +140,10 @@ function renderMobileDetailContent(type, config, data) {
|
||||
renderMobileNewsCardContent(content, data);
|
||||
return;
|
||||
}
|
||||
if (config.className === 'compute_unresolved') {
|
||||
renderComputeCenterUnresolvedContent(content, data);
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
for (const field of config.fields) {
|
||||
@@ -182,6 +196,11 @@ function ensureMobileDetailsListener() {
|
||||
}
|
||||
|
||||
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]);
|
||||
@@ -198,7 +217,472 @@ function renderDefaultCardContent(content, config, data) {
|
||||
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
|
||||
? '重新自动采集坐标'
|
||||
: '自动采集坐标候选';
|
||||
return `
|
||||
<div class="info-card-compute-collect" data-collect-entity-id="${context.entityId}" data-collect-entity-type="${context.entityType}">
|
||||
<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></div>
|
||||
<div class="info-card-compute-collect-candidates" data-collect-candidates></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function bindLocationCollectControls(content, context) {
|
||||
const collectRoot = content.querySelector('[data-collect-entity-id]');
|
||||
if (!collectRoot) return;
|
||||
const button = collectRoot.querySelector('[data-collect-action="run"]');
|
||||
const statusEl = collectRoot.querySelector('[data-collect-status]');
|
||||
const candidatesEl = collectRoot.querySelector('[data-collect-candidates]');
|
||||
if (!button) return;
|
||||
button.addEventListener('click', async (event) => {
|
||||
event.stopPropagation();
|
||||
button.disabled = true;
|
||||
statusEl.textContent = '正在采集坐标候选...';
|
||||
candidatesEl.innerHTML = '';
|
||||
try {
|
||||
const result = await context.collect();
|
||||
if (!result?.success) {
|
||||
statusEl.textContent = `未能采集到坐标:${result?.failure_reason || '未知原因'}`;
|
||||
return;
|
||||
}
|
||||
const candidates = Array.isArray(result.candidates) ? result.candidates : [];
|
||||
statusEl.textContent = `共找到 ${candidates.length} 个候选位置`;
|
||||
candidatesEl.innerHTML = candidates
|
||||
.slice(0, 5)
|
||||
.map((candidate, index) => renderCollectCandidateRow(candidate, index === 0))
|
||||
.join('');
|
||||
bindCandidatePreviewButtons(candidatesEl, context);
|
||||
bindCandidateSaveButtons(candidatesEl, context, statusEl);
|
||||
} catch (error) {
|
||||
console.error('collect-location failed', error);
|
||||
statusEl.textContent = `采集失败:${error?.message || error}`;
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}, { once: false });
|
||||
}
|
||||
|
||||
function renderCollectCandidateRow(candidate, isBest) {
|
||||
const precisionLabel = {
|
||||
precise: '精确',
|
||||
site: '站点',
|
||||
city: '城市',
|
||||
}[candidate.precision] || candidate.precision || '未知';
|
||||
const confidence = Number.isFinite(Number(candidate.confidence))
|
||||
? `${Math.round(Number(candidate.confidence) * 100)}%`
|
||||
: '-';
|
||||
const candidateJson = JSON.stringify(candidate).replace(/"/g, '"');
|
||||
return `
|
||||
<div class="info-card-compute-candidate ${isBest ? 'is-best' : ''}">
|
||||
<div class="info-card-compute-candidate-line">
|
||||
<span class="info-card-compute-candidate-name">${candidate.matched_location_name || candidate.display_name || '候选'}</span>
|
||||
<span class="info-card-compute-candidate-precision">${precisionLabel}</span>
|
||||
</div>
|
||||
<div class="info-card-compute-candidate-line">
|
||||
<span class="info-card-compute-candidate-source">${candidate.source}</span>
|
||||
<span class="info-card-compute-candidate-confidence">置信 ${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-lat="${candidate.latitude}" data-lon="${candidate.longitude}"
|
||||
data-candidate-json="${candidateJson}">预览</button>
|
||||
<button type="button" class="info-card-compute-candidate-preview" data-save-candidate
|
||||
data-candidate-json="${candidateJson}">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function getUnresolvedComputeCenterContext(item) {
|
||||
const metadata = item?.metadata && typeof item.metadata === 'object'
|
||||
? item.metadata
|
||||
: {};
|
||||
return {
|
||||
sourceId: item?.source_id || item?.id || '',
|
||||
recordId: item?.id || item?.record_id || '',
|
||||
name: item?.name || item?.title || '未命名算力中心',
|
||||
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 meta = [context.site || context.operator, context.city, context.country]
|
||||
.filter(Boolean)
|
||||
.join(' · ') || '缺少可用地址字段';
|
||||
return `
|
||||
<div class="info-card-unresolved-item" data-unresolved-item>
|
||||
<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></div>
|
||||
<div class="info-card-compute-collect-candidates" data-unresolved-candidates></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) {
|
||||
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 : [],
|
||||
};
|
||||
}
|
||||
|
||||
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 bindCandidatePreviewButtons(container, context) {
|
||||
container.querySelectorAll('[data-preview-candidate]').forEach((el) => {
|
||||
el.addEventListener('click', (clickEvt) => {
|
||||
clickEvt.stopPropagation();
|
||||
const lat = Number(el.dataset.lat);
|
||||
const lon = Number(el.dataset.lon);
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return;
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('earth:preview-location-candidate', {
|
||||
detail: {
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
entityType: context.entityType,
|
||||
entityId: context.entityId,
|
||||
candidate: JSON.parse(el.dataset.candidateJson || '{}'),
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function bindCandidateSaveButtons(container, context, statusEl) {
|
||||
container.querySelectorAll('[data-save-candidate]').forEach((el) => {
|
||||
el.addEventListener('click', async (clickEvt) => {
|
||||
clickEvt.stopPropagation();
|
||||
if (typeof context.save !== 'function') return;
|
||||
const candidate = JSON.parse(el.dataset.candidateJson || '{}');
|
||||
el.disabled = true;
|
||||
if (statusEl) statusEl.textContent = '正在保存所选坐标...';
|
||||
try {
|
||||
await context.save(candidate);
|
||||
if (statusEl) statusEl.textContent = '坐标已保存,正在刷新图层...';
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('earth:compute-center-location-saved', {
|
||||
detail: {
|
||||
entityType: context.entityType,
|
||||
entityId: context.entityId,
|
||||
candidate,
|
||||
},
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('save compute-center location failed', error);
|
||||
if (statusEl) statusEl.textContent = `保存失败:${error?.message || error}`;
|
||||
} finally {
|
||||
el.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function bindComputeCenterUnresolvedControls(content) {
|
||||
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;
|
||||
statusEl.textContent = '正在采集坐标候选...';
|
||||
candidatesEl.innerHTML = '';
|
||||
try {
|
||||
const { mod, result, candidates } = await collectUnresolvedComputeCenterCandidates(context);
|
||||
if (!result?.success) {
|
||||
statusEl.textContent = `未能采集到坐标:${result?.failure_reason || '未知原因'}`;
|
||||
return;
|
||||
}
|
||||
statusEl.textContent = `共找到 ${candidates.length} 个候选位置`;
|
||||
candidatesEl.innerHTML = candidates
|
||||
.slice(0, 5)
|
||||
.map((candidate, index) => renderCollectCandidateRow(candidate, index === 0))
|
||||
.join('');
|
||||
const actionContext = {
|
||||
entityType: 'compute_center',
|
||||
entityId: context.sourceId,
|
||||
save: (candidate) => mod.saveComputeCenterLocation(context.sourceId, candidate, context),
|
||||
};
|
||||
bindCandidatePreviewButtons(candidatesEl, actionContext);
|
||||
bindCandidateSaveButtons(candidatesEl, actionContext, statusEl);
|
||||
} catch (error) {
|
||||
console.error('collect unresolved compute-center location failed', error);
|
||||
statusEl.textContent = `采集失败:${error?.message || error}`;
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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 } = await collectUnresolvedComputeCenterCandidates(context);
|
||||
if (!result?.success) {
|
||||
if (itemStatusEl) {
|
||||
itemStatusEl.textContent = `未找到可采用候选:${result?.failure_reason || '未知原因'}`;
|
||||
}
|
||||
missedCount += 1;
|
||||
continue;
|
||||
}
|
||||
const bestCandidate = getBestLocationCandidate(candidates);
|
||||
if (!bestCandidate) {
|
||||
if (itemStatusEl) {
|
||||
itemStatusEl.textContent = '未找到包含有效经纬度的候选';
|
||||
}
|
||||
missedCount += 1;
|
||||
continue;
|
||||
}
|
||||
await mod.saveComputeCenterLocation(context.sourceId, bestCandidate, 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) {
|
||||
@@ -283,6 +767,7 @@ function getMobilePopupTitle(type, data) {
|
||||
case 'bgp': return data.anomaly_type || 'BGP事件';
|
||||
case 'news': return data.title || '新闻事件';
|
||||
case 'bgp_collector': return data.collector || 'BGP观测站';
|
||||
case 'compute_center_unresolved': return '待定位算力中心';
|
||||
case 'supercomputer': return data.name || '超算';
|
||||
case 'gpu_cluster': return data.name || 'GPU集群';
|
||||
case 'vessel': return data.name || '船只';
|
||||
@@ -298,6 +783,7 @@ function getMobilePopupSubtitle(type, data) {
|
||||
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 船只';
|
||||
@@ -593,6 +1079,12 @@ const CARD_CONFIG = {
|
||||
{ key: 'status', label: '状态' }
|
||||
]
|
||||
},
|
||||
compute_center_unresolved: {
|
||||
icon: '📍',
|
||||
title: '待定位算力中心',
|
||||
className: 'compute_unresolved',
|
||||
fields: []
|
||||
},
|
||||
supercomputer: {
|
||||
icon: '🖥️',
|
||||
title: '超算中心详情',
|
||||
@@ -609,6 +1101,13 @@ const CARD_CONFIG = {
|
||||
{ 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: '更新时间' }
|
||||
]
|
||||
@@ -628,6 +1127,13 @@ const CARD_CONFIG = {
|
||||
{ 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: '更新时间' }
|
||||
]
|
||||
@@ -875,6 +1381,7 @@ 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);
|
||||
@@ -896,6 +1403,7 @@ function hidePanel() {
|
||||
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', '');
|
||||
}
|
||||
@@ -915,6 +1423,10 @@ export function setInfoCardNoBorder(noBorder = true) {
|
||||
}
|
||||
}
|
||||
|
||||
export function isInfoCardSticky() {
|
||||
return getPanel()?.dataset.sticky === 'true';
|
||||
}
|
||||
|
||||
export function showInfoCard(type, data, options = {}) {
|
||||
const config = CARD_CONFIG[type];
|
||||
if (!config) {
|
||||
|
||||
@@ -11,6 +11,26 @@ const DEFAULT_AVOIDANCE_PRECISION = 4;
|
||||
const DEFAULT_AVOIDANCE_RADIUS = 1.1;
|
||||
const DEFAULT_AVOIDANCE_STEP = 0.35;
|
||||
const AVOIDANCE_RING_SLOT_COUNT = 8;
|
||||
|
||||
// Named avoidance profiles. Layers that should mutex with each other (e.g. fan
|
||||
// out when sharing the same city center) must reference the SAME profile —
|
||||
// markers are bucketed by the resulting key, and only equal keys collide.
|
||||
//
|
||||
// city — ~1.1km grid (precision 2). Use for site/observatory/POI markers
|
||||
// that often share a city-center coordinate from geocoding.
|
||||
// precise — ~11m grid (precision 4). Use for markers with building-level
|
||||
// coordinates (default; preserves prior behavior).
|
||||
//
|
||||
// Layers that need a fully custom cluster identity (e.g. a city ID string)
|
||||
// can pass `getKey: (item, position) => "..."` instead of using a profile.
|
||||
export const SURFACE_AVOIDANCE_PROFILES = Object.freeze({
|
||||
city: Object.freeze({ precision: 2, radius: 1.4, step: 0.5 }),
|
||||
precise: Object.freeze({
|
||||
precision: DEFAULT_AVOIDANCE_PRECISION,
|
||||
radius: DEFAULT_AVOIDANCE_RADIUS,
|
||||
step: DEFAULT_AVOIDANCE_STEP,
|
||||
}),
|
||||
});
|
||||
const TANGENT_EPSILON_SQ = 1e-6;
|
||||
const avoidanceNorthPole = new THREE.Vector3(0, 1, 0);
|
||||
const avoidanceFallbackEast = new THREE.Vector3(1, 0, 0);
|
||||
@@ -58,7 +78,16 @@ function createCanvas(width, height) {
|
||||
return canvas;
|
||||
}
|
||||
|
||||
function getAvoidanceKey(position, basePosition, precision = 4) {
|
||||
function getAvoidanceKey(item, position, basePosition, config) {
|
||||
if (typeof config?.getKey === "function") {
|
||||
const key = config.getKey(item, position, basePosition);
|
||||
if (key) return String(key);
|
||||
}
|
||||
|
||||
const precision = Number.isFinite(config?.precision)
|
||||
? config.precision
|
||||
: DEFAULT_AVOIDANCE_PRECISION;
|
||||
|
||||
if (position instanceof THREE.Vector3) {
|
||||
return [
|
||||
"vec",
|
||||
@@ -85,11 +114,14 @@ function recomputeAvoidanceBucket(key) {
|
||||
if (!entries || entries.length === 0) return;
|
||||
|
||||
const affectedLayerIds = new Set(entries.map((entry) => entry.layerId));
|
||||
const crossLayer = affectedLayerIds.size > 1;
|
||||
if (entries.length === 1) {
|
||||
const entry = entries[0];
|
||||
entry.marker.position.copy(entry.marker.userData.icon_base_position);
|
||||
entry.marker.userData.icon_avoidance_index = 0;
|
||||
entry.marker.userData.icon_avoidance_count = 1;
|
||||
entry.marker.userData.icon_avoidance_layer_count = 1;
|
||||
entry.marker.userData.icon_avoidance_cross_layer = false;
|
||||
notifyAvoidancePositionChanged(affectedLayerIds);
|
||||
return;
|
||||
}
|
||||
@@ -127,6 +159,8 @@ function recomputeAvoidanceBucket(key) {
|
||||
entry.marker.position.copy(avoidancePositionScratch);
|
||||
entry.marker.userData.icon_avoidance_index = index;
|
||||
entry.marker.userData.icon_avoidance_count = count;
|
||||
entry.marker.userData.icon_avoidance_layer_count = affectedLayerIds.size;
|
||||
entry.marker.userData.icon_avoidance_cross_layer = crossLayer;
|
||||
});
|
||||
|
||||
notifyAvoidancePositionChanged(affectedLayerIds);
|
||||
@@ -657,9 +691,10 @@ export function createInteractableLayer(options = {}) {
|
||||
if (!position) return;
|
||||
const kind = getKind(item);
|
||||
const avoidanceKey = getAvoidanceKey(
|
||||
item,
|
||||
rawPosition,
|
||||
position,
|
||||
avoidanceConfig.precision,
|
||||
avoidanceConfig,
|
||||
);
|
||||
const marker = new THREE.Object3D();
|
||||
marker.position.copy(position);
|
||||
|
||||
@@ -160,6 +160,9 @@ import {
|
||||
clearComputeCenterSelection,
|
||||
formatComputeCenterCapacity,
|
||||
formatComputeCenterLocationPrecision,
|
||||
formatComputeCenterLocationConfidence,
|
||||
formatComputeCenterLocationSource,
|
||||
formatComputeCenterNeedsConfirmation,
|
||||
formatComputeCenterTypeLabel,
|
||||
formatComputeCenterUpdatedAt,
|
||||
getComputeCenterCount,
|
||||
@@ -167,6 +170,7 @@ import {
|
||||
getComputeCenterMarkers,
|
||||
getComputeCenterPointerIntersections as getComputeCenterIconPointerIntersections,
|
||||
getShowComputeCenters,
|
||||
getUnresolvedComputeCenters,
|
||||
loadComputeCenters,
|
||||
setComputeCenterMarkerState,
|
||||
toggleComputeCenters,
|
||||
@@ -208,6 +212,7 @@ import {
|
||||
setTerrainLayerInteractable,
|
||||
setDayNightInteractable,
|
||||
applyDeferredLayerVisibilitySettings,
|
||||
scheduleTerrainPrefetch,
|
||||
} from "./controls.js";
|
||||
import {
|
||||
createLayerStartupTaskMap,
|
||||
@@ -224,6 +229,7 @@ import {
|
||||
initInfoCard,
|
||||
showInfoCard,
|
||||
hideInfoCard,
|
||||
isInfoCardSticky,
|
||||
} from "./info-card.js";
|
||||
import {
|
||||
initLegend,
|
||||
@@ -239,6 +245,10 @@ import {
|
||||
registerEarthClientErrorHandlers,
|
||||
reportEarthClientLog,
|
||||
} from "./client-logs.js";
|
||||
import {
|
||||
clearMobileCenterCountryHighlight,
|
||||
updateMobileCenterCountryHighlight,
|
||||
} from "./mobile-center-country-highlight.js";
|
||||
|
||||
export let scene;
|
||||
export let camera;
|
||||
@@ -762,22 +772,36 @@ function showComputeCenterInfo(marker, coords) {
|
||||
? "supercomputer"
|
||||
: "gpu_cluster";
|
||||
setLegendMode("computeCenters");
|
||||
const ud = marker.userData || {};
|
||||
showInfoCard(siteType, {
|
||||
name: marker.userData?.name || "-",
|
||||
id: ud.id,
|
||||
source_id: ud.source_id,
|
||||
name: ud.name || "-",
|
||||
site_type_label: formatComputeCenterTypeLabel(siteType),
|
||||
rank: marker.userData?.rank ?? "-",
|
||||
capacity: formatComputeCenterCapacity(marker.userData),
|
||||
vendor: marker.userData?.vendor || "-",
|
||||
operator: marker.userData?.operator || "-",
|
||||
gpu_count: marker.userData?.gpu_count ?? "-",
|
||||
gpu_type: marker.userData?.gpu_type || "-",
|
||||
cores: marker.userData?.cores ?? "-",
|
||||
power: marker.userData?.power ?? "-",
|
||||
country: marker.userData?.country || "-",
|
||||
city: marker.userData?.city || "-",
|
||||
location_precision_label: formatComputeCenterLocationPrecision(marker.userData),
|
||||
source: marker.userData?.source || "-",
|
||||
updated_at: formatComputeCenterUpdatedAt(marker.userData?.updated_at),
|
||||
rank: ud.rank ?? "-",
|
||||
capacity: formatComputeCenterCapacity(ud),
|
||||
vendor: ud.vendor || "-",
|
||||
operator: ud.operator || "-",
|
||||
gpu_count: ud.gpu_count ?? "-",
|
||||
gpu_type: ud.gpu_type || "-",
|
||||
cores: ud.cores ?? "-",
|
||||
power: ud.power ?? "-",
|
||||
country: ud.country || "-",
|
||||
city: ud.city || "-",
|
||||
metadata: ud.metadata || {},
|
||||
location_precision: ud.location_precision,
|
||||
location_precision_label: formatComputeCenterLocationPrecision(ud),
|
||||
location_source: ud.location_source,
|
||||
location_source_label: formatComputeCenterLocationSource(ud),
|
||||
location_status_label: formatComputeCenterNeedsConfirmation(ud),
|
||||
needs_confirmation: ud.needs_confirmation === true,
|
||||
estimated_reason: ud.estimated_reason || "-",
|
||||
location_confidence: formatComputeCenterLocationConfidence(ud),
|
||||
location_source_note: ud.location_source_note || "-",
|
||||
matched_location_name: ud.matched_location_name || "-",
|
||||
location_verified_at: ud.location_verified_at || "-",
|
||||
source: ud.source || "-",
|
||||
updated_at: formatComputeCenterUpdatedAt(ud.updated_at),
|
||||
}, coords);
|
||||
}
|
||||
|
||||
@@ -1193,6 +1217,26 @@ async function focusSearchComputeCenter(marker) {
|
||||
);
|
||||
}
|
||||
|
||||
async function previewLocationCandidate(detail) {
|
||||
const lat = Number(detail?.latitude);
|
||||
const lon = Number(detail?.longitude);
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return;
|
||||
interruptCruisePresentation({ resetLoop: true });
|
||||
setAutoRotate(false);
|
||||
await focusSearchTarget({ lat, lon }, Math.max(getZoomLevel(), 1.16));
|
||||
}
|
||||
|
||||
async function refreshComputeCentersAfterLocationSave() {
|
||||
if (!scene || !earth) return;
|
||||
const result = await loadComputeCenters(scene, earth);
|
||||
toggleComputeCenters(getShowComputeCenters());
|
||||
updateComputeCenterHud(result);
|
||||
setLegendItems("computeCenters", getComputeCenterLegendItems());
|
||||
refreshLegend();
|
||||
updateStatsSummary();
|
||||
showStatusMessage("算力中心坐标已保存", "success");
|
||||
}
|
||||
|
||||
async function focusSearchVessel(marker) {
|
||||
await setVesselsEnabled(true, {
|
||||
suppressStatus: true,
|
||||
@@ -1514,17 +1558,81 @@ async function loadEarthStatsSummary({ shouldApply = () => true } = {}) {
|
||||
|
||||
function updateComputeCenterHud(computeCenterResult) {
|
||||
const computeBtn = document.getElementById("toggle-compute-centers");
|
||||
const unresolvedCount = Number(computeCenterResult?.unresolvedCount) || 0;
|
||||
const unresolvedTooltip =
|
||||
unresolvedCount > 0 ? `(${unresolvedCount} 个待定位)` : "";
|
||||
|
||||
if (computeBtn) {
|
||||
setLayerButtonState(computeBtn, {
|
||||
active: getShowComputeCenters(),
|
||||
loading: false,
|
||||
tooltip: getShowComputeCenters() ? "隐藏算力中心" : "显示算力中心",
|
||||
tooltip: getShowComputeCenters()
|
||||
? `隐藏算力中心${unresolvedTooltip}`
|
||||
: `显示算力中心${unresolvedTooltip}`,
|
||||
});
|
||||
updateComputeCenterUnresolvedBadge(computeBtn, unresolvedCount);
|
||||
}
|
||||
|
||||
setEarthStatValue("compute-center-count", `${computeCenterResult.totalCount} 个`);
|
||||
}
|
||||
|
||||
function updateComputeCenterUnresolvedBadge(computeBtn, unresolvedCount) {
|
||||
const count = Math.max(0, Number(unresolvedCount) || 0);
|
||||
const layerRow = computeBtn.closest(".layer-row");
|
||||
if (!layerRow) return;
|
||||
let badge = layerRow.querySelector("[data-compute-center-unresolved-badge]");
|
||||
|
||||
if (count <= 0) {
|
||||
delete computeBtn.dataset.unresolvedCount;
|
||||
badge?.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(badge instanceof HTMLButtonElement)) {
|
||||
badge?.remove();
|
||||
badge = document.createElement("button");
|
||||
badge.type = "button";
|
||||
badge.className = "layer-row-notification-badge";
|
||||
badge.dataset.computeCenterUnresolvedBadge = "true";
|
||||
layerRow.appendChild(badge);
|
||||
}
|
||||
badge.onclick = (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
showComputeCenterUnresolvedQueue(event.currentTarget);
|
||||
};
|
||||
|
||||
computeBtn.dataset.unresolvedCount = String(count);
|
||||
badge.textContent = count > 99 ? "99+" : String(count);
|
||||
badge.title = `${count} 个算力中心待定位`;
|
||||
badge.setAttribute("aria-label", `${count} 个算力中心待定位,点击查看`);
|
||||
}
|
||||
|
||||
function syncComputeCenterUnresolvedCount(unresolvedCount) {
|
||||
const computeBtn = document.getElementById("toggle-compute-centers");
|
||||
if (!computeBtn) return;
|
||||
updateComputeCenterUnresolvedBadge(computeBtn, unresolvedCount);
|
||||
}
|
||||
|
||||
function showComputeCenterUnresolvedQueue(anchorEl) {
|
||||
const items = getUnresolvedComputeCenters();
|
||||
const panelRect = document.getElementById("layer-toggles")?.getBoundingClientRect();
|
||||
const anchorRect = anchorEl instanceof HTMLElement
|
||||
? anchorEl.getBoundingClientRect()
|
||||
: null;
|
||||
const rect = panelRect || anchorRect;
|
||||
showInfoCard("compute_center_unresolved", {
|
||||
totalCount: items.length,
|
||||
items,
|
||||
}, {
|
||||
x: rect ? rect.right + 10 : 284,
|
||||
y: rect ? rect.top : 120,
|
||||
absolute: true,
|
||||
anchorStable: true,
|
||||
sticky: true,
|
||||
});
|
||||
}
|
||||
|
||||
function updateBGPHud(bgpResult) {
|
||||
const bgpBtn = document.getElementById("toggle-bgp");
|
||||
if (bgpBtn) {
|
||||
@@ -2045,15 +2153,21 @@ function applyCableVisualState() {
|
||||
(lockedObjectType === "satellite" && lockedSatellite) ||
|
||||
(lockedObjectType === "bgp" && lockedObject) ||
|
||||
(isCruiseModeActive() && isCruisePresentationPinned());
|
||||
const isHoveredCable = isSameCable(cable, hoveredCable);
|
||||
|
||||
switch (state) {
|
||||
case CABLE_STATE.LOCKED:
|
||||
cable.material.opacity = isHoveredCable
|
||||
? CABLE_CONFIG.lockedOpacityMax
|
||||
: THREE.MathUtils.lerp(
|
||||
CABLE_CONFIG.lockedOpacityMin,
|
||||
CABLE_CONFIG.lockedOpacityMax,
|
||||
pulse,
|
||||
);
|
||||
cable.material.color.setRGB(0.92, 0.98, 1.0);
|
||||
break;
|
||||
case CABLE_STATE.HOVERED:
|
||||
cable.material.opacity = THREE.MathUtils.lerp(
|
||||
CABLE_CONFIG.lockedOpacityMin,
|
||||
CABLE_CONFIG.lockedOpacityMax,
|
||||
pulse,
|
||||
);
|
||||
cable.material.opacity = CABLE_CONFIG.lockedOpacityMax;
|
||||
cable.material.color.setRGB(0.92, 0.98, 1.0);
|
||||
break;
|
||||
case CABLE_STATE.NORMAL:
|
||||
@@ -2740,9 +2854,15 @@ async function loadData() {
|
||||
queueStatusMessage("数据已加载", "success");
|
||||
}
|
||||
|
||||
applyDeferredLayerVisibilitySettings().catch((error) => {
|
||||
console.warn("恢复 Earth 图层可见性失败:", error);
|
||||
});
|
||||
applyDeferredLayerVisibilitySettings()
|
||||
.catch((error) => {
|
||||
console.warn("恢复 Earth 图层可见性失败:", error);
|
||||
})
|
||||
.finally(() => {
|
||||
if (loadToken === currentLoadToken && !destroyed) {
|
||||
scheduleTerrainPrefetch();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const POSITION_UPDATE_FORCE_DELTA = 250;
|
||||
@@ -3053,6 +3173,20 @@ function setupEventListeners() {
|
||||
const handleRotationMode = (event) => handleRotationModeChange(event);
|
||||
const handleCruiseModules = () => handleCruiseModulesChange();
|
||||
const handleInfoCardDrag = () => repositionCruiseConnector();
|
||||
const handlePreviewLocationCandidate = (event) => {
|
||||
previewLocationCandidate(event?.detail || {}).catch((error) => {
|
||||
console.warn("预览候选位置失败:", error);
|
||||
});
|
||||
};
|
||||
const handleComputeCenterLocationSaved = () => {
|
||||
refreshComputeCentersAfterLocationSave().catch((error) => {
|
||||
console.warn("刷新算力中心图层失败:", error);
|
||||
showStatusMessage("坐标已保存,但刷新算力中心失败", "error");
|
||||
});
|
||||
};
|
||||
const handleComputeCenterUnresolvedCountChange = (event) => {
|
||||
syncComputeCenterUnresolvedCount(event?.detail?.unresolvedCount);
|
||||
};
|
||||
|
||||
bindListener(window, "resize", handleResize);
|
||||
bindListener(document, "visibilitychange", handleVisibilityChange);
|
||||
@@ -3061,6 +3195,21 @@ function setupEventListeners() {
|
||||
bindListener(window, "earth:rotation-mode-change", handleRotationMode);
|
||||
bindListener(window, "earth:cruise-modules-change", handleCruiseModules);
|
||||
bindListener(window, "earth:info-card-drag", handleInfoCardDrag);
|
||||
bindListener(
|
||||
window,
|
||||
"earth:preview-location-candidate",
|
||||
handlePreviewLocationCandidate,
|
||||
);
|
||||
bindListener(
|
||||
window,
|
||||
"earth:compute-center-location-saved",
|
||||
handleComputeCenterLocationSaved,
|
||||
);
|
||||
bindListener(
|
||||
window,
|
||||
"earth:compute-center-unresolved-count-change",
|
||||
handleComputeCenterUnresolvedCountChange,
|
||||
);
|
||||
getCruiseModuleDefinitions().forEach((module) => {
|
||||
if (!module.externalEventName) return;
|
||||
bindListener(window, module.externalEventName, (event) => {
|
||||
@@ -3158,7 +3307,12 @@ function onMouseMove(event) {
|
||||
applyBGPHoverState(lockedObject);
|
||||
} else if (lockedObjectType === "bgp_collector" && lockedObject) {
|
||||
applyBGPHoverState(lockedObject);
|
||||
} else if (!lockedObject && !lockedSatellite && !isCruisePresentationPinned()) {
|
||||
} else if (
|
||||
!lockedObject &&
|
||||
!lockedSatellite &&
|
||||
!isCruisePresentationPinned() &&
|
||||
!isInfoCardSticky()
|
||||
) {
|
||||
hideInfoCard();
|
||||
}
|
||||
hideTooltip();
|
||||
@@ -3329,7 +3483,7 @@ function onMouseMove(event) {
|
||||
applyComputeCenterHoverState(lockedObject);
|
||||
} else if (lockedObjectType === "vessel" && lockedObject) {
|
||||
applyVesselHoverState(lockedObject);
|
||||
} else if (!lockedObjectType && !isCruisePresentationPinned()) {
|
||||
} else if (!lockedObjectType && !isCruisePresentationPinned() && !isInfoCardSticky()) {
|
||||
resetTransientBGPStates();
|
||||
resetTransientComputeCenterStates();
|
||||
if (vesselPick.checked) {
|
||||
@@ -3824,6 +3978,12 @@ function animate() {
|
||||
const currentSunDirection = getSunDirection();
|
||||
setEarthSunDirection(currentSunDirection);
|
||||
setSatelliteSunDirection(currentSunDirection);
|
||||
updateMobileCenterCountryHighlight({
|
||||
camera,
|
||||
earth: getEarthSurfacePickTarget() || earth,
|
||||
renderer,
|
||||
now: performance.now(),
|
||||
});
|
||||
updateNewsViewFocus(getCurrentViewCenterCoords());
|
||||
const satPositions = getSatellitePositions();
|
||||
if (
|
||||
@@ -3863,6 +4023,7 @@ export function destroy() {
|
||||
}
|
||||
|
||||
clearLockedObject();
|
||||
clearMobileCenterCountryHighlight();
|
||||
clearCableData(getEarth());
|
||||
clearBGPData(getEarth());
|
||||
clearComputeCenterData(getEarth());
|
||||
|
||||
128
frontend/public/earth/js/mobile-center-country-highlight.js
Normal file
128
frontend/public/earth/js/mobile-center-country-highlight.js
Normal file
@@ -0,0 +1,128 @@
|
||||
import * as THREE from "three";
|
||||
|
||||
import {
|
||||
clearCountryBoundaryHover,
|
||||
getShowCountryBoundaries,
|
||||
updateCountryBoundaryHover,
|
||||
} from "./country-boundaries.js";
|
||||
import { screenToEarthCoords, vector3ToLatLon } from "./utils.js";
|
||||
|
||||
const UPDATE_INTERVAL_MS = 120;
|
||||
const MIN_COORD_DELTA_DEGREES = 0.05;
|
||||
const BLOCKING_BODY_CLASSES = [
|
||||
"earth-mobile-drawer-open",
|
||||
"earth-search-open",
|
||||
"earth-settings-open",
|
||||
"earth-media-open",
|
||||
"earth-info-open",
|
||||
];
|
||||
|
||||
const centerRaycaster = new THREE.Raycaster();
|
||||
const centerMouse = new THREE.Vector2();
|
||||
|
||||
let ownsHighlight = false;
|
||||
let lastUpdateAt = 0;
|
||||
let lastLat = null;
|
||||
let lastLon = null;
|
||||
|
||||
function isMobileLayout() {
|
||||
return document.body.classList.contains("layout-mode-mobile");
|
||||
}
|
||||
|
||||
function hasBlockingForegroundUi() {
|
||||
return BLOCKING_BODY_CLASSES.some((className) =>
|
||||
document.body.classList.contains(className),
|
||||
);
|
||||
}
|
||||
|
||||
function resetCachedCenter() {
|
||||
lastUpdateAt = 0;
|
||||
lastLat = null;
|
||||
lastLon = null;
|
||||
}
|
||||
|
||||
export function clearMobileCenterCountryHighlight() {
|
||||
if (ownsHighlight) {
|
||||
clearCountryBoundaryHover();
|
||||
}
|
||||
ownsHighlight = false;
|
||||
resetCachedCenter();
|
||||
}
|
||||
|
||||
function clearAnyMobileCountryHighlight() {
|
||||
clearCountryBoundaryHover();
|
||||
ownsHighlight = false;
|
||||
resetCachedCenter();
|
||||
}
|
||||
|
||||
function shouldSkipForSmallMovement(coords) {
|
||||
if (lastLat === null || lastLon === null) return false;
|
||||
return (
|
||||
Math.abs(coords.lat - lastLat) < MIN_COORD_DELTA_DEGREES &&
|
||||
Math.abs(coords.lon - lastLon) < MIN_COORD_DELTA_DEGREES
|
||||
);
|
||||
}
|
||||
|
||||
export function updateMobileCenterCountryHighlight({
|
||||
camera,
|
||||
earth,
|
||||
renderer,
|
||||
now = performance.now(),
|
||||
isBlocked = false,
|
||||
} = {}) {
|
||||
const mobile = isMobileLayout();
|
||||
|
||||
if (!mobile) {
|
||||
clearMobileCenterCountryHighlight();
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
!camera ||
|
||||
!earth ||
|
||||
!renderer?.domElement ||
|
||||
isBlocked ||
|
||||
hasBlockingForegroundUi() ||
|
||||
!getShowCountryBoundaries()
|
||||
) {
|
||||
clearAnyMobileCountryHighlight();
|
||||
return null;
|
||||
}
|
||||
|
||||
if (now - lastUpdateAt < UPDATE_INTERVAL_MS) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rect = renderer.domElement.getBoundingClientRect();
|
||||
if (rect.width <= 0 || rect.height <= 0) {
|
||||
clearMobileCenterCountryHighlight();
|
||||
return null;
|
||||
}
|
||||
|
||||
const earthPoint = screenToEarthCoords(
|
||||
rect.left + rect.width / 2,
|
||||
rect.top + rect.height / 2,
|
||||
camera,
|
||||
earth,
|
||||
renderer.domElement,
|
||||
centerRaycaster,
|
||||
centerMouse,
|
||||
);
|
||||
|
||||
lastUpdateAt = now;
|
||||
|
||||
if (!earthPoint) {
|
||||
clearMobileCenterCountryHighlight();
|
||||
return null;
|
||||
}
|
||||
|
||||
const coords = vector3ToLatLon(earthPoint);
|
||||
if (shouldSkipForSmallMovement(coords)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
lastLat = coords.lat;
|
||||
lastLon = coords.lon;
|
||||
ownsHighlight = true;
|
||||
return updateCountryBoundaryHover(coords);
|
||||
}
|
||||
@@ -33,6 +33,10 @@ function buildTileUrl(z, x, y) {
|
||||
.replace("{y}", String(y));
|
||||
}
|
||||
|
||||
function buildTerrainTileKey(z, x, y) {
|
||||
return `${z}/${x}/${y}`;
|
||||
}
|
||||
|
||||
function getTerrainCanvas(size) {
|
||||
if (typeof OffscreenCanvas !== "undefined") {
|
||||
return new OffscreenCanvas(size, size);
|
||||
@@ -44,8 +48,34 @@ function getTerrainCanvas(size) {
|
||||
return canvas;
|
||||
}
|
||||
|
||||
async function decodeTerrainBlob(blob, cacheKey) {
|
||||
const bitmap = await createImageBitmap(blob);
|
||||
const canvas = getTerrainCanvas(TERRAIN_CONFIG.tileSize);
|
||||
const ctx = canvas.getContext("2d", { willReadFrequently: true });
|
||||
ctx.drawImage(bitmap, 0, 0, TERRAIN_CONFIG.tileSize, TERRAIN_CONFIG.tileSize);
|
||||
bitmap.close?.();
|
||||
const { data, width, height } = ctx.getImageData(
|
||||
0,
|
||||
0,
|
||||
TERRAIN_CONFIG.tileSize,
|
||||
TERRAIN_CONFIG.tileSize,
|
||||
);
|
||||
const tileData = { data, width, height };
|
||||
resolvedTileCache.set(cacheKey, tileData);
|
||||
return tileData;
|
||||
}
|
||||
|
||||
function base64ToBlob(base64, contentType = "image/png") {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return new Blob([bytes], { type: contentType });
|
||||
}
|
||||
|
||||
async function decodeTerrainTile(z, x, y) {
|
||||
const cacheKey = `${z}/${x}/${y}`;
|
||||
const cacheKey = buildTerrainTileKey(z, x, y);
|
||||
if (terrainTileCache.has(cacheKey)) {
|
||||
return terrainTileCache.get(cacheKey);
|
||||
}
|
||||
@@ -56,27 +86,89 @@ async function decodeTerrainTile(z, x, y) {
|
||||
throw new Error(`HTTP ${response.status} for terrain tile ${cacheKey}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const bitmap = await createImageBitmap(blob);
|
||||
const canvas = getTerrainCanvas(TERRAIN_CONFIG.tileSize);
|
||||
const ctx = canvas.getContext("2d", { willReadFrequently: true });
|
||||
ctx.drawImage(bitmap, 0, 0, TERRAIN_CONFIG.tileSize, TERRAIN_CONFIG.tileSize);
|
||||
bitmap.close?.();
|
||||
const { data, width, height } = ctx.getImageData(
|
||||
0,
|
||||
0,
|
||||
TERRAIN_CONFIG.tileSize,
|
||||
TERRAIN_CONFIG.tileSize,
|
||||
);
|
||||
const tileData = { data, width, height };
|
||||
resolvedTileCache.set(cacheKey, tileData);
|
||||
return tileData;
|
||||
return decodeTerrainBlob(await response.blob(), cacheKey);
|
||||
})();
|
||||
|
||||
terrainTileCache.set(cacheKey, tilePromise);
|
||||
return tilePromise;
|
||||
}
|
||||
|
||||
async function fetchTerrainTileBatch(keys) {
|
||||
const uncachedKeys = keys.filter((key) => !terrainTileCache.has(key));
|
||||
if (uncachedKeys.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!TERRAIN_CONFIG.batchUrl || typeof atob !== "function") {
|
||||
uncachedKeys.forEach((key) => {
|
||||
const [z, x, y] = key.split("/").map(Number);
|
||||
terrainTileCache.set(key, decodeTerrainTile(z, x, y));
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const batchPromise = (async () => {
|
||||
const response = await fetch(TERRAIN_CONFIG.batchUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
tiles: uncachedKeys.map((key) => {
|
||||
const [z, x, y] = key.split("/").map(Number);
|
||||
return { z, x, y };
|
||||
}),
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status} for terrain tile batch`);
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
const tiles = Array.isArray(payload?.tiles) ? payload.tiles : [];
|
||||
const returnedKeys = new Set();
|
||||
|
||||
await Promise.all(
|
||||
tiles.map(async (tile) => {
|
||||
const z = Number(tile?.z);
|
||||
const x = Number(tile?.x);
|
||||
const y = Number(tile?.y);
|
||||
const data = typeof tile?.data === "string" ? tile.data : "";
|
||||
if (!Number.isFinite(z) || !Number.isFinite(x) || !Number.isFinite(y) || !data) {
|
||||
return;
|
||||
}
|
||||
const key = buildTerrainTileKey(z, x, y);
|
||||
returnedKeys.add(key);
|
||||
const blob = base64ToBlob(data, tile.content_type || "image/png");
|
||||
terrainTileCache.set(key, decodeTerrainBlob(blob, key));
|
||||
}),
|
||||
);
|
||||
|
||||
uncachedKeys.forEach((key) => {
|
||||
if (returnedKeys.has(key)) return;
|
||||
const [z, x, y] = key.split("/").map(Number);
|
||||
terrainTileCache.delete(key);
|
||||
terrainTileCache.set(key, decodeTerrainTile(z, x, y));
|
||||
});
|
||||
})().catch((error) => {
|
||||
console.warn("批量地形瓦片加载失败,回退到单瓦片请求:", error);
|
||||
uncachedKeys.forEach((key) => {
|
||||
terrainTileCache.delete(key);
|
||||
const [z, x, y] = key.split("/").map(Number);
|
||||
terrainTileCache.set(key, decodeTerrainTile(z, x, y));
|
||||
});
|
||||
});
|
||||
|
||||
uncachedKeys.forEach((key) => {
|
||||
terrainTileCache.set(
|
||||
key,
|
||||
batchPromise.then(() => terrainTileCache.get(key)),
|
||||
);
|
||||
});
|
||||
|
||||
await batchPromise;
|
||||
}
|
||||
|
||||
function decodeTerrariumHeight(tile, pixelX, pixelY) {
|
||||
const safeX = THREE.MathUtils.clamp(pixelX, 0, tile.width - 1);
|
||||
const safeY = THREE.MathUtils.clamp(pixelY, 0, tile.height - 1);
|
||||
@@ -168,19 +260,39 @@ async function runWithConcurrency(items, limit, worker) {
|
||||
|
||||
async function fetchRequiredTiles(samples) {
|
||||
const uniqueKeys = Array.from(
|
||||
new Set(samples.map((sample) => `${TERRAIN_CONFIG.baseZoom}/${sample.tileX}/${sample.tileY}`)),
|
||||
new Set(
|
||||
samples.map((sample) =>
|
||||
buildTerrainTileKey(TERRAIN_CONFIG.baseZoom, sample.tileX, sample.tileY),
|
||||
),
|
||||
),
|
||||
);
|
||||
const resolvedTiles = new Map();
|
||||
const batchSize = Math.max(1, Number(TERRAIN_CONFIG.batchRequestSize) || 1);
|
||||
const batchChunks = [];
|
||||
for (let i = 0; i < uniqueKeys.length; i += batchSize) {
|
||||
batchChunks.push(uniqueKeys.slice(i, i + batchSize));
|
||||
}
|
||||
|
||||
await runWithConcurrency(
|
||||
uniqueKeys,
|
||||
batchChunks,
|
||||
TERRAIN_CONFIG.maxConcurrentRequests,
|
||||
async (key) => {
|
||||
const [z, x, y] = key.split("/").map(Number);
|
||||
resolvedTiles.set(key, await decodeTerrainTile(z, x, y));
|
||||
async (keys) => {
|
||||
await fetchTerrainTileBatch(keys);
|
||||
},
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
uniqueKeys.map(async (key) => {
|
||||
const tilePromise = terrainTileCache.get(key);
|
||||
if (!tilePromise) {
|
||||
const [z, x, y] = key.split("/").map(Number);
|
||||
resolvedTiles.set(key, await decodeTerrainTile(z, x, y));
|
||||
return;
|
||||
}
|
||||
resolvedTiles.set(key, await tilePromise);
|
||||
}),
|
||||
);
|
||||
|
||||
return resolvedTiles;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import axios from 'axios'
|
||||
|
||||
import MarkdownRenderer from '../../components/MarkdownRenderer/MarkdownRenderer'
|
||||
import Scrollbar from '../../components/Scrollbar/Scrollbar'
|
||||
import SegmentedControl from '../../components/SegmentedControl/SegmentedControl'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import {
|
||||
createHeadingIdResolver,
|
||||
defaultDocsSlug,
|
||||
@@ -15,12 +17,23 @@ import {
|
||||
groupDocsEntries,
|
||||
slugFromDocsHref,
|
||||
} from './docs-content'
|
||||
import type { DocsHeading, DocsLang } from './docs-content'
|
||||
import type { DocsCatalogItem, DocsEntry, DocsHeading, DocsLang } from './docs-content'
|
||||
import { buildDocsSearchRecords, searchDocs } from './docs-search'
|
||||
import type { DocsSearchRecord } from './docs-search'
|
||||
import './Docs.css'
|
||||
|
||||
type DocsThemeMode = 'system' | 'light' | 'dark'
|
||||
type DocsErrorState = 'none' | 'unauthenticated' | 'forbidden' | 'not_found' | 'load_failed'
|
||||
|
||||
interface DocsCatalogResponse {
|
||||
items: DocsCatalogItem[]
|
||||
authenticated: boolean
|
||||
}
|
||||
|
||||
interface DocsContentResponse extends DocsEntry {
|
||||
lang: DocsLang
|
||||
markdown: string
|
||||
}
|
||||
|
||||
const MIN_TOC_HEADING_LEVEL = 2
|
||||
const MAX_TOC_HEADING_LEVEL = 3
|
||||
@@ -56,11 +69,16 @@ function getSystemTheme(): 'light' | 'dark' {
|
||||
export default function Docs() {
|
||||
const { slug } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const { token } = useAuthStore()
|
||||
|
||||
const [lang, setLang] = useState<DocsLang>(readStoredLang)
|
||||
const [themeMode, setThemeMode] = useState<DocsThemeMode>(readStoredThemeMode)
|
||||
const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>(getSystemTheme)
|
||||
const [catalogItems, setCatalogItems] = useState<DocsCatalogItem[]>([])
|
||||
const [isCatalogLoading, setIsCatalogLoading] = useState(true)
|
||||
const [markdown, setMarkdown] = useState('')
|
||||
const [activeContentEntry, setActiveContentEntry] = useState<DocsEntry | null>(null)
|
||||
const [docError, setDocError] = useState<DocsErrorState>('none')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false)
|
||||
@@ -69,9 +87,10 @@ export default function Docs() {
|
||||
const articleRef = useRef<HTMLDivElement>(null)
|
||||
const searchRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const docsEntries = useMemo(() => getDocsEntries(lang), [lang])
|
||||
const docsEntries = useMemo(() => getDocsEntries(lang, catalogItems), [catalogItems, lang])
|
||||
const activeSlug = slug || defaultDocsSlug
|
||||
const activeEntry = useMemo(() => getDocsEntry(activeSlug, lang), [activeSlug, lang])
|
||||
const activeEntry = useMemo(() => getDocsEntry(activeSlug, docsEntries), [activeSlug, docsEntries])
|
||||
const activeHeaderEntry = activeEntry || activeContentEntry
|
||||
const groupedEntries = useMemo(() => groupDocsEntries(docsEntries), [docsEntries])
|
||||
const effectiveTheme = themeMode === 'system' ? systemTheme : themeMode
|
||||
const langOptions = useMemo(() => [
|
||||
@@ -140,20 +159,54 @@ export default function Docs() {
|
||||
|
||||
useEffect(() => {
|
||||
let isCancelled = false
|
||||
if (!activeEntry) {
|
||||
setMarkdown('')
|
||||
return
|
||||
}
|
||||
setIsCatalogLoading(true)
|
||||
axios.get<DocsCatalogResponse>('/api/v1/docs/catalog')
|
||||
.then((response) => {
|
||||
if (!isCancelled) setCatalogItems(response.data.items || [])
|
||||
})
|
||||
.catch(() => {
|
||||
if (!isCancelled) setCatalogItems([])
|
||||
})
|
||||
.finally(() => {
|
||||
if (!isCancelled) setIsCatalogLoading(false)
|
||||
})
|
||||
return () => { isCancelled = true }
|
||||
}, [token])
|
||||
|
||||
useEffect(() => {
|
||||
let isCancelled = false
|
||||
if (isCatalogLoading) return
|
||||
setIsLoading(true)
|
||||
activeEntry.loader()
|
||||
.then((content) => {
|
||||
if (!isCancelled) setMarkdown(content)
|
||||
setDocError('none')
|
||||
setMarkdown('')
|
||||
setActiveContentEntry(activeEntry || null)
|
||||
axios.get<DocsContentResponse>(`/api/v1/docs/${lang}/${activeSlug}`)
|
||||
.then((response) => {
|
||||
if (isCancelled) return
|
||||
const content = response.data
|
||||
setMarkdown(content.markdown)
|
||||
setActiveContentEntry({
|
||||
slug: content.slug,
|
||||
filename: content.filename,
|
||||
title: content.title,
|
||||
group: content.group,
|
||||
order: content.order,
|
||||
access: content.access,
|
||||
})
|
||||
})
|
||||
.catch((error) => {
|
||||
if (isCancelled) return
|
||||
const status = (error as { response?: { status?: number } })?.response?.status
|
||||
if (status === 401) setDocError('unauthenticated')
|
||||
else if (status === 403) setDocError('forbidden')
|
||||
else if (status === 404) setDocError('not_found')
|
||||
else setDocError('load_failed')
|
||||
})
|
||||
.finally(() => {
|
||||
if (!isCancelled) setIsLoading(false)
|
||||
})
|
||||
return () => { isCancelled = true }
|
||||
}, [activeEntry])
|
||||
}, [activeEntry, activeSlug, isCatalogLoading, lang])
|
||||
|
||||
useEffect(() => {
|
||||
if (!markdown || !window.location.hash) return
|
||||
@@ -164,11 +217,17 @@ export default function Docs() {
|
||||
|
||||
useEffect(() => {
|
||||
let isCancelled = false
|
||||
buildDocsSearchRecords(docsEntries).then((records) => {
|
||||
const loadMarkdown = async (entry: DocsEntry) => {
|
||||
const response = await axios.get<DocsContentResponse>(`/api/v1/docs/${lang}/${entry.slug}`)
|
||||
return response.data.markdown
|
||||
}
|
||||
buildDocsSearchRecords(docsEntries, loadMarkdown).then((records) => {
|
||||
if (!isCancelled) setSearchRecords(records)
|
||||
}).catch(() => {
|
||||
if (!isCancelled) setSearchRecords([])
|
||||
})
|
||||
return () => { isCancelled = true }
|
||||
}, [docsEntries])
|
||||
}, [docsEntries, lang])
|
||||
|
||||
useEffect(() => {
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
@@ -243,9 +302,9 @@ export default function Docs() {
|
||||
|
||||
// Sidebar H2 sub-items for active Manual doc
|
||||
const sidebarSubHeadings = useMemo(() => {
|
||||
if (activeEntry?.group !== 'Manual' || headings.length === 0) return []
|
||||
if (activeHeaderEntry?.group !== 'Manual' || headings.length === 0) return []
|
||||
return headings.filter((h) => h.level === 2)
|
||||
}, [activeEntry, headings])
|
||||
}, [activeHeaderEntry, headings])
|
||||
|
||||
return (
|
||||
<main className="docs-page" data-theme={effectiveTheme}>
|
||||
@@ -326,9 +385,11 @@ export default function Docs() {
|
||||
<header className="docs-header">
|
||||
<div>
|
||||
<p className="docs-header__eyebrow">
|
||||
{activeEntry ? getDocsGroupLabel(activeEntry.group, lang) : 'Docs'}
|
||||
{activeHeaderEntry ? getDocsGroupLabel(activeHeaderEntry.group, lang) : 'Docs'}
|
||||
</p>
|
||||
<h1 className="docs-header__title">{activeEntry?.title || 'Document not found'}</h1>
|
||||
<h1 className="docs-header__title">
|
||||
{activeHeaderEntry?.title || (lang === 'zh' ? '文档不可用' : 'Document unavailable')}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="docs-search" ref={searchRef}>
|
||||
@@ -382,30 +443,50 @@ export default function Docs() {
|
||||
|
||||
<div className="docs-content-layout">
|
||||
<Scrollbar className="docs-article" viewportRef={articleRef}>
|
||||
{activeEntry ? (
|
||||
isLoading ? (
|
||||
{isCatalogLoading || isLoading ? (
|
||||
<div className="docs-state">
|
||||
{lang === 'zh' ? '加载中...' : 'Loading document...'}
|
||||
</div>
|
||||
) : (
|
||||
) : docError === 'none' ? (
|
||||
<MarkdownRenderer
|
||||
markdown={markdown}
|
||||
className="docs-markdown"
|
||||
getHeadingId={makeHeadingIdResolver}
|
||||
transformLink={transformLink}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="docs-not-found">
|
||||
<h2>{lang === 'zh' ? '文档未找到' : 'Document not found'}</h2>
|
||||
<p>
|
||||
{lang === 'zh'
|
||||
? '请求的文档不在公开文档集中。'
|
||||
: 'The requested guide is not part of the public technical documentation set.'}
|
||||
</p>
|
||||
<Link to="/docs">
|
||||
{lang === 'zh' ? '返回文档首页' : 'Return to docs overview'}
|
||||
</Link>
|
||||
{docError === 'unauthenticated' ? (
|
||||
<>
|
||||
<h2>{lang === 'zh' ? '需要登录' : 'Login required'}</h2>
|
||||
<p>
|
||||
{lang === 'zh'
|
||||
? '这份文档需要登录并具备对应 Gatekeeper 权限组后才能阅读。'
|
||||
: 'This document requires login and the matching Gatekeeper permission group.'}
|
||||
</p>
|
||||
<Link to="/admin">{lang === 'zh' ? '前往登录' : 'Go to login'}</Link>
|
||||
</>
|
||||
) : docError === 'forbidden' ? (
|
||||
<>
|
||||
<h2>{lang === 'zh' ? '无权访问' : 'Permission required'}</h2>
|
||||
<p>
|
||||
{lang === 'zh'
|
||||
? '当前账号没有阅读这份文档所需的 Gatekeeper 权限组。'
|
||||
: 'Your account does not have the Gatekeeper permission group required for this document.'}
|
||||
</p>
|
||||
<Link to="/docs">{lang === 'zh' ? '返回文档首页' : 'Return to docs overview'}</Link>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h2>{lang === 'zh' ? '文档未找到' : 'Document not found'}</h2>
|
||||
<p>
|
||||
{lang === 'zh'
|
||||
? '请求的文档不存在,或当前语言没有对应内容。'
|
||||
: 'The requested guide does not exist or is not available in the current language.'}
|
||||
</p>
|
||||
<Link to="/docs">{lang === 'zh' ? '返回文档首页' : 'Return to docs overview'}</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Scrollbar>
|
||||
|
||||
@@ -7,7 +7,19 @@ export interface DocsEntry {
|
||||
title: string
|
||||
group: DocsGroup
|
||||
order: number
|
||||
loader: () => Promise<string>
|
||||
access: DocsAccess
|
||||
}
|
||||
|
||||
export type DocsAccess = 'public' | 'docs_user' | 'docs_developer' | 'docs_admin'
|
||||
|
||||
export interface DocsCatalogItem {
|
||||
slug: string
|
||||
filename: string
|
||||
lang: DocsLang
|
||||
title: string
|
||||
group: DocsGroup
|
||||
order: number
|
||||
access: DocsAccess
|
||||
}
|
||||
|
||||
export interface DocsHeading {
|
||||
@@ -16,7 +28,7 @@ export interface DocsHeading {
|
||||
text: string
|
||||
}
|
||||
|
||||
interface DocsMetadataEntry {
|
||||
export interface DocsMetadataEntry {
|
||||
zh: { title: string; group: DocsGroup; order: number }
|
||||
en: { title: string; group: DocsGroup; order: number }
|
||||
}
|
||||
@@ -48,17 +60,7 @@ const DOCS_README_FILENAME = 'README.md'
|
||||
const MAX_HEADING_ID_LENGTH = 80
|
||||
export const defaultDocsSlug = 'overview'
|
||||
|
||||
const zhModules = import.meta.glob('../../../../docs/technical/zh/*.md', {
|
||||
query: '?raw',
|
||||
import: 'default',
|
||||
})
|
||||
|
||||
const enModules = import.meta.glob('../../../../docs/technical/en/*.md', {
|
||||
query: '?raw',
|
||||
import: 'default',
|
||||
})
|
||||
|
||||
const DOCS_METADATA: Record<string, DocsMetadataEntry> = {
|
||||
export const DOCS_METADATA: Record<string, DocsMetadataEntry> = {
|
||||
[DOCS_README_FILENAME]: {
|
||||
zh: { title: '技术文档', group: 'Overview', order: 0 },
|
||||
en: { title: 'Technical Docs', group: 'Overview', order: 0 },
|
||||
@@ -71,6 +73,10 @@ const DOCS_METADATA: Record<string, DocsMetadataEntry> = {
|
||||
zh: { title: 'Planet 使用手册', group: 'Manual', order: 2 },
|
||||
en: { title: 'Planet Manual', group: 'Manual', order: 2 },
|
||||
},
|
||||
'location-pipeline-user.md': {
|
||||
zh: { title: 'Earth 位置候选采集使用手册', group: 'Manual', order: 3 },
|
||||
en: { title: 'Earth Location Candidate Collection User Guide', group: 'Manual', order: 3 },
|
||||
},
|
||||
'earth-frontend-context.md': {
|
||||
zh: { title: 'Earth 前端结构', group: 'Earth', order: 10 },
|
||||
en: { title: 'Earth Frontend Context', group: 'Earth', order: 10 },
|
||||
@@ -99,6 +105,10 @@ const DOCS_METADATA: Record<string, DocsMetadataEntry> = {
|
||||
zh: { title: 'Earth 可交互图标接入', group: 'Earth', order: 16 },
|
||||
en: { title: 'Earth Interactable Usage', group: 'Earth', order: 16 },
|
||||
},
|
||||
'earth-toolbar-overlay-coordination.md': {
|
||||
zh: { title: 'Earth 工具栏与浮层协同', group: 'Earth', order: 17 },
|
||||
en: { title: 'Earth Toolbar and Overlay Coordination', group: 'Earth', order: 17 },
|
||||
},
|
||||
'frontend-admin-frontend-context.md': {
|
||||
zh: { title: '控制台前端结构', group: 'Frontend', order: 20 },
|
||||
en: { title: 'Admin Frontend Context', group: 'Frontend', order: 20 },
|
||||
@@ -107,6 +117,10 @@ const DOCS_METADATA: Record<string, DocsMetadataEntry> = {
|
||||
zh: { title: '前端布局指南', group: 'Frontend', order: 21 },
|
||||
en: { title: 'Frontend Layout Guidelines', group: 'Frontend', order: 21 },
|
||||
},
|
||||
'docs-gatekeeper-development.md': {
|
||||
zh: { title: 'Docs Gatekeeper 开发说明', group: 'Frontend', order: 22 },
|
||||
en: { title: 'Docs Gatekeeper Development Guide', group: 'Frontend', order: 22 },
|
||||
},
|
||||
'backend-collectors.md': {
|
||||
zh: { title: '数据采集系统', group: 'Backend', order: 30 },
|
||||
en: { title: 'Data Collectors', group: 'Backend', order: 30 },
|
||||
@@ -119,6 +133,14 @@ const DOCS_METADATA: Record<string, DocsMetadataEntry> = {
|
||||
zh: { title: '数据源、采集器设置与连接验证', group: 'Backend', order: 32 },
|
||||
en: { title: 'Datasource Collector Settings and Connectivity', group: 'Backend', order: 32 },
|
||||
},
|
||||
'backend-datasources-api-performance.md': {
|
||||
zh: { title: '数据源 API 性能', group: 'Backend', order: 33 },
|
||||
en: { title: 'Datasource API Performance', group: 'Backend', order: 33 },
|
||||
},
|
||||
'location-pipeline-development.md': {
|
||||
zh: { title: '通用位置估算管线开发说明', group: 'Backend', order: 34 },
|
||||
en: { title: 'Shared Location Resolution Pipeline Development Guide', group: 'Backend', order: 34 },
|
||||
},
|
||||
'agents-aiprovider.md': {
|
||||
zh: { title: 'AI Provider 指南', group: 'Agents', order: 40 },
|
||||
en: { title: 'AI Provider Guide', group: 'Agents', order: 40 },
|
||||
@@ -127,47 +149,35 @@ const DOCS_METADATA: Record<string, DocsMetadataEntry> = {
|
||||
zh: { title: 'Docker + Compose + Buildx 升级', group: 'Ops', order: 50 },
|
||||
en: { title: 'Docker + Compose + Buildx Upgrade', group: 'Ops', order: 50 },
|
||||
},
|
||||
'ops-planet-sh-startup.md': {
|
||||
zh: { title: 'planet.sh 启动机制', group: 'Ops', order: 51 },
|
||||
en: { title: 'planet.sh Startup', group: 'Ops', order: 51 },
|
||||
},
|
||||
}
|
||||
|
||||
const GROUP_ORDER: DocsGroup[] = ['Overview', 'Manual', 'Earth', 'Frontend', 'Backend', 'Agents', 'Ops', 'Other']
|
||||
|
||||
const ALL_KNOWN_SLUGS = new Set(
|
||||
Object.keys(DOCS_METADATA).map((filename) =>
|
||||
filename === DOCS_README_FILENAME ? defaultDocsSlug : filename.replace(/\.md$/, '')
|
||||
)
|
||||
)
|
||||
|
||||
function filenameFromPath(path: string): string {
|
||||
return path.split('/').pop() || path
|
||||
}
|
||||
|
||||
export function slugFromFilename(filename: string): string {
|
||||
return filename === DOCS_README_FILENAME ? defaultDocsSlug : filename.replace(/\.md$/, '')
|
||||
}
|
||||
|
||||
export function getDocsEntries(lang: DocsLang): DocsEntry[] {
|
||||
const modules = lang === 'zh' ? zhModules : enModules
|
||||
return Object.entries(modules)
|
||||
.filter(([path]) => DOCS_METADATA[filenameFromPath(path)])
|
||||
.map(([path, loader]) => {
|
||||
const filename = filenameFromPath(path)
|
||||
const meta = DOCS_METADATA[filename]
|
||||
const langMeta = meta[lang]
|
||||
return {
|
||||
slug: slugFromFilename(filename),
|
||||
filename,
|
||||
title: langMeta.title,
|
||||
group: langMeta.group,
|
||||
order: langMeta.order,
|
||||
loader: loader as () => Promise<string>,
|
||||
}
|
||||
})
|
||||
export function getDocsEntries(lang: DocsLang, catalogItems: DocsCatalogItem[]): DocsEntry[] {
|
||||
return catalogItems
|
||||
.filter((item) => item.lang === lang)
|
||||
.map((item) => ({
|
||||
slug: item.slug,
|
||||
filename: item.filename,
|
||||
title: item.title,
|
||||
group: item.group,
|
||||
order: item.order,
|
||||
access: item.access,
|
||||
}))
|
||||
.sort((a, b) => a.order - b.order || a.title.localeCompare(b.title))
|
||||
}
|
||||
|
||||
export function getDocsEntry(slug: string | undefined, lang: DocsLang): DocsEntry | undefined {
|
||||
export function getDocsEntry(slug: string | undefined, entries: DocsEntry[]): DocsEntry | undefined {
|
||||
const normalizedSlug = slug || defaultDocsSlug
|
||||
return getDocsEntries(lang).find((entry) => entry.slug === normalizedSlug)
|
||||
return entries.find((entry) => entry.slug === normalizedSlug)
|
||||
}
|
||||
|
||||
export function groupDocsEntries(entries: DocsEntry[]): Array<{ group: DocsGroup; entries: DocsEntry[] }> {
|
||||
@@ -236,6 +246,5 @@ export function slugFromDocsHref(href: string): string | null {
|
||||
return null
|
||||
}
|
||||
|
||||
const slug = slugFromFilename(filename)
|
||||
return ALL_KNOWN_SLUGS.has(slug) ? slug : null
|
||||
return slugFromFilename(filename)
|
||||
}
|
||||
|
||||
@@ -52,10 +52,13 @@ function createExcerpt(text: string, query: string): string {
|
||||
return `${prefix}${text.slice(start, end)}${suffix}`
|
||||
}
|
||||
|
||||
export async function buildDocsSearchRecords(entries: DocsEntry[]): Promise<DocsSearchRecord[]> {
|
||||
export async function buildDocsSearchRecords(
|
||||
entries: DocsEntry[],
|
||||
loadMarkdown: (entry: DocsEntry) => Promise<string>,
|
||||
): Promise<DocsSearchRecord[]> {
|
||||
const records = await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
const markdown = await entry.loader()
|
||||
const markdown = await loadMarkdown(entry)
|
||||
return {
|
||||
entry,
|
||||
markdown,
|
||||
|
||||
@@ -6,17 +6,20 @@ import { TableActions, actionCellProps } from '../../components/TableActions/Tab
|
||||
import axios from 'axios'
|
||||
import AppLayout from '../../components/AppLayout/AppLayout'
|
||||
import TableScrollRegion from '../../components/Scrollbar/TableScrollRegion'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
|
||||
interface User {
|
||||
id: number
|
||||
username: string
|
||||
email: string
|
||||
role: string
|
||||
gatekeeper_groups: string[]
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
function Users() {
|
||||
const { user: currentUser } = useAuthStore()
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [modalVisible, setModalVisible] = useState(false)
|
||||
@@ -64,6 +67,9 @@ function Users() {
|
||||
|
||||
const handleSubmit = async (values: Record<string, unknown>) => {
|
||||
try {
|
||||
if (currentUser?.role !== 'super_admin') {
|
||||
delete values.gatekeeper_groups
|
||||
}
|
||||
if (editingUser) {
|
||||
await axios.put(`/api/v1/users/${editingUser.id}`, values)
|
||||
message.success('更新成功')
|
||||
@@ -98,6 +104,21 @@ function Users() {
|
||||
return <Tag color={colors[role] || 'default'}>{role}</Tag>
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Gatekeeper',
|
||||
dataIndex: 'gatekeeper_groups',
|
||||
key: 'gatekeeper_groups',
|
||||
width: 260,
|
||||
render: (groups: string[] = []) => (
|
||||
<>
|
||||
{groups.length > 0 ? groups.map((group) => (
|
||||
<Tag key={group} color={group === 'docs_admin' ? 'red' : group === 'docs_developer' ? 'blue' : 'green'}>
|
||||
{group}
|
||||
</Tag>
|
||||
)) : <Tag>未配置</Tag>}
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'is_active',
|
||||
@@ -177,6 +198,18 @@ function Users() {
|
||||
<Select.Option value="viewer">只读用户</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item name="gatekeeper_groups" label="Gatekeeper 权限组">
|
||||
<Select
|
||||
mode="multiple"
|
||||
disabled={currentUser?.role !== 'super_admin'}
|
||||
placeholder="选择 Docs 鉴权权限组"
|
||||
options={[
|
||||
{ value: 'docs_user', label: 'Docs 用户文档' },
|
||||
{ value: 'docs_developer', label: 'Docs 开发文档' },
|
||||
{ value: 'docs_admin', label: 'Docs 管理/运维文档' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" block>提交</Button>
|
||||
</Form.Item>
|
||||
|
||||
@@ -6,6 +6,7 @@ interface User {
|
||||
id: number
|
||||
username: string
|
||||
role: string
|
||||
gatekeeper_groups?: string[]
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
|
||||
Reference in New Issue
Block a user