diff --git a/public/index.html b/public/index.html index 513c203..3a5f651 100644 --- a/public/index.html +++ b/public/index.html @@ -85,43 +85,55 @@

coordinates:

- +
- -

Find Distance

- + +

+ Find Distance +

+ +

Measure the road distance and time between two locations.

+
- -
- + +
+ +
- - +
- -
- + +
+ +
- - - + +
+ + +
+
- Distance :
- Time : + Distance:
+ Estimated Time: +
-


Directions courtesy of Valhalla (FOSSGIS)

+ +

Directions courtesy of Valhalla (FOSSGIS)

+
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