Weather page
This commit is contained in:
@@ -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>
|
||||
@@ -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/',
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user