@eeacms/volto-marine-policy 2.0.3 → 2.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/jest-addon.config.js +4 -4
  3. package/package.json +6 -4
  4. package/src/components/Blocks/DemoSitesExplorer/DemoSitesExplorerEdit.js +5 -0
  5. package/src/components/Blocks/DemoSitesExplorer/DemoSitesExplorerView.js +115 -0
  6. package/src/components/Blocks/DemoSitesExplorer/DemoSitesFilters.jsx +406 -0
  7. package/src/components/Blocks/DemoSitesExplorer/DemoSitesFilters.test.jsxZ +91 -0
  8. package/src/components/Blocks/DemoSitesExplorer/DemoSitesListing.jsx +383 -0
  9. package/src/components/Blocks/DemoSitesExplorer/DemoSitesMap.jsx +230 -0
  10. package/src/components/Blocks/DemoSitesExplorer/FeatureDisplay.jsx +97 -0
  11. package/src/components/Blocks/DemoSitesExplorer/FeatureDisplay.test.jsxZ +48 -0
  12. package/src/components/Blocks/DemoSitesExplorer/FeatureInteraction.jsx +95 -0
  13. package/src/components/Blocks/DemoSitesExplorer/InfoOverlay.jsx +79 -0
  14. package/src/components/Blocks/DemoSitesExplorer/hooks.js +20 -0
  15. package/src/components/Blocks/DemoSitesExplorer/images/icon-depth.png +0 -0
  16. package/src/components/Blocks/DemoSitesExplorer/images/icon-light.png +0 -0
  17. package/src/components/Blocks/DemoSitesExplorer/images/search.svg +3 -0
  18. package/src/components/Blocks/DemoSitesExplorer/index.js +16 -0
  19. package/src/components/Blocks/DemoSitesExplorer/mockJsdom.js +8 -0
  20. package/src/components/Blocks/DemoSitesExplorer/styles.less +376 -0
  21. package/src/components/Blocks/DemoSitesExplorer/utils.js +193 -0
  22. package/src/components/Blocks/DemoSitesExplorer/utils.test.jsZ +63 -0
  23. package/src/components/index.js +1 -0
  24. package/src/express-middleware.js +37 -0
  25. package/src/index.js +11 -5
@@ -0,0 +1,91 @@
1
+ import './mockJsdom';
2
+ import React from 'react';
3
+ import '@testing-library/jest-dom/extend-expect';
4
+ import { render } from '@testing-library/react';
5
+
6
+ import {
7
+ DemoSitesFilters,
8
+ ActiveFilters,
9
+ SearchBox,
10
+ DemoSitesFilter,
11
+ } from './DemoSitesFilters';
12
+
13
+ describe('DemoSitesFilters', () => {
14
+ const mockSetActiveFilters = jest.fn();
15
+ window.URL.createObjectURL = function () {};
16
+ global.URL.createObjectURL = jest.fn();
17
+
18
+ const mockFilters = {
19
+ sectors: { sector1: 'Sector 1', sector2: 'Sector 2' },
20
+ };
21
+
22
+ it('renders without crashing', () => {
23
+ const { container } = render(
24
+ <DemoSitesFilters
25
+ filters={mockFilters}
26
+ activeFilters={{ sectors: [] }}
27
+ setActiveFilters={mockSetActiveFilters}
28
+ />,
29
+ );
30
+
31
+ expect(container.querySelector('.filter-wrapper')).toBeInTheDocument();
32
+ });
33
+ });
34
+
35
+ describe('ActiveFilters', () => {
36
+ const mockSetActiveFilters = jest.fn();
37
+ const mockFilters = {
38
+ sectors: { sector1: 'Sector 1', sector2: 'Sector 2' },
39
+ };
40
+
41
+ it('renders without crashing', () => {
42
+ render(
43
+ <ActiveFilters
44
+ filters={mockFilters}
45
+ activeFilters={{ sectors: [] }}
46
+ setActiveFilters={mockSetActiveFilters}
47
+ />,
48
+ );
49
+ });
50
+ });
51
+
52
+ describe('SearchBox', () => {
53
+ const mockSetActiveFilters = jest.fn();
54
+ const mockFilters = {
55
+ sectors: { sector1: 'Sector 1', sector2: 'Sector 2' },
56
+ };
57
+
58
+ const mockSetSearchInput = jest.fn();
59
+ const mockSearchInput = 'freshwater';
60
+
61
+ it('renders without crashing', () => {
62
+ render(
63
+ <SearchBox
64
+ filters={mockFilters}
65
+ activeFilters={{ sectors: [] }}
66
+ setActiveFilters={mockSetActiveFilters}
67
+ searchInput={mockSearchInput}
68
+ setSearchInput={mockSetSearchInput}
69
+ />,
70
+ );
71
+ });
72
+ });
73
+
74
+ describe('DemoSitesFilter', () => {
75
+ const mockSetActiveFilters = jest.fn();
76
+ const mockFilters = {
77
+ sectors: { sector1: 'Sector 1', sector2: 'Sector 2' },
78
+ };
79
+
80
+ it('renders without crashing', () => {
81
+ render(
82
+ <DemoSitesFilter
83
+ filterTitle={'Case study filter'}
84
+ filters={mockFilters}
85
+ activeFilters={{ sectors: [] }}
86
+ setActiveFilters={mockSetActiveFilters}
87
+ filterName={'Filter name'}
88
+ />,
89
+ );
90
+ });
91
+ });
@@ -0,0 +1,383 @@
1
+ import React from 'react';
2
+ import {
3
+ centerAndResetMapZoom,
4
+ scrollToElement,
5
+ zoomMapToFeatures,
6
+ isValidURL,
7
+ } from './utils';
8
+
9
+ const showPageNr = (pageNr, currentPage, numberOfPages) => {
10
+ // show first 5 pages
11
+ if (currentPage < 4 && pageNr <= 5) {
12
+ return true;
13
+ }
14
+
15
+ // show last 5 pages
16
+ if (numberOfPages - currentPage < 4 && numberOfPages - pageNr < 5) {
17
+ return true;
18
+ }
19
+
20
+ if (
21
+ currentPage >= 4 &&
22
+ numberOfPages - currentPage >= 4 &&
23
+ pageNr >= currentPage - 2 &&
24
+ pageNr <= currentPage + 2
25
+ ) {
26
+ return true;
27
+ }
28
+
29
+ return false;
30
+ };
31
+
32
+ export default function DemoSitesList(props) {
33
+ const { selectedCase, onSelectedCase, pointsSource, map } = props;
34
+ // const reSearch = new RegExp(`\\b(${searchInput})\\b`, 'gi');
35
+ const [currentPage, setCurrentPage] = React.useState(1);
36
+
37
+ const features = pointsSource
38
+ .getFeatures(selectedCase)
39
+ .sort((item1, item2) =>
40
+ item1.values_.title.localeCompare(item2.values_.title),
41
+ );
42
+ const numberOfPages = Math.ceil(features.length / 10);
43
+
44
+ const displayFatures = features.slice(
45
+ 10 * (currentPage - 1),
46
+ 10 * currentPage,
47
+ );
48
+
49
+ return displayFatures.length === 0 ? (
50
+ <>
51
+ <h3 style={{ margin: 'calc(2rem - 0.1em) 0 1rem' }}>
52
+ We could not find any results for your search criteria
53
+ </h3>
54
+ <ul>
55
+ <li>check the selected filters</li>
56
+ </ul>
57
+ </>
58
+ ) : (
59
+ <>
60
+ <div className="listing">
61
+ {selectedCase ? (
62
+ <div
63
+ className="content-box u-item listing-item result-item"
64
+ style={{
65
+ marginTop: '2em',
66
+ padding: 'em',
67
+ // border: '3px solid #f2f2f2',
68
+ // borderTop: '1em solid #f2f2f2',
69
+ paddingTop: 0,
70
+ backgroundColor: '#f2f2f2',
71
+ border: 'none',
72
+ }}
73
+ >
74
+ <div className="slot-top">
75
+ <div className="listing-body">
76
+ <h3 className="listing-header">
77
+ <a
78
+ target="_blank"
79
+ rel="noopener noreferrer"
80
+ href={selectedCase.path}
81
+ title={selectedCase.title}
82
+ >
83
+ {selectedCase.title}
84
+ </a>
85
+ </h3>
86
+ <p className="listing-description">
87
+ {selectedCase.description}
88
+ </p>
89
+ <div className="slot-bottom">
90
+ <div className="result-bottom">
91
+ {selectedCase.info ? (
92
+ <div className="result-info">
93
+ <span className="result-info-title">Info: </span>
94
+ <span>
95
+ {isValidURL(selectedCase.info) ? (
96
+ <a
97
+ href={selectedCase.info}
98
+ target="_blank"
99
+ rel="noopener noreferrer"
100
+ >
101
+ {selectedCase.info}
102
+ </a>
103
+ ) : (
104
+ <span>{selectedCase.info}</span>
105
+ )}
106
+ </span>
107
+ </div>
108
+ ) : (
109
+ ''
110
+ )}
111
+
112
+ {selectedCase.project ? (
113
+ <div className="result-info">
114
+ <span className="result-info-title">Project: </span>
115
+ <span>{selectedCase.project}</span>
116
+ </div>
117
+ ) : (
118
+ ''
119
+ )}
120
+
121
+ {selectedCase.country ? (
122
+ <div className="result-info">
123
+ <span className="result-info-title">Country: </span>
124
+ <span>{selectedCase.country}</span>
125
+ </div>
126
+ ) : (
127
+ ''
128
+ )}
129
+
130
+ {selectedCase.project_link ? (
131
+ <div className="result-info">
132
+ <span className="result-info-title">
133
+ Project link:{' '}
134
+ </span>
135
+ <span>
136
+ {isValidURL(selectedCase.project_link) ? (
137
+ <a
138
+ href={selectedCase.project_link}
139
+ target="_blank"
140
+ rel="noopener noreferrer"
141
+ >
142
+ {selectedCase.project_link}
143
+ </a>
144
+ ) : (
145
+ <span>{selectedCase.project_link}</span>
146
+ )}
147
+ </span>
148
+ </div>
149
+ ) : (
150
+ ''
151
+ )}
152
+
153
+ <div
154
+ className="result-info show-on-map"
155
+ tabIndex="0"
156
+ role="button"
157
+ onKeyDown={() => {}}
158
+ onClick={() => {
159
+ // scroll to the map
160
+ scrollToElement('search-input');
161
+ // reset map zoom
162
+ onSelectedCase(null);
163
+ centerAndResetMapZoom(map);
164
+ map.getInteractions().array_[9].getFeatures().clear();
165
+ }}
166
+ >
167
+ <span className="result-info-title">Reset map</span>
168
+ <i className="icon ri-map-2-line"></i>
169
+ </div>
170
+ </div>
171
+ </div>
172
+ </div>
173
+ </div>
174
+ </div>
175
+ ) : (
176
+ displayFatures.map((item, index) => {
177
+ return (
178
+ <div className="u-item listing-item result-item" key={index}>
179
+ <div className="slot-top">
180
+ <div className="listing-body">
181
+ <h3 className="listing-header">
182
+ <a
183
+ target="_blank"
184
+ rel="noopener noreferrer"
185
+ href={item.values_.path}
186
+ title={item.values_.title}
187
+ >
188
+ {item.values_.title}
189
+ </a>
190
+ </h3>
191
+ {/* <p
192
+ className="listing-description"
193
+ dangerouslySetInnerHTML={{
194
+ __html: searchInput
195
+ ? item.values_.description.replaceAll(
196
+ reSearch,
197
+ '<b>$1</b>',
198
+ )
199
+ : item.values_.description,
200
+ }}
201
+ ></p> */}
202
+ <div className="slot-bottom">
203
+ <div className="result-bottom">
204
+ {item.values_.info ? (
205
+ <div className="result-info">
206
+ <span className="result-info-title">Info: </span>
207
+ <span>
208
+ {isValidURL(item.values_.info) ? (
209
+ <a
210
+ href={item.values_.info}
211
+ target="_blank"
212
+ rel="noopener noreferrer"
213
+ >
214
+ {item.values_.info}
215
+ </a>
216
+ ) : (
217
+ <span>{item.values_.info}</span>
218
+ )}
219
+ </span>
220
+ </div>
221
+ ) : (
222
+ ''
223
+ )}
224
+
225
+ {item.values_.project ? (
226
+ <div className="result-info">
227
+ <span className="result-info-title">Project: </span>
228
+ <span>{item.values_.project}</span>
229
+ </div>
230
+ ) : (
231
+ ''
232
+ )}
233
+
234
+ {item.values_.country ? (
235
+ <div className="result-info">
236
+ <span className="result-info-title">Country: </span>
237
+ <span>{item.values_.country}</span>
238
+ </div>
239
+ ) : (
240
+ ''
241
+ )}
242
+
243
+ {item.values_.project_link ? (
244
+ <div className="result-info">
245
+ <span className="result-info-title">
246
+ Project link:{' '}
247
+ </span>
248
+ <span>
249
+ {isValidURL(item.values_.project_link) ? (
250
+ <a
251
+ href={item.values_.project_link}
252
+ target="_blank"
253
+ rel="noopener noreferrer"
254
+ >
255
+ {item.values_.project_link}
256
+ </a>
257
+ ) : (
258
+ <span>{item.values_.project_link}</span>
259
+ )}
260
+ </span>
261
+ </div>
262
+ ) : (
263
+ ''
264
+ )}
265
+
266
+ <div
267
+ className="result-info show-on-map"
268
+ tabIndex="0"
269
+ role="button"
270
+ onKeyDown={() => {}}
271
+ onClick={() => {
272
+ map
273
+ .getInteractions()
274
+ .array_[9].getFeatures()
275
+ .clear();
276
+ // scroll to the map
277
+ scrollToElement('ol-map-container');
278
+
279
+ zoomMapToFeatures(map, [item], 5000);
280
+ onSelectedCase(item.values_);
281
+
282
+ const popupOverlay =
283
+ document.getElementById('popup-overlay');
284
+ popupOverlay.style.visibility = 'visible';
285
+
286
+ setTimeout(() => {
287
+ const coords =
288
+ item.values_.geometry.flatCoordinates;
289
+ const pixel = map.getPixelFromCoordinate(coords);
290
+ map
291
+ .getInteractions()
292
+ .array_[9].getFeatures()
293
+ .push(map.getFeaturesAtPixel(pixel)[0]);
294
+ }, 1100);
295
+ }}
296
+ >
297
+ <span className="result-info-title">Show on map</span>
298
+ <i className="icon ri-road-map-line"></i>
299
+ </div>
300
+ </div>
301
+ </div>
302
+ </div>
303
+ </div>
304
+ </div>
305
+ );
306
+ })
307
+ )}
308
+ </div>
309
+ {!selectedCase ? (
310
+ <div className="search-body-footer">
311
+ <div className="ui centered grid">
312
+ <div className="center aligned column">
313
+ <div className="prev-next-paging">
314
+ <div className="paging-wrapper">
315
+ {currentPage !== 1 ? (
316
+ <button
317
+ className="ui button prev double-angle"
318
+ onClick={() => {
319
+ setCurrentPage(1);
320
+ }}
321
+ ></button>
322
+ ) : (
323
+ ''
324
+ )}
325
+ {currentPage !== 1 ? (
326
+ <button
327
+ className="ui button prev single-angle"
328
+ onClick={() => {
329
+ setCurrentPage(currentPage - 1);
330
+ }}
331
+ ></button>
332
+ ) : (
333
+ ''
334
+ )}
335
+ {Array.from(Array(numberOfPages).keys()).map((index) => {
336
+ const pageNr = index + 1;
337
+ return showPageNr(pageNr, currentPage, numberOfPages) ? (
338
+ <button
339
+ className={
340
+ 'ui button pagination-item' +
341
+ (currentPage === pageNr ? ' active' : '')
342
+ }
343
+ onClick={() => {
344
+ setCurrentPage(pageNr);
345
+ }}
346
+ >
347
+ {pageNr}
348
+ </button>
349
+ ) : (
350
+ ''
351
+ );
352
+ })}
353
+ {currentPage !== numberOfPages ? (
354
+ <button
355
+ className="ui button next single-angle"
356
+ onClick={() => {
357
+ setCurrentPage(currentPage + 1);
358
+ }}
359
+ ></button>
360
+ ) : (
361
+ ''
362
+ )}
363
+ {currentPage !== numberOfPages ? (
364
+ <button
365
+ className="ui button next double-angle"
366
+ onClick={() => {
367
+ setCurrentPage(numberOfPages);
368
+ }}
369
+ ></button>
370
+ ) : (
371
+ ''
372
+ )}{' '}
373
+ </div>
374
+ </div>
375
+ </div>
376
+ </div>
377
+ </div>
378
+ ) : (
379
+ ''
380
+ )}
381
+ </>
382
+ );
383
+ }
@@ -0,0 +1,230 @@
1
+ import React from 'react';
2
+
3
+ import cx from 'classnames';
4
+
5
+ import { Map, Layer, Layers, Controls } from '@eeacms/volto-openlayers-map/api';
6
+ import { openlayers as ol } from '@eeacms/volto-openlayers-map';
7
+
8
+ import InfoOverlay from './InfoOverlay';
9
+ import FeatureInteraction from './FeatureInteraction';
10
+ import DemoSitesList from './DemoSitesListing';
11
+ import { useMapContext } from '@eeacms/volto-openlayers-map/api';
12
+
13
+ import { centerAndResetMapZoom, getFeatures, scrollToElement } from './utils';
14
+
15
+ const styleCache = {};
16
+ const MapContextGateway = ({ setMap }) => {
17
+ const { map } = useMapContext();
18
+ React.useEffect(() => {
19
+ setMap(map);
20
+ }, [map, setMap]);
21
+ return null;
22
+ };
23
+
24
+ export default function DemoSitesMap(props) {
25
+ const {
26
+ items,
27
+ activeItems,
28
+ hideFilters,
29
+ selectedCase,
30
+ onSelectedCase,
31
+ searchInput,
32
+ map,
33
+ setMap,
34
+ } = props;
35
+ const features = getFeatures(items);
36
+ const [resetMapButtonClass, setResetMapButtonClass] =
37
+ React.useState('inactive');
38
+
39
+ const [tileWMSSources] = React.useState([
40
+ new ol.source.TileWMS({
41
+ url: 'https://gisco-services.ec.europa.eu/maps/service',
42
+ params: {
43
+ // LAYERS: 'OSMBlossomComposite', OSMCartoComposite, OSMPositronComposite
44
+ LAYERS: 'OSMPositronComposite',
45
+ TILED: true,
46
+ },
47
+ serverType: 'geoserver',
48
+ transition: 0,
49
+ }),
50
+ ]);
51
+ const [pointsSource] = React.useState(
52
+ new ol.source.Vector({
53
+ features,
54
+ }),
55
+ );
56
+
57
+ const [clusterSource] = React.useState(
58
+ new ol.source.Cluster({
59
+ distance: 19,
60
+ source: pointsSource,
61
+ }),
62
+ );
63
+
64
+ React.useEffect(() => {
65
+ if (activeItems) {
66
+ pointsSource.clear();
67
+ pointsSource.addFeatures(getFeatures(activeItems));
68
+ }
69
+ }, [activeItems, pointsSource]);
70
+
71
+ React.useEffect(() => {
72
+ if (!map) return null;
73
+
74
+ const moveendListener = (e) => {
75
+ // console.log('map.getView()', map.getView());
76
+ // console.log('selectedCase', selectedCase);
77
+ const mapZoom = Math.round(map.getView().getZoom() * 10) / 10;
78
+ const mapCenter = map.getView().getCenter();
79
+
80
+ if (selectedCase) {
81
+ const coords = selectedCase.geometry.flatCoordinates;
82
+ const pixel = map.getPixelFromCoordinate(coords);
83
+ map.getInteractions().array_[9].getFeatures().clear();
84
+ map
85
+ .getInteractions()
86
+ .array_[9].getFeatures()
87
+ .push(map.getFeaturesAtPixel(pixel)[0]);
88
+ } else {
89
+ map.getInteractions().array_[9].getFeatures().clear();
90
+ }
91
+
92
+ if (
93
+ mapZoom === 4 &&
94
+ JSON.stringify(mapCenter) ===
95
+ JSON.stringify(ol.proj.transform([10, 49], 'EPSG:4326', 'EPSG:3857'))
96
+ ) {
97
+ setResetMapButtonClass('inactive');
98
+ } else {
99
+ setResetMapButtonClass('active');
100
+ }
101
+ };
102
+
103
+ map.on('moveend', moveendListener);
104
+
105
+ return () => {
106
+ map.un('moveend', moveendListener);
107
+ };
108
+ }, [map, selectedCase, resetMapButtonClass, setResetMapButtonClass]);
109
+
110
+ const clusterStyle = React.useMemo(
111
+ () => selectedClusterStyle(selectedCase),
112
+ [selectedCase],
113
+ );
114
+
115
+ const MapWithSelection = React.useMemo(() => Map, []);
116
+ // console.log('render');
117
+
118
+ return features.length > 0 ? (
119
+ <div id="ol-map-container">
120
+ <MapWithSelection
121
+ view={{
122
+ center: ol.proj.fromLonLat([10, 54]),
123
+ showFullExtent: true,
124
+ zoom: 2.5,
125
+ }}
126
+ pixelRatio={1}
127
+ // controls={ol.control.defaults({ attribution: false })}
128
+ >
129
+ <Controls attribution={false} />
130
+ <Layers>
131
+ {hideFilters ? null : (
132
+ <button
133
+ className={cx(
134
+ 'reset-map-button ui button secondary',
135
+ String(resetMapButtonClass),
136
+ )}
137
+ onClick={() => {
138
+ scrollToElement('search-input');
139
+ onSelectedCase(null);
140
+ centerAndResetMapZoom(map);
141
+ map.getInteractions().array_[9].getFeatures().clear();
142
+ }}
143
+ >
144
+ <span className="result-info-title">Reset map</span>
145
+ <i className="icon ri-map-2-line"></i>
146
+ </button>
147
+ )}
148
+ <InfoOverlay
149
+ selectedFeature={selectedCase}
150
+ onFeatureSelect={onSelectedCase}
151
+ layerId={tileWMSSources[0]}
152
+ hideFilters={hideFilters}
153
+ />
154
+ <FeatureInteraction
155
+ onFeatureSelect={onSelectedCase}
156
+ hideFilters={hideFilters}
157
+ selectedCase={selectedCase}
158
+ />
159
+ <Layer.Tile source={tileWMSSources[0]} zIndex={0} />
160
+ <Layer.Vector
161
+ style={clusterStyle}
162
+ source={clusterSource}
163
+ zIndex={1}
164
+ />
165
+ <MapContextGateway setMap={setMap} />
166
+ </Layers>
167
+ </MapWithSelection>
168
+ {hideFilters ? null : (
169
+ <DemoSitesList
170
+ map={map}
171
+ activeItems={activeItems}
172
+ selectedCase={selectedCase}
173
+ onSelectedCase={onSelectedCase}
174
+ pointsSource={pointsSource}
175
+ searchInput={searchInput}
176
+ />
177
+ )}
178
+ </div>
179
+ ) : null;
180
+ }
181
+
182
+ const selectedClusterStyle = (selectedFeature) => {
183
+ function _clusterStyle(feature) {
184
+ const size = feature.get('features').length;
185
+ let style = styleCache[size];
186
+
187
+ if (!style) {
188
+ style = new ol.style.Style({
189
+ image: new ol.style.Circle({
190
+ radius: 12 + Math.min(Math.floor(size / 3), 10),
191
+ stroke: new ol.style.Stroke({
192
+ color: '#fff',
193
+ }),
194
+ fill: new ol.style.Fill({
195
+ // 309ebc blue 3 + green 3 mix
196
+ color: '#309ebc', // #006BB8 #309ebc
197
+ }),
198
+ }),
199
+ text: new ol.style.Text({
200
+ text: size.toString(),
201
+ fill: new ol.style.Fill({
202
+ color: '#fff',
203
+ }),
204
+ }),
205
+ });
206
+ styleCache[size] = style;
207
+ }
208
+
209
+ if (size === 1) {
210
+ // let color = feature.values_.features[0].values_['color'];
211
+ let color = '#50B0A4'; // #0083E0 #50B0A4
212
+
213
+ return new ol.style.Style({
214
+ image: new ol.style.Circle({
215
+ radius: 6,
216
+ fill: new ol.style.Fill({
217
+ color: '#fff',
218
+ }),
219
+ stroke: new ol.style.Stroke({
220
+ color: color,
221
+ width: 6,
222
+ }),
223
+ }),
224
+ });
225
+ } else {
226
+ return style;
227
+ }
228
+ }
229
+ return _clusterStyle;
230
+ };