mirror of
https://github.com/arthur-pbty/discord-weather-bot.git
synced 2026-08-01 20:28:48 +02:00
317 lines
12 KiB
JavaScript
317 lines
12 KiB
JavaScript
'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 };
|