Initial commit of a working website

This commit is contained in:
Martin Asprusten
2025-06-30 01:15:53 +02:00
parent cf57ea7002
commit e413687435
26 changed files with 3565 additions and 1 deletions
+117
View File
@@ -0,0 +1,117 @@
// Messages passed to and from the Web Worker
export interface Message {
loadData?: LoadData,
dataLoaded?: DataLoaded
findClosestNode?: FindClosestNode,
foundClosestNode?: FoundClosestNode,
findPathsFromNode?: FindPathsFromNode,
foundPathsFromNode?: FoundPathsFromNode,
getFullPath?: GetFullPath,
returnFullPath?: ReturnFullPath,
excludeAreas?: ExcludeAreas,
searchArea?: SearchArea,
continueSearch?: ContinueSearch,
searchAreaResult?: SearchAreaResult,
errorMessage?: ErrorMessage
}
interface LoadData {
data: Uint8Array
}
interface DataLoaded {
}
interface FindClosestNode {
latitude: number,
longitude: number
}
interface FoundClosestNode {
originalLatitude: number,
originalLongitude: number,
foundLatitude: number,
foundLongitude: number,
foundNodeId: number
}
interface FindPathsFromNode {
nodeId: number,
minimumSpeed: number,
maximumSpeed: number,
maximumSpeedLimit: number,
dragCoefficient: number,
allowMotorways: boolean,
allowTunnels: boolean,
allowAgainstOneway: boolean
}
export interface Endpoint {
nodeId: number,
latitude: number,
longitude: number,
distanceFromStart: number
}
interface FoundPathsFromNode {
nodeId: number,
endpoints: Endpoint[]
}
interface GetFullPath {
startNodeId: number,
endNodeId: number
}
export interface Coordinate {
latitude: number,
longitude: number
}
interface ReturnFullPath {
coordinates: Coordinate[]
}
export interface Ring {
coordinates: Coordinate[]
}
export interface Polygon {
rings: Ring[]
}
interface ExcludeAreas {
polygons: Polygon[]
}
interface SearchArea {
polygons: Polygon[],
minimumSpeed: number,
maximumSpeed: number,
maximumSpeedLimit: number,
dragCoefficient: number,
allowMotorways: boolean,
allowTunnels: boolean,
allowAgainstOneway: boolean
}
interface ContinueSearch {
}
export interface SearchAreaResultEntry {
nodeId: number,
latitude: number,
longitude: number,
longestRoute: number
}
interface SearchAreaResult {
remainingNodes: number,
searchResults: SearchAreaResultEntry[]
}
interface ErrorMessage {
error: string
}
+374
View File
@@ -0,0 +1,374 @@
import './style.css';
import './modules/maphandler/maphandler'
import MapHandler from './modules/maphandler/maphandler';
import type { Message, Polygon } from './interfaces';
interface WindowState {
state: 'DataNotLoaded' | 'DataLoading' | 'Ready' | 'Searching'
}
interface Settings {
minimumSpeed: number,
maximumSpeed: number,
maximumSpeedLimit: number,
dragCoefficient: number,
allowMotorways: boolean,
allowTunnels: boolean,
allowAgainstOneway: boolean,
cutoffDistance: number
}
// Default settings values
const DEFAULT_MINIMUM_SPEED = 1;
const DEFAULT_MAXIMUM_SPEED = 40;
const DEFAULT_MAXIMUM_SPEED_LIMIT = 80;
const DEFAULT_DRAG_COEFFICIENT = 0.005;
const DEFAULT_ALLOW_MOTORWAYS = false;
const DEFAULT_ALLOW_TUNNELS = false;
const DEFAULT_ALLOW_AGAINST_ONE_WAY = false;
const DEFAULT_CUTOFF_DISTANCE = 1000;
// Set up variables
let routeWorker = new Worker(new URL('./modules/worker/worker.ts', import.meta.url), {type: 'module'});
var mapHandler: MapHandler | null = null;
var currentSearchArea: Polygon[] = [];
var currentState: WindowState = {state: 'DataNotLoaded'};
var lastSearchUpdate = Date.now();
let freewheelingHeader = document.getElementById('freewheeling-header');
let notLoadedContainer = document.getElementById('notloadedcontainer');
let loadingContainer = document.getElementById('loadingcontainer');
let mapContainer = document.getElementById('mapcontainer');
let searchButton = document.getElementById('search-button');
let searchStatusParagraph = document.getElementById('search-status-paragraph');
let searchResultsTable = document.getElementById('search-result-table');
let searchResultTableBody = document.getElementById('search-result-table-body');
let settingsButton = document.getElementById('settings-button');
let settingsDiv = document.getElementById('settings-div');
let minimumSpeedInput = document.getElementById('minimum-speed-input');
let maximumSpeedInput = document.getElementById('maximum-speed-input');
let maximumSpeedLimitInput = document.getElementById('maximum-speed-limit-input');
let dragCoefficientInput = document.getElementById('drag-coefficient-input');
let allowMotorwaysInput = document.getElementById('allow-motorways-input');
let allowTunnelsInput = document.getElementById('allow-tunnels-input');
let allowAgainstOnewayInput = document.getElementById('allow-wrong-way-input');
let cutoffDistanceInput = document.getElementById('cutoff-distance-input');
// Set up the web worker and what to do when we get messages from it
routeWorker.onmessage = e => {
let message = e.data as Message;
if (message.dataLoaded != null) {
setState({state: 'Ready'})
}
else if (message.foundClosestNode != null) {
mapHandler?.drawStartNode(message.foundClosestNode.foundNodeId, message.foundClosestNode.foundLatitude, message.foundClosestNode.foundLongitude);
let settings = getSettings();
let findPathsMessage: Message = {findPathsFromNode: {
nodeId: message.foundClosestNode.foundNodeId,
minimumSpeed: settings.minimumSpeed,
maximumSpeed: settings.maximumSpeed,
maximumSpeedLimit: settings.maximumSpeedLimit,
dragCoefficient: settings.dragCoefficient,
allowMotorways: settings.allowMotorways,
allowTunnels: settings.allowTunnels,
allowAgainstOneway: settings.allowAgainstOneway
}};
routeWorker.postMessage(findPathsMessage);
} else if (message.foundPathsFromNode != null) {
mapHandler?.drawEndPoints(message.foundPathsFromNode.endpoints);
} else if (message.returnFullPath != null) {
mapHandler?.drawPath(message.returnFullPath.coordinates);
} else if (message.searchAreaResult != null) {
searchStatusParagraph?.setHTMLUnsafe('Searching. ' + message.searchAreaResult.remainingNodes + ' possible starting points remain.');
let currentTime = Date.now();
let settings = getSettings();
if (message.searchAreaResult.searchResults.length > 0 && (currentTime - lastSearchUpdate > 500 || message.searchAreaResult.remainingNodes == 0)) {
lastSearchUpdate = currentTime;
searchResultsTable?.style.setProperty('display', 'block');
searchResultTableBody?.setHTMLUnsafe('');
message.searchAreaResult.searchResults.forEach(result => {
if (result.longestRoute < settings.cutoffDistance) {
return;
}
let tableRow = document.createElement('tr');
let distanceCell = document.createElement('td');
let latitudeCell = document.createElement('td');
let longitudeCell = document.createElement('td')
let buttonCell = document.createElement('td');
searchResultTableBody?.appendChild(tableRow);
tableRow.appendChild(distanceCell);
tableRow.appendChild(latitudeCell);
tableRow.appendChild(longitudeCell);
tableRow.appendChild(buttonCell);
distanceCell.setHTMLUnsafe(result.longestRoute.toFixed(0) + ' m');
latitudeCell.setHTMLUnsafe(result.latitude.toFixed(6));
longitudeCell.setHTMLUnsafe(result.longitude.toFixed(6));
let button = document.createElement('button');
buttonCell.appendChild(button);
button.setHTMLUnsafe('Show in map');
button.addEventListener('click', _ => {
mapHandler?.drawStartNode(result.nodeId, result.latitude, result.longitude);
let settings = getSettings();
let requestMessage: Message = {
findPathsFromNode: {
nodeId: result.nodeId,
minimumSpeed: settings.minimumSpeed,
maximumSpeed: settings.maximumSpeed,
maximumSpeedLimit: settings.maximumSpeedLimit,
dragCoefficient: settings.dragCoefficient,
allowMotorways: settings.allowMotorways,
allowTunnels: settings.allowTunnels,
allowAgainstOneway: settings.allowAgainstOneway
}
};
routeWorker.postMessage(requestMessage);
});
});
}
if (currentState.state == 'Searching' && message.searchAreaResult.remainingNodes > 0) {
let continueMessage: Message = {continueSearch: {}};
routeWorker.postMessage(continueMessage);
} else {
searchStatusParagraph?.setHTMLUnsafe('Finished searching.');
setState({state: 'Ready'});
}
}
}
routeWorker.onerror = e => {
console.log(e);
}
function setUpMapHandler() {
mapHandler = new MapHandler();
// Next, set up what to do when the user clicks different stuff in the map
mapHandler.addClickedMapListener((latitude, longitude) => {
let message: Message = {findClosestNode: {latitude: latitude, longitude: longitude}};
routeWorker.postMessage(message);
});
mapHandler.addClickedEndpointListener((startNodeId, endNodeId) => {
let message: Message = {getFullPath: {startNodeId: startNodeId, endNodeId: endNodeId}};
routeWorker.postMessage(message);
});
mapHandler.addExclusionAreaListener(polygons => {
let message: Message = {excludeAreas: { polygons: polygons}};
routeWorker.postMessage(message);
let currentStartPoint = mapHandler?.getCurrentStartPointId();
if (currentStartPoint == null) {
return;
}
if (currentStartPoint >= 0) {
let settings = getSettings();
let newRoutesMessage: Message = {findPathsFromNode: {
nodeId: currentStartPoint,
minimumSpeed: settings.minimumSpeed,
maximumSpeed: settings.maximumSpeed,
maximumSpeedLimit: settings.maximumSpeedLimit,
dragCoefficient: settings.dragCoefficient,
allowMotorways: settings.allowMotorways,
allowTunnels: settings.allowTunnels,
allowAgainstOneway: settings.allowAgainstOneway
}};
routeWorker.postMessage(newRoutesMessage);
}
});
mapHandler.addSearchAreaListener(polygons => {
currentSearchArea = polygons;
});
}
// What to do when the window state changes
function setState(state: WindowState) {
currentState = state;
if (state.state == 'DataNotLoaded') {
freewheelingHeader?.style.setProperty('display', 'block');
notLoadedContainer?.style.setProperty('display', 'block');
loadingContainer?.style.setProperty('display', 'none');
mapContainer?.style.setProperty('display', 'none');
} else if (state.state == 'DataLoading') {
freewheelingHeader?.style.setProperty('display', 'block');
notLoadedContainer?.style.setProperty('display', 'none');
loadingContainer?.style.setProperty('display', 'block');
mapContainer?.style.setProperty('display', 'none');
} else if (state.state == 'Ready') {
freewheelingHeader?.style.setProperty('display', 'none');
notLoadedContainer?.style.setProperty('display', 'none');
loadingContainer?.style.setProperty('display', 'none');
mapContainer?.style.setProperty('display', 'block');
if (mapHandler == null) {
setUpMapHandler();
}
mapHandler?.enableEditing();
enableSettings();
searchButton?.setHTMLUnsafe('Start search');
} else if (state.state == 'Searching') {
freewheelingHeader?.style.setProperty('display', 'none');
notLoadedContainer?.style.setProperty('display', 'none');
loadingContainer?.style.setProperty('display', 'none');
mapContainer?.style.setProperty('display', 'block');
if (mapHandler == null) {
setUpMapHandler();
}
mapHandler?.disableEditing();
disableSettings();
searchButton?.setHTMLUnsafe('Cancel search');
}
};
setState({state: 'DataNotLoaded'});
// Set up the settings values
function setUpNumberInput(element: HTMLElement | null, localStorageKey: string, defaultValue: number): void {
if (element != null && element instanceof HTMLInputElement) {
let numberValue: number = Number(localStorage.getItem(localStorageKey)) || defaultValue;
element.value = numberValue.toString();
element.addEventListener('change', _ => localStorage.setItem(localStorageKey, element.value));
}
}
function setUpBooleanInput(element: HTMLElement | null, localStorageKey: string, defaultValue: boolean): void {
if (element != null && element instanceof HTMLInputElement) {
if (localStorage.getItem(localStorageKey) != null) {
element.checked = localStorage.getItem(localStorageKey) === 'true';
} else {
element.checked = defaultValue;
}
element.addEventListener('change', _ => {localStorage.setItem(localStorageKey, element.checked.toString())});
}
}
function getNumberValue(element: HTMLElement | null, defaultValue: number): number {
if (element != null && element instanceof HTMLInputElement) {
return Number(element.value) || defaultValue;
}
return defaultValue;
}
function getBooleanValue(element: HTMLElement | null, defaultValue: boolean): boolean {
if (element != null && element instanceof HTMLInputElement) {
return element.checked;
}
return defaultValue;
}
function enableInput(element: HTMLElement | null) {
if (element != null && element instanceof HTMLInputElement) {
element.disabled = false;
}
}
function disableInput(element: HTMLElement | null) {
if (element != null && element instanceof HTMLInputElement) {
element.disabled = true;
}
}
setUpNumberInput(minimumSpeedInput, 'minimum-speed', DEFAULT_MINIMUM_SPEED);
setUpNumberInput(maximumSpeedInput, 'maximum-speed', DEFAULT_MAXIMUM_SPEED);
setUpNumberInput(maximumSpeedLimitInput, 'maximum-speed-limit', DEFAULT_MAXIMUM_SPEED_LIMIT);
setUpNumberInput(dragCoefficientInput, 'drag-coefficient', DEFAULT_DRAG_COEFFICIENT);
setUpBooleanInput(allowMotorwaysInput, 'allow-motorways', DEFAULT_ALLOW_MOTORWAYS);
setUpBooleanInput(allowTunnelsInput, 'allow-tunnels', DEFAULT_ALLOW_TUNNELS);
setUpBooleanInput(allowAgainstOnewayInput, 'allow-against-one-way', DEFAULT_ALLOW_AGAINST_ONE_WAY);
setUpNumberInput(cutoffDistanceInput, 'cutoff-distance', DEFAULT_CUTOFF_DISTANCE);
function getSettings(): Settings {
return {
minimumSpeed: getNumberValue(minimumSpeedInput, DEFAULT_MINIMUM_SPEED) / 3.6,
maximumSpeed: getNumberValue(maximumSpeedInput, DEFAULT_MAXIMUM_SPEED) / 3.6,
maximumSpeedLimit: getNumberValue(maximumSpeedLimitInput, DEFAULT_MAXIMUM_SPEED_LIMIT),
dragCoefficient: getNumberValue(dragCoefficientInput, DEFAULT_DRAG_COEFFICIENT),
allowMotorways: getBooleanValue(allowMotorwaysInput, DEFAULT_ALLOW_MOTORWAYS),
allowTunnels: getBooleanValue(allowTunnelsInput, DEFAULT_ALLOW_TUNNELS),
allowAgainstOneway: getBooleanValue(allowAgainstOnewayInput, DEFAULT_ALLOW_AGAINST_ONE_WAY),
cutoffDistance: getNumberValue(cutoffDistanceInput, DEFAULT_CUTOFF_DISTANCE)
};
}
function enableSettings(): void {
enableInput(minimumSpeedInput);
enableInput(maximumSpeedInput);
enableInput(maximumSpeedLimitInput);
enableInput(dragCoefficientInput);
enableInput(allowMotorwaysInput);
enableInput(allowTunnelsInput);
enableInput(allowAgainstOnewayInput);
}
function disableSettings(): void {
disableInput(minimumSpeedInput);
disableInput(maximumSpeedInput);
disableInput(maximumSpeedLimitInput);
disableInput(dragCoefficientInput);
disableInput(allowMotorwaysInput);
disableInput(allowTunnelsInput);
disableInput(allowAgainstOnewayInput);
}
// Finally, set up various events when clicking things
searchButton?.addEventListener('click', _ => {
if (currentState.state == 'Ready') {
if (currentSearchArea.length > 0) {
setState({state: 'Searching'})
let settings = getSettings();
let message: Message = {searchArea: {
polygons: currentSearchArea,
minimumSpeed: settings.minimumSpeed,
maximumSpeed: settings.maximumSpeed,
maximumSpeedLimit: settings.maximumSpeedLimit,
dragCoefficient: settings.dragCoefficient,
allowMotorways: settings.allowMotorways,
allowTunnels: settings.allowTunnels,
allowAgainstOneway: settings.allowAgainstOneway
}};
routeWorker.postMessage(message);
}
} else if (currentState.state == 'Searching') {
setState({state: 'Ready'});
}
});
settingsButton?.addEventListener('click', _ => {
let settingsDisplay = settingsDiv?.style.getPropertyValue('display');
if (settingsDisplay === 'none') {
settingsDiv?.style.setProperty('display', 'block');
} else {
settingsDiv?.style.setProperty('display', 'none');
}
});
document.getElementById('data-file-chooser')?.addEventListener('change', chooseEvent => {
let eventTarget = chooseEvent.target;
if (!(eventTarget instanceof HTMLInputElement) || eventTarget.files == null || eventTarget.files.length == 0) {
return;
}
setState({state: 'DataLoading'});
let reader = new FileReader();
reader.onload = loadEvent => {
let data = loadEvent.target?.result;
if (data != null && data instanceof ArrayBuffer) {
let message: Message = {loadData: {data: new Uint8Array(data)}};
routeWorker.postMessage(message);
} else {
setState({state: 'DataNotLoaded'});
}
};
reader.readAsArrayBuffer(eventTarget.files[0]);
});
+93
View File
@@ -0,0 +1,93 @@
import L from 'leaflet'
import blueUrl from '../../../static/marker-icon-2x-blue.png'
import goldUrl from '../../../static/marker-icon-2x-gold.png'
import redUrl from '../../../static/marker-icon-2x-red.png'
import greenUrl from '../../../static/marker-icon-2x-green.png'
import orangeUrl from '../../../static/marker-icon-2x-orange.png'
import yellowUrl from '../../../static/marker-icon-2x-yellow.png'
import violetUrl from '../../../static/marker-icon-2x-violet.png'
import greyUrl from '../../../static/marker-icon-2x-grey.png'
import blackUrl from '../../../static/marker-icon-2x-black.png'
import shadowUrl from '../../../static/marker-shadow.png'
export const blueIcon = new L.Icon({
iconUrl: blueUrl,
shadowUrl: shadowUrl,
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowSize: [41, 41]
});
export const goldIcon = new L.Icon({
iconUrl: goldUrl,
shadowUrl: shadowUrl,
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowSize: [41, 41]
});
export const redIcon = new L.Icon({
iconUrl: redUrl,
shadowUrl: shadowUrl,
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowSize: [41, 41]
});
export const greenIcon = new L.Icon({
iconUrl: greenUrl,
shadowUrl: shadowUrl,
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowSize: [41, 41]
});
export const orangeIcon = new L.Icon({
iconUrl: orangeUrl,
shadowUrl: shadowUrl,
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowSize: [41, 41]
});
export const yellowIcon = new L.Icon({
iconUrl: yellowUrl,
shadowUrl: shadowUrl,
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowSize: [41, 41]
});
export const violetIcon = new L.Icon({
iconUrl: violetUrl,
shadowUrl: shadowUrl,
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowSize: [41, 41]
});
export const greyIcon = new L.Icon({
iconUrl: greyUrl,
shadowUrl: shadowUrl,
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowSize: [41, 41]
});
export const blackIcon = new L.Icon({
iconUrl: blackUrl,
shadowUrl: shadowUrl,
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowSize: [41, 41]
});
+11
View File
@@ -0,0 +1,11 @@
.search-icon {
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.--><path d="M128 32l32 0c17.7 0 32 14.3 32 32l0 32L96 96l0-32c0-17.7 14.3-32 32-32zm64 96l0 320c0 17.7-14.3 32-32 32L32 480c-17.7 0-32-14.3-32-32l0-59.1c0-34.6 9.4-68.6 27.2-98.3C40.9 267.8 49.7 242.4 53 216L60.5 156c2-16 15.6-28 31.8-28l99.8 0zm227.8 0c16.1 0 29.8 12 31.8 28L459 216c3.3 26.4 12.1 51.8 25.8 74.6c17.8 29.7 27.2 63.7 27.2 98.3l0 59.1c0 17.7-14.3 32-32 32l-128 0c-17.7 0-32-14.3-32-32l0-320 99.8 0zM320 64c0-17.7 14.3-32 32-32l32 0c17.7 0 32 14.3 32 32l0 32-96 0 0-32zm-32 64l0 160-64 0 0-160 64 0z"/></svg>');
}
.exclude-icon {
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.--><path d="M367.2 412.5L99.5 144.8C77.1 176.1 64 214.5 64 256c0 106 86 192 192 192c41.5 0 79.9-13.1 111.2-35.5zm45.3-45.3C434.9 335.9 448 297.5 448 256c0-106-86-192-192-192c-41.5 0-79.9 13.1-111.2 35.5L412.5 367.2zM0 256a256 256 0 1 1 512 0A256 256 0 1 1 0 256z"/></svg>');
}
.cancel-icon {
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.--><path d="M342.6 150.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L192 210.7 86.6 105.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L146.7 256 41.4 361.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192 301.3 297.4 406.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L237.3 256 342.6 150.6z"/></svg>')
}
+347
View File
@@ -0,0 +1,347 @@
import 'leaflet/dist/leaflet.css';
import L, { FeatureGroup, Marker, Polygon, Polyline, 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, Coordinate, Polygon as InterfacePolygon } from '../../interfaces.ts'
import type { Position } from 'geojson';
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?: Polyline;
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.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 {
if (this.path != null) {
this.map.removeLayer(this.path);
this.path = undefined;
}
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();
if (this.path != null) {
this.map.removeLayer(this.path);
this.path = undefined;
}
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(coordinates: Coordinate[]): void {
if (this.path != null) {
this.map.removeLayer(this.path);
}
var leafletCoordinates = coordinates.map(coordinate => {
let latLngTuple: LatLngTuple = [coordinate.latitude, coordinate.longitude];
return latLngTuple;
});
this.path = L.polyline(leafletCoordinates).addTo(this.map);
}
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;
+191
View File
@@ -0,0 +1,191 @@
import Module, { type AreaSearchEntries, type MainModule, type MultiPolygon } from '../../../native/route_search.js';
import proj4 from 'proj4';
import type { Endpoint, Message, Polygon, SearchAreaResultEntry } from '../../interfaces';
var dataLoaded = false;
var module: MainModule | undefined = undefined;
function sendErrorMessage(error: string): void {
let message: Message = {errorMessage: {error: error}};
postMessage(message);
}
function createCMultiPolygon(module: MainModule, polygons: Polygon[]): MultiPolygon {
let multiPolygon = new module.MultiPolygon();
polygons.forEach(polygon => {
let cPolygon = new module.Polygon();
polygon.rings.forEach(ring => {
let cRing = new module.Ring();
ring.coordinates.forEach(coordinate => {
let polygonCoordinate = new module.PolygonCoordinate();
let utmCoordinates = proj4('EPSG:4326', 'EPSG:32633', [coordinate.longitude, coordinate.latitude]);
polygonCoordinate.x = utmCoordinates[0];
polygonCoordinate.y = utmCoordinates[1];
cRing.push_back(polygonCoordinate);
});
cPolygon.push_back(cRing);
});
multiPolygon.push_back(cPolygon);
});
return multiPolygon;
}
function getAreaSearchResults(searchedNodes: AreaSearchEntries): SearchAreaResultEntry[] {
let searchResults: SearchAreaResultEntry[] = [];
for (var i = 0; i < searchedNodes.size(); i++) {
let searchedNode = searchedNodes.get(i);
if (searchedNode != null) {
let utmCoordinates = [searchedNode.positionX, searchedNode.positionY];
let lngLatCoordinates = proj4('EPSG:32633', 'EPSG:4326', utmCoordinates);
searchResults.push({
nodeId: searchedNode.nodeId,
latitude: lngLatCoordinates[1],
longitude: lngLatCoordinates[0],
longestRoute: searchedNode.longestRoute
});
}
}
return searchResults;
}
onmessage = async (e) => {
if (module == null) {
module = await Module();
}
let message = e.data as Message;
if (message.loadData != null) {
module.FS.writeFile('roads.dat', message.loadData.data);
module.loadData('roads.dat');
dataLoaded = true;
let returnMessage: Message = {dataLoaded: {}}
postMessage(returnMessage);
return;
}
// If the data is not loaded, it will not be possible to perform any further operations
if (!dataLoaded) {
sendErrorMessage('Data not loaded')
return;
}
if (message.findClosestNode != null) {
let lngLatCoordinates = [message.findClosestNode.longitude, message.findClosestNode.latitude];
let utmCoordinates = proj4('EPSG:4326', 'EPSG:32633', lngLatCoordinates);
let node = module.findClosestNode(utmCoordinates[0], utmCoordinates[1]);
let nodeUtmCoordinates = [node.positionX, node.positionY];
let nodeLngLatCoordinates = proj4('EPSG:32633', 'EPSG:4326', nodeUtmCoordinates);
let returnMessage: Message = {
foundClosestNode: {
originalLatitude: message.findClosestNode.latitude,
originalLongitude: message.findClosestNode.longitude,
foundLatitude: nodeLngLatCoordinates[1],
foundLongitude: nodeLngLatCoordinates[0],
foundNodeId: node.nodeId
}
};
node.delete();
postMessage(returnMessage);
};
if (message.findPathsFromNode != null) {
let results = module.findAllPathsFromPoint(
message.findPathsFromNode.nodeId,
message.findPathsFromNode.minimumSpeed,
message.findPathsFromNode.maximumSpeed,
message.findPathsFromNode.maximumSpeedLimit,
message.findPathsFromNode.dragCoefficient,
message.findPathsFromNode.allowMotorways,
message.findPathsFromNode.allowTunnels,
message.findPathsFromNode.allowAgainstOneway
);
let endpoints: Endpoint[] = [];
for (var i = 0; i < results.endPoints.size(); i++) {
let nodeData = results.endPoints.get(i);
if (!nodeData) {
sendErrorMessage('Could not find paths from node ' + message.findPathsFromNode.nodeId);
return;
}
let coordinates = [nodeData.positionX, nodeData.positionY];
let lngLatCoordinates = proj4('EPSG:32633', 'EPSG:4326', coordinates);
endpoints.push({
nodeId: nodeData.nodeId,
latitude: lngLatCoordinates[1],
longitude: lngLatCoordinates[0],
distanceFromStart: nodeData.distanceFromStart
});
}
results.delete();
let returnMessage: Message = {foundPathsFromNode: {nodeId: message.findPathsFromNode.nodeId, endpoints: endpoints}};
postMessage(returnMessage);
}
if (message.getFullPath != null) {
let path = module.getPath(message.getFullPath.startNodeId, message.getFullPath.endNodeId);
if (!path) {
sendErrorMessage('Could not get path');
return;
}
var coordinates = [];
for (var i = 0; i < path.size(); i++) {
let currentPoint = path.get(i);
if (!currentPoint) {
continue;
}
coordinates.push([currentPoint.positionX, currentPoint.positionY]);
}
path.delete();
coordinates = coordinates.map(utmCoordinate => {
let lngLat = proj4('EPSG:32633', 'EPSG:4326', utmCoordinate);
return {latitude: lngLat[1], longitude: lngLat[0]};
});
let returnMessage: Message = {returnFullPath: {coordinates: coordinates}};
postMessage(returnMessage);
}
if (message.excludeAreas != null) {
module.excludeNodesWithinPolygons(createCMultiPolygon(module, message.excludeAreas.polygons));
}
if (message.searchArea != null) {
let settings = message.searchArea;
let result = module.startAreaSearch(
settings.minimumSpeed,
settings.maximumSpeed,
settings.maximumSpeedLimit,
settings.dragCoefficient,
settings.allowMotorways,
settings.allowTunnels,
settings.allowAgainstOneway,
createCMultiPolygon(module, settings.polygons)
);
let returnMessage: Message = { searchAreaResult: {
remainingNodes: result.remainingNodes,
searchResults: getAreaSearchResults(result.searchedNodes)
}};
result.delete();
postMessage(returnMessage);
}
if (message.continueSearch != null) {
let result = module.continueAreaSearch();
let returnMessage: Message = { searchAreaResult: {
remainingNodes: result.remainingNodes,
searchResults: getAreaSearchResults(result.searchedNodes)
}};
result.delete();
postMessage(returnMessage);
}
}
+74
View File
@@ -0,0 +1,74 @@
:root {
--background-color: #fff7f7;
--header-color: #d46a64;
--header-stroke-color: #550000;
--text-color: #000000;
--collapsible-background-color: #AA3939;
--collapsible-active-background-color: #D46A6A;
--collapsible-color: #FFAAAA;
--collapsible-active-color: #550000;
--explanation-background-color: #FFAAAA;
}
body {
background-color: var(--background-color);
color: var(--text-color);
text-align: center;
font-family: 'Courier New';
font-size: 20px;
}
h1 {
color: var(--header-color);
-webkit-text-stroke-width: 2px;
-webkit-text-stroke-color: var(--header-stroke-color);
font-size: 80px;
font-family: 'Helvetica';
margin-bottom: 20px;
}
p {
width: 80vw;
margin-left: auto;
margin-right: auto;
text-align: center;
}
#settings-div {
width: 40vw;
margin-left: auto;
margin-right: auto;
border-style: solid;
border-width: 2px;
padding: 10px;
margin-top:10px;
}
.settings-line {
display: flex;
flex-direction: row;
justify-content: space-between;
margin-bottom: 2px;
}
#map {
height: 80vh;
}
table {
width: max(60vw, 800px);
margin-left: auto;
margin-right: auto;
border: solid 1px;
max-height: 50vh;
overflow-y: scroll;
}
th {
border: solid 1px;
width: max(15vw, 200px);
}
td {
border: solid 1px;
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />