diff --git a/AGENTS.md b/AGENTS.md index 2eb79eb9..1e7cb1bf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/DESIGN.md b/DESIGN.md index 213a46a7..82efd9ee 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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. diff --git a/public/assets/icons/flags-1.png b/public/assets/icons/flags-1.png new file mode 100644 index 00000000..2f5c8bc0 Binary files /dev/null and b/public/assets/icons/flags-1.png differ diff --git a/public/llms-full.txt b/public/llms-full.txt index 2ae6b701..98ee452f 100644 --- a/public/llms-full.txt +++ b/public/llms-full.txt @@ -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. diff --git a/src/components/settings-modal/p2p-stats-settings/__tests__/p2p-stats-settings.test.tsx b/src/components/settings-modal/p2p-stats-settings/__tests__/p2p-stats-settings.test.tsx index 21a36566..e83f2f1a 100644 --- a/src/components/settings-modal/p2p-stats-settings/__tests__/p2p-stats-settings.test.tsx +++ b/src/components/settings-modal/p2p-stats-settings/__tests__/p2p-stats-settings.test.tsx @@ -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); diff --git a/src/components/settings-modal/p2p-stats-settings/p2p-stats-settings.module.css b/src/components/settings-modal/p2p-stats-settings/p2p-stats-settings.module.css index 21847e5b..01fc8dca 100644 --- a/src/components/settings-modal/p2p-stats-settings/p2p-stats-settings.module.css +++ b/src/components/settings-modal/p2p-stats-settings/p2p-stats-settings.module.css @@ -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 { diff --git a/src/components/settings-modal/p2p-stats-settings/p2p-stats-settings.tsx b/src/components/settings-modal/p2p-stats-settings/p2p-stats-settings.tsx index c854bb86..96e558fe 100644 --- a/src/components/settings-modal/p2p-stats-settings/p2p-stats-settings.tsx +++ b/src/components/settings-modal/p2p-stats-settings/p2p-stats-settings.tsx @@ -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; @@ -512,9 +515,9 @@ const getBrowserLibp2pStats = async (account?: AccountShape): Promise 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 }) => ( -
- - {formatCount(row.peerCount, 'peer')}, {formatCount(row.connectionCount, 'connection')} +
+ + {row.name}: {formatCount(row.peerCount, 'peer')}, {formatCount(row.connectionCount, 'connection')} +
{row.entries.length ? ( - row.entries.map((entry) => ( -
-
- {entry.transport} - {entry.direction && {entry.direction}} - {entry.status && {entry.status}} + row.entries.map((entry) => { + const countryCode = getApproximateCountryCode(entry.address); + const flagPosition = getCountryFlagPosition(countryCode); + return ( +
+
+ {entry.transport} + {entry.direction && ( + + {entry.direction} + + )} + {entry.status && ( + + {entry.status} + + )} +
+
+ {entry.peerId} +
+
+ {flagPosition && ( + + )} + + {entry.address} + +
-
{entry.peerId}
- {entry.address} -
- )) + ); + }) ) : (
No active peer addresses
)} @@ -587,8 +618,6 @@ const ConnectedPeersValue = ({ row }: { row: ConnectedPeersStatRow }) => (
); -const StatValue = ({ row }: { row: StatRow }) => (row.type === 'connectedPeers' ? : row.value); - const P2PStatsSettings = () => { const { t } = useTranslation(); const account = useAccount() as AccountShape | undefined; @@ -635,14 +664,20 @@ const P2PStatsSettings = () => { <> - {statsState.rows.map((row) => ( - - - - - ))} + {statsState.rows.map((row) => + row.type === 'connectedPeers' ? ( + + + + ) : ( + + + + + ), + )} {updatedAtLabel && ( diff --git a/src/components/settings-modal/p2p-stats-settings/peer-world-map.tsx b/src/components/settings-modal/p2p-stats-settings/peer-world-map.tsx new file mode 100644 index 00000000..5c0769cc --- /dev/null +++ b/src/components/settings-modal/p2p-stats-settings/peer-world-map.tsx @@ -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 ( +
+ + {LAND_DOTS.map((dot) => ( + + ))} + {plotted.map((peer) => ( + + {peer.peerId} + + ))} + +
approximate locations
+
+ ); +}; + +export default PeerWorldMap; diff --git a/src/lib/__tests__/country-flags.test.ts b/src/lib/__tests__/country-flags.test.ts new file mode 100644 index 00000000..d98feb47 --- /dev/null +++ b/src/lib/__tests__/country-flags.test.ts @@ -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(); + }); +}); diff --git a/src/lib/__tests__/peer-geo.test.ts b/src/lib/__tests__/peer-geo.test.ts new file mode 100644 index 00000000..9fb53a06 --- /dev/null +++ b/src/lib/__tests__/peer-geo.test.ts @@ -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}$/); + }); +}); diff --git a/src/lib/country-flags.ts b/src/lib/country-flags.ts new file mode 100644 index 00000000..a2c2cd9d --- /dev/null +++ b/src/lib/country-flags.ts @@ -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 = 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(); +}; diff --git a/src/lib/peer-geo.ts b/src/lib/peer-geo.ts new file mode 100644 index 00000000..29a903eb --- /dev/null +++ b/src/lib/peer-geo.ts @@ -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 = { + 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]?\/[^/]*?(? { + 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 = { + 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]; +};
{row.name} - -
+ +
{row.name}{row.value}
{t('p2p_stats_updated')}