feat(ui): Phase 3c — vehicle selector panel with all Tesla trims

Replace the simple <select> vehicle chip with a proper anchored
glass panel matching Direction B.

- Full Tesla model catalogue (S / 3 / Y / X / Cybertruck) with all
  trims (Standard / Long Range RWD / Long Range AWD / Performance /
  Plaid / Cyberbeast) and per-trim range, peak kW, 0-100, top speed,
  and badges (Most popular, Best range, Performance).
- Vehicle chip in the top bar now shows the model + abbreviated trim
  ("Model Y LR AWD") with range underneath, opens the panel anchored
  to the chip's bounding rect.
- Panel header has a starting-charge slider (10-100%, snaps to 5).
  Each trim shows "X km now" (= range × charge%) in green so the
  user sees the live impact of picking a different trim or charge
  level.
- Model groups collapse / expand independently; the currently-
  selected model expands by default.
- Picking a trim updates the chip everywhere; closes the panel.
- Backdrop click and Esc both dismiss.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-20 14:05:48 +01:00
parent 7187975ca5
commit b61e3510b9
+261 -29
View File
@@ -118,13 +118,89 @@ const EMPTY_ITINERARY: Itinerary = {
const STOP_TYPES: StopType[] = ['supercharger', 'destination-charger', 'hotel', 'attraction', 'restaurant', 'cafe', 'viewpoint', 'custom', 'origin', 'destination', 'tunnel'];
const VEHICLES = [
{ name: 'Model Y Long Range', trim: 'Long Range AWD', rangeKm: 514, efficiency: 165 },
{ name: 'Model 3 Highland LR', trim: 'Long Range', rangeKm: 549, efficiency: 155 },
{ name: 'Model S Long Range', trim: 'Long Range', rangeKm: 634, efficiency: 175 },
{ name: 'Model Y RWD (EU)', trim: 'Standard Range', rangeKm: 455, efficiency: 158 },
interface VehicleTrim {
id: string;
name: string; // e.g. "Long Range AWD"
rangeKm: number;
kw: number;
sec0to60: number;
topKmh: number;
badge?: string;
}
interface VehicleModel {
id: string;
name: string; // e.g. "Model Y"
description: string;
trims: VehicleTrim[];
}
interface Vehicle {
modelId: string;
trimId: string;
name: string; // model name
trim: string; // trim name
rangeKm: number;
kw: number;
sec0to60: number;
topKmh: number;
badge?: string;
}
const TESLA_MODELS: VehicleModel[] = [
{
id: 'model-s', name: 'Model S', description: 'Fastback sedan',
trims: [
{ id: 'lr', name: 'Long Range', rangeKm: 634, kw: 250, sec0to60: 3.1, topKmh: 240 },
{ id: 'plaid', name: 'Plaid', rangeKm: 600, kw: 250, sec0to60: 1.99, topKmh: 322, badge: 'Performance' },
],
},
{
id: 'model-3', name: 'Model 3', description: 'Compact sedan',
trims: [
{ id: 'std', name: 'Standard Range', rangeKm: 438, kw: 175, sec0to60: 5.6, topKmh: 201 },
{ id: 'lr-rwd', name: 'Long Range RWD', rangeKm: 553, kw: 250, sec0to60: 4.9, topKmh: 201, badge: 'Best range' },
{ id: 'lr-awd', name: 'Long Range AWD', rangeKm: 528, kw: 250, sec0to60: 4.2, topKmh: 201 },
{ id: 'perf', name: 'Performance', rangeKm: 528, kw: 250, sec0to60: 2.9, topKmh: 261, badge: 'Performance' },
],
},
{
id: 'model-y', name: 'Model Y', description: 'Crossover · best-seller',
trims: [
{ id: 'std', name: 'Standard Range', rangeKm: 460, kw: 175, sec0to60: 5.6, topKmh: 217 },
{ id: 'lr-rwd', name: 'Long Range RWD', rangeKm: 531, kw: 250, sec0to60: 5.9, topKmh: 217 },
{ id: 'lr-awd', name: 'Long Range AWD', rangeKm: 514, kw: 250, sec0to60: 4.8, topKmh: 217, badge: 'Most popular' },
{ id: 'perf', name: 'Performance', rangeKm: 488, kw: 250, sec0to60: 3.5, topKmh: 250, badge: 'Performance' },
],
},
{
id: 'model-x', name: 'Model X', description: 'Three-row SUV · falcon doors',
trims: [
{ id: 'lr', name: 'Long Range', rangeKm: 543, kw: 250, sec0to60: 3.8, topKmh: 250 },
{ id: 'plaid', name: 'Plaid', rangeKm: 528, kw: 250, sec0to60: 2.5, topKmh: 262, badge: 'Performance' },
],
},
{
id: 'cybertruck', name: 'Cybertruck', description: 'Angular pickup',
trims: [
{ id: 'rwd', name: 'Long Range RWD', rangeKm: 563, kw: 350, sec0to60: 6.5, topKmh: 180 },
{ id: 'awd', name: 'AWD', rangeKm: 547, kw: 350, sec0to60: 4.1, topKmh: 180 },
{ id: 'beast', name: 'Cyberbeast', rangeKm: 515, kw: 350, sec0to60: 2.6, topKmh: 209, badge: 'Performance' },
],
},
];
const DEFAULT_VEHICLE: Vehicle = (() => {
const m = TESLA_MODELS.find(x => x.id === 'model-y')!;
const t = m.trims.find(tr => tr.id === 'lr-awd')!;
return { modelId: m.id, trimId: t.id, name: m.name, trim: t.name, rangeKm: t.rangeKm, kw: t.kw, sec0to60: t.sec0to60, topKmh: t.topKmh, badge: t.badge };
})();
function abbrevTrim(trim: string): string {
return trim
.replace('Long Range', 'LR')
.replace('Standard Range', 'Std')
.replace('Performance', 'Perf');
}
const QUICK_PROMPTS = [
'Plan a 2-day trip from London to Edinburgh in my Model Y',
'I want to drive from Amsterdam to Munich',
@@ -1031,7 +1107,7 @@ function makePinIcon(color: string, active: boolean, hover: boolean): L.DivIcon
function TopBar({
origin, destination, onOriginChange, onDestinationChange, onODCommit,
chatInput, setChatInput, onChatSubmit, chips, onRemoveChip,
vehicle, onVehicleChange, grokStatus, onOpenGpx,
vehicle, onOpenVehiclePanel, grokStatus, onOpenGpx,
}: {
origin: string; destination: string;
onOriginChange: (v: string) => void;
@@ -1040,7 +1116,7 @@ function TopBar({
chatInput: string; setChatInput: (v: string) => void;
onChatSubmit: () => void;
chips: string[]; onRemoveChip: (i: number) => void;
vehicle: typeof VEHICLES[number]; onVehicleChange: (v: typeof VEHICLES[number]) => void;
vehicle: Vehicle; onOpenVehiclePanel: (rect: DOMRect) => void;
grokStatus: { label?: string };
onOpenGpx: () => void;
}) {
@@ -1141,28 +1217,22 @@ function TopBar({
</button>
</div>
{/* Vehicle chip */}
<div className="relative">
<select
value={vehicle.name}
onChange={(e) => onVehicleChange(VEHICLES.find(v => v.name === e.target.value)!)}
className="appearance-none h-[38px] pl-9 pr-7 rounded-[10px] text-[12px] cursor-pointer"
style={{
background: 'var(--gd-panel)',
border: '1px solid var(--gd-border)',
color: 'var(--gd-text)',
fontFamily: 'inherit',
}}
{/* Vehicle chip — opens trim panel */}
<button
onClick={(e) => onOpenVehiclePanel(e.currentTarget.getBoundingClientRect())}
className="h-[38px] px-3 inline-flex items-center gap-2 rounded-[10px] cursor-pointer"
style={{ background: 'var(--gd-panel)', border: '1px solid var(--gd-border)' }}
>
{VEHICLES.map(v => (
<option key={v.name} value={v.name}>
{v.name} {v.rangeKm} km
</option>
))}
</select>
<Gauge className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 pointer-events-none" style={{ color: 'var(--gd-text-2)' }} />
<ChevronDown className="absolute right-2.5 top-1/2 -translate-y-1/2 w-3 h-3 pointer-events-none" style={{ color: 'var(--gd-text-3)' }} />
<Gauge className="w-3.5 h-3.5" style={{ color: 'var(--gd-text-2)' }} />
<div className="flex flex-col items-start leading-[1.15]">
<div className="text-[12px] font-medium" style={{ letterSpacing: '-0.005em' }}>
{vehicle.name}{' '}
<span style={{ color: 'var(--gd-text-3)', fontWeight: 400 }}>{abbrevTrim(vehicle.trim)}</span>
</div>
<div className="text-[10px] num" style={{ color: 'var(--gd-text-3)' }}>{vehicle.rangeKm} km</div>
</div>
<ChevronDown className="w-3 h-3" style={{ color: 'var(--gd-text-3)' }} />
</button>
<ChipButton onClick={() => onOpenGpx()}>
<Download className="w-3.5 h-3.5" style={{ color: 'var(--gd-text-2)' }} />
@@ -1190,7 +1260,9 @@ export default function TeslaTripPlanner() {
const [chips, setChips] = useState<string[]>([]);
const [thinking, setThinking] = useState(false);
const [itinerary, setItinerary] = useState<Itinerary>(EMPTY_ITINERARY);
const [vehicle, setVehicle] = useState(VEHICLES[0]);
const [vehicle, setVehicle] = useState<Vehicle>(DEFAULT_VEHICLE);
const [vehiclePanelOpen, setVehiclePanelOpen] = useState(false);
const [vehicleAnchor, setVehicleAnchor] = useState<DOMRect | null>(null);
const [grokStatus, setGrokStatus] = useState<{ label?: string }>({ label: 'Local Heavy' });
const [legs, setLegs] = useState<Leg[]>([]);
const [activeStopId, setActiveStopId] = useState<string | null>(null);
@@ -1467,7 +1539,8 @@ export default function TeslaTripPlanner() {
chatInput={chatInput} setChatInput={setChatInput}
onChatSubmit={() => sendMessage(chatInput)}
chips={chips} onRemoveChip={(i) => setChips(chips.filter((_, idx) => idx !== i))}
vehicle={vehicle} onVehicleChange={setVehicle}
vehicle={vehicle}
onOpenVehiclePanel={(rect) => { setVehicleAnchor(rect); setVehiclePanelOpen(true); }}
grokStatus={grokStatus}
onOpenGpx={() => setModal({ kind: 'gpx' })}
/>
@@ -1752,6 +1825,14 @@ export default function TeslaTripPlanner() {
{modal?.kind === 'gpx' && (
<GpxExportModal itinerary={itinerary} onClose={() => setModal(null)} />
)}
<VehicleSelectorPanel
open={vehiclePanelOpen}
anchorRect={vehicleAnchor}
selected={vehicle}
onSelect={setVehicle}
onClose={() => setVehiclePanelOpen(false)}
/>
</ErrorBoundary>
);
}
@@ -2510,6 +2591,157 @@ function GpxExportModal({ itinerary, onClose }: { itinerary: Itinerary; onClose:
);
}
// ─── Vehicle selector panel ──────────────────────────────────────────────────
function VehicleSelectorPanel({
open, anchorRect, selected, onSelect, onClose,
}: {
open: boolean;
anchorRect: DOMRect | null;
selected: Vehicle;
onSelect: (v: Vehicle) => void;
onClose: () => void;
}) {
const [chargePct, setChargePct] = React.useState(80);
React.useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
if (!open || !anchorRect) return null;
const panelWidth = 460;
const left = Math.max(12, Math.min(window.innerWidth - panelWidth - 12, anchorRect.right - panelWidth));
const top = anchorRect.bottom + 8;
const pick = (m: VehicleModel, t: VehicleTrim) => {
onSelect({
modelId: m.id, trimId: t.id, name: m.name, trim: t.name,
rangeKm: t.rangeKm, kw: t.kw, sec0to60: t.sec0to60, topKmh: t.topKmh, badge: t.badge,
});
onClose();
};
return (
<>
<div className="fixed inset-0 z-40" onClick={onClose} />
<div
className="fixed z-50 overflow-hidden"
style={{
left, top, width: panelWidth, maxHeight: '78vh',
background: 'rgba(20,20,24,0.96)',
backdropFilter: 'blur(18px)',
border: '1px solid var(--gd-border-2)',
borderRadius: 14,
boxShadow: '0 24px 60px rgba(0,0,0,0.5)',
display: 'flex', flexDirection: 'column',
}}
>
{/* Header with starting-charge slider */}
<div className="px-4 py-3.5 flex-shrink-0" style={{ borderBottom: '1px solid var(--gd-border)' }}>
<div className="flex items-center justify-between mb-2.5">
<div className="text-[11px] uppercase tracking-wider" style={{ color: 'var(--gd-text-3)' }}>
Starting charge
</div>
<div className="text-[12px] num" style={{ color: 'var(--gd-text)' }}>
{chargePct}% · <span className="num" style={{ color: 'var(--gd-green)' }}>
{Math.round(selected.rangeKm * chargePct / 100)} km
</span>
</div>
</div>
<div className="relative h-4">
<input
type="range" min={10} max={100} step={5} value={chargePct}
onChange={(e) => setChargePct(parseInt(e.target.value))}
className="absolute inset-0 w-full opacity-0 cursor-pointer z-10"
/>
<div className="absolute top-1.5 left-0 right-0 h-1 rounded" style={{ background: 'var(--gd-border)' }} />
<div className="absolute top-1.5 left-0 h-1 rounded" style={{ width: `${chargePct}%`, background: 'var(--gd-green)' }} />
<div
className="absolute top-0 w-4 h-4 rounded-full"
style={{ left: `calc(${chargePct}% - 8px)`, background: '#fff', border: '3px solid var(--gd-green)' }}
/>
</div>
</div>
{/* Model + trim list */}
<div className="overflow-y-auto px-2 py-2 flex-1 min-h-0">
{TESLA_MODELS.map(m => (
<ModelGroup
key={m.id}
model={m}
selected={selected}
chargePct={chargePct}
onPick={(t) => pick(m, t)}
/>
))}
</div>
</div>
</>
);
}
function ModelGroup({ model, selected, chargePct, onPick }: {
model: VehicleModel; selected: Vehicle; chargePct: number; onPick: (t: VehicleTrim) => void;
}) {
const isExpanded = model.id === selected.modelId;
const [open, setOpen] = React.useState(isExpanded);
return (
<div className="mb-1.5">
<button
onClick={() => setOpen(o => !o)}
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-white/[0.04] transition"
>
<div className="text-[14px] font-medium flex-1 text-left">{model.name}</div>
<div className="text-[11px]" style={{ color: 'var(--gd-text-3)' }}>{model.description}</div>
{open ? <ChevronUp className="w-3.5 h-3.5" style={{ color: 'var(--gd-text-3)' }} /> : <ChevronDown className="w-3.5 h-3.5" style={{ color: 'var(--gd-text-3)' }} />}
</button>
{open && (
<div className="pl-3 pr-1 pb-1 space-y-1">
{model.trims.map(t => {
const isSel = model.id === selected.modelId && t.id === selected.trimId;
const kmNow = Math.round(t.rangeKm * chargePct / 100);
return (
<button
key={t.id}
onClick={() => onPick(t)}
className="w-full text-left p-2.5 rounded-lg transition"
style={{
background: isSel ? 'var(--gd-red-soft)' : 'transparent',
border: `1px solid ${isSel ? 'var(--gd-red-line)' : 'var(--gd-border)'}`,
}}
>
<div className="flex items-center gap-2 flex-wrap">
<div className="text-[13px] font-medium">{t.name}</div>
{t.badge && (
<span
className="text-[9px] font-semibold tracking-wider px-1.5 py-0.5 rounded-full"
style={{
color: t.badge === 'Performance' ? 'var(--gd-amber)' : 'var(--gd-blue)',
border: `1px solid ${t.badge === 'Performance' ? 'rgba(251,191,36,0.4)' : 'rgba(96,165,250,0.4)'}`,
}}
>
{t.badge}
</span>
)}
<div className="flex-1" />
{isSel && (
<span className="text-[9.5px] font-semibold tracking-wider" style={{ color: 'var(--gd-red)' }}>SELECTED</span>
)}
</div>
<div className="flex gap-3.5 mt-1.5 text-[10.5px] num" style={{ color: 'var(--gd-text-3)' }}>
<span>{t.rangeKm} km full</span>
<span style={{ color: 'var(--gd-green)' }}>{kmNow} km now</span>
<span>0100 in {t.sec0to60}s</span>
<span>{t.kw} kW peak</span>
</div>
</button>
);
})}
</div>
)}
</div>
);
}
function CheckRow({ label, value, onChange }: { label: string; value: boolean; onChange: (v: boolean) => void }) {
return (
<label className="flex items-center gap-2 py-1.5 text-[12px] cursor-pointer" style={{ color: 'var(--gd-text-2)' }}>