commit ccbf9c12a07d60a9a626424cc1b10877e31e29bd Author: Martin Asprusten Date: Thu Dec 25 02:14:20 2025 +0100 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3f405c6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,147 @@ +.vscode/ +dist/ + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.* +!.env.example + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist +.output + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp +.cache + +# Sveltekit cache directory +.svelte-kit/ + +# vitepress build output +**/.vitepress/dist + +# vitepress cache directory +**/.vitepress/cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# Firebase cache directory +.firebase/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v3 +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions + +# Vite files +vite.config.js.timestamp-* +vite.config.ts.timestamp-* +.vite/ + +# My personal shell script to upload to server +build_and_upload_to_server.sh diff --git a/index.html b/index.html new file mode 100644 index 0000000..eecc088 --- /dev/null +++ b/index.html @@ -0,0 +1,68 @@ + + + + + + Orientering + + + +

Korteste vei

+ +
+
+

Avstander

+ +
+ + +
+ + +
+ + +
+ + +
Avstand
+ +
+
+
+ +
+
+

Last inn

+ +
+ +
+ +
+
+

Orientering

+ + +
+ + +
+ +

+
+ +
+
+

+
+ + \ No newline at end of file diff --git a/native/salesman.cpp b/native/salesman.cpp new file mode 100644 index 0000000..6796209 --- /dev/null +++ b/native/salesman.cpp @@ -0,0 +1,425 @@ +#ifdef __EMSCRIPTEN__ +#include +#include +#endif + +#include +#include +#include +#include +#include + +struct City { + double x; + double y; +}; + +struct State{ + double** weights; + int** adjacencyMatrix; + double lowerBound; + bool finished = false; + std::vector> paths; +}; + +struct SplitState { + bool wasSplittable = true; + State firstState; + State secondState; +}; + + +double reduceWeights(double** weights, int numberOfCities) { + double totalReduced = 0; + + for (int row = 0; row < numberOfCities; row++) { + double smallestOnRow = 1e100; + for (int column = 0; column < numberOfCities; column++) { + if (weights[row][column] < smallestOnRow) { + smallestOnRow = weights[row][column]; + } + } + + if (smallestOnRow > 1e50) { + continue; + } + + totalReduced += smallestOnRow; + + for (int column = 0; column < numberOfCities; column++) { + weights[row][column] -= smallestOnRow; + } + } + + for (int column = 0; column < numberOfCities; column++) { + double smallestOnColumn = 1e100; + for (int row = 0; row < numberOfCities; row++) { + if (weights[row][column] < smallestOnColumn) { + smallestOnColumn = weights[row][column]; + } + } + + if (smallestOnColumn > 1e50) { + continue; + } + + totalReduced += smallestOnColumn; + + for (int row = 0; row < numberOfCities; row++) { + weights[row][column] -= smallestOnColumn; + } + } + + return totalReduced; +} + +std::pair findPivotPoint(double** weights, int numberOfCities) { + int bestRow = -1; + int bestColumn = -1; + double bestIncrease = -1; + + for (int testRow = 0; testRow < numberOfCities; testRow++) { + for (int testColumn = 0; testColumn < numberOfCities; testColumn++) { + // Only look for pivot cells that have a value of zero + if (weights[testRow][testColumn] > 0.0001) { + continue; + } + + double smallestOnRow = 1e100; + double smallestOnColumn = 1e100; + + for (int otherColumn = 0; otherColumn < numberOfCities; otherColumn++) { + if (otherColumn == testColumn) { + continue; + } + + if (weights[testRow][otherColumn] < smallestOnRow) { + smallestOnRow = weights[testRow][otherColumn]; + } + } + + for (int otherRow = 0; otherRow < numberOfCities; otherRow++) { + if (otherRow == testRow) { + continue; + } + + if (weights[otherRow][testColumn] < smallestOnColumn) { + smallestOnColumn = weights[otherRow][testColumn]; + } + } + + double totalIncrease = smallestOnRow + smallestOnColumn; + if (totalIncrease > bestIncrease) { + bestIncrease = totalIncrease; + bestRow = testRow; + bestColumn = testColumn; + } + } + } + + return std::pair(bestRow, bestColumn); +} + +int getDegreeOfNode(int nodeNumber, int** adjacencyMatrix, int numberOfCities) { + int degree = 0; + for (int column = 0; column < numberOfCities; column++) { + degree += adjacencyMatrix[nodeNumber][column]; + } + return degree; +} + +void multiplyAdjacencyMatrices(int** matrix, int** multiplier, int size) { + int** temporary = new int*[size]; + for (int row = 0; row < size; row++) { + temporary[row] = new int[size]; + for (int column = 0; column < size; column++) { + int result = 0; + for (int k = 0; k < size; k++) { + result += matrix[row][k] * multiplier[k][column]; + } + temporary[row][column] = result; + } + } + + // Copy into original matrix + for (int row = 0; row < size; row++) { + for (int column = 0; column < size; column++) { + matrix[row][column] = temporary[row][column]; + } + delete [] temporary[row]; + } + delete [] temporary; +} + +void disallowSubloops(State &state, int pivotRow, int pivotColumn, int numberOfCities) { + // All points with degree one are endpoint + int totalDegree = 0; + for (int node = 0; node < numberOfCities; node++) { + int degree = getDegreeOfNode(node, state.adjacencyMatrix, numberOfCities); + totalDegree += degree; + if (degree == 2) { + for (int otherNode = 0; otherNode < numberOfCities; otherNode++) { + state.weights[node][otherNode] = 1e100; + state.weights[otherNode][node] = 1e100; + } + } + } + + // Add this path to the existing paths + std::pair path(pivotRow, pivotColumn); + auto iterator = state.paths.begin(); + while (iterator != state.paths.end()) { + std::pair otherPath = *iterator; + + bool expandsPath = false; + if (path.first == otherPath.first) { + expandsPath = true; + path = std::pair(path.second, otherPath.second); + } else if (path.first == otherPath.second) { + expandsPath = true; + path = std::pair(path.second, otherPath.first); + } else if (path.second == otherPath.first) { + expandsPath = true; + path = std::pair(path.first, otherPath.second); + } else if (path.second == otherPath.second) { + expandsPath = true; + path = std::pair(path.first, otherPath.first); + } + + if (expandsPath) { + iterator = state.paths.erase(iterator); + } else { + iterator++; + } + } + + state.paths.push_back(path); + + // There needs to be n edges in a loop, and so the total degree should be 2*n. If we're getting close to this, don't + // block of the ability to finish a loop + if (state.paths.size() == 1 && totalDegree == 2*(numberOfCities - 1)) { + // Finish the loop + int startingNode = state.paths.at(0).first; + int endingNode = state.paths.at(0).second; + + state.adjacencyMatrix[startingNode][endingNode] = 1; + state.adjacencyMatrix[endingNode][startingNode] = 1; + + state.finished = true; + + return; + } + + for (std::pair path : state.paths) { + state.weights[path.first][path.second] = 1e100; + state.weights[path.second][path.first] = 1e100; + } +} + +State createNewStateOnPivot(int pivotRow, int pivotColumn, State originalState, int numberOfCities) { + int** adjacencyMatrixCopy = new int*[numberOfCities]; + double** weightsCopy = new double*[numberOfCities]; + + for (int row = 0; row < numberOfCities; row++) { + adjacencyMatrixCopy[row] = new int[numberOfCities]; + weightsCopy[row] = new double[numberOfCities]; + for (int column = 0; column < numberOfCities; column++) { + adjacencyMatrixCopy[row][column] = originalState.adjacencyMatrix[row][column]; + weightsCopy[row][column] = originalState.weights[row][column]; + } + } + + adjacencyMatrixCopy[pivotRow][pivotColumn] = 1; + adjacencyMatrixCopy[pivotColumn][pivotRow] = 1; + + for (int column = 0; column < numberOfCities; column++) { + weightsCopy[pivotRow][column] = 1e100; + } + + for (int row = 0; row < numberOfCities; row++) { + weightsCopy[row][pivotColumn] = 1e100; + } + + weightsCopy[pivotColumn][pivotRow] = 1e100; + + State pivotedState; + pivotedState.adjacencyMatrix = adjacencyMatrixCopy; + pivotedState.weights = weightsCopy; + pivotedState.lowerBound = originalState.lowerBound; + for (std::pair path : originalState.paths) { + pivotedState.paths.push_back(std::pair(path.first, path.second)); + } + + disallowSubloops(pivotedState, pivotRow, pivotColumn, numberOfCities); + + return pivotedState; +} + +SplitState splitState(State state, int numberOfCities) { + SplitState splitState; + + double reduction = reduceWeights(state.weights, numberOfCities); + state.lowerBound += reduction; + + std::pair pivotPoint = findPivotPoint(state.weights, numberOfCities); + if (pivotPoint.first == -1 || pivotPoint.second == -1) { + splitState.wasSplittable = false; + + for (int row = 0; row < numberOfCities; row++) { + delete [] state.adjacencyMatrix[row]; + delete [] state.weights[row]; + } + + delete [] state.adjacencyMatrix; + delete [] state.weights; + + return splitState; + } + State pivotedState = createNewStateOnPivot(pivotPoint.first, pivotPoint.second, state, numberOfCities); + + // Disallow the pivot in the original state + state.weights[pivotPoint.first][pivotPoint.second] = 1e100; + + splitState.firstState = pivotedState; + splitState.secondState = state; + return splitState; +} + +double getActualWeight(int** adjacencyMatrix, double** weights, int numberOfCities) { + double totalWeight = 0; + for (int row = 0; row < numberOfCities; row++) { + for (int column = 0; column < numberOfCities; column++) { + totalWeight += adjacencyMatrix[row][column] * weights[row][column]; + } + } + + return totalWeight / 2; +} + +class StateComparator { + public: + bool operator()(State one, State two) { + return one.lowerBound > two.lowerBound; + } +}; + +std::vector findShortestPath(std::vector> jsWeights) { + + int numberOfCities = jsWeights.size(); + + double** weights = new double*[numberOfCities]; + for (int row = 0; row < numberOfCities; row++) { + weights[row] = new double[numberOfCities]; + for (int column = 0; column < numberOfCities; column++) { + weights[row][column] = jsWeights.at(row).at(column); + if (column == row) { + weights[row][column] = 1e100; + } + } + } + + + // Create a state + State initialState; + initialState.weights = new double*[numberOfCities]; + initialState.adjacencyMatrix = new int*[numberOfCities]; + for (int row = 0; row < numberOfCities; row++) { + initialState.weights[row] = new double[numberOfCities]; + initialState.adjacencyMatrix[row] = new int[numberOfCities]; + for (int column = 0; column < numberOfCities; column++) { + initialState.adjacencyMatrix[row][column] = 0; + initialState.weights[row][column] = weights[row][column]; + } + } + initialState.lowerBound = 0; + + std::priority_queue, StateComparator> queue; + queue.push(initialState); + + double currentBest = 1e100; + int** bestAdjacenyMatrix = NULL; + + while (!queue.empty()) { + State nextState = queue.top(); + queue.pop(); + + if (nextState.lowerBound > currentBest) { + break; + } + + if (nextState.finished) { + double actualWeight = getActualWeight(nextState.adjacencyMatrix, weights, numberOfCities); + if (actualWeight < currentBest) { + currentBest = actualWeight; + bestAdjacenyMatrix = nextState.adjacencyMatrix; + } else { + // Delete and clean up memory + for (int row = 0; row < numberOfCities; row++) { + delete [] nextState.adjacencyMatrix[row]; + } + delete [] nextState.adjacencyMatrix; + } + + for (int row = 0; row < numberOfCities; row++) { + delete [] nextState.weights[row]; + } + delete [] nextState.weights; + + continue; + } + + // If we're not finished, split the state and add the new ones to the queue + SplitState split = splitState(nextState, numberOfCities); + if (split.wasSplittable) { + queue.push(split.firstState); + queue.push(split.secondState); + } + } + + // Clear up remaining queue for memory + while (!queue.empty()) { + State stateToDelete = queue.top(); + queue.pop(); + + for (int row = 0; row < numberOfCities; row++) { + delete [] stateToDelete.adjacencyMatrix[row]; + delete [] stateToDelete.weights[row]; + } + delete [] stateToDelete.adjacencyMatrix; + delete [] stateToDelete.weights; + } + + std::vector path; + + int current = 0; + int previous = -1; + + path.push_back(current); + + bool firstTime = true; + while (current != 0 || firstTime) { + firstTime = false; + + for (int column = 0; column < numberOfCities; column++) { + if (bestAdjacenyMatrix[current][column] == 1 && column != previous) { + previous = current; + current = column; + break; + } + } + + path.push_back(current); + } + + return path; +} + +#ifdef __EMSCRIPTEN__ +EMSCRIPTEN_BINDINGS(my_module) { + emscripten::register_vector("WeightsRow"); + emscripten::register_vector>("Weights"); + emscripten::register_vector("Path"); + + emscripten::function("findShortestPath", &findShortestPath); +} +#endif \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..9c8c26a --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1150 @@ +{ + "name": "orienteering", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "orienteering", + "version": "1.0.0", + "dependencies": { + "@terraformer/wkt": "^2.2.1", + "@types/geojson": "^7946.0.16", + "@types/leaflet": "^1.9.21", + "haversine-distance": "^1.2.4", + "leaflet": "^1.9.4", + "openrouteservice-js": "^0.4.1" + }, + "devDependencies": { + "@types/node": "^25.0.3", + "@types/terraformer__wkt": "^2.0.3", + "typescript": "^5.9.3", + "vite": "^7.3.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.54.0.tgz", + "integrity": "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.54.0.tgz", + "integrity": "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.54.0.tgz", + "integrity": "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.54.0.tgz", + "integrity": "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.54.0.tgz", + "integrity": "sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.54.0.tgz", + "integrity": "sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.54.0.tgz", + "integrity": "sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.54.0.tgz", + "integrity": "sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.54.0.tgz", + "integrity": "sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.54.0.tgz", + "integrity": "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.54.0.tgz", + "integrity": "sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.54.0.tgz", + "integrity": "sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.54.0.tgz", + "integrity": "sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.54.0.tgz", + "integrity": "sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.54.0.tgz", + "integrity": "sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.54.0.tgz", + "integrity": "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.54.0.tgz", + "integrity": "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.54.0.tgz", + "integrity": "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.54.0.tgz", + "integrity": "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.54.0.tgz", + "integrity": "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.54.0.tgz", + "integrity": "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.54.0.tgz", + "integrity": "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@terraformer/wkt": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@terraformer/wkt/-/wkt-2.2.1.tgz", + "integrity": "sha512-XDUsW/lvbMzFi7GIuRD9+UqR4QyP+5C+TugeJLMDczKIRbaHoE9J3N8zLSdyOGmnJL9B6xTS3YMMlBnMU0Ar5A==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, + "node_modules/@types/leaflet": { + "version": "1.9.21", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz", + "integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/node": { + "version": "25.0.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.3.tgz", + "integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/terraformer__wkt": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/terraformer__wkt/-/terraformer__wkt-2.0.3.tgz", + "integrity": "sha512-60CGvi30kMIKl2QERrE6LD5iPm4lutZ1M/mqBY4wrn6H/QlZQa/5CN1e6trZ6ZtSRHLbHLwG+egt/nAIDbPG0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/haversine-distance": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/haversine-distance/-/haversine-distance-1.2.4.tgz", + "integrity": "sha512-8MHQBQXR7GXZJIvitCf/ux+gwLtWOxmXNNz5dReG+jJbuvaEoj6QIXlaIO3cPr433BFWM4jececJaLYpwxnZhg==", + "license": "MIT" + }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/openrouteservice-js": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/openrouteservice-js/-/openrouteservice-js-0.4.1.tgz", + "integrity": "sha512-Oeb/KgzaYXEtafSHB40KfZvHFfTSPhtt0/oEf0jv5o5Ljw3//+C63CFxbknOqDBrOkYLLQMMCjJGa54rUOBtLg==", + "license": "Apache-2.0" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.54.0.tgz", + "integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.54.0", + "@rollup/rollup-android-arm64": "4.54.0", + "@rollup/rollup-darwin-arm64": "4.54.0", + "@rollup/rollup-darwin-x64": "4.54.0", + "@rollup/rollup-freebsd-arm64": "4.54.0", + "@rollup/rollup-freebsd-x64": "4.54.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.54.0", + "@rollup/rollup-linux-arm-musleabihf": "4.54.0", + "@rollup/rollup-linux-arm64-gnu": "4.54.0", + "@rollup/rollup-linux-arm64-musl": "4.54.0", + "@rollup/rollup-linux-loong64-gnu": "4.54.0", + "@rollup/rollup-linux-ppc64-gnu": "4.54.0", + "@rollup/rollup-linux-riscv64-gnu": "4.54.0", + "@rollup/rollup-linux-riscv64-musl": "4.54.0", + "@rollup/rollup-linux-s390x-gnu": "4.54.0", + "@rollup/rollup-linux-x64-gnu": "4.54.0", + "@rollup/rollup-linux-x64-musl": "4.54.0", + "@rollup/rollup-openharmony-arm64": "4.54.0", + "@rollup/rollup-win32-arm64-msvc": "4.54.0", + "@rollup/rollup-win32-ia32-msvc": "4.54.0", + "@rollup/rollup-win32-x64-gnu": "4.54.0", + "@rollup/rollup-win32-x64-msvc": "4.54.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz", + "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..831071d --- /dev/null +++ b/package.json @@ -0,0 +1,28 @@ +{ + "name": "orienteering", + "version": "1.0.0", + "private": true, + "description": "Tries to find the shortest path through a set of given points, i.e. solve the traveling salesman problem", + "type": "module", + "scripts": { + "dev": "vite --host 0.0.0.0", + "build:emscripten": "cd native && emcc -O3 -lembind -sEXPORT_ES6 -sASSERTIONS -sEXPORTED_RUNTIME_METHODS=FS -sALLOW_MEMORY_GROWTH -sMAXIMUM_MEMORY=4294967296 --emit-tsd salesman.d.ts -o salesman.js salesman.cpp", + "build:normal": "tsc && vite build", + "build": "npm run build:emscripten && npm run build:normal", + "preview": "vite preview" + }, + "devDependencies": { + "@types/node": "^25.0.3", + "@types/terraformer__wkt": "^2.0.3", + "typescript": "^5.9.3", + "vite": "^7.3.0" + }, + "dependencies": { + "@terraformer/wkt": "^2.2.1", + "@types/geojson": "^7946.0.16", + "@types/leaflet": "^1.9.21", + "haversine-distance": "^1.2.4", + "leaflet": "^1.9.4", + "openrouteservice-js": "^0.4.1" + } +} diff --git a/src/declarations.d.ts b/src/declarations.d.ts new file mode 100644 index 0000000..d4c4dec --- /dev/null +++ b/src/declarations.d.ts @@ -0,0 +1 @@ +declare module 'openrouteservice-js'; \ No newline at end of file diff --git a/src/files/ppenhandler.ts b/src/files/ppenhandler.ts new file mode 100644 index 0000000..3b0b235 --- /dev/null +++ b/src/files/ppenhandler.ts @@ -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; \ No newline at end of file diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..1d5ff8a --- /dev/null +++ b/src/main.ts @@ -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(); + } +}) diff --git a/src/map/maphandler.ts b/src/map/maphandler.ts new file mode 100644 index 0000000..37638dc --- /dev/null +++ b/src/map/maphandler.ts @@ -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 | null; + + markerDebounce: Map = 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: '© OpenStreetMap 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; \ No newline at end of file diff --git a/src/style.css b/src/style.css new file mode 100644 index 0000000..6a2d3ed --- /dev/null +++ b/src/style.css @@ -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; + } +} \ No newline at end of file diff --git a/src/url/UrlHandler.ts b/src/url/UrlHandler.ts new file mode 100644 index 0000000..1775bee --- /dev/null +++ b/src/url/UrlHandler.ts @@ -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 ''; + } +} \ No newline at end of file diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..151aa68 --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1 @@ +/// \ No newline at end of file diff --git a/static/marker-icon-2x-grey.png b/static/marker-icon-2x-grey.png new file mode 100644 index 0000000..43b3eb4 Binary files /dev/null and b/static/marker-icon-2x-grey.png differ diff --git a/static/marker-icon-2x-red.png b/static/marker-icon-2x-red.png new file mode 100644 index 0000000..1c26e9f Binary files /dev/null and b/static/marker-icon-2x-red.png differ diff --git a/static/marker-shadow.png b/static/marker-shadow.png new file mode 100644 index 0000000..84c5808 Binary files /dev/null and b/static/marker-shadow.png differ diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..4f5edc2 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +}