Refactor SPA: extract CSS/JS to modular files, add static file serving, fix timestamp parsing

This commit is contained in:
2025-12-24 00:11:09 +10:00
parent 0a29c2d0c1
commit 1a257931e8
21 changed files with 2471 additions and 2158 deletions
+3
View File
@@ -30,6 +30,9 @@ if (enableHttpsRedirection)
// Enable routing (required for CORS to work with controllers)
app.UseRouting();
// Static files (for wwwroot assets like CSS, JS, images)
app.UseStaticFiles();
// CORS middleware - MUST be after UseRouting but before MapControllers
app.UseCors();
+9
View File
@@ -406,8 +406,17 @@ public class BomRadarService : IBomRadarService, IDisposable
}
}
// Safely release semaphore - it may be disposed during application shutdown
try
{
semaphore.Release();
}
catch (ObjectDisposedException)
{
// Semaphore was disposed during shutdown, ignore
_logger.LogDebug("Semaphore was disposed, skipping release");
}
}
}
public async Task<LastUpdatedInfo?> GetLastUpdatedInfoAsync(string suburb, string state, CancellationToken cancellationToken = default)
+19 -7
View File
@@ -190,20 +190,32 @@ public class TimeParsingService : ITimeParsingService
}
}
// Determine the timezone - use configured timezone if not specified or if it's AEST/AEDT
// Determine the timezone - map AEST/AEDT based on what the BOM website displays
// Note: The BOM website shows the timezone abbreviation for the location being viewed,
// NOT the browser's configured timezone. The browser timezone only affects JavaScript Date operations.
TimeZoneInfo tz = _timeZoneInfo;
if (!string.IsNullOrEmpty(timezoneStr))
{
// AEST/AEDT are typically the same as Australia/Brisbane or Australia/Sydney
// Both Brisbane and Sydney use AEST/AEDT
if (timezoneStr.Contains("AEST", StringComparison.OrdinalIgnoreCase) ||
timezoneStr.Contains("AEDT", StringComparison.OrdinalIgnoreCase))
// Map timezone abbreviations to actual timezones:
// - "AEST" = UTC+10 (Australian Eastern Standard Time)
// Used by Brisbane year-round, and by Sydney/Melbourne during winter
// Since both are UTC+10 when showing AEST, we use Brisbane timezone (UTC+10 year-round)
// - "AEDT" = UTC+11 (Australian Eastern Daylight Time)
// Used by Sydney/Melbourne during summer (Oct-Apr), never Brisbane
// We use Sydney timezone which handles the UTC+11 offset correctly
if (timezoneStr.Contains("AEST", StringComparison.OrdinalIgnoreCase))
{
tz = _timeZoneInfo; // Use configured timezone
// AEST maps to Brisbane timezone (UTC+10) - works for both Brisbane and Sydney in winter
tz = TimeZoneInfo.FindSystemTimeZoneById("Australia/Brisbane");
}
else if (timezoneStr.Contains("AEDT", StringComparison.OrdinalIgnoreCase))
{
// AEDT maps to Sydney timezone (UTC+11) - only appears in summer for Sydney/Melbourne
tz = TimeZoneInfo.FindSystemTimeZoneById("Australia/Sydney");
}
else
{
// Try to find the timezone, fallback to configured
// Try to find the timezone by name, fallback to configured browser timezone
try
{
tz = TimeZoneInfo.FindSystemTimeZoneById(timezoneStr);
File diff suppressed because it is too large Load Diff
+72 -9
View File
@@ -1,29 +1,92 @@
#!/bin/bash
set -e
# Function to cleanup stale Xvfb processes and lock files
cleanup_xvfb() {
# Kill any stale Xvfb processes for display :99
pkill -f "Xvfb :99" 2>/dev/null || true
sleep 0.5
# Remove stale lock files
rm -f /tmp/.X99-lock /tmp/.X11-unix/X99 2>/dev/null || true
}
# Function to check if Xvfb is actually functional
check_xvfb_working() {
# Check if process exists and is running (not zombie)
local pid=$(pgrep -f "Xvfb :99" | head -n1)
if [ -z "$pid" ]; then
return 1
fi
# Check if process is actually running (not a zombie)
if ! kill -0 "$pid" 2>/dev/null; then
return 1
fi
return 0
}
# Function to start Xvfb
start_xvfb() {
if ! pgrep -f "Xvfb :99" > /dev/null; then
# Clean up any stale processes/locks first
cleanup_xvfb
if ! check_xvfb_working; then
echo "Starting Xvfb on display :99..."
Xvfb :99 -screen 0 1920x1080x24 -ac +extension GLX +render -noreset > /dev/null 2>&1 &
sleep 1
if ! pgrep -f "Xvfb :99" > /dev/null; then
sleep 1.5
if check_xvfb_working; then
echo "Xvfb started successfully"
return 0
else
echo "ERROR: Xvfb failed to start" >&2
return 1
fi
echo "Xvfb started successfully"
fi
return 0
}
# Function to monitor and restart Xvfb if it dies (runs as background process)
monitor_xvfb() {
local consecutive_failures=0
local max_backoff=60 # Maximum backoff in seconds
local backoff_seconds=5
# Monitor while PID 1 (dotnet after exec) is still running
while kill -0 1 2>/dev/null; do
sleep 5
if ! pgrep -f "Xvfb :99" > /dev/null; then
echo "WARNING: Xvfb process not found, restarting..." >&2
start_xvfb || true
sleep $backoff_seconds
if ! check_xvfb_working; then
consecutive_failures=$((consecutive_failures + 1))
if [ $consecutive_failures -eq 1 ]; then
echo "WARNING: Xvfb not working, attempting restart..." >&2
elif [ $consecutive_failures -le 3 ]; then
echo "WARNING: Xvfb restart attempt $consecutive_failures..." >&2
elif [ $((consecutive_failures % 10)) -eq 0 ]; then
echo "ERROR: Xvfb has failed to start $consecutive_failures times. Display may be unavailable." >&2
fi
if start_xvfb; then
consecutive_failures=0
backoff_seconds=5
else
# Exponential backoff, capped at max_backoff
backoff_seconds=$((backoff_seconds * 2))
if [ $backoff_seconds -gt $max_backoff ]; then
backoff_seconds=$max_backoff
fi
fi
else
# Xvfb is working, reset failure counter
if [ $consecutive_failures -gt 0 ]; then
echo "Xvfb is now working" >&2
consecutive_failures=0
backoff_seconds=5
fi
fi
done
}
@@ -31,7 +94,7 @@ monitor_xvfb() {
# Cleanup function
cleanup() {
echo "Shutting down Xvfb..."
pkill -f "Xvfb :99" || true
cleanup_xvfb
}
# Set up signal handlers for cleanup
+288
View File
@@ -0,0 +1,288 @@
.info-section {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
background: #f8f9fa;
border-radius: 12px;
padding: 15px;
}
@media (min-width: 480px) {
.info-section {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 768px) {
.info-section {
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
padding: 20px;
}
}
.radar-section {
background: #f8f9fa;
border-radius: 12px;
padding: 15px;
}
@media (min-width: 768px) {
.radar-section {
padding: 20px;
}
}
.radar-image-container {
position: relative;
width: 100%;
background: #000;
border-radius: 8px;
overflow: hidden;
margin-bottom: 15px;
aspect-ratio: 16/9;
min-height: 250px; /* Smaller on mobile */
}
@media (min-width: 768px) {
.radar-image-container {
margin-bottom: 20px;
min-height: 400px;
}
}
.radar-image {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: contain;
display: block;
image-rendering: crisp-edges; /* Prevent blurry scaling */
}
.loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: white;
font-size: 1.2em;
}
.frame-controls {
display: flex;
gap: 8px;
align-items: center;
justify-content: center;
flex-wrap: wrap;
margin-bottom: 15px;
}
@media (min-width: 768px) {
.frame-controls {
gap: 10px;
margin-bottom: 20px;
}
}
.frame-btn {
padding: 10px 20px;
border: 2px solid #667eea;
background: white;
color: #667eea;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
transition: all 0.2s;
font-size: 0.9em;
}
.frame-btn:hover {
background: #667eea;
color: white;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.frame-btn.active {
background: #667eea;
color: white;
}
.frame-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.play-controls {
display: flex;
gap: 8px;
justify-content: center;
margin-bottom: 15px;
flex-wrap: wrap;
}
@media (min-width: 768px) {
.play-controls {
gap: 10px;
margin-bottom: 20px;
}
}
.play-btn {
padding: 12px 20px;
border: none;
background: #667eea;
color: white;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
font-size: 0.9em;
transition: all 0.2s;
min-height: 44px; /* Touch target size */
flex: 1;
min-width: 100px;
white-space: nowrap;
}
@media (min-width: 480px) {
.play-btn {
flex: 0 1 auto;
min-width: auto;
}
}
@media (min-width: 768px) {
.play-btn {
padding: 12px 30px;
font-size: 1em;
}
}
.play-btn:hover {
background: #5568d3;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.play-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.info-card {
background: white;
border-radius: 8px;
padding: 12px;
border-left: 4px solid #667eea;
}
@media (min-width: 768px) {
.info-card {
padding: 15px;
}
}
.info-card h3 {
font-size: 0.8em;
text-transform: uppercase;
color: #666;
margin-bottom: 6px;
letter-spacing: 0.5px;
}
@media (min-width: 768px) {
.info-card h3 {
font-size: 0.9em;
margin-bottom: 8px;
}
}
.info-card .value {
font-size: 1em;
font-weight: 600;
color: #333;
word-wrap: break-word;
}
@media (min-width: 768px) {
.info-card .value {
font-size: 1.2em;
}
}
.status-badge {
display: inline-block;
padding: 4px 12px;
border-radius: 12px;
font-size: 0.85em;
font-weight: 600;
margin-top: 5px;
}
.status-valid {
background: #d4edda;
color: #155724;
}
.status-invalid {
background: #f8d7da;
color: #721c24;
}
.status-updating {
background: #fff3cd;
color: #856404;
}
.error {
background: #f8d7da;
color: #721c24;
padding: 15px;
border-radius: 8px;
margin: 20px;
border-left: 4px solid #dc3545;
animation: fadeIn 0.3s;
}
.error.retrying {
background: #fff3cd;
color: #856404;
border-left-color: #ffc107;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.timestamp {
font-size: 0.85em;
color: #666;
margin-top: 5px;
}
.refresh-indicator {
display: inline-block;
width: 12px;
height: 12px;
border-radius: 50%;
background: #28a745;
margin-right: 8px;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.frame-info {
text-align: center;
color: #666;
font-size: 0.9em;
margin-top: 10px;
}
+275
View File
@@ -0,0 +1,275 @@
/* Frame Slider Controls */
.frame-slider-container {
width: 100%;
max-width: 100%;
padding: 15px;
background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%);
border-radius: 12px;
border: 2px solid #e0e0e0;
box-shadow: 0 4px 16px rgba(0,0,0,0.08);
box-sizing: border-box;
margin-bottom: 15px;
}
@media (min-width: 768px) {
.frame-slider-container {
padding: 24px;
border-radius: 16px;
margin-bottom: 20px;
}
}
.frame-slider-wrapper {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 12px;
width: 100%;
flex-wrap: wrap;
}
@media (min-width: 480px) {
.frame-slider-wrapper {
gap: 6px;
flex-wrap: nowrap;
}
}
@media (min-width: 768px) {
.frame-slider-wrapper {
gap: 12px;
margin-bottom: 20px;
}
}
.frame-nav-btn {
padding: 10px 16px;
border: 2px solid #667eea;
background: white;
color: #667eea;
border-radius: 10px;
cursor: pointer;
font-weight: 700;
font-size: 0.9em;
transition: all 0.2s;
min-width: 50px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.frame-nav-btn:hover:not(:disabled) {
background: #667eea;
color: white;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.frame-nav-btn:active:not(:disabled) {
transform: translateY(0);
}
.frame-nav-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
background: #f0f0f0;
border-color: #d0d0d0;
color: #999;
}
.frame-slider {
flex: 1;
min-width: 120px;
height: 10px;
border-radius: 5px;
background: #e0e0e0;
outline: none;
-webkit-appearance: none;
max-width: 100%;
cursor: pointer;
transition: background 0.2s;
width: 100%;
order: 3; /* Put slider in middle on mobile when wrapped */
}
@media (min-width: 480px) {
.frame-slider {
min-width: 100px;
order: 0; /* Reset order for larger screens */
}
}
@media (min-width: 768px) {
.frame-slider {
min-width: 0;
}
}
.frame-slider:hover {
background: #d0d0d0;
}
/* Webkit (Chrome, Safari, Edge) */
.frame-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 24px;
height: 24px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
cursor: pointer;
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.4);
transition: all 0.2s;
border: 3px solid white;
}
.frame-slider::-webkit-slider-thumb:hover {
transform: scale(1.15);
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.6);
}
.frame-slider::-webkit-slider-thumb:active {
transform: scale(1.05);
}
/* Track fill for webkit */
.frame-slider::-webkit-slider-runnable-track {
height: 10px;
border-radius: 5px;
background: linear-gradient(to right, #667eea 0%, #764ba2 100%);
}
/* Firefox */
.frame-slider::-moz-range-thumb {
width: 24px;
height: 24px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
cursor: pointer;
border: 3px solid white;
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.4);
transition: all 0.2s;
}
.frame-slider::-moz-range-thumb:hover {
transform: scale(1.15);
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.6);
}
.frame-slider::-moz-range-thumb:active {
transform: scale(1.05);
}
.frame-slider::-moz-range-track {
height: 10px;
border-radius: 5px;
background: #e0e0e0;
}
.frame-slider::-moz-range-progress {
height: 10px;
border-radius: 5px;
background: linear-gradient(to right, #667eea 0%, #764ba2 100%);
}
.frame-info-display {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
padding-top: 12px;
border-top: 1px solid #e0e0e0;
}
@media (min-width: 480px) {
.frame-info-display {
grid-template-columns: repeat(2, 1fr);
gap: 16px;
padding-top: 16px;
}
}
@media (min-width: 768px) {
.frame-info-display {
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}
}
.frame-info-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.85em;
}
@media (min-width: 768px) {
.frame-info-item {
gap: 8px;
font-size: 0.95em;
}
}
.frame-info-label {
color: #667eea;
font-weight: 600;
font-size: 0.8em;
text-transform: uppercase;
letter-spacing: 0.5px;
min-width: 60px;
}
@media (min-width: 768px) {
.frame-info-label {
font-size: 0.9em;
min-width: 70px;
}
}
.frame-info-value {
color: #333;
font-weight: 700;
font-size: 0.95em;
}
@media (min-width: 768px) {
.frame-info-value {
font-size: 1.1em;
}
}
.frame-jump-input {
width: 70px;
padding: 10px 12px;
border: 2px solid #667eea;
border-radius: 8px;
font-size: 0.9em;
text-align: center;
font-weight: 700;
color: #333;
background: white;
transition: all 0.2s;
min-height: 44px; /* Touch target size */
box-sizing: border-box;
}
@media (min-width: 768px) {
.frame-jump-input {
padding: 8px 12px;
font-size: 1em;
min-height: auto;
}
}
.frame-jump-input:focus {
outline: none;
border-color: #764ba2;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.15);
background: #f8f9ff;
}
.frame-jump-input::-webkit-inner-spin-button,
.frame-jump-input::-webkit-outer-spin-button {
opacity: 1;
height: 20px;
}
+122
View File
@@ -0,0 +1,122 @@
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 10px;
color: #333;
}
@media (min-width: 768px) {
body {
padding: 20px;
}
}
.container {
max-width: 1400px;
margin: 0 auto;
background: white;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
overflow: hidden;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 15px;
}
@media (min-width: 768px) {
.header {
padding: 30px;
gap: 20px;
}
}
.header-content {
flex: 1;
text-align: center;
min-width: 0;
}
.header h1 {
font-size: 1.5em;
margin-bottom: 8px;
font-weight: 700;
word-wrap: break-word;
}
@media (min-width: 768px) {
.header h1 {
font-size: 2.5em;
margin-bottom: 10px;
}
}
.header .subtitle {
font-size: 0.9em;
opacity: 0.9;
}
@media (min-width: 768px) {
.header .subtitle {
font-size: 1.1em;
}
}
.header-actions {
display: flex;
gap: 10px;
align-items: center;
}
.settings-header-btn {
background: rgba(255, 255, 255, 0.2);
border: 2px solid rgba(255, 255, 255, 0.3);
color: white;
padding: 10px 16px;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
font-size: 0.9em;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 6px;
min-height: 44px; /* Touch target size */
}
@media (min-width: 768px) {
.settings-header-btn {
padding: 12px 24px;
font-size: 1em;
gap: 8px;
}
}
.settings-header-btn:hover {
background: rgba(255, 255, 255, 0.3);
border-color: rgba(255, 255, 255, 0.5);
transform: translateY(-2px);
}
.content {
display: flex;
flex-direction: column;
gap: 15px;
padding: 15px;
}
@media (min-width: 768px) {
.content {
gap: 20px;
padding: 30px;
}
}
+6
View File
@@ -0,0 +1,6 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
+5
View File
@@ -0,0 +1,5 @@
@import url('./reset.css');
@import url('./layout.css');
@import url('./components.css');
@import url('./frame-slider.css');
+382
View File
@@ -0,0 +1,382 @@
// API client for fetching radar data
import { API_BASE } from './config.js';
import { formatDate, isNetworkError, isNetworkErrorResponse, createCacheStatusData, triggerBackgroundRefresh } from './utils.js';
import { state } from './state.js';
/**
* Fetch cache range information
*/
export async function fetchCacheRange() {
try {
const response = await fetch(`${API_BASE.replace('/api/radar', '/api/cache')}/range`, {
signal: AbortSignal.timeout(10000)
});
if (!response.ok) {
if (isNetworkErrorResponse(response)) {
throw new Error('Network error');
}
console.warn('Failed to fetch cache range');
return { data: null, error: null };
}
const data = await response.json();
state.cacheRangeInfo = data;
// Update UI
const rangeTextEl = document.getElementById('cache-range-text');
if (rangeTextEl) {
if (data.totalCacheFolders > 0) {
const spanHours = data.timeSpanMinutes ? Math.round(data.timeSpanMinutes / 60 * 10) / 10 : 0;
rangeTextEl.innerHTML = `
<div>Oldest: ${formatDate(data.oldestCache.cacheTimestamp)}</div>
<div>Newest: ${formatDate(data.newestCache.cacheTimestamp)}</div>
<div>Total: ${data.totalCacheFolders} cache folders (${spanHours} hours)</div>
`;
} else {
rangeTextEl.textContent = 'No cache data available';
}
}
return { data: data, error: null };
} catch (error) {
console.error('Error fetching cache range:', error);
const rangeTextEl = document.getElementById('cache-range-text');
if (rangeTextEl) {
rangeTextEl.textContent = 'Unable to load cache range';
}
if (isNetworkError(error)) {
return { data: null, error: 'network' };
}
return { data: null, error: null };
}
}
/**
* Fetch historical radar data for extended timespan
*/
export async function fetchHistoricalRadar(startTime, endTime) {
try {
let url = `${API_BASE}/timeseries`;
const params = new URLSearchParams();
if (startTime) params.append('startTime', startTime.toISOString());
if (endTime) params.append('endTime', endTime.toISOString());
if (params.toString()) url += '?' + params.toString();
const response = await fetch(url, {
signal: AbortSignal.timeout(30000) // Longer timeout for potentially large responses
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: `HTTP ${response.status}` }));
// Handle 400 Bad Request
if (response.status === 400) {
const errorMessage = errorData.message || 'Invalid request';
let details = '';
if (errorData.errorCode === 'TIME_RANGE_ERROR' && errorData.details?.requestedRange) {
const requested = errorData.details.requestedRange;
if (requested.requestedHours && errorData.details.maxHours) {
details = ` Requested: ${requested.requestedHours.toFixed(1)} hours, Maximum: ${errorData.details.maxHours} hours.`;
}
}
throw new Error(errorMessage + details);
}
// Handle 404 Not Found
if (response.status === 404) {
if (errorData.errorCode === 'CACHE_NOT_FOUND') {
triggerBackgroundRefresh(errorData.suggestions?.refreshEndpoint);
return {
frames: null,
error: 'location_missing',
cacheStatus: errorData.details || errorData
};
} else if (errorData.errorCode === 'TIME_RANGE_ERROR' && errorData.details?.availableRange) {
const availableRange = errorData.details.availableRange;
let rangeMessage = '';
if (errorData.suggestions?.suggestedRange) {
const suggested = errorData.suggestions.suggestedRange;
rangeMessage = ` Suggested range: ${formatDate(suggested.start)} to ${formatDate(suggested.end)}.`;
} else if (availableRange.oldest && availableRange.newest) {
rangeMessage = ` Available data: ${formatDate(availableRange.oldest)} to ${formatDate(availableRange.newest)}.`;
} else if (availableRange.totalCacheFolders > 0) {
rangeMessage = ` ${availableRange.totalCacheFolders} cache folders available.`;
}
if (errorData.suggestions?.suggestion) {
rangeMessage += ` ${errorData.suggestions.suggestion}`;
}
throw new Error(errorData.message + rangeMessage);
} else {
throw new Error(errorData.message || 'No historical data found');
}
}
throw new Error(errorData.message || 'Failed to fetch historical radar');
}
const data = await response.json();
state.historicalData = data;
// Flatten all frames from all cache folders
const allFrames = [];
data.cacheFolders.forEach(cacheFolder => {
cacheFolder.frames.forEach(frame => {
frame.cacheTimestamp = cacheFolder.cacheTimestamp;
frame.observationTime = cacheFolder.observationTime;
frame.cacheFolderName = cacheFolder.cacheFolderName;
allFrames.push(frame);
});
});
// Filter out frames without absoluteObservationTime
const invalidFrames = allFrames.filter(frame => !frame.absoluteObservationTime);
if (invalidFrames.length > 0) {
console.error('Backend returned frames missing absoluteObservationTime:', {
count: invalidFrames.length,
frames: invalidFrames.map((f, idx) => ({
index: idx,
frameIndex: f.frameIndex,
cacheFolder: f.cacheFolderName,
imageUrl: f.imageUrl
}))
});
}
const validFrames = allFrames.filter(frame => frame.absoluteObservationTime);
// Validate chronological order
let previousTime = null;
const outOfOrderFrames = [];
validFrames.forEach((frame, idx) => {
const currentTime = new Date(frame.absoluteObservationTime).getTime();
if (previousTime !== null && currentTime < previousTime) {
outOfOrderFrames.push({
index: idx,
frameIndex: frame.frameIndex,
cacheFolder: frame.cacheFolderName,
absoluteTime: frame.absoluteObservationTime,
previousTime: new Date(previousTime).toISOString(),
timeDiffMinutes: (currentTime - previousTime) / 1000 / 60
});
}
previousTime = currentTime;
});
if (outOfOrderFrames.length > 0) {
console.error('Backend returned frames out of chronological order:', {
count: outOfOrderFrames.length,
issues: outOfOrderFrames
});
}
// Re-index frames sequentially for display
validFrames.forEach((frame, idx) => {
frame.sequentialIndex = idx;
});
return { frames: validFrames, error: null };
} catch (error) {
console.error('Error fetching historical radar:', error);
if (isNetworkError(error)) {
return { frames: null, error: 'network' };
}
return { frames: [], error: null };
}
}
/**
* Fetch metadata separately
*/
export async function fetchMetadata() {
try {
const response = await fetch(`${API_BASE}/metadata`, {
signal: AbortSignal.timeout(5000)
});
if (response.ok) {
return await response.json();
}
} catch (error) {
console.debug('Could not fetch metadata:', error);
}
return null;
}
/**
* Fetch latest radar data (for latest mode)
*/
export async function fetchLatestRadar() {
const response = await fetch(API_BASE, {
signal: AbortSignal.timeout(10000)
});
if (!response.ok) {
const error = await response.json().catch(() => ({ message: `HTTP ${response.status}: ${response.statusText}` }));
if (response.status === 404) {
const errorData = await response.json().catch(() => ({}));
state.lastRefreshTime = new Date();
const refreshEndpoint = errorData.suggestions?.refreshEndpoint || errorData.refreshEndpoint;
triggerBackgroundRefresh(refreshEndpoint);
const cacheStatusData = createCacheStatusData(errorData);
const retryAfter = errorData.suggestions?.retryAfter || errorData.retryAfter || 30;
const message = errorData.message || `Cache is being generated. Please wait ${retryAfter} seconds and refresh.`;
return {
error: 'cache_generating',
cacheStatus: cacheStatusData,
message: message,
refreshEndpoint: refreshEndpoint
};
}
throw new Error(error.message || error.error || 'Failed to fetch radar data');
}
const data = await response.json();
data.isExtendedMode = false;
return { data: data, error: null };
}
/**
* Main function to fetch radar data (handles both latest and extended modes)
*/
export async function fetchRadarData() {
try {
// Extended mode
if (state.settings.timespan !== 'latest') {
// Fetch cache range first if needed
if (!state.cacheRangeInfo) {
const rangeResult = await fetchCacheRange();
if (rangeResult.error === 'network') {
throw new Error('Network error - API unavailable');
}
state.cacheRangeInfo = rangeResult.data;
}
if (!state.cacheRangeInfo || state.cacheRangeInfo.totalCacheFolders === 0) {
const refreshEndpoint = `${API_BASE.replace('/api/radar', '/api/cache')}/refresh`;
triggerBackgroundRefresh(refreshEndpoint);
// Try to fetch cache status
try {
const statusResponse = await fetch(`${API_BASE}`, {
signal: AbortSignal.timeout(5000)
});
if (statusResponse.status === 404) {
const errorData = await statusResponse.json().catch(() => ({}));
return {
error: 'no_cache_data',
cacheStatus: createCacheStatusData(errorData),
message: 'No historical cache data available yet. Cache update has been triggered in the background.'
};
}
} catch (err) {
console.debug('Could not fetch cache status:', err);
}
return {
error: 'no_cache_data',
message: 'No historical cache data available yet. Cache update has been triggered in the background.'
};
}
// Calculate time range
let startTime = null;
let endTime = new Date();
if (state.settings.timespan === 'custom') {
const startInput = document.getElementById('start-time-input');
const endInput = document.getElementById('end-time-input');
if (startInput && startInput.value) startTime = new Date(startInput.value);
if (endInput && endInput.value) endTime = new Date(endInput.value);
} else {
const hours = parseInt(state.settings.timespan.replace('h', '')) || 1;
startTime = new Date(endTime.getTime() - (hours * 60 * 60 * 1000));
}
// Fetch historical data
const result = await fetchHistoricalRadar(startTime, endTime);
if (result.error === 'network') {
throw new Error('Network error - API unavailable');
}
if (result.error === 'location_missing') {
return {
error: 'location_missing',
cacheStatus: createCacheStatusData(result.cacheStatus || {}),
message: 'No cached data found for this location. Cache update has been triggered in background.'
};
}
if (!result.frames || result.frames.length === 0) {
return {
error: 'no_frames',
message: 'No frames found for selected timespan. Try a different range or wait for more cache data.'
};
}
// Fetch metadata
let metadata = null;
try {
const metadataResponse = await fetch(API_BASE, {
signal: AbortSignal.timeout(5000)
});
if (metadataResponse.ok) {
const latestData = await metadataResponse.json();
metadata = {
weatherStation: latestData.weatherStation,
distance: latestData.distance,
observationTime: latestData.observationTime,
cacheExpiresAt: latestData.cacheExpiresAt,
nextUpdateTime: latestData.nextUpdateTime,
cacheIsValid: latestData.cacheIsValid,
isUpdating: latestData.isUpdating
};
}
} catch (err) {
console.debug('Could not fetch metadata for extended mode:', err);
}
// Create response object
const newestCacheFolder = state.historicalData.cacheFolders[state.historicalData.cacheFolders.length - 1];
return {
data: {
frames: result.frames,
lastUpdated: endTime.toISOString(),
observationTime: metadata?.observationTime || newestCacheFolder?.observationTime || endTime.toISOString(),
forecastTime: endTime.toISOString(),
weatherStation: metadata?.weatherStation || null,
distance: metadata?.distance || null,
cacheIsValid: metadata?.cacheIsValid ?? true,
cacheExpiresAt: metadata?.cacheExpiresAt || endTime.toISOString(),
isUpdating: metadata?.isUpdating || false,
nextUpdateTime: metadata?.nextUpdateTime || endTime.toISOString(),
totalFrames: result.frames.length,
isExtendedMode: true
},
error: null
};
} else {
// Latest mode
const result = await fetchLatestRadar();
if (result.error) {
return result;
}
return result;
}
} catch (error) {
console.error('Error fetching radar data:', error);
if (isNetworkError(error)) {
return { error: 'network', originalError: error };
}
return { error: 'unknown', message: error.message };
}
}
+203
View File
@@ -0,0 +1,203 @@
// Main application entry point
import { loadSettings, saveSettings, updateSettingsUI, applySettings } from './settings.js';
import { fetchRadarData } from './api-client.js';
import { updateUI, updateRelativeTimes } from './ui-updater.js';
import { showError, showNoFramesMessage, clearError } from './ui-updater.js';
import { handleApiDown, resetApiRetryState } from './error-handler.js';
import { buildFrameControls, showFrame, findFrameToShowAfterRefresh } from './frame-navigation.js';
import { play, pause, togglePlay, previousFrame, nextFrame } from './slideshow.js';
import { setupKeyboardNavigation } from './keyboard.js';
import { state } from './state.js';
import { isNetworkError } from './utils.js';
/**
* Settings modal management
*/
function showSettings() {
const modal = document.getElementById('settings-modal');
if (modal) modal.style.display = 'flex';
}
function hideSettings() {
const modal = document.getElementById('settings-modal');
if (modal) modal.style.display = 'none';
}
/**
* Auto-refresh data
*/
async function refreshData() {
// Don't clear error if API is down (keep retry message visible)
if (!state.isApiDown) {
clearError();
}
const result = await fetchRadarData();
if (result.error) {
if (result.error === 'network') {
handleApiDown(result.originalError || new Error('Network error'));
return;
}
// Handle other errors
if (result.error === 'cache_generating') {
updateUI(result.cacheStatus);
showNoFramesMessage(result.message, result.refreshEndpoint);
} else if (result.error === 'location_missing' || result.error === 'no_cache_data' || result.error === 'no_frames') {
if (result.cacheStatus) {
updateUI(result.cacheStatus);
}
showNoFramesMessage(result.message, result.refreshEndpoint);
} else {
showError(result.message || 'An error occurred');
}
return;
}
if (result.data) {
state.lastRefreshTime = new Date();
resetApiRetryState();
updateUI(result.data);
// Build frame controls after UI update
buildFrameControls();
// Find and show appropriate frame
const frameToShow = findFrameToShowAfterRefresh();
showFrame(frameToShow);
// Restart auto-refresh if it was stopped
if (!state.refreshInterval && !state.isApiDown) {
state.refreshInterval = setInterval(refreshData, state.settings.refreshInterval * 1000);
}
// Restart play if it was playing before refresh
const wasPlaying = state.isPlaying;
if (wasPlaying && result.data.frames && result.data.frames.length > 0) {
setTimeout(() => {
if (!state.isPlaying) {
play();
}
}, 100);
} else if (state.settings.autoPlay && !state.isPlaying && result.data.frames && result.data.frames.length > 0) {
// Auto-play on initial load only
setTimeout(() => play(), 500);
}
}
}
/**
* Initialize application
*/
async function init() {
// Load settings from cookies
loadSettings();
// Set up event listeners
const playBtn = document.getElementById('play-btn');
const prevBtn = document.getElementById('prev-btn');
const nextBtn = document.getElementById('next-btn');
if (playBtn) playBtn.addEventListener('click', togglePlay);
if (prevBtn) prevBtn.addEventListener('click', previousFrame);
if (nextBtn) nextBtn.addEventListener('click', nextFrame);
// Settings button
const settingsBtn = document.getElementById('settings-btn-header');
if (settingsBtn) {
settingsBtn.addEventListener('click', showSettings);
}
// Settings modal handlers
const saveSettingsBtn = document.getElementById('save-settings-btn');
if (saveSettingsBtn) {
saveSettingsBtn.addEventListener('click', () => {
const frameIntervalValue = parseFloat(document.getElementById('frame-interval-input').value);
state.settings.frameInterval = (frameIntervalValue > 0) ? frameIntervalValue : 2.0;
const refreshIntervalValue = parseInt(document.getElementById('refresh-interval-input').value);
state.settings.refreshInterval = (refreshIntervalValue >= 5) ? refreshIntervalValue : 30;
state.settings.autoPlay = document.getElementById('auto-play-input').checked;
state.settings.timespan = document.getElementById('timespan-select').value;
if (state.settings.timespan === 'custom') {
const startInput = document.getElementById('start-time-input');
const endInput = document.getElementById('end-time-input');
if (startInput && startInput.value) {
state.settings.customStartTime = startInput.value;
}
if (endInput && endInput.value) {
state.settings.customEndTime = endInput.value;
}
}
saveSettings();
hideSettings();
// Apply settings and restart refresh interval
applySettings();
if (state.refreshInterval) {
clearInterval(state.refreshInterval);
state.refreshInterval = null;
}
// Reload data with new timespan
refreshData();
});
}
// Timespan select change handler
const timespanSelect = document.getElementById('timespan-select');
if (timespanSelect) {
timespanSelect.addEventListener('change', (e) => {
state.settings.timespan = e.target.value;
const customSection = document.getElementById('custom-range-section');
if (customSection) {
customSection.style.display = e.target.value === 'custom' ? 'block' : 'none';
}
});
}
// Cancel settings button
const cancelSettingsBtn = document.getElementById('cancel-settings-btn');
if (cancelSettingsBtn) {
cancelSettingsBtn.addEventListener('click', () => {
updateSettingsUI(); // Reset to current settings
hideSettings();
});
}
// Close button handler
const closeSettingsBtn = document.getElementById('close-settings-btn');
if (closeSettingsBtn) {
closeSettingsBtn.addEventListener('click', hideSettings);
}
// Close modal on background click
const settingsModal = document.getElementById('settings-modal');
if (settingsModal) {
settingsModal.addEventListener('click', (e) => {
if (e.target.id === 'settings-modal') {
hideSettings();
}
});
}
// Setup keyboard navigation
setupKeyboardNavigation();
// Initial load
await refreshData();
// Auto-refresh based on settings (only if API is up)
if (!state.isApiDown) {
state.refreshInterval = setInterval(refreshData, state.settings.refreshInterval * 1000);
}
// Update relative times every second
setInterval(updateRelativeTimes, 1000);
}
// Start when page loads
init();
+15
View File
@@ -0,0 +1,15 @@
// Configuration constants
export const MAX_RETRY_ATTEMPTS = 10;
export const RETRY_DELAY_MS = 5000; // 5 seconds between retries
// Get API base URL from global variable set by Razor view
export const API_BASE = window.API_BASE || '';
// Default settings
export const DEFAULT_SETTINGS = {
frameInterval: 2.0, // seconds between frames
refreshInterval: 30, // seconds between API refreshes
autoPlay: true, // auto-play on load
timespan: 'latest' // 'latest', '1h', '3h', '6h', '12h', '24h', 'custom'
};
+68
View File
@@ -0,0 +1,68 @@
// Error handling and API retry logic
import { MAX_RETRY_ATTEMPTS, RETRY_DELAY_MS } from './config.js';
import { state } from './state.js';
import { updateApiStatus, showError, clearError } from './ui-updater.js';
import { fetchRadarData } from './api-client.js';
import { updateUI } from './ui-updater.js';
/**
* Reset API retry state when connection is restored
*/
export function resetApiRetryState() {
if (state.isApiDown) {
state.isApiDown = false;
state.retryAttempts = 0;
clearError();
if (state.retryInterval) {
clearInterval(state.retryInterval);
state.retryInterval = null;
}
updateApiStatus('connected');
}
}
/**
* Handle API being down
*/
export function handleApiDown(error) {
if (!state.isApiDown) {
state.isApiDown = true;
state.retryAttempts = 0;
updateApiStatus('disconnected');
}
state.retryAttempts++;
showError(`API connection lost. Retrying... (Attempt ${state.retryAttempts}/${MAX_RETRY_ATTEMPTS})`);
// Stop auto-refresh while retrying
if (state.refreshInterval) {
clearInterval(state.refreshInterval);
state.refreshInterval = null;
}
// Start retry mechanism
if (!state.retryInterval && state.retryAttempts < MAX_RETRY_ATTEMPTS) {
state.retryInterval = setInterval(async () => {
if (state.retryAttempts >= MAX_RETRY_ATTEMPTS) {
clearInterval(state.retryInterval);
state.retryInterval = null;
showError('API connection failed after multiple attempts. Please check if the service is running.');
updateApiStatus('failed');
return;
}
state.retryAttempts++;
showError(`API connection lost. Retrying... (Attempt ${state.retryAttempts}/${MAX_RETRY_ATTEMPTS})`);
// Try to fetch data
const result = await fetchRadarData();
if (result.data) {
updateUI(result.data);
}
}, RETRY_DELAY_MS);
} else if (state.retryAttempts >= MAX_RETRY_ATTEMPTS) {
showError('API connection failed after multiple attempts. Please check if the service is running.');
updateApiStatus('failed');
}
}
+305
View File
@@ -0,0 +1,305 @@
// Frame navigation and display
import { formatDate, formatFrameTime, getMinutesAgo, preloadImages } from './utils.js';
import { state } from './state.js';
/**
* Calculate jump amount for frame navigation
* Returns 10% of total frames (min 1, max 50) for extended mode, 0 otherwise
*/
export function getJumpAmount() {
if (!state.isExtendedMode || state.frames.length === 0) {
return 0;
}
return Math.max(1, Math.min(50, Math.round(state.frames.length / 10)));
}
/**
* Build frame controls HTML and attach event listeners
*/
export function buildFrameControls() {
const controlsEl = document.getElementById('frame-controls');
if (!controlsEl) return;
// Calculate jump amount - 10% of total frames, minimum 1, maximum 50
// Only used in extended mode (timeseries)
const jumpAmount = getJumpAmount();
// Build jump buttons HTML only for extended mode
const prevJumpButtonHtml = state.isExtendedMode && jumpAmount > 0
? `<button class="frame-nav-btn" id="frame-prev-jump-btn" title="Go back ${jumpAmount} frames" aria-label="Go back ${jumpAmount} frames">-${jumpAmount}</button>`
: '';
const nextJumpButtonHtml = state.isExtendedMode && jumpAmount > 0
? `<button class="frame-nav-btn" id="frame-next-jump-btn" title="Go forward ${jumpAmount} frames" aria-label="Go forward ${jumpAmount} frames">+${jumpAmount}</button>`
: '';
controlsEl.innerHTML = `
<div class="frame-slider-container">
<div class="frame-slider-wrapper">
<button class="frame-nav-btn" id="frame-first-btn" title="First frame" aria-label="First frame">⏮</button>
${prevJumpButtonHtml}
<input type="range" class="frame-slider" id="frame-slider" min="0" max="${state.frames.length - 1}" value="${state.currentFrameIndex}" aria-label="Frame slider">
${nextJumpButtonHtml}
<button class="frame-nav-btn" id="frame-last-btn" title="Last frame" aria-label="Last frame">⏭</button>
</div>
<div class="frame-info-display">
<div class="frame-info-item">
<span class="frame-info-label">Frame:</span>
<input type="number" class="frame-jump-input" id="frame-jump-input" min="0" max="${state.frames.length - 1}" value="0">
<span class="frame-info-value">/ ${state.frames.length - 1}</span>
</div>
<div class="frame-info-item">
<span class="frame-info-label">Time:</span>
<span class="frame-info-value" id="current-frame-time">-</span>
</div>
<div class="frame-info-item">
<span class="frame-info-label">Progress:</span>
<span class="frame-info-value" id="frame-progress">0%</span>
</div>
</div>
</div>
`;
// Attach event listeners
const firstBtn = document.getElementById('frame-first-btn');
const prevJumpBtn = document.getElementById('frame-prev-jump-btn');
const nextJumpBtn = document.getElementById('frame-next-jump-btn');
const lastBtn = document.getElementById('frame-last-btn');
const slider = document.getElementById('frame-slider');
const jumpInput = document.getElementById('frame-jump-input');
if (firstBtn) firstBtn.addEventListener('click', () => showFrame(0));
if (prevJumpBtn && jumpAmount > 0) {
prevJumpBtn.addEventListener('click', () => jumpFrame(-jumpAmount));
}
if (nextJumpBtn && jumpAmount > 0) {
nextJumpBtn.addEventListener('click', () => jumpFrame(jumpAmount));
}
if (lastBtn) lastBtn.addEventListener('click', () => showFrame(state.frames.length - 1));
if (slider) {
slider.addEventListener('input', (e) => updateFrameFromSlider(parseInt(e.target.value)));
}
if (jumpInput) {
jumpInput.addEventListener('change', (e) => jumpToFrame(parseInt(e.target.value)));
jumpInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') jumpToFrame(parseInt(e.target.value));
});
}
// Update initial display
updateFrameSliderInfo(state.currentFrameIndex);
// Preload all frame images
preloadImages(state.frames);
}
/**
* Update frame slider info display
*/
export function updateFrameSliderInfo(index) {
if (index < 0 || index >= state.frames.length) return;
const frame = state.frames[index];
const progress = Math.round(((index + 1) / state.frames.length) * 100);
// Update jump input
const jumpInput = document.getElementById('frame-jump-input');
if (jumpInput) jumpInput.value = index;
// Update slider
const slider = document.getElementById('frame-slider');
if (slider) slider.value = index;
// Update frame number display
const frameNumDisplay = document.getElementById('current-frame-num-display');
if (frameNumDisplay) frameNumDisplay.textContent = index + 1;
// Update time display
const timeDisplay = document.getElementById('current-frame-time');
if (timeDisplay) {
timeDisplay.textContent = formatFrameTime(frame, state.isExtendedMode);
}
// Update progress
const progressDisplay = document.getElementById('frame-progress');
if (progressDisplay) progressDisplay.textContent = `${progress}%`;
// Update navigation buttons
const firstBtn = document.getElementById('frame-first-btn');
const prevJumpBtn = document.getElementById('frame-prev-jump-btn');
const nextJumpBtn = document.getElementById('frame-next-jump-btn');
const lastBtn = document.getElementById('frame-last-btn');
if (firstBtn) firstBtn.disabled = index === 0;
if (prevJumpBtn) prevJumpBtn.disabled = index === 0;
if (nextJumpBtn) nextJumpBtn.disabled = index >= state.frames.length - 1;
if (lastBtn) lastBtn.disabled = index >= state.frames.length - 1;
}
let sliderUpdateTimeout;
/**
* Update frame from slider (with debouncing)
*/
export function updateFrameFromSlider(value) {
clearTimeout(sliderUpdateTimeout);
sliderUpdateTimeout = setTimeout(() => {
showFrame(value);
}, 50);
}
/**
* Jump to specific frame number
*/
export function jumpToFrame(frameNum) {
const index = Math.max(0, Math.min(state.frames.length - 1, frameNum));
showFrame(index);
}
/**
* Jump forward/backward by N frames
*/
export function jumpFrame(offset) {
const newIndex = Math.max(0, Math.min(state.frames.length - 1, state.currentFrameIndex + offset));
showFrame(newIndex);
}
/**
* Build alt text for frame image
*/
function buildFrameAltText(frame, index) {
if (state.isExtendedMode) {
const frameNum = frame.sequentialIndex !== undefined ? frame.sequentialIndex : index;
const timeInfo = frame.absoluteObservationTime
? formatDate(frame.absoluteObservationTime)
: (frame.cacheTimestamp ? formatDate(frame.cacheTimestamp) : '');
return `Radar frame ${frameNum}${timeInfo ? ' (' + timeInfo + ')' : ''}`;
} else {
const minutesAgo = getMinutesAgo(frame.absoluteObservationTime);
if (minutesAgo !== null) {
return `Radar frame ${frame.frameIndex} (${minutesAgo} minutes ago)`;
} else {
// Fall back to formatted date if minutes ago calculation fails
const timeInfo = frame.absoluteObservationTime ? formatDate(frame.absoluteObservationTime) : '';
return `Radar frame ${frame.frameIndex}${timeInfo ? ' (' + timeInfo + ')' : ''}`;
}
}
}
/**
* Build frame info text
*/
function buildFrameInfoText(frame, index) {
if (state.isExtendedMode) {
const frameNum = frame.sequentialIndex !== undefined ? frame.sequentialIndex : index;
const timeInfo = frame.absoluteObservationTime
? formatDate(frame.absoluteObservationTime)
: (frame.cacheTimestamp ? formatDate(frame.cacheTimestamp) : '');
return `Frame ${frameNum} of ${state.frames.length - 1}${timeInfo ? ' • ' + timeInfo : ''}`;
} else {
const minutesAgo = getMinutesAgo(frame.absoluteObservationTime);
if (minutesAgo !== null) {
return `Frame ${frame.frameIndex} of ${state.frames.length - 1} • ${minutesAgo} minutes ago`;
} else {
// Fall back to formatted date if minutes ago calculation fails
const timeInfo = frame.absoluteObservationTime ? formatDate(frame.absoluteObservationTime) : '';
return `Frame ${frame.frameIndex} of ${state.frames.length - 1}${timeInfo ? ' • ' + timeInfo : ''}`;
}
}
}
/**
* Show specific frame
*/
export function showFrame(index) {
if (index < 0 || index >= state.frames.length) {
console.error(`showFrame: Invalid index ${index}, frames.length = ${state.frames.length}`);
return;
}
if (!state.frames[index]) {
console.error(`showFrame: Frame at index ${index} is undefined`);
return;
}
state.currentFrameIndex = index;
const frame = state.frames[index];
// Update slider info
updateFrameSliderInfo(index);
// Update active button state
document.querySelectorAll('.frame-btn').forEach(btn => {
btn.classList.remove('active');
if (parseInt(btn.getAttribute('data-frame')) === index) {
btn.classList.add('active');
}
});
// Update image
const imgEl = document.getElementById('radar-image');
const loadingEl = document.getElementById('loading');
const altText = buildFrameAltText(frame, index);
// Check if image is already loaded (from preload)
const preloadedImg = new Image();
preloadedImg.onload = () => {
if (imgEl) {
imgEl.src = frame.imageUrl;
imgEl.alt = altText;
}
if (loadingEl) loadingEl.style.display = 'none';
if (imgEl) imgEl.style.display = 'block';
};
preloadedImg.onerror = () => {
if (imgEl) {
imgEl.src = frame.imageUrl;
imgEl.alt = altText;
}
};
preloadedImg.src = frame.imageUrl;
// Update frame info
const frameInfoEl = document.getElementById('frame-info');
if (frameInfoEl) {
frameInfoEl.textContent = buildFrameInfoText(frame, index);
}
}
/**
* Find frame to show after refresh (preserves position)
*/
export function findFrameToShowAfterRefresh() {
if (state.frames.length === 0) return 0;
const previousFrameIndex = state.currentFrameIndex;
if (previousFrameIndex < 0 || !state.radarData?.frames) return 0;
const previousFrame = state.radarData.frames[previousFrameIndex];
if (!previousFrame) return 0;
if (previousFrame.absoluteObservationTime) {
// Find frame with same absolute observation time
const matchingIndex = state.frames.findIndex(f =>
f.absoluteObservationTime === previousFrame.absoluteObservationTime
);
if (matchingIndex >= 0) return matchingIndex;
// Find closest frame by time
const previousTime = new Date(previousFrame.absoluteObservationTime).getTime();
let closestIndex = 0;
let closestDiff = Infinity;
state.frames.forEach((f, idx) => {
if (f.absoluteObservationTime) {
const diff = Math.abs(new Date(f.absoluteObservationTime).getTime() - previousTime);
if (diff < closestDiff) {
closestDiff = diff;
closestIndex = idx;
}
}
});
return closestIndex;
}
// Fallback: keep same index if valid
return previousFrameIndex < state.frames.length ? previousFrameIndex : 0;
}
+59
View File
@@ -0,0 +1,59 @@
// Keyboard navigation
import { state } from './state.js';
import { showFrame, jumpFrame, getJumpAmount } from './frame-navigation.js';
import { togglePlay } from './slideshow.js';
/**
* Setup keyboard navigation
*/
export function setupKeyboardNavigation() {
document.addEventListener('keydown', (e) => {
// Don't handle keyboard shortcuts if user is typing in an input
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') {
return;
}
// Arrow keys for frame navigation
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
e.preventDefault();
if (e.key === 'ArrowLeft') {
if (e.shiftKey) {
// Shift+Left: jump back by proportional amount (only in extended mode)
const jumpAmount = getJumpAmount();
if (jumpAmount > 0) {
jumpFrame(-jumpAmount);
}
} else {
showFrame(Math.max(0, state.currentFrameIndex - 1)); // Left: previous frame
}
} else {
if (e.shiftKey) {
// Shift+Right: jump forward by proportional amount (only in extended mode)
const jumpAmount = getJumpAmount();
if (jumpAmount > 0) {
jumpFrame(jumpAmount);
}
} else {
showFrame(Math.min(state.frames.length - 1, state.currentFrameIndex + 1)); // Right: next frame
}
}
}
// Home/End for first/last frame
if (e.key === 'Home') {
e.preventDefault();
showFrame(0);
}
if (e.key === 'End') {
e.preventDefault();
showFrame(state.frames.length - 1);
}
// Spacebar for play/pause
if (e.key === ' ' && state.frames.length > 0) {
e.preventDefault();
togglePlay();
}
});
}
+62
View File
@@ -0,0 +1,62 @@
// Settings management
import { getCookie, setCookie } from './utils.js';
import { state } from './state.js';
import { fetchCacheRange } from './api-client.js';
import { pause, play } from './slideshow.js';
/**
* Load settings from cookies
*/
export function loadSettings() {
const saved = getCookie('radarTestSettings');
if (saved) {
state.settings = { ...state.settings, ...saved };
}
updateSettingsUI();
}
/**
* Save settings to cookies
*/
export function saveSettings() {
setCookie('radarTestSettings', state.settings);
updateSettingsUI();
applySettings();
}
/**
* Update settings UI elements
*/
export function updateSettingsUI() {
document.getElementById('frame-interval-input').value = state.settings.frameInterval;
document.getElementById('refresh-interval-input').value = state.settings.refreshInterval;
document.getElementById('auto-play-input').checked = state.settings.autoPlay;
document.getElementById('timespan-select').value = state.settings.timespan || 'latest';
// Show/hide custom range section
const customSection = document.getElementById('custom-range-section');
if (customSection) {
customSection.style.display = state.settings.timespan === 'custom' ? 'block' : 'none';
}
// Load cache range info when settings modal opens
fetchCacheRange();
}
/**
* Apply settings (restart intervals if needed)
*/
export function applySettings() {
// Restart play interval if playing
if (state.isPlaying) {
pause();
play();
}
// Restart refresh interval (will be handled by refreshData caller)
if (state.refreshInterval) {
clearInterval(state.refreshInterval);
state.refreshInterval = null;
}
}
+67
View File
@@ -0,0 +1,67 @@
// Slideshow controls (play/pause)
import { state } from './state.js';
import { showFrame } from './frame-navigation.js';
/**
* Play slideshow
*/
export function play() {
if (state.frames.length === 0) return;
state.isPlaying = true;
const playBtn = document.getElementById('play-btn');
if (playBtn) playBtn.textContent = '⏸ Pause';
if (state.playInterval) {
clearInterval(state.playInterval);
}
state.playInterval = setInterval(() => {
state.currentFrameIndex = (state.currentFrameIndex + 1) % state.frames.length;
showFrame(state.currentFrameIndex);
}, state.settings.frameInterval * 1000);
}
/**
* Pause slideshow
*/
export function pause() {
state.isPlaying = false;
const playBtn = document.getElementById('play-btn');
if (playBtn) playBtn.textContent = '▶ Play';
if (state.playInterval) {
clearInterval(state.playInterval);
state.playInterval = null;
}
}
/**
* Toggle play/pause
*/
export function togglePlay() {
if (state.isPlaying) {
pause();
} else {
play();
}
}
/**
* Go to previous frame
*/
export function previousFrame() {
pause();
const newIndex = state.currentFrameIndex > 0 ? state.currentFrameIndex - 1 : state.frames.length - 1;
showFrame(newIndex);
}
/**
* Go to next frame
*/
export function nextFrame() {
pause();
const newIndex = (state.currentFrameIndex + 1) % state.frames.length;
showFrame(newIndex);
}
+41
View File
@@ -0,0 +1,41 @@
// Application state management
import { DEFAULT_SETTINGS } from './config.js';
// Export a mutable state object - properties can be mutated even though the object binding is read-only
export const state = {
currentFrameIndex: 0,
frames: [],
radarData: null,
playInterval: null,
isPlaying: false,
lastRefreshTime: null,
refreshInterval: null,
nextClientCheckTime: null,
retryInterval: null,
isApiDown: false,
retryAttempts: 0,
cacheRangeInfo: null,
historicalData: null,
isExtendedMode: false,
settings: { ...DEFAULT_SETTINGS }
};
// Reset state (for testing/debugging)
export function resetState() {
state.currentFrameIndex = 0;
state.frames = [];
state.radarData = null;
state.playInterval = null;
state.isPlaying = false;
state.lastRefreshTime = null;
state.refreshInterval = null;
state.nextClientCheckTime = null;
state.retryInterval = null;
state.isApiDown = false;
state.retryAttempts = 0;
state.cacheRangeInfo = null;
state.historicalData = null;
state.isExtendedMode = false;
state.settings = { ...DEFAULT_SETTINGS };
}
+281
View File
@@ -0,0 +1,281 @@
// UI update functions
import { formatDate, getRelativeTime, setElementText, setElementHTML, getElement } from './utils.js';
import { state } from './state.js';
/**
* Update API connection status indicator
*/
export function updateApiStatus(status) {
const refreshStatusEl = getElement('refresh-status');
const refreshIndicatorEl = getElement('refresh-indicator');
if (!refreshStatusEl || !refreshIndicatorEl) return;
if (status === 'connected') {
refreshStatusEl.textContent = 'Active';
refreshIndicatorEl.style.background = '#28a745';
refreshIndicatorEl.style.animation = 'pulse 2s infinite';
} else if (status === 'disconnected') {
refreshStatusEl.textContent = 'Reconnecting...';
refreshIndicatorEl.style.background = '#ffc107';
refreshIndicatorEl.style.animation = 'pulse 1s infinite';
} else if (status === 'failed') {
refreshStatusEl.textContent = 'Connection Failed';
refreshIndicatorEl.style.background = '#dc3545';
refreshIndicatorEl.style.animation = 'none';
}
}
/**
* Show error message
*/
export function showError(message) {
const container = getElement('error-container');
if (!container) return;
const isRetrying = message.includes('Retrying');
const errorClass = isRetrying ? 'error retrying' : 'error';
container.innerHTML = `<div class="${errorClass}">${isRetrying ? '⚠️ ' : '❌ Error: '}${message}</div>`;
}
/**
* Show graceful "no frames" message
*/
export function showNoFramesMessage(message, refreshEndpoint = null) {
const container = getElement('error-container');
if (!container) return;
let html = `<div class="error retrying" style="background: #fff3cd; color: #856404; border-left-color: #ffc107;">
<div style="margin-bottom: 10px;">⏳ ${message}</div>`;
if (refreshEndpoint) {
html += `<button class="play-btn" onclick="window.location.reload()" style="margin-top: 10px; padding: 8px 16px; font-size: 0.9em;">
🔄 Refresh Now
</button>`;
}
html += `</div>`;
container.innerHTML = html;
// Hide loading and show message in image container
const loadingEl = getElement('loading');
const imageEl = getElement('radar-image');
if (loadingEl) loadingEl.textContent = message;
if (imageEl) imageEl.style.display = 'none';
}
/**
* Clear error message
*/
export function clearError() {
const container = getElement('error-container');
if (container) container.innerHTML = '';
}
/**
* Update relative times periodically
*/
export function updateRelativeTimes() {
if (!state.radarData) return;
const expiresEl = getElement('cache-expires-relative');
const nextUpdateEl = getElement('next-update-relative');
const updateStatusDetailEl = getElement('update-status-detail');
const nextCheckEl = getElement('next-client-check');
const estimationNoteEl = getElement('estimation-note');
if (state.radarData.cacheExpiresAt && expiresEl) {
expiresEl.textContent = getRelativeTime(state.radarData.cacheExpiresAt);
}
if (state.radarData.nextUpdateTime && nextUpdateEl) {
nextUpdateEl.textContent = getRelativeTime(state.radarData.nextUpdateTime);
}
// Update update status detail with relative time
if (updateStatusDetailEl) {
if (state.radarData.isUpdating && state.radarData.nextUpdateTime) {
const relativeTime = getRelativeTime(state.radarData.nextUpdateTime);
updateStatusDetailEl.textContent = 'Update in progress • Estimated completion: ' + formatDate(state.radarData.nextUpdateTime) + ' (' + relativeTime + ')';
updateStatusDetailEl.title = 'Estimate is based on historical metrics and current progress. Accuracy improves as more updates complete.';
if (estimationNoteEl) estimationNoteEl.style.display = 'inline';
} else if (!state.radarData.cacheIsValid && !state.radarData.isUpdating && state.radarData.nextUpdateTime) {
const relativeTime = getRelativeTime(state.radarData.nextUpdateTime);
updateStatusDetailEl.textContent = 'Update scheduled: ' + formatDate(state.radarData.nextUpdateTime) + ' (' + relativeTime + ')';
updateStatusDetailEl.title = 'Estimated time when cache update will be triggered or completed. Based on metrics from previous updates.';
if (estimationNoteEl) estimationNoteEl.style.display = 'inline';
} else if (state.radarData.cacheIsValid) {
updateStatusDetailEl.textContent = 'Cache is valid, no update needed';
updateStatusDetailEl.title = '';
if (estimationNoteEl) estimationNoteEl.style.display = 'none';
} else {
if (estimationNoteEl) estimationNoteEl.style.display = 'none';
}
}
// Update next client check time
if (state.nextClientCheckTime && nextCheckEl) {
nextCheckEl.textContent = 'Next check: ' + formatDate(state.nextClientCheckTime.toISOString()) + ' (' + getRelativeTime(state.nextClientCheckTime.toISOString()) + ')';
}
}
/**
* Update status cards with cache update information
*/
export function updateStatusForCacheUpdate(retryAfter) {
const statusEl = getElement('cache-status');
const updateStatusDetailEl = getElement('update-status-detail');
if (statusEl) {
statusEl.innerHTML = 'Generating <span class="status-badge status-updating">IN PROGRESS</span>';
}
if (updateStatusDetailEl) {
if (retryAfter) {
const estimatedTime = new Date(Date.now() + (retryAfter * 1000));
updateStatusDetailEl.textContent = `Cache generation in progress. Estimated ready in ${retryAfter} seconds.`;
setElementText('next-update', formatDate(estimatedTime.toISOString()));
} else {
updateStatusDetailEl.textContent = 'Cache generation in progress, please wait...';
setElementText('next-update', '-');
}
}
// Set other fields to indicate cache is being generated
setElementText('observation-time', '-');
setElementText('cache-expires', '-');
setElementText('weather-station', '-');
setElementText('distance', '-');
// Update last refresh
if (state.lastRefreshTime) {
setElementText('last-refresh', 'Last checked: ' + formatDate(state.lastRefreshTime.toISOString()));
// Calculate and display next client check time
state.nextClientCheckTime = new Date(state.lastRefreshTime.getTime() + (state.settings.refreshInterval * 1000));
const nextCheckEl = getElement('next-client-check');
if (nextCheckEl) {
nextCheckEl.textContent = 'Next check: ' + formatDate(state.nextClientCheckTime.toISOString()) + ' (' + getRelativeTime(state.nextClientCheckTime.toISOString()) + ')';
}
}
}
/**
* Update UI with radar data (main update function)
*/
export function updateUI(data) {
// Update cache status even if no frames are available
if (data) {
const statusEl = getElement('cache-status');
if (statusEl && (data.isUpdating !== undefined || data.cacheIsValid !== undefined)) {
let statusHtml = '';
if (data.isUpdating) {
statusHtml = 'Updating <span class="status-badge status-updating">IN PROGRESS</span>';
} else if (data.cacheIsValid) {
statusHtml = 'Valid <span class="status-badge status-valid">ACTIVE</span>';
} else {
statusHtml = 'Invalid <span class="status-badge status-invalid">EXPIRED</span>';
}
statusEl.innerHTML = statusHtml;
}
// Update update status detail
const updateStatusDetailEl = getElement('update-status-detail');
const estimationNoteEl = getElement('estimation-note');
if (updateStatusDetailEl && (data.isUpdating !== undefined || data.cacheIsValid !== undefined)) {
if (data.isUpdating) {
if (data.nextUpdateTime) {
const relativeTime = getRelativeTime(data.nextUpdateTime);
updateStatusDetailEl.textContent = 'Update in progress • Estimated completion: ' + formatDate(data.nextUpdateTime) + ' (' + relativeTime + ')';
updateStatusDetailEl.title = 'Estimate is based on historical metrics and current progress. Accuracy improves as more updates complete.';
if (estimationNoteEl) estimationNoteEl.style.display = 'inline';
} else {
updateStatusDetailEl.textContent = 'Update in progress, please wait...';
updateStatusDetailEl.title = 'Cache update is in progress. Completion time will be estimated once progress tracking begins.';
if (estimationNoteEl) estimationNoteEl.style.display = 'none';
}
} else if (data.cacheIsValid) {
updateStatusDetailEl.textContent = 'Cache is valid, no update needed';
updateStatusDetailEl.title = '';
if (estimationNoteEl) estimationNoteEl.style.display = 'none';
} else {
if (data.nextUpdateTime) {
const relativeTime = getRelativeTime(data.nextUpdateTime);
updateStatusDetailEl.textContent = 'Update scheduled: ' + formatDate(data.nextUpdateTime) + ' (' + relativeTime + ')';
updateStatusDetailEl.title = 'Estimated time when cache update will be triggered or completed. Based on metrics from previous updates.';
if (estimationNoteEl) estimationNoteEl.style.display = 'inline';
} else {
updateStatusDetailEl.textContent = 'Update will be triggered by background service';
updateStatusDetailEl.title = 'The background cache management service will check and update the cache periodically.';
if (estimationNoteEl) estimationNoteEl.style.display = 'none';
}
}
}
// Update other metadata fields
if (data.observationTime) setElementText('observation-time', formatDate(data.observationTime));
if (data.cacheExpiresAt) setElementText('cache-expires', formatDate(data.cacheExpiresAt));
if (data.nextUpdateTime) setElementText('next-update', formatDate(data.nextUpdateTime));
if (data.weatherStation !== undefined) setElementText('weather-station', data.weatherStation || '-');
if (data.distance !== undefined) setElementText('distance', data.distance || '-');
}
// Handle no frames case
if (!data || !data.frames || data.frames.length === 0) {
const currentStatus = getElement('cache-status')?.innerHTML;
if (!currentStatus || currentStatus === 'Checking...' || currentStatus.trim() === '') {
const statusEl = getElement('cache-status');
const updateStatusDetailEl = getElement('update-status-detail');
if (statusEl) statusEl.innerHTML = 'Generating <span class="status-badge status-updating">IN PROGRESS</span>';
if (updateStatusDetailEl) updateStatusDetailEl.textContent = 'Cache generation in progress, please wait...';
}
showNoFramesMessage('No radar frames available yet. Cache is being generated in the background. Please wait a moment and refresh.');
state.frames = [];
const controlsEl = getElement('frame-controls');
if (controlsEl) controlsEl.innerHTML = '';
setElementText('play-btn', '▶ Play');
const playBtn = getElement('play-btn');
const prevBtn = getElement('prev-btn');
const nextBtn = getElement('next-btn');
if (playBtn) playBtn.disabled = true;
if (prevBtn) prevBtn.disabled = true;
if (nextBtn) nextBtn.disabled = true;
setElementText('frame-info', 'Waiting for cache to be generated...');
return;
}
// Update state
state.radarData = data;
state.isExtendedMode = data.isExtendedMode || false;
// Enable controls
const playBtn = getElement('play-btn');
const prevBtn = getElement('prev-btn');
const nextBtn = getElement('next-btn');
if (playBtn) playBtn.disabled = false;
if (prevBtn) prevBtn.disabled = false;
if (nextBtn) nextBtn.disabled = false;
// Sort frames appropriately
if (state.isExtendedMode) {
state.frames = data.frames;
} else {
state.frames = data.frames.sort((a, b) => a.frameIndex - b.frameIndex);
}
// Update metadata fields (redundant updates removed since already done above)
// Update last refresh
if (state.lastRefreshTime) {
setElementText('last-refresh', 'Last checked: ' + formatDate(state.lastRefreshTime.toISOString()));
state.nextClientCheckTime = new Date(state.lastRefreshTime.getTime() + (state.settings.refreshInterval * 1000));
const nextCheckEl = getElement('next-client-check');
if (nextCheckEl) {
nextCheckEl.textContent = 'Next check: ' + formatDate(state.nextClientCheckTime.toISOString()) + ' (' + getRelativeTime(state.nextClientCheckTime.toISOString()) + ')';
}
}
// Frame controls will be built by the caller (to avoid circular dependencies)
}
+184
View File
@@ -0,0 +1,184 @@
// Utility functions
// Cookie management
export function setCookie(name, value, days = 365) {
const expires = new Date();
expires.setTime(expires.getTime() + (days * 24 * 60 * 60 * 1000));
document.cookie = `${name}=${encodeURIComponent(JSON.stringify(value))};expires=${expires.toUTCString()};path=/`;
}
export function getCookie(name) {
const nameEQ = name + "=";
const ca = document.cookie.split(';');
for (let i = 0; i < ca.length; i++) {
let c = ca[i];
while (c.charAt(0) === ' ') c = c.substring(1, c.length);
if (c.indexOf(nameEQ) === 0) {
try {
return JSON.parse(decodeURIComponent(c.substring(nameEQ.length, c.length)));
} catch (e) {
return null;
}
}
}
return null;
}
// Date formatting utilities
export function formatDate(dateString) {
if (!dateString) return '-';
const date = new Date(dateString);
return date.toLocaleString('en-AU', {
timeZone: 'Australia/Brisbane',
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
}
export function getRelativeTime(dateString) {
if (!dateString) return '';
const date = new Date(dateString);
const now = new Date();
const diffMs = date - now;
const diffMins = Math.floor(diffMs / 60000);
const diffSecs = Math.floor((diffMs % 60000) / 1000);
if (diffMs < 0) {
return Math.abs(diffMins) + ' minute(s) ago';
} else if (diffMins > 0) {
return 'in ' + diffMins + ' minute(s)';
} else {
return 'in ' + diffSecs + ' second(s)';
}
}
export function getMinutesAgo(dateString) {
if (!dateString) return null;
// Handle both string and Date object inputs
let date;
if (typeof dateString === 'string') {
date = new Date(dateString);
} else if (dateString instanceof Date) {
date = dateString;
} else {
console.warn('getMinutesAgo: Expected string or Date, got:', typeof dateString, dateString);
return null;
}
// Check if date is valid
if (isNaN(date.getTime())) {
console.warn('getMinutesAgo: Invalid date:', dateString, 'parsed as:', date);
return null;
}
// Calculate difference in milliseconds
// Date.now() is UTC-based, date.getTime() is UTC-based, so this should be correct
const now = Date.now();
const dateTime = date.getTime();
const diffMs = now - dateTime;
// Convert to minutes and round
const minutes = Math.round(diffMs / 60000);
// If negative (future date), return null
if (minutes < 0) {
console.warn('getMinutesAgo: Future date detected:', dateString, 'parsed as:', date.toISOString());
return null;
}
// Safeguard against unreasonably large values (likely parsing errors)
// But don't cap at 60 - radar frames can legitimately be older in some cases
if (minutes > 100000) { // e.g., more than ~69 days ago
console.warn('getMinutesAgo: Unreasonably large minutes value:', minutes, 'for date:', dateString, 'parsed as:', date.toISOString());
return null;
}
return minutes;
}
// Format frame time for display (consolidates duplicate logic)
export function formatFrameTime(frame, isExtendedMode) {
if (!frame.absoluteObservationTime) return '-';
if (isExtendedMode) {
return formatDate(frame.absoluteObservationTime);
} else {
const minutesAgo = getMinutesAgo(frame.absoluteObservationTime);
// If minutesAgo is null (invalid date or edge case), fall back to formatted date
if (minutesAgo === null) {
return formatDate(frame.absoluteObservationTime);
}
return `${minutesAgo} min ago`;
}
}
// Network error detection (consolidates duplicate checks)
export function isNetworkError(error) {
return error.name === 'TypeError' ||
error.name === 'AbortError' ||
error.message.includes('fetch') ||
error.message.includes('network') ||
error.message.includes('Failed to fetch') ||
error.message === 'Network error';
}
export function isNetworkErrorResponse(response) {
return response.status === 0 || response.status >= 500;
}
// DOM helpers
export function getElement(id) {
return document.getElementById(id);
}
export function setElementText(id, text) {
const el = getElement(id);
if (el) el.textContent = text;
}
export function setElementHTML(id, html) {
const el = getElement(id);
if (el) el.innerHTML = html;
}
// Create cache status data object (consolidates duplicate creation)
export function createCacheStatusData(errorData) {
const details = errorData.details || errorData;
const isUpdating = details.updateTriggered === true ||
(errorData.message && errorData.message.includes('in progress')) ||
(details.statusMessage && details.statusMessage.includes('in progress'));
return {
frames: [],
isUpdating: isUpdating,
cacheIsValid: details.cacheIsValid || false,
cacheExpiresAt: details.cacheExpiresAt || null,
nextUpdateTime: details.nextUpdateTime || null
};
}
// Trigger background cache refresh (consolidates duplicate calls)
export function triggerBackgroundRefresh(refreshEndpoint) {
if (refreshEndpoint) {
fetch(refreshEndpoint, {
method: 'POST',
signal: AbortSignal.timeout(5000)
}).catch(err => {
console.debug('Background cache refresh trigger failed (non-critical):', err);
});
}
}
// Preload all frame images to prevent jiggle
export function preloadImages(frames) {
frames.forEach(frame => {
const img = new Image();
img.src = frame.imageUrl;
});
}