"use client"; import { useState, useEffect } from "react"; import dynamic from "next/dynamic"; import type { City, CityStats, CategoryId, TravelMode, ProfileId, } from "@transportationer/shared"; import { PROFILES } from "@transportationer/shared"; import { ControlPanel } from "@/components/control-panel"; import { StatsBar } from "@/components/stats-bar"; import { CitySelector } from "@/components/city-selector"; import { LocationScorePanel, type LocationScoreData, type OverlayMode, } from "@/components/location-score-panel"; import { MapLegend } from "@/components/map-legend"; const MapView = dynamic( () => import("@/components/map-view").then((m) => m.MapView), { ssr: false, loading: () =>
}, ); /** Compute 3 evenly-spaced contour values up to the threshold (deduped, min 1). */ function isochroneContours(threshold: number): number[] { const raw = [ Math.max(1, Math.round(threshold / 3)), Math.max(2, Math.round((threshold * 2) / 3)), threshold, ]; return [...new Set(raw)]; } export default function HomePage() { const [cities, setCities] = useState([]); const [selectedCity, setSelectedCity] = useState(null); const [profile, setProfile] = useState("universal"); const [mode, setMode] = useState("walking"); const [threshold, setThreshold] = useState(15); const [weights, setWeights] = useState({ ...PROFILES["universal"].categoryWeights }); const [activeCategory, setActiveCategory] = useState("composite"); const [stats, setStats] = useState(null); // Pin / location rating const [pinLocation, setPinLocation] = useState<{ lat: number; lng: number } | null>(null); const [pinData, setPinData] = useState(null); const [pinAddress, setPinAddress] = useState(undefined); // Overlay mode: isochrone (new default) or relative heatmap const [overlayMode, setOverlayMode] = useState("isochrone"); const [isochroneData, setIsochroneData] = useState(null); const [isochroneLoading, setIsochroneLoading] = useState(false); // Load city list useEffect(() => { fetch("/api/cities") .then((r) => r.json()) .then((data: City[]) => { setCities(data); const firstReady = data.find((c) => c.status === "ready"); if (firstReady) setSelectedCity(firstReady.slug); }) .catch(console.error); }, []); // Load stats when city/mode/threshold change useEffect(() => { if (!selectedCity) return; const params = new URLSearchParams({ city: selectedCity, mode, threshold: String(threshold), profile }); fetch(`/api/stats?${params}`) .then((r) => r.json()) .then(setStats) .catch(console.error); }, [selectedCity, mode, threshold, profile]); // Fetch location score + reverse geocode when pin changes useEffect(() => { if (!pinLocation || !selectedCity) return; const params = new URLSearchParams({ lat: String(pinLocation.lat), lng: String(pinLocation.lng), city: selectedCity, mode, threshold: String(threshold), profile, }); Promise.all([ fetch(`/api/location-score?${params}`).then((r) => r.json()), fetch( `https://nominatim.openstreetmap.org/reverse?lat=${pinLocation.lat}&lon=${pinLocation.lng}&format=json`, { headers: { "Accept-Language": "en" } }, ) .then((r) => r.json()) .then((d) => d.display_name as string) .catch(() => undefined), ]) .then(([scoreData, address]) => { if (scoreData?.error) return; setPinData(scoreData as LocationScoreData); setPinAddress(address); }) .catch(console.error); }, [pinLocation, selectedCity, mode, threshold, profile]); // Fetch isochrone when in isochrone mode with an active pin useEffect(() => { if (!pinLocation || overlayMode !== "isochrone") { setIsochroneData(null); return; } setIsochroneLoading(true); setIsochroneData(null); fetch("/api/isochrones", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ lng: pinLocation.lng, lat: pinLocation.lat, travelMode: mode, contourMinutes: isochroneContours(threshold), }), }) .then((r) => r.json()) .then((data) => { // Valhalla may return 200 OK with error_code (not error) for unroutable locations. // Only accept valid FeatureCollections. if (!data.error && !data.error_code && Array.isArray(data.features)) { setIsochroneData(data); } }) .catch(console.error) .finally(() => setIsochroneLoading(false)); }, [pinLocation, overlayMode, mode, threshold]); function handleProfileChange(newProfile: ProfileId) { setProfile(newProfile); setWeights({ ...PROFILES[newProfile].categoryWeights }); // Clear pin when profile changes so scores are re-fetched with new profile setPinLocation(null); setPinData(null); setPinAddress(undefined); setIsochroneData(null); } function handleLocationClick(lat: number, lng: number) { setPinLocation({ lat, lng }); setPinData(null); setPinAddress(undefined); setIsochroneData(null); } function handlePinClose() { setPinLocation(null); setPinData(null); setPinAddress(undefined); setIsochroneData(null); } const selectedCityData = cities.find((c) => c.slug === selectedCity); return (

Transportationer

setWeights((prev) => ({ ...prev, [cat]: w }))} onCategoryChange={setActiveCategory} />
{!selectedCity ? (

Select a city to begin

Or{" "} add a new city {" "} in the admin area.

) : ( )} {pinData && ( )}
); }