diff --git a/public/404.html b/public/404.html new file mode 100644 index 0000000..467ea6e --- /dev/null +++ b/public/404.html @@ -0,0 +1,82 @@ + + +
+ + + + + + + + + + + + + + + + + + + +Measure the road distance and time between two locations.
+
Directions courtesy of Valhalla (FOSSGIS)
Directions courtesy of Valhalla (FOSSGIS)
The page you are looking for does not exist.
'); + } else { + res.writeHead(404, { 'Content-Type': 'text/html' }); + res.end(content404); + } + }); } else { res.writeHead(200, { 'Content-Type': contentType }); res.end(content); 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} + +