1// Live First Coast weather widgets (navigation + footer). 2// 3// Data source: US National Weather Service API (api.weather.gov). 4// Chosen deliberately over Open-Meteo/OpenWeatherMap because it needs 5// NO API KEY (nothing to leak on a static site), sends 6// Access-Control-Allow-Origin: * so the browser can call it directly, 7// and is US Government public-domain data that is free for commercial 8// use â unlike Open-Meteo, whose free tier is non-commercial only. 9// 10// Design rule: never show broken UI. The widgets are injected by JS and 11// only appear once real data arrives. Any failure (offline, rate limit, 12// API change) leaves the page exactly as it was. 13 14(function () { 15 "use strict"; 16 17 var CACHE_KEY = "fcl_weather_v1"; 18 var CACHE_MS = 15 * 60 * 1000; // NWS observations update ~hourly; 15 min is plenty fresh 19 20 // Airport observation stations spanning the service area, 21 // north to south (verified live against api.weather.gov). 22 var NAV_STATION = { id: "KSGJ", label: "St Augustine" }; 23 var FOOTER_STATIONS = [ 24 { id: "KFHB", label: "Amelia Island" }, 25 { id: "KCRG", label: "Jacksonville" }, 26 { id: "KSGJ", label: "St Augustine" }, 27 { id: "KFIN", label: "Palm Coast" }, 28 { id: "KDAB", label: "Daytona Beach" } 29 ]; 30 31 function icon(desc, isNight) { 32 var d = (desc || "").toLowerCase(); 33 if (!d) return isNight ? "ð" : "âï¸"; // moon / sun 34 if (d.indexOf("thunder") > -1) return "âï¸"; 35 if (d.indexOf("rain") > -1 || d.indexOf("shower") > -1 || d.indexOf("drizzle") > -1) return "ð§ï¸"; 36 if (d.indexOf("fog") > -1 || d.indexOf("mist") > -1 || d.indexOf("haze") > -1) return "ð«ï¸"; 37 if (d.indexOf("overcast") > -1 || d.indexOf("cloudy") > -1) { 38 return d.indexOf("mostly clear") > -1 || d.indexOf("partly") > -1 39 ? (isNight ? "ð" : "â ") : "âï¸"; 40 } 41 if (d.indexOf("clear") > -1 || d.indexOf("fair") > -1 || d.indexOf("sunny") > -1) { 42 return isNight ? "ð" : "âï¸"; 43 } 44 if (d.indexOf("wind") > -1) return "ð¨"; 45 return isNight ? "ð" : "âï¸"; 46 } 47 48 function cToF(c) { 49 return (typeof c === "number") ? Math.round(c * 9 / 5 + 32) : null; 50 } 51 52 function readCache() { 53 try { 54 var raw = sessionStorage.getItem(CACHE_KEY); 55 if (!raw) return null; 56 var obj = JSON.parse(raw); 57 if (!obj || (Date.now() - obj.t) > CACHE_MS) return null; 58 return obj.d; 59 } catch (e) { return null; } 60 } 61 62 function writeCache(d) { 63 try { 64 sessionStorage.setItem(CACHE_KEY, JSON.stringify({ t: Date.now(), d: d })); 65 } catch (e) { /* private mode / quota â cache is optional */ } 66 } 67 68 function fetchStation(station) { 69 return fetch("https://api.weather.gov/stations/" + station.id + "/observations/latest", { 70 headers: { Accept: "application/geo+json" } 71 }) 72 .then(function (r) { 73 if (!r.ok) throw new Error("status " + r.status); 74 return r.json(); 75 }) 76 .then(function (j) { 77 var p = (j && j.properties) || {}; 78 var f = cToF(p.temperature && p.temperature.value); 79 if (f === null) throw new Error("no temperature"); 80 // Some stations (e.g. KFHB) return an empty textDescription â 81 // fall back to the icon URL slug so the widget still reads sensibly. 82 var desc = (p.textDescription || "").trim(); 83 var isNight = typeof p.icon === "string" && p.icon.indexOf("/night/") > -1; 84 return { id: station.id, label: station.label, f: f, desc: desc, night: isNight }; 85 }); 86 } 87 88 function fetchAll() { 89 var cached = readCache(); 90 if (cached) return Promise.resolve(cached); 91 92 var ids = {}; 93 FOOTER_STATIONS.concat([NAV_STATION]).forEach(function (s) { ids[s.id] = s; }); 94 var unique = Object.keys(ids).map(function (k) { return ids[k]; }); 95 96 return Promise.all(unique.map(function (s) { 97 return fetchStation(s).catch(function () { return null; }); // one station failing must not sink the rest 98 })).then(function (results) { 99 var byId = {}; 100 results.forEach(function (r) { if (r) byId[r.id] = r; }); 101 if (!Object.keys(byId).length) throw new Error("all stations failed"); 102 writeCache(byId); 103 return byId; 104 }); 105 } 106 107 function buildNav(data) { 108 var d = data[NAV_STATION.id]; 109 if (!d) return; 110 var host = document.querySelector(".site-header .header-inner"); 111 if (!host || host.querySelector(".wx-nav")) return; 112 113 var el = document.createElement("a"); 114 el.className = "wx-nav"; 115 el.href = navHref(); 116 el.setAttribute("aria-label", 117 "Current weather in " + d.label + ": " + d.f + " degrees Fahrenheit" + (d.desc ? ", " + d.desc : "")); 118 // The city label is hidden by CSS so the header can't wrap â surface the 119 // full reading here so mouse users still get the location and conditions. 120 el.title = d.label + " â " + d.f + "°F" + (d.desc ? ", " + d.desc : "") + 121 " (National Weather Service)"; 122 el.innerHTML = 123 '<span class="wx-ico" aria-hidden="true">' + icon(d.desc, d.night) + "</span>" + 124 '<span class="wx-t">' + d.f + "°</span>" + 125 '<span class="wx-c">' + escapeHtml(d.label) + "</span>"; 126 127 var ctas = host.querySelector(".header-ctas"); 128 if (ctas) host.insertBefore(el, ctas); else host.appendChild(el); 129 } 130 131 // Weather links to the local lifestyle/things-to-do page, matching this 132 // page's directory depth (site has pages at 3 different depths). 133 function navHref() { 134 var main = document.querySelector('script[src$="js/main.js"]'); 135 var prefix = ""; 136 if (main) { 137 var src = main.getAttribute("src") || ""; 138 prefix = src.replace(/js\/main\.js$/, ""); 139 } 140 return prefix + "things-to-do.html"; 141 } 142 143 function buildFooter(data) { 144 var rows = FOOTER_STATIONS.map(function (s) { return data[s.id]; }
144).filter(Boolean); 145 if (!rows.length) return; 146 var footer = document.querySelector(".site-footer .container"); 147 if (!footer || footer.querySelector(".wx-strip")) return; 148 149 var wrap = document.createElement("div"); 150 wrap.className = "wx-strip"; 151 wrap.innerHTML = 152 '<h4 class="wx-strip-title">First Coast Conditions Right Now</h4>' + 153 '<div class="wx-strip-row">' + 154 rows.map(function (d) { 155 return '<div class="wx-city">' + 156 '<span class="wx-ico" aria-hidden="true">' + icon(d.desc, d.night) + "</span>" + 157 '<span class="wx-city-name">' + escapeHtml(d.label) + "</span>" + 158 '<span class="wx-city-temp">' + d.f + "°F</span>" + 159 (d.desc ? '<span class="wx-city-desc">' + escapeHtml(d.desc) + "</span>" : "") + 160 "</div>"; 161 }).join("") + 162 "</div>" + 163 '<p class="wx-credit">Live conditions via the ' + 164 '<a href="https://www.weather.gov/documentation/services-web-api" target="_blank" rel="noopener">' + 165 "National Weather Service</a>. Updated hourly.</p>"; 166 167 var bottom = footer.querySelector(".footer-bottom"); 168 if (bottom) footer.insertBefore(wrap, bottom); else footer.appendChild(wrap); 169 } 170 171 function escapeHtml(s) { 172 return String(s).replace(/[&<>"']/g, function (c) { 173 return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]; 174 }); 175 } 176 177 function init() { 178 if (typeof fetch !== "function" || typeof Promise !== "function") return; 179 fetchAll() 180 .then(function (data) { 181 buildNav(data); 182 buildFooter(data); 183 }) 184 .catch(function () { /* leave the page untouched */ }); 185 } 186 187 if (document.readyState === "loading") { 188 document.addEventListener("DOMContentLoaded", init); 189 } else { 190 init(); 191 } 192})();
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.