{"id":2327,"date":"2025-08-03T21:07:26","date_gmt":"2025-08-03T20:07:26","guid":{"rendered":"https:\/\/flying.sarge-smiffy.com\/?page_id=2327"},"modified":"2025-08-15T12:16:34","modified_gmt":"2025-08-15T11:16:34","slug":"flying-stats","status":"publish","type":"page","link":"https:\/\/flying.sarge-smiffy.com\/?page_id=2327","title":{"rendered":"Live Flight Information"},"content":{"rendered":"\n<!-- Leaflet -->\n<link rel=\"stylesheet\" href=\"https:\/\/unpkg.com\/leaflet@1.9.4\/dist\/leaflet.css\" \/>\n<script src=\"https:\/\/unpkg.com\/leaflet@1.9.4\/dist\/leaflet.js\"><\/script>\n\n<style>\n  .topbar{display:flex;gap:12px;align-items:center;margin-bottom:10px;\n    font:14px\/1.35 system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif}\n  .topbar button{padding:6px 10px;border:1px solid #d1d5db;border-radius:6px;background:#fff;cursor:pointer}\n  .ws-dot{width:10px;height:10px;border-radius:50%;background:#9CA3AF}\n  .hb-dot{width:8px;height:8px;border-radius:50%;background:#9CA3AF;display:inline-block;margin-left:8px;margin-right:6px}\n\n  .wrap{display:grid;gap:16px;align-items:start;grid-template-columns:1fr 420px}\n  @media (max-width:900px){.wrap{grid-template-columns:1fr}}\n\n  #map{height:420px;border-radius:12px;overflow:hidden}\n  @media (max-width:900px){#map{height:380px}}\n\n  .right{\n    font:15px\/1.45 system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;\n    border:1px solid #e5e7eb;border-radius:12px;padding:16px;background:#fff;\n    box-shadow:0 1px 2px rgba(16,24,40,.05);\n  }\n\n  .ac-card{margin:8px 0 14px;text-align:center}\n  .ac-card img{width:100%;max-width:520px;height:auto;display:block;margin:0 auto 8px;transition:opacity 150ms ease;border-radius:8px}\n  .ac-title{font-weight:700;margin-bottom:2px}\n  .ac-sub{opacity:.85}\n\n  .row{display:flex;gap:10px;align-items:center;margin:6px 0}\n  .row .k{min-width:120px;color:#6b7280}\n\n  \/* Custom rotating marker *\/\n  .ac-marker{ background:none; border:0; }\n  .ac-marker-img{\n    width:32px; height:32px; display:block;\n    transform-origin:16px 16px;\n    will-change: transform;\n    transition: transform 120ms linear;\n  }\n<\/style>\n\n<div class=\"topbar\">\n  <span id=\"wsDot\" class=\"ws-dot\" aria-hidden=\"true\"><\/span>\n  <span id=\"wsText\">WS: connecting\u2026<\/span>\n\n  <span class=\"hb-dot\" id=\"hb\"><\/span><span id=\"hbText\">No data\u2026<\/span>\n\n  <button id=\"keepAwakeBtn\" type=\"button\">Keep screen on: OFF<\/button>\n  <button id=\"followBtn\" type=\"button\">Center on aircraft: ON<\/button>\n<\/div>\n\n<div class=\"wrap\">\n  <!-- Map -->\n  <div id=\"map\"><\/div>\n\n  <!-- Right card -->\n  <aside class=\"right\">\n    <div class=\"ac-card\">\n      <img id=\"acftImg\"\n           src=\"https:\/\/flying.sarge-smiffy.com\/wp-content\/uploads\/2025\/08\/spitfire-generic.png\"\n           alt=\"Aircraft\" loading=\"eager\" decoding=\"async\" \/>\n      <div class=\"ac-title\">Aircraft<\/div>\n      <div id=\"acftText\" class=\"ac-sub\">Waiting\u2026<\/div>\n    <\/div>\n\n    <div class=\"row\"><span class=\"k\">\ud83d\udef0\ufe0f Position<\/span> <span id=\"posText\">Waiting\u2026<\/span><\/div>\n    <div class=\"row\"><span class=\"k\">\ud83e\udded Heading<\/span>  <span id=\"hdgText\">\u2014<\/span>\u00b0<\/div>\n    <div class=\"row\"><span class=\"k\">\ud83d\uddfb Altitude<\/span> <span id=\"altText\">\u2014<\/span> ft<\/div>\n    <div class=\"row\"><span class=\"k\">\u2708\ufe0f Speed<\/span>    <span id=\"gsText\">\u2014<\/span> kts<\/div>\n    <div class=\"row\"><span class=\"k\">\ud83d\udccd Phase<\/span>    <span id=\"phaseText\">\u2014<\/span><\/div>\n    <div class=\"row\"><span class=\"k\">\ud83d\udeeb Departure<\/span><span id=\"depText\">\u2014<\/span><\/div>\n    <div class=\"row\"><span class=\"k\">\ud83d\udeec Arrival<\/span>  <span id=\"arrText\">\u2014<\/span><\/div>\n  <\/aside>\n<\/div>\n\n<script>\nwindow.addEventListener('DOMContentLoaded', () => {\n  \/\/ ================= SETTINGS =================\n  const WS_URL = 'wss:\/\/flying.sarge-smiffy.com\/ws';\n\n  let   KEEP_CENTERED = true;       \/\/ toggle with button\n  const CENTER_DEADBAND_M  = 6;     \/\/ recenter only if drift > 6 m\n  const RENDER_THROTTLE_MS = 300;   \/\/ min time between DOM\/map updates\n\n  const PHASE_STABLE_MS    = 2000;  \/\/ phase smoothing\n  const ACFT_STABLE_MS     = 2500;  \/\/ aircraft image identity smoothing\n\n  const TRAIL_MIN_SPACING_M = 100;\n  const TRAIL_MAX_POINTS    = 2000;\n\n  const MARKER_ICON_URL = 'https:\/\/flying.sarge-smiffy.com\/wp-content\/uploads\/2025\/08\/noun-airplane-676.png';\n\n  \/\/ ================= MAP =================\n  const map = L.map('map', { zoomControl:true }).setView([51.5074,-0.1278], 7);\n  L.tileLayer('https:\/\/{s}.tile.openstreetmap.org\/{z}\/{x}\/{y}.png', {\n    attribution:'&copy; OpenStreetMap contributors'\n  }).addTo(map);\n\n  \/\/ Rotating DivIcon marker\n  const markerHtml = `<img decoding=\"async\" class=\"ac-marker-img\" src=\"${MARKER_ICON_URL}\" alt=\"\">`;\n  const AircraftDivIcon = L.divIcon({ className:'ac-marker', html:markerHtml, iconSize:[32,32], iconAnchor:[16,16] });\n  const marker = L.marker(map.getCenter(), { icon: AircraftDivIcon }).addTo(map);\n\n  let lastHeading = null;\n  function setMarkerHeading(deg){\n    const el = marker.getElement(); if (!el) return;\n    const img = el.querySelector('.ac-marker-img');\n    const d = Math.round(((deg%360)+360)%360);\n    if (lastHeading !== null && Math.abs(d - lastHeading) < 1) return;\n    lastHeading = d;\n    if (img) img.style.transform = `rotate(${d}deg)`;\n  }\n\n  \/\/ Trail\n  const trailPts = [];\n  let lastTrailLL = null, lastCenterLL = null;\n  const trail = L.polyline([], { color:'#F15500', weight:4, opacity:.9 }).addTo(map);\n  function addTrail(lat, lon){\n    const ll = L.latLng(lat, lon);\n    if (!lastTrailLL || lastTrailLL.distanceTo(ll) >= TRAIL_MIN_SPACING_M){\n      trailPts.push([lat, lon]);\n      if (trailPts.length > TRAIL_MAX_POINTS) trailPts.shift();\n      trail.setLatLngs(trailPts);\n      lastTrailLL = ll;\n    }\n  }\n  window.addEventListener('resize', () => setTimeout(() => map.invalidateSize(), 200));\n\n  \/\/ ================= UI REFS =================\n  const wsDot   = document.getElementById('wsDot');\n  const wsText  = document.getElementById('wsText');\n  const hb      = document.getElementById('hb');\n  const hbText  = document.getElementById('hbText');\n  const keepAwakeBtn = document.getElementById('keepAwakeBtn');\n  const followBtn    = document.getElementById('followBtn');\n\n  const el = {\n    pos:   document.getElementById('posText'),\n    alt:   document.getElementById('altText'),\n    gs:    document.getElementById('gsText'),\n    hdg:   document.getElementById('hdgText'),\n    phase: document.getElementById('phaseText'),\n    eta:   document.getElementById('etaText'),\n    dep:   document.getElementById('depText'),\n    arr:   document.getElementById('arrText'),\n    acftImg:  document.getElementById('acftImg'),\n    acftText: document.getElementById('acftText'),\n  };\n\n  \/\/ ================= HELPERS =================\n  const clean = s => typeof s === 'string' ? s.trim() : '';\n  function setText(n,t){ if(n && n.textContent !== t) n.textContent = t; }\n  function fmtPos(lat, lon){\n    const la = Math.abs(lat).toFixed(5), lo = Math.abs(lon).toFixed(5);\n    return `${la}\u00b0 ${lat>=0?'N':'S'}, ${lo}\u00b0 ${lon>=0?'E':'W'}`;\n  }\n  function fmtEta(mins, distNm, gs){\n    const m = Number(mins);\n    if (Number.isFinite(m)){ const h=Math.floor(m\/60), r=m%60; return h?`${h}h ${r}m`:`${r}m`; }\n    const d=Number(distNm), v=Number(gs);\n    if (Number.isFinite(d)&&Number.isFinite(v)&&v>1){ const mm=Math.round((d\/v)*60), h=Math.floor(mm\/60), r=mm%60; return h?`${h}h ${r}m`:`${r}m`; }\n    return '\u2014';\n  }\n\n  \/\/ ========== Phase debouncer (ignore empty\/'Unknown') ==========\n  let phaseShown = 'Unknown', phaseCand = 'Unknown', phaseSince = 0;\n  function stablePhase(newPhase){\n    const now = Date.now();\n    const raw = (typeof newPhase === 'string' ? newPhase.trim() : '');\n    if (!raw || raw.toLowerCase() === 'unknown') return phaseShown;\n    if (phaseShown === 'Unknown'){ phaseShown = raw; phaseCand = raw; phaseSince = now; return phaseShown; }\n    if (raw !== phaseCand){ phaseCand = raw; phaseSince = now; }\n    if (now - phaseSince >= PHASE_STABLE_MS) phaseShown = phaseCand;\n    return phaseShown;\n  }\n\n  \/\/ ========== ICAO debouncers (per-field blockable) ==========\n  const ICAO_STABLE_MS = 3000;\n  let depShown='\u2014', depCand='\u2014', depSince=0;\n  let arrShown='\u2014', arrCand='\u2014', arrSince=0;\n  let icaoBlockDep = false;     \/\/ block Departure updates\n  let icaoBlockArr = true;      \/\/ block Arrival by default (stays blank until landing)\n\n  function stableIcao(newVal, which){\n    const blocked = (which === 'dep') ? icaoBlockDep : icaoBlockArr;\n    if (blocked) return (which === 'dep') ? depShown : arrShown;\n\n    const val = (typeof newVal === 'string' ? newVal.trim().toUpperCase() : '') || '\u2014';\n    const now = Date.now();\n\n    if (which === 'dep'){\n      if (depShown === '\u2014' && val !== '\u2014') { depShown = depCand = val; depSince = now; return depShown; }\n      if (val !== depCand){ depCand = val; depSince = now; }\n      if (now - depSince >= ICAO_STABLE_MS) depShown = depCand;\n      return depShown;\n    } else {\n      if (arrShown === '\u2014' && val !== '\u2014') { arrShown = arrCand = val; arrSince = now; return arrShown; }\n      if (val !== arrCand){ arrCand = val; arrSince = now; }\n      if (now - arrSince >= ICAO_STABLE_MS) arrShown = arrCand;\n      return arrShown;\n    }\n  }\n\n  \/\/ ========== Auto-reset & gating logic ==========\n  const PARK_RESET_MS = 120000; \/\/ 2 minutes parked\n  let parkedSince = 0;\n  let didAutoResetPark = false;\n  let lastAirborne = false;\n\n  function clearDepArrDebouncers(){ depShown='\u2014';depCand='\u2014';depSince=0; arrShown='\u2014';arrCand='\u2014';arrSince=0; }\n  function resetDepArrUI(){ setText(el.dep,'\u2014'); setText(el.arr,'\u2014'); lastVals.dep='\u2014'; lastVals.arr='\u2014'; }\n\n  const isParkedPhase   = ph => typeof ph === 'string' && \/parked\/i.test(ph);\n  const isTaxiPhase     = ph => typeof ph === 'string' && \/taxi\/i.test(ph);\n  const isAirbornePhase = ph => typeof ph === 'string' && \/(climb|cruise|descent|approach)\/i.test(ph);\n\n  function maybeAutoResetAndGate(phaseStr){\n    const parked = isParkedPhase(phaseStr);\n    const taxi   = isTaxiPhase(phaseStr);\n    const air    = isAirbornePhase(phaseStr);\n\n    \/\/ Detect landing (airborne -> on-ground)\n    if (!air && (taxi || parked) && lastAirborne){\n      \/\/ Just landed \u2192 allow ARRIVAL to populate\n      icaoBlockArr = false;\n      lastAirborne = false;\n    }\n\n    \/\/ Track airborne state\n    if (air) lastAirborne = true;\n\n    \/\/ Clear & block after 2 minutes parked\n    if (parked){\n      if (!parkedSince) parkedSince = Date.now();\n\n      if (!didAutoResetPark && (Date.now() - parkedSince) >= PARK_RESET_MS){\n        clearDepArrDebouncers();\n        resetDepArrUI();\n        icaoBlockDep = true;   \/\/ keep dep cleared while parked\n        icaoBlockArr = true;   \/\/ keep arr cleared for next leg (until landing again)\n        didAutoResetPark = true;\n        lastAirborne = false;  \/\/ ready for next flight detection\n\/\/Clear the Trail\ntrailPts.length = 0;\ntrail.setLatLngs([]);\n        console.log('[auto] Cleared dep\/arr after 2 min parked; ARR will show only after next landing');\n      }\n    } else {\n      \/\/ Left parked\n      parkedSince = 0;\n\n      \/\/ If we had done the auto-reset, allow DEP to populate once we leave parked (taxi\/takeoff)\n      if (didAutoResetPark && (taxi || air)){\n        icaoBlockDep = false;\n      }\n\n      \/\/ Once we are airborne after a reset, ensure ARR stays blocked until the next landing\n      if (air) {\n        didAutoResetPark = false; \/\/ we consider the new leg started\n      }\n    }\n  }\n\n  \/\/ ========== Fleet image (big picture) ==========\n  const FLEET = [\n\t{ test:\/SPITFIRE MK ?I+|SPITFIRE\\b\/i, \turl:'https:\/\/flying.sarge-smiffy.com\/wp-content\/uploads\/2025\/08\/spitfire.png' },\n\t{ test:\/B738|737-800WL\/i,               url:'https:\/\/flying.sarge-smiffy.com\/wp-content\/uploads\/2025\/08\/737-800.png' },\n\t{ test:\/B77L|777F\/i,               \t\turl:'https:\/\/flying.sarge-smiffy.com\/wp-content\/uploads\/2025\/08\/777F.png' },\n\t{ test:\/B748|747-8F\/i,\t\t\t\t\turl:'https:\/\/flying.sarge-smiffy.com\/wp-content\/uploads\/2025\/08\/747-8F.png' },\n\t{ test:\/BE9L|C90\/i,\t\t\t\t\t\turl:'https:\/\/flying.sarge-smiffy.com\/wp-content\/uploads\/2025\/08\/c90.png' },\n\t{ test:\/H25B|850xp\/i,\t\t\t\t\t\turl:'https:\/\/flying.sarge-smiffy.com\/wp-content\/uploads\/2025\/08\/850xp.png' },\n\t{ test:\/RJ85|RJ85\/i,\t\t\t\t\t\turl:'https:\/\/flying.sarge-smiffy.com\/wp-content\/uploads\/2025\/08\/RJ85.png' },\n\t{ test:\/MD11|MD-11F\/i,\t\t\t\t\t\turl:'https:\/\/flying.sarge-smiffy.com\/wp-content\/uploads\/2025\/08\/MD-11F.png' },\n\t{ test:\/a318|a318\/i,\t\t\t\t\t\turl:'https:\/\/flying.sarge-smiffy.com\/wp-content\/uploads\/2025\/08\/a318.png' },\n\t{ test:\/B788|787-8\/i,\t\t\t\t\t\turl:'https:\/\/flying.sarge-smiffy.com\/wp-content\/uploads\/2025\/08\/787-8.png' },\n\t{ test:\/CARENADO PC12|PC-12|PC12\/i,\t\t\t\t\t\turl:'https:\/\/flying.sarge-smiffy.com\/wp-content\/uploads\/2025\/08\/pc-12.png' },\n\t\t\n\n  ];\n  const FLEET_FALLBACK = 'https:\/\/flying.sarge-smiffy.com\/wp-content\/uploads\/2025\/08\/not-found.png';\n\n  const norm = s => (typeof s === 'string' ? s.trim() : '');\n  function fleetImageFor(p){\n    const hay = [norm(p.aircraftTitle), norm(p.aircraftType), norm(p.aircraftModel)].filter(Boolean).join(' ');\n    for (const r of FLEET) if (r.test.test(hay)) return r.url;\n    return FLEET_FALLBACK;\n  }\n    let acft = { curKey:'', curImg:FLEET_FALLBACK, curLabel:'', candKey:'', candImg:'', candLabel:'', since:0 };\n  function acftKey(p){\n    const title = norm(p.aircraftTitle);\n\tconst type = norm(p.aircraftType);\n\tconst model = norm(p.aircraftModel);\n\treturn [title, type, model].filter(Boolean).join('|').toLowerCase();\n  }\n  function updateAircraftIdentity(p){\n    const key = acftKey(p); if (!key) return;\n\tconst ACFT_STABLE_MS = 2500; \/\/milliseconds to wait before changing aircraft label\/image\n    if (key !== acft.candKey){ acft.candKey=key; acft.candLabel=norm(p.aircraftTitle)||[norm(p.aircraftType),norm(p.aircraftModel)].filter(Boolean).join(' '); acft.candImg=fleetImageFor(p); acft.since=Date.now(); return; }\n    if (key === acft.curKey) return;\n    if (Date.now() - acft.since < ACFT_STABLE_MS) return;\n\n    const preload = new Image();\n    preload.onload = () => {\n      if (acft.curKey === key) return;\n      el.acftImg.style.opacity = '0';\n      requestAnimationFrame(() => {\n        el.acftImg.src = acft.candImg || FLEET_FALLBACK;\n        el.acftImg.onload = () => { el.acftImg.style.opacity = '1'; };\n        setText(el.acftText, acft.candLabel || '\u2014');\n        acft.curKey = key; acft.curImg = acft.candImg; acft.curLabel = acft.candLabel;\n      });\n    };\n    preload.src = acft.candImg || FLEET_FALLBACK;\n  }\n\n  \/\/ ================= WAKE LOCK + CENTER =================\n  let wakeLock = null;\n  async function toggleWake(){\n    try{\n      if (!wakeLock){\n        if ('wakeLock' in navigator){\n          wakeLock = await navigator.wakeLock.request('screen');\n          wakeLock.addEventListener('release', () => {\n            wakeLock = null; keepAwakeBtn.setAttribute('aria-pressed','false'); keepAwakeBtn.textContent='Keep screen on: OFF';\n          });\n          keepAwakeBtn.setAttribute('aria-pressed','true'); keepAwakeBtn.textContent='Keep screen on: ON';\n        }else{ alert('Wake Lock API not supported.'); }\n      }else{ await wakeLock.release(); }\n    }catch(e){ console.log('WakeLock error', e); }\n  }\n  keepAwakeBtn.addEventListener('click', toggleWake);\n\n  followBtn.addEventListener('click', () => {\n    KEEP_CENTERED = !KEEP_CENTERED;\n    followBtn.textContent = `Center on aircraft: ${KEEP_CENTERED ? 'ON' : 'OFF'}`;\n  });\n\n  \/\/ ================= RENDER THROTTLE =================\n  let pending = null, renderScheduled = false, lastRenderAt = 0, lastVals = {};\n  function scheduleRender(){\n    if (renderScheduled) return;\n    renderScheduled = true;\n    const delay = Math.max(0, RENDER_THROTTLE_MS - (Date.now() - lastRenderAt));\n    setTimeout(() => { renderScheduled = false; doRender(); }, delay);\n  }\n\n  function doRender(){\n    const p = pending; pending = null; if (!p) return;\n    lastRenderAt = Date.now();\n\n    if (typeof p.latitude !== 'number' || typeof p.longitude !== 'number') return;\n    const lat = Number(p.latitude), lon = Number(p.longitude);\n    const alt = Math.round(Number(p.altitude) || 0);\n    const gs  = Math.round(Number(p.groundspeed) || 0);\n    const hdg = Math.round((((Number(p.heading)||0) % 360) + 360) % 360);\n\n    marker.setLatLng([lat, lon]);\n    setMarkerHeading(hdg);\n    addTrail(lat, lon);\n\n    if (KEEP_CENTERED){\n      const ll = L.latLng(lat, lon);\n      if (!lastCenterLL || lastCenterLL.distanceTo(ll) > CENTER_DEADBAND_M){\n        map.setView([lat, lon], map.getZoom(), { animate:false });\n        lastCenterLL = ll;\n      }\n    }\n\n    \/\/ Phase smoothing + gating (arrival stays blank until landed; clear after 2 min parked)\n    const phaseStr = stablePhase(p.phase);\n    maybeAutoResetAndGate(phaseStr);\n\n    \/\/ Fleet identity (smoothed)\n    updateAircraftIdentity(p);\n\n    \/\/ Text (only update if changed)\n    const posStr   = fmtPos(lat, lon);\n    const altStr   = Number.isFinite(alt) ? `${alt}` : '\u2014';\n    const gsStr    = Number.isFinite(gs)  ? `${gs}`  : '\u2014';\n    const hdgStr   = Number.isFinite(hdg) ? `${hdg}` : '\u2014';\n    const etaStr   = fmtEta(p.etaMinutes, p.distanceToDestNm, p.groundspeed);\n\n    \/\/ Use blockable debouncers\n    const depStr   = stableIcao(p.departure, 'dep');\n    const arrStr   = stableIcao(p.destination, 'arr');\n\n    if (lastVals.pos !== posStr)   { setText(el.pos, posStr); lastVals.pos = posStr; }\n    if (lastVals.alt !== altStr)   { setText(el.alt, altStr); lastVals.alt = altStr; }\n    if (lastVals.gs  !== gsStr)    { setText(el.gs,  gsStr);  lastVals.gs  = gsStr; }\n    if (lastVals.hdg !== hdgStr)   { setText(el.hdg, hdgStr); lastVals.hdg = hdgStr; }\n    if (lastVals.phase !== phaseStr){ setText(el.phase, phaseStr); lastVals.phase = phaseStr; }\n    if (lastVals.eta !== etaStr)   { setText(el.eta, etaStr); lastVals.eta = etaStr; }\n    if (lastVals.dep !== depStr)   { setText(el.dep, depStr); lastVals.dep = depStr; }\n    if (lastVals.arr !== arrStr)   { setText(el.arr, arrStr); lastVals.arr = arrStr; }\n  }\n\/\/ ================= WEBSOCKET =================\nwindow.ws = null;\nlet lastMsgAt = 0;\n\nfunction connectWS(){\n  const startedAt = Date.now();\nwindow.ws = new WebSocket(WS_URL);\n\n  ws.onopen = () => {\n    wsDot.style.background = '#10B981';\n    wsText.textContent = 'WS: connected';\n  };\n\n  ws.onerror = () => {\n    wsDot.style.background = '#EF4444';\n    wsText.textContent = 'WS: error (see console)';\n  };\n\n  ws.onclose = (e) => {\n    const ms = Date.now() - startedAt;\n    wsDot.style.background = '#9ca3af';\n    wsText.textContent = `WS: closed code=${e.code} after ${ms}ms`;\n    setTimeout(connectWS, 1500);\n  };\n\n  ws.onmessage = ev => {\n    const raw = typeof ev.data === 'string' ? ev.data.trim() : '';\n    if (!raw) return;\n\n    \/\/ === Handle CLEAR_TRAIL commands ===\n    if (raw === 'CLEAR_TRAIL') {\n      trailPts.length = 0;\n      trail.setLatLngs([]);\n      lastTrailLL = null;\n      console.log('[WS] Trail cleared (raw string)');\n      return;\n    }\n\n    if (raw.startsWith('{')) {\n      let p;\n      try {\n        const m = JSON.parse(raw);\n        if (m.command === 'CLEAR_TRAIL') {\n          trailPts.length = 0;\n          trail.setLatLngs([]);\n          lastTrailLL = null;\n          console.log('[WS] Trail cleared (JSON command)');\n          return;\n        }\n        p = m.payload || m;\n      } catch {\n        return;\n      }\n\n      lastMsgAt = Date.now();\n      hb.style.background = '#10B981';\n      hbText.textContent = 'Receiving telemetry';\n      pending = p;\n      scheduleRender();\n    }\n  };\n}\n\nconnectWS();\n\n\n  \/\/ Heartbeat\n  setInterval(() => {\n    const ok = (Date.now() - lastMsgAt) < 3500;\n    hb.style.background = ok ? '#10B981' : '#9CA3AF';\n    if (!ok){ hbText.textContent = 'No data\u2026'; }\n  }, 1000);\n});\n<\/script>\n\n\n\n<p><\/p>\n","protected":false},"excerpt":{"rendered":"<p>WS: connecting\u2026 No data\u2026 Keep screen on: OFF Center on aircraft: ON Aircraft Waiting\u2026 \ud83d\udef0\ufe0f Position Waiting\u2026 \ud83e\udded Heading \u2014\u00b0 \ud83d\uddfb Altitude \u2014 ft \u2708\ufe0f Speed \u2014 kts \ud83d\udccd Phase \u2014 \ud83d\udeeb Departure\u2014 \ud83d\udeec Arrival \u2014<\/p>\n","protected":false},"author":1,"featured_media":0,"parent":0,"menu_order":0,"comment_status":"closed","ping_status":"closed","template":"","meta":{"footnotes":""},"class_list":["post-2327","page","type-page","status-publish","hentry"],"jetpack_sharing_enabled":true,"_links":{"self":[{"href":"https:\/\/flying.sarge-smiffy.com\/index.php?rest_route=\/wp\/v2\/pages\/2327","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/flying.sarge-smiffy.com\/index.php?rest_route=\/wp\/v2\/pages"}],"about":[{"href":"https:\/\/flying.sarge-smiffy.com\/index.php?rest_route=\/wp\/v2\/types\/page"}],"author":[{"embeddable":true,"href":"https:\/\/flying.sarge-smiffy.com\/index.php?rest_route=\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/flying.sarge-smiffy.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=2327"}],"version-history":[{"count":91,"href":"https:\/\/flying.sarge-smiffy.com\/index.php?rest_route=\/wp\/v2\/pages\/2327\/revisions"}],"predecessor-version":[{"id":2518,"href":"https:\/\/flying.sarge-smiffy.com\/index.php?rest_route=\/wp\/v2\/pages\/2327\/revisions\/2518"}],"wp:attachment":[{"href":"https:\/\/flying.sarge-smiffy.com\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=2327"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}