Files
planet/frontend/public/earth/js/compute-centers.js
linkong f3f1ceb833
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / delivery (push) Has been cancelled
release / images (push) Has been cancelled
release: bump version to 0.68.0
2026-05-28 17:10:05 +08:00

693 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import * as THREE from "three";
import { COMPUTE_CENTER_CONFIG, CONFIG, PATHS } from "./constants.js";
import {
createInteractableLayer,
SURFACE_AVOIDANCE_PROFILES,
} from "./interactable.js";
import { latLonToVector3 } from "./utils.js";
const COMPUTE_CENTER_RENDER_ORDER = 4.5;
const COMPUTE_CENTER_POINT_SIZE = 36;
const COMPUTE_CENTER_ICON_FIT_SIZE = 60;
const COMPUTE_CENTER_ATLAS_CELL_SIZE = 128;
const COMPUTE_CENTER_ICON_SOURCES = {
supercomputer: new URL("../assets/icons/compute-supercomputer.svg", import.meta.url).href,
gpu_cluster: new URL("../assets/icons/compute-gpu-cluster.svg", import.meta.url).href,
infrastructure: new URL("../assets/icons/compute-hdd-network.svg", import.meta.url).href,
};
let showComputeCenters = true;
let supercomputerCount = 0;
let gpuClusterCount = 0;
let unresolvedComputeCenters = [];
let previewRingTexture = null;
let previewRingGroup = null;
let previewRingA = null;
let previewRingB = null;
let previewRingPulseOffset = 0;
const COLLECT_LOCATION_API_BASE = "/api/v1/visualization/compute-centers";
const LOCATION_CAPABILITY_API = "/api/v1/visualization/compute-centers/location-capability";
let computeCenterLocationCapabilityPromise = null;
function getPreviewRingTexture() {
if (previewRingTexture) return previewRingTexture;
const canvas = document.createElement("canvas");
canvas.width = 128;
canvas.height = 128;
const context = canvas.getContext("2d");
if (!context) {
previewRingTexture = new THREE.Texture(canvas);
return previewRingTexture;
}
context.clearRect(0, 0, 128, 128);
context.strokeStyle = "rgba(255,255,255,0.96)";
context.lineWidth = 5;
context.beginPath();
context.arc(64, 64, 43, 0, Math.PI * 2);
context.stroke();
previewRingTexture = new THREE.CanvasTexture(canvas);
return previewRingTexture;
}
function ensurePreviewRingGroup(earth) {
if (!earth) return null;
if (!previewRingGroup) {
previewRingGroup = new THREE.Group();
previewRingGroup.name = "compute-center-location-preview";
}
if (previewRingGroup.parent !== earth) {
previewRingGroup.parent?.remove?.(previewRingGroup);
earth.add(previewRingGroup);
}
return previewRingGroup;
}
function createPreviewRingSprite({ color = 0x2dd4bf } = {}) {
const sprite = new THREE.Sprite(
new THREE.SpriteMaterial({
map: getPreviewRingTexture(),
color,
transparent: true,
opacity: 0,
depthWrite: false,
depthTest: true,
blending: THREE.AdditiveBlending,
}),
);
sprite.renderOrder = COMPUTE_CENTER_RENDER_ORDER + 0.18;
return sprite;
}
function disposePreviewRingSprite(sprite) {
if (!sprite) return;
sprite.parent?.remove?.(sprite);
sprite.material?.dispose?.();
}
function attachPreviewRings(group, position, color) {
disposePreviewRingSprite(previewRingA);
disposePreviewRingSprite(previewRingB);
previewRingA = createPreviewRingSprite({ color });
previewRingB = createPreviewRingSprite({ color });
previewRingA.position.copy(position);
previewRingB.position.copy(position);
group.add(previewRingA);
group.add(previewRingB);
previewRingPulseOffset = Math.random() * Math.PI * 2;
}
function buildComputeCenterMarkerData(feature) {
const props = feature?.properties || {};
const coordinates = feature?.geometry?.coordinates || [];
const longitude = Number(coordinates[0]);
const latitude = Number(coordinates[1]);
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
return null;
}
return {
...props,
latitude,
longitude,
displayLatitude: latitude,
displayLongitude: longitude,
site_type: normalizeSiteType(props.site_type),
};
}
function spreadComputeCenterPositions(markers) {
const groups = new Map();
const precision = COMPUTE_CENTER_CONFIG.overlapSpread.groupPrecision;
markers.forEach((marker) => {
const key = `${marker.latitude.toFixed(precision)}|${marker.longitude.toFixed(precision)}`;
if (!groups.has(key)) {
groups.set(key, []);
}
groups.get(key).push(marker);
});
groups.forEach((group) => {
if (group.length <= 1) return;
const radius = COMPUTE_CENTER_CONFIG.overlapSpread.radius;
const offsetStep = COMPUTE_CENTER_CONFIG.overlapSpread.offsetStep;
group.forEach((marker, index) => {
const angle = (Math.PI * 2 * index) / group.length;
marker.displayLatitude =
marker.latitude + Math.sin(angle) * radius * offsetStep;
marker.displayLongitude =
marker.longitude + Math.cos(angle) * radius * offsetStep;
marker.isSpread = true;
marker.groupSize = group.length;
});
});
markers.forEach((marker) => {
if (marker.isSpread) return;
marker.displayLatitude = marker.latitude;
marker.displayLongitude = marker.longitude;
marker.isSpread = false;
marker.groupSize = 1;
});
return markers;
}
function drawComputeCenterEstimatedBadge(context, isEstimated = false) {
if (isEstimated) {
context.save();
context.fillStyle = "rgba(15,23,42,0.92)";
context.beginPath();
context.arc(94, 36, 12, 0, Math.PI * 2);
context.fill();
context.fillStyle = "rgba(255,255,255,0.98)";
context.font = "bold 18px sans-serif";
context.textAlign = "center";
context.textBaseline = "middle";
context.fillText("?", 94, 36);
context.restore();
}
}
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";
}
const computeCenterIconLayer = createInteractableLayer({
id: "computeCenters",
objectType: "compute_center",
renderOrder: COMPUTE_CENTER_RENDER_ORDER,
altitudeOffset: COMPUTE_CENTER_CONFIG.altitudeOffset,
pointSize: COMPUTE_CENTER_POINT_SIZE,
atlasCellSize: COMPUTE_CENTER_ATLAS_CELL_SIZE,
colors: {
byKind: COMPUTE_CENTER_CONFIG.colors,
normal: COMPUTE_CENTER_CONFIG.colors.gpu_cluster,
},
opacity: {
normal: COMPUTE_CENTER_CONFIG.marker.baseOpacity,
dimmed: COMPUTE_CENTER_CONFIG.marker.dimmedOpacity,
hover: 0.98,
locked: 1,
},
stateScale: {
hover: COMPUTE_CENTER_CONFIG.marker.hoverScale,
locked: COMPUTE_CENTER_CONFIG.marker.lockedScale,
dimmed: COMPUTE_CENTER_CONFIG.marker.dimmedScale,
},
pulse: {
enabled: true,
speed: COMPUTE_CENTER_CONFIG.marker.pulseSpeed,
amplitude: COMPUTE_CENTER_CONFIG.marker.pulseAmplitude,
},
icon: {
coordinates: "canvas",
fitSize: COMPUTE_CENTER_ICON_FIT_SIZE,
glowBlur: 16,
getSource({ marker, item }) {
const siteType =
marker?.userData?.site_type || item?.site_type || "gpu_cluster";
return (
COMPUTE_CENTER_ICON_SOURCES[siteType] ||
COMPUTE_CENTER_ICON_SOURCES.infrastructure
);
},
afterDraw(context, { marker, item }) {
const data = marker?.userData || item;
drawComputeCenterEstimatedBadge(
context,
shouldShowComputeCenterEstimatedBadge(data),
);
},
},
getPosition: (item) => ({
latitude: item.displayLatitude,
longitude: item.displayLongitude,
}),
getKind: (item) => item.site_type || "gpu_cluster",
getBucketKey: (marker) =>
[
marker.userData?.site_type || "gpu_cluster",
shouldShowComputeCenterEstimatedBadge(marker.userData) ? "estimated" : "verified",
].join(":"),
getUserData: (item) => ({
...item,
pulseOffset: Math.random() * Math.PI * 2,
}),
avoidance: SURFACE_AVOIDANCE_PROFILES.city,
cluster: {
strategy: "stable-spherical",
},
});
export function formatComputeCenterTypeLabel(siteType) {
return siteType === "supercomputer" ? "超算中心" : "GPU 集群";
}
export function formatComputeCenterCapacity(markerData) {
const value = markerData?.capacity_value;
const unit = markerData?.capacity_unit;
if (value === null || value === undefined || value === "") return "-";
return `${value}${unit ? ` ${unit}` : ""}`;
}
export function formatComputeCenterUpdatedAt(value) {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return String(value);
return date.toLocaleString("zh-CN", { hour12: false });
}
export function formatComputeCenterLocationPrecision(markerData) {
const precision = markerData?.location_precision;
if (precision === "precise") 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 在线搜索",
llm_location_factcheck: "LLM factcheck 兜底",
};
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() {
return [
{
label: "超算中心",
color: COMPUTE_CENTER_CONFIG.colors.supercomputer,
},
{
label: "GPU 集群",
color: COMPUTE_CENTER_CONFIG.colors.gpu_cluster,
},
];
}
export function getComputeCenterMarkers() {
return computeCenterIconLayer.getMarkers();
}
export function getComputeCenterCount() {
return computeCenterIconLayer.getCount();
}
export function getComputeCenterSupercomputerCount() {
return supercomputerCount;
}
export function getComputeCenterGPUClusterCount() {
return gpuClusterCount;
}
export function getComputeCenterStatusSummary() {
if (getComputeCenterCount() === 0) return "暂无算力中心数据";
return `${supercomputerCount} 台超算 / ${gpuClusterCount} 个 GPU 集群`;
}
export function setComputeCenterMarkerState(marker, state = "normal") {
computeCenterIconLayer.setMarkerState(marker, state);
}
export function clearComputeCenterSelection() {
getComputeCenterMarkers().forEach((marker) => setComputeCenterMarkerState(marker, "normal"));
}
export function clearComputeCenterData(earth) {
supercomputerCount = 0;
gpuClusterCount = 0;
unresolvedComputeCenters = [];
clearComputeCenterLocationPreview();
computeCenterIconLayer.clearData(earth);
}
export function clearComputeCenterLocationPreview() {
disposePreviewRingSprite(previewRingA);
disposePreviewRingSprite(previewRingB);
previewRingA = null;
previewRingB = null;
}
export function showComputeCenterLocationPreview(earth, { latitude, longitude, color = 0x2dd4bf } = {}) {
const lat = Number(latitude);
const lon = Number(longitude);
if (!earth || !Number.isFinite(lat) || !Number.isFinite(lon)) return false;
const group = ensurePreviewRingGroup(earth);
if (!group) return false;
const position = latLonToVector3(
lat,
lon,
CONFIG.earthRadius + COMPUTE_CENTER_CONFIG.altitudeOffset + 0.2,
);
attachPreviewRings(group, position, color);
updateComputeCenterLocationPreview();
return true;
}
function updateComputeCenterLocationPreview() {
if (!previewRingA && !previewRingB) return;
const now = performance.now();
const baseScale = 8.5;
const pulseSpeed = 0.00105;
const applyRing = (ring, phaseOffset, maxScale) => {
if (!ring) return;
const phase = (now * pulseSpeed + previewRingPulseOffset + phaseOffset) % 1;
const progress = Math.max(0, Math.min(1, phase));
const fadeIn = Math.max(0, Math.min(1, (progress - 0.04) / 0.16));
const fadeOut = 1 - progress;
const visibility = fadeIn * fadeOut;
ring.scale.setScalar(baseScale * (1.0 + progress * (maxScale - 1.0)));
ring.material.opacity = 0.5 * visibility;
ring.visible = true;
};
applyRing(previewRingA, 0, 1.85);
applyRing(previewRingB, 0.45, 2.35);
}
function recomputeComputeCenterCounts(markerData) {
let nextSupercomputerCount = 0;
let nextGpuClusterCount = 0;
markerData.forEach((item) => {
if (item.site_type === "supercomputer") {
nextSupercomputerCount += 1;
} else {
nextGpuClusterCount += 1;
}
});
supercomputerCount = nextSupercomputerCount;
gpuClusterCount = nextGpuClusterCount;
}
function buildOptimisticComputeCenterMarkerData({ sourceId, candidate, context, saveResult }) {
const location = saveResult?.location || {};
const existingData = context?.data || {};
const latitude = Number(location.latitude ?? candidate?.latitude);
const longitude = Number(location.longitude ?? candidate?.longitude);
if (!sourceId || !Number.isFinite(latitude) || !Number.isFinite(longitude)) {
return null;
}
const siteType = normalizeSiteType(
context?.site_type || context?.siteType || existingData.site_type || location.site_type,
);
return {
id: context?.recordId || context?.id || existingData.id || saveResult?.record_id || sourceId,
source_id: sourceId,
name:
context?.name ||
existingData.name ||
location.name ||
candidate?.matched_location_name ||
candidate?.display_name ||
"算力中心",
source: saveResult?.source || context?.source || existingData.source || location.source || "",
site_type: siteType,
country: location.country || candidate?.country || context?.country || existingData.country || "",
city: location.city || candidate?.city || context?.city || existingData.city || "",
region: location.region || candidate?.region || "",
latitude,
longitude,
displayLatitude: latitude,
displayLongitude: longitude,
operator: context?.operator || existingData.operator || location.operator || "",
vendor: context?.vendor || existingData.vendor || "",
capacity_value: context?.capacity_value ?? existingData.capacity_value,
capacity_unit: context?.capacity_unit ?? existingData.capacity_unit,
rank: context?.rank ?? existingData.rank,
gpu_count: context?.gpu_count ?? existingData.gpu_count,
gpu_type: context?.gpu_type ?? existingData.gpu_type,
cores: context?.cores ?? existingData.cores,
power: context?.power ?? existingData.power,
updated_at: new Date().toISOString(),
status: "observed",
location_precision: location.precision || candidate?.precision || "city",
location_confidence: location.confidence ?? candidate?.confidence ?? null,
location_source: location.location_source || candidate?.source || "manual_selection",
location_source_note: location.location_source_note || candidate?.source_note || "",
location_verified_at: location.verified_at || new Date().toISOString(),
matched_location_name:
location.matched_location_name ||
candidate?.matched_location_name ||
candidate?.display_name ||
"",
needs_confirmation: location.needs_confirmation === true,
is_estimated: false,
estimated_reason: location.estimated_reason || "",
data_type: "compute_center",
metadata: context?.metadata || existingData.metadata || {},
optimistic_spawn: true,
};
}
export async function spawnSavedComputeCenterLocation(earth, { sourceId, candidate, context, saveResult } = {}) {
clearComputeCenterLocationPreview();
const spawned = buildOptimisticComputeCenterMarkerData({
sourceId,
candidate,
context,
saveResult,
});
if (!spawned) return null;
const currentItems = getComputeCenterMarkers()
.map((marker) => ({ ...(marker.userData || {}) }))
.filter((item) => item.source_id !== sourceId);
currentItems.push(spawned);
const nextItems = spreadComputeCenterPositions(currentItems);
await computeCenterIconLayer.preloadAssets(nextItems);
computeCenterIconLayer.setData(nextItems);
computeCenterIconLayer.attach(earth);
computeCenterIconLayer.setVisible(showComputeCenters);
recomputeComputeCenterCounts(nextItems);
unresolvedComputeCenters = unresolvedComputeCenters.filter((item) => {
const itemSourceId = item?.source_id || item?.id;
return itemSourceId !== sourceId;
});
return {
marker: getComputeCenterMarkers().find((marker) => marker.userData?.source_id === sourceId) || null,
totalCount: getComputeCenterCount(),
supercomputerCount,
gpuClusterCount,
unresolvedCount: unresolvedComputeCenters.length,
unresolved: unresolvedComputeCenters.slice(),
summary: getComputeCenterStatusSummary(),
optimistic: true,
};
}
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(() => "");
let detail = "";
try {
const payload = JSON.parse(text);
detail = payload?.detail?.reason || payload?.detail?.message || payload?.detail || "";
} catch {
detail = text;
}
throw new Error(
`Collect location failed: HTTP ${response.status} ${detail}`.trim(),
);
}
return response.json();
}
export async function getComputeCenterLocationCapability({ force = false } = {}) {
if (!computeCenterLocationCapabilityPromise || force) {
computeCenterLocationCapabilityPromise = fetch(LOCATION_CAPABILITY_API, { cache: "no-store" })
.then(async (response) => {
if (!response.ok) {
const text = await response.text().catch(() => "");
return {
enabled: false,
provider: null,
reason: `WebSearch 状态检查失败HTTP ${response.status} ${text}`.trim(),
};
}
const payload = await response.json();
return {
enabled: payload?.enabled === true,
provider: payload?.provider || "",
reason: payload?.reason || "",
};
})
.catch((error) => ({
enabled: false,
provider: null,
reason: `WebSearch 状态检查失败:${error?.message || error}`,
}));
}
return computeCenterLocationCapabilityPromise;
}
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);
}
export function getShowComputeCenters() {
return showComputeCenters;
}
export async function loadComputeCenters(_scene, earth) {
const separator = PATHS.computeCentersApi.includes("?") ? "&" : "?";
const response = await fetch(
`${PATHS.computeCentersApi}${separator}_=${Date.now()}`,
{ cache: "no-store" },
);
if (!response.ok) {
throw new Error(`Compute centers HTTP ${response.status}`);
}
const payload = await response.json();
const features = Array.isArray(payload?.features) ? payload.features : [];
const unresolved = Array.isArray(payload?.unresolved) ? payload.unresolved : [];
const markerData = spreadComputeCenterPositions(
features
.map((feature) => buildComputeCenterMarkerData(feature))
.filter(Boolean),
)
.slice(0, COMPUTE_CENTER_CONFIG.maxRenderedMarkers);
let nextSupercomputerCount = 0;
let nextGpuClusterCount = 0;
markerData.forEach((item) => {
if (item.site_type === "supercomputer") {
nextSupercomputerCount += 1;
} else {
nextGpuClusterCount += 1;
}
});
await computeCenterIconLayer.preloadAssets(markerData);
clearComputeCenterData(earth);
unresolvedComputeCenters = unresolved;
supercomputerCount = nextSupercomputerCount;
gpuClusterCount = nextGpuClusterCount;
computeCenterIconLayer.setData(markerData);
computeCenterIconLayer.attach(earth);
computeCenterIconLayer.setVisible(showComputeCenters);
return {
totalCount: getComputeCenterCount(),
supercomputerCount,
gpuClusterCount,
unresolvedCount: unresolvedComputeCenters.length,
unresolved: unresolvedComputeCenters.slice(),
summary: getComputeCenterStatusSummary(),
};
}
export function getComputeCenterPointerIntersections(options) {
return computeCenterIconLayer.getPointerIntersections(options);
}
export function updateComputeCenterVisualState(lockedObjectType, lockedObject, camera) {
updateComputeCenterLocationPreview();
computeCenterIconLayer.updateVisualState(lockedObjectType, lockedObject, camera);
}