@tpzdsp/next-toolkit 1.14.0 → 1.14.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/components/Button/Button.test.tsx +0 -2
- package/src/components/ErrorText/ErrorText.test.tsx +0 -2
- package/src/components/Heading/Heading.test.tsx +0 -2
- package/src/components/Hint/Hint.test.tsx +0 -2
- package/src/components/Modal/Modal.stories.tsx +0 -1
- package/src/components/Modal/Modal.test.tsx +1 -3
- package/src/components/NotificationBanner/NotificationBanner.test.tsx +0 -2
- package/src/components/Paragraph/Paragraph.test.tsx +0 -2
- package/src/components/SlidingPanel/SlidingPanel.test.tsx +0 -2
- package/src/components/accordion/Accordion.test.tsx +0 -2
- package/src/components/backToTop/BackToTop.stories.tsx +0 -2
- package/src/components/dropdown/DropdownMenu.test.tsx +0 -2
- package/src/components/dropdown/useDropdownMenu.ts +0 -1
- package/src/components/form/TextArea.test.tsx +0 -2
- package/src/components/images/DefraLogo.tsx +1 -1
- package/src/components/layout/header/Header.stories.tsx +48 -0
- package/src/components/layout/header/Header.test.tsx +36 -0
- package/src/components/layout/header/HeaderAuthClient.test.tsx +45 -0
- package/src/components/layout/header/HeaderNavClient.test.tsx +44 -0
- package/src/components/layout/header/HeaderNavClient.tsx +6 -3
- package/src/components/link/ExternalLink.test.tsx +0 -2
- package/src/http/stream.test.ts +98 -0
- package/src/map/geocoder/Geocoder.test.tsx +1 -2
- package/src/map/geocoder/Geocoder.tsx +10 -1
- package/src/map/osOpenNamesSearch.ts +37 -27
- package/src/types/navigation.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tpzdsp/next-toolkit",
|
|
3
|
-
"version": "1.14.
|
|
3
|
+
"version": "1.14.2",
|
|
4
4
|
"description": "A reusable React component library for Next.js applications",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">= 24.12.0",
|
|
@@ -138,7 +138,7 @@
|
|
|
138
138
|
"@testing-library/jest-dom": "^6.6.4",
|
|
139
139
|
"@testing-library/react": "^16.3.0",
|
|
140
140
|
"@testing-library/user-event": "^14.6.1",
|
|
141
|
-
"@tpzdsp/eslint-config-dsp": "^
|
|
141
|
+
"@tpzdsp/eslint-config-dsp": "^2.0.0",
|
|
142
142
|
"@turf/turf": "^7.2.0",
|
|
143
143
|
"@types/geojson": "^7946.0.16",
|
|
144
144
|
"@types/jsonwebtoken": "^9.0.10",
|
|
@@ -158,7 +158,7 @@
|
|
|
158
158
|
"eslint-config-prettier": "^10.1.8",
|
|
159
159
|
"eslint-import-resolver-alias": "^1.1.2",
|
|
160
160
|
"eslint-import-resolver-typescript": "^4.4.4",
|
|
161
|
-
"eslint-plugin-import": "^
|
|
161
|
+
"eslint-plugin-import-x": "^4.16.1",
|
|
162
162
|
"eslint-plugin-jest-dom": "^5.5.0",
|
|
163
163
|
"eslint-plugin-jsx-a11y": "^6.10.2",
|
|
164
164
|
"eslint-plugin-prettier": "^5.5.5",
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
-
|
|
3
1
|
import { Modal } from './Modal';
|
|
4
2
|
import { render, screen, userEvent } from '../../test/renderers';
|
|
5
3
|
|
|
@@ -25,7 +23,7 @@ describe.todo('Modal', () => {
|
|
|
25
23
|
const modalRoot = document.getElementById(MODAL_ROOT_ID);
|
|
26
24
|
|
|
27
25
|
if (modalRoot) {
|
|
28
|
-
|
|
26
|
+
modalRoot.remove();
|
|
29
27
|
}
|
|
30
28
|
});
|
|
31
29
|
|
|
@@ -190,7 +190,6 @@ export const useDropdownMenu = (itemCount: number): DropdownMenuHook => {
|
|
|
190
190
|
},
|
|
191
191
|
},
|
|
192
192
|
|
|
193
|
-
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
194
193
|
itemProps: Array.from({ length: itemCount }, (_, itemIndex) => ({
|
|
195
194
|
key: itemIndex,
|
|
196
195
|
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
+
|
|
3
|
+
import { Header } from './Header';
|
|
4
|
+
import type { Credentials } from '../../../types/auth';
|
|
5
|
+
import type { NavLink } from '../../../types/navigation';
|
|
6
|
+
|
|
7
|
+
const navLinks: NavLink[] = [
|
|
8
|
+
{ label: 'Home', url: '/', isExternal: false },
|
|
9
|
+
{ label: 'API', url: '/api-docs', isExternal: false },
|
|
10
|
+
{ label: 'Support', url: 'https://example.com/support', isExternal: true },
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
const authenticatedCredentials: Credentials = {
|
|
14
|
+
token: 'mock-token',
|
|
15
|
+
user: {
|
|
16
|
+
name: 'Jamie Taylor',
|
|
17
|
+
email: 'jamie.taylor@example.com',
|
|
18
|
+
groupInfoIds: ['group-1', 'group-2'],
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export default {
|
|
23
|
+
title: 'Components/Layout/Header',
|
|
24
|
+
component: Header,
|
|
25
|
+
parameters: {
|
|
26
|
+
layout: 'fullscreen',
|
|
27
|
+
docs: {
|
|
28
|
+
description: {
|
|
29
|
+
component:
|
|
30
|
+
'Header with DSP branding, navigation, and auth state. Includes responsive nav and client-side auth links.',
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
args: {
|
|
35
|
+
dspUrl: 'https://environment.data.gov.uk',
|
|
36
|
+
appName: 'Wetlands Inventory',
|
|
37
|
+
navLinks,
|
|
38
|
+
credentials: null,
|
|
39
|
+
},
|
|
40
|
+
} as Meta<typeof Header>;
|
|
41
|
+
|
|
42
|
+
export const Default: StoryObj<typeof Header> = {};
|
|
43
|
+
|
|
44
|
+
export const Authenticated: StoryObj<typeof Header> = {
|
|
45
|
+
args: {
|
|
46
|
+
credentials: authenticatedCredentials,
|
|
47
|
+
},
|
|
48
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Header } from './Header';
|
|
2
|
+
import { render, screen } from '../../../test/renderers';
|
|
3
|
+
import type { NavLink } from '../../../types/navigation';
|
|
4
|
+
|
|
5
|
+
const NAV_LINKS: NavLink[] = [
|
|
6
|
+
{ label: 'Home', url: '/', isExternal: false },
|
|
7
|
+
{ label: 'API', url: '/api-docs', isExternal: false },
|
|
8
|
+
{ label: 'Support', url: 'https://example.com/support', isExternal: true },
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
describe('Header', () => {
|
|
12
|
+
it('renders the primary brand links and app name', async () => {
|
|
13
|
+
render(
|
|
14
|
+
<Header
|
|
15
|
+
credentials={null}
|
|
16
|
+
dspUrl="https://example.com"
|
|
17
|
+
appName="Wetlands Inventory"
|
|
18
|
+
navLinks={NAV_LINKS}
|
|
19
|
+
/>,
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
expect(
|
|
23
|
+
screen.getByRole('link', {
|
|
24
|
+
name: /department for environment food & rural affairs/i,
|
|
25
|
+
}),
|
|
26
|
+
).toBeInTheDocument();
|
|
27
|
+
|
|
28
|
+
expect(screen.getByRole('link', { name: /data services platform/i })).toBeInTheDocument();
|
|
29
|
+
expect(screen.getByRole('link', { name: /wetlands inventory/i })).toBeInTheDocument();
|
|
30
|
+
|
|
31
|
+
const loginLink = await screen.findByRole('link', { name: /login/i });
|
|
32
|
+
|
|
33
|
+
expect(loginLink).toBeInTheDocument();
|
|
34
|
+
expect(screen.getByRole('button', { name: /open menu/i })).toBeInTheDocument();
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { HeaderAuthClient } from './HeaderAuthClient';
|
|
2
|
+
import { render, screen } from '../../../test/renderers';
|
|
3
|
+
import type { Credentials } from '../../../types/auth';
|
|
4
|
+
|
|
5
|
+
const AUTH_CREDENTIALS: Credentials = {
|
|
6
|
+
token: 'token-123',
|
|
7
|
+
user: {
|
|
8
|
+
name: 'Jamie Taylor',
|
|
9
|
+
email: 'jamie.taylor@example.com',
|
|
10
|
+
groupInfoIds: ['group-1'],
|
|
11
|
+
},
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
describe('HeaderAuthClient', () => {
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
globalThis.history.pushState({}, '', '/header-test');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('renders guest state with login link', async () => {
|
|
20
|
+
render(<HeaderAuthClient hostname="https://example.com" credentials={null} />);
|
|
21
|
+
|
|
22
|
+
expect(screen.getByText(/welcome,/i)).toBeInTheDocument();
|
|
23
|
+
expect(screen.getByText(/guest/i)).toBeInTheDocument();
|
|
24
|
+
|
|
25
|
+
const loginLink = await screen.findByRole('link', { name: /login/i });
|
|
26
|
+
|
|
27
|
+
expect(loginLink).toHaveAttribute(
|
|
28
|
+
'href',
|
|
29
|
+
`https://example.com/login?redirect-uri=${encodeURIComponent(globalThis.location.href)}`,
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('renders authenticated state with logout link', async () => {
|
|
34
|
+
render(<HeaderAuthClient hostname="https://example.com" credentials={AUTH_CREDENTIALS} />);
|
|
35
|
+
|
|
36
|
+
expect(screen.getByText(/jamie.taylor@example.com/i)).toBeInTheDocument();
|
|
37
|
+
|
|
38
|
+
const logoutLink = await screen.findByRole('link', { name: /logout/i });
|
|
39
|
+
|
|
40
|
+
expect(logoutLink).toHaveAttribute(
|
|
41
|
+
'href',
|
|
42
|
+
`https://example.com/api/logout?redirect-uri=${encodeURIComponent(globalThis.location.href)}`,
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { HeaderNavClient } from './HeaderNavClient';
|
|
2
|
+
import { render, screen, userEvent } from '../../../test/renderers';
|
|
3
|
+
import type { NavLink } from '../../../types/navigation';
|
|
4
|
+
|
|
5
|
+
const NAV_LINKS: NavLink[] = [
|
|
6
|
+
{ label: 'Home', url: '/', isExternal: false },
|
|
7
|
+
{ label: 'API', url: '/api-docs', isExternal: false },
|
|
8
|
+
{ label: 'Support', url: 'https://example.com/support', isExternal: true },
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
describe('HeaderNavClient', () => {
|
|
12
|
+
it('renders both navigation regions', () => {
|
|
13
|
+
render(<HeaderNavClient navLinks={NAV_LINKS} />);
|
|
14
|
+
|
|
15
|
+
expect(
|
|
16
|
+
screen.getByRole('navigation', { name: /small screen navigation/i }),
|
|
17
|
+
).toBeInTheDocument();
|
|
18
|
+
expect(
|
|
19
|
+
screen.getByRole('navigation', { name: /large screen navigation/i }),
|
|
20
|
+
).toBeInTheDocument();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('shows menu items in the small-screen dropdown', async () => {
|
|
24
|
+
render(<HeaderNavClient navLinks={NAV_LINKS} />);
|
|
25
|
+
|
|
26
|
+
const user = userEvent.setup();
|
|
27
|
+
|
|
28
|
+
await user.click(screen.getByRole('button', { name: /open menu/i }));
|
|
29
|
+
|
|
30
|
+
expect(await screen.findByRole('menuitem', { name: /home/i })).toBeInTheDocument();
|
|
31
|
+
expect(await screen.findByRole('menuitem', { name: /api/i })).toBeInTheDocument();
|
|
32
|
+
expect(await screen.findByRole('menuitem', { name: /support/i })).toBeInTheDocument();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('renders large-screen links for navigation', () => {
|
|
36
|
+
render(<HeaderNavClient navLinks={NAV_LINKS} />);
|
|
37
|
+
|
|
38
|
+
const supportLink = screen.getByRole('link', { name: /support/i });
|
|
39
|
+
|
|
40
|
+
expect(supportLink).toHaveAttribute('href', 'https://example.com/support');
|
|
41
|
+
expect(screen.getByRole('link', { name: /home/i })).toBeInTheDocument();
|
|
42
|
+
expect(screen.getByRole('link', { name: /api/i })).toBeInTheDocument();
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -23,12 +23,15 @@ const InternalNavItem = ({ label, url, icon, ...props }: DropdownMenuItem<NavLin
|
|
|
23
23
|
</Link>
|
|
24
24
|
);
|
|
25
25
|
|
|
26
|
-
const NavItem = ({
|
|
26
|
+
const NavItem = ({ url, openInNewTab, ...props }: DropdownMenuItem<NavLink>) => {
|
|
27
|
+
const isExternal = /^https?:\/\//.test(url);
|
|
28
|
+
const newTabProps = openInNewTab ? { target: '_blank' as const, rel: 'noopener noreferrer' } : {};
|
|
29
|
+
|
|
27
30
|
if (isExternal) {
|
|
28
|
-
return <ExternalNavItem {...props} />;
|
|
31
|
+
return <ExternalNavItem url={url} {...newTabProps} {...props} />;
|
|
29
32
|
}
|
|
30
33
|
|
|
31
|
-
return <InternalNavItem {...props} />;
|
|
34
|
+
return <InternalNavItem url={url} {...newTabProps} {...props} />;
|
|
32
35
|
};
|
|
33
36
|
|
|
34
37
|
export const HeaderNavClient = ({ navLinks }: HeaderNavClientProps) => {
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { readJsonXLinesStream } from './stream';
|
|
2
|
+
|
|
3
|
+
const createMockReader = (chunks: Uint8Array[]) => {
|
|
4
|
+
let index = 0;
|
|
5
|
+
|
|
6
|
+
return {
|
|
7
|
+
read: vi.fn(async () => {
|
|
8
|
+
if (index < chunks.length) {
|
|
9
|
+
return { value: chunks[index++], done: false };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
return { value: undefined, done: true };
|
|
13
|
+
}),
|
|
14
|
+
releaseLock: vi.fn(),
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const createMockResponse = (chunks: string[]): Response => {
|
|
19
|
+
const encoder = new TextEncoder();
|
|
20
|
+
const uintChunks = chunks.map((c) => encoder.encode(c));
|
|
21
|
+
|
|
22
|
+
const mockReader = createMockReader(uintChunks);
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
status: 200,
|
|
26
|
+
body: {
|
|
27
|
+
getReader: () => mockReader,
|
|
28
|
+
},
|
|
29
|
+
} as unknown as Response;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
describe('readJsonXLinesStream', () => {
|
|
33
|
+
it('parses valid NDJSON chunks', async () => {
|
|
34
|
+
const response = createMockResponse([
|
|
35
|
+
'{"id":1,"name":"Alice"}\n{"id":2,',
|
|
36
|
+
'"name":"Bob"}\n{"id":3,"name":"Charlie"}\n',
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
const result = await readJsonXLinesStream(response);
|
|
40
|
+
|
|
41
|
+
expect(result).toEqual([
|
|
42
|
+
{ id: 1, name: 'Alice' },
|
|
43
|
+
{ id: 2, name: 'Bob' },
|
|
44
|
+
{ id: 3, name: 'Charlie' },
|
|
45
|
+
]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('ignores invalid JSON lines', async () => {
|
|
49
|
+
const response = createMockResponse([
|
|
50
|
+
'{"id":1,"name":"Alice"}\nINVALID_JSON\n{"id":2,"name":"Bob"}\n',
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
const result = await readJsonXLinesStream(response);
|
|
54
|
+
|
|
55
|
+
expect(result).toEqual([
|
|
56
|
+
{ id: 1, name: 'Alice' },
|
|
57
|
+
{ id: 2, name: 'Bob' },
|
|
58
|
+
]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('handles trailing line without newline', async () => {
|
|
62
|
+
const response = createMockResponse(['{"id":1,"name":"Alice"}\n{"id":2,"name":"Bob"}']);
|
|
63
|
+
|
|
64
|
+
const result = await readJsonXLinesStream(response);
|
|
65
|
+
|
|
66
|
+
expect(result).toEqual([
|
|
67
|
+
{ id: 1, name: 'Alice' },
|
|
68
|
+
{ id: 2, name: 'Bob' },
|
|
69
|
+
]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('returns empty array when no valid data', async () => {
|
|
73
|
+
const response = createMockResponse(['\n\n']);
|
|
74
|
+
|
|
75
|
+
const result = await readJsonXLinesStream(response);
|
|
76
|
+
|
|
77
|
+
expect(result).toEqual([]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('skips whitespace-only lines', async () => {
|
|
81
|
+
const response = createMockResponse([' \n{"id":1,"name":"Alice"}\n \n']);
|
|
82
|
+
|
|
83
|
+
const result = await readJsonXLinesStream(response);
|
|
84
|
+
|
|
85
|
+
expect(result).toEqual([{ id: 1, name: 'Alice' }]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('logs a warning for invalid JSON', async () => {
|
|
89
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
90
|
+
const response = createMockResponse(['INVALID\n']);
|
|
91
|
+
|
|
92
|
+
await readJsonXLinesStream(response);
|
|
93
|
+
|
|
94
|
+
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
95
|
+
|
|
96
|
+
warnSpy.mockRestore();
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import Map from 'ol/Map';
|
|
2
2
|
import View from 'ol/View';
|
|
3
|
-
import { describe, expect, it, vi } from 'vitest';
|
|
4
3
|
|
|
5
4
|
import { Geocoder } from './Geocoder';
|
|
6
5
|
import { render, screen, userEvent } from '../../test/renderers';
|
|
7
6
|
|
|
8
7
|
class MockView extends View {
|
|
9
|
-
animate = vi.fn((
|
|
8
|
+
animate = vi.fn((_, callback) => {
|
|
10
9
|
// Immediately execute callback if provided (simulating instant animation)
|
|
11
10
|
if (typeof callback === 'function') {
|
|
12
11
|
callback(true);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useId, useRef, useState } from 'react';
|
|
3
|
+
import { useEffect, useId, useRef, useState } from 'react';
|
|
4
4
|
|
|
5
5
|
import type Map from 'ol/Map';
|
|
6
6
|
|
|
@@ -129,6 +129,15 @@ export const Geocoder = ({
|
|
|
129
129
|
const [status, setStatus] = useState('');
|
|
130
130
|
const [isSearching, setIsSearching] = useState(false);
|
|
131
131
|
|
|
132
|
+
// Scroll the active result into view when navigating with arrow keys
|
|
133
|
+
useEffect(() => {
|
|
134
|
+
if (activeIndex >= 0) {
|
|
135
|
+
document
|
|
136
|
+
.getElementById(`${listboxId}-option-${activeIndex}`)
|
|
137
|
+
?.scrollIntoView({ block: 'nearest' });
|
|
138
|
+
}
|
|
139
|
+
}, [activeIndex, listboxId]);
|
|
140
|
+
|
|
132
141
|
const performSearch = async () => {
|
|
133
142
|
if (query.length < minChars) {
|
|
134
143
|
setStatus(`Enter at least ${minChars} characters`);
|
|
@@ -63,21 +63,6 @@ export const createOsOpenNamesSearch = (options: OsOpenNamesSearchOptions) => {
|
|
|
63
63
|
|
|
64
64
|
return features
|
|
65
65
|
.map((feature, index): GeocoderResult | null => {
|
|
66
|
-
if (feature.geometry.type !== 'Point') {
|
|
67
|
-
console.error('Geometry type is not Point');
|
|
68
|
-
|
|
69
|
-
return null;
|
|
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
66
|
const properties = feature.properties ?? {};
|
|
82
67
|
|
|
83
68
|
// Handle address which can be an object or string
|
|
@@ -88,35 +73,60 @@ export const createOsOpenNamesSearch = (options: OsOpenNamesSearchOptions) => {
|
|
|
88
73
|
|
|
89
74
|
label = [addr.name, addr.town, addr.country].filter(Boolean).join(', ');
|
|
90
75
|
} else {
|
|
91
|
-
label = properties.address ?? properties.name ??
|
|
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;
|
|
76
|
+
label = properties.address ?? properties.name ?? `result-${index}`;
|
|
101
77
|
}
|
|
102
78
|
|
|
103
79
|
const result: GeocoderResult = {
|
|
104
80
|
id: properties.id ?? `result-${index}`,
|
|
105
81
|
label,
|
|
106
82
|
group: properties.type ?? properties.localType,
|
|
107
|
-
center: [x, y],
|
|
108
83
|
// Assign zoom level based on type, fallback to 14
|
|
109
84
|
zoom: ZOOM_BY_TYPE[properties.type] ?? ZOOM_BY_TYPE[properties.localType] ?? 14,
|
|
110
85
|
};
|
|
111
86
|
|
|
112
|
-
//
|
|
87
|
+
// Prefer bbox — works for all geometry types (Point, Polygon, MultiPolygon)
|
|
113
88
|
if (feature.bbox && Array.isArray(feature.bbox) && feature.bbox.length === 4) {
|
|
89
|
+
const [minLon, minLat, maxLon, maxLat] = feature.bbox;
|
|
90
|
+
|
|
114
91
|
result.extent = transformExtent(feature.bbox, EPSG_4326, EPSG_3857) as [
|
|
115
92
|
number,
|
|
116
93
|
number,
|
|
117
94
|
number,
|
|
118
95
|
number,
|
|
119
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;
|
|
120
130
|
}
|
|
121
131
|
|
|
122
132
|
return result;
|