> ## Documentation Index
> Fetch the complete documentation index at: https://docs.canadava.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Vancouver (CYVR)

> vACA briefing for CYVR Vancouver International: runway layout, ATC frequencies, common procedures, hot spots, live METAR, and online ATC network coverage

export const Gates = ({sections = [], title = "Gates", terminal, labels = {}}) => {
  const DEFAULT_LABELS = {
    range: "Range",
    operators: "Operators",
    aircraft: "Aircraft"
  };
  const mergedLabels = {
    ...DEFAULT_LABELS,
    ...labels
  };
  const typeKey = type => {
    const t = (type || "").toLowerCase();
    if (t.includes("domestic") || t.includes("domestique")) return "domestic";
    if (t.includes("transborder") || t.includes("transfrontalier") || t.includes("us")) return "transborder";
    if (t.includes("international")) return "international";
    if (t.includes("regional") || t.includes("régional")) return "regional";
    if (t.includes("remote") || t.includes("hardstand") || t.includes("isol")) return "remote";
    if (t.includes("swing") || t.includes("polyvalent")) return "swing";
    if (t.includes("deice") || t.includes("dégivrage") || t.includes("ice")) return "deice";
    return "default";
  };
  const TypeIcon = ({k}) => {
    const svgProps = {
      width: 14,
      height: 14,
      viewBox: "0 0 24 24",
      fill: "none",
      stroke: "currentColor",
      strokeWidth: 2,
      strokeLinecap: "round",
      strokeLinejoin: "round",
      "aria-hidden": true
    };
    switch (k) {
      case "domestic":
        return <svg {...svgProps}>
            <path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
            <polyline points="9 22 9 12 15 12 15 22" />
          </svg>;
      case "international":
        return <svg {...svgProps}>
            <circle cx="12" cy="12" r="10" />
            <path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" />
            <path d="M2 12h20" />
          </svg>;
      case "transborder":
        return <svg {...svgProps}>
            <path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z" />
            <line x1="4" x2="4" y1="22" y2="15" />
          </svg>;
      case "regional":
        return <svg {...svgProps}>
            <path d="M17.8 19.2 16 11l3.5-3.5C21 6 21.5 4 21 3c-1-.5-3 0-4.5 1.5L13 8 4.8 6.2c-.5-.1-.9.1-1.1.5l-.3.5c-.2.5-.1 1 .3 1.3L9 12l-2 3H4l-1 1 3 2 2 3 1-1v-3l3-2 3.5 5.3c.3.4.8.5 1.3.3l.5-.2c.4-.3.6-.7.5-1.2z" />
          </svg>;
      case "remote":
        return <svg {...svgProps}>
            <path d="M11 21.73a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73z" />
            <path d="M12 22V12" />
            <polyline points="3.29 7 12 12 20.71 7" />
            <path d="m7.5 4.27 9 5.15" />
          </svg>;
      case "swing":
        return <svg {...svgProps}>
            <path d="M8 3 4 7l4 4" />
            <path d="M4 7h16" />
            <path d="m16 21 4-4-4-4" />
            <path d="M20 17H4" />
          </svg>;
      case "deice":
        return <svg {...svgProps}>
            <line x1="2" x2="22" y1="12" y2="12" />
            <line x1="12" x2="12" y1="2" y2="22" />
            <path d="m20 16-4-4 4-4" />
            <path d="m4 8 4 4-4 4" />
            <path d="m16 4-4 4-4-4" />
            <path d="m8 20 4-4 4 4" />
          </svg>;
      default:
        return <svg {...svgProps}>
            <rect x="3" y="3" width="18" height="18" rx="2" />
          </svg>;
    }
  };
  const Chip = ({value, variant}) => <span className={`kb-gates-chip kb-gates-chip--${variant}`}>
      <span className="kb-gates-chip-value">{value}</span>
    </span>;
  return <div className="kb-gates">
      <div className="kb-gates-header">
        <span className="kb-gates-eyebrow">{title}</span>
        {terminal && <span className="kb-gates-station">
            <span className="kb-gates-station-terminal">{terminal}</span>
          </span>}
      </div>
      <div className="kb-gates-list">
        {sections.map((section, i) => {
    const k = typeKey(section.type);
    const operators = Array.isArray(section.operators) ? section.operators : [];
    const aircraft = Array.isArray(section.aircraft) ? section.aircraft : [];
    const gates = Array.isArray(section.gates) ? section.gates : [];
    return <div key={`g-${i}`} className="kb-gates-row" data-type={k}>
              <div className="kb-gates-type">
                <span className="kb-gates-type-icon" aria-hidden="true">
                  <TypeIcon k={k} />
                </span>
                <div className="kb-gates-type-text">
                  <div className="kb-gates-type-label">{section.label}</div>
                  {section.subLabel && <div className="kb-gates-type-note">{section.subLabel}</div>}
                </div>
              </div>
              <div className="kb-gates-body">
                {section.range && <div className="kb-gates-range">
                    <span className="kb-gates-range-value">{section.range}</span>
                  </div>}
                {gates.length > 0 && <div className="kb-gates-chips kb-gates-chips--gates">
                    {gates.map((g, j) => <Chip key={`gx-${i}-${j}`} value={g} variant="gate" />)}
                  </div>}
                {operators.length > 0 && <div className="kb-gates-meta">
                    <span className="kb-gates-meta-label">{mergedLabels.operators}</span>
                    <div className="kb-gates-chips">
                      {operators.map((o, j) => <Chip key={`op-${i}-${j}`} value={o} variant="operator" />)}
                    </div>
                  </div>}
                {aircraft.length > 0 && <div className="kb-gates-meta">
                    <span className="kb-gates-meta-label">{mergedLabels.aircraft}</span>
                    <div className="kb-gates-chips">
                      {aircraft.map((a, j) => <Chip key={`ac-${i}-${j}`} value={a} variant="aircraft" />)}
                    </div>
                  </div>}
                {section.notes && <div className="kb-gates-notes">{section.notes}</div>}
              </div>
            </div>;
  })}
      </div>
    </div>;
};

export const ChartsLinks = ({icao, title = "Charts", freeLabel = "Free"}) => {
  const code = (icao || "").toUpperCase().trim();
  const providers = [{
    name: "ChartFox",
    href: code ? `https://chartfox.org/${code}` : "https://chartfox.org",
    logoSrc: "https://chartfox.org/images/icons/180x180.png",
    badge: freeLabel
  }, {
    name: "Navigraph",
    href: code ? `https://charts.navigraph.com/airport/${code}` : "https://charts.navigraph.com",
    logoSrc: "https://charts.navigraph.com/favicon.ico"
  }];
  return <div className="kb-charts-links">
      <div className="kb-charts-links-header">
        <span className="kb-charts-links-eyebrow">{title}</span>
      </div>
      <div className="kb-charts-links-grid">
        {providers.map(({name, href, logoSrc, badge}) => <a key={name} className="kb-charts-link" href={href} target="_blank" rel="noopener noreferrer">
            <span className="kb-charts-link-logo" aria-hidden="true">
              <img src={logoSrc} alt="" width="20" height="20" loading="lazy" />
            </span>
            <span className="kb-charts-link-name">{name}</span>
            {badge && <span className="kb-charts-link-badge">{badge}</span>}
            <span className="kb-charts-link-chevron" aria-hidden="true">
              <svg width="14" height="14" viewBox="0 0 14 14" fill="none">
                <path d="M5 3L9 7L5 11" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
              </svg>
            </span>
          </a>)}
      </div>
    </div>;
};

export const NetworkLinks = ({icao, title = "Online ATC Network Maps"}) => {
  const code = (icao || "").toUpperCase();
  const networks = [{
    name: "VATSIM",
    href: `https://vatsim-radar.com/airport/${code}`,
    logoSrc: "/images/networks/vatsim.png"
  }, {
    name: "IVAO",
    href: `https://webeye.ivao.aero/?airportId=${code}`,
    logoSrc: "/images/networks/ivao.svg"
  }, {
    name: "POSCON",
    href: "https://hq.poscon.net/map",
    logoSrc: "/images/networks/poscon.ico"
  }, {
    name: "PilotEdge",
    href: "http://map.pilotedge.net/map/",
    logoSrc: "/images/networks/pilotedge.ico"
  }];
  return <div className="kb-network-links">
      <div className="kb-network-links-header">
        <span className="kb-network-links-eyebrow">{title}</span>
      </div>
      <div className="kb-network-links-grid">
        {networks.map(({name, href, logoSrc}) => <a key={name} className="kb-network-link" href={href} target="_blank" rel="noopener noreferrer">
            <span className="kb-network-link-logo" aria-hidden="true">
              <img src={logoSrc} alt="" width="20" height="20" loading="lazy" />
            </span>
            <span className="kb-network-link-name">{name}</span>
            <span className="kb-network-link-chevron" aria-hidden="true">
              <svg width="14" height="14" viewBox="0 0 14 14" fill="none">
                <path d="M5 3L9 7L5 11" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
              </svg>
            </span>
          </a>)}
      </div>
    </div>;
};

export const AtcFrequencies = ({frequencies = [], title = "ATC Frequencies"}) => {
  const roleKey = role => {
    const r = (role || "").toLowerCase();
    if (r.includes("atis")) return "atis";
    if (r.includes("clearance") || r.includes("autorisation") || r.includes("clnc")) return "clearance";
    if (r.includes("sol") || r.includes("ground")) return "ground";
    if (r.includes("tower") || r.includes("tour")) return "tower";
    if (r.includes("départ") || r.includes("depart")) return "departure";
    if (r.includes("arrivée") || r.includes("arrival")) return "arrival";
    if (r.includes("terminal") || r.includes("approach") || r.includes("approche")) return "terminal";
    if (r.includes("center") || r.includes("centre") || r.includes("enroute")) return "center";
    if (r.includes("vfr") || r.includes("advisory") || r.includes("conseil")) return "advisory";
    return "default";
  };
  const RoleIcon = ({k}) => {
    const stroke = {
      stroke: "currentColor",
      strokeWidth: 1.4,
      strokeLinecap: "round",
      strokeLinejoin: "round",
      fill: "none"
    };
    switch (k) {
      case "atis":
        return <svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true">
            <path d="M3 7v2M5.5 5.5v5M8 4v8" {...stroke} />
            <path d="M10.5 6.5C11.6 7 12 7.5 12 8s-0.4 1-1.5 1.5" {...stroke} />
            <path d="M12 5C13.7 5.7 14.5 6.7 14.5 8s-0.8 2.3-2.5 3" {...stroke} />
          </svg>;
      case "clearance":
        return <svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true">
            <rect x="3.5" y="2.5" width="9" height="11" rx="1" {...stroke} />
            <path d="M6 6h4M6 8.5h4M6 11h2.5" {...stroke} />
            <path d="M9.5 11l1 1 2-2.2" {...stroke} stroke="#10b981" strokeWidth="1.5" />
          </svg>;
      case "ground":
        return <svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true">
            <path d="M2.5 12.5h11" {...stroke} />
            <path d="M5 9.5L8 12.5L11 9.5" {...stroke} />
            <path d="M8 4v8.5" {...stroke} />
            <circle cx="3" cy="12.5" r="0.5" fill="currentColor" />
            <circle cx="13" cy="12.5" r="0.5" fill="currentColor" />
          </svg>;
      case "tower":
        return <svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true">
            <path d="M6 13.5V8l-1.2-1V5.5L8 4l3.2 1.5V7L10 8v5.5" {...stroke} />
            <path d="M4.5 13.5h7" {...stroke} />
            <path d="M8 4V2.5" {...stroke} />
            <path d="M6 6.5h4" {...stroke} />
            <path d="M2.5 3.5l1 -1M13.5 3.5l-1 -1" {...stroke} />
          </svg>;
      case "departure":
        return <svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true">
            <path d="M2.5 13.5h11" {...stroke} />
            <path d="M2.5 11l9-4.5 c1.4-0.7 2.5 0.4 1.8 1.8L8 12 M5.5 10.2L4.2 8" {...stroke} />
          </svg>;
      case "arrival":
        return <svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true">
            <path d="M2.5 13.5h11" {...stroke} />
            <path d="M13 11.5L4 7c-1.4-0.7-2.5 0.4-1.8 1.8L7.5 12.5z" {...stroke} />
          </svg>;
      case "terminal":
        return <svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true">
            <path d="M8 13.5V8" {...stroke} />
            <path d="M3 11.5a6 6 0 0 1 10 0" {...stroke} />
            <path d="M5 9a4 4 0 0 1 6 0" {...stroke} />
            <circle cx="8" cy="8" r="0.8" fill="currentColor" />
          </svg>;
      case "center":
        return <svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true">
            <circle cx="8" cy="8" r="5.5" {...stroke} />
            <path d="M8 2.5L9 8L8 13.5L7 8Z" fill="currentColor" />
            <circle cx="8" cy="8" r="0.8" fill="currentColor" />
          </svg>;
      case "advisory":
        return <svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true">
            <circle cx="8" cy="8" r="5.5" {...stroke} />
            <path d="M8 5v3.5M8 11h0.01" {...stroke} />
          </svg>;
      default:
        return <svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true">
            <circle cx="8" cy="8" r="5.5" {...stroke} />
          </svg>;
    }
  };
  return <div className="kb-atc-freqs">
      <div className="kb-atc-freqs-header">
        <span className="kb-atc-freqs-eyebrow">{title}</span>
      </div>
      <div className="kb-atc-freqs-list">
        {frequencies.map((row, i) => {
    const k = roleKey(row.role);
    const freqs = (row.freqs || []).map(f => typeof f === "string" ? {
      value: f
    } : f);
    return <div key={`r-${i}`} className="kb-atc-freq-row" data-role={k}>
              <div className="kb-atc-freq-role">
                <span className="kb-atc-freq-role-icon" aria-hidden="true">
                  <RoleIcon k={k} />
                </span>
                <div className="kb-atc-freq-role-text">
                  <div className="kb-atc-freq-role-label">{row.role}</div>
                  {row.subLabel && <div className="kb-atc-freq-role-note">{row.subLabel}</div>}
                </div>
              </div>
              <div className="kb-atc-freq-chips">
                {freqs.map((f, j) => <div key={`${i}-${j}-${f.value}`} className="kb-atc-freq-chip">
                    <span className="kb-atc-freq-chip-value">{f.value}</span>
                    {f.note && <span className="kb-atc-freq-chip-note">{f.note}</span>}
                  </div>)}
              </div>
            </div>;
  })}
      </div>
    </div>;
};

export const Runways = ({runways = [], magneticVariation, title = "Runways", labels = {}}) => {
  const ilsClass = cat => {
    if (!cat) return "kb-runway-pill--no-ils";
    const c = String(cat).toUpperCase();
    if (c.includes("III")) return "kb-runway-pill--cat3";
    if (c.includes("II")) return "kb-runway-pill--cat2";
    return "kb-runway-pill--cat1";
  };
  const fmt = n => typeof n === "number" ? n.toLocaleString("en-US") : n;
  const DEFAULT_LABELS = {
    length: "Length",
    width: "Width",
    surface: "Surface",
    lighting: "Lighting",
    noIls: "None",
    ilsPrefix: "ILS",
    magvar: "Magnetic variation"
  };
  const mergedLabels = {
    ...DEFAULT_LABELS,
    ...labels
  };
  return <div className="kb-runways">
      <div className="kb-runways-header">
        <span className="kb-runways-eyebrow">{title}</span>
        {magneticVariation && <span className="kb-runways-variation">
            <svg width="11" height="11" viewBox="0 0 12 12" fill="none" aria-hidden="true">
              <circle cx="6" cy="6" r="5" stroke="currentColor" strokeWidth="1" opacity="0.6" />
              <path d="M6 1.5L6 4M6 10.5L6 8" stroke="currentColor" strokeWidth="1" strokeLinecap="round" opacity="0.6" />
              <path d="M6 3L4 7H8L6 3Z" fill="currentColor" />
            </svg>
            {mergedLabels.magvar} {magneticVariation}
          </span>}
      </div>

      <div className="kb-runways-list">
        {runways.map((runway, index) => {
    const [d1, d2] = runway.designators;
    const ilsEntries = runway.ils ? Object.entries(runway.ils) : [];
    return <div key={`${d1}-${d2}`} className="kb-runway" style={{
      "--i": index
    }}>
              <div className="kb-runway-strip">
                <svg viewBox="0 0 1200 150" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet" role="img" aria-label={`Runway ${d1}/${d2}`}>
                  <rect x="0" y="20" width="1200" height="110" rx="5" className="kb-runway-body" />

                  {[0, 1, 2, 3, 4, 5].map(i => <rect key={`pl-${i}`} x={20 + i * 12} y="36" width="7" height="78" fill="white" opacity="0.92" rx="1" />)}
                  {[0, 1, 2, 3, 4, 5].map(i => <rect key={`pr-${i}`} x={1180 - i * 12 - 7} y="36" width="7" height="78" fill="white" opacity="0.92" rx="1" />)}

                  {[...Array(18)].map((_, i) => <rect key={`cl-${i}`} x={240 + i * 40} y="71" width="24" height="8" fill="white" opacity="0.88" rx="1" />)}

                  <text x="142" y="75" fontSize="46" fontWeight="700" fill="white" textAnchor="middle" dominantBaseline="middle" style={{
      fontFamily: "ui-monospace, 'SF Mono', 'Geist Mono', Menlo, monospace",
      letterSpacing: "1.5px"
    }}>
                    {d1}
                  </text>
                  <text x="1058" y="75" fontSize="46" fontWeight="700" fill="white" textAnchor="middle" dominantBaseline="middle" style={{
      fontFamily: "ui-monospace, 'SF Mono', 'Geist Mono', Menlo, monospace",
      letterSpacing: "1.5px"
    }}>
                    {d2}
                  </text>
                </svg>
              </div>

              <div className="kb-runway-scale">
                <span>{fmt(runway.length_ft)} ft</span>
                <span>{fmt(runway.length_m)} m</span>
              </div>

              <div className="kb-runway-meta">
                <span className="kb-runway-pill">
                  <span className="kb-runway-pill-label">{mergedLabels.length}</span>
                  <span className="kb-runway-pill-value">
                    {fmt(runway.length_ft)} ft · {fmt(runway.length_m)} m
                  </span>
                </span>
                {runway.width_ft && <span className="kb-runway-pill">
                    <span className="kb-runway-pill-label">{mergedLabels.width}</span>
                    <span className="kb-runway-pill-value">{runway.width_ft} ft</span>
                  </span>}
                {runway.surface && <span className="kb-runway-pill">
                    <span className="kb-runway-pill-label">{mergedLabels.surface}</span>
                    <span className="kb-runway-pill-value">{runway.surface}</span>
                  </span>}
                {ilsEntries.length > 0 ? ilsEntries.map(([threshold, cat]) => <span key={threshold} className={`kb-runway-pill ${ilsClass(cat)}`}>
                      <span className="kb-runway-pill-label">{threshold}</span>
                      <span className="kb-runway-pill-value">
                        {cat ? `${mergedLabels.ilsPrefix} ${cat}` : mergedLabels.noIls}
                      </span>
                    </span>) : <span className="kb-runway-pill kb-runway-pill--no-ils">
                    <span className="kb-runway-pill-label">{mergedLabels.ilsPrefix}</span>
                    <span className="kb-runway-pill-value">{mergedLabels.noIls}</span>
                  </span>}
                {runway.lighting && <span className="kb-runway-pill">
                    <span className="kb-runway-pill-label">{mergedLabels.lighting}</span>
                    <span className="kb-runway-pill-value">{runway.lighting}</span>
                  </span>}
              </div>

              {runway.notes && <div className="kb-runway-notes">{runway.notes}</div>}
            </div>;
  })}
      </div>
    </div>;
};

export const VatsimAtis = ({icao, name, runways = [], magneticVariation, title = "VATSIM ATIS", loadingLabel = "Tuning broadcast", offlineLabel = "No live VATSIM ATIS for this airport right now", errorLabel = "Unable to reach VATSIM data feed", refreshLabel = "Refresh", infoLabel = "Info", frequencyLabel = "Frequency", observedLabel = "Updated", agoLabel = "min ago", justNowLabel = "just now", windLabel = "Wind", altimeterLabel = "Altimeter", approachLabel = "Approach", transcriptLabel = "Transcript", runwaysInUseLabel = "Runways in use", notUsedLabel = "Not in use", depLabel = "DEP", arrLabel = "ARR", bearingLabel = "Bearing", runwayLabel = "Runway", calmLabel = "Calm", variableLabel = "Variable", gustingLabel = "gusting", fromLabel = "from", atLabel = "at", arrivalsBroadcastLabel = "Arrivals", departuresBroadcastLabel = "Departures"}) => {
  const code = (icao || "").toUpperCase();
  const [data, setData] = useState(null);
  const [metarWind, setMetarWind] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  const [refreshing, setRefreshing] = useState(false);
  const [nowTick, setNowTick] = useState(Date.now());
  const parseMagVar = mv => {
    if (!mv) return 0;
    const m = String(mv).match(/(\d+(?:\.\d+)?)\s*°?\s*([EW])/i);
    if (!m) return 0;
    return parseFloat(m[1]) * (m[2].toUpperCase() === "E" ? 1 : -1);
  };
  const magVarDeg = parseMagVar(magneticVariation);
  const parseEmbeddedMetar = text => {
    if (!text) return {
      wind: null,
      altimeter: null
    };
    const ups = text.toUpperCase();
    let wind = null;
    const wm = ups.match(/\b(VRB|\d{3})(\d{2,3})(?:G(\d{2,3}))?\s*(KT|MPS|KMH)\b/);
    if (wm) {
      wind = {
        dir: wm[1] === "VRB" ? "VRB" : +wm[1],
        speed: +wm[2],
        gust: wm[3] ? +wm[3] : null,
        unit: wm[4]
      };
    } else if ((/\b00000(KT|MPS|KMH)\b/).test(ups)) {
      wind = {
        calm: true
      };
    }
    let altimeter = null;
    const altA = ups.match(/\bA(\d{4})\b/);
    const altQ = ups.match(/\bQ(\d{4})\b/);
    if (altA) altimeter = {
      value: +altA[1] / 100,
      unit: "inHg"
    }; else if (altQ) altimeter = {
      value: +altQ[1],
      unit: "hPa"
    };
    return {
      wind,
      altimeter
    };
  };
  const parseRunwaysInUse = text => {
    if (!text) return {
      departures: [],
      arrivals: [],
      approachType: null
    };
    const ups = text.toUpperCase();
    const deps = new Set();
    const arrs = new Set();
    let approachType = null;
    const clauses = ups.split(/(?:\.{1,}|;|\s-\s)/);
    const runwayInClauseRe = /\b(?:RUNWAYS|RUNWAY|RWYS|RWY)\s*((?:\d{1,2}\s*(?:LEFT|RIGHT|CENTER|CENTRE|L|R|C)?)(?:\s*(?:,|\/|AND|&)\s*\d{1,2}\s*(?:LEFT|RIGHT|CENTER|CENTRE|L|R|C)?)*)/g;
    for (const clause of clauses) {
      if ((/\b(OTS|U\/S|UNSERVICEABLE|OUT\s+OF\s+SERVICE|CLOSED)\b/).test(clause)) continue;
      const isDep = (/\b(DEPARTURE|DEPARTURES|DEPARTING|DEPTG|DEPG|TAKEOFF|TKOF|TAKE\s*OFF|DEPS|FOR\s+DEP)\b/).test(clause);
      const isArr = (/\b(APPROACH|APPROACHES|APCH|APCHS|APPR|ARRIVAL|ARRIVALS|ARRIVING|LANDING|LDG|FOR\s+LANDING|FOR\s+ARRIVAL)\b/).test(clause);
      const inUse = (/\b(IN\s+USE|ACTIVE)\b/).test(clause);
      if (!isDep && !isArr && !inUse) continue;
      const ap = clause.match(/\b(ILS|RNAV|VOR|VISUAL|LOC|RNP|GPS)\b/);
      if (ap && !approachType) approachType = ap[1];
      for (const m of clause.matchAll(runwayInClauseRe)) {
        const normalized = m[1].replace(/\s*LEFT\b/g, "L").replace(/\s*RIGHT\b/g, "R").replace(/\s*(?:CENTER|CENTRE)\b/g, "C").split(/[,\s\/&]+|AND/).filter(Boolean);
        for (const d of normalized) {
          const mm = d.match(/^(\d{1,2})([LRC])?$/);
          if (!mm) continue;
          const num = parseInt(mm[1], 10);
          if (num < 1 || num > 36) continue;
          const norm = String(num).padStart(2, "0") + (mm[2] || "");
          if (isDep) deps.add(norm);
          if (isArr) arrs.add(norm);
          if (!isDep && !isArr && inUse) {
            deps.add(norm);
            arrs.add(norm);
          }
        }
      }
    }
    return {
      departures: Array.from(deps),
      arrivals: Array.from(arrs),
      approachType
    };
  };
  const windComponent = (windDirTrue, windSpeed, runwayBearingMag) => {
    if (windDirTrue == null || windSpeed == null) return null;
    const windDirMag = ((windDirTrue - magVarDeg) % 360 + 360) % 360;
    let angle = windDirMag - runwayBearingMag;
    while (angle > 180) angle -= 360;
    while (angle < -180) angle += 360;
    const rad = angle * Math.PI / 180;
    return {
      headwind: windSpeed * Math.cos(rad),
      crosswind: windSpeed * Math.sin(rad),
      relAngle: angle,
      speed: windSpeed
    };
  };
  const runwayEnds = [];
  runways.forEach(r => {
    const [d1, d2] = r.designators || [];
    const bearings = r.bearings || [];
    if (d1) {
      const b = bearings[0] != null ? bearings[0] : (parseInt(d1, 10) || 0) * 10;
      runwayEnds.push({
        designator: d1,
        bearing: b
      });
    }
    if (d2) {
      const b = bearings[1] != null ? bearings[1] : (parseInt(d2, 10) || 0) * 10;
      runwayEnds.push({
        designator: d2,
        bearing: b
      });
    }
  });
  const fetchAtis = async isUserRefresh => {
    try {
      if (isUserRefresh) setRefreshing(true); else setLoading(true);
      setError(null);
      const res = await fetch("https://data.vatsim.net/v3/vatsim-data.json", {
        cache: "no-store"
      });
      if (!res.ok) throw new Error("http");
      const json = await res.json();
      const all = Array.isArray(json.atis) ? json.atis : [];
      const re = new RegExp(`^${code}(?:_[AD])?_ATIS$`, "i");
      const matches = all.filter(a => re.test(a.callsign || "")).map(a => {
        const text = Array.isArray(a.text_atis) ? a.text_atis.join(" ") : "";
        const cs = (a.callsign || "").toUpperCase();
        const split = cs.includes("_A_ATIS") ? "arrival" : cs.includes("_D_ATIS") ? "departure" : null;
        return {
          callsign: a.callsign,
          atisCode: a.atis_code || "",
          frequency: a.frequency,
          text,
          lastUpdated: a.last_updated,
          split,
          ...parseEmbeddedMetar(text),
          ...parseRunwaysInUse(text)
        };
      });
      const merged = matches.reduce((acc, m) => {
        m.departures.forEach(r => acc.departures.add(r));
        m.arrivals.forEach(r => acc.arrivals.add(r));
        return acc;
      }, {
        departures: new Set(),
        arrivals: new Set()
      });
      setData({
        broadcasts: matches,
        departures: Array.from(merged.departures),
        arrivals: Array.from(merged.arrivals)
      });
    } catch (e) {
      setError("fetch");
    } finally {
      setLoading(false);
      if (isUserRefresh) {
        setTimeout(() => setRefreshing(false), 600);
      }
    }
  };
  useEffect(() => {
    if (!code) return;
    fetchAtis(false);
    const interval = setInterval(() => fetchAtis(false), 60 * 1000);
    return () => clearInterval(interval);
  }, [code]);
  useEffect(() => {
    if (!code) return;
    let cancelled = false;
    const fetchMetar = async () => {
      try {
        const res = await fetch(`https://metar.vatsim.net/${code}`, {
          cache: "no-store"
        });
        if (!res.ok) return;
        const text = (await res.text()).trim();
        if (cancelled || !text) return;
        const parsed = parseEmbeddedMetar(text);
        setMetarWind(parsed.wind || null);
      } catch (_) {}
    };
    fetchMetar();
    const interval = setInterval(fetchMetar, 5 * 60 * 1000);
    return () => {
      cancelled = true;
      clearInterval(interval);
    };
  }, [code]);
  useEffect(() => {
    const tick = setInterval(() => setNowTick(Date.now()), 30000);
    return () => clearInterval(tick);
  }, []);
  const primary = data?.broadcasts?.[0] || null;
  const wind = primary?.wind || metarWind || null;
  const altimeter = primary?.altimeter || null;
  const departures = data?.departures || [];
  const arrivals = data?.arrivals || [];
  const isOnline = data && data.broadcasts && data.broadcasts.length > 0;
  const mostRecentUpdate = isOnline ? data.broadcasts.map(b => b.lastUpdated).filter(Boolean).sort().pop() : null;
  const formatFreq = f => {
    if (!f) return "---.---";
    const n = parseFloat(f);
    if (Number.isNaN(n)) return f;
    return n.toFixed(3);
  };
  const formatWind = w => {
    if (!w) return "—";
    if (w.calm) return calmLabel;
    const dir = w.dir === "VRB" ? variableLabel : `${String(w.dir).padStart(3, "0")}°`;
    let s = `${fromLabel} ${dir} ${atLabel} ${w.speed} kt`;
    if (w.gust) s += `, ${gustingLabel} ${w.gust} kt`;
    return s;
  };
  const formatAltimeter = a => {
    if (!a) return "—";
    if (a.unit === "inHg") {
      return `${a.value.toFixed(2)} inHg · ${Math.round(a.value * 33.8639)} hPa`;
    }
    return `${a.value} hPa · ${(a.value / 33.8639).toFixed(2)} inHg`;
  };
  const ageLabel = iso => {
    if (!iso) return null;
    const t = Date.parse(iso);
    if (Number.isNaN(t)) return null;
    const diffMin = Math.max(0, Math.round((nowTick - t) / 60000));
    if (diffMin === 0) return justNowLabel;
    return `${diffMin} ${agoLabel}`;
  };
  const endsVm = runwayEnds.map(end => {
    const desig = end.designator.toUpperCase();
    const isDep = departures.includes(desig);
    const isArr = arrivals.includes(desig);
    const status = !isOnline ? "unknown" : isDep || isArr ? "active" : "not_used";
    let comp = null;
    if (wind && !wind.calm && typeof wind.dir === "number") {
      const speed = wind.gust || wind.speed;
      comp = windComponent(wind.dir, speed, end.bearing);
    }
    return {
      ...end,
      desig,
      isDep,
      isArr,
      status,
      comp
    };
  });
  const renderRunwayRow = (vm, index) => {
    const isUnknown = vm.status === "unknown";
    const isNotUsed = vm.status === "not_used";
    let windChips = null;
    if (vm.comp) {
      const speed = Math.round(vm.comp.speed);
      const hw = Math.round(vm.comp.headwind);
      const cw = Math.round(vm.comp.crosswind);
      const hwAbs = Math.abs(hw);
      const cwAbs = Math.abs(cw);
      let crossLevel = "green";
      if (cwAbs > 25) crossLevel = "red"; else if (cwAbs > 15) crossLevel = "amber";
      const longitudinalLevel = hw < 0 ? "red" : "green";
      const crossArrowPath = cw >= 0 ? "M10 6 H3 M6 3 L3 6 L6 9" : "M2 6 H9 M6 3 L9 6 L6 9";
      const longArrowPath = hw >= 0 ? "M6 2 V9 M3 6 L6 9 L9 6" : "M6 10 V3 M3 6 L6 3 L9 6";
      const cwSideLabel = cw >= 0 ? "from the right" : "from the left";
      const longTypeLabel = hw >= 0 ? "headwind" : "tailwind";
      const title = `RWY ${vm.desig} (bearing ${String(Math.round(vm.bearing)).padStart(3, "0")}°): ${cwAbs} kt crosswind ${cwSideLabel}, ${hwAbs} kt ${longTypeLabel}. Total wind ${speed} kt.`;
      windChips = <span className="kb-vatsim-atis-wind-chip" title={title}>
          <span className={`kb-vatsim-atis-wind-row kb-vatsim-atis-wind-row--${crossLevel}`}>
            <svg width="10" height="10" viewBox="0 0 12 12" aria-hidden="true">
              <path d={crossArrowPath} stroke="currentColor" strokeWidth="1.6" fill="none" strokeLinecap="round" strokeLinejoin="round" />
            </svg>
            <span>{cwAbs} kt</span>
          </span>
          <span className={`kb-vatsim-atis-wind-row kb-vatsim-atis-wind-row--${longitudinalLevel}`}>
            <svg width="10" height="10" viewBox="0 0 12 12" aria-hidden="true">
              <path d={longArrowPath} stroke="currentColor" strokeWidth="1.6" fill="none" strokeLinecap="round" strokeLinejoin="round" />
            </svg>
            <span>{hwAbs} kt</span>
          </span>
        </span>;
    }
    return <div key={`${vm.desig}-${index}`} className={`kb-vatsim-atis-rwy kb-vatsim-atis-rwy--${vm.status}`} style={{
      "--i": index
    }}>
        <div className="kb-vatsim-atis-rwy-label">
          <span className="kb-vatsim-atis-rwy-label-eyebrow">{runwayLabel}</span>
          <span className="kb-vatsim-atis-rwy-designator">{vm.desig}</span>
        </div>

        <div className="kb-vatsim-atis-rwy-wind">
          {windChips || <span className="kb-vatsim-atis-rwy-wind-empty">—</span>}
        </div>

        <div className="kb-vatsim-atis-rwy-usage">
          {isNotUsed ? <span className="kb-vatsim-atis-usage-pill kb-vatsim-atis-usage-pill--off">
              {notUsedLabel}
            </span> : isUnknown ? <span className="kb-vatsim-atis-usage-pill kb-vatsim-atis-usage-pill--off">—</span> : <span className="kb-vatsim-atis-usage-group">
              <span className={`kb-vatsim-atis-usage-pill${vm.isDep ? "" : " kb-vatsim-atis-usage-pill--dim"}`}>
                {depLabel}
              </span>
              <span className={`kb-vatsim-atis-usage-pill${vm.isArr ? "" : " kb-vatsim-atis-usage-pill--dim"}`}>
                {arrLabel}
              </span>
            </span>}
        </div>

        <div className="kb-vatsim-atis-rwy-bearing">
          <span className="kb-vatsim-atis-rwy-bearing-label">{bearingLabel}</span>
          <span className="kb-vatsim-atis-rwy-bearing-value">
            {String(Math.round(vm.bearing)).padStart(3, "0")}°
          </span>
        </div>
      </div>;
  };
  const renderBroadcast = (b, idx) => <div className="kb-vatsim-atis-broadcast" key={`b-${idx}`}>
      <div className="kb-vatsim-atis-broadcast-head">
        <div className="kb-vatsim-atis-info-letter" aria-label={`${infoLabel} ${b.atisCode || "—"}`}>
          {b.atisCode || "—"}
        </div>
        <div className="kb-vatsim-atis-broadcast-meta">
          <div className="kb-vatsim-atis-broadcast-callsign">
            {b.callsign}
            {b.split && <span className="kb-vatsim-atis-split-tag">
                {b.split === "arrival" ? arrivalsBroadcastLabel : departuresBroadcastLabel}
              </span>}
          </div>
          <div className="kb-vatsim-atis-broadcast-freq">
            <span className="kb-vatsim-atis-broadcast-freq-label">{frequencyLabel}</span>
            <span className="kb-vatsim-atis-broadcast-freq-value">{formatFreq(b.frequency)}</span>
          </div>
        </div>
      </div>

      {b.text && <div className="kb-vatsim-atis-transcript-wrap">
          <span className="kb-vatsim-atis-transcript-label">{transcriptLabel}</span>
          <pre className="kb-vatsim-atis-transcript">{b.text}</pre>
        </div>}
    </div>;
  return <div className="kb-vatsim-atis">
      <div className="kb-vatsim-atis-header">
        <span className="kb-vatsim-atis-eyebrow">
          <span className={`kb-vatsim-atis-dot${isOnline ? " kb-vatsim-atis-dot--on" : ""}`} aria-hidden="true" />
          {title}
        </span>
        <span className="kb-vatsim-atis-station">
          <span className="kb-vatsim-atis-station-icao">{code}</span>
          {name && <span className="kb-vatsim-atis-station-name">{name}</span>}
        </span>
      </div>

      {loading && <div className="kb-vatsim-atis-loading">
          <div className="kb-vatsim-atis-skeleton kb-vatsim-atis-skeleton--long" />
          <div className="kb-vatsim-atis-skeleton kb-vatsim-atis-skeleton--short" />
          <div className="kb-vatsim-atis-meta-status">{loadingLabel}</div>
        </div>}

      {!loading && error && <div className="kb-vatsim-atis-message">{errorLabel}</div>}

      {!loading && !error && !isOnline && <div className="kb-vatsim-atis-message">{offlineLabel}</div>}

      {!loading && !error && isOnline && <div className="kb-vatsim-atis-broadcasts">
          {data.broadcasts.map(renderBroadcast)}
        </div>}

      {!loading && !error && isOnline && (wind || altimeter || primary?.approachType) && <div className="kb-vatsim-atis-decoded">
          {wind && <div className="kb-vatsim-atis-stat">
              <span className="kb-vatsim-atis-stat-label">{windLabel}</span>
              <span className="kb-vatsim-atis-stat-value">
                {typeof wind.dir === "number" && <svg className="kb-vatsim-atis-wind-needle" width="11" height="11" viewBox="0 0 12 12" aria-hidden="true" style={{
    transform: `rotate(${wind.dir}deg)`
  }}>
                    <path d="M6 1.2 L9 7.2 L6 5.7 L3 7.2 Z" fill="currentColor" />
                    <line x1="6" y1="5.7" x2="6" y2="10.8" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" />
                  </svg>}
                {formatWind(wind)}
              </span>
            </div>}
          {altimeter && <div className="kb-vatsim-atis-stat">
              <span className="kb-vatsim-atis-stat-label">{altimeterLabel}</span>
              <span className="kb-vatsim-atis-stat-value">{formatAltimeter(altimeter)}</span>
            </div>}
          {primary?.approachType && <div className="kb-vatsim-atis-stat">
              <span className="kb-vatsim-atis-stat-label">{approachLabel}</span>
              <span className="kb-vatsim-atis-stat-value">{primary.approachType}</span>
            </div>}
        </div>}

      {endsVm.length > 0 && <div className="kb-vatsim-atis-runways-section">
          <div className="kb-vatsim-atis-runways-head">
            <span className="kb-vatsim-atis-runways-eyebrow">{runwaysInUseLabel}</span>
            {magneticVariation && <span className="kb-vatsim-atis-magvar">MAG VAR {magneticVariation}</span>}
          </div>
          <div className="kb-vatsim-atis-runways">{endsVm.map(renderRunwayRow)}</div>
        </div>}

      <div className="kb-vatsim-atis-meta">
        {mostRecentUpdate && <span className="kb-vatsim-atis-meta-time">
            {observedLabel} {ageLabel(mostRecentUpdate)}
          </span>}
        <button type="button" className={"kb-vatsim-atis-refresh" + (refreshing ? " refreshing" : "")} onClick={() => fetchAtis(true)} aria-label={refreshLabel} title={refreshLabel}>
          <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
            <path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
            <path d="M21 3v5h-5" />
            <path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
            <path d="M3 21v-5h5" />
          </svg>
        </button>
      </div>
    </div>;
};

export const LiveMetar = ({icao, name, decoderUrl, loadingLabel = "Fetching latest observation", errorLabel = "Unable to fetch live METAR. Try the visual decoder.", refreshLabel = "Refresh", decoderLabel = "Metar & Taf Visual decoder", observedLabel = "Observed", agoLabel = "min ago", justNowLabel = "just now", windLabel = "Wind", visibilityLabel = "Visibility", weatherLabel = "Weather", cloudsLabel = "Clouds", tempDewLabel = "Temp · Dew", altimeterLabel = "Altimeter", calmLabel = "Calm", variableLabel = "Variable", gustingLabel = "gusting", fromLabel = "from", atLabel = "at"}) => {
  const code = (icao || "").toUpperCase();
  const [raw, setRaw] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  const [refreshing, setRefreshing] = useState(false);
  const [nowTick, setNowTick] = useState(Date.now());
  const WX_CODES = {
    MI: "shallow",
    PR: "partial",
    BC: "patches",
    DR: "drifting",
    BL: "blowing",
    SH: "showers",
    TS: "thunderstorm",
    FZ: "freezing",
    DZ: "drizzle",
    RA: "rain",
    SN: "snow",
    SG: "snow grains",
    IC: "ice crystals",
    PL: "ice pellets",
    GR: "hail",
    GS: "small hail",
    UP: "unknown precip",
    BR: "mist",
    FG: "fog",
    FU: "smoke",
    VA: "volcanic ash",
    DU: "dust",
    SA: "sand",
    HZ: "haze",
    PY: "spray",
    PO: "dust whirls",
    SQ: "squalls",
    FC: "funnel cloud",
    SS: "sandstorm",
    DS: "duststorm"
  };
  const CLOUD_LABELS = {
    FEW: "Few",
    SCT: "Scattered",
    BKN: "Broken",
    OVC: "Overcast",
    CLR: "Clear",
    SKC: "Sky clear",
    NCD: "No clouds",
    NSC: "No significant clouds",
    VV: "Vertical visibility"
  };
  const parseMetar = input => {
    if (!input || typeof input !== "string") return null;
    const beforeRmk = input.split(/\s+RMK\b/)[0].trim();
    if (beforeRmk.length < 10) return null;
    const tokens = beforeRmk.split(/\s+/);
    let i = 0;
    if (tokens[i] === "METAR" || tokens[i] === "SPECI") i++;
    let station = null;
    if ((/^[A-Z]{4}$/).test(tokens[i] || "")) station = tokens[i++];
    let time = null;
    const tm = (tokens[i] || "").match(/^(\d{2})(\d{2})(\d{2})Z$/);
    if (tm) {
      time = {
        day: +tm[1],
        hour: +tm[2],
        minute: +tm[3]
      };
      i++;
    }
    while (tokens[i] === "AUTO" || tokens[i] === "COR" || tokens[i] === "NIL") i++;
    let wind = null;
    const wt = tokens[i] || "";
    if ((/^00000(KT|MPS|KMH)$/).test(wt)) {
      wind = {
        calm: true
      };
      i++;
    } else {
      const wm = wt.match(/^(VRB|\d{3})(\d{2,3})(?:G(\d{2,3}))?(KT|MPS|KMH)$/);
      if (wm) {
        wind = {
          dir: wm[1] === "VRB" ? "VRB" : +wm[1],
          speed: +wm[2],
          gust: wm[3] ? +wm[3] : null,
          unit: wm[4]
        };
        i++;
        if ((/^\d{3}V\d{3}$/).test(tokens[i] || "")) i++;
      }
    }
    let visibility = null;
    if (tokens[i] === "CAVOK") {
      visibility = {
        cavok: true
      };
      i++;
    } else if ((/^\d{4}$/).test(tokens[i] || "")) {
      visibility = {
        value: +tokens[i],
        unit: "m"
      };
      i++;
    } else if ((/^\d+$/).test(tokens[i] || "") && (/^\d+\/\d+SM$/).test(tokens[i + 1] || "")) {
      const whole = +tokens[i];
      const fm = tokens[i + 1].match(/^(\d+)\/(\d+)SM$/);
      visibility = {
        value: whole + +fm[1] / +fm[2],
        unit: "SM"
      };
      i += 2;
    } else {
      const sm = (tokens[i] || "").match(/^(M)?(\d+(?:\/\d+)?)SM$/);
      if (sm) {
        const v = sm[2].includes("/") ? +sm[2].split("/")[0] / +sm[2].split("/")[1] : +sm[2];
        visibility = {
          value: v,
          unit: "SM",
          lessThan: !!sm[1]
        };
        i++;
      }
    }
    const weather = [];
    const wxRe = /^(VC|\+|-)?((?:MI|PR|BC|DR|BL|SH|TS|FZ){0,2})((?:DZ|RA|SN|SG|IC|PL|GR|GS|UP|BR|FG|FU|VA|DU|SA|HZ|PY|PO|SQ|FC|SS|DS)+)$/;
    while (i < tokens.length && wxRe.test(tokens[i])) {
      weather.push(tokens[i]);
      i++;
    }
    const clouds = [];
    while (i < tokens.length) {
      const t = tokens[i];
      if (t === "CLR" || t === "SKC" || t === "NCD" || t === "NSC") {
        clouds.push({
          cover: t,
          base: null
        });
        i++;
      } else {
        const cm = t.match(/^(FEW|SCT|BKN|OVC)(\d{3})(CB|TCU)?$/);
        if (cm) {
          clouds.push({
            cover: cm[1],
            base: +cm[2] * 100,
            type: cm[3] || null
          });
          i++;
          continue;
        }
        const vvm = t.match(/^VV(\d{3}|\/\/\/)$/);
        if (vvm) {
          clouds.push({
            cover: "VV",
            base: vvm[1] === "///" ? null : +vvm[1] * 100
          });
          i++;
          continue;
        }
        break;
      }
    }
    let temp = null;
    let dewpoint = null;
    const td = (tokens[i] || "").match(/^(M)?(\d{2})\/(M)?(\d{2})$/);
    if (td) {
      temp = (td[1] ? -1 : 1) * +td[2];
      dewpoint = (td[3] ? -1 : 1) * +td[4];
      i++;
    }
    let altimeter = null;
    const altA = (tokens[i] || "").match(/^A(\d{4})$/);
    const altQ = (tokens[i] || "").match(/^Q(\d{4})$/);
    if (altA) {
      altimeter = {
        value: +altA[1] / 100,
        unit: "inHg"
      };
      i++;
    } else if (altQ) {
      altimeter = {
        value: +altQ[1],
        unit: "hPa"
      };
      i++;
    }
    const ceilingLayer = clouds.find(c => c.cover === "BKN" || c.cover === "OVC");
    const ceiling = ceilingLayer ? ceilingLayer.base : null;
    let visSM = Infinity;
    if (visibility?.cavok) visSM = 10; else if (visibility?.unit === "SM") visSM = visibility.value; else if (visibility?.unit === "m") visSM = visibility.value / 1609;
    let flightCategory = "VFR";
    if (ceiling !== null && ceiling < 500 || visSM < 1) flightCategory = "LIFR"; else if (ceiling !== null && ceiling < 1000 || visSM < 3) flightCategory = "IFR"; else if (ceiling !== null && ceiling < 3000 || visSM <= 5) flightCategory = "MVFR";
    return {
      station,
      time,
      wind,
      visibility,
      weather,
      clouds,
      temp,
      dewpoint,
      altimeter,
      flightCategory,
      raw: input
    };
  };
  const formatWind = w => {
    if (w.calm) return calmLabel;
    const dir = w.dir === "VRB" ? variableLabel : `${String(w.dir).padStart(3, "0")}°`;
    let s = `${fromLabel} ${dir} ${atLabel} ${w.speed} kt`;
    if (w.gust) s += `, ${gustingLabel} ${w.gust} kt`;
    return s;
  };
  const formatVisibility = v => {
    if (v.cavok) return "CAVOK";
    if (v.unit === "SM") {
      const prefix = v.lessThan ? "<" : "";
      const val = Number.isInteger(v.value) ? v.value : v.value.toFixed(1);
      return `${prefix}${val} SM`;
    }
    if (v.unit === "m") {
      if (v.value >= 9999) return "≥10 km";
      if (v.value >= 1000) return `${(v.value / 1000).toFixed(1)} km`;
      return `${v.value} m`;
    }
    return "";
  };
  const formatWeather = wxList => {
    return wxList.map(tok => {
      let intensity = "";
      let rest = tok;
      if (rest.startsWith("+")) {
        intensity = "Heavy ";
        rest = rest.slice(1);
      } else if (rest.startsWith("-")) {
        intensity = "Light ";
        rest = rest.slice(1);
      } else if (rest.startsWith("VC")) {
        intensity = "Vicinity ";
        rest = rest.slice(2);
      }
      const parts = [];
      for (let p = 0; p < rest.length; p += 2) {
        const w = WX_CODES[rest.substr(p, 2)];
        if (w) parts.push(w);
      }
      const phrase = (intensity + parts.join(" ")).trim();
      return phrase.charAt(0).toUpperCase() + phrase.slice(1);
    }).join(", ");
  };
  const formatClouds = cs => {
    return cs.map(c => {
      const lbl = CLOUD_LABELS[c.cover] || c.cover;
      if (c.base == null) return lbl;
      const ft = c.base.toLocaleString("en-US");
      const type = c.type === "CB" ? " (CB)" : c.type === "TCU" ? " (TCU)" : "";
      return `${lbl} ${ft} ft${type}`;
    }).join(", ");
  };
  const formatTempDew = (t, d) => {
    if (t == null) return null;
    const toF = c => Math.round(c * 9 / 5 + 32);
    let s = `${t}°C / ${toF(t)}°F`;
    if (d != null) s += ` · ${d}°C / ${toF(d)}°F`;
    return s;
  };
  const formatAltimeter = a => {
    if (a.unit === "inHg") {
      return `${a.value.toFixed(2)} inHg · ${Math.round(a.value * 33.8639)} hPa`;
    }
    return `${a.value} hPa · ${(a.value / 33.8639).toFixed(2)} inHg`;
  };
  const fetchMetar = async isUserRefresh => {
    try {
      if (isUserRefresh) setRefreshing(true); else setLoading(true);
      setError(null);
      const res = await fetch(`https://metar.vatsim.net/${code}`, {
        cache: "no-store"
      });
      if (!res.ok) throw new Error("http");
      const text = (await res.text()).trim();
      if (!text || !(/[A-Z]{4}\s+\d{6}Z/).test(text)) {
        setRaw(null);
        setError("nodata");
      } else {
        setRaw(text);
      }
    } catch (e) {
      setError("fetch");
    } finally {
      setLoading(false);
      if (isUserRefresh) {
        setTimeout(() => setRefreshing(false), 600);
      }
    }
  };
  useEffect(() => {
    if (!code) return;
    fetchMetar(false);
    const interval = setInterval(() => fetchMetar(false), 5 * 60 * 1000);
    return () => clearInterval(interval);
  }, [code]);
  useEffect(() => {
    const tick = setInterval(() => setNowTick(Date.now()), 30000);
    return () => clearInterval(tick);
  }, []);
  const metar = raw ? parseMetar(raw) : null;
  const decoder = decoderUrl || `https://metar-taf.com/${code}`;
  const windDeg = typeof metar?.wind?.dir === "number" ? metar.wind.dir : null;
  let obsLabel = null;
  if (metar?.time) {
    const now = new Date();
    let year = now.getUTCFullYear();
    let month = now.getUTCMonth();
    if (metar.time.day > now.getUTCDate()) {
      month -= 1;
      if (month < 0) {
        month = 11;
        year -= 1;
      }
    }
    const obs = new Date(Date.UTC(year, month, metar.time.day, metar.time.hour, metar.time.minute));
    if (!isNaN(obs)) {
      const hh = String(obs.getUTCHours()).padStart(2, "0");
      const mm = String(obs.getUTCMinutes()).padStart(2, "0");
      const diffMin = Math.max(0, Math.round((nowTick - obs.getTime()) / 60000));
      obsLabel = `${observedLabel} ${hh}${mm}Z · ${diffMin === 0 ? justNowLabel : `${diffMin} ${agoLabel}`}`;
    }
  }
  return <div className="kb-metar">
      <div className="kb-metar-header">
        <span className="kb-metar-eyebrow">Weather</span>
        <a className="kb-metar-decoder-link" href={decoder} target="_blank" rel="noopener noreferrer">
          {decoderLabel}
          <svg className="kb-metar-decoder-arrow" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
            <path d="M7 7h10v10" />
            <path d="M7 17 17 7" />
          </svg>
        </a>
      </div>

      <div className="kb-metar-body">
        <div className="kb-metar-icon" aria-hidden="true">
          <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
            <path d="M17.7 7.7a2.5 2.5 0 1 1 1.8 4.3H2" />
            <path d="M9.6 4.6A2 2 0 1 1 11 8H2" />
            <path d="M12.6 19.4A2 2 0 1 0 14 16H2" />
          </svg>
        </div>

        <div className="kb-metar-content">
          <div className="kb-metar-title">
            <span className="kb-metar-icao">{code}</span>
            {name ? <span className="kb-metar-name">{name}</span> : null}
            {metar?.flightCategory && <span className={`kb-metar-cat kb-metar-cat--${metar.flightCategory.toLowerCase()}`}>
                {metar.flightCategory}
              </span>}
          </div>

          {loading && <>
              <div className="kb-metar-skeleton kb-metar-skeleton--long" />
              <div className="kb-metar-skeleton kb-metar-skeleton--short" />
              <div className="kb-metar-meta">
                <span className="kb-metar-meta-status">{loadingLabel}</span>
              </div>
            </>}

          {!loading && error && <div className="kb-metar-error">{errorLabel}</div>}

          {!loading && !error && metar && <>
              <pre className="kb-metar-raw">{metar.raw}</pre>
              <div className="kb-metar-decoded">
                {metar.wind && <div className="kb-metar-stat">
                    <span className="kb-metar-stat-label">{windLabel}</span>
                    <span className="kb-metar-stat-value">
                      {windDeg !== null && <svg className="kb-metar-wind-arrow" width="12" height="12" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" style={{
    transform: `rotate(${windDeg}deg)`
  }}>
                          <polygon points="12 2 19 21 12 17 5 21 12 2" />
                        </svg>}
                      {formatWind(metar.wind)}
                    </span>
                  </div>}
                {metar.visibility && <div className="kb-metar-stat">
                    <span className="kb-metar-stat-label">{visibilityLabel}</span>
                    <span className="kb-metar-stat-value">{formatVisibility(metar.visibility)}</span>
                  </div>}
                {metar.weather.length > 0 && <div className="kb-metar-stat">
                    <span className="kb-metar-stat-label">{weatherLabel}</span>
                    <span className="kb-metar-stat-value">{formatWeather(metar.weather)}</span>
                  </div>}
                {metar.clouds.length > 0 && <div className="kb-metar-stat">
                    <span className="kb-metar-stat-label">{cloudsLabel}</span>
                    <span className="kb-metar-stat-value">{formatClouds(metar.clouds)}</span>
                  </div>}
                {metar.temp != null && <div className="kb-metar-stat">
                    <span className="kb-metar-stat-label">{tempDewLabel}</span>
                    <span className="kb-metar-stat-value">{formatTempDew(metar.temp, metar.dewpoint)}</span>
                  </div>}
                {metar.altimeter && <div className="kb-metar-stat">
                    <span className="kb-metar-stat-label">{altimeterLabel}</span>
                    <span className="kb-metar-stat-value">{formatAltimeter(metar.altimeter)}</span>
                  </div>}
              </div>
              <div className="kb-metar-meta">
                {obsLabel && <span className="kb-metar-meta-time">{obsLabel}</span>}
                <button type="button" className={"kb-metar-refresh" + (refreshing ? " refreshing" : "")} onClick={() => fetchMetar(true)} aria-label={refreshLabel} title={refreshLabel}>
                  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                    <path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
                    <path d="M21 3v5h-5" />
                    <path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
                    <path d="M3 21v-5h5" />
                  </svg>
                </button>
              </div>
            </>}
        </div>
      </div>
    </div>;
};

<Info>For flight-simulation use only - Do **NOT** use for real-world flight</Info>

Vancouver International sits on Sea Island in the Fraser River delta, with the city of Richmond to the south and downtown Vancouver across the inlet to the north. It is Air Canada's primary Western Canada hub, with Mainline, Rouge, and Air Canada Express all operating here. Three runways (two parallels plus a crosswind), sea-level elevation, and frequent coastal fog or stratus make Vancouver a beautiful but weather-sensitive field. The North Shore Mountains rise to over 4,500 ft within 10 nm of the field and dominate the missed-approach and noise-routing design.

## ATIS

<VatsimAtis
  icao="CYVR"
  name="Vancouver International"
  magneticVariation="17°E"
  runways={[
{ designators: ["08L", "26R"] },
{ designators: ["08R", "26L"] }
]}
/>

## Live Weather

<LiveMetar icao="CYVR" name="Vancouver International" />

## Online ATC Network Maps

<NetworkLinks icao="CYVR" />

## Runways

<Runways
  magneticVariation="17°E"
  runways={[
{
  designators: ["08L", "26R"],
  length_ft: 9941,
  length_m: 3030,
  width_ft: 200,
  surface: "Asphalt/Concrete",
  ils: { "08L": "CAT I", "26R": "CAT I" },
  lighting: "HIRL, CL",
  notes: "North parallel runway."
},
{
  designators: ["08R", "26L"],
  length_ft: 12188,
  length_m: 3715,
  width_ft: 200,
  surface: "Asphalt/Concrete",
  ils: { "08R": "CAT I", "26L": "CAT IIIA" },
  lighting: "HIRL, CL, ALS",
  notes: "South parallel and longest runway. CAT IIIA on 26L supports low-visibility arrivals from the west."
},
{
  designators: ["13", "31"],
  length_ft: 7300,
  length_m: 2225,
  width_ft: 200,
  surface: "Asphalt/Concrete",
  ils: { },
  lighting: "MIRL",
  notes: "Crosswind runway. Built in 1961 (originally designated 12/30, renumbered to 13/31 following a magnetic-variation update). Used during strong cross-component conditions from the south or north."
}
]}
/>

## ATC Frequencies

<AtcFrequencies
  frequencies={[
{ role: "ATIS", freqs: ["124.60"] },
{ role: "Clearance Delivery", freqs: ["121.40"] },
{ role: "Ground", freqs: [
  { value: "121.70", note: "South" },
  { value: "127.15", note: "North" }
]},
{ role: "Tower", freqs: [
  { value: "118.70", note: "South" },
  { value: "119.55", note: "North" }
]},
{ role: "VFR Tower", freqs: ["124.02", "125.65"] },
{ role: "Departure", subLabel: "Vancouver Terminal", freqs: [
  { value: "126.125", note: "North" },
  { value: "132.30", note: "South" }
]},
{ role: "Arrival", subLabel: "Vancouver Terminal", freqs: [
  { value: "128.60", note: "outer" },
  { value: "128.17", note: "outer" },
  { value: "133.10", note: "inner" },
  { value: "134.225", note: "inner" }
]}
]}
/>

## Gates

<Gates
  terminal="Main Terminal"
  sections={[
{
  type: "domestic",
  label: "Domestic",
  range: "Pier C (C29-C52)",
  gates: ["C29-C47", "C48-C52 (swing)"],
  operators: ["Air Canada Mainline", "Air Canada Express"],
  aircraft: ["A220", "A319/A320/A321", "E175", "Q400"],
  notes: "Air Canada's primary YVR pier. C48-C52 swing to international departures as D48-D52 when the international jetty is full."
},
{
  type: "international",
  label: "International",
  range: "Pier D (D48-D78)",
  gates: ["D48-D78"],
  operators: ["Air Canada Mainline"],
  aircraft: ["B777-200LR/-300ER", "B787-8/-9", "A330-300", "A350 (partners)"],
  notes: "All 31 stands are widebody-capable. Four stands accept the A380 with dual jet bridges. Seven Pier D positions (D56, D57, D59, D60, D61, D63, D65) serve as bus gates for remote stands."
},
{
  type: "transborder",
  label: "US Transborder",
  range: "Pier E (E71-E96)",
  gates: ["E71-E78 (swing from D)", "E80-E96"],
  operators: ["Air Canada Mainline", "Air Canada Express"],
  aircraft: ["A220", "A319/A320/A321", "E175", "B787 (HNL/deep US)"],
  notes: "US Customs preclearance before boarding. The lower-numbered range (E71-E78) swings from Pier D71-D78 depending on flight assignment."
},
{
  type: "swing",
  label: "Swing Gates",
  range: "C/D and D/E pairs",
  gates: ["C48-C52 / D48-D52", "D71-D78 / E71-E78"],
  operators: ["Air Canada Mainline"],
  aircraft: ["Narrowbody and widebody"],
  notes: "Gate identifier flips depending on the destination served (domestic vs. international vs. US). Watch the gate-info display on pushback for the active designator."
},
{
  type: "regional",
  label: "Regional / Ground Load",
  range: "Pier A (A6-A12)",
  gates: ["A6-A10", "A12"],
  operators: [],
  aircraft: ["Q400", "Other turboprop"],
  notes: "Ground-loaded narrow stands. Air Canada Express Q400 operations may use remote stands on the south apron when A is committed to WestJet."
}
]}
/>

## Airport at a Glance

| Item               | Value                                  |
| ------------------ | -------------------------------------- |
| ICAO / IATA        | CYVR / YVR                             |
| City               | Vancouver / Richmond, British Columbia |
| Field elevation    | 14 ft (4 m)                            |
| Magnetic variation | 17°E                                   |
| Time zone          | America/Vancouver (PST / PDT)          |
| Air Canada role    | Primary Western Canada hub             |

## Charts

<ChartsLinks icao="CYVR" />

## Common Procedures

* **Runway selection**: Westerly flow (26L/26R) is the most common configuration thanks to the prevailing Pacific weather. Easterly flow (08L/08R) appears on calm or outflow-wind days. Runway 13/31 is reserved for strong south or north crosswinds.
* **Parallel operations**: Independent simultaneous approaches and departures are routine on the 08L/26R and 08R/26L pair. The runways are spaced more than 1,300 ft apart, which meets the requirement for fully independent IFR ops.
* **North Shore terrain**: Missed approaches and circling are designed to keep you over water as long as possible. After a missed on 08R, expect an immediate climbing left turn to remain south of the harbour and away from the mountains.
* **Noise routing**: Departures over the Strait of Georgia are preferred between 2200L and 0700L. Expect a turn left or right off the runway centreline within 1.5 nm of the threshold when noise-sensitive routing is active.
* **Sea Island wildlife**: Bird strikes from gulls and waterfowl are an everyday hazard. Tower regularly broadcasts wildlife advisories on Tower frequency.

## Hot Spots and Local Hazards

* **Coastal fog and stratus**: October through February sees frequent low-ceiling and low-visibility events. Expect CAT II/III on 26L when conditions warrant. Have the LVTC ready and brief the autoland decision early.
* **Outflow winds**: In winter, cold dry air pours out of Howe Sound and the Fraser Valley. The result is strong easterly winds and turbulence on final approach to 08L/08R. Brief gust factors and crosswind limits during the approach.
* **Sea Island connector**: Taxi between the South Terminal and the main passenger ramp can route across the active taxi connector. Read every clearance carefully and never assume blanket clearances.
* **High terrain north and east**: Even on a clear day, do not deviate north of the river without ATC clearance. The Coast Mountains rise sharply and many GPWS events are caused by improper missed-approach climb-out.
* **U.S. Customs preclearance**: Departures to U.S. destinations clear customs at YVR before boarding. Expect tighter ramp schedules and longer boarding windows on US-bound flights.

<Card title="Next: Calgary (CYYC)" icon="plane-departure" href="/aops/airports/calgary-international">
  Continue to the Calgary International briefing
</Card>
