fifteen/apps/web/app/page.tsx

379 lines
14 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";
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>
);
}