fifteen/apps/web/components/control-panel.tsx

305 lines
12 KiB
TypeScript

"use client";
import { CATEGORIES, PROFILES, PROFILE_IDS, VALID_THRESHOLDS } from "@transportationer/shared";
import type { CategoryId, TravelMode, ProfileId } from "@transportationer/shared";
const TRAVEL_MODES: Array<{ value: TravelMode; label: string; icon: string; description: string }> =
[
{ value: "cyclist", label: "Cyclist", icon: "🚶🚲🚌", description: "Best of walking, cycling & transit — for people who cycle and use public transport" },
{ value: "cycling_walk", label: "Cyclist (no transit)", icon: "🚶🚲", description: "Best of walking & cycling only — for cyclists who avoid public transport" },
{ value: "transit_walk", label: "Transit + Walk", icon: "🚶🚌", description: "Best of walking & transit — for people without a bike" },
{ value: "walking", label: "Walker", icon: "🚶", description: "Walking only — for people who rely solely on foot travel" },
{ value: "cycling", label: "Cycling only", icon: "🚲", description: "Raw cycling travel times, unblended" },
{ value: "transit", label: "Transit only", icon: "🚌", description: "Raw transit travel times, unblended" },
{ value: "driving", label: "Driving only", icon: "🚗", description: "Raw driving travel times, unblended" },
];
type BaseOverlay = "accessibility" | "estate-value" | "hidden-gem";
interface ControlPanelProps {
profile: ProfileId;
mode: TravelMode;
threshold: number;
weights: Record<CategoryId, number>;
activeCategory: CategoryId | "composite";
baseOverlay: BaseOverlay;
estateValueAvailable: boolean;
onProfileChange: (p: ProfileId) => void;
onModeChange: (m: TravelMode) => void;
onThresholdChange: (t: number) => void;
onWeightChange: (cat: CategoryId, w: number) => void;
onCategoryChange: (cat: CategoryId | "composite") => void;
onBaseOverlayChange: (o: BaseOverlay) => void;
}
export function ControlPanel({
profile,
mode,
threshold,
weights,
activeCategory,
baseOverlay,
estateValueAvailable,
onProfileChange,
onModeChange,
onThresholdChange,
onWeightChange,
onCategoryChange,
onBaseOverlayChange,
}: ControlPanelProps) {
return (
<aside className="w-72 shrink-0 bg-white border-r border-gray-200 flex flex-col overflow-y-auto">
<div className="p-4 border-b border-gray-100">
<h1 className="font-bold text-gray-900 text-sm">
15-Minute City Analyzer
</h1>
<p className="text-xs text-gray-500 mt-0.5">
Transportationer
</p>
</div>
{/* Resident profile */}
<div className="p-4 border-b border-gray-100">
<p className="text-xs font-medium text-gray-600 mb-2 uppercase tracking-wide">
Resident Profile
</p>
<div className="grid grid-cols-1 gap-1">
{PROFILE_IDS.map((pid) => {
const p = PROFILES[pid];
return (
<button
key={pid}
onClick={() => onProfileChange(pid)}
className={`flex items-center gap-2 px-3 py-1.5 rounded-md text-xs transition-colors text-left ${
profile === pid
? "bg-gray-900 text-white font-medium"
: "text-gray-600 hover:bg-gray-100"
}`}
>
<span>{p.emoji}</span>
<span className="font-medium">{p.label}</span>
<span className={`ml-auto text-[10px] truncate max-w-[110px] ${profile === pid ? "text-gray-300" : "text-gray-400"}`}>
{p.description}
</span>
</button>
);
})}
</div>
</div>
{/* Travel mode */}
<div className="p-4 border-b border-gray-100">
<p className="text-xs font-medium text-gray-600 mb-2 uppercase tracking-wide">
Travel Mode
</p>
<div className="grid grid-cols-2 gap-1">
{TRAVEL_MODES.map((m, i) => {
const isRightCol = i % 2 === 1;
return (
<div key={m.value} className="relative group">
<button
onClick={() => onModeChange(m.value)}
className={`w-full flex flex-col items-center gap-1 py-2 rounded-md text-xs border transition-colors ${
mode === m.value
? "border-brand-500 bg-brand-50 text-brand-700 font-medium"
: "border-gray-200 text-gray-600 hover:border-gray-300"
}`}
>
<span className="text-lg">{m.icon}</span>
{m.label}
</button>
<div className={`pointer-events-none absolute bottom-full mb-2 w-48 rounded-md bg-gray-900 px-2.5 py-1.5 text-[11px] leading-snug text-white opacity-0 group-hover:opacity-100 transition-opacity z-50 shadow-lg ${isRightCol ? "right-0" : "left-0"}`}>
{m.description}
<div className={`absolute top-full border-4 border-transparent border-t-gray-900 ${isRightCol ? "right-4" : "left-4"}`} />
</div>
</div>
);
})}
</div>
</div>
{/* Threshold */}
<div className="p-4 border-b border-gray-100">
<p className="text-xs font-medium text-gray-600 mb-2 uppercase tracking-wide">
Target Threshold
</p>
<div className="grid grid-cols-4 gap-1">
{VALID_THRESHOLDS.map((t) => (
<button
key={t}
onClick={() => onThresholdChange(t)}
className={`py-1.5 rounded text-xs font-medium transition-colors ${
threshold === t
? "bg-brand-600 text-white"
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
}`}
>
{t}m
</button>
))}
</div>
</div>
{/* Active heatmap */}
<div className="p-4 border-b border-gray-100">
<p className="text-xs font-medium text-gray-600 mb-2 uppercase tracking-wide">
Display Layer
</p>
<div className="space-y-1">
<button
onClick={() => onCategoryChange("composite")}
className={`w-full text-left px-3 py-2 rounded-md text-sm flex items-center gap-2 transition-colors ${
activeCategory === "composite"
? "bg-gray-900 text-white"
: "text-gray-700 hover:bg-gray-100"
}`}
>
<span
className="w-2.5 h-2.5 rounded-full bg-gradient-to-r from-red-400 to-green-400 shrink-0"
/>
Composite Score
</button>
{CATEGORIES.map((cat) => (
<button
key={cat.id}
onClick={() => onCategoryChange(cat.id)}
className={`w-full text-left px-3 py-2 rounded-md text-sm flex items-center gap-2 transition-colors ${
activeCategory === cat.id
? "bg-gray-100 text-gray-900 font-medium"
: "text-gray-600 hover:bg-gray-50"
}`}
>
<span
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: cat.color }}
/>
{cat.label}
</button>
))}
</div>
</div>
{/* Base overlay selector */}
<div className="p-4 border-b border-gray-100">
<p className="text-xs font-medium text-gray-600 mb-2 uppercase tracking-wide">
Map Overlay
</p>
<div className="space-y-1">
<button
onClick={() => onBaseOverlayChange("accessibility")}
className={`w-full flex items-center gap-2 px-3 py-2 rounded-md text-sm transition-colors border ${
baseOverlay === "accessibility"
? "bg-gray-900 text-white font-medium border-gray-900"
: "text-gray-600 hover:bg-gray-50 border-transparent"
}`}
>
<span className="w-2.5 h-2.5 rounded-full shrink-0 bg-gradient-to-r from-red-400 to-green-400" />
<span>Accessibility</span>
</button>
{estateValueAvailable && (
<>
<button
onClick={() => onBaseOverlayChange("estate-value")}
className={`w-full flex items-center gap-2 px-3 py-2 rounded-md text-sm transition-colors border ${
baseOverlay === "estate-value"
? "bg-amber-100 text-amber-900 font-medium border-amber-300"
: "text-gray-600 hover:bg-gray-50 border-transparent"
}`}
>
<span className={`w-2.5 h-2.5 rounded-full shrink-0 ${baseOverlay === "estate-value" ? "bg-amber-500" : "bg-gray-300"}`} />
<span>Land Value</span>
<span className="ml-auto text-[10px] text-gray-400">BORIS NI</span>
</button>
<button
onClick={() => onBaseOverlayChange("hidden-gem")}
className={`w-full flex items-center gap-2 px-3 py-2 rounded-md text-sm transition-colors border ${
baseOverlay === "hidden-gem"
? "bg-green-100 text-green-900 font-medium border-green-300"
: "text-gray-600 hover:bg-gray-50 border-transparent"
}`}
>
<span className={`w-2.5 h-2.5 rounded-full shrink-0 ${baseOverlay === "hidden-gem" ? "bg-green-500" : "bg-gray-300"}`} />
<span>Hidden Gems</span>
<span className="ml-auto text-[10px] text-gray-400">BORIS NI</span>
</button>
</>
)}
</div>
{baseOverlay === "estate-value" && estateValueAvailable && (
<div className="mt-2 px-1">
<div className="h-2 rounded-full" style={{ background: "linear-gradient(to right, #ffffb2, #fecc5c, #fd8d3c, #f03b20, #bd0026)" }} />
<div className="flex justify-between mt-0.5 text-[9px] text-gray-400">
<span>0</span>
<span>100</span>
<span>300</span>
<span>700</span>
<span>1500+</span>
</div>
<div className="text-center text-[9px] text-gray-400 mt-0.5">/m²</div>
</div>
)}
{baseOverlay === "hidden-gem" && estateValueAvailable && (
<div className="mt-2 px-1">
<div className="h-2 rounded-full" style={{ background: "linear-gradient(to right, #d73027, #fee08b, #d9ef8b, #1a9850)" }} />
<div className="flex justify-between mt-0.5 text-[9px] text-gray-400">
<span>Poor</span>
<span>Gem score</span>
<span>High</span>
</div>
</div>
)}
</div>
{/* Category weights */}
<div className="p-4 flex-1">
<p className="text-xs font-medium text-gray-600 mb-3 uppercase tracking-wide">
Category Weights
</p>
<div className="space-y-3">
{CATEGORIES.map((cat) => (
<div key={cat.id}>
<div className="flex items-center justify-between mb-1">
<label className="text-xs text-gray-600 flex items-center gap-1.5">
<span
className="w-2 h-2 rounded-full"
style={{ backgroundColor: cat.color }}
/>
{cat.label}
</label>
<span className="text-xs font-medium text-gray-700 w-6 text-right">
{weights[cat.id].toFixed(1)}
</span>
</div>
<input
type="range"
min="0"
max="2"
step="0.1"
value={weights[cat.id]}
onChange={(e) =>
onWeightChange(cat.id, parseFloat(e.target.value))
}
className="w-full accent-brand-600"
style={{ accentColor: cat.color }}
/>
</div>
))}
</div>
</div>
{/* Legend */}
<div className="p-4 border-t border-gray-100">
<p className="text-xs font-medium text-gray-600 mb-2">Score Legend</p>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500">Low</span>
<div className="flex-1 h-3 rounded-full bg-gradient-to-r from-red-400 via-yellow-400 to-green-500" />
<span className="text-xs text-gray-500">High</span>
</div>
<p className="text-xs text-gray-400 mt-1 text-center">
Midpoint = {threshold} min threshold
</p>
</div>
</aside>
);
}