fifteen/apps/web/app/page.tsx
2026-03-01 21:58:53 +01:00

249 lines
8.4 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);
// 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);
// 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 (
<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}
onProfileChange={handleProfileChange}
onModeChange={setMode}
onThresholdChange={setThreshold}
onWeightChange={(cat, w) => setWeights((prev) => ({ ...prev, [cat]: w }))}
onCategoryChange={setActiveCategory}
/>
<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}
onLocationClick={handleLocationClick}
/>
)}
<MapLegend
overlayMode={overlayMode}
threshold={threshold}
hasPinData={!!pinData}
/>
{pinData && (
<LocationScorePanel
data={pinData}
weights={weights}
address={pinAddress}
overlayMode={overlayMode}
isochroneLoading={isochroneLoading}
onOverlayModeChange={setOverlayMode}
onClose={handlePinClose}
/>
)}
</div>
</div>
<StatsBar stats={stats} activeCategory={activeCategory} />
</div>
);
}