├── .github └── workflows │ ├── build.yml │ └── release.yml ├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── card.png ├── config ├── sortConfig.js ├── templateConfig.js └── unitsConfig.js ├── flightradar24-card.js ├── hacs.json ├── package.json ├── render ├── flag.js ├── map.js ├── radar.js ├── radarScreen.js ├── static.js ├── style.js └── toggles.js ├── resources ├── example_templates_747_a380_toggle.PNG └── example_templates_tails_dark.PNG ├── rollup.config.js ├── test └── index.html ├── utils ├── filter.js ├── geometric.js ├── location.js ├── sort.js ├── template.js └── zoom.js └── yarn.lock /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: CI — Build (Rollup) 2 | 3 | # Run on pushes to main, PRs, tag pushes (so tag-based releases build), and manual dispatch 4 | on: 5 | push: 6 | branches: [ main ] 7 | tags: 8 | - 'v*.*.*' 9 | pull_request: 10 | branches: [ main ] 11 | workflow_dispatch: 12 | 13 | # Prevent duplicate concurrent runs for the same ref 14 | concurrency: 15 | group: build-${{ github.ref }} 16 | cancel-in-progress: true 17 | 18 | permissions: 19 | contents: read 20 | 21 | jobs: 22 | build: 23 | name: Build, zip and upload artifact 24 | runs-on: ubuntu-latest 25 | steps: 26 | - name: Checkout repository 27 | uses: actions/checkout@v4 28 | with: 29 | fetch-depth: 0 30 | 31 | - name: Setup Node.js 18 and Yarn cache 32 | uses: actions/setup-node@v4 33 | with: 34 | node-version: '18' 35 | cache: 'yarn' 36 | 37 | - name: Cache yarn cache 38 | uses: actions/cache@v4 39 | with: 40 | path: | 41 | ~/.cache/yarn 42 | ~/.config/yarn 43 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 44 | restore-keys: | 45 | ${{ runner.os }}-yarn- 46 | 47 | - name: Install dependencies (Yarn) 48 | run: yarn install --frozen-lockfile 49 | 50 | - name: Read package.json into output (for conditional steps) 51 | id: pkg 52 | run: | 53 | if [ -f package.json ]; then 54 | echo "pkg<> $GITHUB_OUTPUT 55 | cat package.json >> $GITHUB_OUTPUT 56 | echo "EOF" >> $GITHUB_OUTPUT 57 | else 58 | echo "pkg<> $GITHUB_OUTPUT 59 | echo "{}" >> $GITHUB_OUTPUT 60 | echo "EOF" >> $GITHUB_OUTPUT 61 | fi 62 | 63 | - name: Run lint (if configured) 64 | if: contains(steps.pkg.outputs.pkg, '"lint"') 65 | run: yarn lint 66 | continue-on-error: true 67 | 68 | - name: Build (Rollup) 69 | run: yarn build 70 | 71 | - name: Run tests (if configured) 72 | if: contains(steps.pkg.outputs.pkg, '"test"') 73 | run: yarn test 74 | continue-on-error: true 75 | 76 | - name: Prepare release artifact 77 | run: | 78 | mkdir -p release-artifact 79 | if [ -d "dist" ]; then 80 | zip -r release-artifact/flightradar24-card.zip dist hacs.json LICENSE README.md 81 | else 82 | echo "dist directory not found — nothing to zip" && exit 1 83 | fi 84 | 85 | - name: Upload build artifact 86 | uses: actions/upload-artifact@v4 87 | with: 88 | name: flightradar24-card 89 | path: release-artifact/flightradar24-card.zip 90 | retention-days: 7 -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Build and attach Rollup artifact to release 2 | 3 | # Triggers: 4 | # - When a release is published (typical "create release" flow) 5 | # - Manual dispatch (useful to create a release from the UI) 6 | on: 7 | release: 8 | types: [published] 9 | workflow_dispatch: 10 | inputs: 11 | tag: 12 | description: 'Tag for release (e.g. v1.2.3)' 13 | required: true 14 | name: 15 | description: 'Release name' 16 | required: false 17 | body: 18 | description: 'Release notes' 19 | required: false 20 | prerelease: 21 | description: 'Pre-release? (true/false)' 22 | required: false 23 | default: 'false' 24 | 25 | permissions: 26 | contents: write 27 | 28 | jobs: 29 | build-and-upload: 30 | runs-on: ubuntu-latest 31 | steps: 32 | - name: Checkout 33 | uses: actions/checkout@v4 34 | with: 35 | fetch-depth: 0 36 | 37 | - name: Setup Node.js 18 and Yarn cache 38 | uses: actions/setup-node@v4 39 | with: 40 | node-version: '18' 41 | cache: 'yarn' 42 | 43 | - name: Cache yarn cache 44 | uses: actions/cache@v4 45 | with: 46 | path: | 47 | ~/.cache/yarn 48 | ~/.config/yarn 49 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 50 | restore-keys: | 51 | ${{ runner.os }}-yarn- 52 | 53 | - name: Install dependencies (Yarn) 54 | run: yarn install --frozen-lockfile 55 | 56 | - name: Build (Rollup) 57 | run: yarn build 58 | 59 | # === Release event handling (true GitHub Release) === 60 | - name: Upload release asset (release published) 61 | if: github.event_name == 'release' 62 | uses: actions/upload-release-asset@v1 63 | env: 64 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 65 | with: 66 | upload_url: ${{ github.event.release.upload_url }} 67 | asset_path: dist/flightradar24-card.js 68 | asset_name: flightradar24-card.js 69 | asset_content_type: application/javascript 70 | 71 | - name: Upload README.md (release published) 72 | if: github.event_name == 'release' 73 | uses: actions/upload-release-asset@v1 74 | env: 75 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 76 | with: 77 | upload_url: ${{ github.event.release.upload_url }} 78 | asset_path: README.md 79 | asset_name: README.md 80 | asset_content_type: text/markdown 81 | 82 | - name: Upload LICENSE (release published) 83 | if: github.event_name == 'release' 84 | uses: actions/upload-release-asset@v1 85 | env: 86 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 87 | with: 88 | upload_url: ${{ github.event.release.upload_url }} 89 | asset_path: LICENSE 90 | asset_name: LICENSE 91 | asset_content_type: text/plain 92 | 93 | # === Manual workflow_dispatch event handling (UI-triggered Release) === 94 | - name: Create release (when manually triggered) 95 | if: github.event_name == 'workflow_dispatch' 96 | id: create_release 97 | uses: softprops/action-gh-release@v1 98 | with: 99 | tag_name: ${{ github.event.inputs.tag }} 100 | name: ${{ github.event.inputs.name }} 101 | body: ${{ github.event.inputs.body }} 102 | draft: false 103 | prerelease: ${{ github.event.inputs.prerelease }} 104 | 105 | - name: Upload release asset (created by this workflow) 106 | if: github.event_name == 'workflow_dispatch' 107 | uses: actions/upload-release-asset@v1 108 | env: 109 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 110 | with: 111 | upload_url: ${{ steps.create_release.outputs.upload_url }} 112 | asset_path: dist/flightradar24-card.js 113 | asset_name: flightradar24-card.js 114 | asset_content_type: application/javascript 115 | 116 | - name: Upload README.md (created by this workflow) 117 | if: github.event_name == 'workflow_dispatch' 118 | uses: actions/upload-release-asset@v1 119 | env: 120 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 121 | with: 122 | upload_url: ${{ steps.create_release.outputs.upload_url }} 123 | asset_path: README.md 124 | asset_name: README.md 125 | asset_content_type: text/markdown 126 | 127 | - name: Upload LICENSE (created by this workflow) 128 | if: github.event_name == 'workflow_dispatch' 129 | uses: actions/upload-release-asset@v1 130 | env: 131 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 132 | with: 133 | upload_url: ${{ steps.create_release.outputs.upload_url }} 134 | asset_path: LICENSE 135 | asset_name: LICENSE 136 | asset_content_type: text/plain -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "livePreview.defaultPreviewPath": "/test/index.html" 3 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Ivar Andreas Bonsaksen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Flightradar24 Integration Card 2 | 3 | Custom card to use with [Flightradar24 integration](https://github.com/AlexandrErohin/home-assistant-flightradar24) for Home Assistant. 4 | 5 | 6 | 7 | ## Table of Contents 8 | 9 | 1. [Introduction](#introduction) 10 | 2. [Installation](#installation) 11 | 3. [Configuration](#configuration) 12 | - [Basic Configuration](#basic-configuration) 13 | - [Advanced Configuration](#advanced-configuration) 14 | - [Sort](#sort-configuration) 15 | - [Filter](#filter-configuration) 16 | - [Radar](#radar-configuration) 17 | - [Radar filter](#radar-filter) 18 | - [Radar features](#radar-features) 19 | - [Radar map background](#radar-map-background) 20 | - [List](#list-configuration) 21 | - [Annotations](#annotation-configuration) 22 | - [Toggles](#toggles-configuration) 23 | - [Defines](#defines-configuration) 24 | - [Templates](#templates-configuration) 25 | 4. [Usage](#usage) 26 | - [Features](#features) 27 | - [Examples](#examples) 28 | 5. [Support](#support) 29 | 30 | ## Introduction 31 | 32 | The Flightradar24 Integration Card allows you to display flight data from Flightradar24 in your Home Assistant dashboard. You can track flights near your location, view details about specific aircraft, and customize the display to fit your needs. 33 | 34 | ## Installation 35 | 36 | ### Prerequisites 37 | 38 | This card is designed to work with sensor data provided by Flightradar24 integration. 39 | 40 | ### HACS (recommended) 41 | 42 | Have [HACS](https://hacs.xyz/) installed, this will allow you to update easily. 43 | 44 | [![Install quickly via a HACS link](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=Springvar&repository=home-assistant-flightradar24-card&category=plugin) 45 | 46 | 1. Go to **HACS** -> **Integrations**. 47 | 2. Add this repository ([https://github.com/Springvar/home-assistant-flightradar24-card](https://github.com/Springvar/home-assistant-flightradar24-card)) as a [custom repository](https://hacs.xyz/docs/faq/custom_repositories/) 48 | 3. Click on `+ Explore & Download Repositories`, search for `Flightradar24 Card`. 49 | 4. Search for and select `Flightradar24 Card`. 50 | 5. Press `DOWNLOAD` and in the next window also press `DOWNLOAD`. 51 | 6. After download, restart Home Assistant. 52 | 53 | ### Manual 54 | 55 | To install the card, follow these steps: 56 | 57 | 1. **Download the Card**: 58 | 59 | - Download the latest release from the [GitHub repository](https://github.com/your-repo/home-assistant-flightradar24-card/releases). 60 | 61 | 2. **Add to Home Assistant**: 62 | 63 | - Place the downloaded files in a `flightradar24` directory under your `www` directory inside your Home Assistant configuration directory. 64 | 65 | 3. **Add the Custom Card to Lovelace**: 66 | - Edit your Lovelace dashboard and add the custom card: 67 | ```yaml 68 | resources: 69 | - url: /local/flightradar24/flightradar24-card.js 70 | type: module 71 | ``` 72 | 73 | ## Configuration 74 | 75 | ### Basic Configuration 76 | 77 | To use the card, simply add the following configuration to your Lovelace dashboard: 78 | 79 | ```yaml 80 | type: custom:flightradar24-card 81 | ``` 82 | 83 | | Name | Description | Default Value | Constraints | 84 | | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | 85 | | `flights_entity` | Entity ID for the Flightradar24 sensor. | `sensor.flightradar24_current_in_area` | Must be a valid sensor entity ID | 86 | | `location_tracker` | Entity ID for a location tracker device.

If provided, all distances and directions will be calculated with the tracker position as the reference position. | None | Must be a valid device_tracker entity ID | 87 | | `location` | Latitude and longitude of a static reference position. Will be used as fallback if tracker is unavailable or not provided. | Home Assistant home location | Must have both lat and lon | 88 | | `projection_interval` | Interval in seconds for when to recalculate projected positions and altitude. | `5` | Number (seconds) - `0` disables projection. Radar and list will refresh when flights sensor or tracker updates. | 89 | | `units` | Unit object to customize units for altitude, speed and distance. | units:
  altitude: ft
  speed: kts
  distance: km | `altitude` must be `m` or `ft`
`speed` must be `kmh`, `mph` or `kts`
`distance` must be `km` or `miles` | 90 | | `no_flights_message` | Message to display if no flights are visible. | `No flights are currently visible. Please check back later.` | String - Use empty string to disable the message | 91 | 92 | _Note:_ If location is configured, this must be within the area fetched by the [Flightradar24 Integration]("https://github.com/AlexandrErohin/home-assistant-flightradar24"). 93 | The location would normally be the same given to the integration. If no location is configured, the home location for Home Assistant will be used. 94 | 95 | ### Advanced Configuration 96 | 97 | #### Sort Configuration 98 | 99 | To sort the displayed flights with a custom sort order, use the sort option. 100 | 101 | ```yaml 102 | sort: 103 | - field: id 104 | comparator: oneOf 105 | value: ${selectedFlights} 106 | order: desc 107 | - field: altitude 108 | comparator: eq 109 | value: 0 110 | order: asc 111 | - field: distance_to_tracker 112 | order: asc 113 | ``` 114 | 115 | | Name | Description | Default Value | Constraints | 116 | | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------- | ------------- | ---------------------------------------------------------------- | 117 | | `field` | Flight field to sort on | None | Must be valid flight field | 118 | | `order` | Sort in ascending or descending order | ASC | Must be `ASC` or `DESC` | 119 | | `comparator` | Optional comparator to evaluate field against a value. If no comparator is given, the fields of the flights are evaluated against each other. | None | Must be `eq`, `lt`, `lte`, `gt`, `gte`, `oneOf`, `containsOneOf` | 120 | | `value` | Optional value to evaluate field against with comparator | None | Must be a valid value or list of values | 121 | 122 | #### Filter Configuration 123 | 124 | To filter the displayed flights, use the filter option. 125 | 126 | ```yaml 127 | filter: 128 | - type: OR 129 | conditions: 130 | - field: distance_to_tracker 131 | comparator: lte 132 | value: 15 133 | - type: AND 134 | conditions: 135 | - field: closest_passing_distance 136 | comparator: lte 137 | value: 15 138 | - field: is_approaching 139 | comparator: eq 140 | value: true 141 | - field: altitude 142 | comparator: lte 143 | value: 2500 144 | ``` 145 | 146 | ##### Group conditions 147 | 148 | | Name | Description | Default Value | Constraints | 149 | | ------------ | ------------------------------------------ | ------------- | ----------------------------------- | 150 | | `type` | Logical operator for the filter conditions | None | Must be `OR` or `AND` | 151 | | `conditions` | List of filter conditions | None | Must be a list of condition objects | 152 | 153 | ##### Negative condition 154 | 155 | | Name | Description | Default Value | Constraints | 156 | | ----------- | ------------------------------------------ | ------------- | ------------------------- | 157 | | `type` | Logical operator for the filter conditions | None | Must be `NOT` | 158 | | `condition` | Condition to negate | None | Must be a valid condition | 159 | 160 | ##### Field comparision condition 161 | 162 | | Name | Description | Default Value | Constraints | 163 | | -------------------- | ------------------------------------------ | ------------- | ---------------------------------------------------------------- | 164 | | `field` or `defined` | Flight field or defined value to filter on | None | Must be a valid field name or defined property | 165 | | `comparator` | Comparator for the filter condition | None | Must be `eq`, `lt`, `lte`, `gt`, `gte`, `oneOf`, `containsOneOf` | 166 | | `value` | Value to compare against | None | Must be a valid value or list of values | 167 | 168 | #### Radar Configuration 169 | 170 | Configure radar settings with the radar option. 171 | 172 | ```yaml 173 | radar: 174 | range: 35 175 | filter: false 176 | primary-color: rgb(0,200,100) // Default colors defined by theme 177 | feature-color: rgb(0,100,20) 178 | ``` 179 | 180 | To hide the radar: 181 | 182 | ```yaml 183 | radar: 184 | hide: true 185 | ``` 186 | 187 | | Name | Description | Default Value | Constraints | 188 | | ---------------------- | ---------------------------------------------------- | --------------------------------- | ----------------------------------------------------- | 189 | | `range` | Range of the radar in selected distance unit | 35 km or 20 miles | Must be a positive number | 190 | | `min_range` | Minimum range of the radar in selected distance unit | 1 km or 1 mile | Must be a positive number | 191 | | `max_range` | Maximum range of the radar in selected distance unit | 100 km or 100 miles | Must be a positive number | 192 | | `ring_distance` | Distance between rings in selected distance unit | 10 km or 10 miles | Must be a positive number | 193 | | `filter` | Filter the flights displayed on the radar | false | `true`, `false` or a filter configuration (see below) | 194 | | `primary-color` | Primary color for the radar display | `var(--dark-primary-color)` | Must be a valid CSS color | 195 | | `accent-color` | Accent Color for the radar display | `var(--accent-color)` | Must be a valid CSS color | 196 | | `feature-color` | Color for radar features | `var(--primary-background-color)` | Must be a valid CSS color | 197 | | `callsign-label-color` | Color for callsign labels | `var(--secondary-text-color)` | Must be a valid CSS color | 198 | | `hide` | Option to hide the radar | `false` | Must be `true` or `false` | 199 | | `hide_range` | Option to hide the radar range | `false` | Must be `true` or `false` | 200 | 201 | ##### Radar Filter 202 | 203 | You can filter the flights displayed on the radar using a filter configuration similar to the main filter configuration. 204 | 205 | ```yaml 206 | radar: 207 | filter: 208 | - field: altitude 209 | comparator: lte 210 | value: 5000 211 | ``` 212 | 213 | ##### Radar Features 214 | 215 | ###### General settings for all feature types 216 | 217 | | Name | Description | Default Value | Constraints | 218 | | ----------- | ------------------------------------ | ------------- | ------------------------- | 219 | | `max_range` | Maximum range to draw the feature at | None | Must be a positive number | 220 | 221 | ###### Locations 222 | 223 | Add locations to the radar. 224 | 225 | ```yaml 226 | radar: 227 | local_features: 228 | - type: location 229 | label: Trondheim 230 | position: 231 | lat: 63.430472 232 | lon: 10.394964 233 | ``` 234 | 235 | | Name | Description | Default Value | Constraints | 236 | | ---------- | ------------------------- | ------------- | ------------------------------ | 237 | | `type` | Type of the radar feature | None | Must be `location` | 238 | | `label` | Label for the location | None | Must be a string | 239 | | `position` | Position of the location | None | Must be a valid lat/lon object | 240 | 241 | Position object: 242 | 243 | | Name | Description | Default Value | Constraints | 244 | | ----- | ------------------------- | ------------- | ------------------------- | 245 | | `lat` | Latitude of the position | None | Must be a valid latitude | 246 | | `lon` | Longitude of the position | None | Must be a valid longitude | 247 | 248 | ###### Runways 249 | 250 | Add runways to the radar. 251 | 252 | ```yaml 253 | radar: 254 | local_features: 255 | - type: runway 256 | position: 257 | lat: 63.457647 258 | lon: 10.894486 259 | heading: 86.7 260 | length: 9052 261 | ``` 262 | 263 | | Name | Description | Default Value | Constraints | 264 | | ---------- | ------------------------------------------------ | ------------- | ------------------------------ | 265 | | `type` | Type of the radar feature | None | Must be `runway` | 266 | | `position` | Position of the runway (one end of the rwy) | None | Must be a valid lat/lon object | 267 | | `heading` | Heading of the runway in degrees (from position) | None | Must be a valid number | 268 | | `length` | Length of the runway in feet | None | Must be a positive number | 269 | 270 | ##### Outlines 271 | 272 | Add geographic outlines to the radar. 273 | 274 | ```yaml 275 | radar: 276 | local_features: 277 | - type: outline 278 | points: 279 | - lat: 63.642064 280 | lon: 9.713992 281 | - lat: 63.443223 282 | lon: 9.974975 283 | - lat: 63.353184 284 | lon: 9.912988 285 | ``` 286 | 287 | | Name | Description | Default Value | Constraints | 288 | | -------- | ----------------------------------- | ------------- | --------------------------------- | 289 | | `type` | Type of the radar feature | None | Must be `outline` | 290 | | `points` | List of points defining the outline | None | Must be a list of lat/lon objects | 291 | 292 | **Tip:** You can use a LLM like ChatGPT to generate outlines for you, and you may get useful results. 293 | 294 | Try a question like this one: 295 | 296 | ```ChatGPT 297 | I need a series of coordinates to make out the rough shape of Manhattan. 298 | I want them printed as a yaml list on the format: 299 | - lat: MM.MMMMMM 300 | lon: MM.MMMMMM 301 | desc: [Placename] 302 | - lat: NN.NNNNNN 303 | lon: NN.NNNNNN 304 | desc: [Placename] 305 | ``` 306 | 307 | The desc: fields will be ignored by the Card, but will be useful if you want to add or move coordinates. 308 | 309 | ##### Radar Map Background 310 | 311 | ```yaml 312 | radar: 313 | background_map: bw # Options: bw, color, dark, outlines 314 | background_map_opacity: 0.7 # Opacity of the map (0=transparent, 1=opaque) 315 | background_map_api_key: YOUR_API_KEY # Optional, for some providers 316 | ``` 317 | 318 | | Option | Description | Values | Default | 319 | | ------------------------ | ----------------------------------------------------------------------------- | --------------------------------- | ------- | 320 | | `background_map` | Type of map background. | `bw`, `color`, `dark`, `outlines` | `none` | 321 | | `background_map_opacity` | Opacity for background map (0 to 1). | 0–1 (float) | 1 | 322 | | `background_map_api_key` | API key for selected tile provider (optional, for providers that require it). | string (optional) | – | 323 | 324 | - **`bw`**: Black-and-white (Stamen Toner) 325 | - **`color`**: Standard OpenStreetMap (colored) 326 | - **`dark`**: Dark theme map (CartoDB) 327 | - **`outlines`**: Geographic outlines only 328 | 329 | Example: 330 | 331 | ```yaml 332 | radar: 333 | background_map: color 334 | background_map_opacity: 0.5 335 | ``` 336 | 337 | If `background_map` is configured, the selected map is rendered beneath the radar graphics. 338 | 339 | ##### Known Issue 340 | 341 | **Note:** The zoom levels of the radar overlay and the background map may not always align perfectly. This may cause aircraft icons or overlays to have small positional offsets on the map background. For exact position tracking, trust the radar overlay rather than the basemap. 342 | 343 | #### List Configuration 344 | 345 | Configure flight list settings with the `list` option. 346 | 347 | ```yaml 348 | list: 349 | hide: true 350 | ``` 351 | 352 | | Name | Description | Default Value | Constraints | 353 | | ------ | --------------------------------------------------- | ------------- | ------------------------- | 354 | | `hide` | Option to hide the flight list below the radar card | `false` | Must be `true` or `false` | 355 | 356 | **Note:** When `list.hide` is enabled, the detailed flight list will not be displayed. 357 | 358 | #### Annotation Configuration 359 | 360 | Control how single fields are rendered based on conditions. Add annotations to highlight certain flights based on custom criteria. 361 | 362 | ```yaml 363 | annotate: 364 | - field: aircraft_registration 365 | render: ${aircraft_registration} 366 | conditions: 367 | - field: aircraft_registration 368 | comparator: oneOf 369 | value: [LN-NIE, PH-EXV] 370 | ``` 371 | 372 | | Name | Description | Default Value | Constraints | 373 | | ------------ | ------------------------------------- | ------------- | ------------------------------------------ | 374 | | `field` | Flight field to be annotated | None | Must be a valid flight field name | 375 | | `render` | Template for rendering the annotation | None | Must be a valid javascript template string | 376 | | `conditions` | List of conditions for annotation | None | Must be a list of condition objects | 377 | 378 | #### Toggles Configuration 379 | 380 | Toggle buttons control flags which can be used by the filters. Add toggle buttons to dynamically control your filters. 381 | 382 | ```yaml 383 | toggles: 384 | list_all: 385 | label: List all 386 | default: false 387 | filter: 388 | - type: OR 389 | conditions: 390 | - defined: list_all 391 | defaultValue: false 392 | comparator: eq 393 | value: true 394 | - type: OR 395 | conditions: 396 | - field: distance_to_tracker 397 | comparator: lte 398 | value: 15 399 | - type: AND 400 | conditions: 401 | - field: closest_passing_distance 402 | comparator: lte 403 | value: 15 404 | - field: is_approaching 405 | comparator: eq 406 | value: true 407 | - field: altitude 408 | comparator: lte 409 | value: 2500 410 | ``` 411 | 412 | | Name | Description | Default Value | Constraints | 413 | | --------- | ------------------------------------------------------ | ------------- | ------------------------- | 414 | | `[key]:` | key: Name of the property to be defined by this toggle | None | Must be a string | 415 | | `label` | Label for the toggle button | None | Must be a string | 416 | | `default` | Default state of the toggle | `false` | Must be `true` or `false` | 417 | 418 | #### Defines Configuration 419 | 420 | Use the defines option to create reusable condition values. 421 | 422 | ```yaml 423 | defines: 424 | aircraftsOfDisinterest: 425 | - Helicopter 426 | - LocalPilot1 427 | filter: 428 | - type: NOT 429 | condition: 430 | type: OR 431 | conditions: 432 | - field: aircraft_model 433 | comparator: containsOneOf 434 | value: ${aircraftsOfDisinterest} 435 | - field: callsign 436 | comparator: containsOneOf 437 | value: ${aircraftsOfDisinterest} 438 | ``` 439 | 440 | | Name | Description | Default Value | Constraints | 441 | | ---------------- | ----------------------------------------- | ------------- | ---------------- | 442 | | `[key]: [value]` | key: Name of the property to be defined | None | Must be a string | 443 | | | value: The value for the defined property | None | None | 444 | 445 | #### Templates Configuration 446 | 447 | The `templates` configuration option allows you to customize the HTML templates used for rendering various parts of the flight information displayed on the card. You can define your own HTML templates for different elements such as icons, flight information, aircraft information, departure and arrival details, route information, flight status, position status, and proximity information. 448 | 449 | By default, the card comes with predefined templates for each element. However, you can override these defaults or add new templates according to your preferences. 450 | 451 | | Template Name | Description | Default Value | 452 | | ----------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | 453 | | `img_element` | HTML template for rendering the image element of the aircraft. | `${flight.aircraft_photo_small ? '' : ""}` | 454 | | `icon` | Template for defining the icon to be displayed based on flight altitude and vertical speed. | `${flight.altitude > 0 ? (flight.vertical_speed > 100 ? "airplane-takeoff" : flight.vertical_speed < -100 ? "airplane-landing" : "airplane") : "airport"}` | 455 | | `icon_element` | HTML template for rendering the icon element. | `` | 456 | | `flight_info` | Template for displaying basic flight information like airline, flight number, and callsign. | `${joinList(" - ")(flight.airline_short, flight.flight_number, flight.callsign !== flight.flight_number ? flight.callsign : "")}` | 457 | | `flight_info_element` | HTML template for rendering the flight information element. | `
${tpl.flight_info}
` | 458 | | `header` | HTML template for rendering the header section of the flight card. | `
${tpl.img_element}${tpl.icon_element}${tpl.flight_info_element}
` | 459 | | `aircraft_info` | Template for displaying aircraft registration and model information. | `${joinList(" - ")(flight.aircraft_registration, flight.aircraft_model)}` | 460 | | `aircraft_info_element` | HTML template for rendering the aircraft information element. | `` ${tpl.aircraft_info ? `
${tpl.aircraft_info}
` : ""} `` | 461 | | `departure_info` | Template for displaying departure time information. | `` ${flight.altitude === 0 && flight.time_scheduled_departure ? ` (${new Date(flight.time_scheduled_departure * 1000).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })})` : ""} `` | 462 | | `origin_info` | Template for displaying origin airport information. | `${joinList("")(flight.airport_origin_code_iata, tpl.departure_info, flight.origin_flag)}` | 463 | | `arrival_info` | Template for displaying arrival airport information. | | 464 | | `destination_info` | Template for displaying destination airport information. | `${joinList("")(flight.airport_destination_code_iata, tpl.arrival_info, flight.destination_flag)}` | 465 | | `route_info` | Template for displaying the flight route information. | `${joinList(" -> ")(tpl.origin_info, tpl.destination_info)}` | 466 | | `route_element` | HTML template for rendering the route information element. | `
${tpl.route_info}
` | 467 | | `alt_info` | Template for displaying altitude information. | `${flight.alt_in_unit ? "Alt: " + flight.alt_in_unit + flight.climb_descend_indicator : undefined}` | 468 | | `spd_info` | Template for displaying altitude information. | `${flight.spd_in_unit ? "Spd: " + flight.spd_in_unit : undefined}` | 469 | | `hdg_info` | Template for displaying heading information. | `${flight.heading ? "Hdg: " + flight.heading + "°" : undefined}` | 470 | | `dist_info` | Template for displaying altitude information. | `${flight.dist_in_unit ? "Dist: " + flight.dist_in_unit + flight.approach_indicator : undefined}` | 471 | | `flight_status` | Template for displaying flight status information like altitude, speed, and heading. | `
${joinList(" - ")(tpl.alt_info, tpl.spd_info, tpl.hdg_info)}
` | 472 | | `position_status` | Template for displaying flight position status information like distance and direction. | `
${joinList(" - ")(tpl.dist_info, flight.direction_info)}
` | 473 | | `proximity_info` | Template for displaying proximity information when the flight is approaching. | `
${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' : ""}
` | 474 | | `flight_element` | HTML template for rendering the complete flight element. | `${tpl.header}${tpl.aircraft_info_element}${tpl.route_element}${tpl.flight_status}${tpl.position_status}${tpl.proximity_info}` | 475 | | `radar_range` | Template for displaying the current radar range in the radar view. | `Range: ${radar_range} ${units.distance}` | 476 | 477 | **Note:** The template used when rendering flights is the `flight_element` template. The default `flight_element` consist of the templates for `header`, `aircraft_info_element`, `route_element`, `flight_status`, `position_status` and `proximity_info`. 478 | 479 | You can customize each template by providing your own javascript template string building HTML structure and using placeholders like `${flight.property}` to dynamically insert flight data into the template. For example, `${flight.aircraft_photo_small}` will be replaced with the URL of the small aircraft photo. Refer to the [Flightradar24 integration documentation](https://github.com/AlexandrErohin/home-assistant-flightradar24?tab=readme-ov-file#flight-fields) for a list of valid flight fields. 480 | 481 | You can reference the result other templates as `tpl.[name of template]`. 482 | You can reference configured units as `unit.[unit name]`. 483 | 484 | In addition you will find these fields defined 485 | 486 | | Field | Description | 487 | | --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 488 | | origin_flag | | 489 | | destination_flag | | 490 | | climb_descend_indicator | Arrow pointing up or down to indicate vertical speed exceeding 100 ft/minute | 491 | | alt_in_unit | Altitude given in the configured altitude unit | 492 | | spd_in_unit | Speed given in the configured speed unit | 493 | | dist_in_unit | Distance from observer given in the configured distance unit | 494 | | heading_from_tracker | Heading from tracker to flight | 495 | | cardinal_direction_from_tracker | Cardinal direction (N, NW, W, SW, S, SE, E, NE) from tracker to flight | 496 | | is_approaching | Boolean to indicate if the aircraft is approaching the tracker | 497 | | approach_indicator | Arrow pointing up or down to indicate if the aircraft is approaching the tracker | 498 | | closest_passing_distance | Distance from tracker to calculated closest point between tracker and flight (available if is_approaching is true) in configured distance unit | 499 | | eta_to_closest_distance | Time until flight reaches calculated closest point between tracker and flight in minutes | 500 | | heading_from_tracker_to_closest_passing | Heading from tracker to calculated closest point between tracker and flight | 501 | | is_landing | True if the flight is approaching and has a projected landing point before closest point between tracker and flight, in which case closest_passing_distance, eta_to_closest_distance and heading_from_tracker_to_closest_passing will be calculated based on the projected landing point | 502 | 503 | ## Usage 504 | 505 | ### Features 506 | 507 | The Flightradar24 Integration Card offers the following features: 508 | 509 | - Display real-time flight data from Flightradar24. 510 | - Customizable radar view with range, projection interval, and colors. 511 | - Add custom locations, runways, and geographic outlines. 512 | - Filter flights based on various criteria. 513 | - Annotate specific flights with custom conditions. 514 | - Toggle options to control flight visibility. 515 | 516 | ### Examples 517 | 518 | #### Example: Lists all aircraft in the air with a toggle button to also display aircraft on the ground (altitude <= 0) 519 | 520 | Note: Radar will show all tracked flights 521 | 522 | ```yaml 523 | type: custom:flightradar24-card 524 | toggles: 525 | show_on_ground: 526 | label: Show aircraft on the ground 527 | default: false 528 | filter: 529 | - type: OR 530 | conditions: 531 | - field: altitude 532 | comparator: gt 533 | value: 0 534 | - defined: show_on_ground 535 | comparator: eq 536 | value: true 537 | ``` 538 | 539 | #### Example: List aircraft currently visible on radar 540 | 541 | ```yaml 542 | type: custom:flightradar24-card 543 | filter: 544 | - field: distance_to_tracker 545 | comparator: lte 546 | value: ${radar_range} 547 | ``` 548 | 549 | Note: Depending on your layout and system, there may be unwanted flickering or repositioning of elements as you zoom flights in or out of the active range. To avoid this, set the flag `updateRangeFilterOnTouchEnd` to true to only update the filtered list after the pinch/zoom action stops. 550 | 551 | ```yaml 552 | type: custom:flightradar24-card 553 | updateRangeFilterOnTouchEnd: true 554 | filter: 555 | - field: distance_to_tracker 556 | comparator: lte 557 | value: ${radar_range} 558 | ``` 559 | 560 | #### Example: List all aircraft from a given airline ("Delta" in this example), with no radar 561 | 562 | ```yaml 563 | type: custom:flightradar24-card 564 | filter: 565 | - field: airline_short 566 | comparator: eq 567 | value: Delta 568 | radar: 569 | hide: true 570 | ``` 571 | 572 | #### Example: List all approaching and overhead B747 or A380s with toggles to show/hide either 573 | 574 | ![Template with toggles](resources/example_templates_747_a380_toggle.PNG "Example") 575 | 576 | Note: Radar will show all tracked flights 577 | 578 | ```yaml 579 | type: custom:flightradar24-card 580 | defines: 581 | boeing_747_icao_codes: 582 | - B741 583 | - B742 584 | - B743 585 | - BLCF 586 | - B74S 587 | - B74R 588 | - B748 589 | - B744 590 | - B748 591 | toggles: 592 | show_b747s: 593 | label: 747s 594 | default: true 595 | show_a380s: 596 | label: A380s 597 | default: true 598 | filter: 599 | - type: AND 600 | conditions: 601 | - type: OR 602 | conditions: 603 | - type: AND 604 | conditions: 605 | - field: aircraft_code 606 | comparator: oneOf 607 | value: ${boeing_747_icao_codes} 608 | - defined: show_b747s 609 | comparator: eq 610 | value: true 611 | - type: AND 612 | conditions: 613 | - field: aircraft_code 614 | comparator: eq 615 | value: A388 616 | - defined: show_a380s 617 | comparator: eq 618 | value: true 619 | - type: OR 620 | conditions: 621 | - field: is_approaching 622 | comparator: eq 623 | value: true 624 | - field: distance_to_tracker 625 | comparator: lt 626 | value: 10 627 | ``` 628 | 629 | #### Example: Change the flight template to display a tail image instead of airplane icon 630 | 631 | ![Template with tails](resources/example_templates_tails_dark.PNG "Example") 632 | 633 | ```yaml 634 | type: custom:flightradar24-card 635 | templates: 636 | tail_image: >- 637 | 640 | header: ${tpl.tail_image}${tpl.flight_info_element} 641 | ``` 642 | 643 | Note: Here we add a new tail_image template, and update the header template (which previously referenced the icon template) to include our new tail_image template. 644 | 645 | ## Support 646 | 647 | For support, you can: 648 | 649 | - Open an issue on the GitHub repository. 650 | - Join the Home Assistant community forums and ask for help in the relevant threads. 651 | - Check the documentation for more details and troubleshooting tips. 652 | 653 | Feel free to reach out if you encounter any issues or have suggestions for improvements. Your feedback is highly appreciated! 654 | -------------------------------------------------------------------------------- /card.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Springvar/home-assistant-flightradar24-card/0e1c5a2b31e1c753b39ee8d55edaf345dcb9f4f5/card.png -------------------------------------------------------------------------------- /config/sortConfig.js: -------------------------------------------------------------------------------- 1 | export const sortConfig = [ 2 | { field: 'id', comparator: 'oneOf', value: '${selectedFlights}', order: 'DESC' }, 3 | { field: 'altitude', comparator: 'eq', value: 0, order: 'ASC' }, 4 | { field: 'closest_passing_distance ?? distance_to_tracker', order: 'ASC' } 5 | ]; 6 | -------------------------------------------------------------------------------- /config/templateConfig.js: -------------------------------------------------------------------------------- 1 | export const templateConfig = { 2 | img_element: 3 | '${flight.aircraft_photo_small ? `` : ""}', 4 | icon: '${flight.altitude > 0 ? (flight.vertical_speed > 100 ? "airplane-takeoff" : flight.vertical_speed < -100 ? "airplane-landing" : "airplane") : "airport"}', 5 | icon_element: '', 6 | flight_info: '${joinList(" - ")(flight.airline_short, flight.flight_number, flight.callsign !== flight.flight_number ? flight.callsign : "")}', 7 | flight_info_element: '
${tpl.flight_info}
', 8 | header: '
${tpl.img_element}${tpl.icon_element}${tpl.flight_info_element}
', 9 | aircraft_info: '${joinList(" - ")(flight.aircraft_registration, flight.aircraft_model)}', 10 | aircraft_info_element: '${tpl.aircraft_info ? `
${tpl.aircraft_info}
` : ""}', 11 | departure_info: 12 | '${flight.altitude === 0 && flight.time_scheduled_departure ? ` (${new Date(flight.time_scheduled_departure * 1000).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })})` : ""}', 13 | origin_info: '${joinList("")(flight.airport_origin_code_iata, tpl.departure_info, flight.origin_flag)}', 14 | arrival_info: '', 15 | destination_info: '${joinList("")(flight.airport_destination_code_iata, tpl.arrival_info, flight.destination_flag)}', 16 | route_info: '${joinList(" -> ")(tpl.origin_info, tpl.destination_info)}', 17 | route_element: '
${tpl.route_info}
', 18 | alt_info: '${flight.alt_in_unit ? "Alt: " + flight.alt_in_unit + flight.climb_descend_indicator : undefined}', 19 | spd_info: '${flight.spd_in_unit ? "Spd: " + flight.spd_in_unit : undefined}', 20 | hdg_info: '${flight.heading ? "Hdg: " + flight.heading + "°" : undefined}', 21 | dist_info: '${flight.dist_in_unit ? "Dist: " + flight.dist_in_unit + flight.approach_indicator : undefined}', 22 | flight_status: '
${joinList(" - ")(tpl.alt_info, tpl.spd_info, tpl.hdg_info)}
', 23 | position_status: '
${joinList(" - ")(tpl.dist_info, flight.direction_info)}
', 24 | proximity_info: 25 | '
${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` : ""}
', 26 | flight_element: '${tpl.header}${tpl.aircraft_info_element}${tpl.route_element}${tpl.flight_status}${tpl.position_status}${tpl.proximity_info}', 27 | radar_range: 'Range: ${radar_range} ${units.distance}', 28 | list_status: '${flights.shown}/${flights.total}' 29 | }; 30 | -------------------------------------------------------------------------------- /config/unitsConfig.js: -------------------------------------------------------------------------------- 1 | export const unitsConfig = { 2 | altitude: 'ft', 3 | speed: 'kts', 4 | distance: 'km' 5 | }; 6 | -------------------------------------------------------------------------------- /flightradar24-card.js: -------------------------------------------------------------------------------- 1 | import { unitsConfig } from "./config/unitsConfig.js"; 2 | import { templateConfig } from "./config/templateConfig.js"; 3 | import { sortConfig } from "./config/sortConfig.js"; 4 | import { renderStatic } from "./render/static.js"; 5 | import { renderFlag } from "./render/flag.js"; 6 | import { renderRadarScreen } from "./render/radarScreen.js"; 7 | import { renderRadar } from "./render/radar.js"; 8 | import { 9 | haversine, 10 | calculateBearing, 11 | calculateNewPosition, 12 | calculateClosestPassingPoint, 13 | getCardinalDirection, 14 | areHeadingsAligned, 15 | } from "./utils/geometric.js"; 16 | import { applyFilter, applyConditions } from "./utils/filter.js"; 17 | import { getSortFn } from "./utils/sort.js"; 18 | import { parseTemplate, resolvePlaceholders } from "./utils/template.js"; 19 | import { setupZoomHandlers } from "./utils/zoom.js"; 20 | import { getLocation } from "./utils/location.js"; 21 | import { ensureLeafletLoadedIfNeeded } from "./render/map.js"; 22 | 23 | class Flightradar24Card extends HTMLElement { 24 | cardState = { 25 | config: {}, 26 | templates: {}, 27 | flights: [], 28 | dimensions: {}, 29 | selectedFlights: [], 30 | hass: null, 31 | defines: {}, 32 | units: {}, 33 | radar: {}, 34 | list: {}, 35 | flightsContext: {}, 36 | _leafletMap: null, 37 | renderDynamicOnRangeChange: false, 38 | }; 39 | _radarResizeObserver; 40 | _zoomCleanup; 41 | _updateRequired = true; 42 | _timer = null; 43 | 44 | constructor() { 45 | super(); 46 | this.attachShadow({ mode: "open" }); 47 | } 48 | 49 | setConfig(config) { 50 | if (!config) throw new Error("Configuration is missing."); 51 | this.cardState.config = Object.assign({}, config); 52 | this.cardState.config.flights_entity = 53 | config.flights_entity ?? "sensor.flightradar24_current_in_area"; 54 | this.cardState.config.projection_interval = config.projection_interval ?? 5; 55 | this.cardState.config.no_flights_message = 56 | config.no_flights_message ?? 57 | "No flights are currently visible. Please check back later."; 58 | 59 | this.cardState.list = Object.assign({ hide: false }, config.list); 60 | this.cardState.units = Object.assign({}, unitsConfig, config.units); 61 | this.cardState.radar = Object.assign( 62 | { 63 | range: this.cardState.units.distance === "km" ? 35 : 25, 64 | background_map: config.radar?.background_map || "none", 65 | background_map_opacity: config.radar?.background_map_opacity || 1, 66 | background_map_api_key: config.radar?.background_map_api_key || "", 67 | }, 68 | config.radar 69 | ); 70 | this.cardState.radar.initialRange = this.cardState.radar.range; 71 | this.cardState.defines = Object.assign({}, config.defines); 72 | this.cardState.sortFn = getSortFn( 73 | config.sort ?? sortConfig, 74 | (value, defaultValue) => 75 | resolvePlaceholders( 76 | value, 77 | this.cardState.defines, 78 | this.cardState.config, 79 | this.cardState.radar, 80 | this.cardState.selectedFlights, 81 | defaultValue, 82 | (v) => { 83 | this.cardState.renderDynamicOnRangeChange = v; 84 | } 85 | ) 86 | ); 87 | this.cardState.templates = Object.assign( 88 | {}, 89 | templateConfig, 90 | config.templates 91 | ); 92 | renderStatic(this.cardState, this); 93 | this.observeRadarResize(); 94 | } 95 | 96 | set hass(hass) { 97 | this.cardState.hass = hass; 98 | if (this._updateRequired) { 99 | this._updateRequired = false; 100 | this.fetchFlightsData(); 101 | this.updateCardDimensions(); 102 | ensureLeafletLoadedIfNeeded(this.cardState, this.shadowRoot, () => { 103 | renderRadarScreen(this.cardState); 104 | renderRadar(this.cardState, (flight) => 105 | this.toggleSelectedFlight(flight) 106 | ); 107 | }); 108 | this.renderDynamic(); 109 | } 110 | } 111 | 112 | connectedCallback() { 113 | this.observeRadarResize(); 114 | } 115 | 116 | disconnectedCallback() { 117 | if (this._radarResizeObserver) { 118 | this._radarResizeObserver.disconnect(); 119 | this._radarResizeObserver = null; 120 | } 121 | if (this.cardState._leafletMap && this.cardState._leafletMap.remove) { 122 | this.cardState._leafletMap.remove(); 123 | this.cardState._leafletMap = null; 124 | } 125 | if (this._zoomCleanup) { 126 | this._zoomCleanup(); 127 | this._zoomCleanup = null; 128 | } 129 | } 130 | 131 | updateCardDimensions() { 132 | const radarElem = this.shadowRoot.getElementById("radar"); 133 | const width = radarElem?.clientWidth || 400; 134 | const height = radarElem?.clientHeight || 400; 135 | const range = this.cardState.radar.range; 136 | const scaleFactor = width / (range * 2); 137 | this.cardState.dimensions = { 138 | width, 139 | height, 140 | range, 141 | scaleFactor, 142 | centerX: width / 2, 143 | centerY: height / 2, 144 | }; 145 | } 146 | 147 | observeRadarResize() { 148 | const radar = this.shadowRoot.getElementById("radar"); 149 | if (!radar) return; 150 | if (this._radarResizeObserver) this._radarResizeObserver.disconnect(); 151 | this._radarResizeObserver = new ResizeObserver(() => { 152 | this.updateCardDimensions(); 153 | renderRadarScreen(this.cardState); 154 | renderRadar(this.cardState, (f) => this.toggleSelectedFlight(f)); 155 | }); 156 | this._radarResizeObserver.observe(radar); 157 | const radarOverlay = this.shadowRoot.getElementById("radar-overlay"); 158 | if (this._zoomCleanup) this._zoomCleanup(); 159 | this._zoomCleanup = setupZoomHandlers(this.cardState, radarOverlay); 160 | } 161 | 162 | renderDynamic() { 163 | const flightsContainer = this.shadowRoot.getElementById("flights"); 164 | if (!flightsContainer) return; 165 | flightsContainer.innerHTML = ""; 166 | 167 | if (this.cardState.list && this.cardState.list.hide === true) { 168 | flightsContainer.style.display = "none"; 169 | return; 170 | } else { 171 | flightsContainer.style.display = ""; 172 | } 173 | 174 | const filter = this.cardState.config.filter 175 | ? this.cardState.selectedFlights && 176 | this.cardState.selectedFlights.length > 0 177 | ? [ 178 | { 179 | type: "OR", 180 | conditions: [ 181 | { 182 | field: "id", 183 | comparator: "oneOf", 184 | value: this.cardState.selectedFlights, 185 | }, 186 | { type: "AND", conditions: this.cardState.config.filter }, 187 | ], 188 | }, 189 | ] 190 | : this.cardState.config.filter 191 | : undefined; 192 | 193 | const flightsTotal = this.cardState.flights.length; 194 | const flightsFiltered = filter 195 | ? applyFilter(this.cardState.flights, filter, (value, defaultValue) => 196 | resolvePlaceholders( 197 | value, 198 | this.cardState.defines, 199 | this.cardState.config, 200 | this.cardState.radar, 201 | this.cardState.selectedFlights, 202 | defaultValue, 203 | (v) => { 204 | this.cardState.renderDynamicOnRangeChange = v; 205 | } 206 | ) 207 | ) 208 | : this.cardState.flights; 209 | const flightsShown = flightsFiltered.length; 210 | 211 | flightsFiltered.sort(this.cardState.sortFn); 212 | 213 | if (this.cardState.radar.hide !== true) { 214 | requestAnimationFrame(() => { 215 | renderRadar( 216 | this.cardState, 217 | this.cardState.radar.filter === true 218 | ? flightsFiltered 219 | : this.cardState.radar.filter && 220 | typeof this.cardState.radar.filter === "object" 221 | ? applyFilter( 222 | this.cardState.flights, 223 | this.cardState.radar.filter, 224 | (value, defaultValue) => 225 | resolvePlaceholders( 226 | value, 227 | this.cardState.defines, 228 | this.cardState.config, 229 | this.cardState.radar, 230 | this.cardState.selectedFlights, 231 | defaultValue, 232 | (v) => { 233 | this.cardState.renderDynamicOnRangeChange = v; 234 | } 235 | ) 236 | ) 237 | : this.cardState.flights, 238 | (flight) => this.toggleSelectedFlight(flight) 239 | ); 240 | }); 241 | } 242 | 243 | if (this.cardState.list && this.cardState.list.showListStatus === true) { 244 | this.cardState.flightsContext = { 245 | shown: flightsShown, 246 | total: flightsTotal, 247 | filtered: flightsFiltered.length, 248 | }; 249 | const listStatusDiv = document.createElement("div"); 250 | listStatusDiv.className = "list-status"; 251 | listStatusDiv.innerHTML = parseTemplate( 252 | this.cardState, 253 | "list_status", 254 | null, 255 | (joinWith) => 256 | (...elements) => 257 | elements?.filter((e) => e).join(joinWith || " ") 258 | ); 259 | flightsContainer.appendChild(listStatusDiv); 260 | } 261 | 262 | if (flightsShown === 0) { 263 | if (this.cardState.config.no_flights_message !== "") { 264 | const noFlightsMessage = document.createElement("div"); 265 | noFlightsMessage.className = "no-flights-message"; 266 | noFlightsMessage.textContent = this.cardState.config.no_flights_message; 267 | flightsContainer.appendChild(noFlightsMessage); 268 | } 269 | } else { 270 | flightsFiltered.forEach((flight, idx) => { 271 | const flightElement = this.renderFlight(flight); 272 | if (idx === 0) { 273 | flightElement.className += " first"; 274 | } 275 | flightsContainer.appendChild(flightElement); 276 | }); 277 | } 278 | } 279 | 280 | updateRadarRange(delta) { 281 | const minRange = this.cardState.radar.min_range || 1; 282 | const maxRange = 283 | this.cardState.radar.max_range || 284 | Math.max(100, this.cardState.radar.initialRange); 285 | let newRange = this.cardState.radar.range + delta; 286 | if (newRange < minRange) newRange = minRange; 287 | if (newRange > maxRange) newRange = maxRange; 288 | this.cardState.radar.range = newRange; 289 | this.updateCardDimensions(); 290 | renderRadarScreen(this.cardState); 291 | renderRadar(this.cardState, (f) => this.toggleSelectedFlight(f)); 292 | if ( 293 | this.cardState.renderDynamicOnRangeChange && 294 | this.cardState.config.updateRangeFilterOnTouchEnd !== true 295 | ) { 296 | this.renderDynamic(); 297 | } 298 | } 299 | 300 | renderFlight(_flight) { 301 | const flight = Object.assign({}, _flight); 302 | [ 303 | "flight_number", 304 | "callsign", 305 | "aircraft_registration", 306 | "aircraft_model", 307 | "aircraft_code", 308 | "airline", 309 | "airline_short", 310 | "airline_iata", 311 | "airline_icao", 312 | "airport_origin_name", 313 | "airport_origin_code_iata", 314 | "airport_origin_code_icao", 315 | "airport_origin_country_name", 316 | "airport_origin_country_code", 317 | "airport_destination_name", 318 | "airport_destination_code_iata", 319 | "airport_destination_code_icao", 320 | "airport_destination_country_name", 321 | "airport_destination_country_code", 322 | ].forEach((field) => (flight[field] = this.flightField(flight, field))); 323 | flight.origin_flag = flight.airport_origin_country_code 324 | ? renderFlag( 325 | flight.airport_origin_country_code, 326 | flight.airport_origin_country_name 327 | ).outerHTML 328 | : ""; 329 | flight.destination_flag = flight.airport_destination_country_code 330 | ? renderFlag( 331 | flight.airport_destination_country_code, 332 | flight.airport_destination_country_name 333 | ).outerHTML 334 | : ""; 335 | 336 | flight.climb_descend_indicator = 337 | Math.abs(flight.vertical_speed) > 100 338 | ? flight.vertical_speed > 100 339 | ? "↑" 340 | : "↓" 341 | : ""; 342 | flight.alt_in_unit = 343 | flight.altitude >= 17750 344 | ? `FL${Math.round(flight.altitude / 1000) * 10}` 345 | : flight.altitude > 0 346 | ? this.cardState.units.altitude === "m" 347 | ? `${Math.round(flight.altitude * 0.3048)} m` 348 | : `${Math.round(flight.altitude)} ft` 349 | : undefined; 350 | 351 | flight.spd_in_unit = 352 | flight.ground_speed > 0 353 | ? this.cardState.units.speed === "kmh" 354 | ? `${Math.round(flight.ground_speed * 1.852)} km/h` 355 | : this.cardState.units.speed === "mph" 356 | ? `${Math.round(flight.ground_speed * 1.15078)} mph` 357 | : `${Math.round(flight.ground_speed)} kts` 358 | : undefined; 359 | 360 | flight.approach_indicator = 361 | flight.ground_speed > 70 362 | ? flight.is_approaching 363 | ? "↓" 364 | : flight.is_receding 365 | ? "↑" 366 | : "" 367 | : ""; 368 | flight.dist_in_unit = `${Math.round(flight.distance_to_tracker)} ${ 369 | this.cardState.units.distance 370 | }`; 371 | flight.direction_info = `${Math.round(flight.heading_from_tracker)}° ${ 372 | flight.cardinal_direction_from_tracker 373 | }`; 374 | 375 | const flightElement = document.createElement("div"); 376 | flightElement.style.clear = "both"; 377 | flightElement.className = "flight"; 378 | 379 | if ( 380 | this.cardState.selectedFlights && 381 | this.cardState.selectedFlights.includes(flight.id) 382 | ) { 383 | flightElement.className += " selected"; 384 | } 385 | 386 | flightElement.innerHTML = parseTemplate( 387 | this.cardState, 388 | "flight_element", 389 | flight, 390 | (joinWith) => 391 | (...elements) => 392 | elements?.filter((e) => e).join(joinWith || " ") 393 | ); 394 | flightElement.addEventListener("click", () => 395 | this.toggleSelectedFlight(flight) 396 | ); 397 | 398 | return flightElement; 399 | } 400 | 401 | flightField(flight, field) { 402 | let text = flight[field]; 403 | if (this.cardState.config.annotate) { 404 | const f = Object.assign({}, flight); 405 | this.cardState.config.annotate 406 | .filter((a) => a.field === field) 407 | .forEach((a) => { 408 | if ( 409 | applyConditions(flight, a.conditions, (value, defaultValue) => 410 | resolvePlaceholders( 411 | value, 412 | this.cardState.defines, 413 | this.cardState.config, 414 | this.cardState.radar, 415 | this.cardState.selectedFlights, 416 | defaultValue, 417 | (v) => { 418 | this.cardState.renderDynamicOnRangeChange = v; 419 | } 420 | ) 421 | ) 422 | ) { 423 | f[field] = a.render.replace(/\$\{([^}]*)\}/g, (_, p1) => f[p1]); 424 | } 425 | }); 426 | text = f[field]; 427 | } 428 | return text; 429 | } 430 | 431 | subscribeToStateChanges(hass) { 432 | if (!this.cardState.config.test && this.cardState.config.update !== false) { 433 | hass.connection.subscribeEvents((event) => { 434 | if ( 435 | event.data.entity_id === this.cardState.config.flights_entity || 436 | event.data.entity_id === this.cardState.config.location_tracker 437 | ) { 438 | this._updateRequired = true; 439 | } 440 | }, "state_changed"); 441 | } 442 | } 443 | 444 | fetchFlightsData() { 445 | this._timer = clearInterval(this._timer); 446 | const entityState = 447 | this.cardState.hass.states[this.cardState.config.flights_entity]; 448 | if (entityState) { 449 | try { 450 | this.cardState.flights = 451 | parseFloat(entityState.state) > 0 && entityState.attributes.flights 452 | ? JSON.parse(JSON.stringify(entityState.attributes.flights)) 453 | : []; 454 | } catch (error) { 455 | console.error("Error fetching or parsing flight data:", error); 456 | this.cardState.flights = []; 457 | } 458 | } else { 459 | throw new Error( 460 | "Flights entity state is undefined. Check the configuration." 461 | ); 462 | } 463 | 464 | const { moving } = this.calculateFlightData(); 465 | if (this.cardState.config.projection_interval) { 466 | if (moving && !this._timer) { 467 | clearInterval(this._timer); 468 | this._timer = setInterval(() => { 469 | if (this.cardState.hass) { 470 | const { projected } = this.calculateFlightData(); 471 | if (projected) { 472 | this.renderDynamic(); 473 | } 474 | } 475 | }, this.cardState.config.projection_interval * 1000); 476 | } else if (!moving) { 477 | clearInterval(this._timer); 478 | } 479 | } 480 | } 481 | 482 | calculateFlightData() { 483 | let projected = false; 484 | let moving = false; 485 | const currentTime = Date.now() / 1000; 486 | const location = getLocation(this.cardState); 487 | if (location) { 488 | const refLat = location.latitude; 489 | const refLon = location.longitude; 490 | 491 | this.cardState.flights.forEach((flight) => { 492 | if (!flight._timestamp) { 493 | flight._timestamp = currentTime; 494 | } 495 | 496 | moving = moving || flight.ground_speed > 0; 497 | 498 | const timeElapsed = currentTime - flight._timestamp; 499 | if (timeElapsed > 1) { 500 | projected = true; 501 | 502 | flight._timestamp = currentTime; 503 | 504 | const newPosition = calculateNewPosition( 505 | flight.latitude, 506 | flight.longitude, 507 | flight.heading, 508 | ((flight.ground_speed * 1.852) / 3600) * timeElapsed 509 | ); 510 | 511 | flight.latitude = newPosition.lat; 512 | flight.longitude = newPosition.lon; 513 | const newAltitude = Math.max( 514 | flight.altitude + (timeElapsed / 60) * flight.vertical_speed, 515 | 0 516 | ); 517 | if ( 518 | flight.landed || 519 | (newAltitude !== flight.altitude && newAltitude === 0) 520 | ) { 521 | flight.landed = true; 522 | flight.ground_speed = Math.max( 523 | flight.ground_speed - 15 * timeElapsed, 524 | 15 525 | ); 526 | } 527 | flight.altitude = newAltitude; 528 | } 529 | 530 | flight.distance_to_tracker = haversine( 531 | refLat, 532 | refLon, 533 | flight.latitude, 534 | flight.longitude, 535 | this.cardState.units.distance 536 | ); 537 | 538 | flight.heading_from_tracker = calculateBearing( 539 | refLat, 540 | refLon, 541 | flight.latitude, 542 | flight.longitude 543 | ); 544 | flight.cardinal_direction_from_tracker = getCardinalDirection( 545 | flight.heading_from_tracker 546 | ); 547 | const heading_to_tracker = (flight.heading_from_tracker + 180) % 360; 548 | flight.is_approaching = areHeadingsAligned( 549 | heading_to_tracker, 550 | flight.heading 551 | ); 552 | flight.is_receding = areHeadingsAligned( 553 | flight.heading_from_tracker, 554 | flight.heading 555 | ); 556 | 557 | if (flight.is_approaching) { 558 | let closestPassingLatLon = calculateClosestPassingPoint( 559 | refLat, 560 | refLon, 561 | flight.latitude, 562 | flight.longitude, 563 | flight.heading 564 | ); 565 | 566 | flight.closest_passing_distance = Math.round( 567 | haversine( 568 | refLat, 569 | refLon, 570 | closestPassingLatLon.lat, 571 | closestPassingLatLon.lon, 572 | this.cardState.units.distance 573 | ) 574 | ); 575 | const eta_to_closest_distance = this.calculateETA( 576 | flight.latitude, 577 | flight.longitude, 578 | closestPassingLatLon.lat, 579 | closestPassingLatLon.lon, 580 | flight.ground_speed 581 | ); 582 | flight.eta_to_closest_distance = Math.round(eta_to_closest_distance); 583 | 584 | if (flight.vertical_speed < 0 && flight.altitude > 0) { 585 | const timeToTouchdown = 586 | flight.altitude / Math.abs(flight.vertical_speed); 587 | const touchdownLatLon = calculateNewPosition( 588 | flight.latitude, 589 | flight.longitude, 590 | flight.heading, 591 | (flight.ground_speed * timeToTouchdown) / 60 592 | ); 593 | const touchdownDistance = haversine( 594 | refLat, 595 | refLon, 596 | touchdownLatLon.lat, 597 | touchdownLatLon.lon, 598 | this.cardState.units.distance 599 | ); 600 | 601 | if (timeToTouchdown < eta_to_closest_distance) { 602 | flight.is_landing = true; 603 | flight.closest_passing_distance = Math.round(touchdownDistance); 604 | flight.eta_to_closest_distance = Math.round(timeToTouchdown); 605 | closestPassingLatLon = touchdownLatLon; 606 | } 607 | } 608 | 609 | flight.heading_from_tracker_to_closest_passing = Math.round( 610 | calculateBearing( 611 | refLat, 612 | refLon, 613 | closestPassingLatLon.lat, 614 | closestPassingLatLon.lon 615 | ) 616 | ); 617 | } else { 618 | delete flight.closest_passing_distance; 619 | delete flight.eta_to_closest_distance; 620 | delete flight.heading_from_tracker_to_closest_passing; 621 | delete flight.is_landing; 622 | } 623 | }); 624 | } else { 625 | console.error( 626 | "Tracker state is undefined. Make sure the location tracker entity ID is correct." 627 | ); 628 | } 629 | 630 | return { projected, moving }; 631 | } 632 | 633 | calculateETA(fromLat, fromLon, toLat, toLon, groundSpeed) { 634 | const distance = haversine( 635 | fromLat, 636 | fromLon, 637 | toLat, 638 | toLon, 639 | this.cardState.units.distance 640 | ); 641 | if (groundSpeed === 0) { 642 | return Infinity; 643 | } 644 | 645 | const groundSpeedDistanceUnitsPrMin = 646 | (groundSpeed * 647 | (this.cardState.units.distance === "km" ? 1.852 : 1.15078)) / 648 | 60; 649 | const eta = distance / groundSpeedDistanceUnitsPrMin; 650 | return eta; 651 | } 652 | 653 | toggleSelectedFlight(flight) { 654 | if (!this.cardState.selectedFlights) this.cardState.selectedFlights = []; 655 | if (!this.cardState.selectedFlights.includes(flight.id)) { 656 | this.cardState.selectedFlights.push(flight.id); 657 | } else { 658 | this.cardState.selectedFlights = this.cardState.selectedFlights.filter( 659 | (id) => id !== flight.id 660 | ); 661 | } 662 | this.renderDynamic(); 663 | } 664 | 665 | get hass() { 666 | return this.cardState.hass; 667 | } 668 | } 669 | 670 | customElements.define("flightradar24-card", Flightradar24Card); 671 | 672 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Flightradar24 Card", 3 | "filename": "flightradar24-card.js", 4 | "homeassistant": "2024.3.0", 5 | "render_readme": true 6 | } 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "flightradar24-card", 3 | "version": "1.0.0", 4 | "type": "module", 5 | "description": "Home Assistant custom card for Flightradar24 integration", 6 | "devDependencies": { 7 | "rollup": "^4.52.5", 8 | "@rollup/plugin-node-resolve": "^16.0.3", 9 | "@rollup/plugin-commonjs": "^28.0.9" 10 | }, 11 | "scripts": { 12 | "build": "rollup -c" 13 | } 14 | } 15 | 16 | -------------------------------------------------------------------------------- /render/flag.js: -------------------------------------------------------------------------------- 1 | export function renderFlag(countryCode, countryName) { 2 | const flagElement = document.createElement("img"); 3 | flagElement.setAttribute( 4 | "src", 5 | `https://flagsapi.com/${countryCode}/shiny/16.png` 6 | ); 7 | flagElement.setAttribute("title", `${countryName}`); 8 | flagElement.style.position = "relative"; 9 | flagElement.style.top = "3px"; 10 | flagElement.style.left = "2px"; 11 | return flagElement; 12 | } 13 | -------------------------------------------------------------------------------- /render/map.js: -------------------------------------------------------------------------------- 1 | import { getLocation } from "../utils/location.js"; 2 | import { haversine } from "../utils/geometric.js"; 3 | 4 | /** 5 | * Ensures Leaflet CSS/JS are loaded into shadowRoot *if needed*. 6 | * Only loads if cardState wants a map background. 7 | */ 8 | export function ensureLeafletLoadedIfNeeded(cardState, shadowRoot, onReady) { 9 | if ( 10 | cardState.radar && 11 | cardState.radar.background_map && 12 | cardState.radar.background_map !== "none" 13 | ) { 14 | if (window.L) { 15 | onReady(); 16 | return; 17 | } 18 | if (!shadowRoot.querySelector("#leaflet-css-loader")) { 19 | const link = document.createElement("link"); 20 | link.id = "leaflet-css-loader"; 21 | link.rel = "stylesheet"; 22 | link.href = "https://unpkg.com/leaflet/dist/leaflet.css"; 23 | shadowRoot.appendChild(link); 24 | } 25 | if (!shadowRoot.querySelector("#leaflet-js-loader")) { 26 | const script = document.createElement("script"); 27 | script.id = "leaflet-js-loader"; 28 | script.src = "https://unpkg.com/leaflet/dist/leaflet.js"; 29 | script.async = true; 30 | script.defer = true; 31 | script.onload = onReady; 32 | script.onerror = () => script.remove(); 33 | shadowRoot.appendChild(script); 34 | } else { 35 | const poll = setInterval(() => { 36 | if (window.L) { 37 | clearInterval(poll); 38 | onReady(); 39 | } 40 | }, 50); 41 | } 42 | } else { 43 | onReady(); 44 | } 45 | } 46 | 47 | /** 48 | * Sets up or updates the radar map background and Leaflet map. 49 | * Expects Leaflet to be loaded (window.L) 50 | */ 51 | export function setupRadarMapBg(cardState, radarScreen) { 52 | const { config, dimensions } = cardState; 53 | const TILE_LAYERS = { 54 | bw: [ 55 | "https://tiles.stadiamaps.com/tiles/stamen_toner/{z}/{x}/{y}.png", 56 | { 57 | api_key: "?api_key=", 58 | attribution: 59 | "Map tiles by Stamen Design, CC BY 3.0 — Map data © OpenStreetMap", 60 | subdomains: [], 61 | }, 62 | ], 63 | color: [ 64 | "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", 65 | { 66 | attribution: "© OpenStreetMap contributors", 67 | subdomains: ["a", "b", "c"], 68 | }, 69 | ], 70 | dark: [ 71 | "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png", 72 | { attribution: "© CartoDB" }, 73 | ], 74 | outlines: [ 75 | "https://tiles.stadiamaps.com/tiles/stamen_toner_lines/{z}/{x}/{y}.png", 76 | { 77 | api_key: "?api_key=", 78 | attribution: 79 | "Map tiles by Stamen Design, hosted by Stadia Maps; Data by OpenStreetMap", 80 | subdomains: [], 81 | }, 82 | ], 83 | }; 84 | 85 | let opacity = 86 | typeof config.radar.background_map_opacity === "number" 87 | ? Math.max(0, Math.min(1, config.radar.background_map_opacity)) 88 | : 1; 89 | 90 | let mapBg = radarScreen.querySelector("#radar-map-bg"); 91 | if (!mapBg) { 92 | mapBg = document.createElement("div"); 93 | mapBg.id = "radar-map-bg"; 94 | mapBg.style.position = "absolute"; 95 | mapBg.style.top = "0"; 96 | mapBg.style.left = "0"; 97 | mapBg.style.width = "100%"; 98 | mapBg.style.height = "100%"; 99 | mapBg.style.zIndex = "0"; 100 | mapBg.style.pointerEvents = "none"; 101 | mapBg.style.opacity = opacity; 102 | radarScreen.appendChild(mapBg); 103 | } else { 104 | mapBg.style.opacity = opacity; 105 | } 106 | 107 | mapBg.style.transform = "scale(1)"; 108 | 109 | const location = getLocation(cardState); 110 | const radarRange = Math.max(dimensions.range, 1); // in km by assumption. If in mi, convert! 111 | const rangeKm = config.units === "mi" ? radarRange * 1.60934 : radarRange; 112 | 113 | const lat = location.latitude || 0; 114 | const lon = location.longitude || 0; 115 | 116 | const rad = Math.PI / 180; 117 | const km_per_deg_lat = 111.13209 - 0.56605 * Math.cos(2 * lat * rad) + 0.0012 * Math.cos(4 * lat * rad); 118 | const km_per_deg_lon = 111.320 * Math.cos(lat * rad) - 0.094 * Math.cos(3 * lat * rad); 119 | const deltaLat = rangeKm / km_per_deg_lat; 120 | const deltaLon = rangeKm / km_per_deg_lon; 121 | const bounds = [ 122 | [lat - deltaLat, lon - deltaLon], 123 | [lat + deltaLat, lon + deltaLon], 124 | ]; 125 | const type = config.radar.background_map || "bw"; 126 | let [tileUrl, tileOpts] = TILE_LAYERS[type] || TILE_LAYERS.bw; 127 | if ("api_key" in tileOpts && config.radar.background_map_api_key) { 128 | tileUrl = 129 | tileUrl + 130 | tileOpts.api_key + 131 | encodeURIComponent(config.radar.background_map_api_key); 132 | } 133 | 134 | // Only run if window.L is available 135 | if (window.L) { 136 | if (!cardState._leafletMap) { 137 | cardState._leafletMap = window.L.map(mapBg, { 138 | attributionControl: false, 139 | zoomControl: false, 140 | dragging: false, 141 | scrollWheelZoom: false, 142 | boxZoom: false, 143 | doubleClickZoom: false, 144 | keyboard: false, 145 | touchZoom: false, 146 | pointerEvents: false, 147 | }); 148 | } else { 149 | cardState._leafletMap.eachLayer((layer) => { 150 | cardState._leafletMap.removeLayer(layer); 151 | }); 152 | } 153 | window.L.tileLayer(tileUrl, tileOpts).addTo(cardState._leafletMap); 154 | 155 | cardState._leafletMap.fitBounds(bounds, { animate: false, padding: [0, 0] }); 156 | 157 | // --- After fitBounds, measure actual map span --- 158 | const mapContainer = cardState._leafletMap.getContainer(); 159 | const widthPx = mapContainer.offsetWidth; 160 | const heightPx = mapContainer.offsetHeight; 161 | 162 | const centerLatLng = window.L.latLng(lat, lon); 163 | const pixelCenter = cardState._leafletMap.latLngToContainerPoint(centerLatLng); 164 | 165 | const pixelLeft = window.L.point(0, heightPx/2); 166 | const pixelRight = window.L.point(widthPx, heightPx/2); 167 | 168 | const latLngLeft = cardState._leafletMap.containerPointToLatLng(pixelLeft); 169 | const latLngRight = cardState._leafletMap.containerPointToLatLng(pixelRight); 170 | 171 | 172 | let kmAcross = 1; 173 | kmAcross = haversine(latLngLeft.lat, latLngLeft.lng, latLngRight.lat, latLngRight.lng, "km"); 174 | const desiredKmAcross = rangeKm * 2; 175 | 176 | const scaleCorrection = kmAcross / desiredKmAcross; 177 | mapBg.style.transform = `scale(${scaleCorrection})`; 178 | } 179 | return mapBg; 180 | } 181 | -------------------------------------------------------------------------------- /render/radar.js: -------------------------------------------------------------------------------- 1 | export function renderRadar(cardState, toggleSelectedFlight) { 2 | const { flights, dimensions, selectedFlights, dom } = cardState; 3 | const planesContainer = dom?.planesContainer || document.getElementById("planes"); 4 | planesContainer.innerHTML = ""; 5 | 6 | const { 7 | width: radarWidth, 8 | height: radarHeight, 9 | range: radarRange, 10 | scaleFactor, 11 | centerX: radarCenterX, 12 | centerY: radarCenterY, 13 | } = dimensions; 14 | const clippingRange = radarRange * 1.15; 15 | 16 | flights 17 | .slice() 18 | .reverse() 19 | .forEach((flight) => { 20 | const distance = flight.distance_to_tracker; 21 | if (distance <= clippingRange) { 22 | const plane = document.createElement("div"); 23 | plane.className = "plane"; 24 | 25 | const x = 26 | radarCenterX + 27 | Math.cos(((flight.heading_from_tracker - 90) * Math.PI) / 180) * 28 | distance * 29 | scaleFactor; 30 | const y = 31 | radarCenterY + 32 | Math.sin(((flight.heading_from_tracker - 90) * Math.PI) / 180) * 33 | distance * 34 | scaleFactor; 35 | 36 | plane.style.top = y + "px"; 37 | plane.style.left = x + "px"; 38 | 39 | const arrow = document.createElement("div"); 40 | arrow.className = "arrow"; 41 | arrow.style.transform = `rotate(${flight.heading}deg)`; 42 | plane.appendChild(arrow); 43 | 44 | const label = document.createElement("div"); 45 | label.className = "callsign-label"; 46 | label.textContent = 47 | flight.callsign ?? flight.aircraft_registration ?? "n/a"; 48 | 49 | planesContainer.appendChild(label); 50 | 51 | const labelRect = label.getBoundingClientRect(); 52 | const labelWidth = labelRect.width + 3; 53 | const labelHeight = labelRect.height + 6; 54 | 55 | label.style.top = y - labelHeight + "px"; 56 | label.style.left = x - labelWidth + "px"; 57 | 58 | if (flight.altitude <= 0) { 59 | plane.classList.add("plane-small"); 60 | } else { 61 | plane.classList.add("plane-medium"); 62 | } 63 | if (selectedFlights && selectedFlights.includes(flight.id)) { 64 | plane.classList.add("selected"); 65 | } 66 | 67 | plane.addEventListener("click", () => toggleSelectedFlight(flight)); 68 | label.addEventListener("click", () => toggleSelectedFlight(flight)); 69 | planesContainer.appendChild(plane); 70 | } 71 | }); 72 | } 73 | -------------------------------------------------------------------------------- /render/radarScreen.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Renders the radar screen for the Flightradar24CardState object 3 | * @param {Object} cardState - Flightradar24Card state/context object 4 | */ 5 | import { haversine, calculateBearing } from "../utils/geometric.js"; 6 | import { setupRadarMapBg } from "./map.js"; 7 | import { parseTemplate } from "../utils/template.js"; 8 | import { getLocation } from "../utils/location.js"; 9 | 10 | /** 11 | * Renders the radar screen for the Flightradar24CardState object 12 | * @param {Object} cardState - Flightradar24Card state/context object 13 | */ 14 | export function renderRadarScreen(cardState) { 15 | const { units, radar, dom, dimensions, hass } = cardState; 16 | 17 | // Use cardState.dom references if available 18 | const radarInfoDisplay = 19 | dom?.radarInfoDisplay || 20 | (dom && dom.radarContainer?.querySelector("#radar-info")); 21 | if (radarInfoDisplay) { 22 | const infoElements = [ 23 | radar?.hide_range !== true 24 | ? parseTemplate(cardState, "radar_range", null, null) 25 | : "", 26 | ].filter((el) => el); 27 | radarInfoDisplay.innerHTML = infoElements.join("
"); 28 | } 29 | 30 | const radarScreen = 31 | dom?.radarScreen || 32 | (dom && dom.radarContainer?.querySelector("#radar-screen")) || 33 | document.getElementById("radar-screen"); 34 | if (!radarScreen) return; 35 | 36 | // Only remove overlays, not the background map div 37 | Array.from(radarScreen.childNodes).forEach((child) => { 38 | // Preserve Leaflet map bg 39 | if (!(child.id === "radar-map-bg")) { 40 | radarScreen.removeChild(child); 41 | } 42 | }); 43 | 44 | setupRadarMapBg(cardState, radarScreen); 45 | 46 | // All geometry from cardState.dimensions 47 | // Dimensions must provide: width, height, range, scaleFactor, centerX, centerY 48 | const { 49 | width: radarWidth, 50 | height: radarHeight, 51 | range: radarRange, 52 | scaleFactor, 53 | centerX: radarCenterX, 54 | centerY: radarCenterY, 55 | } = dimensions || {}; 56 | 57 | if ( 58 | !radarWidth || 59 | !radarHeight || 60 | !radarRange || 61 | !scaleFactor || 62 | radarCenterX == null || 63 | radarCenterY == null 64 | ) 65 | return; 66 | 67 | const clippingRange = radarRange * 1.15; 68 | 69 | const ringDistance = radar?.ring_distance ?? 10; 70 | const ringCount = Math.floor(radarRange / ringDistance); 71 | for (let i = 1; i <= ringCount; i++) { 72 | const radius = i * ringDistance * scaleFactor; 73 | const ring = document.createElement("div"); 74 | ring.className = "ring"; 75 | ring.style.width = ring.style.height = radius * 2 + "px"; 76 | ring.style.top = Math.floor(radarCenterY - radius) + "px"; 77 | ring.style.left = Math.floor(radarCenterX - radius) + "px"; 78 | radarScreen.appendChild(ring); 79 | } 80 | 81 | for (let angle = 0; angle < 360; angle += 45) { 82 | const line = document.createElement("div"); 83 | line.className = "dotted-line"; 84 | line.style.transform = `rotate(${angle - 90}deg)`; 85 | radarScreen.appendChild(line); 86 | } 87 | 88 | const location = getLocation(cardState); 89 | if (radar?.local_features && hass) { 90 | if (location) { 91 | const refLat = location.latitude; 92 | const refLon = location.longitude; 93 | radar.local_features.forEach((feature) => { 94 | if (feature.max_range && feature.max_range <= radar.range) return; 95 | if (feature.type === "outline" && feature.points?.length > 1) { 96 | for (let i = 0; i < feature.points.length - 1; i++) { 97 | const start = feature.points[i]; 98 | const end = feature.points[i + 1]; 99 | const startDistance = haversine( 100 | refLat, 101 | refLon, 102 | start.lat, 103 | start.lon, 104 | units.distance 105 | ); 106 | const endDistance = haversine( 107 | refLat, 108 | refLon, 109 | end.lat, 110 | end.lon, 111 | units.distance 112 | ); 113 | if ( 114 | startDistance <= clippingRange || 115 | endDistance <= clippingRange 116 | ) { 117 | const startBearing = calculateBearing( 118 | refLat, 119 | refLon, 120 | start.lat, 121 | start.lon 122 | ); 123 | const endBearing = calculateBearing( 124 | refLat, 125 | refLon, 126 | end.lat, 127 | end.lon 128 | ); 129 | const startX = 130 | radarCenterX + 131 | Math.cos(((startBearing - 90) * Math.PI) / 180) * 132 | startDistance * 133 | scaleFactor; 134 | const startY = 135 | radarCenterY + 136 | Math.sin(((startBearing - 90) * Math.PI) / 180) * 137 | startDistance * 138 | scaleFactor; 139 | const endX = 140 | radarCenterX + 141 | Math.cos(((endBearing - 90) * Math.PI) / 180) * 142 | endDistance * 143 | scaleFactor; 144 | const endY = 145 | radarCenterY + 146 | Math.sin(((endBearing - 90) * Math.PI) / 180) * 147 | endDistance * 148 | scaleFactor; 149 | const outlineLine = document.createElement("div"); 150 | outlineLine.className = "outline-line"; 151 | outlineLine.style.width = 152 | Math.hypot(endX - startX, endY - startY) + "px"; 153 | outlineLine.style.height = "1px"; 154 | outlineLine.style.top = startY + "px"; 155 | outlineLine.style.left = startX + "px"; 156 | outlineLine.style.transformOrigin = "0 0"; 157 | outlineLine.style.transform = `rotate(${ 158 | Math.atan2(endY - startY, endX - startX) * (180 / Math.PI) 159 | }deg)`; 160 | radarScreen.appendChild(outlineLine); 161 | } 162 | } 163 | } else if (feature.position) { 164 | const { lat: featLat, lon: featLon } = feature.position; 165 | const distance = haversine( 166 | refLat, 167 | refLon, 168 | featLat, 169 | featLon, 170 | units.distance 171 | ); 172 | if (distance <= clippingRange) { 173 | const bearing = calculateBearing(refLat, refLon, featLat, featLon); 174 | const featureX = 175 | radarCenterX + 176 | Math.cos(((bearing - 90) * Math.PI) / 180) * 177 | distance * 178 | scaleFactor; 179 | const featureY = 180 | radarCenterY + 181 | Math.sin(((bearing - 90) * Math.PI) / 180) * 182 | distance * 183 | scaleFactor; 184 | if (feature.type === "runway") { 185 | const heading = feature.heading; 186 | const lengthFeet = feature.length; 187 | const lengthUnit = 188 | units.distance === "km" 189 | ? lengthFeet * 0.0003048 190 | : lengthFeet * 0.00018939; 191 | const runway = document.createElement("div"); 192 | runway.className = "runway"; 193 | runway.style.width = lengthUnit * scaleFactor + "px"; 194 | runway.style.height = "1px"; 195 | runway.style.top = featureY + "px"; 196 | runway.style.left = featureX + "px"; 197 | runway.style.transformOrigin = "0 50%"; 198 | runway.style.transform = `rotate(${heading - 90}deg)`; 199 | radarScreen.appendChild(runway); 200 | } 201 | if (feature.type === "location") { 202 | const locationDot = document.createElement("div"); 203 | locationDot.className = "location-dot"; 204 | locationDot.title = feature.label ?? "Location"; 205 | locationDot.style.top = featureY + "px"; 206 | locationDot.style.left = featureX + "px"; 207 | radarScreen.appendChild(locationDot); 208 | if (feature.label) { 209 | const label = document.createElement("div"); 210 | label.className = "location-label"; 211 | label.textContent = feature.label || "Location"; 212 | radarScreen.appendChild(label); 213 | const labelRect = label.getBoundingClientRect(); 214 | const labelWidth = labelRect.width; 215 | const labelHeight = labelRect.height; 216 | label.style.top = featureY - labelHeight - 4 + "px"; 217 | label.style.left = featureX - labelWidth / 2 + "px"; 218 | } 219 | } 220 | } 221 | } 222 | }); 223 | } 224 | } 225 | } 226 | -------------------------------------------------------------------------------- /render/static.js: -------------------------------------------------------------------------------- 1 | import { renderStyle } from "./style.js"; 2 | import { renderToggles } from "./toggles.js"; 3 | import { renderRadarScreen } from "./radarScreen.js"; 4 | import { setupZoomHandlers } from "../utils/zoom.js"; 5 | 6 | export function renderStatic(cardState, mainCard) { 7 | mainCard.shadowRoot.innerHTML = ""; 8 | 9 | const card = document.createElement("ha-card"); 10 | card.id = "flights-card"; 11 | 12 | if (!cardState.radar?.hide) { 13 | const radarContainer = document.createElement("div"); 14 | radarContainer.id = "radar-container"; 15 | 16 | const radarOverlay = document.createElement("div"); 17 | radarOverlay.id = "radar-overlay"; 18 | radarContainer.appendChild(radarOverlay); 19 | 20 | const radarInfoDisplay = document.createElement("div"); 21 | radarInfoDisplay.id = "radar-info"; 22 | radarContainer.appendChild(radarInfoDisplay); 23 | 24 | const toggleContainer = document.createElement("div"); 25 | toggleContainer.id = "toggle-container"; 26 | radarContainer.appendChild(toggleContainer); 27 | 28 | const radar = document.createElement("div"); 29 | radar.id = "radar"; 30 | const radarScreenDiv = document.createElement("div"); 31 | radarScreenDiv.id = "radar-screen"; 32 | radar.appendChild(radarScreenDiv); 33 | 34 | const tracker = document.createElement("div"); 35 | tracker.id = "tracker"; 36 | radar.appendChild(tracker); 37 | 38 | const planesContainer = document.createElement("div"); 39 | planesContainer.id = "planes"; 40 | radar.appendChild(planesContainer); 41 | 42 | radarContainer.appendChild(radar); 43 | card.appendChild(radarContainer); 44 | 45 | requestAnimationFrame(() => { 46 | renderRadarScreen(cardState); 47 | mainCard.observeRadarResize(); 48 | setupZoomHandlers(cardState, radarOverlay); 49 | }); 50 | 51 | cardState.dom = cardState.dom || {}; 52 | cardState.dom.toggleContainer = toggleContainer; 53 | cardState.dom.planesContainer = planesContainer; 54 | cardState.dom.radar = radar; 55 | cardState.dom.radarScreen = radarScreenDiv; 56 | cardState.dom.radarInfoDisplay = radarInfoDisplay; 57 | cardState.dom.shadowRoot = mainCard.shadowRoot; 58 | cardState.mainCard = mainCard; 59 | } 60 | 61 | const flightsContainer = document.createElement("div"); 62 | flightsContainer.id = "flights"; 63 | if (cardState.list && cardState.list.hide === true) { 64 | flightsContainer.style.display = "none"; 65 | } 66 | card.appendChild(flightsContainer); 67 | 68 | mainCard.shadowRoot.appendChild(card); 69 | 70 | renderStyle(cardState, mainCard.shadowRoot); 71 | 72 | renderToggles(cardState, cardState.dom?.toggleContainer); 73 | } 74 | 75 | -------------------------------------------------------------------------------- /render/style.js: -------------------------------------------------------------------------------- 1 | export function renderStyle(cardState, shadowRoot) { 2 | const oldStyle = shadowRoot.querySelector("style[data-fr24-style]"); 3 | if (oldStyle) oldStyle.remove(); 4 | 5 | const radar = cardState.radar || {}; 6 | const radarPrimaryColor = radar["primary-color"] || "var(--dark-primary-color)"; 7 | const radarAccentColor = radar["accent-color"] || "var(--accent-color)"; 8 | const callsignLabelColor = radar["callsign-label-color"] || "var(--primary-background-color)"; 9 | const featureColor = radar["feature-color"] || "var(--secondary-text-color)"; 10 | 11 | const style = document.createElement("style"); 12 | style.setAttribute("data-fr24-style", "1"); 13 | style.textContent = ` 14 | :host { 15 | --radar-primary-color: ${radarPrimaryColor}; 16 | --radar-accent-color: ${radarAccentColor}; 17 | --radar-callsign-label-color: ${callsignLabelColor}; 18 | --radar-feature-color: ${featureColor}; 19 | } 20 | #flights-card { 21 | padding: 16px; 22 | } 23 | #flights { 24 | padding: 0px; 25 | } 26 | #flights .flight { 27 | margin-top: 16px; 28 | margin-bottom: 16px; 29 | } 30 | #flights .flight.first { 31 | margin-top: 0px; 32 | } 33 | #flights .flight.selected { 34 | margin-left: -3px; 35 | margin-right: -3px; 36 | padding: 3px; 37 | background-color: var(--primary-background-color); 38 | border: 1px solid var(--fc-border-color); 39 | border-radius: 4px; 40 | } 41 | #flights .flight { 42 | margin-top: 16px; 43 | margin-bottom: 16px; 44 | } 45 | #flights > :first-child { 46 | margin-top: 0px; 47 | } 48 | #flights > :last-child { 49 | margin-bottom: 0px; 50 | } 51 | #flights .flight a { 52 | text-decoration: none; 53 | font-size: 0.8em; 54 | margin-left: 0.2em; 55 | } 56 | #flights .description { 57 | flex-grow: 1; 58 | } 59 | #flights .no-flights-message { 60 | text-align: center; 61 | font-size: 1.2em; 62 | color: gray; 63 | margin-top: 20px; 64 | } 65 | #radar-container { 66 | display: flex; 67 | justify-content: space-between; 68 | } 69 | #radar-overlay { 70 | position: absolute; 71 | width: 70%; 72 | left: 15%; 73 | padding: 0 0 70% 0; 74 | margin-bottom: 5%; 75 | z-index: 1; 76 | opacity: 0; 77 | pointer-events: auto; 78 | border-radius: 50%; 79 | overflow: hidden; 80 | } 81 | #radar-info { 82 | position: absolute; 83 | width: 30%; 84 | text-align: left; 85 | font-size: 0.9em; 86 | padding: 0; 87 | margin: 0; 88 | } 89 | #toggle-container { 90 | position: absolute; 91 | right: 0; 92 | width: 25%; 93 | text-align: left; 94 | font-size: 0.9em; 95 | padding: 0; 96 | margin: 0 15px; 97 | } 98 | .toggle { 99 | display: flex; 100 | align-items: center; 101 | margin-bottom: 5px; 102 | } 103 | .toggle label { 104 | margin-right: 10px; 105 | flex: 1; 106 | } 107 | #radar { 108 | position: relative; 109 | width: 70%; 110 | height: 0; 111 | margin: 0 15%; 112 | padding-bottom: 70%; 113 | margin-bottom: 5%; 114 | border-radius: 50%; 115 | overflow: hidden; 116 | } 117 | #radar-screen { 118 | position: absolute; 119 | width: 100%; 120 | height: 100%; 121 | margin: 0; 122 | padding: 0%; 123 | } 124 | #radar-screen-background { 125 | position: absolute; 126 | width: 100%; 127 | height: 100%; 128 | margin: 0; 129 | padding: 0%; 130 | background-color: var(--radar-primary-color); 131 | opacity: 0.05; 132 | } 133 | #tracker { 134 | position: absolute; 135 | width: 3px; 136 | height: 3px; 137 | background-color: var(--info-color); 138 | border-radius: 50%; 139 | top: 50%; 140 | left: 50%; 141 | transform: translate(-50%, -50%); 142 | } 143 | .plane { 144 | position: absolute; 145 | translate: -50% -50%; 146 | z-index: 2; 147 | } 148 | .plane.plane-small { 149 | width: 4px; 150 | height: 6px; 151 | } 152 | .plane.plane-medium { 153 | width: 6px; 154 | height: 8px; 155 | } 156 | .plane.plane-large { 157 | width: 8px; 158 | height: 16px; 159 | } 160 | .plane .arrow { 161 | position: absolute; 162 | width: 0; 163 | height: 0; 164 | transform-origin: center center; 165 | } 166 | .plane.plane-small .arrow { 167 | border-left: 2px solid transparent; 168 | border-right: 2px solid transparent; 169 | border-bottom: 6px solid var(--radar-accent-color); 170 | } 171 | .plane.plane-medium .arrow { 172 | border-left: 3px solid transparent; 173 | border-right: 3px solid transparent; 174 | border-bottom: 8px solid var(--radar-accent-color); 175 | } 176 | .plane.plane-large .arrow { 177 | border-left: 4px solid transparent; 178 | border-right: 4px solid transparent; 179 | border-bottom: 16px solid var(--radar-accent-color); 180 | } 181 | .plane.selected { 182 | z-index: 3; 183 | transform: scale(1.2); 184 | } 185 | .plane.selected .arrow { 186 | filter: brightness(1.4); 187 | } 188 | .callsign-label { 189 | position: absolute; 190 | background-color: var(--radar-callsign-label-color); 191 | opacity: 0.7; 192 | border: 1px solid lightgray; 193 | line-height: 1em; 194 | padding: 0px; 195 | margin: 0px; 196 | border-radius: 3px; 197 | font-size: 9px; 198 | color: var(--primary-text-color); 199 | z-index: 2; 200 | } 201 | .ring { 202 | position: absolute; 203 | border: 1px dashed var(--radar-primary-color); 204 | border-radius: 50%; 205 | pointer-events: none; 206 | } 207 | .dotted-line { 208 | position: absolute; 209 | top: 50%; 210 | left: 50%; 211 | border-bottom: 1px dotted var(--radar-primary-color); 212 | width: 50%; 213 | height: 0px; 214 | transform-origin: 0 0; 215 | pointer-events: none; 216 | } 217 | .runway { 218 | position: absolute; 219 | background-color: var(--radar-feature-color); 220 | height: 2px; 221 | } 222 | .location-dot { 223 | position: absolute; 224 | width: 4px; 225 | height: 4px; 226 | background-color: var(--radar-feature-color); 227 | border-radius: 50%; 228 | } 229 | .location-label { 230 | position: absolute; 231 | background: none; 232 | line-height: 0; 233 | border: none; 234 | padding: 0px; 235 | font-size: 10px; 236 | color: var(--radar-feature-color); 237 | opacity: 0.5; 238 | } 239 | .outline-line { 240 | position: absolute; 241 | background-color: var(--radar-feature-color); 242 | opacity: 0.35; 243 | } 244 | `; 245 | shadowRoot.appendChild(style); 246 | } 247 | -------------------------------------------------------------------------------- /render/toggles.js: -------------------------------------------------------------------------------- 1 | export function renderToggles(cardState, toggleContainer) { 2 | toggleContainer.innerHTML = ""; 3 | const toggles = cardState.config.toggles || {}; 4 | Object.keys(toggles).forEach((toggleKey) => { 5 | const toggleDef = toggles[toggleKey]; 6 | const toggleDiv = document.createElement("div"); 7 | toggleDiv.className = "toggle"; 8 | const label = document.createElement("label"); 9 | label.textContent = toggleDef.label || toggleKey; 10 | toggleDiv.appendChild(label); 11 | const input = document.createElement("input"); 12 | input.type = "checkbox"; 13 | input.checked = toggleDef.default === "true"; 14 | input.onchange = (evt) => { 15 | cardState.config.toggles[toggleKey].default = evt.target.checked ? "true" : "false"; 16 | if (typeof cardState.mainCard?.renderDynamic === "function") { 17 | cardState.mainCard.renderDynamic(); 18 | } 19 | }; 20 | toggleDiv.appendChild(input); 21 | toggleContainer.appendChild(toggleDiv); 22 | }); 23 | } 24 | -------------------------------------------------------------------------------- /resources/example_templates_747_a380_toggle.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Springvar/home-assistant-flightradar24-card/0e1c5a2b31e1c753b39ee8d55edaf345dcb9f4f5/resources/example_templates_747_a380_toggle.PNG -------------------------------------------------------------------------------- /resources/example_templates_tails_dark.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Springvar/home-assistant-flightradar24-card/0e1c5a2b31e1c753b39ee8d55edaf345dcb9f4f5/resources/example_templates_tails_dark.PNG -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import resolve from '@rollup/plugin-node-resolve'; 2 | import commonjs from '@rollup/plugin-commonjs'; 3 | 4 | export default { 5 | input: 'flightradar24-card.js', 6 | output: { 7 | file: 'dist/flightradar24-card.js', 8 | format: 'es', 9 | sourcemap: false, 10 | }, 11 | plugins: [ 12 | resolve(), 13 | commonjs(), 14 | ], 15 | }; 16 | -------------------------------------------------------------------------------- /test/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Flightradar24 Card Demo 6 | 7 | 37 | 38 | 39 | 40 | 41 | 213 | 214 | 215 | -------------------------------------------------------------------------------- /utils/filter.js: -------------------------------------------------------------------------------- 1 | function applyFilter(flights, filter, resolvePlaceholders) { 2 | return flights.filter(flight => applyConditions(flight, filter, resolvePlaceholders)); 3 | } 4 | 5 | function applyConditions(flight, conditions, resolvePlaceholders) { 6 | if (Array.isArray(conditions)) { 7 | return conditions.every(condition => applyCondition(flight, condition, resolvePlaceholders)); 8 | } else { 9 | return applyCondition(flight, conditions, resolvePlaceholders); 10 | } 11 | } 12 | 13 | function applyCondition(flight, condition, resolvePlaceholders) { 14 | const { field, defined, defaultValue, _, comparator } = condition; 15 | const value = resolvePlaceholders ? resolvePlaceholders(condition.value) : condition.value; 16 | 17 | let result = true; 18 | 19 | if (condition.type === "AND") { 20 | result = condition.conditions.every(cond => applyCondition(flight, cond, resolvePlaceholders)); 21 | } else if (condition.type === "OR") { 22 | result = condition.conditions.some(cond => applyCondition(flight, cond, resolvePlaceholders)); 23 | } else if (condition.type === "NOT") { 24 | result = !applyCondition(flight, condition.condition, resolvePlaceholders); 25 | } else { 26 | const comparand = 27 | flight[field] ?? 28 | (defined 29 | ? resolvePlaceholders 30 | ? resolvePlaceholders("${" + defined + "}", defaultValue) 31 | : defaultValue 32 | : undefined); 33 | 34 | switch (comparator) { 35 | case "eq": 36 | result = comparand === value; 37 | break; 38 | case "lt": 39 | result = Number(comparand) < Number(value); 40 | break; 41 | case "lte": 42 | result = Number(comparand) <= Number(value); 43 | break; 44 | case "gt": 45 | result = Number(comparand) > Number(value); 46 | break; 47 | case "gte": 48 | result = Number(comparand) >= Number(value); 49 | break; 50 | case "oneOf": { 51 | result = ( 52 | Array.isArray(value) 53 | ? value 54 | : typeof value === "string" 55 | ? value.split(",").map(v => v.trim()) 56 | : [] 57 | ).includes(comparand); 58 | break; 59 | } 60 | case "containsOneOf": { 61 | result = 62 | comparand && 63 | (Array.isArray(value) 64 | ? value 65 | : typeof value === "string" 66 | ? value.split(",").map(v => v.trim()) 67 | : [] 68 | ).some(val => comparand.includes(val)); 69 | break; 70 | } 71 | default: 72 | result = false; 73 | } 74 | } 75 | 76 | if (condition.debugIf === result) { 77 | console.debug("applyCondition", condition, flight, result); 78 | } 79 | 80 | return result; 81 | } 82 | 83 | export { applyFilter, applyConditions, applyCondition }; 84 | -------------------------------------------------------------------------------- /utils/geometric.js: -------------------------------------------------------------------------------- 1 | export function toRadians(degrees) { 2 | return degrees * (Math.PI / 180); 3 | } 4 | 5 | export function toDegrees(radians) { 6 | return radians * (180 / Math.PI); 7 | } 8 | 9 | export function haversine(lat1, lon1, lat2, lon2, units = 'km') { 10 | const R = 6371.0; // Radius of the Earth in kilometers 11 | const dLat = toRadians(lat2 - lat1); 12 | const dLon = toRadians(lon2 - lon1); 13 | const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + 14 | Math.cos(toRadians(lat1)) * Math.cos(toRadians(lat2)) * 15 | Math.sin(dLon / 2) * Math.sin(dLon / 2); 16 | const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); 17 | return units === 'km' ? R * c : (R * c) / 1.60934; 18 | } 19 | 20 | export function calculateBearing(lat1, lon1, lat2, lon2) { 21 | const dLon = toRadians(lon2 - lon1); 22 | const y = Math.sin(dLon) * Math.cos(toRadians(lat2)); 23 | const x = Math.cos(toRadians(lat1)) * Math.sin(toRadians(lat2)) - Math.sin(toRadians(lat1)) * Math.cos(toRadians(lat2)) * Math.cos(dLon); 24 | const bearing = Math.atan2(y, x); 25 | return (toDegrees(bearing) + 360) % 360; // Normalize to 0-360 26 | } 27 | 28 | export function calculateNewPosition(lat, lon, bearing, distanceKm) { 29 | const R = 6371.0; // Radius of the Earth in kilometers 30 | const bearingRad = toRadians(bearing); 31 | const latRad = toRadians(lat); 32 | const lonRad = toRadians(lon); 33 | const distanceRad = distanceKm / R; 34 | 35 | const newLatRad = Math.asin(Math.sin(latRad) * Math.cos(distanceRad) + Math.cos(latRad) * Math.sin(distanceRad) * Math.cos(bearingRad)); 36 | const newLonRad = lonRad + Math.atan2(Math.sin(bearingRad) * Math.sin(distanceRad) * Math.cos(latRad), Math.cos(distanceRad) - Math.sin(latRad) * Math.sin(newLatRad)); 37 | 38 | const newLat = toDegrees(newLatRad); 39 | const newLon = toDegrees(newLonRad); 40 | 41 | return { lat: newLat, lon: newLon }; 42 | } 43 | 44 | export function calculateClosestPassingPoint(refLat, refLon, flightLat, flightLon, heading) { 45 | const trackBearing = calculateBearing(flightLat, flightLon, refLat, refLon); 46 | const angle = Math.abs((heading - trackBearing + 360) % 360); 47 | const distanceToFlight = haversine(refLat, refLon, flightLat, flightLon); 48 | const distanceAlongPath = distanceToFlight * Math.cos(toRadians(angle)); 49 | return calculateNewPosition(flightLat, flightLon, heading, distanceAlongPath); 50 | } 51 | 52 | export function getCardinalDirection(bearing) { 53 | const directions = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']; 54 | const index = Math.round(bearing / 45) % 8; 55 | return directions[index]; 56 | } 57 | 58 | export function areHeadingsAligned(direction_to_tracker, heading, margin = 60) { 59 | const diff = Math.abs((direction_to_tracker - heading + 360) % 360); 60 | return diff <= margin || diff >= 360 - margin; 61 | } 62 | -------------------------------------------------------------------------------- /utils/location.js: -------------------------------------------------------------------------------- 1 | export function getLocation(cardState) { 2 | if (!cardState || !cardState.config) { 3 | console.error("Config not set in getLocation"); 4 | return { latitude: 0, longitude: 0 }; 5 | } 6 | const { config, hass } = cardState; 7 | if ( 8 | config.location_tracker && 9 | hass && 10 | hass.states && 11 | config.location_tracker in hass.states 12 | ) { 13 | return hass.states[config.location_tracker].attributes; 14 | } else if (config.location) { 15 | return { 16 | latitude: config.location.lat, 17 | longitude: config.location.lon, 18 | }; 19 | } else if (hass && hass.config) { 20 | return { 21 | latitude: hass.config.latitude, 22 | longitude: hass.config.longitude, 23 | }; 24 | } 25 | return { latitude: 0, longitude: 0 }; 26 | } -------------------------------------------------------------------------------- /utils/sort.js: -------------------------------------------------------------------------------- 1 | export function parseSortField(obj, field) { 2 | return field.split(" ?? ").reduce((acc, cur) => acc ?? obj[cur], undefined); 3 | } 4 | 5 | /** 6 | * Returns a comparison function for sorting arrays of objects using config. 7 | * @param {Array} sortConfig Array of sort criteria 8 | * @param {Function} resolvePlaceholders Function to resolve placeholders (optional) 9 | */ 10 | export function getSortFn(sortConfig, resolvePlaceholders = (v) => v) { 11 | return function(a, b) { 12 | for (let criterion of sortConfig) { 13 | const { field, comparator, order = "ASC" } = criterion; 14 | const value = resolvePlaceholders(criterion.value); 15 | const fieldA = parseSortField(a, field); 16 | const fieldB = parseSortField(b, field); 17 | let result = 0; 18 | switch (comparator) { 19 | case "eq": 20 | if (fieldA === value && fieldB !== value) { 21 | result = 1; 22 | } else if (fieldA !== value && fieldB === value) { 23 | result = -1; 24 | } 25 | break; 26 | case "lt": 27 | if (fieldA < value && fieldB >= value) { 28 | result = 1; 29 | } else if (fieldA >= value && fieldB < value) { 30 | result = -1; 31 | } 32 | break; 33 | case "lte": 34 | if (fieldA <= value && fieldB > value) { 35 | result = 1; 36 | } else if (fieldA > value && fieldB <= value) { 37 | result = -1; 38 | } 39 | break; 40 | case "gt": 41 | if (fieldA > value && fieldB <= value) { 42 | result = 1; 43 | } else if (fieldA <= value && fieldB > value) { 44 | result = -1; 45 | } 46 | break; 47 | case "gte": 48 | if (fieldA >= value && fieldB < value) { 49 | result = 1; 50 | } else if (fieldA < value && fieldB >= value) { 51 | result = -1; 52 | } 53 | break; 54 | case "oneOf": 55 | if (value !== undefined && value !== null && (Array.isArray(value) || typeof value === "string")) { 56 | const isAInValue = value.includes(fieldA); 57 | const isBInValue = value.includes(fieldB); 58 | if (isAInValue && !isBInValue) { 59 | result = 1; 60 | } else if (!isAInValue && isBInValue) { 61 | result = -1; 62 | } 63 | } 64 | break; 65 | case "containsOneOf": 66 | if (Array.isArray(value) && value.length > 0) { 67 | const isAContainsValue = value.some(val => (Array.isArray(fieldA) || typeof fieldA === "string") && fieldA.includes(val)); 68 | const isBContainsValue = value.some(val => (Array.isArray(fieldB) || typeof fieldB === "string") && fieldB.includes(val)); 69 | if (isAContainsValue && !isBContainsValue) { 70 | result = 1; 71 | } else if (!isAContainsValue && isBContainsValue) { 72 | result = -1; 73 | } 74 | } 75 | break; 76 | default: 77 | result = fieldA - fieldB; 78 | break; 79 | } 80 | if (result !== 0) { 81 | return order.toUpperCase() === "DESC" ? -result : result; 82 | } 83 | } 84 | return 0; 85 | } 86 | } -------------------------------------------------------------------------------- /utils/template.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Recursively compiles a template string with possible sub-template references. 3 | */ 4 | export function compileTemplate(templates = {}, templateId, trace = []) { 5 | if (trace.includes(templateId)) { 6 | console.error( 7 | "Circular template dependencies detected. " + 8 | trace.join(" -> ") + 9 | " -> " + 10 | templateId 11 | ); 12 | return ""; 13 | } 14 | if (templates["compiled_" + templateId]) { 15 | return templates["compiled_" + templateId]; 16 | } 17 | let template = templates[templateId]; 18 | if (template === undefined) { 19 | console.error("Missing template reference: " + templateId); 20 | return ""; 21 | } 22 | const tplRegex = /tpl\.([a-zA-Z_$][a-zA-Z0-9_$]*)/g; 23 | let tplMatch; 24 | const compiledTemplates = {}; 25 | while ((tplMatch = tplRegex.exec(template)) !== null) { 26 | const innerTemplateId = tplMatch[1]; 27 | if (!compiledTemplates[innerTemplateId]) { 28 | compiledTemplates[innerTemplateId] = compileTemplate( 29 | templates, 30 | innerTemplateId, 31 | [...trace, templateId] 32 | ); 33 | } 34 | template = template.replace( 35 | `tpl.${innerTemplateId}`, 36 | "(`" + 37 | compiledTemplates[innerTemplateId] + 38 | '`).replace(/^undefined$/, "")' 39 | ); 40 | } 41 | templates["compiled_" + templateId] = template; 42 | return template; 43 | } 44 | 45 | /** 46 | * Safely parses and interpolates a template string with provided context. 47 | * Accepts an optional joinList helper. 48 | * Accepts cardState object as first argument. 49 | */ 50 | export function parseTemplate( 51 | cardState = {}, 52 | templateId, 53 | flight, 54 | joinList 55 | ) { 56 | const templates = cardState.templates || {}; 57 | const flightsContext = cardState.flightsContext || {}; 58 | const units = cardState.units || {}; 59 | const radar = cardState.radar || {}; 60 | const compiledTemplate = compileTemplate(templates, templateId); 61 | try { 62 | const parsedTemplate = new Function( 63 | "flights", 64 | "flight", 65 | "tpl", 66 | "units", 67 | "radar_range", 68 | "joinList", 69 | `return \`${compiledTemplate.replace(/\${(.*?)}/g, (_, expr) => `\${${expr}}`)}\`` 70 | )(flightsContext, flight, {}, units, Math.round(radar.range), joinList); 71 | return parsedTemplate !== "undefined" ? parsedTemplate : ""; 72 | } catch (e) { 73 | console.error("Error when rendering: " + compiledTemplate, e); 74 | return ""; 75 | } 76 | } 77 | 78 | /** 79 | * Substitute placeholders in a string with values from context objects. 80 | * Accepts cardState object as first argument. 81 | */ 82 | export function resolvePlaceholders(cardState = {}, value, defaultValue, renderDynamicOnRangeChangeSetter) { 83 | const { defines = {}, config = {}, radar = {}, selectedFlights = [] } = cardState; 84 | if (typeof value === "string" && value.startsWith("${") && value.endsWith("}")) { 85 | const key = value.slice(2, -1); 86 | if (key === "selectedFlights") return selectedFlights; 87 | else if (key === "radar_range") { 88 | if (renderDynamicOnRangeChangeSetter) renderDynamicOnRangeChangeSetter(true); 89 | return radar.range; 90 | } else if (key in defines) return defines[key]; 91 | else if (config.toggles && key in config.toggles) return config.toggles[key].default; 92 | else if (defaultValue !== undefined) return defaultValue; 93 | else { console.error("Unresolved placeholder: " + key); console.debug("Defines", defines); } 94 | } 95 | return value; 96 | } 97 | -------------------------------------------------------------------------------- /utils/zoom.js: -------------------------------------------------------------------------------- 1 | export function setupZoomHandlers(cardState, radarOverlay) { 2 | let initialPinchDistance = null; 3 | let initialRadarRange = null; 4 | 5 | function getPinchDistance(touches) { 6 | const [touch1, touch2] = touches; 7 | const dx = touch1.clientX - touch2.clientX; 8 | const dy = touch1.clientY - touch2.clientY; 9 | return Math.sqrt(dx * dx + dy * dy); 10 | } 11 | 12 | function handleWheel(event) { 13 | event.preventDefault(); 14 | const delta = Math.sign(event.deltaY); 15 | cardState.radar.range += delta * 5; // Direct state mutation 16 | cardState.mainCard.updateRadarRange(0); // Should propagate updates 17 | } 18 | 19 | function handleTouchStart(event) { 20 | if (event.touches.length === 2) { 21 | initialPinchDistance = getPinchDistance(event.touches); 22 | initialRadarRange = cardState.radar.range; 23 | } 24 | } 25 | 26 | function handleTouchMove(event) { 27 | if (event.touches.length === 2) { 28 | event.preventDefault(); 29 | const currentPinchDistance = getPinchDistance(event.touches); 30 | if (currentPinchDistance > 0 && initialPinchDistance > 0) { 31 | const pinchRatio = currentPinchDistance / initialPinchDistance; 32 | const newRadarRange = initialRadarRange / pinchRatio; 33 | cardState.radar.range = newRadarRange; // Mutate 34 | cardState.mainCard.updateRadarRange(0); // Trigger render with updated range 35 | } 36 | } 37 | } 38 | 39 | function handleTouchEnd() { 40 | initialPinchDistance = null; 41 | initialRadarRange = null; 42 | if ( 43 | cardState.renderDynamicOnRangeChange && 44 | cardState.config.updateRangeFilterOnTouchEnd 45 | ) { 46 | cardState.mainCard.renderDynamic(); 47 | } 48 | } 49 | 50 | if (radarOverlay) { 51 | radarOverlay.addEventListener("wheel", handleWheel, { passive: false }); 52 | radarOverlay.addEventListener("touchstart", handleTouchStart, { passive: true }); 53 | radarOverlay.addEventListener("touchmove", handleTouchMove, { passive: false }); 54 | radarOverlay.addEventListener("touchend", handleTouchEnd, { passive: true }); 55 | radarOverlay.addEventListener("touchcancel", handleTouchEnd, { passive: true }); 56 | } 57 | 58 | // Optional cleanup method 59 | return function cleanup() { 60 | if (radarOverlay) { 61 | radarOverlay.removeEventListener("wheel", handleWheel); 62 | radarOverlay.removeEventListener("touchstart", handleTouchStart); 63 | radarOverlay.removeEventListener("touchmove", handleTouchMove); 64 | radarOverlay.removeEventListener("touchend", handleTouchEnd); 65 | radarOverlay.removeEventListener("touchcancel", handleTouchEnd); 66 | } 67 | }; 68 | } -------------------------------------------------------------------------------- /yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | "@jridgewell/sourcemap-codec@^1.5.5": 6 | version "1.5.5" 7 | resolved "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz#6912b00d2c631c0d15ce1a7ab57cd657f2a8f8ba" 8 | integrity sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og== 9 | 10 | "@rollup/plugin-commonjs@^28.0.9": 11 | version "28.0.9" 12 | resolved "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-28.0.9.tgz#b875cd1590617a40c4916d561d75761c6ca3c6d1" 13 | integrity sha512-PIR4/OHZ79romx0BVVll/PkwWpJ7e5lsqFa3gFfcrFPWwLXLV39JVUzQV9RKjWerE7B845Hqjj9VYlQeieZ2dA== 14 | dependencies: 15 | "@rollup/pluginutils" "^5.0.1" 16 | commondir "^1.0.1" 17 | estree-walker "^2.0.2" 18 | fdir "^6.2.0" 19 | is-reference "1.2.1" 20 | magic-string "^0.30.3" 21 | picomatch "^4.0.2" 22 | 23 | "@rollup/plugin-node-resolve@^16.0.3": 24 | version "16.0.3" 25 | resolved "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz#0988e6f2cbb13316b0f5e7213f757bc9ed44928f" 26 | integrity sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg== 27 | dependencies: 28 | "@rollup/pluginutils" "^5.0.1" 29 | "@types/resolve" "1.20.2" 30 | deepmerge "^4.2.2" 31 | is-module "^1.0.0" 32 | resolve "^1.22.1" 33 | 34 | "@rollup/pluginutils@^5.0.1": 35 | version "5.3.0" 36 | resolved "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz#57ba1b0cbda8e7a3c597a4853c807b156e21a7b4" 37 | integrity sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q== 38 | dependencies: 39 | "@types/estree" "^1.0.0" 40 | estree-walker "^2.0.2" 41 | picomatch "^4.0.2" 42 | 43 | "@rollup/rollup-android-arm-eabi@4.52.5": 44 | version "4.52.5" 45 | resolved "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.5.tgz#0f44a2f8668ed87b040b6fe659358ac9239da4db" 46 | integrity sha512-8c1vW4ocv3UOMp9K+gToY5zL2XiiVw3k7f1ksf4yO1FlDFQ1C2u72iACFnSOceJFsWskc2WZNqeRhFRPzv+wtQ== 47 | 48 | "@rollup/rollup-android-arm64@4.52.5": 49 | version "4.52.5" 50 | resolved "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.5.tgz#25b9a01deef6518a948431564c987bcb205274f5" 51 | integrity sha512-mQGfsIEFcu21mvqkEKKu2dYmtuSZOBMmAl5CFlPGLY94Vlcm+zWApK7F/eocsNzp8tKmbeBP8yXyAbx0XHsFNA== 52 | 53 | "@rollup/rollup-darwin-arm64@4.52.5": 54 | version "4.52.5" 55 | resolved "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.5.tgz#8a102869c88f3780c7d5e6776afd3f19084ecd7f" 56 | integrity sha512-takF3CR71mCAGA+v794QUZ0b6ZSrgJkArC+gUiG6LB6TQty9T0Mqh3m2ImRBOxS2IeYBo4lKWIieSvnEk2OQWA== 57 | 58 | "@rollup/rollup-darwin-x64@4.52.5": 59 | version "4.52.5" 60 | resolved "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.5.tgz#8e526417cd6f54daf1d0c04cf361160216581956" 61 | integrity sha512-W901Pla8Ya95WpxDn//VF9K9u2JbocwV/v75TE0YIHNTbhqUTv9w4VuQ9MaWlNOkkEfFwkdNhXgcLqPSmHy0fA== 62 | 63 | "@rollup/rollup-freebsd-arm64@4.52.5": 64 | version "4.52.5" 65 | resolved "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.5.tgz#0e7027054493f3409b1f219a3eac5efd128ef899" 66 | integrity sha512-QofO7i7JycsYOWxe0GFqhLmF6l1TqBswJMvICnRUjqCx8b47MTo46W8AoeQwiokAx3zVryVnxtBMcGcnX12LvA== 67 | 68 | "@rollup/rollup-freebsd-x64@4.52.5": 69 | version "4.52.5" 70 | resolved "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.5.tgz#72b204a920139e9ec3d331bd9cfd9a0c248ccb10" 71 | integrity sha512-jr21b/99ew8ujZubPo9skbrItHEIE50WdV86cdSoRkKtmWa+DDr6fu2c/xyRT0F/WazZpam6kk7IHBerSL7LDQ== 72 | 73 | "@rollup/rollup-linux-arm-gnueabihf@4.52.5": 74 | version "4.52.5" 75 | resolved "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.5.tgz#ab1b522ebe5b7e06c99504cc38f6cd8b808ba41c" 76 | integrity sha512-PsNAbcyv9CcecAUagQefwX8fQn9LQ4nZkpDboBOttmyffnInRy8R8dSg6hxxl2Re5QhHBf6FYIDhIj5v982ATQ== 77 | 78 | "@rollup/rollup-linux-arm-musleabihf@4.52.5": 79 | version "4.52.5" 80 | resolved "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.5.tgz#f8cc30b638f1ee7e3d18eac24af47ea29d9beb00" 81 | integrity sha512-Fw4tysRutyQc/wwkmcyoqFtJhh0u31K+Q6jYjeicsGJJ7bbEq8LwPWV/w0cnzOqR2m694/Af6hpFayLJZkG2VQ== 82 | 83 | "@rollup/rollup-linux-arm64-gnu@4.52.5": 84 | version "4.52.5" 85 | resolved "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.5.tgz#7af37a9e85f25db59dc8214172907b7e146c12cc" 86 | integrity sha512-a+3wVnAYdQClOTlyapKmyI6BLPAFYs0JM8HRpgYZQO02rMR09ZcV9LbQB+NL6sljzG38869YqThrRnfPMCDtZg== 87 | 88 | "@rollup/rollup-linux-arm64-musl@4.52.5": 89 | version "4.52.5" 90 | resolved "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.5.tgz#a623eb0d3617c03b7a73716eb85c6e37b776f7e0" 91 | integrity sha512-AvttBOMwO9Pcuuf7m9PkC1PUIKsfaAJ4AYhy944qeTJgQOqJYJ9oVl2nYgY7Rk0mkbsuOpCAYSs6wLYB2Xiw0Q== 92 | 93 | "@rollup/rollup-linux-loong64-gnu@4.52.5": 94 | version "4.52.5" 95 | resolved "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.5.tgz#76ea038b549c5c6c5f0d062942627c4066642ee2" 96 | integrity sha512-DkDk8pmXQV2wVrF6oq5tONK6UHLz/XcEVow4JTTerdeV1uqPeHxwcg7aFsfnSm9L+OO8WJsWotKM2JJPMWrQtA== 97 | 98 | "@rollup/rollup-linux-ppc64-gnu@4.52.5": 99 | version "4.52.5" 100 | resolved "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.5.tgz#d9a4c3f0a3492bc78f6fdfe8131ac61c7359ccd5" 101 | integrity sha512-W/b9ZN/U9+hPQVvlGwjzi+Wy4xdoH2I8EjaCkMvzpI7wJUs8sWJ03Rq96jRnHkSrcHTpQe8h5Tg3ZzUPGauvAw== 102 | 103 | "@rollup/rollup-linux-riscv64-gnu@4.52.5": 104 | version "4.52.5" 105 | resolved "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.5.tgz#87ab033eebd1a9a1dd7b60509f6333ec1f82d994" 106 | integrity sha512-sjQLr9BW7R/ZiXnQiWPkErNfLMkkWIoCz7YMn27HldKsADEKa5WYdobaa1hmN6slu9oWQbB6/jFpJ+P2IkVrmw== 107 | 108 | "@rollup/rollup-linux-riscv64-musl@4.52.5": 109 | version "4.52.5" 110 | resolved "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.5.tgz#bda3eb67e1c993c1ba12bc9c2f694e7703958d9f" 111 | integrity sha512-hq3jU/kGyjXWTvAh2awn8oHroCbrPm8JqM7RUpKjalIRWWXE01CQOf/tUNWNHjmbMHg/hmNCwc/Pz3k1T/j/Lg== 112 | 113 | "@rollup/rollup-linux-s390x-gnu@4.52.5": 114 | version "4.52.5" 115 | resolved "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.5.tgz#f7bc10fbe096ab44694233dc42a2291ed5453d4b" 116 | integrity sha512-gn8kHOrku8D4NGHMK1Y7NA7INQTRdVOntt1OCYypZPRt6skGbddska44K8iocdpxHTMMNui5oH4elPH4QOLrFQ== 117 | 118 | "@rollup/rollup-linux-x64-gnu@4.52.5": 119 | version "4.52.5" 120 | resolved "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.5.tgz#a151cb1234cc9b2cf5e8cfc02aa91436b8f9e278" 121 | integrity sha512-hXGLYpdhiNElzN770+H2nlx+jRog8TyynpTVzdlc6bndktjKWyZyiCsuDAlpd+j+W+WNqfcyAWz9HxxIGfZm1Q== 122 | 123 | "@rollup/rollup-linux-x64-musl@4.52.5": 124 | version "4.52.5" 125 | resolved "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.5.tgz#7859e196501cc3b3062d45d2776cfb4d2f3a9350" 126 | integrity sha512-arCGIcuNKjBoKAXD+y7XomR9gY6Mw7HnFBv5Rw7wQRvwYLR7gBAgV7Mb2QTyjXfTveBNFAtPt46/36vV9STLNg== 127 | 128 | "@rollup/rollup-openharmony-arm64@4.52.5": 129 | version "4.52.5" 130 | resolved "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.5.tgz#85d0df7233734df31e547c1e647d2a5300b3bf30" 131 | integrity sha512-QoFqB6+/9Rly/RiPjaomPLmR/13cgkIGfA40LHly9zcH1S0bN2HVFYk3a1eAyHQyjs3ZJYlXvIGtcCs5tko9Cw== 132 | 133 | "@rollup/rollup-win32-arm64-msvc@4.52.5": 134 | version "4.52.5" 135 | resolved "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.5.tgz#e62357d00458db17277b88adbf690bb855cac937" 136 | integrity sha512-w0cDWVR6MlTstla1cIfOGyl8+qb93FlAVutcor14Gf5Md5ap5ySfQ7R9S/NjNaMLSFdUnKGEasmVnu3lCMqB7w== 137 | 138 | "@rollup/rollup-win32-ia32-msvc@4.52.5": 139 | version "4.52.5" 140 | resolved "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.5.tgz#fc7cd40f44834a703c1f1c3fe8bcc27ce476cd50" 141 | integrity sha512-Aufdpzp7DpOTULJCuvzqcItSGDH73pF3ko/f+ckJhxQyHtp67rHw3HMNxoIdDMUITJESNE6a8uh4Lo4SLouOUg== 142 | 143 | "@rollup/rollup-win32-x64-gnu@4.52.5": 144 | version "4.52.5" 145 | resolved "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.5.tgz#1a22acfc93c64a64a48c42672e857ee51774d0d3" 146 | integrity sha512-UGBUGPFp1vkj6p8wCRraqNhqwX/4kNQPS57BCFc8wYh0g94iVIW33wJtQAx3G7vrjjNtRaxiMUylM0ktp/TRSQ== 147 | 148 | "@rollup/rollup-win32-x64-msvc@4.52.5": 149 | version "4.52.5" 150 | resolved "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.5.tgz#1657f56326bbe0ac80eedc9f9c18fc1ddd24e107" 151 | integrity sha512-TAcgQh2sSkykPRWLrdyy2AiceMckNf5loITqXxFI5VuQjS5tSuw3WlwdN8qv8vzjLAUTvYaH/mVjSFpbkFbpTg== 152 | 153 | "@types/estree@*", "@types/estree@1.0.8", "@types/estree@^1.0.0": 154 | version "1.0.8" 155 | resolved "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e" 156 | integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== 157 | 158 | "@types/resolve@1.20.2": 159 | version "1.20.2" 160 | resolved "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz#97d26e00cd4a0423b4af620abecf3e6f442b7975" 161 | integrity sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q== 162 | 163 | commondir@^1.0.1: 164 | version "1.0.1" 165 | resolved "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b" 166 | integrity sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg== 167 | 168 | deepmerge@^4.2.2: 169 | version "4.3.1" 170 | resolved "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a" 171 | integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A== 172 | 173 | estree-walker@^2.0.2: 174 | version "2.0.2" 175 | resolved "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz#52f010178c2a4c117a7757cfe942adb7d2da4cac" 176 | integrity sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w== 177 | 178 | fdir@^6.2.0: 179 | version "6.5.0" 180 | resolved "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz#ed2ab967a331ade62f18d077dae192684d50d350" 181 | integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg== 182 | 183 | fsevents@~2.3.2: 184 | version "2.3.3" 185 | resolved "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" 186 | integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== 187 | 188 | function-bind@^1.1.2: 189 | version "1.1.2" 190 | resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" 191 | integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== 192 | 193 | hasown@^2.0.2: 194 | version "2.0.2" 195 | resolved "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz#003eaf91be7adc372e84ec59dc37252cedb80003" 196 | integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ== 197 | dependencies: 198 | function-bind "^1.1.2" 199 | 200 | is-core-module@^2.16.1: 201 | version "2.16.1" 202 | resolved "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz#2a98801a849f43e2add644fbb6bc6229b19a4ef4" 203 | integrity sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w== 204 | dependencies: 205 | hasown "^2.0.2" 206 | 207 | is-module@^1.0.0: 208 | version "1.0.0" 209 | resolved "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz#3258fb69f78c14d5b815d664336b4cffb6441591" 210 | integrity sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g== 211 | 212 | is-reference@1.2.1: 213 | version "1.2.1" 214 | resolved "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz#8b2dac0b371f4bc994fdeaba9eb542d03002d0b7" 215 | integrity sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ== 216 | dependencies: 217 | "@types/estree" "*" 218 | 219 | magic-string@^0.30.3: 220 | version "0.30.21" 221 | resolved "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz#56763ec09a0fa8091df27879fd94d19078c00d91" 222 | integrity sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ== 223 | dependencies: 224 | "@jridgewell/sourcemap-codec" "^1.5.5" 225 | 226 | path-parse@^1.0.7: 227 | version "1.0.7" 228 | resolved "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" 229 | integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== 230 | 231 | picomatch@^4.0.2: 232 | version "4.0.3" 233 | resolved "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz#796c76136d1eead715db1e7bad785dedd695a042" 234 | integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q== 235 | 236 | resolve@^1.22.1: 237 | version "1.22.11" 238 | resolved "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz#aad857ce1ffb8bfa9b0b1ac29f1156383f68c262" 239 | integrity sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ== 240 | dependencies: 241 | is-core-module "^2.16.1" 242 | path-parse "^1.0.7" 243 | supports-preserve-symlinks-flag "^1.0.0" 244 | 245 | rollup@^4.52.5: 246 | version "4.52.5" 247 | resolved "https://registry.npmjs.org/rollup/-/rollup-4.52.5.tgz#96982cdcaedcdd51b12359981f240f94304ec235" 248 | integrity sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw== 249 | dependencies: 250 | "@types/estree" "1.0.8" 251 | optionalDependencies: 252 | "@rollup/rollup-android-arm-eabi" "4.52.5" 253 | "@rollup/rollup-android-arm64" "4.52.5" 254 | "@rollup/rollup-darwin-arm64" "4.52.5" 255 | "@rollup/rollup-darwin-x64" "4.52.5" 256 | "@rollup/rollup-freebsd-arm64" "4.52.5" 257 | "@rollup/rollup-freebsd-x64" "4.52.5" 258 | "@rollup/rollup-linux-arm-gnueabihf" "4.52.5" 259 | "@rollup/rollup-linux-arm-musleabihf" "4.52.5" 260 | "@rollup/rollup-linux-arm64-gnu" "4.52.5" 261 | "@rollup/rollup-linux-arm64-musl" "4.52.5" 262 | "@rollup/rollup-linux-loong64-gnu" "4.52.5" 263 | "@rollup/rollup-linux-ppc64-gnu" "4.52.5" 264 | "@rollup/rollup-linux-riscv64-gnu" "4.52.5" 265 | "@rollup/rollup-linux-riscv64-musl" "4.52.5" 266 | "@rollup/rollup-linux-s390x-gnu" "4.52.5" 267 | "@rollup/rollup-linux-x64-gnu" "4.52.5" 268 | "@rollup/rollup-linux-x64-musl" "4.52.5" 269 | "@rollup/rollup-openharmony-arm64" "4.52.5" 270 | "@rollup/rollup-win32-arm64-msvc" "4.52.5" 271 | "@rollup/rollup-win32-ia32-msvc" "4.52.5" 272 | "@rollup/rollup-win32-x64-gnu" "4.52.5" 273 | "@rollup/rollup-win32-x64-msvc" "4.52.5" 274 | fsevents "~2.3.2" 275 | 276 | supports-preserve-symlinks-flag@^1.0.0: 277 | version "1.0.0" 278 | resolved "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" 279 | integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== 280 | --------------------------------------------------------------------------------