276 lines
9.4 KiB
TypeScript
276 lines
9.4 KiB
TypeScript
"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,
|
||
onOverlayModeChange,
|
||
onClose,
|
||
}: {
|
||
data: LocationScoreData;
|
||
weights: Weights;
|
||
address?: string;
|
||
estateValue?: number | null;
|
||
/** % of zones within 5 km with a lower value (0–100). */
|
||
estatePercentile?: number | null;
|
||
/** % of zones with similar accessibility score that have a lower value (0–100). */
|
||
estateScorePercentile?: number | null;
|
||
overlayMode: OverlayMode;
|
||
isochroneLoading: boolean;
|
||
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>
|
||
|
||
{/* Per-category score bars */}
|
||
<div className="space-y-2.5">
|
||
{CATEGORIES.map((cat) => {
|
||
const score = data.categoryScores[cat.id] ?? 0;
|
||
const dist = data.distancesM[cat.id];
|
||
const time = data.travelTimesS[cat.id];
|
||
const barColor =
|
||
score >= 0.65 ? "#22c55e" : score >= 0.4 ? "#eab308" : "#ef4444";
|
||
const subcats = data.subcategoryDetails?.[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>
|
||
);
|
||
}
|