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); }