355 lines
11 KiB
TypeScript

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: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> 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;