initial commit

This commit is contained in:
Martin Asprusten
2026-08-29 09:29:39 +02:00
commit ccbf9c12a0
16 changed files with 3416 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
declare module 'openrouteservice-js';
+243
View File
@@ -0,0 +1,243 @@
import { LatLng } from "leaflet";
class PPENHandler {
ppenDocument: XMLDocument;
controlCodes: [string, LatLng][];
constructor(ppenXml: string, xmlXml: string | null) {
const parser = new DOMParser();
this.ppenDocument = parser.parseFromString(ppenXml, "application/xml");
let xmlDocument: Document | null;
if (xmlXml != null) {
xmlDocument = parser.parseFromString(xmlXml, "application/xml");
} else {
xmlDocument = null;
}
// First, use the xml Document to get a mapping from local coordinates to global coordinates
let localAndGlobalPairs: [[number, number], LatLng][] = []
if (xmlDocument != null) {
Array.from(xmlDocument.getElementsByTagName("Control")).forEach(control => {
let position = control.getElementsByTagName("Position").item(0);
let mapPosition = control.getElementsByTagName("MapPosition").item(0);
if (position && mapPosition) {
let mapXString = mapPosition.getAttribute("x");
let mapYString = mapPosition.getAttribute("y");
let latString = position.getAttribute("lat")
let lngString = position.getAttribute("lng");
if (mapXString && mapYString && latString && lngString) {
let mapX = parseFloat(mapXString);
let mapY = parseFloat(mapYString);
let lat = parseFloat(latString);
let lng = parseFloat(lngString);
if (!isNaN(mapX) && !isNaN(mapY) && !isNaN(lat) && !isNaN(lng)) {
localAndGlobalPairs.push([[mapX, mapY], new LatLng(lat, lng)]);
}
}
}
});
}
// If there is no XML document, just create some positions around 0 latitude, 0 longitude
else {
Array.from(this.ppenDocument.getElementsByTagName("control")).forEach(control => {
let location = control.getElementsByTagName("location").item(0);
if (location) {
let xString = location.getAttribute("x");
let yString = location.getAttribute("y");
if (xString && yString) {
let x = parseFloat(xString);
let y = parseFloat(yString);
if (!isNaN(x) && !isNaN(y)) {
let lng = x * 180.0 / (637813.7 * Math.PI);
let lat = y * 180.0 / (637813.7 * Math.PI);
localAndGlobalPairs.push([[x, y], new LatLng(lat, lng)]);
}
}
}
});
}
// Find all control codes
this.controlCodes = [];
Array.from(this.ppenDocument.getElementsByTagName("control")).forEach(control => {
let id = control.getAttribute("id");
let location = control.getElementsByTagName("location").item(0);
if (id && location) {
let xString = location.getAttribute("x");
let yString = location.getAttribute("y");
if (xString && yString) {
let x = parseFloat(xString);
let y = parseFloat(yString);
if (!isNaN(x) && !isNaN(y)) {
let closestGlobal = localAndGlobalPairs.find(pair => {
let otherX = pair[0][0];
let otherY = pair[0][1];
let distance = Math.sqrt(Math.pow(otherX - x, 2) + Math.pow(otherY - y, 2));
return distance < 0.1;
});
if (closestGlobal) {
this.controlCodes.push([id, closestGlobal[1]]);
}
}
}
}
})
};
public getControls(): LatLng[] {
return this.controlCodes.map(code => code[1]);
}
private getLengthOfCourse(course: Element): number {
var distance = 0;
var previousX: number | null = null;
var previousY: number | null = null;
let currentCourseControl = course.getElementsByTagName("first")?.item(0)?.getAttribute("course-control");
while (currentCourseControl) {
let courseControlElement = Array.from(
this.ppenDocument.getElementsByTagName("course-control")
).find(element => element.getAttribute("id") == currentCourseControl);
let controlId = courseControlElement?.getAttribute("control");
let controlElement = Array.from(
this.ppenDocument.getElementsByTagName("control")
).find(element => element.getAttribute("id") == controlId);
if (courseControlElement && controlId && controlElement) {
let currentX = parseFloat(controlElement.getElementsByTagName("location").item(0)?.getAttribute("x") ?? "null");
let currentY = parseFloat(controlElement.getElementsByTagName("location").item(0)?.getAttribute("y") ?? "null");
if (!isNaN(currentX) && !isNaN(currentY)) {
if (previousX != null && previousY != null) {
distance += Math.sqrt(Math.pow(currentX - previousX, 2) + Math.pow(currentY - previousY, 2));
}
previousX = currentX;
previousY = currentY;
}
}
currentCourseControl = courseControlElement?.getElementsByTagName("next").item(0)?.getAttribute("course-control");
}
return distance;
}
public createRoute(path: number[]): void {
let courses = Array.from(this.ppenDocument.getElementsByTagName("course"));
let longestCourse = courses.reduce((a, b) => this.getLengthOfCourse(a) > this.getLengthOfCourse(b) ? a : b);
let courseIds = courses.map(course => parseInt(course.getAttribute("id") ?? "null")).filter(n => !isNaN(n));
let courseOrders = courses.map(course => parseInt(course.getAttribute("order") ?? "null")).filter(n => !isNaN(n));
let courseControls = Array.from(this.ppenDocument.getElementsByTagName("course-control"));
let courseControlIds = courseControls.map(control => parseInt(control.getAttribute("id") ?? "null")).filter(n => !isNaN(n));
let courseId = Math.max(...courseIds) + 1;
let courseOrder = Math.max(...courseOrders) + 1;
let course = this.ppenDocument.createElement("course");
course.setAttribute("id", courseId.toString());
course.setAttribute("kind", "normal");
course.setAttribute("order", courseOrder.toString());
var name = "Utsetting";
var nameCounter = 1;
while (courses.find(course => course.getElementsByTagName("name").item(0)?.textContent == name)) {
name = "Utsetting" + nameCounter;
nameCounter++;
}
let nameElement = this.ppenDocument.createElement("name");
course.appendChild(nameElement);
nameElement.appendChild(this.ppenDocument.createTextNode("Utsetting"));
let labels = this.ppenDocument.createElement("labels");
labels.setAttribute("label-kind", "code");
course.appendChild(labels);
let newCourseControls: Element[] = [];
let firstCourseControl = Math.max(...courseControlIds) + 1;
var currentCourseControl = firstCourseControl;
path.forEach(index => {
let controlId = this.controlCodes[index]?.[0];
if (controlId) {
let courseControlElement = this.ppenDocument.createElement("course-control");
courseControlElement.setAttribute("id", currentCourseControl.toString());
currentCourseControl++;
courseControlElement.setAttribute("control", controlId);
let next = this.ppenDocument.createElement("next");
next.setAttribute("course-control", currentCourseControl.toString());
courseControlElement.appendChild(next);
let sameControlElement = courseControls.filter(e => e.getAttribute("control") == controlId).find(e => e.getElementsByTagName("number-location").item(0) != null);
if (sameControlElement) {
let numberLocation = sameControlElement.getElementsByTagName("number-location").item(0);
if (numberLocation) {
courseControlElement.appendChild(numberLocation.cloneNode());
}
}
newCourseControls.push(courseControlElement);
}
});
// The last course control element shouldn't have a next element
let lastElement = newCourseControls[newCourseControls.length - 1];
let lastNext = lastElement?.getElementsByTagName("next").item(0);
if (lastElement && lastNext) {
lastElement.removeChild(lastNext);
}
let first = this.ppenDocument.createElement("first");
first.setAttribute("course-control", firstCourseControl.toString());
course.appendChild(first);
let printArea = longestCourse.getElementsByTagName("print-area").item(0);
if (printArea) {
course.append(printArea.cloneNode());
}
let options = longestCourse.getElementsByTagName("options").item(0);
if (options) {
course.append(options.cloneNode());
}
longestCourse.parentNode?.insertBefore(course, longestCourse);
newCourseControls.forEach(control => {
var courseControl = null;
var controlCounter = 0;
while (courseControl == null) {
courseControl = courseControls.at(controlCounter);
controlCounter += 1;
if (controlCounter >= courseControls.length) {
break;
}
}
courseControl?.parentNode?.insertBefore(control, courseControl);
})
}
public getPpenFile(): string {
const serializer = new XMLSerializer();
return serializer.serializeToString(this.ppenDocument);
}
};
export default PPENHandler;
+796
View File
@@ -0,0 +1,796 @@
import MapHandler from './map/maphandler';
import Module, { type MainModule } from '../native/salesman.js';
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';
// Outside influences
let mapHandler = new MapHandler();
let module: MainModule = await 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][],
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: [],
foundPath: [],
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
if (state.points == null || state.points.length < 3 || state.foundPath != null) {
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');
}
}
function calculateRoute(): number[] {
if (!state.points) {
return [];
}
let points: LatLng[] = state.points;
let weights = new module.Weights();
for (var i = 0; i < points.length; i++) {
let weightsRow = new module.WeightsRow();
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_back(distance);
}
weights.push_back(weightsRow);
}
let path = module.findShortestPath(weights);
var foundPath = [];
for (var i = 0; i < path.size(); i++) {
let pointIndex = path.get(i);
if (typeof pointIndex != 'undefined') {
foundPath.push(pointIndex);
}
}
return foundPath;
}
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', _ => {
if (distanceInput.classList.contains('unchanged')) {
distanceInput.classList.remove('unchanged');
distanceInput.value = '';
}
});
distanceInput.addEventListener('input', _ => {
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 (!lockedDistance) {
lockedDistance = [firstLatLng!, secondLatLng!, 0.0];
state.distances.push(lockedDistance);
}
lockedDistance[2] = valueFloat;
}
});
distanceInput.addEventListener('blur', _ => {
if (distanceInput.value == '') {
distanceInput.value = distance.toFixed(3);
if (wasUnchanged) {
distanceInput.classList.add('unchanged');
state.distances = state.distances.filter(tuple => tuple != lockedDistance);
}
}
});
}
}
}
}
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')) {
let path = calculateRoute();
state.foundPath = path;
updateState();
}
});
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();
}
})
+253
View File
@@ -0,0 +1,253 @@
import 'leaflet/dist/leaflet.css';
import L, { LatLng, Marker, Polyline } from "leaflet";
import redUrl from '../../static/marker-icon-2x-red.png';
import greyUrl from '../../static/marker-icon-2x-grey.png';
import shadowUrl from '../../static/marker-shadow.png';
import type { GeoJsonObject } from 'geojson';
const redIcon = new L.Icon({
iconUrl: redUrl,
shadowUrl: shadowUrl,
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowSize: [41, 41]
});
const greyIcon = new L.Icon({
iconUrl: greyUrl,
shadowUrl: shadowUrl,
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowSize: [41, 41]
});
interface AddedListener {
(latLng: LatLng): void;
}
interface RemovedListener {
(latLng: LatLng): void;
}
interface DraggedListener {
(latLng: LatLng): void;
}
interface MarkerClickedListener {
(latLng: LatLng): void;
}
class MapHandler {
map;
clickedPoints: Marker[];
addedListeners: AddedListener[] = [];
removedListeners: RemovedListener[] = [];
markerClickedListeners: MarkerClickedListener[] = [];
draggedListeners: DraggedListener[] = [];
polyline: Polyline | L.GeoJSON<any, any> | null;
markerDebounce: Map<Marker, number> = new Map();
overrideLocked = false;
locked = false;
constructor() {
this.clickedPoints = [];
this.polyline = null;
// Use OpenStreetMaps and center on Oslo
this.map = L.map('map', {
center: L.latLng(59.92, 10.74),
zoom: 13,
zoomControl: false
});
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);
this.map.addEventListener('click', e => {
if (!this.locked && !this.overrideLocked) {
this.addPoint(e.latlng);
this.addedListeners.forEach(listener => listener(e.latlng));
}
});
}
public getClickedPoints(): LatLng[] {
return this.clickedPoints.map(marker => marker.getLatLng());
}
public getNumberOfPoints(): number {
return this.clickedPoints.length;
}
public addPoint(position: LatLng): Marker {
let marker = L.marker(position, {draggable: true, bubblingMouseEvents: false, icon: redIcon});
this.clickedPoints.push(marker);
marker.addTo(this.map);
this.markerDebounce.set(marker, Date.now());
marker.addEventListener('moveend', _ => {
this.polyline?.removeFrom(this.map);
this.polyline = null;
this.draggedListeners.forEach(listener => listener(marker.getLatLng()));
this.markerDebounce.set(marker, Date.now());
});
marker.addEventListener('click', _ => {
if (!this.locked && !this.overrideLocked && (!this.markerDebounce.has(marker) || Date.now() - (this.markerDebounce.get(marker) ?? 0) > 500)) {
this.polyline?.removeFrom(this.map);
this.polyline = null;
marker.removeFrom(this.map);
this.clickedPoints = this.clickedPoints.filter(m => m != marker);
this.removedListeners.forEach(listener => listener(marker.getLatLng()));
}
this.markerClickedListeners.forEach(listener => listener(marker.getLatLng()));
});
this.polyline?.removeFrom(this.map);
this.polyline = null;
return marker;
}
public addPoints(positions: LatLng[]): void {
var markers: Marker[] = [];
positions.forEach(position => {
markers.push(this.addPoint(position));
});
this.zoomToExtent();
}
public addPolyLine(coordinates: LatLng[]): void {
if (this.polyline != null) {
this.polyline.removeFrom(this.map);
}
this.polyline = L.polyline(coordinates);
this.polyline.addTo(this.map);
}
public addPolyLineGeojson(geoJson: GeoJsonObject): void {
if (this.polyline != null) {
this.polyline.removeFrom(this.map);
}
this.polyline = L.geoJSON(geoJson);
this.polyline.addTo(this.map);
}
public clearPolyLine(): void {
if (this.polyline != null) {
this.polyline.removeFrom(this.map);
this.polyline = null;
}
}
public clearAll(): void {
this.polyline?.removeFrom(this.map);
this.polyline = null;
this.clickedPoints.forEach(m => m.removeFrom(this.map));
this.clickedPoints = [];
}
private applyLock(): void {
this.clickedPoints.forEach(marker => {
marker.dragging?.disable();
marker.setIcon(greyIcon);
});
}
private applyUnlock(): void {
this.clickedPoints.forEach(marker => {
marker.dragging?.enable();
marker.setIcon(redIcon);
});
}
public lock(): void {
this.locked = true;
this.applyLock();
}
public unlock(): void {
this.locked = false;
if (!this.overrideLocked) {
this.applyUnlock();
}
}
public overrideLock(): void {
this.overrideLocked = true;
this.applyLock();
}
public overrideUnlock(): void {
this.overrideLocked = false;
if (!this.locked) {
this.applyUnlock();
}
}
public showIdPopups(): void {
this.clickedPoints.forEach((marker, index) => {
marker.bindTooltip((index+1).toString(), {permanent: true, direction: 'right'})
})
}
public hideIdPopups(): void {
this.clickedPoints.forEach(marker => {
marker.closeTooltip();
marker.unbindTooltip();
})
}
public invalidateSize(): void {
this.map.invalidateSize();
}
public zoomToExtent(): void {
this.map.invalidateSize();
var group = L.featureGroup(this.clickedPoints);
this.map.fitBounds(group.getBounds());
}
public zoomToLatLongs(latLngs: LatLng[]): void {
var south = 1e100;
var north = -1e100;
var west = 1e100;
var east = -1e100;
latLngs.forEach(latLng => {
south = Math.min(south, latLng.lat);
north = Math.max(north, latLng.lat);
east = Math.max(east, latLng.lng);
west = Math.min(west, latLng.lng);
});
let bounds = L.latLngBounds([south, west], [north, east]);
this.map.fitBounds(bounds);
}
public addAddedListener(addedListener: AddedListener): void {
this.addedListeners.push(addedListener);
}
public addRemovedListener(removedListener: RemovedListener): void {
this.removedListeners.push(removedListener);
}
public addDraggedListener(draggedListener: DraggedListener): void {
this.draggedListeners.push(draggedListener);
}
public addMarkerClickedListener(clickedListener: MarkerClickedListener): void {
this.markerClickedListeners.push(clickedListener);
}
}
export default MapHandler;
+170
View File
@@ -0,0 +1,170 @@
: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;
margin: 0px;
}
h1 {
color: var(--header-color);
-webkit-text-stroke-width: 2px;
-webkit-text-stroke-color: var(--header-stroke-color);
font-size: 80px;
font-family: 'Helvetica';
max-height: 10vh;
margin: 0px;
}
.navbar {
overflow: hidden;
background-color: #333;
width: 100%;
display: flex;
align-items: center;
justify-content: space-evenly;
height:60px;
}
.navbar-button {
float: left;
display: block;
text-align: center;
text-decoration: none;
width: 100%;
height: 100%;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
.unclickable {
color: #999;
}
.unclickable:hover {
cursor: default;
}
.clickable {
color: #f2f2f2;
}
.clickable:hover {
background: #ddd;
color: black;
cursor: pointer;
}
#distance-popup {
float: left;
width:250px;
display: none;
overflow-y: scroll;
height: calc(90vh - 130px);
}
button {
margin-top: 10px;
margin-bottom: 10px;
font-size: 20px;
}
#route-type-form {
text-align: right;
margin-right: 10px;
}
table {
width: 90%;
margin-left: auto;
margin-right: auto;
}
table th td {
border: 1px solid black;
}
tr:hover {
cursor: pointer;
}
tr input {
width: 110px;
}
.unchanged {
color: grey;
}
.wrong {
color: red;
}
#map {
height: calc(90vh - 130px);
}
#middle-popup {
position: fixed;
top: 5vh;
left: 10vw;
width: 80vw;
z-index: 1000;
background-color: var(--background-color);
border: solid 2px black;
box-shadow: 10px 10px rgba(0, 0, 0, 0.5);
display: none;
}
textarea {
width: 90%;
}
#distance-text-div {
position: fixed;
top: 150px;
width: 100vw;
z-index: 900;
display: flex;
justify-content: center;
}
#distance-text-div p {
background-color: rgba(70, 70, 70, 0.7);
color: white;
width: fit-content;
padding: 20px;
}
@media only screen and (max-height: 1100px) {
.big-screen {
display: none;
}
#map {
height: calc(100vh - 170px);
}
#distance-popup {
height: calc(100vh - 170px);
}
#distance-text {
top: 70px;
}
}
+109
View File
@@ -0,0 +1,109 @@
import { LatLng } from "leaflet";
export function parseUrl(url: string): [LatLng[] | null, [LatLng, LatLng, number][] | null, number[] | null] {
let parsedUrl = URL.parse(url);
var points: LatLng[] | null = null;
var distances: [LatLng, LatLng, number][] | null = null;
var path: number[] | null = null;
if (parsedUrl?.searchParams.has('points')) {
points = [];
let pointsString = parsedUrl.searchParams.get('points');
pointsString?.split('-').forEach(pointString => {
let splitString = pointString.split(',');
if (splitString && splitString.length == 2) {
let lat = parseFloat(splitString[0]);
let lng = parseFloat(splitString[1]);
if (!isNaN(lat) && !isNaN(lng)) {
points?.push(new LatLng(lat, lng));
}
}
});
if (points.length == 0) {
points = null;
}
}
if (parsedUrl?.searchParams.has('distances')) {
distances = [];
let distancesString = parsedUrl.searchParams.get('distances');
distancesString?.split('-').forEach(distanceString => {
let splitString = distanceString.split(',');
if (splitString && splitString.length == 3) {
let from = parseInt(splitString[0]);
let to = parseInt(splitString[1]);
let distance = parseFloat(splitString[2]);
if (!isNaN(from) && !isNaN(to) && !isNaN(distance) && points != null && from >= 0 && from < points.length && to >= 0 && to < points.length) {
distances?.push([points[from], points[to], distance]);
}
}
});
if (distances.length == 0) {
distances = null;
}
}
if (parsedUrl?.searchParams.has('path')) {
path = parsedUrl.searchParams.get('path')?.split(',').map(n => parseInt(n)).filter(n => !isNaN(n)) ?? null;
}
return [points, distances, path];
};
export function createUrl(clickedPoints: LatLng[] | null, definedDistances: [LatLng, LatLng, number][] | null, path: number[] | null): string {
if (!clickedPoints && !definedDistances && !path) {
return '';
}
let urlStrings = []
if(clickedPoints) {
let pointStrings = clickedPoints.map(latLng => latLng.lat.toFixed(6) + ',' + latLng.lng.toFixed(6));
if (pointStrings.length > 0) {
urlStrings.push('points=' + pointStrings.join('-'));
}
}
if (definedDistances && clickedPoints) {
let distancesStrings = [];
for (var i = 0; i < clickedPoints.length; i++) {
for (var j = i+1; j < clickedPoints.length; j++) {
let firstLatLng = clickedPoints[i];
let secondLatLng = clickedPoints[j];
let distance = definedDistances.find(d =>
(d[0] == firstLatLng && d[1] == secondLatLng)
|| (d[0] == secondLatLng && d[1] == secondLatLng)
);
if (distance) {
distancesStrings.push(i.toString() + ',' + j.toString() + ',' + distance[2].toFixed(3));
}
}
}
if (distancesStrings.length > 0) {
urlStrings.push('distances=' + distancesStrings.join('-'));
}
}
if (path && clickedPoints) {
let pathPoints = path.filter(n => n < clickedPoints.length).map(n => n.toString());
if (pathPoints.length > 0) {
urlStrings.push('path=' + pathPoints.join(','));
}
}
if (urlStrings.length > 0) {
return '?' + urlStrings.join('&');
} else {
return '';
}
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />