364 lines
14 KiB
JavaScript
364 lines
14 KiB
JavaScript
/* schedule.js — filters, pagination, table render, CSV export */
|
||
|
||
const PAGE_SIZE = 100;
|
||
let currentOffset = 0;
|
||
let currentTotal = 0;
|
||
|
||
// ── init ──────────────────────────────────────────────────────────────────────
|
||
|
||
document.addEventListener("DOMContentLoaded", () => {
|
||
// Default date range: last 7 days
|
||
const today = new Date();
|
||
const week = new Date(today);
|
||
week.setDate(today.getDate() - 7);
|
||
document.getElementById("f-date-from").value = fmtDate(week);
|
||
document.getElementById("f-date-to").value = fmtDate(today);
|
||
|
||
loadData();
|
||
});
|
||
|
||
// ── filter helpers ────────────────────────────────────────────────────────────
|
||
|
||
function getFilters() {
|
||
return {
|
||
date_from: document.getElementById("f-date-from").value || "",
|
||
date_to: document.getElementById("f-date-to").value || "",
|
||
airport: document.getElementById("f-airport").value,
|
||
direction: document.getElementById("f-direction").value,
|
||
flight_number: document.getElementById("f-flight").value.trim(),
|
||
time_from: document.getElementById("f-time-from").value || "",
|
||
time_to: document.getElementById("f-time-to").value || "",
|
||
};
|
||
}
|
||
|
||
function buildQuery(extra = {}) {
|
||
const f = { ...getFilters(), limit: PAGE_SIZE, offset: currentOffset, ...extra };
|
||
return Object.entries(f)
|
||
.filter(([, v]) => v !== "" && v !== "all")
|
||
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
|
||
.join("&");
|
||
}
|
||
|
||
function applyFilters() {
|
||
currentOffset = 0;
|
||
loadData();
|
||
}
|
||
|
||
function resetFilters() {
|
||
document.getElementById("f-date-from").value = "";
|
||
document.getElementById("f-date-to").value = "";
|
||
document.getElementById("f-airport").value = "all";
|
||
document.getElementById("f-direction").value = "all";
|
||
document.getElementById("f-flight").value = "";
|
||
document.getElementById("f-time-from").value = "";
|
||
document.getElementById("f-time-to").value = "";
|
||
currentOffset = 0;
|
||
loadData();
|
||
}
|
||
|
||
// ── pagination ────────────────────────────────────────────────────────────────
|
||
|
||
function prevPage() {
|
||
if (currentOffset <= 0) return;
|
||
currentOffset = Math.max(0, currentOffset - PAGE_SIZE);
|
||
loadData();
|
||
}
|
||
|
||
function nextPage() {
|
||
if (currentOffset + PAGE_SIZE >= currentTotal) return;
|
||
currentOffset += PAGE_SIZE;
|
||
loadData();
|
||
}
|
||
|
||
function updatePagination() {
|
||
const page = Math.floor(currentOffset / PAGE_SIZE) + 1;
|
||
const pages = Math.max(1, Math.ceil(currentTotal / PAGE_SIZE));
|
||
document.getElementById("page-info").textContent = `Стр. ${page} / ${pages}`;
|
||
document.getElementById("total-info").textContent = `Всего: ${currentTotal.toLocaleString("ru")}`;
|
||
document.getElementById("btn-prev").disabled = currentOffset <= 0;
|
||
document.getElementById("btn-next").disabled = currentOffset + PAGE_SIZE >= currentTotal;
|
||
}
|
||
|
||
// ── data load ─────────────────────────────────────────────────────────────────
|
||
|
||
async function loadData() {
|
||
setLoading(true);
|
||
try {
|
||
const resp = await fetch(`/api/schedule/data?${buildQuery()}`);
|
||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||
const data = await resp.json();
|
||
currentTotal = data.total || 0;
|
||
renderTable(data.flights || []);
|
||
renderCards(data.flights || []);
|
||
updatePagination();
|
||
document.getElementById("last-updated").textContent =
|
||
"Обновлено: " + new Date().toLocaleTimeString("ru");
|
||
} catch (e) {
|
||
showError(e.message);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
|
||
// ── render table ──────────────────────────────────────────────────────────────
|
||
// Колонки (13): Дата | Рейс | Авиакомпания | ↑↓ | Маршрут | Аэропорт |
|
||
// По расп. | Взлёт факт | Посадка факт | Длит. | ВПП | ВС | Трек
|
||
|
||
function renderTable(flights) {
|
||
const tbody = document.getElementById("table-body");
|
||
if (!flights.length) {
|
||
tbody.innerHTML = `<tr><td colspan="12" class="state-msg">Нет данных по выбранным фильтрам</td></tr>`;
|
||
return;
|
||
}
|
||
|
||
tbody.innerHTML = flights.map(f => {
|
||
// 1. Дата
|
||
const dateStr = fmtDateShort(f.scheduled_at);
|
||
|
||
// 2. Рейс — номер + cat badge + source badge
|
||
const catBadge = categoryBadge(f.flight_category);
|
||
const srcBadge = f.sched_source === "fr24"
|
||
? `<span class="src-badge src-fr24" title="Источник: FR24 API">FR</span>`
|
||
: "";
|
||
const flightCell = `<strong>${esc(f.flight_number)}</strong>${catBadge}${srcBadge}`;
|
||
|
||
// 3. Авиакомпания + регистрация
|
||
const airlineCell = esc(f.airline || "—")
|
||
+ (f.registration ? `<br><small style="color:#6e7681">${esc(f.registration)}</small>` : "");
|
||
|
||
// 4. Направление
|
||
const dirIcon = f.direction === "departure"
|
||
? `<span class="dir-icon dir-dep" title="Вылет">↑</span>`
|
||
: `<span class="dir-icon dir-arr" title="Прилёт">↓</span>`;
|
||
|
||
// 5. Маршрут
|
||
const route = routeStr(f);
|
||
|
||
// 6. Аэропорт
|
||
const airport = esc(f.airport);
|
||
|
||
// 7. По расписанию (MSK)
|
||
const sched = fmtTime(f.scheduled_at);
|
||
|
||
// 8. Фактическое время: вылет → actual_takeoff, прилёт → actual_landed
|
||
const actTime = f.direction === "departure" ? f.actual_takeoff : f.actual_landed;
|
||
const actDelay = f.direction === "departure" ? f.delay_takeoff_min : f.delay_landed_min;
|
||
const actCell = actTime
|
||
? `<span class="act-time">${fmtTime(actTime)}</span> ${delayCell(actDelay)}`
|
||
: "—";
|
||
|
||
// 9. Длительность (FA flight_time > schedule duration_min)
|
||
const durationCell = fmtDuration(f.duration_eff);
|
||
|
||
// 11. ВПП (по направлению рейса)
|
||
const runway = f.direction === "departure"
|
||
? (f.runway_takeoff || "—")
|
||
: (f.runway_landed || "—");
|
||
|
||
// 12. Тип ВС
|
||
const acType = f.aircraft_type ? esc(f.aircraft_type) : "—";
|
||
|
||
// 13. Трек: ✈ FR24 + 🛰 RTL-SDR + FA badge
|
||
const trackCol = [
|
||
f.fr24_id
|
||
? `<a href="https://www.flightradar24.com/data/flights/${f.fr24_id}" target="_blank" class="track-link" title="Трек FR24">✈</a>`
|
||
: "",
|
||
f.track_source === "rtlsdr"
|
||
? `<span title="${f.track_points || 0} точек RTL-SDR" style="color:#3fb950;cursor:default">🛰</span>`
|
||
: "",
|
||
f.track_source === "fa"
|
||
? `<span title="${f.track_points || 0} точек FA" style="color:#d29922;cursor:default">FA</span>`
|
||
: "",
|
||
].filter(Boolean).join(" ") || "—";
|
||
|
||
return `<tr>
|
||
<td>${dateStr}</td>
|
||
<td>${flightCell}</td>
|
||
<td>${airlineCell}</td>
|
||
<td>${dirIcon}</td>
|
||
<td>${esc(route)}</td>
|
||
<td>${airport}</td>
|
||
<td>${sched}</td>
|
||
<td>${actCell}</td>
|
||
<td>${durationCell}</td>
|
||
<td>${runway}</td>
|
||
<td>${acType}</td>
|
||
<td>${trackCol}</td>
|
||
</tr>`;
|
||
}).join("");
|
||
}
|
||
|
||
// ── render cards (mobile) ─────────────────────────────────────────────────────
|
||
|
||
function renderCards(flights) {
|
||
const container = document.getElementById("cards-container");
|
||
if (!flights.length) {
|
||
container.innerHTML = `<div class="state-msg">Нет данных по выбранным фильтрам</div>`;
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = flights.map(f => {
|
||
const dirLabel = f.direction === "departure" ? "↑ Вылет" : "↓ Прилёт";
|
||
const dirClass = f.direction === "departure" ? "dir-dep" : "dir-arr";
|
||
const route = routeStr(f);
|
||
const sched = fmtTime(f.scheduled_at);
|
||
const catBadge = categoryBadge(f.flight_category);
|
||
const srcBadge = f.sched_source === "fr24"
|
||
? `<span class="src-badge src-fr24" title="Источник: FR24 API">FR</span>`
|
||
: "";
|
||
|
||
// Track column for card
|
||
const trackLinks = [
|
||
f.fr24_id
|
||
? `<a href="https://www.flightradar24.com/data/flights/${f.fr24_id}" target="_blank" style="color:#58a6ff">✈ FR24</a>`
|
||
: "",
|
||
f.track_source === "rtlsdr"
|
||
? `<span title="${f.track_points || 0} точек RTL-SDR" style="color:#3fb950">🛰 RTL-SDR</span>`
|
||
: "",
|
||
f.track_source === "fa"
|
||
? `<span title="${f.track_points || 0} точек FA" style="color:#d29922">FA трек</span>`
|
||
: "",
|
||
].filter(Boolean).join(" ");
|
||
|
||
const acInfo = [
|
||
f.aircraft_type ? esc(f.aircraft_type) : "",
|
||
f.registration ? `<span style="color:#6e7681">${esc(f.registration)}</span>` : "",
|
||
].filter(Boolean).join(" / ");
|
||
|
||
const runway = f.direction === "departure"
|
||
? (f.runway_takeoff || "—")
|
||
: (f.runway_landed || "—");
|
||
|
||
return `<div class="card">
|
||
<div class="card-header">
|
||
<div>
|
||
<div class="card-flight">${esc(f.flight_number)}${catBadge}${srcBadge}</div>
|
||
<div class="card-airline">${esc(f.airline || "—")}</div>
|
||
</div>
|
||
</div>
|
||
<div class="card-row"><span>Аэропорт</span><span>${esc(f.airport)}</span></div>
|
||
<div class="card-row"><span>Направление</span><span class="${dirClass}">${dirLabel}</span></div>
|
||
<div class="card-row"><span>Маршрут</span><span>${esc(route)}</span></div>
|
||
<div class="card-row"><span>По расписанию</span><span>${sched}</span></div>
|
||
${f.actual_takeoff
|
||
? `<div class="card-row"><span>Взлёт факт</span><span>${fmtTime(f.actual_takeoff)} ${delayCell(f.delay_takeoff_min)}</span></div>`
|
||
: ""}
|
||
${f.actual_landed
|
||
? `<div class="card-row"><span>Посадка факт</span><span>${fmtTime(f.actual_landed)} ${delayCell(f.delay_landed_min)}</span></div>`
|
||
: ""}
|
||
${f.duration_eff
|
||
? `<div class="card-row"><span>Длительность</span><span>${fmtDuration(f.duration_eff)}</span></div>`
|
||
: ""}
|
||
${runway !== "—"
|
||
? `<div class="card-row"><span>ВПП</span><span>${runway}</span></div>`
|
||
: ""}
|
||
${acInfo
|
||
? `<div class="card-row"><span>ВС / Борт</span><span>${acInfo}</span></div>`
|
||
: ""}
|
||
${trackLinks
|
||
? `<div class="card-row"><span>Трек</span><span>${trackLinks}</span></div>`
|
||
: ""}
|
||
</div>`;
|
||
}).join("");
|
||
}
|
||
|
||
// ── CSV export ────────────────────────────────────────────────────────────────
|
||
|
||
function exportCsv() {
|
||
const qs = buildQuery({ limit: 100000, offset: 0 });
|
||
window.location.href = `/api/schedule/export?${qs}`;
|
||
}
|
||
|
||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||
|
||
function routeStr(f) {
|
||
// 1. Яндекс thread_title (приоритет 1)
|
||
const tt = f.thread_title || "";
|
||
if (tt) return tt;
|
||
// 2. FR24 IATA коды → города (приоритет 2)
|
||
const fo = f.fa_origin_city || f.fa_origin_iata || "";
|
||
const fd = f.fa_dest_city || f.fa_dest_iata || "";
|
||
if (fo || fd) {
|
||
if (!fo) return `→ ${fd}`;
|
||
if (!fd) return `${fo} →`;
|
||
return `${fo} → ${fd}`;
|
||
}
|
||
// 3. Яндекс IATA → города (запасной вариант)
|
||
const o = f.origin || "";
|
||
const d = f.destination || "";
|
||
if (!o && !d) return "—";
|
||
if (!o) return `→ ${d}`;
|
||
if (!d) return `${o} →`;
|
||
return `${o} → ${d}`;
|
||
}
|
||
|
||
function fmtDuration(min) {
|
||
if (!min) return "—";
|
||
const h = Math.floor(min / 60);
|
||
const m = min % 60;
|
||
return h > 0 ? `${h}ч ${m}м` : `${m}м`;
|
||
}
|
||
|
||
function categoryBadge(category) {
|
||
if (!category) return "";
|
||
const cls = {
|
||
"Passenger": "cat-pax",
|
||
"Cargo": "cat-cargo",
|
||
"Military": "cat-mil",
|
||
}[category] || "cat-other";
|
||
const labels = {
|
||
"Passenger": "P",
|
||
"Cargo": "C",
|
||
"Military": "M",
|
||
};
|
||
return `<span class="cat-badge ${cls}" title="${category}">${labels[category] || category}</span>`;
|
||
}
|
||
|
||
function delayCell(min) {
|
||
if (min == null) return "";
|
||
if (min > 30) return `<span class="delay-critical">+${min}</span>`;
|
||
if (min > 0) return `<span class="delay-pos">+${min}</span>`;
|
||
if (min < 0) return `<span class="delay-neg">${min}</span>`;
|
||
return `<span class="delay-ok">0</span>`;
|
||
}
|
||
|
||
function fmtDate(d) {
|
||
return d.toISOString().slice(0, 10);
|
||
}
|
||
|
||
function fmtDateShort(iso) {
|
||
if (!iso) return "—";
|
||
return iso.slice(0, 10);
|
||
}
|
||
|
||
function fmtTime(iso) {
|
||
if (!iso) return "—";
|
||
try {
|
||
return new Date(iso).toLocaleTimeString("ru", { hour: "2-digit", minute: "2-digit", timeZone: "Europe/Moscow" });
|
||
} catch { return iso.slice(11, 16); }
|
||
}
|
||
|
||
function esc(s) {
|
||
if (!s) return "—";
|
||
return String(s)
|
||
.replace(/&/g, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">");
|
||
}
|
||
|
||
function setLoading(on) {
|
||
if (on) {
|
||
document.getElementById("table-body").innerHTML =
|
||
`<tr><td colspan="12" class="state-msg">Загрузка…</td></tr>`;
|
||
document.getElementById("cards-container").innerHTML =
|
||
`<div class="state-msg">Загрузка…</div>`;
|
||
}
|
||
}
|
||
|
||
function showError(msg) {
|
||
document.getElementById("table-body").innerHTML =
|
||
`<tr><td colspan="12" class="state-msg" style="color:#f85149">Ошибка: ${esc(msg)}</td></tr>`;
|
||
document.getElementById("cards-container").innerHTML =
|
||
`<div class="state-msg" style="color:#f85149">Ошибка: ${esc(msg)}</div>`;
|
||
}
|