Files
html-tools/metar.html
T
2026-02-14 17:14:29 -07:00

988 lines
43 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>METAR Practice</title>
<style>
body {
font-family: monospace;
padding: 2em;
}
#metar-display {
font-size: 1.2em;
margin-bottom: 1em;
min-height: 3em;
line-height: 1.6;
}
#test-all-btn {
position: fixed;
bottom: 2em;
left: 2em;
}
@media (max-width: 600px) {
#metar-display {
min-height: 6em;
}
#test-all-btn {
display: none;
}
}
button {
font-size: 1em;
padding: 0.5em 1em;
}
.highlight {
background-color: yellow;
}
.next-to-decode {
text-decoration: underline dashed;
text-decoration-thickness: 2px;
text-underline-offset: 3px;
}
#decoding-display {
margin-top: 1em;
padding: 1em;
border: 1px solid #ccc;
white-space: pre-wrap; /* Respect newlines for remarks */
}
</style>
</head>
<body>
<div id="app">
<h1>METAR Practice</h1>
<div id="metar-display"></div>
<button id="new-metar-btn">New METAR</button>
<label style="margin-left: 1em;"><input type="checkbox" id="decode-all-checkbox"> Decode all</label>
<div id="decoding-display"></div>
<button id="next-btn" style="margin-top: 1em;">Next</button>
<button id="test-all-btn">Test All METARs</button>
</div>
<script>
const metarDisplay = document.getElementById('metar-display');
const decodingDisplay = document.getElementById('decoding-display');
const newMetarBtn = document.getElementById('new-metar-btn');
const testAllBtn = document.getElementById('test-all-btn');
const nextBtn = document.getElementById('next-btn');
const decodeAllCheckbox = document.getElementById('decode-all-checkbox');
const metars = [];
let currentMetarSections = [];
let currentSectionIndex = 0;
// --- METAR Ranking ---
function getMetarParts(metarString) {
const rawParts = metarString.split(/\s+/).filter(Boolean);
const parts = [];
for (let i = 0; i < rawParts.length; i++) {
let part = rawParts[i];
// Combine mixed fraction visibility, e.g., "1 1/2SM" which is split
if (part.match(/^\d+$/) && (i + 1) < rawParts.length && rawParts[i+1].match(/^\d+\/\d+SM$/)) {
part = `${part} ${rawParts[i+1]}`;
i++;
}
parts.push(part);
}
return parts;
}
function parseVisibilityToNumber(visStr) {
visStr = visStr.replace('SM', '');
const parts = visStr.split(' ');
let value = 0;
parts.forEach(p => {
if (p.includes('/')) {
const frac = p.split('/');
if (frac.length === 2 && !isNaN(parseInt(frac[0])) && !isNaN(parseInt(frac[1])) && parseInt(frac[1]) !== 0) {
value += parseInt(frac[0]) / parseInt(frac[1]);
}
} else if (!isNaN(parseInt(p))) {
value += parseInt(p);
}
});
return value;
}
function calculateMetarScore(metarString, componentFrequencies, totalMetars) {
let score = 0;
const parts = getMetarParts(metarString);
const uniqueParts = new Set(parts);
// --- Component Scoring & Significant Weather Heuristics ---
parts.forEach(part => {
// Wind
if (part.endsWith('KT')) {
if (part.includes('G')) score += 2; // Gusts
if (part.substring(0,3) === 'VRB') score += 2;
}
if (/^\d{3}V\d{3}$/.test(part)) score += 2; // Variable wind range
// Weather Phenomena
if (part.match(/^([+-]|VC)?(MI|PR|BC|DR|BL|SH|TS|FZ|DZ|RA|SN|SG|IC|PL|GR|GS|UP|BR|FG|FU|VA|DU|SA|HZ|PY|PO|SQ|FC|SS|DS|TR)+$/)) {
score += 2;
if (part.startsWith('+')) score += 3; // Heavy intensity
if (part.includes('TS')) score += 5; // Thunderstorm
if (part.includes('FZ')) score += 5; // Freezing
if (part.includes('GR') || part.includes('GS')) score += 5; // Hail
if (part.includes('SQ')) score += 5; // Squalls
if (part.includes('FC')) score += 10; // Funnel cloud
if (part.includes('DS') || part.includes('SS')) score += 4; // Dust/Sand storm
if (part.includes('VA')) score += 10; // Volcanic Ash
}
// Visibility
if (part.endsWith('SM')) {
const vis = parseVisibilityToNumber(part);
if (vis <= 0.25) score += 8; // <= 1/4sm
else if (vis <= 0.5) score += 5; // <= 1/2sm
else if (vis <= 1) score += 3; // <= 1sm
}
// Clouds
if (part.match(/^(BKN|OVC)/)) {
score += 1;
const ceilingAlt = parseInt(part.substring(3, 6));
if (!isNaN(ceilingAlt)) {
const ceilingFt = ceilingAlt * 100;
if (ceilingFt < 500) score += 8;
else if (ceilingFt < 1000) score += 5;
}
if (part.endsWith('CB') || part.endsWith('TCU')) score += 5; // Cumulonimbus / Towering Cumulus
}
});
// Remarks
const rmkIndex = parts.indexOf('RMK');
if (rmkIndex !== -1) {
score += 3;
const remarkParts = parts.slice(rmkIndex + 1);
score += remarkParts.length * 0.5; // More remarks, more interesting
if (remarkParts.includes('PRESFR') || remarkParts.includes('PRESRR')) score += 5;
if (remarkParts.some(p => p.includes('TORNADO') || p.includes('FUNNEL'))) score += 15;
}
// --- Rarity Scoring ---
let rarityScore = 0;
uniqueParts.forEach(part => {
const freq = componentFrequencies[part] || 0;
if (freq > 0) {
rarityScore += Math.log(totalMetars / freq);
}
});
score += rarityScore;
return score;
}
// --- Decoding Functions ---
function decodeAirport(code, metarString) {
if (code === 'CYYC') return "CYYC: Calgary International Airport";
if (code === 'CYEG') return "CYEG: Edmonton International Airport";
if (code === 'CYXS') return "CYXS: Prince George Airport";
if (code === 'CYVR') return "CYVR: Vancouver International Airport";
if (code === 'CYBW') return "CYBW: Calgary/Springbank Airport";
console.log(`Unknown Airport code: ${code} in METAR: ${metarString}`);
return `${code}: Unknown Airport`;
}
function decodeTime(code) {
const day = code.substring(0, 2);
const hours = code.substring(2, 4);
const minutes = code.substring(4, 6);
return `${code}: ${day} day at ${hours}:${minutes} UTC`;
}
function decodeWind(code) {
if (code === '00000KT') return `${code}: Calm winds`;
const dir = code.substring(0, 3);
const speed = parseInt(code.substring(3, 5));
const gustMatch = code.match(/G(\d{2})/);
let text = `${code}: Wind from ${dir}° at ${speed} knots`;
if (gustMatch) {
text += `, gusting to ${parseInt(gustMatch[1])} knots`;
}
return text;
}
function decodeVariableWind(code) {
const [dir1, dir2] = code.split('V');
return `${code}: Wind variable between ${dir1}° and ${dir2}°`;
}
function decodeVisibility(code) {
const vis = code.replace('SM', '');
return `${code}: Visibility ${vis} statute miles`;
}
function decodeWeather(code, metarString) {
const originalCode = code;
const intensityMap = {
'-': 'Light ',
'+': 'Heavy '
};
const descriptorMap = {
'MI': 'Shallow ', 'PR': 'Partial ', 'BC': 'Patches of ', 'DR': 'Low drifting ',
'BL': 'Blowing ', 'SH': 'Showers of ', 'TS': 'Thunderstorm with ', 'FZ': 'Freezing '
};
const weatherMap = {
'DZ': 'drizzle', 'RA': 'rain', 'SN': 'snow', 'SG': 'snow grains',
'IC': 'ice crystals', 'PL': 'ice pellets', 'GR': 'hail', 'GS': 'small hail and/or snow pellets',
'UP': 'unknown precipitation', 'BR': 'mist', 'FG': 'fog', 'FU': 'smoke',
'VA': 'volcanic ash', 'DU': 'widespread dust', 'SA': 'sand', 'HZ': 'haze',
'PY': 'spray', 'PO': 'well-developed dust/sand whirls', 'SQ': 'squalls',
'FC': 'funnel cloud(s)', 'SS': 'sandstorm', 'DS': 'duststorm', 'TR': 'trace'
};
const precipitationTypes = ['DZ', 'RA', 'SN', 'SG', 'IC', 'PL', 'GR', 'GS', 'UP'];
let decoding = '';
let codeToParse = code;
if (codeToParse.startsWith('-') || codeToParse.startsWith('+')) {
decoding += intensityMap[codeToParse[0]];
codeToParse = codeToParse.substring(1);
}
let vicinity = '';
if (codeToParse.startsWith('VC')) {
vicinity = ' in the vicinity';
codeToParse = codeToParse.substring(2);
}
const chunks = codeToParse.match(/.{1,2}/g) || [];
let decodedChunks = [];
let hasPrecipitation = false;
chunks.forEach(chunk => {
if (descriptorMap[chunk]) {
decodedChunks.push(descriptorMap[chunk]);
} else if (weatherMap[chunk]) {
decodedChunks.push(weatherMap[chunk]);
} else {
console.log(`Unknown weather chunk: ${chunk} in code: ${code} in METAR: ${metarString}`);
decodedChunks.push(`unknown (${chunk})`);
}
if (precipitationTypes.includes(chunk)) {
hasPrecipitation = true;
}
});
if ((codeToParse.includes('TS') || codeToParse.includes('SH')) && !hasPrecipitation) {
decodedChunks.push('rain');
}
decoding += decodedChunks.join('');
return `${originalCode}: ${decoding.trim()}${vicinity}`;
}
function decodeClouds(code, metarString) {
const coverMap = {
'SKC': 'Sky clear',
'CLR': 'Sky clear',
'FEW': 'Few clouds',
'SCT': 'Scattered clouds',
'BKN': 'Broken clouds',
'OVC': 'Overcast clouds'
};
const coverType = code.substring(0, 3);
const cover = coverMap[coverType];
if (!cover) {
console.log(`Unknown cloud information: ${code} in METAR: ${metarString}`);
return `${code}: Unknown cloud information`;
}
if (coverType === 'SKC' || coverType === 'CLR') {
return `${code}: ${cover}`;
}
const alt = parseInt(code.substring(3, 6)) * 100;
return `${code}: ${cover} at ${alt.toLocaleString()} feet above ground`;
}
function decodeTempDew(code) {
const parts = code.split('/');
const temp = parts[0].startsWith('M') ? -parseInt(parts[0].substring(1)) : parseInt(parts[0]);
const dew = parts[1].startsWith('M') ? -parseInt(parts[1].substring(1)) : parseInt(parts[1]);
return `${code}: Temperature ${temp}°C, Dewpoint ${dew}°C`;
}
function decodeAltimeter(code) {
const alt = code.substring(1);
return `${code}: Altimeter ${alt.slice(0, 2)}.${alt.slice(2)} inches of mercury`;
}
function decodeRunwayInfo(code) {
const parts = code.split('/');
const runway = `Runway ${parts[0].substring(1)}`;
const visPart = parts[1];
let visText;
function decodeVis(visStr) {
if (visStr.startsWith('P')) return `greater than ${visStr.substring(1)}`;
if (visStr.startsWith('M')) return `less than ${visStr.substring(1)}`;
return visStr;
}
if (visPart.includes('V')) {
const [minVis, maxVis] = visPart.replace('FT', '').split('V');
visText = `visual range variable between ${decodeVis(minVis)} and ${decodeVis(maxVis)} feet`;
} else {
const vis = visPart.replace('FT', '');
visText = `visual range ${decodeVis(vis)} feet`;
}
let trendText = '';
if (parts.length > 2 && parts[2]) {
const trendCode = parts[2];
const trendMap = {
'N': 'not significant',
'U': 'upward',
'D': 'downward'
};
const trendDesc = trendMap[trendCode] || 'unknown';
trendText = `, trend ${trendDesc} (${trendCode})`;
}
return `${code}: ${runway} ${visText}${trendText}`;
}
function decodeSlp(code) {
const pressure = parseInt(code.substring(3));
const hpa = (pressure < 500 ? 1000 : 900) + (pressure / 10);
return `SLP${pressure}: Sea-level pressure ${hpa.toFixed(1)} hPa`;
}
function decodeCloudTypesRemark(code, metarString) {
const cloudTypes = {
'CI': 'Cirrus',
'CC': 'Cirrocumulus',
'CS': 'Cirrostratus',
'AC': 'Altocumulus',
'AS': 'Altostratus',
'NS': 'Nimbostratus',
'SC': 'Stratocumulus',
'ST': 'Stratus',
'CU': 'Cumulus',
'CB': 'Cumulonimbus',
'CF': 'Cumulus Fractus',
'FG': 'Fog',
'SF': 'Stratus Fractus',
'SN': 'Snow',
'ACC': 'Altocumulus Castellanus'
};
let decodedParts = [];
const matches = code.matchAll(/([A-Z]{2,3})(\d)/g);
for (const match of matches) {
const cloudCode = match[1];
const oktas = match[2];
let cloudName = cloudTypes[cloudCode];
if (!cloudName) {
cloudName = `Unknown cloud type (${cloudCode})`;
console.log(`Unknown cloud type in remark: ${cloudCode} in METAR: ${metarString}`);
}
decodedParts.push(`${oktas}/8 ${cloudName}`);
}
return `${code}: ${decodedParts.join(', ')}`;
}
function decodeObscurationRemark(code) {
const weatherMap = {
'FG': 'Fog',
'BR': 'Mist',
'FU': 'Smoke',
'HZ': 'Haze',
'DU': 'Widespread dust',
'SA': 'Sand',
};
const weatherCode = code.substring(0, 2);
const oktas = code.substring(2);
const weatherName = weatherMap[weatherCode];
return `${code}: ${weatherName} obscuring ${oktas}/8 of the sky`;
}
function decodeRemarks(parts, metarString) {
let decoded = ["RMK: Remarks"];
const weatherRegex = /^([+-]|VC)?(MI|PR|BC|DR|BL|SH|TS|FZ|DZ|RA|SN|SG|IC|PL|GR|GS|UP|BR|FG|FU|VA|DU|SA|HZ|PY|PO|SQ|FC|SS|DS|TR)+$/;
const obscurationRegex = /^(FG|BR|FU|HZ|DU|SA)\d$/;
const cloudTypes = {
'CI': 'Cirrus',
'CC': 'Cirrocumulus',
'CS': 'Cirrostratus',
'AC': 'Altocumulus',
'AS': 'Altostratus',
'NS': 'Nimbostratus',
'SC': 'Stratocumulus',
'ST': 'Stratus',
'CU': 'Cumulus',
'CB': 'Cumulonimbus',
'TCU': 'Towering cumulus',
'CF': 'Cumulus Fractus',
'FG': 'Fog',
'SF': 'Stratus Fractus',
'SN': 'Snow'
};
const specialRemarks = {
'FROIN': 'Frost on indicator',
'CONTRAILS': 'Contrails observed',
'VIRGA': 'Virga (precipitation not reaching the ground)',
'HALO': 'Halo phenomenon observed',
'PRESFR': 'Pressure falling rapidly',
'PRESRR': 'Pressure rising rapidly',
'AURBO': 'Aurora Borealis observed'
};
const directions = {
'N': 'North', 'NE': 'Northeast', 'E': 'East', 'SE': 'Southeast',
'S': 'South', 'SW': 'Southwest', 'W': 'West', 'NW': 'Northwest'
};
function isDirection(dirStr) {
if (!dirStr) return false;
const dirParts = dirStr.split('-');
return dirParts.every(part => directions[part]);
}
function decodeDirection(dirStr) {
const dirParts = dirStr.split('-');
const decodedParts = dirParts.map(part => directions[part]);
return decodedParts.join(' to ').toLowerCase();
}
const decoders = [
// SLP
{
match: p => p[0].startsWith('SLP'),
decode: p => ({ consumed: 1, text: ` - ${decodeSlp(p[0])}` })
},
// Obscuration
{
match: p => obscurationRegex.test(p[0]),
decode: p => ({ consumed: 1, text: ` - ${decodeObscurationRemark(p[0])}` })
},
// Cloud types summary
{
match: p => p[0].match(/^([A-Z]{2,3}\d)+$/),
decode: (p, ms) => ({ consumed: 1, text: ` - ${decodeCloudTypesRemark(p[0], ms)}` })
},
// VIA
{
match: p => p[0] === 'VIA' && p.length > 1,
decode: p => ({ consumed: 2, text: ` - VIA ${p[1]}: relayed via ${p[1]}` })
},
// VIS VRB
{
match: p => p[0] === 'VIS' && p.length > 2 && p[1] === 'VRB' && p[2].includes('-'),
decode: p => {
const [min, max_part1] = p[2].split('-');
let max = max_part1;
let consumed = 3;
let raw_range = p[2];
// Handle fractional part for max visibility, e.g. VIS VRB 3/4-2 1/2
if (p.length > 3 && /^\d+\/\d+$/.test(p[3])) {
max += ` ${p[3]}`;
consumed++;
raw_range += ` ${p[3]}`;
}
return { consumed: consumed, text: ` - VIS VRB ${raw_range}: Visibility variable between ${min} and ${max} statute miles` };
}
},
// VIS
{
match: p => p[0] === 'VIS' && p.length > 2 && (isDirection(p[1]) || (p[1] === 'LW' && p.length > 3 && (isDirection(p[2]) || isDirection(p[3])))),
decode: p => {
// VIS LW ...
if (p[1] === 'LW') {
// VIS LW <direction> <visibility>... (new format)
if (isDirection(p[2])) {
const direction = p[2];
let vis_parts = [p[3]];
let vis_consumed = 1;
if (p.length > 4) {
if (p[4] === 'SM') { // "3 SM"
vis_parts.push(p[4]);
vis_consumed++;
} else if (p[3].match(/^\d+$/) && p[4].match(/^\d+\/\d+SM$/)) { // "1 1/2SM"
vis_parts.push(p[4]);
vis_consumed++;
}
}
const raw_vis = vis_parts.join(' ');
const raw_remark = ['VIS', 'LW', direction, raw_vis].join(' ');
const visibility = raw_vis.replace('SM', '');
return {
consumed: 3 + vis_consumed,
text: ` - ${raw_remark}: Visibility locally worse, ${visibility} statute miles to the ${decodeDirection(direction).replace(/ to /g, '-')}`
};
}
// VIS LW <visibility> <direction> (old format)
if (isDirection(p[3])) {
const visibility = p[2].replace('SM', '');
const direction = p[3];
return {
consumed: 4,
text: ` - VIS LW ${p[2]} ${direction}: Visibility locally worse, ${visibility} statute miles to the ${decodeDirection(direction).replace(/ to /g, '-')}`
};
}
}
// VIS <direction> <visibility>
if (isDirection(p[1])) {
const direction = p[1];
let vis_parts = [p[2]];
let vis_consumed = 1;
if (p.length > 3) {
if (p[3] === 'SM') { // "3 SM"
vis_parts.push(p[3]);
vis_consumed++;
} else if (p[2].match(/^\d+$/) && p[3].match(/^\d+\/\d+SM$/)) { // "1 1/2SM"
vis_parts.push(p[3]);
vis_consumed++;
}
}
const raw_vis = vis_parts.join(' ');
const visibility = raw_vis.replace('SM', '');
let consumed = 2 + vis_consumed;
let raw_parts = ['VIS', direction, raw_vis];
let text_parts = [];
text_parts.push(`Visibility ${decodeDirection(direction)} ${visibility} miles`);
if (p.length > (2 + vis_consumed) && p[2 + vis_consumed] === 'INTMT') {
consumed++;
raw_parts.push('INTMT');
text_parts.push('intermittent');
}
const raw_remark = raw_parts.join(' ');
const text = ` - ${raw_remark}: ${text_parts.join(', ')}`;
return { consumed, text };
}
}
},
// VIRGA
{
match: p => p[0] === 'VIRGA',
decode: p => {
if (p.length > 1 && p[1] === 'ALQDS') {
return { consumed: 2, text: ` - VIRGA ALQDS: Virga in all quadrants` };
}
if (p.length > 1 && p[1] === 'OHD') {
return { consumed: 2, text: ` - VIRGA OHD: Virga overhead` };
}
if (p.length > 1 && p[1] === 'PTCHS') {
return { consumed: 2, text: ` - VIRGA PTCHS: Virga in patches` };
}
if (p.length > 1 && isDirection(p[1])) {
return { consumed: 2, text: ` - VIRGA ${p[1]}: Virga to the ${decodeDirection(p[1])}` };
}
return { consumed: 1, text: ` - ${p[0]}: ${specialRemarks[p[0]]}` };
}
},
// CVCTV CLD EMBD
{
match: p => p[0] === 'CVCTV' && p.length > 2 && p[1] === 'CLD' && p[2] === 'EMBD',
decode: p => ({ consumed: 3, text: ` - CVCTV CLD EMBD: Convective cloud embedded in stratiform layer` })
},
// ICE
{
match: p => p[0] === 'ICE',
decode: p => {
if (p.length > 2 && p[1] === 'PAST' && p[2] === 'HR') {
return { consumed: 3, text: ` - ICE PAST HR: Ice observed in the past hour` };
}
if (p.length > 1 && p[1] === 'INTMT') {
return { consumed: 2, text: ` - ICE INTMT: Intermittent ice observed` };
}
return { consumed: 1, text: ` - ICE: Ice observed` };
}
},
// DENSITY ALT
{
match: p => p[0] === 'DENSITY' && p.length > 2 && p[1] === 'ALT' && p[2].endsWith('FT'),
decode: p => {
const altFeet = p[2];
const altitude = parseInt(altFeet.replace('FT', '')).toLocaleString();
return { consumed: 3, text: ` - DENSITY ALT ${altFeet}: Density altitude ${altitude} feet` };
}
},
// PCPN VRY LGT
{
match: p => p[0] === 'PCPN' && p.length > 2 && p[1] === 'VRY' && p[2] === 'LGT',
decode: p => ({ consumed: 3, text: ` - PCPN VRY LGT: Very light precipitation` })
},
// PCPN remark
{
match: p => p[0] === 'PCPN' && p.length > 3 && p[1].endsWith('MM') && p[2] === 'PAST' && p[3] === 'HR',
decode: p => {
const amount = p[1].replace('MM', '');
const raw = `PCPN ${p[1]} PAST HR`;
return { consumed: 4, text: ` - ${raw}: Precipitation ${amount} mm in the past hour` };
}
},
// Cloud remarks
{
match: p => cloudTypes[p[0]] || p[0] === 'ACC',
decode: p => {
const cloudCode = p[0];
const cloudName = cloudTypes[cloudCode] || (cloudCode === 'ACC' ? 'Altocumulus Castellanus' : cloudCode);
let consumed = 1;
let text = ` - ${cloudCode}: ${cloudName}`;
if (p.length > 1) {
const mod = p[1];
if (mod === 'LENT') {
text = ` - ${cloudCode} LENT: ${cloudName} lenticularis`;
consumed = 2;
if (p.length > 2 && isDirection(p[2])) {
text += ` to the ${decodeDirection(p[2])}`;
consumed = 3;
}
} else if (mod === 'ASOCTD') {
text = ` - ${cloudCode} ASOCTD: ${cloudName} associated`;
consumed = 2;
if (p.length > 3 && p[2] === '/' && p[3] === 'HALO') {
text = ` - ${cloudCode} ASOCTD / HALO: ${cloudName} associated with Halo phenomenon`;
consumed = 4;
} else if (p.length > 2 && isDirection(p[2])) {
text += ` to the ${decodeDirection(p[2])}`;
consumed = 3;
}
} else if (mod === 'ALQDS') {
text = ` - ${cloudCode} ALQDS: ${cloudName} in all quadrants`;
consumed = 2;
} else if (mod === 'TR') {
text = ` - ${cloudCode} TR: ${cloudName} clouds are translucent (thin)`;
consumed = 2;
} else if (mod === 'TOP' && p.length > 2 && isDirection(p[2])) {
text = ` - ${cloudCode} TOP ${p[2]}: ${cloudName} cloud tops to the ${decodeDirection(p[2])}`;
consumed = 3;
} else if (mod === 'DSNT' && p.length > 2 && isDirection(p[2])) {
text = ` - ${cloudCode} DSNT ${p[2]}: ${cloudName} distant ${decodeDirection(p[2])}`;
consumed = 3;
} else if (isDirection(mod)) {
text = ` - ${cloudCode} ${mod}: ${cloudName} to the ${decodeDirection(mod)}`;
consumed = 2;
if (p.length > 3 && p[2] === 'MOV' && isDirection(p[3])) {
text += `, moving ${decodeDirection(p[3])}`;
consumed = 4;
}
}
}
return { consumed, text };
}
},
// Simple remarks
{
match: p => specialRemarks[p[0]],
decode: p => ({ consumed: 1, text: ` - ${p[0]}: ${specialRemarks[p[0]]}` })
},
// Weather
{
match: p => weatherRegex.test(p[0]),
decode: (p, ms) => ({ consumed: 1, text: ` - ${decodeWeather(p[0], ms)}` })
},
];
let i = 0;
while (i < parts.length) {
const remainingParts = parts.slice(i);
let result = null;
for (const decoder of decoders) {
if (decoder.match(remainingParts)) {
result = decoder.decode(remainingParts, metarString);
break;
}
}
if (result) {
if (result.text) decoded.push(result.text);
i += result.consumed;
} else if (parts[i] === '/') {
i++; // separator, ignore
} else {
console.log(`Other remark: ${parts[i]} in METAR: ${metarString}`);
decoded.push(` - ${parts[i]}: Other remark`);
i++;
}
}
return decoded.join('\n');
}
function generateMetarSections(metarString) {
const sections = [];
const parts = metarString.split(/\s+/).filter(Boolean);
let index = 0;
// Section 1: Airport, Time, Auto
let airportTimeAutoRaw = [];
let airportTimeAutoDecoded = [];
if (parts[index] && parts[index].length === 4 && parts[index].match(/^[A-Z][A-Z0-9]{3}$/)) {
airportTimeAutoRaw.push(parts[index]);
airportTimeAutoDecoded.push(decodeAirport(parts[index], metarString));
index++;
}
if (parts[index] && parts[index].endsWith('Z')) {
airportTimeAutoRaw.push(parts[index]);
airportTimeAutoDecoded.push(decodeTime(parts[index]));
index++;
}
if (parts[index] && parts[index] === 'AUTO') {
airportTimeAutoRaw.push(parts[index]);
airportTimeAutoDecoded.push("AUTO: Fully automated observation");
index++;
}
if (airportTimeAutoRaw.length > 0) {
sections.push({
raw: airportTimeAutoRaw.join(' '),
decoded: airportTimeAutoDecoded.join('\n')
});
}
// Find remarks
const rmkIndex = parts.indexOf('RMK');
const mainParts = rmkIndex !== -1 ? parts.slice(index, rmkIndex) : parts.slice(index);
// Intermediate sections
for (let i = 0; i < mainParts.length; i++) {
let part = mainParts[i];
// Check for mixed fraction visibility, e.g., "1 1/2SM" which is split into "1" and "1/2SM"
if (part.match(/^\d+$/) && (i + 1) < mainParts.length && mainParts[i+1].match(/^\d\/\dSM$/)) {
part = `${part} ${mainParts[i+1]}`;
i++; // Consume next part
}
if (part.endsWith('KT')) {
sections.push({ raw: part, decoded: decodeWind(part) });
} else if (part.match(/^\d{3}V\d{3}$/)) {
sections.push({ raw: part, decoded: decodeVariableWind(part) });
} else if (part.endsWith('SM') || part.match(/^\d+$/)) {
sections.push({ raw: part, decoded: decodeVisibility(part) });
} else if (part.match(/^([+-]|VC)?(MI|PR|BC|DR|BL|SH|TS|FZ|DZ|RA|SN|SG|IC|PL|GR|GS|UP|BR|FG|FU|VA|DU|SA|HZ|PY|PO|SQ|FC|SS|DS|TR)+$/)) {
sections.push({ raw: part, decoded: decodeWeather(part, metarString) });
} else if (part.match(/^(SKC|CLR|FEW|SCT|BKN|OVC)/)) {
sections.push({ raw: part, decoded: decodeClouds(part, metarString) });
} else if (part.match(/^(M?\d{2})\/(M?\d{2})$/)) {
sections.push({ raw: part, decoded: decodeTempDew(part) });
} else if (part.startsWith('A') && part.length === 5 && !isNaN(part.substring(1))) {
sections.push({ raw: part, decoded: decodeAltimeter(part) });
} else if (part.match(/^R\d{2}[LRC]?\//)) {
sections.push({ raw: part, decoded: decodeRunwayInfo(part) });
} else {
console.log(`Unknown main METAR part: ${part} in METAR: ${metarString}`);
sections.push({ raw: part, decoded: `${part}: Unknown METAR component` });
}
}
// Remarks section
if (rmkIndex !== -1) {
const remarkParts = parts.slice(rmkIndex + 1);
sections.push({
raw: `RMK ${remarkParts.join(' ')}`,
decoded: decodeRemarks(remarkParts, metarString)
});
}
return sections;
}
function displayNewMetar() {
if (metars.length === 0) {
metarDisplay.textContent = 'No METARs loaded.';
return;
}
// Bias random selection towards the more interesting (higher-scored) METARs at the start of the array.
// Math.random() * Math.random() skews distribution towards 0.
const randomIndex = Math.floor(Math.random() * Math.random() * metars.length);
const metarString = metars[randomIndex].metar;
currentMetarSections = generateMetarSections(metarString);
currentSectionIndex = 0;
updateDisplay();
}
fetch('metars.txt')
.then(response => response.text())
.then(data => {
const lines = data.split('\n');
const rawMetars = [];
lines.forEach(line => {
if (line.includes('METAR')) {
const metarString = line.substring(line.indexOf('METAR') + 6).replace('=', '').trim();
if (metarString) {
rawMetars.push(metarString);
}
}
});
// --- Rank METARs for interest ---
// 1. Calculate component frequencies for rarity scoring
const componentFrequencies = {};
rawMetars.forEach(metarString => {
const parts = getMetarParts(metarString);
parts.forEach(part => {
componentFrequencies[part] = (componentFrequencies[part] || 0) + 1;
});
});
// 2. Score and store each METAR
rawMetars.forEach(metarString => {
const score = calculateMetarScore(metarString, componentFrequencies, rawMetars.length);
metars.push({ metar: metarString, score: score });
});
// 3. Sort by score descending
metars.sort((a, b) => b.score - a.score);
console.log(`Loaded and ranked ${metars.length} METARs.`);
console.log(`Top 5 most interesting METARs:`);
for (let i = 0; i < Math.min(5, metars.length); i++) {
console.log(` - Score: ${metars[i].score.toFixed(2)}, METAR: ${metars[i].metar}`);
}
displayNewMetar(); // Display initial METAR
})
.catch(error => {
console.error('Error fetching or parsing metars.txt:', error)
metarDisplay.textContent = 'Could not load METARs.';
});
newMetarBtn.addEventListener('click', displayNewMetar);
decodeAllCheckbox.addEventListener('change', updateDisplay);
function updateDisplay() {
if (currentMetarSections.length === 0) return;
if (decodeAllCheckbox.checked) {
metarDisplay.innerHTML = currentMetarSections.map(s => s.raw).join(' ');
decodingDisplay.textContent = currentMetarSections.map(s => s.decoded).join('\n\n');
nextBtn.style.display = 'none';
} else {
// Build highlighted string
const parts = [];
currentMetarSections.forEach((section, index) => {
if (index === currentSectionIndex) {
parts.push(`<span class="highlight">${section.raw}</span>`);
} else if (index === currentSectionIndex + 1) {
parts.push(`<span class="next-to-decode">${section.raw}</span>`);
} else {
parts.push(section.raw);
}
});
metarDisplay.innerHTML = parts.join(' ');
// Show decoding
decodingDisplay.textContent = currentMetarSections[currentSectionIndex].decoded;
// Show/hide next button
nextBtn.style.display = (currentSectionIndex < currentMetarSections.length - 1) ? 'inline-block' : 'none';
}
}
nextBtn.addEventListener('click', () => {
if (currentSectionIndex < currentMetarSections.length - 1) {
currentSectionIndex++;
updateDisplay();
}
});
function testAllMetars() {
console.log(`--- Starting test of ${metars.length} METARs ---`);
let failed = 0;
let withUnknowns = 0;
const originalConsoleLog = console.log;
const unknowns = {
airports: new Set(),
weatherChunks: new Set(),
cloudInfos: new Set(),
cloudTypesInRemark: new Set(),
otherRemarks: new Set(),
};
metars.forEach(metarString => {
const tempLogs = [];
console.log = (...args) => {
tempLogs.push(args.join(' '));
};
try {
const sections = generateMetarSections(metarString);
if (sections.length === 0 && metarString.length > 0) {
throw new Error("Parser produced no sections");
}
const hasUnknowns = tempLogs.some(msg => msg.includes('Unknown') || msg.includes('Other remark'));
if (hasUnknowns) {
withUnknowns++;
originalConsoleLog(`METAR with decoding issues: ${metarString}`);
tempLogs.forEach(msg => {
originalConsoleLog(` -> ${msg}`);
if (msg.startsWith('Unknown Airport code:')) {
const code = msg.split(' ')[3];
unknowns.airports.add(code);
} else if (msg.startsWith('Unknown weather chunk:')) {
const chunk = msg.split(' ')[3];
unknowns.weatherChunks.add(chunk);
} else if (msg.startsWith('Unknown cloud information:')) {
const code = msg.split(' ')[3];
unknowns.cloudInfos.add(code);
} else if (msg.startsWith('Unknown cloud type in remark:')) {
const code = msg.split(' ')[5];
unknowns.cloudTypesInRemark.add(code);
} else if (msg.startsWith('Other remark:')) {
const remark = msg.split(' ')[2];
unknowns.otherRemarks.add(remark);
}
});
}
} catch (e) {
console.error("Failed to parse:", metarString, e);
failed++;
}
});
console.log = originalConsoleLog;
const total = metars.length;
const clean = total - failed - withUnknowns;
console.log(`--- Test finished ---`);
console.log(`Total METARs: ${total}`);
console.log(`Clean decodes: ${clean} (${(clean/total*100).toFixed(1)}%)`);
console.log(`Decodes with issues: ${withUnknowns} (${(withUnknowns/total*100).toFixed(1)}%)`);
console.log(`Failed to parse: ${failed} (${(failed/total*100).toFixed(1)}%)`);
console.log(`\n--- Unknowns encountered ---`);
for (const key in unknowns) {
if (unknowns[key].size > 0) {
console.log(`${key}:`);
console.log([...unknowns[key]].sort());
}
}
}
testAllBtn.addEventListener('click', testAllMetars);
</script>
</body>
</html>