mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
fix(p2p stats): improve own-IP geolocation and world map accuracy (#1138)
* fix(p2p stats): improve own-IP geolocation and world map accuracy Resolve the user's public endpoint when libp2p only advertises private listen addresses, look up an accurate country flag for "Your IP", snap peer markers to country centroids, and add leeching seeder link plus panel layout tweaks. * fix(p2p stats): skip caching own-IP lookups cancelled by abort When the P2P stats panel unmounts mid-request, its AbortSignal cancels the in-flight fetchOwnPublicEndpoint / fetchOwnIpCountryCode calls. Those empty results were still cached for 30-60s, so reopening the panel within that window showed "Your IP" as unavailable or without a country flag even though nothing had actually failed. Skip caching when the signal aborted so a later open retries. Addresses Cursor Bugbot finding.
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
// Generates src/data/country-centroids.ts: ISO 3166-1 alpha-2 -> approximate
|
||||
// country centroid, used to place P2P peer markers at the center of the country
|
||||
// already shown as the peer's flag. Run with `yarn centroids:generate`.
|
||||
//
|
||||
// Peer country itself is derived offline (see lib/peer-geo getApproximateCountryCode)
|
||||
// so no peer IP is ever sent to a geolocation API; this only supplies the static
|
||||
// "where is country X" centroids the markers snap to.
|
||||
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { dirname, relative, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = resolve(SCRIPT_DIR, '..');
|
||||
const OUTPUT_PATH = resolve(REPO_ROOT, 'src/data/country-centroids.ts');
|
||||
|
||||
// Public-domain ISO country centroids (country,latitude,longitude,name).
|
||||
// Override with COUNTRY_CENTROIDS_CSV_URL.
|
||||
const CSV_URL =
|
||||
process.env.COUNTRY_CENTROIDS_CSV_URL ?? 'https://raw.githubusercontent.com/google/dspl/master/samples/google/canonical/countries.csv';
|
||||
|
||||
const log = (message) => process.stdout.write(`[generate-country-centroids] ${message}\n`);
|
||||
|
||||
const fetchCsv = async (url) => {
|
||||
log(`fetching ${url}`);
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`failed to fetch centroid CSV: ${response.status} ${response.statusText}`);
|
||||
return response.text();
|
||||
};
|
||||
|
||||
const round = (value) => Math.round(value * 100) / 100;
|
||||
|
||||
const parseCentroids = (csv) => {
|
||||
const centroids = {};
|
||||
for (const line of csv.trim().split('\n').slice(1)) {
|
||||
// Only the first three columns are needed; the trailing name may contain commas.
|
||||
const [code, lat, lon] = line.split(',');
|
||||
const iso = code?.trim().toLowerCase();
|
||||
const latNum = Number(lat);
|
||||
const lonNum = Number(lon);
|
||||
if (!/^[a-z]{2}$/.test(iso ?? '') || !Number.isFinite(latNum) || !Number.isFinite(lonNum)) continue;
|
||||
centroids[iso] = { lat: round(latNum), lon: round(lonNum) };
|
||||
}
|
||||
return centroids;
|
||||
};
|
||||
|
||||
const renderModule = (centroids) => {
|
||||
const entries = Object.keys(centroids)
|
||||
.sort()
|
||||
.map((iso) => ` ${iso}: { lat: ${centroids[iso].lat}, lon: ${centroids[iso].lon} },`)
|
||||
.join('\n');
|
||||
return `// GENERATED FILE — do not edit by hand.
|
||||
// Source: Google DSPL canonical countries.csv (public domain), via
|
||||
// scripts/generate-country-centroids.mjs. Run \`yarn centroids:generate\` to refresh.
|
||||
//
|
||||
// ISO 3166-1 alpha-2 (lowercase) -> approximate country centroid, used to place
|
||||
// P2P peer markers at the center of the country shown as the peer's flag.
|
||||
export const COUNTRY_CENTROIDS: Record<string, { lat: number; lon: number }> = {
|
||||
${entries}
|
||||
};
|
||||
`;
|
||||
};
|
||||
|
||||
const main = async () => {
|
||||
const csv = await fetchCsv(CSV_URL);
|
||||
const centroids = parseCentroids(csv);
|
||||
writeFileSync(OUTPUT_PATH, renderModule(centroids));
|
||||
log(`wrote ${relative(REPO_ROOT, OUTPUT_PATH)} (${Object.keys(centroids).length} countries)`);
|
||||
};
|
||||
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`[generate-country-centroids] ${error instanceof Error ? error.message : String(error)}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
// Generates src/data/world-map-dots.ts: a compact dotted-world-map backdrop for
|
||||
// the P2P stats panel, rasterized from Natural Earth 1:110m land polygons
|
||||
// (public domain). Run with `yarn map:generate`.
|
||||
//
|
||||
// The map is decorative ("approximate locations"), so we only need a recognizable
|
||||
// land/sea mask. We rasterize the real land polygons onto a regular lon/lat grid
|
||||
// and pack the result into a 1-bit-per-cell bitmap (base64). At runtime the panel
|
||||
// expands set bits into square dots using the same equirectangular projection the
|
||||
// peer markers use, so dots and markers stay aligned without shipping a geo lib.
|
||||
|
||||
import { Buffer } from 'node:buffer';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { dirname, relative, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = resolve(SCRIPT_DIR, '..');
|
||||
const OUTPUT_PATH = resolve(REPO_ROOT, 'src/data/world-map-dots.ts');
|
||||
|
||||
// Natural Earth 1:110m land (public domain). Override with WORLD_MAP_GEOJSON_URL.
|
||||
const GEOJSON_URL =
|
||||
process.env.WORLD_MAP_GEOJSON_URL ??
|
||||
'https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_110m_land.geojson';
|
||||
|
||||
// Equirectangular grid. STEP is the cell size in degrees; smaller = finer/more
|
||||
// dots. The latitude band excludes Antarctica and trims the very top of the map.
|
||||
const STEP = 2;
|
||||
const LON_MIN = -180;
|
||||
const LAT_MAX = 84;
|
||||
const LAT_MIN = -56;
|
||||
const COLS = Math.round((180 - LON_MIN) / STEP);
|
||||
const ROWS = Math.round((LAT_MAX - LAT_MIN) / STEP);
|
||||
|
||||
const log = (message) => process.stdout.write(`[generate-world-map-dots] ${message}\n`);
|
||||
|
||||
const fetchGeoJson = async (url) => {
|
||||
log(`fetching ${url}`);
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`failed to fetch land GeoJSON: ${response.status} ${response.statusText}`);
|
||||
return response.json();
|
||||
};
|
||||
|
||||
// Flatten Polygon/MultiPolygon features into rings plus a bounding box for a fast
|
||||
// reject before the per-edge ray cast.
|
||||
const collectPolygons = (geojson) => {
|
||||
const polygons = [];
|
||||
for (const feature of geojson.features ?? []) {
|
||||
const geometry = feature.geometry;
|
||||
if (!geometry) continue;
|
||||
const coordinateSets =
|
||||
geometry.type === 'Polygon' ? [geometry.coordinates] : geometry.type === 'MultiPolygon' ? geometry.coordinates : [];
|
||||
for (const rings of coordinateSets) {
|
||||
let minLon = Infinity;
|
||||
let minLat = Infinity;
|
||||
let maxLon = -Infinity;
|
||||
let maxLat = -Infinity;
|
||||
for (const ring of rings) {
|
||||
for (const [lon, lat] of ring) {
|
||||
if (lon < minLon) minLon = lon;
|
||||
if (lon > maxLon) maxLon = lon;
|
||||
if (lat < minLat) minLat = lat;
|
||||
if (lat > maxLat) maxLat = lat;
|
||||
}
|
||||
}
|
||||
polygons.push({ bbox: [minLon, minLat, maxLon, maxLat], rings });
|
||||
}
|
||||
}
|
||||
return polygons;
|
||||
};
|
||||
|
||||
// Even-odd ray casting across every ring of a polygon, so interior holes (lakes)
|
||||
// correctly read as sea. Natural Earth cuts geometry at the antimeridian, so no
|
||||
// polygon wraps past +/-180 and a plain lon-space ray cast is sound.
|
||||
const isInsidePolygon = (lon, lat, rings) => {
|
||||
let inside = false;
|
||||
for (const ring of rings) {
|
||||
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
|
||||
const xi = ring[i][0];
|
||||
const yi = ring[i][1];
|
||||
const xj = ring[j][0];
|
||||
const yj = ring[j][1];
|
||||
if (yi > lat !== yj > lat && lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi) inside = !inside;
|
||||
}
|
||||
}
|
||||
return inside;
|
||||
};
|
||||
|
||||
const isLand = (lon, lat, polygons) => {
|
||||
for (const { bbox, rings } of polygons) {
|
||||
if (lon < bbox[0] || lon > bbox[2] || lat < bbox[1] || lat > bbox[3]) continue;
|
||||
if (isInsidePolygon(lon, lat, rings)) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const rasterize = (polygons) => {
|
||||
const bits = new Uint8Array(Math.ceil((COLS * ROWS) / 8));
|
||||
let landCount = 0;
|
||||
for (let row = 0; row < ROWS; row++) {
|
||||
const lat = LAT_MAX - (row + 0.5) * STEP;
|
||||
for (let col = 0; col < COLS; col++) {
|
||||
const lon = LON_MIN + (col + 0.5) * STEP;
|
||||
if (isLand(lon, lat, polygons)) {
|
||||
const index = row * COLS + col;
|
||||
bits[index >> 3] |= 1 << (7 - (index & 7));
|
||||
landCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { bits, landCount };
|
||||
};
|
||||
|
||||
const renderModule = (base64) =>
|
||||
`// GENERATED FILE — do not edit by hand.
|
||||
// Source: Natural Earth 1:110m land (public domain), rasterized by
|
||||
// scripts/generate-world-map-dots.mjs. Run \`yarn map:generate\` to refresh.
|
||||
//
|
||||
// Row-major land/sea grid for the P2P stats world map. \`bitmap\` is a base64
|
||||
// 1-bit-per-cell mask (MSB first, 1 = land). Cell (col,row) centers on
|
||||
// lon = lonMin + (col + 0.5) * step, lat = latMax - (row + 0.5) * step.
|
||||
export const WORLD_MAP_DOTS = {
|
||||
step: ${STEP},
|
||||
lonMin: ${LON_MIN},
|
||||
latMax: ${LAT_MAX},
|
||||
cols: ${COLS},
|
||||
rows: ${ROWS},
|
||||
bitmap: '${base64}',
|
||||
} as const;
|
||||
`;
|
||||
|
||||
const main = async () => {
|
||||
const geojson = await fetchGeoJson(GEOJSON_URL);
|
||||
const polygons = collectPolygons(geojson);
|
||||
log(`rasterizing ${polygons.length} land polygons onto a ${COLS}x${ROWS} grid (step ${STEP}°)`);
|
||||
const { bits, landCount } = rasterize(polygons);
|
||||
const base64 = Buffer.from(bits).toString('base64');
|
||||
writeFileSync(OUTPUT_PATH, renderModule(base64));
|
||||
log(`wrote ${relative(REPO_ROOT, OUTPUT_PATH)} (${landCount} land cells, ${base64.length} base64 chars)`);
|
||||
};
|
||||
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`[generate-world-map-dots] ${error instanceof Error ? error.message : String(error)}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
Reference in New Issue
Block a user