From b14692ab6d42c873328f1b639659e642ee24eb22 Mon Sep 17 00:00:00 2001 From: Francisco J Lopez-Pellicer Date: Sat, 24 Oct 2026 11:59:02 +0200 Subject: [PATCH 1/2] feat(map): pass geometryName and geometryType to SITNA A WFS layer uses the published property name. geometryType is set only when the profile publishes a preferred geometry type. --- CHANGELOG.md | 1 + src/app/api/model/app-cfg.ts | 10 +++ src/app/api/model/sitna-cfg.ts | 4 + .../handlers/feature-info-fields.spec.ts | 13 +++ .../controls/handlers/feature-info-fields.ts | 1 + .../layer-catalog-control.handler.spec.ts | 86 +++++++++++++++++++ .../handlers/layer-catalog-control.handler.ts | 2 + .../map-configuration.service.spec.ts | 33 +++++++ src/app/services/map-configuration.service.ts | 2 + .../virtual-wms-capabilities.service.ts | 8 +- src/app/services/wfs-layer-geometry.ts | 33 +++++++ 11 files changed, 192 insertions(+), 1 deletion(-) create mode 100644 src/app/services/wfs-layer-geometry.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index dd8fdb7f..ecb1c29c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to this project will be documented in this file. The format ### Added - **Map**: A folder with a description, a metadata URL, or a dataset URL shows the same information button as a layer. The window shows the folder name, the description, and those links. An empty folder has no button ([#173](https://github.com/sitmun/sitmun-viewer-app/issues/173)). +- **Map**: Adding a WFS layer passes `geometryName` to SITNA when the profile publishes one, and passes `geometryType` only when the profile publishes `preferredGeometryType`. A specific DescribeFeatureType kind is left for SITNA to read. The identify table skips a feature-information field whose format is `Geometry`. The geometry-property contract is the operator's change on top of [sitmun-admin-app#499](https://github.com/sitmun/sitmun-admin-app/issues/499). - **Map**: A layer with GetFeatureInfo fields shows only that include list, in order, with the translated label. `T`, `N`, `F`, `U`, `P`, and `I` format the cell in the map locale. `AUTO` keeps API-SITNA detection. `feature.data` is not rewritten. More-info reads `data-feature-path` and dotted paths such as `address.city` ([sitmun-application-stack#77](https://github.com/sitmun/sitmun-application-stack/issues/77)). - **Tests**: Field-list specs cover a tree-title heading and two WMS group children that share a title but use different profile layer ids. diff --git a/src/app/api/model/app-cfg.ts b/src/app/api/model/app-cfg.ts index 067f86cf..e56bc9e2 100644 --- a/src/app/api/model/app-cfg.ts +++ b/src/app/api/model/app-cfg.ts @@ -137,6 +137,16 @@ export interface AppLayer { * which leaves the service table unchanged. */ featureInfoFields?: FeatureInfoField[]; + /** + * DescribeFeatureType property SITNA should use as the layer geometry. + * Omitted when unset, so SITNA keeps the last geometry property. + */ + geometryName?: string; + /** + * Preferred geometry id when the chosen property is generic Geometry. + * Omitted for a specific GML type, which SITNA reads from DescribeFeatureType. + */ + preferredGeometryType?: string; } export interface AppService { diff --git a/src/app/api/model/sitna-cfg.ts b/src/app/api/model/sitna-cfg.ts index 95e79ba4..7a20789f 100644 --- a/src/app/api/model/sitna-cfg.ts +++ b/src/app/api/model/sitna-cfg.ts @@ -10,6 +10,8 @@ export interface SitnaBaseLayer { type?: string; isBase?: boolean; featureType?: string; + geometryName?: string; + geometryType?: string; minResolution?: number; maxResolution?: number; overviewMapLayer?: string; @@ -36,6 +38,8 @@ export interface SitnaLayerOptions { type?: string; url?: string; featureType?: string; + geometryName?: string; + geometryType?: string; minResolution?: number; maxResolution?: number; opacity?: number; diff --git a/src/app/controls/handlers/feature-info-fields.spec.ts b/src/app/controls/handlers/feature-info-fields.spec.ts index ce047968..35573cb6 100644 --- a/src/app/controls/handlers/feature-info-fields.spec.ts +++ b/src/app/controls/handlers/feature-info-fields.spec.ts @@ -25,6 +25,19 @@ describe('buildFeatureInfoRows', () => { }); }); + it('skips a parameter whose format is Geometry and keeps a text parameter with a geometry-like name', () => { + const rows = buildFeatureInfoRows( + { shape: 'wkt', the_geom: 'label' }, + [ + { name: 'shape', label: 'Shape', format: 'Geometry' }, + { name: 'the_geom', label: 'Name', format: 'T' } + ], + 'en' + ); + + expect(rows.map((row) => row.path)).toEqual(['the_geom']); + }); + it('filters, orders, and replaces headers without changing feature data', () => { const data = { season: 'summer', diff --git a/src/app/controls/handlers/feature-info-fields.ts b/src/app/controls/handlers/feature-info-fields.ts index 234769f1..7a8aff3b 100644 --- a/src/app/controls/handlers/feature-info-fields.ts +++ b/src/app/controls/handlers/feature-info-fields.ts @@ -80,6 +80,7 @@ export function buildFeatureInfoRows( })); } return fields + .filter((field) => field.format !== 'Geometry') .map((field, index) => ({ field, index })) .sort((left, right) => compareOrder(left, right)) .map(({ field }) => ({ diff --git a/src/app/controls/handlers/layer-catalog-control.handler.spec.ts b/src/app/controls/handlers/layer-catalog-control.handler.spec.ts index 2aee2e62..5c87fda1 100644 --- a/src/app/controls/handlers/layer-catalog-control.handler.spec.ts +++ b/src/app/controls/handlers/layer-catalog-control.handler.spec.ts @@ -1045,6 +1045,92 @@ describe('LayerCatalogControlHandler', () => { minResolution: (1000 * 0.00028) / 1, maxResolution: (500000 * 0.00028) / 1 }); + expect(addLayer.mock.calls[0][0]).not.toHaveProperty('geometryName'); + expect(addLayer.mock.calls[0][0]).not.toHaveProperty('geometryType'); + }); + + it('passes a published geometry property name and an explicit SITNA type when adding a WFS layer', async () => { + const addedLayer: any = {}; + const { TC } = buildMockTC(addedLayer); + mockSitnaApi.getTC.mockReturnValue(TC as any); + mockVirtualCapabilities.findRealLayerConfig = jest.fn().mockReturnValue({ + url: 'https://proxy.example/wfs', + type: 'WFS', + layerNames: ['app:mixed'], + serviceId: 'service/9', + geometryName: 'shape', + preferredGeometryType: 'polygon' + }); + mockSitnaApi.setGlobal('currentAppCfg', minimalContext); + await handler['patchLayerCatalogAddLayerToMap'](); + + const addLayer = jest.fn().mockResolvedValue(addedLayer); + await TC.control.LayerCatalog.prototype.addLayerToMap.call( + { + map: { + crs: 'EPSG:25831', + getMetersPerUnit: () => 1, + addLayer + }, + getUID: () => 'uid-wfs', + showProjectionChangeDialog: jest.fn() + }, + { + title: 'Mixed', + url: 'virtual://stale', + type: 'WMS', + options: { url: 'virtual://stale', type: 'WMS' }, + getLayerNodeByName: () => undefined + }, + 'node/wfs' + ); + + expect(addLayer.mock.calls[0][0]).toMatchObject({ + type: 'WFS', + featureType: 'app:mixed', + geometryName: 'shape', + geometryType: 'polygon' + }); + }); + + it('passes the geometry property name without a type when the profile has no explicit SITNA type', async () => { + const addedLayer: any = {}; + const { TC } = buildMockTC(addedLayer); + mockSitnaApi.getTC.mockReturnValue(TC as any); + mockVirtualCapabilities.findRealLayerConfig = jest.fn().mockReturnValue({ + url: 'https://proxy.example/wfs', + type: 'WFS', + layerNames: ['turisme:tu007poi_fars'], + serviceId: 'service/9', + geometryName: 'the_geom' + }); + mockSitnaApi.setGlobal('currentAppCfg', minimalContext); + await handler['patchLayerCatalogAddLayerToMap'](); + + const addLayer = jest.fn().mockResolvedValue(addedLayer); + await TC.control.LayerCatalog.prototype.addLayerToMap.call( + { + map: { + crs: 'EPSG:25831', + getMetersPerUnit: () => 1, + addLayer + }, + getUID: () => 'uid-wfs', + showProjectionChangeDialog: jest.fn() + }, + { + title: 'Fars', + url: 'virtual://stale', + type: 'WMS', + options: { url: 'virtual://stale', type: 'WMS' }, + getLayerNodeByName: () => undefined + }, + 'node/fars' + ); + + const options = addLayer.mock.calls[0][0]; + expect(options.geometryName).toBe('the_geom'); + expect(options).not.toHaveProperty('geometryType'); }); it('names a WFS leaf with the node title, not the catalog folder title', async () => { diff --git a/src/app/controls/handlers/layer-catalog-control.handler.ts b/src/app/controls/handlers/layer-catalog-control.handler.ts index 4087e8ab..75d9632c 100644 --- a/src/app/controls/handlers/layer-catalog-control.handler.ts +++ b/src/app/controls/handlers/layer-catalog-control.handler.ts @@ -20,6 +20,7 @@ import { } from '../../services/folder-catalog-info'; import { LayerInfoService } from '../../services/layer-info.service'; import { resolveSitmunGfiEnabled } from '../../services/profile-layer-queryable'; +import { wfsLayerGeometryOptions } from '../../services/wfs-layer-geometry'; import { RasterLayerService } from '../../services/raster-layer.service'; import { SitnaApiService } from '../../services/sitna-api.service'; import { SitnaCapabilitiesInterceptor } from '../../services/sitna-capabilities-interceptor.service'; @@ -601,6 +602,7 @@ export class LayerCatalogControlHandler extends ControlHandlerBase { if (featureType) { layerOptions.featureType = featureType; } + Object.assign(layerOptions, wfsLayerGeometryOptions(realLayerConfig)); layerOptions['zIndex'] = realLayerConfig.order ?? 0; const wfsOpacity = typeof transparency === 'number' && transparency > 0 diff --git a/src/app/services/map-configuration.service.spec.ts b/src/app/services/map-configuration.service.spec.ts index abb57745..19675994 100644 --- a/src/app/services/map-configuration.service.spec.ts +++ b/src/app/services/map-configuration.service.spec.ts @@ -628,6 +628,39 @@ describe('MapConfigurationService', () => { expect(wfs?.maxResolution).toBeCloseTo((500000 * 0.00028) / 1); expect(wfs?.overviewMapLayer).toBe(wms?.id); expect(wfs?.overviewMapLayer).not.toBe(wfs?.id); + expect(wfs).not.toHaveProperty('geometryName'); + expect(wfs).not.toHaveProperty('geometryType'); + }); + + it('passes a published WFS geometry property and explicit SITNA type onto the base layer', () => { + const cfg: AppCfg = { + ...mockAppCfg, + backgrounds: [{ id: 'bg-wfs', title: 'WFS background', thumbnail: '' }], + groups: [{ id: 'bg-wfs', title: 'WFS group', layers: ['layer-wfs'] }], + layers: [ + { + id: 'layer-wfs', + title: 'WFS Layer', + layers: ['app:mixed'], + service: 'service-wfs', + geometryName: 'shape', + preferredGeometryType: 'polygon' + } + ], + services: [ + { + id: 'service-wfs', + url: 'http://example.com/wfs', + type: 'WFS', + parameters: { format: 'text/xml' } + } + ] + }; + configLookup.initialize(cfg); + + const layer = service.toBaseLayers(cfg).find((item) => item.title === 'WFS Layer'); + expect(layer?.geometryName).toBe('shape'); + expect(layer?.geometryType).toBe('polygon'); }); it('should use thumbnail from background if available', () => { diff --git a/src/app/services/map-configuration.service.ts b/src/app/services/map-configuration.service.ts index a799b766..0c5c16ad 100644 --- a/src/app/services/map-configuration.service.ts +++ b/src/app/services/map-configuration.service.ts @@ -7,6 +7,7 @@ import { TranslateService } from '@ngx-translate/core'; import { AppConfigService } from './app-config.service'; import { ConfigLookupService } from './config-lookup.service'; import { ControlRegistryService } from './control-registry.service'; +import { wfsLayerGeometryOptions } from './wfs-layer-geometry'; import { sortBackgroundsByOrder, toDefaultBaseLayer @@ -144,6 +145,7 @@ export class MapConfigurationService { if (featureType) { a.featureType = featureType; } + Object.assign(a, wfsLayerGeometryOptions(layer)); const minResolution = resolutionFromScaleDenominator( layer.minScaleDenominator, 1 diff --git a/src/app/services/virtual-wms-capabilities.service.ts b/src/app/services/virtual-wms-capabilities.service.ts index 64ca26d3..72b26738 100644 --- a/src/app/services/virtual-wms-capabilities.service.ts +++ b/src/app/services/virtual-wms-capabilities.service.ts @@ -38,6 +38,10 @@ export interface RealLayerConfig { * reorder via the WorkLayerManager control bypasses `zIndex` and is not persisted. */ order?: number; + /** Profile geometry property name. Absent when the administrator has not chosen one. */ + geometryName?: string; + /** Preferred geometry id. Absent when DescribeFeatureType already names a specific type. */ + preferredGeometryType?: string; } /** @@ -251,7 +255,9 @@ export class VirtualWmsCapabilitiesService { layerNames: layer.layers, // Array of WMS layer names from the layer serviceId: service.id, transparency: layer.transparency, - order: layer.order + order: layer.order, + ...(layer.geometryName ? { geometryName: layer.geometryName } : {}), + ...(layer.preferredGeometryType ? { preferredGeometryType: layer.preferredGeometryType } : {}) }; } diff --git a/src/app/services/wfs-layer-geometry.ts b/src/app/services/wfs-layer-geometry.ts new file mode 100644 index 00000000..e91b8276 --- /dev/null +++ b/src/app/services/wfs-layer-geometry.ts @@ -0,0 +1,33 @@ +const SITNA_WFS_GEOMETRY_TYPES = new Set([ + 'point', + 'multipoint', + 'polyline', + 'polygon', + 'multipolyline', + 'multipolygon' +]); + +export type WfsLayerGeometrySource = { + geometryName?: string | null; + preferredGeometryType?: string | null; +}; + +/** + * Options SITNA reads when a WFS layer is added. + * `geometryName` is the feature property (`ol.format.GeoJSON` and WFS layer options). + * `geometryType` is a `SITNA.Consts.geom` id, passed only for an explicit override. + */ +export function wfsLayerGeometryOptions( + layer: WfsLayerGeometrySource +): { geometryName?: string; geometryType?: string } { + const options: { geometryName?: string; geometryType?: string } = {}; + const name = layer.geometryName?.trim(); + if (name) { + options.geometryName = name; + } + const type = layer.preferredGeometryType?.trim(); + if (type && SITNA_WFS_GEOMETRY_TYPES.has(type)) { + options.geometryType = type; + } + return options; +} From af36e8a490a4d5c05f650146b3712416933a5ea8 Mon Sep 17 00:00:00 2001 From: Francisco J Lopez-Pellicer Date: Sat, 24 Oct 2026 12:05:59 +0200 Subject: [PATCH 2/2] fix(lint): order the wfs-layer-geometry import The import belongs after the virtual WMS capabilities import. --- src/app/controls/handlers/layer-catalog-control.handler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/controls/handlers/layer-catalog-control.handler.ts b/src/app/controls/handlers/layer-catalog-control.handler.ts index 75d9632c..45df57db 100644 --- a/src/app/controls/handlers/layer-catalog-control.handler.ts +++ b/src/app/controls/handlers/layer-catalog-control.handler.ts @@ -20,11 +20,11 @@ import { } from '../../services/folder-catalog-info'; import { LayerInfoService } from '../../services/layer-info.service'; import { resolveSitmunGfiEnabled } from '../../services/profile-layer-queryable'; -import { wfsLayerGeometryOptions } from '../../services/wfs-layer-geometry'; import { RasterLayerService } from '../../services/raster-layer.service'; import { SitnaApiService } from '../../services/sitna-api.service'; import { SitnaCapabilitiesInterceptor } from '../../services/sitna-capabilities-interceptor.service'; import { VirtualWmsCapabilitiesService } from '../../services/virtual-wms-capabilities.service'; +import { wfsLayerGeometryOptions } from '../../services/wfs-layer-geometry'; import type { Meld, MeldJoinPoint } from '../../types/meld.types'; import { WMSLayer } from '../../types/wms-capabilities'; import { resolutionFromScaleDenominator } from '../../utils/scale-resolution';