mirror of
https://github.com/alexhopeoconnor/bom-local-service.git
synced 2026-10-03 21:21:59 +10:00
Refactor SPA: extract CSS/JS to modular files, add static file serving, fix timestamp parsing
This commit is contained in:
@@ -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();
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
+4
-2141
File diff suppressed because it is too large
Load Diff
+72
-9
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
@import url('./reset.css');
|
||||
@import url('./layout.css');
|
||||
@import url('./components.css');
|
||||
@import url('./frame-slider.css');
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
@@ -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'
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user