Root
>
Pollution.html
Download
Delete
Pollution.html
37,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 Air Quality Widget</title> <style> :root { --bg: #090d16; --panel: #111827; --panel-soft: #172033; --panel-strong: #202b3f; --text: #f8fafc; --muted: #a9b4c7; --line: rgba(255, 255, 255, 0.12); --good: #57d68d; --moderate: #f4d35e; --sensitive: #f59e42; --unhealthy: #ef5d5d; --very: #b985f4; --hazard: #9f5568; --accent: var(--good); --shadow: rgba(0, 0, 0, 0.35); --error: #fca5a5; 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; background: var(--bg); color: var(--text); } 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; } .mist { position: absolute; inset: 0; pointer-events: none; overflow: hidden; z-index: 0; } .particle { position: absolute; border-radius: 999px; background: var(--accent); opacity: 0.14; animation: drift linear infinite; } @keyframes drift { from { transform: translate3d(-20vw, 0, 0); } to { transform: translate3d(120vw, -18vh, 0); } } .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; } .label { color: var(--muted); font-size: 0.78rem; font-weight: 800; text-transform: uppercase; letter-spacing: 0.14em; } .city { margin-top: 7px; font-size: clamp(1.8rem, 4.4vw, 4.1rem); font-weight: 900; line-height: 0.92; letter-spacing: -0.07em; } .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; background: rgba(17, 24, 39, 0.88); color: var(--text); 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: 900; 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: #071018; background: var(--accent); font-weight: 900; } .hero { min-height: 0; display: grid; grid-template-columns: minmax(280px, 0.95fr) minmax(0, 1.05fr); gap: clamp(20px, 5vw, 70px); align-items: center; } .aqi-panel { display: grid; gap: 20px; } .aqi-ring { position: relative; width: clamp(260px, 36vw, 470px); height: clamp(260px, 36vw, 470px); border-radius: 50%; background: var(--panel); border: 1px solid var(--line); box-shadow: 0 28px 90px var(--shadow); display: grid; place-items: center; overflow: hidden; } .aqi-ring::before { content: ""; position: absolute; inset: 18px; border-radius: 50%; border: clamp(16px, 2.3vw, 28px) solid var(--accent); opacity: 0.95; } .aqi-ring::after { content: ""; position: absolute; inset: clamp(58px, 7vw, 92px); border-radius: 50%; background: var(--bg); border: 1px solid var(--line); } .aqi-value { position: relative; z-index: 2; text-align: center; } .aqi-number { font-size: clamp(5rem, 11vw, 10rem); font-weight: 950; line-height: 0.78; letter-spacing: -0.1em; } .aqi-unit { margin-top: 14px; color: var(--muted); font-weight: 850; letter-spacing: 0.12em; text-transform: uppercase; font-size: 0.82rem; } .verdict { display: flex; align-items: center; gap: 12px; max-width: 560px; } .verdict-dot { width: 16px; height: 16px; border-radius: 50%; background: var(--accent); flex: 0 0 auto; } .verdict-title { font-size: clamp(1.6rem, 3vw, 2.7rem); font-weight: 950; letter-spacing: -0.06em; line-height: 0.95; } .verdict-copy { margin-top: 7px; 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.4vw, 3rem); 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: 6px; border-radius: 999px; background: var(--accent); } .hour-value { font-weight: 900; font-size: 0.92rem; } .advice-grid { display: grid; gap: 9px; } .advice { display: grid; grid-template-columns: 34px 1fr; align-items: center; gap: 10px; border: 1px solid var(--line); border-radius: 18px; background: var(--panel-soft); padding: 10px; } .advice svg { width: 26px; height: 26px; color: var(--accent); } .advice strong { display: block; font-size: 0.9rem; } .advice span { display: block; color: var(--muted); font-size: 0.8rem; margin-top: 2px; } @media (max-width: 980px) { .app { overflow: auto; } .stage { min-height: 100svh; height: auto; } .hero { grid-template-columns: 1fr; align-items: start; } .aqi-ring { width: min(430px, 100%); height: auto; aspect-ratio: 1; } .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); } .aqi-number { font-size: clamp(4.8rem, 26vw, 7rem); } .app { overflow: hidden !important; } } </style> </head> <body> <main class="app" style="width: 100%"> <div id="mist" class="mist"></div> <section class="stage" aria-live="polite"> <header class="topbar"> <div> <div class="label">Air quality</div> <div id="cityName" class="city">Copenhagen, Denmark</div> <div id="updated" class="updated">Loading air quality...</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"> No location permission, no form submission, no storage. Search only runs when clicked or Enter is pressed. </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="aqi-panel"> <div class="aqi-ring"> <div class="aqi-value"> <div id="aqiNumber" class="aqi-number">--</div> <div class="aqi-unit">US AQI</div> </div> </div> <div class="verdict"> <div class="verdict-dot"></div> <div> <div id="aqiLabel" class="verdict-title">Loading</div> <div id="aqiAdvice" class="verdict-copy">Fetching current pollution forecast.</div> </div> </div> </div> <div class="metrics"> <article class="metric"> <div class="metric-top"> <span>PM2.5</span> <span class="metric-icon" aria-hidden="true"> <svg viewBox="0 0 48 48" fill="none"> <circle cx="16" cy="20" r="6" stroke="currentColor" stroke-width="4" /> <circle cx="31" cy="16" r="4" stroke="currentColor" stroke-width="4" /> <circle cx="30" cy="31" r="7" stroke="currentColor" stroke-width="4" /> </svg> </span> </div> <div id="pm25" class="metric-value">--</div> <div class="metric-unit">µg/m³ fine particles</div> </article> <article class="metric"> <div class="metric-top"> <span>PM10</span> <span class="metric-icon" aria-hidden="true"> <svg viewBox="0 0 48 48" fill="none"> <path d="M10 26c7-12 19-12 28 0" stroke="currentColor" stroke-width="4" stroke-linecap="round" /> <path d="M14 34c5-7 15-7 20 0" stroke="currentColor" stroke-width="4" stroke-linecap="round" /> <circle cx="24" cy="18" r="5" stroke="currentColor" stroke-width="4" /> </svg> </span> </div> <div id="pm10" class="metric-value">--</div> <div class="metric-unit">µg/m³ coarse particles</div> </article> <article class="metric"> <div class="metric-top"> <span>Ozone</span> <span class="metric-icon" aria-hidden="true"> <svg viewBox="0 0 48 48" fill="none"> <circle cx="18" cy="24" r="9" stroke="currentColor" stroke-width="4" /> <circle cx="31" cy="24" r="9" stroke="currentColor" stroke-width="4" /> </svg> </span> </div> <div id="ozone" class="metric-value">--</div> <div class="metric-unit">µg/m³ O₃</div> </article> <article class="metric"> <div class="metric-top"> <span>NO₂</span> <span class="metric-icon" aria-hidden="true"> <svg viewBox="0 0 48 48" fill="none"> <path d="M12 34V14l24 20V14" stroke="currentColor" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" /> </svg> </span> </div> <div id="no2" class="metric-value">--</div> <div class="metric-unit">µg/m³ nitrogen dioxide</div> </article> </div> </section> <section class="bottom"> <article class="card"> <div class="card-head"> <div class="card-title">Next 10 hours</div> <div id="timelineNote" class="card-note">US AQI forecast</div> </div> <div id="timeline" class="timeline"></div> </article> <article class="card"> <div class="card-head"> <div class="card-title">Guidance</div> <div id="guidanceNote" class="card-note">Current level</div> </div> <div class="advice-grid"> <div class="advice"> <svg viewBox="0 0 48 48" fill="none" aria-hidden="true"> <path d="M14 28c4-11 16-11 20 0" stroke="currentColor" stroke-width="4" stroke-linecap="round" /> <path d="M12 34h24" stroke="currentColor" stroke-width="4" stroke-linecap="round" /> <circle cx="24" cy="14" r="6" stroke="currentColor" stroke-width="4" /> </svg> <div> <strong id="outdoorTitle">Outdoor activity</strong> <span id="outdoorCopy">Loading guidance.</span> </div> </div> <div class="advice"> <svg viewBox="0 0 48 48" fill="none" aria-hidden="true"> <path d="M10 26h28" stroke="currentColor" stroke-width="4" stroke-linecap="round" /> <path d="M16 18h18" stroke="currentColor" stroke-width="4" stroke-linecap="round" /> <path d="M14 34h12" stroke="currentColor" stroke-width="4" stroke-linecap="round" /> </svg> <div> <strong id="windowTitle">Ventilation</strong> <span id="windowCopy">Loading guidance.</span> </div> </div> <div class="advice"> <svg viewBox="0 0 48 48" fill="none" aria-hidden="true"> <path d="M14 16h20v22H14z" stroke="currentColor" stroke-width="4" stroke-linejoin="round" /> <path d="M18 10h12" stroke="currentColor" stroke-width="4" stroke-linecap="round" /> <path d="M20 24h8M20 31h8" stroke="currentColor" stroke-width="4" stroke-linecap="round" /> </svg> <div> <strong id="filterTitle">Indoor filter</strong> <span id="filterCopy">Loading guidance.</span> </div> </div> </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 = { mist: document.getElementById("mist"), cityName: document.getElementById("cityName"), updated: document.getElementById("updated"), aqiNumber: document.getElementById("aqiNumber"), aqiLabel: document.getElementById("aqiLabel"), aqiAdvice: document.getElementById("aqiAdvice"), pm25: document.getElementById("pm25"), pm10: document.getElementById("pm10"), ozone: document.getElementById("ozone"), no2: document.getElementById("no2"), timeline: document.getElementById("timeline"), timelineNote: document.getElementById("timelineNote"), guidanceNote: document.getElementById("guidanceNote"), outdoorTitle: document.getElementById("outdoorTitle"), outdoorCopy: document.getElementById("outdoorCopy"), windowTitle: document.getElementById("windowTitle"), windowCopy: document.getElementById("windowCopy"), filterTitle: document.getElementById("filterTitle"), filterCopy: document.getElementById("filterCopy"), settingsToggle: document.getElementById("settingsToggle"), settingsPanel: document.getElementById("settingsPanel"), cityInput: document.getElementById("cityInput"), citySearchButton: document.getElementById("citySearchButton") }; const levels = [ { max: 50, label: "Good", color: "var(--good)", advice: "Air quality is comfortable for normal outdoor activity.", outdoor: "Normal activity is fine.", window: "Opening windows is usually reasonable.", filter: "No special filter action needed." }, { max: 100, label: "Moderate", color: "var(--moderate)", advice: "Acceptable overall, but unusually sensitive people may notice symptoms.", outdoor: "Most activity is fine.", window: "Ventilate if indoor air feels stale.", filter: "Run filtration if sensitive." }, { max: 150, label: "Unhealthy for Sensitive Groups", color: "var(--sensitive)", advice: "Sensitive groups should reduce prolonged or heavy outdoor exertion.", outdoor: "Sensitive groups should take breaks.", window: "Limit long ventilation periods.", filter: "Run filtration if available." }, { max: 200, label: "Unhealthy", color: "var(--unhealthy)", advice: "Everyone may begin to experience effects; sensitive groups are at higher risk.", outdoor: "Reduce heavy outdoor activity.", window: "Keep windows mostly closed.", filter: "Run filtration continuously." }, { max: 300, label: "Very Unhealthy", color: "var(--very)", advice: "Health alert level. Outdoor exposure should be reduced.", outdoor: "Avoid heavy outdoor activity.", window: "Keep windows closed.", filter: "Use filtration if possible." }, { max: Infinity, label: "Hazardous", color: "var(--hazard)", advice: "Emergency-level air quality. Avoid outdoor exposure where possible.", outdoor: "Stay indoors if possible.", window: "Keep windows closed.", filter: "Use filtration continuously." } ]; function getLevel(aqi) { const safeAqi = Number.isFinite(aqi) ? aqi : 0; return levels.find(level => safeAqi <= level.max) || levels[0]; } function setAccent(color) { document.documentElement.style.setProperty("--accent", color); } 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 fmtNumber(value, digits = 0) { if (!Number.isFinite(value)) return "--"; return Number(value).toFixed(digits); } function fmtPollutant(value) { if (!Number.isFinite(value)) return "--"; return value >= 100 ? Math.round(value) : value.toFixed(1); } function fmtHour(timeString) { const date = new Date(timeString); if (Number.isNaN(date.getTime())) return "--"; return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); } 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 renderParticles(aqi) { els.mist.textContent = ""; const count = Math.min(90, Math.max(20, Math.round((Number(aqi) || 25) / 4))); for (let i = 0; i < count; i += 1) { const particle = document.createElement("span"); const size = 4 + Math.random() * 28; particle.className = "particle"; particle.style.width = `${size}px`; particle.style.height = `${size}px`; particle.style.left = `${Math.random() * 100}%`; particle.style.top = `${Math.random() * 110}%`; particle.style.animationDuration = `${22 + Math.random() * 34}s`; particle.style.animationDelay = `${Math.random() * -40}s`; els.mist.appendChild(particle); } } function renderTimeline(hourly, startIndex) { els.timeline.textContent = ""; const times = hourly.time || []; const aqis = hourly.us_aqi || []; const end = Math.min(startIndex + 10, times.length); const maxAqi = Math.max(100, ...aqis.slice(startIndex, end).filter(Number.isFinite)); for (let index = startIndex; index < end; index += 1) { const value = aqis[index]; const level = getLevel(value); const barHeight = Math.max(8, Math.min(100, (value / maxAqi) * 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: ${barHeight}%; background: ${level.color};" aria-hidden="true" ></div> </div> <div class="hour-value">${fmtNumber(value)}</div> `; els.timeline.appendChild(item); } els.timelineNote.textContent = "US AQI forecast"; } function renderGuidance(level) { els.guidanceNote.textContent = level.label; els.outdoorTitle.textContent = "Outdoor activity"; els.outdoorCopy.textContent = level.outdoor; els.windowTitle.textContent = "Ventilation"; els.windowCopy.textContent = level.window; els.filterTitle.textContent = "Indoor filter"; els.filterCopy.textContent = level.filter; } function renderAirQuality(place, data) { const hourly = data.hourly; if (!hourly || !hourly.time || !hourly.time.length) { throw new Error("Air quality data unavailable."); } const index = findCurrentIndex(hourly.time); const aqi = hourly.us_aqi?.[index]; const level = getLevel(aqi); setAccent(level.color); els.cityName.textContent = place.name; els.aqiNumber.textContent = fmtNumber(aqi); els.aqiLabel.textContent = level.label; els.aqiAdvice.textContent = level.advice; els.pm25.textContent = fmtPollutant(hourly.pm2_5?.[index]); els.pm10.textContent = fmtPollutant(hourly.pm10?.[index]); els.ozone.textContent = fmtPollutant(hourly.ozone?.[index]); els.no2.textContent = fmtPollutant(hourly.nitrogen_dioxide?.[index]); renderTimeline(hourly, index); renderGuidance(level); renderParticles(aqi); setStatus(`Updated ${fmtHour(hourly.time[index])}`); } async function fetchAirQuality(place) { setLoading(true); setStatus(`Loading ${place.name}...`); try { const params = new URLSearchParams({ latitude: String(place.latitude), longitude: String(place.longitude), hourly: [ "us_aqi", "pm10", "pm2_5", "carbon_monoxide", "nitrogen_dioxide", "sulphur_dioxide", "ozone", "dust", "uv_index" ].join(","), timezone: "auto", forecast_days: "3" }); const url = `https://air-quality-api.open-meteo.com/v1/air-quality?${params}`; const response = await fetch(url); if (!response.ok) { throw new Error("Air quality request failed."); } const data = await response.json(); renderAirQuality(place, data); } catch (error) { setStatus(error.message || "Could not load air quality.", 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 url = `https://geocoding-api.open-meteo.com/v1/search?${params}`; const response = await fetch(url); 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 fetchAirQuality(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"); }); fetchAirQuality(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 Air Quality Widget</title> <style> :root { --bg: #090d16; --panel: #111827; --panel-soft: #172033; --panel-strong: #202b3f; --text: #f8fafc; --muted: #a9b4c7; --line: rgba(255, 255, 255, 0.12); --good: #57d68d; --moderate: #f4d35e; --sensitive: #f59e42; --unhealthy: #ef5d5d; --very: #b985f4; --hazard: #9f5568; --accent: var(--good); --shadow: rgba(0, 0, 0, 0.35); --error: #fca5a5; 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; background: var(--bg); color: var(--text); } 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; } .mist { position: absolute; inset: 0; pointer-events: none; overflow: hidden; z-index: 0; } .particle { position: absolute; border-radius: 999px; background: var(--accent); opacity: 0.14; animation: drift linear infinite; } @keyframes drift { from { transform: translate3d(-20vw, 0, 0); } to { transform: translate3d(120vw, -18vh, 0); } } .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; } .label { color: var(--muted); font-size: 0.78rem; font-weight: 800; text-transform: uppercase; letter-spacing: 0.14em; } .city { margin-top: 7px; font-size: clamp(1.8rem, 4.4vw, 4.1rem); font-weight: 900; line-height: 0.92; letter-spacing: -0.07em; } .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; background: rgba(17, 24, 39, 0.88); color: var(--text); 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: 900; 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: #071018; background: var(--accent); font-weight: 900; } .hero { min-height: 0; display: grid; grid-template-columns: minmax(280px, 0.95fr) minmax(0, 1.05fr); gap: clamp(20px, 5vw, 70px); align-items: center; } .aqi-panel { display: grid; gap: 20px; } .aqi-ring { position: relative; width: clamp(260px, 36vw, 470px); height: clamp(260px, 36vw, 470px); border-radius: 50%; background: var(--panel); border: 1px solid var(--line); box-shadow: 0 28px 90px var(--shadow); display: grid; place-items: center; overflow: hidden; } .aqi-ring::before { content: ""; position: absolute; inset: 18px; border-radius: 50%; border: clamp(16px, 2.3vw, 28px) solid var(--accent); opacity: 0.95; } .aqi-ring::after { content: ""; position: absolute; inset: clamp(58px, 7vw, 92px); border-radius: 50%; background: var(--bg); border: 1px solid var(--line); } .aqi-value { position: relative; z-index: 2; text-align: center; } .aqi-number { font-size: clamp(5rem, 11vw, 10rem); font-weight: 950; line-height: 0.78; letter-spacing: -0.1em; } .aqi-unit { margin-top: 14px; color: var(--muted); font-weight: 850; letter-spacing: 0.12em; text-transform: uppercase; font-size: 0.82rem; } .verdict { display: flex; align-items: center; gap: 12px; max-width: 560px; } .verdict-dot { width: 16px; height: 16px; border-radius: 50%; background: var(--accent); flex: 0 0 auto; } .verdict-title { font-size: clamp(1.6rem, 3vw, 2.7rem); font-weight: 950; letter-spacing: -0.06em; line-height: 0.95; } .verdict-copy { margin-top: 7px; 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.4vw, 3rem); 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: 6px; border-radius: 999px; background: var(--accent); } .hour-value { font-weight: 900; font-size: 0.92rem; } .advice-grid { display: grid; gap: 9px; } .advice { display: grid; grid-template-columns: 34px 1fr; align-items: center; gap: 10px; border: 1px solid var(--line); border-radius: 18px; background: var(--panel-soft); padding: 10px; } .advice svg { width: 26px; height: 26px; color: var(--accent); } .advice strong { display: block; font-size: 0.9rem; } .advice span { display: block; color: var(--muted); font-size: 0.8rem; margin-top: 2px; } @media (max-width: 980px) { .app { overflow: auto; } .stage { min-height: 100svh; height: auto; } .hero { grid-template-columns: 1fr; align-items: start; } .aqi-ring { width: min(430px, 100%); height: auto; aspect-ratio: 1; } .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); } .aqi-number { font-size: clamp(4.8rem, 26vw, 7rem); } .app { overflow: hidden !important; } } </style> </head> <body> <main class="app" style="width: 100%"> <div id="mist" class="mist"></div> <section class="stage" aria-live="polite"> <header class="topbar"> <div> <div class="label">Air quality</div> <div id="cityName" class="city">Copenhagen, Denmark</div> <div id="updated" class="updated">Loading air quality...</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"> No location permission, no form submission, no storage. Search only runs when clicked or Enter is pressed. </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="aqi-panel"> <div class="aqi-ring"> <div class="aqi-value"> <div id="aqiNumber" class="aqi-number">--</div> <div class="aqi-unit">US AQI</div> </div> </div> <div class="verdict"> <div class="verdict-dot"></div> <div> <div id="aqiLabel" class="verdict-title">Loading</div> <div id="aqiAdvice" class="verdict-copy">Fetching current pollution forecast.</div> </div> </div> </div> <div class="metrics"> <article class="metric"> <div class="metric-top"> <span>PM2.5</span> <span class="metric-icon" aria-hidden="true"> <svg viewBox="0 0 48 48" fill="none"> <circle cx="16" cy="20" r="6" stroke="currentColor" stroke-width="4" /> <circle cx="31" cy="16" r="4" stroke="currentColor" stroke-width="4" /> <circle cx="30" cy="31" r="7" stroke="currentColor" stroke-width="4" /> </svg> </span> </div> <div id="pm25" class="metric-value">--</div> <div class="metric-unit">µg/m³ fine particles</div> </article> <article class="metric"> <div class="metric-top"> <span>PM10</span> <span class="metric-icon" aria-hidden="true"> <svg viewBox="0 0 48 48" fill="none"> <path d="M10 26c7-12 19-12 28 0" stroke="currentColor" stroke-width="4" stroke-linecap="round" /> <path d="M14 34c5-7 15-7 20 0" stroke="currentColor" stroke-width="4" stroke-linecap="round" /> <circle cx="24" cy="18" r="5" stroke="currentColor" stroke-width="4" /> </svg> </span> </div> <div id="pm10" class="metric-value">--</div> <div class="metric-unit">µg/m³ coarse particles</div> </article> <article class="metric"> <div class="metric-top"> <span>Ozone</span> <span class="metric-icon" aria-hidden="true"> <svg viewBox="0 0 48 48" fill="none"> <circle cx="18" cy="24" r="9" stroke="currentColor" stroke-width="4" /> <circle cx="31" cy="24" r="9" stroke="currentColor" stroke-width="4" /> </svg> </span> </div> <div id="ozone" class="metric-value">--</div> <div class="metric-unit">µg/m³ O₃</div> </article> <article class="metric"> <div class="metric-top"> <span>NO₂</span> <span class="metric-icon" aria-hidden="true"> <svg viewBox="0 0 48 48" fill="none"> <path d="M12 34V14l24 20V14" stroke="currentColor" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" /> </svg> </span> </div> <div id="no2" class="metric-value">--</div> <div class="metric-unit">µg/m³ nitrogen dioxide</div> </article> </div> </section> <section class="bottom"> <article class="card"> <div class="card-head"> <div class="card-title">Next 10 hours</div> <div id="timelineNote" class="card-note">US AQI forecast</div> </div> <div id="timeline" class="timeline"></div> </article> <article class="card"> <div class="card-head"> <div class="card-title">Guidance</div> <div id="guidanceNote" class="card-note">Current level</div> </div> <div class="advice-grid"> <div class="advice"> <svg viewBox="0 0 48 48" fill="none" aria-hidden="true"> <path d="M14 28c4-11 16-11 20 0" stroke="currentColor" stroke-width="4" stroke-linecap="round" /> <path d="M12 34h24" stroke="currentColor" stroke-width="4" stroke-linecap="round" /> <circle cx="24" cy="14" r="6" stroke="currentColor" stroke-width="4" /> </svg> <div> <strong id="outdoorTitle">Outdoor activity</strong> <span id="outdoorCopy">Loading guidance.</span> </div> </div> <div class="advice"> <svg viewBox="0 0 48 48" fill="none" aria-hidden="true"> <path d="M10 26h28" stroke="currentColor" stroke-width="4" stroke-linecap="round" /> <path d="M16 18h18" stroke="currentColor" stroke-width="4" stroke-linecap="round" /> <path d="M14 34h12" stroke="currentColor" stroke-width="4" stroke-linecap="round" /> </svg> <div> <strong id="windowTitle">Ventilation</strong> <span id="windowCopy">Loading guidance.</span> </div> </div> <div class="advice"> <svg viewBox="0 0 48 48" fill="none" aria-hidden="true"> <path d="M14 16h20v22H14z" stroke="currentColor" stroke-width="4" stroke-linejoin="round" /> <path d="M18 10h12" stroke="currentColor" stroke-width="4" stroke-linecap="round" /> <path d="M20 24h8M20 31h8" stroke="currentColor" stroke-width="4" stroke-linecap="round" /> </svg> <div> <strong id="filterTitle">Indoor filter</strong> <span id="filterCopy">Loading guidance.</span> </div> </div> </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 = { mist: document.getElementById("mist"), cityName: document.getElementById("cityName"), updated: document.getElementById("updated"), aqiNumber: document.getElementById("aqiNumber"), aqiLabel: document.getElementById("aqiLabel"), aqiAdvice: document.getElementById("aqiAdvice"), pm25: document.getElementById("pm25"), pm10: document.getElementById("pm10"), ozone: document.getElementById("ozone"), no2: document.getElementById("no2"), timeline: document.getElementById("timeline"), timelineNote: document.getElementById("timelineNote"), guidanceNote: document.getElementById("guidanceNote"), outdoorTitle: document.getElementById("outdoorTitle"), outdoorCopy: document.getElementById("outdoorCopy"), windowTitle: document.getElementById("windowTitle"), windowCopy: document.getElementById("windowCopy"), filterTitle: document.getElementById("filterTitle"), filterCopy: document.getElementById("filterCopy"), settingsToggle: document.getElementById("settingsToggle"), settingsPanel: document.getElementById("settingsPanel"), cityInput: document.getElementById("cityInput"), citySearchButton: document.getElementById("citySearchButton") }; const levels = [ { max: 50, label: "Good", color: "var(--good)", advice: "Air quality is comfortable for normal outdoor activity.", outdoor: "Normal activity is fine.", window: "Opening windows is usually reasonable.", filter: "No special filter action needed." }, { max: 100, label: "Moderate", color: "var(--moderate)", advice: "Acceptable overall, but unusually sensitive people may notice symptoms.", outdoor: "Most activity is fine.", window: "Ventilate if indoor air feels stale.", filter: "Run filtration if sensitive." }, { max: 150, label: "Unhealthy for Sensitive Groups", color: "var(--sensitive)", advice: "Sensitive groups should reduce prolonged or heavy outdoor exertion.", outdoor: "Sensitive groups should take breaks.", window: "Limit long ventilation periods.", filter: "Run filtration if available." }, { max: 200, label: "Unhealthy", color: "var(--unhealthy)", advice: "Everyone may begin to experience effects; sensitive groups are at higher risk.", outdoor: "Reduce heavy outdoor activity.", window: "Keep windows mostly closed.", filter: "Run filtration continuously." }, { max: 300, label: "Very Unhealthy", color: "var(--very)", advice: "Health alert level. Outdoor exposure should be reduced.", outdoor: "Avoid heavy outdoor activity.", window: "Keep windows closed.", filter: "Use filtration if possible." }, { max: Infinity, label: "Hazardous", color: "var(--hazard)", advice: "Emergency-level air quality. Avoid outdoor exposure where possible.", outdoor: "Stay indoors if possible.", window: "Keep windows closed.", filter: "Use filtration continuously." } ]; function getLevel(aqi) { const safeAqi = Number.isFinite(aqi) ? aqi : 0; return levels.find(level => safeAqi <= level.max) || levels[0]; } function setAccent(color) { document.documentElement.style.setProperty("--accent", color); } 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 fmtNumber(value, digits = 0) { if (!Number.isFinite(value)) return "--"; return Number(value).toFixed(digits); } function fmtPollutant(value) { if (!Number.isFinite(value)) return "--"; return value >= 100 ? Math.round(value) : value.toFixed(1); } function fmtHour(timeString) { const date = new Date(timeString); if (Number.isNaN(date.getTime())) return "--"; return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); } 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 renderParticles(aqi) { els.mist.textContent = ""; const count = Math.min(90, Math.max(20, Math.round((Number(aqi) || 25) / 4))); for (let i = 0; i < count; i += 1) { const particle = document.createElement("span"); const size = 4 + Math.random() * 28; particle.className = "particle"; particle.style.width = `${size}px`; particle.style.height = `${size}px`; particle.style.left = `${Math.random() * 100}%`; particle.style.top = `${Math.random() * 110}%`; particle.style.animationDuration = `${22 + Math.random() * 34}s`; particle.style.animationDelay = `${Math.random() * -40}s`; els.mist.appendChild(particle); } } function renderTimeline(hourly, startIndex) { els.timeline.textContent = ""; const times = hourly.time || []; const aqis = hourly.us_aqi || []; const end = Math.min(startIndex + 10, times.length); const maxAqi = Math.max(100, ...aqis.slice(startIndex, end).filter(Number.isFinite)); for (let index = startIndex; index < end; index += 1) { const value = aqis[index]; const level = getLevel(value); const barHeight = Math.max(8, Math.min(100, (value / maxAqi) * 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: ${barHeight}%; background: ${level.color};" aria-hidden="true" ></div> </div> <div class="hour-value">${fmtNumber(value)}</div> `; els.timeline.appendChild(item); } els.timelineNote.textContent = "US AQI forecast"; } function renderGuidance(level) { els.guidanceNote.textContent = level.label; els.outdoorTitle.textContent = "Outdoor activity"; els.outdoorCopy.textContent = level.outdoor; els.windowTitle.textContent = "Ventilation"; els.windowCopy.textContent = level.window; els.filterTitle.textContent = "Indoor filter"; els.filterCopy.textContent = level.filter; } function renderAirQuality(place, data) { const hourly = data.hourly; if (!hourly || !hourly.time || !hourly.time.length) { throw new Error("Air quality data unavailable."); } const index = findCurrentIndex(hourly.time); const aqi = hourly.us_aqi?.[index]; const level = getLevel(aqi); setAccent(level.color); els.cityName.textContent = place.name; els.aqiNumber.textContent = fmtNumber(aqi); els.aqiLabel.textContent = level.label; els.aqiAdvice.textContent = level.advice; els.pm25.textContent = fmtPollutant(hourly.pm2_5?.[index]); els.pm10.textContent = fmtPollutant(hourly.pm10?.[index]); els.ozone.textContent = fmtPollutant(hourly.ozone?.[index]); els.no2.textContent = fmtPollutant(hourly.nitrogen_dioxide?.[index]); renderTimeline(hourly, index); renderGuidance(level); renderParticles(aqi); setStatus(`Updated ${fmtHour(hourly.time[index])}`); } async function fetchAirQuality(place) { setLoading(true); setStatus(`Loading ${place.name}...`); try { const params = new URLSearchParams({ latitude: String(place.latitude), longitude: String(place.longitude), hourly: [ "us_aqi", "pm10", "pm2_5", "carbon_monoxide", "nitrogen_dioxide", "sulphur_dioxide", "ozone", "dust", "uv_index" ].join(","), timezone: "auto", forecast_days: "3" }); const url = `https://air-quality-api.open-meteo.com/v1/air-quality?${params}`; const response = await fetch(url); if (!response.ok) { throw new Error("Air quality request failed."); } const data = await response.json(); renderAirQuality(place, data); } catch (error) { setStatus(error.message || "Could not load air quality.", 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 url = `https://geocoding-api.open-meteo.com/v1/search?${params}`; const response = await fetch(url); 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 fetchAirQuality(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"); }); fetchAirQuality(DEFAULT_CITY); </script> </body> </html>
Move to Deleted?
will be moved to Deleted.
Cancel
Delete
Rename
Name
Cancel
Rename