Freewheeling/src/main.ts
2025-07-02 01:10:22 +02:00

399 lines
15 KiB
TypeScript

import './style.css';
import './modules/maphandler/maphandler'
import MapHandler from './modules/maphandler/maphandler';
import type { Message, PathSegment, Polygon } from './interfaces';
import { downloadGpxFile } from './modules/gpx/exporter';
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 lastRoute: PathSegment[] | undefined;
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 exportButton = document.getElementById('export-route-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) {
let settings = getSettings();
mapHandler?.drawPath(message.returnFullPath.pathSegments, settings.minimumSpeed, settings.maximumSpeed);
setRoute(message.returnFullPath.pathSegments);
} 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);
setRoute(undefined);
});
mapHandler.addClickedEndpointListener((startNodeId, endNodeId) => {
let settings = getSettings();
let message: Message = {getFullPath: {startNodeId: startNodeId, endNodeId: endNodeId, minimumSpeed: settings.minimumSpeed, dragCoefficient: settings.dragCoefficient}};
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'});
function setRoute(route?: PathSegment[]) {
lastRoute = route;
if (exportButton && exportButton instanceof HTMLButtonElement) {
if (lastRoute == null) {
exportButton.disabled = true;
} else {
exportButton.disabled = false;
}
}
};
setRoute(undefined);
// 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'});
}
});
exportButton?.addEventListener('click', _ => {
if (lastRoute != null) {
downloadGpxFile(lastRoute);
}
})
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]);
});