38 Commits
v2.28.2 ... 2.x

Author SHA1 Message Date
github-actions[bot]
38ebd9a0ba Update versions to 2.34.0 2026-03-22 22:21:50 +00:00
Hugo Alliaume
e6163d034a [Autocomplete][Chartjs][Cropperjs][Dropzone][LazyImage][LiveComponent][Map][Notify][React][StimulusBundle][Svelte][Swup][TogglePassword][Translator][Turbo][Typed][Vue] Update package.json to 2.33.0
The job that release npm packages failed https://github.com/symfony/ux/actions/runs/23216748609/job/67479440402

it should be fixed by https://github.com/symfony/ux/pull/3400
2026-03-21 23:29:11 +01:00
Hugo Alliaume
f0ab9c5115 Update tsdown & use @tsdown/css
This update simplifies the tsdown configuration, we do not need our custom plugin to minify CSS anymore (replaced by `css.minify = true`), and same for our hooks that rename the built CSS (replaced by `css.fileName`) 😍
2026-03-20 09:00:50 +01:00
Hugo Alliaume
d105663254 Update Vitest to ^4.1.0 2026-03-15 08:57:29 +01:00
Hugo Alliaume
dd94fdb28b Migrate from tsup (deprecated) to tsdown
tsup is deprecated in favor of tsdown.

Follow https://github.com/symfony/ux/pull/2935, https://github.com/symfony/ux/pull/2944, and many (local) tries were I was not really happy with the files generated by tsdown/rolldown, we are finally having something extra good!

We have the benefits from https://github.com/symfony/ux/pull/2935, https://github.com/symfony/ux/pull/2944, but without their drawbacks. The code correctly follow the `es2022` target and does not do anything weird anymore with static properties (needed by controllers).

I (Claude) added a plugin to remove the region and JSDoc comments (except if they contain `@deprecated``), since I think we want to keep the code non-minified.
2026-02-28 09:33:37 +01:00
Hugo Alliaume
4cd73c612d Remove tsx dependency and rely on Node.js 22.18.0 native TypeScript runner 2026-02-27 20:17:39 +01:00
Hugo Alliaume
4e84564e21 Drop Biome.js for oxfmt and oxlint 2026-02-03 23:14:09 +01:00
Hugo Alliaume
74fbe59401 Run PHP-CS-Fixer (no_useless_else & static_lambda) 2026-02-03 22:36:24 +01:00
Hugo Alliaume
4e07cf3b2a Fix npm releases due to repository issue 2026-01-16 23:36:00 +01:00
Hugo Alliaume
9540d0e7ca Update versions to 2.32.0 2026-01-16 23:35:37 +01:00
Hugo Alliaume
4ce88895dc Update root JS dependencies 2026-01-11 00:13:28 +01:00
Hugo Alliaume
c14a0907be [CI] Fix compatibility issues with Symfony 8 and spatie/phpunit-snapshot-assertions 2026-01-10 01:02:57 +01:00
Steven Renaux
97e5fd66c6 [Map] Add Map::removeAll*() methods 2025-12-26 10:58:22 +01:00
Hugo Alliaume
bb30afa5f3 Git-ignore config/reference.php 2025-12-02 08:12:06 +01:00
Hugo Alliaume
5762104d8d [CI] Tests over Symfony 7.4.0-beta1 and 8.0.0-beta1 2025-11-16 19:52:01 +01:00
Hugo Alliaume
a5edf92aba Update versions to 2.31.0 2025-10-27 23:21:26 +01:00
Romain Monteil
9928a5cfec [Map] Add fitBoundsToMarkers option to Twig extension and component 2025-10-02 08:39:00 +02:00
Hugo Alliaume
8bef1f2024 Remove explicit configuration twig.exception_controller from Kernel for testing
Related to https://github.com/symfony/symfony/pull/51273#discussion_r2359723452
2025-09-25 09:20:40 +02:00
Hugo Alliaume
7d1349bac0 Refactor "test_package.sh" to its original purpose, add multiples checks for packages definition 2025-09-20 13:50:04 +02:00
Dariusz Ruminski
299ad7907f PHP CS Fixer - apply const->var annotation conversion 2025-09-02 18:34:50 +02:00
Hugo Alliaume
2165688ac9 Configure .gitattributes to ignore Vitest and Playwright config files from export 2025-09-01 22:55:23 +02:00
github-actions[bot]
6455f300f6 Update versions to 2.30.0 2025-08-27 18:16:44 +00:00
Hugo Alliaume
06a24f4728 Fix changelogs 2025-08-27 17:25:48 +02:00
Hugo Alliaume
974d11f751 Remove some indirect deprecations 2025-08-25 23:33:06 +02:00
Hugo Alliaume
2224131cd1 Ensure PHP 8.5 compatibility 2025-08-22 16:23:49 +02:00
Hugo Alliaume
0a7f3a1b4e [Map] Deprecate option title from Polygon, Polyline, Rectangle and Circle in favor of infoWindow
While writing E2E tests for Map, I found that our `title` option from `Polygon`, `Polyline`, `Rectangle` and `Circle` was wrongly used, and does not make sense.

Using `title` won't add an HTML attribute `title` as I expected, it's not something supported by Google nor Leaflet.
Instead, we used it to display a popup, like the `infoWindow` (which is more complete).

I suggest deprecating `title` from 2.x and remove it in 3.0.
2025-08-21 08:30:19 +02:00
Hugo Alliaume
6dece8c2e9 [Map][Docs] Correct and reword typos about polygons 2025-08-20 15:07:20 +02:00
Hugo Alliaume
63d48e24cd minor #3014 Create E2E app for browsers tests (Kocal)
This PR was merged into the 2.x branch.

Discussion
----------

 Create E2E app for browsers tests

| Q             | A
| ------------- | ---
| Bug fix?      | no
| New feature?  | no <!-- please update src/**/CHANGELOG.md files -->
| Docs?         | no <!-- required for new features -->
| Issues        | Fix #3009 <!-- prefix each issue number with "Fix #", no need to create an issue if none exist, explain below instead -->
| License       | MIT

<!--
Replace this notice by a description of your feature/bugfix.
This will help reviewers and should be a good start for the documentation.

Additionally (see https://symfony.com/releases):
 - Always add tests and ensure they pass.
 - For new features, provide some code snippets to help understand usage.
 - Features and deprecations must be submitted against branch main.
 - Update/add documentation as required (we can help!)
 - Changelog entry should follow https://symfony.com/doc/current/contributing/code/conventions.html#writing-a-changelog-entry
 - Never break backward compatibility (see https://symfony.com/bc).
-->

This pull request updates the browser testing workflow and related configuration to improve reliability, consistency, and maintainability across UX packages. The main changes include refactoring the browser test workflow to use a matrix strategy for Symfony versions, standardizing dependency installation steps, removing unused dependencies, and renaming the Playwright configuration for easier reuse.

**Workflow and CI improvements:**

* Refactored `.github/workflows/browser-tests.yml` to use a matrix strategy for Symfony versions, added concurrency controls to cancel in-progress runs, and split setup steps for JS and PHP dependencies, Docker containers, and E2E app configuration. Artifact uploads now only occur on browser test failures. [[1]](diffhunk://#diff-255cac5fcd7ae015d5bc1ccf14bfa2fff33bcabb653402be014e6668db1036ceR23-R38) [[2]](diffhunk://#diff-255cac5fcd7ae015d5bc1ccf14bfa2fff33bcabb653402be014e6668db1036ceL36-R116)
* Standardized JS dependency installation across workflows by replacing direct `pnpm install` commands with named steps (`Install root JS dependencies`) in code quality, unit test, dist files, and release workflows. [[1]](diffhunk://#diff-4a2765c2cfcbd3804a66aab805cb92ddda74de1730923cc5bf53671d0beccf06R27-R36) [[2]](diffhunk://#diff-b117ce55777f198ed74d5eb1cd6319c0b63837e2e9eed5c44b2477658e12248fR24) [[3]](diffhunk://#diff-b117ce55777f198ed74d5eb1cd6319c0b63837e2e9eed5c44b2477658e12248fL32-R38) [[4]](diffhunk://#diff-8e3deeaeb0bdfc6967ff8173f1d99e5001fe75dc497cbfb85fe64ceaade5e399L33-R34) [[5]](diffhunk://#diff-6e608e02c595d53ab6b70822a2bf19abcfc6ddcc976c2f536ad5bfca20f0443fR148) [[6]](diffhunk://#diff-6e608e02c595d53ab6b70822a2bf19abcfc6ddcc976c2f536ad5bfca20f0443fL157-R161)

**Testing and configuration changes:**

* Updated `package.json` to run browser tests in all workspaces concurrently, removed the unused `webdriverio` dependency, and improved the `test:browser` script for workspace aggregation. [[1]](diffhunk://#diff-7ae45ad102eab3b6d7e7896acd08c427a9b25b346470d7bc6507b6481575d519L13-R13) [[2]](diffhunk://#diff-7ae45ad102eab3b6d7e7896acd08c427a9b25b346470d7bc6507b6481575d519L28-R28)
* Renamed `playwright.config.ts` to `playwright.config.base.ts`, added documentation for usage in UX packages, and improved test matching patterns and output directory configuration. [[1]](diffhunk://#diff-8f3b25b652873317fa4aa36b920f753b44dc82f5c1f0d2ff5e6b1781ef1dc90fL1-R30) [[2]](diffhunk://#diff-8f3b25b652873317fa4aa36b920f753b44dc82f5c1f0d2ff5e6b1781ef1dc90fL30-L36)

**Dependency management:**

* Removed `webdriverio` from the lockfile and marked many transitive dependencies as optional in `pnpm-lock.yaml`, reducing the install footprint and improving clarity for unused packages. [[1]](diffhunk://#diff-32824c984905bb02bc7ffcef96a77addd1f1602cff71a11fbbfdd7f53ee026bbL54-L56) [[2]](diffhunk://#diff-32824c984905bb02bc7ffcef96a77addd1f1602cff71a11fbbfdd7f53ee026bbR3991) [[3]](diffhunk://#diff-32824c984905bb02bc7ffcef96a77addd1f1602cff71a11fbbfdd7f53ee026bbR4242) [[4]](diffhunk://#diff-32824c984905bb02bc7ffcef96a77addd1f1602cff71a11fbbfdd7f53ee026bbL4260-R4260) [[5]](diffhunk://#diff-32824c984905bb02bc7ffcef96a77addd1f1602cff71a11fbbfdd7f53ee026bbL4270-R4276) [[6]](diffhunk://#diff-32824c984905bb02bc7ffcef96a77addd1f1602cff71a11fbbfdd7f53ee026bbR4429) [[7]](diffhunk://#diff-32824c984905bb02bc7ffcef96a77addd1f1602cff71a11fbbfdd7f53ee026bbR4438-R4451) [[8]](diffhunk://#diff-32824c984905bb02bc7ffcef96a77addd1f1602cff71a11fbbfdd7f53ee026bbR4472-R4480) [[9]](diffhunk://#diff-32824c984905bb02bc7ffcef96a77addd1f1602cff71a11fbbfdd7f53ee026bbR4530) [[10]](diffhunk://#diff-32824c984905bb02bc7ffcef96a77addd1f1602cff71a11fbbfdd7f53ee026bbR4541) [[11]](diffhunk://#diff-32824c984905bb02bc7ffcef96a77addd1f1602cff71a11fbbfdd7f53ee026bbL4543-R4556) [[12]](diffhunk://#diff-32824c984905bb02bc7ffcef96a77addd1f1602cff71a11fbbfdd7f53ee026bbL4578-R4597) [[13]](diffhunk://#diff-32824c984905bb02bc7ffcef96a77addd1f1602cff71a11fbbfdd7f53ee026bbL4597-R4613) [[14]](diffhunk://#diff-32824c984905bb02bc7ffcef96a77addd1f1602cff71a11fbbfdd7f53ee026bbR4622) [[15]](diffhunk://#diff-32824c984905bb02bc7ffcef96a77addd1f1602cff71a11fbbfdd7f53ee026bbR4670) [[16]](diffhunk://#diff-32824c984905bb02bc7ffcef96a77addd1f1602cff71a11fbbfdd7f53ee026bbR4685) [[17]](diffhunk://#diff-32824c984905bb02bc7ffcef96a77addd1f1602cff71a11fbbfdd7f53ee026bbL4704-R4724) [[18]](diffhunk://#diff-32824c984905bb02bc7ffcef96a77addd1f1602cff71a11fbbfdd7f53ee026bbR4733) [[19]](diffhunk://#diff-32824c984905bb02bc7ffcef96a77addd1f1602cff71a11fbbfdd7f53ee026bbL4725-R4756) [[20]](diffhunk://#diff-32824c984905bb02bc7ffcef96a77addd1f1602cff71a11fbbfdd7f53ee026bbR4779-R4793) [[21]](diffhunk://#diff-32824c984905bb02bc7ffcef96a77addd1f1602cff71a11fbbfdd7f53ee026bbL4778-R4807) [[22]](diffhunk://#diff-32824c984905bb02bc7ffcef96a77addd1f1602cff71a11fbbfdd7f53ee026bbL4791-R4828)

These changes make the browser testing workflow more robust and maintainable, ensure consistent dependency installation, and clean up unused or optional packages to streamline CI runs.

Commits
-------

dd1c13aff81 Create E2E app & run it in CI
2025-08-19 20:34:58 +02:00
Hugo Alliaume
18c9f36ca7 Create E2E app & run it in CI 2025-08-19 20:30:58 +02:00
github-actions[bot]
dc96f81de9 Update versions to 2.29.2 2025-08-19 12:08:45 +00:00
Hugo Alliaume
31897dcf6d Rename back vitest.config.unit.mjs to vitest.config.mjs 2025-08-18 11:13:24 +02:00
Hugo Alliaume
46964636ec Configure Vitest for unit and browser tests (use @puppeteer/browsers and webdriverio) 2025-08-18 08:22:05 +02:00
Hugo Alliaume
e7af2b2770 Run latest PHP-CS-Fixer with improved configuration 2025-08-14 22:58:41 +02:00
github-actions[bot]
7f4bd7b438 Update versions to 2.29.1 2025-08-08 12:45:34 +00:00
github-actions[bot]
cf81723a24 Update versions to 2.29.0 2025-08-08 11:38:41 +00:00
Simon André
211ed3c911 [Map] Add Clustering Algorithms 2025-08-07 23:49:43 +02:00
Hugo Alliaume
e824d0f34a Add support for Symfony 8 2025-08-06 00:04:40 +02:00
github-actions[bot]
1b7eb9819b Update versions to 2.28.2 2025-07-30 12:24:53 +00:00
52 changed files with 1437 additions and 517 deletions

3
.gitattributes vendored
View File

@@ -3,6 +3,7 @@
/phpunit.xml.dist export-ignore
/assets/src export-ignore
/assets/test export-ignore
/assets/vitest.config.js export-ignore
/assets/playwright.config.ts export-ignore
/assets/vitest.config.mjs export-ignore
/doc export-ignore
/tests export-ignore

View File

@@ -1,5 +1,4 @@
Please do not submit any Pull Requests here. They will be closed.
---
## Please do not submit any Pull Requests here. They will be closed.
Please submit your PR here instead:
https://github.com/symfony/ux

View File

@@ -1,20 +1,20 @@
name: Close Pull Request
on:
pull_request_target:
types: [opened]
pull_request_target:
types: [opened]
jobs:
run:
runs-on: ubuntu-latest
steps:
- uses: superbrothers/close-pull-request@v3
with:
comment: |
Thanks for your Pull Request! We love contributions.
However, you should instead open your PR on the main repository:
https://github.com/symfony/ux
This repository is what we call a "subtree split": a read-only subset of that main repository.
We're looking forward to your PR there!
run:
runs-on: ubuntu-latest
steps:
- uses: superbrothers/close-pull-request@v3
with:
comment: |
Thanks for your Pull Request! We love contributions.
However, you should instead open your PR on the main repository:
https://github.com/symfony/ux
This repository is what we call a "subtree split": a read-only subset of that main repository.
We're looking forward to your PR there!

7
.gitignore vendored
View File

@@ -1,3 +1,4 @@
vendor
composer.lock
.phpunit.result.cache
/config/reference.php
/vendor
/composer.lock
/.phpunit.result.cache

View File

@@ -1,3 +1,3 @@
branches: ["2.x"]
maintained_branches: ["2.x"]
doc_dir: "doc"
branches: ['2.x']
maintained_branches: ['2.x']
doc_dir: 'doc'

View File

@@ -1,17 +1,35 @@
# CHANGELOG
## 2.32
- Add `Map::removeAllMarkers()`, `Map::removeAllPolygons()`, `Map::removeAllPolylines()`, `Map::removeAllCircles()` and `Map::removeAllRectangles()` methods
## 2.31
- Add `fitBoundsToMarkers` parameter to `ux_map()` Twig function
## 2.30
- Ensure compatibility with PHP 8.5
- Deprecate option `title` from `Polygon`, `Polyline`, `Rectangle` and `Circle` in favor of `infoWindow`
## 2.29.0
- Add Symfony 8 support
- Add `Cluster` class and `ClusteringAlgorithmInterface` with two implementations `GridClusteringAlgorithm` and `MortonClusteringAlgorithm`
## 2.28
- Add `minZoom` and `maxZoom` options to `Map` to set the minimum and maximum zoom levels
- The package is not experimental anymore
- Add `minZoom` and `maxZoom` options to `Map` to set the minimum and maximum zoom levels
## 2.27
- The `fitBoundsToMarkers` option is not overridden anymore when using the `Map` LiveComponent, but now respects the value you defined.
You may encounter unwanted behavior when adding/removing elements to the map.
To use the previous behavior, you must call `$this->getMap()->fitBoundsToMarkers(false)` in your LiveComponent's live actions
- The `fitBoundsToMarkers` option is not overridden anymore when using the `Map` LiveComponent, but now respects the value you defined.
You may encounter unwanted behavior when adding/removing elements to the map.
To use the previous behavior, you must call `$this->getMap()->fitBoundsToMarkers(false)` in your LiveComponent's live actions
- Add support for creating `Circle` by passing a `Point` and a radius (in meters) to the `Circle` constructor, e.g.:
- Add support for creating `Circle` by passing a `Point` and a radius (in meters) to the `Circle` constructor, e.g.:
```php
$map->addCircle(new Circle(
center: new Point(48.856613, 2.352222), // Paris
@@ -19,7 +37,8 @@ $map->addCircle(new Circle(
));
```
- Add support for creating `Rectangle` by passing two `Point` instances to the `Rectangle` constructor, e.g.:
- Add support for creating `Rectangle` by passing two `Point` instances to the `Rectangle` constructor, e.g.:
```php
$map->addRectangle(new Rectangle(
southWest: new Point(48.856613, 2.352222), // Paris
@@ -27,8 +46,9 @@ $map->addRectangle(new Rectangle(
));
```
- Deprecate property `rawOptions` from `ux:map:*:before-create` events, in favor of `bridgeOptions` instead.
- Map options can now be configured and overridden through the `ux:map:pre-connect` event:
- Deprecate property `rawOptions` from `ux:map:*:before-create` events, in favor of `bridgeOptions` instead.
- Map options can now be configured and overridden through the `ux:map:pre-connect` event:
```js
this.element.addEventListener('ux:map:pre-connect', (event) => {
// Override the map center and zoom
@@ -44,11 +64,13 @@ this.element.addEventListener('ux:map:pre-connect', (event) => {
};
});
```
- Add `extra` data support to `Map`, which can be accessed in `ux:map:pre-connect` and `ux:map:connect` events
- Add `extra` data support to `Map`, which can be accessed in `ux:map:pre-connect` and `ux:map:connect` events
## 2.26
- Add support for creating `Polygon` with holes, by passing an array of `array<Point>` as `points` parameter to the `Polygon` constructor, e.g.:
- Add support for creating `Polygon` with holes, by passing an array of `array<Point>` as `points` parameter to the `Polygon` constructor, e.g.:
```php
// Draw a polygon with a hole in it, on the French map
$map->addPolygon(new Polygon(points: [
@@ -72,43 +94,43 @@ $map->addPolygon(new Polygon(points: [
## 2.25
- Downgrade PHP requirement from 8.3 to 8.1
- Downgrade PHP requirement from 8.3 to 8.1
## 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)
- Add parameter `id` to `Marker`, `Polygon` and `Polyline` constructors
- Add method `Map::removeMarker(string|Marker $markerOrId)`
- Add method `Map::removePolygon(string|Polygon $polygonOrId)`
- Add method `Map::removePolyline(string|Polyline $polylineOrId)`
- 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)
- Add parameter `id` to `Marker`, `Polygon` and `Polyline` constructors
- Add method `Map::removeMarker(string|Marker $markerOrId)`
- Add method `Map::removePolygon(string|Polygon $polygonOrId)`
- Add method `Map::removePolyline(string|Polyline $polylineOrId)`
## 2.23
- Add `DistanceUnit` to represent distance units (`m`, `km`, `miles`, `nmi`) and
ease conversion between units.
- Add `DistanceCalculatorInterface` interface and three implementations:
`HaversineDistanceCalculator`, `SphericalCosineDistanceCalculator` and `VincentyDistanceCalculator`.
- Add `CoordinateUtils` helper, to convert decimal coordinates (`43.2109`) in DMS (`56° 78' 90"`)
- Add `DistanceUnit` to represent distance units (`m`, `km`, `miles`, `nmi`) and
ease conversion between units.
- Add `DistanceCalculatorInterface` interface and three implementations:
`HaversineDistanceCalculator`, `SphericalCosineDistanceCalculator` and `VincentyDistanceCalculator`.
- Add `CoordinateUtils` helper, to convert decimal coordinates (`43.2109`) in DMS (`56° 78' 90"`)
## 2.22
- Add method `Symfony\UX\Map\Renderer\AbstractRenderer::tapOptions()`, to allow Renderer to modify options before rendering a Map.
- Add `ux_map.google_maps.default_map_id` configuration to set the Google ``Map ID``
- Add `ComponentWithMapTrait` to ease maps integration in [Live Components](https://symfony.com/bundles/ux-live-component/current/index.html)
- Add `Polyline` support
- Add method `Symfony\UX\Map\Renderer\AbstractRenderer::tapOptions()`, to allow Renderer to modify options before rendering a Map.
- Add `ux_map.google_maps.default_map_id` configuration to set the Google `Map ID`
- Add `ComponentWithMapTrait` to ease maps integration in [Live Components](https://symfony.com/bundles/ux-live-component/current/index.html)
- Add `Polyline` support
## 2.20
- Deprecate `render_map` Twig function (will be removed in 2.21). Use
`ux_map` or the `<twig:ux:map />` Twig component instead.
- Add `ux_map` Twig function (replaces `render_map` with a more flexible
interface)
- Add `<twig:ux:map />` Twig component
- The importmap entry `@symfony/ux-map/abstract-map-controller` can be removed
from your importmap, it is no longer needed.
- Add `Polygon` support
- Deprecate `render_map` Twig function (will be removed in 2.21). Use
`ux_map` or the `<twig:ux:map />` Twig component instead.
- Add `ux_map` Twig function (replaces `render_map` with a more flexible
interface)
- Add `<twig:ux:map />` Twig component
- The importmap entry `@symfony/ux-map/abstract-map-controller` can be removed
from your importmap, it is no longer needed.
- Add `Polygon` support
## 2.19
- Component added
- Component added

View File

@@ -7,7 +7,7 @@ https://github.com/symfony/ux to create issues or submit pull requests.
## Resources
- [Documentation](https://symfony.com/bundles/ux-map/current/index.html)
- [Report issues](https://github.com/symfony/ux/issues) and
[send Pull Requests](https://github.com/symfony/ux/pulls)
in the [main Symfony UX repository](https://github.com/symfony/ux)
- [Documentation](https://symfony.com/bundles/ux-map/current/index.html)
- [Report issues](https://github.com/symfony/ux/issues) and
[send Pull Requests](https://github.com/symfony/ux/pulls)
in the [main Symfony UX repository](https://github.com/symfony/ux)

View File

@@ -6,7 +6,7 @@ This package is private and is not intended to be published on npm or installed.
## Resources
- [Documentation](https://symfony.com/bundles/ux-map/current/index.html)
- [Report issues](https://github.com/symfony/ux/issues) and
[send Pull Requests](https://github.com/symfony/ux/pulls)
in the [main Symfony UX repository](https://github.com/symfony/ux)
- [Documentation](https://symfony.com/bundles/ux-map/current/index.html)
- [Report issues](https://github.com/symfony/ux/issues) and
[send Pull Requests](https://github.com/symfony/ux/pulls)
in the [main Symfony UX repository](https://github.com/symfony/ux)

View File

@@ -1,197 +1,216 @@
import { Controller } from '@hotwired/stimulus';
import { Controller } from "@hotwired/stimulus";
type Point = {
lat: number;
lng: number;
lat: number;
lng: number;
};
type Identifier = string;
type WithIdentifier<T extends Record<string, unknown>> = T & {
'@id': Identifier;
'@id': Identifier;
};
type ExtraData = Record<string, unknown>;
declare const IconTypes: {
readonly Url: "url";
readonly Svg: "svg";
readonly UxIcon: "ux-icon";
readonly Url: "url";
readonly Svg: "svg";
readonly UxIcon: "ux-icon";
};
type Icon = {
width: number;
height: number;
width: number;
height: number;
} & ({
type: typeof IconTypes.UxIcon;
name: string;
_generated_html: string;
type: typeof IconTypes.UxIcon;
name: string;
_generated_html: string;
} | {
type: typeof IconTypes.Url;
url: string;
type: typeof IconTypes.Url;
url: string;
} | {
type: typeof IconTypes.Svg;
html: string;
type: typeof IconTypes.Svg;
html: string;
});
type MapDefinition<MapOptions, BridgeMapOptions> = {
center: Point | null;
zoom: number | null;
minZoom: number | null;
maxZoom: number | null;
options: MapOptions;
bridgeOptions?: BridgeMapOptions;
extra: ExtraData;
center: Point | null;
zoom: number | null;
minZoom: number | null;
maxZoom: number | null;
options: MapOptions;
bridgeOptions?: BridgeMapOptions;
extra: ExtraData;
};
type MarkerDefinition<BridgeMarkerOptions, BridgeInfoWindowOptions> = WithIdentifier<{
position: Point;
title: string | null;
infoWindow?: Omit<InfoWindowDefinition<BridgeInfoWindowOptions>, 'position'>;
icon?: Icon;
rawOptions?: BridgeMarkerOptions;
bridgeOptions?: BridgeMarkerOptions;
extra: ExtraData;
position: Point;
title: string | null;
infoWindow?: Omit<InfoWindowDefinition<BridgeInfoWindowOptions>, 'position'>;
icon?: Icon;
rawOptions?: BridgeMarkerOptions;
bridgeOptions?: BridgeMarkerOptions;
extra: ExtraData;
}>;
type PolygonDefinition<BridgePolygonOptions, BridgeInfoWindowOptions> = WithIdentifier<{
infoWindow?: Omit<InfoWindowDefinition<BridgeInfoWindowOptions>, 'position'>;
points: Array<Point> | Array<Array<Point>>;
title: string | null;
rawOptions?: BridgePolygonOptions;
bridgeOptions?: BridgePolygonOptions;
extra: ExtraData;
infoWindow?: Omit<InfoWindowDefinition<BridgeInfoWindowOptions>, 'position'>;
points: Array<Point> | Array<Array<Point>>;
title: string | null;
rawOptions?: BridgePolygonOptions;
bridgeOptions?: BridgePolygonOptions;
extra: ExtraData;
}>;
type PolylineDefinition<BridgePolylineOptions, BridgeInfoWindowOptions> = WithIdentifier<{
infoWindow?: Omit<InfoWindowDefinition<BridgeInfoWindowOptions>, 'position'>;
points: Array<Point>;
title: string | null;
rawOptions?: BridgePolylineOptions;
bridgeOptions?: BridgePolylineOptions;
extra: ExtraData;
infoWindow?: Omit<InfoWindowDefinition<BridgeInfoWindowOptions>, 'position'>;
points: Array<Point>;
title: string | null;
rawOptions?: BridgePolylineOptions;
bridgeOptions?: BridgePolylineOptions;
extra: ExtraData;
}>;
type CircleDefinition<BridgeCircleOptions, BridgeInfoWindowOptions> = WithIdentifier<{
infoWindow?: Omit<InfoWindowDefinition<BridgeInfoWindowOptions>, 'position'>;
center: Point;
radius: number;
title: string | null;
rawOptions?: BridgeCircleOptions;
bridgeOptions?: BridgeCircleOptions;
extra: ExtraData;
infoWindow?: Omit<InfoWindowDefinition<BridgeInfoWindowOptions>, 'position'>;
center: Point;
radius: number;
title: string | null;
rawOptions?: BridgeCircleOptions;
bridgeOptions?: BridgeCircleOptions;
extra: ExtraData;
}>;
type RectangleDefinition<BridgeRectangleOptions, BridgeInfoWindowOptions> = WithIdentifier<{
infoWindow?: Omit<InfoWindowDefinition<BridgeInfoWindowOptions>, 'position'>;
southWest: Point;
northEast: Point;
title: string | null;
rawOptions?: BridgeRectangleOptions;
bridgeOptions?: BridgeRectangleOptions;
extra: ExtraData;
infoWindow?: Omit<InfoWindowDefinition<BridgeInfoWindowOptions>, 'position'>;
southWest: Point;
northEast: Point;
title: string | null;
rawOptions?: BridgeRectangleOptions;
bridgeOptions?: BridgeRectangleOptions;
extra: ExtraData;
}>;
type InfoWindowDefinition<BridgeInfoWindowOptions> = {
headerContent: string | null;
content: string | null;
position: Point;
opened: boolean;
autoClose: boolean;
rawOptions?: BridgeInfoWindowOptions;
bridgeOptions?: BridgeInfoWindowOptions;
extra: ExtraData;
headerContent: string | null;
content: string | null;
position: Point;
opened: boolean;
autoClose: boolean;
rawOptions?: BridgeInfoWindowOptions;
bridgeOptions?: BridgeInfoWindowOptions;
extra: ExtraData;
};
declare abstract class export_default<MapOptions, BridgeMapOptions, BridgeMap, BridgeMarkerOptions, BridgeMarker, BridgeInfoWindowOptions, BridgeInfoWindow, BridgePolygonOptions, BridgePolygon, BridgePolylineOptions, BridgePolyline, BridgeCircleOptions, BridgeCircle, BridgeRectangleOptions, BridgeRectangle> extends Controller<HTMLElement> {
static values: {
providerOptions: ObjectConstructor;
center: ObjectConstructor;
zoom: NumberConstructor;
minZoom: NumberConstructor;
maxZoom: NumberConstructor;
fitBoundsToMarkers: BooleanConstructor;
markers: ArrayConstructor;
polygons: ArrayConstructor;
polylines: ArrayConstructor;
circles: ArrayConstructor;
rectangles: ArrayConstructor;
options: ObjectConstructor;
extra: ObjectConstructor;
};
centerValue: Point | null;
zoomValue: number | null;
minZoomValue: number | null;
maxZoomValue: number | null;
fitBoundsToMarkersValue: boolean;
markersValue: Array<MarkerDefinition<BridgeMarkerOptions, BridgeInfoWindowOptions>>;
polygonsValue: Array<PolygonDefinition<BridgePolygonOptions, BridgeInfoWindowOptions>>;
polylinesValue: Array<PolylineDefinition<BridgePolylineOptions, BridgeInfoWindowOptions>>;
circlesValue: Array<CircleDefinition<BridgeCircleOptions, BridgeInfoWindowOptions>>;
rectanglesValue: Array<RectangleDefinition<BridgeRectangleOptions, BridgeInfoWindowOptions>>;
optionsValue: MapOptions;
extraValue: Record<string, unknown>;
hasCenterValue: boolean;
hasZoomValue: boolean;
hasMinZoomValue: boolean;
hasMaxZoomValue: boolean;
hasFitBoundsToMarkersValue: boolean;
hasMarkersValue: boolean;
hasPolygonsValue: boolean;
hasPolylinesValue: boolean;
hasCirclesValue: boolean;
hasRectanglesValue: boolean;
hasOptionsValue: boolean;
hasExtraValue: boolean;
protected map: BridgeMap;
protected markers: Map<string, BridgeMarker>;
protected polygons: Map<string, BridgePolygon>;
protected polylines: Map<string, BridgePolyline>;
protected circles: Map<string, BridgeCircle>;
protected rectangles: Map<string, BridgeRectangle>;
protected infoWindows: Array<BridgeInfoWindow>;
private isConnected;
private createMarker;
private createPolygon;
private createPolyline;
private createCircle;
private createRectangle;
protected abstract dispatchEvent(name: string, payload: Record<string, unknown>): void;
connect(): void;
createInfoWindow({ definition, element, }: {
definition: Omit<InfoWindowDefinition<BridgeInfoWindowOptions>, 'position'>;
element: BridgeMarker | BridgePolygon | BridgePolyline | BridgeCircle | BridgeRectangle;
}): BridgeInfoWindow;
abstract centerValueChanged(): void;
abstract zoomValueChanged(): void;
abstract minZoomValueChanged(): void;
abstract maxZoomValueChanged(): void;
markersValueChanged(): void;
polygonsValueChanged(): void;
polylinesValueChanged(): void;
circlesValueChanged(): void;
rectanglesValueChanged(): void;
protected abstract doCreateMap({ definition }: {
definition: MapDefinition<MapOptions, BridgeMapOptions>;
}): BridgeMap;
protected abstract doFitBoundsToMarkers(): void;
protected abstract doCreateMarker({ definition }: {
definition: MarkerDefinition<BridgeMarkerOptions, BridgeInfoWindowOptions>;
}): BridgeMarker;
protected abstract doRemoveMarker(marker: BridgeMarker): void;
protected abstract doCreatePolygon({ definition }: {
definition: PolygonDefinition<BridgePolygonOptions, BridgeInfoWindowOptions>;
}): BridgePolygon;
protected abstract doRemovePolygon(polygon: BridgePolygon): void;
protected abstract doCreatePolyline({ definition }: {
definition: PolylineDefinition<BridgePolylineOptions, BridgeInfoWindowOptions>;
}): BridgePolyline;
protected abstract doRemovePolyline(polyline: BridgePolyline): void;
protected abstract doCreateCircle({ definition }: {
definition: CircleDefinition<BridgeCircleOptions, BridgeInfoWindowOptions>;
}): BridgeCircle;
protected abstract doRemoveCircle(circle: BridgeCircle): void;
protected abstract doCreateRectangle({ definition }: {
definition: RectangleDefinition<BridgeRectangleOptions, BridgeInfoWindowOptions>;
}): BridgeRectangle;
protected abstract doRemoveRectangle(rectangle: BridgeRectangle): void;
protected abstract doCreateInfoWindow({ definition, element, }: {
definition: Omit<InfoWindowDefinition<BridgeInfoWindowOptions>, 'position'>;
element: BridgeMarker | BridgePolygon | BridgePolyline | BridgeCircle | BridgeRectangle;
}): BridgeInfoWindow;
protected abstract doCreateIcon({ definition, element }: {
definition: Icon;
element: BridgeMarker;
}): void;
private createDrawingFactory;
private onDrawChanged;
static values: {
providerOptions: ObjectConstructor;
center: ObjectConstructor;
zoom: NumberConstructor;
minZoom: NumberConstructor;
maxZoom: NumberConstructor;
fitBoundsToMarkers: BooleanConstructor;
markers: ArrayConstructor;
polygons: ArrayConstructor;
polylines: ArrayConstructor;
circles: ArrayConstructor;
rectangles: ArrayConstructor;
options: ObjectConstructor;
extra: ObjectConstructor;
};
centerValue: Point | null;
zoomValue: number | null;
minZoomValue: number | null;
maxZoomValue: number | null;
fitBoundsToMarkersValue: boolean;
markersValue: Array<MarkerDefinition<BridgeMarkerOptions, BridgeInfoWindowOptions>>;
polygonsValue: Array<PolygonDefinition<BridgePolygonOptions, BridgeInfoWindowOptions>>;
polylinesValue: Array<PolylineDefinition<BridgePolylineOptions, BridgeInfoWindowOptions>>;
circlesValue: Array<CircleDefinition<BridgeCircleOptions, BridgeInfoWindowOptions>>;
rectanglesValue: Array<RectangleDefinition<BridgeRectangleOptions, BridgeInfoWindowOptions>>;
optionsValue: MapOptions;
extraValue: Record<string, unknown>;
hasCenterValue: boolean;
hasZoomValue: boolean;
hasMinZoomValue: boolean;
hasMaxZoomValue: boolean;
hasFitBoundsToMarkersValue: boolean;
hasMarkersValue: boolean;
hasPolygonsValue: boolean;
hasPolylinesValue: boolean;
hasCirclesValue: boolean;
hasRectanglesValue: boolean;
hasOptionsValue: boolean;
hasExtraValue: boolean;
protected map: BridgeMap;
protected markers: Map<string, BridgeMarker>;
protected polygons: Map<string, BridgePolygon>;
protected polylines: Map<string, BridgePolyline>;
protected circles: Map<string, BridgeCircle>;
protected rectangles: Map<string, BridgeRectangle>;
protected infoWindows: Array<BridgeInfoWindow>;
private isConnected;
private createMarker;
private createPolygon;
private createPolyline;
private createCircle;
private createRectangle;
protected abstract dispatchEvent(name: string, payload: Record<string, unknown>): void;
connect(): void;
createInfoWindow({
definition,
element
}: {
definition: Omit<InfoWindowDefinition<BridgeInfoWindowOptions>, 'position'>;
element: BridgeMarker | BridgePolygon | BridgePolyline | BridgeCircle | BridgeRectangle;
}): BridgeInfoWindow;
abstract centerValueChanged(): void;
abstract zoomValueChanged(): void;
abstract minZoomValueChanged(): void;
abstract maxZoomValueChanged(): void;
markersValueChanged(): void;
polygonsValueChanged(): void;
polylinesValueChanged(): void;
circlesValueChanged(): void;
rectanglesValueChanged(): void;
protected abstract doCreateMap({
definition
}: {
definition: MapDefinition<MapOptions, BridgeMapOptions>;
}): BridgeMap;
protected abstract doFitBoundsToMarkers(): void;
protected abstract doCreateMarker({
definition
}: {
definition: MarkerDefinition<BridgeMarkerOptions, BridgeInfoWindowOptions>;
}): BridgeMarker;
protected abstract doRemoveMarker(marker: BridgeMarker): void;
protected abstract doCreatePolygon({
definition
}: {
definition: PolygonDefinition<BridgePolygonOptions, BridgeInfoWindowOptions>;
}): BridgePolygon;
protected abstract doRemovePolygon(polygon: BridgePolygon): void;
protected abstract doCreatePolyline({
definition
}: {
definition: PolylineDefinition<BridgePolylineOptions, BridgeInfoWindowOptions>;
}): BridgePolyline;
protected abstract doRemovePolyline(polyline: BridgePolyline): void;
protected abstract doCreateCircle({
definition
}: {
definition: CircleDefinition<BridgeCircleOptions, BridgeInfoWindowOptions>;
}): BridgeCircle;
protected abstract doRemoveCircle(circle: BridgeCircle): void;
protected abstract doCreateRectangle({
definition
}: {
definition: RectangleDefinition<BridgeRectangleOptions, BridgeInfoWindowOptions>;
}): BridgeRectangle;
protected abstract doRemoveRectangle(rectangle: BridgeRectangle): void;
protected abstract doCreateInfoWindow({
definition,
element
}: {
definition: Omit<InfoWindowDefinition<BridgeInfoWindowOptions>, 'position'>;
element: BridgeMarker | BridgePolygon | BridgePolyline | BridgeCircle | BridgeRectangle;
}): BridgeInfoWindow;
protected abstract doCreateIcon({
definition,
element
}: {
definition: Icon;
element: BridgeMarker;
}): void;
private createDrawingFactory;
private onDrawChanged;
}
export { type CircleDefinition, type Icon, IconTypes, type Identifier, type InfoWindowDefinition, type MapDefinition, type MarkerDefinition, type Point, type PolygonDefinition, type PolylineDefinition, type RectangleDefinition, type WithIdentifier, export_default as default };
export { CircleDefinition, Icon, IconTypes, Identifier, InfoWindowDefinition, MapDefinition, MarkerDefinition, Point, PolygonDefinition, PolylineDefinition, RectangleDefinition, WithIdentifier, export_default as default };

View File

@@ -1,153 +1,145 @@
// src/abstract_map_controller.ts
import { Controller } from "@hotwired/stimulus";
var IconTypes = {
Url: "url",
Svg: "svg",
UxIcon: "ux-icon"
const IconTypes = {
Url: "url",
Svg: "svg",
UxIcon: "ux-icon"
};
var abstract_map_controller_default = class extends Controller {
constructor() {
super(...arguments);
this.markers = /* @__PURE__ */ new Map();
this.polygons = /* @__PURE__ */ new Map();
this.polylines = /* @__PURE__ */ new Map();
this.circles = /* @__PURE__ */ new Map();
this.rectangles = /* @__PURE__ */ new Map();
this.infoWindows = [];
this.isConnected = false;
}
connect() {
const extra = this.hasExtraValue ? this.extraValue : {};
const mapDefinition = {
center: this.hasCenterValue ? this.centerValue : null,
zoom: this.hasZoomValue ? this.zoomValue : null,
minZoom: this.hasMinZoomValue ? this.minZoomValue : null,
maxZoom: this.hasMaxZoomValue ? this.maxZoomValue : null,
options: this.optionsValue,
extra
};
this.dispatchEvent("pre-connect", mapDefinition);
this.createMarker = this.createDrawingFactory("marker", this.markers, this.doCreateMarker.bind(this));
this.createPolygon = this.createDrawingFactory("polygon", this.polygons, this.doCreatePolygon.bind(this));
this.createPolyline = this.createDrawingFactory("polyline", this.polylines, this.doCreatePolyline.bind(this));
this.createCircle = this.createDrawingFactory("circle", this.circles, this.doCreateCircle.bind(this));
this.createRectangle = this.createDrawingFactory("rectangle", this.rectangles, this.doCreateRectangle.bind(this));
this.map = this.doCreateMap({ definition: mapDefinition });
this.markersValue.forEach((definition) => this.createMarker({ definition }));
this.polygonsValue.forEach((definition) => this.createPolygon({ definition }));
this.polylinesValue.forEach((definition) => this.createPolyline({ definition }));
this.circlesValue.forEach((definition) => this.createCircle({ definition }));
this.rectanglesValue.forEach((definition) => this.createRectangle({ definition }));
if (this.fitBoundsToMarkersValue) {
this.doFitBoundsToMarkers();
}
this.dispatchEvent("connect", {
map: this.map,
markers: [...this.markers.values()],
polygons: [...this.polygons.values()],
polylines: [...this.polylines.values()],
circles: [...this.circles.values()],
rectangles: [...this.rectangles.values()],
infoWindows: this.infoWindows,
extra
});
this.isConnected = true;
}
//region Public API
createInfoWindow({
definition,
element
}) {
this.dispatchEvent("info-window:before-create", { definition, element });
const infoWindow = this.doCreateInfoWindow({ definition, element });
this.dispatchEvent("info-window:after-create", { infoWindow, definition, element });
this.infoWindows.push(infoWindow);
return infoWindow;
}
markersValueChanged() {
if (!this.isConnected) {
return;
}
this.onDrawChanged(this.markers, this.markersValue, this.createMarker, this.doRemoveMarker);
if (this.fitBoundsToMarkersValue) {
this.doFitBoundsToMarkers();
}
}
polygonsValueChanged() {
if (!this.isConnected) {
return;
}
this.onDrawChanged(this.polygons, this.polygonsValue, this.createPolygon, this.doRemovePolygon);
}
polylinesValueChanged() {
if (!this.isConnected) {
return;
}
this.onDrawChanged(this.polylines, this.polylinesValue, this.createPolyline, this.doRemovePolyline);
}
circlesValueChanged() {
if (!this.isConnected) {
return;
}
this.onDrawChanged(this.circles, this.circlesValue, this.createCircle, this.doRemoveCircle);
}
rectanglesValueChanged() {
if (!this.isConnected) {
return;
}
this.onDrawChanged(this.rectangles, this.rectanglesValue, this.createRectangle, this.doRemoveRectangle);
}
createDrawingFactory(type, draws, factory) {
const eventBefore = `${type}:before-create`;
const eventAfter = `${type}:after-create`;
return ({ definition }) => {
this.dispatchEvent(eventBefore, { definition });
if (typeof definition.rawOptions !== "undefined") {
console.warn(
`[Symfony UX Map] The event "${eventBefore}" added a deprecated "rawOptions" property to the definition, it will be removed in a next major version, replace it with "bridgeOptions" instead.`,
definition
);
}
const drawing = factory({ definition });
this.dispatchEvent(eventAfter, { [type]: drawing, definition });
draws.set(definition["@id"], drawing);
return drawing;
};
}
onDrawChanged(draws, newDrawDefinitions, factory, remover) {
const idsToRemove = new Set(draws.keys());
newDrawDefinitions.forEach((definition) => {
idsToRemove.delete(definition["@id"]);
});
idsToRemove.forEach((id) => {
const draw = draws.get(id);
remover(draw);
draws.delete(id);
});
newDrawDefinitions.forEach((definition) => {
if (!draws.has(definition["@id"])) {
factory({ definition });
}
});
}
//endregion
var _Class = class extends Controller {
constructor(..._args) {
super(..._args);
this.markers = /* @__PURE__ */ new Map();
this.polygons = /* @__PURE__ */ new Map();
this.polylines = /* @__PURE__ */ new Map();
this.circles = /* @__PURE__ */ new Map();
this.rectangles = /* @__PURE__ */ new Map();
this.infoWindows = [];
this.isConnected = false;
}
connect() {
const extra = this.hasExtraValue ? this.extraValue : {};
const mapDefinition = {
center: this.hasCenterValue ? this.centerValue : null,
zoom: this.hasZoomValue ? this.zoomValue : null,
minZoom: this.hasMinZoomValue ? this.minZoomValue : null,
maxZoom: this.hasMaxZoomValue ? this.maxZoomValue : null,
options: this.optionsValue,
extra
};
this.dispatchEvent("pre-connect", mapDefinition);
this.createMarker = this.createDrawingFactory("marker", this.markers, this.doCreateMarker.bind(this));
this.createPolygon = this.createDrawingFactory("polygon", this.polygons, this.doCreatePolygon.bind(this));
this.createPolyline = this.createDrawingFactory("polyline", this.polylines, this.doCreatePolyline.bind(this));
this.createCircle = this.createDrawingFactory("circle", this.circles, this.doCreateCircle.bind(this));
this.createRectangle = this.createDrawingFactory("rectangle", this.rectangles, this.doCreateRectangle.bind(this));
this.map = this.doCreateMap({ definition: mapDefinition });
this.markersValue.forEach((definition) => {
this.createMarker({ definition });
});
this.polygonsValue.forEach((definition) => {
this.createPolygon({ definition });
});
this.polylinesValue.forEach((definition) => {
this.createPolyline({ definition });
});
this.circlesValue.forEach((definition) => {
this.createCircle({ definition });
});
this.rectanglesValue.forEach((definition) => {
this.createRectangle({ definition });
});
if (this.fitBoundsToMarkersValue) this.doFitBoundsToMarkers();
this.dispatchEvent("connect", {
map: this.map,
markers: [...this.markers.values()],
polygons: [...this.polygons.values()],
polylines: [...this.polylines.values()],
circles: [...this.circles.values()],
rectangles: [...this.rectangles.values()],
infoWindows: this.infoWindows,
extra
});
this.isConnected = true;
}
createInfoWindow({ definition, element }) {
this.dispatchEvent("info-window:before-create", {
definition,
element
});
const infoWindow = this.doCreateInfoWindow({
definition,
element
});
this.dispatchEvent("info-window:after-create", {
infoWindow,
definition,
element
});
this.infoWindows.push(infoWindow);
return infoWindow;
}
markersValueChanged() {
if (!this.isConnected) return;
this.onDrawChanged(this.markers, this.markersValue, this.createMarker, this.doRemoveMarker);
if (this.fitBoundsToMarkersValue) this.doFitBoundsToMarkers();
}
polygonsValueChanged() {
if (!this.isConnected) return;
this.onDrawChanged(this.polygons, this.polygonsValue, this.createPolygon, this.doRemovePolygon);
}
polylinesValueChanged() {
if (!this.isConnected) return;
this.onDrawChanged(this.polylines, this.polylinesValue, this.createPolyline, this.doRemovePolyline);
}
circlesValueChanged() {
if (!this.isConnected) return;
this.onDrawChanged(this.circles, this.circlesValue, this.createCircle, this.doRemoveCircle);
}
rectanglesValueChanged() {
if (!this.isConnected) return;
this.onDrawChanged(this.rectangles, this.rectanglesValue, this.createRectangle, this.doRemoveRectangle);
}
createDrawingFactory(type, draws, factory) {
const eventBefore = `${type}:before-create`;
const eventAfter = `${type}:after-create`;
return ({ definition }) => {
this.dispatchEvent(eventBefore, { definition });
if (typeof definition.rawOptions !== "undefined") console.warn(`[Symfony UX Map] The event "${eventBefore}" added a deprecated "rawOptions" property to the definition, it will be removed in a next major version, replace it with "bridgeOptions" instead.`, definition);
const drawing = factory({ definition });
this.dispatchEvent(eventAfter, {
[type]: drawing,
definition
});
draws.set(definition["@id"], drawing);
return drawing;
};
}
onDrawChanged(draws, newDrawDefinitions, factory, remover) {
const idsToRemove = new Set(draws.keys());
newDrawDefinitions.forEach((definition) => {
idsToRemove.delete(definition["@id"]);
});
idsToRemove.forEach((id) => {
remover(draws.get(id));
draws.delete(id);
});
newDrawDefinitions.forEach((definition) => {
if (!draws.has(definition["@id"])) factory({ definition });
});
}
};
abstract_map_controller_default.values = {
providerOptions: Object,
center: Object,
zoom: Number,
minZoom: Number,
maxZoom: Number,
fitBoundsToMarkers: Boolean,
markers: Array,
polygons: Array,
polylines: Array,
circles: Array,
rectangles: Array,
options: Object,
extra: Object
};
export {
IconTypes,
abstract_map_controller_default as default
_Class.values = {
providerOptions: Object,
center: Object,
zoom: Number,
minZoom: Number,
maxZoom: Number,
fitBoundsToMarkers: Boolean,
markers: Array,
polygons: Array,
polylines: Array,
circles: Array,
rectangles: Array,
options: Object,
extra: Object
};
export { IconTypes, _Class as default };

View File

@@ -3,7 +3,7 @@
"description": "Easily embed interactive maps in your Symfony application.",
"private": true,
"license": "MIT",
"version": "2.28.1",
"version": "2.34.0",
"keywords": [
"symfony-ux",
"map",
@@ -11,7 +11,7 @@
"maps"
],
"homepage": "https://ux.symfony.com/map",
"repository": "https://github.com/symfony/ux-map",
"repository": "https://github.com/symfony/ux",
"type": "module",
"files": [
"dist"
@@ -19,11 +19,10 @@
"main": "dist/abstract_map_controller.js",
"types": "dist/abstract_map_controller.d.ts",
"scripts": {
"build": "tsx ../../../bin/build_package.ts .",
"watch": "tsx ../../../bin/build_package.ts . --watch",
"test": "../../../bin/test_package.sh .",
"check": "biome check",
"ci": "biome ci"
"build": "node ../../../bin/build_package.ts .",
"watch": "node ../../../bin/build_package.ts . --watch",
"test": "pnpm run test:unit",
"test:unit": "../../../bin/unit_test_package.sh ."
},
"symfony": {
"needsPackageAsADependency": false,
@@ -41,8 +40,7 @@
"@testing-library/user-event": "^14.6.1",
"jsdom": "^26.1.0",
"tslib": "^2.8.1",
"tsx": "^4.20.3",
"typescript": "^5.8.3",
"vitest": "^3.2.4"
"vitest": "^4.1.0"
}
}

View File

@@ -65,7 +65,7 @@ export type MarkerDefinition<BridgeMarkerOptions, BridgeInfoWindowOptions> = Wit
infoWindow?: Omit<InfoWindowDefinition<BridgeInfoWindowOptions>, 'position'>;
icon?: Icon;
/**
* @deprecated Use "bridgeOptions" instead.
* @deprecated since Symfony UX Map 2.27, use "bridgeOptions" instead.
* Raw options passed to the marker constructor, specific to the map provider (e.g.: `L.marker()` for Leaflet).
*/
rawOptions?: BridgeMarkerOptions;
@@ -86,9 +86,12 @@ export type MarkerDefinition<BridgeMarkerOptions, BridgeInfoWindowOptions> = Wit
export type PolygonDefinition<BridgePolygonOptions, BridgeInfoWindowOptions> = WithIdentifier<{
infoWindow?: Omit<InfoWindowDefinition<BridgeInfoWindowOptions>, 'position'>;
points: Array<Point> | Array<Array<Point>>;
/**
* @deprecated since Symfony UX Map 2.29, use "infoWindow" instead
*/
title: string | null;
/**
* @deprecated Use "bridgeOptions" instead.
* @deprecated since Symfony UX Map 2.27, use "bridgeOptions" instead.
* Raw options passed to the polygon constructor, specific to the map provider (e.g.: `L.polygon()` for Leaflet).
*/
rawOptions?: BridgePolygonOptions;
@@ -109,9 +112,12 @@ export type PolygonDefinition<BridgePolygonOptions, BridgeInfoWindowOptions> = W
export type PolylineDefinition<BridgePolylineOptions, BridgeInfoWindowOptions> = WithIdentifier<{
infoWindow?: Omit<InfoWindowDefinition<BridgeInfoWindowOptions>, 'position'>;
points: Array<Point>;
/**
* @deprecated since Symfony UX Map 2.29, use "infoWindow" instead
*/
title: string | null;
/**
* @deprecated Use "bridgeOptions" instead.
* @deprecated since Symfony UX Map 2.27, use "bridgeOptions" instead.
* Raw options passed to the polyline constructor, specific to the map provider (e.g.: `L.polyline()` for Leaflet).
*/
rawOptions?: BridgePolylineOptions;
@@ -133,9 +139,12 @@ export type CircleDefinition<BridgeCircleOptions, BridgeInfoWindowOptions> = Wit
infoWindow?: Omit<InfoWindowDefinition<BridgeInfoWindowOptions>, 'position'>;
center: Point;
radius: number;
/**
* @deprecated since Symfony UX Map 2.29, use "infoWindow" instead
*/
title: string | null;
/**
* @deprecated Use "bridgeOptions" instead.
* @deprecated since Symfony UX Map 2.27, use "bridgeOptions" instead.
* Raw options passed to the circle constructor, specific to the map provider (e.g.: `L.circle()` for Leaflet).
*/
rawOptions?: BridgeCircleOptions;
@@ -157,9 +166,12 @@ export type RectangleDefinition<BridgeRectangleOptions, BridgeInfoWindowOptions>
infoWindow?: Omit<InfoWindowDefinition<BridgeInfoWindowOptions>, 'position'>;
southWest: Point;
northEast: Point;
/**
* @deprecated since Symfony UX Map 2.29, use "infoWindow" instead
*/
title: string | null;
/**
* @deprecated Use "bridgeOptions" instead.
* @deprecated since Symfony UX Map 2.27, use "bridgeOptions" instead.
* Raw options passed to the rectangle constructor, specific to the map provider (e.g.: `L.rectangle()` for Leaflet).
*/
rawOptions?: BridgeRectangleOptions;
@@ -184,7 +196,7 @@ export type InfoWindowDefinition<BridgeInfoWindowOptions> = {
opened: boolean;
autoClose: boolean;
/**
* @deprecated Use "bridgeOptions" instead.
* @deprecated since Symfony UX Map 2.27, use "bridgeOptions" instead.
* Raw options passed to the info window constructor, specific to the map provider (e.g.: `google.maps.InfoWindow()` for Google Maps).
*/
rawOptions?: BridgeInfoWindowOptions;
@@ -270,11 +282,31 @@ export default abstract class<
protected infoWindows: Array<BridgeInfoWindow> = [];
private isConnected = false;
private createMarker: ({ definition }: { definition: MarkerDefinition<BridgeMarkerOptions, BridgeInfoWindowOptions> }) => BridgeMarker;
private createPolygon: ({ definition }: { definition: PolygonDefinition<BridgePolygonOptions, BridgeInfoWindowOptions> }) => BridgePolygon;
private createPolyline: ({ definition }: { definition: PolylineDefinition<BridgePolylineOptions, BridgeInfoWindowOptions> }) => BridgePolyline;
private createCircle: ({ definition }: { definition: CircleDefinition<BridgeCircleOptions, BridgeInfoWindowOptions> }) => BridgeCircle;
private createRectangle: ({ definition }: { definition: RectangleDefinition<BridgeRectangleOptions, BridgeInfoWindowOptions> }) => BridgeRectangle;
private createMarker: ({
definition,
}: {
definition: MarkerDefinition<BridgeMarkerOptions, BridgeInfoWindowOptions>;
}) => BridgeMarker;
private createPolygon: ({
definition,
}: {
definition: PolygonDefinition<BridgePolygonOptions, BridgeInfoWindowOptions>;
}) => BridgePolygon;
private createPolyline: ({
definition,
}: {
definition: PolylineDefinition<BridgePolylineOptions, BridgeInfoWindowOptions>;
}) => BridgePolyline;
private createCircle: ({
definition,
}: {
definition: CircleDefinition<BridgeCircleOptions, BridgeInfoWindowOptions>;
}) => BridgeCircle;
private createRectangle: ({
definition,
}: {
definition: RectangleDefinition<BridgeRectangleOptions, BridgeInfoWindowOptions>;
}) => BridgeRectangle;
protected abstract dispatchEvent(name: string, payload: Record<string, unknown>): void;
@@ -294,14 +326,28 @@ export default abstract class<
this.createPolygon = this.createDrawingFactory('polygon', this.polygons, this.doCreatePolygon.bind(this));
this.createPolyline = this.createDrawingFactory('polyline', this.polylines, this.doCreatePolyline.bind(this));
this.createCircle = this.createDrawingFactory('circle', this.circles, this.doCreateCircle.bind(this));
this.createRectangle = this.createDrawingFactory('rectangle', this.rectangles, this.doCreateRectangle.bind(this));
this.createRectangle = this.createDrawingFactory(
'rectangle',
this.rectangles,
this.doCreateRectangle.bind(this)
);
this.map = this.doCreateMap({ definition: mapDefinition });
this.markersValue.forEach((definition) => this.createMarker({ definition }));
this.polygonsValue.forEach((definition) => this.createPolygon({ definition }));
this.polylinesValue.forEach((definition) => this.createPolyline({ definition }));
this.circlesValue.forEach((definition) => this.createCircle({ definition }));
this.rectanglesValue.forEach((definition) => this.createRectangle({ definition }));
this.markersValue.forEach((definition) => {
this.createMarker({ definition });
});
this.polygonsValue.forEach((definition) => {
this.createPolygon({ definition });
});
this.polylinesValue.forEach((definition) => {
this.createPolyline({ definition });
});
this.circlesValue.forEach((definition) => {
this.createCircle({ definition });
});
this.rectanglesValue.forEach((definition) => {
this.createRectangle({ definition });
});
if (this.fitBoundsToMarkersValue) {
this.doFitBoundsToMarkers();
@@ -396,27 +442,51 @@ export default abstract class<
//endregion
//region Abstract factory methods to be implemented by the concrete classes, they are specific to the map provider
protected abstract doCreateMap({ definition }: { definition: MapDefinition<MapOptions, BridgeMapOptions> }): BridgeMap;
protected abstract doCreateMap({
definition,
}: {
definition: MapDefinition<MapOptions, BridgeMapOptions>;
}): BridgeMap;
protected abstract doFitBoundsToMarkers(): void;
protected abstract doCreateMarker({ definition }: { definition: MarkerDefinition<BridgeMarkerOptions, BridgeInfoWindowOptions> }): BridgeMarker;
protected abstract doCreateMarker({
definition,
}: {
definition: MarkerDefinition<BridgeMarkerOptions, BridgeInfoWindowOptions>;
}): BridgeMarker;
protected abstract doRemoveMarker(marker: BridgeMarker): void;
protected abstract doCreatePolygon({ definition }: { definition: PolygonDefinition<BridgePolygonOptions, BridgeInfoWindowOptions> }): BridgePolygon;
protected abstract doCreatePolygon({
definition,
}: {
definition: PolygonDefinition<BridgePolygonOptions, BridgeInfoWindowOptions>;
}): BridgePolygon;
protected abstract doRemovePolygon(polygon: BridgePolygon): void;
protected abstract doCreatePolyline({ definition }: { definition: PolylineDefinition<BridgePolylineOptions, BridgeInfoWindowOptions> }): BridgePolyline;
protected abstract doCreatePolyline({
definition,
}: {
definition: PolylineDefinition<BridgePolylineOptions, BridgeInfoWindowOptions>;
}): BridgePolyline;
protected abstract doRemovePolyline(polyline: BridgePolyline): void;
protected abstract doCreateCircle({ definition }: { definition: CircleDefinition<BridgeCircleOptions, BridgeInfoWindowOptions> }): BridgeCircle;
protected abstract doCreateCircle({
definition,
}: {
definition: CircleDefinition<BridgeCircleOptions, BridgeInfoWindowOptions>;
}): BridgeCircle;
protected abstract doRemoveCircle(circle: BridgeCircle): void;
protected abstract doCreateRectangle({ definition }: { definition: RectangleDefinition<BridgeRectangleOptions, BridgeInfoWindowOptions> }): BridgeRectangle;
protected abstract doCreateRectangle({
definition,
}: {
definition: RectangleDefinition<BridgeRectangleOptions, BridgeInfoWindowOptions>;
}): BridgeRectangle;
protected abstract doRemoveRectangle(rectangle: BridgeRectangle): void;
@@ -432,11 +502,31 @@ export default abstract class<
//endregion
//region Private APIs
private createDrawingFactory(type: 'marker', draws: typeof this.markers, factory: typeof this.doCreateMarker): typeof this.doCreateMarker;
private createDrawingFactory(type: 'polygon', draws: typeof this.polygons, factory: typeof this.doCreatePolygon): typeof this.doCreatePolygon;
private createDrawingFactory(type: 'polyline', draws: typeof this.polylines, factory: typeof this.doCreatePolyline): typeof this.doCreatePolyline;
private createDrawingFactory(type: 'circle', draws: typeof this.circles, factory: typeof this.doCreateCircle): typeof this.doCreateCircle;
private createDrawingFactory(type: 'rectangle', draws: typeof this.rectangles, factory: typeof this.doCreateRectangle): typeof this.doCreateRectangle;
private createDrawingFactory(
type: 'marker',
draws: typeof this.markers,
factory: typeof this.doCreateMarker
): typeof this.doCreateMarker;
private createDrawingFactory(
type: 'polygon',
draws: typeof this.polygons,
factory: typeof this.doCreatePolygon
): typeof this.doCreatePolygon;
private createDrawingFactory(
type: 'polyline',
draws: typeof this.polylines,
factory: typeof this.doCreatePolyline
): typeof this.doCreatePolyline;
private createDrawingFactory(
type: 'circle',
draws: typeof this.circles,
factory: typeof this.doCreateCircle
): typeof this.doCreateCircle;
private createDrawingFactory(
type: 'rectangle',
draws: typeof this.rectangles,
factory: typeof this.doCreateRectangle
): typeof this.doCreateRectangle;
private createDrawingFactory<
Factory extends
| typeof this.doCreateMarker
@@ -445,7 +535,11 @@ export default abstract class<
| typeof this.doCreateCircle
| typeof this.doCreateRectangle,
Draw extends ReturnType<Factory>,
>(type: 'marker' | 'polygon' | 'polyline' | 'circle' | 'rectangle', draws: globalThis.Map<WithIdentifier<any>, Draw>, factory: Factory): Factory {
>(
type: 'marker' | 'polygon' | 'polyline' | 'circle' | 'rectangle',
draws: globalThis.Map<WithIdentifier<any>, Draw>,
factory: Factory
): Factory {
const eventBefore = `${type}:before-create`;
const eventAfter = `${type}:after-create`;

View File

@@ -1,8 +1,8 @@
import { Application } from '@hotwired/stimulus';
import { getByTestId, waitFor } from '@testing-library/dom';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { clearDOM, mountDOM } from '../../../../test/stimulus-helpers';
import AbstractMapController from '../src/abstract_map_controller.ts';
import { clearDOM, mountDOM } from '../../../../../test/stimulus-helpers';
import AbstractMapController from '../../src/abstract_map_controller.ts';
class MyMapController extends AbstractMapController {
protected dispatchEvent(name: string, payload: Record<string, unknown> = {}): void {

3
assets/tsconfig.json Normal file
View File

@@ -0,0 +1,3 @@
{
"extends": "../../../tsconfig.package.json"
}

4
assets/vitest.config.mjs Normal file
View File

@@ -0,0 +1,4 @@
import { mergeConfig } from 'vitest/config';
import configShared from '../../../vitest.config.base.mjs';
export default mergeConfig(configShared, {});

View File

@@ -36,13 +36,13 @@
"symfony/stimulus-bundle": "^2.18.1"
},
"require-dev": {
"symfony/asset-mapper": "^6.4|^7.0",
"symfony/framework-bundle": "^6.4|^7.0",
"symfony/phpunit-bridge": "^7.2",
"symfony/twig-bundle": "^6.4|^7.0",
"symfony/ux-twig-component": "^2.18",
"symfony/asset-mapper": "^6.4|^7.0|^8.0",
"symfony/framework-bundle": "^6.4|^7.0|^8.0",
"symfony/phpunit-bridge": "^7.2|^8.0",
"symfony/twig-bundle": "^6.4|^7.0|^8.0",
"symfony/ux-twig-component": "^2.18|^8.0",
"symfony/ux-icons": "^2.18",
"spatie/phpunit-snapshot-assertions": "^4.2.17",
"spatie/phpunit-snapshot-assertions": "^4.2.17|^5.2.3",
"phpunit/phpunit": "^9.6.22"
},
"conflict": {

View File

@@ -191,9 +191,9 @@ You can also add Polygons, which represents an area enclosed by a series of ``Po
`Polygon` with holes is available since UX Map 2.26.
Since UX Map 2.26, you can also create polygons with holes in them, by passing an array of `array<Point>` to `points` parameter::
Since UX Map 2.26, you can create polygons with holes by using an array of ``array<Point>``::
// Draw a polygon with a hole in it, on the French map
// Draw a polygon with a hole in it, on the France map
$map->addPolygon(new Polygon(points: [
// First path, the outer boundary of the polygon
[
@@ -203,7 +203,7 @@ Since UX Map 2.26, you can also create polygons with holes in them, by passing a
new Point(43.296482, 5.369780), // Marseille
new Point(44.837789, -0.579180), // Bordeaux
],
// Second path, it will make a hole in the previous one
// Second path, making a hole in the first path
[
new Point(45.833619, 1.261105), // Limoges
new Point(45.764043, 4.835659), // Lyon
@@ -237,7 +237,6 @@ You can add Circles, which represents a circular area defined by a center point
$map->addCircle(new Circle(
center: new Point(48.8566, 2.3522),
radius: 5_000, // 5km
title: 'Paris',
infoWindow: new InfoWindow(
content: 'A 5km radius circle centered on Paris',
),
@@ -251,7 +250,6 @@ You can add Rectangles, which represents a rectangular area defined by two corne
$map->addRectangle(new Rectangle(
southWest: new Point(48.8566, 2.3522), // Paris
northEast: new Point(50.6292, 3.0573), // Lille
title: 'Paris to Lille',
infoWindow: new InfoWindow(
content: 'A rectangle from Paris to Lille',
),
@@ -294,6 +292,22 @@ If you haven't stored the element instance, you can still remove them by passing
$map->removeCircle('my-circle');
$map->removeRectangle('my-rectangle');
To remove all instances of a certain element, you can use the `Map::removeAll*()` methods::
// Add elements
$map->addMarker($marker = new Marker(/* ... */));
$map->addPolygon($polygon = new Polygon(/* ... */));
$map->addPolyline($polyline = new Polyline(/* ... */));
$map->addCircle($circle = new Circle(/* ... */));
$map->addRectangle($rectangle = new Rectangle(/* ... */));
// And later, remove those elements
$map->removeAllMarkers();
$map->removeAllPolygons();
$map->removeAllPolylines();
$map->removeAllCircles();
$map->removeAllRectangles();
Render a map
------------
@@ -331,12 +345,17 @@ templates. The function accepts the same arguments as the ``Map`` class:
infoWindow: { content: 'Welcome to <b>New York</b>' }
},
],
fitBoundsToMarkers: true,
attributes: {
class: 'foo',
style: 'height: 800px; width: 100%; border: 4px solid red; margin-block: 10vh;',
}
) }}
.. versionadded:: 2.31
`fitBoundsToMarkers` option for the twig function is available since UX Map 2.31.
Twig Component ``<twig:ux:map />``
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -356,6 +375,7 @@ Alternatively, you can use the ``<twig:ux:map />`` component.
"infoWindow": {"content": "Welcome to <b>New York</b>"}
}
]'
:fitBoundsToMarkers="true",
class="foo"
style="height: 800px; width: 100%; border: 4px solid red; margin-block: 10vh;"
/>
@@ -366,6 +386,10 @@ The ``<twig:ux:map />`` component requires the `Twig Component`_ package.
$ composer require symfony/ux-twig-component
.. versionadded:: 2.31
`fitBoundsToMarkers` option for the twig component is available since UX Map 2.31.
Interact with the map
~~~~~~~~~~~~~~~~~~~~~
@@ -504,7 +528,7 @@ Symfony UX Map allows you to extend its default behavior using a custom Stimulus
*/
_onPolygonBeforeCreate(event) {
console.log(event.detail.definition);
// { title: 'My polygon', points: [ { lat: 48.8566, lng: 2.3522 }, { lat: 45.7640, lng: 4.8357 }, { lat: 43.2965, lng: 5.3698 }, ... ], ... }
// { points: [ { lat: 48.8566, lng: 2.3522 }, { lat: 45.7640, lng: 4.8357 }, { lat: 43.2965, lng: 5.3698 }, ... ], ... }
}
/**
@@ -522,7 +546,7 @@ Symfony UX Map allows you to extend its default behavior using a custom Stimulus
*/
_onPolylineBeforeCreate(event) {
console.log(event.detail.definition);
// { title: 'My polyline', points: [ { lat: 48.8566, lng: 2.3522 }, { lat: 45.7640, lng: 4.8357 }, { lat: 43.2965, lng: 5.3698 }, ... ], ... }
// { points: [ { lat: 48.8566, lng: 2.3522 }, { lat: 45.7640, lng: 4.8357 }, { lat: 43.2965, lng: 5.3698 }, ... ], ... }
}
/**
@@ -536,7 +560,7 @@ Symfony UX Map allows you to extend its default behavior using a custom Stimulus
_onCircleBeforeCreate(event) {
console.log(event.detail.definition);
// { title: 'My circle', center: { lat: 48.8566, lng: 2.3522 }, radius: 1000, ... }
// { center: { lat: 48.8566, lng: 2.3522 }, radius: 1000, ... }
}
_onCircleAfterCreate(event) {
@@ -546,7 +570,7 @@ Symfony UX Map allows you to extend its default behavior using a custom Stimulus
_onRectangleBeforeCreate(event) {
console.log(event.detail.definition);
// { title: 'My rectangle', southWest: { lat: 48.8566, lng: 2.3522 }, northEast: { lat: 45.7640, lng: 4.8357 }, ... }
// { southWest: { lat: 48.8566, lng: 2.3522 }, northEast: { lat: 45.7640, lng: 4.8357 }, ... }
}
_onRectangleAfterCreate(event) {
@@ -727,7 +751,7 @@ property available in ``Map``, ``Marker``, ``InfoWindow``, ``Polygon``, ``Polyli
));
On the JavaScript side, you can access these extra data by listening to ``ux:map:pre-connect``,
``ux:map:connect``, ``ux:map:*:before-create``, ``ux:map:*:after-create`` events::
``ux:map:connect``, ``ux:map:*:before-create``, ``ux:map:*:after-create`` events:
.. code-block:: javascript
@@ -842,6 +866,45 @@ You can retrieve the map instance using the ``getMap()`` method, and change the
</button>
</div>
Advanced: Clusters
------------------
.. versionadded:: 2.29
Clusters were added in UX Map 2.29.
A cluster is a group of points that are close to each other on a map.
Clustering reduces clutter and improves performance when displaying many points.
This makes maps easier to read and faster to render.
UX Map supports two algorithms:
- **Grid**: Fast, divides map into cells.
- **Morton**: Uses Z-order curves for spatial locality.
Create a clustering algorithm, cluster your points, and add cluster markers::
use Symfony\UX\Map\Cluster\GridClusteringAlgorithm;
use Symfony\UX\Map\Cluster\MortonClusteringAlgorithm;
use Symfony\UX\Map\Point;
// Initialize clustering algorithm
$clusteringAlgorithm = new GridClusteringAlgorithm();
// or
// $clusteringAlgorithm = new MortonClusteringAlgorithm();
// Create clusters of points
$points = [new Point(48.8566, 2.3522), new Point(45.7640, 4.8357), /* ... */];
$clusters = $clusteringAlgorithm->cluster($points, zoom: 5.0);
// Iterate over each cluster
foreach ($clusters as $cluster) {
$cluster->getCenter(); // A Point, representing the cluster center
$cluster->getPoints(); // A list of Point
$cluster->count(); // The number of points in the cluster
}
Backward Compatibility promise
------------------------------

38
phpunit11.dist.xml Normal file
View File

@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- https://phpunit.de/manual/current/en/appendixes.configuration.html -->
<phpunit
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
colors="true"
bootstrap="tests/bootstrap.php"
failOnDeprecation="true"
failOnRisky="true"
failOnWarning="true"
cacheDirectory=".phpunit.cache"
>
<php>
<ini name="error_reporting" value="-1"/>
<env name="SHELL_VERBOSITY" value="-1"/>
</php>
<testsuites>
<testsuite name="Symfony UX Map Test Suite">
<directory>./tests</directory>
</testsuite>
</testsuites>
<source
ignoreSuppressionOfDeprecations="true"
ignoreIndirectDeprecations="true"
restrictNotices="true"
restrictWarnings="true"
>
<include>
<directory>src</directory>
</include>
<deprecationTrigger>
<function>trigger_deprecation</function>
</deprecationTrigger>
</source>
</phpunit>

View File

@@ -34,6 +34,10 @@ final class Circle implements Element
public readonly array $extra = [],
public readonly ?string $id = null,
) {
if (null !== $title) {
trigger_deprecation('symfony/ux-map', '2.30', 'The "title" parameter is deprecated and will be removed in 3.0. Use "infoWindow" instead.');
}
if ($radius <= 0) {
throw new InvalidArgumentException(\sprintf('Radius must be greater than 0, "%s" given.', $radius));
}

81
src/Cluster/Cluster.php Normal file
View File

@@ -0,0 +1,81 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\Map\Cluster;
use Symfony\UX\Map\Point;
/**
* Cluster representation.
*
* @implements \IteratorAggregate<int, Point>
*
* @author Simon André <smn.andre@gmail.com>
*/
final class Cluster implements \Countable, \IteratorAggregate
{
/**
* @var Point[]
*/
private array $points = [];
private float $sumLat = 0.0;
private float $sumLng = 0.0;
private int $count = 0;
/**
* Initializes the cluster with an initial point.
*/
public function __construct(Point $initialPoint)
{
$this->addPoint($initialPoint);
}
public function addPoint(Point $point): void
{
$this->points[] = $point;
$this->sumLat += $point->getLatitude();
$this->sumLng += $point->getLongitude();
++$this->count;
}
/**
* Returns the center of the cluster as a Point.
*/
public function getCenter(): Point
{
return new Point($this->sumLat / $this->count, $this->sumLng / $this->count);
}
/**
* @return non-empty-list<Point>
*/
public function getPoints(): array
{
return $this->points;
}
/**
* Returns the number of points in the cluster.
*/
public function count(): int
{
return $this->count;
}
/**
* @return \Traversable<int, Point>
*/
public function getIterator(): \Traversable
{
return new \ArrayIterator($this->points);
}
}

View File

@@ -0,0 +1,30 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\Map\Cluster;
use Symfony\UX\Map\Point;
/**
* Interface for various Clustering implementations.
*/
interface ClusteringAlgorithmInterface
{
/**
* Clusters a set of points.
*
* @param Point[] $points List of points to be clustered
* @param float $zoom The zoom level, determining grid resolution
*
* @return Cluster[] An array of clusters, each containing grouped points
*/
public function cluster(array $points, float $zoom): array;
}

View File

@@ -0,0 +1,67 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\Map\Cluster;
use Symfony\UX\Map\Point;
/**
* Grid-based clustering algorithm for spatial data.
*
* This algorithm groups points into fixed-size grid cells based on the given zoom level.
*
* Best for:
* - Fast, scalable clustering on large geographical datasets
* - Real-time clustering where performance is critical
* - Use cases where a simple, predictable grid structure is sufficient
*
* Slower for:
* - Highly dynamic data that requires adaptive cluster sizes
* - Scenarios where varying density should influence cluster sizes (e.g., DBSCAN-like approaches)
* - Irregularly shaped clusters that do not fit a strict grid pattern
*
* @author Simon André <smn.andre@gmail.com>
*/
final class GridClusteringAlgorithm implements ClusteringAlgorithmInterface
{
/**
* Clusters a set of points using a fixed grid resolution based on the zoom level.
*
* @param Point[] $points List of points to be clustered
* @param float $zoom The zoom level, determining grid resolution
*
* @return Cluster[] An array of clusters, each containing grouped points
*/
public function cluster(iterable $points, float $zoom): array
{
$gridResolution = 1 << (int) $zoom;
$gridSize = 360 / $gridResolution;
$invGridSize = 1 / $gridSize;
$cells = [];
foreach ($points as $point) {
$lng = $point->getLongitude();
$lat = $point->getLatitude();
$gridX = (int) (($lng + 180) * $invGridSize);
$gridY = (int) (($lat + 90) * $invGridSize);
$key = ($gridX << 16) | $gridY;
if (!isset($cells[$key])) {
$cells[$key] = new Cluster($point);
} else {
$cells[$key]->addPoint($point);
}
}
return array_values($cells);
}
}

View File

@@ -0,0 +1,76 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\Map\Cluster;
use Symfony\UX\Map\Point;
/**
* Clustering algorithm based on Morton codes (Z-order curves).
*
* This approach is optimized for spatial data and preserves locality efficiently.
*
* Best for:
* - Large-scale spatial clustering
* - Hierarchical clustering with fast locality-based grouping
* - Datasets where preserving spatial proximity is crucial
*
* Slower for:
* - High-dimensional data (beyond 2D/3D) due to Morton code limitations
* - Non-spatial or categorical data
* - Scenarios requiring dynamic cluster adjustments (e.g., streaming data)
*
* @author Simon André <smn.andre@gmail.com>
*/
final class MortonClusteringAlgorithm implements ClusteringAlgorithmInterface
{
/**
* @param Point[] $points
*
* @return Cluster[]
*/
public function cluster(iterable $points, float $zoom): array
{
$resolution = 1 << (int) $zoom;
$clustersMap = [];
foreach ($points as $point) {
$xNorm = ($point->getLatitude() + 180) / 360;
$yNorm = ($point->getLongitude() + 90) / 180;
$x = (int) floor($xNorm * $resolution);
$y = (int) floor($yNorm * $resolution);
$x &= 0xFFFF;
$y &= 0xFFFF;
$x = ($x | ($x << 8)) & 0x00FF00FF;
$x = ($x | ($x << 4)) & 0x0F0F0F0F;
$x = ($x | ($x << 2)) & 0x33333333;
$x = ($x | ($x << 1)) & 0x55555555;
$y = ($y | ($y << 8)) & 0x00FF00FF;
$y = ($y | ($y << 4)) & 0x0F0F0F0F;
$y = ($y | ($y << 2)) & 0x33333333;
$y = ($y | ($y << 1)) & 0x55555555;
$code = ($y << 1) | $x;
if (!isset($clustersMap[$code])) {
$clustersMap[$code] = new Cluster($point);
} else {
$clustersMap[$code]->addPoint($point);
}
}
return array_values($clustersMap);
}
}

View File

@@ -23,7 +23,7 @@ use Symfony\UX\Map\Point;
final class HaversineDistanceCalculator implements DistanceCalculatorInterface
{
/**
* @const float The Earth's radius in meters.
* @var float the Earth's radius in meters
*/
private const EARTH_RADIUS = 6371000.0;

View File

@@ -23,7 +23,7 @@ use Symfony\UX\Map\Point;
final class SphericalCosineDistanceCalculator implements DistanceCalculatorInterface
{
/**
* @const float The Earth's radius in meters.
* @var float the Earth's radius in meters
*/
private const EARTH_RADIUS = 6371000.0;

View File

@@ -27,18 +27,18 @@ abstract class Elements
) {
$this->elements = new \SplObjectStorage();
foreach ($elements as $element) {
$this->elements->attach($element);
$this->add($element);
}
}
public function add(Element $element): static
{
$this->elements->attach($element, $element->id ?? $this->elements->getHash($element));
$this->elements[$element] = $element->id ?? $this->elements->getHash($element);
return $this;
}
private function getElement(string $id): ?Element
private function getElementById(string $id): ?Element
{
foreach ($this->elements as $element) {
if ($element->id === $id) {
@@ -52,16 +52,21 @@ abstract class Elements
public function remove(Element|string $elementOrId): static
{
if (\is_string($elementOrId)) {
$elementOrId = $this->getElement($elementOrId);
$element = $this->getElementById($elementOrId);
} else {
$element = $elementOrId;
}
if (null === $elementOrId) {
return $this;
if (null !== $element && $this->elements->offsetExists($element)) {
unset($this->elements[$element]);
}
if ($this->elements->contains($elementOrId)) {
$this->elements->detach($elementOrId);
}
return $this;
}
public function removeAll(): static
{
$this->elements->removeAll($this->elements);
return $this;
}

View File

@@ -133,6 +133,13 @@ final class Map
return $this;
}
public function removeAllMarkers(): self
{
$this->markers->removeAll();
return $this;
}
public function addPolygon(Polygon $polygon): self
{
$this->polygons->add($polygon);
@@ -147,6 +154,13 @@ final class Map
return $this;
}
public function removeAllPolygons(): self
{
$this->polygons->removeAll();
return $this;
}
public function addPolyline(Polyline $polyline): self
{
$this->polylines->add($polyline);
@@ -161,6 +175,13 @@ final class Map
return $this;
}
public function removeAllPolylines(): self
{
$this->polylines->removeAll();
return $this;
}
public function addCircle(Circle $circle): self
{
$this->circles->add($circle);
@@ -175,6 +196,13 @@ final class Map
return $this;
}
public function removeAllCircles(): self
{
$this->circles->removeAll();
return $this;
}
public function addRectangle(Rectangle $rectangle): self
{
$this->rectangles->add($rectangle);
@@ -189,6 +217,13 @@ final class Map
return $this;
}
public function removeAllRectangles(): self
{
$this->rectangles->removeAll();
return $this;
}
/**
* @param array<string, mixed> $extra Extra data forwarded to the JavaScript side. It can be used in your custom
* Stimulus controller to benefit from greater flexibility and customization.

View File

@@ -33,6 +33,9 @@ final class Polygon implements Element
private readonly array $extra = [],
public readonly ?string $id = null,
) {
if (null !== $title) {
trigger_deprecation('symfony/ux-map', '2.30', 'The "title" parameter is deprecated and will be removed in 3.0. Use "infoWindow" instead.');
}
}
/**
@@ -50,8 +53,8 @@ final class Polygon implements Element
{
return [
'points' => current($this->points) instanceof Point
? array_map(fn (Point $point) => $point->toArray(), $this->points)
: array_map(fn (array $path) => array_map(fn (Point $point) => $point->toArray(), $path), $this->points),
? array_map(static fn (Point $point) => $point->toArray(), $this->points)
: array_map(static fn (array $path) => array_map(static fn (Point $point) => $point->toArray(), $path), $this->points),
'title' => $this->title,
'infoWindow' => $this->infoWindow?->toArray(),
'extra' => $this->extra,
@@ -78,7 +81,7 @@ final class Polygon implements Element
$polygon['points'] = isset($polygon['points'][0]['lat'], $polygon['points'][0]['lng'])
? array_map(Point::fromArray(...), $polygon['points'])
: array_map(fn (array $points) => array_map(Point::fromArray(...), $points), $polygon['points']);
: array_map(static fn (array $points) => array_map(Point::fromArray(...), $points), $polygon['points']);
if (isset($polygon['infoWindow'])) {
$polygon['infoWindow'] = InfoWindow::fromArray($polygon['infoWindow']);

View File

@@ -32,6 +32,9 @@ final class Polyline implements Element
private readonly array $extra = [],
public readonly ?string $id = null,
) {
if (null !== $title) {
trigger_deprecation('symfony/ux-map', '2.30', 'The "title" parameter is deprecated and will be removed in 3.0. Use "infoWindow" instead.');
}
}
/**
@@ -48,7 +51,7 @@ final class Polyline implements Element
public function toArray(): array
{
return [
'points' => array_map(fn (Point $point) => $point->toArray(), $this->points),
'points' => array_map(static fn (Point $point) => $point->toArray(), $this->points),
'title' => $this->title,
'infoWindow' => $this->infoWindow?->toArray(),
'extra' => $this->extra,

View File

@@ -33,6 +33,9 @@ final class Rectangle implements Element
public readonly array $extra = [],
public readonly ?string $id = null,
) {
if (null !== $title) {
trigger_deprecation('symfony/ux-map', '2.30', 'The "title" parameter is deprecated and will be removed in 3.0. Use "infoWindow" instead.');
}
}
/**

View File

@@ -87,7 +87,7 @@ abstract class AbstractRenderer implements RendererInterface
private function getMapAttributes(Map $map): array
{
$computeId = fn (array $array) => hash('xxh3', json_encode($array, \JSON_THROW_ON_ERROR));
$computeId = static fn (array $array) => hash('xxh3', json_encode($array, \JSON_THROW_ON_ERROR));
$attrs = $map->toArray();

View File

@@ -30,7 +30,7 @@ final class NullRenderer implements RendererInterface
{
$message = 'You must install at least one bridge package to use the Symfony UX Map component.';
if ($this->availableBridges) {
$message .= \PHP_EOL.'Try running '.implode(' or ', array_map(fn ($name) => \sprintf('"composer require %s"', $name), $this->availableBridges)).'.';
$message .= \PHP_EOL.'Try running '.implode(' or ', array_map(static fn ($name) => \sprintf('"composer require %s"', $name), $this->availableBridges)).'.';
}
throw new LogicException($message);

View File

@@ -13,6 +13,7 @@ namespace Symfony\UX\Map\Test;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Spatie\Snapshots\Drivers\TextDriver;
use Spatie\Snapshots\MatchesSnapshots;
use Symfony\UX\Map\Elements;
use Symfony\UX\Map\Map;
@@ -40,7 +41,7 @@ abstract class RendererTestCase extends TestCase
$rendered = $this->prettify($rendered);
$this->assertElementsHaveComputedId($rendered);
$this->assertMatchesSnapshot($rendered);
$this->assertMatchesSnapshot($rendered, new TextDriver());
}
private function prettify(string $html): string
@@ -77,9 +78,8 @@ abstract class RendererTestCase extends TestCase
throw new \LogicException(\sprintf('Failed to parse the rendered HTML for property "%s".', $property));
} elseif (0 === $matchesResult) {
throw new \LogicException(\sprintf('It looks like the property "%s" is missing from "Map::toArray()" normalization.', $property));
} else {
$htmlAttributes[$property] = $matches[1];
}
$htmlAttributes[$property] = $matches[1];
}
// Check that each property has a computed "@id" attribute

View File

@@ -53,9 +53,10 @@ final class MapRuntime implements RuntimeExtensionInterface
?array $rectangles = null,
?float $minZoom = null,
?float $maxZoom = null,
?bool $fitBoundsToMarkers = null,
): string {
if ($map instanceof Map) {
if (null !== $center || null !== $zoom || $markers || $polygons || $polylines || $circles || $rectangles || $minZoom || $maxZoom) {
if (null !== $center || null !== $zoom || $markers || $polygons || $polylines || $circles || $rectangles || $minZoom || $maxZoom || null !== $fitBoundsToMarkers) {
throw new \InvalidArgumentException('It is not allowed to pass both a Map object and other parameters (like "center", "zoom", "markers", etc...) to the "renderMap" method. Please use either a Map object or the individual parameters.');
}
@@ -90,13 +91,16 @@ final class MapRuntime implements RuntimeExtensionInterface
if (null !== $maxZoom) {
$map->maxZoom($maxZoom);
}
if (null !== $fitBoundsToMarkers) {
$map->fitBoundsToMarkers($fitBoundsToMarkers);
}
return $this->renderer->renderMap($map, $attributes);
}
public function render(array $args = []): string
{
$map = array_intersect_key($args, array_flip(['map', 'center', 'zoom', 'markers', 'polygons', 'polylines', 'circles', 'rectangles', 'minZoom', 'maxZoom']));
$map = array_intersect_key($args, array_flip(['map', 'center', 'zoom', 'markers', 'polygons', 'polylines', 'circles', 'rectangles', 'minZoom', 'maxZoom', 'fitBoundsToMarkers']));
$attributes = array_diff_key($args, $map);
return $this->renderMap(...$map, attributes: $attributes);

View File

@@ -33,6 +33,8 @@ final class UXMapComponent
public ?Point $center;
public ?bool $fitBoundsToMarkers;
/**
* @var Marker[]
*/

View File

@@ -69,7 +69,7 @@ final class UXMapBundle extends AbstractBundle
if (str_starts_with($config['renderer'], 'null://')) {
$container->services()
->set('ux_map.renderer_factory.null', NullRendererFactory::class)
->arg(0, array_map(fn ($name) => 'symfony/ux-'.$name.'-map', array_keys(self::$bridges)))
->arg(0, array_map(static fn ($name) => 'symfony/ux-'.$name.'-map', array_keys(self::$bridges)))
->tag('ux_map.renderer_factory');
}

View File

@@ -27,7 +27,6 @@ class CircleTest extends TestCase
$circle = new Circle(
center: $center,
radius: 500,
title: 'Test Circle',
infoWindow: $infoWindow,
extra: ['foo' => 'bar'],
id: 'circle1'
@@ -37,7 +36,7 @@ class CircleTest extends TestCase
self::assertSame([
'center' => ['lat' => 1.1, 'lng' => 2.2],
'radius' => 500.0,
'title' => 'Test Circle',
'title' => null,
'infoWindow' => [
'headerContent' => 'info content',
'content' => null,
@@ -56,7 +55,7 @@ class CircleTest extends TestCase
$data = [
'center' => ['lat' => 1.1, 'lng' => 2.2],
'radius' => 500,
'title' => 'Test Circle',
'title' => null,
'infoWindow' => ['content' => 'info content'],
'extra' => ['foo' => 'bar'],
'id' => 'circle1',
@@ -70,7 +69,7 @@ class CircleTest extends TestCase
self::assertSame([
'center' => ['lat' => 1.1, 'lng' => 2.2],
'radius' => 500.0,
'title' => 'Test Circle',
'title' => null,
'infoWindow' => [
'headerContent' => null,
'content' => 'info content',

View File

@@ -0,0 +1,79 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\Map\Tests\Cluster;
use PHPUnit\Framework\TestCase;
use Symfony\UX\Map\Cluster\Cluster;
use Symfony\UX\Map\Point;
class ClusterTest extends TestCase
{
public function testAddPointAndGetCenter(): void
{
$point1 = new Point(10.0, 20.0);
$cluster = new Cluster($point1);
$this->assertEquals(10.0, $cluster->getCenter()->getLatitude());
$this->assertEquals(20.0, $cluster->getCenter()->getLongitude());
$point2 = new Point(12.0, 22.0);
$cluster->addPoint($point2);
$this->assertEquals(11.0, $cluster->getCenter()->getLatitude());
$this->assertEquals(21.0, $cluster->getCenter()->getLongitude());
}
public function testGetPoints(): void
{
$point1 = new Point(10.0, 20.0);
$point2 = new Point(12.0, 22.0);
$cluster = new Cluster($point1);
$cluster->addPoint($point2);
$points = $cluster->getPoints();
$this->assertCount(2, $points);
$this->assertSame($point1, $points[0]);
$this->assertSame($point2, $points[1]);
}
public function testCount(): void
{
$cluster = new Cluster(new Point(10.0, 20.0));
$this->assertCount(1, $cluster);
$cluster->addPoint(new Point(10.0, 20.0));
$this->assertCount(2, $cluster);
}
public function testIterator(): void
{
$point1 = new Point(10.0, 20.0);
$point2 = new Point(12.0, 22.0);
$cluster = new Cluster($point1);
$cluster->addPoint($point2);
$points = iterator_to_array($cluster);
$this->assertCount(2, $points);
$this->assertSame($point1, $points[0]);
$this->assertSame($point2, $points[1]);
}
public function testCreateCluster(): void
{
$point1 = new Point(10.0, 20.0);
$cluster = new Cluster($point1);
$this->assertCount(1, $cluster->getPoints());
$this->assertEquals(10.0, $cluster->getCenter()->getLatitude());
$this->assertEquals(20.0, $cluster->getCenter()->getLongitude());
}
}

View File

@@ -0,0 +1,110 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\Map\Tests\Cluster;
use PHPUnit\Framework\TestCase;
use Symfony\UX\Map\Cluster\ClusteringAlgorithmInterface;
use Symfony\UX\Map\Cluster\GridClusteringAlgorithm;
use Symfony\UX\Map\Cluster\MortonClusteringAlgorithm;
use Symfony\UX\Map\Point;
class ClusteringPerformanceTest extends TestCase
{
/**
* @var array<float>
*/
private const ZOOMS = [
2.0,
5.0,
8.0,
];
/**
* @var array<string>
*/
private const ALGORITHMS = [
GridClusteringAlgorithm::class,
MortonClusteringAlgorithm::class,
];
/**
* @return iterable<array{0: ClusteringAlgorithmInterface, 1: float}>
*/
public static function algorithmProvider(): iterable
{
foreach (self::ZOOMS as $zoom) {
foreach (self::ALGORITHMS as $algorithm) {
yield $algorithm.' '.$zoom => [new $algorithm(), $zoom];
}
}
}
/**
* Scenario 1: Large number of points (50,000), concentrated area (Paris region).
*
* @dataProvider algorithmProvider
*/
public function testScenarioRegion50000(ClusteringAlgorithmInterface $algorithm, float $zoom)
{
$points = $this->generatePoints(50000, 48.8, 49, 2.2, 2.5);
$this->runPerformanceTest($algorithm, $points, $zoom);
}
/**
* Scenario 2: Moderate number of points (5,000), broad area (France and surroundings).
*
* @dataProvider algorithmProvider
*/
public function testScenarioCountry5000(ClusteringAlgorithmInterface $algorithm, float $zoom)
{
$points = $this->generatePoints(5000, 30, 60, -10, 35);
$this->runPerformanceTest($algorithm, $points, $zoom);
}
/**
* Scenario 3: Very large number of points (100,000), global distribution.
*
* @dataProvider algorithmProvider
*/
public function testScenarioWorld100000(ClusteringAlgorithmInterface $algorithm, float $zoom)
{
$points = $this->generatePoints(100000, -90, 90, -180, 180);
$this->runPerformanceTest($algorithm, $points, $zoom);
}
/**
* @param array<Point> $points
*/
private function runPerformanceTest(ClusteringAlgorithmInterface $algorithm, array $points, float $zoom): void
{
$startTime = microtime(true);
$algorithm->cluster($points, $zoom);
$elapsed = microtime(true) - $startTime;
$this->assertLessThan(2.0, $elapsed, $algorithm::class." took too long: {$elapsed} seconds (zoom {$zoom}, ".\count($points).' points)');
}
private function generatePoints(int $count, float $latMin, float $latMax, float $lngMin, float $lngMax): array
{
$points = [];
for ($i = 0; $i < $count; ++$i) {
$lat = random_int((int) ($latMin * 100), (int) ($latMax * 100)) / 100.0;
$lng = random_int((int) ($lngMin * 100), (int) ($lngMax * 100)) / 100.0;
$points[] = new Point($lat, $lng);
}
return $points;
}
}

View File

@@ -0,0 +1,98 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\Map\Tests\Cluster;
use PHPUnit\Framework\TestCase;
use Symfony\UX\Map\Cluster\Cluster;
use Symfony\UX\Map\Cluster\GridClusteringAlgorithm;
use Symfony\UX\Map\Point;
class GridClusteringAlgorithmTest extends TestCase
{
public function testSinglePointCreatesSingleCluster(): void
{
$point = new Point(10.0, 20.0);
$algorithm = new GridClusteringAlgorithm();
$clusters = $algorithm->cluster([$point], 1.0);
$this->assertCount(1, $clusters);
/** @var Cluster $cluster */
$cluster = $clusters[0];
$this->assertEquals(10.0, $cluster->getCenter()->getLatitude());
$this->assertEquals(20.0, $cluster->getCenter()->getLongitude());
$this->assertCount(1, $cluster->getPoints());
}
public function testPointsInSameGridAreClusteredTogether(): void
{
$point1 = new Point(10.0, 20.0);
$point2 = new Point(10.1, 20.1);
$algorithm = new GridClusteringAlgorithm();
$clusters = $algorithm->cluster([$point1, $point2], 1.0);
$this->assertCount(1, $clusters, 'One cluster should have been created due to the low zoom value.');
$cluster = $clusters[0];
$this->assertCount(2, $cluster->getPoints());
$this->assertEqualsWithDelta(10.05, $cluster->getCenter()->getLatitude(), 0.0001);
$this->assertEqualsWithDelta(20.05, $cluster->getCenter()->getLongitude(), 0.0001);
}
public function testPointsInDifferentGridsAreNotClustered(): void
{
$point1 = new Point(10.0, 20.0);
$point2 = new Point(-10.0, -20.0); // Far away
$algorithm = new GridClusteringAlgorithm();
$clusters = $algorithm->cluster([$point1, $point2], 5.0);
$this->assertCount(2, $clusters, 'Two clusters should have created due to the high zoom value.');
}
public function testEmptyPointsArray(): void
{
$algorithm = new GridClusteringAlgorithm();
// Empty points array
$clusters = $algorithm->cluster([], 2.0);
$this->assertCount(0, $clusters);
}
public function testLargeCoordinates(): void
{
$point1 = new Point(89.9, 179.9);
$point2 = new Point(-89.9, -179.9);
$algorithm = new GridClusteringAlgorithm();
$clusters = $algorithm->cluster([$point1, $point2], 3.0);
$this->assertGreaterThanOrEqual(1, \count($clusters));
}
public function testZeroZoomLevel(): void
{
$point1 = new Point(10, 20);
$point2 = new Point(30, 40);
$algorithm = new GridClusteringAlgorithm();
// With zoom 0, everything should be in one big cluster.
$clusters = $algorithm->cluster([$point1, $point2], 0.0);
$this->assertCount(1, $clusters);
$this->assertCount(2, $clusters[0]->getPoints());
}
}

View File

@@ -0,0 +1,83 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\Map\Tests\Cluster;
use PHPUnit\Framework\TestCase;
use Symfony\UX\Map\Cluster\Cluster;
use Symfony\UX\Map\Cluster\MortonClusteringAlgorithm;
use Symfony\UX\Map\Point;
class MortonClusteringAlgorithmTest extends TestCase
{
public function testSinglePointCreatesSingleCluster(): void
{
$point = new Point(10.0, 20.0);
$algorithm = new MortonClusteringAlgorithm();
$clusters = $algorithm->cluster([$point], 1.0);
$this->assertCount(1, $clusters);
/** @var Cluster $cluster */
$cluster = $clusters[0];
$this->assertEquals(10.0, $cluster->getCenter()->getLatitude());
$this->assertEquals(20.0, $cluster->getCenter()->getLongitude());
$this->assertCount(1, $cluster->getPoints());
}
public function testPointsWithSameMortonCodeAreClustered(): void
{
// These points should have the same Morton code at zoom level 1
$point1 = new Point(45.0, 90.0);
$point2 = new Point(45.1, 90.1);
$algorithm = new MortonClusteringAlgorithm();
$clusters = $algorithm->cluster([$point1, $point2], 1.0);
$this->assertCount(1, $clusters);
$this->assertCount(2, $clusters[0]->getPoints());
}
public function testPointsWithDifferentMortonCodeAreNotClustered(): void
{
// These points will have different Morton codes at zoom level 5
$point1 = new Point(45.0, 90.0);
$point2 = new Point(-45.0, -90.0);
$algorithm = new MortonClusteringAlgorithm();
$clusters = $algorithm->cluster([$point1, $point2], 5.0);
$this->assertCount(2, $clusters);
}
public function testEmptyPointsArray(): void
{
$algorithm = new MortonClusteringAlgorithm();
$clusters = $algorithm->cluster([], 2.0);
$this->assertCount(0, $clusters);
}
public function testZeroZoomLevel(): void
{
$point1 = new Point(10, 20);
$point2 = new Point(30, 40);
$algorithm = new MortonClusteringAlgorithm();
$clusters = $algorithm->cluster([$point1, $point2], 0.0);
// With zoom 0, everything should be in one big cluster
$this->assertCount(1, $clusters);
$this->assertCount(2, $clusters[0]->getPoints());
}
}

View File

@@ -69,8 +69,8 @@ class IconTest extends TestCase
$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())
static fn (\ReflectionMethod $method) => $method->name,
array_filter($refl->getMethods(\ReflectionMethod::IS_PUBLIC), static fn (\ReflectionMethod $method) => SvgIcon::class === $method->getDeclaringClass()->getName())
),
['toArray', 'fromArray']
);

View File

@@ -33,7 +33,7 @@ trait AppKernelTrait
$dir = sys_get_temp_dir().'/map_bundle/'.uniqid($type.'_', true);
if (!file_exists($dir)) {
mkdir($dir, 0777, true);
mkdir($dir, 0o777, true);
}
return $dir;

View File

@@ -32,10 +32,17 @@ class FrameworkAppKernel extends Kernel
return [new FrameworkBundle(), new StimulusBundle(), new UXMapBundle()];
}
public function registerContainerConfiguration(LoaderInterface $loader)
public function registerContainerConfiguration(LoaderInterface $loader): void
{
$loader->load(function (ContainerBuilder $container) {
$container->loadFromExtension('framework', ['secret' => '$ecret', 'test' => true, 'http_method_override' => false]);
$loader->load(static function (ContainerBuilder $container) {
$container->loadFromExtension('framework', [
'secret' => '$ecret',
'test' => true,
'http_method_override' => false,
...(self::VERSION_ID >= 70300 ? [
'property_info' => ['with_constructor_extractor' => false],
] : []),
]);
$container->loadFromExtension('ux_map', []);
$container->setAlias('test.ux_map.renderers', 'ux_map.renderers')->setPublic(true);

View File

@@ -33,11 +33,24 @@ class TwigAppKernel extends Kernel
return [new FrameworkBundle(), new StimulusBundle(), new TwigBundle(), new UXMapBundle()];
}
public function registerContainerConfiguration(LoaderInterface $loader)
public function registerContainerConfiguration(LoaderInterface $loader): void
{
$loader->load(function (ContainerBuilder $container) {
$container->loadFromExtension('framework', ['secret' => '$ecret', 'test' => true, 'http_method_override' => false]);
$container->loadFromExtension('twig', ['default_path' => __DIR__.'/templates', 'strict_variables' => true, 'exception_controller' => null]);
$loader->load(static function (ContainerBuilder $container) {
$container->loadFromExtension('framework', [
'secret' => '$ecret',
'test' => true,
'http_method_override' => false,
'php_errors' => [
'log' => true,
],
...(self::VERSION_ID >= 60200 ? [
'handle_all_throwables' => true,
] : []),
]);
$container->loadFromExtension('twig', [
'default_path' => __DIR__.'/templates',
'strict_variables' => true,
]);
$container->loadFromExtension('ux_map', []);
$container->setAlias('test.ux_map.renderers', 'ux_map.renderers')->setPublic(true);

View File

@@ -38,9 +38,9 @@ class TwigComponentKernel extends Kernel
];
}
public function registerContainerConfiguration(LoaderInterface $loader)
public function registerContainerConfiguration(LoaderInterface $loader): void
{
$loader->load(function (ContainerBuilder $container) {
$loader->load(static function (ContainerBuilder $container) {
$container->loadFromExtension('framework', [
'secret' => '$ecret',
'test' => true,
@@ -49,7 +49,6 @@ class TwigComponentKernel extends Kernel
$container->loadFromExtension('twig', [
'default_path' => __DIR__.'/templates',
'strict_variables' => true,
'exception_controller' => null,
]);
$container->loadFromExtension('twig_component', [
'defaults' => [],

View File

@@ -85,7 +85,6 @@ class MapFactoryTest extends TestCase
new Point(48.853, 2.3499),
new Point(48.8566, 2.3522),
],
title: 'Polygon 1',
infoWindow: new InfoWindow('Polygon 1', 'Polygon 1', extra: ['color' => 'red']),
extra: ['color' => 'blue'],
));
@@ -222,7 +221,7 @@ class MapFactoryTest extends TestCase
'lng' => 2.3522,
],
],
'title' => 'Polygon 1',
'title' => null,
'infoWindow' => [
'headerContent' => 'Polygon 1',
'content' => 'Polygon 1',
@@ -245,7 +244,7 @@ class MapFactoryTest extends TestCase
'lng' => 2.3522,
],
],
'title' => 'Polyline 1',
'title' => null,
'infoWindow' => [
'headerContent' => 'Polyline 1',
'content' => 'Polyline 1',

View File

@@ -135,7 +135,6 @@ class MapTest extends TestCase
new Point(48.853, 2.3499),
new Point(48.8566, 2.3522),
],
title: 'Polygon 1',
infoWindow: null,
))
->addPolygon(new Polygon(
@@ -144,7 +143,6 @@ class MapTest extends TestCase
new Point(45.75, 4.85),
new Point(45.77, 4.82),
],
title: 'Polygon 2',
infoWindow: new InfoWindow(
headerContent: '<b>Polygon 2</b>',
content: 'A polygon around Lyon with some additional info.',
@@ -159,7 +157,6 @@ class MapTest extends TestCase
new Point(48.853, 2.3499),
new Point(48.8566, 2.3522),
],
title: 'Polyline 1',
infoWindow: null,
))
->addPolyline(new Polyline(
@@ -168,7 +165,6 @@ class MapTest extends TestCase
new Point(45.75, 4.85),
new Point(45.77, 4.82),
],
title: 'Polyline 2',
infoWindow: new InfoWindow(
headerContent: '<b>Polyline 2</b>',
content: 'A polyline around Lyon with some additional info.',
@@ -180,7 +176,6 @@ class MapTest extends TestCase
->addCircle(new Circle(
center: new Point(48.8566, 2.3522),
radius: 500,
title: 'Circle around Paris',
infoWindow: new InfoWindow(
headerContent: '<b>Circle around Paris</b>',
content: 'A circle with a radius of 500 meters around Paris.',
@@ -192,7 +187,6 @@ class MapTest extends TestCase
->addCircle(new Circle(
center: new Point(45.764, 4.8357),
radius: 300,
title: 'Circle around Lyon',
infoWindow: new InfoWindow(
headerContent: '<b>Circle around Lyon</b>',
content: 'A circle with a radius of 300 meters around Lyon.',
@@ -204,7 +198,6 @@ class MapTest extends TestCase
->addRectangle(new Rectangle(
southWest: new Point(48.853, 2.3499),
northEast: new Point(48.8566, 2.3522),
title: 'Rectangle around Paris',
infoWindow: new InfoWindow(
headerContent: '<b>Rectangle around Paris</b>',
content: 'A rectangle around Paris.',
@@ -216,7 +209,6 @@ class MapTest extends TestCase
->addRectangle(new Rectangle(
southWest: new Point(45.75, 4.85),
northEast: new Point(45.77, 4.82),
title: 'Rectangle around Lyon',
infoWindow: new InfoWindow(
headerContent: '<b>Rectangle around Lyon</b>',
content: 'A rectangle around Lyon.',
@@ -297,7 +289,7 @@ class MapTest extends TestCase
['lat' => 48.853, 'lng' => 2.3499],
['lat' => 48.8566, 'lng' => 2.3522],
],
'title' => 'Polygon 1',
'title' => null,
'infoWindow' => null,
'extra' => [],
'id' => null,
@@ -308,7 +300,7 @@ class MapTest extends TestCase
['lat' => 45.75, 'lng' => 4.85],
['lat' => 45.77, 'lng' => 4.82],
],
'title' => 'Polygon 2',
'title' => null,
'infoWindow' => [
'headerContent' => '<b>Polygon 2</b>',
'content' => 'A polygon around Lyon with some additional info.',
@@ -328,7 +320,7 @@ class MapTest extends TestCase
['lat' => 48.853, 'lng' => 2.3499],
['lat' => 48.8566, 'lng' => 2.3522],
],
'title' => 'Polyline 1',
'title' => null,
'infoWindow' => null,
'extra' => [],
'id' => null,
@@ -339,7 +331,7 @@ class MapTest extends TestCase
['lat' => 45.75, 'lng' => 4.85],
['lat' => 45.77, 'lng' => 4.82],
],
'title' => 'Polyline 2',
'title' => null,
'infoWindow' => [
'headerContent' => '<b>Polyline 2</b>',
'content' => 'A polyline around Lyon with some additional info.',
@@ -356,7 +348,7 @@ class MapTest extends TestCase
[
'center' => ['lat' => 48.8566, 'lng' => 2.3522],
'radius' => 500,
'title' => 'Circle around Paris',
'title' => null,
'infoWindow' => [
'headerContent' => '<b>Circle around Paris</b>',
'content' => 'A circle with a radius of 500 meters around Paris.',
@@ -371,7 +363,7 @@ class MapTest extends TestCase
[
'center' => ['lat' => 45.764, 'lng' => 4.8357],
'radius' => 300,
'title' => 'Circle around Lyon',
'title' => null,
'infoWindow' => [
'headerContent' => '<b>Circle around Lyon</b>',
'content' => 'A circle with a radius of 300 meters around Lyon.',
@@ -388,7 +380,7 @@ class MapTest extends TestCase
[
'southWest' => ['lat' => 48.853, 'lng' => 2.3499],
'northEast' => ['lat' => 48.8566, 'lng' => 2.3522],
'title' => 'Rectangle around Paris',
'title' => null,
'infoWindow' => [
'headerContent' => '<b>Rectangle around Paris</b>',
'content' => 'A rectangle around Paris.',
@@ -403,7 +395,7 @@ class MapTest extends TestCase
[
'southWest' => ['lat' => 45.75, 'lng' => 4.85],
'northEast' => ['lat' => 45.77, 'lng' => 4.82],
'title' => 'Rectangle around Lyon',
'title' => null,
'infoWindow' => [
'headerContent' => '<b>Rectangle around Lyon</b>',
'content' => 'A rectangle around Lyon.',

View File

@@ -28,7 +28,6 @@ class PolygonTest extends TestCase
$polygon = new Polygon(
points: [$point1, $point2],
title: 'Test Polygon',
infoWindow: $infoWindow,
extra: ['foo' => 'bar'],
id: 'poly1'
@@ -37,7 +36,7 @@ class PolygonTest extends TestCase
$array = $polygon->toArray();
$this->assertSame([
'points' => [['lat' => 1.1, 'lng' => 2.2], ['lat' => 3.3, 'lng' => 4.4]],
'title' => 'Test Polygon',
'title' => null,
'infoWindow' => [
'headerContent' => 'info content',
'content' => null,
@@ -80,7 +79,6 @@ class PolygonTest extends TestCase
'points' => [
['lat' => 1.1, 'lng' => 2.2], ['lat' => 3.3, 'lng' => 4.4],
],
'title' => 'Test Polygon',
'infoWindow' => ['content' => 'info content'],
'extra' => ['foo' => 'bar'],
'id' => 'poly1',
@@ -93,7 +91,7 @@ class PolygonTest extends TestCase
$array = $polygon->toArray();
$this->assertSame([
'points' => [['lat' => 1.1, 'lng' => 2.2], ['lat' => 3.3, 'lng' => 4.4]],
'title' => 'Test Polygon',
'title' => null,
'infoWindow' => [
'headerContent' => null,
'content' => 'info content',
@@ -114,7 +112,6 @@ class PolygonTest extends TestCase
[['lat' => 1.1, 'lng' => 2.2], ['lat' => 3.3, 'lng' => 4.4]],
[['lat' => 5.5, 'lng' => 6.6]],
],
'title' => 'Test Polygon',
'infoWindow' => ['content' => 'info content'],
'extra' => ['foo' => 'bar'],
'id' => 'poly1',
@@ -130,7 +127,7 @@ class PolygonTest extends TestCase
[['lat' => 1.1, 'lng' => 2.2], ['lat' => 3.3, 'lng' => 4.4]],
[['lat' => 5.5, 'lng' => 6.6]],
],
'title' => 'Test Polygon',
'title' => null,
'infoWindow' => [
'headerContent' => null,
'content' => 'info content',

View File

@@ -26,13 +26,13 @@ class RectangleTest extends TestCase
$southWest = new Point(1.0, 2.0);
$northEast = new Point(3.0, 4.0);
$rectangle = new Rectangle($southWest, $northEast, 'Test Rectangle', $infoWindow, ['foo' => 'bar'], 'rect1');
$rectangle = new Rectangle($southWest, $northEast, null, $infoWindow, ['foo' => 'bar'], 'rect1');
$array = $rectangle->toArray();
self::assertSame([
'southWest' => ['lat' => 1.0, 'lng' => 2.0],
'northEast' => ['lat' => 3.0, 'lng' => 4.0],
'title' => 'Test Rectangle',
'title' => null,
'infoWindow' => $infoWindow->toArray(),
'extra' => ['foo' => 'bar'],
'id' => 'rect1',
@@ -44,7 +44,7 @@ class RectangleTest extends TestCase
$data = [
'southWest' => ['lat' => 1.0, 'lng' => 2.0],
'northEast' => ['lat' => 3.0, 'lng' => 4.0],
'title' => 'Test Rectangle',
'title' => null,
'infoWindow' => ['content' => 'Hello'],
'extra' => ['foo' => 'bar'],
'id' => 'rect1',
@@ -56,7 +56,7 @@ class RectangleTest extends TestCase
self::assertSame([
'southWest' => ['lat' => 1.0, 'lng' => 2.0],
'northEast' => ['lat' => 3.0, 'lng' => 4.0],
'title' => 'Test Rectangle',
'title' => null,
'infoWindow' => [
'headerContent' => null,
'content' => 'Hello',

View File

@@ -21,21 +21,21 @@ use Symfony\UX\Map\Renderer\RendererInterface;
final class NullRendererTest extends TestCase
{
public function provideTestRenderMap(): iterable
public static function provideTestRenderMap(): iterable
{
yield 'no bridges' => [
'expected_exception_message' => 'You must install at least one bridge package to use the Symfony UX Map component.',
'expectedExceptionMessage' => 'You must install at least one bridge package to use the Symfony UX Map component.',
'renderer' => new NullRenderer(),
];
yield 'one bridge' => [
'expected_exception_message' => 'You must install at least one bridge package to use the Symfony UX Map component.'
'expectedExceptionMessage' => 'You must install at least one bridge package to use the Symfony UX Map component.'
.\PHP_EOL.'Try running "composer require symfony/ux-leaflet-map".',
'renderer' => new NullRenderer(['symfony/ux-leaflet-map']),
];
yield 'two bridges' => [
'expected_exception_message' => 'You must install at least one bridge package to use the Symfony UX Map component.'
'expectedExceptionMessage' => 'You must install at least one bridge package to use the Symfony UX Map component.'
.\PHP_EOL.'Try running "composer require symfony/ux-leaflet-map" or "composer require symfony/ux-google-map".',
'renderer' => new NullRenderer(['symfony/ux-leaflet-map', 'symfony/ux-google-map']),
];

View File

@@ -11,7 +11,6 @@
namespace Symfony\UX\Map\Tests\Twig;
use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\UX\Map\Map;
use Symfony\UX\Map\Point;
@@ -23,8 +22,6 @@ use Twig\Environment;
class MapExtensionTest extends KernelTestCase
{
use ExpectDeprecationTrait;
protected static function getKernelClass(): string
{
return TwigAppKernel::class;