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..9eb15e76cbd 100644 --- a/src/Map/assets/dist/abstract_map_controller.d.ts +++ b/src/Map/assets/dist/abstract_map_controller.d.ts @@ -7,10 +7,30 @@ export type Identifier = string; export type WithIdentifier> = T & { '@id': Identifier; }; +export declare const IconTypes: { + readonly Url: "url"; + readonly Svg: "svg"; + readonly UxIcon: "ux-icon"; +}; +export type Icon = { + 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; infoWindow?: InfoWindowWithoutPositionDefinition; + icon?: Icon; rawOptions?: MarkerOptions; extra: Record; }>; @@ -105,6 +125,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/dist/abstract_map_controller.js b/src/Map/assets/dist/abstract_map_controller.js index 2627b65df2a..bd50b95f615 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', + Svg: '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 63b227af8d8..db5dd7e5f0c 100644 --- a/src/Map/assets/src/abstract_map_controller.ts +++ b/src/Map/assets/src/abstract_map_controller.ts @@ -1,14 +1,38 @@ 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', + Svg: 'svg', + UxIcon: 'ux-icon', +} as const; +export type Icon = { + 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; infoWindow?: InfoWindowWithoutPositionDefinition; + icon?: Icon; /** * Raw options passed to the marker constructor, specific to the map provider (e.g.: `L.marker()` for Leaflet). */ @@ -268,6 +292,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/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 961eb365b46..41189f35cc5 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'), ]) ->set('ux_map.renderer_factory', Renderer::class) diff --git a/src/Map/doc/index.rst b/src/Map/doc/index.rst index c36ea6109c9..156b52a9e71 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,27 @@ You can add markers to a map using the ``addMarker()`` method:: )) ; +Add Marker icons +~~~~~~~~~~~~~~~~ + +.. versionadded:: 2.24 + + ``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')->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( + // ... + 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..db073bc7732 100644 --- a/src/Map/src/Bridge/Google/assets/src/map_controller.ts +++ b/src/Map/src/Bridge/Google/assets/src/map_controller.ts @@ -9,8 +9,9 @@ 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, MarkerDefinition, Point, @@ -36,6 +37,8 @@ type MapOptions = Pick< let _google: typeof google; +const parser = new DOMParser(); + export default class extends AbstractMapController< MapOptions, google.maps.Map, @@ -55,6 +58,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 +93,7 @@ export default class extends AbstractMapController< } super.connect(); + this.parser = new DOMParser(); } public centerValueChanged(): void { @@ -139,7 +145,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 +159,10 @@ export default class extends AbstractMapController< this.createInfoWindow({ definition: infoWindow, element: marker }); } + if (icon) { + this.doCreateIcon({ definition: icon, element: marker }); + } + return marker; } @@ -297,6 +307,30 @@ export default class extends AbstractMapController< return content; } + protected doCreateIcon({ + definition, + element, + }: { + definition: Icon; + element: google.maps.marker.AdvancedMarkerElement; + }): void { + 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 = definition.url; + element.content = icon; + } else { + throw new Error(`Unsupported icon type: ${type}.`); + } + } + private closeInfoWindowsExcept(infoWindow: google.maps.InfoWindow) { this.infoWindows.forEach((otherInfoWindow) => { if (otherInfoWindow !== infoWindow) { 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 cb325ba0171..1077e26a466 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; @@ -28,6 +29,7 @@ */ public function __construct( StimulusHelper $stimulusHelper, + UxIconRenderer $uxIconRenderer, #[\SensitiveParameter] private string $apiKey, private ?string $id = null, @@ -43,7 +45,7 @@ public function __construct( private array $libraries = [], private ?string $defaultMapId = 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..29f81edc3d0 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, private ?string $defaultMapId = null, ) { - parent::__construct($stimulus); + parent::__construct($stimulus, $uxIconRenderer); } public function create(Dsn $dsn): RendererInterface @@ -41,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'), 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 dda259fec56..dcb35dae2ce 100644 --- a/src/Map/src/Bridge/Google/tests/GoogleRendererTest.php +++ b/src/Map/src/Bridge/Google/tests/GoogleRendererTest.php @@ -11,8 +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; @@ -35,26 +38,26 @@ public function provideTestRenderMap(): iterable 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'), + '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) @@ -64,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) @@ -75,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) @@ -88,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) @@ -98,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) @@ -108,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) @@ -122,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) @@ -135,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.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..636c99f3c4f 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', + Svg: 'svg', + UxIcon: 'ux-icon', +}; class default_1 extends Controller { constructor() { super(...arguments); @@ -147,11 +152,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 +205,35 @@ class map_controller extends default_1 { } return popup; } + doCreateIcon({ definition, element, }) { + 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() { 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..53333592a58 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 AbstractMapController, { IconTypes } from '@symfony/ux-map'; import type { + Icon, InfoWindowWithoutPositionDefinition, MarkerDefinition, Point, @@ -41,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(); @@ -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,40 @@ export default class extends AbstractMapController< return popup; } + protected doCreateIcon({ + definition, + element, + }: { + definition: Icon; + element: L.Marker; + }): void { + 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: '', // 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: '', // 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: '', // Adding an empty class to the icon to avoid the default Leaflet styles + }); + } else { + throw new Error(`Unsupported icon type: ${type}.`); + } + element.setIcon(icon); + } + protected doFitBoundsToMarkers(): void { if (this.markers.size === 0) { return; 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/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/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 e9f8cec5354..47e5da7cbe2 100644 --- a/src/Map/src/Bridge/Leaflet/tests/LeafletRendererTest.php +++ b/src/Map/src/Bridge/Leaflet/tests/LeafletRendererTest.php @@ -11,7 +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; @@ -35,19 +38,20 @@ public function provideTestRenderMap(): iterable 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)), + 'expected_render' => '
', + 'renderer' => new LeafletRenderer(new StimulusHelper(null), new UxIconRenderer(null)), 'map' => (new Map()) ->center(new Point(48.8566, 2.3522)) ->zoom(12) @@ -57,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) @@ -68,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) @@ -81,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) @@ -91,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/Icon.php b/src/Map/src/Icon/Icon.php new file mode 100644 index 00000000000..d097952de23 --- /dev/null +++ b/src/Map/src/Icon/Icon.php @@ -0,0 +1,120 @@ + + * + * 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 + * @author Hugo Alliaume + */ +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 + { + return new UrlIcon($url); + } + + /** + * 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 + { + return new SvgIcon($html); + } + + /** + * 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 + { + 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, + ) { + } + + /** + * Sets the width of the icon. + * + * @param positive-int $width + */ + public function width(int $width): static + { + $this->width = $width; + + return $this; + } + + /** + * Sets the height of the icon. + * + * @param positive-int $height + */ + public function height(int $height): static + { + $this->height = $height; + + return $this; + } + + /** + * @internal + */ + public function toArray(): array + { + return [ + 'type' => $this->type->value, + 'width' => $this->width, + 'height' => $this->height, + ]; + } + + /** + * @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']) { + 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/IconType.php b/src/Map/src/Icon/IconType.php new file mode 100644 index 00000000000..ed6befaaebd --- /dev/null +++ b/src/Map/src/Icon/IconType.php @@ -0,0 +1,24 @@ + + * + * 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 Hugo Alliaume + */ +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..fc59f0c8184 --- /dev/null +++ b/src/Map/src/Icon/SvgIcon.php @@ -0,0 +1,66 @@ + + * + * 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 + */ + protected function __construct( + protected string $html, + ) { + parent::__construct(IconType::Svg); + } + + /** + * @param array{ html: string } $data + */ + public static function fromArray(array $data): static + { + return new self( + html: $data['html'], + ); + } + + /** + * @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 [ + ...parent::toArray(), + 'html' => $this->html, + ]; + } +} 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 new file mode 100644 index 00000000000..ed456b312f1 --- /dev/null +++ b/src/Map/src/Icon/UxIcon.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 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/src/Icon/UxIconRenderer.php b/src/Map/src/Icon/UxIconRenderer.php new file mode 100644 index 00000000000..1b99f4c6a67 --- /dev/null +++ b/src/Map/src/Icon/UxIconRenderer.php @@ -0,0 +1,42 @@ + + * + * 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 ?IconRendererInterface $renderer, + ) { + } + + /** + * @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.'); + } + + 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 0310834a14f..922bd06d706 100644 --- a/src/Map/src/Marker.php +++ b/src/Map/src/Marker.php @@ -12,6 +12,8 @@ namespace Symfony\UX\Map; use Symfony\UX\Map\Exception\InvalidArgumentException; +use Symfony\UX\Map\Icon\Icon; +use Symfony\UX\Map\Icon\IconType; /** * Represents a marker on a map. @@ -30,6 +32,7 @@ public function __construct( public ?InfoWindow $infoWindow = null, public array $extra = [], public ?string $id = null, + public ?Icon $icon = null, ) { } @@ -38,6 +41,7 @@ public function __construct( * position: array{lat: float, lng: float}, * title: string|null, * infoWindow: array|null, + * icon: array{type: value-of, width: positive-int, height: positive-int, ...}|null, * extra: array, * id: string|null * } @@ -48,6 +52,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 +63,7 @@ public function toArray(): array * position: array{lat: float, lng: float}, * title: string|null, * infoWindow: array|null, + * icon: array{type: value-of, width: positive-int, height: positive-int, ...}|null, * extra: array, * id: string|null * } $marker @@ -74,6 +80,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..8b2ef9fd7cf 100644 --- a/src/Map/src/Renderer/AbstractRenderer.php +++ b/src/Map/src/Renderer/AbstractRenderer.php @@ -11,6 +11,8 @@ 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; use Symfony\UX\StimulusBundle\Helper\StimulusHelper; @@ -22,6 +24,7 @@ { public function __construct( private StimulusHelper $stimulus, + private UxIconRenderer $uxIconRenderer, ) { } @@ -89,9 +92,14 @@ private function getMapAttributes(Map $map): array $attrs = $map->toArray(); foreach ($attrs['markers'] as $key => $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 02587a75d09..769c39d6f1b 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, ) { } diff --git a/src/Map/tests/IconTest.php b/src/Map/tests/IconTest.php new file mode 100644 index 00000000000..35ddeb943fb --- /dev/null +++ b/src/Map/tests/IconTest.php @@ -0,0 +1,100 @@ + + * + * 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\SvgIcon; +use Symfony\UX\Map\Icon\UrlIcon; +use Symfony\UX\Map\Icon\UxIcon; + +class IconTest extends TestCase +{ + public static function provideIcons(): iterable + { + 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'], + ]; + } + + /** + * @dataProvider provideIcons + * + * @param class-string $expectedInstance + */ + public function testIconConstruction(Icon $icon, string $expectedInstance, array $expectedToArray): void + { + self::assertInstanceOf($expectedInstance, $icon); + } + + /** + * @dataProvider provideIcons + */ + public function testToArray(Icon $icon, string $expectedInstance, array $expectedToArray): void + { + self::assertSame($expectedToArray, $icon->toArray()); + } + + /** + * @dataProvider provideIcons + */ + public function testFromArray(Icon $icon, string $expectedInstance, array $expectedToArray): void + { + 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); + } +} 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..db1ae73bd2b 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::url('https://example.com/image.png'), ); $array = $marker->toArray(); @@ -57,6 +60,12 @@ public function testToArray(): void 'autoClose' => true, 'extra' => $array['infoWindow']['extra'], ], + 'icon' => [ + 'type' => 'url', + 'width' => 24, + 'height' => 24, + 'url' => 'https://example.com/image.png', + ], 'extra' => $array['extra'], 'id' => null, ], $array);