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;
+}