import 'leaflet/dist/leaflet.css'; import L, { FeatureGroup, Marker, Polygon, type LatLngTuple } from 'leaflet'; import '@geoman-io/leaflet-geoman-free' import '@geoman-io/leaflet-geoman-free/dist/leaflet-geoman.css' import './maphandler.css' import { greenIcon, redIcon, violetIcon } from './icons.ts' import type { Endpoint, Polygon as InterfacePolygon, PathSegment } from '../../interfaces.ts' import type { Position } from 'geojson'; import { createPalette } from 'hue-map'; const viridisPalette = createPalette({ map: 'viridis', steps: 100 }); interface ClickedMapListener { (latitude: number, longitude: number): void; } interface ClickedEndpointListener { (startpointId: number, endpointId: number): void; } interface SearchAreaPolygonListener { (searchAreaPolygons: InterfacePolygon[]): void; } interface ExclusionPolygonListener { (exclusionPolygons: InterfacePolygon[]): void; } interface OnChangeFunction { (): void; } class MapHandler { map; currentStartPoint?: number; // Whenever the polygons of either the search area or the exclusion area are changed, these listeners are called clickedMapListeners: ClickedMapListener[] = []; clickedEndpointListeners: ClickedEndpointListener[] = []; searchAreaPolygonListeners: SearchAreaPolygonListener[] = []; exclusionPolygonListeners: ExclusionPolygonListener[] = []; // We need feature groups to store the polygons that define these areas searchAreaFeatureGroup: FeatureGroup; exclusionAreaFeatureGroup: FeatureGroup; startMarker?: Marker; endMarkers: FeatureGroup; path: FeatureGroup; editingPolygons: boolean = false; onChangeFunction?: OnChangeFunction constructor() { // Use OpenStreetMaps and center on Oslo this.map = L.map('map', { center: L.latLng(59.92, 10.74), zoom: 13, }); L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap contributors' }).addTo(this.map); // Make sure that geoman can only control the polygons it itself has created L.PM.setOptIn(true); this.map.on('pm:create', (e) => { e.layer.options.pmIgnore = false; L.PM.reInitLayer(e.layer); if (this.onChangeFunction != null) { e.layer.on('pm:edit', this.onChangeFunction); e.layer.on('pm:drag', this.onChangeFunction); e.layer.on('pm:cut', this.onChangeFunction); e.layer.on('pm:remove', this.onChangeFunction); e.layer.on('pm:rotate', this.onChangeFunction); this.onChangeFunction(); }; }) // Make sure we don't start route searches points when editing polygons this.map.on('pm:globaldrawmodetoggled', e => { this.editingPolygons = e.enabled; }); this.map.on('pm:globalcutmodetoggled', e => { this.editingPolygons = e.enabled; }); this.map.on('pm:globaldragmodetoggled', e => { this.editingPolygons = e.enabled; }); this.map.on('pm:globaleditmodetoggled', e => { this.editingPolygons = e.enabled; }); this.map.on('pm:globalrotatemodetoggled', e => { this.editingPolygons = e.enabled; }); this.map.on('pm:globalremovalmodetoggled', e => { this.editingPolygons = e.enabled; }) // Add geoman controls for drawing these polygons this.map.pm.addControls({ position: 'bottomleft', drawMarker: false, drawCircle: false, drawCircleMarker: false, drawPolyline: false, drawText: false, drawRectangle: false, drawPolygon: false }); this.map.pm.Toolbar.copyDrawControl('Polygon', { name: 'searcharea', block: 'draw', title: 'Define the search area for longest route', className: 'search-icon', onClick: () => {this.setFeatureGroup(this.searchAreaFeatureGroup, 'green')} }) this.map.pm.Toolbar.copyDrawControl('Polygon', { name: 'exclusionarea', block: 'draw', title: 'Draw areas that should not be entered', className: 'exclude-icon', onClick: () => {this.setFeatureGroup(this.exclusionAreaFeatureGroup, 'red')} }); this.map.pm.Toolbar.createCustomControl({ name: 'removePolygons', block: 'draw', title: 'Remove all search and exclusion areas', className: 'cancel-icon', onClick: () => { this.searchAreaFeatureGroup.clearLayers(); this.exclusionAreaFeatureGroup.clearLayers(); this.onExclusionAreaChange(); this.onSearchAreaChange(); } }); this.map.pm.Toolbar.setBlockPosition('draw', 'topleft'); // In order for the polygons in the feature groups to be displayed, they must be added to the map this.searchAreaFeatureGroup = new L.FeatureGroup().addTo(this.map); this.exclusionAreaFeatureGroup = new L.FeatureGroup().addTo(this.map); this.endMarkers = new L.FeatureGroup().addTo(this.map); this.path = new L.FeatureGroup().addTo(this.map); this.map.addEventListener('click', e => { if (!this.editingPolygons) { this.clickedMapListeners.forEach(routeSearchListener => { routeSearchListener(e.latlng.lat, e.latlng.lng); }); } }); this.exclusionAreaFeatureGroup.addEventListener('pm:change', _ => { let polygons: Polygon[] = []; this.exclusionAreaFeatureGroup.eachLayer(layer => { if (layer instanceof Polygon) { polygons.push(layer); } }); }); } public getCurrentStartPointId(): number { if (this.currentStartPoint) { return this.currentStartPoint; } else { return -1; } } private getPolygonsOfFeatureGroup(featureGroup: FeatureGroup): InterfacePolygon[] { let multiPolygonCoordinates: Position[][][] = []; featureGroup.eachLayer(layer => { if (layer instanceof Polygon) { let geojson = layer.toGeoJSON(); if (geojson.geometry.type == 'Polygon') { multiPolygonCoordinates.push(geojson.geometry.coordinates); } else if (geojson.geometry.type == 'MultiPolygon') { geojson.geometry.coordinates.forEach(polygon => { multiPolygonCoordinates.push(polygon); }) } } }); return multiPolygonCoordinates.map(polygonCoordinates => { return {rings: polygonCoordinates.map(ringCoordinates => { return {coordinates: ringCoordinates.map(singleCoordinate => { return { latitude: singleCoordinate[1], longitude: singleCoordinate[0] } })} })}; }); } private onExclusionAreaChange(): void { let multiPolygon: InterfacePolygon[] = this.getPolygonsOfFeatureGroup(this.exclusionAreaFeatureGroup); this.exclusionPolygonListeners.forEach(listener => { listener(multiPolygon); }) } private onSearchAreaChange(): void { let multiPolygon: InterfacePolygon[] = this.getPolygonsOfFeatureGroup(this.searchAreaFeatureGroup); this.searchAreaPolygonListeners.forEach(listener => { listener(multiPolygon); }) } // This function is called when clicking one of the draw buttons, to make sure the polygons drawn have the right colour and // are added to the right feature group private setFeatureGroup(featureGroup: FeatureGroup, color: string): void { this.map.pm.setGlobalOptions({ layerGroup: featureGroup, pathOptions: {color: color, fillOpacity: 0.05}, templineStyle: {color: color, radius: 10}, hintlineStyle: {color: color, dashArray: [5, 5]} }); if (featureGroup === this.exclusionAreaFeatureGroup) { this.onChangeFunction = this.onExclusionAreaChange; } else if (featureGroup === this.searchAreaFeatureGroup) { this.onChangeFunction = this.onSearchAreaChange; } } public addClickedMapListener(routeSearchListener: ClickedMapListener): void { this.clickedMapListeners.push(routeSearchListener); } public addClickedEndpointListener(clickedEndpointListener: ClickedEndpointListener): void { this.clickedEndpointListeners.push(clickedEndpointListener); } public addExclusionAreaListener(exclusionAreaListener: ExclusionPolygonListener): void { this.exclusionPolygonListeners.push(exclusionAreaListener); } public addSearchAreaListener(searchAreaListener: SearchAreaPolygonListener): void { this.searchAreaPolygonListeners.push(searchAreaListener); } public drawStartNode(nodeId: number, latitude: number, longitude: number): void { this.path.clearLayers(); if (this.startMarker != null) { this.map.removeLayer(this.startMarker); } this.currentStartPoint = nodeId; this.endMarkers.clearLayers(); this.startMarker = L.marker([latitude, longitude], {icon: greenIcon}).addTo(this.map); } public drawEndPoints(endpoints: Endpoint[]): void { this.endMarkers.clearLayers(); this.path.clearLayers(); var firstMarker = true; endpoints.forEach(endpoint => { var settings; if (firstMarker) { settings = { icon: redIcon }; } else { settings = { icon: violetIcon, opacity: 0.7 }; } var marker = L.marker([endpoint.latitude, endpoint.longitude], settings).addTo(this.endMarkers); marker.bindTooltip(Math.round(endpoint.distanceFromStart) + 'm'); if (firstMarker) { marker.openTooltip(); } firstMarker = false; marker.addEventListener('click', _ => { this.clickedEndpointListeners.forEach(endpointListener => { if (this.currentStartPoint != null) { endpointListener(this.currentStartPoint, endpoint.nodeId); } }); }); }); } public drawPath(pathSegments: PathSegment[], minimumSpeed: number, maximumSpeed: number): void { this.path.clearLayers(); pathSegments.forEach(pathSegment => { let startLeafletCoordinate: LatLngTuple = [pathSegment.start.latitude, pathSegment.start.longitude]; let endLeafletCoordinate: LatLngTuple = [pathSegment.end.latitude, pathSegment.end.longitude]; let leafletCoordinates = [startLeafletCoordinate, endLeafletCoordinate]; let intensity = Math.round((pathSegment.requiredSpeed - minimumSpeed) * 99.0 / (maximumSpeed - minimumSpeed)); intensity = Math.min(Math.max(intensity, 0), 99); let color = viridisPalette.format('cssHex')[99-intensity]; let polyLine = L.polyline(leafletCoordinates, {color: color}).addTo(this.path); let requiredSpeed = Math.max(minimumSpeed, pathSegment.requiredSpeed); let requiredSpeedKmh = requiredSpeed * 3.6; let requiredSpeedKmhRounded = Math.round(requiredSpeedKmh * 10.0); polyLine.bindTooltip('Required speed: ' + Math.floor(requiredSpeedKmhRounded / 10.0) + '.' + (requiredSpeedKmhRounded % 10) + ' km/h'); }); } public enableEditing() { this.map.pm.Toolbar.setButtonDisabled('searcharea', false); this.map.pm.Toolbar.setButtonDisabled('exclusionarea', false); this.map.pm.Toolbar.setButtonDisabled('editMode', false); this.map.pm.Toolbar.setButtonDisabled('dragMode', false); this.map.pm.Toolbar.setButtonDisabled('cutPolygon', false); this.map.pm.Toolbar.setButtonDisabled('removalMode', false); this.map.pm.Toolbar.setButtonDisabled('rotateMode', false); } public disableEditing() { this.map.pm.Toolbar.setButtonDisabled('searcharea', true); this.map.pm.Toolbar.setButtonDisabled('exclusionarea', true); this.map.pm.Toolbar.setButtonDisabled('editMode', true); this.map.pm.Toolbar.setButtonDisabled('dragMode', true); this.map.pm.Toolbar.setButtonDisabled('cutPolygon', true); this.map.pm.Toolbar.setButtonDisabled('removalMode', true); this.map.pm.Toolbar.setButtonDisabled('rotateMode', true); this.map.pm.disableDraw(); this.map.pm.disableGlobalEditMode(); this.map.pm.disableGlobalDragMode(); this.map.pm.disableGlobalRemovalMode(); this.map.pm.disableGlobalCutMode(); this.map.pm.disableGlobalRotateMode(); } } export default MapHandler;