Frontend Changes

This commit is contained in:
2025-09-27 12:59:03 -04:00
parent e4a8638f28
commit 4571f8f9c9
8 changed files with 536 additions and 12 deletions

View File

@@ -0,0 +1,312 @@
"use client";
import React, { useEffect, useRef, useState } from "react";
import mapboxgl from "mapbox-gl";
interface Props {
mapRef: React.MutableRefObject<mapboxgl.Map | null>;
profile?: "mapbox/driving" | "mapbox/walking" | "mapbox/cycling";
}
function parseLngLat(value: string): [number, number] | null {
// Accept formats like: "lng,lat" or "lat,lng" if clearly parseable
if (!value) return null;
const parts = value.split(",").map((s) => s.trim());
if (parts.length !== 2) return null;
const a = Number(parts[0]);
const b = Number(parts[1]);
if (Number.isFinite(a) && Number.isFinite(b)) {
// Heuristic: if abs(a) > 90 then assume it's lng,lat
if (Math.abs(a) > 90) return [a, b];
if (Math.abs(b) > 90) return [b, a];
// otherwise assume input is lng,lat
return [a, b];
}
return null;
}
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<HTMLDivElement | null>(null);
const [originText, setOriginText] = useState<string>("");
const [destText, setDestText] = useState<string>("");
const [originCoord, setOriginCoord] = useState<[number, number] | null>(null);
const [destCoord, setDestCoord] = useState<[number, number] | null>(null);
const [loading, setLoading] = useState(false);
const [pickMode, setPickMode] = useState<"origin" | "dest" | null>(null);
const mountedRef = useRef(true);
useEffect(() => {
return () => { mountedRef.current = false; };
}, []);
useEffect(() => {
const map = mapRef.current;
if (!map) return;
function onMapClick(e: mapboxgl.MapMouseEvent) {
if (!pickMode) return;
const lngLat: [number, number] = [e.lngLat.lng, e.lngLat.lat];
if (pickMode === "origin") {
setOriginCoord(lngLat);
setOriginText(`${lngLat[0].toFixed(5)}, ${lngLat[1].toFixed(5)}`);
} else {
setDestCoord(lngLat);
setDestText(`${lngLat[0].toFixed(5)}, ${lngLat[1].toFixed(5)}`);
}
setPickMode(null);
}
map.on("click", onMapClick as any);
return () => { if (map) map.off("click", onMapClick as any); };
}, [mapRef, pickMode]);
// 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;
let o = originCoord;
let d = destCoord;
// if coords not set but text parsable
if (!o) o = parseLngLat(originText);
if (!d) d = parseLngLat(destText);
if (!o || !d) {
alert("Please provide origin and destination coordinates or pick them on the map (click 'Pick on map').\nFormat: lng,lat");
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<GeoJSON.Geometry> = { 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<GeoJSON.Point> = {
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("");
}
// 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 (
<div
ref={containerRef}
role="region"
aria-label="Directions sidebar"
className={`flex flex-col transition-all duration-200 ease-in-out z-40 ${collapsed ? 'w-11 h-11 self-start m-3 rounded-full' : 'w-[340px] h-full bg-[#111214] rounded-tr-lg rounded-br-lg'}`}
>
{/* Toggle */}
<button
aria-label={collapsed ? 'Expand directions' : 'Collapse directions'}
onClick={() => setCollapsed((s) => !s)}
title={collapsed ? 'Expand directions' : 'Minimize directions'}
className={collapsed
? 'w-full h-full rounded-full bg-white text-black/85 flex items-center justify-center shadow-md border border-black/10'
: 'absolute top-3 right-3 w-9 h-9 rounded-md bg-white/5 text-white border border-black/10 flex items-center justify-center hover:bg-white/10'
}
>
<span aria-hidden="true" className="text-lg leading-none"></span>
</button>
{/* Content (hidden when collapsed) */}
<div className={`${collapsed ? 'hidden' : 'flex'} flex-col flex-1 p-4 overflow-auto`} aria-hidden={collapsed}>
<div className="flex items-center justify-between mb-3 sticky top-2 z-10">
<strong className="text-sm">Directions</strong>
</div>
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2 min-w-0">
<label className="text-sm w-20 flex-shrink-0">Origin</label>
<input
className="flex-1 min-w-0 px-3 py-2 rounded-md border border-black/10 bg-transparent text-sm"
value={originText}
onChange={(e) => setOriginText(e.target.value)}
placeholder="lng,lat"
/>
<button className="ml-2 px-3 py-1 rounded-md bg-white/5 text-sm flex-shrink-0" onClick={() => setPickMode('origin')}>Pick</button>
</div>
<div className="flex items-center gap-2 min-w-0">
<label className="text-sm w-20 flex-shrink-0">Destination</label>
<input
className="flex-1 min-w-0 px-3 py-2 rounded-md border border-black/10 bg-transparent text-sm"
value={destText}
onChange={(e) => setDestText(e.target.value)}
placeholder="lng,lat"
/>
<button className="ml-2 px-3 py-1 rounded-md bg-white/5 text-sm flex-shrink-0" onClick={() => setPickMode('dest')}>Pick</button>
</div>
<div className="flex gap-2 mt-2">
<button onClick={handleGetRoute} disabled={loading} className="flex-1 px-4 py-2 rounded-lg bg-gradient-to-r from-[#ff7e5f] to-[#ffb199] text-white shadow-md">{loading ? 'Routing…' : 'Get Route'}</button>
<button onClick={handleClear} className="px-4 py-2 rounded-lg border border-black/10 bg-transparent text-sm">Clear</button>
</div>
{pickMode && <div className="text-sm">Click on the map to set {pickMode}.</div>}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,41 @@
"use client";
import React, { useEffect } from 'react';
import mapboxgl from 'mapbox-gl';
interface Props {
mapRef: React.MutableRefObject<mapboxgl.Map | null>;
position?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
profile?: 'mapbox/driving' | 'mapbox/walking' | 'mapbox/cycling';
unit?: 'metric' | 'imperial';
}
export default function MapDirectionsControl({ mapRef, position = 'top-left', profile = 'mapbox/driving', unit = 'metric' }: Props) {
useEffect(() => {
let directionsControl: any = null;
let cssEl: HTMLLinkElement | null = null;
(async () => {
if (!mapRef.current) return;
// dynamic import so the package doesn't run during SSR
// import the browser UMD build from the package dist to avoid server-only fs usage
const MapboxDirections = (await import('@mapbox/mapbox-gl-directions/dist/mapbox-gl-directions.js')).default || (await import('@mapbox/mapbox-gl-directions/dist/mapbox-gl-directions.js'));
// append plugin css
cssEl = document.createElement('link');
cssEl.rel = 'stylesheet';
cssEl.href = 'https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-directions/v4.1.0/mapbox-gl-directions.css';
document.head.appendChild(cssEl);
directionsControl = new MapboxDirections({ accessToken: process.env.NEXT_PUBLIC_MAPBOX_TOKEN, unit, profile });
mapRef.current.addControl(directionsControl, position);
})();
return () => {
try {
if (mapRef.current && directionsControl) mapRef.current.removeControl(directionsControl);
} catch (e) {}
if (cssEl && cssEl.parentNode) cssEl.parentNode.removeChild(cssEl);
};
}, [mapRef, position, profile, unit]);
return null;
}

View File

@@ -210,8 +210,9 @@ export default function MapView({ mapStyleChoice, heatRadius, heatIntensity, hea
}, [heatRadius, heatIntensity, heatVisible, pointsVisible]);
return (
<div ref={containerRef} style={{ position: 'absolute', inset: 0 }}>
<div ref={mapContainerRef} style={{ width: size.width || '100%', height: size.height || '100%' }} />
// container should fill its parent so parent can control sizing (flex)
<div ref={containerRef} style={{ position: 'relative', width: '100%', height: '100%' }}>
<div ref={mapContainerRef} style={{ width: '100%', height: '100%' }} />
</div>
);
}

View File

@@ -34,8 +34,8 @@ body {
/* Map control theming that follows Skeleton/Tailwind color variables */
.map-control {
position: absolute;
top: 12px;
left: 12px;
bottom: 50px;
right: 12px;
z-index: 2;
background: rgba(255,255,255,0.04);
color: var(--foreground);
@@ -191,3 +191,6 @@ body {
.mapbox-popup-inner button[aria-label="Close popup"] { border-radius: 6px; padding: 6px; background: rgba(0,0,0,0.04); }
.mapbox-popup-inner button[aria-label="Close popup"]:focus { outline: 2px solid rgba(0,0,0,0.12); }
.mapbox-popup-inner button[aria-label="Close popup"] { cursor: pointer; }
/* Directions sidebar (left-side, full-height, collapsible) */
/* Directions sidebar styling is now handled via Tailwind classes in the component. Legacy CSS removed. */

View File

@@ -6,6 +6,7 @@ import ControlsPanel from './components/ControlsPanel';
import PopupOverlay from './components/PopupOverlay';
import Legend from './components/Legend';
import MapNavigationControl from './components/MapNavigationControl';
import DirectionsSidebar from './components/DirectionsSidebar';
export default function Home() {
const mapRef = useRef<any>(null);
@@ -21,8 +22,13 @@ export default function Home() {
const [popupVisible, setPopupVisible] = useState(false);
return (
<div style={{ position: 'absolute', inset: 0 }}>
<ControlsPanel
<div style={{ position: 'absolute', inset: 0, display: 'flex', flexDirection: 'row' }}>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<DirectionsSidebar mapRef={mapRef} profile="mapbox/driving" />
</div>
<div style={{ flex: 1, position: 'relative', minWidth: 0 }}>
<ControlsPanel
panelOpen={panelOpen}
onTogglePanel={(next) => { setPanelOpen(next); try { window.localStorage.setItem('map_panel_open', next ? '1' : '0'); } catch (e) {} }}
mapStyleChoice={mapStyleChoice}
@@ -37,7 +43,7 @@ export default function Home() {
onChangeIntensity={(v) => setHeatIntensity(v)}
/>
<MapView
<MapView
mapStyleChoice={mapStyleChoice}
heatRadius={heatRadius}
heatIntensity={heatIntensity}
@@ -46,12 +52,12 @@ export default function Home() {
onMapReady={(m) => { mapRef.current = m; }}
onPopupCreate={(p) => { setPopupVisible(false); setPopup(p); requestAnimationFrame(() => setPopupVisible(true)); }}
/>
{/* Native Mapbox navigation control (zoom + compass) */}
<MapNavigationControl mapRef={mapRef} position="top-right" />
{/* Native Mapbox navigation control (zoom + compass) */}
<MapNavigationControl mapRef={mapRef} position="top-right" />
<Legend />
<PopupOverlay popup={popup} popupVisible={popupVisible} mapRef={mapRef} onClose={() => { setPopupVisible(false); setTimeout(() => setPopup(null), 220); }} />
<Legend />
<PopupOverlay popup={popup} popupVisible={popupVisible} mapRef={mapRef} onClose={() => { setPopupVisible(false); setTimeout(() => setPopup(null), 220); }} />
</div>
</div>
);
}

View File

@@ -0,0 +1,9 @@
declare module '@mapbox/mapbox-gl-directions' {
const MapboxDirections: any;
export default MapboxDirections;
}
declare module '@mapbox/mapbox-gl-directions/dist/mapbox-gl-directions.js' {
const MapboxDirections: any;
export default MapboxDirections;
}