@@ -59,6 +59,25 @@ export const GEOLENS_PLUGIN_ID = "maplibre-gl-geolens";
5959 */
6060export const GEOLENS_FEATURES_SOURCE_KIND = "geolens-features" ;
6161
62+ /** One entry in the sample-server dropdown. */
63+ export interface GeoLensSampleServer {
64+ /** Shown in the dropdown; the URL is the title text. */
65+ label : string ;
66+ baseUrl : string ;
67+ }
68+
69+ /**
70+ * Public GeoLens deployments offered in the panel, so the plugin can be tried
71+ * without hunting for a server URL. Both are open catalogs that need no key.
72+ *
73+ * The labels are the deployments' own names rather than translatable strings:
74+ * they identify a specific server, the way a bookmark does.
75+ */
76+ export const GEOLENS_SAMPLE_SERVERS : readonly GeoLensSampleServer [ ] = [
77+ { label : "GeoLibre datasets" , baseUrl : "https://datasets.geolibre.app" } ,
78+ { label : "GeoLens demo" , baseUrl : "https://demo.getgeolens.com" } ,
79+ ] ;
80+
6281/** Number of datasets requested per catalog search. */
6382const SEARCH_LIMIT = 50 ;
6483/** Default maximum number of editable GeoJSON features loaded per dataset. */
@@ -80,6 +99,8 @@ const TOKEN_REFRESH_MAX_RETRY_SECONDS = 300;
8099
81100export interface GeoLensLabels {
82101 hint : string ;
102+ sampleServer : string ;
103+ sampleServerTitle : string ;
83104 baseUrlPlaceholder : string ;
84105 apiKeyPlaceholder : string ;
85106 connect : string ;
@@ -89,6 +110,7 @@ export interface GeoLensLabels {
89110 searching : string ;
90111 noResults : string ;
91112 loadError : ( message : string ) => string ;
113+ blockedError : ( host : string ) => string ;
92114 showing : ( count : number ) => string ;
93115 vectorBadge : string ;
94116 rasterBadge : string ;
@@ -125,6 +147,8 @@ export interface GeoLensLabels {
125147
126148export const DEFAULT_GEOLENS_LABELS : GeoLensLabels = {
127149 hint : "Connect to a GeoLens server to browse and add its catalog datasets." ,
150+ sampleServer : "Sample server…" ,
151+ sampleServerTitle : "Connect to a public GeoLens deployment" ,
128152 baseUrlPlaceholder : "GeoLens URL, e.g. https://datasets.geolibre.app" ,
129153 apiKeyPlaceholder : "API key (optional, for private data)" ,
130154 connect : "Connect" ,
@@ -134,6 +158,9 @@ export const DEFAULT_GEOLENS_LABELS: GeoLensLabels = {
134158 searching : "Searching…" ,
135159 noResults : "No matching datasets." ,
136160 loadError : ( message ) => `Could not reach GeoLens: ${ message } ` ,
161+ blockedError : ( host ) =>
162+ `Could not reach ${ host } . The server refused a cross-origin request from ` +
163+ `GeoLibre — its administrator has to allow this origin (CORS).` ,
137164 showing : ( count ) => `${ count } dataset${ count === 1 ? "" : "s" } .` ,
138165 vectorBadge : "vector" ,
139166 rasterBadge : "raster" ,
@@ -299,6 +326,32 @@ function messageOf(error: unknown): string {
299326 return error instanceof Error ? error . message : String ( error ) ;
300327}
301328
329+ /**
330+ * Whether a failure was the request never reaching the server at all.
331+ *
332+ * `fetch` rejects with a bare `TypeError` ("Failed to fetch") when the browser
333+ * blocks the request — a CORS policy, a DNS failure, being offline — and gives
334+ * the page no detail beyond that, by design. Every failure this module raises
335+ * itself is a plain `Error`, so the constructor is a reliable discriminator.
336+ *
337+ * It matters because CORS is the likeliest cause and the least guessable: a
338+ * GeoLens deployment that serves its catalog happily to `curl` is unreachable
339+ * from a browser unless it sends `Access-Control-Allow-Origin` for the app's
340+ * origin, and `demo.getgeolens.com` currently sends none at all.
341+ */
342+ function isTransportFailure ( error : unknown ) : boolean {
343+ return error instanceof TypeError ;
344+ }
345+
346+ /** The host of a base URL, for an error message; falls back to the whole URL. */
347+ function hostOf ( baseUrl : string ) : string {
348+ try {
349+ return new URL ( baseUrl ) . host ;
350+ } catch {
351+ return baseUrl ;
352+ }
353+ }
354+
302355// ---------------------------------------------------------------------------
303356// Layer creation + tile-token lifecycle.
304357// ---------------------------------------------------------------------------
@@ -920,6 +973,22 @@ function buildPanel(
920973 featureLimitLabel . append ( featureLimitInput ) ;
921974 settingsPanel . append ( featureLimitLabel , el ( "div" , CSS . hint , labels . featureLimitHelp ) ) ;
922975
976+ // A shortcut to the public deployments. It fills the URL field and connects,
977+ // rather than only filling it: picking a sample server is the whole intent, so
978+ // leaving the user to press Connect afterwards would just be a second click.
979+ const sampleSelect = el ( "select" , CSS . input ) as HTMLSelectElement ;
980+ sampleSelect . title = labels . sampleServerTitle ;
981+ sampleSelect . setAttribute ( "aria-label" , labels . sampleServerTitle ) ;
982+ const samplePlaceholder = el ( "option" , "" , labels . sampleServer ) ;
983+ samplePlaceholder . value = "" ;
984+ sampleSelect . append ( samplePlaceholder ) ;
985+ for ( const server of GEOLENS_SAMPLE_SERVERS ) {
986+ const option = el ( "option" , "" , server . label ) ;
987+ option . value = server . baseUrl ;
988+ option . title = server . baseUrl ;
989+ sampleSelect . append ( option ) ;
990+ }
991+
923992 const baseUrlInput = el ( "input" , CSS . input ) as HTMLInputElement ;
924993 baseUrlInput . placeholder = labels . baseUrlPlaceholder ;
925994 baseUrlInput . autocomplete = "off" ;
@@ -953,6 +1022,7 @@ function buildPanel(
9531022 panel . append (
9541023 hintRow ,
9551024 settingsPanel ,
1025+ sampleSelect ,
9561026 baseUrlInput ,
9571027 apiKeyInput ,
9581028 connectRow ,
@@ -999,7 +1069,11 @@ function buildPanel(
9991069 } catch ( error ) {
10001070 if ( isAbort ( error ) || generation !== state . generation ) return false ;
10011071 status . textContent = "" ;
1002- showError ( labels . loadError ( messageOf ( error ) ) ) ;
1072+ showError (
1073+ isTransportFailure ( error )
1074+ ? labels . blockedError ( hostOf ( state . client ?. baseUrl ?? "" ) )
1075+ : labels . loadError ( messageOf ( error ) ) ,
1076+ ) ;
10031077 return false ;
10041078 }
10051079 } ;
@@ -1284,6 +1358,15 @@ function buildPanel(
12841358 } ;
12851359
12861360 connectButton . addEventListener ( "click" , ( ) => void connect ( ) ) ;
1361+ sampleSelect . addEventListener ( "change" , ( ) => {
1362+ const baseUrl = sampleSelect . value ;
1363+ // Reset to the placeholder: the URL field is the source of truth (the user
1364+ // can edit it afterwards), so a stuck selection would soon be a lie.
1365+ sampleSelect . value = "" ;
1366+ if ( ! baseUrl ) return ;
1367+ baseUrlInput . value = baseUrl ;
1368+ void connect ( ) ;
1369+ } ) ;
12871370 settingsButton . addEventListener ( "click" , ( ) => {
12881371 const open = settingsPanel . style . display !== "flex" ;
12891372 settingsPanel . style . display = open ? "flex" : "none" ;
0 commit comments