215 lines
7.4 KiB
TypeScript
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>
|
|
);
|
|
}
|