Add connections endpoint and UI configuration for connections time period and colored lines
This commit is contained in:
parent
1333447398
commit
556dde517b
2 changed files with 407 additions and 3 deletions
188
src/index.js
188
src/index.js
|
|
@ -155,6 +155,14 @@ app.get('/api', async (req, res) => {
|
|||
"time_to": "Only include traceroutes updated before this unix timestamp (milliseconds)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "/api/v1/connections",
|
||||
"description": "Aggregated edges between nodes from traceroutes",
|
||||
"params": {
|
||||
"time_from": "Only include edges created after this unix timestamp (milliseconds)",
|
||||
"time_to": "Only include edges created before this unix timestamp (milliseconds)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "/api/v1/nodes/:nodeId/position-history",
|
||||
"description": "Position history for a meshtastic node",
|
||||
|
|
@ -698,6 +706,186 @@ app.get('/api/v1/traceroutes', async (req, res) => {
|
|||
}
|
||||
});
|
||||
|
||||
// Aggregated edges endpoint
|
||||
// GET /api/v1/connections?time_from=...&time_to=...
|
||||
app.get('/api/v1/connections', async (req, res) => {
|
||||
try {
|
||||
const timeFrom = req.query.time_from ? parseInt(req.query.time_from) : undefined;
|
||||
const timeTo = req.query.time_to ? parseInt(req.query.time_to) : undefined;
|
||||
|
||||
// Query edges from database
|
||||
const edges = await prisma.edge.findMany({
|
||||
where: {
|
||||
created_at: {
|
||||
gte: timeFrom ? new Date(timeFrom) : undefined,
|
||||
lte: timeTo ? new Date(timeTo) : undefined,
|
||||
},
|
||||
// Only include edges where both nodes have positions
|
||||
from_latitude: { not: null },
|
||||
from_longitude: { not: null },
|
||||
to_latitude: { not: null },
|
||||
to_longitude: { not: null },
|
||||
},
|
||||
orderBy: [
|
||||
{ created_at: 'desc' },
|
||||
{ packet_id: 'desc' },
|
||||
],
|
||||
});
|
||||
|
||||
// Collect all unique node IDs from edges
|
||||
const nodeIds = new Set();
|
||||
for (const edge of edges) {
|
||||
nodeIds.add(edge.from_node_id);
|
||||
nodeIds.add(edge.to_node_id);
|
||||
}
|
||||
|
||||
// Fetch current positions for all nodes
|
||||
const nodes = await prisma.node.findMany({
|
||||
where: {
|
||||
node_id: { in: Array.from(nodeIds) },
|
||||
},
|
||||
select: {
|
||||
node_id: true,
|
||||
latitude: true,
|
||||
longitude: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Create a map of current node positions
|
||||
const nodePositions = new Map();
|
||||
for (const node of nodes) {
|
||||
nodePositions.set(node.node_id, {
|
||||
latitude: node.latitude,
|
||||
longitude: node.longitude,
|
||||
});
|
||||
}
|
||||
|
||||
// Filter edges: only include edges where both nodes are still at the same location
|
||||
const validEdges = edges.filter(edge => {
|
||||
const fromCurrentPos = nodePositions.get(edge.from_node_id);
|
||||
const toCurrentPos = nodePositions.get(edge.to_node_id);
|
||||
|
||||
// Skip if either node doesn't exist or doesn't have a current position
|
||||
if (!fromCurrentPos || !toCurrentPos ||
|
||||
fromCurrentPos.latitude === null || fromCurrentPos.longitude === null ||
|
||||
toCurrentPos.latitude === null || toCurrentPos.longitude === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if stored positions match current positions
|
||||
const fromMatches = fromCurrentPos.latitude === edge.from_latitude &&
|
||||
fromCurrentPos.longitude === edge.from_longitude;
|
||||
const toMatches = toCurrentPos.latitude === edge.to_latitude &&
|
||||
toCurrentPos.longitude === edge.to_longitude;
|
||||
|
||||
return fromMatches && toMatches;
|
||||
});
|
||||
|
||||
// Normalize node pairs: always use min/max to treat A->B and B->A as same connection
|
||||
const connectionsMap = new Map();
|
||||
|
||||
for (const edge of validEdges) {
|
||||
const nodeA = edge.from_node_id < edge.to_node_id ? edge.from_node_id : edge.to_node_id;
|
||||
const nodeB = edge.from_node_id < edge.to_node_id ? edge.to_node_id : edge.from_node_id;
|
||||
const key = `${nodeA}-${nodeB}`;
|
||||
|
||||
if (!connectionsMap.has(key)) {
|
||||
connectionsMap.set(key, {
|
||||
node_a: nodeA,
|
||||
node_b: nodeB,
|
||||
direction_ab: [], // A -> B edges
|
||||
direction_ba: [], // B -> A edges
|
||||
});
|
||||
}
|
||||
|
||||
const connection = connectionsMap.get(key);
|
||||
const isAB = edge.from_node_id === nodeA;
|
||||
|
||||
// Add edge to appropriate direction
|
||||
if (isAB) {
|
||||
connection.direction_ab.push({
|
||||
snr: edge.snr,
|
||||
snr_db: edge.snr / 4, // Convert to dB
|
||||
created_at: edge.created_at,
|
||||
packet_id: edge.packet_id,
|
||||
source: edge.source,
|
||||
});
|
||||
} else {
|
||||
connection.direction_ba.push({
|
||||
snr: edge.snr,
|
||||
snr_db: edge.snr / 4,
|
||||
created_at: edge.created_at,
|
||||
packet_id: edge.packet_id,
|
||||
source: edge.source,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Aggregate each connection
|
||||
const connections = Array.from(connectionsMap.values()).map(conn => {
|
||||
// Deduplicate edges by packet_id for each direction (keep first occurrence, which is most recent)
|
||||
const dedupeByPacketId = (edges) => {
|
||||
const seen = new Set();
|
||||
return edges.filter(edge => {
|
||||
if (seen.has(edge.packet_id)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(edge.packet_id);
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
const deduplicatedAB = dedupeByPacketId(conn.direction_ab);
|
||||
const deduplicatedBA = dedupeByPacketId(conn.direction_ba);
|
||||
|
||||
// Calculate average SNR for A->B (using deduplicated edges)
|
||||
const avgSnrAB = deduplicatedAB.length > 0
|
||||
? deduplicatedAB.reduce((sum, e) => sum + e.snr_db, 0) / deduplicatedAB.length
|
||||
: null;
|
||||
|
||||
// Calculate average SNR for B->A (using deduplicated edges)
|
||||
const avgSnrBA = deduplicatedBA.length > 0
|
||||
? deduplicatedBA.reduce((sum, e) => sum + e.snr_db, 0) / deduplicatedBA.length
|
||||
: null;
|
||||
|
||||
// Get last 5 edges for each direction (already sorted by created_at DESC, packet_id DESC, now deduplicated)
|
||||
const last5AB = deduplicatedAB.slice(0, 5);
|
||||
const last5BA = deduplicatedBA.slice(0, 5);
|
||||
|
||||
// Determine worst average SNR
|
||||
const worstAvgSnrDb = [avgSnrAB, avgSnrBA]
|
||||
.filter(v => v !== null)
|
||||
.reduce((min, val) => val < min ? val : min, Infinity);
|
||||
|
||||
return {
|
||||
node_a: conn.node_a,
|
||||
node_b: conn.node_b,
|
||||
direction_ab: {
|
||||
avg_snr_db: avgSnrAB,
|
||||
last_5_edges: last5AB,
|
||||
total_count: deduplicatedAB.length, // Use deduplicated count
|
||||
},
|
||||
direction_ba: {
|
||||
avg_snr_db: avgSnrBA,
|
||||
last_5_edges: last5BA,
|
||||
total_count: deduplicatedBA.length, // Use deduplicated count
|
||||
},
|
||||
worst_avg_snr_db: worstAvgSnrDb !== Infinity ? worstAvgSnrDb : null,
|
||||
};
|
||||
}).filter(conn => conn.worst_avg_snr_db !== null); // Only return connections with at least one direction
|
||||
|
||||
res.json({
|
||||
connections: connections,
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({
|
||||
message: "Something went wrong, try again later.",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/v1/nodes/:nodeId/position-history', async (req, res) => {
|
||||
try {
|
||||
|
||||
|
|
|
|||
|
|
@ -1440,6 +1440,36 @@
|
|||
<div class="text-xs text-gray-600">Map will animate flying to nodes.</div>
|
||||
</div>
|
||||
|
||||
<!-- configConnectionsTimePeriodInSeconds -->
|
||||
<div class="p-2">
|
||||
<label class="block text-sm font-medium text-gray-900">Connections Time Period</label>
|
||||
<div class="text-xs text-gray-600 mb-2">Edges within this time period are shown in the Connections layer. Reload to update map.</div>
|
||||
<select v-model="configConnectionsTimePeriodInSeconds" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5">
|
||||
<option value="300">5 minutes</option>
|
||||
<option value="900">15 minutes</option>
|
||||
<option value="3600">1 hour</option>
|
||||
<option value="21600">6 hours</option>
|
||||
<option value="43200">12 hours</option>
|
||||
<option value="86400">24 hours</option>
|
||||
<option value="172800">2 days</option>
|
||||
<option value="259200">3 days</option>
|
||||
<option value="604800">7 days</option>
|
||||
<option value="1209600">14 days</option>
|
||||
<option value="2592000">30 days</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- configConnectionsColoredLines -->
|
||||
<div class="p-2">
|
||||
<div class="flex items-start">
|
||||
<div class="flex items-center h-5">
|
||||
<input type="checkbox" v-model="configConnectionsColoredLines" class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300" required>
|
||||
</div>
|
||||
<label class="ml-2 text-sm font-medium text-gray-900">Colored Connection Lines</label>
|
||||
</div>
|
||||
<div class="text-xs text-gray-600">Colors the connection lines by the average SNR in the worst direction. Reload to update map.</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
|
@ -1725,6 +1755,29 @@
|
|||
return localStorage.setItem("config_zoom_level_go_to_node", value);
|
||||
}
|
||||
|
||||
function getConfigConnectionsTimePeriodInSeconds() {
|
||||
const value = localStorage.getItem("config_connections_time_period_in_seconds");
|
||||
// default to 24 hours if unset
|
||||
return value != null ? parseInt(value) : 86400;
|
||||
}
|
||||
|
||||
function setConfigConnectionsTimePeriodInSeconds(value) {
|
||||
return localStorage.setItem("config_connections_time_period_in_seconds", value);
|
||||
}
|
||||
|
||||
function getConfigConnectionsColoredLines() {
|
||||
const value = localStorage.getItem("config_connections_colored_lines");
|
||||
// disable colored lines by default
|
||||
if(value === null){
|
||||
return false;
|
||||
}
|
||||
return value === "true";
|
||||
}
|
||||
|
||||
function setConfigConnectionsColoredLines(value) {
|
||||
return localStorage.setItem("config_connections_colored_lines", value);
|
||||
}
|
||||
|
||||
function isMobile() {
|
||||
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
|
||||
}
|
||||
|
|
@ -1748,6 +1801,8 @@
|
|||
configAutoUpdatePositionInUrl: window.getConfigAutoUpdatePositionInUrl(),
|
||||
configEnableMapAnimations: window.getConfigEnableMapAnimations(),
|
||||
configTemperatureFormat: window.getConfigTemperatureFormat(),
|
||||
configConnectionsTimePeriodInSeconds: window.getConfigConnectionsTimePeriodInSeconds(),
|
||||
configConnectionsColoredLines: window.getConfigConnectionsColoredLines(),
|
||||
|
||||
isShowingHardwareModels: false,
|
||||
hardwareModelStats: null,
|
||||
|
|
@ -2673,6 +2728,12 @@
|
|||
configTemperatureFormat() {
|
||||
window.setConfigTemperatureFormat(this.configTemperatureFormat);
|
||||
},
|
||||
configConnectionsTimePeriodInSeconds() {
|
||||
window.setConfigConnectionsTimePeriodInSeconds(this.configConnectionsTimePeriodInSeconds);
|
||||
},
|
||||
configConnectionsColoredLines() {
|
||||
window.setConfigConnectionsColoredLines(this.configConnectionsColoredLines);
|
||||
},
|
||||
deviceMetricsTimeRange() {
|
||||
this.loadNodeDeviceMetrics(this.selectedNode.node_id);
|
||||
},
|
||||
|
|
@ -2771,6 +2832,7 @@
|
|||
var waypointsLayerGroup = new L.LayerGroup();
|
||||
var nodePositionHistoryLayerGroup = new L.LayerGroup();
|
||||
var traceroutesLayerGroup = new L.LayerGroup();
|
||||
var connectionsLayerGroup = new L.LayerGroup();
|
||||
|
||||
// create icons
|
||||
var iconMqttConnected = L.divIcon({
|
||||
|
|
@ -2859,6 +2921,7 @@
|
|||
"Legend": legendLayerGroup,
|
||||
"Neighbours": neighboursLayerGroup,
|
||||
"Backbone Connection": backboneNeighboursLayerGroup,
|
||||
"Connections": connectionsLayerGroup,
|
||||
"Waypoints": waypointsLayerGroup,
|
||||
"Position History": nodePositionHistoryLayerGroup,
|
||||
"Traceroutes": traceroutesLayerGroup,
|
||||
|
|
@ -2882,6 +2945,9 @@
|
|||
if(enabledOverlayLayers.includes("Backbone Connection")){
|
||||
backboneNeighboursLayerGroup.addTo(map);
|
||||
}
|
||||
if(enabledOverlayLayers.includes("Connections")){
|
||||
connectionsLayerGroup.addTo(map);
|
||||
}
|
||||
if(enabledOverlayLayers.includes("Waypoints")){
|
||||
waypointsLayerGroup.addTo(map);
|
||||
}
|
||||
|
|
@ -3053,6 +3119,10 @@
|
|||
traceroutesLayerGroup.clearLayers();
|
||||
}
|
||||
|
||||
function clearAllConnections() {
|
||||
connectionsLayerGroup.clearLayers();
|
||||
}
|
||||
|
||||
function closeAllPopups() {
|
||||
map.eachLayer(function(layer) {
|
||||
if(layer.options.pane === "popupPane"){
|
||||
|
|
@ -3120,9 +3190,40 @@
|
|||
}
|
||||
|
||||
function getColourForSnr(snr) {
|
||||
if(snr >= -5) return "#16a34a"; // good
|
||||
if(snr > -15) return "#fff200"; // meh
|
||||
if(snr <= -15) return "#dc2626"; // bad
|
||||
if(snr >= -4) return "#16a34a"; // good
|
||||
if(snr > -8) return "#fff200"; // medium-good
|
||||
if(snr > -12) return "#ff9f1c"; // medium
|
||||
return "#dc2626"; // bad
|
||||
}
|
||||
|
||||
function getSignalBarsIndicator(snrDb) {
|
||||
if(snrDb == null) return '';
|
||||
|
||||
// Determine number of bars based on SNR
|
||||
let bars = 0;
|
||||
if(snrDb >= -4) bars = 4; // good
|
||||
else if(snrDb > -8) bars = 3; // medium-good
|
||||
else if(snrDb > -12) bars = 2; // medium
|
||||
else bars = 1; // bad
|
||||
|
||||
const color = getColourForSnr(snrDb);
|
||||
|
||||
// Create 4 bars with increasing height
|
||||
let indicator = '<span style="display: inline-flex; align-items: flex-end; gap: 2px; height: 12px; vertical-align: middle; margin-left: 4px;">';
|
||||
|
||||
// Bar heights: 4px, 6px, 8px, 10px
|
||||
const barHeights = [4, 6, 8, 10];
|
||||
const barWidth = 2;
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const height = barHeights[i];
|
||||
const isActive = i < bars;
|
||||
const barColor = isActive ? color : '#d1d5db'; // gray for inactive bars
|
||||
indicator += `<span style="width: ${barWidth}px; height: ${height}px; background-color: ${barColor}; border-radius: 1px; display: inline-block;"></span>`;
|
||||
}
|
||||
|
||||
indicator += '</span>';
|
||||
return indicator;
|
||||
}
|
||||
|
||||
function cleanUpNodeNeighbours() {
|
||||
|
|
@ -3492,6 +3593,7 @@
|
|||
clearAllNeighbours();
|
||||
clearAllWaypoints();
|
||||
clearAllTraceroutes();
|
||||
clearAllConnections();
|
||||
clearNodeOutline();
|
||||
cleanUpNodeNeighbours();
|
||||
}
|
||||
|
|
@ -3968,6 +4070,109 @@
|
|||
}
|
||||
}
|
||||
|
||||
function onConnectionsUpdated(connections) {
|
||||
// Clear existing connections
|
||||
clearAllConnections();
|
||||
|
||||
for (const connection of connections) {
|
||||
// Find both node markers
|
||||
const nodeAMarker = findNodeMarkerById(connection.node_a);
|
||||
const nodeBMarker = findNodeMarkerById(connection.node_b);
|
||||
|
||||
// Skip if either node marker doesn't exist
|
||||
if (!nodeAMarker || !nodeBMarker) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find node objects for names and terrain profile
|
||||
const nodeA = findNodeById(connection.node_a);
|
||||
const nodeB = findNodeById(connection.node_b);
|
||||
|
||||
if (!nodeA || !nodeB) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Calculate distance between nodes
|
||||
const distanceInMeters = nodeAMarker.getLatLng().distanceTo(nodeBMarker.getLatLng()).toFixed(2);
|
||||
let distance = `${distanceInMeters} meters`;
|
||||
if (distanceInMeters >= 1000) {
|
||||
const distanceInKilometers = (distanceInMeters / 1000).toFixed(2);
|
||||
distance = `${distanceInKilometers} kilometers`;
|
||||
}
|
||||
|
||||
// Determine line color based on worst average SNR (if colored lines enabled)
|
||||
const configConnectionsColoredLines = getConfigConnectionsColoredLines();
|
||||
const worstSnrDb = connection.worst_avg_snr_db;
|
||||
const lineColor = configConnectionsColoredLines && worstSnrDb != null ? getColourForSnr(worstSnrDb) : '#2563eb';
|
||||
|
||||
// Create polyline (bidirectional, no arrows)
|
||||
const line = L.polyline([
|
||||
nodeAMarker.getLatLng(),
|
||||
nodeBMarker.getLatLng(),
|
||||
], {
|
||||
color: lineColor,
|
||||
opacity: 0.75,
|
||||
weight: 3,
|
||||
}).addTo(connectionsLayerGroup);
|
||||
|
||||
// Generate tooltip
|
||||
let tooltip = `<b>Connection</b>`;
|
||||
tooltip += `<br/>[${escapeString(nodeA.short_name)}] ${escapeString(nodeA.long_name)} <-> [${escapeString(nodeB.short_name)}] ${escapeString(nodeB.long_name)}`;
|
||||
tooltip += `<br/>Distance: ${distance}`;
|
||||
tooltip += `<br/>`;
|
||||
|
||||
// Direction A -> B
|
||||
if (connection.direction_ab.avg_snr_db != null) {
|
||||
tooltip += `<br/><b>${escapeString(nodeA.short_name)} -> ${escapeString(nodeB.short_name)}:</b>`;
|
||||
tooltip += `<br/>SNR: ${connection.direction_ab.avg_snr_db.toFixed(1)}dB ${getSignalBarsIndicator(connection.direction_ab.avg_snr_db)} (Average of ${connection.direction_ab.total_count} edges)`;
|
||||
if (connection.direction_ab.last_5_edges.length > 0) {
|
||||
tooltip += `<br/>Last 5 edges:`;
|
||||
for (const edge of connection.direction_ab.last_5_edges) {
|
||||
const timeAgo = moment(new Date(edge.created_at)).fromNow();
|
||||
console.log(edge.source);
|
||||
const sourceIcon = edge.source === "TRACEROUTE_APP" ? "⇵" : (edge.source === "NEIGHBORINFO_APP" ? "✳" : "?");
|
||||
tooltip += `<br/> ${edge.snr_db.toFixed(1)}dB ${getSignalBarsIndicator(edge.snr_db)} (${timeAgo} by:${sourceIcon})`;
|
||||
}
|
||||
} else {
|
||||
tooltip += `<br/>No recent edges`;
|
||||
}
|
||||
}
|
||||
|
||||
// Direction B -> A
|
||||
if (connection.direction_ba.avg_snr_db != null) {
|
||||
tooltip += `<br/><br/><b>${escapeString(nodeB.short_name)} -> ${escapeString(nodeA.short_name)}:</b>`;
|
||||
tooltip += `<br/>SNR: ${connection.direction_ba.avg_snr_db.toFixed(1)}dB ${getSignalBarsIndicator(connection.direction_ba.avg_snr_db)} (Average of ${connection.direction_ba.total_count} edges)`;
|
||||
if (connection.direction_ba.last_5_edges.length > 0) {
|
||||
tooltip += `<br/>Last 5 edges:`;
|
||||
for (const edge of connection.direction_ba.last_5_edges) {
|
||||
const timeAgo = moment(new Date(edge.created_at)).fromNow();
|
||||
const sourceIcon = edge.source === "TRACEROUTE_APP" ? "⇵" : (edge.source === "NEIGHBORINFO_APP" ? "✳" : "?");
|
||||
tooltip += `<br/> ${edge.snr_db.toFixed(1)}dB ${getSignalBarsIndicator(edge.snr_db)} (${timeAgo} by:${sourceIcon})`;
|
||||
}
|
||||
} else {
|
||||
tooltip += `<br/>No recent edges`;
|
||||
}
|
||||
}
|
||||
|
||||
// Add terrain profile image
|
||||
const terrainImageUrl = getTerrainProfileImage(nodeA, nodeB);
|
||||
tooltip += `<br/><br/>Terrain images from <a href="http://www.heywhatsthat.com" target="_blank">HeyWhatsThat.com</a>`;
|
||||
tooltip += `<br/><a href="${terrainImageUrl}" target="_blank"><img src="${terrainImageUrl}" width="100%"></a>`;
|
||||
|
||||
// Bind tooltip and popup
|
||||
line.bindTooltip(tooltip, {
|
||||
sticky: true,
|
||||
opacity: 1,
|
||||
interactive: true,
|
||||
})
|
||||
.bindPopup(tooltip)
|
||||
.on('click', function(event) {
|
||||
// close tooltip on click to prevent tooltip and popup showing at same time
|
||||
event.target.closeTooltip();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function onPositionHistoryUpdated(updatedPositionHistories) {
|
||||
|
||||
let positionHistoryLinesCords = [];
|
||||
|
|
@ -4111,6 +4316,17 @@
|
|||
onTracerouteEdgesUpdated([]);
|
||||
});
|
||||
|
||||
// fetch connections (edges)
|
||||
const connectionsTimePeriodSec = getConfigConnectionsTimePeriodInSeconds();
|
||||
const connectionsTimeFrom = connectionsTimePeriodSec ? (Date.now() - connectionsTimePeriodSec * 1000) : undefined;
|
||||
const connectionsParams = new URLSearchParams();
|
||||
if (connectionsTimeFrom) connectionsParams.set('time_from', connectionsTimeFrom);
|
||||
await window.axios.get(`/api/v1/connections?${connectionsParams.toString()}`).then(async (response) => {
|
||||
onConnectionsUpdated(response.data.connections ?? []);
|
||||
}).catch(() => {
|
||||
onConnectionsUpdated([]);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
function getRegionFrequencyRange(regionName) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue