feat: simplify globe point handling and update globe hint text
This commit is contained in:
+27
-159
@@ -14,14 +14,6 @@ interface GlobePoint {
|
|||||||
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) => {
|
||||||
@@ -33,107 +25,18 @@ function buildPoints(snapshot: InfraSnapshot): GlobePoint[] {
|
|||||||
return fallback ? [{ lat: fallback.lat, lng: fallback.lng, count: 1 }] : [];
|
return fallback ? [{ lat: fallback.lat, lng: fallback.lng, count: 1 }] : [];
|
||||||
});
|
});
|
||||||
|
|
||||||
if (points.length) return spreadOverlappingPoints(points);
|
if (points.length) return 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, count }] : [];
|
return info ? [{ lat: info.lat, lng: info.lng, count }] : [];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function spreadOverlappingPoints(points: GlobePoint[]): GlobePoint[] {
|
|
||||||
const groups = new Map<string, GlobePoint[]>();
|
|
||||||
for (const point of points) {
|
|
||||||
const key = `${point.lat.toFixed(3)},${point.lng.toFixed(3)}`;
|
|
||||||
const group = groups.get(key) ?? [];
|
|
||||||
group.push(point);
|
|
||||||
groups.set(key, group);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(groups.values()).flatMap((group) => {
|
|
||||||
if (group.length === 1) return group;
|
|
||||||
return group.map((point, index) => {
|
|
||||||
const angle = (index / group.length) * Math.PI * 2;
|
|
||||||
const radius = 0.65 + index * 0.72;
|
|
||||||
return {
|
|
||||||
...point,
|
|
||||||
lat: Math.max(-89.9, Math.min(89.9, point.lat + Math.sin(angle) * radius)),
|
|
||||||
lng: point.lng + Math.cos(angle) * radius,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
||||||
@@ -149,7 +52,6 @@ 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 globeBase = cssVar("--bg-elevated", "#1b1e26");
|
let globeBase = cssVar("--bg-elevated", "#1b1e26");
|
||||||
@@ -167,13 +69,12 @@ 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 visualizing global infrastructure distribution");
|
renderer.domElement.setAttribute("aria-label", "Interactive globe showing server regions");
|
||||||
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, 0.82));
|
scene.add(new THREE.AmbientLight(0xffffff, 1.1));
|
||||||
const frontLight = new THREE.DirectionalLight(0xffffff, 0.58);
|
const frontLight = new THREE.DirectionalLight(0xffffff, 0.65);
|
||||||
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);
|
||||||
@@ -183,74 +84,44 @@ 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({
|
const globeMaterial = new THREE.MeshPhongMaterial({ color: new THREE.Color(globeBase), shininess: 14, transparent: true, opacity: 0.96 });
|
||||||
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.075))
|
.polygonCapColor(() => hexToRgba(accent, 0.12))
|
||||||
.polygonSideColor(() => hexToRgba(accent, 0.018))
|
.polygonSideColor(() => hexToRgba(accent, 0.04))
|
||||||
.polygonStrokeColor(() => hexToRgba(accent, 0.24))
|
.polygonStrokeColor(() => hexToRgba(accent, 0.48))
|
||||||
.polygonAltitude(0.0025)
|
.polygonAltitude(0.004)
|
||||||
.showGraticules(true)
|
.showGraticules(true)
|
||||||
.showAtmosphere(true)
|
.showAtmosphere(true)
|
||||||
.atmosphereColor(accent)
|
.atmosphereColor(accent)
|
||||||
.atmosphereAltitude(0.14);
|
.atmosphereAltitude(0.18)
|
||||||
|
.pointsData(points)
|
||||||
|
.pointLat("lat")
|
||||||
|
.pointLng("lng")
|
||||||
|
.pointColor(() => accent)
|
||||||
|
.pointAltitude(0.018)
|
||||||
|
.pointRadius(0.42);
|
||||||
|
|
||||||
globe
|
if (!reduceMotion) {
|
||||||
.customLayerData(lights)
|
globe.ringsData(points).ringLat("lat").ringLng("lng").ringColor(() => (t: number) => hexToRgba(accent, 1 - t)).ringMaxRadius(5).ringPropagationSpeed(1.5).ringRepeatPeriod(2200);
|
||||||
.customThreeObject((datum) => {
|
}
|
||||||
const light = datum as GlobeLight;
|
|
||||||
const sprite = new THREE.Sprite(light.kind === "core" ? coreLightMaterial : sparkLightMaterial);
|
|
||||||
sprite.scale.setScalar(light.size);
|
|
||||||
return sprite;
|
|
||||||
})
|
|
||||||
.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);
|
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");
|
||||||
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.075))
|
.polygonCapColor(() => hexToRgba(accent, 0.12))
|
||||||
.polygonSideColor(() => hexToRgba(accent, 0.018))
|
.polygonSideColor(() => hexToRgba(accent, 0.04))
|
||||||
.polygonStrokeColor(() => hexToRgba(accent, 0.24))
|
.polygonStrokeColor(() => hexToRgba(accent, 0.48))
|
||||||
.atmosphereColor(accent);
|
.atmosphereColor(accent)
|
||||||
|
.pointColor(() => accent);
|
||||||
|
if (!reduceMotion) {
|
||||||
|
globe.ringColor(() => (t: number) => hexToRgba(accent, 1 - t));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const unsubscribeTheme = onThemeChange(repaint);
|
const unsubscribeTheme = onThemeChange(repaint);
|
||||||
|
|
||||||
@@ -299,9 +170,6 @@ export function mountGlobe(container: HTMLElement, snapshot: InfraSnapshot): ()
|
|||||||
controls.dispose();
|
controls.dispose();
|
||||||
globe._destructor();
|
globe._destructor();
|
||||||
globeMaterial.dispose();
|
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
@@ -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, explore the global network",
|
"infra.globeHint": "Drag to rotate, showing node distribution",
|
||||||
|
|
||||||
"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
@@ -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": "一些自己写的和维护的项目,点击卡片可以看更多细节。",
|
||||||
|
|||||||
@@ -465,15 +465,6 @@
|
|||||||
.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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user