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 & 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 & 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 & 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 & 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 & 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&stars=3&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.