diff --git a/README.md b/README.md index 4e4e272..c3efe39 100644 --- a/README.md +++ b/README.md @@ -74,14 +74,16 @@ To install the card, follow these steps: ### Migrating from `custom:flightradar24-card` -The Flightradar24 integration now ships its own card under the type `custom:flightradar24-card`, which conflicts with this card's original type name. To resolve this, this card is now registered as **`custom:flightradar24-radar-card`**. +The Flightradar24 integration now ships its own card under the type `custom:flightradar24-card`, which conflicts with this card's original type name. This card is therefore registered as **`custom:flightradar24-radar-card`** and no longer registers the `custom:flightradar24-card` type. -- **If you have the Flightradar24 integration installed**, your existing `custom:flightradar24-card` cards now render the integration's built-in map card. Update them to `custom:flightradar24-radar-card` to keep using this card. -- The card is now listed as **"Flightradar24 Radar Card"** in the Add card dialog, and appears in the "By entity" suggestions for your Flightradar24 flights sensor. +As of **v0.4.1** the `custom:flightradar24-card` alias has been removed. Earlier versions registered it when the name was free, but a custom element type can only be registered once: if this card loaded its alias before the Flightradar24 integration's card, the integration's card failed to load. To avoid breaking the integration's card, the alias must not be registered at all. + +- Update every existing `custom:flightradar24-card` card on your dashboards to `custom:flightradar24-radar-card`. +- The card is listed as **"Flightradar24 Radar Card"** in the Add card dialog, and appears in the "By entity" suggestions for your Flightradar24 flights sensor. #### Updating existing cards -The Flightradar24 integration's built-in card now owns the `custom:flightradar24-card` type, and the card **type** cannot be changed in the visual card editor — it has to be updated in the dashboard's raw YAML configuration. There are two ways: +The card **type** cannot be changed in the visual card editor — it has to be updated in the dashboard's raw YAML configuration. There are two ways: - **Dashboards with YAML mode** (`lovelace: mode: yaml`): edit `ui-lovelace.yaml` and replace `type: custom:flightradar24-card` with `type: custom:flightradar24-radar-card`. - **Dashboards managed via the UI**: open the dashboard, click the edit (pencil) icon, then the three-dot menu (⋮) and select **Raw config editor**. In the YAML, replace `type: custom:flightradar24-card` with `type: custom:flightradar24-radar-card`, then save. @@ -229,9 +231,13 @@ radar: | `hide` | Option to hide the radar | `false` | Must be `true` or `false` | | `hide_range` | Option to hide the radar range | `false` | Must be `true` or `false` | | `radar_size` | Size of the radar as percentage of card width | `70` | Number between 30 and 90 | +| `view` | Display mode of the radar area | `radar` | `radar` (circular screen) or `map` (square, full-bleed) | +| `rings` | Draw the radar grid rings / bearing lines | `true` in radar view, `false` in map view | Must be `true` or `false` | **Note:** For backwards compatibility, the old color property names (`primary-color`, `accent-color`, `feature-color`) are still supported but deprecated. They will be automatically migrated to the new names when using the visual editor. +**Square map mode (`view: map`):** Instead of the circular radar screen, the radar area is rendered as a square map that fills the full width of the card (each side of the square equals the card's inner width). This works great in fullscreen and multi-column layouts where you want a large, unobstructed map. The `radar_size` setting is ignored in map mode. When no `background_map` is configured in map mode, the card automatically falls back to the `system` map type so a map is always shown. Setting `background_map: none` skips the tiled background — the map area still renders with the radar background color, overlays, and flights on top. Radar grid rings and bearing lines are hidden in map mode by default; set `rings: true` to force them back on. + ##### Radar Filter You can filter the flights displayed on the radar using a filter configuration similar to the main filter configuration. @@ -369,22 +375,35 @@ The desc: fields will be ignored by the Card, but will be useful if you want to ```yaml radar: - background_map: bw # Options: bw, color, dark, outlines + background_map: color # Options: keyless (color, satellite, topo) or keyed (light, dark, voyager, bw, outlines) background_map_opacity: 0.7 # Opacity of the map (0=transparent, 1=opaque) - background_map_api_key: YOUR_API_KEY # Optional, for some providers + background_map_api_key: YOUR_API_KEY # Required only for keyed map types ``` -| Option | Description | Values | Default | -| ------------------------ | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------- | ------- | -| `background_map` | Type of map background.
- `system`: Auto-selects 'dark' or 'color' style to match Home Assistant or system Dark Mode. | `system`, `bw`, `color`, `dark`, `outlines` | `none` | -| `background_map_opacity` | Opacity for background map (0 [visible] to 1 [transparent]). | 0–1 (float) | 0 | -| `background_map_api_key` | API key for selected tile provider (optional, for providers that require it). | string (optional) | – | - -- **`system`**: Automatically uses a dark map (`dark`) in dark mode and a standard colored map (`color`) in light mode, matching your Home Assistant or operating system theme. -- **`bw`**: Black-and-white (Stamen Toner) +| Option | Description | Values | Default | +| ------------------------ | -------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | ------- | +| `background_map` | Type of map background.
- `system`: Uses one map for light themes and one for dark themes (select each below). | `system`, `none`, `color`, `satellite`, `topo`, `light`, `dark`, `voyager`, `bw`, `outlines` | `none` | +| `background_map_light` | Map used in light theme when `background_map: system`. | `color`, `satellite`, `topo`, `light`, `dark`, `voyager`, `bw`, `outlines` | `color` | +| `background_map_dark` | Map used in dark theme when `background_map: system`. | `color`, `satellite`, `topo`, `light`, `dark`, `voyager`, `bw`, `outlines` | `dark` | +| `background_map_light_api_key` | API key for `background_map_light`, if that map requires one. | string (optional) | – | +| `background_map_dark_api_key` | API key for `background_map_dark`, if that map requires one. | string (optional) | – | +| `background_map_opacity` | Opacity for background map (0=transparent, 1=opaque). | 0–1 (float) | 0 | +| `background_map_api_key` | API key for the selected tile provider. Required only for keyed map types (see below). | string (optional) | – | + +**Keyless maps** (no API key required — work out of the box): +- **`system`**: Auto-follows your Home Assistant or operating system theme. In light mode it uses your `background_map_light` map and in dark mode your `background_map_dark` map (each can have its own API key). Defaults to `color` (light) and `dark` (dark), matching the previous fixed behaviour — existing `system` configurations keep working unchanged after upgrading. - **`color`**: Standard OpenStreetMap (colored) -- **`dark`**: Dark theme map (CartoDB) -- **`outlines`**: Geographic outlines only +- **`satellite`**: Esri World Imagery (aerial/satellite) +- **`topo`**: OpenTopoMap (topographic) + +**Keyed maps** (require `background_map_api_key` to render — the tile provider serves an "API key required" watermark without it): +- **`light`**: CartoDB Positron (light). [Request a free CARTO key](https://carto.com/basemaps/apikey) +- **`dark`**: CartoDB Dark Matter (dark). [Request a free CARTO key](https://carto.com/basemaps/apikey) +- **`voyager`**: CartoDB Voyager (color). [Request a free CARTO key](https://carto.com/basemaps/apikey) +- **`bw`**: Stamen Toner, black-and-white (Stadia Maps). [Get a free Stadia Maps key](https://stadiamaps.com/) +- **`outlines`**: Geographic outlines only (Stadia Maps). [Get a free Stadia Maps key](https://stadiamaps.com/) + +> CARTO and Stadia tiles require a per-user API key. Per provider terms the key must not be shared, so the card does not ship a default key — each user requests their own and enters it in the `background_map_api_key` field (or in the card editor). Example: @@ -394,6 +413,16 @@ radar: background_map_opacity: 0.5 ``` +Example with per-theme maps (auto dark/light, each with its own key): + +```yaml +radar: + background_map: system + background_map_light: color + background_map_dark: voyager + background_map_dark_api_key: YOUR_CARTO_KEY +``` + If `background_map` is configured, the selected map is rendered beneath the radar graphics. Use transparency to blend the map with the radar background color. #### List Configuration @@ -404,15 +433,19 @@ Configure flight list settings with the `list` option. list: hide: true showListStatus: true + position: below ``` -| Name | Description | Default Value | Constraints | -| ---------------- | --------------------------------------------------------------------------------- | ------------- | ------------------------- | -| `hide` | Option to hide the flight list below the radar card | `false` | Must be `true` or `false` | -| `showListStatus` | Show a summary/status line above the list showing flights listed and total count. | `false` | Must be `true` or `false` | +| Name | Description | Default Value | Constraints | +| ---------------- | --------------------------------------------------------------------------------- | ------------- | --------------------------------------------- | +| `hide` | Option to hide the flight list below the radar card | `false` | Must be `true` or `false` | +| `showListStatus` | Show a summary/status line above the list showing flights listed and total count. | `false` | Must be `true` or `false` | +| `position` | Position of the flight list relative to the radar/map | `below` | Must be `below`, `left`, or `right` | **Note:** When `list.hide` is enabled, the detailed flight list will not be displayed. +**Responsive fallback:** The `left` and `right` positions place the flight list beside the radar/map on wide cards. If the card is too narrow to fit the radar and flight list side by side comfortably (below ~560px), the layout automatically falls back to the `below` position. This is handled with CSS container queries, so the layout adapts dynamically as the card is resized (e.g. when switching between single-, multi-column, and fullscreen dashboards) without any configuration changes. + #### Annotation Configuration Control how single fields are rendered based on conditions. Add annotations to highlight certain flights based on custom criteria. diff --git a/RELEASES.md b/RELEASES.md index b9cd9c7..9862dcb 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -1,5 +1,17 @@ # Release Notes +## v0.4.1 + +### Removed the `custom:flightradar24-card` backwards-compatible alias + +Earlier versions kept a guarded alias that registered `custom:flightradar24-card` when that name was not yet taken. A custom element type can only be registered once, however, so the guard was a race: if this card loaded before the Flightradar24 integration's built-in card, the alias claimed the name and the integration's card failed to load with `Failed to execute 'define' on 'CustomElementRegistry': the name "flightradar24-card" has already been used` on every dashboard load. + +The alias is now removed. The card only registers as **`custom:flightradar24-radar-card`** and no longer collides with the integration's card. + +### Action required + +Update any remaining `custom:flightradar24-card` cards on your dashboards to `custom:flightradar24-radar-card`. All other configuration options are unchanged. + ## v0.3.0 ### Breaking change: card type renamed to `custom:flightradar24-radar-card` diff --git a/eslint.config.js b/eslint.config.js index b2af1e0..8d15025 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -21,6 +21,15 @@ export default [ } }, { - ignores: ['node_modules/**', 'dist/**', 'coverage/**', 'test/index.html'] + // The bundled, minified artifact is generated by `vite build` and is + // committed for HACS distribution, not hand-written source. Excluding it + // (along with other generated/output dirs) keeps lint meaningful. + ignores: [ + 'node_modules/**', + 'dist/**', + 'coverage/**', + 'test/index.html', + 'home-assistant-flightradar24-card.js' + ] } ]; diff --git a/flightradar24-card-editor.ts b/flightradar24-card-editor.ts index 2b00832..836abe8 100644 --- a/flightradar24-card-editor.ts +++ b/flightradar24-card-editor.ts @@ -1,6 +1,7 @@ -import type { CardConfig, Condition, FieldCondition, GroupCondition, NotCondition, SortCriterion, ToggleConfig, AnnotationConfig, RadarFeature, LocationFeature, RunwayFeature, OutlineFeature, UnitsConfig, RadarConfig, ListConfig } from './types/config'; +import type { CardConfig, Condition, FieldCondition, GroupCondition, NotCondition, SortCriterion, ToggleConfig, AnnotationConfig, RadarFeature, LocationFeature, RunwayFeature, OutlineFeature, UnitsConfig, RadarConfig, ListConfig, AircraftMarkerEntry } from './types/config'; import { searchRunways } from './utils/runwayLookup'; import { templateConfig } from './config/templateConfig'; +import { TILE_PROVIDERS, requiresApiKey, getTileProviderHelp } from './render/tileProviders'; export class Flightradar24CardEditor extends HTMLElement { private _config: CardConfig = {}; @@ -79,8 +80,39 @@ export class Flightradar24CardEditor extends HTMLElement { } private _mapTypeRequiresApiKey(mapType?: string): boolean { - // Stadia Maps tiles (bw and outlines) require an API key - return mapType === 'bw' || mapType === 'outlines'; + // Delegates to the tile provider store so provider quirks live in one place + return requiresApiKey(mapType); + } + + // Build the options (no 'none'/'system') for a per-theme map. + private _themeMapOptionsHtml(selected: string): string { + return this._backgroundMapOptionsHtml(selected); + } + + // Build an API key input row for a per-theme map, shown only when that map needs a key. + private _themeApiKeyRowHtml(idSuffix: string, label: string, mapType: string, apiKey?: string): string { + if (!this._mapTypeRequiresApiKey(mapType)) return ''; + return ` +
+ +
+ + ${getTileProviderHelp(mapType)} +
+
`; } // Validation methods @@ -558,6 +590,21 @@ export class Flightradar24CardEditor extends HTMLElement { margin: 2px 0; line-height: 1.3; } + .input-with-help { + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + } + .input-with-help .full-width { + flex: 1; + min-width: 0; + } + .input-with-help .help-text { + flex: 0 0 auto; + max-width: 60%; + margin: 0; + } .item-box { border: 1px solid var(--divider-color, #ccc); border-radius: 4px; @@ -1121,6 +1168,23 @@ export class Flightradar24CardEditor extends HTMLElement { +
+ + + "Map" displays a square map that fills the available width (great for fullscreen and wide layouts) instead of the circular radar screen. The radar size setting is ignored in map view. +
+ +
+ + Draw the radar grid rings and bearing lines on top of the display. On by default for the circular "Radar" view and off by default for the "Map" view; tick or untick to override. +
+

Range

@@ -1209,7 +1273,7 @@ export class Flightradar24CardEditor extends HTMLElement { Custom Image Marker

Use a PNG image as aircraft marker instead of the default triangle. Image should have a transparent background.

${(() => { - const marker = radar['aircraft-marker']?.default || {}; + const marker: AircraftMarkerEntry = radar['aircraft-marker']?.default || ({} as AircraftMarkerEntry); return `
@@ -1247,24 +1311,39 @@ export class Flightradar24CardEditor extends HTMLElement {
- ${this._mapTypeRequiresApiKey(radar.background_map) ? ` -
- - - Required for Black & White and Outlines map types. Get a free API key -
- ` : ''} + ${ + radar.background_map === 'system' + ? ` +

Pick one map for light themes and one for dark themes. Each map can have its own API key.

+
+ + +
+ ${this._themeApiKeyRowHtml('light', 'Light Map API Key', radar.background_map_light || 'color', radar.background_map_light_api_key)} +
+ + +
+ ${this._themeApiKeyRowHtml('dark', 'Dark Map API Key', radar.background_map_dark || 'dark', radar.background_map_dark_api_key)} + ` + : this._mapTypeRequiresApiKey(radar.background_map) ? ` +
+ +
+ + ${getTileProviderHelp(radar.background_map)} +
+
+ ` : '' + }
+
+ + + Place the flight list to the side of the radar/map on wide cards. If the card is too narrow to fit the flight list side by side, it automatically falls back to the "below" layout. +
{ + const radar = this._config.radar || {}; + this._config = { ...this._config, radar: { ...radar, view: (e.target as HTMLSelectElement).value as 'radar' | 'map' } }; + this._emitConfigChanged(); + this._render(); + }); + } + + // Radar rings / lines + const radarRings = root.getElementById('radar-rings') as HTMLInputElement; + if (radarRings) { + radarRings.addEventListener('change', (e) => { + const radar = this._config.radar || {}; + this._config = { ...this._config, radar: { ...radar, rings: (e.target as HTMLInputElement).checked } }; + this._emitConfigChanged(); + }); + } + // Radar colors (new properties) ['background-color', 'aircraft-color', 'aircraft-selected-color', 'radar-grid-color', 'local-features-color'].forEach(prop => { // Properties already prefixed with 'radar-' don't need the extra prefix @@ -2128,6 +2237,34 @@ export class Flightradar24CardEditor extends HTMLElement { }); } + const bindThemeMapSelect = (id: string, field: 'background_map_light' | 'background_map_dark') => { + const el = root.getElementById(id) as HTMLSelectElement; + if (el) { + el.addEventListener('change', (e) => { + const radar = this._config.radar || {}; + this._config = { ...this._config, radar: { ...radar, [field]: (e.target as HTMLSelectElement).value as any } }; + this._emitConfigChanged(); + this._render(); + }); + } + }; + bindThemeMapSelect('radar-background-map-light', 'background_map_light'); + bindThemeMapSelect('radar-background-map-dark', 'background_map_dark'); + + const bindThemeMapKey = (id: string, field: 'background_map_light_api_key' | 'background_map_dark_api_key') => { + const el = root.getElementById(id) as HTMLInputElement; + if (el) { + el.addEventListener('input', (e) => { + const radar = this._config.radar || {}; + const value = (e.target as HTMLInputElement).value; + this._config = { ...this._config, radar: { ...radar, [field]: value || undefined } }; + this._emitConfigChanged(); + }); + } + }; + bindThemeMapKey('radar-background-map-light-api-key', 'background_map_light_api_key'); + bindThemeMapKey('radar-background-map-dark-api-key', 'background_map_dark_api_key'); + const radarBackgroundMapApiKey = root.getElementById('radar-background-map-api-key') as HTMLInputElement; if (radarBackgroundMapApiKey) { radarBackgroundMapApiKey.addEventListener('input', (e) => { @@ -2173,6 +2310,19 @@ export class Flightradar24CardEditor extends HTMLElement { }); } + const listPosition = root.getElementById('list-position') as HTMLSelectElement; + if (listPosition) { + listPosition.addEventListener('change', (e) => { + const list = this._config.list || {}; + const value = (e.target as HTMLSelectElement).value as 'below' | 'left' | 'right'; + // 'below' is the default, so only persist it when it is not the default + const position = value === 'below' ? undefined : value; + this._config = { ...this._config, list: { ...list, position } }; + this._emitConfigChanged(); + this._render(); + }); + } + const noFlightsMessage = root.getElementById('no-flights-message') as HTMLInputElement; if (noFlightsMessage) { noFlightsMessage.addEventListener('input', (e) => { @@ -3414,14 +3564,3 @@ export class Flightradar24CardEditor extends HTMLElement { } customElements.define('flightradar24-radar-card-editor', Flightradar24CardEditor); - -// Backwards-compatible alias for the original editor element. The Flightradar24 -// integration ships its own card under 'flightradar24-card-editor', so only -// register this alias when that name is not already taken by another card. -if (!customElements.get('flightradar24-card-editor')) { - try { - customElements.define('flightradar24-card-editor', class extends Flightradar24CardEditor {}); - } catch (e) { - console.error('[FR24Card] Could not register flightradar24-card-editor alias:', e); - } -} diff --git a/flightradar24-card-state.ts b/flightradar24-card-state.ts index 2c75a11..5d49ca0 100644 --- a/flightradar24-card-state.ts +++ b/flightradar24-card-state.ts @@ -8,7 +8,8 @@ import type { Hass } from './types/hass'; import type { CardConfig, RadarConfig, ListConfig, UnitsConfig, SortCriterion } from './types/config'; import type { CardState, Dimensions, FlightsContext, DomRefs, MainCard, LeafletMap } from './types/cardState'; -type BackgroundMapType = 'none' | 'system' | 'bw' | 'color' | 'dark' | 'outlines'; +type BackgroundMapType = 'none' | 'system' | 'bw' | 'light' | 'color' | 'dark' | 'voyager' | 'satellite' | 'topo' | 'outlines'; +type ThemeMapType = Exclude; interface Defaults { flights_entity: string; @@ -18,9 +19,14 @@ interface Defaults { units: UnitsConfig; radar: { range: number; + view: 'radar' | 'map'; background_map: BackgroundMapType; background_map_opacity: number; background_map_api_key: string; + background_map_light: ThemeMapType; + background_map_dark: ThemeMapType; + background_map_light_api_key: string; + background_map_dark_api_key: string; }; sort: SortCriterion[]; templates: Record; @@ -31,13 +37,18 @@ const defaults: Defaults = { flights_entity: 'sensor.flightradar24_current_in_area', projection_interval: 5, no_flights_message: 'No flights are currently visible. Please check back later.', - list: { hide: false, showListStatus: true }, + list: { hide: false, showListStatus: true, position: 'below' }, units: unitsConfig, radar: { range: unitsConfig.distance === 'km' ? 35 : 25, + view: 'radar', background_map: 'none', background_map_opacity: 0, - background_map_api_key: '' + background_map_api_key: '', + background_map_light: 'color', + background_map_dark: 'dark', + background_map_light_api_key: '', + background_map_dark_api_key: '' }, sort: sortConfig, templates: templateConfig, @@ -59,6 +70,7 @@ export class Flightradar24CardState implements CardState { selectedFlights: string[]; renderDynamicOnRangeChange: boolean; _leafletMap: LeafletMap | null; + _currentMapConfig?: { type: string; apiKey?: string }; sortFn: (a: Flight, b: Flight) => number; renderDynamicFn?: () => void; dom?: DomRefs; @@ -95,9 +107,14 @@ export class Flightradar24CardState implements CardState { this.units = { ...defaults.units, ...config.units }; this.radar = { range: this.units.distance === 'km' ? defaults.radar.range : 25, + view: config.radar?.view ?? defaults.radar.view, background_map: config.radar?.background_map ?? defaults.radar.background_map, background_map_opacity: config.radar?.background_map_opacity ?? defaults.radar.background_map_opacity, background_map_api_key: config.radar?.background_map_api_key ?? defaults.radar.background_map_api_key, + background_map_light: config.radar?.background_map_light ?? defaults.radar.background_map_light, + background_map_dark: config.radar?.background_map_dark ?? defaults.radar.background_map_dark, + background_map_light_api_key: config.radar?.background_map_light_api_key ?? defaults.radar.background_map_light_api_key, + background_map_dark_api_key: config.radar?.background_map_dark_api_key ?? defaults.radar.background_map_dark_api_key, ...config.radar }; this.radar.initialRange = this.radar.range; diff --git a/flightradar24-card.ts b/flightradar24-card.ts index dfd63f5..f151023 100644 --- a/flightradar24-card.ts +++ b/flightradar24-card.ts @@ -38,6 +38,7 @@ class Flightradar24Card extends HTMLElement implements MainCard { _visibilityChangeHandler: (() => void) | null = null; cardState: Flightradar24CardState; shadowRoot!: ShadowRoot; + _layoutOptions: Record | null = null; constructor() { super(); @@ -90,6 +91,28 @@ class Flightradar24Card extends HTMLElement implements MainCard { }; } + /** + * Modern (sections/masonry) view layout support. + * Home Assistant calls getGridSize() to decide how many grid cells a card + * occupies in the sections view. Without it the card is pinned to 1x1 and + * can never grow into a wider section. + */ + getGridSize(): number { + return 1; + } + + getLayoutOptions(): unknown { + return this._layoutOptions ?? { columns: 1, rows: 1 }; + } + + setLayoutOptions(layout: unknown): void { + this._layoutOptions = layout as Record; + } + + cardSize(): number { + return 2; + } + set hass(hass: Hass) { try { this.cardState.hass = hass; @@ -605,17 +628,6 @@ class Flightradar24Card extends HTMLElement implements MainCard { customElements.define('flightradar24-radar-card', Flightradar24Card); -// Backwards-compatible alias for the original card type. The Flightradar24 -// integration ships its own card under 'flightradar24-card', so only register -// this alias when that name is not already taken by another card. -if (!customElements.get('flightradar24-card')) { - try { - customElements.define('flightradar24-card', class extends Flightradar24Card {}); - } catch (e) { - console.error('[FR24Card] Could not register flightradar24-card alias:', e); - } -} - if (typeof window !== 'undefined') { (window as any).customCards = (window as any).customCards || []; (window as any).customCards.push({ diff --git a/home-assistant-flightradar24-card.js b/home-assistant-flightradar24-card.js index c03cfb1..af3c537 100644 --- a/home-assistant-flightradar24-card.js +++ b/home-assistant-flightradar24-card.js @@ -1,19 +1,65 @@ -(function(){var tt=Object.defineProperty,U=(t,e)=>()=>(t&&(e=t(t=0)),e),gt=(t,e)=>{let a={};for(var i in t)tt(a,i,{get:t[i],enumerable:!0});return e||tt(a,Symbol.toStringTag,{value:"Module"}),a};function mt(t,e){const a=e.querySelector("style[data-fr24-style]");a&&a.remove();const i=t.radar,o=i["background-color"]||i["primary-color"]||"var(--dark-primary-color)",r=i["aircraft-color"]||i["accent-color"]||"var(--accent-color)",n=i["aircraft-selected-color"]||i["aircraft-color"]||i["accent-color"]||"var(--accent-color)",d=i["radar-grid-color"]||i["feature-color"]||"var(--secondary-text-color)",s=i["local-features-color"]||i["feature-color"]||i["radar-grid-color"]||"var(--secondary-text-color)",f=i["callsign-label-color"]||"var(--primary-background-color)",b=i["background-opacity"]!==void 0?Math.max(0,Math.min(1,i["background-opacity"])):.05,_=i.radar_size!==void 0?Math.max(30,Math.min(90,i.radar_size)):70,v=(100-_)/2,y=t.config.scale!==void 0?Math.max(.5,Math.min(3,t.config.scale)):1,C=document.createElement("style");C.setAttribute("data-fr24-style","1"),C.textContent=` +var ft = Object.defineProperty, J = (t, e) => () => (t && (e = t(t = 0)), e), It = (t, e) => { + let a = {}; + for (var i in t) + ft(a, i, { + get: t[i], + enumerable: !0 + }); + return e || ft(a, Symbol.toStringTag, { value: "Module" }), a; +}; +function St(t, e) { + const a = e.querySelector("style[data-fr24-style]"); + a && a.remove(); + const i = t.radar, o = i["background-color"] || i["primary-color"] || "var(--dark-primary-color)", r = i["aircraft-color"] || i["accent-color"] || "var(--accent-color)", n = i["aircraft-selected-color"] || i["aircraft-color"] || i["accent-color"] || "var(--accent-color)", d = i["radar-grid-color"] || i["feature-color"] || "var(--secondary-text-color)", l = i["local-features-color"] || i["feature-color"] || i["radar-grid-color"] || "var(--secondary-text-color)", u = i["callsign-label-color"] || "var(--primary-background-color)", _ = i["background-opacity"] !== void 0 ? Math.max(0, Math.min(1, i["background-opacity"])) : 0.05, m = i.radar_size !== void 0 ? Math.max(30, Math.min(90, i.radar_size)) : 70, g = (100 - m) / 2, y = t.config.scale !== void 0 ? Math.max(0.5, Math.min(3, t.config.scale)) : 1, k = document.createElement("style"); + k.setAttribute("data-fr24-style", "1"), k.textContent = ` :host { --radar-background-color: ${o}; --radar-aircraft-color: ${r}; --radar-aircraft-selected-color: ${n}; --radar-grid-color: ${d}; - --radar-local-features-color: ${s}; - --radar-callsign-label-color: ${f}; + --radar-local-features-color: ${l}; + --radar-callsign-label-color: ${u}; } #flights-card { padding: 16px; transform: scale(${y}); transform-origin: top center; + container-type: inline-size; + } + #layout-root { + display: flex; + flex-direction: column; + width: 100%; + } + /* + Responsive list position: when the list is configured to sit on the + left or right of the radar/map, only keep it side by side when the card + is wide enough. Narrower cards fall back to the stacked "below" layout. + */ + @container (min-width: 560px) { + #layout-root.layout-left, + #layout-root.layout-right { + flex-direction: row; + align-items: stretch; + gap: 16px; + } + #layout-root.layout-left #radar-container, + #layout-root.layout-right #radar-container { + flex: 1 1 60%; + min-width: 0; + } + #layout-root.layout-left #flights, + #layout-root.layout-right #flights { + flex: 1 1 40%; + min-width: 0; + } + #layout-root.layout-left #flights { + order: -1; + } } #flights { padding: 0px; + min-width: 0; } #flights .flight { margin-top: 16px; @@ -57,16 +103,18 @@ #radar-container { display: flex; justify-content: space-between; + position: relative; + min-width: 0; } #radar-overlay { position: absolute; - width: ${_}%; - left: ${v}%; - padding: 0 0 ${_}% 0; + width: ${m}%; + left: ${g}%; + padding: 0 0 ${m}% 0; margin-bottom: 5%; z-index: 1; opacity: 0; - pointer-events: auto; + pointer-events: none; border-radius: 50%; overflow: hidden; } @@ -86,6 +134,7 @@ font-size: 0.9em; padding: 0; margin: 0 15px; + z-index: 10; } .toggle { display: flex; @@ -98,14 +147,21 @@ } #radar { position: relative; - width: ${_}%; + width: ${m}%; height: 0; - margin: 0 ${v}%; - padding-bottom: ${_}%; + margin: 0 ${g}%; + padding-bottom: ${m}%; margin-bottom: 5%; border-radius: 50%; overflow: hidden; } + /* Square map view: full-bleed square map instead of the circular radar screen */ + #layout-root.view-map #radar { + width: 100%; + margin: 0; + padding-bottom: 100%; + border-radius: 0; + } #radar-screen { position: absolute; width: 100%; @@ -120,7 +176,7 @@ margin: 0; padding: 0%; background-color: var(--radar-background-color); - opacity: ${b}; + opacity: ${_}; } #tracker { position: absolute; @@ -156,6 +212,10 @@ width: 8px; height: 16px; } + .plane.plane-custom { + width: 12px; + height: 14px; + } .plane .arrow { position: absolute; width: 0; @@ -184,6 +244,19 @@ .plane.selected .arrow { border-bottom-color: var(--radar-aircraft-selected-color); } + .custom-marker { + position: absolute; + top: 50%; + left: 50%; + translate: -50% -50%; + } + .custom-marker img, + .custom-marker canvas { + display: block; + width: 12px; + height: auto; + } + .callsign-label { position: absolute; background-color: var(--radar-callsign-label-color); @@ -240,9 +313,1411 @@ background-color: var(--radar-local-features-color); opacity: 0.35; } - `,e.appendChild(C)}function _t(t,e){if(!e)return;e.innerHTML="";const a=t.config.toggles||{},i=!!window.customElements&&!!customElements.get("ha-switch");Object.keys(a).forEach(o=>{const r=a[o],n=document.createElement("div");n.className="toggle";const d=document.createElement("label");d.textContent=r.label||o,n.appendChild(d);let s;i?s=document.createElement("ha-switch"):(s=document.createElement("input"),s.type="checkbox"),s.checked=r.default===!0,s.addEventListener("change",()=>{t.setToggleValue&&t.setToggleValue(o,s.checked)}),n.appendChild(s),e.appendChild(n)})}function O(t){return t*(Math.PI/180)}function W(t){return t*(180/Math.PI)}function z(t,e,a,i,o="km"){const n=O(a-t),d=O(i-e),s=Math.sin(n/2)*Math.sin(n/2)+Math.cos(O(t))*Math.cos(O(a))*Math.sin(d/2)*Math.sin(d/2),f=2*Math.atan2(Math.sqrt(s),Math.sqrt(1-s));return o==="km"?6371*f:6371*f/1.60934}function q(t,e,a,i){const o=O(i-e),r=Math.sin(o)*Math.cos(O(a)),n=Math.cos(O(t))*Math.sin(O(a))-Math.sin(O(t))*Math.cos(O(a))*Math.cos(o);return(W(Math.atan2(r,n))+360)%360}function G(t,e,a,i){const r=O(a),n=O(t),d=O(e),s=i/6371,f=Math.asin(Math.sin(n)*Math.cos(s)+Math.cos(n)*Math.sin(s)*Math.cos(r)),b=d+Math.atan2(Math.sin(r)*Math.sin(s)*Math.cos(n),Math.cos(s)-Math.sin(n)*Math.sin(f));return{lat:W(f),lon:W(b)}}function vt(t,e,a,i,o){const r=q(a,i,t,e),n=Math.abs((o-r+360)%360);return G(a,i,o,z(t,e,a,i)*Math.cos(O(n)))}function bt(t){return["N","NE","E","SE","S","SW","W","NW"][Math.round(t/45)%8]}function et(t,e,a=60){const i=Math.abs((t-e+360)%360);return i<=a||i>=360-a}function K(t){if(!t||!t.config)return console.error("Config not set in getLocation"),{latitude:0,longitude:0};const{config:e,hass:a}=t;if(e.location_tracker&&a&&a.states&&e.location_tracker in a.states){const i=a.states[e.location_tracker].attributes;return{latitude:i.latitude,longitude:i.longitude}}else{if(e.location)return{latitude:e.location.lat,longitude:e.location.lon};if(a&&a.config)return{latitude:a.config.latitude,longitude:a.config.longitude}}return{latitude:0,longitude:0}}var yt=new Set(["bw","light","color","dark","voyager","satellite","topo","outlines","system"]);function at(t){const e=t?.radar;return!(!e||e.hide===!0||!e.background_map||!yt.has(e.background_map))}function xt(t,e,a){if(at(t)){if(window.L){a();return}if(!e.querySelector("#leaflet-css-loader")){const i=document.createElement("link");i.id="leaflet-css-loader",i.rel="stylesheet",i.href="https://unpkg.com/leaflet/dist/leaflet.css",e.appendChild(i)}if(e.querySelector("#leaflet-js-loader")){const i=setInterval(()=>{window.L&&(clearInterval(i),a())},50)}else{const i=document.createElement("script");i.id="leaflet-js-loader",i.src="https://unpkg.com/leaflet/dist/leaflet.js",i.async=!0,i.defer=!0,i.onload=a,i.onerror=()=>i.remove(),e.appendChild(i)}}}function wt(t,e){const{config:a,dimensions:i}=t;if(!at(t)){t._leafletMap&&(t._leafletMap.remove(),t._leafletMap=null);const w=e.querySelector("#radar-map-bg");w&&w.remove();return}const o=a?.radar?.background_map,r={bw:["https://tiles.stadiamaps.com/tiles/stamen_toner/{z}/{x}/{y}.png",{api_key:"?api_key=",attribution:"Map tiles by Stamen Design, CC BY 3.0 — Map data © OpenStreetMap",subdomains:[]}],light:["https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png",{attribution:"© CartoDB, © OpenStreetMap contributors",subdomains:["a","b","c","d"]}],color:["https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",{attribution:"© OpenStreetMap contributors",subdomains:["a","b","c"]}],dark:["https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png",{attribution:"© CartoDB, © OpenStreetMap contributors",subdomains:["a","b","c","d"]}],voyager:["https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}.png",{attribution:"© CartoDB, © OpenStreetMap contributors",subdomains:["a","b","c","d"]}],satellite:["https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",{attribution:"© Esri, Maxar, Earthstar Geographics",subdomains:[]}],topo:["https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png",{attribution:"© OpenTopoMap, © OpenStreetMap contributors",subdomains:["a","b","c"]}],outlines:["https://tiles.stadiamaps.com/tiles/stamen_toner_lines/{z}/{x}/{y}.png",{api_key:"?api_key=",attribution:"Map tiles by Stamen Design, hosted by Stadia Maps; Data by OpenStreetMap",subdomains:[]}],system:null},n=typeof a?.radar?.background_map_opacity=="number"?Math.max(0,Math.min(1,a.radar.background_map_opacity)):1;let d=e.querySelector("#radar-map-bg");d?d.style.opacity=String(n):(d=document.createElement("div"),d.id="radar-map-bg",d.style.position="absolute",d.style.top="0",d.style.left="0",d.style.width="100%",d.style.height="100%",d.style.zIndex="0",d.style.pointerEvents="none",d.style.opacity=String(n),e.appendChild(d)),d.style.transform="",t._leafletMap&&t._leafletMap.getContainer()!==d&&(t._leafletMap.remove(),t._leafletMap=null);const s=K(t),f=Math.max(i?.range||1,1),b=t.units?.distance==="miles"?f*1.60934:f,_=s?.latitude||0,v=s?.longitude||0,y=Math.PI/180,C=111.13209-.56605*Math.cos(2*_*y)+.0012*Math.cos(4*_*y),M=111.32*Math.cos(_*y)-.094*Math.cos(3*_*y),u=b/C,h=b/M,c=[[_-u,v-h],[_+u,v+h]];let g=o;if(o==="system"){const w=window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").matches;let $=!1;try{$=!!(window.parent&&window.parent.document&&window.parent.document.body.classList.contains("dark"))}catch{}$||w?g="dark":g="color"}const m=r[g||"bw"]||r.bw;if(!m)return d;let[l,p]=m;const x=p&&"api_key"in p,k=a?.radar?.background_map_api_key&&a.radar.background_map_api_key.trim().length>0;if(x&&!k)return t._leafletMap&&(t._leafletMap.remove(),t._leafletMap=null),d.innerHTML='
API key required for this map type. Configure in Background Map settings.
',d;if(t._leafletMap||(d.innerHTML=""),x&&k&&a?.radar?.background_map_api_key&&(l=l+p.api_key+encodeURIComponent(a.radar.background_map_api_key)),window.L){const w={type:g||"bw",apiKey:a?.radar?.background_map_api_key},$=!t._currentMapConfig||t._currentMapConfig.type!==w.type||t._currentMapConfig.apiKey!==w.apiKey;t._leafletMap?$&&(t._leafletMap.eachLayer(A=>{t._leafletMap.removeLayer(A)}),window.L.tileLayer(l,p).addTo(t._leafletMap),t._currentMapConfig=w):(t._leafletMap=window.L.map(d,{attributionControl:!1,zoomControl:!1,dragging:!1,scrollWheelZoom:!1,boxZoom:!1,doubleClickZoom:!1,keyboard:!1,touchZoom:!1,pointerEvents:!1}),window.L.tileLayer(l,p).addTo(t._leafletMap),t._currentMapConfig=w),t._leafletMap.fitBounds(c,{animate:!1,padding:[0,0]}),requestAnimationFrame(()=>{if(!t._leafletMap)return;const A=t._leafletMap.getContainer(),L=A.offsetHeight,S=A.offsetWidth;if(L===0||S===0)return;const F=window.L.point(0,L/2),E=window.L.point(S,L/2),R=t._leafletMap.containerPointToLatLng(F),P=t._leafletMap.containerPointToLatLng(E),T=z(R.lat,R.lng,P.lat,P.lng,"km")/(b*2);Math.abs(T-1)>.01?d.style.transform=`scale(${T})`:d.style.transform=""})}return d}function it(t={},e,a=[]){if(a.includes(e))return console.error("Circular template dependencies detected. "+a.join(" -> ")+" -> "+e),"";if(t["compiled_"+e])return t["compiled_"+e];let i=t[e];if(i===void 0)return console.error("Missing template reference: "+e),"";const o=/tpl\.([a-zA-Z_$][a-zA-Z0-9_$]*)/g;let r;const n={};for(;(r=o.exec(i))!==null;){const d=r[1];n[d]||(n[d]=it(t,d,[...a,e])),i=i.replace(`tpl.${d}`,"(`"+n[d]+'`).replace(/^undefined$/, "")')}return t["compiled_"+e]=i,i}function Y(t,e,a,i){const o=t.templates||{},r=t.flightsContext||{},n=t.units||{distance:"km",altitude:"ft",speed:"kts"},d=t.radar||{range:35},s=it(o,e);try{const f=new Function("flights","flight","tpl","units","radar_range","joinList",`return \`${s.replace(/\${(.*?)}/g,(b,_)=>`\${${_}}`)}\``)(r,a,{},n,Math.round(d.range),i);return f!=="undefined"?f:""}catch(f){return console.error("Error when rendering: "+s,f),""}}function J(t,e,a,i){const{defines:o={},config:r={},radar:n={range:35},selectedFlights:d=[]}=t;if(typeof e=="string"&&e.startsWith("${")&&e.endsWith("}")){const s=e.slice(2,-1);if(s==="selectedFlights")return d;if(s==="radar_range")return i&&i(!0),n.range;if(s in o)return o[s];if(r.toggles&&s in r.toggles)return r.toggles[s].default;if(a!==void 0)return a;console.error("Unresolved placeholder: "+s),console.debug("Defines",o)}return e}function X(t){const{units:e,radar:a,dom:i,dimensions:o,hass:r}=t,n=i?.radarInfoDisplay||i&&i.radarContainer?.querySelector("#radar-info");n&&(n.innerHTML=[a?.hide_range!==!0?Y(t,"radar_range",null,void 0):""].filter(m=>m).join("
"));const d=i?.radarScreen||i&&i.radarContainer?.querySelector("#radar-screen")||t.mainCard?.shadowRoot&&t.mainCard.shadowRoot.getElementById("radar-screen");if(!d)return;Array.from(d.childNodes).forEach(m=>{const l=m;l.id!=="radar-map-bg"&&l.id!=="radar-screen-background"&&d.removeChild(m)});let s=d.querySelector("#radar-screen-background");s||(s=document.createElement("div"),s.id="radar-screen-background",d.appendChild(s)),wt(t,d);const{width:f,height:b,range:_,scaleFactor:v,centerX:y,centerY:C}=o||{};if(!f||!b||!_||!v||y==null||C==null)return;const M=_*1.15,u=a?.ring_distance??10,h=Math.floor(_/u);for(let m=1;m<=h;m++){const l=m*u*v,p=document.createElement("div");p.className="ring",p.style.width=p.style.height=l*2+"px",p.style.top=Math.floor(C-l)+"px",p.style.left=Math.floor(y-l)+"px",d.appendChild(p)}for(let m=0;m<360;m+=45){const l=document.createElement("div");l.className="dotted-line",l.style.transform=`rotate(${m-90}deg)`,d.appendChild(l)}const c=K(t),g=a?.local_features;if(g&&r&&c){const m=c.latitude,l=c.longitude;g.forEach(p=>{if(!(p.max_range&&a.range&&p.max_range<=a.range)){if(p.type==="outline"&&p.points&&p.points.length>1)for(let x=0;xv&&(t.radar.range=v),t.mainCard.updateRadarRange(b*2)}function n(f){f.touches.length===2&&(a=o(f.touches),i=t.radar.range)}function d(f){if(f.touches.length===2&&a!==null&&i!==null){f.preventDefault();const b=o(f.touches),_=a/b,v=t.radar.min_range||1,y=t.radar.max_range||Math.max(100,t.radar.initialRange||35);let C=Math.round(i*_);Cy&&(C=y),t.radar.range=C,t.mainCard.updateRadarRange(0)}}function s(){a!==null&&(a=null,i=null,t.config.updateRangeFilterOnTouchEnd&&t.renderDynamicOnRangeChange&&t.mainCard.renderDynamic())}return e&&(e.addEventListener("wheel",r,{passive:!1}),e.addEventListener("touchstart",n,{passive:!0}),e.addEventListener("touchmove",d,{passive:!1}),e.addEventListener("touchend",s,{passive:!0})),()=>{e&&(e.removeEventListener("wheel",r),e.removeEventListener("touchstart",n),e.removeEventListener("touchmove",d),e.removeEventListener("touchend",s))}}function Ct(t,e){e.shadowRoot.innerHTML="";const a=document.createElement("ha-card");if(a.id="flights-card",!t.radar?.hide){const o=document.createElement("div");o.id="radar-container";const r=document.createElement("div");r.id="radar-overlay",o.appendChild(r);const n=document.createElement("div");n.id="radar-info",o.appendChild(n);const d=document.createElement("div");d.id="toggle-container",o.appendChild(d);const s=document.createElement("div");s.id="radar";const f=document.createElement("div");f.id="radar-screen",s.appendChild(f);const b=document.createElement("div");b.id="tracker",s.appendChild(b);const _=document.createElement("div");_.id="planes",s.appendChild(_),o.appendChild(s),a.appendChild(o),requestAnimationFrame(()=>{X(t),e.observeRadarResize(),nt(t,r)}),t.dom=t.dom||{},t.dom.toggleContainer=d,t.dom.planesContainer=_,t.dom.radar=s,t.dom.radarScreen=f,t.dom.radarInfoDisplay=n,t.dom.shadowRoot=e.shadowRoot,t.mainCard=e}const i=document.createElement("div");i.id="flights",t.list&&t.list.hide===!0&&(i.style.display="none"),a.appendChild(i),e.shadowRoot.appendChild(a),mt(t,e.shadowRoot),t.dom?.toggleContainer&&_t(t,t.dom.toggleContainer)}function ot(t,e){return(t.flights||[]).filter(a=>rt(t,a,e))}function rt(t,e,a){return Array.isArray(a)?a.every(i=>B(t,e,i)):B(t,e,a)}function B(t,e,a){let i=!0;if(a.type==="AND"&&a.conditions)i=a.conditions.every(o=>B(t,e,o));else if(a.type==="OR"&&a.conditions)i=a.conditions.some(o=>B(t,e,o));else if(a.type==="NOT"&&a.condition)i=!B(t,e,a.condition);else{const{field:o,defined:r,defaultValue:n,comparator:d}=a,s=J(t,a.value),f=o?e[o]:r?J(t,"${"+r+"}",n):void 0;switch(d){case"eq":i=f===s;break;case"lt":i=Number(f)Number(s);break;case"gte":i=Number(f)>=Number(s);break;case"oneOf":i=(Array.isArray(s)?s:typeof s=="string"?s.split(",").map(b=>b.trim()):[]).includes(f);break;case"containsOneOf":{const b=Array.isArray(s)?s:typeof s=="string"?s.split(",").map(_=>_.trim()):[];i=!!f&&b.some(_=>f.includes(_));break}default:i=!1}}return a.debugIf===i&&console.debug("applyCondition",a,e,i),i}function Z(t){const{flights:e,radar:a,selectedFlights:i,dimensions:o,dom:r}=t;let n;a&&a.filter===!0?n=t.flightsFiltered||e:a&&a.filter&&typeof a.filter=="object"?n=ot(t,a.filter):n=e;const d=r?.planesContainer||t.mainCard?.shadowRoot&&t.mainCard.shadowRoot.getElementById("planes");if(!d)return;d.innerHTML="";const{range:s,scaleFactor:f,centerX:b,centerY:_}=o;if(!s||!f||b===void 0||_===void 0)return;const v=s*1.15;n.slice().reverse().forEach(y=>{const C=y.distance_to_tracker;if(C!==void 0&&C<=v){const M=document.createElement("div");M.className="plane";const u=y.heading_from_tracker??0,h=b+Math.cos((u-90)*Math.PI/180)*C*f,c=_+Math.sin((u-90)*Math.PI/180)*C*f;M.style.top=c+"px",M.style.left=h+"px";const g=document.createElement("div");g.className="arrow",g.style.transform=`rotate(${y.heading}deg)`,M.appendChild(g);const m=document.createElement("div");m.className="callsign-label",m.textContent=y.callsign??y.aircraft_registration??"n/a",d.appendChild(m);const l=m.getBoundingClientRect(),p=l.width+3,x=l.height+6;m.style.top=c-x+"px",m.style.left=h-p+"px",(y.altitude??0)<=0?M.classList.add("plane-small"):M.classList.add("plane-medium");const k=a["aircraft-marker-size"];k&&k!=="normal"&&M.classList.add(`marker-size-${k}`),i&&i.includes(y.id)&&M.classList.add("selected"),M.addEventListener("click",()=>t.toggleSelectedFlight(y)),m.addEventListener("click",()=>t.toggleSelectedFlight(y)),d.appendChild(M)}})}function st(t,e){const a=document.createElement("img");return a.setAttribute("src",`https://flagsapi.com/${t}/shiny/16.png`),a.setAttribute("title",`${e}`),a.style.position="relative",a.style.top="3px",a.style.left="2px",a}function kt(t,e,a){try{let i=e[a];if(t.config.annotate){const o=Object.assign({},e);t.config.annotate.filter(r=>r.field===a).forEach(r=>{rt(t,e,r.conditions)&&(o[a]=r.render.replace(/\$\{([^}]*)\}/g,(n,d)=>String(o[d]||"")))}),i=String(o[a]||"")}return i}catch(i){return console.error(`[FR24Card] flightField error for field '${a}':`,i),""}}function $t(t,e){try{const a=Object.assign({},e);["flight_number","callsign","aircraft_registration","aircraft_model","aircraft_code","airline","airline_short","airline_iata","airline_icao","airport_origin_name","airport_origin_code_iata","airport_origin_code_icao","airport_origin_country_name","airport_origin_country_code","airport_destination_name","airport_destination_code_iata","airport_destination_code_icao","airport_destination_country_name","airport_destination_country_code"].forEach(o=>{a[o]=kt(t,a,o)}),a.origin_flag=a.airport_origin_country_code?st(a.airport_origin_country_code,a.airport_origin_country_name||"").outerHTML:"",a.destination_flag=a.airport_destination_country_code?st(a.airport_destination_country_code,a.airport_destination_country_name||"").outerHTML:"",a.climb_descend_indicator=Math.abs(a.vertical_speed)>100?a.vertical_speed>100?"↑":"↓":"",a.alt_in_unit=a.altitude>=17750?`FL${Math.round(a.altitude/1e3)*10}`:a.altitude>0?t.units.altitude==="m"?`${Math.round(a.altitude*.3048)} m`:`${Math.round(a.altitude)} ft`:void 0,a.spd_in_unit=a.ground_speed>0?t.units.speed==="kmh"?`${Math.round(a.ground_speed*1.852)} km/h`:t.units.speed==="mph"?`${Math.round(a.ground_speed*1.15078)} mph`:`${Math.round(a.ground_speed)} kts`:void 0,a.approach_indicator=a.ground_speed>70?a.is_approaching?"↓":a.is_receding?"↑":"":"",a.dist_in_unit=`${Math.round(a.distance_to_tracker||0)} ${t.units.distance}`,a.direction_info=`${Math.round(a.heading_from_tracker||0)}° ${a.cardinal_direction_from_tracker||""}`;const i=document.createElement("div");return i.style.clear="both",i.className="flight",t.selectedFlights&&t.selectedFlights.includes(a.id)&&(i.className+=" selected"),i.innerHTML=Y(t,"flight_element",a,o=>(...r)=>r?.filter(n=>n).join(o||" ")),i.addEventListener("click",()=>t.toggleSelectedFlight(a)),i}catch(a){console.error("[FR24Card] renderFlight error:",a);const i=document.createElement("div");return i.className="flight error",i.textContent=`Error rendering flight: ${a}`,i}}var lt={altitude:"ft",speed:"kts",distance:"km"},Mt=[{field:"id",comparator:"oneOf",value:"${selectedFlights}",order:"DESC"},{field:"altitude",comparator:"eq",value:0,order:"ASC"},{field:"closest_passing_distance ?? distance_to_tracker",order:"ASC"}],V,dt=U((()=>{V={img_element:'${flight.aircraft_photo_small ? `` : ""}',icon:'${flight.altitude > 0 ? (flight.vertical_speed > 100 ? "airplane-takeoff" : flight.vertical_speed < -100 ? "airplane-landing" : "airplane") : "airport"}',icon_element:'',flight_info:'${joinList(" - ")(flight.airline_short, flight.flight_number, flight.callsign !== flight.flight_number ? flight.callsign : "")}',flight_info_element:'
${tpl.flight_info}
',header:"
${tpl.img_element}${tpl.icon_element}${tpl.flight_info_element}
",aircraft_info:'${joinList(" - ")(flight.aircraft_registration, flight.aircraft_model)}',aircraft_info_element:'${tpl.aircraft_info ? `
${tpl.aircraft_info}
` : ""}',departure_info:'${flight.altitude === 0 && flight.time_scheduled_departure ? ` (${new Date(flight.time_scheduled_departure * 1000).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })})` : ""}',origin_info:'${joinList("")(flight.airport_origin_code_iata, tpl.departure_info, flight.origin_flag)}',arrival_info:"",destination_info:'${joinList("")(flight.airport_destination_code_iata, tpl.arrival_info, flight.destination_flag)}',route_info:'${joinList(" -> ")(tpl.origin_info, tpl.destination_info)}',route_element:"
${tpl.route_info}
",alt_info:'${flight.alt_in_unit ? "Alt: " + flight.alt_in_unit + flight.climb_descend_indicator : undefined}',spd_info:'${flight.spd_in_unit ? "Spd: " + flight.spd_in_unit : undefined}',hdg_info:'${flight.heading ? "Hdg: " + flight.heading + "°" : undefined}',dist_info:'${flight.dist_in_unit ? "Dist: " + flight.dist_in_unit + flight.approach_indicator : undefined}',flight_status:'
${joinList(" - ")(tpl.alt_info, tpl.spd_info, tpl.hdg_info)}
',position_status:'
${joinList(" - ")(tpl.dist_info, flight.direction_info)}
',proximity_info:'
${flight.is_approaching && flight.ground_speed > 70 && flight.closest_passing_distance < 15 ? `Closest Distance: ${flight.closest_passing_distance} ${units.distance}, ETA: ${flight.eta_to_closest_distance} min` : ""}
',flight_element:"${tpl.header}${tpl.aircraft_info_element}${tpl.route_element}${tpl.flight_status}${tpl.position_status}${tpl.proximity_info}",radar_range:"Range: ${radar_range} ${units.distance}",list_status:"${flights.shown}/${flights.total}"}}));dt();function ct(t,e){return e.split(" ?? ").reduce((a,i)=>a??t[i],void 0)}function At(t,e=a=>a){return function(a,i){for(const o of t){const{field:r,comparator:n,order:d="ASC"}=o,s=e(o.value),f=ct(a,r),b=ct(i,r);let _=0;switch(n){case"eq":f===s&&b!==s?_=1:f!==s&&b===s&&(_=-1);break;case"lt":f=s?_=1:f>=s&&bs?_=1:f>s&&b<=s&&(_=-1);break;case"gt":f>s&&b<=s?_=1:f<=s&&b>s&&(_=-1);break;case"gte":f>=s&&b=s&&(_=-1);break;case"oneOf":if(s!=null&&(Array.isArray(s)||typeof s=="string")){const v=s.includes(f),y=s.includes(b);v&&!y?_=1:!v&&y&&(_=-1)}break;case"containsOneOf":if(Array.isArray(s)&&s.length>0){const v=s.some(C=>(Array.isArray(f)||typeof f=="string")&&f.includes(C)),y=s.some(C=>(Array.isArray(b)||typeof b=="string")&&b.includes(C));v&&!y?_=1:!v&&y&&(_=-1)}break;default:_=f-b;break}if(_!==0)return d.toUpperCase()==="DESC"?-_:_}return 0}}var D={flights_entity:"sensor.flightradar24_current_in_area",projection_interval:5,no_flights_message:"No flights are currently visible. Please check back later.",list:{hide:!1,showListStatus:!0},units:lt,radar:{range:lt.distance==="km"?35:25,background_map:"none",background_map_opacity:0,background_map_api_key:""},sort:Mt,templates:V,defines:{}},pt=class{constructor(){this.hass=null,this.config={},this.radar={range:35},this.list={},this.templates={},this.defines={},this.units={altitude:"ft",speed:"kts",distance:"km"},this.flightsContext={},this.dimensions={},this.flights=[],this.selectedFlights=[],this.renderDynamicOnRangeChange=!1,this._leafletMap=null,this.sortFn=()=>0}setConfig(t){if(!t)throw new Error("Configuration is missing.");this.config={...t},this.config.flights_entity=t.flights_entity??D.flights_entity,this.config.projection_interval=t.projection_interval??D.projection_interval,this.config.no_flights_message=t.no_flights_message??D.no_flights_message,this.list={...D.list,...t.list},this.units={...D.units,...t.units},this.radar={range:this.units.distance==="km"?D.radar.range:25,background_map:t.radar?.background_map??D.radar.background_map,background_map_opacity:t.radar?.background_map_opacity??D.radar.background_map_opacity,background_map_api_key:t.radar?.background_map_api_key??D.radar.background_map_api_key,...t.radar},this.radar.initialRange=this.radar.range,this.defines={...D.defines,...t.defines},this.sortFn=At(t.sort??D.sort,e=>J(this,e,void 0,a=>{this.renderDynamicOnRangeChange=a})),this.templates={...D.templates,...t.templates}}toggleSelectedFlight(t){this.selectedFlights||(this.selectedFlights=[]),this.selectedFlights.includes(t.id)?this.selectedFlights=this.selectedFlights.filter(e=>e!==t.id):this.selectedFlights.push(t.id),typeof this.renderDynamicFn=="function"&&this.renderDynamicFn()}setRenderDynamic(t){this.renderDynamicFn=t}setToggleValue(t,e){this.config&&this.config.toggles&&(this.defines[t]=["true",!0,1].includes(e),typeof this.renderDynamicFn=="function"&&this.renderDynamicFn())}};async function Lt(){if(N)return N;try{const e=await fetch("/local/flightradar24-card/runways.csv");if(e.ok)return N=await e.text(),N}catch{}try{const e=await fetch("data/runways.csv");if(e.ok)return N=await e.text(),N}catch{}const t=await fetch("https://davidmegginson.github.io/ourairports-data/runways.csv");if(!t.ok)throw new Error(`Failed to fetch runway data: ${t.status}`);return N=await t.text(),N}async function Et(){if(j)return j;try{const e=await fetch("/local/flightradar24-card/airports.csv");if(e.ok)return j=await e.text(),j}catch{}try{const e=await fetch("data/airports.csv");if(e.ok)return j=await e.text(),j}catch{}const t=await fetch("https://davidmegginson.github.io/ourairports-data/airports.csv");if(!t.ok)throw new Error(`Failed to fetch airport data: ${t.status}`);return j=await t.text(),j}function H(t){const e=[];let a="",i=!1;for(let o=0;ok.score-x.score).slice(0,10).map(({score:x,...k})=>k)}var N,j,St=U((()=>{N=null,j=null})),Tt=gt({Flightradar24CardEditor:()=>Q}),Q,ut=U((()=>{St(),dt(),Q=class extends HTMLElement{constructor(){super(),this._config={},this._openSections=new Set(["basic-settings"]),this._openConditions=new Set,this._openFeatures=new Set,this._openAnnotations=new Set,this._mapModal=null,this._internalUpdate=!1,this._shadowRoot=this.attachShadow({mode:"open"})}setConfig(t){this._config={...t},this._internalUpdate||this._render(),this._internalUpdate=!1}get availableFlightEntities(){return this.hass?Object.keys(this.hass.states).filter(t=>t.includes("flightradar")).sort():[]}get availableTrackerEntities(){return this.hass?Object.keys(this.hass.states).filter(t=>t.startsWith("device_tracker.")||t.startsWith("person.")||t.startsWith("zone.")).sort():[]}get availableFlightFields(){return[{value:"id",label:"ID",group:"Basic"},{value:"flight_number",label:"Flight Number",group:"Basic"},{value:"callsign",label:"Callsign",group:"Basic"},{value:"aircraft_registration",label:"Aircraft Registration",group:"Aircraft"},{value:"aircraft_model",label:"Aircraft Model",group:"Aircraft"},{value:"aircraft_code",label:"Aircraft Code",group:"Aircraft"},{value:"airline",label:"Airline Name",group:"Airline"},{value:"airline_short",label:"Airline Short",group:"Airline"},{value:"airline_iata",label:"Airline IATA",group:"Airline"},{value:"airline_icao",label:"Airline ICAO",group:"Airline"},{value:"airport_origin_name",label:"Origin Airport",group:"Origin"},{value:"airport_origin_code_iata",label:"Origin IATA",group:"Origin"},{value:"airport_origin_country_name",label:"Origin Country",group:"Origin"},{value:"airport_origin_country_code",label:"Origin Country Code",group:"Origin"},{value:"airport_destination_name",label:"Destination Airport",group:"Destination"},{value:"airport_destination_code_iata",label:"Destination IATA",group:"Destination"},{value:"airport_destination_country_name",label:"Destination Country",group:"Destination"},{value:"airport_destination_country_code",label:"Destination Country Code",group:"Destination"},{value:"latitude",label:"Latitude",group:"Position"},{value:"longitude",label:"Longitude",group:"Position"},{value:"altitude",label:"Altitude",group:"Position"},{value:"vertical_speed",label:"Vertical Speed",group:"Movement"},{value:"ground_speed",label:"Ground Speed",group:"Movement"},{value:"heading",label:"Heading",group:"Movement"},{value:"distance_to_tracker",label:"Distance to Tracker",group:"Tracking"},{value:"heading_from_tracker",label:"Heading from Tracker",group:"Tracking"},{value:"cardinal_direction_from_tracker",label:"Cardinal Direction",group:"Tracking"},{value:"is_approaching",label:"Is Approaching",group:"Tracking"},{value:"is_receding",label:"Is Receding",group:"Tracking"},{value:"closest_passing_distance",label:"Closest Passing Distance",group:"Approach"},{value:"eta_to_closest_distance",label:"ETA to Closest",group:"Approach"},{value:"heading_from_tracker_to_closest_passing",label:"Heading to Closest",group:"Approach"}]}_mapTypeRequiresApiKey(t){return t==="bw"||t==="outlines"}get validFlightFields(){return new Set(this.availableFlightFields.map(t=>t.value))}get allDefineAndToggleKeys(){const t=new Set;return Object.keys(this._config.toggles||{}).forEach(e=>t.add(e)),Object.keys(this._config.defines||{}).forEach(e=>t.add(e)),t}getUsedDefinesAndToggles(){const t=new Set,e=this._config.templates||{},a=this._config.filter,i=this._config.sort||[];Object.values(e).forEach(r=>{const n=r.matchAll(/\$\{(\w+)\}/g);for(const d of n){const s=d[1];this.allDefineAndToggleKeys.has(s)&&t.add(s)}});const o=r=>{r.forEach(n=>{if("type"in n&&(n.type==="AND"||n.type==="OR"))o(n.conditions||[]);else if("type"in n&&n.type==="NOT")o([n.condition]);else{const d=n;d.field&&this.allDefineAndToggleKeys.has(d.field)&&t.add(d.field);const s=d.value;if(typeof s=="string"&&s.startsWith("${")&&s.endsWith("}")){const f=s.slice(2,-1);this.allDefineAndToggleKeys.has(f)&&t.add(f)}}})};return a&&Array.isArray(a)&&o(a),i.forEach(r=>{r.field&&this.allDefineAndToggleKeys.has(r.field)&&t.add(r.field)}),t}getUnusedDefinesAndToggles(){const t=this.getUsedDefinesAndToggles(),e=[],a=[];return Object.keys(this._config.toggles||{}).forEach(i=>{t.has(i)||e.push(i)}),Object.keys(this._config.defines||{}).forEach(i=>{t.has(i)||a.push(i)}),{toggles:e,defines:a}}getUsedTemplateKeys(){const t=new Set,e=this._config.templates||{};return["flight_element","radar_range","list_status"].forEach(a=>{e[a]!==void 0&&t.add(a)}),Object.values(e).forEach(a=>{const i=a.matchAll(/\$\{(\w+)\([\s\S]*?\)\}/g);for(const o of i){const r=o[1];e[r]!==void 0&&t.add(r)}}),t}getUnusedTemplates(){const t=this.getUsedTemplateKeys(),e=this._config.templates||{},a=[];return Object.keys(e).forEach(i=>{t.has(i)||a.push(i)}),a}validateConditionField(t){return this.validFlightFields.has(t)?{valid:!0}:this.allDefineAndToggleKeys.has(t)?{valid:!0}:{valid:!1,error:`Unknown field: "${t}". Not a flight property or define/toggle.`}}hasValidationErrors(){const t=this.getUnusedDefinesAndToggles();if(t.toggles.length>0||t.defines.length>0||this.getUnusedTemplates().length>0)return!0;const e=this._config.filter;if(e&&Array.isArray(e)&&this._checkConditionsForInvalidFields(e))return!0;const a=this._config.sort||[];for(const i of a)if(i.field&&!this.validateConditionField(i.field).valid)return!0;return!1}_checkConditionsForInvalidFields(t){for(const e of t)if("type"in e&&(e.type==="AND"||e.type==="OR")){if(this._checkConditionsForInvalidFields(e.conditions||[]))return!0}else if("type"in e&&e.type==="NOT"){if(this._checkConditionsForInvalidFields([e.condition]))return!0}else{const a=e;if(a.field&&!this.validateConditionField(a.field).valid)return!0}return!1}_render(){this.hass&&(this._saveOpenSections(),this._shadowRoot.innerHTML=` + /* Inline critical Leaflet pane CSS — survives Shadow DOM clears and CSP */ + .leaflet-pane { + position: absolute; + left: 0; + top: 0; + } + .leaflet-tile { + pointer-events: none; + } + `, e.appendChild(k); +} +function Dt(t, e) { + if (!e) return; + e.innerHTML = ""; + const a = t.config.toggles || {}, i = !!window.customElements && !!customElements.get("ha-switch"); + Object.keys(a).forEach((o) => { + const r = a[o], n = document.createElement("div"); + n.className = "toggle"; + const d = document.createElement("label"); + d.textContent = r.label || o, n.appendChild(d); + let l; + i ? l = document.createElement("ha-switch") : (l = document.createElement("input"), l.type = "checkbox"), l.checked = r.default === !0, l.addEventListener("change", () => { + t.setToggleValue && t.setToggleValue(o, l.checked); + }), n.appendChild(l), e.appendChild(n); + }); +} +function N(t) { + return t * (Math.PI / 180); +} +function at(t) { + return t * (180 / Math.PI); +} +function j(t, e, a, i, o = "km") { + const n = N(a - t), d = N(i - e), l = Math.sin(n / 2) * Math.sin(n / 2) + Math.cos(N(t)) * Math.cos(N(a)) * Math.sin(d / 2) * Math.sin(d / 2), u = 2 * Math.atan2(Math.sqrt(l), Math.sqrt(1 - l)); + return o === "km" ? 6371 * u : 6371 * u / 1.60934; +} +function V(t, e, a, i) { + const o = N(i - e), r = Math.sin(o) * Math.cos(N(a)), n = Math.cos(N(t)) * Math.sin(N(a)) - Math.sin(N(t)) * Math.cos(N(a)) * Math.cos(o); + return (at(Math.atan2(r, n)) + 360) % 360; +} +function it(t, e, a, i) { + const r = N(a), n = N(t), d = N(e), l = i / 6371, u = Math.asin(Math.sin(n) * Math.cos(l) + Math.cos(n) * Math.sin(l) * Math.cos(r)), _ = d + Math.atan2(Math.sin(r) * Math.sin(l) * Math.cos(n), Math.cos(l) - Math.sin(n) * Math.sin(u)); + return { + lat: at(u), + lon: at(_) + }; +} +function zt(t, e, a, i, o) { + const r = V(a, i, t, e), n = Math.abs((o - r + 360) % 360); + return it(a, i, o, j(t, e, a, i) * Math.cos(N(n))); +} +function Pt(t) { + return [ + "N", + "NE", + "E", + "SE", + "S", + "SW", + "W", + "NW" + ][Math.round(t / 45) % 8]; +} +function ht(t, e, a = 60) { + const i = Math.abs((t - e + 360) % 360); + return i <= a || i >= 360 - a; +} +function K(t) { + if (!t || !t.config) + return console.error("Config not set in getLocation"), { + latitude: 0, + longitude: 0 + }; + const { config: e, hass: a } = t; + if (e.location_tracker && a && a.states && e.location_tracker in a.states) { + const i = a.states[e.location_tracker].attributes; + return { + latitude: i.latitude, + longitude: i.longitude + }; + } else { + if (e.location) return { + latitude: e.location.lat, + longitude: e.location.lon + }; + if (a && a.config) return { + latitude: a.config.latitude, + longitude: a.config.longitude + }; + } + return { + latitude: 0, + longitude: 0 + }; +} +function Q(t) { + if (t) + return Ct.get(t); +} +function xt(t) { + return !!Q(t)?.apiKeyParam; +} +function gt(t) { + return Q(t)?.apiKeyHelp || ""; +} +function Nt(t, e) { + const a = Q(t); + return a ? a.apiKeyParam && e && e.trim().length > 0 ? a.url + a.apiKeyParam + encodeURIComponent(e.trim()) : a.url : ""; +} +var X, Ct, Z, $t = J((() => { + X = [ + { + id: "color", + label: "Color (OpenStreetMap)", + group: "keyless", + url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", + attribution: "© OpenStreetMap contributors", + subdomains: [ + "a", + "b", + "c" + ] + }, + { + id: "satellite", + label: "Satellite", + group: "keyless", + url: "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}", + attribution: "© Esri, Maxar, Earthstar Geographics", + subdomains: [] + }, + { + id: "topo", + label: "Topographic", + group: "keyless", + url: "https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png", + attribution: "© OpenTopoMap, © OpenStreetMap contributors", + subdomains: [ + "a", + "b", + "c" + ] + }, + { + id: "light", + label: "Light (CARTO)", + group: "keyed", + url: "https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png", + apiKeyParam: "?key=", + apiKeyHelp: 'Required for Light, Dark and Voyager. Request a free CARTO key.', + attribution: "© CartoDB, © OpenStreetMap contributors", + subdomains: [ + "a", + "b", + "c", + "d" + ] + }, + { + id: "dark", + label: "Dark (CARTO)", + group: "keyed", + url: "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png", + apiKeyParam: "?key=", + apiKeyHelp: 'Required for Light, Dark and Voyager. Request a free CARTO key.', + attribution: "© CartoDB, © OpenStreetMap contributors", + subdomains: [ + "a", + "b", + "c", + "d" + ] + }, + { + id: "voyager", + label: "Voyager (CARTO)", + group: "keyed", + url: "https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}.png", + apiKeyParam: "?key=", + apiKeyHelp: 'Required for Light, Dark and Voyager. Request a free CARTO key.', + attribution: "© CartoDB, © OpenStreetMap contributors", + subdomains: [ + "a", + "b", + "c", + "d" + ] + }, + { + id: "bw", + label: "Black & White (Stadia)", + group: "keyed", + url: "https://tiles.stadiamaps.com/tiles/stamen_toner/{z}/{x}/{y}.png", + apiKeyParam: "?api_key=", + apiKeyHelp: 'Required for Black & White and Outlines. Get a free Stadia Maps key.', + attribution: "Map tiles by Stamen Design, CC BY 3.0 — Map data © OpenStreetMap", + subdomains: [] + }, + { + id: "outlines", + label: "Outlines (Stadia)", + group: "keyed", + url: "https://tiles.stadiamaps.com/tiles/stamen_toner_lines/{z}/{x}/{y}.png", + apiKeyParam: "?api_key=", + apiKeyHelp: 'Required for Black & White and Outlines. Get a free Stadia Maps key.', + attribution: "Map tiles by Stamen Design, hosted by Stadia Maps; Data by OpenStreetMap", + subdomains: [] + } + ], Ct = new Map(X.map((t) => [t.id, t])), Z = new Set(X.map((t) => t.id)); +})); +$t(); +function Mt(t) { + const e = t?.radar; + if (!e || e.hide === !0) return !1; + const a = t?.config?.radar?.background_map, i = !!a && (Z.has(a) || a === "system"); + return e.view === "map" ? !a || i : !a || a === "none" ? !1 : i; +} +function jt() { + const t = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches; + let e = !1; + try { + e = !!(window.parent && window.parent.document && window.parent.document.body.classList.contains("dark")); + } catch { + } + return e || t; +} +function qt(t, e, a) { + if (!Mt(t)) { + t.radar?.hide !== !0 && a(); + return; + } + if (!e.querySelector("#leaflet-css-loader")) { + const i = document.createElement("link"); + i.id = "leaflet-css-loader", i.rel = "stylesheet", i.href = "https://unpkg.com/leaflet/dist/leaflet.css", e.appendChild(i); + } + if (window.L) { + a(); + return; + } + if (e.querySelector("#leaflet-js-loader")) { + const i = setInterval(() => { + window.L && (clearInterval(i), a()); + }, 50); + } else { + const i = document.createElement("script"); + i.id = "leaflet-js-loader", i.src = "https://unpkg.com/leaflet/dist/leaflet.js", i.async = !0, i.onload = a, i.onerror = () => { + i.remove(), console.error("[FR24] Leaflet script load failed"); + }, e.appendChild(i); + } +} +function Bt(t, e) { + const { config: a, dimensions: i } = t; + if (!Mt(t)) { + t._leafletMap && (t._leafletMap.remove(), t._leafletMap = null); + const E = e.querySelector("#radar-map-bg"); + E && E.remove(); + return; + } + const o = a?.radar?.background_map; + let r = o; + t.radar?.view === "map" && (!o || o === "none" || !Z.has(o)) && (r = "system"); + const n = t.radar?.view === "map" ? 1 : typeof a?.radar?.background_map_opacity == "number" ? Math.max(0, Math.min(1, a.radar.background_map_opacity)) : 1; + let d = e.querySelector("#radar-map-bg"); + d ? d.style.opacity = String(n) : (d = document.createElement("div"), d.id = "radar-map-bg", d.style.position = "absolute", d.style.top = "0", d.style.left = "0", d.style.width = "100%", d.style.height = "100%", d.style.zIndex = "0", d.style.pointerEvents = "none", d.style.opacity = String(n), e.appendChild(d)), d.style.transform = "", t._leafletMap && t._leafletMap.getContainer() !== d && (t._leafletMap.remove(), t._leafletMap = null); + const l = K(t), u = Math.max(i?.range || 1, 1), _ = t.units?.distance === "miles" ? u * 1.60934 : u, m = l?.latitude || 0, g = l?.longitude || 0, y = Math.PI / 180, k = 111.13209 - 0.56605 * Math.cos(2 * m * y) + 12e-4 * Math.cos(4 * m * y), A = 111.32 * Math.cos(m * y) - 0.094 * Math.cos(3 * m * y), R = _ / k, F = _ / A, S = [[m - R, g - F], [m + R, g + F]]; + let x, $; + r === "system" ? jt() ? (x = a?.radar?.background_map_dark || "dark", $ = a?.radar?.background_map_dark_api_key || "") : (x = a?.radar?.background_map_light || "color", $ = a?.radar?.background_map_light_api_key || "") : (x = o && Z.has(o) ? o : "color", $ = a?.radar?.background_map_api_key || ""); + const C = Q(x); + if (!C) return d; + const M = Nt(x, $); + if (!M) return d; + const w = { + attribution: C.attribution, + subdomains: C.subdomains + }; + if (xt(x) && !($ && $.trim().length > 0)) + return t._leafletMap && (t._leafletMap.remove(), t._leafletMap = null), d.innerHTML = '
API key required for this map type. Configure in Background Map settings.
', d; + if (t._leafletMap || (d.innerHTML = ""), window.L) { + const E = { + type: x, + apiKey: $ + }, L = !t._currentMapConfig || t._currentMapConfig.type !== E.type || t._currentMapConfig.apiKey !== E.apiKey; + t._leafletMap ? L && (t._leafletMap.eachLayer((O) => { + t._leafletMap.removeLayer(O); + }), window.L.tileLayer(M, w).addTo(t._leafletMap), t._currentMapConfig = E) : (t._leafletMap = window.L.map(d, { + attributionControl: !1, + zoomControl: !1, + dragging: !1, + scrollWheelZoom: !1, + boxZoom: !1, + doubleClickZoom: !1, + keyboard: !1, + touchZoom: !1, + pointerEvents: !1 + }), window.L.tileLayer(M, w).addTo(t._leafletMap), t._currentMapConfig = E), Lt(t._leafletMap, d, S, _), t.mapCenter = { + lat: Math.round(m * 100) / 100, + lon: Math.round(g * 100) / 100 + }, t.mapZoom = Math.round(t._leafletMap.getZoom()); + } + return d; +} +function Lt(t, e, a, i, o = 15) { + e.offsetHeight; + const r = t.getContainer(), n = r.offsetWidth, d = r.offsetHeight; + if (n > 0 && d > 0) { + t.fitBounds(a, { + animate: !1, + padding: [0, 0] + }); + const l = window.L.point(0, d / 2), u = window.L.point(n, d / 2), _ = t.containerPointToLatLng(l), m = t.containerPointToLatLng(u), g = j(_.lat, _.lng, m.lat, m.lng, "km") / (i * 2); + e.style.transform = `scale(${g})`; + } else o > 0 && setTimeout(() => { + Lt(t, e, a, i, o - 1); + }, 50); +} +function Ht(t) { + const e = t._leafletMap; + if (!e) return; + const a = K(t), i = Math.max(t.dimensions?.range || 1, 1), o = t.units?.distance === "miles" ? i * 1.60934 : i, r = a?.latitude || 0, n = a?.longitude || 0, d = Math.PI / 180, l = 111.13209 - 0.56605 * Math.cos(2 * r * d) + 12e-4 * Math.cos(4 * r * d), u = 111.32 * Math.cos(r * d) - 0.094 * Math.cos(3 * r * d), _ = o / l, m = o / u, g = [[r - _, n - m], [r + _, n + m]], y = e.getContainer(); + y.offsetHeight; + const k = y.offsetWidth, A = y.offsetHeight; + if (k > 0 && A > 0) { + e.fitBounds(g, { + animate: !1, + padding: [0, 0] + }); + const R = window.L.point(0, A / 2), F = window.L.point(k, A / 2), S = e.containerPointToLatLng(R), x = e.containerPointToLatLng(F), $ = j(S.lat, S.lng, x.lat, x.lng, "km") / (o * 2); + y.style.transform = `scale(${$})`; + } +} +function Et(t = {}, e, a = []) { + if (a.includes(e)) + return console.error("Circular template dependencies detected. " + a.join(" -> ") + " -> " + e), ""; + if (t["compiled_" + e]) return t["compiled_" + e]; + let i = t[e]; + if (i === void 0) + return console.error("Missing template reference: " + e), ""; + const o = /tpl\.([a-zA-Z_$][a-zA-Z0-9_$]*)/g; + let r; + const n = {}; + for (; (r = o.exec(i)) !== null; ) { + const d = r[1]; + n[d] || (n[d] = Et(t, d, [...a, e])), i = i.replace(`tpl.${d}`, "(`" + n[d] + '`).replace(/^undefined$/, "")'); + } + return t["compiled_" + e] = i, i; +} +function pt(t, e, a, i) { + const o = t.templates || {}, r = t.flightsContext || {}, n = t.units || { + distance: "km", + altitude: "ft", + speed: "kts" + }, d = t.radar || { range: 35 }, l = Et(o, e); + try { + const u = new Function("flights", "flight", "tpl", "units", "radar_range", "joinList", `return \`${l.replace(/\${(.*?)}/g, (_, m) => `\${${m}}`)}\``)(r, a, {}, n, Math.round(d.range), i); + return u !== "undefined" ? u : ""; + } catch (u) { + return console.error("Error when rendering: " + l, u), ""; + } +} +function nt(t, e, a, i) { + const { defines: o = {}, config: r = {}, radar: n = { range: 35 }, selectedFlights: d = [] } = t; + if (typeof e == "string" && e.startsWith("${") && e.endsWith("}")) { + const l = e.slice(2, -1); + if (l === "selectedFlights") return d; + if (l === "radar_range") + return i && i(!0), n.range; + if (l in o) return o[l]; + if (r.toggles && l in r.toggles) return r.toggles[l].default; + if (a !== void 0) return a; + console.error("Unresolved placeholder: " + l), console.debug("Defines", o); + } + return e; +} +function ot(t, e) { + if (!t) return ""; + try { + const a = new Function("map_lat", "map_lon", "zoom", "radar_range", "click_lat", "click_lon", "flight", "entity", "return `" + t.replace(/\${(.*?)}/g, "${$1}") + "`")(e.map_lat, e.map_lon, e.zoom, e.radar_range, e.click_lat, e.click_lon, e.flight ?? null, e.entity ?? null); + return a !== "undefined" ? a : ""; + } catch (a) { + return console.error("Error rendering URL path:", t, a), t; + } +} +function rt(t) { + const { units: e, radar: a, dom: i, dimensions: o, hass: r } = t, n = i?.radarInfoDisplay || i && i.radarContainer?.querySelector("#radar-info"); + n && (n.innerHTML = [a?.hide_range !== !0 ? pt(t, "radar_range", null, void 0) : ""].filter((x) => x).join("
")); + const d = i?.radarScreen || i && i.radarContainer?.querySelector("#radar-screen") || t.mainCard?.shadowRoot && t.mainCard.shadowRoot.getElementById("radar-screen"); + if (!d) return; + Array.from(d.childNodes).forEach((x) => { + const $ = x; + $.id !== "radar-map-bg" && $.id !== "radar-screen-background" && d.removeChild(x); + }); + let l = d.querySelector("#radar-screen-background"); + l || (l = document.createElement("div"), l.id = "radar-screen-background", d.appendChild(l)); + const u = K(t); + t.mapCenter = { + lat: Math.round(u.latitude * 100) / 100, + lon: Math.round(u.longitude * 100) / 100 + }, t._leafletMap || (t.mapZoom = 8), Bt(t, d); + const { width: _, height: m, range: g, scaleFactor: y, centerX: k, centerY: A } = o || {}; + if (!_ || !m || !g || !y || k == null || A == null) return; + const R = g * 1.15; + if (a?.rings ?? a?.view !== "map") { + const x = a?.ring_distance ?? 10, $ = Math.floor(g / x); + for (let C = 1; C <= $; C++) { + const M = C * x * y, w = document.createElement("div"); + w.className = "ring", w.style.width = w.style.height = M * 2 + "px", w.style.top = Math.floor(A - M) + "px", w.style.left = Math.floor(k - M) + "px", d.appendChild(w); + } + for (let C = 0; C < 360; C += 45) { + const M = document.createElement("div"); + M.className = "dotted-line", M.style.transform = `rotate(${C - 90}deg)`, d.appendChild(M); + } + } + const F = K(t), S = a?.local_features; + if (S && r && F) { + const x = F.latitude, $ = F.longitude; + S.forEach((C) => { + if (!(C.max_range && a.range && C.max_range <= a.range)) { + if (C.type === "outline" && C.points && C.points.length > 1) for (let M = 0; M < C.points.length - 1; M++) { + const w = C.points[M], E = C.points[M + 1], L = j(x, $, w.lat, w.lon, e.distance), O = j(x, $, E.lat, E.lon, e.distance); + if (L <= R || O <= R) { + const z = V(x, $, w.lat, w.lon), T = V(x, $, E.lat, E.lon), c = k + Math.cos((z - 90) * Math.PI / 180) * L * y, p = A + Math.sin((z - 90) * Math.PI / 180) * L * y, s = k + Math.cos((T - 90) * Math.PI / 180) * O * y, h = A + Math.sin((T - 90) * Math.PI / 180) * O * y, v = document.createElement("div"); + v.className = "outline-line", v.style.width = Math.hypot(s - c, h - p) + "px", v.style.height = "1px", v.style.top = p + "px", v.style.left = c + "px", v.style.transformOrigin = "0 0", v.style.transform = `rotate(${Math.atan2(h - p, s - c) * (180 / Math.PI)}deg)`, d.appendChild(v); + } + } + else if ("position" in C && C.position) { + const { lat: M, lon: w } = C.position, E = j(x, $, M, w, e.distance); + if (E <= R) { + const L = V(x, $, M, w), O = k + Math.cos((L - 90) * Math.PI / 180) * E * y, z = A + Math.sin((L - 90) * Math.PI / 180) * E * y; + if (C.type === "runway") { + const T = C.heading ?? 0, c = C.length ?? 0, p = e.distance === "km" ? c * 3048e-7 : c * 18939e-8, s = document.createElement("div"); + s.className = "runway", s.style.width = p * y + "px", s.style.height = "1px", s.style.top = z + "px", s.style.left = O + "px", s.style.transformOrigin = "0 50%", s.style.transform = `rotate(${T - 90}deg)`, d.appendChild(s); + } + if (C.type === "location") { + const T = document.createElement("div"); + T.className = "location-dot"; + const c = C.label; + if (T.title = c ?? "Location", T.style.top = z + "px", T.style.left = O + "px", d.appendChild(T), c) { + const p = document.createElement("div"); + p.className = "location-label", p.textContent = c || "Location", d.appendChild(p); + const s = p.getBoundingClientRect(), h = s.width, v = s.height; + p.style.top = z - v - 4 + "px", p.style.left = O - h / 2 + "px"; + } + } + } + } + } + }); + } +} +function At(t, e) { + let a = null, i = null; + function o(u) { + const _ = u[0], m = u[1], g = _.clientX - m.clientX, y = _.clientY - m.clientY; + return Math.sqrt(g * g + y * y); + } + function r(u) { + u.preventDefault(); + const _ = Math.sign(u.deltaY); + t.radar.range += _ * 2; + const m = t.radar.min_range || 1, g = t.radar.max_range || Math.max(100, t.radar.initialRange || 35); + t.radar.range < m && (t.radar.range = m), t.radar.range > g && (t.radar.range = g), t.mainCard.updateRadarRange(_ * 2); + } + function n(u) { + u.touches.length === 2 && (a = o(u.touches), i = t.radar.range); + } + function d(u) { + if (u.touches.length === 2 && a !== null && i !== null) { + u.preventDefault(); + const _ = o(u.touches), m = a / _, g = t.radar.min_range || 1, y = t.radar.max_range || Math.max(100, t.radar.initialRange || 35); + let k = Math.round(i * m); + k < g && (k = g), k > y && (k = y), t.radar.range = k, t.mainCard.updateRadarRange(0); + } + } + function l() { + a !== null && (a = null, i = null, t.config.updateRangeFilterOnTouchEnd && t.renderDynamicOnRangeChange && t.mainCard.renderDynamic()); + } + return e && (e.addEventListener("wheel", r, { passive: !1 }), e.addEventListener("touchstart", n, { passive: !0 }), e.addEventListener("touchmove", d, { passive: !1 }), e.addEventListener("touchend", l, { passive: !0 })), () => { + e && (e.removeEventListener("wheel", r), e.removeEventListener("touchstart", n), e.removeEventListener("touchmove", d), e.removeEventListener("touchend", l)); + }; +} +function Vt(t, e, a) { + const i = t.config?.tap_action; + if (!i) return; + const o = t.dom?.radar; + if (!o) { + dt(ot(i, { + map_lat: t.mapCenter?.lat, + map_lon: t.mapCenter?.lon, + zoom: t.mapZoom, + radar_range: t.radar?.range, + entity: lt(t) + })); + return; + } + const r = o.getBoundingClientRect(), n = e - r.left, d = a - r.top, l = r.width, u = r.height, _ = t.units?.distance === "miles" ? (t.radar?.range || 1) * 1.60934 : t.radar?.range || 1, m = l / 2, g = u / 2, y = n - m, k = d - g, A = Math.sqrt(y * y + k * k) / Math.min(m, g) * _, R = (Math.atan2(y, -k) * (180 / Math.PI) + 360) % 360, F = t.mapCenter?.lat || 0, S = t.mapCenter?.lon || 0, x = R * Math.PI / 180, $ = 111.32, C = 111.32 * Math.cos(F * Math.PI / 180), M = Math.round((F + A / $ * Math.cos(x)) * 100) / 100, w = Math.round((S + A / C * Math.sin(x)) * 100) / 100; + dt(ot(i, { + map_lat: t.mapCenter?.lat, + map_lon: t.mapCenter?.lon, + zoom: t.mapZoom, + radar_range: t.radar?.range, + click_lat: M, + click_lon: w, + entity: lt(t) + })); +} +function st(t, e) { + const a = (t.config?.flight_tap_action || "toggle").split("|").map((r) => r.trim()), i = a.includes("toggle"), o = a.find((r) => r !== "toggle") || ""; + i && t.toggleSelectedFlight(e), o && dt(ot(o, { + map_lat: t.mapCenter?.lat, + map_lon: t.mapCenter?.lon, + zoom: t.mapZoom, + radar_range: t.radar?.range, + flight: e, + entity: lt(t) + })); +} +function lt(t) { + const e = t.config?.flights_entity; + if (!e || !t.hass?.states) return; + const a = t.hass.states[e]; + if (a) + return a; +} +function dt(t) { + t && window.open(t, "_blank"); +} +function Ut(t, e) { + e.shadowRoot.innerHTML = ""; + const a = document.createElement("ha-card"); + a.id = "flights-card"; + const i = document.createElement("div"); + i.id = "layout-root"; + const o = t.list?.position || "below"; + if (i.classList.add(`layout-${o}`), t.radar?.view === "map" && i.classList.add("view-map"), !t.radar?.hide) { + const n = document.createElement("div"); + n.id = "radar-container"; + const d = document.createElement("div"); + d.id = "radar-overlay", n.appendChild(d); + const l = document.createElement("div"); + l.id = "radar-info", n.appendChild(l); + const u = document.createElement("div"); + u.id = "toggle-container"; + const _ = document.createElement("div"); + _.id = "radar"; + const m = document.createElement("div"); + m.id = "radar-screen", _.appendChild(m); + const g = document.createElement("div"); + g.id = "tracker", _.appendChild(g); + const y = document.createElement("div"); + y.id = "planes", _.appendChild(y), n.appendChild(_), n.appendChild(u), i.appendChild(n), requestAnimationFrame(() => { + rt(t), e.observeRadarResize(), At(t, _), _.addEventListener("click", (k) => { + k.composedPath().some((A) => A.classList?.contains?.("plane")) || Vt(t, k.clientX, k.clientY); + }); + }), t.dom = t.dom || {}, t.dom.toggleContainer = u, t.dom.planesContainer = y, t.dom.radar = _, t.dom.radarScreen = m, t.dom.radarInfoDisplay = l, t.dom.radarContainer = n, t.dom.shadowRoot = e.shadowRoot, t.mainCard = e; + } + const r = document.createElement("div"); + r.id = "flights", t.list && t.list.hide === !0 && (r.style.display = "none"), i.appendChild(r), a.appendChild(i), e.shadowRoot.appendChild(a), St(t, e.shadowRoot), t.dom?.toggleContainer && Dt(t, t.dom.toggleContainer); +} +function Rt(t, e) { + return (t.flights || []).filter((a) => Ot(t, a, e)); +} +function Ot(t, e, a) { + return Array.isArray(a) ? a.every((i) => U(t, e, i)) : U(t, e, a); +} +function U(t, e, a) { + let i = !0; + if (a.type === "AND" && a.conditions) i = a.conditions.every((o) => U(t, e, o)); + else if (a.type === "OR" && a.conditions) i = a.conditions.some((o) => U(t, e, o)); + else if (a.type === "NOT" && a.condition) i = !U(t, e, a.condition); + else { + const { field: o, defined: r, defaultValue: n, comparator: d } = a, l = nt(t, a.value), u = o ? e[o] : r ? nt(t, "${" + r + "}", n) : void 0; + switch (d) { + case "eq": + i = u === l; + break; + case "lt": + i = Number(u) < Number(l); + break; + case "lte": + i = Number(u) <= Number(l); + break; + case "gt": + i = Number(u) > Number(l); + break; + case "gte": + i = Number(u) >= Number(l); + break; + case "oneOf": + i = (Array.isArray(l) ? l : typeof l == "string" ? l.split(",").map((_) => _.trim()) : []).includes(u); + break; + case "containsOneOf": { + const _ = Array.isArray(l) ? l : typeof l == "string" ? l.split(",").map((m) => m.trim()) : []; + i = !!u && _.some((m) => u.includes(m)); + break; + } + default: + i = !1; + } + } + return a.debugIf === i && console.debug("applyCondition", a, e, i), i; +} +var Wt = 12, mt = /* @__PURE__ */ new Map(), _t = /* @__PURE__ */ new Map(); +function Kt(t) { + if (!t) return [0, 0]; + const e = t.split(",").map(Number); + return [e[0] || 0, e[1] || 0]; +} +function Yt(t) { + const e = { + offsetX: 0, + offsetY: 0, + blur: 0, + color: "rgba(0,0,0,0.5)" + }; + if (!t) return e; + const a = t.trim().split(/\s+/); + if (a.length < 2) return e; + e.offsetX = parseFloat(a[0]) || 0, e.offsetY = parseFloat(a[1]) || 0; + let i = 2; + return a.length > 2 && /^[\d.]+(?:px|em|rem|pt|cm|mm|in|pc|ex|ch|vw|vh|vmin|vmax)$/i.test(a[2]) && (e.blur = Math.max(0, parseFloat(a[2]) || 0), i = 3), a.length > i && (e.color = a.slice(i).join(" ")), e; +} +function Gt(t) { + const e = mt.get(t); + if (e) return e; + const a = new Promise((i, o) => { + const r = new Image(); + r.onload = () => i(r), r.onerror = () => o(/* @__PURE__ */ new Error(`Failed to load marker image: ${t}`)), r.src = t; + }); + return mt.set(t, a), a; +} +function Xt(t, e) { + const a = t.width, i = t.height, o = a / Wt, r = e["aircraft-marker-color-overlay"], n = e["aircraft-marker-outline-width"] ?? 0, d = e["aircraft-marker-outline-color"] || "#000000", l = e["aircraft-marker-shadow"] || "", u = Yt(l), _ = Math.round(u.offsetX * o), m = Math.round(u.offsetY * o), g = Math.round(u.blur * o), y = Math.ceil(n * o), k = Math.max(1, Math.round(y * 0.4)), A = Math.ceil(Math.max(y + k * 2, Math.abs(_) + g * 2, Math.abs(m) + g * 2)), R = a + 2 * A, F = i + 2 * A, S = document.createElement("canvas"); + S.width = R, S.height = F; + const x = S.getContext("2d"), $ = A, C = A; + if (l && (_ !== 0 || m !== 0 || g > 0)) { + const M = document.createElement("canvas"); + M.width = a, M.height = i; + const w = M.getContext("2d"); + w.drawImage(t, 0, 0, a, i), w.globalCompositeOperation = "source-atop", w.fillStyle = u.color, w.fillRect(0, 0, a, i), x.save(), g > 0 && (x.filter = `blur(${g}px)`), x.drawImage(M, $ + _, C + m, a, i), x.restore(); + } + if (y > 0) { + const M = document.createElement("canvas"); + M.width = R, M.height = F; + const w = M.getContext("2d"), E = a + 2 * y, L = i + 2 * y; + w.save(), w.filter = `blur(${k}px)`, w.drawImage(t, $ - y, C - y, E, L), w.filter = "none", w.globalCompositeOperation = "source-atop", w.fillStyle = d, w.fillRect(0, 0, R, F), w.restore(), x.drawImage(M, 0, 0); + } + return x.drawImage(t, $, C, a, i), r && (x.globalCompositeOperation = "source-atop", x.fillStyle = r, x.fillRect($, C, a, i)), S; +} +function Zt(t) { + return `${t["aircraft-marker-url"]}|${t["aircraft-marker-color-overlay"]}|${t["aircraft-marker-outline-width"]}|${t["aircraft-marker-outline-color"]}|${t["aircraft-marker-shadow"]}`; +} +function Jt(t) { + const e = Zt(t), a = _t.get(e); + if (a) return a; + const i = Gt(t["aircraft-marker-url"]).then((o) => Xt(o, t)); + return _t.set(e, i), i; +} +function Qt(t, e) { + const a = document.createElement("div"); + a.className = "custom-marker"; + const i = document.createElement("div"); + i.className = "custom-marker-transform", a.appendChild(i); + const o = t["aircraft-marker-url"], r = t["aircraft-marker-color-overlay"], n = t["aircraft-marker-outline-width"] ?? 0; + t["aircraft-marker-outline-color"]; + const d = t["aircraft-marker-shadow"] || ""; + if (r || n > 0 || d.length > 0) { + const g = document.createElement("canvas"); + i.appendChild(g), Jt(t).then((y) => { + g.width = y.width, g.height = y.height, g.getContext("2d").drawImage(y, 0, 0); + }).catch(() => { + }); + } else { + const g = document.createElement("img"); + g.src = o, g.draggable = !1, i.appendChild(g); + } + const l = t["aircraft-marker-rotation"] ?? 0, u = t["aircraft-marker-scale"] ?? 1, [_, m] = Kt(t["aircraft-marker-center"]); + return i.style.transform = `rotate(${e + l}deg) scale(${u})`, i.style.transformOrigin = `calc(50% + ${_}px) calc(50% + ${m}px)`, a; +} +function et(t) { + const { flights: e, radar: a, selectedFlights: i, dimensions: o, dom: r } = t; + let n; + a && a.filter === !0 ? n = t.flightsFiltered || e : a && a.filter && typeof a.filter == "object" ? n = Rt(t, a.filter) : n = e; + const d = r?.planesContainer || t.mainCard?.shadowRoot && t.mainCard.shadowRoot.getElementById("planes"); + if (!d) return; + d.innerHTML = ""; + const { range: l, scaleFactor: u, centerX: _, centerY: m } = o; + if (!l || !u || _ === void 0 || m === void 0) return; + const g = l * 1.15, y = a?.["aircraft-marker"]?.default; + n.slice().reverse().forEach((k) => { + const A = k.distance_to_tracker; + if (A !== void 0 && A <= g) { + const R = document.createElement("div"); + R.className = "plane"; + const F = k.heading_from_tracker ?? 0, S = _ + Math.cos((F - 90) * Math.PI / 180) * A * u, x = m + Math.sin((F - 90) * Math.PI / 180) * A * u; + if (R.style.top = x + "px", R.style.left = S + "px", y?.["aircraft-marker-url"]) { + R.classList.add("plane-custom"); + const L = Qt(y, k.heading ?? 0); + R.appendChild(L); + } else { + const L = document.createElement("div"); + L.className = "arrow", L.style.transform = `rotate(${k.heading}deg)`, R.appendChild(L), (k.altitude ?? 0) <= 0 ? R.classList.add("plane-small") : R.classList.add("plane-medium"); + } + const $ = document.createElement("div"); + $.className = "callsign-label", $.textContent = k.callsign ?? k.aircraft_registration ?? "n/a", d.appendChild($); + const C = $.getBoundingClientRect(), M = C.width + 3, w = C.height + 6; + $.style.top = x - w + "px", $.style.left = S - M + "px"; + const E = a["aircraft-marker-size"]; + E && E !== "normal" && R.classList.add(`marker-size-${E}`), i && i.includes(k.id) && R.classList.add("selected"), R.addEventListener("click", (L) => { + L.stopPropagation(), st(t, k); + }), $.addEventListener("click", (L) => { + L.stopPropagation(), st(t, k); + }), d.appendChild(R); + } + }); +} +function vt(t, e) { + const a = document.createElement("img"); + return a.setAttribute("src", `https://flagsapi.com/${t}/shiny/16.png`), a.setAttribute("title", `${e}`), a.style.position = "relative", a.style.top = "3px", a.style.left = "2px", a; +} +function te(t, e, a) { + try { + let i = e[a]; + if (t.config.annotate) { + const o = Object.assign({}, e); + t.config.annotate.filter((r) => r.field === a).forEach((r) => { + Ot(t, e, r.conditions) && (o[a] = r.render.replace(/\$\{([^}]*)\}/g, (n, d) => String(o[d] || ""))); + }), i = String(o[a] || ""); + } + return i; + } catch (i) { + return console.error(`[FR24Card] flightField error for field '${a}':`, i), ""; + } +} +function ee(t, e) { + try { + const a = Object.assign({}, e); + [ + "flight_number", + "callsign", + "aircraft_registration", + "aircraft_model", + "aircraft_code", + "airline", + "airline_short", + "airline_iata", + "airline_icao", + "airport_origin_name", + "airport_origin_code_iata", + "airport_origin_code_icao", + "airport_origin_country_name", + "airport_origin_country_code", + "airport_destination_name", + "airport_destination_code_iata", + "airport_destination_code_icao", + "airport_destination_country_name", + "airport_destination_country_code" + ].forEach((o) => { + a[o] = te(t, a, o); + }), a.origin_flag = a.airport_origin_country_code ? vt(a.airport_origin_country_code, a.airport_origin_country_name || "").outerHTML : "", a.destination_flag = a.airport_destination_country_code ? vt(a.airport_destination_country_code, a.airport_destination_country_name || "").outerHTML : "", a.climb_descend_indicator = Math.abs(a.vertical_speed) > 100 ? a.vertical_speed > 100 ? "↑" : "↓" : "", a.alt_in_unit = a.altitude >= 17750 ? `FL${Math.round(a.altitude / 1e3) * 10}` : a.altitude > 0 ? t.units.altitude === "m" ? `${Math.round(a.altitude * 0.3048)} m` : `${Math.round(a.altitude)} ft` : void 0, a.spd_in_unit = a.ground_speed > 0 ? t.units.speed === "kmh" ? `${Math.round(a.ground_speed * 1.852)} km/h` : t.units.speed === "mph" ? `${Math.round(a.ground_speed * 1.15078)} mph` : `${Math.round(a.ground_speed)} kts` : void 0, a.approach_indicator = a.ground_speed > 70 ? a.is_approaching ? "↓" : a.is_receding ? "↑" : "" : "", a.dist_in_unit = `${Math.round(a.distance_to_tracker || 0)} ${t.units.distance}`, a.direction_info = `${Math.round(a.heading_from_tracker || 0)}° ${a.cardinal_direction_from_tracker || ""}`; + const i = document.createElement("div"); + return i.style.clear = "both", i.className = "flight", t.selectedFlights && t.selectedFlights.includes(a.id) && (i.className += " selected"), i.innerHTML = pt(t, "flight_element", a, (o) => (...r) => r?.filter((n) => n).join(o || " ")), i.addEventListener("click", (o) => { + o.stopPropagation(), st(t, a); + }), i; + } catch (a) { + console.error("[FR24Card] renderFlight error:", a); + const i = document.createElement("div"); + return i.className = "flight error", i.textContent = `Error rendering flight: ${a}`, i; + } +} +var bt = { + altitude: "ft", + speed: "kts", + distance: "km" +}, ae = [ + { + field: "id", + comparator: "oneOf", + value: "${selectedFlights}", + order: "DESC" + }, + { + field: "altitude", + comparator: "eq", + value: 0, + order: "ASC" + }, + { + field: "closest_passing_distance ?? distance_to_tracker", + order: "ASC" + } +], W, Ft = J((() => { + W = { + img_element: '${flight.aircraft_photo_small ? `` : ""}', + icon: '${flight.altitude > 0 ? (flight.vertical_speed > 100 ? "airplane-takeoff" : flight.vertical_speed < -100 ? "airplane-landing" : "airplane") : "airport"}', + icon_element: '', + flight_info: '${joinList(" - ")(flight.airline_short, flight.flight_number, flight.callsign !== flight.flight_number ? flight.callsign : "")}', + flight_info_element: '
${tpl.flight_info}
', + header: "
${tpl.img_element}${tpl.icon_element}${tpl.flight_info_element}
", + aircraft_info: '${joinList(" - ")(flight.aircraft_registration, flight.aircraft_model)}', + aircraft_info_element: '${tpl.aircraft_info ? `
${tpl.aircraft_info}
` : ""}', + departure_info: '${flight.altitude === 0 && flight.time_scheduled_departure ? ` (${new Date(flight.time_scheduled_departure * 1000).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })})` : ""}', + origin_info: '${joinList("")(flight.airport_origin_code_iata, tpl.departure_info, flight.origin_flag)}', + arrival_info: "", + destination_info: '${joinList("")(flight.airport_destination_code_iata, tpl.arrival_info, flight.destination_flag)}', + route_info: '${joinList(" -> ")(tpl.origin_info, tpl.destination_info)}', + route_element: "
${tpl.route_info}
", + alt_info: '${flight.alt_in_unit ? "Alt: " + flight.alt_in_unit + flight.climb_descend_indicator : undefined}', + spd_info: '${flight.spd_in_unit ? "Spd: " + flight.spd_in_unit : undefined}', + hdg_info: '${flight.heading ? "Hdg: " + flight.heading + "°" : undefined}', + dist_info: '${flight.dist_in_unit ? "Dist: " + flight.dist_in_unit + flight.approach_indicator : undefined}', + flight_status: '
${joinList(" - ")(tpl.alt_info, tpl.spd_info, tpl.hdg_info)}
', + position_status: '
${joinList(" - ")(tpl.dist_info, flight.direction_info)}
', + proximity_info: '
${flight.is_approaching && flight.ground_speed > 70 && flight.closest_passing_distance < 15 ? `Closest Distance: ${flight.closest_passing_distance} ${units.distance}, ETA: ${flight.eta_to_closest_distance} min` : ""}
', + flight_element: "${tpl.header}${tpl.aircraft_info_element}${tpl.route_element}${tpl.flight_status}${tpl.position_status}${tpl.proximity_info}", + radar_range: "Range: ${radar_range} ${units.distance}", + list_status: "${flights.shown}/${flights.total}" + }; +})); +Ft(); +function yt(t, e) { + return e.split(" ?? ").reduce((a, i) => a ?? t[i], void 0); +} +function ie(t, e = (a) => a) { + return function(a, i) { + for (const o of t) { + const { field: r, comparator: n, order: d = "ASC" } = o, l = e(o.value), u = yt(a, r), _ = yt(i, r); + let m = 0; + switch (n) { + case "eq": + u === l && _ !== l ? m = 1 : u !== l && _ === l && (m = -1); + break; + case "lt": + u < l && _ >= l ? m = 1 : u >= l && _ < l && (m = -1); + break; + case "lte": + u <= l && _ > l ? m = 1 : u > l && _ <= l && (m = -1); + break; + case "gt": + u > l && _ <= l ? m = 1 : u <= l && _ > l && (m = -1); + break; + case "gte": + u >= l && _ < l ? m = 1 : u < l && _ >= l && (m = -1); + break; + case "oneOf": + if (l != null && (Array.isArray(l) || typeof l == "string")) { + const g = l.includes(u), y = l.includes(_); + g && !y ? m = 1 : !g && y && (m = -1); + } + break; + case "containsOneOf": + if (Array.isArray(l) && l.length > 0) { + const g = l.some((k) => (Array.isArray(u) || typeof u == "string") && u.includes(k)), y = l.some((k) => (Array.isArray(_) || typeof _ == "string") && _.includes(k)); + g && !y ? m = 1 : !g && y && (m = -1); + } + break; + default: + m = u - _; + break; + } + if (m !== 0) return d.toUpperCase() === "DESC" ? -m : m; + } + return 0; + }; +} +var P = { + flights_entity: "sensor.flightradar24_current_in_area", + projection_interval: 5, + no_flights_message: "No flights are currently visible. Please check back later.", + list: { + hide: !1, + showListStatus: !0, + position: "below" + }, + units: bt, + radar: { + range: bt.distance === "km" ? 35 : 25, + view: "radar", + background_map: "none", + background_map_opacity: 0, + background_map_api_key: "", + background_map_light: "color", + background_map_dark: "dark", + background_map_light_api_key: "", + background_map_dark_api_key: "" + }, + sort: ae, + templates: W, + defines: {} +}, kt = class { + constructor() { + this.hass = null, this.config = {}, this.radar = { range: 35 }, this.list = {}, this.templates = {}, this.defines = {}, this.units = { + altitude: "ft", + speed: "kts", + distance: "km" + }, this.flightsContext = {}, this.dimensions = {}, this.flights = [], this.selectedFlights = [], this.renderDynamicOnRangeChange = !1, this._leafletMap = null, this.sortFn = () => 0; + } + setConfig(t) { + if (!t) throw new Error("Configuration is missing."); + this.config = { ...t }, this.config.flights_entity = t.flights_entity ?? P.flights_entity, this.config.projection_interval = t.projection_interval ?? P.projection_interval, this.config.no_flights_message = t.no_flights_message ?? P.no_flights_message, this.list = { + ...P.list, + ...t.list + }, this.units = { + ...P.units, + ...t.units + }, this.radar = { + range: this.units.distance === "km" ? P.radar.range : 25, + view: t.radar?.view ?? P.radar.view, + background_map: t.radar?.background_map ?? P.radar.background_map, + background_map_opacity: t.radar?.background_map_opacity ?? P.radar.background_map_opacity, + background_map_api_key: t.radar?.background_map_api_key ?? P.radar.background_map_api_key, + background_map_light: t.radar?.background_map_light ?? P.radar.background_map_light, + background_map_dark: t.radar?.background_map_dark ?? P.radar.background_map_dark, + background_map_light_api_key: t.radar?.background_map_light_api_key ?? P.radar.background_map_light_api_key, + background_map_dark_api_key: t.radar?.background_map_dark_api_key ?? P.radar.background_map_dark_api_key, + ...t.radar + }, this.radar.initialRange = this.radar.range, this.defines = { + ...P.defines, + ...t.defines + }, this.sortFn = ie(t.sort ?? P.sort, (e) => nt(this, e, void 0, (a) => { + this.renderDynamicOnRangeChange = a; + })), this.templates = { + ...P.templates, + ...t.templates + }; + } + toggleSelectedFlight(t) { + this.selectedFlights || (this.selectedFlights = []), this.selectedFlights.includes(t.id) ? this.selectedFlights = this.selectedFlights.filter((e) => e !== t.id) : this.selectedFlights.push(t.id), typeof this.renderDynamicFn == "function" && this.renderDynamicFn(); + } + setRenderDynamic(t) { + this.renderDynamicFn = t; + } + setToggleValue(t, e) { + this.config && this.config.toggles && (this.defines[t] = [ + "true", + !0, + 1 + ].includes(e), typeof this.renderDynamicFn == "function" && this.renderDynamicFn()); + } +}; +async function ne() { + if (q) return q; + try { + const e = await fetch("/local/flightradar24-card/runways.csv"); + if (e.ok) + return q = await e.text(), q; + } catch { + } + try { + const e = await fetch("data/runways.csv"); + if (e.ok) + return q = await e.text(), q; + } catch { + } + const t = await fetch("https://davidmegginson.github.io/ourairports-data/runways.csv"); + if (!t.ok) throw new Error(`Failed to fetch runway data: ${t.status}`); + return q = await t.text(), q; +} +async function oe() { + if (B) return B; + try { + const e = await fetch("/local/flightradar24-card/airports.csv"); + if (e.ok) + return B = await e.text(), B; + } catch { + } + try { + const e = await fetch("data/airports.csv"); + if (e.ok) + return B = await e.text(), B; + } catch { + } + const t = await fetch("https://davidmegginson.github.io/ourairports-data/airports.csv"); + if (!t.ok) throw new Error(`Failed to fetch airport data: ${t.status}`); + return B = await t.text(), B; +} +function G(t) { + const e = []; + let a = "", i = !1; + for (let o = 0; o < t.length; o++) { + const r = t[o]; + r === '"' ? i = !i : r === "," && !i ? (e.push(a), a = "") : a += r; + } + return e.push(a), e; +} +function re(t, e, a, i, o) { + let r = 0; + a && a === t && (r += 1e3), a && a.startsWith(t) && (r += 500), e === t && (r += 900), e.startsWith(t) && (r += 400), o && `${e}${o}`.includes(t) && (r += 300); + const n = i.toUpperCase().split(/[\s,/-]+/); + for (const d of n) if (d.startsWith(t)) { + r += 250; + break; + } + return i.toUpperCase().includes(t) && (r += 100), r; +} +async function se(t) { + if (!t || t.length < 2) return []; + const e = t.trim().toUpperCase(), a = [], [i, o] = await Promise.all([ne(), oe()]), r = /* @__PURE__ */ new Map(), n = o.split(` +`), d = G(n[0]), l = d.indexOf("ident"), u = d.indexOf("name"), _ = d.indexOf("iata_code"); + for (let w = 1; w < n.length; w++) { + const E = n[w].trim(); + if (!E) continue; + const L = G(E), O = L[l], z = L[u], T = L[_]; + O && r.set(O, { + name: z || "", + iata: T || "" + }); + } + const m = i.split(` +`), g = G(m[0]), y = g.indexOf("airport_ident"), k = g.indexOf("le_ident"), A = g.indexOf("he_ident"), R = g.indexOf("le_latitude_deg"), F = g.indexOf("le_longitude_deg"), S = g.indexOf("he_latitude_deg"), x = g.indexOf("he_longitude_deg"), $ = g.indexOf("le_heading_degT"), C = g.indexOf("he_heading_degT"), M = g.indexOf("length_ft"); + for (let w = 1; w < m.length; w++) { + const E = m[w].trim(); + if (!E) continue; + const L = G(E), O = L[y], z = L[k], T = L[A], c = r.get(O); + if (!c) continue; + const { name: p, iata: s } = c, h = O.startsWith(e), v = s && s.toUpperCase().startsWith(e), f = p.toUpperCase().includes(e), b = z && `${O}${z}`.includes(e), I = T && `${O}${T}`.includes(e); + if (!h && !v && !f && !b && !I) continue; + const H = re(e, O, s, p, z || T || ""); + if (z) { + const D = []; + s && D.push(s), D.push(O), D.push(`RWY${z}`), p && D.push(`- ${p}`), a.push({ + displayText: D.join(" "), + airportCode: O, + airportName: p, + iataCode: s, + runwayDesignator: z, + data: { + airportCode: O, + runwayDesignator: z, + latitude: parseFloat(L[R]), + longitude: parseFloat(L[F]), + heading: parseFloat(L[$]), + length: parseFloat(L[M]) + }, + score: H + }); + } + if (T) { + const D = []; + s && D.push(s), D.push(O), D.push(`RWY${T}`), p && D.push(`- ${p}`), a.push({ + displayText: D.join(" "), + airportCode: O, + airportName: p, + iataCode: s, + runwayDesignator: T, + data: { + airportCode: O, + runwayDesignator: T, + latitude: parseFloat(L[S]), + longitude: parseFloat(L[x]), + heading: parseFloat(L[C]), + length: parseFloat(L[M]) + }, + score: H + }); + } + } + return a.sort((w, E) => E.score - w.score).slice(0, 10).map(({ score: w, ...E }) => E); +} +var q, B, le = J((() => { + q = null, B = null; +})), de = /* @__PURE__ */ It({ Flightradar24CardEditor: () => ct }), ct, Tt = J((() => { + le(), Ft(), $t(), ct = class extends HTMLElement { + constructor() { + super(), this._config = {}, this._openSections = /* @__PURE__ */ new Set(["basic-settings"]), this._openConditions = /* @__PURE__ */ new Set(), this._openFeatures = /* @__PURE__ */ new Set(), this._openAnnotations = /* @__PURE__ */ new Set(), this._mapModal = null, this._internalUpdate = !1, this._shadowRoot = this.attachShadow({ mode: "open" }); + } + setConfig(t) { + this._config = { ...t }, this._internalUpdate || this._render(), this._internalUpdate = !1; + } + get availableFlightEntities() { + return this.hass ? Object.keys(this.hass.states).filter((t) => t.includes("flightradar")).sort() : []; + } + get availableTrackerEntities() { + return this.hass ? Object.keys(this.hass.states).filter((t) => t.startsWith("device_tracker.") || t.startsWith("person.") || t.startsWith("zone.")).sort() : []; + } + get availableFlightFields() { + return [ + { + value: "id", + label: "ID", + group: "Basic" + }, + { + value: "flight_number", + label: "Flight Number", + group: "Basic" + }, + { + value: "callsign", + label: "Callsign", + group: "Basic" + }, + { + value: "aircraft_registration", + label: "Aircraft Registration", + group: "Aircraft" + }, + { + value: "aircraft_model", + label: "Aircraft Model", + group: "Aircraft" + }, + { + value: "aircraft_code", + label: "Aircraft Code", + group: "Aircraft" + }, + { + value: "airline", + label: "Airline Name", + group: "Airline" + }, + { + value: "airline_short", + label: "Airline Short", + group: "Airline" + }, + { + value: "airline_iata", + label: "Airline IATA", + group: "Airline" + }, + { + value: "airline_icao", + label: "Airline ICAO", + group: "Airline" + }, + { + value: "airport_origin_name", + label: "Origin Airport", + group: "Origin" + }, + { + value: "airport_origin_code_iata", + label: "Origin IATA", + group: "Origin" + }, + { + value: "airport_origin_country_name", + label: "Origin Country", + group: "Origin" + }, + { + value: "airport_origin_country_code", + label: "Origin Country Code", + group: "Origin" + }, + { + value: "airport_destination_name", + label: "Destination Airport", + group: "Destination" + }, + { + value: "airport_destination_code_iata", + label: "Destination IATA", + group: "Destination" + }, + { + value: "airport_destination_country_name", + label: "Destination Country", + group: "Destination" + }, + { + value: "airport_destination_country_code", + label: "Destination Country Code", + group: "Destination" + }, + { + value: "latitude", + label: "Latitude", + group: "Position" + }, + { + value: "longitude", + label: "Longitude", + group: "Position" + }, + { + value: "altitude", + label: "Altitude", + group: "Position" + }, + { + value: "vertical_speed", + label: "Vertical Speed", + group: "Movement" + }, + { + value: "ground_speed", + label: "Ground Speed", + group: "Movement" + }, + { + value: "heading", + label: "Heading", + group: "Movement" + }, + { + value: "distance_to_tracker", + label: "Distance to Tracker", + group: "Tracking" + }, + { + value: "heading_from_tracker", + label: "Heading from Tracker", + group: "Tracking" + }, + { + value: "cardinal_direction_from_tracker", + label: "Cardinal Direction", + group: "Tracking" + }, + { + value: "is_approaching", + label: "Is Approaching", + group: "Tracking" + }, + { + value: "is_receding", + label: "Is Receding", + group: "Tracking" + }, + { + value: "closest_passing_distance", + label: "Closest Passing Distance", + group: "Approach" + }, + { + value: "eta_to_closest_distance", + label: "ETA to Closest", + group: "Approach" + }, + { + value: "heading_from_tracker_to_closest_passing", + label: "Heading to Closest", + group: "Approach" + } + ]; + } + _mapTypeRequiresApiKey(t) { + return xt(t); + } + _backgroundMapOptionsHtml(t) { + const e = (a, i) => `${X.filter((o) => o.group === i).map((o) => ``).join("")}`; + return e("Keyless", "keyless") + e("Requires API key", "keyed"); + } + _themeMapOptionsHtml(t) { + return this._backgroundMapOptionsHtml(t); + } + _themeApiKeyRowHtml(t, e, a, i) { + return this._mapTypeRequiresApiKey(a) ? ` +
+ +
+ + ${gt(a)} +
+
` : ""; + } + get validFlightFields() { + return new Set(this.availableFlightFields.map((t) => t.value)); + } + get allDefineAndToggleKeys() { + const t = /* @__PURE__ */ new Set(); + return Object.keys(this._config.toggles || {}).forEach((e) => t.add(e)), Object.keys(this._config.defines || {}).forEach((e) => t.add(e)), t; + } + getUsedDefinesAndToggles() { + const t = /* @__PURE__ */ new Set(), e = this._config.templates || {}, a = this._config.filter, i = this._config.sort || []; + Object.values(e).forEach((r) => { + const n = r.matchAll(/\$\{(\w+)\}/g); + for (const d of n) { + const l = d[1]; + this.allDefineAndToggleKeys.has(l) && t.add(l); + } + }); + const o = (r) => { + r.forEach((n) => { + if ("type" in n && (n.type === "AND" || n.type === "OR")) o(n.conditions || []); + else if ("type" in n && n.type === "NOT") o([n.condition]); + else { + const d = n; + d.field && this.allDefineAndToggleKeys.has(d.field) && t.add(d.field), d.defined && this.allDefineAndToggleKeys.has(d.defined) && t.add(d.defined); + const l = d.value; + if (typeof l == "string" && l.startsWith("${") && l.endsWith("}")) { + const u = l.slice(2, -1); + this.allDefineAndToggleKeys.has(u) && t.add(u); + } + } + }); + }; + return a && Array.isArray(a) && o(a), i.forEach((r) => { + r.field && this.allDefineAndToggleKeys.has(r.field) && t.add(r.field); + }), t; + } + getUnusedDefinesAndToggles() { + const t = this.getUsedDefinesAndToggles(), e = [], a = []; + return Object.keys(this._config.toggles || {}).forEach((i) => { + t.has(i) || e.push(i); + }), Object.keys(this._config.defines || {}).forEach((i) => { + t.has(i) || a.push(i); + }), { + toggles: e, + defines: a + }; + } + getUsedTemplateKeys() { + const t = /* @__PURE__ */ new Set(), e = this._config.templates || {}, a = { + ...W, + ...e + }; + return [ + "flight_element", + "radar_range", + "list_status" + ].forEach((i) => { + e[i] !== void 0 && t.add(i); + }), Object.values(a).forEach((i) => { + const o = i.matchAll(/\$\{(\w+)\([\s\S]*?\)\}/g); + for (const n of o) { + const d = n[1]; + e[d] !== void 0 && t.add(d); + } + const r = i.matchAll(/tpl\.(\w+)/g); + for (const n of r) { + const d = n[1]; + e[d] !== void 0 && t.add(d); + } + }), t; + } + getUnusedTemplates() { + const t = this.getUsedTemplateKeys(), e = this._config.templates || {}, a = []; + return Object.keys(e).forEach((i) => { + t.has(i) || a.push(i); + }), a; + } + validateConditionField(t) { + const e = t.split(" ?? "); + for (const a of e) + if (!this.validFlightFields.has(a) && !this.allDefineAndToggleKeys.has(a)) + return { + valid: !1, + error: `Unknown field: "${a}". Not a flight property or define/toggle.` + }; + return { valid: !0 }; + } + _renameConfigKey(t, e, a) { + if (e === a || !a.trim()) return; + const i = { ...this._config.templates }, o = new RegExp(`\\$\\{${e}\\}`, "g"); + for (const [d, l] of Object.entries(i)) + l.includes(`\${${e}}`) && (i[d] = l.replace(o, `\${${a}}`)), t === "template" && (l.includes(`\${tpl.${e}}`) || l.includes(`tpl.${e}`)) && (i[d] = l.replace(new RegExp(`tpl\\.${e}`, "g"), `tpl.${a}`)); + t === "template" && e in i && (i[a] = i[e], delete i[e]); + const r = this._config.filter ? JSON.parse(JSON.stringify(this._config.filter)) : void 0; + if (r) { + const d = (l) => { + l.forEach((u) => { + u.type === "AND" || u.type === "OR" ? d(u.conditions || []) : u.type === "NOT" ? d([u.condition]) : (u.field === e && (u.field = a), u.defined === e && (u.defined = a), typeof u.value == "string" && (u.value = u.value.replace(o, `\${${a}}`))); + }); + }; + d(r); + } + const n = (this._config.sort || []).map((d) => { + if (d.field === e) return { + ...d, + field: a + }; + if (d.field?.includes(" ?? ")) { + const l = d.field.split(" ?? ").map((u) => u === e ? a : u); + return { + ...d, + field: l.join(" ?? ") + }; + } + return d; + }); + this._config = { + ...this._config, + templates: Object.keys(i).length > 0 ? i : void 0, + filter: r && r.length > 0 ? r : void 0, + sort: n.length > 0 ? n : void 0 + }; + } + hasValidationErrors() { + const t = this.getUnusedDefinesAndToggles(); + if (t.toggles.length > 0 || t.defines.length > 0 || this.getUnusedTemplates().length > 0) return !0; + const e = this._config.filter; + if (e && Array.isArray(e) && this._checkConditionsForInvalidFields(e)) + return !0; + const a = this._config.sort || []; + for (const i of a) if (i.field && !this.validateConditionField(i.field).valid) + return !0; + return !1; + } + _checkConditionsForInvalidFields(t) { + for (const e of t) if ("type" in e && (e.type === "AND" || e.type === "OR")) { + if (this._checkConditionsForInvalidFields(e.conditions || [])) return !0; + } else if ("type" in e && e.type === "NOT") { + if (this._checkConditionsForInvalidFields([e.condition])) return !0; + } else { + const a = e; + if (a.field && !this.validateConditionField(a.field).valid || a.defined && !this.allDefineAndToggleKeys.has(a.defined)) return !0; + } + return !1; + } + _render() { + this.hass && (this._saveOpenSections(), this._shadowRoot.innerHTML = ` @@ -254,7 +1729,34 @@ ${this._renderTogglesAndDefinesConfig()} ${this._renderTemplatesConfig()}
- `,this._attachEventListeners(),this._restoreOpenSections())}_saveOpenSections(){this._shadowRoot.querySelectorAll("details").forEach(t=>{const e=t.getAttribute("data-section-id");e&&(t.open?this._openSections.add(e):this._openSections.delete(e));const a=t.getAttribute("data-condition-path");a&&(t.open?this._openConditions.add(a):this._openConditions.delete(a));const i=t.getAttribute("data-feature-id");i&&(t.open?this._openFeatures.add(i):this._openFeatures.delete(i));const o=t.getAttribute("data-annotation-id");o&&(t.open?this._openAnnotations.add(o):this._openAnnotations.delete(o))})}_restoreOpenSections(){this._shadowRoot.querySelectorAll("details").forEach(t=>{const e=t.getAttribute("data-section-id");e&&this._openSections.has(e)&&(t.open=!0);const a=t.getAttribute("data-condition-path");a&&this._openConditions.has(a)&&(t.open=!0);const i=t.getAttribute("data-feature-id");i&&this._openFeatures.has(i)&&(t.open=!0);const o=t.getAttribute("data-annotation-id");o&&this._openAnnotations.has(o)&&(t.open=!0)})}_getStyles(){return` + `, this._attachEventListeners(), this._restoreOpenSections()); + } + _saveOpenSections() { + this._shadowRoot.querySelectorAll("details").forEach((t) => { + const e = t.getAttribute("data-section-id"); + e && (t.open ? this._openSections.add(e) : this._openSections.delete(e)); + const a = t.getAttribute("data-condition-path"); + a && (t.open ? this._openConditions.add(a) : this._openConditions.delete(a)); + const i = t.getAttribute("data-feature-id"); + i && (t.open ? this._openFeatures.add(i) : this._openFeatures.delete(i)); + const o = t.getAttribute("data-annotation-id"); + o && (t.open ? this._openAnnotations.add(o) : this._openAnnotations.delete(o)); + }); + } + _restoreOpenSections() { + this._shadowRoot.querySelectorAll("details").forEach((t) => { + const e = t.getAttribute("data-section-id"); + e && this._openSections.has(e) && (t.open = !0); + const a = t.getAttribute("data-condition-path"); + a && this._openConditions.has(a) && (t.open = !0); + const i = t.getAttribute("data-feature-id"); + i && this._openFeatures.has(i) && (t.open = !0); + const o = t.getAttribute("data-annotation-id"); + o && this._openAnnotations.has(o) && (t.open = !0); + }); + } + _getStyles() { + return ` .editor-container { position: relative; z-index: 1000; @@ -370,6 +1872,21 @@ margin: 2px 0; line-height: 1.3; } + .input-with-help { + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + } + .input-with-help .full-width { + flex: 1; + min-width: 0; + } + .input-with-help .help-text { + flex: 0 0 auto; + max-width: 60%; + margin: 0; + } .item-box { border: 1px solid var(--divider-color, #ccc); border-radius: 4px; @@ -738,7 +2255,10 @@ position: relative; z-index: 1; } - `}_renderBasicSettings(){return` + `; + } + _renderBasicSettings() { + return `

Basic

@@ -746,7 +2266,7 @@
@@ -754,20 +2274,20 @@
- ${this._config.location_tracker?"":` + ${this._config.location_tracker ? "" : `
+ value="${this._config.location?.lat ?? ""}" placeholder="63.4041" />
+ value="${this._config.location?.lon ?? ""}" placeholder="10.4301" />
`} @@ -776,23 +2296,23 @@
@@ -800,36 +2320,42 @@
+ value="${this._config.max_flights ?? ""}" placeholder="unlimited" />
- `}_renderAdvancedSettings(){const t=this._config.annotate||[];JSON.stringify(t,null,2);const e=this._config.filter||[];return` + `; + } + _renderAdvancedSettings() { + const t = this._config.annotate || []; + JSON.stringify(t, null, 2); + const e = this._config.filter || []; + return `

Advanced

+ value="${this._config.projection_interval ?? 1e3}" /> Flight position update frequency
+ value="${this._config.scale ?? 1}" /> Card zoom level - Use with caution: values > 1 may cause the card to overflow and break page layout

Filter

- ${e.length>0&&this._checkConditionsForInvalidFields(e)?'⚠️':""} + ${e.length > 0 && this._checkConditionsForInvalidFields(e) ? '⚠️' : ""}

Filter which flights are displayed. All top-level conditions must match (implicit AND).

- ${e.length>0?this._renderConditionsList(e,"filter"):'

No filters defined

'} + ${e.length > 0 ? this._renderConditionsList(e, "filter") : '

No filters defined

'}
@@ -842,32 +2368,35 @@

Sort

- ${(this._config.sort||[]).some(a=>a.field?!this.validateConditionField(a.field).valid:!1)?'⚠️':""} + ${(this._config.sort || []).some((a) => a.field ? !this.validateConditionField(a.field).valid : !1) ? '⚠️' : ""}

Define how flights are sorted in the list

- ${(this._config.sort||[]).map((a,i)=>{const o=a.field?this.validateConditionField(a.field):{valid:!0},r=!o.valid;return` -
+ ${(this._config.sort || []).map((a, i) => { + const o = a.field ? this.validateConditionField(a.field) : { valid: !0 }, r = !o.valid; + return ` +
- Sort ${i+1} - ${r?`⚠️`:""} + Sort ${i + 1} + ${r ? `⚠️` : ""}
- +
- ${r?`

${o.error||"Invalid field"}

`:""} + ${r ? `

${o.error || "Invalid field"}

` : ""}
- `}).join("")} + `; + }).join("")}
@@ -878,42 +2407,63 @@

Conditional rendering with custom templates for specific flight fields

- ${t.length>0?t.map((a,i)=>this._renderAnnotation(a,i)).join(""):'

No annotations defined

'} + ${t.length > 0 ? t.map((a, i) => this._renderAnnotation(a, i)).join("") : '

No annotations defined

'}
- `}_renderRadarConfig(){const t=this._config.radar||{},e=(this._config.units?.distance||"km")==="miles"?"miles":"km";return` + `; + } + _renderRadarConfig() { + const t = this._config.radar || {}, e = (this._config.units?.distance || "km") === "miles" ? "miles" : "km"; + return `

Radar

+
+ + + "Map" displays a square map that fills the available width (great for fullscreen and wide layouts) instead of the circular radar screen. The radar size setting is ignored in map view. +
+ +
+ + Draw the radar grid rings and bearing lines on top of the display. On by default for the circular "Radar" view and off by default for the "Map" view; tick or untick to override. +
+

Range

- +
- +
- +
- +
@@ -923,27 +2473,27 @@
- +
- +
- +
- +
- +
- +
@@ -954,23 +2504,69 @@
- ${["small","normal","large","x-large","xx-large"].map(a=>{const i=(t["aircraft-marker-size"]||"normal")===a,o={small:.7,normal:1,large:1.4,"x-large":2,"xx-large":2.8}[a],r=t["background-color"]||t["primary-color"]||"#1a1a1a",n=t["aircraft-color"]||t["accent-color"]||"#ff0000",d=t["background-opacity"]??.05;return` - - `}).join("")} + `; + }).join("")}
+
+ Custom Image Marker +

Use a PNG image as aircraft marker instead of the default triangle. Image should have a transparent background.

+ ${(() => { + const a = t["aircraft-marker"]?.default || {}; + return ` +
+ + +
+
+ + + Extra rotation if the image does not point due north +
+
+ + + Offset from image center for rotation pivot (px) +
+
+ + +
+
+ + + Fills the image shape with this color +
`; + })()} +
@@ -980,30 +2576,41 @@
- ${this._mapTypeRequiresApiKey(t.background_map)?` -
- - - Required for Black & White and Outlines map types. Get a free API key -
- `:""} + ${t.background_map === "system" ? ` +

Pick one map for light themes and one for dark themes. Each map can have its own API key.

+
+ + +
+ ${this._themeApiKeyRowHtml("light", "Light Map API Key", t.background_map_light || "color", t.background_map_light_api_key)} +
+ + +
+ ${this._themeApiKeyRowHtml("dark", "Dark Map API Key", t.background_map_dark || "dark", t.background_map_dark_api_key)} + ` : this._mapTypeRequiresApiKey(t.background_map) ? ` +
+ +
+ + ${gt(t.background_map)} +
+
+ ` : ""}
+ value="${t.background_map_opacity ?? 0.3}" />
@@ -1013,7 +2620,7 @@

Add custom locations, runways, and outlines to the radar

- ${(t.local_features||[]).map((a,i)=>this._renderLocalFeature(a,i)).join("")} + ${(t.local_features || []).map((a, i) => this._renderLocalFeature(a, i)).join("")}
@@ -1022,52 +2629,73 @@
+ + ${this._renderTapActionsConfig()} - `}_renderListConfig(){const t=this._config.list||{};return` + `; + } + _renderListConfig() { + const t = this._config.list || {}; + return `

Flight List

+
+ + + Place the flight list to the side of the radar/map on wide cards. If the card is too narrow to fit the flight list side by side, it automatically falls back to the "below" layout. +
+ value="${this._config.no_flights_message ?? ""}" placeholder="No flights in range" />
- `}_renderTogglesAndDefinesConfig(){const t=this._config.defines||{},e=this._config.toggles||{},a=this.getUnusedDefinesAndToggles();return` + `; + } + _renderTogglesAndDefinesConfig() { + const t = this._config.defines || {}, e = this._config.toggles || {}, a = this.getUnusedDefinesAndToggles(); + return `

Toggles & Defines

- ${a.toggles.length>0||a.defines.length>0?'⚠️':""} + ${a.toggles.length > 0 || a.defines.length > 0 ? '⚠️' : ""}

Toggles

- ${a.toggles.length>0?`⚠️`:""} + ${a.toggles.length > 0 ? `⚠️` : ""}

UI buttons that set define values dynamically

- ${Object.entries(e).map(([i,o])=>{const r=a.toggles.includes(i);return` -
+ ${Object.entries(e).map(([i, o]) => { + const r = a.toggles.includes(i); + return ` +
- - ${r?'⚠️':""} + + ${r ? '⚠️' : ""}
@@ -1075,13 +2703,14 @@
- `}).join("")} + `; + }).join("")}
@@ -1090,17 +2719,19 @@

Defines

- ${a.defines.length>0?`⚠️`:""} + ${a.defines.length > 0 ? `⚠️` : ""}

Reusable values referenced as \${defineName} in filters and sort

- ${Object.entries(t).map(([i,o])=>{const r=a.defines.includes(i);return` -
+ ${Object.entries(t).map(([i, o]) => { + const r = a.defines.includes(i); + return ` +
- - ${r?'⚠️':""} + + ${r ? '⚠️' : ""}
@@ -1108,30 +2739,66 @@
- `}).join("")} + `; + }).join("")}
- `}_renderTemplatesConfig(){const t=this._config.templates||{},e=["flight_element","radar_range","list_status"],a=Object.keys(V).filter(n=>!(n in t)),i=a.filter(n=>e.includes(n)),o=a.filter(n=>!e.includes(n)),r=this.getUnusedTemplates();return` + `; + } + _renderTapActionsConfig() { + return ` +
+

Tap Actions

+
+

Configure what happens when tapping on the radar or flights (only applies when Show Radar is enabled)

+
+
+ + +
+

Available variables: \${map_lat}, \${map_lon}, \${zoom}, \${radar_range}, \${click_lat}, \${click_lon}, \${entity.state}, \${flight.*}

+
+
+
+ + +
+

Enter "toggle", a URL, or "toggle|URL". Leave empty for default toggle behavior.

+

Available variables: \${map_lat}, \${map_lon}, \${zoom}, \${radar_range}, \${flight.callsign}, \${flight.latitude}, \${flight.longitude}, \${entity.state}

+
+
+
+ `; + } + _renderTemplatesConfig() { + const t = this._config.templates || {}, e = [ + "flight_element", + "radar_range", + "list_status" + ], a = Object.keys(W).filter((n) => !(n in t)), i = a.filter((n) => e.includes(n)), o = a.filter((n) => !e.includes(n)), r = this.getUnusedTemplates(); + return `

Templates

- ${r.length>0?`⚠️`:""} + ${r.length > 0 ? `⚠️` : ""}

Customize HTML templates for flight list items using \${flight.field} placeholders. Main templates are used directly by renderers; helper templates are used by other templates.

- ${Object.entries(t).map(([n,d])=>{const s=e.includes(n),f=r.includes(n);return` -
+ ${Object.entries(t).map(([n, d]) => { + const l = e.includes(n), u = r.includes(n); + return ` +
- - ${s?'MAIN':""} - ${f?'⚠️':""} + + ${l ? 'MAIN' : ""} + ${u ? '⚠️' : ""}
@@ -1140,7 +2807,8 @@
- `}).join("")} + `; + }).join("")}
New custom template...
- ${i.length>0?` + ${i.length > 0 ? `
Main Templates (used by renderers)
- ${i.map(n=>` + ${i.map((n) => `
${n}
`).join("")} - `:""} - ${o.length>0?` + ` : ""} + ${o.length > 0 ? `
Helper Templates (used by other templates)
- ${o.map(n=>` + ${o.map((n) => `
${n}
`).join("")} - `:""} + ` : ""}
- `}_renderLocalFeature(t,e){if(t.type==="location"){const a=t;return` + `; + } + _renderLocalFeature(t, e) { + if (t.type === "location") { + const a = t; + return `
- Location: ${a.label||"Unnamed"} + Location: ${a.label || "Unnamed"}
- +
@@ -1186,11 +2859,14 @@
- +
- `}else if(t.type==="runway"){const a=t;return` + `; + } else if (t.type === "runway") { + const a = t; + return `
Runway (${a.heading}°) @@ -1225,11 +2901,14 @@
- +
- `}else if(t.type==="outline"){const a=t,i=JSON.stringify(a.points);return` + `; + } else if (t.type === "outline") { + const a = t, i = JSON.stringify(a.points); + return `
Outline (${a.points.length} points) @@ -1244,14 +2923,20 @@

Array of {"lat": number, "lon": number} objects

- +
- `}return""}_renderAnnotation(t,e){const a=t.conditions||[];return` + `; + } + return ""; + } + _renderAnnotation(t, e) { + const a = t.conditions || []; + return `
- Annotation: ${t.field||"Unnamed"} + Annotation: ${t.field || "Unnamed"}
@@ -1259,16 +2944,22 @@
- +
@@ -1276,7 +2967,7 @@

Define when this annotation should be displayed. All conditions must match (implicit AND).

- ${a.length>0?this._renderConditionsList(a,`annotate:${e}`):'

No conditions defined

'} + ${a.length > 0 ? this._renderConditionsList(a, `annotate:${e}`) : '

No conditions defined

'}
@@ -1287,75 +2978,100 @@
- `}_renderConditionsList(t,e){return t.map((a,i)=>this._renderCondition(a,`${e}:${i}`)).join("")}_renderCondition(t,e){return"type"in t?t.type==="NOT"?this._renderNotCondition(t,e):this._renderGroupCondition(t,e):this._renderFieldCondition(t,e)}_renderFieldCondition(t,e){const a=this._getConditionDescription(t),i=!!t.defined,o=Object.keys(this._config.defines||{}),r=Object.keys(this._config.toggles||{}),n=[...o,...r],d=i?t.defined:t.field,s=d?this.validateConditionField(d):{valid:!0},f=!s.valid;this._formatValue(t.value);const b=typeof t.value=="string"&&t.value.startsWith("${")&&t.value.endsWith("}"),_=b?t.value.slice(2,-1):"";return` -
+ `; + } + _renderConditionsList(t, e) { + return t.map((a, i) => this._renderCondition(a, `${e}:${i}`)).join(""); + } + _renderCondition(t, e) { + return "type" in t ? t.type === "NOT" ? this._renderNotCondition(t, e) : this._renderGroupCondition(t, e) : this._renderFieldCondition(t, e); + } + _renderFieldCondition(t, e) { + const a = this._getConditionDescription(t), i = !!t.defined, o = Object.keys(this._config.defines || {}), r = Object.keys(this._config.toggles || {}), n = [...o, ...r], d = i ? t.defined : t.field, l = d ? this.validateConditionField(d) : { valid: !0 }, u = !l.valid; + this._formatValue(t.value); + const _ = typeof t.value == "string" && t.value.startsWith("${") && t.value.endsWith("}"), m = _ ? t.value.slice(2, -1) : ""; + return ` +
Value ${a} - ${f?`⚠️`:""} + ${u ? `⚠️` : ""}
- ${i?n.length>0?` - - ${n.map(v=>{const y=this._config.toggles&&v in this._config.toggles?`toggle: ${this._config.toggles[v].label}`:this._formatValueForDisplay(this._config.defines[v]);return``}).join("")} + ${n.map((g) => { + const y = this._config.toggles && g in this._config.toggles ? `toggle: ${this._config.toggles[g].label}` : this._formatValueForDisplay(this._config.defines[g]); + return ``; + }).join("")} - `:` + ` : ` - `:` - - ${this.availableFlightFields.map((v,y,C)=>{const M=y>0?C[y-1].group:null;return`${v.group&&v.group!==M?``:""}`}).join("")} + ${this.availableFlightFields.map((g, y, k) => { + const A = y > 0 ? k[y - 1].group : null; + return `${g.group && g.group !== A ? `` : ""}`; + }).join("")} `}
- ${f?`

${s.error||"Invalid field"}

`:""} + ${u ? `

${l.error || "Invalid field"}

` : ""}
- ${b&&n.length>0?` + ${_ && n.length > 0 ? ` - `:` + ` : ` + value="${_ ? m : this._formatValue(t.value)}" placeholder="Value or comma-separated list" /> `}
- ${t.defaultValue!==void 0?` + ${t.defaultValue !== void 0 ? `
- `:""} + ` : ""}
- `}_renderGroupCondition(t,e){const a=this._getConditionDescription(t);return` + `; + } + _renderGroupCondition(t, e) { + const a = this._getConditionDescription(t); + return `
${t.type} @@ -1367,12 +3083,12 @@
- ${t.conditions.length>0?this._renderConditionsList(t.conditions,e):'

No conditions in this group

'} + ${t.conditions.length > 0 ? this._renderConditionsList(t.conditions, e) : '

No conditions in this group

'}
@@ -1381,7 +3097,10 @@
- `}_renderNotCondition(t,e){return` + `; + } + _renderNotCondition(t, e) { + return `
NOT @@ -1390,13 +3109,1144 @@ onclick="event.preventDefault(); event.stopPropagation();">Remove
- ${this._renderCondition(t.condition,e)} + ${this._renderCondition(t.condition, e)}
- `}_getConditionDescription(t){if("type"in t){if(t.type==="NOT")return this._getConditionDescription(t.condition);{const e=t,a=e.conditions.length;if(a===0)return"(empty group)";const i=e.conditions.slice(0,2).map(n=>{const d=this._getConditionDescription(n);return d.length>30?d.substring(0,27)+"...":d}),o=a-i.length,r=i.join(` ${e.type} `);return o>0?`${r} + ${o} more`:r}}else{const e=t;return`${e.defined?`\${${e.defined}}`:e.field||"(no field)"} ${this._getComparatorSymbol(e.comparator)} ${this._formatValueForDisplay(e.value)}`}}_getComparatorSymbol(t){switch(t){case"eq":return"=";case"lt":return"<";case"lte":return"≤";case"gt":return">";case"gte":return"≥";case"oneOf":return"in";case"containsOneOf":return"contains";default:return t}}_formatValueForDisplay(t){if(Array.isArray(t))return t.length>2?`[${t.slice(0,2).join(", ")}, +${t.length-2}]`:`[${t.join(", ")}]`;const e=String(t??"");return e==="${}"?"(select define)":e.length>20?e.substring(0,17)+"...":e}_formatValue(t){return Array.isArray(t)?t.join(", "):String(t??"")}_attachEventListeners(){const t=this._shadowRoot,e=t.getElementById("flights-entity");e&&e.addEventListener("change",u=>{this._config={...this._config,flights_entity:u.target.value},this._emitConfigChanged()});const a=t.getElementById("location-tracker");a&&a.addEventListener("change",u=>{const h=u.target.value;if(this._config={...this._config,location_tracker:h||void 0},h){const{location:c,...g}=this._config;this._config=g}this._emitConfigChanged(),this._render()}),["lat","lon"].forEach(u=>{const h=t.getElementById(`location-${u}`);h&&h.addEventListener("input",c=>{const g=parseFloat(c.target.value);if(!isNaN(g)){const m=this._config.location||{lat:0,lon:0};this._config={...this._config,location:{...m,[u]:g}},this._emitConfigChanged()}})}),["altitude","speed","distance"].forEach(u=>{const h=t.getElementById(`unit-${u}`);h&&h.addEventListener("change",c=>{const g=this._config.units||{};this._config={...this._config,units:{...g,[u]:c.target.value}},this._emitConfigChanged(),u==="distance"&&this._render()})});const i=t.getElementById("projection-interval");i&&i.addEventListener("input",u=>{this._config={...this._config,projection_interval:parseInt(u.target.value)},this._emitConfigChanged()});const o=t.getElementById("scale");o&&o.addEventListener("input",u=>{this._config={...this._config,scale:parseFloat(u.target.value)},this._emitConfigChanged()});const r=t.getElementById("max-flights");r&&r.addEventListener("input",u=>{const h=u.target.value,c=parseInt(h);this._config={...this._config,max_flights:!h||isNaN(c)||c<=0?void 0:c},this._emitConfigChanged()});const n=t.getElementById("radar-show");n&&n.addEventListener("change",u=>{const h=this._config.radar||{},c=u.target.checked?void 0:!0;this._config={...this._config,radar:{...h,hide:c}},this._emitConfigChanged()}),["range","min-range","max-range","ring-distance"].forEach(u=>{const h=t.getElementById(`radar-${u}`);h&&h.addEventListener("input",c=>{const g=this._config.radar||{},m=u.replace(/-/g,"_");this._config={...this._config,radar:{...g,[m]:parseFloat(c.target.value)}},this._emitConfigChanged()})}),["background-color","aircraft-color","aircraft-selected-color","radar-grid-color","local-features-color"].forEach(u=>{const h=u.startsWith("radar-")?u:`radar-${u}`,c=t.getElementById(h);c&&c.addEventListener("input",g=>{const m=this._config.radar||{},l={...this._config};u==="background-color"&&l.radar&&delete l.radar["primary-color"],u==="aircraft-color"&&l.radar&&delete l.radar["accent-color"],(u==="radar-grid-color"||u==="local-features-color")&&l.radar&&delete l.radar["feature-color"],this._config={...l,radar:{...m,[u]:g.target.value}},this._emitConfigChanged(),(u==="background-color"||u==="aircraft-color")&&this._render()})});const d=t.getElementById("radar-background-opacity");d&&d.addEventListener("input",u=>{const h=this._config.radar||{};this._config={...this._config,radar:{...h,"background-opacity":parseFloat(u.target.value)}},this._emitConfigChanged(),this._render()}),t.querySelectorAll(".marker-size-option").forEach(u=>{u.addEventListener("click",h=>{const c=h.currentTarget.getAttribute("data-size"),g=this._config.radar||{};this._config={...this._config,radar:{...g,"aircraft-marker-size":c==="normal"?void 0:c}},this._emitConfigChanged(),this._render()})});const s=t.getElementById("radar-background-map");s&&s.addEventListener("change",u=>{const h=this._config.radar||{};this._config={...this._config,radar:{...h,background_map:u.target.value}},this._emitConfigChanged(),this._render()});const f=t.getElementById("radar-background-map-api-key");f&&f.addEventListener("input",u=>{const h=this._config.radar||{},c=u.target.value;this._config={...this._config,radar:{...h,background_map_api_key:c||void 0}},this._emitConfigChanged()});const b=t.getElementById("radar-background-map-opacity");b&&b.addEventListener("input",u=>{const h=this._config.radar||{};this._config={...this._config,radar:{...h,background_map_opacity:parseFloat(u.target.value)}},this._emitConfigChanged()});const _=t.getElementById("list-show");_&&_.addEventListener("change",u=>{const h=this._config.list||{},c=u.target.checked?void 0:!0;this._config={...this._config,list:{...h,hide:c}},this._emitConfigChanged()});const v=t.getElementById("list-show-status");v&&v.addEventListener("change",u=>{const h=this._config.list||{},c=u.target.checked?void 0:!1;this._config={...this._config,list:{...h,showListStatus:c}},this._emitConfigChanged()});const y=t.getElementById("no-flights-message");y&&y.addEventListener("input",u=>{this._config={...this._config,no_flights_message:u.target.value},this._emitConfigChanged()});const C=t.getElementById("add-template-button"),M=t.getElementById("template-dropdown");C&&M&&(C.addEventListener("click",u=>{u.stopPropagation();const h=M.classList.contains("open");t.querySelectorAll(".template-dropdown").forEach(c=>c.classList.remove("open")),h||M.classList.add("open")}),M.querySelectorAll(".template-dropdown-item").forEach(u=>{u.addEventListener("click",h=>{h.stopPropagation();const c=h.target.getAttribute("data-template-key");if(!c)return;const g=this._config.templates||{};if(c==="__custom__"){let m=1;for(;g[`template${m}`];)m++;this._config={...this._config,templates:{...g,[`template${m}`]:""}}}else{const m=V[c];this._config={...this._config,templates:{...g,[c]:m}}}this._emitConfigChanged(),this._render(),M.classList.remove("open")})}),document.addEventListener("click",()=>{M.classList.remove("open")})),t.querySelectorAll("[data-action]").forEach(u=>{u.addEventListener("click",h=>{const c=h.target.getAttribute("data-action"),g=h.target.getAttribute("data-index"),m=h.target.getAttribute("data-key");if(c==="add-sort"){const l=this._config.sort||[];this._config={...this._config,sort:[...l,{field:"distance",order:"asc"}]},this._emitConfigChanged(),this._render()}else if(c==="remove-sort"&&g){const l=[...this._config.sort||[]];l.splice(parseInt(g),1),this._config={...this._config,sort:l.length>0?l:void 0},this._emitConfigChanged(),this._render()}else if(c==="add-define"){const l=this._config.defines||{};let p=1;for(;l[`define${p}`];)p++;this._config={...this._config,defines:{...l,[`define${p}`]:""}},this._emitConfigChanged(),this._render()}else if(c==="remove-define"&&m){const l={...this._config.defines};delete l[m],this._config={...this._config,defines:Object.keys(l).length>0?l:void 0},this._emitConfigChanged(),this._render()}else if(c==="add-toggle"){const l=this._config.toggles||{};let p=1;for(;l[`toggle${p}`];)p++;this._config={...this._config,toggles:{...l,[`toggle${p}`]:{label:"Toggle",default:!1}}},this._emitConfigChanged(),this._render()}else if(c==="remove-toggle"&&m){const l={...this._config.toggles};delete l[m],this._config={...this._config,toggles:Object.keys(l).length>0?l:void 0},this._emitConfigChanged(),this._render()}else if(c==="remove-template"&&m){const l={...this._config.templates};delete l[m],this._config={...this._config,templates:Object.keys(l).length>0?l:void 0},this._emitConfigChanged(),this._render()}else if(c==="add-filter-condition"){const l=this._config.filter||[],p=l.length;this._config={...this._config,filter:[...l,{field:"",comparator:"eq",value:""}]},this._openConditions.add(`filter:${p}`),this._emitConfigChanged(),this._render()}else if(c==="add-filter-group"){const l=this._config.filter||[],p=l.length;this._config={...this._config,filter:[...l,{type:"AND",conditions:[]}]},this._openConditions.add(`filter:${p}`),this._emitConfigChanged(),this._render()}else if(c==="add-filter-not"){const l=this._config.filter||[],p=l.length;this._config={...this._config,filter:[...l,{type:"NOT",condition:{field:"",comparator:"eq",value:""}}]},this._openConditions.add(`filter:${p}`),this._emitConfigChanged(),this._render()}else if(c==="remove-condition"){const l=h.target.getAttribute("data-path");l&&(this._removeConditionAtPath(l),this._emitConfigChanged(),this._render())}else if(c==="add-group-condition"){const l=h.target.getAttribute("data-path");if(l){const p=this._addConditionToGroup(l,{field:"",comparator:"eq",value:""});p&&this._openConditions.add(p),this._emitConfigChanged(),this._render()}}else if(c==="add-group-group"){const l=h.target.getAttribute("data-path");if(l){const p=this._addConditionToGroup(l,{type:"AND",conditions:[]});p&&this._openConditions.add(p),this._emitConfigChanged(),this._render()}}else if(c==="add-group-not"){const l=h.target.getAttribute("data-path");if(l){const p=this._addConditionToGroup(l,{type:"NOT",condition:{field:"",comparator:"eq",value:""}});p&&this._openConditions.add(p),this._emitConfigChanged(),this._render()}}else if(c==="add-local-feature-location"){const l=this._config.radar||{},p=l.local_features||[],x=p.length;this._config={...this._config,radar:{...l,local_features:[...p,{type:"location",label:"",position:{lat:0,lon:0}}]}},this._openFeatures.add(`feature-${x}`),this._emitConfigChanged(),this._render()}else if(c==="add-local-feature-runway"){const l=this._config.radar||{},p=l.local_features||[],x=p.length;this._config={...this._config,radar:{...l,local_features:[...p,{type:"runway",position:{lat:0,lon:0},heading:0,length:0}]}},this._openFeatures.add(`feature-${x}`),this._emitConfigChanged(),this._render()}else if(c==="add-local-feature-outline"){const l=this._config.radar||{},p=l.local_features||[],x=p.length;this._config={...this._config,radar:{...l,local_features:[...p,{type:"outline",points:[]}]}},this._openFeatures.add(`feature-${x}`),this._emitConfigChanged(),this._render()}else if(c==="remove-local-feature"&&g){const l=this._config.radar||{},p=[...l.local_features||[]];p.splice(parseInt(g),1),this._config={...this._config,radar:{...l,local_features:p.length>0?p:void 0}},this._emitConfigChanged(),this._render()}else if(c==="select-location-on-map"&&g)this._openMapModal("location",parseInt(g));else if(c==="draw-outline-on-map"&&g)this._openMapModal("outline",parseInt(g));else if(c==="add-annotation"){const l=this._config.annotate||[],p=l.length;this._config={...this._config,annotate:[...l,{field:"",render:"",conditions:[]}]},this._openAnnotations.add(`annotation-${p}`),this._emitConfigChanged(),this._render()}else if(c==="remove-annotation"&&g){const l=[...this._config.annotate||[]];l.splice(parseInt(g),1),this._config={...this._config,annotate:l.length>0?l:void 0},this._emitConfigChanged(),this._render()}else if(c==="add-annotation-condition"&&g){const l=[...this._config.annotate||[]],p=parseInt(g);if(l[p]){const x=(l[p].conditions||[]).length;l[p]={...l[p],conditions:[...l[p].conditions||[],{field:"",comparator:"eq",value:""}]},this._config={...this._config,annotate:l},this._openConditions.add(`annotate:${p}:${x}`),this._emitConfigChanged(),this._render()}}else if(c==="add-annotation-group"&&g){const l=[...this._config.annotate||[]],p=parseInt(g);if(l[p]){const x=(l[p].conditions||[]).length;l[p]={...l[p],conditions:[...l[p].conditions||[],{type:"AND",conditions:[]}]},this._config={...this._config,annotate:l},this._openConditions.add(`annotate:${p}:${x}`),this._emitConfigChanged(),this._render()}}else if(c==="add-annotation-not"&&g){const l=[...this._config.annotate||[]],p=parseInt(g);if(l[p]){const x=(l[p].conditions||[]).length;l[p]={...l[p],conditions:[...l[p].conditions||[],{type:"NOT",condition:{field:"",comparator:"eq",value:""}}]},this._config={...this._config,annotate:l},this._openConditions.add(`annotate:${p}:${x}`),this._emitConfigChanged(),this._render()}}})}),t.querySelectorAll("[data-runway-index]").forEach(u=>{const h=parseInt(u.getAttribute("data-runway-index")),c=t.getElementById(`runway-dropdown-${h}`),g=t.getElementById(`runway-lookup-status-${h}`);let m;u.addEventListener("input",l=>{const p=l.target.value.trim();if(clearTimeout(m),!p||p.length<2){c&&(c.style.display="none"),g&&(g.textContent="");return}c&&(c.innerHTML='
⏳ Searching runways...
',c.style.display="block"),m=window.setTimeout(()=>{Rt(p).then(x=>{c&&(x.length===0?c.innerHTML='
No runways found
':(c.innerHTML=x.map(k=>`
${k.displayText}
`).join(""),c.querySelectorAll(".runway-dropdown-item").forEach(k=>{k.addEventListener("click",()=>{const w=JSON.parse(k.getAttribute("data-runway-result")),$=this._config.radar||{},A=[...$.local_features||[]],L={...A[h]};L.position={lat:w.latitude,lon:w.longitude},L.heading=Math.round(w.heading),L.length=Math.round(w.length),A[h]=L,this._config={...this._config,radar:{...$,local_features:A}},this._emitConfigChanged(),u.value=`${w.airportCode} ${w.runwayDesignator}`,c.style.display="none",g&&(g.style.color="var(--success-color, #43a047)",g.textContent=`✓ ${w.airportCode} RWY${w.runwayDesignator} - ${Math.round(w.length)}ft`),this._render()})})))}).catch(x=>{c&&(c.innerHTML=`
Error: ${x.message}
`),g&&(g.style.color="var(--error-color, #f44336)",g.textContent=`❌ ${x.message}`)})},300)}),u.addEventListener("blur",()=>{setTimeout(()=>{c&&(c.style.display="none")},200)})}),t.querySelectorAll(".condition-field-type").forEach(u=>{const h=u.getAttribute("data-path"),c=u.getAttribute("data-target");h&&u.addEventListener("change",g=>{const m=g.target.value;c==="value"?this._switchConditionValueType(h,m):this._switchConditionFieldType(h,m),this._emitConfigChanged(),this._render()})}),t.querySelectorAll(".condition-field").forEach(u=>{const h=u.getAttribute("data-path"),c=u.getAttribute("data-prop");if(h&&c){u.addEventListener("input",m=>{this._updateConditionAtPath(h,c,m.target.value),this._emitConfigChanged()});const g=()=>{this._render()};u.tagName==="SELECT"?u.addEventListener("change",g):u.addEventListener("blur",g)}}),t.querySelectorAll(".condition-field-value-defined").forEach(u=>{const h=u.getAttribute("data-path");h&&u.addEventListener("change",c=>{const g=c.target.value,m=g?`\${${g}}`:"${}";this._updateConditionAtPath(h,"value",m),this._emitConfigChanged(),this._render()})}),t.querySelectorAll("[data-define-value]").forEach(u=>{u.addEventListener("input",h=>{const c=h.target.getAttribute("data-define-value"),g={...this._config.defines};g[c]=h.target.value,this._config={...this._config,defines:g},this._emitConfigChanged()})}),t.querySelectorAll("[data-toggle-label]").forEach(u=>{u.addEventListener("input",h=>{const c=h.target.getAttribute("data-toggle-label"),g={...this._config.toggles};g[c]={...g[c],label:h.target.value},this._config={...this._config,toggles:g},this._emitConfigChanged()})}),t.querySelectorAll("[data-toggle-default]").forEach(u=>{u.addEventListener("change",h=>{const c=h.target.getAttribute("data-toggle-default"),g={...this._config.toggles},m=h.target.checked?!0:void 0;g[c]={...g[c],default:m},this._config={...this._config,toggles:g},this._emitConfigChanged()})}),t.querySelectorAll("[data-template-value]").forEach(u=>{u.addEventListener("input",h=>{const c=h.target.getAttribute("data-template-value"),g={...this._config.templates};g[c]=h.target.value,this._config={...this._config,templates:g},this._emitConfigChanged()})}),t.querySelectorAll("[data-sort-prop]").forEach(u=>{u.addEventListener("input",h=>{const[c,g]=h.target.getAttribute("data-sort-prop").split(":"),m=[...this._config.sort||[]];m[parseInt(c)]={...m[parseInt(c)],[g]:h.target.value},this._config={...this._config,sort:m},this._emitConfigChanged()})}),t.querySelectorAll("[data-feature-prop]").forEach(u=>{const[h,c]=u.getAttribute("data-feature-prop").split(":");u.addEventListener("input",g=>{const m=this._config.radar||{},l=[...m.local_features||[]],p={...l[parseInt(h)]};if(c==="label")p.label=g.target.value;else if(c==="lat")"position"in p&&(p.position={...p.position,lat:parseFloat(g.target.value)||0});else if(c==="lon")"position"in p&&(p.position={...p.position,lon:parseFloat(g.target.value)||0});else if(c==="heading")p.heading=parseFloat(g.target.value)||0;else if(c==="length")p.length=parseFloat(g.target.value)||0;else if(c==="max_range"){const x=g.target.value;p.max_range=x?parseFloat(x):void 0}else if(c==="points")try{p.points=JSON.parse(g.target.value)}catch{return}l[parseInt(h)]=p,this._config={...this._config,radar:{...m,local_features:l}},this._emitConfigChanged()}),c==="label"&&u.addEventListener("blur",()=>{this._render()})}),t.querySelectorAll("[data-annotation-prop]").forEach(u=>{const[h,c]=u.getAttribute("data-annotation-prop").split(":");u.addEventListener("input",g=>{const m=[...this._config.annotate||[]],l={...m[parseInt(h)]};c==="field"?l.field=g.target.value:c==="render"&&(l.render=g.target.value),m[parseInt(h)]=l,this._config={...this._config,annotate:m},this._emitConfigChanged()}),c==="field"&&u.addEventListener("blur",()=>{this._render()})})}_emitConfigChanged(){this._internalUpdate=!0,this.dispatchEvent(new CustomEvent("config-changed",{detail:{config:this._config},bubbles:!0,composed:!0}))}_getConditionAtPath(t){const e=t.split(":"),a=e[0];let i=null;if(a==="filter"){if(!this._config.filter)return null;i=this._config.filter}else if(a==="annotate"){if(!this._config.annotate||e.length<2)return null;const n=parseInt(e[1]);if(!this._config.annotate[n])return null;i=this._config.annotate[n].conditions||[],e.splice(1,1)}else return null;let o=i;for(let n=1;na.trim()).filter(a=>a);const e=parseFloat(t);return!isNaN(e)&&t===String(e)?e:t==="true"?!0:t==="false"?!1:t}_escapeHtml(t){const e=document.createElement("div");return e.textContent=t,e.innerHTML}_getLocation(){const t=this._config;if(t.location_tracker&&this.hass&&this.hass.states&&t.location_tracker in this.hass.states){const e=this.hass.states[t.location_tracker].attributes;return{latitude:e.latitude,longitude:e.longitude}}else{if(t.location)return{latitude:t.location.lat,longitude:t.location.lon};if(this.hass&&this.hass.config)return{latitude:this.hass.config.latitude,longitude:this.hass.config.longitude}}return{latitude:0,longitude:0}}_openMapModal(t,e){if(this._mapModal={type:t,index:e,points:[]},this._renderModalOverlay(),!this._shadowRoot.getElementById("leaflet-css-shadow")){const a=document.createElement("link");a.id="leaflet-css-shadow",a.rel="stylesheet",a.href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css",this._shadowRoot.appendChild(a)}if(window.L)requestAnimationFrame(()=>{requestAnimationFrame(()=>{this._initializeMap()})});else{if(!document.getElementById("leaflet-css")){const a=document.createElement("link");a.id="leaflet-css",a.rel="stylesheet",a.href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css",document.head.appendChild(a)}if(document.getElementById("leaflet-js")){const a=setInterval(()=>{window.L&&(clearInterval(a),this._initializeMap())},50)}else{const a=document.createElement("script");a.id="leaflet-js",a.src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js",a.onload=()=>this._initializeMap(),document.head.appendChild(a)}}}_initializeMap(){const t=this._shadowRoot.querySelector(".map-modal-map");if(!t||!window.L||!this._mapModal)return;const e=t.getBoundingClientRect();if(e.width===0||e.height===0){setTimeout(()=>this._initializeMap(),100);return}t.innerHTML="",t.removeAttribute("data-leaflet-id");const a=this._getLocation(),i=(this._config.radar||{}).range||50,o=window.L.map(t,{center:[a.latitude,a.longitude],zoom:this._calculateZoomLevel(i),zoomControl:!0,attributionControl:!1});window.L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",{maxZoom:19}).addTo(o),window.L.circleMarker([a.latitude,a.longitude],{radius:6,color:"#2196f3",fillColor:"#2196f3",fillOpacity:.8}).addTo(o),window.L.circle([a.latitude,a.longitude],{radius:i*1e3,color:"#2196f3",fillColor:"#2196f3",fillOpacity:.1,weight:2}).addTo(o),this._mapModal.map=o,this._mapModal.type==="location"?this._setupLocationSelection(o):this._mapModal.type==="outline"&&this._setupOutlineDrawing(o),setTimeout(()=>o.invalidateSize(),10)}_calculateZoomLevel(t){return t<=10?13:t<=25?12:t<=50?11:t<=100?10:9}_setupLocationSelection(t){const e=((this._config.radar||{}).local_features||[])[this._mapModal.index];e&&e.position.lat!==0&&e.position.lon!==0&&(this._mapModal.marker=window.L.circleMarker([e.position.lat,e.position.lon],{radius:10,color:"#ff9800",fillColor:"#ff9800",fillOpacity:.8,weight:3,draggable:!0}).addTo(t),this._mapModal.marker.on("dragend",()=>{const a=this._mapModal.marker.getLatLng();this._updateLocationCoordinates(a.lat,a.lng)})),t.on("click",a=>{const i=a.latlng.lat,o=a.latlng.lng;this._mapModal.marker?this._mapModal.marker.setLatLng([i,o]):(this._mapModal.marker=window.L.circleMarker([i,o],{radius:10,color:"#ff9800",fillColor:"#ff9800",fillOpacity:.8,weight:3,draggable:!0}).addTo(t),this._mapModal.marker.on("dragend",()=>{const r=this._mapModal.marker.getLatLng();this._updateLocationCoordinates(r.lat,r.lng)})),this._updateLocationCoordinates(i,o)})}_setupOutlineDrawing(t){const e=((this._config.radar||{}).local_features||[])[this._mapModal.index];this._mapModal.markers=[],e&&e.points&&e.points.length>0&&(this._mapModal.points=[...e.points],this._updateOutlinePolyline(t)),t.on("click",a=>{if(a.originalEvent.target.classList?.contains("leaflet-marker-icon")||a.originalEvent.target.closest(".leaflet-marker-icon"))return;const i=a.latlng.lat,o=a.latlng.lng;this._mapModal.points.push({lat:i,lon:o}),this._updateOutlinePolyline(t)})}_updateLocationCoordinates(t,e){const a=this._shadowRoot.querySelector(".map-modal-instructions");a&&(a.textContent=`Location: ${t.toFixed(4)}, ${e.toFixed(4)} - Click "Apply" to save`)}_updateOutlinePolyline(t){this._mapModal.polygon&&this._mapModal.polygon.remove(),this._mapModal.markers&&(this._mapModal.markers.forEach(i=>i.remove()),this._mapModal.markers=[]);const e=this._mapModal.points;if(e.length>0){const i=e.map(o=>[o.lat,o.lon]);this._mapModal.polygon=window.L.polyline(i,{color:"#ff9800",weight:3}).addTo(t),e.forEach((o,r)=>{const n=window.L.circleMarker([o.lat,o.lon],{radius:8,color:"#ff9800",fillColor:"#fff",fillOpacity:1,weight:3,draggable:!0}).addTo(t);n.on("drag",()=>{const d=n.getLatLng();this._mapModal.points[r]={lat:d.lat,lon:d.lng};const s=this._mapModal.points.map(f=>[f.lat,f.lon]);this._mapModal.polygon.setLatLngs(s)}),n.on("contextmenu",d=>{d.originalEvent.preventDefault(),this._removeOutlinePoint(r)}),this._mapModal.markers.push(n)})}const a=this._shadowRoot.querySelector(".map-modal-instructions");a&&(a.textContent=`${e.length} points - Click to add, drag to move, right-click to remove, "Clear Last" to undo`)}_removeOutlinePoint(t){this._mapModal&&this._mapModal.points&&t>=0&&t0&&(this._mapModal.points.pop(),this._updateOutlinePolyline(this._mapModal.map))}_applyMapSelection(){if(!this._mapModal)return;const t=this._config.radar||{},e=[...t.local_features||[]],a=this._mapModal.index;if(this._mapModal.type==="location"&&this._mapModal.marker){const i=this._mapModal.marker.getLatLng(),o={...e[a]};o.position={lat:i.lat,lon:i.lng},e[a]=o}else if(this._mapModal.type==="outline"&&this._mapModal.points&&this._mapModal.points.length>0){const i={...e[a]};i.points=[...this._mapModal.points],e[a]=i}this._config={...this._config,radar:{...t,local_features:e}},this._emitConfigChanged(),this._closeMapModal(),this._render()}_closeMapModal(){this._mapModal&&this._mapModal.map&&this._mapModal.map.remove(),this._mapModal=null;const t=this._shadowRoot.querySelector(".map-modal-overlay");t&&t.classList.remove("open")}_renderModalOverlay(){let t=this._shadowRoot.querySelector(".map-modal-overlay");t||(t=document.createElement("div"),t.className="map-modal-overlay",t.innerHTML=` + `; + } + _getConditionDescription(t) { + if ("type" in t) { + if (t.type === "NOT") return this._getConditionDescription(t.condition); + { + const e = t, a = e.conditions.length; + if (a === 0) return "(empty group)"; + const i = e.conditions.slice(0, 2).map((n) => { + const d = this._getConditionDescription(n); + return d.length > 30 ? d.substring(0, 27) + "..." : d; + }), o = a - i.length, r = i.join(` ${e.type} `); + return o > 0 ? `${r} + ${o} more` : r; + } + } else { + const e = t; + return `${e.defined ? `\${${e.defined}}` : e.field || "(no field)"} ${this._getComparatorSymbol(e.comparator)} ${this._formatValueForDisplay(e.value)}`; + } + } + _getComparatorSymbol(t) { + switch (t) { + case "eq": + return "="; + case "lt": + return "<"; + case "lte": + return "≤"; + case "gt": + return ">"; + case "gte": + return "≥"; + case "oneOf": + return "in"; + case "containsOneOf": + return "contains"; + default: + return t; + } + } + _formatValueForDisplay(t) { + if (Array.isArray(t)) + return t.length > 2 ? `[${t.slice(0, 2).join(", ")}, +${t.length - 2}]` : `[${t.join(", ")}]`; + const e = String(t ?? ""); + return e === "${}" ? "(select define)" : e.length > 20 ? e.substring(0, 17) + "..." : e; + } + _formatValue(t) { + return Array.isArray(t) ? t.join(", ") : String(t ?? ""); + } + _attachEventListeners() { + const t = this._shadowRoot, e = t.getElementById("flights-entity"); + e && e.addEventListener("change", (c) => { + this._config = { + ...this._config, + flights_entity: c.target.value + }, this._emitConfigChanged(); + }); + const a = t.getElementById("location-tracker"); + a && a.addEventListener("change", (c) => { + const p = c.target.value; + if (this._config = { + ...this._config, + location_tracker: p || void 0 + }, p) { + const { location: s, ...h } = this._config; + this._config = h; + } + this._emitConfigChanged(), this._render(); + }), ["lat", "lon"].forEach((c) => { + const p = t.getElementById(`location-${c}`); + p && p.addEventListener("input", (s) => { + const h = parseFloat(s.target.value); + if (!isNaN(h)) { + const v = this._config.location || { + lat: 0, + lon: 0 + }; + this._config = { + ...this._config, + location: { + ...v, + [c]: h + } + }, this._emitConfigChanged(); + } + }); + }), [ + "altitude", + "speed", + "distance" + ].forEach((c) => { + const p = t.getElementById(`unit-${c}`); + p && p.addEventListener("change", (s) => { + const h = this._config.units || {}; + this._config = { + ...this._config, + units: { + ...h, + [c]: s.target.value + } + }, this._emitConfigChanged(), c === "distance" && this._render(); + }); + }); + const i = t.getElementById("projection-interval"); + i && i.addEventListener("input", (c) => { + this._config = { + ...this._config, + projection_interval: parseInt(c.target.value) + }, this._emitConfigChanged(); + }); + const o = t.getElementById("scale"); + o && o.addEventListener("input", (c) => { + this._config = { + ...this._config, + scale: parseFloat(c.target.value) + }, this._emitConfigChanged(); + }); + const r = t.getElementById("max-flights"); + r && r.addEventListener("input", (c) => { + const p = c.target.value, s = parseInt(p); + this._config = { + ...this._config, + max_flights: !p || isNaN(s) || s <= 0 ? void 0 : s + }, this._emitConfigChanged(); + }); + const n = t.getElementById("radar-show"); + n && n.addEventListener("change", (c) => { + const p = this._config.radar || {}, s = c.target.checked ? void 0 : !0; + this._config = { + ...this._config, + radar: { + ...p, + hide: s + } + }, this._emitConfigChanged(); + }), [ + "range", + "min-range", + "max-range", + "ring-distance" + ].forEach((c) => { + const p = t.getElementById(`radar-${c}`); + p && p.addEventListener("input", (s) => { + const h = this._config.radar || {}, v = c.replace(/-/g, "_"); + this._config = { + ...this._config, + radar: { + ...h, + [v]: parseFloat(s.target.value) + } + }, this._emitConfigChanged(); + }); + }); + const d = t.getElementById("radar-view"); + d && d.addEventListener("change", (c) => { + const p = this._config.radar || {}; + this._config = { + ...this._config, + radar: { + ...p, + view: c.target.value + } + }, this._emitConfigChanged(), this._render(); + }); + const l = t.getElementById("radar-rings"); + l && l.addEventListener("change", (c) => { + const p = this._config.radar || {}; + this._config = { + ...this._config, + radar: { + ...p, + rings: c.target.checked + } + }, this._emitConfigChanged(); + }), [ + "background-color", + "aircraft-color", + "aircraft-selected-color", + "radar-grid-color", + "local-features-color" + ].forEach((c) => { + const p = c.startsWith("radar-") ? c : `radar-${c}`, s = t.getElementById(p); + s && s.addEventListener("input", (h) => { + const v = this._config.radar || {}, f = { ...this._config }; + c === "background-color" && f.radar && delete f.radar["primary-color"], c === "aircraft-color" && f.radar && delete f.radar["accent-color"], (c === "radar-grid-color" || c === "local-features-color") && f.radar && delete f.radar["feature-color"], this._config = { + ...f, + radar: { + ...v, + [c]: h.target.value + } + }, this._emitConfigChanged(), (c === "background-color" || c === "aircraft-color") && this._render(); + }); + }); + const u = t.getElementById("radar-background-opacity"); + u && u.addEventListener("input", (c) => { + const p = this._config.radar || {}; + this._config = { + ...this._config, + radar: { + ...p, + "background-opacity": parseFloat(c.target.value) + } + }, this._emitConfigChanged(), this._render(); + }), t.querySelectorAll(".marker-size-option").forEach((c) => { + c.addEventListener("click", (p) => { + const s = p.currentTarget.getAttribute("data-size"), h = this._config.radar || {}; + this._config = { + ...this._config, + radar: { + ...h, + "aircraft-marker-size": s === "normal" ? void 0 : s + } + }, this._emitConfigChanged(), this._render(); + }); + }); + const _ = (c, p) => { + const s = this._config.radar || {}, h = { ...s["aircraft-marker"]?.default || {} }; + p === "" || p === void 0 || p === 0 ? delete h[c] : h[c] = p; + const v = Object.keys(h).length > 0 && h["aircraft-marker-url"]; + this._config = { + ...this._config, + radar: { + ...s, + "aircraft-marker": v ? { default: h } : void 0 + } + }, this._emitConfigChanged(); + }, m = t.getElementById("radar-custom-marker-url"); + m && m.addEventListener("input", (c) => { + _("aircraft-marker-url", c.target.value); + }); + const g = t.getElementById("radar-custom-marker-rotation"); + g && g.addEventListener("input", (c) => { + const p = parseInt(c.target.value); + _("aircraft-marker-rotation", isNaN(p) ? 0 : p); + }); + const y = t.getElementById("radar-custom-marker-center"); + y && y.addEventListener("input", (c) => { + _("aircraft-marker-center", c.target.value || void 0); + }); + const k = t.getElementById("radar-custom-marker-scale"); + k && k.addEventListener("input", (c) => { + const p = parseFloat(c.target.value); + _("aircraft-marker-scale", isNaN(p) ? 1 : p); + }); + const A = t.getElementById("radar-custom-marker-overlay"); + A && A.addEventListener("input", (c) => { + const p = c.target.value; + _("aircraft-marker-color-overlay", p || void 0); + }); + const R = t.getElementById("radar-background-map"); + R && R.addEventListener("change", (c) => { + const p = this._config.radar || {}; + this._config = { + ...this._config, + radar: { + ...p, + background_map: c.target.value + } + }, this._emitConfigChanged(), this._render(); + }); + const F = (c, p) => { + const s = t.getElementById(c); + s && s.addEventListener("change", (h) => { + const v = this._config.radar || {}; + this._config = { + ...this._config, + radar: { + ...v, + [p]: h.target.value + } + }, this._emitConfigChanged(), this._render(); + }); + }; + F("radar-background-map-light", "background_map_light"), F("radar-background-map-dark", "background_map_dark"); + const S = (c, p) => { + const s = t.getElementById(c); + s && s.addEventListener("input", (h) => { + const v = this._config.radar || {}, f = h.target.value; + this._config = { + ...this._config, + radar: { + ...v, + [p]: f || void 0 + } + }, this._emitConfigChanged(); + }); + }; + S("radar-background-map-light-api-key", "background_map_light_api_key"), S("radar-background-map-dark-api-key", "background_map_dark_api_key"); + const x = t.getElementById("radar-background-map-api-key"); + x && x.addEventListener("input", (c) => { + const p = this._config.radar || {}, s = c.target.value; + this._config = { + ...this._config, + radar: { + ...p, + background_map_api_key: s || void 0 + } + }, this._emitConfigChanged(); + }); + const $ = t.getElementById("radar-background-map-opacity"); + $ && $.addEventListener("input", (c) => { + const p = this._config.radar || {}; + this._config = { + ...this._config, + radar: { + ...p, + background_map_opacity: parseFloat(c.target.value) + } + }, this._emitConfigChanged(); + }); + const C = t.getElementById("list-show"); + C && C.addEventListener("change", (c) => { + const p = this._config.list || {}, s = c.target.checked ? void 0 : !0; + this._config = { + ...this._config, + list: { + ...p, + hide: s + } + }, this._emitConfigChanged(); + }); + const M = t.getElementById("list-show-status"); + M && M.addEventListener("change", (c) => { + const p = this._config.list || {}, s = c.target.checked ? void 0 : !1; + this._config = { + ...this._config, + list: { + ...p, + showListStatus: s + } + }, this._emitConfigChanged(); + }); + const w = t.getElementById("list-position"); + w && w.addEventListener("change", (c) => { + const p = this._config.list || {}, s = c.target.value, h = s === "below" ? void 0 : s; + this._config = { + ...this._config, + list: { + ...p, + position: h + } + }, this._emitConfigChanged(), this._render(); + }); + const E = t.getElementById("no-flights-message"); + E && E.addEventListener("input", (c) => { + this._config = { + ...this._config, + no_flights_message: c.target.value + }, this._emitConfigChanged(); + }); + const L = t.getElementById("add-template-button"), O = t.getElementById("template-dropdown"); + L && O && (L.addEventListener("click", (c) => { + c.stopPropagation(); + const p = O.classList.contains("open"); + t.querySelectorAll(".template-dropdown").forEach((s) => s.classList.remove("open")), p || O.classList.add("open"); + }), O.querySelectorAll(".template-dropdown-item").forEach((c) => { + c.addEventListener("click", (p) => { + p.stopPropagation(); + const s = p.target.getAttribute("data-template-key"); + if (!s) return; + const h = this._config.templates || {}; + if (s === "__custom__") { + let v = 1; + for (; h[`template${v}`]; ) v++; + this._config = { + ...this._config, + templates: { + ...h, + [`template${v}`]: "" + } + }; + } else { + const v = W[s]; + this._config = { + ...this._config, + templates: { + ...h, + [s]: v + } + }; + } + this._emitConfigChanged(), this._render(), O.classList.remove("open"); + }); + }), document.addEventListener("click", () => { + O.classList.remove("open"); + })), t.querySelectorAll("[data-action]").forEach((c) => { + c.addEventListener("click", (p) => { + const s = p.target.getAttribute("data-action"), h = p.target.getAttribute("data-index"), v = p.target.getAttribute("data-key"); + if (s === "add-sort") { + const f = this._config.sort || []; + this._config = { + ...this._config, + sort: [...f, { + field: "distance", + order: "asc" + }] + }, this._emitConfigChanged(), this._render(); + } else if (s === "remove-sort" && h) { + const f = [...this._config.sort || []]; + f.splice(parseInt(h), 1), this._config = { + ...this._config, + sort: f.length > 0 ? f : void 0 + }, this._emitConfigChanged(), this._render(); + } else if (s === "add-define") { + const f = this._config.defines || {}; + let b = 1; + for (; f[`define${b}`]; ) b++; + this._config = { + ...this._config, + defines: { + ...f, + [`define${b}`]: "" + } + }, this._emitConfigChanged(), this._render(); + } else if (s === "remove-define" && v) { + const f = { ...this._config.defines }; + delete f[v], this._config = { + ...this._config, + defines: Object.keys(f).length > 0 ? f : void 0 + }, this._emitConfigChanged(), this._render(); + } else if (s === "add-toggle") { + const f = this._config.toggles || {}; + let b = 1; + for (; f[`toggle${b}`]; ) b++; + this._config = { + ...this._config, + toggles: { + ...f, + [`toggle${b}`]: { + label: "Toggle", + default: !1 + } + } + }, this._emitConfigChanged(), this._render(); + } else if (s === "remove-toggle" && v) { + const f = { ...this._config.toggles }; + delete f[v], this._config = { + ...this._config, + toggles: Object.keys(f).length > 0 ? f : void 0 + }, this._emitConfigChanged(), this._render(); + } else if (s === "remove-template" && v) { + const f = { ...this._config.templates }; + delete f[v], this._config = { + ...this._config, + templates: Object.keys(f).length > 0 ? f : void 0 + }, this._emitConfigChanged(), this._render(); + } else if (s === "add-filter-condition") { + const f = this._config.filter || [], b = f.length; + this._config = { + ...this._config, + filter: [...f, { + field: "", + comparator: "eq", + value: "" + }] + }, this._openConditions.add(`filter:${b}`), this._emitConfigChanged(), this._render(); + } else if (s === "add-filter-group") { + const f = this._config.filter || [], b = f.length; + this._config = { + ...this._config, + filter: [...f, { + type: "AND", + conditions: [] + }] + }, this._openConditions.add(`filter:${b}`), this._emitConfigChanged(), this._render(); + } else if (s === "add-filter-not") { + const f = this._config.filter || [], b = f.length; + this._config = { + ...this._config, + filter: [...f, { + type: "NOT", + condition: { + field: "", + comparator: "eq", + value: "" + } + }] + }, this._openConditions.add(`filter:${b}`), this._emitConfigChanged(), this._render(); + } else if (s === "remove-condition") { + const f = p.target.getAttribute("data-path"); + f && (this._removeConditionAtPath(f), this._emitConfigChanged(), this._render()); + } else if (s === "add-group-condition") { + const f = p.target.getAttribute("data-path"); + if (f) { + const b = this._addConditionToGroup(f, { + field: "", + comparator: "eq", + value: "" + }); + b && this._openConditions.add(b), this._emitConfigChanged(), this._render(); + } + } else if (s === "add-group-group") { + const f = p.target.getAttribute("data-path"); + if (f) { + const b = this._addConditionToGroup(f, { + type: "AND", + conditions: [] + }); + b && this._openConditions.add(b), this._emitConfigChanged(), this._render(); + } + } else if (s === "add-group-not") { + const f = p.target.getAttribute("data-path"); + if (f) { + const b = this._addConditionToGroup(f, { + type: "NOT", + condition: { + field: "", + comparator: "eq", + value: "" + } + }); + b && this._openConditions.add(b), this._emitConfigChanged(), this._render(); + } + } else if (s === "add-local-feature-location") { + const f = this._config.radar || {}, b = f.local_features || [], I = b.length; + this._config = { + ...this._config, + radar: { + ...f, + local_features: [...b, { + type: "location", + label: "", + position: { + lat: 0, + lon: 0 + } + }] + } + }, this._openFeatures.add(`feature-${I}`), this._emitConfigChanged(), this._render(); + } else if (s === "add-local-feature-runway") { + const f = this._config.radar || {}, b = f.local_features || [], I = b.length; + this._config = { + ...this._config, + radar: { + ...f, + local_features: [...b, { + type: "runway", + position: { + lat: 0, + lon: 0 + }, + heading: 0, + length: 0 + }] + } + }, this._openFeatures.add(`feature-${I}`), this._emitConfigChanged(), this._render(); + } else if (s === "add-local-feature-outline") { + const f = this._config.radar || {}, b = f.local_features || [], I = b.length; + this._config = { + ...this._config, + radar: { + ...f, + local_features: [...b, { + type: "outline", + points: [] + }] + } + }, this._openFeatures.add(`feature-${I}`), this._emitConfigChanged(), this._render(); + } else if (s === "remove-local-feature" && h) { + const f = this._config.radar || {}, b = [...f.local_features || []]; + b.splice(parseInt(h), 1), this._config = { + ...this._config, + radar: { + ...f, + local_features: b.length > 0 ? b : void 0 + } + }, this._emitConfigChanged(), this._render(); + } else if (s === "select-location-on-map" && h) this._openMapModal("location", parseInt(h)); + else if (s === "draw-outline-on-map" && h) this._openMapModal("outline", parseInt(h)); + else if (s === "add-annotation") { + const f = this._config.annotate || [], b = f.length; + this._config = { + ...this._config, + annotate: [...f, { + field: "", + render: "", + conditions: [] + }] + }, this._openAnnotations.add(`annotation-${b}`), this._emitConfigChanged(), this._render(); + } else if (s === "remove-annotation" && h) { + const f = [...this._config.annotate || []]; + f.splice(parseInt(h), 1), this._config = { + ...this._config, + annotate: f.length > 0 ? f : void 0 + }, this._emitConfigChanged(), this._render(); + } else if (s === "add-annotation-condition" && h) { + const f = [...this._config.annotate || []], b = parseInt(h); + if (f[b]) { + const I = (f[b].conditions || []).length; + f[b] = { + ...f[b], + conditions: [...f[b].conditions || [], { + field: "", + comparator: "eq", + value: "" + }] + }, this._config = { + ...this._config, + annotate: f + }, this._openConditions.add(`annotate:${b}:${I}`), this._emitConfigChanged(), this._render(); + } + } else if (s === "add-annotation-group" && h) { + const f = [...this._config.annotate || []], b = parseInt(h); + if (f[b]) { + const I = (f[b].conditions || []).length; + f[b] = { + ...f[b], + conditions: [...f[b].conditions || [], { + type: "AND", + conditions: [] + }] + }, this._config = { + ...this._config, + annotate: f + }, this._openConditions.add(`annotate:${b}:${I}`), this._emitConfigChanged(), this._render(); + } + } else if (s === "add-annotation-not" && h) { + const f = [...this._config.annotate || []], b = parseInt(h); + if (f[b]) { + const I = (f[b].conditions || []).length; + f[b] = { + ...f[b], + conditions: [...f[b].conditions || [], { + type: "NOT", + condition: { + field: "", + comparator: "eq", + value: "" + } + }] + }, this._config = { + ...this._config, + annotate: f + }, this._openConditions.add(`annotate:${b}:${I}`), this._emitConfigChanged(), this._render(); + } + } + }); + }), t.querySelectorAll("[data-runway-index]").forEach((c) => { + const p = parseInt(c.getAttribute("data-runway-index")), s = t.getElementById(`runway-dropdown-${p}`), h = t.getElementById(`runway-lookup-status-${p}`); + let v; + c.addEventListener("input", (f) => { + const b = f.target.value.trim(); + if (clearTimeout(v), !b || b.length < 2) { + s && (s.style.display = "none"), h && (h.textContent = ""); + return; + } + s && (s.innerHTML = '
⏳ Searching runways...
', s.style.display = "block"), v = window.setTimeout(() => { + se(b).then((I) => { + s && (I.length === 0 ? s.innerHTML = '
No runways found
' : (s.innerHTML = I.map((H) => `
${H.displayText}
`).join(""), s.querySelectorAll(".runway-dropdown-item").forEach((H) => { + H.addEventListener("click", () => { + const D = JSON.parse(H.getAttribute("data-runway-result")), ut = this._config.radar || {}, tt = [...ut.local_features || []], Y = { ...tt[p] }; + Y.position = { + lat: D.latitude, + lon: D.longitude + }, Y.heading = Math.round(D.heading), Y.length = Math.round(D.length), tt[p] = Y, this._config = { + ...this._config, + radar: { + ...ut, + local_features: tt + } + }, this._emitConfigChanged(), c.value = `${D.airportCode} ${D.runwayDesignator}`, s.style.display = "none", h && (h.style.color = "var(--success-color, #43a047)", h.textContent = `✓ ${D.airportCode} RWY${D.runwayDesignator} - ${Math.round(D.length)}ft`), this._render(); + }); + }))); + }).catch((I) => { + s && (s.innerHTML = `
Error: ${I.message}
`), h && (h.style.color = "var(--error-color, #f44336)", h.textContent = `❌ ${I.message}`); + }); + }, 300); + }), c.addEventListener("blur", () => { + setTimeout(() => { + s && (s.style.display = "none"); + }, 200); + }); + }), t.querySelectorAll(".condition-field-type").forEach((c) => { + const p = c.getAttribute("data-path"), s = c.getAttribute("data-target"); + p && c.addEventListener("change", (h) => { + const v = h.target.value; + s === "value" ? this._switchConditionValueType(p, v) : this._switchConditionFieldType(p, v), this._emitConfigChanged(), this._render(); + }); + }), t.querySelectorAll(".condition-field").forEach((c) => { + const p = c.getAttribute("data-path"), s = c.getAttribute("data-prop"); + if (p && s) { + c.addEventListener("input", (v) => { + this._updateConditionAtPath(p, s, v.target.value), this._emitConfigChanged(); + }); + const h = () => { + this._render(); + }; + c.tagName === "SELECT" ? c.addEventListener("change", h) : c.addEventListener("blur", h); + } + }), t.querySelectorAll(".condition-field-value-defined").forEach((c) => { + const p = c.getAttribute("data-path"); + p && c.addEventListener("change", (s) => { + const h = s.target.value, v = h ? `\${${h}}` : "${}"; + this._updateConditionAtPath(p, "value", v), this._emitConfigChanged(), this._render(); + }); + }), t.querySelectorAll("[data-define-key]").forEach((c) => { + c.addEventListener("input", (p) => { + const s = p.target, h = s.getAttribute("data-define-key"), v = s.value; + h !== v && v.trim() && (this._renameConfigKey("define", h, v), s.setAttribute("data-define-key", v), s.closest(".item-box")?.querySelector("[data-define-value]")?.setAttribute("data-define-value", v), this._emitConfigChanged()); + }); + }), t.querySelectorAll("[data-toggle-key]").forEach((c) => { + c.addEventListener("input", (p) => { + const s = p.target, h = s.getAttribute("data-toggle-key"), v = s.value; + if (h !== v && v.trim()) { + this._renameConfigKey("toggle", h, v), s.setAttribute("data-toggle-key", v); + const f = s.closest(".item-box"); + f?.querySelector("[data-toggle-label]")?.setAttribute("data-toggle-label", v), f?.querySelector("[data-toggle-default]")?.setAttribute("data-toggle-default", v), this._emitConfigChanged(); + } + }); + }), t.querySelectorAll("[data-template-name]").forEach((c) => { + c.addEventListener("input", (p) => { + const s = p.target, h = s.getAttribute("data-template-name"), v = s.value; + h !== v && v.trim() && (this._renameConfigKey("template", h, v), s.setAttribute("data-template-name", v), s.closest(".item-box")?.querySelector("[data-template-value]")?.setAttribute("data-template-value", v), this._emitConfigChanged()); + }); + }), t.querySelectorAll("[data-define-value]").forEach((c) => { + c.addEventListener("input", (p) => { + const s = p.target.getAttribute("data-define-value"), h = { ...this._config.defines }; + h[s] = p.target.value, this._config = { + ...this._config, + defines: h + }, this._emitConfigChanged(); + }); + }), t.querySelectorAll("[data-toggle-label]").forEach((c) => { + c.addEventListener("input", (p) => { + const s = p.target.getAttribute("data-toggle-label"), h = { ...this._config.toggles }; + h[s] = { + ...h[s], + label: p.target.value + }, this._config = { + ...this._config, + toggles: h + }, this._emitConfigChanged(); + }); + }), t.querySelectorAll("[data-toggle-default]").forEach((c) => { + c.addEventListener("change", (p) => { + const s = p.target.getAttribute("data-toggle-default"), h = { ...this._config.toggles }, v = p.target.checked ? !0 : void 0; + h[s] = { + ...h[s], + default: v + }, this._config = { + ...this._config, + toggles: h + }, this._emitConfigChanged(); + }); + }), t.querySelectorAll("[data-template-value]").forEach((c) => { + c.addEventListener("input", (p) => { + const s = p.target.getAttribute("data-template-value"), h = { ...this._config.templates }; + h[s] = p.target.value, this._config = { + ...this._config, + templates: h + }, this._emitConfigChanged(); + }); + }), t.querySelectorAll("[data-sort-prop]").forEach((c) => { + c.addEventListener("input", (p) => { + const [s, h] = p.target.getAttribute("data-sort-prop").split(":"), v = [...this._config.sort || []]; + v[parseInt(s)] = { + ...v[parseInt(s)], + [h]: p.target.value + }, this._config = { + ...this._config, + sort: v + }, this._emitConfigChanged(); + }); + }), t.querySelectorAll("[data-feature-prop]").forEach((c) => { + const [p, s] = c.getAttribute("data-feature-prop").split(":"); + c.addEventListener("input", (h) => { + const v = this._config.radar || {}, f = [...v.local_features || []], b = { ...f[parseInt(p)] }; + if (s === "label") b.label = h.target.value; + else if (s === "lat") + "position" in b && (b.position = { + ...b.position, + lat: parseFloat(h.target.value) || 0 + }); + else if (s === "lon") + "position" in b && (b.position = { + ...b.position, + lon: parseFloat(h.target.value) || 0 + }); + else if (s === "heading") b.heading = parseFloat(h.target.value) || 0; + else if (s === "length") b.length = parseFloat(h.target.value) || 0; + else if (s === "max_range") { + const I = h.target.value; + b.max_range = I ? parseFloat(I) : void 0; + } else if (s === "points") try { + b.points = JSON.parse(h.target.value); + } catch { + return; + } + f[parseInt(p)] = b, this._config = { + ...this._config, + radar: { + ...v, + local_features: f + } + }, this._emitConfigChanged(); + }), s === "label" && c.addEventListener("blur", () => { + this._render(); + }); + }), t.querySelectorAll("[data-annotation-prop]").forEach((c) => { + const [p, s] = c.getAttribute("data-annotation-prop").split(":"); + c.addEventListener("input", (h) => { + const v = [...this._config.annotate || []], f = { ...v[parseInt(p)] }; + s === "field" ? f.field = h.target.value : s === "render" && (f.render = h.target.value), v[parseInt(p)] = f, this._config = { + ...this._config, + annotate: v + }, this._emitConfigChanged(); + }), s === "field" && c.addEventListener("blur", () => { + this._render(); + }); + }); + const z = t.getElementById("tap-action"); + z && z.addEventListener("input", (c) => { + const p = c.target.value; + this._config = { + ...this._config, + tap_action: p || void 0 + }, this._emitConfigChanged(); + }); + const T = t.getElementById("flight-tap-action"); + T && T.addEventListener("input", (c) => { + const p = c.target.value; + this._config = { + ...this._config, + flight_tap_action: p || void 0 + }, this._emitConfigChanged(); + }); + } + _emitConfigChanged() { + this._internalUpdate = !0, this.dispatchEvent(new CustomEvent("config-changed", { + detail: { config: this._config }, + bubbles: !0, + composed: !0 + })); + } + _getConditionAtPath(t) { + const e = t.split(":"), a = e[0]; + let i = null; + if (a === "filter") { + if (!this._config.filter) return null; + i = this._config.filter; + } else if (a === "annotate") { + if (!this._config.annotate || e.length < 2) return null; + const n = parseInt(e[1]); + if (!this._config.annotate[n]) return null; + i = this._config.annotate[n].conditions || [], e.splice(1, 1); + } else return null; + let o = i; + for (let n = 1; n < e.length - 1; n++) { + if (!i) return null; + const d = parseInt(e[n]), l = i[d]; + if (!l) return null; + if ("type" in l) l.type === "NOT" ? (o = l, i = [l.condition]) : (o = i, i = l.conditions); + else return null; + } + const r = parseInt(e[e.length - 1]); + return { + parent: o, + index: r, + condition: Array.isArray(o) ? o[r] : o.condition + }; + } + _updateConditionAtPath(t, e, a) { + const i = this._getConditionAtPath(t); + if (!i || !i.condition) return; + const o = i.condition; + if (e === "type" && "type" in o && o.type !== "NOT") o.type = a; + else if (!("type" in o)) { + const r = o; + e === "field" || e === "defined" ? r[e] = a || void 0 : e === "comparator" ? r.comparator = a : (e === "value" || e === "defaultValue") && (r[e] = this._parseValue(a)); + } + this._updateConditionsConfig(t); + } + _updateConditionsConfig(t) { + const e = t.split(":")[0]; + e === "filter" ? this._config = { + ...this._config, + filter: [...this._config.filter || []] + } : e === "annotate" && (this._config = { + ...this._config, + annotate: [...this._config.annotate || []] + }); + } + _removeConditionAtPath(t) { + const e = this._getConditionAtPath(t); + if (!e) return; + if (Array.isArray(e.parent) && e.index !== void 0) e.parent.splice(e.index, 1); + else if ("type" in e.parent && e.parent.type === "NOT") return; + const a = t.split(":")[0]; + if (a === "filter") if (this._config.filter?.length === 0) { + const { filter: i, ...o } = this._config; + this._config = o; + } else this._config = { + ...this._config, + filter: [...this._config.filter || []] + }; + else a === "annotate" && (this._config = { + ...this._config, + annotate: [...this._config.annotate || []] + }); + } + _addConditionToGroup(t, e) { + const a = this._getConditionAtPath(t); + if (!a || !a.condition) return null; + const i = a.condition; + if ("type" in i && i.type !== "NOT") { + const o = i, r = o.conditions.length; + return o.conditions.push(e), this._updateConditionsConfig(t), `${t}:${r}`; + } + return null; + } + _switchConditionFieldType(t, e) { + const a = this._getConditionAtPath(t); + if (!a || !a.condition) return; + const i = a.condition; + e === "defined" ? (i.defined = i.field || "", delete i.field) : (i.field = i.defined || "", delete i.defined), this._updateConditionsConfig(t); + } + _switchConditionValueType(t, e) { + const a = this._getConditionAtPath(t); + if (!a || !a.condition) return; + const i = a.condition, o = i.value; + if (e === "defined") { + if (typeof o == "string" && o.startsWith("${") && o.endsWith("}")) return; + i.value = "${}"; + } else if (typeof o == "string" && o.startsWith("${") && o.endsWith("}")) { + const r = o.slice(2, -1), n = this._config.defines || {}; + i.value = n[r] !== void 0 ? n[r] : ""; + } + this._updateConditionsConfig(t); + } + _parseValue(t) { + if (!t) return ""; + if (t.includes(",")) return t.split(",").map((a) => a.trim()).filter((a) => a); + const e = parseFloat(t); + return !isNaN(e) && t === String(e) ? e : t === "true" ? !0 : t === "false" ? !1 : t; + } + _escapeHtml(t) { + const e = document.createElement("div"); + return e.textContent = t, e.innerHTML; + } + _getLocation() { + const t = this._config; + if (t.location_tracker && this.hass && this.hass.states && t.location_tracker in this.hass.states) { + const e = this.hass.states[t.location_tracker].attributes; + return { + latitude: e.latitude, + longitude: e.longitude + }; + } else { + if (t.location) return { + latitude: t.location.lat, + longitude: t.location.lon + }; + if (this.hass && this.hass.config) return { + latitude: this.hass.config.latitude, + longitude: this.hass.config.longitude + }; + } + return { + latitude: 0, + longitude: 0 + }; + } + _openMapModal(t, e) { + if (this._mapModal = { + type: t, + index: e, + points: [] + }, this._renderModalOverlay(), !this._shadowRoot.getElementById("leaflet-css-shadow")) { + const a = document.createElement("link"); + a.id = "leaflet-css-shadow", a.rel = "stylesheet", a.href = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.css", this._shadowRoot.appendChild(a); + } + if (window.L) + requestAnimationFrame(() => { + requestAnimationFrame(() => { + this._initializeMap(); + }); + }); + else { + if (!document.getElementById("leaflet-css")) { + const a = document.createElement("link"); + a.id = "leaflet-css", a.rel = "stylesheet", a.href = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.css", document.head.appendChild(a); + } + if (document.getElementById("leaflet-js")) { + const a = setInterval(() => { + window.L && (clearInterval(a), this._initializeMap()); + }, 50); + } else { + const a = document.createElement("script"); + a.id = "leaflet-js", a.src = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.js", a.onload = () => this._initializeMap(), document.head.appendChild(a); + } + } + } + _initializeMap() { + const t = this._shadowRoot.querySelector(".map-modal-map"); + if (!t || !window.L || !this._mapModal) return; + const e = t.getBoundingClientRect(); + if (e.width === 0 || e.height === 0) { + setTimeout(() => this._initializeMap(), 100); + return; + } + t.innerHTML = "", t.removeAttribute("data-leaflet-id"); + const a = this._getLocation(), i = (this._config.radar || {}).range || 50, o = window.L.map(t, { + center: [a.latitude, a.longitude], + zoom: this._calculateZoomLevel(i), + zoomControl: !0, + attributionControl: !1 + }); + window.L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { maxZoom: 19 }).addTo(o), window.L.circleMarker([a.latitude, a.longitude], { + radius: 6, + color: "#2196f3", + fillColor: "#2196f3", + fillOpacity: 0.8 + }).addTo(o), window.L.circle([a.latitude, a.longitude], { + radius: i * 1e3, + color: "#2196f3", + fillColor: "#2196f3", + fillOpacity: 0.1, + weight: 2 + }).addTo(o), this._mapModal.map = o, this._mapModal.type === "location" ? this._setupLocationSelection(o) : this._mapModal.type === "outline" && this._setupOutlineDrawing(o), setTimeout(() => o.invalidateSize(), 10); + } + _calculateZoomLevel(t) { + return t <= 10 ? 13 : t <= 25 ? 12 : t <= 50 ? 11 : t <= 100 ? 10 : 9; + } + _setupLocationSelection(t) { + const e = ((this._config.radar || {}).local_features || [])[this._mapModal.index]; + e && e.position.lat !== 0 && e.position.lon !== 0 && (this._mapModal.marker = window.L.circleMarker([e.position.lat, e.position.lon], { + radius: 10, + color: "#ff9800", + fillColor: "#ff9800", + fillOpacity: 0.8, + weight: 3, + draggable: !0 + }).addTo(t), this._mapModal.marker.on("dragend", () => { + const a = this._mapModal.marker.getLatLng(); + this._updateLocationCoordinates(a.lat, a.lng); + })), t.on("click", (a) => { + const i = a.latlng.lat, o = a.latlng.lng; + this._mapModal.marker ? this._mapModal.marker.setLatLng([i, o]) : (this._mapModal.marker = window.L.circleMarker([i, o], { + radius: 10, + color: "#ff9800", + fillColor: "#ff9800", + fillOpacity: 0.8, + weight: 3, + draggable: !0 + }).addTo(t), this._mapModal.marker.on("dragend", () => { + const r = this._mapModal.marker.getLatLng(); + this._updateLocationCoordinates(r.lat, r.lng); + })), this._updateLocationCoordinates(i, o); + }); + } + _setupOutlineDrawing(t) { + const e = ((this._config.radar || {}).local_features || [])[this._mapModal.index]; + this._mapModal.markers = [], e && e.points && e.points.length > 0 && (this._mapModal.points = [...e.points], this._updateOutlinePolyline(t)), t.on("click", (a) => { + if (a.originalEvent.target.classList?.contains("leaflet-marker-icon") || a.originalEvent.target.closest(".leaflet-marker-icon")) return; + const i = a.latlng.lat, o = a.latlng.lng; + this._mapModal.points.push({ + lat: i, + lon: o + }), this._updateOutlinePolyline(t); + }); + } + _updateLocationCoordinates(t, e) { + const a = this._shadowRoot.querySelector(".map-modal-instructions"); + a && (a.textContent = `Location: ${t.toFixed(4)}, ${e.toFixed(4)} - Click "Apply" to save`); + } + _updateOutlinePolyline(t) { + this._mapModal.polygon && this._mapModal.polygon.remove(), this._mapModal.markers && (this._mapModal.markers.forEach((i) => i.remove()), this._mapModal.markers = []); + const e = this._mapModal.points; + if (e.length > 0) { + const i = e.map((o) => [o.lat, o.lon]); + this._mapModal.polygon = window.L.polyline(i, { + color: "#ff9800", + weight: 3 + }).addTo(t), e.forEach((o, r) => { + const n = window.L.circleMarker([o.lat, o.lon], { + radius: 8, + color: "#ff9800", + fillColor: "#fff", + fillOpacity: 1, + weight: 3, + draggable: !0 + }).addTo(t); + n.on("drag", () => { + const d = n.getLatLng(); + this._mapModal.points[r] = { + lat: d.lat, + lon: d.lng + }; + const l = this._mapModal.points.map((u) => [u.lat, u.lon]); + this._mapModal.polygon.setLatLngs(l); + }), n.on("contextmenu", (d) => { + d.originalEvent.preventDefault(), this._removeOutlinePoint(r); + }), this._mapModal.markers.push(n); + }); + } + const a = this._shadowRoot.querySelector(".map-modal-instructions"); + a && (a.textContent = `${e.length} points - Click to add, drag to move, right-click to remove, "Clear Last" to undo`); + } + _removeOutlinePoint(t) { + this._mapModal && this._mapModal.points && t >= 0 && t < this._mapModal.points.length && (this._mapModal.points.splice(t, 1), this._updateOutlinePolyline(this._mapModal.map)); + } + _clearLastOutlinePoint() { + this._mapModal && this._mapModal.points && this._mapModal.points.length > 0 && (this._mapModal.points.pop(), this._updateOutlinePolyline(this._mapModal.map)); + } + _applyMapSelection() { + if (!this._mapModal) return; + const t = this._config.radar || {}, e = [...t.local_features || []], a = this._mapModal.index; + if (this._mapModal.type === "location" && this._mapModal.marker) { + const i = this._mapModal.marker.getLatLng(), o = { ...e[a] }; + o.position = { + lat: i.lat, + lon: i.lng + }, e[a] = o; + } else if (this._mapModal.type === "outline" && this._mapModal.points && this._mapModal.points.length > 0) { + const i = { ...e[a] }; + i.points = [...this._mapModal.points], e[a] = i; + } + this._config = { + ...this._config, + radar: { + ...t, + local_features: e + } + }, this._emitConfigChanged(), this._closeMapModal(), this._render(); + } + _closeMapModal() { + this._mapModal && this._mapModal.map && this._mapModal.map.remove(), this._mapModal = null; + const t = this._shadowRoot.querySelector(".map-modal-overlay"); + t && t.classList.remove("open"); + } + _renderModalOverlay() { + let t = this._shadowRoot.querySelector(".map-modal-overlay"); + t || (t = document.createElement("div"), t.className = "map-modal-overlay", t.innerHTML = `
-

${this._mapModal?.type==="location"?"Select Location":"Draw Outline"}

+

${this._mapModal?.type === "location" ? "Select Location" : "Draw Outline"}

@@ -1404,17 +4254,374 @@
- `,this._shadowRoot.appendChild(t),t.querySelector(".map-modal").addEventListener("click",o=>o.stopPropagation()),t.addEventListener("click",()=>this._closeMapModal()),t.querySelectorAll("[data-action]").forEach(o=>{o.addEventListener("click",r=>{const n=r.target.getAttribute("data-action");n==="close-map-modal"?this._closeMapModal():n==="apply-map-selection"?this._applyMapSelection():n==="clear-last-point"&&this._clearLastOutlinePoint()})})),t.classList.add("open");const e=t.querySelector(".map-modal-header h3");e&&(e.textContent=this._mapModal?.type==="location"?"Select Location":"Draw Outline");const a=t.querySelector(".map-modal-instructions");a&&(a.textContent=this._mapModal?.type==="location"?"Click on the map to select a location":"Click on the map to add points to the outline");const i=t.querySelector(".map-modal-footer > div:last-child");i&&(i.innerHTML=` - ${this._mapModal?.type==="outline"?'':""} + `, this._shadowRoot.appendChild(t), t.querySelector(".map-modal").addEventListener("click", (o) => o.stopPropagation()), t.addEventListener("click", () => this._closeMapModal()), t.querySelectorAll("[data-action]").forEach((o) => { + o.addEventListener("click", (r) => { + const n = r.target.getAttribute("data-action"); + n === "close-map-modal" ? this._closeMapModal() : n === "apply-map-selection" ? this._applyMapSelection() : n === "clear-last-point" && this._clearLastOutlinePoint(); + }); + })), t.classList.add("open"); + const e = t.querySelector(".map-modal-header h3"); + e && (e.textContent = this._mapModal?.type === "location" ? "Select Location" : "Draw Outline"); + const a = t.querySelector(".map-modal-instructions"); + a && (a.textContent = this._mapModal?.type === "location" ? "Click on the map to select a location" : "Click on the map to add points to the outline"); + const i = t.querySelector(".map-modal-footer > div:last-child"); + i && (i.innerHTML = ` + ${this._mapModal?.type === "outline" ? '' : ""} - `,i.querySelectorAll("[data-action]").forEach(o=>{o.addEventListener("click",r=>{const n=r.target.getAttribute("data-action");n==="close-map-modal"?this._closeMapModal():n==="apply-map-selection"?this._applyMapSelection():n==="clear-last-point"&&this._clearLastOutlinePoint()})}))}},customElements.define("flightradar24-card-editor",Q)}));ut();var ft="___CARD_VERSION___";ft!=="___CARD_VERSION___"&&console.info(`%cFLIGHTRADAR24-CARD%c v${ft} `,"color: #236597; font-weight: bold","color: inherit; font-weight: normal");var Ot=class extends HTMLElement{constructor(){super(),this._radarResizeObserver=null,this._zoomCleanup=null,this._updateRequired=!0,this._timer=null,this._unsubStateChangesPromise=null;try{this.attachShadow({mode:"open"}),this.cardState=new pt,this.cardState.setRenderDynamic(()=>this.renderDynamic())}catch(t){console.error("[FR24Card] constructor error:",t),this.cardState=new pt}}setConfig(t){try{if(!t)throw new Error("Configuration is missing.");this.cardState._leafletMap&&(this.cardState._leafletMap.remove(),this.cardState._leafletMap=null,this.cardState._currentMapConfig=void 0),this.cardState.setConfig(t),Ct(this.cardState,this),this.observeRadarResize()}catch(e){console.error("[FR24Card] setConfig error:",e)}}static async getConfigElement(t){await Promise.resolve().then(()=>(ut(),Tt));const e=document.createElement("flightradar24-card-editor");return e.setConfig(t),e}static getStubConfig(t){return{flights_entity:Object.keys(t.states).filter(e=>e.includes("flightradar")).sort()[0]||"sensor.flightradar24_current_in_area",radar:{range:50,min_range:5,max_range:100}}}set hass(t){try{this.cardState.hass=t,this._unsubStateChangesPromise||(this._unsubStateChangesPromise=this.subscribeToStateChanges(t)),this._updateRequired&&(this._updateRequired=!1,setTimeout(()=>{this.fetchFlightsData(),requestAnimationFrame(()=>{this.updateCardDimensions(),xt(this.cardState,this.shadowRoot,()=>{try{X(this.cardState),Z(this.cardState)}catch(e){console.error("[FR24Card] Leaflet render error:",e)}}),requestAnimationFrame(()=>{this.renderDynamic()})})},0))}catch(e){console.error("[FR24Card] set hass error:",e)}}connectedCallback(){try{this.observeRadarResize()}catch(t){console.error("[FR24Card] connectedCallback error:",t)}}disconnectedCallback(){try{this._radarResizeObserver&&(this._radarResizeObserver.disconnect(),this._radarResizeObserver=null),this.cardState._leafletMap&&(this.cardState._leafletMap.remove(),this.cardState._leafletMap=null),this._zoomCleanup&&(this._zoomCleanup(),this._zoomCleanup=null),this._unsubStateChangesPromise&&(this._unsubStateChangesPromise.then(t=>t()),this._unsubStateChangesPromise=null)}catch(t){console.error("[FR24Card] disconnectedCallback error:",t)}}updateCardDimensions(){try{const t=this.shadowRoot?.getElementById("radar"),e=t?.clientWidth||400,a=t?.clientHeight||400,i=this.cardState.radar.range,o=e/(i*2);(e!==this.cardState.dimensions.width||a!==this.cardState.dimensions.height||i!==this.cardState.dimensions.range||o!==this.cardState.dimensions.scaleFactor)&&(this.cardState.dimensions={width:e,height:a,range:i,scaleFactor:o,centerX:e/2,centerY:a/2},this.cardState.radar.hide!==!0&&(X(this.cardState),Z(this.cardState)))}catch(t){console.error("[FR24Card] updateCardDimensions error:",t)}}observeRadarResize(){try{const t=this.shadowRoot?.getElementById("radar");if(!t)return;this._radarResizeObserver&&this._radarResizeObserver.disconnect(),this._radarResizeObserver=new ResizeObserver(()=>{try{this.updateCardDimensions()}catch(a){console.error("[FR24Card] ResizeObserver error:",a)}}),this._radarResizeObserver.observe(t);const e=this.shadowRoot?.getElementById("radar-overlay")||null;this._zoomCleanup&&this._zoomCleanup(),this._zoomCleanup=nt(this.cardState,e)}catch(t){console.error("[FR24Card] observeRadarResize error:",t)}}renderDynamic(){try{const t=this.shadowRoot?.getElementById("flights");if(!t)return;const e=document.createDocumentFragment();if(this.cardState.list&&this.cardState.list.hide===!0){t.style.display="none";return}else t.style.display="";const a=this.cardState.config.filter?this.cardState.selectedFlights&&this.cardState.selectedFlights.length>0?[{type:"OR",conditions:[{field:"id",comparator:"oneOf",value:this.cardState.selectedFlights},{type:"AND",conditions:this.cardState.config.filter}]}]:this.cardState.config.filter:void 0,i=this.cardState.flights.length,o=a?ot(this.cardState,a):this.cardState.flights,r=o.length;if(o.sort(this.cardState.sortFn),this.cardState.radar.hide!==!0&&requestAnimationFrame(()=>{try{Z(this.cardState)}catch(n){console.error("[FR24Card] requestAnimationFrame renderRadar error:",n)}}),this.cardState.list&&this.cardState.list.showListStatus===!0&&i>0){this.cardState.flightsContext={shown:r,total:i,filtered:o.length};const n=document.createElement("div");n.className="list-status",n.innerHTML=Y(this.cardState,"list_status",null,d=>(...s)=>s?.filter(f=>f).join(d||" ")),e.appendChild(n)}if(r===0){if(this.cardState.config.no_flights_message!==""){const n=document.createElement("div");n.className="no-flights-message",n.textContent=this.cardState.config.no_flights_message||"",e.appendChild(n)}}else{const n=this.cardState.config.max_flights;(n&&n>0?o.slice(0,n):o).forEach((d,s)=>{const f=$t(this.cardState,d);s===0&&(f.className+=" first"),e.appendChild(f)})}t.innerHTML="",t.appendChild(e)}catch(t){console.error("[FR24Card] renderDynamic error:",t)}}updateRadarRange(t){try{const e=this.cardState.radar.min_range||1,a=this.cardState.radar.max_range||Math.max(100,this.cardState.radar.initialRange||35);let i=this.cardState.radar.range+t;ia&&(i=a),this.cardState.radar.range=i,this.updateCardDimensions(),this.cardState.renderDynamicOnRangeChange&&this.cardState.config.updateRangeFilterOnTouchEnd!==!0&&this.renderDynamic()}catch(e){console.error("[FR24Card] updateRadarRange error:",e)}}subscribeToStateChanges(t){try{if(!this.cardState.config.test&&this.cardState.config.update!==!1)return t.connection.subscribeEvents(e=>{try{(e.data.entity_id===this.cardState.config.flights_entity||e.data.entity_id===this.cardState.config.location_tracker)&&(this._updateRequired=!0)}catch(a){console.error("[FR24Card] subscribeEvents callback error:",a)}},"state_changed")}catch(e){console.error("[FR24Card] subscribeToStateChanges error:",e)}return Promise.resolve(()=>{})}fetchFlightsData(){try{this._timer&&(clearInterval(this._timer),this._timer=null);const t=this.cardState.hass?.states[this.cardState.config.flights_entity||""];if(t)try{this.cardState.flights=parseFloat(t.state)>0&&t.attributes.flights?JSON.parse(JSON.stringify(t.attributes.flights)):[]}catch(a){console.error("Error fetching or parsing flight data:",a),this.cardState.flights=[]}else throw new Error("Flights entity state is undefined. Check the configuration.");const{moving:e}=this.calculateFlightData();this.cardState.config.projection_interval&&(e&&!this._timer?this._timer=setInterval(()=>{try{if(this.cardState.hass){const{projected:a}=this.calculateFlightData();a&&this.renderDynamic()}}catch(a){console.error("[FR24Card] projectionInterval setInterval error:",a)}},this.cardState.config.projection_interval*1e3):!e&&this._timer&&(clearInterval(this._timer),this._timer=null))}catch(t){console.error("[FR24Card] fetchFlightsData error:",t)}}calculateFlightData(){try{let t=!1,e=!1;const a=Date.now()/1e3,i=K(this.cardState);if(i){const o=i.latitude,r=i.longitude;this.cardState.flights.forEach(n=>{n._timestamp||(n._timestamp=a),e=e||n.ground_speed>0;const d=a-(n._timestamp||a);if(d>1){t=!0,n._timestamp=a;const s=G(n.latitude,n.longitude,n.heading,n.ground_speed*1.852/3600*d);n.latitude=s.lat,n.longitude=s.lon;const f=Math.max(n.altitude+d/60*n.vertical_speed,0);(n.landed||f!==n.altitude&&f===0)&&(n.landed=!0,n.ground_speed=Math.max(n.ground_speed-15*d,15)),n.altitude=f}if(n.distance_to_tracker=z(o,r,n.latitude,n.longitude,this.cardState.units.distance),n.heading_from_tracker=q(o,r,n.latitude,n.longitude),n.cardinal_direction_from_tracker=bt(n.heading_from_tracker),n.is_approaching=et((n.heading_from_tracker+180)%360,n.heading),n.is_receding=et(n.heading_from_tracker,n.heading),n.is_approaching){let s=vt(o,r,n.latitude,n.longitude,n.heading);n.closest_passing_distance=Math.round(z(o,r,s.lat,s.lon,this.cardState.units.distance));const f=this.calculateETA(n.latitude,n.longitude,s.lat,s.lon,n.ground_speed);if(n.eta_to_closest_distance=Math.round(f),n.vertical_speed<0&&n.altitude>0){const b=n.altitude/Math.abs(n.vertical_speed),_=G(n.latitude,n.longitude,n.heading,n.ground_speed*b/60),v=z(o,r,_.lat,_.lon,this.cardState.units.distance);be!==t.id):this.cardState.selectedFlights.push(t.id),this.renderDynamic()}catch(e){console.error("[FR24Card] toggleSelectedFlight error:",e)}}get hass(){return this.cardState.hass}};customElements.define("flightradar24-card",Ot)})(); + `, i.querySelectorAll("[data-action]").forEach((o) => { + o.addEventListener("click", (r) => { + const n = r.target.getAttribute("data-action"); + n === "close-map-modal" ? this._closeMapModal() : n === "apply-map-selection" ? this._applyMapSelection() : n === "clear-last-point" && this._clearLastOutlinePoint(); + }); + })); + } + }, customElements.define("flightradar24-radar-card-editor", ct); +})); +Tt(); +var wt = "___CARD_VERSION___"; +wt !== "___CARD_VERSION___" && console.info(`%cFLIGHTRADAR24-CARD%c v${wt} `, "color: #236597; font-weight: bold", "color: inherit; font-weight: normal"); +var ce = class extends HTMLElement { + constructor() { + super(), this._radarResizeObserver = null, this._zoomCleanup = null, this._updateRequired = !0, this._timer = null, this._unsubStateChangesPromise = null, this._intersectionObserver = null, this._visibilityChangeHandler = null, this._layoutOptions = null; + try { + this.attachShadow({ mode: "open" }), this.cardState = new kt(), this.cardState.setRenderDynamic(() => this.renderDynamic()); + } catch (t) { + console.error("[FR24Card] constructor error:", t), this.cardState = new kt(); + } + } + setConfig(t) { + try { + if (!t) throw new Error("Configuration is missing."); + this.cardState._leafletMap && (this.cardState._leafletMap.remove(), this.cardState._leafletMap = null, this.cardState._currentMapConfig = void 0), this.cardState.setConfig(t), Ut(this.cardState, this), this.observeRadarResize(); + } catch (e) { + console.error("[FR24Card] setConfig error:", e); + } + } + static async getConfigElement(t) { + await Promise.resolve().then(() => (Tt(), de)); + const e = document.createElement("flightradar24-radar-card-editor"); + return e.setConfig(t), e; + } + static getStubConfig(t) { + return { + flights_entity: Object.keys(t.states).filter((e) => e.includes("flightradar")).sort()[0] || "sensor.flightradar24_current_in_area", + radar: { + range: 50, + min_range: 5, + max_range: 100 + } + }; + } + getGridSize() { + return 1; + } + getLayoutOptions() { + return this._layoutOptions ?? { + columns: 1, + rows: 1 + }; + } + setLayoutOptions(t) { + this._layoutOptions = t; + } + cardSize() { + return 2; + } + set hass(t) { + try { + this.cardState.hass = t, this._unsubStateChangesPromise || (this._unsubStateChangesPromise = this.subscribeToStateChanges(t)), this._updateRequired && (this._updateRequired = !1, setTimeout(() => { + this.fetchFlightsData(), requestAnimationFrame(() => { + this.updateCardDimensions(), qt(this.cardState, this.shadowRoot, () => { + try { + rt(this.cardState), et(this.cardState); + } catch (e) { + console.error("[FR24Card] Leaflet render error:", e); + } + }), requestAnimationFrame(() => { + this.renderDynamic(); + }); + }); + }, 0)); + } catch (e) { + console.error("[FR24Card] set hass error:", e); + } + } + connectedCallback() { + try { + this.observeRadarResize(), this.observeVisibility(); + } catch (t) { + console.error("[FR24Card] connectedCallback error:", t); + } + } + disconnectedCallback() { + try { + this._radarResizeObserver && (this._radarResizeObserver.disconnect(), this._radarResizeObserver = null), this._intersectionObserver && (this._intersectionObserver.disconnect(), this._intersectionObserver = null), this._visibilityChangeHandler && (document.removeEventListener("visibilitychange", this._visibilityChangeHandler), this._visibilityChangeHandler = null), this.cardState._leafletMap && (this.cardState._leafletMap.remove(), this.cardState._leafletMap = null), this._zoomCleanup && (this._zoomCleanup(), this._zoomCleanup = null), this._unsubStateChangesPromise && (this._unsubStateChangesPromise.then((t) => t()), this._unsubStateChangesPromise = null); + } catch (t) { + console.error("[FR24Card] disconnectedCallback error:", t); + } + } + updateCardDimensions() { + try { + const t = this.shadowRoot?.getElementById("radar"), e = t?.clientWidth || 400, a = t?.clientHeight || 400, i = this.cardState.radar.range, o = e / (i * 2); + (e !== this.cardState.dimensions.width || a !== this.cardState.dimensions.height || i !== this.cardState.dimensions.range || o !== this.cardState.dimensions.scaleFactor) && (this.cardState.dimensions = { + width: e, + height: a, + range: i, + scaleFactor: o, + centerX: e / 2, + centerY: a / 2 + }, this.cardState.radar.hide !== !0 && (rt(this.cardState), et(this.cardState))); + } catch (t) { + console.error("[FR24Card] updateCardDimensions error:", t); + } + } + observeRadarResize() { + try { + const t = this.shadowRoot?.getElementById("radar"); + if (!t) return; + this._radarResizeObserver && this._radarResizeObserver.disconnect(), this._radarResizeObserver = new ResizeObserver(() => { + try { + this.updateCardDimensions(), this.cardState._leafletMap && requestAnimationFrame(() => { + try { + this.cardState._leafletMap?.invalidateSize({ pan: !1 }), Ht(this.cardState); + } catch (e) { + console.error("[FR24Card] ResizeObserver map refresh error:", e); + } + }); + } catch (e) { + console.error("[FR24Card] ResizeObserver error:", e); + } + }), this._radarResizeObserver.observe(t), this._zoomCleanup && this._zoomCleanup(), this._zoomCleanup = At(this.cardState, t); + } catch (t) { + console.error("[FR24Card] observeRadarResize error:", t); + } + } + observeVisibility() { + try { + this._intersectionObserver && this._intersectionObserver.disconnect(), this._intersectionObserver = new IntersectionObserver((t) => { + try { + t.some((e) => e.isIntersecting) && this.invalidateMapOnVisible(); + } catch (e) { + console.error("[FR24Card] IntersectionObserver error:", e); + } + }), this._intersectionObserver.observe(this), this._visibilityChangeHandler && document.removeEventListener("visibilitychange", this._visibilityChangeHandler), this._visibilityChangeHandler = () => { + try { + document.hidden || this.invalidateMapOnVisible(); + } catch (t) { + console.error("[FR24Card] visibilitychange error:", t); + } + }, document.addEventListener("visibilitychange", this._visibilityChangeHandler); + } catch (t) { + console.error("[FR24Card] observeVisibility error:", t); + } + } + invalidateMapOnVisible() { + try { + if (!this.cardState._leafletMap) return; + const t = (e) => { + requestAnimationFrame(() => { + const a = this.cardState._leafletMap?.getContainer(); + if (!a || a.offsetWidth === 0 || a.offsetHeight === 0) { + e > 0 && setTimeout(() => t(e - 1), 50); + return; + } + try { + this.cardState._leafletMap?.invalidateSize({ pan: !1 }), this.updateCardDimensions(); + } catch (i) { + console.error("[FR24Card] invalidateMapOnVisible error:", i); + } + }); + }; + requestAnimationFrame(() => { + requestAnimationFrame(() => { + t(20); + }); + }); + } catch (t) { + console.error("[FR24Card] invalidateMapOnVisible error:", t); + } + } + renderDynamic() { + try { + const t = this.shadowRoot?.getElementById("flights"); + if (!t) return; + const e = document.createDocumentFragment(); + if (this.cardState.list && this.cardState.list.hide === !0) { + t.style.display = "none"; + return; + } else t.style.display = ""; + const a = this.cardState.config.filter ? this.cardState.selectedFlights && this.cardState.selectedFlights.length > 0 ? [{ + type: "OR", + conditions: [{ + field: "id", + comparator: "oneOf", + value: this.cardState.selectedFlights + }, { + type: "AND", + conditions: this.cardState.config.filter + }] + }] : this.cardState.config.filter : void 0, i = this.cardState.flights.length, o = a ? Rt(this.cardState, a) : this.cardState.flights, r = o.length; + if (o.sort(this.cardState.sortFn), this.cardState.radar.hide !== !0 && requestAnimationFrame(() => { + try { + et(this.cardState); + } catch (n) { + console.error("[FR24Card] requestAnimationFrame renderRadar error:", n); + } + }), this.cardState.list && this.cardState.list.showListStatus === !0 && i > 0) { + this.cardState.flightsContext = { + shown: r, + total: i, + filtered: o.length + }; + const n = document.createElement("div"); + n.className = "list-status", n.innerHTML = pt(this.cardState, "list_status", null, (d) => (...l) => l?.filter((u) => u).join(d || " ")), e.appendChild(n); + } + if (r === 0) { + if (this.cardState.config.no_flights_message !== "") { + const n = document.createElement("div"); + n.className = "no-flights-message", n.textContent = this.cardState.config.no_flights_message || "", e.appendChild(n); + } + } else { + const n = this.cardState.config.max_flights; + (n && n > 0 ? o.slice(0, n) : o).forEach((d, l) => { + const u = ee(this.cardState, d); + l === 0 && (u.className += " first"), e.appendChild(u); + }); + } + t.innerHTML = "", t.appendChild(e); + } catch (t) { + console.error("[FR24Card] renderDynamic error:", t); + } + } + updateRadarRange(t) { + try { + const e = this.cardState.radar.min_range || 1, a = this.cardState.radar.max_range || Math.max(100, this.cardState.radar.initialRange || 35); + let i = this.cardState.radar.range + t; + i < e && (i = e), i > a && (i = a), this.cardState.radar.range = i, this.updateCardDimensions(), this.cardState.renderDynamicOnRangeChange && this.cardState.config.updateRangeFilterOnTouchEnd !== !0 && this.renderDynamic(); + } catch (e) { + console.error("[FR24Card] updateRadarRange error:", e); + } + } + subscribeToStateChanges(t) { + try { + if (!this.cardState.config.test && this.cardState.config.update !== !1) return t.connection.subscribeEvents((e) => { + try { + (e.data.entity_id === this.cardState.config.flights_entity || e.data.entity_id === this.cardState.config.location_tracker) && (this._updateRequired = !0); + } catch (a) { + console.error("[FR24Card] subscribeEvents callback error:", a); + } + }, "state_changed"); + } catch (e) { + console.error("[FR24Card] subscribeToStateChanges error:", e); + } + return Promise.resolve(() => { + }); + } + fetchFlightsData() { + try { + this._timer && (clearInterval(this._timer), this._timer = null); + const t = this.cardState.hass?.states[this.cardState.config.flights_entity || ""]; + if (t) try { + this.cardState.flights = parseFloat(t.state) > 0 && t.attributes.flights ? JSON.parse(JSON.stringify(t.attributes.flights)) : []; + } catch (a) { + console.error("Error fetching or parsing flight data:", a), this.cardState.flights = []; + } + else throw new Error("Flights entity state is undefined. Check the configuration."); + const { moving: e } = this.calculateFlightData(); + this.cardState.config.projection_interval && (e && !this._timer ? this._timer = setInterval(() => { + try { + if (this.cardState.hass) { + const { projected: a } = this.calculateFlightData(); + a && this.renderDynamic(); + } + } catch (a) { + console.error("[FR24Card] projectionInterval setInterval error:", a); + } + }, this.cardState.config.projection_interval * 1e3) : !e && this._timer && (clearInterval(this._timer), this._timer = null)); + } catch (t) { + console.error("[FR24Card] fetchFlightsData error:", t); + } + } + calculateFlightData() { + try { + let t = !1, e = !1; + const a = Date.now() / 1e3, i = K(this.cardState); + if (i) { + const o = i.latitude, r = i.longitude; + this.cardState.flights.forEach((n) => { + n._timestamp || (n._timestamp = a), e = e || n.ground_speed > 0; + const d = a - (n._timestamp || a); + if (d > 1) { + t = !0, n._timestamp = a; + const l = it(n.latitude, n.longitude, n.heading, n.ground_speed * 1.852 / 3600 * d); + n.latitude = l.lat, n.longitude = l.lon; + const u = Math.max(n.altitude + d / 60 * n.vertical_speed, 0); + (n.landed || u !== n.altitude && u === 0) && (n.landed = !0, n.ground_speed = Math.max(n.ground_speed - 15 * d, 15)), n.altitude = u; + } + if (n.distance_to_tracker = j(o, r, n.latitude, n.longitude, this.cardState.units.distance), n.heading_from_tracker = V(o, r, n.latitude, n.longitude), n.cardinal_direction_from_tracker = Pt(n.heading_from_tracker), n.is_approaching = ht((n.heading_from_tracker + 180) % 360, n.heading), n.is_receding = ht(n.heading_from_tracker, n.heading), n.is_approaching) { + let l = zt(o, r, n.latitude, n.longitude, n.heading); + n.closest_passing_distance = Math.round(j(o, r, l.lat, l.lon, this.cardState.units.distance)); + const u = this.calculateETA(n.latitude, n.longitude, l.lat, l.lon, n.ground_speed); + if (n.eta_to_closest_distance = Math.round(u), n.vertical_speed < 0 && n.altitude > 0) { + const _ = n.altitude / Math.abs(n.vertical_speed), m = it(n.latitude, n.longitude, n.heading, n.ground_speed * _ / 60), g = j(o, r, m.lat, m.lon, this.cardState.units.distance); + _ < u && (n.is_landing = !0, n.closest_passing_distance = Math.round(g), n.eta_to_closest_distance = Math.round(_), l = m); + } + n.heading_from_tracker_to_closest_passing = Math.round(V(o, r, l.lat, l.lon)); + } else + delete n.closest_passing_distance, delete n.eta_to_closest_distance, delete n.heading_from_tracker_to_closest_passing, delete n.is_landing; + }); + } else console.error("Tracker state is undefined. Make sure the location tracker entity ID is correct."); + return { + projected: t, + moving: e + }; + } catch (t) { + return console.error("[FR24Card] calculateFlightData error:", t), { + projected: !1, + moving: !1 + }; + } + } + calculateETA(t, e, a, i, o) { + try { + const r = j(t, e, a, i, this.cardState.units.distance); + return o === 0 ? 1 / 0 : r / (o * (this.cardState.units.distance === "km" ? 1.852 : 1.15078) / 60); + } catch (r) { + return console.error("[FR24Card] calculateETA error:", r), 1 / 0; + } + } + toggleSelectedFlight(t) { + try { + this.cardState.selectedFlights || (this.cardState.selectedFlights = []), this.cardState.selectedFlights.includes(t.id) ? this.cardState.selectedFlights = this.cardState.selectedFlights.filter((e) => e !== t.id) : this.cardState.selectedFlights.push(t.id), this.renderDynamic(); + } catch (e) { + console.error("[FR24Card] toggleSelectedFlight error:", e); + } + } + get hass() { + return this.cardState.hass; + } +}; +customElements.define("flightradar24-radar-card", ce); +typeof window < "u" && (window.customCards = window.customCards || [], window.customCards.push({ + type: "flightradar24-radar-card", + name: "Flightradar24 Radar Card", + preview: !0, + description: "A custom card for displaying Flightradar24 flight tracking data.", + getEntitySuggestion: (t, e) => { + const a = t.states[e]; + return !a || !Array.isArray(a.attributes?.flights) ? null : { config: { + type: "custom:flightradar24-radar-card", + flights_entity: e + } }; + } +})); diff --git a/package.json b/package.json index 1361130..521aa94 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "flightradar24-card", - "version": "0.3.0", + "version": "0.4.1", "type": "module", "description": "Home Assistant custom card for Flightradar24 integration", "devDependencies": { diff --git a/render/map.ts b/render/map.ts index a2d068d..d890537 100644 --- a/render/map.ts +++ b/render/map.ts @@ -1,5 +1,11 @@ import { getLocation } from '../utils/location'; import { haversine } from '../utils/geometric'; +import { + getTileProvider, + buildTileUrl, + requiresApiKey, + VALID_MAPS +} from './tileProviders'; import type { CardState, LeafletMap } from '../types/cardState'; declare global { @@ -28,7 +34,7 @@ interface LeafletMapOptions { doubleClickZoom: boolean; keyboard: boolean; touchZoom: boolean; - zoomSnap: number; + zoomSnap?: number; pointerEvents: boolean; } @@ -44,15 +50,31 @@ interface TileLayerOptions { type LatLngBoundsLiteral = [[number, number], [number, number]]; -type BackgroundMapType = 'none' | 'system' | 'bw' | 'color' | 'dark' | 'outlines'; - -const VALID_MAPS = new Set(['bw', 'light', 'color', 'dark', 'voyager', 'satellite', 'topo', 'outlines', 'system']); - export function shouldRenderRadarBackgroundMap(cardState: CardState): boolean { const radar = cardState?.radar; if (!radar || radar.hide === true) return false; - if (!radar.background_map || !VALID_MAPS.has(radar.background_map)) return false; - return true; + // Read the raw config so the default 'none' (nothing configured) can be told + // apart from an explicit "none" choice, which hides the map entirely. + const configured = cardState?.config?.radar?.background_map as string | undefined; + const isValid = !!configured && (VALID_MAPS.has(configured) || configured === 'system'); + // Square map view always renders a map background unless 'none' was chosen. + if (radar.view === 'map') { + return !configured || isValid; + } + if (!configured || configured === 'none') return false; + return isValid; +} + +/** Best-effort dark theme detection (Home Assistant parent theme, else OS preference). */ +function isDarkTheme(): boolean { + const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; + let haDark = false; + try { + haDark = !!(window.parent && window.parent.document && window.parent.document.body.classList.contains('dark')); + } catch (_e) { + // Cross-origin access may fail, ignore + } + return haDark || prefersDark; } /** @@ -61,6 +83,12 @@ export function shouldRenderRadarBackgroundMap(cardState: CardState): boolean { */ export function ensureLeafletLoadedIfNeeded(cardState: CardState, shadowRoot: ShadowRoot, onReady: () => void): void { if (!shouldRenderRadarBackgroundMap(cardState)) { + // No tiled background is wanted (e.g. background_map: 'none'), but the + // map area with radar overlays and flights still renders. Run the render + // callback immediately instead of gating it behind Leaflet. + if (cardState.radar?.hide !== true) { + onReady(); + } return; } // Always ensure Leaflet CSS is present. renderStatic clears shadow DOM, @@ -124,71 +152,18 @@ export function setupRadarMapBg(cardState: CardState, radarScreen: HTMLElement): return; } - const type = config?.radar?.background_map as BackgroundMapType | undefined; - - const TILE_LAYERS: Record = { - bw: [ - 'https://tiles.stadiamaps.com/tiles/stamen_toner/{z}/{x}/{y}.png', - { - api_key: '?api_key=', - attribution: 'Map tiles by Stamen Design, CC BY 3.0 — Map data © OpenStreetMap', - subdomains: [] - } - ], - light: [ - 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png', - { - attribution: '© CartoDB, © OpenStreetMap contributors', - subdomains: ['a', 'b', 'c', 'd'] - } - ], - color: [ - 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', - { - attribution: '© OpenStreetMap contributors', - subdomains: ['a', 'b', 'c'] - } - ], - dark: [ - 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png', - { - attribution: '© CartoDB, © OpenStreetMap contributors', - subdomains: ['a', 'b', 'c', 'd'] - } - ], - voyager: [ - 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}.png', - { - attribution: '© CartoDB, © OpenStreetMap contributors', - subdomains: ['a', 'b', 'c', 'd'] - } - ], - satellite: [ - 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', - { - attribution: '© Esri, Maxar, Earthstar Geographics', - subdomains: [] - } - ], - topo: [ - 'https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png', - { - attribution: '© OpenTopoMap, © OpenStreetMap contributors', - subdomains: ['a', 'b', 'c'] - } - ], - outlines: [ - 'https://tiles.stadiamaps.com/tiles/stamen_toner_lines/{z}/{x}/{y}.png', - { - api_key: '?api_key=', - attribution: 'Map tiles by Stamen Design, hosted by Stadia Maps; Data by OpenStreetMap', - subdomains: [] - } - ], - system: null - }; + const configuredType = config?.radar?.background_map as string | undefined; + // In map view, always show a map. Fall back to the auto dark/light "system" + // tiles when the user has not chosen an explicit background map. + let mode = configuredType; + if (cardState.radar?.view === 'map' && (!configuredType || configuredType === 'none' || !VALID_MAPS.has(configuredType))) { + mode = 'system'; + } - const opacity = typeof config?.radar?.background_map_opacity === 'number' ? Math.max(0, Math.min(1, config.radar.background_map_opacity)) : 1; + const mapView = cardState.radar?.view === 'map'; + const opacity = mapView + ? 1 + : (typeof config?.radar?.background_map_opacity === 'number' ? Math.max(0, Math.min(1, config.radar.background_map_opacity)) : 1); let mapBg = radarScreen.querySelector('#radar-map-bg') as HTMLDivElement | null; if (!mapBg) { @@ -231,49 +206,51 @@ export function setupRadarMapBg(cardState: CardState, radarScreen: HTMLElement): [lat - deltaLat, lon - deltaLon], [lat + deltaLat, lon + deltaLon] ]; - let resolvedType = type; - if (type === 'system') { - const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; - let haDark = false; - try { - haDark = !!(window.parent && window.parent.document && window.parent.document.body.classList.contains('dark')); - } catch (_e) { - // Cross-origin access may fail, ignore - } - if (haDark || prefersDark) { - resolvedType = 'dark'; + // Resolve the effective map type and its API key. + // - 'system' (auto dark/light) picks from the user's two configured maps, + // one for light theme and one for dark theme, each with its own key. + // - an explicit provider id uses background_map + background_map_api_key + // (kept for backwards compatibility). + let finalType: string; + let effectiveApiKey: string | undefined; + if (mode === 'system') { + if (isDarkTheme()) { + finalType = config?.radar?.background_map_dark || 'dark'; + effectiveApiKey = config?.radar?.background_map_dark_api_key || ''; } else { - resolvedType = 'color'; + finalType = config?.radar?.background_map_light || 'color'; + effectiveApiKey = config?.radar?.background_map_light_api_key || ''; } + } else { + finalType = configuredType && VALID_MAPS.has(configuredType) ? configuredType : 'color'; + effectiveApiKey = config?.radar?.background_map_api_key || ''; } - const tileLayerConfig = TILE_LAYERS[resolvedType || 'color'] || TILE_LAYERS.color; - if (!tileLayerConfig) return mapBg; - - let [tileUrl, tileOpts] = tileLayerConfig; // eslint-disable-line prefer-const - - // Check if this tile provider requires an API key - const requiresApiKey = tileOpts && 'api_key' in tileOpts; - const hasApiKey = config?.radar?.background_map_api_key && config.radar.background_map_api_key.trim().length > 0; - if (requiresApiKey && !hasApiKey) { - if (cardState._leafletMap) { - cardState._leafletMap.remove(); - cardState._leafletMap = null; + const provider = getTileProvider(finalType); + if (!provider) return mapBg; + const tileUrl = buildTileUrl(finalType, effectiveApiKey); + if (!tileUrl) return mapBg; + const tileOpts = { attribution: provider.attribution, subdomains: provider.subdomains }; + + // If this provider requires an API key and none is configured, show a notice. + if (requiresApiKey(finalType)) { + const hasApiKey = effectiveApiKey && effectiveApiKey.trim().length > 0; + if (!hasApiKey) { + if (cardState._leafletMap) { + cardState._leafletMap.remove(); + cardState._leafletMap = null; + } + mapBg.innerHTML = '
API key required for this map type. Configure in Background Map settings.
'; + return mapBg; } - mapBg.innerHTML = '
API key required for this map type. Configure in Background Map settings.
'; - return mapBg; } if (!cardState._leafletMap) { mapBg.innerHTML = ''; } - if (requiresApiKey && hasApiKey && config?.radar?.background_map_api_key) { - tileUrl = tileUrl + tileOpts.api_key + encodeURIComponent(config.radar.background_map_api_key); - } - if (window.L) { - const newMapConfig = { type: resolvedType || 'color', apiKey: config?.radar?.background_map_api_key }; + const newMapConfig = { type: finalType, apiKey: effectiveApiKey }; const mapConfigChanged = !cardState._currentMapConfig || cardState._currentMapConfig.type !== newMapConfig.type || cardState._currentMapConfig.apiKey !== newMapConfig.apiKey; diff --git a/render/radarScreen.ts b/render/radarScreen.ts index 0e0243d..45ed0ca 100644 --- a/render/radarScreen.ts +++ b/render/radarScreen.ts @@ -58,23 +58,30 @@ export function renderRadarScreen(cardState: CardState): void { const clippingRange = radarRange * 1.15; - const ringDistance = radar?.ring_distance ?? 10; - const ringCount = Math.floor(radarRange / ringDistance); - for (let i = 1; i <= ringCount; i++) { - const radius = i * ringDistance * scaleFactor; - const ring = document.createElement('div'); - ring.className = 'ring'; - ring.style.width = ring.style.height = radius * 2 + 'px'; - ring.style.top = Math.floor(radarCenterY - radius) + 'px'; - ring.style.left = Math.floor(radarCenterX - radius) + 'px'; - radarScreen.appendChild(ring); - } + // In square map view the radar grid rings / bearing lines are hidden by + // default so the map is shown cleanly. They can be forced back on with + // the `radar.rings` setting. Flights are still rendered on top by renderRadar(). + const showRings = radar?.rings ?? radar?.view !== 'map'; + + if (showRings) { + const ringDistance = radar?.ring_distance ?? 10; + const ringCount = Math.floor(radarRange / ringDistance); + for (let i = 1; i <= ringCount; i++) { + const radius = i * ringDistance * scaleFactor; + const ring = document.createElement('div'); + ring.className = 'ring'; + ring.style.width = ring.style.height = radius * 2 + 'px'; + ring.style.top = Math.floor(radarCenterY - radius) + 'px'; + ring.style.left = Math.floor(radarCenterX - radius) + 'px'; + radarScreen.appendChild(ring); + } - for (let angle = 0; angle < 360; angle += 45) { - const line = document.createElement('div'); - line.className = 'dotted-line'; - line.style.transform = `rotate(${angle - 90}deg)`; - radarScreen.appendChild(line); + for (let angle = 0; angle < 360; angle += 45) { + const line = document.createElement('div'); + line.className = 'dotted-line'; + line.style.transform = `rotate(${angle - 90}deg)`; + radarScreen.appendChild(line); + } } const location = getLocation(cardState); diff --git a/render/static.ts b/render/static.ts index a72195b..f671131 100644 --- a/render/static.ts +++ b/render/static.ts @@ -20,6 +20,17 @@ export function renderStatic(cardState: StaticCardState, mainCard: StaticMainCar const card = document.createElement('ha-card'); card.id = 'flights-card'; + // Layout wrapper that holds the radar/map and the flights list. The class + // reflects the configured list position so CSS can lay them out side by side + // on wide cards, falling back to a stacked "below" layout on narrow cards. + const layoutRoot = document.createElement('div'); + layoutRoot.id = 'layout-root'; + const listPosition = cardState.list?.position || 'below'; + layoutRoot.classList.add(`layout-${listPosition}`); + if (cardState.radar?.view === 'map') { + layoutRoot.classList.add('view-map'); + } + if (!cardState.radar?.hide) { const radarContainer = document.createElement('div'); radarContainer.id = 'radar-container'; @@ -51,7 +62,7 @@ export function renderStatic(cardState: StaticCardState, mainCard: StaticMainCar radarContainer.appendChild(radar); radarContainer.appendChild(toggleContainer); - card.appendChild(radarContainer); + layoutRoot.appendChild(radarContainer); requestAnimationFrame(() => { renderRadarScreen(cardState); @@ -69,6 +80,7 @@ export function renderStatic(cardState: StaticCardState, mainCard: StaticMainCar cardState.dom.radar = radar; cardState.dom.radarScreen = radarScreenDiv; cardState.dom.radarInfoDisplay = radarInfoDisplay; + cardState.dom.radarContainer = radarContainer; cardState.dom.shadowRoot = mainCard.shadowRoot; cardState.mainCard = mainCard; } @@ -78,7 +90,9 @@ export function renderStatic(cardState: StaticCardState, mainCard: StaticMainCar if (cardState.list && cardState.list.hide === true) { flightsContainer.style.display = 'none'; } - card.appendChild(flightsContainer); + layoutRoot.appendChild(flightsContainer); + + card.appendChild(layoutRoot); mainCard.shadowRoot.appendChild(card); diff --git a/render/style.ts b/render/style.ts index d30e68a..ede01af 100644 --- a/render/style.ts +++ b/render/style.ts @@ -32,9 +32,42 @@ export function renderStyle(cardState: CardState, shadowRoot: ShadowRoot): void padding: 16px; transform: scale(${scale}); transform-origin: top center; + container-type: inline-size; + } + #layout-root { + display: flex; + flex-direction: column; + width: 100%; + } + /* + Responsive list position: when the list is configured to sit on the + left or right of the radar/map, only keep it side by side when the card + is wide enough. Narrower cards fall back to the stacked "below" layout. + */ + @container (min-width: 560px) { + #layout-root.layout-left, + #layout-root.layout-right { + flex-direction: row; + align-items: stretch; + gap: 16px; + } + #layout-root.layout-left #radar-container, + #layout-root.layout-right #radar-container { + flex: 1 1 60%; + min-width: 0; + } + #layout-root.layout-left #flights, + #layout-root.layout-right #flights { + flex: 1 1 40%; + min-width: 0; + } + #layout-root.layout-left #flights { + order: -1; + } } #flights { padding: 0px; + min-width: 0; } #flights .flight { margin-top: 16px; @@ -79,6 +112,7 @@ export function renderStyle(cardState: CardState, shadowRoot: ShadowRoot): void display: flex; justify-content: space-between; position: relative; + min-width: 0; } #radar-overlay { position: absolute; @@ -129,6 +163,13 @@ export function renderStyle(cardState: CardState, shadowRoot: ShadowRoot): void border-radius: 50%; overflow: hidden; } + /* Square map view: full-bleed square map instead of the circular radar screen */ + #layout-root.view-map #radar { + width: 100%; + margin: 0; + padding-bottom: 100%; + border-radius: 0; + } #radar-screen { position: absolute; width: 100%; diff --git a/render/tileProviders.ts b/render/tileProviders.ts new file mode 100644 index 0000000..0e3b198 --- /dev/null +++ b/render/tileProviders.ts @@ -0,0 +1,141 @@ +/** + * Tile provider store. + * + * Central place describing every background-map provider the card can render. + * Each entry carries the URL template, whether/how an API key is appended, + * provider-specific help text (including the key request link), and the values + * used by both the renderer and the editor. Keeping this data here means the + * renderer and the editor never have to hard-code provider quirks. + */ + +export type TileProviderGroup = 'keyless' | 'keyed'; + +export interface TileProvider { + /** Config value, e.g. `radar.background_map: 'light'`. */ + id: string; + /** Human-readable label shown in the card editor. */ + label: string; + /** Whether this provider needs no key (keyless) or does (keyed). */ + group: TileProviderGroup; + /** Leaflet tile URL template. */ + url: string; + /** + * Query parameter used to append the API key, e.g. '?key=' (CARTO) or + * '?api_key=' (Stadia). Present means the provider requires a key. + */ + apiKeyParam?: string; + /** Optional HTML help text shown in the editor when this provider is selected. */ + apiKeyHelp?: string; + /** Map attribution. */ + attribution: string; + /** Tile server subdomains. */ + subdomains: string[]; +} + +export const TILE_PROVIDERS: TileProvider[] = [ + // --- Keyless --- + { + id: 'color', + label: 'Color (OpenStreetMap)', + group: 'keyless', + url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', + attribution: '© OpenStreetMap contributors', + subdomains: ['a', 'b', 'c'] + }, + { + id: 'satellite', + label: 'Satellite', + group: 'keyless', + url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', + attribution: '© Esri, Maxar, Earthstar Geographics', + subdomains: [] + }, + { + id: 'topo', + label: 'Topographic', + group: 'keyless', + url: 'https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png', + attribution: '© OpenTopoMap, © OpenStreetMap contributors', + subdomains: ['a', 'b', 'c'] + }, + // --- Keyed: CARTO (appended as ?key=) --- + { + id: 'light', + label: 'Light (CARTO)', + group: 'keyed', + url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png', + apiKeyParam: '?key=', + apiKeyHelp: 'Required for Light, Dark and Voyager. Request a free CARTO key.', + attribution: '© CartoDB, © OpenStreetMap contributors', + subdomains: ['a', 'b', 'c', 'd'] + }, + { + id: 'dark', + label: 'Dark (CARTO)', + group: 'keyed', + url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png', + apiKeyParam: '?key=', + apiKeyHelp: 'Required for Light, Dark and Voyager. Request a free CARTO key.', + attribution: '© CartoDB, © OpenStreetMap contributors', + subdomains: ['a', 'b', 'c', 'd'] + }, + { + id: 'voyager', + label: 'Voyager (CARTO)', + group: 'keyed', + url: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}.png', + apiKeyParam: '?key=', + apiKeyHelp: 'Required for Light, Dark and Voyager. Request a free CARTO key.', + attribution: '© CartoDB, © OpenStreetMap contributors', + subdomains: ['a', 'b', 'c', 'd'] + }, + // --- Keyed: Stadia Maps (appended as ?api_key=) --- + { + id: 'bw', + label: 'Black & White (Stadia)', + group: 'keyed', + url: 'https://tiles.stadiamaps.com/tiles/stamen_toner/{z}/{x}/{y}.png', + apiKeyParam: '?api_key=', + apiKeyHelp: 'Required for Black & White and Outlines. Get a free Stadia Maps key.', + attribution: 'Map tiles by Stamen Design, CC BY 3.0 — Map data © OpenStreetMap', + subdomains: [] + }, + { + id: 'outlines', + label: 'Outlines (Stadia)', + group: 'keyed', + url: 'https://tiles.stadiamaps.com/tiles/stamen_toner_lines/{z}/{x}/{y}.png', + apiKeyParam: '?api_key=', + apiKeyHelp: 'Required for Black & White and Outlines. Get a free Stadia Maps key.', + attribution: 'Map tiles by Stamen Design, hosted by Stadia Maps; Data by OpenStreetMap', + subdomains: [] + } +]; + +const BY_ID = new Map(TILE_PROVIDERS.map((p) => [p.id, p])); + +/** All valid selectable background map ids (excludes special 'none'/'system'). */ +export const VALID_MAPS = new Set(TILE_PROVIDERS.map((p) => p.id)); + +export function getTileProvider(id?: string): TileProvider | undefined { + if (!id) return undefined; + return BY_ID.get(id); +} + +export function requiresApiKey(id?: string): boolean { + return !!getTileProvider(id)?.apiKeyParam; +} + +export function getTileProviderHelp(id?: string): string { + return getTileProvider(id)?.apiKeyHelp || ''; +} + +/** Build the tile-provider URL, appending the API key where the provider requires one. */ +export function buildTileUrl(id: string, apiKey?: string): string { + const provider = getTileProvider(id); + if (!provider) return ''; + if (provider.apiKeyParam && apiKey && apiKey.trim().length > 0) { + return provider.url + provider.apiKeyParam + encodeURIComponent(apiKey.trim()); + } + return provider.url; +} diff --git a/test/browser/card.test.ts b/test/browser/card.test.ts index 515b4bc..6686fb6 100644 --- a/test/browser/card.test.ts +++ b/test/browser/card.test.ts @@ -30,10 +30,10 @@ describe('Flightradar24Card browser tests', () => { expect(consoleErrors).toHaveLength(0); }); - it('should register flightradar24-card alias when name is free', async () => { + it('should not register flightradar24-card to avoid colliding with the integration card', async () => { await import('../../flightradar24-card'); - expect(customElements.get('flightradar24-card')).toBeDefined(); + expect(customElements.get('flightradar24-card')).toBeUndefined(); expect(consoleErrors).toHaveLength(0); }); @@ -84,6 +84,56 @@ describe('Flightradar24Card browser tests', () => { expect(card.shadowRoot?.innerHTML).not.toBe(''); }); + it('should apply the configured list position class to the layout root', async () => { + await import('../../flightradar24-card'); + + const card = document.createElement('flightradar24-radar-card') as HTMLElement & { + setConfig: (config: Record) => void; + }; + document.body.appendChild(card); + + card.setConfig({ + radar: {}, + list: { position: 'left' } + }); + + const layoutRoot = card.shadowRoot?.getElementById('layout-root'); + expect(layoutRoot).not.toBeNull(); + expect(layoutRoot!.classList.contains('layout-left')).toBe(true); + // Radar is not hidden, so the radar container should be present + expect(card.shadowRoot?.getElementById('radar-container')).not.toBeNull(); + }); + + it('should default the layout to the below position', async () => { + await import('../../flightradar24-card'); + + const card = document.createElement('flightradar24-radar-card') as HTMLElement & { + setConfig: (config: Record) => void; + }; + document.body.appendChild(card); + + card.setConfig({ radar: {} }); + + const layoutRoot = card.shadowRoot?.getElementById('layout-root'); + expect(layoutRoot).not.toBeNull(); + expect(layoutRoot!.classList.contains('layout-below')).toBe(true); + }); + + it('should apply the map view class on the layout root', async () => { + await import('../../flightradar24-card'); + + const card = document.createElement('flightradar24-radar-card') as HTMLElement & { + setConfig: (config: Record) => void; + }; + document.body.appendChild(card); + + card.setConfig({ radar: { view: 'map' } }); + + const layoutRoot = card.shadowRoot?.getElementById('layout-root'); + expect(layoutRoot).not.toBeNull(); + expect(layoutRoot!.classList.contains('view-map')).toBe(true); + }); + it('should not produce unhandled promise rejections', async () => { const rejections: PromiseRejectionEvent[] = []; const handler = (event: PromiseRejectionEvent) => { @@ -137,6 +187,7 @@ describe('Flightradar24Card browser tests', () => { await import('../../flightradar24-card'); const entry = (window as unknown as { customCards: Array<{ + type: string; getEntitySuggestion: (hass: Record, entityId: string) => unknown; }> }).customCards.find((card) => card.type === 'flightradar24-radar-card'); diff --git a/test/unit/map/should-render-map.test.ts b/test/unit/map/should-render-map.test.ts new file mode 100644 index 0000000..a7145ab --- /dev/null +++ b/test/unit/map/should-render-map.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect, vi } from 'vitest'; +import { shouldRenderRadarBackgroundMap, ensureLeafletLoadedIfNeeded } from '../../../render/map'; +import { createMockCardState } from '../../fixtures/cardState'; + +const render = (overrides: { config?: Record; radar?: Record } = {}): boolean => + shouldRenderRadarBackgroundMap(createMockCardState(overrides as any) as never); + +const fakeShadowRoot = { querySelector: () => null, appendChild: (el: unknown) => el } as unknown as ShadowRoot; + +describe('shouldRenderRadarBackgroundMap (tile background gate)', () => { + it('renders no tiles in radar view when no background map is configured', () => { + expect(render({ radar: { view: 'radar' } })).toBe(false); + }); + + it('renders no tiles in radar view when none is explicitly chosen', () => { + expect(render({ config: { radar: { background_map: 'none' } }, radar: { view: 'radar' } })).toBe(false); + }); + + it('renders tiles in radar view when a valid map is configured', () => { + expect(render({ config: { radar: { background_map: 'color' } }, radar: { view: 'radar', background_map: 'color' } })).toBe(true); + }); + + it('renders tiles in radar view when system is configured', () => { + expect(render({ config: { radar: { background_map: 'system' } }, radar: { view: 'radar', background_map: 'system' } })).toBe(true); + }); + + it('renders tiles in map view when nothing is configured (system fallback)', () => { + expect(render({ radar: { view: 'map' } })).toBe(true); + }); + + it('renders no tiles in map view when none is explicitly chosen', () => { + expect(render({ config: { radar: { background_map: 'none' } }, radar: { view: 'map', background_map: 'none' } })).toBe(false); + }); + + it('renders tiles in map view when a valid map is configured', () => { + expect(render({ config: { radar: { background_map: 'satellite' } }, radar: { view: 'map', background_map: 'satellite' } })).toBe(true); + }); + + it('renders tiles in map view when system is configured', () => { + expect(render({ config: { radar: { background_map: 'system' } }, radar: { view: 'map', background_map: 'system' } })).toBe(true); + }); + + it('renders no tiles when the radar is hidden', () => { + expect(render({ config: { radar: { background_map: 'color' } }, radar: { view: 'radar', hide: true, background_map: 'color' } })).toBe(false); + }); +}); + +describe('ensureLeafletLoadedIfNeeded', () => { + it('renders the map screen immediately when none is selected in map view (map area with overlays still shows)', () => { + const cardState = createMockCardState({ + config: { radar: { background_map: 'none' } }, + radar: { view: 'map', background_map: 'none' } + }); + const onReady = vi.fn(); + ensureLeafletLoadedIfNeeded(cardState as never, fakeShadowRoot, onReady); + expect(onReady).toHaveBeenCalledTimes(1); + }); + + it('does not render the map screen when the radar is hidden', () => { + const cardState = createMockCardState({ + config: { radar: { background_map: 'none' } }, + radar: { view: 'map', hide: true, background_map: 'none' } + }); + const onReady = vi.fn(); + ensureLeafletLoadedIfNeeded(cardState as never, fakeShadowRoot, onReady); + expect(onReady).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/test/unit/state/state.test.ts b/test/unit/state/state.test.ts index 2cca252..4c2ad66 100644 --- a/test/unit/state/state.test.ts +++ b/test/unit/state/state.test.ts @@ -78,7 +78,21 @@ describe('Flightradar24CardState', () => { const state = new Flightradar24CardState(); state.setConfig({ list: { hide: true } }); - expect(state.list).toEqual({ hide: true, showListStatus: true }); + expect(state.list).toEqual({ hide: true, showListStatus: true, position: 'below' }); + }); + + it('sets default list position to below', () => { + const state = new Flightradar24CardState(); + state.setConfig({}); + + expect(state.list.position).toBe('below'); + }); + + it('uses custom list position', () => { + const state = new Flightradar24CardState(); + state.setConfig({ list: { position: 'right' } }); + + expect(state.list.position).toBe('right'); }); it('merges units config with defaults', () => { @@ -115,6 +129,49 @@ describe('Flightradar24CardState', () => { expect(state.radar.background_map).toBe('dark'); }); + it('defaults per-theme maps for system mode and keeps them backwards compatible', () => { + const state = new Flightradar24CardState(); + state.setConfig({ radar: { background_map: 'system' } }); + + expect(state.radar.background_map).toBe('system'); + expect(state.radar.background_map_light).toBe('color'); + expect(state.radar.background_map_dark).toBe('dark'); + expect(state.radar.background_map_light_api_key).toBe(''); + expect(state.radar.background_map_dark_api_key).toBe(''); + }); + + it('merges per-theme map config and their api keys', () => { + const state = new Flightradar24CardState(); + state.setConfig({ + radar: { + background_map: 'system', + background_map_light: 'voyager', + background_map_dark: 'satellite', + background_map_light_api_key: 'light-key', + background_map_dark_api_key: 'dark-key' + } + }); + + expect(state.radar.background_map_light).toBe('voyager'); + expect(state.radar.background_map_dark).toBe('satellite'); + expect(state.radar.background_map_light_api_key).toBe('light-key'); + expect(state.radar.background_map_dark_api_key).toBe('dark-key'); + }); + + it('sets default radar view to radar', () => { + const state = new Flightradar24CardState(); + state.setConfig({}); + + expect(state.radar.view).toBe('radar'); + }); + + it('uses custom radar view from config', () => { + const state = new Flightradar24CardState(); + state.setConfig({ radar: { view: 'map' } }); + + expect(state.radar.view).toBe('map'); + }); + it('sets initialRange from range', () => { const state = new Flightradar24CardState(); state.setConfig({ radar: { range: 50 } }); diff --git a/types/cardState.ts b/types/cardState.ts index e631ea5..3bae8e6 100644 --- a/types/cardState.ts +++ b/types/cardState.ts @@ -43,6 +43,7 @@ export interface LeafletMap { containerPointToLatLng(point: { x: number; y: number }): { lat: number; lng: number }; getZoom(): number; setZoom(zoom: number): void; + invalidateSize(options?: { pan?: boolean }): void; } /** diff --git a/types/config.ts b/types/config.ts index c5c3114..766c322 100644 --- a/types/config.ts +++ b/types/config.ts @@ -66,12 +66,20 @@ export interface AircraftMarkerEntry { 'aircraft-marker-shadow'?: string; } +export type RadarView = 'radar' | 'map'; + export interface RadarConfig { range?: number; initialRange?: number; min_range?: number; max_range?: number; ring_distance?: number; + // Display mode: 'radar' = circular radar screen (default), 'map' = square full-bleed map + view?: RadarView; + // Whether to draw the radar grid rings / bearing lines. Defaults to on for + // the circular 'radar' view and off for the square 'map' view. Set explicitly + // to override. + rings?: boolean; filter?: boolean | Condition[]; // Old color properties (kept for backwards compatibility) 'primary-color'?: string; @@ -94,11 +102,22 @@ export interface RadarConfig { background_map?: 'none' | 'system' | 'bw' | 'light' | 'color' | 'dark' | 'voyager' | 'satellite' | 'topo' | 'outlines'; background_map_opacity?: number; background_map_api_key?: string; + // Per-theme maps used when background_map is 'system' (auto dark/light). + // Each can carry its own API key. + background_map_light?: 'bw' | 'light' | 'color' | 'dark' | 'voyager' | 'satellite' | 'topo' | 'outlines'; + background_map_dark?: 'bw' | 'light' | 'color' | 'dark' | 'voyager' | 'satellite' | 'topo' | 'outlines'; + background_map_light_api_key?: string; + background_map_dark_api_key?: string; } +export type ListPosition = 'below' | 'left' | 'right'; + export interface ListConfig { hide?: boolean; showListStatus?: boolean; + // Position of the flight list relative to the radar/map. + // Falls back to 'below' when the card is too narrow (see README). + position?: ListPosition; } export interface UnitsConfig { diff --git a/vitest.browser.config.ts b/vitest.browser.config.ts index 9507eb1..924b211 100644 --- a/vitest.browser.config.ts +++ b/vitest.browser.config.ts @@ -6,7 +6,7 @@ export default defineConfig({ include: ['test/browser/**/*.test.ts'], browser: { enabled: true, - provider: playwright({ launch: { headless: true } }), + provider: playwright({ launchOptions: { headless: true } }), instances: [{ browser: 'chromium' }] } }