From 87b968568a4134728e606f3ba0f9f6860c8ef1a5 Mon Sep 17 00:00:00 2001 From: Matt Fiddaman Date: Tue, 1 Apr 2025 22:23:19 -0400 Subject: [PATCH] add met section --- src/App.js | 3 +- src/components/Radar.jsx | 2 +- src/components/metar/LimitsMatrix.jsx | 82 +++++++++++++++++++++ src/components/metar/MatrixComponents.jsx | 73 +++++++++++++++++++ src/components/metar/Metar.jsx | 41 +++++++++-- src/components/metar/RunwayMatrix.jsx | 23 ++++++ src/lib/metar.js | 88 +++++++++++++++++++++++ 7 files changed, 306 insertions(+), 6 deletions(-) create mode 100644 src/components/metar/LimitsMatrix.jsx create mode 100644 src/components/metar/MatrixComponents.jsx create mode 100644 src/components/metar/RunwayMatrix.jsx create mode 100644 src/lib/metar.js diff --git a/src/App.js b/src/App.js index 2176f2d..ac045e4 100644 --- a/src/App.js +++ b/src/App.js @@ -2,9 +2,10 @@ import { useState, useEffect, useMemo } from 'react'; import { format, formatDistanceToNow, startOfDay } from 'date-fns'; import { formatInTimeZone } from 'date-fns-tz'; -import './App.css'; import '@fontsource/roboto-mono'; +import './App.css'; + import Schedule from './components/schedule/Schedule'; import Radar from './components/Radar'; import Metar from './components/metar/Metar'; diff --git a/src/components/Radar.jsx b/src/components/Radar.jsx index 5365d06..aea12ee 100644 --- a/src/components/Radar.jsx +++ b/src/components/Radar.jsx @@ -12,7 +12,7 @@ const Radar = () => { diff --git a/src/components/metar/LimitsMatrix.jsx b/src/components/metar/LimitsMatrix.jsx new file mode 100644 index 0000000..a041e5c --- /dev/null +++ b/src/components/metar/LimitsMatrix.jsx @@ -0,0 +1,82 @@ +import React from 'react'; +import { + Row, + LabelCell, + ValueCell, + HeaderRow, + CheckIcon, + CrossIcon, +} from './MatrixComponents'; + +const constraints = { + pattern: { visibility: 5, wind: 25, ceiling: 2000, crosswind: 15 }, + dual: { visibility: 5, wind: 25, ceiling: 3000, crosswind: 15 }, + solo: { visibility: 5, wind: 25, ceiling: 3000, crosswind: 15 }, +}; + +const withinLimits = (regime, parameter, data) => { + switch (parameter) { + case 'wind': + case 'crosswind': + return data <= constraints[regime][parameter]; + case 'visibility': + return data >= constraints[regime][parameter]; + case 'ceiling': + return data === null || data >= constraints[regime][parameter]; + default: + return true; + } +}; + +const LimitsMatrix = ({ metar }) => { + const conditions = [ + { + label: 'Ceiling', + value: metar.ceiling !== null ? `${metar.ceiling}'` : 'None', + pattern: withinLimits('pattern', 'ceiling', metar.ceiling), + dual: withinLimits('dual', 'ceiling', metar.ceiling), + solo: withinLimits('solo', 'ceiling', metar.ceiling), + }, + { + label: 'Visibility', + value: `${metar.visibility}SM`, + pattern: withinLimits('pattern', 'visibility', metar.visibility), + dual: withinLimits('dual', 'visibility', metar.visibility), + solo: withinLimits('solo', 'visibility', metar.visibility), + }, + { + label: 'Wind', + value: `${metar.wind}kts`, + pattern: withinLimits('pattern', 'wind', metar.wind), + dual: withinLimits('dual', 'wind', metar.wind), + solo: withinLimits('solo', 'wind', metar.wind), + }, + { + label: 'Crosswind', + value: `${metar.crosswind}kts (R${metar.runway})`, + pattern: withinLimits('pattern', 'crosswind', metar.crosswind), + dual: withinLimits('dual', 'crosswind', metar.crosswind), + solo: withinLimits('solo', 'crosswind', metar.crosswind), + }, + ]; + + return ( + <> + + {conditions.map(({ label, value, pattern, dual, solo }) => ( + + {label} + {value} + {pattern ? : } + {dual ? : } + {solo ? : } + + ))} + + ); +}; + +export { LimitsMatrix }; diff --git a/src/components/metar/MatrixComponents.jsx b/src/components/metar/MatrixComponents.jsx new file mode 100644 index 0000000..207537c --- /dev/null +++ b/src/components/metar/MatrixComponents.jsx @@ -0,0 +1,73 @@ +import React from 'react'; + +const baseStyles = { + row: { + display: 'grid', + alignItems: 'center', + gap: '12px', + marginBottom: '8px', + }, + headerCell: { + fontWeight: 'bold', + textAlign: 'center', + }, + label: { + fontSize: '1.1em', + }, + value: { + fontSize: '1.1em', + }, + icon: { + fontSize: '1.5em', + textAlign: 'center', + }, + check: { + color: '#5fd98d', + }, + cross: { + color: '#f44336', + }, +}; + +export const Row = ({ columns, children }) => ( +
+ {children} +
+); + +export const HeaderCell = ({ children, align }) => ( +
+ {children} +
+); + +export const LabelCell = ({ children }) => ( +
{children}
+); + +export const ValueCell = ({ children }) => ( +
{children}
+); + +export const CheckIcon = () => ( + +); + +export const CrossIcon = () => ( + +); + +export const HeaderRow = ({ columns, labels, align }) => ( + + {labels.map((label, i) => ( + + {label} + + ))} + +); diff --git a/src/components/metar/Metar.jsx b/src/components/metar/Metar.jsx index 7ad9a07..7e8f179 100644 --- a/src/components/metar/Metar.jsx +++ b/src/components/metar/Metar.jsx @@ -1,8 +1,11 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useMemo } from 'react'; +import { decodeMetar } from '../../lib/metar'; +import { LimitsMatrix } from './LimitsMatrix'; +import { RunwayMatrix } from './RunwayMatrix'; const TafDisplay = ({ taf }) => { const lines = taf - .split(/\s+(?=FM\d{6})/) // split on whitespace before "FM" lines + .split(/\s+(?=FM\d{6}|TEMPO)/) // split on whitespace before "FM" lines .map((line, index) =>
{line}
); return
{lines}
; @@ -36,6 +39,8 @@ const Metar = ({ data }) => { return () => clearInterval(interval); }, []); + const decodedMetar = useMemo(() => decodeMetar(metar), [metar]); + return (
{ >
{ color: 'white', }} > +
+
+ +
+
+ +
+
+

{metar}

diff --git a/src/components/metar/RunwayMatrix.jsx b/src/components/metar/RunwayMatrix.jsx new file mode 100644 index 0000000..9fdc9cb --- /dev/null +++ b/src/components/metar/RunwayMatrix.jsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { Row, LabelCell, ValueCell, HeaderRow } from './MatrixComponents'; + +const RunwayMatrix = ({ metar }) => { + return ( + <> + + {metar?.runwayBreakdown?.map(({ runway, headwind, crosswind }) => ( + + {runway} + {headwind}kts + {crosswind}kts + + ))} + + ); +}; + +export { RunwayMatrix }; diff --git a/src/lib/metar.js b/src/lib/metar.js new file mode 100644 index 0000000..da2ea7a --- /dev/null +++ b/src/lib/metar.js @@ -0,0 +1,88 @@ +export function decodeMetar(metar) { + const result = { + wind: null, + gusts: null, + direction: null, + visibility: null, + ceiling: null, + crosswind: null, + runway: null, + runwayBreakdown: [], + }; + + const runways = [ + { id: '12', heading: 120 }, + { id: '30', heading: 300 }, + { id: '22', heading: 220 }, + { id: '04', heading: 40 }, + ]; + + // Wind: match dddffKT, dddffGggKT, or VRBffKT + const windMatch = metar.match(/\b(VRB|\d{3})(\d{2})(G(\d{2}))?KT/); + if (windMatch) { + const dir = windMatch[1]; + const baseWind = parseInt(windMatch[2], 10); + const gust = windMatch[4] ? parseInt(windMatch[4], 10) : null; + + result.direction = + dir === 'VRB' + ? 'variable' + : dir === '000' && baseWind === 0 + ? 'calm' + : parseInt(dir, 10); + + result.gusts = gust; + const effectiveWind = gust ? Math.max(baseWind, gust) : baseWind; + result.wind = effectiveWind; + + // Runway calculations + if (typeof result.direction === 'number') { + const windDir = result.direction; + + result.runwayBreakdown = runways.map(({ id, heading }) => { + const angleDiff = Math.abs(windDir - heading); + const relAngle = Math.min(angleDiff, 360 - angleDiff); + const radians = (relAngle * Math.PI) / 180; + + const crosswind = effectiveWind * Math.sin(radians); + const headwind = effectiveWind * Math.cos(radians); + + return { + runway: id, + heading, + crosswind: Math.round(crosswind), + headwind: Math.round(headwind), + }; + }); + + // Choose the runway with the strongest headwind + const best = result.runwayBreakdown.reduce((a, b) => + a.headwind > b.headwind ? a : b, + ); + + result.runway = best.runway; + result.crosswind = Math.abs(best.crosswind); + } + } + + // Visibility: look for ##SM + const visMatch = metar.match(/(\d+)(?:SM|\sSM)/); + if (visMatch) { + result.visibility = parseInt(visMatch[1], 10); + } + + // Ceiling: lowest BKN or OVC unless CLM + if (/\bCLM\b/.test(metar)) { + result.ceiling = null; + } else { + const cloudMatches = [...metar.matchAll(/\b(BKN|OVC)(\d{3})\b/g)]; + if (cloudMatches.length > 0) { + const lowestCeiling = Math.min( + ...cloudMatches.map(m => parseInt(m[2], 10)), + ); + result.ceiling = lowestCeiling * 100; // hundreds of feet AGL + } + } + + return result; +}