An EV trip as a vertical route with charging stops, battery-at-arrival gauges color-coded by charge, added charge time, and a total ETA.
npx shadcn@latest add @paragon/ev-route-planner"use client";
import * as React from "react";
import { MapPin, Zap, Flag, Clock } from "lucide-react";
import { cn } from "@/lib/utils";
export interface EvStop {
/** Place name. */
name: string;
/** Battery % at arrival (before charging). */
arrival: number;
/** Battery % at departure (after charging). Omit for origin/destination. */
departure?: number;
/** Distance covered to reach this point, in the given unit. */
distance?: number;
/** Minutes spent charging here. Omit for origin/destination. */
charge?: number;
kind?: "origin" | "charger" | "destination";
}
export interface EvRoutePlannerProps extends React.ComponentProps<"div"> {
stops?: EvStop[];
unit?: string;
/** Total drive + charge time, formatted. */
eta?: string;
}
const DEFAULT_STOPS: EvStop[] = [
{ name: "San Francisco", arrival: 92, departure: 92, distance: 0, kind: "origin" },
{ name: "Harris Ranch Supercharger", arrival: 18, departure: 84, distance: 188, charge: 22, kind: "charger" },
{ name: "Tejon Ranch Supercharger", arrival: 31, departure: 78, distance: 142, charge: 15, kind: "charger" },
{ name: "Los Angeles", arrival: 24, distance: 118, kind: "destination" },
];
function batteryTone(pct: number) {
if (pct <= 15) return "text-destructive";
if (pct <= 30) return "text-warning";
return "text-success";
}
function batteryBar(pct: number) {
if (pct <= 15) return "bg-destructive";
if (pct <= 30) return "bg-warning";
return "bg-success";
}
/**
* An EV trip laid out as a vertical route with charging stops. Each node shows
* battery-at-arrival on a mini gauge (green/amber/red by charge), the charge
* time added, and the leg distance. The connecting rail is a single element so
* there is no per-row layout cost; figures use tabular numerals.
*/
export function EvRoutePlanner({
stops = DEFAULT_STOPS,
unit = "mi",
eta = "6h 04m",
className,
...props
}: EvRoutePlannerProps) {
const totalDistance = stops.reduce((a, s) => a + (s.distance ?? 0), 0);
const chargeStops = stops.filter((s) => s.kind === "charger").length;
return (
<div
data-slot="ev-route-planner"
className={cn(
"w-full max-w-md rounded-xl bg-card p-5 text-card-foreground shadow-border",
className,
)}
{...props}
>
<div className="flex items-start justify-between">
<div>
<h3 className="text-sm font-medium">Trip plan</h3>
<p className="text-xs text-muted-foreground tabular-nums">
{totalDistance} {unit} · {chargeStops} charging stops
</p>
</div>
<span className="flex items-center gap-1 rounded-full bg-secondary px-2.5 py-1 text-xs font-medium tabular-nums">
<Clock className="size-3 text-muted-foreground" />
{eta}
</span>
</div>
<ol className="mt-4 space-y-0">
{stops.map((s, i) => {
const isLast = i === stops.length - 1;
const Icon =
s.kind === "origin" ? MapPin : s.kind === "destination" ? Flag : Zap;
return (
<li key={s.name} className="relative flex gap-3 pb-5 last:pb-0">
{!isLast && (
<span
aria-hidden
className="absolute left-[15px] top-8 bottom-0 w-px bg-border"
/>
)}
<span
className={cn(
"relative z-10 flex size-8 shrink-0 items-center justify-center rounded-full shadow-border",
s.kind === "charger"
? "bg-secondary text-foreground"
: "bg-primary text-primary-foreground",
)}
>
<Icon className="size-4" />
</span>
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2">
<span className="truncate text-sm font-medium">{s.name}</span>
{s.distance != null && s.distance > 0 && (
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">
{s.distance} {unit}
</span>
)}
</div>
<div className="mt-1.5 flex items-center gap-2">
<div className="h-1.5 w-16 overflow-hidden rounded-full bg-secondary">
<div
className={cn(
"h-full origin-left rounded-full transition-transform duration-500 ease-out",
batteryBar(s.arrival),
)}
style={{ transform: `scaleX(${s.arrival / 100})` }}
/>
</div>
<span
className={cn(
"text-xs font-medium tabular-nums",
batteryTone(s.arrival),
)}
>
{s.arrival}%
</span>
{s.charge != null && (
<span className="text-xs text-muted-foreground tabular-nums">
· +{s.charge}m → {s.departure}%
</span>
)}
{s.kind === "origin" && (
<span className="text-xs text-muted-foreground">
· departing
</span>
)}
{s.kind === "destination" && (
<span className="text-xs text-muted-foreground">
· arrival
</span>
)}
</div>
</div>
</li>
);
})}
</ol>
</div>
);
}