add met section

This commit is contained in:
Matt Fiddaman
2025-04-01 22:23:19 -04:00
parent 19cf3e31c5
commit 87b968568a
7 changed files with 306 additions and 6 deletions

View File

@@ -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';

View File

@@ -12,7 +12,7 @@ const Radar = () => {
<iframe
title="radar"
width="1015"
height="662"
height="600"
src="https://embed.windy.com/embed.html?type=map&location=coordinates&metricRain=default&metricTemp=°C&metricWind=kt&zoom=8&overlay=radar&product=radar&level=surface&lat=27.794&lon=-81.745&pressure=true&message=true&play=true"
frameborder="0"
></iframe>

View File

@@ -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 (
<>
<HeaderRow
columns="120px 120px 100px 100px 100px"
labels={['', '', 'Pattern', 'Dual', 'Solo']}
/>
{conditions.map(({ label, value, pattern, dual, solo }) => (
<Row key={label} columns="120px 120px 100px 100px 100px">
<LabelCell>{label}</LabelCell>
<ValueCell>{value}</ValueCell>
{pattern ? <CheckIcon /> : <CrossIcon />}
{dual ? <CheckIcon /> : <CrossIcon />}
{solo ? <CheckIcon /> : <CrossIcon />}
</Row>
))}
</>
);
};
export { LimitsMatrix };

View File

@@ -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 }) => (
<div style={{ ...baseStyles.row, gridTemplateColumns: columns }}>
{children}
</div>
);
export const HeaderCell = ({ children, align }) => (
<div
style={{
...baseStyles.headerCell,
textAlign: align ?? baseStyles.headerCell.textAlign,
}}
>
{children}
</div>
);
export const LabelCell = ({ children }) => (
<div style={baseStyles.label}>{children}</div>
);
export const ValueCell = ({ children }) => (
<div style={baseStyles.value}>{children}</div>
);
export const CheckIcon = () => (
<span style={{ ...baseStyles.icon, ...baseStyles.check }}></span>
);
export const CrossIcon = () => (
<span style={{ ...baseStyles.icon, ...baseStyles.cross }}></span>
);
export const HeaderRow = ({ columns, labels, align }) => (
<Row columns={columns}>
{labels.map((label, i) => (
<HeaderCell key={i} align={align}>
{label}
</HeaderCell>
))}
</Row>
);

View File

@@ -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) => <div key={index}>{line}</div>);
return <div>{lines}</div>;
@@ -36,6 +39,8 @@ const Metar = ({ data }) => {
return () => clearInterval(interval);
}, []);
const decodedMetar = useMemo(() => decodeMetar(metar), [metar]);
return (
<div
style={{
@@ -47,8 +52,8 @@ const Metar = ({ data }) => {
>
<div
style={{
minHeight: '395px',
maxHeight: '395px',
minHeight: '450px',
maxHeight: '450px',
minWidth: '1015px',
maxWidth: '1015px',
backgroundColor: '#23252A',
@@ -57,6 +62,34 @@ const Metar = ({ data }) => {
color: 'white',
}}
>
<div
style={{
margin: '1rem 1rem 0 1rem',
display: 'flex',
gap: '1rem',
}}
>
<div
style={{
borderRadius: '12px',
backgroundColor: '#313340',
padding: '1rem',
}}
>
<LimitsMatrix metar={decodedMetar} />
</div>
<div
style={{
borderRadius: '12px',
backgroundColor: '#313340',
padding: '1rem',
width: '100%',
}}
>
<RunwayMatrix metar={decodedMetar} />
</div>
</div>
<div style={{ padding: '.5rem 1rem' }}>
<p>{metar}</p>
<TafDisplay taf={taf} />

View File

@@ -0,0 +1,23 @@
import React from 'react';
import { Row, LabelCell, ValueCell, HeaderRow } from './MatrixComponents';
const RunwayMatrix = ({ metar }) => {
return (
<>
<HeaderRow
columns="70px 100px 100px"
align="left"
labels={['', 'Headwind', 'Crosswind']}
/>
{metar?.runwayBreakdown?.map(({ runway, headwind, crosswind }) => (
<Row key={runway} columns="70px 100px 100px">
<LabelCell>{runway}</LabelCell>
<ValueCell>{headwind}kts</ValueCell>
<ValueCell>{crosswind}kts</ValueCell>
</Row>
))}
</>
);
};
export { RunwayMatrix };

88
src/lib/metar.js Normal file
View File

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