dawarich/app/javascript/controllers/maps_controller.js

692 lines
23 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 { createMarkersArray } from "../maps/markers";
import { createPolylinesLayer } from "../maps/polylines";
import { updatePolylinesOpacity } from "../maps/polylines";
import { fetchAndDrawAreas } from "../maps/areas";
import { handleAreaCreated } from "../maps/areas";
import { showFlashMessage } from "../maps/helpers";
import { osmMapLayer } from "../maps/layers";
import { osmHotMapLayer } from "../maps/layers";
2024-09-15 15:04:13 -04:00
import { OPNVMapLayer } from "../maps/layers";
import { openTopoMapLayer } from "../maps/layers";
import { cyclOsmMapLayer } from "../maps/layers";
import { esriWorldStreetMapLayer } from "../maps/layers";
import { esriWorldTopoMapLayer } from "../maps/layers";
import { esriWorldImageryMapLayer } from "../maps/layers";
import { esriWorldGrayCanvasMapLayer } from "../maps/layers";
import { countryCodesMap } from "../maps/country_codes";
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"];
2024-08-28 15:34:26 -04:00
settingsButtonAdded = false;
layerControl = null;
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;
2024-08-28 14:24:35 -04:00
this.userSettings = JSON.parse(this.element.dataset.user_settings);
this.clearFogRadius = parseInt(this.userSettings.fog_of_war_meters) || 50;
this.routeOpacity = parseFloat(this.userSettings.route_opacity) || 0.6;
this.distanceUnit = this.element.dataset.distance_unit || "km";
this.pointsRenderingMode = this.userSettings.points_rendering_mode || "raw";
this.countryCodesMap = countryCodesMap();
2024-11-01 08:29:24 -04:00
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);
// Set the maximum bounds to prevent infinite scroll
var southWest = L.latLng(-90, -180);
var northEast = L.latLng(90, 180);
var bounds = L.latLngBounds(southWest, northEast);
this.map.setMaxBounds(bounds);
this.markersArray = createMarkersArray(this.markers, this.userSettings);
this.markersLayer = L.layerGroup(this.markersArray);
this.heatmapMarkers = this.markersArray.map((element) => [element._latlng.lat, element._latlng.lng, 0.2]);
this.polylinesLayer = createPolylinesLayer(this.markers, this.map, this.timezone, this.routeOpacity, this.userSettings);
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
this.setupScratchLayer(this.countryCodesMap);
2024-08-28 15:34:26 -04:00
if (!this.settingsButtonAdded) {
this.addSettingsButton();
}
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-11-01 08:29:24 -04:00
"Scratch map": this.scratchLayer,
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);
2024-08-28 15:34:26 -04:00
this.layerControl = 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
fetchAndDrawAreas(this.areasLayer, 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
async setupScratchLayer(countryCodesMap) {
2024-11-01 08:29:24 -04:00
this.scratchLayer = L.geoJSON(null, {
style: {
fillColor: '#FFD700',
fillOpacity: 0.3,
color: '#FFA500',
weight: 1
}
2024-11-01 08:43:21 -04:00
})
2024-11-01 08:29:24 -04:00
2024-11-01 09:05:16 -04:00
try {
// Up-to-date version can be found on Github:
// https://raw.githubusercontent.com/datasets/geo-countries/master/data/countries.geojson
const response = await fetch('/api/v1/countries/borders.json', {
2024-11-01 09:05:16 -04:00
headers: {
'Accept': 'application/geo+json,application/json'
}
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const worldData = await response.json();
const visitedCountries = this.getVisitedCountries(countryCodesMap)
const filteredFeatures = worldData.features.filter(feature =>
visitedCountries.includes(feature.properties.ISO_A2)
)
this.scratchLayer.addData({
type: 'FeatureCollection',
features: filteredFeatures
})
} catch (error) {
console.error('Error loading GeoJSON:', error);
}
2024-11-01 08:29:24 -04:00
}
2024-11-01 09:05:16 -04:00
getVisitedCountries(countryCodesMap) {
2024-11-01 08:29:24 -04:00
if (!this.markers) return [];
return [...new Set(
this.markers
.filter(marker => marker[7]) // Ensure country exists
.map(marker => {
// Convert country name to ISO code, or return the original if not found
return countryCodesMap[marker[7]] || marker[7];
2024-11-01 08:29:24 -04:00
})
)];
}
// Optional: Add methods to handle user interactions
toggleScratchLayer() {
if (this.map.hasLayer(this.scratchLayer)) {
this.map.removeLayer(this.scratchLayer)
} else {
this.scratchLayer.addTo(this.map)
}
}
baseMaps() {
2024-09-15 15:04:13 -04:00
let selectedLayerName = this.userSettings.preferred_map_layer || "OpenStreetMap";
return {
2024-09-15 15:04:13 -04:00
OpenStreetMap: osmMapLayer(this.map, selectedLayerName),
"OpenStreetMap.HOT": osmHotMapLayer(this.map, selectedLayerName),
OPNV: OPNVMapLayer(this.map, selectedLayerName),
openTopo: openTopoMapLayer(this.map, selectedLayerName),
cyclOsm: cyclOsmMapLayer(this.map, selectedLayerName),
esriWorldStreet: esriWorldStreetMapLayer(this.map, selectedLayerName),
esriWorldTopo: esriWorldTopoMapLayer(this.map, selectedLayerName),
esriWorldImagery: esriWorldImageryMapLayer(this.map, selectedLayerName),
esriWorldGrayCanvas: esriWorldGrayCanvasMapLayer(this.map, selectedLayerName)
2024-06-19 15:16:06 -04:00
};
}
removeEventListeners() {
document.removeEventListener('click', this.handleDeleteClick);
}
addEventListeners() {
this.handleDeleteClick = (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);
}
}
};
// Ensure only one listener is attached by removing any existing ones first
this.removeEventListeners();
document.addEventListener('click', this.handleDeleteClick);
2024-09-15 15:04:13 -04:00
// Add an event listener for base layer change in Leaflet
this.map.on('baselayerchange', (event) => {
const selectedLayerName = event.name;
this.updatePreferredBaseLayer(selectedLayerName);
});
}
updatePreferredBaseLayer(selectedLayerName) {
fetch(`/api/v1/settings?api_key=${this.apiKey}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
settings: {
preferred_map_layer: selectedLayerName
},
}),
})
.then((response) => response.json())
.then((data) => {
if (data.status === 'success') {
showFlashMessage('notice', `Preferred map layer updated to: ${selectedLayerName}`);
2024-09-15 15:04:13 -04:00
} else {
showFlashMessage('error', data.message);
2024-09-15 15:04:13 -04:00
}
});
}
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 => {
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);
}
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') {
handleAreaCreated(this.areasLayer, layer, this.apiKey);
2024-07-21 14:09:42 -04:00
}
this.drawnItems.addLayer(layer);
});
}
2024-08-28 14:24:35 -04:00
addSettingsButton() {
2024-08-28 15:34:26 -04:00
if (this.settingsButtonAdded) return;
2024-08-28 14:24:35 -04:00
// Define the custom control
const SettingsControl = L.Control.extend({
onAdd: (map) => {
const button = L.DomUtil.create('button', 'map-settings-button');
button.innerHTML = '⚙️'; // Gear icon
// Style the button
button.style.backgroundColor = 'white';
2024-08-28 15:34:26 -04:00
button.style.width = '32px';
button.style.height = '32px';
2024-08-28 14:24:35 -04:00
button.style.border = 'none';
button.style.cursor = 'pointer';
button.style.boxShadow = '0 1px 4px rgba(0,0,0,0.3)';
// Disable map interactions when clicking the button
L.DomEvent.disableClickPropagation(button);
// Toggle settings menu on button click
L.DomEvent.on(button, 'click', () => {
this.toggleSettingsMenu();
});
return button;
}
});
// Add the control to the map
2024-08-28 15:34:26 -04:00
this.map.addControl(new SettingsControl({ position: 'topleft' }));
this.settingsButtonAdded = true;
2024-08-28 14:24:35 -04:00
}
toggleSettingsMenu() {
// If the settings panel already exists, just show/hide it
if (this.settingsPanel) {
if (this.settingsPanel._map) {
this.map.removeControl(this.settingsPanel);
} else {
this.map.addControl(this.settingsPanel);
}
return;
}
// Create the settings panel for the first time
2024-08-28 15:34:26 -04:00
this.settingsPanel = L.control({ position: 'topleft' });
2024-08-28 14:24:35 -04:00
this.settingsPanel.onAdd = () => {
const div = L.DomUtil.create('div', 'leaflet-settings-panel');
// Form HTML
div.innerHTML = `
<form id="settings-form" class="w-48">
<label for="route-opacity">Route Opacity</label>
<div class="join">
<input type="number" class="input input-ghost join-item focus:input-ghost input-xs input-bordered w-full max-w-xs" id="route-opacity" name="route_opacity" min="0" max="1" step="0.1" value="${this.routeOpacity}">
<label for="route_opacity_info" class="btn-xs join-item ">?</label>
</div>
<label for="fog_of_war_meters">Fog of War radius</label>
<div class="join">
<input type="number" class="join-item input input-ghost focus:input-ghost input-xs input-bordered w-full max-w-xs" id="fog_of_war_meters" name="fog_of_war_meters" min="5" max="100" step="1" value="${this.clearFogRadius}">
<label for="fog_of_war_meters_info" class="btn-xs join-item">?</label>
</div>
<label for="meters_between_routes">Meters between routes</label>
<div class="join">
<input type="number" class="join-item input input-ghost focus:input-ghost input-xs input-bordered w-full max-w-xs" id="meters_between_routes" name="meters_between_routes" step="1" value="${this.userSettings.meters_between_routes}">
<label for="meters_between_routes_info" class="btn-xs join-item">?</label>
</div>
<label for="minutes_between_routes">Minutes between routes</label>
<div class="join">
<input type="number" class="join-item input input-ghost focus:input-ghost input-xs input-bordered w-full max-w-xs" id="minutes_between_routes" name="minutes_between_routes" step="1" value="${this.userSettings.minutes_between_routes}">
<label for="minutes_between_routes_info" class="btn-xs join-item">?</label>
</div>
<label for="time_threshold_minutes">Time threshold minutes</label>
<div class="join">
<input type="number" class="join-item input input-ghost focus:input-ghost input-xs input-bordered w-full max-w-xs" id="time_threshold_minutes" name="time_threshold_minutes" step="1" value="${this.userSettings.time_threshold_minutes}">
<label for="time_threshold_minutes_info" class="btn-xs join-item">?</label>
</div>
<label for="merge_threshold_minutes">Merge threshold minutes</label>
<div class="join">
<input type="number" class="join-item input input-ghost focus:input-ghost input-xs input-bordered w-full max-w-xs" id="merge_threshold_minutes" name="merge_threshold_minutes" step="1" value="${this.userSettings.merge_threshold_minutes}">
<label for="merge_threshold_minutes_info" class="btn-xs join-item">?</label>
</div>
2024-10-20 14:55:43 -04:00
2024-10-22 06:02:12 -04:00
<label for="points_rendering_mode">
Points rendering mode
<label for="points_rendering_mode_info" class="btn-xs join-item inline">?</label>
</label>
<label for="raw">
<input type="radio" id="raw" name="points_rendering_mode" class='w-4' style="width: 20px;" value="raw" ${this.pointsRenderingModeChecked('raw')} />
Raw
</label>
<label for="simplified">
<input type="radio" id="simplified" name="points_rendering_mode" class='w-4' style="width: 20px;" value="simplified" ${this.pointsRenderingModeChecked('simplified')}/>
Simplified
</label>
2024-08-28 14:24:35 -04:00
<button type="submit">Update</button>
</form>
`;
// Style the panel
div.style.backgroundColor = 'white';
div.style.padding = '10px';
div.style.border = '1px solid #ccc';
div.style.boxShadow = '0 1px 4px rgba(0,0,0,0.3)';
// Prevent map interactions when interacting with the form
L.DomEvent.disableClickPropagation(div);
// Add event listener to the form submission
div.querySelector('#settings-form').addEventListener(
'submit', this.updateSettings.bind(this)
);
return div;
};
this.map.addControl(this.settingsPanel);
}
2024-10-20 14:55:43 -04:00
pointsRenderingModeChecked(value) {
if (value === this.pointsRenderingMode) {
return 'checked';
} else {
return '';
}
}
2024-08-28 14:24:35 -04:00
updateSettings(event) {
event.preventDefault();
fetch(`/api/v1/settings?api_key=${this.apiKey}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
settings: {
route_opacity: event.target.route_opacity.value,
fog_of_war_meters: event.target.fog_of_war_meters.value,
meters_between_routes: event.target.meters_between_routes.value,
minutes_between_routes: event.target.minutes_between_routes.value,
time_threshold_minutes: event.target.time_threshold_minutes.value,
merge_threshold_minutes: event.target.merge_threshold_minutes.value,
points_rendering_mode: event.target.points_rendering_mode.value
2024-08-28 14:24:35 -04:00
},
}),
})
.then((response) => response.json())
.then((data) => {
if (data.status === 'success') {
showFlashMessage('notice', data.message);
2024-08-28 14:24:35 -04:00
this.updateMapWithNewSettings(data.settings);
} else {
showFlashMessage('error', data.message);
2024-08-28 14:24:35 -04:00
}
});
}
updateMapWithNewSettings(newSettings) {
const currentLayerStates = this.getLayerControlStates();
// Update local state with new settings
this.clearFogRadius = parseInt(newSettings.fog_of_war_meters) || 50;
this.routeOpacity = parseFloat(newSettings.route_opacity) || 0.6;
// Preserve existing layer instances if they exist
const preserveLayers = {
2024-08-28 15:34:26 -04:00
Points: this.markersLayer,
Polylines: this.polylinesLayer,
Heatmap: this.heatmapLayer,
2024-08-28 14:24:35 -04:00
"Fog of War": this.fogOverlay,
2024-08-28 15:34:26 -04:00
Areas: this.areasLayer,
2024-08-28 14:24:35 -04:00
};
// Clear all layers except base layers
this.map.eachLayer((layer) => {
if (!(layer instanceof L.TileLayer)) {
this.map.removeLayer(layer);
}
});
// Recreate layers only if they don't exist
this.markersLayer = preserveLayers.Points || L.layerGroup(createMarkersArray(this.markers, newSettings));
this.polylinesLayer = preserveLayers.Polylines || createPolylinesLayer(this.markers, this.map, this.timezone, this.routeOpacity, this.userSettings);
2024-08-28 15:34:26 -04:00
this.heatmapLayer = preserveLayers.Heatmap || L.heatLayer(this.markers.map((element) => [element[0], element[1], 0.2]), { radius: 20 });
this.fogOverlay = preserveLayers["Fog of War"] || L.layerGroup();
this.areasLayer = preserveLayers.Areas || L.layerGroup();
2024-08-28 14:24:35 -04:00
// Redraw areas
fetchAndDrawAreas(this.areasLayer, this.apiKey);
2024-08-28 14:24:35 -04:00
let fogEnabled = false;
document.getElementById('fog').style.display = 'none';
this.map.on('overlayadd', (e) => {
if (e.name === 'Fog of War') {
fogEnabled = true;
document.getElementById('fog').style.display = 'block';
this.updateFog(this.markers, this.clearFogRadius);
}
});
this.map.on('overlayremove', (e) => {
if (e.name === 'Fog of War') {
fogEnabled = false;
document.getElementById('fog').style.display = 'none';
}
});
this.map.on('zoomend moveend', () => {
if (fogEnabled) {
this.updateFog(this.markers, this.clearFogRadius);
}
});
this.addLastMarker(this.map, this.markers);
this.addEventListeners();
this.initializeDrawControl();
updatePolylinesOpacity(this.polylinesLayer, this.routeOpacity);
2024-08-28 14:24:35 -04:00
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);
}
});
this.applyLayerControlStates(currentLayerStates);
}
getLayerControlStates() {
const controls = {};
this.map.eachLayer((layer) => {
const layerName = this.getLayerName(layer);
2024-08-28 14:24:35 -04:00
if (layerName) {
controls[layerName] = this.map.hasLayer(layer);
}
});
return controls;
}
getLayerName(layer) {
const controlLayers = {
Points: this.markersLayer,
Polylines: this.polylinesLayer,
Heatmap: this.heatmapLayer,
"Fog of War": this.fogOverlay,
Areas: this.areasLayer,
};
for (const [name, val] of Object.entries(controlLayers)) {
if (val && val.hasLayer && layer && val.hasLayer(layer)) // Check if the group layer contains the current layer
return name;
}
// Direct instance matching
for (const [name, val] of Object.entries(controlLayers)) {
if (val === layer) return name;
}
return undefined; // Indicate no matching layer name found
}
applyLayerControlStates(states) {
const layerControl = {
Points: this.markersLayer,
Polylines: this.polylinesLayer,
Heatmap: this.heatmapLayer,
"Fog of War": this.fogOverlay,
Areas: this.areasLayer,
};
for (const [name, isVisible] of Object.entries(states)) {
const layer = layerControl[name];
if (isVisible && !this.map.hasLayer(layer)) {
this.map.addLayer(layer);
} else if (this.map.hasLayer(layer)) {
this.map.removeLayer(layer);
2024-08-28 14:24:35 -04:00
}
}
// Ensure the layer control reflects the current state
2024-08-28 15:34:26 -04:00
this.map.removeControl(this.layerControl);
2024-08-28 14:24:35 -04:00
this.layerControl = L.control.layers(this.baseMaps(), layerControl).addTo(this.map);
}
}