Files
2026-06-19 07:18:00 +02:00

256 lines
16 KiB
JavaScript

'use strict';
const axios = require('axios');
const { getSolarElevationForDay, getTideForDay } = require('./astronomy');
const QC_URL = 'https://quickchart.io/chart';
const BG = 'rgb(20,20,32)';
const GRID = 'rgba(255,255,255,0.07)';
const TICK = '#BBBBCC';
const TITLE = '#FFFFFF';
const LEG = '#CCCCDD';
// ─── Heure courante Paris → préfixe "YYYY-MM-DDTHH" ─────────────────────────
function nowParisPrefix() {
return new Date()
.toLocaleString('sv', { timeZone: 'Europe/Paris' })
.substring(0, 13)
.replace(' ', 'T');
}
// ─── Fenêtre de N heures à partir de maintenant ──────────────────────────────
function getWindow(hourlyTimes, count = 25) {
const prefix = nowParisPrefix();
let start = 0;
for (let i = 0; i < hourlyTimes.length; i++) {
if (hourlyTimes[i].startsWith(prefix)) { start = i; break; }
if (hourlyTimes[i] < prefix) start = i;
}
return { start, end: Math.min(start + count, hourlyTimes.length), startIdx: start };
}
// ─── Heure depuis "YYYY-MM-DDTHH:MM" ────────────────────────────────────────
const hl = t => t.substring(11, 16);
// ─── POST QuickChart (config JS string = supporte les callbacks) ──────────────
async function postChart(chartJsStr) {
const res = await axios.post(QC_URL, {
chart: chartJsStr,
width: 760, height: 295,
backgroundColor: BG,
format: 'png', version: '3',
}, { responseType: 'arraybuffer', timeout: 20000 });
return Buffer.from(res.data);
}
// ─── Options communes ─────────────────────────────────────────────────────────
function opts({ title, yUnit = '', y1 = null, yMin = null, yMax = null }) {
const yMinStr = yMin !== null ? `,min:${yMin}` : '';
const yMaxStr = yMax !== null ? `,max:${yMax}` : '';
const y1block = y1
? `,y1:{position:'right',min:0,max:100,grid:{drawOnChartArea:false},ticks:{color:'${y1.color}',callback:function(v){return v+'${y1.unit}';}}}`
: '';
return `{
plugins:{
title:{display:true,text:'${title}',color:'${TITLE}',font:{size:14,weight:'bold'}},
legend:{labels:{color:'${LEG}',font:{size:11}}}
},
scales:{
x:{ticks:{color:'${TICK}',maxRotation:45,font:{size:10}},grid:{color:'${GRID}'}},
y:{ticks:{color:'${TICK}',callback:function(v){return v+'${yUnit}';}},grid:{color:'${GRID}'},position:'left'${yMinStr}${yMaxStr}}
${y1block}
}
}`;
}
// ═══════════════════════════════════════════════════════════════════════════════
// 1. TEMPÉRATURES
// ═══════════════════════════════════════════════════════════════════════════════
async function generateTemperatureChart(hourly) {
const { start, end } = getWindow(hourly.time);
const labels = JSON.stringify(hourly.time.slice(start, end).map(hl));
const temps = JSON.stringify(hourly.temperature_2m.slice(start, end).map(v => v?.toFixed(1) ?? null));
const feels = JSON.stringify(hourly.apparent_temperature.slice(start, end).map(v => v?.toFixed(1) ?? null));
return postChart(`{type:'line',data:{labels:${labels},datasets:[
{label:'Température',data:${temps},borderColor:'rgb(255,107,53)',backgroundColor:'rgba(255,107,53,0.18)',fill:true,tension:0.4,pointRadius:3,borderWidth:2.5},
{label:'Ressenti',data:${feels},borderColor:'rgb(255,200,70)',backgroundColor:'rgba(255,200,70,0.05)',fill:false,tension:0.4,pointRadius:2,borderWidth:2,borderDash:[5,4]}
]},options:${opts({ title: '🌡️ Températures sur 24h', yUnit: '°C' })}}`);
}
// ═══════════════════════════════════════════════════════════════════════════════
// 2. VENT (noeuds + km/h en tooltip)
// ═══════════════════════════════════════════════════════════════════════════════
async function generateWindChart(hourly) {
const { start, end } = getWindow(hourly.time);
const labels = JSON.stringify(hourly.time.slice(start, end).map(hl));
// Convertir en noeuds pour l'axe Y, garder km/h en label dataset
const windKt = JSON.stringify(hourly.wind_speed_10m.slice(start, end).map(v => v != null ? +(v / 1.852).toFixed(1) : null));
const gustsKt = JSON.stringify(hourly.wind_gusts_10m.slice(start, end).map(v => v != null ? +(v / 1.852).toFixed(1) : null));
return postChart(`{type:'line',data:{labels:${labels},datasets:[
{label:'Vent moy. (kt)',data:${windKt},borderColor:'rgb(0,191,255)',backgroundColor:'rgba(0,191,255,0.18)',fill:true,tension:0.4,pointRadius:3,borderWidth:2.5},
{label:'Rafales (kt)',data:${gustsKt},borderColor:'rgb(120,140,255)',backgroundColor:'rgba(120,140,255,0.08)',fill:false,tension:0.4,pointRadius:2,borderWidth:2,borderDash:[6,3]}
]},options:${opts({ title: '💨 Vent sur 24h', yUnit: ' kt' })}}`);
}
// ═══════════════════════════════════════════════════════════════════════════════
// 3. PRÉCIPITATIONS
// ═══════════════════════════════════════════════════════════════════════════════
async function generatePrecipitationChart(hourly) {
const { start, end } = getWindow(hourly.time);
const labels = JSON.stringify(hourly.time.slice(start, end).map(hl));
const precip = JSON.stringify(hourly.precipitation.slice(start, end).map(v => v?.toFixed(2) ?? 0));
const prob = JSON.stringify(hourly.precipitation_probability.slice(start, end));
return postChart(`{type:'bar',data:{labels:${labels},datasets:[
{label:'Précip. (mm)',data:${precip},backgroundColor:'rgba(30,144,255,0.75)',borderColor:'rgb(30,144,255)',borderWidth:1,yAxisID:'y'},
{label:'Probabilité (%)',data:${prob},type:'line',borderColor:'rgb(0,210,210)',backgroundColor:'rgba(0,210,210,0.1)',fill:false,tension:0.4,borderWidth:2,pointRadius:2,yAxisID:'y1'}
]},options:${opts({ title: '🌧️ Précipitations sur 24h', yUnit: ' mm', yMin: 0, y1: { color: '#00D2D2', unit: '%' } })}}`);
}
// ═══════════════════════════════════════════════════════════════════════════════
// 4. PRESSION
// ═══════════════════════════════════════════════════════════════════════════════
async function generatePressureChart(hourly) {
const { start, end } = getWindow(hourly.time);
const labels = JSON.stringify(hourly.time.slice(start, end).map(hl));
const pressure = JSON.stringify(hourly.pressure_msl.slice(start, end).map(v => v?.toFixed(1) ?? null));
return postChart(`{type:'line',data:{labels:${labels},datasets:[
{label:'Pression (hPa)',data:${pressure},borderColor:'rgb(147,112,219)',backgroundColor:'rgba(147,112,219,0.22)',fill:true,tension:0.4,pointRadius:3,borderWidth:2.5}
]},options:${opts({ title: '🔵 Pression atmosphérique sur 24h', yUnit: ' hPa' })}}`);
}
// ═══════════════════════════════════════════════════════════════════════════════
// 5. NUAGES
// ═══════════════════════════════════════════════════════════════════════════════
async function generateCloudChart(hourly) {
const { start, end } = getWindow(hourly.time);
const labels = JSON.stringify(hourly.time.slice(start, end).map(hl));
const total = JSON.stringify(hourly.cloud_cover.slice(start, end));
const low = JSON.stringify(hourly.cloud_cover_low.slice(start, end));
const mid = JSON.stringify(hourly.cloud_cover_mid.slice(start, end));
const high = JSON.stringify(hourly.cloud_cover_high.slice(start, end));
return postChart(`{type:'line',data:{labels:${labels},datasets:[
{label:'Total',data:${total},borderColor:'rgb(160,160,160)',backgroundColor:'rgba(160,160,160,0.2)',fill:true,tension:0.4,pointRadius:2,borderWidth:2.5},
{label:'Bas',data:${low},borderColor:'rgb(176,196,222)',fill:false,tension:0.4,pointRadius:2,borderWidth:1.5,borderDash:[4,3]},
{label:'Moyen',data:${mid},borderColor:'rgb(100,180,220)',fill:false,tension:0.4,pointRadius:2,borderWidth:1.5,borderDash:[6,3]},
{label:'Haut',data:${high},borderColor:'rgb(200,230,255)',fill:false,tension:0.4,pointRadius:2,borderWidth:1.5,borderDash:[8,4]}
]},options:${opts({ title: '☁️ Couverture nuageuse sur 24h', yUnit: '%', yMin: 0, yMax: 100 })}}`);
}
// ═══════════════════════════════════════════════════════════════════════════════
// 6. UV INDEX
// ═══════════════════════════════════════════════════════════════════════════════
async function generateUvChart(hourly) {
const { start, end } = getWindow(hourly.time);
const labels = JSON.stringify(hourly.time.slice(start, end).map(hl));
const uv = JSON.stringify(hourly.uv_index.slice(start, end).map(v => v?.toFixed(1) ?? 0));
// Zones de danger (annotations via dataset fill + background colors par zone)
return postChart(`{type:'bar',data:{labels:${labels},datasets:[
{label:'UV Index',data:${uv},backgroundColor:function(ctx){
const v=ctx.dataset.data[ctx.dataIndex];
if(v>=11)return 'rgba(148,0,211,0.85)';
if(v>=8)return 'rgba(255,0,0,0.80)';
if(v>=6)return 'rgba(255,140,0,0.82)';
if(v>=3)return 'rgba(255,215,0,0.82)';
return 'rgba(76,175,80,0.82)';
},borderColor:'rgba(0,0,0,0)',borderWidth:0,borderRadius:3}
]},options:${opts({ title: '☀️ UV Index sur 24h', yUnit: '', yMin: 0 })}}`);
}
// ═══════════════════════════════════════════════════════════════════════════════
// 7. ÉLÉVATION SOLAIRE
// ═══════════════════════════════════════════════════════════════════════════════
async function generateSolarElevationChart(hourlyTimes) {
const lat = parseFloat(process.env.LAT);
const lon = parseFloat(process.env.LON);
const { start, end, startIdx } = getWindow(hourlyTimes);
const labels = JSON.stringify(hourlyTimes.slice(start, end).map(hl));
// Reconstruire les dates UTC depuis les heures Paris
// Open-Meteo renvoie l'heure locale Paris sans offset → on applique l'offset manuellement
const parisOffsetH = getParisOffsetHours();
const startTimeUTC = new Date(
new Date(hourlyTimes[start] + ':00').getTime() - parisOffsetH * 3_600_000
);
const elevations = JSON.stringify(getSolarElevationForDay(lat, lon, startTimeUTC, end - start));
return postChart(`{type:'line',data:{labels:${labels},datasets:[
{label:'Élévation (°)',data:${elevations},borderColor:'rgb(255,220,50)',backgroundColor:function(ctx){
const v=ctx.dataset.data[ctx.dataIndex];
if(v<=0)return 'rgba(0,0,50,0.4)';
if(v<15)return 'rgba(255,120,30,0.3)';
return 'rgba(255,220,50,0.25)';
},fill:true,tension:0.4,pointRadius:2,borderWidth:2.5}
]},options:${opts({ title: '⬆️ Elevation solaire sur 24h', yUnit: '°', yMin: -10 })}}`);
}
// Offset UTC de Paris en heures (gère l'heure d'été)
function getParisOffsetHours() {
const now = new Date();
const paris = new Date(now.toLocaleString('en-US', { timeZone: 'Europe/Paris' }));
return (paris - new Date(now.toLocaleString('en-US', { timeZone: 'UTC' }))) / 3_600_000;
}
// ═══════════════════════════════════════════════════════════════════════════════
// 8. MARÉES (calcul harmonique approximatif)
// ═══════════════════════════════════════════════════════════════════════════════
async function generateTideChart(hourlyTimes) {
const { start, end } = getWindow(hourlyTimes);
const labels = JSON.stringify(hourlyTimes.slice(start, end).map(hl));
// Heure de départ (heure locale Paris → UTC)
const parisOffsetH = getParisOffsetHours();
const startTimeUTC = new Date(
new Date(hourlyTimes[start] + ':00').getTime() - parisOffsetH * 3_600_000
);
const tides = JSON.stringify(getTideForDay(startTimeUTC, end - start));
return postChart(`{type:'line',data:{labels:${labels},datasets:[
{label:'Hauteur marée (m)',data:${tides},borderColor:'rgb(0,180,216)',backgroundColor:'rgba(0,180,216,0.20)',fill:true,tension:0.4,pointRadius:3,borderWidth:2.5}
]},options:${opts({ title: '🌊 Marees sur 24h (estimation harmonique)', yUnit: ' m' })}}`);
}
// ═══════════════════════════════════════════════════════════════════════════════
// GÉNÉRATION PARALLÈLE DES 8 GRAPHIQUES
// ═══════════════════════════════════════════════════════════════════════════════
async function generateAllCharts(hourly) {
const keys = ['temperature','wind','rain','pressure','clouds','uv','solar','tide'];
const jobs = [
generateTemperatureChart(hourly),
generateWindChart(hourly),
generatePrecipitationChart(hourly),
generatePressureChart(hourly),
generateCloudChart(hourly),
generateUvChart(hourly),
generateSolarElevationChart(hourly.time),
generateTideChart(hourly.time),
];
const results = await Promise.allSettled(jobs);
const out = { errors: [] };
results.forEach((r, i) => {
if (r.status === 'fulfilled') out[keys[i]] = r.value;
else {
out[keys[i]] = null;
out.errors.push(`${keys[i]}: ${r.reason?.message}`);
}
});
return out;
}
module.exports = {
generateAllCharts,
generateTemperatureChart,
generateWindChart,
generatePrecipitationChart,
generatePressureChart,
generateCloudChart,
generateUvChart,
generateSolarElevationChart,
generateTideChart,
};