Initial commit of a working website

This commit is contained in:
Martin Asprusten 2025-06-30 01:15:53 +02:00
parent cf57ea7002
commit e413687435
26 changed files with 3565 additions and 1 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
dist/
native/*
node_modules/
!native/route_search.cpp

View File

@ -1,3 +1,13 @@
# Freewheeling
A website for calculating how far you can ride your bike without pedalling
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

92
index.html Normal file
View File

@ -0,0 +1,92 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/x-icon" href="././static/bicycle-solid.svg" />
<title>Freewheeling</title>
</head>
<body>
<script type="module" src="/src/main.ts"></script>
<h1 id="freewheeling-header">Freewheeling</h1>
<div id="notloadedcontainer">
<p>
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 <a href="https://kartkatalog.geonorge.no/metadata/elveg-20/77944f7e-3d75-4f6d-ae04-c528cc72e8f6">Kartkatalogen at Geonorge</a>.
Since I don't have a very powerful server, all calculations are done locally in your browser.
</p>
<p>
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
<a href="./roads.dat">here.</a> 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.
</p>
<label for="data-file-chooser">File containing road data: </label><input id="data-file-chooser" type="file" />
</div>
<div id="loadingcontainer">
<p>Loading data...</p>
<p>This might take a few seconds.</p>
</div>
<div id="mapcontainer">
<div id="map"></div>
<br />
<button id="search-button">Start search</button>
<p id="search-status-paragraph"></p>
<table id="search-result-table" style="display: none;">
<thead>
<tr>
<th>Distance</th><th>Latitude</th><th>Longitude</th><th>Show in map</th>
</tr>
</thead>
<tbody id="search-result-table-body">
</tbody>
</table>
<br />
<button id="settings-button">Settings</button>
<div id="settings-div" style="display: none;">
<div class="settings-line">
<label for="minimum-speed-input">Minimum speed (km/h):</label><input id="minimum-speed-input" type="number" min="0" step="1" />
</div>
<div class="settings-line">
<label for="maximum-speed-input">Maximum speed (km/h):</label><input id="maximum-speed-input" type="number" min="0" step="1" />
</div>
<div class="settings-line">
<label for="maximum-speed-limit-input">Maximum speed of road (km/h):</label><input id="maximum-speed-limit-input" type="number" min="0" step="10" />
</div>
<div class="settings-line">
<label for="allow-motorways-input">Allow motorways:</label><input id="allow-motorways-input" type="checkbox" />
</div>
<div class="settings-line">
<label for="allow-tunnels-input">Allow tunnels:</label><input id="allow-tunnels-input" type="checkbox" />
</div>
<div class="settings-line">
<label for="allow-wrong-way-input">Allow going the wrong way down one-way roads:</label><input id="allow-wrong-way-input" type="checkbox" />
</div>
<div class="settings-line">
<label for="drag-coefficient-input">Drag coeffient (0.005 is a good default):</label><input id="drag-coefficient-input" type="number" min="0" step="0.001" />
</div>
<div class="settings-line">
<label for="cutoff-distance-input">Cut-off distance when searching (m):</label><input id="cutoff-distance-input" type="number" min="0" step="1000" />
</div>
</div>
<h2>Instructions:</h2>
<p>
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.
</p>
<p>
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: <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="20px"><!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc. --> <path d="M367.2 412.5L99.5 144.8C77.1 176.1 64 214.5 64 256c0 106 86 192 192 192c41.5 0 79.9-13.1 111.2-35.5zm45.3-45.3C434.9 335.9 448 297.5 448 256c0-106-86-192-192-192c-41.5 0-79.9 13.1-111.2 35.5L412.5 367.2zM0 256a256 256 0 1 1 512 0A256 256 0 1 1 0 256z"/></svg>.
Any routes calculated will then avoid the areas drawn.
</p>
<p>
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 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="20px"><!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc. --><path d="M128 32l32 0c17.7 0 32 14.3 32 32l0 32L96 96l0-32c0-17.7 14.3-32 32-32zm64 96l0 320c0 17.7-14.3 32-32 32L32 480c-17.7 0-32-14.3-32-32l0-59.1c0-34.6 9.4-68.6 27.2-98.3C40.9 267.8 49.7 242.4 53 216L60.5 156c2-16 15.6-28 31.8-28l99.8 0zm227.8 0c16.1 0 29.8 12 31.8 28L459 216c3.3 26.4 12.1 51.8 25.8 74.6c17.8 29.7 27.2 63.7 27.2 98.3l0 59.1c0 17.7-14.3 32-32 32l-128 0c-17.7 0-32-14.3-32-32l0-320 99.8 0zM320 64c0-17.7 14.3-32 32-32l32 0c17.7 0 32 14.3 32 32l0 32-96 0 0-32zm-32 64l0 160-64 0 0-160 64 0z"/></svg>
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.
</p>
</div>
</body>
</html>

798
native/route_search.cpp Normal file
View File

@ -0,0 +1,798 @@
#include <emscripten/bind.h>
#include <emscripten/val.h>
#include <iostream>
#include <fstream>
#include <arpa/inet.h>
#include <map>
#include <set>
// 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<Connection> *extra_connections;
};
struct RoadNodeSet {
uint32_t numberOfNodes;
RoadNode* roadNodes;
};
struct SearchNodeInfo {
float distanceFromPrevious;
float currentSpeed;
};
struct SearchResult {
uint32_t startingNode;
std::map<uint32_t, uint32_t> previous;
std::map<uint32_t, SearchNodeInfo> reachableNodes;
};
struct JSNodeInfo {
uint32_t nodeId;
float positionX;
float positionY;
float positionZ;
float distanceFromStart;
float currentSpeed;
};
struct JSSearchResult {
std::vector<JSNodeInfo> 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<AreaSearchEntry> searchedNodes;
};
struct CurrentAreaSearch {
float minimumSpeed;
float maximumSpeed;
float maximumSpeedLimit;
float dragCoefficient;
bool allowMotorways;
bool allowTunnels;
bool allowAgainstOneway;
std::vector<uint32_t> startNodes;
size_t startNodeCounter = 0;
std::vector<AreaSearchEntry> currentAreaSearchResults;
std::set<uint32_t> utilizedNodes;
};
// Data
RoadNodeSet set;
SearchResult lastSearchResult;
std::set<uint32_t> 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<uint32_t*>(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<Connection>();
}
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<uint32_t*>(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<uint16_t*>(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<uint32_t*>(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<uint32_t*>(buffer));
source.read(buffer, 4);
uint32_t to_point = ntohl(*reinterpret_cast<uint32_t*>(buffer));
source.read(buffer, 1);
uint8_t flags_byte = *reinterpret_cast<uint8_t*>(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<uint32_t> notEndPoints;
for (auto it = lastSearchResult.previous.begin(); it != lastSearchResult.previous.end(); it++) {
notEndPoints.insert(it->second);
}
std::vector<JSNodeInfo> 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<JSNodeInfo> 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<JSNodeInfo> getPathJS(uint32_t startingNode, uint32_t endNode) {
std::vector<JSNodeInfo> path;
if (lastSearchResult.startingNode != startingNode) {
return path;
}
if (lastSearchResult.reachableNodes.find(endNode) == lastSearchResult.reachableNodes.end()) {
return path;
}
std::vector<uint32_t> 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<PolygonCoordinate> 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<std::vector<std::vector<PolygonCoordinate>>> polygons, std::set<uint32_t> &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<std::vector<std::vector<PolygonCoordinate>>> polygons) {
// Clear any old excluded nodes
excludedNodes.clear();
getNodesWithinPolygons(polygons, excludedNodes);
}
std::vector<uint32_t> findPossibleStartNodes(float minimumSpeed, float maximumSpeedLimit, float dragCoefficient, bool allowMotorways, bool allowTunnels, bool allowAgainstOneway, std::vector<std::vector<std::vector<PolygonCoordinate>>> searchArea) {
std::set<uint32_t> allNodesWithinSearchArea;
getNodesWithinPolygons(searchArea, allNodesWithinSearchArea);
float minimumSlope = asin(dragCoefficient * minimumSpeed * minimumSpeed / GRAVITY_ACCELERATION);
float minimumSlopeTan = tan(minimumSlope);
std::vector<uint32_t> 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<std::vector<std::vector<PolygonCoordinate>>> 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<uint32_t> 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<uint32_t> 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<uint32_t> 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_<JSNodeInfo>("NodeInfo")
.constructor<>()
.property("nodeId", &JSNodeInfo::nodeId)
.property("positionX", &JSNodeInfo::positionX)
.property("positionY", &JSNodeInfo::positionY)
.property("distanceFromStart", &JSNodeInfo::distanceFromStart);
emscripten::class_<JSSearchResult>("SearchResult")
.constructor<>()
.property("endPoints", &JSSearchResult::endPoints);
emscripten::class_<PolygonCoordinate>("PolygonCoordinate")
.constructor<>()
.property("x", &PolygonCoordinate::x)
.property("y", &PolygonCoordinate::y);
emscripten::class_<AreaSearchEntry>("AreaSearchEntry")
.constructor<>()
.property("nodeId", &AreaSearchEntry::nodeId)
.property("positionX", &AreaSearchEntry::positionX)
.property("positionY", &AreaSearchEntry::positionY)
.property("longestRoute", &AreaSearchEntry::longestRoute);
emscripten::class_<AreaSearchResult>("AreaSearchResult")
.constructor<>()
.property("remainingNodes", &AreaSearchResult::remainingNodes)
.property("searchedNodes", &AreaSearchResult::searchedNodes);
emscripten::register_vector<JSNodeInfo>("NodeInfoArray");
emscripten::register_vector<PolygonCoordinate>("Ring");
emscripten::register_vector<std::vector<PolygonCoordinate>>("Polygon");
emscripten::register_vector<std::vector<std::vector<PolygonCoordinate>>>("MultiPolygon");
emscripten::register_vector<AreaSearchEntry>("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);
}

1401
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

25
package.json Normal file
View File

@ -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"
}
}

117
src/interfaces.ts Normal file
View File

@ -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
}

374
src/main.ts Normal file
View File

@ -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]);
});

View File

@ -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]
});

View File

@ -0,0 +1,11 @@
.search-icon {
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.--><path d="M128 32l32 0c17.7 0 32 14.3 32 32l0 32L96 96l0-32c0-17.7 14.3-32 32-32zm64 96l0 320c0 17.7-14.3 32-32 32L32 480c-17.7 0-32-14.3-32-32l0-59.1c0-34.6 9.4-68.6 27.2-98.3C40.9 267.8 49.7 242.4 53 216L60.5 156c2-16 15.6-28 31.8-28l99.8 0zm227.8 0c16.1 0 29.8 12 31.8 28L459 216c3.3 26.4 12.1 51.8 25.8 74.6c17.8 29.7 27.2 63.7 27.2 98.3l0 59.1c0 17.7-14.3 32-32 32l-128 0c-17.7 0-32-14.3-32-32l0-320 99.8 0zM320 64c0-17.7 14.3-32 32-32l32 0c17.7 0 32 14.3 32 32l0 32-96 0 0-32zm-32 64l0 160-64 0 0-160 64 0z"/></svg>');
}
.exclude-icon {
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.--><path d="M367.2 412.5L99.5 144.8C77.1 176.1 64 214.5 64 256c0 106 86 192 192 192c41.5 0 79.9-13.1 111.2-35.5zm45.3-45.3C434.9 335.9 448 297.5 448 256c0-106-86-192-192-192c-41.5 0-79.9 13.1-111.2 35.5L412.5 367.2zM0 256a256 256 0 1 1 512 0A256 256 0 1 1 0 256z"/></svg>');
}
.cancel-icon {
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.--><path d="M342.6 150.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L192 210.7 86.6 105.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L146.7 256 41.4 361.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192 301.3 297.4 406.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L237.3 256 342.6 150.6z"/></svg>')
}

View File

@ -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: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> 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;

View File

@ -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);
}
}

74
src/style.css Normal file
View File

@ -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;
}

1
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

1
static/bicycle-solid.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512"><!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.--><path d="M312 32c-13.3 0-24 10.7-24 24s10.7 24 24 24l25.7 0 34.6 64-149.4 0-27.4-38C191 99.7 183.7 96 176 96l-56 0c-13.3 0-24 10.7-24 24s10.7 24 24 24l43.7 0 22.1 30.7-26.6 53.1c-10-2.5-20.5-3.8-31.2-3.8C57.3 224 0 281.3 0 352s57.3 128 128 128c65.3 0 119.1-48.9 127-112l49 0c8.5 0 16.3-4.5 20.7-11.8l84.8-143.5 21.7 40.1C402.4 276.3 384 312 384 352c0 70.7 57.3 128 128 128s128-57.3 128-128s-57.3-128-128-128c-13.5 0-26.5 2.1-38.7 6L375.4 48.8C369.8 38.4 359 32 347.2 32L312 32zM458.6 303.7l32.3 59.7c6.3 11.7 20.9 16 32.5 9.7s16-20.9 9.7-32.5l-32.3-59.7c3.6-.6 7.4-.9 11.2-.9c39.8 0 72 32.2 72 72s-32.2 72-72 72s-72-32.2-72-72c0-18.6 7-35.5 18.6-48.3zM133.2 368l65 0c-7.3 32.1-36 56-70.2 56c-39.8 0-72-32.2-72-72s32.2-72 72-72c1.7 0 3.4 .1 5.1 .2l-24.2 48.5c-9 18.1 4.1 39.4 24.3 39.4zm33.7-48l50.7-101.3 72.9 101.2-.1 .1-123.5 0zm90.6-128l108.5 0L317 274.8 257.4 192z"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

BIN
static/marker-shadow.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 608 B

25
tsconfig.json Normal file
View File

@ -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"]
}