From 841b34bd1b055630fb50c015c232eff42be8cd1c Mon Sep 17 00:00:00 2001 From: Nakouban CAMARA Date: Tue, 16 Dec 2025 11:06:51 +0100 Subject: [PATCH 1/3] Adapt Mapstore2 for Panoramax support map.forEachFeatureAtPixel in openlayers/Map.jsx adapted for detection of features of type RenderFeature used for MVT layers. By doing that, MVT layers are clickable and the intersected features are return on clic TileProviderLayer for being able to create MVT layer and display it. --- web/client/components/map/openlayers/Map.jsx | 44 ++++++++++++++++- .../openlayers/plugins/TileProviderLayer.js | 49 ++++++++++++++++++- 2 files changed, 91 insertions(+), 2 deletions(-) diff --git a/web/client/components/map/openlayers/Map.jsx b/web/client/components/map/openlayers/Map.jsx index 069c8f51b2..0feabef51b 100644 --- a/web/client/components/map/openlayers/Map.jsx +++ b/web/client/components/map/openlayers/Map.jsx @@ -13,6 +13,7 @@ import View from 'ol/View'; import { get as getProjection, toLonLat } from 'ol/proj'; import Zoom from 'ol/control/Zoom'; import GeoJSON from 'ol/format/GeoJSON'; +import {LineString, MultiLineString, MultiPoint, MultiPolygon, Polygon, Point} from "ol/geom"; import proj4 from 'proj4'; import { register } from 'ol/proj/proj4.js'; @@ -32,6 +33,8 @@ import 'ol/ol.css'; // add overrides for css import './mapstore-ol-overrides.css'; +import Feature from "ol/Feature"; +import RenderFeature from "ol/render/Feature"; const geoJSONFormat = new GeoJSON(); @@ -399,11 +402,49 @@ class OpenlayersMap extends React.Component { return view.getProjection().getExtent() || msGetProjection(props.projection).extent; }; + /** + * Compute the OLGeometry from a RenderedGeometry + * @param geomLike + * @returns {Point|MultiPoint|null|MultiLineString|LineString|*|Polygon|MultiPolygon} + */ + renderGeometryToOLGeometry = (geomLike) => { + if (!geomLike) return null; + // If it is a OLGeom, we return it as it is + if (typeof geomLike.clone === 'function') { + return geomLike; + } + const type = geomLike.getType?.(); + const coords = geomLike.getFlatCoordinates?.(); + if (!type || !coords) return null; + + switch (type) { + case 'Point': return new Point(coords); + case 'MultiPoint': return new MultiPoint([coords]); + case 'LineString': return new LineString(coords); + case 'MultiLineString': return new MultiLineString([coords]); + case 'Polygon': return new Polygon(coords); + case 'MultiPolygon': return new MultiPolygon([coords]); + default: return null; // types not supported + } + }; + getIntersectedFeatures = (map, pixel) => { let groupIntersectedFeatures = {}; map.forEachFeatureAtPixel(pixel, (feature, layer) => { if (layer?.get('msId')) { - const geoJSONFeature = geoJSONFormat.writeFeatureObject(feature, { + let olFeature = feature; + // Transform RenderFeature to an olFeature + // It is necessary to compute intersected features + // The MVT features are of type RenderFeature + if (feature instanceof RenderFeature) { + const geometry = this.renderGeometryToOLGeometry(feature.getGeometry()); + // If null, not supported cause we can't compute intersects + if (!geometry) return null; + olFeature = new Feature(geometry); + olFeature.setProperties(feature.getProperties()); + } + + const geoJSONFeature = geoJSONFormat.writeFeatureObject(olFeature, { featureProjection: this.props.projection, dataProjection: 'EPSG:4326' }); @@ -411,6 +452,7 @@ class OpenlayersMap extends React.Component { ? [ ...groupIntersectedFeatures[layer.get('msId')], geoJSONFeature ] : [ geoJSONFeature ]; } + return null; }); const intersectedFeatures = Object.keys(groupIntersectedFeatures).map(id => ({ id, features: groupIntersectedFeatures[id] })); return intersectedFeatures; diff --git a/web/client/components/map/openlayers/plugins/TileProviderLayer.js b/web/client/components/map/openlayers/plugins/TileProviderLayer.js index 34d6c4bda6..9e770f040e 100644 --- a/web/client/components/map/openlayers/plugins/TileProviderLayer.js +++ b/web/client/components/map/openlayers/plugins/TileProviderLayer.js @@ -9,11 +9,16 @@ import Layers from '../../../../utils/openlayers/Layers'; import TileProvider from '../../../../utils/TileConfigProvider'; import CoordinatesUtils from '../../../../utils/CoordinatesUtils'; import { getUrls, template } from '../../../../utils/TileProviderUtils'; +import VectorTileLayer from 'ol/layer/VectorTile'; +import VectorTileSource from 'ol/source/VectorTile'; +import MVT from 'ol/format/MVT'; import XYZ from 'ol/source/XYZ'; import TileLayer from 'ol/layer/Tile'; import axios from 'axios'; import { getCredentials } from '../../../../utils/SecurityUtils'; import { isEqual } from 'lodash'; +import {applyDefaultStyleToVectorLayer} from '../../../../utils/StyleUtils'; +import {getStyle} from '../VectorStyle'; function lBoundsToOlExtent(bounds, destPrj) { var [ [ miny, minx], [ maxy, maxx ] ] = bounds; return CoordinatesUtils.reprojectBbox([minx, miny, maxx, maxy], 'EPSG:4326', CoordinatesUtils.normalizeSRS(destPrj)); @@ -58,9 +63,51 @@ function tileXYZToOpenlayersOptions(options) { } Layers.registerType('tileprovider', { - create: (options) => { + create: (options, map) => { let [url, opt] = TileProvider.getLayerConfig(options.provider, options); opt.url = url; + const isMVT = options.format === 'application/vnd.mapbox-vector-tile'; + // specific case of mvt layers + if (isMVT) { + const source = new VectorTileSource({ + format: new MVT({}), + url: options.url, + maxZoom: options.maximumLevel ?? 22, + minZoom: options.minimumLevel ?? 0 + }); + + const layer = new VectorTileLayer({ + msId: options.id, + source, + visible: options.visibility !== false, + zIndex: options.zIndex, + opacity: options.opacity, + declutter: options.declutter ?? true, + preload: options.preload ?? 0, + cacheSize: options.cacheSize ?? 256, + tilePixelRatio: options.tilePixelRatio ?? 1, + renderBuffer: options.renderBuffer ?? 100, + renderMode: options.renderMode ?? 'hybrid' // or vector + }); + // MapStore Style (GeoStyler) if supported, otherwise Openlayers style + if (options.style) { + getStyle(applyDefaultStyleToVectorLayer({ ...options, asPromise: true })) + .then((style) => { + if (style) { + if (style.__geoStylerStyle) { + style({ map }).then((olStyle) => layer.setStyle(olStyle)); + } else { + layer.setStyle(style); // OL style (function/Style) + } + } + }); + } else if (options.olStyle) { + layer.setStyle(options.olStyle); // OL style directly set + } + + return layer; + } + // other cases keep working the same way return new TileLayer(tileXYZToOpenlayersOptions(opt)); }, update: (layer, newOptions, oldOptions) => { From be0e5a77c7058a37695b437f166fac144b7b8e07 Mon Sep 17 00:00:00 2001 From: Nakouban CAMARA Date: Tue, 16 Dec 2025 11:21:55 +0100 Subject: [PATCH 2/3] Add and configure Panoramax plugin in StreetView component - Create a viewer using pnx-photo-viewer to display pictures. - Add a Panoramax VectorTileLayer using OpenLayers styling to match other providers. - Handle click events: retrieve picture data directly from the layer datas, or call the API if the data is not found. --- package.json | 1 + web/client/plugins/StreetView/StreetView.jsx | 19 ++- web/client/plugins/StreetView/api/index.js | 5 +- .../plugins/StreetView/api/panoramax.js | 112 ++++++++++++++++++ .../PanoramaxView/PanoramaxView.jsx | 107 +++++++++++++++++ web/client/plugins/StreetView/constants.js | 9 +- .../containers/PanoramaxViewPanel.jsx | 18 +++ .../containers/StreetViewContainer.jsx | 4 +- .../plugins/StreetView/epics/streetView.js | 7 +- .../StreetView/selectors/streetView.js | 35 ++++++ 10 files changed, 311 insertions(+), 6 deletions(-) create mode 100644 web/client/plugins/StreetView/api/panoramax.js create mode 100644 web/client/plugins/StreetView/components/PanoramaxView/PanoramaxView.jsx create mode 100644 web/client/plugins/StreetView/containers/PanoramaxViewPanel.jsx diff --git a/package.json b/package.json index 5ad434495a..637b2042c5 100644 --- a/package.json +++ b/package.json @@ -114,6 +114,7 @@ "@mapbox/geojsonhint": "3.3.0", "@mapbox/togeojson": "0.16.2", "@mapstore/patcher": "https://github.com/geosolutions-it/Patcher/tarball/master", + "@panoramax/web-viewer": "4.2.0", "@turf/along": "6.5.0", "@turf/area": "6.5.0", "@turf/bbox": "4.1.0", diff --git a/web/client/plugins/StreetView/StreetView.jsx b/web/client/plugins/StreetView/StreetView.jsx index 0cda4d1b43..52d2d9de48 100644 --- a/web/client/plugins/StreetView/StreetView.jsx +++ b/web/client/plugins/StreetView/StreetView.jsx @@ -51,7 +51,7 @@ const StreetViewPluginContainer = connect(() => ({}), { * StreetView Plugin. Uses Google Street View services to provide the navigation of Google panoramic photos of street view service through the map. * @name StreetView * @memberof plugins - * @property {string} provider the Street View provider. Can be `google`, `cyclomedia` or `mapillary`. It is set to `google` by default. + * @property {string} provider the Street View provider. Can be `google`, `cyclomedia`, `mapillary` or `panoramax`. It is set to `google` by default. * @property {string} cfg.apiKey The API key to use. This is generically valid for all the providers. * It is Mandatory in production. Depending on the provider, this can be also configured globally: * - `google` provider: In order to allow fine billing strategies (with different API keys), the API key can be defined and customized here in this configuration option or in `localConfig.json` with the following order of priority: @@ -115,6 +115,23 @@ const StreetViewPluginContainer = connect(() => ({}), { * - `initOptions.doOAuthLogoutOnDestroy` (optional). If true, the plugin will logout from the StreetSmart API when the plugin is destroyed. Default: `false`. * - `mapillary` provider: * - `providerSettings.ApiURL` The URL of the the custom Geojson endpoint API. Currently is only supported a custom GeoJSON format. Example of endpoint is `https://hostname/directory-with-images/`, ensure the directory contains all the images and the index.json (GeoJSON) file + * - `panoramax` provider: + * Here an example, and below the details for every property: + * ```json + * { + * "provider": "panoramax", + * "providerSettings": { + * "srs": "EPSG:4326", + * "PanoramaxApiURL": "https://api.panoramax.xyz/api" + * "minimumLevel": 0, + * "maximumLevel": 15 + * } + * ``` + * - `providerSettings` (optional). The settings specific for the provider. It is an object with the following properties: + * - `providerSettings.PanoramaxApiURL` (optional). The URL of the Panoramax API. Default: `https://api.panoramax.xyz/api`. + * - `providerSettings.srs` (optional). Coordinate reference system code to use for the API. Default: `EPSG:4326`. Note that the SRS used here must be supported by the Panoramax API **and** defined in `localConfig.json` file, in `projectionDefs`. This param is not used yet, panoramax api is implemented to receive coordinates only in SRS EPSG:4326 + * - `providerSettings.minimumLevel` The minimum zoom level at which the provider can provide tiles. Default value is 0 + * - `providerSettings.maximumLevel` The maximul zoom level at which the provider can provide tiles Default value is 15 according to the openstreetmap and IGN Panoramax instances * Generally speaking, you should prefer general settings in `localConfig.json` over the plugin configuration, in order to reuse the same configuration for default viewer and all the contexts, automatically. This way you will not need to configure the `apiKey` in every context. *
**Important**: You can use only **one** API-key for a MapStore instance. The api-key can be configured replicated in every plugin configuration or using one of the unique global settings (suggested) in `localConfig.json`). @see {@link https://github.com/googlemaps/js-api-loader/issues/5|here} and @see {@link https://github.com/googlemaps/js-api-loader/issues/100|here} * @property {boolean} [cfg.useDataLayer=true] If true, adds to the map a layer for street view data availability when the plugin is turned on. diff --git a/web/client/plugins/StreetView/api/index.js b/web/client/plugins/StreetView/api/index.js index 298dfac0cc..4ea853b0e4 100644 --- a/web/client/plugins/StreetView/api/index.js +++ b/web/client/plugins/StreetView/api/index.js @@ -1,12 +1,14 @@ import * as google from '../api/google'; import * as cyclomedia from '../api/cyclomedia'; import * as mapillary from '../api/mapillary'; +import * as panoramax from '../api/panoramax'; /** * Street view APIs * @prop google google street view API * @prop cyclomedia cyclomedia street view API * @prop mapillary mapillary street view API + * @prop panoramax panoramax street view API * Each API has the following methods: * - `getAPI()`: returns the API object (specific to the provider) * - `loadAPI(apiKey)`: loads the API and returns a `Promise` that resolves when the API is loaded. Takes an `apiKey` as argument (depending on the provider) @@ -21,5 +23,6 @@ import * as mapillary from '../api/mapillary'; export default { google, cyclomedia, - mapillary + mapillary, + panoramax }; diff --git a/web/client/plugins/StreetView/api/panoramax.js b/web/client/plugins/StreetView/api/panoramax.js new file mode 100644 index 0000000000..024b28addb --- /dev/null +++ b/web/client/plugins/StreetView/api/panoramax.js @@ -0,0 +1,112 @@ +import axios from 'axios'; +import {PANORAMAX_DEFAULT_API_URL} from '../constants'; + +const DEFAULT_SRS = 'EPSG:4326'; + +/** + * Load the panoramax API. Does nothing for now but written to respect the same interface as the others plugins. + * If would be helpful to load the panoramax library but we loaded it dynamcally via npm package + * @returns {Promise} + */ +export const loadAPI = () => Promise.resolve(); + +/** + * Get the panoramax API + */ +export const getAPI = () => {}; + +/** + * Compute the bbox around the clicked point + * @param lat latitude + * @param lng longitude + * @returns {`${number},${number},${number},${number}`} + */ +const getBbox = (lat, lng) => { + // We reduce the search perimeter to a bbox of 20 meters around the clicked point + // We assume the geometry coordinates are in EPSG:4326 projection as expected by default by panoramax + const rayonMetres = 10; // 10m on each side = 20m wide + + // Latitude conversion factor + const metresParDegreLat = 111320; + + // 1. Calculation of Delta Lat (constant) + const deltaLat = rayonMetres / metresParDegreLat; + + // 2. Calculation of Delta Lng (variable depending on latitude) + const cosLat = Math.cos(lat * Math.PI / 180); // Convertir lat en radians + const deltaLng = rayonMetres / (metresParDegreLat * cosLat); + + // Bbox construction + return `${lng - deltaLng},${lat - deltaLat},${lng + deltaLng},${lat + deltaLat}`; +}; + +/** + * Search the feature properties at the clicked point + * @param point The coordinates of the clicked point + * @param providerSettings The provider settings + * @returns {Promise} + */ +export const getLocation = (point, providerSettings = {}) => { + return new Promise((resolve, reject) => { + if (point?.intersectedFeatures?.length) { + + // We are interested only in picture features (with id and sequence_id) + // At a certain zoom level (when the points are not displayed on the map but the lines) we encounter more sequence features instead of picture features + const feature = point.intersectedFeatures[0].features.find(ft => ft.properties.first_sequence && ft.properties.id); + if (feature) { + resolve({ + latLng: { lat: point.latlng.lat, lng: point.latlng.lng, h: 0 }, + // sequence_id and id will be passed to the panoramax viewer to get the corresponding picture + properties: {id: feature.properties.id, sequence_id: feature.properties.first_sequence} + }); + return; + } + } + + if (point) { + // In case where no picture feature is found + // We directly ask the API to get one feature near that point + // Panoramax config params + const apiUrl = providerSettings.PanoramaxApiURL || PANORAMAX_DEFAULT_API_URL; + + // Clic coordinates + const { lat, lng } = point.latlng; + + // TODO Implements transformation to the specified srs (can be get from the providerSettings), + // Now panoramax API only uses EPSG:4326 as coordinates projection + + // We calculate the bbox by constructing a rectangle with a radius of 10 m around the clicked point. + const bbox = getBbox(lat, lng); + + // The features will be filtered by their proximity to the clic position on for those located at a distance of 0 to 10m. + // By passing a bbox, the features are sorted by their proximity to the center of the bbox + // The param limit=1 is set to return only the closest feature to the center of the bbox + axios.get(`${apiUrl}/search`, { + params: { + bbox: bbox, + limit: 1 + } + }).then(response => { + // For now, the response features are of type Point only, so we assume that we receive points features as responses + const features = response.data?.features; + if (features && features.length > 0) { + const feature = features[0]; + const [fLng, fLat] = feature.geometry.coordinates; + resolve({ + latLng: { lat: fLat, lng: fLng, h: 0 }, + // sequence_id and id will be passed to the panoramax viewer to get the corresponding picture + properties: {id: feature.id, sequence_id: feature.collection} + }); + } else { + reject({ code: "ZERO_RESULTS" }); + } + }).catch(e => { + console.error(e); + reject({ code: "ZERO_RESULTS" }); + }); + return; + } + + reject({ code: "ZERO_RESULTS" }); + }); +}; diff --git a/web/client/plugins/StreetView/components/PanoramaxView/PanoramaxView.jsx b/web/client/plugins/StreetView/components/PanoramaxView/PanoramaxView.jsx new file mode 100644 index 0000000000..cd9195faaa --- /dev/null +++ b/web/client/plugins/StreetView/components/PanoramaxView/PanoramaxView.jsx @@ -0,0 +1,107 @@ +import React, { useEffect, useRef } from 'react'; +import '@panoramax/web-viewer'; +// Import of viewer's styles +import '@panoramax/web-viewer/build/index.css'; +import '@photo-sphere-viewer/core/index.css'; +import '@photo-sphere-viewer/markers-plugin/index.css'; +import '@photo-sphere-viewer/virtual-tour-plugin/index.css'; +import Message from "../../../locale/Message"; +import {PANORAMAX_DEFAULT_API_URL} from "../../constants"; + +const PanoramaxView = ( + { + style, + location, + providerSettings = {}, + setLocation, + setPov + }) => { + const viewerRef = useRef(null); + const endpoint = providerSettings.PanoramaxApiURL || PANORAMAX_DEFAULT_API_URL; + + // The photo ID is required + const pictureId = location?.properties?.id; + // The sequence ID is optional but recommended if available + // GeoVisio often returns "sequence_id" or it can be found in the links + const sequenceId = location?.properties.sequence_id || location?.properties?.sequence || undefined; + + useEffect(() => { + const element = viewerRef.current; + if (!element) return () => null; + + const handleReady = () => { + const psv = element.psv; + if (!psv) return; + + // Update of Point Of View (Angle/POV) + const onRotate = (e) => { + if (setPov && e.detail) { + // e.detail.x correspond to heading (0-360°) + // e.detail.y correspond to roll, the cursor used in mapstore does not show the roll + // e.detail.y correspond to pitch, the cursor used in mapstore does not show the pitch + setPov({ heading: e.detail.x, pitch: e.detail.y, zoom: e.detail.z }); + } + }; + + // Update cursor position on Map when moving on the pictures + const onPictureLoading = (e) => { + if (setLocation && e.detail) { + const { picId, lon, lat } = e.detail; + // Check the picture to change only the cursor position on picture change + if (location?.properties?.id !== picId) { + setLocation({ + latLng: { lat, lng: lon, h: 0 }, + properties: { id: picId } + }); + } + } + }; + + // Binding of the listeners to the panoramax viewer + psv.addEventListener('view-rotated', onRotate); + psv.addEventListener('picture-loading', onPictureLoading); + + // Cleaning of the listerners on completed actions + element._cleanupListeners = () => { + psv.removeEventListener('view-rotated', onRotate); + psv.removeEventListener('picture-loading', onPictureLoading); + }; + }; + + // The component emits 'ready' when its loading is complete + element.addEventListener('ready', handleReady); + + // Handling case whn the component is already ready + if (element.psv) { + handleReady(); + } + + return () => { + element.removeEventListener('ready', handleReady); + if (element._cleanupListeners) { + element._cleanupListeners(); + } + }; + }, [setLocation, setPov, location?.properties?.id]); + + if (!pictureId) { + return
; + } + + return ( +
+ + + +
+ ); +}; + +export default PanoramaxView; diff --git a/web/client/plugins/StreetView/constants.js b/web/client/plugins/StreetView/constants.js index 09f127c899..5d7d2fc0d1 100644 --- a/web/client/plugins/StreetView/constants.js +++ b/web/client/plugins/StreetView/constants.js @@ -10,7 +10,8 @@ export const STREET_VIEW_DATA_LAYER_ID = "street-view-data"; export const PROVIDERS = { GOOGLE: 'google', CYCLOMEDIA: 'cyclomedia', - MAPILLARY: "mapillary" + MAPILLARY: "mapillary", + PANORAMAX: "panoramax" }; // /////////////////////////////// @@ -24,3 +25,9 @@ export const CYCLOMEDIA_DEFAULT_MAX_RESOLUTION = 1; * Unique key to find the credentials in the global state */ export const CYCLOMEDIA_CREDENTIALS_REFERENCE = "__cycloMedia_streetView_source_credential__"; + + +// /////////////////////////////// +// PANORAMAX SPECIFIC CONSTANTS +// /////////////////////////////// +export const PANORAMAX_DEFAULT_API_URL = "https://api.panoramax.xyz/api"; diff --git a/web/client/plugins/StreetView/containers/PanoramaxViewPanel.jsx b/web/client/plugins/StreetView/containers/PanoramaxViewPanel.jsx new file mode 100644 index 0000000000..5fe9978d53 --- /dev/null +++ b/web/client/plugins/StreetView/containers/PanoramaxViewPanel.jsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { createStructuredSelector } from 'reselect'; +import { setLocation, setPov } from '../actions/streetView'; + +import { locationSelector } from '../selectors/streetView'; +import PanoramaxView from '../components/PanoramaxView/PanoramaxView'; + +const PanoramaxViewPanel = connect(createStructuredSelector({ + location: locationSelector +}), { + setLocation, + setPov +})((props) => { + return ; +}); + +export default PanoramaxViewPanel; diff --git a/web/client/plugins/StreetView/containers/StreetViewContainer.jsx b/web/client/plugins/StreetView/containers/StreetViewContainer.jsx index 5da17b6e42..41cae17be5 100644 --- a/web/client/plugins/StreetView/containers/StreetViewContainer.jsx +++ b/web/client/plugins/StreetView/containers/StreetViewContainer.jsx @@ -11,12 +11,14 @@ import { enabledSelector } from '../selectors/streetView'; import GStreetViewPanel from './GStreetViewPanel'; import CyclomediaViewPanel from './CyclomediaViewPanel'; import MapillaryViewPanel from './MapillaryViewPanel'; +import PanoramaxViewPanel from './PanoramaxViewPanel'; import { toggleStreetView } from '../actions/streetView'; const panels = { google: GStreetViewPanel, cyclomedia: CyclomediaViewPanel, - mapillary: MapillaryViewPanel + mapillary: MapillaryViewPanel, + panoramax: PanoramaxViewPanel }; diff --git a/web/client/plugins/StreetView/epics/streetView.js b/web/client/plugins/StreetView/epics/streetView.js index b648f9809c..7009d81e31 100644 --- a/web/client/plugins/StreetView/epics/streetView.js +++ b/web/client/plugins/StreetView/epics/streetView.js @@ -23,7 +23,8 @@ import { enabledSelector, getStreetViewDataLayer, useStreetViewDataLayerSelector, - streetViewDataLayerSelector + streetViewDataLayerSelector, + streetViewConfigurationSelector } from "../selectors/streetView"; import {setLocation, UPDATE_STREET_VIEW_LAYER } from '../actions/streetView'; import API from '../api'; @@ -156,8 +157,10 @@ export const streetViewMapClickHandler = (action$, {getState = () => {}}) => { error({title: "streetView.title", message: "streetView.messages.providerNotSupported"}) ); } + // we need to pass the providerSetting to the api getLocation method, where it is need to get the search feature api url + const providerSettings = streetViewConfigurationSelector(getState())?.providerSettings; return Rx.Observable - .defer(() => getLocation(point)) + .defer(() => getLocation(point, providerSettings)) .map(setLocation) .catch((e) => { if (e.code === "ZERO_RESULTS") { diff --git a/web/client/plugins/StreetView/selectors/streetView.js b/web/client/plugins/StreetView/selectors/streetView.js index 6b781a2d66..3c27d15085 100644 --- a/web/client/plugins/StreetView/selectors/streetView.js +++ b/web/client/plugins/StreetView/selectors/streetView.js @@ -5,6 +5,7 @@ import { PROVIDERS, CONTROL_NAME, MARKER_LAYER_ID, STREET_VIEW_DATA_LAYER_ID, CY import { createControlEnabledSelector } from '../../../selectors/controls'; import { additionalLayersSelector } from '../../../selectors/additionallayers'; import { localConfigSelector } from '../../../selectors/localConfig'; +import {Style, Stroke, Fill, Circle as CircleStyle} from 'ol/style'; export const enabledSelector = createControlEnabledSelector(CONTROL_NAME); export const streetViewStateSelector = state => state?.streetView ?? {}; @@ -95,6 +96,14 @@ const getVectorStyle = (overrides) => ({ ] } }); + +const getPanoramaxMVTStyle = () => { + // For a better integration we use the same radius, width, color and opacity as the geostyler style + return new Style({ + stroke: new Stroke({ color: '#3165EFCC', width: 2}), + image: new CircleStyle({ radius: 6, fill: new Fill({ color: '#3165EF99' }) }) + }); +}; const GOOGLE_DATA_LAYER_DEFAULTS = { provider: 'custom', type: "tileprovider", @@ -120,6 +129,22 @@ const CYCLOMEDIA_DATA_LAYER_DEFAULTS = { const MAPILLARY_DATA_LAYER_DEFAULTS = { type: 'vector' }; + +const PANORAMAX_DATA_LAYER_DEFAULTS = { + type: 'tileprovider', // utilise le plugin TileProvider OpenLayers + provider: 'custom', // “custom” pour donner directement une URL + url: 'https://panoramax.openstreetmap.fr/api/map/{z}/{x}/{y}.mvt', + format: 'application/vnd.mapbox-vector-tile', // signale que c’est du MVT + name: 'panoramax:sequences', + visibility: true, + // optionnel: bornes de zoom + minimumLevel: 0, + // Panoramax instances such as IGN and openstreetmap don't have tiles above zoom level 15 + // By setting 15 as max zoom level, if the map is at a level above 15, the tiles at level 15 will be loaded and then overzoomed + maximumLevel: 15, + olStyle: getPanoramaxMVTStyle(), // we can't use getVectorStyle() because it only applies to olFeature and the MVT features are of type RenderFeature + attribution: 'Panoramax' +}; /** * Gets the default data layer configuration for the current provider. * @memberof selectors.streetview @@ -144,6 +169,16 @@ const providerDataLayerDefaultsSelector = createSelector( style: getVectorStyle({ msHeightReference }), url: configuration?.providerSettings?.ApiURL }; + case PROVIDERS.PANORAMAX: + const tilesUrl = configuration?.providerSettings?.PanoramaxApiURL ? configuration?.providerSettings?.PanoramaxApiURL + '/map/{z}/{x}/{y}.mvt' : PANORAMAX_DATA_LAYER_DEFAULTS.url; + const tilesZoomLevel = {min: configuration?.providerSettings?.minimumLevel, max: configuration?.providerSettings?.maximumLevel}; + return { + ...PANORAMAX_DATA_LAYER_DEFAULTS, + // Use specific url or default layer url + url: tilesUrl, + minimumLevel: tilesZoomLevel.min ?? PANORAMAX_DATA_LAYER_DEFAULTS.minimumLevel, + maximumLevel: tilesZoomLevel.max ?? PANORAMAX_DATA_LAYER_DEFAULTS.maximumLevel + }; default: return {}; } From 6e7f4bfb8c00acece452e19e126aa3c72febb0ef Mon Sep 17 00:00:00 2001 From: Nakouban CAMARA Date: Tue, 16 Dec 2025 11:36:08 +0100 Subject: [PATCH 3/3] Translate panoramax emptySelection message in French, English, Spanish, Italian and German --- web/client/translations/data.de-DE.json | 3 +++ web/client/translations/data.en-US.json | 3 +++ web/client/translations/data.es-ES.json | 3 +++ web/client/translations/data.fr-FR.json | 4 +++- web/client/translations/data.it-IT.json | 3 +++ 5 files changed, 15 insertions(+), 1 deletion(-) diff --git a/web/client/translations/data.de-DE.json b/web/client/translations/data.de-DE.json index 7f911e160e..e1ecfbd4ae 100644 --- a/web/client/translations/data.de-DE.json +++ b/web/client/translations/data.de-DE.json @@ -4300,6 +4300,9 @@ "projectionNotAvailable": "Die konfigurierte Projektion {srs} ist nicht verfügbar. Bitte kontaktieren Sie den Administrator." }, "reloadAPI": "API neu laden" + }, + "panoramax": { + "emptySelection": "Sie haben kein Bild ausgewählt." } }, "sidebarMenu": { diff --git a/web/client/translations/data.en-US.json b/web/client/translations/data.en-US.json index c72531bf2a..6650be0c82 100644 --- a/web/client/translations/data.en-US.json +++ b/web/client/translations/data.en-US.json @@ -4271,6 +4271,9 @@ "projectionNotAvailable": "The projection {srs} configured is not available. Please contact the administrator." }, "reloadAPI": "Reload API" + }, + "panoramax": { + "emptySelection": "You have not selected an image." } }, "sidebarMenu": { diff --git a/web/client/translations/data.es-ES.json b/web/client/translations/data.es-ES.json index cfe74e30b6..c19efc7cfc 100644 --- a/web/client/translations/data.es-ES.json +++ b/web/client/translations/data.es-ES.json @@ -4261,6 +4261,9 @@ "projectionNotAvailable": "La proyección {srs} configurada no está disponible. Por favor, contacte al administrador." }, "reloadAPI": "Recargar API" + }, + "panoramax": { + "emptySelection": "No ha seleccionado ninguna imagen." } }, "sidebarMenu": { diff --git a/web/client/translations/data.fr-FR.json b/web/client/translations/data.fr-FR.json index fd691925a7..1c7fba7579 100644 --- a/web/client/translations/data.fr-FR.json +++ b/web/client/translations/data.fr-FR.json @@ -4261,7 +4261,9 @@ "invalidCredentials": "Identifiants invalides", "projectionNotAvailable": "La projection {srs} configurée n'est pas disponible. Veuillez contacter l'administrateur." }, - "reloadAPI": "Recharger l'API" + "reloadAPI": "Recharger l'API"}, + "panoramax": { + "emptySelection": "Vous n'avez pas sélectionné d'image." } }, "sidebarMenu": { diff --git a/web/client/translations/data.it-IT.json b/web/client/translations/data.it-IT.json index 746d18460d..d1fd64bd14 100644 --- a/web/client/translations/data.it-IT.json +++ b/web/client/translations/data.it-IT.json @@ -4263,6 +4263,9 @@ "projectionNotAvailable": "La proiezione {srs} configurata non è disponibile. Contattare l'amministratore." }, "reloadAPI": "Ricarica API" + }, + "panoramax": { + "emptySelection": "Non hai selezionato alcuna immagine." } }, "sidebarMenu": {