Update card UI styling and editor enhancements

- Improve error display styling and button appearance
- Enhance editor with expanded metadata and controls configuration
- Update types and README documentation
This commit is contained in:
2025-12-17 20:27:43 +10:00
parent 4ad308fa04
commit f9776ec833
4 changed files with 910 additions and 327 deletions
+26 -59
View File
@@ -166,11 +166,13 @@ For more detailed service setup and configuration options, see the [BOM Local Se
- Play/Pause Button
- Previous/Next Buttons
- Frame Slider
- Navigation Buttons (-10, +10, First, Last)
- Navigation Buttons (skip 1/3 of frames, First, Last)
- Frame Info
- **Overlay Controls on Image**: Toggle to overlay controls on the radar image
- **Overlay Position**: Choose overlay position (Top, Bottom, Left, Right, Center)
- **Overlay Opacity**: Control overlay transparency (0.0 to 1.0)
- **Controls Position**: Choose where to display controls (Above Image, Below Image, Overlay on Image)
- When position is "Overlay on Image":
- **Controls Overlay Position**: Choose overlay position (Top, Bottom, Left, Right, Center)
- **Controls Overlay Opacity**: Control overlay transparency (0.0 to 1.0)
- **Conflict Detection**: Editor warns if metadata and controls overlays would conflict
- **Image Zoom**: Zoom level for radar images (0.5 = 50%, 1.0 = 100%, 2.0 = 200%, range: 0.5-3.0)
- **Image Fit**: How images fit in container (Contain, Cover, Fill)
@@ -182,7 +184,7 @@ For more detailed service setup and configuration options, see the [BOM Local Se
**Auto Refresh**:
- **Refresh Interval**: Seconds between automatic data refreshes (default: `30`, range: 10-300)
**Note**: For custom time ranges (using `timespan: custom`), you'll need to configure `custom_start_time` and `custom_end_time` via YAML as these options are not available in the visual editor.
**Note**: For custom time ranges (using `timespan: custom`), the editor will show input fields for `custom_start_time` and `custom_end_time`. The editor also displays available cache range information and warns if your selected timespan exceeds available data.
### Using YAML
@@ -256,10 +258,12 @@ custom_end_time: "2024-01-15T14:00:00Z"
| `show_play_pause` | boolean | `true` | Show play/pause button |
| `show_prev_next` | boolean | `true` | Show previous/next buttons |
| `show_slider` | boolean | `true` | Show frame slider |
| `show_nav_buttons` | boolean | `true` | Show navigation buttons (First, -10, +10, Last) |
| `show_nav_buttons` | boolean | `true` | Show navigation buttons (skip 1/3 of frames, First, Last) |
| `show_frame_info` | boolean | `true` | Show frame information (frame number, timestamp) |
| `position` | string | `below` | Where to display: `above`, `below`, or `overlay` |
**Note**: When `position` is set to `overlay`, use the top-level `controls_overlay_position` and `controls_overlay_opacity` options to control overlay placement and transparency.
#### Slideshow Configuration
| Option | Type | Default | Required | Description |
@@ -307,22 +311,27 @@ show_metadata: false
show_controls: false
```
### Overlay Controls on Image
### Overlay Controls and Metadata on Image
Overlay controls on the radar image to save space:
Overlay both controls and metadata on the radar image to save space:
```yaml
type: custom:bom-local-radar-card
suburb: Brisbane
state: QLD
overlay_controls: true
overlay_position: bottom
overlay_opacity: 0.85
show_controls:
position: overlay
controls_overlay_position: bottom
controls_overlay_opacity: 0.9
show_metadata:
position: overlay
style: minimal
style: compact
metadata_overlay_position: top
metadata_overlay_opacity: 0.85
```
**Note**: The editor will warn you if metadata and controls overlays are configured at conflicting positions (e.g., both at "top").
### Image Zoom
Zoom in on the radar image (may pixelate at higher zoom levels):
@@ -356,7 +365,7 @@ show_metadata:
### Granular Controls Control
Show only specific controls:
Show only specific controls and position them above the image:
```yaml
type: custom:bom-local-radar-card
@@ -368,6 +377,7 @@ show_controls:
show_frame_info: true
show_nav_buttons: false
show_prev_next: false
position: above
```
### Historical Data (Last 3 Hours)
@@ -430,11 +440,11 @@ The card provides several controls for navigating radar frames (all can be indiv
- **Play/Pause Button**: Start or stop the animation
- **Previous/Next Buttons**: Navigate to the previous or next frame
- **Frame Slider**: Drag to jump to any frame
- **Navigation Buttons**:
- **Navigation Buttons** (only shown for timeseries data):
- ⏮ First frame
- -10 / +10: Jump backward/forward by 10 frames
- -X / +X: Jump backward/forward by approximately 1/3 of total frames (dynamically calculated)
- ⏭ Last frame
- **Frame Info**: Displays frame number, total frames, observation time, and progress percentage
- **Frame Info**: Compact display showing frame number, total frames, observation time, and progress percentage in a single line
### Keyboard Navigation
@@ -594,49 +604,6 @@ To test the card against a local version of the BOM Local Service (instead of th
This builds the service from your local source and uses it in the test environment.
## Changelog
### Version 0.1.0 (Current)
**Major Improvements:**
- ✨ **Enhanced Error Handling**: Full support for structured API error responses with detailed error codes, retry suggestions, and intelligent auto-retry functionality
- 🔄 **Smart Retry Logic**: Action-based retry behavior - respects service recommendations for when to auto-retry vs. manual refresh
- 🎨 **Redesigned Editor**: Complete overhaul of the visual editor with native dropdowns, better layout, and expandable sections
- 🖼️ **Image Display Fixes**: Fixed image centering issues and added zoom/fit options
- 🎛️ **Granular Configuration**: Fine-grained control over metadata and controls visibility
- 📍 **Overlay Support**: Overlay controls and metadata on images to save dashboard space
- ⌨️ **Keyboard Navigation**: Full keyboard support for accessibility
- ♿ **Accessibility**: ARIA labels and screen reader support
- 🔍 **Better Error Messages**: Detailed error information including previous update failures and available data ranges
**New Configuration Options:**
- `show_card_title`: Control card title visibility
- `show_metadata`: Object-based configuration for granular metadata control
- `show_controls`: Object-based configuration for granular controls control
- `image_zoom`: Zoom images from 0.5x to 3.0x
- `image_fit`: Control how images fit (contain/cover/fill)
- `overlay_controls`: Overlay controls on image
- `overlay_position`: Control overlay position
- `overlay_opacity`: Control overlay transparency
**Bug Fixes:**
- Fixed editor dropdown crashes
- Fixed image centering issues
- Fixed editor layout problems
- Improved error handling for fresh cache scenarios
**Service Integration:**
- Enhanced compatibility with service's action-based error recommendations
- Proper handling of `manual_refresh_recommended` action type
- Respects service-calculated retry times based on update progress
- Displays refresh endpoint URLs in error details
### Version 0.0.1 (Initial Release)
- Initial release with basic radar display functionality
- Support for latest frames and historical data
- Basic configuration options
- Visual editor support
## License
+250 -81
View File
@@ -187,6 +187,7 @@ export class BomLocalRadarCard extends LitElement implements LovelaceCard {
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);
@@ -220,20 +221,24 @@ export class BomLocalRadarCard extends LitElement implements LovelaceCard {
display: flex;
gap: 8px;
justify-content: center;
margin-bottom: 15px;
align-items: center;
margin-bottom: 12px;
flex-wrap: wrap;
}
.play-btn {
padding: 12px 20px;
border: none;
padding: 10px 16px;
border: 2px solid var(--primary-color, #667eea);
background: var(--primary-color, #667eea);
color: white;
border-radius: 8px;
border-radius: 10px;
cursor: pointer;
font-weight: 600;
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);
@@ -286,6 +291,7 @@ export class BomLocalRadarCard extends LitElement implements LovelaceCard {
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 {
@@ -433,10 +439,13 @@ export class BomLocalRadarCard extends LitElement implements LovelaceCard {
.detail-content code {
font-family: monospace;
font-size: 0.9em;
background: var(--card-background-color, #ffffff);
padding: 2px 6px;
border-radius: 3px;
border: 1px solid var(--divider-color, rgba(0, 0, 0, 0.12));
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 {
@@ -467,19 +476,35 @@ export class BomLocalRadarCard extends LitElement implements LovelaceCard {
gap: 8px;
padding: 12px 24px;
min-height: 44px;
background: var(--error-color);
background: var(--error-color, #f44336);
color: white !important;
border: none;
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 2px 4px rgba(0, 0, 0, 0.1);
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 {
@@ -544,20 +569,71 @@ export class BomLocalRadarCard extends LitElement implements LovelaceCard {
}
}
.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: 8px;
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: 4px;
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;
@@ -651,11 +727,31 @@ export class BomLocalRadarCard extends LitElement implements LovelaceCard {
.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%);
}
/* Controls Section */
@@ -701,6 +797,7 @@ export class BomLocalRadarCard extends LitElement implements LovelaceCard {
font-size: 0.9em;
transition: all 0.2s;
min-width: 50px;
min-height: 44px;
display: flex;
align-items: center;
justify-content: center;
@@ -743,6 +840,7 @@ export class BomLocalRadarCard extends LitElement implements LovelaceCard {
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 {
@@ -780,36 +878,53 @@ export class BomLocalRadarCard extends LitElement implements LovelaceCard {
.play-controls {
display: flex;
flex-direction: column; /* Stack on mobile */
flex-direction: row;
gap: 8px;
justify-content: center;
margin-bottom: 15px;
}
@media (min-width: 480px) {
.play-controls {
flex-direction: row;
gap: 12px;
}
align-items: center;
margin-bottom: 12px;
flex-wrap: wrap;
}
/* Frame Info */
.frame-info {
text-align: center;
color: var(--bom-secondary-color);
font-size: 0.9em;
margin-top: 10px;
display: flex;
flex-direction: column;
gap: 4px;
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(--bom-text-color);
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;
}
font-size: 0.85em;
}
@@ -818,7 +933,6 @@ export class BomLocalRadarCard extends LitElement implements LovelaceCard {
opacity: 0.8;
}
/* Legacy info-section for backward compatibility */
.info-section {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
@@ -912,13 +1026,14 @@ export class BomLocalRadarCard extends LitElement implements LovelaceCard {
size += 1;
}
// Add space for controls if shown below
// Add space for controls if shown below (not overlay)
const controlsConfig = this._getControlsConfig();
if (controlsConfig && typeof controlsConfig !== 'boolean') {
if (controlsConfig.position !== 'overlay' && !this._config.overlay_controls) {
if (controlsConfig.position !== 'overlay') {
size += 2; // +100px for controls
}
} else if (controlsConfig === true && !this._config.overlay_controls) {
} else if (controlsConfig === true) {
// Default position is 'below', so add space
size += 2;
}
@@ -982,7 +1097,7 @@ export class BomLocalRadarCard extends LitElement implements LovelaceCard {
try {
this.isLoading = true;
this.error = undefined;
// Don't clear error immediately - let it fade out smoothly after data loads
// Check if we're in extended mode (historical data)
const timespan = this._config.timespan || 'latest';
@@ -1130,6 +1245,9 @@ export class BomLocalRadarCard extends LitElement implements LovelaceCard {
throw new Error('No frames available in response');
}
// Clear error only after successful data load to prevent glitch
this.error = undefined;
// 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
@@ -1382,6 +1500,9 @@ export class BomLocalRadarCard extends LitElement implements LovelaceCard {
nextUpdateTime: metadata.nextUpdateTime || endTime.toISOString(),
};
// Clear error only after successful data load to prevent glitch
this.error = undefined;
this.radarData = radarResponse;
this.frames = allFrames;
this.isExtendedMode = true;
@@ -1546,7 +1667,8 @@ export class BomLocalRadarCard extends LitElement implements LovelaceCard {
if (!isoString) return '-';
const date = new Date(isoString);
const locale = this._getLocale();
const timeZone = this.hass?.config?.time_zone || 'Australia/Brisbane';
// Use HA timezone if available, otherwise use browser's timezone
const timeZone = this.hass?.config?.time_zone || Intl.DateTimeFormat().resolvedOptions().timeZone;
return date.toLocaleString(locale, {
timeZone: timeZone,
@@ -1870,19 +1992,21 @@ export class BomLocalRadarCard extends LitElement implements LovelaceCard {
}
const displayConfig = typeof config === 'object' ? config : {};
const configPosition = displayConfig.position || 'above';
// Default position is 'above' if not specified
const configPosition = displayConfig.position ?? 'above';
// Only render if position matches
if (configPosition !== position) {
return '';
}
// If overlay, render differently
// If overlay, render differently (overlay doesn't use style, it has its own compact format)
if (position === 'overlay') {
return this._renderOverlayMetadata();
}
const style = displayConfig.style || 'cards';
// Default style is 'cards' if not specified
const style = displayConfig.style ?? 'cards';
return html`
<div class="metadata-section metadata-${position} metadata-${style}">
@@ -1987,21 +2111,25 @@ export class BomLocalRadarCard extends LitElement implements LovelaceCard {
*/
private _renderOverlayMetadata(): TemplateResult {
const config = this._getMetadataConfig();
if (!config || typeof config !== 'object' || config.position !== 'overlay') {
if (!config || (typeof config === 'boolean' && !config)) {
return html``;
}
const opacity = this._config.overlay_opacity ?? 0.85;
const position = this._config.overlay_position || 'top';
// Handle both object config and boolean true (default)
const displayConfig = typeof config === 'object' ? config : {};
// Use separate metadata overlay settings if available, otherwise fall back to controls overlay settings
const opacity = this._config.metadata_overlay_opacity ?? 0.85;
const position = this._config.metadata_overlay_position ?? 'top';
return html`
<div class="metadata-overlay overlay-${position}" style="opacity: ${opacity};">
${config.show_cache_status !== false ? this._renderCacheStatus() : ''}
${config.show_observation_time !== false ? this._renderObservationTime() : ''}
${config.show_forecast_time !== false ? this._renderForecastTime() : ''}
${config.show_weather_station !== false ? this._renderWeatherStation() : ''}
${config.show_distance !== false ? this._renderDistance() : ''}
${config.show_next_update !== false ? this._renderNextUpdate() : ''}
${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>
`;
}
@@ -2012,7 +2140,10 @@ export class BomLocalRadarCard extends LitElement implements LovelaceCard {
private _renderRadarImage(): TemplateResult {
const zoom = this._config.image_zoom || 1.0;
const fit = this._config.image_fit || 'contain';
const overlayControls = this._config.overlay_controls || false;
const controlsConfig = this._getControlsConfig();
const controlsPosition = typeof controlsConfig === 'object' && controlsConfig.position === 'overlay';
const metadataConfig = this._getMetadataConfig();
const metadataPosition = typeof metadataConfig === 'object' && metadataConfig.position === 'overlay';
const currentFrameUrl = this.getCurrentFrameUrl();
return html`
@@ -2040,8 +2171,8 @@ export class BomLocalRadarCard extends LitElement implements LovelaceCard {
};
}}"
/>
${overlayControls ? this._renderOverlayControls() : ''}
${this._renderOverlayMetadata()}
${controlsPosition ? this._renderOverlayControls() : ''}
${metadataPosition ? this._renderOverlayMetadata() : ''}
`
: ''
}
@@ -2059,8 +2190,8 @@ export class BomLocalRadarCard extends LitElement implements LovelaceCard {
}
const displayConfig = typeof config === 'object' ? config : {};
const position = this._config.overlay_position || 'bottom';
const opacity = this._config.overlay_opacity ?? 0.9;
const position = this._config.controls_overlay_position ?? 'bottom';
const opacity = this._config.controls_overlay_opacity ?? 0.9;
return html`
<div class="controls-overlay overlay-${position}" style="opacity: ${opacity};">
@@ -2075,22 +2206,28 @@ export class BomLocalRadarCard extends LitElement implements LovelaceCard {
/**
* Render controls section
*/
private _renderControls(): TemplateResult {
private _renderControls(position?: 'above' | 'below' | 'overlay'): TemplateResult {
const config = this._getControlsConfig();
if (!config || (typeof config === 'boolean' && !config)) {
return html``;
}
// If overlay is enabled, controls are rendered in overlay
if (this._config.overlay_controls) {
const displayConfig = typeof config === 'object' ? config : {};
// Default position is 'below' if not specified
const configPosition = displayConfig.position ?? 'below';
// Only render if position matches (or if no position filter specified)
if (position !== undefined && configPosition !== position) {
return html``;
}
const displayConfig = typeof config === 'object' ? config : {};
const position = displayConfig.position || 'below';
// If overlay, render differently
if (configPosition === 'overlay') {
return this._renderOverlayControls();
}
return html`
<div class="controls-section controls-${position}">
<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() : ''}
@@ -2125,12 +2262,19 @@ export class BomLocalRadarCard extends LitElement implements LovelaceCard {
/**
* Render navigation buttons
* Only shows skip buttons for timeseries data (extended mode)
*/
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.showFrame(0)}"
@@ -2140,18 +2284,18 @@ export class BomLocalRadarCard extends LitElement implements LovelaceCard {
>⏮</button>
<button
class="frame-nav-btn"
@click="${() => this.jumpFrame(-10)}"
@click="${() => this.jumpFrame(-skipAmount)}"
?disabled="${this.currentFrameIndex === 0}"
title="Go back 10 frames"
aria-label="Go back 10 frames"
>-10</button>
title="Go back ${skipAmount} frames"
aria-label="Go back ${skipAmount} frames"
>-${skipAmount}</button>
<button
class="frame-nav-btn"
@click="${() => this.jumpFrame(10)}"
@click="${() => this.jumpFrame(skipAmount)}"
?disabled="${this.currentFrameIndex >= this.frames.length - 1}"
title="Go forward 10 frames"
aria-label="Go forward 10 frames"
>+10</button>
title="Go forward ${skipAmount} frames"
aria-label="Go forward ${skipAmount} frames"
>+${skipAmount}</button>
<button
class="frame-nav-btn"
@click="${() => this.showFrame(this.frames.length - 1)}"
@@ -2159,6 +2303,22 @@ export class BomLocalRadarCard extends LitElement implements LovelaceCard {
title="Last frame"
aria-label="Last frame"
>⏭</button>
` : html`
<button
class="frame-nav-btn"
@click="${() => this.showFrame(0)}"
?disabled="${this.currentFrameIndex === 0}"
title="First frame"
aria-label="First frame"
>⏮</button>
<button
class="frame-nav-btn"
@click="${() => this.showFrame(this.frames.length - 1)}"
?disabled="${this.currentFrameIndex >= this.frames.length - 1}"
title="Last frame"
aria-label="Last frame"
>⏭</button>
`}
</div>
`;
}
@@ -2168,6 +2328,7 @@ export class BomLocalRadarCard extends LitElement implements LovelaceCard {
*/
private _renderPlayPause(): TemplateResult {
return html`
<div class="play-controls">
<button
class="play-btn"
@click="${() => this.toggleAnimation()}"
@@ -2176,6 +2337,7 @@ export class BomLocalRadarCard extends LitElement implements LovelaceCard {
>
${this.isPlaying ? '⏸ Pause' : '▶ Play'}
</button>
</div>
`;
}
@@ -2223,22 +2385,22 @@ export class BomLocalRadarCard extends LitElement implements LovelaceCard {
return html`
<div class="frame-info">
<div class="frame-index">${frameInfoText}</div>
<span class="frame-index">${frameInfoText}</span>
${showFrameTimes && currentFrame.absoluteObservationTime ? html`
<div class="frame-time">
<span class="frame-time">
${this.formatTimestamp(currentFrame.absoluteObservationTime)}
</div>
</span>
` : showFrameTimes && currentFrame.minutesAgo !== undefined ? html`
<div class="frame-time">
${currentFrame.minutesAgo} minutes ago
</div>
<span class="frame-time">
${currentFrame.minutesAgo} min ago
</span>
` : ''}
${this.radarData?.observationTime && showFrameTimes ? html`
<div class="observation-time">
<span class="observation-time">
Obs: ${this.formatTimestamp(this.radarData.observationTime)}
</div>
</span>
` : ''}
<div class="frame-progress">Progress: ${progress}%</div>
<span class="frame-progress">${progress}%</span>
</div>
`;
}
@@ -2317,7 +2479,7 @@ export class BomLocalRadarCard extends LitElement implements LovelaceCard {
<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 || 'N/A'} to ${this.error.details.availableRange.newest || 'N/A'}
<strong>Available Data Range:</strong> ${this.error.details.availableRange.oldest ? this.formatTimestamp(this.error.details.availableRange.oldest) : 'N/A'} to ${this.error.details.availableRange.newest ? this.formatTimestamp(this.error.details.availableRange.newest) : 'N/A'}
${this.error.details.availableRange.totalCacheFolders ? html` (${this.error.details.availableRange.totalCacheFolders} folders)` : ''}
</div>
</div>
@@ -2326,7 +2488,7 @@ export class BomLocalRadarCard extends LitElement implements LovelaceCard {
<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 || 'N/A'} to ${this.error.details.requestedRange.end || 'N/A'}
<strong>Requested Range:</strong> ${this.error.details.requestedRange.start ? this.formatTimestamp(this.error.details.requestedRange.start) : 'N/A'} to ${this.error.details.requestedRange.end ? this.formatTimestamp(this.error.details.requestedRange.end) : 'N/A'}
</div>
</div>
` : ''}
@@ -2334,7 +2496,7 @@ export class BomLocalRadarCard extends LitElement implements LovelaceCard {
<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 || 'N/A'} to ${this.error.details.suggestedRange.end || 'N/A'}
<strong>Suggested Range:</strong> ${this.error.details.suggestedRange.start ? this.formatTimestamp(this.error.details.suggestedRange.start) : 'N/A'} to ${this.error.details.suggestedRange.end ? this.formatTimestamp(this.error.details.suggestedRange.end) : 'N/A'}
<div class="suggestion-hint">Try using this time range instead</div>
</div>
</div>
@@ -2355,6 +2517,7 @@ export class BomLocalRadarCard extends LitElement implements LovelaceCard {
<button
class="retry-button"
type="button"
style="--error-color: ${errorColor}"
@click=${(e: Event) => {
e.preventDefault();
e.stopPropagation();
@@ -2385,9 +2548,10 @@ export class BomLocalRadarCard extends LitElement implements LovelaceCard {
>
<div id="root">
${this._renderMetadata('above')}
${this._renderControls('above')}
${this._renderRadarImage()}
${this._renderMetadata('below')}
${this._renderControls()}
${this._renderControls('below')}
</div>
</ha-card>
`;
@@ -2398,7 +2562,12 @@ export class BomLocalRadarCard extends LitElement implements LovelaceCard {
*/
private _getRootClasses(): string {
const classes: string[] = [];
if (this._config.overlay_controls) {
const controlsConfig = this._getControlsConfig();
const controlsPosition = typeof controlsConfig === 'object' && controlsConfig.position === 'overlay';
const metadataConfig = this._getMetadataConfig();
const metadataPosition = typeof metadataConfig === 'object' && metadataConfig.position === 'overlay';
if (controlsPosition || metadataPosition) {
classes.push('overlay-enabled');
}
return classes.join(' ');
+501 -56
View File
@@ -16,12 +16,27 @@ const AUSTRALIAN_STATES = [
{ value: 'WA', label: 'Western Australia' },
];
interface CacheRange {
oldestCache?: {
cacheFolderName: string;
cacheTimestamp: string;
};
newestCache?: {
cacheFolderName: string;
cacheTimestamp: string;
};
totalCacheFolders: number;
timeSpanMinutes?: number;
}
@customElement('bom-local-radar-card-editor')
export class BomLocalRadarCardEditor extends LitElement implements LovelaceCardEditor {
@property({ attribute: false }) public hass?: HomeAssistant;
@state() private _config: BomLocalRadarCardConfig = this._mergeWithDefaults();
@state() private _metadataExpanded = false;
@state() private _controlsExpanded = false;
@state() private _cacheRange?: CacheRange;
@state() private _cacheRangeError?: string;
private _mergeWithDefaults(config: Partial<BomLocalRadarCardConfig> = {}): BomLocalRadarCardConfig {
const defaults: Partial<BomLocalRadarCardConfig> = {
@@ -35,7 +50,6 @@ export class BomLocalRadarCardEditor extends LitElement implements LovelaceCardE
show_controls: true,
image_zoom: 1.0,
image_fit: 'contain',
overlay_opacity: 0.9,
};
return {
@@ -90,14 +104,16 @@ export class BomLocalRadarCardEditor extends LitElement implements LovelaceCardE
<div class="section">
<h3>Display</h3>
<div class="switch-wrapper">
<label class="switch-label">Show Card Title</label>
<ha-switch
label="Show Card Title"
.checked=${this._config.show_card_title !== false}
@change=${(e: Event) => {
const checked = (e.target as HTMLInputElement).checked;
this._updateConfig('show_card_title', checked);
}}
></ha-switch>
</div>
${this._config.show_card_title !== false ? html`
<ha-textfield
label="Card Title"
@@ -109,14 +125,16 @@ export class BomLocalRadarCardEditor extends LitElement implements LovelaceCardE
<div class="metadata-section">
<div class="section-header">
<div class="switch-wrapper">
<label class="switch-label">Show Metadata</label>
<ha-switch
label="Show Metadata"
.checked=${this._getMetadataEnabled()}
@change=${(e: Event) => {
const checked = (e.target as HTMLInputElement).checked;
this._updateMetadataToggle(checked);
}}
></ha-switch>
</div>
${this._getMetadataEnabled() ? html`
<ha-icon-button
.label=${this._metadataExpanded ? 'Collapse' : 'Expand'}
@@ -128,85 +146,154 @@ export class BomLocalRadarCardEditor extends LitElement implements LovelaceCardE
</div>
${this._getMetadataEnabled() && this._metadataExpanded ? html`
<div class="metadata-options">
<div class="switch-wrapper">
<label class="switch-label">Cache Status</label>
<ha-switch
label="Cache Status"
.checked=${this._getMetadataConfig('show_cache_status')}
@change=${(e: Event) => this._updateMetadataConfig('show_cache_status', (e.target as HTMLInputElement).checked)}
></ha-switch>
</div>
<div class="switch-wrapper">
<label class="switch-label">Observation Time</label>
<ha-switch
label="Observation Time"
.checked=${this._getMetadataConfig('show_observation_time')}
@change=${(e: Event) => this._updateMetadataConfig('show_observation_time', (e.target as HTMLInputElement).checked)}
></ha-switch>
</div>
<div class="switch-wrapper">
<label class="switch-label">Forecast Time</label>
<ha-switch
label="Forecast Time"
.checked=${this._getMetadataConfig('show_forecast_time')}
@change=${(e: Event) => this._updateMetadataConfig('show_forecast_time', (e.target as HTMLInputElement).checked)}
></ha-switch>
</div>
<div class="switch-wrapper">
<label class="switch-label">Weather Station</label>
<ha-switch
label="Weather Station"
.checked=${this._getMetadataConfig('show_weather_station')}
@change=${(e: Event) => this._updateMetadataConfig('show_weather_station', (e.target as HTMLInputElement).checked)}
></ha-switch>
</div>
<div class="switch-wrapper">
<label class="switch-label">Distance</label>
<ha-switch
label="Distance"
.checked=${this._getMetadataConfig('show_distance')}
@change=${(e: Event) => this._updateMetadataConfig('show_distance', (e.target as HTMLInputElement).checked)}
></ha-switch>
</div>
<div class="switch-wrapper">
<label class="switch-label">Next Update</label>
<ha-switch
label="Next Update"
.checked=${this._getMetadataConfig('show_next_update')}
@change=${(e: Event) => this._updateMetadataConfig('show_next_update', (e.target as HTMLInputElement).checked)}
></ha-switch>
</div>
<div class="switch-wrapper">
<label class="switch-label">Frame Times</label>
<ha-switch
label="Frame Times"
.checked=${this._getMetadataConfig('show_frame_times')}
@change=${(e: Event) => this._updateMetadataConfig('show_frame_times', (e.target as HTMLInputElement).checked)}
></ha-switch>
</div>
<div class="select-wrapper">
<label class="select-label">Metadata Position</label>
<label class="select-label">Metadata Position *</label>
<select
class="native-select"
.value=${this._getMetadataConfig('position') || 'above'}
.value=${String(this._getMetadataConfig('position') ?? 'above')}
@change=${(e: Event) => {
e.preventDefault();
e.stopPropagation();
try {
const select = e.target as HTMLSelectElement;
this._updateMetadataConfig('position', select.value as 'above' | 'below' | 'overlay');
const value = select.value;
if (value && value !== '') {
this._updateMetadataConfig('position', value as 'above' | 'below' | 'overlay');
}
} catch (err) {
console.error('Error updating metadata position:', err);
}
}}
>
<option value="above">Above Image</option>
<option value="above">Above Image (Default)</option>
<option value="below">Below Image</option>
<option value="overlay">Overlay on Image</option>
</select>
</div>
${this._getMetadataConfig('position') === 'overlay' ? html`
<div class="select-wrapper">
<label class="select-label">Metadata Style</label>
<label class="select-label">Metadata Overlay Position *</label>
<select
class="native-select"
.value=${this._getMetadataConfig('style') || 'cards'}
.value=${this._config.metadata_overlay_position ?? 'top'}
@change=${(e: Event) => {
e.preventDefault();
e.stopPropagation();
const select = e.target as HTMLSelectElement;
this._updateMetadataConfig('style', select.value as 'cards' | 'compact' | 'minimal');
this._updateConfig('metadata_overlay_position', select.value);
}}
>
<option value="cards">Cards</option>
<option value="top">Top (Default)</option>
<option value="bottom">Bottom</option>
<option value="left">Left</option>
<option value="right">Right</option>
<option value="center">Center</option>
</select>
</div>
<ha-textfield
label="Metadata Overlay Opacity (0.0 - 1.0)"
type="number"
step="0.1"
min="0"
max="1"
.value=${String(this._config.metadata_overlay_opacity ?? 0.85)}
@input=${(e: Event) => this._updateConfig('metadata_overlay_opacity', parseFloat((e.target as HTMLInputElement).value))}
></ha-textfield>
` : ''}
${this._getMetadataConfig('position') !== 'overlay' ? html`
<div class="select-wrapper">
<label class="select-label">Metadata Style *</label>
<select
class="native-select"
.value=${this._getMetadataConfig('style') ?? 'cards'}
@change=${(e: Event) => {
e.preventDefault();
e.stopPropagation();
const select = e.target as HTMLSelectElement;
const value = select.value;
// Allow empty string to reset to default
if (value === '') {
this._updateMetadataConfig('style', undefined);
} else {
this._updateMetadataConfig('style', value as 'cards' | 'compact' | 'minimal');
}
}}
>
<option value="cards">Cards (Default)</option>
<option value="compact">Compact</option>
<option value="minimal">Minimal</option>
</select>
</div>
` : html`
<div class="info-text" style="padding: 8px; background: var(--info-color, rgba(33, 150, 243, 0.1)); border-radius: 4px; font-size: 0.875em;">
<strong>Note:</strong> Overlay position uses a fixed compact style optimized for overlaying on images.
</div>
`}
</div>
` : ''}
</div>
<div class="controls-section">
<div class="section-header">
<div class="switch-wrapper">
<label class="switch-label">Show Controls</label>
<ha-switch
label="Show Controls"
.checked=${this._getControlsEnabled()}
@change=${(e: Event) => {
const checked = (e.target as HTMLInputElement).checked;
this._updateControlsToggle(checked);
}}
></ha-switch>
</div>
${this._getControlsEnabled() ? html`
<ha-icon-button
.label=${this._controlsExpanded ? 'Collapse' : 'Expand'}
@@ -218,66 +305,106 @@ export class BomLocalRadarCardEditor extends LitElement implements LovelaceCardE
</div>
${this._getControlsEnabled() && this._controlsExpanded ? html`
<div class="controls-options">
<div class="switch-wrapper">
<label class="switch-label">Play/Pause Button</label>
<ha-switch
label="Play/Pause Button"
.checked=${this._getControlsConfig('show_play_pause')}
@change=${(e: Event) => this._updateControlsConfig('show_play_pause', (e.target as HTMLInputElement).checked)}
></ha-switch>
</div>
<div class="switch-wrapper">
<label class="switch-label">Previous/Next Buttons</label>
<ha-switch
label="Previous/Next Buttons"
.checked=${this._getControlsConfig('show_prev_next')}
@change=${(e: Event) => this._updateControlsConfig('show_prev_next', (e.target as HTMLInputElement).checked)}
></ha-switch>
</div>
<div class="switch-wrapper">
<label class="switch-label">Frame Slider</label>
<ha-switch
label="Frame Slider"
.checked=${this._getControlsConfig('show_slider')}
@change=${(e: Event) => this._updateControlsConfig('show_slider', (e.target as HTMLInputElement).checked)}
></ha-switch>
</div>
<div class="switch-wrapper">
<label class="switch-label">Navigation Buttons (-10, +10, First, Last)</label>
<ha-switch
label="Navigation Buttons (-10, +10, First, Last)"
.checked=${this._getControlsConfig('show_nav_buttons')}
@change=${(e: Event) => this._updateControlsConfig('show_nav_buttons', (e.target as HTMLInputElement).checked)}
></ha-switch>
</div>
<div class="switch-wrapper">
<label class="switch-label">Frame Info</label>
<ha-switch
label="Frame Info"
.checked=${this._getControlsConfig('show_frame_info')}
@change=${(e: Event) => this._updateControlsConfig('show_frame_info', (e.target as HTMLInputElement).checked)}
></ha-switch>
<ha-switch
label="Overlay Controls on Image"
.checked=${this._config.overlay_controls || false}
@change=${(e: Event) => {
const checked = (e.target as HTMLInputElement).checked;
this._updateConfig('overlay_controls', checked);
}}
></ha-switch>
${this._config.overlay_controls ? html`
</div>
<div class="select-wrapper">
<label class="select-label">Overlay Position</label>
<label class="select-label">Controls Position *</label>
<select
class="native-select"
.value=${this._config.overlay_position || 'bottom'}
.value=${String(this._getControlsConfig('position') ?? 'below')}
@change=${(e: Event) => {
e.preventDefault();
e.stopPropagation();
try {
const select = e.target as HTMLSelectElement;
this._updateConfig('overlay_position', select.value);
const value = select.value;
if (value && value !== '') {
this._updateControlsConfig('position', value as 'above' | 'below' | 'overlay');
}
} catch (err) {
console.error('Error updating controls position:', err);
}
}}
>
<option value="above">Above Image</option>
<option value="below">Below Image (Default)</option>
<option value="overlay">Overlay on Image</option>
</select>
</div>
${this._getControlsConfig('position') === 'overlay' ? html`
<div class="select-wrapper">
<label class="select-label">Controls Overlay Position *</label>
<select
class="native-select"
.value=${this._config.controls_overlay_position ?? 'bottom'}
@change=${(e: Event) => {
e.preventDefault();
e.stopPropagation();
try {
const select = e.target as HTMLSelectElement;
this._updateConfig('controls_overlay_position', select.value);
} catch (err) {
console.error('Error updating controls overlay position:', err);
}
}}
>
<option value="top">Top</option>
<option value="bottom">Bottom</option>
<option value="bottom">Bottom (Default)</option>
<option value="left">Left</option>
<option value="right">Right</option>
<option value="center">Center</option>
</select>
</div>
<ha-textfield
label="Overlay Opacity (0.0 - 1.0)"
label="Controls Overlay Opacity (0.0 - 1.0)"
type="number"
step="0.1"
min="0"
max="1"
.value=${String(this._config.overlay_opacity ?? 0.9)}
@input=${(e: Event) => this._updateConfig('overlay_opacity', parseFloat((e.target as HTMLInputElement).value))}
.value=${String(this._config.controls_overlay_opacity ?? 0.9)}
@input=${(e: Event) => this._updateConfig('controls_overlay_opacity', parseFloat((e.target as HTMLInputElement).value))}
></ha-textfield>
${(() => {
const conflict = this._checkOverlayConflict();
return conflict.hasConflict ? html`
<div class="warning-text" style="padding: 8px; background: var(--warning-color, rgba(255, 152, 0, 0.1)); border-left: 3px solid var(--warning-color, #ff9800); border-radius: 4px; font-size: 0.875em; margin-top: 8px;">
${conflict.message}
</div>
` : '';
})()}
` : ''}
</div>
` : ''}
@@ -319,18 +446,76 @@ export class BomLocalRadarCardEditor extends LitElement implements LovelaceCardE
.value=${this._config.timespan || 'latest'}
@change=${(e: Event) => {
const select = e.target as HTMLSelectElement;
this._updateConfig('timespan', select.value);
const newTimespan = select.value as 'latest' | '1h' | '3h' | '6h' | '12h' | '24h' | 'custom';
// Clear custom time fields if switching away from custom
if (newTimespan !== 'custom' && this._config.timespan === 'custom') {
const updatedConfig: BomLocalRadarCardConfig = { ...this._config };
updatedConfig.timespan = newTimespan;
delete updatedConfig.custom_start_time;
delete updatedConfig.custom_end_time;
this._config = updatedConfig;
fireEvent(this, 'config-changed', { config: updatedConfig });
} else {
this._updateConfig('timespan', newTimespan);
}
}}
>
<option value="latest">Latest 7 frames</option>
${this._cacheRange && this._cacheRange.totalCacheFolders > 0 ? html`
<option value="1h">Last 1 hour${this._getRangeWarning('1h') ? ' ⚠️' : ''}</option>
<option value="3h">Last 3 hours${this._getRangeWarning('3h') ? ' ⚠️' : ''}</option>
<option value="6h">Last 6 hours${this._getRangeWarning('6h') ? ' ⚠️' : ''}</option>
<option value="12h">Last 12 hours${this._getRangeWarning('12h') ? ' ⚠️' : ''}</option>
<option value="24h">Last 24 hours${this._getRangeWarning('24h') ? ' ⚠️' : ''}</option>
` : html`
<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 time range</option>
</select>
</div>
${this._cacheRange && this._cacheRange.totalCacheFolders > 0 ? html`
<div class="cache-range-info">
<div class="info-text">
<strong>Available cache:</strong> ${this._formatCacheRange()}
</div>
</div>
` : this._cacheRangeError ? html`
<div class="cache-range-warning">
<div class="warning-text">⚠️ ${this._cacheRangeError}</div>
</div>
` : ''}
${this._getRangeWarning(this._config.timespan || 'latest') ? html`
<div class="range-warning">
<div class="warning-text">
⚠️ Selected timespan may not have enough data. Available: ${this._formatCacheRange()}
</div>
</div>
` : ''}
${this._config.timespan === 'custom' ? html`
<ha-textfield
label="Custom Start Time (ISO 8601)"
.value=${this._config.custom_start_time || ''}
@input=${(e: Event) => this._updateConfig('custom_start_time', (e.target as HTMLInputElement).value)}
helper="e.g., 2025-01-15T10:00:00Z"
></ha-textfield>
<ha-textfield
label="Custom End Time (ISO 8601)"
.value=${this._config.custom_end_time || ''}
@input=${(e: Event) => this._updateConfig('custom_end_time', (e.target as HTMLInputElement).value)}
helper="e.g., 2025-01-15T18:00:00Z"
></ha-textfield>
${this._validateCustomRange() ? html`
<div class="range-warning">
<div class="warning-text">
⚠️ ${this._validateCustomRange()}
</div>
</div>
` : ''}
` : ''}
<ha-textfield
label="Frame Interval (seconds)"
type="number"
@@ -340,8 +525,9 @@ export class BomLocalRadarCardEditor extends LitElement implements LovelaceCardE
.value=${String(this._config.frame_interval || 2.0)}
@input=${(e: Event) => this._updateConfig('frame_interval', parseFloat((e.target as HTMLInputElement).value))}
></ha-textfield>
<div class="switch-wrapper">
<label class="switch-label">Auto Play</label>
<ha-switch
label="Auto Play"
.checked=${this._config.auto_play !== false}
@change=${(e: Event) => {
const checked = (e.target as HTMLInputElement).checked;
@@ -349,6 +535,7 @@ export class BomLocalRadarCardEditor extends LitElement implements LovelaceCardE
}}
></ha-switch>
</div>
</div>
<div class="section">
<h3>Auto Refresh</h3>
@@ -395,18 +582,30 @@ export class BomLocalRadarCardEditor extends LitElement implements LovelaceCardE
}
}
private _getMetadataConfig(key: keyof MetadataDisplayConfig): boolean {
private _getMetadataConfig(key: keyof MetadataDisplayConfig): boolean | string | undefined {
const config = this._config.show_metadata;
if (typeof config === 'boolean') {
return config; // If boolean, all metadata follows this value
// For boolean config, return default values for position/style, true for others
if (key === 'position') return 'above';
if (key === 'style') return 'cards';
return config;
}
if (typeof config === 'object') {
return config[key] !== false; // Default to true if not explicitly false
const value = config[key];
// For position/style, return the value or undefined (default)
if (key === 'position' || key === 'style') {
return value as string | undefined;
}
return true; // Default
// For boolean fields, default to true if not explicitly false
return value !== false;
}
// Default values
if (key === 'position') return 'above';
if (key === 'style') return 'cards';
return true;
}
private _updateMetadataConfig(key: keyof MetadataDisplayConfig, value: boolean | string): void {
private _updateMetadataConfig(key: keyof MetadataDisplayConfig, value: boolean | string | undefined): void {
let config: MetadataDisplayConfig;
if (typeof this._config.show_metadata === 'object') {
@@ -415,14 +614,25 @@ export class BomLocalRadarCardEditor extends LitElement implements LovelaceCardE
config = {};
}
// Handle undefined to remove the key (revert to default)
if (value === undefined) {
delete (config as any)[key];
} else {
// Type-safe assignment based on key
if (key === 'position' || key === 'style') {
(config as any)[key] = value;
(config as any)[key] = value as string;
} else {
(config as any)[key] = value;
(config as any)[key] = value as boolean;
}
}
// If config is empty, set to true (default)
if (Object.keys(config).length === 0) {
this._updateConfig('show_metadata', true);
} else {
this._updateConfig('show_metadata', config);
}
}
// Controls configuration helpers
private _getControlsEnabled(): boolean {
@@ -447,18 +657,28 @@ export class BomLocalRadarCardEditor extends LitElement implements LovelaceCardE
}
}
private _getControlsConfig(key: keyof ControlsDisplayConfig): boolean {
private _getControlsConfig(key: keyof ControlsDisplayConfig): boolean | string | undefined {
const config = this._config.show_controls;
if (typeof config === 'boolean') {
// For boolean config, return default values for position, true for others
if (key === 'position') return 'below';
return config;
}
if (typeof config === 'object') {
return config[key] !== false;
const value = config[key];
// For position, return the value or undefined (default)
if (key === 'position') {
return value as string | undefined;
}
return true; // Default
// For boolean fields, default to true if not explicitly false
return value !== false;
}
// Default values
if (key === 'position') return 'below';
return true;
}
private _updateControlsConfig(key: keyof ControlsDisplayConfig, value: boolean): void {
private _updateControlsConfig(key: keyof ControlsDisplayConfig, value: boolean | string | undefined): void {
let config: ControlsDisplayConfig;
if (typeof this._config.show_controls === 'object') {
@@ -467,10 +687,67 @@ export class BomLocalRadarCardEditor extends LitElement implements LovelaceCardE
config = {};
}
// All control config values are boolean (position is handled separately in template)
(config as any)[key] = value;
// Handle undefined to remove the key (revert to default)
if (value === undefined) {
delete (config as any)[key];
} else {
// Type-safe assignment based on key
if (key === 'position') {
(config as any)[key] = value as string;
} else {
(config as any)[key] = value as boolean;
}
}
// If config is empty, set to true (default)
if (Object.keys(config).length === 0) {
this._updateConfig('show_controls', true);
} else {
this._updateConfig('show_controls', config);
}
}
/**
* Check if metadata and controls would overlap when both are overlays
*/
private _checkOverlayConflict(): { hasConflict: boolean; message: string } {
const metadataConfig = this._getMetadataConfig('position');
const controlsConfig = this._getControlsConfig('position');
// Both must be overlays to have a conflict
if (metadataConfig !== 'overlay' || controlsConfig !== 'overlay') {
return { hasConflict: false, message: '' };
}
const metadataPos = this._config.metadata_overlay_position ?? 'top';
const controlsPos = this._config.controls_overlay_position ?? 'bottom';
// Check for conflicts
if (metadataPos === controlsPos) {
return {
hasConflict: true,
message: `⚠️ Warning: Both metadata and controls are overlayed at the same position (${metadataPos}). They will overlap.`
};
}
// Check for adjacent positions that might conflict
const conflicts: { [key: string]: string[] } = {
'top': ['top', 'center'],
'bottom': ['bottom', 'center'],
'left': ['left', 'center'],
'right': ['right', 'center'],
'center': ['top', 'bottom', 'left', 'right', 'center']
};
if (conflicts[metadataPos]?.includes(controlsPos)) {
return {
hasConflict: true,
message: `⚠️ Warning: Metadata (${metadataPos}) and controls (${controlsPos}) may overlap. Consider using different positions.`
};
}
return { hasConflict: false, message: '' };
}
static styles: CSSResultGroup = css`
.editor {
@@ -568,7 +845,7 @@ export class BomLocalRadarCardEditor extends LitElement implements LovelaceCardE
box-shadow: 0 0 0 2px rgba(3, 169, 244, 0.2);
}
ha-switch {
.switch-wrapper {
display: flex;
align-items: center;
justify-content: space-between;
@@ -576,10 +853,178 @@ export class BomLocalRadarCardEditor extends LitElement implements LovelaceCardE
margin-bottom: 0;
}
.switch-label {
font-size: 0.875rem;
font-weight: 500;
color: var(--primary-text-color, #212121);
flex: 1;
cursor: pointer;
}
ha-switch {
margin-left: 16px;
flex-shrink: 0;
}
ha-icon-button {
--mdc-icon-button-size: 32px;
}
.cache-range-info {
margin-top: 8px;
padding: 8px 12px;
background: var(--info-color, rgba(33, 150, 243, 0.1));
border-left: 3px solid var(--info-color, #2196f3);
border-radius: 4px;
}
.cache-range-info .info-text {
font-size: 0.875em;
color: var(--primary-text-color, #212121);
}
.range-warning,
.cache-range-warning {
margin-top: 8px;
padding: 8px 12px;
background: var(--warning-color, rgba(255, 152, 0, 0.1));
border-left: 3px solid var(--warning-color, #ff9800);
border-radius: 4px;
}
.range-warning .warning-text,
.cache-range-warning .warning-text {
font-size: 0.875em;
color: var(--primary-text-color, #212121);
}
`;
private _getRangeWarning(timespan: string): boolean {
if (!this._cacheRange || this._cacheRange.totalCacheFolders === 0 || timespan === 'latest' || timespan === 'custom') {
return false;
}
const hours = parseInt(timespan.replace('h', '')) || 0;
const requestedMinutes = hours * 60;
const availableMinutes = this._cacheRange.timeSpanMinutes || 0;
return requestedMinutes > availableMinutes;
}
private _formatCacheRange(): string {
if (!this._cacheRange || this._cacheRange.totalCacheFolders === 0) {
return 'No cache available';
}
const oldest = this._cacheRange.oldestCache?.cacheTimestamp;
const newest = this._cacheRange.newestCache?.cacheTimestamp;
const timeSpan = this._cacheRange.timeSpanMinutes;
if (oldest && newest) {
const oldestDate = new Date(oldest);
const newestDate = new Date(newest);
// Use local timezone for display
const locale = this.hass?.locale?.language || 'en-AU';
const timeZone = this.hass?.config?.time_zone || Intl.DateTimeFormat().resolvedOptions().timeZone;
const oldestStr = oldestDate.toLocaleString(locale, {
timeZone: timeZone,
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
const newestStr = newestDate.toLocaleString(locale, {
timeZone: timeZone,
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
if (timeSpan) {
const hours = Math.floor(timeSpan / 60);
const minutes = timeSpan % 60;
return `${oldestStr} to ${newestStr} (${hours}h ${minutes}m, ${this._cacheRange.totalCacheFolders} folders)`;
}
return `${oldestStr} to ${newestStr} (${this._cacheRange.totalCacheFolders} folders)`;
}
return `${this._cacheRange.totalCacheFolders} cache folders available`;
}
private _validateCustomRange(): string | null {
// Only validate if timespan is custom
if (this._config.timespan !== 'custom') {
return null;
}
if (!this._config.custom_start_time || !this._config.custom_end_time) {
return null; // No validation needed if fields are empty
}
try {
const startTime = new Date(this._config.custom_start_time);
const endTime = new Date(this._config.custom_end_time);
if (isNaN(startTime.getTime()) || isNaN(endTime.getTime())) {
return 'Invalid date format. Use ISO 8601 format (e.g., 2025-01-15T10:00:00Z)';
}
if (startTime >= endTime) {
return 'Start time must be before end time';
}
if (this._cacheRange && this._cacheRange.oldestCache && this._cacheRange.newestCache) {
try {
const oldestCache = new Date(this._cacheRange.oldestCache.cacheTimestamp);
const newestCache = new Date(this._cacheRange.newestCache.cacheTimestamp);
if (isNaN(oldestCache.getTime()) || isNaN(newestCache.getTime())) {
return null; // Can't validate if cache dates are invalid
}
if (startTime < oldestCache) {
const locale = this.hass?.locale?.language || 'en-AU';
const timeZone = this.hass?.config?.time_zone || Intl.DateTimeFormat().resolvedOptions().timeZone;
const oldestStr = oldestCache.toLocaleString(locale, {
timeZone: timeZone,
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
return `Start time is before available cache (${oldestStr})`;
}
if (endTime > newestCache) {
const locale = this.hass?.locale?.language || 'en-AU';
const timeZone = this.hass?.config?.time_zone || Intl.DateTimeFormat().resolvedOptions().timeZone;
const newestStr = newestCache.toLocaleString(locale, {
timeZone: timeZone,
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
return `End time is after available cache (${newestStr})`;
}
} catch (err) {
// Ignore cache date parsing errors
return null;
}
}
return null; // Valid
} catch (err) {
return 'Invalid date format';
}
}
}
+7 -5
View File
@@ -116,7 +116,6 @@ export interface BomLocalRadarCardConfig extends LovelaceCardConfig {
// Display
show_card_title?: boolean; // Show/hide card title (uses HA card header)
card_title?: string;
show_timestamp?: boolean; // Deprecated - use show_metadata instead
show_metadata?: boolean | MetadataDisplayConfig; // Granular metadata display
// Control visibility
@@ -126,10 +125,13 @@ export interface BomLocalRadarCardConfig extends LovelaceCardConfig {
image_zoom?: number; // 1.0 = 100%, 1.5 = 150%, etc. (0.5 to 3.0)
image_fit?: 'contain' | 'cover' | 'fill';
// Overlay options
overlay_controls?: boolean; // Overlay controls on image
overlay_position?: 'top' | 'bottom' | 'left' | 'right' | 'center';
overlay_opacity?: number; // 0.0 to 1.0
// Controls overlay options (separate from metadata overlay)
controls_overlay_position?: 'top' | 'bottom' | 'left' | 'right' | 'center';
controls_overlay_opacity?: number; // 0.0 to 1.0
// Metadata overlay options (separate from controls overlay)
metadata_overlay_position?: 'top' | 'bottom' | 'left' | 'right' | 'center';
metadata_overlay_opacity?: number; // 0.0 to 1.0
// Slideshow configuration
timespan?: 'latest' | '1h' | '3h' | '6h' | '12h' | '24h' | 'custom'; // Historical data timespan