homepage/clock-lille.html
Nathan Leclercq f795cc48b5 Homepage dashboard : horloge animée, todo, notes, RSS, pomodoro, recherche
- 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é)
2026-04-09 12:49:55 +02:00

718 lines
28 KiB
HTML
Raw Permalink 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.

<!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`
+ `&current_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>