diff --git a/web/client/components/map/cesium/plugins/WFSLayer.js b/web/client/components/map/cesium/plugins/WFSLayer.js index 0153bc8858c..8ee6c1bc352 100644 --- a/web/client/components/map/cesium/plugins/WFSLayer.js +++ b/web/client/components/map/cesium/plugins/WFSLayer.js @@ -70,7 +70,8 @@ const createLayer = (options, map) => { id: options?.id, map: map, opacity: options.opacity, - queryable: options.queryable === undefined || options.queryable + queryable: options.queryable === undefined || options.queryable, + styleRules: options?.style?.body?.rules || [] }); let loader; let loadingBbox; @@ -187,6 +188,11 @@ Layers.registerType('wfs', { return createLayer(newOptions, map); } if (layer?.styledFeatures && !isEqual(newOptions.style, oldOptions.style)) { + // update style rules here + if (!isEqual(newOptions?.style?.body?.rules, oldOptions?.style?.body?.rules)) { + let styleRules = newOptions?.style?.body?.rules || []; + layer.styledFeatures._setStyleRules(styleRules); + } layerToGeoStylerStyle(newOptions) .then((style) => { getStyle(applyDefaultStyleToVectorLayer({ diff --git a/web/client/plugins/styleeditor/VectorStyleEditor.jsx b/web/client/plugins/styleeditor/VectorStyleEditor.jsx index 97d9d2323a2..13f695ad244 100644 --- a/web/client/plugins/styleeditor/VectorStyleEditor.jsx +++ b/web/client/plugins/styleeditor/VectorStyleEditor.jsx @@ -32,7 +32,7 @@ import { classifyGeoJSON, availableMethods } from '../../api/GeoJSONClassificati import { getLayerJSONFeature } from '../../observables/wfs'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; -import { scalesSelector } from '../../selectors/map'; +import { currentZoomLevelSelector, scalesSelector } from '../../selectors/map'; const { getColors } = SLDService; @@ -88,7 +88,8 @@ function VectorStyleEditor({ 'Brush Script MT' ], onUpdateNode = () => {}, - scales = [] + scales = [], + zoom = 0 }) { const request = capabilitiesRequest[layer?.type]; @@ -258,12 +259,14 @@ function VectorStyleEditor({ supportedSymbolizerMenuOptions: ['Simple', 'Extrusion', 'Classification'], fonts, enableFieldExpression: ['vector', 'wfs'].includes(layer.type), - scales + scales, + zoom: Math.round(zoom) // passing this for showing arrow of current scale for ScaleDenominator }} /> ); } -const ConnectedVectorStyleEditor = connect(createSelector([scalesSelector], (scales) => ({ - scales: scales.map(scale => Math.round(scale)) +const ConnectedVectorStyleEditor = connect(createSelector([scalesSelector, currentZoomLevelSelector], (scales, zoom) => ({ + scales: scales.map(scale => Math.round(scale)), + zoom })))(VectorStyleEditor); export default ConnectedVectorStyleEditor; diff --git a/web/client/utils/MapUtils.js b/web/client/utils/MapUtils.js index a8c775c0675..8177c1a534a 100644 --- a/web/client/utils/MapUtils.js +++ b/web/client/utils/MapUtils.js @@ -348,6 +348,25 @@ export function getScale(projection, dpi, resolution) { return resolution * dpu; } +/** + * Checks if the camera is looking perpendicular (nadir) to the surface + * @param {Cesium.Camera} camera - The Cesium camera + * @param {Cesium.Cartesian3} position - Position on the globe (Cartesian3) + * @param {Cesium.Ellipsoid} ellipsoid - The ellipsoid (usually scene.globe.ellipsoid) + * @param {number} threshold - Cosine threshold (0.95 = ~18°, 0.99 = ~8°) + * @returns {boolean} True if camera is approximately perpendicular + */ +export function isCameraPerpendicularToSurface(camera, position, ellipsoid, threshold = 0.95) { + const surfaceNormal = ellipsoid.geodeticSurfaceNormal(position); + const cameraDirection = camera.direction; + + // Dot product: -1 = exactly opposite (straight down), 0 = parallel to surface + const dot = Cesium.Cartesian3.dot(cameraDirection, surfaceNormal); + + // Check if dot product is close to -1 (camera looking straight down) + return dot < -threshold; +} + /** * Calculates the map scale denominator at the center of the Cesium viewer's screen. * @@ -362,7 +381,7 @@ export function getMapScaleForCesium(viewer) { const scene = viewer.scene; const camera = scene.camera; const canvas = scene.canvas; - + const ellipsoid = scene.globe.ellipsoid; // 1. Get two points at the center of the screen, 1 pixel apart horizontally const centerX = Math.floor(canvas.clientWidth / 2); const centerY = Math.floor(canvas.clientHeight / 2); @@ -377,15 +396,17 @@ export function getMapScaleForCesium(viewer) { const leftPos = scene.globe.pick(leftRay, scene); const rightPos = scene.globe.pick(rightRay, scene); + // Check if camera is perpendicular (only if we have a valid position to test against) + const isPerpendicular = Cesium.defined(leftPos) ? isCameraPerpendicularToSurface(camera, leftPos, ellipsoid, 0.95) : false; - if (!Cesium.defined(leftPos) || !Cesium.defined(rightPos)) { - console.warn('Camera is looking at space/sky'); - const cameraPosition = viewer.camera.positionCartographic; + if (!Cesium.defined(leftPos) || !Cesium.defined(rightPos) || isPerpendicular) { + console.warn('Camera is looking at space/sky or is perpendicular'); + const cameraPosition = camera.positionCartographic; const currentZoom = Math.log2(FALLBACK_EARTH_CIRCUMFERENCE_METERS / (cameraPosition.height)) + 1; const resolutions = getResolutions(); const resolution = resolutions[Math.round(currentZoom)]; const scaleVal = getScale(cesiumDefaultProj, DEFAULT_SCREEN_DPI, resolution); - return scaleVal; + return Math.round(scaleVal ?? 0); } const leftCartographic = scene.globe.ellipsoid.cartesianToCartographic(leftPos); @@ -394,7 +415,7 @@ export function getMapScaleForCesium(viewer) { const geodesic = new Cesium.EllipsoidGeodesic(leftCartographic, rightCartographic); const resolution = geodesic.surfaceDistance; // This is meters per 1 pixel [resolution] const scaleValue = getScale(cesiumDefaultProj, DEFAULT_SCREEN_DPI, resolution); - return scaleValue; + return Math.round(scaleValue ?? 0); } /** * get random coordinates within CRS extent diff --git a/web/client/utils/__tests__/MapUtils-test.js b/web/client/utils/__tests__/MapUtils-test.js index 16144030bfa..dce8a922e60 100644 --- a/web/client/utils/__tests__/MapUtils-test.js +++ b/web/client/utils/__tests__/MapUtils-test.js @@ -6,6 +6,7 @@ * LICENSE file in the root directory of this source tree. */ import expect from 'expect'; +import * as Cesium from 'cesium'; import { keys, sortBy } from 'lodash'; @@ -47,7 +48,9 @@ import { recursiveIsChangedWithRules, filterFieldByRules, prepareObjectEntries, - parseFieldValue + parseFieldValue, + isCameraPerpendicularToSurface, + getMapScaleForCesium } from '../MapUtils'; import { VisualizationModes } from '../MapTypeUtils'; @@ -2560,3 +2563,127 @@ describe('prepareObjectEntries', () => { expect(entries).toEqual([]); }); }); +describe('test calc scale utils for cesium', () => { + it('isCameraPerpendicularToSurface - nadir (straight down) returns true', () => { + const ellipsoid = Cesium.Ellipsoid.WGS84; + const position = Cesium.Cartesian3.fromDegrees(0, 0); + const surfaceNormal = ellipsoid.geodeticSurfaceNormal(position); + + // Camera direction opposite to surface normal = looking straight down + const camera = { + direction: Cesium.Cartesian3.negate(surfaceNormal, new Cesium.Cartesian3()) + }; + + const result = isCameraPerpendicularToSurface(camera, position, ellipsoid, 0.95); + expect(result).toBe(true); + }); + + it('isCameraPerpendicularToSurface - oblique angle returns false', () => { + const ellipsoid = Cesium.Ellipsoid.WGS84; + const position = Cesium.Cartesian3.fromDegrees(0, 0); + + // Tilted camera direction (~45° from nadir, dot ≈ -0.7) + const camera = { + direction: new Cesium.Cartesian3(0, -0.7, -0.7) + }; + + const result = isCameraPerpendicularToSurface(camera, position, ellipsoid, 0.95); + expect(result).toBe(false); + }); + + it('isCameraPerpendicularToSurface - horizontal view returns false', () => { + const ellipsoid = Cesium.Ellipsoid.WGS84; + const position = Cesium.Cartesian3.fromDegrees(0, 0); + + // Camera looking horizontally (dot = 0) + const camera = { + direction: new Cesium.Cartesian3(1, 0, 0) + }; + + const result = isCameraPerpendicularToSurface(camera, position, ellipsoid, 0.95); + expect(result).toBe(false); + }); + + it('getMapScaleForCesium - returns number when camera looks at sky', () => { + // Minimal stub: globe.pick returns undefined = looking at sky + const mockViewer = { + scene: { + canvas: { clientWidth: 800, clientHeight: 600 }, + camera: { + direction: new Cesium.Cartesian3(0, 0, 1), + positionCartographic: { height: 10000 }, + getPickRay: () => ({}) + }, + globe: { + ellipsoid: Cesium.Ellipsoid.WGS84, + pick: () => undefined + } + } + }; + + const result = getMapScaleForCesium(mockViewer); + + expect(typeof result).toBe('number'); + expect(Number.isInteger(result)).toBe(true); + expect(result).toBeGreaterThan(0); + }); + + it('getMapScaleForCesium - returns number with valid positions + perpendicular camera', () => { + // Minimal stub: valid positions + perpendicular camera → triggers fallback by design + const mockPos = Cesium.Cartesian3.fromDegrees(0, 0, 0); + const mockViewer = { + scene: { + canvas: { clientWidth: 800, clientHeight: 600 }, + camera: { + direction: new Cesium.Cartesian3(0, 0, -1), // straight down + positionCartographic: { height: 5000 }, + getPickRay: () => ({}) + }, + globe: { + ellipsoid: Cesium.Ellipsoid.WGS84, + pick: () => mockPos, + cartesianToCartographic: () => ({ + longitude: 0, + latitude: 0, + height: 0 + }) + } + } + }; + + const result = getMapScaleForCesium(mockViewer); + + expect(typeof result).toBe('number'); + expect(Number.isInteger(result)).toBe(true); + }); + + it('getMapScaleForCesium - returns number with valid positions + oblique camera', () => { + // Minimal stub: valid positions + oblique camera → uses EllipsoidGeodesic path + const mockPos = Cesium.Cartesian3.fromDegrees(0, 0, 0); + const mockViewer = { + scene: { + canvas: { clientWidth: 800, clientHeight: 600 }, + camera: { + direction: new Cesium.Cartesian3(0, -0.5, -0.8), // tilted + positionCartographic: { height: 5000 }, + getPickRay: () => ({}) + }, + globe: { + ellipsoid: Cesium.Ellipsoid.WGS84, + pick: () => mockPos, + cartesianToCartographic: () => ({ + longitude: 0, + latitude: 0, + height: 0 + }) + } + } + }; + + const result = getMapScaleForCesium(mockViewer); + + expect(typeof result).toBe('number'); + expect(Number.isInteger(result)).toBe(true); + }); +}); + diff --git a/web/client/utils/styleparser/OLStyleParser.js b/web/client/utils/styleparser/OLStyleParser.js index 65259d90250..7abc9f3ff13 100644 --- a/web/client/utils/styleparser/OLStyleParser.js +++ b/web/client/utils/styleparser/OLStyleParser.js @@ -66,6 +66,7 @@ import { drawIcons } from './IconUtils'; import isString from 'lodash/isString'; import { geometryFunctionsLibrary } from './GeometryFunctionsUtils'; +import { DEFAULT_SCREEN_DPI, getScale } from '../MapUtils'; const getGeometryFunction = geometryFunctionsLibrary.openlayers({ Point: OlGeomPoint, @@ -443,14 +444,9 @@ export class OlStyleParser { this._getMap = () => map; const styles = []; - // calculate scale for resolution (from ol-util MapUtil) - const units = map - ? map.getView().getProjection().getUnits() - : 'm'; - const dpi = 25.4 / 0.28; - const mpu = METERS_PER_UNIT[units]; - const inchesPerMeter = 39.37; - const scale = resolution * mpu * inchesPerMeter * dpi; + // instead of using ol0util MapUtil -> calc. scale value using getScale() to be matched with the predefined scale list values + const projection = map?.getView()?.getProjection()?.getCode() || "EPSG:3857"; + const scale = Math.round(getScale(projection, DEFAULT_SCREEN_DPI, resolution)); rules.forEach((rule) => { // handling scale denominator diff --git a/web/client/utils/styleparser/StyleParserUtils.js b/web/client/utils/styleparser/StyleParserUtils.js index a10bc345d16..9e9684a73ee 100644 --- a/web/client/utils/styleparser/StyleParserUtils.js +++ b/web/client/utils/styleparser/StyleParserUtils.js @@ -442,7 +442,7 @@ export const geoStylerScaleDenominatorFilter = (rule = {}, mapViewScale) => { if (rule?.scaleDenominator && mapViewScale) { const {min, max} = rule?.scaleDenominator; if ((min !== undefined && mapViewScale < min) || - (max !== undefined && mapViewScale > max)) { + (max !== undefined && mapViewScale >= max)) { return false; } } diff --git a/web/client/utils/styleparser/__tests__/StyleParserUtils-test.js b/web/client/utils/styleparser/__tests__/StyleParserUtils-test.js index 193254a813c..70522484564 100644 --- a/web/client/utils/styleparser/__tests__/StyleParserUtils-test.js +++ b/web/client/utils/styleparser/__tests__/StyleParserUtils-test.js @@ -171,11 +171,11 @@ describe("StyleParserUtils ", () => { // tests for geoStylerScaleDenominatorFilter describe('geoStylerScaleDenominatorFilter', () => { - it('returns true when scale is within [min, max] range', () => { + it('returns true when scale is within [min, max] range -> max excluded and min included', () => { const rule = { scaleDenominator: { min: 1000, max: 10000 } }; + expect(geoStylerScaleDenominatorFilter(rule, 1000)).toBe(true); // min included expect(geoStylerScaleDenominatorFilter(rule, 5000)).toBe(true); - expect(geoStylerScaleDenominatorFilter(rule, 1000)).toBe(true); - expect(geoStylerScaleDenominatorFilter(rule, 10000)).toBe(true); + expect(geoStylerScaleDenominatorFilter(rule, 10000)).toBe(false); // max is excluded }); it('returns false when scale is outside [min, max] range', () => { @@ -207,8 +207,8 @@ describe("StyleParserUtils ", () => { color: "#3388FF", scaleDenominator: { min: 2500, max: 25000 } }; - expect(geoStylerScaleDenominatorFilter(rule, 10000)).toBe(true); expect(geoStylerScaleDenominatorFilter(rule, 1000)).toBe(false); + expect(geoStylerScaleDenominatorFilter(rule, 10000)).toBe(true); expect(geoStylerScaleDenominatorFilter(rule, 50000)).toBe(false); }); });