adsb-1090/webapp/src/api.php

1915 lines
94 KiB
PHP
Raw 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.

<?php
require_once __DIR__ . '/config.php';
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-cache, no-store');
// Fuseau de référence de la station. Sans cela, PHP retombe sur UTC (ou sur la
// valeur de date.timezone du conteneur) et les dates locales envoyées par la
// webapp partent telles quelles dans les requêtes SQL, où la colonne ts est en
// UTC : la fenêtre interrogée est alors décalée de 2 h (CEST) et revient vide.
date_default_timezone_set('Europe/Paris');
// Position de la station, utilisée pour calculer la distance des départs
// détectés. config.php peut les définir ; sinon on retombe sur les coordonnées
// de Linux-25 (Vélizy).
if (!defined('STATION_LAT')) { define('STATION_LAT', 48.8236); }
if (!defined('STATION_LON')) { define('STATION_LON', 2.2770); }
$action = $_GET['action'] ?? 'live';
$period_h = max(1, min(720, (int)($_GET['period'] ?? 24)));
$granularity = max(5, min(1440, (int)($_GET['granularity'] ?? 60)));
$limit = max(10, min(500, (int)($_GET['limit'] ?? 100)));
$schema = PG_SCHEMA;
function q(string $sql, array $p = []): array {
$st = db()->prepare($sql);
$st->execute($p);
return $st->fetchAll();
}
function q1(string $sql, array $p = []): ?array {
$rows = q($sql, $p);
return $rows[0] ?? null;
}
// ── Filtre multi-sources (récepteurs) ────────────────────────────────────────
// La webapp envoie receivers=1,4 ou receivers=1,null (CSV). Le jeton
// « null » vise les positions dont receiver_id n'est pas renseigné : elles
// existent sur toute installation dont la base a tourné avant que la table
// adsb.receivers ne soit peuplée. Sans traitement explicite, un IN (...) les
// écarte silencieusement — trois valeurs logiques SQL — et l'utilisateur
// perd des lignes sans comprendre pourquoi.
//
// Paramètre vide ou absent = aucune restriction (toutes les sources).
// Renvoie un fragment SQL prêt à être concaténé dans un WHERE, et les
// paramètres positionnels correspondants, à insérer AU MÊME ENDROIT dans le
// tableau passé à q().
function rx_filter($raw): array {
$out = ['sql' => '', 'params' => [], 'ids' => [], 'null' => false];
$raw = trim((string)$raw);
if ($raw === '') return $out;
foreach (explode(',', $raw) as $tok) {
$tok = trim($tok);
if ($tok === '') continue;
if (strcasecmp($tok, 'null') === 0) { $out['null'] = true; continue; }
if (ctype_digit($tok)) $out['ids'][] = (int)$tok;
}
$out['ids'] = array_values(array_unique($out['ids']));
if ($out['ids'] && $out['null']) {
$out['sql'] = ' AND (receiver_id = ANY(?::int[]) OR receiver_id IS NULL)';
$out['params'] = ['{' . implode(',', $out['ids']) . '}'];
} elseif ($out['ids']) {
$out['sql'] = ' AND receiver_id = ANY(?::int[])';
$out['params'] = ['{' . implode(',', $out['ids']) . '}'];
} elseif ($out['null']) {
$out['sql'] = ' AND receiver_id IS NULL';
}
return $out;
}
try {
switch ($action) {
// ── Avions en vue ────────────────────────────────────────────────────
case 'live':
$data = tar1090_fetch('/data/aircraft.json');
$aircraft = array_values(array_filter(
$data['aircraft'] ?? [],
fn($ac) => isset($ac['lat']) || isset($ac['altitude']) || isset($ac['alt_baro'])
));
usort($aircraft, fn($a,$b) =>
($b['alt_baro'] ?? $b['altitude'] ?? 0) <=> ($a['alt_baro'] ?? $a['altitude'] ?? 0)
);
echo json_encode([
'aircraft' => $aircraft,
'count' => count($aircraft),
'messages' => $data['messages'] ?? 0,
'now' => time(),
]);
break;
// ── KPI ──────────────────────────────────────────────────────────────
case 'kpi':
$today = q1("
SELECT COUNT(*) AS sessions, COUNT(DISTINCT icao) AS avions
FROM {$schema}.aircraft_history
WHERE session_start >= date_trunc('day',
NOW() AT TIME ZONE 'Europe/Paris' AT TIME ZONE 'UTC')
");
$period = q1("
SELECT COUNT(*) AS sessions, COUNT(DISTINCT icao) AS avions
FROM {$schema}.aircraft_history
WHERE session_start >= NOW() - INTERVAL '{$period_h} hours'
");
$data = tar1090_fetch('/data/aircraft.json');
$live = count(array_filter(
$data['aircraft'] ?? [],
fn($ac) => isset($ac['lat']) || isset($ac['altitude']) || isset($ac['alt_baro'])
));
echo json_encode([
'live' => $live,
'messages' => $data['messages'] ?? 0,
'today_sess' => (int)($today['sessions'] ?? 0),
'today_avions' => (int)($today['avions'] ?? 0),
'period_sess' => (int)($period['sessions'] ?? 0),
'period_avions' => (int)($period['avions'] ?? 0),
'period_h' => $period_h,
]);
break;
// ── Courbe d'affluence globale ────────────────────────────────────────
case 'affluence':
$rows = q("
WITH slots AS (
SELECT generate_series(
date_trunc('hour', NOW() - INTERVAL '{$period_h} hours')
+ floor(extract(minute FROM NOW() - INTERVAL '{$period_h} hours')
/ {$granularity}) * INTERVAL '{$granularity} minutes',
NOW(),
INTERVAL '{$granularity} minutes'
) AS slot_start
),
se AS (
SELECT slot_start,
slot_start + INTERVAL '{$granularity} minutes' AS slot_end
FROM slots
)
SELECT
to_char(se.slot_start AT TIME ZONE 'Europe/Paris', 'DD/MM HH24:MI') AS label,
COUNT(DISTINCT h.icao) AS nb_avions,
COUNT(h.id) AS nb_sessions
FROM se
LEFT JOIN {$schema}.aircraft_history h
ON h.session_start < se.slot_end
AND h.session_end >= se.slot_start
AND h.session_start >= NOW() - INTERVAL '{$period_h} hours'
GROUP BY se.slot_start
ORDER BY se.slot_start
");
echo json_encode(['slots' => $rows, 'period_h' => $period_h, 'granularity' => $granularity]);
break;
// ── Courbe affluence par type d'opérateur ─────────────────────────────
case 'affluence_by_optype':
$rows = q("
WITH slots AS (
SELECT generate_series(
date_trunc('hour', NOW() - INTERVAL '{$period_h} hours')
+ floor(extract(minute FROM NOW() - INTERVAL '{$period_h} hours')
/ {$granularity}) * INTERVAL '{$granularity} minutes',
NOW(),
INTERVAL '{$granularity} minutes'
) AS slot_start
),
se AS (
SELECT slot_start,
slot_start + INTERVAL '{$granularity} minutes' AS slot_end
FROM slots
),
types AS (
SELECT DISTINCT COALESCE(operator_type, 'unknown') AS op_type
FROM {$schema}.aircraft_history
WHERE session_start >= NOW() - INTERVAL '{$period_h} hours'
)
SELECT
to_char(se.slot_start AT TIME ZONE 'Europe/Paris', 'DD/MM HH24:MI') AS label,
se.slot_start,
COALESCE(h.operator_type, 'unknown') AS op_type,
COUNT(DISTINCT h.icao) AS nb_avions
FROM se
CROSS JOIN types t
LEFT JOIN {$schema}.aircraft_history h
ON h.session_start < se.slot_end
AND h.session_end >= se.slot_start
AND h.session_start >= NOW() - INTERVAL '{$period_h} hours'
AND COALESCE(h.operator_type, 'unknown') = t.op_type
GROUP BY se.slot_start, COALESCE(h.operator_type, 'unknown')
ORDER BY se.slot_start, op_type
");
echo json_encode(['rows' => $rows, 'period_h' => $period_h]);
break;
// ── Courbe affluence par type d'aéronef ───────────────────────────────
case 'affluence_by_aircraft':
$rows = q("
WITH slots AS (
SELECT generate_series(
date_trunc('hour', NOW() - INTERVAL '{$period_h} hours')
+ floor(extract(minute FROM NOW() - INTERVAL '{$period_h} hours')
/ {$granularity}) * INTERVAL '{$granularity} minutes',
NOW(),
INTERVAL '{$granularity} minutes'
) AS slot_start
),
se AS (
SELECT slot_start,
slot_start + INTERVAL '{$granularity} minutes' AS slot_end
FROM slots
),
types AS (
SELECT DISTINCT COALESCE(aircraft_type, 'unknown') AS ac_type
FROM {$schema}.aircraft_history
WHERE session_start >= NOW() - INTERVAL '{$period_h} hours'
)
SELECT
to_char(se.slot_start AT TIME ZONE 'Europe/Paris', 'DD/MM HH24:MI') AS label,
se.slot_start,
COALESCE(h.aircraft_type, 'unknown') AS ac_type,
COUNT(DISTINCT h.icao) AS nb_avions
FROM se
CROSS JOIN types t
LEFT JOIN {$schema}.aircraft_history h
ON h.session_start < se.slot_end
AND h.session_end >= se.slot_start
AND h.session_start >= NOW() - INTERVAL '{$period_h} hours'
AND COALESCE(h.aircraft_type, 'unknown') = t.ac_type
GROUP BY se.slot_start, COALESCE(h.aircraft_type, 'unknown')
ORDER BY se.slot_start, ac_type
");
echo json_encode(['rows' => $rows, 'period_h' => $period_h]);
break;
// ── Stats détaillées par type d'opérateur ────────────────────────────
case 'stats_by_optype':
$total = q1("
SELECT COUNT(*) AS t FROM {$schema}.aircraft_history
WHERE session_start >= NOW() - INTERVAL '{$period_h} hours'
");
$rows = q("
SELECT
COALESCE(operator_type, 'unknown') AS type_op,
COUNT(*) AS sessions,
COUNT(DISTINCT icao) AS avions,
ROUND(AVG(duration_seconds)/60.0, 1) AS duree_moy_min,
ROUND(AVG(alt_avg)) AS alt_moy,
ROUND(COUNT(*)*100.0/NULLIF({$total['t']},0),1) AS pct
FROM {$schema}.aircraft_history
WHERE session_start >= NOW() - INTERVAL '{$period_h} hours'
GROUP BY operator_type
ORDER BY sessions DESC
");
echo json_encode(['rows' => $rows, 'total' => (int)($total['t'] ?? 0)]);
break;
// ── Stats détaillées par type d'aéronef ──────────────────────────────
case 'stats_by_aircraft':
$total = q1("
SELECT COUNT(*) AS t FROM {$schema}.aircraft_history
WHERE session_start >= NOW() - INTERVAL '{$period_h} hours'
");
$rows = q("
SELECT
COALESCE(aircraft_type, 'unknown') AS type_ac,
COUNT(*) AS sessions,
COUNT(DISTINCT icao) AS avions,
ROUND(AVG(alt_avg)) AS alt_moy,
ROUND(AVG(speed_avg)) AS vit_moy,
ROUND(COUNT(*)*100.0/NULLIF({$total['t']},0),1) AS pct
FROM {$schema}.aircraft_history
WHERE session_start >= NOW() - INTERVAL '{$period_h} hours'
GROUP BY aircraft_type
ORDER BY sessions DESC
");
echo json_encode(['rows' => $rows, 'total' => (int)($total['t'] ?? 0)]);
break;
// ── Dernières sessions ───────────────────────────────────────────────
case 'history':
$rows = q("
SELECT
icao, callsign,
COALESCE(operator_name, '—') AS operator_name,
operator_type, aircraft_type, country,
origin, origin_name, destination, destination_name,
to_char(session_start AT TIME ZONE 'Europe/Paris','DD/MM HH24:MI:SS') AS entree,
to_char(session_end AT TIME ZONE 'Europe/Paris','DD/MM HH24:MI:SS') AS sortie,
duration_seconds, position_count,
ROUND(alt_avg) AS alt_avg, ROUND(speed_avg) AS speed_avg, rssi_avg
FROM {$schema}.aircraft_history
WHERE session_start >= NOW() - INTERVAL '{$period_h} hours'
ORDER BY session_end DESC
LIMIT {$limit}
");
echo json_encode(['sessions' => $rows, 'count' => count($rows)]);
break;
// ── Top opérateurs ───────────────────────────────────────────────────
case 'top_operators':
$rows = q("
SELECT
COALESCE(operator_name,'(' || COALESCE(callsign,'?') || ')') AS operateur,
COALESCE(operator_country,'—') AS pays,
operator_type AS type_op,
COUNT(*) AS sessions,
ROUND(AVG(duration_seconds)/60.0,0) AS duree_moy_min
FROM {$schema}.aircraft_history
WHERE session_start >= NOW() - INTERVAL '{$period_h} hours'
AND (operator_name IS NOT NULL OR callsign IS NOT NULL)
GROUP BY operator_name, callsign, operator_country, operator_type
ORDER BY sessions DESC
LIMIT 20
");
echo json_encode(['operators' => $rows]);
break;
// ── Top pays ─────────────────────────────────────────────────────────
case 'top_countries':
$rows = q("
WITH tot AS (SELECT COUNT(*) AS t FROM {$schema}.aircraft_history
WHERE session_start >= NOW() - INTERVAL '{$period_h} hours')
SELECT COALESCE(country,'Inconnu') AS pays,
COUNT(*) AS sessions,
ROUND(COUNT(*)*100.0/NULLIF(tot.t,0),1) AS pct
FROM {$schema}.aircraft_history, tot
WHERE session_start >= NOW() - INTERVAL '{$period_h} hours'
GROUP BY country, tot.t
ORDER BY sessions DESC LIMIT 15
");
echo json_encode(['countries' => $rows]);
break;
// ── Répartition par type d'opérateur (donut) ─────────────────────────
case 'by_type':
$rows = q("
SELECT COALESCE(operator_type,'unknown') AS type_op, COUNT(*) AS sessions
FROM {$schema}.aircraft_history
WHERE session_start >= NOW() - INTERVAL '{$period_h} hours'
GROUP BY operator_type ORDER BY sessions DESC
");
echo json_encode(['types' => $rows]);
break;
case 'trajectories':
// Trajectoires des 30 dernières minutes depuis adsb.positions
$minutes = max(5, min(60, (int)($_GET['minutes'] ?? 30)));
$rows = q("
SELECT
icao, callsign, aircraft_type, operator_type,
lat, lon,
CASE WHEN altitude > 0 THEN altitude ELSE NULL END AS altitude,
speed, track,
aircraft_type, aircraft_desc, aircraft_wtc,
to_char(ts AT TIME ZONE 'Europe/Paris', 'HH24:MI:SS') AS heure,
ts
FROM {$schema}.positions
WHERE ts >= NOW() - INTERVAL '{$minutes} minutes'
AND lat IS NOT NULL AND lon IS NOT NULL
AND lat BETWEEN 48.4 AND 49.2
AND lon BETWEEN 1.8 AND 2.8
ORDER BY icao, ts
");
// Grouper par icao
$aircraft = [];
foreach ($rows as $r) {
$icao = $r['icao'];
if (!isset($aircraft[$icao])) {
$aircraft[$icao] = [
'icao' => $icao,
'callsign' => $r['callsign'],
'aircraft_type' => $r['aircraft_type'],
'operator_type' => $r['operator_type'],
'track' => [],
];
}
$aircraft[$icao]['track'][] = [
'lat' => (float)$r['lat'],
'lon' => (float)$r['lon'],
'alt' => (int)$r['altitude'],
'spd' => (int)$r['speed'],
'hdg' => (float)$r['track'],
'ts' => $r['heure'],
];
// Enrichir aircraft_desc/wtc depuis la première valeur non vide
if (empty($aircraft[$icao]['desc']) && !empty($r['aircraft_desc'])) {
$aircraft[$icao]['desc'] = $r['aircraft_desc'];
$aircraft[$icao]['wtc'] = $r['aircraft_wtc'];
$aircraft[$icao]['aircraft_type'] = $r['aircraft_type'];
}
}
// Dernière position = position actuelle
foreach ($aircraft as &$ac) {
$last = end($ac['track']);
$ac['lat'] = $last['lat'];
$ac['lon'] = $last['lon'];
$ac['alt'] = $last['alt'];
$ac['spd'] = $last['spd'];
$ac['hdg'] = $last['hdg'];
// desc/wtc déjà enrichis dans la boucle track
}
echo json_encode([
'aircraft' => array_values($aircraft),
'count' => count($aircraft),
'minutes' => $minutes,
]);
break;
// ── Trajectoires historiques avec agrégation automatique ─────────────
case 'trajectories_history':
$window_min = max(1, min(525600, (int)($_GET['window'] ?? 30))); // fenêtre en minutes
// Les bornes sont horodatées AVEC leur décalage (ex. « +02:00 »).
// La webapp envoie une heure locale Europe/Paris ; la colonne ts est
// en UTC. Sans offset explicite, PostgreSQL interprète la chaîne dans
// le fuseau de la session (UTC ici) et la fenêtre se retrouve 2 h dans
// le futur — d'où une carte historique systématiquement vide.
$tz = new DateTimeZone('Europe/Paris');
try {
$from_dt = new DateTime($_GET['from'] ?? '-30 minutes', $tz);
} catch (Exception $e) {
$from_dt = new DateTime('-30 minutes', $tz);
}
$to_dt = (clone $from_dt)->modify("+{$window_min} minutes");
$from_ts = $from_dt->format('Y-m-d H:i:sP'); // 2026-08-05 19:28:00+02:00
$to_ts = $to_dt->format('Y-m-d H:i:sP');
// Stratégie d'agrégation selon la fenêtre
if ($window_min <= 60) {
// ≤ 1h : positions brutes (5s)
$rows = q("
SELECT icao, callsign, aircraft_type, aircraft_desc, aircraft_wtc,
operator_type,
lat, lon,
CASE WHEN altitude > 0 THEN altitude ELSE NULL END AS altitude,
speed, track AS hdg,
to_char(ts AT TIME ZONE 'Europe/Paris','HH24:MI:SS') AS heure
FROM {$schema}.positions
WHERE ts BETWEEN ? AND ?
AND lat IS NOT NULL AND lon IS NOT NULL
AND lat BETWEEN 48.4 AND 49.2
AND lon BETWEEN 1.8 AND 2.8
ORDER BY icao, ts
", [$from_ts, $to_ts]);
} elseif ($window_min <= 360) {
// 1h-6h : 1 point par minute
$rows = q("
SELECT icao,
MAX(callsign) AS callsign,
MAX(aircraft_type) AS aircraft_type,
MAX(aircraft_desc) AS aircraft_desc,
MAX(aircraft_wtc) AS aircraft_wtc,
MAX(operator_type) AS operator_type,
AVG(lat)::NUMERIC(9,6) AS lat,
AVG(lon)::NUMERIC(9,6) AS lon,
AVG(CASE WHEN altitude > 0 THEN altitude END)::INT AS altitude,
AVG(speed)::INT AS speed,
AVG(track)::NUMERIC(5,1) AS hdg,
to_char(date_trunc('minute', ts) AT TIME ZONE 'Europe/Paris','HH24:MI') AS heure
FROM {$schema}.positions
WHERE ts BETWEEN ? AND ?
AND lat IS NOT NULL AND lon IS NOT NULL
AND lat BETWEEN 48.4 AND 49.2
AND lon BETWEEN 1.8 AND 2.8
GROUP BY icao, date_trunc('minute', ts)
ORDER BY icao, date_trunc('minute', ts)
", [$from_ts, $to_ts]);
} elseif ($window_min <= 1440) {
// 6h-24h : 1 point par 5 minutes
$rows = q("
SELECT icao,
MAX(callsign) AS callsign,
MAX(aircraft_type) AS aircraft_type,
MAX(aircraft_desc) AS aircraft_desc,
MAX(aircraft_wtc) AS aircraft_wtc,
MAX(operator_type) AS operator_type,
AVG(lat)::NUMERIC(9,6) AS lat,
AVG(lon)::NUMERIC(9,6) AS lon,
AVG(CASE WHEN altitude > 0 THEN altitude END)::INT AS altitude,
AVG(speed)::INT AS speed,
AVG(track)::NUMERIC(5,1) AS hdg,
to_char(date_trunc('hour',ts) + floor(extract(minute FROM ts)/5)*interval '5 min'
AT TIME ZONE 'Europe/Paris','HH24:MI') AS heure
FROM {$schema}.positions
WHERE ts BETWEEN ? AND ?
AND lat IS NOT NULL AND lon IS NOT NULL
AND lat BETWEEN 48.4 AND 49.2
AND lon BETWEEN 1.8 AND 2.8
GROUP BY icao,
date_trunc('hour',ts) + floor(extract(minute FROM ts)/5)*interval '5 min'
ORDER BY icao,
date_trunc('hour',ts) + floor(extract(minute FROM ts)/5)*interval '5 min'
", [$from_ts, $to_ts]);
} elseif ($window_min <= 10080) {
// 24h-7j : 1 point par 15 minutes
$rows = q("
SELECT icao,
MAX(callsign) AS callsign,
MAX(aircraft_type) AS aircraft_type,
MAX(aircraft_desc) AS aircraft_desc,
MAX(aircraft_wtc) AS aircraft_wtc,
MAX(operator_type) AS operator_type,
AVG(lat)::NUMERIC(9,6) AS lat,
AVG(lon)::NUMERIC(9,6) AS lon,
AVG(CASE WHEN altitude > 0 THEN altitude END)::INT AS altitude,
AVG(speed)::INT AS speed,
AVG(track)::NUMERIC(5,1) AS hdg,
to_char(date_trunc('hour',ts) + floor(extract(minute FROM ts)/15)*interval '15 min'
AT TIME ZONE 'Europe/Paris','DD/MM HH24:MI') AS heure
FROM {$schema}.positions
WHERE ts BETWEEN ? AND ?
AND lat IS NOT NULL AND lon IS NOT NULL
AND lat BETWEEN 48.4 AND 49.2
AND lon BETWEEN 1.8 AND 2.8
GROUP BY icao,
date_trunc('hour',ts) + floor(extract(minute FROM ts)/15)*interval '15 min'
ORDER BY icao,
date_trunc('hour',ts) + floor(extract(minute FROM ts)/15)*interval '15 min'
", [$from_ts, $to_ts]);
} else {
// > 7j : depuis aircraft_history (sessions), 1 segment départ→arrivée
$rows = q("
SELECT icao, callsign, aircraft_type, aircraft_desc, aircraft_wtc,
operator_type,
first_lat AS lat, first_lon AS lon,
alt_avg::INT AS altitude,
speed_avg::INT AS speed,
NULL::NUMERIC AS hdg,
to_char(session_start AT TIME ZONE 'Europe/Paris','DD/MM HH24:MI') AS heure,
last_lat, last_lon,
to_char(session_end AT TIME ZONE 'Europe/Paris','DD/MM HH24:MI') AS heure_fin
FROM {$schema}.aircraft_history
WHERE session_start BETWEEN ? AND ?
AND (
(first_lat BETWEEN 48.4 AND 49.2 AND first_lon BETWEEN 1.8 AND 2.8)
OR
(last_lat BETWEEN 48.4 AND 49.2 AND last_lon BETWEEN 1.8 AND 2.8)
)
ORDER BY icao, session_start
", [$from_ts, $to_ts]);
}
// Grouper par icao (même logique que trajectories)
$use_history = ($window_min > 10080);
$aircraft = [];
foreach ($rows as $r) {
$icao = $r['icao'];
if (!isset($aircraft[$icao])) {
$aircraft[$icao] = [
'icao' => $icao,
'callsign' => $r['callsign'],
'aircraft_type' => $r['aircraft_type'],
'aircraft_desc' => $r['aircraft_desc'] ?? '',
'desc' => $r['aircraft_desc'] ?? '',
'wtc' => $r['aircraft_wtc'] ?? '',
'operator_type' => $r['operator_type'],
'track' => [],
];
}
if ($use_history) {
// Mode history : 2 points (départ + arrivée)
if (!empty($r['lat']) && !empty($r['lon'])) {
$aircraft[$icao]['track'][] = [
'lat'=>(float)$r['lat'], 'lon'=>(float)$r['lon'],
'alt'=>(int)$r['altitude'], 'spd'=>(int)$r['speed'],
'hdg'=>0, 'ts'=>$r['heure']
];
}
if (!empty($r['last_lat']) && !empty($r['last_lon'])) {
$aircraft[$icao]['track'][] = [
'lat'=>(float)$r['last_lat'], 'lon'=>(float)$r['last_lon'],
'alt'=>(int)$r['altitude'], 'spd'=>(int)$r['speed'],
'hdg'=>0, 'ts'=>$r['heure_fin']
];
}
} else {
$aircraft[$icao]['track'][] = [
'lat'=>(float)$r['lat'], 'lon'=>(float)$r['lon'],
'alt'=>(int)($r['altitude']??0), 'spd'=>(int)($r['speed']??0),
'hdg'=>(float)($r['hdg']??0), 'ts'=>$r['heure']
];
if (empty($aircraft[$icao]['desc']) && !empty($r['aircraft_desc'])) {
$aircraft[$icao]['desc'] = $r['aircraft_desc'];
$aircraft[$icao]['wtc'] = $r['aircraft_wtc'] ?? '';
}
}
}
// Calculer position courante (dernière du track)
foreach ($aircraft as &$ac) {
if (!empty($ac['track'])) {
$last = end($ac['track']);
$ac['lat'] = $last['lat'];
$ac['lon'] = $last['lon'];
$ac['alt'] = $last['alt'];
$ac['spd'] = $last['spd'];
$ac['hdg'] = $last['hdg'];
}
}
echo json_encode([
'aircraft' => array_values($aircraft),
'count' => count($aircraft),
'window_min' => $window_min,
'from' => $from_ts,
'to' => $to_ts,
'strategy' => $window_min <= 60 ? 'raw_5s'
: ($window_min <= 360 ? 'agg_1min'
: ($window_min <= 1440 ? 'agg_5min'
: ($window_min <= 10080? 'agg_15min'
: 'history_sessions'))),
]);
break;
// ── Liste des aérodromes servant de zones d'exclusion ────────────────
case 'airports':
echo json_encode([
'airports' => q("
SELECT id, code, nom, lat, lon, rayon_km, type, actif,
commentaire, source,
to_char(cree_le AT TIME ZONE 'Europe/Paris', 'DD/MM/YYYY') AS cree_le,
to_char(modifie_le AT TIME ZONE 'Europe/Paris', 'DD/MM/YYYY HH24:MI') AS modifie_le
FROM {$schema}.airports
ORDER BY actif DESC, source, rayon_km DESC, code
"),
]);
break;
// ═══════════════════════════════════════════════════════════════════
// LISTES DE SURVEILLANCE
// ═══════════════════════════════════════════════════════════════════
// Listes et leur effectif. Sert au sélecteur de l'onglet Surveillance
// comme à la modale d'ajout depuis les tableaux de départs.
case 'watchlists':
echo json_encode(['lists' => q("
SELECT w.id, w.nom, w.couleur, w.description, w.actif,
count(i.id) AS appareils,
to_char(w.created_at AT TIME ZONE 'Europe/Paris',
'DD/MM/YYYY') AS created_at
FROM {$schema}.watchlists w
LEFT JOIN {$schema}.watchlist_items i ON i.watchlist_id = w.id
GROUP BY w.id, w.nom, w.couleur, w.description, w.actif, w.created_at
ORDER BY w.nom
")]);
break;
// Création ou renommage d'une liste
case 'watchlist_save':
$id = (int)($_POST['id'] ?? 0);
$nom = trim((string)($_POST['nom'] ?? ''));
$col = trim((string)($_POST['couleur'] ?? '#d29922'));
$desc = trim((string)($_POST['description'] ?? ''));
if ($nom === '') {
http_response_code(400);
echo json_encode(['error' => 'Le nom de la liste est obligatoire']);
break;
}
if (!preg_match('/^#[0-9a-fA-F]{6}$/', $col)) $col = '#d29922';
try {
if ($id > 0) {
q("UPDATE {$schema}.watchlists
SET nom = ?, couleur = ?, description = ?
WHERE id = ?", [$nom, $col, $desc ?: null, $id]);
} else {
$row = q1("INSERT INTO {$schema}.watchlists (nom, couleur, description)
VALUES (?, ?, ?) RETURNING id", [$nom, $col, $desc ?: null]);
$id = (int)$row['id'];
}
echo json_encode(['ok' => true, 'id' => $id]);
} catch (PDOException $e) {
// 23505 = violation d'unicité sur watchlists.nom
http_response_code(409);
echo json_encode(['error' => $e->getCode() === '23505'
? "Une liste porte déjà le nom « {$nom} »"
: $e->getMessage()]);
}
break;
// Suppression d'une liste. ON DELETE CASCADE emporte ses membres :
// l'appelant doit donc confirmer, le compte est renvoyé pour l'alerte.
case 'watchlist_delete':
$id = (int)($_POST['id'] ?? 0);
if ($id <= 0) { http_response_code(400); echo json_encode(['error' => 'id manquant']); break; }
$n = q1("SELECT count(*) AS n FROM {$schema}.watchlist_items WHERE watchlist_id = ?", [$id]);
q("DELETE FROM {$schema}.watchlists WHERE id = ?", [$id]);
echo json_encode(['ok' => true, 'removed' => (int)($n['n'] ?? 0)]);
break;
// Mise sous surveillance. Idempotent : réajouter un appareil déjà
// présent met à jour le motif plutôt que d'échouer sur la contrainte
// d'unicité — un double clic ne doit pas produire une erreur.
case 'watchlist_add':
$wid = (int)($_POST['watchlist_id'] ?? 0);
$icao = strtoupper(trim((string)($_POST['icao'] ?? '')));
$motif = trim((string)($_POST['motif'] ?? ''));
if (!preg_match('/^[0-9A-F]{6}$/', $icao)) {
http_response_code(400);
echo json_encode(['error' => "Adresse ICAO invalide : « {$icao} »"]);
break;
}
if ($wid <= 0) { http_response_code(400); echo json_encode(['error' => 'Liste non précisée']); break; }
q("INSERT INTO {$schema}.watchlist_items
(watchlist_id, icao, motif, callsign, aircraft_type,
aircraft_desc, aircraft_wtc, operator_name, country)
VALUES (?,?,?,?,?,?,?,?,?)
ON CONFLICT (watchlist_id, icao) DO UPDATE
SET motif = COALESCE(NULLIF(EXCLUDED.motif, ''), {$schema}.watchlist_items.motif)",
[
$wid, $icao, $motif ?: null,
substr(trim((string)($_POST['callsign'] ?? '')), 0, 12) ?: null,
substr(trim((string)($_POST['aircraft_type'] ?? '')), 0, 8) ?: null,
substr(trim((string)($_POST['aircraft_desc'] ?? '')), 0, 60) ?: null,
substr(trim((string)($_POST['aircraft_wtc'] ?? '')), 0, 4) ?: null,
substr(trim((string)($_POST['operator_name'] ?? '')), 0, 120) ?: null,
substr(trim((string)($_POST['country'] ?? '')), 0, 60) ?: null,
]);
echo json_encode(['ok' => true, 'icao' => $icao]);
break;
// Retrait. Sans watchlist_id, l'appareil sort de TOUTES les listes :
// c'est ce que fait la bascule depuis les tableaux de départs, où
// l'utilisateur ne voit pas dans quelle liste l'appareil se trouve.
case 'watchlist_remove':
$icao = strtoupper(trim((string)($_POST['icao'] ?? '')));
$wid = (int)($_POST['watchlist_id'] ?? 0);
if (!preg_match('/^[0-9A-F]{6}$/', $icao)) {
http_response_code(400);
echo json_encode(['error' => 'Adresse ICAO invalide']);
break;
}
if ($wid > 0) {
q("DELETE FROM {$schema}.watchlist_items
WHERE icao = ? AND watchlist_id = ?", [$icao, $wid]);
} else {
q("DELETE FROM {$schema}.watchlist_items WHERE icao = ?", [$icao]);
}
echo json_encode(['ok' => true]);
break;
// Contenu d'une liste (ou de toutes), enrichi des statistiques de la
// période : nombre de départs hors aérodrome détectés, dernier contact
// et point d'ancrage pour l'affichage de la trajectoire.
//
// La détection reprend exactement la logique de departures_offfield,
// mais restreinte aux ICAO surveillés — c'est ce qui la rend abordable
// sur une longue période : la table est attaquée par l'index sur icao
// plutôt que balayée en entier.
case 'watchlist_items':
$tz = new DateTimeZone('Europe/Paris');
try {
$from_dt = new DateTime($_GET['from'] ?? '-30 days', $tz);
} catch (Exception $e) {
$from_dt = new DateTime('-30 days', $tz);
}
$win_min = max(1, min(525600, (int)($_GET['window'] ?? 43200)));
$to_dt = (clone $from_dt)->modify("+{$win_min} minutes");
$alt_max = max(500, min(20000, (int)($_GET['alt_max'] ?? 4000)));
$vrate = max(0, min(5000, (int)($_GET['vrate'] ?? 300)));
$gap_min = max(2, min(120, (int)($_GET['gap'] ?? 10)));
$margin = max(0, min(50, (float)($_GET['margin'] ?? 0)));
$wid = (int)($_GET['watchlist_id'] ?? 0);
$rx = rx_filter($_GET['receivers'] ?? '');
$where_list = $wid > 0 ? 'WHERE i.watchlist_id = ?' : '';
$sql = "
WITH sel AS (
SELECT i.id, i.watchlist_id, i.icao, i.motif, i.callsign,
i.aircraft_type, i.aircraft_desc, i.aircraft_wtc,
i.operator_name, i.country, i.added_at,
w.nom AS liste_nom, w.couleur AS liste_couleur
FROM {$schema}.watchlist_items i
JOIN {$schema}.watchlists w ON w.id = i.watchlist_id
{$where_list}
),
icaos AS (SELECT DISTINCT icao FROM sel),
pts AS (
SELECT icao, ts, altitude, vrate, lat, lon, geom,
LAG(ts) OVER (PARTITION BY icao ORDER BY ts) AS ts_prev
FROM {$schema}.positions
WHERE ts >= ? AND ts < ?
AND icao IN (SELECT icao FROM icaos)
AND altitude IS NOT NULL
AND altitude BETWEEN 0 AND ?
AND lat IS NOT NULL AND lon IS NOT NULL
{$rx['sql']}
),
seg AS (
SELECT *,
SUM(CASE WHEN ts_prev IS NULL
OR ts - ts_prev > (? || ' minutes')::interval
THEN 1 ELSE 0 END)
OVER (PARTITION BY icao ORDER BY ts) AS seg_id
FROM pts
),
bas AS (
SELECT DISTINCT ON (icao, seg_id) *
FROM seg ORDER BY icao, seg_id, altitude ASC, ts ASC
),
hors AS (
SELECT * FROM bas b
WHERE b.vrate >= ?
AND NOT EXISTS (
SELECT 1 FROM {$schema}.airports a
WHERE a.actif
AND ST_DWithin(b.geom::geography, a.geom,
(a.rayon_km + ?::double precision) * 1000)
)
),
dep AS (
SELECT DISTINCT ON (icao)
icao, count(*) OVER (PARTITION BY icao) AS departs,
ts AS dep_ts, lat AS dep_lat, lon AS dep_lon,
altitude AS dep_alt
FROM hors
ORDER BY icao, ts DESC
),
der AS (
SELECT DISTINCT ON (icao) icao, ts, lat, lon, altitude
FROM {$schema}.positions
WHERE ts >= ? AND ts < ?
AND icao IN (SELECT icao FROM icaos)
ORDER BY icao, ts DESC
)
SELECT s.*,
to_char(s.added_at AT TIME ZONE 'Europe/Paris',
'DD/MM/YYYY HH24:MI') AS added_local,
COALESCE(d.departs, 0) AS departs,
to_char(d.dep_ts AT TIME ZONE 'Europe/Paris',
'DD/MM/YYYY HH24:MI:SS') AS dep_ts_local,
d.dep_lat, d.dep_lon, d.dep_alt,
to_char(r.ts AT TIME ZONE 'Europe/Paris',
'DD/MM/YYYY HH24:MI:SS') AS last_ts_local,
r.lat AS last_lat, r.lon AS last_lon,
r.altitude AS last_alt
FROM sel s
LEFT JOIN dep d ON d.icao = s.icao
LEFT JOIN der r ON r.icao = s.icao
ORDER BY COALESCE(d.departs, 0) DESC, s.added_at DESC
";
$rows = q($sql, array_merge(
$wid > 0 ? [$wid] : [],
[
$from_dt->format('Y-m-d H:i:sP'),
$to_dt->format('Y-m-d H:i:sP'),
$alt_max,
],
$rx['params'],
[
$gap_min,
$vrate,
$margin,
$from_dt->format('Y-m-d H:i:sP'),
$to_dt->format('Y-m-d H:i:sP'),
]
));
echo json_encode([
'items' => $rows,
'count' => count($rows),
'from' => $from_dt->format('Y-m-d H:i:sP'),
'to' => $to_dt->format('Y-m-d H:i:sP'),
]);
break;
// Simple ensemble des ICAO surveillés, pour colorer les boutons des
// tableaux de départs sans rapatrier toutes les métadonnées.
case 'watchlist_icaos':
$rows = q("SELECT DISTINCT icao FROM {$schema}.watchlist_items");
echo json_encode(['icaos' => array_column($rows, 'icao')]);
break;
// ── Sources de données disponibles (récepteurs) ──────────────────────
// Alimente le menu déroulant de sélection des dongles. Deux principes :
//
// 1. La liste proposée est celle des récepteurs AYANT RÉELLEMENT DES
// POINTS sur la fenêtre demandée, pas le contenu de adsb.receivers.
// L'inserter crée automatiquement une fiche à la découverte d'un
// serial inconnu : sur une installation partagée, la table se
// remplit de récepteurs fantômes (dongle branché puis retiré,
// serial erroné dans .env.receivers) qui n'ont jamais rien produit.
// Les proposer au filtrage n'aurait aucun sens.
//
// 2. Les positions à receiver_id NULL sont exposées comme une source à
// part entière. Elles apparaissent sur toute base ayant tourné avant
// que adsb.receivers ne soit peuplée.
//
// Les fiches déclarées sans données sont renvoyées séparément (clé
// « declared_unused ») : l'interface peut les signaler sans les mêler
// aux sources sélectionnables.
case 'receivers':
$tz = new DateTimeZone('Europe/Paris');
try {
$from_dt = new DateTime($_GET['from'] ?? '-7 days', $tz);
} catch (Exception $e) {
$from_dt = new DateTime('-7 days', $tz);
}
$win_min = max(1, min(525600, (int)($_GET['window'] ?? 10080)));
$to_dt = (clone $from_dt)->modify("+{$win_min} minutes");
$used = q("
WITH used AS (
SELECT receiver_id, COUNT(*) AS pts,
MIN(ts) AS premier, MAX(ts) AS dernier
FROM {$schema}.positions
WHERE ts >= ? AND ts < ?
GROUP BY receiver_id
)
SELECT u.receiver_id AS id,
u.pts,
to_char(u.premier AT TIME ZONE 'Europe/Paris',
'DD/MM/YYYY HH24:MI') AS premier,
to_char(u.dernier AT TIME ZONE 'Europe/Paris',
'DD/MM/YYYY HH24:MI') AS dernier,
r.serial, r.model, r.chipset, r.location_desc,
r.lat, r.lon, r.active,
COALESCE(NULLIF(r.name, ''),
'Dongle ' || r.serial,
'Source non identifiée') AS name
FROM used u
LEFT JOIN {$schema}.receivers r ON r.id = u.receiver_id
ORDER BY u.pts DESC
", [$from_dt->format('Y-m-d H:i:sP'), $to_dt->format('Y-m-d H:i:sP')]);
$seen = [];
foreach ($used as $u) { if ($u['id'] !== null) $seen[] = (int)$u['id']; }
$declared = q("
SELECT id, serial, model, chipset, location_desc, lat, lon, active,
COALESCE(NULLIF(name, ''), 'Dongle ' || serial) AS name,
to_char(installed_at AT TIME ZONE 'Europe/Paris',
'DD/MM/YYYY') AS installed_at
FROM {$schema}.receivers
ORDER BY id
");
$unused = array_values(array_filter(
$declared, fn($d) => !in_array((int)$d['id'], $seen, true)
));
echo json_encode([
'receivers' => $used,
'declared_unused' => $unused,
'from' => $from_dt->format('Y-m-d H:i:sP'),
'to' => $to_dt->format('Y-m-d H:i:sP'),
]);
break;
// ── Bilan par zone connue ───────────────────────────────────────────
// Miroir de departures_offfield : au lieu d'écarter les départs situés
// dans une zone d'exclusion, on les COMPTE par zone. Répond à « combien
// d'appareils ont décollé de Villacoublay sur la période ? ».
//
// Une ligne de synthèse « hors zone » est ajoutée pour les départs qui
// ne tombent dans aucune zone active — ce sont exactement ceux que la
// carte affiche en cellules.
case 'departures_by_zone':
$tz = new DateTimeZone('Europe/Paris');
try {
$from_dt = new DateTime($_GET['from'] ?? '-14 days', $tz);
} catch (Exception $e) {
$from_dt = new DateTime('-14 days', $tz);
}
$win_min = max(1, min(525600, (int)($_GET['window'] ?? 20160)));
$to_dt = (clone $from_dt)->modify("+{$win_min} minutes");
$alt_max = max(500, min(20000, (int)($_GET['alt_max'] ?? 4000)));
$vrate = max(0, min(5000, (int)($_GET['vrate'] ?? 300)));
$gap_min = max(2, min(120, (int)($_GET['gap'] ?? 10)));
$margin = max(0, min(50, (float)($_GET['margin'] ?? 0)));
$rx = rx_filter($_GET['receivers'] ?? '');
// Les deux requêtes partagent la même préparation : on la nomme
// une fois plutôt que de la dupliquer ou de la retoucher par
// remplacement de chaîne, ce qui casserait au moindre écart
// d'indentation.
$cte = "
WITH pts AS (
SELECT icao, ts, altitude, vrate, geom,
LAG(ts) OVER (PARTITION BY icao ORDER BY ts) AS ts_prev
FROM {$schema}.positions
WHERE ts >= ? AND ts < ?
AND altitude IS NOT NULL
AND altitude BETWEEN 0 AND ?
AND lat IS NOT NULL AND lon IS NOT NULL
{$rx['sql']}
),
seg AS (
SELECT *,
SUM(CASE WHEN ts_prev IS NULL
OR ts - ts_prev > (? || ' minutes')::interval
THEN 1 ELSE 0 END)
OVER (PARTITION BY icao ORDER BY ts) AS seg_id
FROM pts
),
bas AS (
SELECT DISTINCT ON (icao, seg_id) *
FROM seg ORDER BY icao, seg_id, altitude ASC, ts ASC
),
dep AS (SELECT * FROM bas WHERE vrate >= ?),
-- Rattachement d'un départ à sa zone. Un point peut tomber dans
-- deux zones qui se chevauchent — Issy et l'Élysée sont à 3 km
-- l'un de l'autre : DISTINCT ON retient la plus proche du
-- centre, sans quoi le même décollage serait compté deux fois.
rat AS (
SELECT DISTINCT ON (d.icao, d.seg_id)
d.icao, d.seg_id, d.ts, d.altitude, d.vrate,
a.id AS zone_id
FROM dep d
LEFT JOIN {$schema}.airports a
ON a.actif
AND ST_DWithin(d.geom, a.geom,
(a.rayon_km + ?::double precision) * 1000)
ORDER BY d.icao, d.seg_id,
ST_Distance(d.geom, a.geom) NULLS LAST
)
";
$params = array_merge(
[
$from_dt->format('Y-m-d H:i:sP'),
$to_dt->format('Y-m-d H:i:sP'),
$alt_max,
],
$rx['params'],
[$gap_min, $vrate, $margin]
);
$rows = q($cte . "
SELECT a.id, a.code, a.nom, a.type, a.rayon_km, a.actif,
a.lat, a.lon, a.source,
COUNT(r.icao) AS departs,
COUNT(DISTINCT r.icao) AS appareils,
to_char(MAX(r.ts) AT TIME ZONE 'Europe/Paris',
'DD/MM/YYYY HH24:MI') AS dernier,
ROUND(AVG(r.altitude)::numeric, 0) AS alt_moy,
ROUND(AVG(r.vrate)::numeric, 0) AS vrate_moy
FROM {$schema}.airports a
LEFT JOIN rat r ON r.zone_id = a.id
WHERE a.actif
GROUP BY a.id, a.code, a.nom, a.type, a.rayon_km, a.actif,
a.lat, a.lon, a.source
ORDER BY departs DESC, a.rayon_km DESC
", $params);
// Départs ne tombant dans aucune zone active : ce sont exactement
// ceux que la carte agrège en cellules.
$hors = q1($cte . "
SELECT COUNT(*) AS departs, COUNT(DISTINCT icao) AS appareils,
to_char(MAX(ts) AT TIME ZONE 'Europe/Paris',
'DD/MM/YYYY HH24:MI') AS dernier
FROM rat WHERE zone_id IS NULL
", $params);
echo json_encode([
'zones' => $rows,
'hors_zone' => $hors,
'from' => $from_dt->format('Y-m-d H:i:sP'),
'to' => $to_dt->format('Y-m-d H:i:sP'),
'params' => ['alt_max' => $alt_max, 'vrate' => $vrate,
'gap' => $gap_min, 'margin' => $margin],
]);
break;
// ── Derniers appareils rattachés à une zone connue ──────────────────
// Alimente le tableau de détail du bilan. zone_id = 'null' vise les
// départs hors de toute zone active.
case 'zone_aircraft':
$tz = new DateTimeZone('Europe/Paris');
try {
$from_dt = new DateTime($_GET['from'] ?? '-14 days', $tz);
} catch (Exception $e) {
$from_dt = new DateTime('-14 days', $tz);
}
$win_min = max(1, min(525600, (int)($_GET['window'] ?? 20160)));
$to_dt = (clone $from_dt)->modify("+{$win_min} minutes");
$alt_max = max(500, min(20000, (int)($_GET['alt_max'] ?? 4000)));
$vrate = max(0, min(5000, (int)($_GET['vrate'] ?? 300)));
$gap_min = max(2, min(120, (int)($_GET['gap'] ?? 10)));
$margin = max(0, min(50, (float)($_GET['margin'] ?? 0)));
$lim = max(1, min(200, (int)($_GET['limit'] ?? 8)));
$rx = rx_filter($_GET['receivers'] ?? '');
$zid = $_GET['zone_id'] ?? '';
$horszone = (strcasecmp((string)$zid, 'null') === 0);
$zid_int = (int)$zid;
$where_zone = $horszone ? 'zone_id IS NULL' : 'zone_id = ?';
$sql = "
WITH pts AS (
SELECT icao, callsign, aircraft_type, aircraft_desc,
aircraft_wtc, operator_name, country,
ts, altitude, vrate, speed, lat, lon, geom,
LAG(ts) OVER (PARTITION BY icao ORDER BY ts) AS ts_prev
FROM {$schema}.positions
WHERE ts >= ? AND ts < ?
AND altitude IS NOT NULL
AND altitude BETWEEN 0 AND ?
AND lat IS NOT NULL AND lon IS NOT NULL
{$rx['sql']}
),
seg AS (
SELECT *,
SUM(CASE WHEN ts_prev IS NULL
OR ts - ts_prev > (? || ' minutes')::interval
THEN 1 ELSE 0 END)
OVER (PARTITION BY icao ORDER BY ts) AS seg_id
FROM pts
),
bas AS (
SELECT DISTINCT ON (icao, seg_id) *
FROM seg ORDER BY icao, seg_id, altitude ASC, ts ASC
),
dep AS (SELECT * FROM bas WHERE vrate >= ?),
rat AS (
SELECT DISTINCT ON (d.icao, d.seg_id)
d.icao, d.callsign, d.aircraft_type, d.aircraft_desc,
d.aircraft_wtc, d.operator_name, d.country,
d.ts, d.altitude, d.vrate, d.speed, d.lat, d.lon,
a.id AS zone_id
FROM dep d
LEFT JOIN {$schema}.airports a
ON a.actif
AND ST_DWithin(d.geom, a.geom,
(a.rayon_km + ?::double precision) * 1000)
ORDER BY d.icao, d.seg_id,
ST_Distance(d.geom, a.geom) NULLS LAST
)
SELECT icao, callsign, aircraft_type, aircraft_desc, aircraft_wtc,
operator_name, country, altitude, vrate, speed, lat, lon,
ts,
to_char(ts AT TIME ZONE 'Europe/Paris',
'DD/MM/YYYY HH24:MI:SS') AS ts_local
FROM rat
WHERE {$where_zone}
ORDER BY ts DESC
LIMIT ?
";
$params = array_merge(
[
$from_dt->format('Y-m-d H:i:sP'),
$to_dt->format('Y-m-d H:i:sP'),
$alt_max,
],
$rx['params'],
[$gap_min, $vrate, $margin],
$horszone ? [] : [$zid_int],
[$lim]
);
echo json_encode(['aircraft' => q($sql, $params)]);
break;
// ── Détail d'une zone : aéronefs détectés dans la cellule ────────────
// Rejoue la même détection que departures_offfield_zones, mais bornée
// à une seule cellule, pour lister les appareils concernés.
case 'departures_offfield_zone_detail':
$tz = new DateTimeZone('Europe/Paris');
try {
$from_dt = new DateTime($_GET['from'] ?? '-30 days', $tz);
} catch (Exception $e) {
$from_dt = new DateTime('-30 days', $tz);
}
$win_min = max(1, min(525600, (int)($_GET['window'] ?? 43200)));
$to_dt = (clone $from_dt)->modify("+{$win_min} minutes");
$alt_max = max(500, min(20000, (int)($_GET['alt_max'] ?? 4000)));
$vrate = max(0, min(5000, (int)($_GET['vrate'] ?? 300)));
$gap_min = max(2, min(120, (int)($_GET['gap'] ?? 10)));
$margin = max(0, min(50, (float)($_GET['margin'] ?? 0)));
$cell_km = max(0.5, min(20, (float)($_GET['cell'] ?? 2)));
$c_lat = (float)($_GET['lat'] ?? 0);
$c_lon = (float)($_GET['lon'] ?? 0);
// Demi-côté de la cellule, en degrés
$half_lat = ($cell_km / 111.0) / 2;
$half_lon = ($cell_km / 73.0) / 2;
$rx = rx_filter($_GET['receivers'] ?? '');
$sql = "
WITH pts AS (
SELECT icao, callsign, aircraft_type, aircraft_desc, aircraft_wtc,
operator_name, operator_type, country, receiver_id,
origin, origin_name, destination, destination_name,
ts, altitude, vrate, speed, track, lat, lon, geom, squawk,
LAG(ts) OVER (PARTITION BY icao ORDER BY ts) AS ts_prev
FROM {$schema}.positions
WHERE ts >= ? AND ts < ?
AND altitude IS NOT NULL
AND altitude BETWEEN 0 AND ?
AND lat BETWEEN ? AND ?
AND lon BETWEEN ? AND ?
{$rx['sql']}
),
seg AS (
SELECT *,
SUM(CASE WHEN ts_prev IS NULL
OR ts - ts_prev > (? || ' minutes')::interval
THEN 1 ELSE 0 END)
OVER (PARTITION BY icao ORDER BY ts) AS seg_id
FROM pts
),
segb AS (
SELECT *,
MIN(ts) OVER (PARTITION BY icao, seg_id) AS seg_t0,
MAX(ts) OVER (PARTITION BY icao, seg_id) AS seg_t1
FROM seg
),
bas AS (
SELECT DISTINCT ON (icao, seg_id) *
FROM segb ORDER BY icao, seg_id, altitude ASC, ts ASC
),
srcs AS (
SELECT icao, seg_id,
string_agg(DISTINCT COALESCE(receiver_id::text, 'null'), ',')
AS sources
FROM seg GROUP BY icao, seg_id
),
hors AS (
SELECT b.* FROM bas b
WHERE b.vrate >= ?
AND NOT EXISTS (
SELECT 1 FROM {$schema}.airports a
WHERE a.actif
AND ST_DWithin(b.geom::geography, a.geom,
(a.rayon_km + ?::double precision) * 1000)
)
)
SELECT h.icao, h.callsign, h.aircraft_type, h.aircraft_desc,
h.aircraft_wtc, h.operator_name, h.operator_type, h.country,
h.origin, h.origin_name, h.destination, h.destination_name,
h.squawk, h.altitude, h.vrate, h.speed, h.track,
h.lat, h.lon,
h.receiver_id, s.sources,
COALESCE(NULLIF(r.name, ''), 'Dongle ' || r.serial,
'Source non identifiée') AS receiver_name,
to_char(h.ts AT TIME ZONE 'Europe/Paris',
'DD/MM/YYYY HH24:MI:SS') AS ts_local,
h.ts,
p.n_pts_seg, p.alt_debut, p.alt_fin,
p.alt_min_seg, p.alt_max_seg, p.duree_min,
CASE
WHEN p.n_pts_seg < 3 OR p.duree_min < 1 THEN 'indetermine'
WHEN (p.alt_max_seg - GREATEST(p.alt_debut, p.alt_fin)) >= 1000
AND (LEAST(p.alt_debut, p.alt_fin) - p.alt_min_seg) >= 1000
THEN 'mixte'
WHEN (LEAST(p.alt_debut, p.alt_fin) - p.alt_min_seg) >= 1000
THEN 'creux'
WHEN (p.alt_max_seg - GREATEST(p.alt_debut, p.alt_fin)) >= 1000
THEN 'bosse'
WHEN p.alt_fin - p.alt_debut >= 1000 THEN 'montee'
WHEN p.alt_fin - p.alt_debut <= -1000 THEN 'descente'
ELSE 'palier'
END AS phase
FROM hors h
LEFT JOIN srcs s ON s.icao = h.icao AND s.seg_id = h.seg_id
LEFT JOIN {$schema}.receivers r ON r.id = h.receiver_id
LEFT JOIN LATERAL (
SELECT count(*) AS n_pts_seg,
MIN(q.altitude) AS alt_min_seg,
MAX(q.altitude) AS alt_max_seg,
(array_agg(q.altitude ORDER BY q.ts ASC))[1] AS alt_debut,
(array_agg(q.altitude ORDER BY q.ts DESC))[1] AS alt_fin,
ROUND((EXTRACT(EPOCH FROM (MAX(q.ts) - MIN(q.ts)))
/ 60.0)::numeric, 1) AS duree_min
FROM {$schema}.positions q
WHERE q.icao = h.icao
AND q.altitude IS NOT NULL
AND q.ts >= h.seg_t0 - (? || ' minutes')::interval
AND q.ts <= h.seg_t1 + (? || ' minutes')::interval
) p ON true
ORDER BY h.ts DESC
LIMIT 200
";
$rows = q($sql, array_merge(
[
$from_dt->format('Y-m-d H:i:sP'),
$to_dt->format('Y-m-d H:i:sP'),
$alt_max,
$c_lat - $half_lat, $c_lat + $half_lat,
$c_lon - $half_lon, $c_lon + $half_lon,
],
$rx['params'],
[
$gap_min, // seg
$vrate, $margin, // hors
$gap_min, $gap_min, // fenêtre élargie du profil
]
));
// La zone est-elle déjà couverte par une exclusion ?
$covered = q1("
SELECT a.id, a.code, a.nom, a.rayon_km, a.actif,
ROUND((ST_Distance(
ST_SetSRID(ST_MakePoint(?, ?),4326)::geography,
a.geom)/1000.0)::numeric, 2) AS dist_km
FROM {$schema}.airports a
WHERE ST_DWithin(ST_SetSRID(ST_MakePoint(?, ?),4326)::geography,
a.geom, a.rayon_km * 1000)
ORDER BY a.geom <-> ST_SetSRID(ST_MakePoint(?, ?),4326)::geography
LIMIT 1
", [$c_lon, $c_lat, $c_lon, $c_lat, $c_lon, $c_lat]);
echo json_encode([
'aircraft' => $rows,
'count' => count($rows),
'lat' => $c_lat,
'lon' => $c_lon,
'cell_km' => $cell_km,
'covered' => $covered ?: null,
]);
break;
// ── Trajectoire d'un aéronef autour d'un instant donné ───────────────
// Sert au panneau de détail : tracé sur carte + profil d'altitude.
// La fenêtre est centrée sur l'horodatage du point de départ détecté.
case 'aircraft_track':
$icao = preg_replace('/[^0-9A-Fa-f]/', '', $_GET['icao'] ?? '');
if ($icao === '') {
http_response_code(400);
echo json_encode(['error' => 'Paramètre icao requis']);
break;
}
$tz = new DateTimeZone('Europe/Paris');
try {
$ref_dt = new DateTime($_GET['ts'] ?? 'now', $tz);
} catch (Exception $e) {
$ref_dt = new DateTime('now', $tz);
}
// Fenêtre asymétrique : on veut surtout ce qui suit le décollage
$before = max(1, min(120, (int)($_GET['before'] ?? 15)));
$after = max(1, min(240, (int)($_GET['after'] ?? 45)));
$t0 = (clone $ref_dt)->modify("-{$before} minutes");
$t1 = (clone $ref_dt)->modify("+{$after} minutes");
$rows = q("
SELECT ts,
to_char(ts AT TIME ZONE 'Europe/Paris', 'HH24:MI:SS') AS hhmm,
EXTRACT(EPOCH FROM (ts - ?::timestamptz))::int AS dt_sec,
lat, lon, altitude, speed, track, vrate, callsign, squawk
FROM {$schema}.positions
WHERE icao = ?
AND ts >= ? AND ts <= ?
AND lat IS NOT NULL AND lon IS NOT NULL
ORDER BY ts
", [$ref_dt->format('Y-m-d H:i:sP'), strtoupper($icao),
$t0->format('Y-m-d H:i:sP'), $t1->format('Y-m-d H:i:sP')]);
// Fiche appareil, prise sur le point le plus renseigné
$info = q1("
SELECT icao, callsign, aircraft_type, aircraft_desc, aircraft_wtc,
operator_name, operator_type, country,
origin, origin_name, destination, destination_name
FROM {$schema}.positions
WHERE icao = ? AND ts >= ? AND ts <= ?
ORDER BY (callsign IS NOT NULL) DESC,
(operator_name IS NOT NULL) DESC, ts
LIMIT 1
", [strtoupper($icao), $t0->format('Y-m-d H:i:sP'), $t1->format('Y-m-d H:i:sP')]);
echo json_encode([
'icao' => strtoupper($icao),
'info' => $info ?: null,
'track' => $rows,
'count' => count($rows),
'ref_ts' => $ref_dt->format('Y-m-d H:i:sP'),
'from' => $t0->format('Y-m-d H:i:sP'),
'to' => $t1->format('Y-m-d H:i:sP'),
]);
break;
// ── Création / modification d'une zone d'exclusion ───────────────────
// Accepte GET ou POST. Sans id : création. Avec id : mise à jour.
// Les zones ajoutées ici portent source='manuelle' et peuvent être
// supprimées, contrairement aux aérodromes de référence.
case 'airport_save':
$in = array_merge($_GET, $_POST);
$id = isset($in['id']) && $in['id'] !== '' ? (int)$in['id'] : null;
$nom = trim($in['nom'] ?? '');
$lat = isset($in['lat']) ? (float)$in['lat'] : null;
$lon = isset($in['lon']) ? (float)$in['lon'] : null;
$rayon = isset($in['rayon_km']) ? (float)$in['rayon_km'] : 5.0;
$type = trim($in['type'] ?? 'inconnu');
$comm = trim($in['commentaire'] ?? '');
$actif = isset($in['actif'])
? filter_var($in['actif'], FILTER_VALIDATE_BOOLEAN) : true;
if ($rayon <= 0 || $rayon > 100) {
http_response_code(400);
echo json_encode(['error' => 'Rayon hors bornes (0 < rayon ≤ 100 km)']);
break;
}
if ($id) {
// Mise à jour : sur une fiche de référence, seuls le rayon,
// l'état actif et le commentaire sont modifiables — le nom et
// les coordonnées d'un aérodrome officiel ne doivent pas dériver.
$cur = q1("SELECT source FROM {$schema}.airports WHERE id = ?", [$id]);
if (!$cur) {
http_response_code(404);
echo json_encode(['error' => 'Zone introuvable']);
break;
}
if ($cur['source'] === 'reference') {
q("UPDATE {$schema}.airports
SET rayon_km = ?, actif = ?, commentaire = ?
WHERE id = ?", [$rayon, $actif ? 't' : 'f', $comm, $id]);
} else {
if ($nom === '' || $lat === null || $lon === null) {
http_response_code(400);
echo json_encode(['error' => 'Nom, latitude et longitude requis']);
break;
}
q("UPDATE {$schema}.airports
SET nom = ?, lat = ?, lon = ?, rayon_km = ?,
type = ?, actif = ?, commentaire = ?
WHERE id = ?",
[$nom, $lat, $lon, $rayon, $type, $actif ? 't' : 'f', $comm, $id]);
}
echo json_encode(['ok' => true, 'id' => $id, 'mode' => 'update']);
} else {
if ($nom === '' || $lat === null || $lon === null) {
http_response_code(400);
echo json_encode(['error' => 'Nom, latitude et longitude requis']);
break;
}
// Code interne unique, non OACI : Z001, Z002…
$next = q1("SELECT COALESCE(MAX(NULLIF(regexp_replace(code,'\\D','','g'),'')::int),0)+1 AS n
FROM {$schema}.airports WHERE code ~ '^Z[0-9]+$'");
$code = sprintf('Z%03d', $next['n'] ?? 1);
$row = q1("INSERT INTO {$schema}.airports
(code, nom, lat, lon, rayon_km, type, actif, commentaire, source)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'manuelle')
RETURNING id",
[$code, $nom, $lat, $lon, $rayon, $type,
$actif ? 't' : 'f', $comm]);
echo json_encode(['ok' => true, 'id' => $row['id'] ?? null,
'code' => $code, 'mode' => 'insert']);
}
break;
// ── Suppression d'une zone manuelle ──────────────────────────────────
// Les aérodromes de référence ne sont jamais supprimés : on les
// désactive (actif=false), ce qui les retire de l'exclusion tout en
// conservant la fiche pour une réactivation ultérieure.
// Réglage groupé des rayons, écrit en base — à ne pas confondre avec
// le sélecteur « Marge » de la barre d'outils, qui applique un ajout
// au moment de la requête sans rien persister.
//
// Trois modes. « delta » et « facteur » préservent la hiérarchie entre
// un aéroport à 15 km et une hélisurface à 300 m ; « set » l'écrase,
// d'où la confirmation exigée côté interface.
case 'airports_bulk_radius':
$mode = $_POST['mode'] ?? '';
$val = (float)($_POST['value'] ?? 0);
$scope = $_POST['scope'] ?? 'all';
if (!in_array($mode, ['set', 'delta', 'factor'], true)) {
http_response_code(400);
echo json_encode(['error' => 'Mode inconnu']);
break;
}
if ($mode === 'set' && ($val <= 0 || $val > 100)) {
http_response_code(400);
echo json_encode(['error' => 'Rayon hors bornes (0 < r ≤ 100 km)']);
break;
}
if ($mode === 'factor' && ($val <= 0 || $val > 20)) {
http_response_code(400);
echo json_encode(['error' => 'Facteur hors bornes (0 < f ≤ 20)']);
break;
}
if ($mode === 'delta' && abs($val) > 100) {
http_response_code(400);
echo json_encode(['error' => 'Delta hors bornes (±100 km)']);
break;
}
$expr = [
'set' => '?',
'delta' => 'rayon_km + ?',
'factor' => 'rayon_km * ?',
][$mode];
$where = [
'all' => '1=1',
'actives' => 'actif',
'reference' => "source = 'reference'",
'manuelle' => "source = 'manuelle'",
][$scope] ?? '1=1';
// Bornage en SQL : la contrainte airports_rayon_chk rejetterait
// tout l'UPDATE si une seule ligne sortait de l'intervalle, et un
// delta négatif appliqué à une hélisurface de 300 m y arriverait
// immédiatement.
$rows = q("
UPDATE {$schema}.airports
SET rayon_km = LEAST(100.0, GREATEST(0.1, {$expr}))
WHERE {$where}
RETURNING id, code, nom, rayon_km
", [$val]);
echo json_encode([
'ok' => true,
'updated' => count($rows),
'mode' => $mode,
'value' => $val,
'scope' => $scope,
'rows' => $rows,
]);
break;
case 'airport_delete':
$in = array_merge($_GET, $_POST);
$id = isset($in['id']) ? (int)$in['id'] : 0;
$cur = q1("SELECT source, code, nom FROM {$schema}.airports WHERE id = ?", [$id]);
if (!$cur) {
http_response_code(404);
echo json_encode(['error' => 'Zone introuvable']);
break;
}
if ($cur['source'] === 'reference') {
q("UPDATE {$schema}.airports SET actif = false WHERE id = ?", [$id]);
echo json_encode([
'ok' => true, 'mode' => 'deactivated',
'message' => "{$cur['code']} est un aérodrome de référence : "
. "désactivé plutôt que supprimé",
]);
} else {
q("DELETE FROM {$schema}.airports WHERE id = ?", [$id]);
echo json_encode(['ok' => true, 'mode' => 'deleted',
'message' => "Zone {$cur['nom']} supprimée"]);
}
break;
// ── Activation / désactivation rapide ────────────────────────────────
case 'airport_toggle':
$in = array_merge($_GET, $_POST);
$id = isset($in['id']) ? (int)$in['id'] : 0;
$row = q1("UPDATE {$schema}.airports SET actif = NOT actif
WHERE id = ? RETURNING id, code, actif", [$id]);
if (!$row) {
http_response_code(404);
echo json_encode(['error' => 'Zone introuvable']);
break;
}
echo json_encode(['ok' => true, 'id' => $row['id'],
'code' => $row['code'], 'actif' => $row['actif']]);
break;
// ── Départs détectés HORS des aérodromes connus ──────────────────────
// Principe : on segmente la trace de chaque appareil sur les trous de
// plus de $gap minutes, on prend le point le PLUS BAS de chaque segment,
// on ne garde que ceux en montée franche, puis on écarte tout ce qui
// tombe dans le rayon d'un aérodrome actif.
//
// Limite physique à garder en tête : un appareil décollant à 40 km est
// masqué par l'horizon radio jusqu'à ~1500 ft. Le point détecté est donc
// « le plus bas capté », pas le point de décollage réel — l'écart croît
// avec la distance.
case 'departures_offfield':
$tz = new DateTimeZone('Europe/Paris');
try {
$from_dt = new DateTime($_GET['from'] ?? '-7 days', $tz);
} catch (Exception $e) {
$from_dt = new DateTime('-7 days', $tz);
}
$win_min = max(1, min(525600, (int)($_GET['window'] ?? 10080)));
$to_dt = (clone $from_dt)->modify("+{$win_min} minutes");
$alt_max = max(500, min(20000, (int)($_GET['alt_max'] ?? 4000)));
$vrate = max(0, min(5000, (int)($_GET['vrate'] ?? 300)));
$gap_min = max(2, min(120, (int)($_GET['gap'] ?? 10)));
$margin = max(0, min(50, (float)($_GET['margin'] ?? 0)));
$lim = max(10, min(2000, (int)($_GET['max'] ?? 500)));
// Filtre sur les sources. La restriction s'applique EN ENTRÉE, dans
// la CTE pts : la segmentation et la détection travaillent ensuite
// sur la trace fusionnée des récepteurs retenus. Deux dongles cochés
// donnent donc UN départ, pas deux — les trous de couverture de
// l'un étant comblés par l'autre, la détection gagne en fiabilité.
$rx = rx_filter($_GET['receivers'] ?? '');
$sql = "
WITH pts AS (
SELECT icao, callsign, aircraft_type, aircraft_desc, aircraft_wtc,
operator_name, operator_type, country, receiver_id,
ts, altitude, vrate, speed, track, lat, lon, geom,
LAG(ts) OVER (PARTITION BY icao ORDER BY ts) AS ts_prev
FROM {$schema}.positions
WHERE ts >= ? AND ts < ?
AND altitude IS NOT NULL
AND altitude BETWEEN 0 AND ?
AND lat IS NOT NULL AND lon IS NOT NULL
{$rx['sql']}
),
seg AS (
SELECT *,
SUM(CASE WHEN ts_prev IS NULL
OR ts - ts_prev > (? || ' minutes')::interval
THEN 1 ELSE 0 END)
OVER (PARTITION BY icao ORDER BY ts) AS seg_id
FROM pts
),
-- Bornes temporelles du segment : elles servent à aller
-- rechercher le profil d'altitude complet, hors plafond.
segb AS (
SELECT *,
MIN(ts) OVER (PARTITION BY icao, seg_id) AS seg_t0,
MAX(ts) OVER (PARTITION BY icao, seg_id) AS seg_t1
FROM seg
),
bas AS (
SELECT DISTINCT ON (icao, seg_id) *
FROM segb
ORDER BY icao, seg_id, altitude ASC, ts ASC
),
-- Récepteurs ayant contribué au segment retenu. Le point le plus
-- bas ne vient que d'UNE source ; cette agrégation dit lesquelles
-- ont vu la montée dans son ensemble.
srcs AS (
SELECT icao, seg_id,
string_agg(DISTINCT COALESCE(receiver_id::text, 'null'), ',')
AS sources
FROM seg
GROUP BY icao, seg_id
),
-- Départs retenus. Le filtrage est remonté ici pour que le
-- calcul de profil qui suit ne porte que sur les survivants :
-- c'est une recherche indexée par appareil, on n'en veut pas
-- une par segment écarté.
hors AS (
SELECT b.* FROM bas b
WHERE b.vrate >= ?
AND NOT EXISTS (
SELECT 1 FROM {$schema}.airports a
WHERE a.actif
AND ST_DWithin(b.geom::geography, a.geom,
(a.rayon_km + ?::double precision) * 1000)
)
)
SELECT h.icao, h.callsign, h.aircraft_type, h.aircraft_desc, h.aircraft_wtc,
h.operator_name, h.operator_type, h.country,
to_char(h.ts AT TIME ZONE 'Europe/Paris',
'DD/MM/YYYY HH24:MI:SS') AS ts_local,
h.ts, h.altitude, h.vrate, h.speed, h.track,
h.lat, h.lon,
h.receiver_id,
COALESCE(NULLIF(r.name, ''), 'Dongle ' || r.serial,
'Source non identifiée') AS receiver_name,
s.sources,
(SELECT a.code FROM {$schema}.airports a
WHERE a.actif
ORDER BY h.geom::geography <-> a.geom LIMIT 1) AS aero_proche,
ROUND(((SELECT MIN(ST_Distance(h.geom::geography, a.geom))
FROM {$schema}.airports a WHERE a.actif)/1000.0)::numeric, 1)
AS dist_aero_km,
-- Distance mesurée au récepteur qui a capté le point, et
-- non à une station unique : sur une base alimentée par
-- plusieurs contributeurs, une constante rendrait cet
-- indicateur de fiabilité faux. Repli sur les coordonnées
-- de config.php quand le récepteur n'est pas localisé.
ROUND((ST_Distance(
h.geom::geography,
ST_SetSRID(ST_MakePoint(
COALESCE(r.lon::double precision, ?),
COALESCE(r.lat::double precision, ?)),4326)::geography
)/1000.0)::numeric, 1) AS dist_station_km,
-- ── Profil d'altitude du contact ────────────────────
p.n_pts_seg, p.alt_debut, p.alt_fin,
p.alt_min_seg, p.alt_max_seg, p.duree_min,
CASE
WHEN p.n_pts_seg < 3 OR p.duree_min < 1 THEN 'indetermine'
-- Creux ET bosse marqués : profil en dents de scie
WHEN (p.alt_max_seg - GREATEST(p.alt_debut, p.alt_fin)) >= 1000
AND (LEAST(p.alt_debut, p.alt_fin) - p.alt_min_seg) >= 1000
THEN 'mixte'
-- Descend nettement sous ses deux extrémités puis
-- remonte : approche interrompue, touch-and-go, ou
-- posé suivi d'un nouveau départ.
WHEN (LEAST(p.alt_debut, p.alt_fin) - p.alt_min_seg) >= 1000
THEN 'creux'
-- Monte nettement au-dessus de ses deux extrémités
-- puis redescend : vol local complet dans le contact.
WHEN (p.alt_max_seg - GREATEST(p.alt_debut, p.alt_fin)) >= 1000
THEN 'bosse'
WHEN p.alt_fin - p.alt_debut >= 1000 THEN 'montee'
WHEN p.alt_fin - p.alt_debut <= -1000 THEN 'descente'
ELSE 'palier'
END AS phase
FROM hors h
LEFT JOIN srcs s ON s.icao = h.icao AND s.seg_id = h.seg_id
LEFT JOIN {$schema}.receivers r ON r.id = h.receiver_id
-- Profil recalculé SANS le plafond d'altitude : la CTE pts est
-- bornée à alt_max (4 000 ft par défaut), ce qui tronque toute
-- montée qui le dépasse et ferait conclure à un palier. La
-- fenêtre est élargie de la durée du trou de segmentation, afin
-- de récupérer la suite du contact au-delà du plafond.
LEFT JOIN LATERAL (
SELECT count(*) AS n_pts_seg,
MIN(q.altitude) AS alt_min_seg,
MAX(q.altitude) AS alt_max_seg,
(array_agg(q.altitude ORDER BY q.ts ASC))[1] AS alt_debut,
(array_agg(q.altitude ORDER BY q.ts DESC))[1] AS alt_fin,
ROUND((EXTRACT(EPOCH FROM (MAX(q.ts) - MIN(q.ts)))
/ 60.0)::numeric, 1) AS duree_min
FROM {$schema}.positions q
WHERE q.icao = h.icao
AND q.altitude IS NOT NULL
AND q.ts >= h.seg_t0 - (? || ' minutes')::interval
AND q.ts <= h.seg_t1 + (? || ' minutes')::interval
) p ON true
ORDER BY h.ts DESC
LIMIT ?
";
// L'ordre des paramètres suit l'ordre d'apparition des ? dans le SQL :
// les paramètres du filtre de sources s'insèrent donc APRÈS alt_max
// et AVANT gap_min.
$rows = q($sql, array_merge(
[
$from_dt->format('Y-m-d H:i:sP'),
$to_dt->format('Y-m-d H:i:sP'),
$alt_max,
],
$rx['params'],
[
$gap_min, // seg
$vrate, $margin, // hors
STATION_LON, STATION_LAT, // distance station
$gap_min, $gap_min, // fenêtre élargie du profil
$lim,
]
));
echo json_encode([
'departures' => $rows,
'count' => count($rows),
'from' => $from_dt->format('Y-m-d H:i:sP'),
'to' => $to_dt->format('Y-m-d H:i:sP'),
'params' => [
'alt_max' => $alt_max, 'vrate' => $vrate,
'gap' => $gap_min, 'margin' => $margin, 'max' => $lim,
'receivers' => $rx['ids'],
'receivers_null' => $rx['null'],
'receivers_all' => ($rx['sql'] === ''),
],
'truncated' => count($rows) >= $lim,
]);
break;
// ── Agrégation spatiale des départs hors aérodromes ──────────────────
// Même détection, mais regroupée en cellules pour faire ressortir les
// ZONES récurrentes plutôt que les événements isolés. La taille de
// cellule est donnée en km et convertie en degrés (approximation
// suffisante à la latitude de Paris : 1° lat ≈ 111 km,
// 1° lon ≈ 111 × cos(48.8) ≈ 73 km).
case 'departures_offfield_zones':
$tz = new DateTimeZone('Europe/Paris');
try {
$from_dt = new DateTime($_GET['from'] ?? '-30 days', $tz);
} catch (Exception $e) {
$from_dt = new DateTime('-30 days', $tz);
}
$win_min = max(1, min(525600, (int)($_GET['window'] ?? 43200)));
$to_dt = (clone $from_dt)->modify("+{$win_min} minutes");
$alt_max = max(500, min(20000, (int)($_GET['alt_max'] ?? 4000)));
$vrate = max(0, min(5000, (int)($_GET['vrate'] ?? 300)));
$gap_min = max(2, min(120, (int)($_GET['gap'] ?? 10)));
$margin = max(0, min(50, (float)($_GET['margin'] ?? 0)));
$cell_km = max(0.5, min(20, (float)($_GET['cell'] ?? 2)));
$min_evt = max(1, min(100, (int)($_GET['min_events'] ?? 2)));
$cell_lat = $cell_km / 111.0;
$cell_lon = $cell_km / 73.0;
// Même sémantique que l'onglet liste : restriction en entrée, puis
// segmentation sur la trace fusionnée des récepteurs retenus.
$rx = rx_filter($_GET['receivers'] ?? '');
$sql = "
WITH pts AS (
SELECT icao, callsign, aircraft_type, operator_type,
ts, altitude, vrate, lat, lon, geom,
LAG(ts) OVER (PARTITION BY icao ORDER BY ts) AS ts_prev
FROM {$schema}.positions
WHERE ts >= ? AND ts < ?
AND altitude IS NOT NULL
AND altitude BETWEEN 0 AND ?
AND lat IS NOT NULL AND lon IS NOT NULL
{$rx['sql']}
),
seg AS (
SELECT *,
SUM(CASE WHEN ts_prev IS NULL
OR ts - ts_prev > (? || ' minutes')::interval
THEN 1 ELSE 0 END)
OVER (PARTITION BY icao ORDER BY ts) AS seg_id
FROM pts
),
bas AS (
SELECT DISTINCT ON (icao, seg_id) *
FROM seg ORDER BY icao, seg_id, altitude ASC, ts ASC
),
hors AS (
SELECT * FROM bas b
WHERE b.vrate >= ?
AND NOT EXISTS (
SELECT 1 FROM {$schema}.airports a
WHERE a.actif
AND ST_DWithin(b.geom::geography, a.geom,
(a.rayon_km + ?::double precision) * 1000)
)
),
cells AS (
-- La cellule est calculée UNE fois ici : réutiliser les
-- mêmes paramètres liés dans le SELECT et le GROUP BY ne
-- fonctionne pas (PostgreSQL ne reconnaît pas $7 et $12
-- comme la même expression, d'où « must appear in the
-- GROUP BY clause »).
--
-- Les ::double precision sont indispensables : sans eux,
-- PostgreSQL infère integer depuis le contexte FLOOR() et
-- rejette une taille de cellule décimale (0.018 en degrés).
SELECT icao, callsign, ts, altitude, vrate,
FLOOR(lat / ?::double precision) AS cy,
FLOOR(lon / ?::double precision) AS cx
FROM hors
)
SELECT ROUND((cy * ?::double precision + ?::double precision / 2)::numeric, 5) AS lat,
ROUND((cx * ?::double precision + ?::double precision / 2)::numeric, 5) AS lon,
count(*) AS evenements,
count(DISTINCT icao) AS appareils,
ROUND(AVG(altitude)::numeric) AS alt_moy,
MIN(altitude) AS alt_min,
ROUND(AVG(vrate)::numeric) AS vrate_moy,
to_char(MIN(ts) AT TIME ZONE 'Europe/Paris', 'DD/MM/YYYY') AS premier,
to_char(MAX(ts) AT TIME ZONE 'Europe/Paris', 'DD/MM/YYYY') AS dernier,
string_agg(DISTINCT coalesce(callsign,'—'), ', '
ORDER BY coalesce(callsign,'—')) AS callsigns
FROM cells
GROUP BY cy, cx
HAVING count(*) >= ?
ORDER BY count(*) DESC
LIMIT 500
";
$rows = q($sql, array_merge(
[
$from_dt->format('Y-m-d H:i:sP'),
$to_dt->format('Y-m-d H:i:sP'),
$alt_max,
],
$rx['params'],
[
$gap_min,
$vrate,
$margin,
$cell_lat, $cell_lon,
$cell_lat, $cell_lat,
$cell_lon, $cell_lon,
$min_evt,
]
));
echo json_encode([
'zones' => $rows,
'count' => count($rows),
'from' => $from_dt->format('Y-m-d H:i:sP'),
'to' => $to_dt->format('Y-m-d H:i:sP'),
'params' => [
'alt_max' => $alt_max, 'vrate' => $vrate, 'gap' => $gap_min,
'margin' => $margin, 'cell' => $cell_km, 'min_events' => $min_evt,
'receivers' => $rx['ids'],
'receivers_null' => $rx['null'],
'receivers_all' => ($rx['sql'] === ''),
],
]);
break;
default:
http_response_code(400);
echo json_encode(['error' => 'Action inconnue']);
}
} catch (\Throwable $e) {
http_response_code(500);
echo json_encode(['error' => $e->getMessage()]);
}