@reuters-graphics/graphics-components 3.3.4 → 3.4.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/dist/components/Geocoder/Geocoder.svelte +303 -0
- package/dist/components/Geocoder/Geocoder.svelte.d.ts +17 -0
- package/dist/components/Geocoder/geocode.d.ts +50 -0
- package/dist/components/Geocoder/geocode.js +34 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/package.json +1 -1
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
<!-- @component `Geocoder` [Read the docs.](https://reuters-graphics.github.io/graphics-components/?path=/docs/components-controls-geocoder--docs) -->
|
|
2
|
+
<script lang="ts">
|
|
3
|
+
import { onDestroy } from 'svelte';
|
|
4
|
+
import { geocode, type GeocodeFeature, type GeocodeOptions } from './geocode';
|
|
5
|
+
import MagnifyingGlass from '../SearchInput/components/MagnifyingGlass.svelte';
|
|
6
|
+
import X from '../SearchInput/components/X.svelte';
|
|
7
|
+
|
|
8
|
+
interface Props extends Omit<GeocodeOptions, 'accessToken'> {
|
|
9
|
+
/** Mapbox public access token. */
|
|
10
|
+
accessToken: string;
|
|
11
|
+
/** Placeholder text shown in the search input. */
|
|
12
|
+
searchPlaceholder?: string;
|
|
13
|
+
/** Callback fired when a location is selected from the results. */
|
|
14
|
+
onselect?: (location: { lng: number; lat: number; name: string }) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
let {
|
|
18
|
+
accessToken,
|
|
19
|
+
searchPlaceholder = 'Search for a location',
|
|
20
|
+
onselect,
|
|
21
|
+
autocomplete = true,
|
|
22
|
+
bbox,
|
|
23
|
+
country,
|
|
24
|
+
language = ['en'],
|
|
25
|
+
limit = 5,
|
|
26
|
+
proximity,
|
|
27
|
+
types,
|
|
28
|
+
worldview,
|
|
29
|
+
permanent,
|
|
30
|
+
entrances,
|
|
31
|
+
}: Props = $props();
|
|
32
|
+
|
|
33
|
+
let query = $state('');
|
|
34
|
+
let suggestions: GeocodeFeature[] = $state([]);
|
|
35
|
+
let selectedIndex = $state(-1);
|
|
36
|
+
let focused = $state(false);
|
|
37
|
+
|
|
38
|
+
let showDropdown = $derived(suggestions.length > 0 && focused);
|
|
39
|
+
|
|
40
|
+
const instanceId = Math.random().toString(36).slice(2, 9);
|
|
41
|
+
const listboxId = `geocoder-listbox-${instanceId}`;
|
|
42
|
+
|
|
43
|
+
let inputEl: HTMLInputElement;
|
|
44
|
+
let debounceTimer: ReturnType<typeof setTimeout>;
|
|
45
|
+
let abortController: AbortController | null = null;
|
|
46
|
+
|
|
47
|
+
function handleInput() {
|
|
48
|
+
selectedIndex = -1;
|
|
49
|
+
clearTimeout(debounceTimer);
|
|
50
|
+
abortController?.abort();
|
|
51
|
+
|
|
52
|
+
if (query.length < 2) {
|
|
53
|
+
suggestions = [];
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
debounceTimer = setTimeout(async () => {
|
|
58
|
+
abortController = new AbortController();
|
|
59
|
+
try {
|
|
60
|
+
suggestions = await geocode(
|
|
61
|
+
query,
|
|
62
|
+
{
|
|
63
|
+
accessToken,
|
|
64
|
+
autocomplete,
|
|
65
|
+
bbox,
|
|
66
|
+
country,
|
|
67
|
+
language,
|
|
68
|
+
limit,
|
|
69
|
+
proximity,
|
|
70
|
+
types,
|
|
71
|
+
worldview,
|
|
72
|
+
permanent,
|
|
73
|
+
entrances,
|
|
74
|
+
},
|
|
75
|
+
abortController.signal
|
|
76
|
+
);
|
|
77
|
+
} catch (e) {
|
|
78
|
+
if (e instanceof DOMException && e.name === 'AbortError') return;
|
|
79
|
+
console.error('Geocoder error:', e);
|
|
80
|
+
}
|
|
81
|
+
}, 300);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
onDestroy(() => {
|
|
85
|
+
clearTimeout(debounceTimer);
|
|
86
|
+
abortController?.abort();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
let active = $derived(query !== '');
|
|
90
|
+
let statusMessage = $derived(
|
|
91
|
+
showDropdown ?
|
|
92
|
+
`${suggestions.length} result${suggestions.length === 1 ? '' : 's'} available`
|
|
93
|
+
: ''
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
function selectSuggestion(feature: GeocodeFeature) {
|
|
97
|
+
const { longitude: lng, latitude: lat } = feature.properties.coordinates;
|
|
98
|
+
query = feature.properties.name;
|
|
99
|
+
suggestions = [];
|
|
100
|
+
onselect?.({ lng, lat, name: feature.properties.name });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function clear() {
|
|
104
|
+
query = '';
|
|
105
|
+
suggestions = [];
|
|
106
|
+
inputEl?.focus();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function handleKeydown(e: KeyboardEvent) {
|
|
110
|
+
if (!showDropdown) return;
|
|
111
|
+
|
|
112
|
+
if (e.key === 'ArrowDown') {
|
|
113
|
+
e.preventDefault();
|
|
114
|
+
selectedIndex = (selectedIndex + 1) % suggestions.length;
|
|
115
|
+
} else if (e.key === 'ArrowUp') {
|
|
116
|
+
e.preventDefault();
|
|
117
|
+
selectedIndex =
|
|
118
|
+
selectedIndex <= 0 ? suggestions.length - 1 : selectedIndex - 1;
|
|
119
|
+
} else if (e.key === 'Enter') {
|
|
120
|
+
e.preventDefault();
|
|
121
|
+
if (selectedIndex >= 0) selectSuggestion(suggestions[selectedIndex]);
|
|
122
|
+
} else if (e.key === 'Escape') {
|
|
123
|
+
e.preventDefault();
|
|
124
|
+
suggestions = [];
|
|
125
|
+
selectedIndex = -1;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
</script>
|
|
129
|
+
|
|
130
|
+
<div class="geocoder-input-wrapper">
|
|
131
|
+
<div class="geocoder-icon">
|
|
132
|
+
<MagnifyingGlass />
|
|
133
|
+
</div>
|
|
134
|
+
<input
|
|
135
|
+
bind:this={inputEl}
|
|
136
|
+
bind:value={query}
|
|
137
|
+
oninput={handleInput}
|
|
138
|
+
onkeydown={handleKeydown}
|
|
139
|
+
onfocus={() => (focused = true)}
|
|
140
|
+
onblur={() => (focused = false)}
|
|
141
|
+
type="text"
|
|
142
|
+
autocapitalize="words"
|
|
143
|
+
autocomplete="off"
|
|
144
|
+
enterkeyhint="search"
|
|
145
|
+
spellcheck="false"
|
|
146
|
+
role="combobox"
|
|
147
|
+
aria-expanded={showDropdown}
|
|
148
|
+
aria-label={searchPlaceholder}
|
|
149
|
+
aria-controls={showDropdown ? listboxId : undefined}
|
|
150
|
+
aria-activedescendant={selectedIndex >= 0 ?
|
|
151
|
+
`${listboxId}-option-${selectedIndex}`
|
|
152
|
+
: undefined}
|
|
153
|
+
aria-autocomplete="list"
|
|
154
|
+
placeholder={searchPlaceholder}
|
|
155
|
+
class="geocoder-input"
|
|
156
|
+
/>
|
|
157
|
+
{#if active}
|
|
158
|
+
<button
|
|
159
|
+
class="geocoder-clear"
|
|
160
|
+
type="button"
|
|
161
|
+
aria-label="Clear search"
|
|
162
|
+
onmousedown={(e) => {
|
|
163
|
+
e.preventDefault();
|
|
164
|
+
clear();
|
|
165
|
+
}}
|
|
166
|
+
>
|
|
167
|
+
<X />
|
|
168
|
+
</button>
|
|
169
|
+
{/if}
|
|
170
|
+
<div class="sr-only" role="status" aria-live="polite" aria-atomic="true">
|
|
171
|
+
{statusMessage}
|
|
172
|
+
</div>
|
|
173
|
+
{#if showDropdown}
|
|
174
|
+
<ul class="geocoder-results" role="listbox" id={listboxId}>
|
|
175
|
+
{#each suggestions as suggestion, i}
|
|
176
|
+
<li
|
|
177
|
+
id="{listboxId}-option-{i}"
|
|
178
|
+
role="option"
|
|
179
|
+
aria-selected={i === selectedIndex}
|
|
180
|
+
class:active={i === selectedIndex}
|
|
181
|
+
onmousedown={(e) => {
|
|
182
|
+
e.preventDefault();
|
|
183
|
+
selectSuggestion(suggestion);
|
|
184
|
+
}}
|
|
185
|
+
onmouseenter={() => (selectedIndex = i)}
|
|
186
|
+
>
|
|
187
|
+
{suggestion.properties.full_address || suggestion.properties.name}
|
|
188
|
+
</li>
|
|
189
|
+
{/each}
|
|
190
|
+
</ul>
|
|
191
|
+
{/if}
|
|
192
|
+
</div>
|
|
193
|
+
|
|
194
|
+
<style>/* Generated from
|
|
195
|
+
https://utopia.fyi/space/calculator/?c=320,18,1.125,1280,21,1.25,7,3,&s=0.75|0.5|0.25,1.5|2|3|4|6,s-l&g=s,l,xl,12
|
|
196
|
+
*/
|
|
197
|
+
/* Generated from
|
|
198
|
+
https://utopia.fyi/space/calculator/?c=320,18,1.125,1280,21,1.25,7,3,&s=0.75|0.5|0.25,1.5|2|3|4|6,s-l&g=s,l,xl,12
|
|
199
|
+
*/
|
|
200
|
+
/* Scales by 1.125 */
|
|
201
|
+
.geocoder-input-wrapper {
|
|
202
|
+
position: relative;
|
|
203
|
+
width: 100%;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
.geocoder-icon {
|
|
207
|
+
position: absolute;
|
|
208
|
+
top: 50%;
|
|
209
|
+
left: 12px;
|
|
210
|
+
transform: translateY(-50%);
|
|
211
|
+
width: 22px;
|
|
212
|
+
height: 22px;
|
|
213
|
+
pointer-events: none;
|
|
214
|
+
display: flex;
|
|
215
|
+
align-items: center;
|
|
216
|
+
justify-content: center;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
.geocoder-clear {
|
|
220
|
+
position: absolute;
|
|
221
|
+
top: 50%;
|
|
222
|
+
right: 10px;
|
|
223
|
+
transform: translateY(-50%);
|
|
224
|
+
width: 22px;
|
|
225
|
+
height: 22px;
|
|
226
|
+
padding: 0;
|
|
227
|
+
border: none;
|
|
228
|
+
background: none;
|
|
229
|
+
cursor: pointer;
|
|
230
|
+
display: flex;
|
|
231
|
+
align-items: center;
|
|
232
|
+
justify-content: center;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
.geocoder-input {
|
|
236
|
+
font-family: var(--theme-font-family-sans-serif);
|
|
237
|
+
font-size: var(--theme-font-size-sm);
|
|
238
|
+
width: 100%;
|
|
239
|
+
height: 46px;
|
|
240
|
+
line-height: 22px;
|
|
241
|
+
padding: 12px 36px 12px 42px;
|
|
242
|
+
border: 1px solid var(--theme-colour-brand-rules);
|
|
243
|
+
border-radius: 0.25rem;
|
|
244
|
+
background: var(--theme-colour-background);
|
|
245
|
+
box-sizing: border-box;
|
|
246
|
+
}
|
|
247
|
+
.geocoder-input::placeholder {
|
|
248
|
+
color: var(--theme-colour-text-secondary);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
.geocoder-results {
|
|
252
|
+
position: absolute;
|
|
253
|
+
top: 100%;
|
|
254
|
+
left: 0;
|
|
255
|
+
right: 0;
|
|
256
|
+
margin: 4px 0 0;
|
|
257
|
+
padding: 0;
|
|
258
|
+
list-style: none;
|
|
259
|
+
background: var(--theme-colour-background);
|
|
260
|
+
border-radius: 0.25rem;
|
|
261
|
+
box-shadow: 0 2px 6px var(--theme-colour-brand-shadow);
|
|
262
|
+
z-index: 10000;
|
|
263
|
+
overflow: hidden;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
.geocoder-results li {
|
|
267
|
+
padding: 10px 14px;
|
|
268
|
+
cursor: pointer;
|
|
269
|
+
font-family: var(--theme-font-family-sans-serif);
|
|
270
|
+
font-size: var(--theme-font-size-xs);
|
|
271
|
+
white-space: nowrap;
|
|
272
|
+
overflow: hidden;
|
|
273
|
+
text-overflow: ellipsis;
|
|
274
|
+
}
|
|
275
|
+
.geocoder-results li.active {
|
|
276
|
+
background-color: var(--tr-hover-background-grey);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
.sr-only {
|
|
280
|
+
position: absolute;
|
|
281
|
+
width: 1px;
|
|
282
|
+
height: 1px;
|
|
283
|
+
padding: 0;
|
|
284
|
+
margin: -1px;
|
|
285
|
+
overflow: hidden;
|
|
286
|
+
clip: rect(0, 0, 0, 0);
|
|
287
|
+
white-space: nowrap;
|
|
288
|
+
border: 0;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
@media (max-width: 767px) {
|
|
292
|
+
.geocoder-icon {
|
|
293
|
+
left: 10px;
|
|
294
|
+
width: 20px;
|
|
295
|
+
height: 20px;
|
|
296
|
+
}
|
|
297
|
+
.geocoder-input {
|
|
298
|
+
height: 42px;
|
|
299
|
+
font-size: var(--theme-font-size-xs);
|
|
300
|
+
padding: 10px 12px 10px 38px;
|
|
301
|
+
border-radius: 0.25rem;
|
|
302
|
+
}
|
|
303
|
+
}</style>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type GeocodeOptions } from './geocode';
|
|
2
|
+
interface Props extends Omit<GeocodeOptions, 'accessToken'> {
|
|
3
|
+
/** Mapbox public access token. */
|
|
4
|
+
accessToken: string;
|
|
5
|
+
/** Placeholder text shown in the search input. */
|
|
6
|
+
searchPlaceholder?: string;
|
|
7
|
+
/** Callback fired when a location is selected from the results. */
|
|
8
|
+
onselect?: (location: {
|
|
9
|
+
lng: number;
|
|
10
|
+
lat: number;
|
|
11
|
+
name: string;
|
|
12
|
+
}) => void;
|
|
13
|
+
}
|
|
14
|
+
/** `Geocoder` [Read the docs.](https://reuters-graphics.github.io/graphics-components/?path=/docs/components-controls-geocoder--docs) */
|
|
15
|
+
declare const Geocoder: import("svelte").Component<Props, {}, "">;
|
|
16
|
+
type Geocoder = ReturnType<typeof Geocoder>;
|
|
17
|
+
export default Geocoder;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export type GeocodeFeatureType = 'country' | 'region' | 'postcode' | 'district' | 'place' | 'locality' | 'neighborhood' | 'street' | 'address';
|
|
2
|
+
export interface GeocodeOptions {
|
|
3
|
+
/** Mapbox public access token. */
|
|
4
|
+
accessToken: string;
|
|
5
|
+
/** Return partial prefix matches (true) or exact matches only (false). Defaults to true. */
|
|
6
|
+
autocomplete?: boolean;
|
|
7
|
+
/** Limit results to a bounding box: [minLon, minLat, maxLon, maxLat]. Cannot cross the 180th meridian. */
|
|
8
|
+
bbox?: [number, number, number, number];
|
|
9
|
+
/** Filter results to one or more countries using ISO 3166-1 alpha-2 codes. */
|
|
10
|
+
country?: string[];
|
|
11
|
+
/** IETF language tags for the response. Also influences result scoring. Max 20. */
|
|
12
|
+
language?: string[];
|
|
13
|
+
/** Maximum number of results to return (1–10). Defaults to 5. */
|
|
14
|
+
limit?: number;
|
|
15
|
+
/** Bias results toward a location: [lon, lat] coordinates or 'ip' to use the request IP. */
|
|
16
|
+
proximity?: [number, number] | 'ip';
|
|
17
|
+
/** Filter results by feature type. */
|
|
18
|
+
types?: GeocodeFeatureType[];
|
|
19
|
+
/** Geopolitical worldview for boundary representation (e.g. 'us', 'cn', 'in'). Defaults to 'us'. */
|
|
20
|
+
worldview?: string;
|
|
21
|
+
/** Set to true if results will be stored/cached permanently. Defaults to false. */
|
|
22
|
+
permanent?: boolean;
|
|
23
|
+
/** Return building entrance data when available (public preview). Defaults to false. */
|
|
24
|
+
entrances?: boolean;
|
|
25
|
+
}
|
|
26
|
+
export interface GeocodeFeature {
|
|
27
|
+
type: 'Feature';
|
|
28
|
+
properties: {
|
|
29
|
+
mapbox_id: string;
|
|
30
|
+
feature_type: GeocodeFeatureType;
|
|
31
|
+
name: string;
|
|
32
|
+
name_preferred?: string;
|
|
33
|
+
place_formatted?: string;
|
|
34
|
+
full_address?: string;
|
|
35
|
+
coordinates: {
|
|
36
|
+
longitude: number;
|
|
37
|
+
latitude: number;
|
|
38
|
+
};
|
|
39
|
+
context: Record<string, {
|
|
40
|
+
mapbox_id: string;
|
|
41
|
+
name: string;
|
|
42
|
+
[key: string]: unknown;
|
|
43
|
+
}>;
|
|
44
|
+
};
|
|
45
|
+
geometry: {
|
|
46
|
+
type: 'Point';
|
|
47
|
+
coordinates: [number, number];
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
export declare function geocode(query: string, options: GeocodeOptions, signal?: AbortSignal): Promise<GeocodeFeature[]>;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
const BASE_URL = 'https://api.mapbox.com/search/geocode/v6/forward';
|
|
2
|
+
export async function geocode(query, options, signal) {
|
|
3
|
+
const params = new URLSearchParams({
|
|
4
|
+
q: query,
|
|
5
|
+
access_token: options.accessToken,
|
|
6
|
+
});
|
|
7
|
+
if (options.autocomplete !== undefined)
|
|
8
|
+
params.set('autocomplete', String(options.autocomplete));
|
|
9
|
+
if (options.bbox)
|
|
10
|
+
params.set('bbox', options.bbox.join(','));
|
|
11
|
+
if (options.country)
|
|
12
|
+
params.set('country', options.country.join(','));
|
|
13
|
+
if (options.language)
|
|
14
|
+
params.set('language', options.language.join(','));
|
|
15
|
+
if (options.limit !== undefined)
|
|
16
|
+
params.set('limit', String(options.limit));
|
|
17
|
+
if (options.proximity)
|
|
18
|
+
params.set('proximity', Array.isArray(options.proximity) ?
|
|
19
|
+
options.proximity.join(',')
|
|
20
|
+
: options.proximity);
|
|
21
|
+
if (options.types)
|
|
22
|
+
params.set('types', options.types.join(','));
|
|
23
|
+
if (options.worldview)
|
|
24
|
+
params.set('worldview', options.worldview);
|
|
25
|
+
if (options.permanent !== undefined)
|
|
26
|
+
params.set('permanent', String(options.permanent));
|
|
27
|
+
if (options.entrances !== undefined)
|
|
28
|
+
params.set('entrances', String(options.entrances));
|
|
29
|
+
const res = await fetch(`${BASE_URL}?${params}`, { signal });
|
|
30
|
+
if (!res.ok)
|
|
31
|
+
throw new Error(`Geocode request failed: ${res.status}`);
|
|
32
|
+
const data = await res.json();
|
|
33
|
+
return data.features ?? [];
|
|
34
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -16,6 +16,8 @@ export { default as DocumentCloud } from './components/DocumentCloud/DocumentClo
|
|
|
16
16
|
export { default as EmbedPreviewerLink } from './components/EmbedPreviewerLink/EmbedPreviewerLink.svelte';
|
|
17
17
|
export { default as FeaturePhoto } from './components/FeaturePhoto/FeaturePhoto.svelte';
|
|
18
18
|
export { default as Framer } from './components/Framer/Framer.svelte';
|
|
19
|
+
export { default as Geocoder } from './components/Geocoder/Geocoder.svelte';
|
|
20
|
+
export { geocode } from './components/Geocoder/geocode';
|
|
19
21
|
export { default as GraphicBlock } from './components/GraphicBlock/GraphicBlock.svelte';
|
|
20
22
|
export { default as Headline } from './components/Headline/Headline.svelte';
|
|
21
23
|
export { default as Headpile } from './components/Headpile/Headpile.svelte';
|
|
@@ -56,3 +58,4 @@ export { default as ToolsHeader } from './components/ToolsHeader/ToolsHeader.sve
|
|
|
56
58
|
export { default as Video } from './components/Video/Video.svelte';
|
|
57
59
|
export { default as Visible } from './components/Visible/Visible.svelte';
|
|
58
60
|
export type { ContainerWidth, HeadlineSize, ScrollerVideoInstance, } from './components/@types/global';
|
|
61
|
+
export type { GeocodeOptions, GeocodeFeature, GeocodeFeatureType, } from './components/Geocoder/geocode';
|
package/dist/index.js
CHANGED
|
@@ -19,6 +19,8 @@ export { default as DocumentCloud } from './components/DocumentCloud/DocumentClo
|
|
|
19
19
|
export { default as EmbedPreviewerLink } from './components/EmbedPreviewerLink/EmbedPreviewerLink.svelte';
|
|
20
20
|
export { default as FeaturePhoto } from './components/FeaturePhoto/FeaturePhoto.svelte';
|
|
21
21
|
export { default as Framer } from './components/Framer/Framer.svelte';
|
|
22
|
+
export { default as Geocoder } from './components/Geocoder/Geocoder.svelte';
|
|
23
|
+
export { geocode } from './components/Geocoder/geocode';
|
|
22
24
|
export { default as GraphicBlock } from './components/GraphicBlock/GraphicBlock.svelte';
|
|
23
25
|
export { default as Headline } from './components/Headline/Headline.svelte';
|
|
24
26
|
export { default as Headpile } from './components/Headpile/Headpile.svelte';
|