feat: enhance globe visualization with light effects and improve UI hints

This commit is contained in:
2026-08-20 20:55:51 +08:00
parent 7b865f638e
commit 5093e64c69
4 changed files with 150 additions and 50 deletions
+139 -48
View File
@@ -11,26 +11,32 @@ import { onThemeChange } from "./theme";
interface GlobePoint { interface GlobePoint {
lat: number; lat: number;
lng: number; lng: number;
name: string;
count: number; count: number;
} }
interface GlobeLight {
lat: number;
lng: number;
altitude: number;
size: number;
kind: "core" | "spark";
}
function buildPoints(snapshot: InfraSnapshot): GlobePoint[] { function buildPoints(snapshot: InfraSnapshot): GlobePoint[] {
const locationsById = new Map(snapshot.nodeLocations.map((location) => [location.uuid, location])); const locationsById = new Map(snapshot.nodeLocations.map((location) => [location.uuid, location]));
const points = snapshot.nodes.flatMap((node) => { const points = snapshot.nodes.flatMap((node) => {
const location = locationsById.get(node.uuid); const location = locationsById.get(node.uuid);
if (location) { if (location) {
const place = [location.city, location.country].filter(Boolean).join(", "); return [{ lat: location.lat, lng: location.lng, count: 1 }];
return [{ lat: location.lat, lng: location.lng, name: place ? `${node.name} · ${place}` : node.name, count: 1 }];
} }
const fallback = flagToRegionInfo(node.region); 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); if (points.length) return spreadOverlappingPoints(points);
return snapshot.regionBreakdown.flatMap(({ flag, count }) => { return snapshot.regionBreakdown.flatMap(({ flag, count }) => {
const info = flagToRegionInfo(flag); 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; if (group.length === 1) return group;
return group.map((point, index) => { return group.map((point, index) => {
const angle = (index / group.length) * Math.PI * 2; 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 { return {
...point, ...point,
lat: Math.max(-89.9, Math.min(89.9, point.lat + Math.sin(angle) * radius)), 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 { function cssVar(name: string, fallback: string): string {
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return value || fallback; 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 { function hexToRgba(hex: string, alpha: number): string {
const clean = hex.replace("#", ""); const clean = hex.replace("#", "");
const r = parseInt(clean.substring(0, 2), 16); 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 { export function mountGlobe(container: HTMLElement, snapshot: InfraSnapshot): () => void {
const points = buildPoints(snapshot); const points = buildPoints(snapshot);
const lights = buildLightField(points);
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let accent = cssVar("--accent", "#3b4fa3"); let accent = cssVar("--accent", "#3b4fa3");
let textColor = cssVar("--text", "#f5f4f1");
let globeBase = cssVar("--bg-elevated", "#1b1e26"); let globeBase = cssVar("--bg-elevated", "#1b1e26");
const topology = countriesTopology as unknown as Topology; const topology = countriesTopology as unknown as Topology;
const countries = feature(topology, topology.objects.countries as GeometryCollection); const countries = feature(topology, topology.objects.countries as GeometryCollection);
@@ -95,12 +167,13 @@ export function mountGlobe(container: HTMLElement, snapshot: InfraSnapshot): ()
renderer.setClearColor(0x000000, 0); renderer.setClearColor(0x000000, 0);
container.replaceChildren(renderer.domElement); container.replaceChildren(renderer.domElement);
renderer.domElement.style.cursor = "grab"; 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.nodeCount = String(points.length);
renderer.domElement.dataset.visualLightCount = String(lights.length);
renderer.domElement.dataset.preciseLocationCount = String(snapshot.nodeLocations.length); renderer.domElement.dataset.preciseLocationCount = String(snapshot.nodeLocations.length);
scene.add(new THREE.AmbientLight(0xffffff, 1.1)); scene.add(new THREE.AmbientLight(0xffffff, 0.82));
const frontLight = new THREE.DirectionalLight(0xffffff, 0.65); const frontLight = new THREE.DirectionalLight(0xffffff, 0.58);
frontLight.position.set(200, 180, 250); frontLight.position.set(200, 180, 250);
scene.add(frontLight); scene.add(frontLight);
const fillLightLeft = new THREE.DirectionalLight(0xffffff, 0.28); 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); rimLight.position.set(0, -120, -260);
scene.add(rimLight); 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() const globe = new ThreeGlobe()
.globeMaterial(globeMaterial) .globeMaterial(globeMaterial)
.polygonsData(countries.features) .polygonsData(countries.features)
.polygonCapColor(() => hexToRgba(accent, 0.12)) .polygonCapColor(() => hexToRgba(accent, 0.075))
.polygonSideColor(() => hexToRgba(accent, 0.04)) .polygonSideColor(() => hexToRgba(accent, 0.018))
.polygonStrokeColor(() => hexToRgba(accent, 0.48)) .polygonStrokeColor(() => hexToRgba(accent, 0.24))
.polygonAltitude(0.004) .polygonAltitude(0.0025)
.showGraticules(true) .showGraticules(true)
.showAtmosphere(true) .showAtmosphere(true)
.atmosphereColor(accent) .atmosphereColor(accent)
.atmosphereAltitude(0.18) .atmosphereAltitude(0.14);
.pointsData(points)
.pointLat("lat")
.pointLng("lng")
.pointColor(() => accent)
.pointAltitude(0.018)
.pointRadius(0.42);
globe globe
.labelsData(points) .customLayerData(lights)
.labelLat("lat") .customThreeObject((datum) => {
.labelLng("lng") const light = datum as GlobeLight;
.labelText((d) => { const sprite = new THREE.Sprite(light.kind === "core" ? coreLightMaterial : sparkLightMaterial);
const point = d as GlobePoint; sprite.scale.setScalar(light.size);
return point.count > 1 ? `${point.name} · ${point.count}` : point.name; return sprite;
}) })
.labelSize(1.15) .customThreeObjectUpdate((object, datum) => {
.labelColor(() => textColor) const light = datum as GlobeLight;
.labelDotRadius(0.32) const { x, y, z } = globe.getCoords(light.lat, light.lng, light.altitude);
.labelDotOrientation("bottom") object.position.set(x, y, z);
.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);
}
scene.add(globe); 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 { function repaint(): void {
accent = cssVar("--accent", "#3b4fa3"); accent = cssVar("--accent", "#3b4fa3");
textColor = cssVar("--text", "#f5f4f1");
globeBase = cssVar("--bg-elevated", "#1b1e26"); globeBase = cssVar("--bg-elevated", "#1b1e26");
globeMaterial.color.set(globeBase); globeMaterial.color.set(globeBase);
globeMaterial.emissive.set(accent);
coreLightMaterial.color.copy(lightColor(accent));
sparkLightMaterial.color.copy(lightColor(accent));
globe globe
.polygonCapColor(() => hexToRgba(accent, 0.12)) .polygonCapColor(() => hexToRgba(accent, 0.075))
.polygonSideColor(() => hexToRgba(accent, 0.04)) .polygonSideColor(() => hexToRgba(accent, 0.018))
.polygonStrokeColor(() => hexToRgba(accent, 0.48)) .polygonStrokeColor(() => hexToRgba(accent, 0.24))
.atmosphereColor(accent) .atmosphereColor(accent);
.pointColor(() => accent)
.labelColor(() => textColor);
if (!reduceMotion) {
globe.ringColor(() => (t: number) => hexToRgba(accent, 1 - t));
}
} }
const unsubscribeTheme = onThemeChange(repaint); const unsubscribeTheme = onThemeChange(repaint);
@@ -211,6 +297,11 @@ export function mountGlobe(container: HTMLElement, snapshot: InfraSnapshot): ()
renderer.domElement.removeEventListener("pointerdown", handlePointerDown); renderer.domElement.removeEventListener("pointerdown", handlePointerDown);
renderer.domElement.removeEventListener("pointerup", handlePointerUp); renderer.domElement.removeEventListener("pointerup", handlePointerUp);
controls.dispose(); controls.dispose();
globe._destructor();
globeMaterial.dispose();
coreLightMaterial.dispose();
sparkLightMaterial.dispose();
glowTexture.dispose();
renderer.dispose(); renderer.dispose();
scene.clear(); scene.clear();
if (renderer.domElement.parentElement === container) container.removeChild(renderer.domElement); if (renderer.domElement.parentElement === container) container.removeChild(renderer.domElement);
+1 -1
View File
@@ -42,7 +42,7 @@
"infra.uptime.desc": "Tracks uptime and response health across my other services.", "infra.uptime.desc": "Tracks uptime and response health across my other services.",
"infra.alist.name": "Alist", "infra.alist.name": "Alist",
"infra.alist.desc": "File management and sharing across several storage sources.", "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.title": "Projects",
"projects.lead": "Things I've written and maintained. Click a card for more detail.", "projects.lead": "Things I've written and maintained. Click a card for more detail.",
+1 -1
View File
@@ -42,7 +42,7 @@
"infra.uptime.desc": "监控各项服务的在线状态和响应情况。", "infra.uptime.desc": "监控各项服务的在线状态和响应情况。",
"infra.alist.name": "Alist", "infra.alist.name": "Alist",
"infra.alist.desc": "聚合多个存储来源的文件管理与分享服务。", "infra.alist.desc": "聚合多个存储来源的文件管理与分享服务。",
"infra.globeHint": "拖拽旋转,查看节点分布", "infra.globeHint": "拖拽旋转,查看全球分布",
"projects.title": "项目", "projects.title": "项目",
"projects.lead": "一些自己写的和维护的项目,点击卡片可以看更多细节。", "projects.lead": "一些自己写的和维护的项目,点击卡片可以看更多细节。",
+9
View File
@@ -465,6 +465,15 @@
.infra__globe-canvas { .infra__globe-canvas {
position: absolute; position: absolute;
inset: 0; 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 { .infra__globe.has-live .infra__globe-fallback {