"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); const [pinEstateValue, setPinEstateValue] = useState(null); const [pinEstatePercentile, setPinEstatePercentile] = useState(null); const [pinEstateScorePercentile, setPinEstateScorePercentile] = useState(null); // 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 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); setPinAddress(undefined); return; } let cancelled = 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 location — clear the pin so the skeleton doesn't persist. setPinLocation(null); return; } setPinData(scoreData as LocationScoreData); setPinAddress(address); }) .catch(() => { if (!cancelled) setPinLocation(null); }); 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]); // 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, travelMode: 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); setPinAddress(undefined); setPinEstateValue(null); setPinEstatePercentile(null); setPinEstateScorePercentile(null); setIsochroneData(null); } function handleLocationClick(lat: number, lng: number, estateValue: number | null) { setPinLocation({ lat, lng }); setPinData(null); setPinAddress(undefined); setPinEstateValue(estateValue); setPinEstatePercentile(null); setPinEstateScorePercentile(null); setIsochroneData(null); } function handlePinClose() { setPinLocation(null); setPinData(null); setPinAddress(undefined); setPinEstateValue(null); setPinEstatePercentile(null); setPinEstateScorePercentile(null); setIsochroneData(null); } 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 && (
{[0, 1, 2, 3, 4].map((i) => (
))}
)} {pinData && ( )}
); }