> ## 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.

# Calgary (CYYC)

> Aperçu vACA pour CYYC Calgary International : configuration des pistes, fréquences ATC, procédures courantes, points chauds, METAR en direct et couverture des réseaux ATC en ligne

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>Pour usage en simulation de vol uniquement - Ne **PAS** utiliser pour le vol réel</Info>

L'aéroport international de Calgary est un terrain chaud et en altitude sur le versant est des Rocheuses. L'altitude du terrain dépasse 3 600 ft, et combinée aux températures élevées d'été et aux vents chinook violents et rafaleux d'ouest, teste régulièrement les performances des aéronefs. La piste 17L/35R de 14 000 ft, ouverte en 2014, est la plus longue piste au Canada et a été construite spécifiquement pour soutenir les départs long-courriers lourds dans ces conditions dégradées. Air Canada exploite un important réseau de service grandes lignes dans l'Ouest et un trafic de correspondance significatif via Calgary.

## ATIS

<VatsimAtis
  icao="CYYC"
  name="Calgary International"
  magneticVariation="15°E"
  title="ATIS VATSIM"
  loadingLabel="Réception de la diffusion"
  offlineLabel="Aucune ATIS VATSIM en direct pour cet aéroport présentement"
  errorLabel="Impossible de joindre le flux de données VATSIM"
  refreshLabel="Rafraîchir"
  frequencyLabel="Fréquence"
  observedLabel="Mis à jour"
  agoLabel="min"
  justNowLabel="à l'instant"
  transcriptLabel="Transcription"
  runwaysInUseLabel="Pistes en service"
  notUsedLabel="Inactive"
  bearingLabel="Cap"
  runwayLabel="Piste"
  runways={[
{ designators: ["17L", "35R"] },
{ designators: ["17R", "35L"] }
]}
/>

## Météo en direct

<LiveMetar icao="CYYC" name="Calgary International" loadingLabel="Téléchargement de la dernière observation" errorLabel="Impossible de récupérer le METAR en direct. Essayez le décodeur visuel." refreshLabel="Rafraîchir" decoderLabel="Décodeur visuel Metar & Taf" observedLabel="Observé" agoLabel="min" justNowLabel="à l'instant" />

## Cartes de réseaux ATC en ligne

<NetworkLinks icao="CYYC" title="Cartes de réseaux ATC en ligne" />

## Pistes

<Runways
  title="Pistes"
  magneticVariation="15°E"
  labels={{
length: "Longueur",
width: "Largeur",
surface: "Surface",
lighting: "Éclairage",
noIls: "Aucun",
ilsPrefix: "ILS",
magvar: "Déclinaison magnétique"
}}
  runways={[
{
  designators: ["17L", "35R"],
  length_ft: 14000,
  length_m: 4267,
  width_ft: 200,
  surface: "Béton",
  ils: { "17L": "CAT I", "35R": "CAT I" },
  lighting: "HIRL, CL",
  notes: "Plus longue piste au Canada (ouverte en 2014). Dimensionnée pour les départs long-courriers lourds en conditions chaudes et en altitude."
},
{
  designators: ["17R", "35L"],
  length_ft: 12675,
  length_m: 3863,
  width_ft: 200,
  surface: "Asphalte",
  ils: { "17R": "CAT I", "35L": "CAT I" },
  lighting: "HIRL, CL",
  notes: "Piste parallèle ouest, plus proche de l'aérogare principale."
},
{
  designators: ["11", "29"],
  length_ft: 8000,
  length_m: 2438,
  width_ft: 200,
  surface: "Asphalte",
  ils: { "11": "CAT I" },
  lighting: "HIRL",
  notes: "Piste transversale diagonale. Utilisée fréquemment lorsque les vents chinook rendent 17/35 inutilisable."
}
]}
/>

L'ancienne piste 08/26 a été redésignée comme voie de circulation le 3 octobre 2024.

## Fréquences ATC

<AtcFrequencies
  title="Fréquences ATC"
  frequencies={[
{ role: "ATIS", freqs: ["128.225"] },
{ role: "Clearance Delivery", freqs: ["120.825"] },
{ role: "Sol", freqs: [
  { value: "121.90", note: "Ouest" },
  { value: "125.35", note: "Est" }
]},
{ role: "Tour", freqs: [
  { value: "118.40", note: "Ouest" },
  { value: "118.70", note: "Est" }
]},
{ role: "Départ", subLabel: "Calgary Terminal", freqs: [
  { value: "119.80", note: "Est" },
  { value: "124.52", note: "Ouest" }
]},
{ role: "Arrivée", subLabel: "Calgary Terminal", freqs: [
  { value: "126.525", note: "Ouest" },
  { value: "125.90", note: "Est" },
  { value: "123.85" },
  { value: "127.15" }
]},
{ role: "Conseil VFR", freqs: ["119.40"] }
]}
/>

## Portes d'embarquement

<Gates
  title="Portes d'embarquement"
  terminal="Aérogares Intérieur et International (un seul bâtiment)"
  labels={{
range: "Plage",
operators: "Exploitants",
aircraft: "Appareils"
}}
  sections={[
{
  type: "domestic",
  label: "Intérieur",
  range: "Hall C (C50-C65)",
  gates: ["C50-C65"],
  operators: ["Air Canada Mainline", "Air Canada Express"],
  aircraft: ["A220", "A319/A320/A321", "E175", "Q400"],
  notes: "Jetée intérieure principale d'Air Canada à YYC. Le Hall B (B31-B40) accueille le surplus pendant les fortes vagues; le Hall A (A1-A6, A11-A24) est largement utilisé par WestJet."
},
{
  type: "international",
  label: "International",
  range: "Hall D (D70-D87)",
  gates: ["D70-D87"],
  operators: ["Air Canada Mainline"],
  aircraft: ["B777-300ER", "B787-8/-9", "A330-300"],
  notes: "Jetée gros porteurs pour les départs internationaux non É.-U. Utilisée par AC long-courrier vers LHR, FRA, NRT, ICN, et les partenaires Star Alliance (LH, NH, partage de code BA). Les postes de l'aérogare internationale sont physiquement superposés : la même position d'aire est accessible depuis le Hall D (international) ou le Hall E (précontrôle É.-U.) selon le vol."
},
{
  type: "transborder",
  label: "Transfrontalier É.-U.",
  range: "Hall E (E70-E97)",
  gates: ["E70-E87 (superposées avec D)", "E88-E97 (É.-U. seulement)"],
  operators: ["Air Canada Mainline", "Air Canada Express"],
  aircraft: ["A220", "A319/A320/A321", "E175"],
  notes: "Précontrôle douanier É.-U. avant l'embarquement. E70-E87 partagent les postes physiques avec D70-D87 (halls superposés); E88-E97 sont des positions exclusives aux É.-U. Principalement des opérations à fuselage étroit vers les destinations principales des É.-U."
},
{
  type: "deice",
  label: "Dégivrage centralisé",
  range: "Plateforme Aire VII",
  operators: [],
  aircraft: ["Toute la flotte AC"],
  notes: "Plateforme de dégivrage située entre les pistes parallèles. Suivez les instructions Iceman en hiver. Planifiez les temps de tenue contre le long roulage de retour vers 17L/35R."
}
]}
/>

## Aperçu de l'aéroport

| Élément                | Valeur                            |
| ---------------------- | --------------------------------- |
| ICAO / IATA            | CYYC / YYC                        |
| Ville                  | Calgary, Alberta                  |
| Altitude du terrain    | 3 606 ft (1 099 m)                |
| Déclinaison magnétique | 15°E                              |
| Fuseau horaire         | America/Edmonton (HNR / HAR)      |
| Rôle pour Air Canada   | Opération clé de l'Ouest canadien |

## Cartes

<ChartsLinks icao="CYYC" title="Cartes" freeLabel="Gratuit" />

## Procédures courantes

* **Sélection de piste** : 17L/17R/35R/35L sont utilisées la plupart du temps, jumelées selon le vent dominant. La piste 11/29 entre en jeu lorsque les vents chinook de l'ouest dépassent la limite traversière des pistes nord/sud.
* **Performance chaud et en altitude** : Prévoyez des performances moteur réduites et une distance de décollage plus longue, particulièrement en été. Plusieurs opérations monocouloirs vers des destinations plus éloignées utilisent 17L/35R pour la marge supplémentaire. Portez une attention particulière aux calculs de performance fournis par SimBrief et le FMS; ce qui passe au niveau de la mer peut être marginal à YYC.
* **Vents chinook** : Le chinook est un vent chaud, sec et souvent turbulent qui descend des Rocheuses et peut faire basculer la composante de vent de 90 degrés en quelques minutes. Attendez-vous à des changements rapides de configuration durant un épisode chinook, incluant des changements de piste imprévus et de brefs délais à l'approche.
* **Plus haute tour de contrôle aéroportuaire autoportante au Canada** : La tour ATC de 91 m (300 ft) a ouvert en 2013 et est visible depuis la plus grande partie du terrain. La référence visuelle de la tour facilite la conscience situationnelle au roulage.
* **Prédédouanement américain** : Disponible pour les départs vers les destinations américaines.

## Points chauds et dangers locaux

* **Distance entre les parallèles et l'aérogare** : La piste 17L/35R est du côté est du terrain, séparée de l'aérogare principale par un long parcours de roulage. Le calcul du carburant au bloc doit prévoir 15 à 20 minutes supplémentaires vers et depuis 17L/35R.
* **Trafic convergent durant les changements de piste** : Lorsque le terrain passe entre 17/35 et 11/29, attendez-vous à des déviations du routage publié pendant que l'ATC reséquence. Écoutez les modifications de clairance et collationnez chaque changement intégralement.
* **Turbulence d'onde orographique** : Par fort vent d'ouest, l'activité d'onde de montagne peut s'étendre bien à l'est des Rocheuses et produire une turbulence modérée aux altitudes de croisière au-dessus des contreforts.
* **Repères visuels de la 08/26 déclassée** : L'ancienne piste 08/26 est maintenant une voie de circulation. Certains décors de simulateur plus anciens peuvent encore l'afficher comme piste active. Vérifiez les cheminements contre le diagramme d'aéroport en vigueur avant chaque autorisation de roulage.
* **Cold-soak et brouillard glaciaire** : Calgary connaît des vagues de grand froid avec brouillard glaciaire au lever du soleil pouvant faire chuter la visibilité sous les minimums CAT I. Planifiez une réserve de carburant de tenue d'attente en hiver.

<Card title="Suivant : Ottawa (CYOW)" icon="plane" href="/fr/aops/airports/ottawa">
  Poursuivez vers l'aperçu d'Ottawa
</Card>
