@tpzdsp/next-toolkit 1.1.0 → 1.2.0
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 +21 -2
- package/src/assets/styles/globals.css +2 -0
- package/src/assets/styles/ol.css +122 -0
- 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/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/index.ts +9 -2
- package/src/components/layout/header/HeaderAuthClient.tsx +16 -8
- package/src/components/layout/header/HeaderNavClient.tsx +2 -2
- package/src/components/map/LayerSwitcherControl.ts +147 -0
- package/src/components/map/Map.tsx +230 -0
- package/src/components/map/MapContext.tsx +211 -0
- package/src/components/map/Popup.tsx +74 -0
- package/src/components/map/basemaps.ts +79 -0
- package/src/components/map/geocoder.ts +61 -0
- package/src/components/map/geometries.ts +60 -0
- package/src/components/map/images/basemaps/OS.png +0 -0
- package/src/components/map/images/basemaps/dark.png +0 -0
- package/src/components/map/images/basemaps/sat-map-tiler.png +0 -0
- package/src/components/map/images/basemaps/satellite-map-tiler.png +0 -0
- package/src/components/map/images/basemaps/satellite.png +0 -0
- package/src/components/map/images/basemaps/streets.png +0 -0
- package/src/components/map/images/openlayers-logo.png +0 -0
- package/src/components/map/index.ts +10 -0
- package/src/components/map/map.ts +40 -0
- package/src/components/map/osOpenNamesSearch.ts +54 -0
- package/src/components/map/projections.ts +14 -0
- 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/types.ts +51 -1
- package/src/utils/http.ts +143 -0
- package/src/utils/index.ts +1 -0
- package/src/components/link/NextLinkWrapper.tsx +0 -66
- package/src/contexts/ThemeContext.tsx +0 -72
|
@@ -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';
|
|
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
|
+
};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { StaticImageData } from 'next/image';
|
|
2
|
+
import TileLayer from 'ol/layer/Tile';
|
|
3
|
+
import OSM from 'ol/source/OSM';
|
|
4
|
+
import XYZ from 'ol/source/XYZ';
|
|
5
|
+
|
|
6
|
+
import OsImage from './images/basemaps/OS.png';
|
|
7
|
+
import SatelliteMapTilerImage from './images/basemaps/satellite-map-tiler.png';
|
|
8
|
+
import SatelliteImage from './images/basemaps/satellite.png';
|
|
9
|
+
import StreetsImage from './images/basemaps/streets.png';
|
|
10
|
+
|
|
11
|
+
export const initializeBasemapLayers = () => {
|
|
12
|
+
const osmLayer = new TileLayer({
|
|
13
|
+
preload: Infinity,
|
|
14
|
+
source: new OSM(),
|
|
15
|
+
visible: true,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
osmLayer.set('name', 'OSM');
|
|
19
|
+
osmLayer.set('basemap', true);
|
|
20
|
+
const osImageData = OsImage as unknown as StaticImageData;
|
|
21
|
+
|
|
22
|
+
osmLayer.set('image', osImageData.src);
|
|
23
|
+
|
|
24
|
+
const osMapsLight = new TileLayer({
|
|
25
|
+
// attributions: [`© Crown Copyright and Database Rights [insert year of creation] OS AC0000807064`],
|
|
26
|
+
source: new XYZ({
|
|
27
|
+
url: '/water-quality-archive/api/maps/raster/v1/zxy/Light_3857/{z}/{x}/{y}.png',
|
|
28
|
+
}),
|
|
29
|
+
visible: false,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
osMapsLight.set('name', 'OS Maps Light');
|
|
33
|
+
osMapsLight.set('basemap', true);
|
|
34
|
+
osMapsLight.set('image', osImageData.src);
|
|
35
|
+
|
|
36
|
+
const osMapsOutdoor = new TileLayer({
|
|
37
|
+
// attributions: [`© Crown Copyright and Database Rights [insert year of creation] OS AC0000807064`],
|
|
38
|
+
source: new XYZ({
|
|
39
|
+
url: '/water-quality-archive/api/maps/raster/v1/zxy/Outdoor_3857/{z}/{x}/{y}.png',
|
|
40
|
+
}),
|
|
41
|
+
visible: false,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
osMapsOutdoor.set('name', 'OS Maps Outdoor');
|
|
45
|
+
osMapsOutdoor.set('basemap', true);
|
|
46
|
+
const streetsImageData = StreetsImage as unknown as StaticImageData;
|
|
47
|
+
|
|
48
|
+
osMapsOutdoor.set('image', streetsImageData.src);
|
|
49
|
+
|
|
50
|
+
const osMapsRoad = new TileLayer({
|
|
51
|
+
// attributions: [`© Crown Copyright and Database Rights [insert year of creation] OS AC0000807064`],
|
|
52
|
+
source: new XYZ({
|
|
53
|
+
url: '/water-quality-archive/api/maps/raster/v1/zxy/Road_3857/{z}/{x}/{y}.png',
|
|
54
|
+
}),
|
|
55
|
+
visible: false,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
osMapsRoad.set('name', 'OS Maps Road');
|
|
59
|
+
osMapsRoad.set('basemap', true);
|
|
60
|
+
const satelliteMapTilerImageData = SatelliteMapTilerImage as unknown as StaticImageData;
|
|
61
|
+
|
|
62
|
+
osMapsRoad.set('image', satelliteMapTilerImageData.src);
|
|
63
|
+
|
|
64
|
+
const worldImagery = new TileLayer({
|
|
65
|
+
source: new XYZ({
|
|
66
|
+
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
|
67
|
+
maxZoom: 19,
|
|
68
|
+
}),
|
|
69
|
+
visible: false,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
worldImagery.set('name', 'ESRI World Imagery');
|
|
73
|
+
worldImagery.set('basemap', true);
|
|
74
|
+
const satelliteImageData = SatelliteImage as unknown as StaticImageData;
|
|
75
|
+
|
|
76
|
+
worldImagery.set('image', satelliteImageData.src);
|
|
77
|
+
|
|
78
|
+
return [osmLayer, osMapsLight, osMapsOutdoor, osMapsRoad, worldImagery];
|
|
79
|
+
};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { Map } from 'ol';
|
|
2
|
+
import { transformExtent } from 'ol/proj';
|
|
3
|
+
import Geocoder from 'ol-geocoder';
|
|
4
|
+
|
|
5
|
+
import { EPSG_3857, EPSG_4326 } from './geometries';
|
|
6
|
+
import { osOpenNamesSearch } from './osOpenNamesSearch';
|
|
7
|
+
|
|
8
|
+
type AddressChosenEvent = {
|
|
9
|
+
place: {
|
|
10
|
+
lon: number;
|
|
11
|
+
lat: number;
|
|
12
|
+
bbox?: [number, number, number, number]; // [minLon, minLat, maxLon, maxLat]
|
|
13
|
+
address: {
|
|
14
|
+
name: string;
|
|
15
|
+
city?: string;
|
|
16
|
+
state?: string;
|
|
17
|
+
country?: string;
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const initializeGeocoder = (apiKey: string, geoCoderUrl: string, map: Map) => {
|
|
23
|
+
if (!apiKey) {
|
|
24
|
+
console.warn('No API key provided for geocoder initialization.');
|
|
25
|
+
|
|
26
|
+
throw new Error(
|
|
27
|
+
'API key is required for geocoder initialization. Please provide a valid API key.',
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const provider = osOpenNamesSearch({
|
|
32
|
+
url: geoCoderUrl,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const geocoder = new Geocoder('nominatim', {
|
|
36
|
+
provider,
|
|
37
|
+
key: apiKey,
|
|
38
|
+
lang: 'en',
|
|
39
|
+
placeholder: 'Search for ...',
|
|
40
|
+
targetType: 'text-input',
|
|
41
|
+
keepOpen: false,
|
|
42
|
+
preventMarker: true,
|
|
43
|
+
debug: true,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// Since we provide a bbox in the custom provider response,
|
|
47
|
+
// we also need to handle the address chosen event.
|
|
48
|
+
geocoder.on('addresschosen', (event: AddressChosenEvent) => {
|
|
49
|
+
if (event.place.bbox) {
|
|
50
|
+
const extent3857 = transformExtent(event.place.bbox, EPSG_4326, EPSG_3857);
|
|
51
|
+
|
|
52
|
+
map.getView().fit(extent3857, {
|
|
53
|
+
duration: 500,
|
|
54
|
+
padding: [50, 50, 50, 50],
|
|
55
|
+
maxZoom: 18,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
return geocoder;
|
|
61
|
+
};
|