Upload files to "website/weather"
This commit is contained in:
304
website/weather/app.js
Normal file
304
website/weather/app.js
Normal file
@@ -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, """)
|
||||
.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 = "<p>No forecast available.</p>";
|
||||
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 += "<div class='forecastItem'>";
|
||||
html += "<h3>" + name + "</h3>";
|
||||
if (icon) html += "<img src='" + icon + "' alt='Forecast Icon'/>";
|
||||
html += "<p><b>" + temp + "</b></p>";
|
||||
html += "<p>" + shortF + "</p>";
|
||||
html += "<p style='max-width: 700px; margin-left:auto; margin-right:auto;'>" + detail + "</p>";
|
||||
html += "</div><hr>";
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
55
website/weather/index.html
Normal file
55
website/weather/index.html
Normal file
@@ -0,0 +1,55 @@
|
||||
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 2.0//EN">
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
|
||||
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>SumiWeather</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta charset="UTF-8" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<form action="./" method="get">
|
||||
<a href="./"><b>SumiWeather</b></a> | Enter ZIP or City:
|
||||
<input id="q" type="text" size="30" name="q" value="">
|
||||
<input type="submit" value="Search">
|
||||
<div style="font-size: 12px; margin-top: 6px;">
|
||||
Tips: ZIP (98112) or City, ST (Seattle, WA) works best.
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<hr>
|
||||
|
||||
<div class="center">
|
||||
<div id="results" style="display:none;">
|
||||
<p><b id="placeTitle">—</b></p>
|
||||
<p>Lat/Lon: <span id="latlon">—</span></p>
|
||||
<hr>
|
||||
|
||||
<h2>Current conditions</h2>
|
||||
<h1 id="tempNow">—</h1>
|
||||
|
||||
<p><b>Humidity:</b> <span id="humidity">—</span></p>
|
||||
<p><b>Wind:</b> <span id="wind">—</span></p>
|
||||
<p style="font-size: 12px;"><b>Observed:</b> <span id="observedAt">—</span></p>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2>Forecast</h2>
|
||||
<div id="forecast"></div>
|
||||
|
||||
<p style="font-size: 12px;">
|
||||
Data from <b>api.weather.gov</b>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p class="footer">
|
||||
© 2026 <a href="http://forum.sumisu.xyz"><b>SumiWeather</b></a></br>
|
||||
Based on <a href="http://lunarproject.org" target="_blank">LunarProject.org</a>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
42
website/weather/style.css
Normal file
42
website/weather/style.css
Normal file
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user