fifteen/apps/web/components/control-panel.tsx
2026-03-01 21:58:53 +01:00

215 lines
7.4 KiB
TypeScript

"use client";
import { CATEGORIES, PROFILES, PROFILE_IDS } from "@transportationer/shared";
import type { CategoryId, TravelMode, ProfileId } from "@transportationer/shared";
const TRAVEL_MODES: Array<{ value: TravelMode; label: string; icon: string }> =
[
{ value: "walking", label: "Walking", icon: "🚶" },
{ value: "cycling", label: "Cycling", icon: "🚲" },
{ value: "driving", label: "Driving", icon: "🚗" },
];
const THRESHOLDS = [5, 8, 10, 12, 15, 20, 25, 30];
interface ControlPanelProps {
profile: ProfileId;
mode: TravelMode;
threshold: number;
weights: Record<CategoryId, number>;
activeCategory: CategoryId | "composite";
onProfileChange: (p: ProfileId) => void;
onModeChange: (m: TravelMode) => void;
onThresholdChange: (t: number) => void;
onWeightChange: (cat: CategoryId, w: number) => void;
onCategoryChange: (cat: CategoryId | "composite") => void;
}
export function ControlPanel({
profile,
mode,
threshold,
weights,
activeCategory,
onProfileChange,
onModeChange,
onThresholdChange,
onWeightChange,
onCategoryChange,
}: 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="flex gap-1">
{TRAVEL_MODES.map((m) => (
<button
key={m.value}
onClick={() => onModeChange(m.value)}
className={`flex-1 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>
</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">
{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>
{/* 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>
);
}