From fa7e48278989808f9278779de07aab0cf1004080 Mon Sep 17 00:00:00 2001 From: Fritz Hoeing Date: Fri, 22 Nov 2024 16:15:40 +0100 Subject: [PATCH 1/2] feat: adds wms styling using geostyler --- .../StylingDrawer/SldStylingPanel/index.tsx | 109 ++++++++++++++++ src/components/StylingDrawer/index.tsx | 44 ++++++- .../index.tsx | 10 +- .../LayerTree/LayerTreeContextMenu/index.tsx | 37 +++++- src/store/store.ts | 2 + src/store/stylingDrawerLayerUid/index.tsx | 25 ++++ src/utils/geoserverUtils.tsx | 123 ++++++++++++++++++ 7 files changed, 340 insertions(+), 10 deletions(-) create mode 100644 src/components/StylingDrawer/SldStylingPanel/index.tsx rename src/components/ToolMenu/Draw/StylingDrawerButton/{StylingComponent => DrawLayerStylingPanel}/index.tsx (95%) create mode 100644 src/store/stylingDrawerLayerUid/index.tsx create mode 100644 src/utils/geoserverUtils.tsx diff --git a/src/components/StylingDrawer/SldStylingPanel/index.tsx b/src/components/StylingDrawer/SldStylingPanel/index.tsx new file mode 100644 index 000000000..d39076141 --- /dev/null +++ b/src/components/StylingDrawer/SldStylingPanel/index.tsx @@ -0,0 +1,109 @@ +import React, { + useEffect, useState, useCallback +} from 'react'; + +import { + CardStyle, CardStyleProps +} from 'geostyler/dist/Component/CardStyle/CardStyle'; +import SLDParser from 'geostyler-sld-parser'; +import { Style as GsStyle } from 'geostyler-style'; +import Layer from 'ol/layer/Layer'; +import { + ImageWMS, TileWMS +} from 'ol/source'; + +import { Logger } from '@terrestris/base-util'; +import { MapUtil } from '@terrestris/ol-util'; +import { useMap } from '@terrestris/react-util/dist/Hooks/useMap/useMap'; + +import useAppSelector from '../../../hooks/useAppSelector'; +import useSHOGunAPIClient from '../../../hooks/useSHOGunAPIClient'; +import { + fetchGeoserverStyle, fetchWorkspaceFromGetCapabilities, getLayerUrl +} from '../../../utils/geoserverUtils'; + +export type SldStylingPanelProps = CardStyleProps; + +const SldStylingPanel: React.FC = (props): JSX.Element => { + const [style, setStyle] = useState(); + const layerUid = useAppSelector(state => state.stylingDrawerLayerUid); + const map = useMap(); + const client = useSHOGunAPIClient(); + + const getStyle = useCallback(async () => { + if (!map || !layerUid) { + return; + } + + const layer = MapUtil.getLayerByOlUid(map, layerUid) as Layer; + if (!layer) { + return; + } + + const source = layer.getSource(); + if (!(source instanceof ImageWMS || source instanceof TileWMS)) { + return; + } + + const layerUrl = getLayerUrl(source); + if (!layerUrl) { + return; + } + + try { + const workspaceInfo = await fetchWorkspaceFromGetCapabilities(layerUrl, client); + if (!workspaceInfo) {return;} + + const geoserverStyle = await fetchGeoserverStyle(workspaceInfo.workspace, workspaceInfo.layerName, layerUrl, client); + if (geoserverStyle) { + const parser = new SLDParser(); + const { output: sldObject } = await parser.readStyle(geoserverStyle); + setStyle(sldObject); + } + } catch (error) { + Logger.error('Error: ', error); + } + }, [map, layerUid, client]); + + useEffect(() => { + getStyle(); + }, [getStyle]); + + useEffect(() => { + if (!map || !style) { + return; + } + + const layer = MapUtil.getLayerByOlUid(map, layerUid) as Layer; + const source = layer?.getSource() as ImageWMS; + if (!source) { + return; + } + + style.name = source.getParams().LAYERS; + + const parser = new SLDParser(); + parser.writeStyle(style).then(({ output: sld }) => { + if (sld) { + source.updateParams({ + SLD_BODY: sld, + STYLES: style.name + }); + } + }); + }, [style, map, layerUid]); + + if (!map || !style) { + return <>; + } + + return ( + + ); +}; + +export default SldStylingPanel; diff --git a/src/components/StylingDrawer/index.tsx b/src/components/StylingDrawer/index.tsx index 5be51db1d..d1365798b 100644 --- a/src/components/StylingDrawer/index.tsx +++ b/src/components/StylingDrawer/index.tsx @@ -1,20 +1,33 @@ -import React from 'react'; +import React, { + useEffect, useState +} from 'react'; import { Drawer, DrawerProps } from 'antd'; +import Layer from 'ol/layer/Layer'; +import { + ImageWMS, TileWMS +} from 'ol/source'; import { useTranslation } from 'react-i18next'; import './index.less'; +import { MapUtil } from '@terrestris/ol-util'; + +import { useMap } from '@terrestris/react-util'; + import useAppDispatch from '../../hooks/useAppDispatch'; import useAppSelector from '../../hooks/useAppSelector'; +import { clearStylingDrawerLayerUid } from '../../store/stylingDrawerLayerUid'; import { setStylingDrawerVisibility } from '../../store/stylingDrawerVisibility'; -import StylingComponent from '../ToolMenu/Draw/StylingDrawerButton/StylingComponent'; +import DrawLayerStylingPanel from '../ToolMenu/Draw/StylingDrawerButton/DrawLayerStylingPanel'; + +import SldStylingPanel from './SldStylingPanel'; export type StylingDrawerProps = DrawerProps; @@ -22,15 +35,40 @@ export const StylingDrawer: React.FC = ({ ...passThroughProps }): JSX.Element => { + const [isImageLayer, setIsImageLayer] = useState(); const dispatch = useAppDispatch(); const isStylingDrawerVisible = useAppSelector(state => state.stylingDrawerVisibility); + const layerUid = useAppSelector(state => state.stylingDrawerLayerUid); + const map = useMap(); const { t } = useTranslation(); + useEffect(() => { + + if (!layerUid) { + setIsImageLayer(false); + } + + if (!map) { + return; + } + const layer = MapUtil.getLayerByOlUid(map, layerUid) as Layer; + if (!layer) { + return; + } + const layerSource = layer.getSource(); + if (layerSource instanceof TileWMS || layerSource instanceof ImageWMS){ + setIsImageLayer(true); + } else { + setIsImageLayer(false); + } + }, [layerUid, map]); + const onClose = () => { dispatch(setStylingDrawerVisibility(false)); + dispatch(clearStylingDrawerLayerUid()); }; return ( @@ -44,7 +82,7 @@ export const StylingDrawer: React.FC = ({ mask={false} {...passThroughProps} > - + {isImageLayer ? : } ); }; diff --git a/src/components/ToolMenu/Draw/StylingDrawerButton/StylingComponent/index.tsx b/src/components/ToolMenu/Draw/StylingDrawerButton/DrawLayerStylingPanel/index.tsx similarity index 95% rename from src/components/ToolMenu/Draw/StylingDrawerButton/StylingComponent/index.tsx rename to src/components/ToolMenu/Draw/StylingDrawerButton/DrawLayerStylingPanel/index.tsx index 4f9f3dd03..96699c173 100644 --- a/src/components/ToolMenu/Draw/StylingDrawerButton/StylingComponent/index.tsx +++ b/src/components/ToolMenu/Draw/StylingDrawerButton/DrawLayerStylingPanel/index.tsx @@ -31,9 +31,9 @@ import { useMap } from '@terrestris/react-util/dist/Hooks/useMap/useMap'; -export type StylingComponentProps = CardStyleProps; +export type DrawLayerStylingPanelProps = CardStyleProps; -export const StylingComponent: React.FC = ({ +export const DrawLayerStylingPanel: React.FC = ({ ...passThroughProps }): JSX.Element => { @@ -113,6 +113,10 @@ export const StylingComponent: React.FC = ({ const drawVectorLayer = MapUtil.getLayerByName(map, 'react-geo_digitize') as OlLayerVector; + if (!drawVectorLayer) { + return; + } + const parseStyles = async () => { let olStylePolygon: OlStyleLike; let olStyleLineString: OlStyleLike; @@ -187,4 +191,4 @@ export const StylingComponent: React.FC = ({ ); }; -export default StylingComponent; +export default DrawLayerStylingPanel; diff --git a/src/components/ToolMenu/LayerTree/LayerTreeContextMenu/index.tsx b/src/components/ToolMenu/LayerTree/LayerTreeContextMenu/index.tsx index 345f562af..8eceac2fb 100644 --- a/src/components/ToolMenu/LayerTree/LayerTreeContextMenu/index.tsx +++ b/src/components/ToolMenu/LayerTree/LayerTreeContextMenu/index.tsx @@ -1,10 +1,12 @@ import React, { + useEffect, useState } from 'react'; import { faEllipsisV } from '@fortawesome/free-solid-svg-icons'; + import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; @@ -15,10 +17,7 @@ import { notification, Spin } from 'antd'; -import { - ItemType -} from 'antd/lib/menu/interface'; - +import { ItemType } from 'antd/lib/menu/interface'; import { getUid } from 'ol'; @@ -76,6 +75,9 @@ import { setLayer as setLayerDetailsLayer, show as showLayerDetailsModal } from '../../../../store/layerDetailsModal'; +import { setStylingDrawerLayerUid } from '../../../../store/stylingDrawerLayerUid'; +import { setStylingDrawerVisibility } from '../../../../store/stylingDrawerVisibility'; +import { checkIfGeoserverLayer } from '../../../../utils/geoserverUtils'; export type LayerTreeContextMenuProps = { layer: OlLayerTile | OlLayerImage; @@ -92,6 +94,7 @@ export const LayerTreeContextMenu: React.FC = ({ const [settingsVisible, setSettingsVisible] = useState(false); const [extentLoading, setExtentLoading] = useState(false); + const [isGeoserverLayer, setIsGeoserverLayer] = useState(false); const dispatch = useAppDispatch(); const client = useSHOGunAPIClient(); @@ -100,18 +103,33 @@ export const LayerTreeContextMenu: React.FC = ({ t } = useTranslation(); + const drawerVisibilty = useAppSelector(state => state.stylingDrawerVisibility); const downloadConfig: DownloadConfig[] = layer.get('downloadConfig') ?? null; const allowedEditMode = useAppSelector( state => state.editFeature.userEditMode ); const metadataVisible = useAppSelector(state => state.layerTree.metadataVisible); + useEffect(() => { + if (layer) { + const layerSource = layer.getSource(); + if (layerSource) { + setIsGeoserverLayer(checkIfGeoserverLayer(layerSource)); + } else { + setIsGeoserverLayer(false); + } + } + }, [layer]); + const onContextMenuItemClick = (evt: MenuInfo): void => { if (evt?.key.startsWith('downloadLayer')) { const url = evt.key.split('|')[1]; downloadLayer(decodeURI(url)); } switch (evt?.key) { + case 'geostyler': + configureStyles(); + break; case 'zoomToExtent': zoomToLayerExtent(); break; @@ -238,6 +256,11 @@ export const LayerTreeContextMenu: React.FC = ({ a.click(); }; + const configureStyles = () => { + dispatch(setStylingDrawerVisibility(true)); + dispatch(setStylingDrawerLayerUid(getUid(layer))); + }; + const dropdownMenuItems: ItemType[] = []; if (isWmsLayer(layer)) { @@ -299,6 +322,12 @@ export const LayerTreeContextMenu: React.FC = ({ key: 'layerDetails' }); } + if (isGeoserverLayer) { + dropdownMenuItems.push({ + label: 'Geostyler', + key: 'geostyler' + }); + } return (
{ searchEngines, user, stylingDrawerVisibility, + stylingDrawerLayerUid, ...asyncReducers }); }; diff --git a/src/store/stylingDrawerLayerUid/index.tsx b/src/store/stylingDrawerLayerUid/index.tsx new file mode 100644 index 000000000..6e270b1a0 --- /dev/null +++ b/src/store/stylingDrawerLayerUid/index.tsx @@ -0,0 +1,25 @@ +import { + createSlice, PayloadAction +} from '@reduxjs/toolkit'; + +const initialState = ''; + +export const slice = createSlice({ + name: 'stylingDrawerLayer', + initialState, + reducers: { + setStylingDrawerLayerUid: (state, action: PayloadAction) => { + return action.payload; + }, + clearStylingDrawerLayerUid: () => { + return ''; + } + } +}); + +export const { + setStylingDrawerLayerUid, + clearStylingDrawerLayerUid +} = slice.actions; + +export default slice.reducer; diff --git a/src/utils/geoserverUtils.tsx b/src/utils/geoserverUtils.tsx new file mode 100644 index 000000000..770961a14 --- /dev/null +++ b/src/utils/geoserverUtils.tsx @@ -0,0 +1,123 @@ +import ImageWMS from 'ol/source/ImageWMS'; +import Source from 'ol/source/Source'; +import TileWMS from 'ol/source/TileWMS'; + +import { Logger } from '@terrestris/base-util'; +import { getBearerTokenHeader } from '@terrestris/shogun-util/dist/security/getBearerTokenHeader'; +import { SHOGunAPIClient } from '@terrestris/shogun-util/dist/service/SHOGunAPIClient'; + +export type WorkspaceInfo = { + workspace: string; + layerName: string; +}; + +export const fetchWorkspaceFromGetCapabilities = async ( + layerUrl: string, + client: SHOGunAPIClient | null +): Promise => { + try { + const url = `${layerUrl}SERVICE=WMS&REQUEST=GetCapabilities`; + const response = await fetch(url, { + headers: { + ...getBearerTokenHeader(client?.getKeycloak()) + } + }); + + const xml = await response.text(); + const parser = new DOMParser(); + const xmlDocument = parser.parseFromString(xml, 'text/xml'); + const layers = xmlDocument.getElementsByTagName('Layer'); + + let output: WorkspaceInfo | undefined; + Array.from(layers).forEach((layerElement) => { + const name = layerElement.getElementsByTagName('Name')[0]?.textContent; + + if (name) { + const [workspace, layerName] = name.split(':'); + output = { + workspace, + layerName + }; + } + }); + + return output; + } catch (error) { + Logger.error('Error: ', error); + return undefined; + } +}; + +const fetchSld = async ( + name: string, + layerUrl: string, + client: SHOGunAPIClient | null +): Promise => { + try { + const cleanLayerUrl = layerUrl.replace('/ows?', ''); + const url = `${cleanLayerUrl}/rest/styles/${name}.sld`; + + const response = await fetch(url, { + headers: { + ...getBearerTokenHeader(client?.getKeycloak()) + } + }); + + const sldData = await response.text(); + if (!sldData) { + return undefined; + } + return sldData; + } catch (error) { + Logger.error('Error: ', error); + return undefined; + } +}; + +export const fetchGeoserverStyle = async ( + layerWorkspace: string, + layerName: string, + layerUrl: string, + client: SHOGunAPIClient | null +): Promise => { + const cleanLayerUrl = layerUrl.replace('/ows?', ''); + const url = `${cleanLayerUrl}/rest/layers/${layerWorkspace}:${layerName}.json`; + + try { + const response = await fetch(url, { + headers: { + ...getBearerTokenHeader(client?.getKeycloak()) + } + }); + + const data = await response.json(); + + if (!data) { + return undefined; + } + + const style = data.layer.defaultStyle; + return await fetchSld(style.name, layerUrl, client); + } catch (error) { + Logger.error('Error: ', error); + return undefined; + } +}; + +export const getLayerUrl = (layerSource: Source | undefined): string | false => { + if (!layerSource) { + return false; + } + let url: string | undefined; + if (layerSource instanceof TileWMS || layerSource instanceof ImageWMS) { + url = layerSource instanceof TileWMS ? layerSource.getUrls()?.[0] : layerSource.getUrl(); + } + return url || false; +}; + +export const checkIfGeoserverLayer = (layerSource: Source | undefined): boolean => { + const url = getLayerUrl(layerSource); + return url ? url.includes('/geoserver/') : false; +}; + +export default fetchWorkspaceFromGetCapabilities; From c861dc69104815e64990aff02c0f91892f66a23b Mon Sep 17 00:00:00 2001 From: Fritz Hoeing Date: Thu, 2 Jan 2025 17:14:22 +0100 Subject: [PATCH 2/2] fix: fixing some issues derived from the review --- src/components/StylingDrawer/index.tsx | 1 - .../ToolMenu/LayerTree/LayerTreeContextMenu/index.tsx | 9 +++++---- src/i18n/translations.ts | 6 ++++-- src/utils/geoserverUtils.tsx | 8 +++----- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/components/StylingDrawer/index.tsx b/src/components/StylingDrawer/index.tsx index d1365798b..7c93b83e4 100644 --- a/src/components/StylingDrawer/index.tsx +++ b/src/components/StylingDrawer/index.tsx @@ -46,7 +46,6 @@ export const StylingDrawer: React.FC = ({ } = useTranslation(); useEffect(() => { - if (!layerUid) { setIsImageLayer(false); } diff --git a/src/components/ToolMenu/LayerTree/LayerTreeContextMenu/index.tsx b/src/components/ToolMenu/LayerTree/LayerTreeContextMenu/index.tsx index 8eceac2fb..6566fa55e 100644 --- a/src/components/ToolMenu/LayerTree/LayerTreeContextMenu/index.tsx +++ b/src/components/ToolMenu/LayerTree/LayerTreeContextMenu/index.tsx @@ -103,7 +103,6 @@ export const LayerTreeContextMenu: React.FC = ({ t } = useTranslation(); - const drawerVisibilty = useAppSelector(state => state.stylingDrawerVisibility); const downloadConfig: DownloadConfig[] = layer.get('downloadConfig') ?? null; const allowedEditMode = useAppSelector( state => state.editFeature.userEditMode @@ -257,8 +256,10 @@ export const LayerTreeContextMenu: React.FC = ({ }; const configureStyles = () => { - dispatch(setStylingDrawerVisibility(true)); - dispatch(setStylingDrawerLayerUid(getUid(layer))); + if (layer) { + dispatch(setStylingDrawerVisibility(true)); + dispatch(setStylingDrawerLayerUid(getUid(layer))); + } }; const dropdownMenuItems: ItemType[] = []; @@ -324,7 +325,7 @@ export const LayerTreeContextMenu: React.FC = ({ } if (isGeoserverLayer) { dropdownMenuItems.push({ - label: 'Geostyler', + label: t('LayerTreeContextMenu.styleLayer'), key: 'geostyler' }); } diff --git a/src/i18n/translations.ts b/src/i18n/translations.ts index 469af9f22..d723a65e4 100644 --- a/src/i18n/translations.ts +++ b/src/i18n/translations.ts @@ -90,7 +90,8 @@ export default { hideLegend: 'Legende ausblenden', downloadLayer: 'Layer exportieren ({{formatName}})', editLayer: 'Layer bearbeiten', - layerDetails: 'Eigenschaften' + layerDetails: 'Eigenschaften', + styleLayer: 'Layer Stil' }, LayerDetailsModal: { title: 'Eigenschaften des Layers {{layerName}}', @@ -386,7 +387,8 @@ export default { hideLegend: 'Hide legend', downloadLayer: 'Export layer as {{formatName}}', editLayer: 'Edit layer', - layerDetails: 'Properties' + layerDetails: 'Properties', + styleLayer: 'Layer style' }, LayerDetailsModal: { title: 'Properties of layer {{layerName}}', diff --git a/src/utils/geoserverUtils.tsx b/src/utils/geoserverUtils.tsx index 770961a14..0957f97c8 100644 --- a/src/utils/geoserverUtils.tsx +++ b/src/utils/geoserverUtils.tsx @@ -104,15 +104,13 @@ export const fetchGeoserverStyle = async ( } }; -export const getLayerUrl = (layerSource: Source | undefined): string | false => { - if (!layerSource) { - return false; - } +export const getLayerUrl = (layerSource: Source | undefined): string | undefined => { + let url: string | undefined; if (layerSource instanceof TileWMS || layerSource instanceof ImageWMS) { url = layerSource instanceof TileWMS ? layerSource.getUrls()?.[0] : layerSource.getUrl(); } - return url || false; + return url || undefined; }; export const checkIfGeoserverLayer = (layerSource: Source | undefined): boolean => {