mirror of
https://github.com/alexhopeoconnor/bom-local-service.git
synced 2026-10-03 21:21:59 +10:00
- Refactor TimeParsingService to parse time strings to UTC only - Remove minutesAgo from RadarFrame and FrameMetadata models - Update CaptureFramesStep to extract UTC timestamps directly - Update CacheService to store/load ObservationTime instead of MinutesAgo - Update BomRadarService to use AbsoluteObservationTime directly - Remove all backward compatibility code for old cache folders - Update documentation and test SPA to calculate minutes ago on client - Fix step registration to register concrete types for DI resolution - Fix hosted service registration to use reflection correctly
2335 lines
101 KiB
Plaintext
2335 lines
101 KiB
Plaintext
@{
|
||
ViewData["Title"] = $"BOM Radar Test - {ViewBag.Suburb}, {ViewBag.State}";
|
||
var apiBaseUrl = ViewBag.ApiBaseUrl as string ?? "";
|
||
var suburb = ViewBag.Suburb as string ?? "";
|
||
var state = ViewBag.State as string ?? "";
|
||
}
|
||
|
||
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>@ViewData["Title"]</title>
|
||
<style>
|
||
* {
|
||
margin: 0;
|
||
padding: 0;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
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;
|
||
}
|
||
}
|
||
|
||
.info-section {
|
||
display: grid;
|
||
grid-template-columns: 1fr;
|
||
gap: 12px;
|
||
}
|
||
|
||
@@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;
|
||
}
|
||
}
|
||
|
||
.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-section {
|
||
background: #f8f9fa;
|
||
border-radius: 12px;
|
||
padding: 15px;
|
||
}
|
||
|
||
@@media (min-width: 768px) {
|
||
.info-section {
|
||
padding: 20px;
|
||
}
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
/* 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;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="container">
|
||
<div class="header">
|
||
<div class="header-content">
|
||
<h1>🌧️ BOM Radar Test</h1>
|
||
<div class="subtitle">@suburb, @state</div>
|
||
</div>
|
||
<div class="header-actions">
|
||
<button class="settings-header-btn" id="settings-btn-header">
|
||
<span>⚙️</span>
|
||
<span>Settings</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="error-container"></div>
|
||
|
||
<div class="content">
|
||
<!-- Status Cards Section - Above slideshow -->
|
||
<div class="info-section">
|
||
<div class="info-card">
|
||
<h3>Cache & Update Status</h3>
|
||
<div class="value" id="cache-status">Checking...</div>
|
||
<div class="timestamp" id="update-status-detail" style="cursor: help;"></div>
|
||
<div style="font-size: 0.85em; color: #666; margin-top: 5px; font-style: italic;">
|
||
<span id="estimation-note" style="display: none;">Estimates improve as metrics are collected from completed updates</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="info-card">
|
||
<h3>Observation Time</h3>
|
||
<div class="value" id="observation-time">-</div>
|
||
</div>
|
||
|
||
<div class="info-card">
|
||
<h3>Cache Expires</h3>
|
||
<div class="value" id="cache-expires">-</div>
|
||
<div class="timestamp" id="cache-expires-relative"></div>
|
||
</div>
|
||
|
||
<div class="info-card">
|
||
<h3>Next Update</h3>
|
||
<div class="value" id="next-update">-</div>
|
||
<div class="timestamp" id="next-update-relative"></div>
|
||
</div>
|
||
|
||
<div class="info-card">
|
||
<h3>Weather Station</h3>
|
||
<div class="value" id="weather-station">-</div>
|
||
</div>
|
||
|
||
<div class="info-card">
|
||
<h3>Distance</h3>
|
||
<div class="value" id="distance">-</div>
|
||
</div>
|
||
|
||
<div class="info-card">
|
||
<h3>Auto Refresh</h3>
|
||
<div class="value">
|
||
<span class="refresh-indicator" id="refresh-indicator"></span>
|
||
<span id="refresh-status">Active</span>
|
||
</div>
|
||
<div class="timestamp" id="last-refresh"></div>
|
||
<div class="timestamp" id="next-client-check"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Radar Slideshow Section -->
|
||
<div class="radar-section">
|
||
<div class="radar-image-container">
|
||
<div class="loading" id="loading">Loading radar data...</div>
|
||
<img id="radar-image" class="radar-image" style="display: none;" alt="Radar image">
|
||
</div>
|
||
|
||
<div class="frame-controls" id="frame-controls"></div>
|
||
|
||
<div class="play-controls">
|
||
<button class="play-btn" id="play-btn">▶ Play</button>
|
||
<button class="play-btn" id="prev-btn">◀ Previous</button>
|
||
<button class="play-btn" id="next-btn">Next ▶</button>
|
||
</div>
|
||
|
||
<div class="frame-info" id="frame-info"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Settings Modal -->
|
||
<div id="settings-modal" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.6); backdrop-filter: blur(4px); z-index: 1000; align-items: center; justify-content: center; animation: fadeIn 0.2s;">
|
||
<div style="background: white; border-radius: 16px; padding: 20px; max-width: 650px; width: 95%; max-height: 90vh; overflow-y: auto; box-shadow: 0 20px 60px rgba(0,0,0,0.3); position: relative;">
|
||
<button id="close-settings-btn" style="position: absolute; top: 15px; right: 15px; background: #f0f0f0; border: none; font-size: 24px; color: #666; cursor: pointer; width: 36px; height: 36px; display: flex; align-items: center; justify-content: center; border-radius: 50%; transition: all 0.2s; font-weight: 300;"
|
||
onmouseover="this.style.background='#e0e0e0'; this.style.color='#333';"
|
||
onmouseout="this.style.background='#f0f0f0'; this.style.color='#666';"
|
||
onclick="hideSettings()">×</button>
|
||
<h2 style="margin-bottom: 20px; color: #667eea; font-size: 1.5em; display: flex; align-items: center; gap: 12px; font-weight: 700;">
|
||
<span style="font-size: 1.2em;">⚙️</span> Settings
|
||
</h2>
|
||
|
||
<div style="background: #f8f9ff; border-left: 4px solid #667eea; padding: 15px; border-radius: 8px; margin-bottom: 30px;">
|
||
<div style="font-weight: 600; color: #667eea; margin-bottom: 5px;">💡 Tip</div>
|
||
<div style="font-size: 0.9em; color: #666;">Configure your slideshow experience and select extended timespans to view historical radar data.</div>
|
||
</div>
|
||
|
||
<div style="margin-bottom: 25px;">
|
||
<div style="font-size: 0.85em; text-transform: uppercase; color: #667eea; font-weight: 700; letter-spacing: 0.5px; margin-bottom: 15px;">Slideshow Configuration</div>
|
||
|
||
<div style="margin-bottom: 20px;">
|
||
<label style="display: block; margin-bottom: 8px; font-weight: 600; color: #333;">Slideshow Timespan</label>
|
||
<select id="timespan-select" style="width: 100%; padding: 12px; border: 2px solid #667eea; border-radius: 8px; font-size: 1em; background: white;">
|
||
<option value="latest">Latest 7 frames (default)</option>
|
||
<option value="1h">Last 1 hour</option>
|
||
<option value="3h">Last 3 hours</option>
|
||
<option value="6h">Last 6 hours</option>
|
||
<option value="12h">Last 12 hours</option>
|
||
<option value="24h">Last 24 hours</option>
|
||
<option value="custom">Custom range...</option>
|
||
</select>
|
||
<div class="timestamp" style="margin-top: 6px;">Select how much historical data to include in slideshow</div>
|
||
</div>
|
||
|
||
<div id="cache-range-info" style="margin-bottom: 20px; padding: 12px; background: #f8f9fa; border-radius: 8px; font-size: 0.9em; border: 1px solid #e0e0e0;">
|
||
<div style="font-weight: 600; color: #333; margin-bottom: 5px;">📊 Available Cache Range:</div>
|
||
<div id="cache-range-text">Loading...</div>
|
||
</div>
|
||
|
||
<div style="margin-bottom: 20px;">
|
||
<label style="display: block; margin-bottom: 8px; font-weight: 600; color: #333;">Frame Interval (seconds)</label>
|
||
<input type="number" id="frame-interval-input" min="0.1" step="0.1" style="width: 100%; padding: 12px; border: 2px solid #667eea; border-radius: 8px; font-size: 1em;">
|
||
<div class="timestamp" style="margin-top: 6px;">Time between frames in slideshow (minimum 0.1 seconds)</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div style="margin-bottom: 25px; padding-top: 20px; border-top: 2px solid #f0f0f0;">
|
||
<div style="font-size: 0.85em; text-transform: uppercase; color: #667eea; font-weight: 700; letter-spacing: 0.5px; margin-bottom: 15px;">Auto Refresh</div>
|
||
|
||
<div style="margin-bottom: 20px;">
|
||
<label style="display: block; margin-bottom: 8px; font-weight: 600; color: #333;">Auto Refresh Interval (seconds)</label>
|
||
<input type="number" id="refresh-interval-input" min="5" step="5" style="width: 100%; padding: 12px; border: 2px solid #667eea; border-radius: 8px; font-size: 1em;">
|
||
<div class="timestamp" style="margin-top: 6px;">How often to check for new radar data (minimum 5 seconds)</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="custom-range-section" style="display: none; margin-bottom: 20px; padding: 15px; background: #f8f9ff; border-radius: 8px; border: 1px solid #e0e0ff;">
|
||
<div style="font-weight: 600; color: #667eea; margin-bottom: 12px;">📅 Custom Time Range</div>
|
||
<label style="display: block; margin-bottom: 8px; font-weight: 600; color: #333;">Start Time</label>
|
||
<input type="datetime-local" id="start-time-input" style="width: 100%; padding: 12px; border: 2px solid #667eea; border-radius: 8px; font-size: 1em; margin-bottom: 12px;">
|
||
|
||
<label style="display: block; margin-bottom: 8px; font-weight: 600; color: #333;">End Time</label>
|
||
<input type="datetime-local" id="end-time-input" style="width: 100%; padding: 12px; border: 2px solid #667eea; border-radius: 8px; font-size: 1em;">
|
||
|
||
<div class="timestamp" style="margin-top: 6px;">Select custom time range (times are in local timezone). Maximum range is based on your cache retention settings.</div>
|
||
</div>
|
||
|
||
<div style="margin-bottom: 25px; padding-top: 20px; border-top: 2px solid #f0f0f0;">
|
||
<div style="font-size: 0.85em; text-transform: uppercase; color: #667eea; font-weight: 700; letter-spacing: 0.5px; margin-bottom: 15px;">Playback Options</div>
|
||
|
||
<div style="margin-bottom: 20px;">
|
||
<label style="display: flex; align-items: center; cursor: pointer; padding: 12px; background: #f8f9fa; border-radius: 8px; transition: background 0.2s;"
|
||
onmouseover="this.style.background='#f0f0f0';"
|
||
onmouseout="this.style.background='#f8f9fa';">
|
||
<input type="checkbox" id="auto-play-input" style="width: 20px; height: 20px; margin-right: 12px; cursor: pointer;">
|
||
<span style="font-weight: 600; color: #333;">Auto-play on page load</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<div style="display: flex; gap: 12px; margin-top: 30px; padding-top: 20px; border-top: 2px solid #f0f0f0;">
|
||
<button class="play-btn" id="save-settings-btn" style="flex: 1; padding: 14px; font-size: 1.05em; font-weight: 600;">💾 Save Settings</button>
|
||
<button class="play-btn" id="cancel-settings-btn" style="flex: 1; background: #6c757d; padding: 14px; font-size: 1.05em; font-weight: 600;"
|
||
onmouseover="this.style.background='#5a6268';"
|
||
onmouseout="this.style.background='#6c757d';">Cancel</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
const API_BASE = '@apiBaseUrl';
|
||
let currentFrameIndex = 0;
|
||
let frames = [];
|
||
let radarData = null;
|
||
let playInterval = null;
|
||
let isPlaying = false;
|
||
let lastRefreshTime = null;
|
||
let refreshInterval = null;
|
||
let nextClientCheckTime = null;
|
||
let retryInterval = null;
|
||
let isApiDown = false;
|
||
let retryAttempts = 0;
|
||
const MAX_RETRY_ATTEMPTS = 10;
|
||
const RETRY_DELAY_MS = 5000; // 5 seconds between retries
|
||
|
||
// Settings with defaults
|
||
let 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'
|
||
};
|
||
|
||
// State for extended timespan mode
|
||
let cacheRangeInfo = null;
|
||
let historicalData = null;
|
||
let isExtendedMode = false;
|
||
|
||
// Cookie management
|
||
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=/`;
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
function loadSettings() {
|
||
const saved = getCookie('radarTestSettings');
|
||
if (saved) {
|
||
settings = { ...settings, ...saved };
|
||
}
|
||
updateSettingsUI();
|
||
}
|
||
|
||
function saveSettings() {
|
||
setCookie('radarTestSettings', settings);
|
||
updateSettingsUI();
|
||
applySettings();
|
||
}
|
||
|
||
function updateSettingsUI() {
|
||
document.getElementById('frame-interval-input').value = settings.frameInterval;
|
||
document.getElementById('refresh-interval-input').value = settings.refreshInterval;
|
||
document.getElementById('auto-play-input').checked = settings.autoPlay;
|
||
document.getElementById('timespan-select').value = settings.timespan || 'latest';
|
||
|
||
// Show/hide custom range section
|
||
const customSection = document.getElementById('custom-range-section');
|
||
if (settings.timespan === 'custom') {
|
||
customSection.style.display = 'block';
|
||
} else {
|
||
customSection.style.display = 'none';
|
||
}
|
||
|
||
// Load cache range info when settings modal opens
|
||
fetchCacheRange();
|
||
}
|
||
|
||
function applySettings() {
|
||
// Restart play interval if playing
|
||
if (isPlaying) {
|
||
pause();
|
||
play();
|
||
}
|
||
|
||
// Restart refresh interval
|
||
if (refreshInterval) {
|
||
clearInterval(refreshInterval);
|
||
}
|
||
refreshInterval = setInterval(refreshData, settings.refreshInterval * 1000);
|
||
}
|
||
|
||
// Format date for display
|
||
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'
|
||
});
|
||
}
|
||
|
||
// Calculate relative time
|
||
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)';
|
||
}
|
||
}
|
||
|
||
// Update relative times periodically
|
||
function updateRelativeTimes() {
|
||
if (radarData) {
|
||
const expiresEl = document.getElementById('cache-expires-relative');
|
||
const nextUpdateEl = document.getElementById('next-update-relative');
|
||
const updateStatusDetailEl = document.getElementById('update-status-detail');
|
||
const nextCheckEl = document.getElementById('next-client-check');
|
||
|
||
if (radarData.cacheExpiresAt && expiresEl) {
|
||
expiresEl.textContent = getRelativeTime(radarData.cacheExpiresAt);
|
||
}
|
||
|
||
if (radarData.nextUpdateTime && nextUpdateEl) {
|
||
nextUpdateEl.textContent = getRelativeTime(radarData.nextUpdateTime);
|
||
}
|
||
|
||
// Update update status detail with relative time
|
||
const estimationNoteEl = document.getElementById('estimation-note');
|
||
if (radarData.isUpdating && radarData.nextUpdateTime && updateStatusDetailEl) {
|
||
const relativeTime = getRelativeTime(radarData.nextUpdateTime);
|
||
updateStatusDetailEl.textContent = 'Update in progress • Estimated completion: ' + formatDate(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 (!radarData.cacheIsValid && !radarData.isUpdating && radarData.nextUpdateTime && updateStatusDetailEl) {
|
||
const relativeTime = getRelativeTime(radarData.nextUpdateTime);
|
||
updateStatusDetailEl.textContent = 'Update scheduled: ' + formatDate(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 (radarData.cacheIsValid && updateStatusDetailEl) {
|
||
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 (nextClientCheckTime && nextCheckEl) {
|
||
nextCheckEl.textContent = 'Next check: ' + formatDate(nextClientCheckTime.toISOString()) + ' (' + getRelativeTime(nextClientCheckTime.toISOString()) + ')';
|
||
}
|
||
}
|
||
}
|
||
|
||
// Fetch cache range information
|
||
async function fetchCacheRange() {
|
||
try {
|
||
const response = await fetch(`${API_BASE.replace('/api/radar', '/api/cache')}/range`, {
|
||
signal: AbortSignal.timeout(10000)
|
||
});
|
||
|
||
if (!response.ok) {
|
||
// Check if it's a network error
|
||
const isNetworkError = response.status === 0 || response.status >= 500;
|
||
if (isNetworkError) {
|
||
throw new Error('Network error');
|
||
}
|
||
console.warn('Failed to fetch cache range');
|
||
return { data: null, error: null };
|
||
}
|
||
|
||
const data = await response.json();
|
||
cacheRangeInfo = data;
|
||
|
||
// Update UI
|
||
const rangeTextEl = document.getElementById('cache-range-text');
|
||
if (rangeTextEl) {
|
||
if (data.totalCacheFolders > 0) {
|
||
const oldest = new Date(data.oldestCache.cacheTimestamp);
|
||
const newest = new Date(data.newestCache.cacheTimestamp);
|
||
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';
|
||
}
|
||
|
||
// Check if it's a network error (API down)
|
||
const isNetworkError = error.name === 'TypeError' ||
|
||
error.name === 'AbortError' ||
|
||
error.message.includes('fetch') ||
|
||
error.message.includes('network') ||
|
||
error.message === 'Network error';
|
||
|
||
if (isNetworkError) {
|
||
return { data: null, error: 'network' };
|
||
}
|
||
return { data: null, error: null };
|
||
}
|
||
}
|
||
|
||
// Fetch historical radar data for extended timespan
|
||
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 (e.g., time range too large, validation errors)
|
||
if (response.status === 400) {
|
||
const errorMessage = errorData.message || 'Invalid request';
|
||
let details = '';
|
||
// Check if it's a time range error with 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) {
|
||
// Check error code to determine type
|
||
if (errorData.errorCode === 'CACHE_NOT_FOUND') {
|
||
// Location doesn't exist - trigger cache update if endpoint provided
|
||
if (errorData.suggestions?.refreshEndpoint) {
|
||
fetch(errorData.suggestions.refreshEndpoint, {
|
||
method: 'POST',
|
||
signal: AbortSignal.timeout(5000)
|
||
}).catch(err => {
|
||
console.debug('Background cache refresh trigger failed (non-critical):', err);
|
||
});
|
||
}
|
||
|
||
// Return special error code to indicate location missing
|
||
return {
|
||
frames: null,
|
||
error: 'location_missing',
|
||
cacheStatus: errorData.details || errorData
|
||
};
|
||
} else if (errorData.errorCode === 'TIME_RANGE_ERROR' && errorData.details?.availableRange) {
|
||
// Cache exists but no data in requested range
|
||
const availableRange = errorData.details.availableRange;
|
||
let rangeMessage = '';
|
||
|
||
// Use suggested range from error response if available
|
||
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.`;
|
||
}
|
||
|
||
// Include suggestion text if provided
|
||
if (errorData.suggestions?.suggestion) {
|
||
rangeMessage += ` ${errorData.suggestions.suggestion}`;
|
||
}
|
||
|
||
throw new Error(errorData.message + rangeMessage);
|
||
} else {
|
||
// Generic 404
|
||
throw new Error(errorData.message || 'No historical data found');
|
||
}
|
||
}
|
||
|
||
// Other errors
|
||
throw new Error(errorData.message || 'Failed to fetch historical radar');
|
||
}
|
||
|
||
const data = await response.json();
|
||
historicalData = data;
|
||
|
||
// Flatten all frames from all cache folders (should already be in chronological order from backend)
|
||
const allFrames = [];
|
||
data.cacheFolders.forEach(cacheFolder => {
|
||
cacheFolder.frames.forEach(frame => {
|
||
// Add metadata about which cache folder this frame belongs to
|
||
frame.cacheTimestamp = cacheFolder.cacheTimestamp;
|
||
frame.observationTime = cacheFolder.observationTime;
|
||
frame.cacheFolderName = cacheFolder.cacheFolderName;
|
||
|
||
// absoluteObservationTime is already calculated by the backend
|
||
|
||
allFrames.push(frame);
|
||
});
|
||
});
|
||
|
||
// Filter out frames without absoluteObservationTime (backend should have filtered these, so this catches bugs)
|
||
const invalidFrames = allFrames.filter((frame, idx) => !frame.absoluteObservationTime);
|
||
if (invalidFrames.length > 0) {
|
||
console.error('Backend returned frames missing absoluteObservationTime (should have been filtered):', {
|
||
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 to detect backend issues
|
||
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
|
||
});
|
||
}
|
||
|
||
// Use only valid frames going forward
|
||
allFrames.length = 0;
|
||
allFrames.push(...validFrames);
|
||
|
||
// Re-index frames sequentially for display
|
||
allFrames.forEach((frame, idx) => {
|
||
frame.sequentialIndex = idx;
|
||
});
|
||
|
||
return { frames: allFrames, error: null };
|
||
} catch (error) {
|
||
console.error('Error fetching historical radar:', error);
|
||
// Check if it's a network error (API down)
|
||
const isNetworkError = error.name === 'TypeError' ||
|
||
error.name === 'AbortError' ||
|
||
error.message.includes('fetch') ||
|
||
error.message.includes('network');
|
||
|
||
if (isNetworkError) {
|
||
// Return error indicator so caller can handle it properly
|
||
return { frames: null, error: 'network' };
|
||
}
|
||
|
||
// For other errors, return empty frames (might be a valid "no data" case)
|
||
return { frames: [], error: null };
|
||
}
|
||
}
|
||
|
||
// Fetch radar data
|
||
async function fetchRadarData() {
|
||
try {
|
||
// Check if we're in extended mode
|
||
if (settings.timespan !== 'latest') {
|
||
// Fetch cache range first (if not already loaded)
|
||
if (!cacheRangeInfo) {
|
||
const rangeResult = await fetchCacheRange();
|
||
if (rangeResult.error === 'network') {
|
||
// API is down - let the error handling below deal with it
|
||
throw new Error('Network error - API unavailable');
|
||
}
|
||
cacheRangeInfo = rangeResult.data;
|
||
}
|
||
|
||
if (!cacheRangeInfo || cacheRangeInfo.totalCacheFolders === 0) {
|
||
// No cache available - trigger a refresh and show graceful message
|
||
const refreshEndpoint = `${API_BASE.replace('/api/radar', '/api/cache')}/refresh`;
|
||
fetch(refreshEndpoint, {
|
||
method: 'POST',
|
||
signal: AbortSignal.timeout(5000)
|
||
}).catch(err => {
|
||
console.debug('Background cache refresh trigger failed (non-critical):', err);
|
||
});
|
||
|
||
// Fetch cache status to update UI
|
||
try {
|
||
const statusResponse = await fetch(`${API_BASE}`, {
|
||
signal: AbortSignal.timeout(5000)
|
||
});
|
||
if (statusResponse.status === 404) {
|
||
const errorData = await statusResponse.json().catch(() => ({}));
|
||
// Determine if update is in progress
|
||
const isUpdating = errorData.updateTriggered === true ||
|
||
(errorData.message && errorData.message.includes('in progress'));
|
||
|
||
// Create a data-like object with cache status for updateUI
|
||
const cacheStatusData = {
|
||
frames: [],
|
||
isUpdating: isUpdating,
|
||
cacheIsValid: errorData.cacheIsValid || false,
|
||
cacheExpiresAt: errorData.cacheExpiresAt || null,
|
||
nextUpdateTime: errorData.nextUpdateTime || null
|
||
};
|
||
updateUI(cacheStatusData);
|
||
}
|
||
} catch (err) {
|
||
console.debug('Could not fetch cache status:', err);
|
||
}
|
||
|
||
showNoFramesMessage('No historical cache data available yet. Cache update has been triggered in the background.');
|
||
return null;
|
||
}
|
||
|
||
// Calculate time range based on settings
|
||
let startTime = null;
|
||
let endTime = new Date(); // Now
|
||
|
||
if (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 {
|
||
// Calculate hours back from now
|
||
const hours = parseInt(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') {
|
||
// API is down - let the error handling below deal with it
|
||
throw new Error('Network error - API unavailable');
|
||
}
|
||
|
||
// Handle location missing case (cache update triggered)
|
||
if (result.error === 'location_missing') {
|
||
const cacheStatus = result.cacheStatus || {};
|
||
const isUpdating = cacheStatus.updateTriggered === true ||
|
||
(cacheStatus.message && cacheStatus.message.includes('in progress'));
|
||
|
||
// Create a data-like object with cache status for updateUI
|
||
const cacheStatusData = {
|
||
frames: [],
|
||
isUpdating: isUpdating,
|
||
cacheIsValid: cacheStatus.cacheIsValid || false,
|
||
cacheExpiresAt: cacheStatus.cacheExpiresAt || null,
|
||
nextUpdateTime: cacheStatus.nextUpdateTime || null
|
||
};
|
||
updateUI(cacheStatusData);
|
||
|
||
const retryMessage = cacheStatus.retryAfter ?
|
||
` Please retry in ${cacheStatus.retryAfter} seconds.` :
|
||
' Please retry in a few moments.';
|
||
showNoFramesMessage('No cached data found for this location. Cache update has been triggered in background.' + retryMessage);
|
||
return null;
|
||
}
|
||
|
||
const allFrames = result.frames;
|
||
if (!allFrames || allFrames.length === 0) {
|
||
showNoFramesMessage('No frames found for selected timespan. Try a different range or wait for more cache data.');
|
||
return null;
|
||
}
|
||
|
||
// Fetch latest radar data to get metadata (weatherStation, distance, etc.)
|
||
// We only need the metadata, not the frames
|
||
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) {
|
||
// If we can't fetch metadata, use defaults from historical data
|
||
console.debug('Could not fetch metadata for extended mode:', err);
|
||
}
|
||
|
||
// Create a RadarResponse-like object for compatibility
|
||
const newestCacheFolder = historicalData.cacheFolders[historicalData.cacheFolders.length - 1];
|
||
const data = {
|
||
frames: allFrames,
|
||
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: allFrames.length,
|
||
isExtendedMode: true
|
||
};
|
||
|
||
lastRefreshTime = new Date();
|
||
|
||
// API is working - reset retry state
|
||
if (isApiDown) {
|
||
isApiDown = false;
|
||
retryAttempts = 0;
|
||
clearError();
|
||
if (retryInterval) {
|
||
clearInterval(retryInterval);
|
||
retryInterval = null;
|
||
}
|
||
updateApiStatus('connected');
|
||
}
|
||
|
||
return data;
|
||
} else {
|
||
// Original logic for latest 7 frames
|
||
const response = await fetch(API_BASE, {
|
||
signal: AbortSignal.timeout(10000) // 10 second timeout
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const error = await response.json().catch(() => ({ message: `HTTP ${response.status}: ${response.statusText}` }));
|
||
|
||
// Handle 404 more gracefully - cache is being generated
|
||
if (response.status === 404) {
|
||
const errorData = await response.json().catch(() => ({}));
|
||
lastRefreshTime = new Date(); // Track that we checked
|
||
|
||
// API is working - reset retry state
|
||
if (isApiDown) {
|
||
isApiDown = false;
|
||
retryAttempts = 0;
|
||
clearError();
|
||
if (retryInterval) {
|
||
clearInterval(retryInterval);
|
||
retryInterval = null;
|
||
}
|
||
updateApiStatus('connected');
|
||
}
|
||
|
||
// Use standardized error response format
|
||
// Explicitly trigger cache refresh if refreshEndpoint is provided
|
||
const refreshEndpoint = errorData.suggestions?.refreshEndpoint || errorData.refreshEndpoint;
|
||
if (refreshEndpoint) {
|
||
// Trigger cache update in background (fire and forget)
|
||
fetch(refreshEndpoint, {
|
||
method: 'POST',
|
||
signal: AbortSignal.timeout(5000)
|
||
}).catch(err => {
|
||
console.debug('Background cache refresh trigger failed (non-critical):', err);
|
||
});
|
||
}
|
||
|
||
// Use actual cache status from API if available
|
||
// Check both new format (details) and old format for backward compatibility
|
||
const details = errorData.details || errorData;
|
||
if (details.updateTriggered !== undefined || details.cacheIsValid !== undefined || details.cacheExists !== undefined) {
|
||
// Determine if update is in progress
|
||
const isUpdating = details.updateTriggered === true ||
|
||
(errorData.message && errorData.message.includes('in progress')) ||
|
||
(details.statusMessage && details.statusMessage.includes('in progress'));
|
||
|
||
// Create a data-like object with cache status for updateUI
|
||
const cacheStatusData = {
|
||
frames: [],
|
||
isUpdating: isUpdating,
|
||
cacheIsValid: details.cacheIsValid || false,
|
||
cacheExpiresAt: details.cacheExpiresAt || null,
|
||
nextUpdateTime: details.nextUpdateTime || null
|
||
};
|
||
console.debug('Updating UI with cache status from 404:', cacheStatusData);
|
||
updateUI(cacheStatusData);
|
||
} else {
|
||
// No cache status info at all - set default updating status
|
||
console.debug('No cache status info available, setting default updating status');
|
||
const defaultStatusData = {
|
||
frames: [],
|
||
isUpdating: true,
|
||
cacheIsValid: false
|
||
};
|
||
updateUI(defaultStatusData);
|
||
}
|
||
|
||
const retryAfter = errorData.suggestions?.retryAfter || errorData.retryAfter || 30;
|
||
const message = errorData.message || `Cache is being generated. Please wait ${retryAfter} seconds and refresh.`;
|
||
showNoFramesMessage(message, refreshEndpoint);
|
||
return null;
|
||
}
|
||
|
||
throw new Error(error.message || error.error || 'Failed to fetch radar data');
|
||
}
|
||
|
||
const data = await response.json();
|
||
lastRefreshTime = new Date();
|
||
data.isExtendedMode = false;
|
||
|
||
// API is working - reset retry state
|
||
if (isApiDown) {
|
||
isApiDown = false;
|
||
retryAttempts = 0;
|
||
clearError();
|
||
if (retryInterval) {
|
||
clearInterval(retryInterval);
|
||
retryInterval = null;
|
||
}
|
||
updateApiStatus('connected');
|
||
}
|
||
|
||
return data;
|
||
}
|
||
} catch (error) {
|
||
console.error('Error fetching radar data:', error);
|
||
|
||
// Check if it's a network error (API down)
|
||
const isNetworkError = error.name === 'TypeError' ||
|
||
error.name === 'AbortError' ||
|
||
error.message.includes('fetch') ||
|
||
error.message.includes('network') ||
|
||
error.message.includes('Failed to fetch');
|
||
|
||
if (isNetworkError) {
|
||
handleApiDown(error);
|
||
} else {
|
||
showError(error.message);
|
||
}
|
||
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// Handle API being down
|
||
function handleApiDown(error) {
|
||
if (!isApiDown) {
|
||
isApiDown = true;
|
||
retryAttempts = 0;
|
||
updateApiStatus('disconnected');
|
||
}
|
||
|
||
retryAttempts++;
|
||
showError(`API connection lost. Retrying... (Attempt ${retryAttempts}/${MAX_RETRY_ATTEMPTS})`);
|
||
|
||
// Stop auto-refresh while retrying
|
||
if (refreshInterval) {
|
||
clearInterval(refreshInterval);
|
||
refreshInterval = null;
|
||
}
|
||
|
||
// Start retry mechanism
|
||
if (!retryInterval && retryAttempts < MAX_RETRY_ATTEMPTS) {
|
||
retryInterval = setInterval(async () => {
|
||
if (retryAttempts >= MAX_RETRY_ATTEMPTS) {
|
||
clearInterval(retryInterval);
|
||
retryInterval = null;
|
||
showError('API connection failed after multiple attempts. Please check if the service is running.');
|
||
updateApiStatus('failed');
|
||
return;
|
||
}
|
||
|
||
retryAttempts++;
|
||
showError(`API connection lost. Retrying... (Attempt ${retryAttempts}/${MAX_RETRY_ATTEMPTS})`);
|
||
|
||
// Try to fetch data
|
||
const data = await fetchRadarData();
|
||
if (data) {
|
||
// Success - updateUI will be called
|
||
updateUI(data);
|
||
}
|
||
}, RETRY_DELAY_MS);
|
||
} else if (retryAttempts >= MAX_RETRY_ATTEMPTS) {
|
||
showError('API connection failed after multiple attempts. Please check if the service is running.');
|
||
updateApiStatus('failed');
|
||
}
|
||
}
|
||
|
||
// Update API connection status indicator
|
||
function updateApiStatus(status) {
|
||
const refreshStatusEl = document.getElementById('refresh-status');
|
||
const refreshIndicatorEl = document.getElementById('refresh-indicator');
|
||
|
||
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
|
||
function showError(message) {
|
||
const container = document.getElementById('error-container');
|
||
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 (cache being generated)
|
||
function showNoFramesMessage(message, refreshEndpoint = null) {
|
||
const container = document.getElementById('error-container');
|
||
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
|
||
document.getElementById('loading').textContent = message;
|
||
document.getElementById('radar-image').style.display = 'none';
|
||
}
|
||
|
||
// Clear error message
|
||
function clearError() {
|
||
document.getElementById('error-container').innerHTML = '';
|
||
}
|
||
|
||
// Fetch metadata separately (when frames aren't available yet)
|
||
async function fetchMetadata() {
|
||
try {
|
||
const response = await fetch(`${API_BASE}/metadata`, {
|
||
signal: AbortSignal.timeout(5000)
|
||
});
|
||
|
||
if (response.ok) {
|
||
const metadata = await response.json();
|
||
return metadata;
|
||
}
|
||
} catch (error) {
|
||
console.debug('Could not fetch metadata:', error);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// Update status cards with cache update information (when cache is being generated)
|
||
function updateStatusForCacheUpdate(retryAfter, refreshEndpoint) {
|
||
const statusEl = document.getElementById('cache-status');
|
||
statusEl.innerHTML = 'Generating <span class="status-badge status-updating">IN PROGRESS</span>';
|
||
|
||
const updateStatusDetailEl = document.getElementById('update-status-detail');
|
||
|
||
if (retryAfter) {
|
||
const estimatedTime = new Date(Date.now() + (retryAfter * 1000));
|
||
updateStatusDetailEl.textContent = `Cache generation in progress. Estimated ready in ${retryAfter} seconds.`;
|
||
document.getElementById('next-update').textContent = formatDate(estimatedTime.toISOString());
|
||
} else {
|
||
updateStatusDetailEl.textContent = 'Cache generation in progress, please wait...';
|
||
document.getElementById('next-update').textContent = '-';
|
||
}
|
||
|
||
// Set other fields to indicate cache is being generated
|
||
document.getElementById('observation-time').textContent = '-';
|
||
document.getElementById('cache-expires').textContent = '-';
|
||
document.getElementById('weather-station').textContent = '-';
|
||
document.getElementById('distance').textContent = '-';
|
||
|
||
// Update last refresh
|
||
if (lastRefreshTime) {
|
||
document.getElementById('last-refresh').textContent =
|
||
'Last checked: ' + formatDate(lastRefreshTime.toISOString());
|
||
|
||
// Calculate and display next client check time
|
||
nextClientCheckTime = new Date(lastRefreshTime.getTime() + (settings.refreshInterval * 1000));
|
||
const nextCheckEl = document.getElementById('next-client-check');
|
||
if (nextCheckEl) {
|
||
nextCheckEl.textContent = 'Next check: ' + formatDate(nextClientCheckTime.toISOString()) + ' (' + getRelativeTime(nextClientCheckTime.toISOString()) + ')';
|
||
}
|
||
}
|
||
}
|
||
|
||
// Update UI with radar data
|
||
function updateUI(data) {
|
||
// Update cache status even if no frames are available (if we have metadata)
|
||
if (data) {
|
||
// Update cache status
|
||
const statusEl = document.getElementById('cache-status');
|
||
if (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 (now shown below cache status in combined card)
|
||
const updateStatusDetailEl = document.getElementById('update-status-detail');
|
||
const estimationNoteEl = document.getElementById('estimation-note');
|
||
if (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 available
|
||
if (data.observationTime) {
|
||
document.getElementById('observation-time').textContent = formatDate(data.observationTime);
|
||
}
|
||
if (data.cacheExpiresAt) {
|
||
document.getElementById('cache-expires').textContent = formatDate(data.cacheExpiresAt);
|
||
}
|
||
if (data.nextUpdateTime) {
|
||
document.getElementById('next-update').textContent = formatDate(data.nextUpdateTime);
|
||
}
|
||
if (data.weatherStation !== undefined) {
|
||
document.getElementById('weather-station').textContent = data.weatherStation || '-';
|
||
}
|
||
if (data.distance !== undefined) {
|
||
document.getElementById('distance').textContent = data.distance || '-';
|
||
}
|
||
}
|
||
|
||
if (!data || !data.frames || data.frames.length === 0) {
|
||
// No frames available - show message and disable controls
|
||
// Status should already be set by updateUI if cache status was provided
|
||
// Only set default status if it's still "Checking..."
|
||
const currentStatus = document.getElementById('cache-status').innerHTML;
|
||
if (currentStatus === 'Checking...' || currentStatus.trim() === '') {
|
||
// Status hasn't been set yet, set it to indicate cache is being generated
|
||
const statusEl = document.getElementById('cache-status');
|
||
statusEl.innerHTML = 'Generating <span class="status-badge status-updating">IN PROGRESS</span>';
|
||
|
||
const updateStatusDetailEl = document.getElementById('update-status-detail');
|
||
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.');
|
||
frames = [];
|
||
document.getElementById('frame-controls').innerHTML = '';
|
||
document.getElementById('play-btn').disabled = true;
|
||
document.getElementById('prev-btn').disabled = true;
|
||
document.getElementById('next-btn').disabled = true;
|
||
document.getElementById('frame-info').textContent = 'Waiting for cache to be generated...';
|
||
return;
|
||
}
|
||
|
||
radarData = data;
|
||
isExtendedMode = data.isExtendedMode || false;
|
||
|
||
// Enable controls since we have frames
|
||
document.getElementById('play-btn').disabled = false;
|
||
document.getElementById('prev-btn').disabled = false;
|
||
document.getElementById('next-btn').disabled = false;
|
||
|
||
// Sort frames appropriately
|
||
if (isExtendedMode) {
|
||
// Already sorted by fetchHistoricalRadar
|
||
frames = data.frames;
|
||
} else {
|
||
// Original sorting
|
||
frames = data.frames.sort((a, b) => a.frameIndex - b.frameIndex);
|
||
}
|
||
|
||
// Update observation time
|
||
document.getElementById('observation-time').textContent = formatDate(data.observationTime);
|
||
|
||
// Update cache expires
|
||
document.getElementById('cache-expires').textContent = formatDate(data.cacheExpiresAt);
|
||
|
||
// Update next update
|
||
document.getElementById('next-update').textContent = formatDate(data.nextUpdateTime);
|
||
|
||
// Update weather station
|
||
document.getElementById('weather-station').textContent = data.weatherStation || '-';
|
||
|
||
// Update distance
|
||
document.getElementById('distance').textContent = data.distance || '-';
|
||
|
||
// Update last refresh
|
||
if (lastRefreshTime) {
|
||
document.getElementById('last-refresh').textContent =
|
||
'Last checked: ' + formatDate(lastRefreshTime.toISOString());
|
||
|
||
// Calculate and display next client check time
|
||
nextClientCheckTime = new Date(lastRefreshTime.getTime() + (settings.refreshInterval * 1000));
|
||
const nextCheckEl = document.getElementById('next-client-check');
|
||
if (nextCheckEl) {
|
||
nextCheckEl.textContent = 'Next check: ' + formatDate(nextClientCheckTime.toISOString()) + ' (' + getRelativeTime(nextClientCheckTime.toISOString()) + ')';
|
||
}
|
||
}
|
||
|
||
// Build frame controls - always use fixed-width slider for consistency
|
||
const controlsEl = document.getElementById('frame-controls');
|
||
controlsEl.innerHTML = `
|
||
<div class="frame-slider-container">
|
||
<div class="frame-slider-wrapper">
|
||
<button class="frame-nav-btn" id="frame-first-btn" onclick="showFrame(0)" title="First frame" aria-label="First frame">⏮</button>
|
||
<button class="frame-nav-btn" id="frame-prev-10-btn" onclick="jumpFrame(-10)" title="Go back 10 frames" aria-label="Go back 10 frames">-10</button>
|
||
<input type="range" class="frame-slider" id="frame-slider" min="0" max="${frames.length - 1}" value="${currentFrameIndex}"
|
||
oninput="updateFrameFromSlider(parseInt(this.value))" aria-label="Frame slider">
|
||
<button class="frame-nav-btn" id="frame-next-10-btn" onclick="jumpFrame(10)" title="Go forward 10 frames" aria-label="Go forward 10 frames">+10</button>
|
||
<button class="frame-nav-btn" id="frame-last-btn" onclick="showFrame(${frames.length - 1})" 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="${frames.length - 1}" value="0"
|
||
onchange="jumpToFrame(parseInt(this.value))"
|
||
onkeypress="if(event.key==='Enter') jumpToFrame(parseInt(this.value))">
|
||
<span class="frame-info-value">/ ${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>
|
||
`;
|
||
// Update initial display
|
||
updateFrameSliderInfo(0);
|
||
|
||
// Preload all frame images to prevent jiggle when switching
|
||
preloadImages(frames);
|
||
|
||
// Preserve slideshow state during refresh
|
||
if (frames.length > 0) {
|
||
let frameToShow = 0;
|
||
const wasPlaying = isPlaying;
|
||
const previousFrameIndex = currentFrameIndex;
|
||
|
||
// If we have a previous frame index and frames exist, try to preserve position
|
||
if (previousFrameIndex >= 0 && frames.length > 0) {
|
||
// Try to find frame with matching absoluteObservationTime from previous frames
|
||
if (radarData && radarData.frames && radarData.frames[previousFrameIndex]) {
|
||
const previousFrame = radarData.frames[previousFrameIndex];
|
||
if (previousFrame.absoluteObservationTime) {
|
||
// Find frame with same absolute observation time
|
||
const matchingIndex = frames.findIndex(f =>
|
||
f.absoluteObservationTime === previousFrame.absoluteObservationTime
|
||
);
|
||
if (matchingIndex >= 0) {
|
||
frameToShow = matchingIndex;
|
||
} else {
|
||
// Fallback: keep same index if valid, or find closest time
|
||
if (previousFrameIndex < frames.length) {
|
||
frameToShow = previousFrameIndex;
|
||
} else {
|
||
// Find closest frame by time
|
||
const previousTime = new Date(previousFrame.absoluteObservationTime).getTime();
|
||
let closestIndex = 0;
|
||
let closestDiff = Infinity;
|
||
frames.forEach((f, idx) => {
|
||
if (f.absoluteObservationTime) {
|
||
const diff = Math.abs(new Date(f.absoluteObservationTime).getTime() - previousTime);
|
||
if (diff < closestDiff) {
|
||
closestDiff = diff;
|
||
closestIndex = idx;
|
||
}
|
||
}
|
||
});
|
||
frameToShow = closestIndex;
|
||
}
|
||
}
|
||
} else if (previousFrameIndex < frames.length) {
|
||
// Fallback: keep same index if valid
|
||
frameToShow = previousFrameIndex;
|
||
}
|
||
} else if (previousFrameIndex < frames.length) {
|
||
// Fallback: keep same index if valid
|
||
frameToShow = previousFrameIndex;
|
||
}
|
||
}
|
||
|
||
showFrame(frameToShow);
|
||
|
||
// Restart play if it was playing before refresh
|
||
if (wasPlaying) {
|
||
setTimeout(() => {
|
||
if (!isPlaying) {
|
||
play();
|
||
}
|
||
}, 100);
|
||
} else if (settings.autoPlay && !isPlaying) {
|
||
// Auto-play on initial load only
|
||
setTimeout(() => play(), 500);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Preload all frame images to prevent jiggle
|
||
function preloadImages(frames) {
|
||
frames.forEach(frame => {
|
||
const img = new Image();
|
||
img.src = frame.imageUrl;
|
||
});
|
||
}
|
||
|
||
// Update frame slider info display
|
||
function updateFrameSliderInfo(index) {
|
||
if (index < 0 || index >= frames.length) return;
|
||
|
||
const frame = frames[index];
|
||
const progress = Math.round(((index + 1) / 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) {
|
||
if (frame.absoluteObservationTime) {
|
||
const minutesAgo = Math.round((Date.now() - new Date(frame.absoluteObservationTime).getTime()) / 60000);
|
||
timeDisplay.textContent = `${minutesAgo} min ago`;
|
||
} else {
|
||
timeDisplay.textContent = '-';
|
||
}
|
||
}
|
||
|
||
// Update progress
|
||
const progressDisplay = document.getElementById('frame-progress');
|
||
if (progressDisplay) {
|
||
progressDisplay.textContent = `${progress}%`;
|
||
}
|
||
|
||
// Update navigation buttons
|
||
const firstBtn = document.getElementById('frame-first-btn');
|
||
const prev10Btn = document.getElementById('frame-prev-10-btn');
|
||
const next10Btn = document.getElementById('frame-next-10-btn');
|
||
const lastBtn = document.getElementById('frame-last-btn');
|
||
|
||
if (firstBtn) firstBtn.disabled = index === 0;
|
||
if (prev10Btn) prev10Btn.disabled = index === 0;
|
||
if (next10Btn) next10Btn.disabled = index >= frames.length - 1;
|
||
if (lastBtn) lastBtn.disabled = index >= frames.length - 1;
|
||
}
|
||
|
||
// Update frame from slider (with debouncing for smooth updates)
|
||
let sliderUpdateTimeout;
|
||
function updateFrameFromSlider(value) {
|
||
clearTimeout(sliderUpdateTimeout);
|
||
sliderUpdateTimeout = setTimeout(() => {
|
||
showFrame(value);
|
||
}, 50); // Small delay for smoother slider experience
|
||
}
|
||
|
||
// Jump to specific frame number
|
||
function jumpToFrame(frameNum) {
|
||
const index = Math.max(0, Math.min(frames.length - 1, frameNum));
|
||
showFrame(index);
|
||
}
|
||
|
||
// Jump forward/backward by N frames
|
||
function jumpFrame(offset) {
|
||
const newIndex = Math.max(0, Math.min(frames.length - 1, currentFrameIndex + offset));
|
||
showFrame(newIndex);
|
||
}
|
||
|
||
// Show specific frame
|
||
function showFrame(index) {
|
||
if (index < 0 || index >= frames.length) {
|
||
console.error(`showFrame: Invalid index ${index}, frames.length = ${frames.length}`);
|
||
return;
|
||
}
|
||
|
||
if (!frames[index]) {
|
||
console.error(`showFrame: Frame at index ${index} is undefined`);
|
||
return;
|
||
}
|
||
|
||
currentFrameIndex = index;
|
||
const frame = frames[index];
|
||
|
||
// Update slider info (always using slider now)
|
||
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');
|
||
|
||
// Build alt text based on mode
|
||
let altText;
|
||
if (isExtendedMode) {
|
||
const frameNum = frame.sequentialIndex !== undefined ? frame.sequentialIndex : index;
|
||
const timeInfo = frame.absoluteObservationTime
|
||
? formatDate(frame.absoluteObservationTime)
|
||
: (frame.cacheTimestamp ? formatDate(frame.cacheTimestamp) : '');
|
||
altText = `Radar frame ${frameNum}${timeInfo ? ' (' + timeInfo + ')' : ''}`;
|
||
} else {
|
||
const minutesAgo = frame.absoluteObservationTime
|
||
? Math.round((Date.now() - new Date(frame.absoluteObservationTime).getTime()) / 60000)
|
||
: null;
|
||
altText = `Radar frame ${frame.frameIndex}${minutesAgo !== null ? ` (${minutesAgo} minutes ago)` : ''}`;
|
||
}
|
||
|
||
// Check if image is already loaded (from preload)
|
||
const preloadedImg = new Image();
|
||
preloadedImg.onload = () => {
|
||
// Image is ready, switch immediately to prevent jiggle
|
||
imgEl.src = frame.imageUrl;
|
||
imgEl.alt = altText;
|
||
loadingEl.style.display = 'none';
|
||
imgEl.style.display = 'block';
|
||
};
|
||
preloadedImg.onerror = () => {
|
||
// Fallback if preload failed
|
||
imgEl.src = frame.imageUrl;
|
||
imgEl.alt = altText;
|
||
};
|
||
preloadedImg.src = frame.imageUrl;
|
||
|
||
// Update frame info
|
||
if (isExtendedMode) {
|
||
const frameNum = frame.sequentialIndex !== undefined ? frame.sequentialIndex : index;
|
||
const timeInfo = frame.absoluteObservationTime
|
||
? formatDate(frame.absoluteObservationTime)
|
||
: (frame.cacheTimestamp ? formatDate(frame.cacheTimestamp) : '');
|
||
document.getElementById('frame-info').textContent =
|
||
`Frame ${frameNum} of ${frames.length - 1}${timeInfo ? ' • ' + timeInfo : ''}`;
|
||
} else {
|
||
const minutesAgo = frame.absoluteObservationTime
|
||
? Math.round((Date.now() - new Date(frame.absoluteObservationTime).getTime()) / 60000)
|
||
: null;
|
||
document.getElementById('frame-info').textContent =
|
||
`Frame ${frame.frameIndex} of ${frames.length - 1}${minutesAgo !== null ? ` • ${minutesAgo} minutes ago` : ''}`;
|
||
}
|
||
}
|
||
|
||
// Play/pause slideshow
|
||
function togglePlay() {
|
||
if (isPlaying) {
|
||
pause();
|
||
} else {
|
||
play();
|
||
}
|
||
}
|
||
|
||
function play() {
|
||
if (frames.length === 0) return;
|
||
|
||
isPlaying = true;
|
||
document.getElementById('play-btn').textContent = '⏸ Pause';
|
||
|
||
if (playInterval) {
|
||
clearInterval(playInterval);
|
||
}
|
||
|
||
playInterval = setInterval(() => {
|
||
currentFrameIndex = (currentFrameIndex + 1) % frames.length;
|
||
showFrame(currentFrameIndex);
|
||
}, settings.frameInterval * 1000);
|
||
}
|
||
|
||
function pause() {
|
||
isPlaying = false;
|
||
document.getElementById('play-btn').textContent = '▶ Play';
|
||
|
||
if (playInterval) {
|
||
clearInterval(playInterval);
|
||
playInterval = null;
|
||
}
|
||
}
|
||
|
||
// Previous/Next frame
|
||
function previousFrame() {
|
||
pause();
|
||
const newIndex = currentFrameIndex > 0 ? currentFrameIndex - 1 : frames.length - 1;
|
||
showFrame(newIndex);
|
||
}
|
||
|
||
function nextFrame() {
|
||
pause();
|
||
const newIndex = (currentFrameIndex + 1) % frames.length;
|
||
showFrame(newIndex);
|
||
}
|
||
|
||
// Auto-refresh data
|
||
async function refreshData() {
|
||
// Don't clear error if API is down (keep retry message visible)
|
||
if (!isApiDown) {
|
||
clearError();
|
||
}
|
||
|
||
const data = await fetchRadarData();
|
||
if (data) {
|
||
updateUI(data);
|
||
|
||
// Restart auto-refresh if it was stopped
|
||
if (!refreshInterval && !isApiDown) {
|
||
refreshInterval = setInterval(refreshData, settings.refreshInterval * 1000);
|
||
}
|
||
}
|
||
// Note: If data is null (e.g., 404), updateStatusForCacheUpdate was already called
|
||
// and status should already be updated, so we don't need to do anything here
|
||
}
|
||
|
||
// Settings modal management
|
||
function showSettings() {
|
||
document.getElementById('settings-modal').style.display = 'flex';
|
||
}
|
||
|
||
function hideSettings() {
|
||
document.getElementById('settings-modal').style.display = 'none';
|
||
}
|
||
|
||
// Keyboard navigation
|
||
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) {
|
||
jumpFrame(-10); // Shift+Left: jump back 10
|
||
} else {
|
||
showFrame(Math.max(0, currentFrameIndex - 1)); // Left: previous frame
|
||
}
|
||
} else {
|
||
if (e.shiftKey) {
|
||
jumpFrame(10); // Shift+Right: jump forward 10
|
||
} else {
|
||
showFrame(Math.min(frames.length - 1, 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(frames.length - 1);
|
||
}
|
||
|
||
// Spacebar for play/pause
|
||
if (e.key === ' ' && frames.length > 0) {
|
||
e.preventDefault();
|
||
togglePlay();
|
||
}
|
||
});
|
||
|
||
// Initialize
|
||
async function init() {
|
||
// Load settings from cookies
|
||
loadSettings();
|
||
|
||
// Set up event listeners
|
||
document.getElementById('play-btn').addEventListener('click', togglePlay);
|
||
document.getElementById('prev-btn').addEventListener('click', previousFrame);
|
||
document.getElementById('next-btn').addEventListener('click', nextFrame);
|
||
|
||
// Settings button (now in header)
|
||
const settingsBtn = document.getElementById('settings-btn-header');
|
||
if (settingsBtn) {
|
||
settingsBtn.addEventListener('click', showSettings);
|
||
}
|
||
document.getElementById('save-settings-btn').addEventListener('click', () => {
|
||
const frameIntervalValue = parseFloat(document.getElementById('frame-interval-input').value);
|
||
settings.frameInterval = (frameIntervalValue > 0) ? frameIntervalValue : 2.0;
|
||
|
||
const refreshIntervalValue = parseInt(document.getElementById('refresh-interval-input').value);
|
||
settings.refreshInterval = (refreshIntervalValue >= 5) ? refreshIntervalValue : 30;
|
||
settings.autoPlay = document.getElementById('auto-play-input').checked;
|
||
settings.timespan = document.getElementById('timespan-select').value;
|
||
|
||
if (settings.timespan === 'custom') {
|
||
// Store custom times if provided
|
||
const startInput = document.getElementById('start-time-input');
|
||
const endInput = document.getElementById('end-time-input');
|
||
if (startInput && startInput.value) {
|
||
settings.customStartTime = startInput.value;
|
||
}
|
||
if (endInput && endInput.value) {
|
||
settings.customEndTime = endInput.value;
|
||
}
|
||
}
|
||
|
||
saveSettings();
|
||
hideSettings();
|
||
|
||
// Reload data with new timespan
|
||
refreshData();
|
||
});
|
||
|
||
// Handle timespan select change
|
||
document.getElementById('timespan-select').addEventListener('change', (e) => {
|
||
settings.timespan = e.target.value;
|
||
const customSection = document.getElementById('custom-range-section');
|
||
if (e.target.value === 'custom') {
|
||
customSection.style.display = 'block';
|
||
} else {
|
||
customSection.style.display = 'none';
|
||
}
|
||
});
|
||
document.getElementById('cancel-settings-btn').addEventListener('click', () => {
|
||
updateSettingsUI(); // Reset to current settings
|
||
hideSettings();
|
||
});
|
||
|
||
// Close button handler
|
||
document.getElementById('close-settings-btn').addEventListener('click', hideSettings);
|
||
|
||
// Close modal on background click
|
||
document.getElementById('settings-modal').addEventListener('click', (e) => {
|
||
if (e.target.id === 'settings-modal') {
|
||
hideSettings();
|
||
}
|
||
});
|
||
|
||
// Initial load
|
||
await refreshData();
|
||
|
||
// Auto-refresh based on settings (only if API is up)
|
||
if (!isApiDown) {
|
||
refreshInterval = setInterval(refreshData, settings.refreshInterval * 1000);
|
||
}
|
||
|
||
// Update relative times every second
|
||
setInterval(updateRelativeTimes, 1000);
|
||
|
||
// Auto-play if enabled
|
||
if (settings.autoPlay && frames.length > 0) {
|
||
setTimeout(() => play(), 1000); // Small delay to ensure frames are loaded
|
||
}
|
||
}
|
||
|
||
// Start when page loads
|
||
init();
|
||
</script>
|
||
</body>
|
||
</html>
|
||
|