Skip to content

Commit f25d7a7

Browse files
committed
Fix #12044: fix wfs layer hidden in 3D + scale arrow not rendering (#12155)
* fix #12044: fix wfs layer hidden in 3D + scale arrow not rendering - fix not hidding wfs layers with scale limits - handle showing arrow for current scale in scales DD for scaleDenominator * - Fix: Arrow UI disappears when zooming out in 3D styler view * - fix the scale limit filter to exclude max value and include min scale value * - for 3D, ehnace getMapScaleForCesium to take camera prependicular poosition into account - enhance calc. scale value in geoStylerStyleToOlParserStyleFct to match the predefined scale values list - fix FE unit test * - fix FE unit test * - add unit tests for getMapScaleForCesium and isCameraPerpendicularToSurface
1 parent d2a1028 commit f25d7a7

File tree

7 files changed

+290
-13
lines changed

7 files changed

+290
-13
lines changed

web/client/components/map/cesium/plugins/WFSLayer.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,8 @@ const createLayer = (options, map) => {
7070
id: options?.id,
7171
map: map,
7272
opacity: options.opacity,
73-
queryable: options.queryable === undefined || options.queryable
73+
queryable: options.queryable === undefined || options.queryable,
74+
styleRules: options?.style?.body?.rules || []
7475
});
7576
let loader;
7677
let loadingBbox;
@@ -187,6 +188,11 @@ Layers.registerType('wfs', {
187188
return createLayer(newOptions, map);
188189
}
189190
if (layer?.styledFeatures && !isEqual(newOptions.style, oldOptions.style)) {
191+
// update style rules here
192+
if (!isEqual(newOptions?.style?.body?.rules, oldOptions?.style?.body?.rules)) {
193+
let styleRules = newOptions?.style?.body?.rules || [];
194+
layer.styledFeatures._setStyleRules(styleRules);
195+
}
190196
layerToGeoStylerStyle(newOptions)
191197
.then((style) => {
192198
getStyle(applyDefaultStyleToVectorLayer({

web/client/plugins/styleeditor/VectorStyleEditor.jsx

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ import { classificationVector } from '../../api/StyleEditor';
3030
import SLDService from '../../api/SLDService';
3131
import { classifyGeoJSON, availableMethods } from '../../api/GeoJSONClassification';
3232
import { getLayerJSONFeature } from '../../observables/wfs';
33+
import { connect } from 'react-redux';
34+
import { createSelector } from 'reselect';
35+
import { currentZoomLevelSelector, scalesSelector } from '../../selectors/map';
3336

3437
const { getColors } = SLDService;
3538

@@ -84,7 +87,9 @@ function VectorStyleEditor({
8487
'Courier New',
8588
'Brush Script MT'
8689
],
87-
onUpdateNode = () => {}
90+
onUpdateNode = () => {},
91+
scales = [],
92+
zoom = 0
8893
}) {
8994

9095
const request = capabilitiesRequest[layer?.type];
@@ -253,9 +258,15 @@ function VectorStyleEditor({
253258
simple: !['vector', 'wfs'].includes(layer?.type),
254259
supportedSymbolizerMenuOptions: ['Simple', 'Extrusion', 'Classification'],
255260
fonts,
256-
enableFieldExpression: ['vector', 'wfs'].includes(layer.type)
261+
enableFieldExpression: ['vector', 'wfs'].includes(layer.type),
262+
scales,
263+
zoom: Math.round(zoom) // passing this for showing arrow of current scale for ScaleDenominator
257264
}}
258265
/>
259266
);
260267
}
261-
export default VectorStyleEditor;
268+
const ConnectedVectorStyleEditor = connect(createSelector([scalesSelector, currentZoomLevelSelector], (scales, zoom) => ({
269+
scales: scales.map(scale => Math.round(scale)),
270+
zoom
271+
})))(VectorStyleEditor);
272+
export default ConnectedVectorStyleEditor;

web/client/utils/MapUtils.js

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,76 @@ export function getScale(projection, dpi, resolution) {
346346
const dpu = dpi2dpu(dpi, projection);
347347
return resolution * dpu;
348348
}
349+
350+
/**
351+
* Checks if the camera is looking perpendicular (nadir) to the surface
352+
* @param {Cesium.Camera} camera - The Cesium camera
353+
* @param {Cesium.Cartesian3} position - Position on the globe (Cartesian3)
354+
* @param {Cesium.Ellipsoid} ellipsoid - The ellipsoid (usually scene.globe.ellipsoid)
355+
* @param {number} threshold - Cosine threshold (0.95 = ~18°, 0.99 = ~8°)
356+
* @returns {boolean} True if camera is approximately perpendicular
357+
*/
358+
export function isCameraPerpendicularToSurface(camera, position, ellipsoid, threshold = 0.95) {
359+
const surfaceNormal = ellipsoid.geodeticSurfaceNormal(position);
360+
const cameraDirection = camera.direction;
361+
362+
// Dot product: -1 = exactly opposite (straight down), 0 = parallel to surface
363+
const dot = Cesium.Cartesian3.dot(cameraDirection, surfaceNormal);
364+
365+
// Check if dot product is close to -1 (camera looking straight down)
366+
return dot < -threshold;
367+
}
368+
369+
/**
370+
* Calculates the map scale denominator at the center of the Cesium viewer's screen.
371+
*
372+
* * @param {Cesium.Viewer} viewer - The Cesium Viewer instance containing the scene and camera.
373+
* @returns {number} The map scale denominator (M in 1:M) at the screen center.
374+
* Returns a fallback scale based on camera height if the camera
375+
* is looking at space or the globe intersection fails.
376+
**/
377+
export function getMapScaleForCesium(viewer) {
378+
const FALLBACK_EARTH_CIRCUMFERENCE_METERS = 80000000;
379+
const cesiumDefaultProj = "EPSG:3857";
380+
const scene = viewer.scene;
381+
const camera = scene.camera;
382+
const canvas = scene.canvas;
383+
const ellipsoid = scene.globe.ellipsoid;
384+
// 1. Get two points at the center of the screen, 1 pixel apart horizontally
385+
const centerX = Math.floor(canvas.clientWidth / 2);
386+
const centerY = Math.floor(canvas.clientHeight / 2);
387+
388+
const leftPoint = new Cesium.Cartesian2(centerX, centerY);
389+
const rightPoint = new Cesium.Cartesian2(centerX + 1, centerY);
390+
391+
// 2. Convert screen pixels to Globe positions (Cartesian3)
392+
const leftRay = camera.getPickRay(leftPoint);
393+
const rightRay = camera.getPickRay(rightPoint);
394+
395+
const leftPos = scene.globe.pick(leftRay, scene);
396+
const rightPos = scene.globe.pick(rightRay, scene);
397+
398+
// Check if camera is perpendicular (only if we have a valid position to test against)
399+
const isPerpendicular = Cesium.defined(leftPos) ? isCameraPerpendicularToSurface(camera, leftPos, ellipsoid, 0.95) : false;
400+
401+
if (!Cesium.defined(leftPos) || !Cesium.defined(rightPos) || isPerpendicular) {
402+
console.warn('Camera is looking at space/sky or is perpendicular');
403+
const cameraPosition = camera.positionCartographic;
404+
const currentZoom = Math.log2(FALLBACK_EARTH_CIRCUMFERENCE_METERS / (cameraPosition.height)) + 1;
405+
const resolutions = getResolutions();
406+
const resolution = resolutions[Math.round(currentZoom)];
407+
const scaleVal = getScale(cesiumDefaultProj, DEFAULT_SCREEN_DPI, resolution);
408+
return Math.round(scaleVal ?? 0);
409+
}
410+
411+
const leftCartographic = scene.globe.ellipsoid.cartesianToCartographic(leftPos);
412+
const rightCartographic = scene.globe.ellipsoid.cartesianToCartographic(rightPos);
413+
414+
const geodesic = new Cesium.EllipsoidGeodesic(leftCartographic, rightCartographic);
415+
const resolution = geodesic.surfaceDistance; // This is meters per 1 pixel [resolution]
416+
const scaleValue = getScale(cesiumDefaultProj, DEFAULT_SCREEN_DPI, resolution);
417+
return Math.round(scaleValue ?? 0);
418+
}
349419
/**
350420
* get random coordinates within CRS extent
351421
* @param {string} crs the code of the projection for example EPSG:4346

web/client/utils/__tests__/MapUtils-test.js

Lines changed: 128 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
* LICENSE file in the root directory of this source tree.
77
*/
88
import expect from 'expect';
9+
import * as Cesium from 'cesium';
910

1011
import { keys, sortBy } from 'lodash';
1112

@@ -47,7 +48,9 @@ import {
4748
recursiveIsChangedWithRules,
4849
filterFieldByRules,
4950
prepareObjectEntries,
50-
parseFieldValue
51+
parseFieldValue,
52+
isCameraPerpendicularToSurface,
53+
getMapScaleForCesium
5154
} from '../MapUtils';
5255
import { VisualizationModes } from '../MapTypeUtils';
5356

@@ -2560,3 +2563,127 @@ describe('prepareObjectEntries', () => {
25602563
expect(entries).toEqual([]);
25612564
});
25622565
});
2566+
describe('test calc scale utils for cesium', () => {
2567+
it('isCameraPerpendicularToSurface - nadir (straight down) returns true', () => {
2568+
const ellipsoid = Cesium.Ellipsoid.WGS84;
2569+
const position = Cesium.Cartesian3.fromDegrees(0, 0);
2570+
const surfaceNormal = ellipsoid.geodeticSurfaceNormal(position);
2571+
2572+
// Camera direction opposite to surface normal = looking straight down
2573+
const camera = {
2574+
direction: Cesium.Cartesian3.negate(surfaceNormal, new Cesium.Cartesian3())
2575+
};
2576+
2577+
const result = isCameraPerpendicularToSurface(camera, position, ellipsoid, 0.95);
2578+
expect(result).toBe(true);
2579+
});
2580+
2581+
it('isCameraPerpendicularToSurface - oblique angle returns false', () => {
2582+
const ellipsoid = Cesium.Ellipsoid.WGS84;
2583+
const position = Cesium.Cartesian3.fromDegrees(0, 0);
2584+
2585+
// Tilted camera direction (~45° from nadir, dot ≈ -0.7)
2586+
const camera = {
2587+
direction: new Cesium.Cartesian3(0, -0.7, -0.7)
2588+
};
2589+
2590+
const result = isCameraPerpendicularToSurface(camera, position, ellipsoid, 0.95);
2591+
expect(result).toBe(false);
2592+
});
2593+
2594+
it('isCameraPerpendicularToSurface - horizontal view returns false', () => {
2595+
const ellipsoid = Cesium.Ellipsoid.WGS84;
2596+
const position = Cesium.Cartesian3.fromDegrees(0, 0);
2597+
2598+
// Camera looking horizontally (dot = 0)
2599+
const camera = {
2600+
direction: new Cesium.Cartesian3(1, 0, 0)
2601+
};
2602+
2603+
const result = isCameraPerpendicularToSurface(camera, position, ellipsoid, 0.95);
2604+
expect(result).toBe(false);
2605+
});
2606+
2607+
it('getMapScaleForCesium - returns number when camera looks at sky', () => {
2608+
// Minimal stub: globe.pick returns undefined = looking at sky
2609+
const mockViewer = {
2610+
scene: {
2611+
canvas: { clientWidth: 800, clientHeight: 600 },
2612+
camera: {
2613+
direction: new Cesium.Cartesian3(0, 0, 1),
2614+
positionCartographic: { height: 10000 },
2615+
getPickRay: () => ({})
2616+
},
2617+
globe: {
2618+
ellipsoid: Cesium.Ellipsoid.WGS84,
2619+
pick: () => undefined
2620+
}
2621+
}
2622+
};
2623+
2624+
const result = getMapScaleForCesium(mockViewer);
2625+
2626+
expect(typeof result).toBe('number');
2627+
expect(Number.isInteger(result)).toBe(true);
2628+
expect(result).toBeGreaterThan(0);
2629+
});
2630+
2631+
it('getMapScaleForCesium - returns number with valid positions + perpendicular camera', () => {
2632+
// Minimal stub: valid positions + perpendicular camera → triggers fallback by design
2633+
const mockPos = Cesium.Cartesian3.fromDegrees(0, 0, 0);
2634+
const mockViewer = {
2635+
scene: {
2636+
canvas: { clientWidth: 800, clientHeight: 600 },
2637+
camera: {
2638+
direction: new Cesium.Cartesian3(0, 0, -1), // straight down
2639+
positionCartographic: { height: 5000 },
2640+
getPickRay: () => ({})
2641+
},
2642+
globe: {
2643+
ellipsoid: Cesium.Ellipsoid.WGS84,
2644+
pick: () => mockPos,
2645+
cartesianToCartographic: () => ({
2646+
longitude: 0,
2647+
latitude: 0,
2648+
height: 0
2649+
})
2650+
}
2651+
}
2652+
};
2653+
2654+
const result = getMapScaleForCesium(mockViewer);
2655+
2656+
expect(typeof result).toBe('number');
2657+
expect(Number.isInteger(result)).toBe(true);
2658+
});
2659+
2660+
it('getMapScaleForCesium - returns number with valid positions + oblique camera', () => {
2661+
// Minimal stub: valid positions + oblique camera → uses EllipsoidGeodesic path
2662+
const mockPos = Cesium.Cartesian3.fromDegrees(0, 0, 0);
2663+
const mockViewer = {
2664+
scene: {
2665+
canvas: { clientWidth: 800, clientHeight: 600 },
2666+
camera: {
2667+
direction: new Cesium.Cartesian3(0, -0.5, -0.8), // tilted
2668+
positionCartographic: { height: 5000 },
2669+
getPickRay: () => ({})
2670+
},
2671+
globe: {
2672+
ellipsoid: Cesium.Ellipsoid.WGS84,
2673+
pick: () => mockPos,
2674+
cartesianToCartographic: () => ({
2675+
longitude: 0,
2676+
latitude: 0,
2677+
height: 0
2678+
})
2679+
}
2680+
}
2681+
};
2682+
2683+
const result = getMapScaleForCesium(mockViewer);
2684+
2685+
expect(typeof result).toBe('number');
2686+
expect(Number.isInteger(result)).toBe(true);
2687+
});
2688+
});
2689+

web/client/utils/styleparser/OLStyleParser.js

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ import { drawIcons } from './IconUtils';
6666

6767
import isString from 'lodash/isString';
6868
import { geometryFunctionsLibrary } from './GeometryFunctionsUtils';
69+
import { DEFAULT_SCREEN_DPI, getScale } from '../MapUtils';
6970

7071
const getGeometryFunction = geometryFunctionsLibrary.openlayers({
7172
Point: OlGeomPoint,
@@ -443,14 +444,9 @@ export class OlStyleParser {
443444
this._getMap = () => map;
444445
const styles = [];
445446

446-
// calculate scale for resolution (from ol-util MapUtil)
447-
const units = map
448-
? map.getView().getProjection().getUnits()
449-
: 'm';
450-
const dpi = 25.4 / 0.28;
451-
const mpu = METERS_PER_UNIT[units];
452-
const inchesPerMeter = 39.37;
453-
const scale = resolution * mpu * inchesPerMeter * dpi;
447+
// instead of using ol0util MapUtil -> calc. scale value using getScale() to be matched with the predefined scale list values
448+
const projection = map?.getView()?.getProjection()?.getCode() || "EPSG:3857";
449+
const scale = Math.round(getScale(projection, DEFAULT_SCREEN_DPI, resolution));
454450

455451
rules.forEach((rule) => {
456452
// handling scale denominator

web/client/utils/styleparser/StyleParserUtils.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,29 @@ export const geoStylerStyleFilter = (feature, filter) => {
425425
}
426426
return matchesFilter;
427427
};
428+
/**
429+
* Checks if a GeoStyler rule should be visible at the current map scale.
430+
*
431+
* @param {Object} [rule={}] - GeoStyler rule with optional scaleDenominator
432+
* @param {Object} [rule.scaleDenominator] - Scale constraints { min?, max? }
433+
* @param {number} [mapViewScale] - Current map scale denominator (1 : X)
434+
* @returns {boolean} true if rule should be applied at this scale
435+
* @example
436+
* geoStylerScaleDenominatorFilter(
437+
* { scaleDenominator: { min: 1000, max: 10000 } },
438+
* 5000
439+
* ); // → true
440+
*/
441+
export const geoStylerScaleDenominatorFilter = (rule = {}, mapViewScale) => {
442+
if (rule?.scaleDenominator && mapViewScale) {
443+
const {min, max} = rule?.scaleDenominator;
444+
if ((min !== undefined && mapViewScale < min) ||
445+
(max !== undefined && mapViewScale >= max)) {
446+
return false;
447+
}
448+
}
449+
return true;
450+
};
428451
/**
429452
* parse a string template and replace the placeholders with feature properties
430453
* @param {object} feature a GeoJSON feature

web/client/utils/styleparser/__tests__/StyleParserUtils-test.js

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,4 +167,48 @@ describe("StyleParserUtils ", () => {
167167
expect(width).toBe(annotationSymbolizer.size);
168168
expect(height).toBe(annotationSymbolizer.size);
169169
});
170+
// tests for geoStylerScaleDenominatorFilter
171+
describe('geoStylerScaleDenominatorFilter', () => {
172+
173+
it('returns true when scale is within [min, max] range -> max excluded and min included', () => {
174+
const rule = { scaleDenominator: { min: 1000, max: 10000 } };
175+
expect(geoStylerScaleDenominatorFilter(rule, 1000)).toBe(true); // min included
176+
expect(geoStylerScaleDenominatorFilter(rule, 5000)).toBe(true);
177+
expect(geoStylerScaleDenominatorFilter(rule, 10000)).toBe(false); // max is excluded
178+
});
179+
180+
it('returns false when scale is outside [min, max] range', () => {
181+
const rule = { scaleDenominator: { min: 1000, max: 10000 } };
182+
expect(geoStylerScaleDenominatorFilter(rule, 500)).toBe(false);
183+
expect(geoStylerScaleDenominatorFilter(rule, 50000)).toBe(false);
184+
});
185+
186+
it('works with only min or only max defined', () => {
187+
const minOnly = { scaleDenominator: { min: 5000 } };
188+
const maxOnly = { scaleDenominator: { max: 5000 } };
189+
190+
expect(geoStylerScaleDenominatorFilter(minOnly, 10000)).toBe(true);
191+
expect(geoStylerScaleDenominatorFilter(minOnly, 1000)).toBe(false);
192+
expect(geoStylerScaleDenominatorFilter(maxOnly, 1000)).toBe(true);
193+
expect(geoStylerScaleDenominatorFilter(maxOnly, 10000)).toBe(false);
194+
});
195+
196+
it('returns true when no scale filter or no mapViewScale provided', () => {
197+
expect(geoStylerScaleDenominatorFilter({}, 5000)).toBe(true);
198+
expect(geoStylerScaleDenominatorFilter({ scaleDenominator: {} }, 5000)).toBe(true);
199+
expect(geoStylerScaleDenominatorFilter({ scaleDenominator: { min: 1000 } }, undefined)).toBe(true);
200+
});
201+
202+
it('works with real-world rule structure', () => {
203+
const rule = {
204+
symbolizerId: "rule-001",
205+
kind: "Fill",
206+
color: "#3388FF",
207+
scaleDenominator: { min: 2500, max: 25000 }
208+
};
209+
expect(geoStylerScaleDenominatorFilter(rule, 1000)).toBe(false);
210+
expect(geoStylerScaleDenominatorFilter(rule, 10000)).toBe(true);
211+
expect(geoStylerScaleDenominatorFilter(rule, 50000)).toBe(false);
212+
});
213+
});
170214
});

0 commit comments

Comments
 (0)