[
  {
    "path": ".gitignore",
    "content": ".vscode\n*sync*\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2024 Pablo Sabbag, VA3HDL\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "# Ham Dashboard (hamdashboard)\n\nLive demo: [Hamdash Demo](https://va3hdl.github.io/hamdash/)\n\nThis repository provides a simple, browser-based ham radio dashboard that displays images, maps, web pages, and feeds in a configurable grid. It is lightweight, easy to host, and suitable for use on a local computer, Raspberry Pi, or a static host such as GitHub Pages or Cloudflare Pages.\n\nQuick demo videos:\n- Original presentation: [YouTube - VA3HDL presentation](https://www.youtube.com/watch?v=sIdqMQTGNSc)\n- Spanish overview: [YouTube - VA3HDL en español](https://www.youtube.com/watch?v=IBMxELofKVA)\n\n## User-submitted public dashboards\nThese live dashboards were shared by members of the ham community:\n\n- [BCAT N4TDX](https://qsl.net/n/n5ng/BCAT/) — Steve N5NG (Brevard County ARES)\n  - Steve's config files (as .txt): <https://qsl.net/n5ng/config.txt> and <https://qsl.net/n5ng/HAM/config.txt>\n- [FFX DEMS](https://kq4dne.github.io/WeatherDash/WeatherDash.html) — Sandy KQ4DNE\n- [FFX ARES](https://kq4dne.github.io/hamdash/hamdash.html) — Sandy KQ4DNE\n- [WA4MED](https://dashboard.wa4med.us/hamdash.html) — Matthew WA4MED\n- [PY3TX](https://dashboard.py3tx.com/) — South America\n- [VE7CAS](https://hamradio.smecher.bc.ca/) — Vancouver, BC\n- [G0IKV](https://g0ikv.qsy.to/) — Southport, England\n- [OK1SLM](https://www.qsl.net/ok1slm/) — Prague\n- [VK3VSN](https://www.vicscan.com/hamdash/) — Melbourne, Australia\n- [K6BCW](https://elihickox.com/radio/hamdashboard/hamdash.html) — San Francisco Bay Area\n- [KN6PTQ](https://kn6ptq.com/) — San Francisco Bay Area\n- [W2SZ](https://dashboard.w2sz.org/) — NE US\n- [N2YQT](https://dashboard.tourge.net/) — NE US\n- [KC2VWR](https://baef57ae.ham-desktop.pages.dev/) — NE US\n- [KD2YFY](https://dash.kd2yfy.net/) — NE US\n- [KD4VRD](https://hamdashboard-8fn.pages.dev/) — North Carolina\n- [KD5PQJ](https://kd5pqj.com/dash/index.html) — Texas\n- [N5GAH](http://n5gah.com/) — Texas\n- [KJ7YYI](https://kj7yyi.net/ham-dash/) — Arizona\n- [NQ0M](https://hamdash.nq0m.com/#) — Kansas\n- [W3RDW](https://dashboard.w3rdw.radio/) — Ohio\n- [W4QAL](https://w4qal.net/dashboard/index.html) — West Florida\n\n## Quick start\n\n1. Download the following files from this repository into a single folder: `hamdash.html`, `config.js`, and `wheelzoom.js`.\n2. Open `hamdash.html` in your browser.\n3. Use the right-side menu and select \"Setup\" to open the settings UI and configure your dashboard.\n4. Alternatively, edit `config.js` in a text editor to set sources, menus, and layout.\n5. Load configuration from the browser (Local Storage) or from `config.js`, then save your settings.\n\n**Notes**\n- For hosted (server) installations, store settings in `config.js` so the server serves the same configuration to all visitors.\n- For personal use or testing, Local Storage keeps changes specific to your browser session.\n- Now is possible to use a pure Json file format for the configuration load on hosted environments\n- For file:// access (non-hosted usage) a newer JsonP-style format is available for the configuration load\n\n## Settings UI\n\nThe settings UI provides buttons to manage configurations and backups:\n\n<img src=\"https://github.com/VA3HDL/hamdashboard/blob/main/examples/settings_buttons.png?raw=true\" width=\"800\">\n\n- Save Settings to Local Storage — Save current page settings in the browser.\n- Reset to Defaults — Restore sample settings for testing.\n- Backup Settings to JSON file — Download a JSON file with your settings.\n- Restore Settings from JSON file — Load settings from a JSON backup.\n- Import from `config.js` — Load settings defined in a `config.js` file (recommended for servers).\n- Export to `config.js` — Export current settings in `config.js` format for hosting.\n\n## Public dashboards and safety\n\nThe \"Setup\" UI cannot modify the server-side `config.js` file. When a visitor switches a public dashboard to Local Storage, the change affects only that visitor's browser. To hide the **Setup** option or **Load Cfg** option on public installations, add the following lines to your `config.js`:\n\n```\nconst disableSetup = true;\nconst disableLdCfg = true;\n```\n\n## Video guides\n\n- [Configuration instructions — Jason KM4ACK](https://youtu.be/9ZZXg60tN-o)\n- [Raspberry Pi setup — Andreas M0FXB](https://www.youtube.com/watch?v=Km_vOCvCMFM)\n- [Live stream — Frank KG6NLW](https://www.youtube.com/watch?v=rJHCpNHDbC0&t=140s)\n- [Live stream — KM9G](https://www.youtube.com/watch?v=ohlHaSsf6B8=400s)\n- [Ham Dashboard on Inovato Quadra — Peter KJ5AJB](https://www.youtube.com/watch?v=u07Oz-YSrQY)\n- [French review — Jean-Benard F5SVP](https://www.youtube.com/watch?v=o9Dl9A5hqQI)\n- [Spanish instructions — Jose EA8EE](https://www.youtube.com/watch?v=3CnsfB3zNuM)\n\n## Getting help\n\nAlways check the [Q&A section](https://github.com/VA3HDL/hamdashboard/discussions/categories/q-a) for solutions to common issues.\n\n## Docker\n\nMichael Stevens maintains a Docker image: [michaelsteven/hamdashboard](https://registry.hub.docker.com/r/michaelsteven/hamdashboard)\n\n## How to use\n\n- Double-click an image to view full-screen; double-click again to close.\n- Right-click an image to cycle to the next image (if multiple images are assigned to a tile).\n- Tiles refresh independently (default refresh behavior: every 5 minutes for most sources).\n- Tiles with iFrames: double click to unlock the tile and interact with the content\n\n## Pi-Star iFrame embedding (fix)\n\nIf a remote site sets the `X-Frame-Options` header it may prevent embedding via iframes. On Pi-Star you can temporarily switch to read/write, edit the nginx security config, and restart nginx:\n\n```bash\nrpi-rw\nsudo nano /etc/nginx/default.d/security.conf\n# comment out: add_header X-Frame-Options  \"SAMEORIGIN\";\n\nsudo systemctl restart nginx.service\n```\n\nThis screenshot shows Pi-Star settings:\n\n<img src=\"https://github.com/VA3HDL/hamdashboard/blob/main/examples/pistar.png?raw=true\" width=\"400\">\n\n## iFrame tips\n\nIf the source server forbids embedding and you cannot change its headers, options are limited. A local proxy that strips the header can work but adds complexity. Use the online tool to test a URL before adding it to a tile: [iFrame Tester online](https://iframetester.com/)\n\n## Changelog highlights (most recent)\n\nSee the chronological entries below for full details. Notable recent changes:\n\n- 2026.01.30 — Fixed RSS feeds not loading on some hosting situations due to a CORS issue.\n\n- 2026.01.24 — Added 10 features:\n  1. JSON & JSONP Configuration Support (example .json and JsonP .js files added to the repo)\n  2. Dynamic Date Placeholders\n  3. Rotating Tile **Titles** - Requested by multiple users, see example in all 3 config files\n  4. Smart Mixed-Media Interactivity (for tiles mixing images, videos, iFrames)\n  5. Enhanced Full-Screen Navigation\n  6. Setup UI Improvements\n  7. Enhanced Breadcrumb Navigation to provide always a return path to previous configs\n  8. PREVIOUS Menu Button\n  9. Enhanced Config File Detection to support various file formats\n  10. File Picker Integration to load different dashboards on the fly\n\n- 2026.01.22 — Added directives to load images and iframes with colors inverted. Full details on the release notes.\n- 2026.01.17 — Ability to load any config files via the menu.\n- 2025.11.12 — Switch between multiple config files (e.g., `satellite.js`) via the menu.\n- 2025.04.02 — RSS feed refresh times configurable; feed ticker added.\n- 2025.03.29 — Scrolling RSS ticker and clickable feed items.\n- 2025.01.24 — Settings merged into `hamdash.html`; realtime variable changes enabled.\n\n## Upgrade notes\n\n- For simpler sintax you can now use Json or JsonP files for config files\n- Read the specific upgrade notes in the changelog below before replacing `config.js`\n- To use multiple config files, add a menu entry in `config.js` such as:\n\n```\nvar aURL = [  \n  [\"f3de21ff\", \"SATS\", \"satellite.js\"],\n  [\"f3de21ff\", \"WX\", \"weather.js\", \"1\", \"R\"]\n];\n```\n**Rotating Tile Titles Usage:**\n\nPass an array as the first element of a tile configuration.\n```javascript\n// Example in config.js\n[\n  [\"Radar CONUS\", \"Radar Local\"], \n  \"https://radar.com/map1.gif\", \n  \"https://radar.com/map2.gif\"\n]\n```\n## Example images\n\n<img src=\"https://github.com/VA3HDL/hamdashboard/blob/main/examples/satellite.png?raw=true\" width=\"600\">\n<img src=\"https://github.com/VA3HDL/hamdashboard/blob/main/examples/config.png?raw=true\" width=\"600\">\n\nGrid examples\n\n<img src=\"https://github.com/VA3HDL/hamdashboard/blob/main/examples/2x2.png?raw=true\" width=\"200\">\n<img src=\"https://github.com/VA3HDL/hamdashboard/blob/main/examples/3x3.png?raw=true\" width=\"200\">\n<img src=\"https://github.com/VA3HDL/hamdashboard/blob/main/examples/4x4.png?raw=true\" width=\"200\">\n<img src=\"https://github.com/VA3HDL/hamdashboard/blob/main/examples/5x3.png?raw=true\" width=\"200\">\n\n## More notes and history\n\nThe repository includes a detailed changelog documenting fixes, features, and upgrade instructions dating back through 2024. Please review the changelog entries below before performing upgrades.\n\n[Releases & Change logs](https://github.com/VA3HDL/hamdashboard/releases)\n\n## Host with Cloudflare Pages (free)\n\nTutorial contributed by Robert W3RDW:\n[How to host your dashboard with Cloudflare Pages, free](https://w3rdw.radio/posts/hamdashboard/)\n\n## Sample dashboards submitted by users\n\n![VA3HDL Sample Dashboard](https://github.com/VA3HDL/hamdashboard/blob/main/examples/dashboard_sample.png?raw=true)\n\n![N4NBC Sample Dashboard](https://github.com/VA3HDL/hamdashboard/blob/main/examples/N4NBC-sample.jpg?raw=true)\n\n![KM4ACK Sample Dashboard](https://github.com/VA3HDL/hamdashboard/blob/main/examples/KM4ACK-sample.png?raw=true)\n\n![TI3GB Sample Dashboard](https://github.com/VA3HDL/hamdashboard/blob/main/examples/TI3GB-sample.png?raw=true)\n\n![N5NG Sample Dashboard](https://github.com/VA3HDL/hamdashboard/blob/main/examples/N5NG-sample.png?raw=true)\n\n![VK3MLT Sample Dashboard](https://github.com/VA3HDL/hamdashboard/blob/main/examples/VK3MLT-sample.png?raw=true)\n\n![VK5TUX Sample Dashboard](examples/VK5TUX_Sample_VA3HDL_Ham_Radio_Dashboard.png?raw=true)\n\n![VK5TUX Sample Dashboard Sources](examples/VK5TUX_Sample_VA3HDL_Ham_Radio_Dashboard_Sources.png?raw=true)\n\n![N4TDX Sample Dashboard](https://github.com/VA3HDL/hamdashboard/blob/main/examples/N4TDX-sample.png?raw=true)\n\n![WG5EEK Sample Dashboard](https://github.com/VA3HDL/hamdashboard/blob/main/examples/WG5EEK-sample.jpg?raw=true)\n\n![KJ5FMX Sample Dashboard](https://github.com/VA3HDL/hamdashboard/blob/main/examples/KJ5FMX-sample.jpg?raw=true)\n\n![N0RMJ Sample Dashboard](https://github.com/VA3HDL/hamdashboard/blob/main/examples/N0RMJ-sample.jpg?raw=true)\n\n![N5GAH Sample Dashboard](https://github.com/VA3HDL/hamdashboard/blob/main/examples/N5GAH-sample.jpg?raw=true)\n\n![OES MarTech Sample Dashboard](https://github.com/VA3HDL/hamdashboard/blob/main/examples/OESmartech.jpg?raw=true)\n\n![TheSky Sample Dashboard](https://github.com/VA3HDL/hamdashboard/blob/main/examples/TheSky.jpg?raw=true)\n\n![KJ7T Sample Dashboard](https://github.com/VA3HDL/hamdashboard/blob/main/examples/KJ7T-sample.png?raw=true)\n\n![K4HNH Sample Dashboard](https://github.com/VA3HDL/hamdashboard/blob/main/examples/K4HNH-sample.jpg?raw=true)\n\n![CT1ETE Sample Dashboard](https://github.com/VA3HDL/hamdashboard/blob/main/examples/CT1ETE-sample.jpg?raw=true)\n\n![VK3FS Sample Dashboard](https://github.com/VA3HDL/hamdashboard/blob/main/examples/VK3FS-sample.png?raw=true)\n\n![W5EAK Sample Dashboard](https://github.com/VA3HDL/hamdashboard/blob/main/examples/W5EAK-sample.jpg?raw=true)\n\n![WI5L Sample Dashboard](https://github.com/VA3HDL/hamdashboard/blob/main/examples/WI5L-sample.jpg?raw=true)\n\n![WX9WTF Sample Dashboard](https://github.com/VA3HDL/hamdashboard/blob/main/examples/WX9WTF-sample.jpg?raw=true)\n\n### Dual menu example\n\n![Dual side Menu Sample Dashboard](https://github.com/VA3HDL/hamdashboard/blob/main/examples/DualMenu.png?raw=true)\n\n### Sources display example\n\n![Sources display example](https://github.com/VA3HDL/hamdashboard/blob/main/examples/sources.png?raw=true)\n"
  },
  {
    "path": "config.js",
    "content": "const disableSetup = false;\nconst disableLdCfg = false;\nvar topBarCenterText = `VA3HDL - FN04ga - .js`;\n\n// Grid layout\nvar layout_cols = 4;\nvar layout_rows = 3;\n\n// Menu items\n// Structure is as follows HTML Color code, Option, target URL, scaling 1=Original Size, side (optional, nothing is Left, \"R\" is Right)\n// The values are [color code, menu text, target link, scale factor, side],\n// add new lines following the structure for extra menu options. The comma at the end is important!\nvar aURL = [\n  [\"f3de21\", \"SATS\", \"satellite.js\"],\n  \n  [\"2196F3\", \"CLUBLOG\", \"https://clublog.org/livestream/VA3HDL\", \"1.7\"],\n  [\n    \"2196F3\",\n    \"CONTEST\",\n    \"https://www.contestcalendar.com/fivewkcal.html\",\n    \"1\",\n  ],\n  [\"2196F3\", \"DX CLUSTER\", \"https://dxcluster.ha8tks.hu/map/\", \"1\"],\n  [\n    \"2196F3\",\n    \"LIGHTNING\",\n    \"https://map.blitzortung.org/#3.87/36.5/-89.41\",\n    \"1\",\n    \"R\",\n  ],\n  [\"2196F3\", \"PISTAR\", \"http://pi-star.local/\", \"1.2\"],\n  [\n    \"2196F3\",\n    \"RADAR\",\n    \"dark|https://weather.gc.ca/?layers=alert,radar&center=43.39961001,-78.53212031&zoom=6&alertTableFilterProv=ON\",\n    \"1\",\n    \"R\"\n  ],\n  [\"2196F3\", \"TIME.IS\", \"https://time.is/\", \"1\", \"R\"],\n  [\n    \"2196F3\",\n    \"WEATHER\",\n    \"https://openweathermap.org/weathermap?basemap=map&cities=true&layer=temperature&lat=44.0157&lon=-79.4591&zoom=5\",\n    \"1\",\n    \"R\",\n  ],\n  [\n    \"2196F3\",\n    \"WINDS\",\n    \"https://earth.nullschool.net/#current/wind/surface/level/orthographic=-78.79,44.09,3000\",\n    \"1\",\n    \"R\",\n  ],\n];\n\n// Dashboard items\n// Structure is Title, Image Source URL\n// [Title, Image Source URL],\n// the comma at the end is important!\n// You can't add more items because there are only 12 placeholders on the dashboard\n// but you can replace the titles and the images with anything you want.\nvar aIMG = [\n  [[\"Radar CONUS\", \"Radar Small\"], \"https://radar.weather.gov/ridge/standard/CONUS-LARGE_loop.gif\", \"https://radar.weather.gov/ridge/standard/CONUS_loop.gif\"],\n  [\n    \"LOCAL RADAR (inverted)\",\n    \"invert|https://radar.weather.gov/ridge/standard/KNQA_loop.gif\",\n  ],\n  [\n    \"NOAA D-RAP (inverted)\",\n    \"invert|https://s.w-x.co/staticmaps/wu/wxtype/county_loc/bgm/animate.png\",\n  ],\n  [\n    \"ISS POSITION\",\n    \"https://www.heavens-above.com/orbitdisplay.aspx?icon=iss&width=600&height=300&mode=M&satid=25544\",\n  ],\n  [\n    \"SATELLITE CAN\",\n    \"https://cdn.star.nesdis.noaa.gov/GOES16/GLM/SECTOR/can/EXTENT3/GOES16-CAN-EXTENT3-1125x560.gif\",\n  ],\n  [\n    \"SATELLITE CGL\",\n    \"https://cdn.star.nesdis.noaa.gov/GOES16/GLM/SECTOR/cgl/EXTENT3/GOES16-CGL-EXTENT3-600x600.gif\",\n  ],\n  [\n    \"LIGHTNING\",\n    \"https://images.lightningmaps.org/blitzortung/america/index.php?animation=usa\",\n  ],\n  [\n    \"LIGHTNING LOCAL\",\n    \"https://www.blitzortung.org/en/Images/image_b_ny.png\",\n  ],\n  [\"YOUTUBE EXAMPLE\", \"iframe|https://www.youtube.com/embed/fzPFaXAV_2Y?autoplay=1&mute=1\"],\n  [\n    \"WEBSITE EXAMPLE\",\n    \"iframe|https://globe.adsbexchange.com/?airport=YYZ\",\n  ],\n  [\"VIDEO EXAMPLE\", \"https://himawari8.nict.go.jp/movie/720/20240611_pifd.mp4\"],\n  [\"HF PROPAGATION\",\n    \"https://www.hamqsl.com/solar101vhf.php\"],\n];\n\n// Image rotation intervals in milliseconds per tile - If the line below is commented, all tiles will be rotated every 30000 milliseconds (30s)\nvar tileDelay = [\n  11200,10000,11000,10100,\n  10200,10500,10300,10600,\n  30400,60700,60900,10800\n];\n\n// RSS feed items\n// Structure is [feed URL, refresh interval in minutes]\nvar aRSS = [\n  [\"https://www.amsat.org/feed/\", 60],           // Example RSS feed, refresh every 60 minutes\n  [\"https://daily.hamweekly.com/atom.xml\", 120], // Example Atom feed, refresh every 120 minutes\n  ];\n"
  },
  {
    "path": "config.json",
    "content": "{\n  \"disableSetup\": false,\n  \"disableLdCfg\": false,\n  \"topBarCenterText\": \"VA3HDL - FN04ga - JSON\",\n  \"layout_cols\": 4,\n  \"layout_rows\": 3,\n  \"aURL\": [\n    [\"f3de21\", \"SATS\", \"satellite.js\"],\n    [\"2196F3\", \"CLUBLOG\", \"https://clublog.org/livestream/VA3HDL\", \"1.7\"],\n    [\"2196F3\", \"CONTEST\", \"https://www.contestcalendar.com/fivewkcal.html\", \"1\"],\n    [\"2196F3\", \"DX CLUSTER\", \"https://dxcluster.ha8tks.hu/map/\", \"1\"],\n    [\"2196F3\", \"LIGHTNING\", \"https://map.blitzortung.org/#3.87/36.5/-89.41\", \"1\", \"R\"],\n    [\"2196F3\", \"PISTAR\", \"http://pi-star.local/\", \"1.2\"],\n    [\"2196F3\", \"RADAR\", \"dark|https://weather.gc.ca/?layers=alert,radar&center=43.39961001,-78.53212031&zoom=6&alertTableFilterProv=ON\", \"1\", \"R\"],\n    [\"2196F3\", \"TIME.IS\", \"https://time.is/\", \"1\", \"R\"],\n    [\"2196F3\", \"WEATHER\", \"https://openweathermap.org/weathermap?basemap=map&cities=true&layer=temperature&lat=44.0157&lon=-79.4591&zoom=5\", \"1\", \"R\"],\n    [\"2196F3\", \"WINDS\", \"https://earth.nullschool.net/#current/wind/surface/level/orthographic=-78.79,44.09,3000\", \"1\", \"R\"]\n  ],\n  \"aIMG\": [\n    [[\"Radar CONUS\", \"Radar Small\"], [\"https://radar.weather.gov/ridge/standard/CONUS-LARGE_loop.gif\", \"https://radar.weather.gov/ridge/standard/CONUS_loop.gif\"], 60100],\n    [\"LOCAL RADAR (inverted)\", \"invert|https://radar.weather.gov/ridge/standard/KNQA_loop.gif\", 10000],\n    [\"NOAA D-RAP (inverted)\", \"invert|https://s.w-x.co/staticmaps/wu/wxtype/county_loc/bgm/animate.png\", 11000],\n    [\"ISS POSITION\", \"https://www.heavens-above.com/orbitdisplay.aspx?icon=iss&width=600&height=300&mode=M&satid=25544\", 10100],\n    [\"SATELLITE CAN\", \"https://cdn.star.nesdis.noaa.gov/GOES16/GLM/SECTOR/can/EXTENT3/GOES16-CAN-EXTENT3-1125x560.gif\", 10200],\n    [\"SATELLITE CGL\", \"https://cdn.star.nesdis.noaa.gov/GOES16/GLM/SECTOR/cgl/EXTENT3/GOES16-CGL-EXTENT3-600x600.gif\", 10500],\n    [\"LIGHTNING\", \"https://images.lightningmaps.org/blitzortung/america/index.php?animation=usa\", 10300],\n    [\"LIGHTNING LOCAL\", \"https://www.blitzortung.org/en/Images/image_b_ny.png\", 10600],\n    [\"YOUTUBE EXAMPLE\", \"iframe|https://www.youtube.com/embed/fzPFaXAV_2Y?autoplay=1&mute=1\", 30400],\n    [\"WEBSITE EXAMPLE\", \"iframe|https://globe.adsbexchange.com/?airport=YYZ\", 60700],\n    [\"VIDEO EXAMPLE\", \"https://himawari8.nict.go.jp/movie/720/20240611_pifd.mp4\", 60900],\n    [\"HF PROPAGATION\", \"https://www.hamqsl.com/solar101vhf.php\", 10800]\n  ],  \n  \"aRSS\": [\n    [\"https://www.amsat.org/feed/\", 60],\n    [\"https://daily.hamweekly.com/atom.xml\", 120]\n  ]\n}\n"
  },
  {
    "path": "config_jsonp.js",
    "content": "window.hamdashConfig = {\n  \"disableSetup\": false,\n  \"disableLdCfg\": false,\n  \"topBarCenterText\": \"VA3HDL-FN04ga-JsonP\",\n  \"layout_cols\": 4,\n  \"layout_rows\": 3,\n  \"aURL\": [\n    [\"f3de21\", \"SATS\", \"satellite.js\"],\n    [\"2196F3\", \"CLUBLOG\", \"https://clublog.org/livestream/VA3HDL\", \"1.7\"],\n    [\"2196F3\", \"CONTEST\", \"https://www.contestcalendar.com/fivewkcal.html\", \"1\"],\n    [\"2196F3\", \"DX CLUSTER\", \"https://dxcluster.ha8tks.hu/map/\", \"1\"],\n    [\"2196F3\", \"LIGHTNING\", \"https://map.blitzortung.org/#3.87/36.5/-89.41\", \"1\", \"R\"],\n    [\"2196F3\", \"PISTAR\", \"http://pi-star.local/\", \"1.2\"],\n    [\"2196F3\", \"RADAR\", \"dark|https://weather.gc.ca/?layers=alert,radar&center=43.39961001,-78.53212031&zoom=6&alertTableFilterProv=ON\", \"1\", \"R\"],\n    [\"2196F3\", \"TIME.IS\", \"https://time.is/\", \"1\", \"R\"],\n    [\"2196F3\", \"WEATHER\", \"https://openweathermap.org/weathermap?basemap=map&cities=true&layer=temperature&lat=44.0157&lon=-79.4591&zoom=5\", \"1\", \"R\"],\n    [\"2196F3\", \"WINDS\", \"https://earth.nullschool.net/#current/wind/surface/level/orthographic=-78.79,44.09,3000\", \"1\", \"R\"]\n  ],\n  \"aIMG\": [\n    [[\"Radar CONUS\", \"Radar Small\"], [\"https://radar.weather.gov/ridge/standard/CONUS-LARGE_loop.gif\", \"https://radar.weather.gov/ridge/standard/CONUS_loop.gif\"], 60100],\n    [\"LOCAL RADAR (inverted)\", \"invert|https://radar.weather.gov/ridge/standard/KNQA_loop.gif\", 10000],\n    [\"NOAA D-RAP (inverted)\", \"invert|https://s.w-x.co/staticmaps/wu/wxtype/county_loc/bgm/animate.png\", 11000],\n    [\"ISS POSITION\", \"https://www.heavens-above.com/orbitdisplay.aspx?icon=iss&width=600&height=300&mode=M&satid=25544\", 10100],\n    [\"SATELLITE CAN\", \"https://cdn.star.nesdis.noaa.gov/GOES16/GLM/SECTOR/can/EXTENT3/GOES16-CAN-EXTENT3-1125x560.gif\", 10200],\n    [\"SATELLITE CGL\", \"https://cdn.star.nesdis.noaa.gov/GOES16/GLM/SECTOR/cgl/EXTENT3/GOES16-CGL-EXTENT3-600x600.gif\", 10500],\n    [\"LIGHTNING\", \"https://images.lightningmaps.org/blitzortung/america/index.php?animation=usa\", 10300],\n    [\"LIGHTNING LOCAL\", \"https://www.blitzortung.org/en/Images/image_b_ny.png\", 10600],\n    [\"YOUTUBE EXAMPLE\", \"iframe|https://www.youtube.com/embed/fzPFaXAV_2Y?autoplay=1&mute=1\", 30400],\n    [\"WEBSITE EXAMPLE\", \"iframe|https://globe.adsbexchange.com/?airport=YYZ\", 60700],\n    [\"VIDEO EXAMPLE\", \"https://himawari8.nict.go.jp/movie/720/20240611_pifd.mp4\", 60900],\n    [\"HF PROPAGATION\", \"https://www.hamqsl.com/solar101vhf.php\", 10800]\n  ],  \n  \"aRSS\": [\n    [\"https://www.amsat.org/feed/\", 60],\n    [\"https://daily.hamweekly.com/atom.xml\", 120]\n  ]\n};\n"
  },
  {
    "path": "hamdash.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n\n<head>\n  <meta charset=\"UTF-8\" />\n  <meta http-equiv=\"Cache-Control\" content=\"no-cache, no-store, must-revalidate\" />\n  <meta http-equiv=\"Pragma\" content=\"no-cache\" />\n  <meta http-equiv=\"Expires\" content=\"0\" />\n  <meta http-equiv=\"Cache\" content=\"no-cache\" />\n  <meta http-equiv=\"Pragma-Control\" content=\"no-cache\" />\n  <meta http-equiv=\"Cache-directive\" content=\"no-cache\" />\n  <meta http-equiv=\"Pragma-directive\" content=\"no-cache\" />\n  <meta http-equiv=\"Cache-Control\" content=\"no-cache\" />\n  <meta http-equiv=\"Pragma-directive: no-cache\" />\n  <meta http-equiv=\"Cache-directive: no-cache\" />\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n  <meta name=\"description\" content=\"VA3HDL Ham Radio Dashboard\" />\n  <link rel=\"icon\" href=\"data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>📡</text></svg>\">\n  <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\" />\n  <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin />\n  <link href=\"https://fonts.googleapis.com/css?family=Victor Mono|Audiowide|Bebas Neue\" rel=\"stylesheet\" />\n  <link href=\"https://fonts.googleapis.com/css2?family=Roboto+Condensed:wght@100..900&display=swap\" rel=\"stylesheet\" />\n  <title>Ham Radio Dashboard</title>\n  <!--\n\tHamdash\n\tLicense: MIT\n  Copyright (c) 2026 Pablo Sabbag, VA3HDL\n\thttps://www.va3hdl.com/projects/hamdash\n\n  Credits:\n  Project inspired by the concept of DAVID A GOLD callsign N2MXX published at https://nject.us/HAMSHACK-DASHBOARD-O.html\n--\n\n.d8888. d888888b db    db db      d88888b .d8888.\n88'  YP `~~88~~' `8b  d8' 88      88'     88'  YP\n`8bo.      88     `8bd8'  88      88ooooo `8bo.\n  `Y8b.    88       88    88      88~~~~~   `Y8b.\ndb   8D    88       88    88booo. 88.     db   8D\n`8888Y'    YP       YP    Y88888P Y88888P `8888Y'\n\n\n-->\n  <style>\n    body,\n    html {\n      background: black;\n      font-size: 100%;\n      max-width: 100%;\n      overflow-x: hidden;\n      height: 100%;\n      margin: 0;\n      justify-content: center;\n      align-items: center;\n    }\n\n    /* Style for the div containing the iframe */\n    .iframe-container {\n      background-color: black;\n      border: 0px none;\n      position: fixed;\n      height: 100%;\n      width: 100%;\n      z-index: -2;\n      justify-content: center;\n      align-items: center;\n    }\n\n    .img-zoom {\n      background-color: black;\n      left: 0px;\n      border: 0px none;\n      height: 100%;\n      width: 100%;\n      position: fixed;\n      overflow: hidden;\n      bottom: 0px;\n      z-index: -2;\n    }\n\n    /* Style for the iframe element */\n    .full-screen {\n      background-color: black;\n      border: 0px none;\n      height: 100%;\n      width: 98%;\n      margin-bottom: 0px;\n      margin-left: 1%;\n      justify-content: center;\n      align-items: center;\n      -ms-zoom: 1;\n      -moz-transform: scale(1);\n      -moz-transform-origin: 0 0;\n      -o-transform: scale(1);\n      -o-transform-origin: 0 0;\n      -webkit-transform: scale(1);\n      -webkit-transform-origin: 0 0;\n    }\n\n    .default-frame {\n      margin-top: 0px;\n      margin-bottom: 0px;\n      margin-left: 0px;\n      left: 0px;\n      border: 0px none;\n      height: 100%;\n      width: 100%;\n      position: fixed;\n      overflow: hidden;\n      bottom: 0px;\n    }\n\n    .top-bar {\n      display: grid;\n      grid-template-columns: 2fr 1fr 2fr;\n      background-color: #333;\n      color: #fff;\n      padding: 1vh;\n      border: 0px none;\n      overflow: hidden;\n      position: relative;\n      width: auto;\n    }\n\n    .child {\n      position: relative;\n      display: grid;\n      border: 1px solid hsl(210deg 8% 50%);\n      border-radius: 5px;\n      background: hsl(210deg 15% 20%);\n      color: white;\n      padding: 0.5vh;\n      font-family: \"Victor Mono\", sans-serif;\n      font-size: 1.4vw;\n    }\n\n    /* Default variables values for grid layout 4 cols x 3 rows */\n    :root {\n      --main-layout: auto auto auto auto;\n      --main-width: 24.9vw;\n      --main-height: 31vh;\n    }\n\n    /* Style for the dashboard container */\n    .dashboard {\n      display: grid;\n      grid-template-columns: var(--main-layout);\n      grid-gap: 0px;\n      border: 0px none;\n      margin-bottom: 0px;\n      overflow: hidden;\n      position: relative;\n      width: 100%;\n    }\n\n    /* Style for the image container */\n    .image-container {\n      position: relative;\n      float: inline-start;\n      margin-right: 0px;\n      border: 0px none;\n      height: var(--main-height);\n      width: var(--main-width);\n      overflow: hidden;\n      display: flex;\n      justify-content: center;\n      /* Horizontal centering */\n      align-items: center;\n      /* Vertical centering */\n      border-radius: 5px;\n      /* Rounded corners */\n    }\n\n    .iframe-tile {\n      position: absolute;\n      /* it must be absolute */\n      float: inline-start;\n      overflow: hidden;\n      border: 0px none;\n      display: flex;\n      height: var(--main-height);\n      width: var(--main-width);\n      margin: 0;\n      border-radius: 5px;\n      /* Rounded corners */\n    }\n\n    /* Style for the image */\n    .image-container img {\n      height: 100%;\n      width: 100%;\n    }\n\n    /* Style for the image titles */\n    .image-title {\n      position: absolute;\n      bottom: 6%;\n      left: 50%;\n      transform: translate(-50%, -50%);\n      color: white;\n      /* font color */\n      background-color: black;\n      font-size: 1vw;\n      border-left: 0.25vw solid black;\n      border-right: 0.25vw solid black;\n      font-family: \"Roboto Condensed\", sans-serif;\n      font-optical-sizing: auto;\n      font-weight: 300;\n      font-style: normal;\n      padding-top: 1px;\n      z-index: 2;\n    }\n\n    .click-overlay {\n      position: absolute;\n      top: 0;\n      left: 0;\n      width: 100%;\n      height: 100%;\n      background-color: transparent;\n      z-index: 1;\n      cursor: pointer;\n    }\n\n    /* Style for the full screen image */\n    .image-large {\n      display: block;\n      position: relative;\n      margin-left: auto;\n      margin-right: auto;\n      max-height: 100%;\n      max-width: 100%;\n      height: 100%;\n      width: auto;\n    }\n\n    .media {\n      height: 100%;\n      width: 100%;\n      cursor: pointer;\n    }\n\n    .hidden {\n      display: none;\n    }\n\n    /* Style for the left menu options */\n    .menuL {\n      display: grid;\n      grid-gap: 3px;\n      position: absolute;\n      height: auto;\n      width: auto;\n      margin-top: 10vh;\n      left: calc(-5.2vw - 0px);\n      z-index: 2;\n      transition: 0.3s;\n    }\n\n    /* Style for the right menu options */\n    .menuR {\n      display: grid;\n      grid-gap: 3px;\n      position: absolute;\n      height: auto;\n      width: 30px;\n      margin-top: 10vh;\n      right: -5px;\n      z-index: 2;\n      transition: 0.3s;\n    }\n\n    #myMenuL:hover {\n      width: 7vw;\n      left: 0px;\n    }\n\n    #myMenuR:hover {\n      width: 7vw;\n      right: 0px;\n    }\n\n    #mySidenavL a {\n      position: relative;\n      float: inline-start;\n      left: calc(-0.2vw - 10px);\n      transition: 0.3s;\n      padding-left: 15px;\n      padding-right: 15px;\n      padding-top: 12px;\n      padding-bottom: 8px;\n      width: 5vw;\n      text-decoration: none;\n      font-family: \"Bebas Neue\", sans-serif;\n      font-size: 1.2vw;\n      font-optical-sizing: auto;\n      font-weight: 300;\n      font-style: normal;\n      text-align: right;\n      color: white;\n      border-radius: 0 5px 5px 0;\n      box-shadow: 4px 4px 12px rgba(0, 0, 0, 0.5);\n    }\n\n    #mySidenavL a:hover {\n      left: 0;\n    }\n\n    #mySidenavR a {\n      position: relative;\n      float: inline-start;\n      right: calc(-0.2vw - 10px);\n      transition: 0.3s;\n      padding-left: 15px;\n      padding-right: 15px;\n      padding-top: 12px;\n      padding-bottom: 8px;\n      width: 7vw;\n      text-decoration: none;\n      font-family: \"Bebas Neue\", sans-serif;\n      font-size: 1.2vw;\n      font-optical-sizing: auto;\n      font-weight: 300;\n      font-style: normal;\n      text-align: left;\n      color: white;\n      border-radius: 5px 0px 0px 5px;\n      box-shadow: 4px 4px 12px rgba(0, 0, 0, 0.5);\n    }\n\n    #mySidenavR a:hover {\n      right: 0;\n      width: 7vw;\n    }\n\n    .menu-link.menu-core::before {\n      content: '★';\n      margin-right: 0px;\n      margin-left: 0px;\n      opacity: 0.8;\n      /*\n        position: absolute;\n        top: 1px;\n        left: 1px;\n        */\n    }\n\n    .menu-link.menu-config::before {\n      content: '⚙️';\n      margin-right: 0px;\n      margin-left: 0px;\n      opacity: 0.8;\n      display: inline-block;\n      transform: scale(0.7);\n      transform-origin: center;\n      /*\n        position: absolute;\n        top: 1px;\n        left: 1px; \n        */\n    }\n\n    .menu-link.menu-user::before {\n      content: '';\n      margin-right: 1px;\n    }\n\n    .overlay {\n      display: none;\n      position: fixed;\n      top: 0;\n      left: 0;\n      height: 100%;\n      width: 100%;\n      background-color: rgba(0, 0, 0, 0.8);\n      color: white;\n      padding: 50px;\n      box-sizing: border-box;\n      z-index: 3;\n      font-family: \"Roboto Condensed\", sans-serif;\n    }\n\n    .overlay-content {\n      background-color: #333;\n      padding: 20px;\n      border-radius: 10px;\n      max-height: 80vh;\n      /* Ensures the overlay content is scrollable if it exceeds the viewport height */\n      overflow-y: auto;\n    }\n\n    .close-btn {\n      cursor: pointer;\n      color: white;\n      float: right;\n      font-size: 20px;\n    }\n\n    /************************/\n    /* Settings page styles */\n    /************************/\n    .settings-Page {\n      display: none;\n      font-family: Arial, sans-serif;\n      margin-top: 37px;\n      background-color: aliceblue;\n      height: 98%;\n      width: 98%;\n      margin-left: 1%;\n      overflow-y: auto;\n    }\n\n    .fixed-section {\n      display: none;\n      text-align: center;\n      position: fixed;\n      top: 0;\n      left: 1%;\n      width: 98%;\n      background-color: cadetblue;\n      padding-top: 7px;\n      box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);\n      z-index: -3;\n    }\n\n    .section {\n      margin-bottom: 20px;\n      display: flex;\n      flex-wrap: wrap;\n    }\n\n    label {\n      margin: 2px;\n      font-weight: bold;\n    }\n\n    input[type=\"text\"] {\n      width: 90%;\n      padding: 1px;\n      margin-right: 5px;\n      margin-bottom: 3px;\n    }\n\n    input[type=\"number\"] {\n      width: 20%;\n      padding: 1px;\n      margin-bottom: 10px;\n    }\n\n    table {\n      width: 100%;\n      border-collapse: collapse;\n      margin-bottom: 10px;\n    }\n\n    table th,\n    table td {\n      border: 1px solid #ddd;\n      padding: 8px;\n      align-content: start;\n    }\n\n    table th {\n      background-color: #f4f4f4;\n    }\n\n    button {\n      padding: 5px 5px;\n      margin: 0 0 7px 0;\n      cursor: pointer;\n      background-image: linear-gradient(#f7f8fa, #e7e9ec);\n      border-color: #adb1b8 #a2a6ac #8d9096;\n      border-style: solid;\n      border-width: 1px;\n      border-radius: 3px;\n      box-shadow: rgba(255, 255, 255, 0.6) 0 1px 0 inset;\n      box-sizing: border-box;\n      color: #0f1111;\n      cursor: pointer;\n      font-family: \"Amazon Ember\", Arial, sans-serif;\n      font-size: 14px;\n      height: 23px;\n      font-size: 13px;\n      outline: 0;\n      overflow: hidden;\n      padding: 0 11px;\n      text-align: center;\n      text-decoration: none;\n      text-overflow: ellipsis;\n      user-select: none;\n      -webkit-user-select: none;\n      touch-action: manipulation;\n      white-space: nowrap;\n    }\n\n    button:active {\n      border-bottom-color: #a2a6ac;\n    }\n\n    button:active:hover {\n      border-bottom-color: #a2a6ac;\n    }\n\n    button:hover {\n      border-color: #a2a6ac #979aa1 #82858a;\n    }\n\n    button:focus {\n      border-color: #e77600;\n      box-shadow: rgba(228, 121, 17, 0.5) 0 0 3px 2px;\n      outline: 0;\n    }\n\n    .radio-group {\n      display: flex;\n      align-items: center;\n    }\n\n    .radio-group label {\n      margin-right: 15px;\n      padding-top: 5px;\n      font-weight: normal;\n    }\n\n    /************************/\n    /* Settings RSS styles  */\n    /************************/\n    .rss-ticker {\n      position: fixed;\n      bottom: 0;\n      width: 100%;\n      background-color: rgba(0, 0, 0, 0.5);\n      overflow: hidden;\n      white-space: nowrap;\n      box-sizing: border-box;\n      padding: 3px 0;\n      font-family: \"Victor Mono\", sans-serif;\n      font-size: 1.4vh;\n      font-weight: bold;\n    }\n\n    .rss-ticker-content {\n      display: inline-block;\n      padding-left: 100%;\n      animation: ticker var(--ticker-duration, 90s) linear infinite;\n      animation-play-state: running;\n      /* Default state is running */\n    }\n\n    .rss-ticker-content a {\n      text-decoration: none;\n      color: #e77600;\n    }\n\n    @keyframes ticker {\n      0% {\n        transform: translateX(0%);\n      }\n\n      100% {\n        transform: translateX(-100%);\n      }\n    }\n  </style>\n\n  <!--\n.d8888.  .o88b. d8888b. d888888b d8888b. d888888b .d8888.\n88'  YP d8P  Y8 88  `8D   `88'   88  `8D `~~88~~' 88'  YP\n`8bo.   8P      88oobY'    88    88oodD'    88    `8bo.\n  `Y8b. 8b      88`8b      88    88~~~      88      `Y8b.\ndb   8D Y8b  d8 88 `88.   .88.   88         88    db   8D\n`8888Y'  `Y88P' 88   YD Y888888P 88         YP    `8888Y'\n-->\n  <script src=\"wheelzoom.js\"></script>\n  <script>\n    // Track critical page events\n    window.addEventListener('DOMContentLoaded', () => {\n      console.log('►►► DOMContentLoaded fired');\n    });\n\n    window.addEventListener('load', () => {\n      console.log('►►► Page fully loaded');\n    });\n\n    // Track script execution\n    console.log('►►► Script loaded and running');\n\n    //   .d8888. d88888b d888888b d888888b d888888b d8b   db  d888b  .d8888.      .d8888. d88888b  .o88b. d888888b d888888b  .d88b.  d8b   db\n    //   88'  YP 88'     `~~88~~' `~~88~~'   `88'   888o  88 88' Y8b 88'  YP      88'  YP 88'     d8P  Y8 `~~88~~'   `88'   .8P  Y8. 888o  88\n    //   `8bo.   88ooooo    88       88       88    88V8o 88 88      `8bo.        `8bo.   88ooooo 8P         88       88    88    88 88V8o 88\n    //     `Y8b. 88~~~~~    88       88       88    88 V8o88 88  ooo   `Y8b.        `Y8b. 88~~~~~ 8b         88       88    88    88 88 V8o88\n    //   db   8D 88.        88       88      .88.   88  V888 88. ~8~ db   8D      db   8D 88.     Y8b  d8    88      .88.   `8b  d8' 88  V888\n    //   `8888Y' Y88888P    YP       YP    Y888888P VP   V8P  Y888P  `8888Y'      `8888Y' Y88888P  `Y88P'    YP    Y888888P  `Y88P'  VP   V8P\n\n    function getColumnHeaderTitle(tableId, columnNumber) {\n      const table = document.getElementById(tableId);\n      if (!table) {\n        console.error(`Table with id ${tableId} not found.`);\n        return null;\n      }\n\n      const headers = table.querySelectorAll(\"thead th\");\n      if (columnNumber < 0 || columnNumber >= headers.length) {\n        console.error(`Invalid column number ${columnNumber}.`);\n        return null;\n      }\n\n      return headers[columnNumber].textContent;\n    }\n\n    document.addEventListener(\"DOMContentLoaded\", () => {\n      // Default Values\n      const defaults = {\n        settingsSource: \"localStorage\",\n        topBarCenterText: \"CALLSIGN - Locator\",\n        layout_cols: 4,\n        layout_rows: 3,\n        aURL: [[\"2196F3\", \"Photos\", \"https://picsum.photos/\", 1, \"L\"]],\n        aImages: [\n          [\"Tile 1\", [\"https://picsum.photos/seed/picsum/200/300\"], 30000],\n          [\"Tile 2\", [\"https://picsum.photos/seed/picsum/200/300\"], 30000],\n          [\"Tile 3\", [\"https://picsum.photos/seed/picsum/200/300\"], 30000],\n          [\"Tile 4\", [\"https://picsum.photos/seed/picsum/200/300\"], 30000],\n          [\"Tile 5\", [\"https://picsum.photos/seed/picsum/200/300\"], 30000],\n          [\"Tile 6\", [\"https://picsum.photos/seed/picsum/200/300\"], 30000],\n          [\"Tile 7\", [\"https://picsum.photos/seed/picsum/200/300\"], 30000],\n          [\"Tile 8\", [\"https://picsum.photos/seed/picsum/200/300\"], 30000],\n          [\"Tile 9\", [\"https://picsum.photos/seed/picsum/200/300\"], 30000],\n          [\"Tile 10\", [\"https://picsum.photos/seed/picsum/200/300\"], 30000],\n          [\"Tile 11\", [\"https://picsum.photos/seed/picsum/200/300\"], 30000],\n          [\"Tile 12\", [\"https://picsum.photos/seed/picsum/200/300\"], 30000],\n        ],\n        aRSS: [\n          [\"https://www.amsat.org/feed/\", 60],           // Example RSS feed\n          [\"https://daily.hamweekly.com/atom.xml\", 60], // Example Atom feed\n        ],\n      };\n\n      // Load from LocalStorage or Defaults\n      settings = JSON.parse(localStorage.getItem(\"hamdash_config\")) || {\n        ...defaults,\n      };\n\n      if (settings.settingsSource) {\n        document.querySelector(\n          `input[name=\"settingsSource\"][value=\"${settings.settingsSource}\"]`\n        ).checked = true;\n      }\n\n      // Utility to Update Dashboard Items to Match Layout\n      adjustDashboardItems = () => {\n        const totalItems = settings.layout_cols * settings.layout_rows;\n        const currentItems = settings.aImages.length;\n\n        if (currentItems < totalItems) {\n          // Add new placeholders if there are fewer items than needed\n          for (let i = currentItems; i < totalItems; i++) {\n            settings.aImages.push([\"\", [\"\"], 5000]); // Default title, image array, and rotation interval\n          }\n        } else if (currentItems > totalItems) {\n          // Remove excess items\n          settings.aImages.splice(totalItems);\n        }\n\n        updateTable(\"dashboardTable\", settings.aImages, [\n          \"Tile Title\",\n          \"Tile URLs\",\n          \"URL Rotation Interval (ms)\",\n        ]);\n      };\n\n      updateMenuTable = () => {\n        updateTable(\"menuTable\", settings.aURL, [\n          \"Color\",\n          \"Text\",\n          \"URL\",\n          \"Scale\",\n          \"Side\",\n        ]);\n      };\n\n      updateFeedTable = () => {\n        updateTable(\"feedTable\", settings.aRSS, [\n          \"Feed URL\",\n          \"Refresh Interval (minutes)\",\n        ]);\n      };\n\n      // Utility to Update Tables\n      const updateTable = (tableId, data, columns) => {\n        const tbody = document.querySelector(`#${tableId} tbody`);\n        tbody.innerHTML = \"\";\n        data.forEach((item, index) => {\n          const row = document.createElement(\"tr\");\n          columns.forEach((col, colIndex) => {\n            const cell = document.createElement(\"td\");\n            if (tableId == \"menuTable\" && colIndex == 0) {                        // Color column for Menu options\n              const colorInput = document.createElement(\"input\");\n              colorInput.type = \"color\";\n              colorInput.value = \"#\" + item[colIndex].replace(\"#\", \"\");\n              colorInput.onchange = (e) => (item[colIndex] = e.target.value);\n              cell.appendChild(colorInput);\n            } else {\n              if (Array.isArray(item[colIndex])) {\n                // Handle array of image URLs                  \n                const container = document.createElement(\"div\");\n                item[colIndex].forEach((url, urlIndex) => {\n                  const textarea = document.createElement(\"input\");\n                  textarea.type =\n                    getColumnHeaderTitle(tableId, colIndex) === \"\"\n                      ? \"number\"\n                      : \"text\";\n                  textarea.value = url;\n                  textarea.onchange = (e) =>\n                    (item[colIndex][urlIndex] = e.target.value);\n                  container.appendChild(textarea);\n                  const removeBtn = document.createElement(\"button\");\n                  removeBtn.textContent = \"Remove URL\";\n                  removeBtn.onclick = () => {\n                    item[colIndex].splice(urlIndex, 1);\n                    updateTable(tableId, data, columns);\n                  };\n                  container.appendChild(document.createElement(\"br\"));\n                  container.appendChild(removeBtn);\n                  container.appendChild(document.createElement(\"br\"));\n                });\n                const addBtn = document.createElement(\"button\");\n                addBtn.textContent = \"Add URL\";\n                addBtn.onclick = () => {\n                  item[colIndex].push(\"\");\n                  updateTable(tableId, data, columns);\n                };\n                container.appendChild(addBtn);\n                cell.appendChild(container);\n              } else {\n                const input = document.createElement(\"input\");\n                switch (getColumnHeaderTitle(tableId, colIndex)) {\n                  case \"Scale\":\n                    input.type = \"number\";\n                    break;\n                  case \"URL Rotation Interval (ms)\":\n                    input.type = \"number\";\n                    break;\n                  default:\n                    input.type = \"text\";\n                }\n                input.value = item[colIndex];\n                input.onchange = (e) =>\n                (item[colIndex] =\n                  colIndex === 2 && tableId === \"dashboardTable\"\n                    ? parseInt(e.target.value, 10)\n                    : e.target.value);\n\n                // ADD CONVERT TO ARRAY BUTTON FOR TITLES\n                if (tableId === \"dashboardTable\" && colIndex === 0 && !Array.isArray(item[colIndex])) {\n                  const convertBtn = document.createElement(\"button\");\n                  convertBtn.textContent = \"Convert to Array\";\n                  convertBtn.style.fontSize = \"10px\";\n                  convertBtn.style.height = \"18px\";\n                  convertBtn.onclick = () => {\n                    item[colIndex] = [item[colIndex]];\n                    updateTable(tableId, data, columns);\n                  };\n                  cell.appendChild(document.createElement(\"br\"));\n                  cell.appendChild(convertBtn);\n                }\n\n                cell.appendChild(input);\n              }\n            }\n            row.appendChild(cell);\n          });\n\n          const actionsCell = document.createElement(\"td\");\n          const deleteBtn = document.createElement(\"button\");\n          deleteBtn.textContent = \"Delete\";\n          deleteBtn.onclick = () => {\n            data.splice(index, 1);\n            updateTable(tableId, data, columns);\n            adjustDashboardItems();\n          };\n          actionsCell.appendChild(deleteBtn);\n          row.appendChild(actionsCell);\n\n          tbody.appendChild(row);\n        });\n      };\n\n      // Load Initial Data\n      document.getElementById(\"CenterText\").value = settings.topBarCenterText;\n      document.getElementById(\"layout_cols\").value = settings.layout_cols;\n      document.getElementById(\"layout_rows\").value = settings.layout_rows;\n      updateMenuTable();\n      updateFeedTable();\n      adjustDashboardItems(); // Ensure dashboard items match layout on load\n\n      // Save Configuration\n      document.getElementById(\"saveConfig\").onclick = () => {\n        settings.settingsSource = document.querySelector(\n          'input[name=\"settingsSource\"]:checked'\n        ).value;\n        settings.topBarCenterText =\n          document.getElementById(\"CenterText\").value;\n        settings.layout_cols = parseInt(\n          document.getElementById(\"layout_cols\").value,\n          10\n        );\n        settings.layout_rows = parseInt(\n          document.getElementById(\"layout_rows\").value,\n          10\n        );\n\n        // Update aURL from the table\n        const menuTableRows = document.querySelectorAll(\"#menuTable tbody tr\");\n        settings.aURL = Array.from(menuTableRows).map(row => {\n          const cells = row.querySelectorAll(\"td\");\n          return [\n            cells[0].querySelector(\"input\").value,\n            cells[1].querySelector(\"input\").value,\n            cells[2].querySelector(\"input\").value,\n            parseInt(cells[3].querySelector(\"input\").value, 10),\n            cells[4].querySelector(\"input\").value\n          ];\n        });\n\n        // Update aRSS from the table\n        const feedTableRows = document.querySelectorAll(\"#feedTable tbody tr\");\n        settings.aRSS = Array.from(feedTableRows).map(row => {\n          const cells = row.querySelectorAll(\"td\");\n          return [\n            cells[0].querySelector(\"input\").value,              // Feed URL\n            parseInt(cells[1].querySelector(\"input\").value, 10) // Refresh Interval\n          ];\n        });\n\n        localStorage.setItem(\"hamdash_config\", JSON.stringify(settings));\n        alert(\"Settings saved!\");\n        updateInputs();\n        updateMenuTable();\n        updateFeedTable();\n        adjustDashboardItems();\n      };\n\n      // Reset to Defaults\n      document.getElementById(\"resetConfig\").onclick = () => {\n        localStorage.setItem(\"hamdash_config\", JSON.stringify(defaults));\n        alert(\"Settings reset to defaults!\");\n        settings = defaults;\n        updateInputs();\n        updateMenuTable();\n        updateFeedTable();\n        adjustDashboardItems();\n      };\n\n      // Delete Configuration from local storage\n      document.getElementById(\"deleteConfig\").onclick = () => {\n        window.localStorage.removeItem('hamdash_config');\n        alert(\"Deleted local storage settings!\");\n        // settings = defaults;\n        updateInputs();\n        updateMenuTable();\n        updateFeedTable();\n        adjustDashboardItems();\n      };\n\n      // Backup Configuration\n      document.getElementById(\"backupConfig\").onclick = () => {\n        const dataStr =\n          \"data:text/json;charset=utf-8,\" +\n          encodeURIComponent(JSON.stringify(settings));\n        const downloadAnchorNode = document.createElement(\"a\");\n        downloadAnchorNode.setAttribute(\"href\", dataStr);\n        downloadAnchorNode.setAttribute(\n          \"download\",\n          \"hamdash_config_backup.json\"\n        );\n        document.body.appendChild(downloadAnchorNode);\n        downloadAnchorNode.click();\n        downloadAnchorNode.remove();\n      };\n\n      // Restore Configuration\n      document.getElementById(\"restoreConfig\").onclick = () => {\n        const input = document.createElement(\"input\");\n        input.type = \"file\";\n        input.accept = \"application/json\";\n        input.onchange = (event) => {\n          const file = event.target.files[0];\n          const reader = new FileReader();\n          reader.onload = (e) => {\n            settings = JSON.parse(e.target.result);\n            alert(\"\\nSettings restored from backup!\\n\\nRemember to Save Settings to Local Storage, Backup, or Export\\n\\nif you want to make changes permanent.\");\n            updateInputs();\n            updateMenuTable();\n            updateFeedTable();\n            adjustDashboardItems();\n          };\n          reader.readAsText(file);\n        };\n        input.click();\n      };\n\n      // Import config.js\n      document.getElementById(\"importConfig\").onclick = () => {\n        const input = document.createElement(\"input\");\n        input.type = \"file\";\n        input.accept = \".js\";\n        input.onchange = (event) => {\n          const file = event.target.files[0];\n          const reader = new FileReader();\n          reader.onload = (e) => {\n            // Wrap the content in an IIFE to avoid polluting the global scope\n            const configScript = `(function() {\n                ${e.target.result}\n                return {\n                  topBarCenterText,\n                  layout_cols,\n                  layout_rows,\n                  aURL,\n                  aIMG,\n                  aRSS,\n                  tileDelay\n                };\n              })()`;\n\n            // Evaluate the IIFE and get the variables\n            const config = eval(configScript);\n\n            // Filter out sub-arrays from aURL containing \"BACK\" or \"Refresh\"\n            const filteredAURL = config.aURL.filter(\n              (item) =>\n                !item.some(\n                  (subItem) =>\n                    typeof subItem === \"string\" &&\n                    (subItem.includes(\"BACK\") ||\n                      subItem.includes(\"Back\") ||\n                      subItem.includes(\"Refresh\") ||\n                      subItem.includes(\"Sources\") ||\n                      subItem.includes(\"Update\") ||\n                      subItem.includes(\"Help\"))\n                )\n            );\n\n            // Create a JSON structure from the variables\n            const configJSON = {\n              topBarCenterText: config.topBarCenterText,\n              layout_cols: config.layout_cols,\n              layout_rows: config.layout_rows,\n              aURL: filteredAURL,\n              aImages: config.aIMG.map((item, index) => {\n                // Arrange all items from second to last in a sub-array\n                const [first, ...rest] = item;\n                return [first, rest, config.tileDelay[index]];\n              }),\n              aRSS: config.aRSS,\n              settingsSource: \"localStorage\",\n            };\n            settings = configJSON;\n            alert(\"\\nSettings imported from config.js!\\n\\nRemember to Save Settings to Local Storage, Backup, or Export\\n\\nif you want to make changes permanent.\");\n            updateInputs();\n            updateMenuTable();\n            updateFeedTable();\n            adjustDashboardItems();\n          };\n          reader.readAsText(file);\n        };\n        input.click();\n      };\n\n      document.getElementById(\"exportConfig\").onclick = () => {\n        const configJSContent = `// CUT START\nvar disableSetup = false; // Manually set to true to disable setup page menu option\nvar topBarCenterText = \"${settings.topBarCenterText}\";\n\n// Grid layout desired\nvar layout_cols = ${settings.layout_cols};\nvar layout_rows = ${settings.layout_rows};\n\n// Menu items\n// Structure is as follows: HTML Color code, Option, target URL, scaling 1=Original Size, side (optional, nothing is Left, \"R\" is Right)\n// The values are [color code, menu text, target link, scale factor, side],\n// add new lines following the structure for extra menu options. The comma at the end is important!\nvar aURL = ${JSON.stringify(settings.aURL, null, 2)};\n\n// Feed items\n// Structure is as follows: target URL\n// The values are [target link]\nvar aRSS = ${JSON.stringify(settings.aRSS, null, 2)};\n\n// Dashboard Tiles items\n// Tile Structure is Title, Source URL\n// To display a website on the tiles use \"iframe|\" keyword before the tile URL\n// [Title, Source URL],\n// the comma at the end is important!\nvar aIMG = ${JSON.stringify(settings.aImages.map(item => [item[0], ...item[1].flat()]), null, 2)};\n\n// Image rotation intervals in milliseconds per tile - If the line below is commented, tiles will be rotated every 5000 milliseconds (5s)\nvar tileDelay = ${JSON.stringify(settings.aImages.map(item => item[2]), null, 2)};\n\n// CUT END`;\n\n        const dataStr = \"data:text/javascript;charset=utf-8,\" + encodeURIComponent(configJSContent);\n        const downloadAnchorNode = document.createElement(\"a\");\n        downloadAnchorNode.setAttribute(\"href\", dataStr);\n        downloadAnchorNode.setAttribute(\"download\", \"config.js\");\n        document.body.appendChild(downloadAnchorNode);\n        downloadAnchorNode.click();\n        downloadAnchorNode.remove();\n      };\n\n      // Add New Menu Item\n      document.getElementById(\"addMenuItem\").onclick = () => {\n        settings.aURL.push([\"\", \"\", \"\", \"\", \"\"]);\n        updateMenuTable();\n      };\n\n      // Add New Feed Item\n      document.getElementById(\"addFeedItem\").onclick = () => {\n        settings.aRSS.push([\"\"]);\n        updateFeedTable();\n      };\n\n      // Update Dashboard Items When Layout Changes\n      document\n        .getElementById(\"layout_cols\")\n        .addEventListener(\"change\", () => {\n          settings.layout_cols = parseInt(\n            document.getElementById(\"layout_cols\").value,\n            10\n          );\n          adjustDashboardItems();\n        });\n\n      document\n        .getElementById(\"layout_rows\")\n        .addEventListener(\"change\", () => {\n          settings.layout_rows = parseInt(\n            document.getElementById(\"layout_rows\").value,\n            10\n          );\n          adjustDashboardItems();\n        });\n\n      function updateInputs() {\n        if (settings.settingsSource) {\n          document.querySelector(\n            `input[name=\"settingsSource\"][value=\"${settings.settingsSource}\"]`\n          ).checked = true;\n        }\n        document.getElementById(\"CenterText\").value = settings.topBarCenterText;\n        document.getElementById(\"layout_cols\").value = settings.layout_cols;\n        document.getElementById(\"layout_rows\").value = settings.layout_rows;\n      }\n\n      // Function to update the variable and the display text\n      function updateValue() {\n        topBarCenterText = document.getElementById('CenterText').value;\n        layout_cols = document.getElementById('layout_cols').value;\n        layout_rows = document.getElementById('layout_rows').value;\n        document.getElementById('topBarCenter').textContent = topBarCenterText;\n        document.getElementById('layout_cols').textContent = layout_cols;\n        document.getElementById('layout_rows').textContent = layout_rows;\n      }\n      // Add an onblur event listener to the input field\n      document.getElementById('CenterText').onblur = updateValue;\n      document.getElementById('layout_cols').onblur = updateValue;\n      document.getElementById('layout_rows').onblur = updateValue;\n\n    }); // End of DOMContentLoaded here\n    // End Settings Section\n\n    // db    db d888888b d888888b db      d888888b d888888b db    db      .d8888. d88888b  .o88b. d888888b d888888b  .d88b.  d8b   db\n    // 88    88 `~~88~~'   `88'   88        `88'   `~~88~~' `8b  d8'      88'  YP 88'     d8P  Y8 `~~88~~'   `88'   .8P  Y8. 888o  88\n    // 88    88    88       88    88         88       88     `8bd8'       `8bo.   88ooooo 8P         88       88    88    88 88V8o 88\n    // 88    88    88       88    88         88       88       88           `Y8b. 88~~~~~ 8b         88       88    88    88 88 V8o88\n    // 88b  d88    88      .88.   88booo.   .88.      88       88         db   8D 88.     Y8b  d8    88      .88.   `8b  d8' 88  V888\n    // ~Y8888P'    YP    Y888888P Y88888P Y888888P    YP       YP         `8888Y' Y88888P  `Y88P'    YP    Y888888P  `Y88P'  VP   V8P\n\n    function minimalConfiguration() {\n      // Default settings\n      window.disableSetup = false;\n      window.curSettingsSrc = \"None\";\n      window.topBarCenterText = \"Use 'Setup' to configure your Ham Radio Dashboard\";\n      window.layout_cols = 0;\n      window.layout_rows = 0;\n      window.aURL = [];\n      window.aIMG = [];\n      window.aRSS = [];\n      window.tileDelay = [];\n      start();\n    }\n\n    // Helper to replace date placeholders\n    function replaceDatePlaceholders(obj) {\n      const now = new Date();\n      const YYYYMMDD = now.toISOString().slice(0, 10).replace(/-/g, '');\n      const DATE_ISO = now.toISOString().slice(0, 10);\n\n      if (typeof obj === 'string') {\n        return obj.replace(/{{YYYYMMDD}}/g, YYYYMMDD).replace(/{{DATE_ISO}}/g, DATE_ISO);\n      } else if (Array.isArray(obj)) {\n        return obj.map(replaceDatePlaceholders);\n      } else if (typeof obj === 'object' && obj !== null) {\n        Object.keys(obj).forEach(key => {\n          obj[key] = replaceDatePlaceholders(obj[key]);\n        });\n        return obj;\n      }\n      return obj;\n    }\n\n    function processConfig(settings) {\n      // Handle dynamic placeholders\n      settings = replaceDatePlaceholders(settings);\n\n      // Copy settings to window variables\n      window.settingsSource = settings.settingsSource || \"file\";\n\n      if (settings.disableSetup !== undefined) window.disableSetup = settings.disableSetup;\n      if (settings.topBarCenterText) window.topBarCenterText = settings.topBarCenterText;\n      if (settings.layout_cols) window.layout_cols = settings.layout_cols;\n      if (settings.layout_rows) window.layout_rows = settings.layout_rows;\n      if (settings.aURL) window.aURL = settings.aURL;\n      if (settings.aRSS) window.aRSS = settings.aRSS;\n\n      // Handle aIMG (supports both nested [Title, [Urls], Delay] and flat [Title, Url1, Url2...] formats)\n      if (settings.aIMG) {\n        window.aIMG = [];\n        window.tileDelay = [];\n\n        JSON.parse(JSON.stringify(settings.aIMG)).forEach((subArray) => {\n          // subArray is [Title, [Urls], Delay]\n\n          // Extract delay\n          let delay = 30000;\n          if (subArray.length >= 3) {\n            delay = subArray[2];\n          }\n          window.tileDelay.push(delay);\n\n          // Extract URLs and flatten\n          // The main logic expects aIMG as [Title, Url1, Url2...]\n          let flattened = [subArray[0]]; // Title\n          if (Array.isArray(subArray[1])) {\n            flattened.push(...subArray[1]);\n          } else {\n            flattened.push(subArray[1]);\n          }\n          window.aIMG.push(flattened);\n        });\n      } else if (settings.aImages) {\n        // Fallback for old aImages (internal format)\n        window.aIMG = [];\n        window.tileDelay = [];\n\n        JSON.parse(JSON.stringify(settings.aImages)).forEach((subArray) => {\n          // subArray is [Title, [Urls], Delay]\n\n          // Extract delay\n          let delay = 30000;\n          if (subArray.length >= 3) {\n            delay = subArray[2];\n          }\n          window.tileDelay.push(delay);\n\n          // Extract URLs and flatten\n          let flattened = [subArray[0]]; // Title\n          if (Array.isArray(subArray[1])) {\n            flattened.push(...subArray[1]);\n          } else {\n            flattened.push(subArray[1]);\n          }\n          window.aIMG.push(flattened);\n        });\n      }\n\n      start();\n    }\n\n    // ====================================================================\n    // BREADCRUMB NAVIGATION SYSTEM\n    // ====================================================================\n\n    /**\n     * Parse and validate the breadcrumb parameter from the current URL\n     * @returns {Array<string>} Array of config filenames in the breadcrumb trail\n     */\n    function getCurrentBreadcrumb() {\n      const urlParams = new URLSearchParams(window.location.search);\n      const breadcrumbParam = urlParams.get('breadcrumb');\n\n      if (!breadcrumbParam) return [];\n\n      // Handle both encoded (%2B) and unencoded (+) separators\n      // URLSearchParams automatically decodes %2B to +, so we can split on +\n      const configs = breadcrumbParam.split('+').map(c => c.trim()).filter(c => c);\n\n      // Validate config files (must end with .js or .json)\n      const validated = configs.filter(config => {\n        const valid = config.toLowerCase().endsWith('.js') || config.toLowerCase().endsWith('.json');\n        if (!valid) {\n          console.warn(`Breadcrumb: Skipping invalid config entry: ${config}`);\n        }\n        return valid;\n      });\n\n      return validated;\n    }\n\n    /**\n     * Build a navigation URL with proper breadcrumb tracking\n     * @param {string} targetConfig - The config file to navigate to\n     * @returns {string} Constructed URL with breadcrumb parameter\n     */\n    function buildNavigationUrl(targetConfig) {\n      const urlParams = new URLSearchParams(window.location.search);\n      const currentConfig = urlParams.get('config') || 'config.js';\n\n      // Get current breadcrumb trail\n      let breadcrumb = getCurrentBreadcrumb();\n\n      // Determine if current config is root\n      const isCurrentRoot = (currentConfig === 'config.js' || currentConfig === 'config.json');\n      const isTargetRoot = (targetConfig === 'config.js' || targetConfig === 'config.json');\n\n      // If navigating to root, clear breadcrumb\n      if (isTargetRoot) {\n        return window.location.pathname + '?config=' + encodeURIComponent(targetConfig);\n      }\n\n      // Add current config to breadcrumb if not already root\n      if (!isCurrentRoot) {\n        // Prevent duplicate entries\n        if (!breadcrumb.includes(currentConfig)) {\n          breadcrumb.push(currentConfig);\n        }\n      } else {\n        // If current is root, start fresh breadcrumb from root\n        breadcrumb = [currentConfig];\n      }\n\n      // Limit breadcrumb depth to 10 items\n      if (breadcrumb.length > 10) {\n        breadcrumb = breadcrumb.slice(-10);\n      }\n\n      // Build URL with breadcrumb parameter\n      const breadcrumbStr = breadcrumb.join('+');\n      return window.location.pathname +\n        '?breadcrumb=' + encodeURIComponent(breadcrumbStr) +\n        '&config=' + encodeURIComponent(targetConfig);\n    }\n\n    /**\n     * Build URL for navigating back to previous config in breadcrumb trail\n     * @returns {string} URL for back navigation\n     */\n    function buildPreviousUrl() {\n      const breadcrumb = getCurrentBreadcrumb();\n\n      if (breadcrumb.length === 0) {\n        // No breadcrumb, go to default root\n        return window.location.pathname + '?config=config.js';\n      }\n\n      // Get the last config from breadcrumb (the one to navigate to)\n      const previousConfig = breadcrumb[breadcrumb.length - 1];\n\n      // Remove the last item to create truncated breadcrumb\n      const truncatedBreadcrumb = breadcrumb.slice(0, -1);\n\n      if (truncatedBreadcrumb.length === 0) {\n        // Going back to root, no breadcrumb needed\n        return window.location.pathname + '?config=' + encodeURIComponent(previousConfig);\n      }\n\n      // Build URL with truncated breadcrumb\n      const breadcrumbStr = truncatedBreadcrumb.join('+');\n      return window.location.pathname +\n        '?breadcrumb=' + encodeURIComponent(breadcrumbStr) +\n        '&config=' + encodeURIComponent(previousConfig);\n    }\n\n    // ====================================================================\n    // END BREADCRUMB NAVIGATION SYSTEM\n    // ====================================================================\n\n    function ensureBackMenuItem(settings) {\n      // Get current breadcrumb trail\n      const breadcrumb = getCurrentBreadcrumb();\n\n      // Only add PREVIOUS menu item if we have breadcrumb history\n      if (breadcrumb.length === 0) {\n        // No breadcrumb history, no PREVIOUS menu needed\n        return;\n      }\n\n      // Initialize aURL if not exists\n      if (!settings.aURL) {\n        settings.aURL = [];\n      }\n\n      // Check if PREVIOUS menu item already exists\n      const hasPrevious = settings.aURL.some(item => {\n        if (!Array.isArray(item)) return false;\n        const title = String(item[1] || '').toLowerCase();\n        return title === 'previous' || title === 'prev';\n      });\n\n      if (hasPrevious) {\n        // Already has PREVIOUS menu item, skip\n        return;\n      }\n\n      // Get the previous config filename (last item in breadcrumb)\n      const previousConfig = breadcrumb[breadcrumb.length - 1];\n\n      // Add PREVIOUS menu item with color #212ff3\n      // Store just the config filename, MenuOpt() will handle the navigation\n      console.log(`Adding PREVIOUS menu item for breadcrumb navigation (back to: ${previousConfig})`);\n      settings.aURL.unshift([\"212ff3\", \"PREVIOUS\", previousConfig, \"1\", \"R\"]);\n    }\n\n    function loadScriptConfig(url, fallback) {\n      const script = document.createElement(\"script\");\n      script.src = url;\n      script.onload = async () => {\n        console.log(`${url} loaded successfully (script)`);\n\n        // CHECK FOR NEW JSONP-STYLE CONFIG\n        if (window.hamdashConfig) {\n          console.log(\"Found window.hamdashConfig (JSONP)\");\n          window.curSettingsSrc = `${url} (Data Object)`;\n          const settings = window.hamdashConfig;\n\n          // Ensure navigation\n          ensureBackMenuItem(settings);\n\n          processConfig(settings);\n          // Clear it so it doesn't pollute subsequent loads\n          window.hamdashConfig = undefined;\n          return;\n        }\n\n        // wait for config to finish any async work (Legacy JS logic support)\n        if (window.configReady && typeof window.configReady.then === \"function\") {\n          try { await window.configReady; } catch (e) { console.warn(\"configReady rejected:\", e); }\n        }\n\n        // Legacy: config.js likely set window variables directly\n        window.curSettingsSrc = `${url} (Legacy JS)`;\n\n        // We still want to ensure back menu item even for legacy JS files if possible\n        // But for legacy, we have to inspect the global window.aURL\n        if (window.aURL) {\n          // Re-wrap in a temporary object to use our helper\n          const tempSettings = { aURL: window.aURL };\n          ensureBackMenuItem(tempSettings);\n          window.aURL = tempSettings.aURL;\n        }\n\n        start();\n      };\n      script.onerror = (error) => {\n        console.error(`Failed to load ${url}:`, error);\n        if (fallback) {\n          console.log(\"Attempting fallback...\");\n          fallback();\n        } else {\n          minimalConfiguration();\n        }\n      };\n      document.head.appendChild(script);\n    }\n\n    async function loadJsonConfig(url, fallback) {\n      const isFileProtocol = window.location.protocol === \"file:\";\n      if (isFileProtocol) {\n        console.warn(`Loading JSON config ${url} via file:// protocol might fail due to CORS.`);\n      }\n\n      try {\n        const response = await fetch(url + \"?_=\" + Date.now());\n        if (!response.ok) {\n          throw new Error(`Status ${response.status}`);\n        }\n        const settings = await response.json();\n        console.log(`${url} loaded successfully`);\n        window.curSettingsSrc = `${url} (JSON)`;\n\n        ensureBackMenuItem(settings);\n        processConfig(settings);\n      } catch (e) {\n        console.error(`Failed to load ${url}:`, e);\n        if (fallback) {\n          console.log(\"Attempting fallback from JSON load...\");\n          fallback(e);\n        } else {\n          minimalConfiguration();\n        }\n      }\n    }\n\n    async function loadConfig() {\n      const urlParams = new URLSearchParams(window.location.search);\n      let configParam = urlParams.get(\"config\");\n\n      // Smart config parameter cleaning: handle double-encoded URLs\n      if (configParam) {\n        // Check for double encoding (e.g., %253D instead of %3D)\n        if (configParam.includes('%25')) {\n          console.warn('Detected double-encoded URL, attempting to clean...');\n          try {\n            configParam = decodeURIComponent(configParam);\n          } catch (e) {\n            console.error('Failed to decode config parameter:', e);\n          }\n        }\n\n        // Extract just the filename if it contains URL parameters or encoding issues\n        // This handles cases like \"config.js?breadcrumb=...\" being passed as the config param\n        if (configParam.includes('?')) {\n          const parts = configParam.split('?');\n          configParam = parts[0];\n          console.warn(`Config parameter contained URL params, extracted: ${configParam}`);\n        }\n      }\n\n      // Case 1: user specified a file\n      if (configParam) {\n        const isJson = configParam.toLowerCase().endsWith(\".json\");\n        if (isJson) {\n          loadJsonConfig(configParam, () => minimalConfiguration());\n        } else {\n          loadScriptConfig(configParam, () => minimalConfiguration());\n        }\n        return;\n      }\n\n      // Case 2: Default loading chain\n      // Try config.js -> config.json -> Minimal\n      console.log(\"No config specified, attempting default chain: config.js -> config.json\");\n\n      loadScriptConfig(\"config.js\", () => {\n        console.log(\"config.js failed, falling back to config.json\");\n        loadJsonConfig(\"config.json\", () => {\n          console.log(\"config.json failed, falling back to minimal\");\n          minimalConfiguration();\n        });\n      });\n    }\n\n    // Open OS file picker and reload page with the selected filename as ?config=<filename>\n    function openConfigFileDialog() {\n      let input = document.getElementById('configFileInput');\n      if (!input) {\n        input = document.createElement('input');\n        input.type = 'file';\n        input.accept = '.js,text/javascript,.json,application/json';\n        input.id = 'configFileInput';\n        input.style.display = 'none';\n        input.addEventListener('change', (ev) => {\n          const file = ev.target.files && ev.target.files[0];\n          if (!file) return;\n          const filename = file.name;\n          // Use buildNavigationUrl to maintain breadcrumb chain\n          const newUrl = buildNavigationUrl(filename);\n          window.location.href = newUrl;\n        });\n        document.body.appendChild(input);\n      }\n      input.click();\n    }\n\n    async function main() {\n      try {\n        // Check if settings exist in localStorage\n        const settings = localStorage.getItem('hamdash_config');\n        if (settings) {\n          // console.log('Settings found in localStorage:', settings);\n          console.log('Settings found in localStorage');\n          // Parse the settings JSON string\n          const parsedSettings = JSON.parse(settings);\n          window.settingsSource = parsedSettings.settingsSource;\n\n          if (settingsSource === 'localStorage') {\n            console.log('Loading settings from localStorage');\n            window.curSettingsSrc = \"Browser Local Storage\";\n            processConfig(parsedSettings);\n          } else {\n            console.log('Settings found in localStorage but loading from file');\n            loadConfig();\n          }\n        } else {\n          console.log('No settings found in localStorage');\n          loadConfig();\n        }\n      } catch (error) {\n        console.error('Failed to load configuration:', error);\n        loadConfig();\n      }\n    }\n\n    var help = \"Double click on an image to expand to full screen.\\n\";\n    help += \"Double click again to close full screen view.\\n\";\n    help += \"Right click on an image to display the next one.\\n\";\n    help += \"Images rotates every 30 seconds automatically by default.\\n\";\n\n    const currentVersion = \"v2026.01.30\";\n\n    async function getLatestVersion() {\n      try {\n        const response = await fetch(\n          \"https://api.github.com/repos/VA3HDL/hamdashboard/releases/latest\"\n        );\n        const data = await response.json();\n        return data.tag_name;\n      } catch (error) {\n        console.error(\"Error fetching the latest version:\", error);\n        return currentVersion; // Fallback to the current version if there's an error\n      }\n    }\n\n    function isNewVersionAvailable(currentVersion, latestVersion) {\n      return currentVersion !== latestVersion;\n    }\n\n    bUpdate = false;\n    async function checkForUpdates() {\n      const latestVersion = await getLatestVersion();\n      if (isNewVersionAvailable(currentVersion, latestVersion)) {\n        bUpdate = true;\n      }\n    }\n\n    const videoExtensions = [\".mp4\", \".webm\", \".ogg\", \".ogv\"];\n\n    function isVideo(src) {\n      return videoExtensions.some((ext) => src.includes(ext));\n    }\n\n    function getVideoType(src) {\n      if (src.includes(\".mp4\")) return \"video/mp4\";\n      if (src.includes(\".webm\")) return \"video/webm\";\n      if (src.includes(\".ogg\") || src.includes(\".ogv\")) return \"video/ogg\";\n      return \"\";\n    }\n\n    function isFrame(src) {\n      return src.includes(\"iframe|\") || src.includes(\"iframedark|\");\n    }\n\n    function isDarkFrame(src) {\n      return src.includes(\"iframedark|\");\n    }\n\n    function isDark(src) {\n      return src.includes(\"dark|\");\n    }\n\n    function isInvert(src) {\n      return src.includes(\"invert|\");\n    }\n\n    function oldformatArray(arr) {\n      return arr.join(\"<br>\");\n    }\n\n    function formatArray(arr) {\n      return arr\n        .map((innerArray) =>\n          innerArray\n            .map((item) => (typeof item === \"string\" ? `\"${item}\"` : item))\n            .join(\", \")\n        )\n        .join(\"<br>\");\n    }\n\n    function setRot() {\n      if (typeof tileDelay === \"undefined\") {\n        // If no individual tile rotation is defined then default is 30s or 30000ms\n        aInt[0] = setInterval(() => slide(), 30000);\n      } else {\n        tileDelay.forEach(function (tile, i) {\n          if (tile > 0) {\n            aInt[i] = setInterval(() => slide(i), tile);\n          }\n        });\n      }\n    }\n\n    function rotStop() {\n      if (typeof tileDelay === \"undefined\") {\n        clearTimeout(aInt[0]);\n      } else {\n        tileDelay.forEach(function (tile, i) {\n          clearTimeout(aInt[i]);\n        });\n      }\n    }\n\n    // This function shows the embedded websites\n    function MenuOpt(num) {\n      window.stop();\n      rotStop();\n\n      // If the menu title or URL is a config filename (e.g. \"satellite.js\" or \"traffic.json\"), reload with breadcrumb tracking\n      const title = String(aURL[num][1] || \"\");\n      const link = String(aURL[num][2] || \"\");\n      const menuText = title;\n\n      // Special handling for PREVIOUS button - use buildPreviousUrl() to truncate breadcrumb\n      if (menuText.toUpperCase() === \"PREVIOUS\" || menuText.toUpperCase() === \"PREV\") {\n        const previousUrl = buildPreviousUrl();\n        window.location.href = previousUrl;\n        return;\n      }\n\n      // Check if this is a config file navigation (.js or .json)\n      const isConfigFile = title.toLowerCase().endsWith(\".js\") ||\n        title.toLowerCase().endsWith(\".json\") ||\n        link.toLowerCase().endsWith(\".js\") ||\n        link.toLowerCase().endsWith(\".json\");\n\n      if (isConfigFile) {\n        // Prefer the explicit URL if it contains the filename, otherwise use the title\n        const filename = (link.toLowerCase().endsWith(\".js\") || link.toLowerCase().endsWith(\".json\")) ? link : title;\n\n        // Use buildNavigationUrl for breadcrumb-aware navigation\n        const newUrl = buildNavigationUrl(filename);\n        window.location.href = newUrl;\n        return;\n      }\n\n\n      if (menuText.toLowerCase() == \"refresh\") {\n        location.reload();\n        setRot();\n      } else if (menuText.toLowerCase() == \"load cfg\") {\n        // open file picker and reload page with ?config=<filename>\n        openConfigFileDialog();\n        return;\n      } else if (menuText.toLowerCase() == \"help\") {\n        alert(help);\n      } else if (menuText.toLowerCase() == \"setup\") {\n        // Configure visualization\n        document.getElementById(\"FullScreen\").style.display = \"none\";\n        document.getElementById(\"fixedSection\").style.display = \"block\";\n        document.getElementById(\"settingsPage\").style.display = \"block\";\n        document.getElementById(\"iFrameContainer\").style.zIndex = 1;\n        document.getElementById(\"iFrameContainer\").style.backgroundColor = \"black\";\n        if (curSettingsSrc === \"local\") {\n          document.querySelector(`input[name=\"settingsSource\"][value=\"localStorage\"]`).checked = true;\n        } else if (curSettingsSrc.includes(\"config.js\")) {\n          document.querySelector(`input[name=\"settingsSource\"][value=\"file\"]`).checked = true;\n        }\n        window.settings.topBarCenterText = topBarCenterText;\n        window.settings.layout_cols = window.layout_cols;\n        window.settings.layout_rows = window.layout_rows;\n        document.getElementById(\"CenterText\").value = window.settings.topBarCenterText;\n        document.getElementById(\"layout_cols\").value = window.settings.layout_cols;\n        document.getElementById(\"layout_rows\").value = window.settings.layout_rows;\n        filteredAURL = aURL.filter(\n          (item) =>\n            !item.some(\n              (subItem) =>\n                typeof subItem === \"string\" &&\n                (subItem.includes(\"BACK\") ||\n                  subItem.includes(\"Back\") ||\n                  subItem.includes(\"Refresh\") ||\n                  subItem.includes(\"Setup\") ||\n                  subItem.includes(\"Sources\") ||\n                  subItem.includes(\"Update\") ||\n                  subItem.includes(\"Help\"))\n            )\n        );\n        window.settings.aURL = filteredAURL;\n        window.settings.aImages = aIMG.map((item, index) => {\n          const [first, ...rest] = item;\n          return [first, rest, tileDelay[index]];\n        });\n        window.settings.aRSS = aRSS;\n        // Update the visualization on the Setup page\n        updateMenuTable();\n        updateFeedTable();\n        adjustDashboardItems();\n      } else if (menuText.toLowerCase() == \"sources\") {\n        document.getElementById(\"array1\").innerHTML =\n          \"<br>\" + formatArray(aURL) + \"<br><br>\";\n        document.getElementById(\"array2\").innerHTML =\n          \"<br>\" + formatArray(aIMG) + \"<br><br>\";\n        document.getElementById(\"array3\").innerHTML =\n          \"<br>\" + formatArray(aRSS) + \"<br><br>\";\n        document.getElementById(\"array4\").innerHTML =\n          `<br>Copyright (c) 2026 Pablo Sabbag, VA3HDL | Open Source License: MIT<br>\n            <br>Dashboard codebase version: ${currentVersion}<br><br>`;\n        document.getElementById(\"overlay\").style.display = \"block\";\n      } else if (menuText.toLowerCase() == \"update\") {\n        window\n          .open(\"https://github.com/VA3HDL/hamdashboard/releases/\", \"_blank\")\n          .focus();\n      } else if (menuText.toLowerCase() == \"back\") {\n        document.getElementById(\"FullScreen\").src = \"about:blank\";\n        document.getElementById(\"iFrameContainer\").style.zIndex = -2;\n        document.getElementById(\"iFrameContainer\").style.backgroundColor = \"black\";\n        document.getElementById(\"FullScreen\").style.display = \"none\";\n        document.getElementById(\"settingsPage\").style.display = \"none\";\n        setRot();\n      } else {\n        document.getElementById(\"iFrameContainer\").style.zIndex = 1;\n        document.getElementById(\"FullScreen\").style.display = \"block\";\n        var src = aURL[num][2];\n        if (isDark(src)) {\n          document.getElementById(\"FullScreen\").style.filter = \"invert(1) hue-rotate(180deg)\";\n          src = src.replace(\"dark|\", \"\");\n        } else {\n          document.getElementById(\"FullScreen\").style.filter = \"none\";\n        }\n        document.getElementById(\"FullScreen\").src = src;\n        document.getElementById(\"FullScreen\").style.transform = \"scale(\" + aURL[num][3] + \")\";\n      }\n    }\n\n    function hideOverlay() {\n      document.getElementById(\"overlay\").style.display = \"none\";\n    }\n\n    // This function shows the larger images when double click to enlarge\n    function larger(event) {\n      var targetElement = event.target || event.srcElement;\n\n      if (largeShow == 1) {\n        // Start refreshes\n        setRot();\n        //\n        largeShow = 0;\n        document.getElementById(\"imgZoom\").style.zIndex = -2;\n      } else {\n        // Stop refreshes\n        window.stop();\n        rotStop();\n        //\n        largeShow = 1;\n\n        // Extract index more robustly (handles ClickOverlayN or ImageN)\n        const idMatch = targetElement.id.match(/\\d+/);\n        if (!idMatch) {\n          console.warn(\"Could not find index for zoom\", targetElement.id);\n          return;\n        }\n        largeIdx = +idMatch[0];\n\n        const zoomContainer = document.getElementById(\"imgZoom\");\n        const largeImg = document.getElementById(\"ImageLarge\");\n\n        zoomContainer.style.zIndex = 3;\n\n        // Find the source from the actual tile image\n        const sourceImg = document.getElementById(\"Image\" + largeIdx);\n        if (sourceImg) {\n          // WHEELZOOM COMPATIBILITY: \n          // If wheelzoom is active, sourceImg.src is a transparent placeholder.\n          // The real image is in style.backgroundImage\n          let realSrc = sourceImg.src;\n          if (sourceImg.style.backgroundImage) {\n            realSrc = sourceImg.style.backgroundImage.replace(/^url\\([\"']?/, \"\").replace(/[\"']?\\)$/, \"\");\n          }\n\n          largeImg.src = realSrc;\n        }\n      }\n    }\n\n    // Image cache prevention\n    // Check if the image URL already include parameters, then avoid the random timestamp\n    function getImgURL(url) {\n      return url.includes(\"?\") ? url : url + \"?_=\" + Date.now();\n    }\n\n    // Manually rotate images\n    function rotate(event) {\n      event.preventDefault();\n      var targetElement = event.target || event.srcElement;\n      if (largeShow == 1) {\n        i = largeIdx;\n      } else {\n        i = +targetElement.id.match(/\\d+/)[0];\n      }\n      imgRot(i);\n    }\n\n    function imgRot(i) {\n      if (aIMG[i].length > 2) {\n        ++aIdx[i];\n        if (aIdx[i] > aIMG[i].length - 1) {\n          aIdx[i] = 1;\n        }\n      }\n\n      // ROTATING TITLE LOGIC\n      const titleDiv = document.getElementById(\"Title\" + i);\n      if (titleDiv && Array.isArray(aIMG[i][0])) {\n        titleDiv.innerHTML = aIMG[i][0][aIdx[i] - 1] || \"\";\n      }\n\n      // Conditional overlay visibility (Lock/Unlock based on content type)\n      const currentItem = aIMG[i][aIdx[i]];\n      const overlay = document.getElementById('ClickOverlay' + i);\n      if (overlay) {\n        if (isVideo(currentItem) || isFrame(currentItem)) {\n          overlay.style.display = 'block';\n        } else {\n          overlay.style.display = 'none';\n        }\n      }\n\n      // console.log(\"aIdx\", aIdx);\n      // console.log(\"i\", i, \"aIdx[i]\", aIdx[i], \"aIMG[i][aIdx[i]]\", aIMG[i][aIdx[i]]);\n      vid = document.getElementById(\"Video\" + i);\n      img = document.getElementById(\"Image\" + i);\n      ifr = document.getElementById(\"iFrame\" + i);\n\n      const isImg = !isVideo(aIMG[i][aIdx[i]]) && !isFrame(aIMG[i][aIdx[i]]);\n      const url = getImgURL(aIMG[i][aIdx[i]]);\n\n      if (isVideo(aIMG[i][aIdx[i]])) {\n        // Is video\n        vid.src = url;\n        vid.classList.remove(\"hidden\");\n        // Hide others\n        img.classList.add(\"hidden\");\n        ifr.classList.add(\"hidden\");\n      } else if (isFrame(aIMG[i][aIdx[i]])) {\n        // Is iFrame\n        var src = aIMG[i][aIdx[i]];\n        var newSrc = [];\n        if (isDarkFrame(src)) {\n          newSrc = src.split(\"iframedark|\");\n          ifr.style.filter = \"invert(1) hue-rotate(180deg)\";\n        } else {\n          newSrc = src.split(\"iframe|\");\n          ifr.style.filter = \"none\";\n        }\n        ifr.classList.remove(\"hidden\");\n        // Handle optional scale parameter: prefix|URL|SCALE\n        var content = newSrc[1];\n        var contentParts = content.split(\"|\");\n        ifr.src = contentParts[0];\n        if (contentParts[1]) {\n          ifr.style.transform = \"scale(\" + contentParts[1] + \")\";\n        }\n        ifr.style.zIndex = 0;\n        // Hide others\n        vid.classList.add(\"hidden\");\n        img.classList.add(\"hidden\");\n      } else {\n        // Is image\n        var src = aIMG[i][aIdx[i]];\n        if (isInvert(src)) {\n          img.style.filter = \"invert(1)\";\n          src = src.replace(\"invert|\", \"\");\n        } else {\n          img.style.filter = \"none\";\n        }\n        img.src = getImgURL(src);\n        img.classList.remove(\"hidden\");\n        // Hide others\n        vid.classList.add(\"hidden\");\n        ifr.classList.add(\"hidden\");\n      }\n\n      // FULL SCREEN ROTATION SUPPORT\n      if (largeShow == 1 && i == largeIdx) {\n        const largeImg = document.getElementById(\"ImageLarge\");\n        if (largeImg) {\n          if (isImg) {\n            largeImg.src = url;\n          } else {\n            // If we rotate into a non-image content, close the zoom view\n            larger();\n          }\n        }\n      }\n    }\n\n    // Automatically rotate images\n    function slide(i) {\n      // check all tiles or one tile\n      if (typeof i === \"undefined\") {\n        // get the locations with multiple images\n        aIMG.forEach(function (innerArray, i) {\n          imgRot(i);\n        });\n      } else {\n        // Only one tile as per timeout call\n        imgRot(i);\n      }\n    }\n\n    function updateTickerSpeed() {\n      const rssTickerContent = document.querySelector(\".rss-ticker-content\");\n      if (rssTickerContent) {\n        // Calculate the width of the content and the container\n        const contentWidth = rssTickerContent.scrollWidth;\n        const containerWidth = rssTickerContent.parentElement.offsetWidth;\n\n        // Define a base speed (e.g., 180px per second)\n        const baseSpeed = 180; // pixels per second\n\n        // Calculate the duration based on the content width\n        const duration = (contentWidth + containerWidth) / baseSpeed;\n\n        // Update the CSS variable for the animation duration\n        rssTickerContent.style.setProperty(\"--ticker-duration\", `${duration}s`);\n        // console.log(`Updated ticker speed: ${duration}s`);\n      }\n    }    \n    \n    // .......##.......##....########...######...######.\n    // ......##.......##.....##.....##.##....##.##....##\n    // .....##.......##......##.....##.##.......##......\n    // ....##.......##.......########...######...######.\n    // ...##.......##........##...##.........##.......##\n    // ..##.......##.........##....##..##....##.##....##\n    // .##.......##..........##.....##..######...######.\n    \n    // Store interval IDs to prevent duplicates\n    let rssIntervals = [];\n    let activeFetches = new Map(); // Track active fetch promises per feed URL\n    // Track proxy success/failure rates per feed\n    let proxyHealth = {};\n      \n    // Function to fetch and display the RSS feed\n    function fetchAndDisplayRss() {\n      // Clear any existing intervals first\n      rssIntervals.forEach(intervalId => clearInterval(intervalId));\n      rssIntervals = [];\n      \n      // List of CORS proxies to try (in order of preference)\n      const corsProxies = [\n        {\n          name: 'allorigins',\n          url: (feedUrl) => `https://api.allorigins.win/raw?url=${encodeURIComponent(feedUrl)}`\n        },\n        {\n          name: 'corsproxy',\n          url: (feedUrl) => `https://corsproxy.io/?url=${encodeURIComponent(feedUrl)}`\n        },\n        {\n          name: 'codetabs',\n          url: (feedUrl) => `https://api.codetabs.com/v1/proxy?quest=${encodeURIComponent(feedUrl)}`\n        },\n        {\n          name: 'thingproxy',\n          url: (feedUrl) => `https://thingproxy.freeboard.io/fetch/${feedUrl}`\n        }\n      ];\n      \n      const rssTickerContent = document.getElementById(\"rss-ticker-content\");\n      if (!rssTickerContent) {\n        console.error(\"RSS ticker content element not found\");\n        return;\n      }\n      \n      const feedContents = new Array(aRSS.length).fill(\"\");\n      let loadedFeeds = 0;\n    \n      console.log(\"Fetching RSS feeds...\");\n      \n      aRSS.forEach(([rssUrl, interval], index) => {\n        const fetchFeed = async (retryCount = 0, maxRetries = 1) => {\n          // Prevent multiple simultaneous fetches of the same feed\n          if (activeFetches.has(rssUrl)) {\n            console.log(`⏸️ Fetch already in progress for ${rssUrl}, skipping...`);\n            return;\n          }\n          \n          console.log(`📡 Fetching feed: ${rssUrl}${retryCount > 0 ? ` (retry ${retryCount})` : ''}`);\n          \n          // Initialize proxy health tracking for this feed if needed\n          if (!proxyHealth[rssUrl]) {\n            proxyHealth[rssUrl] = {};\n            corsProxies.forEach(proxy => {\n              proxyHealth[rssUrl][proxy.name] = { successes: 0, failures: 0 };\n            });\n          }\n          \n          // Sort proxies by success rate for this specific feed\n          const sortedProxies = [...corsProxies].sort((a, b) => {\n            const healthA = proxyHealth[rssUrl][a.name];\n            const healthB = proxyHealth[rssUrl][b.name];\n            const rateA = healthA.successes / (healthA.successes + healthA.failures + 1);\n            const rateB = healthB.successes / (healthB.successes + healthB.failures + 1);\n            return rateB - rateA;\n          });\n          \n          // Create the fetch promise and store it\n          const fetchPromise = (async () => {\n            try {\n              // Try all proxies in parallel (race to success)\n              const proxyPromises = sortedProxies.map(async (proxy) => {\n                const proxyUrl = proxy.url(rssUrl);\n                \n                try {\n                  const controller = new AbortController();\n                  const timeoutId = setTimeout(() => controller.abort(), 8000); // 8 second timeout\n                  \n                  const response = await fetch(proxyUrl, { \n                    signal: controller.signal,\n                    cache: 'no-cache',\n                    headers: {\n                      'Accept': 'application/rss+xml, application/xml, text/xml, application/atom+xml'\n                    }\n                  });\n                  clearTimeout(timeoutId);\n                  \n                  if (!response.ok) {\n                    throw new Error(`HTTP ${response.status}`);\n                  }\n                  \n                  const data = await response.text();\n                  \n                  // Check if we actually got XML (not an HTML error page)\n                  const trimmedData = data.trim();\n                  if (!trimmedData.startsWith('<?xml') && \n                      !trimmedData.startsWith('<rss') && \n                      !trimmedData.startsWith('<feed') &&\n                      !trimmedData.includes('<rss') &&\n                      !trimmedData.includes('<feed')) {\n                    throw new Error('Response is not XML');\n                  }\n                  \n                  const parser = new DOMParser();\n                  const xmlDoc = parser.parseFromString(data, \"text/xml\");\n                  \n                  // Check for XML parsing errors\n                  const parserError = xmlDoc.querySelector('parsererror');\n                  if (parserError) {\n                    throw new Error('XML parsing error');\n                  }\n                \n                  // Automatically detect whether the feed uses \"item\" or \"entry\" tags\n                  let itmTag = \"item\"; // Default to RSS\n                  if (xmlDoc.querySelector(\"entry\")) {\n                    itmTag = \"entry\"; // Switch to Atom if \"entry\" is found\n                  }\n                \n                  const feedTitle = xmlDoc.querySelector(\"channel > title, feed > title\")?.textContent || \"Unknown Feed\";\n                  const lastUpdated = xmlDoc.querySelector(\"channel > lastBuildDate, feed > updated\")?.textContent || \"Unknown Time\";\n                \n                  const items = xmlDoc.querySelectorAll(itmTag);\n                \n                  if (items.length === 0) {\n                    throw new Error('No items found in feed');\n                  }\n                \n                  // Success! Update proxy health\n                  proxyHealth[rssUrl][proxy.name].successes++;\n                  \n                  console.log(`✅ Loaded ${items.length} items from ${rssUrl} (${proxy.name})`);\n                \n                  let feedText = `<span style=\"font-size: 0.9em; color: #aaa;\"> ${feedTitle} - Last Updated: ${lastUpdated} </span> - `;\n                \n                  items.forEach((item) => {\n                    const title = item.querySelector(\"title\")?.textContent || \"No title\";\n                  \n                    // Handle both <link href=\"...\"> and <link>...</link>\n                    const linkElement = item.querySelector(\"link\");\n                    let link = \"\";\n                    if (linkElement) {\n                      if (linkElement.getAttribute(\"href\")) {\n                        link = linkElement.getAttribute(\"href\");\n                      } else {\n                        link = linkElement.textContent.trim();\n                      }\n                    }\n                  \n                    feedText += `<a href=\"${link}\" target=\"_blank\" style=\"margin-right: 50px;\">${title}</a>`;\n                  });\n                \n                  // Return the successful result\n                  return { index, feedText, proxy: proxy.name };\n                  \n                } catch (error) {\n                  // Update proxy health on failure\n                  proxyHealth[rssUrl][proxy.name].failures++;\n                  // Only log significant errors\n                  if (!error.message.includes('aborted') && !error.message.includes('Failed to fetch')) {\n                    console.warn(`❌ ${proxy.name} failed for ${rssUrl}: ${error.message}`);\n                  }\n                  throw error; // Re-throw to be caught by Promise.any\n                }\n              });\n              \n              // Wait for the first successful proxy (race condition)\n              const result = await Promise.any(proxyPromises);\n              \n              // Update the content for this feed (only once!)\n              feedContents[index] = result.feedText;\n              loadedFeeds++;\n              \n              // Combine all feeds and update the ticker content\n              const displayContent = feedContents.filter(f => f).join(\"\") || \n                `<span style=\"color: #aaa;\">Loading feeds... (${loadedFeeds}/${aRSS.length})</span>`;\n              rssTickerContent.innerHTML = displayContent;\n              \n              // Update the ticker speed\n              updateTickerSpeed();\n              \n              return result;\n              \n            } catch (error) {\n              // All proxies failed\n              console.error(`🚫 All proxies failed for ${rssUrl}`);\n              \n              // Try retry if we haven't exceeded max retries\n              if (retryCount < maxRetries) {\n                const retryDelay = (retryCount + 1) * 3000; // 3s, 6s\n                console.log(`⏳ Retrying ${rssUrl} in ${retryDelay/1000} seconds...`);\n                \n                // Remove from active fetches before retry\n                activeFetches.delete(rssUrl);\n                \n                await new Promise(resolve => setTimeout(resolve, retryDelay));\n                return fetchFeed(retryCount + 1, maxRetries);\n              } else {\n                // Final failure\n                console.error(`💀 Giving up on ${rssUrl} after ${maxRetries + 1} attempts`);\n                const domain = rssUrl.split('/')[2];\n                feedContents[index] = `<span style=\"color: #f88; margin-right: 50px;\">⚠️ ${domain} unavailable</span>`;\n                rssTickerContent.innerHTML = feedContents.filter(f => f).join(\"\") || \n                  '<span style=\"color: #f88;\">Some feeds failed to load. Check console for details.</span>';\n                throw error;\n              }\n            } finally {\n              // Always remove from active fetches when done (success or failure)\n              activeFetches.delete(rssUrl);\n            }\n          })();\n          \n          // Store the active fetch promise\n          activeFetches.set(rssUrl, fetchPromise);\n          \n          // Wait for it to complete\n          return fetchPromise;\n        };\n      \n        // Fetch the feed immediately\n        fetchFeed().catch(err => {\n          console.error(`Failed to initialize feed ${rssUrl}:`, err);\n        });\n      \n        // Set up periodic refresh based on the interval (in minutes)\n        if (interval && interval > 0) {\n          const intervalId = setInterval(() => {\n            fetchFeed().catch(err => {\n              console.error(`Failed to refresh feed ${rssUrl}:`, err);\n            });\n          }, interval * 60 * 1000);\n          rssIntervals.push(intervalId);\n        }\n      });\n    }\n\n    // .d8888. d888888b  .d8b.  d8888b. d888888b\n    // 88'  YP `~~88~~' d8' `8b 88  `8D `~~88~~'\n    // `8bo.      88    88ooo88 88oobY'    88\n    //   `Y8b.    88    88~~~88 88`8b      88\n    // db   8D    88    88   88 88 `88.    88\n    // `8888Y'    YP    YP   YP 88   YD    YP\n\n    function start() {\n      // Configurable grid layout logic. Defaults to standard 4 columns by 3 rows if values are missing in config.js file.\n      var layout_cols = typeof window.layout_cols === \"undefined\" ? 4 : window.layout_cols;\n      var layout_rows = typeof window.layout_rows === \"undefined\" ? 3 : window.layout_rows;\n      var layout_grid = \"auto \".repeat(layout_cols);\n      var layout_width = 99.6 / layout_cols + \"vw\";\n      var layout_height = 93 / layout_rows + \"vh\";\n      var iTiles = layout_cols * layout_rows;\n      document.documentElement.style.setProperty(\n        \"--main-layout\",\n        layout_grid\n      );\n      document.documentElement.style.setProperty(\n        \"--main-width\",\n        layout_width\n      );\n      document.documentElement.style.setProperty(\n        \"--main-height\",\n        layout_height\n      );\n\n      document.getElementById(\"currentSettingsSource\").innerHTML = curSettingsSrc;\n\n      // Default variables initialization\n      window.largeShow = 0;\n      window.aIdx = [];\n      window.aInt = [];\n      for (var i = 1; i <= iTiles; i++) {\n        aIdx.push(1);\n        aInt.push(null);\n      }\n      if (!(aIMG.length == tileDelay.length && aIMG.length == iTiles)) {\n        var msg = \"\\nError detected on config.js file!\\n\\n\";\n        msg += \"The number of tile sources (\" + aIMG.length + \" in aIMG) and\\n\";\n        msg += \"the tile delay (\" + tileDelay.length + \" in tileDelay) arrays should match\\n\";\n        msg += \"the number of items each one contains and\\n\";\n        msg += \"the number of tiles used on the layout specified (\" + iTiles + \").\";\n        alert(msg);\n      }\n\n      // Get the parent div for Menu container\n      var parentDivL = document.getElementById(\"myMenuL\");\n      var parentDivR = document.getElementById(\"myMenuR\");\n\n      // Preppend the Load Cfg option to the right side menu\n      if (typeof disableLdCfg === \"undefined\" || !disableLdCfg) {\n        aURL.unshift(\n          [\"FF0000\", \"Load Cfg\", \"\", \"1\", \"R\"]\n        )\n      }\n\n      // Preppend the default options to the menu\n      aURL.unshift(\n        [\"add10d\", \"BACK\", \"\", \"1\", \"L\"],\n        [\"0dd1a7\", \"Help\", \"\", \"1\", \"L\"],\n        [\"add10d\", \"BACK\", \"\", \"1\", \"R\"],\n        [\"ff9100\", \"Refresh\", \"?_=\" + Date.now()],\n      );\n\n      // Append the Setup and Sources option to the right side menu\n      if (typeof disableSetup === \"undefined\" || !disableSetup) {\n        aURL.push(\n          [\"ff9100\", \"Setup\", \"\", \"1\", \"R\"]\n        )\n      }\n\n      aURL.push(\n        [\"0dd1a7\", \"Sources\", \"\", \"1\", \"R\"]\n      );\n\n      // Append the Update option to the right side menu if needed\n      if (bUpdate) {\n        aURL.push([\"FF0000\", \"Update\", \"\", \"1\", \"R\"]);\n      }\n\n      // Append the new div to the parent div\n      aURL.forEach(function (innerArray, index) {\n\n        const title = String(innerArray[1] || '').trim();\n        const link = String(innerArray[2] || '').trim();\n        const titleLower = title.toLowerCase();\n        const linkLower = link.toLowerCase();\n        const coreNames = ['back', 'refresh', 'load cfg', 'help', 'setup', 'sources', 'update'];\n\n        // Create a new div element\n        var newDiv = document.createElement(\"div\");\n        var color = innerArray[0].replace(\"#\", \"\");\n\n        let type = 'user';\n        if (coreNames.includes(titleLower))\n          type = 'core';\n        else if (titleLower.includes('.js') || linkLower.includes('.js'))\n          type = 'config';\n\n        newDiv.innerHTML = `<a href=\"#\" class=\"menu-link menu-${type}\" style=\"background-color:#${color};\" onclick=\"MenuOpt(${index})\">${innerArray[1]}</a>`;\n\n        if (innerArray[4] == \"R\") {\n          // Set some properties for the new div\n          newDiv.id = \"mySidenavR\";\n          newDiv.className = \"sidenavR\";\n          parentDivR.appendChild(newDiv);\n        } else {\n          // Set some properties for the new div\n          newDiv.id = \"mySidenavL\";\n          newDiv.className = \"sidenav\";\n          parentDivL.appendChild(newDiv);\n        }\n      });\n\n      // Get the parent div for Dashboard container\n      var parentDiv = document.getElementById(\"dash\");\n\n      // Append the new div to the parent div\n      aIMG.forEach(function (innerArray, index) {\n        // Create a new div element\n        var newDiv = document.createElement(\"div\");\n        newDiv.className = \"image-container\";\n        newDiv.id = `box${index}`;\n\n        // Add video placeholder containers\n        const video = document.createElement(\"video\");\n        video.id = `Video${index}`;\n        video.classList.add(\"media\", \"hidden\");\n        video.controls = true;\n        video.muted = true;\n        video.autoplay = true;\n        video.loop = true;\n        const source = document.createElement(\"source\");\n\n        // Create a new img element\n        var newImg = document.createElement(\"img\");\n        newImg.id = `Image${index}`;\n        newImg.classList.add(\"hidden\");\n        newImg.oncontextmenu = rotate;\n        newImg.ondblclick = larger;\n\n        // append newIframes iFrameNN\n        var newFrame = document.createElement(\"iframe\");\n        newFrame.className = \"iframe-tile\";\n        newFrame.id = `iFrame${index}`;\n        newFrame.classList.add(\"hidden\");\n\n        // CLICK OVERLAY (Fix for missing right-click on video/iframe)\n        var clickOverlay = document.createElement(\"div\");\n        clickOverlay.className = \"click-overlay\";\n        clickOverlay.id = `ClickOverlay${index}`;\n        clickOverlay.oncontextmenu = rotate;\n\n        // Initial visibility\n        const initialItem = innerArray[1];\n        if (isVideo(initialItem) || isFrame(initialItem)) {\n          clickOverlay.style.display = 'block';\n        } else {\n          clickOverlay.style.display = 'none';\n        }\n\n        clickOverlay.ondblclick = function (event) {\n          const currentItem = aIMG[index][aIdx[index]];\n          if (isVideo(currentItem) || isFrame(currentItem)) {\n            // If it's a video or iframe, UNLOCK it instead of zooming\n            console.log(`Unlocking tile ${index} for interaction`);\n            this.style.display = 'none';\n          } else {\n            // If it's an image, trigger the standard zoom\n            larger(event);\n          }\n        };\n        var newSrc = \" \";\n\n        if (isVideo(innerArray[1])) {\n          // Is a video\n          video.classList.remove(\"hidden\");\n          source.src = innerArray[1];\n          source.type = getVideoType(innerArray[1]);\n          video.appendChild(source);\n        } else if (isFrame(innerArray[1])) {\n          // Is iFrame\n          newFrame.classList.remove(\"hidden\");\n          var src = innerArray[1];\n          var newSrc = [];\n          if (isDarkFrame(src)) {\n            newSrc = src.split(\"iframedark|\");\n            newFrame.style.filter = \"invert(1) hue-rotate(180deg)\";\n          } else {\n            newSrc = src.split(\"iframe|\");\n            newFrame.style.filter = \"none\";\n          }\n          var content = newSrc[1];\n          var contentParts = content.split(\"|\");\n          newFrame.src = contentParts[0];\n          if (contentParts[1]) {\n            newFrame.style.transform = \"scale(\" + contentParts[1] + \")\";\n          }\n          newFrame.style.zIndex = 0;\n        } else {\n          // Is an image\n          newImg.classList.remove(\"hidden\");\n          var src = innerArray[1];\n          if (isInvert(src)) {\n            newImg.style.filter = \"invert(1)\";\n            src = src.replace(\"invert|\", \"\");\n          } else {\n            newImg.style.filter = \"none\";\n          }\n          newImg.src = getImgURL(src);\n          newImg.onerror = function () {\n            text = \"Failed to load image\";\n            console.log(text, this.src);\n            if (this.src.includes(\"?\")) {\n              // Retry without passing variables first to see if fixes the error\n              console.log(\"Trying without caching prevention\");\n              newImg.src = this.src.split(\"?\")[0];\n            } else {\n              el = `<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"480\" height=\"330\">\n                  <g>\n                    <text style=\"font-size:34px; line-height:1.25; white-space:pre; fill:#ffaa00; fill-opacity:1; stroke:#ffaa00; stroke-opacity:1;\">\n                      <tspan x=\"100\" y=\"150\">${text}</tspan>\n                      </text>\n                      </g>\n                      </svg>`;\n              newImg.src = \"data:image/svg+xml;base64,\" + window.btoa(el);\n            }\n          };\n        }\n\n        // append newDivs boxNN\n        newDiv.appendChild(video);\n        newDiv.appendChild(newImg);\n        newDiv.appendChild(newFrame);\n        newDiv.appendChild(clickOverlay);\n        parentDiv.appendChild(newDiv);\n\n        // Create a new div element for img title\n        var newTtl = document.createElement(\"div\");\n        newTtl.className = \"image-title\";\n        newTtl.id = `Title${index}`;\n\n        let initialTitle = \"\";\n        if (Array.isArray(innerArray[0])) {\n          initialTitle = innerArray[0][0] || \"\";\n        } else {\n          initialTitle = innerArray[0];\n        }\n\n        if (initialTitle.length > 0 || Array.isArray(innerArray[0])) {\n          newTtl.innerHTML = initialTitle;\n          newDiv.appendChild(newTtl);\n        }\n      });\n\n      // assign wheelzoom functionality to all 12 images\n      wheelzoom(document.querySelectorAll(\"img\"));\n\n      window.addEventListener(\"resize\", function () {\n        \"use strict\";\n        window.location.reload();\n      });\n\n      if (typeof aRSS !== \"undefined\" && aRSS.length > 0) {\n        // Dynamically create the RSS ticker div\n        const rssTicker = document.createElement(\"div\");\n        rssTicker.id = \"rss-ticker\";\n        rssTicker.className = \"rss-ticker\";\n\n        const rssTickerContent = document.createElement(\"div\");\n        rssTickerContent.id = \"rss-ticker-content\";\n        rssTickerContent.className = \"rss-ticker-content\";\n\n        rssTicker.appendChild(rssTickerContent);\n        document.body.appendChild(rssTicker); // Add the ticker to the body\n\n        // Call the function to fetch and display RSS feeds\n        fetchAndDisplayRss();\n\n        // Add event listeners for pause and resume functionality\n        rssTickerContent.addEventListener(\"mouseenter\", () => {\n          rssTickerContent.style.animationPlayState = \"paused\";\n        });\n\n        rssTickerContent.addEventListener(\"mouseleave\", () => {\n          rssTickerContent.style.animationPlayState = \"running\";\n        });\n      }\n\n      setRot();\n    }\n\n    // This function update the time on the top bar\n    function updateTopBar() {\n      const now = new Date();\n      const localDate = now.toLocaleDateString(\"en-US\", {\n        weekday: \"long\",\n        month: \"long\",\n        day: \"numeric\",\n      });\n      const localTime = now.toLocaleTimeString(\"en-US\", {\n        hour12: false,\n        hour: \"2-digit\",\n        minute: \"2-digit\",\n        second: \"2-digit\",\n        timeZoneName: \"short\",\n      });\n\n      const utcDate = now.toISOString().slice(0, 10);\n      const utcTime = now.toISOString().slice(11, 19) + \" UTC\";\n\n      const topBarLeft = document.getElementById(\"topBarLeft\");\n      topBarLeft.textContent = `${localDate} - ${localTime}`;\n      const topBarCenter = document.getElementById(\"topBarCenter\");\n      topBarCenter.textContent = topBarCenterText;\n      const topBarRight = document.getElementById(\"topBarRight\");\n      topBarRight.textContent = `${utcDate} ${utcTime}`;\n    }\n\n    // Update every second\n    setInterval(updateTopBar, 1000);\n\n    // Run the check when the application starts\n    checkForUpdates();\n  </script>\n</head>\n<!--\nd8888b.  .d88b.  d8888b. db    db\n88  `8D .8P  Y8. 88  `8D `8b  d8'\n88oooY' 88    88 88   88  `8bd8'\n88~~~b. 88    88 88   88    88\n88   8D `8b  d8' 88  .8D    88\nY8888P'  `Y88P'  Y8888D'    YP\n-->\n\n<body onload=\"main()\">\n  <div id=\"iFrameContainer\" class=\"iframe-container\">\n    <iframe id=\"FullScreen\" class=\"full-screen\" src=\"\" title=\"Zoom\"></iframe>\n    <!-- Settings Page Div -->\n    <div id=\"settingsPage\" class=\"settings-Page\">\n      <div id=\"fixedSection\" class=\"fixed-section\">\n        <button id=\"saveConfig\">Save Settings to Local Storage</button>\n        <button id=\"resetConfig\">Reset to Defaults</button>\n        <button id=\"deleteConfig\">Delete Settings from Local Storage</button>\n        <button id=\"backupConfig\">Backup Settings to JSON file</button>\n        <button id=\"restoreConfig\">Restore Settings from JSON file</button>\n        <button id=\"importConfig\">Import from config.js file</button>\n        <button id=\"exportConfig\">Export to config.js file</button>\n      </div>\n\n      <h1>Dashboard Setup</h1>\n      <div id=\"configForm\">\n        <!-- Settings Source Selection -->\n        <label>Current Settings Source:</label><span id=\"currentSettingsSource\"></span>\n        <div class=\"section\">\n          <div class=\"radio-group\">\n            <label>Select Settings Source:</label>\n            <input type=\"radio\" id=\"sourceLocalStorage\" name=\"settingsSource\" value=\"localStorage\" />\n            <label for=\"sourceLocalStorage\">Browser Local Storage</label>\n            <input type=\"radio\" id=\"sourceFile\" name=\"settingsSource\" value=\"file\" />\n            <label for=\"sourceFile\">config.js file</label>\n            <font size=2em>(Please choose the source of the settings for the next time the dashboard is loaded.)</font>\n          </div>\n        </div>\n\n        <!-- Top Bar Text -->\n        <div class=\"section\">\n          <label for=\"CenterText\">Top Bar Center Text:</label>\n          <input type=\"text\" id=\"CenterText\" />\n        </div>\n\n        <!-- Grid Layout -->\n        <div class=\"section\">\n          <label>Grid Layout:</label>\n          <label for=\"layout_cols\">Columns:</label>\n          <input type=\"number\" id=\"layout_cols\" min=\"1\" />\n          <label for=\"layout_rows\">Rows:</label>\n          <input type=\"number\" id=\"layout_rows\" min=\"1\" />\n          <font size=2em>(The number of Dashboard Items table will adjust automatically for the grid layout selected\n            here.)</font>\n        </div>\n\n        <!-- Menu Items -->\n        <div class=\"section\">\n          <label>Menu Items:</label>\n          <table id=\"menuTable\">\n            <thead>\n              <tr>\n                <th>Color</th>\n                <th>Text</th>\n                <th>URL</th>\n                <th>Scale</th>\n                <th>Side</th>\n                <th>Actions</th>\n              </tr>\n            </thead>\n            <tbody></tbody>\n          </table>\n          <button id=\"addMenuItem\">Add Menu Item</button>\n        </div>\n\n        <!-- Dashboard Items -->\n        <div class=\"section\">\n          <label>Dashboard Items:</label>\n          <table id=\"dashboardTable\">\n            <thead>\n              <tr>\n                <th>Tile Title</th>\n                <th>Tile URLs</th>\n                <th>URL Rotation Interval (ms)</th>\n                <th>Actions</th>\n              </tr>\n            </thead>\n            <tbody></tbody>\n          </table>\n        </div>\n\n        <!-- Feed Items -->\n        <div class=\"section\">\n          <label>Feed Items:</label>\n          <table id=\"feedTable\">\n            <thead>\n              <tr>\n                <th>Feed URL</th>\n                <th>Refresh Interval (minutes)</th>\n                <th>Actions</th>\n              </tr>\n            </thead>\n            <tbody></tbody>\n          </table>\n          <button id=\"addFeedItem\">Add Feed Item</button>\n        </div>\n\n      </div>\n    </div> <!-- End of Div Settings page -->\n  </div>\n\n  <div id=\"imgZoom\" class=\"img-zoom\">\n    <img class=\"image-large\" id=\"ImageLarge\" alt=\"pic\" ondblclick=\"larger(event);\" oncontextmenu=\"rotate(event);\" />\n  </div>\n\n  <div id=\"myMenuL\" class=\"menuL\">\n    <!-- Left Menu container -->\n  </div>\n\n  <div id=\"myMenuR\" class=\"menuR\">\n    <!-- Right Menu container -->\n  </div>\n\n  <div id=\"defaultFrame\" class=\"default-frame\">\n    <div class=\"top-bar\">\n      <div id=\"topBarLeft\" class=\"child\" style=\"text-align: left; padding-left: 7px; color: blanchedalmond\">\n        &nbsp;\n      </div>\n      <div id=\"topBarCenter\" class=\"child\" style=\"text-align: center; color: rgb(0, 119, 255)\">\n        &nbsp;\n      </div>\n      <div id=\"topBarRight\" class=\"child\" style=\"text-align: right; padding-right: 5px; color: aquamarine\">\n        &nbsp;\n      </div>\n    </div>\n    <div id=\"dash\" class=\"dashboard\">\n      <!-- Images container -->\n    </div>\n  </div>\n\n  <div id=\"overlay\" class=\"overlay\">\n    <div class=\"close-btn\" onclick=\"hideOverlay()\">&#10006;</div>\n    <div class=\"overlay-content\">\n      <div class=\"array-container\">\n        <div class=\"array-title\">\n          <b>Full config.js file: </b>\n          <a href=\"config.js\" target=\"_blank\" style=\"color: white\">(Open in new tab)</a>\n          <p></p>\n        </div>\n      </div>\n      <div class=\"array-container\">\n        <div class=\"array-title\"><b>Menu Options:</b></div>\n        <div id=\"array1\" class=\"array-content\"></div>\n      </div>\n      <div class=\"array-container\">\n        <div class=\"array-title\"><b>Image Sources:</b></div>\n        <div id=\"array2\" class=\"array-content\"></div>\n      </div>\n      <div class=\"array-container\">\n        <div class=\"array-title\"><b>Feed Sources:</b></div>\n        <div id=\"array3\" class=\"array-content\"></div>\n      </div>\n      <div class=\"array-container\">\n        <div class=\"array-title\"><b>Development by:</b></div>\n        <div id=\"array4\" class=\"array-content\"></div>\n      </div>\n    </div>\n  </div>\n\n</body>\n\n</html>"
  },
  {
    "path": "satellite.js",
    "content": "// CUT START\n// const disableSetup = true;\nvar topBarCenterText = \"Satellite Dashboard\";\n\n// Grid layout desired\nvar layout_cols = 2;\nvar layout_rows = 2;\n\n// Menu items\n// Structure is as follows HTML Color code, Option, target URL, scaling 1=Original Size, side (optional, nothing is Left, \"R\" is Right)\n// The values are [color code, menu text, target link, scale factor, side],\n// add new lines following the structure for extra menu options. The comma at the end is important!\nvar aURL = [  \n  [\n    \"2196F3\",\n    \"LIGHTNING\",\n    \"https://map.blitzortung.org/#3.87/36.5/-89.41\",\n    \"1\",\n    \"R\",\n  ],\n  [\n    \"2196F3\",\n    \"RADAR\",\n    \"https://weather.gc.ca/?layers=alert,radar&center=43.39961001,-78.53212031&zoom=6&alertTableFilterProv=ON\",\n    \"1\",\n    \"R\",\n  ],\n  [\n    \"2196F3\",\n    \"WEATHER\",\n    \"https://openweathermap.org/weathermap?basemap=map&cities=true&layer=temperature&lat=44.0157&lon=-79.4591&zoom=5\",\n    \"1\",\n    \"R\",\n  ],\n  [\n    \"2196F3\",\n    \"WINDS\",\n    \"https://earth.nullschool.net/#current/wind/surface/level/orthographic=-78.79,44.09,3000\",\n    \"1\",\n    \"R\",\n  ],\n  [\n    \"2196F3\",\n    \"WINDY\",\n    \"https://embed.windy.com/embed2.html?lat=44.01&lon=-79.45&width=900&detailLat=44.01&detailLon=-79.45&height=600&zoom=8&level=surface&overlay=clouds&product=ecmwf&menu=&message=&marker=&calendar=now&pressure=&type=map&location=coordinates&detail=true&metricWind=km%2Fh&metricTemp=%C2%B0C&radarRange=-1\",\n    \"1\",\n    \"R\"\n  ]\n];\n\n// Dashboard items\n// Structure is Title, Image Source URL\n// [Title, Image Source URL],\n// the comma at the end is important!\n// You can't add more items because there are only 12 placeholders on the dashboard\n// but you can replace the titles and the images with anything you want.\nvar currentDate = new Date();\nvar aIMG = [\n  // 1\n  [\"Radar NA\", \"https://radar.weather.gov/ridge/standard/CONUS-LARGE_loop.gif\", \"https://radar.weather.gov/ridge/standard/CONUS_loop.gif\"],\n  // 2\n  [\n    \"Radar Local\",\n    \"https://s.w-x.co/staticmaps/wu/wxtype/county_loc/bgm/animate.png\",\n  ],\n  // 3\n  [\n    \"Satellite NA (inverted)\",\n    \"invert|https://cdn.star.nesdis.noaa.gov/GOES16/ABI/SECTOR/can/GEOCOLOR/GOES16-CAN-GEOCOLOR-1125x560.gif\",\n  ],\n  // 4\n  [\n    \"Satellite Local (inverted)\",\n    \"invert|https://cdn.star.nesdis.noaa.gov/GOES16/GLM/SECTOR/cgl/EXTENT3/GOES16-CGL-EXTENT3-600x600.gif\",\n  ],\n];\n\n// Image rotation intervals in milliseconds per tile - If the line below is commented, tiles will be rotated every 5000 milliseconds (5s)\nvar tileDelay = [\n  60100, 60200, 300300, 60400,\n];\n\nvar aRSS = [\n  [\"https://weather.gc.ca/rss/battleboard/onrm28_e.xml\", 60],\n];\n\n// CUT END\n"
  },
  {
    "path": "wheelzoom.js",
    "content": "/*!\n\tWheelzoom 4.0.1\n\tlicense: MIT\n\thttp://www.jacklmoore.com/wheelzoom\n*/\nwindow.wheelzoom = (function(){\n\tvar defaults = {\n\t\tzoom: 0.10,\n\t\tmaxZoom: false,\n\t\tinitialZoom: 1,\n\t\tinitialX: 0.5,\n\t\tinitialY: 0.5,\n\t};\n\n\tvar main = function(img, options){\n\t\tif (!img || !img.nodeName || img.nodeName !== 'IMG') { return; }\n\n\t\tvar settings = {};\n\t\tvar width;\n\t\tvar height;\n\t\tvar bgWidth;\n\t\tvar bgHeight;\n\t\tvar bgPosX;\n\t\tvar bgPosY;\n\t\tvar previousEvent;\n\t\tvar transparentSpaceFiller;\n\n\t\tfunction setSrcToBackground(img) {\n\t\t\timg.style.backgroundRepeat = 'no-repeat';\n\t\t\timg.style.backgroundImage = 'url(\"'+img.src+'\")';\n\t\t\ttransparentSpaceFiller = 'data:image/svg+xml;base64,'+window.btoa('<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"'+img.naturalWidth+'\" height=\"'+img.naturalHeight+'\"></svg>');\n\t\t\timg.src = transparentSpaceFiller;\n\t\t}\n\n\t\tfunction updateBgStyle() {\n\t\t\tif (bgPosX > 0) {\n\t\t\t\tbgPosX = 0;\n\t\t\t} else if (bgPosX < width - bgWidth) {\n\t\t\t\tbgPosX = width - bgWidth;\n\t\t\t}\n\n\t\t\tif (bgPosY > 0) {\n\t\t\t\tbgPosY = 0;\n\t\t\t} else if (bgPosY < height - bgHeight) {\n\t\t\t\tbgPosY = height - bgHeight;\n\t\t\t}\n\n\t\t\timg.style.backgroundSize = bgWidth+'px '+bgHeight+'px';\n\t\t\timg.style.backgroundPosition = bgPosX+'px '+bgPosY+'px';\n\t\t}\n\n\t\tfunction reset() {\n\t\t\tbgWidth = width;\n\t\t\tbgHeight = height;\n\t\t\tbgPosX = bgPosY = 0;\n\t\t\tupdateBgStyle();\n\t\t}\n\n\t\tfunction onwheel(e) {\n\t\t\tvar deltaY = 0;\n\n\t\t\te.preventDefault();\n\n\t\t\tif (e.deltaY) { // FireFox 17+ (IE9+, Chrome 31+?)\n\t\t\t\tdeltaY = e.deltaY;\n\t\t\t} else if (e.wheelDelta) {\n\t\t\t\tdeltaY = -e.wheelDelta;\n\t\t\t}\n\n\t\t\t// As far as I know, there is no good cross-browser way to get the cursor position relative to the event target.\n\t\t\t// We have to calculate the target element's position relative to the document, and subtrack that from the\n\t\t\t// cursor's position relative to the document.\n\t\t\tvar rect = img.getBoundingClientRect();\n\t\t\tvar offsetX = e.pageX - rect.left - window.pageXOffset;\n\t\t\tvar offsetY = e.pageY - rect.top - window.pageYOffset;\n\n\t\t\t// Record the offset between the bg edge and cursor:\n\t\t\tvar bgCursorX = offsetX - bgPosX;\n\t\t\tvar bgCursorY = offsetY - bgPosY;\n\t\t\t\n\t\t\t// Use the previous offset to get the percent offset between the bg edge and cursor:\n\t\t\tvar bgRatioX = bgCursorX/bgWidth;\n\t\t\tvar bgRatioY = bgCursorY/bgHeight;\n\n\t\t\t// Update the bg size:\n\t\t\tif (deltaY < 0) {\n\t\t\t\tbgWidth += bgWidth*settings.zoom;\n\t\t\t\tbgHeight += bgHeight*settings.zoom;\n\t\t\t} else {\n\t\t\t\tbgWidth -= bgWidth*settings.zoom;\n\t\t\t\tbgHeight -= bgHeight*settings.zoom;\n\t\t\t}\n\n\t\t\tif (settings.maxZoom) {\n\t\t\t\tbgWidth = Math.min(width*settings.maxZoom, bgWidth);\n\t\t\t\tbgHeight = Math.min(height*settings.maxZoom, bgHeight);\n\t\t\t}\n\n\t\t\t// Take the percent offset and apply it to the new size:\n\t\t\tbgPosX = offsetX - (bgWidth * bgRatioX);\n\t\t\tbgPosY = offsetY - (bgHeight * bgRatioY);\n\n\t\t\t// Prevent zooming out beyond the starting size\n\t\t\tif (bgWidth <= width || bgHeight <= height) {\n\t\t\t\treset();\n\t\t\t} else {\n\t\t\t\tupdateBgStyle();\n\t\t\t}\n\t\t}\n\n\t\tfunction drag(e) {\n\t\t\te.preventDefault();\n\t\t\tbgPosX += (e.pageX - previousEvent.pageX);\n\t\t\tbgPosY += (e.pageY - previousEvent.pageY);\n\t\t\tpreviousEvent = e;\n\t\t\tupdateBgStyle();\n\t\t}\n\n\t\tfunction removeDrag() {\n\t\t\tdocument.removeEventListener('mouseup', removeDrag);\n\t\t\tdocument.removeEventListener('mousemove', drag);\n\t\t}\n\n\t\t// Make the background draggable\n\t\tfunction draggable(e) {\n\t\t\te.preventDefault();\n\t\t\tpreviousEvent = e;\n\t\t\tdocument.addEventListener('mousemove', drag);\n\t\t\tdocument.addEventListener('mouseup', removeDrag);\n\t\t}\n\n\t\tfunction load() {\n\t\t\tvar initial = Math.max(settings.initialZoom, 1);\n\n\t\t\tif (img.src === transparentSpaceFiller) return;\n\n\t\t\tvar computedStyle = window.getComputedStyle(img, null);\n\n\t\t\twidth = parseInt(computedStyle.width, 10);\n\t\t\theight = parseInt(computedStyle.height, 10);\n\t\t\tbgWidth = width * initial;\n\t\t\tbgHeight = height * initial;\n\t\t\tbgPosX = -(bgWidth - width) * settings.initialX;\n\t\t\tbgPosY = -(bgHeight - height) * settings.initialY;\n\n\t\t\tsetSrcToBackground(img);\n\n\t\t\timg.style.backgroundSize = bgWidth+'px '+bgHeight+'px';\n\t\t\timg.style.backgroundPosition = bgPosX+'px '+bgPosY+'px';\n\t\t\timg.addEventListener('wheelzoom.reset', reset);\n\n\t\t\timg.addEventListener('wheel', onwheel);\n\t\t\timg.addEventListener('mousedown', draggable);\n\t\t}\n\n\t\tvar destroy = function (originalProperties) {\n\t\t\timg.removeEventListener('wheelzoom.destroy', destroy);\n\t\t\timg.removeEventListener('wheelzoom.reset', reset);\n\t\t\timg.removeEventListener('load', load);\n\t\t\timg.removeEventListener('mouseup', removeDrag);\n\t\t\timg.removeEventListener('mousemove', drag);\n\t\t\timg.removeEventListener('mousedown', draggable);\n\t\t\timg.removeEventListener('wheel', onwheel);\n\n\t\t\timg.style.backgroundImage = originalProperties.backgroundImage;\n\t\t\timg.style.backgroundRepeat = originalProperties.backgroundRepeat;\n\t\t\timg.src = originalProperties.src;\n\t\t}.bind(null, {\n\t\t\tbackgroundImage: img.style.backgroundImage,\n\t\t\tbackgroundRepeat: img.style.backgroundRepeat,\n\t\t\tsrc: img.src\n\t\t});\n\n\t\timg.addEventListener('wheelzoom.destroy', destroy);\n\n\t\toptions = options || {};\n\n\t\tObject.keys(defaults).forEach(function(key){\n\t\t\tsettings[key] = options[key] !== undefined ? options[key] : defaults[key];\n\t\t});\n\n\t\tif (img.complete) {\n\t\t\tload();\n\t\t}\n\n\t\timg.addEventListener('load', load);\n\t};\n\n\t// Do nothing in IE9 or below\n\tif (typeof window.btoa !== 'function') {\n\t\treturn function(elements) {\n\t\t\treturn elements;\n\t\t};\n\t} else {\n\t\treturn function(elements, options) {\n\t\t\tif (elements && elements.length) {\n\t\t\t\tArray.prototype.forEach.call(elements, function (node) {\n\t\t\t\t\tmain(node, options);\n\t\t\t\t});\n\t\t\t} else if (elements && elements.nodeName) {\n\t\t\t\tmain(elements, options);\n\t\t\t}\n\t\t\treturn elements;\n\t\t};\n\t}\n}());"
  }
]