mirror of
https://github.com/arthur-pbty/discord-weather-bot.git
synced 2026-08-01 20:28:48 +02:00
first commit
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
'use strict';
|
||||
|
||||
const axios = require('axios');
|
||||
|
||||
// ─── Cache journalier ─────────────────────────────────────────────────────────
|
||||
let astroCache = { data: null, date: null };
|
||||
|
||||
// ─── Calcul de la phase lunaire (algorithme Julian Date) ─────────────────────
|
||||
function getMoonPhase(date = new Date()) {
|
||||
// Conversion en Jour Julien (JD)
|
||||
let y = date.getFullYear();
|
||||
let m = date.getMonth() + 1;
|
||||
const d = date.getDate();
|
||||
|
||||
if (m <= 2) { y -= 1; m += 12; }
|
||||
const A = Math.floor(y / 100);
|
||||
const B = 2 - A + Math.floor(A / 4);
|
||||
const jd = Math.floor(365.25 * (y + 4716))
|
||||
+ Math.floor(30.6001 * (m + 1))
|
||||
+ d + B - 1524.5;
|
||||
|
||||
// Nouvelle lune de référence : 6 janvier 2000 à 18h14 UTC → JD 2451549.759
|
||||
const REF_NEW_MOON = 2451549.759;
|
||||
const SYNODIC_PERIOD = 29.53058867; // jours
|
||||
|
||||
let daysSinceNew = (jd - REF_NEW_MOON) % SYNODIC_PERIOD;
|
||||
if (daysSinceNew < 0) daysSinceNew += SYNODIC_PERIOD;
|
||||
|
||||
const fraction = daysSinceNew / SYNODIC_PERIOD;
|
||||
|
||||
// Illumination (0–100 %)
|
||||
const illumination = Math.round((1 - Math.cos(2 * Math.PI * fraction)) / 2 * 100);
|
||||
|
||||
// Nom & emoji de la phase
|
||||
let phaseName, phaseEmoji;
|
||||
if (fraction < 0.0625) { phaseName = 'Nouvelle lune'; phaseEmoji = '🌑'; }
|
||||
else if (fraction < 0.1875) { phaseName = 'Croissant montant'; phaseEmoji = '🌒'; }
|
||||
else if (fraction < 0.3125) { phaseName = 'Premier quartier'; phaseEmoji = '🌓'; }
|
||||
else if (fraction < 0.4375) { phaseName = 'Gibbeuse croissante'; phaseEmoji = '🌔'; }
|
||||
else if (fraction < 0.5625) { phaseName = 'Pleine lune'; phaseEmoji = '🌕'; }
|
||||
else if (fraction < 0.6875) { phaseName = 'Gibbeuse décroissante'; phaseEmoji = '🌖'; }
|
||||
else if (fraction < 0.8125) { phaseName = 'Dernier quartier'; phaseEmoji = '🌗'; }
|
||||
else if (fraction < 0.9375) { phaseName = 'Croissant décroissant'; phaseEmoji = '🌘'; }
|
||||
else { phaseName = 'Nouvelle lune'; phaseEmoji = '🌑'; }
|
||||
|
||||
return { phaseName, phaseEmoji, illumination, daysInCycle: daysSinceNew.toFixed(1) };
|
||||
}
|
||||
|
||||
// ─── Formatage d'une heure UTC ISO → heure Paris HH:MM ──────────────────────
|
||||
function fmtTime(isoString) {
|
||||
if (!isoString) return 'N/A';
|
||||
return new Date(isoString).toLocaleTimeString('fr-FR', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZone: 'Europe/Paris',
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Durée en secondes → "Xh YYmin" ─────────────────────────────────────────
|
||||
function fmtDuration(seconds) {
|
||||
if (!seconds) return 'N/A';
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
return `${h}h ${String(m).padStart(2, '0')}min`;
|
||||
}
|
||||
|
||||
// ─── Fetch astronomie (Sunrise-Sunset API + fallback Open-Meteo) ──────────────
|
||||
async function fetchAstronomyData(lat, lon, fallbackDaily = null) {
|
||||
const todayStr = new Date().toLocaleDateString('sv', { timeZone: 'Europe/Paris' });
|
||||
|
||||
if (astroCache.data && astroCache.date === todayStr) {
|
||||
return astroCache.data;
|
||||
}
|
||||
|
||||
const moon = getMoonPhase(new Date());
|
||||
let result;
|
||||
|
||||
try {
|
||||
const res = await axios.get('https://api.sunrise-sunset.org/json', {
|
||||
params: { lat, lng: lon, date: todayStr, formatted: 0 },
|
||||
timeout: 6000,
|
||||
});
|
||||
|
||||
const r = res.data.results;
|
||||
result = {
|
||||
sunrise: fmtTime(r.sunrise),
|
||||
sunset: fmtTime(r.sunset),
|
||||
solar_noon: fmtTime(r.solar_noon),
|
||||
day_length: fmtDuration(r.day_length),
|
||||
moon,
|
||||
source: 'Sunrise-Sunset.org',
|
||||
};
|
||||
} catch (err) {
|
||||
console.warn(`[astro] Sunrise-Sunset API indisponible (${err.message}), fallback Open-Meteo`);
|
||||
|
||||
// Fallback : Open-Meteo daily contient sunrise/sunset (en heure locale ISO)
|
||||
if (fallbackDaily) {
|
||||
const todayIdx = fallbackDaily.time.findIndex(d => d === todayStr);
|
||||
const i = todayIdx >= 0 ? todayIdx : 0;
|
||||
const sr = fallbackDaily.sunrise?.[i];
|
||||
const ss = fallbackDaily.sunset?.[i];
|
||||
|
||||
// Open-Meteo renvoie "2024-06-10T06:24" (heure locale), pas UTC
|
||||
const fmtLocal = (s) => s ? s.substring(11, 16) : 'N/A';
|
||||
|
||||
let dayLengthSec = null;
|
||||
if (sr && ss) {
|
||||
const [sh, sm] = fmtLocal(sr).split(':').map(Number);
|
||||
const [eh, em] = fmtLocal(ss).split(':').map(Number);
|
||||
dayLengthSec = (eh * 60 + em - sh * 60 - sm) * 60;
|
||||
}
|
||||
|
||||
result = {
|
||||
sunrise: fmtLocal(sr),
|
||||
sunset: fmtLocal(ss),
|
||||
solar_noon: 'N/A',
|
||||
day_length: fmtDuration(dayLengthSec),
|
||||
moon,
|
||||
source: 'Open-Meteo (fallback)',
|
||||
};
|
||||
} else {
|
||||
result = {
|
||||
sunrise: 'N/A', sunset: 'N/A', solar_noon: 'N/A', day_length: 'N/A',
|
||||
moon,
|
||||
source: 'Indisponible',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
astroCache = { data: result, date: todayStr };
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── Invalidation du cache astronomie ────────────────────────────────────────
|
||||
function invalidateAstroCache() {
|
||||
astroCache = { data: null, date: null };
|
||||
}
|
||||
|
||||
// ─── Calcul de l'élévation solaire (algorithme NOAA simplifié) ───────────────
|
||||
function getSolarElevation(lat, lon, dateUTC) {
|
||||
const toRad = d => d * Math.PI / 180;
|
||||
const toDeg = r => r * 180 / Math.PI;
|
||||
|
||||
const y = dateUTC.getUTCFullYear();
|
||||
const m = dateUTC.getUTCMonth() + 1;
|
||||
const dd = dateUTC.getUTCDate();
|
||||
const hh = dateUTC.getUTCHours() + dateUTC.getUTCMinutes() / 60;
|
||||
|
||||
// Julian Day
|
||||
let yj = y, mj = m;
|
||||
if (mj <= 2) { yj -= 1; mj += 12; }
|
||||
const A = Math.floor(yj / 100);
|
||||
const B = 2 - A + Math.floor(A / 4);
|
||||
const JD = Math.floor(365.25 * (yj + 4716)) + Math.floor(30.6001 * (mj + 1)) + dd + B - 1524.5 + hh / 24;
|
||||
const n = JD - 2451545.0; // jours depuis J2000
|
||||
|
||||
// Longitude du Soleil & anomalie moyenne
|
||||
const L = ((280.460 + 0.9856474 * n) % 360 + 360) % 360;
|
||||
const g = toRad(((357.528 + 0.9856003 * n) % 360 + 360) % 360);
|
||||
|
||||
// Longitude écliptique
|
||||
const lambda = toRad(L + 1.915 * Math.sin(g) + 0.020 * Math.sin(2 * g));
|
||||
|
||||
// Obliquité de l'écliptique
|
||||
const eps = toRad(23.439 - 0.0000004 * n);
|
||||
|
||||
// Déclinaison & ascension droite
|
||||
const sinDec = Math.sin(eps) * Math.sin(lambda);
|
||||
const dec = Math.asin(sinDec);
|
||||
const RA_h = toDeg(Math.atan2(Math.cos(eps) * Math.sin(lambda), Math.cos(lambda))) / 15;
|
||||
|
||||
// Temps sidéral de Greenwich (heures)
|
||||
const GMST = ((6.697375 + 0.0657098242 * n + hh) % 24 + 24) % 24;
|
||||
|
||||
// Angle horaire local
|
||||
const LST = (GMST + lon / 15 + 24) % 24;
|
||||
const H = toRad((LST - RA_h) * 15);
|
||||
|
||||
// Altitude (élévation)
|
||||
const latR = toRad(parseFloat(lat));
|
||||
const sinAlt = Math.sin(latR) * Math.sin(dec) + Math.cos(latR) * Math.cos(dec) * Math.cos(H);
|
||||
return toDeg(Math.asin(Math.max(-1, Math.min(1, sinAlt))));
|
||||
}
|
||||
|
||||
// Tableau 25 valeurs horaires (élévation, peut être négatif = nuit)
|
||||
function getSolarElevationForDay(lat, lon, startTime, count = 25) {
|
||||
const out = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const t = new Date(startTime.getTime() + i * 3600_000);
|
||||
out.push(parseFloat(getSolarElevation(lat, lon, t).toFixed(1)));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ─── Marées harmoniques (côte atlantique française — approximation) ───────────
|
||||
// Constituants calibrés sur la zone Lacanau-Arcachon (SHOM approx.)
|
||||
const TIDAL_CONSTITUENTS = [
|
||||
// [amplitude_m, vitesse_°/h, phase_°]
|
||||
[1.42, 28.9841042, 288], // M2 - Lunaire semi-diurne principal
|
||||
[0.50, 30.0000000, 330], // S2 - Solaire semi-diurne principal
|
||||
[0.28, 28.4397295, 270], // N2 - Lunaire elliptique majeure
|
||||
[0.10, 15.0410686, 48 ], // K1 - Luni-solaire diurne
|
||||
[0.09, 13.9430356, 358], // O1 - Lunaire diurne principal
|
||||
[0.05, 30.0821373, 335], // K2 - Luni-solaire semi-diurne
|
||||
[0.04, 28.5125831, 272], // L2 - Lunaire elliptique mineure
|
||||
];
|
||||
const J2000_MS = 946727935816; // 1er jan 2000 12h00 UTC en ms
|
||||
|
||||
function calculateTideHeight(timestampMs) {
|
||||
const t = (timestampMs - J2000_MS) / 3_600_000; // heures depuis J2000
|
||||
const toRad = d => d * Math.PI / 180;
|
||||
return TIDAL_CONSTITUENTS.reduce((h, [amp, speed, phase]) => {
|
||||
return h + amp * Math.cos(toRad(speed * t - phase));
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function getTideForDay(startTime, count = 25) {
|
||||
return Array.from({ length: count }, (_, i) => {
|
||||
const t = new Date(startTime.getTime() + i * 3_600_000);
|
||||
return parseFloat(calculateTideHeight(t.getTime()).toFixed(2));
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
fetchAstronomyData,
|
||||
getMoonPhase,
|
||||
invalidateAstroCache,
|
||||
getSolarElevation,
|
||||
getSolarElevationForDay,
|
||||
calculateTideHeight,
|
||||
getTideForDay,
|
||||
};
|
||||
@@ -0,0 +1,255 @@
|
||||
'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,
|
||||
};
|
||||
@@ -0,0 +1,316 @@
|
||||
'use strict';
|
||||
|
||||
// Chargement dynamique pour éviter un crash si @napi-rs/canvas n'est pas installé
|
||||
let createCanvas, loadImage, GlobalFonts;
|
||||
try {
|
||||
({ createCanvas, loadImage, GlobalFonts } = require('@napi-rs/canvas'));
|
||||
try { GlobalFonts.loadSystemFonts(); } catch (_) {}
|
||||
} catch (e) {
|
||||
console.warn('[composite] @napi-rs/canvas non disponible — image composite désactivée');
|
||||
}
|
||||
|
||||
const {
|
||||
getWeatherDescription, getWindDirection, getVisibilityLabel,
|
||||
getUvLabel, knotsFromKmh, getNextHoursWindow, getTodayDailyIndex,
|
||||
} = require('./weather');
|
||||
|
||||
// ─── Palette ──────────────────────────────────────────────────────────────────
|
||||
const C = {
|
||||
bg: '#0f172a',
|
||||
section: '#1e293b',
|
||||
card: '#243449',
|
||||
border: '#334155',
|
||||
text: '#e2e8f0',
|
||||
dim: '#94a3b8',
|
||||
bright: '#f8fafc',
|
||||
accent: '#38bdf8',
|
||||
orange: '#fb923c',
|
||||
green: '#4ade80',
|
||||
yellow: '#fbbf24',
|
||||
marine: '#1e3a5c',
|
||||
marineB: '#2a5a8c',
|
||||
marineT: '#93c5fd',
|
||||
};
|
||||
|
||||
const W = 1860;
|
||||
const PAD = 22;
|
||||
const GUTTER = 12;
|
||||
|
||||
// ─── Helpers canvas ───────────────────────────────────────────────────────────
|
||||
function roundRect(ctx, x, y, w, h, r = 10) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + r, y);
|
||||
ctx.arcTo(x + w, y, x + w, y + h, r);
|
||||
ctx.arcTo(x + w, y + h, x, y + h, r);
|
||||
ctx.arcTo(x, y + h, x, y, r);
|
||||
ctx.arcTo(x, y, x + w, y, r);
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
function fillCard(ctx, x, y, w, h, color = C.card, borderColor = C.border, r = 10) {
|
||||
roundRect(ctx, x, y, w, h, r);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fill();
|
||||
roundRect(ctx, x, y, w, h, r);
|
||||
ctx.strokeStyle = borderColor;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
function txt(ctx, str, x, y, { size = 16, weight = 'normal', color = C.text, align = 'left', max } = {}) {
|
||||
ctx.save();
|
||||
ctx.font = `${weight} ${size}px sans-serif`;
|
||||
ctx.fillStyle = color;
|
||||
ctx.textAlign = align;
|
||||
if (max) ctx.fillText(String(str ?? ''), x, y, max);
|
||||
else ctx.fillText(String(str ?? ''), x, y);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function hline(ctx, y, opacity = 0.5) {
|
||||
ctx.save();
|
||||
ctx.strokeStyle = C.border;
|
||||
ctx.globalAlpha = opacity;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(PAD, y);
|
||||
ctx.lineTo(W - PAD, y);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function row(ctx, label, value, x, y, valColor = C.accent) {
|
||||
txt(ctx, label, x, y, { size: 14, color: C.dim });
|
||||
txt(ctx, value ?? 'N/A', x + 175, y, { size: 14, weight: 'bold', color: valColor, max: 240 });
|
||||
}
|
||||
|
||||
// ─── Génération de l'image composite ─────────────────────────────────────────
|
||||
async function generateCompositeImage({ weather: wd, marine, astro, charts }) {
|
||||
if (!createCanvas) throw new Error('@napi-rs/canvas non installé');
|
||||
|
||||
const w = wd.weather;
|
||||
const c = w.current;
|
||||
const h = w.hourly;
|
||||
const d = w.daily;
|
||||
const loc = process.env.LOCATION_NAME || `${process.env.LAT}°, ${process.env.LON}°`;
|
||||
|
||||
const todayIdx = getTodayDailyIndex(d.time);
|
||||
const { start: hIdx } = getNextHoursWindow(h.time);
|
||||
|
||||
// ── Layout dynamique ────────────────────────────────────────────────────────
|
||||
const HEADER_H = 130;
|
||||
const SEP = 14;
|
||||
const DATA_H = 310; // 2 lignes × 3 cartes
|
||||
const ASTRO_H = 76;
|
||||
const MARINE_H = marine ? 58 : 0;
|
||||
const ALERT_H = 0; // géré dans DATA section si besoin
|
||||
const CHART_H = 280;
|
||||
const CHART_ROWS = 4; // 8 graphiques / 2 colonnes
|
||||
const FOOTER_H = 52;
|
||||
|
||||
const TOTAL_H = PAD + HEADER_H + SEP + DATA_H + SEP + ASTRO_H + (MARINE_H ? MARINE_H + GUTTER : 0)
|
||||
+ SEP + CHART_ROWS * (CHART_H + GUTTER) + SEP + FOOTER_H + PAD;
|
||||
|
||||
const canvas = createCanvas(W, TOTAL_H);
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// ── Fond ────────────────────────────────────────────────────────────────────
|
||||
ctx.fillStyle = C.bg;
|
||||
ctx.fillRect(0, 0, W, TOTAL_H);
|
||||
|
||||
// Gradient haut
|
||||
const grd = ctx.createLinearGradient(0, 0, 0, 220);
|
||||
grd.addColorStop(0, 'rgba(56,189,248,0.07)');
|
||||
grd.addColorStop(1, 'rgba(0,0,0,0)');
|
||||
ctx.fillStyle = grd;
|
||||
ctx.fillRect(0, 0, W, 220);
|
||||
|
||||
let Y = PAD;
|
||||
|
||||
// ── HEADER ──────────────────────────────────────────────────────────────────
|
||||
// Condition météo
|
||||
txt(ctx, getWeatherDescription(c.weather_code), PAD, Y + 36, { size: 26, weight: 'bold', color: C.bright });
|
||||
txt(ctx, loc, PAD, Y + 64, { size: 17, color: C.dim });
|
||||
|
||||
// Température (droite)
|
||||
txt(ctx, `${c.temperature_2m?.toFixed(1)}°C`, W - PAD, Y + 44, { size: 52, weight: 'bold', color: C.accent, align: 'right' });
|
||||
txt(ctx, `Ressenti ${c.apparent_temperature?.toFixed(1)}°C`, W - PAD, Y + 74, { size: 16, color: C.dim, align: 'right' });
|
||||
|
||||
// Date / heure
|
||||
const now = new Date();
|
||||
const dateStr = now.toLocaleDateString('fr-FR', { weekday:'long', year:'numeric', month:'long', day:'numeric', timeZone:'Europe/Paris' });
|
||||
const timeStr = now.toLocaleTimeString('fr-FR', { hour:'2-digit', minute:'2-digit', timeZone:'Europe/Paris' });
|
||||
txt(ctx, `${dateStr} — ${timeStr}`, PAD, Y + 100, { size: 14, color: C.dim });
|
||||
|
||||
// Ligne sous header
|
||||
Y += HEADER_H;
|
||||
hline(ctx, Y);
|
||||
Y += SEP;
|
||||
|
||||
// ── DATA CARDS (2 lignes × 3 colonnes) ─────────────────────────────────────
|
||||
const CW = (W - PAD * 2 - GUTTER * 2) / 3;
|
||||
const CH = (DATA_H - GUTTER) / 2;
|
||||
const R2 = Y + CH + GUTTER;
|
||||
|
||||
const cells = [
|
||||
// Ligne 1
|
||||
{
|
||||
title: 'Temperatures',
|
||||
items: [
|
||||
['Actuelle', `${c.temperature_2m?.toFixed(1)}C (${(c.temperature_2m + 273.15).toFixed(0)} K)`],
|
||||
['Ressentie', `${c.apparent_temperature?.toFixed(1)}C`],
|
||||
['Max / Min', `${d.temperature_2m_max[todayIdx]}C / ${d.temperature_2m_min[todayIdx]}C`],
|
||||
['Humidite', `${c.relative_humidity_2m}%`],
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Vent',
|
||||
items: [
|
||||
['Vitesse', `${knotsFromKmh(c.wind_speed_10m)} kt (${c.wind_speed_10m?.toFixed(0)} km/h)`],
|
||||
['Rafales', `${knotsFromKmh(c.wind_gusts_10m)} kt (${c.wind_gusts_10m?.toFixed(0)} km/h)`],
|
||||
['Direction', `${getWindDirection(c.wind_direction_10m)} (${c.wind_direction_10m?.toFixed(0)}deg)`],
|
||||
['Max jour', `${knotsFromKmh(d.wind_speed_10m_max[todayIdx])} kt`],
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Precipitations',
|
||||
items: [
|
||||
['Actuelle', `${c.precipitation?.toFixed(1)} mm/h`],
|
||||
['Probabilite', `${h.precipitation_probability[hIdx] ?? 'N/A'}%`],
|
||||
['Total jour', `${d.precipitation_sum[todayIdx]?.toFixed(1)} mm`],
|
||||
['Neige', `${c.snowfall?.toFixed(1)} cm/h`],
|
||||
]
|
||||
},
|
||||
// Ligne 2
|
||||
{
|
||||
title: 'Atmosphere',
|
||||
items: [
|
||||
['Pression', `${c.pressure_msl?.toFixed(1)} hPa`],
|
||||
['UV actuel', `${c.uv_index?.toFixed(1)} ${getUvLabel(c.uv_index)}`],
|
||||
['UV max', `${d.uv_index_max[todayIdx]}`],
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Couverture nuageuse',
|
||||
items: [
|
||||
['Total', `${c.cloud_cover}%`],
|
||||
['Bas / Moy', `${h.cloud_cover_low[hIdx]}% / ${h.cloud_cover_mid[hIdx]}%`],
|
||||
['Haut', `${h.cloud_cover_high[hIdx]}%`],
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Visibilite & UV',
|
||||
items: [
|
||||
['Distance', `${(c.visibility / 1000).toFixed(1)} km`],
|
||||
['Qualite', getVisibilityLabel(c.visibility)],
|
||||
['UV max j.', `${d.uv_index_max[todayIdx]}`],
|
||||
]
|
||||
},
|
||||
];
|
||||
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const col = i % 3;
|
||||
const ligne = Math.floor(i / 3);
|
||||
const cx = PAD + col * (CW + GUTTER);
|
||||
const cy = Y + ligne * (CH + GUTTER);
|
||||
|
||||
fillCard(ctx, cx, cy, CW, CH);
|
||||
|
||||
// Titre carte
|
||||
txt(ctx, cells[i].title, cx + 14, cy + 24, { size: 14, weight: 'bold', color: C.bright });
|
||||
|
||||
// Séparateur titre
|
||||
ctx.save();
|
||||
ctx.strokeStyle = C.border;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx + 14, cy + 32);
|
||||
ctx.lineTo(cx + CW - 14, cy + 32);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
|
||||
// Données
|
||||
cells[i].items.forEach((item, j) => {
|
||||
row(ctx, item[0], item[1], cx + 14, cy + 54 + j * 26);
|
||||
});
|
||||
}
|
||||
|
||||
Y += DATA_H;
|
||||
hline(ctx, Y);
|
||||
Y += SEP;
|
||||
|
||||
// ── BANDE ASTRONOMIE ────────────────────────────────────────────────────────
|
||||
fillCard(ctx, PAD, Y, W - PAD * 2, ASTRO_H - GUTTER, C.card, C.border, 10);
|
||||
|
||||
const astroItems = [
|
||||
['Lever soleil', astro.sunrise],
|
||||
['Coucher', astro.sunset],
|
||||
['Zenith', astro.solar_noon],
|
||||
['Duree du jour', astro.day_length],
|
||||
['Lune', `${astro.moon.phaseEmoji} ${astro.moon.phaseName} ${astro.moon.illumination}%`],
|
||||
];
|
||||
const aColW = (W - PAD * 4) / astroItems.length;
|
||||
astroItems.forEach((item, i) => {
|
||||
const ax = PAD * 2 + i * aColW;
|
||||
txt(ctx, item[0], ax, Y + 22, { size: 12, color: C.dim });
|
||||
txt(ctx, item[1], ax, Y + 44, { size: 15, weight: 'bold', color: C.bright, max: aColW - 8 });
|
||||
});
|
||||
|
||||
Y += ASTRO_H;
|
||||
|
||||
// ── BANDE MARINE ────────────────────────────────────────────────────────────
|
||||
if (marine && MARINE_H) {
|
||||
const mh = marine.hourly;
|
||||
const { start: ms } = getNextHoursWindow(mh.time);
|
||||
const wH = mh.wave_height?.[ms];
|
||||
const wP = mh.wave_period?.[ms];
|
||||
const wD = mh.wave_direction?.[ms];
|
||||
const sH = mh.swell_wave_height?.[ms];
|
||||
|
||||
if (wH != null) {
|
||||
fillCard(ctx, PAD, Y, W - PAD * 2, MARINE_H - GUTTER, C.marine, C.marineB, 10);
|
||||
const marineStr = [
|
||||
`Vagues : ${wH?.toFixed(2)} m`,
|
||||
`Periode : ${wP?.toFixed(1)} s`,
|
||||
`Direction : ${getWindDirection(wD)} (${wD?.toFixed(0)}deg)`,
|
||||
sH != null ? `Houle : ${sH?.toFixed(2)} m` : '',
|
||||
].filter(Boolean).join(' | ');
|
||||
txt(ctx, marineStr, PAD * 2, Y + 28, { size: 15, weight: 'bold', color: C.marineT });
|
||||
}
|
||||
Y += MARINE_H;
|
||||
}
|
||||
|
||||
hline(ctx, Y);
|
||||
Y += SEP;
|
||||
|
||||
// ── GRILLE DE GRAPHIQUES (2 colonnes) ───────────────────────────────────────
|
||||
const CW2 = (W - PAD * 2 - GUTTER) / 2;
|
||||
const chartOrder = ['temperature','wind','rain','pressure','clouds','uv','solar','tide'];
|
||||
const validCharts = chartOrder.filter(k => Buffer.isBuffer(charts[k]));
|
||||
|
||||
for (let i = 0; i < validCharts.length; i++) {
|
||||
const col = i % 2;
|
||||
const ligne = Math.floor(i / 2);
|
||||
const cx = PAD + col * (CW2 + GUTTER);
|
||||
const cy = Y + ligne * (CHART_H + GUTTER);
|
||||
|
||||
const img = await loadImage(charts[validCharts[i]]);
|
||||
ctx.drawImage(img, cx, cy, CW2, CHART_H);
|
||||
}
|
||||
|
||||
Y += Math.ceil(validCharts.length / 2) * (CHART_H + GUTTER);
|
||||
|
||||
// ── FOOTER ──────────────────────────────────────────────────────────────────
|
||||
hline(ctx, Y, 0.4);
|
||||
Y += 10;
|
||||
txt(
|
||||
ctx,
|
||||
`Open-Meteo | Sunrise-Sunset.org | QuickChart.io | Marees harmoniques (estimation) — ${now.toLocaleTimeString('fr-FR', { timeZone: 'Europe/Paris' })}`,
|
||||
W / 2, Y + 26,
|
||||
{ size: 12, color: C.dim, align: 'center' }
|
||||
);
|
||||
|
||||
return canvas.encode('png');
|
||||
}
|
||||
|
||||
module.exports = { generateCompositeImage };
|
||||
@@ -0,0 +1,254 @@
|
||||
'use strict';
|
||||
|
||||
const axios = require('axios');
|
||||
|
||||
// ─── Cache ─────────────────────────────────────────────────────────────────────
|
||||
let cache = { data: null, ts: null };
|
||||
const CACHE_MS = () => (parseInt(process.env.CACHE_MINUTES, 10) || 10) * 60 * 1000;
|
||||
|
||||
// ─── Appel API principal ────────────────────────────────────────────────────────
|
||||
async function fetchWeatherData() {
|
||||
const now = Date.now();
|
||||
if (cache.data && cache.ts && now - cache.ts < CACHE_MS()) {
|
||||
if (process.env.DEBUG_METEO === 'true') console.log('[cache] Données météo en cache');
|
||||
return cache.data;
|
||||
}
|
||||
|
||||
const lat = process.env.LAT;
|
||||
const lon = process.env.LON;
|
||||
if (!lat || !lon) throw new Error('LAT et LON manquants dans .env');
|
||||
|
||||
const hourlyVars = [
|
||||
'temperature_2m', 'apparent_temperature', 'relative_humidity_2m',
|
||||
'wind_speed_10m', 'wind_gusts_10m', 'wind_direction_10m',
|
||||
'precipitation', 'precipitation_probability', 'rain', 'snowfall', 'showers',
|
||||
'pressure_msl', 'cloud_cover', 'cloud_cover_low', 'cloud_cover_mid', 'cloud_cover_high',
|
||||
'visibility', 'uv_index', 'weather_code',
|
||||
].join(',');
|
||||
|
||||
const dailyVars = [
|
||||
'temperature_2m_max', 'temperature_2m_min',
|
||||
'apparent_temperature_max', 'apparent_temperature_min',
|
||||
'precipitation_sum', 'precipitation_probability_max',
|
||||
'wind_speed_10m_max', 'wind_gusts_10m_max',
|
||||
'uv_index_max', 'sunrise', 'sunset', 'weather_code',
|
||||
].join(',');
|
||||
|
||||
const currentVars = [
|
||||
'temperature_2m', 'apparent_temperature', 'relative_humidity_2m',
|
||||
'wind_speed_10m', 'wind_gusts_10m', 'wind_direction_10m',
|
||||
'precipitation', 'rain', 'snowfall', 'weather_code',
|
||||
'pressure_msl', 'cloud_cover', 'visibility', 'uv_index', 'is_day',
|
||||
].join(',');
|
||||
|
||||
const baseParams = {
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
timezone: 'Europe/Paris',
|
||||
wind_speed_unit: 'kmh',
|
||||
forecast_days: 2,
|
||||
past_days: 1,
|
||||
};
|
||||
|
||||
// Appels parallèles : météo principale + marine
|
||||
const [weatherRes, marineRes] = await Promise.allSettled([
|
||||
axios.get('https://api.open-meteo.com/v1/forecast', {
|
||||
params: { ...baseParams, current: currentVars, hourly: hourlyVars, daily: dailyVars },
|
||||
timeout: 10000,
|
||||
}),
|
||||
axios.get('https://marine-api.open-meteo.com/v1/marine', {
|
||||
params: {
|
||||
...baseParams,
|
||||
hourly: 'wave_height,wave_direction,wave_period,swell_wave_height,swell_wave_direction',
|
||||
daily: 'wave_height_max,swell_wave_height_max',
|
||||
},
|
||||
timeout: 8000,
|
||||
}),
|
||||
]);
|
||||
|
||||
if (weatherRes.status === 'rejected') {
|
||||
throw new Error(`Open-Meteo inaccessible : ${weatherRes.reason?.message}`);
|
||||
}
|
||||
|
||||
const weather = weatherRes.value.data;
|
||||
const marine = marineRes.status === 'fulfilled' ? marineRes.value.data : null;
|
||||
|
||||
if (process.env.DEBUG_METEO === 'true') {
|
||||
console.log('[debug] current:', JSON.stringify(weather.current, null, 2));
|
||||
if (marine) console.log('[debug] marine disponible');
|
||||
}
|
||||
|
||||
const result = { weather, marine, fetchedAt: new Date() };
|
||||
cache = { data: result, ts: now };
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── Invalidation manuelle du cache ───────────────────────────────────────────
|
||||
function invalidateCache() {
|
||||
cache = { data: null, ts: null };
|
||||
}
|
||||
|
||||
// ─── Index de l'heure courante dans les données horaires ──────────────────────
|
||||
function getCurrentHourIndex(hourlyTimes) {
|
||||
// Open-Meteo renvoie les heures en heure locale Paris (ex: "2024-06-10T14:00")
|
||||
// On génère la même chaîne à partir de l'heure Paris actuelle
|
||||
const nowParis = new Date().toLocaleString('sv', { timeZone: 'Europe/Paris' });
|
||||
// format: "YYYY-MM-DD HH:MM:SS"
|
||||
const parisHourPrefix = nowParis.substring(0, 13).replace(' ', 'T'); // "YYYY-MM-DDTHH"
|
||||
|
||||
let best = 0;
|
||||
for (let i = 0; i < hourlyTimes.length; i++) {
|
||||
if (hourlyTimes[i].startsWith(parisHourPrefix)) return i;
|
||||
if (hourlyTimes[i] < `${parisHourPrefix}:59`) best = i;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
// ─── Fenêtre des prochaines N heures depuis maintenant ────────────────────────
|
||||
function getNextHoursWindow(hourlyTimes, count = 24) {
|
||||
const start = getCurrentHourIndex(hourlyTimes);
|
||||
const end = Math.min(start + count, hourlyTimes.length);
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
// ─── Index du jour courant dans les tableaux daily ────────────────────────────
|
||||
function getTodayDailyIndex(dailyTimes) {
|
||||
// avec past_days:1, index 0 = hier, 1 = aujourd'hui, 2 = demain
|
||||
const todayParis = new Date().toLocaleString('sv', { timeZone: 'Europe/Paris' }).substring(0, 10);
|
||||
const idx = dailyTimes.findIndex(d => d === todayParis);
|
||||
return idx >= 0 ? idx : 1; // fallback à 1 (aujourd'hui)
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
function celsiusToKelvin(c) {
|
||||
return (c + 273.15).toFixed(2);
|
||||
}
|
||||
|
||||
function getWindDirection(deg) {
|
||||
if (deg == null) return 'N/A';
|
||||
const dirs = ['N','NNE','NE','ENE','E','ESE','SE','SSE','S','SSO','SO','OSO','O','ONO','NO','NNO'];
|
||||
return dirs[Math.round(((deg % 360) + 360) % 360 / 22.5) % 16];
|
||||
}
|
||||
|
||||
function getWeatherDescription(code) {
|
||||
const map = {
|
||||
0: '☀️ Ciel dégagé',
|
||||
1: '🌤️ Peu nuageux',
|
||||
2: '⛅ Partiellement nuageux',
|
||||
3: '☁️ Couvert',
|
||||
45: '🌫️ Brouillard',
|
||||
48: '🌫️ Brouillard givrant',
|
||||
51: '🌦️ Bruine légère',
|
||||
53: '🌦️ Bruine modérée',
|
||||
55: '🌧️ Bruine dense',
|
||||
61: '🌧️ Pluie légère',
|
||||
63: '🌧️ Pluie modérée',
|
||||
65: '🌧️ Pluie forte',
|
||||
71: '🌨️ Neige légère',
|
||||
73: '🌨️ Neige modérée',
|
||||
75: '❄️ Neige forte',
|
||||
77: '🌨️ Grésil',
|
||||
80: '🌦️ Averses légères',
|
||||
81: '🌧️ Averses modérées',
|
||||
82: '⛈️ Averses violentes',
|
||||
85: '🌨️ Averses de neige',
|
||||
86: '❄️ Averses de neige fortes',
|
||||
95: '⛈️ Orage',
|
||||
96: '⛈️ Orage avec grêle',
|
||||
99: '⛈️ Orage avec grêle forte',
|
||||
};
|
||||
return map[code] ?? `Code météo ${code}`;
|
||||
}
|
||||
|
||||
function getEmbedColor(code) {
|
||||
if (code === 0) return 0xFFD700;
|
||||
if (code <= 2) return 0x87CEEB;
|
||||
if (code <= 3) return 0x708090;
|
||||
if (code <= 48) return 0x9E9E9E;
|
||||
if (code <= 55) return 0x90CAF9;
|
||||
if (code <= 65) return 0x2196F3;
|
||||
if (code <= 77) return 0xB0C4DE;
|
||||
if (code <= 82) return 0x42A5F5;
|
||||
return 0x7B1FA2; // orages
|
||||
}
|
||||
|
||||
function getPressureTendency(hourlyPressure, currentIdx) {
|
||||
if (currentIdx < 3) return '→ Stable';
|
||||
const diff = hourlyPressure[currentIdx] - hourlyPressure[currentIdx - 3];
|
||||
if (diff > 2) return '↑ En hausse';
|
||||
if (diff < -2) return '↓ En baisse';
|
||||
return '→ Stable';
|
||||
}
|
||||
|
||||
function getVisibilityLabel(vis) {
|
||||
if (vis == null) return 'N/A';
|
||||
const km = vis / 1000;
|
||||
if (km >= 20) return '✨ Excellente';
|
||||
if (km >= 10) return '👍 Bonne';
|
||||
if (km >= 5) return '😶🌫️ Correcte';
|
||||
if (km >= 2) return '⚠️ Réduite';
|
||||
if (km >= 1) return '🟠 Mauvaise';
|
||||
return '🔴 Brouillard';
|
||||
}
|
||||
|
||||
function knotsFromKmh(kmh) {
|
||||
if (kmh == null) return 'N/A';
|
||||
return (kmh / 1.852).toFixed(1);
|
||||
}
|
||||
|
||||
function getUvLabel(uv) {
|
||||
if (uv == null) return 'N/A';
|
||||
if (uv <= 2) return '🟢 Faible';
|
||||
if (uv <= 5) return '🟡 Modéré';
|
||||
if (uv <= 7) return '🟠 Élevé';
|
||||
if (uv <= 10) return '🔴 Très élevé';
|
||||
return '🟣 Extrême';
|
||||
}
|
||||
|
||||
// ─── Détection d'alertes météo extrêmes ───────────────────────────────────────
|
||||
function checkAlerts(current, todayDaily) {
|
||||
const alerts = [];
|
||||
|
||||
const wind = current.wind_speed_10m;
|
||||
const gusts = current.wind_gusts_10m;
|
||||
const rain = current.precipitation;
|
||||
const code = current.weather_code;
|
||||
const vis = current.visibility;
|
||||
|
||||
if (wind >= 90) alerts.push({ lvl: '🔴', msg: `Vent violent : **${wind} km/h**` });
|
||||
else if (wind >= 65) alerts.push({ lvl: '🟠', msg: `Vent fort : **${wind} km/h**` });
|
||||
else if (wind >= 50) alerts.push({ lvl: '🟡', msg: `Vent modéré-fort : **${wind} km/h**` });
|
||||
|
||||
if (gusts >= 110) alerts.push({ lvl: '🔴', msg: `Rafales extrêmes : **${gusts} km/h**` });
|
||||
else if (gusts >= 80) alerts.push({ lvl: '🟠', msg: `Rafales fortes : **${gusts} km/h**` });
|
||||
|
||||
if (rain >= 15) alerts.push({ lvl: '🔴', msg: `Pluie torrentielle : **${rain} mm/h**` });
|
||||
else if (rain >= 7) alerts.push({ lvl: '🟠', msg: `Fortes précipitations : **${rain} mm/h**` });
|
||||
|
||||
if (code >= 95) alerts.push({ lvl: '🔴', msg: `Orage en cours ⛈️` });
|
||||
|
||||
const snowDaily = todayDaily?.precipitation_sum;
|
||||
if (current.snowfall >= 1) alerts.push({ lvl: '🟡', msg: `Chutes de neige en cours ❄️` });
|
||||
|
||||
if (vis != null && vis < 500) alerts.push({ lvl: '🔴', msg: `Visibilité quasi nulle : **${(vis/1000).toFixed(1)} km**` });
|
||||
else if (vis != null && vis < 1500) alerts.push({ lvl: '🟠', msg: `Brouillard dense : **${(vis/1000).toFixed(1)} km**` });
|
||||
|
||||
return alerts;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
fetchWeatherData,
|
||||
invalidateCache,
|
||||
getCurrentHourIndex,
|
||||
getNextHoursWindow,
|
||||
getTodayDailyIndex,
|
||||
celsiusToKelvin,
|
||||
getWindDirection,
|
||||
getWeatherDescription,
|
||||
getEmbedColor,
|
||||
getPressureTendency,
|
||||
getVisibilityLabel,
|
||||
getUvLabel,
|
||||
knotsFromKmh,
|
||||
checkAlerts,
|
||||
};
|
||||
Reference in New Issue
Block a user