@tpzdsp/next-toolkit 1.15.3 → 2.0.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 +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 +62 -37
- 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.0",
|
|
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,11 +1,12 @@
|
|
|
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
6
|
|
|
7
7
|
import { groupResults } from './groupResults';
|
|
8
8
|
import type { GeocoderResult } from './types';
|
|
9
|
+
import { useDebounce } from '../../hooks/useDebounce';
|
|
9
10
|
|
|
10
11
|
// SVG Icons matching Mapbox GL Geocoder style
|
|
11
12
|
const SearchIcon = () => (
|
|
@@ -69,6 +70,9 @@ const LoadingIcon = () => (
|
|
|
69
70
|
</svg>
|
|
70
71
|
);
|
|
71
72
|
|
|
73
|
+
// Typeahead defaults
|
|
74
|
+
const DEFAULT_TYPEAHEAD_DELAY_MS = 500;
|
|
75
|
+
|
|
72
76
|
// Animation defaults - can be overridden via props
|
|
73
77
|
const DEFAULT_TARGET_ZOOM = 14;
|
|
74
78
|
const DEFAULT_FLY_OUT_ZOOM_MIN = 6;
|
|
@@ -97,8 +101,10 @@ export type GeocoderProps = {
|
|
|
97
101
|
fitPadding?: number;
|
|
98
102
|
/** Debug callback for logging results and selections */
|
|
99
103
|
onDebug?: (event: { type: 'results' | 'select' | 'clear'; data?: unknown }) => void;
|
|
100
|
-
|
|
101
|
-
|
|
104
|
+
/** Enable typeahead search while typing */
|
|
105
|
+
enableTypeahead?: boolean;
|
|
106
|
+
/** Debounce delay in ms for typeahead search (default: 500) */
|
|
107
|
+
typeaheadDelay?: number;
|
|
102
108
|
};
|
|
103
109
|
|
|
104
110
|
export const Geocoder = ({
|
|
@@ -114,6 +120,8 @@ export const Geocoder = ({
|
|
|
114
120
|
flyToDuration = DEFAULT_FLY_TO_DURATION,
|
|
115
121
|
fitPadding = DEFAULT_FIT_PADDING,
|
|
116
122
|
onDebug,
|
|
123
|
+
enableTypeahead = false,
|
|
124
|
+
typeaheadDelay = DEFAULT_TYPEAHEAD_DELAY_MS,
|
|
117
125
|
}: GeocoderProps) => {
|
|
118
126
|
const generatedId = useId();
|
|
119
127
|
const id = providedId ?? generatedId;
|
|
@@ -129,6 +137,50 @@ export const Geocoder = ({
|
|
|
129
137
|
const [status, setStatus] = useState('');
|
|
130
138
|
const [isSearching, setIsSearching] = useState(false);
|
|
131
139
|
|
|
140
|
+
const debouncedQuery = useDebounce(query, typeaheadDelay);
|
|
141
|
+
|
|
142
|
+
const performSearch = useCallback(
|
|
143
|
+
async (queryStr: string) => {
|
|
144
|
+
if (queryStr.length < minChars) {
|
|
145
|
+
setStatus(`Enter at least ${minChars} characters`);
|
|
146
|
+
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
setIsSearching(true);
|
|
151
|
+
setStatus('Searching...');
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
const data = await search(queryStr);
|
|
155
|
+
|
|
156
|
+
setResults(data);
|
|
157
|
+
setSelectedId(null);
|
|
158
|
+
setOpen(true);
|
|
159
|
+
setActiveIndex(-1);
|
|
160
|
+
setStatus(data.length ? `${data.length} results available` : 'No results found');
|
|
161
|
+
|
|
162
|
+
// Debug callback for results
|
|
163
|
+
onDebug?.({
|
|
164
|
+
type: 'results',
|
|
165
|
+
data: { query: queryStr, results: data, count: data.length },
|
|
166
|
+
});
|
|
167
|
+
} catch (error) {
|
|
168
|
+
console.error('Search error:', error);
|
|
169
|
+
setStatus('There was an error reaching the server');
|
|
170
|
+
setResults([]);
|
|
171
|
+
} finally {
|
|
172
|
+
setIsSearching(false);
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
[minChars, search, onDebug],
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
useEffect(() => {
|
|
179
|
+
if (enableTypeahead && debouncedQuery.length >= minChars) {
|
|
180
|
+
performSearch(debouncedQuery);
|
|
181
|
+
}
|
|
182
|
+
}, [debouncedQuery, enableTypeahead, minChars, performSearch]);
|
|
183
|
+
|
|
132
184
|
// Scroll the active result into view when navigating with arrow keys
|
|
133
185
|
useEffect(() => {
|
|
134
186
|
if (activeIndex >= 0) {
|
|
@@ -138,36 +190,6 @@ export const Geocoder = ({
|
|
|
138
190
|
}
|
|
139
191
|
}, [activeIndex, listboxId]);
|
|
140
192
|
|
|
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
193
|
const selectResult = (result: GeocoderResult) => {
|
|
172
194
|
const view = map.getView();
|
|
173
195
|
const currentZoom = view.getZoom() ?? 10;
|
|
@@ -257,7 +279,10 @@ export const Geocoder = ({
|
|
|
257
279
|
</label>
|
|
258
280
|
|
|
259
281
|
{/* Mapbox-style geocoder container */}
|
|
260
|
-
<div
|
|
282
|
+
<div
|
|
283
|
+
className="relative bg-white rounded shadow-lg transition-all duration-200
|
|
284
|
+
focus-within:ring-2 focus-within:ring-focus"
|
|
285
|
+
>
|
|
261
286
|
<SearchIcon />
|
|
262
287
|
|
|
263
288
|
<input
|
|
@@ -298,7 +323,7 @@ export const Geocoder = ({
|
|
|
298
323
|
if (activeResult && open) {
|
|
299
324
|
selectResult(activeResult);
|
|
300
325
|
} else {
|
|
301
|
-
performSearch();
|
|
326
|
+
performSearch(query);
|
|
302
327
|
}
|
|
303
328
|
}
|
|
304
329
|
|
|
@@ -335,7 +360,7 @@ export const Geocoder = ({
|
|
|
335
360
|
);
|
|
336
361
|
}
|
|
337
362
|
|
|
338
|
-
return
|
|
363
|
+
return <></>;
|
|
339
364
|
})()}
|
|
340
365
|
</div>
|
|
341
366
|
</div>
|
|
@@ -392,7 +417,7 @@ export const Geocoder = ({
|
|
|
392
417
|
);
|
|
393
418
|
}
|
|
394
419
|
|
|
395
|
-
return
|
|
420
|
+
return <></>;
|
|
396
421
|
})()
|
|
397
422
|
)}
|
|
398
423
|
</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
|