Skip to content

Commit

Permalink
feature #2385 [Map] Make UX Map compatible with Live Components (and …
Browse files Browse the repository at this point in the history
…some internal things) (Kocal)

This PR was squashed before being merged into the 2.x branch.

Discussion
----------

[Map] Make UX Map compatible with Live Components (and some internal things)

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

Here’s a corrected and polished version of your text:

---

Hi! 😊

This PR enhances the UX Map to be compatible with Live Components, allowing you to interact with the `Map` directly from your PHP code. You can perform actions and see your map update in real-time from the front-end!

To achieve this, I had to refactor and improve several areas:

1. Due to the hydration and de-hydration processes of Live Components, I ensured that the `toArray` and new `fromArray` methods for `Map`, `Marker`, and all other value objects could rebuild an equivalent object accurately (e.g., `$marker == Marker::fromArray($marker->toArray())`).
2. Since Stimulus monitors value changes, it wasn’t efficient to store everything in a single large `view` object containing `zoom`, `center`, `markers`, etc. These properties were split into individual values to improve performance.
3. Before rendering the map, an ``@id`` is now automatically computed for each `Marker` and `Polygon` definition. This optimization makes rendering significantly more efficient, avoiding the need to remove and re-render all markers or polygons (and avoiding a visual glitch aswell).

https://github.com/user-attachments/assets/c151e64c-7321-46d3-a5c6-cfeaf57beb47

https://github.com/user-attachments/assets/ffb12035-a0c3-48b3-afb7-ddf2c24c318a

Commits
-------

74d937a Apply `@smnandre`'s suggestions from code review
7d30e20 [Map] Listen for zoom, center, markers and polygons changes, and update JS tests
aae9ddd [Map] Complete and simplify the normalization/denormalization process of Map's value objects, add MapOptionsNormalizer
4744d3d [Map] Add "fromArray" methods to options and other DTO, make "*Array" methods internal
8ab164b [Map] Split "view" attribute into multiple attributes
  • Loading branch information
Kocal committed Nov 22, 2024
2 parents 08be7d5 + 74d937a commit 7bef0bf
Show file tree
Hide file tree
Showing 40 changed files with 1,127 additions and 259 deletions.
1 change: 1 addition & 0 deletions src/Map/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

- 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)

## 2.20

Expand Down
35 changes: 22 additions & 13 deletions src/Map/assets/dist/abstract_map_controller.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,16 @@ export type Point = {
lat: number;
lng: number;
};
export type MapView<Options, MarkerOptions, InfoWindowOptions, PolygonOptions> = {
center: Point | null;
zoom: number | null;
fitBoundsToMarkers: boolean;
markers: Array<MarkerDefinition<MarkerOptions, InfoWindowOptions>>;
polygons: Array<PolygonDefinition<PolygonOptions, InfoWindowOptions>>;
options: Options;
};
export type MarkerDefinition<MarkerOptions, InfoWindowOptions> = {
'@id': string;
position: Point;
title: string | null;
infoWindow?: Omit<InfoWindowDefinition<InfoWindowOptions>, 'position'>;
rawOptions?: MarkerOptions;
extra: Record<string, unknown>;
};
export type PolygonDefinition<PolygonOptions, InfoWindowOptions> = {
'@id': string;
infoWindow?: Omit<InfoWindowDefinition<InfoWindowOptions>, 'position'>;
points: Array<Point>;
title: string | null;
Expand All @@ -37,22 +31,33 @@ export type InfoWindowDefinition<InfoWindowOptions> = {
export default abstract class<MapOptions, Map, MarkerOptions, Marker, InfoWindowOptions, InfoWindow, PolygonOptions, Polygon> extends Controller<HTMLElement> {
static values: {
providerOptions: ObjectConstructor;
view: ObjectConstructor;
center: ObjectConstructor;
zoom: NumberConstructor;
fitBoundsToMarkers: BooleanConstructor;
markers: ArrayConstructor;
polygons: ArrayConstructor;
options: ObjectConstructor;
};
viewValue: MapView<MapOptions, MarkerOptions, InfoWindowOptions, PolygonOptions>;
centerValue: Point | null;
zoomValue: number | null;
fitBoundsToMarkersValue: boolean;
markersValue: Array<MarkerDefinition<MarkerOptions, InfoWindowOptions>>;
polygonsValue: Array<PolygonDefinition<PolygonOptions, InfoWindowOptions>>;
optionsValue: MapOptions;
protected map: Map;
protected markers: Array<Marker>;
protected markers: globalThis.Map<any, any>;
protected infoWindows: Array<InfoWindow>;
protected polygons: Array<Polygon>;
protected polygons: globalThis.Map<any, any>;
connect(): void;
protected abstract doCreateMap({ center, zoom, options, }: {
center: Point | null;
zoom: number | null;
options: MapOptions;
}): Map;
createMarker(definition: MarkerDefinition<MarkerOptions, InfoWindowOptions>): Marker;
createPolygon(definition: PolygonDefinition<PolygonOptions, InfoWindowOptions>): Polygon;
protected abstract removeMarker(marker: Marker): void;
protected abstract doCreateMarker(definition: MarkerDefinition<MarkerOptions, InfoWindowOptions>): Marker;
createPolygon(definition: PolygonDefinition<PolygonOptions, InfoWindowOptions>): Polygon;
protected abstract doCreatePolygon(definition: PolygonDefinition<PolygonOptions, InfoWindowOptions>): Polygon;
protected createInfoWindow({ definition, element, }: {
definition: MarkerDefinition<MarkerOptions, InfoWindowOptions>['infoWindow'] | PolygonDefinition<PolygonOptions, InfoWindowOptions>['infoWindow'];
Expand All @@ -67,4 +72,8 @@ export default abstract class<MapOptions, Map, MarkerOptions, Marker, InfoWindow
}): InfoWindow;
protected abstract doFitBoundsToMarkers(): void;
protected abstract dispatchEvent(name: string, payload: Record<string, unknown>): void;
abstract centerValueChanged(): void;
abstract zoomValueChanged(): void;
markersValueChanged(): void;
polygonsValueChanged(): void;
}
66 changes: 54 additions & 12 deletions src/Map/assets/dist/abstract_map_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,38 +3,40 @@ import { Controller } from '@hotwired/stimulus';
class default_1 extends Controller {
constructor() {
super(...arguments);
this.markers = [];
this.markers = new Map();
this.infoWindows = [];
this.polygons = [];
this.polygons = new Map();
}
connect() {
const { center, zoom, options, markers, polygons, fitBoundsToMarkers } = this.viewValue;
const options = this.optionsValue;
this.dispatchEvent('pre-connect', { options });
this.map = this.doCreateMap({ center, zoom, options });
markers.forEach((marker) => this.createMarker(marker));
polygons.forEach((polygon) => this.createPolygon(polygon));
if (fitBoundsToMarkers) {
this.map = this.doCreateMap({ center: this.centerValue, zoom: this.zoomValue, options });
this.markersValue.forEach((marker) => this.createMarker(marker));
this.polygonsValue.forEach((polygon) => this.createPolygon(polygon));
if (this.fitBoundsToMarkersValue) {
this.doFitBoundsToMarkers();
}
this.dispatchEvent('connect', {
map: this.map,
markers: this.markers,
polygons: this.polygons,
markers: [...this.markers.values()],
polygons: [...this.polygons.values()],
infoWindows: this.infoWindows,
});
}
createMarker(definition) {
this.dispatchEvent('marker:before-create', { definition });
const marker = this.doCreateMarker(definition);
this.dispatchEvent('marker:after-create', { marker });
this.markers.push(marker);
marker['@id'] = definition['@id'];
this.markers.set(definition['@id'], marker);
return marker;
}
createPolygon(definition) {
this.dispatchEvent('polygon:before-create', { definition });
const polygon = this.doCreatePolygon(definition);
this.dispatchEvent('polygon:after-create', { polygon });
this.polygons.push(polygon);
polygon['@id'] = definition['@id'];
this.polygons.set(definition['@id'], polygon);
return polygon;
}
createInfoWindow({ definition, element, }) {
Expand All @@ -44,10 +46,50 @@ class default_1 extends Controller {
this.infoWindows.push(infoWindow);
return infoWindow;
}
markersValueChanged() {
if (!this.map) {
return;
}
this.markers.forEach((marker) => {
if (!this.markersValue.find((m) => m['@id'] === marker['@id'])) {
this.removeMarker(marker);
this.markers.delete(marker['@id']);
}
});
this.markersValue.forEach((marker) => {
if (!this.markers.has(marker['@id'])) {
this.createMarker(marker);
}
});
if (this.fitBoundsToMarkersValue) {
this.doFitBoundsToMarkers();
}
}
polygonsValueChanged() {
if (!this.map) {
return;
}
this.polygons.forEach((polygon) => {
if (!this.polygonsValue.find((p) => p['@id'] === polygon['@id'])) {
polygon.remove();
this.polygons.delete(polygon['@id']);
}
});
this.polygonsValue.forEach((polygon) => {
if (!this.polygons.has(polygon['@id'])) {
this.createPolygon(polygon);
}
});
}
}
default_1.values = {
providerOptions: Object,
view: Object,
center: Object,
zoom: Number,
fitBoundsToMarkers: Boolean,
markers: Array,
polygons: Array,
options: Object,
};

export { default_1 as default };
106 changes: 82 additions & 24 deletions src/Map/assets/src/abstract_map_controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,8 @@ import { Controller } from '@hotwired/stimulus';

export type Point = { lat: number; lng: number };

export type MapView<Options, MarkerOptions, InfoWindowOptions, PolygonOptions> = {
center: Point | null;
zoom: number | null;
fitBoundsToMarkers: boolean;
markers: Array<MarkerDefinition<MarkerOptions, InfoWindowOptions>>;
polygons: Array<PolygonDefinition<PolygonOptions, InfoWindowOptions>>;
options: Options;
};

export type MarkerDefinition<MarkerOptions, InfoWindowOptions> = {
'@id': string;
position: Point;
title: string | null;
infoWindow?: Omit<InfoWindowDefinition<InfoWindowOptions>, 'position'>;
Expand All @@ -29,6 +21,7 @@ export type MarkerDefinition<MarkerOptions, InfoWindowOptions> = {
};

export type PolygonDefinition<PolygonOptions, InfoWindowOptions> = {
'@id': string;
infoWindow?: Omit<InfoWindowDefinition<InfoWindowOptions>, 'position'>;
points: Array<Point>;
title: string | null;
Expand Down Expand Up @@ -68,35 +61,45 @@ export default abstract class<
> extends Controller<HTMLElement> {
static values = {
providerOptions: Object,
view: Object,
center: Object,
zoom: Number,
fitBoundsToMarkers: Boolean,
markers: Array,
polygons: Array,
options: Object,
};

declare viewValue: MapView<MapOptions, MarkerOptions, InfoWindowOptions, PolygonOptions>;
declare centerValue: Point | null;
declare zoomValue: number | null;
declare fitBoundsToMarkersValue: boolean;
declare markersValue: Array<MarkerDefinition<MarkerOptions, InfoWindowOptions>>;
declare polygonsValue: Array<PolygonDefinition<PolygonOptions, InfoWindowOptions>>;
declare optionsValue: MapOptions;

protected map: Map;
protected markers: Array<Marker> = [];
protected markers = new Map<Marker>();
protected infoWindows: Array<InfoWindow> = [];
protected polygons: Array<Polygon> = [];
protected polygons = new Map<Polygon>();

connect() {
const { center, zoom, options, markers, polygons, fitBoundsToMarkers } = this.viewValue;
const options = this.optionsValue;

this.dispatchEvent('pre-connect', { options });

this.map = this.doCreateMap({ center, zoom, options });
this.map = this.doCreateMap({ center: this.centerValue, zoom: this.zoomValue, options });

markers.forEach((marker) => this.createMarker(marker));
this.markersValue.forEach((marker) => this.createMarker(marker));

polygons.forEach((polygon) => this.createPolygon(polygon));
this.polygonsValue.forEach((polygon) => this.createPolygon(polygon));

if (fitBoundsToMarkers) {
if (this.fitBoundsToMarkersValue) {
this.doFitBoundsToMarkers();
}

this.dispatchEvent('connect', {
map: this.map,
markers: this.markers,
polygons: this.polygons,
markers: [...this.markers.values()],
polygons: [...this.polygons.values()],
infoWindows: this.infoWindows,
});
}
Expand All @@ -116,20 +119,29 @@ export default abstract class<
const marker = this.doCreateMarker(definition);
this.dispatchEvent('marker:after-create', { marker });

this.markers.push(marker);
marker['@id'] = definition['@id'];

this.markers.set(definition['@id'], marker);

return marker;
}

createPolygon(definition: PolygonDefinition<PolygonOptions, InfoWindowOptions>): Polygon {
protected abstract removeMarker(marker: Marker): void;

protected abstract doCreateMarker(definition: MarkerDefinition<MarkerOptions, InfoWindowOptions>): Marker;

public createPolygon(definition: PolygonDefinition<PolygonOptions, InfoWindowOptions>): Polygon {
this.dispatchEvent('polygon:before-create', { definition });
const polygon = this.doCreatePolygon(definition);
this.dispatchEvent('polygon:after-create', { polygon });
this.polygons.push(polygon);

polygon['@id'] = definition['@id'];

this.polygons.set(definition['@id'], polygon);

return polygon;
}

protected abstract doCreateMarker(definition: MarkerDefinition<MarkerOptions, InfoWindowOptions>): Marker;
protected abstract doCreatePolygon(definition: PolygonDefinition<PolygonOptions, InfoWindowOptions>): Polygon;

protected createInfoWindow({
Expand Down Expand Up @@ -166,4 +178,50 @@ export default abstract class<
protected abstract doFitBoundsToMarkers(): void;

protected abstract dispatchEvent(name: string, payload: Record<string, unknown>): void;

public abstract centerValueChanged(): void;

public abstract zoomValueChanged(): void;

public markersValueChanged(): void {
if (!this.map) {
return;
}

this.markers.forEach((marker) => {
if (!this.markersValue.find((m) => m['@id'] === marker['@id'])) {
this.removeMarker(marker);
this.markers.delete(marker['@id']);
}
});

this.markersValue.forEach((marker) => {
if (!this.markers.has(marker['@id'])) {
this.createMarker(marker);
}
});

if (this.fitBoundsToMarkersValue) {
this.doFitBoundsToMarkers();
}
}

public polygonsValueChanged(): void {
if (!this.map) {
return;
}

this.polygons.forEach((polygon) => {
if (!this.polygonsValue.find((p) => p['@id'] === polygon['@id'])) {
polygon.remove();
this.polygons.delete(polygon['@id']);
}
});

this.polygonsValue.forEach((polygon) => {
if (!this.polygons.has(polygon['@id'])) {
this.createPolygon(polygon);
}
});
}
}
Loading

0 comments on commit 7bef0bf

Please sign in to comment.