"use client"; import React, { useEffect, useRef, useState } from "react"; import mapboxgl from "mapbox-gl"; import GeocodeInput from './GeocodeInput'; interface Props { mapRef: React.MutableRefObject; profile?: "mapbox/driving" | "mapbox/walking" | "mapbox/cycling"; } // Routing now uses geocoder-only selection inside the sidebar (no manual coordinate parsing) export default function DirectionsSidebar({ mapRef, profile = "mapbox/driving" }: Props) { // Sidebar supports collapse via a hamburger button in the header const [collapsed, setCollapsed] = useState(false); const containerRef = useRef(null); const [originText, setOriginText] = useState(""); const [destText, setDestText] = useState(""); const [originCoord, setOriginCoord] = useState<[number, number] | null>(null); const [destCoord, setDestCoord] = useState<[number, number] | null>(null); const [loading, setLoading] = useState(false); // custom geocoder inputs + suggestions (we implement our own UI instead of the library) const originQueryRef = useRef(""); const destQueryRef = useRef(""); const [originQuery, setOriginQuery] = useState(""); const [destQuery, setDestQuery] = useState(""); const [originSuggestions, setOriginSuggestions] = useState([]); const [destSuggestions, setDestSuggestions] = useState([]); const originTimer = useRef(null); const destTimer = useRef(null); const originInputRef = useRef(null); const destInputRef = useRef(null); const mountedRef = useRef(true); useEffect(() => { return () => { mountedRef.current = false; }; }, []); // We'll implement our own geocoder fetcher and suggestion UI. const fetchSuggestions = async (q: string) => { const token = process.env.NEXT_PUBLIC_MAPBOX_TOKEN || mapboxgl.accessToken || (typeof window !== 'undefined' ? (window as any).NEXT_PUBLIC_MAPBOX_TOKEN : undefined); if (!token) { console.warn('[DirectionsSidebar] Mapbox token missing; suggestions disabled'); return []; } if (!q || q.trim().length === 0) return []; const url = `https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(q)}.json?autocomplete=true&limit=6&types=place,locality,address,region,poi&access_token=${token}`; try { console.debug('[DirectionsSidebar] fetchSuggestions url=', url); const res = await fetch(url); if (!res.ok) return []; const data = await res.json(); const feats = data.features || []; console.debug('[DirectionsSidebar] fetchSuggestions results=', feats.length); return feats; } catch (e) { console.warn('[DirectionsSidebar] fetchSuggestions error', e); return []; } }; // debounce origin query useEffect(() => { if (originTimer.current) window.clearTimeout(originTimer.current); if (!originQuery) { setOriginSuggestions([]); return; } originTimer.current = window.setTimeout(async () => { const features = await fetchSuggestions(originQuery); if (mountedRef.current) setOriginSuggestions(features); }, 250) as unknown as number; return () => { if (originTimer.current) window.clearTimeout(originTimer.current); }; }, [originQuery]); // debounce dest query useEffect(() => { if (destTimer.current) window.clearTimeout(destTimer.current); if (!destQuery) { setDestSuggestions([]); return; } destTimer.current = window.setTimeout(async () => { const features = await fetchSuggestions(destQuery); if (mountedRef.current) setDestSuggestions(features); }, 250) as unknown as number; return () => { if (destTimer.current) window.clearTimeout(destTimer.current); }; }, [destQuery]); // when collapsed toggles, mount or unmount geocoder controls to keep DOM stable useEffect(() => { // if expanded, ensure the geocoder instances are attached to their containers if (!collapsed) { // nothing to mount for the custom inputs — they are regular DOM inputs rendered below } else { // nothing to clear: we are managing suggestions via state } }, [collapsed]); // note: we no longer listen for map-level geocoder results here because // the sidebar now embeds its own two geocoder controls and captures results directly. // helper: remove existing route layers/sources function removeRouteFromMap(map: mapboxgl.Map) { try { if (map.getLayer("directions-line")) map.removeLayer("directions-line"); } catch (e) {} try { if (map.getLayer("directions-points")) map.removeLayer("directions-points"); } catch (e) {} try { if (map.getSource("directions-route")) map.removeSource("directions-route"); } catch (e) {} try { if (map.getSource("directions-points-src")) map.removeSource("directions-points-src"); } catch (e) {} } async function fetchRoute(o: [number, number], d: [number, number]) { const accessToken = process.env.NEXT_PUBLIC_MAPBOX_TOKEN; if (!accessToken) { console.warn("Mapbox token missing (NEXT_PUBLIC_MAPBOX_TOKEN)"); return null; } const coords = `${o[0]},${o[1]};${d[0]},${d[1]}`; const url = `https://api.mapbox.com/directions/v5/${profile}/${coords}?geometries=geojson&overview=full&steps=false&access_token=${accessToken}`; const res = await fetch(url); if (!res.ok) throw new Error(`Directions API error: ${res.status}`); const data = await res.json(); return data; } async function handleGetRoute() { const map = mapRef.current; if (!map) return; const o = originCoord; const d = destCoord; if (!o || !d) { alert('Please select both origin and destination using the location search boxes.'); return; } setLoading(true); try { const data = await fetchRoute(o, d); if (!data || !data.routes || data.routes.length === 0) { alert("No route found"); return; } const route = data.routes[0]; const geo: GeoJSON.Feature = { type: "Feature", properties: {}, geometry: route.geometry }; removeRouteFromMap(map); // add route source and line layer if (!map.getSource("directions-route")) { map.addSource("directions-route", { type: "geojson", data: geo }); } else { (map.getSource("directions-route") as mapboxgl.GeoJSONSource).setData(geo); } if (!map.getLayer("directions-line")) { map.addLayer({ id: "directions-line", type: "line", source: "directions-route", layout: { "line-join": "round", "line-cap": "round" }, paint: { "line-color": "#ff7e5f", "line-width": 6, "line-opacity": 0.95 }, }); } // add origin/dest points const pts: GeoJSON.FeatureCollection = { type: "FeatureCollection", features: [ { type: "Feature", properties: { role: "origin" }, geometry: { type: "Point", coordinates: o } }, { type: "Feature", properties: { role: "destination" }, geometry: { type: "Point", coordinates: d } }, ], }; if (!map.getSource("directions-points-src")) { map.addSource("directions-points-src", { type: "geojson", data: pts }); } else { (map.getSource("directions-points-src") as mapboxgl.GeoJSONSource).setData(pts); } if (!map.getLayer("directions-points")) { map.addLayer({ id: "directions-points", type: "circle", source: "directions-points-src", paint: { "circle-radius": 8, "circle-color": ["case", ["==", ["get", "role"], "origin"], "#2ecc71", "#e74c3c"], "circle-stroke-width": 2, "circle-stroke-color": "#ffffff", }, }); } // zoom to route bounds try { const coords = (route.geometry as any).coordinates as [number, number][]; const b = new mapboxgl.LngLatBounds(coords[0], coords[0]); for (let i = 1; i < coords.length; i++) b.extend(coords[i] as any); map.fitBounds(b as any, { padding: 60 }); } catch (e) {} } catch (e: any) { console.error(e); alert(e?.message || "Route request failed"); } finally { if (mountedRef.current) setLoading(false); } } function handleClear() { const map = mapRef.current; if (map) removeRouteFromMap(map); setOriginCoord(null); setDestCoord(null); setOriginText(""); setDestText(""); // clear suggestions and inputs setOriginSuggestions([]); setDestSuggestions([]); } // re-add layers after style change useEffect(() => { const map = mapRef.current; if (!map) return; function onStyleData() { if (!map) return; // if a route source exists, we need to re-add the layers if (map.getSource("directions-route")) { // re-add line layer if missing if (!map.getLayer("directions-line")) { map.addLayer({ id: "directions-line", type: "line", source: "directions-route", layout: { "line-join": "round", "line-cap": "round" }, paint: { "line-color": "#ff7e5f", "line-width": 6, "line-opacity": 0.95 }, }); } } if (map.getSource("directions-points-src") && !map.getLayer("directions-points")) { map.addLayer({ id: "directions-points", type: "circle", source: "directions-points-src", paint: { "circle-radius": 8, "circle-color": ["case", ["==", ["get", "role"], "origin"], "#2ecc71", "#e74c3c"], "circle-stroke-width": 2, "circle-stroke-color": "#ffffff", }, }); } } map.on("styledata", onStyleData); return () => { map.off("styledata", onStyleData); }; }, [mapRef]); // resize map when sidebar collapses/expands so map fills freed space useEffect(() => { const map = mapRef.current; const el = containerRef.current; if (!map) return; // immediate resize to adapt layout change try { map.resize(); } catch (e) { /* ignore */ } // also listen for transitionend on the sidebar container and trigger resize function onTransition(e: TransitionEvent) { if (!map) return; // only respond to width/height changes (or all) if (e.propertyName === 'width' || e.propertyName === 'height' || e.propertyName === 'all') { try { map.resize(); } catch (err) { /* ignore */ } } } // fallback: also schedule a delayed resize in case transitionend doesn't fire const t = window.setTimeout(() => { if (map) try { map.resize(); } catch (e) { /* ignore */ } }, 300); if (el) el.addEventListener('transitionend', onTransition as any); return () => { clearTimeout(t); if (el) el.removeEventListener('transitionend', onTransition as any); }; }, [collapsed, mapRef]); return (
{/* Toggle */} {/* Content — render only when expanded to avoid any collapsed 'strip' */}
Directions
{ setOriginQuery(v); setOriginText(''); }} onSelect={(f) => { const c = f.center; if (c && c.length === 2) { setOriginCoord([c[0], c[1]]); setOriginText(f.place_name || ''); setOriginQuery(f.place_name || ''); try { const m = mapRef.current; if (m) m.easeTo({ center: c, zoom: 14 }); } catch(e){} } }} />
{originText}
{ setDestQuery(v); setDestText(''); }} onSelect={(f) => { const c = f.center; if (c && c.length === 2) { setDestCoord([c[0], c[1]]); setDestText(f.place_name || ''); setDestQuery(f.place_name || ''); try { const m = mapRef.current; if (m) m.easeTo({ center: c, zoom: 14 }); } catch(e){} } }} />
{destText}
{/* pick-on-map mode removed; sidebar uses geocoder-only inputs */}
); }