Files
discord-weather-bot/services/composite.js
T
2026-06-19 07:18:00 +02:00

317 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 };