aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>2026-03-13 11:12:36 +0000
committercopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>2026-03-13 11:12:36 +0000
commit8b4252dc937d6c937bd718515f03dd48948a1519 (patch)
treee26f0455ebbf8ac0d28bcda749da26618b67cefe /src
parent1953bb738bb845c47e63ebc0789308a3cd00ddc2 (diff)
feat: geolocation hook, map context menu, simplified home planner widget"
Co-authored-by: arielcostas <94913521+arielcostas@users.noreply.github.com>
Diffstat (limited to 'src')
-rw-r--r--src/frontend/app/components/PlannerOverlay.tsx24
-rw-r--r--src/frontend/app/components/shared/AppMap.tsx10
-rw-r--r--src/frontend/app/contexts/MapContext.tsx158
-rw-r--r--src/frontend/app/hooks/useGeolocation.ts59
-rw-r--r--src/frontend/app/i18n/locales/en-GB.json4
-rw-r--r--src/frontend/app/i18n/locales/es-ES.json4
-rw-r--r--src/frontend/app/i18n/locales/gl-ES.json4
-rw-r--r--src/frontend/app/routes/home.tsx108
-rw-r--r--src/frontend/app/routes/map.tsx133
-rw-r--r--src/frontend/app/routes/planner.tsx33
10 files changed, 352 insertions, 185 deletions
diff --git a/src/frontend/app/components/PlannerOverlay.tsx b/src/frontend/app/components/PlannerOverlay.tsx
index facf6f9..d953c2e 100644
--- a/src/frontend/app/components/PlannerOverlay.tsx
+++ b/src/frontend/app/components/PlannerOverlay.tsx
@@ -15,6 +15,7 @@ import {
type PlannerSearchResult,
} from "~/data/PlannerApi";
import StopDataProvider from "~/data/StopDataProvider";
+import { useGeolocation } from "~/hooks/useGeolocation";
import { usePlanner } from "~/hooks/usePlanner";
interface PlannerOverlayProps {
@@ -30,7 +31,6 @@ interface PlannerOverlayProps {
clearPickerOnOpen?: boolean;
showLastDestinationWhenCollapsed?: boolean;
cardBackground?: string;
- userLocation?: { latitude: number; longitude: number } | null;
autoLoad?: boolean;
}
@@ -42,11 +42,11 @@ export const PlannerOverlay: React.FC<PlannerOverlayProps> = ({
clearPickerOnOpen = false,
showLastDestinationWhenCollapsed = true,
cardBackground,
- userLocation,
autoLoad = true,
}) => {
const { t } = useTranslation();
const navigate = useNavigate();
+ const { userLocation, requestLocation } = useGeolocation();
const {
origin,
setOrigin,
@@ -173,22 +173,12 @@ export const PlannerOverlay: React.FC<PlannerOverlayProps> = ({
const setOriginFromCurrentLocation = useCallback(
(closePicker: boolean = true) => {
- console.log(
- "[PlannerOverlay] setOriginFromCurrentLocation called, closePicker:",
- closePicker
- );
if (!navigator.geolocation) {
- console.warn("[PlannerOverlay] Geolocation not available");
return;
}
setLocationLoading(true);
navigator.geolocation.getCurrentPosition(
async (pos) => {
- console.log(
- "[PlannerOverlay] Geolocation success:",
- pos.coords.latitude,
- pos.coords.longitude
- );
try {
// Set immediately using raw coordinates; refine later if reverse geocode works.
const initial: PlannerSearchResult = {
@@ -198,16 +188,16 @@ export const PlannerOverlay: React.FC<PlannerOverlayProps> = ({
lon: pos.coords.longitude,
layer: "current-location",
};
- console.log("[PlannerOverlay] Setting initial origin:", initial);
setOrigin(initial);
setOriginQuery(initial.name || "");
+ // Share location with global context so other consumers benefit
+ requestLocation();
try {
const rev = await reverseGeocode(
pos.coords.latitude,
pos.coords.longitude
);
- console.log("[PlannerOverlay] Reverse geocode result:", rev);
if (rev) {
const refined: PlannerSearchResult = {
...initial,
@@ -215,10 +205,6 @@ export const PlannerOverlay: React.FC<PlannerOverlayProps> = ({
label: rev.label || initial.label,
layer: "current-location",
};
- console.log(
- "[PlannerOverlay] Setting refined origin:",
- refined
- );
setOrigin(refined);
setOriginQuery(refined.name || "");
}
@@ -238,7 +224,7 @@ export const PlannerOverlay: React.FC<PlannerOverlayProps> = ({
{ enableHighAccuracy: false, maximumAge: 60000, timeout: 10000 }
);
},
- [setOrigin, t]
+ [setOrigin, t, requestLocation]
);
useEffect(() => {
diff --git a/src/frontend/app/components/shared/AppMap.tsx b/src/frontend/app/components/shared/AppMap.tsx
index c6eb8ee..d4ad557 100644
--- a/src/frontend/app/components/shared/AppMap.tsx
+++ b/src/frontend/app/components/shared/AppMap.tsx
@@ -44,6 +44,7 @@ interface AppMapProps {
onRotateStart?: () => void;
onPitchStart?: () => void;
onLoad?: () => void;
+ onContextMenu?: (e: MapLayerMouseEvent) => void;
}
export const AppMap = forwardRef<MapRef, AppMapProps>(
@@ -72,6 +73,7 @@ export const AppMap = forwardRef<MapRef, AppMapProps>(
onRotateStart,
onPitchStart,
onLoad,
+ onContextMenu,
},
ref
) => {
@@ -79,6 +81,8 @@ export const AppMap = forwardRef<MapRef, AppMapProps>(
theme,
mapState,
updateMapState,
+ setUserLocation,
+ setLocationPermission,
showTraffic: settingsShowTraffic,
showCameras: settingsShowCameras,
mapPositionMode,
@@ -200,6 +204,7 @@ export const AppMap = forwardRef<MapRef, AppMapProps>(
onRotateStart={onRotateStart}
onPitchStart={onPitchStart}
onLoad={onLoad}
+ onContextMenu={onContextMenu}
>
{showNavigation && <NavigationControl position="bottom-right" />}
{showGeolocate && (
@@ -207,6 +212,11 @@ export const AppMap = forwardRef<MapRef, AppMapProps>(
position="bottom-right"
trackUserLocation={true}
positionOptions={{ enableHighAccuracy: false }}
+ onGeolocate={(e) => {
+ const { latitude, longitude } = e.coords;
+ setUserLocation([latitude, longitude]);
+ setLocationPermission(true);
+ }}
/>
)}
{children}
diff --git a/src/frontend/app/contexts/MapContext.tsx b/src/frontend/app/contexts/MapContext.tsx
index f888f34..195c6e6 100644
--- a/src/frontend/app/contexts/MapContext.tsx
+++ b/src/frontend/app/contexts/MapContext.tsx
@@ -1,8 +1,10 @@
import { type LngLatLike } from "maplibre-gl";
import {
createContext,
+ useCallback,
useContext,
useEffect,
+ useRef,
useState,
type ReactNode,
} from "react";
@@ -18,6 +20,7 @@ interface MapContextProps {
setUserLocation: (location: LngLatLike | null) => void;
setLocationPermission: (hasPermission: boolean) => void;
updateMapState: (center: LngLatLike, zoom: number, path: string) => void;
+ requestLocation: () => void;
}
const MapContext = createContext<MapContextProps | undefined>(undefined);
@@ -28,9 +31,6 @@ export const MapProvider = ({ children }: { children: ReactNode }) => {
if (savedMapState) {
try {
const parsed = JSON.parse(savedMapState);
- // Validate that the saved center is valid if needed, or just trust it.
- // We might want to ensure we have a fallback if the region changed while the app was closed?
- // But for now, let's stick to the existing logic.
return {
paths: parsed.paths || {},
userLocation: parsed.userLocation || null,
@@ -47,58 +47,129 @@ export const MapProvider = ({ children }: { children: ReactNode }) => {
};
});
- const setUserLocation = (userLocation: LngLatLike | null) => {
+ const watchIdRef = useRef<number | null>(null);
+
+ const setUserLocation = useCallback((userLocation: LngLatLike | null) => {
setMapState((prev) => {
const newState = { ...prev, userLocation };
localStorage.setItem("mapState", JSON.stringify(newState));
return newState;
});
- };
+ }, []);
- const setLocationPermission = (hasLocationPermission: boolean) => {
- setMapState((prev) => {
- const newState = { ...prev, hasLocationPermission };
- localStorage.setItem("mapState", JSON.stringify(newState));
- return newState;
- });
- };
+ const setLocationPermission = useCallback(
+ (hasLocationPermission: boolean) => {
+ setMapState((prev) => {
+ const newState = { ...prev, hasLocationPermission };
+ localStorage.setItem("mapState", JSON.stringify(newState));
+ return newState;
+ });
+ },
+ []
+ );
- const updateMapState = (center: LngLatLike, zoom: number, path: string) => {
- setMapState((prev) => {
- const newState = {
- ...prev,
- paths: {
- ...prev.paths,
- [path]: { center, zoom },
- },
- };
- localStorage.setItem("mapState", JSON.stringify(newState));
- return newState;
- });
- };
+ const updateMapState = useCallback(
+ (center: LngLatLike, zoom: number, path: string) => {
+ setMapState((prev) => {
+ const newState = {
+ ...prev,
+ paths: {
+ ...prev.paths,
+ [path]: { center, zoom },
+ },
+ };
+ localStorage.setItem("mapState", JSON.stringify(newState));
+ return newState;
+ });
+ },
+ []
+ );
+
+ const startWatching = useCallback(() => {
+ if (!navigator.geolocation || watchIdRef.current !== null) return;
+ watchIdRef.current = navigator.geolocation.watchPosition(
+ (position) => {
+ const { latitude, longitude } = position.coords;
+ setUserLocation([latitude, longitude]);
+ setLocationPermission(true);
+ },
+ (error) => {
+ if (error.code === GeolocationPositionError.PERMISSION_DENIED) {
+ setLocationPermission(false);
+ }
+ },
+ { enableHighAccuracy: false, maximumAge: 30000, timeout: 15000 }
+ );
+ }, [setUserLocation, setLocationPermission]);
+
+ const requestLocation = useCallback(() => {
+ if (typeof window === "undefined" || !("geolocation" in navigator)) return;
+ navigator.geolocation.getCurrentPosition(
+ (pos) => {
+ setUserLocation([pos.coords.latitude, pos.coords.longitude]);
+ setLocationPermission(true);
+ startWatching();
+ },
+ () => {
+ setLocationPermission(false);
+ },
+ { enableHighAccuracy: false, maximumAge: 60000, timeout: 10000 }
+ );
+ }, [setUserLocation, setLocationPermission, startWatching]);
- // Try to get user location on load if permission was granted
+ // On mount: subscribe to permission changes and auto-start watching if already granted
useEffect(() => {
- if (mapState.hasLocationPermission && !mapState.userLocation) {
- if (navigator.geolocation) {
- navigator.geolocation.getCurrentPosition(
- (position) => {
- const { latitude, longitude } = position.coords;
- setUserLocation([latitude, longitude]);
- },
- (error) => {
- console.error("Error getting location:", error);
+ if (typeof window === "undefined" || !("geolocation" in navigator)) return;
+
+ let permissionStatus: PermissionStatus | null = null;
+
+ const onPermChange = () => {
+ if (permissionStatus?.state === "granted") {
+ setLocationPermission(true);
+ startWatching();
+ } else if (permissionStatus?.state === "denied") {
+ setLocationPermission(false);
+ if (watchIdRef.current !== null) {
+ navigator.geolocation.clearWatch(watchIdRef.current);
+ watchIdRef.current = null;
+ }
+ }
+ };
+
+ const init = async () => {
+ try {
+ if (navigator.permissions?.query) {
+ permissionStatus = await navigator.permissions.query({
+ name: "geolocation",
+ });
+ if (permissionStatus.state === "granted") {
+ setLocationPermission(true);
+ startWatching();
+ } else if (permissionStatus.state === "denied") {
setLocationPermission(false);
- },
- {
- enableHighAccuracy: true,
- maximumAge: Infinity,
- timeout: 10000,
}
- );
+ permissionStatus.addEventListener("change", onPermChange);
+ } else if (mapState.hasLocationPermission) {
+ startWatching();
+ }
+ } catch {
+ if (mapState.hasLocationPermission) {
+ startWatching();
+ }
}
- }
- }, [mapState.hasLocationPermission, mapState.userLocation]);
+ };
+
+ init();
+
+ return () => {
+ if (watchIdRef.current !== null) {
+ navigator.geolocation.clearWatch(watchIdRef.current);
+ watchIdRef.current = null;
+ }
+ permissionStatus?.removeEventListener("change", onPermChange);
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
return (
<MapContext.Provider
@@ -107,6 +178,7 @@ export const MapProvider = ({ children }: { children: ReactNode }) => {
setUserLocation,
setLocationPermission,
updateMapState,
+ requestLocation,
}}
>
{children}
diff --git a/src/frontend/app/hooks/useGeolocation.ts b/src/frontend/app/hooks/useGeolocation.ts
new file mode 100644
index 0000000..572a40c
--- /dev/null
+++ b/src/frontend/app/hooks/useGeolocation.ts
@@ -0,0 +1,59 @@
+import { useCallback } from "react";
+import { useMap } from "../contexts/MapContext";
+import type { LngLatLike } from "maplibre-gl";
+
+export interface UseGeolocationResult {
+ userLocation: { latitude: number; longitude: number } | null;
+ hasLocationPermission: boolean;
+ requestLocation: () => void;
+}
+
+function lngLatToCoords(
+ loc: LngLatLike
+): { latitude: number; longitude: number } {
+ if (Array.isArray(loc)) {
+ // Stored as [lat, lng] per codebase convention
+ return { latitude: loc[0], longitude: loc[1] };
+ }
+ if ("lat" in loc) {
+ return {
+ latitude: loc.lat,
+ longitude: "lng" in loc ? (loc as any).lng : (loc as any).lon,
+ };
+ }
+ return { latitude: 0, longitude: 0 };
+}
+
+/**
+ * Provides the current user location from the global MapContext.
+ * Location updates are driven by the MapContext's watchPosition subscription
+ * (started automatically when geolocation permission is granted).
+ *
+ * Call `requestLocation()` to prompt the user for permission and start tracking.
+ */
+export function useGeolocation(): UseGeolocationResult {
+ const { mapState, setUserLocation, setLocationPermission } = useMap();
+
+ const requestLocation = useCallback(() => {
+ if (typeof window === "undefined" || !("geolocation" in navigator)) return;
+ navigator.geolocation.getCurrentPosition(
+ (pos) => {
+ setUserLocation([pos.coords.latitude, pos.coords.longitude]);
+ setLocationPermission(true);
+ },
+ () => {
+ setLocationPermission(false);
+ },
+ { enableHighAccuracy: false, maximumAge: 60000, timeout: 10000 }
+ );
+ }, [setUserLocation, setLocationPermission]);
+
+ const rawLoc = mapState.userLocation;
+ const userLocation = rawLoc ? lngLatToCoords(rawLoc) : null;
+
+ return {
+ userLocation,
+ hasLocationPermission: mapState.hasLocationPermission,
+ requestLocation,
+ };
+}
diff --git a/src/frontend/app/i18n/locales/en-GB.json b/src/frontend/app/i18n/locales/en-GB.json
index 1987d28..17c232f 100644
--- a/src/frontend/app/i18n/locales/en-GB.json
+++ b/src/frontend/app/i18n/locales/en-GB.json
@@ -103,7 +103,9 @@
"popup_title": "Stop",
"lines": "Lines",
"view_all_estimates": "View all estimates",
- "select_nearby_stop": "Select stop"
+ "select_nearby_stop": "Select stop",
+ "route_from_here": "Route from here",
+ "route_to_here": "Route to here"
},
"planner": {
"where_to": "Where do you want to go?",
diff --git a/src/frontend/app/i18n/locales/es-ES.json b/src/frontend/app/i18n/locales/es-ES.json
index 5e65a88..e7d516e 100644
--- a/src/frontend/app/i18n/locales/es-ES.json
+++ b/src/frontend/app/i18n/locales/es-ES.json
@@ -103,7 +103,9 @@
"popup_title": "Parada",
"lines": "Líneas",
"view_all_estimates": "Detalles",
- "select_nearby_stop": "Seleccionar parada"
+ "select_nearby_stop": "Seleccionar parada",
+ "route_from_here": "Ruta desde aquí",
+ "route_to_here": "Ruta hasta aquí"
},
"planner": {
"where_to": "¿A donde quieres ir?",
diff --git a/src/frontend/app/i18n/locales/gl-ES.json b/src/frontend/app/i18n/locales/gl-ES.json
index 2c874d8..baa3998 100644
--- a/src/frontend/app/i18n/locales/gl-ES.json
+++ b/src/frontend/app/i18n/locales/gl-ES.json
@@ -103,7 +103,9 @@
"popup_title": "Parada",
"lines": "Liñas",
"view_all_estimates": "Ver todas as estimacións",
- "select_nearby_stop": "Seleccionar parada"
+ "select_nearby_stop": "Seleccionar parada",
+ "route_from_here": "Ruta desde aquí",
+ "route_to_here": "Ruta ata aquí"
},
"planner": {
"where_to": "Onde queres ir?",
diff --git a/src/frontend/app/routes/home.tsx b/src/frontend/app/routes/home.tsx
index 45d7ddf..e71c788 100644
--- a/src/frontend/app/routes/home.tsx
+++ b/src/frontend/app/routes/home.tsx
@@ -1,8 +1,7 @@
-import { Clock, History, Star } from "lucide-react";
+import { Clock, History, MapPin, Star } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router";
-import { PlannerOverlay } from "~/components/PlannerOverlay";
import { usePageTitle } from "~/contexts/PageTitleContext";
import { usePlanner } from "~/hooks/usePlanner";
import StopItem from "../components/StopItem";
@@ -13,16 +12,12 @@ export default function StopList() {
const { t } = useTranslation();
usePageTitle(t("navbar.stops", "Paradas"));
const navigate = useNavigate();
- const { history, searchRoute, loadRoute } = usePlanner({ autoLoad: false });
+ const { history, loadRoute } = usePlanner({ autoLoad: false });
const [data, setData] = useState<Stop[] | null>(null);
const [loading, setLoading] = useState(true);
const [searchResults, setSearchResults] = useState<Stop[] | null>(null);
const [favouriteStops, setFavouriteStops] = useState<Stop[]>([]);
const [recentStops, setRecentStops] = useState<Stop[]>([]);
- const [userLocation, setUserLocation] = useState<{
- latitude: number;
- longitude: number;
- } | null>(null);
const searchTimeout = useRef<NodeJS.Timeout | null>(null);
const randomPlaceholder = useMemo(
@@ -30,68 +25,6 @@ export default function StopList() {
[t]
);
- const requestUserLocation = useCallback(() => {
- if (typeof window === "undefined" || !("geolocation" in navigator)) {
- return;
- }
-
- navigator.geolocation.getCurrentPosition(
- (position) => {
- setUserLocation({
- latitude: position.coords.latitude,
- longitude: position.coords.longitude,
- });
- },
- (error) => {
- console.warn("Unable to obtain user location", error);
- },
- {
- enableHighAccuracy: false,
- maximumAge: Infinity,
- timeout: 10000,
- }
- );
- }, []);
-
- useEffect(() => {
- if (typeof window === "undefined" || !("geolocation" in navigator)) {
- return;
- }
-
- let permissionStatus: PermissionStatus | null = null;
-
- const handlePermissionChange = () => {
- if (permissionStatus?.state === "granted") {
- requestUserLocation();
- }
- };
-
- const checkPermission = async () => {
- try {
- if (navigator.permissions?.query) {
- permissionStatus = await navigator.permissions.query({
- name: "geolocation",
- });
- if (permissionStatus.state === "granted") {
- requestUserLocation();
- }
- permissionStatus.addEventListener("change", handlePermissionChange);
- } else {
- requestUserLocation();
- }
- } catch (error) {
- console.warn("Geolocation permission check failed", error);
- requestUserLocation();
- }
- };
-
- checkPermission();
-
- return () => {
- permissionStatus?.removeEventListener("change", handlePermissionChange);
- };
- }, [requestUserLocation]);
-
// Load stops from network
const loadStops = useCallback(async () => {
try {
@@ -164,33 +97,16 @@ export default function StopList() {
<div className="flex flex-col gap-4 py-4 pb-8">
{/* Planner Section */}
<div className="w-full px-4">
- <details className="group bg-surface border border-slate-200 dark:border-slate-700 shadow-sm">
- <summary className="list-none cursor-pointer focus:outline-none">
- <div className="flex items-center justify-between p-3 rounded-xl group-open:mb-3 transition-all">
- <div className="flex items-center gap-3">
- <History className="w-5 h-5 text-primary-600 dark:text-primary-400" />
- <span className="font-semibold text-text">
- {t("planner.where_to", "¿A dónde quieres ir?")}
- </span>
- </div>
- <div className="text-muted group-open:rotate-180 transition-transform">
- ↓
- </div>
- </div>
- </summary>
-
- <PlannerOverlay
- inline
- forceExpanded
- cardBackground="bg-transparent"
- userLocation={userLocation}
- autoLoad={false}
- onSearch={(origin, destination, time, arriveBy) => {
- searchRoute(origin, destination, time, arriveBy);
- }}
- onNavigateToPlanner={() => navigate("/planner")}
- />
- </details>
+ <button
+ type="button"
+ onClick={() => navigate("/planner")}
+ className="w-full flex items-center gap-3 p-3 rounded-xl bg-surface border border-slate-200 dark:border-slate-700 shadow-sm hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors text-left"
+ >
+ <MapPin className="w-5 h-5 text-primary-600 dark:text-primary-400 shrink-0" />
+ <span className="font-semibold text-text">
+ {t("planner.where_to", "¿A dónde quieres ir?")}
+ </span>
+ </button>
{history.length > 0 && (
<div className="mt-3 flex flex-col gap-2">
diff --git a/src/frontend/app/routes/map.tsx b/src/frontend/app/routes/map.tsx
index f54f6cf..8149d30 100644
--- a/src/frontend/app/routes/map.tsx
+++ b/src/frontend/app/routes/map.tsx
@@ -1,6 +1,6 @@
-import { Check, MapPin, X } from "lucide-react";
+import { Check, MapPin, Navigation, X } from "lucide-react";
import type { FilterSpecification } from "maplibre-gl";
-import { useMemo, useRef, useState } from "react";
+import { useRef, useState, useMemo } from "react";
import { useTranslation } from "react-i18next";
import {
Layer,
@@ -53,6 +53,81 @@ export default function StopMap() {
const [isConfirming, setIsConfirming] = useState(false);
+ // Context menu state (right-click / long-press)
+ interface ContextMenuState {
+ x: number;
+ y: number;
+ lat: number;
+ lng: number;
+ }
+ const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
+ const [contextMenuLoading, setContextMenuLoading] = useState<
+ "origin" | "destination" | null
+ >(null);
+
+ const handleContextMenu = (e: MapLayerMouseEvent) => {
+ if (pickingMode) return;
+ e.preventDefault?.();
+ setContextMenu({
+ x: e.point.x,
+ y: e.point.y,
+ lat: e.lngLat.lat,
+ lng: e.lngLat.lng,
+ });
+ };
+
+ const closeContextMenu = () => setContextMenu(null);
+
+ const handleRouteFromHere = async () => {
+ if (!contextMenu) return;
+ setContextMenuLoading("origin");
+ try {
+ const result = await reverseGeocode(contextMenu.lat, contextMenu.lng);
+ const place = {
+ name:
+ result?.name ||
+ `${contextMenu.lat.toFixed(5)}, ${contextMenu.lng.toFixed(5)}`,
+ label: result?.label || "Map location",
+ lat: contextMenu.lat,
+ lon: contextMenu.lng,
+ layer: "map-pick",
+ };
+ setOrigin(place);
+ addRecentPlace(place);
+ closeContextMenu();
+ navigate("/planner");
+ } catch {
+ closeContextMenu();
+ } finally {
+ setContextMenuLoading(null);
+ }
+ };
+
+ const handleRouteToHere = async () => {
+ if (!contextMenu) return;
+ setContextMenuLoading("destination");
+ try {
+ const result = await reverseGeocode(contextMenu.lat, contextMenu.lng);
+ const place = {
+ name:
+ result?.name ||
+ `${contextMenu.lat.toFixed(5)}, ${contextMenu.lng.toFixed(5)}`,
+ label: result?.label || "Map location",
+ lat: contextMenu.lat,
+ lon: contextMenu.lng,
+ layer: "map-pick",
+ };
+ setDestination(place);
+ addRecentPlace(place);
+ closeContextMenu();
+ navigate("/planner");
+ } catch {
+ closeContextMenu();
+ } finally {
+ setContextMenuLoading(null);
+ }
+ };
+
const handleConfirmPick = async () => {
if (!mapRef.current || !pickingMode) return;
const center = mapRef.current.getCenter();
@@ -252,9 +327,13 @@ export default function StopMap() {
showGeolocate={true}
showTraffic={pickingMode ? false : undefined}
interactiveLayerIds={["stops", "stops-label"]}
- onClick={onMapClick}
+ onClick={(e) => {
+ closeContextMenu();
+ onMapClick(e);
+ }}
onDragStart={onMapInteraction}
onZoomStart={onMapInteraction}
+ onContextMenu={handleContextMenu}
attributionControl={{ compact: false }}
>
<Source
@@ -440,6 +519,54 @@ export default function StopMap() {
</div>
)}
</AppMap>
+
+ {contextMenu && (
+ <>
+ {/* Dismiss backdrop */}
+ <div
+ className="absolute inset-0 z-30"
+ onClick={closeContextMenu}
+ />
+ {/* Context menu */}
+ <div
+ className="absolute z-40 min-w-[180px] rounded-xl bg-white dark:bg-slate-900 shadow-2xl border border-slate-200 dark:border-slate-700 overflow-hidden"
+ style={{
+ left: Math.min(contextMenu.x, window.innerWidth - 200),
+ top: Math.min(contextMenu.y, window.innerHeight - 120),
+ }}
+ >
+ <button
+ className="w-full flex items-center gap-3 px-4 py-3 text-sm text-left text-slate-800 dark:text-slate-100 hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors disabled:opacity-50"
+ onClick={handleRouteFromHere}
+ disabled={contextMenuLoading !== null}
+ >
+ {contextMenuLoading === "origin" ? (
+ <div className="w-4 h-4 border-2 border-primary-500 border-t-transparent rounded-full animate-spin" />
+ ) : (
+ <Navigation className="w-4 h-4 text-primary-600 dark:text-primary-400 shrink-0" />
+ )}
+ <span className="font-medium">
+ {t("map.route_from_here", "Ruta desde aquí")}
+ </span>
+ </button>
+ <div className="h-px bg-slate-100 dark:bg-slate-800" />
+ <button
+ className="w-full flex items-center gap-3 px-4 py-3 text-sm text-left text-slate-800 dark:text-slate-100 hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors disabled:opacity-50"
+ onClick={handleRouteToHere}
+ disabled={contextMenuLoading !== null}
+ >
+ {contextMenuLoading === "destination" ? (
+ <div className="w-4 h-4 border-2 border-primary-500 border-t-transparent rounded-full animate-spin" />
+ ) : (
+ <MapPin className="w-4 h-4 text-primary-600 dark:text-primary-400 shrink-0" />
+ )}
+ <span className="font-medium">
+ {t("map.route_to_here", "Ruta hasta aquí")}
+ </span>
+ </button>
+ </div>
+ </>
+ )}
</div>
);
}
diff --git a/src/frontend/app/routes/planner.tsx b/src/frontend/app/routes/planner.tsx
index b7ecaf9..ff13225 100644
--- a/src/frontend/app/routes/planner.tsx
+++ b/src/frontend/app/routes/planner.tsx
@@ -13,6 +13,7 @@ import { AppMap } from "~/components/shared/AppMap";
import { APP_CONSTANTS } from "~/config/constants";
import { useBackButton, usePageTitle } from "~/contexts/PageTitleContext";
import { type Itinerary } from "~/data/PlannerApi";
+import { useGeolocation } from "~/hooks/useGeolocation";
import { usePlanner } from "~/hooks/usePlanner";
import "../tailwind-full.css";
@@ -721,6 +722,7 @@ export default function PlannerPage() {
setOrigin,
setDestination,
} = usePlanner();
+ const { userLocation } = useGeolocation();
const [selectedItinerary, setSelectedItinerary] = useState<Itinerary | null>(
null
);
@@ -816,27 +818,16 @@ export default function PlannerPage() {
onClick={() => {
clearRoute();
setDestination(null);
- if (navigator.geolocation) {
- navigator.geolocation.getCurrentPosition(
- async (pos) => {
- const initial = {
- name: t("planner.current_location"),
- label: "GPS",
- lat: pos.coords.latitude,
- lon: pos.coords.longitude,
- layer: "current-location",
- } as any;
- setOrigin(initial);
- },
- () => {
- // If geolocation fails, just keep origin empty
- },
- {
- enableHighAccuracy: true,
- timeout: 10000,
- maximumAge: 60 * 60 * 1000, // 1 hour in milliseconds
- }
- );
+ if (userLocation) {
+ setOrigin({
+ name: t("planner.current_location"),
+ label: "GPS",
+ lat: userLocation.latitude,
+ lon: userLocation.longitude,
+ layer: "current-location",
+ });
+ } else {
+ setOrigin(null);
}
}}
className="text-sm text-red-500"