fifteen/apps/web/app/page.tsx

429 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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: () => <div className="flex-1 bg-gray-200 animate-pulse" /> },
);
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>("cyclist");
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 [pinScoreError, setPinScoreError] = useState(false);
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);
// Reachable POI pins
type ReachablePoi = { category: string; subcategory: string; name: string | null; lat: number; lng: number; scored: boolean };
const [reachablePois, setReachablePois] = useState<ReachablePoi[]>([]);
const [showPois, setShowPois] = useState(true);
// 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 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,
// 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 (
<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}
reachablePois={showPois ? reachablePois : []}
cityBoundary={cityBoundary}
onLocationClick={handleLocationClick}
/>
)}
<MapLegend
overlayMode={overlayMode}
baseOverlay={baseOverlay}
threshold={threshold}
hasPinData={!!pinData}
/>
{pinLocation && !pinData && pinScoreError && (
<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>
<p className="text-sm text-gray-500 text-center py-4">
No score data for this mode yet.<br />
<span className="text-xs text-gray-400">Re-run ingest to compute new modes.</span>
</p>
</div>
)}
{pinLocation && !pinData && !pinScoreError && (
<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}
showPois={showPois}
onTogglePois={() => setShowPois((v) => !v)}
poiCount={reachablePois.length}
onOverlayModeChange={setOverlayMode}
onClose={handlePinClose}
/>
)}
</div>
</div>
<StatsBar stats={stats} activeCategory={activeCategory} />
</div>
);
}