1915 lines
94 KiB
PHP
1915 lines
94 KiB
PHP
<?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()]);
|
||
}
|