@@ -63,6 +63,7 @@ import {
6363export type AddDataKind =
6464 | "xyz"
6565 | "wms"
66+ | "wfs"
6667 | "wmts"
6768 | "vector"
6869 | "raster"
@@ -87,6 +88,7 @@ type SelectedRasterFile = {
8788const KIND_LABELS : Record < AddDataKind , string > = {
8889 xyz : "Add XYZ Layer" ,
8990 wms : "Add WMS Layer" ,
91+ wfs : "Add WFS Layer" ,
9092 wmts : "Add WMTS Layer" ,
9193 vector : "Add Vector Layer" ,
9294 raster : "Add Raster Layer" ,
@@ -113,6 +115,8 @@ const DEFAULT_XYZ_URL =
113115const DEFAULT_WMS_ENDPOINT =
114116 "https://imagery.nationalmap.gov/arcgis/services/USGSNAIPImagery/ImageServer/WMSServer" ;
115117const DEFAULT_WMS_LAYERS = "USGSNAIPImagery:FalseColorComposite" ;
118+ const DEFAULT_WFS_ENDPOINT = "https://ahocevar.com/geoserver/wfs" ;
119+ const DEFAULT_WFS_TYPE_NAME = "topp:states" ;
116120const DEFAULT_WMTS_URL =
117121 "https://wayback.maptiles.arcgis.com/arcgis/rest/services/World_Imagery/MapServer/tile/119/{z}/{y}/{x}" ;
118122const DEFAULT_RASTER_URL =
@@ -127,6 +131,7 @@ const DEFAULT_ARCGIS_URLS: Record<ArcGISLayerType, string> = {
127131 feature : DEFAULT_ARCGIS_FEATURE_URL ,
128132 "vector-tile" : DEFAULT_ARCGIS_VECTOR_TILE_URL ,
129133} ;
134+ const WFS_PROXY_PATH = "/__geolibre_wfs_proxy" ;
130135const POSTGRES_CONNECTIONS_STORAGE_KEY =
131136 "geolibre.postgres.connectionStrings" ;
132137const MAX_SAVED_POSTGRES_CONNECTIONS = 10 ;
@@ -203,6 +208,31 @@ function createWmsTileUrl(options: {
203208 ] ) ;
204209}
205210
211+ function createWfsGetFeatureUrl ( options : {
212+ endpoint : string ;
213+ typeName : string ;
214+ version : string ;
215+ outputFormat : string ;
216+ srsName : string ;
217+ maxFeatures ?: string ;
218+ } ) : string {
219+ const isWfs2 = options . version . startsWith ( "2" ) ;
220+ const params : Array < [ string , string ] > = [
221+ [ "service" , "WFS" ] ,
222+ [ "request" , "GetFeature" ] ,
223+ [ "version" , options . version ] ,
224+ [ isWfs2 ? "typeNames" : "typeName" , options . typeName ] ,
225+ [ "outputFormat" , options . outputFormat ] ,
226+ ] ;
227+
228+ if ( options . srsName ) params . push ( [ "srsName" , options . srsName ] ) ;
229+ if ( options . maxFeatures ) {
230+ params . push ( [ isWfs2 ? "count" : "maxFeatures" , options . maxFeatures ] ) ;
231+ }
232+
233+ return appendQuery ( options . endpoint , params ) ;
234+ }
235+
206236function parseRequiredNumber ( value : string , label : string ) : number {
207237 const parsed = Number ( value ) ;
208238 if ( ! Number . isFinite ( parsed ) ) {
@@ -270,12 +300,53 @@ function errorMessage(error: unknown, fallback: string): string {
270300 return fallback ;
271301}
272302
303+ function isViteDevServer ( ) : boolean {
304+ return Boolean (
305+ (
306+ import . meta as ImportMeta & {
307+ env ?: { DEV ?: boolean } ;
308+ }
309+ ) . env ?. DEV ,
310+ ) ;
311+ }
312+
313+ function proxyWfsRequestUrl ( url : string ) : string {
314+ return isViteDevServer ( )
315+ ? `${ WFS_PROXY_PATH } ?url=${ encodeURIComponent ( url ) } `
316+ : url ;
317+ }
318+
319+ function parseGeoJsonFeatureCollection ( value : unknown ) : FeatureCollection {
320+ if (
321+ ! value ||
322+ typeof value !== "object" ||
323+ ! ( "type" in value ) ||
324+ value . type !== "FeatureCollection" ||
325+ ! ( "features" in value ) ||
326+ ! Array . isArray ( value . features )
327+ ) {
328+ throw new Error ( "The response is not a GeoJSON FeatureCollection." ) ;
329+ }
330+
331+ return value as FeatureCollection ;
332+ }
333+
273334async function fetchGeoJson ( url : string ) : Promise < FeatureCollection > {
274335 const response = await fetch ( url ) ;
336+ const text = await response . text ( ) ;
275337 if ( ! response . ok ) {
276338 throw new Error ( `Request failed with status ${ response . status } ` ) ;
277339 }
278- return ( await response . json ( ) ) as FeatureCollection ;
340+ try {
341+ return parseGeoJsonFeatureCollection ( JSON . parse ( text ) ) ;
342+ } catch ( error ) {
343+ if ( / ^ \s * < / . test ( text ) ) {
344+ throw new Error (
345+ "The service returned XML instead of GeoJSON. Check the layer name and output format." ,
346+ ) ;
347+ }
348+ throw error ;
349+ }
279350}
280351
281352export function AddDataDialog ( {
@@ -303,6 +374,12 @@ export function AddDataDialog({
303374 const [ wmsFormat , setWmsFormat ] = useState ( "image/png" ) ;
304375 const [ wmsTransparent , setWmsTransparent ] = useState ( true ) ;
305376 const [ wmsTileSize , setWmsTileSize ] = useState ( "256" ) ;
377+ const [ wfsEndpoint , setWfsEndpoint ] = useState ( DEFAULT_WFS_ENDPOINT ) ;
378+ const [ wfsTypeName , setWfsTypeName ] = useState ( DEFAULT_WFS_TYPE_NAME ) ;
379+ const [ wfsVersion , setWfsVersion ] = useState ( "2.0.0" ) ;
380+ const [ wfsOutputFormat , setWfsOutputFormat ] = useState ( "application/json" ) ;
381+ const [ wfsSrsName , setWfsSrsName ] = useState ( "EPSG:4326" ) ;
382+ const [ wfsMaxFeatures , setWfsMaxFeatures ] = useState ( "1000" ) ;
306383 const [ wmtsUrl , setWmtsUrl ] = useState ( DEFAULT_WMTS_URL ) ;
307384 const [ wmtsTileSize , setWmtsTileSize ] = useState ( "256" ) ;
308385
@@ -359,6 +436,7 @@ export function AddDataDialog({
359436 {
360437 xyz : "XYZ Layer" ,
361438 wms : "WMS Layer" ,
439+ wfs : "WFS Layer" ,
362440 wmts : "WMTS Layer" ,
363441 vector : "Vector Layer" ,
364442 raster : "Raster Layer" ,
@@ -377,6 +455,12 @@ export function AddDataDialog({
377455 setWmsFormat ( "image/png" ) ;
378456 setWmsTransparent ( true ) ;
379457 setWmsTileSize ( "256" ) ;
458+ setWfsEndpoint ( DEFAULT_WFS_ENDPOINT ) ;
459+ setWfsTypeName ( DEFAULT_WFS_TYPE_NAME ) ;
460+ setWfsVersion ( "2.0.0" ) ;
461+ setWfsOutputFormat ( "application/json" ) ;
462+ setWfsSrsName ( "EPSG:4326" ) ;
463+ setWfsMaxFeatures ( "1000" ) ;
380464 setWmtsUrl ( DEFAULT_WMTS_URL ) ;
381465 setWmtsTileSize ( "256" ) ;
382466 setVectorMode ( "geojson-url" ) ;
@@ -421,6 +505,9 @@ export function AddDataDialog({
421505 if ( kind === "wms" ) {
422506 return "Add a WMS GetMap service as a tiled raster layer." ;
423507 }
508+ if ( kind === "wfs" ) {
509+ return "Add WFS features by requesting GeoJSON from a GetFeature service." ;
510+ }
424511 if ( kind === "wmts" ) {
425512 return "Add a WMTS tile URL template as a raster layer." ;
426513 }
@@ -744,6 +831,53 @@ export function AddDataDialog({
744831 return ;
745832 }
746833
834+ if ( kind === "wfs" ) {
835+ if ( ! wfsEndpoint . trim ( ) ) throw new Error ( "Enter a WFS service URL." ) ;
836+ if ( ! wfsTypeName . trim ( ) ) {
837+ throw new Error ( "Enter a WFS feature type name." ) ;
838+ }
839+ if ( ! wfsOutputFormat . trim ( ) ) {
840+ throw new Error ( "Enter a WFS output format." ) ;
841+ }
842+
843+ const featureUrl = createWfsGetFeatureUrl ( {
844+ endpoint : wfsEndpoint . trim ( ) ,
845+ typeName : wfsTypeName . trim ( ) ,
846+ version : wfsVersion ,
847+ outputFormat : wfsOutputFormat . trim ( ) ,
848+ srsName : wfsSrsName . trim ( ) ,
849+ maxFeatures : wfsMaxFeatures . trim ( ) || undefined ,
850+ } ) ;
851+ const data = await fetchGeoJson ( proxyWfsRequestUrl ( featureUrl ) ) ;
852+ addAndClose (
853+ {
854+ ...createBaseLayer (
855+ name ,
856+ "geojson" ,
857+ {
858+ type : "geojson" ,
859+ url : featureUrl ,
860+ service : "wfs" ,
861+ typeName : wfsTypeName . trim ( ) ,
862+ version : wfsVersion ,
863+ outputFormat : wfsOutputFormat . trim ( ) ,
864+ srsName : wfsSrsName . trim ( ) || undefined ,
865+ } ,
866+ {
867+ featureCount : data . features . length ,
868+ service : "wfs" ,
869+ sourceKind : "wfs-getfeature" ,
870+ typeName : wfsTypeName . trim ( ) ,
871+ } ,
872+ ) ,
873+ geojson : data ,
874+ sourcePath : featureUrl ,
875+ } ,
876+ { fit : true } ,
877+ ) ;
878+ return ;
879+ }
880+
747881 if ( kind === "wmts" ) {
748882 if ( ! wmtsUrl . trim ( ) ) {
749883 throw new Error ( "Enter a WMTS tile URL template." ) ;
@@ -1069,6 +1203,72 @@ export function AddDataDialog({
10691203 </ div >
10701204 ) }
10711205
1206+ { kind === "wfs" && (
1207+ < div className = "space-y-3" >
1208+ < div className = "space-y-1.5" >
1209+ < Label htmlFor = "wfs-endpoint" > Service URL</ Label >
1210+ < Input
1211+ id = "wfs-endpoint"
1212+ placeholder = "https://example.com/geoserver/wfs"
1213+ value = { wfsEndpoint }
1214+ onChange = { ( event ) => setWfsEndpoint ( event . target . value ) }
1215+ />
1216+ </ div >
1217+ < div className = "grid gap-3 sm:grid-cols-2" >
1218+ < div className = "space-y-1.5" >
1219+ < Label htmlFor = "wfs-type-name" > Feature type</ Label >
1220+ < Input
1221+ id = "wfs-type-name"
1222+ placeholder = "workspace:layer"
1223+ value = { wfsTypeName }
1224+ onChange = { ( event ) => setWfsTypeName ( event . target . value ) }
1225+ />
1226+ </ div >
1227+ < div className = "space-y-1.5" >
1228+ < Label htmlFor = "wfs-version" > Version</ Label >
1229+ < Select
1230+ id = "wfs-version"
1231+ value = { wfsVersion }
1232+ onChange = { ( event ) => setWfsVersion ( event . target . value ) }
1233+ >
1234+ < option value = "2.0.0" > 2.0.0</ option >
1235+ < option value = "1.1.0" > 1.1.0</ option >
1236+ < option value = "1.0.0" > 1.0.0</ option >
1237+ </ Select >
1238+ </ div >
1239+ < div className = "space-y-1.5" >
1240+ < Label htmlFor = "wfs-output-format" > Output format</ Label >
1241+ < Input
1242+ id = "wfs-output-format"
1243+ value = { wfsOutputFormat }
1244+ onChange = { ( event ) =>
1245+ setWfsOutputFormat ( event . target . value )
1246+ }
1247+ />
1248+ </ div >
1249+ < div className = "space-y-1.5" >
1250+ < Label htmlFor = "wfs-srs-name" > SRS name</ Label >
1251+ < Input
1252+ id = "wfs-srs-name"
1253+ placeholder = "Optional"
1254+ value = { wfsSrsName }
1255+ onChange = { ( event ) => setWfsSrsName ( event . target . value ) }
1256+ />
1257+ </ div >
1258+ < div className = "space-y-1.5" >
1259+ < Label htmlFor = "wfs-max-features" > Max features</ Label >
1260+ < Input
1261+ id = "wfs-max-features"
1262+ inputMode = "numeric"
1263+ placeholder = "Optional"
1264+ value = { wfsMaxFeatures }
1265+ onChange = { ( event ) => setWfsMaxFeatures ( event . target . value ) }
1266+ />
1267+ </ div >
1268+ </ div >
1269+ </ div >
1270+ ) }
1271+
10721272 { kind === "wmts" && (
10731273 < div className = "grid gap-3 sm:grid-cols-[1fr_7rem]" >
10741274 < div className = "space-y-1.5" >
@@ -1543,7 +1743,7 @@ export function AddDataDialog({
15431743 </ Button >
15441744 < Button type = "submit" disabled = { addLayerDisabled } >
15451745 { ! isSubmitting ? (
1546- kind === "wms" || kind === "wmts" ? (
1746+ kind === "wms" || kind === "wfs" || kind === " wmts" ? (
15471747 < Globe2 className = "mr-2 h-3.5 w-3.5" />
15481748 ) : kind === "raster" ? (
15491749 < Image className = "mr-2 h-3.5 w-3.5" />
0 commit comments