@tpzdsp/next-toolkit 1.12.1 → 1.13.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/README.md +4 -4
- package/package.json +1 -6
- package/src/assets/styles/ol.css +147 -176
- package/src/components/InfoBox/InfoBox.stories.tsx +457 -0
- package/src/components/InfoBox/InfoBox.test.tsx +382 -0
- package/src/components/InfoBox/InfoBox.tsx +177 -0
- package/src/components/InfoBox/hooks/index.ts +3 -0
- package/src/components/InfoBox/hooks/useInfoBoxPosition.test.ts +187 -0
- package/src/components/InfoBox/hooks/useInfoBoxPosition.ts +69 -0
- package/src/components/InfoBox/hooks/useInfoBoxState.test.ts +168 -0
- package/src/components/InfoBox/hooks/useInfoBoxState.ts +71 -0
- package/src/components/InfoBox/hooks/usePortalMount.test.ts +62 -0
- package/src/components/InfoBox/hooks/usePortalMount.ts +15 -0
- package/src/components/InfoBox/types.ts +6 -0
- package/src/components/InfoBox/utils/focusTrapConfig.test.ts +310 -0
- package/src/components/InfoBox/utils/focusTrapConfig.ts +59 -0
- package/src/components/InfoBox/utils/index.ts +2 -0
- package/src/components/InfoBox/utils/positionUtils.test.ts +170 -0
- package/src/components/InfoBox/utils/positionUtils.ts +89 -0
- package/src/components/index.ts +8 -0
- package/src/map/FullScreenControl.ts +126 -0
- package/src/map/LayerSwitcherControl.ts +87 -181
- package/src/map/LayerSwitcherPanel.tsx +173 -0
- package/src/map/MapComponent.tsx +6 -35
- package/src/map/createControlButton.ts +72 -0
- package/src/map/geocoder/Geocoder.test.tsx +115 -0
- package/src/map/geocoder/Geocoder.tsx +393 -0
- package/src/map/geocoder/groupResults.ts +12 -0
- package/src/map/geocoder/index.ts +4 -0
- package/src/map/geocoder/types.ts +11 -0
- package/src/map/index.ts +4 -1
- package/src/map/osOpenNamesSearch.ts +112 -57
- package/src/test/renderers.tsx +9 -20
- package/src/map/geocoder.ts +0 -61
- package/src/ol-geocoder.d.ts +0 -1
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type GeocoderResult = {
|
|
2
|
+
id: string;
|
|
3
|
+
label: string;
|
|
4
|
+
group?: string;
|
|
5
|
+
extent?: [number, number, number, number];
|
|
6
|
+
center?: [number, number];
|
|
7
|
+
/** Optional zoom level for this result (e.g., city=12, street=16, building=18) */
|
|
8
|
+
zoom?: number;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type GroupedResults = Record<string, GeocoderResult[]>;
|
package/src/map/index.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
export * from './basemaps';
|
|
2
|
-
export * from './geocoder';
|
|
2
|
+
export * from './geocoder/index';
|
|
3
3
|
export * from './geometries';
|
|
4
|
+
export * from './createControlButton';
|
|
4
5
|
export * from './LayerSwitcherControl';
|
|
6
|
+
export * from './LayerSwitcherPanel';
|
|
7
|
+
export * from './FullScreenControl';
|
|
5
8
|
export * from './utils';
|
|
6
9
|
export * from './MapComponent';
|
|
7
10
|
export * from './MapContext';
|
|
@@ -1,71 +1,126 @@
|
|
|
1
1
|
import type { FeatureCollection } from 'geojson';
|
|
2
|
+
import { transform, transformExtent } from 'ol/proj';
|
|
2
3
|
|
|
3
|
-
|
|
4
|
-
|
|
4
|
+
import type { GeocoderResult } from './geocoder/types';
|
|
5
|
+
import { EPSG_3857, EPSG_4326 } from './geometries';
|
|
6
|
+
|
|
7
|
+
export type OsOpenNamesSearchOptions = {
|
|
8
|
+
url: string;
|
|
5
9
|
};
|
|
6
10
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
11
|
+
// Zoom levels based on OS Open Names feature types
|
|
12
|
+
const ZOOM_BY_TYPE: Record<string, number> = {
|
|
13
|
+
// Large areas
|
|
14
|
+
City: 12,
|
|
15
|
+
Town: 13,
|
|
16
|
+
'Suburban Area': 14,
|
|
17
|
+
Village: 15,
|
|
18
|
+
Hamlet: 15,
|
|
19
|
+
// Streets and localities
|
|
20
|
+
'Named Road': 16,
|
|
21
|
+
Section: 16,
|
|
22
|
+
'Numbered Road': 16,
|
|
23
|
+
Other: 14,
|
|
24
|
+
// Precise locations
|
|
25
|
+
Postcode: 17,
|
|
26
|
+
'Postcode Unit': 18,
|
|
12
27
|
};
|
|
13
28
|
|
|
14
29
|
/**
|
|
15
|
-
*
|
|
30
|
+
* Creates a search function for OS Open Names API that returns GeocoderResult[].
|
|
31
|
+
*
|
|
32
|
+
* @param options - Configuration options including the API URL.
|
|
33
|
+
* @returns A search function compatible with the Geocoder component.
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* ```tsx
|
|
37
|
+
* const search = createOsOpenNamesSearch({ url: '/api/geocode' });
|
|
38
|
+
* <Geocoder map={map} search={search} />
|
|
39
|
+
* ```
|
|
16
40
|
*/
|
|
17
|
-
export const
|
|
18
|
-
const { url } = options
|
|
19
|
-
|
|
20
|
-
return {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
};
|
|
33
|
-
},
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Process the API response and format it for ol-geocoder.
|
|
37
|
-
*
|
|
38
|
-
* @param {object} results - OS Names API response.
|
|
39
|
-
*/
|
|
40
|
-
handleResponse: (response: FeatureCollection) => {
|
|
41
|
-
const { features } = response;
|
|
42
|
-
|
|
43
|
-
if (!features?.length) {
|
|
44
|
-
return [];
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
return features
|
|
48
|
-
.map((feature) => {
|
|
49
|
-
if (feature.geometry.type === 'Point') {
|
|
50
|
-
const result: SearchResult = {
|
|
51
|
-
lon: feature.geometry.coordinates[0]!,
|
|
52
|
-
lat: feature.geometry.coordinates[1]!,
|
|
53
|
-
address: feature?.properties?.address,
|
|
54
|
-
};
|
|
55
|
-
|
|
56
|
-
// Only include bbox if it's not empty
|
|
57
|
-
if (feature.bbox && Array.isArray(feature.bbox) && feature.bbox.length > 0) {
|
|
58
|
-
result.bbox = feature.bbox;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
return result;
|
|
62
|
-
}
|
|
41
|
+
export const createOsOpenNamesSearch = (options: OsOpenNamesSearchOptions) => {
|
|
42
|
+
const { url } = options;
|
|
43
|
+
|
|
44
|
+
return async (query: string): Promise<GeocoderResult[]> => {
|
|
45
|
+
const searchUrl = new URL(url, globalThis.location.origin);
|
|
46
|
+
|
|
47
|
+
searchUrl.searchParams.set('query', query);
|
|
48
|
+
|
|
49
|
+
const response = await fetch(searchUrl.toString());
|
|
50
|
+
|
|
51
|
+
if (!response.ok) {
|
|
52
|
+
console.error('OS Open Names search failed:', response.statusText);
|
|
53
|
+
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
63
56
|
|
|
57
|
+
const data: FeatureCollection = await response.json();
|
|
58
|
+
const { features } = data;
|
|
59
|
+
|
|
60
|
+
if (!features?.length) {
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return features
|
|
65
|
+
.map((feature, index): GeocoderResult | null => {
|
|
66
|
+
if (feature.geometry.type !== 'Point') {
|
|
64
67
|
console.error('Geometry type is not Point');
|
|
65
68
|
|
|
66
69
|
return null;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const [lon, lat] = feature.geometry.coordinates;
|
|
73
|
+
|
|
74
|
+
// Type guard: ensure coordinates are valid numbers
|
|
75
|
+
if (typeof lon !== 'number' || typeof lat !== 'number') {
|
|
76
|
+
console.error('Invalid coordinates: lon or lat is not a number');
|
|
77
|
+
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const properties = feature.properties ?? {};
|
|
82
|
+
|
|
83
|
+
// Handle address which can be an object or string
|
|
84
|
+
let label: string;
|
|
85
|
+
|
|
86
|
+
if (properties.address && typeof properties.address === 'object') {
|
|
87
|
+
const addr = properties.address as { name?: string; town?: string; country?: string };
|
|
88
|
+
|
|
89
|
+
label = [addr.name, addr.town, addr.country].filter(Boolean).join(', ');
|
|
90
|
+
} else {
|
|
91
|
+
label = properties.address ?? properties.name ?? `${lat}, ${lon}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const [x, y] = transform([lon, lat], EPSG_4326, EPSG_3857);
|
|
95
|
+
|
|
96
|
+
// Type guard: ensure transformed coordinates are valid numbers
|
|
97
|
+
if (typeof x !== 'number' || typeof y !== 'number') {
|
|
98
|
+
console.error('Transform failed: x or y is not a number');
|
|
99
|
+
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const result: GeocoderResult = {
|
|
104
|
+
id: properties.id ?? `result-${index}`,
|
|
105
|
+
label,
|
|
106
|
+
group: properties.type ?? properties.localType,
|
|
107
|
+
center: [x, y],
|
|
108
|
+
// Assign zoom level based on type, fallback to 14
|
|
109
|
+
zoom: ZOOM_BY_TYPE[properties.type] ?? ZOOM_BY_TYPE[properties.localType] ?? 14,
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
// Transform bbox to map projection if available
|
|
113
|
+
if (feature.bbox && Array.isArray(feature.bbox) && feature.bbox.length === 4) {
|
|
114
|
+
result.extent = transformExtent(feature.bbox, EPSG_4326, EPSG_3857) as [
|
|
115
|
+
number,
|
|
116
|
+
number,
|
|
117
|
+
number,
|
|
118
|
+
number,
|
|
119
|
+
];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return result;
|
|
123
|
+
})
|
|
124
|
+
.filter((result): result is GeocoderResult => result !== null);
|
|
70
125
|
};
|
|
71
126
|
};
|
package/src/test/renderers.tsx
CHANGED
|
@@ -3,6 +3,7 @@ import type { ReactElement, ReactNode } from 'react';
|
|
|
3
3
|
import {
|
|
4
4
|
type RenderHookOptions,
|
|
5
5
|
type RenderHookResult,
|
|
6
|
+
type RenderOptions,
|
|
6
7
|
render,
|
|
7
8
|
renderHook,
|
|
8
9
|
} from '@testing-library/react';
|
|
@@ -11,11 +12,6 @@ type WrapperParams = {
|
|
|
11
12
|
children: ReactNode;
|
|
12
13
|
};
|
|
13
14
|
|
|
14
|
-
type Options = {
|
|
15
|
-
initialEntries?: string[];
|
|
16
|
-
renderHookOptions?: RenderHookOptions<unknown>;
|
|
17
|
-
};
|
|
18
|
-
|
|
19
15
|
/**
|
|
20
16
|
* A custom testing-library component renderer that wraps the component being
|
|
21
17
|
* rendered with any Providers used in the app. Any test can pre-configure the
|
|
@@ -26,13 +22,10 @@ type Options = {
|
|
|
26
22
|
*
|
|
27
23
|
* @returns The rendered component.
|
|
28
24
|
*/
|
|
29
|
-
const customRender = (ui: ReactElement, options?:
|
|
30
|
-
const {
|
|
25
|
+
const customRender = (ui: ReactElement, options?: RenderOptions) => {
|
|
26
|
+
const wrapper = ({ children }: WrapperParams): ReactElement => <>{children}</>;
|
|
31
27
|
|
|
32
|
-
return render(ui, {
|
|
33
|
-
wrapper: ({ children }: WrapperParams): ReactElement => <>{children}</>,
|
|
34
|
-
...renderHookOptions,
|
|
35
|
-
});
|
|
28
|
+
return render(ui, { wrapper, ...options });
|
|
36
29
|
};
|
|
37
30
|
|
|
38
31
|
/**
|
|
@@ -45,17 +38,13 @@ const customRender = (ui: ReactElement, options?: Options) => {
|
|
|
45
38
|
*
|
|
46
39
|
* @returns The rendered hook.
|
|
47
40
|
*/
|
|
48
|
-
const customRenderHook = <
|
|
49
|
-
callback: () =>
|
|
50
|
-
options?:
|
|
51
|
-
): RenderHookResult<
|
|
52
|
-
const { renderHookOptions } = options ?? {};
|
|
53
|
-
|
|
41
|
+
const customRenderHook = <Result, Props>(
|
|
42
|
+
callback: (initialProps: Props) => Result,
|
|
43
|
+
options?: RenderHookOptions<Props>,
|
|
44
|
+
): RenderHookResult<Result, Props> => {
|
|
54
45
|
const wrapper = ({ children }: WrapperParams): ReactElement => <>{children}</>;
|
|
55
46
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
return utils as RenderHookResult<T, P>;
|
|
47
|
+
return renderHook(callback, { wrapper, ...options });
|
|
59
48
|
};
|
|
60
49
|
|
|
61
50
|
export { screen, waitFor, within, act } from '@testing-library/react';
|
package/src/map/geocoder.ts
DELETED
|
@@ -1,61 +0,0 @@
|
|
|
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
|
-
};
|
package/src/ol-geocoder.d.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
declare module 'ol-geocoder';
|