dawarich/app/javascript/controllers/maps_controller.js

559 lines
18 KiB
JavaScript
Raw Normal View History

2024-06-19 15:16:06 -04:00
import { Controller } from "@hotwired/stimulus";
import L from "leaflet";
import "leaflet.heat";
import { formatDistance } from "../maps/helpers";
import { getUrlParameter } from "../maps/helpers";
import { minutesToDaysHoursMinutes } from "../maps/helpers";
import { formatDate } from "../maps/helpers";
import { haversineDistance } from "../maps/helpers";
import { osmMapLayer } from "../maps/layers";
import { osmHotMapLayer } from "../maps/layers";
2024-07-21 14:09:42 -04:00
import "leaflet-draw";
export default class extends Controller {
2024-06-19 15:16:06 -04:00
static targets = ["container"];
connect() {
2024-06-19 15:16:06 -04:00
console.log("Map controller connected");
this.apiKey = this.element.dataset.api_key;
this.markers = JSON.parse(this.element.dataset.coordinates);
this.timezone = this.element.dataset.timezone;
this.clearFogRadius = this.element.dataset.fog_of_war_meters;
this.routeOpacity = parseInt(this.element.dataset.route_opacity) / 100 || 0.6;
this.center = this.markers[this.markers.length - 1] || [52.514568, 13.350111];
this.map = L.map(this.containerTarget).setView([this.center[0], this.center[1]], 14);
this.markersArray = this.createMarkersArray(this.markers);
this.markersLayer = L.layerGroup(this.markersArray);
this.heatmapMarkers = this.markers.map((element) => [element[0], element[1], 0.2]);
this.polylinesLayer = this.createPolylinesLayer(this.markers, this.map, this.timezone, this.routeOpacity);
this.heatmapLayer = L.heatLayer(this.heatmapMarkers, { radius: 20 }).addTo(this.map);
this.fogOverlay = L.layerGroup(); // Initialize fog layer
2024-07-21 14:09:42 -04:00
this.areasLayer = L.layerGroup(); // Initialize areas layer
2024-06-19 15:16:06 -04:00
const controlsLayer = {
Points: this.markersLayer,
Polylines: this.polylinesLayer,
Heatmap: this.heatmapLayer,
"Fog of War": this.fogOverlay,
2024-07-21 14:09:42 -04:00
Areas: this.areasLayer // Add the areas layer to the controls
};
2024-07-21 14:26:45 -04:00
L.control
.scale({
position: "bottomright",
metric: true,
imperial: false,
maxWidth: 120,
})
.addTo(this.map);
L.control.layers(this.baseMaps(), controlsLayer).addTo(this.map);
2024-07-21 14:09:42 -04:00
// Fetch and draw areas when the map is loaded
2024-07-27 08:30:46 -04:00
this.fetchAndDrawAreas(this.apiKey);
2024-07-21 14:09:42 -04:00
2024-06-25 15:57:22 -04:00
let fogEnabled = false;
// Hide fog by default
document.getElementById('fog').style.display = 'none';
2024-06-25 15:57:22 -04:00
// Toggle fog layer visibility
this.map.on('overlayadd', (e) => {
2024-06-25 15:57:22 -04:00
if (e.name === 'Fog of War') {
fogEnabled = true;
document.getElementById('fog').style.display = 'block';
this.updateFog(this.markers, this.clearFogRadius);
2024-06-25 15:57:22 -04:00
}
});
this.map.on('overlayremove', (e) => {
2024-06-25 15:57:22 -04:00
if (e.name === 'Fog of War') {
fogEnabled = false;
document.getElementById('fog').style.display = 'none';
}
});
// Update fog circles on zoom and move
this.map.on('zoomend moveend', () => {
2024-06-25 15:57:22 -04:00
if (fogEnabled) {
this.updateFog(this.markers, this.clearFogRadius);
2024-06-25 15:57:22 -04:00
}
});
this.addLastMarker(this.map, this.markers);
this.addEventListeners();
2024-07-21 14:09:42 -04:00
// Initialize Leaflet.draw
this.initializeDrawControl();
// Add event listeners to toggle draw controls
this.map.on('overlayadd', (e) => {
if (e.name === 'Areas') {
this.map.addControl(this.drawControl);
}
});
this.map.on('overlayremove', (e) => {
if (e.name === 'Areas') {
this.map.removeControl(this.drawControl);
}
});
}
disconnect() {
this.map.remove();
}
2024-03-21 18:24:47 -04:00
baseMaps() {
return {
OpenStreetMap: osmMapLayer(this.map),
"OpenStreetMap.HOT": osmHotMapLayer(),
2024-06-19 15:16:06 -04:00
};
}
2024-06-19 15:16:06 -04:00
createMarkersArray(markersData) {
return markersData.map((marker) => {
const [lat, lon] = marker;
const popupContent = this.createPopupContent(marker);
return L.circleMarker([lat, lon], { radius: 4 }).bindPopup(popupContent);
});
}
2024-06-19 15:16:06 -04:00
createPopupContent(marker) {
const timezone = this.element.dataset.timezone;
2024-03-21 18:24:47 -04:00
return `
<b>Timestamp:</b> ${formatDate(marker[4], timezone)}<br>
2024-03-21 18:24:47 -04:00
<b>Latitude:</b> ${marker[0]}<br>
<b>Longitude:</b> ${marker[1]}<br>
<b>Altitude:</b> ${marker[3]}m<br>
<b>Velocity:</b> ${marker[5]}km/h<br>
<b>Battery:</b> ${marker[2]}%<br>
<a href="#" data-id="${marker[6]}" class="delete-point">[Delete]</a>
2024-03-21 18:24:47 -04:00
`;
}
addEventListeners() {
document.addEventListener('click', (event) => {
if (event.target && event.target.classList.contains('delete-point')) {
event.preventDefault();
const pointId = event.target.getAttribute('data-id');
if (confirm('Are you sure you want to delete this point?')) {
this.deletePoint(pointId, this.apiKey);
}
}
});
}
deletePoint(id, apiKey) {
fetch(`/api/v1/points/${id}?api_key=${apiKey}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
}
})
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
})
.then(data => {
console.log('Point deleted:', data);
// Remove the marker from the map
this.removeMarker(id);
})
.catch(error => {
console.error('There was a problem with the delete request:', error);
});
}
removeMarker(id) {
const markerIndex = this.markersArray.findIndex(marker => marker.getPopup().getContent().includes(`data-id="${id}"`));
if (markerIndex !== -1) {
this.markersArray[markerIndex].remove(); // Assuming your marker object has a remove method
this.markersArray.splice(markerIndex, 1);
this.markersLayer.clearLayers();
this.markersLayer.addLayer(L.layerGroup(this.markersArray));
// Remove from the markers data array
this.markers = this.markers.filter(marker => marker[6] !== parseInt(id));
}
}
addLastMarker(map, markers) {
if (markers.length > 0) {
2024-06-19 15:16:06 -04:00
const lastMarker = markers[markers.length - 1].slice(0, 2);
L.marker(lastMarker).addTo(map);
}
}
2024-06-19 15:16:06 -04:00
updateFog(markers, clearFogRadius) {
var fog = document.getElementById('fog');
fog.innerHTML = ''; // Clear previous circles
markers.forEach((point) => {
const radiusInPixels = this.metersToPixels(this.map, clearFogRadius);
this.clearFog(point[0], point[1], radiusInPixels);
});
}
metersToPixels(map, meters) {
const zoom = map.getZoom();
const latLng = map.getCenter(); // Get map center for correct projection
const metersPerPixel = this.getMetersPerPixel(latLng.lat, zoom);
return meters / metersPerPixel;
}
getMetersPerPixel(latitude, zoom) {
const earthCircumference = 40075016.686; // Earth's circumference in meters
const metersPerPixel = earthCircumference * Math.cos(latitude * Math.PI / 180) / Math.pow(2, zoom + 8);
return metersPerPixel;
}
clearFog(lat, lng, radius) {
var fog = document.getElementById('fog');
var point = this.map.latLngToContainerPoint([lat, lng]);
var size = radius * 2;
var circle = document.createElement('div');
circle.className = 'unfogged-circle';
circle.style.width = size + 'px';
circle.style.height = size + 'px';
circle.style.left = (point.x - radius) + 'px';
circle.style.top = (point.y - radius) + 'px';
circle.style.backdropFilter = 'blur(0px)'; // Remove blur for the circles
fog.appendChild(circle);
}
addHighlightOnHover(polyline, map, polylineCoordinates, timezone, routeOpacity) {
const originalStyle = { color: "blue", opacity: routeOpacity, weight: 3 };
2024-06-19 15:16:06 -04:00
const highlightStyle = { color: "yellow", opacity: 1, weight: 5 };
polyline.setStyle(originalStyle);
const startPoint = polylineCoordinates[0];
const endPoint = polylineCoordinates[polylineCoordinates.length - 1];
2024-06-19 15:16:06 -04:00
const firstTimestamp = new Date(startPoint[4] * 1000).toLocaleString("en-GB", { timeZone: timezone });
const lastTimestamp = new Date(endPoint[4] * 1000).toLocaleString("en-GB", { timeZone: timezone });
const minutes = Math.round((endPoint[4] - startPoint[4]) / 60);
const timeOnRoute = minutesToDaysHoursMinutes(minutes);
2024-06-19 15:16:06 -04:00
const totalDistance = polylineCoordinates.reduce((acc, curr, index, arr) => {
if (index === 0) return acc;
const dist = haversineDistance(arr[index - 1][0], arr[index - 1][1], curr[0], curr[1]);
return acc + dist;
}, 0);
2024-06-19 15:16:06 -04:00
const startIcon = L.divIcon({ html: "🚥", className: "emoji-icon" });
const finishIcon = L.divIcon({ html: "🏁", className: "emoji-icon" });
const isDebugMode = getUrlParameter("debug") === "true";
2024-06-19 15:16:06 -04:00
let popupContent = `
<b>Start:</b> ${firstTimestamp}<br>
<b>End:</b> ${lastTimestamp}<br>
<b>Duration:</b> ${timeOnRoute}<br>
<b>Total Distance:</b> ${formatDistance(totalDistance)}<br>
2024-06-19 15:16:06 -04:00
`;
if (isDebugMode) {
const prevPoint = polylineCoordinates[0];
const nextPoint = polylineCoordinates[polylineCoordinates.length - 1];
const distanceToPrev = haversineDistance(prevPoint[0], prevPoint[1], startPoint[0], startPoint[1]);
const distanceToNext = haversineDistance(endPoint[0], endPoint[1], nextPoint[0], nextPoint[1]);
const timeBetweenPrev = Math.round((startPoint[4] - prevPoint[4]) / 60);
const timeBetweenNext = Math.round((endPoint[4] - nextPoint[4]) / 60);
2024-06-19 15:16:06 -04:00
popupContent += `
<b>Prev Route:</b> ${Math.round(distanceToPrev)}m and ${minutesToDaysHoursMinutes(timeBetweenPrev)} away<br>
<b>Next Route:</b> ${Math.round(distanceToNext)}m and ${minutesToDaysHoursMinutes(timeBetweenNext)} away<br>
2024-06-19 15:16:06 -04:00
`;
}
const startMarker = L.marker([startPoint[0], startPoint[1]], { icon: startIcon }).bindPopup(`Start: ${firstTimestamp}`);
const endMarker = L.marker([endPoint[0], endPoint[1]], { icon: finishIcon }).bindPopup(popupContent);
let hoverPopup = null;
polyline.on("mouseover", function (e) {
2024-06-19 15:16:06 -04:00
polyline.setStyle(highlightStyle);
startMarker.addTo(map);
endMarker.addTo(map);
const latLng = e.latlng;
if (hoverPopup) {
map.closePopup(hoverPopup);
}
hoverPopup = L.popup()
.setLatLng(latLng)
.setContent(popupContent)
.openOn(map);
2024-06-19 15:16:06 -04:00
});
polyline.on("mouseout", function () {
polyline.setStyle(originalStyle);
map.closePopup(hoverPopup);
2024-06-19 15:16:06 -04:00
map.removeLayer(startMarker);
map.removeLayer(endMarker);
});
polyline.on("click", function () {
map.fitBounds(polyline.getBounds());
});
// Close the popup when clicking elsewhere on the map
map.on("click", function () {
map.closePopup(hoverPopup);
});
2024-06-19 15:16:06 -04:00
}
createPolylinesLayer(markers, map, timezone, routeOpacity) {
2024-06-19 15:16:06 -04:00
const splitPolylines = [];
let currentPolyline = [];
const distanceThresholdMeters = parseInt(this.element.dataset.meters_between_routes) || 500;
const timeThresholdMinutes = parseInt(this.element.dataset.minutes_between_routes) || 60;
2024-06-19 15:16:06 -04:00
for (let i = 0, len = markers.length; i < len; i++) {
if (currentPolyline.length === 0) {
currentPolyline.push(markers[i]);
} else {
const lastPoint = currentPolyline[currentPolyline.length - 1];
const currentPoint = markers[i];
const distance = haversineDistance(lastPoint[0], lastPoint[1], currentPoint[0], currentPoint[1]);
2024-06-19 15:16:06 -04:00
const timeDifference = (currentPoint[4] - lastPoint[4]) / 60;
if (distance > distanceThresholdMeters || timeDifference > timeThresholdMinutes) {
splitPolylines.push([...currentPolyline]);
currentPolyline = [currentPoint];
} else {
currentPolyline.push(currentPoint);
}
}
}
if (currentPolyline.length > 0) {
splitPolylines.push(currentPolyline);
}
return L.layerGroup(
splitPolylines.map((polylineCoordinates) => {
2024-06-19 15:16:06 -04:00
const latLngs = polylineCoordinates.map((point) => [point[0], point[1]]);
const polyline = L.polyline(latLngs, { color: "blue", opacity: 0.6, weight: 3 });
this.addHighlightOnHover(polyline, map, polylineCoordinates, timezone, routeOpacity);
2024-06-19 15:16:06 -04:00
return polyline;
})
).addTo(map);
}
2024-07-21 14:09:42 -04:00
initializeDrawControl() {
// Initialize the FeatureGroup to store editable layers
this.drawnItems = new L.FeatureGroup();
this.map.addLayer(this.drawnItems);
// Initialize the draw control and pass it the FeatureGroup of editable layers
this.drawControl = new L.Control.Draw({
draw: {
polyline: false,
polygon: false,
rectangle: false,
marker: false,
circlemarker: false,
circle: {
shapeOptions: {
color: 'red',
fillColor: '#f03',
fillOpacity: 0.5,
},
},
},
});
// Handle circle creation
this.map.on(L.Draw.Event.CREATED, (event) => {
const layer = event.layer;
if (event.layerType === 'circle') {
this.handleCircleCreated(layer);
}
this.drawnItems.addLayer(layer);
});
}
handleCircleCreated(layer) {
const radius = layer.getRadius();
const center = layer.getLatLng();
const formHtml = `
2024-07-21 14:26:45 -04:00
<div class="card w-96 max-w-sm bg-content-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">New Area</h2>
<form id="circle-form">
<div class="form-control">
<label for="circle-name" class="label">
<span class="label-text">Name</span>
</label>
<input type="text" id="circle-name" name="area[name]" class="input input-bordered input-ghost focus:input-ghost w-full max-w-xs" required>
</div>
<input type="hidden" name="area[latitude]" value="${center.lat}">
<input type="hidden" name="area[longitude]" value="${center.lng}">
<input type="hidden" name="area[radius]" value="${radius}">
<div class="card-actions justify-end mt-4">
<button type="submit" class="btn btn-primary">Save</button>
</div>
</form>
</div>
</div>
2024-07-21 14:09:42 -04:00
`;
2024-07-21 14:26:45 -04:00
layer.bindPopup(
formHtml, {
maxWidth: "auto",
minWidth: 300
}
).openPopup();
2024-07-21 14:09:42 -04:00
layer.on('popupopen', () => {
const form = document.getElementById('circle-form');
form.addEventListener('submit', (e) => {
e.preventDefault();
2024-07-27 08:30:46 -04:00
this.saveCircle(new FormData(form), layer, this.apiKey);
2024-07-21 14:09:42 -04:00
});
});
// Add the layer to the areas layer group
this.areasLayer.addLayer(layer);
}
saveCircle(formData, layer, apiKey) {
2024-07-21 14:09:42 -04:00
const data = {};
formData.forEach((value, key) => {
const keys = key.split('[').map(k => k.replace(']', ''));
if (keys.length > 1) {
if (!data[keys[0]]) data[keys[0]] = {};
data[keys[0]][keys[1]] = value;
} else {
data[keys[0]] = value;
}
});
2024-07-27 08:30:46 -04:00
fetch(`/api/v1/areas?api_key=${apiKey}`, {
2024-07-21 14:09:42 -04:00
method: 'POST',
headers: { 'Content-Type': 'application/json'},
2024-07-21 14:09:42 -04:00
body: JSON.stringify(data)
})
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
})
.then(data => {
console.log('Circle saved:', data);
layer.closePopup();
layer.bindPopup(`
Name: ${data.name}<br>
Radius: ${Math.round(data.radius)} meters<br>
<a href="#" data-id="${marker[6]}" class="delete-area">[Delete]</a>
`).openPopup();
// Add event listener for the delete button
layer.on('popupopen', () => {
document.querySelector('.delete-area').addEventListener('click', () => {
this.deleteArea(data.id, layer);
});
});
})
.catch(error => {
console.error('There was a problem with the save request:', error);
});
}
2024-07-27 08:30:46 -04:00
deleteArea(id, layer, apiKey) {
fetch(`/api/v1/areas/${id}?api_key=${apiKey}`, {
2024-07-21 14:09:42 -04:00
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
2024-07-21 14:09:42 -04:00
}
})
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
})
.then(data => {
console.log('Area deleted:', data);
this.areasLayer.removeLayer(layer); // Remove the layer from the areas layer group
})
.catch(error => {
console.error('There was a problem with the delete request:', error);
});
}
2024-07-27 08:30:46 -04:00
fetchAndDrawAreas(apiKey) {
fetch(`/api/v1/areas?api_key=${apiKey}`, {
2024-07-21 14:09:42 -04:00
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
})
.then(data => {
console.log('Fetched areas:', data); // Debugging line to check response
data.forEach(area => {
// Log each area to verify the structure
console.log('Area:', area);
// Check if necessary fields are present
if (area.latitude && area.longitude && area.radius && area.name && area.id) {
const layer = L.circle([area.latitude, area.longitude], {
radius: area.radius,
color: 'red',
fillColor: '#f03',
fillOpacity: 0.5
}).bindPopup(`
Name: ${area.name}<br>
Radius: ${Math.round(area.radius)} meters<br>
<a href="#" data-id="${area.id}" class="delete-area">[Delete]</a>
`);
this.areasLayer.addLayer(layer); // Add to areas layer group
console.log('Added layer to areasLayer:', layer); // Debugging line to confirm addition
// Add event listener for the delete button
layer.on('popupopen', () => {
document.querySelector('.delete-area').addEventListener('click', (e) => {
e.preventDefault();
if (confirm('Are you sure you want to delete this area?')) {
2024-07-27 08:30:46 -04:00
this.deleteArea(area.id, layer, this.apiKey);
2024-07-21 14:09:42 -04:00
}
});
});
} else {
console.error('Area missing required fields:', area);
}
});
})
.catch(error => {
console.error('There was a problem with the fetch request:', error);
});
}
}