add webapp, move frontend to webapp folder
This commit is contained in:
911
webapp/frontend/src/views/HomeView.vue
Normal file
911
webapp/frontend/src/views/HomeView.vue
Normal file
@ -0,0 +1,911 @@
|
||||
<script setup>
|
||||
import Header from '../components/Header.vue';
|
||||
import InfoModal from '../components/InfoModal.vue';
|
||||
import HardwareModelList from '../components/HardwareModelList.vue';
|
||||
import Settings from '../components/Settings.vue';
|
||||
import NodeInfo from '../components/NodeInfo.vue';
|
||||
import NodeNeighborsModal from '../components/NodeNeighborsModal.vue';
|
||||
import NodePositionHistoryModal from '../components/NodePositionHistoryModal.vue';
|
||||
import TracerouteInfo from '../components/TracerouteInfo.vue';
|
||||
import Announcement from '../components/Announcement.vue';
|
||||
|
||||
import axios from 'axios';
|
||||
import moment from 'moment';
|
||||
import L from 'leaflet/dist/leaflet.js';
|
||||
import 'leaflet.markercluster/dist/leaflet.markercluster.js';
|
||||
import 'leaflet-groupedlayercontrol/dist/leaflet.groupedlayercontrol.min.js';
|
||||
import { onMounted, useTemplateRef, ref, watch, markRaw } from 'vue';
|
||||
import { state } from '../store.js';
|
||||
import {
|
||||
layerGroups,
|
||||
tileLayers,
|
||||
icons,
|
||||
getTooltipContentForWaypoint,
|
||||
getTooltipContentForNode,
|
||||
getNeighbourTooltipContent,
|
||||
clearAllNodes,
|
||||
clearAllNeighbors,
|
||||
clearAllWaypoints,
|
||||
clearAllPositionHistory,
|
||||
cleanUpPositionHistory,
|
||||
closeAllTooltips,
|
||||
closeAllPopups,
|
||||
cleanUpNodeNeighbors,
|
||||
clearNodeOutline,
|
||||
clearMap,
|
||||
setMap,
|
||||
getMap,
|
||||
} from '../map.js';
|
||||
import {
|
||||
nodesMaxAge,
|
||||
nodesDisconnectedAge,
|
||||
nodesOfflineAge,
|
||||
waypointsMaxAge,
|
||||
enableMapAnimations,
|
||||
goToNodeZoomLevel,
|
||||
autoUpdatePositionInUrl,
|
||||
neighboursMaxDistance,
|
||||
enabledOverlayLayers,
|
||||
selectedTileLayerName,
|
||||
hasSeenInfoModal,
|
||||
lastSeenAnnouncementId,
|
||||
CURRENT_ANNOUNCEMENT_ID,
|
||||
} from '../config.js';
|
||||
import {
|
||||
getColorForSnr,
|
||||
getPositionPrecisionInMeters,
|
||||
getTerrainProfileImage,
|
||||
getRegionFrequencyRange,
|
||||
escapeString,
|
||||
formatPositionPrecision,
|
||||
isMobile,
|
||||
elementOrAnyAncestorHasClass,
|
||||
findNodeById,
|
||||
findNodeMarkerById,
|
||||
hasNodeUplinkedToMqttRecently,
|
||||
buildPath,
|
||||
} from '../utils.js';
|
||||
const mapEl = useTemplateRef('appMap');
|
||||
|
||||
// watchers
|
||||
watch(
|
||||
() => state.positionHistoryDateTimeTo,
|
||||
(newValue) => {
|
||||
if (newValue != null) {
|
||||
loadNodePositionHistory(state.selectedNodeToShowPositionHistory.node_id);
|
||||
}
|
||||
}, {deep: true}
|
||||
);
|
||||
watch(
|
||||
() => state.positionHistoryDateTimeFrom,
|
||||
(newValue) => {
|
||||
if (newValue != null) {
|
||||
loadNodePositionHistory(state.selectedNodeToShowPositionHistory.node_id);
|
||||
}
|
||||
}, {deep: true}
|
||||
);
|
||||
|
||||
function showNodeOutline(id) {
|
||||
// remove any existing node circle
|
||||
clearNodeOutline();
|
||||
|
||||
// find node marker by id
|
||||
const nodeMarker = state.nodeMarkers[id];
|
||||
if (!nodeMarker) {
|
||||
return;
|
||||
}
|
||||
|
||||
// find node by id
|
||||
const node = findNodeById(id);
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
|
||||
// add position precision circle around node
|
||||
if(node.position_precision != null && node.position_precision > 0 && node.position_precision < 32){
|
||||
state.selectedNodeOutlineCircle = L.circle(nodeMarker.getLatLng(), {
|
||||
radius: getPositionPrecisionInMeters(node.position_precision),
|
||||
}).addTo(getMap());
|
||||
}
|
||||
}
|
||||
|
||||
window.showNodeDetails = function(id) {
|
||||
// find node
|
||||
const node = findNodeById(id);
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
state.selectedNode = node;
|
||||
}
|
||||
|
||||
window.showNodeNeighboursThatHeardUs = function(id) {
|
||||
cleanUpNodeNeighbors();
|
||||
|
||||
// find node
|
||||
const node = findNodeById(id);
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
|
||||
// find node marker
|
||||
const nodeMarker = findNodeMarkerById(node.node_id);
|
||||
if (!nodeMarker) {
|
||||
return;
|
||||
}
|
||||
|
||||
// show overlay
|
||||
state.selectedNodeToShowNeighbours = node;
|
||||
state.selectedNodeToShowNeighboursType = 'theyHeard';
|
||||
|
||||
// find all nodes that have us as a neighbour
|
||||
const neighbourNodeInfos = [];
|
||||
for (const nodeThatMayHaveHeardUs of state.nodes) {
|
||||
// find our node in this nodes neighbours
|
||||
const nodeNeighbours = nodeThatMayHaveHeardUs.neighbours ?? [];
|
||||
const neighbour = nodeNeighbours.find(function(neighbour) {
|
||||
return neighbour.node_id.toString() === node.node_id.toString();
|
||||
});
|
||||
|
||||
// we exist as a neighbour
|
||||
if (neighbour) {
|
||||
neighbourNodeInfos.push({
|
||||
node: nodeThatMayHaveHeardUs,
|
||||
neighbour: neighbour,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ensure we have neighbours to show
|
||||
if (neighbourNodeInfos.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// add node neighbours
|
||||
for (const neighbourNodeInfo of neighbourNodeInfos) {
|
||||
|
||||
const neighbourNode = neighbourNodeInfo.node;
|
||||
const neighbour = neighbourNodeInfo.neighbour;
|
||||
|
||||
// fixme: skipping zero snr? saw some crazy long neighbours with zero snr...
|
||||
if (neighbour.snr === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// find neighbour node marker
|
||||
const neighbourNodeMarker = findNodeMarkerById(neighbourNode.node_id);
|
||||
if (!neighbourNodeMarker) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// calculate distance in meters between nodes (rounded to 2 decimal places)
|
||||
const distanceInMeters = neighbourNodeMarker.getLatLng().distanceTo(nodeMarker.getLatLng()).toFixed(2);
|
||||
|
||||
// don't show this neighbour connection if further than config allows
|
||||
if (neighboursMaxDistance.value != null && parseFloat(distanceInMeters) > neighboursMaxDistance.value) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// add neighbour line to map
|
||||
const line = L.polyline([
|
||||
nodeMarker.getLatLng(), // from us
|
||||
neighbourNodeMarker.getLatLng(), // to neighbour
|
||||
], {
|
||||
color: getColourForSnr(neighbour.snr),
|
||||
opacity: 1,
|
||||
}).arrowheads({
|
||||
size: '10px',
|
||||
fill: true,
|
||||
offsets: {
|
||||
start: '25px',
|
||||
end: '25px',
|
||||
},
|
||||
}).addTo(layerGroups.nodeNeighbors);
|
||||
|
||||
const tooltip = getNeighbourTooltipContent('theyHeard', node, neighbourNode, distanceInMeters, neighbour.snr);
|
||||
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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
window.showNodeNeighboursThatWeHeard = function(id) {
|
||||
cleanUpNodeNeighbors();
|
||||
|
||||
// find node
|
||||
const node = findNodeById(id);
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
|
||||
// find node marker
|
||||
const nodeMarker = findNodeMarkerById(node.node_id);
|
||||
if (!nodeMarker) {
|
||||
return;
|
||||
}
|
||||
|
||||
// show overlay
|
||||
state.selectedNodeToShowNeighbours = node;
|
||||
state.selectedNodeToShowNeighboursType = 'weHeard';
|
||||
|
||||
// ensure we have neighbours to show
|
||||
const neighbours = node.neighbours ?? [];
|
||||
if (neighbours.length === 0) {
|
||||
return;
|
||||
}
|
||||
for (const neighbour of neighbours) {
|
||||
// fixme: skipping zero snr? saw some crazy long neighbours with zero snr...
|
||||
if (neighbour.snr === 0) {
|
||||
continue;
|
||||
}
|
||||
// find neighbor node
|
||||
const neighbourNode = findNodeById(neighbour.node_id);
|
||||
if (!neighbourNode) {
|
||||
continue;
|
||||
}
|
||||
// find neighbor node marker
|
||||
const neighbourNodeMarker = findNodeMarkerById(neighbour.node_id);
|
||||
if (!neighbourNodeMarker) {
|
||||
continue;
|
||||
}
|
||||
// calculate distance in meters between nodes (rounded to 2 decimal places)
|
||||
const distanceInMeters = nodeMarker.getLatLng().distanceTo(neighbourNodeMarker.getLatLng()).toFixed(2);
|
||||
|
||||
if (neighboursMaxDistance.value != null && parseFloat(distanceInMeters) > neighboursMaxDistance.value) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// add neighbour line to map
|
||||
const line = L.polyline([
|
||||
neighbourNodeMarker.getLatLng(), // from neighbor
|
||||
nodeMarker.getLatLng(), // to us
|
||||
], {
|
||||
color: getColorForSnr(neighbour.snr),
|
||||
opacity: 1,
|
||||
}).arrowheads({
|
||||
size: '10px',
|
||||
fill: true,
|
||||
offsets: {
|
||||
start: '25px',
|
||||
end: '25px',
|
||||
},
|
||||
}).addTo(layerGroups.nodeNeighbors);
|
||||
|
||||
const tooltip = getNeighbourTooltipContent('weHeard', node, neighbourNode, distanceInMeters, neighbour.snr);
|
||||
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 onNodesUpdated(nodes) {
|
||||
const now = moment();
|
||||
state.nodes = [];
|
||||
for (const node of nodes) {
|
||||
// skip nodes older than configured node max age
|
||||
if (nodesMaxAge.value) {
|
||||
const lastUpdatedAgeInMillis = now.diff(moment(node.updated_at));
|
||||
if (lastUpdatedAgeInMillis > nodesMaxAge.value * 1000) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// skip nodes without position
|
||||
if (!node.latitude || !node.longitude) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// skip nodes with invalid position
|
||||
if (isNaN(node.latitude) || isNaN(node.longitude)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// fix lat long
|
||||
node.latitude = node.latitude / 10000000;
|
||||
node.longitude = node.longitude / 10000000;
|
||||
|
||||
// wrap longitude for shortest path, everything to left of australia should be shown on the right
|
||||
let longitude = parseFloat(node.longitude);
|
||||
if (longitude <= 100) {
|
||||
longitude += 360;
|
||||
}
|
||||
|
||||
// icon based on mqtt connection state
|
||||
let icon = icons.mqttDisconnected;
|
||||
|
||||
// use offline icon for nodes older than configured node offline age
|
||||
if (nodesOfflineAge) {
|
||||
const lastUpdatedAgeInMillis = now.diff(moment(node.updated_at));
|
||||
if (lastUpdatedAgeInMillis > nodesOfflineAge.value * 1000) {
|
||||
icon = icons.offline;
|
||||
}
|
||||
}
|
||||
|
||||
// determine if node was recently heard uplinking packets to mqtt
|
||||
const nodeHasUplinkedToMqttRecently = hasNodeUplinkedToMqttRecently(node);
|
||||
if (nodeHasUplinkedToMqttRecently) {
|
||||
icon = icons.mqttConnected;
|
||||
}
|
||||
|
||||
// create node marker
|
||||
const marker = L.marker([node.latitude, longitude], {
|
||||
icon: icon,
|
||||
tagName: node.node_id,
|
||||
// we want to show online nodes above offline, but without needing to use separate layer groups
|
||||
zIndexOffset: nodeHasUplinkedToMqttRecently ? 1000 : -1000,
|
||||
}).on('click', function(event) {
|
||||
// close tooltip on click to prevent tooltip and popup showing at same time
|
||||
event.target.closeTooltip();
|
||||
});
|
||||
|
||||
// add marker to node layer groups
|
||||
marker.addTo(layerGroups.nodes);
|
||||
layerGroups.nodesClustered.addLayer(marker);
|
||||
|
||||
// add markers for routers and repeaters to routers layer group
|
||||
if (node.role_name === 'ROUTER'
|
||||
|| node.role_name === 'ROUTER_CLIENT'
|
||||
|| node.role_name === 'ROUTER_LATE'
|
||||
|| node.role_name === 'REPEATER') {
|
||||
layerGroups.nodesRouter.addLayer(marker);
|
||||
}
|
||||
|
||||
// show tooltip on desktop only
|
||||
if (!isMobile()) {
|
||||
marker.bindTooltip(getTooltipContentForNode(node), {
|
||||
interactive: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Push node and marker to cache
|
||||
state.nodes.push(node);
|
||||
state.nodeMarkers[node.node_id] = marker;
|
||||
|
||||
// show node info tooltip when clicking node marker
|
||||
marker.on('click', function(event) {
|
||||
// close all other popups and tooltips
|
||||
closeAllTooltips();
|
||||
closeAllPopups();
|
||||
|
||||
// find node
|
||||
const node = findNodeById(event.target.options.tagName);
|
||||
if (!node){
|
||||
return;
|
||||
}
|
||||
|
||||
// show position precision outline
|
||||
showNodeOutline(node.node_id);
|
||||
|
||||
// open tooltip for node
|
||||
getMap().openTooltip(getTooltipContentForNode(node), event.target.getLatLng(), {
|
||||
interactive: true, // allow clicking buttons inside tooltip
|
||||
permanent: true, // don't auto dismiss when clicking buttons inside tooltip
|
||||
});
|
||||
});
|
||||
}
|
||||
for(const node of nodes) {
|
||||
// find current node
|
||||
const currentNode = findNodeMarkerById(node.node_id);
|
||||
if (!currentNode) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// add node neighbours
|
||||
var polylineOffset = 0;
|
||||
const neighbours = node.neighbours ?? [];
|
||||
for( const neighbour of neighbours) {
|
||||
// fixme: skipping zero snr? saw some crazy long neighbours with zero snr...
|
||||
if(neighbour.snr === 0){
|
||||
continue;
|
||||
}
|
||||
const neighbourNode = findNodeById(neighbour.node_id);
|
||||
if (!neighbourNode) {
|
||||
continue;
|
||||
}
|
||||
const neighbourNodeMarker = findNodeMarkerById(neighbour.node_id);
|
||||
if (neighbourNodeMarker) {
|
||||
|
||||
// calculate distance in meters between nodes (rounded to 2 decimal places)
|
||||
const distanceInMeters = currentNode.getLatLng().distanceTo(neighbourNodeMarker.getLatLng()).toFixed(2);
|
||||
|
||||
// don't show this neighbour connection if further than config allows
|
||||
if (neighboursMaxDistance.value != null && parseFloat(distanceInMeters) > neighboursMaxDistance.value) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// add neighbour line to map
|
||||
const line = L.polyline([
|
||||
currentNode.getLatLng(),
|
||||
neighbourNodeMarker.getLatLng(),
|
||||
], {
|
||||
color: '#2563eb',
|
||||
opacity: 0.75,
|
||||
offset: polylineOffset,
|
||||
}).addTo(layerGroups.neighbors);
|
||||
|
||||
// increase offset so next neighbour does not overlay other neighbours from self
|
||||
polylineOffset += 2;
|
||||
|
||||
const tooltip = getNeighbourTooltipContent('weHeard', node, neighbourNode, distanceInMeters, neighbour.snr);
|
||||
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 onWaypointsUpdated(waypoints) {
|
||||
state.waypoints = [];
|
||||
const now = moment();
|
||||
// add nodes
|
||||
for (const waypoint of waypoints) {
|
||||
// skip waypoints older than configured waypoint max age
|
||||
if (waypointsMaxAge.value) {
|
||||
const lastUpdatedAgeInMillis = now.diff(moment(waypoint.updated_at));
|
||||
if (lastUpdatedAgeInMillis > waypointsMaxAge.value * 1000) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// skip expired waypoints
|
||||
if (waypoint.expire < Date.now() / 1000) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// skip waypoints without position
|
||||
if (!waypoint.latitude || !waypoint.longitude) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// skip nodes with invalid position
|
||||
if (isNaN(waypoint.latitude) || isNaN(waypoint.longitude)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// fix lat long
|
||||
waypoint.latitude = waypoint.latitude / 10000000;
|
||||
waypoint.longitude = waypoint.longitude / 10000000;
|
||||
|
||||
// wrap longitude for shortest path, everything to left of australia should be shown on the right
|
||||
let longitude = parseFloat(waypoint.longitude);
|
||||
if (longitude <= 100) {
|
||||
longitude += 360;
|
||||
}
|
||||
|
||||
// determine emoji to show as marker icon
|
||||
const emoji = waypoint.icon === 0 ? 128205 : waypoint.icon;
|
||||
const emojiText = String.fromCodePoint(emoji);
|
||||
|
||||
let tooltip = getTooltipContentForWaypoint(waypoint);
|
||||
|
||||
// create waypoint marker
|
||||
const marker = L.marker([waypoint.latitude, longitude], {
|
||||
icon: L.divIcon({
|
||||
className: 'waypoint-label',
|
||||
iconSize: [26, 26], // increase from 12px to 26px
|
||||
html: emojiText,
|
||||
}),
|
||||
}).bindPopup(tooltip).on('click', function(event) {
|
||||
// close tooltip on click to prevent tooltip and popup showing at same time
|
||||
event.target.closeTooltip();
|
||||
});
|
||||
|
||||
// show tooltip on desktop only
|
||||
if (!isMobile()) {
|
||||
marker.bindTooltip(tooltip, {
|
||||
interactive: true,
|
||||
});
|
||||
}
|
||||
|
||||
// add marker to waypoints layer groups
|
||||
marker.addTo(layerGroups.waypoints)
|
||||
|
||||
// add to cache
|
||||
state.waypoints.push(waypoint);
|
||||
}
|
||||
}
|
||||
|
||||
function onPositionHistoryUpdated(updatedPositionHistories) {
|
||||
let positionHistoryLinesCords = [];
|
||||
// add nodes
|
||||
for (const positionHistory of updatedPositionHistories) {
|
||||
// skip position history without position
|
||||
if (!positionHistory.latitude || !positionHistory.longitude) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// find node this position is for
|
||||
const node = findNodeById(positionHistory.node_id);
|
||||
if (!node) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// skip position history without position
|
||||
if (!positionHistory.latitude || !positionHistory.longitude) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// skip nodes with invalid position
|
||||
if (isNaN(positionHistory.latitude) || isNaN(positionHistory.longitude)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// fix lat long
|
||||
positionHistory.latitude = positionHistory.latitude / 10000000;
|
||||
positionHistory.longitude = positionHistory.longitude / 10000000;
|
||||
|
||||
// wrap longitude for shortest path, everything to left of australia should be shown on the right
|
||||
let longitude = parseFloat(positionHistory.longitude);
|
||||
if (longitude <= 100) {
|
||||
longitude += 360;
|
||||
}
|
||||
positionHistoryLinesCords.push([positionHistory.latitude, longitude]);
|
||||
|
||||
let tooltip = "";
|
||||
if(positionHistory.type === "position"){
|
||||
tooltip += `<b>Position</b>`;
|
||||
} else if(positionHistory.type === "map_report"){
|
||||
tooltip += `<b>Map Report</b>`;
|
||||
}
|
||||
tooltip += `<br/>[${escapeString(node.short_name)}] ${escapeString(node.long_name)}`;
|
||||
tooltip += `<br/>${positionHistory.latitude}, ${positionHistory.longitude}`;
|
||||
tooltip += `<br/>Heard on: ${moment(new Date(positionHistory.created_at)).format("DD/MM/YYYY hh:mm A")}`;
|
||||
|
||||
// add gateway info if available
|
||||
if (positionHistory.gateway_id) {
|
||||
const gatewayNode = findNodeById(positionHistory.gateway_id);
|
||||
const gatewayNodeInfo = gatewayNode ? `[${gatewayNode.short_name}] ${gatewayNode.long_name}` : "???";
|
||||
tooltip += `<br/>Heard by: <a href="javascript:void(0);" onclick="goToNode(${positionHistory.gateway_id})">${gatewayNodeInfo}</a>`;
|
||||
}
|
||||
|
||||
// create position history marker
|
||||
const marker = L.marker([positionHistory.latitude, longitude],{
|
||||
icon: iconPositionHistory,
|
||||
}).bindTooltip(tooltip).bindPopup(tooltip).on('click', function(event) {
|
||||
// close tooltip on click to prevent tooltip and popup showing at same time
|
||||
event.target.closeTooltip();
|
||||
});
|
||||
|
||||
// add marker to position history layer group
|
||||
marker.addTo(layerGroups.nodePositionHistory);
|
||||
}
|
||||
// show lines between position history markers
|
||||
L.polyline(positionHistoryLinesCords).addTo(layerGroups.nodePositionHistory);
|
||||
}
|
||||
|
||||
function goToRandomNode() {
|
||||
if (state.nodes.length > 0) {
|
||||
const randomNode = state.nodes[Math.floor(Math.random() * state.nodes.length)];
|
||||
if (randomNode) {
|
||||
// go to node
|
||||
if (goToNode(randomNode.node_id)) {
|
||||
return;
|
||||
}
|
||||
// fallback to showing node details since we can't go to the node
|
||||
window.showNodeDetails(randomNode.node_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function showNodePositionHistory(nodeId) {
|
||||
// find node
|
||||
const node = findNodeById(nodeId);
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
|
||||
// update ui
|
||||
state.selectedNode = null;
|
||||
state.selectedNodeToShowPositionHistory = node;
|
||||
state.positionHistoryModalExpanded = true;
|
||||
|
||||
// close node info tooltip as position history shows under it
|
||||
closeAllTooltips();
|
||||
|
||||
// reset default time range when opening position history ui
|
||||
// YYYY-MM-DDTHH:mm is the format expected by the datetime-local input type
|
||||
state.positionHistoryDateTimeFrom = moment().subtract(1, "hours").format('YYYY-MM-DDTHH:mm');
|
||||
state.positionHistoryDateTimeTo = moment().format('YYYY-MM-DDTHH:mm');
|
||||
|
||||
// load position history
|
||||
loadNodePositionHistory(nodeId);
|
||||
}
|
||||
|
||||
function loadNodePositionHistory(nodeId) {
|
||||
state.selectedNodePositionHistory = [];
|
||||
axios.get(buildPath(`/api/v1/nodes/${nodeId}/position-history`), {
|
||||
params: {
|
||||
// parse from datetime-local format, and send as unix timestamp in milliseconds
|
||||
time_from: moment(state.positionHistoryDateTimeFrom, "YYYY-MM-DDTHH:mm").format("x"),
|
||||
time_to: moment(state.positionHistoryDateTimeTo, "YYYY-MM-DDTHH:mm").format("x"),
|
||||
},
|
||||
}).then((response) => {
|
||||
state.selectedNodePositionHistory = response.data.position_history;
|
||||
if (state.selectedNodeToShowPositionHistory != null) {
|
||||
clearAllPositionHistory();
|
||||
onPositionHistoryUpdated(response.data.position_history);
|
||||
};
|
||||
}).catch(() => {
|
||||
// do nothing
|
||||
});
|
||||
}
|
||||
|
||||
function reload(goToNodeId, zoom) {
|
||||
// show loading
|
||||
state.loading = true;
|
||||
// clear previous data
|
||||
clearMap();
|
||||
axios.get(buildPath('/api/v1/nodes')).then(response => {
|
||||
// update nodes
|
||||
onNodesUpdated(response.data.nodes);
|
||||
// hide loading
|
||||
state.loading = false;
|
||||
// fetch waypoints (after awaiting nodes, so we can use nodes cache in waypoint tooltips)
|
||||
axios.get(buildPath('/api/v1/waypoints')).then(response => {
|
||||
onWaypointsUpdated(response.data.waypoints);
|
||||
});
|
||||
// go to node id if provided
|
||||
if (goToNodeId) {
|
||||
// go to node
|
||||
if(goToNode(goToNodeId, false, zoom)) {
|
||||
return;
|
||||
}
|
||||
// fallback to showing node details since we can't go to the node
|
||||
window.showNodeDetails(goToNodeId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function goToNode(id, animate, zoom){
|
||||
// find node
|
||||
const node = findNodeById(id);
|
||||
if (!node) {
|
||||
alert("Could not find node: " + id);
|
||||
return false;
|
||||
}
|
||||
|
||||
// find node marker by id
|
||||
const nodeMarker = findNodeMarkerById(id);
|
||||
if (!nodeMarker) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// close all popups and tooltips
|
||||
closeAllPopups();
|
||||
closeAllTooltips();
|
||||
|
||||
// select node
|
||||
showNodeOutline(id);
|
||||
|
||||
// fly to node marker
|
||||
const shouldAnimate = animate != null ? animate : true;
|
||||
getMap().flyTo(nodeMarker.getLatLng(), parseFloat(zoom || goToNodeZoomLevel.value), {
|
||||
animate: enableMapAnimations.value ? shouldAnimate : false,
|
||||
});
|
||||
|
||||
// open tooltip for node
|
||||
getMap().openTooltip(getTooltipContentForNode(node), nodeMarker.getLatLng(), {
|
||||
interactive: true, // allow clicking buttons inside tooltip
|
||||
permanent: true, // don't auto dismiss when clicking buttons inside tooltip
|
||||
});
|
||||
|
||||
// successfully went to node
|
||||
return true;
|
||||
|
||||
}
|
||||
|
||||
function onSearchResultNodeClick(node) {
|
||||
// clear search
|
||||
state.searchText = '';
|
||||
|
||||
// hide search
|
||||
state.mobileSearchVisible = false;
|
||||
|
||||
// go to node
|
||||
if (goToNode(node.node_id) ){
|
||||
return;
|
||||
}
|
||||
|
||||
// fallback to showing node details since we can't go to the node
|
||||
window.showNodeDetails(node.node_id);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// set map bounds to be a little more than full size to prevent panning off screen
|
||||
const bounds = [
|
||||
[-100, 70], // top left
|
||||
[100, 500], // bottom right
|
||||
];
|
||||
// create map positioned over AU and NZ
|
||||
setMap(L.map(mapEl.value, {
|
||||
maxBounds: bounds,
|
||||
}));
|
||||
// set view
|
||||
getMap().setView([-15, 150], 2);
|
||||
// remove leaflet link
|
||||
getMap().attributionControl.setPrefix('');
|
||||
|
||||
// use tile layer based on config
|
||||
const selectedTileLayer = tileLayers[selectedTileLayerName.value] || tileLayers['OpenStreetMap'];
|
||||
selectedTileLayer.addTo(getMap());
|
||||
|
||||
// handle baselayerchange to update tile layer preference
|
||||
getMap().on('baselayerchange', function(event) {
|
||||
selectedTileLayerName.value = event.name;
|
||||
});
|
||||
|
||||
// create legend
|
||||
const legend = L.control({position: 'bottomleft'});
|
||||
legend.onAdd = function (map) {
|
||||
const div = L.DomUtil.create('div', 'leaflet-control-layers');
|
||||
div.style.backgroundColor = 'white';
|
||||
div.style.padding = '12px';
|
||||
div.innerHTML = `<div style="margin-bottom:6px;"><strong>Legend</strong></div>`
|
||||
+ `<div style="display:flex"><div class="icon-mqtt-connected" style="width:12px;height:12px;margin-right:4px;margin-top:auto;margin-bottom:auto;"></div> MQTT Connected</div>`
|
||||
+ `<div style="display:flex"><div class="icon-mqtt-disconnected" style="width:12px;height:12px;margin-right:4px;margin-top:auto;margin-bottom:auto;"></div> MQTT Disconnected</div>`
|
||||
+ `<div style="display:flex"><div class="icon-offline" style="width:12px;height:12px;margin-right:4px;margin-top:auto;margin-bottom:auto;"></div> Offline Too Long</div>`;
|
||||
return div;
|
||||
};
|
||||
// handle adding/remove legend on map (can't use L.Control as an overlay, so we toggle an empty L.LayerGroup)
|
||||
getMap().on('overlayadd overlayremove', function(event) {
|
||||
if (event.name === 'Legend') {
|
||||
if (event.type === 'overlayadd') {
|
||||
getMap().addControl(legend);
|
||||
} else if(event.type === 'overlayremove') {
|
||||
getMap().removeControl(legend);
|
||||
}
|
||||
}
|
||||
});
|
||||
// add layers to control ui
|
||||
L.control.groupedLayers(tileLayers, {
|
||||
'Nodes': {
|
||||
'All': layerGroups.nodes,
|
||||
'Routers': layerGroups.nodesRouter,
|
||||
'Clustered': layerGroups.nodesClustered,
|
||||
'None': layerGroups.none,
|
||||
},
|
||||
'Overlays': {
|
||||
'Legend': layerGroups.legend,
|
||||
'Neighbors': layerGroups.neighbors,
|
||||
'Waypoints': layerGroups.waypoints,
|
||||
'Position History': layerGroups.nodePositionHistory,
|
||||
},
|
||||
}, {
|
||||
// make the "Nodes" group exclusive (use radio inputs instead of checkbox)
|
||||
exclusiveGroups: ['Nodes'],
|
||||
}).addTo(getMap());
|
||||
|
||||
// enable base layers
|
||||
layerGroups.nodesClustered.addTo(getMap());
|
||||
|
||||
// enable overlay layers based on config
|
||||
if (enabledOverlayLayers.value.includes('Legend')) {
|
||||
layerGroups.legend.addTo(getMap());
|
||||
}
|
||||
if (enabledOverlayLayers.value.includes('Neighbors')) {
|
||||
layerGroups.neighbors.addTo(getMap());
|
||||
}
|
||||
if (enabledOverlayLayers.value.includes('Waypoints')) {
|
||||
layerGroups.waypoints.addTo(getMap());
|
||||
}
|
||||
if (enabledOverlayLayers.value.includes('Position History')) {
|
||||
layerGroups.nodePositionHistory.addTo(getMap());
|
||||
}
|
||||
// update config when map overlay is added/removed
|
||||
getMap().on('overlayremove', function(event) {
|
||||
const layerName = event.name;
|
||||
enabledOverlayLayers.value = enabledOverlayLayers.value.filter(function(enabledOverlayLayer) {
|
||||
return enabledOverlayLayer !== layerName;
|
||||
});
|
||||
});
|
||||
|
||||
getMap().on('overlayadd', function(event) {
|
||||
const layerName = event.name;
|
||||
if (!enabledOverlayLayers.value.includes(layerName)) {
|
||||
enabledOverlayLayers.value.push(layerName);
|
||||
}
|
||||
});
|
||||
|
||||
getMap().on('click', function(event) {
|
||||
// remove outline when map clicked
|
||||
clearNodeOutline();
|
||||
// clear search
|
||||
state.searchText = '';
|
||||
state.mobileSearchVisible = false;
|
||||
// do nothing when clicking inside tooltip
|
||||
const clickedElement = event.originalEvent.target;
|
||||
if (elementOrAnyAncestorHasClass(clickedElement, 'leaflet-tooltip')) {
|
||||
return;
|
||||
}
|
||||
|
||||
closeAllTooltips();
|
||||
closeAllPopups();
|
||||
});
|
||||
|
||||
// auto update url when lat/lng/zoom changes
|
||||
getMap().on('moveend zoomend', function() {
|
||||
// check if user enabled auto updating position in url
|
||||
if (!autoUpdatePositionInUrl.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
// get map info
|
||||
const latLng = getMap().getCenter();
|
||||
const zoom = getMap().getZoom();
|
||||
|
||||
// construct new url
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('lat', latLng.lat);
|
||||
url.searchParams.set('lng', latLng.lng);
|
||||
url.searchParams.set('zoom', zoom);
|
||||
|
||||
// update current url
|
||||
if(window.history.replaceState){
|
||||
window.history.replaceState(null, null, url.toString());
|
||||
}
|
||||
});
|
||||
|
||||
// parse url params
|
||||
const queryParams = new URLSearchParams(location.search);
|
||||
const queryNodeId = queryParams.get('node_id');
|
||||
const queryLat = queryParams.get('lat');
|
||||
const queryLng = queryParams.get('lng');
|
||||
const queryZoom = queryParams.get('zoom');
|
||||
|
||||
// go to lat/lng if provided
|
||||
if(queryLat && queryLng){
|
||||
const zoomLevel = queryZoom || goToNodeZoomLevel.value
|
||||
getMap().flyTo([queryLat, queryLng], parseFloat(zoomLevel), {
|
||||
animate: false,
|
||||
});
|
||||
}
|
||||
|
||||
// reload and go to provided node id
|
||||
reload(queryNodeId, queryZoom);
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (lastSeenAnnouncementId.value !== CURRENT_ANNOUNCEMENT_ID) {
|
||||
state.announcementVisible = true;
|
||||
}
|
||||
if (!isMobile() && hasSeenInfoModal.value === false) {
|
||||
state.infoModalVisible = true;
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col h-full w-full overflow-hidden">
|
||||
<div class="flex flex-col h-full">
|
||||
<Announcement />
|
||||
<Header
|
||||
@reload="reload"
|
||||
@random-node="goToRandomNode"
|
||||
@search-click="onSearchResultNodeClick"
|
||||
/>
|
||||
<div id="map" style="width:100%;height:100%;" ref="appMap"></div>
|
||||
</div>
|
||||
</div>
|
||||
<InfoModal />
|
||||
<HardwareModelList />
|
||||
<Settings />
|
||||
<NodeInfo @show-position-history="showNodePositionHistory"/>
|
||||
<NodeNeighborsModal @dismiss="cleanUpNodeNeighbors" />
|
||||
<NodePositionHistoryModal @dismiss="cleanUpPositionHistory" />
|
||||
<TracerouteInfo @go-to="goToNode" />
|
||||
</template>
|
Reference in New Issue
Block a user