Files
html-tools/metar.html
T
2026-02-14 15:02:35 -07:00

292 lines
11 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;
}
@media (max-width: 600px) {
#metar-display {
min-height: 6em;
}
}
button {
font-size: 1em;
padding: 0.5em 1em;
}
.highlight {
background-color: yellow;
}
#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>
<button id="test-all-btn">Test All METARs</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>
</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;
// --- Decoding Functions ---
function decodeAirport(code) {
if (code === 'CYYC') return "CYYC: Calgary International Airport";
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}: On the ${day}th day of the month 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 decodeVisibility(code) {
const vis = code.replace('SM', '');
return `${code}: Visibility ${vis} statute miles`;
}
function decodeClouds(code) {
const coverMap = {
'SKC': 'Sky clear',
'FEW': 'Few clouds',
'SCT': 'Scattered clouds',
'BKN': 'Broken clouds',
'OVC': 'Overcast clouds'
};
const cover = coverMap[code.substring(0, 3)];
if (!cover) return `${code}: Unknown cloud information`;
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 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 decodeRemarks(parts) {
let decoded = ["RMK: Remarks"];
parts.forEach(part => {
if (part.startsWith('SLP')) {
decoded.push(` - ${decodeSlp(part)}`);
} else if (part.match(/^(AC|CI|CC)/)) {
decoded.push(` - ${part}: Cloud types and coverage details`);
} else {
decoded.push(` - ${part}: Other remark`);
}
});
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]));
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
mainParts.forEach(part => {
if (part.endsWith('KT')) {
sections.push({ raw: part, decoded: decodeWind(part) });
} else if (part.endsWith('SM') || part.match(/^\d+$/) || part.match(/^\d\/\dSM$/)) {
sections.push({ raw: part, decoded: decodeVisibility(part) });
} else if (part.match(/^(SKC|CLR|FEW|SCT|BKN|OVC)/)) {
sections.push({ raw: part, decoded: decodeClouds(part) });
} 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) });
}
});
// Remarks section
if (rmkIndex !== -1) {
const remarkParts = parts.slice(rmkIndex + 1);
sections.push({
raw: `RMK ${remarkParts.join(' ')}`,
decoded: decodeRemarks(remarkParts)
});
}
return sections;
}
function displayNewMetar() {
if (metars.length === 0) {
metarDisplay.textContent = 'No METARs loaded.';
return;
}
const randomIndex = Math.floor(Math.random() * metars.length);
const metarString = metars[randomIndex];
currentMetarSections = generateMetarSections(metarString);
currentSectionIndex = 0;
updateDisplay();
}
fetch('metars.txt')
.then(response => response.text())
.then(data => {
const lines = data.split('\n');
lines.forEach(line => {
if (line.includes('METAR')) {
const metarString = line.substring(line.indexOf('METAR') + 6).replace('=', '').trim();
if (metarString) {
metars.push(metarString);
}
}
});
console.log(`Loaded ${metars.length} METARs.`);
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 {
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;
metars.forEach(metarString => {
try {
const sections = generateMetarSections(metarString);
if (sections.length === 0 && metarString.length > 0) {
throw new Error("Parser produced no sections");
}
} catch (e) {
console.error("Failed to parse:", metarString, e);
failed++;
}
});
console.log(`--- Test finished, ${failed} failures ---`);
}
testAllBtn.addEventListener('click', testAllMetars);
</script>
</body>
</html>