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
+
+
+
+
+
+
+ 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.
+
+
File containing road data:
+
+
+
Loading data...
+
This might take a few seconds.
+
+
+
+
+
Start search
+
+
+
+
+ Distance Latitude Longitude Show in map
+
+
+
+
+
+
+
Settings
+
+
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 0000000..23c94cf
Binary files /dev/null and b/static/marker-icon-2x-black.png differ
diff --git a/static/marker-icon-2x-blue.png b/static/marker-icon-2x-blue.png
new file mode 100644
index 0000000..0015b64
Binary files /dev/null and b/static/marker-icon-2x-blue.png differ
diff --git a/static/marker-icon-2x-gold.png b/static/marker-icon-2x-gold.png
new file mode 100644
index 0000000..6992d65
Binary files /dev/null and b/static/marker-icon-2x-gold.png differ
diff --git a/static/marker-icon-2x-green.png b/static/marker-icon-2x-green.png
new file mode 100644
index 0000000..c359abb
Binary files /dev/null and b/static/marker-icon-2x-green.png differ
diff --git a/static/marker-icon-2x-grey.png b/static/marker-icon-2x-grey.png
new file mode 100644
index 0000000..43b3eb4
Binary files /dev/null and b/static/marker-icon-2x-grey.png differ
diff --git a/static/marker-icon-2x-orange.png b/static/marker-icon-2x-orange.png
new file mode 100644
index 0000000..c3c8632
Binary files /dev/null and b/static/marker-icon-2x-orange.png differ
diff --git a/static/marker-icon-2x-red.png b/static/marker-icon-2x-red.png
new file mode 100644
index 0000000..1c26e9f
Binary files /dev/null and b/static/marker-icon-2x-red.png differ
diff --git a/static/marker-icon-2x-violet.png b/static/marker-icon-2x-violet.png
new file mode 100644
index 0000000..ea748aa
Binary files /dev/null and b/static/marker-icon-2x-violet.png differ
diff --git a/static/marker-icon-2x-yellow.png b/static/marker-icon-2x-yellow.png
new file mode 100644
index 0000000..8b677d9
Binary files /dev/null and b/static/marker-icon-2x-yellow.png differ
diff --git a/static/marker-shadow.png b/static/marker-shadow.png
new file mode 100644
index 0000000..84c5808
Binary files /dev/null and b/static/marker-shadow.png differ
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..4f5edc2
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,25 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "useDefineForClassFields": true,
+ "module": "ESNext",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "erasableSyntaxOnly": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true
+ },
+ "include": ["src"]
+}