- Frontend Vue 3 (Composition API) + Vite + Vue Router - Backend Go (stdlib) : API REST todo/notes + proxy RSS + auth token - Docker Compose : SPA nginx + backend + Miniflux + Postgres - Widgets : horloge canvas météo, todo 3 colonnes, notes persistées, agrégateur RSS multi-feeds, pomodoro, recherche DuckDuckGo (Ctrl+K) - Auth : dashboard public, todo/notes protégés par token - Widgets expandables (mode agrandi centré)
718 lines
28 KiB
HTML
718 lines
28 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="fr">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>The Clock — Lille</title>
|
||
<style>
|
||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||
html, body {
|
||
width: 100%; height: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
overflow: hidden;
|
||
font-family: 'Courier New', monospace;
|
||
margin: 0;
|
||
}
|
||
canvas {
|
||
display: block;
|
||
position: fixed;
|
||
top: 0; left: 0;
|
||
width: 100vw !important;
|
||
height: 100vh !important;
|
||
z-index: 0;
|
||
}
|
||
#controls {
|
||
position: fixed;
|
||
bottom: 2rem;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.75rem;
|
||
background: transparent;
|
||
border: 1px solid rgba(128,128,128,0.3);
|
||
border-radius: 4px;
|
||
padding: 0.5rem 0.75rem;
|
||
opacity: 0.4;
|
||
transition: opacity 0.2s;
|
||
white-space: nowrap;
|
||
z-index: 10;
|
||
}
|
||
#controls:hover { opacity: 1; }
|
||
#controls input[type="datetime-local"] {
|
||
font-family: inherit;
|
||
font-size: 0.75rem;
|
||
background: transparent;
|
||
border: none;
|
||
outline: none;
|
||
cursor: pointer;
|
||
color-scheme: light dark;
|
||
color: inherit;
|
||
}
|
||
#controls button {
|
||
font-family: inherit;
|
||
font-size: 0.7rem;
|
||
background: transparent;
|
||
border: 1px solid rgba(128,128,128,0.4);
|
||
border-radius: 2px;
|
||
padding: 0.2rem 0.5rem;
|
||
cursor: pointer;
|
||
color: inherit;
|
||
opacity: 0.7;
|
||
}
|
||
#controls button:hover { opacity: 1; }
|
||
#dot {
|
||
width: 6px; height: 6px;
|
||
border-radius: 50%;
|
||
background: #4a9;
|
||
flex-shrink: 0;
|
||
transition: background 0.3s;
|
||
}
|
||
#dot.paused { background: #a84; }
|
||
#legend {
|
||
position: fixed;
|
||
top: 1.5rem;
|
||
right: 1.5rem;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.8rem;
|
||
opacity: 0.15;
|
||
transition: opacity 0.25s;
|
||
color: inherit;
|
||
z-index: 10;
|
||
}
|
||
#legend:hover { opacity: 0.88; }
|
||
.leg-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.6rem;
|
||
justify-content: flex-end;
|
||
}
|
||
.leg-label {
|
||
font-family: 'Georgia', 'Times New Roman', serif;
|
||
font-size: 12px;
|
||
font-style: italic;
|
||
opacity: 0.8;
|
||
white-space: nowrap;
|
||
flex-shrink: 0;
|
||
letter-spacing: 0.02em;
|
||
}
|
||
.leg-val {
|
||
font-family: 'Courier New', monospace;
|
||
font-size: 10px;
|
||
opacity: 0.5;
|
||
flex-shrink: 0;
|
||
}
|
||
.leg-bar {
|
||
width: 64px;
|
||
height: 4px;
|
||
border-radius: 2px;
|
||
flex-shrink: 0;
|
||
}
|
||
.leg-circles {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
}
|
||
.leg-circle {
|
||
border-radius: 50%;
|
||
border: 1.5px solid currentColor;
|
||
opacity: 0.4;
|
||
flex-shrink: 0;
|
||
}
|
||
.leg-sep {
|
||
border: none;
|
||
border-top: 0.5px solid rgba(128,128,128,0.2);
|
||
margin: 0.15rem 0;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<canvas id="c"></canvas>
|
||
|
||
<div id="legend">
|
||
|
||
<!-- Anneau météo : schéma des couches -->
|
||
<div class="leg-row" style="flex-direction:column;align-items:flex-end;gap:5px;">
|
||
<span class="leg-label" style="font-size:10px;">anneau météo — 24h glissantes</span>
|
||
<div style="display:flex;align-items:center;gap:6px;">
|
||
<!-- schéma concentrique miniature -->
|
||
<div style="position:relative;width:44px;height:44px;flex-shrink:0;">
|
||
<!-- rafales intérieur -->
|
||
<div style="position:absolute;inset:0;border-radius:50%;border:3px solid rgba(255,150,0,0.6);"></div>
|
||
<!-- température + nuages -->
|
||
<div style="position:absolute;inset:4px;border-radius:50%;border:5px solid rgba(0,200,200,0.45);"></div>
|
||
<!-- précipitations extérieur -->
|
||
<div style="position:absolute;inset:-5px;border-radius:50%;border:3px solid rgba(80,150,230,0.5);"></div>
|
||
</div>
|
||
<div style="display:flex;flex-direction:column;gap:4px;align-items:flex-start;">
|
||
<div style="display:flex;align-items:center;gap:5px;">
|
||
<div style="width:18px;height:3px;background:rgba(80,150,230,0.7);border-radius:1px;"></div>
|
||
<span class="leg-val" style="opacity:0.6;">précip. mm/h — extérieur</span>
|
||
</div>
|
||
<div style="display:flex;align-items:center;gap:5px;">
|
||
<div style="width:18px;height:5px;background:linear-gradient(to right,rgba(0,200,200,0.6),rgba(200,255,0,0.6));border-radius:1px;"></div>
|
||
<span class="leg-val" style="opacity:0.6;">temp. + nuages — centre</span>
|
||
</div>
|
||
<div style="display:flex;align-items:center;gap:5px;">
|
||
<div style="width:18px;height:3px;background:rgba(255,150,0,0.7);border-radius:1px;"></div>
|
||
<span class="leg-val" style="opacity:0.6;">rafales km/h — intérieur</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<hr class="leg-sep">
|
||
|
||
<div class="leg-row">
|
||
<span class="leg-val">−10°</span>
|
||
<div class="leg-bar" style="background:linear-gradient(to right,rgba(120,0,180,0.8),rgba(40,80,255,0.8),rgba(0,200,200,0.8),rgba(200,255,0,0.8),rgba(240,140,0,0.8),rgba(255,20,0,0.8));"></div>
|
||
<span class="leg-val">40°C</span>
|
||
<span class="leg-label">température</span>
|
||
</div>
|
||
<div class="leg-row">
|
||
<span class="leg-val">0 → 100 %</span>
|
||
<svg width="17" height="11" viewBox="0 0 17 11" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" opacity="0.5">
|
||
<path d="M4 9.5Q1 9.5 1 7Q1 4.5 3.5 4.5Q4 1 8 1Q12 1 12 4.5Q15.5 4.5 15.5 7.5Q15.5 9.5 12 9.5Z"/>
|
||
</svg>
|
||
<span class="leg-label">nuages (voile)</span>
|
||
</div>
|
||
<div class="leg-row">
|
||
<svg width="13" height="11" viewBox="0 0 13 11" fill="none" stroke="rgba(80,150,230,0.8)" stroke-width="1.5" stroke-linecap="round">
|
||
<line x1="2.5" y1="1" x2="1.5" y2="10"/>
|
||
<line x1="6.5" y1="1" x2="5.5" y2="10"/>
|
||
<line x1="10.5" y1="1" x2="9.5" y2="10"/>
|
||
</svg>
|
||
<span class="leg-label">précipitations mm/h</span>
|
||
</div>
|
||
<div class="leg-row">
|
||
<span class="leg-val">30</span>
|
||
<div class="leg-bar" style="background:linear-gradient(to right,rgba(180,200,40,0.8),rgba(255,170,0,0.8),rgba(255,70,0,0.8),rgba(220,20,20,0.8));"></div>
|
||
<span class="leg-val">80 km/h</span>
|
||
<span class="leg-label">rafales</span>
|
||
</div>
|
||
<div class="leg-row">
|
||
<span class="leg-val">970</span>
|
||
<div class="leg-circles">
|
||
<div class="leg-circle" style="width:5px;height:5px;"></div>
|
||
<span class="leg-val" style="opacity:0.3;letter-spacing:-1px;">···</span>
|
||
<div class="leg-circle" style="width:13px;height:13px;"></div>
|
||
</div>
|
||
<span class="leg-val">1040 hPa</span>
|
||
<span class="leg-label">pression (centre)</span>
|
||
</div>
|
||
<hr class="leg-sep">
|
||
<div class="leg-row" style="flex-direction:column;align-items:flex-end;gap:6px;">
|
||
<div style="display:flex;gap:10px;align-items:flex-end;">
|
||
<div style="display:flex;flex-direction:column;align-items:center;gap:4px;">
|
||
<div style="width:10px;height:10px;border-radius:50%;border:1px solid currentColor;opacity:0.35;"></div>
|
||
<span class="leg-val">sec</span>
|
||
</div>
|
||
<div style="display:flex;flex-direction:column;align-items:center;gap:4px;">
|
||
<div style="width:16px;height:16px;border-radius:50%;border:1.5px solid currentColor;opacity:0.4;"></div>
|
||
<span class="leg-val">min</span>
|
||
</div>
|
||
<div style="display:flex;flex-direction:column;align-items:center;gap:4px;">
|
||
<div style="width:22px;height:22px;border-radius:50%;border:1.5px solid currentColor;opacity:0.4;"></div>
|
||
<span class="leg-val">heure</span>
|
||
</div>
|
||
<div style="display:flex;flex-direction:column;align-items:center;gap:4px;">
|
||
<div style="width:28px;height:28px;border-radius:50%;border:3px solid rgba(80,150,230,0.5);"></div>
|
||
<span class="leg-val">météo</span>
|
||
</div>
|
||
<div style="display:flex;flex-direction:column;align-items:center;gap:4px;">
|
||
<div style="width:36px;height:36px;border-radius:50%;border:2px solid currentColor;opacity:0.45;"></div>
|
||
<span class="leg-val">jour</span>
|
||
</div>
|
||
<div style="display:flex;flex-direction:column;align-items:center;gap:4px;">
|
||
<svg width="13" height="13" viewBox="0 0 13 13" fill="currentColor" opacity="0.45"><circle cx="6.5" cy="6.5" r="6"/></svg>
|
||
<span class="leg-val">soleil</span>
|
||
</div>
|
||
<div style="display:flex;flex-direction:column;align-items:center;gap:4px;">
|
||
<div style="width:44px;height:44px;border-radius:50%;border:1px dashed currentColor;opacity:0.3;"></div>
|
||
<span class="leg-val">année</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="controls">
|
||
<div id="dot"></div>
|
||
<input type="datetime-local" id="dt-input" step="1">
|
||
<button id="btn-now">now</button>
|
||
</div>
|
||
|
||
<script>
|
||
const canvas = document.getElementById('c');
|
||
const ctx = canvas.getContext('2d');
|
||
const dtInput = document.getElementById('dt-input');
|
||
const btnNow = document.getElementById('btn-now');
|
||
const dot = document.getElementById('dot');
|
||
|
||
const TZ = 'Europe/Paris';
|
||
|
||
function parisComponents(d) {
|
||
const parts = new Intl.DateTimeFormat('fr-FR', {
|
||
timeZone: TZ,
|
||
year:'numeric', month:'2-digit', day:'2-digit',
|
||
hour:'2-digit', minute:'2-digit', second:'2-digit', hour12: false
|
||
}).formatToParts(d);
|
||
const get = t => parseInt(parts.find(x=>x.type===t).value, 10);
|
||
return { year:get('year'), month:get('month'), day:get('day'), hour:get('hour'), minute:get('minute'), second:get('second') };
|
||
}
|
||
function parisDateStr(d) {
|
||
const p=parisComponents(d), pad=n=>String(n).padStart(2,'0');
|
||
return `${p.year}-${pad(p.month)}-${pad(p.day)}`;
|
||
}
|
||
function toInputStr(d) {
|
||
const p=parisComponents(d), pad=n=>String(n).padStart(2,'0');
|
||
return `${p.year}-${pad(p.month)}-${pad(p.day)}T${pad(p.hour)}:${pad(p.minute)}:${pad(p.second)}`;
|
||
}
|
||
function getParisOffsetMs(d) {
|
||
const fmt = tz => new Intl.DateTimeFormat('en-GB',{timeZone:tz,hour:'2-digit',minute:'2-digit',hour12:false}).format(d);
|
||
const [uh,um]=fmt('UTC').split(':').map(Number);
|
||
const [ph,pm]=fmt(TZ).split(':').map(Number);
|
||
return ((ph*60+pm)-(uh*60+um))*60000;
|
||
}
|
||
function parisStrToDate(val) {
|
||
const naive = new Date(val+'Z');
|
||
return new Date(naive.getTime() - getParisOffsetMs(naive));
|
||
}
|
||
|
||
// --- Temps simulé ---
|
||
let simDate = null;
|
||
let isLive = true;
|
||
function getNow() { return isLive ? new Date() : simDate; }
|
||
|
||
dtInput.addEventListener('change', () => {
|
||
if (!dtInput.value) return;
|
||
simDate = parisStrToDate(dtInput.value);
|
||
isLive = false;
|
||
dot.classList.add('paused');
|
||
lastFetchKey = null;
|
||
});
|
||
btnNow.addEventListener('click', () => {
|
||
isLive=true; simDate=null;
|
||
dot.classList.remove('paused');
|
||
lastFetchKey=null;
|
||
});
|
||
dtInput.value = toInputStr(new Date());
|
||
|
||
// --- État météo (valeurs cibles) ---
|
||
let weatherTarget = {
|
||
temp:null, cloudcover:0, precip:0, pressure:1013,
|
||
precipForecast:[], tempForecast:[], cloudForecast:[],
|
||
gustForecast:[], sunriseHour:6, sunsetHour:20
|
||
};
|
||
// Valeurs interpolées (ce qu'on dessine)
|
||
let weatherSmooth = {
|
||
temp:null, cloudcover:0, precip:0, pressure:1013,
|
||
precipForecast:Array(24).fill(0), tempForecast:Array(24).fill(null),
|
||
cloudForecast:Array(24).fill(0), gustForecast:Array(24).fill(0),
|
||
sunriseHour:6, sunsetHour:20
|
||
};
|
||
|
||
let lastFetchKey = null;
|
||
let lastFetchTs = 0;
|
||
|
||
// Lerp scalaire
|
||
const lerp = (a,b,t) => a + (b-a)*t;
|
||
const LSPEED = 0.04; // vitesse d'interpolation par frame (~60fps → ~1.5s)
|
||
|
||
function smoothWeather() {
|
||
const s = weatherSmooth, tg = weatherTarget;
|
||
if (tg.temp !== null) {
|
||
s.temp = s.temp === null ? tg.temp : lerp(s.temp, tg.temp, LSPEED);
|
||
}
|
||
s.cloudcover = lerp(s.cloudcover, tg.cloudcover, LSPEED);
|
||
s.precip = lerp(s.precip, tg.precip, LSPEED);
|
||
s.pressure = lerp(s.pressure, tg.pressure, LSPEED);
|
||
s.sunriseHour = lerp(s.sunriseHour, tg.sunriseHour, LSPEED);
|
||
s.sunsetHour = lerp(s.sunsetHour, tg.sunsetHour, LSPEED);
|
||
for (let h=0; h<24; h++) {
|
||
s.precipForecast[h] = lerp(s.precipForecast[h]||0, tg.precipForecast[h]||0, LSPEED);
|
||
s.cloudForecast[h] = lerp(s.cloudForecast[h]||0, tg.cloudForecast[h]||0, LSPEED);
|
||
s.gustForecast[h] = lerp(s.gustForecast[h]||0, tg.gustForecast[h]||0, LSPEED);
|
||
if (tg.tempForecast[h] !== null && tg.tempForecast[h] !== undefined) {
|
||
s.tempForecast[h] = s.tempForecast[h] === null
|
||
? tg.tempForecast[h]
|
||
: lerp(s.tempForecast[h], tg.tempForecast[h], LSPEED);
|
||
}
|
||
}
|
||
}
|
||
|
||
async function fetchWeather(d) {
|
||
const p = parisComponents(d);
|
||
const pad = n => String(n).padStart(2,'0');
|
||
const dateStr = `${p.year}-${pad(p.month)}-${pad(p.day)}`;
|
||
const key = `${dateStr}T${pad(p.hour)}`;
|
||
const now10 = Date.now();
|
||
if (key === lastFetchKey && now10 - lastFetchTs < 10*60*1000) return;
|
||
lastFetchKey = key;
|
||
lastFetchTs = now10;
|
||
|
||
const diffDays = (d.getTime() - Date.now()) / 86400000;
|
||
const tomorrow = new Date(d.getTime() + 86400000);
|
||
const tp = parisComponents(tomorrow);
|
||
const tDateStr = `${tp.year}-${pad(tp.month)}-${pad(tp.day)}`;
|
||
|
||
try {
|
||
let url, hourly, timeArr, daily;
|
||
if (diffDays > 1) {
|
||
url = `https://api.open-meteo.com/v1/forecast?latitude=50.633&longitude=3.067`
|
||
+ `&hourly=temperature_2m,cloudcover,precipitation,surface_pressure,windgusts_10m`
|
||
+ `&daily=sunrise,sunset`
|
||
+ `&start_date=${dateStr}&end_date=${tDateStr}&timezone=${encodeURIComponent(TZ)}`;
|
||
const r=await fetch(url); const dd=await r.json();
|
||
hourly=dd.hourly; timeArr=dd.hourly.time; daily=dd.daily;
|
||
weatherTarget.temp = hourly.temperature_2m?.[p.hour] ?? null;
|
||
weatherTarget.cloudcover = hourly.cloudcover?.[p.hour] ?? 0;
|
||
weatherTarget.precip = hourly.precipitation?.[p.hour] ?? 0;
|
||
weatherTarget.pressure = hourly.surface_pressure?.[p.hour] ?? 1013;
|
||
} else if (diffDays < -1) {
|
||
url = `https://archive-api.open-meteo.com/v1/archive?latitude=50.633&longitude=3.067`
|
||
+ `&hourly=temperature_2m,cloudcover,precipitation,surface_pressure,windgusts_10m`
|
||
+ `&daily=sunrise,sunset`
|
||
+ `&start_date=${dateStr}&end_date=${tDateStr}&timezone=${encodeURIComponent(TZ)}`;
|
||
const r=await fetch(url); const dd=await r.json();
|
||
hourly=dd.hourly; timeArr=dd.hourly.time; daily=dd.daily;
|
||
weatherTarget.temp = hourly.temperature_2m?.[p.hour] ?? null;
|
||
weatherTarget.cloudcover = hourly.cloudcover?.[p.hour] ?? 0;
|
||
weatherTarget.precip = hourly.precipitation?.[p.hour] ?? 0;
|
||
weatherTarget.pressure = hourly.surface_pressure?.[p.hour] ?? 1013;
|
||
} else {
|
||
url = `https://api.open-meteo.com/v1/forecast?latitude=50.633&longitude=3.067`
|
||
+ `¤t_weather=true`
|
||
+ `&hourly=temperature_2m,cloudcover,precipitation,surface_pressure,windgusts_10m`
|
||
+ `&daily=sunrise,sunset`
|
||
+ `&forecast_days=3&timezone=${encodeURIComponent(TZ)}`;
|
||
const r=await fetch(url); const dd=await r.json();
|
||
hourly=dd.hourly; timeArr=dd.hourly.time; daily=dd.daily;
|
||
weatherTarget.temp = dd.current_weather?.temperature ?? hourly.temperature_2m?.[p.hour] ?? null;
|
||
weatherTarget.cloudcover = hourly.cloudcover?.[p.hour] ?? 0;
|
||
weatherTarget.precip = hourly.precipitation?.[p.hour] ?? 0;
|
||
weatherTarget.pressure = hourly.surface_pressure?.[p.hour] ?? 1013;
|
||
}
|
||
|
||
// Lever/coucher depuis l'API (format "HH:MM")
|
||
if (daily?.sunrise?.[0]) {
|
||
const sr = daily.sunrise[0].split('T')[1] || daily.sunrise[0];
|
||
const [sh,sm] = sr.split(':').map(Number);
|
||
weatherTarget.sunriseHour = sh + sm/60;
|
||
}
|
||
if (daily?.sunset?.[0]) {
|
||
const ss = daily.sunset[0].split('T')[1] || daily.sunset[0];
|
||
const [sh,sm] = ss.split(':').map(Number);
|
||
weatherTarget.sunsetHour = sh + sm/60;
|
||
}
|
||
|
||
// Forecasts 24h
|
||
const currentTimeStr = `${dateStr}T${pad(p.hour)}:00`;
|
||
const startIdx = timeArr ? timeArr.indexOf(currentTimeStr) : -1;
|
||
if (startIdx >= 0) {
|
||
weatherTarget.precipForecast = Array.from({length:24},(_,i)=>hourly.precipitation?.[startIdx+i]??0);
|
||
weatherTarget.tempForecast = Array.from({length:24},(_,i)=>hourly.temperature_2m?.[startIdx+i]??null);
|
||
weatherTarget.cloudForecast = Array.from({length:24},(_,i)=>hourly.cloudcover?.[startIdx+i]??0);
|
||
weatherTarget.gustForecast = Array.from({length:24},(_,i)=>hourly.windgusts_10m?.[startIdx+i]??0);
|
||
}
|
||
} catch(e) { console.warn('weather fetch failed', e); }
|
||
}
|
||
|
||
// --- Astro & couleurs ---
|
||
const CITIES = [
|
||
{ label:'LIL', lon:3.067, lat:50.63 },
|
||
{ label:'TYO', lon:139.69, lat:35.68 },
|
||
{ label:'NYC', lon:-74.006, lat:40.71 },
|
||
];
|
||
|
||
function solarDeclination(doy) {
|
||
return -23.45*Math.cos((2*Math.PI/365)*(doy+10))*Math.PI/180;
|
||
}
|
||
function dayOfYear(d) {
|
||
return Math.floor((d-new Date(d.getFullYear(),0,0))/86400000);
|
||
}
|
||
function timeColor(sh,alpha) {
|
||
const h=((sh%24)+24)%24;
|
||
let r,g,b;
|
||
if (h<6) {const t=h/6; r=20+t*210; g=30+t*100; b=80-t*30; }
|
||
else if (h<12) {const t=(h-6)/6; r=230-t*30; g=130+t*90; b=50+t*100; }
|
||
else if (h<18) {const t=(h-12)/6; r=200+t*50; g=220-t*90; b=150-t*100;}
|
||
else {const t=(h-18)/6; r=250-t*230; g=130-t*100; b=50+t*30; }
|
||
return `rgba(${Math.round(r)},${Math.round(g)},${Math.round(b)},${alpha})`;
|
||
}
|
||
function tempColor(temp,alpha) {
|
||
if(temp===null)return `rgba(128,128,128,${alpha})`;
|
||
const t=Math.max(0,Math.min(1,(temp+10)/50));
|
||
let r,g,b;
|
||
if(t<0.2) {const u=t/0.2; r=Math.round(120-u*80); g=Math.round(u*20); b=Math.round(180+u*75); }
|
||
else if(t<0.4) {const u=(t-0.2)/0.2; r=Math.round(40-u*40); g=Math.round(20+u*180); b=Math.round(255-u*60); }
|
||
else if(t<0.6) {const u=(t-0.4)/0.2; r=Math.round(u*200); g=Math.round(200+u*55); b=Math.round(195-u*195);}
|
||
else if(t<0.8) {const u=(t-0.6)/0.2; r=Math.round(200+u*40); g=Math.round(255-u*130); b=0; }
|
||
else {const u=(t-0.8)/0.2; r=Math.round(240+u*15); g=Math.round(125-u*105); b=0; }
|
||
return `rgba(${Math.round(r)},${Math.round(g)},${Math.round(b)},${alpha})`;
|
||
}
|
||
|
||
// --- Luminosité ambiante interpolée ---
|
||
let ambientSmooth = 0;
|
||
|
||
// --- Rotation interpolée (pour transition douce au changement de date) ---
|
||
let ringRotSmooth = null; // initialisé au premier draw
|
||
|
||
// --- Draw ---
|
||
function draw() {
|
||
const now = getNow();
|
||
if (isLive) dtInput.value = toInputStr(now);
|
||
fetchWeather(now);
|
||
smoothWeather();
|
||
|
||
const W = canvas.width = window.innerWidth;
|
||
const H = canvas.height = window.innerHeight;
|
||
const size = Math.min(W, H - 160);
|
||
const cx = W/2, cy = H/2;
|
||
|
||
// Heure solaire Lille
|
||
const utcFrac = now.getUTCHours()+now.getUTCMinutes()/60+now.getUTCSeconds()/3600+now.getUTCMilliseconds()/3600000;
|
||
const lilSolarHour = ((utcFrac+CITIES[0].lon/15)%24+24)%24;
|
||
const ringRotTarget = ((lilSolarHour-12)/24)*Math.PI*2;
|
||
|
||
// Interpolation rotation anneau
|
||
if (ringRotSmooth === null) ringRotSmooth = ringRotTarget;
|
||
// Interpoler sur le chemin le plus court (évite rotation 360°)
|
||
let diff = ringRotTarget - ringRotSmooth;
|
||
while (diff > Math.PI) diff -= Math.PI*2;
|
||
while (diff < -Math.PI) diff += Math.PI*2;
|
||
ringRotSmooth += diff * 0.06;
|
||
|
||
const doy = dayOfYear(now);
|
||
const decl = solarDeclination(doy);
|
||
|
||
// Élévation solaire
|
||
const hourAngle = ((lilSolarHour-12)/24)*Math.PI*2;
|
||
const lat = CITIES[0].lat*Math.PI/180;
|
||
const sinElev = Math.sin(lat)*Math.sin(decl)+Math.cos(lat)*Math.cos(decl)*Math.cos(hourAngle);
|
||
const sunElev = Math.max(0, sinElev);
|
||
|
||
// Luminosité : uniquement élévation solaire, pas les nuages
|
||
const ambientTarget = Math.pow(sunElev, 0.5);
|
||
|
||
// Interpolation luminosité (très lente = sunrise/sunset progressif)
|
||
ambientSmooth = lerp(ambientSmooth, ambientTarget, 0.008);
|
||
const bgLum = ambientSmooth;
|
||
|
||
const bgR = Math.round(8 + bgLum*237);
|
||
const bgG = Math.round(8 + bgLum*234);
|
||
const bgB = Math.round(18 + bgLum*217);
|
||
const fgR = bgLum>0.5 ? 25 : 215;
|
||
const fgG = bgLum>0.5 ? 25 : 210;
|
||
const fgB = bgLum>0.5 ? 20 : 200;
|
||
const fgMid = `rgba(${fgR},${fgG},${fgB},0.42)`;
|
||
const fgLow = `rgba(${fgR},${fgG},${fgB},0.13)`;
|
||
|
||
ctx.clearRect(0,0,W,H);
|
||
ctx.fillStyle=`rgb(${bgR},${bgG},${bgB})`;
|
||
ctx.fillRect(0,0,W,H);
|
||
|
||
document.body.style.background=`rgb(${bgR},${bgG},${bgB})`;
|
||
document.body.style.color=bgLum>0.5?'rgba(25,25,20,0.75)':'rgba(215,210,200,0.8)';
|
||
|
||
const p = parisComponents(now);
|
||
const ps = now.getSeconds()+now.getMilliseconds()/1000;
|
||
const pm = p.minute+ps/60;
|
||
const parisHourFrac = p.hour+pm/60;
|
||
|
||
const R_sec=size*0.11, R_min=size*0.18, R_hour=size*0.23;
|
||
const R_weather=size*0.29;
|
||
const R_day=size*0.36, R_sun=size*0.415, R_year=size*0.46;
|
||
const MIDNIGHT=-Math.PI/2;
|
||
|
||
ctx.save();
|
||
ctx.translate(cx,cy);
|
||
|
||
// Pression centrale
|
||
const pressNorm = Math.max(0,Math.min(1,(weatherSmooth.pressure-970)/70));
|
||
const R_pressure = R_sec*(0.3+pressNorm*0.55);
|
||
ctx.beginPath();ctx.arc(0,0,R_pressure,0,Math.PI*2);
|
||
ctx.strokeStyle=`rgba(${fgR},${fgG},${fgB},0.18)`;
|
||
ctx.lineWidth=size*0.008;ctx.stroke();
|
||
|
||
// Orbite annuelle
|
||
ctx.beginPath();ctx.arc(0,0,R_year,0,Math.PI*2);
|
||
ctx.strokeStyle=fgLow;ctx.lineWidth=size*0.0015;ctx.stroke();
|
||
|
||
const yearFrac=(doy-3)/365;
|
||
const earthAngle=yearFrac*Math.PI*2-Math.PI/2;
|
||
ctx.beginPath();
|
||
ctx.arc(Math.cos(earthAngle)*R_year,Math.sin(earthAngle)*R_year,size*0.011,0,Math.PI*2);
|
||
ctx.fillStyle=tempColor(weatherSmooth.temp,0.9);ctx.fill();
|
||
|
||
const dim=[31,28,31,30,31,30,31,31,30,31,30,31];
|
||
let cumDay=0;
|
||
for(let m=0;m<12;m++){
|
||
const a=((cumDay-3)/365)*Math.PI*2-Math.PI/2, len=size*0.013;
|
||
ctx.beginPath();
|
||
ctx.moveTo(Math.cos(a)*(R_year-len),Math.sin(a)*(R_year-len));
|
||
ctx.lineTo(Math.cos(a)*(R_year+len),Math.sin(a)*(R_year+len));
|
||
ctx.strokeStyle=fgLow;ctx.lineWidth=size*0.0015;ctx.stroke();
|
||
cumDay+=dim[m];
|
||
}
|
||
|
||
// Soleil fixe en haut
|
||
ctx.beginPath();ctx.arc(0,-R_sun,size*0.011,0,Math.PI*2);
|
||
ctx.fillStyle=bgLum>0.5?'rgba(30,25,10,0.9)':'rgba(255,235,150,0.95)';
|
||
ctx.fill();
|
||
|
||
// Arc nuit FIXE
|
||
// lever/coucher sont en heure Paris locale
|
||
// L'anneau est orienté sur le temps SOLAIRE de Lille (midi solaire = haut)
|
||
// Conversion : heure solaire Lille = heure Paris + (lon_Lille/15 - offset_Paris)
|
||
// Offset Paris en heures depuis UTC
|
||
const parisOffsetH = getParisOffsetMs(now) / 3600000;
|
||
const lilleOffsetSolar = CITIES[0].lon / 15; // offset solaire Lille depuis UTC
|
||
// heure solaire Lille = heure Paris - parisOffsetH + lilleOffsetSolar
|
||
const parisToSolar = lilleOffsetSolar - parisOffsetH;
|
||
const srSolar = weatherSmooth.sunriseHour + parisToSolar;
|
||
const ssSolar = weatherSmooth.sunsetHour + parisToSolar;
|
||
// Sur l'anneau : midi solaire = haut (-PI/2), 1h = 2PI/24
|
||
const angleFromSolar = h => -Math.PI/2 + ((h - 12) / 24) * Math.PI*2;
|
||
const nEnd = angleFromSolar(srSolar); // lever = fin de nuit
|
||
const nStart = angleFromSolar(ssSolar); // coucher = début de nuit
|
||
ctx.beginPath();ctx.arc(0,0,R_day,nStart,nEnd);
|
||
ctx.strokeStyle=bgLum>0.5?'rgba(20,20,40,0.07)':'rgba(20,30,70,0.55)';
|
||
ctx.lineWidth=size*0.048;ctx.stroke();
|
||
|
||
// --- Anneau jour (rotation) : ticks + villes uniquement ---
|
||
ctx.save();
|
||
ctx.rotate(ringRotSmooth);
|
||
|
||
ctx.beginPath();ctx.arc(0,0,R_day,0,Math.PI*2);
|
||
ctx.strokeStyle=fgMid;ctx.lineWidth=size*0.002;ctx.stroke();
|
||
|
||
for(let h=0;h<24;h++){
|
||
const a=(h/24)*Math.PI*2-Math.PI/2, isMain=h%6===0, len=isMain?size*0.020:size*0.009;
|
||
ctx.beginPath();
|
||
ctx.moveTo(Math.cos(a)*(R_day-len),Math.sin(a)*(R_day-len));
|
||
ctx.lineTo(Math.cos(a)*(R_day+len),Math.sin(a)*(R_day+len));
|
||
ctx.strokeStyle=isMain?fgMid:fgLow;ctx.lineWidth=size*0.0015;ctx.stroke();
|
||
}
|
||
|
||
for(const city of CITIES){
|
||
const lonDiff=city.lon-CITIES[0].lon;
|
||
const a=(lonDiff/360)*Math.PI*2-Math.PI/2;
|
||
const citySolarHour=lilSolarHour+lonDiff/15;
|
||
const px=Math.cos(a)*R_day, py=Math.sin(a)*R_day;
|
||
ctx.beginPath();ctx.arc(px,py,size*0.010,0,Math.PI*2);
|
||
ctx.fillStyle=timeColor(citySolarHour,0.92);ctx.fill();
|
||
ctx.beginPath();ctx.arc(px,py,size*0.010,0,Math.PI*2);
|
||
ctx.strokeStyle=fgMid;ctx.lineWidth=size*0.0015;ctx.stroke();
|
||
const lx=Math.cos(a)*(R_day+size*0.050), ly=Math.sin(a)*(R_day+size*0.050);
|
||
ctx.save();
|
||
ctx.translate(lx,ly);ctx.rotate(-ringRotSmooth);
|
||
ctx.font=`${size*0.022}px 'Courier New',monospace`;
|
||
ctx.fillStyle=fgMid;ctx.textAlign='center';ctx.textBaseline='middle';
|
||
ctx.fillText(city.label,0,0);
|
||
ctx.restore();
|
||
}
|
||
|
||
ctx.restore(); // fin rotation
|
||
|
||
// --- Anneau météo fixe ---
|
||
if(weatherSmooth.tempForecast.length > 0 || weatherSmooth.precipForecast.length > 0){
|
||
const maxPrecip = Math.max(...weatherSmooth.precipForecast, 0.1);
|
||
|
||
for(let h=0; h<24; h++){
|
||
const aStart=MIDNIGHT+(h/24)*Math.PI*2;
|
||
const aEnd =MIDNIGHT+((h+1)/24)*Math.PI*2;
|
||
|
||
// Température
|
||
const temp=weatherSmooth.tempForecast[h]??null;
|
||
if(temp!==null){
|
||
ctx.beginPath();ctx.arc(0,0,R_weather,aStart,aEnd);
|
||
ctx.strokeStyle=tempColor(temp,0.75);
|
||
ctx.lineWidth=size*0.018;ctx.lineCap='butt';ctx.stroke();
|
||
}
|
||
|
||
// Nuages
|
||
const cloud=weatherSmooth.cloudForecast[h]??0;
|
||
if(cloud>0){
|
||
ctx.beginPath();ctx.arc(0,0,R_weather,aStart,aEnd);
|
||
ctx.strokeStyle=bgLum>0.5
|
||
?`rgba(90,95,110,${cloud/100*0.25})`
|
||
:`rgba(160,165,180,${cloud/100*0.45})`;
|
||
ctx.lineWidth=size*0.018;ctx.lineCap='butt';ctx.stroke();
|
||
}
|
||
|
||
// Précipitations (vers l'extérieur)
|
||
const mm=weatherSmooth.precipForecast[h]??0;
|
||
if(mm>0){
|
||
const intensity=Math.min(1,mm/Math.max(maxPrecip,2));
|
||
const rainWidth=size*(0.003+intensity*0.010);
|
||
const rainR=R_weather+size*0.014+rainWidth/2;
|
||
ctx.beginPath();ctx.arc(0,0,rainR,aStart,aEnd);
|
||
ctx.strokeStyle=`rgba(80,150,230,${0.25+intensity*0.55})`;
|
||
ctx.lineWidth=rainWidth;ctx.lineCap='butt';ctx.stroke();
|
||
}
|
||
|
||
// Rafales (vers l'intérieur, couleur + épaisseur)
|
||
const gust=weatherSmooth.gustForecast[h]??0;
|
||
if(gust>30){
|
||
const gInt=Math.min(1,gust/80);
|
||
const gustWidth=size*(0.003+gInt*0.010);
|
||
const gustR=R_weather-size*0.014-gustWidth/2;
|
||
let gr,gg,gb;
|
||
if(gInt<0.375) {const t=gInt/0.375; gr=Math.round(80+t*175); gg=Math.round(200-t*30); gb=Math.round(40-t*40);}
|
||
else if(gInt<0.625) {const t=(gInt-0.375)/0.25; gr=255; gg=Math.round(170-t*100); gb=0;}
|
||
else if(gInt<0.875) {const t=(gInt-0.625)/0.25; gr=255; gg=Math.round(70-t*70); gb=0;}
|
||
else {gr=220;gg=20;gb=20;}
|
||
ctx.beginPath();ctx.arc(0,0,gustR,aStart,aEnd);
|
||
ctx.strokeStyle=`rgba(${gr},${gg},${gb},${0.3+gInt*0.6})`;
|
||
ctx.lineWidth=gustWidth;ctx.lineCap='butt';ctx.stroke();
|
||
}
|
||
}
|
||
|
||
ctx.beginPath();ctx.arc(0,0,R_weather,0,Math.PI*2);
|
||
ctx.strokeStyle=fgLow;ctx.lineWidth=size*0.001;ctx.stroke();
|
||
}
|
||
|
||
// Anneau heure 24h
|
||
ctx.beginPath();ctx.arc(0,0,R_hour,0,Math.PI*2);
|
||
ctx.strokeStyle=fgLow;ctx.lineWidth=size*0.001;ctx.stroke();
|
||
for(let h=0;h<24;h++){
|
||
const a=(h/24)*Math.PI*2+MIDNIGHT, isMain=h%6===0, len=isMain?size*0.016:size*0.008;
|
||
ctx.beginPath();
|
||
ctx.moveTo(Math.cos(a)*(R_hour-len),Math.sin(a)*(R_hour-len));
|
||
ctx.lineTo(Math.cos(a)*(R_hour+len),Math.sin(a)*(R_hour+len));
|
||
ctx.strokeStyle=isMain?fgMid:fgLow;ctx.lineWidth=size*0.0012;ctx.stroke();
|
||
}
|
||
const hourEnd=MIDNIGHT+(parisHourFrac/24)*Math.PI*2;
|
||
ctx.beginPath();ctx.arc(0,0,R_hour,MIDNIGHT,hourEnd);
|
||
ctx.strokeStyle=timeColor(lilSolarHour,0.75);
|
||
ctx.lineWidth=size*0.007;ctx.lineCap='round';ctx.stroke();
|
||
|
||
// Arc minutes
|
||
ctx.beginPath();ctx.arc(0,0,R_min,0,Math.PI*2);
|
||
ctx.strokeStyle=fgLow;ctx.lineWidth=size*0.001;ctx.stroke();
|
||
const minEnd=MIDNIGHT+(pm/60)*Math.PI*2;
|
||
ctx.beginPath();ctx.arc(0,0,R_min,MIDNIGHT,minEnd);
|
||
ctx.strokeStyle=timeColor(lilSolarHour,0.85);
|
||
ctx.lineWidth=size*0.008;ctx.lineCap='round';ctx.stroke();
|
||
|
||
// Arc secondes
|
||
ctx.beginPath();ctx.arc(0,0,R_sec,0,Math.PI*2);
|
||
ctx.strokeStyle=fgLow;ctx.lineWidth=size*0.001;ctx.stroke();
|
||
const secEnd=MIDNIGHT+(ps/60)*Math.PI*2;
|
||
ctx.beginPath();ctx.arc(0,0,R_sec,MIDNIGHT,secEnd);
|
||
ctx.strokeStyle=fgMid;ctx.lineWidth=size*0.005;ctx.lineCap='round';ctx.stroke();
|
||
|
||
ctx.restore();
|
||
requestAnimationFrame(draw);
|
||
}
|
||
|
||
draw();
|
||
window.addEventListener('resize', draw);
|
||
</script>
|
||
</body>
|
||
</html>
|