diff --git a/.eslintrc.json b/.eslintrc.json index 3149c9bb2..77aa39592 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -29,6 +29,7 @@ "HashColor": "readonly", "HopDisplay": "readonly", "HopResolver": "readonly", + "I18n": "readonly", "IATA_CITIES": "readonly", "IATA_COORDS_GEO": "readonly", "L": "readonly", @@ -283,6 +284,8 @@ "syncBadgeColors": "readonly", "timeAgo": "readonly", "toggleFavorite": "readonly", + "tr": "readonly", + "trn": "readonly", "transportBadge": "readonly", "truncate": "readonly", "viewGeneration": "readonly", diff --git a/public/analytics.js b/public/analytics.js index 5019a8065..849cad8a8 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -2,6 +2,9 @@ 'use strict'; (function () { + // i18n.js defines tr()/trn(); sandboxes that load this file alone get English. + var tr = globalThis.tr || function (s, p) { return p ? s.replace(/\{(\w+)\}/g, function (m, k) { return Object.prototype.hasOwnProperty.call(p, k) ? String(p[k]) : m; }) : s; }; + var trn = globalThis.trn || function (n, one, other, p) { return tr(n === 1 ? one : other, Object.assign({ count: n }, p)); }; let _analyticsData = {}; const sf = (v, d) => (v != null ? v.toFixed(d) : '–'); // safe toFixed function esc(s) { return s ? String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,''') : ''; } @@ -68,7 +71,7 @@ const y = h - 2 - (v / max) * (h - 4); return `${x},${y}`; }).join(' '); - return `Sparkline showing trend of ${data.length} data points`; + return `${tr('Sparkline showing trend of {count} data points', { count: data.length })}`; } // Packets / Hour (#2193): a long history rolls up to coarser bars (at most @@ -81,7 +84,7 @@ // the server has already cut the data to it, so nothing is cropped. function packetsPerHourCard(pph, opts) { const icon = ''; - if (!pph || !pph.length) return { html: `

${icon} Packets / Hour

No packets in this period.

`, from: null }; + if (!pph || !pph.length) return { html: `

${icon} ${tr('Packets / {unit}', { unit: tr('Hour') })}

${tr('No packets in this period.')}

`, from: null }; const at = (h) => Date.parse(h.hour.replace(' ', 'T') + ':00:00Z'); const days = new Map(); pph.forEach((h) => days.set(h.hour.slice(0, 10), (days.get(h.hour.slice(0, 10)) || 0) + h.count)); @@ -101,14 +104,14 @@ if ((t / 3600e3) % every) return ''; return spanH > 48 && (every >= 24 || hh === 0) ? d.toISOString().slice(5, 10) : String(hh).padStart(2, '0') + 'h'; }); - const since = from > first ? ` since ${new Date(from).toISOString().slice(5, 10)}` : ''; - return { html: `

${icon} Packets / ${name}${since}

${barChart(counts, labels, 'var(--accent)')}`, from }; + const since = from > first ? ` ${tr('since {date}', { date: new Date(from).toISOString().slice(5, 10) })}` : ''; + return { html: `

${icon} ${tr('Packets / {unit}', { unit: tr(name) })}${since}

${barChart(counts, labels, 'var(--accent)')}`, from }; } function barChart(data, labels, colors, w = 800, h = 220, pad = 40) { const max = Math.max(...data, 1); const barW = Math.max(1, Math.min((w - pad * 2) / data.length - 2, 30)); - let svg = `Bar chart showing data distribution`; + let svg = `${tr('Bar chart showing data distribution')}`; // Grid for (let i = 0; i <= 4; i++) { const y = pad + (h - pad * 2) * i / 4; @@ -151,44 +154,44 @@ app.innerHTML = `
-

Mesh Analytics

-

Deep dive into your mesh network data

+

${tr('Mesh Analytics')}

+

${tr('Deep dive into your mesh network data')}

- + + + + +
-
- - - - - - - - - - - - - - +
+ + + + + + + + + + + + + + - - - + + +
-
Loading analytics…
+
${tr('Loading analytics…')}
`; @@ -328,7 +331,7 @@ // Tabs drawn from the bulk load; the other tabs fetch their own data. var BULK_TABS = new Set(['overview', 'rf', 'topology', 'channels', 'hashsizes', 'collisions']); function loadErrorHtml(msg) { - return ``; + return ``; } async function loadAnalytics() { @@ -382,7 +385,7 @@ // (or error) state instead of drawing from missing data (#2200). if (BULK_TABS.has(tab) && !d.rfData) { el.innerHTML = _loadError ? loadErrorHtml(_loadError) - : '
Loading analytics…
'; + : '
' + tr('Loading analytics…') + '
'; return; } switch (tab) { @@ -437,45 +440,45 @@
${(rf.totalTransmissions || rf.totalAllPackets || rf.totalPackets).toLocaleString()}
-
Total Transmissions
+
${tr('Total Transmissions')}
${sparkSvg(rf.packetsPerHour.map(h=>h.count), 'var(--accent)')}
${rf.totalPackets.toLocaleString()}
-
Observations with Signal
+
${tr('Observations with Signal')}
${topo.uniqueNodes}
-
Unique Nodes
+
${tr('Unique Nodes')}
${sf(rf.snr.avg, 1)} dB
-
Avg SNR
-
${sf(rf.snr.min, 1)} to ${sf(rf.snr.max, 1)}
+
${tr('Avg SNR')}
+
${tr('{min} to {max}', { min: sf(rf.snr.min, 1), max: sf(rf.snr.max, 1) })}
${sf(rf.rssi.avg, 0)} dBm
-
Avg RSSI
-
${rf.rssi.min} to ${rf.rssi.max}
+
${tr('Avg RSSI')}
+
${tr('{min} to {max}', { min: rf.rssi.min, max: rf.rssi.max })}
${sf(topo.avgHops, 1)}
-
Avg Hops
-
max ${topo.maxHops}
+
${tr('Avg Hops')}
+
${tr('max {count}', { count: topo.maxHops })}
${ch.activeChannels}
-
Active Channels
-
${ch.decryptable} decryptable
+
${tr('Active Channels')}
+
${tr('{count} decryptable', { count: ch.decryptable })}
${rf.avgPacketSize} B
-
Avg Packet Size
+
${tr('Avg Packet Size')}
${rf.minPacketSize}–${rf.maxPacketSize} B
${((rf.timeSpanHours || 1)).toFixed(1)}h
-
Data Span
+
${tr('Data Span')}
@@ -485,19 +488,19 @@
-

Payload Type Mix

+

${tr('Payload Type Mix')}

${renderPayloadPie(rf.payloadTypes)}
-

Hop Count Distribution

+

${tr('Hop Count Distribution')}

${barChart(topo.hopDistribution.map(h=>h.count), topo.hopDistribution.map(h=>h.hops), ['#3b82f6'])}
-

Relay Airtime Share

-

Score = LoRa Time-on-Air × distinct repeaters that forwarded the packet (issue #1768). Counts relay re-transmissions; originator TX excluded. Not comparable across meshes — see preset caption below.

+

${tr('Relay Airtime Share')}

+

${tr('Score = LoRa Time-on-Air × distinct repeaters that forwarded the packet (issue #1768). Counts relay re-transmissions; originator TX excluded. Not comparable across meshes — see preset caption below.')}

${renderRelayAirtimeDumbbell(d.airtimeData)}
@@ -518,16 +521,16 @@ var widget = document.createElement('div'); widget.className = 'analytics-row'; widget.innerHTML = '
' + - '

Neighbor Affinity Graph

' + + '

' + tr('Neighbor Affinity Graph') + '

' + '
' + - '
' + s.totalEdges + '
Total Edges
' + - '
' + s.totalNodes + '
Total Nodes
' + - '
' + s.resolvedCount + ' (' + resolvedPct + '%)
Resolved Prefixes
' + - '
' + s.ambiguousCount + ' (' + ambiguousPct + '%)
Ambiguous Prefixes
' + - '
' + (s.avgConfidence || 0).toFixed(3) + '
Avg Confidence
' + - '
' + (s.coldStartCoverage || 0).toFixed(1) + '%
Cold-Start Coverage
' + - '
' + (s.cacheAge || 'N/A') + '
Cache Age
' + - '
' + (s.lastRebuild ? s.lastRebuild.substring(0, 19) : 'N/A') + '
Last Rebuild
' + + '
' + s.totalEdges + '
' + tr('Total Edges') + '
' + + '
' + s.totalNodes + '
' + tr('Total Nodes') + '
' + + '
' + s.resolvedCount + ' (' + resolvedPct + '%)
' + tr('Resolved Prefixes') + '
' + + '
' + s.ambiguousCount + ' (' + ambiguousPct + '%)
' + tr('Ambiguous Prefixes') + '
' + + '
' + (s.avgConfidence || 0).toFixed(3) + '
' + tr('Avg Confidence') + '
' + + '
' + (s.coldStartCoverage || 0).toFixed(1) + '%
' + tr('Cold-Start Coverage') + '
' + + '
' + (s.cacheAge || tr('N/A')) + '
' + tr('Cache Age') + '
' + + '
' + (s.lastRebuild ? s.lastRebuild.substring(0, 19) : tr('N/A')) + '
' + tr('Last Rebuild') + '
' + '
'; el.appendChild(widget); }) @@ -559,11 +562,11 @@ function renderRelayAirtimeDumbbell(data) { var rows = (data && Array.isArray(data.rows)) ? data.rows : []; if (!rows.length) { - return '
No relay-airtime data in this window.
'; + return '
' + tr('No relay-airtime data in this window.') + '
'; } var totalScore = (data && typeof data.total_score === 'number') ? data.total_score : 0; if (totalScore <= 0) { - return '
No relay activity observed in this window.
'; + return '
' + tr('No relay activity observed in this window.') + '
'; } // Issue #1768 — surface the LoRa preset baked into the ToA score. Share // numbers are only meaningful relative to one PHY preset; operators must @@ -580,12 +583,12 @@ var bwStr = bwKhz ? bwKhz.toFixed(1).replace(/\.0$/, '') : '0'; presetCaption = '
' + - 'Assumed LoRa preset: ' + + tr('Assumed LoRa preset:') + ' ' + (freqMHz ? esc(freqMHz.toFixed(3)) + ' MHz / ' : '') + 'BW ' + esc(bwStr) + ' kHz / ' + 'SF ' + esc(String(Number(preset.sf || 0))) + ' / ' + 'CR 4/' + esc(String(Number(preset.cr || 0))) + - ' (preamble ' + esc(String(Number(preset.preamble || 0))) + ' sym)' + + ' (' + tr('preamble {count} sym', { count: esc(String(Number(preset.preamble || 0))) }) + ')' + '
'; } // Layout: per row → label | track 0..100% | values @@ -593,15 +596,15 @@ var html = '
'; if (presetCaption) html += presetCaption; if (rows.some(function (r) { return r.payload_type === 'ADVERT'; })) { - html += '
Known route evidence: Mixed adverts were observed as both flood and direct with an empty remaining path. An empty path does not prove an original zero-hop send. Other adverts have no classified evidence yet. Background backfill improves classification automatically. Available history is a lower bound; older overwritten observations cannot be recovered, and failed evidence writes may leave gaps.
'; + html += '
' + tr('Known route evidence: Mixed adverts were observed as both flood and direct with an empty remaining path. An empty path does not prove an original zero-hop send. Other adverts have no classified evidence yet. Background backfill improves classification automatically. Available history is a lower bound; older overwritten observations cannot be recovered, and failed evidence writes may leave gaps.') + '
'; } rows.forEach(function (r, i) { var name = r.payload_type || 'UNK'; if (name === 'ADVERT') { - if (r.advert_kind === 'flood') name = 'Flood adverts'; - else if (r.advert_kind === 'zero_hop') name = 'Direct adverts (empty path)'; - else if (r.advert_kind === 'mixed') name = 'Mixed adverts'; - else if (r.advert_kind === 'other') name = 'Other adverts'; + if (r.advert_kind === 'flood') name = tr('Flood adverts'); + else if (r.advert_kind === 'zero_hop') name = tr('Direct adverts (empty path)'); + else if (r.advert_kind === 'mixed') name = tr('Mixed adverts'); + else if (r.advert_kind === 'other') name = tr('Other adverts'); } var cnt = Number(r.count || 0); var cpct = Number(r.count_pct || 0); @@ -620,20 +623,20 @@ : scoreMs.toFixed(2) + ' ms'; var tip = name + '\n' + - 'Count: ' + cnt.toLocaleString() + ' (' + cpct.toFixed(2) + '%)\n' + - 'Airtime: ' + apct.toFixed(2) + '% (score ' + scoreStr + ' · airtime × repeaters)\n' + - 'Score = LoRa Time-on-Air × distinct repeaters. Within-mesh only.'; + tr('Count: {count} ({pct}%)', { count: cnt.toLocaleString(), pct: cpct.toFixed(2) }) + '\n' + + tr('Airtime: {pct}% (score {score} · airtime × repeaters)', { pct: apct.toFixed(2), score: scoreStr }) + '\n' + + tr('Score = LoRa Time-on-Air × distinct repeaters. Within-mesh only.'); html += '
' + '
' + esc(name) + '
' + '
' + '
' + - '
' + - '
' + + '
' + + '
' + '
' + '
' + - 'cnt ' + cpct.toFixed(1) + '%' + + '' + tr('cnt {pct}%', { pct: cpct.toFixed(1) }) + '' + '  ·  ' + - 'air ' + apct.toFixed(1) + '%' + + '' + tr('air {pct}%', { pct: apct.toFixed(1) }) + '' + '
' + '
'; }); @@ -643,7 +646,7 @@ '
0%50%100%
' + '
' + '
'; - html += '
count % airtime %
'; + html += '
' + tr('count %') + ' ' + tr('airtime %') + '
'; html += '
'; return html; } @@ -656,26 +659,26 @@ el.innerHTML = `
-

SNR Distribution

-

Signal-to-Noise Ratio (higher = cleaner signal)

+

${tr('SNR Distribution')}

+

${tr('Signal-to-Noise Ratio (higher = cleaner signal)')}

${snrHist.svg}
- Min: ${sf(rf.snr.min, 1)} dB - Mean: ${sf(rf.snr.avg, 1)} dB - Median: ${sf(rf.snr.median, 1)} dB - Max: ${sf(rf.snr.max, 1)} dB + ${tr('Min:')} ${sf(rf.snr.min, 1)} dB + ${tr('Mean:')} ${sf(rf.snr.avg, 1)} dB + ${tr('Median:')} ${sf(rf.snr.median, 1)} dB + ${tr('Max:')} ${sf(rf.snr.max, 1)} dB σ: ${sf(rf.snr.stddev, 1)} dB
-

RSSI Distribution

-

Received Signal Strength (closer to 0 = stronger)

+

${tr('RSSI Distribution')}

+

${tr('Received Signal Strength (closer to 0 = stronger)')}

${rssiHist.svg}
- Min: ${rf.rssi.min} dBm - Mean: ${sf(rf.rssi.avg, 0)} dBm - Median: ${rf.rssi.median} dBm - Max: ${rf.rssi.max} dBm + ${tr('Min:')} ${rf.rssi.min} dBm + ${tr('Mean:')} ${sf(rf.rssi.avg, 0)} dBm + ${tr('Median:')} ${rf.rssi.median} dBm + ${tr('Max:')} ${rf.rssi.max} dBm σ: ${sf(rf.rssi.stddev, 1)} dBm
@@ -683,31 +686,31 @@
-

SNR vs RSSI Scatter

-

Each dot = one packet. Cluster position reveals link quality.

+

${tr('SNR vs RSSI Scatter')}

+

${tr('Each dot = one packet. Cluster position reveals link quality.')}

${renderScatter(rf.scatterData)}
-

SNR by Payload Type

+

${tr('SNR by Payload Type')}

${renderSNRByType(rf.snrByType)}
-

Signal Quality Over Time

+

${tr('Signal Quality Over Time')}

${renderSignalTimeline(rf.signalOverTime)}
-

Packet Size Distribution

-

Raw packet length in bytes

+

${tr('Packet Size Distribution')}

+

${tr('Raw packet length in bytes')}

${histogram(rf.packetSizes, 25, '#8b5cf6').svg}
- Min: ${rf.minPacketSize} B - Avg: ${rf.avgPacketSize} B - Max: ${rf.maxPacketSize} B + ${tr('Min:')} ${rf.minPacketSize} B + ${tr('Avg:')} ${rf.avgPacketSize} B + ${tr('Max:')} ${rf.maxPacketSize} B
`; @@ -716,7 +719,7 @@ function renderScatter(data) { const w = 600, h = 300, pad = 40; const snrMin = -12, snrMax = 15, rssiMin = -130, rssiMax = -5; - let svg = `SNR vs RSSI scatter plot showing signal quality distribution`; + let svg = `${tr('SNR vs RSSI scatter plot showing signal quality distribution')}`; // Axes svg += ``; svg += ``; @@ -755,7 +758,7 @@ svg += ``; svg += ``; svg += ``; - svg += `${z.label}`; + svg += `${tr(z.label)}`; }); // Dots (sample if too many) const sample = data.length > 500 ? data.filter((_, i) => i % Math.ceil(data.length / 500) === 0) : data; @@ -769,8 +772,8 @@ } function renderSNRByType(snrByType) { - if (!snrByType.length) return '
No data
'; - let html = ''; + if (!snrByType.length) return '
' + tr('No data') + '
'; + let html = '
TypePacketsAvg SNRMinMaxDistribution
'; snrByType.forEach(t => { const barPct = Math.max(((t.avg - (-12)) / 27) * 100, 2); const color = t.avg > 6 ? statusGreen() : t.avg > 0 ? statusYellow() : statusRed(); @@ -787,10 +790,10 @@ } function renderSignalTimeline(data) { - if (!data.length) return '
No data
'; + if (!data.length) return '
' + tr('No data') + '
'; const w = 400, h = 160, pad = 35; const maxPkts = Math.max(...data.map(d => d.count), 1); - let svg = `Signal quality over time showing SNR trend and packet volume`; + let svg = `${tr('Signal quality over time showing SNR trend and packet volume')}`; const snrPts = data.map((d, i) => { const x = pad + i * ((w - pad * 2) / Math.max(data.length - 1, 1)); const y = h - pad - ((d.avgSnr + 12) / 27) * (h - pad * 2); @@ -815,7 +818,7 @@ svg += `${data[i].hour.slice(11)}h`; } svg += ''; - svg += `
Avg SNRVolume
`; + svg += `
${tr('Avg SNR')}${tr('Volume')}
`; return svg; } @@ -824,61 +827,61 @@ el.innerHTML = `
-

Hop Count Distribution

-

Number of repeater hops per packet

+

${tr('Hop Count Distribution')}

+

${tr('Number of repeater hops per packet')}

${barChart(topo.hopDistribution.map(h=>h.count), topo.hopDistribution.map(h=>h.hops), ['#3b82f6'])}
- Avg: ${sf(topo.avgHops, 1)} hops - Median: ${topo.medianHops} - Max: ${topo.maxHops} - 1-hop direct: ${topo.hopDistribution[0]?.count || 0} + ${tr('Avg:')} ${tr('{value} hops', { value: sf(topo.avgHops, 1) })} + ${tr('Median:')} ${topo.medianHops} + ${tr('Max:')} ${topo.maxHops} + ${tr('1-hop direct:')} ${topo.hopDistribution[0]?.count || 0}
-

Top Repeaters

-

Nodes appearing most in packet paths

+

${tr('Top Repeaters')}

+

${tr('Nodes appearing most in packet paths')}

${renderRepeaterTable(topo.topRepeaters)}
-

Retransmission Pressure (proxy)

-

Average number of distinct repeaters seen in the observed paths of each flood packet, over time.

-
Loading…
+

${tr('Retransmission Pressure (proxy)')}

+

${tr('Average number of distinct repeaters seen in the observed paths of each flood packet, over time.')}

+
${tr('Loading…')}
-

Repeater Pair Heatmap

-

Which repeaters frequently appear together in paths

+

${tr('Repeater Pair Heatmap')}

+

${tr('Which repeaters frequently appear together in paths')}

${renderPairTable(topo.topPairs)}
-

Hops vs SNR

-

Does more hops = worse signal?

+

${tr('Hops vs SNR')}

+

${tr('Does more hops = worse signal?')}

${renderHopsSNR(topo.hopsVsSnr)}
-

Best Path to Each Node

-

Shortest hop distance seen across all observers

+

${tr('Best Path to Each Node')}

+

${tr('Shortest hop distance seen across all observers')}

${renderBestPath(topo.bestPathList)}
-

Per-Observer Reachability

-

Nodes at each hop distance, from each observer's perspective

+

${tr('Per-Observer Reachability')}

+

${tr("Nodes at each hop distance, from each observer's perspective")}

${topo.observers.length > 1 ? `
${topo.observers.map((o, i) => ``).join('')} - +
` : ''}
${renderPerObserverReach(topo.perObserverReach, topo.observers[0]?.id)}
${topo.multiObsNodes.length ? `
-

Cross-Observer Comparison

-

Nodes seen by multiple observers — hop distance varies by vantage point

+

${tr('Cross-Observer Comparison')}

+

${tr('Nodes seen by multiple observers — hop distance varies by vantage point')}

${renderCrossObserver(topo.multiObsNodes)}
` : ''} `; @@ -922,7 +925,7 @@ var data = await api('/analytics/retransmissions?' + qs.slice(1), { ttl: CLIENT_TTL.analyticsRF }); html = renderRetransmissionChart(data); } catch (e) { - html = ''; + html = ''; } // A newer request (window/region change) or a tab switch wins. var host = document.getElementById('rtxPressureChart'); @@ -936,7 +939,7 @@ // (dashed). Buckets further apart than bucket_seconds break the lines, so a // gap in data is never drawn as a trend. function renderRetransmissionChart(data) { - var noData = '
No flood packets in this window.
'; + var noData = '
' + tr('No flood packets in this window.') + '
'; var raw = data && Array.isArray(data.buckets) ? data.buckets : []; var pts = []; for (var i = 0; i < raw.length; i++) { @@ -965,7 +968,7 @@ function yObs(v) { return padT + plotH - (v / maxObs) * plotH * 0.9; } function f(n) { return n.toFixed(1); } - var svg = 'Average distinct repeaters per flood packet over time'; + var svg = '' + tr('Average distinct repeaters per flood packet over time') + ''; for (var g = 0; g <= 4; g++) { var gy = padT + plotH * g / 4; svg += ''; @@ -994,7 +997,7 @@ }); // Per-bucket hover targets. pts.forEach(function (p) { - var tip = p.start + '\nAvg distinct repeaters: ' + p.avg.toFixed(1) + '\nFlood packets: ' + p.packets + '\nObservers: ' + p.observers; + var tip = p.start + '\n' + tr('Avg distinct repeaters: {value}', { value: p.avg.toFixed(1) }) + '\n' + tr('Flood packets: {count}', { count: p.packets }) + '\n' + tr('Observers: {count}', { count: p.observers }); svg += '' + esc(tip) + ''; }); var multiDay = tMax - tMin > 2 * 86400000; @@ -1010,32 +1013,25 @@ var oneBytePct = sPackets ? Math.round((Number(s.one_byte_packets) || 0) / sPackets * 100) : 0; var html = svg; html += '
' + - 'Avg distinct repeaters per flood packet' + - 'Observers (dashed, scaled)' + - 'Flood packets (bars, scaled)' + + '' + tr('Avg distinct repeaters per flood packet') + '' + + '' + tr('Observers (dashed, scaled)') + '' + + '' + tr('Flood packets (bars, scaled)') + '' + '
'; html += '
' + - 'Avg: ' + esc((Number(s.avg_repeaters) || 0).toFixed(1)) + ' repeaters/packet' + - 'Flood packets: ' + esc(sPackets.toLocaleString()) + '' + - 'Observers: ' + esc(String(Number(s.observers) || 0)) + '' + - '1-byte hashes: ' + oneBytePct + '%' + - 'Heard without repeaters: ' + esc(String(Number(s.no_repeater_packets) || 0)) + '' + + '' + tr('Avg:') + ' ' + tr('{value} repeaters/packet', { value: esc((Number(s.avg_repeaters) || 0).toFixed(1)) }) + '' + + '' + tr('Flood packets:') + ' ' + esc(sPackets.toLocaleString()) + '' + + '' + tr('Observers:') + ' ' + esc(String(Number(s.observers) || 0)) + '' + + '' + tr('1-byte hashes:') + ' ' + oneBytePct + '%' + + '' + tr('Heard without repeaters:') + ' ' + esc(String(Number(s.no_repeater_packets) || 0)) + '' + '
'; html += '

' + - 'A proxy for collision pressure, not a measured collision rate. ' + - 'It counts only repeaters that at least one observer heard, so observer coverage moves the line too: ' + - 'adding or losing observers (dashed line) changes it without any change on air. ' + - 'Hop prefixes are not resolved to nodes; a prefix counts once per flood, also when two repeaters share it, so the value is a lower bound, ' + - 'most of all for packets on 1-byte hashes. Flood routes only (TRACE and direct routes excluded). ' + - 'A packet heard again more than 5 minutes after its previous observation counts as a new flood; ' + - 'each flood sits in the bucket where it started, so the newest bucket may still be filling. ' + - 'The area filter does not apply to this chart.' + + tr('A proxy for collision pressure, not a measured collision rate. It counts only repeaters that at least one observer heard, so observer coverage moves the line too: adding or losing observers (dashed line) changes it without any change on air. Hop prefixes are not resolved to nodes; a prefix counts once per flood, also when two repeaters share it, so the value is a lower bound, most of all for packets on 1-byte hashes. Flood routes only (TRACE and direct routes excluded). A packet heard again more than 5 minutes after its previous observation counts as a new flood; each flood sits in the bucket where it started, so the newest bucket may still be filling. The area filter does not apply to this chart.') + '

'; return html; } function renderRepeaterTable(repeaters) { - if (!repeaters.length) return '
No data
'; + if (!repeaters.length) return '
' + tr('No data') + '
'; const max = repeaters[0].count; let html = '
'; repeaters.slice(0, 15).forEach(r => { @@ -1050,8 +1046,8 @@ } function renderPairTable(pairs) { - if (!pairs.length) return '
Not enough multi-hop data
'; - let html = '
' + tr('Type') + '' + tr('Packets') + '' + tr('Avg SNR') + '' + tr('Min') + '' + tr('Max') + '' + tr('Distribution') + '
'; + if (!pairs.length) return '
' + tr('Not enough multi-hop data') + '
'; + let html = '
Node ANode BCo-appearances
'; pairs.slice(0, 12).forEach(p => { html += ` @@ -1063,10 +1059,10 @@ } function renderHopsSNR(data) { - if (!data.length) return '
No data
'; + if (!data.length) return '
' + tr('No data') + '
'; const w = 380, h = 160, pad = 40; const maxHop = Math.max(...data.map(d => d.hops)); - let svg = `Hops vs SNR bubble chart showing signal degradation over distance`; + let svg = `${tr('Hops vs SNR bubble chart showing signal degradation over distance')}`; data.forEach(d => { const x = pad + (d.hops / maxHop) * (w - pad * 2); const y = h - pad - ((d.avgSnr + 12) / 27) * (h - pad * 2); @@ -1075,15 +1071,15 @@ svg += ``; svg += `${d.hops}h`; }); - svg += `Hops`; - svg += `Avg SNR`; + svg += `${tr('Hops')}`; + svg += `${tr('Avg SNR')}`; svg += ''; return svg; } function renderPerObserverReach(perObserverReach, obsId) { const data = perObserverReach[obsId]; - if (!data || !data.rings.length) return '
No path data for this observer
'; + if (!data || !data.rings.length) return '
' + tr('No path data for this observer') + '
'; let html = `
`; data.rings.forEach(ring => { const nodeLinks = ring.nodes.slice(0, 8).map(n => { @@ -1091,11 +1087,11 @@ const detail = n.distRange ? ` (${n.distRange})` : ''; return label + detail; }).join(', '); - const extra = ring.nodes.length > 8 ? ` +${ring.nodes.length - 8} more` : ''; + const extra = ring.nodes.length > 8 ? ` ${tr('+{count} more', { count: ring.nodes.length - 8 })}` : ''; html += `
-
${ring.hops} hop${ring.hops > 1 ? 's' : ''}
+
${trn(ring.hops, '{count} hop', '{count} hops')}
${nodeLinks}${extra}
-
${ring.nodes.length} node${ring.nodes.length > 1 ? 's' : ''}
+
${trn(ring.nodes.length, '{count} node', '{count} nodes')}
`; }); return html + '
'; @@ -1107,19 +1103,19 @@ html += `

${esc(data.observer_name)}

`; html += renderPerObserverReach(perObserverReach, obsId); } - return html || '
No data
'; + return html || '
' + tr('No data') + '
'; } function renderCrossObserver(nodes) { - if (!nodes.length) return '
No nodes seen by multiple observers
'; + if (!nodes.length) return '
' + tr('No nodes seen by multiple observers') + '
'; let html = `
' + tr('Node A') + '' + tr('Node B') + '' + tr('Co-appearances') + '
${p.nameA ? `${esc(p.nameA)}` : `${p.hopA}`}
- `; + `; nodes.forEach(n => { const name = n.name ? `${esc(n.name)}` : `${n.hop}`; const obsInfo = n.observers.map(o => - `${esc(o.observer_name)}: ${o.minDist} hop${o.minDist > 1 ? 's' : ''}(${o.count} pkts)` + `${esc(o.observer_name)}: ${trn(o.minDist, '{count} hop', '{count} hops')}(${tr('{count} pkts', { count: o.count })})` ).join('
'); html += ``; }); @@ -1127,7 +1123,7 @@ } function renderBestPath(nodes) { - if (!nodes.length) return '
No data
'; + if (!nodes.length) return '
' + tr('No data') + '
'; // Group by distance for a cleaner view const byDist = {}; nodes.forEach(n => { @@ -1140,13 +1136,13 @@ const label = n.name ? `${esc(n.name)}` : `${n.hop}`; - return label + ` via ${esc(n.observer_name)}`; + return label + ` ${tr('via {name}', { name: esc(n.observer_name) })}`; }).join(', '); - const extra = nodes.length > 10 ? ` +${nodes.length - 10} more` : ''; + const extra = nodes.length > 10 ? ` ${tr('+{count} more', { count: nodes.length - 10 })}` : ''; html += `
-
${dist} hop${+dist > 1 ? 's' : ''}
+
${trn(+dist, '{count} hop', '{count} hops')}
${nodeLinks}${extra}
-
${nodes.length} node${nodes.length > 1 ? 's' : ''}
+
${trn(nodes.length, '{count} node', '{count} nodes')}
`; }); return html + ''; @@ -1220,7 +1216,7 @@ // go through esc() — never trust c.name / c.displayName as HTML. var nameHtml = c.displayNameHtml ? c.displayNameHtml - : esc(c.displayName || c.name || 'Unknown'); + : esc(c.displayName || c.name || tr('Unknown')); return '' + '' + '' + @@ -1263,9 +1259,9 @@ var hex = !isNaN(hashNum) ? ' (0x' + hashNum.toString(16).toUpperCase().padStart(2, '0') + ')' : ''; - copy.displayName = 'Encrypted' + hex; + copy.displayName = tr('Encrypted') + hex; copy.displayNameHtml = - ' Encrypted' + hex; + ' ' + tr('Encrypted') + hex; copy.group = 'encrypted'; } else { // Server gave us a real name (rainbow table hit) for an encrypted ch. @@ -1273,7 +1269,7 @@ copy.group = 'network'; } } else { - copy.displayName = rawName || 'Unknown'; + copy.displayName = rawName || tr('Unknown'); copy.group = 'network'; } out.push(copy); @@ -1313,9 +1309,9 @@ (groups[g] || (groups[g] = [])).push(sorted[gi]); } var sections = [ - { key: 'mine', label: ' My Channels' }, - { key: 'network', label: ' Network' }, - { key: 'encrypted', label: ' Encrypted' }, + { key: 'mine', label: ' ' + tr('My Channels') }, + { key: 'network', label: ' ' + tr('Network') }, + { key: 'encrypted', label: ' ' + tr('Encrypted') }, ]; for (var si = 0; si < sections.length; si++) { var rows = groups[sections[si].key] || []; @@ -1355,7 +1351,7 @@ for (var i = 0; i < cols.length; i++) { var c = cols[i]; ths += ''; + tr(c.label) + channelSortArrow(c.key, activeCol, dir) + ''; } return '' + ths + ''; } @@ -1400,12 +1396,12 @@ var timelineHtml = renderChannelTimeline(ch.channelTimeline); var topSendersHtml = renderTopSenders(ch.topSenders); - var histoHtml = ch.msgLengths.length ? histogram(ch.msgLengths, 20, '#8b5cf6').svg : '
No decrypted messages
'; + var histoHtml = ch.msgLengths.length ? histogram(ch.msgLengths, 20, '#8b5cf6').svg : '
' + tr('No decrypted messages') + '
'; el.innerHTML = '
' + - '

Channel Activity

' + - '

' + ch.activeChannels + ' active channels, ' + ch.decryptable + ' decryptable

' + + '

' + tr('Channel Activity') + '

' + + '

' + tr('{active} active channels, {decryptable} decryptable', { active: ch.activeChannels, decryptable: ch.decryptable }) + '

' + '
NodeObserversHop Distances
${tr('Node')}${tr('Observers')}${tr('Hop Distances')}
${name}${n.observers.length}${obsInfo}
' + nameHtml + '' + (typeof c.hash === 'number' ? '0x' + c.hash.toString(16).toUpperCase().padStart(2, '0') : c.hash) + '' + - c.label + channelSortArrow(c.key, activeCol, dir) + '
' + channelTheadHtml(_channelSortState.col, _channelSortState.dir) + '' + @@ -1415,16 +1411,16 @@ '' + '
' + '
' + - '

Messages / Hour by Channel

' + + '

' + tr('Messages / Hour by Channel') + '

' + timelineHtml + '
' + '
' + - '

Top Senders

' + + '

' + tr('Top Senders') + '

' + topSendersHtml + '
' + '
' + '
' + - '

Message Length Distribution

' + + '

' + tr('Message Length Distribution') + '

' + histoHtml + '
'; @@ -1450,7 +1446,7 @@ var CHANNEL_TIMELINE_MAX_SERIES = 8; function renderChannelTimeline(data) { - if (!data.length) return '
No data
'; + if (!data.length) return '
' + tr('No data') + '
'; var hours = []; var hourSet = {}; var channelList = []; var channelSet = {}; var lookup = {}; @@ -1480,7 +1476,7 @@ var w = 600, h = 180, pad = 35; var xScale = (w - pad * 2) / Math.max(hours.length - 1, 1); var yScale = (h - pad * 2) / maxCount; - var svg = 'Channel message activity over time'; + var svg = '' + tr('Channel message activity over time') + ''; for (var ci = 0; ci < visibleChannels.length; ci++) { var pts = []; for (var hi = 0; hi < hours.length; hi++) { @@ -1502,21 +1498,21 @@ legendParts.push('' + esc(visibleChannels[lci]) + ''); } if (hiddenCount > 0) { - legendParts.push('+' + hiddenCount + ' more'); + legendParts.push('' + tr('+{count} more', { count: hiddenCount }) + ''); } svg += '
' + legendParts.join('') + '
'; return svg; } function renderTopSenders(senders) { - if (!senders.length) return '
No decrypted messages
'; + if (!senders.length) return '
' + tr('No decrypted messages') + '
'; const max = senders[0].count; let html = '
'; senders.slice(0, 10).forEach(s => { html += `
${esc(s.name)}
-
${s.count} msgs
+
${tr('{count} msgs', { count: s.count })}
`; }); return html + '
'; @@ -1532,15 +1528,15 @@ el.innerHTML = `
-

Hash Size Distribution

-

${total.toLocaleString()} packets with path hops

+

${tr('Hash Size Distribution')}

+

${tr('{count} packets with path hops', { count: total.toLocaleString() })}

${[1, 2, 3].map(size => { const count = d[size] || 0; const width = Math.max((count / maxCount) * 100, count ? 2 : 0); const colors = { 1: '#ef4444', 2: '#22c55e', 3: '#3b82f6' }; return `
-
${size}-byte (${size * 8}-bit, ${Math.pow(256, size).toLocaleString()} IDs)
+
${trn(size, '{count}-byte', '{count}-byte')} (${tr('{bits}-bit, {ids} IDs', { bits: size * 8, ids: Math.pow(256, size).toLocaleString() })})
${count.toLocaleString()} (${pct(count)}%)
`; @@ -1552,14 +1548,14 @@ const rpct = (n) => totalRepeaters ? (n / totalRepeaters * 100).toFixed(1) : '0'; const maxRepeaters = Math.max(dr[1] || 0, dr[2] || 0, dr[3] || 0, 1); const colors = { 1: '#ef4444', 2: '#22c55e', 3: '#3b82f6' }; - return `

By Repeaters

-

${totalRepeaters.toLocaleString()} unique repeaters

+ return `

${tr('By Repeaters')}

+

${tr('{count} unique repeaters', { count: totalRepeaters.toLocaleString() })}

${[1, 2, 3].map(size => { const count = dr[size] || 0; const width = Math.max((count / maxRepeaters) * 100, count ? 2 : 0); return `
-
${size}-byte
+
${trn(size, '{count}-byte', '{count}-byte')}
${count.toLocaleString()} (${rpct(count)}%)
`; @@ -1568,7 +1564,7 @@ })() : ''}
-

Hash Size Over Time

+

${tr('Hash Size Over Time')}

${renderHashTimeline(data.hourly)}
@@ -1577,16 +1573,16 @@
-

Top Path Hops

+

${tr('Top Path Hops')}

- + ${data.topHops.map(h => { const link = h.pubkey ? `#/nodes/${encodeURIComponent(h.pubkey)}` : `#/packets?search=${h.hex}`; return ` - - + + `; }).join('')} @@ -1624,48 +1620,48 @@ var filtered = filter === 'all' ? rows : rows.filter(function(r) { return r.status === filter; }); return (filtered.length ? '
HopNodeBytesAppearances
${tr('Hop')}${tr('Node')}${tr('Bytes')}${tr('Appearances')}
${h.hex}${h.name ? `${esc(h.name)}` : 'unknown'}${h.size}-byte${h.name ? `${esc(h.name)}` : '' + tr('unknown') + ''}${trn(h.size, '{count}-byte', '{count}-byte')} ${h.count.toLocaleString()}
' + '' + - '' + - '' + - '' + - '' + - '' + - '' + + '' + + '' + + '' + + '' + + '' + + '' + '' + '' + filtered.map(function(r) { var roleColor = (window.ROLE_COLORS || {})[r.role] || '#6b7280'; return '' + '' + - '' + + '' + '' + - '' + + (statusIcon[r.status] || '') + ' ' + tr(statusLabel[r.status] || 'Unknown') + '' + + '' + '' + '' + ''; }).join('') + '' + - '
NodeRoleStatusHash SizeAdvertsLast Seen' + tr('Node') + '' + tr('Role') + '' + tr('Status') + '' + tr('Hash Size') + '' + tr('Adverts') + '' + tr('Last Seen') + '
' + esc(r.name) + '' + esc(r.role || 'unknown') + '' + esc(r.role ? tr(r.role) : tr('unknown')) + '' + - (statusIcon[r.status] || '') + ' ' + (statusLabel[r.status] || 'Unknown') + '' + r.hashSize + '-byte' + trn(r.hashSize, '{count}-byte', '{count}-byte') + '' + r.packets + '' + (r.lastSeen ? timeAgo(r.lastSeen) : '—') + '
' : '
No adopters match this filter.
'); + '' : '
' + tr('No adopters match this filter.') + '
'); } if (!rows.length) return '
' + - '

Multi-Byte Hash Adopters

' + - '
No multi-byte adopters found
'; + '

' + tr('Multi-Byte Hash Adopters') + '

' + + '
' + tr('No multi-byte adopters found') + '
'; var html = '
' + '
' + '
' + - '

Multi-Byte Hash Adopters

' + - '

Nodes advertising with 2+ byte hash paths. ' + - 'Confirmed = seen advertising with multi-byte hash. ' + - 'Suspected = prefix appeared in a multi-byte path. ' + - 'Unknown = no multi-byte evidence yet.

' + + '

' + tr('Multi-Byte Hash Adopters') + '

' + + '

' + tr('Nodes advertising with 2+ byte hash paths.') + ' ' + + '' + tr('Confirmed') + ' = ' + tr('seen advertising with multi-byte hash.') + ' ' + + '' + tr('Suspected') + ' = ' + tr('prefix appeared in a multi-byte path.') + ' ' + + '' + tr('Unknown') + ' = ' + tr('no multi-byte evidence yet.') + '

' + '
' + '
' + - '' + - '' + - '' + - '' + + '' + + '' + + '' + + '' + '
' + '
' + '
' + buildTableContent(rows, 'all') + '
' + @@ -1730,41 +1726,50 @@ async function renderCollisionTab(el, data, collisionData) { el.innerHTML = ` -

Collisions actually observed in packet traffic — among repeaters grouped by their configured hash size. For theoretical address conflicts that would occur if all repeaters used a given hash size, see the Prefix Tool tab.

+

${tr('Collisions {observed} — among {repeaters} grouped by their configured hash size. For {theoretical} address conflicts that {would} occur if all repeaters used a given hash size, see the {link} tab.', { + observed: '' + tr('actually observed in packet traffic') + '', + repeaters: '' + tr('repeaters') + '', + theoretical: '' + tr('theoretical') + '', + would: '' + tr('would') + '', + link: '' + tr('Prefix Tool') + '', + })}

-

Inconsistent Hash Sizes

↑ top
-

Repeaters and room servers sending adverts with varying hash sizes in the last 7 days. Originally caused by a firmware bug where automatic adverts ignored the configured multibyte path setting, fixed in repeater v1.14.1. Companion nodes are excluded.

-
Loading…
+

${tr('Inconsistent Hash Sizes')}

↑ ${tr('top')}
+

${tr('Repeaters and room servers sending adverts with varying hash sizes in the last 7 days. Originally caused by a {bug} where automatic adverts ignored the configured multibyte path setting, fixed in {release}. Companion nodes are excluded.', { + bug: '' + tr('firmware bug') + '', + release: 'repeater v1.14.1', + })}

+
${tr('Loading…')}
-

Hash Usage Matrix

- ↑ top +

${tr('Hash Usage Matrix')}

+ ↑ ${tr('top')}
- - - + + +
-

Click a cell to see which nodes share that prefix.

+

${tr('Click a cell to see which nodes share that prefix.')}

-

Collision Risk

↑ top
-
Loading…
+

${tr('Collision Risk')}

↑ ${tr('top')}
+
${tr('Loading…')}
`; // Use pre-computed collision data from server (no more /nodes?limit=2000 fetch) @@ -1773,10 +1778,10 @@ const ihEl = document.getElementById('inconsistentHashList'); if (ihEl) { if (!inconsistent.length) { - ihEl.innerHTML = '
No inconsistencies detected — all nodes are reporting consistent hash sizes.
'; + ihEl.innerHTML = '
' + tr('No inconsistencies detected — all nodes are reporting consistent hash sizes.') + '
'; } else { ihEl.innerHTML = ` - + ${inconsistent.map((n, i) => { const roleColor = window.ROLE_COLORS?.[n.role] || '#6b7280'; const prefix = n.hash_size ? n.public_key.slice(0, n.hash_size * 2).toUpperCase() : '?'; @@ -1788,13 +1793,13 @@ const stripe = i % 2 === 1 ? 'background:var(--row-stripe)' : ''; return ` - + `; }).join('')}
NodeRoleCurrent HashSizes Seen
${tr('Node')}${tr('Role')}${tr('Current Hash')}${tr('Sizes Seen')}
${esc(n.name || n.public_key.slice(0, 12))}${n.role}${tr(n.role)} ${prefix} (${n.hash_size || '?'}B) ${sizeBadges}
-

${inconsistent.length} node${inconsistent.length > 1 ? 's' : ''} affected. Click a node name to see which adverts have different hash sizes.

`; +

${trn(inconsistent.length, '{count} node affected.', '{count} nodes affected.')} ${tr('Click a node name to see which adverts have different hash sizes.')}

`; } } @@ -1818,12 +1823,12 @@ const matrixTitle = document.getElementById('hashMatrixTitle'); const matrixDesc = document.getElementById('hashMatrixDesc'); const riskTitle = document.getElementById('collisionRiskTitle'); - if (matrixTitle) matrixTitle.innerHTML = bytes === 3 ? ' Hash Usage Matrix' : ` ${bytes}-Byte Hash Usage Matrix`; - if (riskTitle) riskTitle.innerHTML = ` ${bytes}-Byte Collision Risk`; + if (matrixTitle) matrixTitle.innerHTML = bytes === 3 ? ' ' + tr('Hash Usage Matrix') : ` ${trn(bytes, '{count}-Byte Hash Usage Matrix', '{count}-Byte Hash Usage Matrix')}`; + if (riskTitle) riskTitle.innerHTML = ` ${trn(bytes, '{count}-Byte Collision Risk', '{count}-Byte Collision Risk')}`; if (matrixDesc) { - if (bytes === 1) matrixDesc.textContent = 'Cells include the first byte of all repeaters — including those using 2- or 3-byte prefixes — so this reflects real conflicts in the 1-byte hash space. Click a cell to see the nodes.'; - else if (bytes === 2) matrixDesc.textContent = 'Each cell = first-byte group. Color shows worst 2-byte collision within. Click a cell to see the breakdown.'; - else matrixDesc.textContent = '3-byte prefix space is too large to visualize as a matrix — collision table is shown below.'; + if (bytes === 1) matrixDesc.textContent = tr('Cells include the first byte of all repeaters — including those using 2- or 3-byte prefixes — so this reflects real conflicts in the 1-byte hash space. Click a cell to see the nodes.'); + else if (bytes === 2) matrixDesc.textContent = tr('Each cell = first-byte group. Color shows worst 2-byte collision within. Click a cell to see the breakdown.'); + else matrixDesc.textContent = tr('3-byte prefix space is too large to visualize as a matrix — collision table is shown below.'); } renderHashMatrixFromServer(cData.by_size[String(bytes)], bytes); // Show collision risk section for all byte sizes @@ -1843,11 +1848,11 @@ } function renderHashTimeline(hourly) { - if (!hourly.length) return '
Not enough data
'; + if (!hourly.length) return '
' + tr('Not enough data') + '
'; const w = 800, h = 180, pad = 35; const maxVal = Math.max(...hourly.map(h => Math.max(h[1] || 0, h[2] || 0, h[3] || 0)), 1); const colors = { 1: '#ef4444', 2: '#22c55e', 3: '#3b82f6' }; - let svg = `Hash size distribution over time showing 1-byte, 2-byte, and 3-byte hash trends`; + let svg = `${tr('Hash size distribution over time showing 1-byte, 2-byte, and 3-byte hash trends')}`; for (const size of [1, 2, 3]) { const pts = hourly.map((d, i) => { const x = pad + i * ((w - pad * 2) / Math.max(hourly.length - 1, 1)); @@ -1862,7 +1867,7 @@ svg += `${hourly[i].hour.slice(11)}h`; } svg += ''; - svg += `
1-byte2-byte3-byte
`; + svg += `
${trn(1, '{count}-byte', '{count}-byte')}${trn(2, '{count}-byte', '{count}-byte')}${trn(3, '{count}-byte', '{count}-byte')}
`; return svg; } @@ -1950,20 +1955,20 @@ const spaceLabel = spaceSize >= 1e6 ? (spaceSize / 1e6).toFixed(1) + 'M' : spaceSize.toLocaleString(); return `
-
Nodes tracked
+
${tr('Nodes tracked')}
${totalNodes.toLocaleString()}
-
Using ${sizeLabel} ID
+
${tr('Using {size} ID', { size: sizeLabel })}
${usingCount.toLocaleString()}
-
Prefix space used
+
${tr('Prefix space used')}
${pctStr}%
-
${usedCount > 256 ? usedCount + ' of ' : 'of '}${spaceLabel} possible
+
${usedCount > 256 ? tr('{used} of {space} possible', { used: usedCount, space: spaceLabel }) : tr('of {space} possible', { space: spaceLabel })}
-
0 ? 'onclick="document.getElementById(\'collisionRiskSection\')?.scrollIntoView({behavior:\'smooth\',block:\'start\'})"' : ''} ${collisionCount > 0 ? 'title="Click to see collision details"' : ''}> -
Prefix collisions
+
0 ? 'onclick="document.getElementById(\'collisionRiskSection\')?.scrollIntoView({behavior:\'smooth\',block:\'start\'})"' : ''} ${collisionCount > 0 ? 'title="' + tr('Click to see collision details') + '"' : ''}> +
${tr('Prefix collisions')}
${collisionCount}${collisionCount > 0 ? ' ' : ''}
`; @@ -1987,7 +1992,7 @@ function hashMatrixLegendHtml(labels) { return `
- ${labels.map(l => ` ${l.text}`).join('\n')} + ${labels.map(l => ` ${tr(l.text)}`).join('\n')}
`; } @@ -2064,15 +2069,15 @@ function renderHashMatrixFromServer(sizeData, bytes) { const el = document.getElementById('hashMatrix'); - if (!sizeData) { el.innerHTML = '
No data
'; return; } + if (!sizeData) { el.innerHTML = '
' + tr('No data') + '
'; return; } const stats = sizeData.stats || {}; const totalNodes = stats.total_nodes || 0; // 3-byte: show a summary panel instead of a matrix if (bytes === 3) { - el.innerHTML = hashStatCardsHtml(totalNodes, stats.using_this_size || 0, '3-byte', 16777216, stats.unique_prefixes || 0, stats.collision_count || 0) + - `

The 3-byte prefix space (16.7M values) is too large to visualize as a grid.${(stats.collision_count || 0) > 0 ? ' See collision details below.' : ''}

` + - `

ℹ️ This tab only counts collisions among repeaters configured for this hash size. The Prefix Tool checks all repeaters regardless of configured hash size.

`; + el.innerHTML = hashStatCardsHtml(totalNodes, stats.using_this_size || 0, trn(3, '{count}-byte', '{count}-byte'), 16777216, stats.unique_prefixes || 0, stats.collision_count || 0) + + `

${tr('The 3-byte prefix space (16.7M values) is too large to visualize as a grid.')}${(stats.collision_count || 0) > 0 ? ' ' + tr('See collision details below.') : ''}

` + + `

ℹ️ ${tr('This tab only counts collisions among repeaters configured for this hash size. The {link} checks all repeaters regardless of configured hash size.', { link: '' + tr('Prefix Tool') + '' })}

`; return; } @@ -2083,7 +2088,7 @@ const oneCollisions = Object.values(oneByteCells).filter(v => v.length > 1).length; renderHashMatrixPanel(el, - hashStatCardsHtml(totalNodes, oneByteCount, '1-byte', 256, oneUsed, oneCollisions), + hashStatCardsHtml(totalNodes, oneByteCount, trn(1, '{count}-byte', '{count}-byte'), 256, oneUsed, oneCollisions), (hex, cs) => { const nodes = oneByteCells[hex] || []; const count = nodes.length; @@ -2091,13 +2096,13 @@ const isCollision = count >= 2 && repeaterCount >= 2; const isPossible = count >= 2 && !isCollision; const { cls, bg } = classifyHashCell(count, isCollision, isPossible); - const nodeLabel = m => (m.name || m.public_key.slice(0,12)) + (!m.role ? ' (unknown role)' : ''); + const nodeLabel = m => (m.name || m.public_key.slice(0,12)) + (!m.role ? ' (' + tr('unknown role') + ')' : ''); const previewLines = nodes.slice(0,5).map(nodeLabel); - if (nodes.length > 5) previewLines.push(`+${nodes.length-5} more`); - const tip = count === 0 ? hashTooltipSpec(`0x${hex}`, 'Available') - : count === 1 ? hashTooltipSpec(`0x${hex}`, 'One node — no collision', [nodeLabel(nodes[0])]) - : isPossible ? hashTooltipSpec(`0x${hex}`, `${count} nodes — POSSIBLE CONFLICT`, previewLines) - : hashTooltipSpec(`0x${hex}`, `${count} nodes — COLLISION`, previewLines); + if (nodes.length > 5) previewLines.push(tr('+{count} more', { count: nodes.length-5 })); + const tip = count === 0 ? hashTooltipSpec(`0x${hex}`, tr('Available')) + : count === 1 ? hashTooltipSpec(`0x${hex}`, tr('One node — no collision'), [nodeLabel(nodes[0])]) + : isPossible ? hashTooltipSpec(`0x${hex}`, tr('{count} nodes — POSSIBLE CONFLICT', { count: count }), previewLines) + : hashTooltipSpec(`0x${hex}`, tr('{count} nodes — COLLISION', { count: count }), previewLines); return hashCellTd(hex, cs, cls, bg, count, tip, count >= 2 ? '700' : '400'); }, 400, @@ -2111,11 +2116,11 @@ const hex = td.dataset.hex.toUpperCase(); const matches = oneByteCells[hex] || []; const detail = document.getElementById('hashDetail'); - if (!matches.length) { detail.innerHTML = `0x${hex}
No known nodes`; return; } - detail.innerHTML = `0x${hex} — ${matches.length} node${matches.length !== 1 ? 's' : ''}` + + if (!matches.length) { detail.innerHTML = `0x${hex}
${tr('No known nodes')}`; return; } + detail.innerHTML = `0x${hex} — ${trn(matches.length, '{count} node', '{count} nodes')}` + `
${matches.map(m => { - const coords = (m.lat && m.lon && !(m.lat === 0 && m.lon === 0)) ? `(${m.lat.toFixed(2)}, ${m.lon.toFixed(2)})` : '(no coords)'; - const role = m.role ? `${esc(m.role)} ` : ''; + const coords = (m.lat && m.lon && !(m.lat === 0 && m.lon === 0)) ? `(${m.lat.toFixed(2)}, ${m.lon.toFixed(2)})` : '(' + tr('no coords') + ')'; + const role = m.role ? `${esc(tr(m.role))} ` : ''; return `
${role}${esc(m.name || m.public_key.slice(0,12))} ${coords}
`; }).join('')}
`; } @@ -2128,7 +2133,7 @@ const twoCollisions = Object.values(twoByteCells).filter(v => v.collision_count > 0).length; renderHashMatrixPanel(el, - hashStatCardsHtml(totalNodes, twoByteCount, '2-byte', 65536, uniqueTwoBytePrefixes, twoCollisions), + hashStatCardsHtml(totalNodes, twoByteCount, trn(2, '{count}-byte', '{count}-byte'), 65536, uniqueTwoBytePrefixes, twoCollisions), (hex, cs) => { const info = twoByteCells[hex] || { group_nodes: [], max_collision: 0, collision_count: 0, two_byte_map: {} }; const nodeCount = (info.group_nodes || []).length; @@ -2139,11 +2144,11 @@ const { cls, bg } = classifyHashCell(maxCol > 0 ? maxCol : nodeCount === 0 ? 0 : 1, hasConfirmed, hasPossible); const nodeLabel2 = m => (m.name || m.public_key.slice(0,8)) + (!m.role ? ' (?)' : ''); const tip = nodeCount === 0 - ? hashTooltipSpec(`0x${hex}__`, 'No nodes in this group') + ? hashTooltipSpec(`0x${hex}__`, tr('No nodes in this group')) : (info.collision_count || 0) === 0 - ? hashTooltipSpec(`0x${hex}__`, `${nodeCount} node${nodeCount>1?'s':''} — no 2-byte collisions`) + ? hashTooltipSpec(`0x${hex}__`, trn(nodeCount, '{count} node — no 2-byte collisions', '{count} nodes — no 2-byte collisions')) : hashTooltipSpec(`0x${hex}__`, - hasConfirmed ? (info.collision_count||0) + ' collision' + ((info.collision_count||0)>1?'s':'') : 'Possible conflict', + hasConfirmed ? trn(info.collision_count || 0, '{count} collision', '{count} collisions') : tr('Possible conflict'), Object.entries(info.two_byte_map||{}).filter(([,v])=>v.length>1).slice(0,4).map(([p,ns])=>`${p} — ${ns.map(nodeLabel2).join(', ')}`)); return hashCellTd(hex, cs, cls, bg, nodeCount, tip, maxCol > 0 ? '700' : '400'); }, @@ -2160,9 +2165,9 @@ const detail = document.getElementById('hashDetail'); if (!info || !(info.group_nodes || []).length) { detail.innerHTML = ''; return; } const groupNodes = info.group_nodes || []; - let dhtml = `0x${hex}__ — ${groupNodes.length} node${groupNodes.length !== 1 ? 's' : ''} in group`; + let dhtml = `0x${hex}__ — ${trn(groupNodes.length, '{count} node in group', '{count} nodes in group')}`; if ((info.collision_count || 0) === 0) { - dhtml += `
No 2-byte collisions in this group
`; + dhtml += `
${tr('No 2-byte collisions in this group')}
`; dhtml += `
${groupNodes.map(m => { const prefix = m.public_key.slice(0,4).toUpperCase(); return `
${prefix} ${esc(m.name || m.public_key.slice(0,12))}
`; @@ -2172,7 +2177,7 @@ for (const [twoHex, nodes] of Object.entries(info.two_byte_map || {}).sort()) { const isCollision = nodes.length > 1; dhtml += `
`; - dhtml += `${twoHex}${isCollision ? ' COLLISION' : ''} `; + dhtml += `${twoHex}${isCollision ? ' ' + tr('COLLISION') + '' : ''} `; dhtml += nodes.map(m => `${esc(m.name || m.public_key.slice(0,12))}`).join(', '); dhtml += `
`; } @@ -2186,13 +2191,13 @@ function renderCollisionsFromServer(sizeData, bytes) { const el = document.getElementById('collisionList'); - if (!sizeData) { el.innerHTML = '
No data
'; return; } + if (!sizeData) { el.innerHTML = '
' + tr('No data') + '
'; return; } const collisions = sizeData.collisions || []; if (!collisions.length) { const cleanMsg = bytes === 3 - ? ' No 3-byte prefix collisions detected — all repeaters have unique 3-byte prefixes.' - : ` No ${bytes}-byte collisions detected`; + ? ' ' + tr('No 3-byte prefix collisions detected — all repeaters have unique 3-byte prefixes.') + : ` ${tr('No {count}-byte collisions detected', { count: bytes })}`; el.innerHTML = `
${cleanMsg}
`; return; } @@ -2202,26 +2207,26 @@ const t200 = formatDistanceRound(200); el.innerHTML = ` - - ${showAppearances ? '' : ''} - - - + + ${showAppearances ? '' : ''} + + + ${collisions.map(c => { let badge, tooltip; if (c.classification === 'local') { - badge = ` Local`; - tooltip = 'Nodes close enough for direct RF — probably genuine prefix collision'; + badge = ` ${tr('Local')}`; + tooltip = tr('Nodes close enough for direct RF — probably genuine prefix collision'); } else if (c.classification === 'regional') { - badge = ` Regional`; - tooltip = 'At edge of 915MHz range — could indicate atmospheric ducting or hilltop-to-hilltop links'; + badge = ` ${tr('Regional')}`; + tooltip = tr('At edge of 915MHz range — could indicate atmospheric ducting or hilltop-to-hilltop links'); } else if (c.classification === 'distant') { - badge = ` Distant`; - tooltip = 'Beyond typical LoRa range — likely internet bridging, MQTT gateway, or separate mesh networks sharing prefix'; + badge = ` ${tr('Distant')}`; + tooltip = tr('Beyond typical LoRa range — likely internet bridging, MQTT gateway, or separate mesh networks sharing prefix'); } else { - badge = ' Unknown'; - tooltip = 'Not enough coordinate data to classify'; + badge = ' ' + tr('Unknown') + ''; + tooltip = tr('Not enough coordinate data to classify'); } const nodes = c.nodes || []; const distStr = c.with_coords >= 2 ? formatDistanceRound(c.max_dist_km) : '—'; @@ -2233,20 +2238,20 @@ `; }).join('')}
PrefixAppearancesMax DistanceAssessmentColliding Nodes${tr('Prefix')}' + tr('Appearances') + '${tr('Max Distance')}${tr('Assessment')}${tr('Colliding Nodes')}
${nodes.map(m => { const loc = (m.lat && m.lon && !(m.lat === 0 && m.lon === 0)) ? ` (${m.lat.toFixed(2)}, ${m.lon.toFixed(2)})` - : ' (no coords)'; + : ' (' + tr('no coords') + ')'; return `${esc(m.name || m.public_key.slice(0,12))}${loc}`; }).join('
')}
- Local <${t50}: true prefix collision, same mesh area   - Regional ${t50}–${t200}: edge of LoRa range, possible atmospheric propagation   - Distant >${t200}: beyond 915MHz range — internet bridge, MQTT gateway, or separate networks + ${tr('Local')} <${t50}: ${tr('true prefix collision, same mesh area')}   + ${tr('Regional')} ${t50}–${t200}: ${tr('edge of LoRa range, possible atmospheric propagation')}   + ${tr('Distant')} >${t200}: ${tr('beyond 915MHz range — internet bridge, MQTT gateway, or separate networks')}
`; } async function renderSubpaths(el) { - el.innerHTML = '
Analyzing route patterns…
'; + el.innerHTML = '
' + tr('Analyzing route patterns…') + '
'; try { const rq = RegionFilter.regionQueryString(); // Issue #1217: thread the Time window picker into the Route Patterns @@ -2258,7 +2263,7 @@ const [d2, d3, d4, d5] = bulk.results; function renderTable(data, title) { - if (!data.subpaths.length) return `

${title}

No data
`; + if (!data.subpaths.length) return `

${title}

${tr('No data')}
`; // #1633 — when "Hide 1-byte path hops" is ON, filter route patterns // whose underlying rawHops contain any 1-byte hex token. We filter // INPUT (not just CSS-hide) so the displayed % and ordering reflect @@ -2285,19 +2290,19 @@ : data.subpaths; if (!subpaths.length) { var _reason = []; - if (_hide1) _reason.push('1-byte hide toggle'); - if (_trustTT >= 2) _reason.push(_trustTT + '-byte trust threshold'); - return `

${title}

No data (all matching routes filtered: ${_reason.join(' + ')} — adjust in customizer or config.json)
`; + if (_hide1) _reason.push(tr('1-byte hide toggle')); + if (_trustTT >= 2) _reason.push(trn(_trustTT, '{count}-byte trust threshold', '{count}-byte trust threshold')); + return `

${title}

${tr('No data (all matching routes filtered: {reasons} — adjust in customizer or config.json)', { reasons: _reason.join(' + ') })}
`; } const maxCount = subpaths[0]?.count || 1; var _filterNote = ''; - if (_hide1 && _trustTT >= 2) _filterNote = ` · showing ${subpaths.length} of ${data.subpaths.length} (1-byte hide + ${_trustTT}-byte trust)`; - else if (_hide1) _filterNote = ` · showing ${subpaths.length} of ${data.subpaths.length} (1-byte filtered)`; - else if (_trustTT >= 2) _filterNote = ` · showing ${subpaths.length} of ${data.subpaths.length} (${_trustTT}-byte trust threshold applied)`; + if (_hide1 && _trustTT >= 2) _filterNote = ' · ' + tr('showing {shown} of {total} (1-byte hide + {count}-byte trust)', { shown: subpaths.length, total: data.subpaths.length, count: _trustTT }); + else if (_hide1) _filterNote = ' · ' + tr('showing {shown} of {total} (1-byte filtered)', { shown: subpaths.length, total: data.subpaths.length }); + else if (_trustTT >= 2) _filterNote = ' · ' + tr('showing {shown} of {total} ({count}-byte trust threshold applied)', { shown: subpaths.length, total: data.subpaths.length, count: _trustTT }); return `

${title}

-

From ${data.totalPaths.toLocaleString()} paths with 2+ hops${_filterNote}

+

${tr('From {count} paths with 2+ hops', { count: data.totalPaths.toLocaleString() })}${_filterNote}

- + ${subpaths.map((s, i) => { const barW = Math.max(2, Math.round(s.count / maxCount * 100)); @@ -2308,7 +2313,7 @@ const prefixDisplay = rawHops.join(' → '); return ` - + @@ -2320,25 +2325,25 @@ el.innerHTML = `
-

Route Pattern Analysis

-

Click a route to see details. Most common subpaths — reveals backbone routes, bottlenecks, and preferred relay chains.

+

${tr('Route Pattern Analysis')}

+

${tr('Click a route to see details. Most common subpaths — reveals backbone routes, bottlenecks, and preferred relay chains.')}

- Jump to: - Pairs - Triples - Quads - 5+ hops + ${tr('Jump to:')} + ${tr('Pairs')} + ${tr('Triples')} + ${tr('Quads')} + ${tr('5+ hops')}
-
${renderTable(d2, 'Pairs (2-hop links)')}
-
${renderTable(d3, 'Triples (3-hop chains)')}
-
${renderTable(d4, 'Quads (4-hop chains)')}
-
${renderTable(d5, 'Long chains (5+ hops)')}
+
${renderTable(d2, tr('Pairs (2-hop links)'))}
+
${renderTable(d3, tr('Triples (3-hop chains)'))}
+
${renderTable(d4, tr('Quads (4-hop chains)'))}
+
${renderTable(d5, tr('Long chains (5+ hops)'))}
`; @@ -2370,19 +2375,19 @@ toggle.addEventListener('change', applyCollisionFilter); applyCollisionFilter(); } catch (e) { - el.innerHTML = `
Error loading subpath data: ${e.message}
`; + el.innerHTML = `
${tr('Error loading subpath data: {message}', { message: esc(e.message) })}
`; } } async function loadSubpathDetail(hopsStr) { const panel = document.getElementById('subpathDetail'); panel.classList.remove('collapsed'); - panel.innerHTML = '
Loading…
'; + panel.innerHTML = '
' + tr('Loading…') + '
'; try { const data = await api('/analytics/subpath-detail?hops=' + encodeURIComponent(hopsStr), { ttl: CLIENT_TTL.analyticsRF }); renderSubpathDetail(panel, data); } catch (e) { - panel.innerHTML = `
Error: ${e.message}
`; + panel.innerHTML = `
${tr('Error: {message}', { message: esc(e.message) })}
`; } } @@ -2396,11 +2401,11 @@

${data.nodes.map(n => esc(n.name)).join(' → ')}

${data.hops.join(' → ')} - ${data.totalMatches.toLocaleString()} occurrences + ${tr('{count} occurrences', { count: data.totalMatches.toLocaleString() })}
${nodesWithLoc.length >= 2 ? `
-
Hop Distances
+
${tr('Hop Distances')}
${(() => { const dists = []; let total = 0; @@ -2414,10 +2419,10 @@ const cls = km > 200 ? 'color:var(--status-red);font-weight:bold' : km > 50 ? 'color:var(--status-yellow)' : 'color:var(--status-green-text)'; dists.push(`
${formatDistance(km)} ${esc(a.name)} → ${esc(b.name)}
`); } else { - dists.push(`
? ${esc(a.name)} → ${esc(b.name)} (no coords)
`); + dists.push(`
? ${esc(a.name)} → ${esc(b.name)} (${tr('no coords')})
`); } } - if (dists.length > 1) dists.push(`
Total: ${formatDistance(total)}
`); + if (dists.length > 1) dists.push(`
${tr('Total: {distance}', { distance: formatDistance(total) })}
`); return dists.join(''); })()}
` : ''} @@ -2425,36 +2430,36 @@ ${hasMap ? '
' : ''}
-
Observer Receive Signal
-

Last hop → observer only, not between nodes in the route

+
${tr('Observer Receive Signal')}
+

${tr('Last hop → observer only, not between nodes in the route')}

${data.signal.avgSnr != null - ? `
Avg SNR: ${data.signal.avgSnr} dB · Avg RSSI: ${data.signal.avgRssi} dBm · ${data.signal.samples} samples
` - : '
No signal data
'} + ? `
${tr('Avg SNR:')} ${data.signal.avgSnr} dB · ${tr('Avg RSSI:')} ${data.signal.avgRssi} dBm · ${trn(data.signal.samples, '{count} samples', '{count} samples')}
` + : '
' + tr('No signal data') + '
'}
-
Activity by Hour (UTC)
+
${tr('Activity by Hour (UTC)')}
- ${data.hourDistribution.map((c, h) => `
`).join('')} + ${data.hourDistribution.map((c, h) => `
`).join('')}
06121823
-
Timeline
-
First seen: ${data.firstSeen ? (typeof formatAbsoluteTimestamp === 'function' ? formatAbsoluteTimestamp(data.firstSeen) : new Date(data.firstSeen).toLocaleString()) : '—'}
-
Last seen: ${data.lastSeen ? (typeof formatAbsoluteTimestamp === 'function' ? formatAbsoluteTimestamp(data.lastSeen) : new Date(data.lastSeen).toLocaleString()) : '—'}
+
${tr('Timeline')}
+
${tr('First seen:')} ${data.firstSeen ? (typeof formatAbsoluteTimestamp === 'function' ? formatAbsoluteTimestamp(data.firstSeen) : new Date(data.firstSeen).toLocaleString()) : '—'}
+
${tr('Last seen:')} ${data.lastSeen ? (typeof formatAbsoluteTimestamp === 'function' ? formatAbsoluteTimestamp(data.lastSeen) : new Date(data.lastSeen).toLocaleString()) : '—'}
${data.observers.length ? `
-
Observers
+
${tr('Observers')}
${data.observers.map(o => `
${esc(o.name)}: ${o.count}
`).join('')}
` : ''} ${data.parentPaths.length ? `
-
Full Paths Containing This Route
+
${tr('Full Paths Containing This Route')}
${data.parentPaths.map(p => `
${esc(p.path)} ×${p.count}
`).join('')}
@@ -2485,7 +2490,7 @@ } async function renderNodesTab(el) { - el.innerHTML = '
Loading node analytics…
'; + el.innerHTML = '
' + tr('Loading node analytics…') + '
'; try { const rq = RegionFilter.regionQueryString() + AreaFilter.areaQueryString(); const [nodesResp, bulkHealth] = await Promise.all([ @@ -2523,120 +2528,120 @@ return `${esc(n.name || n.public_key.slice(0, 12))}`; } function claimedBadge(n) { - return myKeys.has(n.public_key) ? ' MINE' : ''; + return myKeys.has(n.public_key) ? ' ' + tr('MINE') + '' : ''; } // ROLE_COLORS from shared roles.js el.innerHTML = `
-

Network Status

+

${tr('Network Status')}

${active}
-
Active
+
${tr('Active')}
${degraded}
-
Degraded
+
${tr('Degraded')}
${silent}
-
Silent
+
${tr('Silent')}
${totalNodes}
-
Total Nodes
+
${tr('Total Nodes')}
-

Role Breakdown

+

${tr('Role Breakdown')}

${Object.entries(roleCounts).sort((a,b) => b[1]-a[1]).map(([role, count]) => { const c = ROLE_COLORS[role] || '#6b7280'; - return `${role}: ${count}`; + return `${tr(role)}: ${count}`; }).join('')}
- ${myKeys.size ? `

My Claimed Nodes

+ ${myKeys.size ? `

${tr('My Claimed Nodes')}

#RouteOccurrences% of pathsFrequency#${tr('Route')}${tr('Occurrences')}${tr('% of paths')}${tr('Frequency')}
${i + 1}${routeDisplay}${hasSelfLoop ? ' ' : ''}
${esc(prefixDisplay)}
${routeDisplay}${hasSelfLoop ? ' ' : ''}
${esc(prefixDisplay)}
${s.count.toLocaleString()} ${s.pct}%
- + ${enriched.filter(n => myKeys.has(n.public_key)).map(n => { const s = n.health.stats; return ` - + `; - }).join('') || ''} + }).join('') || ''}
NodeRolePacketsAvg SNRObserversLast Heard
${tr('Node')}${tr('Role')}${tr('Packets')}${tr('Avg SNR')}${tr('Observers')}${tr('Last Heard')}
${nodeLink(n)}${n.role}${tr(n.role)} ${s.totalTransmissions || s.totalPackets || 0} ${s.avgSnr != null ? s.avgSnr.toFixed(1) + ' dB' : '—'} ${n.health.observers?.length || 0} ${s.lastHeard ? timeAgo(s.lastHeard) : '—'}
No claimed nodes have health data
' + tr('No claimed nodes have health data') + '
` : ''} -

Most Active Nodes

+

${tr('Most Active Nodes')}

- + ${byPackets.slice(0, 15).map((n, i) => ` - + - + `).join('')}
#NodeRoleTotal PacketsPackets TodayAnalytics
#${tr('Node')}${tr('Role')}${tr('Total Packets')}${tr('Packets Today')}${tr('Analytics')}
${i + 1} ${nodeLink(n)}${claimedBadge(n)}${n.role}${tr(n.role)} ${n.health.stats.totalTransmissions || n.health.stats.totalPackets || 0} ${n.health.stats.packetsToday || 0}
-

Best Signal Quality

+

${tr('Best Signal Quality')}

- + ${bySnr.slice(0, 15).map((n, i) => ` - + - + `).join('')}
#NodeRoleAvg SNRObserversAnalytics
#${tr('Node')}${tr('Role')}${tr('Avg SNR')}${tr('Observers')}${tr('Analytics')}
${i + 1} ${nodeLink(n)}${claimedBadge(n)}${n.role}${tr(n.role)} ${n.health.stats.avgSnr.toFixed(1)} dB ${n.health.observers?.length || 0}
-

Most Observed Nodes

+

${tr('Most Observed Nodes')}

- + ${byObservers.slice(0, 15).map((n, i) => ` - + - + `).join('')}
#NodeRoleObserversAvg SNRAnalytics
#${tr('Node')}${tr('Role')}${tr('Observers')}${tr('Avg SNR')}${tr('Analytics')}
${i + 1} ${nodeLink(n)}${claimedBadge(n)}${n.role}${tr(n.role)} ${n.health.observers?.length || 0} ${n.health.stats.avgSnr != null ? n.health.stats.avgSnr.toFixed(1) + ' dB' : '—'}
-

Recently Active

+

${tr('Recently Active')}

- + ${byRecent.slice(0, 15).map(n => ` - + - + `).join('')}
NodeRoleLast HeardPackets TodayAnalytics
${tr('Node')}${tr('Role')}${tr('Last Heard')}${tr('Packets Today')}${tr('Analytics')}
${nodeLink(n)}${claimedBadge(n)}${n.role}${tr(n.role)} ${timeAgo(n.health.stats.lastHeard)} ${n.health.stats.packetsToday || 0}
`; } catch (e) { - el.innerHTML = `
Failed to load node analytics: ${esc(e.message)}
`; + el.innerHTML = `
${tr('Failed to load node analytics: {message}', { message: esc(e.message) })}
`; } } @@ -2650,7 +2655,7 @@ function _mrSummaryCard(value, label, color) { return `
${esc(String(value))}
-
${esc(label)}
+
${esc(tr(label))}
`; } @@ -2666,22 +2671,25 @@ // Fall back to an explicit "Unknown" rather than silently mislabelling an // unexpected status as "Silent". const m = MR_STATUS_META[status] || MR_STATUS_META.unknown; - return `${m.label}`; + return `${tr(m.label)}`; } function _mrEmptyState() { return `
-

No favorite repeaters yet

-

Star a repeater (the on the Nodes page or a node's detail) to add it to your watch-list. Starred repeaters show up here for at-a-glance monitoring.

+

${tr('No favorite repeaters yet')}

+

${tr("Star a repeater (the {star} on the {nodes} page or a node's detail) to add it to your watch-list. Starred repeaters show up here for at-a-glance monitoring.", { + star: '', + nodes: '' + tr('Nodes') + '', + })}

`; } async function renderMyRepeatersTab(el) { const haveFavs = () => (typeof getFavorites === 'function' ? getFavorites() : []); if (!haveFavs().length) { el.innerHTML = _mrEmptyState(); return; } - el.innerHTML = '
Loading your repeaters…
'; + el.innerHTML = '
' + tr('Loading your repeaters…') + '
'; try { const rq = RegionFilter.regionQueryString() + AreaFilter.areaQueryString(); // When a region/area filter is active the node fetch is scoped, so a @@ -2724,7 +2732,7 @@ const rc = window.ROLE_COLORS || {}; const c = rc[role] || rc.unknown || 'var(--role-unknown, #6b7280)'; const style = (window.aaBadgeStyle && window.aaBadgeStyle(c)) || ('background:' + c + ';color:#fff'); - return `${esc(role)}`; + return `${esc(tr(role))}`; } // Re-renders from the cached fetch using the LIVE favorites set, so an @@ -2746,7 +2754,7 @@ // ("not shown with the active filter") rather than claiming a cause. const hiddenByFilter = [...favs].filter(pk => !allByPk.has(pk)).length; const filterNotice = (filterActive && hiddenByFilter > 0) - ? `

${hiddenByFilter} favorite${hiddenByFilter === 1 ? '' : 's'} not shown with the active region/area filter.

` + ? `

${trn(hiddenByFilter, '{count} favorite not shown with the active region/area filter.', '{count} favorites not shown with the active region/area filter.')}

` : ''; let active = 0, degraded = 0, silent = 0, clockOk = 0, totalRelay24 = 0; @@ -2790,17 +2798,17 @@ .sort((a, b) => (b.score || 0) - (a.score || 0)); let affinityHtml; if (!myEdges.length) { - affinityHtml = `

No affinity links detected between your repeaters yet — they may be too far apart, or not enough shared traffic has been observed.

`; + affinityHtml = `

${tr('No affinity links detected between your repeaters yet — they may be too far apart, or not enough shared traffic has been observed.')}

`; } else { affinityHtml = ` - + ${myEdges.map(e => { // The affinity graph is undirected — the server marks every edge // bidirectional — so the link glyph is always a double arrow. const arrow = '↔'; const snr = (e.avg_snr != null) ? e.avg_snr.toFixed(1) + ' dB' : '—'; return ` - + @@ -2811,8 +2819,8 @@ el.innerHTML = `
-

My Repeaters

-

At-a-glance monitoring over the repeaters you have starred. Un-star a row to drop it from this list.

+

${tr('My Repeaters')}

+

${tr('At-a-glance monitoring over the repeaters you have starred. Un-star a row to drop it from this list.')}

${filterNotice}
${_mrSummaryCard(myNodes.length, 'My Repeaters')} @@ -2825,24 +2833,24 @@
LinkAffinityPacketsAvg SNR
${tr('Link')}${tr('Affinity')}${tr('Packets')}${tr('Avg SNR')}
${esc(nameByPk[e.source] || e.source.slice(0, 12))} ${arrow} ${esc(nameByPk[e.target] || e.target.slice(0, 12))}${e.ambiguous ? ' Path attribution ambiguous' : ''}${esc(nameByPk[e.source] || e.source.slice(0, 12))} ${arrow} ${esc(nameByPk[e.target] || e.target.slice(0, 12))}${e.ambiguous ? ' ' + tr('Path attribution ambiguous') + '' : ''} ${pct(e.score)} ${e.weight != null ? e.weight : '—'} ${snr}
- - - - - - - - - - - - + + + + + + + + + + + + - ${rowsHtml || ''} + ${rowsHtml || ''}
RepeaterRoleStatusClockTrafficBridgeRelays 1hRelays 24hLast RelayedLast HeardBattery${tr('Repeater')}${tr('Role')}${tr('Status')}${tr('Clock')}${tr('Traffic')}${tr('Bridge')}${tr('Relays 1h')}${tr('Relays 24h')}${tr('Last Relayed')}${tr('Last Heard')}${tr('Battery')}
None of your favorites are repeaters/rooms in this view.
' + tr('None of your favorites are repeaters/rooms in this view.') + '
-

Links Between My Repeaters

-

Affinity sub-graph filtered to your favorites — how much traffic flows directly between them.

+

${tr('Links Between My Repeaters')}

+

${tr('Affinity sub-graph filtered to your favorites — how much traffic flows directly between them.')}

${affinityHtml}
`; @@ -2851,7 +2859,7 @@ } paint(); } catch (e) { - el.innerHTML = `
Failed to load your repeaters: ${esc(e.message)}
`; + el.innerHTML = `
${tr('Failed to load your repeaters: {message}', { message: esc(e.message) })}
`; } } // === MY REPEATERS BLOCK END (test harness slices the renderer block to here) === @@ -2936,7 +2944,7 @@ const plotW = w - pad * 2, plotH = h - pad * 2; const xOf = v => pad + (v - xAxis.min) / (xAxis.max - xAxis.min || 1) * plotW; const yOf = v => h - pad - (v - yAxis.min) / (yAxis.max - yAxis.min || 1) * plotH; - let svg = `Repeater metric scatter: ${esc(xAxis.label)} (x) vs ${esc(yAxis.label)} (y)`; + let svg = `${tr('Repeater metric scatter: {x} (x) vs {y} (y)', { x: esc(tr(xAxis.label)), y: esc(tr(yAxis.label)) })}`; // Gridlines + tick labels (5 steps per axis). for (let i = 0; i <= 5; i++) { const gx = pad + plotW * i / 5; @@ -2951,12 +2959,12 @@ // Axis lines + titles. svg += ``; svg += ``; - svg += `${esc(xAxis.label)}`; - svg += `${esc(yAxis.label)}`; + svg += `${esc(tr(xAxis.label))}`; + svg += `${esc(tr(yAxis.label))}`; // Only points with BOTH axis values present are plottable. const plottable = points.filter(p => xAxis.get(p) != null && yAxis.get(p) != null); if (!plottable.length) { - svg += `No repeaters have values for both selected metrics.`; + svg += `${tr('No repeaters have values for both selected metrics.')}`; return svg; } // Cap plotted points to keep the SVG snappy (~2000 stays smooth in a @@ -2983,7 +2991,7 @@ // Sampling disclosure (graphical integrity): if we strided the points // down, say so in-plot rather than silently hiding the rest. if (sample.length < plottable.length) { - svg += `showing ${sample.length} of ${plottable.length} points`; + svg += `${tr('showing {shown} of {total} points', { shown: sample.length, total: plottable.length })}`; } // Draw favorites last so their rings are never hidden behind other dots. const ordered = sample.slice().sort((a, b) => (a.fav === b.fav ? 0 : a.fav ? 1 : -1)); @@ -2997,7 +3005,7 @@ const color = (window.ROLE_COLORS && window.ROLE_COLORS[p.role]) || 'var(--text-muted)'; // ' · ' separators, not '\n': SVG tooltips collapse whitespace, // so newlines would render as a run-on string. - const tip = `${p.name} · ${xAxis.label}: ${_axisFmt(xAxis, xAxis.get(p))} · ${yAxis.label}: ${_axisFmt(yAxis, yAxis.get(p))}`; + const tip = `${p.name} · ${tr(xAxis.label)}: ${_axisFmt(xAxis, xAxis.get(p))} · ${tr(yAxis.label)}: ${_axisFmt(yAxis, yAxis.get(p))}`; // tabindex="-1": with up to 2000 points we keep them clickable but out // of the sequential keyboard tab order (the Nodes table is the // keyboard-navigable surface for per-node drill-down). @@ -3010,7 +3018,7 @@ } async function renderRepeaterMetricsTab(el) { - el.innerHTML = '<div style="padding:40px;text-align:center;color:var(--text-muted)">Loading repeater metrics…</div>'; + el.innerHTML = '<div style="padding:40px;text-align:center;color:var(--text-muted)">' + tr('Loading repeater metrics…') + '</div>'; try { const rq = RegionFilter.regionQueryString() + AreaFilter.areaQueryString(); const resp = await fetchAllNodes('&sortBy=lastSeen' + rq, { ttl: CLIENT_TTL.nodeList }); @@ -3020,8 +3028,8 @@ if (!points.length) { el.innerHTML = `<div class="analytics-section"> - <h3><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-target"/></svg> Repeater Metric Scatter</h3> - <div class="text-muted" style="padding:24px">No repeater or room nodes in this view.</div> + <h3><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-target"/></svg> ${tr('Repeater Metric Scatter')}</h3> + <div class="text-muted" style="padding:24px">${tr('No repeater or room nodes in this view.')}</div> </div>`; return; } @@ -3031,21 +3039,21 @@ // (single validation path); the <select> then just shows its first option. let xKey = localStorage.getItem('meshcore-repeater-scatter-x') || 'traffic'; let yKey = localStorage.getItem('meshcore-repeater-scatter-y') || 'bridge'; - const opts = sel => REPEATER_METRIC_AXES.map(a => `<option value="${a.key}"${a.key === sel ? ' selected' : ''}>${esc(a.label)}</option>`).join(''); + const opts = sel => REPEATER_METRIC_AXES.map(a => `<option value="${a.key}"${a.key === sel ? ' selected' : ''}>${esc(tr(a.label))}</option>`).join(''); el.innerHTML = ` <div class="analytics-section"> - <h3><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-target"/></svg> Repeater Metric Scatter</h3> - <p class="text-muted">Each point is a repeater or room. Pick two metrics to compare — colors follow node role, favorites are ringed. Click a point for its analytics.</p> - <p class="text-muted" style="font-size:12px;margin-top:-6px">Units — <strong>Traffic share</strong>: % of non-advert traffic relayed. <strong>Bridge score</strong>: 0–100% (network-normalized betweenness). <strong>Relays (1h/24h)</strong> and <strong>Adverts</strong>: packet counts. Axes auto-scale to the data.</p> + <h3><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-target"/></svg> ${tr('Repeater Metric Scatter')}</h3> + <p class="text-muted">${tr('Each point is a repeater or room. Pick two metrics to compare — colors follow node role, favorites are ringed. Click a point for its analytics.')}</p> + <p class="text-muted" style="font-size:12px;margin-top:-6px">${tr('Units —')} <strong>${tr('Traffic share')}</strong>: ${tr('% of non-advert traffic relayed.')} <strong>${tr('Bridge score')}</strong>: ${tr('0–100% (network-normalized betweenness).')} <strong>${tr('Relays (1h/24h)')}</strong> ${tr('and')} <strong>${tr('Adverts')}</strong>: ${tr('packet counts. Axes auto-scale to the data.')}</p> <div style="display:flex;gap:20px;flex-wrap:wrap;align-items:center;margin-bottom:14px"> - <label style="display:flex;gap:6px;align-items:center">X axis - <select id="metricScatterX" class="analytics-time-window-select" aria-label="X axis metric">${opts(xKey)}</select> + <label style="display:flex;gap:6px;align-items:center">${tr('X axis')} + <select id="metricScatterX" class="analytics-time-window-select" aria-label="${tr('X axis metric')}">${opts(xKey)}</select> </label> - <label style="display:flex;gap:6px;align-items:center">Y axis - <select id="metricScatterY" class="analytics-time-window-select" aria-label="Y axis metric">${opts(yKey)}</select> + <label style="display:flex;gap:6px;align-items:center">${tr('Y axis')} + <select id="metricScatterY" class="analytics-time-window-select" aria-label="${tr('Y axis metric')}">${opts(yKey)}</select> </label> - <span class="text-muted">${points.length} repeater${points.length === 1 ? '' : 's'}</span> + <span class="text-muted">${trn(points.length, '{count} repeater', '{count} repeaters')}</span> </div> <div id="metricScatterPlot"></div> <div id="metricScatterLegend" style="display:flex;gap:16px;flex-wrap:wrap;margin-top:10px;font-size:12px"></div> @@ -3072,11 +3080,11 @@ const roles = Array.from(new Set(points.map(p => p.role))); let lg = roles.map(r => { const c = (window.ROLE_COLORS && window.ROLE_COLORS[r]) || 'var(--text-muted)'; - return `<span style="display:inline-flex;gap:6px;align-items:center"><svg width="12" height="12" aria-hidden="true"><circle cx="6" cy="6" r="4" fill="${c}"/></svg>${esc(r)}</span>`; + return `<span style="display:inline-flex;gap:6px;align-items:center"><svg width="12" height="12" aria-hidden="true"><circle cx="6" cy="6" r="4" fill="${c}"/></svg>${esc(tr(r))}</span>`; }).join(''); const favCount = points.filter(p => p.fav).length; if (favCount) { - lg += `<span style="display:inline-flex;gap:6px;align-items:center"><svg width="14" height="14" aria-hidden="true"><circle cx="7" cy="7" r="5" fill="none" stroke="var(--text)" stroke-width="1.75"/></svg>Favorite (${favCount})</span>`; + lg += `<span style="display:inline-flex;gap:6px;align-items:center"><svg width="14" height="14" aria-hidden="true"><circle cx="7" cy="7" r="5" fill="none" stroke="var(--text)" stroke-width="1.75"/></svg>${tr('Favorite ({count})', { count: favCount })}</span>`; } legendEl.innerHTML = lg; } @@ -3091,7 +3099,7 @@ wireAxis(ySel, 'meshcore-repeater-scatter-y'); draw(); } catch (e) { - el.innerHTML = `<div style="padding:40px;text-align:center;color:#ff6b6b">Failed to load repeater metrics: ${esc(e.message)}</div>`; + el.innerHTML = `<div style="padding:40px;text-align:center;color:#ff6b6b">${tr('Failed to load repeater metrics: {message}', { message: esc(e.message) })}</div>`; } } @@ -3110,8 +3118,8 @@ if (_distanceIsBuilding(data)) { const secs = Math.round(_distanceRetryDelayMs(data) / 1000); el.innerHTML = '<div class="text-center text-muted" id="distanceBuilding" style="padding:40px">' + - 'Building the distance index…' + - '<div style="font-size:12px;margin-top:8px">This runs once per instance. Retrying in ' + secs + 's.</div></div>'; + tr('Building the distance index…') + + '<div style="font-size:12px;margin-top:8px">' + tr('This runs once per instance. Retrying in {seconds}s.', { seconds: secs }) + '</div></div>'; _distanceRetryTimer = setTimeout(function () { _distanceRetryTimer = null; // The user may have switched tabs or left while this was pending. @@ -3125,16 +3133,16 @@ const s = data.summary; let html = `<div class="analytics-grid"> - <div class="stat-card"><div class="stat-value">${s.totalHops.toLocaleString()}</div><div class="stat-label">Total Hops Analyzed</div></div> - <div class="stat-card"><div class="stat-value">${s.totalPaths.toLocaleString()}</div><div class="stat-label">Paths Analyzed</div></div> - <div class="stat-card"><div class="stat-value">${formatDistance(s.avgDist)}</div><div class="stat-label">Avg Hop Distance</div></div> - <div class="stat-card"><div class="stat-value">${formatDistance(s.maxDist)}</div><div class="stat-label">Max Hop Distance</div></div> + <div class="stat-card"><div class="stat-value">${s.totalHops.toLocaleString()}</div><div class="stat-label">${tr('Total Hops Analyzed')}</div></div> + <div class="stat-card"><div class="stat-value">${s.totalPaths.toLocaleString()}</div><div class="stat-label">${tr('Paths Analyzed')}</div></div> + <div class="stat-card"><div class="stat-value">${formatDistance(s.avgDist)}</div><div class="stat-label">${tr('Avg Hop Distance')}</div></div> + <div class="stat-card"><div class="stat-value">${formatDistance(s.maxDist)}</div><div class="stat-label">${tr('Max Hop Distance')}</div></div> </div>`; // Category stats const cats = data.catStats; const distUnitLabel = getDistanceUnit() === 'mi' ? 'mi' : 'km'; - html += `<div class="analytics-section"><h3>Distance by Link Type</h3><table class="data-table"><thead><tr><th scope="col">Type</th><th scope="col">Count</th><th scope="col">Avg (${distUnitLabel})</th><th scope="col">Median (${distUnitLabel})</th><th scope="col">Min (${distUnitLabel})</th><th scope="col">Max (${distUnitLabel})</th></tr></thead><tbody>`; + html += `<div class="analytics-section"><h3>${tr('Distance by Link Type')}</h3><table class="data-table"><thead><tr><th scope="col">${tr('Type')}</th><th scope="col">${tr('Count')}</th><th scope="col">${tr('Avg ({unit})', { unit: distUnitLabel })}</th><th scope="col">${tr('Median ({unit})', { unit: distUnitLabel })}</th><th scope="col">${tr('Min ({unit})', { unit: distUnitLabel })}</th><th scope="col">${tr('Max ({unit})', { unit: distUnitLabel })}</th></tr></thead><tbody>`; for (const [cat, st] of Object.entries(cats)) { if (!st.count) continue; html += `<tr><td><strong>${esc(cat)}</strong></td><td>${st.count.toLocaleString()}</td><td>${formatDistance(st.avg)}</td><td>${formatDistance(st.median)}</td><td>${formatDistance(st.min)}</td><td>${formatDistance(st.max)}</td></tr>`; @@ -3145,16 +3153,16 @@ if (data.distHistogram && data.distHistogram.bins) { const buckets = data.distHistogram.bins.map(b => b.count); const labels = data.distHistogram.bins.map(b => b.x.toFixed(1)); - html += `<div class="analytics-section"><h3>Hop Distance Distribution</h3>${barChart(buckets, labels, statusGreen())}</div>`; + html += `<div class="analytics-section"><h3>${tr('Hop Distance Distribution')}</h3>${barChart(buckets, labels, statusGreen())}</div>`; } // Distance over time if (data.distOverTime && data.distOverTime.length > 1) { - html += `<div class="analytics-section"><h3>Average Distance Over Time</h3>${sparkSvg(data.distOverTime.map(d => d.avg), 'var(--accent)', 800, 120)}</div>`; + html += `<div class="analytics-section"><h3>${tr('Average Distance Over Time')}</h3>${sparkSvg(data.distOverTime.map(d => d.avg), 'var(--accent)', 800, 120)}</div>`; } // Top hops leaderboard - html += `<div class="analytics-section"><h3><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-trophy"/></svg> Top 20 Longest Hops</h3><table class="data-table"><thead><tr><th scope="col">#</th><th scope="col">From</th><th scope="col">To</th><th scope="col">Distance (${distUnitLabel})</th><th scope="col">Type</th><th scope="col">Obs</th><th scope="col">Best SNR</th><th scope="col">Median SNR</th><th scope="col">Packet</th><th scope="col" class="col-action"></th></tr></thead><tbody>`; + html += `<div class="analytics-section"><h3><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-trophy"/></svg> ${tr('Top 20 Longest Hops')}</h3><table class="data-table"><thead><tr><th scope="col">#</th><th scope="col">${tr('From')}</th><th scope="col">${tr('To')}</th><th scope="col">${tr('Distance ({unit})', { unit: distUnitLabel })}</th><th scope="col">${tr('Type')}</th><th scope="col">${tr('Obs')}</th><th scope="col">${tr('Best SNR')}</th><th scope="col">${tr('Median SNR')}</th><th scope="col">${tr('Packet')}</th><th scope="col" class="col-action"></th></tr></thead><tbody>`; const top20 = data.topHops.slice(0, 20); top20.forEach((h, i) => { const fromLink = h.fromPk ? `<a href="#/nodes/${encodeURIComponent(h.fromPk)}" class="analytics-link">${esc(h.fromName)}</a>` : esc(h.fromName || '?'); @@ -3163,15 +3171,15 @@ const medianSnr = h.medianSnr != null ? Number(h.medianSnr).toFixed(1) + ' dB' : '<span class="text-muted">—</span>'; const obs = h.obsCount != null ? h.obsCount : 1; const pktLink = h.hash ? `<a href="#/packet/${encodeURIComponent(h.hash)}" class="analytics-link mono" style="font-size:0.85em">${esc(h.hash.slice(0, 12))}…</a>` : '—'; - const mapBtn = h.fromPk && h.toPk ? `<button class="btn-icon dist-map-hop" data-from="${esc(h.fromPk)}" data-to="${esc(h.toPk)}" title="View on map" aria-label="View on map"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-map-trifold"/></svg></button>` : ''; - const tsTitle = h.timestamp ? `Best observation: ${h.timestamp}` : ''; + const mapBtn = h.fromPk && h.toPk ? `<button class="btn-icon dist-map-hop" data-from="${esc(h.fromPk)}" data-to="${esc(h.toPk)}" title="${tr('View on map')}" aria-label="${tr('View on map')}"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-map-trifold"/></svg></button>` : ''; + const tsTitle = h.timestamp ? tr('Best observation: {time}', { time: h.timestamp }) : ''; html += `<tr title="${esc(tsTitle)}"><td>${i+1}</td><td>${fromLink}</td><td>${toLink}</td><td><strong>${formatDistance(h.dist)}</strong></td><td>${esc(h.type)}</td><td>${obs}</td><td>${bestSnr}</td><td>${medianSnr}</td><td>${pktLink}</td><td class="col-action">${mapBtn}</td></tr>`; }); html += `</tbody></table></div>`; // Top paths if (data.topPaths.length) { - html += `<div class="analytics-section"><h3><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-path"/></svg> Top 10 Longest Multi-Hop Paths</h3><table class="data-table"><thead><tr><th scope="col">#</th><th scope="col">Total Distance (${distUnitLabel})</th><th scope="col">Hops</th><th scope="col">Route</th><th scope="col">Packet</th><th scope="col" class="col-action"></th></tr></thead><tbody>`; + html += `<div class="analytics-section"><h3><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-path"/></svg> ${tr('Top 10 Longest Multi-Hop Paths')}</h3><table class="data-table"><thead><tr><th scope="col">#</th><th scope="col">${tr('Total Distance ({unit})', { unit: distUnitLabel })}</th><th scope="col">${tr('Hops')}</th><th scope="col">${tr('Route')}</th><th scope="col">${tr('Packet')}</th><th scope="col" class="col-action"></th></tr></thead><tbody>`; data.topPaths.slice(0, 10).forEach((p, i) => { const route = p.hops.map(h => esc(h.fromName)).concat(esc(p.hops[p.hops.length-1].toName)).join(' → '); const pktLink = p.hash ? `<a href="#/packet/${encodeURIComponent(p.hash)}" class="analytics-link mono" style="font-size:0.85em">${esc(p.hash.slice(0, 12))}…</a>` : '—'; @@ -3179,7 +3187,7 @@ const pathPks = []; p.hops.forEach(h => { if (h.fromPk && !pathPks.includes(h.fromPk)) pathPks.push(h.fromPk); }); if (p.hops.length && p.hops[p.hops.length-1].toPk) { const last = p.hops[p.hops.length-1].toPk; if (!pathPks.includes(last)) pathPks.push(last); } - const mapBtn = pathPks.length >= 2 ? `<button class="btn-icon dist-map-path" data-hops='${JSON.stringify(pathPks)}' title="View on map" aria-label="View on map"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-map-trifold"/></svg></button>` : ''; + const mapBtn = pathPks.length >= 2 ? `<button class="btn-icon dist-map-path" data-hops='${JSON.stringify(pathPks)}' title="${tr('View on map')}" aria-label="${tr('View on map')}"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-map-trifold"/></svg></button>` : ''; html += `<tr><td>${i+1}</td><td><strong>${formatDistance(p.totalDist)}</strong></td><td>${p.hopCount}</td><td style="font-size:0.9em">${route}</td><td>${pktLink}</td><td class="col-action">${mapBtn}</td></tr>`; }); html += `</tbody></table></div>`; @@ -3204,7 +3212,7 @@ }); }); } catch (e) { - el.innerHTML = `<div style="padding:40px;text-align:center;color:#ff6b6b">Failed to load distance analytics: ${esc(e.message)}</div>`; + el.innerHTML = `<div style="padding:40px;text-align:center;color:#ff6b6b">${tr('Failed to load distance analytics: {message}', { message: esc(e.message) })}</div>`; } } @@ -3238,29 +3246,29 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet async function renderNeighborGraphTab(el) { el.innerHTML = ` <div class="analytics-card" id="ngCard"> - <h3><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-graph"/></svg> Neighbor Graph</h3> + <h3><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-graph"/></svg> ${tr('Neighbor Graph')}</h3> <div id="ngFilters" class="ng-filters" style="display:flex;gap:12px;flex-wrap:wrap;align-items:center;margin-bottom:12px"> - <label style="font-size:13px">Roles: + <label style="font-size:13px">${tr('Roles:')} <span id="ngRoleChecks" style="margin-left:4px"></span> </label> - <label style="font-size:13px">Min Score: <input type="range" id="ngMinScore" min="0" max="100" value="70" style="width:100px;vertical-align:middle"> + <label style="font-size:13px">${tr('Min Score:')} <input type="range" id="ngMinScore" min="0" max="100" value="70" style="width:100px;vertical-align:middle"> <span id="ngMinScoreVal">0.70</span> </label> - <label style="font-size:13px">Confidence: + <label style="font-size:13px">${tr('Confidence:')} <select id="ngConfidence" style="font-size:12px;padding:2px 4px"> - <option value="all">Show All</option> - <option value="high">High Only</option> - <option value="hide-ambiguous">Hide Ambiguous</option> + <option value="all">${tr('Show All')}</option> + <option value="high">${tr('High Only')}</option> + <option value="hide-ambiguous">${tr('Hide Ambiguous')}</option> </select> </label> </div> <div id="ngStats" class="stat-row" style="display:flex;gap:16px;flex-wrap:wrap;margin-bottom:12px"></div> <div style="position:relative;border:1px solid var(--border);border-radius:6px;overflow:hidden"> - <canvas id="ngCanvas" width="900" height="600" style="width:100%;height:600px;cursor:grab;outline-offset:2px" role="img" aria-label="Neighbor affinity graph visualization — interactive force-directed network topology" tabindex="0"></canvas> + <canvas id="ngCanvas" width="900" height="600" style="width:100%;height:600px;cursor:grab;outline-offset:2px" role="img" aria-label="${tr('Neighbor affinity graph visualization — interactive force-directed network topology')}" tabindex="0"></canvas> <div id="ngTooltip" style="position:absolute;display:none;background:var(--bg-secondary);border:1px solid var(--border);border-radius:4px;padding:6px 10px;font-size:12px;pointer-events:none;z-index:10;box-shadow:0 2px 8px rgba(0,0,0,0.2)"></div> </div> <details id="ngAccessibleList" style="margin-top:12px"> - <summary style="cursor:pointer;font-size:13px;color:var(--text-secondary)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-clipboard-text"/></svg> Text-based neighbor list (accessible alternative)</summary> + <summary style="cursor:pointer;font-size:13px;color:var(--text-secondary)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-clipboard-text"/></svg> ${tr('Text-based neighbor list (accessible alternative)')}</summary> <div id="ngTextList" style="font-size:12px;max-height:300px;overflow-y:auto;padding:8px;background:var(--bg-secondary);border-radius:4px;margin-top:4px"></div> </details> </div>`; @@ -3274,11 +3282,11 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet const roles = ['repeater','companion','room','sensor']; const rcEl = document.getElementById('ngRoleChecks'); roles.forEach(r => { - rcEl.innerHTML += `<label style="font-size:12px;margin-right:8px"><input type="checkbox" data-role="${r}" checked> <span class="role-swatch role-swatch--${r}">${esc(r)}</span></label>`; + rcEl.innerHTML += `<label style="font-size:12px;margin-right:8px"><input type="checkbox" data-role="${r}" checked> <span class="role-swatch role-swatch--${r}">${esc(tr(r))}</span></label>`; }); // Observer checkbox — unchecked by default (observers create hub-and-spoke noise) { - rcEl.innerHTML += `<label style="font-size:12px;margin-right:8px"><input type="checkbox" data-role="observer"> <span class="role-swatch role-swatch--observer">observer</span></label>`; + rcEl.innerHTML += `<label style="font-size:12px;margin-right:8px"><input type="checkbox" data-role="observer"> <span class="role-swatch role-swatch--observer">${tr('observer')}</span></label>`; } // Load data @@ -3288,7 +3296,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet try { graphData = await api('/analytics/neighbor-graph' + sep + (sep ? '&' : '?') + 'min_count=1&min_score=0', { ttl: CLIENT_TTL.analyticsRF }); } catch (e) { - el.innerHTML = `<div class="analytics-card"><p class="text-muted">Failed to load neighbor graph: ${esc(e.message)}</p></div>`; + el.innerHTML = `<div class="analytics-card"><p class="text-muted">${tr('Failed to load neighbor graph: {message}', { message: esc(e.message) })}</p></div>`; return; } @@ -3395,16 +3403,16 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet const statsEl = document.getElementById('ngStats'); if (!statsEl) return; statsEl.innerHTML = ` - <div class="stat-card"><div class="stat-value">${nodes.length}</div><div class="stat-label">Nodes</div></div> - <div class="stat-card"><div class="stat-value">${edges.length}</div><div class="stat-label">Edges</div></div> - <div class="stat-card"><div class="stat-value">${avgScore.toFixed(2)}</div><div class="stat-label">Avg Score</div></div> - <div class="stat-card"><div class="stat-value">${resolved.toFixed(0)}%</div><div class="stat-label">Resolved</div></div> - <div class="stat-card"><div class="stat-value">${ambiguous}</div><div class="stat-label">Ambiguous</div></div>`; + <div class="stat-card"><div class="stat-value">${nodes.length}</div><div class="stat-label">${tr('Nodes')}</div></div> + <div class="stat-card"><div class="stat-value">${edges.length}</div><div class="stat-label">${tr('Edges')}</div></div> + <div class="stat-card"><div class="stat-value">${avgScore.toFixed(2)}</div><div class="stat-label">${tr('Avg Score')}</div></div> + <div class="stat-card"><div class="stat-value">${resolved.toFixed(0)}%</div><div class="stat-label">${tr('Resolved')}</div></div> + <div class="stat-card"><div class="stat-value">${ambiguous}</div><div class="stat-label">${tr('Ambiguous')}</div></div>`; // Update canvas aria-label with current graph summary var canvas = document.getElementById('ngCanvas'); if (canvas) { - canvas.setAttribute('aria-label', 'Neighbor affinity graph: ' + nodes.length + ' nodes, ' + edges.length + ' edges, ' + resolved.toFixed(0) + '% resolved. Use arrow keys to pan, +/- to zoom, 0 to reset.'); + canvas.setAttribute('aria-label', tr('Neighbor affinity graph: {nodes} nodes, {edges} edges, {pct}% resolved. Use arrow keys to pan, +/- to zoom, 0 to reset.', { nodes: nodes.length, edges: edges.length, pct: resolved.toFixed(0) })); } // Update accessible text list @@ -3416,7 +3424,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet if (!listEl) return; var nodes = st.nodes, edges = st.edges; if (nodes.length === 0) { - listEl.innerHTML = '<p class="text-muted">No nodes to display.</p>'; + listEl.innerHTML = '<p class="text-muted">' + tr('No nodes to display.') + '</p>'; return; } // Build adjacency for text list @@ -3429,7 +3437,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet }); var nodeMap = {}; nodes.forEach(function(n) { nodeMap[n.pubkey] = n; }); - var html = '<table style="width:100%;border-collapse:collapse"><thead><tr><th style="text-align:left;padding:4px;border-bottom:1px solid var(--border)">Node</th><th style="text-align:left;padding:4px;border-bottom:1px solid var(--border)">Role</th><th style="text-align:left;padding:4px;border-bottom:1px solid var(--border)">Neighbors</th></tr></thead><tbody>'; + var html = '<table style="width:100%;border-collapse:collapse"><thead><tr><th style="text-align:left;padding:4px;border-bottom:1px solid var(--border)">' + tr('Node') + '</th><th style="text-align:left;padding:4px;border-bottom:1px solid var(--border)">' + tr('Role') + '</th><th style="text-align:left;padding:4px;border-bottom:1px solid var(--border)">' + tr('Neighbors') + '</th></tr></thead><tbody>'; nodes.slice().sort(function(a, b) { return (a.name || a.pubkey).localeCompare(b.name || b.pubkey); }).forEach(function(n) { var neighbors = (adj[n.pubkey] || []).map(function(nb) { var peer = nodeMap[nb.pk]; @@ -3437,10 +3445,10 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet var conf = nb.ambiguous ? ' ' + '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-warning"/></svg>' : (nb.score >= 0.5 ? ' ' + '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-circle-fill"/></svg>' : ' ' + '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-circle"/></svg>'); return esc(name) + conf; }).join(', '); - html += '<tr><td style="padding:4px;border-bottom:1px solid var(--border)">' + esc(n.name || n.pubkey.slice(0, 12)) + '</td><td style="padding:4px;border-bottom:1px solid var(--border)">' + esc(n.role || 'unknown') + '</td><td style="padding:4px;border-bottom:1px solid var(--border)">' + (neighbors || '<em>none</em>') + '</td></tr>'; + html += '<tr><td style="padding:4px;border-bottom:1px solid var(--border)">' + esc(n.name || n.pubkey.slice(0, 12)) + '</td><td style="padding:4px;border-bottom:1px solid var(--border)">' + esc(tr(n.role || 'unknown')) + '</td><td style="padding:4px;border-bottom:1px solid var(--border)">' + (neighbors || '<em>' + tr('none') + '</em>') + '</td></tr>'; }); html += '</tbody></table>'; - html += '<p style="margin-top:8px;font-size:11px;color:var(--text-secondary)">' + '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-circle-fill"/></svg>' + ' = high confidence (score ≥ 0.5), ' + '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-circle"/></svg>' + ' = low confidence, ' + '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-warning"/></svg>' + ' = ambiguous/unresolved</p>'; + html += '<p style="margin-top:8px;font-size:11px;color:var(--text-secondary)">' + '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-circle-fill"/></svg>' + ' = ' + tr('high confidence (score ≥ 0.5)') + ', ' + '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-circle"/></svg>' + ' = ' + tr('low confidence') + ', ' + '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-warning"/></svg>' + ' = ' + tr('ambiguous/unresolved') + '</p>'; listEl.innerHTML = html; } @@ -3501,7 +3509,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet tip.style.display = 'block'; tip.style.left = (cx + 12) + 'px'; tip.style.top = (cy - 8) + 'px'; - tip.innerHTML = `<strong>${escapeHtml(n.name || n.pubkey.slice(0, 12) + '…')}</strong><br>Role: ${escapeHtml(n.role || 'unknown')}<br>Neighbors: ${n.neighbor_count || 0}`; + tip.innerHTML = `<strong>${escapeHtml(n.name || n.pubkey.slice(0, 12) + '…')}</strong><br>${tr('Role:')} ${escapeHtml(tr(n.role || 'unknown'))}<br>${tr('Neighbors:')} ${n.neighbor_count || 0}`; } else if (tip) { tip.style.display = 'none'; } @@ -3606,7 +3614,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet skipMsg.textContent = ''; var skipP = document.createElement('p'); skipP.className = 'text-muted'; - skipP.textContent = _ngState.nodes.length + ' of ' + ngTotal + ' nodes match the current filters (limit: ' + NODE_LIMIT + '). Force simulation skipped for performance — tighten the filters to render.'; + skipP.textContent = tr('{shown} of {total} nodes match the current filters (limit: {limit}). Force simulation skipped for performance — tighten the filters to render.', { shown: _ngState.nodes.length, total: ngTotal, limit: NODE_LIMIT }); skipMsg.appendChild(skipP); return; } @@ -3753,7 +3761,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet // --- Prefix Tool --- async function renderPrefixTool(el) { - el.innerHTML = '<div style="padding:40px;text-align:center;color:var(--text-muted)">Loading prefix data…</div>'; + el.innerHTML = '<div style="padding:40px;text-align:center;color:var(--text-muted)">' + tr('Loading prefix data…') + '</div>'; const rq = RegionFilter.regionQueryString() + AreaFilter.areaQueryString(); const regionLabel = rq ? (new URLSearchParams(rq.slice(1)).get('region') || '') : ''; @@ -3768,7 +3776,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet api('/analytics/hash-sizes' + rq, { ttl: CLIENT_TTL.analyticsRF }).catch(() => null), ]); } catch (e) { - el.innerHTML = `<div class="text-muted" role="alert" style="padding:40px">Failed to load: ${esc(e.message)}</div>`; + el.innerHTML = `<div class="text-muted" role="alert" style="padding:40px">${tr('Failed to load: {message}', { message: esc(e.message) })}</div>`; return; } @@ -3785,7 +3793,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet const nodes = allNodes.filter(n => n.role === 'repeater'); if (nodes.length === 0) { - el.innerHTML = `<div class="analytics-card"><p class="text-muted">No repeaters in the network yet. Any prefix is available!</p></div>`; + el.innerHTML = `<div class="analytics-card"><p class="text-muted">${tr('No repeaters in the network yet. Any prefix is available!')}</p></div>`; return; } @@ -3858,7 +3866,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet // colliding entries `[ [prefix, [node, ...]], ... ]`. function renderCollideTable(entries) { if (!entries || !entries.length) { - return '<div class="text-muted" style="padding:8px;font-size:0.8em">No collisions.</div>'; + return '<div class="text-muted" style="padding:8px;font-size:0.8em">' + tr('No collisions.') + '</div>'; } const rows = entries.map(([prefix, arr]) => { const nodeLinks = arr.map(n => { @@ -3872,8 +3880,8 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet }).join(''); return `<table style="width:100%;border-collapse:collapse;font-size:0.85em"> <thead><tr> - <th scope="col" style="text-align:left;padding:4px 8px;border-bottom:1px solid var(--border);background:var(--bg-secondary,var(--bg))">Prefix</th> - <th scope="col" style="text-align:left;padding:4px 8px;border-bottom:1px solid var(--border);background:var(--bg-secondary,var(--bg))">Nodes sharing it</th> + <th scope="col" style="text-align:left;padding:4px 8px;border-bottom:1px solid var(--border);background:var(--bg-secondary,var(--bg))">${tr('Prefix')}</th> + <th scope="col" style="text-align:left;padding:4px 8px;border-bottom:1px solid var(--border);background:var(--bg-secondary,var(--bg))">${tr('Nodes sharing it')}</th> </tr></thead> <tbody>${rows}</tbody> </table>`; @@ -3883,11 +3891,11 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet const totalNodes = nodes.length; let rec, recDetail; if (totalNodes < 20) { - rec = '1-byte'; recDetail = `With only ${totalNodes} repeaters, 1-byte prefixes have low collision risk.`; + rec = trn(1, '{count}-byte', '{count}-byte'); recDetail = tr('With only {count} repeaters, 1-byte prefixes have low collision risk.', { count: totalNodes }); } else if (totalNodes < 500) { - rec = '2-byte'; recDetail = `With ${totalNodes} repeaters, 2-byte prefixes are recommended to avoid collisions.`; + rec = trn(2, '{count}-byte', '{count}-byte'); recDetail = tr('With {count} repeaters, 2-byte prefixes are recommended to avoid collisions.', { count: totalNodes }); } else { - rec = '2-byte'; recDetail = `With ${totalNodes} repeaters, 2-byte prefixes are strongly recommended.`; + rec = trn(2, '{count}-byte', '{count}-byte'); recDetail = tr('With {count} repeaters, 2-byte prefixes are strongly recommended.', { count: totalNodes }); } // URL params for pre-fill / auto-run @@ -3896,23 +3904,23 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet const initGenerate = hashParams.get('generate') || ''; const regionNote = regionLabel - ? `<p class="text-muted" style="font-size:0.85em;margin:4px 0 0">Showing data for region: <strong>${esc(regionLabel)}</strong>. <a href="#/analytics?tab=prefix-tool" style="color:var(--link-color)">Check all repeaters →</a></p>` + ? `<p class="text-muted" style="font-size:0.85em;margin:4px 0 0">${tr('Showing data for region: {region}.', { region: '<strong>' + esc(regionLabel) + '</strong>' })} <a href="#/analytics?tab=prefix-tool" style="color:var(--link-color)">${tr('Check all repeaters')} →</a></p>` : ''; el.innerHTML = ` <div class="analytics-card" id="ptOverview"> <div style="display:flex;align-items:center;gap:8px;cursor:pointer;user-select:none" id="ptOverviewToggle"> <span id="ptOverviewChevron" class="pt-overview-chevron" style="color:var(--text-muted);transition:transform 0.2s;display:inline-flex;align-items:center"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-caret-right"/></svg></span> - <h3 style="margin:0">Network Overview</h3> + <h3 style="margin:0">${tr('Network Overview')}</h3> </div> <div id="ptOverviewBody" style="display:none"> ${regionNote} <div style="display:flex;gap:12px;flex-wrap:wrap;margin:12px 0 16px"> <div class="analytics-stat-card" style="flex:1;min-width:110px"> - <div class="analytics-stat-label">Total repeaters</div> + <div class="analytics-stat-label">${tr('Total repeaters')}</div> <div class="analytics-stat-value">${totalNodes.toLocaleString()}</div> <div class="text-muted" style="font-size:0.78em;margin-top:4px"> - ${totalConfigured.toLocaleString()} with known hash size + ${tr('{count} with known hash size', { count: totalConfigured.toLocaleString() })} </div> </div> ${[1, 2, 3].map(b => { @@ -3928,8 +3936,8 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet // distinguish theoretical/would-collide-if-used from packet- // traffic-observed collisions shown on the Hash Issues tab. const opLine = opC === 0 - ? `<span style="color:var(--status-green-text)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-check-circle"/></svg> No address conflicts among configured repeaters</span>` - : `<span style="color:var(--status-red)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-warning"/></svg> ${opC} address conflict${opC !== 1 ? 's' : ''} among configured repeaters (would-collide-if-used)</span>`; + ? `<span style="color:var(--status-green-text)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-check-circle"/></svg> ${tr('No address conflicts among configured repeaters')}</span>` + : `<span style="color:var(--status-red)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-warning"/></svg> ${trn(opC, '{count} address conflict among configured repeaters (would-collide-if-used)', '{count} address conflicts among configured repeaters (would-collide-if-used)')}</span>`; // #1306: expandable WHICH-collides toggles (op + theoretical) const opEntries = collEntries.operational[b]; const theoEntries = collEntries.theoretical[b]; @@ -3940,7 +3948,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet <button type="button" class="pt-collide-toggle-btn" data-pt-collide-toggle="op-${b}" data-target="${opTogId}" aria-expanded="false" style="background:none;border:none;color:var(--link-color);cursor:pointer;font-size:0.82em;padding:0"> - Show ${opC} colliding slice${opC !== 1 ? 's' : ''} → + ${trn(opC, 'Show {count} colliding slice', 'Show {count} colliding slices')} → </button> <div id="${opTogId}" data-pt-collide-panel="op-${b}" style="display:none;margin-top:6px;max-height:400px;overflow-y:auto;border:1px solid var(--border);border-radius:4px;background:var(--bg)"> ${renderCollideTable(opEntries)} @@ -3951,7 +3959,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet <button type="button" class="pt-collide-toggle-btn" data-pt-collide-toggle="theo-${b}" data-target="${theoTogId}" aria-expanded="false" style="background:none;border:none;color:var(--link-color);cursor:pointer;font-size:0.78em;padding:0"> - Show ${theoC} would-collide slice${theoC !== 1 ? 's' : ''} (across all repeaters) → + ${trn(theoC, 'Show {count} would-collide slice (across all repeaters)', 'Show {count} would-collide slices (across all repeaters)')} → </button> <div id="${theoTogId}" data-pt-collide-panel="theo-${b}" style="display:none;margin-top:6px;max-height:400px;overflow-y:auto;border:1px solid var(--border);border-radius:4px;background:var(--bg)"> ${renderCollideTable(theoEntries)} @@ -3959,77 +3967,87 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet </div>` : ''; return ` <div class="analytics-stat-card" style="flex:1;min-width:170px;border-color:${borderVar}"> - <div class="analytics-stat-label">${b}-byte prefixes</div> + <div class="analytics-stat-label">${trn(b, '{count}-byte prefixes', '{count}-byte prefixes')}</div> <div class="analytics-stat-value" style="font-size:1em" data-pt-configured="${b}" data-value="${cfg}"> ${cfg.toLocaleString()} - <span class="text-muted" style="font-size:0.7em"> of ${totalNodes.toLocaleString()} repeaters configured</span> + <span class="text-muted" style="font-size:0.7em"> ${tr('of {count} repeaters configured', { count: totalNodes.toLocaleString() })}</span> </div> <div style="font-size:0.82em;margin-top:4px">${opLine}</div> ${opToggle} <div class="text-muted" style="font-size:0.72em;margin-top:6px;border-top:1px dashed var(--border);padding-top:4px"> - <em>Theoretical:</em> ${stats[b].usedPrefixes.toLocaleString()} unique ${b}-byte slice${stats[b].usedPrefixes !== 1 ? 's' : ''} - across all repeater pubkeys (of ${spaceSizes[b].toLocaleString()} possible)${theoC > 0 ? ` — ${theoC} would-collide if every repeater used ${b}-byte` : ''} + <em>${tr('Theoretical:')}</em> ${trn(stats[b].usedPrefixes, '{count} unique {size}-byte slice', '{count} unique {size}-byte slices', { count: stats[b].usedPrefixes.toLocaleString(), size: b })} + ${tr('across all repeater pubkeys (of {space} possible)', { space: spaceSizes[b].toLocaleString() })}${theoC > 0 ? ' — ' + tr('{count} would-collide if every repeater used {size}-byte', { count: theoC, size: b }) : ''} </div> ${theoToggle} </div>`; }).join('')} </div> <div style="background:var(--bg-secondary,var(--bg));border:1px solid var(--accent);border-radius:6px;padding:10px 14px;margin-bottom:12px;font-size:0.85em"> - <strong>ℹ️ Theoretical vs observed:</strong> These are <em>theoretical address conflicts</em> that would occur IF all repeaters used this hash size (would-collide-if-used). For collisions <em>actually observed in packet traffic</em>, see the <a href="#/analytics?tab=collisions" style="color:var(--link-color)">Hash Issues</a> tab. + <strong>ℹ️ ${tr('Theoretical vs observed:')}</strong> ${tr('These are {theoretical} that would occur IF all repeaters used this hash size (would-collide-if-used). For collisions {observed}, see the {link} tab.', { + theoretical: '<em>' + tr('theoretical address conflicts') + '</em>', + observed: '<em>' + tr('actually observed in packet traffic') + '</em>', + link: '<a href="#/analytics?tab=collisions" style="color:var(--link-color)">' + tr('Hash Issues') + '</a>', + })} </div> <div style="background:var(--bg-secondary,var(--bg));border:1px solid var(--border);border-radius:6px;padding:10px 14px;margin-bottom:12px"> - <strong>Recommendation: ${rec} prefixes</strong> — ${recDetail} - <span class="text-muted" style="font-size:0.8em;display:block;margin-top:4px">Hash size is configured per-node in firmware. Changing requires reflashing.</span> + <strong>${tr('Recommendation: {size} prefixes', { size: rec })}</strong> — ${recDetail} + <span class="text-muted" style="font-size:0.8em;display:block;margin-top:4px">${tr('Hash size is configured per-node in firmware. Changing requires reflashing.')}</span> </div> <div style="background:var(--bg-secondary,var(--bg));border:1px solid var(--border);border-radius:6px;padding:10px 14px;font-size:0.85em"> - <strong>ℹ️ About these numbers:</strong> The primary count is how many repeaters are <em>configured</em> for each hash size (their advertised path hash byte length), matching the - <a href="#/analytics?tab=hashsizes" style="color:var(--link-color)">Hash Stats</a> tab. Address conflicts (would-collide-if-used) count colliding slices among repeaters configured for the same hash size — same definition the - <a href="#/analytics?tab=collisions" style="color:var(--link-color)">Hash Issues</a> tab uses, except Hash Issues counts collisions <em>actually observed in packet traffic</em> rather than theoretical. The <em>theoretical</em> line shows the math fact: how many distinct slices appear when every repeater pubkey is truncated to N bytes, regardless of configured hash size. + <strong>ℹ️ ${tr('About these numbers:')}</strong> ${tr('The primary count is how many repeaters are {configured} for each hash size (their advertised path hash byte length), matching the {hashStats} tab. Address conflicts (would-collide-if-used) count colliding slices among repeaters configured for the same hash size — same definition the {hashIssues} tab uses, except Hash Issues counts collisions {observed} rather than theoretical. The {theoretical} line shows the math fact: how many distinct slices appear when every repeater pubkey is truncated to N bytes, regardless of configured hash size.', { + configured: '<em>' + tr('configured') + '</em>', + hashStats: '<a href="#/analytics?tab=hashsizes" style="color:var(--link-color)">' + tr('Hash Stats') + '</a>', + hashIssues: '<a href="#/analytics?tab=collisions" style="color:var(--link-color)">' + tr('Hash Issues') + '</a>', + observed: '<em>' + tr('actually observed in packet traffic') + '</em>', + theoretical: '<em>' + tr('theoretical') + '</em>', + })} </div> </div> </div> <div class="analytics-card" id="ptChecker"> - <h3 style="margin-top:0">Check a Prefix</h3> - <p class="text-muted" style="margin-top:0;font-size:0.9em">Enter a 1-byte (2 hex chars), 2-byte (4 hex chars), or 3-byte (6 hex chars) prefix — or paste a full public key.</p> + <h3 style="margin-top:0">${tr('Check a Prefix')}</h3> + <p class="text-muted" style="margin-top:0;font-size:0.9em">${tr('Enter a 1-byte (2 hex chars), 2-byte (4 hex chars), or 3-byte (6 hex chars) prefix — or paste a full public key.')}</p> <div style="display:flex;gap:8px;align-items:flex-start;flex-wrap:wrap"> - <input id="ptPrefixInput" type="text" placeholder="e.g. A3F1" maxlength="64" + <input id="ptPrefixInput" type="text" placeholder="${tr('e.g. {example}', { example: 'A3F1' })}" maxlength="64" style="font-family:var(--mono);font-size:1em;padding:6px 10px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;min-width:180px;flex:1" value="${esc(initPrefix)}"> - <button id="ptCheckBtn" class="btn-active-accent" style="padding:6px 16px;border:none;border-radius:4px;cursor:pointer;font-size:0.95em">Check</button> + <button id="ptCheckBtn" class="btn-active-accent" style="padding:6px 16px;border:none;border-radius:4px;cursor:pointer;font-size:0.95em">${tr('Check')}</button> </div> <div id="ptCheckerResults" style="margin-top:14px"></div> </div> <div class="analytics-card" id="ptGenerator"> - <h3 style="margin-top:0">Generate Available Prefix</h3> - <p class="text-muted" style="margin-top:0;font-size:0.9em">Find a prefix with zero current collisions.</p> + <h3 style="margin-top:0">${tr('Generate Available Prefix')}</h3> + <p class="text-muted" style="margin-top:0;font-size:0.9em">${tr('Find a prefix with zero current collisions.')}</p> <p class="text-muted" style="margin:4px 0 10px;font-size:0.82em"> <span aria-hidden="true"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-prohibit"/></svg></span> - <strong>0x00 and 0xFF excluded</strong> as a first byte — the MeshCore firmware keygen routine re-rolls identities whose <code>pub_key[0]</code> is <code>00</code> or <code>FF</code>, so by convention you should not see those prefixes on real nodes (see - <a href="https://github.com/meshcore-dev/MeshCore/blob/8ede7641/examples/simple_repeater/main.cpp#L83" - target="_blank" rel="noopener noreferrer" style="color:var(--link-color)">simple_repeater/main.cpp:83</a>). + ${tr('{excluded} as a first byte — the MeshCore firmware keygen routine re-rolls identities whose {field} is {zero} or {ff}, so by convention you should not see those prefixes on real nodes (see {link}).', { + excluded: '<strong>' + tr('0x00 and 0xFF excluded') + '</strong>', + field: '<code>pub_key[0]</code>', zero: '<code>00</code>', ff: '<code>FF</code>', + link: '<a href="https://github.com/meshcore-dev/MeshCore/blob/8ede7641/examples/simple_repeater/main.cpp#L83" target="_blank" rel="noopener noreferrer" style="color:var(--link-color)">simple_repeater/main.cpp:83</a>', + })} </p> <div style="display:flex;gap:16px;align-items:center;flex-wrap:wrap;margin-bottom:12px"> <label style="display:flex;align-items:center;gap:6px;cursor:pointer"> - <input type="radio" name="ptGenSize" value="1" ${initGenerate === '1' ? 'checked' : ''}> 1-byte + <input type="radio" name="ptGenSize" value="1" ${initGenerate === '1' ? 'checked' : ''}> ${trn(1, '{count}-byte', '{count}-byte')} </label> <label style="display:flex;align-items:center;gap:6px;cursor:pointer"> - <input type="radio" name="ptGenSize" value="2" ${initGenerate !== '1' && initGenerate !== '3' ? 'checked' : ''}> 2-byte - <span class="text-muted" style="font-size:0.8em">(recommended)</span> + <input type="radio" name="ptGenSize" value="2" ${initGenerate !== '1' && initGenerate !== '3' ? 'checked' : ''}> ${trn(2, '{count}-byte', '{count}-byte')} + <span class="text-muted" style="font-size:0.8em">(${tr('recommended')})</span> </label> <label style="display:flex;align-items:center;gap:6px;cursor:pointer"> - <input type="radio" name="ptGenSize" value="3" ${initGenerate === '3' ? 'checked' : ''}> 3-byte + <input type="radio" name="ptGenSize" value="3" ${initGenerate === '3' ? 'checked' : ''}> ${trn(3, '{count}-byte', '{count}-byte')} </label> - <button id="ptGenBtn" class="btn-active-accent" style="padding:6px 16px;border:none;border-radius:4px;cursor:pointer;font-size:0.95em">Generate</button> + <button id="ptGenBtn" class="btn-active-accent" style="padding:6px 16px;border:none;border-radius:4px;cursor:pointer;font-size:0.95em">${tr('Generate')}</button> </div> <div id="ptGenResult"></div> <div style="margin-top:14px;padding:10px 14px;border:1px solid var(--accent);border-radius:6px;background:var(--bg-secondary,var(--bg));font-size:0.88em"> - <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-book-open"/></svg> <strong>New to multi-byte prefixes?</strong> + <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-book-open"/></svg> <strong>${tr('New to multi-byte prefixes?')}</strong> <a href="https://github.com/meshcore-dev/MeshCore/blob/main/docs/faq.md#39-q-what-is-multi-byte-support--what-do-1-byte-2-byte-3-byte-adverts-and-messages-mean" target="_blank" rel="noopener noreferrer" style="color:var(--link-color);margin-left:4px"> - Read the MeshCore FAQ on multi-byte support → + ${tr('Read the MeshCore FAQ on multi-byte support')} → </a> </div> </div>`; @@ -4037,16 +4055,16 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet // --- Helpers --- function nodeEntry(n) { const name = esc(n.name || n.public_key.slice(0, 12)); - const role = n.role ? `<span class="text-muted" style="font-size:0.82em">${esc(n.role)}</span>` : ''; - const hs = n.hash_size ? ` <span class="text-muted" style="font-size:0.78em;opacity:0.7">${n.hash_size}B hash</span>` : ''; + const role = n.role ? `<span class="text-muted" style="font-size:0.82em">${esc(tr(n.role))}</span>` : ''; + const hs = n.hash_size ? ` <span class="text-muted" style="font-size:0.78em;opacity:0.7">${tr('{count}B hash', { count: n.hash_size })}</span>` : ''; const when = n.last_seen ? ` <span class="text-muted" style="font-size:0.8em">${(typeof formatAbsoluteTimestamp === 'function') ? formatAbsoluteTimestamp(n.last_seen) : new Date(n.last_seen).toLocaleDateString()}</span>` : ''; return `<div style="padding:3px 0"><a href="#/nodes/${encodeURIComponent(n.public_key)}" class="analytics-link">${name}</a> ${role}${hs}${when}</div>`; } function severityBadge(count) { - if (count === 0) return '<span style="color:var(--status-green-text)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-check-circle"/></svg> Unique</span>'; - if (count <= 2) return `<span style="color:var(--status-yellow)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-warning"/></svg> ${count} collision${count !== 1 ? 's' : ''}</span>`; - return `<span style="color:var(--status-red)"><span style="color:var(--status-red)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-circle-fill"/></svg></span> ${count} collisions</span>`; + if (count === 0) return '<span style="color:var(--status-green-text)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-check-circle"/></svg> ' + tr('Unique') + '</span>'; + if (count <= 2) return `<span style="color:var(--status-yellow)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-warning"/></svg> ${trn(count, '{count} collision', '{count} collisions')}</span>`; + return `<span style="color:var(--status-red)"><span style="color:var(--status-red)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-circle-fill"/></svg></span> ${trn(count, '{count} collision', '{count} collisions')}</span>`; } // --- Checker --- @@ -4057,11 +4075,11 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet if (!input) { resultsEl.innerHTML = ''; return; } if (!/^[0-9A-F]+$/.test(input)) { - resultsEl.innerHTML = '<p style="color:var(--status-red);margin:0">Invalid input — hex characters only (0-9, A-F).</p>'; + resultsEl.innerHTML = '<p style="color:var(--status-red);margin:0">' + tr('Invalid input — hex characters only (0-9, A-F).') + '</p>'; return; } if (input.length % 2 !== 0 || (input.length !== 2 && input.length !== 4 && input.length !== 6 && input.length < 8)) { - resultsEl.innerHTML = '<p style="color:var(--status-red);margin:0">Prefix must be 2, 4, or 6 hex characters. For a full public key, use 64 characters.</p>'; + resultsEl.innerHTML = '<p style="color:var(--status-red);margin:0">' + tr('Prefix must be 2, 4, or 6 hex characters. For a full public key, use 64 characters.') + '</p>'; return; } @@ -4078,15 +4096,18 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet if (typeof PrefixReserved !== 'undefined' && PrefixReserved && PrefixReserved.isReservedPrefix(input)) { html += `<div role="alert" style="margin-bottom:10px;padding:10px 14px;border:1px solid var(--status-yellow);border-radius:6px;background:var(--bg-secondary,var(--bg))"> - <strong style="color:var(--status-yellow)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-warning"/></svg> Firmware avoids this first byte</strong> + <strong style="color:var(--status-yellow)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-warning"/></svg> ${tr('Firmware avoids this first byte')}</strong> <div class="text-muted" style="font-size:0.85em;margin-top:4px"> - <code class="mono">${input.slice(0,2)}</code> as the first byte of a node pubkey is avoided by the MeshCore firmware keygen convention (the standard repeater re-rolls identities whose <code class="mono">pub_key[0]</code> is <code class="mono">00</code> or <code class="mono">FF</code>). You generally shouldn't see this on real nodes. + ${tr("{byte} as the first byte of a node pubkey is avoided by the MeshCore firmware keygen convention (the standard repeater re-rolls identities whose {field} is {zero} or {ff}). You generally shouldn't see this on real nodes.", { + byte: '<code class="mono">' + input.slice(0,2) + '</code>', + field: '<code class="mono">pub_key[0]</code>', zero: '<code class="mono">00</code>', ff: '<code class="mono">FF</code>', + })} </div> </div>`; } if (isFullKey) { const inNetwork = nodes.some(n => n.public_key.toUpperCase() === input); - html += `<p class="text-muted" style="font-size:0.85em;margin:0 0 10px">Derived prefixes: <code class="mono">${input.slice(0,2)}</code> / <code class="mono">${input.slice(0,4)}</code> / <code class="mono">${input.slice(0,6)}</code>${!inNetwork ? ' — <em>this node is not yet in the network</em>' : ''}</p>`; + html += `<p class="text-muted" style="font-size:0.85em;margin:0 0 10px">${tr('Derived prefixes:')} <code class="mono">${input.slice(0,2)}</code> / <code class="mono">${input.slice(0,4)}</code> / <code class="mono">${input.slice(0,6)}</code>${!inNetwork ? ' — <em>' + tr('this node is not yet in the network') + '</em>' : ''}</p>`; } tiers.forEach(({ b, prefix }) => { @@ -4097,11 +4118,11 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet <div style="margin-bottom:10px;padding:10px 14px;border:1px solid var(--border);border-radius:6px;background:var(--bg-secondary,var(--bg))"> <div style="display:flex;align-items:center;gap:8px;margin-bottom:6px"> <code class="mono" style="font-weight:700">${prefix}</code> - <span class="text-muted" style="font-size:0.82em">${b}-byte</span> + <span class="text-muted" style="font-size:0.82em">${trn(b, '{count}-byte', '{count}-byte')}</span> ${severityBadge(count)} </div> ${count === 0 - ? '<div class="text-muted" style="font-size:0.85em">No existing nodes use this prefix.</div>' + ? '<div class="text-muted" style="font-size:0.85em">' + tr('No existing nodes use this prefix.') + '</div>' : `<div style="font-size:0.85em;max-height:140px;overflow-y:auto">${colliders.map(nodeEntry).join('')}</div>`} </div>`; }); @@ -4136,8 +4157,8 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet const available = totalSpace - idx[b].size - (reservedTotal - reservedUsed); if (available <= 0) { - const next = b < 3 ? (b + 1) + '-byte' : 'a different size'; - genResultEl.innerHTML = `<p style="color:var(--status-red);margin:0">No collision-free ${b}-byte prefixes available. Try ${next}.</p>`; + const next = b < 3 ? trn(b + 1, '{count}-byte', '{count}-byte') : tr('a different size'); + genResultEl.innerHTML = `<p style="color:var(--status-red);margin:0">${tr('No collision-free {size}-byte prefixes available. Try {next}.', { size: b, next: next })}</p>`; return; } @@ -4174,14 +4195,14 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet <div style="padding:12px 16px;border:1px solid var(--status-green);border-radius:6px;background:var(--bg-secondary,var(--bg))"> <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap"> <code class="mono" style="font-size:1.3em;font-weight:700;color:var(--status-green-text)">${prefix}</code> - <span style="color:var(--status-green-text)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-check-circle"/></svg> No existing nodes use this prefix</span> + <span style="color:var(--status-green-text)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-check-circle"/></svg> ${tr('No existing nodes use this prefix')}</span> </div> - <div class="text-muted" style="font-size:0.85em;margin-top:6px">${available.toLocaleString()} of ${totalSpace.toLocaleString()} ${b}-byte prefixes are available.</div> + <div class="text-muted" style="font-size:0.85em;margin-top:6px">${tr('{available} of {total} {size}-byte prefixes are available.', { available: available.toLocaleString(), total: totalSpace.toLocaleString(), size: b })}</div> <div style="margin-top:10px;display:flex;gap:8px;flex-wrap:wrap;align-items:center"> - <button id="ptRegenBtn" style="padding:5px 14px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;cursor:pointer;font-size:0.9em">Try another</button> + <button id="ptRegenBtn" style="padding:5px 14px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;cursor:pointer;font-size:0.9em">${tr('Try another')}</button> <a href="https://agessaman.github.io/meshcore-web-keygen/?prefix=${prefix}" target="_blank" rel="noopener noreferrer" style="padding:5px 14px;background:var(--bg);color:var(--link-color);border:1px solid var(--border);border-radius:4px;text-decoration:none;font-size:0.9em"> - Generate key with this prefix → + ${tr('Generate key with this prefix')} → </a> </div> </div>`; @@ -4279,20 +4300,20 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet <div class="rf-health-container"> <div class="rf-time-selector"> ${rangeButtons} - <button class="rf-range-btn${_rfHealthState.range === 'custom' ? ' active' : ''}" data-range="custom">Custom</button> + <button class="rf-range-btn${_rfHealthState.range === 'custom' ? ' active' : ''}" data-range="custom">${tr('Custom')}</button> <span class="rf-custom-inputs" style="display:${_rfHealthState.range === 'custom' ? 'inline' : 'none'}"> <input type="datetime-local" class="rf-datetime" id="rfFrom" value="${_rfHealthState.customFrom}"> <span>→</span> <input type="datetime-local" class="rf-datetime" id="rfTo" value="${_rfHealthState.customTo}"> - <button class="rf-range-btn" id="rfCustomApply">Apply</button> + <button class="rf-range-btn" id="rfCustomApply">${tr('Apply')}</button> </span> </div> <div class="rf-health-split"> <div id="rfHealthGrid" class="rf-health-grid"> - <div class="text-muted" style="padding:20px">Loading RF metrics…</div> + <div class="text-muted" style="padding:20px">${tr('Loading RF metrics…')}</div> </div> <div id="rfHealthDetail" class="rf-health-detail rf-panel-empty"> - <span>Select an observer to view details</span> + <span>${tr('Select an observer to view details')}</span> </div> </div> </div>`; @@ -4344,7 +4365,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet }); if (!filteredObservers.length) { - grid.innerHTML = '<div class="text-muted" style="padding:20px">No RF metrics data available yet. Metrics are collected from observer status messages every ~5 minutes.</div>'; + grid.innerHTML = '<div class="text-muted" style="padding:20px">' + tr('No RF metrics data available yet. Metrics are collected from observer status messages every ~5 minutes.') + '</div>'; return; } @@ -4364,7 +4385,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet else if (obs.current_noise_floor >= -100) nfClass = 'rf-nf-warning'; } - return `<div class="rf-cell${isSelected ? ' rf-cell-selected' : ''}" data-observer="${esc(obs.observer_id)}" tabindex="0" role="button" aria-label="Observer ${esc(name)}, noise floor ${nf} dBm"> + return `<div class="rf-cell${isSelected ? ' rf-cell-selected' : ''}" data-observer="${esc(obs.observer_id)}" tabindex="0" role="button" aria-label="${tr('Observer {name}, noise floor {nf} dBm', { name: esc(name), nf: nf })}"> <div class="rf-cell-header"> <span class="rf-cell-name">${esc(name)}</span> <span class="rf-cell-nf ${nfClass}">${nf} dBm</span> @@ -4372,9 +4393,9 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet </div> <div class="rf-cell-sparkline" id="rf-spark-${esc(obs.observer_id)}"></div> <div class="rf-cell-stats"> - <span>avg: ${avgNf}</span> - <span>max: ${maxNf}</span> - <span>${obs.sample_count} samples</span> + <span>${tr('avg:')} ${avgNf}</span> + <span>${tr('max:')} ${maxNf}</span> + <span>${trn(obs.sample_count, '{count} samples', '{count} samples')}</span> <span data-testid="rf-cell-airtime">TX 1h: ${ObserverAirtime.badge(obs.tx_airtime_pct_1h, summaryData.duty_cycle_warn_pct)}</span> </div> </div>`; @@ -4413,7 +4434,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet } } } catch (e) { - grid.innerHTML = `<div class="text-muted" style="padding:20px">Failed to load RF health data: ${esc(e.message)}</div>`; + grid.innerHTML = `<div class="text-muted" style="padding:20px">${tr('Failed to load RF health data: {message}', { message: esc(e.message) })}</div>`; } } @@ -4427,7 +4448,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet if (container && nfValues.length > 1) { container.innerHTML = rfNFSparkline(nfValues, 140, 24); } else if (container) { - container.innerHTML = '<span class="text-muted" style="font-size:10px">insufficient data</span>'; + container.innerHTML = '<span class="text-muted" style="font-size:10px">' + tr('insufficient data') + '</span>'; } } catch (e) { // Non-fatal — sparkline just won't render @@ -4454,12 +4475,12 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet refs += `<line x1="0" y1="${y100.toFixed(1)}" x2="${w}" y2="${y100.toFixed(1)}" stroke="var(--text-muted)" stroke-width="0.5" stroke-dasharray="2"/>`; } - return `<svg viewBox="0 0 ${w} ${h}" style="width:${w}px;height:${h}px" role="img" aria-label="Noise floor sparkline"><title>Noise floor trend${refs}`; + return `${tr('Noise floor trend')}${refs}`; } async function loadRFHealthDetail(observerId, container) { container.classList.remove('rf-panel-empty'); - container.innerHTML = '
Loading detail…
'; + container.innerHTML = '
' + tr('Loading detail…') + '
'; const { since, until } = rfHealthTimeRangeToParams(_rfHealthState.range, _rfHealthState.customFrom, _rfHealthState.customTo); // Choose resolution based on time range @@ -4474,7 +4495,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet const name = data.observer_name || observerId.substring(0, 8); if (!metrics.length) { - container.innerHTML = `
No metrics data for ${esc(name)} in selected time range.
`; + container.innerHTML = `
${tr('No metrics data for {name} in selected time range.', { name: esc(name) })}
`; return; } @@ -4504,7 +4525,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet container.innerHTML = `

${esc(name)}

- +
@@ -4513,13 +4534,13 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet ${hasBattery ? '
' : ''}
- NF: ${curNf} dBm | avg: ${avgNf} | min: ${minNf} | max: ${maxNf} | TX: ${curTx} | RX: ${curRx} | Err: ${curErr} | Batt: ${curBatt}${reboots.length ? ' | ' + reboots.length + ' reboots' : ''} + NF: ${curNf} dBm | ${tr('avg:')} ${avgNf} | ${tr('min:')} ${minNf} | ${tr('max:')} ${maxNf} | TX: ${curTx} | RX: ${curRx} | ${tr('Err:')} ${curErr} | ${tr('Batt:')} ${curBatt}${reboots.length ? ' | ' + trn(reboots.length, '{count} reboots', '{count} reboots') : ''}
`; // Close button container.querySelector('.rf-detail-close').addEventListener('click', () => { container.classList.add('rf-panel-empty'); - container.innerHTML = 'Select an observer to view details'; + container.innerHTML = '' + tr('Select an observer to view details') + ''; _rfHealthState.selectedObserver = null; rfHealthUpdateHash(); document.querySelectorAll('.rf-cell').forEach(c => c.classList.remove('rf-cell-selected')); @@ -4535,7 +4556,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet if (nfEl && nfData.length > 1) { nfEl.innerHTML = rfNFColumnChart(nfData, nfEl.clientWidth || 700, 180, reboots, minT, maxT); } else if (nfEl) { - nfEl.innerHTML = 'Not enough noise floor data'; + nfEl.innerHTML = '' + tr('Not enough noise floor data') + ''; } // Render airtime chart @@ -4562,7 +4583,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet } } } catch (e) { - container.innerHTML = `
Failed to load detail: ${esc(e.message)}
`; + container.innerHTML = `
${tr('Failed to load detail: {message}', { message: esc(e.message) })}
`; } } @@ -4573,7 +4594,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet const x = sx(rt); if (x >= pad.left && x <= w - pad.right) { svg += ``; - svg += `reboot`; + svg += `${tr('reboot')}`; } } return svg; @@ -4625,10 +4646,10 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet const sx = t => pad.left + ((t - minT) / rangeT) * cw; const sy = v => pad.top + ch - (v / yMax) * ch; - let svg = `Airtime %`; + let svg = `${tr('Airtime %')}`; // Chart title - svg += `Airtime %`; + svg += `${tr('Airtime %')}`; // Y-axis: 5 ticks from 0 to yMax const yTicks = 4; @@ -4699,10 +4720,10 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet const sx = t => pad.left + ((t - minT) / rangeT) * cw; const sy = v => pad.top + ch - (v / rangeV) * ch; - let svg = `Error Rate`; + let svg = `${tr('Error Rate')}`; // Chart title - svg += `Error Rate %`; + svg += `${tr('Error Rate %')}`; // Y-axis const yTicks = 4; @@ -4730,7 +4751,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet svg += rfXAxisLabels(errData, sx, h, pad); // Hover tooltips - svg += rfTooltipCircles(errData, sx, sy, 'Err', '%', v => v.toFixed(2)); + svg += rfTooltipCircles(errData, sx, sy, tr('Err'), '%', v => v.toFixed(2)); svg += ''; return svg; @@ -4752,10 +4773,10 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet const sx = t => pad.left + ((t - minT) / rangeT) * cw; const sy = v => pad.top + ch - ((v - minV) / rangeV) * ch; - let svg = `Battery`; + let svg = `${tr('Battery')}`; // Chart title - svg += `Battery`; + svg += `${tr('Battery')}`; // Y-axis (in volts) const yTicks = 4; @@ -4771,7 +4792,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet if (lowBattMv >= minV && lowBattMv <= maxV) { const y = sy(lowBattMv); svg += ``; - svg += `3.3V low`; + svg += `${tr('3.3V low')}`; } // Reboot markers @@ -4791,7 +4812,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet svg += rfXAxisLabels(battData, sx, h, pad); // Hover tooltips - svg += rfTooltipCircles(battData, sx, sy, 'Batt', 'V', v => (v/1000).toFixed(2)); + svg += rfTooltipCircles(battData, sx, sy, tr('Batt'), 'V', v => (v/1000).toFixed(2)); svg += ''; return svg; @@ -4829,13 +4850,13 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet const times = data.map(d => new Date(d.t).getTime()); - let svg = `Noise floor over time`; + let svg = `${tr('Noise floor over time')}`; // Inline style for hover highlighting svg += ``; // Chart title - svg += `Noise Floor dBm`; + svg += `${tr('Noise Floor dBm')}`; // Y-axis labels + grid lines const yTicks = 5; @@ -4891,12 +4912,12 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet // #690 — Clock Health fleet view (M3) async function renderClockHealthTab(el) { - el.innerHTML = '
Loading clock health data…
'; + el.innerHTML = '
' + tr('Loading clock health data…') + '
'; try { const aqs = AreaFilter.areaQueryString(); var data = await (await fetch('/api/nodes/clock-skew' + (aqs ? '?' + aqs.slice(1) : ''))).json(); if (!Array.isArray(data) || !data.length) { - el.innerHTML = '
No clock skew data available. Nodes need recent adverts for clock analysis.
'; + el.innerHTML = '
' + tr('No clock skew data available. Nodes need recent adverts for clock analysis.') + '
'; return; } @@ -4934,7 +4955,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet var filterHtml = '
' + filters.map(function(f) { var dot = f !== 'all' ? '' : ''; return ''; }).join('') + '
'; @@ -4943,7 +4964,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet var rowClass = 'clock-fleet-row--' + (n.severity || 'ok'); var lastAdv = n.lastObservedTS ? new Date(n.lastObservedTS * 1000).toISOString().replace('T', ' ').replace(/\.\d+Z/, ' UTC') : '—'; var skewVal = window.currentSkewValue(n); - var skewText = n.severity === 'no_clock' ? 'No Clock' : formatSkew(skewVal); + var skewText = n.severity === 'no_clock' ? tr('No Clock') : formatSkew(skewVal); var driftText = n.severity === 'no_clock' || !n.driftPerDaySec ? '–' : formatDrift(n.driftPerDaySec); return '' + '' + esc(n.nodeName || n.pubkey.slice(0, 12)) + '' + @@ -4954,15 +4975,15 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet ''; }).join(''); - el.innerHTML = '

Clock Health

' + + el.innerHTML = '

' + tr('Clock Health') + '

' + filterHtml + '' + '' + - '' + - '' + - '' + - '' + - '' + + '' + + '' + + '' + + '' + + '' + '' + rowsHtml + '
NameSkewSeverityDrift RateLast Advert' + tr('Name') + '' + tr('Skew') + '' + tr('Severity') + '' + tr('Drift Rate') + '' + tr('Last Advert') + '
'; // Bind filter clicks @@ -4993,7 +5014,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet render(); } catch (err) { - el.innerHTML = '
Failed to load clock health data: ' + esc(String(err)) + '
'; + el.innerHTML = '
' + tr('Failed to load clock health data: {message}', { message: esc(String(err)) }) + '
'; } } @@ -5001,24 +5022,22 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet // #1979: flood adverts by sender role, split by scope state. Descriptive // only: it shows what each role sent, not why. function scopeAdvertsByRoleHtml(rows) { - var html = '

Flood adverts by node role

' + + var html = '

' + tr('Flood adverts by node role') + '

' + '

' + - 'Flood adverts (routes 0 and 1) in this window, grouped by the sender\'s role. ' + - 'Shares are of the row. Unknown scope means scoped, but this instance could not name the region. ' + - 'This shows what each role sent; it does not show why.' + + tr("Flood adverts (routes 0 and 1) in this window, grouped by the sender's role. Shares are of the row. Unknown scope means scoped, but this instance could not name the region. This shows what each role sent; it does not show why.") + '

'; if (!rows || !rows.length) { - return html + '

No flood adverts in this window.

'; + return html + '

' + tr('No flood adverts in this window.') + '

'; } return html + '' + - '' + + '' + '' + rows.map(function(r) { var total = r.unscoped + r.unknownScope + r.named; function cell(n) { return ''; } - return '' + + return '' + '' + cell(r.unscoped) + cell(r.unknownScope) + cell(r.named) + ''; }).join('') + '' + @@ -5032,9 +5051,9 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet // Fix 5: write static frame only once if (!el.querySelector('#scopes-cards')) { el.innerHTML = - '

Scope Statistics

' + + '

' + tr('Scope Statistics') + '

' + '

' + - 'Denominator is all observed transmissions. Only TRANSPORT_FLOOD (0) and TRANSPORT_DIRECT (3) routes carry a scope; FLOOD (1) and DIRECT (2) are inherently unscoped per MeshCore protocol.' + + tr('Denominator is all observed transmissions. Only TRANSPORT_FLOOD (0) and TRANSPORT_DIRECT (3) routes carry a scope; FLOOD (1) and DIRECT (2) are inherently unscoped per MeshCore protocol.') + '

' + '
' + ['1h', '24h', '7d'].map(function(v) { @@ -5042,9 +5061,9 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet }).join('') + '
' + '
' + - '
Loading scope stats…
' + + '
' + tr('Loading scope stats…') + '
' + '
RoleAdvertsUnscopedUnknown scopeNamed scope
' + tr('Role') + '' + tr('Adverts') + '' + tr('Unscoped') + '' + tr('Unknown scope') + '' + tr('Named scope') + '
' + n.toLocaleString() + ' (' + (n / total * 100).toFixed(1) + '%)
' + esc(r.role) + '
' + esc(tr(r.role)) + '' + total.toLocaleString() + '
' + - '' + + '' + '' + '
RegionMessages% of Scoped
' + tr('Region') + '' + tr('Messages') + '' + tr('% of Scoped') + '
' + '
' + @@ -5082,7 +5101,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet } catch (err) { if (loadingEl) loadingEl.style.display = 'none'; var cardsEl3 = document.getElementById('scopes-cards'); - if (cardsEl3) cardsEl3.innerHTML = '
Failed to load scope stats: ' + esc(String(err)) + '
'; + if (cardsEl3) cardsEl3.innerHTML = '
' + tr('Failed to load scope stats: {message}', { message: esc(String(err)) }) + '
'; } } @@ -5098,10 +5117,10 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet var cardsEl = document.getElementById('scopes-cards'); if (cardsEl) { cardsEl.innerHTML = [ - { label: 'Transport Total', value: total.toLocaleString(), note: 'routes 0,3 (carry scope)' }, - { label: 'Scoped', value: s.scoped.toLocaleString(), note: pct(s.scoped, overall) + ' of all traffic' }, - { label: 'Unscoped', value: s.unscoped.toLocaleString(), note: pct(s.unscoped, overall) + ' of all traffic' }, - { label: 'Unknown Scope', value: s.unknownScope.toLocaleString(), note: pct(s.unknownScope, s.scoped) + ' of scoped' }, + { label: tr('Transport Total'), value: total.toLocaleString(), note: tr('routes 0,3 (carry scope)') }, + { label: tr('Scoped'), value: s.scoped.toLocaleString(), note: tr('{pct} of all traffic', { pct: pct(s.scoped, overall) }) }, + { label: tr('Unscoped'), value: s.unscoped.toLocaleString(), note: tr('{pct} of all traffic', { pct: pct(s.unscoped, overall) }) }, + { label: tr('Unknown Scope'), value: s.unknownScope.toLocaleString(), note: tr('{pct} of scoped', { pct: pct(s.unknownScope, s.scoped) }) }, ].map(function(c) { return '
' + c.value + '
' + '
' + c.label + '
' + @@ -5121,14 +5140,14 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet '' + pct(r.count, s.scoped) + ''; }).join(''); if (s.unknownScope > 0) { - tableBody += 'Unknown scope' + + tableBody += '' + tr('Unknown scope') + '' + '' + s.unknownScope.toLocaleString() + '' + '' + pct(s.unknownScope, s.scoped) + ''; } } else if (s.scoped === 0) { - tableBody = 'No scoped messages in this window'; + tableBody = '' + tr('No scoped messages in this window') + ''; } else { - tableBody = 'No regions configured — add hashRegions to your config'; + tableBody = '' + tr('No regions configured — add {key} to your config', { key: 'hashRegions' }) + ''; } tbodyEl.innerHTML = tableBody; } @@ -5163,17 +5182,17 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet var legendX = padL + plotW - 120; chartHtml = '
' + - '' + + '' + grid + '' + '' + '' + - 'Scoped' + + '' + tr('Scoped') + '' + '' + - 'Unscoped' + + '' + tr('Unscoped') + '' + '
'; } else { - chartHtml = '

Insufficient data points to render chart — wait for more observations in this window.

'; + chartHtml = '

' + tr('Insufficient data points to render chart — wait for more observations in this window.') + '

'; } chartEl.innerHTML = chartHtml; } @@ -5199,7 +5218,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet // Auto-refreshes every 60s while the Roles tab is active (matches the // behavior of the former standalone roles-page.js). async function renderRolesTab(el) { - el.innerHTML = '
Loading roles…
'; + el.innerHTML = '
' + tr('Loading roles…') + '
'; await _renderRolesTabBody(el); // (Re)start the 60s auto-refresh. _stopRolesRefresh(); @@ -5218,7 +5237,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet var roles = (data && data.roles) || []; var total = (data && data.totalNodes) || 0; if (!roles.length) { - el.innerHTML = '
No roles to show.
'; + el.innerHTML = '
' + tr('No roles to show.') + '
'; return; } var maxCount = roles.reduce(function (m, r) { return Math.max(m, r.nodeCount || 0); }, 0) || 1; @@ -5226,14 +5245,14 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet var pct = total > 0 ? ((r.nodeCount / total) * 100).toFixed(1) : '0.0'; var barW = Math.round((r.nodeCount / maxCount) * 100); var sevCells = - '' + (r.okCount || 0) + ' / ' + - '' + (r.warningCount || 0) + ' / ' + - '' + (r.criticalCount || 0) + ' / ' + - '' + (r.absurdCount || 0) + ' / ' + - '' + (r.noClockCount || 0) + ''; + '' + (r.okCount || 0) + ' / ' + + '' + (r.warningCount || 0) + ' / ' + + '' + (r.criticalCount || 0) + ' / ' + + '' + (r.absurdCount || 0) + ' / ' + + '' + (r.noClockCount || 0) + ''; return '' + '' + - '' + _rolesEmoji(r.role) + ' ' + esc(r.role) + '' + + '' + _rolesEmoji(r.role) + ' ' + esc(tr(r.role)) + '' + '' + r.nodeCount + '' + '' + pct + '%' + '' + @@ -5248,25 +5267,25 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet ''; }).join(''); el.innerHTML = - '

Distribution of node roles across the mesh, with per-role clock-skew posture.

' + + '

' + tr('Distribution of node roles across the mesh, with per-role clock-skew posture.') + '

' + '
' + - '' + total + ' nodes across ' + roles.length + ' roles' + + tr('{nodes} nodes across {roles} roles', { nodes: '' + total + '', roles: '' + roles.length + '' }) + '
' + '' + '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + '' + '' + rows + '' + '
RoleCountShareDistributionw/ SkewMedian |skew|Mean |skew|Severity' + tr('Role') + '' + tr('Count') + '' + tr('Share') + '' + tr('Distribution') + '' + tr('w/ Skew') + '' + tr('Median |skew|') + '' + tr('Mean |skew|') + '' + tr('Severity') + '
'; } catch (err) { - el.innerHTML = '
Failed to load roles: ' + esc(String(err.message || err)) + '
'; + el.innerHTML = '
' + tr('Failed to load roles: {message}', { message: esc(String(err.message || err)) }) + '
'; } } diff --git a/public/app.js b/public/app.js index 6b177c896..276ac5c8d 100644 --- a/public/app.js +++ b/public/app.js @@ -1,6 +1,16 @@ /* === CoreScope — app.js === */ 'use strict'; +// --- UI translations --- +// i18n.js loads before this file in the browser and defines tr()/trn(). +// Sandboxes that load app.js on its own get English with {placeholders} filled. +if (typeof globalThis.tr !== 'function') { + globalThis.tr = function (s, p) { + return p ? s.replace(/\{(\w+)\}/g, (m, k) => (Object.prototype.hasOwnProperty.call(p, k) ? String(p[k]) : m)) : s; + }; + globalThis.trn = function (n, one, other, p) { return globalThis.tr(n === 1 ? one : other, Object.assign({ count: n }, p)); }; +} + // --- Route/Payload name maps --- const ROUTE_TYPES = { 0: 'TRANSPORT_FLOOD', 1: 'FLOOD', 2: 'DIRECT', 3: 'TRANSPORT_DIRECT' }; const PAYLOAD_TYPES = { 0: 'Request', 1: 'Response', 2: 'Direct Msg', 3: 'ACK', 4: 'Advert', 5: 'Channel Msg', 6: 'Group Data', 7: 'Anon Req', 8: 'Path', 9: 'Trace', 10: 'Multipart', 11: 'Control', 15: 'Raw Custom' }; @@ -341,8 +351,8 @@ function timeAgo(iso) { else if (abs < 3600) { value = Math.floor(abs / 60); suffix = 'm'; } else if (abs < 86400) { value = Math.floor(abs / 3600); suffix = 'h'; } else { value = Math.floor(abs / 86400); suffix = 'd'; } - if (s < 0) return 'in ' + value + suffix; - return value + suffix + ' ago'; + if (s < 0) return tr('in {time}', { time: value + suffix }); + return tr('{time} ago', { time: value + suffix }); } function getHashParams() { @@ -1153,10 +1163,10 @@ registerPage('tools-landing', { init: function (container) { container.innerHTML = ''; }, @@ -1317,7 +1327,7 @@ function navigate() { else { app.setAttribute('tabindex', '-1'); app.focus({ preventScroll: true }); } }); } else { - app.innerHTML = `

${escapeHtml(route)}

Page not yet implemented.

`; + app.innerHTML = `

${escapeHtml(route)}

${tr('Page not yet implemented.')}

`; } } @@ -1971,9 +1981,15 @@ window.addEventListener('DOMContentLoaded', () => { if (window._customizerV2) window._customizerV2.init(window.SITE_CONFIG); }); - // Navigate immediately — don't gate data-fetching pages on cosmetic theme fetch - if (!location.hash || location.hash === '#/') location.hash = '#/home'; - else navigate(); + // Navigate immediately — don't gate data-fetching pages on cosmetic theme fetch. + // The UI language does gate the first render (i18n.js resolves at once for + // English and gives up after a few seconds), so pages render translated. + const startRouting = () => { + if (!location.hash || location.hash === '#/') location.hash = '#/home'; + else navigate(); + }; + if (window.I18n && window.I18n.ready) window.I18n.ready.then(startRouting); + else startRouting(); }); /** diff --git a/public/area-filter.js b/public/area-filter.js index 34e080029..11cba3fc7 100644 --- a/public/area-filter.js +++ b/public/area-filter.js @@ -1,5 +1,7 @@ /* === CoreScope — area-filter.js (single-select area filter component) === */ 'use strict'; +// i18n.js defines tr()/trn(); sandboxes that load this file alone get English. +var tr = globalThis.tr || function (s, p) { return p ? s.replace(/\{(\w+)\}/g, function (m, k) { return Object.prototype.hasOwnProperty.call(p, k) ? String(p[k]) : m; }) : s; }; (function () { var LS_KEY = 'meshcore-area-filter'; @@ -48,9 +50,9 @@ function areaQueryString() { return _selected ? '&area=' + encodeURIComponent(_selected) : ''; } function triggerLabel() { - if (!_selected) return 'Area: All ▾'; + if (!_selected) return tr('Area: {area}', { area: tr('All') }) + ' ▾'; var area = _areas.find(function (a) { return a.key === _selected; }); - return 'Area: ' + (area ? area.label : _selected) + ' ▾'; + return tr('Area: {area}', { area: area ? area.label : _selected }) + ' ▾'; } function render(container) { @@ -63,12 +65,12 @@ if (container._areaCleanup) { container._areaCleanup(); container._areaCleanup = null; } - var html = '
'; + var html = '
'; html += ''; - html += '`; } catch (err) { const is404 = err && err.message && err.message.includes('404'); const statusIcon = is404 ? '' : ''; const statusMsg = is404 - ? 'Waiting for first advert — this node has been seen in channel messages but hasn\u2019t advertised yet' - : 'Could not load data'; + ? tr('Waiting for first advert — this node has been seen in channel messages but hasn\u2019t advertised yet') + : tr('Could not load data'); return `
${statusIcon}
${escapeHtml(mn.name || truncate(mn.pubkey, 12))}
- +
${statusMsg}
- +
`; } @@ -418,7 +428,7 @@ const opacity = v > 0 ? 0.4 + (v / max) * 0.6 : 0.1; return `
`; }).join(''); - return `
24h activity
${bars}
`; + return `
${tr('24h activity')}
${bars}
`; } // ==================== STATS ==================== @@ -428,10 +438,10 @@ const el = document.getElementById('homeStats'); if (!el) return; el.innerHTML = ` -
${s.totalTransmissions ?? s.totalPackets ?? '—'}
Transmissions
-
${s.totalNodes ?? '—'}
Nodes
-
${s.totalObservers ?? '—'}
Observers
-
${s.packetsLast24h ?? '—'}
Last 24h
+
${s.totalTransmissions ?? s.totalPackets ?? '—'}
${tr('Transmissions')}
+
${s.totalNodes ?? '—'}
${tr('Nodes')}
+
${s.totalObservers ?? '—'}
${tr('Observers')}
+
${s.packetsLast24h ?? '—'}
${tr('Last 24h')}
`; } catch {} } @@ -441,7 +451,7 @@ const card = document.getElementById('homeHealth'); const journey = document.getElementById('homeJourney'); if (!card) return; - card.innerHTML = '

Loading…

'; + card.innerHTML = '

' + tr('Loading…') + '

'; card.classList.add('visible'); if (journey) journey.classList.remove('visible'); @@ -458,29 +468,29 @@ const heard = stats.lastHeard || node.last_seen; const status = getHealthStatus(node.role, heard) || 'silent'; const color = status === 'healthy' ? 'green' : status === 'degraded' ? 'yellow' : 'red'; - const statusMsg = heard ? `Last heard ${timeAgo(heard)}` : 'No packets in the current window'; + const statusMsg = heard ? tr('Last heard {time}', { time: timeAgo(heard) }) : tr('No packets in the current window'); const snrVal = stats.avgSnr; - const snrLabel = snrVal != null ? (snrVal > 10 ? 'Excellent' : snrVal > 0 ? 'Good' : snrVal > -5 ? 'Marginal' : 'Poor') : ''; + const snrLabel = snrVal != null ? snrQualityLabel(snrVal) : ''; card.innerHTML = `
${status === 'healthy' ? '' : status === 'degraded' ? '' : ''} ${escapeHtml(node.name || truncate(pubkey, 16))} — ${statusMsg} - ${!claimed ? `` : ''} + ${!claimed ? `` : ''}
-
${stats.packetsToday ?? '—'}
Packets Today
-
${observers.length}
Observers
-
${heard ? timeAgo(heard) : '—'}
Last seen
-
${snrVal != null ? Number(snrVal).toFixed(1) + ' dB' : '—'}
Avg SNR${snrLabel ? ' · ' + snrLabel : ''}
-
${stats.avgHops != null ? stats.avgHops.toFixed(1) : '—'}
Avg Hops
+
${stats.packetsToday ?? '—'}
${tr('Packets Today')}
+
${observers.length}
${tr('Observers')}
+
${heard ? timeAgo(heard) : '—'}
${tr('Last seen')}
+
${snrVal != null ? Number(snrVal).toFixed(1) + ' dB' : '—'}
${tr('Avg SNR')}${snrLabel ? ' · ' + snrLabel : ''}
+
${stats.avgHops != null ? stats.avgHops.toFixed(1) : '—'}
${tr('Avg Hops')}
- ${observers.length ? `
Heard by: ${observers.map(o => escapeHtml(o.observer_name || o.observer_id)).join(', ')}
` : ''} + ${observers.length ? `
${tr('Heard by:')} ${observers.map(o => escapeHtml(o.observer_name || o.observer_id)).join(', ')}
` : ''} ${hasLocation ? '
' : ''}
-

Recent Activity

+

${tr('Recent Activity')}

${packets.length ? packets.slice(0, 10).map(p => { const decoded = p.decoded_json ? JSON.parse(p.decoded_json) : {}; const obsId = p.observer_name || p.observer_id || '?'; @@ -491,11 +501,11 @@ time: p.timestamp || p.created_at }).replace(/'/g, ''')}'> ${escapeHtml(payloadTypeName(p.payload_type))} - via ${escapeHtml(obsId)} + ${tr('via {observer}', { observer: escapeHtml(obsId) })} ${timeAgo(p.timestamp || p.created_at)} ${p.snr != null ? Number(p.snr).toFixed(1) + ' dB' : ''}
`; - }).join('') : '

No recent packets found for this node.

'} + }).join('') : '

' + tr('No recent packets found for this node.') + '

'}
`; @@ -507,7 +517,7 @@ e.target.remove(); loadMyNodes(); const h1 = document.querySelector('.home-hero h1'); - if (h1) h1.textContent = 'My Mesh'; + if (h1) h1.textContent = tr('My Mesh'); }); // Mini map @@ -534,7 +544,7 @@ }); }); } catch (e) { - card.innerHTML = '

Failed to load node health.

'; + card.innerHTML = '

' + tr('Failed to load node health.') + '

'; } } @@ -542,15 +552,15 @@ const el = document.getElementById('homeJourney'); if (!el) return; const nodes = []; - nodes.push({ name: data.from, meta: 'Sender' }); + nodes.push({ name: data.from, meta: tr('Sender') }); if (data.observers && data.observers.length) { - data.observers.forEach(o => nodes.push({ name: o, meta: 'Observer' })); + data.observers.forEach(o => nodes.push({ name: o, meta: tr('Observer') })); } const flow = nodes.map((n, i) => { const nodeHtml = `
${escapeHtml(n.name)}
${n.meta}
`; return i < nodes.length - 1 ? nodeHtml + '
' : nodeHtml; }).join(''); - el.innerHTML = `
Packet Journey — ${escapeHtml(payloadTypeName(data.type))}
${flow}
`; + el.innerHTML = `
${tr('Packet Journey — {type}', { type: escapeHtml(payloadTypeName(data.type)) })}
${flow}
`; el.classList.add('visible'); } @@ -558,33 +568,38 @@ function escapeHtml(s) { return String(s).replace(/&/g,'&').replace(//g,'>'); } function escapeAttr(s) { return String(s).replace(/"/g,'"').replace(/'/g,'''); } function timeSinceMs(d) { return Date.now() - d.getTime(); } + function snrQualityLabel(snr) { + return snr > 10 ? tr('Excellent') : snr > 0 ? tr('Good') : snr > -5 ? tr('Marginal') : tr('Poor'); + } function checklist(homeCfg) { var html = ''; // Render steps (getting started guide) if (homeCfg?.steps?.length) { - html += homeCfg.steps.map(s => `
${window.miniMarkdown ? miniMarkdown(s.description) : escapeHtml(s.description)}
`).join(''); + html += homeCfg.steps.map(s => `
${window.miniMarkdown ? miniMarkdown(tr(s.description)) : escapeHtml(tr(s.description))}
`).join(''); } // Render FAQ/checklist (additional Q&A) if (homeCfg?.checklist?.length) { - if (html) html += '

FAQ

'; - html += homeCfg.checklist.map(i => `
${window.miniMarkdown ? miniMarkdown(i.answer) : escapeHtml(i.answer)}
`).join(''); + if (html) html += '

' + tr('FAQ') + '

'; + html += homeCfg.checklist.map(i => `
${window.miniMarkdown ? miniMarkdown(tr(i.answer)) : escapeHtml(tr(i.answer))}
`).join(''); } - // Fallback: Bay Area defaults when no config at all + // Fallback: Bay Area defaults when no config at all. + // Markup stays in code and enters the sentences as {placeholders}, so + // translations are plain text. if (!html) { const items = [ - { q: ' First: Join the Bay Area MeshCore Discord', - a: '

The community Discord is the best place to get help and find local mesh enthusiasts.

Join the Discord ↗

Start with #intro-to-meshcore — it has detailed setup instructions.

' }, - { q: ' Step 1: Connect via Bluetooth', - a: '

Flash BLE companion firmware from MeshCore Flasher.

  • Screenless devices: default PIN 123456
  • Screen devices: random PIN shown on display
  • If pairing fails: forget device, reboot, re-pair
' }, - { q: ' Step 2: Set the right frequency preset', - a: '

US Recommended:

910.525 MHz · BW 62.5 kHz · SF 7 · CR 5

Select "US Recommended" in the app or flasher.

' }, - { q: ' Step 3: Advertise yourself', - a: '

Tap the signal icon → Flood to broadcast your node to the mesh. Companions only advert when you trigger it manually.

' }, - { q: ' Step 4: Check "Heard N repeats"', - a: '
  • "Sent" = transmitted, no confirmation
  • "Heard 0 repeats" = no repeater picked it up
  • "Heard 1+ repeats" = you\'re on the mesh!
' }, - { q: ' Repeaters near you?', - a: '

Check the network map to see active repeaters.

' } + { q: ' ' + tr('First: Join the Bay Area MeshCore Discord'), + a: '

' + tr('The community Discord is the best place to get help and find local mesh enthusiasts.') + '

' + tr('Join the Discord') + ' ↗

' + tr('Start with {channel} — it has detailed setup instructions.', { channel: '#intro-to-meshcore' }) + '

' }, + { q: ' ' + tr('Step 1: Connect via Bluetooth'), + a: '

' + tr('Flash {firmware} firmware from {flasher}.', { firmware: 'BLE companion', flasher: 'MeshCore Flasher' }) + '

  • ' + tr('Screenless devices: default PIN {pin}', { pin: '123456' }) + '
  • ' + tr('Screen devices: random PIN shown on display') + '
  • ' + tr('If pairing fails: forget device, reboot, re-pair') + '
' }, + { q: ' ' + tr('Step 2: Set the right frequency preset'), + a: '

' + tr('US Recommended:') + '

910.525 MHz · BW 62.5 kHz · SF 7 · CR 5

' + tr('Select {preset} in the app or flasher.', { preset: '"US Recommended"' }) + '

' }, + { q: ' ' + tr('Step 3: Advertise yourself'), + a: '

' + tr('Tap the signal icon → {flood} to broadcast your node to the mesh. Companions only advert when you trigger it manually.', { flood: 'Flood' }) + '

' }, + { q: ' ' + tr('Step 4: Check "Heard N repeats"'), + a: '
  • ' + tr('{sent} = transmitted, no confirmation', { sent: '"Sent"' }) + '
  • ' + tr('{heard} = no repeater picked it up', { heard: '"Heard 0 repeats"' }) + '
  • ' + tr('{heard} = you\'re on the mesh!', { heard: '"Heard 1+ repeats"' }) + '
' }, + { q: ' ' + tr('Repeaters near you?'), + a: '

' + tr('{map} to see active repeaters.', { map: '' + tr('Check the network map') + '' }) + '

' } ]; html = items.map(i => `
${i.a}
`).join(''); } diff --git a/public/hop-display.js b/public/hop-display.js index 3e7892a90..9a4ce21a5 100644 --- a/public/hop-display.js +++ b/public/hop-display.js @@ -1,6 +1,9 @@ /* === CoreScope — hop-display.js === */ /* Shared hop rendering with conflict info for all pages */ 'use strict'; +// i18n.js defines tr()/trn(); sandboxes that load this file alone get English. +var tr = globalThis.tr || function (s, p) { return p ? s.replace(/\{(\w+)\}/g, function (m, k) { return Object.prototype.hasOwnProperty.call(p, k) ? String(p[k]) : m; }) : s; }; +var trn = globalThis.trn || function (n, one, other, p) { return tr(n === 1 ? one : other, Object.assign({ count: n }, p)); }; window.HopDisplay = (function() { function escapeHtml(s) { @@ -34,15 +37,15 @@ window.HopDisplay = (function() { // An inferred hop (pick) says what the path settled on; a resolve() hop // says how its candidates were narrowed by region. - const count = `${shown.length} candidate${shown.length > 1 ? 's' : ''}`; + const count = trn(shown.length, '{count} candidate', '{count} candidates'); const header = pick - ? `${escapeHtml(h)} — ${pick.name ? 'likely ' + escapeHtml(pick.name) : 'unlisted node'} (${pct(pick.confidence)}) · ${count}` - : `${escapeHtml(h)} — ${count}${regional.length > 0 ? ' in region' : (globalFallback ? ' (global fallback)' : '')}`; + ? `${escapeHtml(h)} — ${pick.name ? tr('likely {name}', { name: escapeHtml(pick.name) }) : tr('unlisted node')} (${pct(pick.confidence)}) · ${count}` + : `${escapeHtml(h)} — ${count}${regional.length > 0 ? ' ' + tr('in region') : (globalFallback ? ' (' + tr('global fallback') + ')' : '')}`; let html = `
${header}
`; html += '
'; for (const c of shown) { const name = escapeHtml(c.name || c.pubkey?.slice(0, 16) || '?'); - const metric = pick ? (c.located === false ? 'no GPS' : c.usedAt != null ? 'hop ' + c.usedAt : pct(c.confidence)) + const metric = pick ? (c.located === false ? tr('no GPS') : c.usedAt != null ? tr('hop {n}', { n: c.usedAt }) : pct(c.confidence)) : (c.distKm != null ? c.distKm + 'km' : ''); const dist = metric ? `${metric}` : ''; const pk = c.pubkey ? c.pubkey.slice(0, 12) + '…' : ''; @@ -78,7 +81,7 @@ window.HopDisplay = (function() { function conflictButton(h, conflicts, globalFallback, pick, count) { const conflictData = escapeHtml(JSON.stringify({ h, conflicts, globalFallback, pick })); - return ` `; + return ` `; } // A hop from inferPath(): one position in one observation's path, with a @@ -93,21 +96,21 @@ window.HopDisplay = (function() { let title; if (!entry.unlisted) { - title = `${h} — ${pct(entry.confidence)} likely` + - (candidates.length > 1 ? ` · ${candidates.length} nodes share this prefix` : ''); + title = `${h} — ${tr('{percent} likely', { percent: pct(entry.confidence) })}` + + (candidates.length > 1 ? ' · ' + tr('{count} nodes share this prefix', { count: candidates.length }) : ''); } else if (name) { - title = `${h} — the only known node with this prefix, no GPS to check its place in the path`; + title = `${h} — ${tr('the only known node with this prefix, no GPS to check its place in the path')}`; } else if (candidates.length) { - title = `${h} — no listed node with this prefix fits this point in the path`; + title = `${h} — ${tr('no listed node with this prefix fits this point in the path')}`; } else { - title = `${h} — no known node has this prefix`; + title = `${h} — ${tr('no known node has this prefix')}`; } const display = opts.hexMode || !name ? h : escapeHtml(opts.truncate ? name.slice(0, opts.truncate) : name); const cls = ['hop', name ? 'hop-named' : '', guess || unsure ? 'hop-ambiguous' : '', entry.unlisted ? 'hop-unlisted' : ''] .filter(Boolean).join(' '); - const label = name && entry.unlisted ? `${display} (no GPS)` : display; + const label = name && entry.unlisted ? `${display} (${tr('no GPS')})` : display; const pill = opts.link !== false && entry.pubkey ? `${label}` : `${label}`; @@ -133,14 +136,14 @@ window.HopDisplay = (function() { // Simple title for the hop link itself let title = h; - if (unreliable) title += ' — unreliable'; + if (unreliable) title += ' — ' + tr('unreliable'); // Badge — only count regional conflicts const regionalConflicts = conflicts.filter(c => c.regional); const badgeCount = regionalConflicts.length > 0 ? regionalConflicts.length : ((globalFallback || entry.regionSkipped) ? conflicts.length : 0); const conflictBadge = badgeCount > 1 ? conflictButton(h, conflicts, globalFallback, null, badgeCount) : ''; const unreliableBadge = unreliable - ? ' ' + ? ' ' : ''; // #2097 — opts.badge === false renders the name without its badge, for the // packets list, which summarises per path instead of per hop. @@ -205,9 +208,11 @@ window.HopDisplay = (function() { function renderPathSymbolsLegend() { const items = PATH_SYMBOLS_LEGEND.map(function(e) { // glyphHtml is trusted (constant strings above), description is escaped. - return '
  • ' + e.glyphHtml + ' — ' + escapeHtml(e.description) + '
  • '; + // Both go through tr() here, at render time: the list is built at load, + // before the dictionary arrives (glyph markup has no entry and passes through). + return '
  • ' + tr(e.glyphHtml) + ' — ' + escapeHtml(tr(e.description)) + '
  • '; }).join(''); - return '
    Path symbols' + + return '
    ' + tr('Path symbols') + '' + '
      ' + items + '
    '; } diff --git a/public/i18n.js b/public/i18n.js new file mode 100644 index 000000000..424f51bba --- /dev/null +++ b/public/i18n.js @@ -0,0 +1,209 @@ +/* === CoreScope — i18n.js (UI translations) === + * + * Runtime-only translation layer: no build step, no library. + * + * tr('Packets') -> "Pacotes" + * tr('Last heard {time}', { time: t }) -> "Visto por último {time}" with {time} filled + * trn(n, '{count} packet', '{count} packets') + * + * The English text is the key. A string missing from the active dictionary + * falls back to English, so an untranslated or changed string shows up in + * English instead of breaking or showing a stale translation. + * + * Dictionaries live in public/locales/.json: a flat object of + * "English text": "translation". A plural entry is keyed by the English + * plural form and holds the CLDR categories of the target language: + * "{count} packets": { "one": "{count} pacote", "other": "{count} pacotes" } + * + * Placeholder values are inserted as given: escape node-controlled values + * (escapeHtml) before passing them when the result goes into innerHTML. + * + * Static markup is translated through attributes, applied once the + * dictionary is loaded (and by applyDom(root) for markup built later): + * data-i18n="Text" -> textContent + * data-i18n-title="Text" -> title + * data-i18n-aria-label="Text" -> aria-label + * data-i18n-placeholder="Text" -> placeholder + * + * The language is the saved choice (localStorage meshcore-lang), else the + * first browser language with a dictionary, else English. app.js waits for + * I18n.ready before the first route renders; changing the language reloads + * the page. + * + * To add a language: add public/locales/.json and one LANGUAGES entry. + */ +(function () { + 'use strict'; + + var STORAGE_KEY = 'meshcore-lang'; + var READY_TIMEOUT_MS = 3000; + // `name` is shown in the language picker, in the language itself. + var LANGUAGES = [ + { code: 'en', name: 'English' }, + { code: 'pt-BR', name: 'Português (Brasil)' } + ]; + var ATTRS = ['title', 'aria-label', 'placeholder']; + + function canonical(code) { + if (typeof code !== 'string') return null; + var lc = code.toLowerCase(); + for (var i = 0; i < LANGUAGES.length; i++) { + if (LANGUAGES[i].code.toLowerCase() === lc) return LANGUAGES[i].code; + } + return null; + } + + // detect resolves a saved preference ('auto' or a code) and the browser's + // language list to a supported code. A browser language matches exactly + // first, then by its primary subtag (pt-PT, pt -> pt-BR). + function detect(pref, browserLangs) { + if (pref && pref !== 'auto') { + var saved = canonical(pref); + if (saved) return saved; + } + var list = (browserLangs || []).filter(function (l) { return typeof l === 'string' && l; }); + for (var i = 0; i < list.length; i++) { + var exact = canonical(list[i]); + if (exact) return exact; + var primary = list[i].split('-')[0].toLowerCase(); + for (var j = 0; j < LANGUAGES.length; j++) { + if (LANGUAGES[j].code.split('-')[0].toLowerCase() === primary) return LANGUAGES[j].code; + } + } + return 'en'; + } + + function format(text, params) { + if (!params) return text; + return text.replace(/\{(\w+)\}/g, function (m, k) { + return Object.prototype.hasOwnProperty.call(params, k) ? String(params[k]) : m; + }); + } + + var dict = {}; + var pluralRules = null; + + function tr(text, params) { + var v = dict[text]; + return format(typeof v === 'string' && v ? v : text, params); + } + + function trn(count, singular, plural, params) { + var p = Object.assign({ count: count }, params); + var v = dict[plural]; + if (v && typeof v === 'object') { + var cat = pluralRules ? pluralRules.select(count) : (count === 1 ? 'one' : 'other'); + var s = v[cat] || v.other; + if (typeof s === 'string' && s) return format(s, p); + } + return format(count === 1 ? singular : plural, p); + } + + function applyDom(root) { + if (!root || typeof root.querySelectorAll !== 'function') return; + root.querySelectorAll('[data-i18n]').forEach(function (el) { + el.textContent = tr(el.getAttribute('data-i18n')); + }); + ATTRS.forEach(function (attr) { + root.querySelectorAll('[data-i18n-' + attr + ']').forEach(function (el) { + el.setAttribute(attr, tr(el.getAttribute('data-i18n-' + attr))); + }); + }); + } + + function readPref() { + try { return localStorage.getItem(STORAGE_KEY) || 'auto'; } catch (_) { return 'auto'; } + } + + // setPreference saves 'auto' or a language code and reloads, so every + // page and widget renders in the new language without per-module wiring. + function setPreference(code) { + var c = code === 'auto' ? 'auto' : canonical(code); + if (!c) return; + try { + if (c === 'auto') localStorage.removeItem(STORAGE_KEY); + else localStorage.setItem(STORAGE_KEY, c); + } catch (_) {} + location.reload(); + } + + var browserLangs = (typeof navigator !== 'undefined') + ? (navigator.languages && navigator.languages.length ? navigator.languages : [navigator.language]) + : []; + var lang = detect(readPref(), browserLangs); + + var resolveReady; + var ready = new Promise(function (resolve) { resolveReady = resolve; }); + var settled = false; + + // settle installs the dictionary (or none: English) once, translates the + // static markup when the body exists, and releases app.js's first render. + function settle(code, data) { + if (settled) return; + settled = true; + if (data && typeof data === 'object') dict = data; + else code = 'en'; + lang = code; + I18n.lang = code; + try { pluralRules = new Intl.PluralRules(code); } catch (_) { pluralRules = null; } + document.documentElement.setAttribute('lang', code); + function finish() { + if (code !== 'en') { + applyDom(document); + // Translated nav labels change width: let the nav overflow logic re-measure. + window.dispatchEvent(new Event('resize')); + } + resolveReady(code); + window.dispatchEvent(new CustomEvent('i18n-ready', { detail: { lang: code } })); + } + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', finish, { once: true }); + else finish(); + } + + // The dictionary URL is resolved next to this script and carries its + // cache buster, so a deploy invalidates both together. + function dictUrl(code) { + var src = (document.currentScript && document.currentScript.src) || location.href; + var u = new URL('locales/' + code + '.json', src); + var v = new URL(src).searchParams.get('v'); + if (v) u.searchParams.set('v', v); + return u.toString(); + } + + var I18n = { + LANGUAGES: LANGUAGES, + STORAGE_KEY: STORAGE_KEY, + lang: lang, + ready: ready, + tr: tr, + trn: trn, + applyDom: applyDom, + detect: detect, + getPreference: function () { var p = readPref(); return p === 'auto' ? 'auto' : (canonical(p) || 'auto'); }, + setPreference: setPreference + }; + window.I18n = I18n; + window.tr = tr; + window.trn = trn; + + if (lang === 'en') { + settle('en', null); + } else { + var url = dictUrl(lang); + var timer = setTimeout(function () { + console.warn('[i18n] ' + lang + ' dictionary not loaded in time; using English'); + settle('en', null); + }, READY_TIMEOUT_MS); + fetch(url).then(function (r) { + if (!r.ok) throw new Error('HTTP ' + r.status); + return r.json(); + }).then(function (data) { + clearTimeout(timer); + settle(lang, data); + }).catch(function (e) { + clearTimeout(timer); + console.warn('[i18n] could not load ' + url + ': ' + e.message + '; using English'); + settle('en', null); + }); + } +})(); diff --git a/public/index.html b/public/index.html index 9aa3003d7..7ae927ef1 100644 --- a/public/index.html +++ b/public/index.html @@ -48,6 +48,9 @@ } })(); + + @@ -119,10 +122,10 @@ body[data-cb-sim="trit"] { filter: url(#cb-trit); } body[data-cb-sim="achromat"] { filter: url(#cb-achromat); } - -