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

# Halifax (CYHZ)

> Aperçu vACA pour CYHZ Halifax Stanfield : 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 Halifax Stanfield est la principale porte d'entrée internationale du Canada atlantique et la plus grande opération d'Air Canada à l'est de Montréal. Le terrain possède deux pistes perpendiculaires et soutient les opérations gros-porteurs sur la piste 05/23 allongée. Le brouillard côtier et maritime est un facteur opérationnel déterminant ici, avec des périodes fréquentes de plafond bas et de visibilité réduite tout au long de l'année. Le prédédouanement américain est disponible depuis la fin 2006 pour les départs vers les États-Unis.

## ATIS

<VatsimAtis
  icao="CYHZ"
  name="Halifax Stanfield"
  magneticVariation="18°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: ["05", "23"] },
{ designators: ["14", "32"] }
]}
/>

## Météo en direct

<LiveMetar icao="CYHZ" name="Halifax Stanfield" 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="CYHZ" title="Cartes de réseaux ATC en ligne" />

## Pistes

<Runways
  title="Pistes"
  magneticVariation="18°W"
  labels={{
length: "Longueur",
width: "Largeur",
surface: "Surface",
lighting: "Éclairage",
noIls: "Aucun",
ilsPrefix: "ILS",
magvar: "Déclinaison magnétique"
}}
  runways={[
{
  designators: ["05", "23"],
  length_ft: 10500,
  length_m: 3200,
  width_ft: 200,
  surface: "Asphalte/Béton",
  ils: { "05": "CAT I", "23": "CAT I" },
  lighting: "HIRL, CL",
  notes: "Piste principale. Allongée en novembre 2012 pour soutenir les opérations gros-porteurs vers l'Europe."
},
{
  designators: ["14", "32"],
  length_ft: 7700,
  length_m: 2347,
  width_ft: 200,
  surface: "Asphalte",
  ils: { "14": "CAT I", "32": "CAT I" },
  lighting: "HIRL",
  notes: "Piste secondaire à composante traversière."
}
]}
/>

Les voies de circulation ont une largeur standard de 75 ft; la voie Kilo est de 50 ft.

## Fréquences ATC

<AtcFrequencies
  title="Fréquences ATC"
  frequencies={[
{ role: "ATIS", freqs: ["121.00"] },
{ role: "Clearance Delivery", freqs: ["123.95"] },
{ role: "Sol", freqs: ["121.90"] },
{ role: "Tour", freqs: ["118.40"] },
{ role: "Terminal", subLabel: "Départ / Arrivée", freqs: ["119.20", "118.70", "128.55"] }
]}
/>

## Portes d'embarquement

<Gates
  title="Portes d'embarquement"
  terminal="Aérogare unique, 32 postes de stationnement"
  labels={{
range: "Plage",
operators: "Exploitants",
aircraft: "Appareils"
}}
  sections={[
{
  type: "domestic",
  label: "Intérieur (Mainline, avec passerelle)",
  range: "Postes avec passerelle (fuselage étroit)",
  gates: ["12, 14-16, 18, 20"],
  operators: ["Air Canada Mainline"],
  aircraft: ["A220", "A319/A320/A321", "E175", "B737"],
  notes: "Départs Air Canada Mainline en fuselage étroit depuis les postes avec passerelle au deuxième étage. Les portes 22-28 (gros porteurs) sont détaillées dans la section International / Transatlantique ci-dessous."
},
{
  type: "regional",
  label: "Régional / Embarquement au sol",
  range: "Portes 2(a-e) à 9",
  gates: ["2a-2e", "3-9"],
  operators: ["Air Canada Express (Jazz)"],
  aircraft: ["Q400", "CRJ-900"],
  notes: "Postes au sol pour turbopropulseurs et jets régionaux à l'extrémité sud de l'aérogare. Embarquement à pied."
},
{
  type: "international",
  label: "International / Transatlantique",
  range: "Postes passerelle 22-28",
  gates: ["22-24", "26-28"],
  operators: ["Air Canada Mainline"],
  aircraft: ["B767-300ER", "B787", "A330", "A321LR"],
  notes: "Postes passerelle aptes aux gros porteurs utilisés pour les départs transatlantiques (LHR, CDG, FRA). Les mêmes postes basculent en transfrontalier É.-U."
},
{
  type: "transborder",
  label: "Transfrontalier É.-U.",
  range: "Sol 34-46 / polyvalentes 52-58",
  gates: ["34-46 (au sol)", "52-54, 56-58 (polyvalentes depuis 22-28)"],
  operators: ["Air Canada Mainline", "Air Canada Express"],
  aircraft: ["A220", "A319/A320", "E175", "CRJ-900"],
  notes: "Précontrôle douanier É.-U. disponible depuis 2006. Les portes 34-46 sont des postes au sol; les passerelles 22-28 sont renumérotées 52-58 en mode départs É.-U."
},
{
  type: "swing",
  label: "Portes polyvalentes",
  range: "22-28 international / 52-58 É.-U.",
  gates: ["22-24, 26-28"],
  operators: ["Air Canada Mainline"],
  aircraft: ["Gros porteur et fuselage étroit"],
  notes: "Un corridor sécurisé vitré permet au même poste physique de traiter une arrivée internationale ou un départ É.-U. précontrôlé. La désignation bascule à chaque vol."
}
]}
/>

## Aperçu de l'aéroport

| Élément                | Valeur                                             |
| ---------------------- | -------------------------------------------------- |
| ICAO / IATA            | CYHZ / YHZ                                         |
| Ville                  | Municipalité régionale de Halifax, Nouvelle-Écosse |
| Altitude du terrain    | 477 ft (145 m)                                     |
| Déclinaison magnétique | 18°W                                               |
| Fuseau horaire         | America/Halifax (HNA / HAA)                        |
| Rôle pour Air Canada   | Opération principale au Canada atlantique          |

## Cartes

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

## Procédures courantes

* **Sélection de piste** : 05/23 est la piste principale. 14/32 est choisie par fortes composantes traversières ou lorsque 05/23 est fermée.
* **Prédédouanement américain** : Disponible pour les départs vers les destinations américaines. Planifiez l'embarquement plus tôt qu'un départ domestique.
* **Opérations par brouillard** : Le brouillard maritime peut s'installer en quelques minutes et se dissiper tout aussi rapidement. Lisez attentivement le <Tooltip tip="Prévision d'aérodrome" cta="Voir le glossaire" href="/fr/aops/additional-resources/glossary/taf">TAF</Tooltip> le plus récent et assurez-vous que les déroutements emportent une marge généreuse de carburant.
* **Régulation transatlantique** : Halifax est le point de régulation habituel pour plusieurs vols transatlantiques Air Canada depuis l'Est du Canada. Les opérations gros-porteurs vers LHR, CDG et d'autres plaques tournantes européennes utilisent la piste 05/23.
* **Pas d'opérations parallèles** : Deux pistes sécantes. Attendez-vous à un flot de trafic séquentiel.

## Points chauds et dangers locaux

* **Apparition soudaine de brouillard** : Un après-midi clair peut devenir CAT I ou pire en 30 minutes lorsqu'une brise de mer amène la nappe de brouillard à terre. Briefez toujours la remise de gaz comme si vous alliez l'exécuter.
* **Faune** : Les cerfs et la sauvagine sont des dangers récurrents. La tour diffuse des avertissements de faune sur la fréquence Tour.
* **Largeur de la voie Kilo** : Plus étroite que la norme de 75 ft. Confirmez que la catégorie d'aéronef passe avant d'accepter un roulage par Kilo.
* **Incursions par traversée de piste** : 05/23 et 14/32 se croisent près du centre du terrain. Les autorisations de maintien à l'écart sur ces pistes doivent être collationnées intégralement et vérifiées à la ligne de maintien.
* **Exposition à la trajectoire des tempêtes** : Halifax est directement sur la trajectoire des tempêtes de l'Atlantique Nord. Attendez-vous à des vents forts, à de la pluie verglaçante en hiver et à l'occasion à des résidus d'ouragans à la fin de l'été et au début de l'automne. Planifiez les déroutements en conséquence.

<Card title="Retour aux aperçus des aéroports canadiens" icon="arrow-left" href="/fr/aops/airports/index">
  Retournez à l'index des aéroports
</Card>
