diff --git a/src/globe.ts b/src/globe.ts index c6d4e08..2f1c0fe 100644 --- a/src/globe.ts +++ b/src/globe.ts @@ -11,26 +11,32 @@ import { onThemeChange } from "./theme"; interface GlobePoint { lat: number; lng: number; - name: string; count: number; } +interface GlobeLight { + lat: number; + lng: number; + altitude: number; + size: number; + kind: "core" | "spark"; +} + function buildPoints(snapshot: InfraSnapshot): GlobePoint[] { const locationsById = new Map(snapshot.nodeLocations.map((location) => [location.uuid, location])); const points = snapshot.nodes.flatMap((node) => { const location = locationsById.get(node.uuid); if (location) { - const place = [location.city, location.country].filter(Boolean).join(", "); - return [{ lat: location.lat, lng: location.lng, name: place ? `${node.name} · ${place}` : node.name, count: 1 }]; + return [{ lat: location.lat, lng: location.lng, count: 1 }]; } const fallback = flagToRegionInfo(node.region); - return fallback ? [{ lat: fallback.lat, lng: fallback.lng, name: node.name, count: 1 }] : []; + return fallback ? [{ lat: fallback.lat, lng: fallback.lng, count: 1 }] : []; }); if (points.length) return spreadOverlappingPoints(points); return snapshot.regionBreakdown.flatMap(({ flag, count }) => { const info = flagToRegionInfo(flag); - return info ? [{ lat: info.lat, lng: info.lng, name: info.name, count }] : []; + return info ? [{ lat: info.lat, lng: info.lng, count }] : []; }); } @@ -47,7 +53,7 @@ function spreadOverlappingPoints(points: GlobePoint[]): GlobePoint[] { if (group.length === 1) return group; return group.map((point, index) => { const angle = (index / group.length) * Math.PI * 2; - const radius = 0.28 + Math.floor(index / 8) * 0.12; + const radius = 0.65 + index * 0.72; return { ...point, lat: Math.max(-89.9, Math.min(89.9, point.lat + Math.sin(angle) * radius)), @@ -57,11 +63,77 @@ function spreadOverlappingPoints(points: GlobePoint[]): GlobePoint[] { }); } +const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5)); +const MAX_LIGHTS = 420; + +function seededNoise(seed: number): number { + const value = Math.sin(seed * 12.9898) * 43758.5453; + return value - Math.floor(value); +} + +function wrapLongitude(lng: number): number { + return ((lng + 540) % 360) - 180; +} + +function buildLightField(points: GlobePoint[]): GlobeLight[] { + const sparkCount = points.length <= 12 ? 10 : points.length <= 50 ? 8 : points.length <= 90 ? 5 : 2; + const lights: GlobeLight[] = []; + + for (const [pointIndex, point] of points.entries()) { + if (lights.length >= MAX_LIGHTS) break; + lights.push({ lat: point.lat, lng: point.lng, altitude: 0.006, size: 3.1, kind: "core" }); + + const densityBoost = Math.min(4, Math.max(0, Math.round(point.count) - 1)); + const clusterSize = sparkCount + densityBoost; + const seed = Math.abs(Math.round((point.lat + 90) * 997 + (point.lng + 180) * 619 + pointIndex * 7919)); + const longitudeScale = Math.max(0.35, Math.cos(THREE.MathUtils.degToRad(point.lat))); + + for (let index = 0; index < clusterSize && lights.length < MAX_LIGHTS; index += 1) { + const noise = seededNoise(seed + index * 17); + const angle = index * GOLDEN_ANGLE + noise * 0.8; + const radius = 0.9 + Math.sqrt((index + 0.5) / clusterSize) * (4.2 + noise * 0.85); + lights.push({ + lat: THREE.MathUtils.clamp(point.lat + Math.cos(angle) * radius, -89.8, 89.8), + lng: wrapLongitude(point.lng + (Math.sin(angle) * radius) / longitudeScale), + altitude: 0.0035 + noise * 0.002, + size: 1.75 + noise * 0.75, + kind: "spark", + }); + } + } + + return lights; +} + +function createGlowTexture(): THREE.CanvasTexture { + const canvas = document.createElement("canvas"); + canvas.width = 64; + canvas.height = 64; + const context = canvas.getContext("2d"); + if (!context) return new THREE.CanvasTexture(canvas); + + const gradient = context.createRadialGradient(32, 32, 0, 32, 32, 32); + gradient.addColorStop(0, "rgba(255, 255, 255, 1)"); + gradient.addColorStop(0.18, "rgba(255, 255, 255, 0.96)"); + gradient.addColorStop(0.44, "rgba(255, 255, 255, 0.42)"); + gradient.addColorStop(1, "rgba(255, 255, 255, 0)"); + context.fillStyle = gradient; + context.fillRect(0, 0, 64, 64); + + const texture = new THREE.CanvasTexture(canvas); + texture.colorSpace = THREE.SRGBColorSpace; + return texture; +} + function cssVar(name: string, fallback: string): string { const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); return value || fallback; } +function lightColor(accent: string): THREE.Color { + return new THREE.Color(accent).lerp(new THREE.Color(cssVar("--text", "#f5f4f1")), 0.38); +} + function hexToRgba(hex: string, alpha: number): string { const clean = hex.replace("#", ""); const r = parseInt(clean.substring(0, 2), 16); @@ -77,9 +149,9 @@ function sizeOf(container: HTMLElement): { width: number; height: number } { export function mountGlobe(container: HTMLElement, snapshot: InfraSnapshot): () => void { const points = buildPoints(snapshot); + const lights = buildLightField(points); const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; let accent = cssVar("--accent", "#3b4fa3"); - let textColor = cssVar("--text", "#f5f4f1"); let globeBase = cssVar("--bg-elevated", "#1b1e26"); const topology = countriesTopology as unknown as Topology; const countries = feature(topology, topology.objects.countries as GeometryCollection); @@ -95,12 +167,13 @@ export function mountGlobe(container: HTMLElement, snapshot: InfraSnapshot): () renderer.setClearColor(0x000000, 0); container.replaceChildren(renderer.domElement); renderer.domElement.style.cursor = "grab"; - renderer.domElement.setAttribute("aria-label", "Interactive globe showing server regions"); + renderer.domElement.setAttribute("aria-label", "Interactive globe visualizing global infrastructure distribution"); renderer.domElement.dataset.nodeCount = String(points.length); + renderer.domElement.dataset.visualLightCount = String(lights.length); renderer.domElement.dataset.preciseLocationCount = String(snapshot.nodeLocations.length); - scene.add(new THREE.AmbientLight(0xffffff, 1.1)); - const frontLight = new THREE.DirectionalLight(0xffffff, 0.65); + scene.add(new THREE.AmbientLight(0xffffff, 0.82)); + const frontLight = new THREE.DirectionalLight(0xffffff, 0.58); frontLight.position.set(200, 180, 250); scene.add(frontLight); const fillLightLeft = new THREE.DirectionalLight(0xffffff, 0.28); @@ -110,61 +183,74 @@ export function mountGlobe(container: HTMLElement, snapshot: InfraSnapshot): () rimLight.position.set(0, -120, -260); scene.add(rimLight); - const globeMaterial = new THREE.MeshPhongMaterial({ color: new THREE.Color(globeBase), shininess: 14, transparent: true, opacity: 0.96 }); + const globeMaterial = new THREE.MeshPhongMaterial({ + color: new THREE.Color(globeBase), + emissive: new THREE.Color(accent), + emissiveIntensity: 0.045, + shininess: 9, + transparent: true, + opacity: 0.98, + }); + const glowTexture = createGlowTexture(); + const coreLightMaterial = new THREE.SpriteMaterial({ + map: glowTexture, + color: lightColor(accent), + transparent: true, + opacity: 0.98, + depthWrite: false, + blending: THREE.AdditiveBlending, + }); + const sparkLightMaterial = new THREE.SpriteMaterial({ + map: glowTexture, + color: lightColor(accent), + transparent: true, + opacity: 0.86, + depthWrite: false, + blending: THREE.AdditiveBlending, + }); const globe = new ThreeGlobe() .globeMaterial(globeMaterial) .polygonsData(countries.features) - .polygonCapColor(() => hexToRgba(accent, 0.12)) - .polygonSideColor(() => hexToRgba(accent, 0.04)) - .polygonStrokeColor(() => hexToRgba(accent, 0.48)) - .polygonAltitude(0.004) + .polygonCapColor(() => hexToRgba(accent, 0.075)) + .polygonSideColor(() => hexToRgba(accent, 0.018)) + .polygonStrokeColor(() => hexToRgba(accent, 0.24)) + .polygonAltitude(0.0025) .showGraticules(true) .showAtmosphere(true) .atmosphereColor(accent) - .atmosphereAltitude(0.18) - .pointsData(points) - .pointLat("lat") - .pointLng("lng") - .pointColor(() => accent) - .pointAltitude(0.018) - .pointRadius(0.42); + .atmosphereAltitude(0.14); globe - .labelsData(points) - .labelLat("lat") - .labelLng("lng") - .labelText((d) => { - const point = d as GlobePoint; - return point.count > 1 ? `${point.name} · ${point.count}` : point.name; + .customLayerData(lights) + .customThreeObject((datum) => { + const light = datum as GlobeLight; + const sprite = new THREE.Sprite(light.kind === "core" ? coreLightMaterial : sparkLightMaterial); + sprite.scale.setScalar(light.size); + return sprite; }) - .labelSize(1.15) - .labelColor(() => textColor) - .labelDotRadius(0.32) - .labelDotOrientation("bottom") - .labelResolution(3) - .labelAltitude(0.022); - - if (!reduceMotion) { - globe.ringsData(points).ringLat("lat").ringLng("lng").ringColor(() => (t: number) => hexToRgba(accent, 1 - t)).ringMaxRadius(5).ringPropagationSpeed(1.5).ringRepeatPeriod(2200); - } + .customThreeObjectUpdate((object, datum) => { + const light = datum as GlobeLight; + const { x, y, z } = globe.getCoords(light.lat, light.lng, light.altitude); + object.position.set(x, y, z); + }); scene.add(globe); + const initialView = globe.getCoords(24, 110, 1.8); + camera.position.set(initialView.x, initialView.y, initialView.z); + camera.lookAt(0, 0, 0); function repaint(): void { accent = cssVar("--accent", "#3b4fa3"); - textColor = cssVar("--text", "#f5f4f1"); globeBase = cssVar("--bg-elevated", "#1b1e26"); globeMaterial.color.set(globeBase); + globeMaterial.emissive.set(accent); + coreLightMaterial.color.copy(lightColor(accent)); + sparkLightMaterial.color.copy(lightColor(accent)); globe - .polygonCapColor(() => hexToRgba(accent, 0.12)) - .polygonSideColor(() => hexToRgba(accent, 0.04)) - .polygonStrokeColor(() => hexToRgba(accent, 0.48)) - .atmosphereColor(accent) - .pointColor(() => accent) - .labelColor(() => textColor); - if (!reduceMotion) { - globe.ringColor(() => (t: number) => hexToRgba(accent, 1 - t)); - } + .polygonCapColor(() => hexToRgba(accent, 0.075)) + .polygonSideColor(() => hexToRgba(accent, 0.018)) + .polygonStrokeColor(() => hexToRgba(accent, 0.24)) + .atmosphereColor(accent); } const unsubscribeTheme = onThemeChange(repaint); @@ -211,6 +297,11 @@ export function mountGlobe(container: HTMLElement, snapshot: InfraSnapshot): () renderer.domElement.removeEventListener("pointerdown", handlePointerDown); renderer.domElement.removeEventListener("pointerup", handlePointerUp); controls.dispose(); + globe._destructor(); + globeMaterial.dispose(); + coreLightMaterial.dispose(); + sparkLightMaterial.dispose(); + glowTexture.dispose(); renderer.dispose(); scene.clear(); if (renderer.domElement.parentElement === container) container.removeChild(renderer.domElement); diff --git a/src/locales/en.json b/src/locales/en.json index eadf79a..b735b63 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -42,7 +42,7 @@ "infra.uptime.desc": "Tracks uptime and response health across my other services.", "infra.alist.name": "Alist", "infra.alist.desc": "File management and sharing across several storage sources.", - "infra.globeHint": "Drag to rotate, showing node distribution", + "infra.globeHint": "Drag to rotate, explore the global network", "projects.title": "Projects", "projects.lead": "Things I've written and maintained. Click a card for more detail.", diff --git a/src/locales/zh.json b/src/locales/zh.json index d50f3ca..d4f86b7 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -42,7 +42,7 @@ "infra.uptime.desc": "监控各项服务的在线状态和响应情况。", "infra.alist.name": "Alist", "infra.alist.desc": "聚合多个存储来源的文件管理与分享服务。", - "infra.globeHint": "拖拽旋转,查看节点分布", + "infra.globeHint": "拖拽旋转,查看全球分布", "projects.title": "项目", "projects.lead": "一些自己写的和维护的项目,点击卡片可以看更多细节。", diff --git a/src/styles/components.css b/src/styles/components.css index 3626bc3..7dd15a7 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -465,6 +465,15 @@ .infra__globe-canvas { position: absolute; inset: 0; + isolation: isolate; + background: + radial-gradient(circle at 50% 48%, color-mix(in srgb, var(--accent) 8%, transparent) 0, transparent 48%), + radial-gradient(circle at 50% 52%, color-mix(in srgb, var(--text) 4%, transparent) 0, transparent 66%); +} + +.infra__globe-canvas canvas { + position: relative; + z-index: 1; } .infra__globe.has-live .infra__globe-fallback {