355 lines
11 KiB
TypeScript
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: '© <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; |