Weather page

This commit is contained in:
2026-06-21 04:56:58 -04:00
parent 569c8cf80d
commit 4498fa2611
3 changed files with 664 additions and 0 deletions
+653
View File
@@ -0,0 +1,653 @@
---
const weatherBase = (import.meta.env.PUBLIC_WEATHER_BASE ?? 'https://wx.swansea-airport.wales').replace(/\/$/, '');
const mqttHost = import.meta.env.PUBLIC_WEATHER_MQTT_HOST ?? `${weatherBase}/mqtt`;
const archiveEndpoint = `${weatherBase}/agcs/archive.php`;
const pressureEndpoint = `${weatherBase}/wlproxy.php?api=current/195562`;
---
<section class="weather-shell" aria-labelledby="weather-heading">
<div class="weather-head">
<div>
<p class="eyebrow">Live airfield weather</p>
<h1 id="weather-heading" class="section-title">Swansea EGFH conditions</h1>
<p class="section-copy">
Live readings from the airport weather station. Values update automatically where the
upstream feed is available. This page is for situational awareness only; always use
official pre-flight sources, NOTAMs, and radio updates for flight safety decisions.
</p>
</div>
<div class="weather-status" aria-live="polite">
<span class="status-dot waiting" id="connection-dot"></span>
<span id="connection-status">Connecting</span>
</div>
</div>
<div class="weather-dashboard">
<div class="weather-main-column">
<article class="weather-card weather-wind-card">
<div class="weather-wind-primary">
<div class="weather-metric-block">
<p class="weather-label">Surface wind</p>
<p class="weather-value" id="avg-wind">XXX/XX</p>
<p class="weather-meta" id="avg-wind-meta">Waiting for 2 minute average</p>
</div>
<div class="weather-metric-block">
<p class="weather-label">Instant wind</p>
<p class="weather-value" id="instant-wind">XXX/XX</p>
<p class="weather-meta" id="instant-wind-meta">Waiting for live wind</p>
</div>
</div>
</article>
<div class="weather-grid">
<article class="weather-card weather-pressure-card">
<div class="weather-pressure-grid">
<div class="weather-metric-block">
<p class="weather-pressure-label">QNH</p>
<p class="weather-pressure-value" id="qnh">XXX</p>
</div>
<div class="weather-metric-block">
<p class="weather-pressure-label">QFE</p>
<p class="weather-pressure-value" id="qfe">XXX</p>
</div>
</div>
<p class="weather-meta" id="pressure-meta">Waiting for pressure</p>
</article>
<article class="weather-card">
<p class="weather-label">2 minute gust</p>
<p class="weather-value" id="gust">XXX/XX</p>
</article>
<article class="weather-card">
<p class="weather-label">OAT</p>
<p class="weather-value" id="oat">XXX</p>
</article>
</div>
<p class="weather-feed-note" id="feed-note">
Live weather is initialising.
</p>
</div>
<div class="weather-side-column">
<article class="weather-card weather-compass-card">
<div class="weather-card-heading">
<p class="weather-label">Wind over runway layout</p>
<span class="pill" id="wind-source">Awaiting feed</span>
</div>
<canvas id="weather-compass" width="720" height="450" aria-label="Runway layout with live wind direction"></canvas>
</article>
</div>
</div>
</section>
<script define:vars={{ mqttHost, archiveEndpoint, pressureEndpoint }}>
(() => {
const MQTT_SCRIPT = 'https://cdnjs.cloudflare.com/ajax/libs/paho-mqtt/1.0.1/mqttws31.js';
const MPS_TO_KT = 0.868976;
const F_TO_C = (fahrenheit) => (fahrenheit - 32) * 5 / 9;
const STALE = {
instantWind: 10000,
avgWind: 150000,
pressure: 1800000,
oat: 300000,
};
const state = {
connected: false,
avgWind: null,
instantWind: null,
gust: null,
oat: null,
qfe: null,
qnh: null,
lastAvgWind: 0,
lastInstantWind: 0,
lastOat: 0,
lastPressure: 0,
windDirection: null,
};
let mqttClient;
let ctx;
const get = (id) => document.getElementById(id);
function loadScript(src) {
return new Promise((resolve, reject) => {
if (window.Paho?.MQTT) {
resolve();
return;
}
const existing = document.querySelector(`script[src="${src}"]`);
if (existing) {
existing.addEventListener('load', resolve, { once: true });
existing.addEventListener('error', reject, { once: true });
return;
}
const script = document.createElement('script');
script.src = src;
script.async = true;
script.onload = resolve;
script.onerror = reject;
document.head.append(script);
});
}
function isValidWind(speed, direction, gustSpeed, gustDirection) {
const validSpeed = Number.isFinite(speed) && speed >= 0 && speed <= 200;
const validDirection = Number.isFinite(direction) && direction >= 0 && direction <= 360;
const validGustSpeed = gustSpeed === undefined || (Number.isFinite(gustSpeed) && gustSpeed >= 0 && gustSpeed <= 200);
const validGustDirection = gustDirection === undefined || (Number.isFinite(gustDirection) && gustDirection >= 0 && gustDirection <= 360);
return validSpeed && validDirection && validGustSpeed && validGustDirection;
}
function roundDirection(direction) {
let rounded = Math.round(direction / 10) * 10;
if (rounded === 0) rounded = 360;
return String(rounded).padStart(3, '0');
}
function formatWind(reading) {
if (!reading) return 'XXX/XX';
if (reading.speed < 2) return 'CALM';
return `${roundDirection(reading.direction)}/${Math.round(reading.speed)}`;
}
function setConnection(status, className) {
get('connection-status').textContent = status;
get('connection-dot').className = `status-dot ${className}`;
}
function setText(id, value) {
const element = get(id);
if (element.textContent === value) return;
element.textContent = value;
element.classList.remove('weather-flash');
window.requestAnimationFrame(() => element.classList.add('weather-flash'));
}
function setFeedNote(message) {
get('feed-note').textContent = message;
}
function saveSnapshot() {
localStorage.setItem('swansea-weather', JSON.stringify({ ...state, savedAt: Date.now() }));
}
function loadSnapshot() {
try {
const raw = localStorage.getItem('swansea-weather');
if (!raw) return;
const cached = JSON.parse(raw);
if (Date.now() - cached.savedAt > 3600000) return;
Object.assign(state, cached);
} catch (error) {
console.warn('Could not load cached weather', error);
}
}
function render() {
const now = Date.now();
const avgFresh = state.lastAvgWind && now - state.lastAvgWind <= STALE.avgWind;
const instantFresh = state.lastInstantWind && now - state.lastInstantWind <= STALE.instantWind;
const oatFresh = state.lastOat && now - state.lastOat <= STALE.oat;
const pressureFresh = state.lastPressure && now - state.lastPressure <= STALE.pressure;
setText('avg-wind', avgFresh ? formatWind(state.avgWind) : 'XXX/XX');
setText('instant-wind', instantFresh ? formatWind(state.instantWind) : 'XXX/XX');
setText('gust', avgFresh ? formatWind(state.gust) : 'XXX/XX');
setText('oat', oatFresh && state.oat !== null ? `${Math.round(state.oat)} \u00b0C` : 'XXX');
setText('qfe', pressureFresh && state.qfe !== null ? String(state.qfe) : 'XXX');
setText('qnh', pressureFresh && state.qnh !== null ? String(state.qnh) : 'XXX');
get('avg-wind-meta').textContent = avgFresh ? '2 minute average' : 'Average wind stale';
get('instant-wind-meta').textContent = instantFresh ? 'Latest live sample' : 'Instant wind stale';
get('pressure-meta').textContent = pressureFresh ? 'Pressure current' : 'Pressure unavailable';
get('wind-source').textContent = avgFresh ? 'Live' : 'No current wind';
if (state.connected && avgFresh && instantFresh) {
setConnection('Live', 'ok');
} else if (state.connected) {
setConnection('Partial feed', 'waiting');
}
drawCompass(avgFresh ? state.windDirection : null);
}
function applyLoop(loop) {
const speed = Number(loop.windSpeed_knot);
const direction = Number(loop.windDir);
const gustSpeed = Number(loop.windGust_knot);
const gustDirection = Number(loop.windGustDir);
if (loop.interval_minute) {
if (!isValidWind(speed, direction, gustSpeed, gustDirection)) return;
state.avgWind = { speed, direction };
state.gust = { speed: gustSpeed, direction: gustDirection };
state.windDirection = direction;
state.lastAvgWind = Date.now();
} else {
if (isValidWind(speed, direction)) {
state.instantWind = { speed, direction };
state.lastInstantWind = Date.now();
}
const oat = Number(loop.outTemp_C);
if (Number.isFinite(oat)) {
state.oat = oat;
state.lastOat = Date.now();
}
}
saveSnapshot();
render();
}
async function fetchArchive() {
try {
const response = await fetch(archiveEndpoint, { cache: 'no-store' });
if (!response.ok) throw new Error(`Archive responded ${response.status}`);
const archive = await response.json();
const timestamp = Number(archive.dateTime) * 1000;
if (!timestamp || Date.now() - timestamp > STALE.oat) return;
const avgSpeed = Number(archive.windSpeed) * MPS_TO_KT;
const avgDirection = Number(archive.windDir);
const gustSpeed = Number(archive.windGust) * MPS_TO_KT;
const gustDirection = Number(archive.windGustDir);
const oat = F_TO_C(Number(archive.outTemp));
if (!isValidWind(avgSpeed, avgDirection, gustSpeed, gustDirection) || !Number.isFinite(oat)) return;
state.avgWind = { speed: avgSpeed, direction: avgDirection };
state.gust = { speed: gustSpeed, direction: gustDirection };
state.oat = oat;
state.windDirection = avgDirection;
state.lastAvgWind = timestamp;
state.lastOat = timestamp;
render();
saveSnapshot();
} catch (error) {
console.warn('Archive weather unavailable', error);
setFeedNote('Live MQTT can still update wind and temperature. Archive JSON needs CORS enabled on the weather host for browser access.');
}
}
async function fetchPressure() {
try {
const response = await fetch(pressureEndpoint, { cache: 'no-store' });
if (!response.ok) throw new Error(`Pressure responded ${response.status}`);
const data = await response.json();
const pressure = data?.contents?.sensors?.[2]?.data?.[0];
const timestamp = Number(pressure?.ts) * 1000;
const barAbsolute = Number(pressure?.bar_absolute);
if (!timestamp || Date.now() - timestamp > STALE.pressure || !Number.isFinite(barAbsolute)) {
throw new Error('Pressure data is stale or invalid');
}
const qfe = Math.round(barAbsolute / 0.029529983071445);
state.qfe = qfe;
state.qnh = qfe + 11;
state.lastPressure = timestamp;
render();
saveSnapshot();
} catch (error) {
console.warn('Pressure unavailable', error);
setFeedNote('Pressure JSON needs CORS enabled on the weather host, or a same-origin proxy if this site stops being static.');
}
}
function getMqttConfig() {
const url = new URL(mqttHost);
const useSSL = url.protocol === 'wss:' || url.protocol === 'https:';
return {
host: url.hostname,
port: Number(url.port || (useSSL ? 443 : 80)),
path: url.pathname || '/mqtt',
useSSL,
};
}
function connectMqtt() {
const clientId = `swansea-wx-${Math.floor(Math.random() * 100000)}`;
const mqttConfig = getMqttConfig();
mqttClient = new window.Paho.MQTT.Client(mqttConfig.host, mqttConfig.port, mqttConfig.path, clientId);
mqttClient.onConnectionLost = () => {
state.connected = false;
setConnection('Reconnecting', 'error');
window.setTimeout(connectMqtt, 2500);
};
mqttClient.onMessageArrived = (message) => {
if (message.destinationName !== 'weather/loop') return;
try {
applyLoop(JSON.parse(message.payloadString));
setFeedNote('Live data received from the airport weather feed.');
} catch (error) {
console.warn('Could not process weather message', error);
}
};
mqttClient.connect({
timeout: 4,
cleanSession: true,
useSSL: mqttConfig.useSSL,
onSuccess: () => {
state.connected = true;
setConnection('Connected', 'ok');
mqttClient.subscribe('weather/#', { qos: 0 });
},
onFailure: () => {
state.connected = false;
setConnection('Retrying', 'error');
window.setTimeout(connectMqtt, 2500);
},
});
}
function drawRunway(centerX, centerY, angle, length, width, xShift, yShift = 0) {
const scale = Math.min(ctx.canvas.width / 360, ctx.canvas.height / 260);
const radians = (angle - 90) * (Math.PI / 180);
const xStart = centerX - length * Math.cos(radians) + xShift;
const yStart = centerY - length * Math.sin(radians) + yShift;
const xEnd = centerX + length * Math.cos(radians) + xShift;
const yEnd = centerY + length * Math.sin(radians) + yShift;
ctx.strokeStyle = '#263f55';
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(xStart, yStart);
ctx.lineTo(xEnd, yEnd);
ctx.stroke();
drawRunwayNumber(angle, xStart, yStart, radians, -22 * scale);
drawRunwayNumber(angle + 180, xEnd, yEnd, radians, 22 * scale);
}
function drawRunwayNumber(angle, x, y, radians, extension) {
const number = Math.round(angle / 10) % 36;
const scale = Math.min(ctx.canvas.width / 360, ctx.canvas.height / 260);
ctx.font = `700 ${17 * scale}px Manrope, Arial, sans-serif`;
ctx.fillStyle = '#102233';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(String(number).padStart(2, '0'), x + extension * Math.cos(radians), y + extension * Math.sin(radians));
}
function drawArrow(centerX, centerY, angle) {
const scale = Math.min(ctx.canvas.width / 360, ctx.canvas.height / 260);
const length = 86 * scale;
const radians = (angle + 90) * (Math.PI / 180);
const xStart = centerX - length * Math.cos(radians);
const yStart = centerY - length * Math.sin(radians);
const xEnd = centerX + length * Math.cos(radians);
const yEnd = centerY + length * Math.sin(radians);
ctx.strokeStyle = '#a11f3a';
ctx.fillStyle = '#a11f3a';
ctx.lineWidth = 4 * scale;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(xStart, yStart);
ctx.lineTo(xEnd, yEnd);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(xEnd, yEnd);
ctx.lineTo(xEnd - 14 * scale * Math.cos(radians - Math.PI / 6), yEnd - 14 * scale * Math.sin(radians - Math.PI / 6));
ctx.lineTo(xEnd - 14 * scale * Math.cos(radians + Math.PI / 6), yEnd - 14 * scale * Math.sin(radians + Math.PI / 6));
ctx.closePath();
ctx.fill();
}
function drawCompass(windDirection) {
const canvas = get('weather-compass');
ctx = ctx || canvas.getContext('2d');
const centerX = canvas.width / 2;
const centerY = canvas.height / 2 + 10;
const scale = Math.min(canvas.width / 360, canvas.height / 260);
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#f8fbff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
drawRunway(centerX, centerY, 220, 120 * scale, 13 * scale, -22 * scale);
drawRunway(centerX, centerY, 280, 92 * scale, 9 * scale, 0, 42 * scale);
if (Number.isFinite(windDirection)) drawArrow(centerX, centerY, windDirection);
}
async function init() {
loadSnapshot();
render();
drawCompass(null);
await Promise.allSettled([fetchArchive(), fetchPressure()]);
try {
await loadScript(MQTT_SCRIPT);
connectMqtt();
} catch (error) {
console.warn('MQTT library unavailable', error);
setConnection('Feed unavailable', 'error');
}
window.setInterval(render, 5000);
window.setInterval(fetchPressure, 600000);
}
init();
})();
</script>
<style>
.weather-shell {
--weather-gap: 0.85rem;
--weather-pad: 1rem;
--weather-value-size: clamp(2.65rem, 4.2vw, 4.15rem);
--weather-secondary-size: clamp(2.25rem, 3.1vw, 3.05rem);
display: grid;
gap: var(--weather-gap);
padding: 1rem 0 2rem;
}
.weather-head {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: end;
}
.weather-status {
display: inline-flex;
align-items: center;
gap: 0.55rem;
min-height: 2.5rem;
padding: 0.5rem 0.8rem;
border: 1px solid var(--line);
border-radius: 999px;
background: rgba(255, 255, 255, 0.78);
color: var(--muted);
font-weight: 800;
white-space: nowrap;
}
.status-dot {
width: 0.72rem;
height: 0.72rem;
border-radius: 50%;
background: var(--warning);
box-shadow: 0 0 0 0.28rem rgba(187, 104, 0, 0.14);
}
.status-dot.ok {
background: #16834a;
box-shadow: 0 0 0 0.28rem rgba(22, 131, 74, 0.14);
}
.status-dot.error {
background: var(--critical);
box-shadow: 0 0 0 0.28rem rgba(161, 31, 58, 0.14);
}
.weather-dashboard,
.weather-main-column,
.weather-side-column,
.weather-grid {
display: grid;
gap: var(--weather-gap);
}
.weather-dashboard {
grid-template-columns: minmax(24rem, 0.95fr) minmax(28rem, 1.05fr);
align-items: start;
}
.weather-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.weather-card {
min-width: 0;
padding: var(--weather-pad);
border: 1px solid var(--line);
border-radius: 0.8rem;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(237, 245, 253, 0.94));
box-shadow: var(--shadow);
}
.weather-wind-card {
min-height: 8.75rem;
}
.weather-wind-primary {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--weather-gap);
align-items: end;
}
.weather-metric-block {
min-width: 0;
}
.weather-label,
.weather-meta {
margin: 0;
color: var(--muted);
font-size: 0.85rem;
font-weight: 800;
}
.weather-label {
color: var(--brand);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.weather-value {
margin: 0.35rem 0;
font-family: 'Manrope', system-ui, sans-serif;
font-size: var(--weather-value-size);
font-weight: 800;
font-variant-numeric: tabular-nums;
line-height: 1;
}
.weather-grid .weather-value {
font-size: var(--weather-secondary-size);
}
.weather-pressure-card {
grid-column: 1 / -1;
min-height: 8.75rem;
}
.weather-pressure-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--weather-gap);
margin-top: 0.45rem;
}
.weather-pressure-label {
margin: 0;
color: var(--muted);
font-size: 0.82rem;
font-weight: 800;
letter-spacing: 0.08em;
}
.weather-pressure-value {
margin: 0.35rem 0;
font-family: 'Manrope', system-ui, sans-serif;
font-size: var(--weather-secondary-size);
font-weight: 800;
font-variant-numeric: tabular-nums;
line-height: 1;
}
.weather-card-heading {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: center;
margin-bottom: 0.75rem;
}
.weather-compass-card canvas {
width: 100%;
height: auto;
aspect-ratio: 16 / 10;
border: 1px solid rgba(16, 34, 51, 0.1);
border-radius: 0.7rem;
background: #f8fbff;
}
.weather-feed-note {
margin: 0;
color: var(--muted);
font-weight: 700;
}
.weather-flash {
animation: weather-flash 0.6s ease;
}
@keyframes weather-flash {
0% {
background: rgba(255, 224, 97, 0.8);
}
100% {
background: transparent;
}
}
@media (max-width: 980px) {
.weather-shell {
--weather-gap: 1rem;
--weather-value-size: clamp(2.2rem, 14vw, 4rem);
--weather-secondary-size: clamp(2rem, 12vw, 3.4rem);
}
.weather-head,
.weather-dashboard,
.weather-grid,
.weather-main-column,
.weather-side-column {
grid-template-columns: 1fr;
}
.weather-head {
display: grid;
align-items: start;
}
.weather-wind-card {
min-height: auto;
}
.weather-wind-primary,
.weather-pressure-grid {
grid-template-columns: 1fr;
}
}
</style>
+1
View File
@@ -16,6 +16,7 @@ export const site = {
navigation: [
{ label: 'Home', href: '/' },
{ label: 'Pilot Info', href: '/pilot-info/' },
{ label: 'Weather', href: '/weather/' },
{
label: 'About',
href: '/about/',
+10
View File
@@ -0,0 +1,10 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import WeatherPanel from '../components/WeatherPanel.astro';
---
<BaseLayout title="Weather" description="Live Swansea Airport weather conditions for pilots and visitors.">
<section class="container">
<WeatherPanel />
</section>
</BaseLayout>