Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 19 additions & 6 deletions web/client/api/FlatGeobuf.js
Original file line number Diff line number Diff line change
Expand Up @@ -153,23 +153,36 @@ export const FGB_MATCH_ALL_RECT = {
};

/**
* Read the first feature from an FGB stream and return its GeoJSON
* geometry.type. Used as a fallback when the FGB binary header declares
* Unknown (0); heterogeneous datasets need an actual feature to know
* Read the first feature from an FGB stream and return its
* Used as a fallback when the FGB binary header is empty or malformed
* Or geometrytype is Unknown (0); heterogeneous datasets need an actual feature to know
* what the layer should be styled as.
* @param {string} url FGB URL (already resolved with auth params)
* @param {object} [headers] HTTP headers
* @returns {Promise<string|undefined>}
*/
export const sniffFlatGeobufFirstGeometryType = (url, headers) => {
export const sniffFlatGeobufFirstFeature = (url, headers) => {
return getFlatGeobufGeojson().then((flatgeobuf) => {
const iterator = flatgeobuf.deserialize(url, FGB_MATCH_ALL_RECT, undefined, false, headers);
// best-effort cancel: stops the underlying fetch when the FGB
// library honors generator return() (3.x streamSearch does).
// Wrap so we can preserve / drop the resolved type through cleanup.
const cleanup = (passthrough) => Promise.resolve(iterator.return?.()).then(() => passthrough);
return iterator.next()
.then(({ value: feature }) => cleanup(feature?.geometry?.type))
return iterator.next() // only read the first feature, then stop the stream
.then(({ value: feature }) => cleanup(feature))
.catch(() => cleanup(undefined));
});
};

/**
* Read the first feature from an FGB stream and return its GeoJSON
* geometry.type. Used as a fallback when the FGB binary header declares
* Unknown (0); heterogeneous datasets need an actual feature to know
* what the layer should be styled as.
* @param {string} url FGB URL (already resolved with auth params)
* @param {object} [headers] HTTP headers
* @returns {Promise<string|undefined>}
*/
export const sniffFlatGeobufFirstGeometryType = (url, headers) => {
return sniffFlatGeobufFirstFeature(url, headers).then((feature) => feature?.geometry?.type) || Promise.resolve(undefined);
};
35 changes: 32 additions & 3 deletions web/client/api/__tests__/FlatGeobuf-test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,25 @@ import {
FGB_VERSION,
getCapabilities,
createFlatGeobufGeometryTypeResolver,
sniffFlatGeobufFirstGeometryType
sniffFlatGeobufFirstGeometryType,
sniffFlatGeobufFirstFeature
} from '../FlatGeobuf';

const FGB_FILE = 'base/web/client/test-resources/flatgeobuf/UScounties_subset.fgb';

const FGB_FILE_FIRST_FEATURE_PROPS = {
"geometry": {
"type": "Polygon",
"coordinates": [[], []]
},
"properties": {
"STATE_FIPS": "40",
"COUNTY_FIP": "133",
"FIPS": "40133",
"STATE": "OK",
"NAME": "Seminole",
"LSAD": "County"
}
};
describe('Test FlatGeobuf API', () => {
it('getCapabilities from FlatGeobuf file', (done) => {
getCapabilities(FGB_FILE).then(({ bbox, format, version, title}) => {
Expand Down Expand Up @@ -98,6 +112,21 @@ describe('Test FlatGeobuf API', () => {
expect(calls).toEqual(['Point']);
});
});
describe('sniffFlatGeobufFirstFeature', () => {
it('reads the first feature properties from a real FGB file', (done) => {
sniffFlatGeobufFirstFeature(FGB_FILE).then((feature) => {
try {
expect(feature).toBeTruthy();
expect(feature.properties).toBeTruthy();
expect(feature.properties).toEqual(FGB_FILE_FIRST_FEATURE_PROPS.properties);
} catch (e) {
done(e);
return;
}
done();
}, done);
});
});
describe('sniffFlatGeobufFirstGeometryType', () => {
it('reads the first feature geometry type from a real FGB file', (done) => {
// Guards against two real bugs surfaced during development:
Expand All @@ -108,7 +137,7 @@ describe('Test FlatGeobuf API', () => {
// The fixture's header reports geometryType=3 (Polygon).
sniffFlatGeobufFirstGeometryType(FGB_FILE).then((type) => {
try {
expect(type).toBe('Polygon');
expect(type).toBe(FGB_FILE_FIRST_FEATURE_PROPS.geometry.type);
} catch (e) {
done(e);
return;
Expand Down
1 change: 1 addition & 0 deletions web/client/components/catalog/datasets/Catalog.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ const Catalog = ({
{ name: "3dtiles", label: "3D Tiles" },
{ name: "model", label: "IFC Model" },
{ name: "arcgis", label: "ArcGIS" },
{ name: "flatgeobuf", label: "FlatGeobuf" },
{ name: "geonode", label: "GeoNode" }
],
result,
Expand Down
59 changes: 43 additions & 16 deletions web/client/plugins/styleeditor/VectorStyleEditor.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import React, { useEffect, useRef, useState } from 'react';
import uniq from 'lodash/uniq';
import isEmpty from 'lodash/isEmpty';
import { StyleEditor } from './StyleCodeEditor';
import TextareaEditor from '../../components/styleeditor/Editor';
import VisualStyleEditor from '../../components/styleeditor/VisualStyleEditor';
Expand Down Expand Up @@ -37,7 +38,7 @@ import { getFlatGeobufGeometryTypeFromOptions } from '../../utils/FlatGeobufLaye

import {
getCapabilities as getFlatGeobufCapabilities,
sniffFlatGeobufFirstGeometryType
sniffFlatGeobufFirstFeature
} from '../../api/FlatGeobuf';
import { getRequestConfigurationByUrl } from '../../utils/SecurityUtils';
import { updateUrlParams } from '../../utils/URLUtils';
Expand All @@ -61,27 +62,53 @@ const capabilitiesRequest = {
});
},
'flatgeobuf': (layer) => {
// Priority to check geometryType:
// 1. explicit layer.geometryType
// 2. FGB header from capabilities
// 3. then sniff the first feature from remote file header
const layerGeometryType = getFlatGeobufGeometryTypeFromOptions(layer);
const geometryType = getGeometryType({ localType: layerGeometryType });

if (layer?.sourceMetadata?.columns) {
const properties = layer?.sourceMetadata?.columns?.reduce((acc, { name }) => ({ ...acc, [name]: '' }), {}) || {};
return {
geometryType,
properties
};
}

return getFlatGeobufCapabilities(layer.url).then((capabilities) => {
const properties = capabilities?.metadata?.columns?.reduce((acc, { name }) => ({ ...acc, [name]: '' }), {}) || {};
// Priority: explicit layer.geometryType, then FGB header from
// capabilities, then sniff the first feature when the header
// declares Unknown (id 0). Normalize through getGeometryType so
// the result matches the WFS/vector convention (lowercase,
// Multi prefix collapsed).
const fromOptions = getFlatGeobufGeometryTypeFromOptions({
const optionsProperties = capabilities?.metadata?.columns?.reduce((acc, { name }) => ({ ...acc, [name]: '' }), {}) || {};
const optionsGeometryType = getFlatGeobufGeometryTypeFromOptions({
geometryType: layer.geometryType,
metadata: capabilities.metadata
sourceMetadata: capabilities.metadata
});
const finalize = (rawType) => ({
properties,
geometryType: getGeometryType({ localType: rawType || '' })
const optionsFirstFeature = { // hipotetical feature with geometry type from options and properties from metadata, used when sniffing is not needed
geometry: { type: optionsGeometryType },
properties: optionsProperties
};
const finalize = (firstFeature) => ({
geometryType: getGeometryType({ localType: firstFeature?.geometry?.type || '' }),
properties: optionsProperties || firstFeature?.properties || {}
});
if (fromOptions) {
return finalize(fromOptions);
if (optionsGeometryType && !isEmpty(optionsProperties)) {
return finalize(optionsFirstFeature);
}
const { headers, params } = getRequestConfigurationByUrl(layer.url, layer?.security?.sourceId);
return sniffFlatGeobufFirstGeometryType(updateUrlParams(layer.url, params), headers)
.then(finalize);
return sniffFlatGeobufFirstFeature(updateUrlParams(layer.url, params), headers)
.then(finalize)
.catch(() => {
return {
geometryType: optionsGeometryType,
properties: optionsProperties
};
});

}).catch(() => {
return {
geometryType: layer.geometryType,
properties: layer.properties
};
});
},
'wfs': (layer) => layer.url
Expand Down
Loading