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

# Montréal-Trudeau (CYUL)

> Aperçu vACA pour CYUL Montréal-Trudeau : 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 Pierre-Elliott-Trudeau de Montréal est la plaque tournante d'Air Canada pour l'Est du Canada et le siège social de la compagnie. Depuis le déclassement de la piste 10/28 en juillet 2023, toutes les opérations utilisent les deux pistes parallèles rapprochées 06L/24R et 06R/24L. Les deux ATIS sont diffusés en français et en anglais et l'ATC accepte les appels dans l'une ou l'autre langue. Révisez votre phraséologie de base en français québécois si vous volez ici souvent.

## ATIS

<VatsimAtis
  icao="CYUL"
  name="Montréal-Trudeau"
  magneticVariation="15°W"
  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: ["06L", "24R"] },
{ designators: ["06R", "24L"] }
]}
/>

## Météo en direct

<LiveMetar icao="CYUL" name="Montréal-Trudeau" 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="CYUL" title="Cartes de réseaux ATC en ligne" />

## Pistes

<Runways
  title="Pistes"
  magneticVariation="15°W"
  labels={{
length: "Longueur",
width: "Largeur",
surface: "Surface",
lighting: "Éclairage",
noIls: "Aucun",
ilsPrefix: "ILS",
magvar: "Déclinaison magnétique"
}}
  runways={[
{
  designators: ["06L", "24R"],
  length_ft: 11000,
  length_m: 3353,
  width_ft: 200,
  surface: "Béton",
  ils: { "06L": "CAT II", "24R": "CAT I" },
  lighting: "HIRL, CL",
  notes: "Piste principale. La CAT II sur 06L permet des opérations jusqu'à RVR 1 200 ft."
},
{
  designators: ["06R", "24L"],
  length_ft: 9890,
  length_m: 3014,
  width_ft: 200,
  surface: "Béton",
  ils: { "06R": "CAT I", "24L": "CAT I" },
  lighting: "HIRL, CL",
  notes: "Parallèle rapprochée secondaire. Les opérations IFR simultanées indépendantes avec 06L/24R ne sont pas autorisées."
}
]}
/>

La piste 10/28 a été déclassée de façon permanente en juillet 2023 et convertie en voie de circulation. Toutes les opérations à réaction partagent désormais les deux pistes parallèles.

## Fréquences ATC

<AtcFrequencies
  title="Fréquences ATC"
  frequencies={[
{ role: "ATIS", freqs: [
  { value: "133.70", note: "anglais" },
  { value: "127.50", note: "français" }
]},
{ role: "Clearance Delivery", freqs: ["125.60"] },
{ role: "Sol", freqs: [
  { value: "121.00", note: "Ouest" },
  { value: "121.90", note: "Est" }
]},
{ role: "Tour", freqs: ["119.30", "119.90", "124.30"] },
{ role: "Départ", subLabel: "Montréal Terminal", freqs: ["120.42", "124.65"] },
{ role: "Arrivée", subLabel: "Montréal Terminal", freqs: ["118.90", "126.90", "132.85"] },
{ role: "Conseil VFR", freqs: ["134.15"] }
]}
/>

L'ATIS est diffusé séparément en anglais sur 133.70 et en français sur 127.50. Le contenu technique est identique entre les deux. Choisissez la langue dans laquelle vous êtes le plus à l'aise avant d'enfoncer le bouton micro.

## Portes d'embarquement

<Gates
  title="Portes d'embarquement"
  terminal="Aérogare unique, trois jetées"
  labels={{
range: "Plage",
operators: "Exploitants",
aircraft: "Appareils"
}}
  sections={[
{
  type: "domestic",
  label: "Jetée Intérieur",
  range: "Principale : 1-12, 15, 47-49 / Satellite : 17-34",
  gates: ["1-12", "15", "47-49", "17, 19, 21, 23, 25, 27, 28, 30, 32, 34"],
  operators: ["Air Canada Mainline", "Air Canada Express", "Air Canada Rouge"],
  aircraft: ["A220", "A319/A320/A321", "E175", "Q400"],
  notes: "La jetée satellite est accessible par tunnel souterrain depuis le point de contrôle Intérieur. Très utilisée par les vagues de Dash-8 d'Air Canada Express."
},
{
  type: "international",
  label: "Jetée International",
  range: "50-53, 55-68",
  gates: ["50-53", "55-61", "62-68 (gros porteurs)"],
  operators: ["Air Canada Mainline"],
  aircraft: ["B777-300ER", "B787-8/-9", "A330-300", "A380 (62, 65, 66)"],
  notes: "Les portes 53 et 62 sont des postes véhicule de transfert passagers (autobus). Les portes 62-68 sont aptes aux gros porteurs, avec doubles passerelles sur 62, 65 et 66 pour l'A380."
},
{
  type: "transborder",
  label: "Jetée Transfrontalier É.-U.",
  range: "72-89",
  gates: ["72-89"],
  operators: ["Air Canada Mainline", "Air Canada Express"],
  aircraft: ["A220", "A319/A320/A321", "E175"],
  notes: "Précontrôle douanier É.-U. via le point de contrôle C. Les portes 87-89 nécessitent un escalier pour rejoindre le niveau d'embarquement inférieur."
},
{
  type: "swing",
  label: "Portes polyvalentes",
  range: "International / Transfrontalier",
  gates: ["56, 58, 60"],
  operators: ["Air Canada Mainline"],
  aircraft: ["A220", "A319/A320/A321", "B787 (HNL/É.-U. lointain)"],
  notes: "Basculent entre la jetée internationale et les opérations transfrontalières É.-U. La désignation active dépend de la destination desservie ce jour-là."
},
{
  type: "deice",
  label: "Dégivrage centralisé",
  range: "Plateforme dédiée Aire VI",
  operators: [],
  aircraft: ["Toute la flotte AC"],
  notes: "Plateforme centralisée à l'est de l'aérogare. Passez sur la fréquence de dégivrage au transfert et suivez l'équipe Iceman pour l'assignation de poste."
}
]}
/>

## Aperçu de l'aéroport

| Élément                | Valeur                           |
| ---------------------- | -------------------------------- |
| ICAO / IATA            | CYUL / YUL                       |
| Ville                  | Montréal (Dorval), Québec        |
| Altitude du terrain    | 118 ft (36 m)                    |
| Déclinaison magnétique | 15°W                             |
| Fuseau horaire         | America/Toronto (HNE / HAE)      |
| Rôle pour Air Canada   | Plaque tournante et siège social |

## Cartes

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

## Procédures courantes

* **Sélection de piste** : 06L/06R en configuration est, 24L/24R en configuration ouest. La direction du vent dicte habituellement la piste en usage, mais les considérations anti-bruit et les arrivées convergentes de Québec au nord fixent souvent le terrain dans une configuration donnée pendant plusieurs heures.
* **Opérations bilingues** : L'ATC traite les appels en anglais ou en français sans problème. Si vous initiez en anglais, attendez-vous à des réponses en anglais pendant toute la durée du contact. Mélanger les langues dans un même échange est acceptable mais ralentit le contrôleur. Choisissez-en une et tenez-vous-y.
* **Parallèles dépendantes** : Les deux pistes étant rapprochées sous le minimum OACI pour les opérations indépendantes IFR, les approches ILS simultanées exigent une séparation additionnelle. Attendez-vous à un séquencement échelonné des arrivées.
* **Coordination TRACON** : L'unité de contrôle terminal de Montréal traite aussi le trafic de CYHU (Saint-Hubert) et de plusieurs satellites plus petits. Attendez-vous à une descente précoce et à une mise en branche efficace.

## Points chauds et dangers locaux

* **Marquages fantômes de 10/28** : La piste déclassée est maintenant une voie de circulation. Vérifiez votre cheminement contre le diagramme actuel, car certaines cartes anciennes et certains décors de simulateur affichent encore 10/28 comme une piste active. Lisez chaque autorisation de roulage attentivement près des voies Foxtrot et Hotel.
* **Trajectoires convergentes au départ 24L/24R** : Après le décollage sur l'une des pistes en configuration ouest, le trafic suit généralement un cap ouest-sud-ouest. Maintenez le cap de piste jusqu'à autorisation pour éviter un conflit avec le départ parallèle.
* **Contamination hivernale** : CYUL connaît des épisodes importants de pluie verglaçante et de bruine verglaçante chaque hiver. Les temps de tenue du Type IV diminuent rapidement sous précipitation verglaçante. Coordonnez le plan de dégivrage avec la rampe avant le refoulement.
* **Activité d'oiseaux** près du petit lac à l'est du terrain : danger récurrent dans le monde réel. La tour diffuse des avertissements sur la fréquence d'opérations.
* **Espace aérien restreint** au-dessus du centre-ville de Montréal (CYR-572) et près de l'aéroport Mirabel (CYMX) au nord. Consultez les NOTAM pour les heures d'activation.

<Card title="Suivant : Vancouver (CYVR)" icon="plane-departure" href="/fr/aops/airports/vancouver-international">
  Poursuivez vers l'aperçu de Vancouver International
</Card>
