249 lines
8.4 KiB
TypeScript
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>
|
|
);
|
|
}
|