fifteen/apps/web/components/location-score-panel.tsx

306 lines
11 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 } from "react";
import type { CategoryId } from "@transportationer/shared";
import { CATEGORIES } from "@transportationer/shared";
type Weights = Record<CategoryId, number>;
export type OverlayMode = "isochrone" | "relative";
export interface SubcategoryDetail {
subcategory: string;
name: string | null;
distanceM: number | null;
travelTimeS: number | null;
}
export interface LocationScoreData {
lat: number;
lng: number;
categoryScores: Record<CategoryId, number>;
distancesM: Partial<Record<CategoryId, number>>;
travelTimesS: Partial<Record<CategoryId, number>>;
subcategoryDetails?: Partial<Record<CategoryId, SubcategoryDetail[]>>;
}
const SUBCATEGORY_LABELS: Record<string, string> = {
// service_trade
supermarket: "Supermarket",
pharmacy: "Pharmacy",
convenience: "Convenience store",
restaurant: "Restaurant",
cafe: "Café",
bank: "Bank",
atm: "ATM",
market: "Market",
laundry: "Laundry",
post_office: "Post office",
// transport
train_station: "Train station",
metro: "Metro",
tram_stop: "Tram stop",
bus_stop: "Bus stop",
stop: "Transit stop",
ferry: "Ferry",
bike_share: "Bike share",
car_share: "Car share",
// work_school
school: "School",
driving_school: "Driving school",
kindergarten: "Kindergarten",
university: "University",
coworking: "Coworking",
office: "Office",
// culture_community
hospital: "Hospital",
clinic: "Clinic",
library: "Library",
community_center: "Community center",
social_services: "Social services",
theatre: "Theatre",
place_of_worship: "Place of worship",
government: "Government",
museum: "Museum",
// recreation
park: "Park",
playground: "Playground",
sports_facility: "Sports facility",
gym: "Gym",
green_space: "Green space",
swimming_pool: "Swimming pool",
};
export function compositeScore(
scores: Record<CategoryId, number>,
weights: Weights,
): number {
const entries = Object.entries(weights) as [CategoryId, number][];
const total = entries.reduce((s, [, w]) => s + w, 0);
if (total === 0) return 0;
return entries.reduce((s, [cat, w]) => s + (scores[cat] ?? 0) * w, 0) / total;
}
function grade(score: number): string {
if (score >= 0.8) return "A";
if (score >= 0.65) return "B";
if (score >= 0.5) return "C";
if (score >= 0.35) return "D";
return "F";
}
function gradeColor(g: string): string {
const map: Record<string, string> = {
A: "text-green-600",
B: "text-green-500",
C: "text-yellow-500",
D: "text-orange-500",
F: "text-red-600",
};
return map[g] ?? "text-gray-600";
}
function formatDist(m: number): string {
return m >= 1000 ? `${(m / 1000).toFixed(1)} km` : `${Math.round(m)} m`;
}
function formatTime(s: number): string {
const min = Math.round(s / 60);
return min < 1 ? "<1 min" : `${min} min`;
}
export function LocationScorePanel({
data,
weights,
address,
estateValue,
estatePercentile,
estateScorePercentile,
overlayMode,
isochroneLoading,
showPois,
onTogglePois,
poiCount,
onOverlayModeChange,
onClose,
}: {
data: LocationScoreData;
weights: Weights;
address?: string;
estateValue?: number | null;
/** % of zones within 5 km with a lower value (0100). */
estatePercentile?: number | null;
/** % of zones with similar accessibility score that have a lower value (0100). */
estateScorePercentile?: number | null;
overlayMode: OverlayMode;
isochroneLoading: boolean;
showPois: boolean;
onTogglePois: () => void;
poiCount: number;
onOverlayModeChange: (mode: OverlayMode) => void;
onClose: () => void;
}) {
const [expandedCategory, setExpandedCategory] = useState<CategoryId | null>(null);
const composite = compositeScore(data.categoryScores, weights);
const g = grade(composite);
return (
<div className="absolute bottom-8 right-4 z-20 bg-white rounded-xl shadow-lg border border-gray-100 w-72 p-4">
{/* Header: grade + address + close */}
<div className="flex items-start justify-between mb-3">
<div>
<div className={`text-5xl font-bold leading-none ${gradeColor(g)}`}>{g}</div>
<div className="text-xs text-gray-400 mt-1">{Math.round(composite * 100)} / 100</div>
{estateValue != null && (
<div className="mt-1.5">
<div className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-amber-50 border border-amber-200">
<span className="text-amber-700 font-semibold text-xs">{estateValue.toLocaleString("de-DE")} /m²</span>
<span className="text-amber-400 text-[10px]">land value</span>
</div>
{(estatePercentile != null || estateScorePercentile != null) && (
<div className="mt-1 space-y-0.5 pl-0.5">
{estatePercentile != null && (
<div className="text-[10px] text-amber-600">
Pricier than {estatePercentile}% of zones · 5 km radius
</div>
)}
{estateScorePercentile != null && (
<div className="text-[10px] text-amber-500">
Pricier than {estateScorePercentile}% · similar accessibility
</div>
)}
</div>
)}
</div>
)}
{address && (
<div className="text-xs text-gray-400 mt-1 truncate max-w-[200px]" title={address}>
{address}
</div>
)}
</div>
<button
onClick={onClose}
className="text-gray-300 hover:text-gray-500 text-xl leading-none mt-0.5"
aria-label="Close"
>
×
</button>
</div>
{/* Overlay mode toggle */}
<div className="flex rounded-lg border border-gray-200 overflow-hidden text-xs mb-3.5">
<button
className={`flex-1 py-1.5 transition-colors ${
overlayMode === "isochrone"
? "bg-blue-600 text-white font-medium"
: "text-gray-500 hover:bg-gray-50"
}`}
onClick={() => onOverlayModeChange("isochrone")}
>
{isochroneLoading && overlayMode === "isochrone" ? "Loading…" : "Isochrone"}
</button>
<button
className={`flex-1 py-1.5 transition-colors ${
overlayMode === "relative"
? "bg-blue-600 text-white font-medium"
: "text-gray-500 hover:bg-gray-50"
}`}
onClick={() => onOverlayModeChange("relative")}
>
Relative
</button>
</div>
{/* POI pin toggle */}
{poiCount > 0 && (
<div className="mb-3">
<button
onClick={onTogglePois}
className={`w-full py-1.5 rounded-lg text-xs font-medium transition-colors ${
showPois
? "bg-blue-100 text-blue-700 hover:bg-blue-200"
: "bg-gray-100 text-gray-500 hover:bg-gray-200"
}`}
>
{showPois ? `Hide ${poiCount} POIs` : `Show ${poiCount} POIs`}
</button>
</div>
)}
{/* Per-category score bars */}
<div className="space-y-2.5">
{CATEGORIES.map((cat) => {
const score = data.categoryScores[cat.id] ?? 0;
const barColor =
score >= 0.65 ? "#22c55e" : score >= 0.4 ? "#eab308" : "#ef4444";
const subcats = data.subcategoryDetails?.[cat.id];
// Use the fastest subcategory entry as the category headline — this is
// consistent with what the user sees in the expanded view and avoids
// the straight-line-nearest POI having an unexpectedly long routed time.
const bestSubcat = subcats?.reduce<SubcategoryDetail | null>((best, d) => {
if (d.travelTimeS == null) return best;
if (best == null || d.travelTimeS < (best.travelTimeS ?? Infinity)) return d;
return best;
}, null);
const time = bestSubcat?.travelTimeS ?? data.travelTimesS[cat.id];
const dist = bestSubcat?.distanceM ?? data.distancesM[cat.id];
const isExpanded = expandedCategory === cat.id;
const hasDetails = subcats && subcats.length > 0;
return (
<div key={cat.id}>
<button
className="w-full text-left"
onClick={() => setExpandedCategory(isExpanded ? null : cat.id)}
disabled={!hasDetails}
>
<div className="flex items-center justify-between text-xs mb-1">
<span className="text-gray-600 font-medium flex items-center gap-1">
{cat.label}
{hasDetails && (
<span className="text-gray-300 text-[9px]">{isExpanded ? "▲" : "▼"}</span>
)}
</span>
<span className="text-gray-400 text-[10px]">
{dist != null && formatDist(dist)}
{dist != null && time != null && " · "}
{time != null && formatTime(time)}
</span>
</div>
<div className="h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-300"
style={{ width: `${Math.round(score * 100)}%`, backgroundColor: barColor }}
/>
</div>
</button>
{isExpanded && hasDetails && (
<div className="mt-1.5 space-y-0.5 pl-1">
{subcats.map((d) => (
<div
key={d.subcategory}
className="flex items-center justify-between text-[10px] text-gray-500 py-0.5"
>
<span className="truncate max-w-[130px]" title={d.name ?? undefined}>
<span className="text-gray-400 mr-1">
{SUBCATEGORY_LABELS[d.subcategory] ?? d.subcategory}
</span>
{d.name && (
<span className="text-gray-600 font-medium">{d.name}</span>
)}
</span>
<span className="text-gray-400 shrink-0 ml-1">
{d.distanceM != null && formatDist(d.distanceM)}
{d.distanceM != null && d.travelTimeS != null && " · "}
{d.travelTimeS != null && formatTime(d.travelTimeS)}
</span>
</div>
))}
</div>
)}
</div>
);
})}
</div>
</div>
);
}