"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"; import { isochroneContours } from "@/lib/isochrone"; const MapView = dynamic( () => import("@/components/map-view").then((m) => m.MapView), { ssr: false, loading: () =>
}, ); export default function HomePage() { const [cities, setCities] = useState([]); const [selectedCity, setSelectedCity] = useState(null); const [profile, setProfile] = useState("universal"); const [mode, setMode] = useState("cyclist"); 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 [pinScoreError, setPinScoreError] = useState(false); const [pinAddress, setPinAddress] = useState(undefined); const [pinEstateValue, setPinEstateValue] = useState(null); const [pinEstatePercentile, setPinEstatePercentile] = useState(null); const [pinEstateScorePercentile, setPinEstateScorePercentile] = useState(null); // Reachable POI pins type ReachablePoi = { category: string; subcategory: string; name: string | null; lat: number; lng: number; scored: boolean }; const [reachablePois, setReachablePois] = useState([]); const [showPois, setShowPois] = useState(true); // Overlay mode: isochrone (new default) or relative heatmap const [overlayMode, setOverlayMode] = useState("isochrone"); const [isochroneData, setIsochroneData] = useState(null); const [isochroneLoading, setIsochroneLoading] = useState(false); // Base overlay: which layer to show when no pin is active const [baseOverlay, setBaseOverlay] = useState<"accessibility" | "estate-value" | "hidden-gem">("accessibility"); // Derived city data — used in effects below so must be declared before them const selectedCityData = cities.find((c) => c.slug === selectedCity); const cityBbox = selectedCityData?.bbox; const cityBoundary = selectedCityData?.boundary ?? null; const estateValueAvailable = cityBbox != null && cityBbox[0] < 11.779 && cityBbox[2] > 6.526 && cityBbox[1] < 54.033 && cityBbox[3] > 51.197; // Reset base overlay when city changes (availability depends on city) useEffect(() => { setBaseOverlay("accessibility"); }, [selectedCity]); // 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) { setPinData(null); setPinScoreError(false); setPinAddress(undefined); return; } let cancelled = false; setPinScoreError(false); 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 (cancelled) return; if (scoreData?.error) { // No grid data for this mode — keep the pin (isochrone may still show) but // display an error state instead of an infinite loading skeleton. setPinScoreError(true); return; } setPinData(scoreData as LocationScoreData); setPinAddress(address); }) .catch(() => { if (!cancelled) setPinScoreError(true); }); return () => { cancelled = true; }; }, [pinLocation, selectedCity, mode, threshold, profile]); // Fetch estate value + percentile ratings for the clicked location useEffect(() => { if (!pinLocation || !estateValueAvailable || !selectedCity) { setPinEstateValue(null); setPinEstatePercentile(null); setPinEstateScorePercentile(null); return; } const { lat, lng } = pinLocation; let cancelled = false; const params = new URLSearchParams({ lat: String(lat), lng: String(lng), city: selectedCity, mode, threshold: String(threshold), profile, }); fetch(`/api/estate-value?${params}`) .then((r) => r.json()) .then((geojson) => { if (cancelled) return; const props = geojson?.features?.[0]?.properties; setPinEstateValue(typeof props?.value === "number" ? props.value : null); setPinEstatePercentile(typeof props?.percentileRank === "number" ? props.percentileRank : null); setPinEstateScorePercentile(typeof props?.scorePercentileRank === "number" ? props.scorePercentileRank : null); }) .catch(() => { if (!cancelled) { setPinEstateValue(null); setPinEstatePercentile(null); setPinEstateScorePercentile(null); } }); return () => { cancelled = true; }; }, [pinLocation, estateValueAvailable, selectedCity, mode, threshold, profile]); // Fetch reachable POIs once isochroneData is set — guarantees the isochrone // cache is warm so the GET route finds the entry with an exact contours match. useEffect(() => { if (!pinLocation || !selectedCity || !isochroneData) { setReachablePois([]); return; } let cancelled = false; const params = new URLSearchParams({ city: selectedCity, lat: String(pinLocation.lat), lng: String(pinLocation.lng), mode, threshold: String(threshold), }); fetch(`/api/reachable-pois?${params}`) .then((r) => r.json()) .then((data) => { if (!cancelled) setReachablePois(data.pois ?? []); }) .catch(() => { if (!cancelled) setReachablePois([]); }); return () => { cancelled = true; }; }, [pinLocation, selectedCity, mode, threshold, isochroneData]); // Pre-fetch the isochrone whenever a pin is placed (in accessibility mode). // overlayMode is intentionally NOT a dep — fetching is decoupled from display. // This means: // - Switching to "relative" doesn't discard the data. // - Switching back to "isochrone" shows the isochrone instantly (no re-fetch). // Isochrone data is cleared when switching to a non-accessibility overlay // (estate-value / hidden-gem), since those overlays replace the isochrone slot. useEffect(() => { if (!pinLocation || baseOverlay !== "accessibility") { setIsochroneData(null); return; } let cancelled = false; setIsochroneLoading(true); setIsochroneData(null); fetch("/api/isochrones", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ lng: pinLocation.lng, lat: pinLocation.lat, city: selectedCity, // Synthetic modes map to a single Valhalla costing for the isochrone display. // "cyclist" uses cycling (largest catchment); "transit_walk" uses transit. travelMode: mode === "cyclist" ? "cycling" : mode === "cycling_walk" ? "cycling" : mode === "transit_walk" ? "transit" : mode, contourMinutes: isochroneContours(threshold), }), }) .then((r) => r.json()) .then((data) => { if (cancelled) return; // 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(() => {}) .finally(() => { if (!cancelled) setIsochroneLoading(false); }); return () => { cancelled = true; setIsochroneLoading(false); }; }, [pinLocation, mode, threshold, baseOverlay]); // eslint-disable-line react-hooks/exhaustive-deps 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); setPinScoreError(false); setPinAddress(undefined); setPinEstateValue(null); setPinEstatePercentile(null); setPinEstateScorePercentile(null); setIsochroneData(null); } function handleLocationClick(lat: number, lng: number, estateValue: number | null) { setPinLocation({ lat, lng }); setPinData(null); setPinScoreError(false); setPinAddress(undefined); setPinEstateValue(estateValue); setPinEstatePercentile(null); setPinEstateScorePercentile(null); setIsochroneData(null); } function handlePinClose() { setPinLocation(null); setPinData(null); setPinScoreError(false); setPinAddress(undefined); setPinEstateValue(null); setPinEstatePercentile(null); setPinEstateScorePercentile(null); setIsochroneData(null); setReachablePois([]); } return (

Transportationer

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

Select a city to begin

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

) : ( )} {pinLocation && !pinData && pinScoreError && (

No score data for this mode yet.
Re-run ingest to compute new modes.

)} {pinLocation && !pinData && !pinScoreError && (
{[0, 1, 2, 3, 4].map((i) => (
))}
)} {pinData && ( setShowPois((v) => !v)} poiCount={reachablePois.length} onOverlayModeChange={setOverlayMode} onClose={handlePinClose} /> )}
); }