693 lines
23 KiB
JavaScript
693 lines
23 KiB
JavaScript
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);
|
||
}
|