import MapHandler from './map/maphandler'; import haversine from 'haversine-distance'; import './style.css'; import { LatLng } from 'leaflet'; import Openrouteservice from 'openrouteservice-js'; import { createUrl, parseUrl } from './url/UrlHandler.js'; import { wktToGeoJSON } from '@terraformer/wkt'; import PPENHandler from './files/ppenhandler.js'; import type { GeoJsonObject } from 'geojson'; import type { FromWorkerMessage, ToWorkerMessage } from './worker/messages.js'; // Outside influences let mapHandler = new MapHandler(); let worker = new Worker(new URL('./worker/worker.ts', import.meta.url), {type: 'module'}); // HTTP elements to be edited let clearMapButton = document.getElementById('clear-map'); let editDistanceButton = document.getElementById('edit-distances'); let shareLinkButton = document.getElementById('share-link'); let calculateRouteButton = document.getElementById('calculate-route'); let orienteeringButton = document.getElementById('orienteering'); let uploadButton = document.getElementById('upload'); let distancePopup = document.getElementById('distance-popup'); let distanceTable = document.getElementById('distance-table'); let retrieveButton = document.getElementById('retrieve-distances-button'); let routeTypeForm = document.getElementById('route-type-form') as HTMLFormElement; let retrieveRoutesButton = document.getElementById('retrieve-routes-button'); let middlePopup = document.getElementById('middle-popup'); let uploadDiv = document.getElementById('upload-div'); let orienteeringDiv = document.getElementById('orienteering-div'); let uploadTextArea = document.getElementById('input-text-area') as HTMLTextAreaElement; let uploadTextButton = document.getElementById('upload-button'); let ppenUploader = document.getElementById('upload-ppen-input') as HTMLInputElement; let xmlUploader = document.getElementById('upload-xml-input') as HTMLInputElement; let downloadPpenButton = document.getElementById('download-ppen-route-button') as HTMLButtonElement; let ppenExplanation = document.getElementById('ppen-explanation') as HTMLParagraphElement; let closePopupButton = document.getElementById('close-popup-button'); let distanceTextParagraph = document.getElementById('distance-text') as HTMLParagraphElement; // We have some asynchronous actions later depending on the user clicking something in the map. // Set that up here let awaitingResolutions: ((latLng: LatLng) => void)[] = []; mapHandler.addMarkerClickedListener((latLng) => { awaitingResolutions.forEach(awaiting => { awaiting(latLng); }); awaitingResolutions = []; }); // The overall state of our web browser window interface State { points: LatLng[] | null, distances: [LatLng, LatLng, number][], calculating: boolean, foundPath: number[] | null, complexPath: GeoJsonObject | null, ppenHandler: PPENHandler | null, ppenString: string | null, xmlString: string | null, current: "Editor" | "Distance" | "SpecificDistance" | "Orienteering" | "Downloading" | "Upload", currentDistance: [LatLng, LatLng] | null, distancesTablePopulated: boolean } let state: State = { points: null, distances: [], calculating: false, foundPath: null, complexPath: null, ppenHandler: null, ppenString: null, xmlString: null, current: "Editor", currentDistance: null, distancesTablePopulated: false } // Make a kembo function that draws the interface based on the current state function updateState() { // Decide which buttons to show var allowClearMap = true; var allowEditDistances = true; var allowShareLink = true; var allowCalculateRoute = true; var allowOrienteering = true; var allowUpload = true; // If there are no points, don't show the clear map button if (state.points == null || state.points.length < 1) { allowClearMap = false; } // If there are less than two points, don't show the edit distances button if (state.points == null || state.points.length < 2) { allowEditDistances = false; } // However, show it if we are in the editing distances state, so we are able to actually close // the popup window if (state.current == "Distance" || state.current == "SpecificDistance") { allowEditDistances = true; } // Always just allow the share link button, do nothing here // Only allow the calculate route button when we have more than three points, and don't already have a route // Also, disallow the get route button when currently calculating if (state.points == null || state.points.length < 3 || state.foundPath != null || state.calculating) { allowCalculateRoute = false; } // Always allow the orienteering and upload buttons, do nothing here // However, final override: If we are in the downloading orienteering map state, disallow all buttons until done if (state.current == "Downloading") { allowClearMap = false; allowEditDistances = false; allowShareLink = false; allowCalculateRoute = false; allowOrienteering = false; allowUpload = false; } let buttonArray: [HTMLElement | null, boolean][] = Array.of( [clearMapButton, allowClearMap], [editDistanceButton, allowEditDistances], [shareLinkButton, allowShareLink], [calculateRouteButton, allowCalculateRoute], [orienteeringButton, allowOrienteering], [uploadButton, allowUpload] ); buttonArray.forEach( buttonData => { var button = buttonData[0]; var allow = buttonData[1]; if (allow) { button?.classList.add('clickable'); button?.classList.remove('unclickable'); } else { button?.classList.remove('clickable'); button?.classList.add('unclickable'); } } ) // Next, update which popups are showing based on the state if (state.current == "Editor") { distancePopup?.style.setProperty('display', 'none'); middlePopup?.style.setProperty('display', 'none'); } else if (state.current == "Distance" || state.current == "SpecificDistance") { distancePopup?.style.setProperty('display', 'block'); middlePopup?.style.setProperty('display', 'none'); if (!state.distancesTablePopulated) { populateDistancesTable(); state.distancesTablePopulated = true; } } else if (state.current == 'Orienteering' || state.current == 'Downloading') { distancePopup?.style.setProperty('display', 'none'); orienteeringDiv?.style.setProperty('display', 'block'); uploadDiv?.style.setProperty('display', 'none'); middlePopup?.style.setProperty('display', 'block'); if (state.current == 'Downloading') { closePopupButton?.setAttribute('disabled', 'true'); ppenUploader?.setAttribute('disabled', 'true'); xmlUploader?.setAttribute('disabled', 'true'); downloadPpenButton?.setAttribute('disabled', 'true'); } else { closePopupButton?.removeAttribute('disabled'); ppenUploader?.removeAttribute('disabled'); xmlUploader?.removeAttribute('disabled'); downloadPpenButton?.removeAttribute('disabled'); } if (state.foundPath == null || state.foundPath.length == 0) { downloadPpenButton?.setAttribute('disabled', 'true'); } } else if (state.current == 'Upload') { distancePopup?.style.setProperty('display', 'none'); orienteeringDiv?.style.setProperty('display', 'none'); uploadDiv?.style.setProperty('display', 'block'); middlePopup?.style.setProperty('display', 'block'); } // Decide whether the map should be locked or not var locked = false; if (state.current == 'Distance' || state.current == 'SpecificDistance') { locked = true; } if (state.ppenHandler != null) { locked = true; } if (locked) { mapHandler.lock(); } else { mapHandler.unlock(); } // Decide whether index popups should be shown over points if (state.current == 'Distance' || state.current == 'SpecificDistance') { mapHandler.showIdPopups(); } else { mapHandler.hideIdPopups(); } // Decide which polyline to draw let polyLine: LatLng[] | null = null; var showingDistanceSegment = false; var distanceText: string | null = null; if (state.current == 'SpecificDistance' && state.currentDistance && state.currentDistance.length > 1) { polyLine = state.currentDistance; showingDistanceSegment = true; } else if (state.foundPath && state.foundPath.length > 0 && state.points && state.points.length > 0) { polyLine = state.foundPath .filter(index => index < (state.points?.length ?? 0)) .map(index => state.points?.at(index) ?? new LatLng(0, 0)); var totalDistance = 0.0; for (var i = 1; i < state.foundPath.length; i++) { let currentIndex = state.foundPath[i]; let previousIndex = state.foundPath[i-1]; let currentLatLng = state.points[currentIndex]; let previousLatLng = state.points[previousIndex]; let lockedDistance = state.distances.find(d => (d[0].distanceTo(currentLatLng) < 2 && d[1].distanceTo(previousLatLng) < 2) || (d[0].distanceTo(previousLatLng) < 2 && d[1].distanceTo(currentLatLng) < 2) ); if (lockedDistance) { totalDistance += lockedDistance[2]; } else { totalDistance += haversine(currentLatLng, previousLatLng) / 1000.0; } } // Calculate distance distanceText = "Avstand: " + totalDistance.toFixed(3) + " km"; } mapHandler.clearPolyLine(); if (polyLine) { if (!showingDistanceSegment && state.complexPath) { mapHandler.addPolyLineGeojson(state.complexPath); } else { mapHandler.addPolyLine(polyLine); } if (showingDistanceSegment) { mapHandler.zoomToLatLongs(polyLine); } } if (distanceText && distanceTextParagraph) { distanceTextParagraph.textContent = distanceText; distanceTextParagraph.style.setProperty('display', 'block'); } else { distanceTextParagraph?.style.setProperty('display', 'none'); } if (state.foundPath && !state.complexPath) { retrieveRoutesButton?.style.removeProperty('disabled'); } else { retrieveButton?.style.setProperty('disabled', 'true'); } } // In case of messages from the web worker, do work worker.onmessage = e => { let fromWorker = e.data as FromWorkerMessage; let message = fromWorker.message; if (message.type == 'SearchFinished') { state.foundPath = message.path; state.calculating = false; updateState(); } } function calculateRoute(): void { if (!state.points) { return; } let points: LatLng[] = state.points; let weights: number[][] = []; for (var i = 0; i < points.length; i++) { let weightsRow: number[] = []; for (var j = 0; j < points.length; j++) { var distance: number; if (i == j) { distance = 1e100; } else { // Check if this distance has been locked in let firstLatLng = points[i]; let secondLatLng = points[j]; let lockedDistance = state.distances.find( d => (d[0].distanceTo(firstLatLng) < 2 && d[1].distanceTo(secondLatLng) < 2) || (d[0].distanceTo(secondLatLng) < 2 && d[1].distanceTo(firstLatLng) < 2) ); if (lockedDistance) { distance = lockedDistance[2] * 1000.0; } else { distance = haversine(firstLatLng, secondLatLng); } } weightsRow.push(distance); } weights.push(weightsRow); } let startSearchMessage: ToWorkerMessage = { message: { type: "StartSearch", weights: weights } } worker.postMessage(startSearchMessage); } function populateDistancesTable() { if (distanceTable && distanceTable instanceof HTMLTableElement) { // Delete the rows that might have already been there while (distanceTable.rows.length > 1) { distanceTable.deleteRow(1); } // Add rows for each pair of coordinates let coordinates = state.points; if (!coordinates) { coordinates = []; } for (var i = 0; i < coordinates.length; i++) { for (var j = i+1; j < coordinates.length; j++) { let firstIndex = i+1; let secondIndex = j+1; let tableRow = document.createElement('tr'); tableRow.classList.add('distance-row'); let pairCell = document.createElement('td'); pairCell.appendChild(document.createTextNode(firstIndex.toString() + '-' + secondIndex.toString())); let distanceCell = document.createElement('td'); tableRow.appendChild(pairCell); tableRow.appendChild(distanceCell); distanceTable.appendChild(tableRow); let firstLatLng = coordinates.at(i); let secondLatLng = coordinates.at(j); tableRow.addEventListener('click', () => { if (typeof firstLatLng != 'undefined' && typeof secondLatLng != 'undefined') { state.current = 'SpecificDistance'; state.currentDistance = [firstLatLng, secondLatLng]; updateState(); } }); // Add the input for distances let distanceInput = document.createElement('input') as HTMLInputElement; distanceCell.appendChild(distanceInput); distanceCell.appendChild(document.createTextNode(' km')); let lockedDistance = state.distances.find( d => (d[0] == firstLatLng && d[1] == secondLatLng) || (d[0] == secondLatLng && d[1] == firstLatLng) ); var distance: number; var wasUnchanged: boolean = true; if (lockedDistance) { distance = lockedDistance[2]; wasUnchanged = false; } else { distance = haversine(firstLatLng!, secondLatLng!) / 1000.0; distanceInput.classList.add('unchanged'); } distanceInput.value = distance.toFixed(3); distanceInput.addEventListener('focus', e => { let distanceInput = e.target; if (!(distanceInput instanceof HTMLInputElement)) { return; } if (distanceInput.classList.contains('unchanged')) { distanceInput.classList.remove('unchanged'); distanceInput.value = ''; } }); function createInputFunction(firstLatLng: LatLng | undefined, secondLatLng: LatLng | undefined) { return function (event: Event) { let distanceInput = event.target; if (!(distanceInput instanceof HTMLInputElement)) { return; } let lockedDistance = state.distances.find( d => (d[0] == firstLatLng && d[1] == secondLatLng) || (d[0] == secondLatLng && d[1] == firstLatLng) ); var value = distanceInput.value; var valueFloat = parseFloat(value); if (value != '' && isNaN(valueFloat)) { if (!distanceInput.classList.contains('wrong')) { distanceInput.classList.add('wrong'); } } else { if (distanceInput.classList.contains('wrong')) { distanceInput.classList.remove('wrong'); } if (!isNaN(valueFloat)) { if (!lockedDistance) { lockedDistance = [firstLatLng!, secondLatLng!, 0.0]; state.distances.push(lockedDistance); } lockedDistance[2] = valueFloat; } } }; }; distanceInput.addEventListener('input', createInputFunction(firstLatLng, secondLatLng)); function createBlurFunction(distance: number, wasUnchanged: boolean, firstLatLng: LatLng | undefined, secondLatLng: LatLng | undefined) { return function (event: Event) { let distanceInput = event.target; if (!(distanceInput instanceof HTMLInputElement)) { return; } let lockedDistance = state.distances.find( d => (d[0] == firstLatLng && d[1] == secondLatLng) || (d[0] == secondLatLng && d[1] == firstLatLng) ); if (distanceInput.value == '') { distanceInput.value = distance.toFixed(3); if (wasUnchanged) { distanceInput.classList.add('unchanged'); state.distances = state.distances.filter(tuple => tuple != lockedDistance); } else if (lockedDistance) { lockedDistance[2] = distance; } } }; }; distanceInput.addEventListener('blur', createBlurFunction(distance, wasUnchanged, firstLatLng, secondLatLng)); } } } } retrieveButton?.addEventListener('click', async _ => { let matrix = new Openrouteservice.Matrix({host: 'https://routeserviceproxy.martinserver.no'}); let clickedPoints = state.points; if (!clickedPoints) { return; } let locations = clickedPoints.map(point => [point.lng, point.lat]); let profile = new FormData(routeTypeForm)?.get("route-type") ?? "driving-car" let response = await matrix.calculate({ locations: locations, profile: profile, sources: ['all'], destinations: ['all'], metrics: ['distance', 'duration'] }); if (response && response.distances && response.distances.length == locations.length) { state.distances = []; for (var i = 0; i < clickedPoints.length; i++) { for (var j = i+1; j < clickedPoints.length; j++) { if (i == j) { continue; } let firstLatLng = clickedPoints[i]; let secondLatLng = clickedPoints[j]; let distance = response.distances[i][j]; state.distances.push([firstLatLng, secondLatLng, distance / 1000.0]); } } populateDistancesTable(); } }); uploadTextButton?.addEventListener('click', _ => { let uploadedText = uploadTextArea?.value; // First, try to parse as WKT var parsed = null; try { parsed = wktToGeoJSON(uploadedText); } catch(error) { // Do nothing } // Next, try to parse input as geojson if (!parsed) { try { parsed = JSON.parse(uploadedText); } catch(error) { // Do nothing } } // Parsed should now be a GeoJSON object. Try to read all points from it, and add them to the map if (parsed) { // We don't really care what type of object or anything it is. We'll just search through it for // any child element called coordinates, and extract all coordinates and make them into points function readCoordinates(coordinates: any): LatLng[] { var parsedCoordinates: LatLng[] = []; if ((coordinates instanceof Array) && coordinates.length > 0) { let firstChild = coordinates[0]; if (firstChild instanceof Array) { coordinates.forEach(child => { let childResult = readCoordinates(child); childResult.forEach(latlng => { parsedCoordinates.push(latlng); }) }) } else if (coordinates.length > 1) { let lng = parseFloat(coordinates[0]); let lat = parseFloat(coordinates[1]); if (!isNaN(lat) && !isNaN(lng)) { parsedCoordinates.push(new LatLng(lat, lng)); } } } return parsedCoordinates; } function findCoordinates(object: Object): LatLng[] { var coordinates: LatLng[] = []; if (object instanceof Array) { object.forEach(child => { let childCoordinates = findCoordinates(child); childCoordinates.forEach(coordinate => coordinates.push(coordinate)); }); return coordinates; } if (!(object instanceof Object)) { return coordinates; } for(const [key, value] of Object.entries(object)) { if (key == 'coordinates') { let currentCoordinates = readCoordinates(value); currentCoordinates.forEach(coordinate => coordinates.push(coordinate)); } else { findCoordinates(value).forEach(coordinate => coordinates.push(coordinate)); } } return coordinates; } let newCoordinates = findCoordinates(parsed); if (newCoordinates.length > 0) { mapHandler.clearAll(); mapHandler.addPoints(newCoordinates); state.points = newCoordinates; state.foundPath = null; state.complexPath = null; updateState(); } } }); function loadPpen(): void { if (state.ppenString != null) { state.ppenHandler = new PPENHandler(state.ppenString, state.xmlString); mapHandler.clearAll() mapHandler.addPoints(state.ppenHandler.getControls()); state.points = state.ppenHandler.getControls(); state.foundPath = null; state.complexPath = null; updateState(); } } ppenUploader?.addEventListener('change', chooseFileEvent => { let eventTarget = chooseFileEvent.target; if (!(eventTarget instanceof HTMLInputElement) || eventTarget.files == null || eventTarget.files.length == 0) { return; } let reader = new FileReader(); reader.addEventListener('load', e => { let data = e.target?.result; if (data != null && typeof data == 'string') { state.ppenString = data; loadPpen(); } }); reader.readAsText(eventTarget.files[0]); }) xmlUploader?.addEventListener('change', chooseFileEvent => { let eventTarget = chooseFileEvent.target; if (!(eventTarget instanceof HTMLInputElement) || eventTarget.files == null || eventTarget.files.length == 0) { return; } let reader = new FileReader(); reader.addEventListener('load', e => { let data = e.target?.result; if (data != null && typeof data == 'string') { state.xmlString = data; loadPpen(); } }); reader.readAsText(eventTarget.files[0]); }); downloadPpenButton?.addEventListener('click', async _ => { if (state.points && state.points.length > 1 && state.foundPath != null) { state.current == 'Downloading'; updateState(); if (ppenExplanation) { ppenExplanation.textContent = 'Trykk på posten du vil starte runden fra'; } await new Promise((resolve) => { awaitingResolutions.push(resolve); }).then(async start => { if (ppenExplanation) { ppenExplanation.textContent = 'Trykk på neste post, for å bestemme retningen på ruten.' await new Promise((resolve) => { awaitingResolutions.push(resolve); }).then(next => { let firstIndex = state.foundPath?.findIndex(nodeIndex => state.points?.at(nodeIndex) == start); let nextIndex = state.foundPath?.findIndex(nodeIndex => state.points?.at(nodeIndex) == next); let currentPath = state.foundPath; if (firstIndex !== null && typeof firstIndex != 'undefined' && nextIndex !== null && typeof nextIndex != 'undefined' && currentPath) { let withNewStart = currentPath.slice(firstIndex).concat(currentPath.slice(0, firstIndex)); let difference = (nextIndex - firstIndex) % withNewStart.length; if (difference < 0) { difference += withNewStart.length; } // If the difference is more than half, reverse the path let finalPath; if (difference > withNewStart.length / 2) { finalPath = withNewStart.reverse(); } else { finalPath = withNewStart; } state.ppenHandler?.createRoute(finalPath); let newPpenFile = state.ppenHandler?.getPpenFile(); if (newPpenFile) { var blob = new Blob([newPpenFile], {type: "application/xml"}); var url = window.URL.createObjectURL(blob); var hiddenLink = document.createElement('a'); var fileName = ppenUploader?.files?.item(0)?.name; if (fileName) { if (fileName.endsWith('.ppen')) { let nameLength = fileName.length; fileName = fileName.substring(0, nameLength - 5) + '_med_utsetting.ppen'; } } else { fileName = 'newFile.ppen'; } hiddenLink.download = fileName; hiddenLink.href = url; hiddenLink.style.display = 'none'; document.body.appendChild(hiddenLink); hiddenLink.click(); document.body.removeChild(hiddenLink); } } state.current = 'Orienteering'; updateState(); if (ppenExplanation) { ppenExplanation.textContent = ''; } }); } }); } }); // Load URL data and update state let [points, distances, path] = parseUrl(window.location.href); if (points) { mapHandler.clearAll(); mapHandler.addPoints(points); state.points = points; } if (distances) { state.distances = distances; } if (path) { state.foundPath = path; } updateState(); // Finally, add button listeners and map listeners mapHandler.addAddedListener(_ => { state.points = mapHandler.getClickedPoints(); state.foundPath = null; state.complexPath = null; state.distancesTablePopulated = false; updateState(); }) mapHandler.addRemovedListener(_ => { state.points = mapHandler.getClickedPoints(); state.foundPath = null; state.complexPath = null; state.distancesTablePopulated = false; updateState(); }); mapHandler.addDraggedListener(_ => { state.points = mapHandler.getClickedPoints(); state.foundPath = null; state.complexPath = null; state.distancesTablePopulated = false; updateState(); }); clearMapButton?.addEventListener('click', _ => { if (clearMapButton.classList.contains('clickable')) { state.foundPath = null; state.complexPath = null; state.ppenHandler = null; state.distancesTablePopulated = false; state.current = 'Editor'; mapHandler.clearAll(); state.points = null; updateState(); } }); editDistanceButton?.addEventListener('click', _ => { if (editDistanceButton?.classList.contains('clickable')) { if (state.current == 'Distance' || state.current == 'SpecificDistance') { state.current = 'Editor'; updateState(); } else { state.current = 'Distance'; updateState(); } } }); shareLinkButton?.addEventListener('click', async _ => { if (shareLinkButton.classList.contains('clickable')) { const linkPath = window.location.origin + createUrl(state.points, state.distances, state.foundPath); await navigator.clipboard.writeText(linkPath); } }); calculateRouteButton?.addEventListener('click', _ => { if (calculateRouteButton.classList.contains('clickable')) { state.calculating = true; updateState(); calculateRoute(); } }); orienteeringButton?.addEventListener('click', _ => { if (orienteeringButton.classList.contains('clickable')) { state.current = 'Orienteering'; updateState(); } }); uploadButton?.addEventListener('click', _ => { if (uploadButton.classList.contains('clickable')) { state.current = 'Upload'; updateState(); } }); closePopupButton?.addEventListener('click', _ => { state.current = 'Editor'; updateState(); }); retrieveRoutesButton?.addEventListener('click', async _ => { if (state.foundPath && state.points && !state.complexPath) { let directions = new Openrouteservice.Directions({host: 'https://routeserviceproxy.martinserver.no'}); let coordinates = state.foundPath .map(pointIndex => state.points?.at(pointIndex) ?? new LatLng(0,0)) .map(latLng => [latLng.lng, latLng.lat]); let profile = new FormData(routeTypeForm)?.get("route-type") ?? "driving-car" let response = await directions.calculate({ coordinates: coordinates, profile: profile, format: 'geojson' }); state.complexPath = response; updateState(); } })