@tpzdsp/next-toolkit 1.15.3 → 2.0.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/README.md +19 -19
- package/package.json +42 -73
- package/src/components/InfoBox/InfoBox.tsx +2 -2
- package/src/components/SlidingPanel/SlidingPanel.tsx +11 -3
- package/src/components/backToTop/BackToTop.stories.tsx +2 -2
- package/src/components/backToTop/BackToTop.tsx +1 -1
- package/src/components/layout/header/HeaderAuthClient.tsx +2 -2
- package/src/http/constants.ts +7 -2
- package/src/http/logger.ts +3 -3
- package/src/map/LayerSwitcherPanel.tsx +1 -1
- package/src/map/MapComponent.tsx +9 -14
- package/src/map/Popup.tsx +1 -1
- package/src/map/geocoder/Geocoder.tsx +77 -118
- package/src/map/osOpenNamesSearch.test.tsx +186 -0
- package/src/map/osOpenNamesSearch.ts +78 -72
- package/src/utils/http.ts +2 -2
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@ A modern, reusable React component library designed specifically for Next.js app
|
|
|
5
5
|
## 📋 Prerequisites
|
|
6
6
|
|
|
7
7
|
- **Node.js**: 22.17.1 (managed with `asdf`)
|
|
8
|
-
- **Package Manager**:
|
|
8
|
+
- **Package Manager**: pnpm (preferred) or npm
|
|
9
9
|
- **Next.js**: 13+ (peer dependency)
|
|
10
10
|
- **React**: 18+ (peer dependency)
|
|
11
11
|
|
|
@@ -23,11 +23,11 @@ A modern, reusable React component library designed specifically for Next.js app
|
|
|
23
23
|
## Installation
|
|
24
24
|
|
|
25
25
|
```bash
|
|
26
|
-
|
|
26
|
+
pnpm add @your-org/nextjs-library
|
|
27
27
|
# or
|
|
28
28
|
npm install @your-org/nextjs-library
|
|
29
29
|
# or
|
|
30
|
-
|
|
30
|
+
yarn add @your-org/nextjs-library
|
|
31
31
|
```
|
|
32
32
|
|
|
33
33
|
## Usage
|
|
@@ -239,25 +239,25 @@ This library distributes **raw TypeScript/TSX files** instead of compiled bundle
|
|
|
239
239
|
|
|
240
240
|
```bash
|
|
241
241
|
# Install dependencies
|
|
242
|
-
|
|
242
|
+
pnpm install
|
|
243
243
|
|
|
244
244
|
# Start development server
|
|
245
|
-
|
|
245
|
+
pnpm dev
|
|
246
246
|
|
|
247
247
|
# Run tests
|
|
248
|
-
|
|
248
|
+
pnpm test
|
|
249
249
|
|
|
250
250
|
# Run tests with UI
|
|
251
|
-
|
|
251
|
+
pnpm test:ui
|
|
252
252
|
|
|
253
253
|
# Build the library (TypeScript check only)
|
|
254
|
-
|
|
254
|
+
pnpm build
|
|
255
255
|
|
|
256
256
|
# Build distribution files (if needed)
|
|
257
|
-
|
|
257
|
+
pnpm build:dist
|
|
258
258
|
|
|
259
259
|
# Preview the built library
|
|
260
|
-
|
|
260
|
+
pnpm preview
|
|
261
261
|
```
|
|
262
262
|
|
|
263
263
|
## Local Development with Yalc
|
|
@@ -285,7 +285,7 @@ yalc publish
|
|
|
285
285
|
yalc add @tpzdsp/next-toolkit
|
|
286
286
|
|
|
287
287
|
# Install dependencies
|
|
288
|
-
|
|
288
|
+
pnpm install
|
|
289
289
|
```
|
|
290
290
|
|
|
291
291
|
### Your Next.js App Configuration
|
|
@@ -313,7 +313,7 @@ module.exports = nextConfig;
|
|
|
313
313
|
yalc push
|
|
314
314
|
|
|
315
315
|
# 3. In your test app, clear Next.js cache and restart
|
|
316
|
-
rm -rf .next &&
|
|
316
|
+
rm -rf .next && pnpm dev
|
|
317
317
|
```
|
|
318
318
|
|
|
319
319
|
### Why No Hot Reloading?
|
|
@@ -327,7 +327,7 @@ rm -rf .next && yarn dev
|
|
|
327
327
|
```bash
|
|
328
328
|
# In your Next.js app directory
|
|
329
329
|
yalc remove @tpzdsp/next-toolkit
|
|
330
|
-
|
|
330
|
+
pnpm install
|
|
331
331
|
```
|
|
332
332
|
|
|
333
333
|
### Benefits Despite Manual Process
|
|
@@ -464,13 +464,13 @@ This library uses Vitest and React Testing Library for testing:
|
|
|
464
464
|
|
|
465
465
|
```bash
|
|
466
466
|
# Run all tests
|
|
467
|
-
|
|
467
|
+
pnpm test
|
|
468
468
|
|
|
469
469
|
# Run tests in watch mode
|
|
470
|
-
|
|
470
|
+
pnpm test:watch
|
|
471
471
|
|
|
472
472
|
# Run tests with coverage
|
|
473
|
-
|
|
473
|
+
pnpm test:coverage
|
|
474
474
|
```
|
|
475
475
|
|
|
476
476
|
## Build Configuration
|
|
@@ -543,12 +543,12 @@ import '@your-org/nextjs-library/src/assets/styles/globals.css';
|
|
|
543
543
|
2. **Install dependencies:**
|
|
544
544
|
|
|
545
545
|
```bash
|
|
546
|
-
|
|
546
|
+
pnpm install
|
|
547
547
|
```
|
|
548
548
|
|
|
549
549
|
3. **Run tests to ensure everything works:**
|
|
550
550
|
```bash
|
|
551
|
-
|
|
551
|
+
pnpm test
|
|
552
552
|
```
|
|
553
553
|
|
|
554
554
|
### Development Workflow
|
|
@@ -557,7 +557,7 @@ import '@your-org/nextjs-library/src/assets/styles/globals.css';
|
|
|
557
557
|
2. Create your feature branch (`git checkout -b feature/amazing-feature`)
|
|
558
558
|
3. Make your changes following our component templates above
|
|
559
559
|
4. Add tests for your changes (`src/test/YourComponent.test.tsx`)
|
|
560
|
-
5. Run the test suite (`
|
|
560
|
+
5. Run the test suite (`pnpm test`)
|
|
561
561
|
6. Test with yalc in a real Next.js app
|
|
562
562
|
7. Commit your changes (`git commit -m 'Add some amazing feature'`)
|
|
563
563
|
8. Push to the branch (`git push origin feature/amazing-feature`)
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tpzdsp/next-toolkit",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.1",
|
|
4
4
|
"description": "A reusable React component library for Next.js applications",
|
|
5
5
|
"engines": {
|
|
6
|
-
"node": "
|
|
7
|
-
"
|
|
6
|
+
"node": "^24.12.0",
|
|
7
|
+
"pnpm": "^10.0.0"
|
|
8
8
|
},
|
|
9
9
|
"type": "module",
|
|
10
10
|
"private": false,
|
|
@@ -121,67 +121,50 @@
|
|
|
121
121
|
"dev:push": "yalc push",
|
|
122
122
|
"tsc:check": "tsc -b --noEmit",
|
|
123
123
|
"storybook": "storybook dev -p 6006",
|
|
124
|
-
"release": "semantic-release"
|
|
124
|
+
"release": "semantic-release",
|
|
125
|
+
"postinstall": "if [ \"$CI\" != \"true\" ]; then ./postinstall.sh; fi"
|
|
125
126
|
},
|
|
126
127
|
"devDependencies": {
|
|
127
128
|
"@better-fetch/fetch": "^1.1.21",
|
|
128
|
-
"@
|
|
129
|
-
"@
|
|
130
|
-
"@
|
|
131
|
-
"@
|
|
132
|
-
"@storybook/
|
|
133
|
-
"@storybook/addon-links": "10.2.0",
|
|
134
|
-
"@storybook/addon-onboarding": "10.2.0",
|
|
135
|
-
"@storybook/react-vite": "10.2.0",
|
|
129
|
+
"@floating-ui/react": "^0.27.17",
|
|
130
|
+
"@storybook/addon-docs": "10.3.5",
|
|
131
|
+
"@storybook/addon-links": "10.3.5",
|
|
132
|
+
"@storybook/addon-onboarding": "10.3.5",
|
|
133
|
+
"@storybook/react-vite": "10.3.5",
|
|
136
134
|
"@tailwindcss/typography": "^0.5.16",
|
|
137
135
|
"@tanstack/react-query": "^5.90.19",
|
|
138
136
|
"@testing-library/dom": "^10.4.1",
|
|
139
137
|
"@testing-library/jest-dom": "^6.6.4",
|
|
140
138
|
"@testing-library/react": "^16.3.0",
|
|
141
139
|
"@testing-library/user-event": "^14.6.1",
|
|
142
|
-
"@tpzdsp/eslint-config-dsp": "^
|
|
140
|
+
"@tpzdsp/eslint-config-dsp": "^4.0.0",
|
|
143
141
|
"@turf/turf": "^7.2.0",
|
|
144
142
|
"@types/geojson": "^7946.0.16",
|
|
145
143
|
"@types/jsonwebtoken": "^9.0.10",
|
|
146
|
-
"@types/node": "^24.
|
|
144
|
+
"@types/node": "^24.12.2",
|
|
147
145
|
"@types/react": "^19.1.8",
|
|
148
146
|
"@types/react-dom": "^19.1.6",
|
|
149
|
-
"@
|
|
150
|
-
"@typescript-eslint/parser": "^8.38.0",
|
|
151
|
-
"@vitejs/plugin-react": "^5.1.2",
|
|
147
|
+
"@vitejs/plugin-react": "^6.0.1",
|
|
152
148
|
"@vitest/coverage-v8": "^4.0.17",
|
|
153
149
|
"@vitest/eslint-plugin": "^1.3.4",
|
|
154
150
|
"autoprefixer": "^10.4.21",
|
|
155
151
|
"buffer": "^6.0.3",
|
|
156
152
|
"crypto-browserify": "^3.12.1",
|
|
157
153
|
"date-fns": "^4.1.0",
|
|
158
|
-
"eslint": "^9.
|
|
159
|
-
"eslint-
|
|
160
|
-
"
|
|
161
|
-
"
|
|
162
|
-
"eslint-plugin-import-x": "^4.16.1",
|
|
163
|
-
"eslint-plugin-jest-dom": "^5.5.0",
|
|
164
|
-
"eslint-plugin-jsx-a11y": "^6.10.2",
|
|
165
|
-
"eslint-plugin-prettier": "^5.5.5",
|
|
166
|
-
"eslint-plugin-react": "^7.37.5",
|
|
167
|
-
"eslint-plugin-react-hooks": "^7.0.1",
|
|
168
|
-
"eslint-plugin-react-refresh": "^0.4.20",
|
|
169
|
-
"eslint-plugin-sonarjs": "^3.0.5",
|
|
170
|
-
"eslint-plugin-storybook": "10.2.0",
|
|
171
|
-
"focus-trap": "^7.6.5",
|
|
172
|
-
"focus-trap-react": "^11.0.6",
|
|
154
|
+
"eslint": "^9.39.4",
|
|
155
|
+
"eslint-plugin-storybook": "10.3.5",
|
|
156
|
+
"focus-trap": "^8.0.1",
|
|
157
|
+
"focus-trap-react": "^12.0.0",
|
|
173
158
|
"geojson": "^0.5.0",
|
|
174
159
|
"globals": "^17.0.0",
|
|
175
160
|
"husky": "^9.1.7",
|
|
176
|
-
"jsdom": "^
|
|
161
|
+
"jsdom": "^29.0.2",
|
|
177
162
|
"jsonwebtoken": "^9.0.3",
|
|
178
163
|
"next": "^16.1.4",
|
|
179
164
|
"ol": "^10.6.1",
|
|
180
165
|
"ol-mapbox-style": "^13.0.1",
|
|
181
166
|
"postcss": "^8.5.6",
|
|
182
167
|
"prettier": "^3.8.1",
|
|
183
|
-
"prettier-plugin-classnames": "^0.9.0",
|
|
184
|
-
"prettier-plugin-tailwindcss": "^0.7.2",
|
|
185
168
|
"process": "^0.11.10",
|
|
186
169
|
"proj4": "^2.19.10",
|
|
187
170
|
"react": "^19.2.1",
|
|
@@ -192,58 +175,50 @@
|
|
|
192
175
|
"react-select-event": "^5.5.1",
|
|
193
176
|
"rollup-plugin-peer-deps-external": "^2.2.4",
|
|
194
177
|
"semantic-release": "^25.0.2",
|
|
195
|
-
"storybook": "10.
|
|
178
|
+
"storybook": "10.3.5",
|
|
196
179
|
"stream-browserify": "^3.0.0",
|
|
197
180
|
"tailwind-merge": "^3.4.0",
|
|
198
181
|
"tailwindcss": "^3.4.16",
|
|
199
|
-
"typescript": "^
|
|
182
|
+
"typescript": "^6.0.2",
|
|
200
183
|
"typescript-eslint": "^8.35.1",
|
|
201
184
|
"util": "^0.12.5",
|
|
202
|
-
"vite": "^
|
|
185
|
+
"vite": "^8.0.8",
|
|
203
186
|
"vite-plugin-dts": "^4.5.4",
|
|
204
187
|
"vitest": "^4.0.17",
|
|
205
188
|
"zod": "^4.3.5"
|
|
206
189
|
},
|
|
207
190
|
"peerDependencies": {
|
|
208
|
-
"@better-fetch/fetch": "^1
|
|
209
|
-
"@floating-ui/react": "^0.27
|
|
210
|
-
"@tanstack/react-query": "^5
|
|
211
|
-
"@
|
|
212
|
-
"
|
|
213
|
-
"
|
|
214
|
-
"
|
|
215
|
-
"
|
|
216
|
-
"
|
|
217
|
-
"
|
|
218
|
-
"
|
|
219
|
-
"
|
|
220
|
-
"
|
|
221
|
-
"
|
|
222
|
-
"
|
|
223
|
-
"
|
|
224
|
-
"react": "^
|
|
225
|
-
"react-
|
|
226
|
-
"
|
|
227
|
-
"react-icons": "^5.5.0",
|
|
228
|
-
"zod": "^4.3.5"
|
|
191
|
+
"@better-fetch/fetch": "^1",
|
|
192
|
+
"@floating-ui/react": "^0.27",
|
|
193
|
+
"@tanstack/react-query": "^5",
|
|
194
|
+
"@turf/turf": "^7",
|
|
195
|
+
"date-fns": "^4",
|
|
196
|
+
"focus-trap": "^8",
|
|
197
|
+
"focus-trap-react": "^12",
|
|
198
|
+
"geojson": "^0.5",
|
|
199
|
+
"jsonwebtoken": "^9",
|
|
200
|
+
"next": "^16",
|
|
201
|
+
"ol": "^10",
|
|
202
|
+
"ol-mapbox-style": "^13",
|
|
203
|
+
"proj4": "^2",
|
|
204
|
+
"react": "^19",
|
|
205
|
+
"react-dom": "^19",
|
|
206
|
+
"react-error-boundary": "^6",
|
|
207
|
+
"react-icons": "^5",
|
|
208
|
+
"react-select": "^5",
|
|
209
|
+
"zod": "^4"
|
|
229
210
|
},
|
|
230
211
|
"peerDependenciesMeta": {
|
|
231
|
-
"@tanstack/react-query": {
|
|
232
|
-
"optional": true
|
|
233
|
-
},
|
|
234
212
|
"@better-fetch/fetch": {
|
|
235
213
|
"optional": true
|
|
236
214
|
},
|
|
237
215
|
"@floating-ui/react": {
|
|
238
216
|
"optional": true
|
|
239
217
|
},
|
|
240
|
-
"@
|
|
241
|
-
"optional": true
|
|
242
|
-
},
|
|
243
|
-
"@types/geojson": {
|
|
218
|
+
"@tanstack/react-query": {
|
|
244
219
|
"optional": true
|
|
245
220
|
},
|
|
246
|
-
"@
|
|
221
|
+
"@turf/turf": {
|
|
247
222
|
"optional": true
|
|
248
223
|
},
|
|
249
224
|
"date-fns": {
|
|
@@ -277,13 +252,7 @@
|
|
|
277
252
|
"optional": true
|
|
278
253
|
}
|
|
279
254
|
},
|
|
280
|
-
"optionalDependencies": {
|
|
281
|
-
"react-select": "^5.10.2"
|
|
282
|
-
},
|
|
283
255
|
"release": {
|
|
284
256
|
"extends": "./release.config.js"
|
|
285
|
-
},
|
|
286
|
-
"dependencies": {
|
|
287
|
-
"@floating-ui/react": "^0.27.17"
|
|
288
257
|
}
|
|
289
258
|
}
|
|
@@ -134,7 +134,7 @@ export const InfoBox = ({
|
|
|
134
134
|
<Icon className={iconClasses} aria-hidden="true" />
|
|
135
135
|
</button>
|
|
136
136
|
|
|
137
|
-
{isOpen
|
|
137
|
+
{isOpen ? (
|
|
138
138
|
<FloatingPortal>
|
|
139
139
|
<FloatingFocusManager context={context} modal={false}>
|
|
140
140
|
<div
|
|
@@ -165,7 +165,7 @@ export const InfoBox = ({
|
|
|
165
165
|
</div>
|
|
166
166
|
</FloatingFocusManager>
|
|
167
167
|
</FloatingPortal>
|
|
168
|
-
)}
|
|
168
|
+
) : null}
|
|
169
169
|
</div>
|
|
170
170
|
);
|
|
171
171
|
};
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
useState,
|
|
5
|
+
useEffect,
|
|
6
|
+
type ReactNode,
|
|
7
|
+
useRef,
|
|
8
|
+
useMemo,
|
|
9
|
+
useId,
|
|
10
|
+
type CSSProperties,
|
|
11
|
+
} from 'react';
|
|
4
12
|
|
|
5
13
|
import { FocusTrap } from 'focus-trap-react';
|
|
6
14
|
|
|
@@ -115,7 +123,7 @@ export const SlidingPanel = ({
|
|
|
115
123
|
}[position];
|
|
116
124
|
|
|
117
125
|
// Dynamic positioning using actual panel dimensions
|
|
118
|
-
const getButtonStyle = useMemo(() => {
|
|
126
|
+
const getButtonStyle = useMemo((): CSSProperties => {
|
|
119
127
|
if (position === 'center-left') {
|
|
120
128
|
return {
|
|
121
129
|
left: isVisible ? `${panelDimensions.width}px` : '0px',
|
|
@@ -188,7 +196,7 @@ export const SlidingPanel = ({
|
|
|
188
196
|
|
|
189
197
|
return true; // still allow it to deactivate
|
|
190
198
|
},
|
|
191
|
-
|
|
199
|
+
|
|
192
200
|
setReturnFocus: (target) => {
|
|
193
201
|
// if the user has clicked outside the focus trap we shouldn't return focus as it will steal focus from the element they clicked on
|
|
194
202
|
return deactivatedByClick.current ? false : target;
|
|
@@ -310,14 +310,14 @@ export const LongPageExample: Story = {
|
|
|
310
310
|
fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa
|
|
311
311
|
qui officia deserunt mollit anim id est laborum.
|
|
312
312
|
</p>
|
|
313
|
-
{i % 10 === 9
|
|
313
|
+
{i % 10 === 9 ? (
|
|
314
314
|
<div className="mt-4 p-3 bg-gray-100 rounded">
|
|
315
315
|
<p className="text-sm text-gray-600">
|
|
316
316
|
📍 Checkpoint: You've scrolled through {i + 1} articles. Use the back to
|
|
317
317
|
top button to return to the beginning!
|
|
318
318
|
</p>
|
|
319
319
|
</div>
|
|
320
|
-
)}
|
|
320
|
+
) : null}
|
|
321
321
|
</article>
|
|
322
322
|
))}
|
|
323
323
|
</section>
|
|
@@ -26,7 +26,7 @@ export const HeaderAuthClient = ({ hostname, credentials }: HeaderAuthClientProp
|
|
|
26
26
|
<span>Welcome,</span> {credentials ? credentials.user.email : 'Guest'}
|
|
27
27
|
</span>
|
|
28
28
|
|
|
29
|
-
{isClient
|
|
29
|
+
{isClient ? (
|
|
30
30
|
<Link
|
|
31
31
|
href={`${hostname}/${credentials ? 'api/logout' : 'login'}?redirect-uri=${encodeURIComponent(window.location.href)}`}
|
|
32
32
|
className="text-white visited:text-white active:text-black hover:text-white"
|
|
@@ -34,7 +34,7 @@ export const HeaderAuthClient = ({ hostname, credentials }: HeaderAuthClientProp
|
|
|
34
34
|
>
|
|
35
35
|
<span className="text-base">{credentials ? 'Logout' : 'Login'}</span>
|
|
36
36
|
</Link>
|
|
37
|
-
)}
|
|
37
|
+
) : null}
|
|
38
38
|
</span>
|
|
39
39
|
);
|
|
40
40
|
};
|
package/src/http/constants.ts
CHANGED
|
@@ -20,18 +20,21 @@ export const MimeType = {
|
|
|
20
20
|
*/
|
|
21
21
|
export type MimeTypes = (typeof MimeType)[keyof typeof MimeType];
|
|
22
22
|
|
|
23
|
+
const jsonMimeTypes = [MimeType.Json, MimeType.GeoJson, MimeType.JsonLd] as const;
|
|
24
|
+
const textMimeTypes = [MimeType.Text, MimeType.Csv] as const;
|
|
25
|
+
|
|
23
26
|
/**
|
|
24
27
|
* Returns `true` if the mime type is a non-streamed JSON mime type (I.E. not JSON X-Lines).
|
|
25
28
|
*/
|
|
26
29
|
export const isJsonMimeType = (mime: MimeTypes | string | null): boolean => {
|
|
27
|
-
return
|
|
30
|
+
return mime != null && jsonMimeTypes.includes(mime as (typeof jsonMimeTypes)[number]);
|
|
28
31
|
};
|
|
29
32
|
|
|
30
33
|
/**
|
|
31
34
|
* Returns `true` if the mime type is a plain-text mime type.
|
|
32
35
|
*/
|
|
33
36
|
export const isTextMimeType = (mime: MimeTypes | string | null): boolean => {
|
|
34
|
-
return
|
|
37
|
+
return mime != null && textMimeTypes.includes(mime as (typeof textMimeTypes)[number]);
|
|
35
38
|
};
|
|
36
39
|
|
|
37
40
|
/**
|
|
@@ -52,6 +55,8 @@ export const HttpStatus = {
|
|
|
52
55
|
InternalServerError: 500,
|
|
53
56
|
NotImplemented: 501,
|
|
54
57
|
BadGateway: 502,
|
|
58
|
+
ServiceUnavailable: 503,
|
|
59
|
+
GatewayTimeout: 504,
|
|
55
60
|
} as const;
|
|
56
61
|
|
|
57
62
|
/**
|
package/src/http/logger.ts
CHANGED
|
@@ -7,7 +7,7 @@ const MAX_PREVIEW_LENGTH = 150;
|
|
|
7
7
|
// Tries to print data as a string.
|
|
8
8
|
const stringify = (data: unknown): string | null => {
|
|
9
9
|
if (data == null) {
|
|
10
|
-
return
|
|
10
|
+
return '';
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
try {
|
|
@@ -22,7 +22,7 @@ const preview = (data: unknown): string | null => {
|
|
|
22
22
|
const str = stringify(data);
|
|
23
23
|
|
|
24
24
|
if (str == null) {
|
|
25
|
-
return
|
|
25
|
+
return '';
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
if (str.length > MAX_PREVIEW_LENGTH) {
|
|
@@ -191,7 +191,7 @@ const formatBodyLine = ({
|
|
|
191
191
|
const rendered = truncateBody ? preview(body) : stringify(body);
|
|
192
192
|
|
|
193
193
|
if (!rendered) {
|
|
194
|
-
return
|
|
194
|
+
return '';
|
|
195
195
|
}
|
|
196
196
|
|
|
197
197
|
return truncateBody ? `\t↳ ${label}: ${rendered}` : `\t↳ ${label}:\n${rendered}`;
|
package/src/map/MapComponent.tsx
CHANGED
|
@@ -135,26 +135,21 @@ const MapComponentBase = ({ osMapsApiKey, basePath, children }: MapComponentProp
|
|
|
135
135
|
return true; // stop iteration
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
-
// Direct feature (e.g. polygon) — only show popup
|
|
139
|
-
// layer, not boundary or AOI layers
|
|
138
|
+
// Direct feature (e.g. polygon or MVT tile feature) — only show popup
|
|
139
|
+
// for the sampling points layer, not boundary or AOI layers.
|
|
140
140
|
if (layerName !== LAYER_NAMES.SamplingPoints) {
|
|
141
141
|
return;
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
-
const
|
|
145
|
-
const
|
|
144
|
+
const coordinate = event.coordinate;
|
|
145
|
+
const direction = getPopupPositionClass(coordinate, newMap);
|
|
146
146
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
setPopupFeatures([feature as Feature]);
|
|
152
|
-
setPopupCoordinate(coordinate);
|
|
153
|
-
setPopupPositionClass(direction);
|
|
154
|
-
overlay.setPosition(event.coordinate);
|
|
147
|
+
setPopupFeatures([feature as Feature]);
|
|
148
|
+
setPopupCoordinate(coordinate);
|
|
149
|
+
setPopupPositionClass(direction);
|
|
150
|
+
overlay.setPosition(event.coordinate);
|
|
155
151
|
|
|
156
|
-
|
|
157
|
-
}
|
|
152
|
+
return true; // stop iteration
|
|
158
153
|
});
|
|
159
154
|
});
|
|
160
155
|
|
package/src/map/Popup.tsx
CHANGED
|
@@ -1,73 +1,17 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useEffect, useId, useRef, useState } from 'react';
|
|
3
|
+
import { useCallback, useEffect, useId, useRef, useState } from 'react';
|
|
4
4
|
|
|
5
5
|
import type Map from 'ol/Map';
|
|
6
|
+
import { ImSpinner2 } from 'react-icons/im';
|
|
7
|
+
import { IoClose, IoSearch } from 'react-icons/io5';
|
|
6
8
|
|
|
7
9
|
import { groupResults } from './groupResults';
|
|
8
10
|
import type { GeocoderResult } from './types';
|
|
11
|
+
import { useDebounce } from '../../hooks/useDebounce';
|
|
9
12
|
|
|
10
|
-
//
|
|
11
|
-
const
|
|
12
|
-
<svg
|
|
13
|
-
className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none"
|
|
14
|
-
width="20"
|
|
15
|
-
height="20"
|
|
16
|
-
viewBox="0 0 18 18"
|
|
17
|
-
fill="none"
|
|
18
|
-
xmlns="http://www.w3.org/2000/svg"
|
|
19
|
-
>
|
|
20
|
-
<path
|
|
21
|
-
d="M7.5 13.5C10.8137 13.5 13.5 10.8137 13.5 7.5C13.5 4.18629 10.8137 1.5 7.5 1.5C4.18629 1.5 1.5 4.18629 1.5 7.5C1.5 10.8137 4.18629 13.5 7.5 13.5Z"
|
|
22
|
-
stroke="#757575"
|
|
23
|
-
strokeWidth="1.5"
|
|
24
|
-
strokeLinecap="round"
|
|
25
|
-
strokeLinejoin="round"
|
|
26
|
-
/>
|
|
27
|
-
|
|
28
|
-
<path
|
|
29
|
-
d="M16.5 16.5L11.625 11.625"
|
|
30
|
-
stroke="#757575"
|
|
31
|
-
strokeWidth="1.5"
|
|
32
|
-
strokeLinecap="round"
|
|
33
|
-
strokeLinejoin="round"
|
|
34
|
-
/>
|
|
35
|
-
</svg>
|
|
36
|
-
);
|
|
37
|
-
|
|
38
|
-
const ClearIcon = () => (
|
|
39
|
-
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
40
|
-
<path
|
|
41
|
-
d="M15 5L5 15M5 5L15 15"
|
|
42
|
-
stroke="#757575"
|
|
43
|
-
strokeWidth="2"
|
|
44
|
-
strokeLinecap="round"
|
|
45
|
-
strokeLinejoin="round"
|
|
46
|
-
/>
|
|
47
|
-
</svg>
|
|
48
|
-
);
|
|
49
|
-
|
|
50
|
-
const LoadingIcon = () => (
|
|
51
|
-
<svg
|
|
52
|
-
className="animate-spin"
|
|
53
|
-
width="26"
|
|
54
|
-
height="26"
|
|
55
|
-
viewBox="0 0 26 26"
|
|
56
|
-
fill="none"
|
|
57
|
-
xmlns="http://www.w3.org/2000/svg"
|
|
58
|
-
>
|
|
59
|
-
<circle
|
|
60
|
-
cx="13"
|
|
61
|
-
cy="13"
|
|
62
|
-
r="10"
|
|
63
|
-
stroke="#757575"
|
|
64
|
-
strokeWidth="3"
|
|
65
|
-
strokeLinecap="round"
|
|
66
|
-
strokeDasharray="60"
|
|
67
|
-
strokeDashoffset="40"
|
|
68
|
-
/>
|
|
69
|
-
</svg>
|
|
70
|
-
);
|
|
13
|
+
// Typeahead defaults
|
|
14
|
+
const DEFAULT_TYPEAHEAD_DELAY_MS = 500;
|
|
71
15
|
|
|
72
16
|
// Animation defaults - can be overridden via props
|
|
73
17
|
const DEFAULT_TARGET_ZOOM = 14;
|
|
@@ -97,8 +41,10 @@ export type GeocoderProps = {
|
|
|
97
41
|
fitPadding?: number;
|
|
98
42
|
/** Debug callback for logging results and selections */
|
|
99
43
|
onDebug?: (event: { type: 'results' | 'select' | 'clear'; data?: unknown }) => void;
|
|
100
|
-
|
|
101
|
-
|
|
44
|
+
/** Enable typeahead search while typing */
|
|
45
|
+
enableTypeahead?: boolean;
|
|
46
|
+
/** Debounce delay in ms for typeahead search (default: 500) */
|
|
47
|
+
typeaheadDelay?: number;
|
|
102
48
|
};
|
|
103
49
|
|
|
104
50
|
export const Geocoder = ({
|
|
@@ -114,6 +60,8 @@ export const Geocoder = ({
|
|
|
114
60
|
flyToDuration = DEFAULT_FLY_TO_DURATION,
|
|
115
61
|
fitPadding = DEFAULT_FIT_PADDING,
|
|
116
62
|
onDebug,
|
|
63
|
+
enableTypeahead = false,
|
|
64
|
+
typeaheadDelay = DEFAULT_TYPEAHEAD_DELAY_MS,
|
|
117
65
|
}: GeocoderProps) => {
|
|
118
66
|
const generatedId = useId();
|
|
119
67
|
const id = providedId ?? generatedId;
|
|
@@ -129,6 +77,50 @@ export const Geocoder = ({
|
|
|
129
77
|
const [status, setStatus] = useState('');
|
|
130
78
|
const [isSearching, setIsSearching] = useState(false);
|
|
131
79
|
|
|
80
|
+
const debouncedQuery = useDebounce(query, typeaheadDelay);
|
|
81
|
+
|
|
82
|
+
const performSearch = useCallback(
|
|
83
|
+
async (queryStr: string) => {
|
|
84
|
+
if (queryStr.length < minChars) {
|
|
85
|
+
setStatus(`Enter at least ${minChars} characters`);
|
|
86
|
+
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
setIsSearching(true);
|
|
91
|
+
setStatus('Searching...');
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
const data = await search(queryStr);
|
|
95
|
+
|
|
96
|
+
setResults(data);
|
|
97
|
+
setSelectedId(null);
|
|
98
|
+
setOpen(true);
|
|
99
|
+
setActiveIndex(-1);
|
|
100
|
+
setStatus(data.length ? `${data.length} results available` : 'No results found');
|
|
101
|
+
|
|
102
|
+
// Debug callback for results
|
|
103
|
+
onDebug?.({
|
|
104
|
+
type: 'results',
|
|
105
|
+
data: { query: queryStr, results: data, count: data.length },
|
|
106
|
+
});
|
|
107
|
+
} catch (error) {
|
|
108
|
+
console.error('Search error:', error);
|
|
109
|
+
setStatus('There was an error reaching the server');
|
|
110
|
+
setResults([]);
|
|
111
|
+
} finally {
|
|
112
|
+
setIsSearching(false);
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
[minChars, search, onDebug],
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
useEffect(() => {
|
|
119
|
+
if (enableTypeahead && debouncedQuery.length >= minChars) {
|
|
120
|
+
performSearch(debouncedQuery);
|
|
121
|
+
}
|
|
122
|
+
}, [debouncedQuery, enableTypeahead, minChars, performSearch]);
|
|
123
|
+
|
|
132
124
|
// Scroll the active result into view when navigating with arrow keys
|
|
133
125
|
useEffect(() => {
|
|
134
126
|
if (activeIndex >= 0) {
|
|
@@ -138,36 +130,6 @@ export const Geocoder = ({
|
|
|
138
130
|
}
|
|
139
131
|
}, [activeIndex, listboxId]);
|
|
140
132
|
|
|
141
|
-
const performSearch = async () => {
|
|
142
|
-
if (query.length < minChars) {
|
|
143
|
-
setStatus(`Enter at least ${minChars} characters`);
|
|
144
|
-
|
|
145
|
-
return;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
setIsSearching(true);
|
|
149
|
-
setStatus('Searching...');
|
|
150
|
-
|
|
151
|
-
try {
|
|
152
|
-
const data = await search(query);
|
|
153
|
-
|
|
154
|
-
setResults(data);
|
|
155
|
-
setSelectedId(null);
|
|
156
|
-
setOpen(true);
|
|
157
|
-
setActiveIndex(-1);
|
|
158
|
-
setStatus(data.length ? `${data.length} results available` : 'No results found');
|
|
159
|
-
|
|
160
|
-
// Debug callback for results
|
|
161
|
-
onDebug?.({ type: 'results', data: { query, results: data, count: data.length } });
|
|
162
|
-
} catch (error) {
|
|
163
|
-
console.error('Search error:', error);
|
|
164
|
-
setStatus('There was an error reaching the server');
|
|
165
|
-
setResults([]);
|
|
166
|
-
} finally {
|
|
167
|
-
setIsSearching(false);
|
|
168
|
-
}
|
|
169
|
-
};
|
|
170
|
-
|
|
171
133
|
const selectResult = (result: GeocoderResult) => {
|
|
172
134
|
const view = map.getView();
|
|
173
135
|
const currentZoom = view.getZoom() ?? 10;
|
|
@@ -195,19 +157,11 @@ export const Geocoder = ({
|
|
|
195
157
|
if (shouldZoomOut) {
|
|
196
158
|
// Step 1: Zoom out, then combined pan + zoom in (like ol-geocoder)
|
|
197
159
|
view.animate({ zoom: flyZoom, duration: zoomOutDuration }, () => {
|
|
198
|
-
view.animate({
|
|
199
|
-
center: result.center,
|
|
200
|
-
zoom: finalZoom,
|
|
201
|
-
duration: flyToDuration,
|
|
202
|
-
});
|
|
160
|
+
view.animate({ center: result.center, zoom: finalZoom, duration: flyToDuration });
|
|
203
161
|
});
|
|
204
162
|
} else {
|
|
205
163
|
// Combined pan + zoom in one smooth animation
|
|
206
|
-
view.animate({
|
|
207
|
-
center: result.center,
|
|
208
|
-
zoom: finalZoom,
|
|
209
|
-
duration: flyToDuration,
|
|
210
|
-
});
|
|
164
|
+
view.animate({ center: result.center, zoom: finalZoom, duration: flyToDuration });
|
|
211
165
|
}
|
|
212
166
|
}
|
|
213
167
|
|
|
@@ -256,9 +210,14 @@ export const Geocoder = ({
|
|
|
256
210
|
Search for a place
|
|
257
211
|
</label>
|
|
258
212
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
213
|
+
<div
|
|
214
|
+
className="relative bg-white rounded shadow-lg transition-all duration-200
|
|
215
|
+
focus-within:ring-2 focus-within:ring-focus"
|
|
216
|
+
>
|
|
217
|
+
<IoSearch
|
|
218
|
+
size={20}
|
|
219
|
+
className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none text-gray-400"
|
|
220
|
+
/>
|
|
262
221
|
|
|
263
222
|
<input
|
|
264
223
|
ref={inputRef}
|
|
@@ -298,7 +257,7 @@ export const Geocoder = ({
|
|
|
298
257
|
if (activeResult && open) {
|
|
299
258
|
selectResult(activeResult);
|
|
300
259
|
} else {
|
|
301
|
-
performSearch();
|
|
260
|
+
performSearch(query);
|
|
302
261
|
}
|
|
303
262
|
}
|
|
304
263
|
|
|
@@ -311,13 +270,12 @@ export const Geocoder = ({
|
|
|
311
270
|
placeholder:text-gray-500 focus:outline-none focus:ring-0"
|
|
312
271
|
/>
|
|
313
272
|
|
|
314
|
-
{/* Clear or Loading button */}
|
|
315
273
|
<div className="absolute right-2 top-1/2 -translate-y-1/2">
|
|
316
274
|
{(() => {
|
|
317
275
|
if (isSearching) {
|
|
318
276
|
return (
|
|
319
277
|
<div className="p-1">
|
|
320
|
-
<
|
|
278
|
+
<ImSpinner2 size={22} className="animate-spin text-gray-500" />
|
|
321
279
|
</div>
|
|
322
280
|
);
|
|
323
281
|
}
|
|
@@ -330,12 +288,12 @@ export const Geocoder = ({
|
|
|
330
288
|
aria-label="Clear search"
|
|
331
289
|
className="p-1 hover:bg-gray-100 rounded transition-colors"
|
|
332
290
|
>
|
|
333
|
-
<
|
|
291
|
+
<IoClose size={20} className="text-gray-500" />
|
|
334
292
|
</button>
|
|
335
293
|
);
|
|
336
294
|
}
|
|
337
295
|
|
|
338
|
-
return
|
|
296
|
+
return <></>;
|
|
339
297
|
})()}
|
|
340
298
|
</div>
|
|
341
299
|
</div>
|
|
@@ -344,7 +302,7 @@ export const Geocoder = ({
|
|
|
344
302
|
{open ? (
|
|
345
303
|
<div className="absolute z-1000 mt-1.5 w-full bg-white rounded shadow-lg overflow-hidden">
|
|
346
304
|
{results.length > 0 ? (
|
|
347
|
-
<div id={listboxId} role="listbox" className="max-h-60 overflow-auto">
|
|
305
|
+
<div id={listboxId} role="listbox" tabIndex={-1} className="max-h-60 overflow-auto">
|
|
348
306
|
{Object.entries(grouped).map(([group, items]) => (
|
|
349
307
|
<div key={group} role="presentation">
|
|
350
308
|
<div
|
|
@@ -357,21 +315,22 @@ export const Geocoder = ({
|
|
|
357
315
|
<div role="group">
|
|
358
316
|
{items.map((item) => {
|
|
359
317
|
flatIndex += 1;
|
|
360
|
-
const
|
|
318
|
+
const itemIndex = flatIndex;
|
|
319
|
+
const isActive = itemIndex === activeIndex;
|
|
361
320
|
const isSelected = item.id === selectedId;
|
|
362
321
|
|
|
363
322
|
return (
|
|
364
323
|
<div
|
|
365
|
-
id={`${listboxId}-option-${
|
|
324
|
+
id={`${listboxId}-option-${itemIndex}`}
|
|
366
325
|
key={item.id}
|
|
367
326
|
role="option"
|
|
368
327
|
aria-selected={isSelected}
|
|
369
328
|
tabIndex={-1}
|
|
370
|
-
className={`cursor-pointer px-3 py-2 text-sm text-gray-800
|
|
329
|
+
className={`scroll-mt-8 cursor-pointer px-3 py-2 text-sm text-gray-800
|
|
371
330
|
transition-colors ${isSelected ? 'bg-blue-50 font-semibold' : ''}
|
|
372
331
|
${isActive && !isSelected ? 'bg-gray-100' : ''}
|
|
373
332
|
${!isSelected && !isActive ? 'hover:bg-gray-50' : ''}`}
|
|
374
|
-
onMouseEnter={() => setActiveIndex(
|
|
333
|
+
onMouseEnter={() => setActiveIndex(itemIndex)}
|
|
375
334
|
onMouseDown={() => selectResult(item)}
|
|
376
335
|
>
|
|
377
336
|
<div className="truncate">{item.label}</div>
|
|
@@ -392,7 +351,7 @@ export const Geocoder = ({
|
|
|
392
351
|
);
|
|
393
352
|
}
|
|
394
353
|
|
|
395
|
-
return
|
|
354
|
+
return <></>;
|
|
396
355
|
})()
|
|
397
356
|
)}
|
|
398
357
|
</div>
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { transform, transformExtent } from 'ol/proj';
|
|
2
|
+
import { describe, expect, it, vi, afterEach } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
mapOsOpenNamesFeaturesToGeocoderResults,
|
|
6
|
+
mapOsOpenNamesFeatureToGeocoderResult,
|
|
7
|
+
} from './osOpenNamesSearch';
|
|
8
|
+
|
|
9
|
+
vi.mock('ol/proj', () => ({
|
|
10
|
+
transform: vi.fn((coords) => coords),
|
|
11
|
+
transformExtent: vi.fn((extent) => extent),
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
const createPointFeature = (overrides: Record<string, unknown> = {}) =>
|
|
15
|
+
({
|
|
16
|
+
type: 'Feature',
|
|
17
|
+
properties: {
|
|
18
|
+
id: 'place-1',
|
|
19
|
+
address: '10 Downing Street',
|
|
20
|
+
type: 'populatedPlace',
|
|
21
|
+
},
|
|
22
|
+
geometry: {
|
|
23
|
+
type: 'Point',
|
|
24
|
+
coordinates: [-0.1276, 51.5034],
|
|
25
|
+
},
|
|
26
|
+
...overrides,
|
|
27
|
+
}) as never;
|
|
28
|
+
|
|
29
|
+
const createPolygonFeature = (overrides: Record<string, unknown> = {}) =>
|
|
30
|
+
({
|
|
31
|
+
type: 'Feature',
|
|
32
|
+
properties: {
|
|
33
|
+
id: 'area-1',
|
|
34
|
+
name: 'Westminster',
|
|
35
|
+
type: 'city',
|
|
36
|
+
},
|
|
37
|
+
geometry: {
|
|
38
|
+
type: 'Polygon',
|
|
39
|
+
coordinates: [],
|
|
40
|
+
},
|
|
41
|
+
bbox: [-0.15, 51.49, -0.12, 51.52],
|
|
42
|
+
...overrides,
|
|
43
|
+
}) as never;
|
|
44
|
+
|
|
45
|
+
describe('mapOsOpenNamesFeatureToGeocoderResult', () => {
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
vi.clearAllMocks();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('maps a Point feature with string address', () => {
|
|
51
|
+
const result = mapOsOpenNamesFeatureToGeocoderResult(createPointFeature(), 0);
|
|
52
|
+
|
|
53
|
+
expect(result[0]).toEqual({
|
|
54
|
+
id: 'place-1',
|
|
55
|
+
label: '10 Downing Street',
|
|
56
|
+
group: 'populatedPlace',
|
|
57
|
+
zoom: expect.any(Number),
|
|
58
|
+
center: [-0.1276, 51.5034],
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
expect(transform).toHaveBeenCalledWith(
|
|
62
|
+
[-0.1276, 51.5034],
|
|
63
|
+
expect.anything(),
|
|
64
|
+
expect.anything(),
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('builds the label from address object fields', () => {
|
|
69
|
+
const feature = createPointFeature({
|
|
70
|
+
properties: {
|
|
71
|
+
id: 'place-2',
|
|
72
|
+
address: {
|
|
73
|
+
name: 'Baker Street',
|
|
74
|
+
town: 'London',
|
|
75
|
+
country: 'England',
|
|
76
|
+
},
|
|
77
|
+
type: 'street',
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const result = mapOsOpenNamesFeatureToGeocoderResult(feature, 1);
|
|
82
|
+
|
|
83
|
+
expect(result[0]?.label).toBe('Baker Street, London, England');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('uses bbox to populate extent and center', () => {
|
|
87
|
+
const result = mapOsOpenNamesFeatureToGeocoderResult(createPolygonFeature(), 0);
|
|
88
|
+
|
|
89
|
+
expect(result[0]).toEqual({
|
|
90
|
+
id: 'area-1',
|
|
91
|
+
label: 'Westminster',
|
|
92
|
+
group: 'city',
|
|
93
|
+
zoom: expect.any(Number),
|
|
94
|
+
extent: [-0.15, 51.49, -0.12, 51.52],
|
|
95
|
+
center: [-0.135, 51.505],
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
expect(transformExtent).toHaveBeenCalledWith(
|
|
99
|
+
[-0.15, 51.49, -0.12, 51.52],
|
|
100
|
+
expect.anything(),
|
|
101
|
+
expect.anything(),
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
expect(transform).toHaveBeenCalledWith([-0.135, 51.505], expect.anything(), expect.anything());
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('falls back to localType when type is missing', () => {
|
|
108
|
+
const feature = createPointFeature({
|
|
109
|
+
properties: {
|
|
110
|
+
id: 'place-3',
|
|
111
|
+
name: 'Somewhere',
|
|
112
|
+
localType: 'Village',
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const result = mapOsOpenNamesFeatureToGeocoderResult(feature, 0);
|
|
117
|
+
|
|
118
|
+
expect(result[0]?.group).toBe('Village');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('falls back to generated id and label when missing', () => {
|
|
122
|
+
const feature = createPointFeature({
|
|
123
|
+
properties: {},
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const result = mapOsOpenNamesFeatureToGeocoderResult(feature, 7);
|
|
127
|
+
|
|
128
|
+
expect(result[0]?.id).toBe('result-7');
|
|
129
|
+
expect(result[0]?.label).toBe('result-7');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('returns null for non-Point geometry without bbox', () => {
|
|
133
|
+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
134
|
+
|
|
135
|
+
const feature = createPolygonFeature({
|
|
136
|
+
bbox: undefined,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const result = mapOsOpenNamesFeatureToGeocoderResult(feature, 0);
|
|
140
|
+
|
|
141
|
+
expect(result[0]).toBeUndefined();
|
|
142
|
+
expect(errorSpy).toHaveBeenCalledWith('Feature has no bbox and geometry is not a Point');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('returns null for invalid Point coordinates', () => {
|
|
146
|
+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
147
|
+
|
|
148
|
+
const feature = createPointFeature({
|
|
149
|
+
geometry: {
|
|
150
|
+
type: 'Point',
|
|
151
|
+
coordinates: ['bad', 51.5],
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const result = mapOsOpenNamesFeatureToGeocoderResult(feature, 0);
|
|
156
|
+
|
|
157
|
+
expect(result[0]).toBeUndefined();
|
|
158
|
+
expect(errorSpy).toHaveBeenCalledWith('Invalid coordinates: lon or lat is not a number');
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe('mapOsOpenNamesFeaturesToGeocoderResults', () => {
|
|
163
|
+
afterEach(() => {
|
|
164
|
+
vi.clearAllMocks();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('maps features and filters out null results', () => {
|
|
168
|
+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
169
|
+
|
|
170
|
+
const results = mapOsOpenNamesFeaturesToGeocoderResults([
|
|
171
|
+
createPointFeature({
|
|
172
|
+
properties: { id: 'valid-1', address: 'Valid place', type: 'street' },
|
|
173
|
+
}),
|
|
174
|
+
createPolygonFeature({
|
|
175
|
+
bbox: undefined,
|
|
176
|
+
}),
|
|
177
|
+
createPointFeature({
|
|
178
|
+
properties: { id: 'valid-2', address: 'Another place', type: 'street' },
|
|
179
|
+
}),
|
|
180
|
+
]);
|
|
181
|
+
|
|
182
|
+
expect(results).toHaveLength(2);
|
|
183
|
+
expect(results.map((result) => result.id)).toEqual(['valid-1', 'valid-2']);
|
|
184
|
+
expect(errorSpy).toHaveBeenCalledTimes(1);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { FeatureCollection } from 'geojson';
|
|
1
|
+
import type { Feature, FeatureCollection } from 'geojson';
|
|
2
2
|
import { transform, transformExtent } from 'ol/proj';
|
|
3
3
|
|
|
4
4
|
import type { GeocoderResult } from './geocoder/types';
|
|
@@ -26,6 +26,82 @@ const ZOOM_BY_TYPE: Record<string, number> = {
|
|
|
26
26
|
'Postcode Unit': 18,
|
|
27
27
|
};
|
|
28
28
|
|
|
29
|
+
export const mapOsOpenNamesFeatureToGeocoderResult = (
|
|
30
|
+
feature: Feature,
|
|
31
|
+
index: number,
|
|
32
|
+
): [] | [GeocoderResult] => {
|
|
33
|
+
const properties = feature.properties ?? {};
|
|
34
|
+
|
|
35
|
+
let label: string;
|
|
36
|
+
|
|
37
|
+
if (properties.address && typeof properties.address === 'object') {
|
|
38
|
+
const addr = properties.address as { name?: string; town?: string; country?: string };
|
|
39
|
+
|
|
40
|
+
label = [addr.name, addr.town, addr.country].filter(Boolean).join(', ');
|
|
41
|
+
} else {
|
|
42
|
+
label = properties.address ?? properties.name ?? `result-${index}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const result: GeocoderResult = {
|
|
46
|
+
id: properties.id ?? `result-${index}`,
|
|
47
|
+
label,
|
|
48
|
+
group: properties.type ?? properties.localType,
|
|
49
|
+
zoom: ZOOM_BY_TYPE[properties.type] ?? ZOOM_BY_TYPE[properties.localType] ?? 14,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
if (feature.bbox && Array.isArray(feature.bbox) && feature.bbox.length === 4) {
|
|
53
|
+
const [minLon, minLat, maxLon, maxLat] = feature.bbox;
|
|
54
|
+
|
|
55
|
+
result.extent = transformExtent(feature.bbox, EPSG_4326, EPSG_3857) as [
|
|
56
|
+
number,
|
|
57
|
+
number,
|
|
58
|
+
number,
|
|
59
|
+
number,
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
const [x, y] = transform([(minLon + maxLon) / 2, (minLat + maxLat) / 2], EPSG_4326, EPSG_3857);
|
|
63
|
+
|
|
64
|
+
if (typeof x !== 'number' || typeof y !== 'number') {
|
|
65
|
+
console.error('Transform failed: x or y is not a number');
|
|
66
|
+
|
|
67
|
+
return [];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
result.center = [x, y];
|
|
71
|
+
|
|
72
|
+
return [result];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (feature.geometry.type === 'Point') {
|
|
76
|
+
const [lon, lat] = feature.geometry.coordinates;
|
|
77
|
+
|
|
78
|
+
if (typeof lon !== 'number' || typeof lat !== 'number') {
|
|
79
|
+
console.error('Invalid coordinates: lon or lat is not a number');
|
|
80
|
+
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const [x, y] = transform([lon, lat], EPSG_4326, EPSG_3857);
|
|
85
|
+
|
|
86
|
+
if (typeof x !== 'number' || typeof y !== 'number') {
|
|
87
|
+
console.error('Transform failed: x or y is not a number');
|
|
88
|
+
|
|
89
|
+
return [];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
result.center = [x, y];
|
|
93
|
+
|
|
94
|
+
return [result];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
console.error('Feature has no bbox and geometry is not a Point');
|
|
98
|
+
|
|
99
|
+
return [];
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export const mapOsOpenNamesFeaturesToGeocoderResults = (features: Feature[]): GeocoderResult[] =>
|
|
103
|
+
features.flatMap((feature, index) => mapOsOpenNamesFeatureToGeocoderResult(feature, index));
|
|
104
|
+
|
|
29
105
|
/**
|
|
30
106
|
* Creates a search function for OS Open Names API that returns GeocoderResult[].
|
|
31
107
|
*
|
|
@@ -61,76 +137,6 @@ export const createOsOpenNamesSearch = (options: OsOpenNamesSearchOptions) => {
|
|
|
61
137
|
return [];
|
|
62
138
|
}
|
|
63
139
|
|
|
64
|
-
return features
|
|
65
|
-
.map((feature, index): GeocoderResult | null => {
|
|
66
|
-
const properties = feature.properties ?? {};
|
|
67
|
-
|
|
68
|
-
// Handle address which can be an object or string
|
|
69
|
-
let label: string;
|
|
70
|
-
|
|
71
|
-
if (properties.address && typeof properties.address === 'object') {
|
|
72
|
-
const addr = properties.address as { name?: string; town?: string; country?: string };
|
|
73
|
-
|
|
74
|
-
label = [addr.name, addr.town, addr.country].filter(Boolean).join(', ');
|
|
75
|
-
} else {
|
|
76
|
-
label = properties.address ?? properties.name ?? `result-${index}`;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
const result: GeocoderResult = {
|
|
80
|
-
id: properties.id ?? `result-${index}`,
|
|
81
|
-
label,
|
|
82
|
-
group: properties.type ?? properties.localType,
|
|
83
|
-
// Assign zoom level based on type, fallback to 14
|
|
84
|
-
zoom: ZOOM_BY_TYPE[properties.type] ?? ZOOM_BY_TYPE[properties.localType] ?? 14,
|
|
85
|
-
};
|
|
86
|
-
|
|
87
|
-
// Prefer bbox — works for all geometry types (Point, Polygon, MultiPolygon)
|
|
88
|
-
if (feature.bbox && Array.isArray(feature.bbox) && feature.bbox.length === 4) {
|
|
89
|
-
const [minLon, minLat, maxLon, maxLat] = feature.bbox;
|
|
90
|
-
|
|
91
|
-
result.extent = transformExtent(feature.bbox, EPSG_4326, EPSG_3857) as [
|
|
92
|
-
number,
|
|
93
|
-
number,
|
|
94
|
-
number,
|
|
95
|
-
number,
|
|
96
|
-
];
|
|
97
|
-
|
|
98
|
-
const [x, y] = transform(
|
|
99
|
-
[(minLon + maxLon) / 2, (minLat + maxLat) / 2],
|
|
100
|
-
EPSG_4326,
|
|
101
|
-
EPSG_3857,
|
|
102
|
-
);
|
|
103
|
-
|
|
104
|
-
if (typeof x === 'number' && typeof y === 'number') {
|
|
105
|
-
result.center = [x, y];
|
|
106
|
-
}
|
|
107
|
-
} else if (feature.geometry.type === 'Point') {
|
|
108
|
-
// Fallback for Point features without a bbox
|
|
109
|
-
const [lon, lat] = feature.geometry.coordinates;
|
|
110
|
-
|
|
111
|
-
if (typeof lon !== 'number' || typeof lat !== 'number') {
|
|
112
|
-
console.error('Invalid coordinates: lon or lat is not a number');
|
|
113
|
-
|
|
114
|
-
return null;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
const [x, y] = transform([lon, lat], EPSG_4326, EPSG_3857);
|
|
118
|
-
|
|
119
|
-
if (typeof x !== 'number' || typeof y !== 'number') {
|
|
120
|
-
console.error('Transform failed: x or y is not a number');
|
|
121
|
-
|
|
122
|
-
return null;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
result.center = [x, y];
|
|
126
|
-
} else {
|
|
127
|
-
console.error('Feature has no bbox and geometry is not a Point');
|
|
128
|
-
|
|
129
|
-
return null;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
return result;
|
|
133
|
-
})
|
|
134
|
-
.filter((result): result is GeocoderResult => result !== null);
|
|
140
|
+
return mapOsOpenNamesFeaturesToGeocoderResults(features);
|
|
135
141
|
};
|
|
136
142
|
};
|
package/src/utils/http.ts
CHANGED
|
@@ -48,7 +48,7 @@ const encodeBase64 = (input: string): string => {
|
|
|
48
48
|
throw new Error('Base64 encoding not available in this environment');
|
|
49
49
|
};
|
|
50
50
|
|
|
51
|
-
const getAuthorizationHeader = (): { Authorization: string }
|
|
51
|
+
const getAuthorizationHeader = (): { Authorization: string } => {
|
|
52
52
|
const apiHasAuth = getNodeEnvVar('API_HAS_AUTH');
|
|
53
53
|
const apiUsername = getNodeEnvVar('API_USERNAME');
|
|
54
54
|
const apiPassword = getNodeEnvVar('API_PASSWORD');
|
|
@@ -61,7 +61,7 @@ const getAuthorizationHeader = (): { Authorization: string } | Record<string, ne
|
|
|
61
61
|
};
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
return {};
|
|
64
|
+
return { Authorization: '' };
|
|
65
65
|
};
|
|
66
66
|
|
|
67
67
|
// common fetch wrapper to provide auth headers if necessary
|