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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
10 changes: 10 additions & 0 deletions src/app/api/model/app-cfg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 4 additions & 0 deletions src/app/api/model/sitna-cfg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export interface SitnaBaseLayer {
type?: string;
isBase?: boolean;
featureType?: string;
geometryName?: string;
geometryType?: string;
minResolution?: number;
maxResolution?: number;
overviewMapLayer?: string;
Expand All @@ -36,6 +38,8 @@ export interface SitnaLayerOptions {
type?: string;
url?: string;
featureType?: string;
geometryName?: string;
geometryType?: string;
minResolution?: number;
maxResolution?: number;
opacity?: number;
Expand Down
13 changes: 13 additions & 0 deletions src/app/controls/handlers/feature-info-fields.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions src/app/controls/handlers/feature-info-fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => ({
Expand Down
86 changes: 86 additions & 0 deletions src/app/controls/handlers/layer-catalog-control.handler.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
2 changes: 2 additions & 0 deletions src/app/controls/handlers/layer-catalog-control.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ 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';
Expand Down Expand Up @@ -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
Expand Down
33 changes: 33 additions & 0 deletions src/app/services/map-configuration.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
2 changes: 2 additions & 0 deletions src/app/services/map-configuration.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -144,6 +145,7 @@ export class MapConfigurationService {
if (featureType) {
a.featureType = featureType;
}
Object.assign(a, wfsLayerGeometryOptions(layer));
const minResolution = resolutionFromScaleDenominator(
layer.minScaleDenominator,
1
Expand Down
8 changes: 7 additions & 1 deletion src/app/services/virtual-wms-capabilities.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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 } : {})
};
}

Expand Down
33 changes: 33 additions & 0 deletions src/app/services/wfs-layer-geometry.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading