@tpzdsp/next-toolkit 1.1.0 → 1.2.1
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/package.json +70 -8
- package/src/assets/styles/globals.css +2 -0
- package/src/assets/styles/ol.css +122 -0
- package/src/components/Button/Button.test.tsx +1 -1
- package/src/components/Button/Button.tsx +1 -1
- package/src/components/Card/Card.test.tsx +1 -1
- package/src/components/ErrorText/ErrorText.test.tsx +1 -1
- package/src/components/ErrorText/ErrorText.tsx +1 -1
- package/src/components/Heading/Heading.test.tsx +1 -1
- package/src/components/Hint/Hint.test.tsx +1 -1
- package/src/components/Hint/Hint.tsx +1 -1
- package/src/components/Modal/Modal.stories.tsx +252 -0
- package/src/components/Modal/Modal.test.tsx +248 -0
- package/src/components/Modal/Modal.tsx +61 -0
- package/src/components/Paragraph/Paragraph.test.tsx +1 -1
- package/src/components/SlidingPanel/SlidingPanel.test.tsx +1 -2
- package/src/components/accordion/Accordion.stories.tsx +235 -0
- package/src/components/accordion/Accordion.test.tsx +199 -0
- package/src/components/accordion/Accordion.tsx +47 -0
- package/src/components/divider/RuleDivider.stories.tsx +255 -0
- package/src/components/divider/RuleDivider.test.tsx +164 -0
- package/src/components/divider/RuleDivider.tsx +18 -0
- package/src/components/dropdown/DropdownMenu.test.tsx +1 -1
- package/src/components/dropdown/useDropdownMenu.ts +1 -1
- package/src/components/index.ts +6 -2
- package/src/components/layout/header/Header.tsx +2 -1
- package/src/components/layout/header/HeaderAuthClient.tsx +17 -9
- package/src/components/layout/header/HeaderNavClient.tsx +3 -3
- package/src/components/link/ExternalLink.tsx +1 -1
- package/src/components/link/Link.tsx +1 -1
- package/src/components/select/Select.stories.tsx +336 -0
- package/src/components/select/Select.test.tsx +473 -0
- package/src/components/select/Select.tsx +132 -0
- package/src/components/select/SelectSkeleton.stories.tsx +195 -0
- package/src/components/select/SelectSkeleton.test.tsx +105 -0
- package/src/components/select/SelectSkeleton.tsx +16 -0
- package/src/components/select/common.ts +4 -0
- package/src/contexts/index.ts +0 -5
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useClickOutside.test.ts +290 -0
- package/src/hooks/useClickOutside.ts +26 -0
- package/src/index.ts +3 -0
- package/src/map/LayerSwitcherControl.ts +147 -0
- package/src/map/Map.tsx +230 -0
- package/src/map/MapContext.tsx +211 -0
- package/src/map/Popup.tsx +74 -0
- package/src/map/basemaps.ts +79 -0
- package/src/map/geocoder.ts +61 -0
- package/src/map/geometries.ts +60 -0
- package/src/map/images/basemaps/OS.png +0 -0
- package/src/map/images/basemaps/dark.png +0 -0
- package/src/map/images/basemaps/sat-map-tiler.png +0 -0
- package/src/map/images/basemaps/satellite-map-tiler.png +0 -0
- package/src/map/images/basemaps/satellite.png +0 -0
- package/src/map/images/basemaps/streets.png +0 -0
- package/src/map/images/openlayers-logo.png +0 -0
- package/src/map/index.ts +10 -0
- package/src/map/map.ts +40 -0
- package/src/map/osOpenNamesSearch.ts +54 -0
- package/src/map/projections.ts +14 -0
- package/src/ol-geocoder.d.ts +1 -0
- package/src/test/index.ts +1 -0
- package/src/types/api.ts +52 -0
- package/src/types/auth.ts +13 -0
- package/src/types/index.ts +6 -0
- package/src/types/map.ts +26 -0
- package/src/types/navigation.ts +8 -0
- package/src/types/utils.ts +13 -0
- package/src/utils/auth.ts +1 -1
- package/src/utils/http.ts +143 -0
- package/src/utils/index.ts +1 -1
- package/src/utils/utils.ts +1 -1
- package/src/components/link/NextLinkWrapper.tsx +0 -66
- package/src/contexts/ThemeContext.tsx +0 -72
- package/src/types.ts +0 -99
- /package/src/{utils → test}/renderers.tsx +0 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/* eslint-disable no-restricted-syntax */
|
|
2
|
+
import type { StaticImageData } from 'next/image';
|
|
3
|
+
import { Map } from 'ol';
|
|
4
|
+
import { Control } from 'ol/control';
|
|
5
|
+
import type { Options as ControlOptions } from 'ol/control/Control';
|
|
6
|
+
import BaseLayer from 'ol/layer/Base';
|
|
7
|
+
|
|
8
|
+
import OpenLayersLogo from './images/openlayers-logo.png';
|
|
9
|
+
|
|
10
|
+
export class LayerSwitcherControl extends Control {
|
|
11
|
+
map!: Map;
|
|
12
|
+
panel!: HTMLElement;
|
|
13
|
+
isCollapsed = true;
|
|
14
|
+
|
|
15
|
+
constructor(layers: BaseLayer[], options?: ControlOptions) {
|
|
16
|
+
const button = document.createElement('button');
|
|
17
|
+
|
|
18
|
+
button.setAttribute('aria-labelledby', 'Button to toggle layer switcher');
|
|
19
|
+
button.setAttribute('aria-label', 'Button to toggle layer switcher');
|
|
20
|
+
button.setAttribute('title', 'Basemap switcher');
|
|
21
|
+
button.className = 'ol-layer-switcher ol-btn';
|
|
22
|
+
|
|
23
|
+
const switcherImage = document.createElement('img');
|
|
24
|
+
|
|
25
|
+
const openLayersLogoData = OpenLayersLogo as unknown as StaticImageData;
|
|
26
|
+
|
|
27
|
+
switcherImage.src = openLayersLogoData.src;
|
|
28
|
+
switcherImage.setAttribute('alt', 'Openlayers logo');
|
|
29
|
+
switcherImage.setAttribute('width', '20px');
|
|
30
|
+
switcherImage.setAttribute('height', '20px');
|
|
31
|
+
button.appendChild(switcherImage);
|
|
32
|
+
|
|
33
|
+
const element = document.createElement('div');
|
|
34
|
+
|
|
35
|
+
element.className = 'ol-layer-switcher ol-unselectable ol-control';
|
|
36
|
+
element.appendChild(button);
|
|
37
|
+
|
|
38
|
+
super({
|
|
39
|
+
element,
|
|
40
|
+
target: options?.target,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
this.panel = document.createElement('div');
|
|
44
|
+
this.panel.className = 'ol-layer-switcher-panel';
|
|
45
|
+
|
|
46
|
+
layers.forEach((layer) => {
|
|
47
|
+
const img = document.createElement('img');
|
|
48
|
+
|
|
49
|
+
img.src = layer.get('image') as string;
|
|
50
|
+
img.setAttribute('title', layer.get('name'));
|
|
51
|
+
|
|
52
|
+
const switcherText = document.createElement('div');
|
|
53
|
+
|
|
54
|
+
switcherText.textContent = layer.get('name') as string;
|
|
55
|
+
|
|
56
|
+
const btn = document.createElement('button');
|
|
57
|
+
|
|
58
|
+
btn.appendChild(img);
|
|
59
|
+
btn.appendChild(switcherText);
|
|
60
|
+
|
|
61
|
+
btn.addEventListener(
|
|
62
|
+
'click',
|
|
63
|
+
this.selectBasemap.bind(this, layer.get('name') as string),
|
|
64
|
+
false,
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
this.panel.appendChild(btn);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
button.addEventListener('click', this.toggleLayerSwitcher.bind(this), false);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
setMap(map: Map) {
|
|
74
|
+
super.setMap(map);
|
|
75
|
+
|
|
76
|
+
if (map) {
|
|
77
|
+
// Ensure we only set the initial active layer when the map is assigned
|
|
78
|
+
map.once('rendercomplete', this.setInitialActiveLayer.bind(this));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
toggleLayerSwitcher() {
|
|
83
|
+
if (this.isCollapsed) {
|
|
84
|
+
this.element.appendChild(this.panel);
|
|
85
|
+
requestAnimationFrame(() => {
|
|
86
|
+
this.panel.classList.add('open'); // Ensure animation works after adding to DOM
|
|
87
|
+
});
|
|
88
|
+
} else {
|
|
89
|
+
this.panel.classList.remove('open');
|
|
90
|
+
setTimeout(() => {
|
|
91
|
+
this.element.removeChild(this.panel);
|
|
92
|
+
}, 300); // Matches CSS transition time to prevent flickering
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
this.isCollapsed = !this.isCollapsed;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
selectBasemap(layerName: string) {
|
|
99
|
+
const currentBasemap = this.getMap()
|
|
100
|
+
?.getLayers()
|
|
101
|
+
.getArray()
|
|
102
|
+
.filter((layer: BaseLayer) => layer.get('basemap') === true)
|
|
103
|
+
.find((layer: BaseLayer) => layer.getVisible() === true);
|
|
104
|
+
|
|
105
|
+
const newBasemap = this.getMap()
|
|
106
|
+
?.getLayers()
|
|
107
|
+
.getArray()
|
|
108
|
+
.find((layer: BaseLayer) => layer.get('name') === layerName);
|
|
109
|
+
|
|
110
|
+
currentBasemap?.setVisible(false);
|
|
111
|
+
newBasemap?.setVisible(true);
|
|
112
|
+
|
|
113
|
+
this.updateActiveButton(layerName); // Update the active button style
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
setInitialActiveLayer() {
|
|
117
|
+
const map = this.getMap();
|
|
118
|
+
|
|
119
|
+
if (!map) {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const currentBasemap = this.getMap()
|
|
124
|
+
?.getLayers()
|
|
125
|
+
.getArray()
|
|
126
|
+
.filter((layer: BaseLayer) => layer.get('basemap') === true)
|
|
127
|
+
.find((layer: BaseLayer) => layer.getVisible() === true);
|
|
128
|
+
|
|
129
|
+
if (currentBasemap) {
|
|
130
|
+
const activeLayerName = currentBasemap.get('name');
|
|
131
|
+
|
|
132
|
+
this.updateActiveButton(activeLayerName);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
updateActiveButton(layerName: string) {
|
|
137
|
+
const buttons = this.panel.querySelectorAll('button');
|
|
138
|
+
|
|
139
|
+
buttons.forEach((btn: HTMLButtonElement) => {
|
|
140
|
+
if (btn.textContent?.trim() === layerName) {
|
|
141
|
+
btn.classList.add('active');
|
|
142
|
+
} else {
|
|
143
|
+
btn.classList.remove('active');
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}
|
package/src/map/Map.tsx
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
import { Map, Overlay, View } from 'ol';
|
|
6
|
+
import { Control, ScaleLine, defaults as defaultControls } from 'ol/control';
|
|
7
|
+
import { fromLonLat } from 'ol/proj';
|
|
8
|
+
|
|
9
|
+
import { initializeBasemapLayers } from './basemaps';
|
|
10
|
+
import { initializeGeocoder } from './geocoder';
|
|
11
|
+
import { LayerSwitcherControl } from './LayerSwitcherControl';
|
|
12
|
+
import { getPopupPositionClass } from './map';
|
|
13
|
+
import { useMap } from './MapContext';
|
|
14
|
+
import { Popup } from './Popup';
|
|
15
|
+
import type { MapConfig, PopupDirection } from '../types/map';
|
|
16
|
+
|
|
17
|
+
export type MapComponentProps = {
|
|
18
|
+
osMapsApiKey?: string;
|
|
19
|
+
geocoderUrl: string;
|
|
20
|
+
basePath: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const positionTransforms: Record<PopupDirection, string> = {
|
|
24
|
+
'bottom-left': 'top-[-2em] right-[1em]',
|
|
25
|
+
'top-left': 'bottom-[-2em] right-[1em]',
|
|
26
|
+
'top-right': 'bottom-[-2em] left-[1em]',
|
|
27
|
+
'bottom-right': 'top-[-2em] left-[1em]',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const arrowStyles: Record<PopupDirection, string> = {
|
|
31
|
+
'bottom-left': 'right-[-.5em] top-[1em]',
|
|
32
|
+
'bottom-right': 'left-[-.5em] top-[1em]',
|
|
33
|
+
'top-left': 'right-[-.5em] bottom-[1em]',
|
|
34
|
+
'top-right': 'left-[-.5em] bottom-[1em]',
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* This component manages the main map, this is where the basemap layers
|
|
39
|
+
* and basic controls are added to the map. The map component encapsulates
|
|
40
|
+
* a number of child components, used to interact with the map itself.
|
|
41
|
+
*
|
|
42
|
+
* @return {*}
|
|
43
|
+
*/
|
|
44
|
+
export const MapComponent = ({ osMapsApiKey, geocoderUrl, basePath }: MapComponentProps) => {
|
|
45
|
+
const [popupFeatures, setPopupFeatures] = useState([]);
|
|
46
|
+
const [popupCoordinate, setPopupCoordinate] = useState<number[] | null>(null);
|
|
47
|
+
const [popupPositionClass, setPopupPositionClass] = useState<PopupDirection>('bottom-right');
|
|
48
|
+
|
|
49
|
+
const mapInitializedRef = useRef(false);
|
|
50
|
+
|
|
51
|
+
const overlayRef = useRef<Overlay | null>(null);
|
|
52
|
+
|
|
53
|
+
const {
|
|
54
|
+
mapRef,
|
|
55
|
+
mapConfig: { center, zoom },
|
|
56
|
+
setMap,
|
|
57
|
+
setMapConfig,
|
|
58
|
+
map,
|
|
59
|
+
isDrawing,
|
|
60
|
+
isSamplingPointsLoading,
|
|
61
|
+
} = useMap();
|
|
62
|
+
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
// Check if map is already initialized to avoid re-initialization
|
|
65
|
+
if (mapInitializedRef.current) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (!osMapsApiKey) {
|
|
70
|
+
console.warn('OS Maps API key not provided. Geocoder will not be available.');
|
|
71
|
+
// Continue with map initialization but skip geocoder
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Initialise map's basemap layers.
|
|
75
|
+
const layers = initializeBasemapLayers();
|
|
76
|
+
|
|
77
|
+
const scaleLine = new ScaleLine({
|
|
78
|
+
units: 'metric',
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const target = mapRef.current;
|
|
82
|
+
|
|
83
|
+
if (!target) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const newMap = new Map({
|
|
88
|
+
target,
|
|
89
|
+
controls: defaultControls().extend([scaleLine]),
|
|
90
|
+
layers,
|
|
91
|
+
view: new View({
|
|
92
|
+
projection: 'EPSG:3857',
|
|
93
|
+
// extent: transformExtent(
|
|
94
|
+
// [-8.371582, 49.852152, 2.021484, 59.445075],
|
|
95
|
+
// 'EPSG:4326',
|
|
96
|
+
// 'EPSG:3857',
|
|
97
|
+
// ),
|
|
98
|
+
center: fromLonLat(center),
|
|
99
|
+
zoom,
|
|
100
|
+
}),
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// Mark the map as initialized to prevent re-initialization
|
|
104
|
+
// This is a workaround to avoid re-initializing the map when the component
|
|
105
|
+
// re-renders. The map is only initialized once when the component mounts.
|
|
106
|
+
// This is important because the map is a singleton and should not be
|
|
107
|
+
// re-initialized.
|
|
108
|
+
mapInitializedRef.current = true;
|
|
109
|
+
|
|
110
|
+
// Create an instance of the custom provider, passing any options that are
|
|
111
|
+
// required
|
|
112
|
+
// Only initialize geocoder if API key is available
|
|
113
|
+
if (osMapsApiKey) {
|
|
114
|
+
try {
|
|
115
|
+
const geocoder = initializeGeocoder(osMapsApiKey, geocoderUrl, newMap);
|
|
116
|
+
|
|
117
|
+
newMap.addControl(geocoder as Control);
|
|
118
|
+
} catch (error) {
|
|
119
|
+
console.error('Failed to initialize geocoder:', error);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
newMap.addControl(new LayerSwitcherControl(layers));
|
|
124
|
+
|
|
125
|
+
// Setup popup overlay
|
|
126
|
+
const overlay = new Overlay({
|
|
127
|
+
element: document.getElementById('popup-container') ?? undefined,
|
|
128
|
+
positioning: popupPositionClass,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
overlayRef.current = overlay;
|
|
132
|
+
newMap.addOverlay(overlay);
|
|
133
|
+
|
|
134
|
+
newMap.on('click', (event) => {
|
|
135
|
+
if (isDrawing) {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
newMap.forEachFeatureAtPixel(event.pixel, (feature) => {
|
|
140
|
+
const features = feature.get('features');
|
|
141
|
+
|
|
142
|
+
if (features?.length > 0) {
|
|
143
|
+
const coordinate = event.coordinate;
|
|
144
|
+
const direction = getPopupPositionClass(coordinate, newMap);
|
|
145
|
+
|
|
146
|
+
setPopupFeatures(features);
|
|
147
|
+
setPopupCoordinate(coordinate);
|
|
148
|
+
setPopupPositionClass(direction); // new state we add below
|
|
149
|
+
overlay.setPosition(event.coordinate);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
if (!map) {
|
|
155
|
+
setMap(newMap);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return () => {
|
|
159
|
+
newMap?.setTarget();
|
|
160
|
+
};
|
|
161
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
162
|
+
}, []); // This is a rare case where we want to run this only once.
|
|
163
|
+
|
|
164
|
+
// Update map config when map is moved
|
|
165
|
+
useEffect(() => {
|
|
166
|
+
if (!map) {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const view = map.getView();
|
|
171
|
+
const moveHandler = () => {
|
|
172
|
+
const center = view.getCenter();
|
|
173
|
+
|
|
174
|
+
if (center) {
|
|
175
|
+
setMapConfig((prev: MapConfig) => ({
|
|
176
|
+
...prev,
|
|
177
|
+
center,
|
|
178
|
+
zoom: view.getZoom()!,
|
|
179
|
+
}));
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
map.on('moveend', moveHandler);
|
|
184
|
+
|
|
185
|
+
return () => {
|
|
186
|
+
map.un('moveend', moveHandler);
|
|
187
|
+
};
|
|
188
|
+
}, [map, setMapConfig]);
|
|
189
|
+
|
|
190
|
+
const closePopup = () => {
|
|
191
|
+
setPopupFeatures([]);
|
|
192
|
+
setPopupCoordinate(null);
|
|
193
|
+
|
|
194
|
+
if (overlayRef.current) {
|
|
195
|
+
overlayRef.current.setPosition(undefined);
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
return (
|
|
200
|
+
<div className="flex flex-grow min-h-0">
|
|
201
|
+
<div ref={mapRef} className="flex flex-grow relative z-10" id="map">
|
|
202
|
+
{isSamplingPointsLoading ? (
|
|
203
|
+
<div className="absolute inset-0 flex items-center justify-center bg-white/50 z-10">
|
|
204
|
+
<div
|
|
205
|
+
className="w-9 h-9 border-4 border-gray-200 border-t-blue-500 rounded-full
|
|
206
|
+
animate-spin"
|
|
207
|
+
/>
|
|
208
|
+
</div>
|
|
209
|
+
) : null}
|
|
210
|
+
|
|
211
|
+
<div className="absolute top-36 z-20" id="geocoder" />
|
|
212
|
+
|
|
213
|
+
<div
|
|
214
|
+
className={`absolute z-20 ${positionTransforms[popupPositionClass]}`}
|
|
215
|
+
id="popup-container"
|
|
216
|
+
>
|
|
217
|
+
{popupFeatures.length > 0 ? (
|
|
218
|
+
<Popup
|
|
219
|
+
features={popupFeatures}
|
|
220
|
+
onClose={closePopup}
|
|
221
|
+
clickedCoord={popupCoordinate}
|
|
222
|
+
arrowClasses={arrowStyles[popupPositionClass]}
|
|
223
|
+
baseUrl={`${basePath}/sampling-point/`}
|
|
224
|
+
/>
|
|
225
|
+
) : null}
|
|
226
|
+
</div>
|
|
227
|
+
</div>
|
|
228
|
+
</div>
|
|
229
|
+
);
|
|
230
|
+
};
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { Dispatch, RefObject, ReactNode, SetStateAction } from 'react';
|
|
4
|
+
import { createContext, useContext, useRef, useState, useMemo, useCallback } from 'react';
|
|
5
|
+
|
|
6
|
+
import type { Coordinate } from 'ol/coordinate';
|
|
7
|
+
import BaseLayer from 'ol/layer/Base';
|
|
8
|
+
import Layer from 'ol/layer/Layer';
|
|
9
|
+
import Map from 'ol/Map';
|
|
10
|
+
import { fromLonLat } from 'ol/proj';
|
|
11
|
+
import ClusterSource from 'ol/source/Cluster';
|
|
12
|
+
import VectorSource from 'ol/source/Vector';
|
|
13
|
+
|
|
14
|
+
import './projections';
|
|
15
|
+
import { LAYER_NAMES } from './map';
|
|
16
|
+
import type { MapConfig } from '../types/map';
|
|
17
|
+
|
|
18
|
+
export type MapContextType = {
|
|
19
|
+
mapConfig: MapConfig;
|
|
20
|
+
setMapConfig: Dispatch<SetStateAction<MapConfig>>;
|
|
21
|
+
mapRef: RefObject<HTMLDivElement | null>;
|
|
22
|
+
map: Map | undefined;
|
|
23
|
+
setMap: Dispatch<SetStateAction<Map | undefined>>;
|
|
24
|
+
getLayers: () => BaseLayer[] | undefined;
|
|
25
|
+
addLayer: (layer: Layer) => void;
|
|
26
|
+
removeLayer: (name: string) => void;
|
|
27
|
+
clearLayer: (name: string) => void;
|
|
28
|
+
resetMap: () => void;
|
|
29
|
+
getLayerByName: (name: string) => BaseLayer | undefined;
|
|
30
|
+
aoi: Coordinate[][] | null;
|
|
31
|
+
setAoi: Dispatch<SetStateAction<Coordinate[][] | null>>;
|
|
32
|
+
selectedLayer: Layer | null;
|
|
33
|
+
setSelectedLayer: Dispatch<SetStateAction<Layer | null>>;
|
|
34
|
+
isDrawing: boolean;
|
|
35
|
+
setIsDrawing: Dispatch<SetStateAction<boolean>>;
|
|
36
|
+
samplingPoints: string[] | null;
|
|
37
|
+
setSamplingPoints: Dispatch<SetStateAction<string[] | null>>;
|
|
38
|
+
isTooManySamplingPoints: boolean;
|
|
39
|
+
setIsTooManySamplingPoints: Dispatch<SetStateAction<boolean>>;
|
|
40
|
+
isSamplingPointsLoading: boolean;
|
|
41
|
+
setIsSamplingPointsLoading: Dispatch<SetStateAction<boolean>>;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
type MapProviderProps = {
|
|
45
|
+
initialState?: Partial<MapContextType>;
|
|
46
|
+
children: ReactNode;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export const MapContext = createContext<MapContextType | null>(null);
|
|
50
|
+
MapContext.displayName = 'MapContext';
|
|
51
|
+
|
|
52
|
+
const DEFAULT_LON_LAT = [-3, 53];
|
|
53
|
+
const DEFAULT_ZOOM = 7;
|
|
54
|
+
|
|
55
|
+
export const MAX_SAMPLING_POINTS = 20;
|
|
56
|
+
const DEFAULT_DURATION = 500;
|
|
57
|
+
|
|
58
|
+
export const MapProvider = ({ initialState = {}, children }: MapProviderProps) => {
|
|
59
|
+
const mapRef = useRef(null);
|
|
60
|
+
|
|
61
|
+
const [mapConfig, setMapConfig] = useState<MapConfig>({
|
|
62
|
+
center: DEFAULT_LON_LAT,
|
|
63
|
+
zoom: DEFAULT_ZOOM,
|
|
64
|
+
});
|
|
65
|
+
const [map, setMap] = useState<Map | undefined>();
|
|
66
|
+
const [aoi, setAoi] = useState<Coordinate[][] | null>(null);
|
|
67
|
+
const [selectedLayer, setSelectedLayer] = useState<Layer | null>(null);
|
|
68
|
+
const [isDrawing, setIsDrawing] = useState(false);
|
|
69
|
+
const [samplingPoints, setSamplingPoints] = useState<string[] | null>(null);
|
|
70
|
+
const [isTooManySamplingPoints, setIsTooManySamplingPoints] = useState(false);
|
|
71
|
+
const [isSamplingPointsLoading, setIsSamplingPointsLoading] = useState(false);
|
|
72
|
+
|
|
73
|
+
const getLayers = useCallback(() => map?.getLayers().getArray(), [map]);
|
|
74
|
+
|
|
75
|
+
const getLayerByName = useCallback(
|
|
76
|
+
(name: string) => getLayers()?.find((layer) => layer.get('name') === name),
|
|
77
|
+
[getLayers],
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
const addLayer = useCallback(
|
|
81
|
+
(layer: Layer) => {
|
|
82
|
+
// Add layer to map.
|
|
83
|
+
map?.addLayer(layer);
|
|
84
|
+
},
|
|
85
|
+
[map],
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
const removeLayer = useCallback(
|
|
89
|
+
(name: string) => {
|
|
90
|
+
const foundLayer = getLayerByName(name);
|
|
91
|
+
|
|
92
|
+
map?.removeLayer(foundLayer!);
|
|
93
|
+
},
|
|
94
|
+
[getLayerByName, map],
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
const isClearableSource = (source: unknown): source is VectorSource =>
|
|
98
|
+
typeof (source as VectorSource)?.clear === 'function';
|
|
99
|
+
|
|
100
|
+
const clearLayer = useCallback(
|
|
101
|
+
(name: string) => {
|
|
102
|
+
const foundLayer = getLayerByName(name) as Layer | undefined;
|
|
103
|
+
|
|
104
|
+
if (!foundLayer) {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const source = foundLayer.getSource?.();
|
|
109
|
+
|
|
110
|
+
if (!source) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (source instanceof ClusterSource) {
|
|
115
|
+
const childSource = source.getSource?.();
|
|
116
|
+
|
|
117
|
+
if (isClearableSource(childSource)) {
|
|
118
|
+
childSource.clear();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
source.refresh(); // Required to reflect clearing
|
|
122
|
+
} else if (isClearableSource(source)) {
|
|
123
|
+
source.clear();
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
[getLayerByName],
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
const resetMap = useCallback(() => {
|
|
130
|
+
if (map) {
|
|
131
|
+
const view = map.getView();
|
|
132
|
+
|
|
133
|
+
// Animate view to default
|
|
134
|
+
view.animate(
|
|
135
|
+
{
|
|
136
|
+
center: fromLonLat(DEFAULT_LON_LAT),
|
|
137
|
+
duration: DEFAULT_DURATION,
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
zoom: DEFAULT_ZOOM,
|
|
141
|
+
duration: DEFAULT_DURATION,
|
|
142
|
+
},
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Remove all defined layers
|
|
147
|
+
Object.values(LAYER_NAMES).forEach(clearLayer);
|
|
148
|
+
|
|
149
|
+
// Reset sampling points state
|
|
150
|
+
setSamplingPoints(null);
|
|
151
|
+
setIsTooManySamplingPoints(false);
|
|
152
|
+
}, [map, clearLayer]);
|
|
153
|
+
|
|
154
|
+
const contextValue = useMemo(
|
|
155
|
+
() => ({
|
|
156
|
+
mapRef,
|
|
157
|
+
map,
|
|
158
|
+
setMap,
|
|
159
|
+
mapConfig,
|
|
160
|
+
setMapConfig,
|
|
161
|
+
getLayers,
|
|
162
|
+
addLayer,
|
|
163
|
+
removeLayer,
|
|
164
|
+
getLayerByName,
|
|
165
|
+
aoi,
|
|
166
|
+
setAoi,
|
|
167
|
+
selectedLayer,
|
|
168
|
+
setSelectedLayer,
|
|
169
|
+
isDrawing,
|
|
170
|
+
setIsDrawing,
|
|
171
|
+
samplingPoints,
|
|
172
|
+
setSamplingPoints,
|
|
173
|
+
isTooManySamplingPoints,
|
|
174
|
+
setIsTooManySamplingPoints,
|
|
175
|
+
isSamplingPointsLoading,
|
|
176
|
+
setIsSamplingPointsLoading,
|
|
177
|
+
clearLayer,
|
|
178
|
+
resetMap,
|
|
179
|
+
...initialState,
|
|
180
|
+
}),
|
|
181
|
+
[
|
|
182
|
+
map,
|
|
183
|
+
mapConfig,
|
|
184
|
+
getLayers,
|
|
185
|
+
addLayer,
|
|
186
|
+
removeLayer,
|
|
187
|
+
getLayerByName,
|
|
188
|
+
aoi,
|
|
189
|
+
selectedLayer,
|
|
190
|
+
isDrawing,
|
|
191
|
+
samplingPoints,
|
|
192
|
+
isTooManySamplingPoints,
|
|
193
|
+
isSamplingPointsLoading,
|
|
194
|
+
clearLayer,
|
|
195
|
+
resetMap,
|
|
196
|
+
initialState,
|
|
197
|
+
],
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
return <MapContext.Provider value={contextValue}>{children}</MapContext.Provider>;
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
export const useMap = () => {
|
|
204
|
+
const context = useContext(MapContext);
|
|
205
|
+
|
|
206
|
+
if (!context) {
|
|
207
|
+
throw Error('The component needs to be wrapped in a <MapProvider /> component.');
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return context;
|
|
211
|
+
};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Feature } from 'ol';
|
|
4
|
+
import { GoLinkExternal } from 'react-icons/go';
|
|
5
|
+
import { IoMdCloseCircle } from 'react-icons/io';
|
|
6
|
+
|
|
7
|
+
import { ExternalLink } from '../components/link/ExternalLink';
|
|
8
|
+
|
|
9
|
+
type PopupProps = {
|
|
10
|
+
features: Feature[];
|
|
11
|
+
onClose: () => void;
|
|
12
|
+
clickedCoord: number[] | null;
|
|
13
|
+
arrowClasses: string;
|
|
14
|
+
baseUrl: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const Popup = ({
|
|
18
|
+
features,
|
|
19
|
+
onClose,
|
|
20
|
+
clickedCoord,
|
|
21
|
+
arrowClasses = 'bottom-left',
|
|
22
|
+
baseUrl,
|
|
23
|
+
}: PopupProps) => {
|
|
24
|
+
if (!features.length || !clickedCoord) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<div className={'relative z-50 w-[300px] shadow-md'}>
|
|
30
|
+
<button
|
|
31
|
+
type="button"
|
|
32
|
+
onClick={onClose}
|
|
33
|
+
className="absolute z-4 top-[.5em] right-[1em] text-gray-500 hover:text-black text-sm"
|
|
34
|
+
aria-label="Close popup"
|
|
35
|
+
>
|
|
36
|
+
<IoMdCloseCircle size={20} />
|
|
37
|
+
</button>
|
|
38
|
+
|
|
39
|
+
<div
|
|
40
|
+
className="space-y-2 pt-4 pb-1 overflow-y-auto max-h-[300px] bg-white border border-border
|
|
41
|
+
rounded-lg divide-y divide-gray-300"
|
|
42
|
+
>
|
|
43
|
+
{features.map((feature) => {
|
|
44
|
+
const id = feature.get('id');
|
|
45
|
+
const name = feature.get('name');
|
|
46
|
+
const notation = feature.get('notation');
|
|
47
|
+
const url = `${baseUrl}${notation}`;
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<div key={id}>
|
|
51
|
+
<strong>
|
|
52
|
+
<ExternalLink
|
|
53
|
+
href={url}
|
|
54
|
+
className="px-4 my-2 text-blue-500 underline flex flex-row gap-1 items-center"
|
|
55
|
+
>
|
|
56
|
+
<span>{name}</span>
|
|
57
|
+
|
|
58
|
+
<span className="flex-none w-5 h-5 flex items-center justify-center">
|
|
59
|
+
<GoLinkExternal size={20} className="cursor-pointer" />
|
|
60
|
+
</span>
|
|
61
|
+
</ExternalLink>
|
|
62
|
+
</strong>
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
65
|
+
})}
|
|
66
|
+
</div>
|
|
67
|
+
|
|
68
|
+
<div
|
|
69
|
+
className={`absolute z-[-1] w-4 h-4 bg-white border border-gray-300 rotate-45
|
|
70
|
+
${arrowClasses}`}
|
|
71
|
+
/>
|
|
72
|
+
</div>
|
|
73
|
+
);
|
|
74
|
+
};
|