diff --git a/apps/client/package-lock.json b/apps/client/package-lock.json index 23b40d51a..15f62a40e 100644 --- a/apps/client/package-lock.json +++ b/apps/client/package-lock.json @@ -49,6 +49,7 @@ "react-number-format": "^5.3.3", "react-rnd": "^10.4.1", "react-scroll": "^1.9.0", + "react-smooth-dnd": "^0.11.1", "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.0", "uuid": "^9.0.1", @@ -16549,9 +16550,10 @@ } }, "node_modules/react-is": { - "version": "18.2.0", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", - "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==" + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" }, "node_modules/react-markdown": { "version": "9.0.1", @@ -16714,6 +16716,19 @@ "react-dom": "^15.5.4 || ^16.0.0 || ^17.0.0 || ^18.0.0" } }, + "node_modules/react-smooth-dnd": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/react-smooth-dnd/-/react-smooth-dnd-0.11.1.tgz", + "integrity": "sha512-+lVWQwPlK980VbFLtMSKfAJBt5ep37H/rVm3OnFiTpMFAghB9YQmrj3MnoIZ8tF+/niM/FFLpnSo8IgnbEY4Kg==", + "license": "ISC", + "dependencies": { + "prop-types": ">=15.6.0", + "smooth-dnd": "0.12.1" + }, + "peerDependencies": { + "react": "^16.3.0" + } + }, "node_modules/react-transition-group": { "version": "4.4.5", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", @@ -17733,6 +17748,12 @@ "node": ">=8" } }, + "node_modules/smooth-dnd": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/smooth-dnd/-/smooth-dnd-0.12.1.tgz", + "integrity": "sha512-Dndj/MOG7VP83mvzfGCLGzV2HuK1lWachMtWl/Iuk6zV7noDycIBnflwaPuDzoaapEl3Pc4+ybJArkkx9sxPZg==", + "license": "MIT" + }, "node_modules/sockjs": { "version": "0.3.24", "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz", @@ -20561,4 +20582,4 @@ } } } -} \ No newline at end of file +} diff --git a/apps/client/package.json b/apps/client/package.json index c993f21c3..ca18f05b4 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -83,10 +83,11 @@ "react-number-format": "^5.3.3", "react-rnd": "^10.4.1", "react-scroll": "^1.9.0", + "react-smooth-dnd": "^0.11.1", "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.0", "uuid": "^9.0.1", "x2js": "^3.4.4", "xlsx": "^0.18.5" } -} \ No newline at end of file +} diff --git a/apps/client/public/index.css b/apps/client/public/index.css index f8df678bf..4900f50b5 100644 --- a/apps/client/public/index.css +++ b/apps/client/public/index.css @@ -84,6 +84,10 @@ body { min-width: 280px !important; } +/* Z-index override for draggable listitem in layerswitcher */ +.smooth-dnd-ghost.vertical.smooth-dnd-draggable-wrapper { + z-index: 99999 !important; +} .material-icons { font-family: "Material Icons"; font-weight: normal; diff --git a/apps/client/src/components/App.js b/apps/client/src/components/App.js index 84762072e..89605beb1 100644 --- a/apps/client/src/components/App.js +++ b/apps/client/src/components/App.js @@ -4,7 +4,6 @@ import { PLUGINS_TO_IGNORE_IN_HASH_APP_STATE } from "constants"; import PropTypes from "prop-types"; import { styled } from "@mui/material/styles"; -import { SnackbarProvider } from "notistack"; import Observer from "react-event-observer"; import { isMobile } from "../utils/IsMobile"; import { getMergedSearchAndHashParams } from "../utils/getMergedSearchAndHashParams"; @@ -24,6 +23,7 @@ import Alert from "./Alert"; import PluginWindows from "./PluginWindows"; import SimpleDialog from "./SimpleDialog"; import MapClickViewer from "./MapClickViewer/MapClickViewer"; +import SnackbarProvider from "./SnackbarProvider"; import Search from "./Search/Search.js"; @@ -806,6 +806,18 @@ class App extends React.PureComponent { // to anyone wanting to act on layer visibility change. this.globalObserver.publish("core.layerVisibilityChanged", e); }); + // Listener for "quickAccess" changes + layer.on("change:quickAccess", (e) => { + // Send an event on the global observer + // to anyone wanting to act on layer quickAccess change. + this.globalObserver.publish("core.layerQuickAccessChanged", e); + }); + // Listener for "subLayers" changes + layer.on("change:subLayers", (e) => { + // Send an event on the global observer + // to anyone wanting to act on layer subLayers change. + this.globalObserver.publish("core.layerSubLayersChanged", e); + }); }); } diff --git a/apps/client/src/components/ConfirmationDialog.js b/apps/client/src/components/ConfirmationDialog.js index 564214cf6..a4f2bc4b0 100644 --- a/apps/client/src/components/ConfirmationDialog.js +++ b/apps/client/src/components/ConfirmationDialog.js @@ -32,10 +32,25 @@ const ConfirmationDialog = ({ {contentDescription} - - + {confirm && ( + + )} , document.getElementById("map") diff --git a/apps/client/src/components/SnackbarProvider.js b/apps/client/src/components/SnackbarProvider.js new file mode 100644 index 000000000..403f098c4 --- /dev/null +++ b/apps/client/src/components/SnackbarProvider.js @@ -0,0 +1,49 @@ +import React, { createContext, useState } from "react"; +import { + SnackbarProvider as NotistackSnackbarProvider, + useSnackbar, +} from "notistack"; + +// Create a context for sharing state across components. +// This context will hold message items and functions to control the snackbar. +export const SnackbarContext = createContext(); + +// SnackbarProvider is a custom component that sets up the SnackbarContext provider. +// It uses the useSnackbar hook to provide snackbar controls to its child components. +const SnackbarProvider = ({ + // Destructure and set defaults for maxSnack and anchorOrigin props. + // maxSnack is the maximum number of snackbar notifications that can be displayed at once. + // anchorOrigin defines the position of the snackbar on the screen. + children, + maxSnack = 3, + anchorOrigin = { vertical: "bottom", horizontal: "left" }, + ...props +}) => { + // Initialize the shared state (messageItems) and its updater (setMessageItems) + // messageItems holds a list of messages to be displayed in the snackbar. + const [messageItems, setMessageItems] = useState([]); + + // Get the snackbar object containing functions control the snackbar's visibility and its messages. + const snackbar = useSnackbar(); + + // Render the NotistackSnackbarProvider and provide the shared state and functions through the SnackbarContext. + // This allows any child component to access the snackbar controls and the shared state. + return ( + + {/* Pass shared state and functions to the SnackbarContext.Provider value. */} + {/* This allows child components to access the snackbar controls and the shared state. */} + + {children} + + + ); +}; + +export default SnackbarProvider; diff --git a/apps/client/src/controls/MapCleaner.js b/apps/client/src/controls/MapCleaner.js index 6a861167b..81c5803cc 100644 --- a/apps/client/src/controls/MapCleaner.js +++ b/apps/client/src/controls/MapCleaner.js @@ -5,6 +5,8 @@ import VisibilityOffIcon from "@mui/icons-material/VisibilityOff"; import { styled } from "@mui/material/styles"; import HajkToolTip from "components/HajkToolTip"; +import useSnackbar from "../hooks/useSnackbar"; + const StyledPaper = styled(Paper)(({ theme }) => ({ marginBottom: theme.spacing(1), })); @@ -20,6 +22,10 @@ const StyledIconButton = styled(IconButton)(({ theme }) => ({ * @returns {object} React */ const MapCleaner = React.memo((props) => { + // Import the clearAllMessages function from the useSnackbar hook. + // This allows us to clear the Snackbar's state when the button is clicked. + const { clearSnackbar } = useSnackbar(); + return ( props.appModel.config.mapConfig.map.mapcleaner && ( @@ -28,6 +34,11 @@ const MapCleaner = React.memo((props) => { aria-label="Rensa kartan" onClick={(e) => { props.appModel.clear(); + + // Call the clearAllMessages function from the useCustomSnackbar hook. + // This clears the state of messageItems in the Snackbar, + // ensuring it stays in sync with the actual visibility of the layers. + clearSnackbar(); }} > diff --git a/apps/client/src/hooks/useSnackbar.js b/apps/client/src/hooks/useSnackbar.js new file mode 100644 index 000000000..a63ed44b8 --- /dev/null +++ b/apps/client/src/hooks/useSnackbar.js @@ -0,0 +1,139 @@ +import { useContext, useEffect, useState, useRef, useCallback } from "react"; +import { useSnackbar as useNotistackSnackbar } from "notistack"; +import { IconButton } from "@mui/material"; +import { Close as CloseIcon } from "@mui/icons-material"; +import { SnackbarContext } from "../components/SnackbarProvider"; + +// Constants for operation types. +const ADD = "ADD"; +const SHOW = "SHOW"; +const REMOVE = "REMOVE"; +const ADD_ONLY = "ADD_ONLY"; + +// Function to generate a composite key for identifying message items. +const generateCompositeKey = (id, caption) => `${id}-${caption}`; + +// Custom hook to manage snackbars. +const useSnackbar = () => { + const { enqueueSnackbar, closeSnackbar } = useNotistackSnackbar(); + const { messageItems, setMessageItems } = useContext(SnackbarContext); + const [operationType, setOperationType] = useState(null); + + // Reference to keep track of the current message items. + const messageItemsRef = useRef({}); + + // Effect to update messageItemsRef whenever messageItems changes. + useEffect(() => { + if (messageItems !== messageItemsRef.current) { + messageItemsRef.current = messageItems; + } + }, [messageItems]); + + // Function to format the message text for the snackbar. + const formatMessage = (items) => { + const keys = Object.keys(items); + if (keys.length === 0) return ""; + + const mostRecentKey = keys[keys.length - 1]; + const mostRecentLayer = items[mostRecentKey]; + const otherLayersCount = keys.length - 1; + + return otherLayersCount > 0 + ? `Lagret '${mostRecentLayer}' och ${otherLayersCount} andra lager är inte synliga vid aktuell zoomnivå.` + : `Lagret '${mostRecentLayer}' är inte synligt vid aktuell zoomnivå.`; + }; + + // Function to display the snackbar. + const displaySnackbar = useCallback(() => { + const message = formatMessage(messageItemsRef.current); + if (!message) return; + + // Action to display a close button on the snackbar. + const action = (key) => ( + closeSnackbar(key)} + > + + + ); + + enqueueSnackbar(message, { + variant: "warning", + autoHideDuration: 5000, + action, + anchorOrigin: { + vertical: "bottom", + horizontal: "center", + }, + }); + }, [enqueueSnackbar, closeSnackbar]); + + // Effect to handle the display of the snackbar based on operationType. + useEffect(() => { + if (!operationType || Object.keys(messageItems).length === 0) return; + + // Exclude REMOVE operation type from triggering snackbar. + if ([ADD, SHOW].includes(operationType)) { + displaySnackbar(); + } + + setOperationType(null); + }, [operationType, messageItems, displaySnackbar]); + + // Function to update the snackbar messages and type. + const updateSnackbar = useCallback( + (type, id, caption) => { + if (!id || !caption) return; + const key = generateCompositeKey(id, caption); + + setMessageItems((prevItems) => { + if ([ADD, ADD_ONLY].includes(type)) { + return { ...prevItems, [key]: caption }; + } + const { [key]: _, ...rest } = prevItems; + return rest; + }); + + if (type !== ADD_ONLY) { + setOperationType(type); + } + }, + [setMessageItems] + ); + + // Function to add a new message to the snackbar. + const addToSnackbar = (id, caption, addOnly = false) => { + updateSnackbar(addOnly ? ADD_ONLY : ADD, id, caption); + }; + + // Function to remove a message from the snackbar. + const removeFromSnackbar = (id, caption) => + updateSnackbar(REMOVE, id, caption); + + // Function to hide the snackbar. + const hideSnackbar = useCallback( + (key) => { + if (key) closeSnackbar(key); + }, + [closeSnackbar] + ); + + // Function to clear all messages from the snackbar. + const clearSnackbar = () => setMessageItems({}); + + // Function to display the snackbar. + const showSnackbar = () => setOperationType(SHOW); + + // Return the snackbar methods. + return { + addToSnackbar, + removeFromSnackbar, + hideSnackbar, + clearSnackbar, + showSnackbar, + }; +}; + +export default useSnackbar; diff --git a/apps/client/src/models/layers/WMSLayer.js b/apps/client/src/models/layers/WMSLayer.js index 9dbb5e0c4..8a788298f 100644 --- a/apps/client/src/models/layers/WMSLayer.js +++ b/apps/client/src/models/layers/WMSLayer.js @@ -118,6 +118,12 @@ class WMSLayer { this.layer.layersInfo = config.layersInfo; this.layer.subLayers = this.subLayers; this.layer.visibleAtStartSubLayers = config.visibleAtStartSubLayers; + this.layer.set( + "subLayers", + config.visibleAtStartSubLayers?.length > 0 + ? config.visibleAtStartSubLayers + : this.subLayers + ); this.layer.getSource().set("url", config.url); this.type = "wms"; this.bindHandlers(); diff --git a/apps/client/src/plugins/LayerSwitcher/LayerSwitcher.js b/apps/client/src/plugins/LayerSwitcher/LayerSwitcher.js index 815fb3d40..ec37e4300 100644 --- a/apps/client/src/plugins/LayerSwitcher/LayerSwitcher.js +++ b/apps/client/src/plugins/LayerSwitcher/LayerSwitcher.js @@ -5,7 +5,6 @@ import BaseWindowPlugin from "../BaseWindowPlugin"; import LayersIcon from "@mui/icons-material/Layers"; import LayerSwitcherView from "./LayerSwitcherView.js"; -import LayerSwitcherModel from "./LayerSwitcherModel.js"; import Observer from "react-event-observer"; export default class LayerSwitcher extends React.PureComponent { @@ -19,12 +18,6 @@ export default class LayerSwitcher extends React.PureComponent { super(props); this.localObserver = Observer(); - - this.layerSwitcherModel = new LayerSwitcherModel({ - map: props.map, - app: props.app, - observer: this.localObserver, - }); } render() { @@ -38,13 +31,15 @@ export default class LayerSwitcher extends React.PureComponent { description: "Välj vad du vill se i kartan", height: "auto", width: 400, + scrollable: true, + disablePadding: true, }} > diff --git a/apps/client/src/plugins/LayerSwitcher/LayerSwitcherModel.js b/apps/client/src/plugins/LayerSwitcher/LayerSwitcherModel.js deleted file mode 100644 index 6cb589df7..000000000 --- a/apps/client/src/plugins/LayerSwitcher/LayerSwitcherModel.js +++ /dev/null @@ -1,24 +0,0 @@ -class LayerSwitcherModel { - constructor(settings) { - this.olMap = settings.map; - this.observer = settings.observer; - this.globalObserver = settings.app.globalObserver; - this.layerMap = this.olMap - .getLayers() - .getArray() - .reduce((a, b) => { - a[b.get("name")] = b; - return a; - }, {}); - } - - getBaseLayers() { - return this.olMap - .getLayers() - .getArray() - .filter((l) => l.get("layerType") === "base") - .map((l) => l.getProperties()); - } -} - -export default LayerSwitcherModel; diff --git a/apps/client/src/plugins/LayerSwitcher/LayerSwitcherView.js b/apps/client/src/plugins/LayerSwitcher/LayerSwitcherView.js index f577c79d7..a9e86e7b2 100644 --- a/apps/client/src/plugins/LayerSwitcher/LayerSwitcherView.js +++ b/apps/client/src/plugins/LayerSwitcher/LayerSwitcherView.js @@ -3,74 +3,86 @@ import { createPortal } from "react-dom"; import propTypes from "prop-types"; import { styled } from "@mui/material/styles"; -import { AppBar, Tab, Tabs } from "@mui/material"; +import { AppBar, Tab, Tabs, Box } from "@mui/material"; import BackgroundSwitcher from "./components/BackgroundSwitcher.js"; import LayerGroup from "./components/LayerGroup.js"; import BreadCrumbs from "./components/BreadCrumbs.js"; import DrawOrder from "./components/DrawOrder.js"; - -// The styled-component below might seem unnecessary since we are using the sx-prop -// on it as well. However, since we cannot use the sx-prop on a non-MUI-component -// (which would force us to change the
to a ) this felt OK in this -// particular occasion. -const Root = styled("div")(() => ({ - margin: -10, // special case, we need to "unset" the padding for Window content that's set in Window.js -})); +import LayerPackage from "./components/LayerPackage"; +import QuickAccessView from "./components/QuickAccessView.js"; +import LayerItemDetails from "./components/LayerItemDetails.js"; +import LayerListFilter from "./components/LayerListFilter.js"; +import { debounce } from "utils/debounce"; const StyledAppBar = styled(AppBar)(() => ({ top: -10, })); -const ContentWrapper = styled("div")(() => ({ - padding: 10, -})); +/** + * BreadCrumbs are a feature used to "link" content between LayerSwitcher + * and Informative plugins. They get rendered directly to #map, as they + * are not part of LayerSwitcher plugin, at least not visually. To achieve + * that we use createPortal(). + * + * @returns + * @memberof LayersSwitcherView + */ +const BreadCrumbsContainer = ({ map, app }) => { + return createPortal( + // We must wrap the component in a div, on which we can catch + // events. This is done to prevent event bubbling to the + // layerSwitcher component. +
e.stopPropagation()}> + +
, + document.getElementById("breadcrumbs-container") + ); +}; class LayersSwitcherView extends React.PureComponent { static propTypes = { app: propTypes.object.isRequired, map: propTypes.object.isRequired, - model: propTypes.object.isRequired, - observer: propTypes.object.isRequired, + localObserver: propTypes.object.isRequired, + globalObserver: propTypes.object.isRequired, options: propTypes.object.isRequired, }; - // Static members to determine which Tabs should be rendered. - #renderRegularLayersView; - #renderBackgroundLayersView; - #renderActiveLayersView; - constructor(props) { super(props); this.options = props.options; - - // Let's prepare some constants that will be used to determine which - // tabs should be rendered. - - // Regular layers are straightforward. - this.#renderRegularLayersView = this.options.groups.length > 0; - - // The Backgrounds tab should be visible if there are baselayers in - // config or if any of the special layers is enabled. - this.#renderBackgroundLayersView = - this.options.baselayers.length > 0 || - this.options.enableOSM === true || - this.options.backgroundSwitcherBlack || - this.options.backgroundSwitcherWhite; - - // The Active layers tab is straightforward too. - this.#renderActiveLayersView = this.options.showActiveLayersView ?? false; - + this.layerMap = props.map + .getLayers() + .getArray() + .reduce((a, b) => { + a[b.get("name")] = b; + return a; + }, {}); + this.layerTree = this.addLayerNames(this.options.groups); + // Create a ref to store a reference to the search layer input element this.state = { chapters: [], - baseLayers: props.model.getBaseLayers(), - activeTab: this.#renderRegularLayersView // Let's calculate which - ? "regularLayers" // view should be visible on start, given - : this.#renderBackgroundLayersView // that we must find out which - ? "backgroundLayers" // tabs are available. - : false, + baseLayers: props.map + .getLayers() + .getArray() + .filter((l) => l.get("layerType") === "base") + .map((l) => l.getProperties()), + activeTab: 0, + displayContentOverlay: null, // 'layerPackage' | 'favorites' | 'layerItemDetails' + layerItemDetails: null, + filterValue: "", + treeData: this.layerTree, + scrollPositions: { + tab0: 0, + tab1: 0, + tab2: 0, + }, }; + this.localObserver = this.props.localObserver; + this.globalObserver = this.props.globalObserver; + props.app.globalObserver.subscribe("informativeLoaded", (chapters) => { if (Array.isArray(chapters)) { this.setState({ @@ -78,20 +90,314 @@ class LayersSwitcherView extends React.PureComponent { }); } }); + + props.app.globalObserver.subscribe("setLayerDetails", (details) => { + if (details) { + // Set scroll position state when layer details is opened + const currentScrollPosition = this.getScrollPosition(); + this.setState((prevState) => ({ + layerItemDetails: details, + displayContentOverlay: "layerItemDetails", + scrollPositions: { + ...prevState.scrollPositions, + [`tab${prevState.activeTab}`]: currentScrollPosition, + }, + })); + } else { + this.setState({ + displayContentOverlay: null, + }); + } + }); + + // this.globalHideLayerSubscription = props.app.globalObserver.subscribe( + // "layerswitcher.hideLayer", + // (la) => console.log("LSV: globalHideLayer", la.get("caption")) + // ); + + // this.globalShowLayerSubscription = props.app.globalObserver.subscribe( + // "layerswitcher.showLayer", + // (la) => console.log("LSV: globalShowLayer", la.get("caption")) + // ); + // TODO This is a work around for showing/hideing group layers which are + // collapsed. It's also the GroupLayer component that listens for this. + // That should be refactored. + // TODO Unsubscribe + this.localHideLayerSubscription = this.localObserver.subscribe( + "hideLayer", + (la) => { + // TODO Make sure QuickAccess updates + // Send some event + la.setVisible(false); + // this.setState({ treeData: [...this.layerTree] }); + } + ); + this.localShowLayerSubscription = this.localObserver.subscribe( + "showLayer", + (la) => { + la.setVisible(true); + // TODO Make sure QuickAccess updates + // Send some event + // this.setState({ treeData: [...this.layerTree] }); + } + ); } + // Prepare tree data for filtering + addLayerNames = (data) => { + data.forEach((item) => { + item.isFiltered = true; + item.isExpanded = false; + item.changeIndicator = new Date(); + if (item.layers) { + item.layers.forEach((layer) => { + const mapLayer = this.layerMap[layer.id]; + if (!mapLayer) { + console.warn(`Maplayer with id ${layer.id} not found`); + return; + } + layer.name = mapLayer.get("caption"); + layer.isFiltered = true; + item.changeIndicator = new Date(); + // Check if layer is a group + if (mapLayer.get("layerType") === "group") { + layer.subLayers = []; + const subLayers = mapLayer.get("subLayers"); + subLayers.forEach((subLayer) => { + const subLayerMapLayer = mapLayer.layersInfo[subLayer].caption; + layer.subLayers.push({ + id: subLayer, + name: subLayerMapLayer, + isFiltered: true, + changeIndicator: new Date(), + }); + }); + } + }); + } + + if (item.groups) { + // Call recursevly for subgroups + this.addLayerNames(item.groups); + } + }); + return data; + }; + + // Handles click on Layerpackage button and backbutton + handleLayerPackageToggle = (layerPackageState) => { + layerPackageState?.event?.stopPropagation(); + // Set scroll position state when layer package is opened + const currentScrollPosition = this.getScrollPosition(); + this.setState((prevState) => ({ + displayContentOverlay: + this.state.displayContentOverlay === "layerPackage" + ? null + : "layerPackage", + scrollPositions: { + ...prevState.scrollPositions, + [`tab${prevState.activeTab}`]: currentScrollPosition, + }, + })); + }; + + // Filter tree data + filterTree = (node, filterText, parentMatch = false) => { + let foundInChild = false; + + // Determine if the current node matches the filter + const selfMatch = + filterText === "" || + node.name.toLocaleLowerCase().includes(filterText.toLocaleLowerCase()); + + // If the current node matches the filter criteria or if there is a parent match, mark it as filtered + if (parentMatch || selfMatch) { + this.updateNode(node, true, true); // Update node to be visible + foundInChild = true; + } + + // Process child layers + if (node.layers) { + node.layers.forEach((layer) => { + // Pass true if either parent matches, or this node itself matches + foundInChild = + this.filterTree(layer, filterText, parentMatch || selfMatch) || + foundInChild; + }); + } + + // Process child groups + if (node.groups) { + node.groups.forEach((group) => { + // Pass true if either parent matches, or this node itself matches + foundInChild = + this.filterTree(group, filterText, parentMatch || selfMatch) || + foundInChild; + }); + } + + // Update the current node based on child findings or its own match status + this.updateNode(node, foundInChild || selfMatch, false); + + // If a parentMatch exists or the current node itself is a match, check the expandFilteredResults setting to determine if the node should be expanded + if (foundInChild || selfMatch) { + if (this.options.expandFilteredResults) { + node.isExpanded = true; // Expand the node if expandFilteredResults is true + } + } + + return foundInChild || selfMatch; + }; + + updateNode = (node, isFiltered, compare) => { + if (!compare) { + // Indicate that node has changed + node.changeIndicator = new Date(); + } else if (node.isFiltered !== isFiltered) { + // Indicate that node has changed + node.changeIndicator = new Date(); + } + node.isFiltered = isFiltered; + }; + + setChildrenFiltered = (node, value) => { + if (node.layers) { + node.layers.forEach((layer) => { + this.updateNode(layer, value, true); + this.setChildrenFiltered(layer, value); + }); + } + + if (node.groups) { + node.groups.forEach((group) => { + this.updateNode(group, value, true); + this.setChildrenFiltered(group, value); + }); + } + + if (node.subLayers) { + node.subLayers.forEach((subLayer) => { + this.updateNode(subLayer, value, true); + subLayer.isFiltered = value; + }); + } + }; + + // Handles click on Favorites button and backbutton + handleFavoritesViewToggle = (layerPackageState) => { + layerPackageState?.event?.stopPropagation(); + // Set scroll position state when favorites view is opened + const currentScrollPosition = this.getScrollPosition(); + this.setState((prevState) => ({ + displayContentOverlay: + this.state.displayContentOverlay === "favorites" ? null : "favorites", + scrollPositions: { + ...prevState.scrollPositions, + [`tab${prevState.activeTab}`]: currentScrollPosition, + }, + })); + }; + + collapseAllGroups = () => { + const collapseGroups = (groups) => { + groups.forEach((group) => { + group.isExpanded = false; + if (group.groups && group.groups.length > 0) { + collapseGroups(group.groups); + } + }); + }; + + collapseGroups(this.layerTree); + }; + + // Call this method for each root node in the tree when the filter is cleared + resetFilterStatus = (node) => { + node.isFiltered = true; // Mark node as filtered + node.isExpanded = false; // Collapse all groups by default + node.changeIndicator = new Date(); // Update change indicator + + // Recursively reset status for layers, groups, and subLayers + if (node.layers) { + node.layers.forEach((layer) => this.resetFilterStatus(layer)); + } + if (node.groups) { + node.groups.forEach((group) => this.resetFilterStatus(group)); + } + if (node.subLayers) { + node.subLayers.forEach((subLayer) => this.resetFilterStatus(subLayer)); + } + }; + + // Handles filter functionality + handleFilterValueChange = debounce((value) => { + const filterCleared = value === "" && this.state.filterValue !== ""; + this.setState({ filterValue: value }); + + if (filterCleared) { + // Reset filter status when filter is cleared + this.layerTree.forEach((node) => this.resetFilterStatus(node)); + } else { + // Apply filter and propagate matches + this.layerTree.forEach((node) => this.filterTree(node, value)); + } + + // Trigger re-render + this.setState({ treeData: [...this.layerTree] }); + }, 100); + /** - * LayerSwitcher consists of one to three Tabs: - * - one shows regular layers (as checkboxes, multi select) - * - one show the background layers (as radio buttons, one-at-at-time) - * - there's also an option to show a tab with active layers only. + * LayerSwitcher consists of two Tabs: one shows + * "regular" layers (as checkboxes, multi select), and the + * other shows background layers (as radio buttons, one-at-at-time). * - * This method handles switching to the selected tab's content. + * This method controls which of the two Tabs is visible and hides LayerPackage view. * * @memberof LayersSwitcherView */ handleChangeTabs = (event, activeTab) => { - this.setState({ activeTab }); + // Set scroll position state when tab is changed + const currentScrollPosition = this.getScrollPosition(); + this.setState((prevState) => ({ + activeTab, + displayContentOverlay: null, + scrollPositions: { + ...prevState.scrollPositions, + [`tab${prevState.activeTab}`]: currentScrollPosition, + }, + })); + }; + + /** + * This method resets scrollposition when component updates, + * but only when tab is changed or content overlay is opened. + * + * @memberof LayersSwitcherView + */ + componentDidUpdate(prevProps, prevState) { + if ( + prevState.activeTab !== this.state.activeTab || + prevState.displayContentOverlay !== this.state.displayContentOverlay + ) { + // Reset scroll position when tab is changed, or when content overlay is opened + const { scrollPositions } = this.state; + const currentScrollPosition = + scrollPositions[`tab${this.state.activeTab}`]; + if (currentScrollPosition !== undefined) { + const scrollContainer = document.getElementById("scroll-container"); + scrollContainer.scrollTop = currentScrollPosition; + } + } + } + + /** + * This method gets scrollposition of container + * + * @memberof LayersSwitcherView + */ + getScrollPosition = () => { + const scrollContainer = document.getElementById("scroll-container"); // Byt ut mot din scroll-container ID + return scrollContainer.scrollTop; }; /** @@ -111,68 +417,14 @@ class LayersSwitcherView extends React.PureComponent { }, 1); }; - /** - * @summary Loops through map configuration and - * renders all groups. Visible only if @param shouldRender is true. - * - * @param {boolean} [shouldRender=true] - * @returns {
} - */ - renderLayerGroups = (shouldRender = true) => { + render() { + const { windowVisible } = this.props; return (
- {this.options.groups.map((group, i) => { - return ( - - ); - })} -
- ); - }; - - /** - * BreadCrumbs are a feature used to "link" content between LayerSwitcher - * and Informative plugins. They get rendered directly to #map, as they - * are not part of LayerSwitcher plugin, at least not visually. To achieve - * that we use createPortal(). - * - * @returns - * @memberof LayersSwitcherView - */ - renderBreadCrumbs = () => { - return ( - this.options.showBreadcrumbs && - createPortal( - // We must wrap the component in a div, on which we can catch - // events. This is done to prevent event bubbling to the - // layerSwitcher component. -
e.stopPropagation()}> - -
, - document.getElementById("breadcrumbs-container") - ) - ); - }; - - render() { - const { windowVisible } = this.props; - return ( - - {this.#renderRegularLayersView && ( - - )} - {this.#renderBackgroundLayersView && ( - - )} - {this.#renderActiveLayersView && ( - + + + {this.options.showDrawOrderView === true && ( + )} - - {this.#renderRegularLayersView && - this.renderLayerGroups(this.state.activeTab === "regularLayers")} - {this.#renderBackgroundLayersView && ( - + + {this.props.options.showFilter && ( + + this.handleFilterValueChange(value) + } + /> + )} + + this.handleLayerPackageToggle({ event: e }) + } + favoritesViewDisplay={ + this.state.displayContentOverlay === "favorites" + } + handleFavoritesViewToggle={this.handleFavoritesViewToggle} + favoritesInfoText={this.options.userQuickAccessFavoritesInfoText} + treeData={this.state.treeData} + filterValue={this.state.filterValue} /> + {this.state.treeData.map((group, i) => ( + + ))} + + {this.props.options.enableQuickAccessTopics && ( + )} - {this.#renderActiveLayersView && - this.state.activeTab === "activeLayers" && ( - - )} - - {this.renderBreadCrumbs()} - + + + {this.options.showDrawOrderView === true && ( + + )} + {this.options.showBreadcrumbs && ( + + )} +
+
); } } diff --git a/apps/client/src/plugins/LayerSwitcher/components/BackgroundLayer.js b/apps/client/src/plugins/LayerSwitcher/components/BackgroundLayer.js new file mode 100644 index 000000000..c537b35ec --- /dev/null +++ b/apps/client/src/plugins/LayerSwitcher/components/BackgroundLayer.js @@ -0,0 +1,81 @@ +import React, { useEffect, useState } from "react"; + +import LayerItem from "./LayerItem"; + +import PublicOutlinedIcon from "@mui/icons-material/PublicOutlined"; +import WallpaperIcon from "@mui/icons-material/Wallpaper"; +import RadioButtonChecked from "@mui/icons-material/RadioButtonChecked"; +import RadioButtonUnchecked from "@mui/icons-material/RadioButtonUnchecked"; + +export default function BackgroundLayer({ layer, app, toggleable, draggable }) { + // Keep visible backgroundlayer in state + const [backgroundVisible, setBackgroundVisible] = useState( + layer.get("visible") + ); + + // When component is successfully mounted into the DOM. + useEffect(() => { + app.globalObserver.subscribe( + "layerswitcher.backgroundLayerChanged", + (activeLayer) => { + if (activeLayer !== layer.get("name")) { + if (!layer.isFakeMapLayer) { + layer.setVisible(false); + } + setBackgroundVisible(false); + } else { + setBackgroundVisible(true); + } + } + ); + }, [layer, app.globalObserver]); + + // Handles list item click + const handleLayerItemClick = () => { + const name = layer.get("name"); + document.getElementById("map").style.backgroundColor = "#FFF"; // sets the default background color to white + if (layer.isFakeMapLayer) { + switch (name) { + case "-2": + document.getElementById("map").style.backgroundColor = "#000"; + break; + case "-1": + default: + document.getElementById("map").style.backgroundColor = "#FFF"; + break; + } + } else { + layer.setVisible(true); + } + // Publish event to ensure all other background layers are disabled + app.globalObserver.publish("layerswitcher.backgroundLayerChanged", name); + }; + + // Render method for backgroundlayer icon + const getLayerToggleIcon = () => { + if (toggleable) { + return !backgroundVisible ? ( + + ) : ( + + ); + } + return layer.isFakeMapLayer ? ( + + ) : ( + + ); + }; + + return ( + + ); +} diff --git a/apps/client/src/plugins/LayerSwitcher/components/BackgroundSwitcher.js b/apps/client/src/plugins/LayerSwitcher/components/BackgroundSwitcher.js index 10f418e70..09c060d67 100644 --- a/apps/client/src/plugins/LayerSwitcher/components/BackgroundSwitcher.js +++ b/apps/client/src/plugins/LayerSwitcher/components/BackgroundSwitcher.js @@ -3,8 +3,7 @@ import propTypes from "prop-types"; import { isValidLayerId } from "../../../utils/Validator"; import OSM from "ol/source/OSM"; import TileLayer from "ol/layer/Tile"; -import LayerItem from "./LayerItem.js"; -import Observer from "react-event-observer"; +import BackgroundLayer from "./BackgroundLayer"; import Box from "@mui/material/Box"; const WHITE_BACKROUND_LAYER_ID = "-1"; @@ -31,7 +30,6 @@ class BackgroundSwitcher extends React.PureComponent { }; constructor(props) { super(props); - this.localObserver = Observer(); if (props.enableOSM) { this.osmSource = new OSM({ reprojectionErrorThreshold: 5, @@ -49,6 +47,10 @@ class BackgroundSwitcher extends React.PureComponent { layerType: "base", }, }); + this.osmLayer.on("change:visible", (e) => { + // Publish event to ensure DrawOrder tab is updated with osmLayer changes + this.props.app.globalObserver.publish("core.layerVisibilityChanged", e); + }); } } @@ -169,6 +171,7 @@ class BackgroundSwitcher extends React.PureComponent { properties: { name: config.name, visible: checked, + caption: config.caption, layerInfo: { caption: config.caption, name: config.name, @@ -188,19 +191,15 @@ class BackgroundSwitcher extends React.PureComponent { }; } - // No matter the type of 'mapLayer', we want to append these - // properties: - mapLayer["localObserver"] = this.localObserver; - // Finally, let's render the component return ( - + draggable={false} + toggleable={true} + > ); } diff --git a/apps/client/src/plugins/LayerSwitcher/components/BreadCrumbs.js b/apps/client/src/plugins/LayerSwitcher/components/BreadCrumbs.js index 25854b2ef..a0d68d77e 100644 --- a/apps/client/src/plugins/LayerSwitcher/components/BreadCrumbs.js +++ b/apps/client/src/plugins/LayerSwitcher/components/BreadCrumbs.js @@ -122,16 +122,10 @@ class BreadCrumbs extends Component { this.#resetLayerBuffers(); }, 0); - if (this.props.model.clearing) { - this.setState({ - visibleLayers: [], - }); + if (changedLayer.get("visible")) { + this.addedLayerBuffer.push(changedLayer); } else { - if (changedLayer.get("visible")) { - this.addedLayerBuffer.push(changedLayer); - } else { - this.removedLayerBuffer.push(changedLayer); - } + this.removedLayerBuffer.push(changedLayer); } }); }; diff --git a/apps/client/src/plugins/LayerSwitcher/components/CQLFilter.js b/apps/client/src/plugins/LayerSwitcher/components/CQLFilter.js index a4808138e..b19940833 100644 --- a/apps/client/src/plugins/LayerSwitcher/components/CQLFilter.js +++ b/apps/client/src/plugins/LayerSwitcher/components/CQLFilter.js @@ -1,21 +1,25 @@ -import React, { useState } from "react"; +import React, { useState, useEffect } from "react"; import { - InputLabel, OutlinedInput, IconButton, InputAdornment, + Stack, + Typography, } from "@mui/material"; import RefreshIcon from "@mui/icons-material/Refresh"; import HajkToolTip from "components/HajkToolTip"; const CQLFilter = ({ layer }) => { - const source = layer.getSource(); - const currentCqlFilterValue = - (typeof source.getParams === "function" && - source.getParams()?.CQL_FILTER) || - ""; + const [cqlFilter, setCqlFilter] = useState(""); - const [cqlFilter, setCqlFilter] = useState(currentCqlFilterValue); + useEffect(() => { + const source = layer.getSource(); + const currentCqlFilterValue = + (typeof source.getParams === "function" && + source.getParams()?.CQL_FILTER) || + ""; + setCqlFilter(currentCqlFilterValue); + }, [layer]); const updateFilter = () => { let filter = cqlFilter.trim(); @@ -24,11 +28,14 @@ const CQLFilter = ({ layer }) => { }; return ( - <> - Ange CQL-filter + + + CQL-filter + { onChange={(e) => setCqlFilter(e.target.value)} endAdornment={ - - + + } /> - + ); }; diff --git a/apps/client/src/plugins/LayerSwitcher/components/DrawOrder.js b/apps/client/src/plugins/LayerSwitcher/components/DrawOrder.js index 2f88cb965..e5e1cf6bb 100644 --- a/apps/client/src/plugins/LayerSwitcher/components/DrawOrder.js +++ b/apps/client/src/plugins/LayerSwitcher/components/DrawOrder.js @@ -1,132 +1,299 @@ import React, { useCallback, useEffect, useState } from "react"; -import Box from "@mui/material/Box"; -import List from "@mui/material/List"; -import DrawOrderListItem from "./DrawOrderListItem"; -import DrawOrderOptions from "./DrawOrderOptions"; -import { Chip, Divider } from "@mui/material"; +import { Container, Draggable } from "react-smooth-dnd"; -function DrawOrder({ app, map }) { +import { + IconButton, + Box, + FormGroup, + FormControlLabel, + List, + Switch, + Tooltip, + Collapse, + Typography, + Stack, +} from "@mui/material"; + +import LayerItem from "./LayerItem"; +import BackgroundLayer from "./BackgroundLayer"; +import GroupLayer from "./GroupLayer"; + +import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; + +function DrawOrder({ display, app, map, localObserver, options }) { // A Set that will hold type of OL layers that should be shown. // This is a user setting, changed by toggling a switch control. const [filterList, setFilterList] = useState( - new Set(["layer", "group", "base"]) // Also "system" is available, but let's start without it + ["layer", "group", "base"] // Also "system" is available, but let's start without it ); + // State that contains the layers that are currently visible + const [sortedLayers, setSortedLayers] = useState([]); + // State that toggles info collapse + const [infoIsActive, setInfoIsActive] = useState(false); + // State that keeps track if system filter is active + const [systemFilterActive, setSystemFilterActive] = useState(false); - // A helper that grabs all visible OL layers, filters so that + // A helper that grabs all OL layers with state visible, filters so that // only the selected layer types are shown and sorts them // in reverse numerical order (highest zIndex at top of the list). const getSortedLayers = useCallback(() => { + // Get all visible layers + let visibleLayers = map.getAllLayers().filter((l) => { + l.getZIndex() === undefined && l.setZIndex(-2); + return ( + l.get("visible") === true && filterList.includes(l.get("layerType")) + ); + }); + + // Return layers in reversed numerical order + return visibleLayers.sort((a, b) => b.getZIndex() - a.getZIndex()); + }, [filterList, map]); + + // A helper that grabs all OL layers, filters on selected layer types. + // This is needed for the z-index ordering to be correct with all layers added to map + const getAllLayers = useCallback(() => { return ( map .getAllLayers() .filter((l) => { l.getZIndex() === undefined && l.setZIndex(-2); - return ( - l.getVisible() === true && - Array.from(filterList).includes(l.get("layerType")) - ); + return filterList.includes(l.get("layerType")); }) // Reversed numerical order .sort((a, b) => b.getZIndex() - a.getZIndex()) ); }, [filterList, map]); + // When values of display changes to true, let's update the list useEffect(() => { - // Register a listener: when any layer's visibility changes make sure - // to update the list. - app.globalObserver.subscribe("core.layerVisibilityChanged", (l) => { + let visibilityChangedSubscription; + + if (display) { + // Subscribe to the layerVisibilityChanged event when display sets to true + visibilityChangedSubscription = app.globalObserver.subscribe( + "core.layerVisibilityChanged", + (l) => { + // Update list of layers + setSortedLayers(getSortedLayers()); + } + ); + + // Update list of layers when display sets to true setSortedLayers(getSortedLayers()); - }); - }, [app.globalObserver, getSortedLayers]); + } - const [sortedLayers, setSortedLayers] = useState(getSortedLayers()); + // Unsubscribe from the layerVisibilityChanged event + return function () { + if (visibilityChangedSubscription) { + visibilityChangedSubscription.unsubscribe(); + } + }; + }, [display, getSortedLayers, app.globalObserver]); - // When values of the filterList set changes, let's update the list. + // When values of the filterList set changes, let's update the list useEffect(() => { + // Update list of layers setSortedLayers(getSortedLayers()); - }, [filterList, getSortedLayers]); + }, [filterList, app.globalObserver, getSortedLayers]); - // Main handler of this component. Takes care of layer zIndex ordering. - const handleLayerOrderChange = (layer, direction) => { + // Handler that takes care of the layer zIndex ordering. + const onDrop = (dropResult) => { + const layer = dropResult.payload; + const { removedIndex, addedIndex } = dropResult; + // The layers original z-index const oldZIndex = layer.getZIndex() || 0; - // Setup two variables that will have different values depending on // whether we're moving the layer up or down the list. - let layerToBypass, - otherAffectedLayers = null; + let otherAffectedLayers = null; + + // Fail check + if (addedIndex === null || removedIndex === null) return; // No reorder + + // Determine the direction of the reorder + const direction = removedIndex - addedIndex; + + if (direction === 0) return; // No reorder if (direction > 0) { - // Increasing zIndex. We want to get everything above current layer and increase it too. - otherAffectedLayers = getSortedLayers().filter( + // Increasing zIndex. We want to get every layer with higher zindex than current layer and increase it too. + otherAffectedLayers = getAllLayers().filter( (l) => l.getZIndex() >= oldZIndex && layer !== l // Make sure to ignore current layer ); + // Get the layer that current layer need to replace zindex with + const layerToReplaceZindexWith = getSortedLayers()[addedIndex]; + const newZIndex = layerToReplaceZindexWith.getZIndex() || 0; - // Abort if there are no layers above the current one - if (otherAffectedLayers.length === 0) return; + // Remove layers from otherAffectedLayers that are not affected by the zindex change + otherAffectedLayers = otherAffectedLayers.filter( + (l) => l.getZIndex() <= newZIndex + ); - // Now we have a list of layers that are above the one we want to lift. Next thing to do - // is grab the _last_ layer in this list. That will be the layer that we want to "go above". - // The .pop() below does two things: it grabs the layer (so we can get it's zIndex) and it - // removes it from the array of other affected layers. We don't want to increase this one - // layer's zIndex (as opposed to everything else!). - layerToBypass = otherAffectedLayers.pop(); + // Decrease otherAffectedLayers with one zIndex. + otherAffectedLayers.forEach((l) => l.setZIndex(l.getZIndex() - 1)); + // Finally, the layer that is to be moved must get a new zIndex. + layer.setZIndex(newZIndex); } else { // Decreasing zIndex. Grab all layers with zIndex below the current layer's. - otherAffectedLayers = getSortedLayers().filter( + otherAffectedLayers = getAllLayers().filter( (l) => l.getZIndex() <= oldZIndex && layer !== l // Make sure to ignore current layer ); - // Abort if there are no layers below the current one - if (otherAffectedLayers.length === 0) return; + // Get the layer that current layer need to replace zindex with + const layerToReplaceZindexWith = getSortedLayers()[addedIndex]; + const newZIndex = layerToReplaceZindexWith.getZIndex() || 0; - // The first layer (directly below the moved one) should remain untouched. So we - // use .shift() to removed it from the array of affected layers and save to a variable. - // That variable will be used later on to determine the zIndex of this layer so that - // the layer we're currently moving can bypass this one. - layerToBypass = otherAffectedLayers.shift(); - } - - // otherAffectedLayers is an array of layers that are not in direct contact with the - // layer being moved or the one below/above it. To ensure that their internal order - // remains the same, we move them one step up/down (depending on the direction). - otherAffectedLayers.forEach((la) => - la.setZIndex(la.getZIndex() + direction) - ); + // Remove layers from otherAffectedLayers that are not affected by the zindex change + otherAffectedLayers = otherAffectedLayers.filter( + (l) => l.getZIndex() >= newZIndex + ); - // Finally, the layer that is to be moved must get a new zIndex. That value is determined - // by taking a look at the zIndex of the layer that we want to bypass and increased/decrease - // by one step. - layer.setZIndex(layerToBypass.getZIndex() + direction); + // Increase otherAffectedLayers with one zIndex. + otherAffectedLayers.forEach((la) => la.setZIndex(la.getZIndex() + 1)); + // Finally, the layer that is to be moved must get a new zIndex. + layer.setZIndex(newZIndex); + } // When we're done setting OL layers' zIndexes, we can update the state of our component, // so that the UI reflects the new order. setSortedLayers(getSortedLayers()); }; - const getLabelFromNumber = () => - sortedLayers.length.toString() + - " " + - (sortedLayers.length === 1 ? "AKTIVT LAGER" : "AKTIVA LAGER"); + // Handles click on info button in header + const handleInfoButtonClick = () => { + setInfoIsActive(!infoIsActive); + }; - return ( - - { + // Because parent element is transformed we need to render the "ghost" element in body instead of parent + return document.body; + }; + + // Sets system filter + const setSystemFilter = () => { + if (filterList.includes("system")) { + // Remove "system" from filerList + const newFilterList = filterList.filter((item) => item !== "system"); + setFilterList(newFilterList); + } else { + // Add "system" to filerList + setFilterList((filterList) => [...filterList, "system"]); + } + // Change systemFilterActive state + setSystemFilterActive(!systemFilterActive); + }; + + const renderLockedBaseLayerItem = () => { + if (!options.lockDrawOrderBaselayer) return null; + const l = sortedLayers.find((l) => l.get("layerType") === "base"); + if (!l) return null; + return ( + - - - - - {sortedLayers.map((l) => ( - - ))} + ); + }; + + return ( + + + theme.palette.mode === "dark" ? "#373737" : theme.palette.grey[100], + borderBottom: (theme) => + `${theme.spacing(0.2)} solid ${theme.palette.divider}`, + }} + > + + {options.enableSystemLayersSwitch && ( + + + } + label="Systemlager" + /> + + )} + + + + + + + + + +
+ + {options.drawOrderViewInfoText} + +
+
+
+ + sortedLayers[i]} + animationDuration={500} + onDrop={onDrop} + getGhostParent={getGhostParent} + > + {sortedLayers.map((l) => { + if ( + l.get("layerType") === "base" && + options.lockDrawOrderBaselayer + ) { + return null; + } else { + return ( + + {l.get("layerType") === "base" ? ( + + ) : l.get("layerType") === "group" ? ( + + ) : ( + + )} + + ); + } + })} + + {renderLockedBaseLayerItem()}
); diff --git a/apps/client/src/plugins/LayerSwitcher/components/DrawOrderListItem.js b/apps/client/src/plugins/LayerSwitcher/components/DrawOrderListItem.js deleted file mode 100644 index a9e4eb13f..000000000 --- a/apps/client/src/plugins/LayerSwitcher/components/DrawOrderListItem.js +++ /dev/null @@ -1,194 +0,0 @@ -import React, { useEffect } from "react"; - -import { - Icon, - IconButton, - ListItem, - ListItemButton, - ListItemIcon, - ListItemText, - Slider, - SvgIcon, -} from "@mui/material"; -import HajkToolTip from "components/HajkToolTip"; - -import ArrowUpward from "@mui/icons-material/ArrowUpward"; -import ArrowDownward from "@mui/icons-material/ArrowDownward"; -import FolderIcon from "@mui/icons-material/Folder"; -import GppMaybeIcon from "@mui/icons-material/GppMaybe"; -import LayersIcon from "@mui/icons-material/Layers"; -import Visibility from "@mui/icons-material/Visibility"; -import VisibilityOff from "@mui/icons-material/VisibilityOff"; -import WallpaperIcon from "@mui/icons-material/Wallpaper"; - -function MouseClickIcon(props) { - return ( - - - - - - ); -} - -function MouseNoClickIcon(props) { - return ( - - - - - - - ); -} - -export default function DrawOrderListItem({ changeOrder, layer }) { - // We want let user toggle a layer on/off without actually removing it - // from the list of visible layers. To accomplish this, we will change - // the layer's opacity between 0 and 1. - - // We keep the opacity in state… - const [opacity, setOpacity] = React.useState(layer.get("opacity")); - - // …and let a useEffect manage the actual OL layer's opacity. - useEffect(() => { - layer.set("opacity", opacity); - }, [layer, opacity]); - - const handleOpacitySliderChange = (event, newValue) => { - setOpacity(newValue); - }; - - const handleVisibilityButtonClick = () => { - setOpacity(opacity === 0 ? 1 : 0); - }; - - // To make the layers list more fun, we want to display an icon next to - // the layer. - const getIconFromLayer = (layer) => { - // Some layers can have a "infoclickIcon" property. If so, use it. - const layerSpecificIcon = - layer.get("layerInfo")?.infoclickIcon || layer.get("infoclickIcon"); - if (layerSpecificIcon !== undefined) { - return {layerSpecificIcon}; - } else { - // Else, let's pick an icon depending on the layer's type. - switch (layer.get("layerType")) { - case "layer": - return ; - case "group": - return ; - case "base": - return ; - case "system": - default: - return ; - } - } - }; - - const getFriendlyTypeFromLayer = (layer) => { - switch (layer.get("layerType")) { - case "layer": - return "Lager"; - case "group": - return "Grupplager"; - case "base": - return "Bakgrundslager"; - case "system": - default: - return "Systemlager"; - } - }; - - const isLayerQueryable = (layer) => { - // The simplest option. Vector layers will have this property set. - if (layer.get("queryable") === true) { - return true; - } - - // If we got this far, we must search further. The WMS and WMTS layers - // lack a handy top property. The 'queryable' property is set on each - // sublayer instead. - // One problem is that we display grouplayers as one item in the list. - // The 'queryable' settings is a property of each sublayer though, so we - // can have a situation where only one sublayer is queryable in a grouplayer. - // In this case, we want to display the item in the list as queryable (even - // if only one of its ingredients is). The simple solution is to search for - // the first sublayer that has the property sat to true - if we find it, we - // consider the entire group queryable. - return ( - layer.layersInfo !== undefined && - Object.values(layer.layersInfo).findIndex( - (sl) => sl.queryable === true - ) !== -1 - ); - }; - - return ( - - 0 ? 1 : 0.38, - }} - disableRipple - disableTouchRipple - > - - - {getIconFromLayer(layer)} - - - - - {layer.get("layerType") !== "system" && - (isLayerQueryable(layer) ? ( - - ) : ( - - ))} - - - } - /> - 0 ? "Dölj " : "Visa ") + "lager"}> - - {opacity > 0 ? : } - - - changeOrder(layer, +1)} - > - - - changeOrder(layer, -1)} - > - - - - - ); -} diff --git a/apps/client/src/plugins/LayerSwitcher/components/DrawOrderOptions.js b/apps/client/src/plugins/LayerSwitcher/components/DrawOrderOptions.js deleted file mode 100644 index c97443964..000000000 --- a/apps/client/src/plugins/LayerSwitcher/components/DrawOrderOptions.js +++ /dev/null @@ -1,201 +0,0 @@ -import * as React from "react"; -import { useSnackbar } from "notistack"; - -import LocalStorageHelper from "../../../utils/LocalStorageHelper"; - -import { - Button, - Divider, - ListItemIcon, - ListItemText, - Menu, - MenuItem, -} from "@mui/material"; -import FolderOpen from "@mui/icons-material/FolderOpen"; -import GppMaybeIcon from "@mui/icons-material/GppMaybe"; -import Save from "@mui/icons-material/Save"; -import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; - -export default function DrawOrderOptions({ - app, - filterList, - setFilterList, - map, -}) { - // Prepare the Snackbar - we want to display nice messages when - // user saves/restores layers. - const { enqueueSnackbar } = useSnackbar(); - - // Element that we will anchor the options menu to is - // held in state. If it's null (unanchored), we can tell - // that the menu should be hidden. - const [anchorEl, setAnchorEl] = React.useState(null); - const optionsMenuIsOpen = Boolean(anchorEl); - - // Show the options menu by setting an anchor element - const handleShowMoreOptionsClick = (e) => { - setAnchorEl(e.currentTarget); - }; - - // Hides the options menu by resetting the anchor element - const handleCloseOptionsMenu = () => { - setAnchorEl(null); - }; - - /** - * Take care of saving active layers so that they can be restored layer. - * For time being we're only saving in local storage, but this may change - * in the future. - * We take care of saving **all non-system layers**. - * We save the opacity as well as the layers' internal order (by reading - * the value of zIndex). - */ - const handleSave = () => { - // Grab layers to be saved by… - const layers = map - .getAllLayers() // - .filter((l) => l.getVisible() === true && l.get("layerType") !== "system") // …filtering out system layers. - .map((l) => { - // Create an array of objects. For each layer, we want to read its… - return { i: l.get("name"), z: l.getZIndex(), o: l.getOpacity() }; // …name, zIndex and opacity. - }); - - // Let's create some metadata about our saved layers. User might want to know - // how many layers are saved and when they were saved. - // First, we try to get the map's name. We can't be certain that this exists (not - // all maps have the userSpecificMaps property), so we must be careful. - const mapName = - Array.isArray(app.config.userSpecificMaps) && - app.config.userSpecificMaps.find( - (m) => m.mapConfigurationName === app.config.activeMap - )?.mapConfigurationTitle; - - // Next, let's put together the metadata object… - const metadata = { - savedAt: new Date(), - numberOfLayers: layers.length, - ...(mapName && { mapName }), // …if we have a map name, let's add it too. - }; - - // Let's combine it all to an object that will be saved. - const objectToSave = { layers, metadata }; - - const currentLsSettings = LocalStorageHelper.get("layerswitcher"); - - // TODO: Determine whether this should be a functional or required cookie, - // add the appropriate hook and describe here https://github.com/hajkmap/Hajk/wiki/Cookies-in-Hajk. - LocalStorageHelper.set("layerswitcher", { - ...currentLsSettings, - savedLayers: objectToSave, - }); - - enqueueSnackbar(`${metadata.numberOfLayers} lager sparades utan problem`, { - variant: "success", - }); - }; - - const handleRestore = () => { - // Let's be safe about parsing JSON - try { - const { metadata, layers } = LocalStorageHelper._experimentalGet( - "layerswitcher.savedLayers" - ); - - map - .getAllLayers() // Traverse all layers… - .filter((l) => l.get("layerType") !== "system") // …ignore system layers. - .forEach((l) => { - // See if the current layer is in the list of saved layers. - const match = layers.find((rl) => rl.i === l.get("name")); - // If yes… - if (match) { - // …read and set some options. - l.setZIndex(match.z); - l.setOpacity(match.o); - l.setVisible(true); - } else { - // If not, ensure that the layer is hidden. - l.setVisible(false); - } - }); - - enqueueSnackbar( - `${metadata.numberOfLayers} lager återställdes från tidigare session`, - { - variant: "success", - } - ); - } catch (error) { - enqueueSnackbar( - "Innan du kan återställa måste du spara dina befintliga lager först." - ); - } - }; - - // Handler function for the show/hide system layers toggle - const handleSystemLayerSwitchChange = () => { - if (filterList.has("system")) { - filterList.delete("system"); - setFilterList(new Set(filterList)); - } else { - filterList.add("system"); - setFilterList(new Set(filterList)); - } - }; - - return ( - <> - - - { - handleSave(); - handleCloseOptionsMenu(); - }} - > - - - - Spara aktiva lager - - { - handleRestore(); - handleCloseOptionsMenu(); - }} - > - - - - Återställ sparade lager - - - { - handleSystemLayerSwitchChange(); - handleCloseOptionsMenu(); - }} - > - - - - {`${ - filterList.has("system") ? "Dölj" : "Visa" - } systemlager`} - - - - ); -} diff --git a/apps/client/src/plugins/LayerSwitcher/components/Favorites/FavoritePackageOptions.js b/apps/client/src/plugins/LayerSwitcher/components/Favorites/FavoritePackageOptions.js new file mode 100644 index 000000000..aed447424 --- /dev/null +++ b/apps/client/src/plugins/LayerSwitcher/components/Favorites/FavoritePackageOptions.js @@ -0,0 +1,124 @@ +import * as React from "react"; + +import { + IconButton, + Divider, + ListItemIcon, + ListItemText, + Menu, + MenuItem, + Tooltip, +} from "@mui/material"; + +import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; +import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; +import FileDownloadOutlinedIcon from "@mui/icons-material/FileDownloadOutlined"; +import DeleteOutlinedIcon from "@mui/icons-material/DeleteOutlined"; +import MoreVertOutlinedIcon from "@mui/icons-material/MoreVertOutlined"; + +export default function FavoritePackageOptions({ + infoCallback, + deleteCallback, + downloadCallback, + editCallback, + favorite, +}) { + // Element that we will anchor the options menu to is + // held in state. If it's null (unanchored), we can tell + // that the menu should be hidden. + const [anchorEl, setAnchorEl] = React.useState(null); + + const optionsMenuIsOpen = Boolean(anchorEl); + + // Show the options menu by setting an anchor element + const handleShowMoreOptionsClick = (e) => { + e.stopPropagation(); + e.preventDefault(); + setAnchorEl(e.currentTarget); + }; + + // Hides the options menu by resetting the anchor element + const onOptionsMenuClose = (e) => { + e.stopPropagation(); + e.preventDefault(); + setAnchorEl(null); + }; + + // Handle download action + const handleDownload = (e) => { + e.stopPropagation(); + setAnchorEl(null); + downloadCallback(favorite); + }; + + // Handle info action + const handleInfo = (e) => { + e.stopPropagation(); + setAnchorEl(null); + infoCallback(favorite); + }; + + // Handle edit action + const handleEdit = (e) => { + e.stopPropagation(); + setAnchorEl(null); + editCallback(favorite); + }; + + // Handle delete action + const handleDelete = (e) => { + e.stopPropagation(); + setAnchorEl(null); + deleteCallback(favorite); + }; + + return ( + <> + + + + + + e.stopPropagation()} + > + + + + + Info + + + + + + + Redigera + + + + + + Ta bort + + + + + + + Exportera + + + + ); +} diff --git a/apps/client/src/plugins/LayerSwitcher/components/Favorites/Favorites.js b/apps/client/src/plugins/LayerSwitcher/components/Favorites/Favorites.js new file mode 100644 index 000000000..8fa473764 --- /dev/null +++ b/apps/client/src/plugins/LayerSwitcher/components/Favorites/Favorites.js @@ -0,0 +1,572 @@ +import React, { useState, useEffect } from "react"; +import { createPortal } from "react-dom"; +import { useSnackbar } from "notistack"; +import useCookieStatus from "hooks/useCookieStatus"; + +import LocalStorageHelper from "utils/LocalStorageHelper"; +import FavoritesList from "./FavoritesList.js"; +import FavoritesOptions from "./FavoritesOptions.js"; +import FavoritesViewHeader from "./FavoritesViewHeader.js"; +import ConfirmationDialog from "components/ConfirmationDialog.js"; + +import { + Box, + Button, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + TextField, + Typography, +} from "@mui/material"; + +function Favorites({ + handleFavoritesViewToggle, + app, + map, + favoriteViewDisplay, + globalObserver, + favoritesInfoText, + handleQuickAccessSectionExpanded, +}) { + const { enqueueSnackbar } = useSnackbar(); + const [domReady, setDomReady] = useState(false); + const [description, setDescription] = useState(""); + const [title, setTitle] = useState(""); + const [saveFavoriteDialog, setSaveFavoriteDialog] = useState(false); + const [loadDialog, setLoadDialog] = useState(false); + const [selectedFavorite, setSelectedFavorite] = useState(null); + const [favorites, setFavorites] = useState([]); + const [missingLayersConfirmation, setMissingLayersConfirmation] = + useState(null); + const [toggleFavoritesView, setToggleFavoritesView] = useState(false); + const [openNoLayersAlert, setOpenNoLayersAlert] = useState(false); + // We're gonna need to keep track of if we're allowed to save stuff in LS. Let's use the hook. + const { functionalCookiesOk } = useCookieStatus(globalObserver); + + useEffect(() => { + // Set state from localstorage on component load + const currentLsSettings = LocalStorageHelper.get("layerswitcher"); + if (currentLsSettings.savedLayers?.length > 0) { + handleSetFavorites(currentLsSettings.savedLayers); + } + // Set dom ready flag to true + setDomReady(true); + }, []); + + useEffect(() => { + // Save to storage when state changes occur + const currentLsSettings = LocalStorageHelper.get("layerswitcher"); + + // TODO: Determine whether this should be a functional or required cookie, + // add the appropriate hook and describe here https://github.com/hajkmap/Hajk/wiki/Cookies-in-Hajk. + LocalStorageHelper.set("layerswitcher", { + ...currentLsSettings, + savedLayers: favorites, + }); + }, [favorites]); + + // Handles click on add favorite button in menu + const handleAddFavoriteClick = () => { + if (getQuickAccessLayers().length > 0) { + setSaveFavoriteDialog(!saveFavoriteDialog); + } else { + setOpenNoLayersAlert(true); + } + }; + + const handleLoadFavorite = (favorite, showDialog, toggleView) => { + setToggleFavoritesView(toggleView); + setSelectedFavorite(favorite); + if (showDialog) { + setLoadDialog(!loadDialog); + } else { + loadFavorite(favorite, toggleView); + } + }; + + const loadFavorite = (favorite, toggleView) => { + setLoadDialog(false); + favorite = favorite || selectedFavorite; + // Check if layers from layerpackage exists in map + const missingLayers = checkForMissingLayers(favorite.layers); + if (missingLayers.length > 0) { + // Show missing layers dialog + setMissingLayersConfirmation({ + missingLayers: missingLayers, + layers: favorite.layers, + title: favorite.metadata.title, + }); + } else { + loadLayers(favorite.layers, favorite.metadata.title, toggleView); + } + }; + + // Load layers to quickAccess section + const loadLayers = (layers, title, toggleView) => { + clearQuickAccessLayers(); + resetVisibleLayers(); + setMissingLayersConfirmation(null); + + const allMapLayers = map.getAllLayers(); + layers.forEach((l) => { + const layer = allMapLayers.find((la) => la.get("name") === l.id); + if (layer) { + // Set quickaccess property + if (layer.get("layerType") !== "base") { + layer.set("quickAccess", true); + } + // Set drawOrder (zIndex) + layer.setZIndex(l.drawOrder); + // Set opacity + layer.setOpacity(l.opacity); + // Special handling for layerGroups and baselayers + if (layer.get("layerType") === "group") { + if (l.visible === true) { + const subLayersToShow = l.subLayers ? l.subLayers : []; + globalObserver.publish("layerswitcher.showLayer", { + layer, + subLayersToShow, + }); + } else { + globalObserver.publish("layerswitcher.hideLayer", layer); + } + } else if (layer.get("layerType") === "base") { + // Hide all other background layers + globalObserver.publish( + "layerswitcher.backgroundLayerChanged", + layer.get("name") + ); + // Set visibility + layer.set("visible", l.visible); + } else { + layer.set("visible", l.visible); + } + } else if (l.id < 0) { + // A fake maplayer is in the package + // Hide all other background layers + globalObserver.publish("layerswitcher.backgroundLayerChanged", l.id); + // And set background color to map + switch (l.id) { + case "-2": + document.getElementById("map").style.backgroundColor = "#000"; + break; + case "-1": + default: + document.getElementById("map").style.backgroundColor = "#FFF"; + break; + } + } + }); + + enqueueSnackbar(`${title} har nu laddats till snabbåtkomst.`, { + variant: "success", + anchorOrigin: { vertical: "bottom", horizontal: "center" }, + }); + + const doToggleView = + toggleView !== undefined ? toggleView : toggleFavoritesView; + + if (doToggleView) { + // Close favorites view on load + handleFavoritesViewToggle({ setQuickAccessSectionExpanded: true }); + } else { + handleQuickAccessSectionExpanded(); + } + }; + + // Clear quickaccessLayers + const clearQuickAccessLayers = () => { + getQuickAccessLayers().map((l) => l.set("quickAccess", false)); + }; + + // Reset visible layers + const resetVisibleLayers = () => { + map + .getAllLayers() + .filter((l) => l.get("visible") === true) + .forEach((l) => { + if (l.get("layerType") === "group") { + globalObserver.publish("layerswitcher.hideLayer", l); + } else if (l.get("layerType") !== "system") { + l.set("visible", false); + } + }); + }; + + // Get quickaccess layers + const getQuickAccessLayers = () => { + return map.getAllLayers().filter((l) => l.get("quickAccess") === true); + }; + + const changeCookieSetting = () => { + // Handles clicks on the "change-cookie-settings-button". Simply emits an event + // on the global-observer, stating that the cookie-banner should be shown again. + globalObserver.publish("core.showCookieBanner"); + }; + + // Check if all layers in favorite package exist in map + const checkForMissingLayers = (layers) => { + map.getAllLayers().forEach((layer) => { + const existingLayer = layers.find((l) => l.id === layer.get("name")); + if (existingLayer) { + // Remove the layer from the layers array once it's found + layers = layers.filter((l) => l.id !== existingLayer.id); + } + }); + // Also, remove potential fake background layers included in the package + if (layers.length > 0) { + // Fake maplayers have id below 0 + layers = layers.filter((l) => l.id > 0); + } + // At this point, the layers array will only contain the layers that don't exist in map.getAllLayers() or is a fake mapLayer + return layers; + }; + + // Handles save favorite + const handleSaveFavorite = () => { + // Grab layers to be saved by… + const layers = getQuickAccessLayers().map((l) => { + // Create an array of objects. For each layer, we want to read its… + return { + id: l.get("name"), + visible: l.getVisible(), + subLayers: l.get("layerType") === "group" ? l.get("subLayers") : [], + opacity: l.getOpacity(), + drawOrder: l.getZIndex(), + }; // …name as id, visibility and potentially sublayers. + }); + + if (layers.length === 0) { + enqueueSnackbar( + "Inga lager i snabbåtkomst tillagda, därmed inget att spara.", + { + variant: "warning", + anchorOrigin: { vertical: "bottom", horizontal: "center" }, + } + ); + return; + } + + // Also, grab current baselayer and add it to the layers array. + const baseLayer = map + .getLayers() + .getArray() + .find((l) => l.get("layerType") === "base" && l.getVisible()); + + if (baseLayer) { + layers.push({ + id: baseLayer.get("name"), + visible: true, + subLayers: [], + opacity: baseLayer.getOpacity(), + drawOrder: baseLayer.getZIndex(), + }); + } else { + // No "real" base layer is visible, so we need to check for a fake one (black or white). + const currentBackgroundColor = + document.getElementById("map").style.backgroundColor; + const WHITE_BACKROUND_LAYER_ID = "-1"; + const BLACK_BACKROUND_LAYER_ID = "-2"; + layers.push({ + id: + currentBackgroundColor === "rgb(0, 0, 0)" + ? BLACK_BACKROUND_LAYER_ID + : WHITE_BACKROUND_LAYER_ID, + visible: true, + subLayers: [], + opacity: 1, + drawOrder: -1, + }); + } + + // Let's create some metadata about our saved layers. User might want to know + // how many layers are saved and when they were saved. + // First, we try to get the map's name. We can't be certain that this exists (not + // all maps have the userSpecificMaps property), so we must be careful. + const mapName = + Array.isArray(app.config.userSpecificMaps) && + app.config.userSpecificMaps.find( + (m) => m.mapConfigurationName === app.config.activeMap + )?.mapConfigurationTitle; + + // Next, let's put together the metadata object… + const metadata = { + savedAt: new Date(), + numberOfLayers: layers.length, + title: title, + description: description, + ...(mapName && { mapName }), // …if we have a map name, let's add it too. + }; + + // Let's combine it all to an object that will be saved. + const objectToSave = { layers, metadata }; + const newFavorites = [...favorites]; + newFavorites.push(objectToSave); + handleSetFavorites(newFavorites); + setTitle(""); + setDescription(""); + + enqueueSnackbar(`${metadata.title} har lagts till i favoriter.`, { + variant: "success", + anchorOrigin: { vertical: "bottom", horizontal: "center" }, + }); + }; + + const handleRemoveFavorite = (selectedFavorite) => { + // Clone added favorites + let favoritesArray = [...favorites]; + // Get remaining favorites + favoritesArray = favoritesArray.filter((f) => f !== selectedFavorite); + // And set to state + handleSetFavorites(favoritesArray); + enqueueSnackbar(`${selectedFavorite.metadata.title} har tagits bort.`, { + variant: "success", + anchorOrigin: { vertical: "bottom", horizontal: "center" }, + }); + }; + + const handleSetFavorites = (newFavorites) => { + // Sort favorites by title and saved date + const sortedFavorites = newFavorites.sort((a, b) => { + // Compare titles + const titleComparison = a.metadata.title.localeCompare(b.metadata.title); + // If titles are equal, compare saved dates + if (titleComparison === 0) { + return new Date(b.metadata.savedAt) - new Date(a.metadata.savedAt); + } + return titleComparison; + }); + // And set to state + setFavorites(sortedFavorites); + }; + + // Handles edit favorite title and description + const handleEditFavorite = (selectedFavorite, editTitle, editDescription) => { + const index = favorites.findIndex((lp) => lp === selectedFavorite); + // Clone added favorites + const favoritesArray = [...favorites]; + // Create a new updated object + const updatedObject = { + ...favoritesArray[index], + metadata: { + ...favoritesArray[index].metadata, + title: editTitle, + description: editDescription, + }, + }; + // Replace with updatedObject + favoritesArray[index] = updatedObject; + + // And set to state + handleSetFavorites(favoritesArray); + + enqueueSnackbar(`Favoriten uppdaterades utan problem`, { + variant: "success", + anchorOrigin: { vertical: "bottom", horizontal: "center" }, + }); + }; + + // Fires when the user closes the missing layers-window. + const handleMissingLayersConfirmationAbort = () => { + setMissingLayersConfirmation(null); + }; + + const handleImportFavorites = (parsedFavorites) => { + const favoritesArray = [...favorites]; + favoritesArray.push(parsedFavorites); + handleSetFavorites(favoritesArray); + enqueueSnackbar(`Favoriten importerades utan problem`, { + variant: "success", + anchorOrigin: { vertical: "bottom", horizontal: "center" }, + }); + }; + + const renderFavoritesView = () => { + return createPortal( + + + + + + , + document.getElementById("scroll-container") + ); + }; + + // Render dialog with missing layers information + const renderMissingLayersDialog = () => { + return createPortal( + { + e.stopPropagation(); + }} + onClick={(e) => { + e.stopPropagation(); + }} + > + Lager saknas + + + {missingLayersConfirmation && + `Följande lagerid:n kan inte hittas i kartans lagerlista:`} +

+
+
    + {missingLayersConfirmation?.missingLayers.map((l) => { + return
  • {l.id}
  • ; + })} +
+ + {missingLayersConfirmation && + `Det kan bero på att lagret har utgått. Vänligen kontrollera och uppdatera favoriten.`} +

+
+
+ + + + +
, + document.getElementById("map") + ); + }; + + const renderAddFavoriteDialog = () => { + return createPortal( + { + e.stopPropagation(); + }} + onClick={(e) => { + e.stopPropagation(); + }} + > + + Spara till favoriter + + + + Spara aktuell snabbåtkomst som en favorit lokalt på din enhet så att + du kan använda den senare. + + setTitle(e.target.value)} + /> + setDescription(e.target.value)} + /> + + + + + + , + document.getElementById("map") + ); + }; + + return ( + <> + + {domReady && renderFavoritesView()} + { + setLoadDialog(!loadDialog); + }} + /> + { + setOpenNoLayersAlert(false); + }} + > + {renderAddFavoriteDialog()} + {renderMissingLayersDialog()} + + ); +} + +export default Favorites; diff --git a/apps/client/src/plugins/LayerSwitcher/components/Favorites/FavoritesList.js b/apps/client/src/plugins/LayerSwitcher/components/Favorites/FavoritesList.js new file mode 100644 index 000000000..7b8e6a810 --- /dev/null +++ b/apps/client/src/plugins/LayerSwitcher/components/Favorites/FavoritesList.js @@ -0,0 +1,348 @@ +import React, { useState } from "react"; +import { createPortal } from "react-dom"; +import { saveAs } from "file-saver"; + +import FavoritePackageOptions from "./FavoritePackageOptions.js"; +import ConfirmationDialog from "../../../../components/ConfirmationDialog.js"; + +import { + Button, + Dialog, + DialogActions, + DialogTitle, + DialogContent, + DialogContentText, + Divider, + List, + ListItemButton, + ListItemIcon, + ListItemSecondaryAction, + ListItemText, + Stack, + TextField, + Typography, +} from "@mui/material"; + +import LayersOutlinedIcon from "@mui/icons-material/LayersOutlined"; +import PublicOutlinedIcon from "@mui/icons-material/PublicOutlined"; + +function FavoritesList({ + favorites, + loadFavoriteCallback, + map, + removeCallback, + editCallback, + functionalCookiesOk, + cookieSettingCallback, +}) { + const [selectedItem, setSelectedItem] = useState(null); + const [removeAlert, setRemoveAlert] = useState(false); + const [editAlert, setEditAlert] = useState(false); + const [infoAlert, setInfoAlert] = useState(false); + const [editDescription, setEditDescription] = useState(""); + const [editTitle, setEditTitle] = useState(""); + + // Handles click on info button in menu + const handleInfo = (lp) => { + setSelectedItem(lp); + setInfoAlert(!infoAlert); + }; + + // Handles click on edit button in menu + const handleEdit = (lp) => { + setSelectedItem(lp); + setEditTitle(lp.metadata.title); + setEditDescription(lp.metadata.description); + setEditAlert(!editAlert); + }; + + // Handles remove favorite + const handleRemoveFavorite = () => { + removeCallback(selectedItem); + setRemoveAlert(!removeAlert); + }; + + // Handles edit favorite + const handleEditFavorite = () => { + editCallback(selectedItem, editTitle, editDescription); + setEditAlert(!editAlert); + }; + + // Handles click on delete button in menu + const handleDelete = (lp) => { + setSelectedItem(lp); + setRemoveAlert(!removeAlert); + }; + + // Handles click on download button in menu + const handleDownload = (lp) => { + handleExportJsonClick(lp); + }; + + // Parse date for prettier display + const parseDate = (date) => { + return new Date(date).toLocaleTimeString([], { + year: "numeric", + month: "numeric", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); + }; + + // Handle export layerpackage as json file + const handleExportJsonClick = (lp) => { + try { + new Blob(); + } catch { + console.info("JSON export not supported on current platform."); + return; + } + // Convert JSON-data to blob + const blobData = new Blob([JSON.stringify(lp, null, 2)], { + type: "application/json", + }); + + saveAs( + blobData, + `${lp.metadata.title} - ${new Date().toLocaleString()}.json` + ); + }; + + // Function that finds a layer by id and returns caption + const getBaseLayerName = (layers) => { + let backgroundLayerName = "Bakgrundskarta hittades inte"; + layers.forEach((layer) => { + const mapLayer = map + .getAllLayers() + .find((l) => l.get("name") === layer.id); + if (mapLayer && mapLayer.get("layerType") === "base") { + backgroundLayerName = mapLayer.get("caption"); + } else if (layer.id < 0) { + // A fake maplayer is in the package + switch (layer.id) { + case "-2": + backgroundLayerName = "Svart"; + break; + case "-1": + default: + backgroundLayerName = "Vit"; + break; + } + } + }); + return backgroundLayerName; + }; + + // Render dialog with layerpackage information + const renderInfoDialog = () => { + return createPortal( + { + e.stopPropagation(); + }} + onClick={(e) => { + e.stopPropagation(); + }} + > + + {selectedItem ? selectedItem.metadata.title : ""} + + + + {selectedItem && parseDate(selectedItem.metadata.savedAt)} + + + {selectedItem ? selectedItem.metadata.description : ""} + + Bakgrund + + + + {selectedItem && getBaseLayerName(selectedItem.layers)} + + + + + Vid laddning ersätts lagren i snabbåtkomst. Alla tända lager i + kartan släcks och ersätts med favoritens tända lager. + + + + + + + , + document.getElementById("map") + ); + }; + + const renderEditDialog = () => { + return createPortal( + { + e.stopPropagation(); + }} + onClick={(e) => { + e.stopPropagation(); + }} + > + Redigera + + setEditTitle(e.target.value)} + /> + setEditDescription(e.target.value)} + /> + + + + + + , + document.getElementById("map") + ); + }; + + // A view that is rendered if the user has selected not to accept functional + // cookies. (Functional cookies has to be accepted) + const notSupportedView = () => { + return ( + + + Det ser ut som att du har valt att inte tillåta funktionella kakor. På + grund av detta så kan du inte se dina sparade favoriter eller lägga + till nya. + + + Klicka nedan för att ändra inställningarna. + + + + ); + }; + + return ( + <> + {functionalCookiesOk ? ( + + {!favorites.length ? ( + Inga favoriter finns sparade + ) : ( + favorites.map((favorite, index) => { + return ( + { + e.stopPropagation(); + loadFavoriteCallback(favorite, true, true); + }} + > + + + + + + + + + ); + }) + )} + + ) : ( + notSupportedView() + )} + { + setRemoveAlert(!removeAlert); + }} + /> + {renderEditDialog()} + {renderInfoDialog()} + + ); +} + +export default FavoritesList; diff --git a/apps/client/src/plugins/LayerSwitcher/components/Favorites/FavoritesOptions.js b/apps/client/src/plugins/LayerSwitcher/components/Favorites/FavoritesOptions.js new file mode 100644 index 000000000..ccfdb42c0 --- /dev/null +++ b/apps/client/src/plugins/LayerSwitcher/components/Favorites/FavoritesOptions.js @@ -0,0 +1,121 @@ +import * as React from "react"; + +import { + IconButton, + Divider, + ListItemIcon, + ListItemText, + Menu, + MenuItem, + Tooltip, +} from "@mui/material"; + +import PersonOutlinedIcon from "@mui/icons-material/PersonOutlined"; +import LibraryAddOutlinedIcon from "@mui/icons-material/LibraryAddOutlined"; +import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; +import LayersOutlinedIcon from "@mui/icons-material/LayersOutlined"; + +export default function FavoritesOptions({ + handleFavoritesViewToggle, + loadFavoriteCallback, + addFavoriteCallback, + favorites, + functionalCookiesOk, +}) { + // Element that we will anchor the options menu to is + // held in state. If it's null (unanchored), we can tell + // that the menu should be hidden. + const [anchorEl, setAnchorEl] = React.useState(null); + + const optionsMenuIsOpen = Boolean(anchorEl); + + // Show the options menu by setting an anchor element + const handleShowMoreOptionsClick = (e) => { + e.stopPropagation(); + e.preventDefault(); + setAnchorEl(e.currentTarget); + }; + + // Hides the options menu by resetting the anchor element + const onOptionsMenuClose = (e) => { + e.stopPropagation(); + e.preventDefault(); + setAnchorEl(null); + }; + + // Handle load favorite + const handleLoad = (e, favorite) => { + e.stopPropagation(); + setAnchorEl(null); + loadFavoriteCallback(favorite, true, false); + }; + + // Handle add favorite action + const handleAdd = (e) => { + e.stopPropagation(); + setAnchorEl(null); + addFavoriteCallback(); + }; + + // Handle edit favorites + const handleEditFavorites = (e) => { + e.stopPropagation(); + setAnchorEl(null); + handleFavoritesViewToggle({ event: e }); + }; + + return ( + <> + + + + + + e.stopPropagation()} + > + + + + + Spara till favoriter + + + + + + Redigera favoriter + + {favorites.length > 0 && } + {favorites.map((favorite) => { + return ( + handleLoad(e, favorite)} + > + + + + {favorite.metadata.title} + + ); + })} + + + ); +} diff --git a/apps/client/src/plugins/LayerSwitcher/components/Favorites/FavoritesViewHeader.js b/apps/client/src/plugins/LayerSwitcher/components/Favorites/FavoritesViewHeader.js new file mode 100644 index 000000000..2e5a10be6 --- /dev/null +++ b/apps/client/src/plugins/LayerSwitcher/components/Favorites/FavoritesViewHeader.js @@ -0,0 +1,163 @@ +import React, { useState, useRef } from "react"; +import { useSnackbar } from "notistack"; + +import { + Box, + Collapse, + IconButton, + Stack, + Tooltip, + Typography, +} from "@mui/material"; + +import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; +import ArrowBackIcon from "@mui/icons-material/ArrowBack"; +import FileUploadOutlinedIcon from "@mui/icons-material/FileUploadOutlined"; + +function FavoritesViewHeader({ + backButtonCallback, + importFavoritesCallback, + functionalCookiesOk, + favoritesInfoText, +}) { + const { enqueueSnackbar } = useSnackbar(); + const fileInputRef = useRef(null); + // State that toggles info collapse + const [infoIsActive, setInfoIsActive] = useState(false); + // Because of a warning in dev console, we need special handling of tooltip for backbutton. + // When a user clicks back, the tooltip of the button needs to be closed before this view hides. + // TODO: Needs a better way to handle this + const [tooltipOpen, setTooltipOpen] = useState(false); + + // Handles click on back button in header + const handleBackButtonClick = (e) => { + e.stopPropagation(); + setTooltipOpen(false); + setTimeout(() => { + backButtonCallback(); + }, 100); + }; + + // Handles click on info button in header + const handleInfoButtonClick = (e) => { + e.stopPropagation(); + setInfoIsActive(!infoIsActive); + }; + + // Handles click on upload button in header + const handleImportButtonClick = (e) => { + e.stopPropagation(); + if (fileInputRef.current) { + fileInputRef.current.click(); + } + }; + + // Handles backbutton tooltip open event + const handleOpen = () => { + setTooltipOpen(true); + }; + + // Handles backbutton tooltip close event + const handleClose = () => { + setTooltipOpen(false); + }; + + // Handles file input changes + const handleFileInputChange = (event) => { + const file = event.target.files?.[0]; + if (file) { + const reader = new FileReader(); + reader.readAsText(file); + reader.onload = (event) => { + const contents = event.target?.result; + try { + // Parse the string to a real object + const parsedFavorites = JSON.parse(contents); + + if (parsedFavorites.layers?.length > 0) { + // Add it to collection + importFavoritesCallback(parsedFavorites); + } + } catch (error) { + console.error(`Favorite could not be parsed. Error: ${error}`); + enqueueSnackbar( + "Favoriten kunde inte laddas, kontrollera att .json filen ser korrekt ut.", + { + variant: "error", + anchorOrigin: { vertical: "bottom", horizontal: "center" }, + } + ); + } + }; + } + }; + + return ( + + theme.palette.mode === "dark" ? "#373737" : theme.palette.grey[100], + borderBottom: (theme) => + `${theme.spacing(0.2)} solid ${theme.palette.divider}`, + }} + onClick={(e) => e.stopPropagation()} + > + + + + + + + + Mina favoriter + + + + + + + + + + + + + + + + `${theme.spacing(0.2)} solid ${theme.palette.divider}`, + }} + > + {favoritesInfoText} + + + + ); +} + +export default FavoritesViewHeader; diff --git a/apps/client/src/plugins/LayerSwitcher/components/GroupLayer.js b/apps/client/src/plugins/LayerSwitcher/components/GroupLayer.js new file mode 100644 index 000000000..a07c93135 --- /dev/null +++ b/apps/client/src/plugins/LayerSwitcher/components/GroupLayer.js @@ -0,0 +1,325 @@ +import React, { useEffect, useState, useCallback } from "react"; + +import { Box, Collapse, IconButton } from "@mui/material"; + +import LayerItem from "./LayerItem"; +import SubLayerItem from "./SubLayerItem"; + +import KeyboardArrowRightOutlinedIcon from "@mui/icons-material/KeyboardArrowRightOutlined"; + +// Custom hooks +import useSnackbar from "../../../hooks/useSnackbar"; + +/* A grouplayer is a layer configured with multiple layers in admin, NOT a group in layerswitcher */ + +export default function GroupLayer({ + layer, + app, + localObserver, + toggleable, + draggable, + quickAccessLayer, + display, + groupLayer, +}) { + // Keep the subLayers area active in state + const [showSublayers, setShowSublayers] = useState(false); + // Keep visible sublayers in state + const [visibleSubLayers, setVisibleSubLayers] = useState( + layer.get("visible") + ? quickAccessLayer || draggable + ? layer.get("subLayers") + : layer.visibleAtStartSubLayers?.length > 0 + ? layer.visibleAtStartSubLayers + : layer.subLayers + : [] + ); + const [zoomVisible, setZoomVisible] = useState(true); + const [subLayerClicked, setSubLayerClicked] = useState(false); + + const { removeFromSnackbar } = useSnackbar(); + + // Special case for removing layer captions from snackbar message when being toggled + // through the LayerGroup component. + useEffect(() => { + if (visibleSubLayers.length === 0) { + const removeLayerCaptions = layer.subLayers.map( + (subLayer) => layer.layersInfo[subLayer]?.caption || "" + ); + // Remove layer caption from snackbar message. + removeFromSnackbar(removeLayerCaptions); + } + }, [visibleSubLayers, layer.layersInfo, layer.subLayers, removeFromSnackbar]); + + const setGroupHidden = useCallback( + (l) => { + if (l.get("name") === layer.get("name")) { + // Update OL layer sublayers property + layer.set("subLayers", []); + // Update visibleSubLayers state + setVisibleSubLayers([]); + } + }, + [layer] + ); + + const setSubLayers = (visibleSubLayersArray) => { + // Check if layer is visible + let layerVisibility = layer.get("visible"); + // If layer is not visible and remaining visible subLayers exists, layer should turn visible + if (!layerVisibility && visibleSubLayersArray.length > 0) { + layerVisibility = true; + } + + // If remaining visible subLayers are zero, layer should turn not visible + if (visibleSubLayersArray.length === 0) { + layerVisibility = false; + } + + // If remaining visible subLayers exists, set layer visibility and set visibleSubLayers state + if (visibleSubLayersArray.length >= 1) { + layer.setVisible(layerVisibility); + layer.set("subLayers", visibleSubLayersArray); + } else { + // Otherwise, set OL layer subLayers property to empty array + layer.set("subLayers", []); + } + }; + + const setSubLayerVisible = (subLayer) => { + // Clone visibleSubLayers state + let visibleSubLayersArray = [...visibleSubLayers]; + // Push subLayer to visibleSubLayersArray and set component state + visibleSubLayersArray.push(subLayer); + setSubLayers(visibleSubLayersArray); + }; + + // Gets added subLayers and removes the one that user clicked on, then passes the array to setSubLayers + const setSubLayerHidden = (subLayer) => { + // Clone visibleSubLayers state + let visibleSubLayersArray = [...visibleSubLayers]; + // Get remaining visible subLayers + visibleSubLayersArray = visibleSubLayersArray.filter( + (visibleSubLayer) => visibleSubLayer !== subLayer + ); + setSubLayers(visibleSubLayersArray); + }; + + const setGroupVisible = useCallback( + (la) => { + let l, + subLayersToShow = null; + + // If the incoming parameter is an object that contains additional subLayersToShow, + // let's filter out the necessary objects from it + if (la.hasOwnProperty("layer") && la.hasOwnProperty("subLayersToShow")) { + subLayersToShow = la.subLayersToShow; + l = la.layer; + } else { + // In this case the incoming parameter is the actual OL Layer and there is + // no need to further filter. Just set subLayers to everything that's in this + // layer, and the incoming object itself as the working 'l' variable. + subLayersToShow = layer.subLayers; + l = la; + } + + // Now we can be sure that we have the working 'l' variable and can compare + // it to the 'layer' object in current props. Note that this is necessary, as + // every single LayerGroupItem is subscribing to the event that calls this method, + // so without this check we'd end up running this for every LayerGroupItem, which + // is not intended. + if (l === layer) { + // Show the OL layer + layer.setVisible(true); + // Update OL layer subLayers property + layer.set("subLayers", subLayersToShow); + // Update visibleSubLayers state + setVisibleSubLayers(subLayersToShow); + } + }, + [layer] + ); + + // Register subscriptions for groupLayer. + useEffect(() => { + app.globalObserver.subscribe("core.layerSubLayersChanged", (l) => { + if (l.target.get("name") === layer.get("name")) { + setVisibleSubLayers(l.target.get("subLayers")); + } + }); + + app.globalObserver.subscribe("layerswitcher.hideLayer", setGroupHidden); + const layerswitcherShowLayerSubscription = app.globalObserver.subscribe( + "layerswitcher.showLayer", + setGroupVisible + ); + const hideLayerSubscription = localObserver.subscribe( + "hideLayer", + setGroupHidden + ); + const showLayerSubscription = localObserver.subscribe( + "showLayer", + setGroupVisible + ); + + // Unsubscribe when component unmounts + return () => { + layerswitcherShowLayerSubscription.unsubscribe(); + hideLayerSubscription.unsubscribe(); + showLayerSubscription.unsubscribe(); + }; + }, [ + app.globalObserver, + localObserver, + setGroupHidden, + setGroupVisible, + layer, + ]); + + // When visibleSubLayers state changes, update layer params + useEffect(() => { + const visibleSubLayersArray = [...visibleSubLayers]; + if (visibleSubLayersArray.length === 0) { + // Fix underlying source + layer.getSource().updateParams({ + // Ensure that the list of sublayers is emptied (otherwise they'd be + // "remembered" the next time user toggles group) + LAYERS: "", + // Remove any filters + CQL_FILTER: null, + }); + + // Hide the layer in OL + layer.setVisible(false); + // layer.set("subLayers", []); + } else { + // Set LAYERS and STYLES so that the exact sublayers that are needed + // will be visible + layer.getSource().updateParams({ + // join(), so we always provide a string as value to LAYERS + LAYERS: visibleSubLayersArray.join(), + // Filter STYLES to only contain styles for currently visible layers, + // and maintain the order from layersInfo (it's crucial that the order + // of STYLES corresponds exactly to the order of LAYERS!) + STYLES: Object.entries(layer.layersInfo) + .filter((k) => visibleSubLayersArray.indexOf(k[0]) !== -1) + .map((l) => l[1].style) + .join(","), + CQL_FILTER: null, + }); + } + }, [visibleSubLayers, layer]); + + // Handles list item click + const handleLayerItemClick = () => { + if (layer.get("visible")) { + const removeLayerCaptions = layer.subLayers.map( + (subLayer) => layer.layersInfo[subLayer]?.caption || "" + ); + + // Remove layer caption from snackbar message. + removeFromSnackbar(removeLayerCaptions); + // Hide the layer. + setGroupHidden(layer); + } else { + // Show the layer. + setGroupVisible(layer); + } + }; + + // Toggles a subLayer + const toggleSubLayer = (subLayer, visible) => { + if (visible) { + setSubLayerHidden(subLayer); + } else { + setSubLayerVisible(subLayer); + } + setSubLayerClicked(!visible); + }; + + // Toggles sublayers section + const toggleShowSublayers = (e) => { + e.stopPropagation(); + setShowSublayers(!showSublayers); + }; + + // Determines visibility of subLayer + // If the groupLayer is not toggleable + // then the sublayer should only be visible if it's included in visibleSubLayers + const showSublayer = (subLayer) => { + if (toggleable) { + return isSubLayerFiltered(subLayer); + } else if (visibleSubLayers.includes(subLayer)) { + return true; + } + return false; + }; + + const isSubLayerFiltered = (subLayer) => { + const foundSubLayer = groupLayer.subLayers.find((sl) => sl.id === subLayer); + return foundSubLayer ? foundSubLayer.isFiltered : false; + }; + + return ( + layer.layersInfo[subLayer]?.caption || "" + )} + onSetZoomVisible={setZoomVisible} + subLayerClicked={subLayerClicked} + toggleSubLayer={toggleSubLayer} + expandableSection={ + layer.get("layerInfo").hideExpandArrow !== true && ( + + toggleShowSublayers(e)} + > + + + + ) + } + subLayersSection={ + + + {layer.subLayers.map((subLayer, index) => ( + s === subLayer)} + toggleSubLayer={toggleSubLayer} + zoomVisible={zoomVisible} + > + ))} + + + } + > + ); +} diff --git a/apps/client/src/plugins/LayerSwitcher/components/LayerGroup.js b/apps/client/src/plugins/LayerSwitcher/components/LayerGroup.js index f717bcd84..7c37462a9 100644 --- a/apps/client/src/plugins/LayerSwitcher/components/LayerGroup.js +++ b/apps/client/src/plugins/LayerSwitcher/components/LayerGroup.js @@ -1,68 +1,21 @@ import React from "react"; import propTypes from "prop-types"; -import LayerItem from "./LayerItem.js"; -import { styled } from "@mui/material/styles"; -import { Accordion, AccordionDetails, AccordionSummary } from "@mui/material"; -import { Box, Typography, Divider, IconButton, Link } from "@mui/material"; +import LayerItem from "./LayerItem"; +import GroupLayer from "./GroupLayer"; +import LayerGroupAccordion from "./LayerGroupAccordion.js"; +import { + Box, + Typography, + Divider, + IconButton, + ListItemText, + Link, +} from "@mui/material"; import CheckBoxIcon from "@mui/icons-material/CheckBox"; import CheckBoxOutlineBlankIcon from "@mui/icons-material/CheckBoxOutlineBlank"; -import InfoIcon from "@mui/icons-material/Info"; import RemoveCircleIcon from "@mui/icons-material/RemoveCircle"; -import KeyboardArrowRightIcon from "@mui/icons-material/KeyboardArrowRight"; -import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; -import HajkToolTip from "components/HajkToolTip.js"; - -const StyledAccordion = styled(Accordion)(() => ({ - borderRadius: 0, - boxShadow: "none", - backgroundImage: "none", -})); - -const StyledAccordionSummary = styled(AccordionSummary)(() => ({ - minHeight: 35, - padding: "0px", - overflow: "hidden", - "&.MuiAccordionSummary-root.Mui-expanded": { - minHeight: 35, - }, - "& .MuiAccordionSummary-content": { - transition: "inherit", - marginTop: 0, - marginBottom: 0, - "&.Mui-expanded": { - marginTop: 0, - marginBottom: 0, - }, - }, -})); - -const StyledAccordionDetails = styled(AccordionDetails)(() => ({ - width: "100%", - display: "block", - padding: "0", -})); - -const SummaryContainer = styled("div")(({ theme }) => ({ - display: "flex", - flexBasis: "100%", - borderBottom: `${theme.spacing(0.2)} solid ${theme.palette.divider}`, -})); - -const HeadingTypography = styled(Typography)(({ theme }) => ({ - fontSize: theme.typography.pxToRem(15), - flexBasis: "100%", -})); - -const ExpandButtonWrapper = styled("div")(() => ({ - float: "left", -})); - -const checkBoxIconStyle = { - cursor: "pointer", - float: "left", - marginRight: "5px", - padding: "0", -}; +import InfoIcon from "@mui/icons-material/Info"; +import HajkToolTip from "components/HajkToolTip"; class LayerGroup extends React.PureComponent { state = { @@ -72,6 +25,7 @@ class LayerGroup extends React.PureComponent { name: "", parent: "-1", toggled: false, + chapters: [], infogrouptitle: "", infogrouptext: "", @@ -89,24 +43,25 @@ class LayerGroup extends React.PureComponent { static propTypes = { app: propTypes.object.isRequired, - chapters: propTypes.array.isRequired, child: propTypes.bool.isRequired, expanded: propTypes.bool.isRequired, group: propTypes.object.isRequired, handleChange: propTypes.func, - model: propTypes.object.isRequired, + localObserver: propTypes.object.isRequired, + layerMap: propTypes.object.isRequired, }; constructor(props) { super(props); - this.model = this.props.model; this.bindVisibleChangeForLayersInGroup(); } componentDidMount() { this.setState({ ...this.props.group, + expanded: this.props.group.isExpanded, }); + this.allLayers = this.props.app.getMap().getAllLayers(); } componentWillUnmount() { @@ -158,9 +113,10 @@ class LayerGroup extends React.PureComponent { }); }; - handleChange = (panel) => (event, expanded) => { + handleChange = (panel) => (event, isExpanded) => { this.setState({ - expanded: expanded ? panel : false, + expanded: isExpanded ? panel : false, + isExpanded: isExpanded, }); }; @@ -172,14 +128,15 @@ class LayerGroup extends React.PureComponent { return this.state.groups.map((group, i) => { return ( ); @@ -286,32 +243,31 @@ class LayerGroup extends React.PureComponent { } toggleLayers(visibility, layers) { - this.props.app - .getMap() - .getAllLayers() + this.allLayers .filter((mapLayer) => { return layers.some((layer) => layer.id === mapLayer.get("name")); }) .forEach((mapLayer) => { if (mapLayer.get("layerType") === "group") { if (visibility === true) { - this.model.observer.publish("showLayer", mapLayer); + this.props.localObserver.publish("showLayer", mapLayer); } else { - this.model.observer.publish("hideLayer", mapLayer); + this.props.localObserver.publish("hideLayer", mapLayer); } + } else { + mapLayer.setVisible(visibility); } - mapLayer.setVisible(visibility); }); } getCheckbox = () => { if (this.isToggled()) { - return ; + return ; } if (this.isSemiToggled()) { - return ; + return ; } - return ; + return ; }; toggleInfo = () => { @@ -457,7 +413,7 @@ class LayerGroup extends React.PureComponent { if (this.props.group.toggled) { return ( - { e.preventDefault(); e.stopPropagation(); @@ -470,100 +426,82 @@ class LayerGroup extends React.PureComponent { } }} > -
{this.getCheckbox()}
- - {this.state.name} - - {this.renderGroupInfoToggler()} -
- ); - } else { - return ( - - - {this.state.name} - - {this.renderGroupInfoToggler()} - + + {this.getCheckbox()} + + ); } + return; + } + + renderChildren() { + // const { expanded } = this.state; + return ( +
+ {this.props.group.layers.map((layer, i) => { + const mapLayer = this.props.layerMap[layer.id]; + // If mapLayer doesn't exist, the layer shouldn't be displayed + if (!mapLayer) { + return null; + } + + // Check if it's a group or a regular layer + const isGroup = mapLayer.get("layerType") === "group"; + const Component = isGroup ? GroupLayer : LayerItem; + + // Render the component with the appropriate attributes + return ( + + ); + })} + {this.renderLayerGroups()} +
+ ); + } + + static getDerivedStateFromProps(nextProps, prevState) { + // Check if the isExpanded property has changed + if (nextProps.group.isExpanded !== prevState.isExpanded) { + return { + expanded: nextProps.group.isExpanded, + isExpanded: nextProps.group.isExpanded, // Keep a copy of isExpanded in state for comparison + }; + } + return null; } render() { const { expanded } = this.state; return ( - // If the layerItem is a child, it should be rendered a tad to the - // right. Apparently 21px. - - { - this.setState({ - expanded: !this.state.expanded, - }); - }} - > - - - - {expanded ? ( - this.toggleExpanded()} - /> - ) : ( - this.toggleExpanded()} - /> - )} - - {this.renderToggleAll()} - - {this.renderGroupInfoDetails()} - - - -
- {this.state.layers.map((layer, i) => { - const mapLayer = this.model.layerMap[layer.id]; - if (mapLayer) { - return ( - { - const informativeWindow = this.props.app.windows.find( - (window) => window.type === "informative" - ); - informativeWindow.props.custom.open(chapter); - }} - /> - ); - } else { - return null; - } - })} - {this.renderLayerGroups()} -
-
-
-
+ + } + children={this.renderChildren()} + > ); } } diff --git a/apps/client/src/plugins/LayerSwitcher/components/LayerGroupAccordion.js b/apps/client/src/plugins/LayerSwitcher/components/LayerGroupAccordion.js new file mode 100644 index 000000000..354751892 --- /dev/null +++ b/apps/client/src/plugins/LayerSwitcher/components/LayerGroupAccordion.js @@ -0,0 +1,62 @@ +import React from "react"; +import { Collapse, Box, IconButton, ListItemButton } from "@mui/material"; +import KeyboardArrowRightOutlinedIcon from "@mui/icons-material/KeyboardArrowRightOutlined"; + +export default function LayerGroupAccordion({ + expanded, + toggleable, + children, + layerGroupTitle, + toggleDetails, + display, +}) { + const [state, setState] = React.useState({ expanded: expanded }); + + React.useEffect(() => { + setState({ expanded: expanded }); + }, [expanded]); + + return ( +
+ setState({ expanded: !state.expanded })} + sx={{ + p: 0, + }} + dense + > + + + + + `${theme.spacing(0.2)} solid ${theme.palette.divider}`, + }} + > + {toggleable && toggleDetails} + {layerGroupTitle} + + + + {children} + +
+ ); +} diff --git a/apps/client/src/plugins/LayerSwitcher/components/LayerGroupItem.js b/apps/client/src/plugins/LayerSwitcher/components/LayerGroupItem.js deleted file mode 100644 index d4de2584e..000000000 --- a/apps/client/src/plugins/LayerSwitcher/components/LayerGroupItem.js +++ /dev/null @@ -1,1027 +0,0 @@ -import React, { Component } from "react"; -import PropTypes from "prop-types"; -import { Button, Typography, Grid, Box } from "@mui/material"; -import { styled } from "@mui/material/styles"; -import { withSnackbar } from "notistack"; -import IconWarning from "@mui/icons-material/Warning"; -import CallMadeIcon from "@mui/icons-material/CallMade"; -import InfoIcon from "@mui/icons-material/Info"; -import RemoveCircleIcon from "@mui/icons-material/RemoveCircle"; -import CheckBoxIcon from "@mui/icons-material/CheckBox"; -import CheckBoxOutlineBlankIcon from "@mui/icons-material/CheckBoxOutlineBlank"; -import MoreHorizIcon from "@mui/icons-material/MoreHoriz"; -import CloseIcon from "@mui/icons-material/Close"; -import LayerSettings from "./LayerSettings.js"; -import DownloadLink from "./DownloadLink"; -import KeyboardArrowRightIcon from "@mui/icons-material/KeyboardArrowRight"; -import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; -import HajkToolTip from "components/HajkToolTip"; - -const ExpandButtonWrapper = styled("div")(() => ({ - display: "flex", - float: "left", - cursor: "pointer", -})); - -const LayerInfo = styled("div")(({ theme }) => ({ - width: "100%", - borderBottom: `${theme.spacing(0.2)} solid ${theme.palette.divider}`, -})); - -const LayerSummaryContainer = styled((props) => ( - -))(({ theme }) => ({ - width: "100%", -})); - -const SummaryButtonsContainer = styled("div")(() => ({ - display: "flex", - alignItems: "center", -})); - -const SummaryButtonWrapper = styled("div")(() => ({ - display: "flex", - alignItems: "center", - width: 35, - height: 35, - cursor: "pointer", -})); - -const Caption = styled(Typography)(({ theme }) => ({ - cursor: "pointer", - fontSize: theme.typography.pxToRem(15), -})); - -const CheckBoxWrapper = styled("div")(() => ({ - display: "flex", - alignItems: "center", - cursor: "pointer", - float: "left", - marginRight: "5px", -})); - -const LegendImage = styled("img")(({ theme }) => ({ - marginLeft: theme.spacing(0.4), - maxWidth: "250px", -})); - -const LegendIcon = styled("img")(({ theme }) => ({ - width: theme.typography.pxToRem(18), - height: theme.typography.pxToRem(18), - marginRight: "5px", -})); - -const InfoTextContainer = styled("div")(() => ({ - margin: "10px 45px", -})); - -const StyledList = styled("ul")(() => ({ - padding: 0, - margin: 0, - listStyle: "none", -})); - -class LayerGroupItem extends Component { - static propTypes = { - options: PropTypes.object, - layer: PropTypes.object.isRequired, - cqlFilterVisible: PropTypes.bool, - model: PropTypes.object.isRequired, - observer: PropTypes.object, - chapters: PropTypes.array, - }; - - constructor(props) { - super(props); - const { layer } = props; - const layerInfo = props.layer.get("layerInfo"); - this.state = { - subLayers: props.layer.subLayers, - caption: layerInfo.caption, - visible: props.layer.get("visible"), - // If layer is to be shown, check if there are some specified sublayers (if yes, we'll - // enable only those). Else, let's default to showing all sublayers, or finally fallback - // to an empty array. - visibleSubLayers: props.layer.get("visible") - ? props.layer.visibleAtStartSubLayers?.length > 0 - ? props.layer.visibleAtStartSubLayers - : props.layer.subLayers - : [], - expanded: false, - name: props.layer.get("name"), - legend: layerInfo.legend, - status: "ok", - infoVisible: false, - infoTitle: layerInfo.infoTitle, - infoText: layerInfo.infoText, - infoUrl: layerInfo.infoUrl, - infoUrlText: layerInfo.infoUrlText, - infoOpenDataLink: layerInfo.infoOpenDataLink, - infoOwner: layerInfo.infoOwner, - infoExpanded: false, - instruction: layerInfo.instruction, - open: false, - opacityValue: 1, - toggleSettings: false, - toggleSubLayerSettings: {}, - }; - this.toggleSubLayerSettings = this.toggleSubLayerSettings.bind(this); - this.renderSubLayer = this.renderSubLayer.bind(this); - - this.hideExpandArrow = layerInfo?.hideExpandArrow === true ? true : false; - // Check if the layer uses min and max zoom levels. - this.usesMinMaxZoom = this.layerUsesMinMaxZoom(); - // Get the minMaxZoomAlertOnToggleOnly property from the layer. - this.minMaxZoomAlertOnToggleOnly = layer.get("minMaxZoomAlertOnToggleOnly"); - } - /** - * Triggered when the component is successfully mounted into the DOM. - * @instance - */ - componentDidMount() { - const { model } = this.props; - model.globalObserver.subscribe("layerswitcher.hideLayer", this.setHidden); - model.globalObserver.subscribe("layerswitcher.showLayer", this.setVisible); - model.observer.subscribe("hideLayer", this.setHidden); - model.observer.subscribe("showLayer", this.setVisible); - model.observer.subscribe("toggleGroup", this.toggleGroupVisible); - - // Listen for changes in the layer's visibility. - this.props.layer.on?.("change:visible", (e) => { - // Update the 'visible' state based on the layer's new visibility. - const visible = !e.oldValue; - this.setState({ - visible, - }); - - // Listen to zoom changes if the layer is visible. - this.listenToZoomChange(visible); - }); - - // Initially listen to zoom changes if the layer is visible. - this.listenToZoomChange(this.state.visible); - - // Set load status by subscribing to a global event. Expect ID (int) of layer - // and status (string "ok"|"loaderror"). Also, once status was set to "loaderror", - // don't change it back to "ok": we'll get a response for each tile, so most of - // the tiles might be "ok", but if only one of the tiles has "loaderror", we - // consider that the layer has failed loading and want to inform the user. - model.globalObserver.subscribe("layerswitcher.wmsLayerLoadStatus", (d) => { - this.state.status !== "loaderror" && - this.state.name === d.id && - this.setState({ - status: d.status, - }); - }); - } - - /** - * Determines if the layer has minimum and maximum zoom level restrictions. - * - * This function checks the layer properties to see if the layer has minimum and/or maximum - * zoom levels defined. If either minZoom or maxZoom is within the valid range (0 to Infinity), - * the function returns true, indicating that the layer has zoom restrictions. - * - * Example: If the layer has minZoom = 5 and maxZoom = 10, it will only be visible - * when the map's zoom level is between 5 and 10. - * - * @returns {boolean} - True if the layer has zoom restrictions, false otherwise. - */ - layerUsesMinMaxZoom() { - // Retrieve the layer properties from the layer object. - const lprops = this.props.layer.getProperties(); - - // Get the maxZoom and minZoom properties if they exist, otherwise set them to 0. - const maxZ = lprops.maxZoom ?? 0; - const minZ = lprops.minZoom ?? 0; - - // Check if either minZoom or maxZoom is within a valid range (0 < value < Infinity). - // Return true if any of them are within the valid range, otherwise return false. - return (maxZ > 0 && maxZ < Infinity) || (minZ > 0 && minZ < Infinity); - } - - /** - * Handles the zoom end event to check if the layer should be visible at the current zoom level. - * - * This function is triggered when the map's zoom level changes. It checks if the layer's - * visibility is within the specified minZoom and maxZoom range. If the layer is not visible - * at the current zoom level and the conditions to show a Snackbar message are met, it calls - * the showZoomSnack() function to display a message. The function also updates the - * state.zoomVisible property accordingly. - * - * @param {Object} e - The event object (optional). - * @returns {boolean} - True if the layer is visible at the current zoom level, false otherwise. - */ - zoomEndHandler = (e) => { - // Get the current map zoom level. - const zoom = this.props.model.olMap.getView().getZoom(); - // Retrieve the layer properties. - const lprops = this.props.layer.getProperties(); - // Check if the current zoom level is within the allowed range of minZoom and maxZoom. - const layerIsZoomVisible = zoom > lprops.minZoom && zoom <= lprops.maxZoom; - - let showSnack = false; - - // Determine if the Snackbar message should be shown based on the layer visibility and zoom level conditions. - if (this.minMaxZoomAlertOnToggleOnly === true) { - if (!this.state.visible && !layerIsZoomVisible && e?.type === "click") { - showSnack = true; - } - } else { - if ( - !layerIsZoomVisible && - (this.state.zoomVisible || !this.state.visible) - ) { - showSnack = true; - } - } - - // If the Snackbar message should be shown, call the showZoomSnack function. - if (showSnack === true) { - this.showZoomSnack(); - } - - // Update the state with the new value for zoomVisible. - this.setState({ - zoomVisible: layerIsZoomVisible, - }); - return layerIsZoomVisible; - }; - - /** - * Subscribes or unsubscribes to the zoom end event based on the provided parameter. - * - * This function either subscribes or unsubscribes the zoomEndHandler() function to the - * 'core.zoomEnd' event, depending on the value of the bListen parameter. If the layer - * doesn't have any zoom restrictions, the function returns without doing anything. - * - * Example: If the layer is visible, subscribe to the map's "moveend" event to listen for - * zoom changes; if it's not visible, unsubscribe from the event. - * - * @param {boolean} bListen - If true, subscribes to the zoom end event; if false, unsubscribes. - */ - listenToZoomChange(bListen) { - const { model } = this.props; - - // If the layer doesn't use minZoom and maxZoom properties, return without doing anything. - if (!this.usesMinMaxZoom) return; - - // Define the event name for zoom change events. - const eventName = "core.zoomEnd"; - - // Subscribe or unsubscribe to the zoom change event based on the 'bListen' parameter. - if (bListen && !this.zoomEndListener) { - this.zoomEndListener = model.globalObserver.subscribe( - eventName, - this.zoomEndHandler - ); - } else { - if (this.zoomEndListener) { - model.globalObserver.unsubscribe(eventName, this.zoomEndListener); - this.zoomEndListener = null; - } - } - } - - /** - * Render the load information component. - * @instance - * @return {external:ReactElement} - */ - renderStatus() { - return ( - this.state.status === "loaderror" && ( - - - - - - ) - ); - } - - openInformative = (chapter) => (e) => { - this.props.onOpenChapter(chapter); - }; - - findChapters(id, chapters) { - var result = []; - if (Array.isArray(chapters)) { - result = chapters.reduce((chaptersWithLayer, chapter) => { - if (Array.isArray(chapter.layers)) { - if (chapter.layers.some((layerId) => layerId === id)) { - chaptersWithLayer = [...chaptersWithLayer, chapter]; - } - if (chapter.chapters.length > 0) { - chaptersWithLayer = [ - ...chaptersWithLayer, - ...this.findChapters(id, chapter.chapters), - ]; - } - } - return chaptersWithLayer; - }, []); - } - return result; - } - - renderChapterLinks(chapters) { - if (chapters && chapters.length > 0) { - const chaptersWithLayer = this.findChapters( - this.props.layer.get("name"), - chapters - ); - if (chaptersWithLayer.length > 0) { - return ( - - - Innehåll från denna kategori finns benämnt i följande kapitel i - översiktsplanen: - - - {chaptersWithLayer.map((chapter, i) => { - return ( -
  • - -
  • - ); - })} -
    -
    - ); - } else { - return null; - } - } else { - return null; - } - } - - toggle() { - this.setState({ - open: !this.state.open, - }); - } - - toggleInfo() { - this.setState({ - infoVisible: !this.state.infoVisible, - }); - } - - isInfoEmpty() { - const chaptersWithLayer = this.findChapters( - this.props.layer.get("name"), - this.props.chapters - ); - const { infoCaption, infoUrl, infoOwner, infoText } = this.state; - return !( - infoCaption || - infoUrl || - infoOwner || - infoText || - chaptersWithLayer.length > 0 - ); - } - - setHidden = (l) => { - const { layer } = this.props; - - if (l.get("name") === layer.get("name")) { - // Fix underlying source - this.props.layer.getSource().updateParams({ - // Ensure that the list of sublayers is emptied (otherwise they'd be - // "remembered" the next time user toggles group) - LAYERS: "", - // Remove any filters - CQL_FILTER: null, - }); - - // Hide the layer in OL - layer.setVisible(false); - - // Close any existing zoom warning Snackbars. - this.props.closeSnackbar(this.zoomWarningSnack); - - // Update UI state - this.setState({ - visible: false, - visibleSubLayers: [], - }); - } - }; - - // FIXME: The second parameter, `subLayer` is never used. - setVisible = (la, subLayer) => { - let l, - subLayersToShow = null; - - // If the incoming parameter is an object that contains additional subLayersToShow, - // let's filter out the necessary objects from it - if (la.hasOwnProperty("layer") && la.hasOwnProperty("subLayersToShow")) { - subLayersToShow = la.subLayersToShow; - l = la.layer; - } else { - // In this case the incoming parameter is the actual OL Layer and there is - // no need to further filter. Just set subLayers to everything that's in this - // layer, and the incoming object itself as the working 'l' variable. - subLayersToShow = this.props.layer.subLayers; - l = la; - } - - // Now we can be sure that we have the working 'l' variable and can compare - // it to the 'layer' object in current props. Note that this is necessary, as - // every single LayerGroupItem is subscribing to the event that calls this method, - // so without this check we'd end up running this for every LayerGroupItem, which - // is not intended. - if (l === this.props.layer) { - // Show the OL layer - this.props.layer.setVisible(true); - - // Set LAYERS and STYLES so that the exact sublayers that are needed - // will be visible - this.props.layer.getSource().updateParams({ - // join(), so we always provide a string as value to LAYERS - LAYERS: subLayersToShow.join(), - CQL_FILTER: null, - // Extract .style property from each sub layer. - // Join them into a string that will be used to - // reset STYLES param for the GET request. - STYLES: Object.entries(this.props.layer.layersInfo) - .filter((k) => subLayersToShow.indexOf(k[0]) !== -1) - .map((l) => l[1].style) - .join(","), - }); - - const { layer } = this.props; - - let visibleLayers = [...subLayersToShow]; - if (layer.get("layers") && layer.get("layers").length > 0) { - visibleLayers.push(layer.get("layers")[0]); - } - - this.setState({ - visible: true, - visibleSubLayers: subLayersToShow, - }); - } - }; - - /** - * Toggles the visibility of a group layer and handles Snackbar messages for sublayers. - * - * This function toggles the visibility of the provided group layer. If the layer becomes visible, - * it calls setVisible() and checks the current zoom level to see if the layer should be visible. - * If the layer is not visible at the current zoom level and the conditions to show a Snackbar - * message are met, it calls the showZoomSnack() function to display a message. The function - * also updates the state.zoomVisible and state.visibleSubLayers properties accordingly. - * If the layer becomes hidden, it calls setHidden(). - * - * Example: If a layer group has 3 sublayers, clicking the checkbox will show - * or hide all 3 sublayers at once. - * - * @param {Object} layer - The group layer to toggle visibility for. - * @returns {function} - A function to handle the onClick event for the group layer. - */ - toggleGroupVisible = (layer) => (e) => { - const visible = !this.state.visible; - - // If the layer is becoming visible. - if (visible) { - // Set the layer as visible. - this.setVisible(layer); - - // Get all sublayers of the layer group. - const subLayers = Object.keys(layer.getProperties().layerInfo.layersInfo); - - // Get the current zoom level. - const zoom = this.props.model.olMap.getView().getZoom(); - const lprops = this.props.layer.getProperties(); - const layerIsZoomVisible = - zoom > lprops.minZoom && zoom <= lprops.maxZoom; - - let showSnack = false; - - // If the layer is not visible at the current zoom level, show a Snackbar. - // Example: If minMaxZoomAlertOnToggleOnly is set to true, a Snackbar will only be shown - // when the layer is toggled on and is not visible at the current zoom level. - if (this.minMaxZoomAlertOnToggleOnly === true) { - if (!this.state.visible && !layerIsZoomVisible && e?.type === "click") { - showSnack = true; - } - } else { - // If the layer is not visible at the current zoom level and either - // state.zoomVisible is true or state.visible is false, a Snackbar will be shown. - if ( - !layerIsZoomVisible && - (this.state.zoomVisible || !this.state.visible) - ) { - showSnack = true; - } - } - - // If a Snackbar should be shown, call the showZoomSnack function with the subLayers array and set isGroupLayer to true. - // Example: If a group layer has 3 sublayers and none are visible at the current zoom level, - // the Snackbar will display a message for each sublayer. - if (showSnack === true) { - this.showZoomSnack(subLayers, true); - } - - // Update the state with the new values for zoomVisible and visibleSubLayers. - this.setState({ - zoomVisible: layerIsZoomVisible, - visibleSubLayers: subLayers, - }); - } else { - // If the layer is becoming hidden, call setHidden() to set the layer as hidden. - this.setHidden(layer); - } - }; - - toggleLayerVisible = (subLayer) => (e) => { - var visibleSubLayers = [...this.state.visibleSubLayers], - isVisible = visibleSubLayers.some( - (visibleSubLayer) => visibleSubLayer === subLayer - ), - layerVisibility; - - const { visible } = this.state; - layerVisibility = visible; - - let isNewSubLayer = false; - - if (isVisible) { - visibleSubLayers = visibleSubLayers.filter( - (visibleSubLayer) => visibleSubLayer !== subLayer - ); - } else { - visibleSubLayers.push(subLayer); - // Restore order to its former glory. Sort using original sublayer array. - visibleSubLayers.sort((a, b) => { - return ( - this.state.subLayers.indexOf(a) - this.state.subLayers.indexOf(b) - ); - }); - isNewSubLayer = true; - } - - if (!visible && visibleSubLayers.length > 0) { - layerVisibility = true; - // FIXME: `subLayer` below doesn't do anything since - // setVisible only makes use of its first parameter - // (see its implementation). - this.setVisible(this.props.layer, subLayer); - } - - if (visibleSubLayers.length === 0) { - layerVisibility = false; - this.setHidden(this.props.layer); - } - - if (visibleSubLayers.length >= 1) { - // Create an Array to be used as STYLES param, it should only contain selected sublayers' styles - let visibleSubLayersStyles = []; - visibleSubLayers.forEach((subLayer) => { - visibleSubLayersStyles.push( - this.props.layer.layersInfo[subLayer].style - ); - }); - - this.props.layer.getSource().updateParams({ - // join(), so we always provide a string as value to LAYERS - LAYERS: visibleSubLayers.join(), - // Filter STYLES to only contain styles for currently visible layers, - // and maintain the order from layersInfo (it's crucial that the order - // of STYLES corresponds exactly to the order of LAYERS!) - STYLES: Object.entries(this.props.layer.layersInfo) - .filter((k) => visibleSubLayers.indexOf(k[0]) !== -1) - .map((l) => l[1].style) - .join(","), - CQL_FILTER: null, - }); - this.props.layer.setVisible(layerVisibility); - this.setState({ - visible: layerVisibility, - visibleSubLayers: visibleSubLayers, - }); - - // Display a Snackbar message if the layer is not visible at the current zoom level. - const zoom = this.props.model.olMap.getView().getZoom(); - const lprops = this.props.layer.getProperties(); - const layerIsZoomVisible = - zoom > lprops.minZoom && zoom <= lprops.maxZoom; - - let showSnack = false; - - if (this.minMaxZoomAlertOnToggleOnly === true) { - if (!this.state.visible && !layerIsZoomVisible && e?.type === "click") { - showSnack = true; - } - } else { - if ( - !layerIsZoomVisible && - (this.state.zoomVisible || !this.state.visible) - ) { - showSnack = true; - } - } - - if (isNewSubLayer && !layerIsZoomVisible) { - showSnack = true; - } - - if (showSnack === true) { - this.showZoomSnack(subLayer, false); - } - - this.setState({ - zoomVisible: layerIsZoomVisible, - }); - } else { - this.setHidden(this.props.layer); - } - }; - - renderLegendIcon(url) { - return ; - } - - renderSubLayer(layer, subLayer, index) { - const { visibleSubLayers } = this.state; - const visible = visibleSubLayers.some( - (visibleSubLayer) => visibleSubLayer === subLayer - ); - const toggleSettings = this.toggleSubLayerSettings.bind(this, index); - const legendIcon = layer.layersInfo[subLayer].legendIcon; - - return ( - - - - - {!visible ? ( - - ) : ( - - !this.state.zoomVisible && this.state.visible - ? theme.palette.warning.dark - : "", - }} - /> - )} - - {legendIcon && this.renderLegendIcon(legendIcon)} - - {layer.layersInfo[subLayer].caption} - - - - - - - - {this.state.toggleSubLayerSettings[index] ? ( - toggleSettings()} /> - ) : ( - toggleSettings()} /> - )} - - - - {this.state.toggleSubLayerSettings[index] ? ( - - - - ) : null} - - ); - } - - renderSubLayers() { - const { open } = this.state; - const { layer } = this.props; - - if (open) { - return ( - - {layer.subLayers.map((subLayer, index) => - this.renderSubLayer(layer, subLayer, index) - )} - - ); - } else { - return null; - } - } - - renderInfo() { - const { infoTitle, infoText } = this.state; - if (infoText) { - return ( - - {infoTitle} - - - ); - } else { - return null; - } - } - - renderMetadataLink() { - const { infoUrl, infoUrlText } = this.state; - if (infoUrl) { - return ( - - - - {infoUrlText || infoUrl} - - - - ); - } else { - return null; - } - } - - renderOpenDataLink() { - const { infoOpenDataLink } = this.state; - if (infoOpenDataLink) { - return ( - - - - {this.infoOpenDataLink} - - - - ); - } else { - return null; - } - } - - renderOwner() { - const { infoOwner } = this.state; - if (infoOwner) { - return ( - - - - ); - } else { - return null; - } - } - - renderDetails() { - if (this.state.infoVisible) { - return ( -
    - {this.renderInfo()} - {this.renderMetadataLink()} - {this.renderOpenDataLink()} - {this.renderOwner()} -
    {this.renderChapterLinks(this.props.chapters || [])}
    -
    - ); - } else { - return null; - } - } - - toggleSettings() { - this.setState({ - toggleSettings: !this.state.toggleSettings, - }); - } - - toggleSubLayerSettings(index) { - var selected = this.state.toggleSubLayerSettings; - selected[index] = !selected[index]; - this.setState({ toggleSubLayerSettings: selected }); - } - - renderInfoToggler = () => { - return ( - !this.isInfoEmpty() && ( - - {this.state.infoVisible ? ( - this.toggleInfo()} /> - ) : ( - this.toggleInfo()} - style={{ - boxShadow: this.state.infoVisible - ? "rgb(204, 204, 204) 2px 3px 1px" - : "inherit", - borderRadius: "100%", - }} - /> - )} - - ) - ); - }; - - /** - * Displays a Snackbar message to inform the user about the layer's visibility at the current zoom level. - * - * This function shows a Snackbar message for each visible sublayer that is not visible at the - * current zoom level, informing the user that the layer is not visible. If the layer is a - * group layer, it handles the Snackbar messages for all sublayers within the group. - * - * @param {Array|string} sublayer - The sublayer or an array of sublayers to show the Snackbar message for. - * @param {boolean} isGroupLayer - True if the layer is a group layer, false otherwise. - */ - showZoomSnack(sublayer, isGroupLayer) { - // If a zoom warning snackbar is already displayed, return without doing anything. - // This method ensures that only one Snackbar notification is displayed at a time, preventing multiple notifications from overlapping. - if (this.zoomWarningSnack) return; - - const { layer } = this.props; - const layerProperties = layer.getProperties(); - const layerInfo = layerProperties.layerInfo || {}; - const layersInfo = layerInfo.layersInfo || {}; - - let visibleLayers = [...this.state.visibleSubLayers]; - if (layer.get("layers") && layer.get("layers").length > 0) { - visibleLayers.push(layer.get("layers")[0]); - } - - /** - * Adds captions to sublayers for display in a Snackbar message. - * - * This function receives a sublayer and retrieves the corresponding layer caption from the - * layerInfo object. It then creates a message string and enqueues a Snackbar with the message. - * The Snackbar will be displayed with a "warning" variant and will be removed when closed. - * - * @param {Object} subLayer - The sublayer for which to add captions. - */ - const addSubLayerCaptions = (subLayer) => { - if (subLayer) { - const layerCaption = layersInfo[subLayer]?.caption; - if (layerCaption) { - const message = `Lagret "${layerCaption}" är inte synligt vid aktuell zoomnivå.`; - - this.zoomWarningSnack = this.props.enqueueSnackbar(message, { - variant: "warning", - preventDuplicate: false, - onClose: () => { - this.zoomWarningSnack = null; - }, - }); - } - } - }; - - // Check if isGroupLayer is true and sublayer is an array. - if (isGroupLayer && Array.isArray(sublayer)) { - // Iterate through the sublayer array and call addSubLayerCaptions for each subLayer. - sublayer.forEach((subLayer) => { - addSubLayerCaptions(subLayer); - }); - } else if (sublayer) { - // Check if sublayer is defined (not undefined or null). - addSubLayerCaptions(sublayer); - } else { - // If sublayer is undefined, iterate through the visibleLayers array and call addSubLayerCaptions for each subLayer. - visibleLayers.forEach((subLayer) => { - addSubLayerCaptions(subLayer); - }); - } - } - - /** - * Returns a checkbox element for the layer group. - * - * This function creates and returns a checkbox element for the layer group. The checkbox - * will toggle the visibility of the layer group when clicked. - * - * @returns {ReactElement} - A checkbox element for the layer group. - */ - getCheckBox() { - const { layer } = this.props; - const { visible, visibleSubLayers } = this.state; - return ( - - {!visible ? ( - - ) : visibleSubLayers.length !== layer.subLayers.length ? ( - - ) : ( - - )} - - ); - } - - render() { - const { cqlFilterVisible, layer } = this.props; - const { open, toggleSettings, infoVisible, visible } = this.state; - - const legendIcon = layer.get("layerInfo").legendIcon; - return ( - - - {this.hideExpandArrow === false && ( - - {open ? ( - this.toggle()} /> - ) : ( - this.toggle()} /> - )} - - )} - - - - {this.getCheckBox()} - {legendIcon && this.renderLegendIcon(legendIcon)} - - {layer.get("caption")} - - - - {this.renderStatus()} - {this.renderInfoToggler()} - - {toggleSettings ? ( - this.toggleSettings()} /> - ) : ( - this.toggleSettings()} /> - )} - - - - - - {this.renderDetails()} - {toggleSettings && infoVisible && !this.isInfoEmpty() ?
    : null} -
    - -
    - {this.renderSubLayers()} -
    - ); - } -} - -export default withSnackbar(LayerGroupItem); diff --git a/apps/client/src/plugins/LayerSwitcher/components/LayerItem.js b/apps/client/src/plugins/LayerSwitcher/components/LayerItem.js index 5d87b1acc..80a9b8df0 100644 --- a/apps/client/src/plugins/LayerSwitcher/components/LayerItem.js +++ b/apps/client/src/plugins/LayerSwitcher/components/LayerItem.js @@ -1,348 +1,380 @@ -import React from "react"; -import { withSnackbar } from "notistack"; -import { styled } from "@mui/material/styles"; -import { Button, Typography, Grid, Link } from "@mui/material"; -import IconWarning from "@mui/icons-material/Warning"; -import CallMadeIcon from "@mui/icons-material/CallMade"; -import InfoIcon from "@mui/icons-material/Info"; -import RemoveCircleIcon from "@mui/icons-material/RemoveCircle"; -import CheckBoxIcon from "@mui/icons-material/CheckBox"; -import RadioButtonChecked from "@mui/icons-material/RadioButtonChecked"; -import RadioButtonUnchecked from "@mui/icons-material/RadioButtonUnchecked"; -import CheckBoxOutlineBlankIcon from "@mui/icons-material/CheckBoxOutlineBlank"; -import MoreHorizIcon from "@mui/icons-material/MoreHoriz"; -import CloseIcon from "@mui/icons-material/Close"; -import TableViewIcon from "@mui/icons-material/TableView"; -import LayerGroupItem from "./LayerGroupItem.js"; -import LayerSettings from "./LayerSettings.js"; -import DownloadLink from "./DownloadLink.js"; +import React, { useEffect, useState, useRef, useCallback } from "react"; + +// Material UI components +import { + Box, + IconButton, + ListItemButton, + ListItemSecondaryAction, + ListItemText, + Tooltip, + useTheme, +} from "@mui/material"; import HajkToolTip from "components/HajkToolTip"; -const LayerItemContainer = styled("div")(({ theme }) => ({ - paddingLeft: "0", - borderBottom: `${theme.spacing(0.2)} solid ${theme.palette.divider}`, -})); - -const LayerItemWrapper = styled("div")(({ theme }) => ({ - display: "flex", - justifyContent: "space-between", - marginTop: "0", -})); - -const LayerTogglerButtonWrapper = styled("div")(() => ({ - display: "flex", - alignItems: "center", - cursor: "pointer", - float: "left", - marginRight: "5px", -})); - -const InfoTextContainer = styled("div")(({ theme }) => ({ - margin: "10px 45px", -})); - -const Caption = styled(Typography)(({ theme }) => ({ - cursor: "pointer", - fontSize: theme.typography.pxToRem(15), -})); - -const LegendIcon = styled("img")(({ theme }) => ({ - width: theme.typography.pxToRem(18), - height: theme.typography.pxToRem(18), - marginRight: "5px", -})); - -const LayerButtonsContainer = styled("div")(() => ({ - display: "flex", - alignItems: "center", -})); - -const LayerButtonWrapper = styled("div")(() => ({ - display: "flex", - alignItems: "center", - width: 35, - height: 35, - cursor: "pointer", -})); - -const StyledList = styled("ul")(() => ({ - padding: 0, - margin: 0, - listStyle: "none", -})); - -class LayerItem extends React.PureComponent { - constructor(props) { - super(props); - const { layer } = props; - const layerInfo = layer.get("layerInfo"); - - this.isBackgroundLayer = layerInfo.layerType === "base"; - this.rotateMap = layer.get("rotateMap"); - this.caption = layerInfo.caption; - this.name = layer.get("name"); - this.legend = layerInfo.legend; - this.legendIcon = layerInfo.legendIcon; - this.infoTitle = layerInfo.infoTitle; - this.infoText = layerInfo.infoText; - this.infoUrl = layerInfo.infoUrl; - this.infoUrlText = layerInfo.infoUrlText; - this.infoOpenDataLink = layerInfo.infoOpenDataLink; - this.infoOwner = layerInfo.infoOwner; - this.localObserver = layer.localObserver; - this.showAttributeTableButton = layerInfo.showAttributeTableButton || false; - this.usesMinMaxZoom = this.layerUsesMinMaxZoom(); - this.minMaxZoomAlertOnToggleOnly = layer.get("minMaxZoomAlertOnToggleOnly"); - - this.state = { - visible: layer.get("visible"), - status: "ok", - zoomVisible: true, - open: false, - toggleSettings: false, - infoVisible: false, - }; - - // Subscribe to events sent when another background layer is clicked and - // disable all other layers to implement the RadioButton behaviour - if (this.isBackgroundLayer) { - layer.localObserver.subscribe("backgroundLayerChanged", (activeLayer) => { - if (activeLayer !== this.name) { - if (!layer.isFakeMapLayer) { - layer.setVisible(false); - } - this.setState({ - visible: false, - }); +import DragIndicatorOutlinedIcon from "@mui/icons-material/DragIndicatorOutlined"; +import PublicOutlinedIcon from "@mui/icons-material/PublicOutlined"; +import WallpaperIcon from "@mui/icons-material/Wallpaper"; +import BuildOutlinedIcon from "@mui/icons-material/BuildOutlined"; +import CheckBoxOutlineBlankIcon from "@mui/icons-material/CheckBoxOutlineBlank"; +import CheckBoxIcon from "@mui/icons-material/CheckBox"; +import WarningAmberOutlinedIcon from "@mui/icons-material/WarningAmberOutlined"; +import LockOutlinedIcon from "@mui/icons-material/LockOutlined"; +import KeyboardArrowRightOutlinedIcon from "@mui/icons-material/KeyboardArrowRightOutlined"; +import FormatListBulletedOutlinedIcon from "@mui/icons-material/FormatListBulletedOutlined"; + +// Custom components +import LegendIcon from "./LegendIcon"; +import LegendImage from "./LegendImage"; + +// Custom hooks +import useSnackbar from "../../../hooks/useSnackbar"; + +export default function LayerItem({ + layer, + toggleIcon, + clickCallback, + isBackgroundLayer, + draggable, + toggleable, + app, + display, + subLayersSection, + visibleSubLayers, + expandableSection, + visibleSubLayersCaption, + onSetZoomVisible, + subLayerClicked, + showSublayers, +}) { + // WmsLayer load status, shows warning icon if !ok + const [wmsLayerLoadStatus, setWmsLayerLoadStatus] = useState("ok"); + // State for layer zoom visibility + const [zoomVisible, setZoomVisible] = useState(true); + // State that toggles legend collapse + const [legendIsActive, setLegendIsActive] = useState(false); + const [visibleMinMaxZoomLayers, setVisibleMinMaxZoomLayers] = useState([]); + const prevVisibleMinMaxZoomLayersRef = useRef([]); + const prevLayerIsZoomVisible = useRef(null); + const [isGroupHidden, setIsGroupHidden] = useState(false); + const [showSnackbarOnClick, setShowSnackbarOnClick] = useState(false); + + const { addToSnackbar, removeFromSnackbar } = useSnackbar(); + const theme = useTheme(); + + const layerSwitcherConfig = app.config.mapConfig.tools.find( + (tool) => tool.type === "layerswitcher" + ); + + const minMaxZoomAlertOnToggleOnly = + layerSwitcherConfig?.options?.minMaxZoomAlertOnToggleOnly ?? false; + + const layerUsesMinMaxZoom = useCallback(() => { + const lprops = layer.getProperties(); + const maxZ = lprops.maxZoom ?? 0; + const minZ = lprops.minZoom ?? 0; + return (maxZ > 0 && maxZ < Infinity) || (minZ > 0 && minZ < Infinity); + }, [layer]); + + const addValue = useCallback( + (value) => { + if (minMaxZoomAlertOnToggleOnly) { + if (showSnackbarOnClick) { + // Add layer caption and show snackbar message on click. + addToSnackbar(layer.get("name"), value); + } else { + // Add layer caption to snackbar message, but don't show it. + addToSnackbar(layer.get("name"), value, true); } - }); - } - } + setShowSnackbarOnClick(false); + } else { + addToSnackbar(layer.get("name"), value); + } + }, + [minMaxZoomAlertOnToggleOnly, showSnackbarOnClick, addToSnackbar, layer] + ); + + const removeValue = useCallback( + (value) => { + removeFromSnackbar(layer.get("name"), value); + }, + [removeFromSnackbar, layer] + ); /** - * Triggered when the component is successfully mounted into the DOM. - * @instance + * Handles the zoom end event and determines if the layer should be visible at the current zoom level. + * @param {boolean} wasClicked - True if the zoom button was clicked, false otherwise. + * @returns {boolean} - True if the layer is visible at the current zoom level, false otherwise. */ - componentDidMount() { - // Setup some listeners triggered whenever the layer visibility changes: - this.props.layer.on?.("change:visible", (e) => { - // Grab the new value… - const visible = !e.oldValue; - - // …and update the radio button state. - this.setState({ - visible, - }); + const zoomEndHandler = useCallback( + (click) => { + const zoom = app.map.getView().getZoom(); + const lprops = layer.getProperties(); + const layerIsZoomVisible = + zoom > lprops.minZoom && zoom <= lprops.maxZoom; + + const prevVisibleMinMaxZoomLayers = + prevVisibleMinMaxZoomLayersRef.current; + const isGroupLayer = Array.isArray(visibleSubLayersCaption); + + const arraysAreEqual = (a, b) => { + if (a.length !== b.length) { + return false; + } + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) { + return false; + } + } + return true; + }; - this.listenToZoomChange(visible); + if ( + layerIsZoomVisible !== prevLayerIsZoomVisible.current || + isGroupLayer + ) { + if (!layerIsZoomVisible && (zoomVisible || !layer.get("visible"))) { + setVisibleMinMaxZoomLayers( + isGroupLayer ? visibleSubLayersCaption : [layer.get("caption")] + ); + } else if ( + !layerIsZoomVisible && + layer.get("visible") && + isGroupLayer + ) { + setVisibleMinMaxZoomLayers( + isGroupLayer ? visibleSubLayersCaption : [layer.get("caption")] + ); + } else if ( + !arraysAreEqual(visibleMinMaxZoomLayers, prevVisibleMinMaxZoomLayers) + ) { + setVisibleMinMaxZoomLayers([]); + } else { + setVisibleMinMaxZoomLayers([]); + } - // Also, check if we need to auto-rotate the map - if (visible === true && this.isBackgroundLayer) { - this.changeRotation(); + if (isGroupLayer) { + onSetZoomVisible(layerIsZoomVisible); + } + prevLayerIsZoomVisible.current = layerIsZoomVisible; } - }); - - // Check if we should display a zoom warning at start - if (this.state.visible) { - this.triggerZoomCheck(null, this.state.visible); - } - - // Check if we should auto-rotate the map at start - if (this.state.visible === true && this.isBackgroundLayer === true) { - this.changeRotation(); - } - - this.listenToZoomChange(this.state.visible); - // Set load status by subscribing to a global event. Expect ID (int) of layer - // and status (string "ok"|"loaderror"). Also, once status was set to "loaderror", - // don't change it back to "ok": we'll get a response for each tile, so most of - // the tiles might be "ok", but if only one of the tiles has "loaderror", we - // consider that the layer has failed loading and want to inform the user. - this.props.app.globalObserver.subscribe( - "layerswitcher.wmsLayerLoadStatus", - (d) => { - this.state.status !== "loaderror" && - this.name === d.id && - this.setState({ - status: d.status, - }); + setZoomVisible(layerIsZoomVisible); + return layerIsZoomVisible; + }, + [ + app.map, + layer, + zoomVisible, + visibleMinMaxZoomLayers, + visibleSubLayersCaption, + onSetZoomVisible, + ] + ); + + const triggerZoomCheck = useCallback( + (click, visible) => { + if (!layerUsesMinMaxZoom()) return; + + zoomEndHandler(click, visible); + + if (visible === false) { + removeValue(layer.get("caption")); } - ); - } - - changeRotation() { - // Retrieve the current rotation in a standardized manner - const newRotation = this.rotateMap?.toLowerCase(); - - // Determine rotation in radians - if (["n", "e", "s", "w"].includes(newRotation)) { - let radians = 0; - switch (newRotation) { - case "n": - radians = 0; - break; - case "e": - radians = -(Math.PI / 2); - break; - case "s": - radians = Math.PI; - break; - case "w": - radians = Math.PI / 2; - break; - default: - break; + }, + [layer, layerUsesMinMaxZoom, zoomEndHandler, removeValue] + ); + + useEffect(() => { + // Handler for zoom change event. + const handleChange = () => { + // Check if the layer is currently visible. + if (layer.get("visible")) { + // Trigger zoom check. + triggerZoomCheck(false, true); } + }; - // Grab the OL View - const View = this.props.app.map.getView(); + // Subscribe to zoom changes. + const zoomChangeSubscription = app.globalObserver.subscribe( + "core.zoomEnd", + handleChange + ); - // Rotate the map if current rotation differs from the new rotation - if (View.getRotation() !== radians) { - View.animate({ - rotation: radians, - }); - } - } - } + // Call handleChange immediately to ensure initial state is correct. + handleChange(); - layerUsesMinMaxZoom() { - const lprops = this.props.layer.getProperties(); - const maxZ = lprops.maxZoom ?? 0; - const minZ = lprops.minZoom ?? 0; - // When reading min/max-Zoom from layer, its not consistent with the - // initial values from config. Suddenly Infinity is used. - return (maxZ > 0 && maxZ < Infinity) || (minZ > 0 && minZ < Infinity); - } + // Cleanup function to unsubscribe when the component unmounts or dependencies change. + return () => + app.globalObserver.unsubscribe("core.zoomEnd", zoomChangeSubscription); + }, [app.globalObserver, layer, triggerZoomCheck]); - zoomEndHandler = (e) => { - const zoom = this.props.model.olMap.getView().getZoom(); - const lprops = this.props.layer.getProperties(); + useEffect(() => { + const zoom = app.map.getView().getZoom(); + const lprops = layer.getProperties(); const layerIsZoomVisible = zoom > lprops.minZoom && zoom <= lprops.maxZoom; - let showSnack = false; - - if (this.minMaxZoomAlertOnToggleOnly === true) { - if (!this.state.visible && !layerIsZoomVisible && e?.type === "click") { - showSnack = true; - } - } else { - if ( - !layerIsZoomVisible && - (this.state.zoomVisible || !this.state.visible) - ) { - showSnack = true; - } + if ( + !layerIsZoomVisible && + minMaxZoomAlertOnToggleOnly && + (subLayerClicked || layer.get("visible")) + ) { + setShowSnackbarOnClick(true); } - if (showSnack === true) { - this.showZoomSnack(); + if ( + (visibleSubLayers !== undefined && + !visibleSubLayers && + visibleMinMaxZoomLayers.length > 0) || + (!layer.get("visible") && visibleMinMaxZoomLayers.length > 0) + ) { + setIsGroupHidden(true); } + }, [ + app.map, + layer, + minMaxZoomAlertOnToggleOnly, + subLayerClicked, + visibleSubLayers, + visibleMinMaxZoomLayers.length, + ]); + + useEffect(() => { + const handleLoadStatusChange = (d) => { + if (wmsLayerLoadStatus !== "loaderror" && layer.get("name") === d.id) { + setWmsLayerLoadStatus(d.status); + } + }; - this.setState({ - zoomVisible: layerIsZoomVisible, - }); - return layerIsZoomVisible; - }; - - listenToZoomChange(bListen) { - if (!this.usesMinMaxZoom) return; + // Subscribe to layer load status. + const loadStatusSubscription = app.globalObserver.subscribe( + "layerswitcher.wmsLayerLoadStatus", + handleLoadStatusChange + ); - const eventName = "core.zoomEnd"; - if (bListen && !this.zoomEndListener) { - this.zoomEndListener = this.props.app.globalObserver.subscribe( - eventName, - this.zoomEndHandler + // Cleanup function to unsubscribe when the component unmounts or if the relevant dependencies change. + return () => + app.globalObserver.unsubscribe( + "layerswitcher.wmsLayerLoadStatus", + loadStatusSubscription ); - } else { - if (this.zoomEndListener) { - this.props.app.globalObserver.unsubscribe( - eventName, - this.zoomEndListener - ); - this.zoomEndListener = null; - } + }, [app.globalObserver, layer, wmsLayerLoadStatus]); + + useEffect(() => { + if (isGroupHidden) { + visibleMinMaxZoomLayers.forEach((value) => { + removeValue(value); + }); + setIsGroupHidden(false); } - } - showZoomSnack() { - if (this.zoomWarningSnack) return; + const prevVisibleMinMaxZoomLayers = prevVisibleMinMaxZoomLayersRef.current; + + const addedValues = visibleMinMaxZoomLayers.filter( + (value) => !prevVisibleMinMaxZoomLayers.includes(value) + ); + + const removedValues = prevVisibleMinMaxZoomLayers.filter( + (value) => !visibleMinMaxZoomLayers.includes(value) + ); + + addedValues.forEach((value) => { + addValue(value); + }); - // We're fetching layerInfo object from the layer object. - const layerInfo = this.props.layer.get("layerInfo"); + removedValues.forEach((value) => { + removeValue(value); + }); - // If layerInfo is defined, we get layersInfo from it. - // Otherwise, layersInfo is set as undefined. - const layersInfo = layerInfo ? layerInfo.layersInfo : undefined; + prevVisibleMinMaxZoomLayersRef.current = visibleMinMaxZoomLayers; + }, [visibleMinMaxZoomLayers, isGroupHidden, addValue, removeValue]); - // If the layer is a LayerGroupItem (meaning it contains more than one object in the "layersInfo" array), - // then no message should be displayed. - // Here we also ensure that layersInfo is defined and contains more than one layer - // before trying to access its keys. This prevents a TypeError when layersInfo - // is undefined. - if (layersInfo && Object.keys(layersInfo).length > 1) { + // Handles list item click + const handleLayerItemClick = (e) => { + // If a clickCallback is defined, call it. + if (clickCallback) { + clickCallback(); return; } - this.zoomWarningSnack = this.props.enqueueSnackbar( - `Lagret "${this.caption}" är inte synligt vid aktuell zoomnivå.`, - { - variant: "warning", - preventDuplicate: true, - onClose: () => { - this.zoomWarningSnack = null; - }, - } - ); - } + // Handle system layers by showing layer details directly + if (layer.get("layerType") === "system") { + showLayerDetails(e); + return; + } - triggerZoomCheck(e, visible) { - if (!this.usesMinMaxZoom) return; + // Continue with existing functionality for non-system layers + triggerZoomCheck(true, !layer.get("visible")); - this.zoomEndHandler(e); + // Toggle visibility for non-system layers + if (layer.get("layerType") !== "system") { + // This check is technically redundant now but left for clarity + layer.set("visible", !layer.get("visible")); + } + }; - if (visible === false) { - if (!this.zoomWarningSnack) return; - this.props.closeSnackbar(this.zoomWarningSnack); - this.zoomWarningSnack = null; + // Render method for legend icon + const getIconFromLayer = () => { + const layerLegendIcon = + layer.get("layerInfo")?.legendIcon || layer.get("legendIcon"); + if (layerLegendIcon !== undefined) { + return ; + } else if (layer.get("layerType") === "system") { + return ; } - } + return renderLegendIcon(); + }; - /** - * Toggle visibility of this layer item. - * Also, if layer is being hidden, reset "status" (if layer loading failed, - * "status" is "loaderror", and it should be reset if user unchecks layer). - * @instance - */ - toggleVisible = (e) => { - const layer = this.props.layer; - if (this.isBackgroundLayer) { - document.getElementById("map").style.backgroundColor = "#FFF"; // sets the default background color to white - if (layer.isFakeMapLayer) { - switch (this.name) { - case "-2": - document.getElementById("map").style.backgroundColor = "#000"; - break; - case "-1": - default: - document.getElementById("map").style.backgroundColor = "#FFF"; - break; + const renderLegendIcon = () => { + if ( + layer.get("layerType") === "group" || + layer.get("layerType") === "base" || + layer.isFakeMapLayer || + layer.get("layerType") === "system" + ) { + return null; + } + return ( + + { + e.stopPropagation(); + setLegendIsActive(!legendIsActive); + }} + > + + + + ); + }; + + // Render method for checkbox icon + const getLayerToggleIcon = () => { + if (toggleIcon) { + return toggleIcon; } + return !layer.get("visible") ? ( + + ) : layer.get("layerType") === "group" && + visibleSubLayers.length !== layer.subLayers.length ? ( + + ) : ( + + !zoomVisible && layer.get("visible") && !visibleSubLayers + ? theme.palette.warning.dark + : "", + }} + /> + ); }; /** @@ -350,398 +382,175 @@ class LayerItem extends React.PureComponent { * @instance * @return {external:ReactElement} */ - renderStatusButton() { + const renderStatusIcon = () => { return ( - this.state.status === "loaderror" && ( - - - - - + wmsLayerLoadStatus === "loaderror" && ( + + + + + ) ); - } - - renderInfoButton = () => { - return this.isInfoEmpty() ? null : ( - - - {this.state.infoVisible ? ( - - ) : ( - - )} - - - ); - }; - - renderMoreButton = () => { - return ( - - - {this.state.toggleSettings ? ( - - ) : ( - - )} - - - ); }; - isInfoEmpty() { - let chaptersWithLayer = this.findChapters(this.name, this.props.chapters); - return !( - this.infoCaption || - this.infoUrl || - this.infoOwner || - this.infoText || - chaptersWithLayer.length > 0 - ); - } - - openInformative = (chapter) => (e) => { - this.props.onOpenChapter(chapter); + // Show layer details action + const showLayerDetails = (e, specificLayer = layer) => { + e.stopPropagation(); + app.globalObserver.publish("setLayerDetails", { layer: specificLayer }); }; - findChapters(id, chapters) { - let result = []; - if (Array.isArray(chapters)) { - result = chapters.reduce((chaptersWithLayer, chapter) => { - if (Array.isArray(chapter.layers)) { - if (chapter.layers.some((layerId) => layerId === id)) { - chaptersWithLayer = [...chaptersWithLayer, chapter]; - } - if (chapter.chapters.length > 0) { - chaptersWithLayer = [ - ...chaptersWithLayer, - ...this.findChapters(id, chapter.chapters), - ]; - } - } - return chaptersWithLayer; - }, []); + const drawOrderItem = () => { + if (draggable) { + return true; } - return result; - } - - renderChapterLinks(chapters) { - if (chapters && chapters.length > 0) { - let chaptersWithLayer = this.findChapters(this.name, chapters); - if (chaptersWithLayer.length > 0) { - return ( - - - Innehåll från denna kategori finns benämnt i följande kapitel i - översiktsplanen: - - - {chaptersWithLayer.map((chapter, i) => { - return ( -
  • - -
  • - ); - })} -
    -
    - ); - } else { - return null; - } - } else { - return null; + if (isBackgroundLayer && !toggleable) { + return true; } - } + return false; + }; - toggle() { - this.setState({ - open: !this.state.open, - }); - } - - renderInfo() { - if (this.infoText) { - return ( - - {this.infoTitle} - - - ); - } else { - return null; + const renderBorder = (theme) => { + if (drawOrderItem()) { + return "none"; } - } - - renderMetadataLink() { - if (this.infoUrl) { - return ( - - - - {this.infoUrlText || this.infoUrl} - - - - ); - } else { - return null; - } - } - - renderOpenDataLink() { - if (this.infoOpenDataLink) { - return ( - - - - Öppna data: {this.caption} - - - - ); - } else { - return null; - } - } - - renderOwner() { - if (this.infoOwner) { - return ( - - - - ); - } else { - return null; - } - } - - renderDetails() { - if (this.state.infoVisible) { - return ( -
    - {this.renderInfo()} - {this.renderMetadataLink()} - {this.renderOpenDataLink()} - {this.renderOwner()} -
    {this.renderChapterLinks(this.props.chapters || [])}
    -
    - ); + if (legendIsActive) { + return `${theme.spacing(0.2)} solid transparent`; } - } - - toggleSettings = () => { - this.setState({ - toggleSettings: !this.state.toggleSettings, - }); - }; - - toggleInfo = () => { - this.setState({ - infoVisible: !this.state.infoVisible, - }); + return `${theme.spacing(0.2)} solid ${theme.palette.divider}`; }; - renderLegendIcon() { - return ; - } - - getLayerToggler = () => { - const { visible } = this.state; - const icon = visible ? ( - this.isBackgroundLayer ? ( - - ) : ( - + + drawOrderItem() && showSublayers + ? "none" + : drawOrderItem() && !legendIsActive + ? `${theme.spacing(0.2)} solid ${theme.palette.divider}` + : "none", + display: "flex", + "&:hover .dragInidcatorIcon": { + opacity: draggable ? 1 : 0, + }, + }} + > + {draggable && ( + + + + + + )} + {expandableSection && expandableSection} + - !this.state.zoomVisible && this.state.visible - ? theme.palette.warning.dark - : "", - }} - /> - ) - ) : this.isBackgroundLayer ? ( - - ) : ( - - ); - return ( - - {icon} - - ); - }; - - #showAttributeTable = async () => { - try { - const url = this.props.layer.getSource().get("url").replace("wms", "wfs"); - const { LAYERS } = this.props.layer.getSource().getParams(); - // If URL already contains a query string part, we want to glue them together. - const glue = url.includes("?") ? "&" : "?"; - const getFeatureUrl = `${url}${glue}service=WFS&version=1.0.0&request=GetFeature&typeName=${LAYERS}&maxFeatures=5000&outputFormat=application%2Fjson`; - const describeFeatureTypeUrl = `${url}${glue}service=WFS&version=1.0.0&request=DescribeFeatureType&typeName=${LAYERS}&outputFormat=application%2Fjson`; - // TODO: QGIS Server doesn't support JSON response for DescribeFeatureType. We must - // fetch the result as GML2 and then parse it accordingly. This will require - // some more work than the current approach. - // const describeFeatureTypeUrl = `${url}${glue}service=WFS&version=1.0.0&request=DescribeFeatureType&typeName=${LAYERS}`; - const r1 = await fetch(getFeatureUrl); - const features = await r1.json(); - const r2 = await fetch(describeFeatureTypeUrl); - const description = await r2.json(); - - const columns = description.featureTypes - .find((f) => f.typeName === LAYERS) // featureTypes contains an object, where typeName will be the same as the layer name we requested - .properties.filter((c) => !c.type.toLowerCase().includes("gml")) // Best guess to try to filter out the geometry column, we don't want to show it - .map((c) => { - // Prepare an object that has the format of 'columns' prop for MUI's DataGrid - return { - field: c.name, - headerName: c.name, - type: c.localType === "int" ? "number" : c.localType, // DataGrid wants 'number', not 'int', see https://mui.com/components/data-grid/columns/#column-types - flex: 1, - }; - }); - - const rows = features.features.map((r, i) => { - return { ...r.properties, id: i }; - }); - - this.props.app.globalObserver.publish("core.showAttributeTable", { - title: `${this.caption} (${LAYERS})`, - content: { columns, rows }, - }); - } catch (error) { - console.error(error); - console.log(this); - this.props.enqueueSnackbar( - `Serverfel: attributtabellen för lagret "${this.caption}" kunde inte visas`, - { variant: "error" } - ); - } - }; - - render() { - const { layer, model, app, chapters } = this.props; - - const cqlFilterVisible = - this.props.app.config.mapConfig.map?.cqlFilterVisible || false; - - if (!this.caption) { - return null; - } - - if (layer.get("layerType") === "group") { - return ( - { - const informativeWindow = app.windows.find( - (window) => window.type === "informative" - ); - informativeWindow.props.custom.open(chapter); + p: 0, + ml: isBackgroundLayer && !toggleable ? (draggable ? 0 : "20px") : 0, }} - /> - ); - } - - return ( - - - + renderBorder(theme), + }} > - {this.getLayerToggler()} - {this.legendIcon && this.renderLegendIcon()} - - {this.caption} - - - - {layer.isFakeMapLayer ? null : ( - + {toggleable && ( + + {getLayerToggleIcon()} + )} - {this.renderStatusButton()} - {this.renderInfoButton()} - - {this.showAttributeTableButton && ( - - - - - + {isBackgroundLayer && !toggleable ? ( + layer.isFakeMapLayer ? ( + + ) : ( + + ) + ) : ( + getIconFromLayer() )} - {this.renderMoreButton()} - - -
    - {this.renderDetails()} - {this.state.toggleSettings && - this.state.infoVisible && - !this.isInfoEmpty() ? ( -
    - ) : null} - {layer.isFakeMapLayer ? null : ( - - )} -
    -
    - ); - } + + {renderStatusIcon()} + {isBackgroundLayer && !toggleable && !draggable ? ( + + + + + + ) : null} + {layer.isFakeMapLayer !== true && ( + showLayerDetails(e)}> + theme.palette.grey[500], + }} + > + + )} + +
    + +
    + {layer.get("layerType") === "group" || + layer.get("layerType") === "base" || + layer.isFakeMapLayer || + layer.get("layerType") === "system" ? null : ( + + )} + {subLayersSection && subLayersSection} + + ); } - -export default withSnackbar(LayerItem); diff --git a/apps/client/src/plugins/LayerSwitcher/components/LayerItemDetails.js b/apps/client/src/plugins/LayerSwitcher/components/LayerItemDetails.js new file mode 100644 index 000000000..7de856b0d --- /dev/null +++ b/apps/client/src/plugins/LayerSwitcher/components/LayerItemDetails.js @@ -0,0 +1,391 @@ +import React, { useState, useEffect } from "react"; +import { useSnackbar } from "notistack"; + +import LayerItemOptions from "./LayerItemOptions"; +import VectorFilter from "./VectorFilter"; +import CQLFilter from "./CQLFilter"; + +import { + Button, + Box, + IconButton, + Divider, + Slider, + Tooltip, + Typography, + Stack, +} from "@mui/material"; + +import ArrowBackIcon from "@mui/icons-material/ArrowBack"; +import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; +import FormatListBulletedOutlinedIcon from "@mui/icons-material/FormatListBulletedOutlined"; +import SettingsOutlinedIcon from "@mui/icons-material/SettingsOutlined"; +import StarOutlineOutlinedIcon from "@mui/icons-material/StarOutlineOutlined"; +import DeleteOutlinedIcon from "@mui/icons-material/DeleteOutlined"; +import FilterAltOutlinedIcon from "@mui/icons-material/FilterAltOutlined"; +import LayerItemInfo from "./LayerItemInfo"; +import LegendImage from "./LegendImage"; + +function LayerItemDetails({ + display, + layerItemDetails, + chapters, + app, + showOpacitySlider, + showQuickAccess, +}) { + const { enqueueSnackbar } = useSnackbar(); + // State that toggles legend collapse + const [legendIsActive, setLegendIsActive] = useState(false); + // Keep the layer opacity in state + const [opacity, setOpacity] = useState(0); + // Keep the layer quickAccess property in state + const [quickAccess, setQuickAccess] = useState(false); + + // Because of a warning in dev console, we need special handling of tooltip for backbutton. + // When a user clicks back, the tooltip of the button needs to be closed before this view hides. + // TODO: Needs a better way to handle this + const [tooltipOpen, setTooltipOpen] = useState(false); + + const layerSwitcherConfig = app.config.mapConfig.tools.find( + (tool) => tool.type === "layerswitcher" + ); + const cqlFilterVisible = + layerSwitcherConfig?.options.cqlFilterVisible || false; + const subLayerIndex = + layerItemDetails?.subLayerIndex === undefined + ? null + : layerItemDetails?.subLayerIndex; + const showOpacity = subLayerIndex !== null ? false : true; + const showLegend = + layerItemDetails?.layer.get("layerType") === "group" && + subLayerIndex === null + ? false + : true; + + // Handle opacity slider changes + const handleOpacitySliderChange = (e, newValue) => { + layerItemDetails.layer.set("opacity", newValue); + }; + + // Callback for change:opacity listeners + const setOpacityCallback = (e) => { + setOpacity(e.target.get("opacity")); + }; + + // Setup listeners when component is mounted + useEffect(() => { + if (layerItemDetails?.layer) { + // Register a listener: when layer's opacity changes make sure + // to update opacity state. Not applicable for fakeMapLayers + if (!layerItemDetails.layer.isFakeMapLayer) { + setOpacity(layerItemDetails.layer.get("opacity")); + setQuickAccess(layerItemDetails.layer.get("quickAccess")); + layerItemDetails.layer.on("change:opacity", setOpacityCallback); + } + } + return function () { + layerItemDetails?.layer.un("change:opacity", setOpacityCallback); + }; + }, [layerItemDetails]); + + // Format slider label + const valueLabelFormat = (value) => { + return `${Math.trunc(value * 100)} %`; + }; + + // Handles click on back button in header + const handleBackButtonClick = () => { + setTooltipOpen(false); + setLegendIsActive(false); + setTimeout(() => { + app.globalObserver.publish("setLayerDetails", null); + }, 100); + }; + + // Handles backbutton tooltip close event + const handleClose = () => { + setTooltipOpen(false); + }; + + // Handles backbutton tooltip open event + const handleOpen = () => { + setTooltipOpen(true); + }; + + // Checks if layer is enabled for options + const hasListItemOptions = () => { + return ( + layerItemDetails.layer.get("layerType") !== "system" && + layerItemDetails.layer.isFakeMapLayer !== true + ); + }; + + // Check that layer is elligible for quickAccess option + const isQuickAccessEnabled = () => { + return ( + layerItemDetails.layer.get("layerType") !== "base" && + layerItemDetails.layer.get("layerType") !== "system" && // Exclude system layers + subLayerIndex === null && + showQuickAccess + ); + }; + + // Add a check for CQL filter visibility and exclude system layers + const isCqlFilterEnabled = () => { + return ( + cqlFilterVisible && layerItemDetails.layer.get("layerType") !== "system" // Exclude system layers + ); + }; + + // Handle quickacces action + const handleQuickAccess = () => { + let snackbarMessage = ""; + if (!quickAccess) { + snackbarMessage = `${renderDetailTitle()} har nu lagts till i snabbåtkomst.`; + } else { + snackbarMessage = `${renderDetailTitle()} har nu tagits bort från snabbåtkomst.`; + } + enqueueSnackbar(snackbarMessage, { + variant: "success", + anchorOrigin: { vertical: "bottom", horizontal: "center" }, + }); + setQuickAccess(!quickAccess); + // Set quicklayer access flag + layerItemDetails.layer.set( + "quickAccess", + !layerItemDetails.layer.get("quickAccess") + ); + }; + + // Render title + const renderDetailTitle = () => { + if (subLayerIndex !== null) { + return layerItemDetails.layer.layersInfo[ + layerItemDetails.layer.subLayers[subLayerIndex] + ].caption; + } else { + return layerItemDetails.layer.get("caption"); + } + }; + + return ( + <> + {layerItemDetails && ( + + theme.palette.mode === "dark" ? "rgb(18,18,18)" : "#fff", + }} + > + + theme.palette.mode === "dark" + ? "#373737" + : theme.palette.grey[100], + borderBottom: (theme) => + `${theme.spacing(0.2)} solid ${theme.palette.divider}`, + }} + > + + + + + + + + + {renderDetailTitle()} + + + {hasListItemOptions() && ( + + )} + + + + + + + + + Info + + {showLegend && ( + + setLegendIsActive(!legendIsActive)} + > + + + + )} + + + `${theme.spacing(0.2)} solid ${theme.palette.divider}`, + }} + > + + + + + + + + + Inställningar + + + {showOpacitySlider !== false && showOpacity ? ( + + + + Opacitet + + + + + ) : null} + {isCqlFilterEnabled() && ( + + + + )} + {layerItemDetails.layer.getProperties().filterable && ( + <> + + + + + + + Filter + + + + + + + )} + {isQuickAccessEnabled() && ( + + `${theme.spacing(0.2)} solid ${theme.palette.divider}`, + p: 2, + }} + > + + + )} + + + )} + + ); +} + +export default LayerItemDetails; diff --git a/apps/client/src/plugins/LayerSwitcher/components/LayerItemInfo.js b/apps/client/src/plugins/LayerSwitcher/components/LayerItemInfo.js new file mode 100644 index 000000000..4d4d7b28e --- /dev/null +++ b/apps/client/src/plugins/LayerSwitcher/components/LayerItemInfo.js @@ -0,0 +1,129 @@ +import * as React from "react"; + +import { Box, Button, List, Typography } from "@mui/material"; + +import CallMadeIcon from "@mui/icons-material/CallMade"; + +export default function LayerItemInfo({ layer, app, chapters }) { + const layerInfo = layer.get("layerInfo") || {}; + + const hasInfo = () => { + const chaptersWithLayer = findChapters(layer.get("name"), chapters); + return ( + layerInfo.infoCaption || + "" || + layerInfo.infoUrl || + "" || + layerInfo.infoOwner || + "" || + layerInfo.infoText || + "" || + chaptersWithLayer.length > 0 + ); + }; + + const findChapters = (id, incomingChapters) => { + let result = []; + if (Array.isArray(incomingChapters)) { + result = incomingChapters.reduce((chaptersWithLayer, chapter) => { + if (Array.isArray(chapter.layers)) { + if (chapter.layers.some((layerId) => layerId === id)) { + chaptersWithLayer = [...chaptersWithLayer, chapter]; + } + if (chapter.chapters.length > 0) { + chaptersWithLayer = [ + ...chaptersWithLayer, + ...findChapters(id, chapter.chapters), + ]; + } + } + return chaptersWithLayer; + }, []); + } + return result; + }; + + const onOpenChapter = (chapter) => { + const informativeWindow = app.windows.find( + (window) => window.type === "informative" + ); + informativeWindow.props.custom.open(chapter); + }; + + const renderChapterLinks = () => { + if (chapters && chapters.length > 0) { + let chaptersWithLayer = findChapters(this.name, chapters); + if (chaptersWithLayer.length > 0) { + return ( + <> + + Innehåll från denna kategori finns benämnt i följande kapitel i + översiktsplanen: + + + {chaptersWithLayer.map((chapter, i) => { + return ( +
  • + +
  • + ); + })} +
    + + ); + } else { + return null; + } + } else { + return null; + } + }; + + return ( + <> + {hasInfo() ? ( + + {/* Infotext */} + {layerInfo.infoText && ( + <> + {layerInfo.infoTitle} + + + )} + {/* MetadataLink */} + {layerInfo.infoUrl && ( + + {layerInfo.infoUrlText || layerInfo.infoUrl} + + )} + {/* Owner */} + {layerInfo.infoOwner && ( + + )} + {renderChapterLinks()} + + ) : ( + Ingen information tillgänglig + )} + + ); +} diff --git a/apps/client/src/plugins/LayerSwitcher/components/LayerItemOptions.js b/apps/client/src/plugins/LayerSwitcher/components/LayerItemOptions.js new file mode 100644 index 000000000..b5aa5cf20 --- /dev/null +++ b/apps/client/src/plugins/LayerSwitcher/components/LayerItemOptions.js @@ -0,0 +1,161 @@ +import * as React from "react"; + +import { + IconButton, + ListItemIcon, + ListItemText, + Menu, + MenuItem, + Tooltip, +} from "@mui/material"; + +import MoreVertOutlinedIcon from "@mui/icons-material/MoreVertOutlined"; +import FileDownloadOutlinedIcon from "@mui/icons-material/FileDownloadOutlined"; +import TableViewOutlinedIcon from "@mui/icons-material/TableViewOutlined"; + +export default function LayerItemOptions({ + layer, + app, + enqueueSnackbar, + subLayerIndex, +}) { + // Element that we will anchor the options menu to is + // held in state. If it's null (unanchored), we can tell + // that the menu should be hidden. + const [anchorEl, setAnchorEl] = React.useState(null); + + const optionsMenuIsOpen = Boolean(anchorEl); + const layerInfo = layer.get("layerInfo"); + + // Show the options menu by setting an anchor element + const handleShowMoreOptionsClick = (e) => { + e.stopPropagation(); + e.preventDefault(); + setAnchorEl(e.currentTarget); + }; + + // Hides the options menu by resetting the anchor element + const onOptionsMenuClose = (e) => { + e.stopPropagation(); + e.preventDefault(); + setAnchorEl(null); + }; + + // Check that layer is downloadable + const isDownloadable = () => { + return ( + app.config.mapConfig.map.enableDownloadLink && + Array.isArray(layer.subLayers) + ); + }; + + // Handle download action + const handleDownload = (e) => { + e.stopPropagation(); + setAnchorEl(null); + if (!subLayerIndex) { + subLayerIndex = 0; + } + // Construct link + const layerName = Array.isArray(layer.subLayers) + ? encodeURI(layer.subLayers[subLayerIndex]) + : null; + const wmsUrl = layer.get("url"); + const downloadUrl = `${wmsUrl}/kml?layers=${layerName}&mode=download`; + document.location = downloadUrl; + }; + + // Shows attribute table + const handleAttributeTable = async (e) => { + e.stopPropagation(); + setAnchorEl(null); + try { + const url = layer.getSource().get("url").replace("wms", "wfs"); + const { LAYERS } = layer.getSource().getParams(); + // If URL already contains a query string part, we want to glue them together. + const glue = url.includes("?") ? "&" : "?"; + const getFeatureUrl = `${url}${glue}service=WFS&version=1.0.0&request=GetFeature&typeName=${LAYERS}&maxFeatures=5000&outputFormat=application%2Fjson`; + const describeFeatureTypeUrl = `${url}${glue}service=WFS&version=1.0.0&request=DescribeFeatureType&typeName=${LAYERS}&outputFormat=application%2Fjson`; + // TODO: QGIS Server doesn't support JSON response for DescribeFeatureType. We must + // fetch the result as GML2 and then parse it accordingly. This will require + // some more work than the current approach. + // const describeFeatureTypeUrl = `${url}${glue}service=WFS&version=1.0.0&request=DescribeFeatureType&typeName=${LAYERS}`; + const r1 = await fetch(getFeatureUrl); + const features = await r1.json(); + const r2 = await fetch(describeFeatureTypeUrl); + const description = await r2.json(); + + const columns = description.featureTypes + .find((f) => f.typeName === LAYERS) // featureTypes contains an object, where typeName will be the same as the layer name we requested + .properties.filter((c) => !c.type.toLowerCase().includes("gml")) // Best guess to try to filter out the geometry column, we don't want to show it + .map((c) => { + // Prepare an object that has the format of 'columns' prop for MUI's DataGrid + return { + field: c.name, + headerName: c.name, + type: c.localType === "int" ? "number" : c.localType, // DataGrid wants 'number', not 'int', see https://mui.com/components/data-grid/columns/#column-types + flex: 1, + }; + }); + + const rows = features.features.map((r, i) => { + return { ...r.properties, id: i }; + }); + const caption = layer.get("caption"); + app.globalObserver.publish("core.showAttributeTable", { + title: `${caption} (${LAYERS})`, + content: { columns, rows }, + }); + } catch (error) { + console.error(error); + console.log(this); + const caption = layer.get("caption"); + enqueueSnackbar( + `Serverfel: attributtabellen för lagret "${caption}" kunde inte visas`, + { + variant: "error", + anchorOrigin: { vertical: "bottom", horizontal: "center" }, + } + ); + } + }; + + return !layerInfo.showAttributeTableButton && !isDownloadable() ? null : ( + <> + + + + + + + {layerInfo.showAttributeTableButton && ( + + + + + Visa attributtabell + + )} + {isDownloadable() && ( + + + + + Ladda ner + + )} + + + ); +} diff --git a/apps/client/src/plugins/LayerSwitcher/components/LayerListFilter.js b/apps/client/src/plugins/LayerSwitcher/components/LayerListFilter.js new file mode 100644 index 000000000..5ead3c25a --- /dev/null +++ b/apps/client/src/plugins/LayerSwitcher/components/LayerListFilter.js @@ -0,0 +1,62 @@ +import React, { useRef } from "react"; + +import { Box, IconButton, InputAdornment, TextField } from "@mui/material"; + +import SearchIcon from "@mui/icons-material/Search"; +import ClearIcon from "@mui/icons-material/Clear"; + +const LayerListFilter = ({ filterValue, handleFilterValueChange }) => { + const inputRef = useRef(null); + + return ( + + theme.palette.mode === "dark" ? "#373737" : theme.palette.grey[100], + borderBottom: (theme) => + `${theme.spacing(0.2)} solid ${theme.palette.divider}`, + }} + > + + + + ), + endAdornment: ( + + {inputRef.current?.value && ( + { + if (inputRef.current) { + inputRef.current.value = ""; + handleFilterValueChange(""); + } + }} + size="small" + > + + + )} + + ), + }} + size="small" + onChange={(event) => handleFilterValueChange(event.target.value)} + fullWidth + placeholder="Sök lager och grupper" + inputRef={inputRef} + variant="outlined" + sx={{ + background: (theme) => + theme.palette.mode === "dark" ? "inherit" : "#fff", + width: 500, + maxWidth: "100%", + }} + /> + + ); +}; +export default LayerListFilter; diff --git a/apps/client/src/plugins/LayerSwitcher/components/LayerPackage.js b/apps/client/src/plugins/LayerSwitcher/components/LayerPackage.js new file mode 100644 index 000000000..9b8124bc0 --- /dev/null +++ b/apps/client/src/plugins/LayerSwitcher/components/LayerPackage.js @@ -0,0 +1,567 @@ +import React, { useState } from "react"; +import { createPortal } from "react-dom"; +import { useSnackbar } from "notistack"; + +import { + Button, + Box, + Chip, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + DialogContentText, + IconButton, + InputAdornment, + List, + ListItemButton, + ListItemIcon, + ListItemSecondaryAction, + ListItemText, + Tooltip, + Collapse, + TextField, + Typography, + Stack, + Divider, +} from "@mui/material"; + +import PublicOutlinedIcon from "@mui/icons-material/PublicOutlined"; +import SearchIcon from "@mui/icons-material/Search"; +import ArrowBackIcon from "@mui/icons-material/ArrowBack"; +import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; +import TopicOutlinedIcon from "@mui/icons-material/TopicOutlined"; + +function LayerPackage({ + display, + backButtonCallback, + quickLayerPresets, + map, + globalObserver, + layerPackageInfoText, +}) { + const { enqueueSnackbar } = useSnackbar(); + // State that toggles info collapse + const [infoIsActive, setInfoIsActive] = useState(false); + // Confirmation dialogs + const [loadLpConfirmation, setLoadLpConfirmation] = useState(null); + const [loadLpInfoConfirmation, setLoadLpInfoConfirmation] = useState(null); + const [missingLayersConfirmation, setMissingLayersConfirmation] = + useState(null); + + // Because of a warning in dev console, we need special handling of tooltip for backbutton. + // When a user clicks back, the tooltip of the button needs to be closed before this view hides. + // TODO: Needs a better way to handle this + const [tooltipOpen, setTooltipOpen] = useState(false); + + const quickLayerPresetsArray = quickLayerPresets || []; + console.log({ quickLayerPresetsArray }); + // Filter state + const [filter, setFilter] = useState({ + query: "", + list: quickLayerPresetsArray, + }); + + // Handles click on back button in header + const handleBackButtonClick = (setQuickAccessSectionExpanded) => { + setTooltipOpen(false); + setTimeout(() => { + setQuickAccessSectionExpanded + ? backButtonCallback({ setQuickAccessSectionExpanded: true }) + : backButtonCallback(); + }, 100); + }; + + // Handles click on info button in header + const handleInfoButtonClick = () => { + setInfoIsActive(!infoIsActive); + }; + + // Handles click on info button in list + const handleLpInfoClick = (qlp) => { + setLoadLpInfoConfirmation(qlp); + }; + + // Handles filter functionality + const handleFilterChange = (value) => { + const results = quickLayerPresetsArray.filter((data) => { + if (value === "") return data; + return ( + data.title.toLowerCase().includes(value.toLowerCase()) || + data.author.toLowerCase().includes(value.toLowerCase()) || + data.keywords.some((keyword) => + keyword.toLowerCase().includes(value.toLowerCase()) + ) + ); + }); + setFilter({ + query: value, + list: results, + }); + }; + + // Handles LayerPackage item click + const handleLpClick = (qlp) => { + setLoadLpConfirmation(qlp); + }; + + // Fires when the user confirms the confirmation-window. + const handleLoadConfirmation = (infoType) => { + let lpInfo = infoType + ? { ...loadLpInfoConfirmation } + : { ...loadLpConfirmation }; + + setLoadLpConfirmation(null); + setLoadLpInfoConfirmation(null); + + // Check if layers from layerpackage exists in map + const missingLayers = checkForMissingLayers(lpInfo.layers); + if (missingLayers.length > 0) { + // Show missing layers dialog + setMissingLayersConfirmation({ + missingLayers: missingLayers, + layers: lpInfo.layers, + title: lpInfo.title, + }); + } else { + loadLayers(lpInfo.layers, lpInfo.title); + } + }; + + // Load layers to quickAccess section + const loadLayers = (layers, title) => { + clearQuickAccessLayers(); + resetVisibleLayers(); + setMissingLayersConfirmation(null); + + const allMapLayers = map.getAllLayers(); + layers.forEach((l) => { + const layer = allMapLayers.find((la) => la.get("name") === l.id); + if (layer) { + // Set quickaccess property + if (layer.get("layerType") !== "base") { + layer.set("quickAccess", true); + } + // Set drawOrder (zIndex) + layer.setZIndex(l.drawOrder); + // Set opacity + layer.setOpacity(l.opacity); + // Special handling for layerGroups and baselayers + if (layer.get("layerType") === "group") { + if (l.visible === true) { + const subLayersToShow = l.subLayers ? l.subLayers : []; + globalObserver.publish("layerswitcher.showLayer", { + layer, + subLayersToShow, + }); + } else { + globalObserver.publish("layerswitcher.hideLayer", layer); + } + } else if (layer.get("layerType") === "base") { + // Hide all other background layers + globalObserver.publish( + "layerswitcher.backgroundLayerChanged", + layer.get("name") + ); + // Set visibility + layer.set("visible", l.visible); + } else { + layer.set("visible", l.visible); + } + } else if (l.id < 0) { + // A fake maplayer is in the package + // Hide all other background layers + globalObserver.publish("layerswitcher.backgroundLayerChanged", l.id); + // And set background color to map + switch (l.id) { + case "-2": + document.getElementById("map").style.backgroundColor = "#000"; + break; + case "-1": + default: + document.getElementById("map").style.backgroundColor = "#FFF"; + break; + } + } + }); + + enqueueSnackbar(`${title} har nu laddats till snabbåtkomst.`, { + variant: "success", + anchorOrigin: { vertical: "bottom", horizontal: "center" }, + }); + + // Close layerPackage view on load + handleBackButtonClick(true); + }; + + // Check if all layers in package exist in map + const checkForMissingLayers = (layers) => { + map.getAllLayers().forEach((layer) => { + const existingLayer = layers.find((l) => l.id === layer.get("name")); + if (existingLayer) { + // Remove the layer from the layers array once it's found + layers = layers.filter((l) => l.id !== existingLayer.id); + } + }); + // Also, remove potential fake background layers included in the package + if (layers.length > 0) { + // Fake maplayers have id below 0 + layers = layers.filter((l) => l.id > 0); + } + // At this point, the layers array will only contain the layers that don't exist in map.getAllLayers() or is a fake mapLayer + return layers; + }; + + // Clear quickaccessLayers + const clearQuickAccessLayers = () => { + map + .getAllLayers() + .filter((l) => l.get("quickAccess") === true) + .map((l) => l.set("quickAccess", false)); + }; + + // Reset visible layers + const resetVisibleLayers = () => { + map + .getAllLayers() + .filter((l) => l.get("visible") === true) + .forEach((l) => { + if (l.get("layerType") === "group") { + globalObserver.publish("layerswitcher.hideLayer", l); + } else if (l.get("layerType") !== "system") { + l.set("visible", false); + } + }); + }; + + // Fires when the user closes the confirmation-window. + const handleLoadConfirmationAbort = () => { + setLoadLpConfirmation(null); + setLoadLpInfoConfirmation(null); + }; + + // Fires when the user closes the missing layers-window. + const handleMissingLayersConfirmationAbort = () => { + setMissingLayersConfirmation(null); + }; + + // Handles backbutton tooltip close event + const handleClose = () => { + setTooltipOpen(false); + }; + + // Handles backbutton tooltip open event + const handleOpen = () => { + setTooltipOpen(true); + }; + + // Function that finds a layer by id and returns caption + const getBaseLayerName = (layers) => { + let backgroundLayerName = "Bakgrundskarta hittades inte"; + layers.forEach((layer) => { + const mapLayer = map + .getAllLayers() + .find((l) => l.get("name") === layer.id); + if (mapLayer && mapLayer.get("layerType") === "base") { + backgroundLayerName = mapLayer.get("caption"); + } else if (layer.id < 0) { + // A fake maplayer is in the package + switch (layer.id) { + case "-2": + backgroundLayerName = "Svart"; + break; + case "-1": + default: + backgroundLayerName = "Vit"; + break; + } + } + }); + return backgroundLayerName; + }; + + // Render layerpackage keywords + const renderKeywords = (keywords) => { + // Check if keywords is an array and if it contains any items + if (Array.isArray(keywords) && keywords.length > 0) { + // Check if keywords contains any empty strings + const nonEmptyKeywords = keywords.filter( + (keyword) => keyword.trim() !== "" + ); + if (nonEmptyKeywords.length > 0) { + return ( + + {nonEmptyKeywords.map((k) => ( + + ))} + + ); + } else { + return null; + } + } else { + return null; + } + }; + + // Render dialog with layerpackage information + const renderInfoDialog = () => { + return createPortal( + + + {loadLpInfoConfirmation ? loadLpInfoConfirmation.title : ""} + + + + {loadLpInfoConfirmation ? loadLpInfoConfirmation.author : ""} + + + {loadLpInfoConfirmation ? loadLpInfoConfirmation.description : ""} + + {loadLpInfoConfirmation + ? renderKeywords(loadLpInfoConfirmation.keywords) + : null} + Bakgrund + + + + {loadLpInfoConfirmation && + getBaseLayerName(loadLpInfoConfirmation.layers)} + + + + + Vid laddning kommer aktuella lager i snabbåtkomst att ersättas med + temat. Alla tända lager i kartan släcks och ersätts med temats tända + lager. + + + + + + + , + document.getElementById("windows-container") + ); + }; + + // Render dialog to load layerpackage + const renderLoadDialog = () => { + return createPortal( + + Ladda tema + + + {loadLpConfirmation + ? `Aktuella lager i snabbåtkomst kommer nu att ersättas med tema "${loadLpConfirmation.title}". Alla tända lager i kartan släcks och ersätts med temats tända lager.` + : ""} +

    +
    +
    + + + + +
    , + document.getElementById("windows-container") + ); + }; + + // Render dialog with missing layers information + const renderMissingLayersDialog = () => { + return createPortal( + + Lager saknas + + + {missingLayersConfirmation && + `Följande lagerid:n kan inte hittas i kartans lagerlista:`} +

    +
    +
      + {missingLayersConfirmation?.missingLayers.map((l) => { + return
    • {l.id}
    • ; + })} +
    + + {missingLayersConfirmation && + `Det kan bero på att lagret har utgått. Vänligen kontrollera och uppdatera lagerpaketet eller kontakta administratören av kartan.`} +

    +
    +
    + + + + +
    , + document.getElementById("windows-container") + ); + }; + + return ( + <> + + + theme.palette.mode === "dark" + ? "#373737" + : theme.palette.grey[100], + borderBottom: (theme) => + `${theme.spacing(0.2)} solid ${theme.palette.divider}`, + }} + > + + + + + + + + Teman + + + + + + + + + + `${theme.spacing(0.2)} solid ${theme.palette.divider}`, + }} + > + + {layerPackageInfoText} + + + + + + + + ), + }} + size="small" + value={filter.query} + onChange={(event) => handleFilterChange(event.target.value)} + fullWidth + placeholder="Filtrera" + variant="outlined" + sx={{ + background: (theme) => + theme.palette.mode === "dark" ? "inherit" : "#fff", + }} + /> + + + + + {!filter.list.length ? ( + + {quickLayerPresetsArray.length === 0 ? ( + Inga lagerpaket är konfigurerade + ) : ( + Din filtrering gav inga resultat + )} + + ) : ( + filter.list.map((l) => { + return ( + handleLpClick(l)} + > + + + + + + { + e.preventDefault(); + e.stopPropagation(); + handleLpInfoClick(l); + }} + > + + + + + + + ); + }) + )} + + + + {renderLoadDialog()} + {renderInfoDialog()} + {renderMissingLayersDialog()} + + ); +} + +export default LayerPackage; diff --git a/apps/client/src/plugins/LayerSwitcher/components/LayerSettings.js b/apps/client/src/plugins/LayerSwitcher/components/LayerSettings.js deleted file mode 100644 index c0aa9be59..000000000 --- a/apps/client/src/plugins/LayerSwitcher/components/LayerSettings.js +++ /dev/null @@ -1,146 +0,0 @@ -import React from "react"; -import VectorFilter from "./VectorFilter"; -import CQLFilter from "./CQLFilter"; -import Typography from "@mui/material/Typography"; -import Slider from "@mui/material/Slider"; -import { styled } from "@mui/material/styles"; - -const SettingsContainer = styled("div")(({ theme }) => ({ - overflow: "hidden", - paddingLeft: "45px", - paddingRight: "30px", - paddingBottom: "10px", - paddingTop: "10px", -})); - -const SliderContainer = styled("div")(({ theme }) => ({ - display: "flex", - flexFlow: "row nowrap", - alignItems: "center", -})); - -const SliderTextWrapper = styled("div")(({ theme }) => ({ - flex: "0 1 auto", - minWidth: "40px", -})); - -const SliderWrapper = styled("div")(({ theme }) => ({ - padding: "0 16px", - flex: "1 1 auto", - "& > span": { - top: "4px", - }, -})); - -class LayerSettings extends React.PureComponent { - constructor(props) { - super(props); - - const { layer } = props; - const layerInfo = layer.get("layerInfo"); - - this.state = { - opacityValue: layer.get("opacity"), - legend: layerInfo.legend, - }; - - // Ensure that state is updated when OL Layer's opacity changes - layer.on?.("change:opacity", this.updateOpacity); - } - - // Ensure that opacity slider's value gets updated when - // opacity is changed programmatically (e.g. via BreadCrumbs) - updateOpacity = (e) => { - const opacityValue = e.target.getOpacity(); - this.setState({ - opacityValue, - }); - }; - - renderOpacitySlider() { - const opacityValue = this.state.opacityValue; - return ( - - - Opacitet: - - - - - - - {Math.trunc(100 * opacityValue.toFixed(2))} % - - - - ); - } - - /* This function does two things: - * 1) it updates opacityValue, which is in state, - * and is important as uses it to set - * its internal value. - * 2) it changes OL layer's opacity - * - * As is set up to return a value between - * 0 and 1 and it has a step of 0.1, we don't have - * to worry about any conversion and rounding here. - * */ - opacitySliderChanged = (event, opacityValue) => { - this.props.layer.setOpacity(opacityValue); - }; - - toggle = (e) => { - this.setState({ - toggled: !this.state.toggled, - }); - }; - - renderSettings() { - return ( -
    - - {this.props.options?.enableTransparencySlider !== false && - this.props.showOpacity - ? this.renderOpacitySlider() - : null} - {this.props.showLegend ? this.renderLegendImage() : null} - {this.props.layer.getProperties().filterable ? ( - - ) : null} - {this.props.cqlFilterVisible && ( - - )} - -
    - ); - } - - renderLegendImage() { - const index = this.props.index ? this.props.index : 0; - const src = this.state.legend?.[index]?.url ?? ""; - - return src ? ( -
    - Teckenförklaring -
    - ) : null; - } - - render() { - return ( -
    -
    {this.props.toggled ? this.renderSettings() : null}
    -
    - ); - } -} - -export default LayerSettings; diff --git a/apps/client/src/plugins/LayerSwitcher/components/LegendIcon.js b/apps/client/src/plugins/LayerSwitcher/components/LegendIcon.js new file mode 100644 index 000000000..5817e1dcd --- /dev/null +++ b/apps/client/src/plugins/LayerSwitcher/components/LegendIcon.js @@ -0,0 +1,9 @@ +export default function LegendIcon({ url }) { + return ( + Teckenförklaringsikon + ); +} diff --git a/apps/client/src/plugins/LayerSwitcher/components/LegendImage.js b/apps/client/src/plugins/LayerSwitcher/components/LegendImage.js new file mode 100644 index 000000000..116b1a9a5 --- /dev/null +++ b/apps/client/src/plugins/LayerSwitcher/components/LegendImage.js @@ -0,0 +1,25 @@ +import { Collapse } from "@mui/material"; + +export default function LegendImage({ layerItemDetails, open, subLayerIndex }) { + const layerInfo = layerItemDetails.layer.get("layerInfo") || {}; + const index = subLayerIndex ? subLayerIndex : 0; + + // Check if layerInfo.legend is an array and has the required index + const src = + Array.isArray(layerInfo.legend) && layerInfo.legend.length > index + ? layerInfo.legend[index].url || "" + : ""; + + return src ? ( + +
    + Teckenförklaring +
    +
    + ) : null; +} diff --git a/apps/client/src/plugins/LayerSwitcher/components/QuickAccessLayers.js b/apps/client/src/plugins/LayerSwitcher/components/QuickAccessLayers.js new file mode 100644 index 000000000..e5d67d5e2 --- /dev/null +++ b/apps/client/src/plugins/LayerSwitcher/components/QuickAccessLayers.js @@ -0,0 +1,129 @@ +import React, { useCallback, useEffect, useState } from "react"; + +import LayerItem from "./LayerItem"; +import BackgroundLayer from "./BackgroundLayer"; +import GroupLayer from "./GroupLayer"; +import { Box } from "@mui/material"; + +export default function QuickAccessLayers({ + app, + map, + localObserver, + filterValue, + treeData, +}) { + // State that contains the layers that are currently visible + const [quickAccessLayers, setQuickAccessLayers] = useState([]); + const [, setForceState] = useState(false); + + // Function that forces a rerender of the component + const forceUpdate = () => setForceState((prevState) => !prevState); + + // Function that finds a layer by id in the treeData + const findLayerById = useCallback((groups, targetId) => { + for (const group of groups) { + for (const layer of group.layers) { + if (layer.id === targetId) { + return layer; + } + } + if (group.groups) { + const foundLayerInGroup = findLayerById(group.groups, targetId); + if (foundLayerInGroup) { + return foundLayerInGroup; + } + } + } + return null; + }, []); + + // A helper that grabs all OL layers with state quickAccess + const getQuickAccessLayers = useCallback(() => { + // Get all quickaccess layers + const layers = map.getAllLayers().filter((l) => { + return l.get("quickAccess") === true; + }); + if (filterValue === "") { + return layers; + } else { + // If filter is applied, only show layers that match the filter + return layers.filter((l) => { + return l + .get("caption") + .toLocaleLowerCase() + .includes(filterValue.toLocaleLowerCase()); + }); + } + }, [map, filterValue]); + + // On component mount, update the list and subscribe to events + useEffect(() => { + // Register a listener: when any layer's quickaccess flag changes make sure + // to update the list. + const quickAccessChangedSubscription = app.globalObserver.subscribe( + "core.layerQuickAccessChanged", + (l) => { + if (l.target.get("quickAccess") === true) { + // We force update when a layer changed visibility to + // be able to sync togglebuttons in GUI + l.target.on("change:visible", forceUpdate); + } else { + // Remove listener when layer is removed from quickaccess + l.target.un("change:visible", forceUpdate); + } + setQuickAccessLayers(getQuickAccessLayers()); + } + ); + // Update list of layers + setQuickAccessLayers(getQuickAccessLayers()); + // Unsubscribe when component unmounts + return function () { + quickAccessChangedSubscription.unsubscribe(); + }; + }, [app.globalObserver, getQuickAccessLayers]); + + return ( + + quickAccessLayers.length > 0 + ? `${theme.spacing(0.2)} solid ${theme.palette.divider}` + : "none", + }} + > + {quickAccessLayers.map((l) => { + return l.get("layerType") === "base" ? ( + + ) : l.get("layerType") === "group" ? ( + + ) : ( + + ); + })} + + ); +} diff --git a/apps/client/src/plugins/LayerSwitcher/components/QuickAccessOptions.js b/apps/client/src/plugins/LayerSwitcher/components/QuickAccessOptions.js new file mode 100644 index 000000000..bda0ddc8a --- /dev/null +++ b/apps/client/src/plugins/LayerSwitcher/components/QuickAccessOptions.js @@ -0,0 +1,87 @@ +import * as React from "react"; + +import { + IconButton, + ListItemIcon, + ListItemText, + Menu, + MenuItem, + Tooltip, +} from "@mui/material"; + +import MoreVertOutlinedIcon from "@mui/icons-material/MoreVertOutlined"; +import AddOutlinedIcon from "@mui/icons-material/AddOutlined"; +import DeleteOutlinedIcon from "@mui/icons-material/DeleteOutlined"; + +export default function QuickAccessOptions({ + handleAddLayersToQuickAccess, + handleClearQuickAccessLayers, +}) { + // Element that we will anchor the options menu to is + // held in state. If it's null (unanchored), we can tell + // that the menu should be hidden. + const [anchorEl, setAnchorEl] = React.useState(null); + + const optionsMenuIsOpen = Boolean(anchorEl); + + // Show the options menu by setting an anchor element + const handleShowMoreOptionsClick = (e) => { + e.stopPropagation(); + e.preventDefault(); + setAnchorEl(e.currentTarget); + }; + + // Hides the options menu by resetting the anchor element + const onOptionsMenuClose = (e) => { + e.stopPropagation(); + e.preventDefault(); + setAnchorEl(null); + }; + + return ( + <> + + + + + + + { + e.stopPropagation(); + setAnchorEl(null); + handleAddLayersToQuickAccess(e); + }} + > + + + + Lägg till tända lager + + { + e.stopPropagation(); + setAnchorEl(null); + handleClearQuickAccessLayers(e); + }} + > + + + + Rensa allt + + + + ); +} diff --git a/apps/client/src/plugins/LayerSwitcher/components/QuickAccessView.js b/apps/client/src/plugins/LayerSwitcher/components/QuickAccessView.js new file mode 100644 index 000000000..c03cb8d53 --- /dev/null +++ b/apps/client/src/plugins/LayerSwitcher/components/QuickAccessView.js @@ -0,0 +1,200 @@ +import React, { useState } from "react"; +import { withSnackbar } from "notistack"; +// import { createPortal } from "react-dom"; + +// import { styled } from "@mui/material/styles"; +import { + Box, + IconButton, + ListItemText, + ListItemButton, + ListItemSecondaryAction, + Collapse, +} from "@mui/material"; + +import ConfirmationDialog from "../../../components/ConfirmationDialog.js"; +import HajkTooltip from "../../../components/HajkToolTip.js"; +import QuickAccessLayers from "./QuickAccessLayers.js"; +import QuickAccessOptions from "./QuickAccessOptions.js"; +import Favorites from "./Favorites/Favorites.js"; + +import StarOutlineOutlinedIcon from "@mui/icons-material/StarOutlineOutlined"; +import TopicOutlinedIcon from "@mui/icons-material/TopicOutlined"; +import KeyboardArrowRightOutlinedIcon from "@mui/icons-material/KeyboardArrowRightOutlined"; + +const QuickAccessView = ({ + show, + map, // A OpenLayers map instance + app, + localObserver, + globalObserver, + enableQuickAccessTopics, // : boolean + enableUserQuickAccessFavorites, + handleLayerPackageToggle, + favoritesViewDisplay, + handleFavoritesViewToggle, + favoritesInfoText, + treeData, + filterValue, + enqueueSnackbar, +}) => { + // TODO This iterates on all OL layers every render, that can be optimized + const hasVisibleLayers = + map + .getAllLayers() + .filter((l) => l.get("quickAccess") === true && l.get("visible") === true) + .length > 0; + + const [quickAccessSectionExpanded, setQuickAccessSectionExpanded] = + useState(false); + + const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(false); + + // Handles click on clear quickAccess menu item + const handleShowDeleteConfirmation = (e) => { + e.stopPropagation(); + setShowDeleteConfirmation(true); + }; + + // Handles click on confirm clear quickAccess button + const handleClearQuickAccessLayers = () => { + setShowDeleteConfirmation(false); + map + .getAllLayers() + .filter((l) => l.get("quickAccess") === true) + .map((l) => l.set("quickAccess", false)); + }; + + // Handles click on AddLayersToQuickAccess menu item + const handleAddLayersToQuickAccess = (e) => { + e.stopPropagation(); + // Add visible layers to quickAccess section + map + .getAllLayers() + .filter( + (l) => + l.get("visible") === true && + l.get("layerType") !== "base" && + l.get("layerType") !== "system" + ) + .map((l) => l.set("quickAccess", true)); + + // Show snackbar + enqueueSnackbar && + enqueueSnackbar(`Tända lager har nu lagts till i snabbåtkomst.`, { + variant: "success", + anchorOrigin: { vertical: "bottom", horizontal: "center" }, + }); + // Expand quickAccess section + setQuickAccessSectionExpanded(true); + }; + + return ( + + `${theme.spacing(0.2)} solid ${theme.palette.divider}`, + }} + > + + setQuickAccessSectionExpanded(!quickAccessSectionExpanded) + } + sx={{ + p: 0, + }} + dense + > + + + + `${theme.spacing(0.2)} solid transparent`, + }} + > + + + + + + + + {enableQuickAccessTopics && ( + + + + + + )} + {enableUserQuickAccessFavorites && ( + { + setQuickAccessSectionExpanded(true); + }} + > + )} + + + + + + + + + + + { + setShowDeleteConfirmation(false); + }} + /> + + ); +}; + +export default withSnackbar(QuickAccessView); diff --git a/apps/client/src/plugins/LayerSwitcher/components/SubLayerItem.js b/apps/client/src/plugins/LayerSwitcher/components/SubLayerItem.js new file mode 100644 index 000000000..d3ea4e605 --- /dev/null +++ b/apps/client/src/plugins/LayerSwitcher/components/SubLayerItem.js @@ -0,0 +1,138 @@ +import React, { useState } from "react"; + +import { + IconButton, + ListItemButton, + ListItemSecondaryAction, + ListItemText, + Tooltip, +} from "@mui/material"; + +import LegendIcon from "./LegendIcon"; +import LegendImage from "./LegendImage"; +import CheckBoxOutlineBlankIcon from "@mui/icons-material/CheckBoxOutlineBlank"; +import CheckBoxIcon from "@mui/icons-material/CheckBox"; +import KeyboardArrowRightOutlinedIcon from "@mui/icons-material/KeyboardArrowRightOutlined"; +import FormatListBulletedOutlinedIcon from "@mui/icons-material/FormatListBulletedOutlined"; + +export default function SubLayerItem({ + layer, + subLayer, + toggleable, + app, + display, + visible, + toggleSubLayer, + subLayerIndex, + zoomVisible, +}) { + // State that toggles legend collapse + const [legendIsActive, setLegendIsActive] = useState(false); + // Render method for checkbox icon + const getLayerToggleIcon = () => { + return visible ? ( + + !zoomVisible ? theme.palette.warning.dark : theme.palette.primary, + }} + /> + ) : ( + + ); + }; + + // Show layer details action + const showLayerDetails = (e) => { + e.stopPropagation(); + app.globalObserver.publish("setLayerDetails", { + layer: layer, + subLayerIndex: subLayerIndex, + }); + }; + + // Render method for legend icon + const getIconFromLayer = () => { + if (layer.layersInfo[subLayer].legendIcon) { + return ; + } + return renderLegendIcon(); + }; + + const renderLegendIcon = () => { + return ( + + { + e.stopPropagation(); + setLegendIsActive(!legendIsActive); + }} + > + + + + ); + }; + + return ( +
    + (toggleable ? toggleSubLayer(subLayer, visible) : null)} + sx={{ + pl: 0, + borderBottom: (theme) => + toggleable + ? `${theme.spacing(0.2)} solid ${theme.palette.divider}` + : "none", + }} + dense + > + {toggleable && ( + + {getLayerToggleIcon()} + + )} + {getIconFromLayer()} + + + showLayerDetails(e)}> + theme.palette.grey[500], + }} + > + + + + {layer.layersInfo[subLayer].legendIcon ? null : ( + + )} +
    + ); +} diff --git a/apps/client/src/plugins/LayerSwitcher/components/VectorFilter.js b/apps/client/src/plugins/LayerSwitcher/components/VectorFilter.js index 32fd9f3ff..9845e97ce 100644 --- a/apps/client/src/plugins/LayerSwitcher/components/VectorFilter.js +++ b/apps/client/src/plugins/LayerSwitcher/components/VectorFilter.js @@ -1,43 +1,31 @@ -import React from "react"; -import { styled } from "@mui/material/styles"; -import Select from "@mui/material/Select"; -import MenuItem from "@mui/material/MenuItem"; -import InputLabel from "@mui/material/InputLabel"; -import FormControl from "@mui/material/FormControl"; -import Input from "@mui/material/Input"; -import Button from "@mui/material/Button"; -import Typography from "@mui/material/Typography"; -import { Vector as VectorLayer } from "ol/layer"; -import { hfetch } from "../../../utils/FetchWrapper"; - -const StyledFormControl = styled(FormControl)(({ theme }) => ({ - margin: theme.spacing(1), - minWidth: 120, -})); - -const StyledTypography = styled(Typography)(({ theme }) => ({ - fontWeight: 500, -})); - -class VectorFilter extends React.PureComponent { - constructor(props) { - super(props); - this.state = { - filterAttribute: props.layer.get("filterAttribute") || "", - filterValue: props.layer.get("filterValue") || "", - filterComparer: props.layer.get("filterComparer") || "", - layerProperties: [], - }; - this.loadFeatureInfo(); - } +import React, { useState, useEffect, useCallback } from "react"; + +import { + Button, + Chip, + Input, + MenuItem, + Select, + Typography, + Stack, +} from "@mui/material"; + +import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; + +import { hfetch } from "utils/FetchWrapper"; + +function VectorFilter({ layer }) { + const [filterAttribute, setFilterAttribute] = useState(""); + const [filterValue, setFilterValue] = useState(""); + const [filterComparer, setFilterComparer] = useState(""); + const [currentFilter, setCurrentFilter] = useState(""); + const [layerProperties, setLayerProperties] = useState([]); /** - * @summary Prepare entries for dropdown, will contain possible values for filterAttribute. - * - * @memberof VectorFilter + * Prepare entries for dropdown, will contain possible values for filterAttribute. */ - loadFeatureInfo = () => { - const { url, featureType } = this.props.layer.getProperties(); + const loadFeatureInfo = useCallback(() => { + const { url, featureType } = layer.getProperties(); hfetch( url + `?service=WFS&request=describeFeatureType&outputFormat=application/json&typename=${featureType}` @@ -50,74 +38,156 @@ class VectorFilter extends React.PureComponent { const layerProperties = featureTypeInfo.properties .filter((property) => property.type !== "gml:Geometry") .map((property) => property.name); - this.setState({ - layerProperties, - }); + setLayerProperties(layerProperties); } }); }); + }, [layer, setLayerProperties]); + + /** + * Load filter options from layer and set state. + */ + useEffect(() => { + setFilterAttribute(layer.get("filterAttribute") || ""); + setFilterValue(layer.get("filterValue") || ""); + setFilterComparer(layer.get("filterComparer") || ""); + if ( + layer.get("filterAttribute") && + layer.get("filterComparer") && + layer.get("filterValue") + ) { + setCurrentFilter( + `${layer.get("filterAttribute")} ${translateComparer( + layer.get("filterComparer") + )} ${layer.get("filterValue")}` + ); + } else { + setCurrentFilter(""); + } + loadFeatureInfo(); + }, [layer, loadFeatureInfo]); + + /** + * Translates the comparer to a more human readable format + **/ + const translateComparer = (comparer) => { + switch (comparer) { + case "gt": + return ">"; + case "lt": + return "<"; + case "eq": + return "="; + case "not": + return "≠"; + default: + return comparer; + } }; - handleChange = (e) => { - this.setState({ - [e.target.name]: e.target.value, - }); + /** + * Handles change of filter options + */ + const handleChange = (e, type) => { + switch (type) { + case "attribute": + setFilterAttribute(e.target.value); + break; + case "comparer": + setFilterComparer(e.target.value); + break; + case "value": + setFilterValue(e.target.value); + break; + default: + break; + } }; /** - * @summary Reads filter options from state, applies them on layer and refreshes the source. - * - * @memberof VectorFilter + * Reads filter options from state, applies them on layer and refreshes the source. */ - setFilter = (e) => { - this.props.layer.set("filterAttribute", this.state.filterAttribute); - this.props.layer.set("filterComparer", this.state.filterComparer); - this.props.layer.set("filterValue", this.state.filterValue); + const setFilter = () => { + layer.set("filterAttribute", filterAttribute); + layer.set("filterComparer", filterComparer); + layer.set("filterValue", filterValue); + + setCurrentFilter( + `${filterAttribute} ${translateComparer(filterComparer)} ${filterValue}` + ); - this.props.layer.getSource().refresh(); + layer.getSource().refresh(); }; /** - * @ Resets the UI to no filter and reloads the source - * - * @memberof VectorFilter + * Resets the UI to no filter and reloads the source */ - resetFilter = (e) => { + const resetFilter = () => { // Reset the UI - this.setState({ - filterAttribute: "", - filterValue: "", - filterComparer: "", - }); + setCurrentFilter(""); + setFilterAttribute(""); + setFilterComparer(""); + setFilterValue(""); // Reset filter options on layer - this.props.layer.set("filterAttribute", ""); - this.props.layer.set("filterComparer", ""); - this.props.layer.set("filterValue", ""); + layer.set("filterAttribute", ""); + layer.set("filterComparer", ""); + layer.set("filterValue", ""); // Refresh source - this.props.layer.getSource().refresh(); + layer.getSource().refresh(); + }; + + /** + * Enables the activate button if both attribute and comparer are set. + */ + const enableActivateButton = () => { + if (filterAttribute !== "" && filterComparer !== "") { + return true; + } + return false; }; - render() { - const { layer } = this.props; - if (layer instanceof VectorLayer) { - return ( + return ( + <> + {currentFilter ? ( + setCurrentFilter("")} + deleteIcon={} + /> + ) : ( <> - - Filtrera innehåll baserat på attribut - - - Attribut + + + Attribut + - - - Jämförare + + + + Operator + - - + + + + Värde + handleChange(e, "value")} placeholder="Filtervärde" + fullWidth + size="small" inputProps={{ name: "filterValue", "aria-label": "Värde", }} /> - - - - + + + + + - ); - } else { - return null; - } - } + )} + + ); } export default VectorFilter;