Files
2026-10-26 23:24:57 +00:00

628 lines
27 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ============================================================================
NUMEX CORE — SKY COMMAND · Arayüz katmanı
Radar, listeler, telemetri ve operasyon panelleri AYNI durum motorundan
(window.NUMEX) beslenir. Radar ve 3D konumları ayrı hesaplanmaz.
========================================================================== */
(function () {
'use strict';
const N = window.NUMEX;
// NUMEX-DÜZELTME (2026-10-27): Kök neden: app.js başlangıçta NUMEX.flights referansını bir kez alıp sabitliyordu; reset/seedWorld diziyi yeniden atayınca arayüz boş/eski diziyi görüyordu. Getter üzerinden her erişimde güncel dizi okunur.
const { STATE, AIRPORT, authority, weather, S } = N;
// flights/alerts canlı getter'lardan okunur (reset sonrası bayat referans olmasın)
const flights = () => N.flights;
const alerts = () => N.alerts;
const $ = (id) => document.getElementById(id);
const el = (tag, cls, html) => {
const e = document.createElement(tag);
if (cls) e.className = cls;
if (html != null) e.innerHTML = html;
return e;
};
const num = (v, d = 0) => Number(v).toFixed(d);
const esc = (s) => String(s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
let selectedId = null;
let followId = null;
/* ---------------------------------------------------------------- toast */
let toastT = null;
function toast(msg, err) {
const t = $('toast');
t.textContent = msg;
t.className = 'show' + (err ? ' err' : '');
clearTimeout(toastT);
toastT = setTimeout(() => { t.className = err ? 'err' : ''; }, 3200);
}
/* ---------------------------------------------------------------- sekmeler */
document.querySelectorAll('.tab').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
$('view-' + btn.dataset.tab).classList.add('active');
// durum SIFIRLANMAZ — yalnız render
if (btn.dataset.tab === 'airport' && window.SKY3D) window.SKY3D.resize();
if (btn.dataset.tab === 'radar') resizeRadar();
drawRadar();
});
});
/* ================================================================ RADAR */
const cv = $('radar');
const ctx = cv.getContext('2d');
let radarRange = 50; // km
let R = { w: 0, h: 0, cx: 0, cy: 0, scale: 1 };
// Park ızgarası sırası — KALICI (drawRadar içinde tanımlanırsa her karede sıfırlanır ve
// tüm uçaklar idx=0 alıp tek noktaya çizilir)
const yerIndex = {};
let yerIdxCounter = 0;
function resizeRadar() {
const wrap = cv.parentElement;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const w = wrap.clientWidth, h = wrap.clientHeight;
cv.width = Math.max(10, Math.floor(w * dpr));
cv.height = Math.max(10, Math.floor(h * dpr));
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
R.w = w; R.h = h;
R.cx = w / 2; R.cy = h / 2;
R.scale = Math.min(w, h) * 0.46 / radarRange; // px per km
}
window.addEventListener('resize', () => { resizeRadar(); if (window.SKY3D) window.SKY3D.resize(); });
$('radarRange').addEventListener('change', (e) => { radarRange = +e.target.value; resizeRadar(); drawRadar(); });
function worldToRadar(x, z) {
return { px: R.cx + x * R.scale, py: R.cy + z * R.scale };
}
function drawRadar() {
if (!R.w) resizeRadar();
const t = performance.now() / 1000;
// zemin
ctx.clearRect(0, 0, R.w, R.h);
const g = ctx.createRadialGradient(R.cx, R.cy, 8, R.cx, R.cy, Math.min(R.w, R.h) * 0.6);
g.addColorStop(0, '#08161f');
g.addColorStop(1, '#03080d');
ctx.fillStyle = g;
ctx.fillRect(0, 0, R.w, R.h);
// menzil halkaları
ctx.strokeStyle = 'rgba(79,209,224,0.13)';
ctx.lineWidth = 1;
const rings = radarRange <= 15 ? [5, 10, 15] : radarRange <= 30 ? [10, 20, 30] : radarRange <= 50 ? [10, 25, 50] : [20, 40, 80];
rings.forEach(km => {
ctx.beginPath();
ctx.arc(R.cx, R.cy, km * R.scale, 0, Math.PI * 2);
ctx.stroke();
ctx.fillStyle = 'rgba(109,131,153,.6)';
ctx.font = '9px monospace';
ctx.fillText(km + 'km', R.cx + km * R.scale - 26, R.cy - 3);
});
// kerteriz çizgileri
ctx.strokeStyle = 'rgba(79,209,224,.08)';
for (let a = 0; a < 360; a += 30) {
const r = a * Math.PI / 180;
ctx.beginPath();
ctx.moveTo(R.cx, R.cy);
ctx.lineTo(R.cx + Math.sin(r) * radarRange * R.scale, R.cy - Math.cos(r) * radarRange * R.scale);
ctx.stroke();
}
// kerteriz rakamları
ctx.fillStyle = 'rgba(109,131,153,.65)';
ctx.font = '10px monospace';
['N', 'E', 'S', 'W'].forEach((d, i) => {
const a = i * 90 * Math.PI / 180;
const rr = radarRange * R.scale + 12;
ctx.fillText(d, R.cx + Math.sin(a) * rr - 3, R.cy - Math.cos(a) * rr + 4);
});
// sweep (tarama)
const ang = (t * 1.1) % (Math.PI * 2);
const grad = ctx.createLinearGradient(R.cx, R.cy, R.cx + Math.sin(ang) * 300, R.cy - Math.cos(ang) * 300);
grad.addColorStop(0, 'rgba(79,209,224,0.28)');
grad.addColorStop(1, 'rgba(79,209,224,0)');
ctx.strokeStyle = grad;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(R.cx, R.cy);
ctx.lineTo(R.cx + Math.sin(ang) * radarRange * R.scale, R.cy - Math.cos(ang) * radarRange * R.scale);
ctx.stroke();
// pistler
AIRPORT.runways.forEach(rw => {
const p1 = worldToRadar(rw.x, rw.z0), p2 = worldToRadar(rw.x, rw.z1);
ctx.strokeStyle = rw.closed ? 'rgba(255,77,94,.85)' : 'rgba(200,230,245,.55)';
ctx.lineWidth = 7;
ctx.beginPath(); ctx.moveTo(p1.px, p1.py); ctx.lineTo(p2.px, p2.py); ctx.stroke();
ctx.strokeStyle = rw.closed ? 'rgba(255,77,94,.35)' : 'rgba(79,209,224,.3)';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(p1.px, p1.py); ctx.lineTo(p2.px, p2.py); ctx.stroke();
ctx.fillStyle = rw.closed ? '#ff4d5e' : '#9fd8e4';
ctx.font = '9px monospace';
ctx.fillText('RWY ' + rw.short, p1.px + 7, p1.py + 3);
if (rw.closed) {
ctx.fillStyle = 'rgba(255,77,94,.25)';
ctx.fillRect(p1.px - 12, p1.py, 24, p2.py - p1.py);
}
});
// NUMEX-DÜZELTME (2026-10-27): Terminal+apron dikdörtgeni park uçaklarının üstüne dolgu ile biniyor ve merkezi okunamaz beyaz bir leke hâline getiriyordu; yalnız ince kontur çizilir.
// terminal + apron (yalnız ince kontur — uçak sembollerini örtmesin)
const tc = worldToRadar(AIRPORT.terminal.x, AIRPORT.terminal.z);
ctx.strokeStyle = 'rgba(79,209,224,.22)';
ctx.lineWidth = 1;
ctx.strokeRect(tc.px - AIRPORT.terminal.w * R.scale / 2, tc.py - AIRPORT.terminal.d * R.scale / 2,
AIRPORT.terminal.w * R.scale, AIRPORT.terminal.d * R.scale);
// uçak izleri
const showTrail = $('showTrail').checked;
const showLabels = $('showLabels').checked;
if (showTrail) {
flights().forEach(f => {
if (f.state === S.FINISHED) return;
if (f.track.length < 2) return;
ctx.beginPath();
f.track.forEach((p, i) => {
const q = worldToRadar(p.x, p.z);
i === 0 ? ctx.moveTo(q.px, q.py) : ctx.lineTo(q.px, q.py);
});
ctx.strokeStyle = f.emergency ? 'rgba(255,77,94,.5)' : f.id === selectedId ? 'rgba(255,181,61,.55)' : 'rgba(79,209,224,.22)';
ctx.lineWidth = f.id === selectedId ? 1.8 : 1;
ctx.stroke();
});
}
// uçak sembolleri
// NUMEX-DÜZELTME (2026-10-27): Apronda duran uçaklar aynı noktada üst üste çizilip okunamaz bir yığın oluşturuyordu; radar ölçeğinde park pozisyonları birkaç piksele sığıyor. Yerdeki uçaklar için semboller ölçekle ayrıştırılır (görsel çakışma giderilir, konum hatası uçağın kendi değerinde kalır).
flights().forEach(f => {
if (f.state === S.FINISHED) return;
// NUMEX-DÜZELTME (2026-10-27): Aprondaki uçaklar radar ölçeğinde 3 piksele sığıp tek leke oluyordu (26 uçak okunamıyordu). Yerdeki uçaklar için gösterim çarpanı uygulanır: konumları terminal bölgesinden okunur bir park bloğuna açılır; uçağın GERÇEK konumu telemetride korunur.
// YERDEKİ uçaklar: apron ölçeği radar menzilinde birkaç piksel kalır → okunur park bloğuna açılır
let px = f.x, pz = f.z;
const yerde = f.altitude <= 60;
if (yerde) {
// NUMEX-DÜZELTME (2026-10-27): Park noktaları 0,28 km aralıkla duruyordu; 50 km menzilli radarda bu ~3 piksel eder ve 26 uçak tek leke oluyordu. Yerdeki uçaklar aprona okunur aralıkla (0,62 km) yayılır; uçağın gerçek konumu telemetride korunur.
const idx = yerIndex[f.id] !== undefined ? yerIndex[f.id] : (yerIndex[f.id] = yerIdxCounter++);
// NUMEX-DÜZELTME (2026-10-27): Park uçakları 50 km menzilde hâlâ tek leke oluyordu (0,62 km ≈ 9 piksel, sembol+etiket 60 piksel). Izgara doğrudan EKRAN pikseli cinsinden kurulur ve dünya→ekran dönüşümü atlanır; böylece 26 uçak da okunur aralıkta durur.
const perRow = 5;
// Apron merkezinin EKRAN konumu; ızgara piksel cinsinden (dönüşüm atlanır)
const apC = worldToRadar(AIRPORT.apron.x, AIRPORT.apron.z);
const sx = apC.px + ((idx % perRow) - 2) * 62;
const sy = apC.py + (Math.floor(idx / perRow) - 1) * 46;
drawSymbol(f, sx, sy, t, showLabels);
return;
}
const p = worldToRadar(px, pz);
if (p.px < -40 || p.px > R.w + 40 || p.py < -40 || p.py > R.h + 40) return;
drawSymbol(f, p.px, p.py, t, showLabels);
});
}
/** Uçak sembolünü EKRAN koordinatında çizer (havada/yerde aynı görsel dil). */
function drawSymbol(f, X, Y, t, showLabels) {
const air = f.altitude > 60;
const col = f.emergency ? '#ff4d5e' : air ? '#4fd1e0' : '#8aa4b8';
ctx.save();
ctx.translate(X, Y);
ctx.rotate(f.heading * Math.PI / 180);
ctx.beginPath();
if (air) {
ctx.moveTo(0, -6.5); ctx.lineTo(4.4, 4.5); ctx.lineTo(0, 2.4); ctx.lineTo(-4.4, 4.5);
} else {
ctx.rect(-3, -3, 6, 6);
}
ctx.closePath();
ctx.fillStyle = col;
ctx.fill();
ctx.restore();
// yakıt halkası (kritik olanlar)
if (f.fuel < 25) {
ctx.beginPath();
ctx.arc(X, Y, 9, -Math.PI / 2, -Math.PI / 2 + (f.fuel / 100) * Math.PI * 2);
ctx.strokeStyle = f.fuel < 15 ? '#ff4d5e' : '#ffb53d';
ctx.lineWidth = 1.6;
ctx.stroke();
}
if (f.emergency) {
const pr = 8 + (t * 12 % 14);
ctx.beginPath();
ctx.arc(X, Y, pr, 0, Math.PI * 2);
ctx.strokeStyle = `rgba(255,77,94,${Math.max(0, 1 - (pr - 8) / 14) * 0.8})`;
ctx.lineWidth = 1.4;
ctx.stroke();
}
if (f.id === selectedId) {
ctx.beginPath();
ctx.arc(X, Y, 13, 0, Math.PI * 2);
ctx.strokeStyle = '#ffb53d';
ctx.lineWidth = 1.4;
ctx.setLineDash([3, 3]);
ctx.stroke();
ctx.setLineDash([]);
// hız vektörü
const vr = 14 + f.speed * 0.06;
const hr = f.heading * Math.PI / 180;
ctx.beginPath();
ctx.moveTo(X, Y);
ctx.lineTo(X + Math.sin(hr) * vr, Y - Math.cos(hr) * vr);
ctx.strokeStyle = 'rgba(255,181,61,.75)';
ctx.stroke();
}
if (showLabels) {
ctx.fillStyle = f.emergency ? '#ffd7db' : 'rgba(201,216,230,.92)';
ctx.font = '9.5px monospace';
ctx.fillText(f.callsign, X + 10, Y - 2);
ctx.fillStyle = 'rgba(109,131,153,.9)';
ctx.font = '8.5px monospace';
ctx.fillText(air ? Math.round(f.altitude / 100) : 'GND', X + 10, Y + 8);
}
}
// radar tıklama → seçim
cv.addEventListener('click', (e) => {
const rect = cv.getBoundingClientRect();
const mx = e.clientX - rect.left, my = e.clientY - rect.top;
let best = null, bd = 1e9;
flights().forEach(f => {
if (f.state === S.FINISHED) return;
const p = worldToRadar(f.x, f.z);
const d = Math.hypot(p.px - mx, p.py - my);
if (d < bd) { bd = d; best = f; }
});
if (best && bd < 18) selectFlight(best.id);
});
/* ================================================================ LİSTE */
function renderFlightList() {
const list = $('flightList');
const q = $('searchFlight').value.trim().toUpperCase();
const st = $('filterState').value;
const onlyEmg = $('onlyEmergency').checked;
const act = flights().filter(f => f.state !== S.FINISHED)
.slice()
.sort((a, b) => {
const pa = a.emergency ? 0 : a.priority === 'HIGH' ? 1 : 2;
const pb = b.emergency ? 0 : b.priority === 'HIGH' ? 1 : 2;
return pa - pb || a.callsign.localeCompare(b.callsign);
});
$('flightCount').textContent = act.length;
const rows = act.filter(f =>
(!q || f.callsign.includes(q)) &&
(st === 'all' || f.state === st) &&
(!onlyEmg || f.emergency)
);
const frag = document.createDocumentFragment();
rows.forEach(f => {
const li = el('li', (f.id === selectedId ? 'sel ' : '') + (f.emergency ? 'emg' : ''));
li.dataset.id = f.id;
li.setAttribute('role', 'option');
li.setAttribute('aria-selected', f.id === selectedId ? 'true' : 'false');
const fc = f.fuel < 15 ? 'c' : f.fuel < 25 ? 'w' : '';
li.innerHTML =
`<span class="cs">${esc(f.callsign)}${f.priority === 'EMERGENCY' ? ' ⚠' : ''}</span>` +
`<span class="tel">${f.altitude > 60 ? Math.round(f.altitude) + 'ft' : 'GND'} ${Math.round(f.speed)}kt</span>` +
`<span class="st">${esc(f.state)} · ${esc(f.runway || '—')} · yakıt ${num(f.fuel)}%</span>` +
`<span class="fuel"><i class="${fc}" style="width:${Math.max(2, f.fuel)}%"></i></span>`;
li.addEventListener('click', () => selectFlight(f.id));
frag.appendChild(li);
});
list.innerHTML = '';
list.appendChild(frag);
if (!rows.length) list.appendChild(el('li', 'empty', 'uçuş yok'));
}
function selectFlight(id) {
selectedId = id;
renderFlightList();
renderDetail();
if (window.SKY3D) window.SKY3D.highlight(id);
}
/* ================================================================ DETAY */
function renderDetail() {
const f = flights().find(x => x.id === selectedId);
const box = $('detailBody');
$('btnFollow').disabled = !f;
if (!f) { box.innerHTML = '<p class="empty">Radar veya listeden bir uçak seç.</p>'; return; }
const rw = f.runway ? AIRPORT.runways.find(r => r.short === f.runway) : null;
const fc = f.fuel < 15 ? 'c' : f.fuel < 25 ? 'w' : '';
const advisories = alerts().filter(a => a.flightIds && a.flightIds.includes(f.id));
box.innerHTML = `
<div class="plane-title">
<b>${esc(f.callsign)}</b>
<span>${esc(f.airline)} · ${esc(f.acType)}</span>
${f.emergency ? '<span class="pill emg">ACİL 7700</span>' : f.priority === 'HIGH' ? '<span class="pill hi">ÖNCELİKLİ</span>' : '<span class="pill">NORMAL</span>'}
</div>
<dl class="dl">
<dt>DURUM</dt><dd class="em">${esc(f.state)}</dd>
<dt>KONUM</dt><dd>${num(f.x, 2)} / ${num(f.z, 2)} km</dd>
<dt>YÖN</dt><dd>${Math.round(f.heading)}° (${compass(f.heading)})</dd>
<dt>İRTİFA</dt><dd>${Math.round(f.altitude)} ft</dd>
<dt>HIZ</dt><dd>${Math.round(f.speed)} kt</dd>
<dt>PİST</dt><dd>${esc(f.runway || '—')}${rw ? (rw.closed ? ' · <span style="color:#ff4d5e">KAPALI</span>' : ' · müsait') : ''}</dd>
<dt>GÜZERGÂH</dt><dd>${esc(f.kind === 'arr' ? f.origin + ' → LTNX' : 'LTNX → ' + f.dest)}</dd>
<dt>DURUM YAŞI</dt><dd>${Math.round(N.stateAge ? N.stateAge(f) : 0)} sn</dd>
</dl>
<div class="tiny dim">YAKIT ${num(f.fuel, 1)}%</div>
<div class="bar"><i class="${fc}" style="width:${Math.max(1, f.fuel)}%"></i></div>
<div class="sec">UÇUŞ PLANI</div>
<div class="planbox">${esc((f.plan && f.plan.aktarma) || '—')}</div>
<div class="sec">MÜDAHALE</div>
<div class="actions">
<button class="mini" data-act="priority" data-id="${f.id}">Öncelik Yükselt</button>
<button class="mini" data-act="reroute" data-id="${f.id}">Alternatif Pist</button>
<button class="mini" data-act="hold" data-id="${f.id}">Beklet</button>
<button class="mini" data-act="climb" data-id="${f.id}">Tırman +1500</button>
<button class="mini" data-act="descend" data-id="${f.id}">Alçal −1500</button>
<button class="mini" data-act="emergency" data-id="${f.id}">Acil İlan Et</button>
</div>
<div class="sec">BU UÇAĞIN UYARILARI (${advisories.length})</div>
${advisories.length ? advisories.map(a =>
`<div class="alert ${a.severity}" style="margin-bottom:5px">
<div class="type">${esc(a.type)}</div>
<div class="ah"><b>${esc(a.title)}</b><span class="sev">${a.severity}</span></div>
</div>`).join('') : '<p class="tiny dim">Bu uçuşa ait aktif uyarı yok.</p>'}
`;
box.querySelectorAll('[data-act]').forEach(b => {
b.addEventListener('click', () => {
const id = +b.dataset.id;
const act = b.dataset.act;
const r = N.doAction(flights().find(x => x.id === id), act, 'OPERATOR');
toast(r.msg || r.why || 'işlem', !r.ok);
renderAll();
});
});
}
function compass(h) {
const d = ['K', 'KKD', 'KD', 'DKD', 'D', 'DGD', 'GD', 'GGD', 'G', 'GGB', 'GB', 'BGB', 'B', 'BKB', 'KB', 'KKB'];
return d[Math.round(((h % 360) / 22.5)) % 16];
}
$('btnFollow').addEventListener('click', () => {
if (!selectedId) return;
followId = selectedId;
const f = flights().find(x => x.id === selectedId);
$('followName').textContent = f ? f.callsign : '—';
document.querySelector('.tab[data-tab="airport"]').click();
if (window.SKY3D) window.SKY3D.follow(followId);
toast('3D kamerada takip: ' + (f ? f.callsign : '—'));
});
$('btnUnfollow').addEventListener('click', () => {
followId = null;
$('followName').textContent = '—';
if (window.SKY3D) window.SKY3D.follow(null);
});
/* ================================================================ KPI */
function renderKpis() {
const k = N.kpis();
const items = [
{ v: k.total, l: 'TOPLAM UÇUŞ', c: '' },
{ v: k.active, l: 'AKTİF', c: '' },
{ v: k.airborne, l: 'HAVADA', c: '' },
{ v: k.landed, l: 'TAMAMLANDI', c: 'k-ok' },
{ v: k.emergencies, l: 'ACİL DURUM', c: k.emergencies ? 'k-crit' : 'k-ok' },
{ v: k.alerts, l: 'AKTİF UYARI', c: k.alerts ? 'k-warn' : 'k-ok' },
{ v: k.critical, l: 'KRİTİK', c: k.critical ? 'k-crit' : 'k-ok' },
{ v: k.ops + '/' + AIRPORT.runways.length, l: 'AÇIK PİST', c: k.closedRunways ? 'k-crit' : 'k-ok' },
{ v: num(k.avgFuel) + '%', l: 'ORT. YAKIT', c: '' },
];
$('kpis').innerHTML = items.map(i => `<div class="kpi ${i.c}"><div class="v">${i.v}</div><div class="l">${i.l}</div></div>`).join('');
}
/* ================================================================ UYARILAR */
function renderAlerts() {
const list = N.analyze();
// canlı uyarı defterini güncelle (id atanmış örnekler)
const al = alerts();
al.length = 0; list.forEach(a => al.push(a));
$('alertSummary').textContent = list.length;
$('alertSummary').className = 'badge ' + (list.length ? 'warn' : '');
const box = $('alertList');
if (!list.length) {
box.innerHTML = '<p class="noalert">✓ Aktif uyarı yok — operasyon normal.</p>';
return;
}
box.innerHTML = list.map(a => {
const fid = a.target;
return `<div class="alert ${a.severity}" data-alert="${a.id}">
<div class="type">${esc(a.type)}</div>
<div class="ah"><b>${esc(a.title)}</b><span class="sev">${a.severity}</span></div>
<ol>
<li><b>Sorun:</b> ${esc(a.title)}</li>
<li><b>Etkilenen:</b> ${esc((a.flights || []).join(', ') || '—')}</li>
<li><b>Veri:</b> ${esc(a.data)}</li>
<li><b>Öneri:</b> ${esc(a.action)}</li>
<li><b>Beklenen sonuç:</b> ${esc(a.expected)}</li>
</ol>
<div class="dec">
${fid ? `<button class="mini" data-dec="${fid}" data-kind="${esc(a.kind)}">Öneriyi Uygula</button>
<button class="mini" data-goto="${fid}">Uçağa Git</button>` : ''}
</div>
</div>`;
}).join('');
box.querySelectorAll('[data-dec]').forEach(b => {
b.addEventListener('click', () => {
const fid = +b.dataset.dec;
const kind = b.dataset.kind;
const map = { separation: 'climb', fuel_crit: 'priority', fuel_warn: 'priority', bad_rwy: 'reroute', rwy_conflict: 'hold', emergency: 'emergency', wx_rwy: 'reroute', delay: 'hold' };
const action = map[kind] || 'priority';
const r = N.applyDecision({ flightId: fid, type: kind === 'bad_rwy' || kind === 'wx_rwy' ? 'runway' : kind === 'emergency' ? 'emergency' : kind === 'fuel_crit' || kind === 'fuel_warn' ? 'fuel' : 'other', action, reason: kind }, 'OPERATOR');
toast(r.ok ? (r.msg || 'Karar uygulandı.') : (r.why || r.msg || 'Uygulanamadı'), !r.ok);
renderAll();
});
});
box.querySelectorAll('[data-goto]').forEach(b => {
b.addEventListener('click', () => {
selectFlight(+b.dataset.goto);
document.querySelector('.tab[data-tab="radar"]').click();
});
});
}
/* ================================================================ OLAY KAYDI */
function renderEvents() {
const box = $('eventLog');
const f = $('eventFilter').value;
const evs = N.events.slice(0, 200).filter(e => f === 'all' || e.kind === f);
box.innerHTML = evs.map(e =>
`<div class="ev"><span class="t">${e.clock}</span><span class="k ${e.kind}">${e.kind}</span>
<span class="f">${esc(e.flight)}</span>
<span class="m">${esc(e.msg)}${e.reason ? ` <em>— ${esc(e.reason)}</em>` : ''}</span></div>`
).join('');
if ($('autoScroll').checked) box.scrollTop = 0;
}
/* ================================================================ HAVA */
function renderWeather() {
const w = weather;
$('wxPanel').innerHTML = `
<div><span>GÖRÜŞ</span>${w.visKm} km</div>
<div><span>RÜZGÂR</span>${String(w.windDir).padStart(3, '0')}° / ${w.windKt} kt</div>
<div><span>TAVAN</span>${w.ceilingFt} ft</div>
<div><span>DURUM</span><b style="color:${w.visKm < 3 ? '#ff4d5e' : '#43d17a'}">${w.condition}</b></div>
${AIRPORT.runways.map(r => `<div><span>PİST ${r.short}</span>${r.closed ? '<b style="color:#ff4d5e">KAPALI</b>' : (r.assigned || 'müsait')}</div>`).join('')}
`;
$('radarWx').textContent = `LTFM · ${w.condition} · ${w.visKm}km · ${w.windDir}°/${w.windKt}kt`;
if (w.night) document.body.classList.add('night');
}
function renderStrip() {
const k = N.kpis();
$('radarStrip').innerHTML =
`<span>AKTİF <b>${k.active}</b></span>` +
`<span>HAVADA <b>${k.airborne}</b></span>` +
`<span>ACİL <b>${k.emergencies}</b></span>` +
`<span>KRİTİK UYARI <b>${k.critical}</b></span>` +
`<span>AÇIK PİST <b>${k.ops}/${AIRPORT.runways.length}</b></span>` +
`<span>MOD <b>${authority.mode}</b></span>` +
`<span>REDDEDİLEN MÜDAHALE <b>${authority.denied}</b></span>`;
$('denyCount').textContent = authority.denied;
}
/* ================================================================ KONTROLLER */
$('btnPlay').addEventListener('click', () => {
STATE.running = !STATE.running;
$('btnPlay').textContent = STATE.running ? '❚❚ DURAKLAT' : '▶ DEVAM ET';
});
$('speed').addEventListener('change', e => { STATE.timeScale = +e.target.value; });
$('btnReset').addEventListener('click', () => {
N.reset();
selectedId = null; followId = null;
$('followName').textContent = '—';
if (window.SKY3D) { window.SKY3D.follow(null); window.SKY3D.rebuild(); }
toast('Simülasyon sıfırlandı — 25 kurgusal uçuş yeniden yüklendi.');
renderAll();
});
$('btnAdvance').addEventListener('click', () => {
for (let i = 0; i < 240; i++) N.tick(250); // 60 sn
toast('Simülasyon 60 sn ilerletildi.');
renderAll();
});
$('searchFlight').addEventListener('input', renderFlightList);
$('filterState').addEventListener('change', renderFlightList);
$('onlyEmergency').addEventListener('change', renderFlightList);
$('eventFilter').addEventListener('change', renderEvents);
$('showLabels').addEventListener('change', drawRadar);
$('showTrail').addEventListener('change', drawRadar);
document.querySelectorAll('.scn').forEach(b => {
b.addEventListener('click', () => {
const r = N.scenario(b.dataset.scn);
toast(r.msg, !r.ok);
renderAll();
});
});
document.querySelectorAll('.mode').forEach(b => {
b.addEventListener('click', () => {
document.querySelectorAll('.mode').forEach(x => x.classList.remove('active'));
b.classList.add('active');
authority.mode = b.dataset.mode;
$('authDesc').textContent = authority.mode === 'ONERI'
? 'Motor yalnızca öneri sunar. Uçuş durumunu kullanıcı onayı olmadan değiştiremez.'
: 'Motor, tanımlı sınırlar içinde kurgusal uçuş durumlarını kendisi değiştirebilir.';
$('autoLimits').style.opacity = authority.mode === 'OTOMATIK' ? '1' : '.5';
toast('Mod: ' + (authority.mode === 'ONERI' ? 'Öneri' : 'Otomatik simülasyon'));
renderStrip();
});
});
$('limFuel').addEventListener('input', e => {
authority.limits.maxFuelForAuto = +e.target.value;
$('limFuelVal').textContent = e.target.value + '%';
});
$('limRwy').addEventListener('change', e => { authority.limits.allowRunwayChange = e.target.checked; });
$('limEmg').addEventListener('change', e => { authority.limits.allowEmergencyPriority = e.target.checked; });
/* ================================================================ DÖNGÜ */
const STEP = 1 / 10; // sabit simülasyon adımı (saniye) — 10 Hz, gerçek zamanla 1:1
let simAcc = 0; // birikmiş simülasyon zamanı (saniye)
let last = performance.now();
function loop(now) {
const dt = Math.min(120, now - last);
last = now;
// Sabit adım (60 Hz) — kare hızından bağımsız, deterministik
simAcc += (dt * STATE.timeScale) / 1000;
while (simAcc >= STEP) {
N.tick(STEP);
simAcc -= STEP;
}
if (window.SKY3D) window.SKY3D.update();
requestAnimationFrame(loop);
}
// arayüz render döngüsü (daha seyrek — 6 Hz)
let uiAcc = 0, uiLast = performance.now();
setInterval(() => {
$('simClock').textContent = N.clockStr();
if (selectedId) renderDetail();
}, 250);
function renderAll() {
renderKpis();
renderFlightList();
renderDetail();
renderAlerts();
renderEvents();
renderWeather();
renderStrip();
}
// radar her karede çizilir (akıcı sweep)
function radarLoop() {
if ($('view-radar').classList.contains('active')) drawRadar();
requestAnimationFrame(radarLoop);
}
window.addEventListener('load', () => {
N.seedWorld();
resizeRadar();
renderAll();
requestAnimationFrame(loop);
requestAnimationFrame(radarLoop);
// 6 Hz hafif yenileme
setInterval(() => { renderFlightList(); renderKpis(); renderStrip(); }, 900);
// uyarı motoru 1.5 sn'de bir
setInterval(() => { renderAlerts(); }, 1500);
setInterval(() => { renderEvents(); renderWeather(); }, 1200);
// dışa aç (testler için)
window.NUMEX_UI = { selectFlight, renderAll, get selectedId() { return selectedId; } };
console.log('[SKY COMMAND] hazır — 25 kurgusal uçuş, ortak durum motoru aktif.');
});
})();