mirror of
https://github.com/pablorevilla-meshtastic/meshview.git
synced 2026-03-04 23:27:46 +01:00
Start adding language support
This commit is contained in:
@@ -4,175 +4,150 @@
|
||||
.timestamp {
|
||||
min-width: 10em;
|
||||
}
|
||||
.chat-packet:nth-of-type(odd) {
|
||||
background-color: #3a3a3a;
|
||||
}
|
||||
.chat-packet {
|
||||
border-bottom: 1px solid #555;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.chat-packet:nth-of-type(even) {
|
||||
background-color: #333333;
|
||||
}
|
||||
.chat-packet:nth-of-type(odd) { background-color: #3a3a3a; }
|
||||
.chat-packet { border-bottom: 1px solid #555; padding: 8px; border-radius: 8px; }
|
||||
.chat-packet:nth-of-type(even) { background-color: #333333; }
|
||||
|
||||
@keyframes flash {
|
||||
0% { background-color: #ffe066; }
|
||||
100% { background-color: inherit; }
|
||||
}
|
||||
.chat-packet.flash {
|
||||
animation: flash 3.5s ease-out;
|
||||
}
|
||||
.chat-packet.flash { animation: flash 3.5s ease-out; }
|
||||
|
||||
/* Nested reply style below the message */
|
||||
.replying-to {
|
||||
font-size: 0.85em;
|
||||
color: #aaa; /* gray text */
|
||||
margin-top: 4px;
|
||||
padding-left: 20px; /* increased indentation */
|
||||
|
||||
.replying-to .reply-preview {
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
/* Nested reply style */
|
||||
.replying-to { font-size: 0.85em; color: #aaa; margin-top: 4px; padding-left: 20px; }
|
||||
.replying-to .reply-preview { color: #aaa; }
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div id="chat-container">
|
||||
<div class="container" id="chat-log">
|
||||
</div>
|
||||
<div class="container" id="chat-log"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const chatContainer = document.querySelector("#chat-log");
|
||||
let lastTime = null;
|
||||
const renderedPacketIds = new Set();
|
||||
const packetMap = new Map(); // store all packets we’ve seen
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
const chatContainer = document.querySelector("#chat-log");
|
||||
if (!chatContainer) return console.error("#chat-log not found");
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text == null ? "" : text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
let lastTime = null;
|
||||
const renderedPacketIds = new Set();
|
||||
const packetMap = new Map();
|
||||
let chatTranslations = {};
|
||||
|
||||
function renderPacket(packet, highlight = false) {
|
||||
// prevent duplicates
|
||||
if (renderedPacketIds.has(packet.id)) return;
|
||||
renderedPacketIds.add(packet.id);
|
||||
packetMap.set(packet.id, packet);
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text == null ? "" : text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
const date = new Date(packet.import_time);
|
||||
const formattedTime = date.toLocaleTimeString([], {
|
||||
hour: "numeric", minute: "2-digit", second: "2-digit", hour12: true
|
||||
});
|
||||
const formattedDate = `${(date.getMonth() + 1).toString().padStart(2,"0")}/` +
|
||||
`${date.getDate().toString().padStart(2,"0")}/` +
|
||||
`${date.getFullYear()}`;
|
||||
const formattedTimestamp = `${formattedTime} - ${formattedDate}`;
|
||||
// 🔑 helper to apply translations
|
||||
function applyTranslations(translations, root=document) {
|
||||
root.querySelectorAll("[data-translate-lang]").forEach(el => {
|
||||
const key = el.dataset.translateLang;
|
||||
if (translations[key]) el.textContent = translations[key];
|
||||
});
|
||||
root.querySelectorAll("[data-translate-lang-title]").forEach(el => {
|
||||
const key = el.dataset.translateLangTitle;
|
||||
if (translations[key]) el.title = translations[key];
|
||||
});
|
||||
}
|
||||
|
||||
// Try to resolve the reply target
|
||||
let replyHtml = "";
|
||||
if (packet.reply_id) {
|
||||
const parent = packetMap.get(packet.reply_id);
|
||||
if (parent) {
|
||||
replyHtml = `
|
||||
<div class="replying-to">
|
||||
<div class="reply-preview">
|
||||
<i>Replying to: <strong>${escapeHtml((parent.long_name || "").trim() || `Node ${parent.from_node_id}`)}</strong>:
|
||||
${escapeHtml(parent.payload || "")}</i>
|
||||
</div>
|
||||
</div>
|
||||
function renderPacket(packet, highlight = false) {
|
||||
if (renderedPacketIds.has(packet.id)) return;
|
||||
renderedPacketIds.add(packet.id);
|
||||
packetMap.set(packet.id, packet);
|
||||
|
||||
const date = new Date(packet.import_time);
|
||||
const formattedTime = date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit", second: "2-digit", hour12: true });
|
||||
const formattedDate = `${(date.getMonth()+1).toString().padStart(2,"0")}/${date.getDate().toString().padStart(2,"0")}/${date.getFullYear()}`;
|
||||
const formattedTimestamp = `${formattedTime} - ${formattedDate}`;
|
||||
|
||||
let replyHtml = "";
|
||||
if (packet.reply_id) {
|
||||
const parent = packetMap.get(packet.reply_id);
|
||||
if (parent) {
|
||||
replyHtml = `
|
||||
<div class="replying-to">
|
||||
<div class="reply-preview">
|
||||
<i data-translate-lang="replying_to"></i>
|
||||
<strong>${escapeHtml((parent.long_name || "").trim() || `Node ${parent.from_node_id}`)}</strong>:
|
||||
${escapeHtml(parent.payload || "")}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
replyHtml = `
|
||||
<div class="replying-to">
|
||||
<i data-translate-lang="replying_to"></i>
|
||||
<a href="/packet/${packet.reply_id}">${packet.reply_id}</a>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
const div = document.createElement("div");
|
||||
div.className = "row chat-packet" + (highlight ? " flash" : "");
|
||||
div.dataset.packetId = packet.id;
|
||||
div.innerHTML = `
|
||||
<span class="col-2 timestamp" title="${packet.import_time}">${formattedTimestamp}</span>
|
||||
<span class="col-2 channel">
|
||||
<a href="/packet/${packet.id}" title="" data-translate-lang-title="view_packet_details">✉️</a>
|
||||
${escapeHtml(packet.channel || "")}
|
||||
</span>
|
||||
<span class="col-3 nodename">
|
||||
<a href="/packet_list/${packet.from_node_id}">
|
||||
${escapeHtml((packet.long_name || "").trim() || `Node ${packet.from_node_id}`)}
|
||||
</a>
|
||||
</span>
|
||||
<span class="col-5 message">${escapeHtml(packet.payload)}${replyHtml}</span>
|
||||
`;
|
||||
} else {
|
||||
// fallback if parent not loaded yet
|
||||
replyHtml = `
|
||||
<div class="replying-to">
|
||||
<i>Replying to: <a href="/packet/${packet.reply_id}">${packet.reply_id}</a></i>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
chatContainer.prepend(div);
|
||||
|
||||
// Apply translations to the newly added packet
|
||||
applyTranslations(chatTranslations, div);
|
||||
|
||||
if (highlight) setTimeout(() => div.classList.remove("flash"), 2500);
|
||||
}
|
||||
|
||||
|
||||
const div = document.createElement("div");
|
||||
div.className = "row chat-packet" + (highlight ? " flash" : "");
|
||||
div.dataset.packetId = packet.id;
|
||||
div.innerHTML = `
|
||||
<span class="col-2 timestamp" title="${packet.import_time}">
|
||||
${formattedTimestamp}
|
||||
</span>
|
||||
<span class="col-2 channel">
|
||||
<a href="/packet/${packet.id}" title="View packet details">✉️</a> ${escapeHtml(packet.channel || "")}
|
||||
</span>
|
||||
<span class="col-3 nodename">
|
||||
<a href="/packet_list/${packet.from_node_id}">
|
||||
${escapeHtml((packet.long_name || "").trim() || `Node ${packet.from_node_id}`)}
|
||||
</a>
|
||||
</span>
|
||||
<span class="col-5 message">
|
||||
${escapeHtml(packet.payload)}
|
||||
${replyHtml}
|
||||
</span>
|
||||
`;
|
||||
|
||||
// Prepend so newest messages are at the top.
|
||||
chatContainer.prepend(div);
|
||||
|
||||
if (highlight) setTimeout(() => div.classList.remove("flash"), 2500);
|
||||
}
|
||||
|
||||
function renderPacketsEnsureDescending(packets, highlight = false) {
|
||||
if (!Array.isArray(packets) || packets.length === 0) return;
|
||||
const sortedDesc = packets.slice().sort((a, b) =>
|
||||
new Date(b.import_time) - new Date(a.import_time)
|
||||
);
|
||||
for (let i = sortedDesc.length - 1; i >= 0; i--) {
|
||||
renderPacket(sortedDesc[i], highlight);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchInitial() {
|
||||
try {
|
||||
const url = new URL("/api/chat", window.location.origin);
|
||||
url.searchParams.set("limit", "100");
|
||||
|
||||
const resp = await fetch(url);
|
||||
const data = await resp.json();
|
||||
|
||||
if (data && data.packets && data.packets.length > 0) {
|
||||
renderPacketsEnsureDescending(data.packets, false);
|
||||
if (data.latest_import_time) lastTime = data.latest_import_time;
|
||||
function renderPacketsEnsureDescending(packets, highlight = false) {
|
||||
if (!Array.isArray(packets) || packets.length === 0) return;
|
||||
const sortedDesc = packets.slice().sort((a,b) => new Date(b.import_time)-new Date(a.import_time));
|
||||
for (let i = sortedDesc.length-1; i>=0; i--) renderPacket(sortedDesc[i], highlight);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Initial fetch error:", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchUpdates() {
|
||||
try {
|
||||
const url = new URL("/api/chat", window.location.origin);
|
||||
url.searchParams.set("limit", "100");
|
||||
if (lastTime) url.searchParams.set("since", lastTime);
|
||||
|
||||
const resp = await fetch(url);
|
||||
const data = await resp.json();
|
||||
|
||||
if (data && data.packets && data.packets.length > 0) {
|
||||
renderPacketsEnsureDescending(data.packets, true);
|
||||
if (data.latest_import_time) lastTime = data.latest_import_time;
|
||||
async function fetchInitial() {
|
||||
try {
|
||||
const resp = await fetch("/api/chat?limit=100");
|
||||
const data = await resp.json();
|
||||
if (data?.packets?.length) renderPacketsEnsureDescending(data.packets);
|
||||
lastTime = data?.latest_import_time || lastTime;
|
||||
} catch(err) { console.error("Initial fetch error:", err); }
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Fetch updates error:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// initial load
|
||||
fetchInitial();
|
||||
setInterval(fetchUpdates, 5000);
|
||||
async function fetchUpdates() {
|
||||
try {
|
||||
const url = new URL("/api/chat", window.location.origin);
|
||||
url.searchParams.set("limit","100");
|
||||
if(lastTime) url.searchParams.set("since", lastTime);
|
||||
const resp = await fetch(url);
|
||||
const data = await resp.json();
|
||||
if (data?.packets?.length) renderPacketsEnsureDescending(data.packets, true);
|
||||
lastTime = data?.latest_import_time || lastTime;
|
||||
} catch(err){ console.error("Fetch updates error:", err); }
|
||||
}
|
||||
|
||||
async function loadTranslations() {
|
||||
try {
|
||||
const langCode = "{{ site_config.get('site', {}).get('language','en') }}";
|
||||
const res = await fetch(`/api/lang?lang=${langCode}§ion=chat`);
|
||||
chatTranslations = await res.json();
|
||||
applyTranslations(chatTranslations, document);
|
||||
} catch(err){ console.error("Chat translation load failed:", err); }
|
||||
}
|
||||
|
||||
await loadTranslations();
|
||||
await fetchInitial();
|
||||
setInterval(fetchUpdates, 5000);
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -72,10 +72,10 @@
|
||||
{% block body %}
|
||||
<div id="map" style="width: 100%; height: calc(100vh - 270px)"></div>
|
||||
<div id="filter-container">
|
||||
<input type="checkbox" class="filter-checkbox" id="filter-routers-only"> Show Routers Only
|
||||
<input type="checkbox" class="filter-checkbox" id="filter-routers-only"> <span id="filter-routers-label">Show Routers Only</span>
|
||||
</div>
|
||||
<div style="text-align: center; margin-top: 5px;">
|
||||
<button id="share-button" onclick="shareCurrentView()">🔗 Share This View</button>
|
||||
<button id="share-button">🔗 Share This View</button>
|
||||
<button id="reset-filters-button" onclick="resetFiltersToDefaults()">↺ Reset Filters To Defaults</button>
|
||||
</div>
|
||||
|
||||
@@ -87,6 +87,21 @@
|
||||
crossorigin></script>
|
||||
|
||||
<script>
|
||||
async function loadTranslations() {
|
||||
const langCode = "{{ site_config.get('site', {}).get('language','en') }}";
|
||||
try {
|
||||
const res = await fetch(`/api/lang?lang=${langCode}§ion=map`);
|
||||
window.mapTranslations = await res.json();
|
||||
} catch(err) {
|
||||
console.error("Map translation load failed:", err);
|
||||
window.mapTranslations = {};
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize map AFTER translations are loaded
|
||||
loadTranslations().then(() => {
|
||||
const t = window.mapTranslations || {};
|
||||
|
||||
// ---- Map Setup ----
|
||||
var map = L.map('map');
|
||||
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
@@ -94,13 +109,8 @@
|
||||
attribution: '© <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
}).addTo(map);
|
||||
|
||||
// Custom view from URL parameters
|
||||
{% if custom_view %}
|
||||
var customView = {
|
||||
lat: {{ custom_view.lat }},
|
||||
lng: {{ custom_view.lng }},
|
||||
zoom: {{ custom_view.zoom }}
|
||||
};
|
||||
var customView = { lat: {{ custom_view.lat }}, lng: {{ custom_view.lng }}, zoom: {{ custom_view.zoom }} };
|
||||
{% else %}
|
||||
var customView = null;
|
||||
{% endif %}
|
||||
@@ -111,8 +121,8 @@
|
||||
var nodes = [
|
||||
{% for node in nodes %}
|
||||
{
|
||||
lat: {{ ((node.last_lat / 10**7) + (range(-9,9) | random) / 30000) | round(7) }},
|
||||
long: {{ ((node.last_long / 10**7) + (range(-9,9) | random) / 30000) | round(7) if node.last_long is not none else "null" }},
|
||||
lat: {{ ((node.last_lat / 10**7) + (range(-9,9) | random) / 10000) | round(7) }},
|
||||
long: {{ ((node.last_long / 10**7) + (range(-9,9) | random) / 10000) | round(7) if node.last_long is not none else "null" }},
|
||||
long_name: {{ (node.long_name or "") | tojson }},
|
||||
short_name: {{ (node.short_name or "") | tojson }},
|
||||
channel: {{ (node.channel or "") | tojson }},
|
||||
@@ -144,7 +154,6 @@
|
||||
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;
|
||||
|
||||
function hashToColor(str) {
|
||||
if (colorMap.has(str)) return colorMap.get(str);
|
||||
const color = palette[nextColorIndex % palette.length];
|
||||
@@ -155,12 +164,7 @@
|
||||
|
||||
const nodeMap = new Map();
|
||||
nodes.forEach(n => nodeMap.set(n.id, n));
|
||||
|
||||
function isInvalidCoord(node) {
|
||||
if (!node) return true;
|
||||
let {lat, long} = node;
|
||||
return !lat || !long || lat === 0 || long === 0 || Number.isNaN(lat) || Number.isNaN(long);
|
||||
}
|
||||
function isInvalidCoord(node) { return !node || !node.lat || !node.long || node.lat===0 || node.long===0 || Number.isNaN(node.lat) || Number.isNaN(node.long); }
|
||||
|
||||
// ---- Marker Plotting ----
|
||||
var bounds = L.latLngBounds();
|
||||
@@ -172,43 +176,38 @@
|
||||
channels.add(category);
|
||||
let color = hashToColor(category);
|
||||
|
||||
let markerOptions = { radius: node.isRouter ? 9 : 7, color: "white", fillColor: color, fillOpacity: 1, weight: 0.7 };
|
||||
let popupContent = `<b><a href="/packet_list/${node.id}">${node.long_name}</a> (${node.short_name})</b><br>
|
||||
<b>Channel:</b> ${node.channel}<br>
|
||||
<b>Model:</b> ${node.hw_model}<br>
|
||||
<b>Role:</b> ${node.role}<br>`;
|
||||
if (node.last_update) popupContent += `<b>Last seen:</b> ${timeAgo(node.last_update)}<br>`;
|
||||
if (node.firmware) popupContent += `<b>Firmware:</b> ${node.firmware}<br>`;
|
||||
<b>${t.channel||'Channel:'}</b> ${node.channel}<br>
|
||||
<b>${t.model||'Model:'}</b> ${node.hw_model}<br>
|
||||
<b>${t.role||'Role:'}</b> ${node.role}<br>`;
|
||||
if (node.last_update) popupContent += `<b>${t.last_seen||'Last seen:'}</b> ${timeAgo(node.last_update)}<br>`;
|
||||
if (node.firmware) popupContent += `<b>${t.firmware||'Firmware:'}</b> ${node.firmware}<br>`;
|
||||
|
||||
var marker = L.circleMarker([node.lat, node.long], markerOptions).addTo(map);
|
||||
var marker = L.circleMarker([node.lat, node.long], { radius: node.isRouter?9:7, color:"white", fillColor:color, fillOpacity:1, weight:0.7 }).addTo(map);
|
||||
marker.nodeId = node.id;
|
||||
marker.originalColor = color;
|
||||
markerById[node.id] = marker;
|
||||
|
||||
marker.on('click', function(e) {
|
||||
marker.on('click', e => {
|
||||
e.originalEvent.stopPropagation();
|
||||
marker.bindPopup(popupContent).openPopup();
|
||||
setTimeout(() => marker.closePopup(), 3000);
|
||||
onNodeClick(node);
|
||||
});
|
||||
|
||||
if (!markers[category]) markers[category] = [];
|
||||
markers[category].push({ marker, isRouter: node.isRouter });
|
||||
if (!markers[category]) markers[category]=[];
|
||||
markers[category].push({marker,isRouter:node.isRouter});
|
||||
bounds.extend(marker.getLatLng());
|
||||
}
|
||||
});
|
||||
|
||||
var bayAreaBounds = [
|
||||
// ---- Map bounds ----
|
||||
var areaBounds = [
|
||||
[{{ site_config["site"]["map_top_left_lat"] }}, {{ site_config["site"]["map_top_left_lon"] }}],
|
||||
[{{ site_config["site"]["map_bottom_right_lat"] }}, {{ site_config["site"]["map_bottom_right_lon"] }}]
|
||||
];
|
||||
|
||||
// Apply custom view or default bounds
|
||||
if (customView) {
|
||||
map.setView([customView.lat, customView.lng], customView.zoom);
|
||||
} else {
|
||||
map.fitBounds(bayAreaBounds);
|
||||
}
|
||||
if (customView) map.setView([customView.lat,customView.lng],customView.zoom);
|
||||
else map.fitBounds(areaBounds);
|
||||
|
||||
// ---- LocalStorage for Filter Preferences ----
|
||||
const FILTER_STORAGE_KEY = 'meshview_map_filters';
|
||||
@@ -283,13 +282,16 @@
|
||||
}
|
||||
|
||||
// ---- Filters ----
|
||||
const filterLabel = document.getElementById("filter-routers-label");
|
||||
filterLabel.textContent = t.show_routers_only || "Show Routers Only";
|
||||
|
||||
let filterContainer = document.getElementById("filter-container");
|
||||
channels.forEach(channel => {
|
||||
let filterId = `filter-${channel.replace(/\s+/g, '-').toLowerCase()}`;
|
||||
let filterId = `filter-${channel.replace(/\s+/g,'-').toLowerCase()}`;
|
||||
let color = hashToColor(channel);
|
||||
let label = document.createElement('label');
|
||||
label.style.color = color;
|
||||
label.innerHTML = `<input type="checkbox" class="filter-checkbox" id="${filterId}" checked> ${channel}`;
|
||||
label.style.color=color;
|
||||
label.innerHTML=`<input type="checkbox" class="filter-checkbox" id="${filterId}" checked> ${channel}`;
|
||||
filterContainer.appendChild(label);
|
||||
});
|
||||
|
||||
@@ -312,28 +314,48 @@
|
||||
function updateMarkers() {
|
||||
let showRoutersOnly = document.getElementById("filter-routers-only").checked;
|
||||
nodes.forEach(node => {
|
||||
let category = node.channel;
|
||||
let checkbox = document.getElementById(`filter-${category.replace(/\s+/g,'-').toLowerCase()}`);
|
||||
let shouldShow = checkbox.checked && (!showRoutersOnly || node.isRouter);
|
||||
let marker = markerById[node.id];
|
||||
if (marker) marker.setStyle({ fillOpacity: shouldShow ? 1 : 0 });
|
||||
let category=node.channel;
|
||||
let checkbox=document.getElementById(`filter-${category.replace(/\s+/g,'-').toLowerCase()}`);
|
||||
let shouldShow=checkbox.checked && (!showRoutersOnly || node.isRouter);
|
||||
let marker=markerById[node.id];
|
||||
if(marker) marker.setStyle({fillOpacity:shouldShow?1:0});
|
||||
});
|
||||
|
||||
// Save filters to localStorage whenever they change
|
||||
saveFiltersToLocalStorage();
|
||||
}
|
||||
|
||||
document.querySelectorAll(".filter-checkbox").forEach(input => input.addEventListener("change", updateMarkers));
|
||||
document.querySelectorAll(".filter-checkbox").forEach(input=>input.addEventListener("change",updateMarkers));
|
||||
|
||||
// Apply initial filters (from localStorage or defaults)
|
||||
updateMarkers();
|
||||
|
||||
// ---- Share button ----
|
||||
const shareBtn = document.getElementById("share-button");
|
||||
shareBtn.textContent = `🔗 ${t.share_view || "Share This View"}`;
|
||||
shareBtn.onclick = function() {
|
||||
const center = map.getCenter();
|
||||
const zoom = map.getZoom();
|
||||
const lat = center.lat.toFixed(6);
|
||||
const lng = center.lng.toFixed(6);
|
||||
const shareUrl = `${window.location.origin}/map?lat=${lat}&lng=${lng}&zoom=${zoom}`;
|
||||
navigator.clipboard.writeText(shareUrl).then(()=>{
|
||||
const orig = shareBtn.textContent;
|
||||
shareBtn.textContent = '✓ Link Copied!';
|
||||
shareBtn.style.backgroundColor='#2196F3';
|
||||
setTimeout(()=>{ shareBtn.textContent=orig; shareBtn.style.backgroundColor='#4CAF50'; },2000);
|
||||
}).catch(()=>{ alert('Share this link:\n'+shareUrl); });
|
||||
};
|
||||
|
||||
// ---- Edges ----
|
||||
var edgeLayer = L.layerGroup().addTo(map);
|
||||
var edgesData = null;
|
||||
let selectedNodeId = null;
|
||||
|
||||
fetch('/api/edges').then(res => res.json()).then(data => { edgesData = data.edges; }).catch(err => console.error(err));
|
||||
fetch('/api/edges')
|
||||
.then(r => r.json())
|
||||
.then(data => edgesData = data.edges)
|
||||
.catch(err => console.error(err));
|
||||
|
||||
function onNodeClick(node) {
|
||||
if (selectedNodeId != node.id) {
|
||||
@@ -342,180 +364,120 @@
|
||||
if (!edgesData) return;
|
||||
if (!map.hasLayer(edgeLayer)) edgeLayer.addTo(map);
|
||||
|
||||
edgesData.forEach(edge => {
|
||||
if (edge.from !== node.id && edge.to !== node.id) return;
|
||||
const fromNode = nodeMap.get(edge.from);
|
||||
const toNode = nodeMap.get(edge.to);
|
||||
if (!fromNode || !toNode) return;
|
||||
if (isInvalidCoord(fromNode) || isInvalidCoord(toNode)) return;
|
||||
edgesData.forEach(edge => {
|
||||
if (edge.from !== node.id && edge.to !== node.id) return;
|
||||
const fromNode = nodeMap.get(edge.from);
|
||||
const toNode = nodeMap.get(edge.to);
|
||||
if (!fromNode || !toNode) return;
|
||||
if (isInvalidCoord(fromNode) || isInvalidCoord(toNode)) return;
|
||||
|
||||
const lineColor = edge.type === "neighbor" ? "darkred" : "black";
|
||||
const dash = edge.type === "traceroute" ? "5,5" : null;
|
||||
const weight = edge.type === "neighbor" ? 3 : 2;
|
||||
const lineColor = edge.type === "neighbor" ? "gray" : "orange";
|
||||
const weight = 3;
|
||||
|
||||
const polyline = L.polyline([[fromNode.lat, fromNode.long],[toNode.lat, toNode.long]], { color: lineColor, weight, opacity: 1, dashArray: dash }).addTo(edgeLayer).bringToFront();
|
||||
const polyline = L.polyline(
|
||||
[[fromNode.lat, fromNode.long], [toNode.lat, toNode.long]],
|
||||
{ color: lineColor, weight, opacity: 1 }
|
||||
).addTo(edgeLayer).bringToFront();
|
||||
|
||||
if (edge.type === "traceroute") {
|
||||
L.polylineDecorator(polyline, {
|
||||
patterns: [{ offset: '100%', repeat: 0, symbol: L.Symbol.arrowHead({ pixelSize: 5, polygon: false, pathOptions: { stroke: true, color: lineColor } }) }]
|
||||
}).addTo(edgeLayer);
|
||||
// ✅ Show tooltip right where the user clicks
|
||||
polyline.on('click', e => {
|
||||
const tooltip = L.tooltip({
|
||||
permanent: false,
|
||||
direction: 'top',
|
||||
offset: [0, -5],
|
||||
className: 'blinking-tooltip'
|
||||
})
|
||||
.setContent(edge.type.charAt(0).toUpperCase() + edge.type.slice(1))
|
||||
.setLatLng(e.latlng)
|
||||
.addTo(map);
|
||||
|
||||
setTimeout(() => map.removeLayer(tooltip), 3000);
|
||||
});
|
||||
|
||||
if (edge.type === "traceroute") {
|
||||
L.polylineDecorator(polyline, {
|
||||
patterns: [{
|
||||
offset: '100%',
|
||||
repeat: 0,
|
||||
symbol: L.Symbol.arrowHead({
|
||||
pixelSize: 5,
|
||||
polygon: false,
|
||||
pathOptions: { stroke: true, color: lineColor }
|
||||
})
|
||||
}]
|
||||
}).addTo(edgeLayer);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
map.on('click', e=>{ if(!e.originalEvent.target.classList.contains('leaflet-interactive')){ edgeLayer.clearLayers(); selectedNodeId=null; }});
|
||||
|
||||
// ---- Blinking ----
|
||||
var activeBlinks=new Map();
|
||||
function blinkNode(marker,longName,portnum){
|
||||
if(!map.hasLayer(marker)) return;
|
||||
if(activeBlinks.has(marker)){
|
||||
clearInterval(activeBlinks.get(marker));
|
||||
marker.setStyle({fillColor:marker.originalColor});
|
||||
if(marker.tooltip) map.removeLayer(marker.tooltip);
|
||||
}
|
||||
let blinkCount=0;
|
||||
let portName=portMap[portnum]||`Port ${portnum}`;
|
||||
let tooltip=L.tooltip({permanent:true,direction:'top',offset:[0,-marker.options.radius-5],className:'blinking-tooltip'})
|
||||
.setContent(`${longName} (${portName})`).setLatLng(marker.getLatLng());
|
||||
tooltip.addTo(map);
|
||||
marker.tooltip=tooltip;
|
||||
let interval=setInterval(()=>{
|
||||
if(map.hasLayer(marker)){
|
||||
marker.setStyle({fillColor:blinkCount%2===0?'yellow':marker.originalColor});
|
||||
marker.bringToFront();
|
||||
}
|
||||
blinkCount++;
|
||||
if(blinkCount>7){
|
||||
clearInterval(interval);
|
||||
marker.setStyle({fillColor:marker.originalColor});
|
||||
map.removeLayer(tooltip);
|
||||
activeBlinks.delete(marker);
|
||||
}
|
||||
},500);
|
||||
activeBlinks.set(marker,interval);
|
||||
}
|
||||
|
||||
// ---- Packet fetching ----
|
||||
let lastImportTime=null;
|
||||
const mapInterval={{ site_config["site"]["map_interval"]|default(3) }};
|
||||
function fetchLatestPacket(){
|
||||
fetch(`/api/packets?limit=1`).then(r=>r.json()).then(data=>{
|
||||
if(data.packets && data.packets.length>0) lastImportTime=data.packets[0].import_time;
|
||||
else lastImportTime=new Date().toISOString();
|
||||
}).catch(err=>console.error(err));
|
||||
}
|
||||
function fetchNewPackets(){
|
||||
if(!lastImportTime) return;
|
||||
fetch(`/api/packets?since=${lastImportTime}`).then(r=>r.json()).then(data=>{
|
||||
if(!data.packets||data.packets.length===0) return;
|
||||
let latestSeen=lastImportTime;
|
||||
data.packets.forEach(packet=>{
|
||||
if(packet.import_time && (!latestSeen || packet.import_time>latestSeen)) latestSeen=packet.import_time;
|
||||
let marker=markerById[packet.from_node_id];
|
||||
if(marker){
|
||||
let nodeData=nodeMap.get(packet.from_node_id);
|
||||
if(nodeData) blinkNode(marker,nodeData.long_name,packet.portnum);
|
||||
}
|
||||
});
|
||||
}
|
||||
if(latestSeen) lastImportTime=latestSeen;
|
||||
}).catch(err=>console.error(err));
|
||||
}
|
||||
|
||||
map.on('click', function(e) {
|
||||
if (!e.originalEvent.target.classList.contains('leaflet-interactive')) {
|
||||
edgeLayer.clearLayers();
|
||||
selectedNodeId = null;
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Blinking Nodes ----
|
||||
var activeBlinks = new Map();
|
||||
|
||||
function blinkNode(marker, longName, portnum) {
|
||||
if (!map.hasLayer(marker)) return;
|
||||
if (activeBlinks.has(marker)) {
|
||||
clearInterval(activeBlinks.get(marker));
|
||||
marker.setStyle({ fillColor: marker.originalColor });
|
||||
if (marker.tooltip) map.removeLayer(marker.tooltip);
|
||||
}
|
||||
|
||||
let blinkCount = 0;
|
||||
let portName = portMap[portnum] || `Port ${portnum}`;
|
||||
let tooltip = L.tooltip({
|
||||
permanent: true,
|
||||
direction: 'top',
|
||||
offset: [0, -marker.options.radius - 5],
|
||||
className: 'blinking-tooltip'
|
||||
}).setContent(`${longName} (${portName})`).setLatLng(marker.getLatLng());
|
||||
tooltip.addTo(map);
|
||||
marker.tooltip = tooltip;
|
||||
|
||||
let interval = setInterval(() => {
|
||||
if (map.hasLayer(marker)) {
|
||||
// Alternate color
|
||||
marker.setStyle({ fillColor: blinkCount % 2 === 0 ? 'yellow' : marker.originalColor });
|
||||
// Bring marker to top
|
||||
marker.bringToFront();
|
||||
}
|
||||
blinkCount++;
|
||||
if (blinkCount > 7) {
|
||||
clearInterval(interval);
|
||||
marker.setStyle({ fillColor: marker.originalColor });
|
||||
map.removeLayer(tooltip);
|
||||
activeBlinks.delete(marker);
|
||||
}
|
||||
}, 500);
|
||||
|
||||
activeBlinks.set(marker, interval);
|
||||
}
|
||||
|
||||
|
||||
// ---- Packet Fetching ----
|
||||
let lastImportTime = null;
|
||||
|
||||
function fetchLatestPacket() {
|
||||
fetch(`/api/packets?limit=1`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.packets && data.packets.length > 0) {
|
||||
lastImportTime = data.packets[0].import_time;
|
||||
console.log("Initial lastImportTime:", lastImportTime);
|
||||
} else {
|
||||
lastImportTime = new Date().toISOString();
|
||||
console.log("No packets, setting lastImportTime to now:", lastImportTime);
|
||||
}
|
||||
})
|
||||
.catch(err => console.error("Error fetching latest packet:", err));
|
||||
}
|
||||
|
||||
function fetchNewPackets() {
|
||||
if (!lastImportTime) return;
|
||||
fetch(`/api/packets?since=${lastImportTime}`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
console.log("===== New Fetch =====");
|
||||
if (!data.packets || data.packets.length === 0) {
|
||||
console.log("No new packets");
|
||||
return;
|
||||
}
|
||||
|
||||
let latestSeen = lastImportTime;
|
||||
|
||||
data.packets.forEach(packet => {
|
||||
console.log(`Packet ID: ${packet.id}, From Node: ${packet.from_node_id}, Port: ${packet.portnum}, Time: ${packet.import_time}`);
|
||||
if (packet.import_time && (!latestSeen || packet.import_time > latestSeen)) latestSeen = packet.import_time;
|
||||
|
||||
let marker = markerById[packet.from_node_id];
|
||||
if (marker) {
|
||||
let nodeData = nodeMap.get(packet.from_node_id);
|
||||
if (nodeData) blinkNode(marker, nodeData.long_name, packet.portnum);
|
||||
}
|
||||
});
|
||||
|
||||
if (latestSeen) lastImportTime = latestSeen;
|
||||
console.log("Updated lastImportTime:", lastImportTime);
|
||||
console.log("===== End Fetch =====");
|
||||
})
|
||||
.catch(err => console.error("Fetch error:", err));
|
||||
}
|
||||
|
||||
// ---- Polling Control ----
|
||||
let packetInterval = null;
|
||||
const mapInterval = {{ site_config["site"]["map_interval"] | default(3) }};
|
||||
|
||||
function startPacketFetcher() {
|
||||
if (mapInterval <= 0) return;
|
||||
if (!packetInterval) {
|
||||
fetchLatestPacket();
|
||||
packetInterval = setInterval(fetchNewPackets, mapInterval * 1000);
|
||||
console.log("Packet fetcher started, interval:", mapInterval, "seconds");
|
||||
}
|
||||
}
|
||||
|
||||
function stopPacketFetcher() {
|
||||
if (packetInterval) {
|
||||
clearInterval(packetInterval);
|
||||
packetInterval = null;
|
||||
console.log("Packet fetcher stopped");
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("visibilitychange", function() {
|
||||
if (document.hidden) stopPacketFetcher();
|
||||
else startPacketFetcher();
|
||||
});
|
||||
|
||||
// ---- Share Current View ----
|
||||
function shareCurrentView() {
|
||||
const center = map.getCenter();
|
||||
const zoom = map.getZoom();
|
||||
const lat = center.lat.toFixed(6);
|
||||
const lng = center.lng.toFixed(6);
|
||||
|
||||
const shareUrl = `${window.location.origin}/map?lat=${lat}&lng=${lng}&zoom=${zoom}`;
|
||||
|
||||
// Copy to clipboard
|
||||
navigator.clipboard.writeText(shareUrl).then(() => {
|
||||
const button = document.getElementById('share-button');
|
||||
const originalText = button.textContent;
|
||||
button.textContent = '✓ Link Copied!';
|
||||
button.style.backgroundColor = '#2196F3';
|
||||
|
||||
setTimeout(() => {
|
||||
button.textContent = originalText;
|
||||
button.style.backgroundColor = '#4CAF50';
|
||||
}, 2000);
|
||||
}).catch(err => {
|
||||
// Fallback for older browsers
|
||||
alert('Share this link:\n' + shareUrl);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Initialize ----
|
||||
if (mapInterval > 0) startPacketFetcher();
|
||||
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",function(){ if(document.hidden) stopPacketFetcher(); else startPacketFetcher(); });
|
||||
if(mapInterval>0) startPacketFetcher();
|
||||
});
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -19,9 +19,11 @@
|
||||
|
||||
{% block body %}
|
||||
<div class="container">
|
||||
{{ site_config["site"]["weekly_net_message"] }} <br><br>
|
||||
<span>{{ site_config["site"]["weekly_net_message"] }}</span> <br><br>
|
||||
|
||||
<h5>Number of Check-ins: {{ packets|length }}</h5>
|
||||
<h5>
|
||||
<span data-translate-lang="number_of_checkins">Number of Check-ins:</span> {{ packets|length }}
|
||||
</h5>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
@@ -48,7 +50,26 @@
|
||||
</span>
|
||||
</div>
|
||||
{% else %}
|
||||
No packets found.
|
||||
<span data-translate-lang="no_packets_found">No packets found.</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function loadTranslations() {
|
||||
try {
|
||||
const langCode = "{{ site_config.get('site', {}).get('language','en') }}";
|
||||
const res = await fetch(`/api/lang?lang=${langCode}§ion=net`);
|
||||
const translations = await res.json();
|
||||
document.querySelectorAll("[data-translate-lang]").forEach(el => {
|
||||
const key = el.dataset.translateLang;
|
||||
if(el.placeholder !== undefined && el.placeholder !== "") el.placeholder = translations[key] || el.placeholder;
|
||||
else el.textContent = translations[key] || el.textContent;
|
||||
});
|
||||
} catch(err) {
|
||||
console.error("Net translations load failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", loadTranslations());
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
position: absolute;
|
||||
bottom: 100px;
|
||||
left: 10px;
|
||||
z-index: 10;
|
||||
z-index: 10;1
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
|
||||
@@ -93,88 +93,87 @@
|
||||
|
||||
{% block body %}
|
||||
<div class="main-container">
|
||||
<h2 class="main-header">Mesh Statistics - Summary (all available in Database)</h2>
|
||||
<h2 class="main-header" data-translate-lang="mesh_stats_summary">Mesh Statistics - Summary (all available in Database)</h2>
|
||||
|
||||
<div class="summary-container" style="display:flex; justify-content:space-between; gap:10px; margin-bottom:20px;">
|
||||
<div class="summary-card" style="flex:1;">
|
||||
<p>Total Nodes</p>
|
||||
<p data-translate-lang="total_nodes">Total Nodes</p>
|
||||
<div class="summary-count">{{ "{:,}".format(total_nodes) }}</div>
|
||||
</div>
|
||||
<div class="summary-card" style="flex:1;">
|
||||
<p>Total Packets</p>
|
||||
<p data-translate-lang="total_packets">Total Packets</p>
|
||||
<div class="summary-count">{{ "{:,}".format(total_packets) }}</div>
|
||||
</div>
|
||||
<div class="summary-card" style="flex:1;">
|
||||
<p>Total Packets Seen</p>
|
||||
<p data-translate-lang="total_packets_seen">Total Packets Seen</p>
|
||||
<div class="summary-count">{{ "{:,}".format(total_packets_seen) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Daily Charts -->
|
||||
<div class="card-section">
|
||||
<p class="section-header">Packets per Day - All Ports (Last 14 Days)</p>
|
||||
<div id="total_daily_all" class="total-count">Total: 0</div>
|
||||
<button class="expand-btn" data-chart="chart_daily_all">Expand Chart</button>
|
||||
<button class="export-btn" data-chart="chart_daily_all">Export CSV</button>
|
||||
<div id="chart_daily_all" class="chart"></div>
|
||||
</div>
|
||||
<!-- Daily Charts -->
|
||||
<div class="card-section">
|
||||
<p class="section-header" data-translate-lang="packets_per_day_all">Packets per Day - All Ports (Last 14 Days)</p>
|
||||
<div id="total_daily_all" class="total-count">Total: 0</div>
|
||||
<button class="expand-btn" data-chart="chart_daily_all" data-translate-lang="expand_chart">Expand Chart</button>
|
||||
<button class="export-btn" data-chart="chart_daily_all" data-translate-lang="export_csv">Export CSV</button>
|
||||
<div id="chart_daily_all" class="chart"></div>
|
||||
</div>
|
||||
|
||||
<!-- Packet Types Pie Chart with Channel Selector (moved here) -->
|
||||
<div class="card-section">
|
||||
<p class="section-header">Packet Types - Last 24 Hours</p>
|
||||
<select id="channelSelect">
|
||||
<option value="">All Channels</option>
|
||||
</select>
|
||||
<button class="expand-btn" data-chart="chart_packet_types">Expand Chart</button>
|
||||
<button class="export-btn" data-chart="chart_packet_types">Export CSV</button>
|
||||
<div id="chart_packet_types" class="chart"></div>
|
||||
</div>
|
||||
|
||||
<div class="card-section">
|
||||
<p class="section-header">Packets per Day - Text Messages (Port 1, Last 14 Days)</p>
|
||||
<div id="total_daily_portnum_1" class="total-count">Total: 0</div>
|
||||
<button class="expand-btn" data-chart="chart_daily_portnum_1">Expand Chart</button>
|
||||
<button class="export-btn" data-chart="chart_daily_portnum_1">Export CSV</button>
|
||||
<div id="chart_daily_portnum_1" class="chart"></div>
|
||||
</div>
|
||||
<!-- Packet Types Pie Chart with Channel Selector -->
|
||||
<div class="card-section">
|
||||
<p class="section-header" data-translate-lang="packet_types_last_24h">Packet Types - Last 24 Hours</p>
|
||||
<select id="channelSelect">
|
||||
<option value="" data-translate-lang="all_channels">All Channels</option>
|
||||
</select>
|
||||
<button class="expand-btn" data-chart="chart_packet_types" data-translate-lang="expand_chart">Expand Chart</button>
|
||||
<button class="export-btn" data-chart="chart_packet_types" data-translate-lang="export_csv">Export CSV</button>
|
||||
<div id="chart_packet_types" class="chart"></div>
|
||||
</div>
|
||||
|
||||
<div class="card-section">
|
||||
<p class="section-header" data-translate-lang="packets_per_day_text">Packets per Day - Text Messages (Port 1, Last 14 Days)</p>
|
||||
<div id="total_daily_portnum_1" class="total-count">Total: 0</div>
|
||||
<button class="expand-btn" data-chart="chart_daily_portnum_1" data-translate-lang="expand_chart">Expand Chart</button>
|
||||
<button class="export-btn" data-chart="chart_daily_portnum_1" data-translate-lang="export_csv">Export CSV</button>
|
||||
<div id="chart_daily_portnum_1" class="chart"></div>
|
||||
</div>
|
||||
|
||||
<!-- Hourly Charts -->
|
||||
<div class="card-section">
|
||||
<p class="section-header">Packets per Hour - All Ports</p>
|
||||
<p class="section-header" data-translate-lang="packets_per_hour_all">Packets per Hour - All Ports</p>
|
||||
<div id="total_hourly_all" class="total-count">Total: 0</div>
|
||||
<button class="expand-btn" data-chart="chart_hourly_all">Expand Chart</button>
|
||||
<button class="export-btn" data-chart="chart_hourly_all">Export CSV</button>
|
||||
<button class="expand-btn" data-chart="chart_hourly_all" data-translate-lang="expand_chart">Expand Chart</button>
|
||||
<button class="export-btn" data-chart="chart_hourly_all" data-translate-lang="export_csv">Export CSV</button>
|
||||
<div id="chart_hourly_all" class="chart"></div>
|
||||
</div>
|
||||
|
||||
<div class="card-section">
|
||||
<p class="section-header">Packets per Hour - Text Messages (Port 1)</p>
|
||||
<p class="section-header" data-translate-lang="packets_per_hour_text">Packets per Hour - Text Messages (Port 1)</p>
|
||||
<div id="total_portnum_1" class="total-count">Total: 0</div>
|
||||
<button class="expand-btn" data-chart="chart_portnum_1">Expand Chart</button>
|
||||
<button class="export-btn" data-chart="chart_portnum_1">Export CSV</button>
|
||||
<button class="expand-btn" data-chart="chart_portnum_1" data-translate-lang="expand_chart">Expand Chart</button>
|
||||
<button class="export-btn" data-chart="chart_portnum_1" data-translate-lang="export_csv">Export CSV</button>
|
||||
<div id="chart_portnum_1" class="chart"></div>
|
||||
</div>
|
||||
|
||||
<!-- Node breakdown charts -->
|
||||
<div class="card-section">
|
||||
<p class="section-header">Hardware Breakdown</p>
|
||||
<button class="expand-btn" data-chart="chart_hw_model">Expand Chart</button>
|
||||
<button class="export-btn" data-chart="chart_hw_model">Export CSV</button>
|
||||
<p class="section-header" data-translate-lang="hardware_breakdown">Hardware Breakdown</p>
|
||||
<button class="expand-btn" data-chart="chart_hw_model" data-translate-lang="expand_chart">Expand Chart</button>
|
||||
<button class="export-btn" data-chart="chart_hw_model" data-translate-lang="export_csv">Export CSV</button>
|
||||
<div id="chart_hw_model" class="chart"></div>
|
||||
</div>
|
||||
|
||||
<div class="card-section">
|
||||
<p class="section-header">Role Breakdown</p>
|
||||
<button class="expand-btn" data-chart="chart_role">Expand Chart</button>
|
||||
<button class="export-btn" data-chart="chart_role">Export CSV</button>
|
||||
<p class="section-header" data-translate-lang="role_breakdown">Role Breakdown</p>
|
||||
<button class="expand-btn" data-chart="chart_role" data-translate-lang="expand_chart">Expand Chart</button>
|
||||
<button class="export-btn" data-chart="chart_role" data-translate-lang="export_csv">Export CSV</button>
|
||||
<div id="chart_role" class="chart"></div>
|
||||
</div>
|
||||
|
||||
<div class="card-section">
|
||||
<p class="section-header">Channel Breakdown</p>
|
||||
<button class="expand-btn" data-chart="chart_channel">Expand Chart</button>
|
||||
<button class="export-btn" data-chart="chart_channel">Export CSV</button>
|
||||
<p class="section-header" data-translate-lang="channel_breakdown">Channel Breakdown</p>
|
||||
<button class="expand-btn" data-chart="chart_channel" data-translate-lang="expand_chart">Expand Chart</button>
|
||||
<button class="export-btn" data-chart="chart_channel" data-translate-lang="export_csv">Export CSV</button>
|
||||
<div id="chart_channel" class="chart"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -215,87 +214,17 @@ async function fetchStats(period_type,length,portnum=null,channel=null){
|
||||
}catch{return [];}
|
||||
}
|
||||
|
||||
async function fetchNodes(){
|
||||
try{
|
||||
const res=await fetch("/api/nodes");
|
||||
const json=await res.json();
|
||||
return json.nodes||[];
|
||||
}catch{return [];}
|
||||
}
|
||||
async function fetchNodes(){ try{ const res=await fetch("/api/nodes"); const json=await res.json(); return json.nodes||[];}catch{return [];} }
|
||||
async function fetchChannels(){ try{ const res = await fetch("/api/channels"); const json = await res.json(); return json.channels || [];}catch{return [];} }
|
||||
|
||||
async function fetchChannels(){
|
||||
try{
|
||||
const res = await fetch("/api/channels");
|
||||
const json = await res.json();
|
||||
return json.channels || [];
|
||||
}catch{return [];}
|
||||
}
|
||||
|
||||
function processCountField(nodes,field){
|
||||
const counts={};
|
||||
nodes.forEach(n=>{
|
||||
const key=n[field]||"Unknown";
|
||||
counts[key]=(counts[key]||0)+1;
|
||||
});
|
||||
return Object.entries(counts).map(([name,value])=>({name,value}));
|
||||
}
|
||||
|
||||
function updateTotalCount(domId,data){
|
||||
const el=document.getElementById(domId);
|
||||
if(!el||!data.length) return;
|
||||
const total=data.reduce((acc,d)=>acc+(d.count??d.packet_count??0),0);
|
||||
el.textContent=`Total: ${total.toLocaleString()}`;
|
||||
}
|
||||
|
||||
function prepareTopN(data,n=20){
|
||||
data.sort((a,b)=>b.value-a.value);
|
||||
let top=data.slice(0,n);
|
||||
if(data.length>n){
|
||||
const otherValue=data.slice(n).reduce((sum,item)=>sum+item.value,0);
|
||||
top.push({name:"Other", value:otherValue});
|
||||
}
|
||||
return top;
|
||||
}
|
||||
function processCountField(nodes,field){ const counts={}; nodes.forEach(n=>{ const key=n[field]||"Unknown"; counts[key]=(counts[key]||0)+1; }); return Object.entries(counts).map(([name,value])=>({name,value})); }
|
||||
function updateTotalCount(domId,data){ const el=document.getElementById(domId); if(!el||!data.length) return; const total=data.reduce((acc,d)=>acc+(d.count??d.packet_count??0),0); el.textContent=`Total: ${total.toLocaleString()}`; }
|
||||
function prepareTopN(data,n=20){ data.sort((a,b)=>b.value-a.value); let top=data.slice(0,n); if(data.length>n){ const otherValue=data.slice(n).reduce((sum,item)=>sum+item.value,0); top.push({name:"Other", value:otherValue}); } return top; }
|
||||
|
||||
// --- Chart Rendering ---
|
||||
function renderChart(domId,data,type,color,isHourly){
|
||||
const el=document.getElementById(domId);
|
||||
if(!el) return;
|
||||
const chart=echarts.init(el);
|
||||
const periods=data.map(d=>(d.period??d.period===0)?d.period.toString():'');
|
||||
const counts=data.map(d=>d.count??d.packet_count??0);
|
||||
const option={
|
||||
backgroundColor:'#272b2f',
|
||||
tooltip:{trigger:'axis'},
|
||||
grid:{left:'6%', right:'6%', bottom:'18%'},
|
||||
xAxis:{type:'category', data:periods, axisLine:{lineStyle:{color:'#aaa'}}, axisLabel:{rotate:45,color:'#ccc'}},
|
||||
yAxis:{type:'value', axisLine:{lineStyle:{color:'#aaa'}}, axisLabel:{color:'#ccc'}},
|
||||
series:[{data:counts,type:type,smooth:type==='line',itemStyle:{color:color}, areaStyle:type==='line'?{}:undefined}]
|
||||
};
|
||||
chart.setOption(option);
|
||||
return chart;
|
||||
}
|
||||
function renderChart(domId,data,type,color){ const el=document.getElementById(domId); if(!el) return; const chart=echarts.init(el); const periods=data.map(d=>(d.period??d.period===0)?d.period.toString():''); const counts=data.map(d=>d.count??d.packet_count??0); chart.setOption({backgroundColor:'#272b2f', tooltip:{trigger:'axis'}, grid:{left:'6%', right:'6%', bottom:'18%'}, xAxis:{type:'category', data:periods, axisLine:{lineStyle:{color:'#aaa'}}, axisLabel:{rotate:45,color:'#ccc'}}, yAxis:{type:'value', axisLine:{lineStyle:{color:'#aaa'}}, axisLabel:{color:'#ccc'}}, series:[{data:counts,type:type,smooth:type==='line',itemStyle:{color:color}, areaStyle:type==='line'?{}:undefined}]}); return chart; }
|
||||
|
||||
function renderPieChart(elId,data,name){
|
||||
const el=document.getElementById(elId);
|
||||
if(!el) return;
|
||||
const chart=echarts.init(el);
|
||||
const top20=prepareTopN(data,20);
|
||||
const option={
|
||||
backgroundColor:"#272b2f",
|
||||
tooltip:{trigger:"item", formatter: params=>`${params.name}: ${Math.round(params.percent)}% (${params.value})`},
|
||||
series:[{
|
||||
name:name, type:"pie", radius:["30%","70%"], center:["50%","50%"],
|
||||
avoidLabelOverlap:true,
|
||||
itemStyle:{borderRadius:6,borderColor:"#272b2f",borderWidth:2},
|
||||
label:{show:true,formatter:"{b}\n{d}%", color:"#ccc", fontSize:10},
|
||||
labelLine:{show:true,length:10,length2:6},
|
||||
data:top20
|
||||
}]
|
||||
};
|
||||
chart.setOption(option);
|
||||
return chart;
|
||||
}
|
||||
function renderPieChart(elId,data,name){ const el=document.getElementById(elId); if(!el) return; const chart=echarts.init(el); const top20=prepareTopN(data,20); chart.setOption({backgroundColor:"#272b2f", tooltip:{trigger:"item", formatter: params=>`${params.name}: ${Math.round(params.percent)}% (${params.value})`}, series:[{name:name, type:"pie", radius:["30%","70%"], center:["50%","50%"], avoidLabelOverlap:true, itemStyle:{borderRadius:6,borderColor:"#272b2f",borderWidth:2}, label:{show:true,formatter:"{b}\n{d}%", color:"#ccc", fontSize:10}, labelLine:{show:true,length:10,length2:6}, data:top20}]}); return chart; }
|
||||
|
||||
// --- Packet Type Pie Chart ---
|
||||
async function fetchPacketTypeBreakdown(channel=null) {
|
||||
@@ -305,10 +234,8 @@ async function fetchPacketTypeBreakdown(channel=null) {
|
||||
const total = (data || []).reduce((sum,d)=>sum+(d.count??d.packet_count??0),0);
|
||||
return {portnum: pn, count: total};
|
||||
});
|
||||
|
||||
const allData = await fetchStats('hour',24,null,channel);
|
||||
const totalAll = allData.reduce((sum,d)=>sum+(d.count??d.packet_count??0),0);
|
||||
|
||||
const results = await Promise.all(requests);
|
||||
const trackedTotal = results.reduce((sum,d)=>sum+d.count,0);
|
||||
const other = Math.max(totalAll - trackedTotal,0);
|
||||
@@ -323,62 +250,41 @@ let chartHwModel, chartRole, chartChannel;
|
||||
let chartPacketTypes;
|
||||
|
||||
async function init(){
|
||||
// Populate channels
|
||||
const channels = await fetchChannels();
|
||||
const select = document.getElementById("channelSelect");
|
||||
channels.forEach(ch=>{
|
||||
const opt = document.createElement("option");
|
||||
opt.value = ch;
|
||||
opt.textContent = ch;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
channels.forEach(ch=>{ const opt = document.createElement("option"); opt.value = ch; opt.textContent = ch; select.appendChild(opt); });
|
||||
|
||||
// Daily
|
||||
const dailyAllData=await fetchStats('day',14);
|
||||
updateTotalCount('total_daily_all',dailyAllData);
|
||||
chartDailyAll=renderChart('chart_daily_all',dailyAllData,'line','#66bb6a',false);
|
||||
chartDailyAll=renderChart('chart_daily_all',dailyAllData,'line','#66bb6a');
|
||||
|
||||
const dailyPort1Data=await fetchStats('day',14,1);
|
||||
updateTotalCount('total_daily_portnum_1',dailyPort1Data);
|
||||
chartDailyPortnum1=renderChart('chart_daily_portnum_1',dailyPort1Data,'bar','#ff5722',false);
|
||||
chartDailyPortnum1=renderChart('chart_daily_portnum_1',dailyPort1Data,'bar','#ff5722');
|
||||
|
||||
// Hourly
|
||||
const hourlyAllData=await fetchStats('hour',24);
|
||||
updateTotalCount('total_hourly_all',hourlyAllData);
|
||||
chartHourlyAll=renderChart('chart_hourly_all',hourlyAllData,'bar','#03dac6',true);
|
||||
chartHourlyAll=renderChart('chart_hourly_all',hourlyAllData,'bar','#03dac6');
|
||||
|
||||
const portnums=[1,3,4,67,70,71];
|
||||
const colors=['#ff5722','#2196f3','#9c27b0','#ffeb3b','#795548','#4caf50'];
|
||||
const domIds=['chart_portnum_1','chart_portnum_3','chart_portnum_4','chart_portnum_67','chart_portnum_70','chart_portnum_71'];
|
||||
const totalIds=['total_portnum_1','total_portnum_3','total_portnum_4','total_portnum_67','total_portnum_70','total_portnum_71'];
|
||||
const allData=await Promise.all(portnums.map(pn=>fetchStats('hour',24,pn)));
|
||||
for(let i=0;i<portnums.length;i++){
|
||||
updateTotalCount(totalIds[i],allData[i]);
|
||||
window['chartPortnum'+portnums[i]]=renderChart(domIds[i],allData[i],'bar',colors[i],true);
|
||||
}
|
||||
for(let i=0;i<portnums.length;i++){ updateTotalCount(totalIds[i],allData[i]); window['chartPortnum'+portnums[i]]=renderChart(domIds[i],allData[i],'bar',colors[i]); }
|
||||
|
||||
// Node Breakdown
|
||||
const nodes=await fetchNodes();
|
||||
chartHwModel=renderPieChart("chart_hw_model",processCountField(nodes,"hw_model"),"Hardware");
|
||||
chartRole=renderPieChart("chart_role",processCountField(nodes,"role"),"Role");
|
||||
chartChannel=renderPieChart("chart_channel",processCountField(nodes,"channel"),"Channel");
|
||||
|
||||
// Packet Type Pie Chart
|
||||
const packetTypesData = await fetchPacketTypeBreakdown();
|
||||
const formatted = packetTypesData.filter(d=>d.count>0).map(d=>({
|
||||
name: d.portnum==="other" ? "Other" : (PORTNUM_LABELS[d.portnum]||`Port ${d.portnum}`),
|
||||
value: d.count
|
||||
}));
|
||||
const formatted = packetTypesData.filter(d=>d.count>0).map(d=>({ name: d.portnum==="other" ? "Other" : (PORTNUM_LABELS[d.portnum]||`Port ${d.portnum}`), value: d.count }));
|
||||
chartPacketTypes = renderPieChart("chart_packet_types",formatted,"Packet Types (Last 24h)");
|
||||
}
|
||||
|
||||
// --- Resize ---
|
||||
window.addEventListener('resize',()=>{
|
||||
[chartHourlyAll,chartPortnum1,chartPortnum3,chartPortnum4,chartPortnum67,chartPortnum70,chartPortnum71,
|
||||
chartDailyAll,chartDailyPortnum1,chartHwModel,chartRole,chartChannel,chartPacketTypes].forEach(c=>c?.resize());
|
||||
});
|
||||
window.addEventListener('resize',()=>{ [chartHourlyAll,chartPortnum1,chartPortnum3,chartPortnum4,chartPortnum67,chartPortnum70,chartPortnum71, chartDailyAll,chartDailyPortnum1,chartHwModel,chartRole,chartChannel,chartPacketTypes].forEach(c=>c?.resize()); });
|
||||
|
||||
// --- Modal ---
|
||||
const modal=document.getElementById("chartModal");
|
||||
const modalChartEl=document.getElementById("modalChart");
|
||||
let modalChart=null;
|
||||
@@ -400,7 +306,6 @@ document.getElementById("closeModal").addEventListener("click",()=>{
|
||||
modalChart=null;
|
||||
});
|
||||
|
||||
// --- CSV Export ---
|
||||
function downloadCSV(filename,rows){
|
||||
const csvContent=rows.map(r=>r.map(v=>`"${v}"`).join(",")).join("\n");
|
||||
const blob=new Blob([csvContent],{type:"text/csv;charset=utf-8;"});
|
||||
@@ -437,18 +342,34 @@ document.querySelectorAll(".export-btn").forEach(btn=>{
|
||||
});
|
||||
});
|
||||
|
||||
// --- Channel filter for Packet Types ---
|
||||
document.getElementById("channelSelect").addEventListener("change", async (e)=>{
|
||||
const channel = e.target.value;
|
||||
const packetTypesData = await fetchPacketTypeBreakdown(channel);
|
||||
const formatted = packetTypesData.filter(d=>d.count>0).map(d=>({
|
||||
name: d.portnum==="other" ? "Other" : (PORTNUM_LABELS[d.portnum]||`Port ${d.portnum}`),
|
||||
value: d.count
|
||||
}));
|
||||
const formatted = packetTypesData.filter(d=>d.count>0).map(d=>({ name: d.portnum==="other" ? "Other" : (PORTNUM_LABELS[d.portnum]||`Port ${d.portnum}`), value: d.count }));
|
||||
chartPacketTypes?.dispose();
|
||||
chartPacketTypes = renderPieChart("chart_packet_types",formatted,"Packet Types (Last 24h)");
|
||||
});
|
||||
|
||||
init();
|
||||
|
||||
// --- Translation Loader ---
|
||||
async function loadTranslations() {
|
||||
const langCode = "{{ site_config.get('site', {}).get('language','en') }}";
|
||||
try {
|
||||
const res = await fetch(`/api/lang?lang=${langCode}§ion=stats`);
|
||||
window.statsTranslations = await res.json();
|
||||
} catch(err){
|
||||
console.error("Stats translation load failed:", err);
|
||||
window.statsTranslations = {};
|
||||
}
|
||||
}
|
||||
function applyTranslations() {
|
||||
const t = window.statsTranslations || {};
|
||||
document.querySelectorAll("[data-translate-lang]").forEach(el=>{
|
||||
const key = el.getAttribute("data-translate-lang");
|
||||
if(t[key]) el.textContent = t[key];
|
||||
});
|
||||
}
|
||||
loadTranslations().then(applyTranslations);
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -81,15 +81,22 @@ select {
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<h1>Top Traffic Nodes (last 24 hours)</h1>
|
||||
<h1 data-translate-lang="top_traffic_nodes">Top Traffic Nodes (last 24 hours)</h1>
|
||||
|
||||
<!-- Channel Filter Dropdown -->
|
||||
<select id="channelFilter"></select>
|
||||
|
||||
<div id="stats">
|
||||
<p>This chart shows a bell curve (normal distribution) based on the total <strong>"Times Seen"</strong> values for all nodes. It helps visualize how frequently nodes are heard, relative to the average.</p>
|
||||
<p>This "Times Seen" value is the closest that we can get to Mesh utilization by node.</p>
|
||||
<p><strong>Mean:</strong> <span id="mean"></span> - <strong>Standard Deviation:</strong> <span id="stdDev"></span></p>
|
||||
<p data-translate-lang="chart_description_1">
|
||||
This chart shows a bell curve (normal distribution) based on the total <strong>"Times Seen"</strong> values for all nodes. It helps visualize how frequently nodes are heard, relative to the average.
|
||||
</p>
|
||||
<p data-translate-lang="chart_description_2">
|
||||
This "Times Seen" value is the closest that we can get to Mesh utilization by node.
|
||||
</p>
|
||||
<p>
|
||||
<strong data-translate-lang="mean_label">Mean:</strong> <span id="mean"></span> -
|
||||
<strong data-translate-lang="stddev_label">Standard Deviation:</strong> <span id="stdDev"></span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Chart -->
|
||||
@@ -97,23 +104,23 @@ select {
|
||||
|
||||
<!-- Table -->
|
||||
{% if nodes %}
|
||||
<div class="container">
|
||||
<div class="container">
|
||||
<table id="trafficTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th onclick="sortTable(0)">Long Name</th>
|
||||
<th onclick="sortTable(1)">Short Name</th>
|
||||
<th onclick="sortTable(2)">Channel</th>
|
||||
<th onclick="sortTable(3)">Packets Sent</th>
|
||||
<th onclick="sortTable(4)">Times Seen</th>
|
||||
<th onclick="sortTable(5)">Seen % of Mean</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-translate-lang="long_name" onclick="sortTable(0)">Long Name</th>
|
||||
<th data-translate-lang="short_name" onclick="sortTable(1)">Short Name</th>
|
||||
<th data-translate-lang="channel" onclick="sortTable(2)">Channel</th>
|
||||
<th data-translate-lang="packets_sent" onclick="sortTable(3)">Packets Sent</th>
|
||||
<th data-translate-lang="times_seen" onclick="sortTable(4)">Times Seen</th>
|
||||
<th data-translate-lang="seen_percent" onclick="sortTable(5)">Seen % of Mean</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p style="text-align: center;">No top traffic nodes available.</p>
|
||||
<p style="text-align: center;" data-translate-lang="no_nodes">No top traffic nodes available.</p>
|
||||
{% endif %}
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/echarts@5.3.2/dist/echarts.min.js"></script>
|
||||
@@ -121,31 +128,45 @@ select {
|
||||
const nodes = {{ nodes | tojson }};
|
||||
let filteredNodes = [];
|
||||
|
||||
// Chart & Stats
|
||||
// --- Language support ---
|
||||
async function loadTopTranslations() {
|
||||
const langCode = "{{ site_config.get('site', {}).get('language','en') }}";
|
||||
try {
|
||||
const res = await fetch(`/api/lang?lang=${langCode}§ion=top`);
|
||||
window.topTranslations = await res.json();
|
||||
} catch(err) {
|
||||
console.error("Top page translation load failed:", err);
|
||||
window.topTranslations = {};
|
||||
}
|
||||
}
|
||||
|
||||
function applyTopTranslations() {
|
||||
const t = window.topTranslations || {};
|
||||
document.querySelectorAll("[data-translate-lang]").forEach(el=>{
|
||||
const key = el.getAttribute("data-translate-lang");
|
||||
if(t[key]) el.textContent = t[key];
|
||||
});
|
||||
}
|
||||
|
||||
// --- Chart & Table code ---
|
||||
const chart = echarts.init(document.getElementById('bellCurveChart'));
|
||||
const meanEl = document.getElementById('mean');
|
||||
const stdEl = document.getElementById('stdDev');
|
||||
|
||||
// Populate Channel Dropdown (without "All"), default to "LongFast"
|
||||
// Populate channel dropdown
|
||||
const channelSet = new Set();
|
||||
nodes.forEach(n => channelSet.add(n.channel));
|
||||
const dropdown = document.getElementById('channelFilter');
|
||||
const sortedChannels = [...channelSet].sort();
|
||||
|
||||
sortedChannels.forEach(channel => {
|
||||
[...channelSet].sort().forEach(channel => {
|
||||
const option = document.createElement('option');
|
||||
option.value = channel;
|
||||
option.textContent = channel;
|
||||
if (channel === "LongFast") {
|
||||
option.selected = true;
|
||||
}
|
||||
if (channel === "LongFast") option.selected = true;
|
||||
dropdown.appendChild(option);
|
||||
});
|
||||
|
||||
// Default to LongFast filter on load
|
||||
// Filter default
|
||||
filteredNodes = nodes.filter(n => n.channel === "LongFast");
|
||||
|
||||
// Filter change handler
|
||||
dropdown.addEventListener('change', () => {
|
||||
const val = dropdown.value;
|
||||
filteredNodes = nodes.filter(n => n.channel === val);
|
||||
@@ -153,18 +174,16 @@ dropdown.addEventListener('change', () => {
|
||||
updateStatsAndChart();
|
||||
});
|
||||
|
||||
// Normal distribution function
|
||||
// Normal distribution
|
||||
function normalDistribution(x, mean, stdDev) {
|
||||
return (1 / (stdDev * Math.sqrt(2 * Math.PI))) * Math.exp(-0.5 * Math.pow((x - mean) / stdDev, 2));
|
||||
return (1 / (stdDev * Math.sqrt(2 * Math.PI))) * Math.exp(-0.5 * Math.pow((x - mean) / stdDev, 2));
|
||||
}
|
||||
|
||||
// Update table based on filteredNodes
|
||||
// Update table
|
||||
function updateTable() {
|
||||
const tbody = document.querySelector('#trafficTable tbody');
|
||||
tbody.innerHTML = "";
|
||||
|
||||
const mean = filteredNodes.reduce((sum, n) => sum + n.total_times_seen, 0) / (filteredNodes.length || 1);
|
||||
|
||||
for (const node of filteredNodes) {
|
||||
const percent = mean > 0 ? ((node.total_times_seen / mean) * 100).toFixed(1) + "%" : "0%";
|
||||
const row = `<tr>
|
||||
@@ -182,73 +201,52 @@ function updateTable() {
|
||||
// Update chart & stats
|
||||
function updateStatsAndChart() {
|
||||
const timesSeen = filteredNodes.map(n => n.total_times_seen);
|
||||
const mean = timesSeen.reduce((sum, v) => sum + v, 0) / (timesSeen.length || 1);
|
||||
const stdDev = Math.sqrt(timesSeen.reduce((sum, v) => sum + Math.pow(v - mean, 2), 0) / (timesSeen.length || 1));
|
||||
|
||||
const mean = timesSeen.reduce((sum,v)=>sum+v,0)/(timesSeen.length||1);
|
||||
const stdDev = Math.sqrt(timesSeen.reduce((sum,v)=>sum+Math.pow(v-mean,2),0)/(timesSeen.length||1));
|
||||
meanEl.textContent = mean.toFixed(2);
|
||||
stdEl.textContent = stdDev.toFixed(2);
|
||||
|
||||
const min = Math.min(...timesSeen);
|
||||
const max = Math.max(...timesSeen);
|
||||
const step = (max - min) / 100;
|
||||
const xData = [], yData = [];
|
||||
const xData=[], yData=[];
|
||||
for(let x=min;x<=max;x+=step){ xData.push(x); yData.push(normalDistribution(x,mean,stdDev)); }
|
||||
|
||||
for (let x = min; x <= max; x += step) {
|
||||
xData.push(x);
|
||||
yData.push(normalDistribution(x, mean, stdDev));
|
||||
}
|
||||
|
||||
const option = {
|
||||
animation: false,
|
||||
tooltip: { trigger: 'axis' },
|
||||
xAxis: {
|
||||
name: 'Total Times Seen',
|
||||
type: 'value',
|
||||
min, max
|
||||
},
|
||||
yAxis: {
|
||||
name: 'Probability Density',
|
||||
type: 'value',
|
||||
},
|
||||
series: [{
|
||||
data: xData.map((x, i) => [x, yData[i]]),
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
color: 'blue',
|
||||
lineStyle: { width: 3 }
|
||||
}]
|
||||
};
|
||||
chart.setOption(option);
|
||||
chart.setOption({
|
||||
animation:false,
|
||||
tooltip:{ trigger:'axis' },
|
||||
xAxis:{ name:'Total Times Seen', type:'value', min, max },
|
||||
yAxis:{ name:'Probability Density', type:'value' },
|
||||
series:[{ data:xData.map((x,i)=>[x,yData[i]]), type:'line', smooth:true, color:'blue', lineStyle:{ width:3 }}]
|
||||
});
|
||||
chart.resize();
|
||||
}
|
||||
|
||||
// Sorting
|
||||
// Sort table
|
||||
function sortTable(n) {
|
||||
const table = document.getElementById("trafficTable");
|
||||
const rows = Array.from(table.rows).slice(1);
|
||||
const header = table.rows[0].cells[n];
|
||||
const isNumeric = !isNaN(rows[0].cells[n].innerText.replace('%', ''));
|
||||
let sortedRows = rows.sort((a, b) => {
|
||||
const valA = isNumeric ? parseFloat(a.cells[n].innerText.replace('%', '')) : a.cells[n].innerText.toLowerCase();
|
||||
const valB = isNumeric ? parseFloat(b.cells[n].innerText.replace('%', '')) : b.cells[n].innerText.toLowerCase();
|
||||
const isNumeric = !isNaN(rows[0].cells[n].innerText.replace('%',''));
|
||||
let sortedRows = rows.sort((a,b)=>{
|
||||
const valA = isNumeric ? parseFloat(a.cells[n].innerText.replace('%','')) : a.cells[n].innerText.toLowerCase();
|
||||
const valB = isNumeric ? parseFloat(b.cells[n].cells[n].innerText.replace('%','')) : b.cells[n].innerText.toLowerCase();
|
||||
return valA > valB ? 1 : -1;
|
||||
});
|
||||
|
||||
if (header.getAttribute('data-sort-direction') === 'asc') {
|
||||
sortedRows.reverse();
|
||||
header.setAttribute('data-sort-direction', 'desc');
|
||||
} else {
|
||||
header.setAttribute('data-sort-direction', 'asc');
|
||||
}
|
||||
|
||||
if(header.getAttribute('data-sort-direction')==='asc'){ sortedRows.reverse(); header.setAttribute('data-sort-direction','desc'); }
|
||||
else header.setAttribute('data-sort-direction','asc');
|
||||
const tbody = table.tBodies[0];
|
||||
sortedRows.forEach(row => tbody.appendChild(row));
|
||||
sortedRows.forEach(row=>tbody.appendChild(row));
|
||||
}
|
||||
|
||||
// Initialize
|
||||
updateTable();
|
||||
updateStatsAndChart();
|
||||
window.addEventListener('resize', () => chart.resize());
|
||||
(async ()=>{
|
||||
await loadTopTranslations();
|
||||
applyTopTranslations();
|
||||
updateTable();
|
||||
updateStatsAndChart();
|
||||
window.addEventListener('resize',()=>chart.resize());
|
||||
})();
|
||||
</script>
|
||||
|
||||
{% if timing_data %}
|
||||
|
||||
Reference in New Issue
Block a user