diff --git a/client/src/pages/TeslaTripPlanner.tsx b/client/src/pages/TeslaTripPlanner.tsx index faa3d11..e920b90 100644 --- a/client/src/pages/TeslaTripPlanner.tsx +++ b/client/src/pages/TeslaTripPlanner.tsx @@ -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({ - {/* Vehicle chip */} -
- - - -
+ {/* Vehicle chip — opens trim panel */} + onOpenGpx()}> @@ -1190,7 +1260,9 @@ export default function TeslaTripPlanner() { const [chips, setChips] = useState([]); const [thinking, setThinking] = useState(false); const [itinerary, setItinerary] = useState(EMPTY_ITINERARY); - const [vehicle, setVehicle] = useState(VEHICLES[0]); + const [vehicle, setVehicle] = useState(DEFAULT_VEHICLE); + const [vehiclePanelOpen, setVehiclePanelOpen] = useState(false); + const [vehicleAnchor, setVehicleAnchor] = useState(null); const [grokStatus, setGrokStatus] = useState<{ label?: string }>({ label: 'Local Heavy' }); const [legs, setLegs] = useState([]); const [activeStopId, setActiveStopId] = useState(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' && ( setModal(null)} /> )} + + setVehiclePanelOpen(false)} + /> ); } @@ -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 ( + <> +
+
+ {/* Header with starting-charge slider */} +
+
+
+ Starting charge +
+
+ {chargePct}% · + {Math.round(selected.rangeKm * chargePct / 100)} km + +
+
+
+ setChargePct(parseInt(e.target.value))} + className="absolute inset-0 w-full opacity-0 cursor-pointer z-10" + /> +
+
+
+
+
+ + {/* Model + trim list */} +
+ {TESLA_MODELS.map(m => ( + pick(m, t)} + /> + ))} +
+
+ + ); +} + +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 ( +
+ + {open && ( +
+ {model.trims.map(t => { + const isSel = model.id === selected.modelId && t.id === selected.trimId; + const kmNow = Math.round(t.rangeKm * chargePct / 100); + return ( + + ); + })} +
+ )} +
+ ); +} + function CheckRow({ label, value, onChange }: { label: string; value: boolean; onChange: (v: boolean) => void }) { return (