diff --git a/README.md b/README.md index 8b0a063..7b12105 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ This bundle adds google maps integration to [Contao](https://contao.org/de/). It - frontend module and content element - insert tag and twig function - easy contao command based migration tool for [delahaye/dlh_googlemaps](https://github.com/delahaye/dlh_googlemaps) (courtesy to delahaye!) +- GeoJSON layer overlay type replacing the deprecated `KmlLayer`, including a command to convert existing KML files - responsive support (mobile first), provide responsive configurations that will update the map upon reaching the value (greater than breakpoint) - support for [hofff/contao-consent-bridge](https://github.com/hofff/contao-consent-bridge) - support for [Oveleon Cookiebar](https://packagist.org/packages/oveleon/contao-cookiebar) @@ -70,6 +71,42 @@ To render your map in a twig template, use `google_map` function: +## GeoJSON layers (replacing KML) + +Google's `KmlLayer` is deprecated: unavailable from Maps JavaScript API 3.66 (August 2026), removed in May 2027. The **GeoJSON layer** +overlay type replaces it, built on `google.maps.Data`. + +Since GeoJSON has no style specification, styling comes from each feature's [simplestyle](https://github.com/mapbox/simplestyle-spec) +properties (`stroke`, `stroke-width`, `fill`, `icon`, ...), falling back to the overlay's own style settings. Info windows are built from the +remaining properties and use BEM classes (`.huh-geojson-infowindow`, `__text`, `__properties`) without any styling of their own, so style +them in your project. + +### Converting existing KML files + +```bash +vendor/bin/contao-console huh:google-maps:convert-kml files/maps --dry-run +vendor/bin/contao-console huh:google-maps:convert-kml files/maps --force + +# rewrite absolute icon URLs to mirrored local files +vendor/bin/contao-console huh:google-maps:convert-kml files/maps --force \ + --icon-base=files/maps/ico --local-host=maps.example.com +``` + +Writes `.geojson` next to each `.kml` and leaves the sources untouched. Paths may be files or directories (scanned recursively), +defaulting to the project root. + +| Option | Description | +|------------------|------------------------------------------------------------------------------------------------------------| +| `--dry-run` | Report what would be written without writing. | +| `--force` | Overwrite existing `.geojson` files. | +| `--icon-base` | Project-relative base path for rewritten icon URLs. Without it, URLs stay untouched. | +| `--local-host` | Host whose icon URLs are rewritten below `--icon-base`. Repeatable. | +| `--keep-folders` | Keep the KML folder hierarchy as `folder`/`folderPath` properties, e.g. to drive a layer switcher. | +| `--precision` | Coordinate decimal places (default `6`). | + +Icons on `maps.google.com` are matched by file name below `/google/`. After each run, external URLs left alone and rewritten +paths that do not exist are reported, so broken markers surface before they reach the map. + ## Migrating from dlh_googlemaps Although we cannot guarantee to fully migrate your existing dlh_googlemaps instances, you will nevertheless have a point to start from. Think of it as a 95% migration ;-) diff --git a/composer.json b/composer.json index ed449a8..658d93b 100644 --- a/composer.json +++ b/composer.json @@ -10,12 +10,12 @@ "heimrichhannot/contao-utils-bundle": "^3.6", "ivory/google-map": "^6.0", "mvo/contao-group-widget": "^1.5", - "symfony/config": "^6.4", - "symfony/console": "^6.4", - "symfony/dependency-injection": "^6.4", - "symfony/event-dispatcher": "^6.4", - "symfony/http-foundation": "^6.4", - "symfony/http-kernel": "^6.4", + "symfony/config": "^6.4 || ^7.0", + "symfony/console": "^6.4 || ^7.0", + "symfony/dependency-injection": "^6.4 || ^7.0", + "symfony/event-dispatcher": "^6.4 || ^7.0", + "symfony/http-foundation": "^6.4 || ^7.0", + "symfony/http-kernel": "^6.4 || ^7.0", "twig/twig": "^3.15" }, "require-dev": { diff --git a/config/services.yaml b/config/services.yaml index efd661d..147abac 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -5,7 +5,11 @@ services: HeimrichHannot\GoogleMapsBundle\: resource: '../src/{Command,Controller,EventListener,MapBuilder,Security,Twig,Util}/*' - exclude: '../src/EventListener/{MapRendererListener.php,ApiRenderListener.php}' + exclude: '../src/{EventListener/MapRendererListener.php,EventListener/ApiRenderListener.php,Util/KmlConverter.php}' + + HeimrichHannot\GoogleMapsBundle\Command\ConvertKmlCommand: + arguments: + $projectDir: '%kernel.project_dir%' HeimrichHannot\GoogleMapsBundle\Manager\: resource: '../src/Manager/*' diff --git a/contao/config/config.php b/contao/config/config.php index 3050a41..9b10980 100644 --- a/contao/config/config.php +++ b/contao/config/config.php @@ -2,8 +2,8 @@ declare(strict_types=1); -/* - * Copyright (c) 2024 Heimrich & Hannot GmbH +/** + * Copyright (c) 2024 Heimrich & Hannot GmbH. * * @license LGPL-3.0-or-later */ diff --git a/contao/dca/tl_content.php b/contao/dca/tl_content.php index 2e61a5f..ed2a48f 100644 --- a/contao/dca/tl_content.php +++ b/contao/dca/tl_content.php @@ -2,8 +2,8 @@ declare(strict_types=1); -/* - * Copyright (c) 2024 Heimrich & Hannot GmbH +/** + * Copyright (c) 2024 Heimrich & Hannot GmbH. * * @license LGPL-3.0-or-later */ diff --git a/contao/dca/tl_google_map.php b/contao/dca/tl_google_map.php index 826d4f6..98f60df 100644 --- a/contao/dca/tl_google_map.php +++ b/contao/dca/tl_google_map.php @@ -2,8 +2,8 @@ declare(strict_types=1); -/* - * Copyright (c) 2024 Heimrich & Hannot GmbH +/** + * Copyright (c) 2024 Heimrich & Hannot GmbH. * * @license LGPL-3.0-or-later */ diff --git a/contao/dca/tl_google_map_overlay.php b/contao/dca/tl_google_map_overlay.php index b157988..2ac78da 100644 --- a/contao/dca/tl_google_map_overlay.php +++ b/contao/dca/tl_google_map_overlay.php @@ -2,8 +2,8 @@ declare(strict_types=1); -/* - * Copyright (c) 2024 Heimrich & Hannot GmbH +/** + * Copyright (c) 2024 Heimrich & Hannot GmbH. * * @license LGPL-3.0-or-later */ @@ -60,6 +60,7 @@ 'markerType', 'clickEvent', 'addRouting', + 'geojsonSource', // CAUTION: type must be at this position, else a Contao palette error takes places! 'type', 'published', @@ -69,8 +70,11 @@ OverlayListener::TYPE_INFO_WINDOW => '{general_legend},title,type;{config_legend},positioningMode,infoWindowWidth,infoWindowHeight,infoWindowText,addRouting,zIndex;{publish_legend},published;', OverlayListener::TYPE_KML_LAYER => '{general_legend},title,type;{config_legend},kmlUrl,kmlClickable,kmlPreserveViewport,kmlScreenOverlays,kmlSuppressInfowindows,zIndex;{publish_legend},published;', OverlayListener::TYPE_POLYGON => '{general_legend},title,type;{config_legend},pathCoordinates,strokeColor,strokeOpacity,fillColor,fillOpacity,strokeWeight,zIndex;{publish_legend},published;', + OverlayListener::TYPE_GEOJSON_LAYER => '{general_legend},title,type;{config_legend},geojsonSource,geojsonClickable,geojsonFitBounds,zIndex;{style_legend},geojsonStrokeColor,geojsonStrokeWeight,geojsonStrokeOpacity,geojsonFillColor,geojsonFillOpacity,geojsonStylePropertiesEnabled;{publish_legend},published;', ], 'subpalettes' => [ + 'geojsonSource_'.OverlayListener::GEOJSON_SOURCE_FILE => 'geojsonFile', + 'geojsonSource_'.OverlayListener::GEOJSON_SOURCE_URL => 'geojsonUrl', 'titleMode_'.OverlayListener::TITLE_MODE_CUSTOM_TEXT => 'titleText', 'positioningMode_'.OverlayListener::POSITIONING_MODE_COORDINATE => 'positioningLat,positioningLng', 'positioningMode_'.OverlayListener::POSITIONING_MODE_STATIC_ADDRESS => 'positioningAddress', @@ -477,6 +481,126 @@ ], 'sql' => "char(1) NOT NULL default ''", ], + 'geojsonSource' => [ + 'label' => &$GLOBALS['TL_LANG']['tl_google_map_overlay']['geojsonSource'], + 'default' => OverlayListener::GEOJSON_SOURCE_FILE, + 'inputType' => 'select', + 'options' => OverlayListener::GEOJSON_SOURCES, + 'reference' => &$GLOBALS['TL_LANG']['tl_google_map_overlay']['reference']['geojsonSource'], + 'eval' => [ + 'submitOnChange' => true, + 'mandatory' => true, + 'tl_class' => 'w50', + ], + 'sql' => "varchar(16) NOT NULL default ''", + ], + 'geojsonFile' => [ + 'label' => &$GLOBALS['TL_LANG']['tl_google_map_overlay']['geojsonFile'], + 'inputType' => 'fileTree', + 'eval' => [ + 'fieldType' => 'radio', + 'filesOnly' => true, + 'extensions' => 'geojson,json', + 'mandatory' => true, + 'tl_class' => 'clr', + ], + 'sql' => 'binary(16) NULL', + ], + 'geojsonUrl' => [ + 'label' => &$GLOBALS['TL_LANG']['tl_google_map_overlay']['geojsonUrl'], + 'search' => true, + 'inputType' => 'text', + 'eval' => [ + 'rgxp' => 'url', + 'decodeEntities' => true, + 'maxlength' => 255, + 'mandatory' => true, + 'tl_class' => 'clr w50', + ], + 'sql' => "varchar(255) NOT NULL default ''", + ], + 'geojsonClickable' => [ + 'label' => &$GLOBALS['TL_LANG']['tl_google_map_overlay']['geojsonClickable'], + 'filter' => true, + 'default' => true, + 'inputType' => 'checkbox', + 'eval' => [ + 'tl_class' => 'clr m12', + ], + 'sql' => "char(1) NOT NULL default '1'", + ], + 'geojsonFitBounds' => [ + 'label' => &$GLOBALS['TL_LANG']['tl_google_map_overlay']['geojsonFitBounds'], + 'filter' => true, + 'default' => false, + 'inputType' => 'checkbox', + 'eval' => [ + 'tl_class' => 'm12', + ], + 'sql' => "char(1) NOT NULL default ''", + ], + 'geojsonStrokeColor' => [ + 'label' => &$GLOBALS['TL_LANG']['tl_google_map_overlay']['geojsonStrokeColor'], + 'inputType' => 'text', + 'eval' => [ + 'maxlength' => 6, + 'isHexColor' => true, + 'colorpicker' => true, + 'decodeEntities' => true, + 'tl_class' => 'w50 wizard', + ], + 'sql' => "varchar(6) NOT NULL default ''", + ], + 'geojsonStrokeWeight' => [ + 'label' => &$GLOBALS['TL_LANG']['tl_google_map_overlay']['geojsonStrokeWeight'], + 'inputType' => 'text', + 'eval' => [ + 'rgxp' => 'natural', + 'maxlength' => 3, + 'tl_class' => 'w50', + ], + 'sql' => "varchar(3) NOT NULL default ''", + ], + 'geojsonStrokeOpacity' => [ + 'label' => &$GLOBALS['TL_LANG']['tl_google_map_overlay']['geojsonStrokeOpacity'], + 'inputType' => 'text', + 'eval' => [ + 'maxlength' => 3, + 'tl_class' => 'w50', + ], + 'sql' => "varchar(3) NOT NULL default ''", + ], + 'geojsonFillColor' => [ + 'label' => &$GLOBALS['TL_LANG']['tl_google_map_overlay']['geojsonFillColor'], + 'inputType' => 'text', + 'eval' => [ + 'maxlength' => 6, + 'isHexColor' => true, + 'colorpicker' => true, + 'decodeEntities' => true, + 'tl_class' => 'w50 wizard', + ], + 'sql' => "varchar(6) NOT NULL default ''", + ], + 'geojsonFillOpacity' => [ + 'label' => &$GLOBALS['TL_LANG']['tl_google_map_overlay']['geojsonFillOpacity'], + 'inputType' => 'text', + 'eval' => [ + 'maxlength' => 3, + 'tl_class' => 'w50', + ], + 'sql' => "varchar(3) NOT NULL default ''", + ], + 'geojsonStylePropertiesEnabled' => [ + 'label' => &$GLOBALS['TL_LANG']['tl_google_map_overlay']['geojsonStylePropertiesEnabled'], + 'filter' => true, + 'default' => true, + 'inputType' => 'checkbox', + 'eval' => [ + 'tl_class' => 'clr m12', + ], + 'sql' => "char(1) NOT NULL default '1'", + ], 'pathCoordinates' => [ 'inputType' => 'group', 'palette' => ['positioningLat', 'positioningLng'], diff --git a/contao/dca/tl_module.php b/contao/dca/tl_module.php index bcff3f4..d5a155c 100644 --- a/contao/dca/tl_module.php +++ b/contao/dca/tl_module.php @@ -2,8 +2,8 @@ declare(strict_types=1); -/* - * Copyright (c) 2024 Heimrich & Hannot GmbH +/** + * Copyright (c) 2024 Heimrich & Hannot GmbH. * * @license LGPL-3.0-or-later */ diff --git a/contao/dca/tl_page.php b/contao/dca/tl_page.php index 0770961..ec97e2b 100644 --- a/contao/dca/tl_page.php +++ b/contao/dca/tl_page.php @@ -2,8 +2,8 @@ declare(strict_types=1); -/* - * Copyright (c) 2024 Heimrich & Hannot GmbH +/** + * Copyright (c) 2024 Heimrich & Hannot GmbH. * * @license LGPL-3.0-or-later */ diff --git a/contao/dca/tl_settings.php b/contao/dca/tl_settings.php index d9eaa06..978c983 100644 --- a/contao/dca/tl_settings.php +++ b/contao/dca/tl_settings.php @@ -2,12 +2,11 @@ declare(strict_types=1); -/* - * Copyright (c) 2024 Heimrich & Hannot GmbH +/** + * Copyright (c) 2024 Heimrich & Hannot GmbH. * * @license LGPL-3.0-or-later */ - $dca = &$GLOBALS['TL_DCA']['tl_settings']; /* diff --git a/contao/dca/tl_user.php b/contao/dca/tl_user.php index 13feb8a..f9c9fba 100644 --- a/contao/dca/tl_user.php +++ b/contao/dca/tl_user.php @@ -2,8 +2,8 @@ declare(strict_types=1); -/* - * Copyright (c) 2024 Heimrich & Hannot GmbH +/** + * Copyright (c) 2024 Heimrich & Hannot GmbH. * * @license LGPL-3.0-or-later */ diff --git a/contao/dca/tl_user_group.php b/contao/dca/tl_user_group.php index 2e1a428..24eaec9 100644 --- a/contao/dca/tl_user_group.php +++ b/contao/dca/tl_user_group.php @@ -2,8 +2,8 @@ declare(strict_types=1); -/* - * Copyright (c) 2024 Heimrich & Hannot GmbH +/** + * Copyright (c) 2024 Heimrich & Hannot GmbH. * * @license LGPL-3.0-or-later */ diff --git a/contao/languages/de/default.php b/contao/languages/de/default.php index 69aeed4..6e55d73 100644 --- a/contao/languages/de/default.php +++ b/contao/languages/de/default.php @@ -2,8 +2,8 @@ declare(strict_types=1); -/* - * Copyright (c) 2024 Heimrich & Hannot GmbH +/** + * Copyright (c) 2024 Heimrich & Hannot GmbH. * * @license LGPL-3.0-or-later */ diff --git a/contao/languages/de/modules.php b/contao/languages/de/modules.php index 94187c6..1a9ee80 100644 --- a/contao/languages/de/modules.php +++ b/contao/languages/de/modules.php @@ -2,8 +2,8 @@ declare(strict_types=1); -/* - * Copyright (c) 2024 Heimrich & Hannot GmbH +/** + * Copyright (c) 2024 Heimrich & Hannot GmbH. * * @license LGPL-3.0-or-later */ diff --git a/contao/languages/de/tl_content.php b/contao/languages/de/tl_content.php index 5474667..ac558bb 100644 --- a/contao/languages/de/tl_content.php +++ b/contao/languages/de/tl_content.php @@ -2,12 +2,11 @@ declare(strict_types=1); -/* - * Copyright (c) 2024 Heimrich & Hannot GmbH +/** + * Copyright (c) 2024 Heimrich & Hannot GmbH. * * @license LGPL-3.0-or-later */ - $lang = &$GLOBALS['TL_LANG']['tl_content']; /* diff --git a/contao/languages/de/tl_google_map.php b/contao/languages/de/tl_google_map.php index c713862..ceb11f0 100644 --- a/contao/languages/de/tl_google_map.php +++ b/contao/languages/de/tl_google_map.php @@ -2,8 +2,8 @@ declare(strict_types=1); -/* - * Copyright (c) 2024 Heimrich & Hannot GmbH +/** + * Copyright (c) 2024 Heimrich & Hannot GmbH. * * @license LGPL-3.0-or-later */ diff --git a/contao/languages/de/tl_google_map_overlay.php b/contao/languages/de/tl_google_map_overlay.php index d8fad10..acef219 100644 --- a/contao/languages/de/tl_google_map_overlay.php +++ b/contao/languages/de/tl_google_map_overlay.php @@ -2,8 +2,8 @@ declare(strict_types=1); -/* - * Copyright (c) 2024 Heimrich & Hannot GmbH +/** + * Copyright (c) 2024 Heimrich & Hannot GmbH. * * @license LGPL-3.0-or-later */ @@ -108,17 +108,24 @@ OverlayListener::TYPE_CIRCLE => 'Kreis', OverlayListener::TYPE_RECTANGLE => 'Rechteck', OverlayListener::TYPE_GROUND_OVERLAY => 'Ground-Overlay', - OverlayListener::TYPE_KML_LAYER => 'KML-Layer', + OverlayListener::TYPE_KML_LAYER => 'KML-Layer (veraltet)', + OverlayListener::TYPE_GEOJSON_LAYER => 'GeoJSON-Layer', OverlayListener::MARKER_TYPE_SIMPLE => 'Standard', OverlayListener::MARKER_TYPE_ICON => 'Individuelles Bild', OverlayListener::CLICK_EVENT_LINK => 'Link', ]; +$lang['reference']['geojsonSource'] = [ + OverlayListener::GEOJSON_SOURCE_FILE => 'Datei aus der Dateiverwaltung', + OverlayListener::GEOJSON_SOURCE_URL => 'Externe URL', +]; + /* * Legends */ $lang['general_legend'] = 'Allgemeine Einstellungen'; $lang['config_legend'] = 'Konfiguration'; +$lang['style_legend'] = 'Darstellung'; $lang['publish_legend'] = 'Veröffentlichung'; /* @@ -144,3 +151,29 @@ $lang['kmlScreenOverlays'][1] = 'Die Overlays des Layers rendern.'; $lang['kmlSuppressInfowindows'][0] = 'Infoblasen unterdrücken'; $lang['kmlSuppressInfowindows'][1] = 'Unterdrückt die Anzeige von Infoblasen im Layer.'; + +/* + * GeoJSON + */ +$lang['geojsonSource'][0] = 'Quelle'; +$lang['geojsonSource'][1] = 'Legen Sie fest, woher die GeoJSON-Daten geladen werden.'; +$lang['geojsonFile'][0] = 'GeoJSON-Datei'; +$lang['geojsonFile'][1] = 'Wählen Sie eine .geojson-Datei aus der Dateiverwaltung.'; +$lang['geojsonUrl'][0] = 'GeoJSON-URL'; +$lang['geojsonUrl'][1] = 'Geben Sie eine absolute URL zu einer .geojson-Datei ein.'; +$lang['geojsonClickable'][0] = 'Klickbar'; +$lang['geojsonClickable'][1] = 'Wenn aktiv, öffnet ein Klick auf ein Element eine Infoblase mit dessen Eigenschaften.'; +$lang['geojsonFitBounds'][0] = 'Auf Inhalt zoomen'; +$lang['geojsonFitBounds'][1] = 'Passt den Kartenausschnitt automatisch an die geladenen Daten an.'; +$lang['geojsonStrokeColor'][0] = 'Linienfarbe'; +$lang['geojsonStrokeColor'][1] = 'Fallback-Farbe für Linien und Umrisse, sofern das Element keine eigene Farbe mitbringt.'; +$lang['geojsonStrokeWeight'][0] = 'Linienstärke'; +$lang['geojsonStrokeWeight'][1] = 'Fallback-Linienstärke in Pixel.'; +$lang['geojsonStrokeOpacity'][0] = 'Linien-Deckkraft'; +$lang['geojsonStrokeOpacity'][1] = 'Fallback-Deckkraft der Linien zwischen 0 und 1.'; +$lang['geojsonFillColor'][0] = 'Füllfarbe'; +$lang['geojsonFillColor'][1] = 'Fallback-Füllfarbe für Flächen.'; +$lang['geojsonFillOpacity'][0] = 'Füll-Deckkraft'; +$lang['geojsonFillOpacity'][1] = 'Fallback-Deckkraft der Füllung zwischen 0 und 1.'; +$lang['geojsonStylePropertiesEnabled'][0] = 'Eigenschaften aus der Datei verwenden'; +$lang['geojsonStylePropertiesEnabled'][1] = 'Wertet die simplestyle-Eigenschaften (stroke, stroke-width, fill, icon, ...) der einzelnen Elemente aus. Die Angaben oben dienen dann nur als Fallback.'; diff --git a/contao/languages/de/tl_module.php b/contao/languages/de/tl_module.php index ab7666f..849b550 100644 --- a/contao/languages/de/tl_module.php +++ b/contao/languages/de/tl_module.php @@ -2,12 +2,11 @@ declare(strict_types=1); -/* - * Copyright (c) 2024 Heimrich & Hannot GmbH +/** + * Copyright (c) 2024 Heimrich & Hannot GmbH. * * @license LGPL-3.0-or-later */ - $lang = &$GLOBALS['TL_LANG']['tl_module']; /* diff --git a/contao/languages/de/tl_page.php b/contao/languages/de/tl_page.php index cba1c10..4cb328e 100644 --- a/contao/languages/de/tl_page.php +++ b/contao/languages/de/tl_page.php @@ -2,12 +2,11 @@ declare(strict_types=1); -/* - * Copyright (c) 2024 Heimrich & Hannot GmbH +/** + * Copyright (c) 2024 Heimrich & Hannot GmbH. * * @license LGPL-3.0-or-later */ - $lang = &$GLOBALS['TL_LANG']['tl_page']; /* diff --git a/contao/languages/de/tl_settings.php b/contao/languages/de/tl_settings.php index cbc1d76..2fc32bd 100644 --- a/contao/languages/de/tl_settings.php +++ b/contao/languages/de/tl_settings.php @@ -2,12 +2,11 @@ declare(strict_types=1); -/* - * Copyright (c) 2024 Heimrich & Hannot GmbH +/** + * Copyright (c) 2024 Heimrich & Hannot GmbH. * * @license LGPL-3.0-or-later */ - $lang = &$GLOBALS['TL_LANG']['tl_settings']; /* diff --git a/contao/languages/de/tl_user.php b/contao/languages/de/tl_user.php index 4a3bda0..4900089 100644 --- a/contao/languages/de/tl_user.php +++ b/contao/languages/de/tl_user.php @@ -2,12 +2,11 @@ declare(strict_types=1); -/* - * Copyright (c) 2024 Heimrich & Hannot GmbH +/** + * Copyright (c) 2024 Heimrich & Hannot GmbH. * * @license LGPL-3.0-or-later */ - $lang = &$GLOBALS['TL_LANG']['tl_user']; /* diff --git a/contao/languages/de/tl_user_group.php b/contao/languages/de/tl_user_group.php index beb47ba..bf514f6 100644 --- a/contao/languages/de/tl_user_group.php +++ b/contao/languages/de/tl_user_group.php @@ -2,12 +2,11 @@ declare(strict_types=1); -/* - * Copyright (c) 2024 Heimrich & Hannot GmbH +/** + * Copyright (c) 2024 Heimrich & Hannot GmbH. * * @license LGPL-3.0-or-later */ - $lang = &$GLOBALS['TL_LANG']['tl_user_group']; /* diff --git a/contao/languages/en/modules.php b/contao/languages/en/modules.php index 2e93075..6e44065 100644 --- a/contao/languages/en/modules.php +++ b/contao/languages/en/modules.php @@ -2,10 +2,9 @@ declare(strict_types=1); -/* - * Copyright (c) 2024 Heimrich & Hannot GmbH +/** + * Copyright (c) 2024 Heimrich & Hannot GmbH. * * @license LGPL-3.0-or-later */ - $GLOBALS['TL_LANG']['MOD']['google_maps'] = ['Google Maps', '']; diff --git a/contao/languages/en/tl_google_map.php b/contao/languages/en/tl_google_map.php index 13e571b..3d56f38 100644 --- a/contao/languages/en/tl_google_map.php +++ b/contao/languages/en/tl_google_map.php @@ -2,12 +2,11 @@ declare(strict_types=1); -/* - * Copyright (c) 2024 Heimrich & Hannot GmbH +/** + * Copyright (c) 2024 Heimrich & Hannot GmbH. * * @license LGPL-3.0-or-later */ - $lang = &$GLOBALS['TL_LANG']['tl_google_map']; /* diff --git a/contao/languages/en/tl_google_map_overlay.php b/contao/languages/en/tl_google_map_overlay.php index 07cd8ca..f188baf 100644 --- a/contao/languages/en/tl_google_map_overlay.php +++ b/contao/languages/en/tl_google_map_overlay.php @@ -2,12 +2,11 @@ declare(strict_types=1); -/* - * Copyright (c) 2024 Heimrich & Hannot GmbH +/** + * Copyright (c) 2024 Heimrich & Hannot GmbH. * * @license LGPL-3.0-or-later */ - $lang = &$GLOBALS['TL_LANG']['tl_google_map_overlay']; /* @@ -31,10 +30,38 @@ $lang['fillOpacity'][0] = 'Area opacity'; $lang['fillOpacity'][1] = 'Insert the area opacity in the range from 0 to 1.'; +/* + * GeoJSON + */ +$lang['geojsonSource'][0] = 'Source'; +$lang['geojsonSource'][1] = 'Choose where the GeoJSON data is loaded from.'; +$lang['geojsonFile'][0] = 'GeoJSON file'; +$lang['geojsonFile'][1] = 'Select a .geojson file from the file manager.'; +$lang['geojsonUrl'][0] = 'GeoJSON URL'; +$lang['geojsonUrl'][1] = 'Insert an absolute URL to a .geojson file.'; +$lang['geojsonClickable'][0] = 'Clickable'; +$lang['geojsonClickable'][1] = 'If enabled, clicking a feature opens an info window with its properties.'; +$lang['geojsonFitBounds'][0] = 'Zoom to content'; +$lang['geojsonFitBounds'][1] = 'Adjust the map viewport to the loaded data.'; +$lang['geojsonStrokeColor'][0] = 'Line color'; +$lang['geojsonStrokeColor'][1] = 'Fallback color for lines and outlines when a feature carries no color of its own.'; +$lang['geojsonStrokeWeight'][0] = 'Line weight'; +$lang['geojsonStrokeWeight'][1] = 'Fallback line weight in pixels.'; +$lang['geojsonStrokeOpacity'][0] = 'Line opacity'; +$lang['geojsonStrokeOpacity'][1] = 'Fallback line opacity in the range from 0 to 1.'; +$lang['geojsonFillColor'][0] = 'Area color'; +$lang['geojsonFillColor'][1] = 'Fallback fill color for areas.'; +$lang['geojsonFillOpacity'][0] = 'Area opacity'; +$lang['geojsonFillOpacity'][1] = 'Fallback fill opacity in the range from 0 to 1.'; +$lang['geojsonStylePropertiesEnabled'][0] = 'Use properties from the file'; +$lang['geojsonStylePropertiesEnabled'][1] = 'Evaluate the simplestyle properties (stroke, stroke-width, fill, icon, ...) of each feature. The settings above then act as fallbacks only.'; + /* * Legends */ $lang['general_legend'] = 'General settings'; +$lang['config_legend'] = 'Configuration'; +$lang['style_legend'] = 'Appearance'; $lang['publish_legend'] = 'Publish settings'; /* diff --git a/contao/languages/en/tl_list_config.php b/contao/languages/en/tl_list_config.php index 2bb691b..6bb94da 100644 --- a/contao/languages/en/tl_list_config.php +++ b/contao/languages/en/tl_list_config.php @@ -2,12 +2,11 @@ declare(strict_types=1); -/* - * Copyright (c) 2024 Heimrich & Hannot GmbH +/** + * Copyright (c) 2024 Heimrich & Hannot GmbH. * * @license LGPL-3.0-or-later */ - $lang = &$GLOBALS['TL_LANG']['tl_list_config']; /* diff --git a/contao/languages/en/tl_page.php b/contao/languages/en/tl_page.php index f8f3609..361f0a8 100644 --- a/contao/languages/en/tl_page.php +++ b/contao/languages/en/tl_page.php @@ -2,12 +2,11 @@ declare(strict_types=1); -/* - * Copyright (c) 2024 Heimrich & Hannot GmbH +/** + * Copyright (c) 2024 Heimrich & Hannot GmbH. * * @license LGPL-3.0-or-later */ - $lang = &$GLOBALS['TL_LANG']['tl_page']; /* diff --git a/contao/languages/en/tl_settings.php b/contao/languages/en/tl_settings.php index e64daa2..d6845f8 100644 --- a/contao/languages/en/tl_settings.php +++ b/contao/languages/en/tl_settings.php @@ -2,12 +2,11 @@ declare(strict_types=1); -/* - * Copyright (c) 2024 Heimrich & Hannot GmbH +/** + * Copyright (c) 2024 Heimrich & Hannot GmbH. * * @license LGPL-3.0-or-later */ - $lang = &$GLOBALS['TL_LANG']['tl_settings']; /* diff --git a/contao/languages/en/tl_user.php b/contao/languages/en/tl_user.php index 656fac4..32876c8 100644 --- a/contao/languages/en/tl_user.php +++ b/contao/languages/en/tl_user.php @@ -2,12 +2,11 @@ declare(strict_types=1); -/* - * Copyright (c) 2024 Heimrich & Hannot GmbH +/** + * Copyright (c) 2024 Heimrich & Hannot GmbH. * * @license LGPL-3.0-or-later */ - $lang = &$GLOBALS['TL_LANG']['tl_user']; /* diff --git a/contao/languages/en/tl_user_group.php b/contao/languages/en/tl_user_group.php index 5f31a0e..a8d8a4f 100644 --- a/contao/languages/en/tl_user_group.php +++ b/contao/languages/en/tl_user_group.php @@ -2,12 +2,11 @@ declare(strict_types=1); -/* - * Copyright (c) 2024 Heimrich & Hannot GmbH +/** + * Copyright (c) 2024 Heimrich & Hannot GmbH. * * @license LGPL-3.0-or-later */ - $lang = &$GLOBALS['TL_LANG']['tl_user_group']; /* diff --git a/public/js/google-maps-geojson.js b/public/js/google-maps-geojson.js new file mode 100644 index 0000000..94f2431 --- /dev/null +++ b/public/js/google-maps-geojson.js @@ -0,0 +1,240 @@ +/* + * Copyright (c) 2024 Heimrich & Hannot GmbH + * + * @license LGPL-3.0-or-later + */ + +/** + * Styling and interaction for GeoJSON data layers, replacing what KmlLayer + * rendered from the KML file itself. + * + * Feature properties follow the simplestyle spec + * (https://github.com/mapbox/simplestyle-spec); features without their own + * style fall back to the overlay settings. + */ +(function (window, document) { + 'use strict'; + + var HUH = (window.HuhGoogleMaps = window.HuhGoogleMaps || {}); + + /** Feature properties mapped to Google style options; first match wins. */ + var STYLE_PROPERTIES = { + strokeColor: ['stroke', 'stroke-color'], + strokeWeight: ['stroke-width', 'stroke-weight'], + strokeOpacity: ['stroke-opacity'], + fillColor: ['fill', 'fill-color'], + fillOpacity: ['fill-opacity'], + zIndex: ['zIndex', 'z-index'], + }; + + var TITLE_PROPERTIES = ['name', 'title', 'Name', 'NAME']; + + var DESCRIPTION_PROPERTIES = ['description', 'Beschreibung', 'desc']; + + /** Never shown in the property table. */ + var HIDDEN_PROPERTIES = [ + 'stroke', 'stroke-color', 'stroke-width', 'stroke-weight', + 'stroke-opacity', 'fill', 'fill-color', 'fill-opacity', + 'icon', 'icon-scale', 'marker-color', 'marker-size', 'marker-symbol', + 'zIndex', 'z-index', 'styleUrl', 'styleHash', + // Structural: where a feature sits in the source file. + 'folder', 'folderPath', + ]; + + function firstProperty(feature, names) { + for (var i = 0; i < names.length; i++) { + var value = feature.getProperty(names[i]); + + if (value !== undefined && value !== null && value !== '') { + return value; + } + } + + return null; + } + + function escapeHtml(value) { + var div = document.createElement('div'); + div.appendChild(document.createTextNode(String(value))); + + return div.innerHTML; + } + + /** Converted descriptions carry plain-text URLs; make them clickable. */ + function linkify(text) { + return escapeHtml(text).replace( + /(https?:\/\/[^\s<]+)/g, + '$1' + ); + } + + /** Builds the style for a single feature, over the configured fallback. */ + function buildStyle(feature, config) { + var style = {}; + var key; + + for (key in config.style) { + if (Object.prototype.hasOwnProperty.call(config.style, key)) { + style[key] = config.style[key]; + } + } + + if (config.useFeatureStyles) { + for (key in STYLE_PROPERTIES) { + if (!Object.prototype.hasOwnProperty.call(STYLE_PROPERTIES, key)) { + continue; + } + + var value = firstProperty(feature, STYLE_PROPERTIES[key]); + + if (value !== null) { + style[key] = value; + } + } + + var icon = feature.getProperty('icon'); + + if (icon) { + style.icon = icon; + } + } + + return style; + } + + /** + * Composes the info window markup, or null when the feature carries + * nothing worth showing. + */ + function buildContent(feature) { + var title = firstProperty(feature, TITLE_PROPERTIES); + var description = firstProperty(feature, DESCRIPTION_PROPERTIES); + var rows = []; + + feature.forEachProperty(function (value, name) { + if (HIDDEN_PROPERTIES.indexOf(name) !== -1) { + return; + } + + if (TITLE_PROPERTIES.indexOf(name) !== -1 || DESCRIPTION_PROPERTIES.indexOf(name) !== -1) { + return; + } + + if (value === undefined || value === null || value === '') { + return; + } + + rows.push( + '' + escapeHtml(name) + '' + + '' + linkify(value) + '' + ); + }); + + if (!title && !description && !rows.length) { + return null; + } + + var html = '
'; + + if (title) { + html += '

' + escapeHtml(title) + '

'; + } + + if (description) { + html += '
' + + linkify(description).replace(/\n/g, '
') + + '
'; + } + + if (rows.length) { + html += '' + + rows.join('') + + '
'; + } + + return html + '
'; + } + + /** Extends the bounds by every position in a geometry, at any depth. */ + function extendBounds(bounds, geometry) { + if (!geometry) { + return; + } + + if (typeof geometry.forEachLatLng === 'function') { + geometry.forEachLatLng(function (latLng) { + bounds.extend(latLng); + }); + } + } + + /** + * @param {google.maps.Map} map + * @param {Object} config the huhOverlay options emitted by the overlay + */ + HUH.initGeoJsonLayer = function (map, config) { + if (!map || !config) { + return; + } + + config.style = config.style || {}; + + var data = map.data; + + data.setStyle(function (feature) { + return buildStyle(feature, config); + }); + + if (config.clickable) { + if (!HUH._infoWindow) { + HUH._infoWindow = new google.maps.InfoWindow(); + } + + data.addListener('click', function (event) { + var content = buildContent(event.feature); + + if (!content) { + return; + } + + HUH._infoWindow.setContent(content); + // Lines and polygons have no single position of their own. + HUH._infoWindow.setPosition(event.latLng); + HUH._infoWindow.open({ map: map }); + }); + } + + if (config.fitBounds) { + var bounds = new google.maps.LatLngBounds(); + var pending = false; + + var apply = function () { + if (pending) { + return; + } + + pending = true; + + window.setTimeout(function () { + pending = false; + + if (!bounds.isEmpty()) { + map.fitBounds(bounds); + } + }, 0); + }; + + // Features may already have arrived by the time this runs. + data.forEach(function (feature) { + extendBounds(bounds, feature.getGeometry()); + }); + + data.addListener('addfeature', function (event) { + extendBounds(bounds, event.feature.getGeometry()); + apply(); + }); + + apply(); + } + }; +})(window, document); diff --git a/src/Collection/MapCollection.php b/src/Collection/MapCollection.php index 2ca8d5b..c13babe 100644 --- a/src/Collection/MapCollection.php +++ b/src/Collection/MapCollection.php @@ -2,8 +2,8 @@ declare(strict_types=1); -/* - * Copyright (c) 2024 Heimrich & Hannot GmbH +/** + * Copyright (c) 2024 Heimrich & Hannot GmbH. * * @license LGPL-3.0-or-later */ diff --git a/src/Command/ConvertKmlCommand.php b/src/Command/ConvertKmlCommand.php new file mode 100644 index 0000000..515abd0 --- /dev/null +++ b/src/Command/ConvertKmlCommand.php @@ -0,0 +1,235 @@ +addArgument( + 'path', + InputArgument::IS_ARRAY, + 'Files or directories to convert; directories are scanned recursively.' + ) + ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Report what would be written without writing.') + ->addOption('force', null, InputOption::VALUE_NONE, 'Overwrite existing .geojson files.') + ->addOption( + 'icon-base', + null, + InputOption::VALUE_REQUIRED, + 'Project-relative base path for rewritten icon URLs, e.g. files/maps/ico. Without it, icon URLs are left untouched.', + '' + ) + ->addOption( + 'local-host', + null, + InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, + 'Host whose icon URLs are rewritten below --icon-base. Repeatable.' + ) + ->addOption( + 'keep-folders', + null, + InputOption::VALUE_NONE, + 'Keep the KML folder hierarchy as folder/folderPath properties, e.g. to drive a layer switcher.' + ) + ->addOption( + 'precision', + null, + InputOption::VALUE_REQUIRED, + 'Coordinate decimal places.', + '6' + ) + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $io->title('Convert KML to GeoJSON'); + + $paths = $input->getArgument('path') ?: [$this->projectDir]; + $files = $this->collectKmlFiles($paths, $io); + + if (!$files) { + $io->error('No KML files found.'); + + return Command::FAILURE; + } + + $dryRun = (bool) $input->getOption('dry-run'); + $force = (bool) $input->getOption('force'); + + $converter = new KmlConverter( + rtrim((string) $input->getOption('icon-base'), '/'), + $input->getOption('local-host'), + keepFolders: (bool) $input->getOption('keep-folders'), + precision: max(0, (int) $input->getOption('precision')), + iconRoot: $this->projectDir, + ); + + $io->text(\sprintf( + 'Converting %d KML file(s)%s', + \count($files), + $dryRun ? ' (dry run — nothing will be written)' : '' + )); + $io->newLine(); + + $converted = 0; + $failed = 0; + $totalFeatures = 0; + + foreach ($files as $file) { + $target = preg_replace('/\.kml$/i', '.geojson', $file); + $label = $this->relativePath($file); + + if (file_exists($target) && !$force && !$dryRun) { + $io->writeln(\sprintf(' SKIP %s (exists — use --force)', $label)); + + continue; + } + + try { + $geoJson = $converter->convertFile($file); + } catch (\Throwable $exception) { + $io->writeln(\sprintf(' FAIL %s — %s', $label, $exception->getMessage())); + ++$failed; + + continue; + } + + $count = \count($geoJson['features']); + + if (0 === $count) { + $io->writeln(\sprintf(' EMPTY %s (no usable geometry)', $label)); + + continue; + } + + $json = json_encode($geoJson, \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE); + + if (false === $json) { + $io->writeln(\sprintf(' FAIL %s — %s', $label, json_last_error_msg())); + ++$failed; + + continue; + } + + if (!$dryRun && false === file_put_contents($target, $json."\n")) { + $io->writeln(\sprintf(' FAIL %s — could not write target', $label)); + ++$failed; + + continue; + } + + $io->writeln(\sprintf(' OK %s → %s (%d features)', $label, basename((string) $target), $count)); + $totalFeatures += $count; + ++$converted; + } + + $io->newLine(); + $io->text(\sprintf('%d converted, %d failed, %d features total', $converted, $failed, $totalFeatures)); + + if ($external = $converter->getExternalIcons()) { + $io->warning(\sprintf( + "%d external icon URL(s) left untouched. Mirror them locally if the map is served over HTTPS, since http:// resources get blocked:\n %s", + \count($external), + implode("\n ", $external) + )); + } + + if ($missing = $converter->getMissingIcons()) { + $io->warning(\sprintf( + "%d rewritten icon path(s) do not exist and would render as broken markers:\n %s", + \count($missing), + implode("\n ", $missing) + )); + } + + return $failed > 0 ? Command::FAILURE : Command::SUCCESS; + } + + /** + * @param string[] $paths + * + * @return string[] + */ + private function collectKmlFiles(array $paths, SymfonyStyle $io): array + { + $files = []; + + foreach ($paths as $path) { + $path = $this->absolutePath($path); + + if (is_file($path)) { + $files[] = $path; + + continue; + } + + if (!is_dir($path)) { + $io->warning(\sprintf('Not found, skipping: %s', $path)); + + continue; + } + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS) + ); + + foreach ($iterator as $file) { + if ('kml' === strtolower($file->getExtension())) { + $files[] = $file->getPathname(); + } + } + } + + sort($files); + + return array_values(array_unique($files)); + } + + private function absolutePath(string $path): string + { + if (is_file($path) || is_dir($path)) { + return $path; + } + + return rtrim($this->projectDir, '/').'/'.ltrim($path, '/'); + } + + private function relativePath(string $path): string + { + $root = rtrim(str_replace('\\', '/', $this->projectDir), '/').'/'; + $path = str_replace('\\', '/', $path); + + return str_starts_with($path, $root) ? substr($path, \strlen($root)) : $path; + } +} diff --git a/src/Command/MigrateDlhCommand.php b/src/Command/MigrateDlhCommand.php index cf7183d..985da1a 100644 --- a/src/Command/MigrateDlhCommand.php +++ b/src/Command/MigrateDlhCommand.php @@ -2,8 +2,8 @@ declare(strict_types=1); -/* - * Copyright (c) 2024 Heimrich & Hannot GmbH +/** + * Copyright (c) 2024 Heimrich & Hannot GmbH. * * @license LGPL-3.0-or-later */ @@ -79,7 +79,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->io->note('Dry run enabled.'); $this->io->newLine(); } - $this->io->getFormatter()->setStyle('userwarning', new OutputFormatterStyle('red', null)); + $this->io->getFormatter() + ->setStyle('userwarning', new OutputFormatterStyle('red', null)); $this->framework->initialize(); diff --git a/src/ContaoManager/Plugin.php b/src/ContaoManager/Plugin.php index ade44b5..7176503 100644 --- a/src/ContaoManager/Plugin.php +++ b/src/ContaoManager/Plugin.php @@ -2,8 +2,8 @@ declare(strict_types=1); -/* - * Copyright (c) 2024 Heimrich & Hannot GmbH +/** + * Copyright (c) 2024 Heimrich & Hannot GmbH. * * @license LGPL-3.0-or-later */ diff --git a/src/Controller/ContentElement/GoogleMapsElementController.php b/src/Controller/ContentElement/GoogleMapsElementController.php index 8ab01d3..9aed389 100644 --- a/src/Controller/ContentElement/GoogleMapsElementController.php +++ b/src/Controller/ContentElement/GoogleMapsElementController.php @@ -2,8 +2,8 @@ declare(strict_types=1); -/* - * Copyright (c) 2024 Heimrich & Hannot GmbH +/** + * Copyright (c) 2024 Heimrich & Hannot GmbH. * * @license LGPL-3.0-or-later */ diff --git a/src/Controller/FrontendModule/GoogleMapsFrontendModuleController.php b/src/Controller/FrontendModule/GoogleMapsFrontendModuleController.php index f617d18..c44fe7b 100644 --- a/src/Controller/FrontendModule/GoogleMapsFrontendModuleController.php +++ b/src/Controller/FrontendModule/GoogleMapsFrontendModuleController.php @@ -2,8 +2,8 @@ declare(strict_types=1); -/* - * Copyright (c) 2024 Heimrich & Hannot GmbH +/** + * Copyright (c) 2024 Heimrich & Hannot GmbH. * * @license LGPL-3.0-or-later */ diff --git a/src/DependencyInjection/GoogleMapsExtension.php b/src/DependencyInjection/GoogleMapsExtension.php index a85149b..173d9bc 100644 --- a/src/DependencyInjection/GoogleMapsExtension.php +++ b/src/DependencyInjection/GoogleMapsExtension.php @@ -2,8 +2,8 @@ declare(strict_types=1); -/* - * Copyright (c) 2024 Heimrich & Hannot GmbH +/** + * Copyright (c) 2024 Heimrich & Hannot GmbH. * * @license LGPL-3.0-or-later */ diff --git a/src/Event/BeforeRenderApiEvent.php b/src/Event/BeforeRenderApiEvent.php index 53b6ccd..156f180 100644 --- a/src/Event/BeforeRenderApiEvent.php +++ b/src/Event/BeforeRenderApiEvent.php @@ -2,8 +2,8 @@ declare(strict_types=1); -/* - * Copyright (c) 2024 Heimrich & Hannot GmbH +/** + * Copyright (c) 2024 Heimrich & Hannot GmbH. * * @license LGPL-3.0-or-later */ diff --git a/src/Event/BeforeRenderMapEvent.php b/src/Event/BeforeRenderMapEvent.php index ed69207..e9c5c82 100644 --- a/src/Event/BeforeRenderMapEvent.php +++ b/src/Event/BeforeRenderMapEvent.php @@ -2,8 +2,8 @@ declare(strict_types=1); -/* - * Copyright (c) 2024 Heimrich & Hannot GmbH +/** + * Copyright (c) 2024 Heimrich & Hannot GmbH. * * @license LGPL-3.0-or-later */ diff --git a/src/Event/DlhMigrationModifyMapEvent.php b/src/Event/DlhMigrationModifyMapEvent.php index f20fa15..36c40be 100644 --- a/src/Event/DlhMigrationModifyMapEvent.php +++ b/src/Event/DlhMigrationModifyMapEvent.php @@ -2,8 +2,8 @@ declare(strict_types=1); -/* - * Copyright (c) 2024 Heimrich & Hannot GmbH +/** + * Copyright (c) 2024 Heimrich & Hannot GmbH. * * @license LGPL-3.0-or-later */ diff --git a/src/Event/DlhMigrationModifyOverlayEvent.php b/src/Event/DlhMigrationModifyOverlayEvent.php index d557cb1..d89869a 100644 --- a/src/Event/DlhMigrationModifyOverlayEvent.php +++ b/src/Event/DlhMigrationModifyOverlayEvent.php @@ -2,8 +2,8 @@ declare(strict_types=1); -/* - * Copyright (c) 2024 Heimrich & Hannot GmbH +/** + * Copyright (c) 2024 Heimrich & Hannot GmbH. * * @license LGPL-3.0-or-later */ diff --git a/src/Event/GoogleMapBeforeRenderEvent.php b/src/Event/GoogleMapBeforeRenderEvent.php index 81069fb..0be418b 100644 --- a/src/Event/GoogleMapBeforeRenderEvent.php +++ b/src/Event/GoogleMapBeforeRenderEvent.php @@ -2,8 +2,8 @@ declare(strict_types=1); -/* - * Copyright (c) 2024 Heimrich & Hannot GmbH +/** + * Copyright (c) 2024 Heimrich & Hannot GmbH. * * @license LGPL-3.0-or-later */ diff --git a/src/Event/GoogleMapsPrepareExternalItemEvent.php b/src/Event/GoogleMapsPrepareExternalItemEvent.php index 42371a6..fa6c6cd 100644 --- a/src/Event/GoogleMapsPrepareExternalItemEvent.php +++ b/src/Event/GoogleMapsPrepareExternalItemEvent.php @@ -2,8 +2,8 @@ declare(strict_types=1); -/* - * Copyright (c) 2024 Heimrich & Hannot GmbH +/** + * Copyright (c) 2024 Heimrich & Hannot GmbH. * * @license LGPL-3.0-or-later */ diff --git a/src/EventListener/ApiRenderListener.php b/src/EventListener/ApiRenderListener.php index 8fddfed..ab03e09 100644 --- a/src/EventListener/ApiRenderListener.php +++ b/src/EventListener/ApiRenderListener.php @@ -2,8 +2,8 @@ declare(strict_types=1); -/* - * Copyright (c) 2024 Heimrich & Hannot GmbH +/** + * Copyright (c) 2024 Heimrich & Hannot GmbH. * * @license LGPL-3.0-or-later */ diff --git a/src/EventListener/ConsentBridgeListener.php b/src/EventListener/ConsentBridgeListener.php index 4746d71..6e251a0 100644 --- a/src/EventListener/ConsentBridgeListener.php +++ b/src/EventListener/ConsentBridgeListener.php @@ -2,8 +2,8 @@ declare(strict_types=1); -/* - * Copyright (c) 2024 Heimrich & Hannot GmbH +/** + * Copyright (c) 2024 Heimrich & Hannot GmbH. * * @license LGPL-3.0-or-later */ diff --git a/src/EventListener/DataContainer/ContentListener.php b/src/EventListener/DataContainer/ContentListener.php index 58966d9..34a62df 100644 --- a/src/EventListener/DataContainer/ContentListener.php +++ b/src/EventListener/DataContainer/ContentListener.php @@ -2,8 +2,8 @@ declare(strict_types=1); -/* - * Copyright (c) 2024 Heimrich & Hannot GmbH +/** + * Copyright (c) 2024 Heimrich & Hannot GmbH. * * @license LGPL-3.0-or-later */ diff --git a/src/EventListener/DataContainer/GoogleMapListener.php b/src/EventListener/DataContainer/GoogleMapListener.php index bb2e381..a7f19b8 100644 --- a/src/EventListener/DataContainer/GoogleMapListener.php +++ b/src/EventListener/DataContainer/GoogleMapListener.php @@ -2,8 +2,8 @@ declare(strict_types=1); -/* - * Copyright (c) 2024 Heimrich & Hannot GmbH +/** + * Copyright (c) 2024 Heimrich & Hannot GmbH. * * @license LGPL-3.0-or-later */ @@ -182,7 +182,8 @@ public function adjustPermissions(string|int $insertId): void return; } - $objSessionBag = $this->requestStack->getSession()->getBag('contao_backend'); + $objSessionBag = $this->requestStack->getSession() + ->getBag('contao_backend'); $newRecords = $objSessionBag->get('new_records'); if (\is_array($newRecords['tl_google_map']) && \in_array($insertId, $newRecords['tl_google_map'], true)) { @@ -218,7 +219,8 @@ public function adjustPermissions(string|int $insertId): void ->from('tl_user') ; $qb->where($qb->expr()->eq('id', $user->id)); - $userPermissions = $qb->executeQuery()->fetchAssociative(); + $userPermissions = $qb->executeQuery() + ->fetchAssociative(); $mapPermissions = StringUtil::deserialize($userPermissions['contao_google_maps_bundlep']); diff --git a/src/EventListener/DataContainer/MapWizardListener.php b/src/EventListener/DataContainer/MapWizardListener.php index a4faeb1..c10af22 100644 --- a/src/EventListener/DataContainer/MapWizardListener.php +++ b/src/EventListener/DataContainer/MapWizardListener.php @@ -2,8 +2,8 @@ declare(strict_types=1); -/* - * Copyright (c) 2024 Heimrich & Hannot GmbH +/** + * Copyright (c) 2024 Heimrich & Hannot GmbH. * * @license LGPL-3.0-or-later */ diff --git a/src/EventListener/DataContainer/ModuleListener.php b/src/EventListener/DataContainer/ModuleListener.php index 894160d..98888d3 100644 --- a/src/EventListener/DataContainer/ModuleListener.php +++ b/src/EventListener/DataContainer/ModuleListener.php @@ -2,8 +2,8 @@ declare(strict_types=1); -/* - * Copyright (c) 2024 Heimrich & Hannot GmbH +/** + * Copyright (c) 2024 Heimrich & Hannot GmbH. * * @license LGPL-3.0-or-later */ diff --git a/src/EventListener/DataContainer/OverlayListener.php b/src/EventListener/DataContainer/OverlayListener.php index eee9e73..9fa553e 100644 --- a/src/EventListener/DataContainer/OverlayListener.php +++ b/src/EventListener/DataContainer/OverlayListener.php @@ -2,8 +2,8 @@ declare(strict_types=1); -/* - * Copyright (c) 2024 Heimrich & Hannot GmbH +/** + * Copyright (c) 2024 Heimrich & Hannot GmbH. * * @license LGPL-3.0-or-later */ @@ -38,6 +38,8 @@ class OverlayListener extends Backend public const TYPE_KML_LAYER = 'kml'; + public const TYPE_GEOJSON_LAYER = 'geojson'; + public const TYPES = [ self::TYPE_MARKER, self::TYPE_INFO_WINDOW, @@ -47,6 +49,16 @@ class OverlayListener extends Backend self::TYPE_RECTANGLE, self::TYPE_GROUND_OVERLAY, self::TYPE_KML_LAYER, + self::TYPE_GEOJSON_LAYER, + ]; + + public const GEOJSON_SOURCE_FILE = 'file'; + + public const GEOJSON_SOURCE_URL = 'url'; + + public const GEOJSON_SOURCES = [ + self::GEOJSON_SOURCE_FILE, + self::GEOJSON_SOURCE_URL, ]; public const TITLE_MODE_TITLE_FIELD = 'title_field'; diff --git a/src/EventListener/InsertTagsListener.php b/src/EventListener/InsertTagsListener.php index e68d4cb..cac3906 100644 --- a/src/EventListener/InsertTagsListener.php +++ b/src/EventListener/InsertTagsListener.php @@ -2,8 +2,8 @@ declare(strict_types=1); -/* - * Copyright (c) 2024 Heimrich & Hannot GmbH +/** + * Copyright (c) 2024 Heimrich & Hannot GmbH. * * @license LGPL-3.0-or-later */ @@ -34,7 +34,8 @@ public function __invoke(ResolvedInsertTag $insertTag): InsertTagResult { $this->framework->initialize(); - $mapId = (int) $insertTag->getParameters()->get(0); + $mapId = (int) $insertTag->getParameters() + ->get(0); return match ($insertTag->getName()) { 'google_map' => new InsertTagResult( diff --git a/src/EventListener/MapRendererListener.php b/src/EventListener/MapRendererListener.php index cb4bc09..52d1f2c 100644 --- a/src/EventListener/MapRendererListener.php +++ b/src/EventListener/MapRendererListener.php @@ -2,8 +2,8 @@ declare(strict_types=1); -/* - * Copyright (c) 2024 Heimrich & Hannot GmbH +/** + * Copyright (c) 2024 Heimrich & Hannot GmbH. * * @license LGPL-3.0-or-later */ @@ -31,7 +31,8 @@ public function __construct( public function renderStylesheet(MapEvent $event, string $eventName, EventDispatcherInterface $dispatcher): void { - $this->mapHelper->getEventDispatcher()->removeListener('map.stylesheet', [$this, 'renderStylesheet']); + $this->mapHelper->getEventDispatcher() + ->removeListener('map.stylesheet', [$this, 'renderStylesheet']); $responsiveSettings = StringUtil::deserialize($this->model->responsive, true); @@ -59,7 +60,9 @@ public function renderStylesheet(MapEvent $event, string $eventName, EventDispat '.$event->getMap()->getVariable().'.setCenter(center); }'); - $event->getMap()->getEventManager()->addDomEvent($resizeEvent); + $event->getMap() + ->getEventManager() + ->addDomEvent($resizeEvent); } public function getManager(): MapManager diff --git a/src/EventListener/OveleonContaoCookiebarListener.php b/src/EventListener/OveleonContaoCookiebarListener.php index f4c841f..d0f9c15 100644 --- a/src/EventListener/OveleonContaoCookiebarListener.php +++ b/src/EventListener/OveleonContaoCookiebarListener.php @@ -66,7 +66,9 @@ public function onBeforeRenderApiEvent(BeforeRenderApiEvent $event): void return; } - $listeners = $event->getApiHelper()->getEventDispatcher()->getListeners(ApiEvents::JAVASCRIPT); + $listeners = $event->getApiHelper() + ->getEventDispatcher() + ->getListeners(ApiEvents::JAVASCRIPT); $apiSubscriber = null; foreach ($listeners as $listener) { @@ -84,7 +86,8 @@ public function onBeforeRenderApiEvent(BeforeRenderApiEvent $event): void } $apiRenderer = $apiSubscriber->getApiRenderer(); - $source = $apiRenderer->getLoaderRenderer()->renderSource('ivory_google_map_init', $event->getApiEvent()->getLibraries()); + $source = $apiRenderer->getLoaderRenderer() + ->renderSource('ivory_google_map_init', $event->getApiEvent()->getLibraries()); $this->addScriptToGlobals($this->maskExternalResource($source, $config['id'], 'gmap_library')); @@ -150,7 +153,8 @@ private function findConfig(): ?array return null; } - $rootPage = $this->utils->request()->getCurrentRootPageModel(); + $rootPage = $this->utils->request() + ->getCurrentRootPageModel(); if (null === $rootPage) { return null; } @@ -216,9 +220,12 @@ private function parseHtml(string $content, Map $map, Request $request, CookieMo // support legacy path for bc $legacyTemplateName = '@Contao/oveleon_cookiebar/blocker/default.html.twig'; if ( - $this->twig->getLoader()->exists($legacyTemplateName) + $this->twig->getLoader() + ->exists($legacyTemplateName) && !str_contains( - $this->twig->getLoader()->getSourceContext($legacyTemplateName)->getPath(), + $this->twig->getLoader() + ->getSourceContext($legacyTemplateName) + ->getPath(), 'heimrichhannot/contao-google-maps-bundle' ) ) { diff --git a/src/EventListener/ReplaceDynamicScriptTagsListener.php b/src/EventListener/ReplaceDynamicScriptTagsListener.php index fd82148..f27fe73 100644 --- a/src/EventListener/ReplaceDynamicScriptTagsListener.php +++ b/src/EventListener/ReplaceDynamicScriptTagsListener.php @@ -2,8 +2,8 @@ declare(strict_types=1); -/* - * Copyright (c) 2024 Heimrich & Hannot GmbH +/** + * Copyright (c) 2024 Heimrich & Hannot GmbH. * * @license LGPL-3.0-or-later */ @@ -34,6 +34,12 @@ public function __invoke($buffer): string return $buffer; } + // Registered here because maps render inside the body, by which point + // a TL_JAVASCRIPT entry would come too late for the head. + if ($this->mapManager->hasGeoJsonLayers()) { + $GLOBALS['TL_JAVASCRIPT']['huh_google_maps_geojson'] = 'bundles/heimrichhannotgooglemaps/js/google-maps-geojson.js|static'; + } + // fix the code for the case more than 1 map is on the page and not the first one // is clicked and add to body variable $GLOBALS['TL_BODY']['huhGoogleMaps'] = preg_replace( diff --git a/src/HeimrichHannotGoogleMapsBundle.php b/src/HeimrichHannotGoogleMapsBundle.php index 21d47d0..7a36c28 100644 --- a/src/HeimrichHannotGoogleMapsBundle.php +++ b/src/HeimrichHannotGoogleMapsBundle.php @@ -2,8 +2,8 @@ declare(strict_types=1); -/* - * Copyright (c) 2024 Heimrich & Hannot GmbH +/** + * Copyright (c) 2024 Heimrich & Hannot GmbH. * * @license LGPL-3.0-or-later */ diff --git a/src/Manager/MapManager.php b/src/Manager/MapManager.php index 182f0f6..65fd06e 100644 --- a/src/Manager/MapManager.php +++ b/src/Manager/MapManager.php @@ -2,8 +2,8 @@ declare(strict_types=1); -/* - * Copyright (c) 2024 Heimrich & Hannot GmbH +/** + * Copyright (c) 2024 Heimrich & Hannot GmbH. * * @license LGPL-3.0-or-later */ @@ -35,6 +35,7 @@ use Ivory\GoogleMap\Control\ScaleControl; use Ivory\GoogleMap\Control\StreetViewControl; use Ivory\GoogleMap\Control\ZoomControl; +use Ivory\GoogleMap\Event\Event; use Ivory\GoogleMap\Helper\ApiHelper; use Ivory\GoogleMap\Helper\Builder\ApiHelperBuilder; use Ivory\GoogleMap\Helper\Builder\MapHelperBuilder; @@ -127,6 +128,8 @@ public function prepareMap(int $mapId, array $config = [], ?Collection $overlays } } + $this->addGeoJsonInitialization($map); + $templateData['mapModel'] = $map; $templateData['mapConfig'] = $mapConfig->row(); $templateData['mapConfigModel'] = $mapConfig; @@ -161,7 +164,8 @@ public function renderMapObject(Map $map, ?int $mapId = null, ?GoogleMapModel $m if ($mapConfigModel) { $listener = new MapRendererListener($templateData['mapConfigModel'], $this, $mapHelper, $this->framework); - $mapHelper->getEventDispatcher()->addListener('map.stylesheet', [$listener, 'renderStylesheet']); + $mapHelper->getEventDispatcher() + ->addListener('map.stylesheet', [$listener, 'renderStylesheet']); } $templateData['mapHtml'] = $mapHelper->renderHtml($map); @@ -181,6 +185,68 @@ public function renderMapObject(Map $map, ?int $mapId = null, ?GoogleMapModel $m return $template->parse(); } + /** + * Registers the data layer initialization as a map event. + * + * Registered as an event because Ivory renders those inside the map's + * own init callback, where the map variable is guaranteed to exist. + */ + protected function addGeoJsonInitialization(Map $map): void + { + if (!$map->getLayerManager()->hasGeoJsonLayers()) { + return; + } + + // One data layer is shared by all GeoJSON overlays of a map. + $merged = [ + 'style' => [], + 'useFeatureStyles' => false, + 'clickable' => false, + 'fitBounds' => false, + ]; + + $found = false; + + foreach ($map->getLayerManager()->getGeoJsonLayers() as $layer) { + $options = $layer->getOptions(); + + if (!isset($options['huhOverlay'])) { + continue; + } + + $found = true; + $config = (array) $options['huhOverlay']; + + $merged['style'] = array_merge($merged['style'], (array) ($config['style'] ?? [])); + $merged['useFeatureStyles'] = $merged['useFeatureStyles'] || !empty($config['useFeatureStyles']); + $merged['clickable'] = $merged['clickable'] || !empty($config['clickable']); + $merged['fitBounds'] = $merged['fitBounds'] || !empty($config['fitBounds']); + } + + if (!$found) { + return; + } + + // An empty style must serialize as an object, not an array. + $merged['style'] = (object) $merged['style']; + + // "idle" repeats on every viewport change, so the handler detaches + // itself after the first run. + $event = new Event( + $map->getVariable(), + 'idle', + \sprintf( + 'function(){HuhGoogleMaps.initGeoJsonLayer(%s,%s);google.maps.event.clearListeners(%s,"idle");}', + $map->getVariable(), + json_encode($merged, \JSON_THROW_ON_ERROR), + $map->getVariable() + ) + ); + + $map->getEventManager() + ->addDomEvent($event); + } + public function renderHtml(int $mapId, array $config = []) { $config['skipCss'] = true; @@ -205,6 +271,20 @@ public function renderJs(int $mapId, array $config = []) return $this->render($mapId, $config); } + /** + * Whether any rendered map on this page holds a GeoJSON layer. + */ + public function hasGeoJsonLayers(): bool + { + foreach ($this->mapCollection->getMaps() as $map) { + if ($map->getLayerManager()->hasGeoJsonLayers()) { + return true; + } + } + + return false; + } + /** * Render the google map api. */ @@ -229,7 +309,8 @@ public function renderApi(): string ; $listener = new ApiRenderListener($apiHelper, $this->eventDispatcher); - $apiHelper->getEventDispatcher()->addListener(ApiEvents::JAVASCRIPT, [$listener, 'onApiRender']); + $apiHelper->getEventDispatcher() + ->addListener(ApiEvents::JAVASCRIPT, [$listener, 'onApiRender']); $output = $apiHelper->render($this->mapCollection->getMaps()); @@ -287,7 +368,8 @@ public function setVisualization(Map $map, GoogleMapModel $mapConfig): void // clustering if ($mapConfig->addClusterer) { - $clusterer = $map->getOverlayManager()->getMarkerCluster(); + $clusterer = $map->getOverlayManager() + ->getMarkerCluster(); $clusterer->setType(MarkerClusterType::MARKER_CLUSTERER); if ($mapConfig->clustererImg) { @@ -388,7 +470,8 @@ public function addStaticMap(Map $map, GoogleMapModel $mapConfig, array &$templa { if ($mapConfig->staticMapNoscript) { $staticParams = [ - 'center' => $map->getCenter()->getLatitude().','.$map->getCenter()->getLongitude(), + 'center' => $map->getCenter() + ->getLatitude().','.$map->getCenter()->getLongitude(), 'zoom' => $map->getMapOption('zoom'), 'size' => $mapConfig->staticMapWidth.'x'.$mapConfig->staticMapHeight, 'maptype' => $map->getMapOption('mapTypeId'), @@ -409,7 +492,8 @@ public function addControls(Map $map, GoogleMapModel $mapConfig): void $mapConfig->mapTypeControlStyle, ); - $map->getControlManager()->setMapTypeControl($control); + $map->getControlManager() + ->setMapTypeControl($control); } else { // Explicitly disable map type control when not enabled $map->setMapOption('mapTypeControl', false); @@ -421,7 +505,8 @@ public function addControls(Map $map, GoogleMapModel $mapConfig): void $mapConfig->zoomControlPos, ); - $map->getControlManager()->setZoomControl($control); + $map->getControlManager() + ->setZoomControl($control); } // rotate @@ -430,7 +515,8 @@ public function addControls(Map $map, GoogleMapModel $mapConfig): void $mapConfig->rotateControlPos, ); - $map->getControlManager()->setRotateControl($control); + $map->getControlManager() + ->setRotateControl($control); } else { // Explicitly disable rotate control when not enabled $map->setMapOption('rotateControl', false); @@ -442,7 +528,8 @@ public function addControls(Map $map, GoogleMapModel $mapConfig): void $mapConfig->streetViewControlPos, ); - $map->getControlManager()->setStreetViewControl($control); + $map->getControlManager() + ->setStreetViewControl($control); } else { // Explicitly disable street view control when not enabled $map->setMapOption('streetViewControl', false); @@ -454,7 +541,8 @@ public function addControls(Map $map, GoogleMapModel $mapConfig): void $mapConfig->fullscreenControlPos, ); - $map->getControlManager()->setFullscreenControl($control); + $map->getControlManager() + ->setFullscreenControl($control); } else { // Explicitly disable fullscreen control when not enabled $map->setMapOption('fullscreenControl', false); @@ -464,7 +552,8 @@ public function addControls(Map $map, GoogleMapModel $mapConfig): void if ($mapConfig->addScaleControl) { $control = new ScaleControl(); - $map->getControlManager()->setScaleControl($control); + $map->getControlManager() + ->setScaleControl($control); } else { // Explicitly disable scale control when not enabled $map->setMapOption('scaleControl', false); diff --git a/src/Manager/OverlayManager.php b/src/Manager/OverlayManager.php index 506b3e0..bde111c 100644 --- a/src/Manager/OverlayManager.php +++ b/src/Manager/OverlayManager.php @@ -2,8 +2,8 @@ declare(strict_types=1); -/* - * Copyright (c) 2024 Heimrich & Hannot GmbH +/** + * Copyright (c) 2024 Heimrich & Hannot GmbH. * * @license LGPL-3.0-or-later */ @@ -24,6 +24,7 @@ use Ivory\GoogleMap\Base\Size; use Ivory\GoogleMap\Event\Event; use Ivory\GoogleMap\Event\MouseEvent; +use Ivory\GoogleMap\Layer\GeoJsonLayer; use Ivory\GoogleMap\Layer\KmlLayer; use Ivory\GoogleMap\Map; use Ivory\GoogleMap\Overlay\Icon; @@ -67,10 +68,12 @@ public function addOverlayToMap(Map $map, OverlayModel $overlayConfig, string $a case OverlayListener::TYPE_MARKER: [$marker, $events] = $this->prepareMarker($overlayConfig, $map); - $map->getOverlayManager()->addMarker($marker); + $map->getOverlayManager() + ->addMarker($marker); foreach ($events as $event) { - $map->getEventManager()->addDomEvent($event); + $map->getEventManager() + ->addDomEvent($event); } break; @@ -79,21 +82,32 @@ public function addOverlayToMap(Map $map, OverlayModel $overlayConfig, string $a $infoWindow = $this->prepareInfoWindow($overlayConfig); $infoWindow->setOpen(true); - $map->getOverlayManager()->addInfoWindow($infoWindow); + $map->getOverlayManager() + ->addInfoWindow($infoWindow); break; case OverlayListener::TYPE_KML_LAYER: $kmlLayer = $this->prepareKmlLayer($overlayConfig); - $map->getLayerManager()->addKmlLayer($kmlLayer); + $map->getLayerManager() + ->addKmlLayer($kmlLayer); + + break; + + case OverlayListener::TYPE_GEOJSON_LAYER: + if (null !== ($geoJsonLayer = $this->prepareGeoJsonLayer($overlayConfig))) { + $map->getLayerManager() + ->addGeoJsonLayer($geoJsonLayer); + } break; case OverlayListener::TYPE_POLYGON: $polygon = $this->preparePolygon($overlayConfig); - $map->getOverlayManager()->addPolygon($polygon); + $map->getOverlayManager() + ->addPolygon($polygon); break; @@ -341,6 +355,55 @@ protected function prepareKmlLayer(OverlayModel $overlayConfig) return $kmlLayer; } + /** + * Builds the data layer replacing the deprecated KmlLayer. + * + * GeoJSON has no style specification, so the styling options travel as + * layer options and are applied by the frontend script. + */ + protected function prepareGeoJsonLayer(OverlayModel $overlayConfig): ?GeoJsonLayer + { + if (null === ($url = $this->getGeoJsonUrl($overlayConfig))) { + return null; + } + + $geoJsonLayer = new GeoJsonLayer($url); + + $style = array_filter([ + 'strokeColor' => $overlayConfig->geojsonStrokeColor ? '#'.ltrim($overlayConfig->geojsonStrokeColor, '#') : null, + 'strokeWeight' => '' !== (string) $overlayConfig->geojsonStrokeWeight ? (int) $overlayConfig->geojsonStrokeWeight : null, + 'strokeOpacity' => '' !== (string) $overlayConfig->geojsonStrokeOpacity ? (float) $overlayConfig->geojsonStrokeOpacity : null, + 'fillColor' => $overlayConfig->geojsonFillColor ? '#'.ltrim($overlayConfig->geojsonFillColor, '#') : null, + 'fillOpacity' => '' !== (string) $overlayConfig->geojsonFillOpacity ? (float) $overlayConfig->geojsonFillOpacity : null, + 'zIndex' => $overlayConfig->zIndex ? (int) $overlayConfig->zIndex : null, + ], static fn ($value) => null !== $value); + + // Namespaced so the options are not mistaken for loadGeoJson() ones. + // Booleans are kept verbatim; filtering would drop an explicit "off". + $geoJsonLayer->setOption('huhOverlay', [ + 'id' => (int) $overlayConfig->id, + 'style' => (object) $style, + 'useFeatureStyles' => (bool) $overlayConfig->geojsonStylePropertiesEnabled, + 'clickable' => (bool) $overlayConfig->geojsonClickable, + 'fitBounds' => (bool) $overlayConfig->geojsonFitBounds, + ]); + + return $geoJsonLayer; + } + + /** + * Resolves the configured source to a URL the browser can fetch, or null + * when a file reference no longer resolves. + */ + protected function getGeoJsonUrl(OverlayModel $overlayConfig): ?string + { + if (OverlayListener::GEOJSON_SOURCE_URL === $overlayConfig->geojsonSource) { + return $overlayConfig->geojsonUrl ?: null; + } + + return $this->fileUtil->getPathFromUuid($overlayConfig->geojsonFile) ?: null; + } + protected function preparePolygon(OverlayModel $overlayConfig) { $polygon = new Polygon(); diff --git a/src/Model/GoogleMapModel.php b/src/Model/GoogleMapModel.php index aa40a58..5b772b1 100644 --- a/src/Model/GoogleMapModel.php +++ b/src/Model/GoogleMapModel.php @@ -2,8 +2,8 @@ declare(strict_types=1); -/* - * Copyright (c) 2024 Heimrich & Hannot GmbH +/** + * Copyright (c) 2024 Heimrich & Hannot GmbH. * * @license LGPL-3.0-or-later */ diff --git a/src/Model/OverlayModel.php b/src/Model/OverlayModel.php index 5bad4b3..8f2857e 100644 --- a/src/Model/OverlayModel.php +++ b/src/Model/OverlayModel.php @@ -2,8 +2,8 @@ declare(strict_types=1); -/* - * Copyright (c) 2024 Heimrich & Hannot GmbH +/** + * Copyright (c) 2024 Heimrich & Hannot GmbH. * * @license LGPL-3.0-or-later */ @@ -48,6 +48,17 @@ * @property string $kmlPreserveViewport * @property string $kmlScreenOverlays * @property string $kmlSuppressInfowindows + * @property string $geojsonSource + * @property string $geojsonFile + * @property string $geojsonUrl + * @property string $geojsonClickable + * @property string $geojsonFitBounds + * @property string $geojsonStrokeColor + * @property string $geojsonStrokeWeight + * @property string $geojsonStrokeOpacity + * @property string $geojsonFillColor + * @property string $geojsonFillOpacity + * @property string $geojsonStylePropertiesEnabled * @property string $pathCoordinates * @property string $strokeWeight * @property string $strokeColor diff --git a/src/Security/GoogleMapsPermissions.php b/src/Security/GoogleMapsPermissions.php index d0f95cc..90e8d99 100644 --- a/src/Security/GoogleMapsPermissions.php +++ b/src/Security/GoogleMapsPermissions.php @@ -2,8 +2,8 @@ declare(strict_types=1); -/* - * Copyright (c) 2024 Heimrich & Hannot GmbH +/** + * Copyright (c) 2024 Heimrich & Hannot GmbH. * * @license LGPL-3.0-or-later */ diff --git a/src/Security/Voter/GoogleMapAccessVoter.php b/src/Security/Voter/GoogleMapAccessVoter.php index 29f47ae..b84bc5c 100644 --- a/src/Security/Voter/GoogleMapAccessVoter.php +++ b/src/Security/Voter/GoogleMapAccessVoter.php @@ -2,8 +2,8 @@ declare(strict_types=1); -/* - * Copyright (c) 2024 Heimrich & Hannot GmbH +/** + * Copyright (c) 2024 Heimrich & Hannot GmbH. * * @license LGPL-3.0-or-later */ diff --git a/src/Security/Voter/OverlayAccessVoter.php b/src/Security/Voter/OverlayAccessVoter.php index 04ce114..b7b548c 100644 --- a/src/Security/Voter/OverlayAccessVoter.php +++ b/src/Security/Voter/OverlayAccessVoter.php @@ -2,8 +2,8 @@ declare(strict_types=1); -/* - * Copyright (c) 2024 Heimrich & Hannot GmbH +/** + * Copyright (c) 2024 Heimrich & Hannot GmbH. * * @license LGPL-3.0-or-later */ diff --git a/src/Service/ElevationService.php b/src/Service/ElevationService.php index 2ee3c54..9166270 100644 --- a/src/Service/ElevationService.php +++ b/src/Service/ElevationService.php @@ -2,8 +2,8 @@ declare(strict_types=1); -/* - * Copyright (c) 2024 Heimrich & Hannot GmbH +/** + * Copyright (c) 2024 Heimrich & Hannot GmbH. * * @license LGPL-3.0-or-later */ diff --git a/src/Twig/GoogleMapsExtension.php b/src/Twig/GoogleMapsExtension.php index d72fede..d6a1012 100644 --- a/src/Twig/GoogleMapsExtension.php +++ b/src/Twig/GoogleMapsExtension.php @@ -2,8 +2,8 @@ declare(strict_types=1); -/* - * Copyright (c) 2024 Heimrich & Hannot GmbH +/** + * Copyright (c) 2024 Heimrich & Hannot GmbH. * * @license LGPL-3.0-or-later */ diff --git a/src/Util/ArrayUtil.php b/src/Util/ArrayUtil.php index de76b42..2937598 100644 --- a/src/Util/ArrayUtil.php +++ b/src/Util/ArrayUtil.php @@ -2,8 +2,8 @@ declare(strict_types=1); -/* - * Copyright (c) 2024 Heimrich & Hannot GmbH +/** + * Copyright (c) 2024 Heimrich & Hannot GmbH. * * @license LGPL-3.0-or-later */ diff --git a/src/Util/DcaUtil.php b/src/Util/DcaUtil.php index 7ca814c..fdbd632 100644 --- a/src/Util/DcaUtil.php +++ b/src/Util/DcaUtil.php @@ -2,8 +2,8 @@ declare(strict_types=1); -/* - * Copyright (c) 2024 Heimrich & Hannot GmbH +/** + * Copyright (c) 2024 Heimrich & Hannot GmbH. * * @license LGPL-3.0-or-later */ diff --git a/src/Util/KmlConverter.php b/src/Util/KmlConverter.php new file mode 100644 index 0000000..15a063d --- /dev/null +++ b/src/Util/KmlConverter.php @@ -0,0 +1,498 @@ +/ definitions are + * flattened into simplestyle feature properties + * (https://github.com/mapbox/simplestyle-spec), which the GeoJSON layer reads + * back when rendering. + */ +class KmlConverter +{ + private array $externalIcons = []; + + private array $rewrittenIcons = []; + + /** + * @param string[] $localIconHosts hosts whose icon URLs are rewritten to + * $iconBase, e.g. after mirroring them + * @param string[] $googleIconHosts hosts whose icons are looked up by + * basename below $iconBase/$googleIconDir + */ + public function __construct( + private readonly string $iconBase = '', + private readonly array $localIconHosts = [], + private readonly array $googleIconHosts = ['maps.google.com', 'maps.gstatic.com', 'kh.google.com'], + private readonly string $googleIconDir = 'google', + private readonly int $precision = 6, + private readonly bool $keepFolders = false, + private readonly ?string $iconRoot = null, + ) { + } + + /** + * @return array{type: string, name?: string, features: array} + * + * @throws \RuntimeException when the file cannot be parsed + */ + public function convertFile(string $file): array + { + $previous = libxml_use_internal_errors(true); + $xml = simplexml_load_file($file); + libxml_use_internal_errors($previous); + + if (false === $xml) { + throw new \RuntimeException('not valid XML'); + } + + return $this->convert($xml); + } + + /** + * @return array{type: string, name?: string, features: array} + */ + public function convert(\SimpleXMLElement $xml): array + { + $styles = $this->collectStyles($xml); + $features = []; + + // local-name() so the queries work regardless of namespace prefixes. + foreach ($xml->xpath('//*[local-name()="Placemark"]') ?: [] as $placemark) { + $geometry = $this->buildGeometry($placemark); + + if (null === $geometry) { + continue; + } + + $features[] = [ + 'type' => 'Feature', + 'properties' => $this->buildProperties($placemark, $styles), + 'geometry' => $geometry, + ]; + } + + $geoJson = [ + 'type' => 'FeatureCollection', + ]; + $name = $xml->xpath('//*[local-name()="Document"]/*[local-name()="name"]')[0] ?? null; + + if (null !== $name && '' !== trim((string) $name)) { + $geoJson['name'] = trim((string) $name); + } + + $geoJson['features'] = $features; + + return $geoJson; + } + + /** + * External icon URLs left untouched — http:// ones get blocked on a + * HTTPS page, so they are worth reporting. + * + * @return string[] + */ + public function getExternalIcons(): array + { + return array_keys($this->externalIcons); + } + + /** + * Rewritten icon paths that do not exist below the icon root. + * + * @return string[] + */ + public function getMissingIcons(): array + { + return array_keys(array_filter( + $this->rewrittenIcons, + fn (string $path): bool => null !== $this->iconRoot + && !file_exists(rtrim($this->iconRoot, '/').'/'.ltrim($path, '/')), + )); + } + + /** + * KML colours are AABBGGRR — alpha first, then *reversed* RGB. Getting + * this wrong silently swaps red and blue. + * + * @return array{0: string, 1: float} hex colour and opacity + */ + public function kmlColorToHex(string $kmlColor): array + { + $kmlColor = ltrim(trim($kmlColor), '#'); + + if (8 === \strlen($kmlColor)) { + $alpha = hexdec(substr($kmlColor, 0, 2)) / 255; + $bb = substr($kmlColor, 2, 2); + $gg = substr($kmlColor, 4, 2); + $rr = substr($kmlColor, 6, 2); + } elseif (6 === \strlen($kmlColor)) { + // Some editors emit BBGGRR without alpha. + $alpha = 1.0; + $bb = substr($kmlColor, 0, 2); + $gg = substr($kmlColor, 2, 2); + $rr = substr($kmlColor, 4, 2); + } else { + return ['#000000', 1.0]; + } + + return ['#'.strtolower($rr.$gg.$bb), round($alpha, 2)]; + } + + /** + * @return array{styles: array, maps: array} + */ + private function collectStyles(\SimpleXMLElement $xml): array + { + $styles = []; + $maps = []; + + foreach ($xml->xpath('//*[local-name()="Style"][@id]') ?: [] as $style) { + $styles[(string) $style['id']] = $this->buildStyle($style); + } + + // Only the "normal" branch is meaningful for a static render. + foreach ($xml->xpath('//*[local-name()="StyleMap"][@id]') ?: [] as $styleMap) { + foreach ($styleMap->Pair as $pair) { + if ('normal' === (string) $pair->key) { + $maps[(string) $styleMap['id']] = ltrim((string) $pair->styleUrl, '#'); + + break; + } + } + } + + return [ + 'styles' => $styles, + 'maps' => $maps, + ]; + } + + private function buildStyle(\SimpleXMLElement $style): array + { + $properties = []; + + if (isset($style->LineStyle)) { + if (isset($style->LineStyle->color)) { + [$hex, $opacity] = $this->kmlColorToHex((string) $style->LineStyle->color); + $properties['stroke'] = $hex; + $properties['stroke-opacity'] = $opacity; + } + + if (isset($style->LineStyle->width)) { + $properties['stroke-width'] = (float) $style->LineStyle->width; + } + } + + if (isset($style->PolyStyle)) { + if (isset($style->PolyStyle->color)) { + [$hex, $opacity] = $this->kmlColorToHex((string) $style->PolyStyle->color); + $properties['fill'] = $hex; + $properties['fill-opacity'] = $opacity; + } + + // 0 means outline only. + if (isset($style->PolyStyle->fill) && '0' === (string) $style->PolyStyle->fill) { + $properties['fill-opacity'] = 0.0; + } + } + + if (isset($style->IconStyle)) { + if (isset($style->IconStyle->Icon->href)) { + $properties['icon'] = $this->rewriteIconHref((string) $style->IconStyle->Icon->href); + } + + if (isset($style->IconStyle->scale)) { + $properties['icon-scale'] = (float) $style->IconStyle->scale; + } + + if (isset($style->IconStyle->color)) { + [$hex] = $this->kmlColorToHex((string) $style->IconStyle->color); + $properties['marker-color'] = $hex; + } + } + + return $properties; + } + + /** + * Resolves a styleUrl through any number of StyleMap indirections. + */ + private function resolveStyle(string $styleUrl, array $styleData): array + { + $id = ltrim(trim($styleUrl), '#'); + + // Guard against a cyclic document. + for ($hops = 0; $hops < 10; ++$hops) { + if (isset($styleData['styles'][$id])) { + return $styleData['styles'][$id]; + } + + if (!isset($styleData['maps'][$id])) { + break; + } + + $id = $styleData['maps'][$id]; + } + + return []; + } + + private function buildProperties(\SimpleXMLElement $placemark, array $styles): array + { + $properties = []; + + if (isset($placemark->name)) { + $properties['name'] = trim((string) $placemark->name); + } + + if (isset($placemark->description)) { + $description = trim((string) $placemark->description); + + if ('' !== $description) { + $properties['description'] = $description; + } + } + + if ($this->keepFolders) { + $folders = []; + + foreach ($placemark->xpath('ancestor::*[local-name()="Folder"]/*[local-name()="name"]') ?: [] as $folder) { + $folders[] = trim((string) $folder); + } + + if ($folders) { + $properties['folder'] = end($folders); + + if (\count($folders) > 1) { + $properties['folderPath'] = implode(' / ', $folders); + } + } + } + + foreach ($placemark->xpath('.//*[local-name()="Data"]') ?: [] as $data) { + $name = trim((string) $data['name']); + $value = trim((string) ($data->value ?? '')); + + if ('' === $name || '' === $value || isset($properties[$name])) { + continue; + } + + // ExtendedData can carry icon URLs of its own. + if (preg_match('#\.(png|jpe?g|gif|svg|webp)$#i', $value)) { + $value = $this->rewriteIconHref($value); + } + + $properties[$name] = $value; + } + + if (isset($placemark->styleUrl)) { + $properties = array_merge($properties, $this->resolveStyle((string) $placemark->styleUrl, $styles)); + } + + return $properties; + } + + /** + * Rewrites icon URLs to local paths, so a HTTPS page no longer depends + * on blocked http:// resources. + */ + private function rewriteIconHref(string $href): string + { + $href = trim($href); + + if ('' === $href) { + return $href; + } + + $host = parse_url($href, PHP_URL_HOST); + + if (null === $host || false === $host) { + return $href; + } + + $host = strtolower($host); + + if ('' !== $this->iconBase && \in_array($host, array_map(strtolower(...), $this->localIconHosts), true)) { + $path = ltrim(parse_url($href, PHP_URL_PATH) ?: '', '/'); + // Prefix only: the segment may legitimately appear mid-path. + $path = preg_replace('#^'.preg_quote(basename($this->iconBase), '#').'/#', '', $path); + + return $this->rewrittenIcons[$this->iconBase.'/'.$path] = $this->iconBase.'/'.$path; + } + + // Mirrored by basename: one icon is referenced through several URL + // prefixes, and kh.google.com puts the filename in the query string. + if ('' !== $this->iconBase && \in_array($host, array_map(strtolower(...), $this->googleIconHosts), true)) { + $candidate = parse_url($href, PHP_URL_PATH) ?: ''; + + if (!str_ends_with(strtolower($candidate), '.png')) { + $candidate = parse_url($href, PHP_URL_QUERY) ?: ''; + } + + $basename = basename($candidate); + + if ('' !== $basename) { + $local = $this->iconBase.'/'.$this->googleIconDir.'/'.$basename; + + if (null === $this->iconRoot || file_exists(rtrim($this->iconRoot, '/').'/'.ltrim($local, '/'))) { + return $this->rewrittenIcons[$local] = $local; + } + } + } + + $this->externalIcons[$href] = $href; + + return $href; + } + + /** + * KML is "lon,lat[,alt]" — already GeoJSON's axis order; altitude is + * dropped as noise for a 2D map. + * + * @return array + */ + private function parseCoordinates(string $raw): array + { + $positions = []; + + foreach (preg_split('/\s+/', trim($raw), -1, PREG_SPLIT_NO_EMPTY) ?: [] as $tuple) { + $parts = explode(',', $tuple); + + if (\count($parts) < 2) { + continue; + } + + $lon = round((float) $parts[0], $this->precision); + $lat = round((float) $parts[1], $this->precision); + + // Guard against the truncated tuples some editors leave behind. + if ($lon < -180 || $lon > 180 || $lat < -90 || $lat > 90) { + continue; + } + + $positions[] = [$lon, $lat]; + } + + return $positions; + } + + private function buildGeometry(\SimpleXMLElement $placemark): ?array + { + if (isset($placemark->MultiGeometry)) { + $geometries = []; + + foreach ($placemark->MultiGeometry->children() as $child) { + $wrapper = new \SimpleXMLElement(''); + $this->appendXml($wrapper, $child); + + if (null !== ($geometry = $this->buildGeometry($wrapper))) { + $geometries[] = $geometry; + } + } + + if (!$geometries) { + return null; + } + + if (1 === \count($geometries)) { + return $geometries[0]; + } + + return [ + 'type' => 'GeometryCollection', + 'geometries' => $geometries, + ]; + } + + if (isset($placemark->Point->coordinates)) { + $positions = $this->parseCoordinates((string) $placemark->Point->coordinates); + + return $positions ? [ + 'type' => 'Point', + 'coordinates' => $positions[0], + ] : null; + } + + if (isset($placemark->LineString->coordinates)) { + $positions = $this->parseCoordinates((string) $placemark->LineString->coordinates); + + return \count($positions) >= 2 ? [ + 'type' => 'LineString', + 'coordinates' => $positions, + ] : null; + } + + if (isset($placemark->Polygon)) { + return $this->buildPolygon($placemark->Polygon); + } + + return null; + } + + private function buildPolygon(\SimpleXMLElement $polygon): ?array + { + $rings = []; + $outer = $polygon->outerBoundaryIs->LinearRing->coordinates ?? null; + + if (null !== $outer) { + $ring = $this->closeRing($this->parseCoordinates((string) $outer)); + + if (\count($ring) >= 4) { + $rings[] = $ring; + } + } + + foreach ($polygon->innerBoundaryIs ?? [] as $inner) { + if (!isset($inner->LinearRing->coordinates)) { + continue; + } + + $ring = $this->closeRing($this->parseCoordinates((string) $inner->LinearRing->coordinates)); + + if (\count($ring) >= 4) { + $rings[] = $ring; + } + } + + return $rings ? [ + 'type' => 'Polygon', + 'coordinates' => $rings, + ] : null; + } + + /** + * GeoJSON requires a linear ring to repeat its first position last. + */ + private function closeRing(array $positions): array + { + if (\count($positions) >= 3 && $positions[0] !== $positions[\count($positions) - 1]) { + $positions[] = $positions[0]; + } + + return $positions; + } + + private function appendXml(\SimpleXMLElement $parent, \SimpleXMLElement $child): void + { + $node = $parent->addChild($child->getName(), htmlspecialchars((string) $child)); + + foreach ($child->attributes() ?: [] as $name => $value) { + $node->addAttribute($name, (string) $value); + } + + foreach ($child->children() as $grandChild) { + $this->appendXml($node, $grandChild); + } + } +} diff --git a/src/Util/LocationUtil.php b/src/Util/LocationUtil.php index a023d02..5710556 100644 --- a/src/Util/LocationUtil.php +++ b/src/Util/LocationUtil.php @@ -2,8 +2,8 @@ declare(strict_types=1); -/* - * Copyright (c) 2024 Heimrich & Hannot GmbH +/** + * Copyright (c) 2024 Heimrich & Hannot GmbH. * * @license LGPL-3.0-or-later */