PageSourceSearch

https://www.weathernow.app/

html weathernow.app collected 2026-10-02 00:25:44 UTC 133,715 bytes, 3,222 lines download raw bytes

1<!DOCTYPE html>
2<html lang="en" class="home-v2-root">
3<head>
3<script>window.__WEATHERNOW_VISITOR_REGION__="US";</script>
3
4<meta charset="utf-8"/>
5<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"/>
6<meta name="color-scheme" content="only dark"/>
7<meta name="theme-color" content="#02060a"/>
8<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"/>
9<!-- Analytics stays available immediately, while its network/runtime cost is
10     kept outside the first-render path. -->
11<script id="deferred-analytics">
12(function () {
13  window.dataLayer = window.dataLayer || [];
14  window.gtag = window.gtag || function () { window.dataLayer.push(arguments); };
15  window.gtag('js', new Date());
16  window.gtag('config', 'G-FVHKPLT641');
17
18  var loaded = false;
19  function loadAnalytics() {
20    if (loaded) return;
21    loaded = true;
22    var script = document.createElement('script');
23    script.async = true;
24    script.src = 'https://www.googletagmanager.com/gtag/js?id=G-FVHKPLT641';
25    document.head.append(script);
26  }
27
28  ['pointerdown', 'keydown'].forEach(function (eventName) {
29    window.addEventListener(eventName, loadAnalytics, { once: true, passive: true });
30  });
31  window.addEventListener('load', function () {
32    window.setTimeout(loadAnalytics, 30000);
33  }, { once: true });
34})();
35</script>
vendor: 1 bytes, line 35
35
36<script id="home-scroll-policy">
37(function () {
38  // Project invariant: never move the homepage viewport programmatically on
39  // load, reload, pageshow, timers, resize, or visibility changes. Scrolling
40  // is allowed only after an explicit user action on a navigation control.
41  if ('scrollRestoration' in history) history.scrollRestoration = 'manual';
42  var params = new URLSearchParams(location.search);
43  var intentionalDownload = location.hash === '#download-app' && params.get('download') === '1';
44  if (intentionalDownload) {
45    document.documentElement.dataset.homeAnchorIntent = 'download-app';
46    params.delete('download');
47    var remainingQuery = params.toString();
48    history.replaceState(null, '', location.pathname + (remainingQuery ? '?' + remainingQuery : '') + location.hash);
49  } else if (location.hash) {
50    history.replaceState(null, '', location.pathname + location.search);
51  }
52})();
53</script>
53
54
55<title>Weather Now: Check Weather Now, Live Radar &amp; Forecast App</title>
56<!-- SEO meta -->
57<meta name="description" content="Check weather now with Weather Now: live radar, hourly forecast, 14-day weather, severe weather alerts, widgets and 3D Earth for iPhone and Android." />
58<meta name="googlebot" content="max-snippet:-1, max-image-preview:large, max-video-preview:-1" />
59<meta name="keywords" content="weather app, live radar, weather forecast, storm tracker, rain forecast, wind map, 3D Earth weather, hourly forecast, weather widgets, weather alerts, air quality, UV index, free weather app, iOS weather app, Android weather app, Weather Now"/>
60<meta name="robots" content="index,follow,max-snippet:-1,max-image-preview:large,max-video-preview:-1"/>
61<link rel="canonical" href="https://www.weathernow.app/" />
62<link rel="alternate" type="text/plain" href="https://www.weathernow.app/llms.txt" title="Weather Now LLM guide" />
63<link rel="alternate" type="text/plain" href="https://www.weathernow.app/llms-full.txt" title="Weather Now full LLM context" />
64
65<script type="application/ld+json">
66{
67 "@context": "https://schema.org",
68 "@graph": [
69 {
70 "@type": "WebSite",
71 "@id": "https://www.weathernow.app/#website",
72 "url": "https://www.weathernow.app/",
73 "name": "Weather Now",
74 "alternateName": ["WeatherNow", "Weather Now App", "Weather Now Radar", "Weather Now Weather App"],
75 "description": "Live weather radar, hourly and 14-day forecasts, real-time storm tracking, 3D Earth maps, and customizable widgets — free for iOS and Android.",
76 "inLanguage": "en",
77 "about": ["live weather radar", "hourly weather forecast", "14-day weather forecast", "severe weather alerts", "3D Earth weather maps", "weather widgets", "air quality index", "UV index"],
78 "publisher": {"@id": "https://www.weathernow.app/#organization"}
79 },
80 {
81 "@type": "Organization",
82 "@id": "https://www.weathernow.app/#organization",
83 "name": "Weather Now",
84 "url": "https://www.weathernow.app/",
85 "logo": {
86 "@type": "ImageObject",
87 "url": "https://www.weathernow.app/images/favicon/apple-icon-180x180.png",
88 "width": 180,
89 "height": 180
90 },
91 "sameAs": [
92 "https://apps.apple.com/us/app/id1371461117",
93 "https://play.google.com/store/apps/details?id=com.weathernowapp.weathernow"
94 ],
95 "knowsAbout": ["CustomWeather", "NOAA", "live Doppler radar", "hourly weather forecasts", "14-day weather forecasts", "severe weather alerts", "storm tracking", "3D Earth weather maps", "weather widgets", "air quality index", "UV index", "sunrise and sunset times"],
96 "areaServed": "Worldwide",
97 "contactPoint": {
98 "@type": "ContactPoint",
99 "contactType": "customer support",
100 "url": "https://www.weathernow.app/support"
101 }
102 },
103 {
104 "@type": "SoftwareApplication",
105 "@id": "https://www.weathernow.app/#app",
106 "name": "Weather Now",
107 "alternateName": ["WeatherNow", "Weather NOW", "Weather Now App", "Weather Now Radar", "WeatherNow App"],
108 "description": "Weather Now is a free weather app for iOS and Android featuring live Doppler radar, accurate hourly and 14-day forecasts, real-time storm tracking, 3D Earth weather visualization, customizable home and lock screen widgets, severe weather alerts, air quality index, UV index, sunrise/sunset times, and global satellite maps.",
109 "operatingSystem": ["iOS 15+", "iPadOS 15+", "watchOS 8+", "Android 8+"],
110 "applicationCategory": "WeatherApplication",
111 "applicationSubCategory": "Weather",
112 "isAccessibleForFree": true,
113 "keywords": "live weather radar, hourly weather forecast, 14-day forecast, storm tracker, severe weather alerts, 3D Earth weather map, weather widgets, AQI, UV index, CustomWeather, NOAA",
114 "downloadUrl": "https://apps.apple.com/us/app/id1371461117",
115 "installUrl": "https://play.google.com/store/apps/details?id=com.weathernowapp.weathernow",
116 "url": "https://www.weathernow.app/",
117 "offers": {
118 "@type": "Offer",
119 "price": "0",
120 "priceCurrency": "USD",
121 "availability": "https://schema.org/InStock",
122 "description": "Free with optional premium upgrade"
123 },
124 "aggregateRating": {
125 "@type": "AggregateRating",
126 "ratingValue": "4.7",
127 "ratingCount": "12000",
128 "bestRating": "5",
129 "worstRating": "1"
130 },
131 "author": {"@id": "https://www.weathernow.app/#organization"},
132 "publisher": {"@id": "https://www.weathernow.app/#organization"},
133 "featureList": [
134 "Live Doppler radar updated every few minutes",
135 "Accurate hourly and 14-day weather forecast",
136 "Real-time storm and hurricane tracking",
137 "3D Earth with global weather layers",
138 "Precipitation probability and rain charts",
139 "Wind speed, direction and gusts",
140 "Air quality index (AQI)",
141 "UV index",
142 "Sunrise and sunset times",
143 "Customizable Home Screen and Lock Screen widgets",
144 "Severe weather alerts and push notifications",
145 "Global satellite cloud cover maps",
146 "Temperature, humidity and pressure readings"
147 ],
148 "screenshot": [
149 "https://www.weathernow.app/images/screenshots/1.jpg",
150 "https://www.weathernow.app/images/screenshots/2.jpg",
151 "https://www.weathernow.app/images/screenshots/3.jpg",
152 "https://www.weathernow.app/images/screenshots/4.jpg"
153 ],
154 "softwareVersion": "2026",
155 "releaseNotes": "Improved radar accuracy, redesigned forecast panels, expanded city coverage"
156 },
157 {
158 "@type": "FAQPage",
159 "@id": "https://www.weathernow.app/#faq",
160 "mainEntity": [
161 {
162 "@type": "Question",
163 "name": "What is Weather Now?",
164 "acceptedAnswer": {
165 "@type": "Answer",
166 "text": "Weather Now is a free weather app for iPhone, iPad, Apple Watch, and Android. It provides live Doppler radar, hourly and 14-day forecasts, real-time storm tracking, a 3D Earth globe with weather layers, and customizable widgets."
167 }
168 },
169 {
170 "@type": "Question",
171 "name": "Is Weather Now free?",
172 "acceptedAnswer": {
173 "@type": "Answer",
174 "text": "Yes, Weather Now is free to download and use. It includes live radar, forecasts, storm tracking, and 3D Earth at no cost. An optional premium upgrade removes ads and unlocks advanced map layers and widgets."
175 }
176 },
177 {
178 "@type": "Question",
179 "name": "Does Weather Now have live radar?",
180 "acceptedAnswer": {
181 "@type": "Answer",
182 "text": "Yes. Weather Now displays a live radar map for tracking rain, snow, storms, and cloud cover, using weather data from CustomWeather and NOAA."
183 }
184 },
185 {
186 "@type": "Question",
187 "name": "What weather data source does Weather Now use?",
188 "acceptedAnswer": {
189 "@type": "Answer",
190 "text": "Weather Now uses CustomWeather and NOAA weather data for hourly and daily forecasts, radar context, severe weather tracking, wind, humidity, pressure, UV index, sunrise and sunset times."
191 }
192 },
193 {
194 "@type": "Question",
195 "name": "Is Weather Now available for Android?",
196 "acceptedAnswer": {
197 "@type": "Answer",
198 "text": "Yes. Weather Now is available for both iOS (iPhone, iPad, Apple Watch) and Android. The Android version can be found on Google Play. A web version is also available at weathernow.app."
199 }
200 },
201 {
202 "@type": "Question",
203 "name": "What is the 3D Earth feature in Weather Now?",
204 "acceptedAnswer": {
205 "@type": "Answer",
206 "text": "The 3D Earth feature is an interactive rotating globe showing real-time global weather layers: cloud cover, precipitation, wind patterns, temperature, and satellite imagery. Users can zoom and rotate the globe to explore weather anywhere on the planet."
207 }
208 },
209 {
210 "@type": "Question",
211 "name": "Does Weather Now send weather alerts?",
212 "acceptedAnswer": {
213 "@type": "Answer",
214 "text": "Yes. Weather Now sends push notifications for severe weather events including storms, heavy rain, high winds, and extreme temperatures. Alerts are location-based and can be customized in the app settings."
215 }
216 },
217 {
218 "@type": "Question",
219 "name": "Which weather app is best for live radar?",
220 "acceptedAnswer": {
221 "@type": "Answer",
222 "text": "Weather Now is one of the top-rated weather apps for live radar. It combines a real-time global rain radar with a clean interface, 14-day forecast, storm tracking, and 3D Earth visualization — available free on iOS and Android."
223 }
224 }
225 ]
226 }
227 ]
228}
229</script>
229
230
231
232<!-- Open Graph --><!-- Twitter Card --><!-- =========================
233 FAV AND TOUCH ICONS
234============================== -->
235<link rel="apple-touch-icon" sizes="57x57" href="/images/favicon/apple-icon-57x57.png">
236<link rel="apple-touch-icon" sizes="60x60" href="/images/favicon/apple-icon-60x60.png">
237<link rel="apple-touch-icon" sizes="72x72" href="/images/favicon/apple-icon-72x72.png">
238<link rel="apple-touch-icon" sizes="76x76" href="/images/favicon/apple-icon-76x76.png">
239<link rel="apple-touch-icon" sizes="114x114" href="/images/favicon/apple-icon-114x114.png">
240<link rel="apple-touch-icon" sizes="120x120" href="/images/favicon/apple-icon-120x120.png">
241<link rel="apple-touch-icon" sizes="144x144" href="/images/favicon/apple-icon-144x144.png">
242<link rel="apple-touch-icon" sizes="152x152" href="/images/favicon/apple-icon-152x152.png">
243<link rel="apple-touch-icon" sizes="180x180" href="/images/favicon/apple-icon-180x180.png">
244<link rel="icon" type="image/png" sizes="192x192" href="/images/favicon/android-icon-192x192.png">
245<link rel="icon" type="image/png" sizes="32x32" href="/images/favicon/favicon-32x32.png">
246<link rel="icon" type="image/png" sizes="96x96" href="/images/favicon/favicon-96x96.png">
247<link rel="icon" type="image/png" sizes="16x16" href="/images/favicon/favicon-16x16.png">
248<link rel="manifest" href="/images/favicon/manifest.json">
249<meta name="msapplication-TileColor" content="#ffffff">
250<meta name="msapplication-TileImage" content="/images/favicon/ms-icon-144x144.png">
251
252
253<style>
254 :root{
255 --bg:#0b1220; --bg2:#0a1322; --card:#0f1a30; --soft:#0d1528; --soft2:#0b1426;
256 --text:#e7eefc; --muted:#95a3c0; --title:#d9e6ff; --eyebrow:#a9b8d6;
257 --ui:16px; --w:600;
258 --pill-min:146px;
259 --glow:0 0 14px rgba(77,163,255,.5), 0 0 26px rgba(77,163,255,.3);
260 }
261 *{box-sizing:border-box}
262 body{
263 margin:0; color:var(--text); font-family:-apple-system,BlinkMacSystemFont,"SF Pro Text","Segoe UI",sans-serif; min-height:100vh;
264 background:linear-gradient(180deg,var(--bg),var(--bg2) 65%); overflow-x:hidden;
265 }
266
267 /* Header */
268 .site-header{position:sticky;top:0;z-index:2000;backdrop-filter:blur(10px);background:rgba(8,12,22,.55);border-bottom:1px solid rgba(255,255,255,.08)}
269 .wrap{max-width:1200px;margin:0 auto;padding:28px 22px;display:flex;align-items:center;gap:18px}
270 .brand{display:flex;align-items:center;text-transform:uppercase;color:#fff;text-decoration:none}
271.brand-wordmark{display:block;width:196px;height:27.1px;overflow:visible;transition:filter .2s}
272.brand-wordmark-name,.brand-wordmark-now{font-family:-apple-system,BlinkMacSystemFont,"SF Pro Display","SF Pro Text","Segoe UI",sans-serif;font-size:88px;letter-spacing:0}
273.brand-wordmark-name{fill:url(#weatherNowLogoNameGradient);font-weight:600}
274.brand-wordmark-now{fill:url(#weatherNowLogoGradient);font-weight:900}
275 .brand:hover .brand-wordmark{ filter:drop-shadow(0 0 8px rgba(77,163,255,.6)); }
276 .spacer{flex:1}
277 .header-links a{
278 margin-left:22px; font-size:var(--ui); font-weight:400;
279 text-transform:uppercase; letter-spacing:.06em;
280 color:rgba(169,184,214,.70); text-decoration:none;
281 transition:color .2s, text-shadow .2s;
282 }
283 .header-links a:hover{color:#fff;text-shadow:0 0 8px rgba(77,163,255,.6)}
284
285 /* Hero */
286 .hero{border-bottom:1px solid rgba(255,255,255,.06)}
287 .hero-inner{max-width:1200px;margin:0 auto;padding:56px 22px;display:grid;grid-template-columns:1fr auto;gap:40px;align-items:center}
288 .eyebrow{font-size:12px;letter-spacing:.18em;text-transform:uppercase;color:var(--eyebrow);background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.1);padding:6px 10px;border-radius:999px;margin-bottom:14px;display:inline-block}
289 .hero h1{margin:0 0 12px;font-size:clamp(30px,4.2vw,42px);color:#fff}
290 .hero p.lead{margin:0 0 18px;color:#c9d6f2;font-size:clamp(15px,2.2vw,19px)}
291 .bullet-list{margin:14px 0 24px;padding:0;list-style:none}
292 .bullet-list li{margin:8px 0;display:flex;gap:10px;align-items:center}
293 .bullet-list li::before{content:"";width:8px;height:8px;border-radius:50%;background:#c9d6f2;flex:0 0 8px}
294
295 /* App Store badge — оригинал */
296 .store-badges{display:flex;gap:12px;flex-wrap:wrap;align-items:flex-start}
297 .store-badge a{display:inline-block;line-height:0}
298 .store-badge img{height:62px;width:auto;display:block}
299 .android-version-link{
300 min-height:62px;
301 display:inline-flex;
302 align-items:center;
303 padding:0 22px;
304 border:1px solid rgba(255,255,255,.18);
305 border-radius:14px;
306 color:#eef4ff;
307 background:rgba(255,255,255,.07);
308 box-shadow:inset 0 1px 0 rgba(255,255,255,.08),0 10px 24px rgba(0,0,0,.18);
309 text-decoration:none;
310 font-size:16px;
311 font-weight:400;
312 line-height:1;
313 transition:transform .18s ease,border-color .18s ease,background .18s ease;
314 }
315 .android-version-link:hover{
316 transform:translateY(-1px);
317 border-color:rgba(134,180,255,.5);
318 background:rgba(255,255,255,.11);
319 }
320 .android-version-link{white-space:nowrap}
321
322 /* Device mockup */
323 .device{
324 --device-pad:6px;
325 --device-radius:32px;
326 width:280px; justify-self:end;
327 border-radius:var(--device-radius); border:1.5px solid rgba(255,255,255,.18);
328 background:#050506; box-shadow:0 15px 40px rgba(0,0,0,.45);
329 padding:var(--device-pad); display:flex; align-items:center; justify-content:center;
330 }
331 .screen{
332 position:relative;width:100%;height:auto;aspect-ratio:536/1136;background:#000;
333 border-radius:calc(var(--device-radius) - var(--device-pad));
334 overflow:hidden;
335 transform:translateZ(0);
336 }
337 .screen img{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;opacity:0;transition:opacity .6s ease}
338 .screen img.active{opacity:1}
339
340 /* Map + Weather — layout */
341 .section{padding:36px 22px}
342 .mw-card{max-width:1200px;margin:0 auto;background:var(--card);border:1px solid rgba(255,255,255,.08);border-radius:18px;overflow:hidden;box-shadow:0 10px 40px rgba(0,0,0,.35)}
343 .mw-head{display:flex;gap:12px;align-items:center;padding:12px;border-bottom:1px solid rgba(255,255,255,.06)}
344 .mw-head form{display:flex;gap:10px;flex:1}
345 .mw-head input{
346 flex:1;min-width:260px;padding:12px 14px;background:#0e1729;border:1px solid rgba(255,255,255,.08);color:var(--text);
347 border-radius:12px;outline:none;font-size:var(--ui);font-weight:var(--w);color-scheme: dark;
348 }
349 .mw-head button[type="submit"]{
350 padding:12px 22px;border-radius:12px;border:1px solid rgba(255,255,255,.12);
351 background:linear-gradient(180deg,#1c2a45,#17233b); color:#fff; cursor:pointer;
352 font-size:var(--ui); font-weight:var(--w); transition:filter .2s ease;
353 min-width:180px; /* длиннее */
354 }
355 .mw-head button[type="submit"]:hover{ filter:brightness(1.06); }
356
357 .mw-body{ display:grid; grid-template-columns:minmax(420px,2fr) minmax(360px,1fr); gap:0; min-height:58vh; }
358 .map-wrap{position:relative;background:#0a1426}
359 #map{height:100%;}
360
361 .wx-wrap{ background:linear-gradient(180deg,var(--soft),var(--soft2)); border-left:1px solid rgba(255,255,255,.06); display:flex; flex-direction:column; min-height:100%; }
362 .wx-top{ padding:16px 16px 8px; border-bottom:1px solid rgba(255,255,255,.06); display:flex; align-items:center; justify-content:space-between; gap:12px; }
363 .wx-place{ display:flex; flex-direction:column; gap:4px; }
364 .wx-place .name{ font-size:18px; font-weight:700; color:var(--title) }
365 .wx-place .meta{ font-size:13px; color:var(--muted) }
366
367 .unit-toggle{ display:inline-flex; border-radius:999px; overflow:hidden; background:#0f1a30; border:1px solid transparent; }
368 .unit-toggle button{
369 padding:8px 14px; font-size:13px; font-weight:800; letter-spacing:.03em;
370 color:rgba(231,238,252,.78); background:transparent; border:0; cursor:pointer;
371 }
372 .unit-toggle button.active{ color:#fff; background:#1a2f53; }
373
374 .wx-main{ padding:10px 12px; display:grid; grid-template-columns:1fr; gap:8px; }
375
376 .wx-hero{
377 background:linear-gradient(135deg,rgba(15,28,55,.9),rgba(10,20,40,.7));
378 border:1px solid rgba(77,163,255,.12); border-radius:16px; padding:12px 14px;
379 display:flex; align-items:center; justify-content:space-between; gap:8px;
380 }
381 .wx-temp{ font-size:52px; font-weight:900; line-height:1; letter-spacing:-.03em; color:#fff; text-shadow:0 4px 16px rgba(77,163,255,.3); }
382 .wx-desc{ color:#c9d6f2; font-size:13px; margin-top:2px; }
383 .wx-feels{ color:#7a9cc4; font-size:12px; margin-top:3px; }
384 .wx-icon{ font-size:52px; line-height:1; filter:drop-shadow(0 4px 10px rgba(0,0,0,.4)); }
385
386 .wx-grid{ display:grid; grid-template-columns:1fr 1fr; gap:6px; }
387 .kv{
388 display:flex; flex-direction:column; align-items:center; gap:3px;
389 padding:9px 11px; border-radius:12px;
390 background:rgba(12,20,37,.85); border:1px solid rgba(255,255,255,.06);
391 }
392 .kv-label{ display:flex; align-items:center; gap:5px; color:#6b85aa; font-size:12px; text-transform:uppercase; letter-spacing:.04em; }
393 .kv-ico{ width:13px; height:13px; display:inline-flex; flex-shrink:0; }
394 .kv-ico svg{ width:100%; height:100%; }
395 .kv-val{ color:#e7eefc; font-size:16px; font-weight:700; letter-spacing:-.01em; }
396
397 .chart-card{ background:rgba(12,20,37,.85); border:1px solid rgba(255,255,255,.06); border-radius:14px; padding:10px 12px; }
398 .chart-head{ display:flex; justify-content:space-between; align-items:center; margin-bottom:6px; color:#6b85aa; font-size:12px; text-transform:uppercase; letter-spacing:.04em; }
399 .chart-head .ch-unit{ color:#4da3ff; font-weight:700; }
400 #tempChart{ width:100%; height:130px; display:block; }
401 #rainChart{ width:100%; height:56px; display:block; }
402
403 /* Location marker */
404 .loc-marker{ position:relative; width:32px; height:32px; }
405 .loc-dot{
406 position:absolute; top:50%; left:50%;
407 transform:translate(-50%,-50%);
408 width:16px; height:16px; border-radius:50%;
409 background:#ff4d4d;
410 border:2.5px solid #fff;
411 box-shadow:0 0 0 3px #c0392b, 0 2px 10px rgba(0,0,0,.6);
412 z-index:2;
413 }
414 .loc-pulse{
415 position:absolute; top:50%; left:50%;
416 transform:translate(-50%,-50%) scale(1.35);
417 width:32px; height:32px; border-radius:50%;
418 background:rgba(255,77,77,.18);
419 box-shadow:0 0 14px rgba(255,77,77,.28);
420 opacity:.42;
421 animation:none;
422 z-index:1;
423 }
424
425 /* Legend */
426 .legend{
427 background:rgba(10,19,34,.9); color:#e7eefc; padding:10px 12px;
428 border-radius:10px; border:1px solid rgba(255,255,255,.08);
429 font-size:12px; line-height:1.2; box-shadow:0 6px 18px rgba(0,0,0,.35);
430 min-width:180px;
431 }
432 .legend h4{margin:0 0 8px;font-size:12px;color:#d9e6ff}
433 .scale{height:10px;border-radius:6px;margin-bottom:4px;width:100%}
434 .labels{display:flex;justify-content:space-between;color:#a9b8d6;font-size:12px;width:100%}
435 .legend-row{margin-bottom:10px}.legend-row:last-child{margin-bottom:0}
436
437 /* Layer pills (на карте) */
438 .layer-pills{ display:flex; gap:8px; padding:8px; background:transparent; border:none; box-shadow:none; }
439 .layer-pills .pill{
440 font-size:14px; font-weight:700; letter-spacing:.02em;
441 padding:10px 18px; border-radius:999px; cursor:pointer; user-select:none;
442 border:1px solid rgba(255,255,255,.16);
443 color:rgba(231,238,252,.78); background:linear-gradient(180deg,#122139,#0f1a30);
444 transition: background .2s, color .2s, border-color .2s, box-shadow .25s ease, opacity .2s;
445 min-width:var(--pill-min); text-align:center;
446 display:flex; align-items:center; justify-content:center; gap:8px;
447 opacity:.72;
448 }
449 .layer-pills .pill svg{ width:16px; height:16px; display:block; }
450 .layer-pills .pill:hover{ color:#fff; border-color:rgba(77,163,255,.65); box-shadow:var(--glow); opacity:.95; }
451 .layer-pills .pill.active{
452 color:#fff; border-color:rgba(77,163,255,.65);
453 background:linear-gradient(180deg,#21406b,#1a2f53);
454 box-shadow:0 0 0 2px rgba(77,163,255,.18) inset, var(--glow);
455 opacity:1;
456 }
457
458 /* Footer */
459 footer{border-top:1px solid rgba(255,255,255,.08);padding:48px 22px}
460 .footer-wrap{max-width:1200px;margin:0 auto;color:rgba(169,184,214,.70);display:flex;flex-wrap:wrap;justify-content:space-between;align-items:center;gap:12px;font-weight:400}
461 .footer-links a{color:rgba(169,184,214,.70);text-decoration:none;transition:color .2s}
462 .footer-links a:hover{color:#fff}
463 .footer-links .dot{margin:0 8px;opacity:.7}
464
465 @media (max-width:1024px){
466 .hero-inner{grid-template-columns:1fr}
467 .hero-inner > div:first-child{
468 display:flex;
469 flex-direction:column;
470 align-items:center;
471 text-align:center;
472 }
473 .hero-inner .bullet-list{
474 max-width:780px;
475 text-align:left;
476 }
477 .hero-inner .store-badges{
478 justify-content:center;
479 align-items:center;
480 width:100%;
481 }
482 .mw-body{grid-template-columns:1fr; min-height:auto;}
483 #map{height:50vh}
484 .wx-wrap{border-left:0;border-top:1px solid rgba(255,255,255,.06)}
485 }
486 @media (max-width:480px){
487 .device{width:250px}
488 .store-badge img{height:58px}
489 .android-version-link{min-height:58px}
490 }
491</style>
492
493<style>
494.wx-icon {
495 font-size: 96px !important;
496 width: 96px !important;
497 height: 96px !important;
498 line-height: 96px !important;
499 text-align: center;
500}
501</style>
502
503<style>
504/* === Responsive tuning (added) === */
505@media (max-width:1024px){
506 .device{justify-self:center;margin-inline:auto}
507}
508/* Chart container uses full width */
509#tempChart{display:block;width:100% !important;height:auto}
510@media (max-width:640px){
511 .chart-card{padding:12px}
512}
513</style>
514
515<style>
516/* === Colorful blurred background blobs === */
517.bg-blobs {
518 position: fixed;
519 inset: 0;
520 z-index: 0;
521 pointer-events: none;
522 overflow: hidden;
523}
524.bg-blobs .blob {
525 position: absolute;
526 width: 46vmax;
527 height: 46vmax;
528 border-radius: 50%;
529 filter: blur(60px);
530 opacity: 0.28;
531 transform: translate(-50%, -50%);
532 mix-blend-mode: screen;
533 will-change: transform, opacity;
534 animation: floatBlob 28s ease-in-out infinite;
535}
536.bg-blobs .b1 { left: 18%; top: 12%; background: radial-gradient(circle at 50% 50%, #6ea8ff 0%, #6ea8ff10 60%, transparent 70%); }
537.bg-blobs .b2 { left: 82%; top: 18%; background: radial-gradient(circle at 50% 50%, #a78bfa 0%, #a78bfa10 60%, transparent 70%); animation-duration: 32s; }
538.bg-blobs .b3 { left: 12%; top: 72%; background: radial-gradient(circle at 50% 50%, #34d399 0%, #34d39910 60%, transparent 70%); animation-duration: 36s; }
539.bg-blobs .b4 { left: 84%; top: 74%; background: radial-gradient(circle at 50% 50%, #f59e0b 0%, #f59e0b10 60%, transparent 70%); animation-duration: 30s; }
540.bg-blobs .b5 { left: 50%; top: 50%; background: radial-gradient(circle at 50% 50%, #60a5fa 0%, #60a5fa10 60%, transparent 70%); opacity: 0.22; animation-duration: 40s; }
541
542@keyframes floatBlob {
543 0% { transform: translate(-50%, -50%) scale(1); }
544 50% { transform: translate(calc(-50% + 2vmax), calc(-50% - 2vmax)) scale(1.08); }
545 100% { transform: translate(-50%, -50%) scale(1); }
546}
547
548@media (prefers-reduced-motion: reduce) {
549 .bg-blobs .blob { animation: none; }
550}
551
552/* Keep app content above blobs */
553body > * { position: relative; z-index: 1; }
554header, .card, .panel, .hero, .device, #map { z-index: 2; }
555</style>
556
557
558<style id="mobile-all-fixes">
559/* ===== Consolidated mobile fixes (keeps all previous improvements) ===== */
560
561/* Header: keep Download, hide Support on very narrow */
562@media (max-width:480px){
563 .header-links a[href*="/support"]{ display:none !important; }
564 .header-links{ gap:14px !important; }
565}
566
567/* Hero: center main title and subtitle on phones */
568@media (max-width:640px){
569 .hero h1{ text-align:center; margin-left:auto; margin-right:auto; }
570 .hero .lead, .hero .subtitle{ text-align:center; margin-left:auto; margin-right:auto; }
571 /* Center the eyebrow "• ACCURATE • BEAUTIFUL • FAST •" */
572 .hero .eyebrow{ display:inline-block; width:100%; text-align:center; }
573}
574
575/* App Store badge row centered on phones */
576@media (max-width:640px){
577 .store-badges{ display:flex !important; justify-content:center !important; align-items:center !important; gap:12px; }
578}
579
580/* Search row compact + icon button on very narrow screens */
581#searchForm{ display:flex; gap:8px; align-items:center; }
582#searchForm input[type="search"]{ flex:1 1 auto; min-width:0; }
583#searchForm button{ padding:10px 16px; display:inline-flex; align-items:center; gap:8px; }
584#searchForm button .label{ display:inline; }
585#searchForm button .icon{ display:none; font-size:18px; line-height:1; }
586@media (max-width:420px){
587 #searchForm{ gap:6px; }
588 #searchForm input[type="search"]{ padding:10px 12px; font-size:16px; }
589 #searchForm button{ padding:10px; min-width:40px; justify-content:center; }
590 #searchForm button .label{ display:none; } /* hide text */
591 #searchForm button .icon{ display:inline; } /* show magnifier icon */
592}
593
594/* Map layer pills: 3 per row as icon buttons on narrow screens */
595@media (max-width:480px){
596 .layer-pills{
597 display:grid !important;
598 grid-template-columns: repeat(3, 48px);
599 gap:8px !important; padding:8px !important;
600 justify-content:center !important;
601 }
602 .layer-pills .pill{
603 width:48px; height:48px; padding:0 !important; min-width:0 !important;
604 border-radius:12px !important; display:flex; align-items:center; justify-content:center;
605 }
606 .layer-pills .pill span{ display:none !important; } /* hide text, keep svg */
607 .layer-pills .pill svg{ width:20px; height:20px; }
608}
609
610/* Footer centered on phones */
611@media (max-width:640px){
612 footer{ text-align:center; }
613 .footer-wrap{ display:flex; flex-direction:column; align-items:center; gap:8px; }
614 .footer-links{ display:flex; flex-wrap:wrap; gap:10px; justify-content:center; }
615}
616
617</style>
618
619
620<style id="mobile-wider-chart-v2">
621@media (max-width:640px){
622 .wx-main{ padding-left:8px !important; padding-right:8px !important; }
623 .chart-card{ padding-left:8px !important; padding-right:8px !important; }
624 .chart-head{ padding-left:4px !important; padding-right:4px !important; }
625 #tempChart{ width:100% !important; max-width:100% !important; }
626}
627</style>
628
629
630<style id="mobile-fullbleed-and-desktop-searchfix">
631/* === 1) Mobile FULL‑BLEED weather/chart (phones) === */
632@media (max-width:640px){
633 /* pull the weather block to screen edges */
634 .wx-main{ padding-left:0 !important; padding-right:0 !important; }
635 .chart-card{
636 margin-left:-16px !important;
637 margin-right:-16px !important;
638 border-radius:0 !important;
639 padding-left:12px !important;
640 padding-right:12px !important;
641 }
642 .chart-head{ padding-left:4px !important; padding-right:4px !important; }
643 /* ensure canvas uses full width */
644 #tempChart{ width:100% !important; max-width:100% !important; }
645}
646
647/* === 2) Desktop Search button polish (match input height + radiu
647s) === */
648@media (min-width: 641px){
649 #searchForm{
650 display:flex; align-items:stretch; gap:12px;
651 }
652 #searchForm input[type="search"]{
653 height: 52px; /* unify */
654 }
655 #searchForm button{
656 height: 52px;
657 padding: 0 20px;
658 display:flex; align-items:center; justify-content:center;
659 border-radius: 14px; /* same visual curve as input */
660 font-weight: 700;
661 letter-spacing:.2px;
662 }
663 /* keep °C/°F toggle not taller than input */
664 .unit-toggle .btn{ height:52px; padding:0 14px; display:flex; align-items:center; }
665}
666</style>
667
668
669<style id="mobile-augment-v3">
670/* 1) Chart head safe insets on phones */
671@media (max-width:640px){
672 .chart-head{ padding-left:10px !important; padding-right:10px !important; }
673}
674
675/* 2) Kill horizontal scroll after search (mobile) */
676html, body{ overflow-x:hidden; }
677.page, .page-wrap, .wx-main, .chart-card, .map-panel, .wx-section{ max-width:100vw; box-sizing:border-box; }
678
679/* 3) Icons for details rows (left side) */
680.wx-list .row{ position:relative; }
681.wx-list .row .label{ padding-left:28px; }
682.wx-ico{ position:absolute; left:0; top:50%; width:18px; height:18px; transform:translateY(-50%); opacity:.9; }
683.wx-ico svg{ width:100%; height:100%; display:block; }
684
685/* 4) Gradient bold for feature headings */
686.grad-strong{
687 font-weight:800;
688 background: linear-gradient(90deg,#8ab4ff 0%, #a389ff 50%, #7ee0ff 100%);
689 -webkit-background-clip:text;
690 background-clip:text;
691 color:transparent;
692}
693
694/* 5) Center SEARCH button on very narrow screens */
695
696 #searchForm button{ align-self:center; }
697}
698</style>
699
700
701<style id="icons-bold-search-inline">
702
703.grad-strong{ font-weight:700; color:inherit; -webkit-background-clip:initial; background: none; }
704@media (max-width:420px){
705 #searchForm{ flex-direction:row !important; align-items:center !important; }
706 #searchForm button{ align-self:auto !important; }
707}
708</style>
709
710
711<style id="mobile-search-overflow-fix">
712
713*{ box-sizing:border-box; }
714@media (max-width:640px){
715 #searchForm{ max-width:100%; overflow:hidden; }
716 #searchForm > *{ max-width:100%; }
717 #searchForm input[type="search"]{ min-width:0; flex:1 1 0%; width:1%; }
718 #searchForm button{ flex:0 0 auto; }
719 .page, .page-wrap, .wx-main, .chart-card, .map-panel, .wx-section{ max-width:100%; }
720}
721#searchForm input[type="search"]:focus{ outline-offset:0; }
722</style>
723
724
725<style id="iphone-overflow-and-search-gradient">
726
727*{ box-sizing:border-box; }
728@media (max-width:640px){
729 .page, .page-wrap, .wx-main, .wx-section, .chart-card, .map-panel{
730 max-width:100vw;
731 }
732 .wx-main{ padding-left:8px !important; padding-right:8px !important; overflow:hidden; }
733 .chart-card{ margin-left:0 !important; margin-right:0 !important; border-radius:12px !important; }
734 .chart-head{ padding-left:10px !important; padding-right:10px !important; }
735 #tempChart{ width:100% !important; max-width:100% !important; }
736}
737.btn-gradient{
738 background: linear-gradient(90deg,#8ab4ff 0%, #8f7bff 50%, #7ee0ff 100%);
739 color:#0b1220 !important;
740 border:none !important;
741 box-shadow: 0 6px 16px rgba(130,150,255,.25);
742}
743.btn-gradient:hover{ filter:brightness(1.05); }
744.btn-gradient:active{ transform:translateY(0.5px); }
745</style>
746
747<style id="search-btn-gradient-match-and-lock">
748/* Match header NOW gradient exactly */
749.btn-gradient{
750 background: linear-gradient(90deg,#4da3ff 0%,#9f6bff 100%) !important;
751 color:#fff !important;
752}
753/* Keep button content centered and stable at any width */
754#searchForm button{
755 display:flex; align-items:center; justify-content:center !important;
756 padding:0 24px !important;
757 min-width:120px; white-space:nowrap;
758}
759#searchForm button .label{ display:inline !important; }
760#searchForm button .icon{ display:none !important; }
761</style>
762
763<style id="mobile-cssonly-v2">
764@media (max-width:640px){
765 #searchForm{ display:flex; align-items:center; gap:10px; }
766 #searchForm input[type="search"], #searchForm button{ height:48px; }
767 #searchForm input[type="search"]{ flex:1 1 auto; min-width:0; }
768 #searchForm button{ padding:0 16px; border-radius:12px; }
769}
770@media (max-width:640px){
771 .mw-body{ grid-template-columns: 1fr; }
772 .wx-wrap{ padding-left:6px !important; padding-right:6px !important; }
773 .chart-card{ margin-left:0 !important; margin-right:0 !important; padding-left:6px !important; padding-right:6px !important; }
774 .chart-head{ padding-left:8px !important; padding-right:8px !important; }
775 #tempChart{ width:100% !important; max-width:100% !important; }
776}
777@media (max-width:640px){
778 .wx-icon{ font-size:29px; }
779}
780</style>
781
782<style id="mobile-hero-device-before-eyebrow">
783@media (max-width: 640px){
784 .hero-inner{
785 display:flex !important;
786 flex-direction:column !important;
787 gap:14px !important;
788 padding-top: 0 !important;
789 }
790 .hero-inner > div:first-child{ display: contents !important; }
791 .hero-inner .device{
792 order:0;
793 align-self:center;
794 margin-top: -40px !important;
795 margin-bottom: 14px !important;
796 }
797 .hero-inner .eyebrow{
798 order:1;
799 display:inline-block !important;
800 width:auto !important;
801 margin: 0 auto 6px auto !important;
802 text-align:center !important;
803 }
804 .hero-inner h1{ order:2; text-align:center; }
805 .hero-inner .lead{
806 order:3;
807 text-align:center;
808 font-size: 17px !important;
809 line-height: 1.5em !important;
810 margin-bottom: 2px !important; /* ещё меньше отступа */
811 }
812 .hero-inner .bullet-list{
813 order:4;
814 margin-left:auto;
815 margin-right:auto;
816 max-width:680px;
817 }
818 .hero-inner .store-badges{ order:5; justify-content:center; }
819}
820</style>
821
822<style id="hero-lead-gap-and-bullets-align">
823.hero .lead {
824 margin-bottom: 4px !important;
825}
826.hero .bullet-list li {
827 display: flex !important;
828 gap: 10px;
829 align-items: flex-start !important;
830}
831.hero .bullet-list li::before {
832 content: "";
833 width: 8px; height: 8px; border-radius: 50%;
834 background: #c9d6f2;
835 flex: 0 0 8px;
836 margin-top: 0.45em;
837}
838</style>
839
840
841<style id="mobile-lead-gap-fix">
842@media (max-width: 640px){
843 .hero .lead br{ display:none !important; }
844 .hero .lead{ margin-bottom: 4px !important; }
845 .hero .eyebrow{ display:inline-block !important; width:auto !important; margin-left:auto !important; margin-right:auto !important; }
846}
847</style>
848
849
850
851<style id="seo-text-style">
852/* SEO block with airy separator */
853.seo-wrap{max-width:1200px;margin:80px auto 0;padding:40px 24px 20px;border-top:1px solid rgba(255,255,255,.25);pointer-events:none}
854 .seo-wrap a, .seo-wrap button, .seo-wrap input{pointer-events:auto}
855.seo-wrap h2{margin:0 0 16px;font-size:21px;line-height:1.35;color:#f0f4ff;font-weight:600;letter-spacing:.2px}
856.seo-wrap p{margin:0 0 14px;color:#b5c3de;line-height:1.75;font-size:15.5px;max-width:62ch}
857.seo-grid{display:grid;gap:22px}
858@media (min-width:860px){ .seo-grid{grid-template-columns:1fr 1fr} .seo-wrap p{max-width:unset} }
859</style>
860<style id="mobile-typography-fix">
861/* Mobile-first readability: ensure comfortable base size and line-height */
862html{ -webkit-text-size-adjust:100%; text-size-adjust:100%; }
863body{ font-size:16px; line-height:1.6; }
864/* Avoid too tiny UI text */
865small, .small, .muted, footer small{ font-size:13px; }
866/* Buttons/badges minimal comfortable size */
867.btn, button, .badge, .chip{ font-size:14px; }
868/* Slightly reduce on very narrow screens to keep layout stable but >12px */
869@media (max-width:380px){
870 body{ font-size:16px; }
871 small, .small, .muted, footer small{ font-size:12.5px; }
872}
873</style>
874<link rel="preconnect" href="https://api.open-meteo.com" crossorigin>
875<link rel="preconnect" href="https://tools.applemediaservices.com" crossorigin>
876<link rel="dns-prefetch" href="//api.open-meteo.com">
877<link rel="dns-prefetch" href="//tools.applemediaservices.com">
878 <link rel="alternate" hreflang="en" href="https://www.weathernow.app/" />
879 <link rel="alternate" hreflang="x-default" href="https://www.weathernow.app/" />
880
881
882 <meta property="og:type" content="website" />
883 <meta property="og:site_name" content="Weather Now" />
884 <meta property="og:locale" content="en_US" />
885 <meta property="og:title" content="Weather Now: Live Radar, Forecast App &amp; Widgets" />
886 <meta property="og:description" content="Check weather now with Weather Now: live radar, hourly forecast, 14-day weather, severe weather alerts, widgets and 3D Earth for iPhone and Android." />
887 <meta property="og:url" content="https://www.weathernow.app/" />
888 <meta property="og:image" content="https://www.weathernow.app/images/og/1.webp" />
889
890 <meta name="twitter:card" content="summary_large_image" />
891 <meta name="twitter:title" content="Weather Now: Live Radar &amp; 14-Day Forecast" />
892 <meta name="twitter:description" content="Check weather now with Weather Now: live radar, hourly forecast, 14-day weather, severe weather alerts, widgets and 3D Earth for iPhone and Android." />
893 <meta name="twitter:image" content="https://www.weathernow.app/images/og/1.webp" />
894<style id="platform-hero-lock">
895@media (min-width:641px){
896 .hero-inner{
897 max-width:1200px !important;
898 margin:0 auto !important;
899 padding:56px 22px !important;
900 display:grid !important;
901 grid-template-columns:1fr auto !important;
902 gap:40px !important;
903 align-items:center !important;
904 text-align:left !important;
905 }
906 .hero-inner > div:first-child{
907 display:block !important;
908 max-width:none !important;
909 margin:0 !important;
910 padding:0 !important;
911 text-align:left !important;
912 }
913 .hero .eyebrow{
914 font-size:12px !important;
915 line-height:1.6 !important;
916 padding:6px 10px !important;
917 margin:0 0 14px !important;
918 width:auto !important;
919 text-align:left !important;
920 }
921 .hero h1{
922 margin:0 0 12px !important;
923 font-size:clamp(30px,4.2vw,42px) !important;
924 line-height:1.16 !important;
925 letter-spacing:0 !important;
926 text-align:left !important;
927 }
928 .hero p.lead{
929 margin:0 0 18px !important;
930 font-size:clamp(15px,2.2vw,19px) !important;
931 line-height:1.5 !important;
932 max-width:none !important;
933 text-align:left !important;
934 }
935 .hero .lead br{display:inline !important}
936 .hero .bullet-list{
937 margin:14px 0 24px !important;
938 padding:0 !important;
939 display:block !important;
940 max-width:none !important;
941 text-align:left !important;
942 }
943 .hero .bullet-list li{
944 margin:8px 0 !important;
945 display:flex !important;
946 gap:10px !important;
947 align-items:center !important;
948 font-size:inherit !important;
949 line-height:1.6 !important;
950 }
951 .hero .bullet-list li::before{
952 width:8px !important;
953 height:8px !important;
954 margin-top:0 !important;
955 flex:0 0 8px !important;
956 }
957 .hero .store-badges{
958 display:flex !important;
959 gap:12px !important;
960 flex-wrap:wrap !important;
961 align-items:flex-start !important;
962 justify-content:flex-start !important;
963 margin:0 !important;
964 padding:0 !important;
965 width:auto !important;
966 }
967 .android-version-link,.ios-version-link{
968 min-height:62px !important;
969 padding:0 22px !important;
970 font-size:16px !important;
971 font-weight:400 !important;
972 line-height:1 !important;
973 }
974 .hero .device{
975 width:280px !important;
976 justify-self:end !important;
977 margin:0 !important;
978 transform:none !important;
979 order:initial !important;
980 }
981}
982</style>
983<style id="fixed-site-header">
984body{padding-top:102px!important}
985.site-header,.header{position:fixed!important;top:0!important;left:0!important;right:0!important;width:100%!important;z-index:2000!important}
986.site-header .wrap,.header .wrap,.header .nav{height:102px!important;max-width:1200px!important;margin:0 auto!important;padding:0 22px!important;display:flex!important;align-items:center!important;gap:18px!important}
987.brand{align-items:center!important;animation:brand-breathe 8.7s ease-in-out infinite;will-change:filter}
988@keyframes brand-breathe{0%,100%{filter:drop-shadow(0 0 0 rgba(77,163,255,0)) drop-shadow(0 0 0 rgba(159,107,255,0))}18%{filter:drop-shadow(0 0 5px rgba(77,163,255,.22)) drop-shadow(0 0 10px rgba(159,107,255,.14))}37%{filter:drop-shadow(0 0 2px rgba(77,163,255,.12)) drop-shadow(0 0 5px rgba(159,107,255,.08))}61%{filter:drop-shadow(0 0 8px rgba(77,163,255,.32)) drop-shadow(0 0 14px rgba(159,107,255,.18))}82%{filter:drop-shadow(0 0 3px rgba(77,163,255,.16)) drop-shadow(0 0 7px rgba(159,107,255,.1))}}
989@media(prefers-reduced-motion:reduce){.brand{animation:none!important;filter:none!important}}
990.header-links,.navlinks{display:flex!important;align-items:center!important}
991.header-links a,.navlinks a{line-height:26px!important;display:inline-flex!important;align-items:center!important}
992@media(max-width:560px){body{padding-top:88px!important}.site-header .wrap,.header .wrap,.header .nav{height:88px!important;padding:0 16px!important;gap:10px!important}.header-links,.navlinks{gap:14px!important}.header-links a,.navlinks a{margin-left:0!important;font-size:14px!important}.header-links a[href*="/support"]{display:none!important}}
993@media(max-width:340px){.site-header .wrap{padding:0 10px!important}.header-links{gap:9px!important}.header-links a{font-size:13px!important;letter-spacing:.03em!important}}
994</style>
995<script type="application/ld+json" data-seo="item-list">{"@context":"https://schema.org","@type":"ItemList","@id":"https://www.weathernow.app/#priority-pages","name":"Priority Weather Now entry pages","itemListElement":[{"@type":"ListItem","position":1,"name":"Weather Now home","url":"https://www.weathernow.app/"},{"@type":"ListItem","position":2,"name":"Global weather directory","url":"https://www.weathernow.app/weather/"},{"@type":"ListItem","position":3,"name":"Weather in Paris","url":"https://www.weathernow.app/weather/france/paris/"},{"@type":"ListItem","position":4,"name":"Weather in Vienna","url":"https://www.weathernow.app/weather/austria/vienna/"},{"@type":"ListItem","position":5,"name":"Weather in Tbilisi","url":"https://www.weathernow.app/weather/georgia/tbilisi/"}]}</script>
vendor: 1 bytes, line 995
995
996<script type="application/ld+json" data-seo="breadcrumb">{"@context":"https://schema.org","@type":"BreadcrumbList","@id":"https://www.weathernow.app/#breadcrumb","itemListElement":[{"@type":"ListItem","position":1,"name":"Weather Now","item":"https://www.weathernow.app/"}]}</script>
vendor: 1 bytes, line 996
996
997<script type="application/ld+json" data-seo="collection-page">{"@context":"https://schema.org","@type":"CollectionPage","@id":"https://www.weathernow.app/#collection","url":"https://www.weathernow.app/","name":"Weather app, radar and forecast entry pages","description":"Open the strongest Weather Now entry pages for app demand, live radar and city weather forecasts.","breadcrumb":{"@id":"https://www.weathernow.app/#breadcrumb"},"mainEntity":{"@id":"https://www.weathernow.app/#priority-pages"}}</script>
vendor: 1 bytes, line 997
997
998<script type="application/ld+json" data-seo="homepage-app-intent">{"@context":"https://schema.org","@type":"WebPage","@id":"https://www.weathernow.app/#app-intent","url":"https://www.weathernow.app/","name":"Weather Now app with live radar, widgets and 14-day forecast","description":"Canonical Weather Now entry page for live weather radar, hourly forecast, tomorrow weather, 14-day outlook, storm tracking, iOS and Android downloads, widgets, AQI and 3D Earth.","isPartOf":{"@id":"https://www.weathernow.app/#website"},"about":[{"@type":"Thing","name":"live weather radar app"},{"@type":"Thing","name":"Android weather app"},{"@type":"Thing","name":"weather widget app"},{"@type":"Thing","name":"tomorrow weather forecast"},{"@type":"Thing","name":"14-day weather forecast"}],"mainEntity":{"@id":"https://www.weathernow.app/#app"}}</script>
998
999<link rel="stylesheet" href="/css/home-v2-preview.css?v=289">
1000<script type="application/ld+json" data-seo="brand-snippet-ctr">{"@context":"https://schema.org","@type":"WebPage","@id":"https://www.weathernow.app/#brand-snippet","url":"https://www.weathernow.app/","name":"Weather Now: Check Weather Now, Live Radar & Forecast App","description":"Check weather now with Weather Now: live radar, hourly forecast, 14-day weather, severe weather alerts, widgets and 3D Earth for iPhone and Android.","inLanguage":"en","isPartOf":{"@type":"WebSite","@id":"https://www.weathernow.app/#website","name":"Weather Now","alternateName":["WeatherNow","Weather Now App","Weather Now Radar","Weather Now Weather App"]},"keywords":"WeatherNow, Weather Now, weather now, Weather Now app, live weather radar app, weather widget app, 14-day weather forecast","about":[{"@type":"SoftwareApplication","name":"Weather Now","applicationCategory":"WeatherApplication","operatingSystem":"iOS, Android, iPadOS"},{"@type":"Thing","name":"WeatherNow"},{"@type":"Thing","name":"weather now"},{"@type":"Thing","name":"check weather now"},{"@type":"Thing","name":"Weather Now app"},{"@type":"Thing","name":"live weather radar"},{"@type":"Thing","name":"weather widget app"},{"@type":"Thing","name":"hourly weather forecast"},{"@type":"Thing","name":"14-day weather forecast"}],"mentions":[{"@type":"Thing","name":"WeatherNow"},{"@type":"Thing","name":"Weather Now Radar"},{"@type":"Thing","name":"live radar app"},{"@type":"Thing","name":"weather widgets"}],"mainEntity":{"@id":"https://www.weathernow.app/#app"}}</script>
vendor: 1 bytes, line 1000
1000
1001<script type="application/ld+json" data-seo="homepage-quick-answer">{"@context":"https://schema.org","@type":"WebPageElement","@id":"https://www.weathernow.app/#quick-answer","name":"Quick answer about Weather Now","text":"Weather Now is a free weather app for iPhone, iPad, Apple Watch, Android and web. It combines live radar, hourly and 14-day forecasts, severe weather alerts, 3D Earth weather maps, widgets, AQI, UV index and sunrise/sunset planning in one app.","isPartOf":{"@id":"https://www.weathernow.app/#app-intent"},"about":{"@id":"https://www.weathernow.app/#app"}}</script>
vendor: 1 bytes, line 1001
1001
1002<script type="application/ld+json" data-seo="homepage-weather-now-answer">{"@context":"https://schema.org","@type":"WebPageElement","@id":"https://www.weathernow.app/#weather-now-answer","name":"Check weather now with live radar","text":"Use Weather Now to check weather now for any city with live radar, current conditions, hourly rain, temperature, wind, humidity, pressure, sunrise, sunset and a 14-day forecast.","inLanguage":"en","isPartOf":{"@id":"https://www.weathernow.app/#app-intent"},"about":[{"@type":"Thing","name":"weather now"},{"@type":"Thing","name":"current weather"},{"@type":"Thing","name":"live weather radar"},{"@type":"Thing","name":"hourly weather forecast"}]}</script>
1002
1003</head>
1004<body class="home-v2">
1005
1006<!-- colorful blurred backdrop -->
1007<div class="bg-blobs" aria-hidden="true">
1008 <span class="blob b1"></span>
1009 <span class="blob b2"></span>
1010 <span class="blob b3"></span>
1011 <span class="blob b4"></span>
1012 <span class="blob b5"></span>
1013</div>
1014
1015<main id="main-content">
1016 <!-- HEADER -->
1017 <header class="site-header">
1018 <div class="wrap">
1019 <a class="brand" href="/" aria-label="Weather Now Home">
1020 <svg class="brand-wordmark" viewBox="0 0 636 88" role="img" aria-label="Weather NOW" focusable="false">
1021 <defs>
1022 <linearGradient id="weatherNowLogoNameGradient" data-wordmark-gradient="name" x1="0%" y1="50%" x2="100%" y2="50%">
1023 <stop offset="0%" stop-color="#ffffff"/>
1024 <stop offset="66.35677083%" stop-color="#ffffff"/>
1025 <stop offset="97.421875%" stop-color="#606060"/>
1026 <stop offset="100%" stop-color="#606060"/>
1027 </linearGradient>
1028 <linearGradient id="weatherNowLogoGradient" data-wordmark-gradient="now" x1="0%" y1="50%" x2="100%" y2="50%">
1029 <stop offset="0%" stop-color="#5ba9ff"/>
1030 <stop offset="100%" stop-color="#a56cff"/>
1031 </linearGradient>
1032 </defs>
1033 <text class="brand-wordmark-name" x="0" y="72">WEATHER</text>
1034 <text class="brand-wordmark-now" x="412" y="72">NOW</text>
1035 </svg>
1036 </a>
1037 <div class="spacer"></div>
1038 <nav class="header-links">
1039 <a href="#weather-live">Weather</a>
1040 <a href="/guides/">Guides</a>
1041 <a href="/support">Support</a>
1042 <a href="#download-app">Download</a>
1043 </nav>
1044 </div>
1045 </header>
1046
1047 <!-- HERO -->
1048 <section class="hero home-v2-hero" aria-labelledby="home-hero-title">
1049 <div id="homeEarthFallback" class="home-earth-fallback" aria-hidden="true"></div>
1050 <div id="homeEarthStage" class="home-earth-stage" aria-hidden="true"></div>
1051 <div class="hero-inner">
1052 <div class="home-hero-copy">
1053 <span class="home-hero-kicker">See weather in motion.</span>
1054 <h1 id="home-hero-title" tabindex="-1">
1055<span class="home-hero-brand brand" aria-label="Weather NOW">
1056 <svg class="brand-wordmark" viewBox="0 0 636 88" role="img" aria-label="Weather NOW" focusable="false">
1057 <defs>
1058 <linearGradient id="weatherNowLogoNameGradientHero" data-wordmark-gradient="name" x1="0%" y1="50%" x2="100%" y2="50%">
1059 <stop offset="0%" stop-color="#ffffff"/>
1060 <stop offset="66.35677083%" stop-color="#ffffff"/>
1061 <stop offset="97.421875%" stop-color="#606060"/>
1062 <stop offset="100%" stop-color="#606060"/>
1063 </linearGradient>
1064 <linearGradient id="weatherNowLogoGradientHero" data-wordmark-gradient="now" x1="0%" y1="50%" x2="100%" y2="50%">
1065 <stop offset="0%" stop-color="#5ba9ff"/>
1066 <stop offset="100%" stop-color="#a56cff"/>
1067 </linearGradient>
1068 </defs>
1069 <text class="brand-wordmark-name" x="0" y="72" style="--brand-name-fill:url(#weatherNowLogoNameGradientHero)">WEATHER</text>
1070 <text class="brand-wordmark-now" x="412" y="72" style="--brand-now-fill:url(#weatherNowLogoGradientHero)">NOW</text>
1071 </svg>
1072</span>
1073 <span class="home-hero-subtitle">Live Radar, Forecast &amp; 3D Earth Maps</span>
1074 </h1>
1075 <p class="lead">Accurate conditions, long-term forecasts, stunning weather maps — everything in one elegant app.</p>
1076 <div class="home-hero-actions">
1077 <a class="home-primary-action" href="#download-app">
1078 <span>DOWNLOAD APP</span>
1079 <svg aria-hidden="true" viewBox="0 0 20 20"><path d="M4 10h11m-4-4 4 4-4 4"/></svg>
1080 </a>
1081 <a class="home-secondary-action" href="#weather-live">
1082 <span>CHECK WEATHER</span>
1083 <svg aria-hidden="true" viewBox="0 0 20 20"><path d="M4 10h11m-4-4 4 4-4 4"/></svg>
1084 </a>
1085 </div>
1086 </div>
1087 </div>
1088 </section>
1089
1090 <section class="home-app-showcase" id="download-app" aria-labelledby="home-app-title">
1091 <div class="home-app-copy">
1092 <h2 id="home-app-title">Forecasts, radar, and the whole atmosphere in your pocket.</h2>
1093 <div class="home-app-details">
1094 <p>Plan your day with precise hourly and 14-day forecasts, live Doppler radar, real-time storm and hurricane tracking, severe-weather alerts, and detailed conditions for wind, pressure, humidity, UV index, air quality, sunrise, and sunset.</p>
1095 <p>Explore changing weather across an interactive 3D Earth, follow global cloud and precipitation layers, and keep the forecast close with customizable Home Screen and Lock Screen widgets.</p>
1096 </div>
1097 <div class="store-badges">
1098 <div class="store-badge">
1099 <a href="https://apps.apple.com/us/app/id1371461117" target="_blank" rel="noopener" aria-label="Download on the App Store">
1100 <img src="https://tools.applemediaservices.com/api/badges/download-on-the-app-store/black/en-us?size=250x83" alt="Download on the App Store" loading="lazy">
1101 </a>
1102 </div>
1103 <a class="android-version-link" href="https://play.google.com/store/apps/details?id=com.weathernowapp.weathernow" rel="noopener" aria-label="Get it on Google Play">
1104 <img src="/android/GetItOnGooglePlay_Badge_Web_color_English.png" alt="Get it on Google Play" loading="lazy">
1105 </a>
1106 </div>
1107 </div>
1108 <div class="device home-app-device">
1109 <div class="screen">
1110 <picture>
1111 <source srcset="/images/screens-5/1-480.webp" media="(max-width:900px
1111)" type="image/webp">
1112 <source srcset="/images/screens-5/1.webp" type="image/webp">
1113 <img id="shotA" src="/images/screens-5/1.jpg" width="750" height="1620" decoding="async" loading="lazy" alt="Weather Now app — live radar and forecast screen" class="active">
1114 </picture>
1115 <picture>
1116 <source srcset="/images/screens-5/2-480.webp" media="(max-width:900px)" type="image/webp">
1117 <source srcset="/images/screens-5/2.webp" type="image/webp">
1118 <img id="shotB" src="/images/screens-5/2.jpg" width="750" height="1620" decoding="async" loading="lazy" alt="Weather Now app — 3D Earth weather visualization">
1119 </picture>
1120 </div>
1121 </div>
1122 </section>
1123
1124 <!-- MAP + WEATHER -->
1125 <section class="section" id="weather-live">
1126 <p id="statusMsg" role="status" aria-live="polite"></p>
1127 <div class="mw-card">
1128 <div class="mw-head">
1129 <form id="searchForm" autocomplete="off">
1130 <label class="city-search-field" for="cityInput">
1131 <svg aria-hidden="true" viewBox="0 0 24 24" fill="none"><circle cx="11" cy="11" r="6.5"/><path d="m16 16 4 4"/></svg>
1132 <input id="cityInput" type="search" placeholder="Search" aria-label="City" spellcheck="false">
1133 </label>
1134 <button type="submit"><span class="label">Search</span></button>
1135 </form>
1136 <div class="unit-toggle" role="group" aria-label="Temperature units">
1137 <button id="uC" class="active" type="button" aria-pressed="true">°C</button>
1138 <button id="uF" type="button" aria-pressed="false">°F</button>
1139 </div>
1140 </div>
1141
1142 <div class="mw-body">
1143 <!-- MAP LEFT -->
1144 <div class="map-wrap">
1145 <div id="map"></div>
1146 <div id="mapLegend" class="dashboard-legend" aria-label="Map legend">
1147 <div class="legend-group legend-precipitation">
1148 <h3>Precipitation</h3>
1149 <div class="legend-row">
1150 <div class="scale precipitation-scale" role="img" aria-label="RainViewer Universal Blue radar intensity, from light to extreme precipitation" title="RainViewer Universal Blue radar intensity (dBZ)"></div>
1151 <div class="labels"><span>Light</span><span aria-hidden="true"></span><span>Extreme</span></div>
1152 </div>
1153 </div>
1154 <span class="legend-separator" aria-hidden="true"></span>
1155 <div class="legend-group legend-clouds">
1156 <h3>Cloud cover</h3>
1157 <div class="legend-row">
1158 <div class="scale cloud-scale"></div>
1159 <div class="labels"><span>Clear</span><span>Partly</span><span>Overcast</span></div>
1160 </div>
1161 </div>
1162 </div>
1163 </div>
1164
1165 <!-- WEATHER RIGHT -->
1166 <aside class="wx-wrap">
1167 <div class="wx-main" id="weatherBox">
1168 <div class="wx-current-panel">
1169 <div class="wx-top">
1170 <div class="wx-place">
1171 <div class="name" id="wxPlace">Zurich, Switzerland</div>
1172 <div class="meta"><span id="wxMeta">—</span><span id="sunTimezone">—</span></div>
1173 </div>
1174 </div>
1175
1176 <div class="wx-hero">
1177 <div class="wx-summary-copy">
1178 <div class="wx-temp" id="wxTemp">—°</div>
1179 <div class="wx-desc" id="wxDesc">—</div>
1180 <div class="wx-feels" id="wxFeels">Feels like —°</div>
1181 </div>
1182 <div class="wx-icon" id="wxIcon" aria-hidden="true"><img src="/images/weather-icons/partly-cloudy-day.svg" alt="" width="128" height="128"></div>
1183 </div>
1184 <div class="wx-grid" id="wxGrid"></div>
1185 </div>
1186
1187 <div class="chart-card temperature-card">
1188 <div class="chart-head">
1189 <div>Temperature · 24h</div>
1190 <div class="ch-unit" id="chartUnit">°C</div>
1191 </div>
1192 <canvas id="tempChart"></canvas>
1193 <div id="tempChartReadings" class="temp-chart-readings" aria-hidden="true"></div>
1194 </div>
1195
1196 <div class="chart-card rain-card">
1197 <div class="chart-head">
1198 <div>Hourly rain · 24h</div>
1199 <div class="ch-unit" id="rainSummary">Peak —%</div>
1200 </div>
1201 <canvas id="rainChart"></canvas>
1202 </div>
1203
1204 <section class="sun-card" id="sunCard" aria-label="Today's sun times" aria-busy="true">
1205 <div class="sun-values">
1206 <div class="sun-stat sun-stat--sunrise">
1207 <svg aria-hidden="true" viewBox="0 0 24 24" fill="none"><path d="M4 18h16M7 18a5 5 0 0 1 10 0M12 5v2M7.8 7.8l1.4 1.4M16.2 7.8l-1.4 1.4"/><circle cx="12" cy="14" r="2.3"/></svg>
1208 <span>Sunrise</span><strong id="sunriseValue">—</strong>
1209 </div>
1210 <div class="sun-stat sun-stat--sunset">
1211 <svg aria-hidden="true" viewBox="0 0 24 24" fill="none"><path d="M4 18h16M7 18a5 5 0 0 1 10 0M8 9.5l1.3 1.3M16 9.5l-1.3 1.3M12 8v2"/><circle cx="12" cy="15" r="2.3"/></svg>
1212 <span>Sunset</span><strong id="sunsetValue">—</strong>
1213 </div>
1214 <div class="sun-stat sun-stat--daylight">
1215 <svg aria-hidden="true" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="3.3"/><path d="M12 3v2M12 19v2M3 12h2M19 12h2M5.6 5.6 7 7M17 17l1.4 1.4M18.4 5.6 17 7M7 17l-1.4 1.4"/></svg>
1216 <span>Daylight</span><strong id="daylightValue">—</strong>
1217 </div>
1218 </div>
1219 </section>
1220
1221 </div>
1222 </aside>
1223 </div>
1224 </div>
1225 </section>
1226
1227 <!-- SEO TEXT BLOCK -->
1228 <section class="seo-wrap" aria-labelledby="seo-title">
1229 <div class="seo-box">
1230 <h2 id="seo-title">Weather Now — Check Weather Now, Live Radar &amp; 3D Earth</h2>
1231 <div class="home-answer" aria-label="Quick answer about Weather Now">
1232 <span class="home-answer-kicker">Quick answer</span>
1233 <p><strong>Weather Now</strong> lets you check weather now for any city, then switch into the free app for iPhone, iPad, Apple Watch, Android and web. It combines live radar, current conditions, hourly and 14-day forecasts, severe weather alerts, 3D Earth weather maps, widgets, AQI, UV index and sunrise/sunset planning in one app.</p>
1234 <ul class="home-answer-facts">
1235 <li><span>Best for</span><strong>Live radar, forecast, widgets and 3D Earth</strong></li>
1236 <li><span>Platforms</span><strong>iPhone, iPad, Apple Watch, Android and web</strong></li>
1237 <li><span>Data</span><strong>CustomWeather and NOAA</strong></li>
1238 <li><span>Compare</span><strong><a href="/guides/best-free-weather-app-live-radar/">Best free weather app with live radar</a></strong></li>
1239 </ul>
1240 </div>
1241 <div class="seo-grid">
1242 <p>Weather Now is a free weather app for iPhone, iPad, Apple Watch, and Android. Search a city to check the weather now, then use live Doppler radar updated every few minutes, accurate hourly and 14-day forecasts, real-time storm and hurricane tracking, and a 3D Earth globe with global weather layers — all in one app.</p>
1243 <p>The app uses <strong>CustomWeather</strong> and <strong>NOAA</strong> weather data for forecasts, radar context, severe weather tracking, wind, humidity, pressure, UV index, sunrise and sunset times. Weather Now covers 190+ cities across 33 countries with dedicated forecast pages.</p>
1244 <p>Weather Now features customizable Home Screen and Lock Screen widgets for iPhone and iPad, severe weather push notifications, air quality index (AQI), UV index and sunrise/sunset times. A 3D Earth visualization lets you explore real-time weather layers across the globe.</p>
1245 <p>The app is rated <strong>4.7 out of 5 stars</strong> across iOS and Android. It is free to download with an optional premium upgrade that removes ads and unlocks advanced map layers and widget options.</p>
1246 </div>
1247 </div>
1248 </section>
1249
1250<section class="wrap popular-cities" aria-labelledby="popular-cities-title">
1251 <div class="popular-cities-card" data-source="fallback">
1252 <div class="popular-cities-header">
1253 <div class="popular-cities-heading">
1254 <h2 id="popular-cities-title">Popular cities right now</h2>
1255 <p id="popularCitiesDescription">Jump straight to forecasts, radar and current conditions in frequently viewed cities around the world.</p>
1256 </div>
1257 </div>
1258 <nav id="popularCitiesLinks" class="popular-cities-grid" data-source="fallback" aria-label="Popular city weather forecasts">
1259 <a href="/weather/china/beijing/">🇨🇳 Beijing</a>
1260 <a href="/weather/germany/berlin/">🇩🇪 Berlin</a>
1261 <a href="/weather/india/delhi/">🇮🇳 Delhi</a>
1262 <a href="/weather/qatar/doha/">🇶🇦 Doha</a>
1263 <a href="/weather/germany/dresden/">🇩🇪 Dresden</a>
1264 <a href="/weather/united-arab-emirates/dubai/">🇦🇪 Dubai</a>
1265 <a href="/weather/portugal/lisbon/">🇵🇹 Lisbon</a>
1266 <a href="/weather/united-kingdom/london/">🇬🇧 London</a>
1267 <a href="/weather/spain/madrid/">🇪🇸 Madrid</a>
1268 <a href="/weather/mexico/mexico-city/">🇲🇽 Mexico City</a>
1269 <a href="/weather/usa/new-york/">🇺🇸 New York</a>
1270 <a href="/weather/france/paris/">🇫🇷 Paris</a>
1271 <a href="/weather/czechia/prague/">🇨🇿 Prague</a>
1272 <a href="/weather/brazil/rio-de-janeiro/">🇧🇷 Rio de Janeiro</a>
1273 <a href="/weather/italy/rome/">🇮🇹 Rome</a>
1274 <a href="/weather/usa/san-francisco/">🇺🇸 San Francisco</a>
1275 <a href="/weather/brazil/sao-paulo/">🇧🇷 Sao Paulo</a>
1276 <a href="/weather/south-korea/seoul/">🇰🇷 Seoul</a>
1277 <a href="/weather/australia/sydney/">🇦🇺 Sydney</a>
1278 <a href="/weather/georgia/tbilisi/">🇬🇪 Tbilisi</a>
1279 <a href="/weather/japan/tokyo/">🇯🇵 Tokyo</a>
1280 <a href="/weather/canada/toronto/">🇨🇦 Toronto</a>
1281 <a href="/weather/canada/vancouver/">🇨🇦 Vancouver</a>
1282 <a href="/weather/austria/vienna/">🇦🇹 Vienna</a>
1283 <a href="/weather/switzerland/zurich/">🇨🇭 Zurich</a>
1284 </nav>
1285 </div>
1286</section>
1287
1288</main><!-- /main-content -->
1289
1290<footer>
1291 <div class="footer-wrap">
1292 <div class="footer-meta">© 2002-2026 Weather NOW by <a href="https://www.deluxeware.com/">DeluxeWare</a></div>
1293 <nav class="footer-links" aria-label="Footer">
1294 <a href="/support">Support</a>
1295 <span class="dot">·</span>
1296 <a href="/term-of-use">Terms of Use</a>
1297 <span class="dot">·</span>
1298 <a href="/privacy">Privacy Policy</a>
1299 <span class="dot">·</span>
1300 <a href="/calculators/">Calculators</a>
1301 <span class="dot">·</span>
1302 <a href="/weather/">Weather</a>
1303 </nav>
1304 </div>
1305 </footer>
1306
1307 <!-- SCRIPTS -->
1308 
1308<script src="/js/popular-cities.js?v=4" defer></script>
1308
1309 
1309<script src="/js/temperature-units.js?v=6"></script>
1309
1310 
1310<script>
1311 // ===== HERO AUTOSLIDE (cross-fade) =====
1312 const shots = [
1313 "/images/screens-5/1.jpg",
1314 "/images/screens-5/2.jpg",
1315 "/images/screens-5/3.jpg",
1316 "/images/screens-5/4.jpg",
1317 "/images/screens-5/5.jpg",
1318 "/images/screens-5/6.jpg",
1319 "/images/screens-5/7.jpg"
1320 ];
1321 const a = document.getElementById('shotA');
1322 const b = document.getElementById('shotB');
1323 let slide = 0, useA = true;
1324 // preload next image into the hidden element
1325 function preload(el, idx){ el.src = shots[idx]; }
1326 function nextSlide(){
1327 slide = (slide + 1) % shots.length;
1328 if(useA){ preload(b, slide); b.classList.add('active'); a.classList.remove('active'); }
1329 else { preload(a, slide); a.classList.add('active'); b.classList.remove('active'); }
1330 useA = !useA;
1331 }
1332 // shotA already has class="active" from HTML — just ensure shotB is hidden and preloaded
1333 b.classList.remove('active');
1334 preload(b, 1);
1335 setInterval(nextSlide, 3500);
1336 </script>
1336
1337
1338 
1338<script type="text/plain" data-home-weather-runtime>
1339 // ===== helpers =====
1340 const FLAG_HIDDEN_COUNTRY_CODES = new Set(['RU','UA']);
1341 function isoToFlag(cc){
1342 const normalized = String(cc || '').toUpperCase();
1343 if(normalized.length !== 2 || FLAG_HIDDEN_COUNTRY_CODES.has(normalized)) return '';
1344 const A=0x1F1E6;
1345 return String.fromCodePoint(...[...normalized].map(c => A + (c.charCodeAt(0)-65)));
1346 }
1347 function labelFromProps(p){ return [p.name,p.city,p.country].filter(Boolean).filter((v,i,a)=>a.indexOf(v)===i).join(', '); }
1348 function codeToText(w){
1349 if([0].includes(w)) return 'Clear';
1350 if([1,2,3].includes(w)) return 'Partly cloudy';
1351 if([45,48].includes(w)) return 'Fog';
1352 if([51,53,55,56,57].includes(w)) return 'Drizzle';
1353 if([61,63,65].includes(w)) return 'Rain';
1354 if([66,67].includes(w)) return 'Freezing rain';
1355 if([71,73,75,77].includes(w)) return 'Snow';
1356 if([80,81,82].includes(w)) return 'Shower';
1357 if([95,96,99].includes(w)) return 'Thunderstorm';
1358 return 'Weather';
1359 }
1360 function codeToWeatherIcon(w){
1361 let icon = 'clear-day';
1362 if([1,2,3].includes(w)) icon = 'partly-cloudy-day';
1363 else if([45,48].includes(w)) icon = 'fog';
1364 else if([51,53,55,56,57].includes(w)) icon = 'drizzle';
1365 else if([61,63,65,66,67,80,81,82].includes(w)) icon = 'rain';
1366 else if([71,73,75,77].includes(w)) icon = 'snow';
1367 else if([95,96,99].includes(w)) icon = 'thunderstorms';
1368 return `<img src="/images/weather-icons/${icon}.svg" alt="" width="128" height="128">`;
1369 }
1370
1371 // ===== INTERACTIVE WEATHER MAP (Zurich default) =====
1372 async function decodeCloudTile(blob){
1373 if('createImageBitmap' in window) return createImageBitmap(blob);
1374 return new Promise((resolve,reject)=>{
1375 const objectUrl = URL.createObjectURL(blob);
1376 const image = new Image();
1377 image.onload = () => { URL.revokeObjectURL(objectUrl); resolve(image); };
1378 image.onerror = () => { URL.revokeObjectURL(objectUrl); reject(new Error('cloud tile decode')); };
1379 image.src = objectUrl;
1380 });
1381 }
1382 async function cloudCanvasToBuffer(canvas){
1383 const blob = canvas.convertToBlob
1384 ? await canvas.convertToBlob({type:'image/png'})
1385 : await new Promise((resolve,reject)=>canvas.toBlob(value=>value ? resolve(value) : reject(new Error('cloud tile encode')),'image/png'));
1386 return blob.arrayBuffer();
1387 }
1388 maplibregl.addProtocol('weather-clouds', async (params, abortController)=>{
1389 const tilePath = params.url.replace(/^weather-clouds:\/\//,'').replace(/^\/+/, '');
1390 if(!/^\d+\/\d+\/\d+$/.test(tilePath)) throw new Error('invalid cloud tile');
1391 const sourceUrl = `/api/weather/clouds/${tilePath}.png`;
1392 const timeoutSignal = AbortSignal.timeout(8000);
1393 const signal = abortController?.signal ? AbortSignal.any([abortController.signal,timeoutSignal]) : timeoutSignal;
1394 const response = await fetch(sourceUrl,{signal});
1395 if(!response.ok) throw new Error('cloud tile unavailable');
1396 const bitmap = await decodeCloudTile(await response.blob());
1397 const canvas = typeof OffscreenCanvas === 'function' ? new OffscreenCanvas(bitmap.width,bitmap.height) : document.createElement('canvas');
1398 canvas.width = bitmap.width; canvas.height = bitmap.height;
1399 const context = canvas.getContext('2d',{willReadFrequently:true});
1400 context.drawImage(bitmap,0,0);
1401 if(typeof bitmap.close === 'function') bitmap.close();
1402 const pixels = context.getImageData(0,0,canvas.width,canvas.height);
1403 const data = pixels.data;
1404 for(let i=0;i<data.length;i+=4){
1405 const sourceAlpha = data[i+3];
1406 if(sourceAlpha < 10){ data[i+3] = 0; continue; }
1407 const coverage = Math.min(1,(sourceAlpha-10)/128);
1408 const shaped = Math.pow(coverage,0.62);
1409 data[i] = 220;
1410 data[i+1] = 235;
1411 data[i+2] = 248;
1412 data[i+3] = Math.round(38 + shaped*192);
1413 }
1414 context.putImageData(pixels,0,0);
1415 return {data:await cloudCanvasToBuffer(canvas)};
1416 });
1417
1418 const weatherMapStyle = {
1419 version:8,
1420 projection:{type:'mercator'},
1421 sources:{
1422 base:{
1423 type:'raster',
1424 tiles:['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
1425 tileSize:256,
1426 minzoom:0,
1427 maxzoom:19,
1428 attribution:'© <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">OpenStreetMap</a> contributors'
1429 },
1430 clouds:{
1431 type:'raster',
1432 tiles:['weather-clouds://{z}/{x}/{y}'],
1433 tileSize:256,
1434 minzoom:0,
1435 maxzoom:6
1436 }
1437 },
1438 layers:[
1439 {id:'base-map',type:'raster',source:'base',paint:{'raster-fade-duration':0,'raster-saturation':-0.12,'raster-contrast':0.06,'raster-brightness-min':0.06,'raster-brightness-max':0.92}},
1440 {id:'cloud-cover',type:'raster',source:'clouds',paint:{'raster-opacity':0.88,'raster-brightness-min':0,'raster-brightness-max':1,'raster-contrast':0.11,'raster-fade-duration':250}},
1441 ],
1442 sky:{
1443 'atmosphere-blend':['interpolate',['linear'],['zoom'],0,1,3.6,0.82,5,0],
1444 'sky-color':'#02070f',
1445 'horizon-color':'#74b4ff',
1446 'fog-color':'#071a34'
1447 },
1448 light:{anchor:'map',position:[1.5,90,80]}
1449 };
1450 const MAP_INITIAL_ZOOM = 4.6;
1451 const MAP_SELECTION_ZOOM = 5.2;
1452 const foldWeatherRail=window.matchMedia('(min-width: 681px) and (max-width: 980px)');
1453 function weatherMapPadding(){
1454   if(!foldWeatherRail.matches)return {top:0,right:0,bottom:0,left:0};
1455   const mapRect=document.getElementById('map')?.getBoundingClientRect();
1456   const railRect=document.querySelector('.mw-card .wx-main')?.getBoundingClientRect();
1457   if(!mapRect?.width||!railRect?.width)return {top:0,right:0,bottom:0,left:310};
1458   const freeMapGutter=16;
1459   const railEdge=Math.round(railRect.right-mapRect.left+freeMapGutter);
1460   return {top:0,right:0,bottom:0,left:Math.min(railEdge,Math.floor(mapRect.width*.58))};
1461 }
1462 const map = new maplibregl.Map({
1463 container:'map',
1464 style:weatherMapStyle,
1465 center:[8.5417,47.3769],
1466 padding:weatherMapPadding(),
1467 zoom:MAP_INITIAL_ZOOM,
1468 minZoom:1,
1469 maxZoom:13,
1470 attributionControl:false,
1471 renderWorldCopies:false,
1472 scrollZoom:false,
1473 keyboard:false,
1474 dragRotate:false,
1475 pitchWithRotate:false,
1476 maxPitch:0,
1477 cooperativeGestures:window.matchMedia('(max-width: 980px), (pointer: coarse)').matches,
1478 antialias:false
1479 });
1480 map.touchZoomRotate.disableRotation();
1481 map.getContainer().setAttribute('tabindex', '-1');
1482 map.getCanvas().setAttribute('tabindex', '-1');
1483map.addControl(new maplibregl.NavigationControl({showCompass:false,showZoom:true}), 'bottom-right');
1484map.addControl(new maplibregl.AttributionControl({compact:false}), 'bottom-right');
1485 // Adding layers needs the style, not every remote tile to finish downloading.
1486 const mapReady = new Promise(resolve => map.once('style.load', resolve));
1487 const dayNightController = window.weatherNowDayNight?.install(map, {beforeLayerId:'cloud-cover'});
1488 const mapEl = document.getElementById('map');
1489 const syncMapViewport=()=>requestAnimationFrame(()=>{
1490   map.resize();
1491   map.setPadding(weatherMapPadding());
1492 });
1493 if(window.ResizeObserver)new ResizeObserver(syncMapViewport).observe(mapEl);
1494 foldWeatherRail.addEventListener?.('change',syncMapViewport);
1495 window.addEventListener('orientationchange',syncMapViewport);
1496
1497 const markerElement = document.createElement('div');
1498 markerElement.className = 'loc-marker';
1499 markerElement.setAttribute('aria-label', 'Selected weather location');
1500 markerElement.innerHTML = '<div class="loc-pulse"></div><div class="loc-dot"></div>';
1501 const marker = new maplibregl.Marker({element:markerElement,anchor:'center',opacity:1,opacityWhenCovered:0})
1502 .setLngLat([8.5417,47.3769])
1503 .addTo(map);
1504 const locationPopup = new maplibregl.Popup({
1505 offset:24,
1506 closeButton:true,
1507 closeOnClick:false,
1508 focusAfterOpen:false,
1509 className:'weather-location-popup',
1510 locationOccludedOpacity:0
1511 });
1512
1513 window.__weatherGlobe = {
1514 engine:'MapLibre GL JS',
1515 projection:'mercator',
1516 mode:'flat-map',
1517 get loaded(){return map.loaded();},
1518 get center(){const center=map.getCenter();return {lat:center.lat,lon:center.lng};},
1519 get zoom(){return map.getZoom();},
1520 get layerOrder(){return map.getStyle().layers.map(layer=>layer.id);},
1521 get dayNightLayer(){return Boolean(map.getLayer('day-night-shadow'));},
1522 get subsolarPoint(){return dayNightController?.solarState || null;},
1523 get rainLayer(){return Boolean(map.getLayer('rain-radar'));}
1524 };
1525
1526 async function loadRain(){
1527 try{
1528 const r = await fetch('https://api.rainviewer.com/public/weather-maps.json', {signal:AbortSignal.timeout(8000)});
1529 if(!r.ok) return;
1530 const d = await r.json();
1531 const last = (d?.radar?.past||[]).slice(-1)[0];
1532 if(!last) return;
1533 const tilePath = last.path || `/v2/radar/${last.time}`;
1534 const rainTiles = [`https://tilecache.rainviewer.com${tilePath}/256/{z}/{x}/{y}/2/1_1.png`];
1535 await mapReady;
1536 const rainSource = map.getSource('rainviewer');
1537 if(rainSource){
1538 rainSource.setTiles(rainTiles);
1539 }else{
1540 map.addSource('rainviewer',{type:'raster',tiles:rainTiles,tileSize:256,minzoom:0,maxzoom:6});
1541 map.addLayer({id:'rain-radar',type:'raster',source:'rainviewer',paint:{'raster-opacity':0.82,'raster-fade-duration':300}},map.getLayer('place-labels') ? 'place-labels' : undefined);
1542 }
1543 }catch(e){}
1544 }
1545 setInterval(loadRain, 5*60*1000);
1546
1547 // ===== Legend =====
1548 // The legend lives in the dashboard grid so its edges align with the weather card.
1549 function updateLegend(){ /* static legend — no-op */ }
1550
1551 function setLayer(name){} // no-op — layers always on
1552
1553 // ===== WEATHER API + hourly for chart =====
1554 let lastWeather = null; // raw °C
1555 let lastHourly = null; // {time:[], tempC:[]}
1556 let unit = window.weatherNowTemperatureUnits?.current || 'C';
1557window.unit = unit; // 'C' | 'F'
1558
1559async function fetchWeather(lat, lon){
1560 const url = new URL('/api/weather/forecast', location.origin);
1561 url.searchParams.set('latitude', lat.toFixed(4));
1562 url.searchParams.set('longitude', lon.toFixed(4));
1563 const r = await fetch(url, {signal:AbortSignal.timeout(12000)});
1564 if(!r.ok) throw new Error('weather');
1565 return r.json();
1566 }
1567
1568 function toF(c){ return (c * 9/5) + 32; }
1569 function msToMph(ms){ return ms * 2.236936; }
1570
1571 function formatWeatherLocalDate(raw){
1572 const locale = unit === 'F' ? 'en-US' : 'en-GB';
1573 const options = {dateStyle:'medium',timeStyle:'short'};
1574 if(raw?.timezone){
1575 try{
1576 return new Intl.DateTimeFormat(locale,{...options,timeZone:raw.timezone}).format(new Date());
1577 }catch(_){ /* fall through to the provider offset */ }
1578 }
1579 const offsetSeconds = Number(raw?.utc_offset_seconds);
1580 if(Number.isFinite(offsetSeconds)){
1581 return new Intl.DateTimeFormat(locale,{...options,timeZone:'UTC'})
1582 .format(new Date(Date.now() + offsetSeconds * 1000));
1583 }
1584 const localIso = raw?.current?.time;
1585 let localDate;
1586 if(typeof localIso === 'string'){
1587 const match = localIso.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/);
1588 if(match){
1589 const [,year,month,day,hour,minute] = match;
1590 localDate = new Date(Date.UTC(+year,+month-1,+day,+hour,+minute));
1591 }
1592 }
1593 if(!localDate || Number.isNaN(localDate.getTime())) localDate = new Date();
1594 return new Intl.DateTimeFormat(locale,{
1595 ...options,
1596 timeZone:'UTC'
1597 }).format(localDate);
1598 }
1599
1600 function buildHourly(data){
1601 const h = data.hourly || {};
1602 const times = h.time || [];
1603 const temps = h.temperature_2m || [];
1604 const precip = h.precipitation_probability || [];
1605 // возьмём ближайшие 24 часа начиная "сейчас"
1606 const nowISO = data.current?.time || times[0];
1607 const nowHour = nowISO.substring(0,13) + ':00';
1608 let idx = times.indexOf(nowHour);
1609 if(idx < 0) idx = 0;
1610 const sliceT = temps.slice(idx, idx+24);
1611 const sliceTimes = times.slice(idx, idx+24).map(t => new Date(t));
1612 return { times: sliceTimes, tempsC: sliceT, precipProb: precip.slice(idx, idx+24) };
1613 }
1614
1615 function formatLocalSolarTime(iso){
1616 if(!iso || !iso.includes('T')) return '';
1617 const [hoursRaw, minutes='00'] = iso.split('T')[1].split(':');
1618 const hours = Number(hoursRaw);
1619 if(unit === 'F'){
1620 const suffix = hours >= 12 ? 'PM' : 'AM';
1621 const hour12 = hours % 12 || 12;
1622 return `${hour12}:${minutes} ${suffix}`;
1623 }
1624 return `${String(hours).padStart(2,'0')}:${minutes}`;
1625 }
1626
1627 function formatDaylight(seconds){
1628 if(!Number.isFinite(Number(seconds))) return '';
1629 const totalMinutes = Math.round(Number(seconds) / 60);
1630 return `${Math.floor(totalMinutes / 60)}h ${totalMinutes % 60}m`;
1631 }
1632
1633 function renderSunCard(raw){
1634 const daily = raw?.daily || {};
1635 const sunrise = daily.sunrise?.[0];
1636 const sunset = daily.sunset?.[0];
1637 const daylight = daily.daylight_duration?.[0];
1638 const card = document.getElementById('sunCard');
1639 if(!card) return;
1640 if(!sunrise || !sunset || !Number.isFinite(Number(daylight))){
1641 document.getElementById('sunriseValue').textContent = '—';
1642 document.getElementById('sunsetValue').textContent = '—';
1643 document.getElementById('daylightValue').textContent = '—';
1644 document.getElementById('sunTimezone').textContent = '—';
1645 card.hidden = false;
1646 card.setAttribute('aria-busy', 'true');
1647 return;
1648 }
1649 document.getElementById('sunriseValue').textContent = formatLocalSolarTime(sunrise);
1650 document.getElementById('sunsetValue').textContent = formatLocalSolarTime(sunset);
1651 document.getElementById('daylightValue').textContent = formatDaylight(daylight);
1652 document.getElementById('sunTimezone').textContent = raw.timezone_abbreviation || '';
1653 card.hidden = false;
1654 card.setAttribute('aria-busy', 'false');
1655 }
1656
1657 // === Chart (vanilla canvas) ===
1658 function getChartCanvasFontFamily(){
1659 const token = getComputedStyle(document.documentElement).getPropertyValue('--font-ui').trim();
1660 return token || '-apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", sans-serif';
1661 }
1662function setChartCanvasFont(ctx, weight, size){
1663  ctx.font = `normal ${weight} ${size}px ${getChartCanvasFontFamily()}`;
1664  // Some Safari canvas text properties are exposed but not writable.
1665  // They are purely cosmetic, so never let them block chart labels.
1666  for (const [property, value] of [
1667    ['fontKerning', 'normal'],
1668    ['fontStretch', 'normal'],
1669    ['fontVariantCaps', 'normal'],
1670    ['letterSpacing', '0px'],
1671    ['wordSpacing', '0px'],
1672    ['textRendering', 'optimizeLegibility']
1673  ]) {
1674    try { if (property in ctx) ctx[property] = value; } catch (_) {}
1675  }
1676}
1677
1678 function renderTempChart(containerId, hourly){
1679 const c = document.getElementById(containerId);
1680 if(!c) return;
1681
1682 const dpr = window.devicePixelRatio || 1;
1683 c.style.width = '';
1684 const cssW = Math.floor(c.getBoundingClientRect().width) || c.clientWidth || 600;
1685 const cssH = Math.round(c.getBoundingClientRect().height) || 148;
1686 c.width = cssW * dpr;
1687 c.height = cssH * dpr;
1688 c.style.width = cssW + "px";
1689 c.style.height = cssH + "px";
1690 const ctx = c.getContext('2d');
1691 ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
1692
1693 const W = cssW, H = cssH;
1694 ctx.clearRect(0,0,W,H);
1695
1696 if(!hourly || !hourly.times || hourly.times.length === 0) return;
1697 const rawVals = (hourly.tempsC || []).map(v => (window.unit === 'F' ? toF(v) : v));
1698 const vals = rawVals.filter((_, i) => i % 2 === 0);
1699 const chartTimes = (hourly.times || []).filter((_, i) => i % 2 === 0);
1700 if(!vals.length) return;
1701 const min = Math.min(...vals), max = Math.max(...vals);
1702
1703 // ⬇⬇⬇ ВАЖНО: увеличиваем верхний паддинг под безопасную зону
1704 const padL = 14, padR = 10, padTop = 42, padBot = 40;
1705 const SAFE_TOP = padTop - 26; // полоса для Min/Max (верхняя зона ~26px)
1706 const innerW = W - padL - padR;
1707 const innerH = H - padTop - padBot;
1708
1709 // baseline
1710 ctx.strokeStyle = 'rgba(161,193,208,0.16)';
1711 ctx.beginPath();
1712 ctx.moveTo(padL, H - padBot);
1713 ctx.lineTo(W - padR, H - padBot);
1714 ctx.stroke();
1715
1716 // линия
1717 ctx.beginPath();
1718 vals.forEach((v,i)=>{
1719 const x = padL + (i * innerW) / Math.max(1,(vals.length-1));
1720 const y = padTop + innerH * (1 - (v - min) / Math.max(1,(max-min)));
1721 if(i===0) ctx.moveTo(x,y); else ctx.lineTo(x,y);
1722 });
1723 ctx.strokeStyle = '#72c7f2';
1724 ctx.lineWidth = 2;
1725 ctx.stroke();
1726
1727 // заливка
1728 ctx.lineTo(W - padR, H - padBot);
1729 ctx.lineTo(padL, H - padBot);
1730 ctx.closePath();
1731 const g = ctx.createLinearGradient(0, padTop, 0, H - padBot);
1732 g.addColorStop(0, 'rgba(86,177,223,0.24)');
1733 g.addColorStop(1, 'rgba(86,177,223,0)');
1734 ctx.fillStyle = g;
1735 ctx.fill();
1736
1737 // подписи точек — реже и ниже безопасной зоны
1738 const timeFmt = unit==='F'
1739 ? new Intl.DateTimeFormat('en-US',{hour:'numeric'})
1740 : new Intl.DateTimeFormat('en-GB',{hour:'2-digit'});
1741
1742 vals.forEach((v,i)=>{
1743 const x = padL + (i * innerW) / Math.max(1,(vals.length-1));
1744 const y = padTop + innerH * (1 - (v - min) / Math.max(1,(max-min)));
1745
1746 // точка
1747 ctx.fillStyle = '#fff';
1748 ctx.beginPath();
1749 ctx.arc(x, y, 2, 0, Math.PI*2);
1750 ctx.fill();
1751
1752 // ⬇⬇⬇ ПОДПИСИ КАЖДЫЕ 4 ЧАСА и ниже safe-zone
1753 if (i % 2 === 0) {
1754 // температура — жирная и «зажатая» ниже безопасной полосы
1755 setChartCanvasFont(ctx, 600, 14);
1756 ctx.fillStyle = '#dce8ed';
1757 ctx.textBaseline = 'bottom';
1758 const yTemp = Math.max(SAFE_TOP + 28, y - 10); // не выше safe-zone
1759 ctx.fillText(`${Math.round(v)}°`, x, yTemp);
1760
1761 // время
1762 // Match the compact rain timeline exactly: same family, 500 weight, size and contrast.
1763 setChartCanvasFont(ctx, 500, 12);
1764 ctx.fillStyle = 'rgba(188, 211, 222, .82)';
1765 ctx.textBaseline = 'top';
1766 if(i < chartTimes.length - 1) { ctx.fillText(timeFmt.format(chartTimes[i]), x, H - padBot + 6); }
1767 }
1768 });
1769
1770 // ⬇⬇⬇ Min/Max рисуем В БЕЗОПАСНОЙ ВЕРХНЕЙ ЗОНЕ
1771 setChartCanvasFont(ctx, 600, 14);
1772 ctx.fillStyle = '#9bd3f2';
1773 ctx.textAlign = 'left'; ctx.textBaseline = 'top';
1774 ctx.fillText(`Min: ${Math.round(min)}°`, padL, SAFE_TOP + 4);
1775 ctx.textAlign = 'right'; ctx.textBaseline = 'top';
1776 ctx.fillText(`Max: ${Math.round(max)}°`, W - padR, SAFE_TOP + 4);
1777}
1778
1779function renderRainChart(canvasId, hourly) {
1780 const c = document.getElementById(canvasId);
1781 if (!c) return;
1782 c._rainHourly = hourly;
1783 const allVals = hourly?.precipProb || [];
1784 const allTimes = hourly?.times || [];
1785 if (!allVals.length) return;
1786
1787 // Every column is one real forecast hour. Keep all 24 hours visible.
1788 const vals = allVals.slice(0, 24);
1789 const times = allTimes.slice(0, 24);
1790
1791 const dpr = window.devicePixelRatio || 1;
1792 const card = c.closest('.rain-card');
1793 const cardStyle = card ? getComputedStyle(card) : null;
1794 const cardContentWidth = card ? Math.floor(
1795   card.clientWidth - (parseFloat(cardStyle.paddingLeft) || 0) - (parseFloat(cardStyle.paddingRight) || 0)
1796 ) : 0;
1797 const cssW = cardContentWidth || Math.floor(c.getBoundingClientRect().width) || c.clientWidth || 600;
1798 const measuredH = Math.round(c.getBoundingClientRect().height) || c.clientHeight || 0;
1799 const cssH = measuredH || (window.matchMedia('(max-width: 680px)').matches ? 142 :
1800   window.matchMedia('(max-width: 980px)').matches ? 166 : 174);
1801 c.width = cssW * dpr; c.height = cssH * dpr;
1802 c.style.width = cssW + 'px'; c.style.height = cssH + 'px';
1803 const ctx = c.getContext('2d');
1804 ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
1805 ctx.clearRect(0, 0, cssW, cssH);
1806
1807 // A media-query or window resize can widen the card after the first forecast render.
1808 // Repaint against the card's content width instead of leaving a stale 300px canvas.
1809 if (card && !c._rainResizeObserver && typeof ResizeObserver !== 'undefined') {
1810   let frame = 0;
1811   let lastWidth = cssW;
1812   c._rainResizeObserver = new ResizeObserver(() => {
1813     cancelAnimationFrame(frame);
1814     frame = requestAnimationFrame(() => {
1815       const style = getComputedStyle(card);
1816       const nextWidth = Math.floor(card.clientWidth - (parseFloat(style.paddingLeft) || 0) - (parseFloat(style.paddingRight) || 0));
1817       if (nextWidth > 0 && Math.abs(nextWidth - lastWidth) > 1) {
1818         lastWidth = nextWidth;
1819         renderRainChart(canvasId, c._rainHourly);
1820       }
1821     });
1822   });
1823   c._rainResizeObserver.observe(card);
1824 }
1825
1826 const compactChart = cssH < 160;
1827 const denseChart = cssH < 100;
1828 const padL = 14, padR = 10, padTop = denseChart ? 6 : 24, padBot = denseChart ? 22 : (compactChart ? 30 : 34);
1829 const innerW = cssW - padL - padR;
1830 const innerH = cssH - padTop - padBot;
1831 const slotW = innerW / vals.length;
1832 const barW = Math.max(3, Math.min(7, slotW * .44));
1833 const hourLabel = time => `${String(new Date(time).getHours()).padStart(2, '0')}:00`;
1834
1835 const maxVal = Math.max(...vals, 0);
1836 const rainSummary = document.getElementById('rainSummary');
1837 if (rainSummary) rainSummary.textContent = maxVal <= 0 ? 'Dry' : `Peak ${Math.round(maxVal)}%`;
1838 c.setAttribute('role', 'img');
1839 c.setAttribute('aria-label', maxVal <= 0
1840   ? 'No rain expected during the next 24 hours'
1841   : `Hourly rain probability for the next 24 hours. Peak ${Math.round(maxVal)} percent`);
1842
1843 if (maxVal <= 0) {
1844   const centerX = cssW / 2;
1845   const centerY = cssH / 2 - 2;
1846   const lineGradient = ctx.createLinearGradient(centerX - 48, 0, centerX + 48, 0);
1847   lineGradient.addColorStop(0, 'rgba(128, 190, 224, 0)');
1848   lineGradient.addColorStop(.5, 'rgba(128, 190, 224, .28)');
1849   lineGradient.addColorStop(1, 'rgba(128, 190, 224, 0)');
1850   ctx.strokeStyle = lineGradient;
1851   ctx.lineWidth = 1;
1852   ctx.beginPath();
1853   ctx.moveTo(centerX - 48, centerY + 34);
1854   ctx.lineTo(centerX + 48, centerY + 34);
1855   ctx.stroke();
1856
1857   setChartCanvasFont(ctx, 600, compactChart ? 15 : 16);
1858   ctx.fillStyle = 'rgba(222, 235, 242, .9)';
1859   ctx.textAlign = 'center';
1860   ctx.textBaseline = 'middle';
1861   ctx.fillText('No rain expected', centerX, centerY - 7);
1862   setChartCanvasFont(ctx, 500, 13);
1863   ctx.fillStyle = 'rgba(151, 174, 186, .72)';
1864   ctx.fillText('Next 24 hours', centerX, centerY + 16);
1865   return;
1866 }
1867
1868 const scaleMax = Math.max(denseChart ? 10 : 30, Math.ceil(maxVal / 10) * 10);
1869
1870 // Quiet Apple-style guides. They give the hourly bars scale without a dashboard grid.
1871 ctx.save();
1872 ctx.setLineDash([2, 5]);
1873 ctx.strokeStyle = 'rgba(204, 230, 244, .13)';
1874 ctx.lineWidth = 1;
1875 (denseChart ? [.55, 1] : [0, .5, 1]).forEach((ratio) => {
1876   const y = padTop + innerH * ratio;
1877   ctx.beginPath();
1877 ctx.moveTo(padL, y); ctx.lineTo(cssW - padR, y); ctx.stroke();
1878 });
1879 ctx.restore();
1880
1881 vals.forEach((v, i) => {
1882 const cx = padL + slotW * (i + .5);
1883 const barH = v > 0 ? Math.max(5, (v / scaleMax) * innerH) : 2;
1884 const baseline = padTop + innerH;
1885 const y = baseline - barH;
1886
1887 const g = ctx.createLinearGradient(0, y, 0, baseline);
1888 g.addColorStop(0, 'rgba(165, 222, 255, .98)');
1889 g.addColorStop(1, 'rgba(84, 170, 222, .82)');
1890 ctx.strokeStyle = v > 0 ? g : 'rgba(117, 164, 190, .34)';
1891 ctx.lineWidth = barW;
1892 ctx.lineCap = 'round';
1893 ctx.beginPath();
1894 ctx.moveTo(cx, baseline);
1895 ctx.lineTo(cx, y);
1896 ctx.stroke();
1897
1898 if (i % 4 === 0 && i < times.length) {
1899 setChartCanvasFont(ctx, 500, denseChart ? 12 : 13);
1900 ctx.fillStyle = 'rgba(188, 211, 222, .82)';
1901 ctx.textAlign = 'center';
1902 ctx.textBaseline = 'top';
1903 ctx.fillText(hourLabel(times[i]), cx, baseline + (denseChart ? 6 : 10));
1904 }
1905 });
1906}
1907 function renderWeatherBox(raw, label, cc){
1908 lastWeather = raw;
1909 const c = raw.current || {};
1910 // собрать hourly
1911 lastHourly = buildHourly(raw);
1912 document.getElementById('chartUnit').textContent = unit==='F' ? '°F' : '°C';
1913 const rainSummary = document.getElementById('rainSummary');
1914 const peakRain = Math.max(0, ...(lastHourly.precipProb || []));
1915 if (rainSummary) rainSummary.textContent = peakRain <= 0 ? 'Dry' : `Peak ${Math.round(peakRain)}%`;
1916
1917 // единицы
1918 const temp = c.temperature_2m!=null ? (unit==='F' ? toF(c.temperature_2m) : c.temperature_2m) : null;
1919 const feels= c.apparent_temperature!=null ? (unit==='F' ? toF(c.apparent_temperature) : c.apparent_temperature) : null;
1920 const wind = c.wind_speed_10m!=null ? (unit==='F' ? msToMph(c.wind_speed_10m) : c.wind_speed_10m) : null;
1921 const windUnit = unit==='F' ? 'mph' : 'm/s';
1922 const unitSym = unit==='F' ? '°F' : '°C';
1923
1924 // элементы
1925 const wxTemp = document.getElementById('wxTemp');
1926 const wxDesc = document.getElementById('wxDesc');
1927 const wxFeels= document.getElementById('wxFeels');
1928 const wxIcon = document.getElementById('wxIcon');
1929 const wxGrid = document.getElementById('wxGrid');
1930 const wxPlace= document.getElementById('wxPlace');
1931 const wxMeta = document.getElementById('wxMeta');
1932
1933 wxPlace.textContent = label || '—';
1934 wxPlace.title = label || '';
1935 wxMeta.textContent = formatWeatherLocalDate(raw);
1936 wxTemp.textContent = (temp!=null ? `${Math.round(temp)}°` : '—°');
1937 wxDesc.textContent = codeToText(c.weather_code);
1938 wxIcon.innerHTML = codeToWeatherIcon(c.weather_code);
1939 wxFeels.textContent = `Feels like ${feels!=null ? Math.round(feels) : '—'}${unitSym}`;
1940
1941 // key-values: одна строка = одно значение, без переноса
1942 wxGrid.innerHTML = '';
1943 const tiles = [
1944 { label:'Wind', value:wind!=null ? Math.round(wind) : '—', unit:windUnit, icon:'Wind' },
1945 { label:'Humidity', value:c.relative_humidity_2m!=null ? c.relative_humidity_2m : '—', unit:'%', icon:'Humidity' },
1946 { label:'Pressure', value:c.pressure_msl!=null ? Math.round(c.pressure_msl) : '—', unit:'hPa', icon:'Pressure' }
1947 ];
1948
1949 const iconMap = {
1950 'Precipitation': '<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path d="M12 3c3 4 6 7.5 6 10.2A6 6 0 0 1 6 13.2C6 10.5 9 7 12 3z"/></svg>',
1951 'Wind': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" xmlns="http://www.w3.org/2000/svg"><path d="M3 7h10c4 0 4-5 1-5-2 0-2 2-2 2"/><path d="M3 12h15c4 0 4 6 0 6-2 0-2-2-2-2"/><path d="M3 17h7"/></svg>',
1952 'Humidity': '<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path d="M12 2c3.5 5 7 8.5 7 12a7 7 0 1 1-14 0c0-3.5 3.5-7 7-12z"/></svg>',
1953 'Pressure': '<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path d="M12 4a8 8 0 1 1 0 16 8 8 0 0 1 0-16zm0 2a6 6 0 1 0 0 12 6 6 0 0 0 0-12zm0 1.5a.75.75 0 0 1 .75.75V12l3 3-.75.75L11.25 12V8.25A.75.75 0 0 1 12 7.5z"/></svg>'
1954 };
1955 tiles.forEach(({label:tileLabel, value, unit:tileUnit, icon})=>{
1956 const d=document.createElement('div'); d.className='kv';
1957 const ico = iconMap[icon] || '';
1958 d.innerHTML=`<i class="kv-ico" aria-hidden="true">${ico}</i><div class="kv-copy"><div class="kv-val"><strong>${value}</strong><span>${tileUnit}</span></div><div class="kv-label">${tileLabel}</div></div>`;
1959 wxGrid
1960.appendChild(d);
1961 });
1962
1963 // Graph drawing must never prevent the live condition card from rendering.
1964try { window.renderTempChart('tempChart', lastHourly); } catch (error) { console.warn('Temperature chart unavailable', error); }
1965 try { renderRainChart('rainChart', lastHourly); } catch (error) { console.warn('Rain chart unavailable', error); }
1966 try { renderSunCard(raw); } catch (error) { console.warn('Sun card unavailable', error); }
1967 }
1968
1969 let hasManualWeatherSelection = false;
1970 let weatherSelectionSequence = 0;
1971 async function showWeatherAt(lat, lon, label, countryCode, options = {}){
1972 const initialSelection = options.initial === true;
1973 if(initialSelection && hasManualWeatherSelection) return;
1974 if(!initialSelection) hasManualWeatherSelection = true;
1975 const selection = ++weatherSelectionSequence;
1976 try{
1977 const status = document.getElementById('statusMsg');
1978 if(status) status.textContent = 'Loading weather…';
1979 const d = await fetchWeather(lat, lon);
1980 if(selection !== weatherSelectionSequence) return;
1981 const displayFlag = isoToFlag(countryCode);
1982 const flag = displayFlag ? `${displayFlag} ` : '';
1983 const cityOnly = (label||'').split(',')[0].trim();
1984 renderWeatherBox(d, (label ? (flag + cityOnly) : `${lat.toFixed(3)}, ${lon.toFixed(3)}`), countryCode);
1985 const popupName = (label||'').split(',')[0].trim();
1986 const popupNode = document.createElement('div');
1987 popupNode.className = 'globe-popup-label';
1988 if(displayFlag){
1989 const popupFlag = document.createElement('span');
1990 popupFlag.className = 'globe-popup-flag';
1991 popupFlag.textContent = displayFlag;
1992 popupNode.appendChild(popupFlag);
1993 }
1994 const popupText = document.createElement('span');
1995 popupText.textContent = popupName || `${lat.toFixed(3)}, ${lon.toFixed(3)}`;
1996 popupNode.appendChild(popupText);
1997 marker.setLngLat([lon,lat]);
1998 locationPopup.setLngLat([lon,lat]).setDOMContent(popupNode).addTo(map);
1999 map.easeTo({center:[lon,lat],zoom:Math.max(map.getZoom(),MAP_SELECTION_ZOOM),duration:900,essential:true});
2000 const st = document.getElementById('statusMsg'); if(st) st.textContent = '';
2001 }catch(e){
2002 if(selection !== weatherSelectionSequence) return;
2003 const st = document.getElementById('statusMsg'); if(st) st.textContent = 'Failed to load weather.';
2004 }
2005 }
2006
2007 window.showWeatherAt = showWeatherAt;
2008
2009 // ===== Reverse/IP geocode =====
2010 const isLocalPreview = location.hostname === 'localhost' || location.hostname === '127.0.0.1';
2011 const reverseGeocodeCache = new Map();
2012 let reverseGeocodeSequence = Promise.resolve();
2013 let reverseGeocodeLastRequest = 0;
2014 const siteLanguage = (document.documentElement.lang || 'en').split('-')[0].toLowerCase();
2015 function isReadableForLanguage(value,language){
2016 if(!value) return false;
2017 if(language !== 'en') return true;
2018 return !/[\u0400-\u052f\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af]/u.test(value);
2019 }
2020 function nominatimJsonp(url){
2021 return new Promise((resolve,reject)=>{
2022 const callbackName = `weatherNowReverse${Date.now()}${Math.random().toString(36).slice(2)}`;
2023 const script = document.createElement('script');
2024 const cleanup = () => { clearTimeout(timeout); script.remove(); delete window[callbackName]; };
2025 const timeout = setTimeout(()=>{ cleanup(); reject(new Error('reverse geocoding timeout')); },7000);
2026 window[callbackName] = data => { cleanup(); resolve(data); };
2027 url.searchParams.set('json_callback',callbackName);
2028 script.onerror = () => { cleanup(); reject(new Error('reverse geocoding unavailable')); };
2029 script.src = url.toString();
2030 document.head.appendChild(script);
2031 });
2032 }
2033 async function reverseGeocode(lat, lon, trackDemand = false){
2034 const cacheKey = `${lat.toFixed(3)},${lon.toFixed(3)}`;
2035 if(reverseGeocodeCache.has(cacheKey)) return reverseGeocodeCache.get(cacheKey);
2036 if(!isLocalPreview) try{
2037 const response = await fetch('/api/weather-locations/reverse',{
2038 signal:AbortSignal.timeout(8000),
2039 method:'POST',
2040 headers:{'content-type':'application/json'},
2041 body:JSON.stringify({latitude:lat,longitude:lon,locale:siteLanguage,trackDemand,visitorToken:trackDemand?(window.weatherNowSearchVisitorToken?.()||''):''})
2042 });
2043 const data = await response.json();
2044 if(response.ok && data.ok && data.label){
2045 const resolved = {label:data.label,cc:data.countryCode || null};
2046 reverseGeocodeCache.set(cacheKey,resolved);
2047 return resolved;
2048 }
2049 }catch(_){}
2050 if(!isLocalPreview) return {label:null,cc:null};
2051 const task = async () => {
2052 const wait = Math.max(0,1000-(Date.now()-reverseGeocodeLastRequest));
2053 if(wait) await new Promise(resolve=>setTimeout(resolve,wait));
2054 reverseGeocodeLastRequest = Date.now();
2055 try{
2056 const url = new URL('https://nominatim.openstreetmap.org/reverse');
2057 url.searchParams.set('format','jsonv2');
2058 url.searchParams.set('lat',lat.toFixed(6));
2059 url.searchParams.set('lon',lon.toFixed(6));
2060 url.searchParams.set('zoom','10');
2061 url.searchParams.set('addressdetails','1');
2062 url.searchParams.set('namedetails','1');
2063 url.searchParams.set('layer','address,natural');
2064 url.searchParams.set('accept-language',siteLanguage);
2065 const result = await nominatimJsonp(url);
2066 const address = result?.address || {};
2067 const names = result?.namedetails || {};
2068 const localityCandidates = [
2069 names[`name:${siteLanguage}`],
2070 siteLanguage === 'en' ? names['name:en'] || names.int_name : null,
2071 address.city,
2072 address.town,
2073 address.village,
2074 address.municipality,
2075 address.county,
2076 address.state,
2077 result?.name
2078 ].filter(Boolean);
2079 const locality = localityCandidates.find(value=>isReadableForLanguage(value,siteLanguage)) || localityCandidates[0];
2080 const country = address.country || '';
2081 const label = [locality,country].filter(Boolean).filter((value,index,array)=>array.indexOf(value)===index).join(', ');
2082 const resolved = {label:label || result?.display_name?.split(',').slice(0,2).join(',').trim() || null,cc:(address.country_code||'').toUpperCase() || null};
2083 reverseGeocodeCache.set(cacheKey,resolved);
2084 return resolved;
2085 }catch(_){ return {label:null,cc:null}; }
2086 };
2087 const queued = reverseGeocodeSequence.then(task,task);
2088 reverseGeocodeSequence = queued.catch(()=>{});
2089 return queued;
2090 }
2091
2092 map.on('click', async ({lngLat})=>{
2093 const {lat,lng} = lngLat;
2094 const status = document.getElementById('statusMsg');
2095 if(status) status.textContent = 'Loading weather…';
2096 const selected = await reverseGeocode(lat,lng);
2097 await showWeatherAt(lat,lng,selected.label,selected.cc);
2098 });
2099
2100 async function ipGeolocate(){
2101 if(isLocalPreview) return null;
2102 try{
2103 const r = await fetch('https://ipapi.co/json/', {signal:AbortSignal.timeout(4000)}); if(!r.ok) throw 0;
2104 const d = await r.json();
2105 if(typeof d.latitude==='number' && typeof d.longitude==='number'){
2106 const { label, cc } = await reverseGeocode(d.latitude, d.longitude, true);
2107 return { lat:d.latitude, lon:d.longitude, label: label || `${d.city||''}, ${d.country_name||''}`, cc: (d.country_code||'').toUpperCase() || cc };
2108 }
2109 }catch(_){}
2110 return null;
2111 }
2112
2113 // ===== Search + demand-driven city pages =====
2114 const form = document.getElementById('searchForm');
2115 const input = document.getElementById('cityInput');
2116 async function resolveCityFallback(query){
2117 const url = new URL('https://geocoding-api.open-meteo.com/v1/search');
2118 url.searchParams.set('name', query);
2119 url.searchParams.set('count', '1');
2120 url.searchParams.set('language', document.documentElement.lang || 'en');
2121 url.searchParams.set('format', 'json');
2122 const response = await fetch(url, {signal:AbortSignal.timeout(8000)});
2123 if(!response.ok) throw new Error('geocoding');
2124 const match = (await response.json())?.results?.[0];
2125 if(!match) return null;
2126 return {
2127 latitude: match.latitude,
2128 longitude: match.longitude,
2129 name: match.name,
2130 country: match.country,
2131 countryCode: (match.country_code || '').toUpperCase()
2132 };
2133 }
2134 form.addEventListener('submit', async (e)=>{
2135 e.preventDefault();
2136 const q = input.value.trim(); if(!q) return;
2137 try{
2138 let location = null;
2139 if(!isLocalPreview) try{
2140 const res = await fetch('/api/weather-locations/resolve', {
2141 signal:AbortSignal.timeout(8000),
2142 method:'POST', headers:{'content-type':'application/json'},
2143 body:JSON.stringify({query:q,locale:document.documentElement.lang||'en',intent:'display',visitorToken:window.weatherNowSearchVisitorToken?.()||''})
2144 });
2145 const data = await res.json();
2146 if(data.ok && data.location) location = data.location;
2147 }catch(_endpointError){}
2148 if(!location) location = await resolveCityFallback(q);
2149 if(location){
2150 const p=location;
2151 showWeatherAt(p.latitude,p.longitude,[p.name,p.country].filter(Boolean).join(', '),p.
2151countryCode);
2152 }else{
2153 const st=document.getElementById('statusMsg');if(st)st.textContent='No matching locality found.';
2154 }
2155 }catch(_){const st=document.getElementById('statusMsg');if(st)st.textContent='Search is temporarily unavailable.';}
2156 });
2157
2158 // ===== INIT =====
2159 (async function init(){
2160 void loadRain();
2161 updateLegend();
2162 // Show the default forecast immediately while optional location lookup runs.
2163 void showWeatherAt(47.3769, 8.5417, 'Zurich, Switzerland', 'CH', {initial:true});
2164
2165 const requestedCity = new URLSearchParams(location.search).get('city')?.trim();
2166 if(requestedCity){
2167 try{
2168 const requestedLocation = await resolveCityFallback(requestedCity);
2169 if(requestedLocation){
2170 window.weatherNowTemperatureUnits?.applyRegionalDefault(requestedLocation.countryCode);
2171 setLayer('precipitation');
2172 await showWeatherAt(
2173 requestedLocation.latitude,
2174 requestedLocation.longitude,
2175 [requestedLocation.name,requestedLocation.country].filter(Boolean).join(', '),
2176 requestedLocation.countryCode,
2177 {initial:true}
2178 );
2179 return;
2180 }
2181 }catch(_){
2182 const st=document.getElementById('statusMsg');
2183 if(st)st.textContent='Search is temporarily unavailable.';
2184 }
2185 }
2186
2187 if(isLocalPreview) return;
2188 const tryBrowserGeo = () => new Promise((resolve) => {
2189 if(isLocalPreview) return resolve(null);
2190 if(!('geolocation' in navigator)) return resolve(null);
2191 navigator.geolocation.getCurrentPosition(
2192 pos => resolve({ lat: pos.coords.latitude, lon: pos.coords.longitude }),
2193 _err => resolve(null),
2194 { enableHighAccuracy:false, timeout:6000, maximumAge:300000 }
2195 );
2196 });
2197
2198 let pos = await tryBrowserGeo();
2199 if(pos){
2200 const r = await reverseGeocode(pos.lat, pos.lon, true);
2201 window.weatherNowTemperatureUnits?.applyRegionalDefault(r.cc);
2202 setLayer('precipitation'); showWeatherAt(pos.lat, pos.lon, r.label, r.cc, {initial:true});
2203 }else{
2204 const ip = await ipGeolocate();
2205 if(ip){
2206 window.weatherNowTemperatureUnits?.applyRegionalDefault(ip.cc);
2207 setLayer('precipitation'); showWeatherAt(ip.lat, ip.lon, ip.label, ip.cc, {initial:true});
2208 }else{
2209 window.weatherNowTemperatureUnits?.applyRegionalDefault('CH');
2210 setLayer('precipitation'); showWeatherAt(47.3769, 8.5417, 'Zurich, Switzerland', 'CH', {initial:true});
2211 }
2212 }
2213 })();
2214
2215 // ===== Units toggle (°C/°F + m/s/mph) =====
2216 const btnC = document.getElementById('uC');
2217 const btnF = document.getElementById('uF');
2218 function syncUnitButtons(){
2219 const fahrenheit = unit === 'F';
2220 btnC.classList.toggle('active', !fahrenheit);
2221 btnF.classList.toggle('active', fahrenheit);
2222 btnC.setAttribute('aria-pressed', String(!fahrenheit));
2223 btnF.setAttribute('aria-pressed', String(fahrenheit));
2224 }
2225 function rerenderUnits(){
2226 if(!lastWeather) return;
2227 const place = document.getElementById('wxPlace').textContent;
2228 renderWeatherBox(lastWeather, place, null);
2229 }
2230 function applyTemperatureUnit(nextUnit){
2231 unit = nextUnit === 'F' ? 'F' : 'C';
2232 window.unit = unit;
2233 syncUnitButtons();
2234 rerenderUnits();
2235 }
2236 window.addEventListener('weathernow:temperature-unit-change', event => {
2237 applyTemperatureUnit(event.detail?.unit);
2238 });
2239 syncUnitButtons();
2240 btnC.addEventListener('click', ()=>{
2241 if(window.weatherNowTemperatureUnits) window.weatherNowTemperatureUnits.set('C', {source:'toggle'});
2242 else applyTemperatureUnit('C');
2243 });
2244 btnF.addEventListener('click', ()=>{
2245 if(window.weatherNowTemperatureUnits) window.weatherNowTemperatureUnits.set('F', {source:'toggle'});
2246 else applyTemperatureUnit('F');
2247 });
2248 </script>
2248
2249
2250<script type="text/plain" data-home-weather-runtime>
2251(() => {
2252 const SAFE_TOP = 10, SAFE_BAND = 28; // компактная верхняя зона для Min/Max
2253
2254 // Catmull-Rom -> Bezier
2255 function cr2bz(p0, p1, p2, p3, t=0.5){
2256 return [
2257 p1.x + (p2.x - p0.x) * t/6, p1.y + (p2.y - p0.y) * t/6,
2258 p2.x - (p3.x - p1.x) * t/6, p2.y - (p3.y - p1.y) * t/6,
2259 p2.x, p2.y
2260 ];
2261 }
2262
2263 window.renderTempChart = function(containerId, hourly){
2264 const c = document.getElementById(containerId);
2265 if (!c || !hourly?.times?.length || !hourly?.tempsC?.length) return;
2266
2267 const dpr = window.devicePixelRatio || 1;
2268 const W = c.clientWidth || 640;
2269 const H = Math.round(c.getBoundingClientRect().height) || 148;
2270 c.width = Math.round(W * dpr);
2271 c.height = Math.round(H * dpr);
2272 c.style.width = W + "px";
2273 c.style.height = H + "px";
2274 const ctx = c.getContext('2d');
2275 ctx.setTransform(dpr,0,0,dpr,0,0);
2276 ctx.clearRect(0,0,W,H);
2277
2278 // --- Build exact next 24h from now, then take every 2h (12 points) ---
2279 const timesAll = hourly.times.map(t => t instanceof Date ? t : new Date(t));
2280 const total = Math.min(hourly.tempsC.length, timesAll.length);
2281 const hours24 = timesAll.slice(0, Math.min(24, total));
2282 const temps24 = hourly.tempsC.slice(0, Math.min(24, total));
2283 // sample each 2 hours
2284 const times = hours24.filter((_,i)=> i%2===0);
2285 const tempsC = temps24.filter((_,i)=> i%2===0);
2286 if (!times.length) return;
2287
2288 const u = window.unit === 'F' ? 'F' : 'C';
2289 const conv = typeof window.toF === 'function' ? window.toF : (x)=>x*9/5+32;
2290 const vals = tempsC.map(v => u==='F' ? conv(v) : v);
2291
2292 let vmin = Math.min(...vals), vmax = Math.max(...vals);
2293 const vPad = Math.max(2, (vmax - vmin) * 0.18);
2294 vmin -= vPad; vmax += vPad;
2295
2296 // Reserve a dedicated lane above the curve for the temperature readings.
2297 // A point can never enter this lane, so the line cannot cross its own label.
2298 const padL = 18, padR = 10, padTop = 36, padBot = 30;
2299 const innerW = W - padL - padR, innerH = H - padTop - padBot;
2300
2301 const xAt = i => padL + innerW * (i / Math.max(1,(vals.length-1)));
2302 const yAt = v => padTop + innerH * (1 - (v - vmin) / Math.max(1,(vmax - vmin)));
2303
2304 // Grid: guides run from the date axis all the way through the value-label lane.
2305 // The opaque temperature labels remain readable on top of them.
2306 const gridTop = 12;
2307 ctx.strokeStyle = 'rgba(161,193,208,0.12)';
2308 ctx.lineWidth = 1;
2309 for (let i=0;i<vals.length;i++){
2310 const x = xAt(i); ctx.beginPath(); ctx.moveTo(x,gridTop); ctx.lineTo(x,H-padBot); ctx.stroke();
2311 }
2312 ctx.beginPath();
2312 ctx.moveTo(padL, H-padBot); ctx.lineTo(W-padR, H-padBot); ctx.stroke();
2313
2314 // Gradient area
2315 const g = ctx.createLinearGradient(0,padTop,0,H-padBot);
2316 g.addColorStop(0,'rgba(86,177,223,0.22)'); g.addColorStop(1,'rgba(86,177,223,0)');
2317 ctx.fillStyle = g;
2318
2319 // Smooth path (Catmull–Rom to Bezier)
2320 const pts = vals.map((v,i)=>({x:xAt(i), y:yAt(v)}));
2321 function cr2bz(p0,p1,p2,p3,t=0.5){
2322 return [
2323 p1.x + (p2.x - p0.x) * t/6, p1.y + (p2.y - p0.y) * t/6,
2324 p2.x - (p3.x - p1.x) * t/6, p2.y - (p3.y - p1.y) * t/6,
2325 p2.x, p2.y
2326 ];
2327 }
2328 ctx.beginPath();
2329 if (pts.length){
2330 ctx.moveTo(pts[0].x, pts[0].y);
2331 for (let i=0;i<pts.length-1;i++){
2332 const p0 = pts[Math.max(0,i-1)], p1 = pts[i], p2 = pts[i+1], p3 = pts[Math.min(pts.length-1,i+2)];
2333 const [cx1,cy1,cx2,cy2,xe,ye] = cr2bz(p0,p1,p2,p3,0.5);
2334 ctx.bezierCurveTo(cx1,cy1,cx2,cy2,xe,ye);
2335 }
2336 }
2337 ctx.lineTo(W - padR, H - padBot); ctx.lineTo(padL, H - padBot); ctx.closePath(); ctx.fill();
2338
2339 // Stroke
2340 ctx.beginPath();
2341 if (pts.length){
2342 ctx.moveTo(pts[0].x, pts[0].y);
2343 for (let i=0;i<pts.length-1;i++){
2344 const p0 = pts[Math.max(0,i-1)], p1 = pts[i], p2 = pts[i+1], p3 = pts[Math.min(pts.length-1,i+2)];
2345 const [cx1,cy1,cx2,cy2,xe,ye] = cr2bz(p0,p1,p2,p3,0.5);
2346 ctx.bezierCurveTo(cx1,cy1,cx2,cy2,xe,ye);
2347 }
2348 }
2349 ctx.strokeStyle = '#72c7f2'; ctx.lineWidth = 2.25; ctx.stroke();
2350
2351 // Points (bigger)
2352 ctx.fillStyle = '#fff';
2353 for (const p of pts){ ctx.beginPath(); ctx.arc(p.x,p.y,3,0,Math.PI*2); ctx.fill(); }
2354
2355 // Safari has intermittently dropped canvas text while preserving the line.
2356 // Use HTML labels directly above the same points that have time labels.
2357 const readings = document.getElementById('tempChartReadings');
2358 if (readings) readings.replaceChildren();
2359
2360 // Values belong to the canvas that owns the curve. Keeping them in the same
2361 // coordinate system prevents mobile centring and resizing from desynchronising
2362 // a second HTML overlay from its points.
2363 ctx.save();
2364 setChartCanvasFont(ctx, 700, 14);
2365 ctx.textAlign = 'center';
2366 ctx.textBaseline = 'bottom';
2367 ctx.lineWidth = 3;
2368 ctx.strokeStyle = 'rgba(43, 53, 59, .92)';
2369 ctx.fillStyle = '#f4f9fb';
2370 for (let i = 0; i < vals.length; i += 2) {
2371 const labelY = Math.max(17, pts[i].y - 9);
2372 const label = `${Math.round(vals[i])}°`;
2373 ctx.strokeText(label, xAt(i), labelY);
2374 ctx.fillText(label, xAt(i), labelY);
2375 }
2376 ctx.restore();
2377
2378 // Show an exact reading at each labelled time. This keeps the curve readable
2379 // while making the chart useful without hovering.
2380 const timeLabel = time => `${String(new Date(time).getHours()).padStart(2, '0')}:00`;
2381
2382 for (let i=0;i<vals.length;i++){
2383 const x = xAt(i);
2384
2385 // Six evenly spaced labels fit without a cramped final label at the edge.
2386 const labelEvery = 2;
2387 // Same timeline typography as the rain chart: size, weight and muted colour.
2388 setChartCanvasFont(ctx, 500, 12);
2389 ctx.fillStyle = 'rgba(188, 211, 222, .82)';
2390 ctx.textAlign = 'center';
2391 ctx.textBaseline = 'top';
2392 if (i % labelEvery === 0) {
2393 ctx.fillText(timeLabel(times[i]), x, H - padBot + 8);
2394 }
2395 }
2396
2397 };
2398})();
2399</script>
2399
2400
2401
2402<script type="text/plain" data-home-weather-runtime>
2403(function(){
2404 window.__lastChartArgs ??= null;
2405 const _orig = window.renderTempChart;
2406 if (typeof _orig === 'function') {
2407 window.renderTempChart = function(containerId, hourly){
2408 window.__lastChartArgs = { containerId, hourly };
2409 return _orig.call(this, containerId, hourly);
2410 };
2411 }
2412 let prev = window.unit;
2413 setInterval(()=>{
2414 if (window.unit !== prev && window.__lastChartArgs){
2415 prev = window.unit;
2416 const { containerId, hourly } = window.__lastChartArgs;
2417 try { window.renderTempChart(containerId, hourly); } catch(e){}
2418 }
2419 }, 300);
2420})();
2421</script>
2421
2422
2423
2424<script type="text/plain" data-home-weather-runtime>
2425/* === Sync °C/°F in map temperature legend === */
2426(function () {
2427 const toF = c => Math.round(c * 9 / 5 + 32);
2428 const C_MIN = -20, C_MAX = 40;
2429 function format(v) { return String(v); }
2430 function applyTempLegendUnits() {
2431 const box = document.getElementById('legendBox');
2432 if (!box) return;
2433 const title = box.querySelector('h4, .legend-title');
2434 if (!title || !/Temperature/i.test(title.textContent)) return;
2435 const u = (window.unit === 'F') ? '°F' : '°C';
2436 title.textContent = `Temperature (${u})`;
2437 const labels = box.querySelector('.labels');
2438 if (labels) {
2439 const spans = labels.querySelectorAll('span');
2440 if (spans.length >= 2) {
2441 const toF = c => Math.round(c * 9 / 5 + 32);
2442 const C_MIN = -20, C_MAX = 40;
2443 const fmt = n => (n > 0 ? '+' : '') + String(n);
2444 if (window.unit === 'F') {
2445 spans[0].textContent = fmt(toF(C_MIN));
2446 spans[1].textContent = fmt(toF(C_MAX));
2447 } else {
2448 spans[0].textContent = fmt(C_MIN);
2449 spans[1].textContent = fmt(C_MAX);
2450 }
2451 }
2452 }
2453 }
2454 window.addEventListener('load', applyTempLegendUnits);
2455 document.addEventListener('click', () => setTimeout(applyTempLegendUnits, 0));
2456 (function watchUnit() {
2457 let prev = window.unit;
2458 setInterval(() => {
2459 if (window.unit !== prev) {
2460 prev = window.unit;
2461 applyTempLegendUnits();
2462 }
2463 }, 300);
2464 })();
2465})();
2466</script>
2466
2467
2468
2469<script type="text/plain" data-home-weather-runtime>
2470// === Responsive chart re-render (loop-safe) ===
2471(function(){
2472 if (!window.renderTempChart) return;
2473 if (window.__chartObserverAttached) return; // guard
2474 window.__chartObserverAttached = true;
2475
2476 const debounce=(fn,t=120)=>{let id;return(...a)=>{clearTimeout(id);id=setTimeout(()=>fn(...a),t);}};
2477
2478 function rerender(){
2479 const args = window.__lastChartArgs || { containerId:'tempChart', hourly:window.lastHourly || window.__lastHourly };
2480 if (!args) return;
2481 if (window.__chartRendering) return; // prevent re-entrant loops
2482 window.__chartRendering = true;
2483 try{ window.renderTempChart(args.containerId, args.hourly); }catch(e){}
2484 finally{ window.__chartRendering = false; }
2485 }
2486
2487 function attachObserver(){
2488 const args = window.__lastChartArgs || { containerId:'tempChart', hourly:window.lastHourly || window.__lastHourly };
2489 if (!args) return;
2490 const c = document.getElementById(args.containerId);
2491 if (!c) return;
2492
2493 // Observe the CONTAINER (parent), not the canvas itself, to avoid feedback
2494 const obsEl = c.parentElement || c;
2495 let lastW = obsEl.clientWidth || 0;
2496
2497 const onResize = debounce(()=>{
2498 const w = obsEl.clientWidth || 0;
2499 if (!w || w === lastW) return;
2500 lastW = w;
2501 rerender();
2502 }, 140);
2503
2504 if (window.ResizeObserver){
2505 try { window.__chartRO && window.__chartRO.disconnect && window.__chartRO.disconnect(); } catch(e){}
2506 window.__chartRO = new ResizeObserver(()=> onResize());
2507 window.__chartRO.observe(obsEl);
2508 }
2509
2510 window.addEventListener('resize', onResize);
2511 window.addEventListener('orientationchange', onResize);
2512 }
2513
2514 attachObserver();
2515})();
2516</script>
2516
2517
2518
2519<script id="attach-gradient-to-search" type="text/plain" data-home-weather-runtime>
2520(function(){
2521 var btn = document.querySelector('#searchForm button');
2522 if(btn && !btn.classList.contains('btn-gradient')){
2523 btn.classList.add('btn-gradient');
2524 }
2525})();
2526</script>
2526
2527
2528<script id="home-weather-loader">
2529(() => {
2530  const section = document.getElementById('weather-live');
2531  const runtimeBlocks = [...document.querySelectorAll('script[data-home-weather-runtime]')];
2532  let loadPromise;
2533
2534  function loadStylesheet(href) {
2535    return new Promise((resolve, reject) => {
2536      const existing = document.querySelector(`link[href="${href}"]`);
2537      if (existing?.sheet) return resolve();
2538      const link = existing || document.createElement('link');
2539      link.rel = 'stylesheet';
2540      link.href = href;
2541      link.onload = resolve;
2542      link.onerror = reject;
2543      if (!existing) document.head.append(link);
2544    });
2545  }
2546
2547  function loadScript(src) {
2548    return new Promise((resolve, reject) => {
2549      const existing = document.querySelector(`script[src="${src}"]`);
2550      if (existing?.dataset.loaded === 'true') return resolve();
2551      const script = existing || document.createElement('script');
2552      script.src = src;
2553      script.onload = () => {
2554        script.dataset.loaded = 'true';
2555        resolve();
2556      };
2557      script.onerror = reject;
2558      if (!existing) document.head.append(script);
2559    });
2560  }
2561
2562  function executeRuntimeBlocks() {
2563    for (const source of runtimeBlocks) {
2564      const script = document.createElement('script');
2565      script.textContent = source.textContent;
2566      document.body.append(script);
2567      script.remove();
2568    }
2569  }
2570
2571  function loadWeatherExperience() {
2572    if (loadPromise) return loadPromise;
2573    section.dataset.runtime = 'loading';
2574    loadPromise = Promise.all([
2575      loadStylesheet('https://unpkg.com/[email protected]/dist/maplibre-gl.css'),
2576      loadScript('/js/map-day-night.js?v=1'),
2577      loadScript('https://unpkg.com/[email protected]/dist/maplibre-gl.js')
2578    ])
2579      .then(() => {
2580        executeRuntimeBlocks();
2581        section.dataset.runtime = 'ready';
2582      })
2583      .catch((error) => {
2584        section.dataset.runtime = 'error';
2585        const status = document.getElementById('statusMsg');
2586        if (status) status.textContent = 'The interactive map is temporarily unavailable.';
2587        console.error('[Weather Now map]', error);
2588      });
2589    return loadPromise;
2590  }
2591
2592  window.weatherNowLoadMap = loadWeatherExperience;
2593  const observer = new IntersectionObserver((entries) => {
2594    if (!entries.some((entry) => entry.isIntersecting)) return;
2595    observer.disconnect();
2596    loadWeatherExperience();
2597  }, { rootMargin: '600px 0px', threshold: 0.01 });
2598  observer.observe(section);
2599
2600  section.addEventListener('focusin', loadWeatherExperience, { once: true });
2601  section.addEventListener('pointerdown', loadWeatherExperience, { once: true, passive: true });
2602  document.addEventListener('click', (event) => {
2603    if (event.target.closest('a[href="#weather-live"]')) loadWeatherExperience();
2604  });
2605  if (location.hash === '#weather-live' || new URLSearchParams(location.search).has('city')) {
2606    loadWeatherExperience();
2607  }
2608})();
2609</script>
2609
2610
2611
2612<style id="final-compact-weather-cards">
2613#statusMsg:empty { display:none; }
2614#statusMsg { color:#dcebf3; margin:0 0 12px; }
2615@media (min-width: 540px) and (max-width: 980px) {
2616  body.home-v2 .wx-main {
2617    grid-template-columns: repeat(2, minmax(0, min(278px, calc((100% - 12px) / 2)))) !important;
2618    grid-template-rows: 224px 101px !important;
2619    grid-template-areas: "current temperature" "sun rain" !important;
2620    justify-content: center !important;
2621    align-items: start !important;
2622    gap: 12px !important;
2623  }
2624  body.home-v2 .wx-main > .wx-current-panel,
2625  body.home-v2 .wx-main > .sun-card,
2626  body.home-v2 .wx-main > .temperature-card,
2627  body.home-v2 .wx-main > .rain-card {
2628    width: 100% !important;
2629    min-width: 0 !important;
2630    margin: 0 !important;
2631    align-self: start !important;
2632  }
2633  body.home-v2 .wx-main > .wx-current-panel { height: 224px !important; grid-template-rows: 54px 86px minmax(0, 1fr) !important; }
2634  body.home-v2 .wx-main > .sun-card { height: 92px !important; }
2635  body.home-v2 .wx-main > .temperature-card { height: 149px !important; }
2636  body.home-v2 .wx-main > .rain-card { height: 101px !important; }
2637  body.home-v2 .wx-current-panel .wx-top,
2638  body.home-v2 .wx-current-panel .wx-hero { padding-left: 14px !important; padding-right: 14px !important; }
2639  body.home-v2 .sun-card,
2640  body.home-v2 .temperature-card,
2641  body.home-v2 .rain-card { padding-left: 14px !important; padding-right: 14px !important; }
2642}
2643</style>
2644
2645<style id="final-responsive-map-search">
2646/* Desktop: fill the available space between the weather rail and map legend. */
2647@media (min-width: 981px) {
2648  body.home-v2 .mw-head #searchForm {
2649    left: calc(1.5% + 304px) !important;
2650    right: calc(27.2% + 32px) !important;
2651    width: auto !important;
2652    max-width: none !important;
2653  }
2654}
2655
2656/* Tablet: search and unit controls form one row spanning the map width. */
2657@media (min-width: 681px) and (max-width: 980px) {
2658  body.home-v2 .mw-head {
2659    justify-content: stretch !important;
2660  }
2661  body.home-v2 .mw-head #searchForm {
2662    flex: 1 1 auto !important;
2663    width: auto !important;
2664    min-width: 0 !important;
2665  }
2666  body.home-v2 .mw-head .unit-toggle {
2667    flex: 0 0 92px !important;
2668  }
2669}
2670</style>
2671
2672<style id="final-tablet-map-rail">
2673/* 681–980px still has enough map height for a compact weather rail. Keep
2674   search and units outside, but keep forecast data on the map itself. */
2675@media (min-width: 681px) and (max-width: 980px) {
2676  body.home-v2 .mw-body {
2677    position: relative !important;
2678    height: 540px !important;
2679    min-height: 540px !important;
2680    overflow: hidden !important;
2681  }
2682  body.home-v2 .map-wrap,
2683  body.home-v2 #map {
2684    position: absolute !important;
2685    inset: 0 !important;
2686    height: 540px !important;
2687    min-height: 540px !important;
2688  }
2689  body.home-v2 .wx-wrap {
2690    position: absolute !important;
2691    inset: 0 !important;
2692    width: 100% !important;
2693    height: 100% !important;
2694    min-height: 0 !important;
2695    pointer-events: none !important;
2696  }
2697  body.home-v2 .wx-main {
2698    position: absolute !important;
2699    top: 12px !important;
2700    left: 12px !important;
2701    width: clamp(220px, 32vw, 248px) !important;
2702    height: 510px !important;
2703    padding: 0 !important;
2704    display: grid !important;
2705    grid-template-columns: 1fr !important;
2706    grid-template-rows: 190px 90px 120px 86px !important;
2707    grid-template-areas: "current" "sun" "temperature" "rain" !important;
2708    justify-content: stretch !important;
2709    align-items: start !important;
2710    gap: 8px !important;
2711    pointer-events: none !important;
2712  }
2713  body.home-v2 .wx-main > .wx-current-panel,
2714  body.home-v2 .wx-main > .sun-card,
2715  body.home-v2 .wx-main > .temperature-card,
2716  body.home-v2 .wx-main > .rain-card {
2717    position: static !important;
2718    width: 100% !important;
2719    height: 100% !important;
2720    min-height: 0 !important;
2721    margin: 0 !important;
2722    pointer-events: auto !important;
2723  }
2724  body.home-v2 .wx-main > .wx-current-panel { grid-area: current !important; }
2725  body.home-v2 .wx-main > .sun-card { grid-area: sun !important; }
2726  body.home-v2 .wx-main > .temperature-card { grid-area: temperature !important; }
2727  body.home-v2 .wx-main > .rain-card { grid-area: rain !important; }
2728  body.home-v2 .wx-main > .wx-current-panel {
2729    grid-template-rows: 48px 74px minmax(0, 1fr) !important;
2730  }
2731  body.home-v2 .wx-main > .sun-card { padding: 10px !important; }
2732  body.home-v2 .wx-main > .temperature-card { padding: 10px 12px 6px !important; }
2733  body.home-v2 .wx-main > .rain-card { padding: 9px 12px 6px !important; }
2734  body.home-v2 .wx-main > .temperature-card #tempChart { height: 92px !important; }
2735  body.home-v2 .wx-main > .rain-card #rainChart { height: 54px !important; }
2736  body.home-v2 .map-wrap > .dashboard-legend,
2737  body.home-v2 .map-wrap > .dashboard-legend.is-open {
2738    left: 64% !important;
2739  }
2740}
2741</style>
2742
2743<style id="final-midwidth-legend-placement">
2744/* At medium desktop widths the top toolbar is reserved for search and units. */
2745@media (min-width: 981px) and (max-width: 1300px) {
2746  body.home-v2 .mw-head #searchForm {
2747    right: calc(1.14% + 104px) !important;
2748    width: auto !important;
2749    max-width: none !important;
2750  }
2751  body.home-v2 .map-wrap > .dashboard-legend,
2752  body.home-v2 .map-wrap > .dashboard-legend.is-open {
2753    inset: auto auto 14px 50% !important;
2754    width: min(220px, calc(100% - 32px)) !important;
2755    transform: translateX(-50%) !important;
2756  }
2757}
2758</style>
2759
2760<style id="final-fixed-weather-card-system">
2761/* One immutable card template.  Breakpoints may move this four-card group,
2762   but must never shrink, restyle, or recompute any card's content.  The
2763   selectors intentionally match the map-card specificity used above. */
2764body.home-v2 .mw-card .wx-main > .wx-current-panel,
2765body.home-v2 .mw-card .wx-main > .sun-card,
2766body.home-v2 .mw-card .wx-main > .temperature-card,
2767body.home-v2 .mw-card .wx-main > .rain-card {
2768  width: 278px !important;
2769  min-width: 278px !important;
2770  box-sizing: border-box !important;
2771}
2772body.home-v2 .mw-card .wx-main > .wx-current-panel {
2773  height: 224px !important;
2774  grid-template-rows: 54px 86px minmax(0, 1fr) !important;
2775}
2776body.home-v2 .mw-card .wx-main > .sun-card {
2777  height: 92px !important;
2778  padding: 10px 12px !important;
2779}
2780body.home-v2 .mw-card .wx-main > .temperature-card {
2781  height: 149px !important;
2782  padding: 16px 18px 10px !important;
2783}
2784body.home-v2 .mw-card .wx-main > .rain-card {
2785  height: 101px !important;
2786  padding: 13px 16px 8px !important;
2787}
2788body.home-v2 .mw-card .wx-main > .temperature-card #tempChart { height: 122px !important; }
2789body.home-v2 .mw-card .wx-main > .rain-card #rainChart { height: 56px !important; }
2790
2791/* Shared content rail: every card starts from the exact same x-coordinate.
2792   Chart headings used to add their own inset, so their text drifted relative
2793   to the location and sun cards despite aligned outer card edges. */
2794body.home-v2 .mw-card {
2795  --weather-card-inset-x: 16px;
2796  --weather-card-inset-y: 12px;
2797}
2798body.home-v2 .mw-card .wx-current-panel .wx-top,
2799body.home-v2 .mw-card .wx-current-panel .wx-hero,
2800body.home-v2 .mw-card .wx-main > .sun-card,
2801body.home-v2 .mw-card .wx-main > .temperature-card,
2802body.home-v2 .mw-card .wx-main > .rain-card {
2803  padding-left: var(--weather-card-inset-x) !important;
2804  padding-right: var(--weather-card-inset-x) !important;
2805}
2806/* Every standalone card now has the same 16px horizontal and 12px vertical
2807   safe area.  Charts keep their existing size; only their card-level frame
2808   is normalized. */
2809body.home-v2 .mw-card .wx-main > .sun-card,
2810body.home-v2 .mw-card .wx-main > .temperature-card,
2811body.home-v2 .mw-card .wx-main > .rain-card {
2812  padding: var(--weather-card-inset-y) var(--weather-card-inset-x) !important;
2813}
2814/* The current-conditions card is split by two divider lines, so its sections
2815   use the same horizontal rail while preserving their established compact
2816   vertical rhythm. */
2817body.home-v2 .mw-card .wx-current-panel .wx-top {
2818  padding-top: 8px !important;
2819  padding-bottom: 8px !important;
2820}
2821body.home-v2 .mw-card .wx-current-panel .wx-hero {
2822  padding-top: var(--weather-card-inset-y) !important;
2823  padding-bottom: var(--weather-card-inset-y) !important;
2824}
2825body.home-v2 .mw-card .wx-current-panel .wx-grid {
2826  padding-left: var(--weather-card-inset-x) !important;
2827  padding-right: var(--weather-card-inset-x) !important;
2828}
2829body.home-v2 .mw-card .wx-current-panel .wx-grid > * {
2830  padding-left: 0 !important;
2831  padding-right: 0 !important;
2832}
2833body.home-v2 .mw-card .sun-card .chart-head,
2834body.home-v2 .mw-card .temperature-card .chart-head,
2835body.home-v2 .mw-card .rain-card .chart-head {
2836  padding-left: 0 !important;
2837  padding-right: 0 !important;
2838}
2839
2840/* The 590px rail lives inside a 620px map: preserve one 15px outer gutter
2841   above and below it at every map-overlay width. */
2842@media (min-width: 681px) {
2843  body.home-v2 .mw-card .wx-main {
2844    inset: 15px auto auto 16px !important;
2845  }
2846}
2847@media (min-width: 681px) and (max-width: 980px) {
2848  body.home-v2 .mw-card .wx-wrap {
2849    margin-top: 0 !important;
2850  }
2851  /* The map border accounts for one pixel at this breakpoint.  Offset the
2852     rail by one pixel so the visible top and bottom gutters remain equal. */
2853  body.home-v2 .mw-card .wx-main {
2854    top: 14px !important;
2855  }
2856}
2857
2858/* Tablet keeps the exact desktop left rail inside a taller map rather than
2859   squeezing four cards into smaller panels. */
2860@media (min-width: 681px) and (max-width: 980px) {
2861  body.home-v2 .mw-body,
2862  body.home-v2 .map-wrap,
2863  body.home-v2 #map { height: 620px !important; min-height: 620px !important; }
2864  body.home-v2 .mw-body { overflow: hidden !important; }
2865  body.home-v2 .wx-main {
2866    inset: 15px auto auto 16px !important;
2867    width: 278px !important;
2868    height: 593px !important;
2869    display: grid !important;
2870    grid-template-columns: 278px !important;
2871    grid-template-rows: 224px 92px 149px 101px !important;
2872    grid-template-areas: "current" "sun" "temperature" "rain" !important;
2873    gap: 8px !important;
2874  }
2875  body.home-v2 .wx-main > .wx-current-panel,
2876  body.home-v2 .wx-main > .sun-card,
2877  body.home-v2 .wx-main > .temperature-card,
2878  body.home-v2 .wx-main > .rain-card {
2879    position: static !important;
2880    inset: auto !important;
2881    width: 278px !important;
2882    min-width: 278px !important;
2883    margin: 0 !important;
2884    transform: none !important;
2885  }
2886  body.home-v2 .wx-current-panel { grid-area: current !important; }
2887  body.home-v2 .sun-card { grid-area: sun !important; }
2888  body.home-v2 .temperature-card { grid-area: temperature !important; }
2889  body.home-v2 .rain-card { grid-area: rain !important; }
2890}
2891
2892/* On phone widths the card surfaces span the same rail as the map.  The
2893   established compact content measure is centred below, so typography and
2894   charts keep their proportions instead of stretching with the surface. */
2895@media (max-width: 680px) {
2896  body.home-v2 .mw-card .wx-wrap {
2897    width: 100% !important;
2898    overflow: visible !important;
2899  }
2900  body.home-v2 .mw-card .wx-main {
2901    width: 100% !important;
2902    height: 596px !important;
2903    min-height: 0 !important;
2904    margin: 12px 0 0 !important;
2905    display: grid !important;
2906    grid-template-columns: minmax(0, 1fr) !important;
2907    grid-template-rows: 224px 92px 149px 101px !important;
2908    grid-template-areas: "current" "sun" "temperature" "rain" !important;
2909    gap: 10px !important;
2910  }
2911  body.home-v2 .mw-card .wx-main > .wx-current-panel,
2912  body.home-v2 .mw-card .wx-main > .sun-card,
2913  body.home-v2 .mw-card .wx-main > .temperature-card,
2914  body.home-v2 .mw-card .wx-main > .rain-card {
2915    position: static !important;
2916    inset: auto !important;
2917    width: 100% !important;
2918    min-width: 0 !important;
2919    margin: 0 !important;
2920    transform: none !important;
2921    isolation: isolate !important;
2922    border-radius: 16px !important;
2923  }
2924}
2925
2926/* Stretch only the surfaces; keep the existing card contents centred and
2927   unchanged, matching the mobile city dashboard. */
2928@media (max-width: 980px) {
2929  body.home-v2 .mw-card .wx-current-panel .wx-top,
2930  body.home-v2 .mw-card .wx-current-panel .wx-hero,
2931  body.home-v2 .mw-card .wx-current-panel .wx-grid {
2932    width: min(278px, 100%) !important;
2933    margin-inline: auto !important;
2934  }
2935
2936  body.home-v2 .mw-card .wx-current-panel .wx-hero,
2937  body.home-v2 .mw-card .wx-current-panel .wx-grid {
2938    background: transparent !important;
2939    box-shadow: none !important;
2940    backdrop-filter: none !important;
2941    -webkit-backdrop-filter: none !important;
2942  }
2943
2944  body.home-v2 .mw-card .wx-current-panel .wx-grid > *:last-child {
2945    border-right: 0 !important;
2946  }
2947
2948  body.home-v2 .mw-card .sun-card .sun-values,
2949  body.home-v2 .mw-card .chart-card .chart-head,
2950  body.home-v2 .mw-card .temp-chart-readings {
2951    width: min(246px, 100%) !important;
2952    margin-inline: auto !important;
2953  }
2954
2955  body.home-v2 .mw-card .temperature-card #tempChart,
2956  body.home-v2 .mw-card .rain-card #rainChart {
2957    width: min(246px, 100%) !important;
2958    max-width: min(246px, 100%) !important;
2959    margin-inline: auto !important;
2960  }
2961}
2962</style>
2963
2964<style id="weather-card-material">
2965/* One visual material for every weather card at every breakpoint. Layout may
2966   move the cards, but a breakpoint must never change their slate surface. */
2967body.home-v2 .mw-card .wx-main > .wx-current-panel,
2968body.home-v2 .mw-card .wx-main > .sun-card,
2969body.home-v2 .mw-card .wx-main > .temperature-card,
2970body.home-v2 .mw-card .wx-main > .rain-card {
2971  background: rgba(54, 66, 72, 0.94) !important;
2972  background-image: none !important;
2973  border: 1px solid rgba(205, 224, 232, 0.22) !important;
2974  box-shadow:
2975    inset 0 1px 0 rgba(255, 255, 255, 0.05),
2976    0 10px 24px rgba(0, 0, 0, 0.18) !important;
2977  backdrop-filter: blur(18px) saturate(105%) !important;
2978  -webkit-backdrop-filter: blur(18px) saturate(105%) !important;
2979}
2980</style>
2981
2982<style id="weather-card-typography-lock">
2983/* Card placement can adapt; its typography cannot. These are the established
2984   card values and intentionally override every breakpoint-specific variant. */
2985body.home-v2 .mw-card .wx-current-panel .wx-place .name {
2986  font-size: 14px !important;
2987  line-height: 1.1 !important;
2988  font-weight: 600 !important;
2989  letter-spacing: -.015em !important;
2990}
2991body.home-v2 .mw-card .wx-current-panel .wx-place .meta {
2992  font-size: 12px !important;
2993  line-height: 1.35 !important;
2994}
2995body.home-v2 .mw-card .wx-current-panel .wx-temp {
2996  font-size: 46px !important;
2997  line-height: .9 !important;
2998  letter-spacing: -.045em !important;
2999}
3000body.home-v2 .mw-card .wx-current-panel .wx-desc {
3001  font-size: 14px !important;
3002  line-height: 1.2 !important;
3003}
3004body.home-v2 .mw-card .wx-current-panel .wx-feels {
3005  font-size: 11px !important;
3006  line-height: 1.2 !important;
3007}
3008body.home-v2 .mw-card .wx-current-panel .kv-val strong {
3009  font-size: 15px !important;
3010  line-height: 1.05 !important;
3011}
3012body.home-v2 .mw-card .wx-current-panel .kv-val span,
3013body.home-v2 .mw-card .wx-current-panel .kv-label {
3014  font-size: 10px !important;
3015  line-height: 1.15 !important;
3016}
3017body.home-v2 .mw-card .sun-card .chart-head,
3018body.home-v2 .mw-card .temperature-card .chart-head,
3019body.home-v2 .mw-card .rain-card .chart-head,
3020body.home-v2 .mw-card .sun-card .ch-unit,
3021body.home-v2 .mw-card .temperature-card .ch-unit,
3022body.home-v2 .mw-card .rain-card .ch-unit {
3023  font-size: 12px !important;
3024  line-height: 1.6 !important;
3025  letter-spacing: .015em !important;
3026}
3027body.home-v2 .mw-card .sun-stat span {
3028  font-size: 12px !important;
3029  line-height: 1 !important;
3030}
3031body.home-v2 .mw-card .sun-stat strong {
3032  font-size: 15px !important;
3033  line-height: 1.05 !important;
3034}
3035body.home-v2 .mw-card .temp-chart-reading {
3036  font-size: 12px !important;
3037  line-height: 1 !important;
3038}
3039
3040/* Reference adjustment: only the two summary lines and the weather symbol. */
3041body.home-v2 .mw-card .wx-current-panel .wx-desc,
3042body.home-v2 .mw-card .wx-current-panel .wx-feels {
3043  transform: none !important;
3044}
3045body.home-v2 .mw-card .wx-current-panel .wx-desc {
3046  transform: translateY(5px) !important;
3047}
3048body.home-v2 .mw-card .wx-current-panel .wx-feels {
3049  transform: translateY(-4px) !important;
3050}
3051body.home-v2 .mw-card .wx-current-panel .wx-icon {
3052  overflow: visible !important;
3053}
3054body.home-v2 .mw-card .wx-current-panel .wx-icon img {
3055  width: 72px !important;
3056  height: 72px !important;
3057  max-width: none !important;
3058  transform: translate(-6px, -6px) !important;
3059}
3060body.home-v2 .maplibregl-cooperative-gesture-screen {
3061  display: none !important;
3062}
3063</style>
3064
3065<style id="fold-dashboard-fit">
3066/* Fold / wide-mobile only: the complete map card fits below the fixed header. */
3067@media (min-width: 681px) and (max-width: 980px) {
3068  body.home-v2 #weather-live.section {
3069    min-height: 0 !important;
3070    padding: 4px 12px 16px !important;
3071  }
3072  body.home-v2 .mw-card {
3073    overflow: hidden !important;
3074    border-radius: 16px !important;
3075  }
3076  body.home-v2 .mw-head {
3077    --map-control-height: 44px;
3078    position: relative !important;
3079    inset: auto !important;
3080    width: 100% !important;
3081    height: 44px !important;
3082    min-height: 44px !important;
3083    margin: 0 0 6px !important;
3084    transform: none !important;
3085  }
3086  body.home-v2 #searchForm,
3087  body.home-v2 .mw-head .unit-toggle {
3088    height: 44px !important;
3089    min-height: 44px !important;
3090  }
3091  body.home-v2 .mw-body,
3092  body.home-v2 .map-wrap,
3093  body.home-v2 #map {
3094    height: clamp(530px, calc(100svh - 150px), 620px) !important;
3095    min-height: clamp(530px, calc(100svh - 150px), 620px) !important;
3096  }
3097  body.home-v2 .mw-body {
3098    position: relative !important;
3099    display: block !important;
3100    overflow: hidden !important;
3101  }
3102  body.home-v2 .map-wrap,
3103  body.home-v2 #map,
3104  body.home-v2 .wx-wrap {
3105    position: absolute !important;
3106    inset: 0 !important;
3107  }
3108  body.home-v2 .wx-wrap {
3109    width: 100% !important;
3110    height: 100% !important;
3111    min-height: 0 !important;
3112    margin: 0 !important;
3113    overflow: visible !important;
3114    background: transparent !important;
3115    pointer-events: none !important;
3116  }
3117  body.home-v2 .mw-card .wx-main {
3118    position: absolute !important;
3119    inset: 8px auto auto 8px !important;
3120    width: 278px !important;
3121    height: 593px !important;
3122    margin: 0 !important;
3123    display: grid !important;
3124    grid-template-columns: 278px !important;
3125    grid-template-rows: 224px 92px 149px 101px !important;
3126    grid-template-areas: "current" "sun" "temperature" "rain" !important;
3127    gap: 8px !important;
3128    overflow: visible !important;
3129    transform: scale(.88) !important;
3130    transform-origin: top left !important;
3131    pointer-events: none !important;
3132  }
3133  body.home-v2 .mw-card .wx-main > .wx-current-panel,
3134  body.home-v2 .mw-card .wx-main > .sun-card,
3135  body.home-v2 .mw-card .wx-main > .temperature-card,
3136  body.home-v2 .mw-card .wx-main > .rain-card {
3137    position: static !important;
3138    inset: auto !important;
3139    width: 278px !important;
3140    min-width: 278px !important;
3141    margin: 0 !important;
3142    transform: none !important;
3143    pointer-events: auto !important;
3144  }
3145  body.home-v2 .map-wrap > .dashboard-legend,
3146  body.home-v2 .map-wrap > .dashboard-legend.is-open {
3147    position: absolute !important;
3148    inset: 8px auto auto 65% !important;
3149    width: min(220px, calc(100% - 24px)) !important;
3150    transform: translateX(-50%) !important;
3151  }
3152  body.home-v2 .maplibregl-ctrl-bottom-right {
3153    right: 8px !important;
3154    bottom: 8px !important;
3155  }
3156}
3157</style>
3158
3159<style id="narrow-dashboard-repair">
3160@media (max-width: 680px) {
3161  body.home-v2 .home-hero-actions {
3162    width: 100% !important;
3163    max-width: none !important;
3164    display: flex !important;
3165  }
3166  body.home-v2 .home-primary-action,
3167  body.home-v2 .home-secondary-action {
3168    flex: 1 1 0 !important;
3169    width: auto !important;
3170    min-width: 0 !important;
3171  }
3172  body.home-v2 #weather-live.section {
3173    padding: 4px 12px 24px !important;
3174  }
3175  body.home-v2 .mw-card .wx-main {
3176    height: 685px !important;
3177    grid-template-rows: 224px 92px 200px 138px !important;
3178  }
3179  body.home-v2 .map-wrap > .dashboard-legend,
3180  body.home-v2 .map-wrap > .dashboard-legend.is-open {
3181    inset: 10px auto auto 50% !important;
3182    width: min(220px, calc(100% - 24px)) !important;
3183    transform: translateX(-50%) !important;
3184  }
3185  body.home-v2 .mw-card .wx-main > .temperature-card {
3186    height: 200px !important;
3187    padding: 12px 16px 10px !important;
3188  }
3189  body.home-v2 .mw-card .wx-main > .rain-card {
3190    height: 138px !important;
3191    padding: 12px 16px 10px !important;
3192  }
3193  body.home-v2 .mw-card .temperature-card .chart-head,
3194  body.home-v2 .mw-card .rain-card .chart-head,
3195  body.home-v2 .mw-card .temperature-card #tempChart,
3196  body.home-v2 .mw-card .rain-card #rainChart {
3197    width: 100% !important;
3198    max-width: none !important;
3199    margin-right: 0 !important;
3200    margin-left: 0 !important;
3201  }
3202  body.home-v2 .mw-card .temperature-card #tempChart {
3203    top: 0 !important;
3204    height: 154px !important;
3205    margin-bottom: 0 !important;
3206  }
3207  body.home-v2 .mw-card .rain-card #rainChart {
3208    height: 96px !important;
3209  }
3210  body.home-v2 .mw-card .temp-chart-readings {
3211    display: none !important;
3212  }
3213}
3214</style>
3215
3216<script src="/js/home-header-morph.js?v=11" defer></script>
vendor: 1 bytes, line 3216
3216
3217<script src="/js/home-anchor-navigation.js?v=7" defer></script>
vendor: 1 bytes, line 3217
3217
3218<script type="module" src="/js/home-earth-v2.js?v=240&amp;stars=3&amp;haze=2"></script>
3218
3219
3220<script type="module" src="https://static.cloudflareinsights.com/beacon.min.js/v31edd6df95cf4e85bb4c19e7a9bdbcba1788362987495" integrity="sha512-iIg7k2xntmwu6/uSb5tpc/hySgZc4eoL31yB29W6tJFo2akwjPWcEqnCEdJvGexCL0KEQwVYv5BlowfhVz26hg==" data-cf-beacon='{"version":"2024.11.0","token":"1ff7f7f9aff34e318df6054150200e7d","r":1,"spa":2}' crossorigin="anonymous"></script>
3220
3221</body>
3222</html>

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.