diff --git a/website/weather/app.js b/website/weather/app.js
new file mode 100644
index 0000000..45ea01b
--- /dev/null
+++ b/website/weather/app.js
@@ -0,0 +1,304 @@
+(function () {
+ function $(id) { return document.getElementById(id); }
+
+ function showResults(show) {
+ var r = $("results");
+ if (r) r.style.display = show ? "" : "none";
+ }
+
+ function escapeHtml(str) {
+ return String(str)
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+ }
+
+ function getParam(name) {
+ var qs = window.location.search || "";
+ qs = qs.replace(/^\?/, "");
+ if (!qs) return "";
+ var parts = qs.split("&");
+ for (var i = 0; i < parts.length; i++) {
+ var kv = parts[i].split("=");
+ var k = decodeURIComponent(kv[0] || "");
+ if (k === name) return decodeURIComponent((kv[1] || "").replace(/\+/g, " "));
+ }
+ return "";
+ }
+
+ function xhrJson(url, accept, cb) {
+ try {
+ var x = new XMLHttpRequest();
+ x.open("GET", url, true);
+ if (accept) x.setRequestHeader("Accept", accept);
+ x.onreadystatechange = function () {
+ if (x.readyState !== 4) return;
+ if (x.status >= 200 && x.status < 300) {
+ var data;
+ try { data = JSON.parse(x.responseText); }
+ catch (e) { return cb("Bad JSON from: " + url); }
+ return cb(null, data);
+ }
+ cb("HTTP " + x.status + " from: " + url);
+ };
+ x.send(null);
+ } catch (e2) {
+ cb("Request failed: " + e2.message);
+ }
+ }
+
+ function isLatLon(s) {
+ return /^\s*-?\d+(\.\d+)?\s*,\s*-?\d+(\.\d+)?\s*$/.test(s);
+ }
+
+ function parseLatLon(s) {
+ var parts = s.split(",");
+ return { lat: parseFloat(parts[0]), lon: parseFloat(parts[1]) };
+ }
+
+ function isZip(s) {
+ return /^\s*\d{5}\s*$/.test(s);
+ }
+
+ // Resolve user input to lat/lon
+ // - lat,lon => direct
+ // - ZIP => zippopotam.us (for coordinates)
+ // - City, ST => OpenStreetMap Nominatim (for coordinates)
+ function resolveLocation(q, cb) {
+ q = (q || "").replace(/^\s+|\s+$/g, "");
+ if (!q) return cb("Empty query");
+
+ if (isLatLon(q)) {
+ var ll = parseLatLon(q);
+ return cb(null, { input: q, display: q, lat: ll.lat, lon: ll.lon });
+ }
+
+ if (isZip(q)) {
+ var zip = q.replace(/\s+/g, "");
+ var url = "https://api.zippopotam.us/us/" + encodeURIComponent(zip);
+ return xhrJson(url, "application/json", function (err, data) {
+ if (err) return cb("Could not resolve ZIP to coordinates. " + err);
+ try {
+ var place = data.places && data.places[0];
+ var lat = parseFloat(place.latitude);
+ var lon = parseFloat(place.longitude);
+ var city = place["place name"];
+ var st = place["state abbreviation"] || place.state;
+ return cb(null, {
+ input: q,
+ display: (city && st) ? (city + ", " + st + " " + zip) : zip,
+ lat: lat,
+ lon: lon
+ });
+ } catch (e) {
+ cb("ZIP lookup returned unexpected data.");
+ }
+ });
+ }
+
+ var nom = "https://nominatim.openstreetmap.org/search?format=json&limit=1&countrycodes=us&q=" + encodeURIComponent(q);
+ xhrJson(nom, "application/json", function (err, arr) {
+ if (err) return cb("Could not resolve city/state to coordinates. " + err);
+ if (!arr || !arr.length) return cb("No results for: " + q);
+
+ var r = arr[0];
+ return cb(null, {
+ input: q,
+ display: r.display_name || q,
+ lat: parseFloat(r.lat),
+ lon: parseFloat(r.lon)
+ });
+ });
+ }
+
+ function cToF(c) {
+ if (c === null || c === undefined) return null;
+ return (c * 9 / 5) + 32;
+ }
+
+ function fmtNum(n) {
+ if (n === null || n === undefined || isNaN(n)) return "—";
+ return String(Math.round(n));
+ }
+
+ function fmtLatLon(lat, lon) {
+ function f(x) { return (Math.round(x * 10000) / 10000).toFixed(4); }
+ return f(lat) + ", " + f(lon);
+ }
+
+ function renderForecast(periods) {
+ var wrap = $("forecast");
+ if (!wrap) return;
+
+ if (!periods || !periods.length) {
+ wrap.innerHTML = "
No forecast available.
";
+ return;
+ }
+
+ var max = Math.min(periods.length, 8);
+ var html = "";
+
+ for (var i = 0; i < max; i++) {
+ var p = periods[i];
+
+ var name = escapeHtml(p.name || "");
+ var icon = escapeHtml(p.icon || "");
+ var temp = (p.temperature !== null && p.temperature !== undefined)
+ ? (escapeHtml(String(p.temperature)) + "°" + escapeHtml(p.temperatureUnit || ""))
+ : "—";
+ var shortF = escapeHtml(p.shortForecast || "");
+ var detail = escapeHtml(p.detailedForecast || "");
+
+ html += "";
+ html += "
" + name + "
";
+ if (icon) html += "

";
+ html += "
" + temp + "
";
+ html += "
" + shortF + "
";
+ html += "
" + detail + "
";
+ html += "
";
+ }
+
+ wrap.innerHTML = html;
+ }
+
+ function loadWeather(lat, lon, resolvedDisplay) {
+ showResults(false);
+
+ var pointsUrl = "https://api.weather.gov/points/" + lat + "," + lon;
+
+ xhrJson(pointsUrl, "application/geo+json", function (err, points) {
+ if (err) {
+ // No status box; just hide results
+ showResults(false);
+ return;
+ }
+
+ var props = (points && points.properties) ? points.properties : {};
+ var rel = props.relativeLocation && props.relativeLocation.properties ? props.relativeLocation.properties : null;
+
+ var city = rel && rel.city ? rel.city : "";
+ var state = rel && rel.state ? rel.state : "";
+
+ var placeText = (city && state) ? (city + ", " + state) : (resolvedDisplay || (lat + "," + lon));
+ $("placeTitle").innerHTML = escapeHtml(placeText);
+ $("latlon").innerHTML = escapeHtml(fmtLatLon(lat, lon));
+
+ var forecastUrl = props.forecast;
+ var stationsUrl = props.observationStations || null;
+
+ if (!forecastUrl) {
+ showResults(false);
+ return;
+ }
+
+ // 1) Forecast
+ xhrJson(forecastUrl, "application/geo+json", function (errF, forecast) {
+ if (errF) {
+ showResults(false);
+ return;
+ }
+
+ var periods = forecast && forecast.properties && forecast.properties.periods ? forecast.properties.periods : [];
+ renderForecast(periods);
+
+ // 2) Current conditions (optional)
+ if (!stationsUrl) {
+ $("tempNow").innerHTML = "—";
+ $("humidity").innerHTML = "—";
+ $("wind").innerHTML = "—";
+ $("observedAt").innerHTML = "—";
+ showResults(true);
+ return;
+ }
+
+ xhrJson(stationsUrl, "application/geo+json", function (errS, stations) {
+ if (errS) {
+ $("tempNow").innerHTML = "—";
+ $("humidity").innerHTML = "—";
+ $("wind").innerHTML = "—";
+ $("observedAt").innerHTML = "—";
+ showResults(true);
+ return;
+ }
+
+ var feat = stations && stations.features && stations.features.length ? stations.features[0] : null;
+ var stationId = feat && feat.id ? feat.id : null;
+
+ if (!stationId) {
+ $("tempNow").innerHTML = "—";
+ $("humidity").innerHTML = "—";
+ $("wind").innerHTML = "—";
+ $("observedAt").innerHTML = "—";
+ showResults(true);
+ return;
+ }
+
+ var obsUrl = stationId.replace(/\/$/, "") + "/observations/latest";
+ xhrJson(obsUrl, "application/geo+json", function (errO, obs) {
+ if (errO) {
+ $("tempNow").innerHTML = "—";
+ $("humidity").innerHTML = "—";
+ $("wind").innerHTML = "—";
+ $("observedAt").innerHTML = "—";
+ showResults(true);
+ return;
+ }
+
+ var op = obs && obs.properties ? obs.properties : {};
+
+ // temperature is Celsius
+ var c = op.temperature && op.temperature.value !== null ? op.temperature.value : null;
+ var f = c !== null ? cToF(c) : null;
+
+ var humidity = op.relativeHumidity && op.relativeHumidity.value !== null ? op.relativeHumidity.value : null;
+
+ // windSpeed meters/sec => mph
+ var windMs = op.windSpeed && op.windSpeed.value !== null ? op.windSpeed.value : null;
+ var windMph = (windMs !== null) ? (windMs * 2.236936) : null;
+
+ var windDir = op.windDirection && op.windDirection.value !== null ? op.windDirection.value : null;
+ var ts = op.timestamp || "—";
+
+ $("tempNow").innerHTML =
+ (f !== null && c !== null)
+ ? (fmtNum(f) + "°F (" + fmtNum(c) + "°C)")
+ : "—";
+
+ $("humidity").innerHTML = (humidity !== null) ? (fmtNum(humidity) + "%") : "—";
+ $("wind").innerHTML =
+ (windMph !== null && windDir !== null)
+ ? (fmtNum(windMph) + " mph @ " + fmtNum(windDir) + "°")
+ : "—";
+
+ $("observedAt").innerHTML = escapeHtml(ts);
+
+ showResults(true);
+ });
+ });
+ });
+ });
+ }
+
+ window.addEventListener("DOMContentLoaded", function () {
+ var q = getParam("q");
+
+ if (!q) {
+ $("q").value = "";
+ showResults(false);
+ return;
+ }
+
+ $("q").value = q;
+
+ resolveLocation(q, function (err, loc) {
+ if (err) {
+ showResults(false);
+ return;
+ }
+ loadWeather(loc.lat, loc.lon, loc.display);
+ });
+ });
+ })();
+
\ No newline at end of file
diff --git a/website/weather/index.html b/website/weather/index.html
new file mode 100644
index 0000000..5b15e50
--- /dev/null
+++ b/website/weather/index.html
@@ -0,0 +1,55 @@
+
+
+
+
+
+ SumiWeather
+
+
+
+
+
+
+
+
+
+
+
+
+
—
+
Lat/Lon: —
+
+
+
Current conditions
+
—
+
+
Humidity: —
+
Wind: —
+
Observed: —
+
+
+
+
Forecast
+
+
+
+ Data from api.weather.gov.
+
+
+
+
+
+
+
+
+
diff --git a/website/weather/style.css b/website/weather/style.css
new file mode 100644
index 0000000..1280ece
--- /dev/null
+++ b/website/weather/style.css
@@ -0,0 +1,42 @@
+/*
+ Lunar-style simple portal CSS
+*/
+
+html, body {
+ font-family: 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
+ color: #222;
+ background-color: #ddd;
+ }
+
+ a, a:visited {
+ text-decoration: none;
+ color: #a22121;
+ }
+
+ .center {
+ margin-left: auto;
+ margin-right: auto;
+ width: 100%;
+ max-width: 700px;
+ text-align: center;
+ }
+
+ .forecastItem {
+ margin: 14px 0;
+ }
+
+ .forecastItem img {
+ width: 64px;
+ height: 64px;
+ }
+
+ hr {
+ border: none;
+ border-top: 1px solid #000;
+ }
+
+ .footer {
+ font-size: 12px;
+ margin-top: 14px;
+ }
+
\ No newline at end of file