From 93a670d2396b402d14571bd223407c5684e74493 Mon Sep 17 00:00:00 2001 From: stefanocudini Date: Wed, 13 May 2026 12:01:13 +0200 Subject: [PATCH] fix-styleditor-empty fix-styleditor-empty flatgeobuf fallback headers fix catalog fgb fix tests --- web/client/api/FlatGeobuf.js | 25 ++++++-- web/client/api/__tests__/FlatGeobuf-test.jsx | 35 ++++++++++- .../components/catalog/datasets/Catalog.jsx | 1 + .../plugins/styleeditor/VectorStyleEditor.jsx | 59 ++++++++++++++----- 4 files changed, 95 insertions(+), 25 deletions(-) diff --git a/web/client/api/FlatGeobuf.js b/web/client/api/FlatGeobuf.js index be20587fa9..31460fe4c7 100644 --- a/web/client/api/FlatGeobuf.js +++ b/web/client/api/FlatGeobuf.js @@ -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} */ -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} + */ +export const sniffFlatGeobufFirstGeometryType = (url, headers) => { + return sniffFlatGeobufFirstFeature(url, headers).then((feature) => feature?.geometry?.type) || Promise.resolve(undefined); +}; diff --git a/web/client/api/__tests__/FlatGeobuf-test.jsx b/web/client/api/__tests__/FlatGeobuf-test.jsx index 57935b280a..834bdb9e4d 100644 --- a/web/client/api/__tests__/FlatGeobuf-test.jsx +++ b/web/client/api/__tests__/FlatGeobuf-test.jsx @@ -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}) => { @@ -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: @@ -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; diff --git a/web/client/components/catalog/datasets/Catalog.jsx b/web/client/components/catalog/datasets/Catalog.jsx index 0f4fecedbc..812d18da30 100644 --- a/web/client/components/catalog/datasets/Catalog.jsx +++ b/web/client/components/catalog/datasets/Catalog.jsx @@ -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, diff --git a/web/client/plugins/styleeditor/VectorStyleEditor.jsx b/web/client/plugins/styleeditor/VectorStyleEditor.jsx index fe29ed4e68..2e1371016c 100644 --- a/web/client/plugins/styleeditor/VectorStyleEditor.jsx +++ b/web/client/plugins/styleeditor/VectorStyleEditor.jsx @@ -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'; @@ -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'; @@ -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