const h = require("../server/node_modules/hyperaxe"); const RAW_HTML = Symbol.for('oasis.rawHtml'); const { div, h2, p, section, button, form, input, span, a, table, tr, td } = h; const { template, i18n, renderStateChip } = require('./main_views'); const TAU = Math.PI * 2; const escAttr = (s) => String(s) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); const escText = (s) => String(s) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); const adaptiveLayout = (n) => { if (n <= 1) return { rRatio: 0.22, nodeR: 30, labelGap: 18 }; if (n <= 3) return { rRatio: 0.28, nodeR: 26, labelGap: 16 }; if (n <= 6) return { rRatio: 0.34, nodeR: 22, labelGap: 14 }; if (n <= 12) return { rRatio: 0.40, nodeR: 18, labelGap: 14 }; return { rRatio: 0.44, nodeR: 14, labelGap: 12 }; }; const buildGraphSvg = (me, peers, links = []) => { const W = 900; const H = 600; const cx = W / 2; const cy = H / 2; const N = Math.max(1, peers.length); const { rRatio, nodeR, labelGap } = adaptiveLayout(N); const r = Math.min(W, H) * rRatio; const meR = nodeR + 6; const positions = peers.map((peer, i) => { const angle = (i / N) * TAU - Math.PI / 2; return { peer, x: cx + r * Math.cos(angle), y: cy + r * Math.sin(angle) }; }); const edges = positions.map(({ peer, x, y }) => `` ).join(''); const at = new Map(positions.map(({ peer, x, y }) => [peer.key, { x, y }])); const chords = links.filter(l => at.has(l.a) && at.has(l.b)).map(l => { const A = at.get(l.a), B = at.get(l.b); return ``; }).join(''); const nodes = positions.map(({ peer, x, y }) => { const xs = x.toFixed(2); const ys = y.toFixed(2); const labelY = (y + nodeR + labelGap).toFixed(2); const enc = escAttr(encodeURIComponent(peer.key)); const centerHref = `/graphos?center=${enc}`; const profileHref = `/author/${enc}`; const name = escText(peer.name); return `` + `${name} (${peer.kind})` + `` + `` + `` + `` + `${name}` + `` + ``; }).join(''); const meLabelY = (cy + meR + labelGap + 2).toFixed(2); const meEnc = escAttr(encodeURIComponent(me.key)); const meProfileHref = `/author/${meEnc}`; const meName = escText(me.name); const center = `` + `${meName}` + `` + `` + `` + `` + `` + `${meName}` + `` + ``; return `` + chords + edges + nodes + center + ``; }; const zoomableGraph = (id, svg, stats = []) => [ div({ class: 'graphos-legend graphos-statsbar' }, ...stats.filter(Boolean)), div({ class: 'graphos-canvas' }, a({ href: process.env.OASIS_MOBILE === '1' ? `#${id}-focus` : `#${id}`, id: `${id}-src`, class: 'tribe-action-btn graphos-zoom-btn' }, `\u2922 ${i18n.graphosZoom || 'Enlarge'}`), div({ class: 'graphos-canvas-svg', [RAW_HTML]: svg }) ), div({ id, class: 'lightbox graphos-lightbox' }, a({ href: `#${id}-src`, class: 'lightbox-close' }, '\u00d7'), div({ class: 'graphos-canvas-zoom' }, div({ class: 'graphos-zoom-stage' }, div({ class: 'graphos-zoom-svg', [RAW_HTML]: svg }), span({ id: `${id}-focus`, class: 'graphos-zoom-focus' }) ) ) ) ]; const kpi = (label, value) => span({ class: 'graphos-legend-item graphos-kpi' }, span({ class: 'stats-kpi-label' }, String(label).toUpperCase()), span({ class: 'stats-kpi-value' }, String(value)) ); const legendItem = (kind, label) => span({ class: 'graphos-legend-item' }, span({ class: `graphos-legend-dot graphos-node-circle-${kind}` }), span(label) ); exports.graphosView = ({ filter, me, peers, links = [], kpis, focus = null, focusId = null, shown = null, total = null }) => { const title = i18n.graphos || 'Graphos'; const description = i18n.graphosDescription || 'Interactive map of the network around you.'; const modes = ['ALL', 'MINE', 'FEDERATION']; const capped = Number.isFinite(shown) && Number.isFinite(total) && total > shown; return template( title, section( div({ class: 'tags-header module-header-line' }, h2(title), p(description) ), focus ? [ form({ method: 'GET', action: '/graphos', class: 'graphos-backbar' }, button({ type: 'submit', class: 'filter-btn' }, i18n.graphosBackToMine || '← My network') ), div({ class: 'graphos-focus-bar' }, span({ class: 'graphos-focus-label' }, `${i18n.graphosViewingGraphOf || 'Viewing'}: `), a({ class: 'user-link', href: `/author/${encodeURIComponent(focusId || '')}` }, `@${focus}`) ) ] : div({ class: 'mode-buttons stats-mode-row' }, modes.map(m => form({ method: 'GET', action: '/graphos' }, input({ type: 'hidden', name: 'filter', value: m }), button({ type: 'submit', class: filter === m ? 'filter-btn active' : 'filter-btn' }, String(m === 'FEDERATION' ? i18n.graphosFederation : i18n[m + 'Button']).toUpperCase()) ) ) ), capped ? p({ class: 'graphos-cap-note' }, `${i18n.graphosShowing || 'Showing'} ${shown} / ${total}`) : null, ...zoomableGraph('graphos-zoom', buildGraphSvg(me, peers, links), [ kpi(i18n.graphosTotalNodes || 'Total nodes', kpis.total), kpi(i18n.online || 'Online', kpis.online), filter !== 'MINE' ? kpi(i18n.discovered || 'Discovered', kpis.discovered) : null, filter !== 'MINE' ? kpi(i18n.unknown || 'Unknown', kpis.unknown) : null ]), div({ class: 'graphos-legend' }, legendItem('me', i18n.graphosYou || 'You'), legendItem('online', i18n.online || 'Online'), filter !== 'MINE' ? legendItem('discovered', i18n.discovered || 'Discovered') : null, filter !== 'MINE' ? legendItem('unknown', i18n.unknown || 'Unknown') : null, span({ class: 'graphos-legend-item' }, span({ class: 'graphos-legend-line graphos-fed-line-mutual' }), span(i18n.graphosFedMutual)), span({ class: 'graphos-legend-item' }, span({ class: 'graphos-legend-line graphos-fed-line-oneway' }), span(i18n.graphosFedOneWay)) ) ) ); }; const majorMinor = (v) => String(v || '').split('.').slice(0, 2).map(n => parseInt(n, 10) || 0); const olderThan = (v, own) => { if (!v || !own) return false; const [a1, b1] = majorMinor(v), [a2, b2] = majorMinor(own); return a1 < a2 || (a1 === a2 && b1 < b2); }; const NET_REASON_KEYS = require('../models/peer_health').NET_REASON_KEYS; const pubLabel = (row) => { const n = String(row.name || '').trim(); if (n && n !== String(row.key).replace(/^@/, '').slice(0, 8)) return n.length > 22 ? n.slice(0, 21) + '…' : n; if (row.host) return row.host.length > 22 ? row.host.slice(0, 21) + '…' : row.host; return '@' + String(row.key).replace(/^@/, '').slice(0, 8) + '…'; }; const buildFederationSvg = (myName, rows, links, ownVersion) => { const W = 900; const H = 640; const cx = W / 2; const cy = H / 2; const N = Math.max(1, rows.length); const r = Math.min(W, H) * (N <= 6 ? 0.34 : 0.42); const pos = new Map(rows.map((row, i) => { const angle = (i / N) * TAU - Math.PI / 2; return [row.key, { x: cx + r * Math.cos(angle), y: cy + r * Math.sin(angle) }]; })); const radius = (row) => 12 + Math.min(14, Math.sqrt(row.inhabitants || 0) * 1.6); const fed = links.map(l => { const A = pos.get(l.a), B = pos.get(l.b); return ``; }).join(''); const mine = rows.filter(row => row.state === 'connected' || row.following).map(row => { const P = pos.get(row.key); return ``; }).join(''); const nodes = rows.map(row => { const P = pos.get(row.key); const rr = radius(row); const old = olderThan(row.version, ownVersion) || (!row.version && row.known); const name = escText(pubLabel(row)); const enc = escAttr(encodeURIComponent(row.key)); const below = (P.y + rr + 14).toFixed(2); return `` + `${escText(row.name || row.host || row.key)}${row.version ? ' · ' + escText(row.version) : ''}` + `` + (old ? `` : '') + (row.isolated ? `` : '') + `` + `${name}` + (row.transport === 'tor' ? `TOR` : '') + ``; }).join(''); const me = `${escText(myName)}` + `` + `${escText(myName)}`; return `` + fed + mine + nodes + me + ``; }; exports.graphosFederationView = ({ myName, rows = [], links = [], ownVersion = '' }) => { const title = i18n.graphos || 'Graphos'; const ORDER = { connected: 0, available: 1, unreachable: 2, unknown: 3 }; const sorted = rows.slice().sort((x, y) => (ORDER[x.state] - ORDER[y.state]) || (y.inhabitants - x.inhabitants) || pubLabel(x).localeCompare(pubLabel(y))); const outdated = (row) => olderThan(row.version, ownVersion) || (!row.version && row.known); const count = (fn) => sorted.filter(fn).length; const stateChip = (row) => { if (row.state === 'connected') return renderStateChip('mutuals', null, String(i18n.peerStateConnected).toUpperCase()); if (row.state === 'available') return renderStateChip(outdated(row) ? 'whole' : 'mutuals', null, String(i18n.peerStateStaged).toUpperCase()); if (row.state === 'unreachable') return renderStateChip('hidden', null, String(i18n[NET_REASON_KEYS[row.reason] || 'peerErrSilent'] || i18n.peerErrOther).toUpperCase()); return renderStateChip('hidden', null, String(i18n.graphosFedNoData).toUpperCase()); }; const versionChip = (row) => row.version ? renderStateChip(outdated(row) ? 'closed' : 'mutuals', null, row.version) : (row.known ? span({ title: i18n.peerVersionUnannounced }, renderStateChip('closed', null, String(i18n.peerOutdated).toUpperCase())) : '—'); const transportChip = (row) => row.transport === 'unknown' ? '—' : renderStateChip(row.transport === 'tor' ? 'fediverse' : 'transport', null, String(row.transport === 'tor' ? i18n.peerErrTor : row.transport === 'both' ? i18n.graphosFedBoth : i18n.graphosFedClearnet).toUpperCase()); const linksCell = (row) => !row.known ? '—' : row.isolated ? renderStateChip('closed', null, String(i18n.graphosFedIsolated).toUpperCase()) : String(row.links); return template( title, section( div({ class: 'tags-header module-header-line' }, h2(title), p(i18n.graphosDescription || '') ), div({ class: 'mode-buttons stats-mode-row' }, ['ALL', 'MINE', 'FEDERATION'].map(m => form({ method: 'GET', action: '/graphos' }, input({ type: 'hidden', name: 'filter', value: m }), button({ type: 'submit', class: m === 'FEDERATION' ? 'filter-btn active' : 'filter-btn' }, String(m === 'FEDERATION' ? i18n.graphosFederation : i18n[m + 'Button']).toUpperCase()) ) ) ), ...(sorted.length ? zoomableGraph('graphos-fed-zoom', buildFederationSvg(myName || '', sorted, links, ownVersion), [ kpi(i18n.graphosFedPubs, sorted.length), kpi(i18n.peerStateConnected, count(r => r.state === 'connected')), kpi(i18n.graphosFedUnreachable, count(r => r.state === 'unreachable')), kpi(i18n.peerOutdated, count(outdated)), kpi(i18n.peerErrTor, count(r => r.transport === 'tor')), kpi(i18n.graphosFedLinks, links.filter(l => l.mutual).length), kpi(i18n.graphosFedIsolated, count(r => r.isolated)) ]) : [p(i18n.graphosFedEmpty)]), div({ class: 'graphos-legend' }, legendItem('me', i18n.graphosYou || 'You'), span({ class: 'graphos-legend-item' }, span({ class: 'graphos-legend-dot graphos-fed-dot-connected' }), span(i18n.peerStateConnected)), span({ class: 'graphos-legend-item' }, span({ class: 'graphos-legend-dot graphos-fed-dot-available' }), span(i18n.peerStateStaged)), span({ class: 'graphos-legend-item' }, span({ class: 'graphos-legend-dot graphos-fed-dot-unreachable' }), span(i18n.graphosFedUnreachable)), span({ class: 'graphos-legend-item' }, span({ class: 'graphos-legend-dot graphos-fed-dot-outdated' }), span(i18n.peerOutdated)), span({ class: 'graphos-legend-item' }, span({ class: 'graphos-legend-line graphos-fed-line-mutual' }), span(i18n.graphosFedMutual)), span({ class: 'graphos-legend-item' }, span({ class: 'graphos-legend-line graphos-fed-line-oneway' }), span(i18n.graphosFedOneWay)) ), sorted.length ? table({ class: 'block-info-table graphos-fed-table' }, tr( td({ class: 'card-label' }, 'PUB'), td({ class: 'card-label' }, i18n.peersOasisVersion), td({ class: 'card-label' }, i18n.graphosFedTransport), td({ class: 'card-label' }, i18n.peerStateLabel), td({ class: 'card-label' }, i18n.graphosFedWith), td({ class: 'card-label' }, i18n.graphosFedInhabitants) ), ...sorted.map(row => tr( td({ 'data-label': 'PUB' }, a({ href: `/author/${encodeURIComponent(row.key)}`, class: 'user-link', title: row.host || row.key }, pubLabel(row))), td({ 'data-label': i18n.peersOasisVersion }, versionChip(row)), td({ 'data-label': i18n.graphosFedTransport }, transportChip(row)), td({ 'data-label': i18n.peerStateLabel }, stateChip(row)), td({ 'data-label': i18n.graphosFedWith }, linksCell(row)), td({ 'data-label': i18n.graphosFedInhabitants }, row.known ? String(row.inhabitants) : '—') )) ) : null ) ); };