add met section
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
82
src/components/metar/LimitsMatrix.jsx
Normal file
82
src/components/metar/LimitsMatrix.jsx
Normal 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 };
|
||||
73
src/components/metar/MatrixComponents.jsx
Normal file
73
src/components/metar/MatrixComponents.jsx
Normal 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>
|
||||
);
|
||||
@@ -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} />
|
||||
|
||||
23
src/components/metar/RunwayMatrix.jsx
Normal file
23
src/components/metar/RunwayMatrix.jsx
Normal 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
88
src/lib/metar.js
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user