shadimage
flightcn
blockflight-route-pickersource: ok · hit

Flight Route Picker

Search airports, explore featured routes, and arrange multiple stopovers to compare flight legs, distance and estimated time in the air.

npx shadcn add "https://flightcn.yencheng.dev/r/flight-route-picker.json"
free

Where it goes

feature-grid

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
iconslucide-react

Registry dependencies

@flightcn/flighthttps://mapcn.dev/r/map.json

Files1 · source cached 10/9/2026, 3:39:50 AM

src/registry/blocks/flight-route-picker/flight-route-picker.tsx
"use client";

import {
  ArrowDown,
  ArrowDownUp,
  ArrowUp,
  ArrowRight,
  LocateFixed,
  Plane,
  Plus,
  Search,
  X,
} from "lucide-react";
import { useEffect, useId, useRef, useState } from "react";
import { LngLatBounds } from "maplibre-gl";
import { Map, useMap } from "@/components/ui/map";
import {
  FlightAirport,
  FlightRoute,
  airports,
  generateArcCoordinates,
  getAirportInfo,
  resolveAirport,
  SingleWorldZoomLimit,
} from "@/components/ui/flight";

const EARTH_RADIUS_KM = 6371;
const CRUISE_SPEED_KMH = 880;
const LEG_COLORS = [
  "#b65320",
  "#2f6f8f",
  "#6d5a9c",
  "#337b60",
  "#a34468",
  "#8a6d24",
] as const;
const ALL_AIRPORTS = Object.values(airports);
const FEATURED_AIRPORTS = [
  "TPE",
  "NRT",
  "HND",
  "SIN",
  "DXB",
  "LHR",
  "JFK",
  "SFO",
  "SYD",
];
const PRESETS = [
  { name: "Across the Pacific", from: "TPE", to: "SFO", via: [] },
  { name: "A stop in Tokyo", from: "TPE", to: "SFO", via: ["NRT"] },
  { name: "Across the Atlantic", from: "LHR", to: "JFK", via: [] },
  { name: "Pacific connections", from: "TPE", to: "SFO", via: ["NRT", "HNL"] },
] as const;
type Route = { from: string; to: string; via: readonly string[] };
const formatKm = (distance: number) =>
  `${Math.round(distance).toLocaleString("en-US")} km`;
const flightMinutes = (distance: number) =>
  Math.round((distance / CRUISE_SPEED_KMH) * 60);
function formatTime(minutes: number) {
  return `${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, "0")}m`;
}

function greatCircleKm(from: string, to: string) {
  const a = resolveAirport(from);
  const b = resolveAirport(to);
  const radians = Math.PI / 180;
  const h =
    Math.sin(((b[1] - a[1]) * radians) / 2) ** 2 +
    Math.cos(a[1] * radians) *
      Math.cos(b[1] * radians) *
      Math.sin(((b[0] - a[0]) * radians) / 2) ** 2;
  return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(h)));
}

function AirportSearch({
  label,
  value,
  excluded,
  onSelect,
}: {
  label: string;
  value: string | null;
  excluded: readonly string[];
  onSelect: (code: string) => void;
}) {
  const id = useId();
  const listRef = useRef<HTMLUListElement>(null);
  const [open, setOpen] = useState(false);
  const [query, setQuery] = useState("");
  const [active, setActive] = useState(0);
  const selected = value ? getAirportInfo(value) : null;
  const term = query.trim().toLowerCase();
  const results = (
    term
      ? ALL_AIRPORTS.filter((airport) =>
          `${airport.code} ${airport.city} ${airport.name} ${airport.country}`
            .toLowerCase()
            .includes(term),
        ).sort((a, b) => {
          const rank = (airport: (typeof ALL_AIRPORTS)[number]) => {
            if (airport.code.toLowerCase() === term) return 0;
            if (airport.code.toLowerCase().startsWith(term)) return 1;
            if (airport.city.toLowerCase() === term) return 2;
            if (airport.city.toLowerCase().startsWith(term)) return 3;
            if (airport.name.toLowerCase().startsWith(term)) return 4;
            return 5;
          };
          return rank(a) - rank(b) || a.code.localeCompare(b.code);
        })
      : FEATURED_AIRPORTS.map((code) => airports[code])
  )
    .filter((airport) => !excluded.includes(airport.code))
    .slice(0, 8);

  useEffect(() => {
    if (open)
      listRef.current?.children[active]?.scrollIntoView({ block: "nearest" });
  }, [active, open]);

  function select(code: string) {
    onSelect(code);
    setOpen(false);
  }

  function openSearch() {
    if (open) return;
    setQuery("");
    setActive(0);
    setOpen(true);
  }

  return (
    <div className="relative min-w-0">
      <label
        htmlFor={id}
        className="mb-1.5 block text-xs font-medium text-slate-600"
      >
        {label}
      </label>
      <div className="flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-3 focus-within:border-orange-600 focus-within:ring-1 focus-within:ring-orange-600">
        <Search
          size={14}
          aria-hidden="true"
          className="shrink-0 text-slate-400"
        />
        <input
          id={id}
          role="combobox"
          aria-autocomplete="list"
          aria-expanded={open}
          aria-controls={open ? `${id}-results` : undefined}
          aria-activedescendant={
            open && results[active]
              ? `${id}-${results[active].code}`
              : undefined
          }
          autoComplete="off"
          spellCheck={false}
          placeholder="Search city or airport"
          value={
            open ? query : selected ? `${selected.code} · ${selected.city}` : ""
          }
          onFocus={openSearch}
          onClick={openSearch}
          onBlur={() => setOpen(false)}
          onChange={(event) => {
            setQuery(event.target.value);
            setActive(0);
            setOpen(true);
          }}
          onKeyDown={(event) => {
            if (event.key === "Escape") {
              setOpen(false);
              return;
            }
            if (event.key === "ArrowDown" || event.key === "ArrowUp") {
              event.preventDefault();
              if (!open) {
                openSearch();
                return;
              }
              setActive((current) =>
                Math.max(
                  0,
                  Math.min(
                    results.length - 1,
                    current + (event.key === "ArrowDown" ? 1 : -1),
                  ),
                ),
              );
            }
            if (event.key === "Enter" && open) {
              event.preventDefault();
              if (results[active]) select(results[active].code);
            }
          }}
          className="w-full min-w-0 bg-transparent py-3 text-sm text-slate-950 outline-none placeholder:text-slate-400"
        />
      </div>
      {selected && (
        <p className="mt-1.5 text-[11px] leading-4 text-slate-500">
          {selected.name}
        </p>
      )}
      {open && (
        <div className="absolute top-full right-0 left-0 z-30 mt-1 overflow-hidden rounded-xl border border-slate-200 bg-white shadow-xl">
          <ul
            ref={listRef}
            id={`${id}-results`}
            role="listbox"
            aria-label={`${label} airports`}
            className="max-h-60 overflow-y-auto p-1"
          >
            {results.map((airport, index) => (
              <li
                key={airport.code}
                id={`${id}-${airport.code}`}
                role="option"
                aria-selected={active === index}
                onPointerDown={(event) => event.preventDefault()}
                onClick={() => select(airport.code)}
                className={`cursor-pointer rounded-lg px-2.5 py-2 text-xs hover:bg-orange-50 ${active === index ? "bg-orange-50" : ""}`}
              >
                <span className="flex items-baseline justify-between gap-2 font-medium text-slate-950">
                  <span>{airport.city}</span>
                  <span className="font-mono text-orange-800">
                    {airport.code}
                  </span>
                </span>
                <span className="mt-0.5 block text-[11px] leading-4 text-slate-500">
                  {airport.name} · {airport.country}
                </span>
              </li>
            ))}
          </ul>
          {results.length === 0 && (
            <p
              role="status"
              className="px-3 py-4 text-xs leading-5 text-slate-500"
            >
              No available airports match “{query}”. Try a city, name or IATA
              code. Airports already on this route are excluded.
            </p>
          )}
        </div>
      )}
    </div>
  );
}

function RouteCamera({ from, via, to }: Route) {
  const { map, isLoaded } = useMap();
  const [revision, setRevision] = useState(0);
  useEffect(() => {
    if (!map || !isLoaded) return;
    const stops = [from, ...via, to];
    const bounds = new LngLatBounds();
    let previousLongitude = resolveAirport(from)[0];
    // Keep consecutive legs in the same world copy across the date line.
    for (let i = 1; i < stops.length; i++) {
      for (const [longitude, latitude] of generateArcCoordinates(
        resolveAirport(stops[i - 1]),
        resolveAirport(stops[i]),
      )) {
        const unwrapped =
          longitude + 360 * Math.round((previousLongitude - longitude) / 360);
        bounds.extend([unwrapped, latitude]);
        previousLongitude = unwrapped;
      }
    }
    if (via.length > 0) {
      const originLongitude = resolveAirport(from)[0];
      for (const [longitude, latitude] of generateArcCoordinates(
        resolveAirport(from),
        resolveAirport(to),
      )) {
        bounds.extend([
          longitude + 360 * Math.round((originLongitude - longitude) / 360),
          latitude,
        ]);
      }
    }
    const fitRoute = () => {
      map.fitBounds(bounds, {
        padding: { top: 65, bottom: 155, left: 45, right: 45 },
        maxZoom: 5,
        duration: 0,
      });
    };
    fitRoute();
    map.on("resize", fitRoute);
    return () => {
      map.off("resize", fitRoute);
    };
  }, [map, isLoaded, from, via, to, revision]);
  return (
    <button
      type="button"
      onClick={() => setRevision((value) => value + 1)}
      className="pressable absolute top-3 right-3 z-10 flex items-center gap-1.5 rounded-lg border border-slate-200 bg-white/95 px-2.5 py-2 text-xs text-slate-600 shadow-sm hover:bg-slate-50"
    >
      <LocateFixed size={14} aria-hidden="true" /> Fit route
    </button>
  );
}

export function FlightRoutePicker() {
  const [route, setRoute] = useState<Route>({
    from: "TPE",
    to: "SFO",
    via: [],
  });
  const [addingStop, setAddingStop] = useState(false);
  const stops = [route.from, ...route.via, route.to];
  const legs = stops.slice(1).map((to, index) => ({
    from: stops[index],
    to,
    // Round each leg first so displayed legs, totals and differences agree.
    distance: Math.round(greatCircleKm(stops[index], to)),
    color: LEG_COLORS[index % LEG_COLORS.length],
  }));
  const directDistance = Math.round(greatCircleKm(route.from, route.to));
  const totalDistance = legs.reduce((total, leg) => total + leg.distance, 0);
  const totalMinutes = legs.reduce(
    (total, leg) => total + flightMinutes(leg.distance),
    0,
  );
  const extraDistance = Math.max(0, totalDistance - directDistance);
  const extraMinutes = Math.max(
    0,
    totalMinutes - flightMinutes(directDistance),
  );
  const detourPercent =
    directDistance > 0 ? (extraDistance / directDistance) * 100 : null;

  function moveStop(index: number, direction: -1 | 1) {
    setRoute((current) => {
      const via = [...current.via];
      const next = index + direction;
      if (next < 0 || next >= via.length) return current;
      [via[index], via[next]] = [via[next], via[index]];
      return { ...current, via };
    });
  }

  return (
    <div className="grid grid-cols-1 w-full min-w-0 overflow-hidden rounded-2xl border border-slate-200 bg-white lg:grid-cols-[320px_1fr]">
      <aside className="flex min-w-0 flex-col border-b border-slate-200 lg:border-r lg:border-b-0">
        <div className="flex items-center justify-between border-b border-slate-200 bg-slate-50 px-4 py-3">
          <p className="flex items-center gap-2 font-mono text-[10px] tracking-widest text-slate-500">
            <Plane size={13} aria-hidden="true" /> ROUTE PICKER
          </p>
          <span className="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[10px] text-slate-500">
            {route.via.length > 0
              ? `${route.via.length} ${route.via.length === 1 ? "stop" : "stops"}`
              : "Direct"}
          </span>
        </div>
        <div className="space-y-4 p-4">
          <div className="flex items-center justify-between">
            <p className="text-sm font-medium text-slate-950">
              Build your route
            </p>
            <button
              type="button"
              aria-label="Swap departure and destination"
              title="Swap departure and destination"
              onClick={() =>
                setRoute({
                  ...route,
                  from: route.to,
                  to: route.from,
                  via: [...route.via].reverse(),
                })
              }
              className="pressable rounded-lg p-2 text-slate-500 hover:bg-slate-100"
            >
              <ArrowDownUp size={15} aria-hidden="true" />
            </button>
          </div>
          <AirportSearch
            label="Departure"
            value={route.from}
            excluded={stops.filter((code) => code !== route.from)}
            onSelect={(from) => setRoute({ ...route, from })}
          />
          {route.via.map((code, index) => (
            <div
              key={code}
              className="relative rounded-xl border border-dashed border-sky-200 bg-sky-50/40 p-3"
            >
              <div className="absolute top-1 right-1 z-10 flex items-center gap-0.5">
                <button
                  type="button"
                  aria-label={`Move stopover ${index + 1} earlier`}
                  disabled={index === 0}
                  onClick={() => moveStop(index, -1)}
                  className="pressable rounded-md p-1.5 text-slate-500 hover:bg-sky-100 disabled:opacity-30"
                >
                  <ArrowUp size={13} aria-hidden="true" />
                </button>
                <button
                  type="button"
                  aria-label={`Move stopover ${index + 1} later`}
                  disabled={index === route.via.length - 1}
                  onClick={() => moveStop(index, 1)}
                  className="pressable rounded-md p-1.5 text-slate-500 hover:bg-sky-100 disabled:opacity-30"
                >
                  <ArrowDown size={13} aria-hidden="true" />
                </button>
                <button
                  type="button"
                  aria-label={`Remove stopover ${index + 1}`}
                  onClick={() =>
                    setRoute({
                      ...route,
                      via: route.via.filter((_, i) => i !== index),
                    })
                  }
                  className="pressable rounded-md p-1.5 text-slate-500 hover:bg-sky-100"
                >
                  <X size={13} aria-hidden="true" />
                </button>
              </div>
              <AirportSearch
                label={`Stopover ${index + 1}`}
                value={code}
                excluded={stops.filter((airport) => airport !== code)}
                onSelect={(selected) =>
                  setRoute({
                    ...route,
                    via: route.via.map((airport, i) =>
                      i === index ? selected : airport,
                    ),
                  })
                }
              />
            </div>
          ))}
          {addingStop ? (
            <div className="relative rounded-xl border border-dashed border-sky-200 bg-sky-50/40 p-3">
              <button
                type="button"
                aria-label="Cancel adding stopover"
                onClick={() => setAddingStop(false)}
                className="pressable absolute top-1 right-1 z-10 rounded-md p-2 text-slate-500 hover:bg-sky-100"
              >
                <X size={13} aria-hidden="true" />
              </button>
              <AirportSearch
                label={`Stopover ${route.via.length + 1}`}
                value={null}
                excluded={stops}
                onSelect={(code) => {
                  setRoute({ ...route, via: [...route.via, code] });
                  setAddingStop(false);
                }}
              />
              <p className="mt-2 text-[11px] leading-4 text-slate-500">
                Choose the next stop before your destination.
              </p>
            </div>
          ) : (
            <button
              type="button"
              onClick={() => setAddingStop(true)}
              className="pressable flex w-full items-center justify-center gap-2 rounded-lg border border-dashed border-slate-300 py-2.5 text-xs font-medium text-slate-600 hover:border-orange-400 hover:bg-orange-50"
            >
              <Plus size={14} aria-hidden="true" />{" "}
              {route.via.length > 0 ? "Add another stop" : "Add a stop"}
            </button>
          )}
          <AirportSearch
            label="Destination"
            value={route.to}
            excluded={stops.filter((code) => code !== route.to)}
            onSelect={(to) => setRoute({ ...route, to })}
          />
          <p className="text-[11px] leading-5 text-slate-500">
            Search by city, airport name or IATA code. Your route updates as you
            select.
          </p>
        </div>
        <div className="border-t border-slate-200 p-4">
          <p className="text-[10px] tracking-widest text-slate-500 uppercase">
            Route breakdown
          </p>
          <ol className="mt-3 space-y-4" aria-label="Flight legs">
            {legs.map((leg, index) => (
              <li key={`${leg.from}-${leg.to}`} className="flex gap-3">
                <span
                  className="mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full text-[10px] font-medium text-white"
                  style={{ backgroundColor: leg.color }}
                >
                  {index + 1}
                </span>
                <div className="min-w-0 flex-1">
                  <p className="flex items-center gap-2 font-mono text-xs font-medium text-slate-950">
                    {leg.from}
                    <ArrowRight size={12} aria-hidden="true" />
                    {leg.to}
                  </p>
                  <p className="mt-1 text-[11px] leading-4 text-slate-500">
                    {getAirportInfo(leg.from)?.city} →{" "}
                    {getAirportInfo(leg.to)?.city}
                  </p>
                  <p className="mt-1.5 font-mono text-[11px] text-slate-600">
                    {formatKm(leg.distance)} ·{" "}
                    {formatTime(flightMinutes(leg.distance))} est.
                  </p>
                </div>
              </li>
            ))}
          </ol>
        </div>
        <p className="mt-auto border-t border-slate-200 bg-slate-50/70 px-4 py-3 text-[11px] leading-5 text-slate-500">
          Estimated time in the air at {CRUISE_SPEED_KMH} km/h. Ground time and
          stopover waits are excluded. Routes follow great-circle distances.
        </p>
      </aside>
      <div className="flex min-w-0 flex-col">
        <div className="border-b border-slate-200 bg-slate-50/70 p-4">
          <p className="text-[10px] tracking-widest text-slate-500 uppercase">
            Try a route
          </p>
          <div className="mt-2 flex flex-wrap gap-2">
            {PRESETS.map((preset) => {
              const selected =
                route.from === preset.from &&
                route.to === preset.to &&
                route.via.length === preset.via.length &&
                route.via.every((code, index) => code === preset.via[index]);
              return (
                <button
                  key={preset.name}
                  type="button"
                  aria-pressed={selected}
                  onClick={() => {
                    setRoute({
                      from: preset.from,
                      to: preset.to,
                      via: preset.via,
                    });
                    setAddingStop(false);
                  }}
                  className={`pressable rounded-lg border px-3 py-2 text-left ${selected ? "border-orange-200 bg-orange-50 text-orange-800" : "border-slate-200 bg-white text-slate-600 hover:border-slate-400"}`}
                >
                  <span className="block text-xs font-medium">
                    {preset.name}
                  </span>
                  <span className="mt-1 block font-mono text-[10px] opacity-80">
                    {[preset.from, ...preset.via, preset.to].join(" → ")}
                  </span>
                </button>
              );
            })}
          </div>
        </div>
        <div
          className="relative min-h-[460px] flex-1 lg:min-h-[560px]"
          role="region"
          aria-label={`Flight map ${stops.join(" to ")}`}
        >
          <div className="absolute inset-0">
            <Map
              theme="light"
              center={[150, 35]}
              zoom={2}
              scrollZoom={false}
              renderWorldCopies
              className="h-full w-full"
            >
              <SingleWorldZoomLimit />
              <RouteCamera {...route} />
              {route.via.length > 0 && (
                <FlightRoute
                  from={route.from}
                  to={route.to}
                  color="#64748b"
                  lineStyle="dash"
                  opacity={0.55}
                  width={2}
                  interactive={false}
                  hoverEffect={false}
                />
              )}
              {legs.map((leg) => (
                <FlightRoute
                  key={`${leg.from}-${leg.to}`}
                  from={leg.from}
                  to={leg.to}
                  color={leg.color}
                  width={3}
                  opacity={0.95}
                />
              ))}
              {stops.map((code, index) => (
                <FlightAirport
                  key={code}
                  code={code}
                  showLabel
                  markerContent={
                    <span
                      className="flex size-6 items-center justify-center rounded-full border-2 border-white font-mono text-[10px] font-medium text-white shadow-sm"
                      style={{
                        backgroundColor:
                          index === 0
                            ? "#0f172a"
                            : LEG_COLORS[(index - 1) % LEG_COLORS.length],
                      }}
                    >
                      {index + 1}
                    </span>
                  }
                />
              ))}
            </Map>
          </div>
          <div
            className="pointer-events-none absolute right-3 bottom-9 left-3 rounded-xl border border-slate-200 bg-white/95 p-3 shadow-sm backdrop-blur sm:left-auto sm:w-80"
            role="status"
            aria-label="Route comparison"
          >
            <div className="flex items-start justify-between gap-3">
              <div>
                <p className="text-[10px] tracking-wide text-slate-500 uppercase">
                  Total distance
                </p>
                <p className="mt-1 font-mono text-lg font-medium text-slate-950 tabular-nums">
                  {formatKm(totalDistance)}
                </p>
              </div>
              <div className="text-right">
                <p className="text-[10px] tracking-wide text-slate-500 uppercase">
                  Est. time in air
                </p>
                <p className="mt-1 font-mono text-lg font-medium text-slate-950 tabular-nums">
                  {formatTime(totalMinutes)}
                </p>
              </div>
            </div>
            <p className="mt-2 border-t border-slate-200 pt-2 text-[11px] leading-5 text-slate-600">
              {route.via.length > 0 ? (
                <>
                  <span className="font-medium text-orange-800">
                    +{formatKm(extraDistance)}
                    {detourPercent !== null &&
                      ` · +${detourPercent.toFixed(1)}%`}
                  </span>{" "}
                  vs direct
                  <br />
                  About {extraMinutes} min more in the air · stopover wait
                  excluded
                </>
              ) : (
                "Direct great-circle route. Add a stop to see the extra distance."
              )}
            </p>
          </div>
        </div>
        <div
          className="flex flex-wrap items-center gap-x-5 gap-y-2 border-t border-slate-200 px-4 py-3 text-[11px] text-slate-600"
          aria-label="Route legend"
        >
          {legs.map((leg, index) => (
            <span key={leg.from} className="flex items-center gap-2">
              <span
                aria-hidden="true"
                className="h-0.5 w-5"
                style={{ backgroundColor: leg.color }}
              />
              Leg {index + 1} · {leg.from} → {leg.to}
            </span>
          ))}
          {route.via.length > 0 && (
            <span className="flex items-center gap-2">
              <span
                aria-hidden="true"
                className="w-5 border-t-2 border-dashed border-slate-500"
              />
              Direct · {formatKm(directDistance)}
            </span>
          )}
        </div>
      </div>
    </div>
  );
}

More from flightcn

Browse all components in this library →