diff --git a/build/docma-config.json b/build/docma-config.json index 5f6464212b..8f9bbdc103 100644 --- a/build/docma-config.json +++ b/build/docma-config.json @@ -261,6 +261,7 @@ "web/client/plugins/LayerInfo.jsx", "web/client/plugins/Locate.jsx", "web/client/plugins/Login.jsx", + "web/client/plugins/LongitudinalProfileTool.jsx", "web/client/plugins/Map.jsx", "web/client/plugins/MapCatalog.jsx", "web/client/plugins/MapEditor.jsx", diff --git a/docs/developer-guide/maps-configuration.md b/docs/developer-guide/maps-configuration.md index 4cf92dfc45..b0764cb5e2 100644 --- a/docs/developer-guide/maps-configuration.md +++ b/docs/developer-guide/maps-configuration.md @@ -1046,6 +1046,7 @@ The `symbolizer` could be of following `kinds`: | `size` | size of the icon | x | x | | `opacity` | opacity of the icon | x | x | | `rotate` | rotation of the icon | x | x | + | `anchor` | array of values defined in fractions [0 to 1] | x | x | | `msBringToFront` | this boolean will allow setting the **disableDepthTestDistance** value for the feature. This would | | x | | `msHeightReference` | reference to compute the distance of the point geometry, one of **none**, **ground** or **clamp** | | x | | `msHeight` | height of the point, the original geometry is applied if undefined | | x | diff --git a/package.json b/package.json index 3ef95b4243..5963a32288 100644 --- a/package.json +++ b/package.json @@ -144,6 +144,7 @@ "@turf/convex": "6.5.0", "@turf/difference": "6.5.0", "@turf/great-circle": "5.1.5", + "@turf/helpers": "6.5.0", "@turf/inside": "4.1.0", "@turf/line-intersect": "4.1.0", "@turf/point-on-surface": "4.1.0", @@ -173,6 +174,7 @@ "draft-js-plugins-editor": "2.1.1", "draft-js-side-toolbar-plugin": "3.0.1", "draftjs-to-html": "0.8.4", + "dxf-parser": "1.1.2", "element-closest": "2.0.2", "embed-video": "2.0.4", "es6-object-assign": "1.1.0", @@ -184,6 +186,7 @@ "git-revision-webpack-plugin": "5.0.0", "history": "4.6.1", "html-to-draftjs": "npm:@geosolutions/html-to-draftjs@1.5.1", + "html-to-image": "1.11.11", "html2canvas": "0.5.0-beta4", "immutable": "4.0.0-rc.12", "intersection-observer": "0.7.0", @@ -217,6 +220,7 @@ "object-fit-images": "3.2.4", "ogc-schemas": "2.6.1", "ol": "5.3.0", + "pdfmake": "0.2.7", "pdfviewer": "0.3.2", "plotly.js-cartesian-dist": "2.5.1", "prop-types": "15.7.2", diff --git a/web/client/actions/__tests__/longitudinalPfofile-test.js b/web/client/actions/__tests__/longitudinalPfofile-test.js new file mode 100644 index 0000000000..86522b2f0b --- /dev/null +++ b/web/client/actions/__tests__/longitudinalPfofile-test.js @@ -0,0 +1,139 @@ +/* + * Copyright 2022, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. +*/ +import expect from "expect"; + +import {CONTROL_DOCK_NAME} from '../../plugins/longitudinalProfile/constants'; +import {SET_CONTROL_PROPERTY} from "../controls"; +import { + ADD_PROFILE_DATA, + addProfileData, + CHANGE_DISTANCE, + CHANGE_GEOMETRY, + CHANGE_REFERENTIAL, + changeDistance, + changeGeometry, + changeReferential, + closeDock, + initialized, + INITIALIZED, + loading, + LOADING, + openDock, + setupPlugin, + SETUP, + TEAR_DOWN, + tearDown, + TOGGLE_MAXIMIZE, + TOGGLE_MODE, + toggleMaximize, + toggleMode +} from "../longitudinalProfile"; + +describe('Test correctness of the actions', () => { + it('setup', () => { + const config = {configProp1: 'example', configProp2: 'test'}; + const action = setupPlugin(config); + expect(action).toExist(); + expect(action.type).toBe(SETUP); + expect(action.config).toEqual(config); + }); + + it('initialized', () => { + const action = initialized(); + expect(action).toExist(); + expect(action.type).toBe(INITIALIZED); + }); + + it('tearDown', () => { + const action = tearDown(); + expect(action).toExist(); + expect(action.type).toBe(TEAR_DOWN); + }); + + it('openDock', () => { + const action = openDock(); + expect(action).toExist(); + expect(action.type).toBe(SET_CONTROL_PROPERTY); + expect(action.control).toBe(CONTROL_DOCK_NAME); + expect(action.property).toBe('enabled'); + expect(action.value).toBe(true); + }); + + it('closeDock', () => { + const action = closeDock(); + expect(action).toExist(); + expect(action.type).toBe(SET_CONTROL_PROPERTY); + expect(action.control).toBe(CONTROL_DOCK_NAME); + expect(action.property).toBe('enabled'); + expect(action.value).toBe(false); + }); + + it('toggleMode - no mode passed', () => { + const action = toggleMode(); + expect(action).toExist(); + expect(action.type).toBe(TOGGLE_MODE); + expect(action.mode).toBe(undefined); + }); + + it('toggleMode - mode passed', () => { + const action = toggleMode('draw'); + expect(action).toExist(); + expect(action.type).toBe(TOGGLE_MODE); + expect(action.mode).toBe('draw'); + }); + + it('addProfileData', () => { + const infos = { prop1: true, prop2: 10, prop3: 'test'}; + const points = [[[1, 2, 5], [2, 3, 5]]]; + const projection = 'EPSG:3857'; + const action = addProfileData(infos, points, projection); + expect(action).toExist(); + expect(action.type).toBe(ADD_PROFILE_DATA); + expect(action.infos).toEqual(infos); + expect(action.points[0]).toEqual(points[0]); + expect(action.points[1]).toEqual(points[1]); + expect(action.projection).toEqual(projection); + }); + + it('loading', () => { + const action = loading(true); + expect(action).toExist(); + expect(action.type).toBe(LOADING); + expect(action.state).toBe(true); + }); + + it('changeReferential', () => { + const action = changeReferential('ref2'); + expect(action).toExist(); + expect(action.type).toBe(CHANGE_REFERENTIAL); + expect(action.referential).toBe('ref2'); + }); + + it('changeDistance', () => { + const action = changeDistance(200); + expect(action).toExist(); + expect(action.type).toBe(CHANGE_DISTANCE); + expect(action.distance).toBe(200); + }); + + it('changeGeometry', () => { + const geometry = { point: [2, 5], center: [1, 1]}; + const action = changeGeometry(geometry); + expect(action).toExist(); + expect(action.type).toBe(CHANGE_GEOMETRY); + expect(action.geometry).toExist(); + expect(action.geometry.point).toEqual([2, 5]); + expect(action.geometry.center).toEqual([1, 1]); + }); + + it('toggleMaximize', () => { + const action = toggleMaximize(); + expect(action).toExist(); + expect(action.type).toBe(TOGGLE_MAXIMIZE); + }); +}); diff --git a/web/client/actions/longitudinalProfile.js b/web/client/actions/longitudinalProfile.js new file mode 100644 index 0000000000..b275b2f293 --- /dev/null +++ b/web/client/actions/longitudinalProfile.js @@ -0,0 +1,233 @@ +/* + * Copyright 2023, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. +*/ +import {updateAdditionalLayer} from "../actions/additionallayers"; +import {SET_CONTROL_PROPERTY} from "../actions/controls"; +import { + updateDockPanelsList +} from "../actions/maplayout"; +import {error} from "../actions/notifications"; +import { + CONTROL_DOCK_NAME, + LONGITUDINAL_OWNER, + LONGITUDINAL_VECTOR_LAYER_ID, + LONGITUDINAL_VECTOR_LAYER_ID_POINT +} from '../plugins/longitudinalProfile/constants'; +import {configSelector} from "../selectors/longitudinalProfile"; + +export const ADD_MARKER = "LONGITUDINAL_PROFILE:ADD_MARKER"; +export const ADD_PROFILE_DATA = "LONGITUDINAL_PROFILE:ADD_PROFILE_DATA"; +export const CHANGE_CHART_TITLE = "LONGITUDINAL_PROFILE:CHANGE_CHART_TITLE"; +export const CHANGE_CRS = "LONGITUDINAL_PROFILE:CHANGE_CRS"; +export const CHANGE_DISTANCE = "LONGITUDINAL_PROFILE:CHANGE_DISTANCE"; +export const CHANGE_GEOMETRY = "LONGITUDINAL_PROFILE:CHANGE_GEOMETRY"; +export const CHANGE_REFERENTIAL = "LONGITUDINAL_PROFILE:CHANGE_REFERENTIAL"; +export const HIDE_MARKER = "LONGITUDINAL_PROFILE:HIDE_MARKER"; +export const INITIALIZED = "LONGITUDINAL_PROFILE:INITIALIZED"; +export const LOADING = "LONGITUDINAL_PROFILE:LOADING"; +export const SETUP = "LONGITUDINAL_PROFILE:SETUP"; +export const TEAR_DOWN = "LONGITUDINAL_PROFILE:TEAR_DOWN"; +export const TOGGLE_MAXIMIZE = "LONGITUDINAL_PROFILE:TOGGLE_MAXIMIZE"; +export const TOGGLE_MODE = "LONGITUDINAL_PROFILE:TOGGLE_MODE"; + +/** + * add a vector point layer with a marker style on the map (used to trigger an epic stream) + * @prop {Object} point + * @prop {Object} point.lat latitude + * @prop {Object} point.lng longitude + * @prop {Object} point.projection the crs of the point // [ ] not sure this is needed + */ +export const addMarker = (point) => ({ + type: ADD_MARKER, + point +}); + +/** + * add profile data results in the state + * @prop {Object} infos + * @prop {Object[]} points the list of points used to draw the line chart + * @prop {string} projection output projection + */ +export const addProfileData = (infos, points, projection) => ({ + type: ADD_PROFILE_DATA, + infos, + points, + projection +}); + +/** + * action used to change the chart title + * @prop {string} chartTitle the chart title + */ +export const changeChartTitle = (chartTitle) => ({ + type: CHANGE_CHART_TITLE, + chartTitle +}); + +/** + * action used to change the crs to be associated to the DXF + * @prop {string} crs the crs to be associated to the DXF + */ +export const changeCRS = (crs) => ({ + type: CHANGE_CRS, + crs +}); + +/** + * action used to change the distance + * @prop {number} distance the distance to use to calculate points for the profile + */ +export const changeDistance = (distance) => ({ + type: CHANGE_DISTANCE, + distance +}); + +/** + * action used to change the geometry + * @prop {Object} geometry the geometry object of a GeoJSON Feature + */ +export const changeGeometry = (geometry) => ({ + type: CHANGE_GEOMETRY, + geometry +}); + +/** + * action used to change the referential + * @prop {string} referential the layer name to be used in the wps process + */ +export const changeReferential = (referential) => ({ + type: CHANGE_REFERENTIAL, + referential +}); + +/** + * action used to close the dock panel + */ +export const closeDock = () => ({ + type: SET_CONTROL_PROPERTY, + control: CONTROL_DOCK_NAME, + property: 'enabled', + value: false +}); + +/** + * action used to hide the marker when hovering the line chart + */ +export const hideMarker = () => ({ + type: HIDE_MARKER +}); + +/** + * action used to complete setup phase + */ +export const initialized = () => ({ + type: INITIALIZED +}); + +/** + * action used to manage loading status + * @prop {boolean} state the flag used to manage loading status + */ +export const loading = (state) => ({ + type: LOADING, + state +}); + +/** + * action used to open the dock panel + */ +export const openDock = () => ({ + type: SET_CONTROL_PROPERTY, + control: CONTROL_DOCK_NAME, + property: 'enabled', + value: true +}); + +/** + * action used to setup the the config of the longitudinal profile plugin + * @prop {Object} config the properties of the config (see Plugin documentation) + */ +export const setupPlugin = (config) => { + return { + type: SETUP, + config + }; +}; + +/** + * action used to show a notification error message + * @prop {string} message the message id to use with a localized string + */ +export const showError = (message) => error({ + message: message, + title: "errorTitleDefault", + position: "tc", + autoDismiss: 10 +}); + +/** + * action used to deactivate the plugin + */ +export const tearDown = () => ({ + type: TEAR_DOWN +}); + +/** + * action used to toggle maximize state of the chart + */ +export const toggleMaximize = () => ({ + type: TOGGLE_MAXIMIZE +}); + +/** + * action used to change the referential + * @prop {string} mode the mode to use for providing the linestring, can be "idle", "draw", "import" + */ +export const toggleMode = (mode) => ({ + type: TOGGLE_MODE, + mode +}); +/** + * action used to setup + */ +export const setup = (config) => { + return (dispatch, getState) => { + dispatch(setupPlugin(config)); + const { referentials, distances, defaultDistance, defaultReferentialName } = config || configSelector(getState()); + const defaultReferential = referentials.find(el => el.layerName === defaultReferentialName); + if (defaultReferentialName && !defaultReferential) { + dispatch(error({ title: "Error", message: "longitudinalProfile.errors.defaultReferentialNotFound", autoDismiss: 10 })); + } + + dispatch(updateDockPanelsList(CONTROL_DOCK_NAME, "add", "right")); + dispatch(changeReferential(defaultReferentialName ?? referentials[0].layerName)); + dispatch(changeDistance(defaultDistance ?? distances[0])); + dispatch(updateAdditionalLayer( + LONGITUDINAL_VECTOR_LAYER_ID, + LONGITUDINAL_OWNER, + 'overlay', + { + id: LONGITUDINAL_VECTOR_LAYER_ID, + features: [], + type: "vector", + name: "selectedLine", + visibility: true + })); + dispatch(updateAdditionalLayer( + LONGITUDINAL_VECTOR_LAYER_ID_POINT, + LONGITUDINAL_OWNER, + 'overlay', + { + id: LONGITUDINAL_VECTOR_LAYER_ID_POINT, + features: [], + type: "vector", + name: "point", + visibility: true + })); + dispatch(initialized()); + }; +}; diff --git a/web/client/components/contextcreator/ConfigurePluginsStep.jsx b/web/client/components/contextcreator/ConfigurePluginsStep.jsx index adbb4c49d5..b17fb395f6 100644 --- a/web/client/components/contextcreator/ConfigurePluginsStep.jsx +++ b/web/client/components/contextcreator/ConfigurePluginsStep.jsx @@ -62,10 +62,10 @@ const getEnabledTools = (plugin, isMandatory, editedPlugin, documentationBaseURL glyph: 'question-sign', tooltipId: 'contextCreator.configurePlugins.tooltips.pluginDocumentation', Element: (props) => - - + ) }, { visible: plugin.isExtension, glyph: 'trash', @@ -184,7 +184,7 @@ const renderPluginError = (error) => { const renderPluginsToUpload = (plugin, onRemove = () => {}) => { const uploadingStatus = plugin.error ? : ; - return (
+ return (
{uploadingStatus}{plugin.name} {plugin.error && renderPluginError(plugin.error)} diff --git a/web/client/components/misc/Button.jsx b/web/client/components/misc/Button.jsx index 34dabf504e..34db94318d 100644 --- a/web/client/components/misc/Button.jsx +++ b/web/client/components/misc/Button.jsx @@ -5,10 +5,12 @@ * This source code is licensed under the BSD-style license found in the * LICENSE file in the root directory of this source tree. */ - // eslint-disable-next-line no-restricted-imports import { Button } from 'react-bootstrap'; import buttonWithDisabled from './enhancers/buttonWithDisabled'; +import tooltip from './enhancers/tooltip'; export default buttonWithDisabled(Button); + +export const ButtonWithTooltip = tooltip(Button); diff --git a/web/client/configs/pluginsConfig.json b/web/client/configs/pluginsConfig.json index e4263c3864..1b469eebfc 100644 --- a/web/client/configs/pluginsConfig.json +++ b/web/client/configs/pluginsConfig.json @@ -624,6 +624,15 @@ "dependencies": [ "SidebarMenu" ] + }, + { + "name": "LongitudinalProfileTool", + "glyph": "1-line", + "title": "plugins.LongitudinalProfileTool.title", + "description": "plugins.LongitudinalProfileTool.description", + "dependencies": [ + "SidebarMenu" + ] } ] } diff --git a/web/client/epics/longitudinalProfile.js b/web/client/epics/longitudinalProfile.js new file mode 100644 index 0000000000..caed67b4ff --- /dev/null +++ b/web/client/epics/longitudinalProfile.js @@ -0,0 +1,491 @@ +/* + * Copyright 2022, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. +*/ +import get from "lodash/get"; +import isEmpty from "lodash/isEmpty"; +import Rx from 'rxjs'; + +import defaultIcon from '../components/map/openlayers/img/marker-icon.png'; +import turfBbox from '@turf/bbox'; +import { + removeAdditionalLayer, + updateAdditionalLayer +} from "../actions/additionallayers"; +import { + SET_CONTROL_PROPERTY, + setControlProperty +} from "../actions/controls"; +import { + changeDrawingStatus, + END_DRAWING +} from "../actions/draw"; +import { + addProfileData, + changeGeometry, + openDock, + loading, + toggleMaximize, + toggleMode, + TEAR_DOWN, + TOGGLE_MODE, + CHANGE_GEOMETRY, + CHANGE_DISTANCE, + CHANGE_REFERENTIAL, + ADD_MARKER, + HIDE_MARKER +} from "../actions/longitudinalProfile"; +import { + zoomToExtent, + CLICK_ON_MAP, + registerEventListener, + unRegisterEventListener +} from "../actions/map"; +import { + changeMapInfoState, + hideMapinfoMarker, + purgeMapInfoResults, + TOGGLE_MAPINFO_STATE, + toggleMapInfoState +} from "../actions/mapInfo"; +import { + UPDATE_MAP_LAYOUT, + updateDockPanelsList, + updateMapLayout +} from "../actions/maplayout"; +import {error, warning} from "../actions/notifications"; +import {wrapStartStop} from "../observables/epics"; +import executeProcess, {makeOutputsExtractor} from "../observables/wps/execute"; +import { + CONTROL_DOCK_NAME, + CONTROL_NAME, + CONTROL_PROPERTIES_NAME, + LONGITUDINAL_OWNER, + LONGITUDINAL_VECTOR_LAYER_ID, + LONGITUDINAL_VECTOR_LAYER_ID_POINT +} from '../plugins/longitudinalProfile/constants'; +import { profileEnLong } from '../plugins/longitudinalProfile/observables/wps/profile'; +import {getSelectedLayer} from "../selectors/layers"; +import { + configSelector, + dataSourceModeSelector, + geometrySelector, + isDockOpenSelector, + isListeningClickSelector, + isMaximizedSelector, + isSupportedLayerSelector +} from "../selectors/longitudinalProfile"; +import {mapSelector} from "../selectors/map"; +import { + mapInfoEnabledSelector +} from "../selectors/mapInfo"; + +import {shutdownToolOnAnotherToolDrawing} from "../utils/ControlUtils"; +import {reprojectGeoJson, reproject} from "../utils/CoordinatesUtils"; +import {selectLineFeature} from "../utils/LongitudinalProfileUtils"; +import {buildIdentifyRequest} from "../utils/MapInfoUtils"; +import {getFeatureInfo} from "../api/identify"; + +const OFFSET = 550; + +const DEACTIVATE_ACTIONS = [ + changeDrawingStatus("stop"), + changeDrawingStatus("clean", '', CONTROL_NAME) +]; + +const deactivate = () => Rx.Observable.from(DEACTIVATE_ACTIONS); + +export const LPcleanOnTearDownEpic = (action$) => + action$.ofType(TEAR_DOWN) + .switchMap(() => { + return Rx.Observable.of( + setControlProperty(CONTROL_NAME, 'enabled', false), + setControlProperty(CONTROL_NAME, 'dataSourceMode', false), + setControlProperty(CONTROL_DOCK_NAME, 'enabled', false), + setControlProperty(CONTROL_PROPERTIES_NAME, 'enabled', false), + updateDockPanelsList(CONTROL_NAME, "remove", "right"), + removeAdditionalLayer({id: LONGITUDINAL_VECTOR_LAYER_ID, owner: LONGITUDINAL_OWNER}), + removeAdditionalLayer({id: LONGITUDINAL_VECTOR_LAYER_ID_POINT, owner: LONGITUDINAL_OWNER}) + ); + }); + +/** + * Adds support of drawing/selecting line whenever corresponding tools is activated via menu + * @param action$ + * @param store + * @returns {*} + */ +export const LPonDrawActivatedEpic = (action$, store) => + action$.ofType(TOGGLE_MODE) + .switchMap(()=> { + const state = store.getState(); + const mode = dataSourceModeSelector(state); + switch (mode) { + case "draw": + const startDrawingAction = changeDrawingStatus('start', "LineString", CONTROL_NAME, [], { stopAfterDrawing: true }); + return action$.ofType(END_DRAWING).flatMap( + ({ geometry }) => { + return Rx.Observable.of(changeGeometry(geometry)) + .merge( + Rx.Observable.of(startDrawingAction).delay(200) // reactivate drawing + ); + }) + .startWith( + unRegisterEventListener('click', CONTROL_NAME), + changeMapInfoState(false), + purgeMapInfoResults(), + hideMapinfoMarker(), + startDrawingAction + ) + .takeUntil(action$.filter(({ type }) => + type === TOGGLE_MODE && dataSourceModeSelector(store.getState()) !== 'draw' + )) + .concat(deactivate()); + case "select": + return Rx.Observable.from([ + purgeMapInfoResults(), hideMapinfoMarker(), + ...(get(store.getState(), 'draw.drawOwner', '') === CONTROL_NAME ? DEACTIVATE_ACTIONS : []), + registerEventListener('click', CONTROL_NAME), + ...(mapInfoEnabledSelector(state) ? [toggleMapInfoState()] : []) + ]); + default: + return Rx.Observable.from([ + purgeMapInfoResults(), + hideMapinfoMarker(), + toggleMapInfoState(), + ...(get(store.getState(), 'draw.drawOwner', '') === CONTROL_NAME ? DEACTIVATE_ACTIONS : []), + unRegisterEventListener('click', CONTROL_NAME) + ]); + } + }); +/** + * Reload chart data from WPS whenever geometry or request configuration changed + * @param action$ + * @param store + * @returns {*} + */ + +export const LPonChartPropsChangeEpic = (action$, store) => + action$.ofType(CHANGE_GEOMETRY, CHANGE_DISTANCE, CHANGE_REFERENTIAL) + .filter(() => { + const state = store.getState(); + return !isEmpty(geometrySelector(state)); + }) + .switchMap(() => { + const state = store.getState(); + const geometry = geometrySelector(state); + const identifier = configSelector(state)?.identifier; + const wpsurl = configSelector(state)?.wpsurl; + const referential = configSelector(state)?.referential; + const distance = configSelector(state)?.distance; + const wpsBody = profileEnLong({identifier, geometry, distance, referential }); + return executeProcess(wpsurl, wpsBody, {outputsExtractor: makeOutputsExtractor()}) + .switchMap((result) => { + if (typeof result === "string" && result.includes("ows:ExceptionReport")) { + return Rx.Observable.of(error({ + title: "errorTitleDefault", + message: "longitudinalProfile.errors.loadingError", + autoDismiss: 6, + position: "tc" + })); + } + const feature = { + type: 'Feature', + geometry, + properties: { + id: "line" + } + }; + const bbox = turfBbox(reprojectGeoJson(feature, geometry.projection, 'EPSG:4326')); + const [minx, minY, maxX, maxY] = bbox; + const { infos, profile: points } = result ?? {}; + const styledFeatures = [feature]; + const features = styledFeatures && geometry.projection ? styledFeatures.map( f => reprojectGeoJson( + f, + geometry.projection + )) : styledFeatures; + return infos && points ? Rx.Observable.from([ + updateAdditionalLayer( + LONGITUDINAL_VECTOR_LAYER_ID, + LONGITUDINAL_OWNER, + 'overlay', + { + id: LONGITUDINAL_VECTOR_LAYER_ID, + features, + style: { + format: "geostyler", + body: { + name: "", + rules: [{ + name: "line-rule", + filter: ["==", "id", "line"], + symbolizers: [ + { + "kind": "Line", + "color": "#3075e9", + "opacity": 1, + "width": 3 + } + ] + }] + } + }, + type: "vector", + name: "selectedLine", + visibility: true + }), + zoomToExtent([minx, minY, maxX, maxY], 'EPSG:4326', 21), + addProfileData(infos, points, geometry.projection) + ]) : Rx.Observable.empty(); + }) + .catch(e => { + console.error("Error while obtaining data for longitudinal profile"); // eslint-disable-line no-console + console.error(e); // eslint-disable-line no-console + return Rx.Observable.of(error({ + title: "errorTitleDefault", + message: e.message.includes("outside coverage") ? "longitudinalProfile.errors.outsideCoverage" : "longitudinalProfile.errors.loadingError", + autoDismiss: 6, + position: "tc" + })); + }) + .let(wrapStartStop( + [loading(true), ...(!isDockOpenSelector(state) ? [openDock()] : [])], + loading(false), + () => Rx.Observable.of(error({ + title: "errorTitleDefault", + message: "longitudinalProfile.errors.loadingError", + autoDismiss: 6, + position: "tc" + })) + )); + }); + +export const LPonAddMarkerEpic = (action$) => + action$.ofType(ADD_MARKER) + .switchMap(({point}) => { + const point4326 = reproject([point.lng, point.lat], "EPSG:3857", "EPSG:4326"); + const pointFeature = { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [point4326.x, point4326.y], + projection: point.projection + }, + properties: { + id: "point" + } + }; + return Rx.Observable.from([ + updateAdditionalLayer( + LONGITUDINAL_VECTOR_LAYER_ID_POINT, + LONGITUDINAL_OWNER, + 'overlay', + { + id: LONGITUDINAL_VECTOR_LAYER_ID_POINT, + features: [pointFeature], + type: "vector", + style: { + format: "geostyler", + body: { + name: "", + rules: [{ + name: "point-rule", + filter: ["==", "id", "point"], + symbolizers: [ + { + kind: 'Icon', + image: defaultIcon, + opacity: 1, + size: 32, + anchor: [0.5, 1], + offset: [0, -16], + rotate: 0, + msBringToFront: true, + msHeightReference: 'none', + symbolizerId: "point-feature" + } + ] + }] + } + }, + name: "selectedLine", + visibility: true + }) + ]); + }); +export const LPonHideMarkerEpic = (action$) => + action$.ofType(HIDE_MARKER) + .switchMap(() => { + return Rx.Observable.from([ + updateAdditionalLayer( + LONGITUDINAL_VECTOR_LAYER_ID_POINT, + LONGITUDINAL_OWNER, + 'overlay', + { + id: LONGITUDINAL_VECTOR_LAYER_ID_POINT, + features: [], + type: "vector", + name: "selectedLine", + visibility: true + }) + ]); + }); + +/** + * Cleanup geometry when dock is closed + * @param action$ + * @param store + * @returns {*} + */ +export const LPonDockClosedEpic = (action$, store) => + action$.ofType(SET_CONTROL_PROPERTY) + .filter(({control, property, value}) => control === CONTROL_DOCK_NAME && property === 'enabled' && value === false) + .switchMap(() => { + return Rx.Observable.from([ + changeGeometry(false), + removeAdditionalLayer({id: LONGITUDINAL_VECTOR_LAYER_ID, owner: LONGITUDINAL_OWNER}), + removeAdditionalLayer({id: LONGITUDINAL_VECTOR_LAYER_ID_POINT, owner: LONGITUDINAL_OWNER}), + ...(isMaximizedSelector(store.getState()) ? [toggleMaximize()] : []) + ]); + }); + +/** + * Re-trigger an update map layout with the margin to adjust map layout and show navigation toolbar. This + * also keep the zoom to extent offsets aligned with the current visibile window, so when zoom the longitudinal panel + * is considered as a right offset and it will not cover the zoomed features. + */ +export const LPlongitudinalMapLayoutEpic = (action$, store) => + action$.ofType(UPDATE_MAP_LAYOUT) + .filter(({source}) => isDockOpenSelector(store.getState()) && source !== CONTROL_NAME) + .map(({layout}) => { + const action = updateMapLayout({ + ...layout, + right: OFFSET + (layout?.boundingSidebarRect?.right ?? 0), + boundingMapRect: { + ...(layout.boundingMapRect || {}), + right: OFFSET + (layout?.boundingSidebarRect?.right ?? 0) + }, + rightPanel: true + }); + return { ...action, source: CONTROL_NAME }; // add an argument to avoid infinite loop. + }); + +/** + * Toggle longitudinal profile drawing/selection tool off when one of the drawing tools takes control + * @param action$ + * @param store + * @returns {Observable} + */ +export const LPresetLongitudinalToolOnDrawToolActiveEpic = (action$, store) => shutdownToolOnAnotherToolDrawing(action$, store, CONTROL_NAME, + () => { + return Rx.Observable.of(toggleMode()); + }, + () => dataSourceModeSelector(store.getState()) +); + +/** + * Ensures that the active tool is getting deactivated when Identify tool is activated + * @param {observable} action$ manages `TOGGLE_MAPINFO_STATE` + * @param store + * @return {observable} + */ +export const LPdeactivateIdentifyEnabledEpic = (action$, store) => + action$ + .ofType(TOGGLE_MAPINFO_STATE) + .filter(() => mapInfoEnabledSelector(store.getState())) + .switchMap(() => { + const mode = dataSourceModeSelector(store.getState()); + return mode === "draw" + ? Rx.Observable.from([ + toggleMode("idle") + ]) + : Rx.Observable.empty(); + }); + +export const LPclickToProfileEpic = (action$, {getState}) => + action$ + .ofType(CLICK_ON_MAP) + .filter(() => isListeningClickSelector(getState())) + .switchMap(({point}) => { + const state = getState(); + const map = mapSelector(state); + const layer = getSelectedLayer(state); + if (!layer) { + return Rx.Observable.of(warning({ + title: "notification.warning", + message: "longitudinalProfile.warnings.noLayerSelected", + autoDismiss: 10, + position: "tc" + })); + } + if (!isSupportedLayerSelector(state)) { + return Rx.Observable.of(warning({ + title: "notification.warning", + message: "longitudinalProfile.warnings.layerNotSupported", + autoDismiss: 10, + position: "tc" + })); + } + + let { + url, + request + } = buildIdentifyRequest(layer, {format: 'application/json', map, point}); + + const basePath = url; + const param = {...request}; + if (url) { + return getFeatureInfo(basePath, param, layer, {attachJSON: true}) + .map(data => { + const { feature, coordinates } = selectLineFeature(data?.features ?? [], data?.featuresCrs); + if (feature && coordinates) { + return changeGeometry({ + type: "LineString", + coordinates, + projection: "EPSG:3857" + }); + } + return warning({ + title: "notification.warning", + message: "longitudinalProfile.warnings.noFeatureInPoint", + autoDismiss: 10, + position: "tc" + }); + }) + .catch(e => { + console.log("Error while obtaining data for longitudinal profile"); // eslint-disable-line no-console + console.log(e); // eslint-disable-line no-console + return Rx.Observable.of(loading(false)); + }) + .let(wrapStartStop( + [loading(true)], + [loading(false)], + () => Rx.Observable.of(error({ + title: "errorTitleDefault", + message: "longitudinalProfile.errors.loadingError", + autoDismiss: 6, + position: "tc" + }), + loading(false)) + )); + } + + const intersected = (point?.intersectedFeatures ?? []).find(l => l.id === layer.id); + const { feature, coordinates } = selectLineFeature(intersected?.features ?? []); + if (feature && coordinates) { + return Rx.Observable.of(changeGeometry({ + type: "LineString", + coordinates, + projection: "EPSG:3857" + })); + } + return Rx.Observable.empty(warning({ + title: "notification.warning", + message: "longitudinalProfile.warnings.noFeatureInPoint", + autoDismiss: 10, + position: "tc" + })); + }); diff --git a/web/client/plugins/LongitudinalProfileTool.jsx b/web/client/plugins/LongitudinalProfileTool.jsx new file mode 100644 index 0000000000..64f14d018e --- /dev/null +++ b/web/client/plugins/LongitudinalProfileTool.jsx @@ -0,0 +1,215 @@ +/* + * Copyright 2023, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; + +import GlobalSpinner from '../components/misc/spinners/GlobalSpinner/GlobalSpinner'; +import Main from './longitudinalProfile/Main'; +import UserMenuConnected from './longitudinalProfile/Menu'; +import { setControlProperty } from "../actions/controls"; +import { + addMarker, + changeCRS, + changeDistance, + changeGeometry, + changeReferential, + closeDock, + hideMarker, + showError, + setup, + tearDown, + toggleMaximize, + toggleMode +} from "../actions/longitudinalProfile"; +import { warning } from '../actions/notifications'; +import {exportCSV} from "../actions/widgets"; +import * as epics from '../epics/longitudinalProfile'; +import longitudinalProfile from '../reducers/longitudinalProfile'; +import { + getSelectedLayer as getSelectedLayerSelector +} from '../selectors/layers'; +import { + currentLocaleSelector, + currentMessagesSelector +} from '../selectors/locale'; +import { + chartTitleSelector, + crsSelectedDXFSelector, + configSelector, + dataSourceModeSelector, + distanceSelector, + infosSelector, + isDockOpenSelector, + isInitializedSelector, + isLoadingSelector, + isMaximizedSelector, + isParametersOpenSelector, + isSupportedLayerSelector, + pointsSelector, + projectionSelector, + referentialSelector +} from '../selectors/longitudinalProfile'; +import { + dockStyleSelector, + helpStyleSelector, + boundingSidebarRectSelector +} from '../selectors/maplayout'; + +import { createPlugin } from '../utils/PluginsUtils'; + +/** + * Plugin for generating a chart with longitudinal profile + * @name LongitudinalProfileTool + * @memberof plugins + * @class + * @prop {Object} cfg.config the plugin configuration + * @prop {string} cfg.config.chartTitle the default title of the chart + * @prop {number} cfg.config.defaultDistance the default distance value in meters + * @prop {string} cfg.config.identifier the profile to use in the wps request, defaulted to gs:LongitudinalProfile + * @prop {string} cfg.config.defaultReferentialName the default referential name + * @prop {Object[]} cfg.config.referentials (required) the layers that can be used as referentials + * @prop {string[]} cfg.filterAllowedCRS the allowed crs to be proposed when dropping a DXF file, (needs to be supported by mapstore) + * @prop {Object} cfg.additionalCRS the crs object that allow to define also a label + * + * @example + * + * { + * "name": "LongitudinalProfileTool", + * "cfg": { + * "config": { + * "chartTitle": "Longitudinal profile", + * "defaultDistance": 75, + * "defaultReferentialName": "sfdem", + * "referentials": [{ + * "layerName": "sfdem", + * "title": "sfdem" + * }] + * }, + * "filterAllowedCRS": ["EPSG:4326", "EPSG:3857"], + * "additionalCRS": { + * "EPSG:3003": { "label": "EPSG:3003" } + * }, + * } + * } + * ` + */ +const MainComponent = connect( + createSelector( + [ + chartTitleSelector, + crsSelectedDXFSelector, + configSelector, + isInitializedSelector, + isLoadingSelector, + dataSourceModeSelector, + isParametersOpenSelector, + isDockOpenSelector, + infosSelector, + pointsSelector, + projectionSelector, + referentialSelector, + distanceSelector, + dockStyleSelector, + helpStyleSelector, + getSelectedLayerSelector, + isSupportedLayerSelector, + currentMessagesSelector, + currentLocaleSelector, + isMaximizedSelector, + boundingSidebarRectSelector + ], + ( + chartTitle, + crsSelectedDXF, + config, + initialized, + loading, + dataSourceMode, + isParametersOpen, + showDock, + infos, + points, + projection, + referential, + distance, + dockStyle, + helpStyle, + selectedLayer, + isSupportedLayer, + messages, + currentLocale, + maximized, + boundingRect + ) => ({ + chartTitle, + crsSelectedDXF, + config, + initialized, + loading, + dataSourceMode, + isParametersOpen, + showDock, + infos, + points, + projection, + referential, + distance, + dockStyle, + helpStyle, + selectedLayer, + isSupportedLayer, + messages, + currentLocale, + maximized, + boundingRect + })), + { + onAddMarker: addMarker, + onChangeCRS: changeCRS, + onChangeDistance: changeDistance, + onChangeGeometry: changeGeometry, + onChangeReferential: changeReferential, + onCloseDock: closeDock, + onError: showError, + onExportCSV: exportCSV, + onHideMarker: hideMarker, + onSetup: setup, + onTearDown: tearDown, + onToggleMaximize: toggleMaximize, + onToggleParameters: setControlProperty.bind(this, "LongitudinalProfileToolParameters", "enabled", true, true), + onToggleSourceMode: toggleMode, + onWarning: warning + })(Main); + +export default createPlugin( + "LongitudinalProfileTool", + { + component: MainComponent, + options: { + disablePluginIf: "{state('mapType') === 'cesium'}" + }, + containers: { + SidebarMenu: { + name: 'LongitudinalProfileTool', + position: 2100, + doNotHide: true, + tool: UserMenuConnected, + priority: 1 + }, + Toolbar: { + name: "LongitudinalProfileTool-spinner", + alwaysVisible: true, + position: 1, + tool: connect((state) => ({ + loading: isLoadingSelector(state) + }))(GlobalSpinner) + } + }, + reducers: { longitudinalProfile }, + epics + }); diff --git a/web/client/plugins/longitudinalProfile/Chart.jsx b/web/client/plugins/longitudinalProfile/Chart.jsx new file mode 100644 index 0000000000..5c521f64bb --- /dev/null +++ b/web/client/plugins/longitudinalProfile/Chart.jsx @@ -0,0 +1,65 @@ +/* + * Copyright 2022, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. +*/ +import React, { Suspense } from 'react'; + +import {toPlotly} from "../../components/charts/WidgetChart"; +import LoadingView from '../../components/misc/LoadingView'; + +const Plot = React.lazy(() => import('../../components/charts/PlotlyChart')); + +/** + * Plotly chart component. This is a slightly modified version of @mapstore/components/charts/WidgetChart + * with support of displaying axis labels + * @prop {string} type one of 'line', 'bar', 'pie' + * @prop {number} height height for the chart, can be the height of the container div + * @prop {number} width width for the chart, can be the width of the container div + * @prop {boolean} legend if present, show legend + * @prop {object[]} data the data set `[{ name: 'Page A', uv: 0, pv: 0, amt: 0 }]` + * @prop {object} xAxis contains xAxis `dataKey`, the key from `data` array for x axis (or category). + * @prop {object} [xAxisOpts] options for xAxis: `type`, `hide`, `nTicks`. + * @prop {string} [xAxisOpts.type] determine the type of the x axis of `date`, `-` (automatic), `log`, `linear`, `category`, `date`. + * @prop {object} [xAxisOpts.hide=false] if true, hides the labels of the axis + * @prop {number} [xAxisOpts.nTicks] max number of ticks. Can be used to force to display all labels, instead of skipping. + * @prop {number} [xAxisAngle] the angle, in degrees, of xAxisAngle. + * @prop {object|boolean} [yAxis=true] if false, hide the yAxis. true by default. (should contain future options for yAxis) + * @prop {object} [yAxisOpts] options for yAxis: `type`, `tickPrefix`, `tickPostfix`, `format`, `formula` + * @prop {string} [yAxisOpts.type] determine the type of the y axis of `date`, `-` (automatic), `log`, `linear`, `category`, `date`. + * @prop {string} [yAxisOpts.format] format for y axis value. See {@link https://d3-wiki.readthedocs.io/zh_CN/master/Formatting/} + * @prop {string} [yAxisOpts.tickPrefix] the prefix on y value + * @prop {string} [yAxisOpts.tickSuffix] the suffix of y value. + * @prop {string} [formula] a formula to calculate the final value + * @prop {string} [yAxisLabel] the label of yAxis, to show in the legend and aside of the axis + * @prop {string} [xAxisLabel] the label of xAxis, to show underneath the axis + * @prop {boolean} [cartesian] show the cartesian grid behind the chart + * @prop {object} [autoColorOptions] options to generate the colors of the chart. + * @prop {object[]} series descriptor for every series. Contains the y axis (or value) `dataKey` + */ +export default function Chart({ + onInitialized = () => {}, + onHover = () => {}, + ...props +}) { + const { data, layout, config } = toPlotly(props); + + const { yAxisLabel, xAxisLabel} = props; + + (yAxisLabel && layout.yaxis) ? layout.yaxis.title = yAxisLabel : null; + (xAxisLabel && layout.xaxis) ? layout.xaxis.title = xAxisLabel : null; + + return ( + }> + + + ); +} diff --git a/web/client/plugins/longitudinalProfile/Dock.jsx b/web/client/plugins/longitudinalProfile/Dock.jsx new file mode 100644 index 0000000000..661fb05692 --- /dev/null +++ b/web/client/plugins/longitudinalProfile/Dock.jsx @@ -0,0 +1,464 @@ +/* + * Copyright 2022, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. +*/ +import React, {useState, useMemo, useEffect} from "react"; +import {toPng} from 'html-to-image'; +import isEmpty from 'lodash/isEmpty'; +import pdfMake from 'pdfmake'; +import PropTypes from 'prop-types'; +import {ButtonGroup, Col, Glyphicon, Nav, NavItem, Row} from 'react-bootstrap'; +import ContainerDimensions from 'react-container-dimensions'; +import ReactDOM from 'react-dom'; + +import Message from "../../components/I18N/Message"; +import Button from "../../components/misc/Button"; +import tooltip from "../../components/misc/enhancers/tooltip"; +import LoadingView from "../../components/misc/LoadingView"; +import ResponsivePanel from "../../components/misc/panels/ResponsivePanel"; +import Toolbar from "../../components/misc/toolbar/Toolbar"; +import Chart from "./Chart"; + +const NavItemT = tooltip(NavItem); + +/** + * Component used to show the chart + * @param {Object} props properties of the component + * @param {string} props.chartTitle + * @param {Object} props.config some info related to the longitudinal profile plugin + * @param {Object} props.dockStyle the style of the dock + * @param {boolean} props.loading flag used to manage the loading time of the process + * @param {boolean} props.maximized flag used to manage the maximized status of the chart + * @param {Object} props.messages the locale messages + * @param {Object[]} props.points the points of the long profile containing geom and other data + * @param {string} props.projection crs used to + * @param {Function} props.onToggleMaximize used to maximize the chart + * @param {Function} props.onAddMarker used to show a marker in the map + * @param {Function} props.onError used to display an error notification + * @param {Function} props.onExportCSV used to export data in CSV + * @param {Function} props.onHideMarker used to hide the marker + */ +const ChartData = ({ + chartTitle, + config, + dockStyle, + loading, + maximized, + messages, + points, + projection, + onToggleMaximize, + onAddMarker, + onError, + onExportCSV, + onHideMarker +}) => { + const data = useMemo(() => points ? points.map((point) => ({ + distance: point.totalDistanceToThisPoint, + x: point.x, + y: point.y, + altitude: point.altitude, + incline: point.slope + })) : [], [points]); + const [marker, setMarker] = useState({}); + const [isTainted, setIsTainted] = useState(false); + + useEffect(() => { + if (!isEmpty(marker)) { + onAddMarker({lng: marker.x, lat: marker.y, projection: 'EPSG:4326'}); + } else { + onHideMarker(); + } + }, [marker]); + + const series = [{dataKey: "altitude", color: `#078aa3`}]; + const xAxis = {dataKey: "distance", show: false, showgrid: true}; + const options = { + xAxisAngle: 0, + yAxis: true, + yAxisLabel: messages.longitudinalProfile.elevation, + legend: false, + tooltip: false, + cartesian: true, + popup: false, + xAxisOpts: { + hide: false + }, + yAxisOpts: { + tickSuffix: ' m' + }, + xAxisLabel: messages.longitudinalProfile.distance + }; + + const generateChartImageUrl = () => { + const toolbar = document.querySelector('.chart-toolbar'); + const chartToolbar = document.querySelector('.modebar-container'); + toolbar.className = toolbar.className + " hide"; + chartToolbar.className = chartToolbar.className + " hide"; + + return toPng(document.querySelector('.longitudinal-tool-container')) + .then(function(dataUrl) { + toolbar.className = toolbar.className.replace(" hide", ""); + chartToolbar.className = chartToolbar.className.replace(" hide", ""); + return dataUrl; + }); + }; + + const content = loading + ? + : ( + <>
marker.length && setMarker({})}> + {chartTitle} + onToggleMaximize() + } + ]} + /> + + {({ width, height }) => ( +
!isEmpty(marker) && setMarker({})}> + { + const idx = info.points[0].pointIndex; + const point = data[idx]; + setMarker({x: point.x, y: point.y, projection}); + }} + {...options} + height={maximized ? height - 115 : 400} + width={maximized ? width - (dockStyle?.right ?? 0) - (dockStyle?.left ?? 0) : 520 } + data={data} + series={series} + xAxis={xAxis} + /> + {config.referential && projection ? + + + + + + + + + + + + +
{projection}
{config.referential}
: null} + +
+ )} +
+
+ {data.length ? ( + + + + + + ) : null} + + ); + + if (maximized) { + return ReactDOM.createPortal( + content, + document.getElementById('dock-chart-portal')); + } + return content; +}; +const Information = ({ + infos, + loading, + messages +}) => { + const infoConfig = [ + { + glyph: '1-layer', + prop: 'layer', + label: + }, + { + glyph: 'line', + prop: 'totalDistance', + round: true, + suffix: ' m', + label: + }, + { + glyph: 'chevron-up', + prop: 'altitudePositive', + suffix: ' m', + label: + }, + { + glyph: 'chevron-down', + prop: 'altitudeNegative', + suffix: ' m', + label: + }, + { + glyph: 'cog', + prop: 'processedPoints', + suffix: messages.longitudinalProfile.points ?? , + label: + } + ]; + + return loading ? : (
+ { + !isEmpty(infos) ? infoConfig.map((conf) => ( +
+ + + + {[ + ...[conf.label ? [conf.label] : []] + ]} + +
+ {[ + ...[conf.round ? [Math.round(infos[conf.prop])] : [infos[conf.prop]]], + ...[conf.suffix ? [conf.suffix] : []] + ]} +
+
+
)) : + } +
); +}; + +const tabs = [ + { + id: 'chart', + titleId: 'longitudinalProfile.chart', + tooltipId: 'longitudinalProfile.chart', + glyph: 'stats', + visible: true, + Component: ChartData + }, + { + id: 'info', + titleId: 'longitudinalProfile.infos', + tooltipId: 'longitudinalProfile.infos', + glyph: 'info-sign', + visible: true, + Component: Information + } +]; + +const Dock = ({ + chartTitle, + config, + dockStyle, + infos, + loading, + maximized, + messages, + onCloseDock, + points, + projection, + showDock, + onAddMarker, + onError, + onExportCSV, + onHideMarker, + onToggleMaximize +}) => { + + const [activeTab, onSetTab] = useState('chart'); + + return showDock ? ( + } + glyph={
} + size={550} + open={showDock} + onClose={onCloseDock} + style={dockStyle} + siblings={ +
+ } + header={[ + + + + + + ]} + > + {activeTab === "chart" ? + : null} + {activeTab === "info" ? + : null} + + ) : null; +}; + +export default Dock; + +ChartData.propTypes = { + chartTitle: PropTypes.string, + config: PropTypes.object, + dockStyle: PropTypes.object, + loading: PropTypes.bool, + maximized: PropTypes.bool, + messages: PropTypes.object, + points: PropTypes.array, + projection: PropTypes.string, + onAddMarker: PropTypes.func, + onError: PropTypes.func, + onExportCSV: PropTypes.func, + onHideMarker: PropTypes.func, + onToggleMaximize: PropTypes.func +}; + +Dock.propTypes = { + chartTitle: PropTypes.string, + config: PropTypes.object, + dockStyle: PropTypes.object, + infos: PropTypes.object, + loading: PropTypes.bool, + maximized: PropTypes.bool, + messages: PropTypes.object, + points: PropTypes.array, + projection: PropTypes.string, + showDock: PropTypes.bool, + onAddMarker: PropTypes.func, + onCloseDock: PropTypes.func, + onError: PropTypes.func, + onExportCSV: PropTypes.func, + onHideMarker: PropTypes.func, + onToggleMaximize: PropTypes.func +}; + +Information.propTypes = { + infos: PropTypes.object, + loading: PropTypes.bool, + messages: PropTypes.object +}; diff --git a/web/client/plugins/longitudinalProfile/HelpInfo.jsx b/web/client/plugins/longitudinalProfile/HelpInfo.jsx new file mode 100644 index 0000000000..cfa214a9ab --- /dev/null +++ b/web/client/plugins/longitudinalProfile/HelpInfo.jsx @@ -0,0 +1,41 @@ +/* + * Copyright 2022, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. +*/ +import React from 'react'; +import classnames from 'classnames'; + +import HTML from '../../components/I18N/HTML'; + +import {getLayerTitle} from "../../utils/LayersUtils"; + +const HelpInfo = ({ + currentLocale, + dataSourceMode, + isSupportedLayer, + messages, + selectedLayer +}) => { + if (dataSourceMode && dataSourceMode !== 'import') { + let layerTitle = messages?.longitudinalProfile?.help?.noLayer; + if (selectedLayer) { + layerTitle = isSupportedLayer ? getLayerTitle(selectedLayer, currentLocale) : messages?.longitudinalProfile?.help?.notSupportedLayer; + } + + return dataSourceMode !== "idle" ? ( +
+ +
+ ) : null; + } + return null; + +}; + +export default HelpInfo; diff --git a/web/client/plugins/longitudinalProfile/Import.jsx b/web/client/plugins/longitudinalProfile/Import.jsx new file mode 100644 index 0000000000..4486be63b8 --- /dev/null +++ b/web/client/plugins/longitudinalProfile/Import.jsx @@ -0,0 +1,48 @@ +/* + * Copyright 2022, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. +*/ +import React from 'react'; +import { compose } from 'recompose'; + +import DragZone from '../../components/import/dragZone/DragZone.jsx'; +import dropZoneHandlers from '../../components/import/dragZone/enhancers/dropZoneHandlers'; +import processFile from './enhancers/processFile'; +import useFile from './enhancers/useFile'; +import ImportContent from './ImportContent'; +import ImportSelectCRS from './ImportSelectCRS'; + +export default compose( + processFile, + useFile, + dropZoneHandlers +)( + ({ + onChangeCRS = () => {}, + onClose = () => {}, + onDrop = () => {}, + onRef = () => {}, + showProjectionCombobox, + filterAllowedCRS, + additionalCRS, + crsSelectedDXF, + ...props + }) => ( + {[, showProjectionCombobox ? + : null]} + )); diff --git a/web/client/plugins/longitudinalProfile/ImportContent.jsx b/web/client/plugins/longitudinalProfile/ImportContent.jsx new file mode 100644 index 0000000000..367b72abf1 --- /dev/null +++ b/web/client/plugins/longitudinalProfile/ImportContent.jsx @@ -0,0 +1,87 @@ +import React, { useCallback } from 'react'; +import { Glyphicon, Alert } from 'react-bootstrap'; + +import HTML from "../../components/I18N/HTML"; +import Message from "../../components/I18N/Message"; +import LoadingContent from "../../components/import/dragZone/LoadingContent"; +import Button from "../../components/misc/Button"; + +const DropZoneContent = ({openFileDialog}) => + ( +
+ + {openFileDialog + ? + + : null + } +
+
+ +
+ ); + +const NormalContent = ({openFileDialog}) => { + return ( + <> +
+ +
+ + + ); +}; + +const ErrorContent = ({error, openFileDialog}) => { + const errorMessages = { + "FILE_NOT_SUPPORTED": , + "PROJECTION_NOT_SUPPORTED": , + "GEOMETRY_NOT_SUPPORTED": + }; + const toErrorMessage = useCallback(e => + e + ? errorMessages[e.message] + || errorMessages[e] + || :{error.message} + : , []); + return ( + <> +
+ +
+
+ {toErrorMessage(error)} +
+ + + ); +}; + +const ImportContent = ({ openFileDialog, showProjectionCombobox, loading, error }) => { + if (showProjectionCombobox) { + return ("showProjectionCombobox"); + } + if (loading) { + return ( + + ); + } + return ( +
+ { + error ? : + } +
+ + ); +}; + +export default ImportContent; diff --git a/web/client/plugins/longitudinalProfile/ImportSelectCRS.jsx b/web/client/plugins/longitudinalProfile/ImportSelectCRS.jsx new file mode 100644 index 0000000000..a8cee2e83e --- /dev/null +++ b/web/client/plugins/longitudinalProfile/ImportSelectCRS.jsx @@ -0,0 +1,64 @@ +import React from 'react'; +import { Button, Glyphicon, FormGroup, ControlLabel } from 'react-bootstrap'; +import Select from 'react-select'; + +import Message from '../../components/I18N/Message'; + +import { getConfigProp } from '../../utils/ConfigUtils'; +import { filterCRSList, getAvailableCRS, reprojectGeoJson } from '../../utils/CoordinatesUtils'; + +const ImportSelectCRS = ({ + additionalCRS = {}, + crsSelectedDXF = "EPSG:3857", + feature, + filterAllowedCRS = ["EPSG:4326", "EPSG:3857"], + onChangeGeometry, + onChangeCRS, + onClose +}) => { + let list = []; + const usableCRS = getAvailableCRS(); + let availableCRS = {}; + if (Object.keys(usableCRS).length) { + availableCRS = filterCRSList(usableCRS, filterAllowedCRS, additionalCRS, getConfigProp('projectionDefs') || [] ); + } + for (let item in availableCRS) { + if (availableCRS.hasOwnProperty(item)) { + list.push({value: item, label: item}); + } + } + return ( +
+ +
+ + {feature.fileName} + +
+ + + ({value: r.layerName, label: r.title}))} + onChange={(selected) => onChangeReferential(selected?.value)} + /> + + + + onChangeDistance(value)} + /> + + + + onChangeChartTitle(value)} + /> + +
+ ); +}; + +const PropertiesConnected = connect( + createSelector( + [ + chartTitleSelector, + configSelector, + distanceSelector, + referentialSelector + ], + ( + chartTitle, + config, + distance, + referential + ) => ({ + chartTitle, + config, + distance, + referential + })), { + onChangeChartTitle: changeChartTitle, + onChangeDistance: changeDistance, + onChangeReferential: changeReferential + })(Properties); + +export default PropertiesConnected; diff --git a/web/client/plugins/longitudinalProfile/SettingsPanel.jsx b/web/client/plugins/longitudinalProfile/SettingsPanel.jsx new file mode 100644 index 0000000000..24f2b613d0 --- /dev/null +++ b/web/client/plugins/longitudinalProfile/SettingsPanel.jsx @@ -0,0 +1,64 @@ +/* + * Copyright 2022, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. +*/ +import React from 'react'; +import { Glyphicon } from 'react-bootstrap'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; + +import Dialog from "../../components/misc/Dialog"; +import Properties from "./Properties"; +import { setControlProperty } from "../../actions/controls"; +import Message from "../../plugins/locale/Message"; +import SettingsPanelComp from "../../plugins/settings/SettingsPanel"; +import { + isParametersOpenSelector +} from '../../selectors/longitudinalProfile'; + +const Panel = ({ + isParametersOpen, + panelStyle = {width: '330px'}, + onToggleParameters +}) => { + + return ( + + + + + + + + + + ); +}; + +const PanelConnected = connect( + createSelector( + [ + isParametersOpenSelector + ], + ( + isParametersOpen + ) => ({ + isParametersOpen + })), { + onToggleParameters: setControlProperty.bind(this, "LongitudinalProfileToolParameters", "enabled", true, true) + })(Panel); + +export default PanelConnected; diff --git a/web/client/plugins/longitudinalProfile/__tests__/ImportContent-test.js b/web/client/plugins/longitudinalProfile/__tests__/ImportContent-test.js new file mode 100644 index 0000000000..6fc0641fd8 --- /dev/null +++ b/web/client/plugins/longitudinalProfile/__tests__/ImportContent-test.js @@ -0,0 +1,46 @@ +/* + * Copyright 2023, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. +*/ +import React from 'react'; +import expect from 'expect'; +import ReactDOM from "react-dom"; + +import ImportContent from "../ImportContent"; + +describe('ImportContent', () => { + beforeEach((done) => { + document.body.innerHTML = '
'; + setTimeout(done); + }); + + afterEach((done) => { + ReactDOM.unmountComponentAtNode(document.getElementById("container")); + document.body.innerHTML = ''; + setTimeout(done); + }); + + it('test render ImportContent component', () => { + ReactDOM.render( {}}/>, document.getElementById("container")); + const elements = document.getElementsByClassName('longitudinal-import'); + expect(elements.length).toBe(1); + }); + + it('test render ImportContent component - loading state', () => { + ReactDOM.render( {}} loading/>, document.getElementById("container")); + const elements = document.getElementsByClassName('mapstore-medium-size-loader'); + expect(elements.length).toBe(1); + }); + + it('test render ImportContent component - error state', () => { + ReactDOM.render( {}} error={{message: "Some error"}}/>, document.getElementById("container")); + const glyphs = document.getElementsByClassName('glyphicon-exclamation-mark'); + const alerts = document.getElementsByClassName('alert-warning'); + expect(glyphs.length).toBe(1); + expect(alerts.length).toBe(1); + }); + +}); diff --git a/web/client/plugins/longitudinalProfile/constants.js b/web/client/plugins/longitudinalProfile/constants.js new file mode 100644 index 0000000000..e871c19e23 --- /dev/null +++ b/web/client/plugins/longitudinalProfile/constants.js @@ -0,0 +1,33 @@ + +/* + * Copyright 2023, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. +*/ +export const CONTROL_NAME = 'LongitudinalProfileTool'; +export const CONTROL_PROPERTIES_NAME = 'LongitudinalProfileToolParameters'; +export const CONTROL_DOCK_NAME = 'LongitudinalToolDock'; + +export const LONGITUDINAL_VECTOR_LAYER_ID = 'longitudinal_profile_tool'; +export const LONGITUDINAL_VECTOR_LAYER_ID_POINT = 'longitudinal_profile_tool_point'; +export const LONGITUDINAL_OWNER = 'LongitudinalTool'; +export const FILE_TYPE_ALLOWED = [ + "application/json", + "image/x-dxf", + "image/vnd.dxf", + "application/x-zip-compressed", + "application/zip" +]; +export const LONGITUDINAL_DISTANCES = [ + 1, + 5, + 10, + 50, + 75, + 100, + 500, + 1000, + 5000 +]; diff --git a/web/client/plugins/longitudinalProfile/enhancers/processFile.js b/web/client/plugins/longitudinalProfile/enhancers/processFile.js new file mode 100644 index 0000000000..54a09ce8d8 --- /dev/null +++ b/web/client/plugins/longitudinalProfile/enhancers/processFile.js @@ -0,0 +1,154 @@ +/* + * Copyright 2022, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. +*/ +import every from 'lodash/every'; +import get from 'lodash/get'; +import includes from 'lodash/includes'; +import some from 'lodash/some'; +import proj4 from 'proj4'; +import { compose, createEventHandler, mapPropsStream } from 'recompose'; +import Rx from 'rxjs'; + +import { FILE_TYPE_ALLOWED } from '../constants'; + +import {getConfigProp} from "../../../utils/ConfigUtils"; +import {parseURN} from "../../../utils/CoordinatesUtils"; +import { + MIME_LOOKUPS, + checkShapePrj, + readJson, + readZip, + readDxf, + recognizeExt, + shpToGeoJSON +} from '../../../utils/FileUtils'; +import {flattenImportedFeatures} from "../../../utils/LongitudinalProfileUtils"; + +/** + * Checks if the file is allowed. Returns a promise that does this check. + */ +const checkFileType = (file) => { + return new Promise((resolve, reject) => { + const ext = recognizeExt(file.name); + const type = file.type || MIME_LOOKUPS[ext]; + if (includes(FILE_TYPE_ALLOWED, type)) { + resolve(file); + } else { + reject(new Error("FILE_NOT_SUPPORTED")); + } + }); +}; +/** + * Create a function that return a Promise for reading file. The Promise resolves with an array of (json) + */ +const readFile = (onWarnings) => (file) => { + const ext = recognizeExt(file.name); + const type = file.type || MIME_LOOKUPS[ext]; + const projectionDefs = getConfigProp('projectionDefs') || []; + const supportedProjections = (projectionDefs.length && projectionDefs.map(({code}) => code) || []).concat(["EPSG:4326", "EPSG:3857", "EPSG:900913"]); + // [ ] change this to use filterCRSList + if (type === 'application/json') { + return readJson(file).then(f => { + const projection = get(f, 'map.projection') ?? parseURN(get(f, 'crs')); + if (projection) { + const supportedProjection = proj4.defs(projection); + if (supportedProjection) { + return [{...f, "fileName": file.name}]; + } + throw new Error("PROJECTION_NOT_SUPPORTED"); + } + return [{...f, "fileName": file.name}]; + }); + } + if (type === 'application/x-zip-compressed' || + type === 'application/zip') { + return readZip(file).then((buffer) => { + return checkShapePrj(buffer) + .then((warnings) => { + if (warnings.length > 0) { + onWarnings({type: 'warning', filename: file.name, message: 'shapefile.error.missingPrj'}); + } + const geoJsonArr = shpToGeoJSON(buffer).map(json => ({ ...json, filename: file.name })); + const areProjectionsPresent = some(geoJsonArr, geoJson => !!get(geoJson, 'map.projection')); + if (areProjectionsPresent) { + const filteredGeoJsonArr = geoJsonArr.filter(item => !!get(item, 'map.projection')); + const areProjectionsValid = every(filteredGeoJsonArr, geoJson => supportedProjections.includes(geoJson.map.projection)); + if (areProjectionsValid) { + return geoJsonArr; + } + throw new Error("PROJECTION_NOT_SUPPORTED"); + } + return geoJsonArr; + }); + }); + } + if (type === 'image/x-dxf' || type === 'image/vnd.dxf') { + return readDxf(file) + .then(({entities}) => { + const geoJSON = { + type: "Feature", + geometry: { + type: "LineString", + coordinates: entities[0].vertices.map(entity => { + return [entity.x, entity.y]; + }) + } + }; + if (entities[0].type === "LWPOLYLINE") { + return [{...geoJSON, "fileName": file.name, showProjectionCombobox: true}]; + } + throw new Error("GEOMETRY_NOT_SUPPORTED"); + + }); + } + return null; +}; + +/** + * Enhancers a component to process files on drop event. + * Recognizes map files (JSON format) or vector data in various formats. + * They are converted in JSON as a "files" property. + */ +export default compose( + mapPropsStream( + props$ => { + const { handler: onDrop, stream: drop$ } = createEventHandler(); + const { handler: onWarnings, stream: warnings$} = createEventHandler(); + return props$.combineLatest( + drop$.switchMap( + files => Rx.Observable.from(files) + .flatMap(checkFileType) // check file types are allowed + .flatMap(readFile(onWarnings)) // read files to convert to json + .map(res => { + return ({ + showProjectionCombobox: !!res[0].showProjectionCombobox, + loading: false, + flattenFeatures: flattenImportedFeatures(res), + crs: res[0]?.crs?.properties?.name ?? 'EPSG:3857' + }); + }) + .catch(error => Rx.Observable.of({error, loading: false})) + .startWith({ loading: true, showProjectionCombobox: false}) + ) + .startWith({}), + (p1, p2) => ({ + ...p1, + ...p2, + onDrop + }) + ).combineLatest( + warnings$ + .scan((warnings = [], warning) => ([...warnings, warning]), []) + .startWith(undefined), + (p1, warnings) => ({ + ...p1, + warnings + }) + ); + } + ) +); diff --git a/web/client/plugins/longitudinalProfile/enhancers/useFile.js b/web/client/plugins/longitudinalProfile/enhancers/useFile.js new file mode 100644 index 0000000000..1d9f4e236f --- /dev/null +++ b/web/client/plugins/longitudinalProfile/enhancers/useFile.js @@ -0,0 +1,54 @@ +/* + * Copyright 2022, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. +*/ +import { compose, mapPropsStream, withHandlers } from 'recompose'; + +import {reprojectGeoJson} from "../../../utils/CoordinatesUtils"; +import {selectLineFeature} from "../../../utils/LongitudinalProfileUtils"; + +/** + * Enhancer for processing json file with features + * Flatten features and then selects first line feature to build longitudinal profile out of it + */ +export default compose( + withHandlers({ + useFlattenFeatures: ({ + onClose = () => {}, + onChangeGeometry = () => {}, + onWarning = () => {} + }) => + (flattenFeatures, crs) => { + const { feature, coordinates } = selectLineFeature(flattenFeatures, crs); + if (feature && feature.showProjectionCombobox) { + // eslint-disable-next-line no-console + console.log(""); + } else if (feature && coordinates) { + const reprojectedFt = reprojectGeoJson(feature, "EPSG:4326", "EPSG:3857"); + onChangeGeometry({ + type: "LineString", + coordinates: reprojectedFt.geometry.coordinates, + projection: 'EPSG:3857' + }); + onClose(); + } else { + onWarning({ + title: "notification.warning", + message: "longitudinalProfile.warnings.noLineFeatureFound", + autoDismiss: 6, + position: "tc" + }); + } + } + }), + mapPropsStream(props$ => props$.merge( + props$ + .distinctUntilKeyChanged('flattenFeatures') + .filter(({flattenFeatures}) => flattenFeatures) + .do(({ flattenFeatures, crs, useFlattenFeatures = () => { }, warnings = []}) => useFlattenFeatures(flattenFeatures, crs, warnings)) + .ignoreElements() + )) +); diff --git a/web/client/plugins/longitudinalProfile/observables/wps/profile.js b/web/client/plugins/longitudinalProfile/observables/wps/profile.js new file mode 100644 index 0000000000..b2bae7644f --- /dev/null +++ b/web/client/plugins/longitudinalProfile/observables/wps/profile.js @@ -0,0 +1,40 @@ +/* + * Copyright 2023, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. +*/ +import { + literalData, + processData, + processParameter, + rawDataOutput, + responseForm +} from "../../../../observables/wps/common"; +import {executeProcessXML} from "../../../../observables/wps/execute"; + +const prepareGeometry = (geometry) => { + return `SRID=${geometry.projection.replace("EPSG:", "")};LINESTRING(${geometry?.coordinates?.map((point) => `${point[0]} ${point[1]}`).join(',')})`; +}; + +// const getCRS = (geometry) => geometry?.projection; + +/** + * Construct payload for request to obtain longitudinal profile data + * @param {string} downloadOptions options object + * @param {string} downloadOptions.identifier identifier of the process + * @param {string} downloadOptions.geometry geometry object to get line points from + * @param {string} downloadOptions.distance resolution of the requested data + * @param {string} downloadOptions.referential layer name + */ +export const profileEnLong = ({identifier, geometry, distance, referential}) => executeProcessXML( + identifier, + [ + processParameter('linestringEWKT', processData(literalData(prepareGeometry(geometry), "application/wkt"))), + processParameter('projection', processData(literalData("EPSG:3857"))), // parametrized? + processParameter('distance', processData(literalData(distance))), + processParameter('layerName', processData(literalData(referential))) + ], + responseForm(rawDataOutput('result')) +); diff --git a/web/client/product/plugins.js b/web/client/product/plugins.js index 34c02c2721..8870dc3cb4 100644 --- a/web/client/product/plugins.js +++ b/web/client/product/plugins.js @@ -5,26 +5,24 @@ * This source code is licensed under the BSD-style license found in the * LICENSE file in the root directory of this source tree. */ - -import {toModulePlugin} from "../utils/ModulePluginsUtils"; - import Context from "../plugins/Context"; import ContextCreator from "../plugins/ContextCreator"; import Dashboard from "../plugins/Dashboard"; import Dashboards from "../plugins/Dashboards"; import FeedbackMask from '../plugins/FeedbackMask'; -import GeoStory from "../plugins/GeoStory"; import GeoStories from "../plugins/GeoStories"; +import GeoStory from "../plugins/GeoStory"; +import Identify from '../plugins/Identify'; +import Login from '../plugins/Login'; import Maps from "../plugins/Maps"; +import Print from "../plugins/Print"; import RulesDataGrid from "../plugins/RulesDataGrid"; import RulesEditor from "../plugins/RulesEditor"; import RulesManagerFooter from "../plugins/RulesManagerFooter"; -import Print from "../plugins/Print"; import UserSession from "../plugins/UserSession"; -import Login from '../plugins/Login'; -import Identify from '../plugins/Identify'; import FeatureEditor from '../plugins/FeatureEditor'; +import {toModulePlugin} from "../utils/ModulePluginsUtils"; /** * Please, keep them sorted alphabetically @@ -36,16 +34,16 @@ export const plugins = { Dashboard: Dashboard, DashboardsPlugin: Dashboards, FeedbackMaskPlugin: FeedbackMask, - GeoStoryPlugin: GeoStory, GeoStoriesPlugin: GeoStories, + GeoStoryPlugin: GeoStory, + IdentifyPlugin: Identify, + LoginPlugin: Login, MapsPlugin: Maps, PrintPlugin: Print, RulesDataGridPlugin: RulesDataGrid, RulesEditorPlugin: RulesEditor, RulesManagerFooter: RulesManagerFooter, UserSessionPlugin: UserSession, - LoginPlugin: Login, - IdentifyPlugin: Identify, FeatureEditorPlugin: FeatureEditor, // ### DYNAMIC PLUGINS ### // @@ -103,6 +101,7 @@ export const plugins = { LayerDownload: toModulePlugin('LayerDownload', () => import(/* webpackChunkName: 'plugins/layerDownload' */ '../plugins/LayerDownload')), LayerInfoPlugin: toModulePlugin('LayerInfo', () => import(/* webpackChunkName: 'plugins/layerInfo' */ '../plugins/LayerInfo')), LocatePlugin: toModulePlugin('Locate', () => import(/* webpackChunkName: 'plugins/locate' */ '../plugins/Locate')), + LongitudinalProfileToolPlugin: toModulePlugin('LongitudinalProfileTool', () => import(/* webpackChunkName: 'plugins/LongitudinalProfileTool' */ '../plugins/LongitudinalProfileTool')), ManagerMenuPlugin: toModulePlugin('ManagerMenu', () => import(/* webpackChunkName: 'plugins/managerMenu' */ '../plugins/manager/ManagerMenu')), ManagerPlugin: toModulePlugin('Manager', () => import(/* webpackChunkName: 'plugins/manager' */ '../plugins/manager/Manager')), MapEditorPlugin: toModulePlugin('MapEditor', () => import(/* webpackChunkName: 'plugins/mapEditor' */ '../plugins/MapEditor')), diff --git a/web/client/reducers/longitudinalProfile.js b/web/client/reducers/longitudinalProfile.js new file mode 100644 index 0000000000..073a213225 --- /dev/null +++ b/web/client/reducers/longitudinalProfile.js @@ -0,0 +1,132 @@ +/* + * Copyright 2023, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. +*/ +import { + ADD_PROFILE_DATA, + CHANGE_CRS, + CHANGE_CHART_TITLE, + CHANGE_DISTANCE, + CHANGE_GEOMETRY, + CHANGE_REFERENTIAL, + INITIALIZED, + LOADING, + SETUP, + TEAR_DOWN, + TOGGLE_MAXIMIZE, + TOGGLE_MODE +} from "../actions/longitudinalProfile"; +import { LONGITUDINAL_DISTANCES } from '../plugins/longitudinalProfile/constants'; + +const DEFAULT_STATE = { + initialized: false, + loading: false, + maximized: false, + mode: "idle", + geometry: {}, + infos: {}, + points: [], + projection: "", + crsSelectedDXF: "EPSG:3857", + config: { + chartTitle: "", + wpsurl: "/geoserver/wps", + identifier: "gs:LongitudinalProfile", + referentials: [], + distances: LONGITUDINAL_DISTANCES, + defaultDistance: 100, + defaultReferentialName: "" + } +}; + +export default function longitudinalProfile(state = DEFAULT_STATE, action) { + const type = action.type; + switch (type) { + case ADD_PROFILE_DATA: + const { + infos, + points, + projection + } = action; + return { + ...state, + infos, + points, + projection + }; + case CHANGE_DISTANCE: + return { + ...state, + config: { + ...state.config, + distance: action.distance + } + }; + case CHANGE_CHART_TITLE: + return { + ...state, + config: { + ...state.config, + chartTitle: action.chartTitle + } + }; + case CHANGE_CRS: + return { + ...state, + crsSelectedDXF: action.crs + }; + case CHANGE_GEOMETRY: + return { + ...state, + geometry: action.geometry + }; + case CHANGE_REFERENTIAL: + return { + ...state, + config: { + ...state.config, + referential: action.referential + } + }; + case INITIALIZED: + return { + ...state, + initialized: true + }; + + case LOADING: + return { + ...state, + loading: action.state + }; + case SETUP: + return { + ...state, + config: { + ...state.config, + ...action.config + } + }; + case TOGGLE_MAXIMIZE: + return { + ...state, + maximized: !state.maximized + }; + case TOGGLE_MODE: + return { + ...state, + mode: state.mode !== action.mode ? action.mode : "idle" + }; + case TEAR_DOWN: + return { + ...DEFAULT_STATE, + initialized: state.initialized, + config: state.config + }; + default: + return state; + } +} diff --git a/web/client/selectors/controls.js b/web/client/selectors/controls.js index 0ef1c0870d..e5b0e07db0 100644 --- a/web/client/selectors/controls.js +++ b/web/client/selectors/controls.js @@ -33,5 +33,6 @@ export const drawerEnabledControlSelector = (state) => get(state, "controls.draw export const unsavedMapSelector = (state) => get(state, "controls.unsavedMap.enabled", false); export const unsavedMapSourceSelector = (state) => get(state, "controls.unsavedMap.source", ""); export const isIdentifyAvailable = (state) => get(state, "controls.info.available"); +export const isLongitudinalProfileEnabledSelector = (state) => get(state, "controls.longitudinalProfile.enabled"); export const showConfirmDeleteMapModalSelector = (state) => get(state, "controls.mapDelete.enabled", false); export const burgerMenuSelector = (state) => get(state, "controls.burgermenu.enabled", false); diff --git a/web/client/selectors/draw.js b/web/client/selectors/draw.js index 7b16aaae29..e915b15dad 100644 --- a/web/client/selectors/draw.js +++ b/web/client/selectors/draw.js @@ -12,6 +12,7 @@ import {createShallowSelectorCreator} from "../utils/ReselectUtils"; import {getLayerTitle} from "../utils/LayersUtils"; import {currentLocaleSelector} from "./locale"; + export const changedGeometriesSelector = state => state && state.draw && state.draw.tempFeatures; export const drawSupportActiveSelector = (state) => { const drawStatus = get(state, "draw.drawStatus", false); diff --git a/web/client/selectors/longitudinalProfile.js b/web/client/selectors/longitudinalProfile.js new file mode 100644 index 0000000000..905e30d9c2 --- /dev/null +++ b/web/client/selectors/longitudinalProfile.js @@ -0,0 +1,57 @@ +/* +* Copyright 2023, GeoSolutions Sas. +* All rights reserved. +* +* This source code is licensed under the BSD-style license found in the +* LICENSE file in the root directory of this source tree. +*/ +import {get, head} from "lodash"; + +import { + CONTROL_DOCK_NAME, + CONTROL_NAME, + CONTROL_PROPERTIES_NAME, + LONGITUDINAL_VECTOR_LAYER_ID, + LONGITUDINAL_VECTOR_LAYER_ID_POINT +} from '../plugins/longitudinalProfile/constants'; +import {additionalLayersSelector} from '../selectors/additionallayers'; +import {getSelectedLayer} from "../selectors/layers"; +import {mapSelector} from "../selectors/map"; + +/** + * selects longitudinalProfile state + * @name longitudinalProfile + * @memberof selectors + * @static + */ +export const isActiveSelector = (state) => state?.controls[CONTROL_NAME]?.enabled; +export const isParametersOpenSelector = (state) => state?.controls[CONTROL_PROPERTIES_NAME]?.enabled; +export const isDockOpenSelector = (state) => state?.controls[CONTROL_DOCK_NAME]?.enabled; + +export const isInitializedSelector = (state) => state?.longitudinalProfile?.initialized; +export const isLoadingSelector = (state) => state?.longitudinalProfile?.loading; +export const dataSourceModeSelector = (state) => state?.longitudinalProfile?.mode; +export const crsSelectedDXFSelector = (state) => state?.longitudinalProfile?.crsSelectedDXF || "EPSG:3857"; +export const geometrySelector = (state) => state?.longitudinalProfile?.geometry; +export const isActiveMenuSelector = (state) => isParametersOpenSelector(state) || dataSourceModeSelector(state) !== "idle"; +export const infosSelector = (state) => state?.longitudinalProfile?.infos; +export const pointsSelector = (state) => state?.longitudinalProfile?.points; +export const projectionSelector = (state) => state?.longitudinalProfile?.projection; +export const configSelector = (state) => state?.longitudinalProfile?.config; +export const referentialSelector = (state) => configSelector(state)?.referential; +export const chartTitleSelector = (state) => configSelector(state)?.chartTitle; +export const distanceSelector = (state) => configSelector(state)?.distance; + +export const isSupportedLayerSelector = (state) => { + const selectedLayer = getSelectedLayer(state); + const layerType = selectedLayer?.type; + return ['wms', 'wfs', 'vector'].includes(layerType) + && (layerType === 'wms' ? selectedLayer?.search?.type === 'wfs' : true) + && selectedLayer.visibility; +}; + +export const isListeningClickSelector = (state) => !!(get(mapSelector(state), 'eventListeners.click', []).find((el) => el === CONTROL_NAME)); + +export const isMaximizedSelector = (state) => state?.longitudinalProfile?.maximized; + +export const vectorLayerFeaturesSelector = (state) => head(additionalLayersSelector(state).filter(l => (l.id === LONGITUDINAL_VECTOR_LAYER_ID || l.id === LONGITUDINAL_VECTOR_LAYER_ID_POINT)))?.options?.features; diff --git a/web/client/selectors/maplayout.js b/web/client/selectors/maplayout.js index be31b65948..c0c35126a8 100644 --- a/web/client/selectors/maplayout.js +++ b/web/client/selectors/maplayout.js @@ -9,6 +9,7 @@ import {head, memoize} from 'lodash'; import { mapSelector } from './map'; import {DEFAULT_MAP_LAYOUT, parseLayoutValue} from '../utils/MapUtils'; + import ConfigUtils from "../utils/ConfigUtils"; /** @@ -143,3 +144,6 @@ export const mapPaddingSelector = state => { }; export const dockPanelsSelector = (state) => state?.maplayout?.dockPanels ?? { left: [], right: []}; + +export const dockStyleSelector = state => mapLayoutValuesSelector(state, { height: true, right: true }, true); +export const helpStyleSelector = state => mapLayoutValuesSelector(state, { right: true }, true); diff --git a/web/client/themes/dark/variables.less b/web/client/themes/dark/variables.less index 19ea90f5c5..f19d2ae4ed 100644 --- a/web/client/themes/dark/variables.less +++ b/web/client/themes/dark/variables.less @@ -1,5 +1,6 @@ @ms-main-color: #eeeeee; @ms-main-bg: #333333; +@ms-mask-bg: #00000088; @ms-main-border-color: #555555; @ms-main-variant-color: #eeeeee; diff --git a/web/client/themes/default/less/longitudinal-profile.less b/web/client/themes/default/less/longitudinal-profile.less new file mode 100644 index 0000000000..a2b426ab2c --- /dev/null +++ b/web/client/themes/default/less/longitudinal-profile.less @@ -0,0 +1,139 @@ +// +// Copyright 2023, GeoSolutions Sas. +// All rights reserved. +// +// This source code is licensed under the BSD-style license found in the +// LICENSE file in the root directory of this source tree. +// + +// ************** +// Theme +// ************** +#ms-components-theme(@theme-vars) { + .longitude-help { + .background-color-var(@theme-vars[mask-bg]); + .color-var(@theme-vars[main-bg]); + } + .longitudinal-tool-container { + .background-color-var(@theme-vars[main-bg]); + } + #dock-chart-portal.visible { + .background-color-var(@theme-vars[main-bg]); + } + .downloadButtons { + .background-color-var(@theme-vars[main-bg]); + } +} + +// ************** +// Layout +// ************** + +#longitudinal-profile-tool-container { + &.maximized { + z-index: 1300; + } + .properties { + padding: 15px; + } + .info-label { + padding-right: 15px; + } +} + +.longitudinal-profile-crs { + width: 200px; +} +.crs-container { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + .file-selected { + margin-top: 30px; + margin-bottom: 20px; + } + .form-group{ + display: flex; + } +} + +.longitude-help { + position: absolute; + right: 50%; + transform: translateX(50%); + top: 45px; + width: 300px; + border-radius: 3px; + z-index: 1; + padding: 10px; + opacity: 0.8; + pointer-events: none; + white-space: pre-line; +} + +.longitudinal-tool-container { + padding: 15px; + + &.maximized { + height: calc(100% - 30px); + width: calc(100% - 20px); + background: inherit; + z-index: 2000; + } + .data-used, .data-used td { + padding: 2px 6px; + } +} + +#dock-chart-portal { + width: 100vw; + z-index: 1200; + display: flex; + flex-direction: column; + position: absolute; + left: 0; + top: 0; + pointer-events: none; + &.visible { + pointer-events: all; + } +} + +.stats-entry { + align-items: center; + display: flex; + padding: 5px 0; +} + +.stats-value { + display: flex; + padding-left: 10px; +} + +.longitudinal-import { + z-index: 1; + position: relative; +} + +.modebar-container.hide { + display: none; +} +.chart-toolbar { + &.hide { + display: none; + } + &.btn-group { + display: flex; + justify-content: right; + padding: 0 5px; + } +} + +.downloadButtons { + padding: 15px; + button + button { + padding-left: 5px !important; + } + +} diff --git a/web/client/themes/default/less/mapstore.less b/web/client/themes/default/less/mapstore.less index e4568af780..3fe5e40483 100644 --- a/web/client/themes/default/less/mapstore.less +++ b/web/client/themes/default/less/mapstore.less @@ -39,6 +39,7 @@ @import "leaflet.less"; @import "loaders.less"; @import "loading-mask.less"; +@import "longitudinal-profile.less"; @import "manager.less"; @import "map-footer.less"; @import "map-search-bar.less"; diff --git a/web/client/themes/default/ms-variables.less b/web/client/themes/default/ms-variables.less index be3b058970..e8ca4c53af 100644 --- a/web/client/themes/default/ms-variables.less +++ b/web/client/themes/default/ms-variables.less @@ -37,6 +37,7 @@ @ms-main-color: @ms2-color-text; @ms-main-bg: @ms2-color-background; +@ms-mask-bg: #00000088; @ms-main-border-color: @ms2-color-shade-lighter; @ms-main-variant-color: @ms-main-color; @@ -96,6 +97,7 @@ // ************** // color for the base page and components main-bg: --ms-main-bg, @ms-main-bg; + mask-bg: --ms-mask-bg, @ms-mask-bg; main-color: --ms-main-color, @ms-main-color; main-border-color: --ms-main-border-color, @ms-main-border-color; diff --git a/web/client/translations/data.de-DE.json b/web/client/translations/data.de-DE.json index 60ea2a1699..bfde46d009 100644 --- a/web/client/translations/data.de-DE.json +++ b/web/client/translations/data.de-DE.json @@ -3053,6 +3053,10 @@ "description": "Anmeldungswerkzeug", "title": "Anmeldung" }, + "LongitudinalProfileTool": { + "description": "Ermöglicht dem Benutzer die Erstellung eines Längsprofils anhand einer Linienfolge und einer Ebene mit Höhenattribut. Muss konfiguriert werden", + "title": "Längsprofilwerkzeug" + }, "MapCatalog": { "description": "Ermöglicht das Durchsuchen, Bearbeiten, Löschen und Laden von Karten, die auf dem Server verfügbar sind", "title": "Kartenkatalog" @@ -3751,6 +3755,71 @@ }, "sidebarMenu": { "showMoreItems": "Weitere Elemente anzeigen" + }, + "longitudinalProfile": { + "open": "Längsprofil öffnen", + "close": "Längsprofil schließen", + "title": "Längsprofil", + "draw": "Linie zeichnen", + "import": "Lade Datei", + "select": "Auswahl zum Profilieren", + "parameters": "Parameter", + "elevation": "Elevation (m)", + "crsSelector": "Geben Sie eine Projektion für die DXF-Datei an", + "distance": "Distanz (m)", + "chart": "Diagramm", + "infos": "Information", + "preferences": "Präferenzen", + "CRS": "CRS", + "uom": "Maßeinheiten", + "fileSelected": "Datei ausgewählt: ", + "uomMeters": "meter", + "source": "Quelle", + "downloadCSV": "CSV", + "downloadPNG": "PNG", + "downloadPDF": "PDF", + "info": { + "points": " punkte", + "totalPoints": "Anzahl der verarbeiteten Punkte:", + "layer":"Schicht:", + "line":"Distance:", + "up":"Kumulierter Höhenunterschied:", + "down":"Kumulativer Höhenverlust:", + "noInfos": "Keine Informationen verfügbar" + }, + "help": { + "draw": "Klicken Sie auf die Karte, um die Linie zu zeichnen. Klicken Sie zum Abschluss noch einmal auf den Endpunkt.", + "select": "Bitte wählen Sie die Ebene im Inhaltsverzeichnis aus und klicken Sie auf das Linienmerkmal, um ein Profil zu erstellen.
Ausgewählte Ebenen: {layerName}", + "noLayer": "Keine Ebene ausgewählt", + "notSupportedLayer": "nicht unterstützt. Bitte wählen Sie WMS, WFS oder Vektorebene aus." + }, + "warnings": { + "noLayerSelected": "Bitte wählen Sie zuerst die Ebene aus", + "layerNotSupported": "Die ausgewählte Ebene wird nicht unterstützt. Bitte wählen Sie WMS, WFS oder Vektorebene aus.", + "noFeatureInPoint": "Das Linienmerkmal wurde am ausgewählten Punkt nicht gefunden.", + "noLineFeatureFound": "Das Linienmerkmal wurde am ausgewählten Punkt nicht gefunden.", + "fallbackToProjection": "Projektion der Standardreferenz \"{defaultReferential}\" wird nicht unterstützt. Rückgriff auf Referenzen \"{referential}\" mit \"{projection}\" Projektion." + }, + "errors": { + "outsideCoverage": "Die angegebene Zeile liegt außerhalb der Profilabdeckung", + "loadingError": "Fehler beim Laden der Daten für das Längsprofil", + "unableToSetupPlugin": "Die Längsprofilverlängerung kann nicht eingerichtet werden.", + "defaultReferentialNotFound": "Die Standardreferenz ist konfiguriert, kann jedoch nicht in der Referenzliste gefunden werden. Bitte aktualisieren Sie die Erweiterungskonfiguration.", + "projectionNotSupported": "Referenz mit von der Karte unterstützter Projektion kann nicht gefunden werden. Bitte aktualisieren Sie die Erweiterungskonfiguration oder fügen Sie die gewünschte Projektion zur App-Konfiguration hinzu.", + "cannotDownloadPDF": "Es ist nicht möglich, die aktuelle Karte zu drucken, da sie Verweise auf externe Domänen enthält. Bitte entfernen Sie diese, speichern Sie die Karte, aktualisieren Sie die Seite und versuchen Sie es erneut.", + "cannotDownloadPNG": "Das Diagramm kann nicht als Bild heruntergeladen werden." + }, + "settings": { + "chartTitle": "Diagrammtitel", + "referential": "Profilebene", + "distance": "Distanz (m)" + }, + "dropZone": { + "heading": "

Legen Sie Ihre Datei hier ab

oder

", + "selectFiles": "Datei aussuchen...", + "infoSupported": "

Unterstützte Dateitypen: GeoJSON, DXF, Shapefiles

", + "dxfGeometryNotSupported": "Es wird nur LWPOLYLINE unterstützt" + } } } } diff --git a/web/client/translations/data.en-US.json b/web/client/translations/data.en-US.json index b059ef8663..6e89edae27 100644 --- a/web/client/translations/data.en-US.json +++ b/web/client/translations/data.en-US.json @@ -3027,6 +3027,10 @@ "description": "Login tool", "title": "Login" }, + "LongitudinalProfileTool": { + "description": "Allows user to generate a longitudinal profile given a linestring and a layer with elevation attribute. Needs configuration", + "title": "Longitudinal Profile tool" + }, "MapCatalog": { "description": "Allows browsing, editing, deleting and loading of maps that are available on the server", "title": "Map Catalog" @@ -3725,6 +3729,71 @@ }, "sidebarMenu": { "showMoreItems": "Show more items" + }, + "longitudinalProfile": { + "open": "Open Longitudinal Profile", + "close": "Close Longitudinal Profile", + "title": "Longitudinal profile", + "draw": "Draw line", + "import": "Load file", + "select": "Selection to profile", + "parameters": "Parameters", + "elevation": "Elevation (m)", + "crsSelector": "Specify a projection for the DXF file", + "distance": "Distance (m)", + "chart": "Chart", + "infos": "Information", + "preferences": "Preferences", + "CRS": "CRS", + "uom": "Units", + "fileSelected": "File selected: ", + "uomMeters": "meters", + "source": "Source", + "downloadCSV": "CSV", + "downloadPNG": "PNG", + "downloadPDF": "PDF", + "info": { + "points": " points", + "totalPoints": "Number of points processed:", + "layer":"Layer:", + "line":"Distance:", + "up":"Cumulative elevation gain:", + "down":"Cumulative elevation loss:", + "noInfos": "No info available" + }, + "help": { + "draw": "Click on the map to draw the line. Click one more time on end point to finish.", + "select": "Please select layer in TOC and click on the line feature to generate profile.
Selected layer: {layerName}", + "noLayer": "No layer selected", + "notSupportedLayer": "not supported. Please select WMS, WFS or vector layer." + }, + "warnings": { + "noLayerSelected": "Please select layer first.", + "layerNotSupported": "Selected layer is not supported. Please select WMS, WFS or vector layer.", + "noFeatureInPoint": "Line feature was not found at selected point.", + "noLineFeatureFound": "Line feature was not found in imported file.", + "fallbackToProjection": "Projection of default referential \"{defaultReferential}\" is not supported. Falling back to referential \"{referential}\" with \"{projection}\" projection." + }, + "errors": { + "outsideCoverage": "The provided line is outside the profile coverage", + "loadingError": "Error loading data for longitudinal profile", + "unableToSetupPlugin": "Unable to setup longitudinal profile extension", + "defaultReferentialNotFound": "Default referential is configured but cannot be found in referentials list. Please update extension configuration.", + "projectionNotSupported": "Referential with projection supported by map cannot be found. Please update extension configuration or add desired projection into app configuration.", + "cannotDownloadPDF": "It is not possible to print the current map as it contains references to external domain, please remove them save the map, refresh the page and try again.", + "cannotDownloadPNG": "Cannot download the chart as image." + }, + "settings": { + "chartTitle": "Chart Title", + "referential": "Profile layer", + "distance": "Distance (m)" + }, + "dropZone": { + "heading": "

Drop your file here

or

", + "selectFiles": "Select file...", + "infoSupported": "

Supported file types: GeoJSON, DXF, Shapefiles

", + "dxfGeometryNotSupported": "Only LWPOLYLINE is supported" + } } } } diff --git a/web/client/translations/data.es-ES.json b/web/client/translations/data.es-ES.json index 46a21e669d..dc99342cf7 100644 --- a/web/client/translations/data.es-ES.json +++ b/web/client/translations/data.es-ES.json @@ -3015,6 +3015,10 @@ "description": "Login Tool", "title": "Login" }, + "LongitudinalProfileTool": { + "description": "Permite al usuario generar un perfil longitudinal dada una línea lineal y una capa con atributo de elevación. Necesita configuración", + "title": "Herramienta Perfil longitudinal" + }, "MapCatalog": { "description": "Permite navegar, editar, borrar y cargar mapas que están disponibles en el servidor", "title": "Catálogo de mapas" @@ -3713,6 +3717,71 @@ }, "sidebarMenu": { "showMoreItems": "Mostrar más elementos" + }, + "longitudinalProfile": { + "open": "Perfil Longitudinal abierto", + "close": "Cerrar Perfil Longitudinal", + "title": "Perfil longitudinal", + "draw": "Dibujar linea", + "import": "Cargar archivo", + "select": "Selección a perfil", + "parameters": "Parámetros", + "elevation": "Elevación (m)", + "crsSelector": "Especifique una proyección para el archivo DXF", + "distance": "Distancia (m)", + "chart": "Cuadro", + "infos": "Información", + "preferences": "Preferencias", + "CRS": "CRS", + "uom": "Unidades de medida", + "fileSelected": "archivo seleccionado: ", + "uomMeters": "metros", + "source": "Fuente", + "downloadCSV": "CSV", + "downloadPNG": "PNG", + "downloadPDF": "PDF", + "info": { + "points": " puntos", + "totalPoints": "Número de puntos procesados:", + "layer":"Capa:", + "line":"Couche:", + "up":"Ganancia de elevación acumulada:", + "down":"Pérdida de elevación acumulada:", + "noInfos": "No hay información disponible" + }, + "help": { + "draw": "Haga clic en el mapa para dibujar la línea. Haga clic una vez más en el punto final para terminar.", + "select": "Seleccione la capa en TOC y haga clic en la entidad de línea para generar el perfil.
capa seleccionada: {layerName}", + "noLayer": "Ninguna capa seleccionada", + "notSupportedLayer": "No soportado. Seleccione WMS, WFS o capa vectorial." + }, + "warnings": { + "noLayerSelected": "Seleccione la capa primero.", + "layerNotSupported": "La capa seleccionada no es compatible. Seleccione WMS, WFS o capa vectorial.", + "noFeatureInPoint": "No se encontró la entidad de línea en el punto seleccionado.", + "noLineFeatureFound": "No se encontró la característica de línea en el archivo importado.", + "fallbackToProjection": "Proyección de referencial por defecto \"{defaultReferential}\" no es apoyado. Volviendo a las referencias \"{referential}\" con este \"{projection}\" proyección." + }, + "errors": { + "outsideCoverage": "La línea proporcionada está fuera de la cobertura del perfil.", + "loadingError": "Error al cargar datos para perfil longitudinal", + "unableToSetupPlugin": "No se puede configurar la extensión del perfil longitudinal.", + "defaultReferentialNotFound": "El referencial predeterminado está configurado pero no se puede encontrar en la lista de referencias. Actualice la configuración de la extensión.", + "projectionNotSupported": "No se encuentra referencial con proyección apoyada en mapa. Actualice la configuración de la extensión o agregue la proyección deseada en la configuración de la aplicación.", + "cannotDownloadPDF": "No es posible imprimir el mapa actual ya que contiene referencias a un dominio externo, elimínelos, guarde el mapa, actualice la página y vuelva a intentarlo.", + "cannotDownloadPNG": "No se puede descargar el gráfico como imagen." + }, + "settings": { + "chartTitle": "Titulo del gráfico", + "referential": "Capa de perfil", + "distance": "Distancia (m)" + }, + "dropZone": { + "heading": "

Deje su archivo aquí

o

", + "selectFiles": "Seleccione Archivo...", + "infoSupported": "

Tipos de archivos compatibles: GeoJSON, DXF, Shapefiles

", + "dxfGeometryNotSupported": "Solo se admite LWPOLYLINE" + } } } } diff --git a/web/client/translations/data.fr-FR.json b/web/client/translations/data.fr-FR.json index 069f16d956..179de42f0f 100644 --- a/web/client/translations/data.fr-FR.json +++ b/web/client/translations/data.fr-FR.json @@ -3017,6 +3017,10 @@ "description": "Authentification", "title": "Login" }, + "LongitudinalProfileTool": { + "description": "Permet à l'utilisateur de générer un profil longitudinal à partir d'une chaîne de lignes et d'un calque avec attribut d'élévation. Configuration requise", + "title": "Outil Profil longitudinal" + }, "MapCatalog": { "description": "Permet la navigation, l'édition, la suppression et le chargement des cartes disponibles sur le serveur", "title": "Catalogue de cartes" @@ -3714,6 +3718,71 @@ }, "sidebarMenu": { "showMoreItems": "Afficher plus d'éléments" + }, + "longitudinalProfile": { + "open": "Ouvrir le profil en long", + "close": "Fermer le profil en long", + "title": "Profil en long", + "draw": "Dessiner une ligne", + "import": "Fichier de chargement", + "select": "Sélection au profil", + "parameters": "Paramètres", + "elevation": "Élévation (m)", + "crsSelector": "Spécifiez une projection pour le fichier DXF", + "distance": "Distance (m)", + "chart": "Graphique", + "infos": "Information", + "preferences": "Préférences", + "CRS": "CRS", + "uom": "Unités de mesure", + "fileSelected": "Fichier sélectionné: ", + "uomMeters": "mètres", + "source": "Source", + "downloadCSV": "CSV", + "downloadPNG": "PNG", + "downloadPDF": "PDF", + "info": { + "points": " points:", + "totalPoints": "Nombre de points traités:", + "layer":"Layer:", + "line":"Distance:", + "up":"Gain d'altitude cumulé.", + "down":"Perte d'altitude cumulée:", + "noInfos": "Aucune information disponible" + }, + "help": { + "draw": "Cliquez sur la carte pour tracer la ligne. Cliquez une fois de plus sur le point final pour terminer.", + "select": "Veuillez sélectionner la couche dans la table des matières et cliquez sur l'entité linéaire pour générer le profil.
Couches sélectionnées: {layerName}", + "noLayer": "Aucun calque sélectionné", + "notSupportedLayer": "non supporté. Veuillez sélectionner WMS, WFS ou couche vectorielle." + }, + "warnings": { + "noLayerSelected": "Veuillez d'abord sélectionner le calque.", + "layerNotSupported": "La couche sélectionnée n'est pas prise en charge. Veuillez sélectionner WMS, WFS ou couche vectorielle.", + "noFeatureInPoint": "L'entité linéaire n'a pas été trouvée au point sélectionné.", + "noLineFeatureFound": "L'entité linéaire n'a pas été trouvée dans le fichier importé.", + "fallbackToProjection": "Projection du référentiel par défaut \"{defaultReferential}\" n'est pas pris en charge. Se replier sur des référentiels \"{referential}\" avec ça \"{projection}\" projection." + }, + "errors": { + "outsideCoverage": "La ligne fournie est en dehors de la couverture du profil", + "loadingError": "Erreur lors du chargement des données pour le profil longitudinal", + "unableToSetupPlugin": "Impossible de configurer l'extension du profil longitudinal.", + "defaultReferentialNotFound": "Le référentiel par défaut est configuré mais introuvable dans la liste des référentiels. Veuillez mettre à jour la configuration de l'extension.", + "projectionNotSupported": "Le référentiel avec projection prise en charge par la carte est introuvable. Veuillez mettre à jour la configuration de l'extension ou ajouter la projection souhaitée dans la configuration de l'application.", + "cannotDownloadPDF": "Il n'est pas possible d'imprimer la carte actuelle car elle contient des références à un domaine externe, veuillez les supprimer, enregistrer la carte, actualiser la page et réessayer.", + "cannotDownloadPNG": "Impossible de télécharger le graphique en tant qu'image." + }, + "settings": { + "chartTitle": "Titre du graphique", + "referential": "Couche de profil", + "distance": "Distance (m)" + }, + "dropZone": { + "heading": "

Déposez votre fichier ici

ou

", + "selectFiles": "Choisir le dossier...", + "infoSupported": "

Types de fichiers pris en charge: GeoJSON, DXF, Shapefiles

", + "dxfGeometryNotSupported": "Seul LWPOLYLINE est pris en charge" + } } } } diff --git a/web/client/translations/data.it-IT.json b/web/client/translations/data.it-IT.json index e9e39b77c3..13e31c0cdd 100644 --- a/web/client/translations/data.it-IT.json +++ b/web/client/translations/data.it-IT.json @@ -3017,6 +3017,10 @@ "description": "Login Tool", "title": "Login" }, + "LongitudinalProfileTool": { + "description": "Consente all'utente di generare un profilo longitudinale data una geomtria lineare e un layer con attributo di elevazione. Necessita di configurazione", + "title": "Profilo Longitudinale" + }, "MapCatalog": { "description": "Consente la navigazione, la modifica, l'eliminazione e il caricamento di mappe disponibili sul server", "title": "Catalogo mappe" @@ -3714,6 +3718,71 @@ }, "sidebarMenu": { "showMoreItems": "Mostra più elementi" + }, + "longitudinalProfile": { + "open": "Apri profilo longitudinale", + "close": "Chiudi profilo longitudinale", + "title": "Profilo longitudinale", + "draw": "Disegna profilo", + "import": "Carica file", + "select": "Profilo da livello", + "parameters": "Parametri", + "elevation": "Altitudine (m)", + "crsSelector": "Specifica una proiezione per il file DXF", + "distance": "Distanza (m)", + "chart": "Grafico", + "infos": "Informazione", + "preferences": "Preferenze", + "CRS": "CRS", + "uom": "Unità", + "fileSelected": "File selezionato: ", + "uomMeters": "metri", + "source": "Sorgente", + "downloadCSV": "CSV", + "downloadPNG": "PNG", + "downloadPDF": "PDF", + "info": { + "points": " punti", + "totalPoints": "Numero di punti processati:", + "layer":"Livello:", + "line":"Distanza:", + "up":"Dislivello Positivo:", + "down":"Dislivello Negativo:", + "noInfos": "Nessuna informazione disponibile" + }, + "help": { + "draw": "Clicca per disegnare una linea in mappa. Clicca ancora una volta per concludere l'interazione.", + "select": "Per favore seleziona un livello nella TOC e clicca una linea in mappa per generare il profilo
Livello selezionato: {layerName}", + "noLayer": "Nessun livello selezionato", + "notSupportedLayer": "Non supportato. Per favore seleziona WMS, WFS o un livello vettoriale." + }, + "warnings": { + "noLayerSelected": "Per favore seleziona prima un livello", + "layerNotSupported": "Il livello selezionato non è supportato. Per favore WMS, WFS o un livello vettoriale", + "noFeatureInPoint": "Non è stata trovata una linea nel punto cliccato.", + "noLineFeatureFound": "Non è stata trovata una linea nelfile importato.", + "fallbackToProjection": "La proiezione del livello di riferimento \"{defaultReferential}\" non è supportata. Viene pertanto usato questo livello di riferimento \"{referential}\" con questa proiezione \"{projection}\"." + }, + "errors": { + "outsideCoverage": "La linea fornita si trova al di fuori del bbox del livello", + "loadingError": "Errore durante il caricamento di dati per il profilo longitudinale", + "unableToSetupPlugin": "Impossibile inizializzare il profilo longitudinale", + "defaultReferentialNotFound": "Il livello di riferimento di default è configurato ma non è stato trovato nella lista. Per favore aggiorna il problema della configurazione dello strumento.", + "projectionNotSupported": "Il livello di riferimento con la proiezione supportata dalla mappa non è stato trovato. Per favore aggiorna la configurazione dello strumento o aggiungi la proiezione non supportata.", + "cannotDownloadPDF": "Non è stato possibile scaricare il PDF contente la mappa attuale perchè contiene riferimenti esterni a questo dominio, rimuovili, salva la mappa e ricarica la pagina per riprovare.", + "cannotDownloadPNG": "Non è stato possibile scaricare il grafico in PNG." + }, + "settings": { + "chartTitle": "Titolo del grafico", + "referential": "Livello del profilo", + "distance": "Distanza (m)" + }, + "dropZone": { + "heading": "

Carica il file qui

o

", + "selectFiles": "Seleziona il file...", + "infoSupported": "

Formati supportati: GeoJSON, DXF, Shapefiles

", + "dxfGeometryNotSupported": "Solo LWPOLYLINE è supportata nel DXF" + } } } } diff --git a/web/client/utils/FileUtils.js b/web/client/utils/FileUtils.js index 63ed005733..e58fa75a50 100644 --- a/web/client/utils/FileUtils.js +++ b/web/client/utils/FileUtils.js @@ -8,6 +8,7 @@ import FileSaver from 'file-saver'; +import { DxfParser } from 'dxf-parser'; import toBlob from 'canvas-to-blob'; import shp from 'shpjs'; import tj from '@mapbox/togeojson'; @@ -37,6 +38,7 @@ export const MIME_LOOKUPS = { 'gpx': 'application/gpx+xml', 'kmz': 'application/vnd.google-earth.kmz', 'kml': 'application/vnd.google-earth.kml+xml', + 'dxf': 'image/vnd.dxf', 'zip': 'application/zip', 'json': 'application/json', 'geojson': 'application/json', @@ -135,6 +137,24 @@ export const readGeoJson = function(file, warnings = false) { reader.readAsText(file); }); }; +export const readDxf = function(file) { + return new Promise((resolve, reject) => { + let reader = new FileReader(); + reader.onload = function() { + try { + const parserDXF = new DxfParser(); + const dxf = parserDXF.parseSync(reader.result); + resolve(dxf); + } catch (err) { + reject(err); + } + }; + reader.onerror = function() { + reject(reader.error.name); + }; + reader.readAsText(file); + }); +}; export const readWMC = function(file) { return new Promise((resolve, reject) => { let reader = new FileReader(); diff --git a/web/client/utils/LongitudinalProfileUtils.js b/web/client/utils/LongitudinalProfileUtils.js new file mode 100644 index 0000000000..a4e9da56c3 --- /dev/null +++ b/web/client/utils/LongitudinalProfileUtils.js @@ -0,0 +1,69 @@ +/* + * Copyright 2023, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. +*/ +import {parseURN, reprojectGeoJson} from "../utils/CoordinatesUtils"; + +/** + * Utility function to traverse through json input recursively and build a flat array of features + * @param json + * @param features + * @returns {*[]|*} + */ +export const flattenImportedFeatures = (json, features = undefined) => { + let flatten = []; + if (typeof features !== 'undefined') { + flatten = features; + } + if (json?.layers && Array.isArray(json.layers)) { + return json.layers.forEach(l => flattenImportedFeatures(l, flatten)); + } + if (json?.map && json.map?.layers) { + flattenImportedFeatures(json.map?.layers, flatten); + } + if (Array.isArray(json)) { + json.forEach(el => flattenImportedFeatures(el, flatten)); + } + if (json?.features && Array.isArray(json.features)) { + json.features.forEach(feature => flattenImportedFeatures(feature, flatten)); + } + if (json?.type === 'Feature') { + flatten.push(json); + } + return flatten; +}; + +/** + * Finds first line feature in array of features and reprojects geometry for further use in WPS request + * @param collection + * @param projection + * @returns {{feature: *, coordinates: *, reprojected: (*)}|{feature: undefined, coordinates: undefined, reprojected: undefined}} + */ +export const selectLineFeature = (collection, projection = "EPSG:4326") => { + const parsedProjectionName = parseURN(projection); + const feature = collection.find((f) => ["LineString", "MultiLineString"].includes(f?.geometry?.type)); + if (feature) { + const reprojected = parsedProjectionName !== "EPSG:3857" ? reprojectGeoJson(feature, parsedProjectionName, "EPSG:3857") : feature; + const coordinates = reprojected.geometry.type === "MultiLineString" ? reprojected.geometry.coordinates[0] : reprojected.geometry.coordinates; + return { feature, reprojected, coordinates }; + } + return { feature: undefined, reprojected: undefined, coordinates: undefined }; +}; + +/** + * Applies style to the features list + * @param features + * @param style + * @returns {*} + */ +export const styleFeatures = (features, style) => { + return features.map((feature) => { + return { + ...feature, + style + }; + }); +}; diff --git a/web/client/utils/styleparser/CesiumStyleParser.js b/web/client/utils/styleparser/CesiumStyleParser.js index a64d24fdab..841a5db38e 100644 --- a/web/client/utils/styleparser/CesiumStyleParser.js +++ b/web/client/utils/styleparser/CesiumStyleParser.js @@ -336,6 +336,7 @@ const getGraphics = ({ billboard: new Cesium.BillboardGraphics({ image, scale, + pixelOffset: symbolizer.offset ? new Cesium.Cartesian2(symbolizer.offset[0], symbolizer.offset[1]) : null, rotation: Cesium.Math.toRadians(-1 * symbolizer.rotate || 0), disableDepthTestDistance: symbolizer.msBringToFront ? Number.POSITIVE_INFINITY : 0, heightReference: Cesium.HeightReference[HEIGHT_REFERENCE_CONSTANTS_MAP[symbolizer.msHeightReference] || 'NONE'], diff --git a/web/client/utils/styleparser/OLStyleParser.js b/web/client/utils/styleparser/OLStyleParser.js index 839df670ba..defa7a0efa 100644 --- a/web/client/utils/styleparser/OLStyleParser.js +++ b/web/client/utils/styleparser/OLStyleParser.js @@ -745,7 +745,9 @@ export class OlStyleParser { scale: this._computeIconScaleBasedOnSymbolizer(symbolizer), // Rotation in openlayers is radians while we use degree rotation: (typeof (symbolizer.rotate) === 'number' ? symbolizer.rotate * Math.PI / 180 : undefined), - displacement: symbolizer.offset + displacement: symbolizer.offset, + anchor: symbolizer.anchor + }; // check if IconSymbolizer.image contains a placeholder const prefix = '\\{\\{';