Measure the road distance and time between two locations.
-
-
-
-
+
+
diff --git a/public/styles.css b/public/styles.css
index 73ce670..5dda2ab 100644
--- a/public/styles.css
+++ b/public/styles.css
@@ -274,12 +274,23 @@ header button{
margin: 3.5px;
background-color: #efeaea;
border-radius: 3px;
- padding: 2px;
+ padding: 8px;
font-size: 13px;
display: flex;
-
+ align-items: center;
+ }
+ #search-results li:first-child {
+ background-color: transparent;
+ border-bottom: 1px solid #ddd;
+ margin-bottom: 8px;
+ padding: 8px;
}
- #search-results li:hover{
+ #search-results li .fa-history {
+ color: #666;
+ margin-right: 8px;
+ font-size: 14px;
+ }
+ #search-results li:hover:not(:first-child) {
background-color: rgb(223, 242, 236);
}
@@ -461,4 +472,163 @@ footer {
align-items: right;
float: right;
margin-left: 10px;
-}
\ No newline at end of file
+}
+
+.box .box-input {
+ display: flex;
+ align-items: center;
+ margin-bottom: 20px;
+ padding-left: 5px;
+ padding-right: 10px;
+}
+
+.box-input span i {
+ font-size: 18px;
+ margin-right: 10px;
+ color: #6F0E0E;
+}
+
+.dist-input {
+ display: flex;
+ align-items: center;
+ flex: 1;
+ background-color: white;
+ border: 2px solid black;
+ border-radius: 10px;
+ padding: 5px 10px;
+}
+
+.dist-input input {
+ flex: 1;
+ border: none;
+ outline: none;
+ font-size: 14px;
+ background-color: transparent;
+}
+
+.dist-input button {
+ background: none;
+ border: none;
+ cursor: pointer;
+ font-size: 16px;
+ color: #777;
+ margin-left: 5px;
+}
+
+#bookmarks-btn {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ margin: 5px 0;
+ padding: 8px 12px;
+ background-color: #fff;
+ border: 2px solid #000;
+ border-radius: 8px;
+ cursor: pointer;
+ font-size: 14px;
+ color: #333;
+ transition: all 0.2s ease;
+}
+
+#bookmarks-btn:hover {
+ background-color: #f0f0f0;
+}
+
+#bookmarks-btn:focus {
+ outline: 2px solid #6F0E0E;
+ outline-offset: 2px;
+}
+
+.bookmark-btn {
+ background: none;
+ border: none;
+ padding: 5px;
+ cursor: pointer;
+ color: #666;
+ margin-right: 8px;
+}
+
+.bookmark-btn:hover {
+ color: #f1c40f;
+}
+
+.bookmark-btn.active {
+ color: #f1c40f;
+}
+
+.bookmark-item {
+ display: flex !important;
+ justify-content: space-between;
+ align-items: center;
+ padding: 8px !important;
+}
+
+.bookmark-name {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.bookmark-name i {
+ color: #6F0E0E;
+}
+
+.bookmark-actions {
+ display: flex;
+ gap: 8px;
+}
+
+.bookmark-actions button {
+ background: none;
+ border: none;
+ padding: 5px;
+ cursor: pointer;
+ color: #666;
+ transition: color 0.2s ease;
+}
+
+.bookmark-actions .navigate-btn:hover {
+ color: #27ae60;
+}
+
+.bookmark-actions .delete-btn:hover {
+ color: #e74c3c;
+}
+
+.bookmark-actions .share-btn:hover {
+ color: #3498db;
+}
+
+.result-content {
+ flex: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+#search-results li {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px;
+ margin: 3.5px;
+ background-color: #efeaea;
+ border-radius: 3px;
+ font-size: 13px;
+ cursor: pointer;
+}
+
+#search-results li:first-child {
+ background-color: transparent;
+ border-bottom: 1px solid #ddd;
+ margin-bottom: 8px;
+ padding: 8px;
+}
+
+#search-results li:hover:not(:first-child) {
+ background-color: rgb(223, 242, 236);
+}
diff --git a/server.js b/server.js
index 5399751..ce1cda0 100644
--- a/server.js
+++ b/server.js
@@ -21,7 +21,7 @@ const mimeTypes = {
};
const server = http.createServer((req, res) => {
- let filePath = '.' + req.url;
+ let filePath = '.' + req.url.split('?')[0]; // ✅ Strip query string
if (filePath == './') filePath = './public/index.html'; // Default to index.html
const extname = String(path.extname(filePath)).toLowerCase();
diff --git a/src/components/DistanceFinder/distance.js b/src/components/DistanceFinder/distance.js
index 51fd2ec..e59ac71 100644
--- a/src/components/DistanceFinder/distance.js
+++ b/src/components/DistanceFinder/distance.js
@@ -1,13 +1,12 @@
-
/*
* Copyright (c) 2023-25 Zendalona
* This software is licensed under the GPL-3.0 License.
* See the LICENSE file in the root directory for more information.
*/
-import { geoLayer } from "../../services/fetch-place.js";
-import { performSearch } from "../../services/do-search.js";
-import { toKMorMeter } from "../../utils/to-km-or-meter.js";
+import { geoLayer, showPlaceDetails } from "../../services/fetch-place.js";
+import { performSearch, removeResults } from "../../services/do-search.js";
+import { geocodingAPI, headerofNominatim, toKMorMeter } from "../../utils/to-km-or-meter.js";
import { closeSound } from "../../utils/sounds.js";
import { FOSSGISValhallaEngine } from "./FOSSGISValhallaEngine.js";
@@ -16,6 +15,7 @@ import { successSound } from "../../utils/sounds.js";
import { adjustablePointer } from "../Marker/adjustable-pointer.js";
import Marker from "../Marker/marker.js";
import { notifySreenReader } from "../../utils/accessibility.js";
+import { map } from '../map.js';
// Tracks the currently focused input element (starting or destination location)
let activeInputElement = null;
@@ -25,6 +25,11 @@ const findDistanceButton = document.getElementById("find");
let startingLocationElement = document.getElementById("beginning");
// Element for user input of the destination location
let destinationLocationElement = document.getElementById("destination");
+// Element to display the distance result
+const distanceResultElement = document.getElementById("distanceResult");
+// Button to copy the route link
+const copyRouteLinkButton = document.getElementById("copy-route-link-btn");
+
// Coordinates for the destination
let destinationCoordinates;
// Coordinates for the starting location
@@ -32,6 +37,144 @@ let startingCoordinates;
// Layer group to represent the road path on the map
let roadPathLayerGroup;
+// --- URL Handling ---
+
+// Generate shareable route URL using place names
+function generateRouteShareUrl(fromName, toName) {
+ if (!fromName || !toName) return null;
+
+ const url = new URL(window.location.href);
+ url.searchParams.set('from', fromName); // no need to encode
+ url.searchParams.set('to', toName);
+
+ // Remove unnecessary params
+ ['lat', 'lng', 'zoom', 'fromLat', 'fromLng', 'toLat', 'toLng'].forEach(param =>
+ url.searchParams.delete(param)
+ );
+
+ return url.toString();
+ }
+
+
+// Update browser URL with route parameters using place names
+function updateRouteUrl() {
+ const fromName = startingLocationElement.value;
+ const toName = destinationLocationElement.value;
+ const shareUrl = generateRouteShareUrl(fromName, toName);
+ if (shareUrl) {
+ window.history.pushState({ path: shareUrl }, '', shareUrl);
+ }
+}
+
+// Geocode location name to get coordinates and display name
+async function geocodeLocationByName(locationName) {
+ if (!locationName) return null;
+ try {
+ const url = `${geocodingAPI}/search.php?q=${encodeURIComponent(locationName)}&format=jsonv2&limit=1`;
+ const response = await fetch(url, headerofNominatim);
+ if (response.ok) {
+ const data = await response.json();
+ if (data && data.length > 0) {
+ const bestResult = data[0];
+ return {
+ lat: parseFloat(bestResult.lat),
+ lon: parseFloat(bestResult.lon),
+ name: bestResult.display_name // Use the display name from Nominatim
+ };
+ }
+ }
+ } catch (error) {
+ console.error(`Geocoding failed for ${locationName}:`, error);
+ }
+ notifySreenReader(`Could not find coordinates for ${locationName}`);
+ return null; // Indicate failure
+}
+
+// Perform reverse geocoding for a coordinate
+async function reverseGeocode(lat, lon) {
+ try {
+ const response = await fetch(
+ `${geocodingAPI}/reverse?lat=${lat}&lon=${lon}&zoom=18&format=jsonv2`,
+ headerofNominatim
+ );
+ if (response.ok) {
+ const data = await response.json();
+ return data.display_name || `${lat.toFixed(3)}, ${lon.toFixed(3)}`;
+ }
+ } catch (error) {
+ console.error("Reverse geocoding failed:", error);
+ }
+ return `${lat.toFixed(3)}, ${lon.toFixed(3)}`; // Fallback name
+}
+
+// Check URL parameters on load and initiate route calculation if present
+async function checkRouteUrlParams() {
+ const urlParams = new URLSearchParams(window.location.search);
+ const fromName = urlParams.get('from');
+ const toName = urlParams.get('to');
+
+ if (fromName && toName) {
+ const decodedFromName = decodeURIComponent(fromName);
+ const decodedToName = decodeURIComponent(toName);
+
+ // Show the distance finder box
+ distanceBox.style.display = "block";
+ startingLocationElement.value = decodedFromName;
+ destinationLocationElement.value = decodedToName;
+ notifySreenReader(`Loading route from ${decodedFromName} to ${decodedToName}`);
+
+ // Geocode both locations
+ findDistanceButton.disabled = true;
+ findDistanceButton.innerHTML = `
Geocoding...`;
+ const startResult = await geocodeLocationByName(decodedFromName);
+ const endResult = await geocodeLocationByName(decodedToName);
+ findDistanceButton.innerHTML = '
'; // Reset icon even if error
+ findDistanceButton.disabled = false;
+
+ if (startResult && endResult) {
+ startingCoordinates = { lat: startResult.lat, lon: startResult.lon };
+ destinationCoordinates = { lat: endResult.lat, lon: endResult.lon };
+ // Update input fields with potentially more precise names from geocoding
+ startingLocationElement.value = startResult.name;
+ destinationLocationElement.value = endResult.name;
+ // Trigger distance calculation
+ calculateDistance();
+ } else {
+ notifySreenReader("Could not find coordinates for one or both locations in the URL.");
+ // Optionally clear the fields or show an error message
+ // startingLocationElement.value = "Error loading location";
+ // destinationLocationElement.value = "Error loading location";
+ }
+ }
+}
+
+// Copy route URL to clipboard using names
+async function copyRouteUrlToClipboard() {
+ const fromName = startingLocationElement.value;
+ const toName = destinationLocationElement.value;
+ const shareUrl = generateRouteShareUrl(fromName, toName);
+ if (!shareUrl || !startingCoordinates || !destinationCoordinates) { // Also check if coords exist (route calculated)
+ notifySreenReader('Cannot copy link, route not calculated yet or locations invalid.');
+ return;
+ }
+ try {
+ await navigator.clipboard.writeText(shareUrl);
+ notifySreenReader('Route link copied to clipboard');
+ // Visual feedback
+ const originalText = copyRouteLinkButton.textContent; // Might not be needed if using innerHTML
+ copyRouteLinkButton.innerHTML = '
Copied!';
+ setTimeout(() => {
+ // copyRouteLinkButton.textContent = originalText;
+ copyRouteLinkButton.innerHTML = '
Copy Route Link'; // Reset icon too
+ }, 2000);
+ } catch (err) {
+ console.error('Failed to copy route URL: ', err);
+ notifySreenReader('Failed to copy route link');
+ }
+}
+
+// --- End URL Handling ---
+
/**
* Sets up event listeners for a search action triggered by an input element and a button.
* @param {HTMLElement} inputElement - The input element for entering a location.
@@ -55,7 +198,8 @@ export const initialize_DistanceFinder_EventListeners = () => {
distanceBox.style.display = "block";
if(detalisElement.parentElement.style.display == 'block') detailsCloseButton.click(); // close search details box
if (adjustablePointer) {
- marker = new Marker(adjustablePointer.primaryMarker.getLatLng()).addTo(map); // Creates a new marker on the map
+ // Ensure marker is defined or initialized if needed
+ let marker = window.marker || new Marker(adjustablePointer.primaryMarker.getLatLng()).addTo(map);
adjustablePointer.remove(); // Removes any active pointer on the map
}
successSound.play(); // Plays a sound to indicate action completion
@@ -69,62 +213,92 @@ export const initialize_DistanceFinder_EventListeners = () => {
[startingLocationElement, destinationLocationElement].forEach((inputElement) => {
inputElement.addEventListener("focus", () => {
activeInputElement = document.activeElement;
-
});
});
- // Handles selecting a location from the map
- document.getElementById("fromMap")?.addEventListener("click", () => {
- if (!marker) return; // Ensures a marker exists on the map
+ // Event listener for the main distance calculation button
+ findDistanceButton.addEventListener("click", calculateDistance);
+
+ // Event listener for the copy route link button
+ copyRouteLinkButton?.addEventListener("click", copyRouteUrlToClipboard);
- try {
- const { lat, lng } = marker.getLatLng();
- activeInputElement.value = `${(lat.toFixed(5))},${lng.toFixed(5)}`; // Updates the active input with the selected coordinates
- const selectedLocation = { lat:lat, lon: lng };
-
- // Updates the appropriate variable based on the focused input
- if (activeInputElement.id === "beginning") {
- startingCoordinates = selectedLocation;
- } else {
- destinationCoordinates = selectedLocation;
- }
-
- successSound.play(); // Plays a sound to confirm selection
- } catch (error) {
- console.error("Error selecting location from map:", error);
- alert("Focus on the starting point or destination then select point on map");
- }
- });
-
- // Closes the distance finder box
+ // Close button for the distance finder box
document.getElementById("closeBtn")?.addEventListener("click", closeDistanceFinder);
- // Adds a keyboard shortcut to trigger the "select from map" action
- distanceBox.addEventListener("keydown", (event) => {
- if (event.altKey && event.key === "l") {
- event.preventDefault();
- document.getElementById("fromMap")?.click(); // Simulates clicking the "fromMap" button
- }
- });
+ // Choose from map button
+ document.getElementById("fromMap")?.addEventListener("click", chooseLocationFromMap);
- // Triggers distance calculation when the "find" button is clicked
- findDistanceButton.addEventListener("click", calculateDistance.bind(findDistanceButton));
+ // Check for route parameters in URL on initial load
+ // We need to wait briefly for the map to potentially initialize from location params
+ // otherwise this might run before map.js sets the view based on lat/lng/zoom
+ setTimeout(checkRouteUrlParams, 500);
};
+// Function to close the distance finder box and clear results
+export function closeDistanceFinder() {
+ distanceBox.style.display = "none";
+ distanceResultElement.style.display = "none";
+ roadPathLayerGroup && roadPathLayerGroup.remove();
+ startingLocationElement.value = "";
+ destinationLocationElement.value = "";
+ startingCoordinates = null;
+ destinationCoordinates = null;
+ copyRouteLinkButton.style.display = 'none'; // Hide copy button
+ // Clear route URL parameters
+ const url = new URL(window.location.href);
+ url.searchParams.delete('from');
+ url.searchParams.delete('to');
+ // Also clear old coord-based ones just in case
+ url.searchParams.delete('fromLat');
+ url.searchParams.delete('fromLng');
+ url.searchParams.delete('toLat');
+ url.searchParams.delete('toLng');
+ window.history.pushState({ path: url.toString() }, '', url.toString());
+ closeSound.play();
+}
+
+// Function to allow user to choose a location from the map
+function chooseLocationFromMap() {
+ if (!activeInputElement) {
+ notifySreenReader("Please focus on either the starting or destination input field first.");
+ return;
+ }
+ notifySreenReader("Map focused. Click on the map to select the location.");
+ map.getContainer().focus();
+ map.once("click", (e) => {
+ const coords = { lat: e.latlng.lat, lon: e.latlng.lng };
+ activeInputElement.value = "Loading...";
+ reverseGeocode(coords.lat, coords.lon).then(name => {
+ activeInputElement.value = name;
+ if (activeInputElement.id === "beginning") {
+ startingCoordinates = coords;
+ } else if (activeInputElement.id === "destination") {
+ destinationCoordinates = coords;
+ }
+ successSound.play();
+ notifySreenReader(`Selected ${name} for ${activeInputElement.id === 'beginning' ? 'starting point' : 'destination'}.`);
+ activeInputElement.focus(); // Return focus
+ });
+ });
+}
// Function to handle search and selection of the starting location
export function handleStartingLocationSearch() {
performSearch(startingLocationElement, [])
.then((result) => {
+ if (!result) throw new Error("No result found"); // Handle case where performSearch resolves with no result
startingCoordinates = {
lat: parseFloat(result.lat),
lon: parseFloat(result.lon),
};
- startingLocationElement.value = result.name;
- document.getElementById("search-results")?.remove();
+ // Use display_name if available, otherwise use name
+ startingLocationElement.value = result.display_name || result.name;
+ removeResults();
})
.catch((error) => {
- console.error("Error fetching search results:", error);
+ console.error("Error fetching start location search results:", error);
+ notifySreenReader("Could not find starting location.");
+ removeResults();
});
}
@@ -132,33 +306,42 @@ export function handleStartingLocationSearch() {
export function handleDestinationSearch() {
performSearch(destinationLocationElement, [])
.then((result) => {
+ if (!result) throw new Error("No result found"); // Handle case where performSearch resolves with no result
destinationCoordinates = {
lat: parseFloat(result.lat),
lon: parseFloat(result.lon),
};
- destinationLocationElement.value = result.name;
- document.getElementById("search-results")?.remove();
+ // Use display_name if available, otherwise use name
+ destinationLocationElement.value = result.display_name || result.name;
+ removeResults();
})
.catch((error) => {
- console.error("Error fetching search results:", error);
+ console.error("Error fetching destination search results:", error);
+ notifySreenReader("Could not find destination location.");
+ removeResults();
});
}
// Function to calculate and display the distance between the starting and destination locations
export function calculateDistance() {
- this.style.pointerEvents = 'none';
- this.innerHTML = `
`;
- this.className = '';
+ if (!startingCoordinates || !destinationCoordinates) {
+ notifySreenReader("Please select both starting and destination points, or ensure locations from URL were found.");
+ return;
+ }
+
+ findDistanceButton.disabled = true;
+ findDistanceButton.innerHTML = `
Finding...`;
+ distanceResultElement.style.display = "none";
+ copyRouteLinkButton.style.display = 'none'; // Hide copy button initially
const routePoints = [startingCoordinates, destinationCoordinates];
const route = FOSSGISValhallaEngine("route", "auto", routePoints);
- route.getRoute(function (error, route) {
- this.style.pointerEvents = 'auto';
- this.innerHTML = '';
- this.className = 'fas fa-arrow-circle-right';
+ route.getRoute(function (error, routeResult) { // Renamed inner 'route' to 'routeResult'
+ findDistanceButton.disabled = false;
+ findDistanceButton.innerHTML = '
'; // Reset icon
- if (!error) {
+ if (!error && routeResult) {
// Add the route line to the map
if (geoLayer != null) {
geoLayer.remove();
@@ -166,10 +349,11 @@ export function calculateDistance() {
if (roadPathLayerGroup) {
roadPathLayerGroup.remove();
}
- marker.clearGeoJson();
+ // Assuming window.marker is available globally or passed appropriately
+ window.marker?.clearGeoJson();
roadPathLayerGroup = L.featureGroup();
- const path = L.polyline(route.line, { color: "blue" }).addTo(roadPathLayerGroup);
+ const path = L.polyline(routeResult.line, { color: "blue" }).addTo(roadPathLayerGroup);
L.circleMarker(path.getLatLngs()[0], { //adding starting point to map
fillColor: "red",
@@ -188,42 +372,31 @@ export function calculateDistance() {
roadPathLayerGroup.addTo(map);
map.fitBounds(roadPathLayerGroup.getBounds());
- document.getElementById("dist").innerHTML = toKMorMeter(route.distance*1000)
- dist.text = `Distance: ${toKMorMeter(route.distance*1000)}`;
- const timeElement = document.getElementById("time");
- if (route.time < 60) {
- timeElement.innerHTML = `${route.time} Minutes`;
- dist.text += `Time: ${route.time} Minutes`;
- } else {
- const hrs = parseInt(route.time / 60);
- const min = route.time % 60;
- timeElement.innerHTML = `${hrs} Hours ${min} Minutes`;
- dist.text += `Time: ${hrs} Hours ${min} Minutes`;
- }
+ const distanceText = toKMorMeter(routeResult.distance * 1000);
+ const timeInMinutes = parseInt(routeResult.time / 60);
+ const hours = Math.floor(timeInMinutes / 60);
+ const minutes = timeInMinutes % 60;
+ const timeText = `${hours > 0 ? hours + ' hr ' : ''}${minutes} min`;
+
+ document.getElementById("dist").textContent = distanceText;
+ document.getElementById("time").textContent = timeText;
+ distanceResultElement.style.display = "block";
+ copyRouteLinkButton.style.display = 'inline-block'; // Show copy button
+ notifySreenReader(`Route found. Distance: ${distanceText}. Estimated time: ${timeText}`);
+ updateRouteUrl(); // Update URL with names
- notifySreenReader(dist.text);
- document.getElementById("distanceResult").style.display = "block"; //showing the distance result
} else {
- const errorData = JSON.parse(route.responseText);
- if (errorData.error_code === 130) {
- alert("Failed to parse locations. Please ensure to select a valid location from suggestions.");
- } else {
- alert(errorData.error);
+ console.error("Error calculating route:", error);
+ // Provide more specific feedback if possible
+ let errorMsg = "Error calculating route.";
+ if (error && error.message) {
+ errorMsg = `Error calculating route: ${error.message}`;
+ } else if (typeof error === 'string') {
+ errorMsg = `Error calculating route: ${error}`;
}
+ notifySreenReader(errorMsg);
+ distanceResultElement.style.display = "none";
+ copyRouteLinkButton.style.display = 'none'; // Hide copy button on error
}
- }.bind(this));
-}
-
-// Function to close and reset the distance finder UI
-export function closeDistanceFinder() {
- if (roadPathLayerGroup) {
- roadPathLayerGroup.remove();
- roadPathLayerGroup = null;
- }
- closeSound.play();
- document.getElementById("distanceResult").style.display = "none";
- startingLocationElement.value = "";
- destinationLocationElement.value = "";
- distanceBox.style.display = "none";
- notifySreenReader("Distance finder closed");
+ }.bind(this));
}
diff --git a/src/components/map.js b/src/components/map.js
index 94ecd8d..58c8676 100644
--- a/src/components/map.js
+++ b/src/components/map.js
@@ -59,4 +59,24 @@ function calculateHeight() {
L.control.scale().addTo(map); // this adds the visible scale to the map
+// Initialize map view
+map.setView([20, 0], 2);
+
+// Check for URL parameters on load
+const urlParams = new URLSearchParams(window.location.search);
+const lat = urlParams.get('lat');
+const lng = urlParams.get('lng');
+const zoom = urlParams.get('zoom');
+const fromName = urlParams.get('from'); // Check for the new name-based route param
+
+// Only set view from lat/lng/zoom if route params are NOT present
+if (lat && lng && zoom && !fromName) {
+ const initialLat = parseFloat(lat);
+ const initialLng = parseFloat(lng);
+ const initialZoom = parseInt(zoom);
+ if (!isNaN(initialLat) && !isNaN(initialLng) && !isNaN(initialZoom)) {
+ map.setView([initialLat, initialLng], initialZoom);
+ }
+}
+
diff --git a/src/layout/add-listeners-to-ui.js b/src/layout/add-listeners-to-ui.js
index 904b6c9..c524fc7 100644
--- a/src/layout/add-listeners-to-ui.js
+++ b/src/layout/add-listeners-to-ui.js
@@ -12,6 +12,7 @@ import { fetchCurrentLocation } from '../services/current-location.js';
import { closeSearchResultsOnClickOutside } from '../utils/general-events.js';
import { closeSound } from '../utils/sounds.js';
import { map } from '../components/map.js';
+import { initializeSearchInput } from '../services/do-search.js';
@@ -34,6 +35,8 @@ let minusIcon = document
export function addListenerstoUI(){
+ // Initialize search input event listeners
+ initializeSearchInput();
layersBtn.addEventListener('click', function () {
layersDropdown.style.display =
diff --git a/src/services/bookmarks.js b/src/services/bookmarks.js
new file mode 100644
index 0000000..c819b1a
--- /dev/null
+++ b/src/services/bookmarks.js
@@ -0,0 +1,135 @@
+/*
+ * Copyright (c) 2023-25 Zendalona
+ * This software is licensed under the GPL-3.0 License.
+ * See the LICENSE file in the root directory for more information.
+ */
+
+import { map } from '../components/map.js';
+import { notifySreenReader } from '../utils/accessibility.js';
+import { successSound } from '../utils/sounds.js';
+import { geocodingAPI, headerofNominatim } from "../utils/to-km-or-meter.js";
+import { showPlaceDetails } from './fetch-place.js';
+
+const BOOKMARKS_KEY = 'map_bookmarks';
+
+// Get bookmarks from localStorage
+export function getBookmarks() {
+ const bookmarks = localStorage.getItem(BOOKMARKS_KEY);
+ return bookmarks ? JSON.parse(bookmarks) : [];
+}
+
+// Add a bookmark
+export function addBookmark(name, lat, lng) {
+ const bookmarks = getBookmarks();
+ // Check if bookmark already exists
+ if (!bookmarks.some(b => b.lat === lat && b.lng === lng)) {
+ bookmarks.unshift({ name, lat, lng });
+ localStorage.setItem(BOOKMARKS_KEY, JSON.stringify(bookmarks));
+ successSound.play();
+ notifySreenReader('Location bookmarked');
+ return true;
+ }
+ return false;
+}
+
+// Remove a bookmark
+export function removeBookmark(lat, lng) {
+ const bookmarks = getBookmarks();
+ const filteredBookmarks = bookmarks.filter(b => b.lat !== lat || b.lng !== lng);
+ localStorage.setItem(BOOKMARKS_KEY, JSON.stringify(filteredBookmarks));
+ successSound.play();
+ notifySreenReader('Bookmark removed');
+}
+
+// Navigate to a bookmarked location
+export async function navigateToBookmark(lat, lng) {
+ const zoom = map.getZoom(); // Get current zoom level
+ map.setView([lat, lng], zoom);
+ updateUrl(lat, lng, zoom); // Update browser URL
+
+ // Perform reverse geocoding to get place details
+ try {
+ const response = await fetch(
+ `${geocodingAPI}/reverse?lat=${lat}&lon=${lng}&zoom=18&format=jsonv2`,
+ headerofNominatim
+ );
+
+ if (response.ok) {
+ const data = await response.json();
+ // Show place details
+ showPlaceDetails(data);
+ }
+
+ successSound.play();
+ notifySreenReader('Navigated to bookmarked location');
+ } catch (error) {
+ console.error("Error fetching location details:", error);
+ notifySreenReader('Navigated to bookmarked location, but could not load details');
+ }
+}
+
+// Generate shareable URL
+function generateShareUrl(lat, lng, zoom) {
+ const url = new URL(window.location.href);
+ url.searchParams.set('lat', lat.toFixed(5));
+ url.searchParams.set('lng', lng.toFixed(5));
+ url.searchParams.set('zoom', zoom);
+ return url.toString();
+}
+
+// Update browser URL without reloading
+function updateUrl(lat, lng, zoom) {
+ const shareUrl = generateShareUrl(lat, lng, zoom);
+ window.history.pushState({ path: shareUrl }, '', shareUrl);
+}
+
+// Copy URL to clipboard
+async function copyUrlToClipboard(lat, lng, zoom, buttonElement) {
+ const shareUrl = generateShareUrl(lat, lng, zoom);
+ try {
+ await navigator.clipboard.writeText(shareUrl);
+ notifySreenReader('Link copied to clipboard');
+ // Optional: Provide visual feedback on the button
+ const originalIcon = buttonElement.innerHTML;
+ buttonElement.innerHTML = '
';
+ setTimeout(() => {
+ buttonElement.innerHTML = originalIcon;
+ }, 1500);
+ } catch (err) {
+ console.error('Failed to copy URL: ', err);
+ notifySreenReader('Failed to copy link');
+ }
+}
+
+// Create a bookmark list item element
+export function createBookmarkListItem(bookmark, onNavigate, onDelete) {
+ const listItem = document.createElement('li');
+ listItem.className = 'bookmark-item';
+ listItem.innerHTML = `
+
+
+ ${bookmark.name}
+
+
+
+
+
+
+ `;
+
+ // Add event listeners
+ listItem.querySelector('.navigate-btn').addEventListener('click', onNavigate);
+ listItem.querySelector('.delete-btn').addEventListener('click', onDelete);
+ listItem.querySelector('.share-btn').addEventListener('click', (e) => {
+ const zoom = map.getZoom();
+ copyUrlToClipboard(bookmark.lat, bookmark.lng, zoom, e.currentTarget);
+ });
+
+ return listItem;
+}
\ No newline at end of file
diff --git a/src/services/do-search.js b/src/services/do-search.js
index 61cdacf..1586914 100644
--- a/src/services/do-search.js
+++ b/src/services/do-search.js
@@ -7,11 +7,46 @@ import { notifyLoading, notifySreenReader } from "../utils/accessibility.js";
import { keyboardselect } from "../utils/keydown-helpers.js";
import { successSound } from "../utils/sounds.js";
import { geocodingAPI, headerofNominatim } from "../utils/to-km-or-meter.js";
+import { getSearchHistory, addToSearchHistory, createHistoryListItem } from "./search-history.js";
+import { getBookmarks, addBookmark, removeBookmark, navigateToBookmark, createBookmarkListItem } from "./bookmarks.js";
var placeIds = [];
let searchLoadingInterval; // To store the interval globally for cancellation
+// Initialize search input event listeners
+export function initializeSearchInput() {
+ const searchInput = document.getElementById('search-input');
+ if (searchInput) {
+ // Add bookmarks button
+ const bookmarksBtn = document.createElement('button');
+ bookmarksBtn.id = 'bookmarks-btn';
+ bookmarksBtn.className = 'action-btn';
+ bookmarksBtn.innerHTML = '
Bookmarks';
+ bookmarksBtn.setAttribute('aria-label', 'Show bookmarks');
+ searchInput.parentElement.parentNode.insertBefore(bookmarksBtn, searchInput.parentElement.nextSibling);
+
+ // Add event listeners
+ bookmarksBtn.addEventListener('click', () => {
+ const container = initializeResultsContainer(searchInput);
+ showBookmarks(container);
+ });
+
+ searchInput.addEventListener('focus', () => {
+ const query = searchInput.value.trim();
+ if (query.length <= 2) {
+ showSearchHistory(initializeResultsContainer(searchInput), searchInput);
+ }
+ });
+
+ searchInput.addEventListener('input', () => {
+ const query = searchInput.value.trim();
+ if (query.length <= 2) {
+ showSearchHistory(initializeResultsContainer(searchInput), searchInput);
+ }
+ });
+ }
+}
export function performSearch(inputField, excludedPlaceIds = []) {
removeResults(); // Clear the previous search results
@@ -20,15 +55,15 @@ export function performSearch(inputField, excludedPlaceIds = []) {
const query = inputField.value.trim();
const loadingMessage = `
`;
- // Clear results if query length is insufficient
+ // Initialize the search results container
+ let resultsContainer = initializeResultsContainer(inputField);
+
+ // If query is empty or too short, show search history
if (query.length <= 2) {
- removeResults();
+ showSearchHistory(resultsContainer, inputField, resolve);
return;
}
- // Initialize the search results container
- let resultsContainer = initializeResultsContainer(inputField);
-
// Display loading indicator
resultsContainer.innerHTML = loadingMessage;
searchLoadingInterval && clearInterval(searchLoadingInterval);
@@ -40,6 +75,9 @@ export function performSearch(inputField, excludedPlaceIds = []) {
.then((data) => {
clearInterval(searchLoadingInterval);
renderSearchResults(data, resultsContainer, inputField, resolve);
+ if (data.length > 0) {
+ addToSearchHistory(query); // Add to history if results were found
+ }
})
.catch((error) => {
clearInterval(searchLoadingInterval);
@@ -49,6 +87,76 @@ export function performSearch(inputField, excludedPlaceIds = []) {
});
}
+// Show search history in the results container
+function showSearchHistory(container, inputField, resolve) {
+ const history = getSearchHistory();
+ container.innerHTML = "";
+
+ if (history.length === 0) {
+ addNoResultsMessage(container);
+ return;
+ }
+
+ // Add a header for search history
+ const headerItem = document.createElement("li");
+ headerItem.innerHTML = `
Recent Searches`;
+ headerItem.style.backgroundColor = "transparent";
+ headerItem.style.cursor = "default";
+ container.appendChild(headerItem);
+
+ history.forEach(query => {
+ const historyItem = createHistoryListItem(query, (selectedQuery) => {
+ inputField.value = selectedQuery;
+ if (resolve) {
+ performSearch(inputField, []).then(resolve);
+ } else {
+ performSearch(inputField, []);
+ }
+ });
+ container.appendChild(historyItem);
+ });
+}
+
+// Show bookmarks in the results container
+function showBookmarks(container) {
+ const bookmarks = getBookmarks();
+ container.innerHTML = "";
+
+ if (bookmarks.length === 0) {
+ addNoResultsMessage(container, "No bookmarks saved");
+ return;
+ }
+
+ // Add header
+ const headerItem = document.createElement("li");
+ headerItem.innerHTML = `
Saved Places`;
+ headerItem.style.backgroundColor = "transparent";
+ headerItem.style.cursor = "default";
+ container.appendChild(headerItem);
+
+ // Add bookmarks
+ bookmarks.forEach(bookmark => {
+ const bookmarkItem = createBookmarkListItem(
+ bookmark,
+ async () => {
+ // Show loading indicator
+ container.innerHTML = `
`;
+
+ // Navigate to the bookmark (this will also show place details)
+ await navigateToBookmark(bookmark.lat, bookmark.lng);
+
+ // Close the results container
+ removeResults();
+ },
+ () => {
+ removeBookmark(bookmark.lat, bookmark.lng);
+ showBookmarks(container); // Refresh the list
+ }
+ );
+ container.appendChild(bookmarkItem);
+ });
+}
+
// Clears the search results and associated event listeners
function clearSearchResults(searchResults) {
if (searchResults) {
@@ -59,17 +167,23 @@ function clearSearchResults(searchResults) {
// Initializes the search results container
function initializeResultsContainer(inputField) {
- let container = inputField.nextElementSibling;
- if (!container || container.tagName !== "UL") {
- container = document.createElement("ul");
- container.id = "search-results";
- container.tabIndex = 7;
- container.setAttribute("aria-label", "Select your result");
- inputField.parentElement.parentNode.insertBefore(
- container,
- inputField.parentNode.nextSibling
- );
- }
+ // First, remove any existing search results containers
+ const existingContainers = document.querySelectorAll('#search-results');
+ existingContainers.forEach(container => {
+ container.parentElement?.removeEventListener("keydown", keyboardselect);
+ container.remove();
+ });
+
+ // Create new container
+ const container = document.createElement("ul");
+ container.id = "search-results";
+ container.tabIndex = 7;
+ container.setAttribute("aria-label", "Select your result");
+ inputField.parentElement.parentNode.insertBefore(
+ container,
+ inputField.parentNode.nextSibling
+ );
+
container.parentElement.addEventListener("keydown", keyboardselect);
return container;
}
@@ -110,9 +224,9 @@ function renderSearchResults(data, container, inputField, resolve) {
}
// Adds a "No Results Found" message
-function addNoResultsMessage(container) {
+function addNoResultsMessage(container, message = "No results found") {
const noResultsItem = document.createElement("li");
- noResultsItem.textContent = "No results found";
+ noResultsItem.textContent = message;
container.appendChild(noResultsItem);
}
@@ -133,9 +247,37 @@ function addMoreResultsOption(container, inputField, placeIds, resolve) {
// Creates a search result list item
function createResultListItem(result, onClick) {
const listItem = document.createElement("li");
+
+ // Create bookmark button
+ const bookmarkBtn = document.createElement("button");
+ bookmarkBtn.className = "bookmark-btn";
+ bookmarkBtn.innerHTML = '
';
+ bookmarkBtn.setAttribute("aria-label", `Bookmark ${result.display_name}`);
+
+ // Add bookmark functionality
+ bookmarkBtn.addEventListener("click", (e) => {
+ e.stopPropagation(); // Prevent triggering the list item click
+ const success = addBookmark(result.display_name, parseFloat(result.lat), parseFloat(result.lon));
+ if (success) {
+ bookmarkBtn.innerHTML = '
';
+ bookmarkBtn.classList.add("active");
+ }
+ });
+
+ // Check if already bookmarked
+ const bookmarks = getBookmarks();
+ if (bookmarks.some(b => b.lat === parseFloat(result.lat) && b.lng === parseFloat(result.lon))) {
+ bookmarkBtn.innerHTML = '
';
+ bookmarkBtn.classList.add("active");
+ }
+
listItem.innerHTML = `
-
${result.type} 
- ${result.display_name}`;
+
+ ${result.type} 
+ ${result.display_name}
+
`;
+
+ listItem.insertBefore(bookmarkBtn, listItem.firstChild);
listItem.setAttribute("aria-label", result.display_name);
listItem.tabIndex = 1;
listItem.addEventListener("click", onClick);
diff --git a/src/services/search-history.js b/src/services/search-history.js
new file mode 100644
index 0000000..fb37ec2
--- /dev/null
+++ b/src/services/search-history.js
@@ -0,0 +1,53 @@
+/*
+ * Copyright (c) 2023-25 Zendalona
+ * This software is licensed under the GPL-3.0 License.
+ * See the LICENSE file in the root directory for more information.
+ */
+
+const HISTORY_KEY = 'search_history';
+const MAX_HISTORY_ITEMS = 5;
+
+// Get search history from localStorage
+export function getSearchHistory() {
+ const history = localStorage.getItem(HISTORY_KEY);
+ return history ? JSON.parse(history) : [];
+}
+
+// Add a search query to history
+export function addToSearchHistory(query) {
+ if (!query || query.trim().length <= 2) return;
+
+ const history = getSearchHistory();
+ const trimmedQuery = query.trim();
+
+ // Remove the query if it already exists (to avoid duplicates)
+ const filteredHistory = history.filter(item => item.toLowerCase() !== trimmedQuery.toLowerCase());
+
+ // Add new query to the beginning
+ filteredHistory.unshift(trimmedQuery);
+
+ // Keep only the most recent MAX_HISTORY_ITEMS
+ while (filteredHistory.length > MAX_HISTORY_ITEMS) {
+ filteredHistory.pop();
+ }
+
+ // Save to localStorage
+ localStorage.setItem(HISTORY_KEY, JSON.stringify(filteredHistory));
+}
+
+// Clear search history
+export function clearSearchHistory() {
+ localStorage.removeItem(HISTORY_KEY);
+}
+
+// Create a history list item element
+export function createHistoryListItem(query, onClick) {
+ const listItem = document.createElement('li');
+ listItem.innerHTML = `
+
+ ${query}`;
+ listItem.setAttribute('aria-label', `Recent search: ${query}`);
+ listItem.tabIndex = 1;
+ listItem.addEventListener('click', () => onClick(query));
+ return listItem;
+}
\ No newline at end of file