Version : 1.2 Date : 16 février 2026 Auteur : Équipe ReactOpenAirMap
- AirQualityMap
- ComparisonSidePanel
- HistoricalChart
- Activation du mode comparaison
- Ajout d'une station
- Changement de polluant
- AtmoRef Service
- AtmoMicro Service
- Différences entre les sources
- Requêtes HTTP complètes
- StationInfo
- ComparisonState
- Normalisation timestamps
- Normalisation unités
- Structure chartData
- Mode Scan
- connectNulls
- Limite de 10 stations
La fonctionnalité d'intercomparaison permet de comparer les mesures de plusieurs stations de qualité de l'air sur un même graphique temporel. Elle supporte la comparaison entre :
- Stations AtmoRef (stations de référence AtmoSud)
- Sites AtmoMicro (microcapteurs AtmoSud)
- NebuleAir (capteurs communautaires)
- Mixte : Comparaison entre ces sources (ex. AtmoRef + AtmoMicro + NebuleAir)
- Sélection multi-stations (maximum 10)
- Détection automatique des polluants communs
- Graphique temporel avec courbes superposées
- Gestion des résolutions temporelles différentes
- Contrôles de période et pas de temps
- Interface responsive avec redimensionnement
- Maximum 10 stations comparables simultanément
- Comparaison d'un seul polluant à la fois (celui disponible dans toutes les stations)
- Supporte AtmoRef, AtmoMicro et NebuleAir (capteurs communautaires)
┌─────────────────────────────────────────────────────────────┐
│ AirQualityMap │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ État de comparaison (ComparisonState) │ │
│ │ - isComparisonMode │ │
│ │ - comparedStations[] │ │
│ │ - comparisonData │ │
│ │ - selectedPollutant, timeRange, timeStep │ │
│ └──────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Handlers de comparaison │ │
│ │ - handleComparisonModeToggle() │ │
│ │ - handleAddStationToComparison() │ │
│ │ - handleRemoveStationFromComparison() │ │
│ │ - handleLoadComparisonData() │ │
│ └──────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ ComparisonSidePanel │ │
│ │ - Liste des stations │ │
│ │ - Sélecteur de polluant │ │
│ │ - Graphique (HistoricalChart) │ │
│ │ - Contrôles temporels │ │
│ └──────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│
▼
┌────────────────────────┐
│ HistoricalChart │
│ - Transformation │
│ - Normalisation │
│ - Rendu Recharts │
└────────────────────────┘
- React 18 : Composants fonctionnels avec hooks
- TypeScript : Typage complet
- Recharts : Bibliothèque de graphiques
- Tailwind CSS : Styling et responsive
Fichier : src/components/map/AirQualityMap.tsx
Responsabilités :
- Gestion de l'état global de comparaison
- Activation/désactivation du mode comparaison
- Ajout/suppression de stations
- Chargement des données historiques
- Coordination entre la carte et le panel
État géré : L’état de comparaison est exposé via sidePanels.comparisonState et mis à jour via sidePanels.setComparisonState (hook / état parent).
interface ComparisonState {
isComparisonMode: boolean; // Mode actif/inactif
comparedStations: StationInfo[]; // Stations sélectionnées (max 10)
comparisonData: Record<string, Record<string, HistoricalDataPoint[]>>;
selectedPollutant: string; // Polluant affiché
timeRange: TimeRange; // Période temporelle
timeStep: string; // Pas de temps (scan, 15min, 1h, 1j)
loading: boolean;
error: string | null;
}Fonctions clés :
// Activation du mode comparaison (ligne 714-724)
const handleComparisonModeToggle = () => {
setComparisonState((prev) => ({
...prev,
isComparisonMode: !prev.isComparisonMode,
comparedStations:
!prev.isComparisonMode && selectedStation
? [selectedStation] // Ajoute la station actuelle comme première
: prev.comparedStations,
}));
};
// Ajout d'une station à la comparaison (AirQualityMap.tsx, ~ligne 433)
// Comportement : si la station est déjà dans la liste, elle est retirée (toggle).
const handleAddStationToComparison = async (device: MeasurementDevice) => {
// 1. Si déjà ajoutée → retirer (désélection)
const isAlreadyAdded = comparisonState.comparedStations.some(
(station) => station.id === device.id
);
if (isAlreadyAdded) {
handleRemoveStationFromComparison(device.id);
return;
}
// 2. Vérifier limite (MAX_COMPARISON_STATIONS = 10)
if (comparisonState.comparedStations.length >= MAX_COMPARISON_STATIONS) return;
// 3. Récupérer les variables disponibles via API
let variables = {};
let sensorModel: string | undefined;
let lastSeenSec: number | undefined;
if (device.source === "atmoRef") {
variables = await atmoRefService.fetchStationVariables(device.id);
} else if (device.source === "atmoMicro") {
const siteInfo = await atmoMicroService.fetchSiteVariables(device.id);
variables = siteInfo.variables;
sensorModel = siteInfo.sensorModel;
} else if (device.source === "nebuleair") {
const siteInfo = await nebuleAirService.fetchSiteInfo(device.id);
variables = siteInfo.variables;
lastSeenSec = siteInfo.lastSeenSec;
}
// 4. Créer StationInfo et ajouter à l'état
const stationInfo: StationInfo = {
id: device.id,
name: device.name,
address: device.address || "",
departmentId: device.departmentId || "",
source: device.source,
variables,
...(sensorModel !== undefined && { sensorModel }),
...(lastSeenSec !== undefined && { lastSeenSec }),
};
setComparisonState((prev) => ({
...prev,
comparedStations: [...prev.comparedStations, stationInfo],
}));
};
// Chargement des données de comparaison
// Implémentation dans src/components/map/handlers/comparisonHandlers.ts
// (createLoadComparisonDataHandler). Supporte atmoRef, atmoMicro et nebuleair.
const handleLoadComparisonData = async (
stations: StationInfo[],
pollutant: string,
timeRange: TimeRange,
timeStep: string
) => {
setComparisonState((prev) => ({ ...prev, loading: true, error: null }));
try {
const { startDate, endDate } = getDateRange(timeRange);
const newComparisonData = {};
for (const station of stations) {
let stationData = [];
if (station.source === "atmoRef") {
stationData = await atmoRefService.fetchHistoricalData({ ... });
} else if (station.source === "atmoMicro") {
stationData = await atmoMicroService.fetchHistoricalData({ ... });
} else if (station.source === "nebuleair") {
stationData = await nebuleAirService.fetchHistoricalData({
sensorId: station.id,
pollutant,
timeStep,
startDate,
endDate,
});
}
if (!newComparisonData[pollutant]) newComparisonData[pollutant] = {};
newComparisonData[pollutant][station.id] = stationData;
}
setComparisonState((prev) => ({
...prev,
comparisonData: newComparisonData,
selectedPollutant: pollutant,
timeRange,
timeStep,
loading: false,
}));
} catch (error) {
setComparisonState((prev) => ({
...prev,
loading: false,
error: "Erreur lors du chargement des données",
}));
}
};Fichier : src/components/panels/ComparisonSidePanel.tsx
Responsabilités :
- Affichage de l'interface de comparaison
- Liste des stations comparées avec suppression
- Sélection du polluant commun
- Affichage du graphique
- Contrôles de période et pas de temps
Fonctions clés :
// Détection des polluants disponibles dans TOUTES les stations (ligne 54-81)
const isPollutantAvailableInAllStations = (pollutantCode: string): boolean => {
return comparisonState.comparedStations.every((station) => {
return Object.entries(station.variables || {}).some(([code, variable]) => {
let mappedCode = code;
// Mapping AtmoRef : codes numériques → codes normalisés
if (station.source === "atmoRef") {
const atmoRefMapping = {
"01": "so2",
"03": "no2",
"08": "o3",
"24": "pm10",
"39": "pm25",
"68": "pm1",
};
mappedCode = atmoRefMapping[code] || code;
}
// AtmoMicro : déjà normalisés ("pm25", "pm10", etc.)
return mappedCode === pollutantCode && variable.en_service;
});
});
};
// Obtenir la liste des polluants communs (ligne 89-98)
const getAvailablePollutants = (): string[] => {
if (comparisonState.comparedStations.length === 0) return [];
return Object.entries(pollutants)
.filter(([pollutantCode]) => {
return isPollutantAvailableInAllStations(pollutantCode);
})
.map(([pollutantCode]) => pollutantCode);
};Interface utilisateur :
- Header : Titre + nombre de stations + boutons de contrôle
- Stations sélectionnées : Liste avec bouton de suppression
- Sélecteur de polluant : Dropdown avec polluants communs uniquement
- Graphique : HistoricalChart en mode "comparison"
- Note mode Scan : Bandeau informatif (si timeStep === "instantane")
- Contrôles temporels : Période + Pas de temps
Fichier : src/components/charts/HistoricalChart.tsx
Responsabilités :
- Transformation des données pour Recharts
- Normalisation des timestamps
- Groupement par unité
- Rendu du graphique avec Recharts
Modes de fonctionnement :
// Props du composant
interface HistoricalChartProps {
data: Record<string, HistoricalDataPoint[]>;
selectedPollutants: string[];
source: string; // "comparison" | "atmoRef" | "atmoMicro" | ...
onHasCorrectedDataChange?: (hasCorrectedData: boolean) => void;
stations?: StationInfo[]; // Requis en mode "comparison"
}Transformation des données - Mode comparaison :
// Mode comparaison : données par station (ligne 114-173)
if (source === "comparison" && stations.length > 0) {
const allTimestamps = new Map<number, string>(); // Timestamps normalisés
const pollutant = selectedPollutants[0];
// 1. Collecter tous les timestamps uniques (en millisecondes)
stations.forEach((station) => {
if (data[station.id]) {
data[station.id].forEach((point) => {
const timestampMs = new Date(point.timestamp).getTime();
if (!allTimestamps.has(timestampMs)) {
allTimestamps.set(timestampMs, point.timestamp);
}
});
}
});
// 2. Trier les timestamps
const sortedTimestamps = Array.from(allTimestamps.entries()).sort(
(a, b) => a[0] - b[0]
);
// 3. Créer les points de données
const transformedData = sortedTimestamps.map(([timestampMs, _]) => {
const point: any = {
timestamp: new Date(timestampMs).toLocaleString("fr-FR", {...}),
rawTimestamp: timestampMs,
};
// Ajouter les valeurs pour chaque station
stations.forEach((station) => {
if (data[station.id]) {
const dataPoint = data[station.id].find(
(p) => new Date(p.timestamp).getTime() === timestampMs
);
if (dataPoint) {
point[station.id] = dataPoint.value; // Clé = ID station
point[`${station.id}_unit`] = dataPoint.unit;
}
}
});
return point;
});
return transformedData;
}Rendu du graphique :
// Une ligne par station en mode comparaison (ligne 420-444)
{source === "comparison" && stations.length > 0
? stations.map((station, index) => {
const pollutant = selectedPollutants[0];
const stationColor = fallbackColors[index % fallbackColors.length];
const pollutantName = pollutants[pollutant]?.name || pollutant;
return (
<Line
key={station.id}
type="monotone"
dataKey={station.id} // Clé = ID station
yAxisId="left"
stroke={stationColor} // Couleur unique par station
strokeWidth={2}
strokeDasharray="0"
dot={{ r: 3 }}
activeDot={{ r: 5 }}
name={`${station.name} - ${pollutantName}`}
connectNulls={true} // Important : relier malgré les gaps
/>
);
})
: /* Mode normal */
}Fichier : src/services/AtmoRefService.ts
Méthode clé pour la comparaison :
// Récupération des variables disponibles (ligne 234-260)
async fetchStationVariables(stationId: string): Promise<
Record<string, { label: string; code_iso: string; en_service: boolean }>
> {
const url = `https://api.atmosud.org/observations/stations?format=json&station_en_service=true&download=false&metadata=true`;
const response = await this.makeRequest(url);
const station = response.stations.find(
(s: AtmoRefStation) => s.id_station === stationId
);
return station.variables; // Format : { "01": {...}, "03": {...}, ... }
}Requête HTTP complète :
GET https://api.atmosud.org/observations/stations?format=json&station_en_service=true&download=false&metadata=true
Paramètres :
format=json: Format de réponse JSONstation_en_service=true: Uniquement les stations activesdownload=false: Pas de téléchargement de fichiermetadata=true: CRUCIAL - Inclure les métadonnées dont les variables
Attention : Sans metadata=true, le champ variables n'est pas retourné par l'API !**
Format de réponse API brut :
{
"stations": [
{
"id_station": "FR00008",
"nom_station": "Marseille Place Verneuil",
"departement_id": "13",
"adresse": "Place Henri Verneuil",
"latitude": 43.30885,
"longitude": 5.36788,
"en_service": true,
"date_debut_mesure": "2018-10-16T15:00:00+00:00",
"date_fin_mesure": null,
"variables": {
"01": {
"label": "SO2",
"code_iso": "01",
"date_fin": null,
"date_debut": "2019-03-08T12:00:00+00:00",
"en_service": true
},
"03": {
"label": "NO2",
"code_iso": "03",
"date_fin": null,
"date_debut": "2019-03-08T12:00:00+00:00",
"en_service": true
},
"24": {
"label": "PM10",
"code_iso": "24",
"date_fin": "2023-03-27T14:30:00+00:00",
"date_debut": "2018-10-16T15:00:00+00:00",
"en_service": false
},
"39": {
"label": "PM2.5",
"code_iso": "39",
"date_fin": null,
"date_debut": "2021-12-10T00:00:00+00:00",
"en_service": true
},
"68": {
"label": "PM1",
"code_iso": "68",
"date_fin": "2023-03-28T00:00:00+00:00",
"date_debut": "2021-04-28T00:00:00+00:00",
"en_service": false
}
}
}
]
}Format retourné par fetchStationVariables() :
{
"01": {
"label": "SO2",
"code_iso": "01",
"date_fin": null,
"date_debut": "2019-03-08T12:00:00+00:00",
"en_service": true
},
"03": {
"label": "NO2",
"code_iso": "03",
"date_fin": null,
"date_debut": "2019-03-08T12:00:00+00:00",
"en_service": true
},
"39": {
"label": "PM2.5",
"code_iso": "39",
"date_fin": null,
"date_debut": "2021-12-10T00:00:00+00:00",
"en_service": true
}
}Note importante : Le champ en_service dans les variables indique si cette mesure est actuellement active. Une variable avec date_fin non-null et en_service: false n'est plus mesurée.
Mapping des codes :
// Défini dans src/types/index.ts
export const ATMOREF_POLLUTANT_MAPPING: Record<string, string> = {
"01": "so2", // SO2
"03": "no2", // NO2
"08": "o3", // O3
"24": "pm10", // PM10
"39": "pm25", // PM2.5
"68": "pm1", // PM1
};Fichier : src/services/AtmoMicroService.ts
Méthode clé pour la comparaison : Retourne un objet { variables, sensorModel? }. L’appelant utilise siteInfo.variables pour construire la StationInfo.
// Récupération des variables disponibles (AtmoMicroService.ts)
async fetchSiteVariables(siteId: string): Promise<{
variables: Record<string, { label: string; code_iso: string; en_service: boolean }>;
sensorModel?: string;
}> {
const allSites = await this.getCachedAllSites();
const site = allSites.find((s) => s.id_site.toString() === siteId);
if (!site) return { variables: {} };
const variablesString = site.variables;
const availableVariables = {};
const variableList = variablesString.split(",").map((v) => v.trim());
for (const variable of variableList) {
const pollutantCode = ATMOMICRO_POLLUTANT_MAPPING[variable];
if (pollutantCode && pollutants[pollutantCode]) {
availableVariables[pollutantCode] = {
label: pollutants[pollutantCode].name,
code_iso: variable,
en_service: true,
};
}
}
const sensorModel = site.modele_capteur || undefined;
return { variables: availableVariables, sensorModel };
}Requête HTTP complète :
GET https://api.atmosud.org/observations/capteurs/sites?format=json&actifs=2880
Paramètres :
format=json: Format de réponse JSONactifs=2880: Sites actifs dans les dernières 2880 minutes (48 heures)
Note : Contrairement à AtmoRef, il n'y a pas de paramètre metadata. Le champ variables est toujours retourné.
Format de réponse API brut :
[
{
"id_site": 1175,
"nom_site": "Aix-en-Provence Centre",
"type_site": "Fixe",
"influence": "urbain",
"lon": 5.4454,
"lat": 43.5263,
"code_station_commun": "FR27001",
"date_debut_site": "2020-01-15T00:00:00",
"date_fin_site": "2099-12-31T23:59:59",
"alti_mer": 150,
"alti_sol": 3,
"id_campagne": 42,
"nom_campagne": "Réseau urbain Aix",
"id_capteur": 234,
"marque_capteur": "Sensirion",
"modele_capteur": "SPS30",
"variables": "PM10, PM2.5, Air Pres., Air Temp., Air Hum., PM1"
},
{
"id_site": 1232,
"nom_site": "Marseille Prado",
"variables": "PM10, PM2.5, NO2, O3"
}
]Important : Le champ variables dans l'API est une chaîne de caractères (string), PAS un objet JSON.
Exemple de réponse API réelle :
{
"id_site": 1175,
"nom_site": "Aix-en-Provence Centre",
"variables": "PM10, PM2.5, Air Pres., Air Temp., Air Hum., PM1" // ← STRING !
}Format retourné par fetchSiteVariables() (objet créé par notre code) :
{
"pm25": {
"label": "PM₂.₅",
"code_iso": "PM2.5",
"en_service": true
},
"pm10": {
"label": "PM₁₀",
"code_iso": "PM10",
"en_service": true
},
"pm1": {
"label": "PM₁",
"code_iso": "PM1",
"en_service": true
}
}Attention : Les champs label, code_iso et en_service sont créés par NOTRE code** (ligne 268-272 de AtmoMicroService.ts), ils ne viennent PAS directement de l'API.
Processus de transformation détaillé :
// 1. API retourne
site.variables = "PM10, PM2.5, Air Pres., Air Temp., Air Hum., PM1"
// 2. Split par virgule et trim
variableList = ["PM10", "PM2.5", "Air Pres.", "Air Temp.", "Air Hum.", "PM1"]
// 3. Pour chaque variable, mapper via ATMOMICRO_POLLUTANT_MAPPING
"PM2.5" → pollutantCode = "pm25"
"PM10" → pollutantCode = "pm10"
"PM1" → pollutantCode = "pm1"
"Air Pres." → pollutantCode = undefined (ignoré, pas un polluant)
// 4. Filtrage : Garder uniquement les variables qui sont des polluants
if (pollutantCode && pollutants[pollutantCode]) {
// Air Pres., Air Temp., Air Hum. sont ignorés
}
// 5. Construction de l'objet de retour (CRÉÉ par notre code)
availableVariables[pollutantCode] = {
label: pollutants[pollutantCode].name, // "PM₂.₅" depuis nos constantes
code_iso: variable, // "PM2.5" original de l'API
en_service: true // Supposé true si listé
};Résultat final : Objet compatible avec le format AtmoRef pour faciliter la comparaison.
Mapping des codes :
// Défini dans src/types/index.ts
export const ATMOMICRO_POLLUTANT_MAPPING: Record<string, string> = {
"PM2.5": "pm25",
PM10: "pm10",
PM1: "pm1",
NO2: "no2",
O3: "o3",
SO2: "so2",
};| Aspect | AtmoRef | AtmoMicro |
|---|---|---|
| Format variables API | Objet JSON structuré | Chaîne de caractères (string) |
| Codes dans l'API | Codes numériques ("01", "03", "24") |
Codes texte ("PM2.5", "PM10", "NO2") |
| Clés retournées | Codes bruts ("01", "39") |
Normalisées ("pm25", "pm10") |
| Transformation | Aucune (données brutes) | Oui (parsing + construction d'objet) |
| Mapping nécessaire | Oui ("01" → "so2") |
Non (déjà "pm25") |
| Champs API | label, code_iso, date_debut, date_fin, en_service |
Uniquement variables (string) |
| Champs créés par code | Aucun (tout vient de l'API) | label, code_iso, en_service (objet entier construit) |
| Variables non-polluants | Uniquement polluants | Inclut Air Pres., Air Temp., Air Hum. (filtrés par notre code) |
Différence clé à comprendre** :
AtmoRef :
- L'API retourne directement un objet structuré avec tous les champs
- Pas de transformation, on utilise
station.variablestel quel - Les clés sont les codes numériques de l'API (
"01","03","39")
AtmoMicro :
- L'API retourne seulement une string :
"PM10, PM2.5, Air Pres., ..." - Transformation complète : parsing + filtrage + construction d'objet
- Les clés sont normalisées par notre code (
"pm25","pm10","pm1") - Le champ
code_isostocke le texte original ("PM2.5") pour référence
Conséquence pour la comparaison :
- Dans
ComparisonSidePanel, les clés AtmoRef ("01") doivent être mappées vers"so2" - Les clés AtmoMicro (
"pm25") sont déjà normalisées, pas de mapping nécessaire
Pour clarifier ce qui vient directement de l'API et ce qui est créé/transformé par notre code :
| Champ | Source | Valeur exemple | Transformation |
|---|---|---|---|
| Clé de l'objet | API | "01", "03", "39" |
Aucune |
label |
API | "SO2", "NO2", "PM2.5" |
Aucune |
code_iso |
API | "01", "03", "39" |
Aucune |
date_debut |
API | "2019-03-08T12:00:00+00:00" |
Aucune |
date_fin |
API | null ou "2025-08-12..." |
Aucune |
en_service |
API | true ou false |
Aucune |
Résumé AtmoRef : return station.variables → Aucune transformation, données brutes de l'API.
| Champ | Source | Valeur exemple | Transformation |
|---|---|---|---|
| Clé de l'objet | Code | "pm25", "pm10", "pm1" |
Mapping "PM2.5" → "pm25" |
label |
Code | "PM₂.₅", "PM₁₀", "PM₁" |
Pris depuis pollutants[].name |
code_iso |
API | "PM2.5", "PM10", "PM1" |
String originale conservée |
en_service |
Code | true |
Toujours true (si présent = actif) |
Résumé AtmoMicro : L'API retourne variables: "PM10, PM2.5, ..." (string) → Transformation complète pour créer un objet compatible avec AtmoRef.
AtmoRef : L'API est bien structurée et retourne déjà un objet JSON avec tous les champs nécessaires.
AtmoMicro : L'API retourne seulement une liste de noms séparés par virgules → Notre code doit :
- Parser la string
- Filtrer les polluants (ignorer Air Pres., Air Temp., etc.)
- Normaliser les clés (
"PM2.5" → "pm25") - Construire un objet compatible avec le format AtmoRef
- Ajouter les champs manquants (
label,en_service)
Objectif final : Avoir le même format de variables pour les deux sources afin de faciliter le traitement unifié dans ComparisonSidePanel.
Question : Pourquoi créer label et code_iso pour AtmoMicro alors qu'ils ne sont jamais utilisés dans le code ?
Analyse de l'utilisation réelle :
| Champ | Créé pour AtmoMicro | Utilisé dans le code | Nécessité |
|---|---|---|---|
label |
Oui | JAMAIS | Non utilisé |
code_iso |
Oui | JAMAIS | Non utilisé |
en_service |
Oui | OUI (partout) | ESSENTIEL |
Seul en_service est réellement utilisé :
// Vérifié dans tous les side panels
return mappedCode === pollutantCode && variable.en_service;Pourquoi les créer quand même ?
Raison #1 - Interface TypeScript commune :
// Type unifié pour les deux sources
interface StationVariable {
label: string; // ← Requis par le type
code_iso: string; // ← Requis par le type
en_service: boolean;
}
// Sans ces champs, erreur TypeScript :
// Type '{ en_service: boolean }' is not assignable to type
// '{ label: string; code_iso: string; en_service: boolean }'Raison #2 - Cohérence et simplicité :
- Même structure pour AtmoRef et AtmoMicro
- Pas besoin de types conditionnels complexes
- Code plus simple et maintenable
Raison #3 - Évolutivité : Ces champs pourraient être utilisés à l'avenir pour :
- Afficher le label original de l'API dans un tooltip
- Debugging : Tracer la correspondance API ↔ Code normalisé
- Mapping inverse si besoin de requêtes API supplémentaires
Raison #4 - Performance négligeable : Créer 2 strings supplémentaires par polluant n'a aucun impact sur les performances (< 1ms).
Alternative envisagée mais rejetée :
// Option : Types différents par source
type AtmoRefVariable = { label: string; code_iso: string; en_service: boolean; ... };
type AtmoMicroVariable = { en_service: boolean }; // Minimal
// Problème : Complexifie le code avec des guards de type partout
if (station.source === "atmoRef" && "label" in variable) {
// ...
}Décision de design : Garder les champs inutilisés pour privilégier la simplicité et la cohérence du code, au détriment d'une micro-optimisation négligeable.
Requête HTTP (exemple pour PM2.5, 1h, dernières 24h) :
GET https://api.atmosud.org/observations/stations/mesures?format=json&station_id=FR24039&nom_polluant=pm2.5&temporalite=horaire&download=false&metadata=true&date_debut=2025-10-08T10:00:00Z&date_fin=2025-10-09T10:00:00Z
Paramètres :
station_id: ID de la station (ex:FR24039)nom_polluant: Nom du polluant (ex:pm2.5,pm10,no2)temporalite: Granularité (quart-horaire,horaire,journalière)date_debut/date_fin: Période au format ISO 8601
Réponse :
{
"mesures": [
{
"id_station": "FR24039",
"nom_station": "Marseille Place Verneuil",
"date_debut": "2025-10-09T07:00:00+00:00",
"valeur": 4.8,
"unite": "µg-m3",
"validation": "validée"
},
{
"id_station": "FR24039",
"date_debut": "2025-10-09T08:00:00+00:00",
"valeur": 4.7,
"unite": "µg-m3",
"validation": "validée"
}
]
}Requête HTTP (exemple pour PM2.5, brute, dernières 3h) :
GET https://api.atmosud.org/observations/capteurs/mesures?id_site=1175&format=json&download=false&nb_dec=1&valeur_brute=true&variable=pm2.5&type_capteur=true&aggregation=brute&debut=2025-10-09T07:00:00Z&fin=2025-10-09T10:00:00Z
Paramètres :
id_site: ID du site (ex:1175)variable: Variable mesurée (ex:pm2.5,pm10)aggregation: Type d'agrégation (brute,quart-horaire,horaire)valeur_brute=true: Inclure les valeurs brutesdebut/fin: Période au format ISO 8601
Réponse :
[
{
"id_site": 1175,
"nom_site": "Aix-en-Provence Centre",
"variable": "PM2.5",
"time": "2025-10-09T07:04:07Z",
"lat": 43.5263,
"lon": 5.4454,
"valeur": null,
"valeur_ref": null,
"valeur_brute": 2,
"unite": "µg/m3",
"marque_capteur": "Sensirion",
"modele_capteur": "SPS30"
},
{
"id_site": 1175,
"time": "2025-10-09T07:06:19Z",
"valeur": null,
"valeur_ref": null,
"valeur_brute": 2,
"unite": "µg/m3"
}
]Champs importants :
valeur: Valeur corrigée (peut êtrenull)valeur_ref: Valeur de référence corrigée (peut êtrenull)valeur_brute: Valeur brute du capteur (toujours présent)has_correction: Calculé par notre code (sivaleur !== valeur_brute)
export interface StationInfo {
id: string; // ID unique de la station
name: string; // Nom de la station
address: string; // Adresse complète
departmentId: string; // ID du département
source: string; // "atmoRef" | "atmoMicro" | "nebuleair"
variables: Record<string, StationVariable>; // Variables disponibles
sensorModel?: string; // Modèle du capteur (AtmoMicro)
lastSeenSec?: number; // Dernière émission en secondes (NebuleAir)
}
interface StationVariable {
label: string; // Nom complet du polluant
code_iso: string; // Code ISO original de l'API
en_service: boolean; // Si la variable est actuellement active
// Champs supplémentaires pour AtmoRef :
date_debut?: string; // Date de début de mesure (AtmoRef uniquement)
date_fin?: string | null; // Date de fin de mesure (AtmoRef uniquement)
}Exemples de variables selon la source :
AtmoRef (format retourné directement par l'API) :
{
"01": {
"label": "SO2",
"code_iso": "01",
"date_debut": "2019-03-08T12:00:00+00:00",
"date_fin": null,
"en_service": true
},
"03": {
"label": "NO2",
"code_iso": "03",
"date_debut": "2019-03-08T12:00:00+00:00",
"date_fin": null,
"en_service": true
},
"39": {
"label": "PM2.5",
"code_iso": "39",
"date_debut": "2021-12-10T00:00:00+00:00",
"date_fin": null,
"en_service": true
},
"24": {
"label": "PM10",
"code_iso": "24",
"date_debut": "2018-10-16T15:00:00+00:00",
"date_fin": "2023-03-27T14:30:00+00:00",
"en_service": false
}
}** Tous ces champs viennent directement de l'API** - Aucune transformation appliquée.
Code source (AtmoRefService.ts ligne 252) :
return station.variables; // Retourné tel quel depuis l'APIAtmoMicro (objet créé par fetchSiteVariables()) :
{
"pm25": {
"label": "PM₂.₅",
"code_iso": "PM2.5",
"en_service": true
},
"pm10": {
"label": "PM₁₀",
"code_iso": "PM10",
"en_service": true
}
}Important : Pour AtmoMicro, cet objet est entièrement construit par notre code :
label: Pris depuispollutants[pollutantCode].name(nos constantes)code_iso: Variable originale de l'API ("PM2.5","PM10") stockée pour référenceen_service: Toujourstruecar si la variable est dans la string, on suppose qu'elle est active
Code source (AtmoMicroService.ts ligne 268-272) :
availableVariables[pollutantCode] = {
label: pollutants[pollutantCode].name, // "PM₂.₅" depuis constants/pollutants.ts
code_iso: variable, // "PM2.5" original de l'API
en_service: true, // Supposé actif si présent dans la liste
};Objectif : Créer le même format que AtmoRef pour permettre un traitement unifié dans ComparisonSidePanel.
export interface ComparisonState {
isComparisonMode: boolean;
comparedStations: StationInfo[]; // Max 10 stations
comparisonData: Record<string, Record<string, HistoricalDataPoint[]>>;
selectedPollutant: string;
timeRange: TimeRange;
timeStep: string;
loading: boolean;
error: string | null;
}Structure détaillée de comparisonData :
// Exemple avec 2 stations comparant PM2.5
comparisonData = {
pm25: {
// Clé = polluant
FR24039: [
// Clé = ID station AtmoRef
{
timestamp: "2025-10-09T07:00:00+00:00",
value: 4.8,
unit: "µg-m3",
},
{
timestamp: "2025-10-09T07:15:00+00:00",
value: 4.8,
unit: "µg-m3",
},
// ... 13 points au total (tous les 15min)
],
"1175": [
// Clé = ID site AtmoMicro
{
timestamp: "2025-10-09T07:04:07Z",
value: 2,
unit: "µg/m3",
corrected_value: undefined,
raw_value: 2,
has_correction: false,
},
{
timestamp: "2025-10-09T07:06:19Z",
value: 2,
unit: "µg/m3",
corrected_value: undefined,
raw_value: 2,
has_correction: false,
},
// ... 81 points au total (tous les ~2min)
],
},
};Points clés :
- Niveau 1 : Polluant (
"pm25","pm10", etc.) - Niveau 2 : ID de station (
"FR24039","1175", etc.) - Niveau 3 : Array de points de données historiques
- Les timestamps sont différents entre les sources (résolutions variables)
// Dans AirQualityMap.tsx (ligne 101-112)
const [comparisonState, setComparisonState] = useState<ComparisonState>({
isComparisonMode: false,
comparedStations: [],
comparisonData: {},
selectedPollutant: selectedPollutant, // Hérite du polluant global
timeRange: {
type: "preset",
preset: "24h",
},
timeStep: "heure",
loading: false,
error: null,
});Challenge : Les deux sources utilisent des formats de timestamps différents :
- AtmoRef :
"2025-10-09T07:00:00+00:00"(timezone explicite +00:00) - AtmoMicro :
"2025-10-09T07:04:07Z"(format Zulu = UTC, équivalent à +00:00)
Problème critique : La comparaison stricte === échoue même pour le même instant :
// Ces deux timestamps représentent LE MÊME INSTANT (07:00:00 UTC)
const timestamp1 = "2025-10-09T07:00:00+00:00"; // AtmoRef
const timestamp2 = "2025-10-09T07:00:00Z"; // AtmoMicro
timestamp1 === timestamp2; // false ! (formats différents)
// Conséquence du bug : Les données ne sont jamais matchées
const dataPoint = data[station.id].find(
(p) => p.timestamp === timestamp // Jamais de match
);
// Résultat : dataPoint = undefined → Pas de valeur dans le point du graphiqueSolution : Normalisation via timestamps numériques (millisecondes depuis epoch Unix)
JavaScript fournit Date.getTime() qui convertit n'importe quel format ISO 8601 en nombre :
const date1 = new Date("2025-10-09T07:00:00+00:00");
const date2 = new Date("2025-10-09T07:00:00Z");
console.log(date1.getTime()); // 1759993200000
console.log(date2.getTime()); // 1759993200000 ← IDENTIQUE !
date1.getTime() === date2.getTime(); // true !Magie : getTime() retourne le nombre de millisecondes depuis le 1er janvier 1970 00:00:00 UTC (timestamp Unix), indépendamment du format de la string.
Étape 1 : Collection des timestamps normalisés
// Structure : Map<millisecondes, string originale>
const allTimestamps = new Map<number, string>();
stations.forEach((station) => {
if (data[station.id]) {
data[station.id].forEach((point) => {
// Convertir en millisecondes
const timestampMs = new Date(point.timestamp).getTime();
// Utiliser les millisecondes comme clé (détection automatique des doublons)
if (!allTimestamps.has(timestampMs)) {
allTimestamps.set(timestampMs, point.timestamp);
}
});
}
});
// Résultat : Map avec clés numériques uniques
// Map {
// 1759993200000 => "2025-10-09T07:00:00+00:00",
// 1759993447000 => "2025-10-09T07:04:07Z",
// 1759993579000 => "2025-10-09T07:06:19Z",
// ...
// }Étape 2 : Tri chronologique
// Convertir en array et trier numériquement
const sortedTimestamps = Array.from(allTimestamps.entries()).sort(
(a, b) => a[0] - b[0] // Tri sur les millisecondes (a[0])
);
// Résultat : Array trié chronologiquement
// [
// [1759993200000, "2025-10-09T07:00:00+00:00"],
// [1759993447000, "2025-10-09T07:04:07Z"],
// ...
// ]Étape 3 : Recherche des valeurs par comparaison numérique
// Pour chaque timestamp normalisé, chercher les valeurs de chaque station
const transformedData = sortedTimestamps.map(([timestampMs, originalTimestamp]) => {
const point: any = {
timestamp: new Date(timestampMs).toLocaleString("fr-FR", {...}),
rawTimestamp: timestampMs,
};
stations.forEach((station) => {
if (data[station.id]) {
// Comparaison numérique au lieu de strings
const dataPoint = data[station.id].find(
(p) => new Date(p.timestamp).getTime() === timestampMs
);
if (dataPoint) {
point[station.id] = dataPoint.value; // Valeur trouvée
point[`${station.id}_unit`] = dataPoint.unit;
}
// Sinon : point[station.id] reste undefined
}
});
return point;
});Recherche du timestamp 1759993200000 (07:00:00 UTC) :
// AtmoRef
const dataPoint = data["FR24039"].find(
p => new Date(p.timestamp).getTime() === 1759993200000
);
// new Date("2025-10-09T07:00:00+00:00").getTime() = 1759993200000
// MATCH ! Retourne { timestamp: "...", value: 4.8, unit: "µg-m3" }
// AtmoMicro
const dataPoint = data["1175"].find(
p => new Date(p.timestamp).getTime() === 1759993200000
);
// Aucun point à exactement 07:00:00, le premier est à 07:04:07
// new Date("2025-10-09T07:04:07Z").getTime() = 1759993447000
// 1759993447000 !== 1759993200000
// Retourne undefined
// Point du graphique généré
{
timestamp: "09 oct., 09:00",
rawTimestamp: 1759993200000,
FR24039: 4.8, // Valeur trouvée
1175: undefined // Pas de mesure à cet instant précis
}Compatible tous formats ISO 8601 :
"2025-10-09T07:00:00Z"(Zulu)"2025-10-09T07:00:00+00:00"(timezone explicite)"2025-10-09T07:00:00.000Z"(avec millisecondes)"2025-10-09T09:00:00+02:00"(autre timezone)
Fusion automatique : Si deux stations mesurent exactement au même instant (même milliseconde), un seul point est créé avec les deux valeurs.
Tri chronologique fiable : Tri numérique garanti, pas de problème d'ordre alphabétique.
Précision maximale : Aucune approximation, précision à la milliseconde.
Question : Et si on voulait fusionner les timestamps proches (ex: ±30 secondes) ?
// Exemple non implémenté : Tolérance de 30 secondes
const tolerance = 30000; // 30 secondes en ms
const dataPoint = data[station.id].find(
(p) => Math.abs(new Date(p.timestamp).getTime() - timestampMs) <= tolerance
);Décision actuelle : Tolérance zéro pour garantir la fidélité aux données réelles. Pas d'interpolation ou d'approximation.
Challenge : Formats d'unités différents entre sources :
- AtmoMicro :
"µg/m3" - AtmoRef :
"µg-m3"
Solution : Fonction encodeUnit() pour normaliser
const encodeUnit = (unit: string): string => {
const unitMap: Record<string, string> = {
"µg-m3": "µg/m³",
"µg-m³": "μg/m³",
"µg/m3": "µg/m³",
"µg/m³": "µg/m³",
"mg/m³": "mg/m³",
ppm: "ppm",
ppb: "ppb",
};
return unitMap[unit] || unit;
};// Mode comparaison spécifique (ligne 75-108)
const groupPollutantsByUnit = () => {
const unitGroups: Record<string, string[]> = {};
if (source === "comparison" && stations.length > 0) {
const pollutant = selectedPollutants[0];
// Trouver la première station avec des données
for (const station of stations) {
if (data[station.id] && data[station.id].length > 0) {
const unit = encodeUnit(data[station.id][0].unit);
if (!unitGroups[unit]) {
unitGroups[unit] = [];
}
if (!unitGroups[unit].includes(pollutant)) {
unitGroups[unit].push(pollutant);
}
break; // Une seule unité en mode comparaison
}
}
}
return unitGroups;
};Entrée (comparisonData) - Données reçues des APIs :
{
"pm25": { // Clé = polluant sélectionné
"FR24039": [ // Clé = ID station AtmoRef
{
timestamp: "2025-10-09T07:00:00+00:00",
value: 4.8,
unit: "µg-m3"
},
{
timestamp: "2025-10-09T07:15:00+00:00",
value: 4.8,
unit: "µg-m3"
},
{
timestamp: "2025-10-09T07:30:00+00:00",
value: 5.1,
unit: "µg-m3"
}
// ... 13 points au total (tous les 15min en mode Scan)
],
"1175": [ // Clé = ID site AtmoMicro
{
timestamp: "2025-10-09T07:04:07Z",
value: 2,
unit: "µg/m3",
corrected_value: undefined,
raw_value: 2,
has_correction: false
},
{
timestamp: "2025-10-09T07:06:19Z",
value: 2,
unit: "µg/m3",
corrected_value: undefined,
raw_value: 2,
has_correction: false
},
{
timestamp: "2025-10-09T07:08:31Z",
value: 2.2,
unit: "µg/m3",
corrected_value: undefined,
raw_value: 2.2,
has_correction: false
}
// ... 81 points au total (tous les ~2min en mode Scan)
]
}
}Sortie (chartData pour Recharts) - Après transformation :
[
{
timestamp: "09 oct., 09:00",
rawTimestamp: 1759993200000,
FR24039: 4.8,
FR24039_unit: "µg/m³",
1175: undefined // ← AtmoMicro n'a pas de donnée à ce timestamp exact
},
{
timestamp: "09 oct., 09:04",
rawTimestamp: 1759993447000,
FR24039: undefined, // ← AtmoRef n'a pas de donnée à ce timestamp exact
1175: 2,
1175_unit: "µg/m³"
},
{
timestamp: "09 oct., 09:06",
rawTimestamp: 1759993579000,
FR24039: undefined,
1175: 2,
1175_unit: "µg/m³"
},
{
timestamp: "09 oct., 09:08",
rawTimestamp: 1759993711000,
FR24039: undefined,
1175: 2.2,
1175_unit: "µg/m³"
},
// ... 94 points au total (union de tous les timestamps des 2 stations)
]Caractéristiques importantes :
- Timestamps uniques collectés : Le graphique collecte tous les timestamps uniques des deux sources (94 points = 13 + 81)
- Valeurs par station : Chaque point n'a généralement qu'une seule station avec une valeur (l'autre est
undefined) - Normalisation : Les timestamps sont convertis en millisecondes pour la comparaison
- Unités normalisées :
"µg-m3"et"µg/m3"→"µg/m³" connectNulls={true}: Essentiel pour relier les points malgré lesundefined
En mode "Scan" (pas de temps le plus fin), les sources ont des résolutions différentes :
- AtmoRef :
"quart-horaire"= 15 minutes fixes - AtmoMicro :
"brute"= Variable (1min, 2min, ou 5min selon le capteur)
// AtmoRefService.ts (ligne 169)
instantane: { temporalite: "quart-horaire", delais: 181 }
// AtmoMicroService.ts (ligne 226)
instantane: { aggregation: "brute", delais: 181 }Collection des timestamps (exemple réel basé sur vos logs) :
// Map<number, string> avec 94 timestamps uniques au total
allTimestamps = [
[1759993200000, "2025-10-09T07:00:00+00:00"], // AtmoRef
[1759993447000, "2025-10-09T07:04:07Z"], // AtmoMicro seul
[1759993579000, "2025-10-09T07:06:19Z"], // AtmoMicro seul
[1759993711000, "2025-10-09T07:08:31Z"], // AtmoMicro seul
[1759993842000, "2025-10-09T07:10:42Z"], // AtmoMicro seul
[1759993974000, "2025-10-09T07:12:54Z"], // AtmoMicro seul
[1759994107000, "2025-10-09T07:15:07Z"], // AtmoMicro proche de 07:15
[1759994100000, "2025-10-09T07:15:00+00:00"], // AtmoRef
// ... 94 points au total (13 AtmoRef + 81 AtmoMicro)
];Rendu visuel :
- AtmoMicro (1175) : Ligne continue et dense avec 81 points (mesure tous les ~2min)
- AtmoRef (FR24039) : Ligne continue mais espacée avec 13 points (mesure tous les 15min)
- Les deux lignes se superposent sur le même graphique avec des couleurs différentes
Note informative : Un bandeau bleu s'affiche pour expliquer cette différence :
{
comparisonState.timeStep === "instantane" && (
<div className="mb-3 sm:mb-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
<p className="text-sm text-blue-800 font-medium">
Mode Scan - Résolutions temporelles variables
</p>
<p className="text-xs text-blue-700 mt-1">
En mode Scan, chaque source affiche sa résolution réelle : AtmoRef
mesure toutes les 15 minutes, AtmoMicro entre 1 et 5 minutes selon le
capteur. Les différences de densité des points sont normales.
</p>
</div>
);
}Valeur : true en mode comparaison
Raison : Les timestamps des deux sources ne correspondent jamais exactement, donc chaque point n'a généralement qu'une station avec une valeur. Sans connectNulls={true}, les lignes ne se traceraient pas.
Exemple :
// Point 1: AtmoRef a une valeur, AtmoMicro = undefined
{ timestamp: "09:00", FR24039: 4.8, 1175: undefined }
// Point 2: AtmoMicro a une valeur, AtmoRef = undefined
{ timestamp: "09:04", FR24039: undefined, 1175: 2 }
// Sans connectNulls=true : Pas de ligne tracée (points isolés)
// Avec connectNulls=true : Ligne continue pour chaque stationRaison : Performance et lisibilité
La limite est définie par la constante MAX_COMPARISON_STATIONS (fichier src/constants/comparison.ts). Valeur par défaut : 10.
// Vérification dans AirQualityMap.tsx (handleAddStationToComparison)
if (sidePanels.comparisonState.comparedStations.length >= MAX_COMPARISON_STATIONS) {
console.warn(`Maximum ${MAX_COMPARISON_STATIONS} stations autorisées en comparaison`);
return;
}Impact :
- Au-delà de 10 stations, le graphique peut devenir moins lisible
- Dix couleurs distinctes sont prévues dans
fallbackColors(historicalChartUtils.ts) - Performance du navigateur à prendre en compte au-delà de 10 stations
Date : 9 octobre 2025
Fichier : ComparisonSidePanel.tsx
Problème :
- AtmoMicro retourne des clés déjà normalisées (
"pm25","pm10") - Le code essayait de mapper
"PM2.5" → "pm25"sur des clés déjà normalisées - Résultat : Aucun polluant commun détecté
Solution :
- Suppression du mapping redondant pour AtmoMicro
- Seul AtmoRef nécessite un mapping (
"01" → "so2", etc.)
Code corrigé :
if (station.source === "atmoRef") {
const atmoRefMapping = { "01": "so2", "03": "no2", ... };
mappedCode = atmoRefMapping[code] || code;
}
// Pour AtmoMicro : pas de mapping, les clés sont déjà normaliséesDate : 9 octobre 2025
Fichier : HistoricalChart.tsx
Problème :
groupPollutantsByUnit()cherchaitdata[pollutant]en mode comparaison- En mode comparaison, les clés sont les IDs des stations (
data[stationId]) - Résultat :
unitGroups = {},unitKeys = [], graphique vide
Solution :
- Ajout d'une branche spécifique pour le mode comparaison
- Utiliser
data[station.id]au lieu dedata[pollutant]
Code corrigé :
if (source === "comparison" && stations.length > 0) {
const pollutant = selectedPollutants[0];
for (const station of stations) {
if (data[station.id] && data[station.id].length > 0) {
const unit = encodeUnit(data[station.id][0].unit);
// ...
break;
}
}
}Date : 9 octobre 2025
Fichier : HistoricalChart.tsx
Problème :
- Formats de timestamps différents :
'...Z'vs'...+00:00' - Comparaison stricte
===échouait toujours - Résultat : Timestamps jamais matchés, pas de valeurs dans les points
Solution :
- Normalisation via timestamps numériques (millisecondes)
- Comparaison avec
getTime()au lieu de strings
Code corrigé :
// Avant
allTimestamps.add(point.timestamp); // Set<string>
const dataPoint = data[station.id].find((p) => p.timestamp === timestamp);
// Après
const timestampMs = new Date(point.timestamp).getTime();
allTimestamps.set(timestampMs, point.timestamp); // Map<number, string>
const dataPoint = data[station.id].find(
(p) => new Date(p.timestamp).getTime() === timestampMs
);Date : 9 octobre 2025
Fichier : HistoricalChart.tsx
Problème :
connectNulls={false}par défaut- Chaque point n'a qu'une station avec une valeur (l'autre =
undefined) - Recharts ne reliait pas les points
Solution :
- Changement de
connectNulls={false}enconnectNulls={true} - Permet à Recharts de relier les points malgré les gaps
Code corrigé :
<Line
key={station.id}
dataKey={station.id}
connectNulls={true} // ← Modification critique
// ...
/>Pour ajouter une nouvelle source (ex: NebuleAir) à la comparaison :
- Vérifier le format des variables :
// Le service doit retourner un format cohérent
async fetchSiteVariables(siteId: string): Promise<
Record<string, { label: string; code_iso: string; en_service: boolean }>
>- Ajouter le mapping si nécessaire :
// Dans ComparisonSidePanel.tsx
else if (station.source === "nebuleair") {
const nebuleAirMapping = { ... };
mappedCode = nebuleAirMapping[code] || code;
}- Ajouter le support dans AirQualityMap :
// Dans handleAddStationToComparison
else if (device.source === "nebuleair") {
const nebuleAirService = new NebuleAirService();
variables = await nebuleAirService.fetchSiteVariables(device.id);
}
// Dans handleLoadComparisonData
else if (station.source === "nebuleair") {
stationData = await nebuleAirService.fetchHistoricalData({...});
}- Mettre à jour les labels :
// Dans ComparisonSidePanel.tsx
{
station.source === "atmoRef"
? "Station de référence"
: station.source === "atmoMicro"
? "Microcapteur"
: "Capteur communautaire";
}Changer le nombre maximum de stations :
Modifier la constante dans src/constants/comparison.ts :
/** Nombre maximum de capteurs/stations pouvant être comparés simultanément */
export const MAX_COMPARISON_STATIONS = 10; // ← Modifier iciLa vérification dans AirQualityMap.tsx utilise cette constante. Aucune modification supplémentaire n’est nécessaire dans le composant.
Attention : Si vous augmentez au-delà de 10, prévoir :
- Plus de couleurs dans
fallbackColors(historicalChartUtils.ts) - Tests de performance
- Ajustements UX (lisibilité)
// historicalChartUtils.ts - fallbackColors
export const fallbackColors = [
"#3B82F6", // Bleu
"#EF4444", // Rouge
"#10B981", // Vert
"#F59E0B", // Orange
"#8B5CF6", // Violet
"#EC4899", // Rose
"#06B6D4", // Cyan
"#84CC16", // Lime
"#F97316", // Orange vif
"#6366F1", // Indigo
// Ajouter plus de couleurs si MAX_COMPARISON_STATIONS > 10
];- Vérifier le support dans les APIs :
// AtmoRefService.ts
const timeStepConfigs = {
// ... existants
"nouveau": { temporalite: "...", delais: ... }
};- Ajouter dans ComparisonSidePanel :
{[
{ key: "instantane", label: "Scan" },
// ...
{ key: "nouveau", label: "Nouveau" },
].map(({ key, label }) => (...))}Après toute modification, tester :
- Comparaison AtmoRef + AtmoRef (même source)
- Comparaison AtmoMicro + AtmoMicro (même source)
- Comparaison AtmoRef + AtmoMicro (sources mixtes)
- Mode Scan (résolutions différentes)
- 10 stations maximum (limite, constante
MAX_COMPARISON_STATIONS) - Changement de polluant (rechargement)
- Changement de période (rechargement)
- Suppression de station (mise à jour graphique)
Logs utiles déjà présents :
// Données reçues
console.log(" [HistoricalChart] Props reçues:", { data, selectedPollutants });
// Données transformées
console.log("[HistoricalChart] Données transformées:", {
chartDataLength,
unitGroups,
unitKeys,
chartData: chartData.slice(0, 3),
});
// Mode comparaison spécifique
console.log(" [HistoricalChart] Mode comparaison - Analyse:", {
totalPoints,
stations: stations.map((s) => s.id),
sampleData: chartData.slice(0, 5),
stationDataCount,
});Ajouter des logs si nécessaire :
- Vérifier les timestamps normalisés
- Vérifier les valeurs des stations
- Tracer le chargement des données
FR24039 (AtmoRef - Station de référence) :
- 13 points de mesure (tous les 15 minutes)
- Format :
"2025-10-09T07:00:00+00:00" - Unité :
"µg-m3"
1175 (AtmoMicro - Microcapteur) :
- 81 points de mesure (tous les ~2 minutes)
- Format :
"2025-10-09T07:04:07Z" - Unité :
"µg/m3"
Normalisation timestamps :
"2025-10-09T07:00:00+00:00" → 1759993200000 (ms)
"2025-10-09T07:04:07Z" → 1759993447000 (ms)
Union des timestamps :
94 timestamps uniques (13 + 81)
Normalisation unités :
"µg-m3" → "µg/m³"
"µg/m3" → "µg/m³"
Structure chartData :
Point 1: { timestamp: "09 oct., 09:00", FR24039: 4.8, 1175: undefined }
Point 2: { timestamp: "09 oct., 09:04", FR24039: undefined, 1175: 2 }
Point 3: { timestamp: "09 oct., 09:06", FR24039: undefined, 1175: 2 }
...
Point 94: { timestamp: "...", FR24039: ..., 1175: ... }
Ligne 1 (Bleue) - "Marseille Place Verneuil - PM₂.₅" :
• 13 points reliés par connectNulls={true}
• Points espacés régulièrement (15min)
Ligne 2 (Rouge) - "Aix-en-Provence Centre - PM₂.₅" :
• 81 points reliés par connectNulls={true}
• Points rapprochés (2min)
Axe X : Timestamps formatés ("09 oct., 09:00", etc.)
Axe Y : "Concentration (µg/m³)"
Légende : Noms des stations + polluant
┌─────────────────────────────────────────────────────────────┐
│ Concentration (µg/m³) │
│ 6 │ │
│ │ ● │
│ 5 │ ●─────●─────● │
│ │ ●────●────● │
│ 4 │ ○○○○○○○○○○○○○○○○○○○○ │
│ │ ○○○○○○ ○○○○○○○○ │
│ 3 │ ○○○ ○○○○○○ │
│ │ │
│ 2 │ │
│ 0 └────────────────────────────────────────────────────── │
│ 07:00 07:15 07:30 07:45 08:00 08:15 08:30 ... │
│ │
│ Légende : │
│ ● Ligne bleue : Marseille Place Verneuil - PM₂.₅ │
│ ○ Ligne rouge : Aix-en-Provence Centre - PM₂.₅ │
└─────────────────────────────────────────────────────────────┘
Observation :
- La ligne bleue (AtmoRef) a moins de points mais ils sont espacés régulièrement
- La ligne rouge (AtmoMicro) est dense avec beaucoup de points
- Les deux lignes se superposent visuellement pour la comparaison
Activation mode comparaison
↓
Clic sur marqueur (en mode comparaison)
↓
Vérifications (limite, doublons)
↓
Récupération variables via API
↓
Création StationInfo
↓
Ajout à comparedStations
↓
Calcul polluants communs
↓
Chargement données historiques (si polluant change)
↓
Transformation des données
↓
Normalisation timestamps (ms)
↓
Création chartData
↓
Groupement par unité
↓
Rendu Recharts
↓
Affichage graphique
Symptôme : Le dropdown du polluant est vide ou grisé.
Causes possibles :
- Aucun polluant commun entre les stations sélectionnées
- Exemple : Station A mesure SO2, NO2, O3 / Station B mesure PM10, PM2.5
- Solution : Sélectionner des stations qui mesurent au moins un polluant commun
- Toutes les variables sont
en_service: false
- Vérifier dans les logs :
[HistoricalChart] Props reçues - Solution : Choisir des stations avec des mesures actives
Vérification : Console logs
// Développer station.variables dans les logs
variables: {
"39": { label: "PM2.5", en_service: false } // Pas actif
}Symptôme : [HistoricalChart] Aucune donnée disponible pour le graphique
Causes possibles :
unitGroupsvide → Bug #2 (voir section Bugs corrigés)- Aucune donnée historique pour la période sélectionnée
- Erreur API lors du chargement
Vérification : Console logs
[HistoricalChart] Données transformées: {
chartDataLength: 0, // Problème
unitGroups: {}, // Problème
unitKeys: []
}Solutions :
- Vérifier les logs d'erreur réseau (onglet Network)
- Essayer une autre période temporelle
- Vérifier que les APIs sont accessibles
Symptôme : Points isolés sur le graphique, pas de lignes continues.
Cause : connectNulls={false} (bug #4)
Solution : Vérifier que connectNulls={true} dans HistoricalChart.tsx ligne 442
<Line
dataKey={station.id}
connectNulls={true} // DOIT être true en mode comparaison
/>Symptôme : stations: Array(2) mais une seule courbe visible.
Causes possibles :
- Timestamps ne correspondent pas → Bug #3
- Toutes les valeurs sont
undefinedpour une station
Vérification : Console logs
[HistoricalChart] Mode comparaison - Analyse des données: {
stationDataCount: {
"FR24039": 13, // OK
"1175": 0 // Problème : aucun point avec valeur
}
}Solutions :
- Vérifier que les timestamps sont bien normalisés en millisecondes
- Vérifier que les données sont bien chargées dans
comparisonData
Symptôme : Impossible d'ajouter une 11ème station.
Cause : Limite définie par MAX_COMPARISON_STATIONS (10 par défaut) pour la performance et la lisibilité.
Solution : Si vous devez vraiment comparer plus de 10 stations :
- Modifier la constante dans
src/constants/comparison.ts - Ajouter plus de couleurs dans
fallbackColors(historicalChartUtils.ts) - Tester la performance avec le nombre souhaité
À considérer : Au-delà de 10, le graphique peut devenir difficile à lire.
Symptôme : Impossible d'ajouter une station qui semble nouvelle.
Cause : La station est déjà dans comparedStations.
Solution : Vérifier la liste des stations sélectionnées dans le panel, supprimer la station si nécessaire, puis la rajouter.
Symptôme : Une station affiche "µg-m3" au lieu de "µg/m³".
Cause : encodeUnit() ne reconnaît pas le format.
Solution : Ajouter le format dans la fonction encodeUnit() :
const unitMap = {
"µg-m3": "µg/m³",
"nouveau-format": "µg/m³", // ← Ajouter ici
// ...
};- Recharts Documentation : https://recharts.org/
- API AtmoSud : https://api.atmosud.org/doc/
- TypeScript : https://www.typescriptlang.org/
- React Hooks : https://react.dev/reference/react
- README.md : Documentation utilisateur et installation
- DOCUMENTATION_TECHNIQUE.md : Architecture globale de l'application
- DOCUMENTATION_USE_AIR_QUALITY_DATA.md : Hook useAirQualityData
- Tests E2E :
docs/test/
src/components/map/AirQualityMap.tsx: Orchestrateur principalsrc/components/map/handlers/comparisonHandlers.ts:createLoadComparisonDataHandler, chargement des données (atmoRef, atmoMicro, nebuleair)src/components/panels/ComparisonSidePanel.tsx: Interface de comparaisonsrc/components/charts/: HistoricalChart et utilitaires (visualisation graphique)src/constants/comparison.ts: ConstanteMAX_COMPARISON_STATIONS(limite de capteurs)src/components/charts/utils/historicalChartUtils.ts: Couleurs de fallback pour les courbessrc/services/AtmoRefService.ts: Service AtmoRefsrc/services/AtmoMicroService.ts: Service AtmoMicrosrc/services/NebuleAirService.ts: Service NebuleAir (comparaison)src/types/index.ts: Types TypeScript et mappings
Fin de la documentation technique
Dernière mise à jour : 16 février 2026 Version : 1.2 - Support NebuleAir en comparaison ; toggle ajout/retrait station ; fetchSiteVariables AtmoMicro retourne { variables, sensorModel } ; état comparaison via sidePanels