Skip to content

Commit b2eee4c

Browse files
committed
[Map] add Circle support
1 parent 9d7ec20 commit b2eee4c

File tree

11 files changed

+492
-21
lines changed

11 files changed

+492
-21
lines changed

src/Map/CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
# CHANGELOG
22

3+
## 2.28
4+
- Add support for creating `Circle` by passing a `Point` and a radius in meters to the `Circle` constructor, e.g.:
5+
```php
6+
$map->addCircle(new Circle(
7+
center: new Point(48.856613, 2.352222), // Paris
8+
radius: 5000 // 5 km
9+
));
10+
```
311
## 2.27
412

513
- The `fitBoundsToMarkers` option is not overridden anymore when using the `Map` LiveComponent, but now respects the value you defined.

src/Map/assets/src/abstract_map_controller.ts

Lines changed: 61 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,24 @@ export type PolylineDefinition<PolylineOptions, InfoWindowOptions> = WithIdentif
8080
extra: Record<string, unknown>;
8181
}>;
8282

83+
export type CircleDefinition<CircleOptions, InfoWindowOptions> = WithIdentifier<{
84+
infoWindow?: InfoWindowWithoutPositionDefinition<InfoWindowOptions>;
85+
center: Point;
86+
radius: number;
87+
title: string | null;
88+
/**
89+
* Raw options passed to the circle constructor, specific to the map provider (e.g.: `L.circle()` for Leaflet).
90+
*/
91+
rawOptions?: CircleOptions;
92+
/**
93+
* Extra data defined by the developer.
94+
* They are not directly used by the Stimulus controller, but they can be used by the developer with event listeners:
95+
* - `ux:map:circle:before-create`
96+
* - `ux:map:circle:after-create`
97+
*/
98+
extra: Record<string, unknown>;
99+
}>;
100+
83101
export type InfoWindowDefinition<InfoWindowOptions> = {
84102
headerContent: string | null;
85103
content: string | null;
@@ -116,6 +134,8 @@ export default abstract class<
116134
Polygon,
117135
PolylineOptions,
118136
Polyline,
137+
CircleOptions,
138+
Circle,
119139
> extends Controller<HTMLElement> {
120140
static values = {
121141
providerOptions: Object,
@@ -125,6 +145,7 @@ export default abstract class<
125145
markers: Array,
126146
polygons: Array,
127147
polylines: Array,
148+
circles: Array,
128149
options: Object,
129150
};
130151

@@ -134,6 +155,7 @@ export default abstract class<
134155
declare markersValue: Array<MarkerDefinition<MarkerOptions, InfoWindowOptions>>;
135156
declare polygonsValue: Array<PolygonDefinition<PolygonOptions, InfoWindowOptions>>;
136157
declare polylinesValue: Array<PolylineDefinition<PolylineOptions, InfoWindowOptions>>;
158+
declare circlesValue: Array<CircleDefinition<CircleOptions, InfoWindowOptions>>;
137159
declare optionsValue: MapOptions;
138160

139161
declare hasCenterValue: boolean;
@@ -142,12 +164,14 @@ export default abstract class<
142164
declare hasMarkersValue: boolean;
143165
declare hasPolygonsValue: boolean;
144166
declare hasPolylinesValue: boolean;
167+
declare hasCirclesValue: boolean;
145168
declare hasOptionsValue: boolean;
146169

147170
protected map: Map;
148171
protected markers = new Map<Identifier, Marker>();
149172
protected polygons = new Map<Identifier, Polygon>();
150173
protected polylines = new Map<Identifier, Polyline>();
174+
protected circles = new Map<Identifier, Circle>();
151175
protected infoWindows: Array<InfoWindow> = [];
152176

153177
private isConnected = false;
@@ -160,6 +184,9 @@ export default abstract class<
160184
private createPolyline: ({
161185
definition,
162186
}: { definition: PolylineDefinition<PolylineOptions, InfoWindowOptions> }) => Polyline;
187+
private createCircle: ({
188+
definition,
189+
}: { definition: CircleDefinition<CircleOptions, InfoWindowOptions> }) => Circle;
163190

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

@@ -171,6 +198,7 @@ export default abstract class<
171198
this.createMarker = this.createDrawingFactory('marker', this.markers, this.doCreateMarker.bind(this));
172199
this.createPolygon = this.createDrawingFactory('polygon', this.polygons, this.doCreatePolygon.bind(this));
173200
this.createPolyline = this.createDrawingFactory('polyline', this.polylines, this.doCreatePolyline.bind(this));
201+
this.createCircle = this.createDrawingFactory('circle', this.circles, this.doCreateCircle.bind(this));
174202

175203
this.map = this.doCreateMap({
176204
center: this.hasCenterValue ? this.centerValue : null,
@@ -180,6 +208,7 @@ export default abstract class<
180208
this.markersValue.forEach((definition) => this.createMarker({ definition }));
181209
this.polygonsValue.forEach((definition) => this.createPolygon({ definition }));
182210
this.polylinesValue.forEach((definition) => this.createPolyline({ definition }));
211+
this.circlesValue.forEach((definition) => this.createCircle({ definition }));
183212

184213
if (this.fitBoundsToMarkersValue) {
185214
this.doFitBoundsToMarkers();
@@ -190,6 +219,7 @@ export default abstract class<
190219
markers: [...this.markers.values()],
191220
polygons: [...this.polygons.values()],
192221
polylines: [...this.polylines.values()],
222+
circles: [...this.circles.values()],
193223
infoWindows: this.infoWindows,
194224
});
195225

@@ -202,7 +232,7 @@ export default abstract class<
202232
element,
203233
}: {
204234
definition: InfoWindowWithoutPositionDefinition<InfoWindowOptions>;
205-
element: Marker | Polygon | Polyline;
235+
element: Marker | Polygon | Polyline | Circle;
206236
}): InfoWindow {
207237
this.dispatchEvent('info-window:before-create', { definition, element });
208238
const infoWindow = this.doCreateInfoWindow({ definition, element });
@@ -248,6 +278,14 @@ export default abstract class<
248278
this.onDrawChanged(this.polylines, this.polylinesValue, this.createPolyline, this.doRemovePolyline);
249279
}
250280

281+
public circlesValueChanged(): void {
282+
if (!this.isConnected) {
283+
return;
284+
}
285+
286+
this.onDrawChanged(this.circles, this.circlesValue, this.createCircle, this.doRemoveCircle);
287+
}
288+
251289
//endregion
252290

253291
//region Abstract factory methods to be implemented by the concrete classes, they are specific to the map provider
@@ -285,12 +323,20 @@ export default abstract class<
285323

286324
protected abstract doRemovePolyline(polyline: Polyline): void;
287325

326+
protected abstract doCreateCircle({
327+
definition,
328+
}: {
329+
definition: CircleDefinition<CircleOptions, InfoWindowOptions>;
330+
}): Circle;
331+
332+
protected abstract doRemoveCircle(circle: Circle): void;
333+
288334
protected abstract doCreateInfoWindow({
289335
definition,
290336
element,
291337
}: {
292338
definition: InfoWindowWithoutPositionDefinition<InfoWindowOptions>;
293-
element: Marker | Polygon | Polyline;
339+
element: Marker | Polygon | Polyline | Circle;
294340
}): InfoWindow;
295341
protected abstract doCreateIcon({
296342
definition,
@@ -318,11 +364,16 @@ export default abstract class<
318364
draws: typeof this.polylines,
319365
factory: typeof this.doCreatePolyline
320366
): typeof this.doCreatePolyline;
367+
private createDrawingFactory(
368+
type: 'circle',
369+
draws: typeof this.circles,
370+
factory: typeof this.doCreateCircle
371+
): typeof this.doCreateCircle;
321372
private createDrawingFactory<
322-
Factory extends typeof this.doCreateMarker | typeof this.doCreatePolygon | typeof this.doCreatePolyline,
373+
Factory extends typeof this.doCreateMarker | typeof this.doCreatePolygon | typeof this.doCreatePolyline | typeof this.doCreateCircle,
323374
Draw extends ReturnType<Factory>,
324375
>(
325-
type: 'marker' | 'polygon' | 'polyline',
376+
type: 'marker' | 'polygon' | 'polyline' | 'circle',
326377
draws: globalThis.Map<WithIdentifier<any>, Draw>,
327378
factory: Factory
328379
): Factory {
@@ -360,6 +411,12 @@ export default abstract class<
360411
factory: typeof this.createPolyline,
361412
remover: typeof this.doRemovePolyline
362413
): void;
414+
private onDrawChanged(
415+
draws: typeof this.circles,
416+
newDrawDefinitions: typeof this.circlesValue,
417+
factory: typeof this.createCircle,
418+
remover: typeof this.doRemoveCircle
419+
): void;
363420
private onDrawChanged<Draw, DrawDefinition extends WithIdentifier<Record<string, unknown>>>(
364421
draws: globalThis.Map<WithIdentifier<any>, Draw>,
365422
newDrawDefinitions: Array<DrawDefinition>,

src/Map/assets/test/abstract_map_controller.test.ts

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,15 @@ class MyMapController extends AbstractMapController {
4444
return polyline;
4545
}
4646

47+
doCreateCircle({ definition }) {
48+
const circle = { circle: 'circle', title: definition.title };
49+
50+
if (definition.infoWindow) {
51+
this.createInfoWindow({ definition: definition.infoWindow, element: circle });
52+
}
53+
return circle;
54+
}
55+
4756
doCreateInfoWindow({ definition, element }) {
4857
if (element.marker) {
4958
return { infoWindow: 'infoWindow', headerContent: definition.headerContent, marker: element.title };
@@ -54,6 +63,9 @@ class MyMapController extends AbstractMapController {
5463
if (element.polyline) {
5564
return { infoWindow: 'infoWindow', headerContent: definition.headerContent, polyline: element.title };
5665
}
66+
if (element.circle) {
67+
return { infoWindow: 'infoWindow', headerContent: definition.headerContent, circle: element.title };
68+
}
5769
}
5870

5971
doFitBoundsToMarkers() {
@@ -72,17 +84,18 @@ describe('AbstractMapController', () => {
7284

7385
beforeEach(() => {
7486
container = mountDOM(`
75-
<div
76-
data-testid="map"
77-
data-controller="map"
78-
data-map-provider-options-value="{}"
79-
data-map-center-value="{&quot;lat&quot;:48.8566,&quot;lng&quot;:2.3522}"
80-
data-map-zoom-value="4"
81-
data-map-fit-bounds-to-markers-value="false"
82-
data-map-options-value="{}"
83-
data-map-markers-value="[{&quot;position&quot;:{&quot;lat&quot;:48.8566,&quot;lng&quot;:2.3522},&quot;title&quot;:&quot;Paris&quot;,&quot;infoWindow&quot;:{&quot;headerContent&quot;:&quot;Paris&quot;,&quot;content&quot;:null,&quot;position&quot;:null,&quot;opened&quot;:false,&quot;autoClose&quot;:true,&quot;extra&quot;:[]},&quot;extra&quot;:[],&quot;@id&quot;:&quot;a69f13edd2e571f3&quot;},{&quot;position&quot;:{&quot;lat&quot;:45.75,&quot;lng&quot;:4.85},&quot;title&quot;:&quot;Lyon&quot;,&quot;infoWindow&quot;:{&quot;headerContent&quot;:&quot;Lyon&quot;,&quot;content&quot;:null,&quot;position&quot;:null,&quot;opened&quot;:false,&quot;autoClose&quot;:true,&quot;extra&quot;:[]},&quot;extra&quot;:[],&quot;@id&quot;:&quot;cb9c1a30d562694b&quot;},{&quot;position&quot;:{&quot;lat&quot;:43.6047,&quot;lng&quot;:1.4442},&quot;title&quot;:&quot;Toulouse&quot;,&quot;infoWindow&quot;:{&quot;headerContent&quot;:&quot;Toulouse&quot;,&quot;content&quot;:null,&quot;position&quot;:null,&quot;opened&quot;:false,&quot;autoClose&quot;:true,&quot;extra&quot;:[]},&quot;extra&quot;:[],&quot;@id&quot;:&quot;e6b3acef1325fb52&quot;}]"
84-
data-map-polygons-value="[{&quot;points&quot;:[{&quot;lat&quot;:48.8566,&quot;lng&quot;:2.3522},{&quot;lat&quot;:45.75,&quot;lng&quot;:4.85},{&quot;lat&quot;:43.6047,&quot;lng&quot;:1.4442}],&quot;title&quot;:null,&quot;infoWindow&quot;:null,&quot;extra&quot;:[],&quot;@id&quot;:&quot;228ae6f5c1b17cfd&quot;},{&quot;points&quot;:[{&quot;lat&quot;:1.4442,&quot;lng&quot;:43.6047},{&quot;lat&quot;:4.85,&quot;lng&quot;:45.75},{&quot;lat&quot;:2.3522,&quot;lng&quot;:48.8566}],&quot;title&quot;:null,&quot;infoWindow&quot;:{&quot;headerContent&quot;:&quot;Polygon&quot;,&quot;content&quot;:null,&quot;position&quot;:null,&quot;opened&quot;:false,&quot;autoClose&quot;:true,&quot;extra&quot;:{&quot;foo&quot;:&quot;bar&quot;}},&quot;extra&quot;:{&quot;fillColor&quot;:&quot;#ff0000&quot;},&quot;@id&quot;:&quot;9874334e4e8caa16&quot;}]"
87+
<div
88+
data-testid="map"
89+
data-controller="map"
90+
data-map-provider-options-value="{}"
91+
data-map-center-value="{&quot;lat&quot;:48.8566,&quot;lng&quot;:2.3522}"
92+
data-map-zoom-value="4"
93+
data-map-fit-bounds-to-markers-value="false"
94+
data-map-options-value="{}"
95+
data-map-markers-value="[{&quot;position&quot;:{&quot;lat&quot;:48.8566,&quot;lng&quot;:2.3522},&quot;title&quot;:&quot;Paris&quot;,&quot;infoWindow&quot;:{&quot;headerContent&quot;:&quot;Paris&quot;,&quot;content&quot;:null,&quot;position&quot;:null,&quot;opened&quot;:false,&quot;autoClose&quot;:true,&quot;extra&quot;:[]},&quot;extra&quot;:[],&quot;@id&quot;:&quot;a69f13edd2e571f3&quot;},{&quot;position&quot;:{&quot;lat&quot;:45.75,&quot;lng&quot;:4.85},&quot;title&quot;:&quot;Lyon&quot;,&quot;infoWindow&quot;:{&quot;headerContent&quot;:&quot;Lyon&quot;,&quot;content&quot;:null,&quot;position&quot;:null,&quot;opened&quot;:false,&quot;autoClose&quot;:true,&quot;extra&quot;:[]},&quot;extra&quot;:[],&quot;@id&quot;:&quot;cb9c1a30d562694b&quot;},{&quot;position&quot;:{&quot;lat&quot;:43.6047,&quot;lng&quot;:1.4442},&quot;title&quot;:&quot;Toulouse&quot;,&quot;infoWindow&quot;:{&quot;headerContent&quot;:&quot;Toulouse&quot;,&quot;content&quot;:null,&quot;position&quot;:null,&quot;opened&quot;:false,&quot;autoClose&quot;:true,&quot;extra&quot;:[]},&quot;extra&quot;:[],&quot;@id&quot;:&quot;e6b3acef1325fb52&quot;}]"
96+
data-map-polygons-value="[{&quot;points&quot;:[{&quot;lat&quot;:48.8566,&quot;lng&quot;:2.3522},{&quot;lat&quot;:45.75,&quot;lng&quot;:4.85},{&quot;lat&quot;:43.6047,&quot;lng&quot;:1.4442}],&quot;title&quot;:null,&quot;infoWindow&quot;:null,&quot;extra&quot;:[],&quot;@id&quot;:&quot;228ae6f5c1b17cfd&quot;},{&quot;points&quot;:[{&quot;lat&quot;:1.4442,&quot;lng&quot;:43.6047},{&quot;lat&quot;:4.85,&quot;lng&quot;:45.75},{&quot;lat&quot;:2.3522,&quot;lng&quot;:48.8566}],&quot;title&quot;:null,&quot;infoWindow&quot;:{&quot;headerContent&quot;:&quot;Polygon&quot;,&quot;content&quot;:null,&quot;position&quot;:null,&quot;opened&quot;:false,&quot;autoClose&quot;:true,&quot;extra&quot;:{&quot;foo&quot;:&quot;bar&quot;}},&quot;extra&quot;:{&quot;fillColor&quot;:&quot;#ff0000&quot;},&quot;@id&quot;:&quot;9874334e4e8caa16&quot;}]"
8597
data-map-polylines-value="[{&quot;points&quot;:[{&quot;lat&quot;:48.1173,&quot;lng&quot;:-1.6778},{&quot;lat&quot;:48.8566,&quot;lng&quot;:2.3522},{&quot;lat&quot;:48.2082,&quot;lng&quot;:16.3738}],&quot;title&quot;:null,&quot;infoWindow&quot;:{&quot;headerContent&quot;:&quot;Polyline&quot;,&quot;content&quot;:null,&quot;position&quot;:null,&quot;opened&quot;:false,&quot;autoClose&quot;:true,&quot;extra&quot;:{&quot;foo&quot;:&quot;bar&quot;}},&quot;extra&quot;:{&quot;strokeColor&quot;:&quot;#ff0000&quot;},&quot;@id&quot;:&quot;0fa955da866c7720&quot;}]"
98+
data-map-circles-value="[{&quot;center&quot;:{&quot;lat&quot;:48.8566,&quot;lng&quot;:2.3522},&quot;title&quot;:null,&quot;infoWindow&quot;:{&quot;headerContent&quot;:&quot;Circle&quot;,&quot;content&quot;:null,&quot;position&quot;:null,&quot;opened&quot;:false,&quot;autoClose&quot;:true,&quot;extra&quot;:{&quot;foo&quot;:&quot;bar&quot;}},&quot;extra&quot;:{&quot;color&quot;:&quot;#ff0000&quot;, &quot;radii&quot;:1000},&quot;@id&quot;:&quot;7c3e1a9b5f2d4e81&quot;}]"
8699
style="height: 600px"
87100
></div>
88101
`);
@@ -92,7 +105,7 @@ describe('AbstractMapController', () => {
92105
clearDOM();
93106
});
94107

95-
it('connect and create map, marker, polygon, polyline and info window', async () => {
108+
it('connect and create map, marker, polygon, polyline, circle and info window', async () => {
96109
const div = getByTestId(container, 'map');
97110
expect(div).not.toHaveClass('connected');
98111

@@ -115,7 +128,7 @@ describe('AbstractMapController', () => {
115128
])
116129
);
117130
expect(controller.polylines).toEqual(new Map([['0fa955da866c7720', { polyline: 'polyline', title: null }]]));
118-
expect(controller.infoWindows).toEqual([
131+
expect(controller.circles).toEqual(new Map([['7c3e1a9b5f2d4e81', { circle: 'circle', title: null }]])); expect(controller.infoWindows).toEqual([
119132
{
120133
headerContent: 'Paris',
121134
infoWindow: 'infoWindow',
@@ -141,6 +154,11 @@ describe('AbstractMapController', () => {
141154
infoWindow: 'infoWindow',
142155
polyline: null,
143156
},
157+
{
158+
headerContent: 'Circle',
159+
infoWindow: 'infoWindow',
160+
circle: null,
161+
},
144162
]);
145163
});
146164
});

src/Map/doc/index.rst

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,21 +208,37 @@ You can add Polylines, which represents a path made by a series of ``Point`` ins
208208
),
209209
));
210210

211+
Add Circles
212+
~~~~~~~~~~
213+
214+
You can add Circles, which represents a circular area defined by a center point and a radius (in meters)::
215+
216+
$map->addCircle(new Circle(
217+
center: new Point(48.8566, 2.3522),
218+
radius: 5000, // 5km radius
219+
title: 'Paris',
220+
infoWindow: new InfoWindow(
221+
content: 'A 5km radius circle centered on Paris',
222+
),
223+
));
224+
211225
Remove elements from Map
212226
~~~~~~~~~~~~~~~~~~~~~~~~
213227

214-
It is possible to remove elements like ``Marker``, ``Polygon`` and ``Polyline`` instances by using ``Map::remove*()`` methods.
228+
It is possible to remove elements like ``Marker``, ``Polygon``, ``Polyline`` and ``Circle`` instances by using ``Map::remove*()`` methods.
215229
It's useful when :ref:`using a Map inside a Live Component <map-live-component>`::
216230

217231
// Add elements
218232
$map->addMarker($marker = new Marker(/* ... */));
219233
$map->addPolygon($polygon = new Polygon(/* ... */));
220234
$map->addPolyline($polyline = new Polyline(/* ... */));
235+
$map->addCircle($circle = new Circle(/* ... */));
221236

222237
// And later, remove those elements
223238
$map->removeMarker($marker);
224239
$map->removePolygon($polygon);
225240
$map->removePolyline($polyline);
241+
$map->removeCircle($circle);
226242

227243
If you haven't stored the element instance, you can still remove them by passing the identifier string::
228244

src/Map/src/Bridge/Google/assets/src/map_controller.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import type {
1717
Point,
1818
PolygonDefinition,
1919
PolylineDefinition,
20+
CircleDefinition,
2021
} from '@symfony/ux-map';
2122

2223
type MapOptions = Pick<
@@ -49,7 +50,9 @@ export default class extends AbstractMapController<
4950
google.maps.PolygonOptions,
5051
google.maps.Polygon,
5152
google.maps.PolylineOptions,
52-
google.maps.Polyline
53+
google.maps.Polyline,
54+
google.maps.CircleOptions,
55+
google.maps.Circle
5356
> {
5457
declare providerOptionsValue: Pick<
5558
LoaderOptions,
@@ -226,6 +229,35 @@ export default class extends AbstractMapController<
226229
polyline.setMap(null);
227230
}
228231

232+
protected doCreateCircle({
233+
definition,
234+
}: {
235+
definition: CircleDefinition<google.maps.CircleOptions, google.maps.InfoWindowOptions>;
236+
}): google.maps.Circle {
237+
const { '@id': _id, center, radius, title, infoWindow, rawOptions = {} } = definition;
238+
239+
const circle = new _google.maps.Circle({
240+
...rawOptions,
241+
center,
242+
radius,
243+
map: this.map,
244+
});
245+
246+
if (title) {
247+
circle.set('title', title);
248+
}
249+
250+
if (infoWindow) {
251+
this.createInfoWindow({ definition: infoWindow, element: circle });
252+
}
253+
254+
return circle;
255+
}
256+
257+
protected doRemoveCircle(circle: google.maps.Circle): void {
258+
circle.setMap(null);
259+
}
260+
229261
protected doCreateInfoWindow({
230262
definition,
231263
element,

0 commit comments

Comments
 (0)