feat: Implement METAR ranking and bias display towards interesting reports

Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
This commit is contained in:
2026-02-14 17:01:12 -07:00
parent b7a66a034b
commit a85ed5c11d
+138 -4
View File
@@ -67,6 +67,111 @@
let currentMetarSections = []; let currentMetarSections = [];
let currentSectionIndex = 0; 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 --- // --- Decoding Functions ---
function decodeAirport(code, metarString) { function decodeAirport(code, metarString) {
@@ -619,8 +724,10 @@
metarDisplay.textContent = 'No METARs loaded.'; metarDisplay.textContent = 'No METARs loaded.';
return; return;
} }
const randomIndex = Math.floor(Math.random() * metars.length); // Bias random selection towards the more interesting (higher-scored) METARs at the start of the array.
const metarString = metars[randomIndex]; // 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); currentMetarSections = generateMetarSections(metarString);
currentSectionIndex = 0; currentSectionIndex = 0;
@@ -631,15 +738,42 @@
.then(response => response.text()) .then(response => response.text())
.then(data => { .then(data => {
const lines = data.split('\n'); const lines = data.split('\n');
const rawMetars = [];
lines.forEach(line => { lines.forEach(line => {
if (line.includes('METAR')) { if (line.includes('METAR')) {
const metarString = line.substring(line.indexOf('METAR') + 6).replace('=', '').trim(); const metarString = line.substring(line.indexOf('METAR') + 6).replace('=', '').trim();
if (metarString) { if (metarString) {
metars.push(metarString); rawMetars.push(metarString);
} }
} }
}); });
console.log(`Loaded ${metars.length} METARs.`);
// --- 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 displayNewMetar(); // Display initial METAR
}) })
.catch(error => { .catch(error => {