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()]); }