commit work on map transition
This commit is contained in:
@ -11,12 +11,11 @@ import Announcement from '../components/Announcement.vue';
|
||||
|
||||
import axios from 'axios';
|
||||
import moment from 'moment';
|
||||
import 'leaflet/dist/leaflet';
|
||||
const L = window.L;
|
||||
import 'leaflet-geometryutil';
|
||||
import 'leaflet-arrowheads';
|
||||
import 'leaflet.markercluster';
|
||||
import 'leaflet-groupedlayercontrol/dist/leaflet.groupedlayercontrol.min.js';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import BasemapsControl from 'maplibre-gl-basemaps';
|
||||
import OpacityControl from 'maplibre-gl-opacity';
|
||||
import LegendControl from '../LegendControl.js';
|
||||
import LayerControl from '../LayerControl.js';
|
||||
import { onMounted, useTemplateRef, ref, watch, markRaw } from 'vue';
|
||||
import { state } from '../store.js';
|
||||
import {
|
||||
@ -290,6 +289,10 @@ window.showNodeNeighboursThatWeHeard = function(id) {
|
||||
}
|
||||
}
|
||||
|
||||
function isValidCoordinates(lat, lng) {
|
||||
return lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180;
|
||||
}
|
||||
|
||||
function onNodesUpdated(nodes) {
|
||||
const now = moment();
|
||||
state.nodes = [];
|
||||
@ -319,12 +322,6 @@ function onNodesUpdated(nodes) {
|
||||
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;
|
||||
|
||||
@ -342,116 +339,35 @@ function onNodesUpdated(nodes) {
|
||||
icon = icons.mqttConnected;
|
||||
}
|
||||
|
||||
if (!isValidCoordinates(node.latitude, node.longitude)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 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 marker to cache
|
||||
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;
|
||||
const marker = {
|
||||
type: 'Feature',
|
||||
properties: {
|
||||
id: node.node_id,
|
||||
role: node.role_name,
|
||||
layer: 'nodes',
|
||||
color: icon,
|
||||
},
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates: [node.longitude, node.latitude]
|
||||
}
|
||||
};
|
||||
|
||||
// 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
|
||||
});
|
||||
});
|
||||
// add maker to cache
|
||||
state.nodeMarkers[node.node_id] = marker;
|
||||
}
|
||||
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();
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
// set data
|
||||
const source = getMap().getSource('nodes');
|
||||
if (source) {
|
||||
source.setData({
|
||||
type: 'FeatureCollection',
|
||||
features: Object.values(state.nodeMarkers),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -487,42 +403,37 @@ function onWaypointsUpdated(waypoints) {
|
||||
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,
|
||||
});
|
||||
if (!isValidCoordinates(node.latitude, node.longitude)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// add marker to waypoints layer groups
|
||||
marker.addTo(layerGroups.waypoints)
|
||||
|
||||
// add to cache
|
||||
state.waypoints.push(waypoint);
|
||||
|
||||
// create waypoint marker
|
||||
const marker = {
|
||||
type: 'Feature',
|
||||
properties: {
|
||||
layer: 'waypoints',
|
||||
},
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates: [waypoint.longitude, waypoint.latitude]
|
||||
}
|
||||
};
|
||||
// add maker to cache
|
||||
state.waypointMarkers.push(marker);
|
||||
}
|
||||
// set data
|
||||
const source = getMap().getSource('waypoints');
|
||||
if (source) {
|
||||
source.setData({
|
||||
type: 'FeatureCollection',
|
||||
features: Object.values(state.waypointMarkers),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -661,19 +572,6 @@ function reload(goToNodeId, zoom) {
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -732,159 +630,363 @@ function onSearchResultNodeClick(node) {
|
||||
}
|
||||
|
||||
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;
|
||||
const map = new maplibregl.Map({
|
||||
container: mapEl.value,
|
||||
attributionControl: false,
|
||||
glyphs: 'https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf',
|
||||
style: {
|
||||
version: 8,
|
||||
sources: {},
|
||||
layers: [],
|
||||
glyphs: 'https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf',
|
||||
},
|
||||
//center: [-15, 150],
|
||||
center: [0, 0],
|
||||
zoom: 2,
|
||||
fadeDuration: 0,
|
||||
renderWorldCopies: false
|
||||
//maxBounds: [[-180, -85], [180, 85]]
|
||||
});
|
||||
setMap(map);
|
||||
map.addControl(new maplibregl.AttributionControl({ compact: true }), 'bottom-right');
|
||||
// Add zoom and rotation controls to the map.
|
||||
map.addControl(new maplibregl.NavigationControl({
|
||||
visualizePitch: false,
|
||||
visualizeRoll: false,
|
||||
showZoom: true,
|
||||
showCompass: false,
|
||||
}), 'top-left');
|
||||
const layerControl = new LayerControl({
|
||||
maps: tileLayers,
|
||||
initialMap: 'OpenStreetMap',
|
||||
controls: {
|
||||
Nodes: {
|
||||
type: 'radio',
|
||||
default: 'Clustered',
|
||||
layers: {
|
||||
'All': {
|
||||
type: 'layer_control',
|
||||
hideAllExcept: ['nodes'],
|
||||
disableCluster: 'nodes',
|
||||
},
|
||||
'Routers': {
|
||||
type: 'source_filter',
|
||||
source: 'nodes',
|
||||
getter: function() { return Object.values(state.nodeMarkers) },
|
||||
filter: function (node) { return ['ROUTER', 'ROUTER_CLIENT', 'ROUTER_LATE', 'REPEATER'].includes(node.properties.role); }
|
||||
},
|
||||
'Clustered': {
|
||||
type: 'layer_control',
|
||||
hideAllExcept: ['clusters', 'unclustered-points', 'cluster-count'],
|
||||
},
|
||||
'None': {
|
||||
type: 'layer_control',
|
||||
hideAllExcept: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
Overlays: {
|
||||
type: 'checkbox',
|
||||
default: ['Legend', 'Position History'],
|
||||
layers: {
|
||||
'Legend': {
|
||||
type: 'toggle_element',
|
||||
element: '.legend-control',
|
||||
},
|
||||
'Neighbors': {},
|
||||
'Waypoints': {
|
||||
type: 'layer_toggle',
|
||||
layer: 'waypoints',
|
||||
},
|
||||
'Position History': {},
|
||||
}
|
||||
},
|
||||
}
|
||||
});
|
||||
map.addControl(layerControl, 'top-right');
|
||||
map.addControl(new LegendControl(), 'bottom-left');
|
||||
|
||||
// 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);
|
||||
map.doubleClickZoom.disable(); // optional but recommended for clean UX
|
||||
|
||||
map.on('load', () => {
|
||||
map.addSource('nodes', {
|
||||
type: 'geojson',
|
||||
cluster: true,
|
||||
clusterMaxZoom: 14,
|
||||
clusterRadius: 50,
|
||||
data: {
|
||||
type: 'FeatureCollection',
|
||||
features: [],
|
||||
}
|
||||
}
|
||||
});
|
||||
// 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,
|
||||
map.addSource('waypoints', {
|
||||
type: 'geojson',
|
||||
data: {
|
||||
type: 'FeatureCollection',
|
||||
features: [],
|
||||
}
|
||||
});
|
||||
map.addLayer({
|
||||
id: 'clusters',
|
||||
type: 'circle',
|
||||
source: 'nodes',
|
||||
filter: ['has', 'point_count'],
|
||||
paint: {
|
||||
'circle-color': [
|
||||
'step',
|
||||
['get', 'point_count'],
|
||||
'#6ecc3999', // small clusters
|
||||
10, '#f0c20c99',
|
||||
30, '#f1801799' // larger clusters
|
||||
],
|
||||
'circle-radius': [
|
||||
'step',
|
||||
['get', 'point_count'],
|
||||
15, 10, 20, 30, 25
|
||||
]
|
||||
}
|
||||
});
|
||||
map.addLayer({
|
||||
id: 'cluster-count',
|
||||
type: 'symbol',
|
||||
source: 'nodes',
|
||||
filter: ['has', 'point_count'],
|
||||
layout: {
|
||||
'text-field': '{point_count_abbreviated}',
|
||||
'text-font': ['Open Sans Regular','Arial Unicode MS Regular'],
|
||||
'text-size': 12
|
||||
}
|
||||
});
|
||||
map.addLayer({
|
||||
id: 'unclustered-points',
|
||||
type: 'circle',
|
||||
source: 'nodes',
|
||||
filter: ['!', ['has', 'point_count']],
|
||||
paint: {
|
||||
'circle-radius': 6,
|
||||
'circle-color': ['coalesce', ['get', 'color'], '#cccccc'],
|
||||
'circle-stroke-width': 1,
|
||||
'circle-stroke-color': '#ffffff'
|
||||
}
|
||||
});
|
||||
map.addLayer({
|
||||
id: 'nodes',
|
||||
type: 'circle',
|
||||
source: 'nodes',
|
||||
layout: {
|
||||
visibility: 'none',
|
||||
},
|
||||
paint: {
|
||||
'circle-radius': 6,
|
||||
'circle-color': ['coalesce', ['get', 'color'], '#cccccc'],
|
||||
'circle-stroke-width': 1,
|
||||
'circle-stroke-color': '#ffffff'
|
||||
}
|
||||
});
|
||||
map.addLayer({
|
||||
id: 'waypoints',
|
||||
type: 'circle',
|
||||
source: 'waypoints',
|
||||
layout: {
|
||||
visibility: 'none',
|
||||
},
|
||||
paint: {
|
||||
'circle-radius': 6,
|
||||
'circle-color': ['coalesce', ['get', 'color'], '#cccccc'],
|
||||
'circle-stroke-width': 1,
|
||||
'circle-stroke-color': '#ffffff'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// reload and go to provided node id
|
||||
reload(queryNodeId, queryZoom);
|
||||
map.on('mouseenter', 'clusters', () => {
|
||||
map.getCanvas().style.cursor = 'pointer';
|
||||
});
|
||||
|
||||
map.on('mouseleave', 'clusters', () => {
|
||||
map.getCanvas().style.cursor = '';
|
||||
});
|
||||
|
||||
map.on('click', 'clusters', async (e) => {
|
||||
const features = map.queryRenderedFeatures(e.point, {
|
||||
layers: ['clusters'] // Ensure you are targeting the 'clusters' layer
|
||||
});
|
||||
if (!features.length) return;
|
||||
const cluster = features[0]; // Get the clicked cluster feature
|
||||
const clusterId = cluster.properties.cluster_id;
|
||||
const zoom = await map.getSource('nodes').getClusterExpansionZoom(clusterId);
|
||||
map.easeTo({
|
||||
center: features[0].geometry.coordinates,
|
||||
zoom: zoom,
|
||||
});
|
||||
});
|
||||
|
||||
// When a click event occurs on a feature in
|
||||
// the unclustered-point layer, open a popup at
|
||||
// the location of the feature, with
|
||||
// description HTML from its properties.
|
||||
map.on('click', 'unclustered-points', showPopupForEvent);
|
||||
map.on('mouseenter', 'unclustered-points', showPopupForEvent);
|
||||
map.on('click', 'nodes', showPopupForEvent);
|
||||
map.on('mouseenter', 'nodes', showPopupForEvent);
|
||||
|
||||
layerControl.applyDefaults();
|
||||
reload();
|
||||
})
|
||||
});
|
||||
|
||||
function measurePopupSize(htmlContent) {
|
||||
const popupContainer = document.createElement('div');
|
||||
popupContainer.className = 'maplibregl-popup maplibregl-popup-anchor-bottom';
|
||||
popupContainer.style.position = 'absolute';
|
||||
popupContainer.style.top = '-9999px';
|
||||
popupContainer.style.left = '-9999px';
|
||||
popupContainer.style.visibility = 'hidden';
|
||||
|
||||
const content = document.createElement('div');
|
||||
content.className = 'maplibregl-popup-content';
|
||||
content.innerHTML = htmlContent;
|
||||
|
||||
popupContainer.appendChild(content);
|
||||
document.body.appendChild(popupContainer);
|
||||
|
||||
const width = popupContainer.offsetWidth;
|
||||
const height = popupContainer.offsetHeight;
|
||||
|
||||
document.body.removeChild(popupContainer);
|
||||
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
function showPopupForEvent(e) {
|
||||
if (e.features[0].popup) {
|
||||
return; // already has a popup open
|
||||
}
|
||||
const map = getMap();
|
||||
const coordinates = e.features[0].geometry.coordinates.slice();
|
||||
const nodeId = e.features[0].properties.id;
|
||||
const html = getTooltipContentForNode(findNodeById(nodeId));
|
||||
|
||||
const mapContainer = map.getContainer();
|
||||
const mapWidth = mapContainer.offsetWidth;
|
||||
const mapHeight = mapContainer.offsetHeight;
|
||||
|
||||
// Get screen point of the marker
|
||||
const screenPoint = map.project(coordinates);
|
||||
|
||||
// Measure the popup size
|
||||
const { width: popupWidth, height: popupHeight } = measurePopupSize(html);
|
||||
|
||||
// Calculate available space around the marker
|
||||
const padding = 10;
|
||||
const headerHeight = document.querySelector('header')?.offsetHeight || 0;
|
||||
|
||||
const space = {
|
||||
left: screenPoint.x - padding,
|
||||
right: mapWidth - screenPoint.x - padding,
|
||||
top: screenPoint.y - headerHeight - padding,
|
||||
bottom: mapHeight - screenPoint.y - padding,
|
||||
};
|
||||
|
||||
const popupOptions = {
|
||||
'Top-Left': { // popup below, caret top left
|
||||
anchor: 'top-left',
|
||||
veriticalDeficit: Math.max(popupHeight - space.bottom, 0),
|
||||
horizontalDeficit: Math.max(popupWidth - space.right, 0),
|
||||
},
|
||||
'Top-Right': { // popup below, caret top right
|
||||
anchor: 'top-right',
|
||||
veriticalDeficit: Math.max(popupHeight - space.bottom, 0),
|
||||
horizontalDeficit: Math.max(popupWidth - space.left, 0),
|
||||
},
|
||||
'Bottom-Right': { // popup above, caret bottom right
|
||||
anchor: 'bottom-right',
|
||||
veriticalDeficit: Math.max(popupHeight - space.top, 0),
|
||||
horizontalDeficit: Math.max(popupWidth - space.left, 0),
|
||||
},
|
||||
'Bottom-Left': { // popup above, caret bottom left
|
||||
anchor: 'bottom-left',
|
||||
veriticalDeficit: Math.max(popupHeight - space.top, 0),
|
||||
horizontalDeficit: Math.max(popupWidth - space.right, 0),
|
||||
},
|
||||
'Left': {
|
||||
anchor: 'right',
|
||||
veriticalDeficit: Math.max(Math.max(((popupHeight / 2) - space.bottom), 0) + Math.max(((popupHeight/2) - space.top), 0), 0),
|
||||
horizontalDeficit: Math.max(popupWidth - space.left, 0),
|
||||
},
|
||||
'Right': {
|
||||
anchor: 'left',
|
||||
veriticalDeficit: Math.max(Math.max(((popupHeight / 2) - space.bottom), 0) + Math.max(((popupHeight/2) - space.top), 0), 0),
|
||||
horizontalDeficit: Math.max(popupWidth - space.right, 0),
|
||||
},
|
||||
'Above': {
|
||||
anchor: 'bottom',
|
||||
veriticalDeficit: Math.max(popupHeight - space.top, 0),
|
||||
horizontalDeficit: Math.max(Math.max(((popupWidth / 2) - space.left), 0) + Math.max(((popupWidth / 2) - space.right), 0), 0),
|
||||
},
|
||||
'Below': {
|
||||
anchor: 'top',
|
||||
veriticalDeficit: Math.max(popupHeight - space.bottom, 0),
|
||||
horizontalDeficit: Math.max(Math.max(((popupWidth / 2) - space.left), 0) + Math.max(((popupWidth / 2) - space.right), 0), 0),
|
||||
},
|
||||
};
|
||||
|
||||
let bestOption = null;
|
||||
let bestScore = -Infinity;
|
||||
const preferredAnchors = ['Left', 'Right', 'Above', 'Below'];
|
||||
|
||||
Object.entries(popupOptions).forEach(([key, option]) => {
|
||||
const verticalCutoff = option.veriticalDeficit / popupHeight;
|
||||
const horizontalCutoff = option.horizontalDeficit / popupWidth;
|
||||
const totalCutoff = verticalCutoff + horizontalCutoff;
|
||||
const visibilityScore = 1 - totalCutoff;
|
||||
|
||||
option.visibilityScore = visibilityScore; // for debugging/logging
|
||||
|
||||
if (
|
||||
visibilityScore > bestScore ||
|
||||
(
|
||||
Math.abs(visibilityScore - bestScore) < 0.01 && // within tolerance
|
||||
bestOption && preferredAnchors.includes(key) && !preferredAnchors.includes(bestOption)
|
||||
)
|
||||
) {
|
||||
bestScore = visibilityScore;
|
||||
bestOption = key;
|
||||
}
|
||||
});
|
||||
|
||||
const popup = new maplibregl.Popup({
|
||||
closeButton: true,
|
||||
closeOnClick: true,
|
||||
anchor: popupOptions[bestOption].anchor,
|
||||
});
|
||||
|
||||
popup
|
||||
.setLngLat(coordinates)
|
||||
.setHTML(html)
|
||||
.addTo(map);
|
||||
|
||||
if (e.type === 'mouseenter') {
|
||||
const removePopup = () => {
|
||||
popup.remove();
|
||||
getMap().off('mousemove', onMouseMove);
|
||||
getMap().getCanvas().style.cursor = '';
|
||||
};
|
||||
const onMouseMove = (e) => {
|
||||
const features = getMap().queryRenderedFeatures(e.point, { layers: ['unclustered-points'] });
|
||||
if (!features.length) {
|
||||
removePopup();
|
||||
}
|
||||
};
|
||||
getMap().on('mousemove', onMouseMove);
|
||||
}
|
||||
e.features[0].popup = popup;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (lastSeenAnnouncementId.value !== CURRENT_ANNOUNCEMENT_ID) {
|
||||
state.announcementVisible = true;
|
||||
@ -898,12 +1000,14 @@ onMounted(() => {
|
||||
<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"
|
||||
/>
|
||||
<header>
|
||||
<Announcement />
|
||||
<Header
|
||||
@reload="reload"
|
||||
@random-node="goToRandomNode"
|
||||
@search-click="onSearchResultNodeClick"
|
||||
/>
|
||||
</header>
|
||||
<div id="map" style="width:100%;height:100%;" ref="appMap"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
Reference in New Issue
Block a user