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:
+138
-4
@@ -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 => {
|
||||||
|
|||||||
Reference in New Issue
Block a user