From 858f28323e845d41656dd436a90579739aa6aa46 Mon Sep 17 00:00:00 2001 From: YiYi Date: Thu, 20 Aug 2026 21:41:51 +0800 Subject: [PATCH] feat: update configuration to include KOMARI_BASE_URL and enhance globe point handling --- .env.example | 5 +-- build/node-locations.ts | 69 ++++++++++++++++++++--------------------- src/globe.ts | 42 +++++++++++++++++++++++-- vite.config.ts | 1 + 4 files changed, 78 insertions(+), 39 deletions(-) diff --git a/.env.example b/.env.example index 17d89a6..dca6027 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,7 @@ # Deployment target: "com" or "cn". Builds default to "com" when omitted. SITE_VARIANT=com -# Build-only secret used to resolve node IPs into public coordinates. -# Never prefix this variable with VITE_, or it may be exposed to the browser. +# Build-only configuration used to resolve node IPs into public coordinates. +# Never prefix these variables with VITE_, or they may be exposed to the browser. +KOMARI_BASE_URL=https://ops.zhouhaoze.top KOMARI_API_KEY= diff --git a/build/node-locations.ts b/build/node-locations.ts index c219133..de72611 100644 --- a/build/node-locations.ts +++ b/build/node-locations.ts @@ -45,11 +45,10 @@ export interface NodeLocationsFile { } export interface NodeLocationBuildOptions { + komariBaseUrl: string; komariApiKey?: string; } -const KOMARI_BASE_URL = "https://ops.zhouhaoze.top"; - function isPrivateIp(ip: string): boolean { if (ip.includes(":")) { const value = ip.toLowerCase(); @@ -91,25 +90,38 @@ async function fetchJson(url: string, init?: RequestInit): Promise>; } +function numberValue(value: unknown): number | null { + const parsed = typeof value === "number" ? value : Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + function geoResult( latValue: unknown, lngValue: unknown, - cityValue: unknown, - countryValue: unknown, - countryCodeValue: unknown, + city: unknown, + country: unknown, + countryCode: unknown, ): GeoResult | null { - const lat = Number(latValue); - const lng = Number(lngValue); - if (!Number.isFinite(lat) || !Number.isFinite(lng) || Math.abs(lat) > 90 || Math.abs(lng) > 180) return null; + const lat = numberValue(latValue); + const lng = numberValue(lngValue); + if (lat === null || lng === null || Math.abs(lat) > 90 || Math.abs(lng) > 180) return null; return { lat, lng, - city: typeof cityValue === "string" ? cityValue : "", - country: typeof countryValue === "string" ? countryValue : "", - countryCode: typeof countryCodeValue === "string" ? countryCodeValue : "", + city: typeof city === "string" ? city : "", + country: typeof country === "string" ? country : "", + countryCode: typeof countryCode === "string" ? countryCode : "", }; } +async function locateWithIpWho(ip: string): Promise { + const data = await fetchJson( + `https://ipwho.is/${encodeURIComponent(ip)}?fields=success,country,country_code,city,latitude,longitude`, + ); + if (data.success !== true) return null; + return geoResult(data.latitude, data.longitude, data.city, data.country, data.country_code); +} + async function locateWithIpSb(ip: string): Promise { const data = await fetchJson(`https://api.ip.sb/geoip/${encodeURIComponent(ip)}`); return geoResult(data.latitude, data.longitude, data.city, data.country, data.country_code); @@ -122,28 +134,15 @@ async function locateWithIpinfo(ip: string): Promise { return geoResult(lat, lng, data.city, data.country, data.country); } -async function locateWithIpWho(ip: string): Promise { - const data = await fetchJson( - `https://ipwho.is/${encodeURIComponent(ip)}?fields=success,country,country_code,city,latitude,longitude`, - ); - if (data.success !== true) return null; - return geoResult(data.latitude, data.longitude, data.city, data.country, data.country_code); -} - -async function locateWithIpapi(ip: string): Promise { - const data = await fetchJson(`https://ipapi.co/${encodeURIComponent(ip)}/json/`); - if (data.error === true) return null; - return geoResult(data.latitude, data.longitude, data.city, data.country_name, data.country_code); -} - async function locateIp(ip: string): Promise { - const providers = [locateWithIpSb, locateWithIpinfo, locateWithIpWho, locateWithIpapi]; + const providers = [() => locateWithIpWho(ip), () => locateWithIpSb(ip), () => locateWithIpinfo(ip)]; + for (const provider of providers) { try { - const result = await provider(ip); - if (result) return result; + const location = await provider(); + if (location) return location; } catch { - // Continue in provider order; the client keeps its country-level fallback if all fail. + // Try the next provider; the country-level fallback remains available in the client. } } return null; @@ -164,8 +163,8 @@ async function mapWithConcurrency(items: T[], limit: number, worker: (item return results; } -async function fetchKomariNodes(apiKey: string): Promise { - const payload = await fetchJson(`${KOMARI_BASE_URL}/api/rpc2`, { +async function fetchKomariNodes(baseUrl: string, apiKey: string): Promise { + const payload = await fetchJson(`${baseUrl.replace(/\/$/, "")}/api/rpc2`, { method: "POST", headers: { Authorization: `Bearer ${apiKey}`, @@ -179,19 +178,19 @@ async function fetchKomariNodes(apiKey: string): Promise { return Object.values(rpc.result).filter((node) => !node.hidden); } -async function fetchPublicNodes(): Promise { - const payload = (await fetchJson(`${KOMARI_BASE_URL}/api/nodes`)) as KomariNodesResponse; +async function fetchPublicNodes(baseUrl: string): Promise { + const payload = (await fetchJson(`${baseUrl.replace(/\/$/, "")}/api/nodes`)) as KomariNodesResponse; if (!Array.isArray(payload.data)) throw new Error("Komari public API returned an invalid node list."); return payload.data.map((node) => ({ uuid: node.uuid, name: node.name, region: node.region ?? "" })); } export async function generateNodeLocations(options: NodeLocationBuildOptions): Promise { - const publicNodes = await fetchPublicNodes(); + const publicNodes = await fetchPublicNodes(options.komariBaseUrl); if (!options.komariApiKey) { return { version: 1, generatedAt: new Date().toISOString(), nodes: publicNodes, locations: [] }; } - const nodes = await fetchKomariNodes(options.komariApiKey); + const nodes = await fetchKomariNodes(options.komariBaseUrl, options.komariApiKey); const nodesWithIp = nodes.flatMap((node) => { const ip = firstPublicIp(node); return ip ? [{ node, ip }] : []; diff --git a/src/globe.ts b/src/globe.ts index 8821196..30e8ec7 100644 --- a/src/globe.ts +++ b/src/globe.ts @@ -25,13 +25,36 @@ function buildPoints(snapshot: InfraSnapshot): GlobePoint[] { return fallback ? [{ lat: fallback.lat, lng: fallback.lng, count: 1 }] : []; }); - if (points.length) return points; + if (points.length) return spreadOverlappingPoints(points); return snapshot.regionBreakdown.flatMap(({ flag, count }) => { const info = flagToRegionInfo(flag); return info ? [{ lat: info.lat, lng: info.lng, count }] : []; }); } +function spreadOverlappingPoints(points: GlobePoint[]): GlobePoint[] { + const groups = new Map(); + 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.28 + Math.floor(index / 8) * 0.12; + return { + ...point, + lat: Math.max(-89.9, Math.min(89.9, point.lat + Math.sin(angle) * radius)), + lng: point.lng + Math.cos(angle) * radius, + }; + }); + }); +} + function cssVar(name: string, fallback: string): string { const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); return value || fallback; @@ -54,6 +77,7 @@ export function mountGlobe(container: HTMLElement, snapshot: InfraSnapshot): () const points = buildPoints(snapshot); 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); @@ -104,6 +128,18 @@ export function mountGlobe(container: HTMLElement, snapshot: InfraSnapshot): () .pointAltitude(0.018) .pointRadius(0.42); + globe + .labelsData(points) + .labelLat("lat") + .labelLng("lng") + .labelText(() => "") + .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); } @@ -111,6 +147,7 @@ export function mountGlobe(container: HTMLElement, snapshot: InfraSnapshot): () function repaint(): void { accent = cssVar("--accent", "#3b4fa3"); + textColor = cssVar("--text", "#f5f4f1"); globeBase = cssVar("--bg-elevated", "#1b1e26"); globeMaterial.color.set(globeBase); globe @@ -118,7 +155,8 @@ export function mountGlobe(container: HTMLElement, snapshot: InfraSnapshot): () .polygonSideColor(() => hexToRgba(accent, 0.04)) .polygonStrokeColor(() => hexToRgba(accent, 0.48)) .atmosphereColor(accent) - .pointColor(() => accent); + .pointColor(() => accent) + .labelColor(() => textColor); if (!reduceMotion) { globe.ringColor(() => (t: number) => hexToRgba(accent, 1 - t)); } diff --git a/vite.config.ts b/vite.config.ts index b57684d..e92ce17 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -112,6 +112,7 @@ export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), ""); const variant = resolveVariant(process.env.SITE_VARIANT ?? env.SITE_VARIANT); const nodeLocationOptions = { + komariBaseUrl: process.env.KOMARI_BASE_URL ?? env.KOMARI_BASE_URL ?? "https://ops.zhouhaoze.top", komariApiKey: process.env.KOMARI_API_KEY ?? env.KOMARI_API_KEY, };