Vous appréciez mon travail ?
Je serais ravi de prendre un café !

Vous prenez du plaisir à lire mes articles ? Vous apprenez de nouvelles choses ? Je serais ravis que vous supportiez mon travail avec une petite participation

1 café Merci, vous financez ma dose quotidienne de théïne (oui, en vrai je ne bois pas de café).
5 cafés Génial, ça couvre mes frais de serveur mensuels.
10 cafés Fantastique, avec ça je peux investir dans du matériel et approfondir mes connaissances.
BazinGa's - Tips & tuto IT

Suivons le Père Noël avec PostGIS – v2

Depuis quelques années, je développe un système de suivi du Père Noël. Comme j’ai « quelques notions » de SQL et de développement web SIG, j’ai tous les outils nécessaires à une telle application sous la main.

J’avais déjà conçu une première version détaillée dans cet article dédié, nous voila reparti pour un nouveau Noël avec un suivi amélioré.


Pour le suivi, j’ai un peu étudier la chose et il s’avère qu’un traineau magique, ça n’apparait pas sur les radars. Il est donc compliqué de suivre le bonhomme.

Malgré mes nombreux courriers, le Père Noël refuse également de m’envoyer son itinéraire détaillé, un petit GPX aurait pourtant bien fait mon affaire mais il semble qu’il n’utilise pas de GPS…

Il faut donc un peu ruser pour suivre Santa à la trace et voici comment j’ai procédé.

Le principe est de partir d’une liste de destinations que le Père Noël va visiter. On calcule ensuite le trajet pour rejoindre toutes ces destinations en tenant compte du fait que le passage doit s’effectuer durant la nuit du 24 au 25 décembre, et entre minuit et 1h du matin en tenant compte de chaque fuseau horaire. Plus facile à dire qu’a faire mais essayons.

L’application repose sur PostgreSQL 15 + PostGIS 3.5.


Notez que cet article s’intéresse à la partie donnée (PostgreSQL + PostGIS) et non à la partie conception de l’application web SIG.

Destinations

A priori, le Père Noël visite toutes les maisons du monde. Ceci dit, « toutes les maisons du monde » c’est complexe à cartographier : un igloo doit il être considéré comme une maison même s’il n’a pas de cheminée ?

Alors simplifions un peu la chose et basons nous sur l’ensemble des villes du monde. Bon là aussi c’est complexe car il y a vraiment beaucoup de villes et ça risque d’être difficile de suivre tous les déplacements.

Simplifions encore et prenons environ 1000 destinations parmi les plus connues dans le monde (avec peut être une sur représentation des villes de l’hexagone ;-).

Pour récupérer ces données, une petite recherche sur internet et nous voila avec un magnifique jeu de données du monde entier dont la population est supérieure à 1000 habitants.

Quelques traitements plus tard (rien de bien sorcier, juste des formules magiques pour récupérer les villes qui m’intéressent) nous voici avec la table suivante :

CREATE TABLE IF NOT EXISTS santa.destination (
	-- Un identifiant numérique
	id_destination int8 PRIMARY KEY,
	-- Le pays de la destination
	pays text,
	-- La région de la destination
	region text,
	-- Le nom de la destination
	ville text,
	-- Le nombre d'habitant dans cette destination
	population int8,
	-- L'interval de temps entre le fuseau horaire de la destination et GMT
	utc_offset interval,
	-- Nom de la timezone
	timezone text,
	-- L'emplacement de la destination
	geom public.geometry(point, 4326)
);

Problème, cette table ne possède pas l’emplacement du Père Noël… Nous allons rusé pour cela.

Quelques variables

Pour les différents calculs que nous allons mener, je vais avoir besoin de définir certains paramètres.

Parmi ces variables les éléments suivants :

  • La population totale
  • Le nombre de cadeaux par personne
  • Le pourcentage de personnes recevant un cadeau (et oui, tout le monde n’est pas sage…)
  • L’emplacement du Père Noël
  • La date et l’heure du début de la tournée : le 25 décembre à minuit dans le premier fuseau horaire (GMT +13)
  • L’instant présent : transaction_timestamp(), cela permet d’effectuer des test en faisant varier cette valeur.

Pour stocker des paramètres, j’utilise souvent une vue effectuant un simple SELECT que j’utilise ensuite dans mes différentes requêtes via un CROSS JOIN.

CREATE OR REPLACE VIEW santa.v_variable as (
	SELECT 
		-- Population
		-- Le nombre total d'être humain sur Terre
		8142000000 as pop_total,
		-- Le pourcentage de personne recevant des cadeaux (oui, certains ont été méchants cette année)
		0.95 as cadeau_par_pop,
		-- Le nombre de cadeau par personnes
		1 as cadeau_par_pers,
		
		-- Autres données
		-- Le pourcentage de temps total passé à voler de maison en maison (ici 40% de temps de vol donc 60% du temps passé dans les maisons)
		40::numeric as pourcent_temps_de_vol,
		-- Les informations sur l'emplacement de la maison du Père Noël
		jsonb_build_object(
			'id', 0,
			'pays', 'Svalbard',
			'region', 'Longyearbyen',
			'ville', 'Maison de la famille Noël',
			'population', 2,
			'timezone', 'Europe/Paris',
			'utc_offset', '01:00:00',
			'geom_4326', 'SRID=4326;POINT(24 80)'
		) as maison_pere_noel,

		-- Paroles du Père Noël
		$${
			"Préparatifs":[
				"Vive le vent d'hiver !",
				"Et un cadeau de plus !",
				"Hum ?!",
				"Quel beau camion !",
				"Ah, une belle poupée !",
				"Oh Oh Oh !",
				"Vive le vent d'hiver !",
				"Et un cadeau de plus !",
				"Hum ?!",
				"Quel beau camion !",
				"Ah, une belle poupée !",
				"Oh Oh Oh !"
			],
			"Démarrage tournée":[
				"Oh Oh Oh !",
				"Joyeux Noël !",
				"Mon beau sapin !",
				"Hue tornade !",
				"Allez comète !",
				"Il fait frais cette nuit !",
				"Oh Oh Oh !",
				"Joyeux Noël !",
				"Mon beau sapin !",
				"Hue tornade !",
				"Allez comète !",
				"Il fait frais cette nuit !"
			],
			"Trajet":[
				"Oh Oh Oh !",
				"Joyeux Noël !",
				"Mon beau sapin !",
				"Hue tornade !",
				"Allez comète !",
				"Il fait frais cette nuit !",
				"Oh Oh Oh !",
				"Joyeux Noël !",
				"Mon beau sapin !",
				"Hue tornade !",
				"Allez comète !",
				"Il fait frais cette nuit !"
			],
			"Distribution":[
				"Oh hisse !",
				"Serrée cette cheminée !",
				"Et un cadeau de plus !",
				"Chut, pas un bruit !",
				"Hum un petit biscuit...",
				"Oh Oh Oh !",
				"Oh hisse !",
				"Serrée cette cheminée !",
				"Et un cadeau de plus !",
				"Chut, pas un bruit !",
				"Hum un petit biscuit...",
				"Oh Oh Oh !"
			],
			"Fin tournée":[
				"Oh Oh Oh !",
				"Joyeux Noël !",
				"Mon beau sapin !",
				"Hue tornade !",
				"Allez comète !",
				"Il fait frais cette nuit !",
				"Oh Oh Oh !",
				"Joyeux Noël !",
				"Mon beau sapin !",
				"Hue tornade !",
				"Allez comète !",
				"Il fait frais cette nuit !"
			],
			"Repos":[
				"ZZZ",
				"ZZZ ZZZ",
				"ZZZ ZZZ ZZZ",
				"ZZZ ZZZ",
				"ZZZ",
				"RONFFFFFFFLE",
				"ZZZ",
				"ZZZ ZZZ",
				"ZZZ ZZZ ZZZ",
				"ZZZ ZZZ",
				"ZZZ",
				"RONFFFFFFFLE"
			]
		}$$::jsonb as parole_pere_noel,
		
		-- Dates et horaire
		-- Les dates contiennent toujours les heures
		-- L'heure exacte du début de la distribution (le 25 décembre à minuit sur le premier fuseau horaire)
		-- Le Père Noël met 1h pour distribuer les cadeau par fuseau horaire sauf si un fuseau intermédiaire existe
		(extract(year from now()) || '-12-25 00:00:00 +13')::timestamp with time zone as debut_distrib,

		-- Durée du trajet pour rejoindre la première destination depuis la maison du Père Noël
		-- Durée également pour rejoindre la maison du Père Noël depuis la dernière maison
		'2 minutes'::interval as delai_dest_santa_house,

		-- Heure du début de la transaction
		transaction_timestamp() as "now"	-- Maintenant
)
;

Trajet

Nous devons maintenant calculer le trajet.

Ce trajet doit répondre aux critères suivants :

  • Passer par toutes les destinations
  • Démarrer et finir par la maison du Père Noël
  • Chaque destination doit être entre minuit et 1h du matin pour le fuseau horaire considéré

Nous allons donc devoir découper la Terre en zones de passage dans lesquelles le Père Noël aura un timing bien spécifique à respecter. Le principe est de déterminer l’intervalle de temps pendant lequel le Père Noël effectue sa tournée puis de diviser cet intervalle par le nombre de zones de passage.

Pour calculer le trajet et pouvoir relancer le calcul facilement, je vais me baser sur plusieurs vues.

Calcul des zones horaires

Le principe est d’attribuer à chaque destination un numéro de rang et une durée de passage en fonction de son fuseau horaire.

Pour cela on se base sur la valeur de la colonne utc_offset qui contient le décalage horaire avec GMT ainsi que plusieurs fonction de fenêtrage.

CREATE OR REPLACE VIEW santa.v_destination_zone_horaire as (
	-- Clause CTE pour récupérer une liste d'offset UTC accompagné de l'offset suivant
	WITH utc_offset_list AS (
		SELECT
			dest.utc_offset,
			-- Récupération de l'offset suivant
			lead(utc_offset) OVER (ORDER BY utc_offset) AS next_utc_offset
		FROM 
			santa.destination as dest
		GROUP BY 
			dest.utc_offset
	)
	SELECT 
		dense_rank() OVER zone_globale as rang_zone_horaire,
		dest.pays,
		dest.region,
		dest.ville,
		dest.timezone,
		dest.utc_offset,
		-- Durée de passage dans la zone par soustraction de la valeur de l'offset suivant
		CASE 
			WHEN uol.next_utc_offset IS NULL THEN '01:00:00'::INTERVAL
			ELSE uol.next_utc_offset - dest.utc_offset
		END duree_zone,
		-- Valeur par défaut pour la population
		CASE 
			WHEN dest.population IS NULL OR dest.population < 10 THEN 10
			ELSE dest.population
		END as population,
		-- Nombre de destination dans la zone horaire
		count(id_destination) OVER zone_horaire as nb_dest_zone_horaire,
		-- Coordnnées Y minimum et maximum de chaque pays (pour l'ordonnancement)
		first_value(ST_Y(dest.geom)) OVER pays as miny_pays,
		last_value(ST_Y(dest.geom)) OVER pays as maxy_pays,
		geom
	FROM 
		santa.destination as dest
	CROSS JOIN 
		santa.v_variable as var
	LEFT JOIN 
		utc_offset_list AS uol
		ON uol.utc_offset = dest.utc_offset 
	WINDOW 
		zone_globale AS (
			ORDER BY 
				dest.utc_offset DESC
			ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
		),
		zone_horaire AS (
			PARTITION BY 
				dest.utc_offset
			ORDER BY 
				dest.utc_offset DESC
			ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
		),
		pays AS (
			PARTITION BY 
				dest.utc_offset,
				dest.pays
			ORDER BY 
				ST_Y(dest.geom)
			ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
		)
)
;

Ordonnancement par latitude

Au sein de chaque zone horaire, les destinations sont ordonnancées afin d’avoir un trajet cohérent allant du nord au sud dans une zone puis du sud au nord dans la zone suivante dans un mouvement à l’effet « yoyo ». Les destinations sont également regroupées par pays : on commence par le pays le plus Nord puis celui juste après…

La génération d’un identifiant spécifique permettra d’ordonnancer les destination.

CREATE OR REPLACE VIEW santa.v_destination_ordonnees as (
	SELECT 
		-- Récupération du rang de chaque destination au sein de sa zone de longitude
		-- Le rang est déterminé par la latitude de l'objet
		-- Pour faire un effet "yoyo", on inverse l'ordonnancement des latitudes une zone horaire sur deux
		concat_ws(
			'-',
			lpad((vdzh.rang_zone_horaire)::text, 2, '0'),
			lpad((row_number() OVER zone_horaire)::text, 3, '0')
		) as id,
		vdzh.pays,
		vdzh.region,
		vdzh.ville,
		vdzh.timezone,
		vdzh.utc_offset,
		vdzh.duree_zone,
		vdzh.population,
		-- Création d'une valeur de population fictive
		-- pour qu'une fois toutes destination visitées, le Père Noël ait vu toute la population mondiale
		round( 
			(
				(vdzh.population / sum(vdzh.population) OVER ()) 
				* var.pop_total::NUMERIC
			),
			0 
		) as pop_fictive,
		vdzh.nb_dest_zone_horaire,
		ST_Transform(vdzh.geom, 3857) as geom
	FROM
		santa.v_destination_zone_horaire as vdzh
	CROSS JOIN 
		santa.v_variable as var
	WINDOW
		zone_horaire AS (
			PARTITION BY 
				vdzh.utc_offset
			ORDER BY
				CASE 
					WHEN rang_zone_horaire % 2 = 0 THEN miny_pays
					ELSE -maxy_pays
				END,
				CASE 
					WHEN rang_zone_horaire % 2 = 0 THEN ST_Y(vdzh.geom)
					ELSE -ST_Y(vdzh.geom)
				END
			ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING
		)
)
;

Consolidation des destinations

Pour chaque destination, on va calculer plusieurs valeurs, notamment les valeurs des destinations suivantes.

Cette vue est une sorte de version améliorée de la table destination

CREATE OR REPLACE VIEW santa.v_destination_globale as (
	SELECT 
		vdo.id,
		vdo.pays,
		vdo.region,
		vdo.ville,
		-- Le fenêtrage permet de récupérer la prochaine destination
		lead (vdo.pays) OVER all_etape as pays_next,
		lead (vdo.region) OVER all_etape as region_next,
		lead (vdo.ville) OVER all_etape as ville_next,
		vdo.timezone,
		vdo.utc_offset,
		lead (vdo.utc_offset) OVER all_etape as utc_offset_next,
		vdo.duree_zone,
		(vdo.duree_zone / vdo.nb_dest_zone_horaire) AS duree_destination,
		vdo.population,
		vdo.pop_fictive,
		vdo.nb_dest_zone_horaire,
		vdo.geom,
		-- Le fenêtrage permet de récupérer la géométrie de la prochaine destination
		lead(vdo.geom) OVER all_etape as geom_next
	FROM 
		santa.v_destination_ordonnees as vdo
	CROSS JOIN 
		santa.v_variable as var
	WINDOW 
		all_etape AS (
			ORDER BY 
				vdo.id
		)
)
;

Étapes de distribution

A partir des données des destination, on génère des étapes de distribution des cadeaux.

Pendant ces étapes, le Père Noël s’arrête dans la destination et distribue les présents.

CREATE OR REPLACE VIEW santa.v_etape_distribution as (
	SELECT 
		concat_ws(
			'-',
			vdg.id,
			'd'
		) as id,
		'Distribution' as type_etape,
		vdg.pays,
		vdg.region,
		vdg.ville,
		-- S'il n'y a pas de prochaine destination, alors on retourne chez le Père Noël
		COALESCE(
			vdg.pays_next,
			var.maison_pere_noel ->> 'pays'
		) as pays_next,
		COALESCE(
			vdg.region_next,
			var.maison_pere_noel ->> 'region'
		) as region_next,
		COALESCE(
			vdg.ville_next,
			var.maison_pere_noel ->> 'ville'
		) as ville_next,
		vdg.timezone,
		vdg.utc_offset,
		vdg.utc_offset_next,
		vdg.duree_destination,
		vdg.duree_destination * (1 - (var.pourcent_temps_de_vol / 100)) AS duree_etape,
		vdg.population,
		vdg.pop_fictive,
		-- Nombre de cadeaux distribués
		round( var.cadeau_par_pop * vdg.pop_fictive::numeric * var.cadeau_par_pers, 0) AS cadeau_distribue,
		vdg.geom
	FROM 
		santa.v_destination_globale as vdg
	CROSS JOIN 
		santa.v_variable as var
)
;

Étapes de trajet

A partir des données des destination, on génère également des étapes de trajet.

Pendant ces étapes, le Père Noël vol à bord de son traineau de destination en destination.

CREATE OR REPLACE VIEW santa.v_etape_trajet as (
	SELECT 
		concat_ws(
			'-',
			vdg.id,
			't'
		) as id,
		'Trajet' as type_etape,
		vdg.pays,
		vdg.region,
		vdg.ville,
		vdg.pays_next,
		vdg.region_next,
		vdg.ville_next,
		vdg.timezone,
		vdg.utc_offset,
		vdg.utc_offset_next,
		vdg.duree_destination,
		-- La durée du vol dépend de la distance entre la destination et la suivante
		-- ainsi, la vitesse de vol est constante dans une zone horaire
		(
			vdg.duree_zone 
			* (var.pourcent_temps_de_vol / 100) 
			* (
				ST_Length(ST_MakeLine(vdg.geom, vdg.geom_next)) 
				/ sum(ST_Length(ST_MakeLine(vdg.geom, vdg.geom_next))) OVER zone_horaire
			)
		 ) as duree_etape,
		ST_Length(ST_MakeLine(vdg.geom, vdg.geom_next)) as distance,
		-- Trajet effectué
		ST_MakeLine(vdg.geom, vdg.geom_next) as geom
	FROM 
		santa.v_destination_globale as vdg
	CROSS JOIN 
		santa.v_variable as var
	WHERE 
		vdg.ville_next IS NOT NULL
	WINDOW 
		zone_horaire AS (
			PARTITION BY 
				vdg.utc_offset
		)
)
;

Étapes spécifiques au Père Noël

Le Père Noël possède des étapes spécifiques ne dépendant pas des destinations : lorsqu’il fabrique les cadeaux, lorsqu’il démarre sa tournée pour rejoindre la première destination…

Pour cela on effectue plusieurs requête que l’on va unir avec un UNION ALL.

CREATE OR REPLACE VIEW santa.v_etape_santa as (
	-- Préparatifs
	SELECT 
		'1' as id,
		'Préparatifs' as type_etape,
		var.maison_pere_noel ->> 'pays' as pays,
		var.maison_pere_noel ->> 'region' as region,
		var.maison_pere_noel ->> 'ville' as ville,
		vdg.pays as pays_next,
		vdg.region as region_next,
		vdg.ville as ville_next,
		var.maison_pere_noel ->> 'timezone' as timezone,
		(var.maison_pere_noel ->> 'utc_offset')::interval as utc_offset,
		vdg.utc_offset as utc_offset_next,
		-- Durée : 1er janvier au 25 décembre minuit dans le premier fuseau horaire moins 2 minutes de trajet pour rejoindre la première destination
		var.debut_distrib - (extract(year from now()) || '-01-01 00:00:00 +1')::timestamp with time zone - var.delai_dest_santa_house as duree_etape,
		-- Nombre de cadeaux fabriqués
		var.pop_total * var.cadeau_par_pop * var.cadeau_par_pers AS cadeau_fabrique,
		0 as distance,
		-- Emplacement
		ST_Transform(
			(var.maison_pere_noel ->> 'geom_4326')::geometry(point,4326),
			3857
		) as geom
	FROM 
		santa.v_destination_globale as vdg
	CROSS JOIN 
		santa.v_variable as var
	WHERE 
		-- Première étape
		vdg.id = '01-001'
	
	UNION ALL
	-- Voyage vers 1ère destination
	SELECT 
		'2' as id,
		'Démarrage tournée' as type_etape,
		var.maison_pere_noel ->> 'pays' as pays,
		var.maison_pere_noel ->> 'region' as region,
		var.maison_pere_noel ->> 'ville' as ville,
		vdg.pays as pays_next,
		vdg.region as region_next,
		vdg.ville as ville_next,
		var.maison_pere_noel ->> 'timezone' as timezone,
		(var.maison_pere_noel ->> 'utc_offset')::interval as utc_offset,
		vdg.utc_offset as utc_offset_next,
		var.delai_dest_santa_house as duree_etape,
		0 as cadeau_fabrique,
		ST_Length(ST_MakeLine(
			ST_Transform(
				(var.maison_pere_noel ->> 'geom_4326')::geometry(point,4326),
				3857
			),
			vdg.geom
		)) as distance,
		-- Trajet effectué
		ST_MakeLine(
			ST_Transform(
				(var.maison_pere_noel ->> 'geom_4326')::geometry(point,4326),
				3857
			),
			vdg.geom
		) as geom
	FROM 
		santa.v_destination_globale as vdg
	CROSS JOIN 
		santa.v_variable as var
	WHERE 
		-- Première étape
		vdg.id = '01-001'

	UNION ALL
	-- Voyage retour dernière destination
	SELECT 
		'4' as id,
		'Fin tournée' as type_etape,
		vdg.pays,
		vdg.region,
		vdg.ville,
		var.maison_pere_noel ->> 'pays' as pays_next,
		var.maison_pere_noel ->> 'region' as region_next,
		var.maison_pere_noel ->> 'ville' as ville_next,
		vdg.timezone,
		vdg.utc_offset,
		(var.maison_pere_noel ->> 'utc_offset')::interval as utc_offset_next,
		var.delai_dest_santa_house as duree_etape,
		0 as cadeau_fabrique,
		ST_Length(ST_MakeLine(
			vdg.geom,
			ST_Transform(
				(var.maison_pere_noel ->> 'geom_4326')::geometry(point,4326),
				3857
			)
		)) as distance,
		-- Trajet effectué
		ST_MakeLine(
			vdg.geom,
			ST_Transform(
				(var.maison_pere_noel ->> 'geom_4326')::geometry(point,4326),
				3857
			)
		) as geom
	FROM 
		santa.v_destination_globale as vdg
	CROSS JOIN 
		santa.v_variable as var
	WHERE 
		-- Dernière étape
		vdg.ville_next IS NULL

	UNION ALL
	-- Repos
	SELECT 
		'5' as id,
		'Repos' as type_etape,
		var.maison_pere_noel ->> 'pays' as pays,
		var.maison_pere_noel ->> 'region' as region,
		var.maison_pere_noel ->> 'ville' as ville,
		NULL as pays_next,
		NULL as region_next,
		NULL as ville_next,
		var.maison_pere_noel ->> 'timezone' as timezone,
		(var.maison_pere_noel ->> 'utc_offset')::interval as utc_offset,
		NULL::interval as utc_offset_next,
		-- Durée : 1er janvier au 25 décembre minuit dans le premier fuseau horaire moins 2 minutes de trajet pour rejoindre la première destination
		(extract(year from now()) || '-12-31 23:59:59 +1')::timestamp with time zone - (extract(year from now()) || '-12-25 01:00:00 -12')::timestamp with time zone - var.delai_dest_santa_house as duree_etape,
		0 AS cadeau_fabrique,
		0 as distance,
		-- Emplacement
		ST_Transform(
			(var.maison_pere_noel ->> 'geom_4326')::geometry(point,4326),
			3857
		) as geom
	FROM 
		santa.v_variable as var
)
;

Construction du voyage

Pour créer le voyage, les étapes suivantes sont associées (via des UNION ALL) :

  • Les étapes de distribution des cadeaux.
  • Les étapes de trajet entre destinations.
  • Les étapes spécifiques au Père Noël.

Les éléments sont ensuite réagencés selon l’ordre de passage afin de pouvoir calculer certains cumuls (population visitée, cadeaux distribués, distance parcourue…).

Les données du voyage sont fixées dans une vue matérialisée pour améliorer les performance lors de la consultation :

CREATE MATERIALIZED VIEW santa.vm_voyage as (
	WITH voyage_complet as (
		-- Etapes de distribution
		SELECT 
			concat_ws(
				'-',
				'3',
				id
			) as id_voyage,
			type_etape,
			pays,
			region,
			ville,
			pays_next,
			region_next,
			ville_next,
			timezone,
			utc_offset,
			utc_offset_next,
			duree_etape,
			population,
			pop_fictive,
			cadeau_distribue,
			0 as cadeau_fabrique,
			0 as distance,
			geom
		FROM 
			santa.v_etape_distribution
		
		UNION ALL 
		-- Etapes de trajet
		SELECT 
			concat_ws(
				'-',
				'3',
				id
			) as id_voyage,
			type_etape,
			pays,
			region,
			ville,
			pays_next,
			region_next,
			ville_next,
			timezone,
			utc_offset,
			utc_offset_next,
			duree_etape,
			0 as population,
			0 as pop_fictive,
			0 as cadeau_distribue,
			0 as cadeau_fabrique,
			distance,
			geom
		FROM 
			santa.v_etape_trajet
		
		UNION ALL 
		-- Etapes spécifiques au Père Noël
		SELECT
			id as id_voyage, 
			type_etape,
			pays,
			region,
			ville,
			pays_next,
			region_next,
			ville_next,
			timezone,
			utc_offset,
			utc_offset_next,
			duree_etape,
			0 as population,
			0 as pop_fictive,
			0 as cadeau_distribue,
			cadeau_fabrique,
			distance,
			geom
		FROM 
			santa.v_etape_santa
	)
	SELECT
		id_voyage, 
		type_etape,
		pays,
		region,
		ville,
		pays_next,
		region_next,
		ville_next,
		timezone,
		utc_offset,
		utc_offset_next,
		duree_etape,
		-- Calcul de l'horaire du début :
		-- 01/01 de l'année en cours + durée des étapes précédentes
		((extract(year from now()) || '-01-01 00:00:00 +1')::timestamp with time zone
		+ coalesce(sum(duree_etape) OVER all_etape, '0')) as horaire_debut,
		to_char(
			((extract(year from now()) || '-01-01 00:00:00 +1')::timestamp with time zone
			+ coalesce(sum(duree_etape) OVER all_etape, '0'))
			AT TIME ZONE utc_offset,
			'DD TMmon YYYY'
		) AS date_debut_local,
		to_char(
			((extract(year from now()) || '-01-01 00:00:00 +1')::timestamp with time zone
			+ coalesce(sum(duree_etape) OVER all_etape, '0'))
			AT TIME ZONE utc_offset,
			'HH24:MI:SS'
		) AS heure_debut_local,
		-- Calcul de l'horaire de fin :
		-- 01/01 de l'année en cours + durée des étapes précédentes + durée de l'étape
		((extract(year from now()) || '-01-01 00:00:00 +1')::timestamp with time zone
		+ coalesce(sum(duree_etape) OVER all_etape, '0')
		+ duree_etape) as horaire_fin,
		to_char(
			((extract(year from now()) || '-01-01 00:00:00 +1')::timestamp with time zone
			+ coalesce(sum(duree_etape) OVER all_etape, '0')
			+ duree_etape) 
			AT TIME ZONE utc_offset_next,
			'DD TMmon YYYY'
		) AS date_fin_local,
		to_char(
			((extract(year from now()) || '-01-01 00:00:00 +1')::timestamp with time zone
			+ coalesce(sum(duree_etape) OVER all_etape, '0')
			+ duree_etape) 
			AT TIME ZONE utc_offset_next,
			'HH24:MI:SS'
		) AS heure_fin_local,
		population,
		pop_fictive,
		coalesce(sum(pop_fictive) OVER all_etape, 0) as pop_fictive_cumul,
		cadeau_distribue,
		coalesce(sum(cadeau_distribue) OVER all_etape, 0) as cadeau_distribue_cumul,
		cadeau_fabrique,
		distance,
		coalesce(sum(distance) OVER all_etape, 0) as distance_cumul,
		(distance) / (EXTRACT(EPOCH FROM duree_etape)) as vitesse_ms,
		-- Vitesse en km/h
		trim(to_char(((distance) / (EXTRACT(EPOCH FROM duree_etape)))/3.6, '999 999 km/h')) AS vitesse,
		-- Azimuth du symbole sur la carte
		CASE 
			WHEN GeometryType(geom) = 'LINESTRING' THEN 
				Degrees(ST_Azimuth(ST_StartPoint(geom),ST_EndPoint(geom)))
			ELSE 0
		END as azimuth,
		-- Orientation du symbole sur la carte
		CASE 
			WHEN GeometryType(geom) = 'LINESTRING' AND Degrees(ST_Azimuth(ST_StartPoint(geom),ST_EndPoint(geom))) < 180 THEN 'Est' 
			WHEN GeometryType(geom) = 'LINESTRING' AND Degrees(ST_Azimuth(ST_StartPoint(geom),ST_EndPoint(geom))) > 180 THEN 'Ouest'
			ELSE '-'
		END as orientation,
		GeometryType(geom) as type_geom,
		geom
	FROM voyage_complet
	WINDOW 
		all_etape AS (
			ORDER BY 
				id_voyage
			ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING
		)
)
;

On oublie pas de calculer quelques index :

CREATE UNIQUE INDEX unique_idx_vm_voyage ON santa.vm_voyage USING BTREE (id_voyage);
CREATE UNIQUE INDEX horaire_debut_idx_vm_voyage ON santa.vm_voyage (horaire_debut);
CREATE UNIQUE INDEX horaire_fin_idx_vm_voyage ON santa.vm_voyage (horaire_fin);
CREATE INDEX geom_idx_vm_voyage ON santa.vm_voyage USING GIST (geom);

Nous avons maintenant un trajet qui ressemble a ceci :

Suivi en live

Avoir un trajet c’est bien mais connaitre toutes les infos sur le trajet à un « instant t », c’est mieux.

Pour cela, nous allons utiliser une vue se basant sur la vue matérialisée précédente et qui ne va extraire qu’une seule ligne correspondant à l’étape du voyage lors de l’instant présent. Nous allons également utiliser diverses astuces pour générer des paroles prononcées par le Père Noël ainsi que du HTML pour mettre en forme le rendu final.

On va utiliser une requête CTE pour calculer les valeurs dont nous avons besoin, puis pour générer le HTML.

CREATE OR REPLACE VIEW santa.v_suivi_santa_live as (
	WITH 
		info_suivi as (
			SELECT
				id_voyage, 
				type_etape,
				-- On génère des paroles du Père Noël.
				-- Le texte varie selon le moment de l'année et change toutes les 10 secondes
				jsonb_extract_path_text(
					parole_pere_noel,
					type_etape,
					FLOOR(EXTRACT(second from var.now) / 5)::text
				) as parole,
				-- Génération d'un titre
				CASE 
					WHEN type_etape = 'Préparatifs' THEN 'Le Père Noël prépare les cadeaux'
					WHEN type_etape = 'Démarrage tournée' THEN 'Le Père Noël est parti vers son premier lieu de distribution'
					WHEN type_etape = 'Trajet' THEN 'Le Père Noël voyage vers un nouveau lieu de distribution'
					WHEN type_etape = 'Distribution' THEN 'Le Père Noël est en cours de distribution'
					WHEN type_etape = 'Fin tournée' THEN 'Le Père Noël a fini sa tournée et rentre chez lui'
					WHEN type_etape = 'Repos' THEN 'Le Père Noël a terminé et prend un repos bien mérité'
				END AS titre,
				pays,
				region,
				ville,
				pays_next,
				region_next,
				ville_next,
				timezone,
				utc_offset,
				utc_offset_next,
				duree_etape::text,
				EXTRACT(EPOCH FROM duree_etape) as duree_etape_seconde,
				horaire_debut,
				horaire_fin,
				-- Timer pour connaitre le prochain départ
				concat(
					NULLIF(extract(day from (horaire_fin - var.now)), 0) || ' jours, ',
					NULLIF(extract(hour from (horaire_fin - var.now)), 0) || ' heures, ',
					NULLIF(extract(minute from (horaire_fin - var.now)), 0) || ' minutes et ',
					round(extract(second from (horaire_fin - var.now))::numeric, 0) || ' secondes'
				) AS timer,
				date_debut_local,
				heure_debut_local,
				date_fin_local,
				heure_fin_local,
				population,
				-- Variation de la population durant l'étape
				trim(to_char( 
					CASE 
						WHEN type_etape = 'Distribution' THEN 
							temps_passe_etape.pourcent * population
						ELSE 0
					END + pop_fictive_cumul,
					'999 999 999 999'
				)) AS pop_cumul,
				cadeau_distribue,
				-- Variation du nombre de cadeau distribués durant l'étape
				trim(to_char( 
					CASE 
						WHEN type_etape = 'Distribution' THEN 
							temps_passe_etape.pourcent * cadeau_distribue
						ELSE 0
					END + cadeau_distribue_cumul,
					'999 999 999 999'
				)) AS cadeau_distribue_cumul,
				trim(to_char(cadeau_fabrique, '999 999 999 999')) as cadeau_fabrique,
				-- Variation du nombre de cadeau fabriqué durant l'étape (uniquement lors de la fabrication)
				trim(to_char(
					round(
						temps_passe_etape.pourcent * cadeau_fabrique,
						0
					),
					'999 999 999 999'
				)) as cadeau_fabrique_cumul,
				-- Distance parcourue depuis le début
				-- il faut tenir compte des étapes de distribution qui sont des points
				trim(
					to_char(
						(
							(
								CASE 
									WHEN type_geom = 'LINESTRING' THEN 
										ST_Length(
											ST_LineSubstring(
												geom,
												0,
												temps_passe_etape.pourcent
											)
										)
									ELSE 0
								END +
								distance_cumul
							)
							/1000
						),
						'999 999 999 999 km'
					)
				) AS distance_cumul,
				vitesse_ms,
				vitesse,
				azimuth,
				orientation,
				CASE 
					WHEN type_geom = 'LINESTRING' THEN 
						-- Calcul de l'emplacement
						ST_LineInterpolatePoint(
							geom,
							temps_passe_etape.pourcent
						)
					ELSE geom
				END::geometry(point,3857) as geom
			FROM vm_voyage
			CROSS JOIN 
				santa.v_variable as var
			-- Calcul du pourcentage de temps passé depuis le début de l'étape 
			CROSS JOIN LATERAL (
				SELECT EXTRACT(EPOCH FROM (var.now - horaire_debut)) / EXTRACT(EPOCH FROM (horaire_fin - horaire_debut)) as pourcent
			) as temps_passe_etape
			-- On ne récupère que l'étape en cours 
			WHERE horaire_debut <= var.now
			AND horaire_fin > var.now
		)
	-- Génération du HTML final
	SELECT 
		*,
		CASE
			WHEN type_etape = 'Préparatifs' THEN
				concat(
					'<p class="titre">', titre, '</p>',
					'<div>',
						...
					'</div>'
				)
			WHEN type_etape IN ('Trajet', 'Démarrage tournée', 'Fin tournée') THEN 
				concat(
					'<p class="titre">', titre, '</p>',
					'<div>',
						...
					'</div>'
				)
			WHEN type_etape = 'Distribution' THEN 
				concat(
					'<p class="titre">', titre, '</p>',
					'<div>',
						...
					'</div>'
				)
			WHEN type_etape = 'Repos' THEN
				concat(
					'<p class="titre">', titre, '</p>',
					'<div>',
						...
					'</div>'
				)
		END ||
		'</div>' as informations
	FROM info_suivi
)
;

Voila un exemple de réponse :

Le Père Noël est en cours de distribution

Petit mot du Père Noël : Oh hisse !

Emplacement actuel : Anadyr (Russie)
Arrivé sur place le 24 déc. 2023 à 08:00:00
(24 déc. 2023 à 19:00:00 heure locale)

Prochaine destination : Majuro Atoll (Îles Marshall)
Départ dans 36 secondes
Le 24 déc. 2023 à 08:00:37
(24 déc. 2023 à 19:00:37 heure locale)

Cadeaux distribués : 7 222
Population visitée : 7 602
Distance parcourue : 18 108 km

L’état des lieux

Pour chaque destination, il est possible de voir en temps réel si le Père Noël est passé ou non. Pour cela nous allons nous appuyer sur la table des destinations ainsi que la vue matérialisée vm_voyage.

CREATE MATERIALIZED VIEW santa.vm_info_destination as (
	SELECT
		min(id_voyage) as id_voyage,
		pays,
		region,
		ville,
		timezone,
		utc_offset,
		max(utc_offset_next) as utc_offset_next,
		max(horaire_debut) as horaire_debut,
		to_char(max(horaire_debut) AT TIME ZONE utc_offset,'DD TMmon YYYY') AS date_debut_local,
		to_char(max(horaire_debut) AT TIME ZONE utc_offset,'HH24:MI:SS') AS heure_debut_local,
		min(horaire_fin) as horaire_fin,
		to_char(min(horaire_fin) AT TIME ZONE utc_offset_next,'DD TMmon YYYY') AS date_fin_local,
		to_char(min(horaire_fin) AT TIME ZONE utc_offset_next,'HH24:MI:SS') AS heure_fin_local,
		trim(to_char(population, '999 999 999 999')) AS population,
		geom::geometry(point,3857) as geom
	FROM vm_voyage
	WHERE type_geom = 'POINT'
	GROUP BY 
		pays,
		region,
		ville,
		timezone,
		utc_offset,
		utc_offset_next,
		population,
		geom
)
;

A partir de ces informations, il est possible de calculer différente informations en temps réelle :

CREATE OR REPLACE VIEW santa.v_suivi_destination_live as (
	SELECT
		id_voyage,
		pays,
		region,
		ville,
		timezone,
		utc_offset,
		utc_offset_next,
		horaire_debut,
		horaire_fin,
		concat(
			NULLIF(extract(day from (horaire_fin - var.now)), 0) || ' jours, ',
			NULLIF(extract(hour from (horaire_fin - var.now)), 0) || ' heures, ',
			NULLIF(extract(minute from (horaire_fin - var.now)), 0) || ' minutes et ',
			round(extract(second from (horaire_fin - var.now))::numeric, 0) || ' secondes'
		) AS timer,
		date_debut_local,
		heure_debut_local,
		date_fin_local,
		heure_fin_local,
		population,
		-- Génération d'un message d'information sur l'état de passage
		CASE 
			-- Pour la maison du Père Noël
			WHEN id_voyage = '1' THEN 
				CASE 
					WHEN var."now" < horaire_fin THEN 'Le Père Noël prépare les cadeaux'
					WHEN var."now" < horaire_debut THEN 'Le Père Noël est absent'
					ELSE 'Le Père Noël se repose'
				END
			-- Sinon on détermine si la visite est réalisée en fonction de l'heure de passage
			WHEN var."now" < horaire_debut  THEN 'Attend la visite du Père Noël'
			WHEN var."now" > horaire_fin  THEN 'A reçu la visite du Père Noël'
			ELSE 'Le Père Noël est ici !'
		END AS etat,
		-- Quelques détails supplémentaires
		CASE
			WHEN id_voyage = '1' THEN 
				CASE 
					WHEN var."now" < horaire_fin THEN '<p>Le Père Noël est ici, vous le trouverez dans sa remise</p>'
					WHEN var."now" < horaire_debut THEN '<p>Le Père Noël est en tournée</p>'
					ELSE '<p>Le Père Noël est ici, vous le trouverez dans son lit</p>'
				END
			WHEN var."now" > horaire_fin THEN
				'<p>Le Père Noël est déjà passé 😉</p>'
			WHEN var."now" < horaire_debut THEN
				'<p>Le Père Noël arrive dans ' || 
				concat(
					NULLIF(extract(day from (horaire_fin - var.now)), 0) || ' jours, ',
					NULLIF(extract(hour from (horaire_fin - var.now)), 0) || ' heures, ',
					NULLIF(extract(minute from (horaire_fin - var.now)), 0) || ' minutes et ',
					round(extract(second from (horaire_fin - var.now))::numeric, 0) || ' secondes'
				)
				|| '</p>'
			ELSE
				'<p>Le Père Noël est en cours de distribution vas dans ton lit !</p>'
		END as informations,
		geom::geometry(point,3857) as geom
	FROM vm_info_destination
	CROSS JOIN 
		santa.v_variable as var
)
;

Voila ! J’espère que ça vous a plu.

🎄 Joyeux Noël !! 🎄


Cet article vous a plu ?

N'hésitez pas à le partager, il interessera surement certains de vos contacts.

Les thèmes suivants contiennent des articles en lien avec celui-ci, allez faire un tour :

PostGISPostgreSQLSQL fenetragelateral joinwindow

50%