Files
test/metar.html
2025-11-27 22:25:36 +00:00

578 lines
24 KiB
HTML

<!DOCTYPE html>
<html>
<head>
<title>RLX METAR Cove</title>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<script src="https://code.jquery.com/ui/1.13.1/jquery-ui.js" integrity="sha256-6XMVI0zB8cRzfZjqKcD01PBsAy3FlDASrlC8SxCpInY=" crossorigin="anonymous"></script>
<link rel="stylesheet" href="https://code.jquery.com/ui/1.13.1/themes/smoothness/jquery-ui.css">
<script src="https://d3js.org/d3.v7.min.js"></script>
</head>
<body>
<style type="text/css">
#weatherGraph {
width: 100%;
height: 600px;
border: 1px solid black;
position: relative;
}
.icao-label {
text-orientation: horizontal;
font-size: 12px;
font-weight: bold;
fill: #333;
text-anchor: left;
/* Dominant-baseline helps center vertically */
dominant-baseline: middle;
}
.time-label { /* General class if needed */
font-size: 10px;
font-weight: bold;
fill: #333;
}
.time-day {
font-size: 10px;
fill: #333;
text-anchor: middle;
}
.time-hour {
font-size: 10px;
fill: #333;
text-anchor: middle;
}
.tooltip {
position: absolute;
display: none;
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 5px;
border-radius: 3px;
pointer-events: none;
font-size: 12px;
}
#timeLabels {
/* Increased height for 4 rows of text (Zulu Date, Zulu Hour, EST Date, EST Hour) */
height: 75px; /* ADJUSTED */
width: 100%;
}
.legend {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin: 10px 0 0 0;
width: 100%;
}
.legend-item {
display: inline-flex;
align-items: center;
gap: 5px;
}
.legend-color {
width: 20px;
height: 20px;
border: 1px solid #000;
}
</style>
<div id="metar">
<label for="start">Start Date/Time (Zulu):</label>
<input type="datetime-local" id="start" name="start" step="1">
<br><br>
<label for="end">End Date/Time (Zulu):</label>
<input type="datetime-local" id="end" name="end" step="1">
<button id="submitButton">Submit</button>
</div>
<div id="weatherGraph"></div>
<!-- Ensure the div height matches the CSS -->
<div id="timeLabels" style="width: 100%; height: 75px;"></div>
<div class="legend"></div>
<script>
function getUrlParams() {
const params = new URLSearchParams(window.location.search);
return {
start: params.get('start'),
end: params.get('end')
};
}
const startPicker = document.getElementById('start');
const endPicker = document.getElementById('end');
document.addEventListener('DOMContentLoaded', (event) => {
const urlParams = getUrlParams();
function adjustEndDate() {
if (startPicker.value) {
let startDate = new Date(startPicker.value + 'Z'); // Treat as Zulu
let endDate = endPicker.value ? new Date(endPicker.value + 'Z') : new Date(startDate); // Treat as Zulu
if (!endPicker.value || endDate < startDate) {
endDate = new Date(startDate);
}
// Keep same day/month/year relative to start if end is invalid or before start
if (endDate < startDate) {
endDate.setUTCDate(startDate.getUTCDate()); // Use UTC functions
endDate.setUTCMonth(startDate.getUTCMonth());
endDate.setUTCFullYear(startDate.getUTCFullYear());
}
// Format back to ISO string suitable for datetime-local, removing the 'Z'
let formattedEndDate = endDate.toISOString().slice(0, 19);
endPicker.value = formattedEndDate;
}
}
if (urlParams.start) {
const startDate = new Date(urlParams.start); // Assuming URL param is Zulu
// Check if date is valid before formatting
if (!isNaN(startDate)) {
startPicker.value = startDate.toISOString().slice(0, 19);
}
}
if (urlParams.end) {
const endDate = new Date(urlParams.end); // Assuming URL param is Zulu
if (!isNaN(endDate)) {
endPicker.value = endDate.toISOString().slice(0, 19);
}
}
if (startPicker.value && !endPicker.value) {
adjustEndDate(); // Adjust end date if start is set but end isn't
}
if (urlParams.start && urlParams.end) {
getValues();
}
startPicker.addEventListener('change', adjustEndDate);
});
function getmetars(startDateStr, endDateStr, startZulu, endZulu) {
const graphContainer = document.getElementById('weatherGraph');
const timeLabelsContainer = document.getElementById('timeLabels');
const legendContainer = document.querySelector('.legend');
const startTime = startZulu;
const endTime = endZulu;
// --- Clear previous state ---
graphContainer.innerHTML = "";
timeLabelsContainer.innerHTML = "";
// legendContainer.innerHTML = ""; // Clear legend at start of function or before calling generateLegend
// --- Date Validation ---
if (!startTime || !endTime || isNaN(startTime) || isNaN(endTime) || startTime >= endTime) {
graphContainer.innerHTML = "<p>Error: Invalid date range selected.</p>";
generateLegend(); // Show legend even on error
return;
}
// --- Fetch Data ---
$.getJSON(`lsr.php?metars=true&start=${startDateStr}&end=${endDateStr}`, function(weatherdata) {
// Ensure weatherdata is an array
weatherdata = weatherdata || [];
const icaos = [...new Set(weatherdata.map(data => data.icao))].sort();
const stationNames = {};
// Pre-process data: Convert times and group by ICAO
const dataByIcao = {};
icaos.forEach(icao => { dataByIcao[icao] = []; }); // Initialize empty array for each ICAO
weatherdata.forEach(data => {
if (data.icao && data.obtime) { // Basic check for valid data
stationNames[data.icao] = data.stationname;
data.obtimeDate = new Date(data.obtime); // Convert string time to Date object
if (dataByIcao[data.icao]) { // Add to the correct ICAO group
dataByIcao[data.icao].push(data);
}
}
});
// Sort data within each ICAO group by time (important for efficient search)
icaos.forEach(icao => {
dataByIcao[icao].sort((a, b) => a.obtimeDate - b.obtimeDate);
});
// --- Calculate graph dimensions ---
const totalMillis = endTime - startTime;
const hours = totalMillis / (1000 * 60 * 60);
if (hours <= 0) {
graphContainer.innerHTML = "<p>Error: End time must be after start time.</p>";
generateLegend();
return;
}
const containerWidth = graphContainer.clientWidth;
const containerHeight = graphContainer.clientHeight;
const hourWidth = containerWidth / hours;
const icaoHeight = icaos.length > 0 ? containerHeight / icaos.length : containerHeight;
const hourMillis = 1000 * 60 * 60;
// --- Create SVG & Tooltip ---
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute('width', containerWidth);
svg.setAttribute('height', containerHeight);
graphContainer.appendChild(svg);
const tooltip = document.createElement('div');
tooltip.className = 'tooltip';
document.body.appendChild(tooltip); // Append to body
// --- Draw Rectangles: Iterate through ICAOs and Time Slots ---
icaos.forEach((icao, index) => {
const y = index * icaoHeight;
const icaoSpecificData = dataByIcao[icao]; // Get pre-processed data for this ICAO
let dataPointer = 0; // Index to track position in sorted icaoSpecificData
// Draw horizontal line separator
const line = document.createElementNS("http://www.w3.org/2000/svg", "line");
line.setAttribute('x1', 0); line.setAttribute('y1', y);
line.setAttribute('x2', '100%'); line.setAttribute('y2', y);
line.setAttribute('stroke', '#e0e0e0'); // Lighter gray for less visual noise
svg.appendChild(line);
// Loop through each *hour slot* on the graph
for (let i = 0; i < Math.ceil(hours); i++) { // Use Math.ceil to cover partial last hour
const slotStartTimeMillis = startTime.getTime() + i * hourMillis;
const slotEndTimeMillis = slotStartTimeMillis + hourMillis;
const x = i * hourWidth;
// Find the *most relevant* observation for this time slot.
// Strategy: Use the *latest* observation whose time is *less than or equal to* the slot's END time.
// This represents the conditions reported that would influence this hour.
let relevantObservation = null;
// Advance pointer past observations before the current slot potentially starts
while (dataPointer < icaoSpecificData.length && icaoSpecificData[dataPointer].obtimeDate.getTime() < slotStartTimeMillis) {
dataPointer++;
}
// Check observations within or just before the slot
let searchIndex = dataPointer;
while (searchIndex < icaoSpecificData.length && icaoSpecificData[searchIndex].obtimeDate.getTime() < slotEndTimeMillis) {
relevantObservation = icaoSpecificData[searchIndex]; // Update with the latest found within range
searchIndex++;
}
// If no observation *within* the slot, check the one immediately preceding it (if pointer > 0)
if (relevantObservation === null && dataPointer > 0 && icaoSpecificData.length > 0) {
// Check the observation pointed to by dataPointer-1 (the last one before this slot started)
if (icaoSpecificData[dataPointer - 1].obtimeDate.getTime() < slotStartTimeMillis) {
// This observation occurred *before* the slot began, use it if nothing else found
// relevantObservation = icaoSpecificData[dataPointer - 1]; // Uncomment this line if you want the previous ob to fill the gap
}
}
let color;
let rawData = null;
let obTimeStr = null;
// Determine color based on whether an observation was found for this slot
if (relevantObservation) {
// An observation exists, use wxToColor to get Precip Color or Light Gray
color = wxToColor(relevantObservation.wx);
rawData = relevantObservation.raw;
obTimeStr = relevantObservation.obtime; // Use original time string for display
} else {
// NO observation record found relevant to this time slot
color = 'white'; // Missing observation
}
// Draw the rectangle for this slot
const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect");
rect.setAttribute('x', x);
rect.setAttribute('y', y);
// Ensure width doesn't exceed SVG boundary
rect.setAttribute('width', Math.min(hourWidth, containerWidth - x));
rect.setAttribute('height', icaoHeight);
rect.setAttribute('fill', color);
// Add border to white/light gray for visibility
if (color === 'white' || color === '#f0f0f0') {
rect.setAttribute('stroke', '#cccccc');
rect.setAttribute('stroke-width', '1');
}
// --- Tooltip Logic ---
rect.setAttribute('data-icao', icao);
if (relevantObservation) {
// Tooltip for existing observation
rect.setAttribute('data-raw', rawData);
rect.setAttribute('data-time', obTimeStr);
rect.addEventListener('mouseover', function(e) {
tooltip.innerHTML = `<b>${this.getAttribute('data-icao')}</b> (${this.getAttribute('data-time')})<br>${this.getAttribute('data-raw')}`;
tooltip.style.display = 'block';
});
} else {
// Tooltip for missing observation slot
const slotStartTime = new Date(slotStartTimeMillis);
const approxTimeStr = slotStartTime.toISOString().slice(11, 16) + "Z"; // HH:MMZ
const approxDateStr = `${slotStartTime.getUTCMonth() + 1}/${slotStartTime.getUTCDate()}`;
rect.setAttribute('data-time-slot', `${approxDateStr} ${approxTimeStr}`);
rect.addEventListener('mouseover', function(e) {
tooltip.innerHTML = `<b>${this.getAttribute('data-icao')}</b><br>No observation found for<br>${this.getAttribute('data-time-slot')} hour`;
tooltip.style.display = 'block';
});
}
// Common tooltip positioning and mouseout
rect.addEventListener('mousemove', function(e) {
// Position relative to page, offset from cursor
tooltip.style.left = (e.pageX + 15) + 'px';
tooltip.style.top = (e.pageY + 15) + 'px';
});
rect.addEventListener('mouseout', function() {
tooltip.style.display = 'none';
});
svg.appendChild(rect);
} // End of hour slot loop (i)
}); // End of ICAO loop (index)
// --- Draw ICAO Labels (Draw AFTER rectangles) ---
icaos.forEach((icao, index) => {
const y = index * icaoHeight;
const text = document.createElementNS("http://www.w3.org/2000/svg", "text");
text.textContent = icao; // Or stationNames[icao]
text.setAttribute('x', 5);
text.setAttribute('y', y + icaoHeight / 2); // Vertically center
text.setAttribute('class', 'icao-label'); // Ensure class with dominant-baseline is applied
svg.appendChild(text);
});
// --- Add final horizontal line at the bottom ---
if (icaos.length > 0) {
const finalY = containerHeight; // Bottom of the SVG
const finalLine = document.createElementNS("http://www.w3.org/2000/svg", "line");
finalLine.setAttribute('x1', 0); finalLine.setAttribute('y1', finalY);
finalLine.setAttribute('x2', '100%'); finalLine.setAttribute('y2', finalY);
finalLine.setAttribute('stroke', '#e0e0e0');
svg.appendChild(finalLine);
}
// --- Add Time Scale (No changes needed here) ---
const timeLabelsSVG = document.createElementNS("http://www.w3.org/2000/svg", "svg");
// ... (rest of time scale code is identical to previous correct version) ...
timeLabelsSVG.setAttribute('width', '100%');
timeLabelsSVG.setAttribute('height', '75'); // Match the container height
timeLabelsContainer.appendChild(timeLabelsSVG);
const yZuluDate = 15;
const yZuluHour = 30;
const yEstDate = 45;
const yEstHour = 60;
const estOffsetMillis = -5 * 60 * 60 * 1000; // UTC-5 in milliseconds
for (let i = 0; i <= hours; i++) { // Use <= hours to get label at the end time too
const x = i * hourWidth;
const tickTimeZulu = new Date(startTime.getTime() + i * hourMillis);
// --- Zulu Labels ---
const zuluDayText = document.createElementNS("http://www.w3.org/2000/svg", "text");
zuluDayText.textContent = `${tickTimeZulu.getUTCMonth() + 1}/${tickTimeZulu.getUTCDate()}`;
zuluDayText.setAttribute('x', x); zuluDayText.setAttribute('y', yZuluDate);
zuluDayText.setAttribute('class', 'time-day'); timeLabelsSVG.appendChild(zuluDayText);
const zuluHourText = document.createElementNS("http://www.w3.org/2000/svg", "text");
zuluHourText.textContent = `${String(tickTimeZulu.getUTCHours()).padStart(2, '0')}Z`;
zuluHourText.setAttribute('x', x); zuluHourText.setAttribute('y', yZuluHour);
zuluHourText.setAttribute('class', 'time-hour'); timeLabelsSVG.appendChild(zuluHourText);
// --- EST Labels (UTC-5) ---
const tickTimeEst = new Date(tickTimeZulu.getTime() + estOffsetMillis);
const estDayText = document.createElementNS("http://www.w3.org/2000/svg", "text");
estDayText.textContent = `${tickTimeEst.getUTCMonth() + 1}/${tickTimeEst.getUTCDate()}`;
estDayText.setAttribute('x', x); estDayText.setAttribute('y', yEstDate);
estDayText.setAttribute('class', 'time-day'); timeLabelsSVG.appendChild(estDayText);
const estHourText = document.createElementNS("http://www.w3.org/2000/svg", "text");
estHourText.textContent = `${String(tickTimeEst.getUTCHours()).padStart(2, '0')}E`;
estHourText.setAttribute('x', x); estHourText.setAttribute('y', yEstHour);
estHourText.setAttribute('class', 'time-hour'); timeLabelsSVG.appendChild(estHourText);
}
}).fail(function(jqXHR, textStatus, errorThrown) {
console.error("Failed to fetch METAR data:", textStatus, errorThrown);
graphContainer.innerHTML = `<p>Error fetching METAR data. Status: ${textStatus}.</p>`;
generateLegend(); // Show legend even on failure
}).always(function() {
// Final check to generate legend if it hasn't been done
if (!legendContainer.hasChildNodes()) {
generateLegend();
}
});
// Call generateLegend immediately so it shows while data loads/if errors occur before AJAX completes
generateLegend();
}
function wxToColor(wx) {
// --- This function is called ONLY when an observation record exists ---
// It determines the color based on the content of the 'wx' field.
// 1. Normalize the input string (handle null/undefined from DB field)
const normalizedWx = String(wx || "").toLowerCase().trim(); // Default to "" if wx is null/undefined
// 2. Handle cases indicating no precipitation or only other phenomena
if (normalizedWx === "") {
// Catches empty strings, null, undefined from the DB field for an existing record
return '#f0f0f0'; // Very light gray for No Reported WX / Clear
}
// 3. Check for specific precipitation types with intensity
// Helper functions (no changes needed)
const checkWx = (pattern) => {
const regex = new RegExp(`(^|\\s)[\\+\\-]?${pattern}(\\s|$)`);
return regex.test(normalizedWx);
};
const getIntensity = (pattern) => {
if (new RegExp(`(^|\\s)\\+${pattern}(\\s|$)`).test(normalizedWx)) return '+';
if (new RegExp(`(^|\\s)\\-${pattern}(\\s|$)`).test(normalizedWx)) return '-';
if (new RegExp(`(^|\\s)${pattern}(\\s|$)`).test(normalizedWx)) return '';
return null;
};
// Precipitation Checks (order matters)
let intensity = getIntensity('fzra|fzdz'); // Freezing Precip
if (intensity !== null) {
if (intensity === '+') return '#4b0082'; // Heavy FZRA/FZDZ
if (intensity === '-') return '#dda0dd'; // Light FZRA/FZDZ
return '#800080'; // Moderate FZRA/FZDZ
}
if (checkWx('blsn')) return 'red'; // Blowing Snow
intensity = getIntensity('sn'); // Snow
if (intensity !== null) {
if (intensity === '+') return '#00008b'; // Heavy SN
if (intensity === '-') return '#b0e0e6'; // Light SN
return '#4682b4'; // Moderate SN
}
intensity = getIntensity('pl|pe'); // Ice Pellets
if (intensity !== null) {
return 'pink'; // All PL intensity as pink
}
intensity = getIntensity('ra|dz'); // Rain/Drizzle
if (intensity !== null) {
if (intensity === '+') return '#006400'; // Heavy RA/DZ
if (intensity === '-') return '#90ee90'; // Light RA/DZ
return '#228b22'; // Moderate RA/DZ
}
if (checkWx('up')) return '#dda0dd'; // Unknown Precip
// 4. If the 'wx' field had content, but it didn't match any known precipitation,
// it represents other reported phenomena (FG, HZ, BR, clouds, etc.).
return '#f0f0f0'; // Very light gray for Other Reported WX (non-precip)
}
function getValues() {
let startDateStr = startPicker.value;
let endDateStr = endPicker.value;
if (!startDateStr || !endDateStr) {
alert("Please select both a start and end date/time.");
return;
}
// Convert input strings (assumed local but representing Zulu) to Zulu Date objects
// Appending 'Z' tells the Date constructor to parse it as UTC/Zulu
let startZulu = new Date(startDateStr + 'Z');
let endZulu = new Date(endDateStr + 'Z');
// Basic validation
if (isNaN(startZulu) || isNaN(endZulu)) {
alert("Invalid date format selected. Please check your input.");
console.error("Invalid Date object created:", startDateStr, endDateStr, startZulu, endZulu);
return;
}
if (startZulu >= endZulu) {
alert("Start date must be before the end date.");
return;
}
console.log("Raw Inputs:", startDateStr, endDateStr);
console.log("Parsed Zulu Dates:", startZulu, endZulu);
// Pass both the original strings (for PHP) and the Date objects (for JS)
getmetars(startDateStr, endDateStr, startZulu, endZulu);
}
document.getElementById('submitButton').addEventListener('click', getValues);
function generateLegend() {
const legendContainer = document.querySelector('.legend');
legendContainer.innerHTML = ''; // Clear previous legend items
// Define the very light gray color
const noPrecipColor = '#f0f0f0';
const legendData = [
// Grouped by Precipitation Type
{ group: 'Freezing', items: [
{ label: '-FZRA/DZ', color: '#dda0dd'},
{ label: 'FZRA/DZ', color: '#800080'},
{ label: '+FZRA/DZ', color: '#4b0082'}
]},
{ group: 'Snow', items: [
{ label: '-SN', color: '#b0e0e6'},
{ label: 'SN', color: '#4682b4'},
{ label: '+SN', color: '#00008b'}
]},
{ group: 'Ice Pellets', items: [
{ label: 'PL/PE', color: 'pink'}
]},
{ group: 'Rain/Drizzle', items: [
{ label: '-RA/DZ', color: '#90ee90'},
{ label: 'RA/DZ', color: '#228b22'},
{ label: '+RA/DZ', color: '#006400'}
]},
// Other Phenomena
{ group: 'Other WX', items: [
{ label: 'BLSN', color: 'red'},
{ label: 'UP', color: '#dda0dd'}
]},
// Status/Misc
{ group: 'Status', items: [
// Use the specific very light gray color here
{ label: 'No Precip / Other WX', color: noPrecipColor },
{ label: 'Missing Ob', color: 'white' }
]}
];
legendData.forEach(groupData => {
groupData.items.forEach(item => {
const legendItem = document.createElement('div');
legendItem.className = 'legend-item';
const colorBox = document.createElement('div');
colorBox.className = 'legend-color';
colorBox.style.backgroundColor = item.color;
// Add border to white and very light gray boxes so they are visible
if (item.color === 'white' || item.color === noPrecipColor) {
colorBox.style.borderColor = '#ccc'; // Use a light border for contrast
} else {
colorBox.style.borderColor = '#000'; // Keep black border for colored boxes
}
legendItem.appendChild(colorBox);
const label = document.createElement('span');
label.textContent = item.label;
legendItem.appendChild(label);
legendContainer.appendChild(legendItem);
});
// Optional spacer can still be added here if desired
});
}
// Call generateLegend once on load to show it initially
generateLegend();
</script>
</body>
</html>