From e41368743540f757d85336faca62a3e7c2172d5e Mon Sep 17 00:00:00 2001 From: Martin Asprusten Date: Mon, 30 Jun 2025 01:15:53 +0200 Subject: [PATCH] Initial commit of a working website --- .gitignore | 4 + README.md | 12 +- index.html | 92 ++ native/route_search.cpp | 798 ++++++++++++++ package-lock.json | 1401 +++++++++++++++++++++++++ package.json | 25 + src/interfaces.ts | 117 +++ src/main.ts | 374 +++++++ src/modules/maphandler/icons.ts | 93 ++ src/modules/maphandler/maphandler.css | 11 + src/modules/maphandler/maphandler.ts | 347 ++++++ src/modules/worker/worker.ts | 191 ++++ src/style.css | 74 ++ src/vite-env.d.ts | 1 + static/bicycle-solid.svg | 1 + static/marker-icon-2x-black.png | Bin 0 -> 3183 bytes static/marker-icon-2x-blue.png | Bin 0 -> 4033 bytes static/marker-icon-2x-gold.png | Bin 0 -> 4274 bytes static/marker-icon-2x-green.png | Bin 0 -> 4203 bytes static/marker-icon-2x-grey.png | Bin 0 -> 3534 bytes static/marker-icon-2x-orange.png | Bin 0 -> 4167 bytes static/marker-icon-2x-red.png | Bin 0 -> 4230 bytes static/marker-icon-2x-violet.png | Bin 0 -> 4190 bytes static/marker-icon-2x-yellow.png | Bin 0 -> 4159 bytes static/marker-shadow.png | Bin 0 -> 608 bytes tsconfig.json | 25 + 26 files changed, 3565 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 index.html create mode 100644 native/route_search.cpp create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/interfaces.ts create mode 100644 src/main.ts create mode 100644 src/modules/maphandler/icons.ts create mode 100644 src/modules/maphandler/maphandler.css create mode 100644 src/modules/maphandler/maphandler.ts create mode 100644 src/modules/worker/worker.ts create mode 100644 src/style.css create mode 100644 src/vite-env.d.ts create mode 100644 static/bicycle-solid.svg create mode 100644 static/marker-icon-2x-black.png create mode 100644 static/marker-icon-2x-blue.png create mode 100644 static/marker-icon-2x-gold.png create mode 100644 static/marker-icon-2x-green.png create mode 100644 static/marker-icon-2x-grey.png create mode 100644 static/marker-icon-2x-orange.png create mode 100644 static/marker-icon-2x-red.png create mode 100644 static/marker-icon-2x-violet.png create mode 100644 static/marker-icon-2x-yellow.png create mode 100644 static/marker-shadow.png create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5dc90e4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +dist/ +native/* +node_modules/ +!native/route_search.cpp diff --git a/README.md b/README.md index 0de05c7..b0b2942 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,13 @@ # Freewheeling -A website for calculating how far you can ride your bike without pedalling \ No newline at end of file +A website for calculating how far you can ride your bike without pedalling. + +To run, it requires a file named 'roads.dat', which contains road data for all of Norway. This file is almost 700 MB large, so it is not included here. It can be downloaded from the [Freewheeling website.](https://freewheeling.martinserver.no) At some point, I may add the code for generating the file to this repository. + +This repository requires emscripten in order to build webassembly from the C++ code in the native folder. Before you can run npm run build or npm run build:emscripten, the emcc binary needs to be added to the PATH. + +## To run +First, run npm run build:emscripten, then run npx vite. + +## To build +Run npm run build. This will first run the emscripten build, and then the typescript build. The resulting website ends up in the dist/ folder \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 0000000..e59f14f --- /dev/null +++ b/index.html @@ -0,0 +1,92 @@ + + + + + + + Freewheeling + + + +

Freewheeling

+
+

+ This web page allows you to calculate how far you can roll on a bicycle without using the pedals. It uses road data for + all of Norway, downloaded from Kartkatalogen at Geonorge. + Since I don't have a very powerful server, all calculations are done locally in your browser. +

+

+ These calculations require a data file containing road data for Norway. Since this is a large data file (of about + 700 MB), this file is not downloaded automatically when the page is loaded. Instead, it can be manually downloaded from + here. Once the file is downloaded, use the file browser below to provide it to the web page. + Note that this does not upload the file anywhere, it just tells the browser to load this file. +

+ +
+
+

Loading data...

+

This might take a few seconds.

+
+
+
+
+ +

+ + + + + + + + + +
+ + +

Instructions:

+

+ Click anywhere on the map to calculate where it is possible to freewheel from that point. A set of possible + end points will then appear on the map. The farthest possible end point is shown in red, while other possible + end points are shown in violet. Click an endpoint to see the calculated route that leads from the start point + to the end point. +

+

+ It is possible to mark areas on the map you would like to avoid. Click the "Draw areas that should not be + entered" button at the top left of the map. It is marked with this icon: . + Any routes calculated will then avoid the areas drawn. +

+

+ It is also possible to search for the longest path that starts within an area. In order to do that, click the + "Define the search area for the longest route" button with the icon + at the top left of the map. After you've drawn the search area on the map, click the "Start search" button. + A table will show a running tally of the longest routes found. +

+
+ + diff --git a/native/route_search.cpp b/native/route_search.cpp new file mode 100644 index 0000000..63d7cb2 --- /dev/null +++ b/native/route_search.cpp @@ -0,0 +1,798 @@ +#include +#include + +#include +#include +#include +#include +#include + +// Constants +const float GRAVITY_ACCELERATION = 9.81; + + +// Data structures +struct Connection { + int connected_point_number; + float distance; + uint8_t speed_limit; + bool motorway; + bool tunnel; + bool againstOneWay; +}; + +struct RoadNode{ + float position_x; + float position_y; + float position_z; + + Connection connection_one; + Connection connection_two; + std::vector *extra_connections; +}; + +struct RoadNodeSet { + uint32_t numberOfNodes; + RoadNode* roadNodes; +}; + +struct SearchNodeInfo { + float distanceFromPrevious; + float currentSpeed; +}; + +struct SearchResult { + uint32_t startingNode; + std::map previous; + std::map reachableNodes; +}; + +struct JSNodeInfo { + uint32_t nodeId; + float positionX; + float positionY; + float positionZ; + float distanceFromStart; + float currentSpeed; +}; + +struct JSSearchResult { + std::vector endPoints; +}; + +struct ListNode { + int id; + float current_speed; + + ListNode* next = NULL; +}; + +struct PolygonCoordinate { + float x; + float y; +}; + +struct AreaSearchEntry { + uint32_t nodeId; + float positionX; + float positionY; + float longestRoute; +}; + +struct AreaSearchResult { + uint32_t remainingNodes; + std::vector searchedNodes; +}; + +struct CurrentAreaSearch { + float minimumSpeed; + float maximumSpeed; + float maximumSpeedLimit; + float dragCoefficient; + bool allowMotorways; + bool allowTunnels; + bool allowAgainstOneway; + + std::vector startNodes; + size_t startNodeCounter = 0; + std::vector currentAreaSearchResults; + std::set utilizedNodes; +}; + +// Data +RoadNodeSet set; +SearchResult lastSearchResult; +std::set excludedNodes; + +CurrentAreaSearch currentAreaSearch; + +// Data loading functions +float getFloatFromBuffer(char* buffer) { + // First, cast the buffer to an int, in order to reverse bytes from network order to host order + uint32_t correctByteOrder = ntohl(*reinterpret_cast(buffer)); + return *((float*) &correctByteOrder); +} + +void addLinkToRoadNode(RoadNode* roadNodes, int node_from, int node_to, uint8_t speed_limit, bool motorway, bool tunnel, bool againstOneWay) { + float from_x = roadNodes[node_from].position_x; + float from_y = roadNodes[node_from].position_y; + float to_x = roadNodes[node_to].position_x; + float to_y = roadNodes[node_to].position_y; + + float distance = sqrt(pow(to_x - from_x, 2) + pow(to_y - from_y, 2)); + + Connection connection; + connection.connected_point_number = node_to; + connection.distance = distance; + connection.speed_limit = speed_limit; + connection.motorway = motorway; + connection.tunnel = tunnel; + connection.againstOneWay = againstOneWay; + + if (roadNodes[node_from].connection_one.connected_point_number == -1) { + roadNodes[node_from].connection_one = connection; + } else if (roadNodes[node_from].connection_two.connected_point_number == -1) { + roadNodes[node_from].connection_two = connection; + } else { + if (roadNodes[node_from].extra_connections == NULL) { + roadNodes[node_from].extra_connections = new std::vector(); + } + + roadNodes[node_from].extra_connections->push_back(connection); + } +} + +void loadData(std::string filePath) { + set = RoadNodeSet(); + + std::ifstream source(filePath.c_str(), std::ios_base::binary); + char *buffer = new char[4]; + source.read(buffer, 4); + uint32_t number_of_entries = ntohl(*reinterpret_cast(buffer)); + std::cout << "Reading " << number_of_entries << " road nodes" << std::endl; + + // Create the memory space for all these road nodes + set.numberOfNodes = number_of_entries; + set.roadNodes = new RoadNode[number_of_entries]; + for (size_t i = 0; i < number_of_entries; i++) { + // Each node in the file is of the type float x, float y, short z + source.read(buffer, 4); + // First, cast this to an int, reverse the byte order, and then cast to float + float position_x = getFloatFromBuffer(buffer); + source.read(buffer, 4); + float position_y = getFloatFromBuffer(buffer); + source.read(buffer, 2); + int position_z_int = ntohs(*reinterpret_cast(buffer)); + float position_z = (position_z_int - 30000) / 10.0; + + set.roadNodes[i].position_x = position_x; + set.roadNodes[i].position_y = position_y; + set.roadNodes[i].position_z = position_z; + + set.roadNodes[i].connection_one.connected_point_number = -1; + set.roadNodes[i].connection_two.connected_point_number = -1; + + set.roadNodes[i].extra_connections = NULL; + } + + source.read(buffer, 4); + uint32_t number_of_links = ntohl(*reinterpret_cast(buffer)); + std::cout << "Reading " << number_of_links << " links" << std::endl; + + // Read all the links between nodes + int connection_vectors = 0; + for (size_t i = 0; i < number_of_links; i++) { + source.read(buffer, 4); + uint32_t from_point = ntohl(*reinterpret_cast(buffer)); + source.read(buffer, 4); + uint32_t to_point = ntohl(*reinterpret_cast(buffer)); + + source.read(buffer, 1); + uint8_t flags_byte = *reinterpret_cast(buffer); + + uint8_t speed_limit = (flags_byte >> 4) * 10; + + bool motorway = (flags_byte & 0x01) > 0; + bool tunnel = (flags_byte & 0x02) > 0; + bool passable_same_direction = (flags_byte & 0x04) > 0; + bool passable_opposite_direction = (flags_byte & 0x08) > 0; + + addLinkToRoadNode(set.roadNodes, from_point, to_point, speed_limit, motorway, tunnel, !passable_same_direction); + addLinkToRoadNode(set.roadNodes, to_point, from_point, speed_limit, motorway, tunnel, !passable_opposite_direction); + } + + delete[] buffer; +} + +// Search functions +JSNodeInfo findClosestNode(float position_x, float position_y) { + float closestDistance = 1e99; + uint32_t closestNode = 0; + + for (size_t i = 0; i < set.numberOfNodes; i++) { + RoadNode node = set.roadNodes[i]; + float node_x = node.position_x; + float node_y = node.position_y; + + float distance = sqrt(pow(position_x - node_x, 2) + pow(position_y - node_y, 2)); + if (distance < closestDistance) { + closestDistance = distance; + closestNode = i; + } + } + + JSNodeInfo result; + result.nodeId = closestNode; + result.positionX = set.roadNodes[closestNode].position_x; + result.positionY = set.roadNodes[closestNode].position_y; + result.positionZ = set.roadNodes[closestNode].position_z; + + return result; +} + +float calculate_speed(float starting_speed, float horizontal_distance, float height_difference, float minimum_speed, float maximum_speed, float drag_coefficient) { + float slope_tan = height_difference / horizontal_distance; + float final_speed = -1; + + // If the slope is flat, that is one calculation + if (fabs(slope_tan) < 0.0001) { + float time_to_finish = (exp(horizontal_distance * drag_coefficient) - 1) / (starting_speed * drag_coefficient); + final_speed = starting_speed / (starting_speed * drag_coefficient * time_to_finish + 1); + } else { + // Otherwise, we need to find some parameters + float slope = atan(slope_tan); + float slope_sin = sin(slope); + float full_distance = horizontal_distance * slope_tan / slope_sin; + float acceleration = -GRAVITY_ACCELERATION * slope_sin; + float terminal_velocity = sqrt(fabs(acceleration) / drag_coefficient); + + // Uphill + if (slope > 0) { + float time_to_peak = atan(starting_speed / terminal_velocity) / (drag_coefficient * terminal_velocity); + // If the discriminant is greater than 1, the slope is so steep that we cannot reach the end with our starting speed + float discriminant = cos(drag_coefficient * terminal_velocity * time_to_peak) * exp(full_distance * drag_coefficient); + if (discriminant > 1.f) { + return -1; + } + + float time_to_reach_end = time_to_peak - acos(discriminant) / (drag_coefficient * terminal_velocity); + final_speed = terminal_velocity * tan(drag_coefficient * terminal_velocity * (time_to_peak - time_to_reach_end)); + } else { + // Downhill + // If the starting speed is very close to the terminal velocity, we'll just stay at terminal velocity + if (fabs(starting_speed - terminal_velocity) < 0.001) { + final_speed = terminal_velocity; + } else if (starting_speed < terminal_velocity) { + float k1 = terminal_velocity * log((terminal_velocity + starting_speed) / (terminal_velocity - starting_speed)) * 0.5; + float k2 = -log(cosh(k1 / terminal_velocity)) / drag_coefficient; + float time_spent = acosh(exp(drag_coefficient * (full_distance - k2))) / (drag_coefficient * terminal_velocity) - k1 / (drag_coefficient * pow(terminal_velocity, 2)); + final_speed = terminal_velocity * tanh(drag_coefficient * terminal_velocity * time_spent + k1 / terminal_velocity); + } else if (starting_speed > terminal_velocity) { + float k1 = log((starting_speed - terminal_velocity) / (starting_speed + terminal_velocity)) * terminal_velocity / 2; + float k2 = -log(-sinh(k1 / terminal_velocity)) / drag_coefficient; + float time_spent = k1 / (drag_coefficient * pow(terminal_velocity, 2)) - asinh(-exp(drag_coefficient * (full_distance - k2))) / (drag_coefficient * terminal_velocity); + final_speed = -terminal_velocity / tanh(k1 / terminal_velocity - drag_coefficient * terminal_velocity * time_spent); + } + } + } + + if (final_speed < minimum_speed) { + return -1; + } else { + return std::min(final_speed, maximum_speed); + } +} + +void getNeighbourConnections(RoadNode node, Connection* targetArray, int &numberOfConnections) { + numberOfConnections = 0; + if (node.connection_one.connected_point_number != -1) { + *(targetArray + numberOfConnections) = node.connection_one; + numberOfConnections++; + } + if (node.connection_two.connected_point_number != -1) { + *(targetArray + numberOfConnections) = node.connection_two; + numberOfConnections++; + } + if (node.extra_connections != NULL) { + for (auto it = node.extra_connections->begin(); it != node.extra_connections->end(); it++) { + *(targetArray + numberOfConnections) = *it; + numberOfConnections++; + } + } +} + +SearchResult findAllPathsFromPoint(int startingNode, float minimum_speed, float maximum_speed, int maximumSpeedLimit, float drag_coefficient, bool allowMotorways, bool allowTunnels, bool allowAgainstOneway) { + SearchResult result; + result.startingNode = startingNode; + + RoadNode firstNode = set.roadNodes[startingNode]; + SearchNodeInfo firstNodeInfo; + firstNodeInfo.distanceFromPrevious = 0; + firstNodeInfo.currentSpeed = minimum_speed; + result.reachableNodes[startingNode] = firstNodeInfo; + + ListNode *nextNode = new ListNode; + ListNode *lastNode = nextNode; + + nextNode->id = startingNode; + nextNode->current_speed = minimum_speed; + + while (nextNode != NULL) { + ListNode *currentNode = nextNode; + nextNode = currentNode->next; + + int currentId = currentNode->id; + float currentSpeed = currentNode->current_speed; + + RoadNode bestNode = set.roadNodes[currentId]; + + if (lastNode == currentNode) { + lastNode = NULL; + } + delete currentNode; + + Connection neighbours[10]; + int neighbourCounter = 0; + getNeighbourConnections(bestNode, neighbours, neighbourCounter); + + for (int i = 0; i < neighbourCounter; i++) { + Connection neighbour = neighbours[i]; + // First, if neighbour is in excluded area, skip it + if (excludedNodes.find(neighbour.connected_point_number) != excludedNodes.end()) { + continue; + } + + if (neighbour.speed_limit > maximumSpeedLimit) { + continue; + } + + if (neighbour.motorway && !allowMotorways) { + continue; + } + + if (neighbour.tunnel && !allowTunnels) { + continue; + } + + if (neighbour.againstOneWay && ! allowAgainstOneway) { + continue; + } + + RoadNode neighbourNode = set.roadNodes[neighbour.connected_point_number]; + float heightDifference = neighbourNode.position_z - bestNode.position_z; + float resultingSpeed = calculate_speed(currentSpeed, neighbour.distance, heightDifference, minimum_speed, maximum_speed, drag_coefficient); + + if (resultingSpeed < 0) { + continue; + } + + // Check if this node is already in the reachable nodes map + auto resultIterator = result.reachableNodes.find(neighbour.connected_point_number); + if (resultIterator != result.reachableNodes.end() && resultingSpeed <= resultIterator->second.currentSpeed) { + continue; + } + + SearchNodeInfo reachableNodeInfo; + reachableNodeInfo.currentSpeed = resultingSpeed; + reachableNodeInfo.distanceFromPrevious = neighbour.distance; + result.reachableNodes[neighbour.connected_point_number] = reachableNodeInfo; + + result.previous[neighbour.connected_point_number] = currentId; + + ListNode *neighbourListNode = new ListNode; + neighbourListNode->id = neighbour.connected_point_number; + neighbourListNode->current_speed = reachableNodeInfo.currentSpeed; + + if (nextNode == NULL || resultingSpeed < nextNode->current_speed) { + neighbourListNode->next = nextNode; + nextNode = neighbourListNode; + } else { + ListNode* previousSearchNode = nextNode; + ListNode* currentSearchNode = nextNode->next; + + while(currentSearchNode != NULL && currentSearchNode->current_speed > resultingSpeed) { + previousSearchNode = currentSearchNode; + currentSearchNode = currentSearchNode->next; + } + + previousSearchNode->next = neighbourListNode; + neighbourListNode->next = currentSearchNode; + } + } + } + + return result; +} + +JSSearchResult findAllPathsFromPointJS(int startingNode, float minimumSpeed, float maximumSpeed, int maximumSpeedLimit, float dragCoefficient, bool allowMotorways, bool allowTunnels, bool allowAgainstOneway) { + lastSearchResult = findAllPathsFromPoint(startingNode, minimumSpeed, maximumSpeed, maximumSpeedLimit, dragCoefficient, allowMotorways, allowTunnels, allowAgainstOneway); + + float start_x = set.roadNodes[startingNode].position_x; + float start_y = set.roadNodes[startingNode].position_y; + + // Find all end points and sort them by distance + std::set notEndPoints; + for (auto it = lastSearchResult.previous.begin(); it != lastSearchResult.previous.end(); it++) { + notEndPoints.insert(it->second); + } + + std::vector farthestEndpoints; + for (auto it = lastSearchResult.reachableNodes.begin(); it != lastSearchResult.reachableNodes.end(); it++) { + if (notEndPoints.find(it->first) == notEndPoints.end()) { + JSNodeInfo entry; + entry.nodeId = it->first; + entry.positionX = set.roadNodes[entry.nodeId].position_x; + entry.positionY = set.roadNodes[entry.nodeId].position_y; + entry.distanceFromStart = sqrt(pow(entry.positionX - start_x, 2) + pow(entry.positionY - start_y, 2)); + + farthestEndpoints.push_back(entry); + } + } + + std::sort(farthestEndpoints.begin(), farthestEndpoints.end(), [](JSNodeInfo first, JSNodeInfo second){return second.distanceFromStart < first.distanceFromStart;}); + + // Discard all points that are too close to each other + float minimumDistance = 300; + + std::vector filteredEndpoints; + for (size_t i = 0; i < farthestEndpoints.size(); i++) { + float closestNode = 1e99; + JSNodeInfo currentNode = farthestEndpoints.at(i); + for (size_t j = 0; j < filteredEndpoints.size(); j++) { + JSNodeInfo otherNode = filteredEndpoints.at(j); + float distance = sqrt(pow(otherNode.positionX - currentNode.positionX, 2) + pow(otherNode.positionY - currentNode.positionY, 2)); + if (distance < closestNode) { + closestNode = distance; + if (distance <= minimumDistance) { + break; + } + } + } + + if (closestNode > minimumDistance) { + filteredEndpoints.push_back(currentNode); + } + } + + JSSearchResult searchResult; + searchResult.endPoints = filteredEndpoints; + return searchResult; +} + +std::vector getPathJS(uint32_t startingNode, uint32_t endNode) { + std::vector path; + + if (lastSearchResult.startingNode != startingNode) { + return path; + } + + if (lastSearchResult.reachableNodes.find(endNode) == lastSearchResult.reachableNodes.end()) { + return path; + } + + std::vector nodes; + uint32_t currentNode = endNode; + nodes.push_back(currentNode); + while (lastSearchResult.previous.find(currentNode) != lastSearchResult.previous.end()) { + currentNode = lastSearchResult.previous.find(currentNode)->second; + nodes.push_back(currentNode); + } + + float currentDistance = 0; + + for (auto it = nodes.rbegin(); it != nodes.rend(); it++) { + RoadNode roadNode = set.roadNodes[*it]; + JSNodeInfo nodeInfo; + nodeInfo.nodeId = *it; + nodeInfo.positionX = roadNode.position_x; + nodeInfo.positionY = roadNode.position_y; + nodeInfo.positionZ = roadNode.position_z; + + SearchNodeInfo searchNodeInfo = lastSearchResult.reachableNodes.find(*it)->second; + nodeInfo.currentSpeed = searchNodeInfo.currentSpeed; + currentDistance += searchNodeInfo.distanceFromPrevious; + nodeInfo.distanceFromStart = currentDistance; + + path.push_back(nodeInfo); + } + + return path; +} + +bool isInsideRing(float pointX, float pointY, std::vector ring) { + // Draw a ray from the point directly towards the right, see if it crosses any line in the ring + int crossings = 0; + for (int i = 1; i < ring.size() + 1; i++) { + int currentPoint = i % ring.size(); + int lastPoint = i - 1; + + float lastY = ring.at(lastPoint).y; + float currentY = ring.at(currentPoint).y; + + if (pointY > fmax(lastY, currentY) || pointY < fmin(lastY, currentY)) { + continue; + } + + float lastX = ring.at(lastPoint).x; + float currentX = ring.at(currentPoint).x; + + if (pointX > fmax(lastX, currentX)) { + continue; + } + + if (pointX < fmin(lastX, currentX)) { + crossings += 1; + continue; + } + + // If we don't go cleanly through the bounding box, we have to be a bit more smart about this + // We don't want to divide by zero. If the line is completely horizontal, we have a freak line which we will ignore + if (fabs(lastY - currentY) < 0.00001) { + continue; + } + + float slope = (currentX - lastX) / (currentY - lastY); + float xAtPointY = lastX + (pointY - lastY) * slope; + + if (xAtPointY >= pointX) { + crossings += 1; + } + } + + return crossings % 2 == 1; +} + +void getNodesWithinPolygons(std::vector>> polygons, std::set &resultSet) { + // Add new ones + for (auto polygon : polygons) { + // Get a bounding box for each polygon + float maxX = -1e99; + float minX = 1e99; + float maxY = -1e99; + float minY = 1e99; + + for (auto ring : polygon) { + for (auto coordinate : ring) { + minX = fmin(minX, coordinate.x); + minY = fmin(minY, coordinate.y); + maxX = fmax(maxX, coordinate.x); + maxY = fmax(maxY, coordinate.y); + } + } + + // Go through all nodes + for (size_t nodeId = 0; nodeId < set.numberOfNodes; nodeId++) { + RoadNode node = set.roadNodes[nodeId]; + // If the node is outside the bounding box, just move on + if (node.position_x < minX || node.position_x > maxX || node.position_y < minY || node.position_y > maxY) { + continue; + } + // Otherwise, count how many times a ray straight east from the point crosses the polygon's rings + int crossings = 0; + for (auto ring : polygon) { + if (isInsideRing(node.position_x, node.position_y, ring)) { + crossings += 1; + } + } + + if (crossings % 2 == 1) { + resultSet.insert(nodeId); + } + } + } +} + +void excludeNodesWithinPolygons(std::vector>> polygons) { + // Clear any old excluded nodes + excludedNodes.clear(); + getNodesWithinPolygons(polygons, excludedNodes); +} + +std::vector findPossibleStartNodes(float minimumSpeed, float maximumSpeedLimit, float dragCoefficient, bool allowMotorways, bool allowTunnels, bool allowAgainstOneway, std::vector>> searchArea) { + std::set allNodesWithinSearchArea; + getNodesWithinPolygons(searchArea, allNodesWithinSearchArea); + + float minimumSlope = asin(dragCoefficient * minimumSpeed * minimumSpeed / GRAVITY_ACCELERATION); + float minimumSlopeTan = tan(minimumSlope); + + std::vector possibleStartNodes; + for (uint32_t nodeId : allNodesWithinSearchArea) { + if (excludedNodes.find(nodeId) != excludedNodes.end()) { + continue; + } + + bool hasWayOut = false; + bool hasWayIn = false; + + RoadNode node = set.roadNodes[nodeId]; + Connection neighbours[10]; + int numberOfNeighbours = 0; + getNeighbourConnections(node, neighbours, numberOfNeighbours); + + for (int i = 0; i < numberOfNeighbours; i++) { + Connection connection = neighbours[i]; + if (excludedNodes.find(connection.connected_point_number) != excludedNodes.end()) { + continue; + } + if (connection.motorway && !allowMotorways) { + continue; + } + if (connection.tunnel && !allowTunnels) { + continue; + } + if (connection.againstOneWay && !allowAgainstOneway) { + continue; + } + + RoadNode neighbourNode = set.roadNodes[connection.connected_point_number]; + float slopeTan = (neighbourNode.position_z - node.position_z) / connection.distance; + if (slopeTan > minimumSlopeTan) { + hasWayIn = true; + break; + } else if (slopeTan < -minimumSlopeTan) { + hasWayOut = true; + } + } + + if (hasWayOut && !hasWayIn) { + // Insertion sort by height + possibleStartNodes.insert( + std::upper_bound( + possibleStartNodes.begin(), + possibleStartNodes.end(), + nodeId, + [](uint32_t nodeOne, uint32_t nodeTwo){return set.roadNodes[nodeOne].position_z > set.roadNodes[nodeTwo].position_z;} + ), + nodeId + ); + } + } + + return possibleStartNodes; +} + +AreaSearchResult startAreaSearch(float minimumSpeed, float maximumSpeed, float maximumSpeedLimit, float dragCoefficient, bool allowMotorways, bool allowTunnels, bool allowAgainstOneway, std::vector>> searchArea) { + currentAreaSearch = CurrentAreaSearch(); + currentAreaSearch.startNodes = findPossibleStartNodes(minimumSpeed, maximumSpeedLimit, dragCoefficient, allowMotorways, allowTunnels, allowAgainstOneway, searchArea); + currentAreaSearch.minimumSpeed = minimumSpeed; + currentAreaSearch.maximumSpeed = maximumSpeed; + currentAreaSearch.maximumSpeedLimit = maximumSpeedLimit; + currentAreaSearch.dragCoefficient = dragCoefficient; + currentAreaSearch.allowMotorways = allowMotorways; + currentAreaSearch.allowTunnels = allowTunnels; + currentAreaSearch.allowAgainstOneway = allowAgainstOneway; + + AreaSearchResult result; + result.remainingNodes = currentAreaSearch.startNodes.size(); + return result; +} + +AreaSearchResult continueAreaSearch() { + uint32_t currentStartNode = currentAreaSearch.startNodes.at(currentAreaSearch.startNodeCounter); + SearchResult result = findAllPathsFromPoint( + currentStartNode, + currentAreaSearch.minimumSpeed, + currentAreaSearch.maximumSpeed, + currentAreaSearch.maximumSpeedLimit, + currentAreaSearch.dragCoefficient, + currentAreaSearch.allowMotorways, + currentAreaSearch.allowTunnels, + currentAreaSearch.allowAgainstOneway + ); + + // Remove all nodes we have reached from here as possible future start nodes + auto startNodeIterator = currentAreaSearch.startNodes.begin() + currentAreaSearch.startNodeCounter + 1; + while (startNodeIterator != currentAreaSearch.startNodes.end()) { + uint32_t startNodeId = *startNodeIterator; + if (result.reachableNodes.find(startNodeId) != result.reachableNodes.end()) { + startNodeIterator = currentAreaSearch.startNodes.erase(startNodeIterator); + } else { + startNodeIterator++; + } + } + + // Find the farthest reachable point from our current start point + std::set notEndPoints; + for (auto it = result.previous.begin(); it != result.previous.end(); it++) { + notEndPoints.insert(it->second); + } + + RoadNode startNode = set.roadNodes[currentStartNode]; + float farthestDistance = 0; + uint32_t farthestNode = 0; + for (auto it = result.reachableNodes.begin(); it != result.reachableNodes.end(); it++) { + if (notEndPoints.find(it->first) == notEndPoints.end()) { + RoadNode endNode = set.roadNodes[it->first]; + float distance = sqrt(pow(endNode.position_x - startNode.position_x, 2) + pow( endNode.position_y - startNode.position_y, 2)); + if (distance > farthestDistance) { + farthestDistance = distance; + farthestNode = it->first; + } + } + } + + std::set path; + path.insert(farthestNode); + uint32_t currentNode = farthestNode; + while (result.previous.find(currentNode) != result.previous.end()) { + currentNode = result.previous.find(currentNode)->second; + path.insert(currentNode); + } + + // Check if our path overlaps too much with already travelled paths + std::vector overlap; + std::set_intersection(path.begin(), path.end(), currentAreaSearch.utilizedNodes.begin(), currentAreaSearch.utilizedNodes.end(), std::back_inserter(overlap)); + if (overlap.size() < 0.5 * path.size()) { + AreaSearchEntry searchEntry; + searchEntry.nodeId = currentStartNode; + searchEntry.positionX = startNode.position_x; + searchEntry.positionY = startNode.position_y; + searchEntry.longestRoute = farthestDistance; + + currentAreaSearch.currentAreaSearchResults.insert( + std::upper_bound( + currentAreaSearch.currentAreaSearchResults.begin(), + currentAreaSearch.currentAreaSearchResults.end(), + searchEntry, + [](AreaSearchEntry one, AreaSearchEntry two){return one.longestRoute > two.longestRoute;} + ), + searchEntry + ); + + for (uint32_t pathNode : path) { + currentAreaSearch.utilizedNodes.insert(pathNode); + } + } + + currentAreaSearch.startNodeCounter++; + + AreaSearchResult searchResult; + searchResult.remainingNodes = currentAreaSearch.startNodes.size() - currentAreaSearch.startNodeCounter; + searchResult.searchedNodes = currentAreaSearch.currentAreaSearchResults; + + return searchResult; +} + +EMSCRIPTEN_BINDINGS(my_module) { + emscripten::class_("NodeInfo") + .constructor<>() + .property("nodeId", &JSNodeInfo::nodeId) + .property("positionX", &JSNodeInfo::positionX) + .property("positionY", &JSNodeInfo::positionY) + .property("distanceFromStart", &JSNodeInfo::distanceFromStart); + + emscripten::class_("SearchResult") + .constructor<>() + .property("endPoints", &JSSearchResult::endPoints); + + emscripten::class_("PolygonCoordinate") + .constructor<>() + .property("x", &PolygonCoordinate::x) + .property("y", &PolygonCoordinate::y); + + emscripten::class_("AreaSearchEntry") + .constructor<>() + .property("nodeId", &AreaSearchEntry::nodeId) + .property("positionX", &AreaSearchEntry::positionX) + .property("positionY", &AreaSearchEntry::positionY) + .property("longestRoute", &AreaSearchEntry::longestRoute); + + emscripten::class_("AreaSearchResult") + .constructor<>() + .property("remainingNodes", &AreaSearchResult::remainingNodes) + .property("searchedNodes", &AreaSearchResult::searchedNodes); + + emscripten::register_vector("NodeInfoArray"); + emscripten::register_vector("Ring"); + emscripten::register_vector>("Polygon"); + emscripten::register_vector>>("MultiPolygon"); + emscripten::register_vector("AreaSearchEntries"); + + emscripten::function("loadData", &loadData); + emscripten::function("findClosestNode", &findClosestNode); + emscripten::function("findAllPathsFromPoint", &findAllPathsFromPointJS); + emscripten::function("getPath", &getPathJS); + emscripten::function("excludeNodesWithinPolygons", &excludeNodesWithinPolygons); + emscripten::function("startAreaSearch", &startAreaSearch); + emscripten::function("continueAreaSearch", &continueAreaSearch); +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..df0144f --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1401 @@ +{ + "name": "freewheeling", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "freewheeling", + "version": "0.0.0", + "dependencies": { + "@geoman-io/leaflet-geoman-free": "^2.18.3", + "@types/geojson": "^7946.0.16", + "@types/leaflet": "^1.9.19", + "leaflet": "^1.9.4", + "leaflet-extra-markers": "^1.2.2", + "proj4": "^2.19.4" + }, + "devDependencies": { + "typescript": "~5.8.3", + "vite": "^7.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz", + "integrity": "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.5.tgz", + "integrity": "sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.5.tgz", + "integrity": "sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.5.tgz", + "integrity": "sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.5.tgz", + "integrity": "sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.5.tgz", + "integrity": "sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.5.tgz", + "integrity": "sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.5.tgz", + "integrity": "sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.5.tgz", + "integrity": "sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.5.tgz", + "integrity": "sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.5.tgz", + "integrity": "sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.5.tgz", + "integrity": "sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.5.tgz", + "integrity": "sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.5.tgz", + "integrity": "sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.5.tgz", + "integrity": "sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.5.tgz", + "integrity": "sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.5.tgz", + "integrity": "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.5.tgz", + "integrity": "sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.5.tgz", + "integrity": "sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.5.tgz", + "integrity": "sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.5.tgz", + "integrity": "sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.5.tgz", + "integrity": "sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.5.tgz", + "integrity": "sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.5.tgz", + "integrity": "sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.5.tgz", + "integrity": "sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@geoman-io/leaflet-geoman-free": { + "version": "2.18.3", + "resolved": "https://registry.npmjs.org/@geoman-io/leaflet-geoman-free/-/leaflet-geoman-free-2.18.3.tgz", + "integrity": "sha512-XzxSKRk2UJUVeGiOt1jU2hyo412Qee1Q0Xsfw4A2r8EoUIo48XKSWfusYe7E53fSPr0aYgZxPevnFdcUXimpdA==", + "license": "MIT", + "dependencies": { + "@turf/boolean-contains": "^6.5.0", + "@turf/kinks": "^6.5.0", + "@turf/line-intersect": "^6.5.0", + "@turf/line-split": "^6.5.0", + "lodash": "4.17.21", + "polyclip-ts": "^0.16.5" + }, + "peerDependencies": { + "leaflet": "^1.2.0" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.44.1.tgz", + "integrity": "sha512-JAcBr1+fgqx20m7Fwe1DxPUl/hPkee6jA6Pl7n1v2EFiktAHenTaXl5aIFjUIEsfn9w3HE4gK1lEgNGMzBDs1w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.44.1.tgz", + "integrity": "sha512-RurZetXqTu4p+G0ChbnkwBuAtwAbIwJkycw1n6GvlGlBuS4u5qlr5opix8cBAYFJgaY05TWtM+LaoFggUmbZEQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.44.1.tgz", + "integrity": "sha512-fM/xPesi7g2M7chk37LOnmnSTHLG/v2ggWqKj3CCA1rMA4mm5KVBT1fNoswbo1JhPuNNZrVwpTvlCVggv8A2zg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.44.1.tgz", + "integrity": "sha512-gDnWk57urJrkrHQ2WVx9TSVTH7lSlU7E3AFqiko+bgjlh78aJ88/3nycMax52VIVjIm3ObXnDL2H00e/xzoipw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.44.1.tgz", + "integrity": "sha512-wnFQmJ/zPThM5zEGcnDcCJeYJgtSLjh1d//WuHzhf6zT3Md1BvvhJnWoy+HECKu2bMxaIcfWiu3bJgx6z4g2XA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.44.1.tgz", + "integrity": "sha512-uBmIxoJ4493YATvU2c0upGz87f99e3wop7TJgOA/bXMFd2SvKCI7xkxY/5k50bv7J6dw1SXT4MQBQSLn8Bb/Uw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.44.1.tgz", + "integrity": "sha512-n0edDmSHlXFhrlmTK7XBuwKlG5MbS7yleS1cQ9nn4kIeW+dJH+ExqNgQ0RrFRew8Y+0V/x6C5IjsHrJmiHtkxQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.44.1.tgz", + "integrity": "sha512-8WVUPy3FtAsKSpyk21kV52HCxB+me6YkbkFHATzC2Yd3yuqHwy2lbFL4alJOLXKljoRw08Zk8/xEj89cLQ/4Nw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.44.1.tgz", + "integrity": "sha512-yuktAOaeOgorWDeFJggjuCkMGeITfqvPgkIXhDqsfKX8J3jGyxdDZgBV/2kj/2DyPaLiX6bPdjJDTu9RB8lUPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.44.1.tgz", + "integrity": "sha512-W+GBM4ifET1Plw8pdVaecwUgxmiH23CfAUj32u8knq0JPFyK4weRy6H7ooxYFD19YxBulL0Ktsflg5XS7+7u9g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.44.1.tgz", + "integrity": "sha512-1zqnUEMWp9WrGVuVak6jWTl4fEtrVKfZY7CvcBmUUpxAJ7WcSowPSAWIKa/0o5mBL/Ij50SIf9tuirGx63Ovew==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.44.1.tgz", + "integrity": "sha512-Rl3JKaRu0LHIx7ExBAAnf0JcOQetQffaw34T8vLlg9b1IhzcBgaIdnvEbbsZq9uZp3uAH+JkHd20Nwn0h9zPjA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.44.1.tgz", + "integrity": "sha512-j5akelU3snyL6K3N/iX7otLBIl347fGwmd95U5gS/7z6T4ftK288jKq3A5lcFKcx7wwzb5rgNvAg3ZbV4BqUSw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.44.1.tgz", + "integrity": "sha512-ppn5llVGgrZw7yxbIm8TTvtj1EoPgYUAbfw0uDjIOzzoqlZlZrLJ/KuiE7uf5EpTpCTrNt1EdtzF0naMm0wGYg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.44.1.tgz", + "integrity": "sha512-Hu6hEdix0oxtUma99jSP7xbvjkUM/ycke/AQQ4EC5g7jNRLLIwjcNwaUy95ZKBJJwg1ZowsclNnjYqzN4zwkAw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.44.1.tgz", + "integrity": "sha512-EtnsrmZGomz9WxK1bR5079zee3+7a+AdFlghyd6VbAjgRJDbTANJ9dcPIPAi76uG05micpEL+gPGmAKYTschQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.44.1.tgz", + "integrity": "sha512-iAS4p+J1az6Usn0f8xhgL4PaU878KEtutP4hqw52I4IO6AGoyOkHCxcc4bqufv1tQLdDWFx8lR9YlwxKuv3/3g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.44.1.tgz", + "integrity": "sha512-NtSJVKcXwcqozOl+FwI41OH3OApDyLk3kqTJgx8+gp6On9ZEt5mYhIsKNPGuaZr3p9T6NWPKGU/03Vw4CNU9qg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.44.1.tgz", + "integrity": "sha512-JYA3qvCOLXSsnTR3oiyGws1Dm0YTuxAAeaYGVlGpUsHqloPcFjPg+X0Fj2qODGLNwQOAcCiQmHub/V007kiH5A==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.44.1.tgz", + "integrity": "sha512-J8o22LuF0kTe7m+8PvW9wk3/bRq5+mRo5Dqo6+vXb7otCm3TPhYOJqOaQtGU9YMWQSL3krMnoOxMr0+9E6F3Ug==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@turf/bbox": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/bbox/-/bbox-6.5.0.tgz", + "integrity": "sha512-RBbLaao5hXTYyyg577iuMtDB8ehxMlUqHEJiMs8jT1GHkFhr6sYre3lmLsPeYEi/ZKj5TP5tt7fkzNdJ4GIVyw==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "^6.5.0", + "@turf/meta": "^6.5.0" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/bearing": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/bearing/-/bearing-6.5.0.tgz", + "integrity": "sha512-dxINYhIEMzgDOztyMZc20I7ssYVNEpSv04VbMo5YPQsqa80KO3TFvbuCahMsCAW5z8Tncc8dwBlEFrmRjJG33A==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/boolean-contains": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/boolean-contains/-/boolean-contains-6.5.0.tgz", + "integrity": "sha512-4m8cJpbw+YQcKVGi8y0cHhBUnYT+QRfx6wzM4GI1IdtYH3p4oh/DOBJKrepQyiDzFDaNIjxuWXBh0ai1zVwOQQ==", + "license": "MIT", + "dependencies": { + "@turf/bbox": "^6.5.0", + "@turf/boolean-point-in-polygon": "^6.5.0", + "@turf/boolean-point-on-line": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/boolean-point-in-polygon": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/boolean-point-in-polygon/-/boolean-point-in-polygon-6.5.0.tgz", + "integrity": "sha512-DtSuVFB26SI+hj0SjrvXowGTUCHlgevPAIsukssW6BG5MlNSBQAo70wpICBNJL6RjukXg8d2eXaAWuD/CqL00A==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/boolean-point-on-line": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/boolean-point-on-line/-/boolean-point-on-line-6.5.0.tgz", + "integrity": "sha512-A1BbuQ0LceLHvq7F/P7w3QvfpmZqbmViIUPHdNLvZimFNLo4e6IQunmzbe+8aSStH9QRZm3VOflyvNeXvvpZEQ==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/destination": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/destination/-/destination-6.5.0.tgz", + "integrity": "sha512-4cnWQlNC8d1tItOz9B4pmJdWpXqS0vEvv65bI/Pj/genJnsL7evI0/Xw42RvEGROS481MPiU80xzvwxEvhQiMQ==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/distance": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/distance/-/distance-6.5.0.tgz", + "integrity": "sha512-xzykSLfoURec5qvQJcfifw/1mJa+5UwByZZ5TZ8iaqjGYN0vomhV9aiSLeYdUGtYRESZ+DYC/OzY+4RclZYgMg==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/helpers": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-6.5.0.tgz", + "integrity": "sha512-VbI1dV5bLFzohYYdgqwikdMVpe7pJ9X3E+dlr425wa2/sMJqYDhTO++ec38/pcPvPE6oD9WEEeU3Xu3gza+VPw==", + "license": "MIT", + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/invariant": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/invariant/-/invariant-6.5.0.tgz", + "integrity": "sha512-Wv8PRNCtPD31UVbdJE/KVAWKe7l6US+lJItRR/HOEW3eh+U/JwRCSUl/KZ7bmjM/C+zLNoreM2TU6OoLACs4eg==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "^6.5.0" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/kinks": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/kinks/-/kinks-6.5.0.tgz", + "integrity": "sha512-ViCngdPt1eEL7hYUHR2eHR662GvCgTc35ZJFaNR6kRtr6D8plLaDju0FILeFFWSc+o8e3fwxZEJKmFj9IzPiIQ==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "^6.5.0" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/line-intersect": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/line-intersect/-/line-intersect-6.5.0.tgz", + "integrity": "sha512-CS6R1tZvVQD390G9Ea4pmpM6mJGPWoL82jD46y0q1KSor9s6HupMIo1kY4Ny+AEYQl9jd21V3Scz20eldpbTVA==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0", + "@turf/line-segment": "^6.5.0", + "@turf/meta": "^6.5.0", + "geojson-rbush": "3.x" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/line-segment": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/line-segment/-/line-segment-6.5.0.tgz", + "integrity": "sha512-jI625Ho4jSuJESNq66Mmi290ZJ5pPZiQZruPVpmHkUw257Pew0alMmb6YrqYNnLUuiVVONxAAKXUVeeUGtycfw==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0", + "@turf/meta": "^6.5.0" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/line-split": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/line-split/-/line-split-6.5.0.tgz", + "integrity": "sha512-/rwUMVr9OI2ccJjw7/6eTN53URtGThNSD5I0GgxyFXMtxWiloRJ9MTff8jBbtPWrRka/Sh2GkwucVRAEakx9Sw==", + "license": "MIT", + "dependencies": { + "@turf/bbox": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0", + "@turf/line-intersect": "^6.5.0", + "@turf/line-segment": "^6.5.0", + "@turf/meta": "^6.5.0", + "@turf/nearest-point-on-line": "^6.5.0", + "@turf/square": "^6.5.0", + "@turf/truncate": "^6.5.0", + "geojson-rbush": "3.x" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/meta": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/meta/-/meta-6.5.0.tgz", + "integrity": "sha512-RrArvtsV0vdsCBegoBtOalgdSOfkBrTJ07VkpiCnq/491W67hnMWmDu7e6Ztw0C3WldRYTXkg3SumfdzZxLBHA==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "^6.5.0" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/nearest-point-on-line": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/nearest-point-on-line/-/nearest-point-on-line-6.5.0.tgz", + "integrity": "sha512-WthrvddddvmymnC+Vf7BrkHGbDOUu6Z3/6bFYUGv1kxw8tiZ6n83/VG6kHz4poHOfS0RaNflzXSkmCi64fLBlg==", + "license": "MIT", + "dependencies": { + "@turf/bearing": "^6.5.0", + "@turf/destination": "^6.5.0", + "@turf/distance": "^6.5.0", + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0", + "@turf/line-intersect": "^6.5.0", + "@turf/meta": "^6.5.0" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/square": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/square/-/square-6.5.0.tgz", + "integrity": "sha512-BM2UyWDmiuHCadVhHXKIx5CQQbNCpOxB6S/aCNOCLbhCeypKX5Q0Aosc5YcmCJgkwO5BERCC6Ee7NMbNB2vHmQ==", + "license": "MIT", + "dependencies": { + "@turf/distance": "^6.5.0", + "@turf/helpers": "^6.5.0" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/truncate": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/truncate/-/truncate-6.5.0.tgz", + "integrity": "sha512-pFxg71pLk+eJj134Z9yUoRhIi8vqnnKvCYwdT4x/DQl/19RVdq1tV3yqOT3gcTQNfniteylL5qV1uTBDV5sgrg==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "^6.5.0", + "@turf/meta": "^6.5.0" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "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.19", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.19.tgz", + "integrity": "sha512-pB+n2daHcZPF2FDaWa+6B0a0mSDf4dPU35y5iTXsx7x/PzzshiX5atYiS1jlBn43X7XvM8AP+AB26lnSk0J4GA==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/bignumber.js": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.0.tgz", + "integrity": "sha512-EM7aMFTXbptt/wZdMlBv2t8IViwQL+h6SLHosp8Yf0dqJMTnY6iL32opnAB6kAdL0SZPuvcAzFr31o0c/R3/RA==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/esbuild": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.5.tgz", + "integrity": "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.5", + "@esbuild/android-arm": "0.25.5", + "@esbuild/android-arm64": "0.25.5", + "@esbuild/android-x64": "0.25.5", + "@esbuild/darwin-arm64": "0.25.5", + "@esbuild/darwin-x64": "0.25.5", + "@esbuild/freebsd-arm64": "0.25.5", + "@esbuild/freebsd-x64": "0.25.5", + "@esbuild/linux-arm": "0.25.5", + "@esbuild/linux-arm64": "0.25.5", + "@esbuild/linux-ia32": "0.25.5", + "@esbuild/linux-loong64": "0.25.5", + "@esbuild/linux-mips64el": "0.25.5", + "@esbuild/linux-ppc64": "0.25.5", + "@esbuild/linux-riscv64": "0.25.5", + "@esbuild/linux-s390x": "0.25.5", + "@esbuild/linux-x64": "0.25.5", + "@esbuild/netbsd-arm64": "0.25.5", + "@esbuild/netbsd-x64": "0.25.5", + "@esbuild/openbsd-arm64": "0.25.5", + "@esbuild/openbsd-x64": "0.25.5", + "@esbuild/sunos-x64": "0.25.5", + "@esbuild/win32-arm64": "0.25.5", + "@esbuild/win32-ia32": "0.25.5", + "@esbuild/win32-x64": "0.25.5" + } + }, + "node_modules/fdir": { + "version": "6.4.6", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", + "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", + "dev": true, + "license": "MIT", + "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/geojson-rbush": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/geojson-rbush/-/geojson-rbush-3.2.0.tgz", + "integrity": "sha512-oVltQTXolxvsz1sZnutlSuLDEcQAKYC/uXt9zDzJJ6bu0W+baTI8LZBaTup5afzibEH4N3jlq2p+a152wlBJ7w==", + "license": "MIT", + "dependencies": { + "@turf/bbox": "*", + "@turf/helpers": "6.x", + "@turf/meta": "6.x", + "@types/geojson": "7946.0.8", + "rbush": "^3.0.1" + } + }, + "node_modules/geojson-rbush/node_modules/@types/geojson": { + "version": "7946.0.8", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.8.tgz", + "integrity": "sha512-1rkryxURpr6aWP7R786/UQOkJ3PcpQiWkAXBmdWc7ryFWqN6a4xfK7BtjXvFBKO9LjQ+MWQSWxYeZX1OApnArA==", + "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/leaflet-extra-markers": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/leaflet-extra-markers/-/leaflet-extra-markers-1.2.2.tgz", + "integrity": "sha512-LQUicXhyOwq0wOFtEftJg9IzAGcQI+6mHgh3wsVntrid149BTqsEgh1UcxPPtzcL7xt8jXY0rssfz7DUZuQudg==", + "peerDependencies": { + "leaflet": ">= 0.5 < 2" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, + "node_modules/mgrs": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/mgrs/-/mgrs-1.0.0.tgz", + "integrity": "sha512-awNbTOqCxK1DBGjalK3xqWIstBZgN6fxsMSiXLs9/spqWkF2pAhb2rrYCFSsr1/tT7PhcDGjZndG8SWYn0byYA==", + "license": "MIT" + }, + "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/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.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/polyclip-ts": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/polyclip-ts/-/polyclip-ts-0.16.8.tgz", + "integrity": "sha512-JPtKbDRuPEuAjuTdhR62Gph7Is2BS1Szx69CFOO3g71lpJDFo78k4tFyi+qFOMVPePEzdSKkpGU3NBXPHHjvKQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.1.0", + "splaytree-ts": "^1.0.2" + } + }, + "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/proj4": { + "version": "2.19.4", + "resolved": "https://registry.npmjs.org/proj4/-/proj4-2.19.4.tgz", + "integrity": "sha512-1l6JiJ2ZOzXIoo6k64diOQVOvHIF0IACMrHTaFHrEQmuo1tY1vb73mrWfTSyPH+muc0Lut4zuj5encvB1Ccuhg==", + "license": "MIT", + "dependencies": { + "mgrs": "1.0.0", + "wkt-parser": "^1.5.1" + }, + "funding": { + "url": "https://github.com/sponsors/ahocevar" + } + }, + "node_modules/quickselect": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz", + "integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==", + "license": "ISC" + }, + "node_modules/rbush": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/rbush/-/rbush-3.0.1.tgz", + "integrity": "sha512-XRaVO0YecOpEuIvbhbpTrZgoiI6xBlz6hnlr6EHhd+0x9ase6EmeN+hdwwUaJvLcsFFQ8iWVF1GAK1yB0BWi0w==", + "license": "MIT", + "dependencies": { + "quickselect": "^2.0.0" + } + }, + "node_modules/rollup": { + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.44.1.tgz", + "integrity": "sha512-x8H8aPvD+xbl0Do8oez5f5o8eMS3trfCghc4HhLAnCkj7Vl0d1JWGs0UF/D886zLW2rOj2QymV/JcSSsw+XDNg==", + "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.44.1", + "@rollup/rollup-android-arm64": "4.44.1", + "@rollup/rollup-darwin-arm64": "4.44.1", + "@rollup/rollup-darwin-x64": "4.44.1", + "@rollup/rollup-freebsd-arm64": "4.44.1", + "@rollup/rollup-freebsd-x64": "4.44.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.44.1", + "@rollup/rollup-linux-arm-musleabihf": "4.44.1", + "@rollup/rollup-linux-arm64-gnu": "4.44.1", + "@rollup/rollup-linux-arm64-musl": "4.44.1", + "@rollup/rollup-linux-loongarch64-gnu": "4.44.1", + "@rollup/rollup-linux-powerpc64le-gnu": "4.44.1", + "@rollup/rollup-linux-riscv64-gnu": "4.44.1", + "@rollup/rollup-linux-riscv64-musl": "4.44.1", + "@rollup/rollup-linux-s390x-gnu": "4.44.1", + "@rollup/rollup-linux-x64-gnu": "4.44.1", + "@rollup/rollup-linux-x64-musl": "4.44.1", + "@rollup/rollup-win32-arm64-msvc": "4.44.1", + "@rollup/rollup-win32-ia32-msvc": "4.44.1", + "@rollup/rollup-win32-x64-msvc": "4.44.1", + "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/splaytree-ts": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/splaytree-ts/-/splaytree-ts-1.0.2.tgz", + "integrity": "sha512-0kGecIZNIReCSiznK3uheYB8sbstLjCZLiwcQwbmLhgHJj2gz6OnSPkVzJQCMnmEz1BQ4gPK59ylhBoEWOhGNA==", + "license": "BDS-3-Clause" + }, + "node_modules/tinyglobby": { + "version": "0.2.14", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", + "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.4.4", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.0.0.tgz", + "integrity": "sha512-ixXJB1YRgDIw2OszKQS9WxGHKwLdCsbQNkpJN171udl6szi/rIySHL6/Os3s2+oE4P/FLD4dxg4mD7Wust+u5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.6", + "picomatch": "^4.0.2", + "postcss": "^8.5.6", + "rollup": "^4.40.0", + "tinyglobby": "^0.2.14" + }, + "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 + } + } + }, + "node_modules/wkt-parser": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/wkt-parser/-/wkt-parser-1.5.2.tgz", + "integrity": "sha512-1ZUiV1FTwSiSrgWzV9KXJuOF2BVW91KY/mau04BhnmgOdroRQea7Q0s5TVqwGLm0D2tZwObd/tBYXW49sSxp3Q==", + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..932a63e --- /dev/null +++ b/package.json @@ -0,0 +1,25 @@ +{ + "name": "freewheeling", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build:emscripten": "cd native && emcc -O3 -lembind -sEXPORT_ES6 -sASSERTIONS -sEXPORTED_RUNTIME_METHODS=FS -sALLOW_MEMORY_GROWTH -sMAXIMUM_MEMORY=4294967296 --emit-tsd route_search.d.ts -o route_search.js route_search.cpp", + "build:normal": "tsc && vite build", + "build": "npm run build:emscripten && npm run build:normal", + "preview": "vite preview" + }, + "devDependencies": { + "typescript": "~5.8.3", + "vite": "^7.0.0" + }, + "dependencies": { + "@geoman-io/leaflet-geoman-free": "^2.18.3", + "@types/geojson": "^7946.0.16", + "@types/leaflet": "^1.9.19", + "leaflet": "^1.9.4", + "leaflet-extra-markers": "^1.2.2", + "proj4": "^2.19.4" + } +} diff --git a/src/interfaces.ts b/src/interfaces.ts new file mode 100644 index 0000000..3136762 --- /dev/null +++ b/src/interfaces.ts @@ -0,0 +1,117 @@ +// Messages passed to and from the Web Worker +export interface Message { + loadData?: LoadData, + dataLoaded?: DataLoaded + findClosestNode?: FindClosestNode, + foundClosestNode?: FoundClosestNode, + findPathsFromNode?: FindPathsFromNode, + foundPathsFromNode?: FoundPathsFromNode, + getFullPath?: GetFullPath, + returnFullPath?: ReturnFullPath, + excludeAreas?: ExcludeAreas, + searchArea?: SearchArea, + continueSearch?: ContinueSearch, + searchAreaResult?: SearchAreaResult, + errorMessage?: ErrorMessage +} + +interface LoadData { + data: Uint8Array +} + +interface DataLoaded { + +} + +interface FindClosestNode { + latitude: number, + longitude: number +} + +interface FoundClosestNode { + originalLatitude: number, + originalLongitude: number, + foundLatitude: number, + foundLongitude: number, + foundNodeId: number +} + +interface FindPathsFromNode { + nodeId: number, + minimumSpeed: number, + maximumSpeed: number, + maximumSpeedLimit: number, + dragCoefficient: number, + allowMotorways: boolean, + allowTunnels: boolean, + allowAgainstOneway: boolean +} + +export interface Endpoint { + nodeId: number, + latitude: number, + longitude: number, + distanceFromStart: number +} + +interface FoundPathsFromNode { + nodeId: number, + endpoints: Endpoint[] +} + +interface GetFullPath { + startNodeId: number, + endNodeId: number +} + +export interface Coordinate { + latitude: number, + longitude: number +} + +interface ReturnFullPath { + coordinates: Coordinate[] +} + +export interface Ring { + coordinates: Coordinate[] +} + +export interface Polygon { + rings: Ring[] +} + +interface ExcludeAreas { + polygons: Polygon[] +} + +interface SearchArea { + polygons: Polygon[], + minimumSpeed: number, + maximumSpeed: number, + maximumSpeedLimit: number, + dragCoefficient: number, + allowMotorways: boolean, + allowTunnels: boolean, + allowAgainstOneway: boolean +} + +interface ContinueSearch { + +} + +export interface SearchAreaResultEntry { + nodeId: number, + latitude: number, + longitude: number, + longestRoute: number +} + +interface SearchAreaResult { + remainingNodes: number, + searchResults: SearchAreaResultEntry[] +} + +interface ErrorMessage { + error: string +} \ No newline at end of file diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..9165a4f --- /dev/null +++ b/src/main.ts @@ -0,0 +1,374 @@ +import './style.css'; +import './modules/maphandler/maphandler' +import MapHandler from './modules/maphandler/maphandler'; +import type { Message, Polygon } from './interfaces'; + +interface WindowState { + state: 'DataNotLoaded' | 'DataLoading' | 'Ready' | 'Searching' +} + +interface Settings { + minimumSpeed: number, + maximumSpeed: number, + maximumSpeedLimit: number, + dragCoefficient: number, + allowMotorways: boolean, + allowTunnels: boolean, + allowAgainstOneway: boolean, + cutoffDistance: number +} + +// Default settings values +const DEFAULT_MINIMUM_SPEED = 1; +const DEFAULT_MAXIMUM_SPEED = 40; +const DEFAULT_MAXIMUM_SPEED_LIMIT = 80; +const DEFAULT_DRAG_COEFFICIENT = 0.005; +const DEFAULT_ALLOW_MOTORWAYS = false; +const DEFAULT_ALLOW_TUNNELS = false; +const DEFAULT_ALLOW_AGAINST_ONE_WAY = false; +const DEFAULT_CUTOFF_DISTANCE = 1000; + +// Set up variables +let routeWorker = new Worker(new URL('./modules/worker/worker.ts', import.meta.url), {type: 'module'}); +var mapHandler: MapHandler | null = null; +var currentSearchArea: Polygon[] = []; + +var currentState: WindowState = {state: 'DataNotLoaded'}; + +var lastSearchUpdate = Date.now(); + +let freewheelingHeader = document.getElementById('freewheeling-header'); +let notLoadedContainer = document.getElementById('notloadedcontainer'); +let loadingContainer = document.getElementById('loadingcontainer'); +let mapContainer = document.getElementById('mapcontainer'); +let searchButton = document.getElementById('search-button'); +let searchStatusParagraph = document.getElementById('search-status-paragraph'); +let searchResultsTable = document.getElementById('search-result-table'); +let searchResultTableBody = document.getElementById('search-result-table-body'); +let settingsButton = document.getElementById('settings-button'); +let settingsDiv = document.getElementById('settings-div'); + +let minimumSpeedInput = document.getElementById('minimum-speed-input'); +let maximumSpeedInput = document.getElementById('maximum-speed-input'); +let maximumSpeedLimitInput = document.getElementById('maximum-speed-limit-input'); +let dragCoefficientInput = document.getElementById('drag-coefficient-input'); +let allowMotorwaysInput = document.getElementById('allow-motorways-input'); +let allowTunnelsInput = document.getElementById('allow-tunnels-input'); +let allowAgainstOnewayInput = document.getElementById('allow-wrong-way-input'); +let cutoffDistanceInput = document.getElementById('cutoff-distance-input'); + + +// Set up the web worker and what to do when we get messages from it +routeWorker.onmessage = e => { + let message = e.data as Message; + if (message.dataLoaded != null) { + setState({state: 'Ready'}) + } + + else if (message.foundClosestNode != null) { + mapHandler?.drawStartNode(message.foundClosestNode.foundNodeId, message.foundClosestNode.foundLatitude, message.foundClosestNode.foundLongitude); + let settings = getSettings(); + let findPathsMessage: Message = {findPathsFromNode: { + nodeId: message.foundClosestNode.foundNodeId, + minimumSpeed: settings.minimumSpeed, + maximumSpeed: settings.maximumSpeed, + maximumSpeedLimit: settings.maximumSpeedLimit, + dragCoefficient: settings.dragCoefficient, + allowMotorways: settings.allowMotorways, + allowTunnels: settings.allowTunnels, + allowAgainstOneway: settings.allowAgainstOneway + }}; + routeWorker.postMessage(findPathsMessage); + } else if (message.foundPathsFromNode != null) { + mapHandler?.drawEndPoints(message.foundPathsFromNode.endpoints); + } else if (message.returnFullPath != null) { + mapHandler?.drawPath(message.returnFullPath.coordinates); + } else if (message.searchAreaResult != null) { + searchStatusParagraph?.setHTMLUnsafe('Searching. ' + message.searchAreaResult.remainingNodes + ' possible starting points remain.'); + let currentTime = Date.now(); + let settings = getSettings(); + if (message.searchAreaResult.searchResults.length > 0 && (currentTime - lastSearchUpdate > 500 || message.searchAreaResult.remainingNodes == 0)) { + lastSearchUpdate = currentTime; + searchResultsTable?.style.setProperty('display', 'block'); + searchResultTableBody?.setHTMLUnsafe(''); + + message.searchAreaResult.searchResults.forEach(result => { + if (result.longestRoute < settings.cutoffDistance) { + return; + } + + let tableRow = document.createElement('tr'); + let distanceCell = document.createElement('td'); + let latitudeCell = document.createElement('td'); + let longitudeCell = document.createElement('td') + let buttonCell = document.createElement('td'); + searchResultTableBody?.appendChild(tableRow); + + tableRow.appendChild(distanceCell); + tableRow.appendChild(latitudeCell); + tableRow.appendChild(longitudeCell); + tableRow.appendChild(buttonCell); + + distanceCell.setHTMLUnsafe(result.longestRoute.toFixed(0) + ' m'); + latitudeCell.setHTMLUnsafe(result.latitude.toFixed(6)); + longitudeCell.setHTMLUnsafe(result.longitude.toFixed(6)); + + let button = document.createElement('button'); + buttonCell.appendChild(button); + + button.setHTMLUnsafe('Show in map'); + button.addEventListener('click', _ => { + mapHandler?.drawStartNode(result.nodeId, result.latitude, result.longitude); + let settings = getSettings(); + let requestMessage: Message = { + findPathsFromNode: { + nodeId: result.nodeId, + minimumSpeed: settings.minimumSpeed, + maximumSpeed: settings.maximumSpeed, + maximumSpeedLimit: settings.maximumSpeedLimit, + dragCoefficient: settings.dragCoefficient, + allowMotorways: settings.allowMotorways, + allowTunnels: settings.allowTunnels, + allowAgainstOneway: settings.allowAgainstOneway + } + }; + routeWorker.postMessage(requestMessage); + }); + }); + } + + if (currentState.state == 'Searching' && message.searchAreaResult.remainingNodes > 0) { + let continueMessage: Message = {continueSearch: {}}; + routeWorker.postMessage(continueMessage); + } else { + searchStatusParagraph?.setHTMLUnsafe('Finished searching.'); + setState({state: 'Ready'}); + } + } +} + +routeWorker.onerror = e => { + console.log(e); +} + +function setUpMapHandler() { + mapHandler = new MapHandler(); +// Next, set up what to do when the user clicks different stuff in the map + mapHandler.addClickedMapListener((latitude, longitude) => { + let message: Message = {findClosestNode: {latitude: latitude, longitude: longitude}}; + routeWorker.postMessage(message); + }); + mapHandler.addClickedEndpointListener((startNodeId, endNodeId) => { + let message: Message = {getFullPath: {startNodeId: startNodeId, endNodeId: endNodeId}}; + routeWorker.postMessage(message); + }); + mapHandler.addExclusionAreaListener(polygons => { + let message: Message = {excludeAreas: { polygons: polygons}}; + routeWorker.postMessage(message); + let currentStartPoint = mapHandler?.getCurrentStartPointId(); + if (currentStartPoint == null) { + return; + } + if (currentStartPoint >= 0) { + let settings = getSettings(); + let newRoutesMessage: Message = {findPathsFromNode: { + nodeId: currentStartPoint, + minimumSpeed: settings.minimumSpeed, + maximumSpeed: settings.maximumSpeed, + maximumSpeedLimit: settings.maximumSpeedLimit, + dragCoefficient: settings.dragCoefficient, + allowMotorways: settings.allowMotorways, + allowTunnels: settings.allowTunnels, + allowAgainstOneway: settings.allowAgainstOneway + }}; + routeWorker.postMessage(newRoutesMessage); + } + }); + + mapHandler.addSearchAreaListener(polygons => { + currentSearchArea = polygons; + }); +} + + +// What to do when the window state changes +function setState(state: WindowState) { + currentState = state; + if (state.state == 'DataNotLoaded') { + freewheelingHeader?.style.setProperty('display', 'block'); + notLoadedContainer?.style.setProperty('display', 'block'); + loadingContainer?.style.setProperty('display', 'none'); + mapContainer?.style.setProperty('display', 'none'); + } else if (state.state == 'DataLoading') { + freewheelingHeader?.style.setProperty('display', 'block'); + notLoadedContainer?.style.setProperty('display', 'none'); + loadingContainer?.style.setProperty('display', 'block'); + mapContainer?.style.setProperty('display', 'none'); + } else if (state.state == 'Ready') { + freewheelingHeader?.style.setProperty('display', 'none'); + notLoadedContainer?.style.setProperty('display', 'none'); + loadingContainer?.style.setProperty('display', 'none'); + mapContainer?.style.setProperty('display', 'block'); + if (mapHandler == null) { + setUpMapHandler(); + } + mapHandler?.enableEditing(); + enableSettings(); + searchButton?.setHTMLUnsafe('Start search'); + } else if (state.state == 'Searching') { + freewheelingHeader?.style.setProperty('display', 'none'); + notLoadedContainer?.style.setProperty('display', 'none'); + loadingContainer?.style.setProperty('display', 'none'); + mapContainer?.style.setProperty('display', 'block'); + if (mapHandler == null) { + setUpMapHandler(); + } + mapHandler?.disableEditing(); + disableSettings(); + searchButton?.setHTMLUnsafe('Cancel search'); + } +}; +setState({state: 'DataNotLoaded'}); + +// Set up the settings values +function setUpNumberInput(element: HTMLElement | null, localStorageKey: string, defaultValue: number): void { + if (element != null && element instanceof HTMLInputElement) { + let numberValue: number = Number(localStorage.getItem(localStorageKey)) || defaultValue; + element.value = numberValue.toString(); + element.addEventListener('change', _ => localStorage.setItem(localStorageKey, element.value)); + } +} + +function setUpBooleanInput(element: HTMLElement | null, localStorageKey: string, defaultValue: boolean): void { + if (element != null && element instanceof HTMLInputElement) { + if (localStorage.getItem(localStorageKey) != null) { + element.checked = localStorage.getItem(localStorageKey) === 'true'; + } else { + element.checked = defaultValue; + } + + element.addEventListener('change', _ => {localStorage.setItem(localStorageKey, element.checked.toString())}); + } +} + +function getNumberValue(element: HTMLElement | null, defaultValue: number): number { + if (element != null && element instanceof HTMLInputElement) { + return Number(element.value) || defaultValue; + } + return defaultValue; +} + +function getBooleanValue(element: HTMLElement | null, defaultValue: boolean): boolean { + if (element != null && element instanceof HTMLInputElement) { + return element.checked; + } + return defaultValue; +} + +function enableInput(element: HTMLElement | null) { + if (element != null && element instanceof HTMLInputElement) { + element.disabled = false; + } +} + +function disableInput(element: HTMLElement | null) { + if (element != null && element instanceof HTMLInputElement) { + element.disabled = true; + } +} + +setUpNumberInput(minimumSpeedInput, 'minimum-speed', DEFAULT_MINIMUM_SPEED); +setUpNumberInput(maximumSpeedInput, 'maximum-speed', DEFAULT_MAXIMUM_SPEED); +setUpNumberInput(maximumSpeedLimitInput, 'maximum-speed-limit', DEFAULT_MAXIMUM_SPEED_LIMIT); +setUpNumberInput(dragCoefficientInput, 'drag-coefficient', DEFAULT_DRAG_COEFFICIENT); +setUpBooleanInput(allowMotorwaysInput, 'allow-motorways', DEFAULT_ALLOW_MOTORWAYS); +setUpBooleanInput(allowTunnelsInput, 'allow-tunnels', DEFAULT_ALLOW_TUNNELS); +setUpBooleanInput(allowAgainstOnewayInput, 'allow-against-one-way', DEFAULT_ALLOW_AGAINST_ONE_WAY); +setUpNumberInput(cutoffDistanceInput, 'cutoff-distance', DEFAULT_CUTOFF_DISTANCE); + +function getSettings(): Settings { + return { + minimumSpeed: getNumberValue(minimumSpeedInput, DEFAULT_MINIMUM_SPEED) / 3.6, + maximumSpeed: getNumberValue(maximumSpeedInput, DEFAULT_MAXIMUM_SPEED) / 3.6, + maximumSpeedLimit: getNumberValue(maximumSpeedLimitInput, DEFAULT_MAXIMUM_SPEED_LIMIT), + dragCoefficient: getNumberValue(dragCoefficientInput, DEFAULT_DRAG_COEFFICIENT), + allowMotorways: getBooleanValue(allowMotorwaysInput, DEFAULT_ALLOW_MOTORWAYS), + allowTunnels: getBooleanValue(allowTunnelsInput, DEFAULT_ALLOW_TUNNELS), + allowAgainstOneway: getBooleanValue(allowAgainstOnewayInput, DEFAULT_ALLOW_AGAINST_ONE_WAY), + cutoffDistance: getNumberValue(cutoffDistanceInput, DEFAULT_CUTOFF_DISTANCE) + }; +} + +function enableSettings(): void { + enableInput(minimumSpeedInput); + enableInput(maximumSpeedInput); + enableInput(maximumSpeedLimitInput); + enableInput(dragCoefficientInput); + enableInput(allowMotorwaysInput); + enableInput(allowTunnelsInput); + enableInput(allowAgainstOnewayInput); +} + +function disableSettings(): void { + disableInput(minimumSpeedInput); + disableInput(maximumSpeedInput); + disableInput(maximumSpeedLimitInput); + disableInput(dragCoefficientInput); + disableInput(allowMotorwaysInput); + disableInput(allowTunnelsInput); + disableInput(allowAgainstOnewayInput); +} + + +// Finally, set up various events when clicking things +searchButton?.addEventListener('click', _ => { + if (currentState.state == 'Ready') { + if (currentSearchArea.length > 0) { + setState({state: 'Searching'}) + let settings = getSettings(); + let message: Message = {searchArea: { + polygons: currentSearchArea, + minimumSpeed: settings.minimumSpeed, + maximumSpeed: settings.maximumSpeed, + maximumSpeedLimit: settings.maximumSpeedLimit, + dragCoefficient: settings.dragCoefficient, + allowMotorways: settings.allowMotorways, + allowTunnels: settings.allowTunnels, + allowAgainstOneway: settings.allowAgainstOneway + }}; + routeWorker.postMessage(message); + } + } else if (currentState.state == 'Searching') { + setState({state: 'Ready'}); + } +}); + +settingsButton?.addEventListener('click', _ => { + let settingsDisplay = settingsDiv?.style.getPropertyValue('display'); + if (settingsDisplay === 'none') { + settingsDiv?.style.setProperty('display', 'block'); + } else { + settingsDiv?.style.setProperty('display', 'none'); + } +}); + +document.getElementById('data-file-chooser')?.addEventListener('change', chooseEvent => { + let eventTarget = chooseEvent.target; + if (!(eventTarget instanceof HTMLInputElement) || eventTarget.files == null || eventTarget.files.length == 0) { + return; + } + + setState({state: 'DataLoading'}); + let reader = new FileReader(); + reader.onload = loadEvent => { + let data = loadEvent.target?.result; + if (data != null && data instanceof ArrayBuffer) { + let message: Message = {loadData: {data: new Uint8Array(data)}}; + routeWorker.postMessage(message); + } else { + setState({state: 'DataNotLoaded'}); + } + }; + + reader.readAsArrayBuffer(eventTarget.files[0]); +}); \ No newline at end of file diff --git a/src/modules/maphandler/icons.ts b/src/modules/maphandler/icons.ts new file mode 100644 index 0000000..0b9341a --- /dev/null +++ b/src/modules/maphandler/icons.ts @@ -0,0 +1,93 @@ +import L from 'leaflet' + +import blueUrl from '../../../static/marker-icon-2x-blue.png' +import goldUrl from '../../../static/marker-icon-2x-gold.png' +import redUrl from '../../../static/marker-icon-2x-red.png' +import greenUrl from '../../../static/marker-icon-2x-green.png' +import orangeUrl from '../../../static/marker-icon-2x-orange.png' +import yellowUrl from '../../../static/marker-icon-2x-yellow.png' +import violetUrl from '../../../static/marker-icon-2x-violet.png' +import greyUrl from '../../../static/marker-icon-2x-grey.png' +import blackUrl from '../../../static/marker-icon-2x-black.png' +import shadowUrl from '../../../static/marker-shadow.png' + +export const blueIcon = new L.Icon({ + iconUrl: blueUrl, + shadowUrl: shadowUrl, + iconSize: [25, 41], + iconAnchor: [12, 41], + popupAnchor: [1, -34], + shadowSize: [41, 41] +}); + +export const goldIcon = new L.Icon({ + iconUrl: goldUrl, + shadowUrl: shadowUrl, + iconSize: [25, 41], + iconAnchor: [12, 41], + popupAnchor: [1, -34], + shadowSize: [41, 41] +}); + +export const redIcon = new L.Icon({ + iconUrl: redUrl, + shadowUrl: shadowUrl, + iconSize: [25, 41], + iconAnchor: [12, 41], + popupAnchor: [1, -34], + shadowSize: [41, 41] +}); + +export const greenIcon = new L.Icon({ + iconUrl: greenUrl, + shadowUrl: shadowUrl, + iconSize: [25, 41], + iconAnchor: [12, 41], + popupAnchor: [1, -34], + shadowSize: [41, 41] +}); + +export const orangeIcon = new L.Icon({ + iconUrl: orangeUrl, + shadowUrl: shadowUrl, + iconSize: [25, 41], + iconAnchor: [12, 41], + popupAnchor: [1, -34], + shadowSize: [41, 41] +}); + +export const yellowIcon = new L.Icon({ + iconUrl: yellowUrl, + shadowUrl: shadowUrl, + iconSize: [25, 41], + iconAnchor: [12, 41], + popupAnchor: [1, -34], + shadowSize: [41, 41] +}); + +export const violetIcon = new L.Icon({ + iconUrl: violetUrl, + shadowUrl: shadowUrl, + iconSize: [25, 41], + iconAnchor: [12, 41], + popupAnchor: [1, -34], + shadowSize: [41, 41] +}); + +export const greyIcon = new L.Icon({ + iconUrl: greyUrl, + shadowUrl: shadowUrl, + iconSize: [25, 41], + iconAnchor: [12, 41], + popupAnchor: [1, -34], + shadowSize: [41, 41] +}); + +export const blackIcon = new L.Icon({ + iconUrl: blackUrl, + shadowUrl: shadowUrl, + iconSize: [25, 41], + iconAnchor: [12, 41], + popupAnchor: [1, -34], + shadowSize: [41, 41] +}); \ No newline at end of file diff --git a/src/modules/maphandler/maphandler.css b/src/modules/maphandler/maphandler.css new file mode 100644 index 0000000..4b53a4e --- /dev/null +++ b/src/modules/maphandler/maphandler.css @@ -0,0 +1,11 @@ +.search-icon { + background-image: url('data:image/svg+xml,'); +} + +.exclude-icon { + background-image: url('data:image/svg+xml,'); +} + +.cancel-icon { + background-image: url('data:image/svg+xml,') +} \ No newline at end of file diff --git a/src/modules/maphandler/maphandler.ts b/src/modules/maphandler/maphandler.ts new file mode 100644 index 0000000..40f2242 --- /dev/null +++ b/src/modules/maphandler/maphandler.ts @@ -0,0 +1,347 @@ +import 'leaflet/dist/leaflet.css'; +import L, { FeatureGroup, Marker, Polygon, Polyline, type LatLngTuple } from 'leaflet'; +import '@geoman-io/leaflet-geoman-free' +import '@geoman-io/leaflet-geoman-free/dist/leaflet-geoman.css' + +import './maphandler.css' +import { greenIcon, redIcon, violetIcon } from './icons.ts' +import type { Endpoint, Coordinate, Polygon as InterfacePolygon } from '../../interfaces.ts' +import type { Position } from 'geojson'; + +interface ClickedMapListener { + (latitude: number, longitude: number): void; +} + +interface ClickedEndpointListener { + (startpointId: number, endpointId: number): void; +} + +interface SearchAreaPolygonListener { + (searchAreaPolygons: InterfacePolygon[]): void; +} + +interface ExclusionPolygonListener { + (exclusionPolygons: InterfacePolygon[]): void; +} + +interface OnChangeFunction { + (): void; +} + +class MapHandler { + map; + currentStartPoint?: number; + + // Whenever the polygons of either the search area or the exclusion area are changed, these listeners are called + clickedMapListeners: ClickedMapListener[] = []; + clickedEndpointListeners: ClickedEndpointListener[] = []; + searchAreaPolygonListeners: SearchAreaPolygonListener[] = []; + exclusionPolygonListeners: ExclusionPolygonListener[] = []; + + // We need feature groups to store the polygons that define these areas + searchAreaFeatureGroup: FeatureGroup; + exclusionAreaFeatureGroup: FeatureGroup; + + startMarker?: Marker; + endMarkers: FeatureGroup; + path?: Polyline; + + editingPolygons: boolean = false; + + onChangeFunction?: OnChangeFunction + + constructor() { + + // Use OpenStreetMaps and center on Oslo + this.map = L.map('map', { + center: L.latLng(59.92, 10.74), + zoom: 13, + }); + + L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap contributors' + }).addTo(this.map); + + // Make sure that geoman can only control the polygons it itself has created + L.PM.setOptIn(true); + this.map.on('pm:create', (e) => { + e.layer.options.pmIgnore = false; + L.PM.reInitLayer(e.layer); + + if (this.onChangeFunction != null) { + e.layer.on('pm:edit', this.onChangeFunction); + e.layer.on('pm:drag', this.onChangeFunction); + e.layer.on('pm:cut', this.onChangeFunction); + e.layer.on('pm:remove', this.onChangeFunction); + e.layer.on('pm:rotate', this.onChangeFunction); + this.onChangeFunction(); + }; + }) + + // Make sure we don't start route searches points when editing polygons + this.map.on('pm:globaldrawmodetoggled', e => { + this.editingPolygons = e.enabled; + }); + + this.map.on('pm:globalcutmodetoggled', e => { + this.editingPolygons = e.enabled; + }); + + this.map.on('pm:globaldragmodetoggled', e => { + this.editingPolygons = e.enabled; + }); + + this.map.on('pm:globaleditmodetoggled', e => { + this.editingPolygons = e.enabled; + }); + + this.map.on('pm:globalrotatemodetoggled', e => { + this.editingPolygons = e.enabled; + }); + + this.map.on('pm:globalremovalmodetoggled', e => { + this.editingPolygons = e.enabled; + }) + + // Add geoman controls for drawing these polygons + this.map.pm.addControls({ + position: 'bottomleft', + drawMarker: false, + drawCircle: false, + drawCircleMarker: false, + drawPolyline: false, + drawText: false, + drawRectangle: false, + drawPolygon: false + }); + + this.map.pm.Toolbar.copyDrawControl('Polygon', { + name: 'searcharea', + block: 'draw', + title: 'Define the search area for longest route', + className: 'search-icon', + onClick: () => {this.setFeatureGroup(this.searchAreaFeatureGroup, 'green')} + }) + + this.map.pm.Toolbar.copyDrawControl('Polygon', { + name: 'exclusionarea', + block: 'draw', + title: 'Draw areas that should not be entered', + className: 'exclude-icon', + onClick: () => {this.setFeatureGroup(this.exclusionAreaFeatureGroup, 'red')} + }); + + this.map.pm.Toolbar.createCustomControl({ + name: 'removePolygons', + block: 'draw', + title: 'Remove all search and exclusion areas', + className: 'cancel-icon', + onClick: () => { + this.searchAreaFeatureGroup.clearLayers(); + this.exclusionAreaFeatureGroup.clearLayers(); + this.onExclusionAreaChange(); + this.onSearchAreaChange(); + } + }); + + this.map.pm.Toolbar.setBlockPosition('draw', 'topleft'); + + // In order for the polygons in the feature groups to be displayed, they must be added to the map + this.searchAreaFeatureGroup = new L.FeatureGroup().addTo(this.map); + this.exclusionAreaFeatureGroup = new L.FeatureGroup().addTo(this.map); + this.endMarkers = new L.FeatureGroup().addTo(this.map); + + this.map.addEventListener('click', e => { + if (!this.editingPolygons) { + this.clickedMapListeners.forEach(routeSearchListener => { + routeSearchListener(e.latlng.lat, e.latlng.lng); + }); + } + }); + + this.exclusionAreaFeatureGroup.addEventListener('pm:change', _ => { + let polygons: Polygon[] = []; + this.exclusionAreaFeatureGroup.eachLayer(layer => { + if (layer instanceof Polygon) { + polygons.push(layer); + } + }); + }); + } + + public getCurrentStartPointId(): number { + if (this.currentStartPoint) { + return this.currentStartPoint; + } else { + return -1; + } + } + + private getPolygonsOfFeatureGroup(featureGroup: FeatureGroup): InterfacePolygon[] { + let multiPolygonCoordinates: Position[][][] = []; + featureGroup.eachLayer(layer => { + if (layer instanceof Polygon) { + let geojson = layer.toGeoJSON(); + if (geojson.geometry.type == 'Polygon') { + multiPolygonCoordinates.push(geojson.geometry.coordinates); + } else if (geojson.geometry.type == 'MultiPolygon') { + geojson.geometry.coordinates.forEach(polygon => { + multiPolygonCoordinates.push(polygon); + }) + } + } + }); + + return multiPolygonCoordinates.map(polygonCoordinates => { + return {rings: polygonCoordinates.map(ringCoordinates => { + return {coordinates: ringCoordinates.map(singleCoordinate => { + return { + latitude: singleCoordinate[1], + longitude: singleCoordinate[0] + } + })} + })}; + }); + } + + private onExclusionAreaChange(): void { + let multiPolygon: InterfacePolygon[] = this.getPolygonsOfFeatureGroup(this.exclusionAreaFeatureGroup); + this.exclusionPolygonListeners.forEach(listener => { + listener(multiPolygon); + }) + } + + private onSearchAreaChange(): void { + let multiPolygon: InterfacePolygon[] = this.getPolygonsOfFeatureGroup(this.searchAreaFeatureGroup); + this.searchAreaPolygonListeners.forEach(listener => { + listener(multiPolygon); + }) + } + + // This function is called when clicking one of the draw buttons, to make sure the polygons drawn have the right colour and + // are added to the right feature group + private setFeatureGroup(featureGroup: FeatureGroup, color: string): void { + this.map.pm.setGlobalOptions({ + layerGroup: featureGroup, + pathOptions: {color: color, fillOpacity: 0.05}, + templineStyle: {color: color, radius: 10}, + hintlineStyle: {color: color, dashArray: [5, 5]} + }); + + if (featureGroup === this.exclusionAreaFeatureGroup) { + this.onChangeFunction = this.onExclusionAreaChange; + } else if (featureGroup === this.searchAreaFeatureGroup) { + this.onChangeFunction = this.onSearchAreaChange; + } + } + + public addClickedMapListener(routeSearchListener: ClickedMapListener): void { + this.clickedMapListeners.push(routeSearchListener); + } + + public addClickedEndpointListener(clickedEndpointListener: ClickedEndpointListener): void { + this.clickedEndpointListeners.push(clickedEndpointListener); + } + + public addExclusionAreaListener(exclusionAreaListener: ExclusionPolygonListener): void { + this.exclusionPolygonListeners.push(exclusionAreaListener); + } + + public addSearchAreaListener(searchAreaListener: SearchAreaPolygonListener): void { + this.searchAreaPolygonListeners.push(searchAreaListener); + } + + public drawStartNode(nodeId: number, latitude: number, longitude: number): void { + if (this.path != null) { + this.map.removeLayer(this.path); + this.path = undefined; + } + + if (this.startMarker != null) { + this.map.removeLayer(this.startMarker); + } + + this.currentStartPoint = nodeId; + this.endMarkers.clearLayers(); + this.startMarker = L.marker([latitude, longitude], {icon: greenIcon}).addTo(this.map); + } + + public drawEndPoints(endpoints: Endpoint[]): void { + this.endMarkers.clearLayers(); + if (this.path != null) { + this.map.removeLayer(this.path); + this.path = undefined; + } + var firstMarker = true; + endpoints.forEach(endpoint => { + var settings; + if (firstMarker) { + settings = { + icon: redIcon + }; + } else { + settings = { + icon: violetIcon, + opacity: 0.7 + }; + } + + var marker = L.marker([endpoint.latitude, endpoint.longitude], settings).addTo(this.endMarkers); + marker.bindTooltip(Math.round(endpoint.distanceFromStart) + 'm'); + if (firstMarker) { + marker.openTooltip(); + } + firstMarker = false; + + marker.addEventListener('click', _ => { + this.clickedEndpointListeners.forEach(endpointListener => { + if (this.currentStartPoint != null) { + endpointListener(this.currentStartPoint, endpoint.nodeId); + } + }); + }); + }); + } + + public drawPath(coordinates: Coordinate[]): void { + if (this.path != null) { + this.map.removeLayer(this.path); + } + + var leafletCoordinates = coordinates.map(coordinate => { + let latLngTuple: LatLngTuple = [coordinate.latitude, coordinate.longitude]; + return latLngTuple; + }); + + this.path = L.polyline(leafletCoordinates).addTo(this.map); + } + + public enableEditing() { + this.map.pm.Toolbar.setButtonDisabled('searcharea', false); + this.map.pm.Toolbar.setButtonDisabled('exclusionarea', false); + this.map.pm.Toolbar.setButtonDisabled('editMode', false); + this.map.pm.Toolbar.setButtonDisabled('dragMode', false); + this.map.pm.Toolbar.setButtonDisabled('cutPolygon', false); + this.map.pm.Toolbar.setButtonDisabled('removalMode', false); + this.map.pm.Toolbar.setButtonDisabled('rotateMode', false); + } + + public disableEditing() { + this.map.pm.Toolbar.setButtonDisabled('searcharea', true); + this.map.pm.Toolbar.setButtonDisabled('exclusionarea', true); + this.map.pm.Toolbar.setButtonDisabled('editMode', true); + this.map.pm.Toolbar.setButtonDisabled('dragMode', true); + this.map.pm.Toolbar.setButtonDisabled('cutPolygon', true); + this.map.pm.Toolbar.setButtonDisabled('removalMode', true); + this.map.pm.Toolbar.setButtonDisabled('rotateMode', true); + + this.map.pm.disableDraw(); + this.map.pm.disableGlobalEditMode(); + this.map.pm.disableGlobalDragMode(); + this.map.pm.disableGlobalRemovalMode(); + this.map.pm.disableGlobalCutMode(); + this.map.pm.disableGlobalRotateMode(); + } +} + +export default MapHandler; \ No newline at end of file diff --git a/src/modules/worker/worker.ts b/src/modules/worker/worker.ts new file mode 100644 index 0000000..069142d --- /dev/null +++ b/src/modules/worker/worker.ts @@ -0,0 +1,191 @@ +import Module, { type AreaSearchEntries, type MainModule, type MultiPolygon } from '../../../native/route_search.js'; +import proj4 from 'proj4'; + +import type { Endpoint, Message, Polygon, SearchAreaResultEntry } from '../../interfaces'; + +var dataLoaded = false; +var module: MainModule | undefined = undefined; + +function sendErrorMessage(error: string): void { + let message: Message = {errorMessage: {error: error}}; + postMessage(message); +} + +function createCMultiPolygon(module: MainModule, polygons: Polygon[]): MultiPolygon { + let multiPolygon = new module.MultiPolygon(); + polygons.forEach(polygon => { + let cPolygon = new module.Polygon(); + polygon.rings.forEach(ring => { + let cRing = new module.Ring(); + ring.coordinates.forEach(coordinate => { + let polygonCoordinate = new module.PolygonCoordinate(); + let utmCoordinates = proj4('EPSG:4326', 'EPSG:32633', [coordinate.longitude, coordinate.latitude]); + polygonCoordinate.x = utmCoordinates[0]; + polygonCoordinate.y = utmCoordinates[1]; + cRing.push_back(polygonCoordinate); + }); + cPolygon.push_back(cRing); + }); + multiPolygon.push_back(cPolygon); + }); + return multiPolygon; +} + +function getAreaSearchResults(searchedNodes: AreaSearchEntries): SearchAreaResultEntry[] { + let searchResults: SearchAreaResultEntry[] = []; + for (var i = 0; i < searchedNodes.size(); i++) { + let searchedNode = searchedNodes.get(i); + if (searchedNode != null) { + let utmCoordinates = [searchedNode.positionX, searchedNode.positionY]; + let lngLatCoordinates = proj4('EPSG:32633', 'EPSG:4326', utmCoordinates); + searchResults.push({ + nodeId: searchedNode.nodeId, + latitude: lngLatCoordinates[1], + longitude: lngLatCoordinates[0], + longestRoute: searchedNode.longestRoute + }); + } + } + return searchResults; +} + +onmessage = async (e) => { + if (module == null) { + module = await Module(); + } + + let message = e.data as Message; + + if (message.loadData != null) { + module.FS.writeFile('roads.dat', message.loadData.data); + module.loadData('roads.dat'); + dataLoaded = true; + + let returnMessage: Message = {dataLoaded: {}} + postMessage(returnMessage); + return; + } + + // If the data is not loaded, it will not be possible to perform any further operations + if (!dataLoaded) { + sendErrorMessage('Data not loaded') + return; + } + + if (message.findClosestNode != null) { + let lngLatCoordinates = [message.findClosestNode.longitude, message.findClosestNode.latitude]; + let utmCoordinates = proj4('EPSG:4326', 'EPSG:32633', lngLatCoordinates); + let node = module.findClosestNode(utmCoordinates[0], utmCoordinates[1]); + + let nodeUtmCoordinates = [node.positionX, node.positionY]; + let nodeLngLatCoordinates = proj4('EPSG:32633', 'EPSG:4326', nodeUtmCoordinates); + + let returnMessage: Message = { + foundClosestNode: { + originalLatitude: message.findClosestNode.latitude, + originalLongitude: message.findClosestNode.longitude, + foundLatitude: nodeLngLatCoordinates[1], + foundLongitude: nodeLngLatCoordinates[0], + foundNodeId: node.nodeId + } + }; + node.delete(); + postMessage(returnMessage); + }; + + if (message.findPathsFromNode != null) { + let results = module.findAllPathsFromPoint( + message.findPathsFromNode.nodeId, + message.findPathsFromNode.minimumSpeed, + message.findPathsFromNode.maximumSpeed, + message.findPathsFromNode.maximumSpeedLimit, + message.findPathsFromNode.dragCoefficient, + message.findPathsFromNode.allowMotorways, + message.findPathsFromNode.allowTunnels, + message.findPathsFromNode.allowAgainstOneway + ); + + let endpoints: Endpoint[] = []; + for (var i = 0; i < results.endPoints.size(); i++) { + let nodeData = results.endPoints.get(i); + if (!nodeData) { + sendErrorMessage('Could not find paths from node ' + message.findPathsFromNode.nodeId); + return; + } + let coordinates = [nodeData.positionX, nodeData.positionY]; + let lngLatCoordinates = proj4('EPSG:32633', 'EPSG:4326', coordinates); + endpoints.push({ + nodeId: nodeData.nodeId, + latitude: lngLatCoordinates[1], + longitude: lngLatCoordinates[0], + distanceFromStart: nodeData.distanceFromStart + }); + } + results.delete(); + + let returnMessage: Message = {foundPathsFromNode: {nodeId: message.findPathsFromNode.nodeId, endpoints: endpoints}}; + postMessage(returnMessage); + } + + if (message.getFullPath != null) { + let path = module.getPath(message.getFullPath.startNodeId, message.getFullPath.endNodeId); + if (!path) { + sendErrorMessage('Could not get path'); + return; + } + var coordinates = []; + for (var i = 0; i < path.size(); i++) { + let currentPoint = path.get(i); + if (!currentPoint) { + continue; + } + coordinates.push([currentPoint.positionX, currentPoint.positionY]); + } + path.delete(); + + coordinates = coordinates.map(utmCoordinate => { + let lngLat = proj4('EPSG:32633', 'EPSG:4326', utmCoordinate); + return {latitude: lngLat[1], longitude: lngLat[0]}; + }); + + let returnMessage: Message = {returnFullPath: {coordinates: coordinates}}; + postMessage(returnMessage); + } + + if (message.excludeAreas != null) { + module.excludeNodesWithinPolygons(createCMultiPolygon(module, message.excludeAreas.polygons)); + } + + if (message.searchArea != null) { + let settings = message.searchArea; + let result = module.startAreaSearch( + settings.minimumSpeed, + settings.maximumSpeed, + settings.maximumSpeedLimit, + settings.dragCoefficient, + settings.allowMotorways, + settings.allowTunnels, + settings.allowAgainstOneway, + createCMultiPolygon(module, settings.polygons) + ); + + let returnMessage: Message = { searchAreaResult: { + remainingNodes: result.remainingNodes, + searchResults: getAreaSearchResults(result.searchedNodes) + }}; + + result.delete(); + postMessage(returnMessage); + } + + if (message.continueSearch != null) { + let result = module.continueAreaSearch(); + let returnMessage: Message = { searchAreaResult: { + remainingNodes: result.remainingNodes, + searchResults: getAreaSearchResults(result.searchedNodes) + }}; + + result.delete(); + postMessage(returnMessage); + } +} \ No newline at end of file diff --git a/src/style.css b/src/style.css new file mode 100644 index 0000000..04e7223 --- /dev/null +++ b/src/style.css @@ -0,0 +1,74 @@ +:root { + --background-color: #fff7f7; + --header-color: #d46a64; + --header-stroke-color: #550000; + --text-color: #000000; + --collapsible-background-color: #AA3939; + --collapsible-active-background-color: #D46A6A; + --collapsible-color: #FFAAAA; + --collapsible-active-color: #550000; + --explanation-background-color: #FFAAAA; +} + +body { + background-color: var(--background-color); + color: var(--text-color); + text-align: center; + font-family: 'Courier New'; + font-size: 20px; +} + +h1 { + color: var(--header-color); + -webkit-text-stroke-width: 2px; + -webkit-text-stroke-color: var(--header-stroke-color); + font-size: 80px; + font-family: 'Helvetica'; + margin-bottom: 20px; +} + +p { + width: 80vw; + margin-left: auto; + margin-right: auto; + text-align: center; +} + +#settings-div { + width: 40vw; + margin-left: auto; + margin-right: auto; + border-style: solid; + border-width: 2px; + padding: 10px; + margin-top:10px; +} + +.settings-line { + display: flex; + flex-direction: row; + justify-content: space-between; + margin-bottom: 2px; +} + +#map { + height: 80vh; +} + +table { + width: max(60vw, 800px); + margin-left: auto; + margin-right: auto; + border: solid 1px; + max-height: 50vh; + overflow-y: scroll; +} + +th { + border: solid 1px; + width: max(15vw, 200px); +} + +td { + border: solid 1px; +} \ 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/bicycle-solid.svg b/static/bicycle-solid.svg new file mode 100644 index 0000000..c31ed29 --- /dev/null +++ b/static/bicycle-solid.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/marker-icon-2x-black.png b/static/marker-icon-2x-black.png new file mode 100644 index 0000000000000000000000000000000000000000..23c94cfed50fb9b7adc5f0b99c5d5e8f4eeb58bc GIT binary patch literal 3183 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 U(+;`1LjV8(07*qoM6N<$g4YR6xc~qF literal 0 HcmV?d00001 diff --git a/static/marker-icon-2x-green.png b/static/marker-icon-2x-green.png new file mode 100644 index 0000000000000000000000000000000000000000..c359abb6c2c2a1ee7272e3f85ee825583f6924f2 GIT binary patch literal 4203 zcmV-x5R~tUP)~BqEIT3 zP^qfMc1(Ddr-&0sp|liQl@_&zh$IalD1n4LjPd)G0CCo@wb%AO@9WIWuYb(F*YCT# z_O1gRYyQ~lb7tl{zxQ|Mh^q4cp}CnJ@a8WqyHv;*32~9a<*K?!3>mNol>ssA2^t1& z8eg>U-uFBdW@=tJSBbpy8IestSblRG(#>*5;(c3Dp8Ud7`2GVDbK_hiAR-UZ=ls`b$%WKR^QlIc>h%GXi7kC zyk|*n%hmgVw*!a>l}jqPh316Cc`fkGkO0I8Cu@*N?}`;{Zg7%eFvs2>4Al2Ej+f9w zm05{=&6O5&JD7F9YaXRK>nH?Vu8AH49=RG82)=<_1@G zSV<}xh9k9S8)y>ifhc<0)9={y)U<)z`uW!^jwU`8;bJ3}%90$pApKwAJ+U58&&)Ov zY}JtENjf?XjhzJ{dJIfNwXc2ho$EJG8OU$mbIY7sR(}k5m2-~rvbGxqikc@h1GCvo z*^pPV1g21$TRcU}1NR$z3WiXUqkwB;_&GFW(g8+z) zNOQb}sv?xTQ5FA>$PfSYqkA97`KgRO~z*qpveixtErmm3R3!}P~6 zB_Z!?tJ!mg@%RVNn(n~W`lva#{TAbR> zWkP9wMpky%>{t}n8Zah^3IaH5aTQBGH^Y=i0#n%JPW;00uFvd_SmB=a?TowrZVEQtQ z3p8sUJIQ|eiGa?t@S;e0PL;-Z4T<#-{=Lm-nIlf!K=p zvB^D@o98g?C~pTcCI%+($7Q!7l5FIp-}*1^r?{%XilPyUk>b>p=H%9A$;*Z?Zm4gl zb;i2&>Gy1Uyd8PM+dwE)N)-KR@o^&HCy_>}-x950n?l@X7@0^Z(SrUlhdqVD#W9rI8YDsl)dNT)(9;+&jX$dccX?R)yET`qy~-)qlZD@|R%DF@s-P zxdy#N*?08>5A6674?gt(jb;`o7HIVdd`^UHo+DVn?#`IlJBFz6SHJUKZoBMuthHSbU87e`Mj8U% zdmbNtl#f63F=}Cg{<-}WuPjpLFziu(W-xilLcc|7$JEQJEHgp%jjBps$`S~mRRpx{ zE>GL5hF}F(Gt~BtqpG~&HNVYU7TrOXWjN=s)^-6IW4eDk=g9M%Tj#%?x4rHTRF%=; zQL;m}>%M!<-D-O;tw!=ZPn)Cqy9RivsNhOjsv~?%$zZR@>(UO(FDx3A7x!PvTbKSp z7eHt6$9ECw0y4(noa0Xx{~1eOz64RB^}I(_ac+``?;*A#s!CRNhzhEDrBT&Is){ph zPdtUNVO$D7%|-)N<(8{o%d);}Pf(|mgW>VNA1|ffzv8x(0yR=~7$ju_QwqK=#S~y+ z7l@W)H{0wPHae3;Xw_3L^zwO2PT2X=m9!@V>OB9_c~@dp2q9p`Y&W&()jDg`Ja%mF zGGkR1i3*{Tj4`&2@=P5os48Y8sg=QS(?vJ-T>P29hzM6#Ude^k1&9jnh)F8jp5shC z#snZ#G-V7b3tfac0BV3B5VWUyNs>R(O6pa%6)rAcdcq>7+e-Z0ek#Uew$uYe=h+w|8xE{TfGl@J zlPIJ~ftaLT-g_iT=y{{6yH%B<;Zf0JiM{9W6iDsOD1UKj6Kwo3KW+& zDb1~*M85jJUt!nS4nhd01=Lx52!Wy~*uxlKeEfduMV+ja5tsOGz20l?4hbSbXpS{I zGWva`A&V-PAEQi< zQ=?9vwPAEUpMCtp?E1w{P@#HtAED}rg%Tzy9Zc0?oZ$9nw2rm_Y8RRQciHVv?uqyR z@ZK9l=LZdcbA|N>DZxilYU?6GwS;#}7E{4w02|E_MsLQV1cib9@UA zJ@+j>_sAOd9^8}s=8OA@i##a!;gB||$GooJV#E-iSA4wyc;vCa-SAbHYH9N@QEzXJ zHpn#x*Ve-xWPz$ujC-){&on7&Exz&CTE6kvS{76nuyFB0a+kCJxdZGS z8z8nFfh$=mSM=c*v=WH1Fey19S&A{LN^_)<)J^2Ut|N1;_&>p+p&7&;bc6*SQ#JUp zVv#5U&-$4-FpkE^!HGc*ZXG;vF~(4yUqP0D&l7hQYLU>GoVIl3 z#HQE-j@YULL^1xs6X>8dv7I7>facXl^E=)keMx_{O5>+19v|`(BC(}XqY2;1CaD%MgN2~-) zMTqq&WW%bo#tQJ3ykZdpzQ2I}W6u;cPh>{MBfjC8YC@A&QbK7aERB(gTr~x|8hnk zTUKlu0{*ij6%WMRpcn$YAl*oYSiu90!6XeM^5re7ca8TxR)~BKpf%c}G@48(D`hX( zjRsRqODqk!P5@NF+&k&9!iLp5o&eSZ6#J5)n@XF~5k0TnipAt!>VnMO&dttx>o@BBQ5N( zL!nDk&SrPzG?yK66z#-SR98+`{nA4!k4WT40IhwEuB~)g$yu_Ej;AX2rnnJR*KW9L z=TFWW2tc%YCqNwbxFZQbsZ^RZAbB~}vUboCCIUcolI-=IfNWa1^HCN3c1J3mvu|e{ zN}SQ2NtXBmRf@I-0RQpRRXetvGmzx>>ajyfDtVswK6*Hde!MhdA~-^{vU-a5 zb0%+_R&RS6_$GkgpS+04P4e|KB#n}kkSxUw-;svMS2o|debt9Bg*<`14!u+P-(zfB?AY?pA0?BVrDwg6phpMFp3Y2!ISK z+Ef%K3ek^EyKb583qSq9*4-lVMSx;|6C2ZUT*;KDtx{!I#yPMa!eG&%WAE9vYRAD@ z2LfQlei*1rQ{iAbJu18M^y=_PQpq6bpmvTDMxy8^&Z?3;o0rzF*s?#sy#P8`fQMip zlL|gErCu^+f`QASv1xe-DglQ;|c81u9tu3zO12Cp>$oD~-d} zHa~OLwf)(?xa9*sJ1nZ70f+}Z5(H}?x0Okmk=$0mfO-Q5d?&;G_=dmx`Pg{}0+5Hw zr+_0`D0(RIxt*OplWm9!m8_DS)?k-`KcKdB?>Vt8=koGXcW)R~)pSwvfF~B|NG!LN zo}NlYkwP$sy42}Mx2)Jyd%=LDXkS_b3~EqlFRWrKojW3@O2Z~;_+!!-h(-M$&YAx@ z*8$o)`4IqfFk+kOK4aBXPkFZLs_FVh%XFoosvp|?!QIUl90)*rM+Y=0amb%mDxyL~ zk~HE0-?`cF^Oqf-|4%c*NY2C{n?Ja_DXJd?Fo#0A=xmbVcWk5Ts_CWW!xMe}Kw&j^o9?y=mpnM}Osjv|HC6B$}18 zIg!lv_w38>UiwXdjjMLtHkY> zK~#9!-CJ919Mu{A&Rll(X2@e*?3 zp$Se}AdpKVl|Wg0wr-kKy`ToQq1;*#fdUjtiVBTVg*GO!lQ`Jf^@@r^@B>qx<*opKckD zty{N#UkLFyfL;L5G!31dorp%GP*ru-{X_(wUkCvq#H@Q<*F`>`M>3g2xm@O-rvU5- zhr`d->qm6~dGNsp1L1J^SpYu(07X&I)zyWLj*c0dvuzudN(HX#!gXC-JvW0~@x3RXd@{ReAe%OA>QYtp2!O8vz>+0P(ACu?8E{<}<#KsG z+poU35YRLYhG9Ta6ggiqnM5j;;@duSU3dN7y?f6!3}o}>&7V~iIby}j&{HvxEV=gys4O(2;}W(N`7Lqu4%Y#9Q90Eh^= zTn@Hv%aGdY%eHOg^LY>vbX`X*7UL+mHj~Nhu4-DFHf{P05uF5}b$558tE&qDP$(2o zsZEXz_|*WCkP{VdaZ z=%I(cN<`lwBJ}k1$e3O#mFknxnufNvHW-F6BhghVl^K4eswx73KwVNs>J~3g0YGa z1;Jnt!C(;W?d>oOL*8F37HhJjsw$$pFURdXUX#0pN?%)6;7Jyrp?JeOCy9P$(o%*0Lc66aU4ucOz6(2 zSFgsZRjaUaqZZX%YY-7GT)2RIJ`Vu6^Ugc5e*OAc_CGK%fPsMltX;bn zufP5}jvhS<%d#*wHinySx(SM+z%Y!eKIytHDwPT}O+z#q*5e&nSr&uf8vbd{MD){7+Pe4St>#nZ-MV!PPIkB7emicx_0}tu)YR0Bx#zoF*Odx`K@-t{LPRTwNQ%OGk`$Xxp-=!3 zVa=K~^7)01wQJXch+tWkG#!@m^9>F{?4tmBX91}tBX|)aLa|r`5nUrsI&$Yg98Y|KS7ZRRBbpQvXf! zU3?#(ioXzQba2LY9B0NFwJx8Qizq}iMnp0i)UrJpi@B)Yk zZEbBc{G^`nUn-UOaL*}3^fuSybA+`yF9zM#)`s@>cDSyKLx&D6xSl_F@E|P9f?*gk za+S;Fs%!i69hbsHbV4bWN^b#36Ooh&wF(~Fw&kW{u^3cU#pL89_V3@1iHV7t0J7OE z;_*06pFRz4aB!o$Rw2iGV-Nrep-||U`u_XxyGM^6T`h$8oWFRfHKA?W&@>IIs=_b~ zjgkt68p>cYUl00MykMn^|6F)<+}P#_RMZ*MO`p^%)bmOSDTnu|Ek@&6>754LT; ztSAbV%Vkk06c7%F5eNjNsZ~>=g+f6VWGh#$L^7F_jp(VVDNIdG%?h0z9UWM@bms#_V#uF!1(w$m%4u(8XCI$>UPl6M6@oG$;bjL5D3(DQXKUR!+>EJviQ+; zT`oRX*;;Rh#-A*0+eS8<<(&7_tiIMqAAR)F%{SltF%k8rQYmzIcSF~8cBr}rcTN;V zk%55wTaM$vw(W)*bwUWqWHOm#v3e>VkN;&>U+dVhV=jPa0U(u1$t>SUox$%+EXzVJ zmqRw2MWIkYxm<3j+vv|7{N99!o}Rb&tEy@OK&f0VOZs$Ozh?d3`7YLUnM{VO!;IrN z2j>M6kH@C~{EjK)1XN41v~sX{vZV1$?H3~>Be|;Gcp`d+LmX#suII1E#ZGQn7W*p+ zAr91ht&m70{tDplepXnSUJo`{k}w-Y^vdw?@JBU)csK6@fa&RJ$%?P_a!uLb-hh`k zAcWX6XN@w~k7v%D`3-=75|KQKKp;@FdTi#007K5x}H-yclVd>+7i{^l7+uvVKCM=B!nv+>gK@bH@p2I7rvthu;{buD~_eWOq) zNa0FE+pE^`)&6Lj<|{;WoLS+#QmcWsSl{4G&TJ6T^NB>_M8iNlyZj;N71mw+4!o7V z;akrYLOfFIxtc&s(|iNKt5-LgYt{9QTr4z+$Y00&)-=t3E*gm6R|;+I21L#LYt#2ecIkY!o&HCr=%h3Cp< zvz)go0De^O^K}C;P4hzlzhPF`SL*HJSG#mwm-Ra{4!|599{#xLK)lAm4ggN6RFbST z!&i8YOeVukg(85*7PPm8+&nZibef0`Ud_s4iZ9l1{Nxy89d5Ps2bC9z1xNi1q_lK0Ib$srBUoH#68b zm<=M@ok%3I*Bpq~9M}gS={U|TR^~G)|2^q+T7K87Y1)Cs>}63vhK7bRM6{brV#jgh zx4YHY;1()_2e2z1j~A{R5KlZl1>lnFx^iQ_Z$xIX!GH6yF;*-VaR6i0Sn?Af28upKl=Lmo@?gXO$Bo7*fH6< z@x6IvNh6CfB6?(SaF9N=KmcHPc=!l_6YLKhwR~*6n@A-7*z9#%)n1qFBmOp?+ek#T ztrZK3R{gK;efQmW9KcuE(Y>zW8>VS~y%p=V?1cvr{g46pUU6@4)p{)hl1L<82k>X# zPp^9I*Z(abeqU)d8!hz_pRfGEYg*jh%5}6BGXf%d1lM5v7jS1S`wZT)V*mgE07*qo IM6N<$f)ZuZgXGXy)F-2*eNI;eWHu;frj%jceYy9&?|biamuES@{&DWT_|EXI^S*<6 zCMS7w=RD_}@A>WD^E^sK_+4o3a!>f^C%3JaOlp(FvPmJEMP!p!c))<5`jt`xn7Dsx zc$;#cNgR>H5pf)2j5&F_BCrpoKX>s>PdtA{fIRSt zZCS^0t`SFl63DiG&t!At@*QMzd0fw*bB-iQs0C$$N|{I4wP05 z$b+A{@WV=}&jK3&9M_|#w~t)D1IKYr880Fr%}pweQtFg3#u%zWg~?)pC~UNTo&atL zM$X#3P&-->kbk@4^?tf9zZ>`nfYzFxzSVSf_srOwi9<|OM@#}H=H$7RQWlWopfdUy!V!$mQNeVzN;?m^K$BYz&ijsySnM^Th*3;n1m#( zA#uY3Sd>0!wCm%fawzSzB{W$qP?{{ZMjauFFS+FA{U??To<_eWbCO$b&^J~cnK^(LQX1&E0v+!JdQgOY439Bm_rL~vF&2-xJ;+VmKei~r}@Pg62^y`_Ef0a_0r|)HT~~ z5RuD7g!Svz;`_b@Q7K^J5D_^e-Nc~t&Klk(JUN&iz zSUwN9sZ@&I-d+$x(g+p>!b#;U0LOdT04Pa>Nn%V`U68XdQJthVg_G`}x2Ky*r9!n< zcc{l-0xmg4wf6nPTiz}rmx~B%RqUF%1OM(aMxwc;lhn)Tu0FE) zG#yS)ai3tCG^@ZDvF;4-Uvvmt8-^0Wqac%y*&CB(*gV^MBz{t=T zMwuCDiBf7t#;cTK^{PHrt?ou8*0|%Kh{)Vv+*~JGrwL8Wv12c`=6d@FZhrD-t`%MK zKIJO1nKY@4XHTF!1$u62xPCXvbBII3;K(?YN(B_;S8w60H(yNeme(#5!Dk+)@WLU+#wVCASJUiLL81Vjxj-*hCuTrjS_Z9~vbH0whtKd(mHV{(C--fWoiy=URL7T!(ZzjS(=KcWdA*fK18* zF*b0S#1=%ebgf+w!^Gq?aboCLyOs0*h~|RMbZ-5x$pcCB0{y+47#pvd%@YIVzUv) z^;&;#&_>u~1bkOFfkg9U+D?12YCXgV^lf?Fk`u(<&F3MBB2G*z!)+hbtU#K3^c-bX zOW3GM*aYCF<`!wIEA4<7>W$FG2BqoUbl#GIc zy2iBQ+Vs9wraa!iWDp`kY49i_LZ)S>abw;}5+JO{El>m62*-^;XyO@zX3a*ExEa+o zzVBlM#*ZIaGLY%fKv|NW`jOtr}qj5+f(H5q>TrOa+aXQf_B%4`vz_0D3wy z^!8*i#_+_qZzK*ES4IBUw|3E}1*B6x>-sF&V8ZI$!MGW(j4@0F4O^QCztsINbo~sN z5)sPPM!US6(@x?L6WLsB^V%L9P>z{9t0r!;bWUI6uSk;nF4&B(F+D5coCxzBpd zq%m(H*V@~3P;UjpszcY~&f~{kFb5vky-g|gTG!Qd<(oy-JQJEYM0+V5$0eQdDNWZ1 zCPz8?#6Gm+(Y0m^ZYne5nW!G{!qY$GN4x)y!U4>OesBgI^tGLD`hh|&?(4~L&Zb`L zr>DnIvH)7t(>Q+5jKr}X8iHDbO3mhD9T|^oCQT;gppbTOjl&Xz@q*UyvTFf2)?c7l zsv}z6f5n{#EKFm7)NQ8_bT@V@z3$I0cpf&7QM3_d1sKpa?N-4aKwO}*@qa{v^RM3j&uGn+vwWsh}yGmjdvfCCpfu$#< zxuHZB)rn%H7GQ!B@$>|7WsIaUi9`W};iBhLq#yxN2_qBM?TeB-X6JtM{#`($7A8zi z*P0n-8Zq*UcAd#0A{2@>!q^}p({W_JIV+IuU)etad>3GNJOBrDIt`w-*7QpBly3_y z2L{K2ma6^B$L@NjGAA2vV(tW(3L4a^&6LH@pD`Bx%%9$9ap^zHLrD_nW^ zpc;dpOeag7|PWK zQ`M+-n|9sYw={FKoqG>HVq$&@&_5EiS$M7874%d-%CSs-OBy1wZ|B{If4O8JmX?%% zw3j$h%a7w^x>n4{owT)$sh~l*Zc}w-ti7HVknMX9|3o2=0-PABHkm*VrM+dh4dvKG z6EU0^Y+q}9XUAQKo;htG_BzD57GSCzQK^M1=D}LhC_Dss zQ#SxfP)(Q$8g0EkZQID%IVPs-)an9I7f;=^sFY?ggFkxb!J{Jb&6jM&*bLQ8FFj57 zGAMB{#xO8kZw2D7?!4pR;EDmUPTl=95LRo3LOB9w>9(SjLgzZcf$?I4Ml1k<^5pZ2 zPBIt!z{mDJ{i0IpYXAddbtD#)cI?S6R;60fhztW0)`1w|pLg7GXl%uSH1m|3fLg6C zOibH%xOBR6aqnXZsFba7#->7|L;$9gmM<-7OG|lZ`&ai5qq*H?#`Z2zxyq6Rjx4A< zUIyhVj4}+4wk|={S9b0>JbA`|0L0bs3qZLM3uA>45^N0-gNkL&P7FHT2?>l9+R`Yv zmHI79F2t7l;K#r9TtQ;F1z@OXufUWzIN991%!rfCp|vJa!oXPE%5cBpco~>ShJ>XNo7|1K%rAMijPc$Bt`&6bccWYX_dyO^YGv9{>8M2e9?Nan1sZB zsgTXh>Zx2mi&hXPhT(}eLH@bzU)f)M#ee|l4*xb_G%>ca>0{q|TK;PZVLLV*1Bv*a+SVLm@CxGFJgebPQ;-zyaB`77h zsXR(4!g|a|p?yzyE_X?v}fgn%cn{Mw=Wm!0%p87iG?0Vc{;64i#G*rIU7T)PrWs+Iay zchB}W{1kY5cQY=QEaUo<9eWS`;mVxd3N?u>)mjSLAX=0Xzg0kX-hKGTg8GkN5K<(Ns+dJFCfH6={$M+WCE1Rh zRK?DRn9Yv27%yeO2^3kpBo%CuD7aV+XplibKv)v8BS+XqXhx&aX!f`D>+X9`K6Jk) zfi$aTM4Ya=CDohmd+-0Ad(Qcv|Ls;H!vBZMUFr^hxbF8)lN_r>^lBw?hKQ`z-~of8 z)PPcIki-m3t^LiR_kQoM%u(zDL7#z*+NratzokBKHObyXAMb zJdnIDKpy+8t5?Ow`6Tcj;EY#wsuYgn;QAh}>w#97IKjj*Ns?f#eQkH+z-F}i;+5O( z`^8BCa{s#Z1;=qdCCdIKP-y+0FBT|vbWkXjaD8vqK8&$6Divy#Dvet0XkZa=d)$=I zU9;o<$z=obz(wmnpp^OounNHOTzbl7iX9y|j&scYA_A>RYK>Crn0u_X)T>n{#>R-7 zQS0YX;Fj8%7vDKwKbjYie|yVSeo)eP0v}4}r0H3?lCGX^l+s5g6-C&vi5Lq>!fX3h zN~M527w!9K-vfQ*`RU0?#z#glrakuuty}t|cRh7a<%EH3zUaELr<(r(TnfSTMF_=b(G@}&2f&zs?`#w%FkIMS9)-o|R#^m^T>#p4-tY5$V{{4#va{s#Z=P8H# zExZxnlvDfY?Cx%F8(Xbo8X<7vxkv}Ws{qxjRfdNSw)|&UTYJsb&p-Iwf`Qz3@w?B` z);1x z9v(a9nPYnSgjRmN{$1b#wC1$aSK&DhB0@Ydwd^2T7LO;U5Myy&m(xyP)$+-;;N1Jf zeSL@sO60PM8eg7u zYi+*higQHtPXTvjxlFlSPR%?OR8K+o4`+gfl zl*BaS2vGnE<#{;y0Jpar9pvWKw`qjfD9!pSR;)<39_H%JZ+qwE$5_ND_bW<-QlUUG zp92hLs&a&4%!okamI8Dk4_M+jW};Ff3`4>&Y-ee$H9-&%1OZ(g9R$9M+gZT+KBh8- z%wZf;siDgbg}|qy7*MH&SgpUpC-mF6&59Bc{{5n>)>&(x2RM7pS>!wq8$~3O({rwt zSMF&iv58uh;gM0SjgO)w8l`v@<5fztvRr0GcPC0TB0@4gIpGl%koUcWacUpv8&d2l8ASR7kMg))FoT86% zAdW4=qZ3$!b3gFDd4mAxJnIZD{M1LW2&0n|)EZGce9ty^IydkfmmmnR2o`gd2F?V? zdp=m3CN5*Z%8X6BE?CRx^c0CTbgW&&`5%7&f|K1@SN$%lF8vJv4o!@MwP-gt>)2up z(u}}b@}AeK&fcI!WVMLkWIxV_(JC*E+Ordil%lF|rQ3%>7T5f1O(vuGgG`v!=@5K)T2&jg5ME7A#G;iSN3aA zuYe+sEm+C+=gi@c#H7wd-Jd@B9uHp(hg@B#tx2J=6H0 zvQ*KCEf&Fu-LEY?BO**S8%HR&>(06&wwm&xo_vAcLLO@^+dlIZk_A?gPu_eh&03AX z_c^sQb$DBE%$|%hIo4XsRI`!QCcK~ry4F4oOo<4UX4oc#v-(LCw?&9EdU|oSW_oal zhd2BMlNtY+?Fz>y+49L-7~Z}Ur4(z+38~He!=dz z^V{o`Qg3#(qAQ>3(sQv;8%5|`4##x~e2>XSNbS&J_HKRUG(IOU@h_3Br-=K=XRDrYep(l)S?*Rkq$>_J!jk5zN#yZf`f;e!#Ew^|5b!lu9$=`ET6K^WV6e(yBgAx!}#XLBQeN zdzgB8f4jg6T#vQoUOF<4XQnEMN#-o4(P=^+uQ#xOQucv%sT*1EVV6W2CQ;Qeq!a`= z`GDkzIzFQ?kW5bFc9zIHF6XW2W4Joa&~$~wS}F&IsT>%7O$yMgEEYJUs|P179p_bt zq!d0?IxCF^&D5W*PSgXhl7sHP@bU+haAi3kaCYyCG)|9@&z&hbz;p>g+-XE~XOCdoWXpF_kLHaZtHjdJf{cpgklG zv5`gMaCYB2UM5!>C!bn-WO@oC7LD3-{g3+}J4V;KSst=VxD((|Ev++@Han`Lk z+aEXW?0>|fp8^=jVz>@+XxBvv%hI%;vI#h14H3I}(~DbPSTqm-N6e=ICgOG+$LZ=^ zHYayF()0DHW=JKiPpnezO>+i^xo+6-;uAkoB0mJ!U$3W1py#HAm)Iq3D(9ziskQ8{ zx9k7!ZQS+vPfi#JfN@e^nTie7sCiTbTiR{avc_c8q?TI4ILh8KuW&t|n?ADRiRXd) z0QP4}iOTuldrM{wf?Qj?ZdpSqzH|M~t$UXYNLEVT2*`9|m~2^P34Nt?j5i)Os9OUN zi*i3Z?*uyE8#lc8#BSgofPqFT#VF53<#MU`eF9Af0k~;?8fdih#@}z;@#wxK2Lh1o zS5q^>R5Q#naA^s$cz_aNG;GjF-z*44#h+ggrCG?$*YDiASHylh^_8%F45x$K;*(!K zKshBT1}P8tw^B$B@YDowGBLh|(S_JzAN<&!C&xt8*8zr-7>l5s98S?E)j(%%PSHne2O}|qiET05FaOEU9;=*m zAOK0h`zkOVi!gE&2LpFz|4<5@W%1!C#-!F5)_!uw36;ht^6;NN`^dD2d>LRcu^7`# zzf>sZW+aAgp@0SwYdCo1^{g*$*uJg)x&cW=g7|jekP+cf=Crz0L=?x8LzVt5wMN*o z#z0iJcb)J&PPBt;CBFbLWFZzo9G#Y{uZ`NJ65?oLk+wBN0R!2Lh0-=s;?f zBo)_6MMSAr`GzVMP$CRPG1;lhFAtU9^p8s-A1y^EAAf0UQ$+nWz(E7CF({?*ip3+o zQ7WR8B1&MmeRd?Dxw-%DDGIfRAODq&Tf{6tWxSGfWu-5tvTv&)G$K(rL~*5 z%`WMeoN76~487~Emu&~$wgOn2>E6FE={I`7a~pT{U$o?+_wvYW<_1VVT??(DZVl}( zrmVeTIYv~=^{wt*Z@ug(;F9j_jh@LgQvGP-uKwR%mSc$Jl0HS$r&|Cr5G_fGUnwA) zUfi-(6ptQd`X6q1@rfV($^pr)wm(>{Zd}fR)N+5%e%D(syANRFuKsJ5a~yY>ErK%K c#7VgR7cCatd{=o@n*aa+07*qoM6N<$g4{nAa{vGU literal 0 HcmV?d00001 diff --git a/static/marker-icon-2x-violet.png b/static/marker-icon-2x-violet.png new file mode 100644 index 0000000000000000000000000000000000000000..ea748aa32933206dee2dec724aa8d9724384a41d GIT binary patch literal 4190 zcmV-k5TWmhP)&xSZ@FT59>)p zK~#9!-J5BQomYLvf9IU{eeX6ip3QbV-jYB`pd>&sq>zfOt*QVbqyecFl_(Y10gC#e zgnS^06NTVQUKrH8eC<5FuvQfX5#bMEcq_f&?c2V)A)Ap+iO42IZ5EMD8tnsvqGUiRH7E%K zM=zKgzWZzMOjl}Ob+(NBg+~45tG|7jQSuIjz8W}xkzbtx_K3*+YxCJh-~H$tlII1; zXa4N_eU&u+Fz^mw^GS;;g)s&{@bEkjG^BA#5~n0dg0=S4<)(o>N^|Q!KYqn;&IyoR z+rF7M#$0QZd<4ihUe6bD6xs_E+Vc3p!WNsRDdl37aWie5pZ`Yj6Z(!zAI-} z4an}x9(uP@>K336!1E1VJzcc57cj=0_IVLO>St8iF?H&&)>1B2n3}tvTs%Nn}ZC7D)x0I3TB*oYJ6ug%@Z{ zpi%n7`*XANOpQ;Irp>c|tLoXm+4-Zl70(*TjhF4|X;c0K!0P}yIy>m;?QSw)Qz5RV zq@iHbrJ1SH-~}4LV9-W28JeD&VRpJ{S%>0;w|;c*TaPar$gXYQyx2GDn}U4-z`FC+ zv8J=5X*O}m5>->wO5}m;$$-kG3ZuiL4gVR%>MO5%>Mc($8OW~JJb1pc`bh(8jWL|p zcOJQ1&Y4in5|`5@Gq4zj{DQ&HIkv+vWMpWBBuPl292j}chkyLGLyHFT@i*SzW-7ts zz@>o~uxaZ?Jmb5tQc8#`c6GCKR+H&)kuOOSh6aWR;}GCkjeXU1|992Yso&A3t)hRR zOeOdVaH-O;VdDlo<0B$eXW~@{(O|qf6Cdvk6IU=}Z`3p^OW;HWt!YmStwyPDjA>^fVKb;{d5|)FmJJ$yHB3 zXVI95+y;1=Oos06Zmf2jV#rU>XZHvBVX-s9=%QEa6;Arrprp*-fcb zqFSq%T0%eY*3(SuwpZ=BTtu!C5!UvuLy1DALR4N{focLxVXaS4FnED(f{2ocFb)wV zO_uzeA=mBUW%Z)gj;kq2`OGo<}3PBdH!y^nVNiij9!A?|6$@FO2 zvw7AO+L1By=E^e+ zkB_2$tGZRnnV3E0wceh!tn2J073Cu0bb_=1lkf0QS&dC14j+4_vDW3+KlzRydjL_I z{@W2!klj9Ui^YpFTM9$BU?eGYv;sy7s6jM5~8si1za|J5ZGS=Fw00+dt z)gnS6-$qgoePS)CS~!}Rj3Ni3|ZfmAygN()m*{g8$~US z8JQf#V!8a?7cCkDK=1h-TygCyuvn&MCn;9T&G5b8sGrRMuX&QqzD5fT36?H6JKy{x+GPv3QsD}ibimuEC6vv6b3 zs75^BH2&_>g54xod>uF-Ey{?-CL%&LsvuZ4{&CN;3&g&QyGfOh+JwkexXlN3MxU>r z(NjKxMX(!n3Y!2jp694*F||=(A&hERYf&0DT-?2EAen+s??#t^OSKBr_gLr*HgRT_ z@tXO(Pa9|hP-sM4ySh+Sigs+I)+Ngz?-69rPUp*aW$Lz`AR?&x9<8Q2QDGIdhzy7b zwIp)1n#Iv-yeuL@tr#;sP+T?$5n=T3Y`t7}&YOnPvXca;B~b&^pcd>=!IIX2s1`nJ z6PI5y+H<`Mmg9#emJMX`=sZy^0wv^wtOJrPTv9Y1cA7*DA33gpV*s@{Myzw@R`!|3 zF8mb&r_cTRiQ#1fIsEvLGtnSGi#d@37C3`a?u>C9H|nk9+G3AcEMZ)2qHW1a-Ml(- zYp_`MJ@gxn>>XV)h?(IMkKDT-i=`{q)m&TbBEx@N#bQY%J+1}&6A_^p&YdW^TO_5p zWa~h>=+1Xzt>uB6A1AIYO^5#a&i|!a4#@^t)^@n6Agc{i*)d znt>k!b0R`HbWZEHXn4}dlGJOlzOK!9+GA>Po-b_wHWNo%OSa|d8ejeJqa4{gic*TL zJr|(Rq@gTaG}en%7pmtQ$*^MAn8(c1&pu`!xp&_-lzNG;J=$|?+>>dsU=mrpjK+9| zT##k1I?L=>nWy#~#`v1GThDBDcNq1wxE+2 zt$#5?(?^*Pi#+_vr+E02Ptn!a#+FOh5oA4%ADUp|*|}zemGQD{?YV%w-;UJRYSmbo zK)XCsVkS$Y zjLZ*{+Jxz$BGW@B=NPRuy@j=GShE?eOjF>97oiP!^>(^kEfS?6fDIG<&`EO8b+7pR zw-9wjPp*e8-7j=s7>|&&wX*ALFkPnUhyanTQ3=abq7s57@3)i7=E;~GN>o!(NoEt` zMZ^eSLuS2eGmcF?!*p#Lt>hcG?t9;rr^-Q}7VH&M)hYU9E1vT4bB3tYS_+CS#8WA` zHbEB*N<*Nt1i37oxl;E*tr3<&|`d$&!HD^{z>AO!8=q^+@>#Hw4?>E5t zn>)^DT}K}(7Ah0*lJkQ$3Y~GER@kS3P>TF|AFE+xc97xHaTNOL z-3Q+HlGAjpBeK&X>Ry2H@|Y_ijlyIUsVr^A34~>#GHWSKCRD}~$}=fRS%@Q+yq;tf zDWFneyy6nC7WwoUwcoaYF9KnhR++2LxHz5F=S=TC)6C}G8K$aJBsM}s<|CVY>5M>j z?%h5K{1?Djc?1JwvKmi=;rW?<;7Sq$jFyKQruHA7`1uD)3##!6_A>ybaE?mq>My_T z`D3LaMiiqG)wm2GN^?)kYlXWGy#EQ{X8@BWXODi)IK@!T?KGao)Ju|y;z;8`e`oi; z9nZAB=yA=?od6TnF{<@GI%qfNoDJ3Hiq~3HVLFF0Gzbz>!m2{hR{8-vAx#$g}pKd|G`MFTk^J1t6XY_Jk0wI(ZwIwvz|cfL`HO3X&n zuDsLSu)w9jbC3T0*X#lI0t}Z15fQwc0<-!ir327`>*tS^n%CB_>zIt^ZxY! zQ{jZuu^eazyF%=B-kGT43`H^sPLX%y+{=}l^ z0t{7-I}zJ=jn|6%M!nF)!tqMeH@^0X13P|x)<9e}n_dqvAJ0*aN~;#Z8f+A!S!!w7 zT|+B#)1uC}m=E2)@BL2!4+0F=+>4F!ba9H4JX)8N`3j3{qH@zVnCe7sT%+( zCi4`d*%kMd#&1vNYSVMbpR2;_^w|$TQ^oiw5hu zpZ(M=2X>6FIuHQmu?whHrOI41={mWNC3cZm{B{>8W}$}sQ%@gX<0yS-@AQ8 zFA<8Y8l(>E#TbQfO-#j-5Lgr9QFy;59Sn3=v4l)UmUc+Pi z*_`F9fZXxKKhBHDZooa+GLm45F zxXFfyeEf6Id?=s=SdQBIvyC$nJ+E)kAK!-)H_yc_S9cq9$(0}m7_pjvMD+=U@?6j&b-OR`Y zp{+IF3vG=_T+*~6H)3Bezq3F9cJJT*0Ps_Q5j)(BTElgQfG6)f@cwVC^u8+&q`v6& z0JBUI+8Q-mVGbumi*@g<^iZqyzq;4H?6W@tE^nvPX;sVu{ILJPj@Pf!!&{w^hAUyZr~Z|M2$?NPV2}2W@Xy&5hJ*|3CY+FZ=9+0R0Dc oT)CS2c&p3^Y1;ZqU?s-?0}~2Jef8zF+5i9m07*qoM6N<$f>oOwwg3PC literal 0 HcmV?d00001 diff --git a/static/marker-icon-2x-yellow.png b/static/marker-icon-2x-yellow.png new file mode 100644 index 0000000000000000000000000000000000000000..8b677d9390539f7f386176b754ec3bb2791def52 GIT binary patch literal 4159 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 JPDHLkV1j#5>~{bF literal 0 HcmV?d00001 diff --git a/static/marker-shadow.png b/static/marker-shadow.png new file mode 100644 index 0000000000000000000000000000000000000000..84c580847f0b20fa72d328985a08c0c176554a69 GIT binary patch literal 608 zcmV-m0-ybfP)9uxu(PDa20NtOu&LOs@Hz6GMI+Q6g1Z=qj$sxT(UW7u!XkL;lC1Sux{-k zrKG=Ll3X`pmJ#TlL7Pm#g6k<}JL*YPF{=D*Jt9ddf(0rbK-p3Q$jBIdfPIGdCL={p zPZe29%|u7Kq6UF#1O~k@qlzy)@7NJH5b5QZH0w1L>&ZF>IAnU}cyCIWV=(@k>0)}s z#u&vVyO&ex=p?gju}^>lcQj(kJsJ0NpK8Z8sR=XG6xB+HOVoRA8qCQS`s8N(9~4t_ zw-PKkoa*ao^O6^s672eiQj0-B4xiUrcGz&vi|)FMV#poEi8-3nKkDR!>3g5X98WNi zBH}N}lhCgFIR?EBbvrT#<`}O6sF8c@fZ`WtHr_)(%$^a*sAHW3!6o*hm;#OI1-8RB zNTX3GBfsk44PkjCr z@9>IwOYpNAYKBY&%*Z2;ebvG5gr4Fto(cRG^HWGEFqo0PtqHs*^9XnG5HESz!R>^Z uBWCvJ