From dd20dbe5e971511bc161e7a1c660ac2f6c4a0fbe Mon Sep 17 00:00:00 2001 From: Sylvain Blondeau Date: Thu, 13 Feb 2025 22:47:10 +0100 Subject: [PATCH 1/5] feat: custom icon on markers --- src/Map/CHANGELOG.md | 1 + .../assets/dist/abstract_map_controller.d.ts | 11 +++ src/Map/assets/src/abstract_map_controller.ts | 15 ++- src/Map/config/services.php | 8 ++ src/Map/doc/index.rst | 23 +++++ .../Google/assets/dist/map_controller.d.ts | 7 +- .../Google/assets/dist/map_controller.js | 20 +++- .../Google/assets/src/map_controller.ts | 30 +++++- .../Google/src/Renderer/GoogleRenderer.php | 4 +- .../src/Renderer/GoogleRendererFactory.php | 5 +- .../Google/tests/GoogleRendererTest.php | 7 +- .../Leaflet/assets/dist/map_controller.d.ts | 6 +- .../Leaflet/assets/dist/map_controller.js | 12 ++- .../Leaflet/assets/src/map_controller.ts | 22 ++++- .../src/Renderer/LeafletRendererFactory.php | 2 +- .../Leaflet/tests/LeafletRendererTest.php | 7 +- src/Map/src/Icon/Icon.php | 94 +++++++++++++++++++ src/Map/src/Icon/InlineSvg.php | 21 +++++ src/Map/src/Icon/Url.php | 21 +++++ src/Map/src/Icon/UxIcon.php | 21 +++++ src/Map/src/Icon/UxIconRenderer.php | 38 ++++++++ src/Map/src/Marker.php | 8 ++ src/Map/src/Renderer/AbstractRenderer.php | 7 +- .../src/Renderer/AbstractRendererFactory.php | 2 + src/Map/tests/IconTest.php | 50 ++++++++++ src/Map/tests/MapTest.php | 3 + src/Map/tests/MarkerTest.php | 9 ++ 27 files changed, 437 insertions(+), 17 deletions(-) create mode 100644 src/Map/src/Icon/Icon.php create mode 100644 src/Map/src/Icon/InlineSvg.php create mode 100644 src/Map/src/Icon/Url.php create mode 100644 src/Map/src/Icon/UxIcon.php create mode 100644 src/Map/src/Icon/UxIconRenderer.php create mode 100644 src/Map/tests/IconTest.php diff --git a/src/Map/CHANGELOG.md b/src/Map/CHANGELOG.md index c8bead94a04..1ab7242f78c 100644 --- a/src/Map/CHANGELOG.md +++ b/src/Map/CHANGELOG.md @@ -3,6 +3,7 @@ ## 2.24 - Installing the package in a Symfony app using Flex won't add the `@symfony/ux-map` dependency to the `package.json` file anymore. +- Add `Icon` to customize a `Marker` icon (URL or SVG content) ## 2.23 diff --git a/src/Map/assets/dist/abstract_map_controller.d.ts b/src/Map/assets/dist/abstract_map_controller.d.ts index 99920e5f4de..d753bc75063 100644 --- a/src/Map/assets/dist/abstract_map_controller.d.ts +++ b/src/Map/assets/dist/abstract_map_controller.d.ts @@ -3,6 +3,12 @@ export type Point = { lat: number; lng: number; }; +export type Icon = { + content: string; + type: 'url' | 'inline-svg' | 'ux-icon'; + width: number; + height: number; +}; export type Identifier = string; export type WithIdentifier> = T & { '@id': Identifier; @@ -11,6 +17,7 @@ export type MarkerDefinition = WithIdentifier< position: Point; title: string | null; infoWindow?: InfoWindowWithoutPositionDefinition; + icon?: Icon; rawOptions?: MarkerOptions; extra: Record; }>; @@ -105,6 +112,10 @@ export default abstract class; element: Marker | Polygon | Polyline; }): InfoWindow; + protected abstract doCreateIcon({ definition, element, }: { + definition: Icon; + element: Marker; + }): void; private createDrawingFactory; private onDrawChanged; } diff --git a/src/Map/assets/src/abstract_map_controller.ts b/src/Map/assets/src/abstract_map_controller.ts index 63b227af8d8..6549d0d520d 100644 --- a/src/Map/assets/src/abstract_map_controller.ts +++ b/src/Map/assets/src/abstract_map_controller.ts @@ -1,7 +1,12 @@ import { Controller } from '@hotwired/stimulus'; export type Point = { lat: number; lng: number }; - +export type Icon = { + content: string; + type: 'url' | 'inline-svg' | 'ux-icon'; + width: number; + height: number; +}; export type Identifier = string; export type WithIdentifier> = T & { '@id': Identifier }; @@ -9,6 +14,7 @@ export type MarkerDefinition = WithIdentifier< position: Point; title: string | null; infoWindow?: InfoWindowWithoutPositionDefinition; + icon?: Icon; /** * Raw options passed to the marker constructor, specific to the map provider (e.g.: `L.marker()` for Leaflet). */ @@ -268,6 +274,13 @@ export default abstract class< definition: InfoWindowWithoutPositionDefinition; element: Marker | Polygon | Polyline; }): InfoWindow; + protected abstract doCreateIcon({ + definition, + element, + }: { + definition: Icon; + element: Marker; + }): void; //endregion diff --git a/src/Map/config/services.php b/src/Map/config/services.php index 961eb365b46..9514c33cd58 100644 --- a/src/Map/config/services.php +++ b/src/Map/config/services.php @@ -11,6 +11,7 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; +use Symfony\UX\Map\Icon\UxIconRenderer; use Symfony\UX\Map\Renderer\AbstractRendererFactory; use Symfony\UX\Map\Renderer\Renderer; use Symfony\UX\Map\Renderer\Renderers; @@ -20,6 +21,7 @@ /* * @author Hugo Alliaume */ + return static function (ContainerConfigurator $container): void { $container->services() ->set('ux_map.renderers', Renderers::class) @@ -28,10 +30,16 @@ abstract_arg('renderers configuration'), ]) + ->set('.ux_map.ux_icons.renderer', UxIconRenderer::class) + ->args([ + service('.ux_icons.icon_renderer')->nullOnInvalid(), + ]) + ->set('ux_map.renderer_factory.abstract', AbstractRendererFactory::class) ->abstract() ->args([ service('stimulus.helper'), + service('.ux_map.ux_icons.renderer')->nullOnInvalid(), ]) ->set('ux_map.renderer_factory', Renderer::class) diff --git a/src/Map/doc/index.rst b/src/Map/doc/index.rst index c36ea6109c9..d9fb39a538f 100644 --- a/src/Map/doc/index.rst +++ b/src/Map/doc/index.rst @@ -108,6 +108,10 @@ You can add markers to a map using the ``addMarker()`` method:: infoWindow: new InfoWindow( headerContent: 'Lyon', content: 'The French town in the historic Rhône-Alpes region, located at the junction of the Rhône and Saône rivers.' + ), + icon: new Icon( + content: '....' + icontType: 'html' ) )) @@ -128,6 +132,25 @@ You can add markers to a map using the ``addMarker()`` method:: )) ; +Add Marker icons +~~~~~~~~~~~~~~~~ + +.. versionadded:: 2.24 + + ``Marker`` icon customizatisation is available since UX Map 2.24. +When returning a Map, it's quite common to want to customize ``Marker`` icon. It is possible thanks to ``icon`` parameter and ``Icon`` class:: + + // It can be a UX Icon (requires `symfony/ux-icons`) + $icon = Icon::fromUxIcon('fa:map-marker'); + // Or an URL pointing to an image + $icon = Icon::fromUrl('https://example.com/marker.png'); + // Or a plain SVG content + $icon = Icon::fromInlineSVG('(...)'); + new Marker( + // ... + icon: $icon +)) + Remove elements from Map ~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/src/Map/src/Bridge/Google/assets/dist/map_controller.d.ts b/src/Map/src/Bridge/Google/assets/dist/map_controller.d.ts index 14fab318737..6f2efe0aae3 100644 --- a/src/Map/src/Bridge/Google/assets/dist/map_controller.d.ts +++ b/src/Map/src/Bridge/Google/assets/dist/map_controller.d.ts @@ -1,10 +1,11 @@ import type { LoaderOptions } from '@googlemaps/js-api-loader'; import AbstractMapController from '@symfony/ux-map'; -import type { InfoWindowWithoutPositionDefinition, MarkerDefinition, Point, PolygonDefinition, PolylineDefinition } from '@symfony/ux-map'; +import type { Icon, InfoWindowWithoutPositionDefinition, MarkerDefinition, Point, PolygonDefinition, PolylineDefinition } from '@symfony/ux-map'; type MapOptions = Pick; export default class extends AbstractMapController { providerOptionsValue: Pick; map: google.maps.Map; + parser: DOMParser; connect(): Promise; centerValueChanged(): void; zoomValueChanged(): void; @@ -32,6 +33,10 @@ export default class extends AbstractMapController { if (otherInfoWindow !== infoWindow) { diff --git a/src/Map/src/Bridge/Google/assets/src/map_controller.ts b/src/Map/src/Bridge/Google/assets/src/map_controller.ts index bcf5da7e9c6..f3a29b06f6c 100644 --- a/src/Map/src/Bridge/Google/assets/src/map_controller.ts +++ b/src/Map/src/Bridge/Google/assets/src/map_controller.ts @@ -11,6 +11,7 @@ import type { LoaderOptions } from '@googlemaps/js-api-loader'; import { Loader } from '@googlemaps/js-api-loader'; import AbstractMapController from '@symfony/ux-map'; import type { + Icon, InfoWindowWithoutPositionDefinition, MarkerDefinition, Point, @@ -55,6 +56,8 @@ export default class extends AbstractMapController< declare map: google.maps.Map; + public parser: DOMParser; + async connect() { if (!_google) { _google = { maps: {} as typeof google.maps }; @@ -88,6 +91,7 @@ export default class extends AbstractMapController< } super.connect(); + this.parser = new DOMParser(); } public centerValueChanged(): void { @@ -139,7 +143,7 @@ export default class extends AbstractMapController< }: { definition: MarkerDefinition; }): google.maps.marker.AdvancedMarkerElement { - const { '@id': _id, position, title, infoWindow, extra, rawOptions = {}, ...otherOptions } = definition; + const { '@id': _id, position, title, infoWindow, icon, extra, rawOptions = {}, ...otherOptions } = definition; const marker = new _google.maps.marker.AdvancedMarkerElement({ position, @@ -153,6 +157,10 @@ export default class extends AbstractMapController< this.createInfoWindow({ definition: infoWindow, element: marker }); } + if (icon) { + this.doCreateIcon({ definition: icon, element: marker }); + } + return marker; } @@ -297,6 +305,26 @@ export default class extends AbstractMapController< return content; } + protected doCreateIcon({ + definition, + element, + }: { + definition: Icon; + element: google.maps.marker.AdvancedMarkerElement; + }): void { + const { content, type, width, height } = definition; + if (type === 'inline-svg') { + const icon = this.parser.parseFromString(content, 'image/svg+xml').documentElement; + element.content = icon; + } else { + const icon = document.createElement('img'); + icon.width = width; + icon.height = height; + icon.src = content; + element.content = icon; + } + } + private closeInfoWindowsExcept(infoWindow: google.maps.InfoWindow) { this.infoWindows.forEach((otherInfoWindow) => { if (otherInfoWindow !== infoWindow) { diff --git a/src/Map/src/Bridge/Google/src/Renderer/GoogleRenderer.php b/src/Map/src/Bridge/Google/src/Renderer/GoogleRenderer.php index cb325ba0171..b2b4de32d08 100644 --- a/src/Map/src/Bridge/Google/src/Renderer/GoogleRenderer.php +++ b/src/Map/src/Bridge/Google/src/Renderer/GoogleRenderer.php @@ -12,6 +12,7 @@ namespace Symfony\UX\Map\Bridge\Google\Renderer; use Symfony\UX\Map\Bridge\Google\GoogleOptions; +use Symfony\UX\Map\Icon\UxIconRenderer; use Symfony\UX\Map\MapOptionsInterface; use Symfony\UX\Map\Renderer\AbstractRenderer; use Symfony\UX\StimulusBundle\Helper\StimulusHelper; @@ -42,8 +43,9 @@ public function __construct( */ private array $libraries = [], private ?string $defaultMapId = null, + ?UxIconRenderer $uxIconRenderer = null, ) { - parent::__construct($stimulusHelper); + parent::__construct($stimulusHelper, $uxIconRenderer); } protected function getName(): string diff --git a/src/Map/src/Bridge/Google/src/Renderer/GoogleRendererFactory.php b/src/Map/src/Bridge/Google/src/Renderer/GoogleRendererFactory.php index e34956349ad..b8ec89ace0e 100644 --- a/src/Map/src/Bridge/Google/src/Renderer/GoogleRendererFactory.php +++ b/src/Map/src/Bridge/Google/src/Renderer/GoogleRendererFactory.php @@ -13,6 +13,7 @@ use Symfony\UX\Map\Exception\InvalidArgumentException; use Symfony\UX\Map\Exception\UnsupportedSchemeException; +use Symfony\UX\Map\Icon\UxIconRenderer; use Symfony\UX\Map\Renderer\AbstractRendererFactory; use Symfony\UX\Map\Renderer\Dsn; use Symfony\UX\Map\Renderer\RendererFactoryInterface; @@ -26,9 +27,10 @@ final class GoogleRendererFactory extends AbstractRendererFactory implements Ren { public function __construct( StimulusHelper $stimulus, + ?UxIconRenderer $uxIconRenderer = null, private ?string $defaultMapId = null, ) { - parent::__construct($stimulus); + parent::__construct($stimulus, $uxIconRenderer); } public function create(Dsn $dsn): RendererInterface @@ -51,6 +53,7 @@ public function create(Dsn $dsn): RendererInterface version: $dsn->getOption('version', 'weekly'), libraries: ['maps', 'marker', ...$dsn->getOption('libraries', [])], defaultMapId: $this->defaultMapId, + uxIconRenderer: $this->uxIconRenderer, ); } diff --git a/src/Map/src/Bridge/Google/tests/GoogleRendererTest.php b/src/Map/src/Bridge/Google/tests/GoogleRendererTest.php index dda259fec56..018dbf6b740 100644 --- a/src/Map/src/Bridge/Google/tests/GoogleRendererTest.php +++ b/src/Map/src/Bridge/Google/tests/GoogleRendererTest.php @@ -13,6 +13,7 @@ use Symfony\UX\Map\Bridge\Google\GoogleOptions; use Symfony\UX\Map\Bridge\Google\Renderer\GoogleRenderer; +use Symfony\UX\Map\Icon\Icon; use Symfony\UX\Map\InfoWindow; use Symfony\UX\Map\Map; use Symfony\UX\Map\Marker; @@ -30,7 +31,7 @@ public function provideTestRenderMap(): iterable ->center(new Point(48.8566, 2.3522)) ->zoom(12); $marker1 = new Marker(position: new Point(48.8566, 2.3522), title: 'Paris', id: 'marker1'); - $marker2 = new Marker(position: new Point(48.8566, 2.3522), title: 'Lyon', infoWindow: new InfoWindow(content: 'Lyon'), id: 'marker2'); + $marker2 = new Marker(position: new Point(48.8566, 2.3522), title: 'Lyon', infoWindow: new InfoWindow(content: 'Lyon'), id: 'marker2', icon: Icon::fromInlineSVG(html: '')); $marker3 = new Marker(position: new Point(45.8566, 2.3522), title: 'Dijon', id: 'marker3'); yield 'simple map, with minimum options' => [ @@ -53,7 +54,7 @@ public function provideTestRenderMap(): iterable ]; yield 'with markers and infoWindows' => [ - 'expected_render' => '
', + 'expected_render' => '
', 'renderer' => new GoogleRenderer(new StimulusHelper(null), apiKey: 'api_key'), 'map' => (new Map()) ->center(new Point(48.8566, 2.3522)) @@ -75,7 +76,7 @@ public function provideTestRenderMap(): iterable ]; yield 'with marker remove and new ones added' => [ - 'expected_render' => '
', + 'expected_render' => '
', 'renderer' => new GoogleRenderer(new StimulusHelper(null), apiKey: 'api_key'), 'map' => (new Map()) ->center(new Point(48.8566, 2.3522)) diff --git a/src/Map/src/Bridge/Leaflet/assets/dist/map_controller.d.ts b/src/Map/src/Bridge/Leaflet/assets/dist/map_controller.d.ts index f7475812e19..8c10b168d5f 100644 --- a/src/Map/src/Bridge/Leaflet/assets/dist/map_controller.d.ts +++ b/src/Map/src/Bridge/Leaflet/assets/dist/map_controller.d.ts @@ -1,5 +1,5 @@ import AbstractMapController from '@symfony/ux-map'; -import type { InfoWindowWithoutPositionDefinition, MarkerDefinition, Point, PolygonDefinition, PolylineDefinition } from '@symfony/ux-map'; +import type { Icon, InfoWindowWithoutPositionDefinition, MarkerDefinition, Point, PolygonDefinition, PolylineDefinition } from '@symfony/ux-map'; import 'leaflet/dist/leaflet.min.css'; import * as L from 'leaflet'; import type { MapOptions as LeafletMapOptions, MarkerOptions, PolylineOptions as PolygonOptions, PolylineOptions, PopupOptions } from 'leaflet'; @@ -37,6 +37,10 @@ export default class extends AbstractMapController; element: L.Marker | L.Polygon | L.Polyline; }): L.Popup; + protected doCreateIcon({ definition, element, }: { + definition: Icon; + element: L.Marker; + }): void; protected doFitBoundsToMarkers(): void; } export {}; diff --git a/src/Map/src/Bridge/Leaflet/assets/dist/map_controller.js b/src/Map/src/Bridge/Leaflet/assets/dist/map_controller.js index 0628c8dafe6..e70b0cb60c0 100644 --- a/src/Map/src/Bridge/Leaflet/assets/dist/map_controller.js +++ b/src/Map/src/Bridge/Leaflet/assets/dist/map_controller.js @@ -147,11 +147,14 @@ class map_controller extends default_1 { return map; } doCreateMarker({ definition }) { - const { '@id': _id, position, title, infoWindow, extra, rawOptions = {}, ...otherOptions } = definition; + const { '@id': _id, position, title, infoWindow, icon, extra, rawOptions = {}, ...otherOptions } = definition; const marker = L.marker(position, { title: title || undefined, ...otherOptions, ...rawOptions }).addTo(this.map); if (infoWindow) { this.createInfoWindow({ definition: infoWindow, element: marker }); } + if (icon) { + this.doCreateIcon({ definition: icon, element: marker }); + } return marker; } doRemoveMarker(marker) { @@ -197,6 +200,13 @@ class map_controller extends default_1 { } return popup; } + doCreateIcon({ definition, element, }) { + const { content, type, width, height } = definition; + const icon = type === 'inline-svg' + ? L.divIcon({ html: content, iconSize: [width, height] }) + : L.icon({ iconUrl: content, iconSize: [width, height] }); + element.setIcon(icon); + } doFitBoundsToMarkers() { if (this.markers.size === 0) { return; diff --git a/src/Map/src/Bridge/Leaflet/assets/src/map_controller.ts b/src/Map/src/Bridge/Leaflet/assets/src/map_controller.ts index b7684446a7c..cdb27324023 100644 --- a/src/Map/src/Bridge/Leaflet/assets/src/map_controller.ts +++ b/src/Map/src/Bridge/Leaflet/assets/src/map_controller.ts @@ -1,5 +1,6 @@ import AbstractMapController from '@symfony/ux-map'; import type { + Icon, InfoWindowWithoutPositionDefinition, MarkerDefinition, Point, @@ -89,7 +90,7 @@ export default class extends AbstractMapController< } protected doCreateMarker({ definition }: { definition: MarkerDefinition }): L.Marker { - const { '@id': _id, position, title, infoWindow, extra, rawOptions = {}, ...otherOptions } = definition; + const { '@id': _id, position, title, infoWindow, icon, extra, rawOptions = {}, ...otherOptions } = definition; const marker = L.marker(position, { title: title || undefined, ...otherOptions, ...rawOptions }).addTo( this.map @@ -99,6 +100,10 @@ export default class extends AbstractMapController< this.createInfoWindow({ definition: infoWindow, element: marker }); } + if (icon) { + this.doCreateIcon({ definition: icon, element: marker }); + } + return marker; } @@ -173,6 +178,21 @@ export default class extends AbstractMapController< return popup; } + protected doCreateIcon({ + definition, + element, + }: { + definition: Icon; + element: L.Marker; + }): void { + const { content, type, width, height } = definition; + const icon = + type === 'inline-svg' + ? L.divIcon({ html: content, iconSize: [width, height] }) + : L.icon({ iconUrl: content, iconSize: [width, height] }); + element.setIcon(icon); + } + protected doFitBoundsToMarkers(): void { if (this.markers.size === 0) { return; diff --git a/src/Map/src/Bridge/Leaflet/src/Renderer/LeafletRendererFactory.php b/src/Map/src/Bridge/Leaflet/src/Renderer/LeafletRendererFactory.php index d5dc0c5dbd3..f213cafac20 100644 --- a/src/Map/src/Bridge/Leaflet/src/Renderer/LeafletRendererFactory.php +++ b/src/Map/src/Bridge/Leaflet/src/Renderer/LeafletRendererFactory.php @@ -28,7 +28,7 @@ public function create(Dsn $dsn): RendererInterface throw new UnsupportedSchemeException($dsn); } - return new LeafletRenderer($this->stimulus); + return new LeafletRenderer($this->stimulus, $this->uxIconRenderer); } protected function getSupportedSchemes(): array diff --git a/src/Map/src/Bridge/Leaflet/tests/LeafletRendererTest.php b/src/Map/src/Bridge/Leaflet/tests/LeafletRendererTest.php index e9f8cec5354..7940061e34f 100644 --- a/src/Map/src/Bridge/Leaflet/tests/LeafletRendererTest.php +++ b/src/Map/src/Bridge/Leaflet/tests/LeafletRendererTest.php @@ -12,6 +12,7 @@ namespace Symfony\UX\Map\Bridge\Leaflet\Tests; use Symfony\UX\Map\Bridge\Leaflet\Renderer\LeafletRenderer; +use Symfony\UX\Map\Icon\Icon; use Symfony\UX\Map\InfoWindow; use Symfony\UX\Map\Map; use Symfony\UX\Map\Marker; @@ -30,7 +31,7 @@ public function provideTestRenderMap(): iterable ->zoom(12); $marker1 = new Marker(position: new Point(48.8566, 2.3522), title: 'Paris', id: 'marker1'); - $marker2 = new Marker(position: new Point(48.8566, 2.3522), title: 'Lyon', infoWindow: new InfoWindow(content: 'Lyon'), id: 'marker2'); + $marker2 = new Marker(position: new Point(48.8566, 2.3522), title: 'Lyon', infoWindow: new InfoWindow(content: 'Lyon'), id: 'marker2', icon: Icon::fromInlineSVG(html: '')); $marker3 = new Marker(position: new Point(45.8566, 2.3522), title: 'Dijon', id: 'marker3'); yield 'simple map' => [ @@ -46,7 +47,7 @@ public function provideTestRenderMap(): iterable 'attributes' => ['data-controller' => 'my-custom-controller', 'class' => 'map'], ]; yield 'with markers and infoWindows' => [ - 'expected_render' => '
', + 'expected_render' => '
', 'renderer' => new LeafletRenderer(new StimulusHelper(null)), 'map' => (new Map()) ->center(new Point(48.8566, 2.3522)) @@ -68,7 +69,7 @@ public function provideTestRenderMap(): iterable ]; yield 'with marker remove and new ones added' => [ - 'expected_render' => '
', + 'expected_render' => '
', 'renderer' => new LeafletRenderer(new StimulusHelper(null)), 'map' => (new Map()) ->center(new Point(48.8566, 2.3522)) diff --git a/src/Map/src/Icon/Icon.php b/src/Map/src/Icon/Icon.php new file mode 100644 index 00000000000..3adb4d8d5ac --- /dev/null +++ b/src/Map/src/Icon/Icon.php @@ -0,0 +1,94 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Icon; + +use Symfony\UX\Map\Exception\InvalidArgumentException; + +/** + * Represents an icon that can be displayed on a map marker. + * + * @author Sylvain Blondeau + */ +class Icon +{ + public const TYPE_URL = 'url'; + public const TYPE_INLINE_SVG = 'inline-svg'; + public const TYPE_UX_ICON = 'ux-icon'; + + private function __construct( + public string $content, + public string $type, + public int $width = 24, + public int $height = 24, + ) { + } + + public function toArray(): array + { + return [ + 'content' => $this->content, + 'type' => $this->type, + 'width' => $this->width, + 'height' => $this->height, + ]; + } + + public static function fromUrl(string $url, int $width = 24, int $height = 24): Url + { + return new Url( + content: $url, + type: self::TYPE_URL, + width: $width, + height: $height + ); + } + + public static function fromInlineSVG(string $html, int $width = 24, int $height = 24): InlineSvg + { + return new InlineSvg( + content: $html, + type: self::TYPE_INLINE_SVG, + width: $width, + height: $height + ); + } + + public static function fromUxIcon(string $name, int $width = 24, int $height = 24): UxIcon + { + return new UxIcon( + content: $name, + type: self::TYPE_UX_ICON, + width: $width, + height: $height + ); + } + + /** + * @param array{ + * content: string, + * type: string, + * width: int, + * height: int, + * } $data + * + * @internal + */ + public static function fromArray(array $data): static + { + return match ($data['type']) { + 'url' => self::fromUrl($data['content'], (int) $data['width'], (int) $data['height']), + 'inline-svg' => self::fromInlineSvg($data['content'], (int) $data['width'], (int) $data['height']), + 'ux-icon' => self::fromUxIcon($data['content'], (int) $data['width'], (int) $data['height']), + default => throw new InvalidArgumentException(\sprintf('Invalid icon type %s.', $data['type'])), + }; + } +} diff --git a/src/Map/src/Icon/InlineSvg.php b/src/Map/src/Icon/InlineSvg.php new file mode 100644 index 00000000000..903e380282f --- /dev/null +++ b/src/Map/src/Icon/InlineSvg.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Icon; + +/** + * Represents an inline SVG icon. + * + * @author Sylvain Blondeau + */ +class InlineSvg extends Icon +{ +} diff --git a/src/Map/src/Icon/Url.php b/src/Map/src/Icon/Url.php new file mode 100644 index 00000000000..6f46019f583 --- /dev/null +++ b/src/Map/src/Icon/Url.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Icon; + +/** + * Represents an URL icon. + * + * @author Sylvain Blondeau + */ +class Url extends Icon +{ +} diff --git a/src/Map/src/Icon/UxIcon.php b/src/Map/src/Icon/UxIcon.php new file mode 100644 index 00000000000..456825ee47b --- /dev/null +++ b/src/Map/src/Icon/UxIcon.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Icon; + +/** + * Represents an ux icon. + * + * @author Sylvain Blondeau + */ +class UxIcon extends Icon +{ +} diff --git a/src/Map/src/Icon/UxIconRenderer.php b/src/Map/src/Icon/UxIconRenderer.php new file mode 100644 index 00000000000..ff7e1b6c649 --- /dev/null +++ b/src/Map/src/Icon/UxIconRenderer.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Icon; + +use Symfony\UX\Icons\IconRendererInterface; + +/** + * @author Sylvain Blondeau + * + * @internal + */ +readonly class UxIconRenderer +{ + public function __construct( + private readonly ?IconRendererInterface $renderer, + ) { + } + + public function render(string $name): string + { + if (null === $this->renderer) { + throw new \LogicException('You cannot use an UX Icon as the "UX Icons" package is not installed. Try running "composer require symfony/ux-icons" to install it.'); + } + + return $this->renderer->renderIcon($name, [ + 'xmlns' => 'http://www.w3.org/2000/svg', + ]); + } +} diff --git a/src/Map/src/Marker.php b/src/Map/src/Marker.php index 0310834a14f..cdad5b18b7d 100644 --- a/src/Map/src/Marker.php +++ b/src/Map/src/Marker.php @@ -12,6 +12,7 @@ namespace Symfony\UX\Map; use Symfony\UX\Map\Exception\InvalidArgumentException; +use Symfony\UX\Map\Icon\Icon; /** * Represents a marker on a map. @@ -30,6 +31,7 @@ public function __construct( public ?InfoWindow $infoWindow = null, public array $extra = [], public ?string $id = null, + public ?Icon $icon = null, ) { } @@ -38,6 +40,7 @@ public function __construct( * position: array{lat: float, lng: float}, * title: string|null, * infoWindow: array|null, + * icon: Icon|null, * extra: array, * id: string|null * } @@ -48,6 +51,7 @@ public function toArray(): array 'position' => $this->position->toArray(), 'title' => $this->title, 'infoWindow' => $this->infoWindow?->toArray(), + 'icon' => $this->icon?->toArray(), 'extra' => $this->extra, 'id' => $this->id, ]; @@ -58,6 +62,7 @@ public function toArray(): array * position: array{lat: float, lng: float}, * title: string|null, * infoWindow: array|null, + * icon: array||null, * extra: array, * id: string|null * } $marker @@ -74,6 +79,9 @@ public static function fromArray(array $marker): self if (isset($marker['infoWindow'])) { $marker['infoWindow'] = InfoWindow::fromArray($marker['infoWindow']); } + if (isset($marker['icon'])) { + $marker['icon'] = Icon::fromArray($marker['icon']); + } return new self(...$marker); } diff --git a/src/Map/src/Renderer/AbstractRenderer.php b/src/Map/src/Renderer/AbstractRenderer.php index 794b08d6d98..4cf49c53039 100644 --- a/src/Map/src/Renderer/AbstractRenderer.php +++ b/src/Map/src/Renderer/AbstractRenderer.php @@ -11,6 +11,7 @@ namespace Symfony\UX\Map\Renderer; +use Symfony\UX\Map\Icon\UxIconRenderer; use Symfony\UX\Map\Map; use Symfony\UX\Map\MapOptionsInterface; use Symfony\UX\StimulusBundle\Helper\StimulusHelper; @@ -22,6 +23,7 @@ { public function __construct( private StimulusHelper $stimulus, + private ?UxIconRenderer $uxIconRenderer = null, ) { } @@ -90,8 +92,11 @@ private function getMapAttributes(Map $map): array foreach ($attrs['markers'] as $key => $marker) { $attrs['markers'][$key]['@id'] = $computeId($marker); + if (null !== $this->uxIconRenderer && null !== $marker['icon'] && 'ux-icon' === $marker['icon']['type']) { + $attrs['markers'][$key]['icon']['content'] = $this->uxIconRenderer->render($marker['icon']['content']); + $attrs['markers'][$key]['icon']['type'] = 'inline-svg'; + } } - foreach ($attrs['polygons'] as $key => $polygon) { $attrs['polygons'][$key]['@id'] = $computeId($polygon); } diff --git a/src/Map/src/Renderer/AbstractRendererFactory.php b/src/Map/src/Renderer/AbstractRendererFactory.php index 02587a75d09..b1417447e94 100644 --- a/src/Map/src/Renderer/AbstractRendererFactory.php +++ b/src/Map/src/Renderer/AbstractRendererFactory.php @@ -12,6 +12,7 @@ namespace Symfony\UX\Map\Renderer; use Symfony\UX\Map\Exception\IncompleteDsnException; +use Symfony\UX\Map\Icon\UxIconRenderer; use Symfony\UX\StimulusBundle\Helper\StimulusHelper; /** @@ -21,6 +22,7 @@ abstract class AbstractRendererFactory { public function __construct( protected StimulusHelper $stimulus, + protected ?UxIconRenderer $uxIconRenderer = null, ) { } diff --git a/src/Map/tests/IconTest.php b/src/Map/tests/IconTest.php new file mode 100644 index 00000000000..0797c330a13 --- /dev/null +++ b/src/Map/tests/IconTest.php @@ -0,0 +1,50 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\Map\Icon\Icon; +use Symfony\UX\Map\Icon\InlineSvg; +use Symfony\UX\Map\Icon\Url; +use Symfony\UX\Map\Icon\UxIcon; + +class IconTest extends TestCase +{ + public function testIconConstruction(): void + { + self::assertInstanceOf(Url::class, Icon::fromUrl(url: 'https://image.png')); + self::assertInstanceOf(InlineSvg::class, Icon::fromInlineSVG(html: '')); + self::assertInstanceOf(UxIcon::class, Icon::fromUxIcon(name: 'bi:heart')); + } + + public function testToArray(): void + { + $urlIcon = Icon::fromUrl(url: 'https://image.png'); + $array = $urlIcon->toArray(); + + self::assertSame([ + 'content' => 'https://image.png', + 'type' => 'url', + 'width' => 24, + 'height' => 24, + ], $array); + } + + public function testFromArray(): void + { + $urlIcon = Icon::fromUrl(url: 'https://image.png'); + $array = $urlIcon->toArray(); + + self::assertEquals( + $urlIcon, Icon::fromArray($array)); + } +} diff --git a/src/Map/tests/MapTest.php b/src/Map/tests/MapTest.php index 2f91323ea6b..c16fe3d1b78 100644 --- a/src/Map/tests/MapTest.php +++ b/src/Map/tests/MapTest.php @@ -186,6 +186,7 @@ public function testWithMaximumConfiguration(): void 'autoClose' => true, 'extra' => ['baz' => 'qux'], ], + 'icon' => null, 'extra' => ['foo' => 'bar'], 'id' => null, ], @@ -200,6 +201,7 @@ public function testWithMaximumConfiguration(): void 'autoClose' => true, 'extra' => [], ], + 'icon' => null, 'extra' => [], 'id' => null, ], @@ -214,6 +216,7 @@ public function testWithMaximumConfiguration(): void 'autoClose' => true, 'extra' => [], ], + 'icon' => null, 'extra' => [], 'id' => null, ], diff --git a/src/Map/tests/MarkerTest.php b/src/Map/tests/MarkerTest.php index f5f9d21ddd2..0a2cb9a1cee 100644 --- a/src/Map/tests/MarkerTest.php +++ b/src/Map/tests/MarkerTest.php @@ -12,6 +12,7 @@ namespace Symfony\UX\Map\Tests; use PHPUnit\Framework\TestCase; +use Symfony\UX\Map\Icon\Icon; use Symfony\UX\Map\InfoWindow; use Symfony\UX\Map\Marker; use Symfony\UX\Map\Point; @@ -30,6 +31,7 @@ public function testToArray(): void 'position' => ['lat' => 48.8566, 'lng' => 2.3522], 'title' => null, 'infoWindow' => null, + 'icon' => null, 'extra' => $array['extra'], 'id' => null, ], $array); @@ -42,6 +44,7 @@ public function testToArray(): void content: "Capitale de la France, est une grande ville européenne et un centre mondial de l'art, de la mode, de la gastronomie et de la culture.", opened: true, ), + icon: Icon::fromUrl('https://example.com/image.png'), ); $array = $marker->toArray(); @@ -57,6 +60,12 @@ public function testToArray(): void 'autoClose' => true, 'extra' => $array['infoWindow']['extra'], ], + 'icon' => [ + 'content' => 'https://example.com/image.png', + 'type' => 'url', + 'width' => 24, + 'height' => 24, + ], 'extra' => $array['extra'], 'id' => null, ], $array); From 73151f63bdaaf9784cd84ee617c3ad263c60d86e Mon Sep 17 00:00:00 2001 From: Hugo Alliaume Date: Mon, 17 Mar 2025 12:18:54 -0500 Subject: [PATCH 2/5] [Map] Define IconTypes and IconType in TypeScript --- src/Map/assets/dist/abstract_map_controller.d.ts | 16 +++++++++++----- src/Map/assets/dist/abstract_map_controller.js | 7 ++++++- src/Map/assets/src/abstract_map_controller.ts | 13 ++++++++++--- .../Bridge/Google/assets/dist/map_controller.js | 7 ++++++- .../Bridge/Google/assets/src/map_controller.ts | 4 ++-- .../Bridge/Leaflet/assets/dist/map_controller.js | 7 ++++++- .../Bridge/Leaflet/assets/src/map_controller.ts | 4 ++-- 7 files changed, 43 insertions(+), 15 deletions(-) diff --git a/src/Map/assets/dist/abstract_map_controller.d.ts b/src/Map/assets/dist/abstract_map_controller.d.ts index d753bc75063..d9eac8ba821 100644 --- a/src/Map/assets/dist/abstract_map_controller.d.ts +++ b/src/Map/assets/dist/abstract_map_controller.d.ts @@ -3,16 +3,22 @@ export type Point = { lat: number; lng: number; }; +export type Identifier = string; +export type WithIdentifier> = T & { + '@id': Identifier; +}; +export declare const IconTypes: { + readonly Url: "url"; + readonly InlineSvg: "inline-svg"; + readonly UxIcon: "ux-icon"; +}; +export type IconType = (typeof IconTypes)[keyof typeof IconTypes]; export type Icon = { content: string; - type: 'url' | 'inline-svg' | 'ux-icon'; + type: IconType; width: number; height: number; }; -export type Identifier = string; -export type WithIdentifier> = T & { - '@id': Identifier; -}; export type MarkerDefinition = WithIdentifier<{ position: Point; title: string | null; diff --git a/src/Map/assets/dist/abstract_map_controller.js b/src/Map/assets/dist/abstract_map_controller.js index 2627b65df2a..b773df56af6 100644 --- a/src/Map/assets/dist/abstract_map_controller.js +++ b/src/Map/assets/dist/abstract_map_controller.js @@ -1,5 +1,10 @@ import { Controller } from '@hotwired/stimulus'; +const IconTypes = { + Url: 'url', + InlineSvg: 'inline-svg', + UxIcon: 'ux-icon', +}; class default_1 extends Controller { constructor() { super(...arguments); @@ -102,4 +107,4 @@ default_1.values = { options: Object, }; -export { default_1 as default }; +export { IconTypes, default_1 as default }; diff --git a/src/Map/assets/src/abstract_map_controller.ts b/src/Map/assets/src/abstract_map_controller.ts index 6549d0d520d..ee2ec0207e5 100644 --- a/src/Map/assets/src/abstract_map_controller.ts +++ b/src/Map/assets/src/abstract_map_controller.ts @@ -1,14 +1,21 @@ import { Controller } from '@hotwired/stimulus'; export type Point = { lat: number; lng: number }; +export type Identifier = string; +export type WithIdentifier> = T & { '@id': Identifier }; + +export const IconTypes = { + Url: 'url', + InlineSvg: 'inline-svg', + UxIcon: 'ux-icon', +} as const; +export type IconType = (typeof IconTypes)[keyof typeof IconTypes]; export type Icon = { content: string; - type: 'url' | 'inline-svg' | 'ux-icon'; + type: IconType; width: number; height: number; }; -export type Identifier = string; -export type WithIdentifier> = T & { '@id': Identifier }; export type MarkerDefinition = WithIdentifier<{ position: Point; diff --git a/src/Map/src/Bridge/Google/assets/dist/map_controller.js b/src/Map/src/Bridge/Google/assets/dist/map_controller.js index 045c26b1b9c..3e73e8d9895 100644 --- a/src/Map/src/Bridge/Google/assets/dist/map_controller.js +++ b/src/Map/src/Bridge/Google/assets/dist/map_controller.js @@ -1,6 +1,11 @@ import { Loader } from '@googlemaps/js-api-loader'; import { Controller } from '@hotwired/stimulus'; +const IconTypes = { + Url: 'url', + InlineSvg: 'inline-svg', + UxIcon: 'ux-icon', +}; class default_1 extends Controller { constructor() { super(...arguments); @@ -278,7 +283,7 @@ class map_controller extends default_1 { } doCreateIcon({ definition, element, }) { const { content, type, width, height } = definition; - if (type === 'inline-svg') { + if (type === IconTypes.InlineSvg) { const icon = this.parser.parseFromString(content, 'image/svg+xml').documentElement; element.content = icon; } diff --git a/src/Map/src/Bridge/Google/assets/src/map_controller.ts b/src/Map/src/Bridge/Google/assets/src/map_controller.ts index f3a29b06f6c..f9446a56d63 100644 --- a/src/Map/src/Bridge/Google/assets/src/map_controller.ts +++ b/src/Map/src/Bridge/Google/assets/src/map_controller.ts @@ -9,7 +9,7 @@ import type { LoaderOptions } from '@googlemaps/js-api-loader'; import { Loader } from '@googlemaps/js-api-loader'; -import AbstractMapController from '@symfony/ux-map'; +import AbstractMapController, { IconTypes } from '@symfony/ux-map'; import type { Icon, InfoWindowWithoutPositionDefinition, @@ -313,7 +313,7 @@ export default class extends AbstractMapController< element: google.maps.marker.AdvancedMarkerElement; }): void { const { content, type, width, height } = definition; - if (type === 'inline-svg') { + if (type === IconTypes.InlineSvg) { const icon = this.parser.parseFromString(content, 'image/svg+xml').documentElement; element.content = icon; } else { diff --git a/src/Map/src/Bridge/Leaflet/assets/dist/map_controller.js b/src/Map/src/Bridge/Leaflet/assets/dist/map_controller.js index e70b0cb60c0..b4801d07542 100644 --- a/src/Map/src/Bridge/Leaflet/assets/dist/map_controller.js +++ b/src/Map/src/Bridge/Leaflet/assets/dist/map_controller.js @@ -2,6 +2,11 @@ import { Controller } from '@hotwired/stimulus'; import 'leaflet/dist/leaflet.min.css'; import * as L from 'leaflet'; +const IconTypes = { + Url: 'url', + InlineSvg: 'inline-svg', + UxIcon: 'ux-icon', +}; class default_1 extends Controller { constructor() { super(...arguments); @@ -202,7 +207,7 @@ class map_controller extends default_1 { } doCreateIcon({ definition, element, }) { const { content, type, width, height } = definition; - const icon = type === 'inline-svg' + const icon = type === IconTypes.InlineSvg ? L.divIcon({ html: content, iconSize: [width, height] }) : L.icon({ iconUrl: content, iconSize: [width, height] }); element.setIcon(icon); diff --git a/src/Map/src/Bridge/Leaflet/assets/src/map_controller.ts b/src/Map/src/Bridge/Leaflet/assets/src/map_controller.ts index cdb27324023..90e5c419554 100644 --- a/src/Map/src/Bridge/Leaflet/assets/src/map_controller.ts +++ b/src/Map/src/Bridge/Leaflet/assets/src/map_controller.ts @@ -1,4 +1,4 @@ -import AbstractMapController from '@symfony/ux-map'; +import AbstractMapController, { IconTypes } from '@symfony/ux-map'; import type { Icon, InfoWindowWithoutPositionDefinition, @@ -187,7 +187,7 @@ export default class extends AbstractMapController< }): void { const { content, type, width, height } = definition; const icon = - type === 'inline-svg' + type === IconTypes.InlineSvg ? L.divIcon({ html: content, iconSize: [width, height] }) : L.icon({ iconUrl: content, iconSize: [width, height] }); element.setIcon(icon); From 311689c2e312db4b30e322979a19cd80f8b5aac8 Mon Sep 17 00:00:00 2001 From: Hugo Alliaume Date: Mon, 17 Mar 2025 15:10:57 -0500 Subject: [PATCH 3/5] [Map] Refactor and simplify the way to create a custom Icon --- src/Map/doc/index.rst | 23 ++-- .../Google/tests/GoogleRendererTest.php | 2 +- .../Leaflet/tests/LeafletRendererTest.php | 2 +- src/Map/src/Icon/Icon.php | 110 ++++++++++-------- .../src/Icon/{InlineSvg.php => IconType.php} | 7 +- src/Map/src/Icon/SvgIcon.php | 56 +++++++++ src/Map/src/Icon/Url.php | 21 ---- src/Map/src/Icon/UrlIcon.php | 53 +++++++++ src/Map/src/Icon/UxIcon.php | 34 +++++- src/Map/tests/IconTest.php | 58 +++++---- src/Map/tests/MarkerTest.php | 4 +- 11 files changed, 265 insertions(+), 105 deletions(-) rename src/Map/src/Icon/{InlineSvg.php => IconType.php} (69%) create mode 100644 src/Map/src/Icon/SvgIcon.php delete mode 100644 src/Map/src/Icon/Url.php create mode 100644 src/Map/src/Icon/UrlIcon.php diff --git a/src/Map/doc/index.rst b/src/Map/doc/index.rst index d9fb39a538f..c21d9b8a6f7 100644 --- a/src/Map/doc/index.rst +++ b/src/Map/doc/index.rst @@ -137,19 +137,24 @@ Add Marker icons .. versionadded:: 2.24 - ``Marker`` icon customizatisation is available since UX Map 2.24. -When returning a Map, it's quite common to want to customize ``Marker`` icon. It is possible thanks to ``icon`` parameter and ``Icon`` class:: - - // It can be a UX Icon (requires `symfony/ux-icons`) - $icon = Icon::fromUxIcon('fa:map-marker'); + ``Marker`` icon customization is available since UX Map 2.24. + +A ``Marker`` can be customized with an ``Icon`` instance, which can either be an UX Icon, an URL, or a SVG content:: + + // It can be a UX Icon (requires `symfony/ux-icons` package) + $icon = Icon::ux('fa:map-marker'); // Or an URL pointing to an image - $icon = Icon::fromUrl('https://example.com/marker.png'); + $icon = Icon::url('https://example.com/marker.png'); // Or a plain SVG content - $icon = Icon::fromInlineSVG('(...)'); - new Marker( + $icon = Icon::svg('(...)'); + + // Configure the icon size with the `width()` and `height()` methods + $icon = Icon::ux('fa:map-marker')->width(48)->height(48); + + $map->addMarker(new Marker( // ... icon: $icon -)) + )); Remove elements from Map ~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/src/Map/src/Bridge/Google/tests/GoogleRendererTest.php b/src/Map/src/Bridge/Google/tests/GoogleRendererTest.php index 018dbf6b740..c8a0baeedec 100644 --- a/src/Map/src/Bridge/Google/tests/GoogleRendererTest.php +++ b/src/Map/src/Bridge/Google/tests/GoogleRendererTest.php @@ -31,7 +31,7 @@ public function provideTestRenderMap(): iterable ->center(new Point(48.8566, 2.3522)) ->zoom(12); $marker1 = new Marker(position: new Point(48.8566, 2.3522), title: 'Paris', id: 'marker1'); - $marker2 = new Marker(position: new Point(48.8566, 2.3522), title: 'Lyon', infoWindow: new InfoWindow(content: 'Lyon'), id: 'marker2', icon: Icon::fromInlineSVG(html: '')); + $marker2 = new Marker(position: new Point(48.8566, 2.3522), title: 'Lyon', infoWindow: new InfoWindow(content: 'Lyon'), id: 'marker2', icon: Icon::svg(html: '')); $marker3 = new Marker(position: new Point(45.8566, 2.3522), title: 'Dijon', id: 'marker3'); yield 'simple map, with minimum options' => [ diff --git a/src/Map/src/Bridge/Leaflet/tests/LeafletRendererTest.php b/src/Map/src/Bridge/Leaflet/tests/LeafletRendererTest.php index 7940061e34f..d07f04f6cc0 100644 --- a/src/Map/src/Bridge/Leaflet/tests/LeafletRendererTest.php +++ b/src/Map/src/Bridge/Leaflet/tests/LeafletRendererTest.php @@ -31,7 +31,7 @@ public function provideTestRenderMap(): iterable ->zoom(12); $marker1 = new Marker(position: new Point(48.8566, 2.3522), title: 'Paris', id: 'marker1'); - $marker2 = new Marker(position: new Point(48.8566, 2.3522), title: 'Lyon', infoWindow: new InfoWindow(content: 'Lyon'), id: 'marker2', icon: Icon::fromInlineSVG(html: '')); + $marker2 = new Marker(position: new Point(48.8566, 2.3522), title: 'Lyon', infoWindow: new InfoWindow(content: 'Lyon'), id: 'marker2', icon: Icon::svg(html: '')); $marker3 = new Marker(position: new Point(45.8566, 2.3522), title: 'Dijon', id: 'marker3'); yield 'simple map' => [ diff --git a/src/Map/src/Icon/Icon.php b/src/Map/src/Icon/Icon.php index 3adb4d8d5ac..aed5db4dd36 100644 --- a/src/Map/src/Icon/Icon.php +++ b/src/Map/src/Icon/Icon.php @@ -17,77 +17,93 @@ * Represents an icon that can be displayed on a map marker. * * @author Sylvain Blondeau + * @author Hugo Alliaume */ -class Icon +abstract class Icon { - public const TYPE_URL = 'url'; - public const TYPE_INLINE_SVG = 'inline-svg'; - public const TYPE_UX_ICON = 'ux-icon'; + /** + * @param non-empty-string $url + */ + public static function url(string $url): UrlIcon + { + return new UrlIcon($url); + } - private function __construct( - public string $content, - public string $type, - public int $width = 24, - public int $height = 24, - ) { + /** + * @param non-empty-string $html + */ + public static function svg(string $html): SvgIcon + { + return new SvgIcon($html); } - public function toArray(): array + /** + * @param non-empty-string $name + */ + public static function ux(string $name): UxIcon { - return [ - 'content' => $this->content, - 'type' => $this->type, - 'width' => $this->width, - 'height' => $this->height, - ]; + return new UxIcon($name); + } + + /** + * @param positive-int $width + * @param positive-int $height + */ + protected function __construct( + protected IconType $type, + protected int $width = 24, + protected int $height = 24, + ) { } - public static function fromUrl(string $url, int $width = 24, int $height = 24): Url + public function width(int $width): static { - return new Url( - content: $url, - type: self::TYPE_URL, - width: $width, - height: $height - ); + if ($width <= 0) { + throw new InvalidArgumentException('Width must be greater than 0.'); + } + + $this->width = $width; + + return $this; } - public static function fromInlineSVG(string $html, int $width = 24, int $height = 24): InlineSvg + public function height(int $height): static { - return new InlineSvg( - content: $html, - type: self::TYPE_INLINE_SVG, - width: $width, - height: $height - ); + if ($height <= 0) { + throw new InvalidArgumentException('Height must be greater than 0.'); + } + + $this->height = $height; + + return $this; } - public static function fromUxIcon(string $name, int $width = 24, int $height = 24): UxIcon + /** + * @internal + */ + public function toArray(): array { - return new UxIcon( - content: $name, - type: self::TYPE_UX_ICON, - width: $width, - height: $height - ); + return [ + 'type' => $this->type->value, + 'width' => $this->width, + 'height' => $this->height, + ]; } /** - * @param array{ - * content: string, - * type: string, - * width: int, - * height: int, - * } $data + * @param array{ type: value-of, width: positive-int, height: positive-int } + * &(array{ url: non-empty-string } + * |array{ html: non-empty-string } + * |array{ name: non-empty-string }) $data * * @internal */ public static function fromArray(array $data): static { return match ($data['type']) { - 'url' => self::fromUrl($data['content'], (int) $data['width'], (int) $data['height']), - 'inline-svg' => self::fromInlineSvg($data['content'], (int) $data['width'], (int) $data['height']), - 'ux-icon' => self::fromUxIcon($data['content'], (int) $data['width'], (int) $data['height']), + IconType::Url->value => UrlIcon::fromArray($data), + IconType::Svg->value => SvgIcon::fromArray($data), + IconType::UxIcon->value => UxIcon::fromArray($data), default => throw new InvalidArgumentException(\sprintf('Invalid icon type %s.', $data['type'])), }; } diff --git a/src/Map/src/Icon/InlineSvg.php b/src/Map/src/Icon/IconType.php similarity index 69% rename from src/Map/src/Icon/InlineSvg.php rename to src/Map/src/Icon/IconType.php index 903e380282f..ed6befaaebd 100644 --- a/src/Map/src/Icon/InlineSvg.php +++ b/src/Map/src/Icon/IconType.php @@ -14,8 +14,11 @@ /** * Represents an inline SVG icon. * - * @author Sylvain Blondeau + * @author Hugo Alliaume */ -class InlineSvg extends Icon +enum IconType: string { + case Url = 'url'; + case Svg = 'svg'; + case UxIcon = 'ux-icon'; } diff --git a/src/Map/src/Icon/SvgIcon.php b/src/Map/src/Icon/SvgIcon.php new file mode 100644 index 00000000000..181d3a753ca --- /dev/null +++ b/src/Map/src/Icon/SvgIcon.php @@ -0,0 +1,56 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Icon; + +/** + * Represents an inline SVG icon. + * + * @author Sylvain Blondeau + * @author Hugo Alliaume + * + * @internal + */ +class SvgIcon extends Icon +{ + /** + * @param non-empty-string $html + * @param positive-int $width + * @param positive-int $height + */ + protected function __construct( + protected string $html, + int $width = 24, + int $height = 24, + ) { + parent::__construct(IconType::Svg, $width, $height); + } + + /** + * @param array{ html: string, width: positive-int, height: positive-int } $data + */ + public static function fromArray(array $data): static + { + return new self( + html: $data['html'], + width: $data['width'], + height: $data['height'], + ); + } + + public function toArray(): array + { + return [ + ...parent::toArray(), + 'html' => $this->html, + ]; + } +} diff --git a/src/Map/src/Icon/Url.php b/src/Map/src/Icon/Url.php deleted file mode 100644 index 6f46019f583..00000000000 --- a/src/Map/src/Icon/Url.php +++ /dev/null @@ -1,21 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\UX\Map\Icon; - -/** - * Represents an URL icon. - * - * @author Sylvain Blondeau - */ -class Url extends Icon -{ -} diff --git a/src/Map/src/Icon/UrlIcon.php b/src/Map/src/Icon/UrlIcon.php new file mode 100644 index 00000000000..07b81c0d4fe --- /dev/null +++ b/src/Map/src/Icon/UrlIcon.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Icon; + +/** + * Represents an URL icon. + * + * @author Sylvain Blondeau + * @author Hugo Alliaume + * + * @internal + */ +class UrlIcon extends Icon +{ + /** + * @param non-empty-string $url + * @param positive-int $width + * @param positive-int $height + */ + protected function __construct( + protected string $url, + int $width = 24, + int $height = 24, + ) { + parent::__construct(IconType::Url, $width, $height); + } + + public static function fromArray(array $data): static + { + return new self( + url: $data['url'], + width: $data['width'], + height: $data['height'], + ); + } + + public function toArray(): array + { + return [ + ...parent::toArray(), + 'url' => $this->url, + ]; + } +} diff --git a/src/Map/src/Icon/UxIcon.php b/src/Map/src/Icon/UxIcon.php index 456825ee47b..ed456b312f1 100644 --- a/src/Map/src/Icon/UxIcon.php +++ b/src/Map/src/Icon/UxIcon.php @@ -12,10 +12,42 @@ namespace Symfony\UX\Map\Icon; /** - * Represents an ux icon. + * Represents an UX icon. * * @author Sylvain Blondeau + * @author Hugo Alliaume + * + * @internal */ class UxIcon extends Icon { + /** + * @param non-empty-string $name + * @param positive-int $width + * @param positive-int $height + */ + protected function __construct( + protected string $name, + int $width = 24, + int $height = 24, + ) { + parent::__construct(IconType::UxIcon, $width, $height); + } + + public static function fromArray(array $data): static + { + return new self( + name: $data['name'], + width: $data['width'], + height: $data['height'], + ); + } + + public function toArray(): array + { + return [ + ...parent::toArray(), + 'name' => $this->name, + ]; + } } diff --git a/src/Map/tests/IconTest.php b/src/Map/tests/IconTest.php index 0797c330a13..02860c4d987 100644 --- a/src/Map/tests/IconTest.php +++ b/src/Map/tests/IconTest.php @@ -13,38 +13,54 @@ use PHPUnit\Framework\TestCase; use Symfony\UX\Map\Icon\Icon; -use Symfony\UX\Map\Icon\InlineSvg; -use Symfony\UX\Map\Icon\Url; +use Symfony\UX\Map\Icon\SvgIcon; +use Symfony\UX\Map\Icon\UrlIcon; use Symfony\UX\Map\Icon\UxIcon; class IconTest extends TestCase { - public function testIconConstruction(): void + public static function provideIcons(): iterable { - self::assertInstanceOf(Url::class, Icon::fromUrl(url: 'https://image.png')); - self::assertInstanceOf(InlineSvg::class, Icon::fromInlineSVG(html: '')); - self::assertInstanceOf(UxIcon::class, Icon::fromUxIcon(name: 'bi:heart')); + yield 'url' => [ + 'icon' => Icon::url('https://image.png')->width(12)->height(12), + 'expectedInstance' => UrlIcon::class, + 'expectedToArray' => ['type' => 'url', 'width' => 12, 'height' => 12, 'url' => 'https://image.png'], + ]; + yield 'svg' => [ + 'icon' => Icon::svg(''), + 'expectedInstance' => SvgIcon::class, + 'expectedToArray' => ['type' => 'svg', 'width' => 24, 'height' => 24, 'html' => ''], + ]; + yield 'ux' => [ + 'icon' => Icon::ux('bi:heart')->width(48)->height(48), + 'expectedInstance' => UxIcon::class, + 'expectedToArray' => ['type' => 'ux-icon', 'width' => 48, 'height' => 48, 'name' => 'bi:heart'], + ]; } - public function testToArray(): void + /** + * @dataProvider provideIcons + * + * @param class-string $expectedInstance + */ + public function testIconConstruction(Icon $icon, string $expectedInstance, array $expectedToArray): void { - $urlIcon = Icon::fromUrl(url: 'https://image.png'); - $array = $urlIcon->toArray(); - - self::assertSame([ - 'content' => 'https://image.png', - 'type' => 'url', - 'width' => 24, - 'height' => 24, - ], $array); + self::assertInstanceOf($expectedInstance, $icon); } - public function testFromArray(): void + /** + * @dataProvider provideIcons + */ + public function testToArray(Icon $icon, string $expectedInstance, array $expectedToArray): void { - $urlIcon = Icon::fromUrl(url: 'https://image.png'); - $array = $urlIcon->toArray(); + self::assertSame($expectedToArray, $icon->toArray()); + } - self::assertEquals( - $urlIcon, Icon::fromArray($array)); + /** + * @dataProvider provideIcons + */ + public function testFromArray(Icon $icon, string $expectedInstance, array $expectedToArray): void + { + self::assertEquals($icon, Icon::fromArray($expectedToArray)); } } diff --git a/src/Map/tests/MarkerTest.php b/src/Map/tests/MarkerTest.php index 0a2cb9a1cee..db1ae73bd2b 100644 --- a/src/Map/tests/MarkerTest.php +++ b/src/Map/tests/MarkerTest.php @@ -44,7 +44,7 @@ public function testToArray(): void content: "Capitale de la France, est une grande ville européenne et un centre mondial de l'art, de la mode, de la gastronomie et de la culture.", opened: true, ), - icon: Icon::fromUrl('https://example.com/image.png'), + icon: Icon::url('https://example.com/image.png'), ); $array = $marker->toArray(); @@ -61,10 +61,10 @@ public function testToArray(): void 'extra' => $array['infoWindow']['extra'], ], 'icon' => [ - 'content' => 'https://example.com/image.png', 'type' => 'url', 'width' => 24, 'height' => 24, + 'url' => 'https://example.com/image.png', ], 'extra' => $array['extra'], 'id' => null, From 99766926eae5c1caa1662803559af22375f5624f Mon Sep 17 00:00:00 2001 From: Hugo Alliaume Date: Mon, 17 Mar 2025 23:29:46 -0500 Subject: [PATCH 4/5] [Map] Rework SvgIcon and factory method name, UX Icon to get generated HTML, and icons rendering --- .../assets/dist/abstract_map_controller.d.ts | 17 +++++++--- .../assets/dist/abstract_map_controller.js | 2 +- src/Map/assets/src/abstract_map_controller.ts | 21 +++++++++--- src/Map/doc/index.rst | 15 ++++---- .../Google/assets/dist/map_controller.js | 20 +++++++---- .../Google/assets/src/map_controller.ts | 18 ++++++---- .../Leaflet/assets/dist/map_controller.js | 20 ++++++++--- .../Leaflet/assets/src/map_controller.ts | 17 +++++++--- src/Map/src/Icon/Icon.php | 26 +++++++++----- src/Map/src/Icon/SvgIcon.php | 26 +++++++++----- src/Map/src/Icon/UxIconRenderer.php | 6 +++- src/Map/src/Marker.php | 5 +-- src/Map/src/Renderer/AbstractRenderer.php | 11 +++--- src/Map/tests/IconTest.php | 34 +++++++++++++++++++ 14 files changed, 172 insertions(+), 66 deletions(-) diff --git a/src/Map/assets/dist/abstract_map_controller.d.ts b/src/Map/assets/dist/abstract_map_controller.d.ts index d9eac8ba821..9eb15e76cbd 100644 --- a/src/Map/assets/dist/abstract_map_controller.d.ts +++ b/src/Map/assets/dist/abstract_map_controller.d.ts @@ -9,16 +9,23 @@ export type WithIdentifier> = T & { }; export declare const IconTypes: { readonly Url: "url"; - readonly InlineSvg: "inline-svg"; + readonly Svg: "svg"; readonly UxIcon: "ux-icon"; }; -export type IconType = (typeof IconTypes)[keyof typeof IconTypes]; export type Icon = { - content: string; - type: IconType; width: number; height: number; -}; +} & ({ + type: typeof IconTypes.UxIcon; + name: string; + _generated_html: string; +} | { + type: typeof IconTypes.Url; + url: string; +} | { + type: typeof IconTypes.Svg; + html: string; +}); export type MarkerDefinition = WithIdentifier<{ position: Point; title: string | null; diff --git a/src/Map/assets/dist/abstract_map_controller.js b/src/Map/assets/dist/abstract_map_controller.js index b773df56af6..bd50b95f615 100644 --- a/src/Map/assets/dist/abstract_map_controller.js +++ b/src/Map/assets/dist/abstract_map_controller.js @@ -2,7 +2,7 @@ import { Controller } from '@hotwired/stimulus'; const IconTypes = { Url: 'url', - InlineSvg: 'inline-svg', + Svg: 'svg', UxIcon: 'ux-icon', }; class default_1 extends Controller { diff --git a/src/Map/assets/src/abstract_map_controller.ts b/src/Map/assets/src/abstract_map_controller.ts index ee2ec0207e5..1deb514b384 100644 --- a/src/Map/assets/src/abstract_map_controller.ts +++ b/src/Map/assets/src/abstract_map_controller.ts @@ -6,16 +6,27 @@ export type WithIdentifier> = T & { '@id': Ide export const IconTypes = { Url: 'url', - InlineSvg: 'inline-svg', + Svg: 'svg', UxIcon: 'ux-icon', } as const; -export type IconType = (typeof IconTypes)[keyof typeof IconTypes]; export type Icon = { - content: string; - type: IconType; width: number; height: number; -}; +} & ( + | { + type: typeof IconTypes.UxIcon; + name: string; + _generated_html: string, + } + | { + type: typeof IconTypes.Url; + url: string; + } + | { + type: typeof IconTypes.Svg; + html: string; + } + ); export type MarkerDefinition = WithIdentifier<{ position: Point; diff --git a/src/Map/doc/index.rst b/src/Map/doc/index.rst index c21d9b8a6f7..156b52a9e71 100644 --- a/src/Map/doc/index.rst +++ b/src/Map/doc/index.rst @@ -141,15 +141,12 @@ Add Marker icons A ``Marker`` can be customized with an ``Icon`` instance, which can either be an UX Icon, an URL, or a SVG content:: - // It can be a UX Icon (requires `symfony/ux-icons` package) - $icon = Icon::ux('fa:map-marker'); - // Or an URL pointing to an image - $icon = Icon::url('https://example.com/marker.png'); - // Or a plain SVG content - $icon = Icon::svg('(...)'); - - // Configure the icon size with the `width()` and `height()` methods - $icon = Icon::ux('fa:map-marker')->width(48)->height(48); + // It can be a UX Icon (requires `symfony/ux-icons` package)... + $icon = Icon::ux('fa:map-marker')->width(24)->height(24); + // ... or an URL pointing to an image + $icon = Icon::url('https://example.com/marker.png')->width(24)->height(24); + // ... or a plain SVG string + $icon = Icon::svg('...'); $map->addMarker(new Marker( // ... diff --git a/src/Map/src/Bridge/Google/assets/dist/map_controller.js b/src/Map/src/Bridge/Google/assets/dist/map_controller.js index 3e73e8d9895..7dad239758c 100644 --- a/src/Map/src/Bridge/Google/assets/dist/map_controller.js +++ b/src/Map/src/Bridge/Google/assets/dist/map_controller.js @@ -3,7 +3,7 @@ import { Controller } from '@hotwired/stimulus'; const IconTypes = { Url: 'url', - InlineSvg: 'inline-svg', + Svg: 'svg', UxIcon: 'ux-icon', }; class default_1 extends Controller { @@ -109,6 +109,7 @@ default_1.values = { }; let _google; +const parser = new DOMParser(); class map_controller extends default_1 { async connect() { if (!_google) { @@ -282,18 +283,23 @@ class map_controller extends default_1 { return content; } doCreateIcon({ definition, element, }) { - const { content, type, width, height } = definition; - if (type === IconTypes.InlineSvg) { - const icon = this.parser.parseFromString(content, 'image/svg+xml').documentElement; - element.content = icon; + const { type, width, height } = definition; + if (type === IconTypes.Svg) { + element.content = parser.parseFromString(definition.html, 'image/svg+xml').documentElement; } - else { + else if (type === IconTypes.UxIcon) { + element.content = parser.parseFromString(definition._generated_html, 'image/svg+xml').documentElement; + } + else if (type === IconTypes.Url) { const icon = document.createElement('img'); icon.width = width; icon.height = height; - icon.src = content; + icon.src = definition.url; element.content = icon; } + else { + throw new Error(`Unsupported icon type: ${type}.`); + } } closeInfoWindowsExcept(infoWindow) { this.infoWindows.forEach((otherInfoWindow) => { diff --git a/src/Map/src/Bridge/Google/assets/src/map_controller.ts b/src/Map/src/Bridge/Google/assets/src/map_controller.ts index f9446a56d63..db073bc7732 100644 --- a/src/Map/src/Bridge/Google/assets/src/map_controller.ts +++ b/src/Map/src/Bridge/Google/assets/src/map_controller.ts @@ -37,6 +37,8 @@ type MapOptions = Pick< let _google: typeof google; +const parser = new DOMParser(); + export default class extends AbstractMapController< MapOptions, google.maps.Map, @@ -312,16 +314,20 @@ export default class extends AbstractMapController< definition: Icon; element: google.maps.marker.AdvancedMarkerElement; }): void { - const { content, type, width, height } = definition; - if (type === IconTypes.InlineSvg) { - const icon = this.parser.parseFromString(content, 'image/svg+xml').documentElement; - element.content = icon; - } else { + const { type, width, height } = definition; + + if (type === IconTypes.Svg) { + element.content = parser.parseFromString(definition.html, 'image/svg+xml').documentElement; + } else if (type === IconTypes.UxIcon) { + element.content = parser.parseFromString(definition._generated_html, 'image/svg+xml').documentElement; + } else if (type === IconTypes.Url) { const icon = document.createElement('img'); icon.width = width; icon.height = height; - icon.src = content; + icon.src = definition.url; element.content = icon; + } else { + throw new Error(`Unsupported icon type: ${type}.`); } } diff --git a/src/Map/src/Bridge/Leaflet/assets/dist/map_controller.js b/src/Map/src/Bridge/Leaflet/assets/dist/map_controller.js index b4801d07542..f1f25afe959 100644 --- a/src/Map/src/Bridge/Leaflet/assets/dist/map_controller.js +++ b/src/Map/src/Bridge/Leaflet/assets/dist/map_controller.js @@ -4,7 +4,7 @@ import * as L from 'leaflet'; const IconTypes = { Url: 'url', - InlineSvg: 'inline-svg', + Svg: 'svg', UxIcon: 'ux-icon', }; class default_1 extends Controller { @@ -206,10 +206,20 @@ class map_controller extends default_1 { return popup; } doCreateIcon({ definition, element, }) { - const { content, type, width, height } = definition; - const icon = type === IconTypes.InlineSvg - ? L.divIcon({ html: content, iconSize: [width, height] }) - : L.icon({ iconUrl: content, iconSize: [width, height] }); + const { type, width, height } = definition; + let icon; + if (type === IconTypes.Svg) { + icon = L.divIcon({ html: definition.html, iconSize: [width, height], className: '' }); + } + else if (type === IconTypes.UxIcon) { + icon = L.divIcon({ html: definition._generated_html, iconSize: [width, height], className: '' }); + } + else if (type === IconTypes.Url) { + icon = L.icon({ iconUrl: definition.url, iconSize: [width, height], className: '' }); + } + else { + throw new Error(`Unsupported icon type: ${type}.`); + } element.setIcon(icon); } doFitBoundsToMarkers() { diff --git a/src/Map/src/Bridge/Leaflet/assets/src/map_controller.ts b/src/Map/src/Bridge/Leaflet/assets/src/map_controller.ts index 90e5c419554..fbec1e80cb0 100644 --- a/src/Map/src/Bridge/Leaflet/assets/src/map_controller.ts +++ b/src/Map/src/Bridge/Leaflet/assets/src/map_controller.ts @@ -185,11 +185,18 @@ export default class extends AbstractMapController< definition: Icon; element: L.Marker; }): void { - const { content, type, width, height } = definition; - const icon = - type === IconTypes.InlineSvg - ? L.divIcon({ html: content, iconSize: [width, height] }) - : L.icon({ iconUrl: content, iconSize: [width, height] }); + const { type, width, height } = definition; + + let icon: L.DivIcon | L.Icon; + if (type === IconTypes.Svg) { + icon = L.divIcon({ html: definition.html, iconSize: [width, height], className: '' }); + } else if (type === IconTypes.UxIcon) { + icon = L.divIcon({ html: definition._generated_html, iconSize: [width, height], className: '' }); + } else if (type === IconTypes.Url) { + icon = L.icon({ iconUrl: definition.url, iconSize: [width, height], className: '' }); + } else { + throw new Error(`Unsupported icon type: ${type}.`); + } element.setIcon(icon); } diff --git a/src/Map/src/Icon/Icon.php b/src/Map/src/Icon/Icon.php index aed5db4dd36..d097952de23 100644 --- a/src/Map/src/Icon/Icon.php +++ b/src/Map/src/Icon/Icon.php @@ -22,6 +22,8 @@ abstract class Icon { /** + * Creates a new icon based on a URL (e.g.: `https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/icons/geo-alt.svg`). + * * @param non-empty-string $url */ public static function url(string $url): UrlIcon @@ -30,6 +32,10 @@ public static function url(string $url): UrlIcon } /** + * Creates a new icon based on an SVG string (e.g.: `...`). + * Using an SVG string may not be the best option if you want to customize the icon afterward, + * it would be preferable to use {@see Icon::ux()} or {@see Icon::url()} instead. + * * @param non-empty-string $html */ public static function svg(string $html): SvgIcon @@ -38,6 +44,8 @@ public static function svg(string $html): SvgIcon } /** + * Creates a new icon based on a UX icon name (e.g.: `fa:map-marker`). + * * @param non-empty-string $name */ public static function ux(string $name): UxIcon @@ -56,23 +64,25 @@ protected function __construct( ) { } + /** + * Sets the width of the icon. + * + * @param positive-int $width + */ public function width(int $width): static { - if ($width <= 0) { - throw new InvalidArgumentException('Width must be greater than 0.'); - } - $this->width = $width; return $this; } + /** + * Sets the height of the icon. + * + * @param positive-int $height + */ public function height(int $height): static { - if ($height <= 0) { - throw new InvalidArgumentException('Height must be greater than 0.'); - } - $this->height = $height; return $this; diff --git a/src/Map/src/Icon/SvgIcon.php b/src/Map/src/Icon/SvgIcon.php index 181d3a753ca..fc59f0c8184 100644 --- a/src/Map/src/Icon/SvgIcon.php +++ b/src/Map/src/Icon/SvgIcon.php @@ -23,29 +23,39 @@ class SvgIcon extends Icon { /** * @param non-empty-string $html - * @param positive-int $width - * @param positive-int $height */ protected function __construct( protected string $html, - int $width = 24, - int $height = 24, ) { - parent::__construct(IconType::Svg, $width, $height); + parent::__construct(IconType::Svg); } /** - * @param array{ html: string, width: positive-int, height: positive-int } $data + * @param array{ html: string } $data */ public static function fromArray(array $data): static { return new self( html: $data['html'], - width: $data['width'], - height: $data['height'], ); } + /** + * @throws \LogicException the SvgIcon can not be customized + */ + public function width(int $width): never + { + throw new \LogicException('Unable to configure the SvgIcon width, please configure it in the HTML with the "width" attribute on the root element instead.'); + } + + /** + * @throws \LogicException the SvgIcon can not be customized + */ + public function height(int $height): never + { + throw new \LogicException('Unable to configure the SvgIcon height, please configure it in the HTML with the "height" attribute on the root element instead.'); + } + public function toArray(): array { return [ diff --git a/src/Map/src/Icon/UxIconRenderer.php b/src/Map/src/Icon/UxIconRenderer.php index ff7e1b6c649..217cc3f224b 100644 --- a/src/Map/src/Icon/UxIconRenderer.php +++ b/src/Map/src/Icon/UxIconRenderer.php @@ -25,7 +25,10 @@ public function __construct( ) { } - public function render(string $name): string + /** + * @param array $attributes + */ + public function render(string $name, array $attributes = []): string { if (null === $this->renderer) { throw new \LogicException('You cannot use an UX Icon as the "UX Icons" package is not installed. Try running "composer require symfony/ux-icons" to install it.'); @@ -33,6 +36,7 @@ public function render(string $name): string return $this->renderer->renderIcon($name, [ 'xmlns' => 'http://www.w3.org/2000/svg', + ...$attributes, ]); } } diff --git a/src/Map/src/Marker.php b/src/Map/src/Marker.php index cdad5b18b7d..922bd06d706 100644 --- a/src/Map/src/Marker.php +++ b/src/Map/src/Marker.php @@ -13,6 +13,7 @@ use Symfony\UX\Map\Exception\InvalidArgumentException; use Symfony\UX\Map\Icon\Icon; +use Symfony\UX\Map\Icon\IconType; /** * Represents a marker on a map. @@ -40,7 +41,7 @@ public function __construct( * position: array{lat: float, lng: float}, * title: string|null, * infoWindow: array|null, - * icon: Icon|null, + * icon: array{type: value-of, width: positive-int, height: positive-int, ...}|null, * extra: array, * id: string|null * } @@ -62,7 +63,7 @@ public function toArray(): array * position: array{lat: float, lng: float}, * title: string|null, * infoWindow: array|null, - * icon: array||null, + * icon: array{type: value-of, width: positive-int, height: positive-int, ...}|null, * extra: array, * id: string|null * } $marker diff --git a/src/Map/src/Renderer/AbstractRenderer.php b/src/Map/src/Renderer/AbstractRenderer.php index 4cf49c53039..038d57f58e8 100644 --- a/src/Map/src/Renderer/AbstractRenderer.php +++ b/src/Map/src/Renderer/AbstractRenderer.php @@ -11,6 +11,7 @@ namespace Symfony\UX\Map\Renderer; +use Symfony\UX\Map\Icon\IconType; use Symfony\UX\Map\Icon\UxIconRenderer; use Symfony\UX\Map\Map; use Symfony\UX\Map\MapOptionsInterface; @@ -23,7 +24,7 @@ { public function __construct( private StimulusHelper $stimulus, - private ?UxIconRenderer $uxIconRenderer = null, + private UxIconRenderer $uxIconRenderer, ) { } @@ -92,9 +93,11 @@ private function getMapAttributes(Map $map): array foreach ($attrs['markers'] as $key => $marker) { $attrs['markers'][$key]['@id'] = $computeId($marker); - if (null !== $this->uxIconRenderer && null !== $marker['icon'] && 'ux-icon' === $marker['icon']['type']) { - $attrs['markers'][$key]['icon']['content'] = $this->uxIconRenderer->render($marker['icon']['content']); - $attrs['markers'][$key]['icon']['type'] = 'inline-svg'; + if (isset($marker['icon']['type']) && IconType::UxIcon->value === $marker['icon']['type']) { + $attrs['markers'][$key]['icon']['_generated_html'] = $this->uxIconRenderer->render($marker['icon']['name'], [ + 'width' => $marker['icon']['width'], + 'height' => $marker['icon']['height'], + ]); } } foreach ($attrs['polygons'] as $key => $polygon) { diff --git a/src/Map/tests/IconTest.php b/src/Map/tests/IconTest.php index 02860c4d987..35ddeb943fb 100644 --- a/src/Map/tests/IconTest.php +++ b/src/Map/tests/IconTest.php @@ -63,4 +63,38 @@ public function testFromArray(Icon $icon, string $expectedInstance, array $expec { self::assertEquals($icon, Icon::fromArray($expectedToArray)); } + + public static function dataProviderForTestSvgIconCustomizationMethodsCanNotBeCalled(): iterable + { + $refl = new \ReflectionClass(SvgIcon::class); + $customizationMethods = array_diff( + array_map( + fn (\ReflectionMethod $method) => $method->name, + array_filter($refl->getMethods(\ReflectionMethod::IS_PUBLIC), fn (\ReflectionMethod $method) => SvgIcon::class === $method->getDeclaringClass()->getName()) + ), + ['toArray', 'fromArray'] + ); + + foreach ($customizationMethods as $method) { + if (\in_array($method, ['width', 'height'], true)) { + yield $method => [$method, 12]; + } elseif (\in_array($method, $customizationMethods, true)) { + throw new \LogicException(\sprintf('The "%s" method is not supposed to be called on the SvgIcon, please modify the test provider.', $method)); + } + } + } + + /** + * @dataProvider dataProviderForTestSvgIconCustomizationMethodsCanNotBeCalled + */ + public function testSvgIconCustomizationMethodsCanNotBeCalled(string $method, mixed ...$args): void + { + $this->expectException(\LogicException::class); + if (\in_array($method, ['width', 'height'], true)) { + $this->expectExceptionMessage(\sprintf('Unable to configure the SvgIcon %s, please configure it in the HTML with the "%s" attribute on the root element instead.', $method, $method)); + } + + $icon = Icon::svg(''); + $icon->{$method}(...$args); + } } From c4f982840fa70000f96d8932003424201c330262 Mon Sep 17 00:00:00 2001 From: Hugo Alliaume Date: Tue, 18 Mar 2025 09:02:19 -0500 Subject: [PATCH 5/5] [Map] Rework UxIconRenderer injection --- src/Map/assets/src/abstract_map_controller.ts | 4 +- src/Map/composer.json | 3 +- src/Map/config/services.php | 2 +- src/Map/src/Bridge/Google/composer.json | 3 +- .../Google/src/Renderer/GoogleRenderer.php | 2 +- .../src/Renderer/GoogleRendererFactory.php | 6 +- .../tests/GoogleRendererFactoryTest.php | 3 +- .../Google/tests/GoogleRendererTest.php | 60 +++++++++++++------ .../Leaflet/assets/dist/map_controller.js | 18 +++++- .../Leaflet/assets/src/map_controller.ts | 20 +++++-- src/Map/src/Bridge/Leaflet/composer.json | 3 +- .../tests/LeafletRendererFactoryTest.php | 3 +- .../Leaflet/tests/LeafletRendererTest.php | 39 +++++++++--- src/Map/src/Icon/UxIconRenderer.php | 2 +- src/Map/src/Renderer/AbstractRenderer.php | 2 +- .../src/Renderer/AbstractRendererFactory.php | 2 +- 16 files changed, 123 insertions(+), 49 deletions(-) diff --git a/src/Map/assets/src/abstract_map_controller.ts b/src/Map/assets/src/abstract_map_controller.ts index 1deb514b384..db5dd7e5f0c 100644 --- a/src/Map/assets/src/abstract_map_controller.ts +++ b/src/Map/assets/src/abstract_map_controller.ts @@ -16,7 +16,7 @@ export type Icon = { | { type: typeof IconTypes.UxIcon; name: string; - _generated_html: string, + _generated_html: string; } | { type: typeof IconTypes.Url; @@ -26,7 +26,7 @@ export type Icon = { type: typeof IconTypes.Svg; html: string; } - ); +); export type MarkerDefinition = WithIdentifier<{ position: Point; diff --git a/src/Map/composer.json b/src/Map/composer.json index 52adf9bc983..509983e26f6 100644 --- a/src/Map/composer.json +++ b/src/Map/composer.json @@ -40,7 +40,8 @@ "symfony/framework-bundle": "^6.4|^7.0", "symfony/phpunit-bridge": "^6.4|^7.0", "symfony/twig-bundle": "^6.4|^7.0", - "symfony/ux-twig-component": "^2.18" + "symfony/ux-twig-component": "^2.18", + "symfony/ux-icons": "^2.18" }, "conflict": { "symfony/ux-twig-component": "<2.21" diff --git a/src/Map/config/services.php b/src/Map/config/services.php index 9514c33cd58..41189f35cc5 100644 --- a/src/Map/config/services.php +++ b/src/Map/config/services.php @@ -39,7 +39,7 @@ ->abstract() ->args([ service('stimulus.helper'), - service('.ux_map.ux_icons.renderer')->nullOnInvalid(), + service('.ux_map.ux_icons.renderer'), ]) ->set('ux_map.renderer_factory', Renderer::class) diff --git a/src/Map/src/Bridge/Google/composer.json b/src/Map/src/Bridge/Google/composer.json index f7355c95c16..9ae81496e79 100644 --- a/src/Map/src/Bridge/Google/composer.json +++ b/src/Map/src/Bridge/Google/composer.json @@ -21,7 +21,8 @@ "symfony/ux-map": "^2.19" }, "require-dev": { - "symfony/phpunit-bridge": "^6.4|^7.0" + "symfony/phpunit-bridge": "^6.4|^7.0", + "symfony/ux-icons": "^2.18" }, "autoload": { "psr-4": { "Symfony\\UX\\Map\\Bridge\\Google\\": "src/" }, diff --git a/src/Map/src/Bridge/Google/src/Renderer/GoogleRenderer.php b/src/Map/src/Bridge/Google/src/Renderer/GoogleRenderer.php index b2b4de32d08..1077e26a466 100644 --- a/src/Map/src/Bridge/Google/src/Renderer/GoogleRenderer.php +++ b/src/Map/src/Bridge/Google/src/Renderer/GoogleRenderer.php @@ -29,6 +29,7 @@ */ public function __construct( StimulusHelper $stimulusHelper, + UxIconRenderer $uxIconRenderer, #[\SensitiveParameter] private string $apiKey, private ?string $id = null, @@ -43,7 +44,6 @@ public function __construct( */ private array $libraries = [], private ?string $defaultMapId = null, - ?UxIconRenderer $uxIconRenderer = null, ) { parent::__construct($stimulusHelper, $uxIconRenderer); } diff --git a/src/Map/src/Bridge/Google/src/Renderer/GoogleRendererFactory.php b/src/Map/src/Bridge/Google/src/Renderer/GoogleRendererFactory.php index b8ec89ace0e..29f81edc3d0 100644 --- a/src/Map/src/Bridge/Google/src/Renderer/GoogleRendererFactory.php +++ b/src/Map/src/Bridge/Google/src/Renderer/GoogleRendererFactory.php @@ -27,7 +27,7 @@ final class GoogleRendererFactory extends AbstractRendererFactory implements Ren { public function __construct( StimulusHelper $stimulus, - ?UxIconRenderer $uxIconRenderer = null, + UxIconRenderer $uxIconRenderer, private ?string $defaultMapId = null, ) { parent::__construct($stimulus, $uxIconRenderer); @@ -43,7 +43,8 @@ public function create(Dsn $dsn): RendererInterface return new GoogleRenderer( $this->stimulus, - $apiKey, + $this->uxIconRenderer, + apiKey: $apiKey, id: $dsn->getOption('id'), language: $dsn->getOption('language'), region: $dsn->getOption('region'), @@ -53,7 +54,6 @@ public function create(Dsn $dsn): RendererInterface version: $dsn->getOption('version', 'weekly'), libraries: ['maps', 'marker', ...$dsn->getOption('libraries', [])], defaultMapId: $this->defaultMapId, - uxIconRenderer: $this->uxIconRenderer, ); } diff --git a/src/Map/src/Bridge/Google/tests/GoogleRendererFactoryTest.php b/src/Map/src/Bridge/Google/tests/GoogleRendererFactoryTest.php index eac705cfd7c..424b3a2943e 100644 --- a/src/Map/src/Bridge/Google/tests/GoogleRendererFactoryTest.php +++ b/src/Map/src/Bridge/Google/tests/GoogleRendererFactoryTest.php @@ -12,6 +12,7 @@ namespace Symfony\UX\Map\Bridge\Google\Tests; use Symfony\UX\Map\Bridge\Google\Renderer\GoogleRendererFactory; +use Symfony\UX\Map\Icon\UxIconRenderer; use Symfony\UX\Map\Renderer\RendererFactoryInterface; use Symfony\UX\Map\Test\RendererFactoryTestCase; use Symfony\UX\StimulusBundle\Helper\StimulusHelper; @@ -20,7 +21,7 @@ final class GoogleRendererFactoryTest extends RendererFactoryTestCase { public function createRendererFactory(): RendererFactoryInterface { - return new GoogleRendererFactory(new StimulusHelper(null)); + return new GoogleRendererFactory(new StimulusHelper(null), new UxIconRenderer(null)); } public static function supportsRenderer(): iterable diff --git a/src/Map/src/Bridge/Google/tests/GoogleRendererTest.php b/src/Map/src/Bridge/Google/tests/GoogleRendererTest.php index c8a0baeedec..dcb35dae2ce 100644 --- a/src/Map/src/Bridge/Google/tests/GoogleRendererTest.php +++ b/src/Map/src/Bridge/Google/tests/GoogleRendererTest.php @@ -11,9 +11,11 @@ namespace Symfony\UX\Map\Bridge\Google\Tests; +use Symfony\UX\Icons\IconRendererInterface; use Symfony\UX\Map\Bridge\Google\GoogleOptions; use Symfony\UX\Map\Bridge\Google\Renderer\GoogleRenderer; use Symfony\UX\Map\Icon\Icon; +use Symfony\UX\Map\Icon\UxIconRenderer; use Symfony\UX\Map\InfoWindow; use Symfony\UX\Map\Map; use Symfony\UX\Map\Marker; @@ -31,31 +33,31 @@ public function provideTestRenderMap(): iterable ->center(new Point(48.8566, 2.3522)) ->zoom(12); $marker1 = new Marker(position: new Point(48.8566, 2.3522), title: 'Paris', id: 'marker1'); - $marker2 = new Marker(position: new Point(48.8566, 2.3522), title: 'Lyon', infoWindow: new InfoWindow(content: 'Lyon'), id: 'marker2', icon: Icon::svg(html: '')); + $marker2 = new Marker(position: new Point(48.8566, 2.3522), title: 'Lyon', infoWindow: new InfoWindow(content: 'Lyon'), id: 'marker2'); $marker3 = new Marker(position: new Point(45.8566, 2.3522), title: 'Dijon', id: 'marker3'); yield 'simple map, with minimum options' => [ 'expected_render' => '
', - 'renderer' => new GoogleRenderer(new StimulusHelper(null), apiKey: 'api_key'), + 'renderer' => new GoogleRenderer(new StimulusHelper(null), new UxIconRenderer(null), apiKey: 'api_key'), 'map' => $map, ]; yield 'with every options' => [ 'expected_render' => '
', - 'renderer' => new GoogleRenderer(new StimulusHelper(null), apiKey: 'api_key', id: 'gmap', language: 'fr', region: 'FR', nonce: 'abcd', retries: 10, url: 'https://maps.googleapis.com/maps/api/js', version: 'quarterly'), + 'renderer' => new GoogleRenderer(new StimulusHelper(null), new UxIconRenderer(null), apiKey: 'api_key', id: 'gmap', language: 'fr', region: 'FR', nonce: 'abcd', retries: 10, url: 'https://maps.googleapis.com/maps/api/js', version: 'quarterly'), 'map' => $map, ]; yield 'with custom attributes' => [ 'expected_render' => '
', - 'renderer' => new GoogleRenderer(new StimulusHelper(null), apiKey: 'api_key'), + 'renderer' => new GoogleRenderer(new StimulusHelper(null), new UxIconRenderer(null), apiKey: 'api_key'), 'map' => $map, 'attributes' => ['data-controller' => 'my-custom-controller', 'class' => 'map'], ]; yield 'with markers and infoWindows' => [ 'expected_render' => '
', - 'renderer' => new GoogleRenderer(new StimulusHelper(null), apiKey: 'api_key'), + 'renderer' => new GoogleRenderer(new StimulusHelper(null), new UxIconRenderer(null), apiKey: 'api_key'), 'map' => (new Map()) ->center(new Point(48.8566, 2.3522)) ->zoom(12) @@ -65,7 +67,7 @@ public function provideTestRenderMap(): iterable yield 'with all markers removed' => [ 'expected_render' => '
', - 'renderer' => new GoogleRenderer(new StimulusHelper(null), apiKey: 'api_key'), + 'renderer' => new GoogleRenderer(new StimulusHelper(null), new UxIconRenderer(null), apiKey: 'api_key'), 'map' => (new Map()) ->center(new Point(48.8566, 2.3522)) ->zoom(12) @@ -76,8 +78,8 @@ public function provideTestRenderMap(): iterable ]; yield 'with marker remove and new ones added' => [ - 'expected_render' => '
', - 'renderer' => new GoogleRenderer(new StimulusHelper(null), apiKey: 'api_key'), + 'expected_render' => '
', + 'renderer' => new GoogleRenderer(new StimulusHelper(null), new UxIconRenderer(null), apiKey: 'api_key'), 'map' => (new Map()) ->center(new Point(48.8566, 2.3522)) ->zoom(12) @@ -89,7 +91,7 @@ public function provideTestRenderMap(): iterable yield 'with polygons and infoWindows' => [ 'expected_render' => '
', - 'renderer' => new GoogleRenderer(new StimulusHelper(null), apiKey: 'api_key'), + 'renderer' => new GoogleRenderer(new StimulusHelper(null), new UxIconRenderer(null), apiKey: 'api_key'), 'map' => (new Map()) ->center(new Point(48.8566, 2.3522)) ->zoom(12) @@ -99,7 +101,7 @@ public function provideTestRenderMap(): iterable yield 'with polylines and infoWindows' => [ 'expected_render' => '
', - 'renderer' => new GoogleRenderer(new StimulusHelper(null), apiKey: 'api_key'), + 'renderer' => new GoogleRenderer(new StimulusHelper(null), new UxIconRenderer(null), apiKey: 'api_key'), 'map' => (new Map()) ->center(new Point(48.8566, 2.3522)) ->zoom(12) @@ -109,7 +111,7 @@ public function provideTestRenderMap(): iterable yield 'with controls enabled' => [ 'expected_render' => '
', - 'renderer' => new GoogleRenderer(new StimulusHelper(null), apiKey: 'api_key'), + 'renderer' => new GoogleRenderer(new StimulusHelper(null), new UxIconRenderer(null), apiKey: 'api_key'), 'map' => (new Map()) ->center(new Point(48.8566, 2.3522)) ->zoom(12) @@ -123,7 +125,7 @@ public function provideTestRenderMap(): iterable yield 'without controls enabled' => [ 'expected_render' => '
', - 'renderer' => new GoogleRenderer(new StimulusHelper(null), apiKey: 'api_key'), + 'renderer' => new GoogleRenderer(new StimulusHelper(null), new UxIconRenderer(null), apiKey: 'api_key'), 'map' => (new Map()) ->center(new Point(48.8566, 2.3522)) ->zoom(12) @@ -136,27 +138,49 @@ public function provideTestRenderMap(): iterable ]; yield 'with default map id' => [ - 'expected_renderer' => '
', - 'renderer' => new GoogleRenderer(new StimulusHelper(null), 'my_api_key', defaultMapId: 'DefaultMapId'), + 'expected_render' => '
', + 'renderer' => new GoogleRenderer(new StimulusHelper(null), new UxIconRenderer(null), 'my_api_key', defaultMapId: 'DefaultMapId'), 'map' => (new Map()) ->center(new Point(48.8566, 2.3522)) ->zoom(12), ]; + yield 'with default map id, when passing options (except the "mapId")' => [ - 'expected_renderer' => '
', - 'renderer' => new GoogleRenderer(new StimulusHelper(null), 'my_api_key', defaultMapId: 'DefaultMapId'), + 'expected_render' => '
', + 'renderer' => new GoogleRenderer(new StimulusHelper(null), new UxIconRenderer(null), 'my_api_key', defaultMapId: 'DefaultMapId'), 'map' => (new Map()) ->center(new Point(48.8566, 2.3522)) ->zoom(12) ->options(new GoogleOptions()), ]; + yield 'with default map id overridden by option "mapId"' => [ - 'expected_renderer' => '
', - 'renderer' => new GoogleRenderer(new StimulusHelper(null), 'my_api_key', defaultMapId: 'DefaultMapId'), + 'expected_render' => '
', + 'renderer' => new GoogleRenderer(new StimulusHelper(null), new UxIconRenderer(null), 'my_api_key', defaultMapId: 'DefaultMapId'), 'map' => (new Map()) ->center(new Point(48.8566, 2.3522)) ->zoom(12) ->options(new GoogleOptions(mapId: 'CustomMapId')), ]; + + yield 'markers with icons' => [ + 'expected_render' => '
', + 'renderer' => new GoogleRenderer( + new StimulusHelper(null), + new UxIconRenderer(new class implements IconRendererInterface { + public function renderIcon(string $name, array $attributes = []): string + { + return '...'; + } + }), + 'my_api_key' + ), + 'map' => (new Map()) + ->center(new Point(48.8566, 2.3522)) + ->zoom(12) + ->addMarker(new Marker(position: new Point(48.8566, 2.3522), title: 'Paris', icon: Icon::url('https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/icons/geo-alt.svg')->width(32)->height(32))) + ->addMarker(new Marker(position: new Point(45.7640, 4.8357), title: 'Lyon', icon: Icon::ux('fa:map-marker')->width(32)->height(32))) + ->addMarker(new Marker(position: new Point(45.8566, 2.3522), title: 'Dijon', icon: Icon::svg('...'))), + ]; } } diff --git a/src/Map/src/Bridge/Leaflet/assets/dist/map_controller.js b/src/Map/src/Bridge/Leaflet/assets/dist/map_controller.js index f1f25afe959..636c99f3c4f 100644 --- a/src/Map/src/Bridge/Leaflet/assets/dist/map_controller.js +++ b/src/Map/src/Bridge/Leaflet/assets/dist/map_controller.js @@ -209,13 +209,25 @@ class map_controller extends default_1 { const { type, width, height } = definition; let icon; if (type === IconTypes.Svg) { - icon = L.divIcon({ html: definition.html, iconSize: [width, height], className: '' }); + icon = L.divIcon({ + html: definition.html, + iconSize: [width, height], + className: '', + }); } else if (type === IconTypes.UxIcon) { - icon = L.divIcon({ html: definition._generated_html, iconSize: [width, height], className: '' }); + icon = L.divIcon({ + html: definition._generated_html, + iconSize: [width, height], + className: '', + }); } else if (type === IconTypes.Url) { - icon = L.icon({ iconUrl: definition.url, iconSize: [width, height], className: '' }); + icon = L.icon({ + iconUrl: definition.url, + iconSize: [width, height], + className: '', + }); } else { throw new Error(`Unsupported icon type: ${type}.`); diff --git a/src/Map/src/Bridge/Leaflet/assets/src/map_controller.ts b/src/Map/src/Bridge/Leaflet/assets/src/map_controller.ts index fbec1e80cb0..53333592a58 100644 --- a/src/Map/src/Bridge/Leaflet/assets/src/map_controller.ts +++ b/src/Map/src/Bridge/Leaflet/assets/src/map_controller.ts @@ -42,7 +42,7 @@ export default class extends AbstractMapController< iconSize: [25, 41], iconAnchor: [12.5, 41], popupAnchor: [0, -41], - className: '', + className: '', // Adding an empty class to the icon to avoid the default Leaflet styles }); super.connect(); @@ -189,11 +189,23 @@ export default class extends AbstractMapController< let icon: L.DivIcon | L.Icon; if (type === IconTypes.Svg) { - icon = L.divIcon({ html: definition.html, iconSize: [width, height], className: '' }); + icon = L.divIcon({ + html: definition.html, + iconSize: [width, height], + className: '', // Adding an empty class to the icon to avoid the default Leaflet styles + }); } else if (type === IconTypes.UxIcon) { - icon = L.divIcon({ html: definition._generated_html, iconSize: [width, height], className: '' }); + icon = L.divIcon({ + html: definition._generated_html, + iconSize: [width, height], + className: '', // Adding an empty class to the icon to avoid the default Leaflet styles + }); } else if (type === IconTypes.Url) { - icon = L.icon({ iconUrl: definition.url, iconSize: [width, height], className: '' }); + icon = L.icon({ + iconUrl: definition.url, + iconSize: [width, height], + className: '', // Adding an empty class to the icon to avoid the default Leaflet styles + }); } else { throw new Error(`Unsupported icon type: ${type}.`); } diff --git a/src/Map/src/Bridge/Leaflet/composer.json b/src/Map/src/Bridge/Leaflet/composer.json index 74a166e64b5..32fc6619d63 100644 --- a/src/Map/src/Bridge/Leaflet/composer.json +++ b/src/Map/src/Bridge/Leaflet/composer.json @@ -21,7 +21,8 @@ "symfony/ux-map": "^2.19" }, "require-dev": { - "symfony/phpunit-bridge": "^6.4|^7.0" + "symfony/phpunit-bridge": "^6.4|^7.0", + "symfony/ux-icons": "^2.18" }, "autoload": { "psr-4": { "Symfony\\UX\\Map\\Bridge\\Leaflet\\": "src/" }, diff --git a/src/Map/src/Bridge/Leaflet/tests/LeafletRendererFactoryTest.php b/src/Map/src/Bridge/Leaflet/tests/LeafletRendererFactoryTest.php index 7ead676cd7f..8fbfdedf115 100644 --- a/src/Map/src/Bridge/Leaflet/tests/LeafletRendererFactoryTest.php +++ b/src/Map/src/Bridge/Leaflet/tests/LeafletRendererFactoryTest.php @@ -12,6 +12,7 @@ namespace Symfony\UX\Map\Bridge\Leaflet\Tests; use Symfony\UX\Map\Bridge\Leaflet\Renderer\LeafletRendererFactory; +use Symfony\UX\Map\Icon\UxIconRenderer; use Symfony\UX\Map\Renderer\RendererFactoryInterface; use Symfony\UX\Map\Test\RendererFactoryTestCase; use Symfony\UX\StimulusBundle\Helper\StimulusHelper; @@ -20,7 +21,7 @@ final class LeafletRendererFactoryTest extends RendererFactoryTestCase { public function createRendererFactory(): RendererFactoryInterface { - return new LeafletRendererFactory(new StimulusHelper(null)); + return new LeafletRendererFactory(new StimulusHelper(null), new UxIconRenderer(null)); } public static function supportsRenderer(): iterable diff --git a/src/Map/src/Bridge/Leaflet/tests/LeafletRendererTest.php b/src/Map/src/Bridge/Leaflet/tests/LeafletRendererTest.php index d07f04f6cc0..47e5da7cbe2 100644 --- a/src/Map/src/Bridge/Leaflet/tests/LeafletRendererTest.php +++ b/src/Map/src/Bridge/Leaflet/tests/LeafletRendererTest.php @@ -11,8 +11,10 @@ namespace Symfony\UX\Map\Bridge\Leaflet\Tests; +use Symfony\UX\Icons\IconRendererInterface; use Symfony\UX\Map\Bridge\Leaflet\Renderer\LeafletRenderer; use Symfony\UX\Map\Icon\Icon; +use Symfony\UX\Map\Icon\UxIconRenderer; use Symfony\UX\Map\InfoWindow; use Symfony\UX\Map\Map; use Symfony\UX\Map\Marker; @@ -31,24 +33,25 @@ public function provideTestRenderMap(): iterable ->zoom(12); $marker1 = new Marker(position: new Point(48.8566, 2.3522), title: 'Paris', id: 'marker1'); - $marker2 = new Marker(position: new Point(48.8566, 2.3522), title: 'Lyon', infoWindow: new InfoWindow(content: 'Lyon'), id: 'marker2', icon: Icon::svg(html: '')); + $marker2 = new Marker(position: new Point(48.8566, 2.3522), title: 'Lyon', infoWindow: new InfoWindow(content: 'Lyon'), id: 'marker2'); $marker3 = new Marker(position: new Point(45.8566, 2.3522), title: 'Dijon', id: 'marker3'); yield 'simple map' => [ 'expected_render' => '
', - 'renderer' => new LeafletRenderer(new StimulusHelper(null)), + 'renderer' => new LeafletRenderer(new StimulusHelper(null), new UxIconRenderer(null)), 'map' => (clone $map), ]; yield 'with custom attributes' => [ 'expected_render' => '
', - 'renderer' => new LeafletRenderer(new StimulusHelper(null)), + 'renderer' => new LeafletRenderer(new StimulusHelper(null), new UxIconRenderer(null)), 'map' => (clone $map), 'attributes' => ['data-controller' => 'my-custom-controller', 'class' => 'map'], ]; + yield 'with markers and infoWindows' => [ 'expected_render' => '
', - 'renderer' => new LeafletRenderer(new StimulusHelper(null)), + 'renderer' => new LeafletRenderer(new StimulusHelper(null), new UxIconRenderer(null)), 'map' => (new Map()) ->center(new Point(48.8566, 2.3522)) ->zoom(12) @@ -58,7 +61,7 @@ public function provideTestRenderMap(): iterable yield 'with all markers removed' => [ 'expected_render' => '
', - 'renderer' => new LeafletRenderer(new StimulusHelper(null)), + 'renderer' => new LeafletRenderer(new StimulusHelper(null), new UxIconRenderer(null)), 'map' => (new Map()) ->center(new Point(48.8566, 2.3522)) ->zoom(12) @@ -69,8 +72,8 @@ public function provideTestRenderMap(): iterable ]; yield 'with marker remove and new ones added' => [ - 'expected_render' => '
', - 'renderer' => new LeafletRenderer(new StimulusHelper(null)), + 'expected_render' => '
', + 'renderer' => new LeafletRenderer(new StimulusHelper(null), new UxIconRenderer(null)), 'map' => (new Map()) ->center(new Point(48.8566, 2.3522)) ->zoom(12) @@ -82,7 +85,7 @@ public function provideTestRenderMap(): iterable yield 'with polygons and infoWindows' => [ 'expected_render' => '
', - 'renderer' => new LeafletRenderer(new StimulusHelper(null)), + 'renderer' => new LeafletRenderer(new StimulusHelper(null), new UxIconRenderer(null)), 'map' => (new Map()) ->center(new Point(48.8566, 2.3522)) ->zoom(12) @@ -92,12 +95,30 @@ public function provideTestRenderMap(): iterable yield 'with polylines and infoWindows' => [ 'expected_render' => '
', - 'renderer' => new LeafletRenderer(new StimulusHelper(null)), + 'renderer' => new LeafletRenderer(new StimulusHelper(null), new UxIconRenderer(null)), 'map' => (new Map()) ->center(new Point(48.8566, 2.3522)) ->zoom(12) ->addPolyline(new Polyline(points: [new Point(48.8566, 2.3522), new Point(48.8566, 2.3522), new Point(48.8566, 2.3522)], id: 'polyline1')) ->addPolyline(new Polyline(points: [new Point(1.1, 2.2), new Point(3.3, 4.4), new Point(5.5, 6.6)], infoWindow: new InfoWindow(content: 'Polyline'), id: 'polyline2')), ]; + + yield 'markers with icons' => [ + 'expected_render' => '
', + 'renderer' => new LeafletRenderer( + new StimulusHelper(null), + new UxIconRenderer(new class implements IconRendererInterface { + public function renderIcon(string $name, array $attributes = []): string + { + return '...'; + } + })), + 'map' => (new Map()) + ->center(new Point(48.8566, 2.3522)) + ->zoom(12) + ->addMarker(new Marker(position: new Point(48.8566, 2.3522), title: 'Paris', icon: Icon::url('https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/icons/geo-alt.svg')->width(32)->height(32))) + ->addMarker(new Marker(position: new Point(45.7640, 4.8357), title: 'Lyon', icon: Icon::ux('fa:map-marker')->width(32)->height(32))) + ->addMarker(new Marker(position: new Point(45.8566, 2.3522), title: 'Dijon', icon: Icon::svg('...'))), + ]; } } diff --git a/src/Map/src/Icon/UxIconRenderer.php b/src/Map/src/Icon/UxIconRenderer.php index 217cc3f224b..1b99f4c6a67 100644 --- a/src/Map/src/Icon/UxIconRenderer.php +++ b/src/Map/src/Icon/UxIconRenderer.php @@ -21,7 +21,7 @@ readonly class UxIconRenderer { public function __construct( - private readonly ?IconRendererInterface $renderer, + private ?IconRendererInterface $renderer, ) { } diff --git a/src/Map/src/Renderer/AbstractRenderer.php b/src/Map/src/Renderer/AbstractRenderer.php index 038d57f58e8..8b2ef9fd7cf 100644 --- a/src/Map/src/Renderer/AbstractRenderer.php +++ b/src/Map/src/Renderer/AbstractRenderer.php @@ -92,13 +92,13 @@ private function getMapAttributes(Map $map): array $attrs = $map->toArray(); foreach ($attrs['markers'] as $key => $marker) { - $attrs['markers'][$key]['@id'] = $computeId($marker); if (isset($marker['icon']['type']) && IconType::UxIcon->value === $marker['icon']['type']) { $attrs['markers'][$key]['icon']['_generated_html'] = $this->uxIconRenderer->render($marker['icon']['name'], [ 'width' => $marker['icon']['width'], 'height' => $marker['icon']['height'], ]); } + $attrs['markers'][$key]['@id'] = $computeId($marker); } foreach ($attrs['polygons'] as $key => $polygon) { $attrs['polygons'][$key]['@id'] = $computeId($polygon); diff --git a/src/Map/src/Renderer/AbstractRendererFactory.php b/src/Map/src/Renderer/AbstractRendererFactory.php index b1417447e94..769c39d6f1b 100644 --- a/src/Map/src/Renderer/AbstractRendererFactory.php +++ b/src/Map/src/Renderer/AbstractRendererFactory.php @@ -22,7 +22,7 @@ abstract class AbstractRendererFactory { public function __construct( protected StimulusHelper $stimulus, - protected ?UxIconRenderer $uxIconRenderer = null, + protected UxIconRenderer $uxIconRenderer, ) { }