@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.
Files changed (49) hide show
  1. package/package.json +21 -2
  2. package/src/assets/styles/globals.css +2 -0
  3. package/src/assets/styles/ol.css +122 -0
  4. package/src/components/Modal/Modal.stories.tsx +252 -0
  5. package/src/components/Modal/Modal.test.tsx +248 -0
  6. package/src/components/Modal/Modal.tsx +61 -0
  7. package/src/components/accordion/Accordion.stories.tsx +235 -0
  8. package/src/components/accordion/Accordion.test.tsx +199 -0
  9. package/src/components/accordion/Accordion.tsx +47 -0
  10. package/src/components/divider/RuleDivider.stories.tsx +255 -0
  11. package/src/components/divider/RuleDivider.test.tsx +164 -0
  12. package/src/components/divider/RuleDivider.tsx +18 -0
  13. package/src/components/index.ts +9 -2
  14. package/src/components/layout/header/HeaderAuthClient.tsx +16 -8
  15. package/src/components/layout/header/HeaderNavClient.tsx +2 -2
  16. package/src/components/map/LayerSwitcherControl.ts +147 -0
  17. package/src/components/map/Map.tsx +230 -0
  18. package/src/components/map/MapContext.tsx +211 -0
  19. package/src/components/map/Popup.tsx +74 -0
  20. package/src/components/map/basemaps.ts +79 -0
  21. package/src/components/map/geocoder.ts +61 -0
  22. package/src/components/map/geometries.ts +60 -0
  23. package/src/components/map/images/basemaps/OS.png +0 -0
  24. package/src/components/map/images/basemaps/dark.png +0 -0
  25. package/src/components/map/images/basemaps/sat-map-tiler.png +0 -0
  26. package/src/components/map/images/basemaps/satellite-map-tiler.png +0 -0
  27. package/src/components/map/images/basemaps/satellite.png +0 -0
  28. package/src/components/map/images/basemaps/streets.png +0 -0
  29. package/src/components/map/images/openlayers-logo.png +0 -0
  30. package/src/components/map/index.ts +10 -0
  31. package/src/components/map/map.ts +40 -0
  32. package/src/components/map/osOpenNamesSearch.ts +54 -0
  33. package/src/components/map/projections.ts +14 -0
  34. package/src/components/select/Select.stories.tsx +336 -0
  35. package/src/components/select/Select.test.tsx +473 -0
  36. package/src/components/select/Select.tsx +132 -0
  37. package/src/components/select/SelectSkeleton.stories.tsx +195 -0
  38. package/src/components/select/SelectSkeleton.test.tsx +105 -0
  39. package/src/components/select/SelectSkeleton.tsx +16 -0
  40. package/src/components/select/common.ts +4 -0
  41. package/src/contexts/index.ts +0 -5
  42. package/src/hooks/index.ts +1 -0
  43. package/src/hooks/useClickOutside.test.ts +290 -0
  44. package/src/hooks/useClickOutside.ts +26 -0
  45. package/src/types.ts +51 -1
  46. package/src/utils/http.ts +143 -0
  47. package/src/utils/index.ts +1 -0
  48. package/src/components/link/NextLinkWrapper.tsx +0 -66
  49. 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
+ };