@eeacms/volto-bise-policy 1.3.11 → 1.3.13
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.
- package/CHANGELOG.md +37 -1
- package/package.json +2 -1
- package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyCard.jsx +404 -0
- package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyCard.test.jsx +366 -0
- package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyExplorerView.jsx +192 -91
- package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyExplorerView.test.jsx +205 -58
- package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyMap.jsx +329 -178
- package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyMap.test.jsx +243 -123
- package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyMapLazy.jsx +61 -0
- package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyMapLazy.test.jsx +89 -0
- package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyResults.jsx +128 -0
- package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyResults.test.jsx +160 -0
- package/src/components/manage/Blocks/CaseStudyExplorer/FacetPopover.jsx +180 -0
- package/src/components/manage/Blocks/CaseStudyExplorer/FacetPopover.test.jsx +125 -0
- package/src/components/manage/Blocks/CaseStudyExplorer/FilterPanel.jsx +171 -0
- package/src/components/manage/Blocks/CaseStudyExplorer/FilterPanel.test.jsx +181 -0
- package/src/components/manage/Blocks/CaseStudyExplorer/Pagination.jsx +81 -0
- package/src/components/manage/Blocks/CaseStudyExplorer/filters.js +350 -0
- package/src/components/manage/Blocks/CaseStudyExplorer/filters.test.js +399 -0
- package/src/components/manage/Blocks/CaseStudyExplorer/hooks.js +28 -4
- package/src/components/manage/Blocks/CaseStudyExplorer/hooks.test.js +73 -0
- package/src/components/manage/Blocks/CaseStudyExplorer/messages.js +138 -0
- package/src/components/manage/Blocks/CaseStudyExplorer/styles.less +1157 -317
- package/src/components/manage/Blocks/CaseStudyExplorer/testUtils.js +44 -46
- package/src/components/manage/Blocks/CaseStudyExplorer/utils.js +68 -184
- package/src/components/manage/Blocks/CaseStudyExplorer/utils.test.js +91 -158
- package/src/index.js +39 -0
- package/src/openlayers.js +51 -0
- package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyFilters.jsx +0 -383
- package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyFilters.test.jsx +0 -90
- package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyListing.jsx +0 -342
- package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyListing.test.jsx +0 -196
- package/src/components/manage/Blocks/CaseStudyExplorer/FeatureDisplay.jsx +0 -24
- package/src/components/manage/Blocks/CaseStudyExplorer/FeatureDisplay.test.jsx +0 -49
- package/src/components/manage/Blocks/CaseStudyExplorer/FeatureInteraction.jsx +0 -100
- package/src/components/manage/Blocks/CaseStudyExplorer/FeatureInteraction.test.jsx +0 -189
- package/src/components/manage/Blocks/CaseStudyExplorer/InfoOverlay.jsx +0 -111
- package/src/components/manage/Blocks/CaseStudyExplorer/InfoOverlay.test.jsx +0 -159
- package/src/components/manage/Blocks/CaseStudyExplorer/styles.less_old +0 -201
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
-
|
|
3
2
|
import cx from 'classnames';
|
|
4
3
|
|
|
5
4
|
import {
|
|
@@ -12,49 +11,77 @@ import {
|
|
|
12
11
|
import { withOpenLayers } from '@eeacms/volto-openlayers-map';
|
|
13
12
|
import { GISCO_OSM_ATTRIBUTION } from '@eeacms/volto-bise-policy/constants';
|
|
14
13
|
|
|
15
|
-
import
|
|
16
|
-
import FeatureInteraction from './FeatureInteraction';
|
|
17
|
-
import CaseStudyList from './CaseStudyListing';
|
|
14
|
+
import { useIntl } from 'react-intl';
|
|
18
15
|
|
|
16
|
+
import messages from './messages';
|
|
19
17
|
import {
|
|
20
|
-
centerAndResetMapZoom,
|
|
21
18
|
CLUSTER_COLOR,
|
|
19
|
+
SELECTED_COLOR,
|
|
20
|
+
STATUS_COLORS,
|
|
21
|
+
centerAndResetMapZoom,
|
|
22
22
|
getFeatures,
|
|
23
|
-
getSelectInteraction,
|
|
24
|
-
scrollToElement,
|
|
25
23
|
} from './utils';
|
|
26
24
|
|
|
27
25
|
const styleCache = {};
|
|
26
|
+
|
|
28
27
|
const MapContextGateway = ({ setMap }) => {
|
|
29
28
|
const { map } = useMapContext();
|
|
29
|
+
|
|
30
30
|
React.useEffect(() => {
|
|
31
31
|
setMap(map);
|
|
32
32
|
}, [map, setMap]);
|
|
33
|
+
|
|
33
34
|
return null;
|
|
34
35
|
};
|
|
35
36
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
37
|
+
const LEGEND = [
|
|
38
|
+
{ status: 'planned', label: messages.statusPlanned },
|
|
39
|
+
{ status: 'ongoing', label: messages.statusOngoing },
|
|
40
|
+
{ status: 'completed', label: messages.statusCompleted },
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
function getFeaturePath(feature) {
|
|
44
|
+
return feature?.get?.('path');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Case study map. Pins are coloured by current status and clustered when
|
|
49
|
+
* they overlap; clicking a pin selects its case study (the pin turns dark
|
|
50
|
+
* green, a popup shows its title and the results list opens the page holding
|
|
51
|
+
* its card), clicking empty map space clears the selection.
|
|
52
|
+
*/
|
|
53
|
+
function CaseStudyMap({
|
|
54
|
+
items = [],
|
|
55
|
+
selectedPath,
|
|
56
|
+
onSelect,
|
|
57
|
+
setMap,
|
|
58
|
+
map,
|
|
59
|
+
focusRequest,
|
|
60
|
+
resetRequest,
|
|
61
|
+
ol,
|
|
62
|
+
}) {
|
|
63
|
+
const intl = useIntl();
|
|
64
|
+
const features = React.useMemo(
|
|
65
|
+
() => getFeatures({ cases: items, ol }),
|
|
66
|
+
[items, ol],
|
|
67
|
+
);
|
|
49
68
|
const [resetMapButtonClass, setResetMapButtonClass] =
|
|
50
69
|
React.useState('inactive');
|
|
70
|
+
const [mapReady, setMapReady] = React.useState(false);
|
|
71
|
+
const containerRef = React.useRef(null);
|
|
72
|
+
const popupRef = React.useRef(null);
|
|
73
|
+
|
|
74
|
+
const selectedItem = React.useMemo(
|
|
75
|
+
() => items.find((item) => item.properties?.path === selectedPath) || null,
|
|
76
|
+
[items, selectedPath],
|
|
77
|
+
);
|
|
78
|
+
const popupTitle = selectedItem?.properties?.title || null;
|
|
51
79
|
|
|
52
80
|
const [tileWMSSources] = React.useState([
|
|
53
81
|
new ol.source.TileWMS({
|
|
54
82
|
url: 'https://gisco-services.ec.europa.eu/maps/service',
|
|
55
83
|
attributions: GISCO_OSM_ATTRIBUTION,
|
|
56
84
|
params: {
|
|
57
|
-
// LAYERS: 'OSMBlossomComposite', OSMCartoComposite, OSMPositronComposite
|
|
58
85
|
LAYERS: 'OSMPositronComposite',
|
|
59
86
|
TILED: true,
|
|
60
87
|
},
|
|
@@ -62,12 +89,7 @@ function CaseStudyMap(props) {
|
|
|
62
89
|
transition: 0,
|
|
63
90
|
}),
|
|
64
91
|
]);
|
|
65
|
-
const [pointsSource] = React.useState(
|
|
66
|
-
new ol.source.Vector({
|
|
67
|
-
features,
|
|
68
|
-
}),
|
|
69
|
-
);
|
|
70
|
-
|
|
92
|
+
const [pointsSource] = React.useState(new ol.source.Vector());
|
|
71
93
|
const [clusterSource] = React.useState(
|
|
72
94
|
new ol.source.Cluster({
|
|
73
95
|
distance: 19,
|
|
@@ -75,110 +97,220 @@ function CaseStudyMap(props) {
|
|
|
75
97
|
}),
|
|
76
98
|
);
|
|
77
99
|
|
|
78
|
-
// `ol` is a fresh object literal on every render (see withOpenLayers)
|
|
79
|
-
//
|
|
80
|
-
// actually change. Refreshing the points source regenerates the cluster
|
|
81
|
-
// features, which drops the currently selected feature from the rendered set.
|
|
100
|
+
// `ol` is a fresh object literal on every render (see withOpenLayers), so
|
|
101
|
+
// effects must not depend on it.
|
|
82
102
|
const olRef = React.useRef(ol);
|
|
83
103
|
olRef.current = ol;
|
|
84
104
|
|
|
85
105
|
React.useEffect(() => {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
106
|
+
pointsSource.clear();
|
|
107
|
+
pointsSource.addFeatures(features);
|
|
108
|
+
}, [features, pointsSource]);
|
|
109
|
+
|
|
110
|
+
// The add-on's `Layer.Vector` applies the `style` prop only when it creates
|
|
111
|
+
// the layer: later prop changes go through `layer.set(...)`, which cannot
|
|
112
|
+
// replace an OpenLayers style. So the style function has to stay stable and
|
|
113
|
+
// read the current selection from a ref, and the pins have to be redrawn by
|
|
114
|
+
// hand whenever that selection changes.
|
|
115
|
+
const selectedPathRef = React.useRef(selectedPath);
|
|
116
|
+
selectedPathRef.current = selectedPath;
|
|
117
|
+
|
|
118
|
+
const clusterStyle = React.useMemo(
|
|
119
|
+
() => clusterStyleFor({ ol, selectedPathRef }),
|
|
120
|
+
[ol],
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
React.useEffect(() => {
|
|
124
|
+
// Dispatches a change on the source, which makes the cluster source
|
|
125
|
+
// re-cluster and the layer paint again — with the new selection colour.
|
|
126
|
+
pointsSource.changed();
|
|
127
|
+
}, [selectedPath, pointsSource]);
|
|
128
|
+
|
|
129
|
+
// Keep the title popup over its pin: OpenLayers positions features, not DOM
|
|
130
|
+
// nodes, so the anchor is moved on every frame the map renders (pan, zoom,
|
|
131
|
+
// resize) and hidden while the pin is outside the viewport.
|
|
132
|
+
React.useEffect(() => {
|
|
133
|
+
if (!map || !selectedItem) return undefined;
|
|
134
|
+
|
|
135
|
+
const anchor = popupRef.current;
|
|
136
|
+
const coordinates = selectedItem.geometry?.coordinates;
|
|
137
|
+
if (!anchor || !coordinates) return undefined;
|
|
138
|
+
|
|
139
|
+
const position = olRef.current.proj.fromLonLat(coordinates);
|
|
140
|
+
let lastX = null;
|
|
141
|
+
let lastY = null;
|
|
142
|
+
|
|
143
|
+
const place = () => {
|
|
144
|
+
const pixel = map.getPixelFromCoordinate(position);
|
|
145
|
+
if (!pixel) return;
|
|
146
|
+
|
|
147
|
+
const x = Math.round(pixel[0]);
|
|
148
|
+
const y = Math.round(pixel[1]);
|
|
149
|
+
const size = map.getSize?.();
|
|
150
|
+
const inView =
|
|
151
|
+
!size || (x >= 0 && y >= 0 && x <= size[0] && y <= size[1]);
|
|
152
|
+
|
|
153
|
+
if (x === lastX && y === lastY) return;
|
|
154
|
+
lastX = x;
|
|
155
|
+
lastY = y;
|
|
156
|
+
|
|
157
|
+
anchor.style.transform = `translate(${x}px, ${y}px)`;
|
|
158
|
+
anchor.style.visibility = inView ? 'visible' : 'hidden';
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
place();
|
|
162
|
+
map.on('postrender', place);
|
|
163
|
+
|
|
164
|
+
return () => {
|
|
165
|
+
map.un('postrender', place);
|
|
166
|
+
};
|
|
167
|
+
}, [map, selectedItem]);
|
|
93
168
|
|
|
94
169
|
React.useEffect(() => {
|
|
95
|
-
if (!map) return;
|
|
96
|
-
|
|
97
|
-
const
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
if (selectedCase) {
|
|
104
|
-
const coords = selectedCase.geometry.flatCoordinates;
|
|
105
|
-
const pixel = map.getPixelFromCoordinate(coords);
|
|
106
|
-
const selectedFeature = map.getFeaturesAtPixel(pixel)[0];
|
|
107
|
-
|
|
108
|
-
selectInteraction.getFeatures().clear();
|
|
109
|
-
if (selectedFeature) {
|
|
110
|
-
selectInteraction.getFeatures().push(selectedFeature);
|
|
111
|
-
}
|
|
112
|
-
} else {
|
|
113
|
-
selectInteraction.getFeatures().clear();
|
|
114
|
-
}
|
|
170
|
+
if (!map) return undefined;
|
|
171
|
+
|
|
172
|
+
const onMapClick = (event) => {
|
|
173
|
+
const feature = map.forEachFeatureAtPixel(event.pixel, (hit) => hit);
|
|
174
|
+
|
|
175
|
+
if (!feature) {
|
|
176
|
+
onSelect?.(null);
|
|
177
|
+
return;
|
|
115
178
|
}
|
|
116
179
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
) {
|
|
122
|
-
setResetMapButtonClass('inactive');
|
|
123
|
-
} else {
|
|
124
|
-
setResetMapButtonClass('active');
|
|
180
|
+
const members = feature.get('features') || [];
|
|
181
|
+
if (members.length === 1) {
|
|
182
|
+
onSelect?.(getFeaturePath(members[0]));
|
|
183
|
+
return;
|
|
125
184
|
}
|
|
185
|
+
|
|
186
|
+
// A cluster: let the visitor zoom into it rather than guess a pin.
|
|
187
|
+
onSelect?.(null);
|
|
188
|
+
const extent = olRef.current.extent.createEmpty();
|
|
189
|
+
members.forEach((member) => {
|
|
190
|
+
olRef.current.extent.extend(extent, member.getGeometry().getExtent());
|
|
191
|
+
});
|
|
192
|
+
map.getView().fit(extent, { duration: 500, padding: [40, 40, 40, 40] });
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const onPointerMove = (event) => {
|
|
196
|
+
const hit = map.hasFeatureAtPixel(map.getEventPixel(event.originalEvent));
|
|
197
|
+
map.getViewport().style.cursor = hit ? 'pointer' : '';
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
map.on('click', onMapClick);
|
|
201
|
+
map.on('pointermove', onPointerMove);
|
|
202
|
+
|
|
203
|
+
return () => {
|
|
204
|
+
map.un('click', onMapClick);
|
|
205
|
+
map.un('pointermove', onPointerMove);
|
|
126
206
|
};
|
|
207
|
+
}, [map, onSelect]);
|
|
127
208
|
|
|
128
|
-
|
|
209
|
+
// The container is sized by CSS (a square that follows the column width),
|
|
210
|
+
// so OpenLayers has to be told whenever that box changes size. Without this
|
|
211
|
+
// the canvas keeps the height it measured at mount and leaves the bottom of
|
|
212
|
+
// the square blank.
|
|
213
|
+
React.useEffect(() => {
|
|
214
|
+
if (!map) return undefined;
|
|
215
|
+
|
|
216
|
+
const container = containerRef.current;
|
|
217
|
+
if (!container) return undefined;
|
|
218
|
+
|
|
219
|
+
const syncSize = () => map.updateSize();
|
|
220
|
+
syncSize();
|
|
221
|
+
|
|
222
|
+
if (typeof ResizeObserver === 'undefined') {
|
|
223
|
+
window.addEventListener('resize', syncSize);
|
|
224
|
+
return () => window.removeEventListener('resize', syncSize);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const observer = new ResizeObserver(syncSize);
|
|
228
|
+
observer.observe(container);
|
|
229
|
+
return () => observer.disconnect();
|
|
230
|
+
}, [map]);
|
|
231
|
+
|
|
232
|
+
// The map instance exists before its first frame is painted: keep the
|
|
233
|
+
// loader up until OpenLayers reports that everything (tiles included) has
|
|
234
|
+
// finished rendering.
|
|
235
|
+
React.useEffect(() => {
|
|
236
|
+
if (!map || mapReady) return undefined;
|
|
237
|
+
|
|
238
|
+
const markReady = () => setMapReady(true);
|
|
239
|
+
map.on('rendercomplete', markReady);
|
|
240
|
+
// Never leave the loader up forever: a failing tile source or a hidden
|
|
241
|
+
// tab should not hide the map.
|
|
242
|
+
const fallback = window.setTimeout(markReady, 10000);
|
|
243
|
+
|
|
244
|
+
return () => {
|
|
245
|
+
map.un('rendercomplete', markReady);
|
|
246
|
+
window.clearTimeout(fallback);
|
|
247
|
+
};
|
|
248
|
+
}, [map, mapReady]);
|
|
249
|
+
|
|
250
|
+
React.useEffect(() => {
|
|
251
|
+
if (!map) return undefined;
|
|
252
|
+
|
|
253
|
+
const onMoveEnd = () => {
|
|
254
|
+
const zoom = Math.round(map.getView().getZoom() * 10) / 10;
|
|
255
|
+
const center = map.getView().getCenter();
|
|
256
|
+
const atHome =
|
|
257
|
+
zoom === 4.5 &&
|
|
258
|
+
JSON.stringify(center) ===
|
|
259
|
+
JSON.stringify(
|
|
260
|
+
olRef.current.proj.transform([8, 55], 'EPSG:4326', 'EPSG:3857'),
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
setResetMapButtonClass(atHome ? 'inactive' : 'active');
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
map.on('moveend', onMoveEnd);
|
|
129
267
|
|
|
130
268
|
return () => {
|
|
131
|
-
map.un('moveend',
|
|
269
|
+
map.un('moveend', onMoveEnd);
|
|
132
270
|
};
|
|
133
|
-
}, [map
|
|
271
|
+
}, [map]);
|
|
272
|
+
|
|
273
|
+
// "Show on map" on a card: fly to that case study without touching the
|
|
274
|
+
// current selection.
|
|
275
|
+
React.useEffect(() => {
|
|
276
|
+
if (!map || !focusRequest?.path) return;
|
|
277
|
+
|
|
278
|
+
const target = items.find(
|
|
279
|
+
(item) => item.properties?.path === focusRequest.path,
|
|
280
|
+
);
|
|
281
|
+
const coordinates = target?.geometry?.coordinates;
|
|
282
|
+
if (!coordinates) return;
|
|
283
|
+
|
|
284
|
+
map.getView().animate({
|
|
285
|
+
center: olRef.current.proj.fromLonLat(coordinates),
|
|
286
|
+
zoom: 8,
|
|
287
|
+
duration: 700,
|
|
288
|
+
});
|
|
289
|
+
// focusRequest.nonce changes on every request, even for the same case.
|
|
290
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
291
|
+
}, [focusRequest?.nonce]);
|
|
134
292
|
|
|
135
|
-
|
|
293
|
+
// "Reset map" on an already selected card: go back to the European extent.
|
|
294
|
+
React.useEffect(() => {
|
|
295
|
+
if (!map || !resetRequest?.nonce) return;
|
|
136
296
|
|
|
137
|
-
|
|
138
|
-
|
|
297
|
+
centerAndResetMapZoom({ map, ol: olRef.current });
|
|
298
|
+
// resetRequest.nonce changes on every request.
|
|
299
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
300
|
+
}, [resetRequest?.nonce]);
|
|
139
301
|
|
|
140
|
-
return
|
|
141
|
-
<div
|
|
142
|
-
<
|
|
302
|
+
return (
|
|
303
|
+
<div className="cse-map" id="cse-map-container" ref={containerRef}>
|
|
304
|
+
<Map
|
|
143
305
|
view={{
|
|
144
|
-
center: ol.proj.fromLonLat([
|
|
306
|
+
center: ol.proj.fromLonLat([8, 55]),
|
|
145
307
|
showFullExtent: true,
|
|
146
|
-
zoom: 4,
|
|
308
|
+
zoom: 4.5,
|
|
147
309
|
}}
|
|
148
310
|
pixelRatio={1}
|
|
149
311
|
>
|
|
150
|
-
<Controls
|
|
151
|
-
attribution={true}
|
|
152
|
-
attributionOptions={{ collapsible: false }}
|
|
153
|
-
/>
|
|
312
|
+
<Controls attribution attributionOptions={{ collapsible: false }} />
|
|
154
313
|
<Layers>
|
|
155
|
-
{hideFilters ? null : (
|
|
156
|
-
<button
|
|
157
|
-
className={cx(
|
|
158
|
-
'reset-map-button ui button secondary',
|
|
159
|
-
String(resetMapButtonClass),
|
|
160
|
-
)}
|
|
161
|
-
onClick={() => {
|
|
162
|
-
scrollToElement('search-input');
|
|
163
|
-
onSelectedCase(null);
|
|
164
|
-
centerAndResetMapZoom({ map, ol });
|
|
165
|
-
getSelectInteraction(map)?.getFeatures().clear();
|
|
166
|
-
}}
|
|
167
|
-
>
|
|
168
|
-
<span className="result-info-title">Reset map</span>
|
|
169
|
-
<i className="icon ri-map-2-line"></i>
|
|
170
|
-
</button>
|
|
171
|
-
)}
|
|
172
|
-
<InfoOverlay
|
|
173
|
-
selectedFeature={selectedCase}
|
|
174
|
-
onFeatureSelect={onSelectedCase}
|
|
175
|
-
layerId={tileWMSSources[0]}
|
|
176
|
-
hideFilters={hideFilters}
|
|
177
|
-
/>
|
|
178
|
-
<FeatureInteraction
|
|
179
|
-
onFeatureSelect={onSelectedCase}
|
|
180
|
-
hideFilters={hideFilters}
|
|
181
|
-
/>
|
|
182
314
|
<Layer.Tile source={tileWMSSources[0]} zIndex={0} />
|
|
183
315
|
<Layer.Vector
|
|
184
316
|
style={clusterStyle}
|
|
@@ -187,88 +319,107 @@ function CaseStudyMap(props) {
|
|
|
187
319
|
/>
|
|
188
320
|
<MapContextGateway setMap={setMap} />
|
|
189
321
|
</Layers>
|
|
190
|
-
</
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
>
|
|
197
|
-
<span className="legend-title">Current status</span>
|
|
198
|
-
{[
|
|
199
|
-
['planned', '#006bb8'],
|
|
200
|
-
['ongoing', '#ff9933'],
|
|
201
|
-
['completed', '#00a390'],
|
|
202
|
-
].map(([status, color]) => (
|
|
203
|
-
<span className="legend-item" key={status}>
|
|
204
|
-
<span
|
|
205
|
-
className="legend-dot"
|
|
206
|
-
style={{ backgroundColor: color }}
|
|
207
|
-
/>
|
|
208
|
-
{status}
|
|
209
|
-
</span>
|
|
210
|
-
))}
|
|
211
|
-
</div>
|
|
212
|
-
<CaseStudyList
|
|
213
|
-
map={map}
|
|
214
|
-
activeItems={activeItems}
|
|
215
|
-
selectedCase={selectedCase}
|
|
216
|
-
onSelectedCase={onSelectedCase}
|
|
217
|
-
pointsSource={pointsSource}
|
|
218
|
-
searchInput={searchInput}
|
|
219
|
-
/>
|
|
220
|
-
</>
|
|
322
|
+
</Map>
|
|
323
|
+
|
|
324
|
+
{popupTitle && (
|
|
325
|
+
<div className="cse-map-popup-anchor" ref={popupRef}>
|
|
326
|
+
<div className="cse-map-popup">{popupTitle}</div>
|
|
327
|
+
</div>
|
|
221
328
|
)}
|
|
329
|
+
|
|
330
|
+
{!mapReady && (
|
|
331
|
+
<div className="cse-map-loading" role="status" aria-live="polite">
|
|
332
|
+
<span className="cse-loading-spinner" aria-hidden="true" />
|
|
333
|
+
{intl.formatMessage(messages.loadingMap)}
|
|
334
|
+
</div>
|
|
335
|
+
)}
|
|
336
|
+
|
|
337
|
+
<button
|
|
338
|
+
type="button"
|
|
339
|
+
className={cx('cse-map-reset', String(resetMapButtonClass))}
|
|
340
|
+
onClick={() => {
|
|
341
|
+
onSelect?.(null);
|
|
342
|
+
centerAndResetMapZoom({ map, ol });
|
|
343
|
+
}}
|
|
344
|
+
>
|
|
345
|
+
<i className="icon ri-map-2-line" aria-hidden="true" />
|
|
346
|
+
{intl.formatMessage(messages.resetMap, {})}
|
|
347
|
+
</button>
|
|
348
|
+
|
|
349
|
+
<div
|
|
350
|
+
className="cse-map-legend"
|
|
351
|
+
aria-label={intl.formatMessage(messages.mapLegend)}
|
|
352
|
+
>
|
|
353
|
+
<span className="cse-map-legend-title">
|
|
354
|
+
{intl.formatMessage(messages.facetStatus)}
|
|
355
|
+
</span>
|
|
356
|
+
<ul className="cse-map-legend-items">
|
|
357
|
+
{LEGEND.map(({ status, label }) => (
|
|
358
|
+
<li key={status} className="cse-map-legend-item">
|
|
359
|
+
<span
|
|
360
|
+
className="cse-map-legend-dot"
|
|
361
|
+
style={{ backgroundColor: STATUS_COLORS[status] }}
|
|
362
|
+
aria-hidden="true"
|
|
363
|
+
/>
|
|
364
|
+
{intl.formatMessage(label)}
|
|
365
|
+
</li>
|
|
366
|
+
))}
|
|
367
|
+
</ul>
|
|
368
|
+
</div>
|
|
222
369
|
</div>
|
|
223
|
-
)
|
|
370
|
+
);
|
|
224
371
|
}
|
|
225
372
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
373
|
+
/**
|
|
374
|
+
* Cluster style: coloured ring per status for a single case study, a dark
|
|
375
|
+
* green disc for the selected one, a numbered bubble for a cluster.
|
|
376
|
+
*/
|
|
377
|
+
const clusterStyleFor = ({ ol, selectedPathRef }) => {
|
|
378
|
+
const styleFor = (size) => {
|
|
379
|
+
if (styleCache[size]) return styleCache[size];
|
|
230
380
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
});
|
|
249
|
-
styleCache[size] = style;
|
|
250
|
-
}
|
|
381
|
+
styleCache[size] = new ol.style.Style({
|
|
382
|
+
image: new ol.style.Circle({
|
|
383
|
+
radius: 12 + Math.min(Math.floor(size / 3), 10),
|
|
384
|
+
stroke: new ol.style.Stroke({ color: '#FFFFFF', width: 2 }),
|
|
385
|
+
fill: new ol.style.Fill({ color: CLUSTER_COLOR }),
|
|
386
|
+
}),
|
|
387
|
+
text: new ol.style.Text({
|
|
388
|
+
text: size.toString(),
|
|
389
|
+
fill: new ol.style.Fill({ color: '#FFFFFF' }),
|
|
390
|
+
}),
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
return styleCache[size];
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
return (feature) => {
|
|
397
|
+
const members = feature.get('features') || [];
|
|
251
398
|
|
|
252
|
-
if (
|
|
253
|
-
const color = feature.values_.features[0].get('color');
|
|
399
|
+
if (members.length !== 1) return styleFor(members.length);
|
|
254
400
|
|
|
401
|
+
const member = members[0];
|
|
402
|
+
const selectedPath = selectedPathRef.current;
|
|
403
|
+
|
|
404
|
+
if (selectedPath && member.get('path') === selectedPath) {
|
|
255
405
|
return new ol.style.Style({
|
|
406
|
+
zIndex: 10,
|
|
256
407
|
image: new ol.style.Circle({
|
|
257
|
-
radius:
|
|
258
|
-
fill: new ol.style.Fill({
|
|
259
|
-
|
|
260
|
-
}),
|
|
261
|
-
stroke: new ol.style.Stroke({
|
|
262
|
-
color: color,
|
|
263
|
-
width: 6,
|
|
264
|
-
}),
|
|
408
|
+
radius: 11,
|
|
409
|
+
fill: new ol.style.Fill({ color: SELECTED_COLOR }),
|
|
410
|
+
stroke: new ol.style.Stroke({ color: '#FFFFFF', width: 1 }),
|
|
265
411
|
}),
|
|
266
412
|
});
|
|
267
|
-
} else {
|
|
268
|
-
return style;
|
|
269
413
|
}
|
|
270
|
-
|
|
271
|
-
|
|
414
|
+
|
|
415
|
+
return new ol.style.Style({
|
|
416
|
+
image: new ol.style.Circle({
|
|
417
|
+
radius: 7,
|
|
418
|
+
fill: new ol.style.Fill({ color: '#FFFFFF' }),
|
|
419
|
+
stroke: new ol.style.Stroke({ color: member.get('color'), width: 5 }),
|
|
420
|
+
}),
|
|
421
|
+
});
|
|
422
|
+
};
|
|
272
423
|
};
|
|
273
424
|
|
|
274
425
|
export default withOpenLayers(CaseStudyMap);
|