first commit

This commit is contained in:
Puechberty Arthur
2026-06-19 07:18:00 +02:00
commit 4bc95c52b1
11 changed files with 2584 additions and 0 deletions
+232
View File
@@ -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 (0100 %)
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,
};
+255
View File
@@ -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,
};
+316
View File
@@ -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 };
+254
View File
@@ -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,
};