openpilot/frogpilot/system/the_pond/assets/components/navigation/navigation_destination.js
2025-11-01 12:00:00 -07:00

826 lines
29 KiB
JavaScript

import { html, reactive } from "https://esm.sh/@arrow-js/core";
import {
addRouteToMap,
formatMetersToHuman,
formatSecondsToHuman,
getCoordinatesFromSearch,
getRoutes,
removeRouteFromMap,
getOrdinalSuffix,
highlightRoute,
} from "./navigation_utilities.js";
import { Modal } from "/assets/components/modal.js";
function sha1hex(str) {
const rot = (v, s) => (v << s) | (v >>> (32 - s));
const bytes = new TextEncoder().encode(str);
const words = [];
for (let i = 0; i < bytes.length; i++) {
words[i >> 2] |= bytes[i] << ((3 - (i & 3)) << 3);
}
const bitLen = bytes.length << 3;
words[bitLen >> 5] |= 0x80 << (24 - (bitLen & 31));
words[((bitLen + 64 >> 9) << 4) + 15] = bitLen;
let [a, b, c, d, e] = [0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476, 0xc3d2e1f0];
const w = new Array(80);
for (let i = 0; i < words.length; i += 16) {
for (let t = 0; t < 16; t++) w[t] = words[i + t] | 0;
for (let t = 16; t < 80; t++) {
w[t] = rot(w[t - 3] ^ w[t - 8] ^ w[t - 14] ^ w[t - 16], 1);
}
let [aa, bb, cc, dd, ee] = [a, b, c, d, e];
for (let t = 0; t < 80; t++) {
const k = t < 20 ? 0x5a827999 : t < 40 ? 0x6ed9eba1 : t < 60 ? 0x8f1bbcdc : 0xca62c1d6;
const f = t < 20 ? (bb & cc) | (~bb & dd) : t < 40 ? bb ^ cc ^ dd : t < 60 ? (bb & cc) | (bb & dd) | (cc & dd) : bb ^ cc ^ dd;
const tmp = (rot(aa, 5) + f + ee + k + w[t]) >>> 0;
ee = dd;
dd = cc;
cc = rot(bb, 30) >>> 0;
bb = aa;
aa = tmp;
}
a = (a + aa) >>> 0;
b = (b + bb) >>> 0;
c = (c + cc) >>> 0;
d = (d + dd) >>> 0;
e = (e + ee) >>> 0;
}
return [a, b, c, d, e].map(x => x.toString(16).padStart(8, "0")).join("");
}
async function geometryHashFromRoute(route) {
const flat = route.geometry.coordinates.flat().join(",");
if (crypto?.subtle?.digest && window.isSecureContext) {
const buf = await crypto.subtle.digest("SHA-1", new TextEncoder().encode(flat));
return [...new Uint8Array(buf)].map(b => b.toString(16).padStart(2, "0")).join("");
}
return sha1hex(flat);
}
async function setSpecial(favorite, type, state, loadFavoritesAlphabetically) {
try {
const isCurrentlyHome = favorite.is_home;
const isCurrentlyWork = favorite.is_work;
let newIsHome = null;
let newIsWork = null;
let message = "";
if (type === "home") {
if (isCurrentlyHome) {
newIsHome = false;
message = "Home location removed!";
} else {
newIsHome = true;
if (isCurrentlyWork) newIsWork = false;
message = "Home location set!";
}
} else if (type === "work") {
if (isCurrentlyWork) {
newIsWork = false;
message = "Work location removed!";
} else {
newIsWork = true;
if (isCurrentlyHome) newIsHome = false;
message = "Work location set!";
}
}
const body = { routeId: favorite.routeId, id: favorite.id };
if (newIsHome !== null) body.is_home = newIsHome;
if (newIsWork !== null) body.is_work = newIsWork;
await fetch("/api/navigation/favorite/rename", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
});
showSnackbar(message);
const sorted = await loadFavoritesAlphabetically();
state.suggestions = "[]";
await new Promise(resolve => setTimeout(resolve, 0));
state.suggestions = JSON.stringify(sorted);
} catch {
showSnackbar(`Failed to update ${type} location...`);
}
}
export function NavDestination() {
let map;
let destinationMarker;
let favoriteMarkers = [];
const state = reactive({
amap1Key: undefined,
amap2Key: undefined,
canToggleProvider: false,
confirmedRoute: null,
confirmedRouteRefresh: 0,
destination: undefined,
favoriteToRemove: null,
favoriteToRename: null,
favoritesCount: 0,
favoritesVisible: false,
initialized: false,
isMetric: true,
lastPosition: undefined,
loadingRoute: false,
mapboxPublic: undefined,
mapboxSecret: undefined,
missingKeys: null,
newFavoriteName: "",
previousDestinations: "[]",
searchProvider: "mapbox",
selectedRoute: null,
showRemoveFavoriteModal: false,
showRenameFavoriteModal: false,
suggestions: "[]"
});
const searchFieldState = reactive({ value: "" });
const sessionToken = crypto.randomUUID?.() || Math.random().toString(36).slice(2);
function areRoutesEqual(a, b) {
return a?.routeHash && b?.routeHash && a.routeHash === b.routeHash;
}
function confirmRemoveFavorite(favorite) {
state.favoriteToRemove = favorite;
state.showRemoveFavoriteModal = true;
}
function confirmRenameFavorite(fav) {
state.favoriteToRename = fav;
state.newFavoriteName = fav.name;
state.showRenameFavoriteModal = true;
}
async function setHome(favorite) {
await setSpecial(favorite, "home", state, loadFavoritesAlphabetically);
}
async function setWork(favorite) {
await setSpecial(favorite, "work", state, loadFavoritesAlphabetically);
}
async function initiateNavigation(destination, { resume = false } = {}) {
state.selectedRoute = null;
state.confirmedRoute = null;
state.loadingRoute = true;
try {
const { name, longitude, latitude } = destination;
const coords = [longitude, latitude];
const inputEl = document.getElementById("search-field");
if (inputEl && !resume) {
inputEl.value = name;
}
if (destinationMarker) destinationMarker.remove();
destinationMarker = new mapboxgl.Marker().setLngLat(coords).addTo(map);
const routes = await getRoutes(
`${state.lastPosition.longitude},${state.lastPosition.latitude}`,
`${coords[0]},${coords[1]}`,
state.mapboxPublic
);
removeRouteFromMap(map);
if (routes.length > 0) {
const selectedRouteId = "main";
const selectedRouteData = routes[0];
const routeHash = await geometryHashFromRoute(selectedRouteData);
const selected = {
name,
duration: selectedRouteData.duration,
distance: selectedRouteData.distance,
destinationCoordinates: coords,
startingCoordinates: [state.lastPosition.longitude, state.lastPosition.latitude],
routeId: selectedRouteId,
routeHash,
steps: selectedRouteData?.legs?.[0]?.steps || []
};
state.selectedRoute = selected;
if (resume) state.confirmedRoute = JSON.parse(JSON.stringify(selected));
localStorage.setItem("lastRouteId", selected.routeId);
addRouteToMap(
map,
routes,
[state.lastPosition.longitude, state.lastPosition.latitude],
coords,
(route, routeId) => {
state.selectedRoute = {
...state.selectedRoute,
duration: route.duration,
distance: route.distance,
routeId,
steps: route?.legs?.[0]?.steps || []
};
highlightRoute(map, routes, routeId);
},
state.isMetric,
() => state.selectedRoute?.routeId ?? null
);
if (resume && map) {
requestAnimationFrame(() => {
map.flyTo({
center: [state.lastPosition.longitude, state.lastPosition.latitude],
zoom: 18,
pitch: 45,
speed: 1,
curve: 1
});
});
}
}
state.suggestions = "[]";
} catch (err) {
console.error("Failed to calculate route:", err);
showSnackbar("Failed to calculate route...");
} finally {
state.loadingRoute = false;
}
}
async function getNavigationData() {
const res = await fetch("/api/navigation");
const data = await res.json();
state.mapboxPublic = data.mapboxPublic.trim();
state.mapboxSecret = data.mapboxSecret.trim();
state.amap1Key = data.amap1Key?.trim() || "";
state.amap2Key = data.amap2Key?.trim() || "";
state.isMetric = data.isMetric ?? true;
const hasMapbox = !!state.mapboxPublic && !!state.mapboxSecret;
const hasAMap = !!state.amap1Key && !!state.amap2Key;
state.missingKeys = !hasMapbox;
state.canToggleProvider = hasMapbox && hasAMap;
state.searchProvider = hasMapbox ? "mapbox" : "";
if (state.missingKeys) return;
state.lastPosition = {
latitude: parseFloat(data.lastPosition.latitude),
longitude: parseFloat(data.lastPosition.longitude)
};
try {
state.destination = JSON.parse(data.destination);
} catch {}
try {
const prev = JSON.parse(data.previousDestinations);
state.previousDestinations = prev.map(d => ({ name: d.place_name }));
state.suggestions = JSON.stringify(state.previousDestinations);
} catch {}
setupMap();
loadFavoritesAlphabetically();
}
async function handleFavoritesClick() {
if (state.favoritesVisible) {
state.suggestions = "[]";
state.favoritesVisible = false;
return;
}
searchFieldState.value = "";
state.selectedRoute = null;
state.confirmedRoute = null;
const sorted = await loadFavoritesAlphabetically();
state.suggestions = JSON.stringify(sorted);
state.favoritesVisible = true;
}
async function handleSearchKey(e) {
if (e.key === "Enter") {
clearTimeout(window.searchTimeout);
const val = e.target.value.trim();
searchFieldState.value = e.target.value;
if (val.length < 3) {
if (val.length === 0) state.suggestions = "[]";
return;
}
state.selectedRoute = null;
state.confirmedRoute = null;
state.suggestions = "[]";
if (state.searchProvider === "mapbox") {
const prox = `${state.lastPosition.longitude},${state.lastPosition.latitude}`;
const params = new URLSearchParams({
proximity: prox,
access_token: state.mapboxPublic,
session_token: sessionToken,
q: val,
limit: 4
});
const res = await fetch(`https://api.mapbox.com/search/searchbox/v1/suggest?${params}`);
const data = await res.json();
state.suggestions = JSON.stringify(data.suggestions);
} else {
const auto = new AMap.Autocomplete({ city: "auto" });
auto.search(val, (status, result) => {
if (status === "complete" && result.tips) {
state.suggestions = JSON.stringify(result.tips);
}
});
}
}
}
function isRouteFavorited(route, favorites) {
return favorites.some(fav =>
fav.latitude === route.destinationCoordinates[1] &&
fav.longitude === route.destinationCoordinates[0]
);
}
function addFavoriteMarkers(favorites) {
favoriteMarkers.forEach(marker => marker.remove());
favoriteMarkers = [];
favorites.forEach(fav => {
const el = document.createElement("div");
el.className = "favorite-marker";
let icon = "❤️";
let popupText = fav.name;
if (fav.is_home) {
icon = "🏠";
el.className += " home-marker";
popupText = `Home: ${fav.name}`;
} else if (fav.is_work) {
icon = "💼";
el.className += " work-marker";
popupText = `Work: ${fav.name}`;
}
el.innerHTML = icon;
const marker = new mapboxgl.Marker(el)
.setLngLat([fav.longitude, fav.latitude])
.setPopup(new mapboxgl.Popup({ offset: 25, closeButton: false }).setText(popupText))
.addTo(map);
el.addEventListener("click", () => {
if (marker.getPopup().isOpen()) {
marker.togglePopup();
}
initiateNavigation(fav);
});
el.addEventListener("mouseenter", () => marker.togglePopup());
el.addEventListener("mouseleave", () => marker.togglePopup());
favoriteMarkers.push(marker);
});
}
async function loadFavoritesAlphabetically() {
try {
const res = await fetch("/api/navigation/favorite");
const json = await res.json();
const sorted = json.favorites.sort((a, b) => (a.name || "").localeCompare(b.name || ""));
state.favoritesCount = sorted.length;
state.favoriteRoutes = sorted;
addFavoriteMarkers(sorted);
if (state.favoritesVisible) {
state.suggestions = JSON.stringify(sorted);
}
return sorted;
} catch {
showSnackbar("Failed to load favorites...");
return [];
}
}
async function removeFavorite() {
if (!state.favoriteToRemove) return;
const { id, name, latitude, longitude, routeId } = state.favoriteToRemove;
try {
await fetch("/api/navigation/favorite", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id, name, latitude, longitude, routeId })
});
await loadFavoritesAlphabetically();
showSnackbar("Favorite removed!");
} catch {
showSnackbar("Failed to remove favorite...");
} finally {
state.showRemoveFavoriteModal = false;
state.favoriteToRemove = null;
}
}
async function renameFavorite() {
const fav = state.favoriteToRename;
const newName = state.newFavoriteName.trim();
if (!fav || !newName || newName === fav.name) {
state.showRenameFavoriteModal = false;
return;
}
try {
await fetch("/api/navigation/favorite", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(fav)
});
await fetch("/api/navigation/favorite", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: newName,
longitude: fav.longitude,
latitude: fav.latitude,
routeId: fav.routeId
})
});
if (state.favoritesVisible) {
state.suggestions = "[]";
state.favoritesVisible = false;
}
handleFavoritesClick();
showSnackbar(`"${fav.name}" renamed to "${newName}"!`, "success");
} catch {
showSnackbar("Failed to edit favorite name...");
} finally {
state.showRenameFavoriteModal = false;
}
}
async function searchInput(e) {
const newVal = e.target.value.trim();
searchFieldState.value = e.target.value;
clearTimeout(window.searchTimeout);
window.searchTimeout = setTimeout(async () => {
const val = newVal;
if (val.length < 3) {
if (val.length === 0) {
state.suggestions = "[]";
}
return;
}
state.selectedRoute = null;
state.confirmedRoute = null;
state.suggestions = "[]";
if (state.searchProvider === "mapbox") {
const prox = `${state.lastPosition.longitude},${state.lastPosition.latitude}`;
const params = new URLSearchParams({
proximity: prox,
access_token: state.mapboxPublic,
session_token: sessionToken,
q: val,
limit: 4
});
const res = await fetch(`https://api.mapbox.com/search/searchbox/v1/suggest?${params}`);
const data = await res.json();
state.suggestions = JSON.stringify(data.suggestions);
} else {
const auto = new AMap.Autocomplete({ city: "auto" });
auto.search(val, (status, result) => {
if (status === "complete" && result.tips) {
state.suggestions = JSON.stringify(result.tips);
}
});
}
}, 800);
}
async function selectSuggestion(sugg) {
const label = sugg.full_address || sugg.name || sugg.address || "Unnamed Location";
let coords;
if (sugg.routeId) {
initiateNavigation({
name: sugg.name,
longitude: sugg.longitude,
latitude: sugg.latitude,
routeId: sugg.routeId
});
return;
}
state.loadingRoute = true;
try {
if (state.searchProvider === "mapbox") {
if (sugg.geometry && Array.isArray(sugg.geometry.coordinates)) {
coords = sugg.geometry.coordinates;
} else if (sugg.mapbox_id) {
const url = new URL(`https://api.mapbox.com/search/searchbox/v1/retrieve/${encodeURIComponent(sugg.mapbox_id)}`);
url.searchParams.set("access_token", state.mapboxPublic);
url.searchParams.set("session_token", sessionToken);
const ret = await fetch(url);
const retJson = await ret.json();
coords = retJson.features[0].geometry.coordinates;
} else {
coords = await getCoordinatesFromSearch(label, state.mapboxPublic);
}
} else {
coords = [sugg.location.lng, sugg.location.lat];
}
if (coords) {
initiateNavigation({
name: label,
longitude: coords[0],
latitude: coords[1],
routeId: null
});
} else {
throw new Error("Could not determine location.");
}
} catch (err) {
console.error(err);
showSnackbar("Error: Could not determine location.", "error");
state.loadingRoute = false;
}
}
const setupMap = async () => {
if (!state.mapboxPublic || state.initialized) return;
const container = document.getElementById("map");
if (!container) {
requestAnimationFrame(setupMap);
return;
}
state.initialized = true;
mapboxgl.accessToken = state.mapboxPublic;
map = new mapboxgl.Map({
container,
center: [state.lastPosition.longitude, state.lastPosition.latitude],
zoom: 15,
pitch: 45,
speed: 1,
curve: 1,
attributionControl: false,
logoPosition: "bottom-right",
style: "mapbox://styles/frogsgomoo/cmcfv151j000o01rcdxebhl76"
});
new mapboxgl.Marker().setLngLat([state.lastPosition.longitude, state.lastPosition.latitude]).addTo(map);
map.on("load", () => {
map.flyTo({
center: [state.lastPosition.longitude, state.lastPosition.latitude],
zoom: 18,
pitch: 45,
speed: 1,
curve: 1
});
if (state.destination) {
const savedId = localStorage.getItem("activeRouteId");
initiateNavigation({ ...state.destination, routeId: savedId }, { resume: true });
}
});
map.on("style.load", () => {
const labelLayer = map.getStyle().layers.find(l => l.type === "symbol" && l.layout["text-field"]).id;
map.addLayer(
{
id: "add-3d-buildings",
source: "composite",
"source-layer": "building",
filter: ["==", "extrude", "true"],
type: "fill-extrusion",
minzoom: 15,
paint: {
"fill-extrusion-color": "#aaa",
"fill-extrusion-height": ["interpolate", ["linear"], ["zoom"], 15, 0, 15.05, ["get", "height"]],
"fill-extrusion-base": ["interpolate", ["linear"], ["zoom"], 15, 0, 15.05, ["get", "min_height"]],
"fill-extrusion-opacity": 0.6
}
},
labelLayer
);
});
};
getNavigationData();
return html`
<div class="navigation-container">
${() => {
if (state.missingKeys === null) return "";
return state.missingKeys
? html`
<section class="keys-required-wrapper">
<div class="keys-required-widget">
<div class="keys-required-title">Mapbox Keys Required</div>
<p class="keys-required-text">You must set both your public and secret Mapbox keys before using navigation features.</p>
<a href="/navigation_keys" class="keys-required-button">Go to "Manage Keys"</a>
</div>
</section>
`
: html`
<div class="map-wrapper">
<div class="search-wrapper">
<div class="search-controls">
<input autocomplete="off" id="search-field" placeholder="Search here" value="${() => searchFieldState.value}" @input="${searchInput}" @keydown="${handleSearchKey}" />
${() => (state.favoritesCount > 0 ? html`<button class="favorites-toggle-button" @click="${handleFavoritesClick}">❤️ Favorites</button>` : "")}
${() => (state.canToggleProvider ? html`
<div class="search-provider-toggle">
<button class="${() => (state.searchProvider === "amap" ? "active" : "")}" @click="${() => { state.searchProvider = "amap"; state.suggestions = "[]"; }}">AMap</button>
<button class="${() => (state.searchProvider === "mapbox" ? "active" : "")}" @click="${() => { state.searchProvider = "mapbox"; state.suggestions = "[]"; }}">Mapbox</button>
</div>
` : "")}
</div>
<div id="infobox">
${() => {
if (state.loadingRoute) {
return html`<div class="navigation-summary-widget loading-status"><span class="spinner"></span> Calculating route...</div>`;
} else if (state.selectedRoute) {
return NavigationDestination({
...state.selectedRoute,
isFavorited: isRouteFavorited(state.selectedRoute, state.favoriteRoutes),
isConfirmed: () => areRoutesEqual(state.selectedRoute, state.confirmedRoute),
map,
isMetric: state.isMetric,
cancelNavigationFn: () => {
state.selectedRoute = null;
state.confirmedRoute = null;
state.suggestions = state.previousDestinations;
if (destinationMarker) destinationMarker.remove();
},
onConfirm: () => {
state.confirmedRoute = JSON.parse(JSON.stringify(state.selectedRoute));
state.confirmedRouteRefresh = Math.random();
},
loadFavorites: loadFavoritesAlphabetically,
removeFavorite: confirmRemoveFavorite,
searchFieldState,
favoriteRoutes: state.favoriteRoutes
}, state.confirmedRouteRefresh);
} else if (JSON.parse(state.suggestions).length > 0) {
return SearchSuggestions({
suggestions: JSON.parse(state.suggestions),
selectSuggestion,
removeFavorite: confirmRemoveFavorite,
renameFavorite: confirmRenameFavorite,
setHome: setHome,
setWork: setWork
});
}
}}
</div>
</div>
<div id="map"></div>
</div>
`;
}}
</div>
${() => (state.showRemoveFavoriteModal ? Modal({
title: "Remove Favorite",
message: `Are you sure you want to remove <strong>${state.favoriteToRemove?.name}</strong> from your favorites?`,
onConfirm: removeFavorite,
onCancel: () => { state.showRemoveFavoriteModal = false; state.favoriteToRemove = null; },
confirmText: "Remove"
}) : "")}
${() => (state.showRenameFavoriteModal ? Modal({
title: "Rename Favorite",
message: html`
<div>
<p>Rename <strong>${state.favoriteToRename.name}</strong> to:</p>
<div style="margin-top: 10px;">
<input class="modal-input" type="text" value="${state.newFavoriteName}" @click="${e => e.stopPropagation()}" @input="${e => state.newFavoriteName = e.target.value}" />
</div>
</div>
`,
onConfirm: renameFavorite,
onCancel: () => { state.showRenameFavoriteModal = false; },
confirmText: "Rename",
confirmClass: "btn-primary"
}) : "")}
`;
}
function SearchSuggestions({ suggestions, selectSuggestion, removeFavorite, renameFavorite, setHome, setWork }) {
const isFavorite = s => s.name && s.latitude != null && s.longitude != null && s.routeId;
const item = s => html`
<div class="suggestion-item" @click="${() => selectSuggestion(s)}">
<p>
${s.is_home ? "🏠 " : ""}
${s.is_work ? "💼 " : ""}
${s.name || s.address}
</p>
${isFavorite(s) ? html`
<div class="favorite-actions">
<button class="home-favorite-button ${s.is_home ? "active" : ""}" title="Set as Home" @click="${e => { e.stopPropagation(); setHome(s); }}">🏠</button>
<button class="work-favorite-button ${s.is_work ? "active" : ""}" title="Set as Work" @click="${e => { e.stopPropagation(); setWork(s); }}">💼</button>
<button class="edit-favorite-button" title="Rename Favorite" @click="${e => { e.stopPropagation(); renameFavorite(s); }}">✏️</button>
<button class="remove-favorite-button" title="Remove from Favorites" @click="${e => { e.stopPropagation(); removeFavorite(s); }}">🗑️</button>
</div>
` : ""}
</div>
`;
return html`<div id="searchSuggestions">${suggestions.map(item)}</div>`;
}
function NavigationDestination({
name,
duration,
distance,
routeId,
routeHash,
isConfirmed,
destinationCoordinates,
startingCoordinates,
isMetric,
map,
cancelNavigationFn,
onConfirm,
loadFavorites,
removeFavorite,
searchFieldState,
isFavorited,
favoriteRoutes = [],
steps = []
}) {
async function cancelNavigation() {
showSnackbar("Navigation cancelled...");
removeRouteFromMap(map);
cancelNavigationFn();
localStorage.removeItem("activeRouteId");
map.flyTo({ center: startingCoordinates, zoom: 15, pitch: 45, speed: 1, curve: 1 });
await fetch("/api/navigation", { method: "DELETE" });
}
async function confirmDestination() {
onConfirm?.();
showSnackbar("Navigation set!");
localStorage.setItem("activeRouteId", routeId);
await fetch("/api/navigation", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name,
longitude: destinationCoordinates[0],
latitude: destinationCoordinates[1]
})
});
await loadFavorites();
const searchInputEl = document.getElementById("search-field");
if (searchInputEl) searchInputEl.value = "";
searchFieldState.value = "";
requestAnimationFrame(() => {
map?.flyTo({
center: startingCoordinates,
zoom: 18,
pitch: 45,
speed: 1,
curve: 1
});
});
}
async function favoriteDestination() {
try {
const res = await fetch("/api/navigation/favorite", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name,
longitude: destinationCoordinates[0],
latitude: destinationCoordinates[1],
routeId
})
});
const { message } = await res.json();
showSnackbar(message || "Added to favorites!");
await loadFavorites();
} catch {
showSnackbar("Failed to add to favorites...");
}
}
async function toggleFavorite() {
if (isFavorited) {
const fav = favoriteRoutes.find(
f => f.latitude === destinationCoordinates[1] && f.longitude === destinationCoordinates[0]
);
if (fav) {
removeFavorite(fav);
} else {
showSnackbar("Couldn't find favorite entry...");
}
} else {
await favoriteDestination();
}
}
const eta = new Date(Date.now() + duration * 1000);
const isLong = duration > 86400;
const timeStr = eta.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
const month = eta.toLocaleString([], { month: "long" });
const day = eta.getDate();
const year = eta.getFullYear();
const etaString = isLong ? `${month} ${day}${getOrdinalSuffix(day)}, ${year}, ${timeStr}` : timeStr;
return html`
<div class="navigation-summary-widget">
<div class="navigation-summary-title">${name}</div>
<div class="summary-row">
<span class="emoji">🛣️</span>
<span class="label">Distance:</span>
<span class="value">${formatMetersToHuman(distance, isMetric)}</span>
</div>
<div class="summary-row">
<span class="emoji">⌛</span>
<span class="label">Duration:</span>
<span class="value">${formatSecondsToHuman(duration)}</span>
</div>
<div class="summary-row">
<span class="emoji">🕗</span>
<span class="label">ETA:</span>
<span class="value">${etaString}</span>
</div>
<div class="buttonCluster">
${() =>
isConfirmed()
? html`<button class="cancel" @click="${cancelNavigation}"><i class="bi bi-x-lg"></i> Cancel Navigation</button>`
: html`<button class="directions" @click="${confirmDestination}"><i class="bi bi-sign-turn-right"></i> Start Navigation</button>`}
<button class="favorite" @click="${toggleFavorite}">${isFavorited ? "💔 Unfavorite" : "❤️ Favorite"}</button>
</div>
</div>
`;
}