diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..779f99a
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,12 @@
+root = true
+
+[*]
+indent_style = space
+indent_size = 4
+end_of_line = lf
+charset = utf-8
+trim_trailing_whitespace = true
+insert_final_newline = true
+
+[*.md]
+trim_trailing_whitespace = false
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..fb275cd
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,9 @@
+# E2E test tokens — copy this file to .env.e2e and fill in your values.
+# .env.e2e is gitignored and must never be committed.
+
+SPOTMAP_TOKEN_MAPBOX=
+SPOTMAP_TOKEN_THUNDERFOREST=
+SPOTMAP_TOKEN_TIMEZONEDB=
+SPOTMAP_TOKEN_LINZ=
+SPOTMAP_TOKEN_GEOPORTAIL=ign_scan_ws
+SPOTMAP_TOKEN_OSDATAHUB=
diff --git a/.eslintignore b/.eslintignore
new file mode 100644
index 0000000..96186d9
--- /dev/null
+++ b/.eslintignore
@@ -0,0 +1,4 @@
+coverage/
+build/
+public/
+vendor/
diff --git a/.eslintrc.js b/.eslintrc.js
new file mode 100644
index 0000000..11e997e
--- /dev/null
+++ b/.eslintrc.js
@@ -0,0 +1,52 @@
+module.exports = {
+ extends: [ 'plugin:@wordpress/eslint-plugin/recommended' ],
+ rules: {
+ // @wordpress/* packages are WordPress externals, not bundled deps
+ 'import/no-unresolved': [ 'error', { ignore: [ '^@wordpress/' ] } ],
+ // @wordpress/* packages are WordPress-provided externals; not bundled
+ 'import/no-extraneous-dependencies': [
+ 'error',
+ {
+ devDependencies: true,
+ packageDir: [
+ '.',
+ './node_modules/@wordpress/icons',
+ './node_modules/@wordpress/env',
+ ],
+ },
+ ],
+ // Allow @returns as alias for @return (both are valid JSDoc)
+ 'jsdoc/check-tag-names': [ 'error', { definedTags: [ 'returns' ] } ],
+ // Allow console.error/warn for legitimate error reporting
+ 'no-console': [ 'error', { allow: [ 'error', 'warn' ] } ],
+ // __experimentalUnitControl is the only option for this API;
+ // no stable UnitControl exists in the WordPress runtime externals
+ '@wordpress/no-unsafe-wp-apis': [
+ 'error',
+ { '@wordpress/components': [ '__experimentalUnitControl' ] },
+ ],
+ },
+ overrides: [
+ {
+ // TypeScript types already document params — @param is redundant
+ files: [ '**/*.ts', '**/*.tsx' ],
+ rules: {
+ 'jsdoc/require-param': 'off',
+ 'jsdoc/check-tag-names': 'off',
+ },
+ },
+ {
+ files: [
+ '**/__tests__/**/*.js',
+ '**/__tests__/**/*.jsx',
+ '**/__tests__/**/*.ts',
+ '**/__tests__/**/*.tsx',
+ '**/*.test.js',
+ '**/*.test.jsx',
+ '**/*.test.ts',
+ '**/*.test.tsx',
+ ],
+ extends: [ 'plugin:@wordpress/eslint-plugin/test-unit' ],
+ },
+ ],
+};
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..73ea48d
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,14 @@
+# Normalize all text files to LF on commit
+* text=auto eol=lf
+
+# Binaries — no conversion
+*.png binary
+*.jpg binary
+*.gif binary
+*.ico binary
+*.zip binary
+*.woff binary
+*.woff2 binary
+*.ttf binary
+*.eot binary
+*.otf binary
diff --git a/.github/workflows/deploy_assets-readme.yml b/.github/workflows/deploy_assets-readme.yml
index 2d35034..80263a7 100644
--- a/.github/workflows/deploy_assets-readme.yml
+++ b/.github/workflows/deploy_assets-readme.yml
@@ -1,16 +1,16 @@
name: Plugin asset/readme update
on:
- push:
- branches:
- - master
+ push:
+ branches:
+ - master
jobs:
- master:
- name: Push to master
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@master
- - name: WordPress.org plugin asset/readme update
- uses: 10up/action-wordpress-plugin-asset-update@master
- env:
- SVN_PASSWORD: ${{ secrets.SVN_PASSWORD }}
- SVN_USERNAME: ${{ secrets.SVN_USERNAME }}
\ No newline at end of file
+ master:
+ name: Push to master
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@master
+ - name: WordPress.org plugin asset/readme update
+ uses: 10up/action-wordpress-plugin-asset-update@master
+ env:
+ SVN_PASSWORD: ${{ secrets.SVN_PASSWORD }}
+ SVN_USERNAME: ${{ secrets.SVN_USERNAME }}
diff --git a/.github/workflows/deploy_plugin.yml b/.github/workflows/deploy_plugin.yml
index 5cab84e..bcec5c4 100644
--- a/.github/workflows/deploy_plugin.yml
+++ b/.github/workflows/deploy_plugin.yml
@@ -1,17 +1,17 @@
name: Deploy to WordPress.org
on:
- push:
- tags:
- - "*"
+ push:
+ tags:
+ - '*'
jobs:
- tag:
- name: New tag
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@master
- - name: WordPress Plugin Deploy
- uses: 10up/action-wordpress-plugin-deploy@master
- env:
- SVN_PASSWORD: ${{ secrets.SVN_PASSWORD }}
- SVN_USERNAME: ${{ secrets.SVN_USERNAME }}
- SLUG: spotmap
+ tag:
+ name: New tag
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@master
+ - name: WordPress Plugin Deploy
+ uses: 10up/action-wordpress-plugin-deploy@master
+ env:
+ SVN_PASSWORD: ${{ secrets.SVN_PASSWORD }}
+ SVN_USERNAME: ${{ secrets.SVN_USERNAME }}
+ SLUG: spotmap
diff --git a/.github/workflows/install_plugin.yaml b/.github/workflows/install_plugin.yaml
deleted file mode 100644
index baca984..0000000
--- a/.github/workflows/install_plugin.yaml
+++ /dev/null
@@ -1,15 +0,0 @@
-name: get latest version for 2-camp.com
-on:
- push:
- branches:
- - master
-jobs:
- master:
- name: get latest
- runs-on: ubuntu-latest
- steps:
- - name: get latest version for 2-camp.com
- uses: satak/webrequest-action@master
- with:
- url: https://2-camp.com/wp-json/github-updater/v1/update/?key=${{ secrets.GIT_REMOTE_UPDATER_KEY }}&plugin=spotmap
- method: GET
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..7673fc2
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,22 @@
+node_modules/
+build/
+
+# Composer (both regenerated by pipeline via `npm run composer install`)
+vendor/
+vendor-prefixed/
+.claude/
+*.js.map
+
+# public/ has its own .gitignore — only the two authored PHP files are tracked
+includes/css/
+includes/webfonts/
+
+# Test artifacts
+.phpunit.result.cache
+coverage/
+
+# E2E — private tokens, temp files, and Playwright output
+.env
+tests/e2e/.inject.php
+playwright-report/
+test-results/
\ No newline at end of file
diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php
new file mode 100644
index 0000000..42859a3
--- /dev/null
+++ b/.php-cs-fixer.dist.php
@@ -0,0 +1,18 @@
+in( __DIR__ )
+ ->exclude( 'vendor' )
+ ->exclude( 'vendor-prefixed' )
+ ->exclude( 'node_modules' )
+ ->exclude( 'build' )
+ ->exclude( 'public' );
+
+$config = new PhpCsFixer\Config();
+
+return $config
+ ->setRules( [
+ '@PSR12' => true,
+ 'indentation_type' => true,
+ ] )
+ ->setFinder( $finder );
diff --git a/.prettierrc.js b/.prettierrc.js
new file mode 100644
index 0000000..4736bfc
--- /dev/null
+++ b/.prettierrc.js
@@ -0,0 +1,7 @@
+const wordpressConfig = require( '@wordpress/prettier-config' );
+
+module.exports = {
+ ...wordpressConfig,
+ useTabs: false,
+ tabWidth: 4,
+};
diff --git a/.stylelintignore b/.stylelintignore
new file mode 100644
index 0000000..c3ffaa1
--- /dev/null
+++ b/.stylelintignore
@@ -0,0 +1,6 @@
+coverage/
+build/
+public/
+node_modules/
+vendor/
+includes/css/
diff --git a/.vscode/launch.json b/.vscode/launch.json
new file mode 100644
index 0000000..98b722d
--- /dev/null
+++ b/.vscode/launch.json
@@ -0,0 +1,14 @@
+{
+ "version": "0.2.0",
+ "configurations": [
+ {
+ "name": "Listen for Xdebug",
+ "type": "php",
+ "request": "launch",
+ "port": 9003,
+ "pathMappings": {
+ "/var/www/html/wp-content/plugins/Spotmap": "${workspaceFolder}"
+ }
+ }
+ ]
+}
diff --git a/.wp-env.json b/.wp-env.json
new file mode 100644
index 0000000..22d91c4
--- /dev/null
+++ b/.wp-env.json
@@ -0,0 +1,21 @@
+{
+ "core": null,
+ "plugins": [ "." ],
+ "config": {
+ "WP_DEBUG": true,
+ "WP_DEBUG_LOG": true,
+ "SCRIPT_DEBUG": true
+ },
+ "env": {
+ "development": {
+ "config": {
+ "WP_DEBUG": true,
+ "WP_DEBUG_LOG": true,
+ "SCRIPT_DEBUG": true
+ }
+ }
+ },
+ "lifecycleScripts": {
+ "afterStart": "node examples/setup-dev.js"
+ }
+}
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..fc1dc79
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,128 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## What This Is
+
+Spotmap is a WordPress plugin that displays GPS tracking data from SPOT devices on interactive Leaflet maps. It provides a Gutenberg block and shortcodes for embedding maps in posts/pages.
+
+## Editing conventions
+
+- This project uses **4 spaces** for indentation throughout — never tabs.
+- When using the Edit tool, use 4-space indentation in `old_string`/`new_string`.
+- Run `npm run format` (JS/TS/CSS) and `npm run format:php` (PHP) to auto-format code.
+- add phpunit test with sample data to rename a feed - what happens if the feedname already exists? (this might wanted but on the UI we should get a warning that must be accepted)
+
+## Build Commands
+
+```bash
+# Development (watch mode, keeps source maps)
+npm run start
+
+# Production build
+npm run build
+
+# WordPress environment (Docker)
+npm run env:start
+npm run env:stop
+
+# After env:destroy + env:start, create the test DB once:
+npm run wp-env -- run cli bash -- -c "mysql -h mysql -u root -ppassword -e 'CREATE DATABASE IF NOT EXISTS wordpress_test'"
+
+# Tests (run inside the wp-env Docker container)
+npm run test:js # Jest unit tests (TypeScript/JS)
+npm run test:php # PHP unit tests (~31 s)
+npm run composer -- run test:coverage # PHP coverage report
+
+# Lint
+npm run lint:js
+npm run lint:css
+npm run lint:php
+
+# Format
+npm run format # JS/TS/CSS (Prettier via wp-scripts)
+npm run format:php # PHP (php-cs-fixer via wp-env; requires env:start + composer install)
+
+# Package plugin zip
+npm run plugin-zip
+```
+
+`npm run build` runs `copy-deps:prod` (strips source maps) then `wp-scripts build`.
+`npm run start` runs `copy-deps` (preserves source maps) then `wp-scripts start`.
+
+PHP tests live in `tests/` and run against a real WordPress environment (requires `npm run env:start`). JS tests in `src/**/__tests__/` run standalone via Jest.
+
+## Architecture Overview
+
+### PHP Backend (`includes/`, `admin/`, `public/`)
+
+| File | Role |
+|------|------|
+| `spotmap.php` | Entry point — instantiates `Spotmap` class |
+| `includes/class-spotmap.php` | Orchestrator — loads dependencies, registers hooks via `Spotmap_Loader` |
+| `includes/class-spotmap-options.php` | Admin options and marker defaults |
+| `includes/class-spotmap-database.php` | DB layer — table `wp_spotmap_points` |
+| `includes/class-spotmap-api-crawler.php` | Fetches data from SPOT API |
+| `admin/class-spotmap-admin.php` | Admin settings page |
+| `public/class-spotmap-public.php` | Enqueues scripts, registers shortcodes |
+| `public/render-block.php` | Server-side renderer for the Gutenberg dynamic block |
+
+Composer dependency `symfony/yaml` is vendor-prefixed under the `Spotmap\` namespace via Strauss.
+
+### Frontend Build (`src/`)
+
+Two webpack entry points (configured in `webpack.config.js`):
+
+1. **`src/spotmap/`** — Gutenberg block (React/JSX)
+ - `block.json` — block definition, 17 attributes, API v3, dynamic block (save returns null)
+ - `edit.jsx` — ~33KB React editor component with live preview
+ - Block is registered via `build/spotmap/` by `register_block_type()`
+
+2. **`src/map-engine/`** — TypeScript map engine compiled to `build/spotmap-map/index.js`
+ - `index.ts` — exposes `window.Spotmap`
+ - `Spotmap.ts` — main class, `initMap()` entry point
+ - `DataFetcher.ts`, `LayerManager.ts`, `MarkerManager.ts`, `LineManager.ts`, `BoundsManager.ts`, `ButtonManager.ts`, `TableRenderer.ts`
+ - `types.ts` — all TypeScript interfaces (`SpotmapOptions`, `SpotPoint`, `FeedStyle`, `GpxTrackConfig`, etc.)
+
+### Runtime Flow
+
+`render-block.php` outputs a `
` with an inline `';
-
- }
-
- public function show_spotmap_block($options){
- $options_json = wp_json_encode($options);
- // error_log("BLOCK init vals: ". $options_json);
- return '
- ';
- }
- public function show_spotmap($atts,$content = null){
- if(empty($atts)){
- $atts = [];
- }
- // error_log("Shortcode init vals: ".wp_json_encode($atts));
- // $atts['feeds'] = $atts['devices'];
- $a = array_merge(
- shortcode_atts( [
- 'height' => !empty( get_option('spotmap_default_values')['height'] ) ?get_option('spotmap_default_values')['height'] : 500,
- 'mapcenter' => !empty( get_option('spotmap_default_values')['mapcenter'] ) ?get_option('spotmap_default_values')['mapcenter'] : 'all',
- 'feeds' => $this->db->get_all_feednames(),
- 'width' => !empty(get_option('spotmap_default_values')['width']) ?get_option('spotmap_default_values')['width'] : 'normal',
- 'colors' => !empty(get_option('spotmap_default_values')['color']) ?get_option('spotmap_default_values')['color'] : 'blue,red',
- 'splitlines' => !empty(get_option('spotmap_default_values')['splitlines']) ?get_option('spotmap_default_values')['splitlines'] : '12',
- 'auto-reload' => FALSE,
- 'last-point' => FALSE,
- 'date-range-from' => NULL,
- 'date' => NULL,
- 'date-range-to' => NULL,
- 'gpx-name' => [],
- 'gpx-url' => [],
- 'gpx-color' => ['blue', 'gold', 'red', 'green', 'orange', 'yellow', 'violet'],
- 'maps' => !empty( get_option('spotmap_default_values')['maps'] ) ?get_option('spotmap_default_values')['maps'] : 'openstreetmap,opentopomap',
- 'map-overlays' => !empty( get_option('spotmap_default_values')['map-overlays'] ) ?get_option('spotmap_default_values')['map-overlays'] : NULL,
- 'filter-points' => !empty( get_option('spotmap_default_values')['filter-points'] ) ?get_option('spotmap_default_values')['filter-points'] : 5,
- 'debug'=> FALSE,
- ], $atts ),
- $atts);
- if (array_key_exists('feeds',$atts)){
- $a['feeds'] = $atts['feeds'];
- }
- // get the keys that don't require a value
- foreach (['auto-reload','debug','last-point',] as $value) {
- if(in_array($value,$atts)){
- if (array_key_exists($value,$atts) && !empty($atts[$value])){
- // if a real value was provided in the shortcode
- $a[$value] = $atts[$value];
- } else {
- $a[$value]=TRUE;
- }
- }
- }
-
- foreach (['feeds','splitlines','colors','gpx-name','gpx-url','gpx-color','maps','map-overlays',] as $value) {
- if(!empty($a[$value]) && !is_array($a[$value])){
- // error_log($a[$value]);
- $a[$value] = explode(',',$a[$value]);
- foreach ($a[$value] as $key => &$data) {
- if (empty($data)){
- unset($a[$value][$key]);
- }
- }
- }
- }
- // error_log(wp_json_encode($a));
- foreach ($atts as $key => &$values) {
- if(is_array($values)){
- foreach($values as &$entry){
- $entry =_sanitize_text_fields($entry);
- }
- } else {
- $values = _sanitize_text_fields($values);
- }
- }
-
- // valid inputs for feeds?
- $styles = [];
- if(!empty($a['feeds'])){
- $number_of_feeds = count($a['feeds']);
- $count_present_numbers = count($a['splitlines']);
- if($count_present_numbers < $number_of_feeds){
- $fillup_array = array_fill($count_present_numbers, $number_of_feeds - $count_present_numbers, $a['splitlines'][0]);
- $a['splitlines'] = array_merge($a['splitlines'],$fillup_array);
-
- // error_log(print_r($a['splitlines'],true));
- }
- if(count($a['colors']) < $number_of_feeds){
- $a['colors'] = array_fill(0,$number_of_feeds, $a['colors'][0]);
- }
- foreach ($a['feeds'] as $key => $value) {
- $styles[$value] = [
- 'color'=>$a['colors'][$key],
- 'splitLines' => $a['splitlines'][$key],
- ];
- }
- }
-
- // valid inputs for gpx tracks?
- $gpx = [];
- if(!empty($a['gpx-url'])){
- $number_of_tracks = count($a['gpx-url']);
- $count_present_numbers = count($a['gpx-color']);
- if($count_present_numbers < $number_of_tracks){
- $fillup_array = [];
- for ($i = $count_present_numbers; $i < $number_of_tracks; $i++) {
- $value = $a['gpx-color'][$i % $count_present_numbers];
- $a['gpx-color'] = array_merge($a['gpx-color'],[$value]);
- }
- }
- if(empty($a['gpx-name'])){
- $a['gpx-name'][0] = "GPX";
- }
- if(count($a['gpx-name']) < $number_of_tracks){
- $a['gpx-name'] = array_fill(0,$number_of_tracks, $a['gpx-name'][0]);
- }
- foreach ($a['gpx-url'] as $key => $url) {
- $name = $a['gpx-name'][$key];
- $gpx[] = [
- 'title' => $name,
- 'url' => $url,
- "color" => $a['gpx-color'][$key]
- ];
- }
- }
- $map_id = "spotmap-container-".mt_rand();
- // generate the option object for init the map
- $options = wp_json_encode([
- 'feeds' => $a['feeds'],
- 'filterPoints' => $a['filter-points'],
- 'styles' => $styles,
- 'gpx' => $gpx,
- 'date' => $a['date'],
- 'dateRange' => [
- 'from' => $a['date-range-from'],
- 'to' => $a['date-range-to']
- ],
- 'mapcenter' => $a['mapcenter'],
- 'maps' => $a['maps'],
- 'mapOverlays' => $a['map-overlays'],
- 'autoReload' => $a['auto-reload'],
- 'lastPoint' => $a['last-point'],
- 'debug' => $a['debug'],
- 'mapId' => $map_id
- ]);
- // error_log($options);
-
- $css ='height: '.$a['height'].'px;z-index: 0;';
- if($a['width'] == 'full'){
- $css .= "max-width: 100%;";
- }
-
- return '
-
- ';
- }
-
-
- public function get_positions(){
- // error_log(print_r($_POST,true));
- if(empty($_POST['feeds'])){
- wp_send_json(['error'=> false,'empty'=>true,'title'=>'No feeds defined','message'=> ""]);
- } else {
- $points = $this->db->get_points($_POST);
- if(empty($points)){
- $points = ['error'=> true,'empty'=>true,'title'=>'No points to show (yet)','message'=> ""];
- }
- error_log(wp_send_json($points));
- wp_send_json($points);
- }
- }
-
-}
+ [ 'spotmap', 'Spotmap' ],
+ 'show_point_overview' => [ 'spotmessages', 'Spotmessages' ],
+ ];
+
+ public $db;
+ public $admin;
+ private ?bool $enqueue_cache = null;
+
+ function __construct( $admin = null ) {
+ require_once plugin_dir_path( dirname( __FILE__ ) ) . 'includes/class-spotmap-database.php';
+ require_once plugin_dir_path( dirname( __FILE__ ) ) . 'includes/class-spotmap-options.php';
+ $this->db = new Spotmap_Database();
+ $this->admin = $admin ?? new Spotmap_Admin();
+ }
+
+ private function should_enqueue(): bool {
+ if ( $this->enqueue_cache !== null ) {
+ return $this->enqueue_cache;
+ }
+ global $post;
+ if ( ! is_a( $post, 'WP_Post' ) ) {
+ return $this->enqueue_cache = false;
+ }
+ if ( has_block( 'spotmap/spotmap', $post ) ) {
+ return $this->enqueue_cache = true;
+ }
+ foreach ( self::SHORTCODE_TAGS as $tags ) {
+ foreach ( $tags as $tag ) {
+ if ( has_shortcode( $post->post_content, $tag ) ) {
+ return $this->enqueue_cache = true;
+ }
+ }
+ }
+ return $this->enqueue_cache = false;
+ }
+
+ public function enqueue_styles() {
+ if ( ! $this->should_enqueue() ) {
+ return;
+ }
+ wp_enqueue_style( 'leaflet', plugin_dir_url( __FILE__ ) . 'leaflet/leaflet.css');
+ wp_enqueue_style( 'custom', plugin_dir_url( __FILE__ ) . 'css/custom.css');
+ wp_enqueue_style( 'leaflet-fullscreen', plugin_dir_url( __FILE__ ) . 'leafletfullscreen/leaflet.fullscreen.css');
+ wp_enqueue_style( 'leaflet-easybutton', plugin_dir_url( __FILE__ ) . 'leaflet-easy-button/easy-button.css');
+ // wp_enqueue_style( 'dashicon', '/wp-includes/css/dashicons.css');
+ wp_enqueue_style( 'font-awesome', plugin_dir_url( __DIR__ ). 'includes/css/font-awesome-all.min.css');
+ wp_enqueue_style( 'leaflet-beautify-marker', plugin_dir_url( __FILE__ ) . 'leaflet-beautify-marker/leaflet-beautify-marker-icon.css');
+ }
+
+ public function register_block(){
+ $block_path = plugin_dir_path( dirname( __FILE__ ) ) . 'build/spotmap';
+ register_block_type( $block_path );
+ }
+
+ public function enqueue_block_editor_assets(){
+ $this->enqueue_scripts();
+ $this->enqueue_styles();
+ $this->localize_js_script( 'spotmap-spotmap-editor-script' );
+ }
+
+ public function enqueue_scripts(){
+ if ( ! $this->should_enqueue() ) {
+ return;
+ }
+ // wp_enqueue_script('spotmap-handler', plugins_url('js/maphandler.js', __FILE__), ['jquery','moment','lodash'], false, true);
+ $map_asset_file = plugin_dir_path( dirname( __FILE__ ) ) . 'build/spotmap-map.asset.php';
+ $map_asset = file_exists( $map_asset_file ) ? include $map_asset_file : [ 'dependencies' => [], 'version' => false ];
+ wp_enqueue_script(
+ 'spotmap-handler',
+ plugin_dir_url( dirname( __FILE__ ) ) . 'build/spotmap-map.js',
+ array_merge(
+ $map_asset['dependencies'],
+ [ 'jquery', 'leaflet', 'leaflet-fullscreen', 'leaflet-gpx', 'leaflet-easybutton', 'leaflet-beautify-marker', 'leaflet-text-path' ]
+ ),
+ $map_asset['version'],
+ true
+ );
+ $this->localize_js_script('spotmap-handler');
+ wp_enqueue_script('leaflet', plugins_url( 'leaflet/leaflet.js', __FILE__ ));
+ wp_enqueue_script('leaflet-fullscreen',plugin_dir_url( __FILE__ ) . 'leafletfullscreen/leaflet.fullscreen.js');
+ wp_enqueue_script('leaflet-gpx',plugin_dir_url( __FILE__ ) . 'leaflet-gpx/gpx.js');
+ wp_enqueue_script('leaflet-easybutton',plugin_dir_url( __FILE__ ) . 'leaflet-easy-button/easy-button.js');
+ wp_enqueue_script('leaflet-swisstopo',plugin_dir_url( __FILE__ ) . 'leaflet-tilelayer-swisstopo/Leaflet.TileLayer.Swiss.umd.js');
+ wp_enqueue_script('leaflet-beautify-marker', plugin_dir_url( __FILE__ ) . 'leaflet-beautify-marker/leaflet-beautify-marker-icon.js');
+ wp_enqueue_script('leaflet-text-path',plugin_dir_url( __FILE__ ) . 'leaflet-textpath/leaflet.textpath.js');
+
+ }
+
+ function localize_js_script($script_slug){
+ $default_values = Spotmap_Options::get_settings();
+ wp_localize_script($script_slug, 'spotmapjsobj', [
+ 'ajaxUrl' => admin_url( 'admin-ajax.php' ),
+ 'maps' => $this->admin->get_maps(),
+ 'overlays' => $this->admin->get_overlays(),
+ 'url' => plugin_dir_url( __FILE__ ),
+ 'feeds' => $this->db->get_all_feednames(),
+ 'defaultValues' => $default_values,
+ 'marker' => Spotmap_Options::get_marker_options(),
+
+ ]);
+ }
+
+ public function register_shortcodes(){
+ foreach ( self::SHORTCODE_TAGS as $method => $tags ) {
+ foreach ( $tags as $tag ) {
+ add_shortcode( $tag, [ $this, $method ] );
+ }
+ }
+ }
+ function show_point_overview($atts){
+ // error_log("Shortcode init vals: ".wp_json_encode($atts));
+ $default_filter_points = Spotmap_Options::get_setting('filter-points');
+ $a = array_merge(
+ shortcode_atts([
+ 'count'=> 10,
+ 'types'=>'HELP,HELP-CANCEL,OK,CUSTOM',
+ 'feeds' => $this->db->get_all_feednames(),
+ 'group'=>'',
+ 'date-range-from' => '',
+ 'date' => '',
+ 'date-range-to' => '',
+ 'auto-reload' => FALSE,
+ 'filter-points' => $default_filter_points,
+ ], $atts),
+ $atts);
+ // get the keys that don't require a value
+ if(array_key_exists('auto-reload',$atts)){
+ $a['auto-reload']=TRUE;
+ }
+ // error_log("Shortcode after vals: ".wp_json_encode($a));
+ foreach (['types','feeds'] as $value) {
+ if(!empty($a[$value]) && !is_array($a[$value])){
+ // error_log($a[$value]);
+ $a[$value] = explode(',',$a[$value]);
+ }
+ }
+ foreach ($a as $key => &$values) {
+ if(is_array($values)){
+ foreach($values as &$entry){
+ $entry =_sanitize_text_fields($entry);
+ }
+ } else {
+ $values = _sanitize_text_fields($values);
+ }
+ }
+
+ $options = [
+ 'select' => "type,id,message,local_timezone,feed_name, time",
+ 'type'=>$a['types'],
+ 'filterPoints' => $a['filter-points'],
+ 'feeds' => $a['feeds'],
+ 'dateRange' => [
+ 'from' => $a['date-range-from'],
+ 'to' => $a['date-range-to']
+ ],
+ 'orderBy' => "time DESC",
+ 'limit' => $a['count'],
+ 'groupBy' => $a['group'],
+ 'autoReload' => $a['auto-reload'],
+ ];
+ $table_id = "spotmap-table-".mt_rand();
+ return '
+
+ ';
+
+ }
+
+ public function show_spotmap_block($options){
+ $options_json = wp_json_encode($options);
+ // error_log("BLOCK init vals: ". $options_json);
+ return '
+ ';
+ }
+ public function show_spotmap($atts,$content = null){
+ if(empty($atts)){
+ $atts = [];
+ }
+ // error_log("Shortcode init vals: ".wp_json_encode($atts));
+ // $atts['feeds'] = $atts['devices'];
+ $defaults = Spotmap_Options::get_settings();
+ $a = array_merge(
+ shortcode_atts( [
+ 'height' => $defaults['height'],
+ 'mapcenter' => $defaults['mapcenter'],
+ 'feeds' => $this->db->get_all_feednames(),
+ 'width' => $defaults['width'],
+ 'colors' => $defaults['color'],
+ 'splitlines' => $defaults['splitlines'],
+ 'auto-reload' => FALSE,
+ 'last-point' => FALSE,
+ 'date-range-from' => NULL,
+ 'date' => NULL,
+ 'date-range-to' => NULL,
+ 'gpx-name' => [],
+ 'gpx-url' => [],
+ 'gpx-color' => ['blue', 'gold', 'red', 'green', 'orange', 'yellow', 'violet'],
+ 'maps' => $defaults['maps'],
+ 'map-overlays' => $defaults['map-overlays'],
+ 'filter-points' => $defaults['filter-points'],
+ 'debug'=> FALSE,
+ 'locate-button' => FALSE,
+ 'fullscreen-button' => TRUE,
+ 'navigation-buttons' => TRUE,
+ ], $atts ),
+ $atts);
+ if (array_key_exists('feeds',$atts)){
+ $a['feeds'] = $atts['feeds'];
+ }
+ // get the keys that don't require a value
+ foreach (['auto-reload','debug','last-point',] as $value) {
+ if(in_array($value,$atts)){
+ if (array_key_exists($value,$atts) && !empty($atts[$value])){
+ // if a real value was provided in the shortcode
+ $a[$value] = $atts[$value];
+ } else {
+ $a[$value]=TRUE;
+ }
+ }
+ }
+
+ foreach (['feeds','splitlines','colors','gpx-name','gpx-url','gpx-color','maps','map-overlays',] as $value) {
+ if(!empty($a[$value]) && !is_array($a[$value])){
+ // error_log($a[$value]);
+ $a[$value] = explode(',',$a[$value]);
+ foreach ($a[$value] as $key => &$data) {
+ if (empty($data)){
+ unset($a[$value][$key]);
+ }
+ }
+ }
+ }
+ // error_log(wp_json_encode($a));
+ foreach ($atts as $key => &$values) {
+ if(is_array($values)){
+ foreach($values as &$entry){
+ $entry =_sanitize_text_fields($entry);
+ }
+ } else {
+ $values = _sanitize_text_fields($values);
+ }
+ }
+
+ // valid inputs for feeds?
+ $styles = [];
+ if(!empty($a['feeds'])){
+ $number_of_feeds = count($a['feeds']);
+ $count_present_numbers = count($a['splitlines']);
+ if($count_present_numbers < $number_of_feeds){
+ $fillup_array = array_fill($count_present_numbers, $number_of_feeds - $count_present_numbers, $a['splitlines'][0]);
+ $a['splitlines'] = array_merge($a['splitlines'],$fillup_array);
+
+ // error_log(print_r($a['splitlines'],true));
+ }
+ if(count($a['colors']) < $number_of_feeds){
+ $a['colors'] = array_fill(0,$number_of_feeds, $a['colors'][0]);
+ }
+ foreach ($a['feeds'] as $key => $value) {
+ $styles[$value] = [
+ 'color'=>$a['colors'][$key],
+ 'splitLines' => $a['splitlines'][$key],
+ ];
+ }
+ }
+
+ // If last-point is set, mark it on every feed style so the engine highlights
+ // the latest point — matching the per-feed lastPoint toggle in the block editor.
+ if ( ! empty( $a['last-point'] ) ) {
+ foreach ( $styles as &$style ) {
+ $style['lastPoint'] = true;
+ }
+ unset( $style );
+ }
+
+ // valid inputs for gpx tracks?
+ $gpx = [];
+ if(!empty($a['gpx-url'])){
+ $number_of_tracks = count($a['gpx-url']);
+ $count_present_numbers = count($a['gpx-color']);
+ if($count_present_numbers < $number_of_tracks){
+ $fillup_array = [];
+ for ($i = $count_present_numbers; $i < $number_of_tracks; $i++) {
+ $value = $a['gpx-color'][$i % $count_present_numbers];
+ $a['gpx-color'] = array_merge($a['gpx-color'],[$value]);
+ }
+ }
+ if(empty($a['gpx-name'])){
+ $a['gpx-name'][0] = "GPX";
+ }
+ if(count($a['gpx-name']) < $number_of_tracks){
+ $a['gpx-name'] = array_fill(0,$number_of_tracks, $a['gpx-name'][0]);
+ }
+ foreach ($a['gpx-url'] as $key => $url) {
+ $name = $a['gpx-name'][$key];
+ $gpx[] = [
+ 'title' => $name,
+ 'url' => $url,
+ "color" => $a['gpx-color'][$key]
+ ];
+ }
+ }
+ // If a single date is given, expand it to a full-day dateRange so the engine
+ // receives only dateRange (matching the block renderer).
+ if ( ! empty( $a['date'] ) && empty( $a['date-range-from'] ) && empty( $a['date-range-to'] ) ) {
+ $parsed = date_create( $a['date'] );
+ if ( $parsed !== null ) {
+ $day = date_format( $parsed, 'Y-m-d' );
+ $a['date-range-from'] = $day . ' 00:00:00';
+ $a['date-range-to'] = $day . ' 23:59:59';
+ }
+ }
+ $map_id = "spotmap-container-".mt_rand();
+ // generate the option object for init the map
+ $options = wp_json_encode([
+ 'feeds' => $a['feeds'],
+ 'filterPoints' => $a['filter-points'],
+ 'styles' => $styles,
+ 'gpx' => $gpx,
+ 'dateRange' => [
+ 'from' => $a['date-range-from'],
+ 'to' => $a['date-range-to']
+ ],
+ 'mapcenter' => $a['mapcenter'],
+ 'maps' => $a['maps'],
+ 'mapOverlays' => $a['map-overlays'],
+ 'autoReload' => $a['auto-reload'],
+ 'debug' => $a['debug'],
+ 'locateButton' => (bool) $a['locate-button'],
+ 'fullscreenButton' => (bool) $a['fullscreen-button'],
+ 'navigationButtons' => $a['navigation-buttons'] ? [ 'enabled' => true, 'allPoints' => true, 'latestPoint' => true, 'gpxTracks' => true ] : [ 'enabled' => false ],
+ 'mapId' => $map_id
+ ]);
+ // error_log($options);
+
+ $css ='height: '.$a['height'].'px;z-index: 0;';
+ if($a['width'] == 'full'){
+ $css .= "max-width: 100%;";
+ }
+
+ return '
+
+ ';
+ }
+
+
+ public function get_positions(){
+ // error_log(print_r($_POST,true));
+ if(empty($_POST['feeds'])){
+ wp_send_json(['error'=> false,'empty'=>true,'title'=>'No feeds defined','message'=> ""]);
+ } else {
+ $points = $this->db->get_points($_POST);
+ if(empty($points)){
+ $points = ['error'=> true,'empty'=>true,'title'=>'No points to show (yet)','message'=> ""];
+ }
+ wp_send_json($points);
+ }
+ }
+
+}
diff --git a/public/css/custom.css b/public/css/custom.css
deleted file mode 100644
index 43ceb02..0000000
--- a/public/css/custom.css
+++ /dev/null
@@ -1,50 +0,0 @@
-/* map toggle work with twenty twenty template */
-
-.leaflet-control-layers-list {
- padding: 0;
-}
-.leaflet-control-zoom-in .leaflet-control-zoom-out {
- text-decoration: none;
-}
-
-
-
-/*style the spot message table*/
-
-tr.spotmap td:first-child {
- width:7em;
- cursor: pointer;
-}
-
-tr.spotmap td:last-child {
- width:7em;
-}
-
-tr.spotmap.OK td:first-child,
-tr.spotmap.HELP-CANCEL td:first-child,
-tr.spotmap.STATUS td:first-child {
- background-color: rgb(142, 223, 89,0.85);
- border-color: rgba(102, 255, 0, 0.85);
-}
-tr.spotmap.HELP td:first-child{
- background-color: rgb(255, 0, 0.85);
- border-color: rgb(255, 0, 0.85);
-}
-tr.spotmap.CUSTOM td:first-child{
- background-color: rgb(255, 255, 0.85);
- border-color: rgb(255, 255, 0.85);
-}
-
-
-
-div.easy-button-container > button.easy-button-button.leaflet-bar-part {
- background-color: white;
- padding: 0px;
-}
-
-
-span.button-state.state-all.all-active > .dashicons {
- position: absolute;
- top: 4px;
- left: 5px;
-}
\ No newline at end of file
diff --git a/public/js/block.js b/public/js/block.js
deleted file mode 100644
index 7220cd0..0000000
--- a/public/js/block.js
+++ /dev/null
@@ -1,475 +0,0 @@
-// block.js
-(function (blocks, element, i18n, blockEditor, components, compose) {
- var el = element.createElement;
- const { __ } = i18n;
- const { InspectorControls, MediaUpload } = blockEditor;
- const { FormTokenField } = components;
- const { SelectControl, TextControl, Button, ToggleControl, ColorPalette, PanelBody, PanelRow, DateTimePicker, RadioGroup, UnitControl, } = components;
-
- blocks.registerBlockType('spotmap/spotmap', {
- title: 'Spotmap',
- supports: {
- align: ['full', 'wide']
- },
- icon: 'location-alt',
- category: 'embed',
- edit: function (props) {
- // if block has just been created
- if (!props.attributes.height) {
- let mapId = 'spotmap-container-' + Math.random() * 10E17;
- let defaultProps = {
- mapId: mapId,
- maps: ['opentopomap', 'openstreetmap',],
- feeds: spotmapjsobj.feeds,
- styles: lodash.zipObject(spotmapjsobj.feeds, lodash.fill(new Array(spotmapjsobj.feeds.length), { color: 'blue', splitLines: '0' })),
- autoReload: false,
- debug: false,
- lastPoint: false,
- filterPoints: '10',
- height: '500',
- dateRange: { to: '', from: '', },
- mapcenter: 'all',
- gpx: [],
- };
- props.setAttributes(defaultProps);
- return [el('div', {
- id: mapId,
- style: {
- class: 'align' + props.attributes.align,
- 'z-index': 0,
- },
- }, ''
- ),]
- }
- var spotmap = new Spotmap(props.attributes);
- try {
- setTimeout(function () {
- spotmap.initMap();
- }, 500);
- } catch (e) {
- console.log(e)
- }
- return [el('div', {
- id: props.attributes.mapId,
- style: {
- 'height': props.attributes.height + 'px',
- class: 'align' + props.attributes.align,
- 'z-index': 0,
- },
- }, ''
- ),
- el(InspectorControls, {},
- generalSettings(props),
- feedPanel(props),
- gpxPanel(props),
- el(PanelBody, { title: 'Experimental Settings', initialOpen: false },
- // /* Toggle Field TODO: use form toggle instead
- el(PanelRow, {},
- el(ToggleControl,
- {
- label: 'Show Last Point',
- onChange: (value) => {
- props.setAttributes({ lastPoint: value });
- },
- checked: props.attributes.lastPoint,
- help: "Show the latest point as a big marker.",
- }
- )
- ),
- el(TextControl,
- {
- label: 'Hide nearby points',
- onChange: (value) => {
- props.setAttributes({ filterPoints: value });
- },
- value: props.attributes.filterPoints,
- help: "Try to reduce point cluster by only showing the latest point per type. Input a radius in meter."
- }
- ),
- el(PanelRow, {},
- el(ToggleControl,
- {
- label: 'automatic reload',
- onChange: (value) => {
- props.setAttributes({ 'autoReload': value });
- },
- checked: props.attributes["autoReload"],
- help: "If enabled this will update the map without reloading the whole webpage. Not tested very much. Will have unexpected results with 'Last Point'"
- }
- )
- ),
- el(PanelRow, {},
- el(ToggleControl,
- {
- label: 'Debug',
- onChange: (value) => {
- props.setAttributes({ debug: value });
- },
- checked: props.attributes.debug,
- }
- )
- ),
- ),
- )]
- },
- attributes: {
- maps: {
- type: 'array',
- },
- feeds: {
- type: 'array',
- },
- styles: {
- type: 'object',
- },
- dateRange: {
- type: 'object',
- },
- gpx: {
- type: 'array',
- },
- mapcenter: {
- type: 'string',
- },
- height: {
- type: 'string',
- },
- filterPoints: {
- type: 'string',
- },
- debug: {
- type: 'boolean',
- },
- lastPoint: {
- type: 'string',
- },
- autoReload: {
- type: 'boolean',
- },
- mapId: {
- type: 'string',
- },
- },
- keywords: ['findmespot', 'spot', 'gps', 'spotmap', 'gpx', __('map')],
- });
-
- function generalSettings(props) {
- let panels = [];
- let general = el(PanelBody, { title: __('General Settings'), initialOpen: false },
- el(PanelRow, {},
- el(FormTokenField, {
- label: "Feeds",
- suggestions: Object.keys(spotmapjsobj.feeds),
- onChange: (value) => {
- props.setAttributes({ feeds: value });
- },
- value: props.attributes.feeds,
- })
- ),
-
- el(PanelRow, {},
- el(FormTokenField, {
- label: "maps",
- suggestions: Object.keys(spotmapjsobj.maps),
- onChange: (value) => {
- props.setAttributes({ maps: value });
- },
- value: props.attributes.maps,
- help: "test"
- })
- ),
-
- /* Text Field */
- el(PanelRow, {},
- el(SelectControl,
- {
- label: 'Zoom to',
- onChange: (value) => {
- props.setAttributes({ mapcenter: value });
- },
- value: props.attributes.mapcenter,
- options: [
- { label: 'all points', value: 'all' },
- { label: 'last trip', value: 'last-trip' },
- { label: 'latest point', value: 'last' },
- { label: 'GPX tracks', value: 'gpx' }
- ],
- labelPosition: "side",
-
- }
- )
- ),
-
- /* Text Field */
- el(PanelRow, {},
- el(TextControl,
- {
- label: 'height',
- onChange: (value) => {
- props.setAttributes({ height: value });
- },
- value: props.attributes.height
- }
- )
- ),
- );
- panels.push(general);
- let options = [
- { label: 'don\'t filter', value: '' },
- { label: 'last week', value: 'last-1-week' },
- { label: 'last 10 days', value: 'last-10-days' },
- { label: 'last 2 weeks', value: 'last-2-weeks' },
- { label: 'last month', value: 'last-1-month' },
- { label: 'last year', value: 'last-1-year' },
- { label: 'a specific date', value: 'specific' },
- ];
- // if option is set to sth else (aka custom date)
- if (!lodash.findKey(options, function (o) { return o.value === props.attributes.dateRange.from })) {
- options[lodash.last(options)] = { label: 'choose new date', value: 'specific' };
- options.push({ label: props.attributes.dateRange.from, value: props.attributes.dateRange.from })
- }
- let dateFrom = [
- el(PanelRow, {},
- el(SelectControl,
- {
- label: 'Show points from',
- onChange: (value) => {
- let returnArray = lodash.cloneDeep(props.attributes.dateRange);
- returnArray.from = value;
- props.setAttributes({ dateRange: returnArray });
- },
- value: props.attributes.dateRange.from,
- options: options,
- labelPosition: "side",
- }
- )
- ),];
-
- if (props.attributes.dateRange.from === 'specific' || !lodash.findKey(options, function (o) { return o.value === props.attributes.dateRange.from })) {
- dateFrom.push(
- el(DateTimePicker,
- {
- onChange: (currentDate) => {
- console.log(currentDate);
- let returnArray = lodash.cloneDeep(props.attributes.dateRange);
- returnArray.from = currentDate;
- props.setAttributes({ dateRange: returnArray });
- },
- currentDate: new Date(),
- }
- )
- )
- }
-
- options = [
- { label: 'don\'t filter', value: '' },
- { label: 'last 30 minutes', value: 'last-30-minutes' },
- { label: 'last hour', value: 'last-1-hour' },
- { label: 'last 2 hours', value: 'last-2-hour' },
- { label: 'last day', value: 'last-1-day' },
- { label: 'a specific date', value: 'specific' },
- ];
- if (!lodash.findKey(options, function (o) { return o.value === props.attributes.dateRange.to })) {
- options.push({ label: props.attributes.dateRange.to, value: props.attributes.dateRange.to })
- }
- let dateTo = [
- el(PanelRow, {},
- el(SelectControl,
- {
- label: 'Show points to',
- onChange: (value) => {
- let returnArray = lodash.cloneDeep(props.attributes.dateRange);
- returnArray.to = value;
- props.setAttributes({ dateRange: returnArray });
- },
- value: props.attributes.dateRange.to,
- options: options,
- labelPosition: "side",
- }
- )
- ),];
-
- if (props.attributes.dateRange.to === 'specific') {
- dateTo.push(
- el(DateTimePicker,
- {
- onChange: (currentDate) => {
- console.log(currentDate);
- let returnArray = lodash.cloneDeep(props.attributes.dateRange);
- returnArray.to = currentDate;
- props.setAttributes({ dateRange: returnArray });
- },
- currentDate: new Date(),
- }
- )
- )
- }
- panels.push(el(PanelBody, { title: 'Time filter of points', initialOpen: false }, dateFrom, dateTo));
- return panels;
- }
-
- function feedPanel(props) {
- let panel;
- let panels = [];
- if (!props.attributes.feeds) {
- return [];
- }
- // console.log(props)
- for (let i = 0; i < props.attributes.feeds.length; i++) {
- const feed = props.attributes.feeds[i];
- // console.log(feed);
- let options = [];
- if (!props.attributes.styles[feed]) {
- let returnArray = lodash.cloneDeep(props.attributes.styles);
- returnArray[feed] = { color: 'blue', splitLines: 12 };
- props.setAttributes({ styles: returnArray });
- }
- options.push(el(PanelRow, {},
- el(ColorPalette, {
- label: "Colors",
- colors: [
- { name: "black", color: "black" },
- { name: "blue", color: "blue" },
- { name: "gold", color: "gold" },
- { name: "green", color: "green" },
- { name: "grey", color: "grey" },
- { name: "red", color: "red" },
- { name: "violet", color: "violet" },
- { name: "yellow", color: "yellow" },
- ],
- onChange: (value) => {
- let returnArray = lodash.cloneDeep(props.attributes.styles);
- console.log(value, returnArray)
- returnArray[feed]['color'] = value;
- props.setAttributes({ styles: returnArray });
- },
- value: props.attributes.styles[feed]['color'] || 'blue',
- disableCustomColors: true,
- })
- ),
-
- // /* Toggle Field TODO: use form toggle instead
- el(PanelRow, {},
- el(ToggleControl,
- {
- label: 'connect points wih line',
- onChange: (value) => {
- let returnArray = lodash.cloneDeep(props.attributes.styles);
- console.log(value, returnArray)
- returnArray[feed]['splitLinesEnabled'] = value;
-
- if (value && !returnArray[feed]['splitLines']) {
- returnArray[feed]['splitLines'] = 12;
- }
- props.setAttributes({ styles: returnArray });
- },
- checked: props.attributes.styles[feed]['splitLinesEnabled'],
- }
- )
- ));
-
- if (props.attributes.styles[feed]['splitLinesEnabled'] === true) {
- options.push(
- el(PanelRow, {},
- el(TextControl,
- {
- label: 'Splitlines',
- onChange: (value) => {
- let returnArray = lodash.cloneDeep(props.attributes.styles);
- console.log(value, returnArray)
- returnArray[feed]['splitLines'] = value;
- props.setAttributes({ styles: returnArray });
- },
- value: props.attributes.styles[feed]['splitLines'],
- }
- )
- ))
- }
- panel = el(PanelBody, { title: feed + ' Feed', initialOpen: false }, options);
-
-
- panels.push(panel);
-
- }
- return panels;
- }
- function gpxPanel(props) {
- let panels = [];
- if (!props.attributes.feeds) {
- return [];
- }
-
- // console.log(feed);
- let options = [];
-
- options.push(
- el(PanelRow, {},
- el(MediaUpload, {
- allowedTypes: ['text/xml'],
- multiple: true,
- value: props.attributes.gpx.map(entry => entry.id),
- title: "Choose gpx tracks (Hint: press ctrl to select multiple)",
- onSelect: function (gpx) {
- let returnArray = [];
- lodash.forEach(gpx, (track) => {
- track = lodash.pick(track, ['id', 'url', 'title']);
- returnArray.push(track);
- })
- props.setAttributes({ gpx: returnArray });
- },
- render: function (callback) {
- return el(Button,
- {
- onClick: callback.open,
- isPrimary: true,
- },
- "Select from Media Library"
- )
- }
- })
- ),
- el(PanelRow, {},
- el("em", {}, "Select a color:"),
- el(PanelRow, {},
- el(ColorPalette, {
- label: "Colors",
- colors: [
- { name: "blue", color: "blue" },
- { name: "gold", color: "gold" },
- { name: "green", color: "green" },
- { name: "red", color: "red" },
- { name: "black", color: "black" },
- { name: "violet", color: "violet" },
- ],
- onChange: (value) => {
- let returnArray = [];
- let gpx = lodash.cloneDeep(props.attributes.gpx);
- lodash.forEach(gpx, (track) => {
- track.color = value;
- returnArray.push(track);
- })
- props.setAttributes({ gpx: returnArray });
- },
- value: props.attributes.gpx[0] ? props.attributes.gpx[0].color : 'gold',
- disableCustomColors: false,
- clearable: false,
- })
- ))
- );
-
-
- panels.push(el(PanelBody, { title: 'GPX', initialOpen: false }, options));
-
- return panels;
- }
-
-})(
- window.wp.blocks,
- window.wp.element,
- window.wp.i18n,
- window.wp.blockEditor,
- window.wp.components,
- window.wp.compose,
-);
-
-
diff --git a/public/js/maphandler.js b/public/js/maphandler.js
deleted file mode 100644
index 7cb83dd..0000000
--- a/public/js/maphandler.js
+++ /dev/null
@@ -1,713 +0,0 @@
-class Spotmap {
- constructor(options) {
- if (!options.maps) {
- console.error("Missing important options!!");
- }
- this.options = options;
- this.mapcenter = {};
- this.debug("Spotmap obj created.");
- this.debug(this.options);
- this.map = {};
- this.layerControl = L.control.layers({},{},{hideSingleBase: true});
- this.layers = {
- feeds: {},
- gpx: {},
- };
- }
-
- doesFeedExists(feedName){
- return this.layers.feeds.hasOwnProperty(feedName)
- }
- initMap() {
- jQuery('#' + this.options.mapId).height(this.options.height);
- var self = this;
-
- let oldOptions = jQuery('#' + this.options.mapId).data('options');
- jQuery('#' + this.options.mapId).data('options', this.options);
- var container = L.DomUtil.get(this.options.mapId);
- if (container != null) {
- if (!lodash.isEqual(this.options, oldOptions)) {
- // https://github.com/Leaflet/Leaflet/issues/3962
- container._leaflet_id = null;
- jQuery('#' + this.options.mapId + " > .leaflet-control-container").empty();
- jQuery('#' + this.options.mapId + " > .leaflet-pane").empty();
- } else {
- return 0;
- }
- }
-
- var mapOptions = {
- fullscreenControl: true,
- scrollWheelZoom: false,
- attributionControl: false,
- };
- this.map = L.map(this.options.mapId, mapOptions);
- L.control.scale().addTo(this.map);
- // use no prefix in attribution
- L.control.attribution({prefix: ''}).addTo(this.map);
- // enable scrolling with mouse once the map was focused
- this.map.once('focus', function () { self.map.scrollWheelZoom.enable(); });
-
- self.getOption('maps');
- this.addButtons();
-
- // define obj to post data
- let body = {
- 'action': 'get_positions',
- 'select': "*",
- 'feeds': '',
- 'date-range': this.options.dateRange,
- 'date': this.options.date,
- 'orderBy': 'feed_name, time',
- 'groupBy': '',
- }
- if (this.options.feeds) {
- body.feeds = this.options.feeds;
- }
- self.layerControl.addTo(self.map);
- this.getPoints(function (response) {
- // console.log(response);
- // this is the case if explicitly no feeds were provided
- if(!response.empty){
- // loop thru the data received from server
- response.forEach(function (entry, index) {
- this.addPoint(entry);
- this.addPointToLine(entry);
- }, self);
-
- }
- if (self.options.gpx) {
-
- for (var i = 0; i < self.options.gpx.length; i++) {
- let entry = self.options.gpx[i];
- let title = self.options.gpx[i].title;
- let color = self.getOption('color', { gpx: entry });
- let gpxOption = {
- async: true,
- marker_options: {
- wptIcons: {
- '': self.getMarkerIcon({color: color}),
- },
- wptIconsType: {
- '': self.getMarkerIcon({color: color}),
- },
- startIconUrl: '', endIconUrl: '',
- shadowUrl: spotmapjsobj.url + 'leaflet-gpx/pin-shadow.png',
- },
- polyline_options: {
- 'color': color,
- }
- }
-
- let track = new L.GPX(entry.url, gpxOption).on('loaded', function (e) {
- // if last track
- if (self.options.mapcenter == 'gpx' || response.empty) {
- self.setBounds('gpx');
- }
- }).on('addline', function (e) {
- e.line.bindPopup(title);
- });
- let html = ' ' + self.getColorDot(color);
- self.layers.gpx[title] = {
- featureGroup: L.featureGroup([track])
- };
- self.layers.gpx[title].featureGroup.addTo(self.map)
- self.layerControl.addOverlay(self.layers.gpx[title].featureGroup,title + html);
-
- }
- }
- // add feeds to layercontrol
- lodash.forEach(self.layers.feeds, function(value, key) {
- self.layers.feeds[key].featureGroup.addTo(self.map);
-
- if (self.layers.feeds.length + self.options.gpx.length == 1){
- self.layerControl.addOverlay(self.layers.feeds[key].featureGroup,key);
- }
- else {
- let color = self.getOption('color', { 'feed': key })
- let label = key + ' ' + self.getColorDot(color)
- // if last element and overlays exists
- // label += '
'
- self.layerControl.addOverlay(self.layers.feeds[key].featureGroup, label)
- }
-
- });
- if(response.empty && self.options.gpx.length == 0){
- self.map.setView([51.505, -0.09], 13)
- var popup = L.popup()
- .setLatLng([51.513, -0.09])
- .setContent("There is nothing to show here yet.")
- .openOn(self.map);
- }
- else {
- self.setBounds(self.options.mapcenter);
- }
-
- // TODO merge displayOverlays
- self.getOption('mapOverlays');
-
- // if (Object.keys(displayOverlays).length == 1) {
- // displayOverlays[Object.keys(displayOverlays)[0]].addTo(self.map);
- // if (Object.keys(baseLayers).length > 1)
- // L.control.layers(baseLayers,{},{hideSingleBase: true}).addTo(self.map);
- // } else {
- // L.control.layers(baseLayers, displayOverlays,{hideSingleBase: true}).addTo(self.map);
- // self.layerControl.addOverlay(self.layers.feeds[key].featureGroup, label)
- // }
- // self.map.on('baselayerchange', self.onBaseLayerChange(event));
-
- if (self.options.autoReload == true && !response.empty) {
- var refresh = setInterval(function () {
- body.groupBy = 'feed_name';
- body.orderBy = 'time DESC';
- self.getPoints(function (response) {
- if (response.error) {
- return;
- }
- response.forEach(function (entry, index) {
- let feedName = entry.feed_name;
- let lastPoint = lodash.last(self.layers.feeds[feedName].points)
- if (lastPoint.unixtime < entry.unixtime) {
- self.debug("Found a new point for Feed: " + feedName);
- self.addPoint(entry);
- self.addPointToLine(entry);
-
- if (self.options.mapcenter == 'last') {
- self.map.setView([entry.latitude, entry.longitude], 14);
- }
- }
- });
-
- }, { body: body, filter: self.options.filterPoints });
- }, 30000);
- }
- }, { body: body, filter: this.options.filterPoints });
- }
-
- getOption(option, config) {
- if (!config) {
- config = {};
- }
- if (option == 'maps') {
- if (this.options.maps) {
- let firstmap = true;
- for (let mapName in this.options.maps) {
- mapName = this.options.maps[mapName];
- let layer;
- if (lodash.keys(spotmapjsobj.maps).includes(mapName)) {
- let map = spotmapjsobj.maps[mapName];
- if (map.wms) {
- layer = L.tileLayer.wms(map.url, map.options);
- } else {
- layer = L.tileLayer(map.url, map.options);
- }
- this.layerControl.addBaseLayer(layer, map.label);
- }
- // if (this.options.maps.includes('swisstopo')) {
- // layer = L.tileLayer.swiss()
- // this.layerControl.addBaseLayer(layer, 'swissTopo');
- // L.Control.Layers.prototype._checkDisabledLayers = function () { };
- // }
- if(firstmap && layer){
- firstmap = false;
- layer.addTo(this.map);
- }
-
- }
- // if (lodash.startsWith(this.options.maps[0], "swiss") && self.map.options.crs.code == "EPSG:3857") {
- // self.changeCRS(L.CRS.EPSG2056)
- // self.map.setZoom(zoom + 7)
- // }
- }
- return;
- }
-
-
- if (option == 'mapOverlays') {
-
- if (this.options.mapOverlays) {
- for (let overlayName in this.options.mapOverlays) {
- overlayName = this.options.mapOverlays[overlayName];
- let layer;
- if (lodash.keys(spotmapjsobj.overlays).includes(overlayName)) {
- let overlay = spotmapjsobj.overlays[overlayName];
- if (overlay.wms) {
- layer = L.tileLayer.wms(overlay.url, overlay.options);
- } else {
- layer = L.tileLayer(overlay.url, overlay.options);
- }
- layer.addTo(this.map);
- this.layerControl.addOverlay(layer, overlay.label);
- }
-
- }
- }}
- if (option == 'color' && config.feed) {
- if (this.options.styles[config.feed] && this.options.styles[config.feed].color)
- return this.options.styles[config.feed].color;
- return 'blue';
- }
- if (option == 'color' && config.gpx) {
- if (config.gpx.color)
- return config.gpx.color;
- return 'gold';
- }
- if (option == 'lastPoint') {
- if (this.options.lastPoint)
- return this.options.lastPoint;
- return false;
- }
- if (option == 'feeds') {
- if (this.options.feeds || this.options.feeds.length == 0)
- return false;
- return this.options.feeds;
- }
-
- if (option == 'splitLines' && config.feed) {
- if (this.options.styles[config.feed] && this.options.styles[config.feed].splitLinesEnabled && this.options.styles[config.feed].splitLinesEnabled === false)
- return false;
- if (this.options.styles[config.feed] && this.options.styles[config.feed].splitLines)
- return this.options.styles[config.feed].splitLines;
- return false;
- }
- }
- debug(message) {
- if (this.options && this.options.debug)
- console.log(message)
- }
-
- getPoints(callback, options) {
- var self = this;
- jQuery.post(spotmapjsobj.ajaxUrl, options.body, function (response) {
- let feeds = true;
- if(self.options.feeds && self.options.feeds.length == 0){
- feeds = false
- }
- if(feeds && (response.error || response == 0)){
- self.debug("There was an error in the response");
- self.debug(response);
- self.map.setView([51.505, -0.09], 13);
- response = response.error ? response : {};
- response.title = response.title || "No data found!";
- response.message = response.message || "";
- if (self.options.gpx.length == 0) {
- var popup = L.popup()
- .setLatLng([51.5, 0])
- .setContent("
" + response.title + " " + response.message)
- .openOn(self.map);
- self.map.setView([51.5, 0], 13);
- }
- }
- else if(feeds && options.filter && !response.empty){
- response = self.removeClosePoints(response, options.filter);
- callback(response);
- } else {
- callback(response);
- }
- });
- }
-
- removeClosePoints(points, radius){
- points = lodash.eachRight(points, function (element, index) {
- // if we spliced the array, or check the last element, do nothing
- if (!element || index == 0)
- return
- let nextPoint,
- indexesToBeDeleted = [];
- for (let i = index - 1; i > 0; i--) {
- nextPoint = [points[i].latitude, points[i].longitude];
- let dif = L.latLng(element.latitude, element.longitude).distanceTo(nextPoint);
- if (dif <= radius && element.type == points[i].type) {
- indexesToBeDeleted.push(i);
- continue;
- }
- if (indexesToBeDeleted.length != 0) {
- points[index].hiddenPoints = { count: indexesToBeDeleted.length, radius: radius };
- }
- break;
- }
- lodash.each(indexesToBeDeleted, function (index) {
- points[index] = undefined;
- });
- });
- // completely remove the entries from the points
- points = points.filter(function (element) {
- return element !== undefined;
- });
- return points;
- }
-
- addButtons() {
- // zoom to bounds btn
- var self = this;
- let zoomOptions = { duration: 2 };
- let last = L.easyButton({
- states: [{
- stateName: 'all',
- icon: 'fa-globe',
- title: 'Show all points',
- onClick: function (control) {
- self.setBounds('all');
- control.state('last');
- },
- }, {
- stateName: 'last',
- icon: 'fa-map-pin',
- title: 'Jump to last known location',
- onClick: function (control) {
- self.setBounds('last');
- if (!lodash.isEmpty(self.options.gpx))
- control.state('gpx');
- else
- control.state('all');
- },
- }, {
- stateName: 'gpx',
- icon: '
Tr. ',
- title: 'Show GPX track(s)',
- onClick: function (control) {
- self.setBounds('gpx');
- control.state('all');
- },
- }]
- });
- // the users position
- let position = L.easyButton({states: [{
- icon: 'fa-location-arrow',
- title: 'Jump to your location',
- onClick: function () {
- self.map.locate({ setView: true, maxZoom: 15 });
- },
- }]});
- // add all btns to map
- L.easyBar([last, position]).addTo(this.map);
- }
-
- // onBaseLayerChange(layer) {
- // // let bounds = this.map.getBounds();
- // let center = this.map.getCenter();
- // let zoom = this.map.getZoom();
- // // console.log(this.map.getZoom());
-
- // if (lodash.startsWith(layer.name, "swiss") && this.map.options.crs.code == "EPSG:3857") {
- // this.changeCRS(L.CRS.EPSG2056)
- // this.map.setZoom(zoom + 7)
- // }
- // else if (!lodash.startsWith(layer.name, "swiss") && this.map.options.crs.code == "EPSG:2056") {
- // this.changeCRS(L.CRS.EPSG3857)
- // this.map.setZoom(zoom - 7)
- // }
- // // this.map.options.zoomSnap = 0;
- // this.map._resetView(center, zoom, true);
- // zoom = this.map.getZoom();
- // // this.map.options.zoomSnap = 1;
- // }
-
- initTable(id) {
- // define obj to post data
- var body = {
- 'action': 'get_positions',
- 'date-range': this.options.dateRange,
- 'type': this.options.type,
- 'date': this.options.date,
- 'orderBy': this.options.orderBy,
- 'limit': this.options.limit,
- 'groupBy': this.options.groupBy,
- }
- if (this.options.feeds) {
- body.feeds = this.options.feeds;
- }
- var self = this;
- this.getPoints(function (response) {
- let headerElements = ["Type", "Message", "Time"];
- let hasLocaltime = false;
- if (lodash.find(response, function (o) { return o.local_timezone; })) {
- headerElements.push("Local Time");
- hasLocaltime = true;
- }
- var table = jQuery('#' + id);
- let row = '
';
- lodash.each(headerElements, function (element) {
- row += '' + element + ' '
- })
- row += ' '
- table.append(jQuery(row));
- if (response.error == true) {
- self.options.autoReload = false;
- table.append(jQuery(" No data found "))
- return;
- } else
- lodash.forEach(response, function (entry) {
- if (!entry.local_timezone) {
- entry.localdate = '';
- entry.localtime = '';
- }
- if (!entry.message)
- entry.message = '';
- let row = "
" + entry.type + " " + entry.message + " " + entry.time + " " + entry.date + " ";
- if (hasLocaltime)
- row += "" + entry.localtime + " " + entry.localdate + " ";
- row += "";
- table.append(jQuery(row))
- });
- if (self.options.autoReload == true) {
- var oldResponse = response;
- var refresh = setInterval(function () {
- self.getPoints(function (response) {
- if (lodash.head(oldResponse).unixtime < lodash.head(response).unixtime) {
- var table = jQuery('#' + id);
- table.empty();
- let headerElements = ["Type", "Message", "Time"];
- let hasLocaltime = false;
- if (lodash.find(response, function (o) { return o.local_timezone; })) {
- headerElements.push("Local Time");
- hasLocaltime = true;
- }
- let row = '
';
- lodash.each(headerElements, function (element) {
- row += '' + element + ' '
- })
- row += ' '
- table.append(jQuery(row));
- lodash.forEach(response, function (entry) {
- if (!entry.local_timezone) {
- entry.localdate = '';
- entry.localtime = '';
- }
- if (!entry.message)
- entry.message = '';
- let row = " " + entry.type + " " + entry.message + " " + entry.time + " " + entry.date + " ";
- if (hasLocaltime)
- row += "" + entry.localtime + " " + entry.localdate + " ";
- row += "";
- table.append(jQuery(row));
- });
- } else {
- self.debug('same response!');
- }
-
- }, { body: body, filter: self.options.filterPoints });
- }, 10000);
- }
- }, { body: body, filter: this.options.filterPoints });
- }
-
- getColorDot(color){
- return '
'
- }
- getPopupText(entry){
- let message = "
" + entry.type + " ";
- message += 'Time: ' + entry.time + 'Date: ' + entry.date + '';
- if (entry.local_timezone && !(entry.localdate == entry.date && entry.localtime == entry.time))
- message += 'Local Time: ' + entry.localtime + 'Local Date: ' + entry.localdate + '';
- if (entry.message && entry.type == 'MEDIA')
- message += '
';
- else
- message += entry.message + '';
- if (entry.altitude > 0)
- message += 'Altitude: ' + Number(entry.altitude) + 'm';
- if (entry.battery_status == 'LOW')
- message += 'Battery status is low!' + '';
- if (entry.hiddenPoints)
- message += 'There are ' + entry.hiddenPoints.count + ' hidden Points within a radius of ' + entry.hiddenPoints.radius + ' meters';
- return message;
- }
- setNewFeedLayer(feedName){
- if(this.doesFeedExists(feedName)){
- return false;
- }
- this.layers.feeds[feedName] = {
- lines: [this.addNewLine(feedName)],
- markers: [],
- points: [],
- featureGroup: L.featureGroup(),
- };
- this.layers.feeds[feedName].featureGroup.addLayer(this.layers.feeds[feedName].lines[0]);
- return true;
- }
-
- addPoint(point){
- let feedName = point.feed_name;
- let coordinates = [point.latitude, point.longitude];
- if(!this.doesFeedExists(feedName)){
- this.setNewFeedLayer(feedName);
- }
-
- // this.getOption('lastPoint')
-
- let markerOptions= this.getMarkerOptions(point)
- let message = this.getPopupText(point);
- let marker = L.marker(coordinates , markerOptions).bindPopup(message);
-
- this.layers.feeds[feedName].points.push(point);
- this.layers.feeds[feedName].markers.push(marker);
- this.layers.feeds[feedName].featureGroup.addLayer(marker)
- jQuery("#spotmap_" + point.id).click(function () {
- marker.togglePopup();
- self.map.panTo(coordinates)
- });
- jQuery("#spotmap_" + point.id).dblclick(function () {
- marker.togglePopup();
- self.map.setView(coordinates, 14)
- });
- }
-
- getMarkerOptions(point){
- let zIndexOffset = 0;
- if(!lodash.includes(['UNLIMITED-TRACK', 'EXTREME-TRACK', 'TRACK'], point.type)){
- zIndexOffset += 1000;
- } else if(!lodash.includes(['CUSTOM', 'OK'], point.type)){
- zIndexOffset -= 2000;
- } else if(!lodash.includes(['HELP', 'HELP-CANCEL',], point.type)){
- zIndexOffset -= 3000;
- }
-
- let markerOptions = {
- icon: this.getMarkerIcon(point),
- zIndexOffset: zIndexOffset,
- };
-
- return markerOptions;
- }
- getMarkerIcon(point){
- let color = point.color ? point.color : this.getOption('color', { 'feed': point.feed_name });
- let iconOptions = {
- textColor: color,
- borderColor: color,
- }
-
- if(lodash.includes(['UNLIMITED-TRACK', 'EXTREME-TRACK', 'TRACK'], point.type)){
- iconOptions.iconShape = spotmapjsobj.marker["UNLIMITED-TRACK"].iconShape;
- iconOptions.icon = spotmapjsobj.marker["UNLIMITED-TRACK"].icon;
- iconOptions.iconAnchor= [4,4];
- iconOptions.iconSize= [8,8];
- iconOptions.borderWith = 8;
- }
- // Is the point.type configured?
- if(spotmapjsobj.marker[point.type]){
- iconOptions.iconShape = spotmapjsobj.marker[point.type].iconShape;
- iconOptions.icon = spotmapjsobj.marker[point.type].icon;
- if(iconOptions.iconShape == 'circle-dot'){
- iconOptions.iconAnchor= [4,4];
- iconOptions.iconSize= [8,8];
- iconOptions.borderWith = 8;
- }
- } else {
- iconOptions.iconShape = "marker";
- iconOptions.icon = "circle";
- }
- return L.BeautifyIcon.icon(iconOptions)
- }
- addPointToLine(point){
- let feedName = point.feed_name;
- if (feedName == 'media')
- return
- let coordinates = [point.latitude, point.longitude];
- let splitLines = this.getOption('splitLines', { 'feed': feedName });
- if(!splitLines){
- return false;
- }
- let numberOfPointsAddedToMap = this.layers.feeds[feedName].points.length;
- let lastPoint;
- if(numberOfPointsAddedToMap == 2){
- // TODO
- lastPoint = this.layers.feeds[feedName].points[ numberOfPointsAddedToMap - 1 ];
- // compare with given point if it's the same exit
- }
- if(numberOfPointsAddedToMap >= 2){
- lastPoint = this.layers.feeds[feedName].points[ numberOfPointsAddedToMap - 2 ];
- }
- let length = this.layers.feeds[feedName].lines.length;
- if(lastPoint && point.unixtime - lastPoint.unixtime >= splitLines * 60 * 60){
- // start new line and add to map
- let line = this.addNewLine(feedName);
- line.addLatLng(coordinates);
- this.layers.feeds[feedName].lines.push(line)
- this.layers.feeds[feedName].featureGroup.addLayer(line);
- } else {
- this.layers.feeds[feedName].lines[length-1].addLatLng(coordinates);
- }
-
- return true;
- }
- /**
- * Creates an empty polyline according to the settings gathered from the feedname
- * @param {string} feedName
- * @returns {L.polyline} line
- */
- addNewLine(feedName){
- let color = this.getOption('color', { 'feed': feedName });
- let line = L.polyline([],{ color: color });
-
- line.setText(' \u25BA ', {
- repeat: true,
- offset: 2,
- attributes: {
- 'fill': 'black',
- 'font-size': 7
- }
- });
- return line;
- // this.layers.feeds[feedName].lines.push(line);
- }
- /**
- *
- * @param {string} option
- */
- setBounds(option){
- this.map.fitBounds(this.getBounds(option));
- }
- /**
- * Calculates the bounds to the given option
- * @param {string} option - all,last,last-trip,gpx
- * @returns {L.latLngBounds}
- */
- getBounds(option){
- let bounds = L.latLngBounds();
- let coordinates =[];
- var self = this;
- let latestPoint;
- if(option == "last" || option == "last-trip"){
- let unixtime = 0;
- lodash.forEach(self.layers.feeds, function(value, feedName) {
- let point = lodash.last(self.layers.feeds[feedName].points);
-
- if( point.unixtime > unixtime){
- latestPoint = lodash.last(self.layers.feeds[feedName].points);
- }
- });
- bounds.extend( [latestPoint.latitude, latestPoint.longitude]);
- if(option == "last"){
- return bounds;
- }
- // get bounds for last-trip
- let line = lodash.last(self.layers.feeds[latestPoint.feed_name].lines);
- return line.getBounds();
- }
-
- let feedBounds = L.latLngBounds();
- var self = this;
- lodash.forEach(self.layers.feeds, function(value, feedName) {
- let layerBounds = self.layers.feeds[feedName].featureGroup.getBounds();
- feedBounds.extend(layerBounds);
- });
- if(option == "feeds"){
- return feedBounds;
- }
- let gpxBounds = L.latLngBounds();
- lodash.forEach(self.layers.gpx, function(value, key) {
- let layerBounds = self.layers.gpx[key].featureGroup.getBounds();
- gpxBounds.extend(layerBounds);
-
- });
- if(option == "gpx"){
- return gpxBounds;
- }
- if(option == "all"){
- bounds.extend(gpxBounds);
- bounds.extend(feedBounds);
- return bounds;
- }
-
- }
-}
diff --git a/public/leaflet-beautify-marker/leaflet-beautify-marker-icon.css b/public/leaflet-beautify-marker/leaflet-beautify-marker-icon.css
deleted file mode 100644
index b497370..0000000
--- a/public/leaflet-beautify-marker/leaflet-beautify-marker-icon.css
+++ /dev/null
@@ -1,38 +0,0 @@
-/*
- Leaflet.BeautifyIcon, a plugin that adds colorful iconic markers for Leaflet by giving full control of style to end user, It has also ability to adjust font awesome
- and glyphicon icons
- (c) 2016-2017, Muhammad Arslan Sajid
- http://leafletjs.com
-*/
-.beautify-marker {
- text-align: center;
- font-weight: 700;
- font-family: monospace;
- position:absolute;
- -webkit-box-sizing: border-box;
- -moz-box-sizing: border-box;
- box-sizing: border-box;
-}
-
- .beautify-marker.circle {
- border-radius: 100%;
- }
-
- .beautify-marker.circle-dot, .beautify-marker.doughnut {
- border-radius: 100%;
- }
-
- .beautify-marker.marker {
- border-top-left-radius: 50%;
- border-top-right-radius: 50% 100%;
- border-bottom-left-radius: 100% 50%;
- border-bottom-right-radius: 0%;
- /* rotating 45deg clockwise to get the corner bottom center */
- transform: rotate(45deg);
-
- }
-
- .beautify-marker.marker > * {
- /* rotating 45deg counterclock to adjust marker content back to normal */
- transform: rotate(-45deg);
- }
\ No newline at end of file
diff --git a/public/leaflet-beautify-marker/leaflet-beautify-marker-icon.js b/public/leaflet-beautify-marker/leaflet-beautify-marker-icon.js
deleted file mode 100644
index 72aaab5..0000000
--- a/public/leaflet-beautify-marker/leaflet-beautify-marker-icon.js
+++ /dev/null
@@ -1,206 +0,0 @@
-/*
- Leaflet.BeautifyIcon, a plugin that adds colorful iconic markers for Leaflet by giving full control of style to end user, It has also ability to adjust font awesome
- and glyphicon icons
- (c) 2016-2017, Muhammad Arslan Sajid
- http://leafletjs.com
-*/
-
-/*global L of leaflet*/
-
-(function (window, document, undefined) {
-
- 'use strict';
-
- /*
- * Leaflet.BeautifyIcon assumes that you have already included the Leaflet library.
- */
-
- /*
- * Default settings for various style markers
- */
- var defaults = {
-
- iconColor: '#1EB300',
-
- iconAnchor: {
- 'marker': [14, 36]
- , 'circle': [11, 10]
- , 'circle-dot': [5, 5]
- , 'rectangle-dot': [5, 6]
- , 'doughnut': [8, 8]
- },
-
- popupAnchor: {
- 'marker': [0, -25]
- , 'circle': [-3, -76]
- , 'circle-dot': [0, -2]
- , 'rectangle-dot': [0, -2]
- , 'doughnut': [0, -2]
- },
-
- innerIconAnchor: {
- 'marker': [-2, 5]
- , 'circle': [0, 2]
- },
-
- iconSize: {
- 'marker': [28, 28]
- , 'circle': [22, 22]
- , 'circle-dot': [2, 2]
- , 'rectangle-dot': [2, 2]
- , 'doughnut': [15, 15]
- }
- };
-
- L.BeautifyIcon = {
-
- Icon: L.Icon.extend({
-
- options: {
- icon: 'leaf'
- , iconSize: defaults.iconSize.circle
- , iconAnchor: defaults.iconAnchor.circle
- , iconShape: 'circle'
- , iconStyle: ''
- , innerIconAnchor: [0, 3] // circle with fa or glyphicon or marker with text
- , innerIconStyle: ''
- , isAlphaNumericIcon: false
- , text: 1
- , borderColor: defaults.iconColor
- , borderWidth: 2
- , borderStyle: 'solid'
- , backgroundColor: 'white'
- , textColor: defaults.iconColor
- , customClasses: ''
- , spin: false
- , prefix: 'fa'
- , html: ''
- },
-
- initialize: function (options) {
-
- this.applyDefaults(options);
- this.options = (!options || !options.html) ? L.Util.setOptions(this, options) : options;
- },
-
- applyDefaults: function (options) {
-
- if (options) {
- if (!options.iconSize && options.iconShape) {
- options.iconSize = defaults.iconSize[options.iconShape];
- }
-
- if (!options.iconAnchor && options.iconShape) {
- options.iconAnchor = defaults.iconAnchor[options.iconShape];
- }
-
- if (!options.popupAnchor && options.iconShape) {
- options.popupAnchor = defaults.popupAnchor[options.iconShape];
- }
-
- if (!options.innerIconAnchor) {
- // if icon is type of circle or marker
- if (options.iconShape === 'circle' || options.iconShape === 'marker') {
- if (options.iconShape === 'circle' && options.isAlphaNumericIcon) { // if circle with text
- options.innerIconAnchor = [0, -1];
- }
- else if (options.iconShape === 'marker' && !options.isAlphaNumericIcon) {// marker with icon
- options.innerIconAnchor = defaults.innerIconAnchor[options.iconShape];
- }
- }
- }
- }
- },
-
- createIcon: function () {
-
- var iconDiv = document.createElement('div')
- , options = this.options;
-
- iconDiv.innerHTML = !options.html ? this.createIconInnerHtml() : options.html;
- this._setIconStyles(iconDiv);
-
- // having a marker requires an extra parent div for rotation correction
- if (this.options.iconShape === 'marker') {
- var wrapperDiv = document.createElement('div');
- wrapperDiv.className = 'beautify-marker';
- wrapperDiv.appendChild(iconDiv);
- return wrapperDiv;
- }
-
- return iconDiv;
- },
-
- createIconInnerHtml: function () {
-
- var options = this.options;
-
- if (options.iconShape === 'circle-dot' || options.iconShape === 'rectangle-dot' || options.iconShape === 'doughnut') {
- return '';
- }
-
- var innerIconStyle = this.getInnerIconStyle(options);
- if (options.isAlphaNumericIcon) {
- return '
' + options.text + '
';
- }
-
- var spinClass = '';
- if (options.spin) {
- spinClass = ' fa-spin';
- }
-
- return '
';
- },
-
- getInnerIconStyle: function (options) {
-
- var innerAnchor = L.point(options.innerIconAnchor)
- return 'color:' + options.textColor + ';margin-top:' + innerAnchor.y + 'px; margin-left:' + innerAnchor.x + 'px;' + options.innerIconStyle;
- },
-
- _setIconStyles: function (iconDiv) {
-
- var options = this.options
- , size = L.point(options.iconSize)
- , anchor = L.point(options.iconAnchor);
-
- iconDiv.className = 'beautify-marker ';
-
- if (options.iconShape) {
- iconDiv.className += options.iconShape;
- }
-
- if (options.customClasses) {
- iconDiv.className += ' ' + options.customClasses;
- }
-
- iconDiv.style.backgroundColor = options.backgroundColor;
- iconDiv.style.color = options.textColor;
- iconDiv.style.borderColor = options.borderColor;
- iconDiv.style.borderWidth = options.borderWidth + 'px';
- iconDiv.style.borderStyle = options.borderStyle;
-
- if (size) {
- iconDiv.style.width = size.x + 'px';
- iconDiv.style.height = size.y + 'px';
- }
-
- if (anchor) {
- iconDiv.style.marginLeft = (-anchor.x) + 'px';
- iconDiv.style.marginTop = (-anchor.y) + 'px';
- }
-
- if (options.iconStyle) {
- var cStyle = iconDiv.getAttribute('style');
- cStyle += options.iconStyle;
- iconDiv.setAttribute('style', cStyle);
- }
- }
- })
- };
-
- L.BeautifyIcon.icon = function (options) {
- return new L.BeautifyIcon.Icon(options);
- }
-
-}(this, document));
diff --git a/public/leaflet-easy-button/easy-button.css b/public/leaflet-easy-button/easy-button.css
deleted file mode 100644
index 18ce9ac..0000000
--- a/public/leaflet-easy-button/easy-button.css
+++ /dev/null
@@ -1,56 +0,0 @@
-.leaflet-bar button,
-.leaflet-bar button:hover {
- background-color: #fff;
- border: none;
- border-bottom: 1px solid #ccc;
- width: 26px;
- height: 26px;
- line-height: 26px;
- display: block;
- text-align: center;
- text-decoration: none;
- color: black;
-}
-
-.leaflet-bar button {
- background-position: 50% 50%;
- background-repeat: no-repeat;
- overflow: hidden;
- display: block;
-}
-
-.leaflet-bar button:hover {
- background-color: #f4f4f4;
-}
-
-.leaflet-bar button:first-of-type {
- border-top-left-radius: 4px;
- border-top-right-radius: 4px;
-}
-
-.leaflet-bar button:last-of-type {
- border-bottom-left-radius: 4px;
- border-bottom-right-radius: 4px;
- border-bottom: none;
-}
-
-.leaflet-bar.disabled,
-.leaflet-bar button.disabled {
- cursor: default;
- pointer-events: none;
- opacity: .4;
-}
-
-.easy-button-button .button-state{
- display: block;
- width: 100%;
- height: 100%;
- position: relative;
-}
-
-
-.leaflet-touch .leaflet-bar button {
- width: 30px;
- height: 30px;
- line-height: 30px;
-}
diff --git a/public/leaflet-easy-button/easy-button.js b/public/leaflet-easy-button/easy-button.js
deleted file mode 100644
index 2863fd5..0000000
--- a/public/leaflet-easy-button/easy-button.js
+++ /dev/null
@@ -1,376 +0,0 @@
-(function(){
-
-// This is for grouping buttons into a bar
-// takes an array of `L.easyButton`s and
-// then the usual `.addTo(map)`
-L.Control.EasyBar = L.Control.extend({
-
- options: {
- position: 'topleft', // part of leaflet's defaults
- id: null, // an id to tag the Bar with
- leafletClasses: true // use leaflet classes?
- },
-
-
- initialize: function(buttons, options){
-
- if(options){
- L.Util.setOptions( this, options );
- }
-
- this._buildContainer();
- this._buttons = [];
-
- for(var i = 0; i < buttons.length; i++){
- buttons[i]._bar = this;
- buttons[i]._container = buttons[i].button;
- this._buttons.push(buttons[i]);
- this.container.appendChild(buttons[i].button);
- }
-
- },
-
-
- _buildContainer: function(){
- this._container = this.container = L.DomUtil.create('div', '');
- this.options.leafletClasses && L.DomUtil.addClass(this.container, 'leaflet-bar easy-button-container leaflet-control');
- this.options.id && (this.container.id = this.options.id);
- },
-
-
- enable: function(){
- L.DomUtil.addClass(this.container, 'enabled');
- L.DomUtil.removeClass(this.container, 'disabled');
- this.container.setAttribute('aria-hidden', 'false');
- return this;
- },
-
-
- disable: function(){
- L.DomUtil.addClass(this.container, 'disabled');
- L.DomUtil.removeClass(this.container, 'enabled');
- this.container.setAttribute('aria-hidden', 'true');
- return this;
- },
-
-
- onAdd: function () {
- return this.container;
- },
-
- addTo: function (map) {
- this._map = map;
-
- for(var i = 0; i < this._buttons.length; i++){
- this._buttons[i]._map = map;
- }
-
- var container = this._container = this.onAdd(map),
- pos = this.getPosition(),
- corner = map._controlCorners[pos];
-
- L.DomUtil.addClass(container, 'leaflet-control');
-
- if (pos.indexOf('bottom') !== -1) {
- corner.insertBefore(container, corner.firstChild);
- } else {
- corner.appendChild(container);
- }
-
- return this;
- }
-
-});
-
-L.easyBar = function(){
- var args = [L.Control.EasyBar];
- for(var i = 0; i < arguments.length; i++){
- args.push( arguments[i] );
- }
- return new (Function.prototype.bind.apply(L.Control.EasyBar, args));
-};
-
-// L.EasyButton is the actual buttons
-// can be called without being grouped into a bar
-L.Control.EasyButton = L.Control.extend({
-
- options: {
- position: 'topleft', // part of leaflet's defaults
-
- id: null, // an id to tag the button with
-
- type: 'replace', // [(replace|animate)]
- // replace swaps out elements
- // animate changes classes with all elements inserted
-
- states: [], // state names look like this
- // {
- // stateName: 'untracked',
- // onClick: function(){ handle_nav_manually(); };
- // title: 'click to make inactive',
- // icon: 'fa-circle', // wrapped with
- // }
-
- leafletClasses: true, // use leaflet styles for the button
- tagName: 'button',
- },
-
-
-
- initialize: function(icon, onClick, title, id){
-
- // clear the states manually
- this.options.states = [];
-
- // add id to options
- if(id != null){
- this.options.id = id;
- }
-
- // storage between state functions
- this.storage = {};
-
- // is the last item an object?
- if( typeof arguments[arguments.length-1] === 'object' ){
-
- // if so, it should be the options
- L.Util.setOptions( this, arguments[arguments.length-1] );
- }
-
- // if there aren't any states in options
- // use the early params
- if( this.options.states.length === 0 &&
- typeof icon === 'string' &&
- typeof onClick === 'function'){
-
- // turn the options object into a state
- this.options.states.push({
- icon: icon,
- onClick: onClick,
- title: typeof title === 'string' ? title : ''
- });
- }
-
- // curate and move user's states into
- // the _states for internal use
- this._states = [];
-
- for(var i = 0; i < this.options.states.length; i++){
- this._states.push( new State(this.options.states[i], this) );
- }
-
- this._buildButton();
-
- this._activateState(this._states[0]);
-
- },
-
- _buildButton: function(){
-
- this.button = L.DomUtil.create(this.options.tagName, '');
-
- if (this.options.tagName === 'button') {
- this.button.setAttribute('type', 'button');
- }
-
- if (this.options.id ){
- this.button.id = this.options.id;
- }
-
- if (this.options.leafletClasses){
- L.DomUtil.addClass(this.button, 'easy-button-button leaflet-bar-part leaflet-interactive');
- }
-
- // don't let double clicks and mousedown get to the map
- L.DomEvent.addListener(this.button, 'dblclick', L.DomEvent.stop);
- L.DomEvent.addListener(this.button, 'mousedown', L.DomEvent.stop);
- L.DomEvent.addListener(this.button, 'mouseup', L.DomEvent.stop);
-
- // take care of normal clicks
- L.DomEvent.addListener(this.button,'click', function(e){
- L.DomEvent.stop(e);
- this._currentState.onClick(this, this._map ? this._map : null );
- this._map && this._map.getContainer().focus();
- }, this);
-
- // prep the contents of the control
- if(this.options.type == 'replace'){
- this.button.appendChild(this._currentState.icon);
- } else {
- for(var i=0;i"']/) ){
-
- // if so, the user should have put in html
- // so move forward as such
- tmpIcon = ambiguousIconString;
-
- // then it wasn't html, so
- // it's a class list, figure out what kind
- } else {
- ambiguousIconString = ambiguousIconString.replace(/(^\s*|\s*$)/g,'');
- tmpIcon = L.DomUtil.create('span', '');
-
- if( ambiguousIconString.indexOf('fa-') === 0 ){
- L.DomUtil.addClass(tmpIcon, 'fa ' + ambiguousIconString)
- } else if ( ambiguousIconString.indexOf('glyphicon-') === 0 ) {
- L.DomUtil.addClass(tmpIcon, 'glyphicon ' + ambiguousIconString)
- } else {
- L.DomUtil.addClass(tmpIcon, /*rollwithit*/ ambiguousIconString)
- }
-
- // make this a string so that it's easy to set innerHTML below
- tmpIcon = tmpIcon.outerHTML;
- }
-
- return tmpIcon;
-}
-
-})();
diff --git a/public/leaflet-gpx/gpx.js b/public/leaflet-gpx/gpx.js
deleted file mode 100644
index dd974b7..0000000
--- a/public/leaflet-gpx/gpx.js
+++ /dev/null
@@ -1,617 +0,0 @@
-/**
- * Copyright (C) 2011-2012 Pavel Shramov
- * Copyright (C) 2013-2017 Maxime Petazzoni
- * All Rights Reserved.
- *
- * Redistribution and use in source and binary forms, with or without
- * modification, are permitted provided that the following conditions are met:
- *
- * - Redistributions of source code must retain the above copyright notice,
- * this list of conditions and the following disclaimer.
- *
- * - Redistributions in binary form must reproduce the above copyright notice,
- * this list of conditions and the following disclaimer in the documentation
- * and/or other materials provided with the distribution.
- *
- * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
- * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
- * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
- * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
- * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
- * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
- * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
- * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
- * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
- * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
- * POSSIBILITY OF SUCH DAMAGE.
- */
-
-/*
- * Thanks to Pavel Shramov who provided the initial implementation and Leaflet
- * integration. Original code was at https://github.com/shramov/leaflet-plugins.
- *
- * It was then cleaned-up and modified to record and make available more
- * information about the GPX track while it is being parsed so that the result
- * can be used to display additional information about the track that is
- * rendered on the Leaflet map.
- */
-
-var L = L || require('leaflet');
-
-var _MAX_POINT_INTERVAL_MS = 15000;
-var _SECOND_IN_MILLIS = 1000;
-var _MINUTE_IN_MILLIS = 60 * _SECOND_IN_MILLIS;
-var _HOUR_IN_MILLIS = 60 * _MINUTE_IN_MILLIS;
-var _DAY_IN_MILLIS = 24 * _HOUR_IN_MILLIS;
-
-var _GPX_STYLE_NS = 'http://www.topografix.com/GPX/gpx_style/0/2';
-
-var _DEFAULT_MARKER_OPTS = {
- startIconUrl: 'pin-icon-start.png',
- endIconUrl: 'pin-icon-end.png',
- shadowUrl: 'pin-shadow.png',
- wptIcons: [],
- wptIconsType: [],
- wptIconUrls : {
- '': 'pin-icon-wpt.png',
- },
- wptIconTypeUrls : {
- '': 'pin-icon-wpt.png',
- },
- pointMatchers: [],
- iconSize: [33, 50],
- shadowSize: [50, 50],
- iconAnchor: [16, 45],
- shadowAnchor: [16, 47],
- clickable: false
-};
-var _DEFAULT_POLYLINE_OPTS = {
- color: 'blue'
-};
-var _DEFAULT_GPX_OPTS = {
- parseElements: ['track', 'route', 'waypoint'],
- joinTrackSegments: true
-};
-
-L.GPX = L.FeatureGroup.extend({
- initialize: function(gpx, options) {
- options.max_point_interval = options.max_point_interval || _MAX_POINT_INTERVAL_MS;
- options.marker_options = this._merge_objs(
- _DEFAULT_MARKER_OPTS,
- options.marker_options || {});
- options.polyline_options = options.polyline_options || {};
- options.gpx_options = this._merge_objs(
- _DEFAULT_GPX_OPTS,
- options.gpx_options || {});
-
- L.Util.setOptions(this, options);
-
- // Base icon class for track pins.
- L.GPXTrackIcon = L.Icon.extend({ options: options.marker_options });
-
- this._gpx = gpx;
- this._layers = {};
- this._init_info();
-
- if (gpx) {
- this._parse(gpx, options, this.options.async);
- }
- },
-
- get_duration_string: function(duration, hidems) {
- var s = '';
-
- if (duration >= _DAY_IN_MILLIS) {
- s += Math.floor(duration / _DAY_IN_MILLIS) + 'd ';
- duration = duration % _DAY_IN_MILLIS;
- }
-
- if (duration >= _HOUR_IN_MILLIS) {
- s += Math.floor(duration / _HOUR_IN_MILLIS) + ':';
- duration = duration % _HOUR_IN_MILLIS;
- }
-
- var mins = Math.floor(duration / _MINUTE_IN_MILLIS);
- duration = duration % _MINUTE_IN_MILLIS;
- if (mins < 10) s += '0';
- s += mins + '\'';
-
- var secs = Math.floor(duration / _SECOND_IN_MILLIS);
- duration = duration % _SECOND_IN_MILLIS;
- if (secs < 10) s += '0';
- s += secs;
-
- if (!hidems && duration > 0) s += '.' + Math.round(Math.floor(duration)*1000)/1000;
- else s += '"';
-
- return s;
- },
-
- get_duration_string_iso: function(duration, hidems) {
- var s = this.get_duration_string(duration, hidems);
- return s.replace("'",':').replace('"','');
- },
-
- // Public methods
- to_miles: function(v) { return v / 1.60934; },
- to_ft: function(v) { return v * 3.28084; },
- m_to_km: function(v) { return v / 1000; },
- m_to_mi: function(v) { return v / 1609.34; },
-
- get_name: function() { return this._info.name; },
- get_desc: function() { return this._info.desc; },
- get_author: function() { return this._info.author; },
- get_copyright: function() { return this._info.copyright; },
- get_distance: function() { return this._info.length; },
- get_distance_imp: function() { return this.to_miles(this.m_to_km(this.get_distance())); },
-
- get_start_time: function() { return this._info.duration.start; },
- get_end_time: function() { return this._info.duration.end; },
- get_moving_time: function() { return this._info.duration.moving; },
- get_total_time: function() { return this._info.duration.total; },
-
- get_moving_pace: function() { return this.get_moving_time() / this.m_to_km(this.get_distance()); },
- get_moving_pace_imp: function() { return this.get_moving_time() / this.get_distance_imp(); },
-
- get_moving_speed: function() { return this.m_to_km(this.get_distance()) / (this.get_moving_time() / (3600 * 1000)) ; },
- get_moving_speed_imp:function() { return this.to_miles(this.m_to_km(this.get_distance())) / (this.get_moving_time() / (3600 * 1000)) ; },
-
- get_total_speed: function() { return this.m_to_km(this.get_distance()) / (this.get_total_time() / (3600 * 1000)); },
- get_total_speed_imp: function() { return this.to_miles(this.m_to_km(this.get_distance())) / (this.get_total_time() / (3600 * 1000)); },
-
- get_elevation_gain: function() { return this._info.elevation.gain; },
- get_elevation_loss: function() { return this._info.elevation.loss; },
- get_elevation_gain_imp: function() { return this.to_ft(this.get_elevation_gain()); },
- get_elevation_loss_imp: function() { return this.to_ft(this.get_elevation_loss()); },
- get_elevation_data: function() {
- var _this = this;
- return this._info.elevation._points.map(
- function(p) { return _this._prepare_data_point(p, _this.m_to_km, null,
- function(a, b) { return a.toFixed(2) + ' km, ' + b.toFixed(0) + ' m'; });
- });
- },
- get_elevation_data_imp: function() {
- var _this = this;
- return this._info.elevation._points.map(
- function(p) { return _this._prepare_data_point(p, _this.m_to_mi, _this.to_ft,
- function(a, b) { return a.toFixed(2) + ' mi, ' + b.toFixed(0) + ' ft'; });
- });
- },
- get_elevation_max: function() { return this._info.elevation.max; },
- get_elevation_min: function() { return this._info.elevation.min; },
- get_elevation_max_imp: function() { return this.to_ft(this.get_elevation_max()); },
- get_elevation_min_imp: function() { return this.to_ft(this.get_elevation_min()); },
-
- get_average_hr: function() { return this._info.hr.avg; },
- get_average_temp: function() { return this._info.atemp.avg; },
- get_average_cadence: function() { return this._info.cad.avg; },
- get_heartrate_data: function() {
- var _this = this;
- return this._info.hr._points.map(
- function(p) { return _this._prepare_data_point(p, _this.m_to_km, null,
- function(a, b) { return a.toFixed(2) + ' km, ' + b.toFixed(0) + ' bpm'; });
- });
- },
- get_heartrate_data_imp: function() {
- var _this = this;
- return this._info.hr._points.map(
- function(p) { return _this._prepare_data_point(p, _this.m_to_mi, null,
- function(a, b) { return a.toFixed(2) + ' mi, ' + b.toFixed(0) + ' bpm'; });
- });
- },
- get_cadence_data: function() {
- var _this = this;
- return this._info.cad._points.map(
- function(p) { return _this._prepare_data_point(p, _this.m_to_km, null,
- function(a, b) { return a.toFixed(2) + ' km, ' + b.toFixed(0) + ' rpm'; });
- });
- },
- get_temp_data: function() {
- var _this = this;
- return this._info.atemp._points.map(
- function(p) { return _this._prepare_data_point(p, _this.m_to_km, null,
- function(a, b) { return a.toFixed(2) + ' km, ' + b.toFixed(0) + ' degrees'; });
- });
- },
- get_cadence_data_imp: function() {
- var _this = this;
- return this._info.cad._points.map(
- function(p) { return _this._prepare_data_point(p, _this.m_to_mi, null,
- function(a, b) { return a.toFixed(2) + ' mi, ' + b.toFixed(0) + ' rpm'; });
- });
- },
- get_temp_data_imp: function() {
- var _this = this;
- return this._info.atemp._points.map(
- function(p) { return _this._prepare_data_point(p, _this.m_to_mi, null,
- function(a, b) { return a.toFixed(2) + ' mi, ' + b.toFixed(0) + ' degrees'; });
- });
- },
-
- reload: function() {
- this._init_info();
- this.clearLayers();
- this._parse(this._gpx, this.options, this.options.async);
- },
-
- // Private methods
- _merge_objs: function(a, b) {
- var _ = {};
- for (var attr in a) { _[attr] = a[attr]; }
- for (var attr in b) { _[attr] = b[attr]; }
- return _;
- },
-
- _prepare_data_point: function(p, trans1, trans2, trans_tooltip) {
- var r = [trans1 && trans1(p[0]) || p[0], trans2 && trans2(p[1]) || p[1]];
- r.push(trans_tooltip && trans_tooltip(r[0], r[1]) || (r[0] + ': ' + r[1]));
- return r;
- },
-
- _init_info: function() {
- this._info = {
- name: null,
- length: 0.0,
- elevation: {gain: 0.0, loss: 0.0, max: 0.0, min: Infinity, _points: []},
- hr: {avg: 0, _total: 0, _points: []},
- duration: {start: null, end: null, moving: 0, total: 0},
- atemp: {avg: 0, _total: 0, _points: []},
- cad: {avg: 0, _total: 0, _points: []}
- };
- },
-
- _load_xml: function(url, cb, options, async) {
- if (async == undefined) async = this.options.async;
- if (options == undefined) options = this.options;
-
- var req = new window.XMLHttpRequest();
- req.open('GET', url, async);
- try {
- req.overrideMimeType('text/xml'); // unsupported by IE
- } catch(e) {}
- req.onreadystatechange = function() {
- if (req.readyState != 4) return;
- if(req.status == 200) cb(req.responseXML, options);
- };
- req.send(null);
- },
-
- _parse: function(input, options, async) {
- var _this = this;
- var cb = function(gpx, options) {
- var layers = _this._parse_gpx_data(gpx, options);
- if (!layers) {
- _this.fire('error', { err: 'No parseable layers of type(s) ' + JSON.stringify(options.gpx_options.parseElements) });
- return;
- }
- _this.addLayer(layers);
- _this.fire('loaded', { layers: layers, element: gpx });
- }
- if (input.substr(0,1)==='<') { // direct XML has to start with a <
- var parser = new DOMParser();
- if (async) {
- setTimeout(function() {
- cb(parser.parseFromString(input, "text/xml"), options);
- });
- } else {
- cb(parser.parseFromString(input, "text/xml"), options);
- }
- } else {
- this._load_xml(input, cb, options, async);
- }
- },
-
- _parse_gpx_data: function(xml, options) {
- var i, t, l, el, layers = [];
-
- var name = xml.getElementsByTagName('name');
- if (name.length > 0) {
- this._info.name = name[0].textContent;
- }
- var desc = xml.getElementsByTagName('desc');
- if (desc.length > 0) {
- this._info.desc = desc[0].textContent;
- }
- var author = xml.getElementsByTagName('author');
- if (author.length > 0) {
- this._info.author = author[0].textContent;
- }
- var copyright = xml.getElementsByTagName('copyright');
- if (copyright.length > 0) {
- this._info.copyright = copyright[0].textContent;
- }
-
- var parseElements = options.gpx_options.parseElements;
- if (parseElements.indexOf('route') > -1) {
- // routes are tags inside sections
- var routes = xml.getElementsByTagName('rte');
- for (i = 0; i < routes.length; i++) {
- layers = layers.concat(this._parse_segment(routes[i], options, {}, 'rtept'));
- }
- }
-
- if (parseElements.indexOf('track') > -1) {
- // tracks are tags in one or more sections in each
- var tracks = xml.getElementsByTagName('trk');
- for (i = 0; i < tracks.length; i++) {
- var track = tracks[i];
- var polyline_options = this._extract_styling(track);
-
- if (options.gpx_options.joinTrackSegments) {
- layers = layers.concat(this._parse_segment(track, options, polyline_options, 'trkpt'));
- } else {
- var segments = track.getElementsByTagName('trkseg');
- for (j = 0; j < segments.length; j++) {
- layers = layers.concat(this._parse_segment(segments[j], options, polyline_options, 'trkpt'));
- }
- }
- }
- }
-
- this._info.hr.avg = Math.round(this._info.hr._total / this._info.hr._points.length);
- this._info.cad.avg = Math.round(this._info.cad._total / this._info.cad._points.length);
- this._info.atemp.avg = Math.round(this._info.atemp._total / this._info.atemp._points.length);
-
- // parse waypoints and add markers for each of them
- if (parseElements.indexOf('waypoint') > -1) {
- el = xml.getElementsByTagName('wpt');
- for (i = 0; i < el.length; i++) {
- var ll = new L.LatLng(
- el[i].getAttribute('lat'),
- el[i].getAttribute('lon'));
-
- var nameEl = el[i].getElementsByTagName('name');
- var name = '';
- if (nameEl.length > 0) {
- name = nameEl[0].textContent;
- }
-
- var descEl = el[i].getElementsByTagName('desc');
- var desc = '';
- if (descEl.length > 0) {
- desc = descEl[0].textContent;
- }
-
- var symEl = el[i].getElementsByTagName('sym');
- var symKey = '';
- if (symEl.length > 0) {
- symKey = symEl[0].textContent;
- }
-
- var typeEl = el[i].getElementsByTagName('type');
- var typeKey = '';
- if (typeEl.length > 0) {
- typeKey = typeEl[0].textContent;
- }
-
- /*
- * Add waypoint marker based on the waypoint symbol key.
- *
- * First look for a configured icon for that symKey. If not found, look
- * for a configured icon URL for that symKey and build an icon from it.
- * Otherwise, fall back to the default icon if one was configured, or
- * finally to the default icon URL.
- */
- var wptIcons = options.marker_options.wptIcons;
- var wptIconUrls = options.marker_options.wptIconUrls;
- var wptIconsType = options.marker_options.wptIconsType;
- var wptIconTypeUrls = options.marker_options.wptIconTypeUrls;
- var symIcon;
- if (wptIcons && wptIcons[symKey]) {
- symIcon = wptIcons[symKey];
- } else if (wptIconsType && wptIconsType[typeKey]){
- symIcon = wptIconsType[typeKey];
- } else if (wptIconUrls && wptIconUrls[symKey]){
- symIcon = new L.GPXTrackIcon({iconUrl: wptIconUrls[symKey]});
- } else if (wptIconTypeUrls && wptIconTypeUrls[typeKey]){
- symIcon = new L.GPXTrackIcon({iconUrl: wptIconTypeUrls[typeKey]});
- } else if (wptIcons && wptIcons['']) {
- symIcon = wptIcons[''];
- } else if (wptIconUrls && wptIconUrls['']) {
- symIcon = new L.GPXTrackIcon({iconUrl: wptIconUrls['']});
- } else {
- console.log('No icon or icon URL configured for symbol type "' + symKey
- + '", and no fallback configured; ignoring waypoint.');
- continue;
- }
-
- var marker = new L.Marker(ll, {
- clickable: options.marker_options.clickable,
- title: name,
- icon: symIcon,
- type: 'waypoint'
- });
- marker.bindPopup("" + name + " " + (desc.length > 0 ? ' ' + desc : '')).openPopup();
- this.fire('addpoint', { point: marker, point_type: 'waypoint', element: el[i] });
- layers.push(marker);
- }
- }
-
- if (layers.length > 1) {
- return new L.FeatureGroup(layers);
- } else if (layers.length == 1) {
- return layers[0];
- }
- },
-
- _parse_segment: function(line, options, polyline_options, tag) {
- var el = line.getElementsByTagName(tag);
- if (!el.length) return [];
-
- var coords = [];
- var markers = [];
- var layers = [];
- var last = null;
-
- for (var i = 0; i < el.length; i++) {
- var _, ll = new L.LatLng(
- el[i].getAttribute('lat'),
- el[i].getAttribute('lon'));
- ll.meta = { time: null, ele: null, hr: null, cad: null, atemp: null };
-
- _ = el[i].getElementsByTagName('time');
- if (_.length > 0) {
- ll.meta.time = new Date(Date.parse(_[0].textContent));
- } else {
- ll.meta.time = new Date('1970-01-01T00:00:00');
- }
-
- _ = el[i].getElementsByTagName('ele');
- if (_.length > 0) {
- ll.meta.ele = parseFloat(_[0].textContent);
- }
-
- _ = el[i].getElementsByTagName('name');
- if (_.length > 0) {
- var name = _[0].textContent;
- var ptMatchers = options.marker_options.pointMatchers || [];
-
- for (var j = 0; j < ptMatchers.length; j++) {
- if (ptMatchers[j].regex.test(name)) {
- markers.push({ label: name, coords: ll, icon: ptMatchers[j].icon, element: el[i] });
- break;
- }
- }
- }
-
- _ = el[i].getElementsByTagNameNS('*', 'hr');
- if (_.length > 0) {
- ll.meta.hr = parseInt(_[0].textContent);
- this._info.hr._points.push([this._info.length, ll.meta.hr]);
- this._info.hr._total += ll.meta.hr;
- }
-
- _ = el[i].getElementsByTagNameNS('*', 'cad');
- if (_.length > 0) {
- ll.meta.cad = parseInt(_[0].textContent);
- this._info.cad._points.push([this._info.length, ll.meta.cad]);
- this._info.cad._total += ll.meta.cad;
- }
-
- _ = el[i].getElementsByTagNameNS('*', 'atemp');
- if (_.length > 0) {
- ll.meta.atemp = parseInt(_[0].textContent);
- this._info.atemp._points.push([this._info.length, ll.meta.atemp]);
- this._info.atemp._total += ll.meta.atemp;
- }
-
- if (ll.meta.ele > this._info.elevation.max) {
- this._info.elevation.max = ll.meta.ele;
- }
-
- if (ll.meta.ele < this._info.elevation.min) {
- this._info.elevation.min = ll.meta.ele;
- }
-
- this._info.elevation._points.push([this._info.length, ll.meta.ele]);
- this._info.duration.end = ll.meta.time;
-
- if (last != null) {
- this._info.length += this._dist3d(last, ll);
-
- var t = ll.meta.ele - last.meta.ele;
- if (t > 0) {
- this._info.elevation.gain += t;
- } else {
- this._info.elevation.loss += Math.abs(t);
- }
-
- t = Math.abs(ll.meta.time - last.meta.time);
- this._info.duration.total += t;
- if (t < options.max_point_interval) {
- this._info.duration.moving += t;
- }
- } else if (this._info.duration.start == null) {
- this._info.duration.start = ll.meta.time;
- }
-
- last = ll;
- coords.push(ll);
- }
-
- // add track
- var l = new L.Polyline(coords, this._extract_styling(line, polyline_options, options.polyline_options));
- this.fire('addline', { line: l, element: line });
- layers.push(l);
-
- if (options.marker_options.startIcon || options.marker_options.startIconUrl) {
- // add start pin
- var marker = new L.Marker(coords[0], {
- clickable: options.marker_options.clickable,
- icon: options.marker_options.startIcon || new L.GPXTrackIcon({iconUrl: options.marker_options.startIconUrl})
- });
- this.fire('addpoint', { point: marker, point_type: 'start', element: el[0] });
- layers.push(marker);
- }
-
- if (options.marker_options.endIcon || options.marker_options.endIconUrl) {
- // add end pin
- var marker = new L.Marker(coords[coords.length-1], {
- clickable: options.marker_options.clickable,
- icon: options.marker_options.endIcon || new L.GPXTrackIcon({iconUrl: options.marker_options.endIconUrl})
- });
- this.fire('addpoint', { point: marker, point_type: 'end', element: el[el.length-1] });
- layers.push(marker);
- }
-
- // add named markers
- for (var i = 0; i < markers.length; i++) {
- var marker = new L.Marker(markers[i].coords, {
- clickable: options.marker_options.clickable,
- title: markers[i].label,
- icon: markers[i].icon
- });
- this.fire('addpoint', { point: marker, point_type: 'label', element: markers[i].element });
- layers.push(marker);
- }
-
- return layers;
- },
-
- _extract_styling: function(el, base, overrides) {
- var style = this._merge_objs(_DEFAULT_POLYLINE_OPTS, base);
- var e = el.getElementsByTagNameNS(_GPX_STYLE_NS, 'line');
- if (e.length > 0) {
- var _ = e[0].getElementsByTagName('color');
- if (_.length > 0) style.color = '#' + _[0].textContent;
- var _ = e[0].getElementsByTagName('opacity');
- if (_.length > 0) style.opacity = _[0].textContent;
- var _ = e[0].getElementsByTagName('weight');
- if (_.length > 0) style.weight = _[0].textContent;
- var _ = e[0].getElementsByTagName('linecap');
- if (_.length > 0) style.lineCap = _[0].textContent;
- }
- return this._merge_objs(style, overrides)
- },
-
- _dist2d: function(a, b) {
- var R = 6371000;
- var dLat = this._deg2rad(b.lat - a.lat);
- var dLon = this._deg2rad(b.lng - a.lng);
- var r = Math.sin(dLat/2) *
- Math.sin(dLat/2) +
- Math.cos(this._deg2rad(a.lat)) *
- Math.cos(this._deg2rad(b.lat)) *
- Math.sin(dLon/2) *
- Math.sin(dLon/2);
- var c = 2 * Math.atan2(Math.sqrt(r), Math.sqrt(1-r));
- var d = R * c;
- return d;
- },
-
- _dist3d: function(a, b) {
- var planar = this._dist2d(a, b);
- var height = Math.abs(b.meta.ele - a.meta.ele);
- return Math.sqrt(Math.pow(planar, 2) + Math.pow(height, 2));
- },
-
- _deg2rad: function(deg) {
- return deg * Math.PI / 180;
- }
-});
-
-if (typeof module === 'object' && typeof module.exports === 'object') {
- module.exports = L;
-} else if (typeof define === 'function' && define.amd) {
- define(L);
-}
diff --git a/public/leaflet-gpx/package.json b/public/leaflet-gpx/package.json
deleted file mode 100644
index efb0b10..0000000
--- a/public/leaflet-gpx/package.json
+++ /dev/null
@@ -1,26 +0,0 @@
-{
- "name": "leaflet-gpx",
- "version": "1.5.0",
- "description": "A Leaflet plugin for showing a GPX track on a map",
- "main": "gpx.js",
- "scripts": {
- "test": "echo \"Error: no test specified\" && exit 1"
- },
- "repository": {
- "type": "git",
- "url": "git+https://github.com/mpetazzoni/leaflet-gpx.git"
- },
- "keywords": [
- "leaflet",
- "gpx",
- "leaflet-gpx",
- "map",
- "gps"
- ],
- "author": "Maxime Petazzoni (https://www.bulix.org)",
- "license": "BSD-2-Clause",
- "bugs": {
- "url": "https://github.com/mpetazzoni/leaflet-gpx/issues"
- },
- "homepage": "https://github.com/mpetazzoni/leaflet-gpx#readme"
-}
diff --git a/public/leaflet-gpx/pin-icon-end.png b/public/leaflet-gpx/pin-icon-end.png
deleted file mode 100644
index 190442f..0000000
Binary files a/public/leaflet-gpx/pin-icon-end.png and /dev/null differ
diff --git a/public/leaflet-gpx/pin-icon-start.png b/public/leaflet-gpx/pin-icon-start.png
deleted file mode 100644
index b1b9c7e..0000000
Binary files a/public/leaflet-gpx/pin-icon-start.png and /dev/null differ
diff --git a/public/leaflet-gpx/pin-icon-wpt.png b/public/leaflet-gpx/pin-icon-wpt.png
deleted file mode 100644
index 393d49e..0000000
Binary files a/public/leaflet-gpx/pin-icon-wpt.png and /dev/null differ
diff --git a/public/leaflet-gpx/pin-shadow.png b/public/leaflet-gpx/pin-shadow.png
deleted file mode 100644
index 948646e..0000000
Binary files a/public/leaflet-gpx/pin-shadow.png and /dev/null differ
diff --git a/public/leaflet-textpath/leaflet.textpath.js b/public/leaflet-textpath/leaflet.textpath.js
deleted file mode 100644
index 783620d..0000000
Binary files a/public/leaflet-textpath/leaflet.textpath.js and /dev/null differ
diff --git a/public/leaflet-tilelayer-swisstopo/Leaflet.TileLayer.Swiss.umd.js b/public/leaflet-tilelayer-swisstopo/Leaflet.TileLayer.Swiss.umd.js
deleted file mode 100644
index e9a4c86..0000000
Binary files a/public/leaflet-tilelayer-swisstopo/Leaflet.TileLayer.Swiss.umd.js and /dev/null differ
diff --git a/public/leaflet/images/layers-2x.png b/public/leaflet/images/layers-2x.png
deleted file mode 100644
index 200c333..0000000
Binary files a/public/leaflet/images/layers-2x.png and /dev/null differ
diff --git a/public/leaflet/images/layers.png b/public/leaflet/images/layers.png
deleted file mode 100644
index 1a72e57..0000000
Binary files a/public/leaflet/images/layers.png and /dev/null differ
diff --git a/public/leaflet/images/marker-icon-2x-black.png b/public/leaflet/images/marker-icon-2x-black.png
deleted file mode 100644
index 23c94cf..0000000
Binary files a/public/leaflet/images/marker-icon-2x-black.png and /dev/null differ
diff --git a/public/leaflet/images/marker-icon-2x-blue.png b/public/leaflet/images/marker-icon-2x-blue.png
deleted file mode 100644
index 0015b64..0000000
Binary files a/public/leaflet/images/marker-icon-2x-blue.png and /dev/null differ
diff --git a/public/leaflet/images/marker-icon-2x-gold.png b/public/leaflet/images/marker-icon-2x-gold.png
deleted file mode 100644
index 6992d65..0000000
Binary files a/public/leaflet/images/marker-icon-2x-gold.png and /dev/null differ
diff --git a/public/leaflet/images/marker-icon-2x-green.png b/public/leaflet/images/marker-icon-2x-green.png
deleted file mode 100644
index c359abb..0000000
Binary files a/public/leaflet/images/marker-icon-2x-green.png and /dev/null differ
diff --git a/public/leaflet/images/marker-icon-2x-grey.png b/public/leaflet/images/marker-icon-2x-grey.png
deleted file mode 100644
index 43b3eb4..0000000
Binary files a/public/leaflet/images/marker-icon-2x-grey.png and /dev/null differ
diff --git a/public/leaflet/images/marker-icon-2x-orange.png b/public/leaflet/images/marker-icon-2x-orange.png
deleted file mode 100644
index c3c8632..0000000
Binary files a/public/leaflet/images/marker-icon-2x-orange.png and /dev/null differ
diff --git a/public/leaflet/images/marker-icon-2x-red.png b/public/leaflet/images/marker-icon-2x-red.png
deleted file mode 100644
index 1c26e9f..0000000
Binary files a/public/leaflet/images/marker-icon-2x-red.png and /dev/null differ
diff --git a/public/leaflet/images/marker-icon-2x-violet.png b/public/leaflet/images/marker-icon-2x-violet.png
deleted file mode 100644
index ea748aa..0000000
Binary files a/public/leaflet/images/marker-icon-2x-violet.png and /dev/null differ
diff --git a/public/leaflet/images/marker-icon-2x-yellow.png b/public/leaflet/images/marker-icon-2x-yellow.png
deleted file mode 100644
index 8b677d9..0000000
Binary files a/public/leaflet/images/marker-icon-2x-yellow.png and /dev/null differ
diff --git a/public/leaflet/images/marker-icon-2x.png b/public/leaflet/images/marker-icon-2x.png
deleted file mode 100644
index 88f9e50..0000000
Binary files a/public/leaflet/images/marker-icon-2x.png and /dev/null differ
diff --git a/public/leaflet/images/marker-icon-black.png b/public/leaflet/images/marker-icon-black.png
deleted file mode 100644
index d262ae4..0000000
Binary files a/public/leaflet/images/marker-icon-black.png and /dev/null differ
diff --git a/public/leaflet/images/marker-icon-blue.png b/public/leaflet/images/marker-icon-blue.png
deleted file mode 100644
index e2e9f75..0000000
Binary files a/public/leaflet/images/marker-icon-blue.png and /dev/null differ
diff --git a/public/leaflet/images/marker-icon-gold.png b/public/leaflet/images/marker-icon-gold.png
deleted file mode 100644
index 162fada..0000000
Binary files a/public/leaflet/images/marker-icon-gold.png and /dev/null differ
diff --git a/public/leaflet/images/marker-icon-green.png b/public/leaflet/images/marker-icon-green.png
deleted file mode 100644
index 56db5ea..0000000
Binary files a/public/leaflet/images/marker-icon-green.png and /dev/null differ
diff --git a/public/leaflet/images/marker-icon-grey.png b/public/leaflet/images/marker-icon-grey.png
deleted file mode 100644
index ebbab8e..0000000
Binary files a/public/leaflet/images/marker-icon-grey.png and /dev/null differ
diff --git a/public/leaflet/images/marker-icon-orange.png b/public/leaflet/images/marker-icon-orange.png
deleted file mode 100644
index fbbce7b..0000000
Binary files a/public/leaflet/images/marker-icon-orange.png and /dev/null differ
diff --git a/public/leaflet/images/marker-icon-red.png b/public/leaflet/images/marker-icon-red.png
deleted file mode 100644
index 3e64e06..0000000
Binary files a/public/leaflet/images/marker-icon-red.png and /dev/null differ
diff --git a/public/leaflet/images/marker-icon-violet.png b/public/leaflet/images/marker-icon-violet.png
deleted file mode 100644
index 28efc3c..0000000
Binary files a/public/leaflet/images/marker-icon-violet.png and /dev/null differ
diff --git a/public/leaflet/images/marker-icon-yellow.png b/public/leaflet/images/marker-icon-yellow.png
deleted file mode 100644
index b011eea..0000000
Binary files a/public/leaflet/images/marker-icon-yellow.png and /dev/null differ
diff --git a/public/leaflet/images/marker-icon.png b/public/leaflet/images/marker-icon.png
deleted file mode 100644
index 950edf2..0000000
Binary files a/public/leaflet/images/marker-icon.png and /dev/null differ
diff --git a/public/leaflet/images/marker-shadow.png b/public/leaflet/images/marker-shadow.png
deleted file mode 100644
index 84c5808..0000000
Binary files a/public/leaflet/images/marker-shadow.png and /dev/null differ
diff --git a/public/leaflet/images/marker-tiny-icon-black.png b/public/leaflet/images/marker-tiny-icon-black.png
deleted file mode 100644
index ccdda09..0000000
Binary files a/public/leaflet/images/marker-tiny-icon-black.png and /dev/null differ
diff --git a/public/leaflet/images/marker-tiny-icon-blue.png b/public/leaflet/images/marker-tiny-icon-blue.png
deleted file mode 100644
index 9a9d93c..0000000
Binary files a/public/leaflet/images/marker-tiny-icon-blue.png and /dev/null differ
diff --git a/public/leaflet/images/marker-tiny-icon-green.png b/public/leaflet/images/marker-tiny-icon-green.png
deleted file mode 100644
index df70e57..0000000
Binary files a/public/leaflet/images/marker-tiny-icon-green.png and /dev/null differ
diff --git a/public/leaflet/images/marker-tiny-icon-grey.png b/public/leaflet/images/marker-tiny-icon-grey.png
deleted file mode 100644
index a329018..0000000
Binary files a/public/leaflet/images/marker-tiny-icon-grey.png and /dev/null differ
diff --git a/public/leaflet/images/marker-tiny-icon-orange.png b/public/leaflet/images/marker-tiny-icon-orange.png
deleted file mode 100644
index 8a133c5..0000000
Binary files a/public/leaflet/images/marker-tiny-icon-orange.png and /dev/null differ
diff --git a/public/leaflet/images/marker-tiny-icon-red.png b/public/leaflet/images/marker-tiny-icon-red.png
deleted file mode 100644
index e9c057a..0000000
Binary files a/public/leaflet/images/marker-tiny-icon-red.png and /dev/null differ
diff --git a/public/leaflet/images/marker-tiny-icon-violet.png b/public/leaflet/images/marker-tiny-icon-violet.png
deleted file mode 100644
index 04d7b7a..0000000
Binary files a/public/leaflet/images/marker-tiny-icon-violet.png and /dev/null differ
diff --git a/public/leaflet/images/marker-tiny-icon-yellow.png b/public/leaflet/images/marker-tiny-icon-yellow.png
deleted file mode 100644
index b65b6cf..0000000
Binary files a/public/leaflet/images/marker-tiny-icon-yellow.png and /dev/null differ
diff --git a/public/leaflet/leaflet.css b/public/leaflet/leaflet.css
deleted file mode 100644
index 2961b76..0000000
--- a/public/leaflet/leaflet.css
+++ /dev/null
@@ -1,661 +0,0 @@
-/* required styles */
-
-.leaflet-pane,
-.leaflet-tile,
-.leaflet-marker-icon,
-.leaflet-marker-shadow,
-.leaflet-tile-container,
-.leaflet-pane > svg,
-.leaflet-pane > canvas,
-.leaflet-zoom-box,
-.leaflet-image-layer,
-.leaflet-layer {
- position: absolute;
- left: 0;
- top: 0;
- }
-.leaflet-container {
- overflow: hidden;
- }
-.leaflet-tile,
-.leaflet-marker-icon,
-.leaflet-marker-shadow {
- -webkit-user-select: none;
- -moz-user-select: none;
- user-select: none;
- -webkit-user-drag: none;
- }
-/* Prevents IE11 from highlighting tiles in blue */
-.leaflet-tile::selection {
- background: transparent;
-}
-/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
-.leaflet-safari .leaflet-tile {
- image-rendering: -webkit-optimize-contrast;
- }
-/* hack that prevents hw layers "stretching" when loading new tiles */
-.leaflet-safari .leaflet-tile-container {
- width: 1600px;
- height: 1600px;
- -webkit-transform-origin: 0 0;
- }
-.leaflet-marker-icon,
-.leaflet-marker-shadow {
- display: block;
- }
-/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
-/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
-.leaflet-container .leaflet-overlay-pane svg {
- max-width: none !important;
- max-height: none !important;
- }
-.leaflet-container .leaflet-marker-pane img,
-.leaflet-container .leaflet-shadow-pane img,
-.leaflet-container .leaflet-tile-pane img,
-.leaflet-container img.leaflet-image-layer,
-.leaflet-container .leaflet-tile {
- max-width: none !important;
- max-height: none !important;
- width: auto;
- padding: 0;
- }
-
-.leaflet-container img.leaflet-tile {
- /* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
- mix-blend-mode: plus-lighter;
-}
-
-.leaflet-container.leaflet-touch-zoom {
- -ms-touch-action: pan-x pan-y;
- touch-action: pan-x pan-y;
- }
-.leaflet-container.leaflet-touch-drag {
- -ms-touch-action: pinch-zoom;
- /* Fallback for FF which doesn't support pinch-zoom */
- touch-action: none;
- touch-action: pinch-zoom;
-}
-.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
- -ms-touch-action: none;
- touch-action: none;
-}
-.leaflet-container {
- -webkit-tap-highlight-color: transparent;
-}
-.leaflet-container a {
- -webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
-}
-.leaflet-tile {
- filter: inherit;
- visibility: hidden;
- }
-.leaflet-tile-loaded {
- visibility: inherit;
- }
-.leaflet-zoom-box {
- width: 0;
- height: 0;
- -moz-box-sizing: border-box;
- box-sizing: border-box;
- z-index: 800;
- }
-/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
-.leaflet-overlay-pane svg {
- -moz-user-select: none;
- }
-
-.leaflet-pane { z-index: 400; }
-
-.leaflet-tile-pane { z-index: 200; }
-.leaflet-overlay-pane { z-index: 400; }
-.leaflet-shadow-pane { z-index: 500; }
-.leaflet-marker-pane { z-index: 600; }
-.leaflet-tooltip-pane { z-index: 650; }
-.leaflet-popup-pane { z-index: 700; }
-
-.leaflet-map-pane canvas { z-index: 100; }
-.leaflet-map-pane svg { z-index: 200; }
-
-.leaflet-vml-shape {
- width: 1px;
- height: 1px;
- }
-.lvml {
- behavior: url(#default#VML);
- display: inline-block;
- position: absolute;
- }
-
-
-/* control positioning */
-
-.leaflet-control {
- position: relative;
- z-index: 800;
- pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
- pointer-events: auto;
- }
-.leaflet-top,
-.leaflet-bottom {
- position: absolute;
- z-index: 1000;
- pointer-events: none;
- }
-.leaflet-top {
- top: 0;
- }
-.leaflet-right {
- right: 0;
- }
-.leaflet-bottom {
- bottom: 0;
- }
-.leaflet-left {
- left: 0;
- }
-.leaflet-control {
- float: left;
- clear: both;
- }
-.leaflet-right .leaflet-control {
- float: right;
- }
-.leaflet-top .leaflet-control {
- margin-top: 10px;
- }
-.leaflet-bottom .leaflet-control {
- margin-bottom: 10px;
- }
-.leaflet-left .leaflet-control {
- margin-left: 10px;
- }
-.leaflet-right .leaflet-control {
- margin-right: 10px;
- }
-
-
-/* zoom and fade animations */
-
-.leaflet-fade-anim .leaflet-popup {
- opacity: 0;
- -webkit-transition: opacity 0.2s linear;
- -moz-transition: opacity 0.2s linear;
- transition: opacity 0.2s linear;
- }
-.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
- opacity: 1;
- }
-.leaflet-zoom-animated {
- -webkit-transform-origin: 0 0;
- -ms-transform-origin: 0 0;
- transform-origin: 0 0;
- }
-svg.leaflet-zoom-animated {
- will-change: transform;
-}
-
-.leaflet-zoom-anim .leaflet-zoom-animated {
- -webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
- -moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
- transition: transform 0.25s cubic-bezier(0,0,0.25,1);
- }
-.leaflet-zoom-anim .leaflet-tile,
-.leaflet-pan-anim .leaflet-tile {
- -webkit-transition: none;
- -moz-transition: none;
- transition: none;
- }
-
-.leaflet-zoom-anim .leaflet-zoom-hide {
- visibility: hidden;
- }
-
-
-/* cursors */
-
-.leaflet-interactive {
- cursor: pointer;
- }
-.leaflet-grab {
- cursor: -webkit-grab;
- cursor: -moz-grab;
- cursor: grab;
- }
-.leaflet-crosshair,
-.leaflet-crosshair .leaflet-interactive {
- cursor: crosshair;
- }
-.leaflet-popup-pane,
-.leaflet-control {
- cursor: auto;
- }
-.leaflet-dragging .leaflet-grab,
-.leaflet-dragging .leaflet-grab .leaflet-interactive,
-.leaflet-dragging .leaflet-marker-draggable {
- cursor: move;
- cursor: -webkit-grabbing;
- cursor: -moz-grabbing;
- cursor: grabbing;
- }
-
-/* marker & overlays interactivity */
-.leaflet-marker-icon,
-.leaflet-marker-shadow,
-.leaflet-image-layer,
-.leaflet-pane > svg path,
-.leaflet-tile-container {
- pointer-events: none;
- }
-
-.leaflet-marker-icon.leaflet-interactive,
-.leaflet-image-layer.leaflet-interactive,
-.leaflet-pane > svg path.leaflet-interactive,
-svg.leaflet-image-layer.leaflet-interactive path {
- pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
- pointer-events: auto;
- }
-
-/* visual tweaks */
-
-.leaflet-container {
- background: #ddd;
- outline-offset: 1px;
- }
-.leaflet-container a {
- color: #0078A8;
- }
-.leaflet-zoom-box {
- border: 2px dotted #38f;
- background: rgba(255,255,255,0.5);
- }
-
-
-/* general typography */
-.leaflet-container {
- font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
- font-size: 12px;
- font-size: 0.75rem;
- line-height: 1.5;
- }
-
-
-/* general toolbar styles */
-
-.leaflet-bar {
- box-shadow: 0 1px 5px rgba(0,0,0,0.65);
- border-radius: 4px;
- }
-.leaflet-bar a {
- background-color: #fff;
- border-bottom: 1px solid #ccc;
- width: 26px;
- height: 26px;
- line-height: 26px;
- display: block;
- text-align: center;
- text-decoration: none;
- color: black;
- }
-.leaflet-bar a,
-.leaflet-control-layers-toggle {
- background-position: 50% 50%;
- background-repeat: no-repeat;
- display: block;
- }
-.leaflet-bar a:hover,
-.leaflet-bar a:focus {
- background-color: #f4f4f4;
- }
-.leaflet-bar a:first-child {
- border-top-left-radius: 4px;
- border-top-right-radius: 4px;
- }
-.leaflet-bar a:last-child {
- border-bottom-left-radius: 4px;
- border-bottom-right-radius: 4px;
- border-bottom: none;
- }
-.leaflet-bar a.leaflet-disabled {
- cursor: default;
- background-color: #f4f4f4;
- color: #bbb;
- }
-
-.leaflet-touch .leaflet-bar a {
- width: 30px;
- height: 30px;
- line-height: 30px;
- }
-.leaflet-touch .leaflet-bar a:first-child {
- border-top-left-radius: 2px;
- border-top-right-radius: 2px;
- }
-.leaflet-touch .leaflet-bar a:last-child {
- border-bottom-left-radius: 2px;
- border-bottom-right-radius: 2px;
- }
-
-/* zoom control */
-
-.leaflet-control-zoom-in,
-.leaflet-control-zoom-out {
- font: bold 18px 'Lucida Console', Monaco, monospace;
- text-indent: 1px;
- }
-
-.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
- font-size: 22px;
- }
-
-
-/* layers control */
-
-.leaflet-control-layers {
- box-shadow: 0 1px 5px rgba(0,0,0,0.4);
- background: #fff;
- border-radius: 5px;
- }
-.leaflet-control-layers-toggle {
- background-image: url(images/layers.png);
- width: 36px;
- height: 36px;
- }
-.leaflet-retina .leaflet-control-layers-toggle {
- background-image: url(images/layers-2x.png);
- background-size: 26px 26px;
- }
-.leaflet-touch .leaflet-control-layers-toggle {
- width: 44px;
- height: 44px;
- }
-.leaflet-control-layers .leaflet-control-layers-list,
-.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
- display: none;
- }
-.leaflet-control-layers-expanded .leaflet-control-layers-list {
- display: block;
- position: relative;
- }
-.leaflet-control-layers-expanded {
- padding: 6px 10px 6px 6px;
- color: #333;
- background: #fff;
- }
-.leaflet-control-layers-scrollbar {
- overflow-y: scroll;
- overflow-x: hidden;
- padding-right: 5px;
- }
-.leaflet-control-layers-selector {
- margin-top: 2px;
- position: relative;
- top: 1px;
- }
-.leaflet-control-layers label {
- display: block;
- font-size: 13px;
- font-size: 1.08333em;
- }
-.leaflet-control-layers-separator {
- height: 0;
- border-top: 1px solid #ddd;
- margin: 5px -10px 5px -6px;
- }
-
-/* Default icon URLs */
-.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
- background-image: url(images/marker-icon.png);
- }
-
-
-/* attribution and scale controls */
-
-.leaflet-container .leaflet-control-attribution {
- background: #fff;
- background: rgba(255, 255, 255, 0.8);
- margin: 0;
- }
-.leaflet-control-attribution,
-.leaflet-control-scale-line {
- padding: 0 5px;
- color: #333;
- line-height: 1.4;
- }
-.leaflet-control-attribution a {
- text-decoration: none;
- }
-.leaflet-control-attribution a:hover,
-.leaflet-control-attribution a:focus {
- text-decoration: underline;
- }
-.leaflet-attribution-flag {
- display: inline !important;
- vertical-align: baseline !important;
- width: 1em;
- height: 0.6669em;
- }
-.leaflet-left .leaflet-control-scale {
- margin-left: 5px;
- }
-.leaflet-bottom .leaflet-control-scale {
- margin-bottom: 5px;
- }
-.leaflet-control-scale-line {
- border: 2px solid #777;
- border-top: none;
- line-height: 1.1;
- padding: 2px 5px 1px;
- white-space: nowrap;
- -moz-box-sizing: border-box;
- box-sizing: border-box;
- background: rgba(255, 255, 255, 0.8);
- text-shadow: 1px 1px #fff;
- }
-.leaflet-control-scale-line:not(:first-child) {
- border-top: 2px solid #777;
- border-bottom: none;
- margin-top: -2px;
- }
-.leaflet-control-scale-line:not(:first-child):not(:last-child) {
- border-bottom: 2px solid #777;
- }
-
-.leaflet-touch .leaflet-control-attribution,
-.leaflet-touch .leaflet-control-layers,
-.leaflet-touch .leaflet-bar {
- box-shadow: none;
- }
-.leaflet-touch .leaflet-control-layers,
-.leaflet-touch .leaflet-bar {
- border: 2px solid rgba(0,0,0,0.2);
- background-clip: padding-box;
- }
-
-
-/* popup */
-
-.leaflet-popup {
- position: absolute;
- text-align: center;
- margin-bottom: 20px;
- }
-.leaflet-popup-content-wrapper {
- padding: 1px;
- text-align: left;
- border-radius: 12px;
- }
-.leaflet-popup-content {
- margin: 13px 24px 13px 20px;
- line-height: 1.3;
- font-size: 13px;
- font-size: 1.08333em;
- min-height: 1px;
- }
-.leaflet-popup-content p {
- margin: 17px 0;
- margin: 1.3em 0;
- }
-.leaflet-popup-tip-container {
- width: 40px;
- height: 20px;
- position: absolute;
- left: 50%;
- margin-top: -1px;
- margin-left: -20px;
- overflow: hidden;
- pointer-events: none;
- }
-.leaflet-popup-tip {
- width: 17px;
- height: 17px;
- padding: 1px;
-
- margin: -10px auto 0;
- pointer-events: auto;
-
- -webkit-transform: rotate(45deg);
- -moz-transform: rotate(45deg);
- -ms-transform: rotate(45deg);
- transform: rotate(45deg);
- }
-.leaflet-popup-content-wrapper,
-.leaflet-popup-tip {
- background: white;
- color: #333;
- box-shadow: 0 3px 14px rgba(0,0,0,0.4);
- }
-.leaflet-container a.leaflet-popup-close-button {
- position: absolute;
- top: 0;
- right: 0;
- border: none;
- text-align: center;
- width: 24px;
- height: 24px;
- font: 16px/24px Tahoma, Verdana, sans-serif;
- color: #757575;
- text-decoration: none;
- background: transparent;
- }
-.leaflet-container a.leaflet-popup-close-button:hover,
-.leaflet-container a.leaflet-popup-close-button:focus {
- color: #585858;
- }
-.leaflet-popup-scrolled {
- overflow: auto;
- }
-
-.leaflet-oldie .leaflet-popup-content-wrapper {
- -ms-zoom: 1;
- }
-.leaflet-oldie .leaflet-popup-tip {
- width: 24px;
- margin: 0 auto;
-
- -ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
- filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
- }
-
-.leaflet-oldie .leaflet-control-zoom,
-.leaflet-oldie .leaflet-control-layers,
-.leaflet-oldie .leaflet-popup-content-wrapper,
-.leaflet-oldie .leaflet-popup-tip {
- border: 1px solid #999;
- }
-
-
-/* div icon */
-
-.leaflet-div-icon {
- background: #fff;
- border: 1px solid #666;
- }
-
-
-/* Tooltip */
-/* Base styles for the element that has a tooltip */
-.leaflet-tooltip {
- position: absolute;
- padding: 6px;
- background-color: #fff;
- border: 1px solid #fff;
- border-radius: 3px;
- color: #222;
- white-space: nowrap;
- -webkit-user-select: none;
- -moz-user-select: none;
- -ms-user-select: none;
- user-select: none;
- pointer-events: none;
- box-shadow: 0 1px 3px rgba(0,0,0,0.4);
- }
-.leaflet-tooltip.leaflet-interactive {
- cursor: pointer;
- pointer-events: auto;
- }
-.leaflet-tooltip-top:before,
-.leaflet-tooltip-bottom:before,
-.leaflet-tooltip-left:before,
-.leaflet-tooltip-right:before {
- position: absolute;
- pointer-events: none;
- border: 6px solid transparent;
- background: transparent;
- content: "";
- }
-
-/* Directions */
-
-.leaflet-tooltip-bottom {
- margin-top: 6px;
-}
-.leaflet-tooltip-top {
- margin-top: -6px;
-}
-.leaflet-tooltip-bottom:before,
-.leaflet-tooltip-top:before {
- left: 50%;
- margin-left: -6px;
- }
-.leaflet-tooltip-top:before {
- bottom: 0;
- margin-bottom: -12px;
- border-top-color: #fff;
- }
-.leaflet-tooltip-bottom:before {
- top: 0;
- margin-top: -12px;
- margin-left: -6px;
- border-bottom-color: #fff;
- }
-.leaflet-tooltip-left {
- margin-left: -6px;
-}
-.leaflet-tooltip-right {
- margin-left: 6px;
-}
-.leaflet-tooltip-left:before,
-.leaflet-tooltip-right:before {
- top: 50%;
- margin-top: -6px;
- }
-.leaflet-tooltip-left:before {
- right: 0;
- margin-right: -12px;
- border-left-color: #fff;
- }
-.leaflet-tooltip-right:before {
- left: 0;
- margin-left: -12px;
- border-right-color: #fff;
- }
-
-/* Printing */
-
-@media print {
- /* Prevent printers from removing background-images of controls. */
- .leaflet-control {
- -webkit-print-color-adjust: exact;
- print-color-adjust: exact;
- }
- }
diff --git a/public/leaflet/leaflet.js b/public/leaflet/leaflet.js
deleted file mode 100644
index a3bf693..0000000
--- a/public/leaflet/leaflet.js
+++ /dev/null
@@ -1,6 +0,0 @@
-/* @preserve
- * Leaflet 1.9.4, a JS library for interactive maps. https://leafletjs.com
- * (c) 2010-2023 Vladimir Agafonkin, (c) 2010-2011 CloudMade
- */
-!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).leaflet={})}(this,function(t){"use strict";function l(t){for(var e,i,n=1,o=arguments.length;n=this.min.x&&i.x<=this.max.x&&e.y>=this.min.y&&i.y<=this.max.y},intersects:function(t){t=_(t);var e=this.min,i=this.max,n=t.min,t=t.max,o=t.x>=e.x&&n.x<=i.x,t=t.y>=e.y&&n.y<=i.y;return o&&t},overlaps:function(t){t=_(t);var e=this.min,i=this.max,n=t.min,t=t.max,o=t.x>e.x&&n.xe.y&&n.y=n.lat&&i.lat<=o.lat&&e.lng>=n.lng&&i.lng<=o.lng},intersects:function(t){t=g(t);var e=this._southWest,i=this._northEast,n=t.getSouthWest(),t=t.getNorthEast(),o=t.lat>=e.lat&&n.lat<=i.lat,t=t.lng>=e.lng&&n.lng<=i.lng;return o&&t},overlaps:function(t){t=g(t);var e=this._southWest,i=this._northEast,n=t.getSouthWest(),t=t.getNorthEast(),o=t.lat>e.lat&&n.late.lng&&n.lng ","http://www.w3.org/2000/svg"===(Wt.firstChild&&Wt.firstChild.namespaceURI));function y(t){return 0<=navigator.userAgent.toLowerCase().indexOf(t)}var b={ie:pt,ielt9:mt,edge:n,webkit:ft,android:gt,android23:vt,androidStock:yt,opera:xt,chrome:wt,gecko:bt,safari:Pt,phantom:Lt,opera12:o,win:Tt,ie3d:Mt,webkit3d:zt,gecko3d:_t,any3d:Ct,mobile:Zt,mobileWebkit:St,mobileWebkit3d:Et,msPointer:kt,pointer:Ot,touch:Bt,touchNative:At,mobileOpera:It,mobileGecko:Rt,retina:Nt,passiveEvents:Dt,canvas:jt,svg:Ht,vml:!Ht&&function(){try{var t=document.createElement("div"),e=(t.innerHTML=' ',t.firstChild);return e.style.behavior="url(#default#VML)",e&&"object"==typeof e.adj}catch(t){return!1}}(),inlineSvg:Wt,mac:0===navigator.platform.indexOf("Mac"),linux:0===navigator.platform.indexOf("Linux")},Ft=b.msPointer?"MSPointerDown":"pointerdown",Ut=b.msPointer?"MSPointerMove":"pointermove",Vt=b.msPointer?"MSPointerUp":"pointerup",qt=b.msPointer?"MSPointerCancel":"pointercancel",Gt={touchstart:Ft,touchmove:Ut,touchend:Vt,touchcancel:qt},Kt={touchstart:function(t,e){e.MSPOINTER_TYPE_TOUCH&&e.pointerType===e.MSPOINTER_TYPE_TOUCH&&O(e);ee(t,e)},touchmove:ee,touchend:ee,touchcancel:ee},Yt={},Xt=!1;function Jt(t,e,i){return"touchstart"!==e||Xt||(document.addEventListener(Ft,$t,!0),document.addEventListener(Ut,Qt,!0),document.addEventListener(Vt,te,!0),document.addEventListener(qt,te,!0),Xt=!0),Kt[e]?(i=Kt[e].bind(this,i),t.addEventListener(Gt[e],i,!1),i):(console.warn("wrong event specified:",e),u)}function $t(t){Yt[t.pointerId]=t}function Qt(t){Yt[t.pointerId]&&(Yt[t.pointerId]=t)}function te(t){delete Yt[t.pointerId]}function ee(t,e){if(e.pointerType!==(e.MSPOINTER_TYPE_MOUSE||"mouse")){for(var i in e.touches=[],Yt)e.touches.push(Yt[i]);e.changedTouches=[e],t(e)}}var ie=200;function ne(t,i){t.addEventListener("dblclick",i);var n,o=0;function e(t){var e;1!==t.detail?n=t.detail:"mouse"===t.pointerType||t.sourceCapabilities&&!t.sourceCapabilities.firesTouchEvents||((e=Ne(t)).some(function(t){return t instanceof HTMLLabelElement&&t.attributes.for})&&!e.some(function(t){return t instanceof HTMLInputElement||t instanceof HTMLSelectElement})||((e=Date.now())-o<=ie?2===++n&&i(function(t){var e,i,n={};for(i in t)e=t[i],n[i]=e&&e.bind?e.bind(t):e;return(t=n).type="dblclick",n.detail=2,n.isTrusted=!1,n._simulated=!0,n}(t)):n=1,o=e))}return t.addEventListener("click",e),{dblclick:i,simDblclick:e}}var oe,se,re,ae,he,le,ue=we(["transform","webkitTransform","OTransform","MozTransform","msTransform"]),ce=we(["webkitTransition","transition","OTransition","MozTransition","msTransition"]),de="webkitTransition"===ce||"OTransition"===ce?ce+"End":"transitionend";function _e(t){return"string"==typeof t?document.getElementById(t):t}function pe(t,e){var i=t.style[e]||t.currentStyle&&t.currentStyle[e];return"auto"===(i=i&&"auto"!==i||!document.defaultView?i:(t=document.defaultView.getComputedStyle(t,null))?t[e]:null)?null:i}function P(t,e,i){t=document.createElement(t);return t.className=e||"",i&&i.appendChild(t),t}function T(t){var e=t.parentNode;e&&e.removeChild(t)}function me(t){for(;t.firstChild;)t.removeChild(t.firstChild)}function fe(t){var e=t.parentNode;e&&e.lastChild!==t&&e.appendChild(t)}function ge(t){var e=t.parentNode;e&&e.firstChild!==t&&e.insertBefore(t,e.firstChild)}function ve(t,e){return void 0!==t.classList?t.classList.contains(e):0<(t=xe(t)).length&&new RegExp("(^|\\s)"+e+"(\\s|$)").test(t)}function M(t,e){var i;if(void 0!==t.classList)for(var n=F(e),o=0,s=n.length;othis.options.maxZoom)?this.setZoom(t):this},panInsideBounds:function(t,e){this._enforcingBounds=!0;var i=this.getCenter(),t=this._limitCenter(i,this._zoom,g(t));return i.equals(t)||this.panTo(t,e),this._enforcingBounds=!1,this},panInside:function(t,e){var i=m((e=e||{}).paddingTopLeft||e.padding||[0,0]),n=m(e.paddingBottomRight||e.padding||[0,0]),o=this.project(this.getCenter()),t=this.project(t),s=this.getPixelBounds(),i=_([s.min.add(i),s.max.subtract(n)]),s=i.getSize();return i.contains(t)||(this._enforcingBounds=!0,n=t.subtract(i.getCenter()),i=i.extend(t).getSize().subtract(s),o.x+=n.x<0?-i.x:i.x,o.y+=n.y<0?-i.y:i.y,this.panTo(this.unproject(o),e),this._enforcingBounds=!1),this},invalidateSize:function(t){if(!this._loaded)return this;t=l({animate:!1,pan:!0},!0===t?{animate:!0}:t);var e=this.getSize(),i=(this._sizeChanged=!0,this._lastCenter=null,this.getSize()),n=e.divideBy(2).round(),o=i.divideBy(2).round(),n=n.subtract(o);return n.x||n.y?(t.animate&&t.pan?this.panBy(n):(t.pan&&this._rawPanBy(n),this.fire("move"),t.debounceMoveend?(clearTimeout(this._sizeTimer),this._sizeTimer=setTimeout(a(this.fire,this,"moveend"),200)):this.fire("moveend")),this.fire("resize",{oldSize:e,newSize:i})):this},stop:function(){return this.setZoom(this._limitZoom(this._zoom)),this.options.zoomSnap||this.fire("viewreset"),this._stop()},locate:function(t){var e,i;return t=this._locateOptions=l({timeout:1e4,watch:!1},t),"geolocation"in navigator?(e=a(this._handleGeolocationResponse,this),i=a(this._handleGeolocationError,this),t.watch?this._locationWatchId=navigator.geolocation.watchPosition(e,i,t):navigator.geolocation.getCurrentPosition(e,i,t)):this._handleGeolocationError({code:0,message:"Geolocation not supported."}),this},stopLocate:function(){return navigator.geolocation&&navigator.geolocation.clearWatch&&navigator.geolocation.clearWatch(this._locationWatchId),this._locateOptions&&(this._locateOptions.setView=!1),this},_handleGeolocationError:function(t){var e;this._container._leaflet_id&&(e=t.code,t=t.message||(1===e?"permission denied":2===e?"position unavailable":"timeout"),this._locateOptions.setView&&!this._loaded&&this.fitWorld(),this.fire("locationerror",{code:e,message:"Geolocation error: "+t+"."}))},_handleGeolocationResponse:function(t){if(this._container._leaflet_id){var e,i,n=new v(t.coords.latitude,t.coords.longitude),o=n.toBounds(2*t.coords.accuracy),s=this._locateOptions,r=(s.setView&&(e=this.getBoundsZoom(o),this.setView(n,s.maxZoom?Math.min(e,s.maxZoom):e)),{latlng:n,bounds:o,timestamp:t.timestamp});for(i in t.coords)"number"==typeof t.coords[i]&&(r[i]=t.coords[i]);this.fire("locationfound",r)}},addHandler:function(t,e){return e&&(e=this[t]=new e(this),this._handlers.push(e),this.options[t]&&e.enable()),this},remove:function(){if(this._initEvents(!0),this.options.maxBounds&&this.off("moveend",this._panInsideMaxBounds),this._containerId!==this._container._leaflet_id)throw new Error("Map container is being reused by another instance");try{delete this._container._leaflet_id,delete this._containerId}catch(t){this._container._leaflet_id=void 0,this._containerId=void 0}for(var t in void 0!==this._locationWatchId&&this.stopLocate(),this._stop(),T(this._mapPane),this._clearControlPos&&this._clearControlPos(),this._resizeRequest&&(r(this._resizeRequest),this._resizeRequest=null),this._clearHandlers(),this._loaded&&this.fire("unload"),this._layers)this._layers[t].remove();for(t in this._panes)T(this._panes[t]);return this._layers=[],this._panes=[],delete this._mapPane,delete this._renderer,this},createPane:function(t,e){e=P("div","leaflet-pane"+(t?" leaflet-"+t.replace("Pane","")+"-pane":""),e||this._mapPane);return t&&(this._panes[t]=e),e},getCenter:function(){return this._checkIfLoaded(),this._lastCenter&&!this._moved()?this._lastCenter.clone():this.layerPointToLatLng(this._getCenterLayerPoint())},getZoom:function(){return this._zoom},getBounds:function(){var t=this.getPixelBounds();return new s(this.unproject(t.getBottomLeft()),this.unproject(t.getTopRight()))},getMinZoom:function(){return void 0===this.options.minZoom?this._layersMinZoom||0:this.options.minZoom},getMaxZoom:function(){return void 0===this.options.maxZoom?void 0===this._layersMaxZoom?1/0:this._layersMaxZoom:this.options.maxZoom},getBoundsZoom:function(t,e,i){t=g(t),i=m(i||[0,0]);var n=this.getZoom()||0,o=this.getMinZoom(),s=this.getMaxZoom(),r=t.getNorthWest(),t=t.getSouthEast(),i=this.getSize().subtract(i),t=_(this.project(t,n),this.project(r,n)).getSize(),r=b.any3d?this.options.zoomSnap:1,a=i.x/t.x,i=i.y/t.y,t=e?Math.max(a,i):Math.min(a,i),n=this.getScaleZoom(t,n);return r&&(n=Math.round(n/(r/100))*(r/100),n=e?Math.ceil(n/r)*r:Math.floor(n/r)*r),Math.max(o,Math.min(s,n))},getSize:function(){return this._size&&!this._sizeChanged||(this._size=new p(this._container.clientWidth||0,this._container.clientHeight||0),this._sizeChanged=!1),this._size.clone()},getPixelBounds:function(t,e){t=this._getTopLeftPoint(t,e);return new f(t,t.add(this.getSize()))},getPixelOrigin:function(){return this._checkIfLoaded(),this._pixelOrigin},getPixelWorldBounds:function(t){return this.options.crs.getProjectedBounds(void 0===t?this.getZoom():t)},getPane:function(t){return"string"==typeof t?this._panes[t]:t},getPanes:function(){return this._panes},getContainer:function(){return this._container},getZoomScale:function(t,e){var i=this.options.crs;return e=void 0===e?this._zoom:e,i.scale(t)/i.scale(e)},getScaleZoom:function(t,e){var i=this.options.crs,t=(e=void 0===e?this._zoom:e,i.zoom(t*i.scale(e)));return isNaN(t)?1/0:t},project:function(t,e){return e=void 0===e?this._zoom:e,this.options.crs.latLngToPoint(w(t),e)},unproject:function(t,e){return e=void 0===e?this._zoom:e,this.options.crs.pointToLatLng(m(t),e)},layerPointToLatLng:function(t){t=m(t).add(this.getPixelOrigin());return this.unproject(t)},latLngToLayerPoint:function(t){return this.project(w(t))._round()._subtract(this.getPixelOrigin())},wrapLatLng:function(t){return this.options.crs.wrapLatLng(w(t))},wrapLatLngBounds:function(t){return this.options.crs.wrapLatLngBounds(g(t))},distance:function(t,e){return this.options.crs.distance(w(t),w(e))},containerPointToLayerPoint:function(t){return m(t).subtract(this._getMapPanePos())},layerPointToContainerPoint:function(t){return m(t).add(this._getMapPanePos())},containerPointToLatLng:function(t){t=this.containerPointToLayerPoint(m(t));return this.layerPointToLatLng(t)},latLngToContainerPoint:function(t){return this.layerPointToContainerPoint(this.latLngToLayerPoint(w(t)))},mouseEventToContainerPoint:function(t){return De(t,this._container)},mouseEventToLayerPoint:function(t){return this.containerPointToLayerPoint(this.mouseEventToContainerPoint(t))},mouseEventToLatLng:function(t){return this.layerPointToLatLng(this.mouseEventToLayerPoint(t))},_initContainer:function(t){t=this._container=_e(t);if(!t)throw new Error("Map container not found.");if(t._leaflet_id)throw new Error("Map container is already initialized.");S(t,"scroll",this._onScroll,this),this._containerId=h(t)},_initLayout:function(){var t=this._container,e=(this._fadeAnimated=this.options.fadeAnimation&&b.any3d,M(t,"leaflet-container"+(b.touch?" leaflet-touch":"")+(b.retina?" leaflet-retina":"")+(b.ielt9?" leaflet-oldie":"")+(b.safari?" leaflet-safari":"")+(this._fadeAnimated?" leaflet-fade-anim":"")),pe(t,"position"));"absolute"!==e&&"relative"!==e&&"fixed"!==e&&"sticky"!==e&&(t.style.position="relative"),this._initPanes(),this._initControlPos&&this._initControlPos()},_initPanes:function(){var t=this._panes={};this._paneRenderers={},this._mapPane=this.createPane("mapPane",this._container),Z(this._mapPane,new p(0,0)),this.createPane("tilePane"),this.createPane("overlayPane"),this.createPane("shadowPane"),this.createPane("markerPane"),this.createPane("tooltipPane"),this.createPane("popupPane"),this.options.markerZoomAnimation||(M(t.markerPane,"leaflet-zoom-hide"),M(t.shadowPane,"leaflet-zoom-hide"))},_resetView:function(t,e,i){Z(this._mapPane,new p(0,0));var n=!this._loaded,o=(this._loaded=!0,e=this._limitZoom(e),this.fire("viewprereset"),this._zoom!==e);this._moveStart(o,i)._move(t,e)._moveEnd(o),this.fire("viewreset"),n&&this.fire("load")},_moveStart:function(t,e){return t&&this.fire("zoomstart"),e||this.fire("movestart"),this},_move:function(t,e,i,n){void 0===e&&(e=this._zoom);var o=this._zoom!==e;return this._zoom=e,this._lastCenter=t,this._pixelOrigin=this._getNewPixelOrigin(t),n?i&&i.pinch&&this.fire("zoom",i):((o||i&&i.pinch)&&this.fire("zoom",i),this.fire("move",i)),this},_moveEnd:function(t){return t&&this.fire("zoomend"),this.fire("moveend")},_stop:function(){return r(this._flyToFrame),this._panAnim&&this._panAnim.stop(),this},_rawPanBy:function(t){Z(this._mapPane,this._getMapPanePos().subtract(t))},_getZoomSpan:function(){return this.getMaxZoom()-this.getMinZoom()},_panInsideMaxBounds:function(){this._enforcingBounds||this.panInsideBounds(this.options.maxBounds)},_checkIfLoaded:function(){if(!this._loaded)throw new Error("Set map center and zoom first.")},_initEvents:function(t){this._targets={};var e=t?k:S;e((this._targets[h(this._container)]=this)._container,"click dblclick mousedown mouseup mouseover mouseout mousemove contextmenu keypress keydown keyup",this._handleDOMEvent,this),this.options.trackResize&&e(window,"resize",this._onResize,this),b.any3d&&this.options.transform3DLimit&&(t?this.off:this.on).call(this,"moveend",this._onMoveEnd)},_onResize:function(){r(this._resizeRequest),this._resizeRequest=x(function(){this.invalidateSize({debounceMoveend:!0})},this)},_onScroll:function(){this._container.scrollTop=0,this._container.scrollLeft=0},_onMoveEnd:function(){var t=this._getMapPanePos();Math.max(Math.abs(t.x),Math.abs(t.y))>=this.options.transform3DLimit&&this._resetView(this.getCenter(),this.getZoom())},_findEventTargets:function(t,e){for(var i,n=[],o="mouseout"===e||"mouseover"===e,s=t.target||t.srcElement,r=!1;s;){if((i=this._targets[h(s)])&&("click"===e||"preclick"===e)&&this._draggableMoved(i)){r=!0;break}if(i&&i.listens(e,!0)){if(o&&!We(s,t))break;if(n.push(i),o)break}if(s===this._container)break;s=s.parentNode}return n=n.length||r||o||!this.listens(e,!0)?n:[this]},_isClickDisabled:function(t){for(;t&&t!==this._container;){if(t._leaflet_disable_click)return!0;t=t.parentNode}},_handleDOMEvent:function(t){var e,i=t.target||t.srcElement;!this._loaded||i._leaflet_disable_events||"click"===t.type&&this._isClickDisabled(i)||("mousedown"===(e=t.type)&&Me(i),this._fireDOMEvent(t,e))},_mouseEvents:["click","dblclick","mouseover","mouseout","contextmenu"],_fireDOMEvent:function(t,e,i){"click"===t.type&&((a=l({},t)).type="preclick",this._fireDOMEvent(a,a.type,i));var n=this._findEventTargets(t,e);if(i){for(var o=[],s=0;sthis.options.zoomAnimationThreshold)return!1;var n=this.getZoomScale(e),n=this._getCenterOffset(t)._divideBy(1-1/n);if(!0!==i.animate&&!this.getSize().contains(n))return!1;x(function(){this._moveStart(!0,i.noMoveStart||!1)._animateZoom(t,e,!0)},this)}return!0},_animateZoom:function(t,e,i,n){this._mapPane&&(i&&(this._animatingZoom=!0,this._animateToCenter=t,this._animateToZoom=e,M(this._mapPane,"leaflet-zoom-anim")),this.fire("zoomanim",{center:t,zoom:e,noUpdate:n}),this._tempFireZoomEvent||(this._tempFireZoomEvent=this._zoom!==this._animateToZoom),this._move(this._animateToCenter,this._animateToZoom,void 0,!0),setTimeout(a(this._onZoomTransitionEnd,this),250))},_onZoomTransitionEnd:function(){this._animatingZoom&&(this._mapPane&&z(this._mapPane,"leaflet-zoom-anim"),this._animatingZoom=!1,this._move(this._animateToCenter,this._animateToZoom,void 0,!0),this._tempFireZoomEvent&&this.fire("zoom"),delete this._tempFireZoomEvent,this.fire("move"),this._moveEnd(!0))}});function Ue(t){return new B(t)}var B=et.extend({options:{position:"topright"},initialize:function(t){c(this,t)},getPosition:function(){return this.options.position},setPosition:function(t){var e=this._map;return e&&e.removeControl(this),this.options.position=t,e&&e.addControl(this),this},getContainer:function(){return this._container},addTo:function(t){this.remove(),this._map=t;var e=this._container=this.onAdd(t),i=this.getPosition(),t=t._controlCorners[i];return M(e,"leaflet-control"),-1!==i.indexOf("bottom")?t.insertBefore(e,t.firstChild):t.appendChild(e),this._map.on("unload",this.remove,this),this},remove:function(){return this._map&&(T(this._container),this.onRemove&&this.onRemove(this._map),this._map.off("unload",this.remove,this),this._map=null),this},_refocusOnMap:function(t){this._map&&t&&0 ",e=document.createElement("div");return e.innerHTML=t,e.firstChild},_addItem:function(t){var e,i=document.createElement("label"),n=this._map.hasLayer(t.layer),n=(t.overlay?((e=document.createElement("input")).type="checkbox",e.className="leaflet-control-layers-selector",e.defaultChecked=n):e=this._createRadioElement("leaflet-base-layers_"+h(this),n),this._layerControlInputs.push(e),e.layerId=h(t.layer),S(e,"click",this._onInputClick,this),document.createElement("span")),o=(n.innerHTML=" "+t.name,document.createElement("span"));return i.appendChild(o),o.appendChild(e),o.appendChild(n),(t.overlay?this._overlaysList:this._baseLayersList).appendChild(i),this._checkDisabledLayers(),i},_onInputClick:function(){if(!this._preventClick){var t,e,i=this._layerControlInputs,n=[],o=[];this._handlingClick=!0;for(var s=i.length-1;0<=s;s--)t=i[s],e=this._getLayer(t.layerId).layer,t.checked?n.push(e):t.checked||o.push(e);for(s=0;se.options.maxZoom},_expandIfNotCollapsed:function(){return this._map&&!this.options.collapsed&&this.expand(),this},_expandSafely:function(){var t=this._section,e=(this._preventClick=!0,S(t,"click",O),this.expand(),this);setTimeout(function(){k(t,"click",O),e._preventClick=!1})}})),qe=B.extend({options:{position:"topleft",zoomInText:'+ ',zoomInTitle:"Zoom in",zoomOutText:'− ',zoomOutTitle:"Zoom out"},onAdd:function(t){var e="leaflet-control-zoom",i=P("div",e+" leaflet-bar"),n=this.options;return this._zoomInButton=this._createButton(n.zoomInText,n.zoomInTitle,e+"-in",i,this._zoomIn),this._zoomOutButton=this._createButton(n.zoomOutText,n.zoomOutTitle,e+"-out",i,this._zoomOut),this._updateDisabled(),t.on("zoomend zoomlevelschange",this._updateDisabled,this),i},onRemove:function(t){t.off("zoomend zoomlevelschange",this._updateDisabled,this)},disable:function(){return this._disabled=!0,this._updateDisabled(),this},enable:function(){return this._disabled=!1,this._updateDisabled(),this},_zoomIn:function(t){!this._disabled&&this._map._zoomthis._map.getMinZoom()&&this._map.zoomOut(this._map.options.zoomDelta*(t.shiftKey?3:1))},_createButton:function(t,e,i,n,o){i=P("a",i,n);return i.innerHTML=t,i.href="#",i.title=e,i.setAttribute("role","button"),i.setAttribute("aria-label",e),Ie(i),S(i,"click",Re),S(i,"click",o,this),S(i,"click",this._refocusOnMap,this),i},_updateDisabled:function(){var t=this._map,e="leaflet-disabled";z(this._zoomInButton,e),z(this._zoomOutButton,e),this._zoomInButton.setAttribute("aria-disabled","false"),this._zoomOutButton.setAttribute("aria-disabled","false"),!this._disabled&&t._zoom!==t.getMinZoom()||(M(this._zoomOutButton,e),this._zoomOutButton.setAttribute("aria-disabled","true")),!this._disabled&&t._zoom!==t.getMaxZoom()||(M(this._zoomInButton,e),this._zoomInButton.setAttribute("aria-disabled","true"))}}),Ge=(A.mergeOptions({zoomControl:!0}),A.addInitHook(function(){this.options.zoomControl&&(this.zoomControl=new qe,this.addControl(this.zoomControl))}),B.extend({options:{position:"bottomleft",maxWidth:100,metric:!0,imperial:!0},onAdd:function(t){var e="leaflet-control-scale",i=P("div",e),n=this.options;return this._addScales(n,e+"-line",i),t.on(n.updateWhenIdle?"moveend":"move",this._update,this),t.whenReady(this._update,this),i},onRemove:function(t){t.off(this.options.updateWhenIdle?"moveend":"move",this._update,this)},_addScales:function(t,e,i){t.metric&&(this._mScale=P("div",e,i)),t.imperial&&(this._iScale=P("div",e,i))},_update:function(){var t=this._map,e=t.getSize().y/2,t=t.distance(t.containerPointToLatLng([0,e]),t.containerPointToLatLng([this.options.maxWidth,e]));this._updateScales(t)},_updateScales:function(t){this.options.metric&&t&&this._updateMetric(t),this.options.imperial&&t&&this._updateImperial(t)},_updateMetric:function(t){var e=this._getRoundNum(t);this._updateScale(this._mScale,e<1e3?e+" m":e/1e3+" km",e/t)},_updateImperial:function(t){var e,i,t=3.2808399*t;5280'+(b.inlineSvg?' ':"")+"Leaflet "},initialize:function(t){c(this,t),this._attributions={}},onAdd:function(t){for(var e in(t.attributionControl=this)._container=P("div","leaflet-control-attribution"),Ie(this._container),t._layers)t._layers[e].getAttribution&&this.addAttribution(t._layers[e].getAttribution());return this._update(),t.on("layeradd",this._addAttribution,this),this._container},onRemove:function(t){t.off("layeradd",this._addAttribution,this)},_addAttribution:function(t){t.layer.getAttribution&&(this.addAttribution(t.layer.getAttribution()),t.layer.once("remove",function(){this.removeAttribution(t.layer.getAttribution())},this))},setPrefix:function(t){return this.options.prefix=t,this._update(),this},addAttribution:function(t){return t&&(this._attributions[t]||(this._attributions[t]=0),this._attributions[t]++,this._update()),this},removeAttribution:function(t){return t&&this._attributions[t]&&(this._attributions[t]--,this._update()),this},_update:function(){if(this._map){var t,e=[];for(t in this._attributions)this._attributions[t]&&e.push(t);var i=[];this.options.prefix&&i.push(this.options.prefix),e.length&&i.push(e.join(", ")),this._container.innerHTML=i.join('
| ')}}}),n=(A.mergeOptions({attributionControl:!0}),A.addInitHook(function(){this.options.attributionControl&&(new Ke).addTo(this)}),B.Layers=Ve,B.Zoom=qe,B.Scale=Ge,B.Attribution=Ke,Ue.layers=function(t,e,i){return new Ve(t,e,i)},Ue.zoom=function(t){return new qe(t)},Ue.scale=function(t){return new Ge(t)},Ue.attribution=function(t){return new Ke(t)},et.extend({initialize:function(t){this._map=t},enable:function(){return this._enabled||(this._enabled=!0,this.addHooks()),this},disable:function(){return this._enabled&&(this._enabled=!1,this.removeHooks()),this},enabled:function(){return!!this._enabled}})),ft=(n.addTo=function(t,e){return t.addHandler(e,this),this},{Events:e}),Ye=b.touch?"touchstart mousedown":"mousedown",Xe=it.extend({options:{clickTolerance:3},initialize:function(t,e,i,n){c(this,n),this._element=t,this._dragStartTarget=e||t,this._preventOutline=i},enable:function(){this._enabled||(S(this._dragStartTarget,Ye,this._onDown,this),this._enabled=!0)},disable:function(){this._enabled&&(Xe._dragging===this&&this.finishDrag(!0),k(this._dragStartTarget,Ye,this._onDown,this),this._enabled=!1,this._moved=!1)},_onDown:function(t){var e,i;this._enabled&&(this._moved=!1,ve(this._element,"leaflet-zoom-anim")||(t.touches&&1!==t.touches.length?Xe._dragging===this&&this.finishDrag():Xe._dragging||t.shiftKey||1!==t.which&&1!==t.button&&!t.touches||((Xe._dragging=this)._preventOutline&&Me(this._element),Le(),re(),this._moving||(this.fire("down"),i=t.touches?t.touches[0]:t,e=Ce(this._element),this._startPoint=new p(i.clientX,i.clientY),this._startPos=Pe(this._element),this._parentScale=Ze(e),i="mousedown"===t.type,S(document,i?"mousemove":"touchmove",this._onMove,this),S(document,i?"mouseup":"touchend touchcancel",this._onUp,this)))))},_onMove:function(t){var e;this._enabled&&(t.touches&&1
e&&(i.push(t[n]),o=n);oe.max.x&&(i|=2),t.ye.max.y&&(i|=8),i}function ri(t,e,i,n){var o=e.x,e=e.y,s=i.x-o,r=i.y-e,a=s*s+r*r;return 0this._layersMaxZoom&&this.setZoom(this._layersMaxZoom),void 0===this.options.minZoom&&this._layersMinZoom&&this.getZoom()t.y!=n.y>t.y&&t.x<(n.x-i.x)*(t.y-i.y)/(n.y-i.y)+i.x&&(l=!l);return l||yi.prototype._containsPoint.call(this,t,!0)}});var wi=ci.extend({initialize:function(t,e){c(this,e),this._layers={},t&&this.addData(t)},addData:function(t){var e,i,n,o=d(t)?t:t.features;if(o){for(e=0,i=o.length;e×',S(i,"click",function(t){O(t),this.close()},this))},_updateLayout:function(){var t=this._contentNode,e=t.style,i=(e.width="",e.whiteSpace="nowrap",t.offsetWidth),i=Math.min(i,this.options.maxWidth),i=(i=Math.max(i,this.options.minWidth),e.width=i+1+"px",e.whiteSpace="",e.height="",t.offsetHeight),n=this.options.maxHeight,o="leaflet-popup-scrolled";(n&&ns.x&&(r=i.x+a-s.x+o.x),i.x-r-n.x<(a=0)&&(r=i.x-n.x),i.y+e+o.y>s.y&&(a=i.y+e-s.y+o.y),i.y-a-n.y<0&&(a=i.y-n.y),(r||a)&&(this.options.keepInView&&(this._autopanning=!0),t.fire("autopanstart").panBy([r,a]))))},_getAnchor:function(){return m(this._source&&this._source._getPopupAnchor?this._source._getPopupAnchor():[0,0])}})),Ii=(A.mergeOptions({closePopupOnClick:!0}),A.include({openPopup:function(t,e,i){return this._initOverlay(Bi,t,e,i).openOn(this),this},closePopup:function(t){return(t=arguments.length?t:this._popup)&&t.close(),this}}),o.include({bindPopup:function(t,e){return this._popup=this._initOverlay(Bi,this._popup,t,e),this._popupHandlersAdded||(this.on({click:this._openPopup,keypress:this._onKeyPress,remove:this.closePopup,move:this._movePopup}),this._popupHandlersAdded=!0),this},unbindPopup:function(){return this._popup&&(this.off({click:this._openPopup,keypress:this._onKeyPress,remove:this.closePopup,move:this._movePopup}),this._popupHandlersAdded=!1,this._popup=null),this},openPopup:function(t){return this._popup&&(this instanceof ci||(this._popup._source=this),this._popup._prepareOpen(t||this._latlng)&&this._popup.openOn(this._map)),this},closePopup:function(){return this._popup&&this._popup.close(),this},togglePopup:function(){return this._popup&&this._popup.toggle(this),this},isPopupOpen:function(){return!!this._popup&&this._popup.isOpen()},setPopupContent:function(t){return this._popup&&this._popup.setContent(t),this},getPopup:function(){return this._popup},_openPopup:function(t){var e;this._popup&&this._map&&(Re(t),e=t.layer||t.target,this._popup._source!==e||e instanceof fi?(this._popup._source=e,this.openPopup(t.latlng)):this._map.hasLayer(this._popup)?this.closePopup():this.openPopup(t.latlng))},_movePopup:function(t){this._popup.setLatLng(t.latlng)},_onKeyPress:function(t){13===t.originalEvent.keyCode&&this._openPopup(t)}}),Ai.extend({options:{pane:"tooltipPane",offset:[0,0],direction:"auto",permanent:!1,sticky:!1,opacity:.9},onAdd:function(t){Ai.prototype.onAdd.call(this,t),this.setOpacity(this.options.opacity),t.fire("tooltipopen",{tooltip:this}),this._source&&(this.addEventParent(this._source),this._source.fire("tooltipopen",{tooltip:this},!0))},onRemove:function(t){Ai.prototype.onRemove.call(this,t),t.fire("tooltipclose",{tooltip:this}),this._source&&(this.removeEventParent(this._source),this._source.fire("tooltipclose",{tooltip:this},!0))},getEvents:function(){var t=Ai.prototype.getEvents.call(this);return this.options.permanent||(t.preclick=this.close),t},_initLayout:function(){var t="leaflet-tooltip "+(this.options.className||"")+" leaflet-zoom-"+(this._zoomAnimated?"animated":"hide");this._contentNode=this._container=P("div",t),this._container.setAttribute("role","tooltip"),this._container.setAttribute("id","leaflet-tooltip-"+h(this))},_updateLayout:function(){},_adjustPan:function(){},_setPosition:function(t){var e,i=this._map,n=this._container,o=i.latLngToContainerPoint(i.getCenter()),i=i.layerPointToContainerPoint(t),s=this.options.direction,r=n.offsetWidth,a=n.offsetHeight,h=m(this.options.offset),l=this._getAnchor(),i="top"===s?(e=r/2,a):"bottom"===s?(e=r/2,0):(e="center"===s?r/2:"right"===s?0:"left"===s?r:i.xthis.options.maxZoom||nthis.options.maxZoom||void 0!==this.options.minZoom&&oi.max.x)||!e.wrapLat&&(t.yi.max.y))return!1}return!this.options.bounds||(e=this._tileCoordsToBounds(t),g(this.options.bounds).overlaps(e))},_keyToBounds:function(t){return this._tileCoordsToBounds(this._keyToTileCoords(t))},_tileCoordsToNwSe:function(t){var e=this._map,i=this.getTileSize(),n=t.scaleBy(i),i=n.add(i);return[e.unproject(n,t.z),e.unproject(i,t.z)]},_tileCoordsToBounds:function(t){t=this._tileCoordsToNwSe(t),t=new s(t[0],t[1]);return t=this.options.noWrap?t:this._map.wrapLatLngBounds(t)},_tileCoordsToKey:function(t){return t.x+":"+t.y+":"+t.z},_keyToTileCoords:function(t){var t=t.split(":"),e=new p(+t[0],+t[1]);return e.z=+t[2],e},_removeTile:function(t){var e=this._tiles[t];e&&(T(e.el),delete this._tiles[t],this.fire("tileunload",{tile:e.el,coords:this._keyToTileCoords(t)}))},_initTile:function(t){M(t,"leaflet-tile");var e=this.getTileSize();t.style.width=e.x+"px",t.style.height=e.y+"px",t.onselectstart=u,t.onmousemove=u,b.ielt9&&this.options.opacity<1&&C(t,this.options.opacity)},_addTile:function(t,e){var i=this._getTilePos(t),n=this._tileCoordsToKey(t),o=this.createTile(this._wrapCoords(t),a(this._tileReady,this,t));this._initTile(o),this.createTile.length<2&&x(a(this._tileReady,this,t,null,o)),Z(o,i),this._tiles[n]={el:o,coords:t,current:!0},e.appendChild(o),this.fire("tileloadstart",{tile:o,coords:t})},_tileReady:function(t,e,i){e&&this.fire("tileerror",{error:e,tile:i,coords:t});var n=this._tileCoordsToKey(t);(i=this._tiles[n])&&(i.loaded=+new Date,this._map._fadeAnimated?(C(i.el,0),r(this._fadeFrame),this._fadeFrame=x(this._updateOpacity,this)):(i.active=!0,this._pruneTiles()),e||(M(i.el,"leaflet-tile-loaded"),this.fire("tileload",{tile:i.el,coords:t})),this._noTilesToLoad()&&(this._loading=!1,this.fire("load"),b.ielt9||!this._map._fadeAnimated?x(this._pruneTiles,this):setTimeout(a(this._pruneTiles,this),250)))},_getTilePos:function(t){return t.scaleBy(this.getTileSize()).subtract(this._level.origin)},_wrapCoords:function(t){var e=new p(this._wrapX?H(t.x,this._wrapX):t.x,this._wrapY?H(t.y,this._wrapY):t.y);return e.z=t.z,e},_pxBoundsToTileRange:function(t){var e=this.getTileSize();return new f(t.min.unscaleBy(e).floor(),t.max.unscaleBy(e).ceil().subtract([1,1]))},_noTilesToLoad:function(){for(var t in this._tiles)if(!this._tiles[t].loaded)return!1;return!0}});var Di=Ni.extend({options:{minZoom:0,maxZoom:18,subdomains:"abc",errorTileUrl:"",zoomOffset:0,tms:!1,zoomReverse:!1,detectRetina:!1,crossOrigin:!1,referrerPolicy:!1},initialize:function(t,e){this._url=t,(e=c(this,e)).detectRetina&&b.retina&&0')}}catch(t){}return function(t){return document.createElement("<"+t+' xmlns="urn:schemas-microsoft.com:vml" class="lvml">')}}(),zt={_initContainer:function(){this._container=P("div","leaflet-vml-container")},_update:function(){this._map._animatingZoom||(Wi.prototype._update.call(this),this.fire("update"))},_initPath:function(t){var e=t._container=Vi("shape");M(e,"leaflet-vml-shape "+(this.options.className||"")),e.coordsize="1 1",t._path=Vi("path"),e.appendChild(t._path),this._updateStyle(t),this._layers[h(t)]=t},_addPath:function(t){var e=t._container;this._container.appendChild(e),t.options.interactive&&t.addInteractiveTarget(e)},_removePath:function(t){var e=t._container;T(e),t.removeInteractiveTarget(e),delete this._layers[h(t)]},_updateStyle:function(t){var e=t._stroke,i=t._fill,n=t.options,o=t._container;o.stroked=!!n.stroke,o.filled=!!n.fill,n.stroke?(e=e||(t._stroke=Vi("stroke")),o.appendChild(e),e.weight=n.weight+"px",e.color=n.color,e.opacity=n.opacity,n.dashArray?e.dashStyle=d(n.dashArray)?n.dashArray.join(" "):n.dashArray.replace(/( *, *)/g," "):e.dashStyle="",e.endcap=n.lineCap.replace("butt","flat"),e.joinstyle=n.lineJoin):e&&(o.removeChild(e),t._stroke=null),n.fill?(i=i||(t._fill=Vi("fill")),o.appendChild(i),i.color=n.fillColor||n.color,i.opacity=n.fillOpacity):i&&(o.removeChild(i),t._fill=null)},_updateCircle:function(t){var e=t._point.round(),i=Math.round(t._radius),n=Math.round(t._radiusY||i);this._setPath(t,t._empty()?"M0 0":"AL "+e.x+","+e.y+" "+i+","+n+" 0,23592600")},_setPath:function(t,e){t._path.v=e},_bringToFront:function(t){fe(t._container)},_bringToBack:function(t){ge(t._container)}},qi=b.vml?Vi:ct,Gi=Wi.extend({_initContainer:function(){this._container=qi("svg"),this._container.setAttribute("pointer-events","none"),this._rootGroup=qi("g"),this._container.appendChild(this._rootGroup)},_destroyContainer:function(){T(this._container),k(this._container),delete this._container,delete this._rootGroup,delete this._svgSize},_update:function(){var t,e,i;this._map._animatingZoom&&this._bounds||(Wi.prototype._update.call(this),e=(t=this._bounds).getSize(),i=this._container,this._svgSize&&this._svgSize.equals(e)||(this._svgSize=e,i.setAttribute("width",e.x),i.setAttribute("height",e.y)),Z(i,t.min),i.setAttribute("viewBox",[t.min.x,t.min.y,e.x,e.y].join(" ")),this.fire("update"))},_initPath:function(t){var e=t._path=qi("path");t.options.className&&M(e,t.options.className),t.options.interactive&&M(e,"leaflet-interactive"),this._updateStyle(t),this._layers[h(t)]=t},_addPath:function(t){this._rootGroup||this._initContainer(),this._rootGroup.appendChild(t._path),t.addInteractiveTarget(t._path)},_removePath:function(t){T(t._path),t.removeInteractiveTarget(t._path),delete this._layers[h(t)]},_updatePath:function(t){t._project(),t._update()},_updateStyle:function(t){var e=t._path,t=t.options;e&&(t.stroke?(e.setAttribute("stroke",t.color),e.setAttribute("stroke-opacity",t.opacity),e.setAttribute("stroke-width",t.weight),e.setAttribute("stroke-linecap",t.lineCap),e.setAttribute("stroke-linejoin",t.lineJoin),t.dashArray?e.setAttribute("stroke-dasharray",t.dashArray):e.removeAttribute("stroke-dasharray"),t.dashOffset?e.setAttribute("stroke-dashoffset",t.dashOffset):e.removeAttribute("stroke-dashoffset")):e.setAttribute("stroke","none"),t.fill?(e.setAttribute("fill",t.fillColor||t.color),e.setAttribute("fill-opacity",t.fillOpacity),e.setAttribute("fill-rule",t.fillRule||"evenodd")):e.setAttribute("fill","none"))},_updatePoly:function(t,e){this._setPath(t,dt(t._parts,e))},_updateCircle:function(t){var e=t._point,i=Math.max(Math.round(t._radius),1),n="a"+i+","+(Math.max(Math.round(t._radiusY),1)||i)+" 0 1,0 ",e=t._empty()?"M0 0":"M"+(e.x-i)+","+e.y+n+2*i+",0 "+n+2*-i+",0 ";this._setPath(t,e)},_setPath:function(t,e){t._path.setAttribute("d",e)},_bringToFront:function(t){fe(t._path)},_bringToBack:function(t){ge(t._path)}});function Ki(t){return b.svg||b.vml?new Gi(t):null}b.vml&&Gi.include(zt),A.include({getRenderer:function(t){t=(t=t.options.renderer||this._getPaneRenderer(t.options.pane)||this.options.renderer||this._renderer)||(this._renderer=this._createRenderer());return this.hasLayer(t)||this.addLayer(t),t},_getPaneRenderer:function(t){var e;return"overlayPane"!==t&&void 0!==t&&(void 0===(e=this._paneRenderers[t])&&(e=this._createRenderer({pane:t}),this._paneRenderers[t]=e),e)},_createRenderer:function(t){return this.options.preferCanvas&&Ui(t)||Ki(t)}});var Yi=xi.extend({initialize:function(t,e){xi.prototype.initialize.call(this,this._boundsToLatLngs(t),e)},setBounds:function(t){return this.setLatLngs(this._boundsToLatLngs(t))},_boundsToLatLngs:function(t){return[(t=g(t)).getSouthWest(),t.getNorthWest(),t.getNorthEast(),t.getSouthEast()]}});Gi.create=qi,Gi.pointsToPath=dt,wi.geometryToLayer=bi,wi.coordsToLatLng=Li,wi.coordsToLatLngs=Ti,wi.latLngToCoords=Mi,wi.latLngsToCoords=zi,wi.getFeature=Ci,wi.asFeature=Zi,A.mergeOptions({boxZoom:!0});var _t=n.extend({initialize:function(t){this._map=t,this._container=t._container,this._pane=t._panes.overlayPane,this._resetStateTimeout=0,t.on("unload",this._destroy,this)},addHooks:function(){S(this._container,"mousedown",this._onMouseDown,this)},removeHooks:function(){k(this._container,"mousedown",this._onMouseDown,this)},moved:function(){return this._moved},_destroy:function(){T(this._pane),delete this._pane},_resetState:function(){this._resetStateTimeout=0,this._moved=!1},_clearDeferredResetState:function(){0!==this._resetStateTimeout&&(clearTimeout(this._resetStateTimeout),this._resetStateTimeout=0)},_onMouseDown:function(t){if(!t.shiftKey||1!==t.which&&1!==t.button)return!1;this._clearDeferredResetState(),this._resetState(),re(),Le(),this._startPoint=this._map.mouseEventToContainerPoint(t),S(document,{contextmenu:Re,mousemove:this._onMouseMove,mouseup:this._onMouseUp,keydown:this._onKeyDown},this)},_onMouseMove:function(t){this._moved||(this._moved=!0,this._box=P("div","leaflet-zoom-box",this._container),M(this._container,"leaflet-crosshair"),this._map.fire("boxzoomstart")),this._point=this._map.mouseEventToContainerPoint(t);var t=new f(this._point,this._startPoint),e=t.getSize();Z(this._box,t.min),this._box.style.width=e.x+"px",this._box.style.height=e.y+"px"},_finish:function(){this._moved&&(T(this._box),z(this._container,"leaflet-crosshair")),ae(),Te(),k(document,{contextmenu:Re,mousemove:this._onMouseMove,mouseup:this._onMouseUp,keydown:this._onKeyDown},this)},_onMouseUp:function(t){1!==t.which&&1!==t.button||(this._finish(),this._moved&&(this._clearDeferredResetState(),this._resetStateTimeout=setTimeout(a(this._resetState,this),0),t=new s(this._map.containerPointToLatLng(this._startPoint),this._map.containerPointToLatLng(this._point)),this._map.fitBounds(t).fire("boxzoomend",{boxZoomBounds:t})))},_onKeyDown:function(t){27===t.keyCode&&(this._finish(),this._clearDeferredResetState(),this._resetState())}}),Ct=(A.addInitHook("addHandler","boxZoom",_t),A.mergeOptions({doubleClickZoom:!0}),n.extend({addHooks:function(){this._map.on("dblclick",this._onDoubleClick,this)},removeHooks:function(){this._map.off("dblclick",this._onDoubleClick,this)},_onDoubleClick:function(t){var e=this._map,i=e.getZoom(),n=e.options.zoomDelta,i=t.originalEvent.shiftKey?i-n:i+n;"center"===e.options.doubleClickZoom?e.setZoom(i):e.setZoomAround(t.containerPoint,i)}})),Zt=(A.addInitHook("addHandler","doubleClickZoom",Ct),A.mergeOptions({dragging:!0,inertia:!0,inertiaDeceleration:3400,inertiaMaxSpeed:1/0,easeLinearity:.2,worldCopyJump:!1,maxBoundsViscosity:0}),n.extend({addHooks:function(){var t;this._draggable||(t=this._map,this._draggable=new Xe(t._mapPane,t._container),this._draggable.on({dragstart:this._onDragStart,drag:this._onDrag,dragend:this._onDragEnd},this),this._draggable.on("predrag",this._onPreDragLimit,this),t.options.worldCopyJump&&(this._draggable.on("predrag",this._onPreDragWrap,this),t.on("zoomend",this._onZoomEnd,this),t.whenReady(this._onZoomEnd,this))),M(this._map._container,"leaflet-grab leaflet-touch-drag"),this._draggable.enable(),this._positions=[],this._times=[]},removeHooks:function(){z(this._map._container,"leaflet-grab"),z(this._map._container,"leaflet-touch-drag"),this._draggable.disable()},moved:function(){return this._draggable&&this._draggable._moved},moving:function(){return this._draggable&&this._draggable._moving},_onDragStart:function(){var t,e=this._map;e._stop(),this._map.options.maxBounds&&this._map.options.maxBoundsViscosity?(t=g(this._map.options.maxBounds),this._offsetLimit=_(this._map.latLngToContainerPoint(t.getNorthWest()).multiplyBy(-1),this._map.latLngToContainerPoint(t.getSouthEast()).multiplyBy(-1).add(this._map.getSize())),this._viscosity=Math.min(1,Math.max(0,this._map.options.maxBoundsViscosity))):this._offsetLimit=null,e.fire("movestart").fire("dragstart"),e.options.inertia&&(this._positions=[],this._times=[])},_onDrag:function(t){var e,i;this._map.options.inertia&&(e=this._lastTime=+new Date,i=this._lastPos=this._draggable._absPos||this._draggable._newPos,this._positions.push(i),this._times.push(e),this._prunePositions(e)),this._map.fire("move",t).fire("drag",t)},_prunePositions:function(t){for(;1e.max.x&&(t.x=this._viscousLimit(t.x,e.max.x)),t.y>e.max.y&&(t.y=this._viscousLimit(t.y,e.max.y)),this._draggable._newPos=this._draggable._startPos.add(t))},_onPreDragWrap:function(){var t=this._worldWidth,e=Math.round(t/2),i=this._initialWorldOffset,n=this._draggable._newPos.x,o=(n-e+i)%t+e-i,n=(n+e+i)%t-e-i,t=Math.abs(o+i)e.getMaxZoom()&&1get_all_feednames();
+
+$default_maps = array_values( array_filter( array_map( 'trim', explode( ',', Spotmap_Options::get_setting( 'maps', 'openstreetmap' ) ) ) ) );
+$maps = ! empty( $attributes['maps'] ) ? $attributes['maps'] : $default_maps;
+
+// Build per-feed styles from admin defaults when not explicitly set (mirrors shortcode behaviour).
+if ( ! empty( $attributes['styles'] ) ) {
+ $styles = $attributes['styles'];
+} else {
+ $defaults = Spotmap_Options::get_settings();
+ $colors = array_values( array_filter( array_map( 'trim', explode( ',', $defaults['color'] ) ) ) );
+ $splitlines = array_values( array_filter( array_map( 'trim', explode( ',', (string) $defaults['splitlines'] ) ) ) );
+ $num_colors = max( 1, count( $colors ) );
+ $styles = array();
+ foreach ( array_values( $feeds ) as $i => $feed_name ) {
+ $styles[ $feed_name ] = array(
+ 'color' => $colors[ $i % $num_colors ] ?? 'blue',
+ 'splitLines' => $splitlines[0] ?? '12',
+ );
+ }
+}
+
+$options = wp_json_encode( array(
+ 'feeds' => $feeds,
+ 'maps' => $maps,
+ 'mapOverlays' => ! empty( $attributes['mapOverlays'] ) ? $attributes['mapOverlays'] : null,
+ 'styles' => $styles,
+ 'height' => ! empty( $attributes['height'] ) ? $attributes['height'] : 500,
+ 'mapcenter' => ! empty( $attributes['mapcenter'] ) ? $attributes['mapcenter'] : 'all',
+ 'filterPoints' => isset( $attributes['filterPoints'] ) ? $attributes['filterPoints'] : (int) Spotmap_Options::get_setting( 'filter-points', 5 ),
+ 'autoReload' => ! empty( $attributes['autoReload'] ),
+ 'debug' => ! empty( $attributes['debug'] ),
+ 'dateRange' => array(
+ 'from' => ! empty( $attributes['dateRange']['from'] ) ? $attributes['dateRange']['from'] : null,
+ 'to' => ! empty( $attributes['dateRange']['to'] ) ? $attributes['dateRange']['to'] : null,
+ ),
+ 'gpx' => ! empty( $attributes['gpx'] ) ? $attributes['gpx'] : array(),
+ 'enablePanning' => isset( $attributes['enablePanning'] ) ? (bool) $attributes['enablePanning'] : true,
+ 'scrollWheelZoom' => isset( $attributes['scrollWheelZoom'] ) ? (bool) $attributes['scrollWheelZoom'] : false,
+ 'locateButton' => isset( $attributes['locateButton'] ) ? (bool) $attributes['locateButton'] : false,
+ 'fullscreenButton' => isset( $attributes['fullscreenButton'] ) ? (bool) $attributes['fullscreenButton'] : true,
+ 'navigationButtons' => isset( $attributes['navigationButtons'] ) && is_array( $attributes['navigationButtons'] )
+ ? $attributes['navigationButtons']
+ : array( 'enabled' => true, 'allPoints' => true, 'latestPoint' => true, 'gpxTracks' => true ),
+ 'mapId' => $map_id,
+) );
+
+$height = ! empty( $attributes['height'] ) ? intval( $attributes['height'] ) : 500;
+$align_class = ! empty( $attributes['align'] ) ? 'align' . esc_attr( $attributes['align'] ) : '';
+
+$wrapper_attributes = get_block_wrapper_attributes( array(
+ 'id' => $map_id,
+ 'class' => $align_class,
+ 'style' => "height: {$height}px; z-index: 0;",
+) );
+
+echo "
";
+echo '';
diff --git a/readme.txt b/readme.txt
index c4e0b10..1483741 100644
--- a/readme.txt
+++ b/readme.txt
@@ -1,204 +1,159 @@
-=== Spotmap ===
-Contributors: techtimo
-Donate link: paypal.me/ebaytimo
-Tags: findmespot, find me spot, saved by spot, spot gps, spot tracker, spotbeacon, liveposition, gpx, gps tracking, gps tracker, spottrace, spotwalla
-License: GPL2
-License URI: http://www.gnu.org/licenses/gpl-2.0.html
-Requires at least: 5.3
-Tested up to: 5.8
-
-See your Spot device movements on an embedded map inside your Blog! 🗺 Add GPX tracks, routes and waypoints to see a planned route.
-
-## Description
-
-Spot does not offer the storage of points free of charge for long term. That's where Spotmap comes into the game:
-Your Wordpress Site will store all positions ever sent. It checks for new positions every 2.5 minutes.
-It supports different devices (They can even belong to different accounts).
-
-The map can fetch new points autmatically without relaoding the entire Post.
-
-
-
-🆕 Support of Gutenberg block editor. Just type `/spotmap` and open the settings on the right.
-
-Currently only the GPX colors cannot be cahnged individually inside the block settings.
-
-With a shortcode you can add an embedded map to your post or page. By default it will show all positions ever sent.
-If needed the map can show a subset of the data. i.e. the last weekend getaway.
-
-Next planned features (Not necessarily in right order):
-
-- grouping of points (partially implemented)
-
-- support of other tracking devices (Garmin InReach, ...)
-
-- Translatable version of the plugin
-
-- Full support of the Spotmap block for Gutenberg
-
-- delete/move points from the Dashboard
-
-- export to gpx files
-
-👉 If you feel like this plugin is missing importants part, let me know. Maybe I have some free time to change this fact. 😉
-
-
-## Installation
-
-After installing the plugin, head over to your Dashboard `Settings > Spotmap`. Add a feed by selecting `findmespot` from the dropdown and hit "Add Feed".
-
-Now you can enter your XML Feed Id, a name for the feed and a password if you have one. Press "Save". A few minutes later Wordpress will download the points that are present in the XML Feed.
-
-In the mean time you can create an empty map with the Shortcode:
-`[spotmap]`
-
-If you use the block editor Gutenberg, you can search for a block named 'Spotmap'.
-
-🎉 Congrats! You just created your first Spotmap. 🎉
-
-If you use the Block editor make sure to select the map and click on the settings icon in the top right corner, in order to see all settings related to the map.
-
-If you use the shortcode,check the Additional attributes section.
-👉 If you need help to configure your map, post a question in the [support forum](https://wordpress.org/support/plugin/spotmap/). 👈
-### Additional attributes
-
-If you add new maps, check the FAQ
-
-To fine tune the map, there are some attributes we can pass with the shortcode:
-
-_Note:_ all the Default values of the attributes can be changed in the settings in Dashboard. This comes in handy, if you use several maps on the blog, and you like to configure them all in one place. Of course you can still use the attributes to overide the default values.
-
-#### Map
-
-- `maps=opentopomap` will show only the opentopomap as map. Default `"openstreetmap,opentopomap"`.
- If you create a mapbox API Key and store it in the settings page. You can choose other map types as well: `mb-outdoors,mb-streets,mb-satelite`
- Use it like this: `maps="mb-satelite,mb-streets,openstreetmap"` This will show a satelite image as the selected map, but it can be changed to the other two maps (mb-streets, openstreetmap).
-
-- `map-overlays=openseamap` can be added to see the openseamap overlay in the map. (You need to zoom in quite a bit).
-
-- `height=600` can define the height of the map in pixels.
-
-- `width=full` if you add this the map will appear in full width. Default is `normal`.
-
-- `mapcenter=last` can be used to zoom into the last known position. Default `all`. Can be set to `'gpx'` to center all GPX files (see below for configurations).
-
-### Feeds
-
-- `splitlines=8` will split the lines between points if two points are sent with a difference greater than X hours. Default 12. Set to 0 if you don't like to see any line.
-
-- `date-range-from=2021-01-01` can be used to show all points starting from date and time X. (Can lie in the future).
-
-- `date-range-to=2022-01-01 19:00` can be used to show all points until date and time X.
-
-- `auto-reload` will auto update the map without the need to reload the page. (This hasn't been tested much...)
-
-- `last-point` will show the last sent point as big marker, to be easily found. Can also be used with a limited range of colors (yellow,red,green,black,gray,blue) like `last-point=red`
-
-- `feeds` can be set, if multiple feeds get used. (See example below, if you have only one spot this is not needed)
-
-#### GPX
-**The following attributes can be used to show GPX tracks:**
-
-- `gpx-name="Track 1,Track 2"` give the tracks a nice name. (Spaces can be used)
-
-- `gpx-url="yourwordpress.com/wp-content/track1.gpx,yourwordpress.com/wp-content/track2.gpx"` specify the URL of the GPX files. (You can upload GPX files to your media library. Make sure to not use 'http://'!)
-
-- `gpx-color="green,#347F33"` give your tracks some color. (It can be any color you can think of, or some hex values)
-
-If there are areas where tracks overlap each other, the track named first will be on top of the others.
-
-_Note:_ `feeds` must always match your feed name.
-This will show a bigger map and the points are all in yellow:
-
-`[spotmap height=600 width=full feeds="My Spot Feed" colors=yellow]`
-
-This will show a map where we zoom into the last known position, and we only show data from the the first of May:
-
-`[spotmap mapcenter=last feeds="My Spot" colors=red date-range-from="2020-05-01"]`
-
-
-We can also show multiple feeds in different colors on a same day (from 0:00:00 to 23:59:59):
-
-`[spotmap mapcenter=last feeds="My first spot,My other Device" colors="gray,green" date="2020-06-01"]`
-
-
-## Frequently Asked Questions
-
-### How do I get my Feed ID?
-You need to create an XML Feed in your spot account. ([See here](https://www.findmespot.com/en-us/support/spot-x/get-help/general/spot-api-support) for more details)
-Unless you like to group devices under one name, it's good to create one feed per device, so you can manage the devices independently.
-Your XML Feed id should look similar to this: `0Wl3diTJcqqvncI6NNsoqJV5ygrFtQfBB`
-
-### Which 3rd Party Services are getting used?
-The plugin uses the following thrid party services:
-1. From [SPOT LLC](http://findmespot.com) it uses the [Public API](https://www.findmespot.com/en-us/support/spot-x/get-help/general/spot-api-support) to get the points.
-2. (optionally) [TimeZoneDB.com](TimeZoneDB.com) To calculate the localtime of sent positions. Create an account [here](https://timezonedb.com/register). Paste the key in the settings page.
-3. (optionally) [Mapbox, Inc.](mapbox.com) To get satelite images and nice looking maps, you can sign up for a [Mapbox API Token](https://account.mapbox.com/access-tokens/). I recommend to restrict the token usage to your domain only.
-4. (optionally) [Thunderforest](thunderforest.com) To get another set of maps. Create an account [here](https://manage.thunderforest.com/users/sign_up?plan_id=5). Paste the key in the settings page.
-5. (optionally) [Land Information New Zealand (LINZ)](https://www.linz.govt.nz) To get the official Topo Maps of NZ create an account [here](https://www.linz.govt.nz/data/linz-data-service/guides-and-documentation/creating-an-api-key). Paste the key in the settings page.
-6. (optionally) [Géoportail France](https://geoservices.ign.fr/) To get the official Topo Maps of IGN France. Create an account [here](https://geoservices.ign.fr/user/register) (french only). Paste the key in the settings page.
-7. (optionally) [UK Ordnance Survey](https://osdatahub.os.uk) To get the official UK OS maps. Create a free plan [here](https://osdatahub.os.uk/plans). And follow this guide on how to [create a project](https://osdatahub.os.uk/docs/wmts/gettingStarted).
-
-
-### Can I use/add other maps?
-Have you created your mapbox/thunderforest API key yet? If not this is a good way to start and get other map styles. See the question 'Which 3rd Party Services are getting used?' for details
-If you still search for another map: Start a search [here](https://leaflet-extras.github.io/leaflet-providers/preview/) and also [here](https://wiki.openstreetmap.org/wiki/Tiles).
-If you have found a map, create a new post in the [support forum](https://wordpress.org/support/plugin/spotmap/).
-
-### I have a question, an idea, found a bug...
-Head over to the wordpress.org [support forum](https://wordpress.org/support/plugin/spotmap/), and ask your question there. I'm happy to assist you! 😊
-
-## Screenshots
-
-1. This screenshot was taken after using the plugin for 3 months.
-2. You can click on every sent positions to get more information. Points sent from a 'normal' Tracking will appear as small dots.
-
-## Changelog
-= 0.12.0 =
-- support for media uploads shown in the map
-- images you upload to wordpress will be added to the wordpress spotmap table using the feed 'media' if GPS location are part of the EXIF data.
-- with date-range filter described above you can show only images taken in a specific range.
-- If you upgrade from a previous version you have to mnaully run this command if you like to use the media feature: "ALTER TABLE `wordpress_local`.`wp_spotmap_points` CHANGE COLUMN `id` `id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT ;"
-
-= 0.11.2 =
-- new marker styles and options how to configure them. Changeable icons
-- maps with many points will load faster
-- tested with WP 5.8
-- Thirdparty API options page includes many comments to better understand what each service is for.
-- new initial map state added: 'last-trip'. Zooms to the last line on the map (In the feed settings splitlines must be activated to work)
-
-= 0.10.3 =
-- added UK Ordnance Survey
-- added US Geological Survey maps
-- possability to hide nearby points of the same type
-
-= 0.10.2 =
-- tested Wordpress 5.7
-- add last-point option to show the latest position as a big marker. (Requested by Elia)
-- fix reload issue of the map inside Gutenberg if no changes were made
-
-= 0.10.1 =
-Full Gutenberg Block support
-added NZtopomap
-added France IGN Topo map token
-
-= 0.9 =
-- new shortcode to show table of messages
-- add gpx overlays
-- new maps available (mapbox, thunderforest, swisstopo)
-
-= 0.7 =
-- added support for multiple feeds
-- filter for certain date ranges
-- added a Gutenberg Block (still experimental!)
-
-
-## Upgrade Notice
-
-= 0.9 =
-If you upgrade to this version from a previous, please uninstall the plugin first.
-If you have data in the db you don't want to loose, please create a post in the support forum.
-
-Adding Gpx support to show a planned route. Adding different maps.
-Adding a table to quickly see the last sent messages. ([spotmessages])
-
+# Spotmap
+
+Contributors: techtimo
+Donate link: paypal.me/ebaytimo
+Tags: findmespot, find me spot, saved by spot, spot gps, spot tracker, spotbeacon, liveposition, gpx, gps tracking, gps tracker, spottrace, spotwalla
+License: GPL2
+License URI: http://www.gnu.org/licenses/gpl-2.0.html
+Requires at least: 5.3
+Tested up to: 6.9
+
+See your Spot device movements on an embedded map inside your Blog! 🗺 Add GPX tracks, routes and waypoints to see a planned route.
+
+## Description
+
+Spot does not offer the storage of points free of charge for long term. That's where Spotmap comes into the game:
+Your Wordpress Blog will store all positions ever sent. It checks for new positions every 2.5 minutes.
+It supports different devices (They can even belong to different accounts).
+
+Next planned features (Not necessarily in right order):
+
+- grouping of points (partially implemented)
+- support of other tracking devices (Garmin InReach, ...)
+- Translatable version of the plugin
+- delete/move points from the Dashboard
+- export to gpx files
+
+## Installation
+
+After installing the plugin, head over to your Dashboard `Settings > Spotmap`. Add a feed by selecting `findmespot` from the dropdown and hit "Add Feed".
+
+Now you can enter your XML Feed Id, a name for the feed and a password if you have one. Press "Save". A few minutes later Wordpress will download the points that are present in the XML Feed.
+
+In the mean time you can create an empty map in the editor with `/spotmap`
+
+🎉 Congrats! You just created your first Spotmap.
+
+
+👉 If you need help to configure your map, post a question in the [support forum](https://wordpress.org/support/plugin/spotmap/). 👈
+
+
+### GPX
+
+**The following attributes can be used to show GPX tracks:**
+
+- `gpx-name="Track 1,Track 2"` give the tracks a nice name. (Spaces can be used)
+
+- `gpx-url="yourwordpress.com/wp-content/track1.gpx,yourwordpress.com/wp-content/track2.gpx"` specify the URL of the GPX files. (You can upload GPX files to your media library. Make sure to not use 'http://'!)
+
+- `gpx-color="green,#347F33"` give your tracks some color. (It can be any color you can think of, or some hex values)
+
+If there are areas where tracks overlap each other, the track named first will be on top of the others.
+
+_Note:_ `feeds` must always match your feed name.
+This will show a bigger map and the points are all in yellow:
+
+`[spotmap height=600 width=full feeds="My Spot Feed" colors=yellow]`
+
+This will show a map where we zoom into the last known position, and we only show data from the the first of May:
+
+`[spotmap mapcenter=last feeds="My Spot" colors=red date-range-from="2020-05-01"]`
+
+We can also show multiple feeds in different colors on a same day (from 0:00:00 to 23:59:59):
+
+`[spotmap mapcenter=last feeds="My first spot,My other Device" colors="gray,green" date="2020-06-01"]`
+
+## Security
+
+### Message content and phone numbers
+
+SPOT devices can include a phone number or personal message in their transmission data. This information is stored in the database and may appear in marker popups on your map.
+To overwrite this content, use the **Marker** section in `Settings > Spotmap`.
+Setting a feed password in your SPOT account (and entering it in the plugin settings) ensures that the message content is not stored in the Wordpress database and thus not accessible by the public.
+
+### Live location privacy
+
+The plugin offers a cosmetic filter to hide points newer than a configurable threshold (e.g. 30 minutes, 2 hours, or 1 day).
+This prevents the most recent positions from appearing on the public map.
+**Important:** this filter is display-only. The REST API endpoint exposed by the plugin can return all points stored in the database, regardless of the block filter setting.
+There is currently no way to fully hide the latest positions from a technically capable visitor. If hiding live locations from the API is a requirement, you should restrict access to the REST API endpoint at the server or WordPress level.
+
+### Map Tokens
+
+API tokens for tile layer providers (Mapbox, Thunderforest, LINZ, IGN France, OS UK, etc.) are stored in WordPress settings and embedded in the page HTML at render time. This means any visitor who views the page source can read your token.
+
+To reduce the risk of token abuse, **restrict each token to your domain using the provider's referrer/HTTP origin restrictions** (e.g. `https://yoursite.com/*`). Requests from other origins will be rejected.
+
+## Frequently Asked Questions
+
+### How do I get my Feed ID?
+
+You need to create an XML Feed in your spot account. ([See here](https://www.findmespot.com/en-us/support/spot-gen4/get-help/general/public-api-and-xml-feed) for more details)
+Unless you like to group devices under one name, it's good to create one feed per device, so you can manage the devices independently.
+Your XML Feed id should look similar to this: `0Wl3diTJcqqvncI6NNsoqJV5ygrFtQfBB`
+
+### Which 3rd Party Services are getting used?
+
+The plugin uses the following third party services:
+
+1. From [SPOT LLC](http://findmespot.com) it uses the [Public API](https://www.findmespot.com/en-us/support/spot-gen4/get-help/general/public-api-and-xml-feed) to get the points.
+2. (optionally) [TimeZoneDB.com](TimeZoneDB.com) To calculate the localtime of sent positions. Create an account [here](https://timezonedb.com/register). Paste the key in the settings page.
+3. (optionally) [Mapbox, Inc.](mapbox.com) To get satelite images and nice looking maps, you can sign up for a [Mapbox API Token](https://account.mapbox.com/access-tokens/). I recommend to restrict the token usage to your domain only.
+4. (optionally) [Thunderforest](thunderforest.com) To get another set of maps. Create an account [here](https://manage.thunderforest.com/users/sign_up?plan_id=5). Paste the key in the settings page.
+5. (optionally) [Land Information New Zealand (LINZ)](https://www.linz.govt.nz) To get the official Topo Maps of NZ create an account [here](https://www.linz.govt.nz/data/linz-data-service/guides-and-documentation/creating-an-api-key). Paste the key in the settings page.
+6. (optionally) [Géoportail France](https://geoservices.ign.fr/) To get the official Topo Maps of IGN France. Create an account [here](https://geoservices.ign.fr/user/register) (french only). Paste the key in the settings page.
+7. (optionally) [UK Ordnance Survey](https://osdatahub.os.uk) To get the official UK OS maps. Create a free plan [here](https://osdatahub.os.uk/plans). And follow this guide on how to [create a project](https://osdatahub.os.uk/docs/wmts/gettingStarted).
+
+### Can I use/add other maps?
+
+Have you created your mapbox/thunderforest API key yet? If not this is a good way to start and get other map styles. See the question 'Which 3rd Party Services are getting used?' for details
+If you still search for another map: Start a search [here](https://leaflet-extras.github.io/leaflet-providers/preview/) and also [here](https://wiki.openstreetmap.org/wiki/Tiles).
+If you have found a map, create a new post in the [support forum](https://wordpress.org/support/plugin/spotmap/).
+
+### I have a question, an idea, found a bug, etc
+
+Head over to the wordpress.org [support forum](https://wordpress.org/support/plugin/spotmap/), and ask your question there. I'm happy to assist you! 😊
+
+## Screenshots
+
+1. This screenshot was taken after using the plugin for 3 months.
+2. You can click on every sent positions to get more information. Points sent from a 'normal' Tracking will appear by default as small dots.
+
+## Changelog
+
+### 1.0
+
+- Gutenberg block — add a Spotmap map directly from the block editor with live preview
+- Rewritten map engine in TypeScript for better reliability and maintainability
+-
+- Support for media uploads shown in the map: images uploaded to WordPress with GPS EXIF data appear as map points under the feed name `media`
+- Security: all database queries use prepared statements to prevent SQL injection
+- `id` column gains `AUTO_INCREMENT` (was missing in 0.11.2); migration runs automatically on first load after update
+- tested with WP 6.9
+
+### 0.11.2
+
+- new marker styles and options how to configure them. Changeable icons
+- maps with many points will load faster
+- tested with WP 5.8
+- Thirdparty API options page includes many comments to better understand what each service is for.
+- new initial map state added: 'last-trip'. Zooms to the last line on the map (In the feed settings splitlines must be activated to work)
+
+### 0.10.3
+
+- added UK Ordnance Survey
+- added US Geological Survey maps
+- possability to hide nearby points of the same type
+
+### 0.10.2
+
+- tested Wordpress 5.7
+- add last-point option to show the latest position as a big marker. (Requested by Elia)
+- fix reload issue of the map inside Gutenberg if no changes were made
+
+## Upgrade Notice
+
+### 1.0
+
+The database table is migrated automatically when the plugin loads after the update — no manual SQL required. Your existing GPS data is preserved.
+
diff --git a/scripts/copy-deps.js b/scripts/copy-deps.js
new file mode 100644
index 0000000..db54598
--- /dev/null
+++ b/scripts/copy-deps.js
@@ -0,0 +1,159 @@
+/**
+ * Copy front-end dependencies from node_modules to public/ so WordPress can
+ * enqueue them as separate scripts/styles (no bundling).
+ *
+ * Run via: npm run copy-deps (dev – keeps source maps)
+ * npm run copy-deps:prod (production – strips sourceMappingURL)
+ */
+
+/* eslint-disable no-console */
+const fs = require( 'fs' );
+const path = require( 'path' );
+
+const root = path.resolve( __dirname, '..' );
+const pub = ( ...parts ) => path.join( root, 'public', ...parts );
+const nm = ( ...parts ) => path.join( root, 'node_modules', ...parts );
+const inc = ( ...parts ) => path.join( root, 'includes', ...parts );
+
+const stripMaps = process.argv.includes( '--strip-maps' );
+
+function stripSourceMappingURL( filePath ) {
+ const content = fs.readFileSync( filePath, 'utf8' );
+ const stripped = content.replace(
+ /\/[*/]#\s*sourceMappingURL=.*?(?:\*\/|$)/gm,
+ ''
+ );
+ if ( content !== stripped ) {
+ fs.writeFileSync( filePath, stripped, 'utf8' );
+ console.log(
+ ` (stripped sourceMappingURL from ${ path.relative(
+ root,
+ filePath
+ ) })`
+ );
+ }
+}
+
+function copyFile( src, dest ) {
+ fs.mkdirSync( path.dirname( dest ), { recursive: true } );
+ fs.copyFileSync( src, dest );
+ console.log(
+ ` ${ path.relative( root, src ) } -> ${ path.relative( root, dest ) }`
+ );
+}
+
+/**
+ * Copy a JS file. In dev mode, also copies the .map file if it exists.
+ * In production mode (--strip-maps), strips the sourceMappingURL comment.
+ * @param {string} src
+ * @param {string} dest
+ */
+function copyJsFile( src, dest ) {
+ copyFile( src, dest );
+ if ( stripMaps ) {
+ stripSourceMappingURL( dest );
+ } else {
+ const mapSrc = src + '.map';
+ if ( fs.existsSync( mapSrc ) ) {
+ copyFile( mapSrc, dest + '.map' );
+ }
+ }
+}
+
+function copyDir( src, dest ) {
+ fs.mkdirSync( dest, { recursive: true } );
+ for ( const entry of fs.readdirSync( src, { withFileTypes: true } ) ) {
+ const srcPath = path.join( src, entry.name );
+ const destPath = path.join( dest, entry.name );
+ if ( entry.isDirectory() ) {
+ copyDir( srcPath, destPath );
+ } else {
+ fs.copyFileSync( srcPath, destPath );
+ console.log(
+ ` ${ path.relative( root, srcPath ) } -> ${ path.relative(
+ root,
+ destPath
+ ) }`
+ );
+ }
+ }
+}
+
+console.log(
+ `Copying front-end dependencies${
+ stripMaps ? ' (production)' : ' (dev)'
+ }...\n`
+);
+
+// Leaflet core
+copyJsFile(
+ nm( 'leaflet', 'dist', 'leaflet.js' ),
+ pub( 'leaflet', 'leaflet.js' )
+);
+copyFile(
+ nm( 'leaflet', 'dist', 'leaflet.css' ),
+ pub( 'leaflet', 'leaflet.css' )
+);
+copyDir( nm( 'leaflet', 'dist', 'images' ), pub( 'leaflet', 'images' ) );
+
+// Leaflet Fullscreen (file names changed in v5)
+copyFile(
+ nm( 'leaflet.fullscreen', 'dist', 'Control.FullScreen.umd.js' ),
+ pub( 'leafletfullscreen', 'leaflet.fullscreen.js' )
+);
+copyFile(
+ nm( 'leaflet.fullscreen', 'dist', 'Control.FullScreen.css' ),
+ pub( 'leafletfullscreen', 'leaflet.fullscreen.css' )
+);
+
+// Leaflet GPX
+copyFile( nm( 'leaflet-gpx', 'gpx.js' ), pub( 'leaflet-gpx', 'gpx.js' ) );
+copyDir( nm( 'leaflet-gpx', 'icons' ), pub( 'leaflet-gpx' ) );
+
+// Leaflet EasyButton
+copyFile(
+ nm( 'leaflet-easybutton', 'src', 'easy-button.js' ),
+ pub( 'leaflet-easy-button', 'easy-button.js' )
+);
+copyFile(
+ nm( 'leaflet-easybutton', 'src', 'easy-button.css' ),
+ pub( 'leaflet-easy-button', 'easy-button.css' )
+);
+
+// Leaflet TextPath
+copyFile(
+ nm( 'leaflet-textpath', 'leaflet.textpath.js' ),
+ pub( 'leaflet-textpath', 'leaflet.textpath.js' )
+);
+
+// Leaflet Beautify Marker
+copyFile(
+ nm( 'beautifymarker', 'leaflet-beautify-marker-icon.js' ),
+ pub( 'leaflet-beautify-marker', 'leaflet-beautify-marker-icon.js' )
+);
+copyFile(
+ nm( 'beautifymarker', 'leaflet-beautify-marker-icon.css' ),
+ pub( 'leaflet-beautify-marker', 'leaflet-beautify-marker-icon.css' )
+);
+
+// Leaflet TileLayer Swiss
+copyJsFile(
+ nm( 'leaflet-tilelayer-swiss', 'dist', 'Leaflet.TileLayer.Swiss.umd.js' ),
+ pub( 'leaflet-tilelayer-swisstopo', 'Leaflet.TileLayer.Swiss.umd.js' )
+);
+
+// Font Awesome
+copyFile(
+ nm( '@fortawesome', 'fontawesome-free', 'css', 'all.min.css' ),
+ inc( 'css', 'font-awesome-all.min.css' )
+);
+copyDir(
+ nm( '@fortawesome', 'fontawesome-free', 'webfonts' ),
+ inc( 'webfonts' )
+);
+
+// Plugin custom styles (authored in src/css/, served from public/css/)
+const src = ( ...parts ) => path.join( root, 'src', ...parts );
+copyFile( src( 'css', 'custom.css' ), pub( 'css', 'custom.css' ) );
+
+console.log( '\nDone.' );
diff --git a/scripts/generate-fa-icons.js b/scripts/generate-fa-icons.js
new file mode 100644
index 0000000..507b4db
--- /dev/null
+++ b/scripts/generate-fa-icons.js
@@ -0,0 +1,39 @@
+#!/usr/bin/env node
+/* eslint-disable no-console */
+// Regenerates src/spotmap-admin/icons.js from FA metadata.
+// Run: node scripts/generate-fa-icons.js
+const yaml = require( 'js-yaml' );
+const fs = require( 'fs' );
+const icons = yaml.load(
+ fs.readFileSync(
+ 'node_modules/@fortawesome/fontawesome-free/metadata/icons.yml',
+ 'utf8'
+ )
+);
+const solid = Object.entries( icons )
+ .filter( ( [ , v ] ) => v.styles && v.styles.includes( 'solid' ) )
+ .map( ( [ k ] ) => k )
+ .sort();
+const out = [
+ '/**',
+ ' * All Font Awesome 5 Free solid icon names, derived from FA metadata.',
+ ' * Regenerate: node scripts/generate-fa-icons.js',
+ ' */',
+ 'export const ICONS = [',
+ ...solid.reduce( ( rows, _, i ) => {
+ if ( i % 8 === 0 ) {
+ rows.push(
+ '\t' +
+ solid
+ .slice( i, i + 8 )
+ .map( ( x ) => JSON.stringify( x ) )
+ .join( ', ' ) +
+ ','
+ );
+ }
+ return rows;
+ }, [] ),
+ '];',
+].join( '\n' );
+fs.writeFileSync( 'src/spotmap-admin/icons.js', out );
+console.log( 'Written', solid.length, 'icons to src/spotmap-admin/icons.js' );
diff --git a/spotmap.php b/spotmap.php
index 3c5657f..cd98454 100644
--- a/spotmap.php
+++ b/spotmap.php
@@ -3,7 +3,7 @@
* Plugin Name: Spotmap
* Plugin URI: https://github.com/techtimo/spotmap
* Description: See Spot GPS tracker positions inside your blog. Show GPX track(s) to let viewers know where you intend to go.
- * Version: 0.11.2
+ * Version: 1.0.0
* Author: Timo Giese
* Author URI: https://github.com/techtimo
* License: GPL2
@@ -17,6 +17,10 @@
// Block direct access
defined( 'ABSPATH' ) or die();
+define( 'SPOTMAP_VERSION', '1.0.0' );
+
+require_once plugin_dir_path( __FILE__ ) . 'vendor-prefixed/autoload.php';
+
register_activation_hook( __FILE__, 'activate_spotmap' );
function activate_spotmap() {
require_once plugin_dir_path( __FILE__ ) . 'includes/class-spotmap-activator.php';
diff --git a/src/css/custom.css b/src/css/custom.css
new file mode 100644
index 0000000..5059713
--- /dev/null
+++ b/src/css/custom.css
@@ -0,0 +1,48 @@
+/* map toggle work with twenty twenty template */
+
+/* .leaflet-control-layers-list {
+ padding: 0;
+}
+
+.leaflet-control-zoom-in .leaflet-control-zoom-out {
+ text-decoration: none;
+} */
+
+/*style the spot message table*/
+
+tr.spotmap td:first-child {
+ width: 7em;
+ cursor: pointer;
+}
+
+tr.spotmap td:last-child {
+ width: 7em;
+}
+
+tr.spotmap.OK td:first-child,
+tr.spotmap.HELP-CANCEL td:first-child,
+tr.spotmap.STATUS td:first-child {
+ background-color: rgb(142, 223, 89, 0.85);
+ border-color: rgba(102, 255, 0, 0.85);
+}
+
+tr.spotmap.HELP td:first-child {
+ background-color: rgb(255, 0, 0.85);
+ border-color: rgb(255, 0, 0.85);
+}
+
+tr.spotmap.CUSTOM td:first-child {
+ background-color: rgb(255, 255, 0.85);
+ border-color: rgb(255, 255, 0.85);
+}
+
+div.easy-button-container > button.easy-button-button.leaflet-bar-part {
+ background-color: #fff;
+ padding: 0;
+}
+
+span.button-state.state-all.all-active > .dashicons {
+ position: absolute;
+ top: 4px;
+ left: 5px;
+}
diff --git a/src/map-engine/BoundsManager.ts b/src/map-engine/BoundsManager.ts
new file mode 100644
index 0000000..d7c8667
--- /dev/null
+++ b/src/map-engine/BoundsManager.ts
@@ -0,0 +1,127 @@
+import type { MapCenter, SpotmapLayers } from './types';
+import { debug as debugLog } from './utils';
+
+/**
+ * Calculates and applies map bounds for different view modes.
+ */
+export class BoundsManager {
+ private readonly map: L.Map;
+ private readonly layers: SpotmapLayers;
+ private readonly dbg: ( ...args: unknown[] ) => void;
+
+ constructor( map: L.Map, layers: SpotmapLayers, debugEnabled = false ) {
+ this.map = map;
+ this.layers = layers;
+ this.dbg = ( ...args ) => debugLog( debugEnabled, ...args );
+ }
+
+ /**
+ * Fit the map to the bounds for the given view mode.
+ */
+ fitBounds( option: MapCenter ): void {
+ const bounds = this.getBounds( option );
+ if ( bounds.isValid() ) {
+ this.dbg(
+ `BoundsManager: fitBounds mode="${ option }"`,
+ bounds.toBBoxString()
+ );
+ this.map.fitBounds( bounds );
+ } else {
+ this.dbg(
+ `BoundsManager: fitBounds mode="${ option }" — no valid bounds (no points?)`
+ );
+ }
+ }
+
+ /**
+ * Calculate bounds for the given view mode.
+ *
+ * @param option - 'all' | 'last' | 'last-trip' | 'gpx' | 'feeds'
+ */
+ getBounds( option: MapCenter ): L.LatLngBounds {
+ if ( option === 'last' || option === 'last-trip' ) {
+ return this.getLastBounds( option );
+ }
+
+ const feedBounds = this.getFeedBounds();
+
+ if ( option === 'feeds' ) {
+ return feedBounds;
+ }
+
+ const gpxBounds = this.getGpxBounds();
+
+ if ( option === 'gpx' ) {
+ return gpxBounds;
+ }
+
+ // option === 'all'
+ const allBounds = L.latLngBounds( [] );
+ if ( feedBounds.isValid() ) {
+ allBounds.extend( feedBounds );
+ }
+ if ( gpxBounds.isValid() ) {
+ allBounds.extend( gpxBounds );
+ }
+ return allBounds;
+ }
+
+ private getLastBounds( option: 'last' | 'last-trip' ): L.LatLngBounds {
+ let latestUnixtime = 0;
+ let latestFeedName = '';
+
+ for ( const [ feedName, feed ] of Object.entries(
+ this.layers.feeds
+ ) ) {
+ if ( ! this.map.hasLayer( feed.featureGroup ) ) {
+ continue;
+ }
+ const lastPoint = feed.points.at( -1 );
+ if ( lastPoint && lastPoint.unixtime > latestUnixtime ) {
+ latestUnixtime = lastPoint.unixtime;
+ latestFeedName = feedName;
+ }
+ }
+
+ const latestPoint =
+ this.layers.feeds[ latestFeedName ]?.points.at( -1 );
+ if ( ! latestPoint ) {
+ return L.latLngBounds( [] );
+ }
+
+ if ( option === 'last' ) {
+ const bounds = L.latLngBounds( [] );
+ bounds.extend( [ latestPoint.latitude, latestPoint.longitude ] );
+ return bounds;
+ }
+
+ // 'last-trip': return the bounds of the last polyline for that feed
+ const lastLine = this.layers.feeds[ latestFeedName ]?.lines.at( -1 );
+ return lastLine ? lastLine.getBounds() : L.latLngBounds( [] );
+ }
+
+ private getFeedBounds(): L.LatLngBounds {
+ const bounds = L.latLngBounds( [] );
+ for ( const feed of Object.values( this.layers.feeds ) ) {
+ if ( ! this.map.hasLayer( feed.featureGroup ) ) {
+ continue;
+ }
+ const layerBounds = feed.featureGroup.getBounds();
+ if ( layerBounds.isValid() ) {
+ bounds.extend( layerBounds );
+ }
+ }
+ return bounds;
+ }
+
+ private getGpxBounds(): L.LatLngBounds {
+ const bounds = L.latLngBounds( [] );
+ for ( const gpx of Object.values( this.layers.gpx ) ) {
+ const layerBounds = gpx.featureGroup.getBounds();
+ if ( layerBounds.isValid() ) {
+ bounds.extend( layerBounds );
+ }
+ }
+ return bounds;
+ }
+}
diff --git a/src/map-engine/ButtonManager.ts b/src/map-engine/ButtonManager.ts
new file mode 100644
index 0000000..943bfa7
--- /dev/null
+++ b/src/map-engine/ButtonManager.ts
@@ -0,0 +1,153 @@
+import type { SpotmapOptions, NavigationButtonsConfig } from './types';
+import type { BoundsManager } from './BoundsManager';
+import 'leaflet-easybutton';
+
+/**
+ * Manages the easyButton navigation controls on the map.
+ */
+export class ButtonManager {
+ private readonly map: L.Map;
+ private readonly options: SpotmapOptions;
+ private readonly boundsManager: BoundsManager;
+ private easyBar: L.Control | null = null;
+
+ constructor(
+ map: L.Map,
+ options: SpotmapOptions,
+ boundsManager: BoundsManager
+ ) {
+ this.map = map;
+ this.options = options;
+ this.boundsManager = boundsManager;
+ }
+
+ /**
+ * Add navigation and locate buttons to the map.
+ * Respects the `navigationButtons` and `locateButton` options.
+ */
+ addButtons(): void {
+ const buttons = this.buildButtons(
+ this.options.locateButton,
+ this.options.navigationButtons
+ );
+ if ( buttons.length > 0 ) {
+ this.easyBar = L.easyBar( buttons ).addTo( this.map );
+ }
+ }
+
+ /**
+ * Replace the button bar in-place without rebuilding the map.
+ */
+ updateButtons(
+ locateButton: boolean | undefined,
+ navigationButtons: NavigationButtonsConfig | undefined
+ ): void {
+ if ( this.easyBar ) {
+ this.map.removeControl( this.easyBar );
+ this.easyBar = null;
+ }
+ const buttons = this.buildButtons( locateButton, navigationButtons );
+ if ( buttons.length > 0 ) {
+ this.easyBar = L.easyBar( buttons ).addTo( this.map );
+ }
+ }
+
+ private buildButtons(
+ locateButton: boolean | undefined,
+ navigationButtons: NavigationButtonsConfig | undefined
+ ): L.Control[] {
+ const buttons: L.Control[] = [];
+
+ if ( navigationButtons?.enabled ) {
+ const button = this.createNavigationButton( navigationButtons );
+ if ( button ) {
+ buttons.push( button );
+ }
+ }
+
+ if ( locateButton !== false ) {
+ buttons.push( this.createLocateButton() );
+ }
+
+ return buttons;
+ }
+
+ private createNavigationButton(
+ navOpts: NavigationButtonsConfig
+ ): L.Control | null {
+ const hasGpx = !! ( this.options.gpx && this.options.gpx.length > 0 );
+
+ const STATE_DEFS: Array< {
+ key: keyof NavigationButtonsConfig;
+ stateName: string;
+ icon: string;
+ title: string;
+ target: 'all' | 'last' | 'gpx';
+ needsGpx?: boolean;
+ } > = [
+ {
+ key: 'allPoints',
+ stateName: 'all',
+ icon: 'fa-globe',
+ title: 'Show all points',
+ target: 'all',
+ },
+ {
+ key: 'latestPoint',
+ stateName: 'last',
+ icon: 'fa-map-pin',
+ title: 'Jump to last known location',
+ target: 'last',
+ },
+ {
+ key: 'gpxTracks',
+ stateName: 'gpx',
+ icon: 'Tr. ',
+ title: 'Show GPX track(s)',
+ target: 'gpx',
+ needsGpx: true,
+ },
+ ];
+
+ const active = STATE_DEFS.filter(
+ ( s ) => navOpts[ s.key ] !== false && ( ! s.needsGpx || hasGpx )
+ );
+
+ if ( active.length === 0 ) {
+ return null;
+ }
+
+ const states: L.EasyButtonState[] = active.map( ( s, i ) => {
+ const nextName = active[ ( i + 1 ) % active.length ].stateName;
+ return {
+ stateName: s.stateName,
+ icon: s.icon,
+ title: s.title,
+ onClick: ( control: L.Control.EasyButton ) => {
+ this.boundsManager.fitBounds( s.target );
+ control.state( nextName );
+ },
+ } as L.EasyButtonState;
+ } );
+
+ return L.easyButton( { states } );
+ }
+
+ private createLocateButton(): L.Control {
+ return L.easyButton( {
+ states: [
+ {
+ stateName: 'locate',
+ icon: 'fa-location-arrow',
+ title: 'Jump to your location',
+ onClick: () => {
+ this.map.locate( {
+ setView: true,
+ maxZoom: 15,
+ } );
+ },
+ },
+ ],
+ } );
+ }
+}
diff --git a/src/map-engine/DataFetcher.ts b/src/map-engine/DataFetcher.ts
new file mode 100644
index 0000000..27cf40c
--- /dev/null
+++ b/src/map-engine/DataFetcher.ts
@@ -0,0 +1,185 @@
+import type { AjaxRequestBody, AjaxResponse, SpotPoint } from './types';
+import { debug as debugLog } from './utils';
+
+/**
+ * Handles AJAX communication with the WordPress backend.
+ * Replaces jQuery.post with the native fetch API.
+ */
+export class DataFetcher {
+ private readonly ajaxUrl: string;
+ private abortController: AbortController | null = null;
+ private readonly dbg: ( ...args: unknown[] ) => void;
+
+ constructor( ajaxUrl: string, debugEnabled = false ) {
+ this.ajaxUrl = ajaxUrl;
+ this.dbg = ( ...args ) => debugLog( debugEnabled, ...args );
+ }
+
+ /**
+ * Fetch points from the server.
+ *
+ * @param body - The request body for the AJAX endpoint.
+ * @param filter - Optional minimum distance (meters) for point filtering.
+ * @returns The array of points, possibly filtered. An empty array signals "empty: true".
+ */
+ async fetchPoints(
+ body: AjaxRequestBody,
+ filter?: number
+ ): Promise< AjaxResponse > {
+ this.abortController = new AbortController();
+
+ // Use URLSearchParams to match jQuery.post's
+ // application/x-www-form-urlencoded format.
+ const params = new URLSearchParams();
+ for ( const [ key, value ] of Object.entries( body ) ) {
+ if ( value === undefined || value === null ) {
+ continue;
+ }
+ if ( Array.isArray( value ) ) {
+ value.forEach( ( item ) => {
+ params.append( `${ key }[]`, String( item ) );
+ } );
+ } else if ( typeof value === 'object' ) {
+ for ( const [ subKey, subVal ] of Object.entries(
+ value as Record< string, unknown >
+ ) ) {
+ params.append( `${ key }[${ subKey }]`, String( subVal ) );
+ }
+ } else {
+ params.append( key, String( value ) );
+ }
+ }
+
+ this.dbg( 'DataFetcher: POST', this.ajaxUrl, body );
+
+ const res = await fetch( this.ajaxUrl, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ },
+ body: params.toString(),
+ signal: this.abortController.signal,
+ } );
+
+ if ( ! res.ok ) {
+ this.dbg( `DataFetcher: HTTP ${ res.status } ${ res.statusText }` );
+ }
+
+ const response = ( await res.json() ) as AjaxResponse;
+
+ if ( response.empty ) {
+ this.dbg( 'DataFetcher: empty response' );
+ return response;
+ }
+
+ if ( response.error ) {
+ this.dbg( 'DataFetcher: error response', response );
+ return response;
+ }
+
+ if ( filter && ! response.empty ) {
+ const before = ( response as SpotPoint[] ).length;
+ const filtered = DataFetcher.removeClosePoints(
+ response,
+ filter
+ ) as AjaxResponse;
+ this.dbg(
+ `DataFetcher: filterPoints=${ filter }m — ${ before } → ${
+ ( filtered as SpotPoint[] ).length
+ } points`
+ );
+ return filtered;
+ }
+
+ this.dbg(
+ `DataFetcher: received ${
+ ( response as SpotPoint[] ).length
+ } points`
+ );
+ return response;
+ }
+
+ /**
+ * Remove points that are within `radius` meters of each other
+ * and share the same type. Runs in O(n) by comparing each point
+ * only to its nearest surviving predecessor.
+ *
+ * When a run of close points is collapsed, the first point in the
+ * run gets a `hiddenPoints` annotation with the count and radius.
+ */
+ static removeClosePoints(
+ points: SpotPoint[],
+ radius: number
+ ): SpotPoint[] {
+ if ( points.length === 0 ) {
+ return points;
+ }
+
+ // We build the result forward.
+ // `anchor` is the last point we decided to keep.
+ const result: SpotPoint[] = [ points[ 0 ] ];
+ let anchor = points[ 0 ];
+ let hiddenCount = 0;
+
+ for ( let i = 1; i < points.length; i++ ) {
+ const point = points[ i ];
+
+ const distance = DataFetcher.haversineMeters(
+ anchor.latitude,
+ anchor.longitude,
+ point.latitude,
+ point.longitude
+ );
+
+ if ( distance <= radius && anchor.type === point.type ) {
+ // Too close — hide this point behind the anchor
+ hiddenCount++;
+ } else {
+ // Far enough (or different type) — flush the hidden count onto the anchor
+ if ( hiddenCount > 0 ) {
+ anchor.hiddenPoints = { count: hiddenCount, radius };
+ hiddenCount = 0;
+ }
+ result.push( point );
+ anchor = point;
+ }
+ }
+
+ // Flush any remaining hidden points for the last anchor
+ if ( hiddenCount > 0 ) {
+ anchor.hiddenPoints = { count: hiddenCount, radius };
+ }
+
+ return result;
+ }
+
+ /**
+ * Fast great-circle distance approximation (Haversine) in metres.
+ * Avoids constructing Leaflet objects in a tight loop.
+ */
+ private static haversineMeters(
+ lat1: number,
+ lng1: number,
+ lat2: number,
+ lng2: number
+ ): number {
+ const R = 6_371_000; // Earth radius in metres
+ const toRad = ( deg: number ) => ( deg * Math.PI ) / 180;
+ const dLat = toRad( lat2 - lat1 );
+ const dLng = toRad( lng2 - lng1 );
+ const a =
+ Math.sin( dLat / 2 ) ** 2 +
+ Math.cos( toRad( lat1 ) ) *
+ Math.cos( toRad( lat2 ) ) *
+ Math.sin( dLng / 2 ) ** 2;
+ return R * 2 * Math.atan2( Math.sqrt( a ), Math.sqrt( 1 - a ) );
+ }
+
+ /**
+ * Abort any in-flight requests.
+ */
+ abort(): void {
+ this.abortController?.abort();
+ this.abortController = null;
+ }
+}
diff --git a/src/map-engine/LayerManager.ts b/src/map-engine/LayerManager.ts
new file mode 100644
index 0000000..2ecb915
--- /dev/null
+++ b/src/map-engine/LayerManager.ts
@@ -0,0 +1,276 @@
+import type {
+ SpotmapOptions,
+ SpotmapLayers,
+ FeedLayer,
+ FeedStyle,
+} from './types';
+import { DEFAULT_COLOR, DEFAULT_GPX_COLOR } from './constants';
+import { debug as debugLog, getColorDot } from './utils';
+
+/**
+ * Manages tile layers, overlays, feed layer groups, and the layer control.
+ */
+export class LayerManager {
+ private readonly map: L.Map;
+ private readonly options: SpotmapOptions;
+ private readonly layers: SpotmapLayers;
+ readonly layerControl: L.Control.Layers;
+ private baseLayers: Array< L.TileLayer | L.TileLayer.WMS > = [];
+ private overlayLayers: Array< L.TileLayer | L.TileLayer.WMS > = [];
+ private readonly dbg: ( ...args: unknown[] ) => void;
+
+ constructor(
+ map: L.Map,
+ options: SpotmapOptions,
+ layers: SpotmapLayers,
+ debugEnabled = false
+ ) {
+ this.map = map;
+ this.options = options;
+ this.layers = layers;
+ this.dbg = ( ...args ) => debugLog( debugEnabled, ...args );
+ this.layerControl = L.control.layers(
+ {},
+ {},
+ { hideSingleBase: true }
+ );
+ }
+
+ /**
+ * Add base tile layers from the maps option.
+ */
+ addBaseLayers(): void {
+ if ( ! this.options.maps ) {
+ return;
+ }
+
+ let firstAdded = false;
+
+ for ( const mapName of this.options.maps ) {
+ const config = spotmapjsobj.maps[ mapName ];
+ if ( ! config ) {
+ this.dbg(
+ `LayerManager: unknown map "${ mapName }" — skipped`
+ );
+ continue;
+ }
+
+ const layer = this.createTileLayer( config );
+ this.layerControl.addBaseLayer( layer, config.label );
+ this.baseLayers.push( layer );
+
+ if ( ! firstAdded ) {
+ layer.addTo( this.map );
+ firstAdded = true;
+ }
+ }
+ }
+
+ /**
+ * Swap base tile layers in-place without rebuilding the map.
+ */
+ updateBaseLayers( newMaps: string[], activeMap?: string ): void {
+ for ( const layer of this.baseLayers ) {
+ this.layerControl.removeLayer( layer );
+ this.map.removeLayer( layer );
+ }
+ this.baseLayers = [];
+
+ let activated = false;
+
+ for ( const mapName of newMaps ) {
+ const config = spotmapjsobj.maps[ mapName ];
+ if ( ! config ) {
+ continue;
+ }
+
+ const layer = this.createTileLayer( config );
+ this.layerControl.addBaseLayer( layer, config.label );
+ this.baseLayers.push( layer );
+
+ const shouldActivate = activeMap
+ ? mapName === activeMap
+ : ! activated;
+ if ( shouldActivate ) {
+ layer.addTo( this.map );
+ activated = true;
+ }
+ }
+
+ // Fallback: activate first if activeMap wasn't found in the list
+ if ( ! activated && this.baseLayers.length > 0 ) {
+ this.baseLayers[ 0 ].addTo( this.map );
+ }
+ }
+
+ /**
+ * Add overlay tile layers from the mapOverlays option.
+ */
+ addOverlays(): void {
+ if ( ! this.options.mapOverlays ) {
+ return;
+ }
+
+ for ( const overlayName of this.options.mapOverlays ) {
+ const config = spotmapjsobj.overlays[ overlayName ];
+ if ( ! config ) {
+ this.dbg(
+ `LayerManager: unknown overlay "${ overlayName }" — skipped`
+ );
+ continue;
+ }
+
+ const layer = this.createTileLayer( config );
+ layer.addTo( this.map );
+ this.layerControl.addOverlay( layer, config.label );
+ this.overlayLayers.push( layer );
+ }
+ }
+
+ /**
+ * Swap overlay tile layers in-place without rebuilding the map.
+ */
+ updateOverlays( newOverlays: string[] ): void {
+ for ( const layer of this.overlayLayers ) {
+ this.layerControl.removeLayer( layer );
+ this.map.removeLayer( layer );
+ }
+ this.overlayLayers = [];
+
+ for ( const overlayName of newOverlays ) {
+ const config = spotmapjsobj.overlays[ overlayName ];
+ if ( ! config ) {
+ continue;
+ }
+
+ const layer = this.createTileLayer( config );
+ layer.addTo( this.map );
+ this.layerControl.addOverlay( layer, config.label );
+ this.overlayLayers.push( layer );
+ }
+ }
+
+ private createTileLayer(
+ config: import('./types').TileLayerConfig
+ ): L.TileLayer | L.TileLayer.WMS {
+ return config.wms
+ ? L.tileLayer.wms( config.url, config.options as L.WMSOptions )
+ : L.tileLayer( config.url, config.options as L.TileLayerOptions );
+ }
+
+ /**
+ * Check if a feed layer already exists.
+ */
+ doesFeedExist( feedName: string ): boolean {
+ return feedName in this.layers.feeds;
+ }
+
+ /**
+ * Initialize a new empty feed layer group.
+ * Returns false if the feed already exists.
+ */
+ initFeedLayer( feedName: string, initialLine: L.Polyline ): boolean {
+ if ( this.doesFeedExist( feedName ) ) {
+ return false;
+ }
+
+ const featureGroup = L.featureGroup();
+ featureGroup.addLayer( initialLine );
+
+ this.layers.feeds[ feedName ] = {
+ lines: [ initialLine ],
+ markers: [],
+ points: [],
+ featureGroup,
+ };
+
+ return true;
+ }
+
+ /**
+ * Add all feed layers to the map and layer control.
+ */
+ addFeedsToMap(): void {
+ const feedNames = Object.keys( this.layers.feeds );
+
+ for ( const feedName of feedNames ) {
+ const feed = this.layers.feeds[ feedName ];
+
+ // Respect per-feed visibility — still register in layer control
+ // so the public user can toggle the feed back on.
+ if ( this.isFeedVisible( feedName ) ) {
+ feed.featureGroup.addTo( this.map );
+ } else {
+ this.dbg(
+ `LayerManager: feed "${ feedName }" initially hidden`
+ );
+ }
+
+ const color = this.getFeedColor( feedName );
+ const label = `${ feedName } ${ getColorDot( color ) }`;
+ this.layerControl.addOverlay( feed.featureGroup, label );
+ this.dbg(
+ `LayerManager: feed "${ feedName }" added — color=${ color }, points=${ feed.points.length }`
+ );
+ }
+ }
+
+ /**
+ * Get the color for a feed from the styles config.
+ */
+ getFeedColor( feedName: string ): string {
+ return this.options.styles[ feedName ]?.color ?? DEFAULT_COLOR;
+ }
+
+ /**
+ * Get the color for a GPX track.
+ */
+ getGpxColor( gpxColor?: string ): string {
+ return gpxColor ?? DEFAULT_GPX_COLOR;
+ }
+
+ /**
+ * Get the splitLines value (in hours) for a feed, or false if disabled.
+ */
+ getFeedSplitLines( feedName: string ): number | false {
+ const style: FeedStyle | undefined = this.options.styles[ feedName ];
+ if ( ! style ) {
+ return false;
+ }
+ if ( style.splitLinesEnabled === false ) {
+ return false;
+ }
+ // Treat 0 as disabled (falsy), matching the old behaviour where
+ // `if (!splitLines) return false` would short-circuit on 0.
+ return style.splitLines || false;
+ }
+
+ /**
+ * Get the line width (px) for a feed's polylines.
+ */
+ getFeedLineWidth( feedName: string ): number {
+ return this.options.styles[ feedName ]?.lineWidth ?? 2;
+ }
+
+ /**
+ * Get the line opacity for a feed's polylines.
+ */
+ getFeedLineOpacity( feedName: string ): number {
+ return this.options.styles[ feedName ]?.lineOpacity ?? 1.0;
+ }
+
+ /**
+ * Whether a feed should be initially visible on the map.
+ * Unset (undefined) defaults to visible.
+ */
+ isFeedVisible( feedName: string ): boolean {
+ return this.options.styles[ feedName ]?.visible !== false;
+ }
+
+ /**
+ * Get the feed layer data for a given feed name.
+ */
+ getFeedLayer( feedName: string ): FeedLayer | undefined {
+ return this.layers.feeds[ feedName ];
+ }
+}
diff --git a/src/map-engine/LineManager.ts b/src/map-engine/LineManager.ts
new file mode 100644
index 0000000..17a8390
--- /dev/null
+++ b/src/map-engine/LineManager.ts
@@ -0,0 +1,113 @@
+import type { SpotmapLayers, SpotPoint } from './types';
+import { debug as debugLog } from './utils';
+import {
+ LINE_ARROW_CHAR,
+ LINE_ARROW_FONT_SIZE,
+ LINE_ARROW_OFFSET,
+ MEDIA_FEED_NAME,
+} from './constants';
+import type { LayerManager } from './LayerManager';
+
+/**
+ * Manages polyline creation and the splitLines logic.
+ */
+export class LineManager {
+ private readonly layers: SpotmapLayers;
+ private readonly layerManager: LayerManager;
+ private readonly dbg: ( ...args: unknown[] ) => void;
+
+ constructor(
+ layers: SpotmapLayers,
+ layerManager: LayerManager,
+ debugEnabled = false
+ ) {
+ this.layers = layers;
+ this.layerManager = layerManager;
+ this.dbg = ( ...args ) => debugLog( debugEnabled, ...args );
+ }
+
+ /**
+ * Add a point to the appropriate polyline for its feed.
+ * Handles line splitting when the time gap exceeds the splitLines threshold.
+ *
+ * @returns true if the point was added to a line, false if not (splitLines disabled or media feed).
+ */
+ addPointToLine( point: SpotPoint ): boolean {
+ const feedName = point.feed_name;
+
+ if ( feedName === MEDIA_FEED_NAME ) {
+ return false;
+ }
+
+ const coordinates: L.LatLngTuple = [ point.latitude, point.longitude ];
+
+ const splitLines = this.layerManager.getFeedSplitLines( feedName );
+ if ( splitLines === false ) {
+ return false;
+ }
+
+ const feed = this.layers.feeds[ feedName ];
+ const pointCount = feed.points.length;
+
+ let lastPoint: SpotPoint | undefined;
+ if ( pointCount >= 2 ) {
+ lastPoint = feed.points[ pointCount - 2 ];
+ }
+
+ const splitThresholdSeconds = splitLines * 60 * 60;
+
+ // If the time gap exceeds the threshold, start a new line
+ if (
+ lastPoint &&
+ point.unixtime - lastPoint.unixtime >= splitThresholdSeconds
+ ) {
+ const gapHours = (
+ ( point.unixtime - lastPoint.unixtime ) /
+ 3600
+ ).toFixed( 1 );
+ this.dbg(
+ `LineManager: line split for feed "${ feedName }" — gap ${ gapHours }h > threshold ${ splitLines }h`
+ );
+ const line = this.createLine( feedName );
+ line.addLatLng( coordinates );
+ feed.lines.push( line );
+ feed.featureGroup.addLayer( line );
+ } else {
+ // Add to the current (last) line
+ const currentLine = feed.lines[ feed.lines.length - 1 ];
+ currentLine.addLatLng( coordinates );
+ }
+
+ return true;
+ }
+
+ /**
+ * Create an empty polyline styled for the given feed.
+ * Includes directional arrow text along the path.
+ */
+ createLine( feedName: string ): L.Polyline {
+ const color = this.layerManager.getFeedColor( feedName );
+ const weight = this.layerManager.getFeedLineWidth( feedName );
+ const opacity = this.layerManager.getFeedLineOpacity( feedName );
+ const line = L.polyline( [], { color, weight, opacity } );
+
+ // Add directional arrows using the TextPath plugin
+ (
+ line as unknown as {
+ setText: (
+ text: string,
+ options: Record< string, unknown >
+ ) => void;
+ }
+ ).setText( LINE_ARROW_CHAR, {
+ repeat: true,
+ offset: LINE_ARROW_OFFSET,
+ attributes: {
+ fill: 'black',
+ 'font-size': LINE_ARROW_FONT_SIZE,
+ },
+ } );
+
+ return line;
+ }
+}
diff --git a/src/map-engine/MarkerManager.ts b/src/map-engine/MarkerManager.ts
new file mode 100644
index 0000000..4e33ebd
--- /dev/null
+++ b/src/map-engine/MarkerManager.ts
@@ -0,0 +1,209 @@
+import type { SpotPoint, SpotmapLayers } from './types';
+import { debug as debugLog } from './utils';
+import {
+ TRACK_TYPES,
+ CIRCLE_DOT_ICON_SIZE,
+ CIRCLE_DOT_ICON_ANCHOR,
+ CIRCLE_DOT_BORDER_WIDTH,
+ Z_INDEX_TRACK,
+ Z_INDEX_STATUS,
+ Z_INDEX_HELP,
+ SINGLE_POINT_ZOOM,
+} from './constants';
+import type { LayerManager } from './LayerManager';
+
+/**
+ * Manages marker creation, icon styling, and popup content.
+ */
+export class MarkerManager {
+ private readonly map: L.Map;
+ private readonly layers: SpotmapLayers;
+ private readonly layerManager: LayerManager;
+ private readonly tableCellControllers: AbortController[] = [];
+ private readonly dbg: ( ...args: unknown[] ) => void;
+
+ constructor(
+ map: L.Map,
+ layers: SpotmapLayers,
+ layerManager: LayerManager,
+ debugEnabled = false
+ ) {
+ this.map = map;
+ this.layers = layers;
+ this.layerManager = layerManager;
+ this.dbg = ( ...args ) => debugLog( debugEnabled, ...args );
+ }
+
+ /**
+ * Add a point to the map as a marker.
+ */
+ addPoint( point: SpotPoint ): void {
+ const feedName = point.feed_name;
+ const coordinates: L.LatLngTuple = [ point.latitude, point.longitude ];
+
+ const feed = this.layers.feeds[ feedName ];
+ if ( ! feed ) {
+ this.dbg(
+ `MarkerManager: unknown feed "${ feedName }" for point id=${ point.id } — skipped`
+ );
+ return;
+ }
+
+ const markerOptions = this.getMarkerOptions( point );
+ const popupHtml = MarkerManager.getPopupHtml( point );
+ const marker = L.marker( coordinates, markerOptions ).bindPopup(
+ popupHtml
+ );
+
+ feed.points.push( point );
+ feed.markers.push( marker );
+ feed.featureGroup.addLayer( marker );
+
+ // Bind click handlers for the corresponding table row (if it exists).
+ // Use an AbortController so all listeners can be removed on destroy().
+ const tableCell = document.getElementById( `spotmap_${ point.id }` );
+ if ( tableCell ) {
+ const controller = new AbortController();
+ this.tableCellControllers.push( controller );
+ const { signal } = controller;
+ tableCell.addEventListener(
+ 'click',
+ () => {
+ marker.togglePopup();
+ this.map.panTo( coordinates );
+ },
+ { signal }
+ );
+ tableCell.addEventListener(
+ 'dblclick',
+ () => {
+ marker.togglePopup();
+ this.map.setView( coordinates, SINGLE_POINT_ZOOM );
+ },
+ { signal }
+ );
+ }
+ }
+
+ /**
+ * Build marker options with the correct icon and z-index.
+ */
+ private getMarkerOptions( point: SpotPoint ): L.MarkerOptions {
+ let zIndexOffset = Z_INDEX_TRACK;
+
+ if ( ! TRACK_TYPES.includes( point.type ) ) {
+ zIndexOffset = Z_INDEX_STATUS;
+ }
+ if ( [ 'HELP', 'HELP-CANCEL' ].includes( point.type ) ) {
+ zIndexOffset = Z_INDEX_HELP;
+ }
+
+ return {
+ icon: this.getMarkerIcon( point ),
+ zIndexOffset,
+ };
+ }
+
+ /**
+ * Create a BeautifyIcon for a point or a GPX waypoint.
+ *
+ * @param point - Either a SpotPoint (has feed_name/type) or a simple {color} object for GPX.
+ */
+ getMarkerIcon(
+ point: SpotPoint | { color: string; feed_name?: string; type?: string }
+ ): L.Icon {
+ const color =
+ ( 'color' in point ? point.color : undefined ) ??
+ ( point.feed_name
+ ? this.layerManager.getFeedColor( point.feed_name )
+ : 'blue' );
+
+ const iconOptions: L.BeautifyIconOptions = {
+ textColor: color,
+ borderColor: color,
+ };
+
+ const pointType = point.type;
+
+ if ( pointType && spotmapjsobj.marker[ pointType ] ) {
+ // Use the configured marker shape for this point type
+ const config = spotmapjsobj.marker[ pointType ];
+ iconOptions.iconShape = config.iconShape;
+ iconOptions.icon = config.icon;
+
+ if ( iconOptions.iconShape === 'circle-dot' ) {
+ iconOptions.iconAnchor = CIRCLE_DOT_ICON_ANCHOR;
+ iconOptions.iconSize = CIRCLE_DOT_ICON_SIZE;
+ iconOptions.borderWith = CIRCLE_DOT_BORDER_WIDTH;
+ }
+ } else if (
+ pointType &&
+ TRACK_TYPES.includes( pointType as SpotPoint[ 'type' ] )
+ ) {
+ // Track types fall back to the UNLIMITED-TRACK circle-dot shape
+ const trackMarker = spotmapjsobj.marker[ 'UNLIMITED-TRACK' ];
+ iconOptions.iconShape = trackMarker?.iconShape;
+ iconOptions.icon = trackMarker?.icon;
+ iconOptions.iconAnchor = CIRCLE_DOT_ICON_ANCHOR;
+ iconOptions.iconSize = CIRCLE_DOT_ICON_SIZE;
+ iconOptions.borderWith = CIRCLE_DOT_BORDER_WIDTH;
+ } else {
+ this.dbg(
+ `MarkerManager: no marker config for type "${
+ pointType ?? '(none)'
+ }" — using generic marker`
+ );
+ iconOptions.iconShape = 'marker';
+ iconOptions.icon = 'circle';
+ }
+
+ return L.BeautifyIcon.icon( iconOptions );
+ }
+
+ /**
+ * Remove all table-cell event listeners added by addPoint().
+ */
+ destroy(): void {
+ for ( const controller of this.tableCellControllers ) {
+ controller.abort();
+ }
+ this.tableCellControllers.length = 0;
+ }
+
+ /**
+ * Generate the popup HTML for a point.
+ */
+ static getPopupHtml( entry: SpotPoint ): string {
+ let html = `${ entry.type } `;
+ html += `Time: ${ entry.time } Date: ${ entry.date } `;
+
+ if (
+ entry.local_timezone &&
+ ! (
+ entry.localdate === entry.date && entry.localtime === entry.time
+ )
+ ) {
+ html += `Local Time: ${ entry.localtime } Local Date: ${ entry.localdate } `;
+ }
+
+ if ( entry.message && entry.type === 'MEDIA' ) {
+ html += ` `;
+ } else if ( entry.message ) {
+ html += `${ entry.message } `;
+ }
+
+ if ( entry.altitude > 0 ) {
+ html += `Altitude: ${ Number( entry.altitude ) }m `;
+ }
+
+ if ( entry.battery_status === 'LOW' ) {
+ html += `Battery status is low! `;
+ }
+
+ if ( entry.hiddenPoints ) {
+ html += `There are ${ entry.hiddenPoints.count } hidden Points within a radius of ${ entry.hiddenPoints.radius } meters `;
+ }
+
+ return html;
+ }
+}
diff --git a/src/map-engine/Spotmap.ts b/src/map-engine/Spotmap.ts
new file mode 100644
index 0000000..0952bd7
--- /dev/null
+++ b/src/map-engine/Spotmap.ts
@@ -0,0 +1,544 @@
+import type {
+ SpotmapOptions,
+ SpotmapLayers,
+ AjaxRequestBody,
+ AjaxResponse,
+ TableOptions,
+ SpotPoint,
+} from './types';
+import {
+ DEFAULT_CENTER,
+ DEFAULT_ZOOM,
+ AUTO_RELOAD_INTERVAL_MS,
+ MAX_RELOAD_BACKOFF_MS,
+ SINGLE_POINT_ZOOM,
+} from './constants';
+import { debug as debugLog, getColorDot } from './utils';
+
+const DOWNLOAD_SVG =
+ ' ';
+import { DataFetcher } from './DataFetcher';
+import { LayerManager } from './LayerManager';
+import { MarkerManager } from './MarkerManager';
+import { LineManager } from './LineManager';
+import { BoundsManager } from './BoundsManager';
+import { ButtonManager } from './ButtonManager';
+import { TableRenderer } from './TableRenderer';
+
+/**
+ * Main Spotmap orchestrator.
+ *
+ * Coordinates map initialization, data fetching, and all sub-managers.
+ * Used both in the Gutenberg editor preview and the public frontend.
+ */
+export class Spotmap {
+ readonly options: SpotmapOptions;
+ map!: L.Map;
+ layers: SpotmapLayers = { feeds: {}, gpx: {} };
+
+ private dataFetcher!: DataFetcher;
+ private layerManager!: LayerManager;
+ private markerManager!: MarkerManager;
+ private lineManager!: LineManager;
+ private boundsManager!: BoundsManager;
+ private buttonManager!: ButtonManager;
+ private tableRenderer: TableRenderer | null = null;
+
+ private _destroyed = false;
+ private autoReloadTimeoutId: ReturnType< typeof setTimeout > | null = null;
+ private latestUnixtimeByFeed: Map< string, number > = new Map();
+ private onVisibilityChange: ( () => void ) | null = null;
+ private reloadBody: AjaxRequestBody | null = null;
+
+ constructor( options: SpotmapOptions ) {
+ if ( ! options.maps ) {
+ console.error( 'Missing important options!!' ); // eslint-disable-line no-console
+ }
+ this.options = options;
+ this.debug( 'Spotmap obj created.' );
+ this.debug( this.options );
+ }
+
+ /**
+ * Swap the base tile layers without rebuilding the map or re-fetching data.
+ */
+ updateMaps( maps: string[], activeMap?: string ): void {
+ if ( ! this.layerManager ) {
+ return;
+ }
+ this.layerManager.updateBaseLayers( maps, activeMap );
+ }
+
+ updateOverlays( overlays: string[] ): void {
+ if ( ! this.layerManager ) {
+ return;
+ }
+ this.layerManager.updateOverlays( overlays );
+ }
+
+ updateHeight( height: number ): void {
+ const el = this.map?.getContainer();
+ if ( ! el ) {
+ return;
+ }
+ el.style.height = `${ height }px`;
+ this.map.invalidateSize();
+ }
+
+ updateButtons(
+ locateButton: boolean | undefined,
+ navigationButtons: import('./types').NavigationButtonsConfig | undefined
+ ): void {
+ if ( ! this.buttonManager ) {
+ return;
+ }
+ this.buttonManager.updateButtons( locateButton, navigationButtons );
+ }
+
+ updateAutoReload( enabled: boolean ): void {
+ if ( enabled ) {
+ if ( this.autoReloadTimeoutId !== null || ! this.reloadBody ) {
+ return;
+ }
+ for ( const [ feedName, feed ] of Object.entries(
+ this.layers.feeds
+ ) ) {
+ this.latestUnixtimeByFeed.set(
+ feedName,
+ feed.points.at( -1 )?.unixtime ?? 0
+ );
+ }
+ this.startAutoReload( this.reloadBody );
+ } else {
+ if ( this.autoReloadTimeoutId !== null ) {
+ clearTimeout( this.autoReloadTimeoutId );
+ this.autoReloadTimeoutId = null;
+ }
+ if ( this.onVisibilityChange ) {
+ document.removeEventListener(
+ 'visibilitychange',
+ this.onVisibilityChange
+ );
+ this.onVisibilityChange = null;
+ }
+ }
+ }
+
+ updateScrollWheelZoom( enabled: boolean ): void {
+ if ( ! this.map ) {
+ return;
+ }
+ if ( enabled ) {
+ this.map.scrollWheelZoom.enable();
+ } else {
+ this.map.scrollWheelZoom.disable();
+ }
+ }
+
+ /**
+ * Initialize the Leaflet map and load data.
+ */
+ async initMap(): Promise< void > {
+ const el =
+ this.options.mapElement ??
+ document.getElementById( this.options.mapId ?? '' );
+
+ if ( ! el ) {
+ throw new Error( 'Map container not found.' );
+ }
+
+ el.style.height = `${ this.options.height }px`;
+
+ // If the element already has a Leaflet map and options haven't changed,
+ // skip re-initialization.
+ const oldOptions = (
+ el as HTMLElement & { _spotmapOptions?: SpotmapOptions }
+ )._spotmapOptions;
+ (
+ el as HTMLElement & { _spotmapOptions?: SpotmapOptions }
+ )._spotmapOptions = this.options;
+
+ if ( ( el as HTMLElement & { _leaflet_id?: number } )._leaflet_id ) {
+ if (
+ JSON.stringify( this.options ) === JSON.stringify( oldOptions )
+ ) {
+ return;
+ }
+ // Reset the Leaflet instance on the element
+ (
+ el as HTMLElement & { _leaflet_id?: number | null }
+ )._leaflet_id = null;
+ // Clear child panes
+ el.querySelectorAll( '.leaflet-control-container' ).forEach(
+ ( c ) => {
+ c.innerHTML = '';
+ }
+ );
+ el.querySelectorAll( '.leaflet-pane' ).forEach( ( p ) => {
+ p.innerHTML = '';
+ } );
+ }
+
+ // Create the Leaflet map
+ this.map = L.map( el, {
+ scrollWheelZoom: this.options.scrollWheelZoom ?? false,
+ dragging: this.options.enablePanning ?? true,
+ zoomControl: this.options.zoomControl ?? true,
+ } );
+
+ // Optional controls
+ if ( this.options.fullscreenButton !== false ) {
+ new L.Control.FullScreen().addTo( this.map );
+ }
+ if ( this.options.scaleControl !== false ) {
+ L.control.scale().addTo( this.map );
+ }
+
+ // Enable scroll wheel zoom on focus, but only if the option allows it
+ if ( this.options.scrollWheelZoom ) {
+ this.map.once( 'focus', () => {
+ this.map.scrollWheelZoom.enable();
+ } );
+ }
+
+ // Initialize sub-managers
+ const dbg = !! this.options.debug;
+ this.dataFetcher = new DataFetcher( spotmapjsobj.ajaxUrl, dbg );
+ this.layerManager = new LayerManager(
+ this.map,
+ this.options,
+ this.layers,
+ dbg
+ );
+ this.markerManager = new MarkerManager(
+ this.map,
+ this.layers,
+ this.layerManager,
+ dbg
+ );
+ this.lineManager = new LineManager(
+ this.layers,
+ this.layerManager,
+ dbg
+ );
+ this.boundsManager = new BoundsManager( this.map, this.layers, dbg );
+ this.buttonManager = new ButtonManager(
+ this.map,
+ this.options,
+ this.boundsManager
+ );
+
+ // Add tile layers and controls
+ this.layerManager.addBaseLayers();
+ this.buttonManager.addButtons();
+ this.layerManager.layerControl.addTo( this.map );
+
+ // Fetch and render data
+ const body: AjaxRequestBody = {
+ action: 'spotmap_get_positions',
+ select: '*',
+ feeds: this.options.feeds ?? '',
+ 'date-range': this.options.dateRange,
+ date: this.options.date,
+ orderBy: 'feed_name, time',
+ groupBy: '',
+ };
+ this.reloadBody = body;
+
+ try {
+ const response = await this.dataFetcher.fetchPoints(
+ body,
+ this.options.filterPoints
+ );
+
+ if ( this._destroyed || ! this.map ) {
+ return;
+ }
+
+ if ( ! response.empty ) {
+ for ( const entry of response as import('./types').SpotPoint[] ) {
+ this.ensureFeedLayer( entry.feed_name );
+ this.markerManager.addPoint( entry );
+ this.lineManager.addPointToLine( entry );
+ }
+ }
+
+ this.loadGpxTracks( response );
+ this.layerManager.addFeedsToMap();
+ this.addLastPointMarkers();
+
+ if (
+ response.empty &&
+ ( ! this.options.gpx || this.options.gpx.length === 0 )
+ ) {
+ this.showEmptyState();
+ } else {
+ this.boundsManager.fitBounds( this.options.mapcenter );
+ }
+
+ this.layerManager.addOverlays();
+
+ if ( this.options.autoReload && ! response.empty ) {
+ for ( const [ feedName, feed ] of Object.entries(
+ this.layers.feeds
+ ) ) {
+ this.latestUnixtimeByFeed.set(
+ feedName,
+ feed.points.at( -1 )?.unixtime ?? 0
+ );
+ }
+ this.startAutoReload( body );
+ }
+ } catch ( err ) {
+ this.debug( 'Error loading map data:' );
+ this.debug( err );
+ }
+ }
+
+ /**
+ * Initialize the [spotmessages] table view.
+ */
+ async initTable( elementId: string ): Promise< void > {
+ const tableOptions: TableOptions = {
+ feeds: this.options.feeds,
+ dateRange: this.options.dateRange,
+ date: this.options.date,
+ autoReload: this.options.autoReload,
+ filterPoints: this.options.filterPoints,
+ debug: this.options.debug,
+ ...( ( this.options as unknown as TableOptions ).type && {
+ type: ( this.options as unknown as TableOptions ).type,
+ } ),
+ ...( ( this.options as unknown as TableOptions ).orderBy && {
+ orderBy: ( this.options as unknown as TableOptions ).orderBy,
+ } ),
+ ...( ( this.options as unknown as TableOptions ).limit && {
+ limit: ( this.options as unknown as TableOptions ).limit,
+ } ),
+ ...( ( this.options as unknown as TableOptions ).groupBy && {
+ groupBy: ( this.options as unknown as TableOptions ).groupBy,
+ } ),
+ };
+
+ this.dataFetcher = new DataFetcher( spotmapjsobj.ajaxUrl );
+ this.tableRenderer = new TableRenderer(
+ tableOptions,
+ this.dataFetcher
+ );
+ await this.tableRenderer.initTable( elementId );
+ }
+
+ /**
+ * Clean up all resources: intervals, event listeners, map instance.
+ */
+ destroy(): void {
+ this._destroyed = true;
+
+ if ( this.autoReloadTimeoutId !== null ) {
+ clearTimeout( this.autoReloadTimeoutId );
+ this.autoReloadTimeoutId = null;
+ }
+
+ if ( this.onVisibilityChange ) {
+ document.removeEventListener(
+ 'visibilitychange',
+ this.onVisibilityChange
+ );
+ this.onVisibilityChange = null;
+ }
+
+ this.tableRenderer?.destroy();
+ this.markerManager?.destroy();
+ this.dataFetcher?.abort();
+
+ if ( this.map ) {
+ this.map.remove();
+ }
+ }
+
+ // ------- Private helpers -------
+
+ private ensureFeedLayer( feedName: string ): void {
+ if ( ! this.layerManager.doesFeedExist( feedName ) ) {
+ const line = this.lineManager.createLine( feedName );
+ this.layerManager.initFeedLayer( feedName, line );
+ }
+ }
+
+ private loadGpxTracks( response: AjaxResponse ): void {
+ if ( ! this.options.gpx ) {
+ return;
+ }
+
+ for ( const entry of this.options.gpx ) {
+ const color = this.layerManager.getGpxColor( entry.color );
+ const gpxOptions = {
+ async: true,
+ marker_options: {
+ wptIcons: {
+ '': this.markerManager.getMarkerIcon( {
+ color,
+ } ),
+ },
+ wptIconsType: {
+ '': this.markerManager.getMarkerIcon( {
+ color,
+ } ),
+ },
+ startIconUrl: '',
+ endIconUrl: '',
+ shadowUrl: spotmapjsobj.url + 'leaflet-gpx/pin-shadow.png',
+ },
+ polyline_options: { color },
+ };
+
+ const downloadLink = entry.download
+ ? ` ${ DOWNLOAD_SVG } `
+ : '';
+
+ const track = new L.GPX( entry.url, gpxOptions )
+ .on( 'loaded', () => {
+ if ( this.options.mapcenter === 'gpx' || response.empty ) {
+ this.boundsManager.fitBounds( 'gpx' );
+ }
+ } )
+ .on( 'addline', ( e: L.LeafletEvent ) => {
+ (
+ e as L.LeafletEvent & { line: L.Polyline }
+ ).line.bindPopup( entry.title + downloadLink );
+ } );
+
+ const html = ` ${ getColorDot( color ) }${ downloadLink }`;
+ this.layers.gpx[ entry.title ] = {
+ featureGroup: L.featureGroup( [ track ] ),
+ };
+ if ( entry.visible !== false ) {
+ this.layers.gpx[ entry.title ].featureGroup.addTo( this.map );
+ }
+ this.layerManager.layerControl.addOverlay(
+ this.layers.gpx[ entry.title ].featureGroup,
+ entry.title + html
+ );
+ }
+ }
+
+ private addLastPointMarkers(): void {
+ for ( const [ feedName, feed ] of Object.entries(
+ this.layers.feeds
+ ) ) {
+ if ( ! this.options.styles?.[ feedName ]?.lastPoint ) {
+ continue;
+ }
+ const lp = feed.points.at( -1 );
+ if ( ! lp ) {
+ continue;
+ }
+ const color = this.layerManager.getFeedColor( feedName );
+ const icon = L.BeautifyIcon.icon( {
+ iconShape: 'marker',
+ icon: 'circle',
+ textColor: color,
+ borderColor: color,
+ } );
+ L.marker( [ lp.latitude, lp.longitude ], {
+ icon,
+ zIndexOffset: 1000,
+ } )
+ .bindPopup( MarkerManager.getPopupHtml( lp ) )
+ .addTo( feed.featureGroup );
+ }
+ }
+
+ private showEmptyState(): void {
+ this.map.setView( DEFAULT_CENTER, DEFAULT_ZOOM );
+ L.popup()
+ .setLatLng( [ DEFAULT_CENTER[ 0 ] + 0.008, DEFAULT_CENTER[ 1 ] ] )
+ .setContent( 'There is nothing to show here yet.' )
+ .openOn( this.map );
+ }
+
+ private startAutoReload( body: AjaxRequestBody ): void {
+ const reloadBody: AjaxRequestBody = {
+ ...body,
+ groupBy: 'feed_name',
+ orderBy: 'time DESC',
+ };
+
+ const poll = ( delay: number ): void => {
+ this.autoReloadTimeoutId = setTimeout( async () => {
+ if ( document.hidden ) {
+ return;
+ }
+
+ try {
+ const response = await this.dataFetcher.fetchPoints(
+ reloadBody,
+ this.options.filterPoints
+ );
+
+ if ( ! response.error && ! response.empty ) {
+ for ( const entry of response as SpotPoint[] ) {
+ const feedName = entry.feed_name;
+ const feed = this.layers.feeds[ feedName ];
+ if ( ! feed ) {
+ continue;
+ }
+
+ const lastUnixtime =
+ this.latestUnixtimeByFeed.get( feedName ) ?? 0;
+ if ( entry.unixtime > lastUnixtime ) {
+ this.latestUnixtimeByFeed.set(
+ feedName,
+ entry.unixtime
+ );
+ this.debug(
+ `Found a new point for Feed: ${ feedName }`
+ );
+ this.markerManager.addPoint( entry );
+ this.lineManager.addPointToLine( entry );
+
+ if ( this.options.mapcenter === 'last' ) {
+ this.map.setView(
+ [ entry.latitude, entry.longitude ],
+ SINGLE_POINT_ZOOM
+ );
+ }
+ }
+ }
+ }
+
+ poll( AUTO_RELOAD_INTERVAL_MS );
+ } catch ( err ) {
+ this.debug( 'Auto-reload error:', err );
+ poll( Math.min( delay * 2, MAX_RELOAD_BACKOFF_MS ) );
+ }
+ }, delay );
+ };
+
+ if ( this.onVisibilityChange ) {
+ document.removeEventListener(
+ 'visibilitychange',
+ this.onVisibilityChange
+ );
+ }
+ this.onVisibilityChange = () => {
+ if ( ! document.hidden ) {
+ if ( this.autoReloadTimeoutId !== null ) {
+ clearTimeout( this.autoReloadTimeoutId );
+ this.autoReloadTimeoutId = null;
+ }
+ poll( 0 );
+ }
+ };
+ document.addEventListener(
+ 'visibilitychange',
+ this.onVisibilityChange
+ );
+
+ poll( AUTO_RELOAD_INTERVAL_MS );
+ }
+
+ private debug( ...args: unknown[] ): void {
+ debugLog( !! this.options?.debug, ...args );
+ }
+}
diff --git a/src/map-engine/TableRenderer.ts b/src/map-engine/TableRenderer.ts
new file mode 100644
index 0000000..8febc4e
--- /dev/null
+++ b/src/map-engine/TableRenderer.ts
@@ -0,0 +1,244 @@
+import type { AjaxRequestBody, SpotPoint, TableOptions } from './types';
+import { TABLE_RELOAD_INTERVAL_MS, MAX_RELOAD_BACKOFF_MS } from './constants';
+import { DataFetcher } from './DataFetcher';
+import { debug as debugLog } from './utils';
+
+/**
+ * Renders the [spotmessages] shortcode table and handles auto-reload.
+ */
+export class TableRenderer {
+ private readonly options: TableOptions;
+ private readonly dataFetcher: DataFetcher;
+ private timeoutId: ReturnType< typeof setTimeout > | null = null;
+ private onVisibilityChange: ( () => void ) | null = null;
+
+ constructor( options: TableOptions, dataFetcher: DataFetcher ) {
+ this.options = options;
+ this.dataFetcher = dataFetcher;
+ }
+
+ /**
+ * Initialize the table: fetch data and render into the given element.
+ */
+ async initTable( elementId: string ): Promise< void > {
+ const body = this.buildRequestBody();
+
+ try {
+ const response = await this.dataFetcher.fetchPoints(
+ body,
+ this.options.filterPoints
+ );
+
+ const table = document.getElementById( elementId );
+ if ( ! table ) {
+ return;
+ }
+
+ if ( response.error ) {
+ this.renderTable( table, [], false );
+ this.appendRow( table, [ '', 'No data found', '' ] );
+ return;
+ }
+
+ const points = Array.isArray( response ) ? response : [];
+ const hasLocaltime = points.some( ( p ) => !! p.local_timezone );
+
+ this.renderTable( table, points, hasLocaltime );
+
+ if ( this.options.autoReload ) {
+ this.startAutoReload( elementId, body, points );
+ }
+ } catch ( err ) {
+ debugLog( !! this.options.debug, 'TableRenderer error:', err );
+ }
+ }
+
+ /**
+ * Render the table header and rows.
+ */
+ private renderTable(
+ table: HTMLElement,
+ points: SpotPoint[],
+ hasLocaltime: boolean
+ ): void {
+ table.innerHTML = '';
+
+ const headers = [ 'Type', 'Message', 'Time' ];
+ if ( hasLocaltime ) {
+ headers.push( 'Local Time' );
+ }
+
+ // Header row
+ const headerRow = document.createElement( 'tr' );
+ for ( const header of headers ) {
+ const th = document.createElement( 'th' );
+ th.textContent = header;
+ headerRow.appendChild( th );
+ }
+ table.appendChild( headerRow );
+
+ // Data rows
+ for ( const entry of points ) {
+ this.appendPointRow( table, entry, hasLocaltime );
+ }
+ }
+
+ private appendPointRow(
+ table: HTMLElement,
+ entry: SpotPoint,
+ hasLocaltime: boolean
+ ): void {
+ const row = document.createElement( 'tr' );
+ row.className = `spotmap ${ entry.type }`;
+
+ const typeCell = document.createElement( 'td' );
+ typeCell.id = `spotmap_${ entry.id }`;
+ typeCell.textContent = entry.type;
+ row.appendChild( typeCell );
+
+ const messageCell = document.createElement( 'td' );
+ messageCell.textContent = entry.message ?? '';
+ row.appendChild( messageCell );
+
+ const timeCell = document.createElement( 'td' );
+ timeCell.innerHTML = `${ entry.time } ${ entry.date }`;
+ row.appendChild( timeCell );
+
+ if ( hasLocaltime ) {
+ const localCell = document.createElement( 'td' );
+ localCell.innerHTML = entry.local_timezone
+ ? `${ entry.localtime ?? '' } ${ entry.localdate ?? '' }`
+ : '';
+ row.appendChild( localCell );
+ }
+
+ table.appendChild( row );
+ }
+
+ private appendRow( table: HTMLElement, cells: string[] ): void {
+ const row = document.createElement( 'tr' );
+ for ( const text of cells ) {
+ const td = document.createElement( 'td' );
+ td.textContent = text;
+ row.appendChild( td );
+ }
+ table.appendChild( row );
+ }
+
+ /**
+ * Set up periodic polling for new data.
+ */
+ private startAutoReload(
+ elementId: string,
+ body: AjaxRequestBody,
+ initialPoints: SpotPoint[]
+ ): void {
+ let lastFirstUnixtime = initialPoints[ 0 ]?.unixtime ?? 0;
+
+ const poll = ( delay: number ): void => {
+ this.timeoutId = setTimeout( async () => {
+ if ( document.hidden ) {
+ return;
+ }
+
+ try {
+ const response = await this.dataFetcher.fetchPoints(
+ body,
+ this.options.filterPoints
+ );
+
+ if ( ! response.error && ! response.empty ) {
+ const points = Array.isArray( response )
+ ? response
+ : [];
+ const newFirstUnixtime = points[ 0 ]?.unixtime ?? 0;
+
+ if ( newFirstUnixtime > lastFirstUnixtime ) {
+ lastFirstUnixtime = newFirstUnixtime;
+ const table = document.getElementById( elementId );
+ if ( table ) {
+ const scrollTop =
+ table.parentElement?.scrollTop ?? 0;
+ const hasLocaltime = points.some(
+ ( p ) => !! p.local_timezone
+ );
+ this.renderTable( table, points, hasLocaltime );
+ if ( table.parentElement ) {
+ table.parentElement.scrollTop = scrollTop;
+ }
+ }
+ } else {
+ debugLog( !! this.options.debug, 'same response!' );
+ }
+ }
+
+ poll( TABLE_RELOAD_INTERVAL_MS );
+ } catch ( err ) {
+ debugLog(
+ !! this.options.debug,
+ 'TableRenderer reload error:',
+ err
+ );
+ poll( Math.min( delay * 2, MAX_RELOAD_BACKOFF_MS ) );
+ }
+ }, delay );
+ };
+
+ if ( this.onVisibilityChange ) {
+ document.removeEventListener(
+ 'visibilitychange',
+ this.onVisibilityChange
+ );
+ }
+ this.onVisibilityChange = () => {
+ if ( ! document.hidden ) {
+ if ( this.timeoutId !== null ) {
+ clearTimeout( this.timeoutId );
+ this.timeoutId = null;
+ }
+ poll( 0 );
+ }
+ };
+ document.addEventListener(
+ 'visibilitychange',
+ this.onVisibilityChange
+ );
+
+ poll( TABLE_RELOAD_INTERVAL_MS );
+ }
+
+ private buildRequestBody(): AjaxRequestBody {
+ return {
+ action: 'spotmap_get_positions',
+ feeds: this.options.feeds ?? '',
+ 'date-range': this.options.dateRange,
+ date: this.options.date,
+ orderBy: this.options.orderBy ?? 'time DESC',
+ groupBy: this.options.groupBy ?? '',
+ type: this.options.type,
+ limit: this.options.limit,
+ };
+ }
+
+ private static pointsHaveLocaltime( points: SpotPoint[] ): boolean {
+ return points.some( ( p ) => !! p.local_timezone );
+ }
+
+ /**
+ * Stop auto-reload polling.
+ */
+ destroy(): void {
+ if ( this.timeoutId !== null ) {
+ clearTimeout( this.timeoutId );
+ this.timeoutId = null;
+ }
+
+ if ( this.onVisibilityChange ) {
+ document.removeEventListener(
+ 'visibilitychange',
+ this.onVisibilityChange
+ );
+ this.onVisibilityChange = null;
+ }
+ }
+}
diff --git a/src/map-engine/__tests__/DataFetcher.test.ts b/src/map-engine/__tests__/DataFetcher.test.ts
new file mode 100644
index 0000000..c0f7ca4
--- /dev/null
+++ b/src/map-engine/__tests__/DataFetcher.test.ts
@@ -0,0 +1,290 @@
+import { DataFetcher } from '../DataFetcher';
+import type { AjaxRequestBody, SpotPoint } from '../types';
+
+// ---------------------------------------------------------------------------
+// fetch mock helpers
+// ---------------------------------------------------------------------------
+
+function mockFetch( data: unknown ): jest.Mock {
+ const mock = jest.fn().mockResolvedValue( {
+ json: jest.fn().mockResolvedValue( data ),
+ } );
+ global.fetch = mock;
+ return mock;
+}
+
+const minimalBody: AjaxRequestBody = { action: 'spotmap' };
+
+function makePoint(
+ lat: number,
+ lng: number,
+ type: SpotPoint[ 'type' ] = 'UNLIMITED-TRACK',
+ id = 1
+): SpotPoint {
+ return {
+ id,
+ feed_name: 'test',
+ latitude: lat,
+ longitude: lng,
+ altitude: 0,
+ type,
+ unixtime: 1700000000,
+ time: '12:00 pm',
+ date: 'Jan 1, 2024',
+ };
+}
+
+describe( 'DataFetcher.removeClosePoints', () => {
+ it( 'returns empty array unchanged', () => {
+ expect( DataFetcher.removeClosePoints( [], 50 ) ).toEqual( [] );
+ } );
+
+ it( 'returns a single point unchanged', () => {
+ const pts = [ makePoint( 47.0, 8.0 ) ];
+ expect( DataFetcher.removeClosePoints( pts, 50 ) ).toEqual( pts );
+ } );
+
+ it( 'keeps both points when they are far apart', () => {
+ const pts = [
+ makePoint( 47.0, 8.0, 'UNLIMITED-TRACK', 1 ),
+ makePoint( 48.0, 9.0, 'UNLIMITED-TRACK', 2 ),
+ ];
+ expect( DataFetcher.removeClosePoints( pts, 50 ) ).toHaveLength( 2 );
+ } );
+
+ it( 'collapses two points within the radius into one', () => {
+ // ~0.01 m apart — well within any practical radius
+ const pts = [
+ makePoint( 47.0, 8.0, 'UNLIMITED-TRACK', 1 ),
+ makePoint( 47.0, 8.0000001, 'UNLIMITED-TRACK', 2 ),
+ ];
+ const result = DataFetcher.removeClosePoints( pts, 50 );
+ expect( result ).toHaveLength( 1 );
+ expect( result[ 0 ].hiddenPoints ).toEqual( { count: 1, radius: 50 } );
+ } );
+
+ it( 'does not collapse points of different types even when close', () => {
+ const pts = [
+ makePoint( 47.0, 8.0, 'UNLIMITED-TRACK', 1 ),
+ makePoint( 47.0, 8.0000001, 'OK', 2 ),
+ ];
+ expect( DataFetcher.removeClosePoints( pts, 50 ) ).toHaveLength( 2 );
+ } );
+
+ it( 'annotates anchor with total hidden count for a run of close points', () => {
+ const pts = [
+ makePoint( 47.0, 8.0, 'UNLIMITED-TRACK', 1 ),
+ makePoint( 47.0, 8.0000001, 'UNLIMITED-TRACK', 2 ),
+ makePoint( 47.0, 8.0000002, 'UNLIMITED-TRACK', 3 ),
+ ];
+ const result = DataFetcher.removeClosePoints( pts, 50 );
+ expect( result ).toHaveLength( 1 );
+ expect( result[ 0 ].hiddenPoints ).toEqual( { count: 2, radius: 50 } );
+ } );
+
+ it( 'resets hidden count after a sufficiently distant point', () => {
+ const pts = [
+ makePoint( 47.0, 8.0, 'UNLIMITED-TRACK', 1 ),
+ makePoint( 47.0, 8.0000001, 'UNLIMITED-TRACK', 2 ), // close
+ makePoint( 48.0, 9.0, 'UNLIMITED-TRACK', 3 ), // far — new anchor
+ makePoint( 48.0, 9.0000001, 'UNLIMITED-TRACK', 4 ), // close to new anchor
+ ];
+ const result = DataFetcher.removeClosePoints( pts, 50 );
+ expect( result ).toHaveLength( 2 );
+ expect( result[ 0 ].hiddenPoints ).toEqual( { count: 1, radius: 50 } );
+ expect( result[ 1 ].hiddenPoints ).toEqual( { count: 1, radius: 50 } );
+ } );
+
+ it( 'with radius 0 keeps all points', () => {
+ const pts = [
+ makePoint( 47.0, 8.0, 'UNLIMITED-TRACK', 1 ),
+ makePoint( 47.0, 8.0, 'UNLIMITED-TRACK', 2 ), // identical coords
+ ];
+ // haversine of identical coords = 0; radius 0 means distance <= 0 collapses it
+ // (edge case: exactly on boundary — both points same location, distance = 0 <= 0)
+ const result = DataFetcher.removeClosePoints( pts, 0 );
+ expect( result ).toHaveLength( 1 );
+ } );
+} );
+
+// ---------------------------------------------------------------------------
+// Load test with sentiero_italia.json (6 468 real GPS tracking points)
+// ---------------------------------------------------------------------------
+
+describe( 'DataFetcher.removeClosePoints — sentiero_italia load test', () => {
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
+ const raw: Record<
+ string,
+ string
+ >[] = require( '../../../examples/sentiero_italia.json' );
+ const points: SpotPoint[] = raw.map( ( p, i ) => ( {
+ id: Number( p.id ) || i,
+ feed_name: p.feed_name ?? 'spot',
+ latitude: Number( p.latitude ),
+ longitude: Number( p.longitude ),
+ altitude: Number( p.altitude ) || 0,
+ type: ( p.type as SpotPoint[ 'type' ] ) ?? 'UNLIMITED-TRACK',
+ unixtime: Number( p.unixtime ) || 0,
+ time: p.time ?? '',
+ date: p.date ?? '',
+ } ) );
+
+ it( `dataset contains ${ raw.length } points`, () => {
+ expect( points ).toHaveLength( raw.length );
+ } );
+
+ it( 'completes within 500 ms for the full dataset (radius 50 m)', () => {
+ const start = performance.now();
+ DataFetcher.removeClosePoints( [ ...points ], 50 );
+ expect( performance.now() - start ).toBeLessThan( 500 );
+ } );
+
+ it( 'reduces point count with radius 50 m (dense tracking data)', () => {
+ const result = DataFetcher.removeClosePoints( [ ...points ], 50 );
+ expect( result.length ).toBeLessThan( points.length );
+ } );
+
+ it( 'always keeps the first point', () => {
+ const result = DataFetcher.removeClosePoints( [ ...points ], 50 );
+ expect( result[ 0 ].id ).toBe( points[ 0 ].id );
+ } );
+
+ it( 'hidden counts and surviving points add up to total', () => {
+ const result = DataFetcher.removeClosePoints( [ ...points ], 50 );
+ const hiddenTotal = result.reduce(
+ ( sum, p ) => sum + ( p.hiddenPoints?.count ?? 0 ),
+ 0
+ );
+ expect( result.length + hiddenTotal ).toBe( points.length );
+ } );
+
+ it( 'larger radius removes more points', () => {
+ const r50 = DataFetcher.removeClosePoints( [ ...points ], 50 ).length;
+ const r200 = DataFetcher.removeClosePoints( [ ...points ], 200 ).length;
+ expect( r200 ).toBeLessThan( r50 );
+ } );
+} );
+
+// ---------------------------------------------------------------------------
+// fetchPoints
+// ---------------------------------------------------------------------------
+
+describe( 'DataFetcher.fetchPoints', () => {
+ afterEach( () => {
+ jest.restoreAllMocks();
+ } );
+
+ it( 'returns the point array from the server', async () => {
+ const serverPoints = [ makePoint( 47.0, 8.0 ) ];
+ mockFetch( serverPoints );
+
+ const fetcher = new DataFetcher(
+ 'https://example.com/wp-admin/admin-ajax.php'
+ );
+ const result = await fetcher.fetchPoints( minimalBody );
+
+ expect( result ).toEqual( serverPoints );
+ } );
+
+ it( 'returns the response as-is when empty flag is set', async () => {
+ mockFetch( { empty: true } );
+
+ const fetcher = new DataFetcher(
+ 'https://example.com/wp-admin/admin-ajax.php'
+ );
+ const result = await fetcher.fetchPoints( minimalBody );
+
+ expect( ( result as { empty: boolean } ).empty ).toBe( true );
+ } );
+
+ it( 'returns the response as-is when error flag is set', async () => {
+ mockFetch( { error: true } );
+
+ const fetcher = new DataFetcher(
+ 'https://example.com/wp-admin/admin-ajax.php'
+ );
+ const result = await fetcher.fetchPoints( minimalBody );
+
+ expect( ( result as { error: boolean } ).error ).toBe( true );
+ } );
+
+ it( 'applies removeClosePoints when filter is given', async () => {
+ // Two points ~0.01 m apart — both within radius 50
+ const serverPoints = [
+ makePoint( 47.0, 8.0, 'UNLIMITED-TRACK', 1 ),
+ makePoint( 47.0, 8.0000001, 'UNLIMITED-TRACK', 2 ),
+ ];
+ mockFetch( serverPoints );
+
+ const fetcher = new DataFetcher(
+ 'https://example.com/wp-admin/admin-ajax.php'
+ );
+ const result = await fetcher.fetchPoints( minimalBody, 50 );
+
+ expect( ( result as SpotPoint[] ).length ).toBe( 1 );
+ } );
+
+ it( 'sends a POST request to the given URL', async () => {
+ const mock = mockFetch( [] );
+
+ const fetcher = new DataFetcher(
+ 'https://example.com/wp-admin/admin-ajax.php'
+ );
+ await fetcher.fetchPoints( minimalBody );
+
+ expect( mock ).toHaveBeenCalledWith(
+ 'https://example.com/wp-admin/admin-ajax.php',
+ expect.objectContaining( { method: 'POST' } )
+ );
+ } );
+
+ it( 'encodes array body values as key[] params', async () => {
+ const mock = mockFetch( [] );
+
+ const fetcher = new DataFetcher(
+ 'https://example.com/wp-admin/admin-ajax.php'
+ );
+ await fetcher.fetchPoints( {
+ action: 'spotmap',
+ feeds: [ 'f1', 'f2' ],
+ } as AjaxRequestBody & { feeds: string[] } );
+
+ const body: string = mock.mock.calls[ 0 ][ 1 ].body;
+ expect( body ).toContain( 'feeds%5B%5D=f1' );
+ expect( body ).toContain( 'feeds%5B%5D=f2' );
+ } );
+} );
+
+// ---------------------------------------------------------------------------
+// abort
+// ---------------------------------------------------------------------------
+
+describe( 'DataFetcher.abort', () => {
+ it( 'does not throw when called before any fetch', () => {
+ const fetcher = new DataFetcher(
+ 'https://example.com/wp-admin/admin-ajax.php'
+ );
+ expect( () => fetcher.abort() ).not.toThrow();
+ } );
+
+ it( 'cancels an in-flight request', async () => {
+ // fetch never resolves — simulates a slow network
+ global.fetch = jest.fn().mockReturnValue( new Promise( () => {} ) );
+
+ const fetcher = new DataFetcher(
+ 'https://example.com/wp-admin/admin-ajax.php'
+ );
+ const promise = fetcher.fetchPoints( minimalBody );
+
+ fetcher.abort();
+
+ // The AbortController abort signal is passed to fetch; the promise stays
+ // pending but we can verify fetch was called with a signal.
+ const signal = ( global.fetch as jest.Mock ).mock.calls[ 0 ][ 1 ]
+ .signal;
+ expect( signal.aborted ).toBe( true );
+
+ // Prevent unhandled promise rejection
+ promise.catch( () => {} );
+ } );
+} );
diff --git a/src/map-engine/__tests__/MarkerManager.test.ts b/src/map-engine/__tests__/MarkerManager.test.ts
new file mode 100644
index 0000000..5db53df
--- /dev/null
+++ b/src/map-engine/__tests__/MarkerManager.test.ts
@@ -0,0 +1,107 @@
+import { MarkerManager } from '../MarkerManager';
+import type { SpotPoint } from '../types';
+
+function makePoint( overrides: Partial< SpotPoint > = {} ): SpotPoint {
+ return {
+ id: 1,
+ feed_name: 'test',
+ latitude: 47.0,
+ longitude: 8.0,
+ altitude: 0,
+ type: 'OK',
+ unixtime: 1700000000,
+ time: '12:00 pm',
+ date: 'Jan 1, 2024',
+ ...overrides,
+ };
+}
+
+describe( 'MarkerManager.getPopupHtml', () => {
+ it( 'shows type and date/time', () => {
+ const html = MarkerManager.getPopupHtml(
+ makePoint( { type: 'OK', time: '3:00 pm', date: 'Jun 15, 2024' } )
+ );
+ expect( html ).toContain( 'OK ' );
+ expect( html ).toContain( '3:00 pm' );
+ expect( html ).toContain( 'Jun 15, 2024' );
+ } );
+
+ it( 'shows altitude when above zero', () => {
+ const html = MarkerManager.getPopupHtml(
+ makePoint( { altitude: 2500 } )
+ );
+ expect( html ).toContain( 'Altitude: 2500m' );
+ } );
+
+ it( 'omits altitude line when altitude is zero', () => {
+ const html = MarkerManager.getPopupHtml( makePoint( { altitude: 0 } ) );
+ expect( html ).not.toContain( 'Altitude:' );
+ } );
+
+ it( 'shows text message for non-MEDIA type', () => {
+ const html = MarkerManager.getPopupHtml(
+ makePoint( { type: 'OK', message: 'Hello from the trail!' } )
+ );
+ expect( html ).toContain( 'Hello from the trail!' );
+ expect( html ).not.toContain( ' {
+ const html = MarkerManager.getPopupHtml(
+ makePoint( {
+ type: 'MEDIA',
+ message: 'https://example.com/photo.jpg',
+ } )
+ );
+ expect( html ).toContain( ' {
+ const html = MarkerManager.getPopupHtml(
+ makePoint( { battery_status: 'LOW' } )
+ );
+ expect( html ).toContain( 'Battery status is low!' );
+ } );
+
+ it( 'omits battery warning when status is GOOD', () => {
+ const html = MarkerManager.getPopupHtml(
+ makePoint( { battery_status: 'GOOD' } )
+ );
+ expect( html ).not.toContain( 'Battery' );
+ } );
+
+ it( 'shows hidden points annotation', () => {
+ const html = MarkerManager.getPopupHtml(
+ makePoint( { hiddenPoints: { count: 12, radius: 50 } } )
+ );
+ expect( html ).toContain( '12 hidden Points' );
+ expect( html ).toContain( '50 meters' );
+ } );
+
+ it( 'shows local time when it differs from UTC time', () => {
+ const html = MarkerManager.getPopupHtml(
+ makePoint( {
+ local_timezone: 'Europe/Rome',
+ time: '10:00 am',
+ date: 'Jan 1, 2024',
+ localtime: '11:00 am',
+ localdate: 'Jan 1, 2024',
+ } )
+ );
+ expect( html ).toContain( 'Local Time: 11:00 am' );
+ } );
+
+ it( 'omits local time when it matches UTC time', () => {
+ const html = MarkerManager.getPopupHtml(
+ makePoint( {
+ local_timezone: 'UTC',
+ time: '10:00 am',
+ date: 'Jan 1, 2024',
+ localtime: '10:00 am',
+ localdate: 'Jan 1, 2024',
+ } )
+ );
+ expect( html ).not.toContain( 'Local Time' );
+ } );
+} );
diff --git a/src/map-engine/__tests__/tsconfig.json b/src/map-engine/__tests__/tsconfig.json
new file mode 100644
index 0000000..19e3207
--- /dev/null
+++ b/src/map-engine/__tests__/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "extends": "../../../tsconfig.json",
+ "compilerOptions": {
+ "moduleResolution": "node",
+ "types": [ "@jest/globals" ]
+ },
+ "include": [ "./**/*", "../*.ts" ],
+ "exclude": []
+}
diff --git a/src/map-engine/__tests__/utils.test.ts b/src/map-engine/__tests__/utils.test.ts
new file mode 100644
index 0000000..12f2d50
--- /dev/null
+++ b/src/map-engine/__tests__/utils.test.ts
@@ -0,0 +1,32 @@
+import { debug, getColorDot } from '../utils';
+
+describe( 'debug', () => {
+ let spy: jest.SpyInstance;
+
+ beforeEach( () => {
+ spy = jest.spyOn( console, 'log' ).mockImplementation( () => {} );
+ } );
+
+ afterEach( () => {
+ spy.mockRestore();
+ } );
+
+ it( 'logs to console when enabled', () => {
+ debug( true, 'hello', 42 );
+ expect( spy ).toHaveBeenCalledWith( 'hello', 42 );
+ } );
+
+ it( 'does not log when disabled', () => {
+ debug( false, 'hello' );
+ expect( spy ).not.toHaveBeenCalled();
+ } );
+} );
+
+describe( 'getColorDot', () => {
+ it( 'returns a span with the given color', () => {
+ const html = getColorDot( 'red' );
+ expect( html ).toContain( 'background-color:red' );
+ expect( html ).toContain( ';
+ wptIconsType?: Record< string, L.Icon >;
+ startIconUrl?: string;
+ endIconUrl?: string;
+ shadowUrl?: string;
+ };
+ polyline_options?: L.PolylineOptions;
+ }
+
+ function easyButton( options: EasyButtonOptions ): L.Control;
+
+ interface EasyButtonState {
+ stateName: string;
+ icon: string;
+ title: string;
+ onClick: ( control: EasyButtonControl ) => void;
+ }
+
+ interface EasyButtonOptions {
+ states: EasyButtonState[];
+ }
+
+ interface EasyButtonControl {
+ state: ( name: string ) => void;
+ }
+
+ function easyBar( buttons: L.Control[] ): L.Control;
+
+ namespace Control {
+ class FullScreen extends L.Control {
+ constructor( options?: Record< string, unknown > );
+ }
+ }
+ }
+}
+
+export {};
diff --git a/src/map-engine/index.ts b/src/map-engine/index.ts
new file mode 100644
index 0000000..a8955ee
--- /dev/null
+++ b/src/map-engine/index.ts
@@ -0,0 +1,18 @@
+import { Spotmap } from './Spotmap';
+
+// Export for module consumers
+export { Spotmap };
+export type {
+ SpotmapOptions,
+ SpotPoint,
+ SpotmapGlobal,
+ FeedStyle,
+ GpxTrackConfig,
+ MapCenter,
+ DateRange,
+ TableOptions,
+ PointType,
+} from './types';
+
+// Expose as a global for PHP inline scripts and the block editor
+window.Spotmap = Spotmap;
diff --git a/src/map-engine/types.ts b/src/map-engine/types.ts
new file mode 100644
index 0000000..4ddca0d
--- /dev/null
+++ b/src/map-engine/types.ts
@@ -0,0 +1,185 @@
+/** Options passed from PHP via wp_localize_script (spotmapjsobj global) */
+export interface SpotmapGlobal {
+ ajaxUrl: string;
+ maps: Record< string, TileLayerConfig >;
+ overlays: Record< string, TileLayerConfig >;
+ url: string;
+ feeds: string[];
+ defaultValues: Record< string, unknown >;
+ marker: Record< string, MarkerTypeConfig >;
+}
+
+export interface TileLayerConfig {
+ url: string;
+ label: string;
+ wms?: boolean;
+ options: Record< string, unknown >;
+}
+
+export interface MarkerTypeConfig {
+ iconShape: string;
+ icon: string;
+}
+
+/**
+ * Main options object — passed to `new Spotmap(options)`.
+ *
+ * Constructed from block attributes (render-block.php / edit.jsx)
+ * or from shortcode parameters (class-spotmap-public.php).
+ */
+export interface SpotmapOptions {
+ // Data sources
+ feeds: string[];
+ gpx: GpxTrackConfig[];
+
+ // Map tile layers
+ maps: string[];
+ mapOverlays?: string[] | null;
+
+ // Per-feed styling
+ styles: Record< string, FeedStyle >;
+
+ // Layout
+ height: number;
+ mapId?: string;
+ mapElement?: HTMLElement;
+
+ // Navigation & view
+ mapcenter: MapCenter;
+ filterPoints: number;
+
+ // Behavior
+ autoReload: boolean;
+ debug: boolean;
+ dateRange: DateRange;
+ date?: string | null;
+
+ // Map controls — all optional with sensible defaults
+ scrollWheelZoom?: boolean;
+ enablePanning?: boolean;
+ zoomControl?: boolean;
+ fullscreenButton?: boolean;
+ scaleControl?: boolean;
+ locateButton?: boolean;
+ navigationButtons?: NavigationButtonsConfig;
+}
+
+export interface NavigationButtonsConfig {
+ enabled: boolean;
+ allPoints: boolean;
+ latestPoint: boolean;
+ gpxTracks: boolean;
+}
+
+export type MapCenter = 'all' | 'last' | 'last-trip' | 'gpx' | 'feeds';
+
+export interface DateRange {
+ from: string;
+ to: string;
+}
+
+export interface FeedStyle {
+ color: string;
+ splitLines?: number | false;
+ splitLinesEnabled?: boolean;
+ lineWidth?: number; // 1–6px, default 2
+ lineOpacity?: number; // 0.2–1.0, default 1.0
+ visible?: boolean; // default true (unset = visible)
+ lastPoint?: boolean; // highlight the latest point with a large circle marker
+}
+
+export interface GpxTrackConfig {
+ url: string;
+ title: string;
+ color?: string;
+ visible?: boolean; // default true (unset = visible)
+ download?: boolean; // default false — show download icon in layer control & popup
+ id?: number;
+}
+
+/** A single GPS point returned from the server AJAX response */
+export interface SpotPoint {
+ id: number;
+ feed_name: string;
+ latitude: number;
+ longitude: number;
+ altitude: number;
+ type: PointType;
+ unixtime: number;
+ time: string;
+ date: string;
+ localtime?: string;
+ localdate?: string;
+ local_timezone?: string;
+ message?: string;
+ battery_status?: string;
+ messengerName?: string;
+ hiddenPoints?: { count: number; radius: number };
+}
+
+export type PointType =
+ | 'OK'
+ | 'CUSTOM'
+ | 'TRACK'
+ | 'EXTREME-TRACK'
+ | 'UNLIMITED-TRACK'
+ | 'HELP'
+ | 'HELP-CANCEL'
+ | 'MEDIA'
+ | 'NEWMOVEMENT'
+ | 'SOS';
+
+/** Internal structure for a feed's map layers */
+export interface FeedLayer {
+ lines: L.Polyline[];
+ markers: L.Marker[];
+ points: SpotPoint[];
+ featureGroup: L.FeatureGroup;
+}
+
+/** Internal structure for a GPX track's map layers */
+export interface GpxLayer {
+ featureGroup: L.FeatureGroup;
+}
+
+/** All map layers managed by Spotmap */
+export interface SpotmapLayers {
+ feeds: Record< string, FeedLayer >;
+ gpx: Record< string, GpxLayer >;
+}
+
+/** Options for the [spotmessages] shortcode table view */
+export interface TableOptions
+ extends Pick<
+ SpotmapOptions,
+ 'feeds' | 'dateRange' | 'date' | 'autoReload' | 'filterPoints' | 'debug'
+ > {
+ type?: string[];
+ orderBy?: string;
+ limit?: number;
+ groupBy?: string;
+}
+
+/** Body sent to the WordPress AJAX endpoint */
+export interface AjaxRequestBody {
+ action: string;
+ select?: string;
+ feeds: string[] | string;
+ 'date-range'?: DateRange;
+ date?: string | null;
+ orderBy: string;
+ groupBy: string;
+ type?: string[];
+ limit?: number;
+}
+
+/** Error response shape from the server */
+export interface AjaxErrorResponse {
+ error: boolean;
+ empty: boolean;
+ title?: string;
+ message?: string;
+}
+
+/** The AJAX response can be an array of points or an error/empty marker */
+export type AjaxResponse = SpotPoint[] & { empty?: boolean; error?: boolean };
diff --git a/src/map-engine/utils.ts b/src/map-engine/utils.ts
new file mode 100644
index 0000000..c666d4d
--- /dev/null
+++ b/src/map-engine/utils.ts
@@ -0,0 +1,18 @@
+/**
+ * Conditional debug logger.
+ *
+ * @param enabled - Whether debug mode is active.
+ * @param args - Values to log.
+ */
+export function debug( enabled: boolean, ...args: unknown[] ): void {
+ if ( enabled ) {
+ console.log( ...args ); // eslint-disable-line no-console
+ }
+}
+
+/**
+ * Returns an inline HTML color dot for use in the layer control legend.
+ */
+export function getColorDot( color: string ): string {
+ return ` `;
+}
diff --git a/src/spotmap-admin/App.jsx b/src/spotmap-admin/App.jsx
new file mode 100644
index 0000000..c5817d4
--- /dev/null
+++ b/src/spotmap-admin/App.jsx
@@ -0,0 +1,133 @@
+import { useState, useEffect, useCallback } from '@wordpress/element';
+import { Notice, TabPanel, Spinner } from '@wordpress/components';
+import * as api from './api';
+import FeedsTab from './tabs/FeedsTab';
+import MarkersTab from './tabs/MarkersTab';
+import TokensTab from './tabs/TokensTab';
+import DefaultsTab from './tabs/DefaultsTab';
+import EditPointsTab from './tabs/EditPointsTab';
+
+const TABS = [
+ { name: 'feeds', title: 'Feeds' },
+ { name: 'markers', title: 'Markers' },
+ { name: 'tokens', title: 'API Tokens' },
+ { name: 'defaults', title: 'Defaults' },
+ { name: 'edit-points', title: 'Edit Points' },
+];
+
+const TAB_NAMES = TABS.map( ( t ) => t.name );
+
+function getTabFromHash() {
+ const hash = window.location.hash.slice( 1 );
+ if ( hash === 'add-feed' ) {
+ return 'feeds';
+ }
+ return TAB_NAMES.includes( hash ) ? hash : TAB_NAMES[ 0 ];
+}
+
+export default function App() {
+ const [ providers, setProviders ] = useState( null );
+ const [ error, setError ] = useState( null );
+ const [ globalNotice, setGlobalNotice ] = useState( null );
+ const [ initialTab ] = useState( getTabFromHash );
+ const openAddFeed = window.location.hash === '#add-feed';
+ const handleNoticeChange = useCallback( setGlobalNotice, [
+ setGlobalNotice,
+ ] );
+
+ useEffect( () => {
+ api.getProviders()
+ .then( setProviders )
+ .catch( ( err ) => setError( err.message ) );
+ }, [] );
+
+ const handleTabSelect = ( tabName ) => {
+ window.location.hash = tabName;
+ };
+
+ if ( error ) {
+ return (
+
+ { globalNotice && (
+
setGlobalNotice( null ) }
+ isDismissible
+ >
+ { globalNotice.text }
+
+ ) }
+
Spotmap Settings
+
+
Failed to load settings: { error }
+
+
+ );
+ }
+
+ if ( ! providers ) {
+ return (
+
+ { globalNotice && (
+ setGlobalNotice( null ) }
+ isDismissible
+ >
+ { globalNotice.text }
+
+ ) }
+
Spotmap Settings
+
+
+ );
+ }
+
+ return (
+
+ { globalNotice && (
+ setGlobalNotice( null ) }
+ isDismissible
+ >
+ { globalNotice.text }
+
+ ) }
+
Spotmap Settings
+
+ { ( tab ) =>
+ ( {
+ feeds: (
+
+ ),
+ markers: (
+
+ ),
+ tokens: (
+
+ ),
+ defaults: (
+
+ ),
+ 'edit-points': (
+
+ ),
+ } )[ tab.name ]
+ }
+
+
+ );
+}
diff --git a/src/spotmap-admin/__tests__/FeedModal.test.jsx b/src/spotmap-admin/__tests__/FeedModal.test.jsx
new file mode 100644
index 0000000..d4ab32c
--- /dev/null
+++ b/src/spotmap-admin/__tests__/FeedModal.test.jsx
@@ -0,0 +1,178 @@
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import FeedModal from '../components/FeedModal';
+import { REDACTED, providers } from './fixtures';
+
+jest.mock( '../api', () => ( { REDACTED: '__REDACTED__' } ) );
+
+const noop = () => {};
+
+describe( 'FeedModal — add mode', () => {
+ it( 'shows "Add Feed" title', () => {
+ render(
+
+ );
+ expect( screen.getByText( 'Add Feed' ) ).toBeInTheDocument();
+ } );
+
+ it( 'shows provider type selector', () => {
+ render(
+
+ );
+ expect( screen.getByText( 'Provider Type' ) ).toBeInTheDocument();
+ } );
+
+ it( 'renders all provider fields', () => {
+ render(
+
+ );
+ expect( screen.getByText( 'Feed Name' ) ).toBeInTheDocument();
+ expect( screen.getByText( 'Feed ID' ) ).toBeInTheDocument();
+ expect( screen.getByText( 'Feed Password' ) ).toBeInTheDocument();
+ } );
+
+ it( 'calls onSave with typed values', async () => {
+ const user = userEvent.setup();
+ const onSave = jest.fn().mockResolvedValue( undefined );
+ render(
+
+ );
+
+ await user.type( screen.getByLabelText( /Feed Name/i ), 'MyFeed' );
+ await user.click( screen.getByRole( 'button', { name: 'Save' } ) );
+
+ await waitFor( () => {
+ expect( onSave ).toHaveBeenCalledWith(
+ expect.objectContaining( {
+ name: 'MyFeed',
+ type: 'findmespot',
+ } ),
+ undefined
+ );
+ } );
+ } );
+} );
+
+describe( 'FeedModal — edit mode', () => {
+ const feed = {
+ id: 'feed-1',
+ type: 'findmespot',
+ name: 'Timo',
+ feed_id: '0XXu6',
+ password: REDACTED,
+ };
+
+ it( 'shows "Edit Feed" title', () => {
+ render(
+
+ );
+ expect( screen.getByText( 'Edit Feed' ) ).toBeInTheDocument();
+ } );
+
+ it( 'hides provider type selector', () => {
+ render(
+
+ );
+ expect( screen.queryByText( 'Provider Type' ) ).not.toBeInTheDocument();
+ } );
+
+ it( 'shows empty password field with placeholder when value is REDACTED', () => {
+ render(
+
+ );
+ const passwordInput = screen.getByLabelText( /Feed Password/i );
+ expect( passwordInput.value ).toBe( '' );
+ expect( passwordInput.placeholder ).toMatch( /keep existing/i );
+ } );
+
+ it( 'passes feed id to onSave', async () => {
+ const user = userEvent.setup();
+ const onSave = jest.fn().mockResolvedValue( undefined );
+ render(
+
+ );
+
+ await user.click( screen.getByRole( 'button', { name: 'Save' } ) );
+
+ await waitFor( () => {
+ expect( onSave ).toHaveBeenCalledWith(
+ expect.objectContaining( { name: 'Timo' } ),
+ 'feed-1'
+ );
+ } );
+ } );
+
+ it( 'calls onClose when Cancel is clicked', async () => {
+ const user = userEvent.setup();
+ const onClose = jest.fn();
+ render(
+
+ );
+ await user.click( screen.getByRole( 'button', { name: 'Cancel' } ) );
+ expect( onClose ).toHaveBeenCalled();
+ } );
+
+ it( 'shows error notice when onSave rejects', async () => {
+ const user = userEvent.setup();
+ const onSave = jest
+ .fn()
+ .mockRejectedValue( new Error( 'Server error' ) );
+ render(
+
+ );
+ await user.click( screen.getByRole( 'button', { name: 'Save' } ) );
+ // WP components also announce to a11y-speak, so multiple matches are expected.
+ const notices = await screen.findAllByText( 'Server error' );
+ expect( notices.length ).toBeGreaterThan( 0 );
+ } );
+} );
diff --git a/src/spotmap-admin/__tests__/FeedsTab.test.jsx b/src/spotmap-admin/__tests__/FeedsTab.test.jsx
new file mode 100644
index 0000000..fc969f9
--- /dev/null
+++ b/src/spotmap-admin/__tests__/FeedsTab.test.jsx
@@ -0,0 +1,184 @@
+import { render, screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import FeedsTab from '../tabs/FeedsTab';
+import { REDACTED, providers } from './fixtures';
+
+jest.mock( '../api', () => ( {
+ REDACTED: '__REDACTED__',
+ getFeeds: jest.fn(),
+ createFeed: jest.fn(),
+ updateFeed: jest.fn(),
+ deleteFeed: jest.fn(),
+} ) );
+
+// FeedModal imports icons — stub the module.
+jest.mock( '../icons', () => ( { ICONS: [ 'star' ] } ) );
+
+import * as api from '../api';
+
+const sampleFeeds = [
+ {
+ id: 'f1',
+ type: 'findmespot',
+ name: 'Timo',
+ feed_id: '0XXu6',
+ password: REDACTED,
+ point_count: 42,
+ },
+ {
+ id: 'f2',
+ type: 'findmespot',
+ name: 'Elia',
+ feed_id: '07bNOnYeUGdYIqFy0b8Bd3uiFVjqgnzTk',
+ password: '',
+ point_count: 0,
+ },
+];
+
+beforeEach( () => {
+ api.getFeeds.mockResolvedValue( [ ...sampleFeeds ] );
+ api.createFeed.mockResolvedValue( {
+ id: 'f3',
+ type: 'findmespot',
+ name: 'New',
+ feed_id: 'xxx',
+ password: '',
+ } );
+ api.updateFeed.mockResolvedValue( {
+ ...sampleFeeds[ 0 ],
+ name: 'Updated',
+ } );
+ api.deleteFeed.mockResolvedValue( {} );
+} );
+
+afterEach( () => {
+ jest.clearAllMocks();
+ jest.restoreAllMocks();
+} );
+
+describe( 'FeedsTab — loading', () => {
+ it( 'shows spinner while feeds are loading', () => {
+ // Never resolve so we stay in loading state.
+ api.getFeeds.mockReturnValue( new Promise( () => {} ) );
+ render( );
+ // Spinner renders as an SVG with class .components-spinner
+ expect(
+ document.querySelector( '.components-spinner' )
+ ).toBeInTheDocument();
+ } );
+} );
+
+describe( 'FeedsTab — loaded', () => {
+ it( 'renders feed names in the table', async () => {
+ render( );
+ await screen.findByText( 'Timo' );
+ expect( screen.getByText( 'Elia' ) ).toBeInTheDocument();
+ } );
+
+ it( 'renders provider label instead of type key', async () => {
+ render( );
+ await screen.findByText( 'Timo' );
+ const cells = screen.getAllByText( 'SPOT Feed' );
+ expect( cells.length ).toBe( 2 );
+ } );
+
+ it( 'renders feed IDs as code elements', async () => {
+ render( );
+ await screen.findByText( '0XXu6' );
+ expect( screen.getByText( '0XXu6' ).tagName ).toBe( 'CODE' );
+ } );
+
+ it( 'renders point count per feed', async () => {
+ render( );
+ await screen.findByText( 'Timo' );
+ expect( screen.getByText( '42' ) ).toBeInTheDocument();
+ } );
+} );
+
+describe( 'FeedsTab — empty state', () => {
+ it( 'shows "No feeds" message when list is empty', async () => {
+ api.getFeeds.mockResolvedValue( [] );
+ render( );
+ expect(
+ await screen.findByText( /No feeds configured/i )
+ ).toBeInTheDocument();
+ } );
+} );
+
+describe( 'FeedsTab — error state', () => {
+ it( 'shows error notice when getFeeds rejects', async () => {
+ api.getFeeds.mockRejectedValue( new Error( 'Failed to fetch' ) );
+ render( );
+ const notices = await screen.findAllByText( 'Failed to fetch' );
+ expect( notices.length ).toBeGreaterThan( 0 );
+ } );
+} );
+
+describe( 'FeedsTab — add feed', () => {
+ it( 'opens FeedModal when Add Feed is clicked', async () => {
+ const user = userEvent.setup();
+ render( );
+ await screen.findByText( 'Timo' );
+
+ await user.click( screen.getByRole( 'button', { name: /Add Feed/i } ) );
+
+ // Modal opened — Cancel button only exists inside the modal.
+ expect(
+ screen.getByRole( 'button', { name: 'Cancel' } )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'adds new feed to the list after save', async () => {
+ const user = userEvent.setup();
+ render( );
+ await screen.findByText( 'Timo' );
+
+ await user.click( screen.getByRole( 'button', { name: /Add Feed/i } ) );
+ await user.type( screen.getByLabelText( /Feed Name/i ), 'New' );
+ await user.click( screen.getByRole( 'button', { name: 'Save' } ) );
+
+ const saved = await screen.findAllByText( /Feed saved/i );
+ expect( saved.length ).toBeGreaterThan( 0 );
+ expect( screen.getByText( 'New' ) ).toBeInTheDocument();
+ } );
+} );
+
+describe( 'FeedsTab — delete feed', () => {
+ it( 'removes feed from list after confirmed delete', async () => {
+ const user = userEvent.setup();
+ jest.spyOn( window, 'confirm' ).mockReturnValue( true );
+
+ render( );
+ await screen.findByText( 'Timo' );
+
+ const rows = screen.getAllByRole( 'row' );
+ const timoRow = rows.find( ( r ) => within( r ).queryByText( 'Timo' ) );
+ await user.click(
+ within( timoRow ).getByRole( 'button', { name: /Delete/i } )
+ );
+
+ await waitFor( () => {
+ expect( api.deleteFeed ).toHaveBeenCalledWith( 'f1' );
+ } );
+ const deleted = await screen.findAllByText( /Feed deleted/i );
+ expect( deleted.length ).toBeGreaterThan( 0 );
+ expect( screen.queryByText( 'Timo' ) ).not.toBeInTheDocument();
+ } );
+
+ it( 'does not delete when confirm is cancelled', async () => {
+ const user = userEvent.setup();
+ jest.spyOn( window, 'confirm' ).mockReturnValue( false );
+
+ render( );
+ await screen.findByText( 'Timo' );
+
+ const rows = screen.getAllByRole( 'row' );
+ const timoRow = rows.find( ( r ) => within( r ).queryByText( 'Timo' ) );
+ await user.click(
+ within( timoRow ).getByRole( 'button', { name: /Delete/i } )
+ );
+
+ expect( api.deleteFeed ).not.toHaveBeenCalled();
+ expect( screen.getByText( 'Timo' ) ).toBeInTheDocument();
+ } );
+} );
diff --git a/src/spotmap-admin/__tests__/IconPicker.test.jsx b/src/spotmap-admin/__tests__/IconPicker.test.jsx
new file mode 100644
index 0000000..7fb85b1
--- /dev/null
+++ b/src/spotmap-admin/__tests__/IconPicker.test.jsx
@@ -0,0 +1,63 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import IconPicker from '../components/IconPicker';
+
+// icons.js requires @fortawesome files at module level — mock it.
+jest.mock( '../icons', () => ( {
+ ICONS: [ 'star', 'map-marker-alt', 'heart', 'mountain', 'compass' ],
+} ) );
+
+const noop = () => {};
+
+describe( 'IconPicker', () => {
+ it( 'renders all icons when search is empty', () => {
+ render( );
+ expect( screen.getByText( 'star' ) ).toBeInTheDocument();
+ expect( screen.getByText( 'heart' ) ).toBeInTheDocument();
+ expect( screen.getByText( 'compass' ) ).toBeInTheDocument();
+ } );
+
+ it( 'filters icons by search term', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.type( screen.getByLabelText( /Search icons/i ), 'mar' );
+
+ expect( screen.getByText( 'map-marker-alt' ) ).toBeInTheDocument();
+ expect( screen.queryByText( 'star' ) ).not.toBeInTheDocument();
+ expect( screen.queryByText( 'heart' ) ).not.toBeInTheDocument();
+ } );
+
+ it( 'shows no-match message when search finds nothing', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.type(
+ screen.getByLabelText( /Search icons/i ),
+ 'xyznotfound'
+ );
+
+ expect( screen.getByText( /No icons match/i ) ).toBeInTheDocument();
+ } );
+
+ it( 'calls onSelect with icon name when clicked', async () => {
+ const user = userEvent.setup();
+ const onSelect = jest.fn();
+ render(
+
+ );
+
+ await user.click( screen.getByText( 'star' ) );
+
+ expect( onSelect ).toHaveBeenCalledWith( 'star' );
+ } );
+
+ it( 'highlights the currently selected icon', () => {
+ render(
+
+ );
+ // The selected button has a blue border — check it exists via aria or style
+ const heartButton = screen.getByText( 'heart' ).closest( 'button' );
+ expect( heartButton ).toHaveStyle( 'border: 2px solid #0073aa' );
+ } );
+} );
diff --git a/src/spotmap-admin/__tests__/TokensTab.test.jsx b/src/spotmap-admin/__tests__/TokensTab.test.jsx
new file mode 100644
index 0000000..b347ca3
--- /dev/null
+++ b/src/spotmap-admin/__tests__/TokensTab.test.jsx
@@ -0,0 +1,171 @@
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import TokensTab from '../tabs/TokensTab';
+import { REDACTED } from './fixtures';
+
+jest.mock( '../api', () => ( {
+ REDACTED: '__REDACTED__',
+ getTokens: jest.fn(),
+ updateTokens: jest.fn(),
+} ) );
+
+import * as api from '../api';
+
+const tokens = {
+ timezonedb: REDACTED,
+ mapbox: '',
+};
+
+beforeEach( () => {
+ api.getTokens.mockResolvedValue( { ...tokens } );
+ api.updateTokens.mockResolvedValue( { ...tokens } );
+} );
+
+afterEach( () => {
+ jest.clearAllMocks();
+} );
+
+describe( 'TokensTab — REDACTED token (stored)', () => {
+ it( 'shows "Token stored" for a REDACTED value', async () => {
+ render( );
+ expect(
+ await screen.findByText( /Token stored/i )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'shows Change and Clear buttons for a stored token', async () => {
+ render( );
+ expect(
+ await screen.findByRole( 'button', { name: /Change/i } )
+ ).toBeInTheDocument();
+ expect(
+ await screen.findByRole( 'button', { name: /Clear/i } )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'switches to text input after clicking Change', async () => {
+ const user = userEvent.setup();
+ render( );
+ await screen.findByRole( 'button', { name: /Change/i } );
+
+ await user.click( screen.getByRole( 'button', { name: /Change/i } ) );
+
+ // After clicking Change, the TextControl for timezonedb should appear.
+ // TimezoneDB label is shown in TOKEN_META.
+ const input = await screen.findByLabelText( /TimezoneDB/i );
+ expect( input ).toBeInTheDocument();
+ expect( input.value ).toBe( '' );
+ } );
+
+ it( 'sends REDACTED when Change is clicked but input left empty', async () => {
+ const user = userEvent.setup();
+ api.updateTokens.mockResolvedValue( {
+ timezonedb: REDACTED,
+ mapbox: '',
+ } );
+ render( );
+ await user.click(
+ await screen.findByRole( 'button', { name: /Change/i } )
+ );
+
+ await user.click(
+ screen.getByRole( 'button', { name: /Save API Tokens/i } )
+ );
+
+ await waitFor( () => {
+ expect( api.updateTokens ).toHaveBeenCalledWith(
+ expect.objectContaining( { timezonedb: REDACTED } )
+ );
+ } );
+ } );
+
+ it( 'sends REDACTED when Change is clicked, value typed then deleted', async () => {
+ const user = userEvent.setup();
+ api.updateTokens.mockResolvedValue( {
+ timezonedb: REDACTED,
+ mapbox: '',
+ } );
+ render( );
+ await user.click(
+ await screen.findByRole( 'button', { name: /Change/i } )
+ );
+
+ const input = await screen.findByLabelText( /TimezoneDB/i );
+ await user.type( input, 'abc' );
+ await user.clear( input );
+
+ await user.click(
+ screen.getByRole( 'button', { name: /Save API Tokens/i } )
+ );
+
+ await waitFor( () => {
+ expect( api.updateTokens ).toHaveBeenCalledWith(
+ expect.objectContaining( { timezonedb: REDACTED } )
+ );
+ } );
+ } );
+
+ it( 'sends empty string when Clear is clicked and saved without typing', async () => {
+ const user = userEvent.setup();
+ api.updateTokens.mockResolvedValue( { timezonedb: '', mapbox: '' } );
+ render( );
+ await user.click(
+ await screen.findByRole( 'button', { name: /Clear/i } )
+ );
+
+ await user.click(
+ screen.getByRole( 'button', { name: /Save API Tokens/i } )
+ );
+
+ await waitFor( () => {
+ expect( api.updateTokens ).toHaveBeenCalledWith(
+ expect.objectContaining( { timezonedb: '' } )
+ );
+ } );
+ } );
+} );
+
+describe( 'TokensTab — empty token (not stored)', () => {
+ it( 'shows text input directly for an empty value', async () => {
+ render( );
+ // mapbox is empty — should show TextControl immediately
+ const input = await screen.findByLabelText( /Mapbox/i );
+ expect( input ).toBeInTheDocument();
+ } );
+} );
+
+describe( 'TokensTab — save', () => {
+ it( 'calls updateTokens and shows success notice', async () => {
+ const user = userEvent.setup();
+ api.updateTokens.mockResolvedValue( {
+ timezonedb: REDACTED,
+ mapbox: '',
+ } );
+ render( );
+ await screen.findByText( /Token stored/i );
+
+ await user.click(
+ screen.getByRole( 'button', { name: /Save API Tokens/i } )
+ );
+
+ await waitFor( () => {
+ expect( api.updateTokens ).toHaveBeenCalled();
+ } );
+ const notices = await screen.findAllByText( /API tokens saved/i );
+ expect( notices.length ).toBeGreaterThan( 0 );
+ } );
+
+ it( 'shows error notice when save fails', async () => {
+ const user = userEvent.setup();
+ api.updateTokens.mockRejectedValue( new Error( 'Network error' ) );
+ render( );
+ await screen.findByText( /Token stored/i );
+
+ await user.click(
+ screen.getByRole( 'button', { name: /Save API Tokens/i } )
+ );
+
+ const notices = await screen.findAllByText( 'Network error' );
+ expect( notices.length ).toBeGreaterThan( 0 );
+ } );
+} );
diff --git a/src/spotmap-admin/__tests__/api.test.js b/src/spotmap-admin/__tests__/api.test.js
new file mode 100644
index 0000000..6109306
--- /dev/null
+++ b/src/spotmap-admin/__tests__/api.test.js
@@ -0,0 +1,133 @@
+import apiFetch from '@wordpress/api-fetch';
+import * as api from '../api';
+
+jest.mock( '@wordpress/api-fetch', () => {
+ const mock = jest.fn();
+ mock.use = jest.fn();
+ return { __esModule: true, default: mock };
+} );
+
+const BASE = 'http://localhost/wp-json/spotmap/v1';
+
+beforeEach( () => {
+ apiFetch.mockResolvedValue( {} );
+} );
+
+afterEach( () => {
+ apiFetch.mockReset();
+} );
+
+describe( 'REDACTED constant', () => {
+ it( 'equals window.spotmapAdminData.REDACTED', () => {
+ expect( api.REDACTED ).toBe( '__REDACTED__' );
+ } );
+} );
+
+describe( 'feeds', () => {
+ it( 'getFeeds — GET /feeds', async () => {
+ await api.getFeeds();
+ expect( apiFetch ).toHaveBeenCalledWith( { url: `${ BASE }/feeds` } );
+ } );
+
+ it( 'createFeed — POST /feeds with data', async () => {
+ const data = { type: 'findmespot', name: 'test' };
+ await api.createFeed( data );
+ expect( apiFetch ).toHaveBeenCalledWith( {
+ url: `${ BASE }/feeds`,
+ method: 'POST',
+ data,
+ } );
+ } );
+
+ it( 'updateFeed — PUT /feeds/:id with data', async () => {
+ const data = { name: 'updated' };
+ await api.updateFeed( 'abc123', data );
+ expect( apiFetch ).toHaveBeenCalledWith( {
+ url: `${ BASE }/feeds/abc123`,
+ method: 'PUT',
+ data,
+ } );
+ } );
+
+ it( 'deleteFeed — DELETE /feeds/:id', async () => {
+ await api.deleteFeed( 'abc123' );
+ expect( apiFetch ).toHaveBeenCalledWith( {
+ url: `${ BASE }/feeds/abc123`,
+ method: 'DELETE',
+ } );
+ } );
+} );
+
+describe( 'providers', () => {
+ it( 'getProviders — GET /providers', async () => {
+ await api.getProviders();
+ expect( apiFetch ).toHaveBeenCalledWith( {
+ url: `${ BASE }/providers`,
+ } );
+ } );
+} );
+
+describe( 'markers', () => {
+ it( 'getMarkers — GET /markers', async () => {
+ await api.getMarkers();
+ expect( apiFetch ).toHaveBeenCalledWith( { url: `${ BASE }/markers` } );
+ } );
+
+ it( 'updateMarkers — PUT /markers with data', async () => {
+ const data = {
+ OK: { iconShape: 'circle', icon: 'star', customMessage: '' },
+ };
+ await api.updateMarkers( data );
+ expect( apiFetch ).toHaveBeenCalledWith( {
+ url: `${ BASE }/markers`,
+ method: 'PUT',
+ data,
+ } );
+ } );
+} );
+
+describe( 'tokens', () => {
+ it( 'getTokens — GET /tokens', async () => {
+ await api.getTokens();
+ expect( apiFetch ).toHaveBeenCalledWith( { url: `${ BASE }/tokens` } );
+ } );
+
+ it( 'updateTokens — PUT /tokens with data', async () => {
+ const data = { mapbox: 'my-token' };
+ await api.updateTokens( data );
+ expect( apiFetch ).toHaveBeenCalledWith( {
+ url: `${ BASE }/tokens`,
+ method: 'PUT',
+ data,
+ } );
+ } );
+} );
+
+describe( 'defaults', () => {
+ it( 'getDefaults — GET /defaults', async () => {
+ await api.getDefaults();
+ expect( apiFetch ).toHaveBeenCalledWith( {
+ url: `${ BASE }/defaults`,
+ } );
+ } );
+
+ it( 'updateDefaults — PUT /defaults with data', async () => {
+ const data = { height: 400, maps: 'openstreetmap' };
+ await api.updateDefaults( data );
+ expect( apiFetch ).toHaveBeenCalledWith( {
+ url: `${ BASE }/defaults`,
+ method: 'PUT',
+ data,
+ } );
+ } );
+} );
+
+describe( 'URL construction', () => {
+ it( 'strips trailing slash from restUrl', async () => {
+ // window.spotmapAdminData.restUrl ends with '/' — base must not double-slash
+ await api.getFeeds();
+ const calledUrl = apiFetch.mock.calls[ 0 ][ 0 ].url;
+ expect( calledUrl ).not.toContain( '//' + 'feeds' );
+ expect( calledUrl ).toBe( `${ BASE }/feeds` );
+ } );
+} );
diff --git a/src/spotmap-admin/__tests__/fixtures.js b/src/spotmap-admin/__tests__/fixtures.js
new file mode 100644
index 0000000..46f0988
--- /dev/null
+++ b/src/spotmap-admin/__tests__/fixtures.js
@@ -0,0 +1,30 @@
+export const REDACTED = '__REDACTED__';
+
+export const providers = {
+ findmespot: {
+ label: 'SPOT Feed',
+ fields: [
+ {
+ key: 'name',
+ type: 'text',
+ label: 'Feed Name',
+ required: true,
+ description: '',
+ },
+ {
+ key: 'feed_id',
+ type: 'text',
+ label: 'Feed ID',
+ required: true,
+ description: '',
+ },
+ {
+ key: 'password',
+ type: 'password',
+ label: 'Feed Password',
+ required: false,
+ description: 'Leave empty if the feed is public.',
+ },
+ ],
+ },
+};
diff --git a/src/spotmap-admin/api.js b/src/spotmap-admin/api.js
new file mode 100644
index 0000000..8d5fdd3
--- /dev/null
+++ b/src/spotmap-admin/api.js
@@ -0,0 +1,63 @@
+import apiFetch from '@wordpress/api-fetch';
+
+const base = window.spotmapAdminData.restUrl.replace( /\/$/, '' );
+
+const url = ( path ) => `${ base }/${ path }`;
+
+export const REDACTED = window.spotmapAdminData.REDACTED;
+
+export const getFeeds = () => apiFetch( { url: url( 'feeds' ) } );
+
+export const createFeed = ( data ) =>
+ apiFetch( { url: url( 'feeds' ), method: 'POST', data } );
+
+export const updateFeed = ( id, data ) =>
+ apiFetch( { url: url( `feeds/${ id }` ), method: 'PUT', data } );
+
+export const deleteFeed = ( id ) =>
+ apiFetch( { url: url( `feeds/${ id }` ), method: 'DELETE' } );
+
+export const pauseFeed = ( id ) =>
+ apiFetch( { url: url( `feeds/${ id }/pause` ), method: 'POST' } );
+
+export const unpauseFeed = ( id ) =>
+ apiFetch( { url: url( `feeds/${ id }/unpause` ), method: 'POST' } );
+
+export const getProviders = () => apiFetch( { url: url( 'providers' ) } );
+
+export const getMarkers = () => apiFetch( { url: url( 'markers' ) } );
+
+export const updateMarkers = ( data ) =>
+ apiFetch( { url: url( 'markers' ), method: 'PUT', data } );
+
+export const getTokens = () => apiFetch( { url: url( 'tokens' ) } );
+
+export const updateTokens = ( data ) =>
+ apiFetch( { url: url( 'tokens' ), method: 'PUT', data } );
+
+export const getDefaults = () => apiFetch( { url: url( 'defaults' ) } );
+
+export const updateDefaults = ( data ) =>
+ apiFetch( { url: url( 'defaults' ), method: 'PUT', data } );
+
+export const getPoints = ( { feed, from, to } = {} ) => {
+ const params = new URLSearchParams();
+ if ( feed ) {
+ params.set( 'feed', feed );
+ }
+ if ( from ) {
+ params.set( 'from', from );
+ }
+ if ( to ) {
+ params.set( 'to', to );
+ }
+ const qs = params.toString();
+ return apiFetch( { url: url( 'points' ) + ( qs ? '?' + qs : '' ) } );
+};
+
+export const updatePoint = ( id, { latitude, longitude } ) =>
+ apiFetch( {
+ url: url( `points/${ id }` ),
+ method: 'PUT',
+ data: { latitude, longitude },
+ } );
diff --git a/src/spotmap-admin/components/FeedModal.jsx b/src/spotmap-admin/components/FeedModal.jsx
new file mode 100644
index 0000000..e7bb732
--- /dev/null
+++ b/src/spotmap-admin/components/FeedModal.jsx
@@ -0,0 +1,211 @@
+/* global navigator */
+import { useState } from '@wordpress/element';
+import { Modal, Button, TextControl, Notice } from '@wordpress/components';
+import { REDACTED } from '../api';
+import ProviderSelector from './ProviderSelector';
+
+function OsmAndTrackingUrl( { url } ) {
+ const [ copied, setCopied ] = useState( false );
+ const copy = () => {
+ navigator.clipboard.writeText( url ).then( () => {
+ setCopied( true );
+ setTimeout( () => setCopied( false ), 2000 );
+ } );
+ };
+ return (
+
+
+ OsmAnd Tracking URL
+
+
+ Enter this URL in OsmAnd:{ ' ' }
+
+ Plugins → Trip Recording → Online tracking → Web address
+
+ . Set Tracking interval to 10 s or more.{ ' ' }
+
+ OsmAnd docs ↗
+
+
+
+
+ { url }
+
+
+ { copied ? '✓ Copied' : 'Copy' }
+
+
+
+ );
+}
+
+function generateOsmAndKey() {
+ const bytes = new Uint8Array( 16 );
+ crypto.getRandomValues( bytes );
+ return Array.from( bytes )
+ .map( ( b ) => b.toString( 16 ).padStart( 2, '0' ) )
+ .join( '' );
+}
+
+function buildOsmAndUrl( key ) {
+ const base = window.spotmapAdminData.restUrl.replace( /\/$/, '' );
+ return (
+ base +
+ '/ingest/osmand?key=' +
+ encodeURIComponent( key ) +
+ '&lat={0}&lon={1}×tamp={2}&hdop={3}&altitude={4}&speed={5}&bearing={6}&batproc={11}'
+ );
+}
+
+export default function FeedModal( { providers, feed, onSave, onClose } ) {
+ const isEdit = !! feed;
+ const providerKeys = Object.keys( providers );
+
+ const [ type, setType ] = useState( feed?.type ?? providerKeys[ 0 ] ?? '' );
+ const [ fields, setFields ] = useState( () => {
+ const initialType = feed?.type ?? providerKeys[ 0 ] ?? '';
+ const provider = providers[ initialType ];
+ const initial = {};
+ provider?.fields.forEach( ( f ) => {
+ initial[ f.key ] = feed?.[ f.key ] ?? '';
+ } );
+ // For new OsmAnd feeds, generate the key upfront so the URL is visible immediately.
+ if ( ! feed && initialType === 'osmand' ) {
+ initial.key = generateOsmAndKey();
+ }
+ return initial;
+ } );
+ const [ saving, setSaving ] = useState( false );
+ const [ error, setError ] = useState( null );
+
+ const provider = providers[ type ];
+
+ const setField = ( key, value ) =>
+ setFields( ( prev ) => ( { ...prev, [ key ]: value } ) );
+
+ const handleTypeChange = ( newType ) => {
+ setType( newType );
+ const newProvider = providers[ newType ];
+ const reset = {};
+ newProvider?.fields.forEach( ( f ) => {
+ reset[ f.key ] = '';
+ } );
+ if ( newType === 'osmand' ) {
+ reset.key = generateOsmAndKey();
+ }
+ setFields( reset );
+ };
+
+ // For new OsmAnd feeds the URL is built from the browser-generated key so it's
+ // visible immediately. For existing feeds the server-provided URL is used.
+ const osmandTrackingUrl =
+ type === 'osmand'
+ ? feed?.tracking_url ??
+ ( fields.key ? buildOsmAndUrl( fields.key ) : null )
+ : null;
+
+ const handleSave = async () => {
+ setSaving( true );
+ setError( null );
+ try {
+ await onSave( { type, ...fields }, feed?.id );
+ } catch ( err ) {
+ setError( err.message );
+ setSaving( false );
+ }
+ };
+
+ return (
+
+ { error && (
+ setError( null ) }>
+ { error }
+
+ ) }
+
+ { ! isEdit && (
+
+ ) }
+
+ { provider?.fields.map( ( field ) => {
+ const isRedacted =
+ isEdit &&
+ field.type === 'password' &&
+ fields[ field.key ] === REDACTED;
+ return (
+ ]+>/g, '' )
+ : undefined
+ }
+ type={ field.type === 'password' ? 'password' : 'text' }
+ // Show empty when REDACTED - user must re-enter to change it.
+ value={ isRedacted ? '' : fields[ field.key ] ?? '' }
+ placeholder={
+ isRedacted
+ ? 'Leave blank to keep existing password'
+ : undefined
+ }
+ autoComplete="off"
+ onChange={ ( val ) => setField( field.key, val ) }
+ __nextHasNoMarginBottom
+ __next40pxDefaultSize
+ />
+ );
+ } ) }
+
+ { osmandTrackingUrl && (
+
+ ) }
+
+
+
+ Save
+
+
+ Cancel
+
+
+
+ );
+}
diff --git a/src/spotmap-admin/components/IconPicker.jsx b/src/spotmap-admin/components/IconPicker.jsx
new file mode 100644
index 0000000..03e10b3
--- /dev/null
+++ b/src/spotmap-admin/components/IconPicker.jsx
@@ -0,0 +1,89 @@
+import { useState, useMemo } from '@wordpress/element';
+import { Modal, TextControl } from '@wordpress/components';
+import { ICONS } from '../icons';
+
+export default function IconPicker( { current, onSelect, onClose } ) {
+ const [ search, setSearch ] = useState( '' );
+
+ const query = search.trim().toLowerCase();
+ const filtered = useMemo(
+ () =>
+ query ? ICONS.filter( ( icon ) => icon.includes( query ) ) : ICONS,
+ [ query ]
+ );
+
+ return (
+
+
+
+ { filtered.length === 0 && (
+
+ No icons match “{ search }”
+
+ ) }
+
+
+ { filtered.map( ( icon ) => {
+ const isSelected = icon === current;
+ return (
+ onSelect( icon ) }
+ style={ {
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ gap: '4px',
+ padding: '8px 4px',
+ border: isSelected
+ ? '2px solid #0073aa'
+ : '1px solid #ddd',
+ borderRadius: '4px',
+ background: isSelected ? '#f0f7fc' : '#fff',
+ cursor: 'pointer',
+ fontSize: '11px',
+ overflow: 'hidden',
+ lineHeight: 1.2,
+ } }
+ >
+
+
+ { icon }
+
+
+ );
+ } ) }
+
+
+ );
+}
diff --git a/src/spotmap-admin/components/ProviderSelector.jsx b/src/spotmap-admin/components/ProviderSelector.jsx
new file mode 100644
index 0000000..5d727ed
--- /dev/null
+++ b/src/spotmap-admin/components/ProviderSelector.jsx
@@ -0,0 +1,240 @@
+/**
+ * Tile-based provider type selector for the Add Feed modal.
+ *
+ * Renders each provider as a clickable card (3 per row) with a brand icon
+ * and label. Selected tile gets a highlighted border.
+ *
+ * Adding a new provider icon: add an entry to PROVIDER_ICONS below.
+ * Any provider without an entry gets a generic fallback icon.
+ */
+
+// ---------------------------------------------------------------------------
+// Brand icon components
+// ---------------------------------------------------------------------------
+
+function OsmAndIcon() {
+ return (
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function FindMeSpotIcon() {
+ // White text on transparent bg — add a black background rect so the logo
+ // is readable on a white tile.
+ return (
+
+
+ { /* Signal wave graphics — orange */ }
+
+
+
+
+ { /* "SPOT" wordmark — white */ }
+
+
+
+
+
+
+
+
+
+ { /* "SAVB" text block — white */ }
+
+
+
+
+
+
+
+ );
+}
+
+/** Fallback for providers without a dedicated icon. */
+function GenericProviderIcon() {
+ return (
+
+
+
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Icon registry — add entries here when new providers are added.
+// ---------------------------------------------------------------------------
+
+const PROVIDER_ICONS = {
+ findmespot: FindMeSpotIcon,
+ osmand: OsmAndIcon,
+};
+
+// ---------------------------------------------------------------------------
+// Tile grid
+// ---------------------------------------------------------------------------
+
+export default function ProviderSelector( { providers, value, onChange } ) {
+ const entries = Object.entries( providers );
+
+ return (
+
+
+ Provider Type
+
+
+ { entries.map( ( [ key, provider ] ) => {
+ const selected = key === value;
+ const IconComponent =
+ PROVIDER_ICONS[ key ] ?? GenericProviderIcon;
+ return (
+ onChange( key ) }
+ style={ {
+ border: `2px solid ${
+ selected ? '#007cba' : '#ddd'
+ }`,
+ borderRadius: '8px',
+ padding: '14px 8px 10px',
+ cursor: 'pointer',
+ textAlign: 'center',
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ gap: '8px',
+ background: selected ? '#f0f8ff' : '#fff',
+ boxShadow: selected
+ ? '0 0 0 1px #007cba'
+ : 'none',
+ transition:
+ 'border-color 0.12s, background 0.12s',
+ outline: 'none',
+ width: '100%',
+ } }
+ >
+
+
+
+
+ { provider.label }
+
+
+ );
+ } ) }
+
+
+ );
+}
diff --git a/src/spotmap-admin/icons.js b/src/spotmap-admin/icons.js
new file mode 100644
index 0000000..32696a2
--- /dev/null
+++ b/src/spotmap-admin/icons.js
@@ -0,0 +1,1008 @@
+/**
+ * All Font Awesome 5 Free solid icon names, derived from FA metadata.
+ * Regenerate: node scripts/generate-fa-icons.js
+ */
+export const ICONS = [
+ 'ad',
+ 'address-book',
+ 'address-card',
+ 'adjust',
+ 'air-freshener',
+ 'align-center',
+ 'align-justify',
+ 'align-left',
+ 'align-right',
+ 'allergies',
+ 'ambulance',
+ 'american-sign-language-interpreting',
+ 'anchor',
+ 'angle-double-down',
+ 'angle-double-left',
+ 'angle-double-right',
+ 'angle-double-up',
+ 'angle-down',
+ 'angle-left',
+ 'angle-right',
+ 'angle-up',
+ 'angry',
+ 'ankh',
+ 'apple-alt',
+ 'archive',
+ 'archway',
+ 'arrow-alt-circle-down',
+ 'arrow-alt-circle-left',
+ 'arrow-alt-circle-right',
+ 'arrow-alt-circle-up',
+ 'arrow-circle-down',
+ 'arrow-circle-left',
+ 'arrow-circle-right',
+ 'arrow-circle-up',
+ 'arrow-down',
+ 'arrow-left',
+ 'arrow-right',
+ 'arrow-up',
+ 'arrows-alt',
+ 'arrows-alt-h',
+ 'arrows-alt-v',
+ 'assistive-listening-systems',
+ 'asterisk',
+ 'at',
+ 'atlas',
+ 'atom',
+ 'audio-description',
+ 'award',
+ 'baby',
+ 'baby-carriage',
+ 'backspace',
+ 'backward',
+ 'bacon',
+ 'bacteria',
+ 'bacterium',
+ 'bahai',
+ 'balance-scale',
+ 'balance-scale-left',
+ 'balance-scale-right',
+ 'ban',
+ 'band-aid',
+ 'barcode',
+ 'bars',
+ 'baseball-ball',
+ 'basketball-ball',
+ 'bath',
+ 'battery-empty',
+ 'battery-full',
+ 'battery-half',
+ 'battery-quarter',
+ 'battery-three-quarters',
+ 'bed',
+ 'beer',
+ 'bell',
+ 'bell-slash',
+ 'bezier-curve',
+ 'bible',
+ 'bicycle',
+ 'biking',
+ 'binoculars',
+ 'biohazard',
+ 'birthday-cake',
+ 'blender',
+ 'blender-phone',
+ 'blind',
+ 'blog',
+ 'bold',
+ 'bolt',
+ 'bomb',
+ 'bone',
+ 'bong',
+ 'book',
+ 'book-dead',
+ 'book-medical',
+ 'book-open',
+ 'book-reader',
+ 'bookmark',
+ 'border-all',
+ 'border-none',
+ 'border-style',
+ 'bowling-ball',
+ 'box',
+ 'box-open',
+ 'box-tissue',
+ 'boxes',
+ 'braille',
+ 'brain',
+ 'bread-slice',
+ 'briefcase',
+ 'briefcase-medical',
+ 'broadcast-tower',
+ 'broom',
+ 'brush',
+ 'bug',
+ 'building',
+ 'bullhorn',
+ 'bullseye',
+ 'burn',
+ 'bus',
+ 'bus-alt',
+ 'business-time',
+ 'calculator',
+ 'calendar',
+ 'calendar-alt',
+ 'calendar-check',
+ 'calendar-day',
+ 'calendar-minus',
+ 'calendar-plus',
+ 'calendar-times',
+ 'calendar-week',
+ 'camera',
+ 'camera-retro',
+ 'campground',
+ 'candy-cane',
+ 'cannabis',
+ 'capsules',
+ 'car',
+ 'car-alt',
+ 'car-battery',
+ 'car-crash',
+ 'car-side',
+ 'caravan',
+ 'caret-down',
+ 'caret-left',
+ 'caret-right',
+ 'caret-square-down',
+ 'caret-square-left',
+ 'caret-square-right',
+ 'caret-square-up',
+ 'caret-up',
+ 'carrot',
+ 'cart-arrow-down',
+ 'cart-plus',
+ 'cash-register',
+ 'cat',
+ 'certificate',
+ 'chair',
+ 'chalkboard',
+ 'chalkboard-teacher',
+ 'charging-station',
+ 'chart-area',
+ 'chart-bar',
+ 'chart-line',
+ 'chart-pie',
+ 'check',
+ 'check-circle',
+ 'check-double',
+ 'check-square',
+ 'cheese',
+ 'chess',
+ 'chess-bishop',
+ 'chess-board',
+ 'chess-king',
+ 'chess-knight',
+ 'chess-pawn',
+ 'chess-queen',
+ 'chess-rook',
+ 'chevron-circle-down',
+ 'chevron-circle-left',
+ 'chevron-circle-right',
+ 'chevron-circle-up',
+ 'chevron-down',
+ 'chevron-left',
+ 'chevron-right',
+ 'chevron-up',
+ 'child',
+ 'church',
+ 'circle',
+ 'circle-notch',
+ 'city',
+ 'clinic-medical',
+ 'clipboard',
+ 'clipboard-check',
+ 'clipboard-list',
+ 'clock',
+ 'clone',
+ 'closed-captioning',
+ 'cloud',
+ 'cloud-download-alt',
+ 'cloud-meatball',
+ 'cloud-moon',
+ 'cloud-moon-rain',
+ 'cloud-rain',
+ 'cloud-showers-heavy',
+ 'cloud-sun',
+ 'cloud-sun-rain',
+ 'cloud-upload-alt',
+ 'cocktail',
+ 'code',
+ 'code-branch',
+ 'coffee',
+ 'cog',
+ 'cogs',
+ 'coins',
+ 'columns',
+ 'comment',
+ 'comment-alt',
+ 'comment-dollar',
+ 'comment-dots',
+ 'comment-medical',
+ 'comment-slash',
+ 'comments',
+ 'comments-dollar',
+ 'compact-disc',
+ 'compass',
+ 'compress',
+ 'compress-alt',
+ 'compress-arrows-alt',
+ 'concierge-bell',
+ 'cookie',
+ 'cookie-bite',
+ 'copy',
+ 'copyright',
+ 'couch',
+ 'credit-card',
+ 'crop',
+ 'crop-alt',
+ 'cross',
+ 'crosshairs',
+ 'crow',
+ 'crown',
+ 'crutch',
+ 'cube',
+ 'cubes',
+ 'cut',
+ 'database',
+ 'deaf',
+ 'democrat',
+ 'desktop',
+ 'dharmachakra',
+ 'diagnoses',
+ 'dice',
+ 'dice-d20',
+ 'dice-d6',
+ 'dice-five',
+ 'dice-four',
+ 'dice-one',
+ 'dice-six',
+ 'dice-three',
+ 'dice-two',
+ 'digital-tachograph',
+ 'directions',
+ 'disease',
+ 'divide',
+ 'dizzy',
+ 'dna',
+ 'dog',
+ 'dollar-sign',
+ 'dolly',
+ 'dolly-flatbed',
+ 'donate',
+ 'door-closed',
+ 'door-open',
+ 'dot-circle',
+ 'dove',
+ 'download',
+ 'drafting-compass',
+ 'dragon',
+ 'draw-polygon',
+ 'drum',
+ 'drum-steelpan',
+ 'drumstick-bite',
+ 'dumbbell',
+ 'dumpster',
+ 'dumpster-fire',
+ 'dungeon',
+ 'edit',
+ 'egg',
+ 'eject',
+ 'ellipsis-h',
+ 'ellipsis-v',
+ 'envelope',
+ 'envelope-open',
+ 'envelope-open-text',
+ 'envelope-square',
+ 'equals',
+ 'eraser',
+ 'ethernet',
+ 'euro-sign',
+ 'exchange-alt',
+ 'exclamation',
+ 'exclamation-circle',
+ 'exclamation-triangle',
+ 'expand',
+ 'expand-alt',
+ 'expand-arrows-alt',
+ 'external-link-alt',
+ 'external-link-square-alt',
+ 'eye',
+ 'eye-dropper',
+ 'eye-slash',
+ 'fan',
+ 'fast-backward',
+ 'fast-forward',
+ 'faucet',
+ 'fax',
+ 'feather',
+ 'feather-alt',
+ 'female',
+ 'fighter-jet',
+ 'file',
+ 'file-alt',
+ 'file-archive',
+ 'file-audio',
+ 'file-code',
+ 'file-contract',
+ 'file-csv',
+ 'file-download',
+ 'file-excel',
+ 'file-export',
+ 'file-image',
+ 'file-import',
+ 'file-invoice',
+ 'file-invoice-dollar',
+ 'file-medical',
+ 'file-medical-alt',
+ 'file-pdf',
+ 'file-powerpoint',
+ 'file-prescription',
+ 'file-signature',
+ 'file-upload',
+ 'file-video',
+ 'file-word',
+ 'fill',
+ 'fill-drip',
+ 'film',
+ 'filter',
+ 'fingerprint',
+ 'fire',
+ 'fire-alt',
+ 'fire-extinguisher',
+ 'first-aid',
+ 'fish',
+ 'fist-raised',
+ 'flag',
+ 'flag-checkered',
+ 'flag-usa',
+ 'flask',
+ 'flushed',
+ 'folder',
+ 'folder-minus',
+ 'folder-open',
+ 'folder-plus',
+ 'font',
+ 'font-awesome-logo-full',
+ 'football-ball',
+ 'forward',
+ 'frog',
+ 'frown',
+ 'frown-open',
+ 'funnel-dollar',
+ 'futbol',
+ 'gamepad',
+ 'gas-pump',
+ 'gavel',
+ 'gem',
+ 'genderless',
+ 'ghost',
+ 'gift',
+ 'gifts',
+ 'glass-cheers',
+ 'glass-martini',
+ 'glass-martini-alt',
+ 'glass-whiskey',
+ 'glasses',
+ 'globe',
+ 'globe-africa',
+ 'globe-americas',
+ 'globe-asia',
+ 'globe-europe',
+ 'golf-ball',
+ 'gopuram',
+ 'graduation-cap',
+ 'greater-than',
+ 'greater-than-equal',
+ 'grimace',
+ 'grin',
+ 'grin-alt',
+ 'grin-beam',
+ 'grin-beam-sweat',
+ 'grin-hearts',
+ 'grin-squint',
+ 'grin-squint-tears',
+ 'grin-stars',
+ 'grin-tears',
+ 'grin-tongue',
+ 'grin-tongue-squint',
+ 'grin-tongue-wink',
+ 'grin-wink',
+ 'grip-horizontal',
+ 'grip-lines',
+ 'grip-lines-vertical',
+ 'grip-vertical',
+ 'guitar',
+ 'h-square',
+ 'hamburger',
+ 'hammer',
+ 'hamsa',
+ 'hand-holding',
+ 'hand-holding-heart',
+ 'hand-holding-medical',
+ 'hand-holding-usd',
+ 'hand-holding-water',
+ 'hand-lizard',
+ 'hand-middle-finger',
+ 'hand-paper',
+ 'hand-peace',
+ 'hand-point-down',
+ 'hand-point-left',
+ 'hand-point-right',
+ 'hand-point-up',
+ 'hand-pointer',
+ 'hand-rock',
+ 'hand-scissors',
+ 'hand-sparkles',
+ 'hand-spock',
+ 'hands',
+ 'hands-helping',
+ 'hands-wash',
+ 'handshake',
+ 'handshake-alt-slash',
+ 'handshake-slash',
+ 'hanukiah',
+ 'hard-hat',
+ 'hashtag',
+ 'hat-cowboy',
+ 'hat-cowboy-side',
+ 'hat-wizard',
+ 'hdd',
+ 'head-side-cough',
+ 'head-side-cough-slash',
+ 'head-side-mask',
+ 'head-side-virus',
+ 'heading',
+ 'headphones',
+ 'headphones-alt',
+ 'headset',
+ 'heart',
+ 'heart-broken',
+ 'heartbeat',
+ 'helicopter',
+ 'highlighter',
+ 'hiking',
+ 'hippo',
+ 'history',
+ 'hockey-puck',
+ 'holly-berry',
+ 'home',
+ 'horse',
+ 'horse-head',
+ 'hospital',
+ 'hospital-alt',
+ 'hospital-symbol',
+ 'hospital-user',
+ 'hot-tub',
+ 'hotdog',
+ 'hotel',
+ 'hourglass',
+ 'hourglass-end',
+ 'hourglass-half',
+ 'hourglass-start',
+ 'house-damage',
+ 'house-user',
+ 'hryvnia',
+ 'i-cursor',
+ 'ice-cream',
+ 'icicles',
+ 'icons',
+ 'id-badge',
+ 'id-card',
+ 'id-card-alt',
+ 'igloo',
+ 'image',
+ 'images',
+ 'inbox',
+ 'indent',
+ 'industry',
+ 'infinity',
+ 'info',
+ 'info-circle',
+ 'italic',
+ 'jedi',
+ 'joint',
+ 'journal-whills',
+ 'kaaba',
+ 'key',
+ 'keyboard',
+ 'khanda',
+ 'kiss',
+ 'kiss-beam',
+ 'kiss-wink-heart',
+ 'kiwi-bird',
+ 'landmark',
+ 'language',
+ 'laptop',
+ 'laptop-code',
+ 'laptop-house',
+ 'laptop-medical',
+ 'laugh',
+ 'laugh-beam',
+ 'laugh-squint',
+ 'laugh-wink',
+ 'layer-group',
+ 'leaf',
+ 'lemon',
+ 'less-than',
+ 'less-than-equal',
+ 'level-down-alt',
+ 'level-up-alt',
+ 'life-ring',
+ 'lightbulb',
+ 'link',
+ 'lira-sign',
+ 'list',
+ 'list-alt',
+ 'list-ol',
+ 'list-ul',
+ 'location-arrow',
+ 'lock',
+ 'lock-open',
+ 'long-arrow-alt-down',
+ 'long-arrow-alt-left',
+ 'long-arrow-alt-right',
+ 'long-arrow-alt-up',
+ 'low-vision',
+ 'luggage-cart',
+ 'lungs',
+ 'lungs-virus',
+ 'magic',
+ 'magnet',
+ 'mail-bulk',
+ 'male',
+ 'map',
+ 'map-marked',
+ 'map-marked-alt',
+ 'map-marker',
+ 'map-marker-alt',
+ 'map-pin',
+ 'map-signs',
+ 'marker',
+ 'mars',
+ 'mars-double',
+ 'mars-stroke',
+ 'mars-stroke-h',
+ 'mars-stroke-v',
+ 'mask',
+ 'medal',
+ 'medkit',
+ 'meh',
+ 'meh-blank',
+ 'meh-rolling-eyes',
+ 'memory',
+ 'menorah',
+ 'mercury',
+ 'meteor',
+ 'microchip',
+ 'microphone',
+ 'microphone-alt',
+ 'microphone-alt-slash',
+ 'microphone-slash',
+ 'microscope',
+ 'minus',
+ 'minus-circle',
+ 'minus-square',
+ 'mitten',
+ 'mobile',
+ 'mobile-alt',
+ 'money-bill',
+ 'money-bill-alt',
+ 'money-bill-wave',
+ 'money-bill-wave-alt',
+ 'money-check',
+ 'money-check-alt',
+ 'monument',
+ 'moon',
+ 'mortar-pestle',
+ 'mosque',
+ 'motorcycle',
+ 'mountain',
+ 'mouse',
+ 'mouse-pointer',
+ 'mug-hot',
+ 'music',
+ 'network-wired',
+ 'neuter',
+ 'newspaper',
+ 'not-equal',
+ 'notes-medical',
+ 'object-group',
+ 'object-ungroup',
+ 'oil-can',
+ 'om',
+ 'otter',
+ 'outdent',
+ 'pager',
+ 'paint-brush',
+ 'paint-roller',
+ 'palette',
+ 'pallet',
+ 'paper-plane',
+ 'paperclip',
+ 'parachute-box',
+ 'paragraph',
+ 'parking',
+ 'passport',
+ 'pastafarianism',
+ 'paste',
+ 'pause',
+ 'pause-circle',
+ 'paw',
+ 'peace',
+ 'pen',
+ 'pen-alt',
+ 'pen-fancy',
+ 'pen-nib',
+ 'pen-square',
+ 'pencil-alt',
+ 'pencil-ruler',
+ 'people-arrows',
+ 'people-carry',
+ 'pepper-hot',
+ 'percent',
+ 'percentage',
+ 'person-booth',
+ 'phone',
+ 'phone-alt',
+ 'phone-slash',
+ 'phone-square',
+ 'phone-square-alt',
+ 'phone-volume',
+ 'photo-video',
+ 'piggy-bank',
+ 'pills',
+ 'pizza-slice',
+ 'place-of-worship',
+ 'plane',
+ 'plane-arrival',
+ 'plane-departure',
+ 'plane-slash',
+ 'play',
+ 'play-circle',
+ 'plug',
+ 'plus',
+ 'plus-circle',
+ 'plus-square',
+ 'podcast',
+ 'poll',
+ 'poll-h',
+ 'poo',
+ 'poo-storm',
+ 'poop',
+ 'portrait',
+ 'pound-sign',
+ 'power-off',
+ 'pray',
+ 'praying-hands',
+ 'prescription',
+ 'prescription-bottle',
+ 'prescription-bottle-alt',
+ 'print',
+ 'procedures',
+ 'project-diagram',
+ 'pump-medical',
+ 'pump-soap',
+ 'puzzle-piece',
+ 'qrcode',
+ 'question',
+ 'question-circle',
+ 'quidditch',
+ 'quote-left',
+ 'quote-right',
+ 'quran',
+ 'radiation',
+ 'radiation-alt',
+ 'rainbow',
+ 'random',
+ 'receipt',
+ 'record-vinyl',
+ 'recycle',
+ 'redo',
+ 'redo-alt',
+ 'registered',
+ 'remove-format',
+ 'reply',
+ 'reply-all',
+ 'republican',
+ 'restroom',
+ 'retweet',
+ 'ribbon',
+ 'ring',
+ 'road',
+ 'robot',
+ 'rocket',
+ 'route',
+ 'rss',
+ 'rss-square',
+ 'ruble-sign',
+ 'ruler',
+ 'ruler-combined',
+ 'ruler-horizontal',
+ 'ruler-vertical',
+ 'running',
+ 'rupee-sign',
+ 'sad-cry',
+ 'sad-tear',
+ 'satellite',
+ 'satellite-dish',
+ 'save',
+ 'school',
+ 'screwdriver',
+ 'scroll',
+ 'sd-card',
+ 'search',
+ 'search-dollar',
+ 'search-location',
+ 'search-minus',
+ 'search-plus',
+ 'seedling',
+ 'server',
+ 'shapes',
+ 'share',
+ 'share-alt',
+ 'share-alt-square',
+ 'share-square',
+ 'shekel-sign',
+ 'shield-alt',
+ 'shield-virus',
+ 'ship',
+ 'shipping-fast',
+ 'shoe-prints',
+ 'shopping-bag',
+ 'shopping-basket',
+ 'shopping-cart',
+ 'shower',
+ 'shuttle-van',
+ 'sign',
+ 'sign-in-alt',
+ 'sign-language',
+ 'sign-out-alt',
+ 'signal',
+ 'signature',
+ 'sim-card',
+ 'sink',
+ 'sitemap',
+ 'skating',
+ 'skiing',
+ 'skiing-nordic',
+ 'skull',
+ 'skull-crossbones',
+ 'slash',
+ 'sleigh',
+ 'sliders-h',
+ 'smile',
+ 'smile-beam',
+ 'smile-wink',
+ 'smog',
+ 'smoking',
+ 'smoking-ban',
+ 'sms',
+ 'snowboarding',
+ 'snowflake',
+ 'snowman',
+ 'snowplow',
+ 'soap',
+ 'socks',
+ 'solar-panel',
+ 'sort',
+ 'sort-alpha-down',
+ 'sort-alpha-down-alt',
+ 'sort-alpha-up',
+ 'sort-alpha-up-alt',
+ 'sort-amount-down',
+ 'sort-amount-down-alt',
+ 'sort-amount-up',
+ 'sort-amount-up-alt',
+ 'sort-down',
+ 'sort-numeric-down',
+ 'sort-numeric-down-alt',
+ 'sort-numeric-up',
+ 'sort-numeric-up-alt',
+ 'sort-up',
+ 'spa',
+ 'space-shuttle',
+ 'spell-check',
+ 'spider',
+ 'spinner',
+ 'splotch',
+ 'spray-can',
+ 'square',
+ 'square-full',
+ 'square-root-alt',
+ 'stamp',
+ 'star',
+ 'star-and-crescent',
+ 'star-half',
+ 'star-half-alt',
+ 'star-of-david',
+ 'star-of-life',
+ 'step-backward',
+ 'step-forward',
+ 'stethoscope',
+ 'sticky-note',
+ 'stop',
+ 'stop-circle',
+ 'stopwatch',
+ 'stopwatch-20',
+ 'store',
+ 'store-alt',
+ 'store-alt-slash',
+ 'store-slash',
+ 'stream',
+ 'street-view',
+ 'strikethrough',
+ 'stroopwafel',
+ 'subscript',
+ 'subway',
+ 'suitcase',
+ 'suitcase-rolling',
+ 'sun',
+ 'superscript',
+ 'surprise',
+ 'swatchbook',
+ 'swimmer',
+ 'swimming-pool',
+ 'synagogue',
+ 'sync',
+ 'sync-alt',
+ 'syringe',
+ 'table',
+ 'table-tennis',
+ 'tablet',
+ 'tablet-alt',
+ 'tablets',
+ 'tachometer-alt',
+ 'tag',
+ 'tags',
+ 'tape',
+ 'tasks',
+ 'taxi',
+ 'teeth',
+ 'teeth-open',
+ 'temperature-high',
+ 'temperature-low',
+ 'tenge',
+ 'terminal',
+ 'text-height',
+ 'text-width',
+ 'th',
+ 'th-large',
+ 'th-list',
+ 'theater-masks',
+ 'thermometer',
+ 'thermometer-empty',
+ 'thermometer-full',
+ 'thermometer-half',
+ 'thermometer-quarter',
+ 'thermometer-three-quarters',
+ 'thumbs-down',
+ 'thumbs-up',
+ 'thumbtack',
+ 'ticket-alt',
+ 'times',
+ 'times-circle',
+ 'tint',
+ 'tint-slash',
+ 'tired',
+ 'toggle-off',
+ 'toggle-on',
+ 'toilet',
+ 'toilet-paper',
+ 'toilet-paper-slash',
+ 'toolbox',
+ 'tools',
+ 'tooth',
+ 'torah',
+ 'torii-gate',
+ 'tractor',
+ 'trademark',
+ 'traffic-light',
+ 'trailer',
+ 'train',
+ 'tram',
+ 'transgender',
+ 'transgender-alt',
+ 'trash',
+ 'trash-alt',
+ 'trash-restore',
+ 'trash-restore-alt',
+ 'tree',
+ 'trophy',
+ 'truck',
+ 'truck-loading',
+ 'truck-monster',
+ 'truck-moving',
+ 'truck-pickup',
+ 'tshirt',
+ 'tty',
+ 'tv',
+ 'umbrella',
+ 'umbrella-beach',
+ 'underline',
+ 'undo',
+ 'undo-alt',
+ 'universal-access',
+ 'university',
+ 'unlink',
+ 'unlock',
+ 'unlock-alt',
+ 'upload',
+ 'user',
+ 'user-alt',
+ 'user-alt-slash',
+ 'user-astronaut',
+ 'user-check',
+ 'user-circle',
+ 'user-clock',
+ 'user-cog',
+ 'user-edit',
+ 'user-friends',
+ 'user-graduate',
+ 'user-injured',
+ 'user-lock',
+ 'user-md',
+ 'user-minus',
+ 'user-ninja',
+ 'user-nurse',
+ 'user-plus',
+ 'user-secret',
+ 'user-shield',
+ 'user-slash',
+ 'user-tag',
+ 'user-tie',
+ 'user-times',
+ 'users',
+ 'users-cog',
+ 'users-slash',
+ 'utensil-spoon',
+ 'utensils',
+ 'vector-square',
+ 'venus',
+ 'venus-double',
+ 'venus-mars',
+ 'vest',
+ 'vest-patches',
+ 'vial',
+ 'vials',
+ 'video',
+ 'video-slash',
+ 'vihara',
+ 'virus',
+ 'virus-slash',
+ 'viruses',
+ 'voicemail',
+ 'volleyball-ball',
+ 'volume-down',
+ 'volume-mute',
+ 'volume-off',
+ 'volume-up',
+ 'vote-yea',
+ 'vr-cardboard',
+ 'walking',
+ 'wallet',
+ 'warehouse',
+ 'water',
+ 'wave-square',
+ 'weight',
+ 'weight-hanging',
+ 'wheelchair',
+ 'wifi',
+ 'wind',
+ 'window-close',
+ 'window-maximize',
+ 'window-minimize',
+ 'window-restore',
+ 'wine-bottle',
+ 'wine-glass',
+ 'wine-glass-alt',
+ 'won-sign',
+ 'wrench',
+ 'x-ray',
+ 'yen-sign',
+ 'yin-yang',
+];
diff --git a/src/spotmap-admin/index.js b/src/spotmap-admin/index.js
new file mode 100644
index 0000000..1e88c9b
--- /dev/null
+++ b/src/spotmap-admin/index.js
@@ -0,0 +1,10 @@
+import { createRoot } from '@wordpress/element';
+import apiFetch from '@wordpress/api-fetch';
+import App from './App';
+
+apiFetch.use( apiFetch.createNonceMiddleware( window.spotmapAdminData.nonce ) );
+
+const root = document.getElementById( 'spotmap-admin-root' );
+if ( root ) {
+ createRoot( root ).render( );
+}
diff --git a/src/spotmap-admin/tabs/DefaultsTab.jsx b/src/spotmap-admin/tabs/DefaultsTab.jsx
new file mode 100644
index 0000000..3594c53
--- /dev/null
+++ b/src/spotmap-admin/tabs/DefaultsTab.jsx
@@ -0,0 +1,149 @@
+import { useState, useEffect } from '@wordpress/element';
+import {
+ Button,
+ TextControl,
+ // eslint-disable-next-line @wordpress/no-unsafe-wp-apis
+ __experimentalNumberControl as NumberControl,
+ Spinner,
+} from '@wordpress/components';
+import * as api from '../api';
+
+const FIELDS = [
+ {
+ key: 'height',
+ label: 'Map Height (px)',
+ help: 'Default height of the map in pixels.',
+ type: 'number',
+ },
+ {
+ key: 'maps',
+ label: 'Default Maps',
+ help: 'Comma-separated list of map layer keys, e.g. openstreetmap,opentopomap.',
+ type: 'text',
+ },
+ {
+ key: 'mapcenter',
+ label: 'Map Center',
+ help: '"all" to fit all points, "auto" for last position, or "lat,lon" for a fixed coordinate.',
+ type: 'text',
+ },
+ {
+ key: 'width',
+ label: 'Map Width',
+ help: 'Container width style: normal, wide, or full.',
+ type: 'text',
+ },
+ {
+ key: 'color',
+ label: 'Track Colors',
+ help: 'Comma-separated list of colors for multi-feed tracks, e.g. blue,red.',
+ type: 'text',
+ },
+ {
+ key: 'splitlines',
+ label: 'Split Lines (hours)',
+ help: 'Break the track line if the gap between two points exceeds this many hours.',
+ type: 'text',
+ },
+ {
+ key: 'filter-points',
+ label: 'Filter Points (minutes)',
+ help: 'Hide points recorded within this many minutes of each other.',
+ type: 'number',
+ },
+ {
+ key: 'map-overlays',
+ label: 'Map Overlays',
+ help: 'Comma-separated overlay keys to enable by default. Leave blank for none.',
+ type: 'text',
+ },
+];
+
+export default function DefaultsTab( { onNoticeChange } ) {
+ const [ defaults, setDefaults ] = useState( null );
+ const [ saving, setSaving ] = useState( false );
+
+ useEffect( () => {
+ api.getDefaults()
+ .then( setDefaults )
+ .catch( ( err ) =>
+ onNoticeChange( { status: 'error', text: err.message } )
+ );
+ }, [ onNoticeChange ] );
+
+ const set = ( key, value ) =>
+ setDefaults( ( prev ) => ( { ...prev, [ key ]: value } ) );
+
+ const handleSave = async () => {
+ setSaving( true );
+ try {
+ const saved = await api.updateDefaults( defaults );
+ setDefaults( saved );
+ onNoticeChange( {
+ status: 'success',
+ text: 'Default settings saved.',
+ } );
+ } catch ( err ) {
+ onNoticeChange( { status: 'error', text: err.message } );
+ } finally {
+ setSaving( false );
+ }
+ };
+
+ if ( ! defaults ) {
+ return ;
+ }
+
+ return (
+
+
+ { FIELDS.map( ( field ) => {
+ const value = defaults[ field.key ] ?? '';
+ if ( field.type === 'number' ) {
+ return (
+
+ set(
+ field.key,
+ val === '' ? null : Number( val )
+ )
+ }
+ __nextHasNoMarginBottom
+ __next40pxDefaultSize
+ />
+ );
+ }
+ return (
+
+ set( field.key, val === '' ? null : val )
+ }
+ __nextHasNoMarginBottom
+ __next40pxDefaultSize
+ />
+ );
+ } ) }
+
+
+
+ Save Defaults
+
+
+ );
+}
diff --git a/src/spotmap-admin/tabs/EditPointsTab.jsx b/src/spotmap-admin/tabs/EditPointsTab.jsx
new file mode 100644
index 0000000..6663702
--- /dev/null
+++ b/src/spotmap-admin/tabs/EditPointsTab.jsx
@@ -0,0 +1,386 @@
+import { useState, useEffect, useRef, useCallback } from '@wordpress/element';
+import {
+ Button,
+ CheckboxControl,
+ Dropdown,
+ Flex,
+ FlexItem,
+ Notice,
+ Toolbar,
+ ToolbarButton,
+ ToolbarGroup,
+} from '@wordpress/components';
+import { undo } from '@wordpress/icons';
+import { __ } from '@wordpress/i18n';
+import MapsToolbarGroup from '../../spotmap/components/MapsToolbarGroup';
+import TimeToolbarGroup from '../../spotmap/components/TimeToolbarGroup';
+import * as api from '../api';
+
+const ALL_FEEDS = ( window.spotmapAdminData.feeds ?? [] ).filter( Boolean );
+
+const DEFAULT_FEED_STYLE = {
+ color: 'blue',
+ splitLines: 0,
+ lineWidth: 2,
+ lineOpacity: 1.0,
+ visible: true,
+};
+
+function getDefaultMaps() {
+ const dv = window.spotmapjsobj?.defaultValues?.maps;
+ if ( dv ) {
+ return dv
+ .split( ',' )
+ .map( ( m ) => m.trim() )
+ .filter( Boolean );
+ }
+ return Object.keys( window.spotmapjsobj?.maps ?? {} ).slice( 0, 1 );
+}
+
+export default function EditPointsTab( { onNoticeChange } ) {
+ const [ feeds, setFeeds ] = useState( ALL_FEEDS.slice( 0, 1 ) );
+ const [ styles, setStyles ] = useState( () => {
+ const s = {};
+ ALL_FEEDS.slice( 0, 1 ).forEach( ( f ) => {
+ s[ f ] = { ...DEFAULT_FEED_STYLE };
+ } );
+ return s;
+ } );
+ const [ maps, setMaps ] = useState( getDefaultMaps );
+ const [ mapOverlays, setMapOverlays ] = useState( [] );
+ const [ dateRange, setDateRange ] = useState( { from: '', to: '' } );
+
+ const [ loading, setLoading ] = useState( false );
+ const [ pointCount, setPointCount ] = useState( null );
+ const [ undoCount, setUndoCount ] = useState( 0 );
+
+ const mapContainerRef = useRef( null );
+ const spotmapRef = useRef( null );
+ // Stack of { pointId, marker, prevLat, prevLng } — stored in a ref so
+ // dragend closures always see the current stack without stale captures.
+ const undoStackRef = useRef( [] );
+
+ // Destroy map instance on tab unmount.
+ useEffect( () => {
+ return () => {
+ spotmapRef.current?.destroy();
+ spotmapRef.current = null;
+ };
+ }, [] );
+
+ const toggleFeed = ( feed, checked ) => {
+ const next = checked
+ ? [ ...feeds, feed ]
+ : feeds.filter( ( f ) => f !== feed );
+ const newStyles = { ...styles };
+ if ( checked && ! newStyles[ feed ] ) {
+ newStyles[ feed ] = { ...DEFAULT_FEED_STYLE };
+ }
+ setFeeds( next );
+ setStyles( newStyles );
+ };
+
+ const handleUndo = useCallback( async () => {
+ const stack = undoStackRef.current;
+ if ( stack.length === 0 ) {
+ return;
+ }
+ const last = stack[ stack.length - 1 ];
+ try {
+ await api.updatePoint( last.pointId, {
+ latitude: last.prevLat,
+ longitude: last.prevLng,
+ } );
+ last.marker.setLatLng( [ last.prevLat, last.prevLng ] );
+ undoStackRef.current = stack.slice( 0, -1 );
+ setUndoCount( undoStackRef.current.length );
+ onNoticeChange( {
+ status: 'success',
+ text: `Undid move of point #${ last.pointId }.`,
+ } );
+ } catch ( err ) {
+ onNoticeChange( {
+ status: 'error',
+ text: `Failed to undo: ${ err.message }`,
+ } );
+ }
+ }, [ onNoticeChange ] );
+
+ const handleLoad = useCallback( async () => {
+ if ( feeds.length === 0 ) {
+ return;
+ }
+ if ( typeof window.Spotmap === 'undefined' ) {
+ onNoticeChange( {
+ status: 'error',
+ text: 'Map engine not loaded. Try refreshing the page.',
+ } );
+ return;
+ }
+
+ if ( spotmapRef.current ) {
+ spotmapRef.current.destroy();
+ // Clear the cached options so initMap() doesn't skip re-initialization.
+ if ( mapContainerRef.current ) {
+ delete mapContainerRef.current._spotmapOptions;
+ }
+ spotmapRef.current = null;
+ }
+
+ setLoading( true );
+ onNoticeChange( null );
+ setPointCount( null );
+ undoStackRef.current = [];
+ setUndoCount( 0 );
+
+ const options = {
+ feeds,
+ styles,
+ maps,
+ mapOverlays,
+ mapElement: mapContainerRef.current,
+ enablePanning: true,
+ scrollWheelZoom: true,
+ autoReload: false,
+ debug: false,
+ mapcenter: 'all',
+ filterPoints: 0,
+ gpx: [],
+ dateRange,
+ fullscreenButton: false,
+ locateButton: false,
+ };
+
+ try {
+ const sm = new window.Spotmap( options );
+ spotmapRef.current = sm;
+ await sm.initMap();
+
+ setTimeout( () => sm.map?.invalidateSize?.(), 150 );
+
+ // Make every marker draggable and wire the save handler.
+ let total = 0;
+ for ( const feedLayer of Object.values( sm.layers.feeds ) ) {
+ feedLayer.markers.forEach( ( marker, i ) => {
+ const point = feedLayer.points[ i ];
+ if ( ! point ) {
+ return;
+ }
+ total++;
+ marker.dragging.enable();
+ // Capture position before each drag so undo knows where to go back.
+ let prevLatLng = marker.getLatLng();
+ marker.on( 'dragstart', ( e ) => {
+ prevLatLng = e.target.getLatLng();
+ } );
+ marker.on( 'dragend', async ( e ) => {
+ const { lat, lng } = e.target.getLatLng();
+ const prev = prevLatLng;
+ try {
+ await api.updatePoint( point.id, {
+ latitude: lat,
+ longitude: lng,
+ } );
+ onNoticeChange( {
+ status: 'success',
+ text:
+ 'Point #' +
+ point.id +
+ ' saved at ' +
+ lat.toFixed( 5 ) +
+ ', ' +
+ lng.toFixed( 5 ) +
+ '.',
+ } );
+ undoStackRef.current = [
+ ...undoStackRef.current,
+ {
+ pointId: point.id,
+ marker,
+ prevLat: prev.lat,
+ prevLng: prev.lng,
+ },
+ ];
+ setUndoCount( undoStackRef.current.length );
+ } catch ( err ) {
+ onNoticeChange( {
+ status: 'error',
+ text: `Failed to save point #${ point.id }: ${ err.message }`,
+ } );
+ e.target.setLatLng( [ prev.lat, prev.lng ] );
+ }
+ } );
+ } );
+ }
+
+ setPointCount( total );
+ if ( total === 0 ) {
+ onNoticeChange( {
+ status: 'warning',
+ text: 'No points found for this selection.',
+ } );
+ }
+ } catch ( err ) {
+ onNoticeChange( { status: 'error', text: err.message } );
+ } finally {
+ setLoading( false );
+ }
+ }, [ feeds, styles, maps, mapOverlays, dateRange, onNoticeChange ] );
+
+ if ( ALL_FEEDS.length === 0 ) {
+ return (
+
+
+ No feeds configured yet. Add a feed in the Feeds tab first.
+
+
+ );
+ }
+
+ return (
+
+
+
+ { /* Feeds */ }
+
+ (
+
+ { __( 'Feeds' ) }
+
+ ) }
+ renderContent={ () => (
+
+ { ALL_FEEDS.map( ( feed ) => (
+
+
+
+ toggleFeed(
+ feed,
+ checked
+ )
+ }
+ />
+
+
+
+ ) ) }
+
+ ) }
+ />
+
+
+ { /* Maps + overlays */ }
+
+
+ { /* Time filter */ }
+
+
+
+ { __( 'Load Points' ) }
+
+ { undoCount > 0 && (
+
+ { __( 'Undo' ) }
+
+ ) }
+
+
+ { pointCount !== null && ! loading && (
+
+ { `${ pointCount } point${
+ pointCount !== 1 ? 's' : ''
+ } loaded - drag any marker to correct its position. Changes save immediately.` }
+
+ ) }
+ { pointCount === null && ! loading && (
+
+ Select feeds, map layers and time range, then click Load
+ Points.
+
+ ) }
+
+
+
+ );
+}
diff --git a/src/spotmap-admin/tabs/FeedsTab.jsx b/src/spotmap-admin/tabs/FeedsTab.jsx
new file mode 100644
index 0000000..3f776a5
--- /dev/null
+++ b/src/spotmap-admin/tabs/FeedsTab.jsx
@@ -0,0 +1,195 @@
+import { useState, useEffect } from '@wordpress/element';
+import { Button, Spinner } from '@wordpress/components';
+import * as api from '../api';
+import FeedModal from '../components/FeedModal';
+
+export default function FeedsTab( {
+ providers,
+ openAddModal,
+ onNoticeChange,
+} ) {
+ const [ feeds, setFeeds ] = useState( null );
+ const [ loading, setLoading ] = useState( true );
+ const [ editingFeed, setEditingFeed ] = useState(
+ openAddModal ? {} : null
+ ); // null=closed, {}=new, feed=edit
+
+ useEffect( () => {
+ let cancelled = false;
+ api.getFeeds()
+ .then( ( data ) => {
+ if ( ! cancelled ) {
+ setFeeds( data );
+ }
+ } )
+ .catch( ( err ) => {
+ if ( ! cancelled ) {
+ setFeeds( [] );
+ onNoticeChange( { status: 'error', text: err.message } );
+ }
+ } )
+ .finally( () => {
+ if ( ! cancelled ) {
+ setLoading( false );
+ }
+ } );
+ return () => {
+ cancelled = true;
+ };
+ }, [ onNoticeChange ] );
+
+ const handleSave = async ( data, id ) => {
+ const saved = id
+ ? await api.updateFeed( id, data )
+ : await api.createFeed( data );
+
+ setFeeds( ( prev ) =>
+ id
+ ? prev.map( ( f ) => ( f.id === id ? saved : f ) )
+ : [ ...prev, saved ]
+ );
+
+ setEditingFeed( null );
+ onNoticeChange( { status: 'success', text: 'Feed saved.' } );
+ };
+
+ const handleTogglePause = async ( feed ) => {
+ try {
+ const updated = feed.paused
+ ? await api.unpauseFeed( feed.id )
+ : await api.pauseFeed( feed.id );
+ setFeeds( ( prev ) =>
+ prev.map( ( f ) =>
+ f.id === feed.id ? { ...f, paused: updated.paused } : f
+ )
+ );
+ onNoticeChange( {
+ status: 'success',
+ text: updated.paused ? 'Feed paused.' : 'Feed resumed.',
+ } );
+ } catch ( err ) {
+ onNoticeChange( { status: 'error', text: err.message } );
+ }
+ };
+
+ const handleDelete = async ( feed ) => {
+ // eslint-disable-next-line no-alert
+ if ( ! window.confirm( `Delete feed "${ feed.name }"?` ) ) {
+ return;
+ }
+ try {
+ await api.deleteFeed( feed.id );
+ setFeeds( ( prev ) => prev.filter( ( f ) => f.id !== feed.id ) );
+ onNoticeChange( { status: 'success', text: 'Feed deleted.' } );
+ } catch ( err ) {
+ onNoticeChange( { status: 'error', text: err.message } );
+ }
+ };
+
+ if ( loading ) {
+ return ;
+ }
+
+ return (
+
+ { feeds.length === 0 ? (
+
No feeds configured yet.
+ ) : (
+
+
+
+ Name
+ Type
+ Feed ID
+ Points
+ Actions
+
+
+
+ { feeds.map( ( feed ) => (
+
+ { feed.name }
+
+ { providers[ feed.type ]?.label ??
+ feed.type }
+
+
+ { feed.type === 'osmand' ? (
+
+ push feed
+
+ ) : (
+ { feed.feed_id }
+ ) }
+
+ { feed.point_count ?? 0 }
+
+ setEditingFeed( feed ) }
+ >
+ Edit
+ { ' ' }
+ { feed.paused ? (
+
+ handleTogglePause( feed )
+ }
+ >
+ Unpause
+
+ ) : (
+
+ handleTogglePause( feed )
+ }
+ >
+ Pause
+
+ ) }{ ' ' }
+ handleDelete( feed ) }
+ >
+ Delete
+
+
+
+ ) ) }
+
+
+ ) }
+
+
setEditingFeed( {} ) }>
+ Add Feed
+
+
+ { editingFeed !== null && (
+
setEditingFeed( null ) }
+ />
+ ) }
+
+ );
+}
diff --git a/src/spotmap-admin/tabs/MarkersTab.jsx b/src/spotmap-admin/tabs/MarkersTab.jsx
new file mode 100644
index 0000000..f6d9371
--- /dev/null
+++ b/src/spotmap-admin/tabs/MarkersTab.jsx
@@ -0,0 +1,146 @@
+import { useState, useEffect } from '@wordpress/element';
+import {
+ Button,
+ SelectControl,
+ TextControl,
+ Spinner,
+} from '@wordpress/components';
+import * as api from '../api';
+import IconPicker from '../components/IconPicker';
+
+const ICON_SHAPES = [
+ { value: 'marker', label: 'Marker' },
+ { value: 'circle', label: 'Circle' },
+ { value: 'circle-dot', label: 'Circle Dot' },
+];
+
+export default function MarkersTab( { onNoticeChange } ) {
+ const [ markers, setMarkers ] = useState( null );
+ const [ saving, setSaving ] = useState( false );
+ const [ pickerFor, setPickerFor ] = useState( null ); // marker type key
+
+ useEffect( () => {
+ api.getMarkers()
+ .then( setMarkers )
+ .catch( ( err ) =>
+ onNoticeChange( { status: 'error', text: err.message } )
+ );
+ }, [ onNoticeChange ] );
+
+ const update = ( type, key, value ) =>
+ setMarkers( ( prev ) => ( {
+ ...prev,
+ [ type ]: { ...prev[ type ], [ key ]: value },
+ } ) );
+
+ const handleSave = async () => {
+ setSaving( true );
+ try {
+ const saved = await api.updateMarkers( markers );
+ setMarkers( saved );
+ onNoticeChange( {
+ status: 'success',
+ text: 'Marker settings saved.',
+ } );
+ } catch ( err ) {
+ onNoticeChange( { status: 'error', text: err.message } );
+ } finally {
+ setSaving( false );
+ }
+ };
+
+ if ( ! markers ) {
+ return ;
+ }
+
+ return (
+
+
+
+
+ Type
+ Shape
+ Icon
+ Custom Message
+
+
+
+ { Object.entries( markers ).map( ( [ type, config ] ) => (
+
+
+ { type }
+
+
+
+ update( type, 'iconShape', val )
+ }
+ __nextHasNoMarginBottom
+ __next40pxDefaultSize
+ />
+
+
+
+
+ setPickerFor( type ) }
+ >
+ { config.icon || 'Pick icon…' }
+
+
+
+
+
+ update( type, 'customMessage', val )
+ }
+ placeholder="Optional custom message…"
+ __nextHasNoMarginBottom
+ __next40pxDefaultSize
+ />
+
+
+ ) ) }
+
+
+
+
+ Save Marker Settings
+
+
+ { pickerFor && (
+
{
+ update( pickerFor, 'icon', icon );
+ setPickerFor( null );
+ } }
+ onClose={ () => setPickerFor( null ) }
+ />
+ ) }
+
+ );
+}
diff --git a/src/spotmap-admin/tabs/TokensTab.jsx b/src/spotmap-admin/tabs/TokensTab.jsx
new file mode 100644
index 0000000..8ee23d3
--- /dev/null
+++ b/src/spotmap-admin/tabs/TokensTab.jsx
@@ -0,0 +1,181 @@
+import { useState, useEffect } from '@wordpress/element';
+import {
+ Button,
+ TextControl,
+ BaseControl,
+ Flex,
+ FlexItem,
+ Spinner,
+} from '@wordpress/components';
+import * as api from '../api';
+
+const { REDACTED } = api;
+
+const TOKEN_META = {
+ timezonedb: {
+ label: 'TimezoneDB',
+ help: 'Used for automatic timezone lookup. Get a free key at timezonedb.com.',
+ },
+ mapbox: {
+ label: 'Mapbox',
+ help: 'Required to display Mapbox tile layers.',
+ },
+ thunderforest: {
+ label: 'Thunderforest',
+ help: 'Required to display Thunderforest tile layers (e.g. OpenCycleMap).',
+ },
+ 'linz.govt.nz': {
+ label: 'LINZ (New Zealand)',
+ help: 'Required for LINZ topographic tile layers.',
+ },
+ 'geoservices.ign.fr': {
+ label: 'IGN France (Géoportail)',
+ help: 'Required for French IGN tile layers.',
+ },
+ 'osdatahub.os.uk': {
+ label: 'Ordnance Survey (UK)',
+ help: 'Required for OS tile layers.',
+ },
+};
+
+function TokenField( { tokenKey, value, onChange } ) {
+ const meta = TOKEN_META[ tokenKey ] ?? { label: tokenKey, help: '' };
+ const isStored = value === REDACTED;
+ const [ editing, setEditing ] = useState( ! isStored );
+ const [ clearing, setClearing ] = useState( false );
+ const [ inputVal, setInputVal ] = useState( '' );
+
+ // If the parent resets the value (e.g. after save), sync editing state.
+ useEffect( () => {
+ if ( value === REDACTED ) {
+ setEditing( false );
+ setClearing( false );
+ setInputVal( '' );
+ }
+ }, [ value ] );
+
+ if ( isStored && ! editing ) {
+ return (
+
+
+
+
+ ✓ Token stored
+
+
+
+ {
+ setEditing( true );
+ setClearing( false );
+ setInputVal( '' );
+ // Keep REDACTED until the user actually types a new value.
+ onChange( REDACTED );
+ } }
+ >
+ Change
+
+
+
+ {
+ setEditing( true );
+ setClearing( true );
+ setInputVal( '' );
+ onChange( '' );
+ } }
+ >
+ Clear
+
+
+
+
+ );
+ }
+
+ const handleInputChange = ( val ) => {
+ setInputVal( val );
+ if ( clearing ) {
+ // In clear mode every value (including empty) is intentional.
+ onChange( val );
+ } else {
+ // In change mode an empty field means "keep the stored token".
+ onChange( val === '' ? REDACTED : val );
+ }
+ };
+
+ return (
+
+ );
+}
+
+export default function TokensTab( { onNoticeChange } ) {
+ const [ tokens, setTokens ] = useState( null );
+ const [ saving, setSaving ] = useState( false );
+
+ useEffect( () => {
+ api.getTokens()
+ .then( setTokens )
+ .catch( ( err ) =>
+ onNoticeChange( { status: 'error', text: err.message } )
+ );
+ }, [ onNoticeChange ] );
+
+ const handleSave = async () => {
+ setSaving( true );
+ try {
+ const saved = await api.updateTokens( tokens );
+ setTokens( saved );
+ onNoticeChange( { status: 'success', text: 'API tokens saved.' } );
+ } catch ( err ) {
+ onNoticeChange( { status: 'error', text: err.message } );
+ } finally {
+ setSaving( false );
+ }
+ };
+
+ if ( ! tokens ) {
+ return ;
+ }
+
+ return (
+
+ { Object.keys( tokens ).map( ( key ) => (
+
+ setTokens( ( prev ) => ( { ...prev, [ key ]: val } ) )
+ }
+ />
+ ) ) }
+
+
+ Save API Tokens
+
+
+ );
+}
diff --git a/src/spotmap/block.json b/src/spotmap/block.json
new file mode 100644
index 0000000..8b01e9b
--- /dev/null
+++ b/src/spotmap/block.json
@@ -0,0 +1,87 @@
+{
+ "$schema": "https://schemas.wp.org/trunk/block.json",
+ "apiVersion": 3,
+ "name": "spotmap/spotmap",
+ "title": "Spotmap",
+ "category": "embed",
+ "icon": "location-alt",
+ "description": "Display a Leaflet map with GPS tracking points from your SPOT device.",
+ "keywords": [ "map", "gps", "spot", "gpx", "tracking" ],
+ "supports": {
+ "align": [ "wide", "full" ],
+ "html": false
+ },
+ "attributes": {
+ "maps": {
+ "type": "array",
+ "default": []
+ },
+ "feeds": {
+ "type": "array",
+ "default": []
+ },
+ "styles": {
+ "type": "object",
+ "default": {}
+ },
+ "height": {
+ "type": "number",
+ "default": 500
+ },
+ "mapcenter": {
+ "type": "string",
+ "default": "all"
+ },
+ "filterPoints": {
+ "type": "number",
+ "default": 5
+ },
+ "autoReload": {
+ "type": "boolean",
+ "default": false
+ },
+ "debug": {
+ "type": "boolean",
+ "default": false
+ },
+ "dateRange": {
+ "type": "object",
+ "default": { "from": "", "to": "" }
+ },
+ "gpx": {
+ "type": "array",
+ "default": []
+ },
+ "mapOverlays": {
+ "type": "array",
+ "default": []
+ },
+ "enablePanning": {
+ "type": "boolean",
+ "default": true
+ },
+ "scrollWheelZoom": {
+ "type": "boolean",
+ "default": false
+ },
+ "locateButton": {
+ "type": "boolean",
+ "default": false
+ },
+ "fullscreenButton": {
+ "type": "boolean",
+ "default": true
+ },
+ "navigationButtons": {
+ "type": "object",
+ "default": {
+ "enabled": true,
+ "allPoints": true,
+ "latestPoint": true,
+ "gpxTracks": true
+ }
+ }
+ },
+ "editorScript": "file:./index.js",
+ "render": "file:../../public/render-block.php"
+}
diff --git a/src/spotmap/components/MapsToolbarGroup.jsx b/src/spotmap/components/MapsToolbarGroup.jsx
new file mode 100644
index 0000000..10878b5
--- /dev/null
+++ b/src/spotmap/components/MapsToolbarGroup.jsx
@@ -0,0 +1,135 @@
+import {
+ CheckboxControl,
+ Dropdown,
+ Flex,
+ FlexItem,
+ ToolbarButton,
+ ToolbarGroup,
+} from '@wordpress/components';
+import { __ } from '@wordpress/i18n';
+
+const MAP_ICON = (
+
+
+
+);
+
+/**
+ * Toolbar group for selecting tile layers and overlays.
+ *
+ * @param {Object} props
+ * @param {string[]} props.maps Selected base layer keys.
+ * @param {string[]} props.mapOverlays Selected overlay keys.
+ * @param {Function} props.onChangeMaps Called with new maps array.
+ * @param {Function} props.onChangeOverlays Called with new overlays array.
+ */
+export default function MapsToolbarGroup( {
+ maps,
+ mapOverlays,
+ onChangeMaps,
+ onChangeOverlays,
+} ) {
+ const availableMaps = window.spotmapjsobj?.maps
+ ? Object.keys( window.spotmapjsobj.maps )
+ : [];
+ const availableOverlays = window.spotmapjsobj?.overlays
+ ? Object.keys( window.spotmapjsobj.overlays )
+ : [];
+
+ const toggleMap = ( mapKey, checked ) => {
+ const next = checked
+ ? [ ...maps, mapKey ]
+ : maps.filter( ( m ) => m !== mapKey );
+ onChangeMaps( next );
+ };
+
+ const toggleOverlay = ( overlayKey, checked ) => {
+ const current = mapOverlays || [];
+ const next = checked
+ ? [ ...current, overlayKey ]
+ : current.filter( ( o ) => o !== overlayKey );
+ onChangeOverlays( next );
+ };
+
+ return (
+
+ (
+
+ { __( 'Maps' ) }
+
+ ) }
+ renderContent={ () => (
+
+ { availableMaps.length === 0 && (
+
{ __( 'No maps available.' ) }
+ ) }
+
+ { availableMaps.map( ( mapKey ) => (
+
+
+ toggleMap( mapKey, checked )
+ }
+ />
+
+ ) ) }
+
+ { availableOverlays.length > 0 && (
+ <>
+
+
+ { availableOverlays.map( ( overlayKey ) => (
+
+
+ toggleOverlay(
+ overlayKey,
+ checked
+ )
+ }
+ />
+
+ ) ) }
+
+ >
+ ) }
+
+ ) }
+ />
+
+ );
+}
diff --git a/src/spotmap/components/TimeToolbarGroup.jsx b/src/spotmap/components/TimeToolbarGroup.jsx
new file mode 100644
index 0000000..5ed8319
--- /dev/null
+++ b/src/spotmap/components/TimeToolbarGroup.jsx
@@ -0,0 +1,191 @@
+import {
+ Button,
+ DateTimePicker,
+ Dropdown,
+ SelectControl,
+ ToolbarButton,
+ ToolbarGroup,
+} from '@wordpress/components';
+import { calendar } from '@wordpress/icons';
+import { __ } from '@wordpress/i18n';
+
+const DATE_PRESETS_FROM = [
+ { label: "don't filter", value: '' },
+ { label: 'last week', value: 'last-1-week' },
+ { label: 'last 10 days', value: 'last-10-days' },
+ { label: 'last 2 weeks', value: 'last-2-weeks' },
+ { label: 'last month', value: 'last-1-month' },
+ { label: 'last year', value: 'last-1-year' },
+ { label: 'a specific date', value: 'specific' },
+];
+
+const DATE_PRESETS_TO = [
+ { label: "don't filter", value: '' },
+ { label: 'last 30 minutes', value: 'last-30-minutes' },
+ { label: 'last hour', value: 'last-1-hour' },
+ { label: 'last 2 hours', value: 'last-2-hour' },
+ { label: 'last day', value: 'last-1-day' },
+ { label: 'a specific date', value: 'specific' },
+];
+
+const formatDateLabel = ( value ) => {
+ try {
+ const d = new Date( value );
+ return isNaN( d ) ? value : d.toLocaleString();
+ } catch {
+ return value;
+ }
+};
+
+const buildDateOptions = ( value, presets ) =>
+ value &&
+ ! presets.find( ( o ) => o.value === value ) &&
+ value !== 'specific'
+ ? [ ...presets, { label: formatDateLabel( value ), value } ]
+ : presets;
+
+/**
+ * Toolbar group for selecting the date range filter.
+ *
+ * @param {Object} props
+ * @param {Object} props.dateRange { from: string, to: string }
+ * @param {Function} props.onChangeDateRange Called with new { from, to } object.
+ */
+export default function TimeToolbarGroup( { dateRange, onChangeDateRange } ) {
+ const fromValue = dateRange?.from || '';
+ const toValue = dateRange?.to || '';
+
+ const isCustomFrom =
+ fromValue === 'specific' ||
+ ( fromValue &&
+ ! DATE_PRESETS_FROM.find( ( o ) => o.value === fromValue ) );
+ const isCustomTo =
+ toValue === 'specific' ||
+ ( toValue && ! DATE_PRESETS_TO.find( ( o ) => o.value === toValue ) );
+
+ return (
+
+ (
+
+ { __( 'Time' ) }
+
+ ) }
+ renderContent={ () => (
+
+
+ onChangeDateRange( {
+ ...dateRange,
+ from: value,
+ } )
+ }
+ />
+ { isCustomFrom && (
+ (
+
+ { fromValue !== 'specific'
+ ? formatDateLabel( fromValue )
+ : __( 'Pick date…' ) }
+
+ ) }
+ renderContent={ () => (
+
+ onChangeDateRange( {
+ ...dateRange,
+ from: date,
+ } )
+ }
+ />
+ ) }
+ />
+ ) }
+
+ onChangeDateRange( {
+ ...dateRange,
+ to: value,
+ } )
+ }
+ />
+ { isCustomTo && (
+ (
+
+ { toValue !== 'specific'
+ ? formatDateLabel( toValue )
+ : __( 'Pick date…' ) }
+
+ ) }
+ renderContent={ () => (
+
+ onChangeDateRange( {
+ ...dateRange,
+ to: date,
+ } )
+ }
+ />
+ ) }
+ />
+ ) }
+
+ ) }
+ />
+
+ );
+}
diff --git a/src/spotmap/edit.jsx b/src/spotmap/edit.jsx
new file mode 100644
index 0000000..56750d4
--- /dev/null
+++ b/src/spotmap/edit.jsx
@@ -0,0 +1,1236 @@
+import { useEffect, useRef, useState } from '@wordpress/element';
+import {
+ BlockControls,
+ InspectorAdvancedControls,
+ MediaUpload,
+ useBlockProps,
+} from '@wordpress/block-editor';
+import {
+ TextControl,
+ ToggleControl,
+ SelectControl,
+ ColorPalette,
+ Button,
+ RangeControl,
+ Dropdown,
+ Flex,
+ FlexItem,
+ ToolbarGroup,
+ ToolbarButton,
+ CheckboxControl,
+ Modal,
+ Popover,
+ __experimentalUnitControl as UnitControl,
+ ExternalLink,
+} from '@wordpress/components';
+import { brush, settings, upload, trash } from '@wordpress/icons';
+import { uploadMedia } from '@wordpress/media-utils';
+import { __ } from '@wordpress/i18n';
+import MapsToolbarGroup from './components/MapsToolbarGroup';
+import TimeToolbarGroup from './components/TimeToolbarGroup';
+
+const COLORS = [
+ { name: 'black', color: 'black' },
+ { name: 'blue', color: 'blue' },
+ { name: 'gold', color: 'gold' },
+ { name: 'green', color: 'green' },
+ { name: 'grey', color: 'grey' },
+ { name: 'red', color: 'red' },
+ { name: 'violet', color: 'violet' },
+ { name: 'yellow', color: 'yellow' },
+];
+
+// Satellite icon (inline SVG)
+const SATELLITE_ICON = (
+
+
+
+
+
+
+
+);
+
+const DEFAULT_FEED_STYLE = {
+ color: 'blue',
+ splitLines: 8,
+ lineWidth: 2,
+ lineOpacity: 1.0,
+ visible: true,
+};
+
+// Toggle with a flyout sub-popover that reveals on hover.
+function NavigationButtonsControl( { value, onChange } ) {
+ const [ open, setOpen ] = useState( false );
+ const anchorRef = useRef( null );
+ const closeTimer = useRef( null );
+ const update = ( key, v ) => onChange( { ...value, [ key ]: v } );
+
+ const scheduleClose = () => {
+ closeTimer.current = setTimeout( () => setOpen( false ), 150 );
+ };
+ const cancelClose = () => {
+ if ( closeTimer.current ) {
+ clearTimeout( closeTimer.current );
+ }
+ };
+
+ return (
+ {
+ cancelClose();
+ setOpen( true );
+ } }
+ onMouseLeave={ scheduleClose }
+ >
+
update( 'enabled', v ) }
+ />
+ { open && (
+ setOpen( false ) }
+ >
+
+ update( 'allPoints', v ) }
+ />
+ update( 'latestPoint', v ) }
+ />
+ update( 'gpxTracks', v ) }
+ />
+
+
+ ) }
+
+ );
+}
+
+const GPX_PAGE_SIZE = 10;
+
+// GPX manager modal — central hub for managing GPX tracks.
+function GpxManagerModal( {
+ gpx,
+ onAdd,
+ onUpload,
+ onRemoveAll,
+ onRemoveOne,
+ setAttributes,
+ onClose,
+} ) {
+ const uploadInputRef = useRef( null );
+ const [ page, setPage ] = useState( 0 );
+ const [ isDragging, setIsDragging ] = useState( false );
+ const [ styleTarget, setStyleTarget ] = useState( null );
+ const dragCounter = useRef( 0 );
+
+ const updateGpxProp = ( key, value ) => {
+ const updated =
+ styleTarget === 'all'
+ ? gpx.map( ( t ) => ( { ...t, [ key ]: value } ) )
+ : gpx.map( ( t, i ) =>
+ i === styleTarget ? { ...t, [ key ]: value } : t
+ );
+ setAttributes( { gpx: updated } );
+ };
+
+ const totalPages = Math.ceil( gpx.length / GPX_PAGE_SIZE );
+ // Clamp page when tracks are removed and current page becomes empty.
+ const safePage = Math.min( page, Math.max( 0, totalPages - 1 ) );
+ const pageStart = safePage * GPX_PAGE_SIZE;
+ const pageTracks = gpx.slice( pageStart, pageStart + GPX_PAGE_SIZE );
+
+ const uploadFiles = ( files ) => {
+ if ( ! files || ! files.length ) {
+ return;
+ }
+ uploadMedia( {
+ filesList: files,
+ allowedTypes: [ 'text/xml' ],
+ onFileChange: ( uploaded ) => {
+ onUpload( uploaded.filter( ( f ) => ! f.errorCode ) );
+ },
+ onError: () => {},
+ } );
+ };
+
+ const handleFileChange = ( e ) => {
+ uploadFiles( e.target.files );
+ // Reset input so same file can be re-uploaded
+ e.target.value = '';
+ };
+
+ const handleDragEnter = ( e ) => {
+ e.preventDefault();
+ dragCounter.current++;
+ setIsDragging( true );
+ };
+
+ const handleDragLeave = ( e ) => {
+ e.preventDefault();
+ dragCounter.current--;
+ if ( dragCounter.current === 0 ) {
+ setIsDragging( false );
+ }
+ };
+
+ const handleDragOver = ( e ) => {
+ e.preventDefault();
+ };
+
+ const handleDrop = ( e ) => {
+ e.preventDefault();
+ dragCounter.current = 0;
+ setIsDragging( false );
+ uploadFiles( e.dataTransfer.files );
+ };
+
+ // Resolve style target values for the inline style panel.
+ const styleTrack = styleTarget === 'all' ? gpx[ 0 ] : gpx[ styleTarget ];
+
+ return (
+ setStyleTarget( null ) : onClose
+ }
+ size="medium"
+ >
+ { styleTarget !== null ? (
+
+
setStyleTarget( null ) }
+ style={ { alignSelf: 'flex-start' } }
+ >
+ { __( 'Back to tracks' ) }
+
+
+ { styleTarget === 'all'
+ ? gpx.map( ( t ) => t.title ).join( ', ' )
+ : styleTrack?.title }
+
+
+
+ { __( 'Color' ) }
+
+
updateGpxProp( 'color', v ) }
+ disableCustomColors={ false }
+ clearable={ false }
+ />
+
+
updateGpxProp( 'visible', v ) }
+ help={ __(
+ 'Uncheck to hide this track without removing it'
+ ) }
+ />
+ updateGpxProp( 'download', v ) }
+ help={ __(
+ 'Show a download icon in the layer control and popup'
+ ) }
+ />
+
+ ) : (
+
+ { isDragging && (
+
+
+ { __( 'Drop GPX files here' ) }
+
+
+ ) }
+
+ { /* Top action bar */ }
+
+ uploadInputRef.current?.click() }
+ >
+ { __( 'Upload' ) }
+
+ t.id ) }
+ onSelect={ ( selection ) => {
+ setPage( 0 );
+ onAdd( selection );
+ } }
+ render={ ( { open } ) => (
+
+ { __( 'Media Library' ) }
+
+ ) }
+ />
+
+ { gpx.length > 0 && (
+ <>
+ setStyleTarget( 'all' ) }
+ />
+ {
+ onRemoveAll();
+ onClose();
+ } }
+ />
+ >
+ ) }
+
+
+ { /* Track table */ }
+ { gpx.length > 0 && (
+ <>
+
+
+ { pageTracks.map( ( track, localIdx ) => {
+ const globalIdx = pageStart + localIdx;
+ return (
+
+
+
+
+ setStyleTarget(
+ globalIdx
+ )
+ }
+ />
+
+
+
+
+ { track.title }
+
+
+
+ onRemoveOne(
+ globalIdx
+ )
+ }
+ />
+
+
+ );
+ } ) }
+
+
+ { totalPages > 1 && (
+
+
+ setPage( safePage - 1 )
+ }
+ >
+ { __( '← Prev' ) }
+
+
+ { pageStart + 1 }–
+ { Math.min(
+ pageStart + GPX_PAGE_SIZE,
+ gpx.length
+ ) }{ ' ' }
+ { __( 'of' ) } { gpx.length }
+
+ = totalPages - 1 }
+ onClick={ () =>
+ setPage( safePage + 1 )
+ }
+ >
+ { __( 'Next →' ) }
+
+
+ ) }
+ >
+ ) }
+
+ { gpx.length === 0 && (
+
+ { __(
+ 'No GPX tracks selected. Use Library or Upload to add tracks.'
+ ) }
+
+ ) }
+
+ ) }
+
+ );
+}
+
+// Feed styling modal opened via the brush icon in the Feeds dropdown.
+function FeedStyleModal( { feed, style, onUpdate, onClose } ) {
+ const s = { ...DEFAULT_FEED_STYLE, ...style };
+ return (
+
+
+
+
+ { __( 'Color' ) }
+
+
onUpdate( 'color', value ) }
+ disableCustomColors={ false }
+ clearable={ false }
+ />
+
+
{
+ const n = parseInt( value, 10 );
+ onUpdate( 'splitLines', n > 0 ? n : 0 );
+ } }
+ help={ __(
+ 'Draws a line connecting GPS points. If the gap between two consecutive points exceeds this many hours, a new line segment starts. Leave empty or 0 to draw no line.'
+ ) }
+ />
+ onUpdate( 'lineWidth', value ) }
+ min={ 1 }
+ max={ 6 }
+ step={ 1 }
+ />
+ onUpdate( 'lineOpacity', value ) }
+ min={ 0.2 }
+ max={ 1.0 }
+ step={ 0.1 }
+ />
+ onUpdate( 'lastPoint', value ) }
+ help={ __(
+ 'Highlight the latest point with a large circle marker'
+ ) }
+ />
+ onUpdate( 'visible', value ) }
+ help={ __(
+ 'Uncheck to hide this feed without removing it'
+ ) }
+ />
+
+
+ );
+}
+
+export default function Edit( { attributes, setAttributes } ) {
+ const mapRef = useRef( null );
+ const spotmapRef = useRef( null );
+ const [ mapId ] = useState(
+ () => 'spotmap-editor-' + Math.random().toString( 36 ).slice( 2, 10 )
+ );
+
+ // Feed style modal: null = closed, string = feed name open
+ const [ feedStyleModal, setFeedStyleModal ] = useState( null );
+ // GPX manager modal
+ const [ gpxManagerOpen, setGpxManagerOpen ] = useState( false );
+
+ // Inject Leaflet CSS into the editor document (handles iframe rendering)
+ useEffect( () => {
+ const el = mapRef.current;
+ if ( ! el ) {
+ return;
+ }
+ const doc = el.ownerDocument;
+ const baseUrl = window.spotmapjsobj?.url || '';
+ const cssFiles = [
+ 'leaflet/leaflet.css',
+ 'leafletfullscreen/leaflet.fullscreen.css',
+ 'leaflet-easy-button/easy-button.css',
+ 'leaflet-beautify-marker/leaflet-beautify-marker-icon.css',
+ 'css/custom.css',
+ '../includes/css/font-awesome-all.min.css',
+ ];
+ const links = cssFiles
+ .filter(
+ ( file ) =>
+ ! doc.querySelector( `link[href="${ baseUrl + file }"]` )
+ )
+ .map( ( file ) => {
+ const link = doc.createElement( 'link' );
+ link.rel = 'stylesheet';
+ link.href = baseUrl + file;
+ doc.head.appendChild( link );
+ return link;
+ } );
+ return () => links.forEach( ( l ) => l.remove() );
+ }, [] );
+
+ // On first insert (when styles is empty, meaning never initialized), populate from admin-configured defaults.
+ // We intentionally do NOT re-trigger when feeds is empty, so the user can choose to show no feeds.
+ useEffect( () => {
+ if (
+ ( Object.keys( attributes.styles ).length === 0 ||
+ attributes.maps.length === 0 ) &&
+ window.spotmapjsobj?.feeds
+ ) {
+ const feedNames = Array.isArray( window.spotmapjsobj.feeds )
+ ? window.spotmapjsobj.feeds
+ : Object.keys( window.spotmapjsobj.feeds );
+
+ const defaultStyles = {};
+ feedNames.forEach( ( name ) => {
+ defaultStyles[ name ] = { ...DEFAULT_FEED_STYLE };
+ } );
+
+ const dv = window.spotmapjsobj?.defaultValues ?? {};
+ const defaultMaps = dv.maps
+ ? dv.maps
+ .split( ',' )
+ .map( ( m ) => m.trim() )
+ .filter( Boolean )
+ : attributes.maps;
+ const defaultHeight = dv.height
+ ? parseInt( dv.height, 10 )
+ : attributes.height;
+ const defaultMapcenter = dv.mapcenter || attributes.mapcenter;
+ const defaultFilterPoints = dv[ 'filter-points' ]
+ ? parseInt( dv[ 'filter-points' ], 10 )
+ : attributes.filterPoints;
+
+ setAttributes( {
+ feeds: feedNames,
+ styles: defaultStyles,
+ maps: defaultMaps,
+ height: defaultHeight,
+ mapcenter: defaultMapcenter,
+ filterPoints: defaultFilterPoints,
+ } );
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [
+ attributes.maps.length,
+ attributes.filterPoints,
+ attributes.height,
+ attributes.mapcenter,
+ setAttributes,
+ ] );
+
+ // Initialize / update the Leaflet map via the existing Spotmap class
+ useEffect( () => {
+ const container = mapRef.current;
+ if ( ! container || typeof window.Spotmap === 'undefined' ) {
+ return;
+ }
+
+ const options = {
+ ...attributes,
+ mapId,
+ mapElement: container,
+ enablePanning: false, // always disabled in editor preview
+ };
+
+ if ( spotmapRef.current ) {
+ spotmapRef.current._destroyed = true;
+ if ( spotmapRef.current.map?.remove ) {
+ spotmapRef.current.map.remove();
+ }
+ spotmapRef.current = null;
+ }
+
+ let timer;
+ try {
+ const sm = new window.Spotmap( options );
+ spotmapRef.current = sm;
+ sm.initMap();
+ timer = setTimeout( () => {
+ if ( ! sm._destroyed ) {
+ sm.map?.invalidateSize?.();
+ }
+ }, 200 );
+ } catch ( e ) {
+ console.error( 'Spotmap init error:', e );
+ }
+
+ return () => {
+ clearTimeout( timer );
+ if ( spotmapRef.current ) {
+ spotmapRef.current._destroyed = true;
+ if ( spotmapRef.current.map?.remove ) {
+ spotmapRef.current.map.remove();
+ }
+ spotmapRef.current = null;
+ }
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [
+ mapId,
+ attributes.feeds,
+ attributes.styles,
+ attributes.mapcenter,
+ attributes.filterPoints,
+ attributes.dateRange,
+ attributes.gpx,
+ attributes.debug,
+ attributes.fullscreenButton,
+ ] );
+
+ // When only the map tile layer selection changes, swap layers in-place
+ // instead of rebuilding the entire map and re-fetching data.
+ const mapsEffectMounted = useRef( false );
+ const prevMapsRef = useRef( attributes.maps );
+ useEffect( () => {
+ if ( ! mapsEffectMounted.current ) {
+ mapsEffectMounted.current = true;
+ return;
+ }
+ if ( spotmapRef.current ) {
+ const prevMaps = prevMapsRef.current;
+ const newlyAdded = attributes.maps.find(
+ ( m ) => ! prevMaps.includes( m )
+ );
+ prevMapsRef.current = attributes.maps;
+ spotmapRef.current.updateMaps( attributes.maps, newlyAdded );
+ }
+ }, [ attributes.maps ] );
+
+ const autoReloadEffectMounted = useRef( false );
+ useEffect( () => {
+ if ( ! autoReloadEffectMounted.current ) {
+ autoReloadEffectMounted.current = true;
+ return;
+ }
+ if ( spotmapRef.current ) {
+ spotmapRef.current.updateAutoReload(
+ attributes.autoReload ?? false
+ );
+ }
+ }, [ attributes.autoReload ] );
+
+ const buttonsEffectMounted = useRef( false );
+ useEffect( () => {
+ if ( ! buttonsEffectMounted.current ) {
+ buttonsEffectMounted.current = true;
+ return;
+ }
+ if ( spotmapRef.current ) {
+ spotmapRef.current.updateButtons(
+ attributes.locateButton,
+ attributes.navigationButtons
+ );
+ }
+ }, [ attributes.locateButton, attributes.navigationButtons ] );
+
+ const overlaysEffectMounted = useRef( false );
+ useEffect( () => {
+ if ( ! overlaysEffectMounted.current ) {
+ overlaysEffectMounted.current = true;
+ return;
+ }
+ if ( spotmapRef.current ) {
+ spotmapRef.current.updateOverlays( attributes.mapOverlays ?? [] );
+ }
+ }, [ attributes.mapOverlays ] );
+
+ const heightEffectMounted = useRef( false );
+ useEffect( () => {
+ if ( ! heightEffectMounted.current ) {
+ heightEffectMounted.current = true;
+ return;
+ }
+ if ( spotmapRef.current ) {
+ spotmapRef.current.updateHeight( attributes.height );
+ }
+ }, [ attributes.height ] );
+
+ const scrollWheelZoomEffectMounted = useRef( false );
+ useEffect( () => {
+ if ( ! scrollWheelZoomEffectMounted.current ) {
+ scrollWheelZoomEffectMounted.current = true;
+ return;
+ }
+ if ( spotmapRef.current ) {
+ spotmapRef.current.updateScrollWheelZoom(
+ attributes.scrollWheelZoom ?? false
+ );
+ }
+ }, [ attributes.scrollWheelZoom ] );
+
+ let availableFeeds = [];
+ if ( window.spotmapjsobj?.feeds ) {
+ availableFeeds = Array.isArray( window.spotmapjsobj.feeds )
+ ? window.spotmapjsobj.feeds
+ : Object.keys( window.spotmapjsobj.feeds );
+ }
+
+ const updateStyle = ( feed, key, value ) => {
+ const newStyles = { ...attributes.styles };
+ newStyles[ feed ] = { ...( newStyles[ feed ] || {} ), [ key ]: value };
+ setAttributes( { styles: newStyles } );
+ };
+
+ const toggleFeed = ( feed, checked ) => {
+ const next = checked
+ ? [ ...attributes.feeds, feed ]
+ : attributes.feeds.filter( ( f ) => f !== feed );
+ const newStyles = { ...attributes.styles };
+ if ( checked && ! newStyles[ feed ] ) {
+ newStyles[ feed ] = { ...DEFAULT_FEED_STYLE };
+ }
+ setAttributes( { feeds: next, styles: newStyles } );
+ };
+
+ const mergeGpxTracks = ( newTracks, getTitle ) => {
+ const existing = attributes.gpx;
+ const existingIds = new Set( existing.map( ( t ) => t.id ) );
+ const merged = [
+ ...existing,
+ ...newTracks
+ .filter( ( t ) => t.id && ! existingIds.has( t.id ) )
+ .map( ( t ) => ( {
+ id: t.id,
+ url: t.url,
+ title: getTitle( t ),
+ color: existing[ 0 ]?.color || 'gold',
+ } ) ),
+ ];
+ setAttributes( { gpx: merged } );
+ };
+
+ return (
+ <>
+ { gpxManagerOpen && (
+
+ mergeGpxTracks( selection, ( t ) => t.title )
+ }
+ onUpload={ ( uploaded ) =>
+ mergeGpxTracks(
+ uploaded,
+ ( t ) =>
+ t.title ||
+ t.filename ||
+ t.slug ||
+ String( t.id )
+ )
+ }
+ onRemoveAll={ () => setAttributes( { gpx: [] } ) }
+ onRemoveOne={ ( i ) => {
+ const next = attributes.gpx.filter(
+ ( _, idx ) => idx !== i
+ );
+ setAttributes( { gpx: next } );
+ } }
+ setAttributes={ setAttributes }
+ onClose={ () => setGpxManagerOpen( false ) }
+ />
+ ) }
+ { feedStyleModal && (
+
+ updateStyle( feedStyleModal, key, value )
+ }
+ onClose={ () => setFeedStyleModal( null ) }
+ />
+ ) }
+
+ { /* Block toolbar */ }
+
+ { /* Feeds */ }
+
+ (
+
+ { __( 'Feeds' ) }
+
+ ) }
+ renderContent={ ( { onClose } ) => (
+
+ { availableFeeds.length === 0 && (
+
+ { __(
+ 'No feeds yet — your map is feeling lonely!'
+ ) }
+
+ { __( 'Add a feed' ) }
+
+
+ ) }
+ { availableFeeds.map( ( feed ) => (
+
+
+
+ toggleFeed( feed, checked )
+ }
+ />
+
+ {
+ onClose();
+ setFeedStyleModal( feed );
+ } }
+ />
+
+
+ ) ) }
+
+ ) }
+ />
+
+
+ { /* Maps */ }
+ setAttributes( { maps: next } ) }
+ onChangeOverlays={ ( next ) =>
+ setAttributes( { mapOverlays: next } )
+ }
+ />
+
+ { /* GPX — opens manager modal */ }
+
+ setGpxManagerOpen( true ) }
+ icon={ SATELLITE_ICON }
+ >
+ { __( 'GPX' ) }
+
+
+
+ { /* Time filter */ }
+
+ setAttributes( { dateRange: next } )
+ }
+ />
+
+ { /* Map settings */ }
+
+ (
+
+ { __( 'Settings' ) }
+
+ ) }
+ renderContent={ () => (
+
+
+ setAttributes( { mapcenter: value } )
+ }
+ />
+
+ setAttributes( { height: value } )
+ }
+ min={ 200 }
+ max={ 1200 }
+ step={ 50 }
+ />
+
+ setAttributes( {
+ enablePanning: value,
+ } )
+ }
+ />
+
+ setAttributes( {
+ scrollWheelZoom: value,
+ } )
+ }
+ />
+
+ setAttributes( {
+ filterPoints:
+ parseInt( value ) || 0,
+ } )
+ }
+ help={ __(
+ 'Hide points within this radius to reduce clutter'
+ ) }
+ />
+
+ setAttributes( { autoReload: value } )
+ }
+ help={ __(
+ 'Refresh map data every 30 seconds'
+ ) }
+ />
+
+ setAttributes( {
+ locateButton: value,
+ } )
+ }
+ />
+
+ setAttributes( {
+ fullscreenButton: value,
+ } )
+ }
+ />
+
+ setAttributes( {
+ navigationButtons: value,
+ } )
+ }
+ />
+
+ ) }
+ />
+
+
+
+ { /* Sidebar — Advanced only */ }
+
+ setAttributes( { debug: value } ) }
+ />
+
+
+ { /* Block preview */ }
+
+ >
+ );
+}
diff --git a/src/spotmap/index.js b/src/spotmap/index.js
new file mode 100644
index 0000000..1016d3f
--- /dev/null
+++ b/src/spotmap/index.js
@@ -0,0 +1,8 @@
+import { registerBlockType } from '@wordpress/blocks';
+import metadata from './block.json';
+import Edit from './edit';
+
+registerBlockType( metadata.name, {
+ edit: Edit,
+ save: () => null,
+} );
diff --git a/tests/SpotmapActivatorTest.php b/tests/SpotmapActivatorTest.php
new file mode 100644
index 0000000..173b6ae
--- /dev/null
+++ b/tests/SpotmapActivatorTest.php
@@ -0,0 +1,85 @@
+ 150,
+ 'display' => '2.5 Minutes',
+ ];
+ return $schedules;
+ } );
+ }
+
+ public function test_activate_creates_table(): void {
+ global $wpdb;
+
+ Spotmap_Activator::activate();
+
+ $this->assertSame(
+ "{$wpdb->prefix}spotmap_points",
+ $wpdb->get_var( "SHOW TABLES LIKE '{$wpdb->prefix}spotmap_points'" ),
+ 'Table must exist after activate()'
+ );
+ }
+
+ public function test_activate_creates_table_with_full_schema(): void {
+ global $wpdb;
+
+ Spotmap_Activator::activate();
+
+ $columns = $wpdb->get_col(
+ "SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '{$wpdb->prefix}spotmap_points'"
+ );
+
+ foreach ( [ 'id', 'type', 'time', 'latitude', 'longitude', 'altitude',
+ 'battery_status', 'message', 'custom_message', 'feed_name',
+ 'feed_id', 'model', 'device_name', 'local_timezone' ] as $col ) {
+ $this->assertContains( $col, $columns, "Column '$col' missing from table" );
+ }
+ }
+
+ public function test_activate_seeds_plugin_options(): void {
+ Spotmap_Activator::activate();
+
+ $this->assertNotFalse( get_option( Spotmap_Options::OPTION_FEEDS ) );
+ $this->assertNotFalse( get_option( Spotmap_Options::OPTION_MARKER ) );
+ $this->assertNotFalse( get_option( Spotmap_Options::OPTION_DEFAULT_VALUES ) );
+ $this->assertNotFalse( get_option( Spotmap_Options::OPTION_API_TOKENS ) );
+ $this->assertNotFalse( get_option( Spotmap_Options::OPTION_CUSTOM_MESSAGES ) );
+ }
+
+ public function test_activate_schedules_cron_hooks(): void {
+ wp_clear_scheduled_hook( 'spotmap_api_crawler_hook' );
+ wp_clear_scheduled_hook( 'spotmap_get_timezone_hook' );
+
+ Spotmap_Activator::activate();
+
+ $this->assertNotFalse( wp_next_scheduled( 'spotmap_api_crawler_hook' ) );
+ $this->assertNotFalse( wp_next_scheduled( 'spotmap_get_timezone_hook' ) );
+ }
+
+ public function test_activate_is_idempotent(): void {
+ wp_clear_scheduled_hook( 'spotmap_api_crawler_hook' );
+
+ Spotmap_Activator::activate();
+ Spotmap_Activator::activate();
+
+ // Guard against scheduling the same event twice.
+ $crawler_count = 0;
+ foreach ( _get_cron_array() as $events ) {
+ if ( isset( $events['spotmap_api_crawler_hook'] ) ) {
+ $crawler_count++;
+ }
+ }
+ $this->assertSame( 1, $crawler_count, 'spotmap_api_crawler_hook must not be scheduled more than once' );
+ }
+}
diff --git a/tests/SpotmapAdminTest.php b/tests/SpotmapAdminTest.php
new file mode 100644
index 0000000..79af832
--- /dev/null
+++ b/tests/SpotmapAdminTest.php
@@ -0,0 +1,112 @@
+getProperty( 'cache' );
+ }
+
+ protected function setUp(): void {
+ parent::setUp();
+ self::$cache_prop->setValue( null, [] );
+ }
+
+ // --- add_cron_schedule ---
+
+ public function test_add_cron_schedule_adds_twohalf_min(): void {
+ $result = self::$admin->add_cron_schedule( [] );
+ $this->assertArrayHasKey( 'twohalf_min', $result );
+ }
+
+ public function test_add_cron_schedule_interval_is_150_seconds(): void {
+ $result = self::$admin->add_cron_schedule( [] );
+ $this->assertSame( 150, $result['twohalf_min']['interval'] );
+ }
+
+ public function test_add_cron_schedule_preserves_existing_schedules(): void {
+ $existing = [ 'hourly' => [ 'interval' => 3600, 'display' => 'Once Hourly' ] ];
+ $result = self::$admin->add_cron_schedule( $existing );
+ $this->assertArrayHasKey( 'hourly', $result );
+ }
+
+ // --- allow_gpx_upload ---
+
+ public function test_allow_gpx_upload_adds_gpx_mime_type(): void {
+ $result = self::$admin->allow_gpx_upload( [] );
+ $this->assertArrayHasKey( 'gpx', $result );
+ $this->assertSame( 'text/xml', $result['gpx'] );
+ }
+
+ public function test_allow_gpx_upload_preserves_existing_types(): void {
+ $existing = [ 'jpg' => 'image/jpeg' ];
+ $result = self::$admin->allow_gpx_upload( $existing );
+ $this->assertArrayHasKey( 'jpg', $result );
+ }
+
+ // --- add_link_plugin_overview ---
+
+ public function test_add_link_plugin_overview_adds_settings_link(): void {
+ $links = self::$admin->add_link_plugin_overview( [] );
+ $combined = implode( ' ', $links );
+ $this->assertStringContainsString( 'Settings', $combined );
+ }
+
+ public function test_add_link_plugin_overview_adds_support_link(): void {
+ $links = self::$admin->add_link_plugin_overview( [] );
+ $combined = implode( ' ', $links );
+ $this->assertStringContainsString( 'Get Support', $combined );
+ }
+
+ // --- get_overlays ---
+
+ public function test_get_overlays_returns_array(): void {
+ $overlays = self::$admin->get_overlays();
+ $this->assertIsArray( $overlays );
+ }
+
+ // --- get_maps ---
+
+ public function test_get_maps_returns_array(): void {
+ $maps = self::$admin->get_maps();
+ $this->assertIsArray( $maps );
+ }
+
+ public function test_get_maps_always_includes_openstreetmap(): void {
+ $maps = self::$admin->get_maps();
+ $this->assertArrayHasKey( 'openstreetmap', $maps );
+ }
+
+ public function test_get_maps_excludes_maps_requiring_missing_token(): void {
+ // Ensure mapbox token is not set.
+ update_option( Spotmap_Options::OPTION_API_TOKENS, [ 'mapbox' => '' ] );
+
+ $maps = self::$admin->get_maps();
+
+ // No map should reference a mapboxToken placeholder anymore.
+ foreach ( $maps as $map ) {
+ $this->assertFalse(
+ isset( $map['options']['mapboxToken'] ) && $map['options']['mapboxToken'] === '',
+ 'Map with empty mapboxToken must be excluded'
+ );
+ }
+ }
+
+ public function test_get_maps_injects_token_when_set(): void {
+ update_option( Spotmap_Options::OPTION_API_TOKENS, [ 'mapbox' => 'pk.test123' ] );
+
+ $maps = self::$admin->get_maps();
+
+ $mapbox_maps = array_filter( $maps, fn( $m ) => isset( $m['options']['mapboxToken'] ) );
+ foreach ( $mapbox_maps as $map ) {
+ $this->assertSame( 'pk.test123', $map['options']['mapboxToken'] );
+ }
+ }
+
+}
diff --git a/tests/SpotmapDatabaseTest.php b/tests/SpotmapDatabaseTest.php
new file mode 100644
index 0000000..188986b
--- /dev/null
+++ b/tests/SpotmapDatabaseTest.php
@@ -0,0 +1,453 @@
+ 'test-feed',
+ 'feedId' => 'feed-001',
+ 'messageType' => 'OK',
+ 'unixTime' => 1700000000,
+ 'latitude' => 47.3769,
+ 'longitude' => 8.5417,
+ 'modelId' => 'SPOT-X',
+ 'messengerName' => 'Test Device',
+ 'messageContent' => '',
+ ], $overrides );
+ }
+
+ // --- insert_point ---
+
+ public function test_insert_point_returns_success(): void {
+ $result = self::$db->insert_point( $this->make_point() );
+ $this->assertSame( 1, $result );
+ }
+
+ public function test_insert_point_with_unixtime_1_is_ignored(): void {
+ $result = self::$db->insert_point( $this->make_point( [ 'unixTime' => 1 ] ) );
+ $this->assertSame( 0, $result );
+ }
+
+ public function test_insert_point_stores_correct_values(): void {
+ self::$db->insert_point( $this->make_point( [
+ 'feedName' => 'alpine-route',
+ 'messageType' => 'HELP',
+ 'latitude' => 46.9481,
+ 'longitude' => 7.4474,
+ ] ) );
+
+ global $wpdb;
+ $row = $wpdb->get_row( "SELECT * FROM {$wpdb->prefix}spotmap_points ORDER BY id DESC LIMIT 1" );
+
+ $this->assertSame( 'alpine-route', $row->feed_name );
+ $this->assertSame( 'HELP', $row->type );
+ $this->assertEqualsWithDelta( 46.9481, (float) $row->latitude, 0.0001 );
+ $this->assertEqualsWithDelta( 7.4474, (float) $row->longitude, 0.0001 );
+ }
+
+ // --- get_all_feednames ---
+
+ public function test_get_all_feednames_returns_inserted_feeds(): void {
+ self::$db->insert_point( $this->make_point( [ 'feedName' => 'feed-a' ] ) );
+ self::$db->insert_point( $this->make_point( [ 'feedName' => 'feed-b' ] ) );
+
+ $names = self::$db->get_all_feednames();
+
+ $this->assertContains( 'feed-a', $names );
+ $this->assertContains( 'feed-b', $names );
+ }
+
+ public function test_get_all_feednames_deduplicates(): void {
+ self::$db->insert_point( $this->make_point( [ 'feedName' => 'solo-feed', 'unixTime' => 1700000001 ] ) );
+ self::$db->insert_point( $this->make_point( [ 'feedName' => 'solo-feed', 'unixTime' => 1700000002 ] ) );
+
+ $names = self::$db->get_all_feednames();
+
+ $this->assertSame( 1, count( array_keys( $names, 'solo-feed' ) ) );
+ }
+
+ // --- get_last_point ---
+
+ public function test_get_last_point_returns_most_recently_inserted(): void {
+ // Space > 10 min apart so deduplication does not skip the second insert.
+ self::$db->insert_point( $this->make_point( [ 'unixTime' => 1700000001 ] ) );
+ self::$db->insert_point( $this->make_point( [ 'unixTime' => 1700000001 + 601 ] ) );
+
+ $last = self::$db->get_last_point();
+
+ $this->assertSame( (string) ( 1700000001 + 601 ), $last->time );
+ }
+
+ // --- does_point_exist ---
+
+ public function test_does_point_exist_returns_true_after_insert(): void {
+ self::$db->insert_point( $this->make_point() );
+
+ global $wpdb;
+ $id = $wpdb->insert_id;
+
+ $this->assertTrue( self::$db->does_point_exist( $id ) );
+ }
+
+ public function test_does_point_exist_returns_false_for_nonexistent_id(): void {
+ $this->assertFalse( self::$db->does_point_exist( 999999 ) );
+ }
+
+ // --- get_points ---
+
+ public function test_get_points_filters_by_feed(): void {
+ self::$db->insert_point( $this->make_point( [ 'feedName' => 'feed-x', 'unixTime' => 1700000010 ] ) );
+ self::$db->insert_point( $this->make_point( [ 'feedName' => 'feed-y', 'unixTime' => 1700000011 ] ) );
+
+ $points = self::$db->get_points( [ 'feeds' => [ 'feed-x' ] ] );
+
+ $this->assertCount( 1, $points );
+ $this->assertSame( 'feed-x', $points[0]->feed_name );
+ }
+
+ public function test_get_points_filters_by_type(): void {
+ self::$db->insert_point( $this->make_point( [ 'messageType' => 'OK', 'unixTime' => 1700000020 ] ) );
+ self::$db->insert_point( $this->make_point( [ 'messageType' => 'HELP', 'unixTime' => 1700000021 ] ) );
+
+ $points = self::$db->get_points( [ 'type' => [ 'HELP' ] ] );
+
+ $this->assertCount( 1, $points );
+ $this->assertSame( 'HELP', $points[0]->type );
+ }
+
+ public function test_get_points_respects_limit(): void {
+ // Space > 10 min apart so deduplication does not skip any insert.
+ for ( $i = 0; $i < 5; $i++ ) {
+ self::$db->insert_point( $this->make_point( [ 'feedName' => 'limit-feed', 'unixTime' => 1700000030 + $i * 601 ] ) );
+ }
+
+ $points = self::$db->get_points( [ 'feeds' => [ 'limit-feed' ], 'limit' => 3 ] );
+
+ $this->assertCount( 3, $points );
+ }
+
+ public function test_get_points_returns_error_for_unknown_feed(): void {
+ $result = self::$db->get_points( [ 'feeds' => [ 'nonexistent-feed' ] ] );
+
+ $this->assertIsArray( $result );
+ $this->assertTrue( $result['error'] );
+ }
+
+ // --- rename_feed_name ---
+
+ public function test_rename_feed_name_updates_all_points(): void {
+ self::$db->insert_point( $this->make_point( [ 'feedName' => 'old-name', 'unixTime' => 1700000040 ] ) );
+ self::$db->insert_point( $this->make_point( [ 'feedName' => 'old-name', 'unixTime' => 1700000041 ] ) );
+
+ self::$db->rename_feed_name( 'old-name', 'new-name' );
+
+ $this->assertNotContains( 'old-name', self::$db->get_all_feednames() );
+ $this->assertContains( 'new-name', self::$db->get_all_feednames() );
+ }
+
+ // --- get_all_types ---
+
+ public function test_get_all_types_returns_inserted_types(): void {
+ self::$db->insert_point( $this->make_point( [ 'messageType' => 'OK', 'unixTime' => 1700000050 ] ) );
+ self::$db->insert_point( $this->make_point( [ 'messageType' => 'HELP', 'unixTime' => 1700000051 ] ) );
+
+ $types = self::$db->get_all_types();
+
+ $this->assertContains( 'OK', $types );
+ $this->assertContains( 'HELP', $types );
+ }
+
+ public function test_get_all_types_deduplicates(): void {
+ self::$db->insert_point( $this->make_point( [ 'messageType' => 'CUSTOM', 'unixTime' => 1700000060 ] ) );
+ self::$db->insert_point( $this->make_point( [ 'messageType' => 'CUSTOM', 'unixTime' => 1700000061 ] ) );
+
+ $types = self::$db->get_all_types();
+
+ $this->assertSame( 1, count( array_keys( $types, 'CUSTOM' ) ) );
+ }
+
+ // --- sanitize helpers (private static, accessed via reflection) ---
+
+ private function sanitize( string $method, mixed ...$args ): mixed {
+ $ref = new ReflectionMethod( Spotmap_Database::class, $method );
+ $ref->setAccessible( true );
+ return $ref->invoke( null, ...$args );
+ }
+
+ // sanitize_select
+
+ public function test_sanitize_select_passes_star(): void {
+ $this->assertSame( '*', $this->sanitize( 'sanitize_select', '*' ) );
+ }
+
+ public function test_sanitize_select_allows_valid_column(): void {
+ $this->assertSame( 'latitude', $this->sanitize( 'sanitize_select', 'latitude' ) );
+ }
+
+ public function test_sanitize_select_allows_multiple_valid_columns(): void {
+ $this->assertSame( 'latitude, longitude', $this->sanitize( 'sanitize_select', 'latitude, longitude' ) );
+ }
+
+ public function test_sanitize_select_drops_unknown_column(): void {
+ $this->assertSame( 'latitude', $this->sanitize( 'sanitize_select', 'latitude, evil_col' ) );
+ }
+
+ public function test_sanitize_select_falls_back_to_star_when_all_invalid(): void {
+ $this->assertSame( '*', $this->sanitize( 'sanitize_select', '1 UNION SELECT user_pass FROM wp_users' ) );
+ }
+
+ // sanitize_identifier
+
+ public function test_sanitize_identifier_returns_valid_column(): void {
+ $this->assertSame( 'feed_name', $this->sanitize( 'sanitize_identifier', 'feed_name' ) );
+ }
+
+ public function test_sanitize_identifier_trims_whitespace(): void {
+ $this->assertSame( 'time', $this->sanitize( 'sanitize_identifier', ' time ' ) );
+ }
+
+ public function test_sanitize_identifier_returns_null_for_unknown(): void {
+ $this->assertNull( $this->sanitize( 'sanitize_identifier', 'wp_users' ) );
+ }
+
+ public function test_sanitize_identifier_returns_null_for_injection_attempt(): void {
+ $this->assertNull( $this->sanitize( 'sanitize_identifier', "feed_name; DROP TABLE wp_spotmap_points" ) );
+ }
+
+ public function test_sanitize_identifier_returns_null_for_empty_string(): void {
+ $this->assertNull( $this->sanitize( 'sanitize_identifier', '' ) );
+ }
+
+ // sanitize_order
+
+ public function test_sanitize_order_single_column_no_direction(): void {
+ $this->assertSame( 'ORDER BY time', $this->sanitize( 'sanitize_order', 'time' ) );
+ }
+
+ public function test_sanitize_order_single_column_desc(): void {
+ $this->assertSame( 'ORDER BY time DESC', $this->sanitize( 'sanitize_order', 'time DESC' ) );
+ }
+
+ public function test_sanitize_order_single_column_asc(): void {
+ $this->assertSame( 'ORDER BY time ASC', $this->sanitize( 'sanitize_order', 'time ASC' ) );
+ }
+
+ public function test_sanitize_order_multiple_columns(): void {
+ $this->assertSame( 'ORDER BY feed_name, time', $this->sanitize( 'sanitize_order', 'feed_name, time' ) );
+ }
+
+ public function test_sanitize_order_multiple_columns_with_directions(): void {
+ $this->assertSame( 'ORDER BY feed_name ASC, time DESC', $this->sanitize( 'sanitize_order', 'feed_name ASC, time DESC' ) );
+ }
+
+ public function test_sanitize_order_drops_unknown_column(): void {
+ $this->assertSame( 'ORDER BY time DESC', $this->sanitize( 'sanitize_order', 'time DESC, evil_col ASC' ) );
+ }
+
+ public function test_sanitize_order_returns_empty_string_for_injection(): void {
+ $this->assertSame( '', $this->sanitize( 'sanitize_order', "1; DROP TABLE wp_users" ) );
+ }
+
+ public function test_sanitize_order_returns_empty_string_when_all_invalid(): void {
+ $this->assertSame( '', $this->sanitize( 'sanitize_order', 'not_a_col, also_bad' ) );
+ }
+
+ // get_points: injection resistance (integration)
+
+ public function test_get_points_select_injection_is_sanitized_and_query_succeeds(): void {
+ self::$db->insert_point( $this->make_point( [ 'feedName' => 'sanitize-test', 'unixTime' => 1700900001 ] ) );
+
+ $points = self::$db->get_points( [
+ 'feeds' => [ 'sanitize-test' ],
+ 'select' => '1 UNION SELECT user_pass,2,3 FROM wp_users-- ',
+ ] );
+
+ // Injection stripped → falls back to SELECT * → returns normal point objects
+ $this->assertIsArray( $points );
+ $this->assertArrayNotHasKey( 'error', $points );
+ $this->assertSame( 'sanitize-test', $points[0]->feed_name );
+ }
+
+ public function test_get_points_limit_injection_is_treated_as_integer(): void {
+ // Space > 10 min apart so deduplication does not skip any insert.
+ self::$db->insert_point( $this->make_point( [ 'feedName' => 'limit-test', 'unixTime' => 1700900010 ] ) );
+ self::$db->insert_point( $this->make_point( [ 'feedName' => 'limit-test', 'unixTime' => 1700900010 + 601 ] ) );
+ self::$db->insert_point( $this->make_point( [ 'feedName' => 'limit-test', 'unixTime' => 1700900010 + 1202 ] ) );
+
+ $points = self::$db->get_points( [
+ 'feeds' => [ 'limit-test' ],
+ 'limit' => '2; DROP TABLE wp_spotmap_points',
+ ] );
+
+ // absint('2; DROP...') === 2
+ $this->assertCount( 2, $points );
+ }
+
+ public function test_get_points_orderby_injection_is_ignored(): void {
+ self::$db->insert_point( $this->make_point( [ 'feedName' => 'order-test', 'unixTime' => 1700900020 ] ) );
+
+ $points = self::$db->get_points( [
+ 'feeds' => [ 'order-test' ],
+ 'orderBy' => "id; DROP TABLE wp_spotmap_points-- ",
+ ] );
+
+ $this->assertIsArray( $points );
+ $this->assertArrayNotHasKey( 'error', $points );
+ }
+
+ public function test_get_points_groupby_injection_is_ignored(): void {
+ self::$db->insert_point( $this->make_point( [ 'feedName' => 'group-test', 'unixTime' => 1700900030 ] ) );
+
+ $points = self::$db->get_points( [
+ 'feeds' => [ 'group-test' ],
+ 'groupBy' => "feed_name; DROP TABLE wp_spotmap_points",
+ ] );
+
+ $this->assertIsArray( $points );
+ $this->assertArrayNotHasKey( 'error', $points );
+ }
+
+
+ // --- does_media_exist / delete_media_point ---
+
+ public function test_does_media_exist_returns_true_after_insert(): void {
+ self::$db->insert_point( $this->make_point( [ 'modelId' => '42', 'unixTime' => 1700000070 ] ) );
+
+ $this->assertTrue( self::$db->does_media_exist( 42 ) );
+ }
+
+ public function test_does_media_exist_returns_false_for_nonexistent(): void {
+ $this->assertFalse( self::$db->does_media_exist( 999888 ) );
+ }
+
+ public function test_delete_media_point_removes_matching_points(): void {
+ self::$db->insert_point( $this->make_point( [ 'modelId' => '77', 'unixTime' => 1700000080 ] ) );
+ self::$db->insert_point( $this->make_point( [ 'modelId' => '77', 'unixTime' => 1700000081 ] ) );
+
+ $result = self::$db->delete_media_point( 77 );
+
+ $this->assertTrue( $result );
+ $this->assertFalse( self::$db->does_media_exist( 77 ) );
+ }
+
+ public function test_delete_media_point_returns_false_when_nothing_to_delete(): void {
+ $this->assertFalse( self::$db->delete_media_point( 999888 ) );
+ }
+
+ // --- stationary deduplication ---
+
+ /**
+ * Arrival point (first in feed) must always be stored.
+ */
+ public function test_stationary_dedup_first_point_is_always_stored(): void {
+ $result = self::$db->insert_point( $this->make_point( [
+ 'feedName' => 'dedup-feed',
+ 'unixTime' => 1710000000,
+ 'latitude' => 47.3769,
+ 'longitude' => 8.5417,
+ ] ) );
+
+ $this->assertSame( 1, $result );
+ }
+
+ /**
+ * A second point within 25 m and < 10 min later must be skipped.
+ */
+ public function test_stationary_dedup_skips_nearby_point_within_10_min(): void {
+ self::$db->insert_point( $this->make_point( [
+ 'feedName' => 'dedup-near',
+ 'unixTime' => 1710000000,
+ 'latitude' => 47.376900,
+ 'longitude' => 8.541700,
+ ] ) );
+
+ // ~5 m away, 5 min later — should be skipped.
+ $result = self::$db->insert_point( $this->make_point( [
+ 'feedName' => 'dedup-near',
+ 'unixTime' => 1710000300,
+ 'latitude' => 47.376940, // ~4 m north
+ 'longitude' => 8.541700,
+ ] ) );
+
+ $this->assertSame( 0, $result );
+ }
+
+ /**
+ * A second point > 10 min later (even if close) must be stored — device may have
+ * moved away and returned, or the gap itself is meaningful.
+ */
+ public function test_stationary_dedup_stores_point_after_10_min_gap(): void {
+ self::$db->insert_point( $this->make_point( [
+ 'feedName' => 'dedup-time',
+ 'unixTime' => 1710000000,
+ 'latitude' => 47.3769,
+ 'longitude' => 8.5417,
+ ] ) );
+
+ // Same spot but 11 min later.
+ $result = self::$db->insert_point( $this->make_point( [
+ 'feedName' => 'dedup-time',
+ 'unixTime' => 1710000000 + 660,
+ 'latitude' => 47.3769,
+ 'longitude' => 8.5417,
+ ] ) );
+
+ $this->assertSame( 1, $result );
+ }
+
+ /**
+ * A point > 25 m away must be stored regardless of time gap.
+ */
+ public function test_stationary_dedup_stores_point_that_moved_beyond_25m(): void {
+ self::$db->insert_point( $this->make_point( [
+ 'feedName' => 'dedup-move',
+ 'unixTime' => 1710000000,
+ 'latitude' => 47.376900,
+ 'longitude' => 8.541700,
+ ] ) );
+
+ // ~30 m north, 2 min later.
+ $result = self::$db->insert_point( $this->make_point( [
+ 'feedName' => 'dedup-move',
+ 'unixTime' => 1710000120,
+ 'latitude' => 47.377170, // ~30 m north
+ 'longitude' => 8.541700,
+ ] ) );
+
+ $this->assertSame( 1, $result );
+ }
+
+ /**
+ * Deduplication is per feed_name: a nearby point on a *different* feed
+ * must not be suppressed.
+ */
+ public function test_stationary_dedup_does_not_affect_different_feed(): void {
+ self::$db->insert_point( $this->make_point( [
+ 'feedName' => 'dedup-feed-a',
+ 'unixTime' => 1710000000,
+ 'latitude' => 47.3769,
+ 'longitude' => 8.5417,
+ ] ) );
+
+ // Different feed, same location, within 10 min — must be stored.
+ $result = self::$db->insert_point( $this->make_point( [
+ 'feedName' => 'dedup-feed-b',
+ 'unixTime' => 1710000060,
+ 'latitude' => 47.3769,
+ 'longitude' => 8.5417,
+ ] ) );
+
+ $this->assertSame( 1, $result );
+ }
+}
diff --git a/tests/SpotmapDeactivatorTest.php b/tests/SpotmapDeactivatorTest.php
new file mode 100644
index 0000000..28e734f
--- /dev/null
+++ b/tests/SpotmapDeactivatorTest.php
@@ -0,0 +1,35 @@
+ 150, 'display' => '2.5 Minutes' ];
+ return $schedules;
+ } );
+ }
+
+ public function test_deactivate_clears_cron_hooks(): void {
+ wp_schedule_event( time(), 'twohalf_min', 'spotmap_api_crawler_hook' );
+ wp_schedule_single_event( time(), 'spotmap_get_timezone_hook' );
+
+ Spotmap_Deactivator::deactivate();
+
+ $this->assertFalse( wp_next_scheduled( 'spotmap_api_crawler_hook' ) );
+ $this->assertFalse( wp_next_scheduled( 'spotmap_get_timezone_hook' ) );
+ }
+
+ public function test_deactivate_is_safe_when_no_hooks_scheduled(): void {
+ wp_clear_scheduled_hook( 'spotmap_api_crawler_hook' );
+ wp_clear_scheduled_hook( 'spotmap_get_timezone_hook' );
+
+ // Must not throw.
+ Spotmap_Deactivator::deactivate();
+
+ $this->assertFalse( wp_next_scheduled( 'spotmap_api_crawler_hook' ) );
+ }
+}
diff --git a/tests/SpotmapMigratorTest.php b/tests/SpotmapMigratorTest.php
new file mode 100644
index 0000000..8df5b37
--- /dev/null
+++ b/tests/SpotmapMigratorTest.php
@@ -0,0 +1,254 @@
+getProperty( 'cache' );
+ }
+
+ protected function setUp(): void {
+ parent::setUp();
+ delete_option( 'spotmap_version' );
+ delete_option( 'spotmap_feeds' );
+ delete_option( 'spotmap_findmespot_name' );
+ delete_option( 'spotmap_findmespot_id' );
+ delete_option( 'spotmap_findmespot_password' );
+ delete_option( 'spotmap_api_providers' );
+ self::$cache_prop->setValue( null, [] );
+ }
+
+ // -------------------------------------------------------------------------
+ // Helpers — mirror exact production wp_options rows
+ // -------------------------------------------------------------------------
+
+ /**
+ * Seeds the three feeds from the real 0.11.2 installation.
+ * All feeds are public (empty password).
+ */
+ private function seed_real_production_data(): void {
+ update_option( 'spotmap_findmespot_name', [ 'timoalt', 'Elia', 'timo&lisa' ] );
+ update_option( 'spotmap_findmespot_id', [ self::FEED_ID_TIMOALT, self::FEED_ID_ELIA, self::FEED_ID_TIMO_LISA ] );
+ update_option( 'spotmap_findmespot_password', [ '', '', '' ] );
+ update_option( 'spotmap_api_providers', [ 'findmespot' => 'Spot Feed' ] );
+ }
+
+ /**
+ * Seeds two feeds, one with a password.
+ */
+ private function seed_two_feeds_one_with_password(): void {
+ update_option( 'spotmap_findmespot_name', [ 'timoalt', 'Elia' ] );
+ update_option( 'spotmap_findmespot_id', [ self::FEED_ID_TIMOALT, self::FEED_ID_ELIA ] );
+ update_option( 'spotmap_findmespot_password', [ '', 'secret123' ] );
+ update_option( 'spotmap_api_providers', [ 'findmespot' => 'Spot Feed' ] );
+ }
+
+ // -------------------------------------------------------------------------
+ // Core migration: real production data
+ // -------------------------------------------------------------------------
+
+ public function test_migration_creates_spotmap_feeds_option(): void {
+ $this->seed_real_production_data();
+
+ Spotmap_Migrator::run();
+
+ $this->assertNotFalse( get_option( 'spotmap_feeds' ) );
+ }
+
+ public function test_migration_converts_three_feeds(): void {
+ $this->seed_real_production_data();
+
+ Spotmap_Migrator::run();
+ self::$cache_prop->setValue( null, [] );
+
+ $this->assertCount( 3, Spotmap_Options::get_feeds() );
+ }
+
+ public function test_migration_preserves_all_feed_names(): void {
+ $this->seed_real_production_data();
+
+ Spotmap_Migrator::run();
+ self::$cache_prop->setValue( null, [] );
+
+ $names = array_column( Spotmap_Options::get_feeds(), 'name' );
+ $this->assertContains( 'timoalt', $names );
+ $this->assertContains( 'Elia', $names );
+ $this->assertContains( 'timo&lisa', $names );
+ }
+
+ public function test_migration_preserves_all_feed_ids(): void {
+ $this->seed_real_production_data();
+
+ Spotmap_Migrator::run();
+ self::$cache_prop->setValue( null, [] );
+
+ $feed_ids = array_column( Spotmap_Options::get_feeds(), 'feed_id' );
+ $this->assertContains( self::FEED_ID_TIMOALT, $feed_ids );
+ $this->assertContains( self::FEED_ID_ELIA, $feed_ids );
+ $this->assertContains( self::FEED_ID_TIMO_LISA, $feed_ids );
+ }
+
+ public function test_migration_preserves_ampersand_in_feed_name(): void {
+ $this->seed_real_production_data();
+
+ Spotmap_Migrator::run();
+ self::$cache_prop->setValue( null, [] );
+
+ $names = array_column( Spotmap_Options::get_feeds(), 'name' );
+ // sanitize_text_field must not corrupt the ampersand.
+ $this->assertContains( 'timo&lisa', $names );
+ }
+
+ public function test_migration_sets_provider_type_to_findmespot(): void {
+ $this->seed_real_production_data();
+
+ Spotmap_Migrator::run();
+ self::$cache_prop->setValue( null, [] );
+
+ foreach ( Spotmap_Options::get_feeds() as $feed ) {
+ $this->assertSame( 'findmespot', $feed['type'] );
+ }
+ }
+
+ public function test_migration_assigns_unique_id_to_each_feed(): void {
+ $this->seed_real_production_data();
+
+ Spotmap_Migrator::run();
+ self::$cache_prop->setValue( null, [] );
+
+ $ids = array_column( Spotmap_Options::get_feeds(), 'id' );
+ $this->assertCount( 3, array_unique( $ids ), 'Every feed must get a distinct id' );
+ }
+
+ public function test_migration_preserves_password(): void {
+ $this->seed_two_feeds_one_with_password();
+
+ Spotmap_Migrator::run();
+ self::$cache_prop->setValue( null, [] );
+
+ $feeds = Spotmap_Options::get_feeds();
+ $elia = null;
+ foreach ( $feeds as $f ) {
+ if ( $f['feed_id'] === self::FEED_ID_ELIA ) {
+ $elia = $f;
+ break;
+ }
+ }
+ $this->assertNotNull( $elia );
+ $this->assertSame( 'secret123', $elia['password'] );
+ }
+
+ public function test_migration_empty_password_stays_empty(): void {
+ $this->seed_real_production_data();
+
+ Spotmap_Migrator::run();
+ self::$cache_prop->setValue( null, [] );
+
+ foreach ( Spotmap_Options::get_feeds() as $feed ) {
+ $this->assertSame( '', $feed['password'] );
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // Cleanup of legacy options
+ // -------------------------------------------------------------------------
+
+ public function test_migration_deletes_old_name_option(): void {
+ $this->seed_real_production_data();
+ Spotmap_Migrator::run();
+ $this->assertFalse( get_option( 'spotmap_findmespot_name' ) );
+ }
+
+ public function test_migration_deletes_old_id_option(): void {
+ $this->seed_real_production_data();
+ Spotmap_Migrator::run();
+ $this->assertFalse( get_option( 'spotmap_findmespot_id' ) );
+ }
+
+ public function test_migration_deletes_old_password_option(): void {
+ $this->seed_real_production_data();
+ Spotmap_Migrator::run();
+ $this->assertFalse( get_option( 'spotmap_findmespot_password' ) );
+ }
+
+ public function test_migration_deletes_api_providers_option(): void {
+ $this->seed_real_production_data();
+ Spotmap_Migrator::run();
+ $this->assertFalse( get_option( 'spotmap_api_providers' ) );
+ }
+
+ // -------------------------------------------------------------------------
+ // Version tracking
+ // -------------------------------------------------------------------------
+
+ public function test_migration_writes_spotmap_version(): void {
+ Spotmap_Migrator::run();
+ $this->assertSame( SPOTMAP_VERSION, get_option( 'spotmap_version' ) );
+ }
+
+ public function test_run_is_noop_when_already_on_current_version(): void {
+ update_option( 'spotmap_version', SPOTMAP_VERSION );
+ update_option( 'spotmap_feeds', [ [ 'id' => 'existing', 'type' => 'findmespot', 'name' => 'timoalt', 'feed_id' => self::FEED_ID_TIMOALT, 'password' => '' ] ] );
+ // Plant old options to prove they are NOT deleted on a no-op run.
+ update_option( 'spotmap_findmespot_id', [ self::FEED_ID_TIMOALT ] );
+
+ Spotmap_Migrator::run();
+
+ $this->assertNotFalse( get_option( 'spotmap_findmespot_id' ), 'Old option must survive a no-op run' );
+ }
+
+ // -------------------------------------------------------------------------
+ // Edge cases
+ // -------------------------------------------------------------------------
+
+ public function test_migration_with_no_old_feeds_creates_empty_array(): void {
+ update_option( 'spotmap_findmespot_name', [] );
+ update_option( 'spotmap_findmespot_id', [] );
+ update_option( 'spotmap_findmespot_password', [] );
+ update_option( 'spotmap_api_providers', [ 'findmespot' => 'Spot Feed' ] );
+
+ Spotmap_Migrator::run();
+ self::$cache_prop->setValue( null, [] );
+
+ $this->assertSame( [], Spotmap_Options::get_feeds() );
+ }
+
+ public function test_migration_skips_entries_with_empty_feed_id(): void {
+ // Middle entry has no feed ID — simulates a partially deleted feed from 0.x.y.
+ update_option( 'spotmap_findmespot_name', [ 'timoalt', 'broken', 'Elia' ] );
+ update_option( 'spotmap_findmespot_id', [ self::FEED_ID_TIMOALT, '', self::FEED_ID_ELIA ] );
+ update_option( 'spotmap_findmespot_password', [ '', '', '' ] );
+
+ Spotmap_Migrator::run();
+ self::$cache_prop->setValue( null, [] );
+
+ $feeds = Spotmap_Options::get_feeds();
+ $this->assertCount( 2, $feeds, 'Entry with empty feed_id must be dropped' );
+ $feed_ids = array_column( $feeds, 'feed_id' );
+ $this->assertNotContains( '', $feed_ids );
+ }
+
+ public function test_migration_with_no_old_options_creates_empty_feeds(): void {
+ // Plugin installed but never configured before being updated to 1.0.0.
+ Spotmap_Migrator::run();
+ self::$cache_prop->setValue( null, [] );
+
+ $feeds = get_option( 'spotmap_feeds' );
+ $this->assertIsArray( $feeds );
+ $this->assertCount( 0, $feeds );
+ }
+
+ public function test_migration_with_no_old_options_still_writes_version(): void {
+ Spotmap_Migrator::run();
+ $this->assertSame( SPOTMAP_VERSION, get_option( 'spotmap_version' ) );
+ }
+}
diff --git a/tests/SpotmapOptionsTest.php b/tests/SpotmapOptionsTest.php
new file mode 100644
index 0000000..bc8779e
--- /dev/null
+++ b/tests/SpotmapOptionsTest.php
@@ -0,0 +1,354 @@
+getProperty( 'cache' );
+ }
+
+ protected function setUp(): void {
+ parent::setUp();
+ // WP_UnitTestCase rolls back DB changes per test; reset PHP-level static cache too.
+ self::$cache_prop->setValue( null, [] );
+ }
+
+ // -------------------------------------------------------------------------
+ // Marker defaults
+ // -------------------------------------------------------------------------
+
+ public function test_marker_defaults_contain_expected_types(): void {
+ $defaults = Spotmap_Options::get_marker_defaults();
+
+ $this->assertArrayHasKey( 'OK', $defaults );
+ $this->assertArrayHasKey( 'HELP', $defaults );
+ $this->assertArrayHasKey( 'MEDIA', $defaults );
+ }
+
+ public function test_marker_defaults_have_required_keys(): void {
+ foreach ( Spotmap_Options::get_marker_defaults() as $type => $config ) {
+ $this->assertArrayHasKey( 'iconShape', $config, "$type missing iconShape" );
+ $this->assertArrayHasKey( 'icon', $config, "$type missing icon" );
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // Settings defaults
+ // -------------------------------------------------------------------------
+
+ public function test_settings_defaults_has_expected_keys(): void {
+ $defaults = Spotmap_Options::get_settings_defaults();
+
+ $this->assertArrayHasKey( 'height', $defaults );
+ $this->assertArrayHasKey( 'maps', $defaults );
+ $this->assertSame( 500, $defaults['height'] );
+ }
+
+ // -------------------------------------------------------------------------
+ // seed_defaults
+ // -------------------------------------------------------------------------
+
+ public function test_seed_defaults_creates_all_options_on_fresh_install(): void {
+ Spotmap_Options::seed_defaults();
+
+ $this->assertNotFalse( get_option( Spotmap_Options::OPTION_FEEDS ) );
+ $this->assertNotFalse( get_option( Spotmap_Options::OPTION_MARKER ) );
+ $this->assertNotFalse( get_option( Spotmap_Options::OPTION_DEFAULT_VALUES ) );
+ $this->assertNotFalse( get_option( Spotmap_Options::OPTION_CUSTOM_MESSAGES ) );
+ $this->assertNotFalse( get_option( Spotmap_Options::OPTION_API_TOKENS ) );
+ }
+
+ public function test_seed_defaults_feeds_option_is_empty_array(): void {
+ Spotmap_Options::seed_defaults();
+
+ $this->assertSame( [], get_option( Spotmap_Options::OPTION_FEEDS ) );
+ }
+
+ public function test_seed_defaults_does_not_overwrite_existing_options(): void {
+ // Simulate an existing install where the user already has a custom marker.
+ update_option( Spotmap_Options::OPTION_MARKER, [
+ 'OK' => [ 'iconShape' => 'circle', 'icon' => 'star', 'customMessage' => 'On the way!' ],
+ ] );
+
+ Spotmap_Options::seed_defaults();
+ self::$cache_prop->setValue( null, [] );
+
+ $marker = get_option( Spotmap_Options::OPTION_MARKER );
+ // add_option() is a no-op when the option already exists.
+ $this->assertArrayHasKey( 'OK', $marker );
+ $this->assertSame( 'star', $marker['OK']['icon'], 'Existing value must not be overwritten by seed_defaults' );
+ }
+
+ // -------------------------------------------------------------------------
+ // Feeds CRUD
+ // -------------------------------------------------------------------------
+
+ public function test_get_feeds_returns_empty_array_when_no_feeds(): void {
+ $this->assertSame( [], Spotmap_Options::get_feeds() );
+ }
+
+ public function test_add_feed_returns_feed_with_generated_id(): void {
+ $feed = Spotmap_Options::add_feed( [
+ 'type' => 'findmespot',
+ 'name' => 'Alps 2024',
+ 'feed_id' => '0onlLopfoM4bG5jXvWRE8H0Obd0oMxMBq',
+ 'password' => '',
+ ] );
+
+ $this->assertArrayHasKey( 'id', $feed );
+ $this->assertNotEmpty( $feed['id'] );
+ }
+
+ public function test_add_feed_persists_to_options(): void {
+ Spotmap_Options::add_feed( [
+ 'type' => 'findmespot',
+ 'name' => 'Alps 2024',
+ 'feed_id' => '0onlLopfoM4bG5jXvWRE8H0Obd0oMxMBq',
+ 'password' => '',
+ ] );
+ self::$cache_prop->setValue( null, [] );
+
+ $feeds = Spotmap_Options::get_feeds();
+ $this->assertCount( 1, $feeds );
+ $this->assertSame( 'Alps 2024', $feeds[0]['name'] );
+ $this->assertSame( '0onlLopfoM4bG5jXvWRE8H0Obd0oMxMBq', $feeds[0]['feed_id'] );
+ }
+
+ public function test_add_multiple_feeds_all_get_unique_ids(): void {
+ $a = Spotmap_Options::add_feed( [ 'type' => 'findmespot', 'name' => 'Feed A', 'feed_id' => '0onlLopfoM4bG5jXvWRE8H0Obd0oMxMBq', 'password' => '' ] );
+ $b = Spotmap_Options::add_feed( [ 'type' => 'findmespot', 'name' => 'Feed B', 'feed_id' => '1abcDefgHiJkLmNoPqRsTuVwXyZ123456', 'password' => '' ] );
+
+ $this->assertNotSame( $a['id'], $b['id'] );
+ }
+
+ public function test_get_feed_returns_correct_feed_by_id(): void {
+ $added = Spotmap_Options::add_feed( [
+ 'type' => 'findmespot',
+ 'name' => 'Summer Hike',
+ 'feed_id' => '0onlLopfoM4bG5jXvWRE8H0Obd0oMxMBq',
+ 'password' => '',
+ ] );
+ self::$cache_prop->setValue( null, [] );
+
+ $found = Spotmap_Options::get_feed( $added['id'] );
+ $this->assertNotNull( $found );
+ $this->assertSame( 'Summer Hike', $found['name'] );
+ }
+
+ public function test_get_feed_returns_null_for_unknown_id(): void {
+ $this->assertNull( Spotmap_Options::get_feed( 'nonexistent_id' ) );
+ }
+
+ public function test_update_feed_persists_changes(): void {
+ $added = Spotmap_Options::add_feed( [
+ 'type' => 'findmespot',
+ 'name' => 'Old Name',
+ 'feed_id' => '0onlLopfoM4bG5jXvWRE8H0Obd0oMxMBq',
+ 'password' => '',
+ ] );
+ self::$cache_prop->setValue( null, [] );
+
+ $result = Spotmap_Options::update_feed( $added['id'], [
+ 'type' => 'findmespot',
+ 'name' => 'New Name',
+ 'feed_id' => '0onlLopfoM4bG5jXvWRE8H0Obd0oMxMBq',
+ 'password' => '',
+ ] );
+ self::$cache_prop->setValue( null, [] );
+
+ $this->assertTrue( $result );
+ $this->assertSame( 'New Name', Spotmap_Options::get_feed( $added['id'] )['name'] );
+ }
+
+ public function test_update_feed_preserves_id(): void {
+ $added = Spotmap_Options::add_feed( [ 'type' => 'findmespot', 'name' => 'Trip', 'feed_id' => '0onlLopfoM4bG5jXvWRE8H0Obd0oMxMBq', 'password' => '' ] );
+ self::$cache_prop->setValue( null, [] );
+
+ Spotmap_Options::update_feed( $added['id'], [ 'type' => 'findmespot', 'name' => 'Trip v2', 'feed_id' => '0onlLopfoM4bG5jXvWRE8H0Obd0oMxMBq', 'password' => '' ] );
+ self::$cache_prop->setValue( null, [] );
+
+ $feed = Spotmap_Options::get_feed( $added['id'] );
+ $this->assertSame( $added['id'], $feed['id'], 'ID must not change on update' );
+ }
+
+ public function test_update_feed_returns_false_for_unknown_id(): void {
+ $result = Spotmap_Options::update_feed( 'no_such_id', [ 'name' => 'Ghost' ] );
+ $this->assertFalse( $result );
+ }
+
+ public function test_delete_feed_removes_it(): void {
+ $added = Spotmap_Options::add_feed( [ 'type' => 'findmespot', 'name' => 'Temp', 'feed_id' => '0onlLopfoM4bG5jXvWRE8H0Obd0oMxMBq', 'password' => '' ] );
+ self::$cache_prop->setValue( null, [] );
+
+ $result = Spotmap_Options::delete_feed( $added['id'] );
+ self::$cache_prop->setValue( null, [] );
+
+ $this->assertTrue( $result );
+ $this->assertNull( Spotmap_Options::get_feed( $added['id'] ) );
+ $this->assertCount( 0, Spotmap_Options::get_feeds() );
+ }
+
+ public function test_delete_feed_returns_false_for_unknown_id(): void {
+ $result = Spotmap_Options::delete_feed( 'no_such_id' );
+ $this->assertFalse( $result );
+ }
+
+ public function test_delete_feed_leaves_other_feeds_intact(): void {
+ $a = Spotmap_Options::add_feed( [ 'type' => 'findmespot', 'name' => 'Keep Me', 'feed_id' => '0onlLopfoM4bG5jXvWRE8H0Obd0oMxMBq', 'password' => '' ] );
+ $b = Spotmap_Options::add_feed( [ 'type' => 'findmespot', 'name' => 'Delete Me', 'feed_id' => '1abcDefgHiJkLmNoPqRsTuVwXyZ123456', 'password' => '' ] );
+ self::$cache_prop->setValue( null, [] );
+
+ Spotmap_Options::delete_feed( $b['id'] );
+ self::$cache_prop->setValue( null, [] );
+
+ $feeds = Spotmap_Options::get_feeds();
+ $this->assertCount( 1, $feeds );
+ $this->assertSame( $a['id'], $feeds[0]['id'] );
+ }
+
+ // -------------------------------------------------------------------------
+ // save_* write methods
+ // -------------------------------------------------------------------------
+
+ public function test_save_marker_options_persists(): void {
+ $custom = [
+ 'OK' => [ 'iconShape' => 'circle', 'icon' => 'star', 'customMessage' => '' ],
+ ];
+ Spotmap_Options::save_marker_options( $custom );
+ self::$cache_prop->setValue( null, [] );
+
+ $stored = get_option( Spotmap_Options::OPTION_MARKER );
+ $this->assertSame( 'star', $stored['OK']['icon'] );
+ }
+
+ public function test_save_settings_persists(): void {
+ Spotmap_Options::save_settings( [ 'height' => 800 ] );
+ self::$cache_prop->setValue( null, [] );
+
+ $this->assertSame( 800, Spotmap_Options::get_setting( 'height' ) );
+ }
+
+ public function test_save_api_tokens_persists(): void {
+ Spotmap_Options::save_api_tokens( [ 'mapbox' => 'pk.newtoken' ] );
+ self::$cache_prop->setValue( null, [] );
+
+ $this->assertSame( 'pk.newtoken', Spotmap_Options::get_api_token( 'mapbox' ) );
+ }
+
+ // -------------------------------------------------------------------------
+ // Marker read methods
+ // -------------------------------------------------------------------------
+
+ public function test_get_marker_options_merges_with_defaults(): void {
+ update_option( Spotmap_Options::OPTION_MARKER, [
+ 'OK' => [ 'iconShape' => 'circle', 'icon' => 'custom-icon', 'customMessage' => '' ],
+ ] );
+
+ $options = Spotmap_Options::get_marker_options();
+
+ $this->assertSame( 'circle', $options['OK']['iconShape'] );
+ // Non-overridden types still return defaults.
+ $this->assertSame( 'marker', $options['HELP']['iconShape'] );
+ }
+
+ public function test_get_marker_setting_returns_default_value(): void {
+ $this->assertSame( 'marker', Spotmap_Options::get_marker_setting( 'OK', 'iconShape' ) );
+ }
+
+ public function test_get_marker_setting_returns_custom_value(): void {
+ update_option( Spotmap_Options::OPTION_MARKER, [
+ 'OK' => [ 'iconShape' => 'circle', 'icon' => 'star', 'customMessage' => '' ],
+ ] );
+
+ $this->assertSame( 'circle', Spotmap_Options::get_marker_setting( 'OK', 'iconShape' ) );
+ }
+
+ public function test_get_marker_setting_returns_fallback_for_unknown_type(): void {
+ $this->assertSame( 'fallback', Spotmap_Options::get_marker_setting( 'UNKNOWN_TYPE', 'iconShape', 'fallback' ) );
+ }
+
+ // -------------------------------------------------------------------------
+ // Settings read methods
+ // -------------------------------------------------------------------------
+
+ public function test_get_setting_returns_default_when_unset(): void {
+ $this->assertSame( 500, Spotmap_Options::get_setting( 'height' ) );
+ }
+
+ public function test_get_setting_returns_fallback_for_unknown_key(): void {
+ $this->assertNull( Spotmap_Options::get_setting( 'nonexistent_key' ) );
+ }
+
+ public function test_get_settings_returns_all_default_keys(): void {
+ foreach ( array_keys( Spotmap_Options::get_settings_defaults() ) as $key ) {
+ $this->assertArrayHasKey( $key, Spotmap_Options::get_settings(), "Missing settings key: $key" );
+ }
+ }
+
+ public function test_get_settings_stored_value_overrides_default(): void {
+ update_option( Spotmap_Options::OPTION_DEFAULT_VALUES, [ 'height' => 800 ] );
+
+ $this->assertSame( 800, Spotmap_Options::get_settings()['height'] );
+ }
+
+ // -------------------------------------------------------------------------
+ // API tokens
+ // -------------------------------------------------------------------------
+
+ public function test_get_api_token_defaults_has_all_expected_keys(): void {
+ foreach ( [ 'timezonedb', 'mapbox', 'thunderforest', 'linz.govt.nz', 'geoservices.ign.fr', 'osdatahub.os.uk' ] as $key ) {
+ $defaults = Spotmap_Options::get_api_token_defaults();
+ $this->assertArrayHasKey( $key, $defaults, "Missing API token key: $key" );
+ $this->assertSame( '', $defaults[ $key ] );
+ }
+ }
+
+ public function test_get_api_token_returns_empty_string_by_default(): void {
+ $this->assertSame( '', Spotmap_Options::get_api_token( 'mapbox' ) );
+ }
+
+ public function test_get_api_token_returns_stored_value(): void {
+ update_option( Spotmap_Options::OPTION_API_TOKENS, [ 'mapbox' => 'tok_123' ] );
+
+ $this->assertSame( 'tok_123', Spotmap_Options::get_api_token( 'mapbox' ) );
+ }
+
+ public function test_get_api_tokens_returns_all_known_keys(): void {
+ foreach ( array_keys( Spotmap_Options::get_api_token_defaults() ) as $key ) {
+ $this->assertArrayHasKey( $key, Spotmap_Options::get_api_tokens() );
+ }
+ }
+
+ public function test_get_api_tokens_stored_value_overrides_default(): void {
+ update_option( Spotmap_Options::OPTION_API_TOKENS, [ 'mapbox' => 'pk.abc123' ] );
+
+ $this->assertSame( 'pk.abc123', Spotmap_Options::get_api_tokens()['mapbox'] );
+ }
+
+ // -------------------------------------------------------------------------
+ // Custom messages
+ // -------------------------------------------------------------------------
+
+ public function test_get_custom_messages_returns_empty_array_by_default(): void {
+ $this->assertSame( [], Spotmap_Options::get_custom_messages() );
+ }
+
+ public function test_get_custom_messages_returns_stored_values(): void {
+ update_option( Spotmap_Options::OPTION_CUSTOM_MESSAGES, [ 'OK' => 'All good!' ] );
+
+ $this->assertSame( 'All good!', Spotmap_Options::get_custom_messages()['OK'] );
+ }
+
+ public function test_get_custom_message_returns_fallback_when_unset(): void {
+ $this->assertSame( 'default', Spotmap_Options::get_custom_message( 'OK', 'default' ) );
+ }
+
+ public function test_get_custom_message_returns_stored_value(): void {
+ update_option( Spotmap_Options::OPTION_CUSTOM_MESSAGES, [ 'HELP' => 'SOS triggered!' ] );
+
+ $this->assertSame( 'SOS triggered!', Spotmap_Options::get_custom_message( 'HELP' ) );
+ }
+}
diff --git a/tests/SpotmapOsmAndIngestTest.php b/tests/SpotmapOsmAndIngestTest.php
new file mode 100644
index 0000000..ef90e4b
--- /dev/null
+++ b/tests/SpotmapOsmAndIngestTest.php
@@ -0,0 +1,341 @@
+getProperty( 'cache' );
+ self::$admin_id = self::factory()->user->create( [ 'role' => 'administrator' ] );
+ }
+
+ protected function setUp(): void {
+ parent::setUp();
+ self::$cache_prop->setValue( null, [] );
+ // Clean stored feeds between tests.
+ delete_option( 'spotmap_feeds' );
+ }
+
+ // -------------------------------------------------------------------------
+ // Helpers
+ // -------------------------------------------------------------------------
+
+ /** Dispatches a GET request to the OsmAnd ingest endpoint. */
+ private function ingest( array $params ): WP_REST_Response {
+ $request = new WP_REST_Request( 'GET', '/spotmap/v1/ingest/osmand' );
+ foreach ( $params as $k => $v ) {
+ $request->set_param( $k, $v );
+ }
+ return rest_get_server()->dispatch( $request );
+ }
+
+ /** Dispatches a request to the admin REST API (requires admin user). */
+ private function admin_request( string $method, string $route, array $body = [] ): WP_REST_Response {
+ $request = new WP_REST_Request( $method, '/spotmap/v1' . $route );
+ if ( ! empty( $body ) ) {
+ $request->set_header( 'Content-Type', 'application/json' );
+ $request->set_body( wp_json_encode( $body ) );
+ }
+ return rest_get_server()->dispatch( $request );
+ }
+
+ /** Stores an OsmAnd feed with a known key and returns the feed array. */
+ private function seed_osmand_feed( string $name = 'MyPhone', string $key = 'abc123testkey' ): array {
+ $feed = [
+ 'id' => uniqid( 'feed_', true ),
+ 'type' => 'osmand',
+ 'name' => $name,
+ 'key' => $key,
+ ];
+ Spotmap_Options::save_feeds( [ $feed ] );
+ self::$cache_prop->setValue( null, [] );
+ return $feed;
+ }
+
+ /** Returns all rows from the points table for the given feed name. */
+ private function get_points( string $feed_name ): array {
+ global $wpdb;
+ // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
+ return $wpdb->get_results(
+ $wpdb->prepare(
+ "SELECT * FROM {$wpdb->prefix}spotmap_points WHERE feed_name = %s ORDER BY id DESC",
+ $feed_name
+ ),
+ ARRAY_A
+ );
+ }
+
+ // -------------------------------------------------------------------------
+ // Authorization
+ // -------------------------------------------------------------------------
+
+ public function test_missing_key_returns_401(): void {
+ $response = $this->ingest( [
+ 'lat' => '47.766',
+ 'lon' => '11.632',
+ 'timestamp' => '1774544316000',
+ ] );
+ $this->assertSame( 401, $response->get_status() );
+ }
+
+ public function test_wrong_key_returns_401(): void {
+ $this->seed_osmand_feed( 'MyPhone', 'correctkey' );
+
+ $response = $this->ingest( [
+ 'key' => 'wrongkey',
+ 'lat' => '47.766',
+ 'lon' => '11.632',
+ 'timestamp' => '1774544316000',
+ ] );
+ $this->assertSame( 401, $response->get_status() );
+ }
+
+ public function test_valid_key_resolves_feed(): void {
+ $this->seed_osmand_feed( 'MyPhone', 'validkey42' );
+
+ $response = $this->ingest( [
+ 'key' => 'validkey42',
+ 'lat' => '47.766',
+ 'lon' => '11.632',
+ 'timestamp' => '1774544316000',
+ ] );
+ $this->assertSame( 200, $response->get_status() );
+ }
+
+ // -------------------------------------------------------------------------
+ // Required parameter validation
+ // -------------------------------------------------------------------------
+
+ public function test_missing_lat_returns_400(): void {
+ $this->seed_osmand_feed( 'MyPhone', 'k1' );
+
+ $response = $this->ingest( [
+ 'key' => 'k1',
+ 'lon' => '11.632',
+ 'timestamp' => '1774544316000',
+ ] );
+ $this->assertSame( 400, $response->get_status() );
+ }
+
+ public function test_missing_lon_returns_400(): void {
+ $this->seed_osmand_feed( 'MyPhone', 'k2' );
+
+ $response = $this->ingest( [
+ 'key' => 'k2',
+ 'lat' => '47.766',
+ 'timestamp' => '1774544316000',
+ ] );
+ $this->assertSame( 400, $response->get_status() );
+ }
+
+ public function test_missing_timestamp_returns_400(): void {
+ $this->seed_osmand_feed( 'MyPhone', 'k3' );
+
+ $response = $this->ingest( [
+ 'key' => 'k3',
+ 'lat' => '47.766',
+ 'lon' => '11.632',
+ ] );
+ $this->assertSame( 400, $response->get_status() );
+ }
+
+ public function test_unsubstituted_lat_placeholder_returns_400(): void {
+ $this->seed_osmand_feed( 'MyPhone', 'k4' );
+
+ $response = $this->ingest( [
+ 'key' => 'k4',
+ 'lat' => '{0}',
+ 'lon' => '11.632',
+ 'timestamp' => '1774544316000',
+ ] );
+ $this->assertSame( 400, $response->get_status() );
+ }
+
+ // -------------------------------------------------------------------------
+ // Timestamp conversion (milliseconds → seconds)
+ // -------------------------------------------------------------------------
+
+ public function test_timestamp_milliseconds_converted_to_seconds(): void {
+ $this->seed_osmand_feed( 'MyPhone', 'tskey' );
+ // Real value from OsmAnd probe: 1774544316682 ms = 1774544317 s (rounded).
+ $response = $this->ingest( [
+ 'key' => 'tskey',
+ 'lat' => '47.766',
+ 'lon' => '11.632',
+ 'timestamp' => '1774544316682',
+ ] );
+ $this->assertSame( 200, $response->get_status() );
+
+ $points = $this->get_points( 'MyPhone' );
+ $this->assertCount( 1, $points );
+ $this->assertSame( 1774544317, (int) $points[0]['time'] );
+ }
+
+ // -------------------------------------------------------------------------
+ // Stored fields
+ // -------------------------------------------------------------------------
+
+ public function test_coordinates_and_type_stored_correctly(): void {
+ $this->seed_osmand_feed( 'GeoPhone', 'geokey' );
+
+ $this->ingest( [
+ 'key' => 'geokey',
+ 'lat' => '47.76655',
+ 'lon' => '11.632608',
+ 'timestamp' => '1774544316000',
+ ] );
+
+ $points = $this->get_points( 'GeoPhone' );
+ $this->assertCount( 1, $points );
+ $this->assertEqualsWithDelta( 47.76655, (float) $points[0]['latitude'], 0.00001 );
+ $this->assertEqualsWithDelta( 11.632608, (float) $points[0]['longitude'], 0.00001 );
+ $this->assertSame( 'TRACK', $points[0]['type'] );
+ }
+
+ public function test_optional_fields_stored(): void {
+ $this->seed_osmand_feed( 'FullPhone', 'fullkey' );
+
+ $this->ingest( [
+ 'key' => 'fullkey',
+ 'lat' => '47.76655',
+ 'lon' => '11.632608',
+ 'timestamp' => '1774544316000',
+ 'hdop' => '4.746587',
+ 'altitude' => '716.77356',
+ 'speed' => '0.119132124',
+ 'bearing' => '356.15067',
+ 'batproc' => '82',
+ ] );
+
+ $points = $this->get_points( 'FullPhone' );
+ $this->assertCount( 1, $points );
+ $p = $points[0];
+
+ $this->assertEqualsWithDelta( 4.746587, (float) $p['hdop'], 0.0001 );
+ $this->assertSame( 717, (int) $p['altitude'] ); // rounded float
+ $this->assertEqualsWithDelta( 0.119132, (float) $p['speed'], 0.0001 );
+ $this->assertEqualsWithDelta( 356.15067, (float) $p['bearing'], 0.001 );
+ $this->assertSame( '82', $p['battery_status'] );
+ }
+
+ public function test_unsubstituted_batproc_placeholder_not_stored(): void {
+ $this->seed_osmand_feed( 'NoBat', 'nobatkey' );
+
+ // This is exactly what OsmAnd sends when battery permission is denied.
+ $this->ingest( [
+ 'key' => 'nobatkey',
+ 'lat' => '47.766',
+ 'lon' => '11.632',
+ 'timestamp' => '1774544316000',
+ 'batproc' => '{11}',
+ ] );
+
+ $points = $this->get_points( 'NoBat' );
+ $this->assertCount( 1, $points );
+ $this->assertNull( $points[0]['battery_status'] );
+ }
+
+ public function test_unsubstituted_optional_placeholder_not_stored(): void {
+ $this->seed_osmand_feed( 'NoHdop', 'nohdopkey' );
+
+ $this->ingest( [
+ 'key' => 'nohdopkey',
+ 'lat' => '47.766',
+ 'lon' => '11.632',
+ 'timestamp' => '1774544316000',
+ 'hdop' => '{3}',
+ 'speed' => '{5}',
+ ] );
+
+ $points = $this->get_points( 'NoHdop' );
+ $this->assertCount( 1, $points );
+ $this->assertNull( $points[0]['hdop'] );
+ $this->assertNull( $points[0]['speed'] );
+ }
+
+ // -------------------------------------------------------------------------
+ // REST API — feed management
+ // -------------------------------------------------------------------------
+
+ public function test_create_osmand_feed_generates_key_and_tracking_url(): void {
+ wp_set_current_user( self::$admin_id );
+
+ $response = $this->admin_request( 'POST', '/feeds', [
+ 'type' => 'osmand',
+ 'name' => 'TestPhone',
+ ] );
+
+ $this->assertSame( 201, $response->get_status() );
+ $data = $response->get_data();
+
+ // Key should be auto-generated (32 hex chars = 16 bytes).
+ $this->assertArrayHasKey( 'key', $data );
+ $this->assertMatchesRegularExpression( '/^[0-9a-f]{32}$/', $data['key'] );
+
+ // tracking_url must contain the key and all OsmAnd placeholders.
+ $this->assertArrayHasKey( 'tracking_url', $data );
+ $this->assertStringContainsString( $data['key'], $data['tracking_url'] );
+ $this->assertStringContainsString( '{0}', $data['tracking_url'] ); // lat
+ $this->assertStringContainsString( '{1}', $data['tracking_url'] ); // lon
+ $this->assertStringContainsString( '{2}', $data['tracking_url'] ); // timestamp
+ $this->assertStringContainsString( '{11}', $data['tracking_url'] ); // batproc
+ $this->assertStringContainsString( 'ingest/osmand', $data['tracking_url'] );
+ }
+
+ public function test_update_osmand_feed_preserves_key(): void {
+ wp_set_current_user( self::$admin_id );
+
+ // Create the feed first.
+ $create = $this->admin_request( 'POST', '/feeds', [
+ 'type' => 'osmand',
+ 'name' => 'InitialName',
+ ] );
+ $this->assertSame( 201, $create->get_status() );
+ $created = $create->get_data();
+ $original_key = $created['key'];
+
+ // Update only the name.
+ $update = $this->admin_request( 'PUT', '/feeds/' . $created['id'], [
+ 'type' => 'osmand',
+ 'name' => 'RenamedPhone',
+ ] );
+ $this->assertSame( 200, $update->get_status() );
+ $updated = $update->get_data();
+
+ // Key must be unchanged after rename.
+ $this->assertSame( $original_key, $updated['key'] );
+ $this->assertStringContainsString( $original_key, $updated['tracking_url'] );
+ }
+
+ public function test_get_feeds_includes_tracking_url_for_osmand(): void {
+ wp_set_current_user( self::$admin_id );
+
+ $this->seed_osmand_feed( 'ListPhone', 'listkey123' );
+
+ $response = $this->admin_request( 'GET', '/feeds' );
+ $this->assertSame( 200, $response->get_status() );
+
+ $feeds = $response->get_data();
+ $osmand = array_values( array_filter( $feeds, fn( $f ) => $f['type'] === 'osmand' ) );
+ $this->assertCount( 1, $osmand );
+ $this->assertArrayHasKey( 'tracking_url', $osmand[0] );
+ $this->assertStringContainsString( 'listkey123', $osmand[0]['tracking_url'] );
+ }
+}
diff --git a/tests/SpotmapPublicTest.php b/tests/SpotmapPublicTest.php
new file mode 100644
index 0000000..78975b1
--- /dev/null
+++ b/tests/SpotmapPublicTest.php
@@ -0,0 +1,76 @@
+_last_response, then throws WPAjaxDieContinueException.
+ */
+ private function capture_positions(): array {
+ $this->_last_response = '';
+ ob_start();
+ try {
+ self::$public->get_positions();
+ } catch ( \WPDieException $e ) {
+ // dieHandler already cleaned the buffer into $this->_last_response.
+ }
+ return json_decode( $this->_last_response, true ) ?? [];
+ }
+
+ public function test_get_positions_returns_empty_when_no_feeds_in_post(): void {
+ $_POST = [];
+
+ $data = $this->capture_positions();
+
+ $this->assertFalse( $data['error'] );
+ $this->assertTrue( $data['empty'] );
+ }
+
+ public function test_get_positions_returns_error_for_unknown_feed(): void {
+ $_POST = [ 'feeds' => [ 'nonexistent-feed' ] ];
+
+ $data = $this->capture_positions();
+
+ $this->assertTrue( $data['error'] );
+ }
+
+ public function test_get_positions_returns_points_for_known_feed(): void {
+ // Insert a point so the feed exists in the DB.
+ $db = new Spotmap_Database();
+ $db->insert_point( [
+ 'feedName' => 'ajax-test-feed',
+ 'feedId' => 'fid-ajax',
+ 'messageType' => 'OK',
+ 'unixTime' => 1700002000,
+ 'latitude' => 47.0,
+ 'longitude' => 8.0,
+ 'modelId' => 'SPOT-X',
+ 'messengerName' => 'Device',
+ 'messageContent' => '',
+ ] );
+
+ $_POST = [ 'feeds' => [ 'ajax-test-feed' ] ];
+
+ $data = $this->capture_positions();
+
+ $this->assertIsArray( $data );
+ $this->assertNotEmpty( $data );
+ }
+}
diff --git a/tests/SpotmapRenderingTest.php b/tests/SpotmapRenderingTest.php
new file mode 100644
index 0000000..5b1f837
--- /dev/null
+++ b/tests/SpotmapRenderingTest.php
@@ -0,0 +1,232 @@
+ function ( $attributes ) {
+ ob_start();
+ include dirname( __DIR__ ) . '/public/render-block.php';
+ return ob_get_clean();
+ },
+ ] );
+ }
+
+ protected function setUp(): void {
+ parent::setUp();
+ // Reset admin settings to built-in defaults so the shortcode reads the
+ // same filter-points value (5) as the block.json default.
+ Spotmap_Options::save_settings( Spotmap_Options::get_settings_defaults() );
+ $public = new Spotmap_Public();
+ $public->register_shortcodes();
+ }
+
+ // --- Helpers ---
+
+ /**
+ * Extracts and decodes the JSON options object passed to new Spotmap(...)
+ * from rendered HTML output.
+ */
+ private function extract_options( string $html ): array {
+ $start = strpos( $html, 'new Spotmap(' );
+ $this->assertNotFalse( $start, 'new Spotmap() call not found in output' );
+ $start += strlen( 'new Spotmap(' );
+
+ $depth = 0;
+ $end = $start;
+ for ( $i = $start; $i < strlen( $html ); $i++ ) {
+ if ( $html[ $i ] === '{' ) {
+ $depth++;
+ }
+ if ( $html[ $i ] === '}' ) {
+ $depth--;
+ }
+ if ( $depth === 0 && $i > $start ) {
+ $end = $i + 1;
+ break;
+ }
+ }
+
+ $decoded = json_decode( substr( $html, $start, $end - $start ), true );
+ $this->assertIsArray( $decoded, 'Spotmap options could not be decoded as JSON' );
+ return $decoded;
+ }
+
+ private function render_block( array $attributes ): string {
+ return render_block( [
+ 'blockName' => 'spotmap/spotmap',
+ 'attrs' => $attributes,
+ ] );
+ }
+
+ // --- Tests ---
+
+ public function test_maps_option_matches(): void {
+ $sc = $this->extract_options( do_shortcode( '[spotmap maps="openstreetmap,opentopomap" feeds="f1"]' ) );
+ $block = $this->extract_options( $this->render_block( [ 'maps' => [ 'openstreetmap', 'opentopomap' ], 'feeds' => [] ] ) );
+
+ $this->assertSame( $sc['maps'], $block['maps'] );
+ }
+
+ public function test_mapcenter_option_matches(): void {
+ $sc = $this->extract_options( do_shortcode( '[spotmap mapcenter="auto" feeds="f1"]' ) );
+ $block = $this->extract_options( $this->render_block( [ 'mapcenter' => 'auto', 'feeds' => [] ] ) );
+
+ $this->assertSame( $sc['mapcenter'], $block['mapcenter'] );
+ }
+
+ public function test_autoreload_defaults_to_false_in_both(): void {
+ $sc = $this->extract_options( do_shortcode( '[spotmap feeds="f1"]' ) );
+ $block = $this->extract_options( $this->render_block( [ 'feeds' => [] ] ) );
+
+ $this->assertFalse( $sc['autoReload'] );
+ $this->assertFalse( $block['autoReload'] );
+ }
+
+ public function test_debug_defaults_to_false_in_both(): void {
+ $sc = $this->extract_options( do_shortcode( '[spotmap feeds="f1"]' ) );
+ $block = $this->extract_options( $this->render_block( [ 'feeds' => [] ] ) );
+
+ $this->assertFalse( $sc['debug'] );
+ $this->assertFalse( $block['debug'] );
+ }
+
+ public function test_map_overlays_null_by_default_in_both(): void {
+ $sc = $this->extract_options( do_shortcode( '[spotmap feeds="f1"]' ) );
+ $block = $this->extract_options( $this->render_block( [ 'feeds' => [] ] ) );
+
+ $this->assertNull( $sc['mapOverlays'] );
+ $this->assertNull( $block['mapOverlays'] );
+ }
+
+ public function test_gpx_empty_by_default_in_both(): void {
+ $sc = $this->extract_options( do_shortcode( '[spotmap feeds="f1"]' ) );
+ $block = $this->extract_options( $this->render_block( [ 'feeds' => [] ] ) );
+
+ $this->assertSame( [], $sc['gpx'] );
+ $this->assertSame( [], $block['gpx'] );
+ }
+
+ public function test_filter_points_option_matches_when_set_explicitly(): void {
+ $sc = $this->extract_options( do_shortcode( '[spotmap filter-points=3 feeds="f1"]' ) );
+ $block = $this->extract_options( $this->render_block( [ 'filterPoints' => 3, 'feeds' => [] ] ) );
+
+ $this->assertSame( 3, (int) $sc['filterPoints'] );
+ $this->assertSame( 3, (int) $block['filterPoints'] );
+ }
+
+ /**
+ * Known difference: the block passes height in the JS options object so the
+ * Spotmap engine can read it directly; the shortcode only sets height via CSS
+ * on the wrapper div and does not include it in the options.
+ */
+ public function test_height_is_in_block_options_but_not_shortcode_options(): void {
+ $sc = $this->extract_options( do_shortcode( '[spotmap height=400 feeds="f1"]' ) );
+ $block = $this->extract_options( $this->render_block( [ 'height' => 400, 'feeds' => [] ] ) );
+
+ $this->assertArrayNotHasKey( 'height', $sc );
+ $this->assertSame( 400, $block['height'] );
+ }
+
+ /**
+ * Provides two DB states: empty and populated.
+ * Both should produce identical defaults for every shared option key.
+ */
+ public static function db_state_provider(): array {
+ return [
+ 'empty DB' => [ false ],
+ 'DB has feeds' => [ true ],
+ ];
+ }
+
+ /**
+ * All option keys that both renderers emit must match in value.
+ * Keys present in only one renderer must be listed in the appropriate
+ * exclusion set below — forcing intentional differences to be documented.
+ *
+ * When a new option is added to either renderer this test fails unless the
+ * option is also added to the other renderer OR explicitly excluded here.
+ *
+ * @dataProvider db_state_provider
+ */
+ public function test_all_shared_defaults_match( bool $with_feeds ): void {
+ if ( $with_feeds ) {
+ ( new Spotmap_Database() )->insert_point( [
+ 'feedName' => 'rendering-test-feed',
+ 'feedId' => 'fid-rendering',
+ 'messageType' => 'OK',
+ 'unixTime' => 1700003000,
+ 'latitude' => 47.0,
+ 'longitude' => 8.0,
+ 'modelId' => 'SPOT-X',
+ 'messengerName' => 'Device',
+ 'messageContent' => '',
+ ] );
+ }
+
+ $sc = $this->extract_options( do_shortcode( '[spotmap]' ) );
+ $block = $this->extract_options( $this->render_block( [] ) );
+
+ // Keys intentionally present only in the block (no shortcode equivalent).
+ $block_only = [ 'height', 'enablePanning', 'scrollWheelZoom' ];
+
+ // Keys intentionally present only in the shortcode (no block equivalent).
+ // NOTE: shortcode 'last-point' is a convenience flag that PHP expands into
+ // styles[feed].lastPoint for every feed — no top-level key reaches the engine.
+ // NOTE: 'date' was shortcode-only but is now converted in PHP to a full-day
+ // dateRange (00:00:00–23:59:59) before building options, so both
+ // renderers pass only 'dateRange' to the engine.
+ $shortcode_only = [];
+
+ // Keys that differ by design (random per-render, etc.).
+ $ignored = [ 'mapId' ];
+
+ $all_keys = array_unique( array_merge( array_keys( $sc ), array_keys( $block ) ) );
+
+ foreach ( $all_keys as $key ) {
+ if ( in_array( $key, $ignored, true ) ) {
+ continue;
+ }
+
+ if ( in_array( $key, $block_only, true ) ) {
+ $this->assertArrayNotHasKey( $key, $sc, "'$key' is block-only but appeared in shortcode output" );
+ continue;
+ }
+
+ if ( in_array( $key, $shortcode_only, true ) ) {
+ $this->assertArrayNotHasKey( $key, $block, "'$key' is shortcode-only but appeared in block output" );
+ continue;
+ }
+
+ $this->assertArrayHasKey( $key, $sc, "'$key' present in block but missing from shortcode" );
+ $this->assertArrayHasKey( $key, $block, "'$key' present in shortcode but missing from block" );
+ $this->assertSame( $sc[ $key ], $block[ $key ], "Default for '$key' differs between shortcode and block" );
+ }
+ }
+
+ public function test_html_contains_map_div_and_init_script(): void {
+ $sc = do_shortcode( '[spotmap feeds="f1"]' );
+ $block = $this->render_block( [ 'feeds' => [] ] );
+
+ $this->assertStringContainsString( 'assertStringContainsString( 'initMap', $sc );
+ $this->assertStringContainsString( '
assertStringContainsString( 'initMap', $block );
+ }
+}
diff --git a/tests/SpotmapRestApiTest.php b/tests/SpotmapRestApiTest.php
new file mode 100644
index 0000000..6297441
--- /dev/null
+++ b/tests/SpotmapRestApiTest.php
@@ -0,0 +1,550 @@
+getProperty( 'cache' );
+ self::$admin_id = self::factory()->user->create( [ 'role' => 'administrator' ] );
+ self::$subscriber_id = self::factory()->user->create( [ 'role' => 'subscriber' ] );
+ }
+
+ protected function setUp(): void {
+ parent::setUp();
+ self::$cache_prop->setValue( null, [] );
+ wp_set_current_user( self::$admin_id );
+ // Default: SPOT API reports E-0195 (valid feed, no points yet).
+ $this->mock_spot_api( 'E-0195' );
+ }
+
+ protected function tearDown(): void {
+ $this->remove_spot_api_mock();
+ parent::tearDown();
+ }
+
+ // -------------------------------------------------------------------------
+ // Helpers
+ // -------------------------------------------------------------------------
+
+ private function request( string $method, string $route, array $body = [] ): WP_REST_Response {
+ $request = new WP_REST_Request( $method, '/spotmap/v1' . $route );
+ if ( ! empty( $body ) ) {
+ $request->set_header( 'Content-Type', 'application/json' );
+ $request->set_body( wp_json_encode( $body ) );
+ }
+ return rest_get_server()->dispatch( $request );
+ }
+
+ private function valid_feed(): array {
+ return [
+ 'type' => 'findmespot',
+ 'name' => 'Alps 2024',
+ 'feed_id' => self::FEED_ID,
+ 'password' => '',
+ ];
+ }
+
+ /**
+ * Replaces the current HTTP mock with one that returns the given SPOT error code.
+ * Pass an empty string to simulate a successful response with data (no error).
+ */
+ private function mock_spot_api( string $error_code ): void {
+ $this->remove_spot_api_mock();
+ $body = $error_code !== ''
+ ? wp_json_encode( [ 'response' => [ 'errors' => [ 'error' => [ 'code' => $error_code ] ] ] ] )
+ : wp_json_encode( [ 'response' => [ 'feedMessageResponse' => [ 'count' => 1 ] ] ] );
+ $this->http_mock = fn() => [
+ 'headers' => [],
+ 'body' => $body,
+ 'response' => [ 'code' => 200, 'message' => 'OK' ],
+ 'cookies' => [],
+ 'http_response' => null,
+ ];
+ add_filter( 'pre_http_request', $this->http_mock, 10, 3 );
+ }
+
+ private function remove_spot_api_mock(): void {
+ if ( $this->http_mock !== null ) {
+ remove_filter( 'pre_http_request', $this->http_mock );
+ $this->http_mock = null;
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // Authentication
+ // -------------------------------------------------------------------------
+
+ public function test_unauthenticated_request_returns_401_or_403(): void {
+ wp_set_current_user( 0 );
+ $response = $this->request( 'GET', '/feeds' );
+ $this->assertContains( $response->get_status(), [ 401, 403 ] );
+ }
+
+ public function test_subscriber_cannot_access_feeds(): void {
+ wp_set_current_user( self::$subscriber_id );
+ $response = $this->request( 'GET', '/feeds' );
+ $this->assertContains( $response->get_status(), [ 401, 403 ] );
+ }
+
+ // -------------------------------------------------------------------------
+ // GET /feeds
+ // -------------------------------------------------------------------------
+
+ public function test_get_feeds_returns_200(): void {
+ $response = $this->request( 'GET', '/feeds' );
+ $this->assertSame( 200, $response->get_status() );
+ }
+
+ public function test_get_feeds_returns_empty_array_by_default(): void {
+ $response = $this->request( 'GET', '/feeds' );
+ $this->assertSame( [], $response->get_data() );
+ }
+
+ public function test_get_feeds_returns_existing_feeds(): void {
+ Spotmap_Options::add_feed( $this->valid_feed() );
+ self::$cache_prop->setValue( null, [] );
+
+ $response = $this->request( 'GET', '/feeds' );
+ $this->assertCount( 1, $response->get_data() );
+ }
+
+ // -------------------------------------------------------------------------
+ // POST /feeds
+ // -------------------------------------------------------------------------
+
+ public function test_create_feed_returns_201(): void {
+ $response = $this->request( 'POST', '/feeds', $this->valid_feed() );
+ $this->assertSame( 201, $response->get_status() );
+ }
+
+ public function test_create_feed_returns_feed_with_id(): void {
+ $response = $this->request( 'POST', '/feeds', $this->valid_feed() );
+ $data = $response->get_data();
+ $this->assertArrayHasKey( 'id', $data );
+ $this->assertNotEmpty( $data['id'] );
+ }
+
+ public function test_create_feed_persists_to_options(): void {
+ $this->request( 'POST', '/feeds', $this->valid_feed() );
+ self::$cache_prop->setValue( null, [] );
+ $this->assertCount( 1, Spotmap_Options::get_feeds() );
+ }
+
+ public function test_create_feed_returns_422_for_unknown_type(): void {
+ $response = $this->request( 'POST', '/feeds', [
+ 'type' => 'unknown_provider',
+ 'name' => 'Test',
+ 'feed_id' => self::FEED_ID,
+ ] );
+ $this->assertSame( 422, $response->get_status() );
+ }
+
+ public function test_create_feed_returns_422_when_name_missing(): void {
+ $response = $this->request( 'POST', '/feeds', [
+ 'type' => 'findmespot',
+ 'name' => '',
+ 'feed_id' => self::FEED_ID,
+ ] );
+ $this->assertSame( 422, $response->get_status() );
+ }
+
+ public function test_create_feed_returns_422_when_feed_id_missing(): void {
+ $response = $this->request( 'POST', '/feeds', [
+ 'type' => 'findmespot',
+ 'name' => 'Test Feed',
+ 'feed_id' => '',
+ ] );
+ $this->assertSame( 422, $response->get_status() );
+ }
+
+ public function test_create_feed_password_is_optional(): void {
+ $response = $this->request( 'POST', '/feeds', [
+ 'type' => 'findmespot',
+ 'name' => 'No Password Feed',
+ 'feed_id' => self::FEED_ID,
+ ] );
+ $this->assertSame( 201, $response->get_status() );
+ }
+
+ public function test_create_feed_with_password_returns_redacted_in_response(): void {
+ $response = $this->request( 'POST', '/feeds', [
+ 'type' => 'findmespot',
+ 'name' => 'Secure Feed',
+ 'feed_id' => self::FEED_ID,
+ 'password' => 'p@$$w0rd&
',
+ ] );
+ $this->assertSame( 201, $response->get_status() );
+ $this->assertSame( Spotmap_Rest_Api::REDACTED, $response->get_data()['password'] );
+ }
+
+ public function test_create_feed_stores_password_verbatim(): void {
+ $response = $this->request( 'POST', '/feeds', [
+ 'type' => 'findmespot',
+ 'name' => 'Secure Feed',
+ 'feed_id' => self::FEED_ID,
+ 'password' => 'p@$$w0rd&',
+ ] );
+ self::$cache_prop->setValue( null, [] );
+ $id = $response->get_data()['id'];
+ $feed = Spotmap_Options::get_feed( $id );
+ $this->assertSame( 'p@$$w0rd&', $feed['password'] );
+ }
+
+ public function test_create_feed_returns_422_for_unknown_feed_id(): void {
+ $this->mock_spot_api( 'E-0160' );
+ $response = $this->request( 'POST', '/feeds', $this->valid_feed() );
+ $this->assertSame( 422, $response->get_status() );
+ }
+
+ public function test_create_feed_succeeds_when_spot_api_unreachable(): void {
+ $this->remove_spot_api_mock();
+ add_filter( 'pre_http_request', fn() => new WP_Error( 'http_request_failed', 'cURL error 6' ), 10, 3 );
+ $response = $this->request( 'POST', '/feeds', $this->valid_feed() );
+ $this->assertSame( 201, $response->get_status() );
+ }
+
+ public function test_get_feeds_masks_non_empty_password(): void {
+ $this->request( 'POST', '/feeds', [
+ 'type' => 'findmespot',
+ 'name' => 'Secure Feed',
+ 'feed_id' => self::FEED_ID,
+ 'password' => 'secret',
+ ] );
+ self::$cache_prop->setValue( null, [] );
+ $response = $this->request( 'GET', '/feeds' );
+ $this->assertSame( Spotmap_Rest_Api::REDACTED, $response->get_data()[0]['password'] );
+ }
+
+ public function test_get_feeds_empty_password_not_masked(): void {
+ $this->request( 'POST', '/feeds', $this->valid_feed() ); // password = ''
+ self::$cache_prop->setValue( null, [] );
+ $response = $this->request( 'GET', '/feeds' );
+ $this->assertSame( '', $response->get_data()[0]['password'] );
+ }
+
+ public function test_update_feed_sentinel_preserves_stored_password(): void {
+ $create = $this->request( 'POST', '/feeds', [
+ 'type' => 'findmespot',
+ 'name' => 'Pwd Feed',
+ 'feed_id' => self::FEED_ID,
+ 'password' => 'original_secret',
+ ] );
+ $id = $create->get_data()['id'];
+
+ // Update name only — echo sentinel for the password.
+ $this->request( 'PUT', '/feeds/' . $id, [
+ 'type' => 'findmespot',
+ 'name' => 'Renamed',
+ 'feed_id' => self::FEED_ID,
+ 'password' => Spotmap_Rest_Api::REDACTED,
+ ] );
+ self::$cache_prop->setValue( null, [] );
+ $this->assertSame( 'original_secret', Spotmap_Options::get_feed( $id )['password'] );
+ }
+
+ public function test_update_feed_new_password_overwrites_stored(): void {
+ $create = $this->request( 'POST', '/feeds', [
+ 'type' => 'findmespot',
+ 'name' => 'Pwd Feed',
+ 'feed_id' => self::FEED_ID,
+ 'password' => 'original_secret',
+ ] );
+ $id = $create->get_data()['id'];
+
+ $this->request( 'PUT', '/feeds/' . $id, [
+ 'type' => 'findmespot',
+ 'name' => 'Pwd Feed',
+ 'feed_id' => self::FEED_ID,
+ 'password' => 'new_secret',
+ ] );
+ self::$cache_prop->setValue( null, [] );
+ $this->assertSame( 'new_secret', Spotmap_Options::get_feed( $id )['password'] );
+ }
+
+ // -------------------------------------------------------------------------
+ // PUT /feeds/{id}
+ // -------------------------------------------------------------------------
+
+ public function test_update_feed_returns_200(): void {
+ $feed = Spotmap_Options::add_feed( $this->valid_feed() );
+ $response = $this->request( 'PUT', '/feeds/' . $feed['id'], [
+ 'type' => 'findmespot',
+ 'name' => 'Updated Name',
+ 'feed_id' => self::FEED_ID,
+ 'password' => '',
+ ] );
+ $this->assertSame( 200, $response->get_status() );
+ }
+
+ public function test_update_feed_persists_new_name(): void {
+ $feed = Spotmap_Options::add_feed( $this->valid_feed() );
+ $this->request( 'PUT', '/feeds/' . $feed['id'], [
+ 'type' => 'findmespot',
+ 'name' => 'New Name',
+ 'feed_id' => self::FEED_ID,
+ 'password' => '',
+ ] );
+ self::$cache_prop->setValue( null, [] );
+ $this->assertSame( 'New Name', Spotmap_Options::get_feed( $feed['id'] )['name'] );
+ }
+
+ public function test_update_feed_preserves_id(): void {
+ $feed = Spotmap_Options::add_feed( $this->valid_feed() );
+ $response = $this->request( 'PUT', '/feeds/' . $feed['id'], [
+ 'type' => 'findmespot',
+ 'name' => 'Renamed',
+ 'feed_id' => self::FEED_ID,
+ 'password' => '',
+ ] );
+ $this->assertSame( $feed['id'], $response->get_data()['id'] );
+ }
+
+ public function test_update_feed_returns_404_for_unknown_id(): void {
+ $response = $this->request( 'PUT', '/feeds/no_such_id', [
+ 'type' => 'findmespot',
+ 'name' => 'Ghost',
+ 'feed_id' => self::FEED_ID,
+ ] );
+ $this->assertSame( 404, $response->get_status() );
+ }
+
+ public function test_update_feed_returns_422_for_invalid_data(): void {
+ $feed = Spotmap_Options::add_feed( $this->valid_feed() );
+ $response = $this->request( 'PUT', '/feeds/' . $feed['id'], [
+ 'type' => 'findmespot',
+ 'name' => '',
+ 'feed_id' => self::FEED_ID,
+ ] );
+ $this->assertSame( 422, $response->get_status() );
+ }
+
+ public function test_update_feed_returns_422_for_unknown_feed_id(): void {
+ $feed = Spotmap_Options::add_feed( $this->valid_feed() );
+ $this->mock_spot_api( 'E-0160' );
+ $response = $this->request( 'PUT', '/feeds/' . $feed['id'], $this->valid_feed() );
+ $this->assertSame( 422, $response->get_status() );
+ }
+
+ // -------------------------------------------------------------------------
+ // DELETE /feeds/{id}
+ // -------------------------------------------------------------------------
+
+ public function test_delete_feed_returns_204(): void {
+ $feed = Spotmap_Options::add_feed( $this->valid_feed() );
+ $response = $this->request( 'DELETE', '/feeds/' . $feed['id'] );
+ $this->assertSame( 204, $response->get_status() );
+ }
+
+ public function test_delete_feed_removes_from_options(): void {
+ $feed = Spotmap_Options::add_feed( $this->valid_feed() );
+ $this->request( 'DELETE', '/feeds/' . $feed['id'] );
+ self::$cache_prop->setValue( null, [] );
+ $this->assertNull( Spotmap_Options::get_feed( $feed['id'] ) );
+ }
+
+ public function test_delete_feed_returns_404_for_unknown_id(): void {
+ $response = $this->request( 'DELETE', '/feeds/no_such_id' );
+ $this->assertSame( 404, $response->get_status() );
+ }
+
+ // -------------------------------------------------------------------------
+ // GET /providers
+ // -------------------------------------------------------------------------
+
+ public function test_get_providers_returns_200(): void {
+ $response = $this->request( 'GET', '/providers' );
+ $this->assertSame( 200, $response->get_status() );
+ }
+
+ public function test_get_providers_includes_findmespot(): void {
+ $response = $this->request( 'GET', '/providers' );
+ $this->assertArrayHasKey( 'findmespot', $response->get_data() );
+ }
+
+ public function test_get_providers_findmespot_has_fields(): void {
+ $response = $this->request( 'GET', '/providers' );
+ $data = $response->get_data();
+ $this->assertArrayHasKey( 'fields', $data['findmespot'] );
+ $this->assertNotEmpty( $data['findmespot']['fields'] );
+ }
+
+ // -------------------------------------------------------------------------
+ // GET /markers
+ // -------------------------------------------------------------------------
+
+ public function test_get_markers_returns_200(): void {
+ $response = $this->request( 'GET', '/markers' );
+ $this->assertSame( 200, $response->get_status() );
+ }
+
+ public function test_get_markers_returns_all_default_types(): void {
+ $response = $this->request( 'GET', '/markers' );
+ $data = $response->get_data();
+ foreach ( array_keys( Spotmap_Options::get_marker_defaults() ) as $type ) {
+ $this->assertArrayHasKey( $type, $data, "Marker type '$type' missing from response" );
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // PUT /markers
+ // -------------------------------------------------------------------------
+
+ public function test_update_markers_returns_200(): void {
+ $response = $this->request( 'PUT', '/markers', [
+ 'OK' => [ 'iconShape' => 'circle', 'icon' => 'star', 'customMessage' => '' ],
+ ] );
+ $this->assertSame( 200, $response->get_status() );
+ }
+
+ public function test_update_markers_persists_changes(): void {
+ $this->request( 'PUT', '/markers', [
+ 'OK' => [ 'iconShape' => 'circle', 'icon' => 'star', 'customMessage' => '' ],
+ ] );
+ self::$cache_prop->setValue( null, [] );
+ $this->assertSame( 'star', Spotmap_Options::get_marker_setting( 'OK', 'icon' ) );
+ }
+
+ public function test_update_markers_ignores_unknown_types(): void {
+ $this->request( 'PUT', '/markers', [
+ 'UNKNOWN_TYPE' => [ 'iconShape' => 'circle', 'icon' => 'star', 'customMessage' => '' ],
+ ] );
+ self::$cache_prop->setValue( null, [] );
+ // The unknown type must not be stored.
+ $stored = get_option( Spotmap_Options::OPTION_MARKER );
+ $this->assertFalse( isset( $stored['UNKNOWN_TYPE'] ) );
+ }
+
+ public function test_update_markers_returns_merged_result(): void {
+ $response = $this->request( 'PUT', '/markers', [
+ 'OK' => [ 'iconShape' => 'circle', 'icon' => 'star', 'customMessage' => '' ],
+ ] );
+ $data = $response->get_data();
+ // Response includes all default types, not just what was sent.
+ $this->assertArrayHasKey( 'HELP', $data );
+ }
+
+ // -------------------------------------------------------------------------
+ // GET /tokens
+ // -------------------------------------------------------------------------
+
+ public function test_get_tokens_returns_200(): void {
+ $response = $this->request( 'GET', '/tokens' );
+ $this->assertSame( 200, $response->get_status() );
+ }
+
+ public function test_get_tokens_includes_all_known_keys(): void {
+ $response = $this->request( 'GET', '/tokens' );
+ $data = $response->get_data();
+ foreach ( array_keys( Spotmap_Options::get_api_token_defaults() ) as $key ) {
+ $this->assertArrayHasKey( $key, $data, "Token key '$key' missing from response" );
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // PUT /tokens
+ // -------------------------------------------------------------------------
+
+ public function test_update_tokens_returns_200(): void {
+ $response = $this->request( 'PUT', '/tokens', [ 'mapbox' => 'pk.test123' ] );
+ $this->assertSame( 200, $response->get_status() );
+ }
+
+ public function test_update_tokens_persists_value(): void {
+ $this->request( 'PUT', '/tokens', [ 'mapbox' => 'pk.test123' ] );
+ self::$cache_prop->setValue( null, [] );
+ $this->assertSame( 'pk.test123', Spotmap_Options::get_api_token( 'mapbox' ) );
+ }
+
+ public function test_update_tokens_ignores_unknown_keys(): void {
+ $this->request( 'PUT', '/tokens', [ 'unknown_service' => 'tok_xxx' ] );
+ self::$cache_prop->setValue( null, [] );
+ $stored = get_option( Spotmap_Options::OPTION_API_TOKENS );
+ $this->assertFalse( isset( $stored['unknown_service'] ) );
+ }
+
+ public function test_get_tokens_masks_non_empty_value(): void {
+ $this->request( 'PUT', '/tokens', [ 'mapbox' => 'pk.realtoken' ] );
+ self::$cache_prop->setValue( null, [] );
+ $response = $this->request( 'GET', '/tokens' );
+ $this->assertSame( Spotmap_Rest_Api::REDACTED, $response->get_data()['mapbox'] );
+ }
+
+ public function test_get_tokens_empty_value_not_masked(): void {
+ $this->request( 'PUT', '/tokens', [ 'mapbox' => '' ] );
+ self::$cache_prop->setValue( null, [] );
+ $response = $this->request( 'GET', '/tokens' );
+ $this->assertSame( '', $response->get_data()['mapbox'] );
+ }
+
+ public function test_update_tokens_sentinel_preserves_stored_token(): void {
+ $this->request( 'PUT', '/tokens', [ 'mapbox' => 'pk.realtoken' ] );
+ self::$cache_prop->setValue( null, [] );
+
+ // Echo sentinel back — should not overwrite.
+ $this->request( 'PUT', '/tokens', [ 'mapbox' => Spotmap_Rest_Api::REDACTED ] );
+ self::$cache_prop->setValue( null, [] );
+ $this->assertSame( 'pk.realtoken', Spotmap_Options::get_api_token( 'mapbox' ) );
+ }
+
+ public function test_update_tokens_persists_value_is_not_exposed_in_response(): void {
+ $response = $this->request( 'PUT', '/tokens', [ 'mapbox' => 'pk.test123' ] );
+ $this->assertSame( Spotmap_Rest_Api::REDACTED, $response->get_data()['mapbox'] );
+ }
+
+ // -------------------------------------------------------------------------
+ // GET /defaults
+ // -------------------------------------------------------------------------
+
+ public function test_get_defaults_returns_200(): void {
+ $response = $this->request( 'GET', '/defaults' );
+ $this->assertSame( 200, $response->get_status() );
+ }
+
+ public function test_get_defaults_includes_height(): void {
+ $response = $this->request( 'GET', '/defaults' );
+ $this->assertArrayHasKey( 'height', $response->get_data() );
+ }
+
+ // -------------------------------------------------------------------------
+ // PUT /defaults
+ // -------------------------------------------------------------------------
+
+ public function test_update_defaults_returns_200(): void {
+ $response = $this->request( 'PUT', '/defaults', [ 'height' => 800 ] );
+ $this->assertSame( 200, $response->get_status() );
+ }
+
+ public function test_update_defaults_persists_value(): void {
+ $this->request( 'PUT', '/defaults', [ 'height' => 800 ] );
+ self::$cache_prop->setValue( null, [] );
+ $this->assertSame( 800, Spotmap_Options::get_setting( 'height' ) );
+ }
+
+ public function test_update_defaults_preserves_numeric_type(): void {
+ $this->request( 'PUT', '/defaults', [ 'height' => 600 ] );
+ self::$cache_prop->setValue( null, [] );
+ $value = Spotmap_Options::get_setting( 'height' );
+ $this->assertIsInt( $value );
+ }
+
+ public function test_update_defaults_ignores_unknown_keys(): void {
+ $this->request( 'PUT', '/defaults', [ 'nonexistent_key' => 'value' ] );
+ self::$cache_prop->setValue( null, [] );
+ $stored = get_option( Spotmap_Options::OPTION_DEFAULT_VALUES );
+ $this->assertFalse( isset( $stored['nonexistent_key'] ) );
+ }
+}
diff --git a/tests/bootstrap.php b/tests/bootstrap.php
new file mode 100644
index 0000000..cb97ea5
--- /dev/null
+++ b/tests/bootstrap.php
@@ -0,0 +1,24 @@
+query( "DROP TABLE IF EXISTS {$wpdb->prefix}spotmap_points" );
+Spotmap_Database::create_table();
diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts
new file mode 100644
index 0000000..6fe2af0
--- /dev/null
+++ b/tests/e2e/global-setup.ts
@@ -0,0 +1,132 @@
+import { execSync } from 'child_process';
+import * as dotenv from 'dotenv';
+import * as fs from 'fs';
+import * as path from 'path';
+
+/**
+ * All base layer keys from config/maps.yaml.
+ * Token-gated maps will simply be absent from spotmapjsobj.maps at runtime —
+ * listing all keys here is safe because LayerManager skips unknown keys.
+ */
+const ALL_MAP_KEYS = [
+ 'mb-outdoors',
+ 'mb-satelite',
+ 'mb-streets',
+ 'tf-landscape',
+ 'tf-transport',
+ 'tf-atlas',
+ 'tf-outdoors',
+ 'tf-cycle',
+ 'openstreetmap',
+ 'opentopomap',
+ 'spain-ign-topo',
+ 'france-ign-topo',
+ 'newzealand-topo50',
+ 'newzealand-topo250',
+ 'stamen-watercolor',
+ 'esri-natgeoworldmap',
+ 'uk-os-outdoor',
+ 'uk-os-road',
+ 'uk-os-light',
+ 'usgs-topo',
+ 'usgs-topo-sat',
+ 'usgs-sat',
+];
+
+/**
+ * Maps .env variable names to the WP option keys used in spotmap_api_tokens.
+ * These match the 'option' field in Spotmap_Admin::get_maps().
+ */
+const TOKEN_OPTION_MAP: Record< string, string > = {
+ SPOTMAP_TOKEN_MAPBOX: 'mapbox',
+ SPOTMAP_TOKEN_THUNDERFOREST: 'thunderforest',
+ SPOTMAP_TOKEN_TIMEZONEDB: 'timezonedb',
+ SPOTMAP_TOKEN_LINZ: 'linz.govt.nz',
+ SPOTMAP_TOKEN_GEOPORTAIL: 'geoservices.ign.fr',
+ SPOTMAP_TOKEN_OSDATAHUB: 'osdatahub.os.uk',
+};
+
+// The plugin directory is mounted at this path inside every wp-env container.
+const CONTAINER_PLUGIN_PATH = '/var/www/html/wp-content/plugins/Spotmap';
+
+// Temp PHP file written by Node.js, executed inside the container via eval-file.
+// Written to the plugin dir so it is automatically available at the container path.
+const TEMP_PHP_LOCAL = path.resolve( process.cwd(), 'tests/e2e/.inject.php' );
+const TEMP_PHP_CONTAINER = `${ CONTAINER_PLUGIN_PATH }/tests/e2e/.inject.php`;
+
+function run( cmd: string ): void {
+ // eslint-disable-next-line no-console
+ console.log( `\n> ${ cmd }` );
+ execSync( cmd, { stdio: 'inherit' } );
+}
+
+/**
+ * Write PHP code to a temp file and execute it via wp eval-file.
+ * This avoids all shell-quoting issues: the PHP source is written directly
+ * by Node.js (no shell involved), and wp eval-file only receives a file path.
+ */
+function runPhp( php: string, env: string = 'tests-cli' ): void {
+ fs.writeFileSync( TEMP_PHP_LOCAL, ` ): string {
+ const pairs = Object.entries( obj )
+ .map( ( [ k, v ] ) => `'${ k }' => '${ v }'` )
+ .join( ', ' );
+ return `[ ${ pairs } ]`;
+}
+
+export default async function globalSetup(): Promise< void > {
+ // Load private tokens from .env (gitignored)
+ const envFile = path.resolve( process.cwd(), '.env' );
+ if ( fs.existsSync( envFile ) ) {
+ dotenv.config( { path: envFile } );
+ } else {
+ console.warn(
+ 'Warning: .env not found — token-gated maps will be excluded.'
+ );
+ }
+
+ // Ensure the plugin is active on the tests environment (port 8889)
+ run( 'npx wp-env run tests-cli -- wp plugin activate Spotmap/spotmap.php' );
+
+ // Build the tokens object from env vars
+ const tokens: Record< string, string > = {};
+ for ( const [ envKey, optionKey ] of Object.entries( TOKEN_OPTION_MAP ) ) {
+ const value = process.env[ envKey ];
+ if ( value ) {
+ tokens[ optionKey ] = value;
+ }
+ }
+
+ // Inject tokens via a temp PHP file — no shell quoting required
+ if ( Object.keys( tokens ).length > 0 ) {
+ runPhp(
+ `Spotmap_Options::save_api_tokens( ${ phpArray( tokens ) } );`
+ );
+ }
+
+ // Build block content listing every map key.
+ // PHP will only expose maps whose tokens are set, so spotmapjsobj.maps will
+ // be correctly filtered — but the block must list all keys upfront.
+ const blockAttrs = JSON.stringify( {
+ maps: ALL_MAP_KEYS,
+ feeds: [],
+ styles: {},
+ } );
+ // Block content only contains double-quotes and no single-quotes, so it is
+ // safe inside a PHP single-quoted string.
+ const blockContent = ``;
+
+ runPhp(
+ `wp_update_post( [ 'ID' => 1, 'post_content' => '${ blockContent }', 'post_status' => 'publish' ] );`
+ );
+}
diff --git a/tests/e2e/maps.spec.ts b/tests/e2e/maps.spec.ts
new file mode 100644
index 0000000..ae324b9
--- /dev/null
+++ b/tests/e2e/maps.spec.ts
@@ -0,0 +1,263 @@
+import { test, expect } from '@playwright/test';
+
+/**
+ * Geographic center coordinates used to trigger tile loading for each map.
+ * Maps with regional coverage need coordinates inside their coverage area,
+ * otherwise the tile request is still made but may 404. Using the right
+ * center avoids ambiguity between "auth failed" and "tile doesn't exist here".
+ */
+const MAP_VIEW: Record< string, { lat: number; lng: number; zoom: number } > = {
+ 'uk-os-outdoor': { lat: 51.5074, lng: -0.1278, zoom: 8 }, // London
+ 'uk-os-road': { lat: 51.5074, lng: -0.1278, zoom: 8 },
+ 'uk-os-light': { lat: 51.5074, lng: -0.1278, zoom: 8 },
+ 'usgs-topo': { lat: 38.9072, lng: -77.0369, zoom: 8 }, // Washington DC
+ 'usgs-topo-sat': { lat: 38.9072, lng: -77.0369, zoom: 8 },
+ 'usgs-sat': { lat: 38.9072, lng: -77.0369, zoom: 8 },
+ 'newzealand-topo50': { lat: -36.8485, lng: 174.7633, zoom: 8 }, // Auckland
+ 'newzealand-topo250': { lat: -36.8485, lng: 174.7633, zoom: 8 },
+ 'spain-ign-topo': { lat: 40.4168, lng: -3.7038, zoom: 8 }, // Madrid
+};
+
+const DEFAULT_VIEW = { lat: 48.8566, lng: 2.3522, zoom: 5 }; // Paris
+
+/**
+ * HTTP status codes that indicate the token or URL is invalid.
+ * 404 is accepted — tile may not exist at these coordinates, but auth worked.
+ */
+const AUTH_ERROR_STATUSES = [ 400, 401, 403 ];
+
+test.describe( 'Tile layer URL validation', () => {
+ test.beforeEach( async ( { page } ) => {
+ await page.goto( '/?p=1' );
+ await page.waitForFunction(
+ () =>
+ typeof ( window as any ).L !== 'undefined' &&
+ typeof ( window as any ).spotmapjsobj !== 'undefined' &&
+ typeof ( window as any ).Spotmap !== 'undefined'
+ );
+ } );
+
+ test( 'every base layer tile URL is reachable and authenticates', async ( {
+ page,
+ request,
+ } ) => {
+ /* eslint-disable @typescript-eslint/no-explicit-any */
+ const maps: Record< string, any > = await page.evaluate(
+ () => ( window as any ).spotmapjsobj.maps
+ );
+
+ for ( const [ mapKey, mapConfig ] of Object.entries( maps ) ) {
+ await test.step( `${ mapKey } — ${ mapConfig.label }`, async () => {
+ const view = MAP_VIEW[ mapKey ] ?? DEFAULT_VIEW;
+
+ /**
+ * Create a real Leaflet map in the browser, add the tile layer,
+ * and capture the first tile URL by patching Image.prototype.src.
+ * Leaflet always creates elements (for both TileLayer and
+ * TileLayer.WMS), so this works for all layer types.
+ */
+ const { url, setupError } = await page.evaluate(
+ async ( args: {
+ key: string;
+ config: any;
+ view: { lat: number; lng: number; zoom: number };
+ } ) => {
+ const L = ( window as any ).L;
+ const capturedUrls: string[] = [];
+
+ // Patch Image.src to intercept every tile URL Leaflet creates
+ const origDescriptor = Object.getOwnPropertyDescriptor(
+ HTMLImageElement.prototype,
+ 'src'
+ )!;
+ Object.defineProperty(
+ HTMLImageElement.prototype,
+ 'src',
+ {
+ set( val: string ) {
+ if (
+ typeof val === 'string' &&
+ val.startsWith( 'http' )
+ ) {
+ capturedUrls.push( val );
+ }
+ origDescriptor.set!.call( this, val );
+ },
+ get() {
+ return origDescriptor.get!.call( this );
+ },
+ configurable: true,
+ }
+ );
+
+ const el = document.createElement( 'div' );
+ el.id = `tile-test-${ args.key }`;
+ el.style.cssText =
+ 'width:512px;height:512px;position:fixed;left:0;top:0;z-index:9999;';
+ document.body.appendChild( el );
+
+ let layerError: string | null = null;
+
+ try {
+ const map = L.map( el );
+ const c = args.config as any;
+ const layer = c.wms
+ ? L.tileLayer.wms( c.url, c.options )
+ : L.tileLayer( c.url, c.options );
+ layer.addTo( map );
+ map.setView(
+ [ args.view.lat, args.view.lng ],
+ args.view.zoom
+ );
+
+ // Give Leaflet time to create tile elements
+ await new Promise( ( resolve ) =>
+ setTimeout( resolve, 500 )
+ );
+
+ map.remove();
+ } catch ( e: any ) {
+ layerError = e?.message ?? String( e );
+ } finally {
+ Object.defineProperty(
+ HTMLImageElement.prototype,
+ 'src',
+ origDescriptor
+ );
+ document
+ .getElementById( `tile-test-${ args.key }` )
+ ?.remove();
+ }
+
+ return {
+ url: capturedUrls[ 0 ] ?? null,
+ setupError: layerError,
+ };
+ },
+ { key: mapKey, config: mapConfig, view }
+ );
+
+ expect
+ .soft( setupError, `threw during setup: ${ setupError }` )
+ .toBeNull();
+ expect
+ .soft(
+ url,
+ 'no tile URL was captured — layer may not have rendered'
+ )
+ .not.toBeNull();
+
+ if ( setupError || ! url ) {
+ return;
+ }
+
+ // Fetch the tile URL from Node.js — no CORS restrictions here
+ let status: number;
+ try {
+ const response = await request.get( url, {
+ timeout: 15000,
+ } );
+ status = response.status();
+ } catch ( e: any ) {
+ // Network-level failure: DNS, connection refused, timeout
+ throw new Error(
+ `network error fetching tile: ${ e.message }\n URL: ${ url }`
+ );
+ }
+
+ expect
+ .soft(
+ AUTH_ERROR_STATUSES.includes( status ),
+ `HTTP ${ status } — token or URL is invalid\n URL: ${ url }`
+ )
+ .toBe( false );
+ } );
+ }
+ } );
+
+ test( 'every overlay in spotmapjsobj.overlays creates a tile layer without error', async ( {
+ page,
+ } ) => {
+ /* eslint-disable @typescript-eslint/no-explicit-any */
+ const overlays: Record< string, any > = await page.evaluate(
+ () => ( window as any ).spotmapjsobj.overlays
+ );
+
+ if ( ! overlays || Object.keys( overlays ).length === 0 ) {
+ test.skip();
+ return;
+ }
+
+ const results: Array< {
+ key: string;
+ label: string;
+ error: string | null;
+ } > = await page.evaluate( () => {
+ const allOverlays: Record< string, any > = ( window as any )
+ .spotmapjsobj.overlays;
+ const overlayResults: Array< {
+ key: string;
+ label: string;
+ error: string | null;
+ } > = [];
+
+ for ( const [ mapKey, config ] of Object.entries( allOverlays ) ) {
+ const el = document.createElement( 'div' );
+ el.id = `test-overlay-${ mapKey }`;
+ el.style.cssText =
+ 'width:300px;height:300px;position:fixed;left:-9999px;top:0;';
+ document.body.appendChild( el );
+
+ let error: string | null = null;
+ let spotmap: any = null;
+
+ try {
+ spotmap = new ( window as any ).Spotmap( {
+ mapId: el.id,
+ maps: [ 'openstreetmap' ],
+ mapOverlays: [ mapKey ],
+ feeds: [],
+ gpx: [],
+ styles: {},
+ height: 300,
+ mapcenter: 'all',
+ filterPoints: 0,
+ autoReload: false,
+ debug: false,
+ dateRange: { from: '', to: '' },
+ } );
+ spotmap.initMap();
+ } catch ( e: any ) {
+ error = e?.message ?? String( e );
+ } finally {
+ spotmap?.destroy();
+ document
+ .getElementById( `test-overlay-${ mapKey }` )
+ ?.remove();
+ }
+
+ overlayResults.push( {
+ key: mapKey,
+ label: config.label,
+ error,
+ } );
+ }
+
+ return overlayResults;
+ } );
+
+ // eslint-disable-next-line no-console
+ console.log(
+ `Testing ${ results.length } overlay(s): ${ results
+ .map( ( r ) => r.key )
+ .join( ', ' ) }`
+ );
+
+ for ( const r of results ) {
+ expect(
+ r.error,
+ `[${ r.key }] "${ r.label }" threw: "${ r.error }"`
+ ).toBeNull();
+ }
+ } );
+} );
diff --git a/tests/wp-tests-config.php b/tests/wp-tests-config.php
new file mode 100644
index 0000000..8855553
--- /dev/null
+++ b/tests/wp-tests-config.php
@@ -0,0 +1,17 @@
+ $count) {
- delete_option('spotmap_'.$key.'_name');
- delete_option('spotmap_'.$key.'_id');
- delete_option('spotmap_'.$key.'_password');
+require_once plugin_dir_path( __FILE__ ) . 'includes/class-spotmap-options.php';
-}
-delete_option("spotmap_api_providers");
+// Remove all plugin options.
+delete_option( Spotmap_Options::OPTION_FEEDS );
+delete_option( Spotmap_Options::OPTION_MARKER );
+delete_option( Spotmap_Options::OPTION_API_TOKENS );
+delete_option( Spotmap_Options::OPTION_DEFAULT_VALUES );
+delete_option( Spotmap_Options::OPTION_CUSTOM_MESSAGES );
+delete_option( Spotmap_Options::OPTION_VERSION );
+
+// Remove legacy 0.x.y options in case the plugin is deleted before migrating.
+delete_option( 'spotmap_api_providers' );
+delete_option( 'spotmap_findmespot_name' );
+delete_option( 'spotmap_findmespot_id' );
+delete_option( 'spotmap_findmespot_password' );
global $wpdb;
-$wpdb->query("DROP TABLE IF EXISTS {$wpdb->prefix}spotmap_points");
\ No newline at end of file
+$wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}spotmap_points" );
diff --git a/webpack.config.js b/webpack.config.js
new file mode 100644
index 0000000..d3898a7
--- /dev/null
+++ b/webpack.config.js
@@ -0,0 +1,25 @@
+const defaultConfig = require( '@wordpress/scripts/config/webpack.config' );
+
+module.exports = {
+ ...defaultConfig,
+ entry: {
+ ...defaultConfig.entry(),
+ 'spotmap-map': './src/map-engine/index.ts',
+ 'spotmap-admin': './src/spotmap-admin/index.js',
+ },
+ module: {
+ ...defaultConfig.module,
+ rules: [
+ ...( defaultConfig.module?.rules || [] ),
+ {
+ test: /\.tsx?$/,
+ use: 'ts-loader',
+ exclude: [ /node_modules/, /__tests__/ ],
+ },
+ ],
+ },
+ resolve: {
+ ...defaultConfig.resolve,
+ extensions: [ '.ts', '.tsx', '.js', '.jsx' ],
+ },
+};