PageSourceSearch

https://firstcoastliving.netlify.app/js/weather.js

js firstcoastliving.netlify.app collected 2026-10-03 10:44:10 UTC 7,612 bytes, 192 lines download raw bytes

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 + "&deg;</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 + "&deg;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 { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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.