mirror of
https://github.com/alexhopeoconnor/bom-local-card.git
synced 2026-10-04 04:28:11 +10:00
Refactor: Extract components, services, utilities, and styles
- Extract error handling to utils/error-handler.ts - Extract API service to services/radar-api-service.ts - Extract utilities: url-resolver.ts, date-formatter.ts - Extract CSS styles to modular style files - Extract components: error-display, metadata-section, controls-section - Remove dead code and unused imports - Reduce main file from 2140 to 739 lines (65% reduction)
This commit is contained in:
+91
-1940
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,244 @@
|
||||
import { LitElement, html, type TemplateResult } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { controlsStyles } from '../styles/controls-styles';
|
||||
import { overlayStyles } from '../styles/overlay-styles';
|
||||
import { formatTimestamp } from '../utils/date-formatter';
|
||||
import type { RadarFrame, RadarResponse, ControlsDisplayConfig } from '../types';
|
||||
|
||||
@customElement('bom-controls-section')
|
||||
export class BomControlsSection extends LitElement {
|
||||
static override styles = [controlsStyles, overlayStyles];
|
||||
|
||||
@property({ attribute: false }) frames: RadarFrame[] = [];
|
||||
@property({ attribute: false }) currentFrameIndex: number = 0;
|
||||
@property({ attribute: false }) isPlaying: boolean = false;
|
||||
@property({ attribute: false }) isExtendedMode: boolean = false;
|
||||
@property({ attribute: false }) radarData?: RadarResponse;
|
||||
@property({ attribute: false }) config?: ControlsDisplayConfig | boolean;
|
||||
@property({ attribute: false }) position?: 'above' | 'below' | 'overlay';
|
||||
@property({ attribute: false }) locale: string = 'en-AU';
|
||||
@property({ attribute: false }) timeZone?: string;
|
||||
@property({ attribute: false }) overlayOpacity?: number;
|
||||
@property({ attribute: false }) overlayPosition?: string;
|
||||
@property({ attribute: false }) showFrameTimes?: boolean;
|
||||
|
||||
// Event handlers
|
||||
@property({ attribute: false }) onFrameChange?: (index: number) => void;
|
||||
@property({ attribute: false }) onPrevious?: () => void;
|
||||
@property({ attribute: false }) onNext?: () => void;
|
||||
@property({ attribute: false }) onJumpFrame?: (offset: number) => void;
|
||||
@property({ attribute: false }) onToggleAnimation?: () => void;
|
||||
|
||||
private _getDisplayConfig(): ControlsDisplayConfig | null {
|
||||
if (!this.config || (typeof this.config === 'boolean' && !this.config)) {
|
||||
return null;
|
||||
}
|
||||
return typeof this.config === 'object' ? this.config : {};
|
||||
}
|
||||
|
||||
protected override render(): TemplateResult {
|
||||
const displayConfig = this._getDisplayConfig();
|
||||
if (!displayConfig) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
const configPosition = displayConfig.position ?? 'below';
|
||||
|
||||
// Only render if position matches (or if no position filter specified)
|
||||
if (this.position !== undefined && configPosition !== this.position) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
// If overlay, render differently
|
||||
if (configPosition === 'overlay') {
|
||||
return this._renderOverlay(displayConfig);
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="controls-section controls-${configPosition}">
|
||||
${displayConfig.show_slider !== false ? this._renderFrameSlider() : ''}
|
||||
${displayConfig.show_nav_buttons !== false ? this._renderNavButtons() : ''}
|
||||
${displayConfig.show_play_pause !== false ? this._renderPlayPause() : ''}
|
||||
${displayConfig.show_prev_next !== false ? this._renderPrevNext() : ''}
|
||||
${displayConfig.show_frame_info !== false ? this._renderFrameInfo() : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderOverlay(displayConfig: ControlsDisplayConfig): TemplateResult {
|
||||
const opacity = this.overlayOpacity ?? 0.9;
|
||||
const position = this.overlayPosition ?? 'bottom';
|
||||
|
||||
return html`
|
||||
<div class="controls-overlay overlay-${position}" style="opacity: ${opacity};">
|
||||
${displayConfig.show_slider !== false ? this._renderFrameSlider() : ''}
|
||||
${displayConfig.show_play_pause !== false ? this._renderPlayPause() : ''}
|
||||
${displayConfig.show_prev_next !== false ? this._renderPrevNext() : ''}
|
||||
${displayConfig.show_frame_info !== false ? this._renderFrameInfo() : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderFrameSlider(): TemplateResult {
|
||||
if (this.frames.length === 0) return html``;
|
||||
|
||||
return html`
|
||||
<div class="frame-slider-container">
|
||||
<div class="frame-slider-wrapper">
|
||||
<input
|
||||
type="range"
|
||||
class="frame-slider"
|
||||
min="0"
|
||||
max="${this.frames.length - 1}"
|
||||
.value="${this.currentFrameIndex}"
|
||||
@input="${(e: Event) => {
|
||||
const index = parseInt((e.target as HTMLInputElement).value);
|
||||
if (this.onFrameChange) {
|
||||
this.onFrameChange(index);
|
||||
}
|
||||
}}"
|
||||
aria-label="Frame slider"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderNavButtons(): TemplateResult {
|
||||
if (this.frames.length === 0) return html``;
|
||||
|
||||
// Calculate dynamic skip amount based on total frames (1/3 of total, min 1, max 50)
|
||||
const skipAmount = this.isExtendedMode
|
||||
? Math.max(1, Math.min(50, Math.round(this.frames.length / 3)))
|
||||
: 0;
|
||||
|
||||
return html`
|
||||
<div class="frame-nav-buttons">
|
||||
${this.isExtendedMode ? html`
|
||||
<button
|
||||
class="frame-nav-btn"
|
||||
@click="${() => this.onFrameChange && this.onFrameChange(0)}"
|
||||
?disabled="${this.currentFrameIndex === 0}"
|
||||
title="First frame"
|
||||
aria-label="First frame"
|
||||
>⏮</button>
|
||||
<button
|
||||
class="frame-nav-btn"
|
||||
@click="${() => this.onJumpFrame && this.onJumpFrame(-skipAmount)}"
|
||||
?disabled="${this.currentFrameIndex === 0}"
|
||||
title="Go back ${skipAmount} frames"
|
||||
aria-label="Go back ${skipAmount} frames"
|
||||
>-${skipAmount}</button>
|
||||
<button
|
||||
class="frame-nav-btn"
|
||||
@click="${() => this.onJumpFrame && this.onJumpFrame(skipAmount)}"
|
||||
?disabled="${this.currentFrameIndex >= this.frames.length - 1}"
|
||||
title="Go forward ${skipAmount} frames"
|
||||
aria-label="Go forward ${skipAmount} frames"
|
||||
>+${skipAmount}</button>
|
||||
<button
|
||||
class="frame-nav-btn"
|
||||
@click="${() => this.onFrameChange && this.onFrameChange(this.frames.length - 1)}"
|
||||
?disabled="${this.currentFrameIndex >= this.frames.length - 1}"
|
||||
title="Last frame"
|
||||
aria-label="Last frame"
|
||||
>⏭</button>
|
||||
` : html`
|
||||
<button
|
||||
class="frame-nav-btn"
|
||||
@click="${() => this.onFrameChange && this.onFrameChange(0)}"
|
||||
?disabled="${this.currentFrameIndex === 0}"
|
||||
title="First frame"
|
||||
aria-label="First frame"
|
||||
>⏮</button>
|
||||
<button
|
||||
class="frame-nav-btn"
|
||||
@click="${() => this.onFrameChange && this.onFrameChange(this.frames.length - 1)}"
|
||||
?disabled="${this.currentFrameIndex >= this.frames.length - 1}"
|
||||
title="Last frame"
|
||||
aria-label="Last frame"
|
||||
>⏭</button>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderPlayPause(): TemplateResult {
|
||||
return html`
|
||||
<div class="play-controls">
|
||||
<button
|
||||
class="play-btn"
|
||||
@click="${() => this.onToggleAnimation && this.onToggleAnimation()}"
|
||||
aria-label="${this.isPlaying ? 'Pause animation' : 'Play animation'}"
|
||||
aria-pressed="${this.isPlaying}"
|
||||
>
|
||||
${this.isPlaying ? '⏸ Pause' : '▶ Play'}
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderPrevNext(): TemplateResult {
|
||||
return html`
|
||||
<div class="play-controls">
|
||||
<button
|
||||
class="play-btn"
|
||||
@click="${() => this.onPrevious && this.onPrevious()}"
|
||||
aria-label="Previous frame"
|
||||
>◀ Previous</button>
|
||||
<button
|
||||
class="play-btn"
|
||||
@click="${() => this.onNext && this.onNext()}"
|
||||
aria-label="Next frame"
|
||||
>Next ▶</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderFrameInfo(): TemplateResult {
|
||||
const currentFrame = this.frames[this.currentFrameIndex];
|
||||
if (!currentFrame) return html``;
|
||||
|
||||
const showFrameTimes = this.showFrameTimes !== false;
|
||||
|
||||
// Format frame info
|
||||
let frameInfoText = '';
|
||||
if (this.isExtendedMode && currentFrame.absoluteObservationTime) {
|
||||
frameInfoText = `Frame ${(currentFrame.sequentialIndex ?? this.currentFrameIndex) + 1} of ${this.frames.length}`;
|
||||
} else {
|
||||
frameInfoText = `Frame ${currentFrame.frameIndex + 1} of ${this.frames.length}`;
|
||||
}
|
||||
|
||||
const progress = this.frames.length > 0
|
||||
? Math.round(((this.currentFrameIndex + 1) / this.frames.length) * 100)
|
||||
: 0;
|
||||
|
||||
return html`
|
||||
<div class="frame-info">
|
||||
<span class="frame-index">${frameInfoText}</span>
|
||||
${showFrameTimes && currentFrame.absoluteObservationTime ? html`
|
||||
<span class="frame-time">
|
||||
${formatTimestamp(currentFrame.absoluteObservationTime, this.locale, this.timeZone)}
|
||||
</span>
|
||||
` : showFrameTimes && currentFrame.minutesAgo !== undefined ? html`
|
||||
<span class="frame-time">
|
||||
${currentFrame.minutesAgo} min ago
|
||||
</span>
|
||||
` : ''}
|
||||
${this.radarData?.observationTime && showFrameTimes ? html`
|
||||
<span class="observation-time">
|
||||
Obs: ${formatTimestamp(this.radarData.observationTime, this.locale, this.timeZone)}
|
||||
</span>
|
||||
` : ''}
|
||||
<span class="frame-progress">${progress}%</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'bom-controls-section': BomControlsSection;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import { LitElement, html, type TemplateResult } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { errorStyles } from '../styles/error-styles';
|
||||
import { formatTimestamp } from '../utils/date-formatter';
|
||||
import type { ErrorState } from '../types';
|
||||
|
||||
@customElement('bom-error-display')
|
||||
export class BomErrorDisplay extends LitElement {
|
||||
static override styles = [errorStyles];
|
||||
|
||||
@property({ attribute: false }) error?: ErrorState;
|
||||
@property({ attribute: false }) locale: string = 'en-AU';
|
||||
@property({ attribute: false }) timeZone?: string;
|
||||
@property({ attribute: false }) onRetry?: () => void;
|
||||
|
||||
private _getErrorIcon(): string {
|
||||
if (!this.error) return 'mdi:alert';
|
||||
switch (this.error.type) {
|
||||
case 'cache':
|
||||
return 'mdi:database-alert';
|
||||
case 'validation':
|
||||
return 'mdi:alert-circle';
|
||||
case 'network':
|
||||
return 'mdi:wifi-off';
|
||||
default:
|
||||
return 'mdi:alert';
|
||||
}
|
||||
}
|
||||
|
||||
private _getErrorColor(): string {
|
||||
if (!this.error) return '#f44336';
|
||||
switch (this.error.type) {
|
||||
case 'cache':
|
||||
return '#ff9800';
|
||||
case 'validation':
|
||||
return '#f44336';
|
||||
case 'network':
|
||||
return '#2196f3';
|
||||
default:
|
||||
return '#f44336';
|
||||
}
|
||||
}
|
||||
|
||||
private _getErrorTitle(): string {
|
||||
if (!this.error) return 'Error';
|
||||
switch (this.error.type) {
|
||||
case 'cache':
|
||||
return 'Cache Error';
|
||||
case 'validation':
|
||||
return 'Validation Error';
|
||||
case 'network':
|
||||
return 'Network Error';
|
||||
default:
|
||||
return 'Error';
|
||||
}
|
||||
}
|
||||
|
||||
protected override render(): TemplateResult {
|
||||
if (!this.error) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
const action = this.error.details?.action as string;
|
||||
const isManualRefreshRecommended = action === 'manual_refresh_recommended';
|
||||
const refreshEndpoint = this.error.details?.refreshEndpoint as string;
|
||||
|
||||
const retryInfo = this.error.retryAfter
|
||||
? html`<div class="error-retry-info">Auto-retrying in ${this.error.retryAfter} seconds...</div>`
|
||||
: isManualRefreshRecommended
|
||||
? html`<div class="error-retry-info">Manual refresh recommended. Previous update failed.</div>`
|
||||
: '';
|
||||
|
||||
// Format error message with line breaks (preserve empty lines)
|
||||
const errorLines = this.error.message.split('\n');
|
||||
const errorMessage = errorLines.map((line, index) =>
|
||||
line.trim()
|
||||
? html`<div class="error-line">${line}</div>`
|
||||
: html`<div class="error-line-spacer"></div>`
|
||||
);
|
||||
|
||||
// Show additional details if available
|
||||
const showDetails = this.error.details && (
|
||||
this.error.details.previousUpdateFailed ||
|
||||
this.error.details.availableRange ||
|
||||
this.error.details.requestedRange ||
|
||||
this.error.details.suggestedRange ||
|
||||
refreshEndpoint
|
||||
);
|
||||
|
||||
// Determine icon and color based on error type
|
||||
const errorIcon = this._getErrorIcon();
|
||||
const errorColor = this._getErrorColor();
|
||||
|
||||
return html`
|
||||
<div class="error-container">
|
||||
<div class="error-card" style="--error-color: ${errorColor}">
|
||||
<div class="error-header">
|
||||
<ha-icon class="error-icon" .icon=${errorIcon}></ha-icon>
|
||||
<div class="error-title">${this._getErrorTitle()}</div>
|
||||
</div>
|
||||
<div class="error-content">
|
||||
<div class="error-message">${errorMessage}</div>
|
||||
${this.error.errorCode ? html`
|
||||
<div class="error-code">
|
||||
<ha-icon class="code-icon" icon="mdi:code-tags"></ha-icon>
|
||||
<span>Error Code: ${this.error.errorCode}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
${showDetails ? html`
|
||||
<div class="error-details">
|
||||
${this.error.details?.previousUpdateFailed ? html`
|
||||
<div class="error-detail-item">
|
||||
<ha-icon class="detail-icon" icon="mdi:alert-circle"></ha-icon>
|
||||
<div class="detail-content">
|
||||
<strong>Previous Update Failed:</strong> ${this.error.details.previousError || 'Unknown error'}
|
||||
${this.error.details.previousErrorCode ? html` (${this.error.details.previousErrorCode})` : ''}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
${this.error.details?.availableRange ? html`
|
||||
<div class="error-detail-item">
|
||||
<ha-icon class="detail-icon" icon="mdi:database-clock"></ha-icon>
|
||||
<div class="detail-content">
|
||||
<strong>Available Data Range:</strong> ${this.error.details.availableRange.oldest ? formatTimestamp(this.error.details.availableRange.oldest, this.locale, this.timeZone) : 'N/A'} to ${this.error.details.availableRange.newest ? formatTimestamp(this.error.details.availableRange.newest, this.locale, this.timeZone) : 'N/A'}
|
||||
${this.error.details.availableRange.totalCacheFolders ? html` (${this.error.details.availableRange.totalCacheFolders} folders)` : ''}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
${this.error.details?.requestedRange ? html`
|
||||
<div class="error-detail-item">
|
||||
<ha-icon class="detail-icon" icon="mdi:calendar-range"></ha-icon>
|
||||
<div class="detail-content">
|
||||
<strong>Requested Range:</strong> ${this.error.details.requestedRange.start ? formatTimestamp(this.error.details.requestedRange.start, this.locale, this.timeZone) : 'N/A'} to ${this.error.details.requestedRange.end ? formatTimestamp(this.error.details.requestedRange.end, this.locale, this.timeZone) : 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
${this.error.details?.suggestedRange ? html`
|
||||
<div class="error-detail-item suggested-range">
|
||||
<ha-icon class="detail-icon" icon="mdi:lightbulb-on"></ha-icon>
|
||||
<div class="detail-content">
|
||||
<strong>Suggested Range:</strong> ${this.error.details.suggestedRange.start ? formatTimestamp(this.error.details.suggestedRange.start, this.locale, this.timeZone) : 'N/A'} to ${this.error.details.suggestedRange.end ? formatTimestamp(this.error.details.suggestedRange.end, this.locale, this.timeZone) : 'N/A'}
|
||||
<div class="suggestion-hint">Try using this time range instead</div>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
${refreshEndpoint ? html`
|
||||
<div class="error-detail-item">
|
||||
<ha-icon class="detail-icon" icon="mdi:refresh"></ha-icon>
|
||||
<div class="detail-content">
|
||||
<strong>Refresh Endpoint:</strong> <code>${refreshEndpoint}</code>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
` : ''}
|
||||
${retryInfo}
|
||||
${this.error.retryable && this.onRetry ? html`
|
||||
<div class="error-actions">
|
||||
<button
|
||||
class="retry-button"
|
||||
type="button"
|
||||
style="--error-color: ${errorColor}"
|
||||
@click=${(e: Event) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (this.onRetry) {
|
||||
this.onRetry();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ha-icon class="retry-icon" icon="mdi:refresh"></ha-icon>
|
||||
<span>${isManualRefreshRecommended ? 'Retry Anyway' : 'Retry Now'}</span>
|
||||
</button>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'bom-error-display': BomErrorDisplay;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { LitElement, html, type TemplateResult } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { metadataStyles } from '../styles/metadata-styles';
|
||||
import { overlayStyles } from '../styles/overlay-styles';
|
||||
import { formatTimestamp, formatRelativeTime } from '../utils/date-formatter';
|
||||
import type { RadarResponse, MetadataDisplayConfig } from '../types';
|
||||
|
||||
@customElement('bom-metadata-section')
|
||||
export class BomMetadataSection extends LitElement {
|
||||
static override styles = [metadataStyles, overlayStyles];
|
||||
|
||||
@property({ attribute: false }) radarData?: RadarResponse;
|
||||
@property({ attribute: false }) config?: MetadataDisplayConfig | boolean;
|
||||
@property({ attribute: false }) position: 'above' | 'below' | 'overlay' = 'above';
|
||||
@property({ attribute: false }) locale: string = 'en-AU';
|
||||
@property({ attribute: false }) timeZone?: string;
|
||||
@property({ attribute: false }) overlayOpacity?: number;
|
||||
@property({ attribute: false }) overlayPosition?: string;
|
||||
|
||||
private _getDisplayConfig(): MetadataDisplayConfig | null {
|
||||
if (!this.config || (typeof this.config === 'boolean' && !this.config)) {
|
||||
return null;
|
||||
}
|
||||
return typeof this.config === 'object' ? this.config : {};
|
||||
}
|
||||
|
||||
protected override render(): TemplateResult {
|
||||
const displayConfig = this._getDisplayConfig();
|
||||
if (!displayConfig) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
const configPosition = displayConfig.position ?? 'above';
|
||||
|
||||
// Only render if position matches
|
||||
if (configPosition !== this.position) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
// If overlay, render differently
|
||||
if (this.position === 'overlay') {
|
||||
return this._renderOverlay();
|
||||
}
|
||||
|
||||
// Default style is 'cards' if not specified
|
||||
const style = displayConfig.style ?? 'cards';
|
||||
|
||||
return html`
|
||||
<div class="metadata-section metadata-${this.position} metadata-${style}">
|
||||
${displayConfig.show_cache_status !== false ? this._renderCacheStatus() : ''}
|
||||
${displayConfig.show_observation_time !== false ? this._renderObservationTime() : ''}
|
||||
${displayConfig.show_forecast_time !== false ? this._renderForecastTime() : ''}
|
||||
${displayConfig.show_weather_station !== false ? this._renderWeatherStation() : ''}
|
||||
${displayConfig.show_distance !== false ? this._renderDistance() : ''}
|
||||
${displayConfig.show_next_update !== false ? this._renderNextUpdate() : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderOverlay(): TemplateResult {
|
||||
const displayConfig = this._getDisplayConfig();
|
||||
if (!displayConfig) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
const opacity = this.overlayOpacity ?? 0.85;
|
||||
const position = this.overlayPosition ?? 'top';
|
||||
|
||||
return html`
|
||||
<div class="metadata-overlay overlay-${position}" style="opacity: ${opacity};">
|
||||
${displayConfig.show_cache_status !== false ? this._renderCacheStatus() : ''}
|
||||
${displayConfig.show_observation_time !== false ? this._renderObservationTime() : ''}
|
||||
${displayConfig.show_forecast_time !== false ? this._renderForecastTime() : ''}
|
||||
${displayConfig.show_weather_station !== false ? this._renderWeatherStation() : ''}
|
||||
${displayConfig.show_distance !== false ? this._renderDistance() : ''}
|
||||
${displayConfig.show_next_update !== false ? this._renderNextUpdate() : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderCacheStatus(): TemplateResult {
|
||||
if (!this.radarData) return html``;
|
||||
return html`
|
||||
<div class="metadata-item cache-status">
|
||||
<span class="metadata-label">Cache:</span>
|
||||
<span class="metadata-value">
|
||||
${this.radarData.isUpdating ? 'Updating' :
|
||||
this.radarData.cacheIsValid ? 'Valid' : 'Invalid'}
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderObservationTime(): TemplateResult {
|
||||
if (!this.radarData?.observationTime) return html``;
|
||||
return html`
|
||||
<div class="metadata-item observation-time">
|
||||
<span class="metadata-label">Observation:</span>
|
||||
<span class="metadata-value">
|
||||
${formatTimestamp(this.radarData.observationTime, this.locale, this.timeZone)}
|
||||
</span>
|
||||
<span class="metadata-relative">
|
||||
(${formatRelativeTime(this.radarData.observationTime)})
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderForecastTime(): TemplateResult {
|
||||
if (!this.radarData?.forecastTime) return html``;
|
||||
return html`
|
||||
<div class="metadata-item forecast-time">
|
||||
<span class="metadata-label">Forecast:</span>
|
||||
<span class="metadata-value">${formatTimestamp(this.radarData.forecastTime, this.locale, this.timeZone)}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderWeatherStation(): TemplateResult {
|
||||
if (!this.radarData?.weatherStation) return html``;
|
||||
return html`
|
||||
<div class="metadata-item weather-station">
|
||||
<span class="metadata-label">Station:</span>
|
||||
<span class="metadata-value">${this.radarData.weatherStation}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderDistance(): TemplateResult {
|
||||
if (!this.radarData?.distance) return html``;
|
||||
return html`
|
||||
<div class="metadata-item distance">
|
||||
<span class="metadata-label">Distance:</span>
|
||||
<span class="metadata-value">${this.radarData.distance}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderNextUpdate(): TemplateResult {
|
||||
if (!this.radarData?.nextUpdateTime) return html``;
|
||||
return html`
|
||||
<div class="metadata-item next-update">
|
||||
<span class="metadata-label">Next Update:</span>
|
||||
<span class="metadata-value">${formatTimestamp(this.radarData.nextUpdateTime, this.locale, this.timeZone)}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'bom-metadata-section': BomMetadataSection;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import { RadarResponse, RadarTimeSeriesResponse, RadarFrame, ErrorState } from '../types';
|
||||
import { parseApiError, parseErrorResponse } from '../utils/error-handler';
|
||||
import { resolveImageUrl } from '../utils/url-resolver';
|
||||
import { DEFAULT_SERVICE_URL } from '../const';
|
||||
|
||||
export interface FetchRadarOptions {
|
||||
serviceUrl: string;
|
||||
suburb: string;
|
||||
state: string;
|
||||
timespan?: string;
|
||||
customStartTime?: string;
|
||||
customEndTime?: string;
|
||||
onError: (error: ErrorState) => void;
|
||||
}
|
||||
|
||||
export class RadarApiService {
|
||||
/**
|
||||
* Fetches latest radar frames from /api/radar/{suburb}/{state}
|
||||
*/
|
||||
async fetchLatestFrames(options: FetchRadarOptions): Promise<RadarResponse | null> {
|
||||
const { serviceUrl, suburb, state, onError } = options;
|
||||
const url = `${serviceUrl}/api/radar/${suburb}/${state}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: { 'Accept': 'application/json' },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const { errorData, parsedJson } = await parseErrorResponse(response);
|
||||
const error = parseApiError(response, errorData, parsedJson, {
|
||||
retryAction: () => this.fetchLatestFrames(options),
|
||||
defaultRetryAfter: 30,
|
||||
});
|
||||
onError(error);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data: any = await response.json();
|
||||
|
||||
// Handle legacy error format (shouldn't happen with structured errors, but handle for compatibility)
|
||||
if (data.error) {
|
||||
onError({
|
||||
message: data.error || 'Service returned an error',
|
||||
type: 'cache',
|
||||
retryable: true,
|
||||
retryAction: () => this.fetchLatestFrames(options),
|
||||
retryAfter: data.retryAfter || 30,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
// Validate response has frames
|
||||
if (!data.frames || data.frames.length === 0) {
|
||||
throw new Error('No frames available in response');
|
||||
}
|
||||
|
||||
// Resolve relative image URLs against service URL
|
||||
// Service returns relative URLs like "/api/radar/.../frame/..."
|
||||
// which need to be absolute for browser to fetch from correct origin
|
||||
data.frames.forEach((frame: RadarFrame) => {
|
||||
if (frame.imageUrl) {
|
||||
frame.imageUrl = resolveImageUrl(frame.imageUrl, serviceUrl);
|
||||
}
|
||||
});
|
||||
|
||||
return data;
|
||||
} catch (err) {
|
||||
this.handleFetchError(err, options);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches historical radar data from /api/radar/{suburb}/{state}/timeseries
|
||||
*/
|
||||
async fetchHistoricalFrames(options: FetchRadarOptions): Promise<RadarResponse | null> {
|
||||
const { serviceUrl, suburb, state, timespan, customStartTime, customEndTime, onError } = options;
|
||||
|
||||
try {
|
||||
let startTime: Date | null = null;
|
||||
let endTime = new Date();
|
||||
|
||||
if (timespan === 'custom') {
|
||||
if (customStartTime) startTime = new Date(customStartTime);
|
||||
if (customEndTime) endTime = new Date(customEndTime);
|
||||
} else if (timespan) {
|
||||
// Calculate hours back from now
|
||||
const hours = parseInt(timespan.replace('h', '')) || 1;
|
||||
startTime = new Date(endTime.getTime() - (hours * 60 * 60 * 1000));
|
||||
}
|
||||
|
||||
if (!startTime) {
|
||||
throw new Error('Invalid timespan configuration');
|
||||
}
|
||||
|
||||
// Build query parameters for timeseries endpoint
|
||||
const url = `${serviceUrl}/api/radar/${suburb}/${state}/timeseries?startTime=${startTime.toISOString()}&endTime=${endTime.toISOString()}`;
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: { 'Accept': 'application/json' },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const { errorData, parsedJson } = await parseErrorResponse(response);
|
||||
const error = parseApiError(response, errorData, parsedJson, {
|
||||
retryAction: () => this.fetchHistoricalFrames(options),
|
||||
defaultRetryAfter: 30,
|
||||
});
|
||||
onError(error);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data: RadarTimeSeriesResponse = await response.json();
|
||||
|
||||
if (!data.cacheFolders || data.cacheFolders.length === 0) {
|
||||
throw new Error('No historical data found for the specified time range.');
|
||||
}
|
||||
|
||||
// Flatten all frames from all cache folders
|
||||
// The service already sets absoluteObservationTime on each frame
|
||||
const allFrames: RadarFrame[] = [];
|
||||
data.cacheFolders.forEach(cacheFolder => {
|
||||
cacheFolder.frames.forEach(frame => {
|
||||
// Store folder metadata for reference (service already sets absoluteObservationTime)
|
||||
frame.cacheTimestamp = cacheFolder.cacheTimestamp;
|
||||
frame.observationTime = cacheFolder.observationTime;
|
||||
frame.cacheFolderName = cacheFolder.cacheFolderName;
|
||||
|
||||
// Resolve relative image URLs against service URL
|
||||
if (frame.imageUrl) {
|
||||
frame.imageUrl = resolveImageUrl(frame.imageUrl, serviceUrl);
|
||||
}
|
||||
|
||||
// Ensure absoluteObservationTime is set (service should provide this, but handle if missing)
|
||||
if (!frame.absoluteObservationTime && frame.observationTime && frame.minutesAgo !== undefined) {
|
||||
// Fallback: calculate from observation time and minutes ago
|
||||
const obsTime = new Date(frame.observationTime);
|
||||
frame.absoluteObservationTime = new Date(obsTime.getTime() - (frame.minutesAgo * 60 * 1000)).toISOString();
|
||||
}
|
||||
|
||||
allFrames.push(frame);
|
||||
});
|
||||
});
|
||||
|
||||
// Re-index frames sequentially
|
||||
allFrames.forEach((frame, idx) => {
|
||||
frame.sequentialIndex = idx;
|
||||
});
|
||||
|
||||
// Fetch latest metadata for display from /api/radar/{suburb}/{state}/metadata
|
||||
let metadata: Partial<RadarResponse> = {};
|
||||
try {
|
||||
const metadataUrl = `${serviceUrl}/api/radar/${suburb}/${state}/metadata`;
|
||||
const metadataResponse = await fetch(metadataUrl);
|
||||
if (metadataResponse.ok) {
|
||||
metadata = await metadataResponse.json();
|
||||
}
|
||||
} catch (err) {
|
||||
console.debug('Could not fetch metadata:', err);
|
||||
}
|
||||
|
||||
// Create RadarResponse-like object
|
||||
const newestCacheFolder = data.cacheFolders[data.cacheFolders.length - 1];
|
||||
const radarResponse: RadarResponse = {
|
||||
frames: allFrames,
|
||||
lastUpdated: endTime.toISOString(),
|
||||
observationTime: metadata.observationTime || newestCacheFolder?.observationTime || endTime.toISOString(),
|
||||
forecastTime: endTime.toISOString(),
|
||||
weatherStation: metadata.weatherStation,
|
||||
distance: metadata.distance,
|
||||
cacheIsValid: metadata.cacheIsValid ?? true,
|
||||
cacheExpiresAt: metadata.cacheExpiresAt || endTime.toISOString(),
|
||||
isUpdating: metadata.isUpdating || false,
|
||||
nextUpdateTime: metadata.nextUpdateTime || endTime.toISOString(),
|
||||
};
|
||||
|
||||
return radarResponse;
|
||||
} catch (err) {
|
||||
this.handleFetchError(err, options);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles fetch errors and categorizes them appropriately
|
||||
*/
|
||||
private handleFetchError(err: any, options: FetchRadarOptions): void {
|
||||
if (err instanceof TypeError && err.message.includes('fetch')) {
|
||||
options.onError({
|
||||
message: 'Network error: Unable to connect to service',
|
||||
type: 'network',
|
||||
retryable: true,
|
||||
retryAction: () => this.fetchLatestFrames(options),
|
||||
});
|
||||
} else {
|
||||
options.onError({
|
||||
message: err instanceof Error ? err.message : 'Unknown error occurred',
|
||||
type: 'unknown',
|
||||
retryable: true,
|
||||
retryAction: () => this.fetchLatestFrames(options),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { css, CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* Base card styles including CSS variables and root layout
|
||||
*/
|
||||
export const cardStyles: CSSResultGroup = css`
|
||||
:host {
|
||||
--bom-primary-color: var(--primary-color, #667eea);
|
||||
--bom-secondary-color: var(--secondary-text-color, #666);
|
||||
--bom-card-background: var(--card-background-color, white);
|
||||
--bom-text-color: var(--primary-text-color, #333);
|
||||
--bom-border-color: var(--divider-color, #e0e0e0);
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import { css, CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* Styles for frame slider, navigation buttons, and play controls
|
||||
*/
|
||||
export const controlsStyles: CSSResultGroup = css`
|
||||
.frame-slider-container {
|
||||
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);
|
||||
margin-bottom: 15px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.frame-slider-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.frame-nav-btn {
|
||||
padding: 10px 16px;
|
||||
border: 2px solid var(--primary-color, #667eea);
|
||||
background: white;
|
||||
color: var(--primary-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;
|
||||
}
|
||||
|
||||
.frame-nav-btn:hover:not(:disabled) {
|
||||
background: var(--primary-color, #667eea);
|
||||
color: white;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.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;
|
||||
cursor: pointer;
|
||||
order: 3;
|
||||
}
|
||||
|
||||
.frame-slider::-webkit-slider-thumb {
|
||||
-webkit-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);
|
||||
border: 3px solid white;
|
||||
transition: all 0.2s;
|
||||
margin-top: -7px; /* Center the 24px thumb on the 10px track: (24-10)/2 = 7 */
|
||||
}
|
||||
|
||||
.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-runnable-track {
|
||||
height: 10px;
|
||||
border-radius: 5px;
|
||||
background: linear-gradient(to right, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.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%);
|
||||
}
|
||||
|
||||
.play-controls {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.play-btn {
|
||||
padding: 10px 16px;
|
||||
border: 2px solid var(--primary-color, #667eea);
|
||||
background: var(--primary-color, #667eea);
|
||||
color: white;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
font-size: 0.9em;
|
||||
transition: all 0.2s;
|
||||
min-height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.play-btn:hover:not(:disabled) {
|
||||
background: var(--primary-color-dark, #5568d3);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.play-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.frame-info {
|
||||
text-align: center;
|
||||
color: var(--secondary-text-color, rgba(0, 0, 0, 0.6));
|
||||
font-size: 0.85em;
|
||||
margin-top: 6px;
|
||||
margin-bottom: 4px;
|
||||
padding: 6px 12px;
|
||||
background: var(--card-background-color, #fff);
|
||||
border-radius: 4px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.frame-index {
|
||||
font-weight: 600;
|
||||
color: var(--primary-text-color, rgba(0, 0, 0, 0.87));
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.frame-time {
|
||||
white-space: nowrap;
|
||||
color: var(--secondary-text-color, rgba(0, 0, 0, 0.6));
|
||||
}
|
||||
|
||||
.observation-time {
|
||||
white-space: nowrap;
|
||||
color: var(--secondary-text-color, rgba(0, 0, 0, 0.6));
|
||||
}
|
||||
|
||||
.frame-progress {
|
||||
white-space: nowrap;
|
||||
color: var(--secondary-text-color, rgba(0, 0, 0, 0.6));
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.frame-nav-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.controls-section {
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (min-width: 480px) {
|
||||
.frame-slider-wrapper {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
.frame-slider {
|
||||
order: 0;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
import { css, CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* Styles for error display components
|
||||
*/
|
||||
export const errorStyles: CSSResultGroup = css`
|
||||
/* Error Display */
|
||||
.error-container {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.error-card {
|
||||
background: var(--card-background-color, #ffffff);
|
||||
border-radius: 12px;
|
||||
border-left: 4px solid var(--error-color);
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--divider-color, rgba(0, 0, 0, 0.1));
|
||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||
}
|
||||
|
||||
.error-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 18px 20px;
|
||||
background: var(--secondary-background-color, #fafafa);
|
||||
border-bottom: 1px solid var(--divider-color, rgba(0, 0, 0, 0.1));
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
color: var(--error-color);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-size: 1.1em;
|
||||
font-weight: 600;
|
||||
color: var(--primary-text-color, #212121);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.error-content {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
font-weight: 500;
|
||||
line-height: 1.7;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
color: var(--primary-text-color, #212121);
|
||||
font-size: 1.05em;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.error-line {
|
||||
min-height: 1.2em;
|
||||
}
|
||||
|
||||
.error-line-spacer {
|
||||
height: 0.5em;
|
||||
}
|
||||
|
||||
.error-code {
|
||||
font-size: 0.85em;
|
||||
font-family: monospace;
|
||||
color: var(--secondary-text-color, #666);
|
||||
background: var(--secondary-background-color, #f5f5f5);
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: fit-content;
|
||||
border: 1px solid var(--divider-color, rgba(0, 0, 0, 0.1));
|
||||
}
|
||||
|
||||
.code-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
opacity: 0.7;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.error-retry-info {
|
||||
font-size: 0.9em;
|
||||
color: var(--primary-text-color, #212121);
|
||||
padding: 10px 14px;
|
||||
background: linear-gradient(135deg,
|
||||
rgba(33, 150, 243, 0.08) 0%,
|
||||
rgba(33, 150, 243, 0.04) 100%);
|
||||
border-radius: 8px;
|
||||
border-left: 3px solid var(--primary-color, #2196f3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.error-retry-info::before {
|
||||
content: '⏱';
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.error-details {
|
||||
margin-top: 8px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--divider-color, rgba(0, 0, 0, 0.12));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.error-detail-item {
|
||||
font-size: 0.9em;
|
||||
line-height: 1.6;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
background: var(--secondary-background-color, #fafafa);
|
||||
border-radius: 8px;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.error-detail-item:hover {
|
||||
background: var(--secondary-background-color, #f0f0f0);
|
||||
}
|
||||
|
||||
.detail-icon {
|
||||
color: var(--error-color);
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.detail-content {
|
||||
flex: 1;
|
||||
color: var(--primary-text-color, #212121);
|
||||
}
|
||||
|
||||
.detail-content strong {
|
||||
font-weight: 600;
|
||||
color: var(--primary-text-color, #212121);
|
||||
}
|
||||
|
||||
.detail-content code {
|
||||
font-family: monospace;
|
||||
font-size: 0.9em;
|
||||
background: var(--secondary-background-color, rgba(0, 0, 0, 0.05));
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--divider-color, rgba(0, 0, 0, 0.15));
|
||||
color: var(--primary-text-color, #212121);
|
||||
display: inline-block;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.error-detail-item.suggested-range {
|
||||
background: linear-gradient(135deg,
|
||||
rgba(255, 193, 7, 0.1) 0%,
|
||||
rgba(255, 193, 7, 0.05) 100%);
|
||||
border-left: 3px solid var(--warning-color, #ffc107);
|
||||
}
|
||||
|
||||
.suggestion-hint {
|
||||
font-size: 0.85em;
|
||||
color: var(--secondary-text-color, #666);
|
||||
margin-top: 4px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.error-actions {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.retry-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 12px 24px;
|
||||
min-height: 44px;
|
||||
background: var(--error-color, #f44336);
|
||||
color: white !important;
|
||||
border: 2px solid var(--error-color, #f44336);
|
||||
border-radius: 8px;
|
||||
font-size: 0.95em;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35), 0 2px 6px rgba(0, 0, 0, 0.25), inset 0 1px 0 rgba(255, 255, 255, 0.2);
|
||||
font-family: inherit;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
user-select: none;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
|
||||
.retry-button::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(to bottom, rgba(255, 255, 255, 0.2), rgba(255, 255, 255, 0));
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.retry-button:hover {
|
||||
opacity: 0.9;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
|
||||
filter: brightness(0.95);
|
||||
}
|
||||
|
||||
.retry-button:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.retry-button:focus {
|
||||
outline: 2px solid var(--error-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.retry-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.retry-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: white;
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.retry-button span {
|
||||
color: white;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { css, CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* Styles for radar image container and loading states
|
||||
*/
|
||||
export const imageStyles: CSSResultGroup = css`
|
||||
/* Radar Image Container */
|
||||
.radar-image-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
background: #000;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 15px;
|
||||
aspect-ratio: 16/9;
|
||||
min-height: 200px; /* Smaller on mobile */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@media (min-width: 480px) {
|
||||
.radar-image-container {
|
||||
min-height: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.radar-image-container {
|
||||
min-height: 400px;
|
||||
}
|
||||
}
|
||||
|
||||
.radar-image {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
image-rendering: crisp-edges;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.radar-image-contain {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.radar-image-cover {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.radar-image-fill {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: fill;
|
||||
}
|
||||
|
||||
.loading {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: white;
|
||||
font-size: 1.2em;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.loading-message {
|
||||
font-size: 0.9em;
|
||||
opacity: 0.9;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { css, CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* Styles for metadata display sections
|
||||
*/
|
||||
export const metadataStyles: CSSResultGroup = css`
|
||||
/* Metadata Section */
|
||||
.metadata-section {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.metadata-section.metadata-cards {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr; /* Single column on mobile */
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@media (min-width: 480px) {
|
||||
.metadata-section.metadata-cards {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.metadata-section.metadata-cards {
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.metadata-section.metadata-cards .metadata-item {
|
||||
background: var(--card-background-color, #ffffff);
|
||||
border: 1px solid var(--divider-color, rgba(0, 0, 0, 0.12));
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
transition: box-shadow 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.metadata-section.metadata-cards .metadata-item:hover {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.metadata-section.metadata-cards .metadata-label {
|
||||
font-size: 0.75em;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
opacity: 0.7;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.metadata-section.metadata-cards .metadata-value {
|
||||
font-size: 1.1em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.metadata-section.metadata-compact {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.metadata-section.metadata-compact .metadata-item {
|
||||
background: var(--secondary-background-color, #f5f5f5);
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.metadata-section.metadata-minimal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.metadata-section.metadata-minimal .metadata-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 0;
|
||||
border-bottom: 1px solid var(--divider-color, rgba(0, 0, 0, 0.08));
|
||||
}
|
||||
|
||||
.metadata-section.metadata-minimal .metadata-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.metadata-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.metadata-label {
|
||||
color: var(--bom-secondary-color);
|
||||
font-weight: 600;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.metadata-value {
|
||||
color: var(--bom-text-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.metadata-relative {
|
||||
color: var(--bom-secondary-color);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.info-section {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 15px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.info-card {
|
||||
background: var(--bom-card-background);
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
border-left: 4px solid var(--bom-primary-color);
|
||||
}
|
||||
|
||||
.info-card h3 {
|
||||
font-size: 0.8em;
|
||||
text-transform: uppercase;
|
||||
color: var(--bom-secondary-color);
|
||||
margin-bottom: 6px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.info-card .value {
|
||||
font-size: 1.2em;
|
||||
font-weight: 600;
|
||||
color: var(--bom-text-color);
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { css, CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* Styles for overlay controls and metadata overlays
|
||||
*/
|
||||
export const overlayStyles: CSSResultGroup = css`
|
||||
/* Overlay Controls */
|
||||
.controls-overlay {
|
||||
position: absolute;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
backdrop-filter: blur(8px);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
z-index: 10;
|
||||
transition: opacity 0.3s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.controls-overlay.overlay-top {
|
||||
top: 12px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.controls-overlay.overlay-bottom {
|
||||
bottom: 12px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.controls-overlay.overlay-left {
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.controls-overlay.overlay-right {
|
||||
right: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.controls-overlay.overlay-center {
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
/* Metadata Overlay */
|
||||
.metadata-overlay {
|
||||
position: absolute;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(6px);
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
z-index: 5;
|
||||
font-size: 0.85em;
|
||||
color: white;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.metadata-overlay .metadata-item {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.metadata-overlay .metadata-label {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.metadata-overlay.overlay-top {
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
right: auto;
|
||||
}
|
||||
|
||||
.metadata-overlay.overlay-bottom {
|
||||
bottom: 12px;
|
||||
left: 12px;
|
||||
right: auto;
|
||||
}
|
||||
|
||||
.metadata-overlay.overlay-left {
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.metadata-overlay.overlay-right {
|
||||
right: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.metadata-overlay.overlay-center {
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Formats timestamp for display
|
||||
*/
|
||||
export function formatTimestamp(
|
||||
isoString: string,
|
||||
locale: string = 'en-AU',
|
||||
timeZone?: string
|
||||
): string {
|
||||
if (!isoString) return '-';
|
||||
const date = new Date(isoString);
|
||||
const tz = timeZone || Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
|
||||
return date.toLocaleString(locale, {
|
||||
timeZone: tz,
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Relative time (e.g., "5 min ago", "2 hours ago")
|
||||
*/
|
||||
export function formatRelativeTime(isoString: string): string {
|
||||
if (!isoString) return '-';
|
||||
const date = new Date(isoString);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffMins < 1) return 'Just now';
|
||||
if (diffMins < 60) return `${diffMins} min ago`;
|
||||
if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
|
||||
return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { ApiErrorResponse, ErrorState } from '../types';
|
||||
|
||||
export interface ErrorHandlerOptions {
|
||||
retryAction: () => void;
|
||||
defaultRetryAfter?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses API error response and creates ErrorState
|
||||
* Consolidates duplicated error handling logic from fetchRadarData and fetchHistoricalRadar
|
||||
*/
|
||||
export function parseApiError(
|
||||
response: Response,
|
||||
errorData: ApiErrorResponse | any,
|
||||
parsedJson: boolean,
|
||||
options: ErrorHandlerOptions
|
||||
): ErrorState {
|
||||
// Extract error information (support both camelCase and PascalCase)
|
||||
const errorCode = errorData.errorCode || errorData.ErrorCode;
|
||||
const message = errorData.message || errorData.Message || response.statusText || 'An error occurred';
|
||||
const errorType = errorData.errorType || errorData.ErrorType;
|
||||
const details = errorData.details || errorData.Details || {};
|
||||
const suggestions = errorData.suggestions || errorData.Suggestions || {};
|
||||
|
||||
// Build enhanced error message
|
||||
let enhancedMessage = message;
|
||||
|
||||
// Enhance message for cache not found scenarios
|
||||
if (errorCode === 'CACHE_NOT_FOUND' || (response.status === 404 && !errorCode)) {
|
||||
if (!enhancedMessage.includes('cache') && !enhancedMessage.includes('Cache')) {
|
||||
enhancedMessage = 'No cached data found for this location. Cache update has been triggered in background. Please retry in a few moments.';
|
||||
}
|
||||
}
|
||||
|
||||
// Handle previous update failure
|
||||
if (details.previousUpdateFailed) {
|
||||
enhancedMessage = `${enhancedMessage}\n\nPrevious update failed: ${details.previousError || 'Unknown error'}`;
|
||||
if (details.previousErrorCode) {
|
||||
enhancedMessage += ` (${details.previousErrorCode})`;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle time range errors with available/requested ranges
|
||||
if (errorCode === 'TIME_RANGE_ERROR' && details.availableRange) {
|
||||
const available = details.availableRange;
|
||||
const requested = details.requestedRange;
|
||||
enhancedMessage += `\n\nAvailable data: ${available.oldest || 'N/A'} to ${available.newest || 'N/A'}`;
|
||||
if (available.totalCacheFolders) {
|
||||
enhancedMessage += ` (${available.totalCacheFolders} cache folders)`;
|
||||
}
|
||||
if (requested) {
|
||||
enhancedMessage += `\nRequested: ${requested.start || 'N/A'} to ${requested.end || 'N/A'}`;
|
||||
}
|
||||
if (details.requestedHours) {
|
||||
enhancedMessage += `\nRequested range: ${details.requestedHours} hours (max: ${details.maxHours || 'N/A'} hours)`;
|
||||
}
|
||||
// Add service suggestion if available
|
||||
if (suggestions.suggestion) {
|
||||
enhancedMessage += `\n\n💡 ${suggestions.suggestion}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Determine retry behavior
|
||||
const retryAfter = suggestions.retryAfter as number ||
|
||||
details.retryAfter as number ||
|
||||
options.defaultRetryAfter || 30;
|
||||
const action = suggestions.action as string || 'retry_after_seconds';
|
||||
|
||||
const shouldAutoRetry = action !== 'manual_refresh_recommended' &&
|
||||
action !== 'check_network_and_retry' &&
|
||||
action !== 'adjust_time_range';
|
||||
|
||||
// Map error type
|
||||
const mappedType = mapErrorType(errorType || errorCode || (response.status === 404 ? 'CACHE_NOT_FOUND' : 'unknown'));
|
||||
|
||||
// Determine retryable status - for historical endpoint, also allow 400
|
||||
const isHistoricalEndpoint = response.url.includes('/timeseries');
|
||||
const retryable = response.status === 404 || (isHistoricalEndpoint && response.status === 400);
|
||||
|
||||
return {
|
||||
message: enhancedMessage,
|
||||
type: mappedType,
|
||||
retryable: retryable,
|
||||
retryAction: options.retryAction,
|
||||
retryAfter: shouldAutoRetry ? retryAfter : undefined,
|
||||
errorCode: errorCode || (response.status === 404 ? 'CACHE_NOT_FOUND' : `HTTP_${response.status}`),
|
||||
details: {
|
||||
...details,
|
||||
action: action,
|
||||
refreshEndpoint: suggestions.refreshEndpoint as string,
|
||||
statusEndpoint: suggestions.statusEndpoint as string,
|
||||
suggestedRange: suggestions.suggestedRange as any,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps API error type to card error type
|
||||
*/
|
||||
function mapErrorType(apiErrorType: string): ErrorState['type'] {
|
||||
if (!apiErrorType) return 'unknown';
|
||||
const type = apiErrorType.toLowerCase();
|
||||
if (type.includes('cache') || type === 'cache_not_found' || type === 'cacheerror') return 'cache';
|
||||
if (type.includes('validation') || type === 'validation_error' || type === 'validationerror') return 'validation';
|
||||
if (type.includes('network') || type.includes('fetch')) return 'network';
|
||||
if (type.includes('notfound') || type === 'not_found' || type === 'notfounderror') return 'cache';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to parse error response from API
|
||||
*/
|
||||
export async function parseErrorResponse(response: Response): Promise<{ errorData: ApiErrorResponse | any; parsedJson: boolean }> {
|
||||
let errorData: ApiErrorResponse | any;
|
||||
let parsedJson = false;
|
||||
|
||||
try {
|
||||
errorData = await response.json();
|
||||
parsedJson = true;
|
||||
} catch {
|
||||
// If JSON parsing fails, create a basic error structure
|
||||
errorData = {
|
||||
message: response.statusText || 'Unknown error',
|
||||
errorCode: response.status === 404 ? 'NOT_FOUND' : 'HTTP_ERROR',
|
||||
errorType: 'ServiceError'
|
||||
};
|
||||
}
|
||||
|
||||
return { errorData, parsedJson };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Resolves a URL relative to the service URL if it's a relative path
|
||||
* Service returns relative URLs like "/api/radar/.../frame/..." which need
|
||||
* to be resolved against the service_url base
|
||||
*/
|
||||
export function resolveImageUrl(url: string, serviceUrl: string): string {
|
||||
if (!url) return url;
|
||||
|
||||
// If URL is already absolute (starts with http:// or https://), use as-is
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
return url;
|
||||
}
|
||||
|
||||
// Remove trailing slash from serviceUrl
|
||||
const baseUrl = serviceUrl.replace(/\/$/, '');
|
||||
|
||||
// If URL is relative (starts with /), resolve against service URL
|
||||
if (url.startsWith('/')) {
|
||||
return `${baseUrl}${url}`;
|
||||
}
|
||||
|
||||
// If URL is relative without leading slash, resolve against service URL with path
|
||||
return `${baseUrl}/${url}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user