feat(p2p stats): redesign connected peers with world map and country flags

Restyle the connected-peers list in the classic square, flat imageboard style,
move it to the end of the panel, and open it by default. Add an offline
approximate world map and per-peer country flags (sprite order taken from the
original 4chan flags.css), plus codify the old-school design rules in DESIGN.md
and point agents to them from AGENTS.md.
This commit is contained in:
Tommaso Casaburi
2026-05-23 18:33:00 +07:00
parent f4965ee678
commit 782a1f466f
12 changed files with 1065 additions and 39 deletions
+9
View File
@@ -49,6 +49,7 @@ Agents may use compiled context to navigate quickly, but must verify against sou
| Situation | Required action |
|---|---|
| New UI designed or styled, or any visual/component/theme work (`src/components`, `src/views`, CSS modules, `src/themes.css`) | Read `DESIGN.md` first and follow it; self-review the diff against its Do/Don't list before verifying. 5chan is a classic imageboard: square edges, 1px theme borders, flat surfaces, dense text, theme palette. No rounded corners, pill/badge shapes, hover background fills, CSS transitions, or soft shadows on product UI |
| React UI logic changed (`src/components`, `src/views`, `src/hooks`, UI stores) | Follow React architecture rules below, review the diff with `vercel-react-best-practices` and `vercel:react-best-practices` when available, fix valid findings, then run `yarn doctor` |
| `package.json` changed | Run `corepack yarn install` to keep `yarn.lock` in sync |
| Dependencies or import graph changed | Run `yarn knip` as an advisory manifest/import audit |
@@ -108,6 +109,14 @@ src/
- Avoid boolean flag soup for complex flows; model state clearly in Zustand.
- Use React Router for navigation; no manual history manipulation.
### Design Rules
- Read `DESIGN.md` before designing, styling, or adding any UI, and self-review the diff against its Do/Don't list before verification. `DESIGN.md` is the source of truth for 5chan's classic-imageboard look.
- Preserve the old-school imageboard aesthetic: square edges, 1px theme borders, flat surfaces, dense compact text, and the existing theme palette (CSS variables in `src/themes.css` or `currentColor`).
- Do not introduce modern UI patterns on product surfaces: no rounded corners/`border-radius`, no pill/capsule/badge shapes, no hover background-color changes or CSS transitions/animations on rows or cards, and no soft shadows, glass, gradients, or new accent/marketing colors.
- Functional per-entity colors (e.g. a small hashed swatch identifying a peer) are allowed, but render them flat and square as small indicators, not as decorative gradients.
- New decentralized/technical surfaces (P2P stats, addresses, tables) still follow the same language: compact, square, theme-aware, terse labels.
### Code Organization Rules
- Keep components focused; split large components.
+6
View File
@@ -129,6 +129,8 @@ The palette is inherited from classic imageboard themes and should remain recogn
**The No Modern Accent Rule.** Do not introduce a new brand accent, gradient palette, neon crypto palette, or marketing color system for core product UI.
**The Functional Color Exception.** Small, deterministic per-entity colors used as data — for example a hashed swatch that gives each connected peer a stable identity — are allowed. Render them as flat, square, small indicators (not gradients, glows, or large fills). This exception is for conveying data, not for decoration or branding.
## 3. Typography
**Display Font:** Tahoma, sans-serif.
@@ -178,6 +180,8 @@ The palette is inherited from classic imageboard themes and should remain recogn
- **Shadow Strategy:** none except the small legacy menu shadow.
- **Border:** 1px theme borders.
- **Internal Padding:** compact, commonly `2px`, `5px`, or `0.5em`.
- **Hover:** do not change a row's or card's background on hover, and do not add CSS transitions or animations to them. Reserve hover feedback for text-link color changes.
- **Inline labels:** plain text, bold for emphasis. Do not wrap labels (tags, statuses, transports, counts) in pill, capsule, chip, or rounded badge shapes; a label is text, not a button.
### Inputs / Fields
@@ -218,6 +222,8 @@ The palette is inherited from classic imageboard themes and should remain recogn
- Do not modernize core imageboard UI for taste.
- Do not add rounded cards, gradient hero areas, glass panels, soft shadows, dark-mode defaults, or large marketing sections to product surfaces.
- Do not use pill, capsule, chip, or rounded badge shapes for labels, tags, statuses, transports, or counts.
- Do not add hover background fills, color fades, or CSS transitions/animations to rows, cards, or list items.
- Do not replace compact text links with large icon-button toolbars unless the existing surface already uses that pattern.
- Do not introduce a new design system that competes with Yotsuba, Yotsuba B, Futaba, or the existing theme variable model.
- Do not make Bitsocial, crypto, or decentralization visuals dominate routine browsing.
Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

+15
View File
@@ -220,6 +220,7 @@ Agents may use compiled context to navigate quickly, but must verify against sou
| Situation | Required action |
|---|---|
| New UI designed or styled, or any visual/component/theme work (`src/components`, `src/views`, CSS modules, `src/themes.css`) | Read `DESIGN.md` first and follow it; self-review the diff against its Do/Don't list before verifying. 5chan is a classic imageboard: square edges, 1px theme borders, flat surfaces, dense text, theme palette. No rounded corners, pill/badge shapes, hover background fills, CSS transitions, or soft shadows on product UI |
| React UI logic changed (`src/components`, `src/views`, `src/hooks`, UI stores) | Follow React architecture rules below, review the diff with `vercel-react-best-practices` and `vercel:react-best-practices` when available, fix valid findings, then run `yarn doctor` |
| `package.json` changed | Run `corepack yarn install` to keep `yarn.lock` in sync |
| Dependencies or import graph changed | Run `yarn knip` as an advisory manifest/import audit |
@@ -279,6 +280,14 @@ src/
- Avoid boolean flag soup for complex flows; model state clearly in Zustand.
- Use React Router for navigation; no manual history manipulation.
### Design Rules
- Read `DESIGN.md` before designing, styling, or adding any UI, and self-review the diff against its Do/Don't list before verification. `DESIGN.md` is the source of truth for 5chan's classic-imageboard look.
- Preserve the old-school imageboard aesthetic: square edges, 1px theme borders, flat surfaces, dense compact text, and the existing theme palette (CSS variables in `src/themes.css` or `currentColor`).
- Do not introduce modern UI patterns on product surfaces: no rounded corners/`border-radius`, no pill/capsule/badge shapes, no hover background-color changes or CSS transitions/animations on rows or cards, and no soft shadows, glass, gradients, or new accent/marketing colors.
- Functional per-entity colors (e.g. a small hashed swatch identifying a peer) are allowed, but render them flat and square as small indicators, not as decorative gradients.
- New decentralized/technical surfaces (P2P stats, addresses, tables) still follow the same language: compact, square, theme-aware, terse labels.
### Code Organization Rules
- Keep components focused; split large components.
@@ -609,6 +618,8 @@ The palette is inherited from classic imageboard themes and should remain recogn
**The No Modern Accent Rule.** Do not introduce a new brand accent, gradient palette, neon crypto palette, or marketing color system for core product UI.
**The Functional Color Exception.** Small, deterministic per-entity colors used as data — for example a hashed swatch that gives each connected peer a stable identity — are allowed. Render them as flat, square, small indicators (not gradients, glows, or large fills). This exception is for conveying data, not for decoration or branding.
## 3. Typography
**Display Font:** Tahoma, sans-serif.
@@ -658,6 +669,8 @@ The palette is inherited from classic imageboard themes and should remain recogn
- **Shadow Strategy:** none except the small legacy menu shadow.
- **Border:** 1px theme borders.
- **Internal Padding:** compact, commonly `2px`, `5px`, or `0.5em`.
- **Hover:** do not change a row's or card's background on hover, and do not add CSS transitions or animations to them. Reserve hover feedback for text-link color changes.
- **Inline labels:** plain text, bold for emphasis. Do not wrap labels (tags, statuses, transports, counts) in pill, capsule, chip, or rounded badge shapes; a label is text, not a button.
### Inputs / Fields
@@ -698,6 +711,8 @@ The palette is inherited from classic imageboard themes and should remain recogn
- Do not modernize core imageboard UI for taste.
- Do not add rounded cards, gradient hero areas, glass panels, soft shadows, dark-mode defaults, or large marketing sections to product surfaces.
- Do not use pill, capsule, chip, or rounded badge shapes for labels, tags, statuses, transports, or counts.
- Do not add hover background fills, color fades, or CSS transitions/animations to rows, cards, or list items.
- Do not replace compact text links with large icon-button toolbars unless the existing surface already uses that pattern.
- Do not introduce a new design system that competes with Yotsuba, Yotsuba B, Futaba, or the existing theme variable model.
- Do not make Bitsocial, crypto, or decentralization visuals dominate routine browsing.
@@ -132,13 +132,14 @@ describe('P2PStatsSettings', () => {
await act(async () => Promise.resolve());
const rows = getStatRows();
const connectedPeers = container.querySelector('[data-testid="connected-peers"]');
expect(container.textContent).toContain('Leeching');
expect(container.textContent).not.toContain('browser Helia');
expect(container.textContent).not.toContain('seed mode');
expect(container.textContent).not.toContain('status');
expect(rows.get('Connected peers')).toContain('2 peers, 1 connection');
expect(rows.get('Connected peers')).toContain('WebSocket');
expect(rows.get('Connected peers')).toContain('/ip4/127.0.0.1/tcp/4001/ws/p2p/peer-1');
expect(connectedPeers?.textContent).toContain('2 peers, 1 connection');
expect(connectedPeers?.textContent).toContain('WebSocket');
expect(connectedPeers?.textContent).toContain('/ip4/127.0.0.1/tcp/4001/ws/p2p/peer-1');
expect(rows.has('connections')).toBe(false);
expect(rows.has('Listen addresses')).toBe(false);
expect(rows.has('p2p_stats_updated')).toBe(true);
@@ -38,44 +38,139 @@
word-break: break-word;
}
.connectedPeers summary {
/* Connected peers span the full panel width instead of the narrow value column. */
.stats td.connectedPeersCell {
padding: 4px 4px 5px 0;
}
.connectedPeersSummary {
cursor: pointer;
padding: 1px 0;
}
.connectedPeerList {
display: grid;
gap: 6px;
margin-top: 5px;
gap: 5px;
margin-top: 6px;
}
.connectedPeer {
border-top: 1px solid var(--border-color, #d9d9d9);
padding-top: 5px;
display: grid;
gap: 3px;
padding: 4px 6px;
border: 1px solid rgba(127, 127, 127, 0.28);
border: 1px solid color-mix(in srgb, currentColor 22%, transparent);
}
.connectedPeerMeta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 5px;
font-size: 0.9em;
font-size: 0.85em;
}
.connectionTransport {
font-weight: 700;
}
.peerId,
.connectionAddress {
word-break: break-all;
.connectionDirection,
.connectionStatus {
display: inline-flex;
align-items: center;
gap: 4px;
text-transform: capitalize;
opacity: 0.85;
}
.connectionDirection::before {
font-weight: 700;
}
.connectionDirection[data-direction*='out' i]::before {
content: '↑';
}
.connectionDirection[data-direction*='in' i]::before {
content: '↓';
}
.connectionStatus::before {
content: '';
width: 7px;
height: 7px;
background: #9aa0a6;
}
.connectionStatus[data-status*='open' i]::before {
background: #3fb950;
}
.peerId {
overflow: hidden;
font-family: monospace;
text-overflow: ellipsis;
white-space: nowrap;
}
.connectionAddressRow {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
}
.peerFlag {
flex-shrink: 0;
width: 16px;
height: 11px;
background-image: url("/assets/icons/flags-1.png");
background-repeat: no-repeat;
image-rendering: pixelated;
}
.connectionAddress {
display: block;
margin-top: 2px;
flex: 1;
min-width: 0;
overflow: hidden;
font-size: 0.85em;
text-overflow: ellipsis;
white-space: nowrap;
opacity: 0.7;
}
.connectedPeerEmpty {
padding: 6px 8px;
font-style: italic;
opacity: 0.7;
}
.peerWorldMap {
margin-top: 6px;
border: 1px solid rgba(127, 127, 127, 0.28);
border: 1px solid color-mix(in srgb, currentColor 22%, transparent);
}
.peerWorldMapSvg {
display: block;
width: 100%;
height: auto;
}
.landDot {
fill: currentColor;
opacity: 0.18;
}
.peerMarker {
fill: #3fb950;
}
.peerWorldMapCaption {
padding: 1px 4px 2px;
font-size: 0.8em;
font-style: italic;
opacity: 0.6;
}
.statsMeta {
@@ -1,7 +1,10 @@
import { memo, useEffect, useReducer } from 'react';
import { useAccount, usePkcRpcSettings } from '@bitsocial/bitsocial-react-hooks';
import { useTranslation } from 'react-i18next';
import { getCountryFlagPosition, getCountryLabel } from '../../../lib/country-flags';
import { getApproximateCountryCode } from '../../../lib/peer-geo';
import { getP2PRuntimeMode, type P2PRuntimeMode } from '../../../lib/p2p-runtime';
import PeerWorldMap from './peer-world-map';
import styles from './p2p-stats-settings.module.css';
type AccountShape = Record<string, any>;
@@ -512,9 +515,9 @@ const getBrowserLibp2pStats = async (account?: AccountShape): Promise<StatRow[]>
return [
{ name: 'Mode', value: getBrowserMode(client) },
{ name: 'Peer ID', value: libp2p?.peerId?.toString() ?? 'unknown' },
getBrowserConnectedPeersRow(peers, connections),
{ name: 'Data received', value: transferStats.downloadedBytes === undefined ? 'unknown' : formatBytes(transferStats.downloadedBytes) },
{ name: 'Data sent', value: transferStats.uploadedBytes === undefined ? 'unknown' : formatBytes(transferStats.uploadedBytes) },
getBrowserConnectedPeersRow(peers, connections),
];
};
@@ -547,13 +550,13 @@ const getElectronKuboStats = async (rpcState?: string, signal?: AbortSignal): Pr
{ name: 'PKC RPC', value: rpcState ?? 'unknown' },
{ name: 'Peer ID', value: identity.ID ?? 'unknown' },
{ name: 'Agent', value: identity.AgentVersion ?? version.Version ?? 'unknown' },
getElectronConnectedPeersRow(peers),
{ name: 'Bandwidth in', value: `${formatBytes(bandwidth.TotalIn)} total, ${formatRate(bandwidth.RateIn)}` },
{ name: 'Bandwidth out', value: `${formatBytes(bandwidth.TotalOut)} total, ${formatRate(bandwidth.RateOut)}` },
{ name: 'Repo size', value: formatBytes(repo.RepoSize) },
{ name: 'Repo objects', value: String(repo.NumObjects ?? 'unknown') },
{ name: 'Bitswap peers', value: formatCount(Array.isArray(bitswap.Peers) ? bitswap.Peers.length : 0, 'peer') },
{ name: 'Bitswap wantlist', value: formatCount(Array.isArray(bitswap.Wantlist) ? bitswap.Wantlist.length : 0, 'item') },
getElectronConnectedPeersRow(peers),
];
};
@@ -563,23 +566,51 @@ const getP2PStats = async (mode: P2PRuntimeMode, account?: AccountShape, rpcStat
};
const ConnectedPeersValue = ({ row }: { row: ConnectedPeersStatRow }) => (
<details className={styles.connectedPeers} data-testid='connected-peers'>
<summary>
{formatCount(row.peerCount, 'peer')}, {formatCount(row.connectionCount, 'connection')}
<details data-testid='connected-peers' open>
<summary className={styles.connectedPeersSummary}>
{row.name}: {formatCount(row.peerCount, 'peer')}, {formatCount(row.connectionCount, 'connection')}
</summary>
<PeerWorldMap peers={row.entries} />
<div className={styles.connectedPeerList}>
{row.entries.length ? (
row.entries.map((entry) => (
<div className={styles.connectedPeer} key={entry.id}>
<div className={styles.connectedPeerMeta}>
<span className={styles.connectionTransport}>{entry.transport}</span>
{entry.direction && <span>{entry.direction}</span>}
{entry.status && <span>{entry.status}</span>}
row.entries.map((entry) => {
const countryCode = getApproximateCountryCode(entry.address);
const flagPosition = getCountryFlagPosition(countryCode);
return (
<div className={styles.connectedPeer} key={entry.id}>
<div className={styles.connectedPeerMeta}>
<span className={styles.connectionTransport}>{entry.transport}</span>
{entry.direction && (
<span className={styles.connectionDirection} data-direction={entry.direction}>
{entry.direction}
</span>
)}
{entry.status && (
<span className={styles.connectionStatus} data-status={entry.status}>
{entry.status}
</span>
)}
</div>
<div className={styles.peerId} title={entry.peerId}>
{entry.peerId}
</div>
<div className={styles.connectionAddressRow}>
{flagPosition && (
<span
aria-label={getCountryLabel(countryCode)}
className={styles.peerFlag}
role='img'
style={{ backgroundPosition: `-${flagPosition.x}px -${flagPosition.y}px` }}
title={getCountryLabel(countryCode)}
/>
)}
<code className={styles.connectionAddress} title={entry.address}>
{entry.address}
</code>
</div>
</div>
<div className={styles.peerId}>{entry.peerId}</div>
<code className={styles.connectionAddress}>{entry.address}</code>
</div>
))
);
})
) : (
<div className={styles.connectedPeerEmpty}>No active peer addresses</div>
)}
@@ -587,8 +618,6 @@ const ConnectedPeersValue = ({ row }: { row: ConnectedPeersStatRow }) => (
</details>
);
const StatValue = ({ row }: { row: StatRow }) => (row.type === 'connectedPeers' ? <ConnectedPeersValue row={row} /> : row.value);
const P2PStatsSettings = () => {
const { t } = useTranslation();
const account = useAccount() as AccountShape | undefined;
@@ -635,14 +664,20 @@ const P2PStatsSettings = () => {
<>
<table className={styles.stats}>
<tbody>
{statsState.rows.map((row) => (
<tr key={row.name}>
<td className={styles.statName}>{row.name}</td>
<td className={styles.statValue}>
<StatValue row={row} />
</td>
</tr>
))}
{statsState.rows.map((row) =>
row.type === 'connectedPeers' ? (
<tr key={row.name}>
<td className={styles.connectedPeersCell} colSpan={2}>
<ConnectedPeersValue row={row} />
</td>
</tr>
) : (
<tr key={row.name}>
<td className={styles.statName}>{row.name}</td>
<td className={styles.statValue}>{row.value}</td>
</tr>
),
)}
{updatedAtLabel && (
<tr>
<td className={styles.statName}>{t('p2p_stats_updated')}</td>
@@ -0,0 +1,262 @@
import { getApproximateLatLon } from '../../../lib/peer-geo';
import styles from './p2p-stats-settings.module.css';
type MapPeer = {
address: string;
id: string;
peerId: string;
};
// Rough continent outlines as [lon, lat] vertices. Deliberately coarse: they are
// only rasterized into a faint dotted backdrop, so approximate shapes are fine.
const CONTINENTS: [number, number][][] = [
// North America
[
[-168, 65],
[-156, 71],
[-128, 70],
[-110, 68],
[-95, 69],
[-81, 73],
[-78, 67],
[-64, 60],
[-56, 52],
[-66, 49],
[-67, 44],
[-70, 41],
[-75, 35],
[-81, 25],
[-90, 29],
[-97, 26],
[-97, 21],
[-105, 20],
[-112, 24],
[-117, 32],
[-122, 37],
[-125, 43],
[-130, 51],
[-141, 59],
[-155, 58],
],
// Greenland
[
[-45, 60],
[-30, 60],
[-18, 66],
[-20, 73],
[-25, 80],
[-40, 83],
[-58, 82],
[-55, 76],
[-50, 68],
],
// South America
[
[-81, 8],
[-72, 11],
[-62, 10],
[-50, 0],
[-35, -6],
[-39, -14],
[-48, -25],
[-58, -34],
[-65, -41],
[-69, -50],
[-74, -53],
[-72, -45],
[-73, -37],
[-71, -28],
[-71, -18],
[-78, -8],
[-81, -4],
[-80, 2],
],
// Europe
[
[-10, 36],
[-9, 44],
[-2, 43],
[-2, 49],
[-5, 54],
[-6, 58],
[5, 61],
[8, 58],
[12, 59],
[16, 66],
[24, 71],
[30, 70],
[40, 67],
[46, 60],
[42, 52],
[36, 46],
[28, 45],
[24, 40],
[18, 40],
[12, 38],
[2, 42],
[-4, 37],
],
// Africa
[
[-17, 15],
[-16, 21],
[-10, 27],
[-6, 32],
[1, 37],
[10, 37],
[11, 33],
[20, 32],
[25, 32],
[32, 31],
[35, 24],
[37, 18],
[43, 12],
[51, 12],
[44, 5],
[48, -3],
[40, -15],
[35, -21],
[31, -26],
[26, -34],
[20, -35],
[16, -29],
[12, -17],
[9, -1],
[8, 4],
[-4, 5],
[-9, 5],
[-13, 9],
],
// Asia
[
[42, 48],
[45, 55],
[55, 62],
[68, 68],
[80, 73],
[100, 77],
[115, 74],
[140, 73],
[160, 70],
[170, 66],
[178, 65],
[170, 60],
[158, 53],
[150, 46],
[143, 44],
[140, 50],
[135, 44],
[131, 43],
[127, 37],
[122, 40],
[120, 34],
[122, 30],
[115, 22],
[108, 21],
[106, 11],
[100, 14],
[98, 8],
[100, 2],
[95, 7],
[90, 22],
[88, 22],
[80, 13],
[77, 8],
[73, 17],
[68, 24],
[61, 25],
[57, 37],
[52, 41],
[47, 43],
],
// Australia
[
[113, -22],
[121, -19],
[129, -15],
[136, -12],
[142, -11],
[145, -15],
[147, -19],
[153, -25],
[153, -31],
[150, -37],
[143, -39],
[138, -35],
[131, -32],
[123, -34],
[115, -34],
[114, -29],
],
// Indonesia
[
[96, 5],
[120, 7],
[128, 8],
[131, 1],
[122, -4],
[114, -8],
[103, -8],
[98, -1],
],
// Japan
[
[130, 31],
[136, 35],
[141, 41],
[142, 45],
[140, 38],
[135, 34],
[131, 31],
],
];
const pointInPolygon = (lon: number, lat: number, polygon: [number, number][]) => {
let inside = false;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const [xi, yi] = polygon[i];
const [xj, yj] = polygon[j];
if (yi > lat !== yj > lat && lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi) inside = !inside;
}
return inside;
};
// Equirectangular projection into the SVG viewBox below: x = lon + 180, y = 90 - lat.
const LAND_STEP = 3.5;
const LAND_DOTS: { x: number; y: number }[] = (() => {
const dots: { x: number; y: number }[] = [];
for (let lat = 80; lat >= -56; lat -= LAND_STEP) {
for (let lon = -178; lon <= 180; lon += LAND_STEP) {
if (CONTINENTS.some((continent) => pointInPolygon(lon, lat, continent))) dots.push({ x: lon + 180, y: 90 - lat });
}
}
return dots;
})();
const PeerWorldMap = ({ peers }: { peers: MapPeer[] }) => {
const plotted: { id: string; peerId: string; x: number; y: number }[] = [];
for (const peer of peers) {
const location = getApproximateLatLon(peer.address);
if (location) plotted.push({ id: peer.id, peerId: peer.peerId, x: location.lon + 180, y: 90 - location.lat });
}
if (!plotted.length) return null;
return (
<div className={styles.peerWorldMap}>
<svg className={styles.peerWorldMapSvg} viewBox='0 8 360 140' shapeRendering='crispEdges' role='img' aria-label='Approximate peer locations'>
{LAND_DOTS.map((dot) => (
<rect className={styles.landDot} height={1.6} key={`${dot.x}-${dot.y}`} width={1.6} x={dot.x - 0.8} y={dot.y - 0.8} />
))}
{plotted.map((peer) => (
<rect className={styles.peerMarker} height={4.5} key={peer.id} width={4.5} x={peer.x - 2.25} y={peer.y - 2.25}>
<title>{peer.peerId}</title>
</rect>
))}
</svg>
<div className={styles.peerWorldMapCaption}>approximate locations</div>
</div>
);
};
export default PeerWorldMap;
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest';
import { COUNTRY_FLAG_HEIGHT, COUNTRY_FLAG_WIDTH, getCountryFlagPosition, getCountryLabel, normalizeCountryCode } from '../country-flags';
describe('country-flags', () => {
it('places the first sprite cell at the origin', () => {
expect(getCountryFlagPosition('ad')).toEqual({ x: 0, y: 0 });
});
it('maps a code to a grid-aligned sprite position', () => {
const position = getCountryFlagPosition('us');
expect(position).toBeDefined();
expect(position!.x % COUNTRY_FLAG_WIDTH).toBe(0);
expect(position!.y % COUNTRY_FLAG_HEIGHT).toBe(0);
expect(position!.x).toBeGreaterThanOrEqual(0);
expect(position!.x).toBeLessThan(16 * COUNTRY_FLAG_WIDTH);
});
// Exact positions from the original 4chan flags.css (the source of flags-1.png).
it('matches the known sprite positions from flags.css', () => {
expect(getCountryFlagPosition('us')).toEqual({ x: 240, y: 154 });
expect(getCountryFlagPosition('ru')).toEqual({ x: 64, y: 132 });
expect(getCountryFlagPosition('pl')).toEqual({ x: 128, y: 121 });
expect(getCountryFlagPosition('jp')).toEqual({ x: 112, y: 77 });
expect(getCountryFlagPosition('de')).toEqual({ x: 176, y: 33 });
expect(getCountryFlagPosition('gb')).toEqual({ x: 32, y: 55 });
expect(getCountryFlagPosition('br')).toEqual({ x: 240, y: 11 });
expect(getCountryFlagPosition('za')).toEqual({ x: 0, y: 176 });
});
it('normalizes uk to gb and rejects unknown codes', () => {
expect(normalizeCountryCode('UK')).toBe('gb');
expect(normalizeCountryCode('zz')).toBeUndefined();
expect(getCountryFlagPosition('zz')).toBeUndefined();
});
it('returns a human-readable label for ISO codes', () => {
expect(getCountryLabel('de')).toBeTruthy();
expect(getCountryLabel('zz')).toBeUndefined();
});
});
+96
View File
@@ -0,0 +1,96 @@
import { describe, expect, it } from 'vitest';
import { extractIpv4FromAddress, getApproximateCountryCode, getApproximateLatLon, isPrivateOrReservedIpv4 } from '../peer-geo';
const addr = (ip: string) => `/ip4/${ip}/tcp/4001/ws/p2p/12D3KooWExample`;
describe('extractIpv4FromAddress', () => {
it('extracts the IPv4 from a multiaddr', () => {
expect(extractIpv4FromAddress('/ip4/147.75.84.175/tcp/4001/ws')).toBe('147.75.84.175');
});
it('returns null for IPv6 and plain DNS multiaddrs', () => {
expect(extractIpv4FromAddress('/ip6/2606:4700::1111/tcp/4001')).toBeNull();
expect(extractIpv4FromAddress('/dns4/relay.example.org/tcp/443/wss')).toBeNull();
});
it('extracts an IPv4 embedded with dashes in a DNS hostname', () => {
expect(extractIpv4FromAddress('/dns4/91-234-56-78.host.example/tcp/443/wss')).toBe('91.234.56.78');
expect(extractIpv4FromAddress('/dns4/ip-203-0-113-9.provider.net/tcp/443/wss')).toBe('203.0.113.9');
});
});
describe('isPrivateOrReservedIpv4', () => {
it('flags private and reserved ranges', () => {
for (const ip of ['10.0.0.1', '172.16.5.4', '192.168.1.10', '127.0.0.1', '169.254.1.1', '100.64.0.1', '0.0.0.0', '239.255.0.1']) {
expect(isPrivateOrReservedIpv4(ip)).toBe(true);
}
});
it('treats public addresses as routable', () => {
for (const ip of ['8.8.8.8', '147.75.84.175', '1.1.1.1', '80.80.80.80']) {
expect(isPrivateOrReservedIpv4(ip)).toBe(false);
}
});
});
describe('getApproximateLatLon', () => {
it('returns null when the peer cannot be placed offline', () => {
expect(getApproximateLatLon('/ip4/192.168.1.5/tcp/4001')).toBeNull();
expect(getApproximateLatLon('/dns4/relay.example.org/tcp/443/wss')).toBeNull();
expect(getApproximateLatLon('/ip6/2606:4700::1111/tcp/4001')).toBeNull();
});
it('is deterministic for the same address', () => {
expect(getApproximateLatLon(addr('8.8.8.8'))).toEqual(getApproximateLatLon(addr('8.8.8.8')));
});
it('places addresses in the expected continental region', () => {
const na = getApproximateLatLon(addr('8.8.8.8'));
expect(na?.lon).toBeLessThan(-80); // North America
expect(na?.lat).toBeGreaterThan(30);
const eu = getApproximateLatLon(addr('80.80.80.80'));
expect(eu?.lon).toBeGreaterThan(0);
expect(eu?.lon).toBeLessThan(30);
expect(eu?.lat).toBeGreaterThan(40);
const as = getApproximateLatLon(addr('1.1.1.1'));
expect(as?.lon).toBeGreaterThan(90);
const af = getApproximateLatLon(addr('41.0.0.1'));
expect(af?.lon).toBeGreaterThan(8);
expect(af?.lon).toBeLessThan(34);
expect(af?.lat).toBeLessThan(12);
const sa = getApproximateLatLon(addr('200.0.0.1'));
expect(sa?.lon).toBeLessThan(-45);
expect(sa?.lat).toBeLessThan(-5);
});
it('stays within valid coordinate bounds', () => {
const loc = getApproximateLatLon(addr('203.0.113.7'));
expect(loc).not.toBeNull();
expect(loc!.lat).toBeGreaterThanOrEqual(-85);
expect(loc!.lat).toBeLessThanOrEqual(85);
expect(loc!.lon).toBeGreaterThanOrEqual(-180);
expect(loc!.lon).toBeLessThanOrEqual(180);
});
});
describe('getApproximateCountryCode', () => {
it('returns undefined when the peer cannot be placed offline', () => {
expect(getApproximateCountryCode('/ip4/10.0.0.1/tcp/4001')).toBeUndefined();
expect(getApproximateCountryCode('/dns4/relay.example.org/tcp/443/wss')).toBeUndefined();
expect(getApproximateCountryCode('/ip6/2606:4700::1111/tcp/4001')).toBeUndefined();
});
it('is deterministic and returns a known 2-letter code for public peers', () => {
const code = getApproximateCountryCode(addr('8.8.8.8'));
expect(code).toBe(getApproximateCountryCode(addr('8.8.8.8')));
expect(code).toMatch(/^[a-z]{2}$/);
});
it('derives a code from an IPv4 embedded in a DNS hostname', () => {
expect(getApproximateCountryCode('/dns4/91-234-56-78.host.example/tcp/443/wss')).toMatch(/^[a-z]{2}$/);
});
});
+300
View File
@@ -0,0 +1,300 @@
// Country flag sprite mapping for /assets/icons/flags-1.png.
//
// The sprite packs 16x11px flags in a 16-column, row-major grid; a flag's sprite
// position is its index in COUNTRY_FLAG_CODES, so this order must match the PNG
// exactly. This order is taken verbatim from the original 4chan flags.css sprite
// (flags.8.png), which is the source of this PNG.
export const COUNTRY_FLAG_WIDTH = 16;
export const COUNTRY_FLAG_HEIGHT = 11;
const COUNTRY_FLAG_COLUMNS = 16;
const COUNTRY_FLAG_CODES = [
'ad',
'ae',
'af',
'ag',
'ai',
'al',
'am',
'an',
'ao',
'aq',
'ar',
'as',
'at',
'au',
'aw',
'ax',
'az',
'ba',
'bb',
'bd',
'be',
'bf',
'bg',
'bh',
'bi',
'bj',
'bl',
'bm',
'bn',
'bo',
'bq',
'br',
'bs',
'bt',
'bv',
'bw',
'by',
'bz',
'ca',
'catalonia',
'cc',
'cd',
'cf',
'cg',
'ch',
'ci',
'ck',
'cl',
'cm',
'cn',
'co',
'cr',
'cs',
'cu',
'cv',
'cw',
'cx',
'cy',
'cz',
'de',
'dj',
'dk',
'dm',
'do',
'dz',
'ec',
'ee',
'eg',
'eh',
'xe',
'er',
'es',
'et',
'eu',
'fam',
'fi',
'fj',
'fk',
'fm',
'fo',
'fr',
'ga',
'gb',
'gd',
'ge',
'gf',
'gg',
'gh',
'gi',
'gl',
'gm',
'gn',
'gp',
'gq',
'gr',
'gs',
'gt',
'gu',
'gw',
'gy',
'hk',
'hm',
'hn',
'hr',
'ht',
'hu',
'id',
'ie',
'il',
'im',
'in',
'io',
'iq',
'ir',
'is',
'it',
'je',
'jm',
'jo',
'jp',
'ke',
'kg',
'kh',
'ki',
'km',
'kn',
'kp',
'kr',
'kw',
'ky',
'kz',
'la',
'lb',
'lc',
'li',
'lk',
'lr',
'ls',
'lt',
'lu',
'lv',
'ly',
'ma',
'mc',
'md',
'me',
'mf',
'mg',
'mh',
'mk',
'ml',
'mm',
'mn',
'mo',
'mp',
'mq',
'mr',
'ms',
'mt',
'mu',
'mv',
'mw',
'mx',
'my',
'mz',
'na',
'nc',
'ne',
'nf',
'ng',
'ni',
'nl',
'no',
'np',
'nr',
'nu',
'nz',
'om',
'pa',
'pe',
'pf',
'pg',
'ph',
'pk',
'pl',
'pm',
'pn',
'pr',
'ps',
'pt',
'pw',
'py',
'qa',
're',
'ro',
'rs',
'ru',
'rw',
'sa',
'sb',
'sc',
'xs',
'sd',
'se',
'sg',
'sh',
'si',
'sj',
'sk',
'sl',
'sm',
'sn',
'so',
'sr',
'ss',
'st',
'sv',
'sx',
'sy',
'sz',
'tc',
'td',
'tf',
'tg',
'th',
'tj',
'tk',
'tl',
'tm',
'tn',
'to',
'tr',
'tt',
'tv',
'tw',
'tz',
'ua',
'ug',
'um',
'us',
'uy',
'uz',
'va',
'vc',
've',
'vg',
'vi',
'vn',
'vu',
'xw',
'wf',
'ws',
'xk',
'xx',
'ye',
'yt',
'za',
'zm',
'zw',
] as const;
const COUNTRY_FLAG_INDEX_BY_CODE: ReadonlyMap<string, number> = new Map(COUNTRY_FLAG_CODES.map((code, index) => [code, index]));
const countryDisplayNames = typeof Intl.DisplayNames === 'function' ? new Intl.DisplayNames(undefined, { type: 'region' }) : undefined;
export const normalizeCountryCode = (value: string | undefined) => {
const code = value?.trim().toLowerCase();
if (!code) return undefined;
if (code === 'uk') return 'gb';
return COUNTRY_FLAG_INDEX_BY_CODE.has(code) ? code : undefined;
};
export const getCountryFlagPosition = (value: string | undefined): { x: number; y: number } | undefined => {
const code = normalizeCountryCode(value);
const index = code === undefined ? undefined : COUNTRY_FLAG_INDEX_BY_CODE.get(code);
if (index === undefined) return undefined;
return {
x: (index % COUNTRY_FLAG_COLUMNS) * COUNTRY_FLAG_WIDTH,
y: Math.floor(index / COUNTRY_FLAG_COLUMNS) * COUNTRY_FLAG_HEIGHT,
};
};
export const getCountryLabel = (value: string | undefined) => {
const code = normalizeCountryCode(value);
if (!code) return undefined;
const regionName = code.length === 2 ? countryDisplayNames?.of(code.toUpperCase()) : undefined;
return regionName ?? code.toUpperCase();
};
+167
View File
@@ -0,0 +1,167 @@
// Offline, approximate peer geolocation for the P2P stats world map.
//
// This intentionally avoids any external geolocation API: 5chan is serverless and
// privacy-focused, so we must not leak the set of peers a user is connected to.
// Instead we map an IPv4 address to its Regional Internet Registry (RIR) region at
// continent resolution, using the coarse IANA /8 allocation table below. Positions
// are therefore approximate ("roughly which continent"), not precise coordinates.
export type LatLon = { lat: number; lon: number };
type Region = 'AF' | 'AS' | 'EU' | 'NA' | 'SA';
const REGION_CENTROIDS: Record<Region, LatLon> = {
AF: { lat: 4, lon: 21 },
AS: { lat: 30, lon: 105 },
EU: { lat: 50, lon: 15 },
NA: { lat: 39, lon: -97 },
SA: { lat: -15, lon: -58 },
};
// Approximate first-octet (/8) -> RIR region, applied in order (later wins).
// Coarse and not authoritative; uncovered octets fall back to NA (ARIN-heavy
// legacy space). Good enough for a continent-level dot on a map.
const REGION_RANGES: [number, number, Region][] = [
// APNIC (Asia / Pacific)
[1, 1, 'AS'],
[14, 14, 'AS'],
[27, 27, 'AS'],
[36, 36, 'AS'],
[39, 39, 'AS'],
[42, 43, 'AS'],
[49, 49, 'AS'],
[58, 61, 'AS'],
[101, 103, 'AS'],
[106, 106, 'AS'],
[110, 126, 'AS'],
[133, 133, 'AS'],
[150, 153, 'AS'],
[163, 163, 'AS'],
[171, 171, 'AS'],
[175, 175, 'AS'],
[180, 183, 'AS'],
[202, 203, 'AS'],
[210, 211, 'AS'],
[218, 223, 'AS'],
// RIPE NCC (Europe / Middle East)
[2, 2, 'EU'],
[5, 5, 'EU'],
[25, 25, 'EU'],
[31, 31, 'EU'],
[37, 37, 'EU'],
[46, 46, 'EU'],
[51, 51, 'EU'],
[53, 53, 'EU'],
[57, 57, 'EU'],
[62, 62, 'EU'],
[77, 95, 'EU'],
[109, 109, 'EU'],
[141, 141, 'EU'],
[145, 145, 'EU'],
[151, 151, 'EU'],
[176, 176, 'EU'],
[178, 178, 'EU'],
[185, 185, 'EU'],
[188, 188, 'EU'],
[193, 195, 'EU'],
[212, 213, 'EU'],
[217, 217, 'EU'],
// AFRINIC (Africa)
[41, 41, 'AF'],
[102, 102, 'AF'],
[105, 105, 'AF'],
[154, 156, 'AF'],
[196, 197, 'AF'],
// LACNIC (Latin America / Caribbean)
[177, 177, 'SA'],
[179, 179, 'SA'],
[181, 181, 'SA'],
[186, 187, 'SA'],
[189, 191, 'SA'],
[200, 201, 'SA'],
];
const REGION_BY_OCTET: Region[] = (() => {
const table: Region[] = Array.from({ length: 256 }, () => 'NA' as Region);
for (const [start, end, region] of REGION_RANGES) {
for (let octet = start; octet <= end; octet++) table[octet] = region;
}
return table;
})();
export const extractIpv4FromAddress = (address: string): string | null => {
const direct = /\/ip4\/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})/.exec(address);
if (direct) return direct[1];
// Some peers are reached via a DNS name that embeds the IPv4 with dashes, e.g.
// /dns4/91-234-56-78.host.example -> 91.234.56.78
const dashed = /\/dns[46]?\/[^/]*?(?<!\d)(\d{1,3})-(\d{1,3})-(\d{1,3})-(\d{1,3})(?![\d-])/.exec(address);
if (dashed) return `${dashed[1]}.${dashed[2]}.${dashed[3]}.${dashed[4]}`;
return null;
};
const parseOctets = (ip: string): number[] | null => {
const parts = ip.split('.').map(Number);
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return null;
return parts;
};
export const isPrivateOrReservedIpv4 = (ip: string): boolean => {
const parts = parseOctets(ip);
if (!parts) return true;
const [a, b] = parts;
if (a === 0 || a === 10 || a === 127) return true; // this-network, private, loopback
if (a === 169 && b === 254) return true; // link-local
if (a === 172 && b >= 16 && b <= 31) return true; // private
if (a === 192 && b === 168) return true; // private
if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT
if (a >= 224) return true; // multicast / reserved
return false;
};
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
const hashOctets = (parts: number[]) => {
let hash = 0;
for (const part of parts) hash = (Math.imul(hash, 131) + part) | 0;
return Math.abs(hash);
};
// Maps a peer's multiaddr to an approximate lat/lon, or null when it cannot be
// placed offline (private/reserved IPv4, IPv6, or a DNS address).
export const getApproximateLatLon = (address: string): LatLon | null => {
const ip = extractIpv4FromAddress(address);
if (!ip || isPrivateOrReservedIpv4(ip)) return null;
const parts = parseOctets(ip);
if (!parts) return null;
const centroid = REGION_CENTROIDS[REGION_BY_OCTET[parts[0]]];
const hash = hashOctets(parts);
// Deterministic intra-region spread so peers don't stack on one point. The
// region is the meaningful signal; the offset is cosmetic.
const lonOffset = ((hash % 1000) / 1000 - 0.5) * 24; // +/- 12 deg
const latOffset = ((Math.floor(hash / 1000) % 1000) / 1000 - 0.5) * 14; // +/- 7 deg
return { lat: clamp(centroid.lat + latOffset, -85, 85), lon: clamp(centroid.lon + lonOffset, -180, 180) };
};
// Representative countries per region. The RIR table only resolves to a continent,
// so the flag is a deterministic, approximate pick from the region's common
// countries — consistent with the map's "approximate locations" framing, not real
// per-peer country geolocation.
const REGION_COUNTRIES: Record<Region, string[]> = {
AF: ['za', 'ng', 'eg', 'ke', 'ma', 'dz', 'tn'],
AS: ['jp', 'cn', 'sg', 'in', 'kr', 'hk', 'id', 'vn', 'th', 'tw'],
EU: ['de', 'nl', 'fr', 'gb', 'ru', 'se', 'fi', 'pl', 'it', 'es', 'ua', 'ro'],
NA: ['us', 'us', 'ca', 'us', 'mx'],
SA: ['br', 'ar', 'cl', 'co', 'pe'],
};
// Approximate 2-letter country code for a peer, or undefined when it cannot be
// placed offline. See REGION_COUNTRIES: this is region-level, not precise.
export const getApproximateCountryCode = (address: string): string | undefined => {
const ip = extractIpv4FromAddress(address);
if (!ip || isPrivateOrReservedIpv4(ip)) return undefined;
const parts = parseOctets(ip);
if (!parts) return undefined;
const pool = REGION_COUNTRIES[REGION_BY_OCTET[parts[0]]];
return pool[hashOctets(parts) % pool.length];
};