/** PlaneFYI — Global Alpine.js components and utilities. */ (function() { var tip = null; document.addEventListener('mouseover', function(e) { var link = e.target.closest('.term-link, .tool-link'); if (!link) return; var def = link.getAttribute('title'); if (!def) return; link.removeAttribute('title'); link.dataset.definition = def; tip = document.createElement('div'); tip.className = 'fixed z-50 max-w-xs px-3 py-2 text-xs text-white bg-slate-900 dark:bg-slate-100 dark:text-slate-900 rounded-lg shadow-lg pointer-events-none'; tip.textContent = def; document.body.appendChild(tip); var r = link.getBoundingClientRect(); tip.style.left = Math.min(r.left, window.innerWidth - tip.offsetWidth - 8) + 'px'; tip.style.top = (r.top - tip.offsetHeight - 6) + 'px'; }); document.addEventListener('mouseout', function(e) { var link = e.target.closest('.term-link, .tool-link'); if (!link || !tip) return; if (link.dataset.definition) { link.setAttribute('title', link.dataset.definition); delete link.dataset.definition; } tip.remove(); tip = null; }); })(); function toast() { return { visible: false, message: '', timeout: null, show(detail) { this.message = detail.message || detail || 'Done!'; this.visible = true; clearTimeout(this.timeout); this.timeout = setTimeout(() => this.visible = false, 2000); } } } function rangeMap() { return { selectedAircraft: '', selectedOrigin: 'JFK', rangeKm: 0, originLat: 40.6413, originLng: -73.7781, originLabel: 'New York JFK', map: null, circle: null, marker: null, airports: [ { code: 'JFK', name: 'New York JFK', lat: 40.6413, lng: -73.7781 }, { code: 'LHR', name: 'London Heathrow', lat: 51.4700, lng: -0.4543 }, { code: 'CDG', name: 'Paris CDG', lat: 49.0097, lng: 2.5479 }, { code: 'NRT', name: 'Tokyo Narita', lat: 35.7720, lng: 140.3929 }, { code: 'ICN', name: 'Seoul Incheon', lat: 37.4602, lng: 126.4407 }, { code: 'SIN', name: 'Singapore Changi', lat: 1.3644, lng: 103.9915 }, { code: 'DXB', name: 'Dubai', lat: 25.2528, lng: 55.3644 }, { code: 'SYD', name: 'Sydney', lat: -33.9461, lng: 151.1772 }, { code: 'LAX', name: 'Los Angeles', lat: 33.9416, lng: -118.4085 }, { code: 'FRA', name: 'Frankfurt', lat: 50.0379, lng: 8.5622 }, { code: 'HKG', name: 'Hong Kong', lat: 22.3080, lng: 113.9185 }, { code: 'PEK', name: 'Beijing Capital', lat: 40.0799, lng: 116.6031 }, { code: 'GRU', name: 'Sao Paulo', lat: -23.4356, lng: -46.4731 }, { code: 'JNB', name: 'Johannesburg', lat: -26.1392, lng: 28.2460 }, ], initMap() { this.map = L.map('range-map', { center: [20, 0], zoom: 2, minZoom: 2, maxZoom: 8, worldCopyJump: true, }); L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap', maxZoom: 19, }).addTo(this.map); /* Click on map to set custom origin */ this.map.on('click', (e) => { this.originLat = e.latlng.lat; this.originLng = e.latlng.lng; this.originLabel = e.latlng.lat.toFixed(2) + ', ' + e.latlng.lng.toFixed(2); this.selectedOrigin = ''; this.drawCircle(); }); /* Place initial marker */ this.placeMarker(); }, changeOrigin() { const apt = this.airports.find(a => a.code === this.selectedOrigin); if (apt) { this.originLat = apt.lat; this.originLng = apt.lng; this.originLabel = apt.name + ' (' + apt.code + ')'; this.drawCircle(); } }, updateRange() { const select = document.getElementById('range-aircraft'); const option = select.options[select.selectedIndex]; this.rangeKm = option ? parseInt(option.dataset.range || '0', 10) : 0; this.drawCircle(); }, placeMarker() { if (this.marker) { this.map.removeLayer(this.marker); } this.marker = L.marker([this.originLat, this.originLng]).addTo(this.map); }, drawCircle() { this.placeMarker(); if (this.circle) { this.map.removeLayer(this.circle); } if (this.rangeKm > 0) { this.circle = L.circle([this.originLat, this.originLng], { radius: this.rangeKm * 1000, color: '#14b8a6', fillColor: '#14b8a6', fillOpacity: 0.1, weight: 2, }).addTo(this.map); this.map.fitBounds(this.circle.getBounds(), { padding: [20, 20] }); } }, }; } function seatComparison() { return { selectedAircraft: '', fleetData: [], loading: false, async loadFleetData() { if (!this.selectedAircraft) { this.fleetData = []; return; } this.loading = true; try { const resp = await fetch('/api/tools/seat-comparison/?aircraft_type=' + encodeURIComponent(this.selectedAircraft)); const data = await resp.json(); this.fleetData = data.results || []; } catch (e) { this.fleetData = []; } this.loading = false; } }; } function fleetAge() { return { selectedAirline: '', fleetData: [], summary: {}, loading: false, get hasAgeData() { return this.fleetData.some(f => f.average_age != null); }, get maxAge() { const ages = this.fleetData.filter(f => f.average_age != null).map(f => f.average_age); return ages.length > 0 ? Math.max(...ages) : 30; }, async loadFleet() { if (!this.selectedAirline) { this.fleetData = []; this.summary = {}; return; } this.loading = true; try { const resp = await fetch('/api/tools/fleet-age/?airline=' + encodeURIComponent(this.selectedAirline)); const data = await resp.json(); this.fleetData = data.results || []; this.summary = data.summary || {}; } catch (e) { this.fleetData = []; this.summary = {}; } this.loading = false; } }; } function aircraftComparison() { return { queryA: '', queryB: '', aircraftA: null, aircraftB: null, resultsA: [], resultsB: [], openA: false, openB: false, loading: false, presets: [ { a: 'boeing-737-800', b: 'airbus-a320-200', label: '737-800 vs A320' }, { a: 'boeing-747-400', b: 'airbus-a380-800', label: '747-400 vs A380' }, { a: 'boeing-787-9', b: 'airbus-a350-900', label: '787-9 vs A350-900' }, { a: 'boeing-777-300er', b: 'boeing-747-8i', label: '777-300ER vs 747-8' }, ], init() { const p = new URLSearchParams(window.location.search); const a = p.get('a'), b = p.get('b'); if (a) this.loadBySlug('a', a); if (b) this.loadBySlug('b', b); }, async searchAircraft(side) { const q = side === 'a' ? this.queryA : this.queryB; if (!q || q.length < 2) { if (side === 'a') this.resultsA = []; else this.resultsB = []; return; } try { const resp = await fetch('/api/v1/aircraft-types/?search=' + encodeURIComponent(q) + '&page_size=10'); const data = await resp.json(); const items = (data.results || []).slice(0, 10); if (side === 'a') { this.resultsA = items; this.openA = true; } else { this.resultsB = items; this.openB = true; } } catch (e) { if (side === 'a') this.resultsA = []; else this.resultsB = []; } }, async loadBySlug(side, slug) { this.loading = true; try { const resp = await fetch('/api/v1/aircraft-types/' + encodeURIComponent(slug) + '/'); if (resp.ok) { const data = await resp.json(); if (side === 'a') { this.aircraftA = data; this.queryA = ''; } else { this.aircraftB = data; this.queryB = ''; } } } catch (e) { /* noop */ } this.loading = false; }, selectAircraft(side, item) { if (side === 'a') { this.aircraftA = item; this.queryA = ''; this.resultsA = []; this.openA = false; } else { this.aircraftB = item; this.queryB = ''; this.resultsB = []; this.openB = false; } this.updateUrl(); }, clearAircraft(side) { if (side === 'a') { this.aircraftA = null; this.queryA = ''; } else { this.aircraftB = null; this.queryB = ''; } this.updateUrl(); }, async loadPreset(slugA, slugB) { await Promise.all([this.loadBySlug('a', slugA), this.loadBySlug('b', slugB)]); this.updateUrl(); }, updateUrl() { const p = new URLSearchParams(); if (this.aircraftA && this.aircraftA.slug) p.set('a', this.aircraftA.slug); if (this.aircraftB && this.aircraftB.slug) p.set('b', this.aircraftB.slug); const qs = p.toString(); const url = window.location.pathname + (qs ? '?' + qs : ''); window.history.replaceState({}, '', url); }, shortName(name) { return name ? name.replace(/^(Boeing|Airbus|Embraer|Bombardier|ATR)\s+/, '') : ''; }, fmtKm(v) { return v ? Number(v).toLocaleString() + ' km' : '—'; }, fmtKmh(v) { return v ? Number(v).toLocaleString() + ' km/h' : '—'; }, fmtSeats(v) { return v ? Number(v).toLocaleString() : '—'; }, fmtMeters(v) { return v ? Number(v).toFixed(2) + ' m' : '—'; }, fmtWeight(v) { return v ? Number(v).toLocaleString() + ' kg' : '—'; }, fmtYear(d) { return d ? String(d).slice(0, 4) : '—'; }, fmtEngines(count, type) { if (!count) return '—'; const t = type ? ' ' + type.charAt(0).toUpperCase() + type.slice(1) : ''; return count + '×' + t; }, fmtStatus(s) { if (!s) return '—'; return s === 'in_production' ? 'In Production' : 'Out of Production'; }, barPct(val, other) { if (!val) return 0; const max = Math.max(Number(val), Number(other || 0)); if (max === 0) return 0; return Math.round((Number(val) / max) * 100); }, }; } function tocScrollspy() { return { activeId: '', init() { const headings = document.querySelectorAll('[id]'); const ids = [...document.querySelectorAll('ol[role="list"] a[href^="#"]')].map(a => a.getAttribute('href').slice(1)); const tracked = [...headings].filter(h => ids.includes(h.id)); if (!tracked.length) return; const observer = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting) { this.activeId = entry.target.id; } }); }, { rootMargin: '-80px 0px -80% 0px' }); tracked.forEach(h => observer.observe(h)); } }; }