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

276 lines
9.4 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,
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;
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>
);
}