const FLIGHT_SERVER = "https://pas-flight-server-new.dsouza-neil2011.workers.dev"; const STALE_AFTER_MS = 30000; // hide aircraft that haven't reported for 30s const POLL_MS = 3000; const aircraftMarkers = {}; const flightMap = L.map("flight-map", { zoomControl: true, attributionControl: true }).setView([-33.86, 151.21], 9); const radarSweep = document.createElement("div"); radarSweep.className = "radar-sweep"; flightMap.getContainer().appendChild(radarSweep); L.tileLayer("https://tile.openstreetmap.de/{z}/{x}/{y}.png", { maxZoom: 18, attribution: "© OpenStreetMap contributors" }).addTo(flightMap); const airportLayer = L.layerGroup().addTo(flightMap); const runwayLayer = L.layerGroup().addTo(flightMap); const runwayLabelLayer = L.layerGroup(); // only shown when zoomed in (thousands of labels are slow) function updateLabelVisibility() { const show = flightMap.getZoom() >= 10; if (show && !flightMap.hasLayer(runwayLabelLayer)) runwayLabelLayer.addTo(flightMap); if (!show && flightMap.hasLayer(runwayLabelLayer)) flightMap.removeLayer(runwayLabelLayer); } flightMap.on("zoomend", updateLabelVisibility); // ---------- helpers ---------- // Flight data comes from other people's browsers: never put it into innerHTML unescaped function escapeHTML(value) { return String(value ?? "") .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } // Proper CSV parser (handles quoted fields containing commas, e.g. airport names) function parseCSV(text) { const rows = []; let row = [], field = "", inQuotes = false; for (let i = 0; i < text.length; i++) { const c = text[i]; if (inQuotes) { if (c === '"' && text[i + 1] === '"') { field += '"'; i++; } else if (c === '"') inQuotes = false; else field += c; } else if (c === '"') { inQuotes = true; } else if (c === ",") { row.push(field); field = ""; } else if (c === "\n") { row.push(field); field = ""; rows.push(row); row = []; } else if (c !== "\r") { field += c; } } if (field || row.length) { row.push(field); rows.push(row); } return rows; } async function loadCSV(path) { const response = await fetch(path); if (!response.ok) throw new Error(`${path}: HTTP ${response.status}`); const rows = parseCSV(await response.text()); const headers = rows.shift() || []; const index = (name) => headers.indexOf(name); return { rows, index }; } // ---------- ATC messages (admin only) ---------- const atcInput = document.getElementById("atc-message"); const sendButton = document.getElementById("send-atc"); const panelStatus = document.querySelector(".panel-status"); function adminToken() { return sessionStorage.getItem("pasAdminToken"); } if (!adminToken()) { // Sending is also enforced by the server; this just makes the UI honest atcInput.disabled = true; sendButton.disabled = true; atcInput.placeholder = "Admin login required to send ATC instructions"; } sendButton.addEventListener("click", sendATCMessage); atcInput.addEventListener("keydown", (event) => { if (event.key === "Enter") sendATCMessage(); }); async function sendATCMessage() { const message = atcInput.value.trim(); if (!message || !adminToken()) return; sendButton.disabled = true; sendButton.textContent = "SENDING..."; try { const response = await fetch(`${FLIGHT_SERVER}/atc`, { method: "POST", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${adminToken()}` }, body: JSON.stringify({ message }) }); const result = await response.json().catch(() => ({})); if (response.ok && result.success) { panelStatus.textContent = "MESSAGE SENT"; panelStatus.style.color = "#58b0ff"; atcInput.value = ""; } else if (response.status === 401) { sessionStorage.removeItem("pasAdminToken"); panelStatus.textContent = "SESSION EXPIRED — LOG IN AGAIN"; panelStatus.style.color = "#ff6b6b"; } else { panelStatus.textContent = "SEND FAILED"; panelStatus.style.color = "#ff6b6b"; } setTimeout(() => { panelStatus.textContent = "STANDBY"; panelStatus.style.color = ""; }, 2500); } catch (error) { console.error("ATC SEND ERROR:", error); panelStatus.textContent = "CONNECTION ERROR"; panelStatus.style.color = "#ff6b6b"; } finally { sendButton.disabled = !adminToken(); sendButton.textContent = "SEND →"; } } // ---------- live aircraft ---------- let loadingAircraft = false; function makeAircraftIcon() { return L.divIcon({ className: "aircraft-marker", html: ``, iconSize: [44, 44], iconAnchor: [22, 22] }); } async function loadAircraft() { if (loadingAircraft) return; // don't stack requests if the server is slow loadingAircraft = true; try { const response = await fetch(FLIGHT_SERVER); if (!response.ok) throw new Error(`HTTP ${response.status}`); const flights = await response.json(); const now = Date.now(); const activeFlights = (Array.isArray(flights) ? flights : []).filter((flight) => flight && flight.pilotId && Array.isArray(flight.position) && Number.isFinite(Number(flight.position[0])) && Number.isFinite(Number(flight.position[1])) && // drop stale entries so a pilot who left doesn't leave a frozen plane behind (!flight.updatedAt || now - Number(flight.updatedAt) < STALE_AFTER_MS) ); document.getElementById("pilot-count").textContent = activeFlights.length; // ----- side panel ----- const aircraftList = document.getElementById("aircraft-list"); aircraftList.innerHTML = ""; if (activeFlights.length === 0) { aircraftList.innerHTML = `
NO ACTIVE AIRCRAFT
`; } activeFlights.forEach((flight) => { const card = document.createElement("div"); card.className = "aircraft-card"; card.innerHTML = `
${escapeHTML(flight.pilotId)} LIVE
${escapeHTML(flight.aircraft || "Aircraft")}
ALT${Math.round(Number(flight.position[2]) || 0)} FT
SPD${Math.round(Number(flight.speed) || 0)} KTS
HDG${Math.round(Number(flight.heading) || 0)}°
`; card.addEventListener("click", () => { flightMap.flyTo([Number(flight.position[0]), Number(flight.position[1])], 12, { duration: 1 }); }); aircraftList.appendChild(card); }); // ----- map markers ----- const seen = new Set(); activeFlights.forEach((flight) => { const latLng = [Number(flight.position[0]), Number(flight.position[1])]; const label = `${escapeHTML(flight.pilotId)} — ${escapeHTML(flight.aircraft || "Aircraft")}`; seen.add(flight.pilotId); let marker = aircraftMarkers[flight.pilotId]; if (!marker) { marker = L.marker(latLng, { icon: makeAircraftIcon() }); marker.bindTooltip(label, { direction: "top" }); marker.addTo(flightMap); aircraftMarkers[flight.pilotId] = marker; } else { marker.setLatLng(latLng); marker.setTooltipContent(label); } const iconElement = marker.getElement()?.querySelector(".aircraft-icon"); const heading = Number(flight.heading); if (iconElement && Number.isFinite(heading)) { iconElement.style.transform = `rotate(${heading}deg)`; } }); // remove markers for aircraft that are gone Object.keys(aircraftMarkers).forEach((pilotId) => { if (!seen.has(pilotId)) { flightMap.removeLayer(aircraftMarkers[pilotId]); delete aircraftMarkers[pilotId]; } }); } catch (error) { console.error("FLIGHT DATA ERROR:", error); } finally { loadingAircraft = false; } } loadAircraft(); setInterval(loadAircraft, POLL_MS); // ---------- airports & runways ---------- async function loadAirports() { try { const { rows, index } = await loadCSV("data/airports_filtered.csv"); const latI = index("latitude_deg"), lonI = index("longitude_deg"); const nameI = index("name"), identI = index("ident"); rows.forEach((c) => { const lat = parseFloat(c[latI]), lon = parseFloat(c[lonI]); if (Number.isNaN(lat) || Number.isNaN(lon)) return; L.circleMarker([lat, lon], { radius: 4, color: "#58b0ff", fillColor: "#58b0ff", fillOpacity: 0.8, weight: 1 }) .bindTooltip(`${escapeHTML(c[identI])} — ${escapeHTML(c[nameI])}`, { direction: "top" }) .addTo(airportLayer); }); } catch (error) { console.error("AIRPORT DATA ERROR:", error); } } async function loadRunways() { try { const { rows, index } = await loadCSV("data/runways_filtered.csv"); const lat1I = index("le_latitude_deg"), lon1I = index("le_longitude_deg"); const lat2I = index("he_latitude_deg"), lon2I = index("he_longitude_deg"); const airportI = index("airport_ident"); const leI = index("le_ident"), heI = index("he_ident"); const labelIcon = (text) => L.divIcon({ className: "runway-label", html: escapeHTML(text), iconSize: [40, 20], iconAnchor: [20, 10] }); rows.forEach((c) => { const lat1 = parseFloat(c[lat1I]), lon1 = parseFloat(c[lon1I]); const lat2 = parseFloat(c[lat2I]), lon2 = parseFloat(c[lon2I]); if ([lat1, lon1, lat2, lon2].some(Number.isNaN)) return; L.polyline([[lat1, lon1], [lat2, lon2]], { color: "#ffffff", weight: 3, opacity: 0.8 }) .bindTooltip(`${escapeHTML(c[airportI])} — RWY ${escapeHTML(c[leI])} / ${escapeHTML(c[heI])}`, { direction: "top" }) .addTo(runwayLayer); L.marker([lat1, lon1], { icon: labelIcon(c[leI]), interactive: false }).addTo(runwayLabelLayer); L.marker([lat2, lon2], { icon: labelIcon(c[heI]), interactive: false }).addTo(runwayLabelLayer); }); updateLabelVisibility(); } catch (error) { console.error("RUNWAY DATA ERROR:", error); } } loadAirports(); loadRunways();