379 lines
14 KiB
TypeScript
379 lines
14 KiB
TypeScript
"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: () => <div className="flex-1 bg-gray-200 animate-pulse" /> },
|
||
);
|
||
|
||
/** 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<City[]>([]);
|
||
const [selectedCity, setSelectedCity] = useState<string | null>(null);
|
||
const [profile, setProfile] = useState<ProfileId>("universal");
|
||
const [mode, setMode] = useState<TravelMode>("walking");
|
||
const [threshold, setThreshold] = useState(15);
|
||
const [weights, setWeights] = useState({ ...PROFILES["universal"].categoryWeights });
|
||
const [activeCategory, setActiveCategory] = useState<CategoryId | "composite">("composite");
|
||
const [stats, setStats] = useState<CityStats | null>(null);
|
||
|
||
// Pin / location rating
|
||
const [pinLocation, setPinLocation] = useState<{ lat: number; lng: number } | null>(null);
|
||
const [pinData, setPinData] = useState<LocationScoreData | null>(null);
|
||
const [pinAddress, setPinAddress] = useState<string | undefined>(undefined);
|
||
const [pinEstateValue, setPinEstateValue] = useState<number | null>(null);
|
||
const [pinEstatePercentile, setPinEstatePercentile] = useState<number | null>(null);
|
||
const [pinEstateScorePercentile, setPinEstateScorePercentile] = useState<number | null>(null);
|
||
|
||
// Overlay mode: isochrone (new default) or relative heatmap
|
||
const [overlayMode, setOverlayMode] = useState<OverlayMode>("isochrone");
|
||
const [isochroneData, setIsochroneData] = useState<object | null>(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]);
|
||
|
||
// Fetch isochrone when in isochrone mode with an active pin.
|
||
// Isochrones are only shown in accessibility mode — switching to estate-value
|
||
// or hidden-gem clears the isochrone so those overlays become visible.
|
||
// Switching back to accessibility re-fetches the isochrone automatically.
|
||
useEffect(() => {
|
||
if (!pinLocation || overlayMode !== "isochrone" || 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, overlayMode, mode, threshold, baseOverlay]);
|
||
|
||
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 (
|
||
<div className="flex flex-col h-screen overflow-hidden">
|
||
<header className="bg-white border-b border-gray-200 px-4 py-2.5 flex items-center gap-4 shrink-0 z-10">
|
||
<h1 className="font-bold text-gray-900 hidden sm:block">Transportationer</h1>
|
||
<div className="w-px h-5 bg-gray-200 hidden sm:block" />
|
||
<CitySelector cities={cities} selected={selectedCity} onSelect={setSelectedCity} />
|
||
<div className="ml-auto">
|
||
<a href="/admin" className="text-xs text-gray-400 hover:text-gray-600">Admin</a>
|
||
</div>
|
||
</header>
|
||
|
||
<div className="flex flex-1 overflow-hidden">
|
||
<ControlPanel
|
||
profile={profile}
|
||
mode={mode}
|
||
threshold={threshold}
|
||
weights={weights}
|
||
activeCategory={activeCategory}
|
||
baseOverlay={baseOverlay}
|
||
estateValueAvailable={estateValueAvailable}
|
||
onProfileChange={handleProfileChange}
|
||
onModeChange={setMode}
|
||
onThresholdChange={setThreshold}
|
||
onWeightChange={(cat, w) => setWeights((prev) => ({ ...prev, [cat]: w }))}
|
||
onCategoryChange={setActiveCategory}
|
||
onBaseOverlayChange={setBaseOverlay}
|
||
/>
|
||
|
||
<div className="flex-1 relative">
|
||
{!selectedCity ? (
|
||
<div className="flex items-center justify-center h-full text-gray-400">
|
||
<div className="text-center">
|
||
<p className="text-xl mb-2">Select a city to begin</p>
|
||
<p className="text-sm">
|
||
Or{" "}
|
||
<a href="/admin/cities/new" className="text-brand-600 underline">
|
||
add a new city
|
||
</a>{" "}
|
||
in the admin area.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<MapView
|
||
citySlug={selectedCity}
|
||
cityBbox={selectedCityData?.bbox ?? [-180, -90, 180, 90]}
|
||
profile={profile}
|
||
mode={mode}
|
||
threshold={threshold}
|
||
activeCategory={activeCategory}
|
||
weights={weights}
|
||
pinLocation={pinLocation}
|
||
pinCategoryScores={
|
||
overlayMode === "relative" ? (pinData?.categoryScores ?? null) : null
|
||
}
|
||
isochrones={overlayMode === "isochrone" ? isochroneData : null}
|
||
baseOverlay={baseOverlay}
|
||
onLocationClick={handleLocationClick}
|
||
/>
|
||
)}
|
||
|
||
<MapLegend
|
||
overlayMode={overlayMode}
|
||
baseOverlay={baseOverlay}
|
||
threshold={threshold}
|
||
hasPinData={!!pinData}
|
||
/>
|
||
|
||
{pinLocation && !pinData && (
|
||
<div className="absolute bottom-8 right-4 z-20 bg-white rounded-xl shadow-lg border border-gray-100 w-72 p-4">
|
||
<button
|
||
onClick={handlePinClose}
|
||
className="absolute top-3 right-3 text-gray-300 hover:text-gray-500 text-xl leading-none"
|
||
aria-label="Close"
|
||
>×</button>
|
||
<div className="animate-pulse">
|
||
<div className="flex items-start gap-3 mb-3">
|
||
<div className="w-14 h-14 bg-gray-200 rounded-lg shrink-0" />
|
||
<div className="flex-1 pt-1 space-y-2">
|
||
<div className="h-3 bg-gray-200 rounded w-3/4" />
|
||
<div className="h-2.5 bg-gray-200 rounded w-1/2" />
|
||
</div>
|
||
</div>
|
||
<div className="h-7 bg-gray-100 rounded-lg mb-3.5" />
|
||
<div className="space-y-3">
|
||
{[0, 1, 2, 3, 4].map((i) => (
|
||
<div key={i}>
|
||
<div className="flex justify-between mb-1">
|
||
<div className="h-2.5 bg-gray-200 rounded w-24" />
|
||
<div className="h-2.5 bg-gray-200 rounded w-14" />
|
||
</div>
|
||
<div className="h-1.5 bg-gray-100 rounded-full" />
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{pinData && (
|
||
<LocationScorePanel
|
||
data={pinData}
|
||
weights={weights}
|
||
address={pinAddress}
|
||
estateValue={pinEstateValue}
|
||
estatePercentile={pinEstatePercentile}
|
||
estateScorePercentile={pinEstateScorePercentile}
|
||
overlayMode={overlayMode}
|
||
isochroneLoading={isochroneLoading}
|
||
onOverlayModeChange={setOverlayMode}
|
||
onClose={handlePinClose}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<StatsBar stats={stats} activeCategory={activeCategory} />
|
||
</div>
|
||
);
|
||
}
|