feat: Implement interactive step-by-step METAR decoding
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
This commit is contained in:
+182
-55
@@ -24,6 +24,15 @@
|
|||||||
font-size: 1em;
|
font-size: 1em;
|
||||||
padding: 0.5em 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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -33,77 +42,157 @@
|
|||||||
<div id="metar-display"></div>
|
<div id="metar-display"></div>
|
||||||
<button id="new-metar-btn">New METAR</button>
|
<button id="new-metar-btn">New METAR</button>
|
||||||
<button id="test-all-btn">Test All METARs</button>
|
<button id="test-all-btn">Test All METARs</button>
|
||||||
|
<div id="decoding-display"></div>
|
||||||
|
<button id="next-btn" style="margin-top: 1em;">Next</button>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
const metarDisplay = document.getElementById('metar-display');
|
const metarDisplay = document.getElementById('metar-display');
|
||||||
|
const decodingDisplay = document.getElementById('decoding-display');
|
||||||
const newMetarBtn = document.getElementById('new-metar-btn');
|
const newMetarBtn = document.getElementById('new-metar-btn');
|
||||||
const testAllBtn = document.getElementById('test-all-btn');
|
const testAllBtn = document.getElementById('test-all-btn');
|
||||||
|
const nextBtn = document.getElementById('next-btn');
|
||||||
const metars = [];
|
const metars = [];
|
||||||
|
|
||||||
function parseMetar(metarString) {
|
let currentMetarSections = [];
|
||||||
const decoded = {};
|
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);
|
const parts = metarString.split(/\s+/).filter(Boolean);
|
||||||
let index = 0;
|
let index = 0;
|
||||||
|
|
||||||
// Airport
|
// 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}$/)) {
|
if (parts[index] && parts[index].length === 4 && parts[index].match(/^[A-Z][A-Z0-9]{3}$/)) {
|
||||||
decoded.airport = parts[index++];
|
airportTimeAutoRaw.push(parts[index]);
|
||||||
} else {
|
airportTimeAutoDecoded.push(decodeAirport(parts[index]));
|
||||||
console.error('Failed to parse airport in:', metarString);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Time
|
|
||||||
if (parts[index] && parts[index].endsWith('Z')) {
|
|
||||||
decoded.time = parts[index++];
|
|
||||||
} else {
|
|
||||||
console.error('Failed to parse time in:', metarString);
|
|
||||||
}
|
|
||||||
|
|
||||||
// AUTO
|
|
||||||
if (parts[index] && parts[index] === 'AUTO') {
|
|
||||||
decoded.auto = true;
|
|
||||||
index++;
|
index++;
|
||||||
}
|
}
|
||||||
|
if (parts[index] && parts[index].endsWith('Z')) {
|
||||||
// Wind
|
airportTimeAutoRaw.push(parts[index]);
|
||||||
if (parts[index] && parts[index].endsWith('KT')) {
|
airportTimeAutoDecoded.push(decodeTime(parts[index]));
|
||||||
decoded.wind = 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')
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// The rest is semi-ordered. Find remarks first.
|
// Find remarks
|
||||||
const remainingParts = parts.slice(index);
|
const rmkIndex = parts.indexOf('RMK');
|
||||||
const rmkIndex = remainingParts.indexOf('RMK');
|
const mainParts = rmkIndex !== -1 ? parts.slice(index, rmkIndex) : parts.slice(index);
|
||||||
let mainParts = remainingParts;
|
|
||||||
if (rmkIndex !== -1) {
|
// Intermediate sections
|
||||||
decoded.remarks = remainingParts.slice(rmkIndex + 1).join(' ');
|
|
||||||
mainParts = remainingParts.slice(0, rmkIndex);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Identify other parts from what's left.
|
|
||||||
const cloudRegex = /^(SKC|CLR|FEW|SCT|BKN|OVC)(\d{3})?(CB|TCU)?$/;
|
|
||||||
const tempDewRegex = /^(M?\d{2})\/(M?\d{2})$/;
|
|
||||||
const altimeterRegex = /^A(\d{4})$/;
|
|
||||||
|
|
||||||
decoded.weather = [];
|
|
||||||
decoded.clouds = [];
|
|
||||||
|
|
||||||
mainParts.forEach(part => {
|
mainParts.forEach(part => {
|
||||||
if (tempDewRegex.test(part)) {
|
if (part.endsWith('KT')) {
|
||||||
if (!decoded.temp_dew) decoded.temp_dew = part;
|
sections.push({ raw: part, decoded: decodeWind(part) });
|
||||||
} else if (altimeterRegex.test(part)) {
|
} else if (part.endsWith('SM') || part.match(/^\d+$/) || part.match(/^\d\/\dSM$/)) {
|
||||||
if (!decoded.altimeter) decoded.altimeter = part;
|
sections.push({ raw: part, decoded: decodeVisibility(part) });
|
||||||
} else if (cloudRegex.test(part)) {
|
} else if (part.match(/^(SKC|CLR|FEW|SCT|BKN|OVC)/)) {
|
||||||
decoded.clouds.push(part);
|
sections.push({ raw: part, decoded: decodeClouds(part) });
|
||||||
} else if (part.endsWith('SM') && !decoded.visibility) {
|
} else if (part.match(/^(M?\d{2})\/(M?\d{2})$/)) {
|
||||||
decoded.visibility = part;
|
sections.push({ raw: part, decoded: decodeTempDew(part) });
|
||||||
} else {
|
} else if (part.startsWith('A') && part.length === 5 && !isNaN(part.substring(1))) {
|
||||||
decoded.weather.push(part);
|
sections.push({ raw: part, decoded: decodeAltimeter(part) });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!decoded.altimeter) console.error('Failed to parse Altimeter in:', metarString);
|
// Remarks section
|
||||||
|
if (rmkIndex !== -1) {
|
||||||
|
const remarkParts = parts.slice(rmkIndex + 1);
|
||||||
|
sections.push({
|
||||||
|
raw: `RMK ${remarkParts.join(' ')}`,
|
||||||
|
decoded: decodeRemarks(remarkParts)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return decoded;
|
return sections;
|
||||||
}
|
}
|
||||||
|
|
||||||
function displayNewMetar() {
|
function displayNewMetar() {
|
||||||
@@ -113,9 +202,10 @@
|
|||||||
}
|
}
|
||||||
const randomIndex = Math.floor(Math.random() * metars.length);
|
const randomIndex = Math.floor(Math.random() * metars.length);
|
||||||
const metarString = metars[randomIndex];
|
const metarString = metars[randomIndex];
|
||||||
metarDisplay.textContent = metarString;
|
|
||||||
|
|
||||||
const decodedMetar = parseMetar(metarString);
|
currentMetarSections = generateMetarSections(metarString);
|
||||||
|
currentSectionIndex = 0;
|
||||||
|
updateDisplay();
|
||||||
}
|
}
|
||||||
|
|
||||||
fetch('metars.txt')
|
fetch('metars.txt')
|
||||||
@@ -140,12 +230,49 @@
|
|||||||
|
|
||||||
newMetarBtn.addEventListener('click', displayNewMetar);
|
newMetarBtn.addEventListener('click', displayNewMetar);
|
||||||
|
|
||||||
|
function updateDisplay() {
|
||||||
|
if (currentMetarSections.length === 0) return;
|
||||||
|
|
||||||
|
// 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() {
|
function testAllMetars() {
|
||||||
console.log(`--- Starting test of ${metars.length} METARs ---`);
|
console.log(`--- Starting test of ${metars.length} METARs ---`);
|
||||||
|
let failed = 0;
|
||||||
metars.forEach(metarString => {
|
metars.forEach(metarString => {
|
||||||
parseMetar(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 ---`);
|
console.log(`--- Test finished, ${failed} failures ---`);
|
||||||
}
|
}
|
||||||
|
|
||||||
testAllBtn.addEventListener('click', testAllMetars);
|
testAllBtn.addEventListener('click', testAllMetars);
|
||||||
|
|||||||
Reference in New Issue
Block a user