Update multi-language support. So far Spanish and english.

This commit is contained in:
Pablo Revilla
2025-12-02 14:24:10 -08:00
parent 679071cc14
commit 0543aeb650
+208 -238
View File
@@ -5,23 +5,30 @@
<style>
.legend { background:white;padding:8px;line-height:1.5;border-radius:5px;box-shadow:0 0 10px rgba(0,0,0,0.3);font-size:14px;color:black; }
.legend i { width:12px;height:12px;display:inline-block;margin-right:6px;border-radius:50%; }
#filter-container { text-align:center;margin-top:10px; }
.filter-checkbox { margin:0 10px; }
#share-button, #reset-filters-button {
padding:5px 15px;border:none;border-radius:4px;font-size:14px;cursor:pointer;color:white;
}
#share-button { margin-left:20px; background-color:#4CAF50; }
#share-button:hover { background-color:#45a049; }
#share-button:active { background-color:#3d8b40; }
#reset-filters-button { margin-left:10px; background-color:#f44336; }
#reset-filters-button:hover { background-color:#da190b; }
#reset-filters-button:active { background-color:#c41e0d; }
.blinking-tooltip { background:white;color:black;border:1px solid black;border-radius:4px;padding:2px 5px; }
</style>
{% endblock %}
{% block body %}
<div id="map" style="width:100%;height:calc(100vh - 270px)"></div>
<!-- ⭐ Map Legend ⭐ -->
<div id="map-legend" class="legend" style="position:absolute; bottom:30px; right:15px; z-index:1000;">
<div>
<i style="background:orange; width:15px; height:3px; border-radius:0;"></i>
@@ -34,29 +41,32 @@
</div>
</div>
<!-- ⭐ Router Filter ⭐ -->
<div id="filter-container">
<input type="checkbox" class="filter-checkbox" id="filter-routers-only">
<span data-translate-lang="filter_routers_only">Show Routers Only</span>
</div>
<!-- ⭐ Share + Reset ⭐ -->
<div style="text-align:center;margin-top:5px;">
<button id="share-button" onclick="shareCurrentView()" data-translate-lang="share_view">
🔗 Share This View
</button>
<button id="reset-filters-button" onclick="resetFiltersToDefaults()" data-translate-lang="reset_filters">
↺ Reset Filters To Defaults
</button>
</div>
<!-- JS Includes -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin=""></script>
<script src="https://unpkg.com/leaflet-polylinedecorator@1.6.0/dist/leaflet.polylinedecorator.js" crossorigin=""></script>
<script>
/* ===========================================================
TRANSLATION LOADER (map section)
TRANSLATION LOADING
=========================================================== */
let mapTranslations = {};
async function loadTranslationsMap() {
@@ -74,303 +84,255 @@ async function loadTranslationsMap() {
function applyTranslationsMap() {
document.querySelectorAll("[data-translate-lang]").forEach(el => {
const key = el.dataset.translateLang;
if (mapTranslations[key]) {
if (el.tagName === "INPUT" && el.placeholder) {
el.placeholder = mapTranslations[key];
} else {
el.textContent = mapTranslations[key];
}
}
if (!mapTranslations[key]) return;
if (el.tagName === "INPUT" && el.placeholder)
el.placeholder = mapTranslations[key];
else
el.textContent = mapTranslations[key];
});
}
/* ===========================================================
MAP LOGIC (unchanged except translated popups)
MAP SETUP
=========================================================== */
var map = L.map('map');
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom:19 }).addTo(map);
var nodes=[], markers={}, markerById={}, nodeMap = new Map();
var edgesData=[], edgeLayer = L.layerGroup().addTo(map), selectedNodeId = null;
var activeBlinks = new Map(), lastImportTime = null;
var mapInterval = 0;
var nodes=[], markerById={}, nodeMap=new Map();
var edgesData=[], edgeLayer=L.layerGroup().addTo(map);
var selectedNodeId = null;
var activeBlinks=new Map();
var lastImportTime=null;
var mapInterval=0;
const portMap = {
1:"Text",
67:"Telemetry",
3:"Position",
70:"Traceroute",
4:"Node Info",
71:"Neighbour Info",
73:"Map Report"
1:"Text", 67:"Telemetry", 3:"Position", 70:"Traceroute",
4:"Node Info", 71:"Neighbour Info", 73:"Map Report"
};
const palette = ["#e6194b","#4363d8","#f58231","#911eb4","#46f0f0","#f032e6",
"#bcf60c","#fabebe","#008080","#e6beff","#9a6324","#fffac8",
"#800000","#aaffc3","#808000","#ffd8b1","#000075","#808080"];
const palette=["#e6194b","#4363d8","#f58231","#911eb4","#46f0f0","#f032e6",
"#bcf60c","#fabebe","#008080","#e6beff","#9a6324","#fffac8",
"#800000","#aaffc3","#808000","#ffd8b1","#000075","#808080"];
const colorMap = new Map();
let nextColorIndex = 0;
const channelSet = new Set();
const colorMap=new Map(); let nextColorIndex=0;
const channelSet=new Set();
function timeAgo(date){
const diff=Date.now()-new Date(date),
s=Math.floor(diff/1000),
m=Math.floor(s/60),
h=Math.floor(m/60),
d=Math.floor(h/24);
return d>0?d+"d":h>0?h+"h":m>0?m+"m":s+"s";
}
function hashToColor(str){
if(colorMap.has(str)) return colorMap.get(str);
const c=palette[nextColorIndex++%palette.length];
colorMap.set(str,c);
function hashColor(s){
if(colorMap.has(s)) return colorMap.get(s);
let c = palette[nextColorIndex++ % palette.length];
colorMap.set(s,c);
return c;
}
function isInvalidCoord(n){
return !n||!n.lat||!n.long||n.lat===0||n.long===0||
Number.isNaN(n.lat)||Number.isNaN(n.long);
function isBad(n) {
return !n || !n.lat || !n.long || n.lat===0 || n.long===0;
}
/* ------------ Packet Polling ------------ */
function fetchLatestPacket(){
fetch(`/api/packets?limit=1`)
.then(r=>r.json())
.then(data=>{
lastImportTime=data.packets?.[0]?.import_time_us||0;
});
function timeAgo(ts) {
const diff = Date.now() - new Date(ts);
const s=Math.floor(diff/1000);
const m=Math.floor(s/60);
const h=Math.floor(m/60);
const d=Math.floor(h/24);
return d>0?(d+"d"):h>0?(h+"h"):m>0?(m+"m"):(s+"s");
}
function fetchNewPackets(){
if(mapInterval <= 0) return;
if(lastImportTime===null) return;
const url = new URL(`/api/packets`, window.location.origin);
url.searchParams.set("since", lastImportTime);
url.searchParams.set("limit", 50);
fetch(url)
.then(r=>r.json())
.then(data=>{
if(!data.packets?.length) return;
let latest = lastImportTime;
data.packets.forEach(pkt=>{
if(pkt.import_time_us > latest) latest = pkt.import_time_us;
const marker = markerById[pkt.from_node_id];
const nodeData = nodeMap.get(pkt.from_node_id);
if(marker && nodeData)
blinkNode(marker, nodeData.long_name, pkt.portnum);
});
lastImportTime = latest;
});
}
let packetInterval=null;
function startPacketFetcher(){
if(mapInterval<=0) return;
if(!packetInterval){
fetchLatestPacket();
packetInterval=setInterval(fetchNewPackets,mapInterval*1000);
}
}
function stopPacketFetcher(){
if(packetInterval){
clearInterval(packetInterval);
packetInterval=null;
}
}
document.addEventListener("visibilitychange",()=>{
document.hidden?stopPacketFetcher():startPacketFetcher();
});
/* ------------ Load Config ------------ */
/* ===========================================================
RESPECT CONFIG ZOOM
=========================================================== */
async function waitForConfig() {
while (!window._siteConfigPromise) {
await new Promise(r => setTimeout(r, 100));
}
try {
const cfg = await window._siteConfigPromise;
return cfg.site || {};
} catch {
return {};
}
while (!window._siteConfigPromise)
await new Promise(r=>setTimeout(r,100));
const cfg = await window._siteConfigPromise;
return cfg.site || {};
}
async function initMapPolling() {
async function initMapView() {
const site = await waitForConfig();
mapInterval = parseInt(site.map_interval, 10) || 0;
mapInterval = parseInt(site.map_interval,10) || 0;
// URL parameters override config
const params = new URLSearchParams(window.location.search);
const lat = parseFloat(params.get('lat'));
const lng = parseFloat(params.get('lng'));
const zoom = parseInt(params.get('zoom'), 10);
const lat = parseFloat(params.get("lat"));
const lng = parseFloat(params.get("lng"));
const zoom = parseInt(params.get("zoom"),10);
if (!isNaN(lat) && !isNaN(lng) && !isNaN(zoom)) {
map.setView([lat, lng], zoom);
map.setView([lat,lng],zoom);
window.configBoundsApplied = true;
setTimeout(()=>map.invalidateSize(),100);
} else {
const tl = [parseFloat(site.map_top_left_lat), parseFloat(site.map_top_left_lon)];
const br = [parseFloat(site.map_bottom_right_lat), parseFloat(site.map_bottom_right_lon)];
if (tl.every(isFinite) && br.every(isFinite)) {
map.fitBounds([tl,br]);
window.configBoundsApplied = true;
}
}
if (mapInterval > 0) startPacketFetcher();
setTimeout(()=>map.invalidateSize(),200);
}
initMapPolling();
initMapView();
/* ------------ Load Nodes + Edges ------------ */
/* ===========================================================
LOAD NODES + EDGES
=========================================================== */
fetch('/api/nodes?days_active=3')
fetch("/api/nodes?days_active=3")
.then(r=>r.json())
.then(data=>{
nodes = (data.nodes||[]).map(n=>({
key: n.node_id ?? n.id,
node_id: n.node_id,
lat: n.last_lat ? n.last_lat/1e7 : null,
long: n.last_long ? n.last_long/1e7 : null,
long_name: n.long_name||"",
short_name: n.short_name||"",
channel: n.channel||"",
hw_model: n.hw_model||"",
role: n.role||"",
firmware: n.firmware||"",
last_update: n.last_update||"",
isRouter: (n.role||"").toLowerCase().includes("router")
}));
nodes = (data.nodes||[]).map(n => ({
key: n.node_id ?? n.id,
node_id: n.node_id,
lat: n.last_lat ? n.last_lat/1e7 : null,
long: n.last_long ? n.last_long/1e7 : null,
long_name: n.long_name || "",
short_name: n.short_name || "",
channel: n.channel || "",
hw_model: n.hw_model || "",
role: n.role || "",
firmware: n.firmware || "",
last_update: n.last_update || "",
isRouter: (n.role||"").toLowerCase().includes("router")
}));
nodes.forEach(n=>{
nodeMap.set(n.key,n);
if(n.channel) channelSet.add(n.channel);
});
nodes.forEach(n => {
nodeMap.set(n.key,n);
if (n.channel) channelSet.add(n.channel);
});
renderNodesOnMap();
createChannelFilters();
renderNodes();
createChannelFilters();
return fetch('/api/edges');
return fetch("/api/edges");
})
.then(r=>r.json())
.then(data=>{ edgesData=data.edges||[]; });
.then(data => edgesData = data.edges || []);
/* ------------ Render Nodes ------------ */
function renderNodesOnMap(){
function renderNodes(){
const bounds = L.latLngBounds();
nodes.forEach(node=>{
if(isInvalidCoord(node)) return;
nodes.forEach(n=>{
if (isBad(n)) return;
const color = hashToColor(node.channel);
const radius = node.isRouter ? 9 : 7;
const color = hashColor(n.channel);
const radius = n.isRouter ? 9 : 7;
const marker = L.circleMarker([node.lat,node.long],{
radius, color:"white", fillColor:color, fillOpacity:1, weight:0.7
const marker = L.circleMarker([n.lat,n.long],{
radius, color:"white",
fillColor:color, fillOpacity:1,
weight:0.7
}).addTo(map);
marker.nodeId = node.key;
markerById[n.key] = marker;
marker.originalColor = color;
markerById[node.key] = marker;
const popup = `
<b><a href="/node/${node.node_id}">${node.long_name}</a> (${node.short_name})</b><br>
<b><a href="/node/${n.node_id}">${n.long_name}</a> (${n.short_name})</b><br>
<b data-translate-lang="channel_label"></b> ${n.channel}<br>
<b data-translate-lang="model_label"></b> ${n.hw_model}<br>
<b data-translate-lang="role_label"></b> ${n.role}<br>
<b data-translate-lang="channel_label"></b> ${node.channel}<br>
<b data-translate-lang="model_label"></b> ${node.hw_model}<br>
<b data-translate-lang="role_label"></b> ${node.role}<br>
${n.last_update
? `<b data-translate-lang="last_seen"></b> ${timeAgo(n.last_update)}<br>` : ""}
${
node.last_update
? `<b data-translate-lang="last_seen"></b> ${timeAgo(node.last_update)}<br>`
: ""
}
${
node.firmware
? `<b data-translate-lang="firmware"></b> ${node.firmware}<br>`
: ""
}
${n.firmware
? `<b data-translate-lang="firmware"></b> ${n.firmware}<br>` : ""}
`;
marker.on('click', ()=>{
onNodeClick(node);
marker.on("click", ()=>{
onNodeClick(n);
marker.bindPopup(popup).openPopup();
setTimeout(()=>applyTranslationsMap(),10);
setTimeout(applyTranslationsMap,10);
});
bounds.extend(marker.getLatLng());
});
if(bounds.isValid()){
/* ⭐ DO NOT RE-ZOOM IF CONFIG/URL VIEW WAS APPLIED ⭐ */
if (!window.configBoundsApplied && bounds.isValid()) {
map.fitBounds(bounds);
setTimeout(()=>map.invalidateSize(),100);
}
}
/* ------------ Edges ------------ */
/* ===========================================================
EDGES
=========================================================== */
function onNodeClick(node){
selectedNodeId = node.key;
function onNodeClick(n) {
selectedNodeId = n.key;
edgeLayer.clearLayers();
edgesData.forEach(edge=>{
if(edge.from!==node.key && edge.to!==node.key) return;
edgesData.forEach(edge => {
if (edge.from !== n.key && edge.to !== n.key) return;
const f=nodeMap.get(edge.from), t=nodeMap.get(edge.to);
if(!f||!t||isInvalidCoord(f)||isInvalidCoord(t)) return;
const f = nodeMap.get(edge.from);
const t = nodeMap.get(edge.to);
if (!f || !t || isBad(f) || isBad(t)) return;
const color=edge.type==="neighbor"?"gray":"orange";
const line=L.polyline([[f.lat,f.long],[t.lat,t.long]],{color,weight:3}).addTo(edgeLayer);
const color = edge.type === "neighbor" ? "gray" : "orange";
if(edge.type==="traceroute"){
const line = L.polyline([[f.lat,f.long],[t.lat,t.long]],{
color, weight:3
}).addTo(edgeLayer);
if (edge.type === "traceroute") {
L.polylineDecorator(line,{
patterns:[{
offset:'100%', repeat:0,
symbol:L.Symbol.arrowHead({pixelSize:5,polygon:false,pathOptions:{color}})
offset:'100%',
symbol:L.Symbol.arrowHead({
pixelSize:6,
pathOptions:{color}
})
}]
}).addTo(edgeLayer);
}
});
}
map.on('click',e=>{
if(!e.originalEvent.target.classList.contains('leaflet-interactive')){
map.on("click", e=>{
if (!e.originalEvent.target.classList.contains("leaflet-interactive")) {
edgeLayer.clearLayers();
selectedNodeId=null;
}
});
/* ------------ Blinking ------------ */
/* ===========================================================
BLINKING NODES
=========================================================== */
function blinkNode(marker,longName,portnum){
if(!map.hasLayer(marker)) return;
function blinkNode(marker, longName, portnum){
if (!map.hasLayer(marker)) return;
if(activeBlinks.has(marker)){
if (activeBlinks.has(marker)) {
clearInterval(activeBlinks.get(marker));
marker.setStyle({fillColor:marker.originalColor});
if(marker.tooltip) map.removeLayer(marker.tooltip);
}
const portLabel = portMap[portnum] || `Port ${portnum}`;
const portLabel = portMap[portnum] || ("Port "+portnum);
const tooltip = L.tooltip({
permanent:true, direction:'top',
offset:[0,-marker.options.radius-5],
permanent:true,
direction:'top',
offset:[0,-marker.options.radius-6],
className:'blinking-tooltip'
})
.setContent(`${longName} (${portLabel})`)
.setLatLng(marker.getLatLng())
.addTo(map);
marker.tooltip = tooltip;
let blinkCount=0;
let count=0;
const interval=setInterval(()=>{
if(!map.hasLayer(marker)) return;
marker.setStyle({fillColor: blinkCount%2===0 ? 'yellow' : marker.originalColor});
blinkCount++;
if(blinkCount>7){
marker.setStyle({
fillColor: (count % 2 === 0 ? "yellow" : marker.originalColor)
});
count++;
if (count > 7) {
clearInterval(interval);
marker.setStyle({fillColor:marker.originalColor});
map.removeLayer(tooltip);
@@ -381,91 +343,99 @@ function blinkNode(marker,longName,portnum){
activeBlinks.set(marker,interval);
}
/* ------------ Channel Filters ------------ */
/* ===========================================================
CHANNEL FILTERS
=========================================================== */
function createChannelFilters(){
const filterContainer = document.getElementById("filter-container");
function createChannelFilters() {
const cont = document.getElementById("filter-container");
const saved = JSON.parse(localStorage.getItem("mapFilters") || "{}");
channelSet.forEach(channel=>{
const cb = document.createElement("input");
channelSet.forEach(ch=>{
const cb=document.createElement("input");
cb.type="checkbox";
cb.className="filter-checkbox";
cb.id=`filter-${channel}`;
cb.checked = saved[channel] !== false;
cb.id="filter-"+ch;
cb.checked = saved[ch] !== false;
cb.addEventListener("change",saveFilters);
cb.addEventListener("change",updateNodeVisibility);
const label = document.createElement("label");
label.htmlFor = cb.id;
label.innerText = channel;
label.style.color = hashToColor(channel);
const label=document.createElement("label");
label.htmlFor=cb.id;
label.innerText = ch;
label.style.color = hashColor(ch);
filterContainer.appendChild(cb);
filterContainer.appendChild(label);
cont.appendChild(cb);
cont.appendChild(label);
});
const routerOnly = document.getElementById("filter-routers-only");
routerOnly.checked = saved["routersOnly"] || false;
routerOnly.addEventListener("change",saveFilters);
routerOnly.addEventListener("change",updateNodeVisibility);
const routerFilter=document.getElementById("filter-routers-only");
routerFilter.checked = saved["routersOnly"] || false;
routerFilter.addEventListener("change",saveFilters);
routerFilter.addEventListener("change",updateNodeVisibility);
updateNodeVisibility();
}
function saveFilters(){
const state = {};
function saveFilters() {
const s={};
channelSet.forEach(ch=>{
state[ch] = document.getElementById(`filter-${ch}`).checked;
s[ch] = document.getElementById("filter-"+ch).checked;
});
state["routersOnly"] = document.getElementById("filter-routers-only").checked;
localStorage.setItem("mapFilters",JSON.stringify(state));
s["routersOnly"]=document.getElementById("filter-routers-only").checked;
localStorage.setItem("mapFilters",JSON.stringify(s));
}
function updateNodeVisibility(){
function updateNodeVisibility() {
const showRouters = document.getElementById("filter-routers-only").checked;
const activeChannels = [...channelSet].filter(ch =>
document.getElementById(`filter-${ch}`).checked
const active = [...channelSet].filter(ch =>
document.getElementById("filter-"+ch).checked
);
nodes.forEach(n=>{
const marker = markerById[n.key];
if(marker){
const show =
(!showRouters || n.isRouter) &&
activeChannels.includes(n.channel);
if(show) map.addLayer(marker);
else map.removeLayer(marker);
}
if (!marker) return;
const visible = (!showRouters || n.isRouter) && active.includes(n.channel);
if (visible) map.addLayer(marker);
else map.removeLayer(marker);
});
}
/* ------------ Share + Reset ------------ */
/* ===========================================================
Share + Reset
=========================================================== */
function shareCurrentView() {
const c = map.getCenter();
const url = `${window.location.origin}/map?lat=${c.lat.toFixed(6)}&lng=${c.lng.toFixed(6)}&zoom=${map.getZoom()}`;
const zoom = map.getZoom();
const url =
`${window.location.origin}/map?lat=${c.lat.toFixed(6)}&lng=${c.lng.toFixed(6)}&zoom=${zoom}`;
navigator.clipboard.writeText(url).then(()=>{
const b=document.getElementById('share-button');
const t=b.textContent;
b.textContent='✓';
setTimeout(()=>b.textContent=t,1500);
const btn=document.getElementById("share-button");
const prev=btn.textContent;
btn.textContent = mapTranslations.link_copied || "✓ Link Copied!";
setTimeout(()=>btn.textContent=prev,1500);
});
}
function resetFiltersToDefaults(){
document.getElementById("filter-routers-only").checked=false;
channelSet.forEach(ch=>{
document.getElementById(`filter-${ch}`).checked=true;
document.getElementById("filter-"+ch).checked=true;
});
saveFilters();
updateNodeVisibility();
}
/* ------------ INIT ------------ */
/* ===========================================================
INIT
=========================================================== */
document.addEventListener("DOMContentLoaded", loadTranslationsMap);
</script>
{% endblock %}