feat: update configuration to include KOMARI_BASE_URL and enhance globe point handling
This commit is contained in:
+3
-2
@@ -1,6 +1,7 @@
|
|||||||
# Deployment target: "com" or "cn". Builds default to "com" when omitted.
|
# Deployment target: "com" or "cn". Builds default to "com" when omitted.
|
||||||
SITE_VARIANT=com
|
SITE_VARIANT=com
|
||||||
|
|
||||||
# Build-only secret used to resolve node IPs into public coordinates.
|
# Build-only configuration used to resolve node IPs into public coordinates.
|
||||||
# Never prefix this variable with VITE_, or it may be exposed to the browser.
|
# Never prefix these variables with VITE_, or they may be exposed to the browser.
|
||||||
|
KOMARI_BASE_URL=https://ops.zhouhaoze.top
|
||||||
KOMARI_API_KEY=
|
KOMARI_API_KEY=
|
||||||
|
|||||||
+34
-35
@@ -45,11 +45,10 @@ export interface NodeLocationsFile {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface NodeLocationBuildOptions {
|
export interface NodeLocationBuildOptions {
|
||||||
|
komariBaseUrl: string;
|
||||||
komariApiKey?: string;
|
komariApiKey?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const KOMARI_BASE_URL = "https://ops.zhouhaoze.top";
|
|
||||||
|
|
||||||
function isPrivateIp(ip: string): boolean {
|
function isPrivateIp(ip: string): boolean {
|
||||||
if (ip.includes(":")) {
|
if (ip.includes(":")) {
|
||||||
const value = ip.toLowerCase();
|
const value = ip.toLowerCase();
|
||||||
@@ -91,25 +90,38 @@ async function fetchJson(url: string, init?: RequestInit): Promise<Record<string
|
|||||||
return response.json() as Promise<Record<string, unknown>>;
|
return response.json() as Promise<Record<string, unknown>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function numberValue(value: unknown): number | null {
|
||||||
|
const parsed = typeof value === "number" ? value : Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
function geoResult(
|
function geoResult(
|
||||||
latValue: unknown,
|
latValue: unknown,
|
||||||
lngValue: unknown,
|
lngValue: unknown,
|
||||||
cityValue: unknown,
|
city: unknown,
|
||||||
countryValue: unknown,
|
country: unknown,
|
||||||
countryCodeValue: unknown,
|
countryCode: unknown,
|
||||||
): GeoResult | null {
|
): GeoResult | null {
|
||||||
const lat = Number(latValue);
|
const lat = numberValue(latValue);
|
||||||
const lng = Number(lngValue);
|
const lng = numberValue(lngValue);
|
||||||
if (!Number.isFinite(lat) || !Number.isFinite(lng) || Math.abs(lat) > 90 || Math.abs(lng) > 180) return null;
|
if (lat === null || lng === null || Math.abs(lat) > 90 || Math.abs(lng) > 180) return null;
|
||||||
return {
|
return {
|
||||||
lat,
|
lat,
|
||||||
lng,
|
lng,
|
||||||
city: typeof cityValue === "string" ? cityValue : "",
|
city: typeof city === "string" ? city : "",
|
||||||
country: typeof countryValue === "string" ? countryValue : "",
|
country: typeof country === "string" ? country : "",
|
||||||
countryCode: typeof countryCodeValue === "string" ? countryCodeValue : "",
|
countryCode: typeof countryCode === "string" ? countryCode : "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function locateWithIpWho(ip: string): Promise<GeoResult | null> {
|
||||||
|
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<GeoResult | null> {
|
async function locateWithIpSb(ip: string): Promise<GeoResult | null> {
|
||||||
const data = await fetchJson(`https://api.ip.sb/geoip/${encodeURIComponent(ip)}`);
|
const data = await fetchJson(`https://api.ip.sb/geoip/${encodeURIComponent(ip)}`);
|
||||||
return geoResult(data.latitude, data.longitude, data.city, data.country, data.country_code);
|
return geoResult(data.latitude, data.longitude, data.city, data.country, data.country_code);
|
||||||
@@ -122,28 +134,15 @@ async function locateWithIpinfo(ip: string): Promise<GeoResult | null> {
|
|||||||
return geoResult(lat, lng, data.city, data.country, data.country);
|
return geoResult(lat, lng, data.city, data.country, data.country);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function locateWithIpWho(ip: string): Promise<GeoResult | null> {
|
|
||||||
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<GeoResult | null> {
|
|
||||||
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<GeoResult | null> {
|
async function locateIp(ip: string): Promise<GeoResult | null> {
|
||||||
const providers = [locateWithIpSb, locateWithIpinfo, locateWithIpWho, locateWithIpapi];
|
const providers = [() => locateWithIpWho(ip), () => locateWithIpSb(ip), () => locateWithIpinfo(ip)];
|
||||||
|
|
||||||
for (const provider of providers) {
|
for (const provider of providers) {
|
||||||
try {
|
try {
|
||||||
const result = await provider(ip);
|
const location = await provider();
|
||||||
if (result) return result;
|
if (location) return location;
|
||||||
} catch {
|
} 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;
|
return null;
|
||||||
@@ -164,8 +163,8 @@ async function mapWithConcurrency<T, R>(items: T[], limit: number, worker: (item
|
|||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchKomariNodes(apiKey: string): Promise<KomariNode[]> {
|
async function fetchKomariNodes(baseUrl: string, apiKey: string): Promise<KomariNode[]> {
|
||||||
const payload = await fetchJson(`${KOMARI_BASE_URL}/api/rpc2`, {
|
const payload = await fetchJson(`${baseUrl.replace(/\/$/, "")}/api/rpc2`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${apiKey}`,
|
Authorization: `Bearer ${apiKey}`,
|
||||||
@@ -179,19 +178,19 @@ async function fetchKomariNodes(apiKey: string): Promise<KomariNode[]> {
|
|||||||
return Object.values(rpc.result).filter((node) => !node.hidden);
|
return Object.values(rpc.result).filter((node) => !node.hidden);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchPublicNodes(): Promise<PublicNode[]> {
|
async function fetchPublicNodes(baseUrl: string): Promise<PublicNode[]> {
|
||||||
const payload = (await fetchJson(`${KOMARI_BASE_URL}/api/nodes`)) as KomariNodesResponse;
|
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.");
|
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 ?? "" }));
|
return payload.data.map((node) => ({ uuid: node.uuid, name: node.name, region: node.region ?? "" }));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateNodeLocations(options: NodeLocationBuildOptions): Promise<NodeLocationsFile> {
|
export async function generateNodeLocations(options: NodeLocationBuildOptions): Promise<NodeLocationsFile> {
|
||||||
const publicNodes = await fetchPublicNodes();
|
const publicNodes = await fetchPublicNodes(options.komariBaseUrl);
|
||||||
if (!options.komariApiKey) {
|
if (!options.komariApiKey) {
|
||||||
return { version: 1, generatedAt: new Date().toISOString(), nodes: publicNodes, locations: [] };
|
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 nodesWithIp = nodes.flatMap((node) => {
|
||||||
const ip = firstPublicIp(node);
|
const ip = firstPublicIp(node);
|
||||||
return ip ? [{ node, ip }] : [];
|
return ip ? [{ node, ip }] : [];
|
||||||
|
|||||||
+40
-2
@@ -25,13 +25,36 @@ 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 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, 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.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 {
|
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;
|
||||||
@@ -54,6 +77,7 @@ export function mountGlobe(container: HTMLElement, snapshot: InfraSnapshot): ()
|
|||||||
const points = buildPoints(snapshot);
|
const points = buildPoints(snapshot);
|
||||||
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);
|
||||||
@@ -104,6 +128,18 @@ export function mountGlobe(container: HTMLElement, snapshot: InfraSnapshot): ()
|
|||||||
.pointAltitude(0.018)
|
.pointAltitude(0.018)
|
||||||
.pointRadius(0.42);
|
.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) {
|
if (!reduceMotion) {
|
||||||
globe.ringsData(points).ringLat("lat").ringLng("lng").ringColor(() => (t: number) => hexToRgba(accent, 1 - t)).ringMaxRadius(5).ringPropagationSpeed(1.5).ringRepeatPeriod(2200);
|
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 {
|
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);
|
||||||
globe
|
globe
|
||||||
@@ -118,7 +155,8 @@ export function mountGlobe(container: HTMLElement, snapshot: InfraSnapshot): ()
|
|||||||
.polygonSideColor(() => hexToRgba(accent, 0.04))
|
.polygonSideColor(() => hexToRgba(accent, 0.04))
|
||||||
.polygonStrokeColor(() => hexToRgba(accent, 0.48))
|
.polygonStrokeColor(() => hexToRgba(accent, 0.48))
|
||||||
.atmosphereColor(accent)
|
.atmosphereColor(accent)
|
||||||
.pointColor(() => accent);
|
.pointColor(() => accent)
|
||||||
|
.labelColor(() => textColor);
|
||||||
if (!reduceMotion) {
|
if (!reduceMotion) {
|
||||||
globe.ringColor(() => (t: number) => hexToRgba(accent, 1 - t));
|
globe.ringColor(() => (t: number) => hexToRgba(accent, 1 - t));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ export default defineConfig(({ mode }) => {
|
|||||||
const env = loadEnv(mode, process.cwd(), "");
|
const env = loadEnv(mode, process.cwd(), "");
|
||||||
const variant = resolveVariant(process.env.SITE_VARIANT ?? env.SITE_VARIANT);
|
const variant = resolveVariant(process.env.SITE_VARIANT ?? env.SITE_VARIANT);
|
||||||
const nodeLocationOptions = {
|
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,
|
komariApiKey: process.env.KOMARI_API_KEY ?? env.KOMARI_API_KEY,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user