@eeacms/volto-cca-policy 0.2.69 → 0.2.70

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 (22) hide show
  1. package/CHANGELOG.md +28 -8
  2. package/package.json +1 -1
  3. package/src/components/Result/ClusterHorizontalCardItem.jsx +136 -0
  4. package/src/components/manage/Blocks/C3SIndicatorsGlossaryBlock/C3SIndicatorsGlossaryBlockView.test.jsx +34 -0
  5. package/src/components/manage/Blocks/C3SIndicatorsListingBlock/C3SIndicatorsListingBlockView.test.jsx +43 -0
  6. package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyExplorerEdit.test.jsx +89 -0
  7. package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyExplorerView.test.jsx +93 -0
  8. package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyMap.jsx +13 -79
  9. package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyMap.test.jsx +131 -0
  10. package/src/components/manage/Blocks/CaseStudyExplorer/FeatureDisplay.jsx +5 -1
  11. package/src/components/manage/Blocks/CaseStudyExplorer/FeatureDisplay.test.jsx +59 -0
  12. package/src/components/manage/Blocks/CaseStudyExplorer/FeatureInteraction.jsx +132 -53
  13. package/src/components/manage/Blocks/CaseStudyExplorer/styles.less +4 -0
  14. package/src/components/manage/Blocks/CaseStudyExplorer/useInteractiveStyles.jsx +189 -0
  15. package/src/components/manage/Blocks/MKHMap/InfoOverlay.jsx +20 -8
  16. package/src/components/manage/Blocks/MKHMap/View.jsx +1 -1
  17. package/src/components/theme/ImageGallery/styles.less +1 -1
  18. package/src/index.js +4 -1
  19. package/src/search/config.js +79 -26
  20. package/src/search/facets.js +12 -1
  21. package/src/search/views.js +15 -0
  22. package/src/components/manage/Blocks/CaseStudyExplorer/InfoOverlay.jsx +0 -80
@@ -0,0 +1,131 @@
1
+ import React from 'react';
2
+ import { MemoryRouter } from 'react-router-dom';
3
+ import configureStore from 'redux-mock-store';
4
+ import { render } from '@testing-library/react';
5
+ import '@testing-library/jest-dom/extend-expect';
6
+ import { Provider } from 'react-intl-redux';
7
+ import CaseStudyMap from './CaseStudyMap';
8
+ import * as utils from './utils';
9
+
10
+ const mockStore = configureStore();
11
+
12
+ jest.mock('./utils', () => ({
13
+ getFeatures: jest.fn(),
14
+ }));
15
+
16
+ // Mock the OpenLayers API
17
+ jest.mock('@eeacms/volto-openlayers-map/api', () => ({
18
+ Map: jest.fn(({ children }) => <div>{children}</div>),
19
+ Layer: {
20
+ Tile: jest.fn(() => <div />),
21
+ },
22
+ Layers: jest.fn(({ children }) => <div>{children}</div>),
23
+ }));
24
+
25
+ // Mock additional OpenLayers functionality
26
+ jest.mock('@eeacms/volto-openlayers-map', () => ({
27
+ openlayers: {
28
+ source: {
29
+ TileWMS: jest.fn().mockImplementation(() => ({})),
30
+ },
31
+ proj: {
32
+ fromLonLat: jest.fn().mockReturnValue([0, 0]),
33
+ },
34
+ control: {
35
+ defaults: jest.fn().mockReturnValue([]),
36
+ },
37
+ },
38
+ }));
39
+
40
+ jest.mock('./FeatureInteraction', () => () => (
41
+ <div>Mock Feature Interaction</div>
42
+ ));
43
+
44
+ const store = mockStore({
45
+ userSession: { token: '1234' },
46
+ intl: {
47
+ locale: 'en',
48
+ messages: {},
49
+ },
50
+ });
51
+
52
+ describe('CaseStudyMap', () => {
53
+ it('renders the component with features', () => {
54
+ const data = {
55
+ activeItems: [
56
+ {
57
+ geometry: {
58
+ color: '#009900',
59
+ svg: { fill_color: '#009900' },
60
+ type: 'Point',
61
+ coordinates: [4.19059753418, 52.0476343911],
62
+ },
63
+ properties: {
64
+ impacts: ',FLOODING,SEALEVELRISE,STORM,',
65
+ title: 'Sand Motor',
66
+ portal_type: 'casestudy',
67
+ description:
68
+ '<p>The Sand Motor is a mega-nourishment implemented in the Delfland Coast.</p>',
69
+ url: 'http://website.com',
70
+ adaptation_options_links:
71
+ "<a href='http://website.com'>Beach and shoreface nourishment</a>",
72
+ elements_str: 'Nature-based solutions',
73
+ image: 'http://website.com/preview',
74
+ origin_adaptecca: 20,
75
+ adaptation_options: 'Dune construction',
76
+ },
77
+ },
78
+ ],
79
+ items: [
80
+ {
81
+ geometry: {
82
+ color: '#009900',
83
+ svg: { fill_color: '#009900' },
84
+ type: 'Point',
85
+ coordinates: [4.19059753418, 52.0476343911],
86
+ },
87
+ properties: {
88
+ impacts: ',FLOODING,SEALEVELRISE,STORM,',
89
+ title: 'Sand Motor',
90
+ portal_type: 'casestudy',
91
+ description:
92
+ '<p>The Sand Motor is a mega-nourishment implemented in the Delfland Coast.</p>',
93
+ url: 'http://website.com',
94
+ adaptation_options_links:
95
+ "<a href='http://website.com'>Beach and shoreface nourishment</a>",
96
+ elements_str: 'Nature-based solutions',
97
+ image: 'http://website.com/preview',
98
+ origin_adaptecca: 20,
99
+ adaptation_options: 'Dune construction',
100
+ },
101
+ },
102
+ ],
103
+ };
104
+
105
+ utils.getFeatures.mockReturnValueOnce(data.items); // Mock getFeatures to return data
106
+
107
+ const { container } = render(
108
+ <Provider store={store}>
109
+ <MemoryRouter>
110
+ <CaseStudyMap {...data} />
111
+ </MemoryRouter>
112
+ </Provider>,
113
+ );
114
+
115
+ expect(container).toBeTruthy(); // Ensure the component renders correctly
116
+ });
117
+
118
+ it('does not render the component when there are no features', () => {
119
+ utils.getFeatures.mockReturnValueOnce([]); // Mock getFeatures to return an empty array
120
+
121
+ const { container } = render(
122
+ <Provider store={store}>
123
+ <MemoryRouter>
124
+ <CaseStudyMap activeItems={[]} items={[]} />
125
+ </MemoryRouter>
126
+ </Provider>,
127
+ );
128
+
129
+ expect(container).toBeEmptyDOMElement(); // Expect the container to be empty
130
+ });
131
+ });
@@ -5,7 +5,11 @@ export default function FeatureDisplay({ feature }) {
5
5
  return feature ? (
6
6
  <div id="csepopup">
7
7
  <p>
8
- <strong>{feature.title}</strong> <a href={feature.url}>open DB</a>
8
+ <strong>
9
+ <a className="dbitem" href={feature.url} target="_blank">
10
+ {feature.title}
11
+ </a>
12
+ </strong>
9
13
  </p>
10
14
  <span className="img">
11
15
  <center>
@@ -0,0 +1,59 @@
1
+ import React from 'react';
2
+ import { MemoryRouter } from 'react-router-dom';
3
+ import configureStore from 'redux-mock-store';
4
+ import { render } from '@testing-library/react';
5
+ import '@testing-library/jest-dom/extend-expect';
6
+ import { Provider } from 'react-intl-redux';
7
+ import FeatureDisplay from './FeatureDisplay';
8
+
9
+ const mockStore = configureStore();
10
+
11
+ describe('FeatureDisplay', () => {
12
+ it('should render the component', () => {
13
+ const feature = {
14
+ title: 'Case study',
15
+ url: 'http://example.com',
16
+ image: 'http://example.com/image.jpg',
17
+ impacts: 'Impact A, Impact B',
18
+ adaptation_options_links: '',
19
+ };
20
+
21
+ const store = mockStore({
22
+ userSession: { token: '1234' },
23
+ intl: {
24
+ locale: 'en',
25
+ messages: {},
26
+ },
27
+ });
28
+
29
+ const { container } = render(
30
+ <Provider store={store}>
31
+ <MemoryRouter>
32
+ <FeatureDisplay feature={feature} />
33
+ </MemoryRouter>
34
+ </Provider>,
35
+ );
36
+ expect(container).toBeTruthy();
37
+ });
38
+
39
+ it('should not render the component', () => {
40
+ const feature = null;
41
+
42
+ const store = mockStore({
43
+ userSession: { token: '1234' },
44
+ intl: {
45
+ locale: 'en',
46
+ messages: {},
47
+ },
48
+ });
49
+
50
+ const { container } = render(
51
+ <Provider store={store}>
52
+ <MemoryRouter>
53
+ <FeatureDisplay feature={feature} />
54
+ </MemoryRouter>
55
+ </Provider>,
56
+ );
57
+ expect(container).toBeTruthy();
58
+ });
59
+ });
@@ -1,52 +1,73 @@
1
1
  import React from 'react';
2
2
  import { openlayers as ol } from '@eeacms/volto-openlayers-map';
3
3
  import { useMapContext } from '@eeacms/volto-openlayers-map/api';
4
-
5
- const useStyles = () => {
6
- const selected = React.useMemo(
7
- () =>
8
- new ol.style.Style({
9
- image: new ol.style.Circle({
10
- radius: 12,
11
- fill: new ol.style.Fill({
12
- color: '#005c96',
13
- }),
14
- stroke: new ol.style.Stroke({
15
- color: 'rgba(0, 92, 150, 0.9)',
16
- width: 2,
17
- }),
18
- }),
19
- }),
20
- [],
4
+ import FeatureDisplay from './FeatureDisplay';
5
+ import { getFeatures } from './utils';
6
+ import {
7
+ clusterStyle,
8
+ useStyles,
9
+ getExtentOfFeatures,
10
+ setClicked,
11
+ } from './useInteractiveStyles';
12
+
13
+ export default function FeatureInteraction({
14
+ selectedFeature,
15
+ onFeatureSelect,
16
+ mapCenter,
17
+ features,
18
+ activeItems,
19
+ }) {
20
+ const { map } = useMapContext();
21
+ const [clusterLayer, setClusterLayer] = React.useState();
22
+ const [clusterCirclesLayer, setClusterCirclesLayer] = React.useState();
23
+ const { selectStyle, clusterCircleStyle } = useStyles();
24
+
25
+ const [pointsSource] = React.useState(
26
+ new ol.source.Vector({
27
+ features,
28
+ }),
21
29
  );
22
30
 
23
- const selectStyle = React.useCallback(
24
- (feature) => {
25
- // const color = feature.get('COLOR') || '#eeeeee';
26
- // selected.getFill().setColor(color);
27
- const color = feature.values_.features[0].values_['color'] || '#ccc';
28
- selected.image_.getFill().setColor(color);
29
- return selected;
30
- },
31
- [selected],
31
+ const [clusterSource] = React.useState(
32
+ new ol.source.Cluster({
33
+ distance: 50,
34
+ source: pointsSource,
35
+ }),
32
36
  );
33
37
 
34
- return { selected, selectStyle };
35
- };
36
-
37
- function getExtentOfFeatures(features) {
38
- const points = features.map((f) => f.getGeometry().flatCoordinates);
39
- const point = new ol.geom.MultiPoint(points);
40
- return point.getExtent();
41
- }
38
+ React.useEffect(() => {
39
+ if (activeItems) {
40
+ pointsSource.clear();
41
+ pointsSource.addFeatures(getFeatures(activeItems));
42
+ }
43
+ }, [activeItems, pointsSource]);
42
44
 
43
- export default function FeatureInteraction({ onFeatureSelect }) {
44
- const { map } = useMapContext();
45
- const { selectStyle } = useStyles();
45
+ const [isClient, setIsClient] = React.useState(false);
46
+ React.useEffect(() => setIsClient(true), []);
46
47
 
48
+ // form the clusters layer
47
49
  React.useEffect(() => {
48
50
  if (!map) return;
49
51
 
52
+ // Layer displaying the expanded view of overlapping cluster members.
53
+ const clusterCirclesLayer = new ol.layer.Vector({
54
+ source: clusterSource,
55
+ style: clusterCircleStyle,
56
+ });
57
+ setClusterCirclesLayer(clusterCirclesLayer);
58
+ map.addLayer(clusterCirclesLayer);
59
+
60
+ const clusterLayer = new ol.layer.Vector({
61
+ source: clusterSource,
62
+ style: clusterStyle,
63
+ });
64
+ setClusterLayer(clusterLayer);
65
+ map.addLayer(clusterLayer);
66
+ }, [map, clusterSource, clusterCircleStyle]);
67
+
68
+ React.useEffect(() => {
69
+ if (!(map && clusterLayer)) return;
70
+
50
71
  const select = new ol.interaction.Select({
51
72
  condition: ol.condition.click,
52
73
  style: selectStyle,
@@ -54,6 +75,10 @@ export default function FeatureInteraction({ onFeatureSelect }) {
54
75
 
55
76
  select.on('select', function (e) {
56
77
  const features = e.target.getFeatures().getArray();
78
+ // const pixel = e.mapBrowserEvent.pixel;
79
+ // clusterLayer.getFeatures(pixel).then((fs) => {
80
+ // console.log('fs', { fs, features });
81
+ // });
57
82
 
58
83
  features.forEach((feature) => {
59
84
  const subfeatures = feature.values_.features;
@@ -64,33 +89,87 @@ export default function FeatureInteraction({ onFeatureSelect }) {
64
89
  onFeatureSelect(selectedFeature);
65
90
  const paddedExtent = ol.extent.buffer(extent, 50000);
66
91
 
67
- map.getView().fit(paddedExtent, { ...map.getSize(), duration: 1000 });
92
+ const view = map.getView();
93
+ view.fit(paddedExtent, { ...map.getSize(), duration: 1000 });
68
94
  } else {
69
95
  // zoom to extent of cluster points
96
+
97
+ const view = map.getView();
98
+ const resolution = view.getResolution();
70
99
  const extent = getExtentOfFeatures(subfeatures);
71
100
 
72
- let extentBuffer =
73
- (extent[3] - extent[1] + extent[2] - extent[0]) / 4;
74
- extentBuffer = extentBuffer < 500 ? 500 : extentBuffer;
75
- const paddedExtent = ol.extent.buffer(extent, extentBuffer);
76
- map.getView().fit(paddedExtent, { ...map.getSize(), duration: 1000 });
101
+ if (
102
+ view.getZoom() === view.getMaxZoom() ||
103
+ (ol.extent.getWidth(extent) < resolution &&
104
+ ol.extent.getHeight(extent) < resolution)
105
+ ) {
106
+ // console.log('set cluster circles style', features[0]);
107
+ setClicked(features[0], resolution);
108
+ clusterCirclesLayer.setStyle(clusterCircleStyle);
109
+ } else {
110
+ let extentBuffer =
111
+ (extent[3] - extent[1] + extent[2] - extent[0]) / 4;
112
+ extentBuffer = extentBuffer < 500 ? 500 : extentBuffer;
113
+ const paddedExtent = ol.extent.buffer(extent, extentBuffer);
114
+ map
115
+ .getView()
116
+ .fit(paddedExtent, { ...map.getSize(), duration: 1000 });
117
+ }
77
118
  }
78
119
  });
79
-
80
- return null;
81
120
  });
82
121
 
83
- map.addInteraction(select);
122
+ function handleClick(evt) {
123
+ if (evt.originalEvent.target.tagName === 'A') return;
124
+ if (selectedFeature) {
125
+ onFeatureSelect(null);
84
126
 
85
- // TODO: does this accumulate?
86
- map.on('pointermove', (e) => {
127
+ const view = map.getView();
128
+ view.animate({
129
+ ...mapCenter,
130
+ duration: 1000,
131
+ });
132
+ }
133
+ }
134
+
135
+ function handlePointerMove(e) {
87
136
  const pixel = map.getEventPixel(e.originalEvent);
88
137
  const hit = map.hasFeatureAtPixel(pixel);
89
138
  map.getViewport().style.cursor = hit ? 'pointer' : '';
90
- });
139
+ }
91
140
 
92
- return () => map.removeInteraction(select);
93
- }, [map, selectStyle, onFeatureSelect]);
94
-
95
- return null;
141
+ map.addInteraction(select);
142
+ map.on('click', handleClick);
143
+ map.on('pointermove', handlePointerMove);
144
+
145
+ return () => {
146
+ map.removeInteraction(select);
147
+ map.un('click', handleClick);
148
+ map.un('pointermove', handlePointerMove);
149
+ };
150
+ }, [
151
+ map,
152
+ selectStyle,
153
+ onFeatureSelect,
154
+ selectedFeature,
155
+ mapCenter,
156
+ clusterLayer,
157
+ clusterCircleStyle,
158
+ clusterCirclesLayer,
159
+ ]);
160
+
161
+ return isClient ? (
162
+ <div
163
+ id="popup-overlay"
164
+ style={{
165
+ position: 'absolute',
166
+ bottom: '30px',
167
+ right: '30px',
168
+ zIndex: 1,
169
+ visibility: selectedFeature ? 'visible' : 'hidden',
170
+ }}
171
+ >
172
+ {selectedFeature ? <FeatureDisplay feature={selectedFeature} /> : null}
173
+ </div>
174
+ ) : null;
96
175
  }
@@ -1,3 +1,7 @@
1
+ a.dbitem {
2
+ font-size: 14px;
3
+ }
4
+
1
5
  #csepopup > span {
2
6
  display: 'block';
3
7
  margin-bottom: '10px';
@@ -0,0 +1,189 @@
1
+ import React from 'react';
2
+ import { openlayers as ol } from '@eeacms/volto-openlayers-map';
3
+ const _cached = {};
4
+
5
+ function getStyle(size, haveAdaptecca) {
6
+ let style = _cached[size + '_' + haveAdaptecca];
7
+
8
+ if (!style) {
9
+ style =
10
+ size === 1
11
+ ? new ol.style.Style({
12
+ image: new ol.style.Circle({
13
+ radius: 5,
14
+ stroke: new ol.style.Stroke({
15
+ color: '#fff',
16
+ }),
17
+ fill: new ol.style.Fill({
18
+ color: haveAdaptecca ? '#00ffff' : '#005c96',
19
+ }),
20
+ }),
21
+ })
22
+ : new ol.style.Style({
23
+ image: new ol.style.Circle({
24
+ radius: 10 + Math.min(Math.floor(size / 3), 10),
25
+ stroke: new ol.style.Stroke({
26
+ color: '#fff',
27
+ }),
28
+ fill: new ol.style.Fill({
29
+ color: haveAdaptecca ? '#318CE7' : '#005c96',
30
+ }),
31
+ }),
32
+ text: new ol.style.Text({
33
+ text: size.toString(),
34
+ fill: new ol.style.Fill({
35
+ color: '#fff',
36
+ }),
37
+ }),
38
+ });
39
+ _cached[size + '_' + haveAdaptecca] = style;
40
+ }
41
+ return style;
42
+ }
43
+
44
+ export function useStyles() {
45
+ const selectStyle = React.useCallback((feature) => {
46
+ const selected = new ol.style.Style({
47
+ image: new ol.style.Circle({
48
+ radius: 12,
49
+ fill: new ol.style.Fill({
50
+ color: '#005c96',
51
+ }),
52
+ stroke: new ol.style.Stroke({
53
+ color: 'rgba(0, 92, 150, 0.9)',
54
+ width: 2,
55
+ }),
56
+ }),
57
+ });
58
+ // const color = feature.get('COLOR') || '#eeeeee';
59
+ // selected.getFill().setColor(color);
60
+ const color = feature.values_.features[0].values_['color'] || '#ccc';
61
+ selected.image_.getFill().setColor(color);
62
+ return selected;
63
+ }, []);
64
+
65
+ const convexHullStroke = React.useMemo(
66
+ () =>
67
+ new ol.style.Stroke({
68
+ color: 'rgba(204, 85, 0, 1)',
69
+ width: 1.5,
70
+ }),
71
+ [],
72
+ );
73
+
74
+ const clusterMemberStyle = React.useMemo(() => {
75
+ const darkIcon = new ol.style.Icon({
76
+ src: 'data/icons/emoticon-cool.svg',
77
+ });
78
+ const lightIcon = new ol.style.Icon({
79
+ src: 'data/icons/emoticon-cool-outline.svg',
80
+ });
81
+ const _clusterMemberStyle = (clusterMember) => {
82
+ return new ol.style.Style({
83
+ geometry: clusterMember.getGeometry(),
84
+ image: clusterMember.get('LEISTUNG') > 5 ? darkIcon : lightIcon,
85
+ });
86
+ };
87
+ return _clusterMemberStyle;
88
+ }, []);
89
+
90
+ const clusterCircleStyle = React.useCallback(
91
+ (cluster, resolution) => {
92
+ // if (cluster?.ol_uid) {
93
+ // console.log(cluster?.ol_uid, cluster);
94
+ // }
95
+ // console.log('clusterCircleStyle', {
96
+ // cluster,
97
+ // resolution,
98
+ // clickFeature,
99
+ // clickResolution,
100
+ // });
101
+ if (cluster !== clickFeature || resolution !== clickResolution) {
102
+ // console.log('return null', {
103
+ // cluster,
104
+ // clickFeature,
105
+ // resolution,
106
+ // clickResolution,
107
+ // });
108
+ return null;
109
+ }
110
+
111
+ // console.log('members', cluster);
112
+ const clusterMembers = cluster.get('features');
113
+ const centerCoordinates = cluster.getGeometry().getCoordinates();
114
+ return generatePointsCircle(
115
+ clusterMembers.length,
116
+ cluster.getGeometry().getCoordinates(),
117
+ resolution,
118
+ ).reduce((styles, coordinates, i) => {
119
+ const point = new ol.geom.Point(coordinates);
120
+ const line = new ol.geom.LineString([centerCoordinates, coordinates]);
121
+ styles.unshift(
122
+ new ol.style.Style({
123
+ geometry: line,
124
+ stroke: convexHullStroke,
125
+ }),
126
+ );
127
+ styles.push(
128
+ clusterMemberStyle(
129
+ new ol.feature.Feature({
130
+ ...clusterMembers[i].getProperties(),
131
+ geometry: point,
132
+ }),
133
+ ),
134
+ );
135
+ return styles;
136
+ }, []);
137
+ },
138
+ [convexHullStroke, clusterMemberStyle],
139
+ );
140
+
141
+ return { clusterCircleStyle, selectStyle };
142
+ }
143
+
144
+ let clickFeature, clickResolution;
145
+
146
+ export function setClicked(feature, resolution) {
147
+ clickFeature = feature;
148
+ clickResolution = resolution;
149
+ }
150
+
151
+ export function generatePointsCircle(count, clusterCenter, resolution) {
152
+ const circumference =
153
+ circleDistanceMultiplier * circleFootSeparation * (2 + count);
154
+ let legLength = circumference / (Math.PI * 2); //radius from circumference
155
+ const angleStep = (Math.PI * 2) / count;
156
+ const res = [];
157
+ let angle;
158
+
159
+ legLength = Math.max(legLength, 35) * resolution; // Minimum distance to get outside the cluster icon.
160
+
161
+ for (let i = 0; i < count; ++i) {
162
+ // Clockwise, like spiral.
163
+ angle = circleStartAngle + i * angleStep;
164
+ res.push([
165
+ clusterCenter[0] + legLength * Math.cos(angle),
166
+ clusterCenter[1] + legLength * Math.sin(angle),
167
+ ]);
168
+ }
169
+
170
+ return res;
171
+ }
172
+ export function clusterStyle(feature) {
173
+ const size = feature.get('features').length;
174
+ const cases = feature
175
+ .get('features')
176
+ .filter((_case) => _case['values_']['origin_adaptecca'] < 20);
177
+
178
+ return getStyle(size, cases.length > 0 ? 1 : 0);
179
+ }
180
+
181
+ export function getExtentOfFeatures(features) {
182
+ const points = features.map((f) => f.getGeometry().flatCoordinates);
183
+ const point = new ol.geom.MultiPoint(points);
184
+ return point.getExtent();
185
+ }
186
+
187
+ const circleDistanceMultiplier = 1;
188
+ const circleFootSeparation = 28;
189
+ const circleStartAngle = Math.PI / 2;