Root
>
Sunlight.html
Download
Delete
Sunlight.html
38,4 KB • HTML • jun. 18, 2026 06:54
Render
Source
Live Edit
Copy
Save
<!doctype html> <html lang="en"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>Sandbox Sunlight Widget</title> <style> :root { --bg: #080b12; --panel: #111827; --panel-soft: #172033; --panel-strong: #1f2937; --text: #f9fafb; --muted: #aab4c5; --line: rgba(255, 255, 255, 0.12); --sun: #ffd166; --uv-low: #67d391; --uv-mid: #f4c542; --uv-high: #f97316; --uv-very: #ef4444; --uv-extreme: #a855f7; --accent: var(--sun); --error: #fca5a5; --shadow: rgba(0, 0, 0, 0.38); font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } * { box-sizing: border-box; } html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; color: var(--text); background: var(--bg); } body { min-height: 100svh; } button, input { font: inherit; } button { cursor: pointer; } .app { position: relative; width: 100vw; height: 100svh; min-height: 560px; overflow: hidden; background: var(--bg); isolation: isolate; } .sky { position: absolute; inset: 0; overflow: hidden; pointer-events: none; z-index: 0; } .star { position: absolute; width: 3px; height: 3px; border-radius: 999px; background: rgba(255, 255, 255, 0.62); animation: twinkle 3.8s ease-in-out infinite; } .ray { position: absolute; width: 2px; height: 120px; border-radius: 999px; background: rgba(255, 209, 102, 0.2); animation: rayDrift linear infinite; } @keyframes twinkle { 0%, 100% { opacity: 0.18; transform: scale(0.75); } 50% { opacity: 0.85; transform: scale(1.25); } } @keyframes rayDrift { from { transform: translate3d(0, 120vh, 0) rotate(18deg); } to { transform: translate3d(26vw, -30vh, 0) rotate(18deg); } } .stage { position: relative; z-index: 1; width: 100%; height: 100%; padding: clamp(18px, 3vw, 42px); display: grid; grid-template-rows: auto 1fr auto; gap: 22px; } .topbar { display: flex; justify-content: space-between; align-items: flex-start; gap: 20px; z-index: 5; } .eyebrow { color: var(--muted); font-size: 0.78rem; font-weight: 850; text-transform: uppercase; letter-spacing: 0.14em; } .city { margin-top: 7px; font-size: clamp(1.8rem, 4.2vw, 4rem); font-weight: 950; line-height: 0.92; letter-spacing: -0.07em; max-width: 900px; } .updated { margin-top: 10px; color: var(--muted); font-size: 0.95rem; } .updated.error { color: var(--error); } .settings { position: relative; z-index: 20; } .gear { width: 50px; height: 50px; display: grid; place-items: center; border: 1px solid var(--line); border-radius: 17px; color: var(--text); background: rgba(17, 24, 39, 0.9); box-shadow: 0 18px 52px var(--shadow); } .gear svg { width: 23px; height: 23px; } .settings-panel { position: absolute; top: 62px; right: 0; width: min(370px, calc(100vw - 36px)); padding: 16px; border: 1px solid var(--line); border-radius: 24px; background: rgba(17, 24, 39, 0.97); box-shadow: 0 24px 80px var(--shadow); } .settings-panel[hidden] { display: none; } .settings-title { font-weight: 950; letter-spacing: -0.03em; margin-bottom: 8px; } .settings-copy { color: var(--muted); font-size: 0.88rem; line-height: 1.4; margin-bottom: 14px; } .search { display: flex; gap: 8px; } .search input { min-width: 0; flex: 1; color: var(--text); background: var(--panel-soft); border: 1px solid var(--line); border-radius: 15px; padding: 11px 12px; outline: none; } .search input:focus { border-color: var(--accent); } .search button { border: 0; border-radius: 15px; padding: 0 16px; color: #12100a; background: var(--accent); font-weight: 950; } .hero { min-height: 0; display: grid; grid-template-columns: minmax(320px, 1fr) minmax(280px, 0.86fr); gap: clamp(24px, 5vw, 74px); align-items: center; } .sun-panel { display: grid; gap: 22px; min-width: 0; } .orb-wrap { position: relative; width: clamp(300px, 43vw, 620px); height: clamp(250px, 34vw, 470px); border: 1px solid var(--line); border-radius: 34px; background: rgba(17, 24, 39, 0.78); box-shadow: 0 28px 90px var(--shadow); overflow: hidden; } .arc { position: absolute; left: 7%; right: 7%; bottom: 18%; height: 64%; border-top: clamp(4px, 0.8vw, 8px) solid rgba(255, 255, 255, 0.16); border-radius: 999px 999px 0 0; } .horizon { position: absolute; left: 7%; right: 7%; bottom: 18%; height: 1px; background: var(--line); } .orb { position: absolute; width: clamp(76px, 9vw, 128px); height: clamp(76px, 9vw, 128px); border-radius: 50%; background: var(--accent); box-shadow: 0 0 58px rgba(255, 209, 102, 0.35); display: grid; place-items: center; transition: left 300ms ease, top 300ms ease; } .orb svg { width: 54%; height: 54%; color: #12100a; } .marker { position: absolute; bottom: 12%; color: var(--muted); font-size: 0.82rem; font-weight: 800; transform: translateX(-50%); text-align: center; white-space: nowrap; } .marker::before { content: ""; display: block; width: 1px; height: 18px; background: var(--line); margin: 0 auto 7px; } .marker.sunrise { left: 12%; } .marker.noon { left: 50%; } .marker.sunset { left: 88%; } .sun-copy { display: flex; gap: 14px; align-items: center; max-width: 680px; } .sun-dot { width: 16px; height: 16px; border-radius: 999px; background: var(--accent); flex: 0 0 auto; } .sun-title { font-size: clamp(1.7rem, 3.3vw, 3rem); font-weight: 950; letter-spacing: -0.06em; line-height: 0.95; } .sun-note { margin-top: 8px; color: var(--muted); line-height: 1.45; max-width: 620px; } .metrics { display: grid; grid-template-columns: repeat(2, minmax(150px, 1fr)); gap: 13px; min-width: 0; } .metric { min-height: 132px; border: 1px solid var(--line); border-radius: 27px; background: rgba(17, 24, 39, 0.78); padding: 18px; box-shadow: 0 18px 52px var(--shadow); overflow: hidden; } .metric-top { display: flex; justify-content: space-between; align-items: center; gap: 10px; color: var(--muted); font-size: 0.76rem; font-weight: 850; text-transform: uppercase; letter-spacing: 0.11em; margin-bottom: 12px; } .metric-icon { width: 30px; height: 30px; color: var(--accent); } .metric-value { font-size: clamp(1.8rem, 3.2vw, 2.9rem); font-weight: 950; line-height: 1; letter-spacing: -0.06em; } .metric-unit { color: var(--muted); margin-top: 8px; font-size: 0.9rem; } .bottom { display: grid; grid-template-columns: 1.15fr 0.85fr; gap: 14px; min-height: 162px; } .card { border: 1px solid var(--line); border-radius: 29px; background: rgba(17, 24, 39, 0.82); box-shadow: 0 18px 52px var(--shadow); padding: 16px; min-width: 0; } .card-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 13px; } .card-title { font-weight: 950; letter-spacing: -0.04em; } .card-note { color: var(--muted); font-size: 0.86rem; text-align: right; } .timeline { display: grid; grid-template-columns: repeat(10, minmax(64px, 1fr)); gap: 9px; overflow: auto; padding-bottom: 3px; } .hour { min-width: 64px; background: var(--panel-soft); border: 1px solid var(--line); border-radius: 18px; padding: 10px 8px; text-align: center; } .hour-time { color: var(--muted); font-size: 0.74rem; font-weight: 850; } .bar-wrap { height: 64px; margin: 10px auto; width: 12px; border-radius: 999px; background: rgba(255, 255, 255, 0.08); display: flex; align-items: flex-end; overflow: hidden; } .bar { width: 100%; min-height: 4px; border-radius: 999px; background: var(--accent); } .hour-value { font-weight: 900; font-size: 0.92rem; } .days { display: grid; gap: 9px; } .day { display: grid; grid-template-columns: 48px 1fr auto; align-items: center; gap: 10px; background: var(--panel-soft); border: 1px solid var(--line); border-radius: 18px; padding: 10px; } .day-name { color: var(--muted); font-weight: 850; font-size: 0.82rem; } .day-main { min-width: 0; } .day-label { font-weight: 900; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .day-sub { color: var(--muted); font-size: 0.78rem; margin-top: 3px; } .day-uv { font-weight: 950; color: var(--accent); } @media (max-width: 980px) { .app { overflow: auto; } .stage { min-height: 100svh; height: auto; } .hero { grid-template-columns: 1fr; align-items: start; } .orb-wrap { width: 100%; } .metrics { grid-template-columns: repeat(4, minmax(150px, 1fr)); overflow: auto; padding-bottom: 4px; } .bottom { grid-template-columns: 1fr; } } @media (max-width: 680px) { .stage { gap: 18px; } .city { font-size: 2rem; } .metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } .metric { min-height: 112px; padding: 14px; } .timeline { grid-template-columns: repeat(10, 68px); } .marker { font-size: 0.72rem; } .app { overflow: hidden !important; } } </style> </head> <body> <main class="app" style="width: 100%"> <div id="sky" class="sky"></div> <section class="stage" aria-live="polite"> <header class="topbar"> <div> <div class="eyebrow">Sunlight tracker</div> <div id="cityName" class="city">Copenhagen, Denmark</div> <div id="updated" class="updated">Loading sunlight data...</div> </div> <div class="settings"> <button id="settingsToggle" class="gear" type="button" aria-label="Open city search" aria-expanded="false" aria-controls="settingsPanel"> <svg viewBox="0 0 24 24" fill="none" aria-hidden="true"> <path d="M12 15.5A3.5 3.5 0 1 0 12 8a3.5 3.5 0 0 0 0 7.5Z" stroke="currentColor" stroke-width="1.8" /> <path d="M19.4 15a1.8 1.8 0 0 0 .36 1.98l.05.05a2.1 2.1 0 0 1-2.97 2.97l-.05-.05a1.8 1.8 0 0 0-1.98-.36 1.8 1.8 0 0 0-1.1 1.65V21.4a2.1 2.1 0 0 1-4.2 0v-.08a1.8 1.8 0 0 0-1.1-1.65 1.8 1.8 0 0 0-1.98.36l-.05.05a2.1 2.1 0 0 1-2.97-2.97l.05-.05A1.8 1.8 0 0 0 4.6 15a1.8 1.8 0 0 0-1.65-1.1H2.9a2.1 2.1 0 0 1 0-4.2h.08A1.8 1.8 0 0 0 4.6 8a1.8 1.8 0 0 0-.36-1.98l-.05-.05A2.1 2.1 0 0 1 7.16 3l.05.05A1.8 1.8 0 0 0 9.2 3.4 1.8 1.8 0 0 0 10.3 1.75V1.7a2.1 2.1 0 0 1 4.2 0v.08A1.8 1.8 0 0 0 15.6 3.4a1.8 1.8 0 0 0 1.98-.36l.05-.05A2.1 2.1 0 0 1 20.6 6l-.05.05A1.8 1.8 0 0 0 20.2 8c.26.66.9 1.1 1.6 1.1h.1a2.1 2.1 0 0 1 0 4.2h-.08A1.8 1.8 0 0 0 19.4 15Z" stroke="currentColor" stroke-width="1.25" /> </svg> </button> <div id="settingsPanel" class="settings-panel" hidden> <div class="settings-title">Change city</div> <div class="settings-copy"> </div> <div class="search"> <input id="cityInput" type="search" autocomplete="off" placeholder="Search city" inputmode="search" /> <button id="citySearchButton" type="button">Go</button> </div> </div> </div> </header> <section class="hero"> <div class="sun-panel"> <div class="orb-wrap"> <div class="arc"></div> <div class="horizon"></div> <div id="orb" class="orb"> <svg viewBox="0 0 48 48" fill="none" aria-hidden="true"> <circle cx="24" cy="24" r="8" stroke="currentColor" stroke-width="4" /> <path d="M24 4v7M24 37v7M4 24h7M37 24h7M10 10l5 5M33 33l5 5M38 10l-5 5M15 33l-5 5" stroke="currentColor" stroke-width="4" stroke-linecap="round" /> </svg> </div> <div class="marker sunrise"> <div id="sunrise">--:--</div> <div>Rise</div> </div> <div class="marker noon"> <div id="solarNoon">--:--</div> <div>Noon</div> </div> <div class="marker sunset"> <div id="sunset">--:--</div> <div>Set</div> </div> </div> <div class="sun-copy"> <div class="sun-dot"></div> <div> <div id="sunTitle" class="sun-title">Loading sunlight</div> <div id="sunNote" class="sun-note">Fetching sunrise, sunset, UV index, and daylight length.</div> </div> </div> </div> <div class="metrics"> <article class="metric"> <div class="metric-top"> <span>UV max</span> <span class="metric-icon" aria-hidden="true"> <svg viewBox="0 0 48 48" fill="none"> <circle cx="24" cy="24" r="8" stroke="currentColor" stroke-width="4" /> <path d="M24 4v7M24 37v7M4 24h7M37 24h7M10 10l5 5M33 33l5 5M38 10l-5 5M15 33l-5 5" stroke="currentColor" stroke-width="4" stroke-linecap="round" /> </svg> </span> </div> <div id="uvMax" class="metric-value">--</div> <div id="uvLabel" class="metric-unit">UV level</div> </article> <article class="metric"> <div class="metric-top"> <span>Daylight</span> <span class="metric-icon" aria-hidden="true"> <svg viewBox="0 0 48 48" fill="none"> <path d="M8 34h32" stroke="currentColor" stroke-width="4" stroke-linecap="round" /> <path d="M14 34a10 10 0 0 1 20 0" stroke="currentColor" stroke-width="4" /> <path d="M24 8v8M10 18l6 6M38 18l-6 6" stroke="currentColor" stroke-width="4" stroke-linecap="round" /> </svg> </span> </div> <div id="daylight" class="metric-value">--</div> <div class="metric-unit">Between sunrise and sunset</div> </article> <article class="metric"> <div class="metric-top"> <span>Sunshine</span> <span class="metric-icon" aria-hidden="true"> <svg viewBox="0 0 48 48" fill="none"> <path d="M8 30c8-9 24-9 32 0" stroke="currentColor" stroke-width="4" stroke-linecap="round" /> <path d="M12 38h24" stroke="currentColor" stroke-width="4" stroke-linecap="round" /> <circle cx="24" cy="17" r="6" stroke="currentColor" stroke-width="4" /> </svg> </span> </div> <div id="sunshine" class="metric-value">--</div> <div class="metric-unit">Forecast bright-sun duration</div> </article> <article class="metric"> <div class="metric-top"> <span>Clouds now</span> <span class="metric-icon" aria-hidden="true"> <svg viewBox="0 0 48 48" fill="none"> <path d="M15 33h21c5 0 9-4 9-9s-4-9-9-9c-2-7-9-11-17-9-6 2-11 8-11 15-5 1-8 5-8 10 0 6 5 2 15 2Z" transform="translate(2 4)" stroke="currentColor" stroke-width="4" stroke-linejoin="round" /> </svg> </span> </div> <div id="cloudCover" class="metric-value">--%</div> <div class="metric-unit">Current cloud cover</div> </article> </div> </section> <section class="bottom"> <article class="card"> <div class="card-head"> <div class="card-title">UV next hours</div> <div id="timelineNote" class="card-note">Hourly forecast</div> </div> <div id="timeline" class="timeline"></div> </article> <article class="card"> <div class="card-head"> <div class="card-title">Next days</div> <div id="daysNote" class="card-note">UV max + daylight</div> </div> <div id="days" class="days"></div> </article> </section> </section> </main> <script> // Sandbox-safe: // - no localStorage // - no sessionStorage // - no IndexedDB // - no cookies // - no geolocation // - no forms // - no external images, CSS, fonts, or libraries const DEFAULT_CITY = { name: "Copenhagen, Denmark", latitude: 55.6761, longitude: 12.5683 }; const els = { sky: document.getElementById("sky"), cityName: document.getElementById("cityName"), updated: document.getElementById("updated"), orb: document.getElementById("orb"), sunrise: document.getElementById("sunrise"), solarNoon: document.getElementById("solarNoon"), sunset: document.getElementById("sunset"), sunTitle: document.getElementById("sunTitle"), sunNote: document.getElementById("sunNote"), uvMax: document.getElementById("uvMax"), uvLabel: document.getElementById("uvLabel"), daylight: document.getElementById("daylight"), sunshine: document.getElementById("sunshine"), cloudCover: document.getElementById("cloudCover"), timeline: document.getElementById("timeline"), timelineNote: document.getElementById("timelineNote"), days: document.getElementById("days"), settingsToggle: document.getElementById("settingsToggle"), settingsPanel: document.getElementById("settingsPanel"), cityInput: document.getElementById("cityInput"), citySearchButton: document.getElementById("citySearchButton") }; function setStatus(text, isError = false) { els.updated.textContent = text; els.updated.classList.toggle("error", isError); } function setLoading(isLoading) { els.citySearchButton.disabled = isLoading; els.settingsToggle.disabled = false; } function fmtHour(timeString) { const date = new Date(timeString); if (Number.isNaN(date.getTime())) return "--:--"; return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); } function fmtDay(timeString) { const date = new Date(timeString); if (Number.isNaN(date.getTime())) return "--"; return date.toLocaleDateString([], { weekday: "short" }); } function fmtUv(value) { if (!Number.isFinite(value)) return "--"; return value.toFixed(value < 10 ? 1 : 0); } function fmtPercent(value) { if (!Number.isFinite(value)) return "--%"; return `${Math.round(value)}%`; } function fmtDurationFromSeconds(seconds) { if (!Number.isFinite(seconds)) return "--"; const totalMinutes = Math.max(0, Math.round(seconds / 60)); const hours = Math.floor(totalMinutes / 60); const minutes = totalMinutes % 60; return `${hours}h ${String(minutes).padStart(2, "0")}m`; } function secondsBetween(start, end) { const startMs = new Date(start).getTime(); const endMs = new Date(end).getTime(); if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) return NaN; return Math.max(0, (endMs - startMs) / 1000); } function midpointTime(start, end) { const startMs = new Date(start).getTime(); const endMs = new Date(end).getTime(); if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) return null; return new Date((startMs + endMs) / 2).toISOString(); } function findCurrentIndex(times) { if (!Array.isArray(times) || !times.length) return 0; const now = Date.now(); let bestIndex = 0; let bestDelta = Infinity; times.forEach((time, index) => { const parsed = new Date(time).getTime(); if (!Number.isFinite(parsed)) return; const delta = Math.abs(parsed - now); if (delta < bestDelta) { bestDelta = delta; bestIndex = index; } }); return bestIndex; } function getUvLevel(uv) { if (!Number.isFinite(uv)) { return { label: "Unknown", color: "var(--sun)", advice: "UV data is unavailable for this time." }; } if (uv < 3) { return { label: "Low", color: "var(--uv-low)", advice: "Low UV. Basic sun protection is usually enough." }; } if (uv < 6) { return { label: "Moderate", color: "var(--uv-mid)", advice: "Moderate UV. Use sunscreen and sunglasses if outside for long." }; } if (uv < 8) { return { label: "High", color: "var(--uv-high)", advice: "High UV. Seek shade around midday and use sun protection." }; } if (uv < 11) { return { label: "Very high", color: "var(--uv-very)", advice: "Very high UV. Avoid prolonged midday exposure." }; } return { label: "Extreme", color: "var(--uv-extreme)", advice: "Extreme UV. Minimize exposure and use full protection." }; } function setAccent(color) { document.documentElement.style.setProperty("--accent", color); } function renderAtmosphere(isDay, uv) { els.sky.textContent = ""; if (isDay) { const rayCount = Math.min(44, Math.max(14, Math.round((uv || 2) * 4))); for (let i = 0; i < rayCount; i += 1) { const ray = document.createElement("span"); ray.className = "ray"; ray.style.left = `${Math.random() * 100}%`; ray.style.top = `${Math.random() * 100}%`; ray.style.animationDuration = `${18 + Math.random() * 26}s`; ray.style.animationDelay = `${Math.random() * -30}s`; ray.style.opacity = `${0.08 + Math.random() * 0.18}`; els.sky.appendChild(ray); } return; } for (let i = 0; i < 90; i += 1) { const star = document.createElement("span"); star.className = "star"; star.style.left = `${Math.random() * 100}%`; star.style.top = `${Math.random() * 100}%`; star.style.animationDuration = `${2.4 + Math.random() * 4}s`; star.style.animationDelay = `${Math.random() * -5}s`; els.sky.appendChild(star); } } function setOrbPosition(sunrise, sunset) { const rise = new Date(sunrise).getTime(); const set = new Date(sunset).getTime(); const now = Date.now(); let progress = 0.5; let visible = true; if (Number.isFinite(rise) && Number.isFinite(set) && set > rise) { progress = (now - rise) / (set - rise); if (progress < 0 || progress > 1) { visible = false; progress = Math.max(0, Math.min(1, progress)); } } const x = 7 + progress * 86; const y = 74 - Math.sin(progress * Math.PI) * 52; els.orb.style.left = `calc(${x}% - var(--orb-half, 55px))`; els.orb.style.top = `calc(${y}% - var(--orb-half, 55px))`; els.orb.style.opacity = visible ? "1" : "0.28"; } function renderTimeline(hourly, startIndex) { els.timeline.textContent = ""; const times = hourly.time || []; const uvValues = hourly.uv_index || []; const radiationValues = hourly.shortwave_radiation || []; const end = Math.min(startIndex + 10, times.length); const maxUv = Math.max(8, ...uvValues.slice(startIndex, end).filter(Number.isFinite)); for (let index = startIndex; index < end; index += 1) { const uv = uvValues[index]; const radiation = radiationValues[index]; const level = getUvLevel(uv); const height = Math.max(4, Math.min(100, ((uv || 0) / maxUv) * 100)); const item = document.createElement("div"); item.className = "hour"; item.innerHTML = ` <div class="hour-time">${index === startIndex ? "Now" : fmtHour(times[index])}</div> <div class="bar-wrap"> <div class="bar" style="height: ${height}%; background: ${level.color};" aria-hidden="true" ></div> </div> <div class="hour-value">${fmtUv(uv)}</div> <div class="hour-time">${Number.isFinite(radiation) ? Math.round(radiation) + " W" : "--"}</div> `; els.timeline.appendChild(item); } els.timelineNote.textContent = "UV index + solar W/m²"; } function renderDays(daily) { els.days.textContent = ""; const times = daily.time || []; times.slice(0, 5).forEach((time, index) => { const uv = daily.uv_index_max?.[index]; const level = getUvLevel(uv); const daylightSeconds = Number.isFinite(daily.daylight_duration?.[index]) ? daily.daylight_duration[index] : secondsBetween(daily.sunrise?.[index], daily.sunset?.[index]); const row = document.createElement("div"); row.className = "day"; row.innerHTML = ` <div class="day-name">${index === 0 ? "Today" : fmtDay(time)}</div> <div class="day-main"> <div class="day-label">${level.label}</div> <div class="day-sub">${fmtDurationFromSeconds(daylightSeconds)} daylight</div> </div> <div class="day-uv" style="color: ${level.color};">${fmtUv(uv)}</div> `; els.days.appendChild(row); }); } function renderSunlight(place, data) { const daily = data.daily; const hourly = data.hourly; if (!daily || !daily.time || !daily.time.length || !hourly || !hourly.time) { throw new Error("Sunlight data unavailable."); } const currentIndex = findCurrentIndex(hourly.time); const currentUv = hourly.uv_index?.[currentIndex]; const currentClouds = hourly.cloud_cover?.[currentIndex]; const isDay = hourly.is_day?.[currentIndex] !== 0; const todaySunrise = daily.sunrise?.[0]; const todaySunset = daily.sunset?.[0]; const noon = midpointTime(todaySunrise, todaySunset); const uvMax = daily.uv_index_max?.[0]; const uvLevel = getUvLevel(uvMax); const daylightSeconds = Number.isFinite(daily.daylight_duration?.[0]) ? daily.daylight_duration[0] : secondsBetween(todaySunrise, todaySunset); const sunshineSeconds = Number.isFinite(daily.sunshine_duration?.[0]) ? daily.sunshine_duration[0] : NaN; setAccent(uvLevel.color); els.cityName.textContent = place.name; els.sunrise.textContent = fmtHour(todaySunrise); els.solarNoon.textContent = fmtHour(noon); els.sunset.textContent = fmtHour(todaySunset); els.sunTitle.textContent = isDay ? "Sun is above the horizon" : "Sun is below the horizon"; els.sunNote.textContent = uvLevel.advice; els.uvMax.textContent = fmtUv(uvMax); els.uvLabel.textContent = `${uvLevel.label} UV maximum today`; els.daylight.textContent = fmtDurationFromSeconds(daylightSeconds); els.sunshine.textContent = fmtDurationFromSeconds(sunshineSeconds); els.cloudCover.textContent = fmtPercent(currentClouds); renderTimeline(hourly, currentIndex); renderDays(daily); renderAtmosphere(isDay, currentUv); setOrbPosition(todaySunrise, todaySunset); setStatus(`Updated ${fmtHour(hourly.time[currentIndex])}`); } async function fetchSunlight(place) { setLoading(true); setStatus(`Loading ${place.name}...`); try { const params = new URLSearchParams({ latitude: String(place.latitude), longitude: String(place.longitude), hourly: [ "uv_index", "is_day", "shortwave_radiation", "direct_radiation", "cloud_cover" ].join(","), daily: [ "sunrise", "sunset", "daylight_duration", "sunshine_duration", "uv_index_max", "uv_index_clear_sky_max" ].join(","), timezone: "auto", forecast_days: "5" }); const response = await fetch(`https://api.open-meteo.com/v1/forecast?${params}`); if (!response.ok) { throw new Error("Sunlight request failed."); } const data = await response.json(); if (data.error) { throw new Error(data.reason || "Sunlight API returned an error."); } renderSunlight(place, data); } catch (error) { setStatus(error.message || "Could not load sunlight data.", true); } finally { setLoading(false); } } async function searchCity() { const query = els.cityInput.value.trim(); if (!query) { setStatus("Enter a city name.", true); return; } setLoading(true); setStatus(`Searching ${query}...`); try { const params = new URLSearchParams({ name: query, count: "1", language: "en", format: "json" }); const response = await fetch(`https://geocoding-api.open-meteo.com/v1/search?${params}`); if (!response.ok) { throw new Error("City search failed."); } const data = await response.json(); const result = data.results && data.results[0]; if (!result) { throw new Error("No matching city found."); } const place = { name: [result.name, result.admin1, result.country].filter(Boolean).join(", "), latitude: result.latitude, longitude: result.longitude }; els.settingsPanel.hidden = true; els.settingsToggle.setAttribute("aria-expanded", "false"); await fetchSunlight(place); } catch (error) { setStatus(error.message || "Could not search city.", true); } finally { setLoading(false); } } els.settingsToggle.addEventListener("click", () => { const isOpen = !els.settingsPanel.hidden; els.settingsPanel.hidden = isOpen; els.settingsToggle.setAttribute("aria-expanded", String(!isOpen)); if (!isOpen) { setTimeout(() => { els.cityInput.focus(); els.cityInput.select(); }, 0); } }); els.citySearchButton.addEventListener("click", searchCity); els.cityInput.addEventListener("keydown", event => { if (event.key === "Enter") { event.preventDefault(); searchCity(); } if (event.key === "Escape") { els.settingsPanel.hidden = true; els.settingsToggle.setAttribute("aria-expanded", "false"); } }); document.addEventListener("click", event => { if (event.target.closest(".settings")) return; els.settingsPanel.hidden = true; els.settingsToggle.setAttribute("aria-expanded", "false"); }); fetchSunlight(DEFAULT_CITY); </script> </body> </html>
Copy
Save
<!doctype html> <html lang="en"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>Sandbox Sunlight Widget</title> <style> :root { --bg: #080b12; --panel: #111827; --panel-soft: #172033; --panel-strong: #1f2937; --text: #f9fafb; --muted: #aab4c5; --line: rgba(255, 255, 255, 0.12); --sun: #ffd166; --uv-low: #67d391; --uv-mid: #f4c542; --uv-high: #f97316; --uv-very: #ef4444; --uv-extreme: #a855f7; --accent: var(--sun); --error: #fca5a5; --shadow: rgba(0, 0, 0, 0.38); font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } * { box-sizing: border-box; } html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; color: var(--text); background: var(--bg); } body { min-height: 100svh; } button, input { font: inherit; } button { cursor: pointer; } .app { position: relative; width: 100vw; height: 100svh; min-height: 560px; overflow: hidden; background: var(--bg); isolation: isolate; } .sky { position: absolute; inset: 0; overflow: hidden; pointer-events: none; z-index: 0; } .star { position: absolute; width: 3px; height: 3px; border-radius: 999px; background: rgba(255, 255, 255, 0.62); animation: twinkle 3.8s ease-in-out infinite; } .ray { position: absolute; width: 2px; height: 120px; border-radius: 999px; background: rgba(255, 209, 102, 0.2); animation: rayDrift linear infinite; } @keyframes twinkle { 0%, 100% { opacity: 0.18; transform: scale(0.75); } 50% { opacity: 0.85; transform: scale(1.25); } } @keyframes rayDrift { from { transform: translate3d(0, 120vh, 0) rotate(18deg); } to { transform: translate3d(26vw, -30vh, 0) rotate(18deg); } } .stage { position: relative; z-index: 1; width: 100%; height: 100%; padding: clamp(18px, 3vw, 42px); display: grid; grid-template-rows: auto 1fr auto; gap: 22px; } .topbar { display: flex; justify-content: space-between; align-items: flex-start; gap: 20px; z-index: 5; } .eyebrow { color: var(--muted); font-size: 0.78rem; font-weight: 850; text-transform: uppercase; letter-spacing: 0.14em; } .city { margin-top: 7px; font-size: clamp(1.8rem, 4.2vw, 4rem); font-weight: 950; line-height: 0.92; letter-spacing: -0.07em; max-width: 900px; } .updated { margin-top: 10px; color: var(--muted); font-size: 0.95rem; } .updated.error { color: var(--error); } .settings { position: relative; z-index: 20; } .gear { width: 50px; height: 50px; display: grid; place-items: center; border: 1px solid var(--line); border-radius: 17px; color: var(--text); background: rgba(17, 24, 39, 0.9); box-shadow: 0 18px 52px var(--shadow); } .gear svg { width: 23px; height: 23px; } .settings-panel { position: absolute; top: 62px; right: 0; width: min(370px, calc(100vw - 36px)); padding: 16px; border: 1px solid var(--line); border-radius: 24px; background: rgba(17, 24, 39, 0.97); box-shadow: 0 24px 80px var(--shadow); } .settings-panel[hidden] { display: none; } .settings-title { font-weight: 950; letter-spacing: -0.03em; margin-bottom: 8px; } .settings-copy { color: var(--muted); font-size: 0.88rem; line-height: 1.4; margin-bottom: 14px; } .search { display: flex; gap: 8px; } .search input { min-width: 0; flex: 1; color: var(--text); background: var(--panel-soft); border: 1px solid var(--line); border-radius: 15px; padding: 11px 12px; outline: none; } .search input:focus { border-color: var(--accent); } .search button { border: 0; border-radius: 15px; padding: 0 16px; color: #12100a; background: var(--accent); font-weight: 950; } .hero { min-height: 0; display: grid; grid-template-columns: minmax(320px, 1fr) minmax(280px, 0.86fr); gap: clamp(24px, 5vw, 74px); align-items: center; } .sun-panel { display: grid; gap: 22px; min-width: 0; } .orb-wrap { position: relative; width: clamp(300px, 43vw, 620px); height: clamp(250px, 34vw, 470px); border: 1px solid var(--line); border-radius: 34px; background: rgba(17, 24, 39, 0.78); box-shadow: 0 28px 90px var(--shadow); overflow: hidden; } .arc { position: absolute; left: 7%; right: 7%; bottom: 18%; height: 64%; border-top: clamp(4px, 0.8vw, 8px) solid rgba(255, 255, 255, 0.16); border-radius: 999px 999px 0 0; } .horizon { position: absolute; left: 7%; right: 7%; bottom: 18%; height: 1px; background: var(--line); } .orb { position: absolute; width: clamp(76px, 9vw, 128px); height: clamp(76px, 9vw, 128px); border-radius: 50%; background: var(--accent); box-shadow: 0 0 58px rgba(255, 209, 102, 0.35); display: grid; place-items: center; transition: left 300ms ease, top 300ms ease; } .orb svg { width: 54%; height: 54%; color: #12100a; } .marker { position: absolute; bottom: 12%; color: var(--muted); font-size: 0.82rem; font-weight: 800; transform: translateX(-50%); text-align: center; white-space: nowrap; } .marker::before { content: ""; display: block; width: 1px; height: 18px; background: var(--line); margin: 0 auto 7px; } .marker.sunrise { left: 12%; } .marker.noon { left: 50%; } .marker.sunset { left: 88%; } .sun-copy { display: flex; gap: 14px; align-items: center; max-width: 680px; } .sun-dot { width: 16px; height: 16px; border-radius: 999px; background: var(--accent); flex: 0 0 auto; } .sun-title { font-size: clamp(1.7rem, 3.3vw, 3rem); font-weight: 950; letter-spacing: -0.06em; line-height: 0.95; } .sun-note { margin-top: 8px; color: var(--muted); line-height: 1.45; max-width: 620px; } .metrics { display: grid; grid-template-columns: repeat(2, minmax(150px, 1fr)); gap: 13px; min-width: 0; } .metric { min-height: 132px; border: 1px solid var(--line); border-radius: 27px; background: rgba(17, 24, 39, 0.78); padding: 18px; box-shadow: 0 18px 52px var(--shadow); overflow: hidden; } .metric-top { display: flex; justify-content: space-between; align-items: center; gap: 10px; color: var(--muted); font-size: 0.76rem; font-weight: 850; text-transform: uppercase; letter-spacing: 0.11em; margin-bottom: 12px; } .metric-icon { width: 30px; height: 30px; color: var(--accent); } .metric-value { font-size: clamp(1.8rem, 3.2vw, 2.9rem); font-weight: 950; line-height: 1; letter-spacing: -0.06em; } .metric-unit { color: var(--muted); margin-top: 8px; font-size: 0.9rem; } .bottom { display: grid; grid-template-columns: 1.15fr 0.85fr; gap: 14px; min-height: 162px; } .card { border: 1px solid var(--line); border-radius: 29px; background: rgba(17, 24, 39, 0.82); box-shadow: 0 18px 52px var(--shadow); padding: 16px; min-width: 0; } .card-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 13px; } .card-title { font-weight: 950; letter-spacing: -0.04em; } .card-note { color: var(--muted); font-size: 0.86rem; text-align: right; } .timeline { display: grid; grid-template-columns: repeat(10, minmax(64px, 1fr)); gap: 9px; overflow: auto; padding-bottom: 3px; } .hour { min-width: 64px; background: var(--panel-soft); border: 1px solid var(--line); border-radius: 18px; padding: 10px 8px; text-align: center; } .hour-time { color: var(--muted); font-size: 0.74rem; font-weight: 850; } .bar-wrap { height: 64px; margin: 10px auto; width: 12px; border-radius: 999px; background: rgba(255, 255, 255, 0.08); display: flex; align-items: flex-end; overflow: hidden; } .bar { width: 100%; min-height: 4px; border-radius: 999px; background: var(--accent); } .hour-value { font-weight: 900; font-size: 0.92rem; } .days { display: grid; gap: 9px; } .day { display: grid; grid-template-columns: 48px 1fr auto; align-items: center; gap: 10px; background: var(--panel-soft); border: 1px solid var(--line); border-radius: 18px; padding: 10px; } .day-name { color: var(--muted); font-weight: 850; font-size: 0.82rem; } .day-main { min-width: 0; } .day-label { font-weight: 900; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .day-sub { color: var(--muted); font-size: 0.78rem; margin-top: 3px; } .day-uv { font-weight: 950; color: var(--accent); } @media (max-width: 980px) { .app { overflow: auto; } .stage { min-height: 100svh; height: auto; } .hero { grid-template-columns: 1fr; align-items: start; } .orb-wrap { width: 100%; } .metrics { grid-template-columns: repeat(4, minmax(150px, 1fr)); overflow: auto; padding-bottom: 4px; } .bottom { grid-template-columns: 1fr; } } @media (max-width: 680px) { .stage { gap: 18px; } .city { font-size: 2rem; } .metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } .metric { min-height: 112px; padding: 14px; } .timeline { grid-template-columns: repeat(10, 68px); } .marker { font-size: 0.72rem; } .app { overflow: hidden !important; } } </style> </head> <body> <main class="app" style="width: 100%"> <div id="sky" class="sky"></div> <section class="stage" aria-live="polite"> <header class="topbar"> <div> <div class="eyebrow">Sunlight tracker</div> <div id="cityName" class="city">Copenhagen, Denmark</div> <div id="updated" class="updated">Loading sunlight data...</div> </div> <div class="settings"> <button id="settingsToggle" class="gear" type="button" aria-label="Open city search" aria-expanded="false" aria-controls="settingsPanel"> <svg viewBox="0 0 24 24" fill="none" aria-hidden="true"> <path d="M12 15.5A3.5 3.5 0 1 0 12 8a3.5 3.5 0 0 0 0 7.5Z" stroke="currentColor" stroke-width="1.8" /> <path d="M19.4 15a1.8 1.8 0 0 0 .36 1.98l.05.05a2.1 2.1 0 0 1-2.97 2.97l-.05-.05a1.8 1.8 0 0 0-1.98-.36 1.8 1.8 0 0 0-1.1 1.65V21.4a2.1 2.1 0 0 1-4.2 0v-.08a1.8 1.8 0 0 0-1.1-1.65 1.8 1.8 0 0 0-1.98.36l-.05.05a2.1 2.1 0 0 1-2.97-2.97l.05-.05A1.8 1.8 0 0 0 4.6 15a1.8 1.8 0 0 0-1.65-1.1H2.9a2.1 2.1 0 0 1 0-4.2h.08A1.8 1.8 0 0 0 4.6 8a1.8 1.8 0 0 0-.36-1.98l-.05-.05A2.1 2.1 0 0 1 7.16 3l.05.05A1.8 1.8 0 0 0 9.2 3.4 1.8 1.8 0 0 0 10.3 1.75V1.7a2.1 2.1 0 0 1 4.2 0v.08A1.8 1.8 0 0 0 15.6 3.4a1.8 1.8 0 0 0 1.98-.36l.05-.05A2.1 2.1 0 0 1 20.6 6l-.05.05A1.8 1.8 0 0 0 20.2 8c.26.66.9 1.1 1.6 1.1h.1a2.1 2.1 0 0 1 0 4.2h-.08A1.8 1.8 0 0 0 19.4 15Z" stroke="currentColor" stroke-width="1.25" /> </svg> </button> <div id="settingsPanel" class="settings-panel" hidden> <div class="settings-title">Change city</div> <div class="settings-copy"> </div> <div class="search"> <input id="cityInput" type="search" autocomplete="off" placeholder="Search city" inputmode="search" /> <button id="citySearchButton" type="button">Go</button> </div> </div> </div> </header> <section class="hero"> <div class="sun-panel"> <div class="orb-wrap"> <div class="arc"></div> <div class="horizon"></div> <div id="orb" class="orb"> <svg viewBox="0 0 48 48" fill="none" aria-hidden="true"> <circle cx="24" cy="24" r="8" stroke="currentColor" stroke-width="4" /> <path d="M24 4v7M24 37v7M4 24h7M37 24h7M10 10l5 5M33 33l5 5M38 10l-5 5M15 33l-5 5" stroke="currentColor" stroke-width="4" stroke-linecap="round" /> </svg> </div> <div class="marker sunrise"> <div id="sunrise">--:--</div> <div>Rise</div> </div> <div class="marker noon"> <div id="solarNoon">--:--</div> <div>Noon</div> </div> <div class="marker sunset"> <div id="sunset">--:--</div> <div>Set</div> </div> </div> <div class="sun-copy"> <div class="sun-dot"></div> <div> <div id="sunTitle" class="sun-title">Loading sunlight</div> <div id="sunNote" class="sun-note">Fetching sunrise, sunset, UV index, and daylight length.</div> </div> </div> </div> <div class="metrics"> <article class="metric"> <div class="metric-top"> <span>UV max</span> <span class="metric-icon" aria-hidden="true"> <svg viewBox="0 0 48 48" fill="none"> <circle cx="24" cy="24" r="8" stroke="currentColor" stroke-width="4" /> <path d="M24 4v7M24 37v7M4 24h7M37 24h7M10 10l5 5M33 33l5 5M38 10l-5 5M15 33l-5 5" stroke="currentColor" stroke-width="4" stroke-linecap="round" /> </svg> </span> </div> <div id="uvMax" class="metric-value">--</div> <div id="uvLabel" class="metric-unit">UV level</div> </article> <article class="metric"> <div class="metric-top"> <span>Daylight</span> <span class="metric-icon" aria-hidden="true"> <svg viewBox="0 0 48 48" fill="none"> <path d="M8 34h32" stroke="currentColor" stroke-width="4" stroke-linecap="round" /> <path d="M14 34a10 10 0 0 1 20 0" stroke="currentColor" stroke-width="4" /> <path d="M24 8v8M10 18l6 6M38 18l-6 6" stroke="currentColor" stroke-width="4" stroke-linecap="round" /> </svg> </span> </div> <div id="daylight" class="metric-value">--</div> <div class="metric-unit">Between sunrise and sunset</div> </article> <article class="metric"> <div class="metric-top"> <span>Sunshine</span> <span class="metric-icon" aria-hidden="true"> <svg viewBox="0 0 48 48" fill="none"> <path d="M8 30c8-9 24-9 32 0" stroke="currentColor" stroke-width="4" stroke-linecap="round" /> <path d="M12 38h24" stroke="currentColor" stroke-width="4" stroke-linecap="round" /> <circle cx="24" cy="17" r="6" stroke="currentColor" stroke-width="4" /> </svg> </span> </div> <div id="sunshine" class="metric-value">--</div> <div class="metric-unit">Forecast bright-sun duration</div> </article> <article class="metric"> <div class="metric-top"> <span>Clouds now</span> <span class="metric-icon" aria-hidden="true"> <svg viewBox="0 0 48 48" fill="none"> <path d="M15 33h21c5 0 9-4 9-9s-4-9-9-9c-2-7-9-11-17-9-6 2-11 8-11 15-5 1-8 5-8 10 0 6 5 2 15 2Z" transform="translate(2 4)" stroke="currentColor" stroke-width="4" stroke-linejoin="round" /> </svg> </span> </div> <div id="cloudCover" class="metric-value">--%</div> <div class="metric-unit">Current cloud cover</div> </article> </div> </section> <section class="bottom"> <article class="card"> <div class="card-head"> <div class="card-title">UV next hours</div> <div id="timelineNote" class="card-note">Hourly forecast</div> </div> <div id="timeline" class="timeline"></div> </article> <article class="card"> <div class="card-head"> <div class="card-title">Next days</div> <div id="daysNote" class="card-note">UV max + daylight</div> </div> <div id="days" class="days"></div> </article> </section> </section> </main> <script> // Sandbox-safe: // - no localStorage // - no sessionStorage // - no IndexedDB // - no cookies // - no geolocation // - no forms // - no external images, CSS, fonts, or libraries const DEFAULT_CITY = { name: "Copenhagen, Denmark", latitude: 55.6761, longitude: 12.5683 }; const els = { sky: document.getElementById("sky"), cityName: document.getElementById("cityName"), updated: document.getElementById("updated"), orb: document.getElementById("orb"), sunrise: document.getElementById("sunrise"), solarNoon: document.getElementById("solarNoon"), sunset: document.getElementById("sunset"), sunTitle: document.getElementById("sunTitle"), sunNote: document.getElementById("sunNote"), uvMax: document.getElementById("uvMax"), uvLabel: document.getElementById("uvLabel"), daylight: document.getElementById("daylight"), sunshine: document.getElementById("sunshine"), cloudCover: document.getElementById("cloudCover"), timeline: document.getElementById("timeline"), timelineNote: document.getElementById("timelineNote"), days: document.getElementById("days"), settingsToggle: document.getElementById("settingsToggle"), settingsPanel: document.getElementById("settingsPanel"), cityInput: document.getElementById("cityInput"), citySearchButton: document.getElementById("citySearchButton") }; function setStatus(text, isError = false) { els.updated.textContent = text; els.updated.classList.toggle("error", isError); } function setLoading(isLoading) { els.citySearchButton.disabled = isLoading; els.settingsToggle.disabled = false; } function fmtHour(timeString) { const date = new Date(timeString); if (Number.isNaN(date.getTime())) return "--:--"; return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); } function fmtDay(timeString) { const date = new Date(timeString); if (Number.isNaN(date.getTime())) return "--"; return date.toLocaleDateString([], { weekday: "short" }); } function fmtUv(value) { if (!Number.isFinite(value)) return "--"; return value.toFixed(value < 10 ? 1 : 0); } function fmtPercent(value) { if (!Number.isFinite(value)) return "--%"; return `${Math.round(value)}%`; } function fmtDurationFromSeconds(seconds) { if (!Number.isFinite(seconds)) return "--"; const totalMinutes = Math.max(0, Math.round(seconds / 60)); const hours = Math.floor(totalMinutes / 60); const minutes = totalMinutes % 60; return `${hours}h ${String(minutes).padStart(2, "0")}m`; } function secondsBetween(start, end) { const startMs = new Date(start).getTime(); const endMs = new Date(end).getTime(); if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) return NaN; return Math.max(0, (endMs - startMs) / 1000); } function midpointTime(start, end) { const startMs = new Date(start).getTime(); const endMs = new Date(end).getTime(); if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) return null; return new Date((startMs + endMs) / 2).toISOString(); } function findCurrentIndex(times) { if (!Array.isArray(times) || !times.length) return 0; const now = Date.now(); let bestIndex = 0; let bestDelta = Infinity; times.forEach((time, index) => { const parsed = new Date(time).getTime(); if (!Number.isFinite(parsed)) return; const delta = Math.abs(parsed - now); if (delta < bestDelta) { bestDelta = delta; bestIndex = index; } }); return bestIndex; } function getUvLevel(uv) { if (!Number.isFinite(uv)) { return { label: "Unknown", color: "var(--sun)", advice: "UV data is unavailable for this time." }; } if (uv < 3) { return { label: "Low", color: "var(--uv-low)", advice: "Low UV. Basic sun protection is usually enough." }; } if (uv < 6) { return { label: "Moderate", color: "var(--uv-mid)", advice: "Moderate UV. Use sunscreen and sunglasses if outside for long." }; } if (uv < 8) { return { label: "High", color: "var(--uv-high)", advice: "High UV. Seek shade around midday and use sun protection." }; } if (uv < 11) { return { label: "Very high", color: "var(--uv-very)", advice: "Very high UV. Avoid prolonged midday exposure." }; } return { label: "Extreme", color: "var(--uv-extreme)", advice: "Extreme UV. Minimize exposure and use full protection." }; } function setAccent(color) { document.documentElement.style.setProperty("--accent", color); } function renderAtmosphere(isDay, uv) { els.sky.textContent = ""; if (isDay) { const rayCount = Math.min(44, Math.max(14, Math.round((uv || 2) * 4))); for (let i = 0; i < rayCount; i += 1) { const ray = document.createElement("span"); ray.className = "ray"; ray.style.left = `${Math.random() * 100}%`; ray.style.top = `${Math.random() * 100}%`; ray.style.animationDuration = `${18 + Math.random() * 26}s`; ray.style.animationDelay = `${Math.random() * -30}s`; ray.style.opacity = `${0.08 + Math.random() * 0.18}`; els.sky.appendChild(ray); } return; } for (let i = 0; i < 90; i += 1) { const star = document.createElement("span"); star.className = "star"; star.style.left = `${Math.random() * 100}%`; star.style.top = `${Math.random() * 100}%`; star.style.animationDuration = `${2.4 + Math.random() * 4}s`; star.style.animationDelay = `${Math.random() * -5}s`; els.sky.appendChild(star); } } function setOrbPosition(sunrise, sunset) { const rise = new Date(sunrise).getTime(); const set = new Date(sunset).getTime(); const now = Date.now(); let progress = 0.5; let visible = true; if (Number.isFinite(rise) && Number.isFinite(set) && set > rise) { progress = (now - rise) / (set - rise); if (progress < 0 || progress > 1) { visible = false; progress = Math.max(0, Math.min(1, progress)); } } const x = 7 + progress * 86; const y = 74 - Math.sin(progress * Math.PI) * 52; els.orb.style.left = `calc(${x}% - var(--orb-half, 55px))`; els.orb.style.top = `calc(${y}% - var(--orb-half, 55px))`; els.orb.style.opacity = visible ? "1" : "0.28"; } function renderTimeline(hourly, startIndex) { els.timeline.textContent = ""; const times = hourly.time || []; const uvValues = hourly.uv_index || []; const radiationValues = hourly.shortwave_radiation || []; const end = Math.min(startIndex + 10, times.length); const maxUv = Math.max(8, ...uvValues.slice(startIndex, end).filter(Number.isFinite)); for (let index = startIndex; index < end; index += 1) { const uv = uvValues[index]; const radiation = radiationValues[index]; const level = getUvLevel(uv); const height = Math.max(4, Math.min(100, ((uv || 0) / maxUv) * 100)); const item = document.createElement("div"); item.className = "hour"; item.innerHTML = ` <div class="hour-time">${index === startIndex ? "Now" : fmtHour(times[index])}</div> <div class="bar-wrap"> <div class="bar" style="height: ${height}%; background: ${level.color};" aria-hidden="true" ></div> </div> <div class="hour-value">${fmtUv(uv)}</div> <div class="hour-time">${Number.isFinite(radiation) ? Math.round(radiation) + " W" : "--"}</div> `; els.timeline.appendChild(item); } els.timelineNote.textContent = "UV index + solar W/m²"; } function renderDays(daily) { els.days.textContent = ""; const times = daily.time || []; times.slice(0, 5).forEach((time, index) => { const uv = daily.uv_index_max?.[index]; const level = getUvLevel(uv); const daylightSeconds = Number.isFinite(daily.daylight_duration?.[index]) ? daily.daylight_duration[index] : secondsBetween(daily.sunrise?.[index], daily.sunset?.[index]); const row = document.createElement("div"); row.className = "day"; row.innerHTML = ` <div class="day-name">${index === 0 ? "Today" : fmtDay(time)}</div> <div class="day-main"> <div class="day-label">${level.label}</div> <div class="day-sub">${fmtDurationFromSeconds(daylightSeconds)} daylight</div> </div> <div class="day-uv" style="color: ${level.color};">${fmtUv(uv)}</div> `; els.days.appendChild(row); }); } function renderSunlight(place, data) { const daily = data.daily; const hourly = data.hourly; if (!daily || !daily.time || !daily.time.length || !hourly || !hourly.time) { throw new Error("Sunlight data unavailable."); } const currentIndex = findCurrentIndex(hourly.time); const currentUv = hourly.uv_index?.[currentIndex]; const currentClouds = hourly.cloud_cover?.[currentIndex]; const isDay = hourly.is_day?.[currentIndex] !== 0; const todaySunrise = daily.sunrise?.[0]; const todaySunset = daily.sunset?.[0]; const noon = midpointTime(todaySunrise, todaySunset); const uvMax = daily.uv_index_max?.[0]; const uvLevel = getUvLevel(uvMax); const daylightSeconds = Number.isFinite(daily.daylight_duration?.[0]) ? daily.daylight_duration[0] : secondsBetween(todaySunrise, todaySunset); const sunshineSeconds = Number.isFinite(daily.sunshine_duration?.[0]) ? daily.sunshine_duration[0] : NaN; setAccent(uvLevel.color); els.cityName.textContent = place.name; els.sunrise.textContent = fmtHour(todaySunrise); els.solarNoon.textContent = fmtHour(noon); els.sunset.textContent = fmtHour(todaySunset); els.sunTitle.textContent = isDay ? "Sun is above the horizon" : "Sun is below the horizon"; els.sunNote.textContent = uvLevel.advice; els.uvMax.textContent = fmtUv(uvMax); els.uvLabel.textContent = `${uvLevel.label} UV maximum today`; els.daylight.textContent = fmtDurationFromSeconds(daylightSeconds); els.sunshine.textContent = fmtDurationFromSeconds(sunshineSeconds); els.cloudCover.textContent = fmtPercent(currentClouds); renderTimeline(hourly, currentIndex); renderDays(daily); renderAtmosphere(isDay, currentUv); setOrbPosition(todaySunrise, todaySunset); setStatus(`Updated ${fmtHour(hourly.time[currentIndex])}`); } async function fetchSunlight(place) { setLoading(true); setStatus(`Loading ${place.name}...`); try { const params = new URLSearchParams({ latitude: String(place.latitude), longitude: String(place.longitude), hourly: [ "uv_index", "is_day", "shortwave_radiation", "direct_radiation", "cloud_cover" ].join(","), daily: [ "sunrise", "sunset", "daylight_duration", "sunshine_duration", "uv_index_max", "uv_index_clear_sky_max" ].join(","), timezone: "auto", forecast_days: "5" }); const response = await fetch(`https://api.open-meteo.com/v1/forecast?${params}`); if (!response.ok) { throw new Error("Sunlight request failed."); } const data = await response.json(); if (data.error) { throw new Error(data.reason || "Sunlight API returned an error."); } renderSunlight(place, data); } catch (error) { setStatus(error.message || "Could not load sunlight data.", true); } finally { setLoading(false); } } async function searchCity() { const query = els.cityInput.value.trim(); if (!query) { setStatus("Enter a city name.", true); return; } setLoading(true); setStatus(`Searching ${query}...`); try { const params = new URLSearchParams({ name: query, count: "1", language: "en", format: "json" }); const response = await fetch(`https://geocoding-api.open-meteo.com/v1/search?${params}`); if (!response.ok) { throw new Error("City search failed."); } const data = await response.json(); const result = data.results && data.results[0]; if (!result) { throw new Error("No matching city found."); } const place = { name: [result.name, result.admin1, result.country].filter(Boolean).join(", "), latitude: result.latitude, longitude: result.longitude }; els.settingsPanel.hidden = true; els.settingsToggle.setAttribute("aria-expanded", "false"); await fetchSunlight(place); } catch (error) { setStatus(error.message || "Could not search city.", true); } finally { setLoading(false); } } els.settingsToggle.addEventListener("click", () => { const isOpen = !els.settingsPanel.hidden; els.settingsPanel.hidden = isOpen; els.settingsToggle.setAttribute("aria-expanded", String(!isOpen)); if (!isOpen) { setTimeout(() => { els.cityInput.focus(); els.cityInput.select(); }, 0); } }); els.citySearchButton.addEventListener("click", searchCity); els.cityInput.addEventListener("keydown", event => { if (event.key === "Enter") { event.preventDefault(); searchCity(); } if (event.key === "Escape") { els.settingsPanel.hidden = true; els.settingsToggle.setAttribute("aria-expanded", "false"); } }); document.addEventListener("click", event => { if (event.target.closest(".settings")) return; els.settingsPanel.hidden = true; els.settingsToggle.setAttribute("aria-expanded", "false"); }); fetchSunlight(DEFAULT_CITY); </script> </body> </html>
Move to Deleted?
will be moved to Deleted.
Cancel
Delete
Rename
Name
Cancel
Rename