Skip to content

Commit 65c0cd3

Browse files
authored
feat: cluster click event (#8081)
* feat: add cluster support for Map * remove circle configuration * use image style by default * merge ClusterLayer into FeatureLayer * filter point based features * update ITs * cleanup * silence sonar * fix units test * cleanup * fix default tests * add rendering ITs * fix layers IT * feat: cluster click event * cleanup FeatureLayer.java * improve JavaDoc
1 parent 95b87c0 commit 65c0cd3

6 files changed

Lines changed: 215 additions & 27 deletions

File tree

vaadin-map-flow-parent/vaadin-map-flow-integration-tests/src/main/java/com/vaadin/flow/component/map/ClusterPage.java

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
*/
99
package com.vaadin.flow.component.map;
1010

11+
import java.util.stream.Collectors;
12+
1113
import com.vaadin.flow.component.html.Div;
1214
import com.vaadin.flow.component.html.NativeButton;
1315
import com.vaadin.flow.component.map.configuration.Coordinate;
@@ -39,7 +41,23 @@ public ClusterPage() {
3941
"Customize cluster style", e -> customizeClusterStyle(map));
4042
customizeStyleButton.setId("customize-style");
4143

42-
add(map, toggleClusteringButton, customizeStyleButton);
44+
Div eventLog = new Div();
45+
eventLog.setId("event-log");
46+
eventLog.getElement().getStyle().set("white-space", "pre");
47+
48+
map.addClusterClickListener(event -> {
49+
String features = event.getFeatures().stream()
50+
.map(info -> info.getFeature().getId()).sorted()
51+
.collect(Collectors.joining(", "));
52+
eventLog.add(new Div("cluster-click: " + features));
53+
});
54+
55+
map.addFeatureClickListener(event -> {
56+
eventLog.add(
57+
new Div("feature-click: " + event.getFeature().getId()));
58+
});
59+
60+
add(map, toggleClusteringButton, customizeStyleButton, eventLog);
4361
}
4462

4563
private void configureClustering(Map map) {

vaadin-map-flow-parent/vaadin-map-flow-integration-tests/src/test/java/com/vaadin/flow/components/map/ClusterIT.java

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,19 @@
1515
import com.vaadin.flow.component.map.Assets;
1616
import com.vaadin.flow.component.map.testbench.MapElement;
1717
import com.vaadin.flow.testutil.TestPath;
18+
import com.vaadin.testbench.TestBenchElement;
1819
import com.vaadin.tests.AbstractComponentIT;
1920

2021
@TestPath("vaadin-map/cluster")
2122
public class ClusterIT extends AbstractComponentIT {
2223
private MapElement map;
24+
private TestBenchElement eventLog;
2325

2426
@Before
2527
public void init() {
2628
open();
2729
map = $(MapElement.class).waitForFirst();
30+
eventLog = $("div").id("event-log");
2831
}
2932

3033
@Test
@@ -170,4 +173,34 @@ public void renderCluster_withCustomClusterStyle_usesCustomStyle() {
170173
Assert.assertEquals(0, text.getOffsetX());
171174
Assert.assertEquals(0, text.getOffsetY());
172175
}
176+
177+
@Test
178+
public void clickCluster_triggersClusterClickEvent() {
179+
map.clickAtCoordinates(0, 0);
180+
181+
// Click events are delayed by around 250ms, wait for event
182+
waitSeconds(1);
183+
184+
Assert.assertEquals("cluster-click: m1, m2, m3", eventLog.getText());
185+
}
186+
187+
@Test
188+
public void clickClusterWithSingleFeature_triggersFeatureClickEvent() {
189+
map.getMapReference().getView().setZoom(6);
190+
191+
map.clickAtCoordinates(0, 0);
192+
193+
// Click events are delayed by around 250ms, wait for event
194+
waitSeconds(1);
195+
196+
Assert.assertEquals("feature-click: m1", eventLog.getText());
197+
}
198+
199+
private void waitSeconds(int seconds) {
200+
try {
201+
Thread.sleep(seconds * 1000L);
202+
} catch (InterruptedException e) {
203+
throw new RuntimeException(e);
204+
}
205+
}
173206
}

vaadin-map-flow-parent/vaadin-map-flow/src/main/java/com/vaadin/flow/component/map/MapBase.java

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
import com.vaadin.flow.component.map.configuration.View;
2727
import com.vaadin.flow.component.map.configuration.layer.VectorLayer;
2828
import com.vaadin.flow.component.map.events.MapClickEvent;
29+
import com.vaadin.flow.component.map.events.MapClusterClickEvent;
2930
import com.vaadin.flow.component.map.events.MapFeatureClickEvent;
3031
import com.vaadin.flow.component.map.events.MapFeatureDropEvent;
3132
import com.vaadin.flow.component.map.events.MapViewMoveEndEvent;
@@ -233,6 +234,11 @@ public Registration addClickListener(
233234
* invoked for a click on any feature in the specified layer. For clicks on
234235
* overlapping features, the listener will be invoked only for the top-level
235236
* feature at that location.
237+
* <p>
238+
* When clustering is enabled, the listener will only be invoked for clicks
239+
* on individual features. Use
240+
* {@link #addClusterClickListener(ComponentEventListener)} to listen for
241+
* clicks on clusters.
236242
*
237243
* @param listener
238244
* the listener to trigger
@@ -256,6 +262,11 @@ public Registration addFeatureClickListener(VectorLayer layer,
256262
* {@link #addFeatureClickListener(VectorLayer, ComponentEventListener)}.
257263
* For clicks on overlapping features, the listener will be invoked only for
258264
* the top-level feature at that location.
265+
* <p>
266+
* When clustering is enabled, the listener will only be invoked for clicks
267+
* on individual features. Use
268+
* {@link #addClusterClickListener(ComponentEventListener)} to listen for
269+
* clicks on clusters.
259270
*
260271
* @param listener
261272
* the listener to trigger
@@ -267,6 +278,21 @@ public Registration addFeatureClickListener(
267278
return addListener(MapFeatureClickEvent.class, listener);
268279
}
269280

281+
/**
282+
* Adds a click listener for clusters of features. The listener will be
283+
* invoked for a click on any cluster, in any feature layer. Use
284+
* {@link #addFeatureClickListener(ComponentEventListener)} to listen for
285+
* clicks on individual features.
286+
*
287+
* @param listener
288+
* the listener to trigger
289+
* @return registration for the listener
290+
*/
291+
public Registration addClusterClickListener(
292+
ComponentEventListener<MapClusterClickEvent> listener) {
293+
return addListener(MapClusterClickEvent.class, listener);
294+
}
295+
270296
/**
271297
* Adds an event listener for when a feature is dropped after a drag
272298
* operation. Features can be made draggable by setting
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/**
2+
* Copyright 2000-2025 Vaadin Ltd.
3+
*
4+
* This program is available under Vaadin Commercial License and Service Terms.
5+
*
6+
* See {@literal <https://vaadin.com/commercial-license-and-service-terms>} for the full
7+
* license.
8+
*/
9+
package com.vaadin.flow.component.map.events;
10+
11+
import java.util.ArrayList;
12+
import java.util.Collections;
13+
import java.util.List;
14+
15+
import com.vaadin.flow.component.ComponentEvent;
16+
import com.vaadin.flow.component.DomEvent;
17+
import com.vaadin.flow.component.EventData;
18+
import com.vaadin.flow.component.map.Map;
19+
import com.vaadin.flow.component.map.MapBase;
20+
21+
import tools.jackson.databind.node.ArrayNode;
22+
23+
/**
24+
* Provides data for click events on a cluster of features
25+
*/
26+
@DomEvent("map-cluster-click")
27+
public class MapClusterClickEvent extends ComponentEvent<MapBase> {
28+
29+
private final List<FeatureEventDetails> features;
30+
private final MouseEventDetails details;
31+
32+
public MapClusterClickEvent(Map source, boolean fromClient,
33+
@EventData("event.detail.features.map(feature => feature.id)") ArrayNode featureIds,
34+
@EventData("event.detail.layer.id") String layerId,
35+
@EventData("event.detail.originalEvent.pageX") int pageX,
36+
@EventData("event.detail.originalEvent.pageY") int pageY,
37+
@EventData("event.detail.originalEvent.altKey") boolean altKey,
38+
@EventData("event.detail.originalEvent.ctrlKey") boolean ctrlKey,
39+
@EventData("event.detail.originalEvent.metaKey") boolean metaKey,
40+
@EventData("event.detail.originalEvent.shiftKey") boolean shiftKey,
41+
@EventData("event.detail.originalEvent.button") int button) {
42+
super(source, fromClient);
43+
44+
List<FeatureEventDetails> features = new ArrayList<>();
45+
for (int i = 0; i < featureIds.size(); i++) {
46+
String featureId = featureIds.get(i).asString();
47+
FeatureEventDetails featureEventDetails = MapEventUtil
48+
.getFeatureEventDetails(source.getRawConfiguration(),
49+
layerId, featureId);
50+
features.add(featureEventDetails);
51+
}
52+
this.features = Collections.unmodifiableList(features);
53+
54+
details = new MouseEventDetails();
55+
details.setAbsoluteX(pageX);
56+
details.setAbsoluteY(pageY);
57+
details.setButton(MouseEventDetails.MouseButton.of(button));
58+
details.setAltKey(altKey);
59+
details.setCtrlKey(ctrlKey);
60+
details.setMetaKey(metaKey);
61+
details.setShiftKey(shiftKey);
62+
}
63+
64+
/**
65+
* List of map features in the cluster.
66+
*
67+
* @return the list of features in the cluster
68+
*/
69+
public List<FeatureEventDetails> getFeatures() {
70+
return features;
71+
}
72+
73+
/**
74+
* Gets the click's mouse event details.
75+
*
76+
* @return mouse event details
77+
*/
78+
public MouseEventDetails getMouseDetails() {
79+
return details;
80+
}
81+
}

vaadin-map-flow-parent/vaadin-map-flow/src/main/resources/META-INF/resources/frontend/vaadin-map/mapConnector.js

Lines changed: 26 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { setUserProjection as openLayersSetUserProjection } from 'ol/proj';
1212
import { register as openLayersRegisterProjections } from 'ol/proj/proj4';
1313
import proj4 from 'proj4';
1414
import { synchronize } from './synchronization/index.js';
15-
import { createLookup, getLayerForFeature } from './util';
15+
import { createLookup, getFeatureInfo } from './util';
1616

1717
// By default, use EPSG:4326 projection for all coordinates passed to, and return from the public API.
1818
// Internally coordinates will be converted to the projection used by the map's view.
@@ -99,40 +99,50 @@ function init(mapElement) {
9999
// back-most feature as the last result
100100
const pixelCoordinate = event.pixel;
101101
const featuresAtPixel = mapElement.configuration.getFeaturesAtPixel(pixelCoordinate);
102-
// Create tuples of features and the layer that they are in
103-
const featuresAndLayers = featuresAtPixel.map((feature) => {
104-
const layer = getLayerForFeature(mapElement.configuration.getLayers().getArray(), feature);
105-
return {
106-
feature,
107-
layer
108-
};
109-
});
102+
const featureInfos = featuresAtPixel.map((feature) => getFeatureInfo(mapElement.configuration, feature));
110103

111104
// Map click event
105+
const nonClusterFeatures = featureInfos.filter((info) => info && !info.isCluster);
112106
const mapClickEvent = new CustomEvent('map-click', {
113107
detail: {
114108
coordinate,
115-
features: featuresAndLayers,
109+
features: nonClusterFeatures,
116110
originalEvent: event.originalEvent
117111
}
118112
});
119113

120114
mapElement.dispatchEvent(mapClickEvent);
121115

122116
// Feature click event
123-
if (featuresAndLayers.length > 0) {
117+
if (nonClusterFeatures.length > 0) {
124118
// Send a feature click event for the top-level feature
125-
const featureAndLayer = featuresAndLayers[0];
119+
const featureInfo = nonClusterFeatures[0];
126120
const featureClickEvent = new CustomEvent('map-feature-click', {
127121
detail: {
128-
feature: featureAndLayer.feature,
129-
layer: featureAndLayer.layer,
122+
feature: featureInfo.feature,
123+
layer: featureInfo.layer,
130124
originalEvent: event.originalEvent
131125
}
132126
});
133127

134128
mapElement.dispatchEvent(featureClickEvent);
135129
}
130+
131+
// Cluster click event
132+
const clusterInfos = featureInfos.filter((info) => info && info.isCluster);
133+
if (clusterInfos.length > 0) {
134+
// Send a cluster click event for the top-level cluster
135+
const clusterInfo = clusterInfos[0];
136+
const clusterClickEvent = new CustomEvent('map-cluster-click', {
137+
detail: {
138+
features: clusterInfo.feature.get('features'),
139+
layer: clusterInfo.layer,
140+
originalEvent: event.originalEvent
141+
}
142+
});
143+
144+
mapElement.dispatchEvent(clusterClickEvent);
145+
}
136146
});
137147

138148
// Feature drag&drop
@@ -144,12 +154,12 @@ function init(mapElement) {
144154
translate.on('translateend', (event) => {
145155
const feature = event.features.item(0);
146156
if (!feature) return;
147-
const layer = getLayerForFeature(mapElement.configuration.getLayers().getArray(), feature);
148157

158+
const featureInfo = getFeatureInfo(mapElement.configuration, feature);
149159
const featureDropEvent = new CustomEvent('map-feature-drop', {
150160
detail: {
151161
feature,
152-
layer,
162+
layer: featureInfo.layer,
153163
coordinate: event.coordinate,
154164
startCoordinate: event.startCoordinate
155165
}

vaadin-map-flow-parent/vaadin-map-flow/src/main/resources/META-INF/resources/frontend/vaadin-map/util.js

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -75,16 +75,36 @@ export function createLookup() {
7575
}
7676

7777
/**
78-
* Searches an OpenLayers map instance for the layer whose source contains a specific feature
79-
* @param layers the array of layers configured in the map
80-
* @param feature the feature that should be contained in the layers source
81-
* @returns {*} the layer that contains the feature, or undefined
78+
* Returns information about a feature within an OpenLayers map instance.
79+
* Includes whether the feature is a cluster or a single feature, and
80+
* which layer and source it belongs to.
81+
* @param map
82+
* @param feature
83+
* @returns {{feature: *, layer: *, source: *, isCluster: boolean}}
8284
*/
83-
export function getLayerForFeature(layers, feature) {
84-
return layers.find((layer) => {
85-
const source = layer.getSource && layer.getSource();
86-
const isVectorSource = source && source instanceof VectorSource;
85+
export function getFeatureInfo(map, feature) {
86+
const layer = map
87+
.getLayers()
88+
.getArray()
89+
.find((layer) => {
90+
const source = layer.getSource && layer.getSource();
91+
const isVectorSource = source && source instanceof VectorSource;
92+
return isVectorSource && source.getFeatures().includes(feature);
93+
});
94+
const source = layer && layer.getSource();
95+
96+
// Unwrap single feature from cluster
97+
const clusterFeatures = feature.get('features');
98+
if (Array.isArray(clusterFeatures) && clusterFeatures.length === 1) {
99+
feature = clusterFeatures[0];
100+
}
101+
102+
const isCluster = Array.isArray(clusterFeatures) && clusterFeatures.length > 1;
87103

88-
return isVectorSource && source.getFeatures().includes(feature);
89-
});
104+
return {
105+
feature,
106+
layer,
107+
source,
108+
isCluster
109+
};
90110
}

0 commit comments

Comments
 (0)