diff --git a/CHANGES.md b/CHANGES.md index 286cdadb..63ca1db1 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -24,6 +24,11 @@ * Made the right sidebar panel's tab bar position sticky. (#373) +* It is now possible to change the color and opacity of user places + and hence associated timeseries and statistic charts. (#216, #97) + +* Improved visual style of selected places in the map. + ### Fixes * Fixed a problem that caused categorical map legends to list the categories diff --git a/src/actions/dataActions.tsx b/src/actions/dataActions.tsx index c2fbb825..96d0b43d 100644 --- a/src/actions/dataActions.tsx +++ b/src/actions/dataActions.tsx @@ -32,7 +32,12 @@ import i18n from "@/i18n"; import { ApiServerConfig, ApiServerInfo } from "@/model/apiServer"; import { ColorBar, ColorBars } from "@/model/colorBar"; import { Dataset, getDatasetUserVariables } from "@/model/dataset"; -import { findPlaceInPlaceGroups, Place, PlaceGroup } from "@/model/place"; +import { + findPlaceInPlaceGroups, + Place, + PlaceGroup, + PlaceStyle, +} from "@/model/place"; import { getUserPlacesFromCsv } from "@/model/user-place/csv"; import { getUserPlacesFromGeoJson } from "@/model/user-place/geojson"; import { getUserPlacesFromWkt } from "@/model/user-place/wkt"; @@ -81,7 +86,7 @@ import { } from "./controlActions"; import { VolumeRenderMode } from "@/states/controlState"; import { MessageLogAction, postMessage } from "./messageLogActions"; -import { renameUserPlaceInLayer } from "./mapActions"; +import { renameUserPlaceInLayer, restyleUserPlaceInLayer } from "./mapActions"; import { ColorBarNorm } from "@/model/variable"; import { getStatistics } from "@/api/getStatistics"; import { StatisticsRecord } from "@/model/statistics"; @@ -447,6 +452,36 @@ export function _renameUserPlace( //////////////////////////////////////////////////////////////////////////////// +export const RESTYLE_USER_PLACE = "RESTYLE_USER_PLACE"; + +export interface RestyleUserPlace { + type: typeof RESTYLE_USER_PLACE; + placeGroupId: string; + placeId: string; + placeStyle: PlaceStyle; +} + +export function restyleUserPlace( + placeGroupId: string, + placeId: string, + placeStyle: PlaceStyle, +) { + return (dispatch: Dispatch) => { + dispatch(_restyleUserPlace(placeGroupId, placeId, placeStyle)); + restyleUserPlaceInLayer(placeGroupId, placeId, placeStyle); + }; +} + +export function _restyleUserPlace( + placeGroupId: string, + placeId: string, + placeStyle: PlaceStyle, +): RestyleUserPlace { + return { type: RESTYLE_USER_PLACE, placeGroupId, placeId, placeStyle }; +} + +//////////////////////////////////////////////////////////////////////////////// + export const REMOVE_USER_PLACE = "REMOVE_USER_PLACE"; export interface RemoveUserPlace { @@ -530,34 +565,6 @@ export function addStatistics() { }; } -// const addStatisticsFromMock = () => { -// const i = statisticsRecords.length + 1; -// setStatisticsRecords([ -// ...statisticsRecords, -// { -// source: { -// datasetId: `ds${i}`, -// datasetTitle: `Dataset ${i}`, -// variableName: "CHL", -// placeId: "p029840456", -// geometry: null, -// }, -// minimum: i, -// maximum: i + 1, -// mean: i + 0.5, -// standardDev: 0.1 * i, -// histogram: { -// bins: Array.from({ length: 100 }, (_, index) => ({ -// x1: index, -// xc: index + 0.5, -// x2: index + 1, -// count: 1000 * Math.random(), -// })), -// }, -// }, -// ]); -// }; - export const ADD_STATISTICS = "ADD_STATISTICS"; export interface AddStatistics { @@ -1206,6 +1213,7 @@ export type DataAction = | AddImportedUserPlaces | RenameUserPlaceGroup | RenameUserPlace + | RestyleUserPlace | RemoveUserPlace | RemoveUserPlaceGroup | AddStatistics diff --git a/src/actions/mapActions.tsx b/src/actions/mapActions.tsx index 63f0c934..02cc8181 100644 --- a/src/actions/mapActions.tsx +++ b/src/actions/mapActions.tsx @@ -24,6 +24,8 @@ import { default as OlMap } from "ol/Map"; import { Geometry as OlGeometry } from "ol/geom"; +import { Vector as OlVectorLayer } from "ol/layer"; +import { Vector as OlVectorSource } from "ol/source"; import { fromExtent } from "ol/geom/Polygon"; import { Extent as OlExtent } from "ol/extent"; import { getCenter } from "ol/extent"; @@ -31,6 +33,8 @@ import { default as OlSimpleGeometry } from "ol/geom/SimpleGeometry"; import { GEOGRAPHIC_CRS } from "@/model/proj"; import { MAP_OBJECTS } from "@/states/controlState"; +import { PlaceStyle } from "@/model/place"; +import { setFeatureStyle } from "@/components/ol/style"; // noinspection JSUnusedLocalSymbols export function renameUserPlaceInLayer( @@ -47,6 +51,24 @@ export function renameUserPlaceInLayer( } } +export function restyleUserPlaceInLayer( + placeGroupId: string, + placeId: string, + placeStyle: PlaceStyle, +) { + if (MAP_OBJECTS[placeGroupId]) { + const userLayer = MAP_OBJECTS[ + placeGroupId + ] as OlVectorLayer; + const source = userLayer.getSource(); + const feature = source?.getFeatureById(placeId); + if (feature) { + // console.log("selected feature:", feature, placeStyle); + setFeatureStyle(feature, placeStyle.color, placeStyle.opacity); + } + } +} + export function locateInMap( mapId: string, location: OlGeometry | OlExtent, diff --git a/src/components/PlaceSelect.tsx b/src/components/PlaceSelect.tsx index 140f6140..361be556 100644 --- a/src/components/PlaceSelect.tsx +++ b/src/components/PlaceSelect.tsx @@ -22,21 +22,23 @@ * SOFTWARE. */ -import * as React from "react"; +import { useState, MouseEvent } from "react"; +import { SxProps } from "@mui/system"; import Input from "@mui/material/Input"; import MenuItem from "@mui/material/MenuItem"; import Select, { SelectChangeEvent } from "@mui/material/Select"; -import { SxProps } from "@mui/system"; import EditIcon from "@mui/icons-material/Edit"; +import FormatColorFillIcon from "@mui/icons-material/FormatColorFill"; import RemoveCircleOutlineIcon from "@mui/icons-material/RemoveCircleOutline"; import TravelExploreIcon from "@mui/icons-material/TravelExplore"; import i18n from "@/i18n"; import { Dataset } from "@/model/dataset"; -import { Place, USER_ID_PREFIX } from "@/model/place"; +import { Place, PlaceInfo, PlaceStyle, USER_ID_PREFIX } from "@/model/place"; import { WithLocale } from "@/util/lang"; import EditableSelect from "./EditableSelect"; import ToolButton from "./ToolButton"; +import PlaceStyleEditor from "@/components/PlaceStyleEditor"; // noinspection JSUnusedLocalSymbols const styles: Record = { @@ -49,6 +51,7 @@ interface PlaceSelectProps extends WithLocale { datasets: Dataset[]; selectedPlaceGroupIds: string[] | null; selectedPlaceId: string | null; + selectedPlaceInfo: PlaceInfo | null; places: Place[]; placeLabels: string[]; selectPlace: ( @@ -61,6 +64,11 @@ interface PlaceSelectProps extends WithLocale { placeId: string, placeName: string, ) => void; + restyleUserPlace: ( + placeGroupId: string, + placeId: string, + placeStyle: PlaceStyle, + ) => void; removeUserPlace: ( placeGroupId: string, placeId: string, @@ -75,12 +83,15 @@ export default function PlaceSelect({ placeLabels, selectedPlaceId, selectedPlaceGroupIds, + selectedPlaceInfo, renameUserPlace, + restyleUserPlace, removeUserPlace, places, locateSelectedPlace, }: PlaceSelectProps) { - const [editMode, setEditMode] = React.useState(false); + const [editMode, setEditMode] = useState(false); + const [styleAnchorEl, setStyleAnchorEl] = useState(null); places = places || []; placeLabels = placeLabels || []; @@ -97,6 +108,10 @@ export default function PlaceSelect({ renameUserPlace(selectedPlaceGroupId!, selectedPlaceId!, placeName); }; + const updatePlaceStyle = (placeStyle: PlaceStyle) => { + restyleUserPlace(selectedPlaceGroupId!, selectedPlaceId!, placeStyle); + }; + const handlePlaceChange = (event: SelectChangeEvent) => { selectPlace(event.target.value || null, places, true); }; @@ -129,12 +144,24 @@ export default function PlaceSelect({ selectedPlaceGroupId.startsWith(USER_ID_PREFIX) && selectedPlaceId !== ""; - let actions; + let actions = [ + } + />, + ]; + if (!editMode && isEditableUserPlace) { const handleEditButtonClick = () => { setEditMode(true); }; + const handleStyleButtonClick = (event: MouseEvent) => { + setStyleAnchorEl(event.currentTarget); + }; + const handleRemoveButtonClick = () => { removeUserPlace(selectedPlaceGroupId!, selectedPlaceId!, places); }; @@ -146,31 +173,42 @@ export default function PlaceSelect({ tooltipText={i18n.get("Rename place")} icon={} />, + } + />, } />, - } - />, - ]; + ].concat(actions); } return ( - v.trim().length > 0} - editMode={editMode} - setEditMode={setEditMode} - labelText={i18n.get("Place")} - select={select} - actions={actions} - /> + <> + v.trim().length > 0} + editMode={editMode} + setEditMode={setEditMode} + labelText={i18n.get("Place")} + select={select} + actions={actions} + /> + {selectedPlaceInfo && ( + + )} + ); } diff --git a/src/components/PlaceStyleEditor/PlaceStyleEditor.tsx b/src/components/PlaceStyleEditor/PlaceStyleEditor.tsx new file mode 100644 index 00000000..b3133762 --- /dev/null +++ b/src/components/PlaceStyleEditor/PlaceStyleEditor.tsx @@ -0,0 +1,161 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2019-2024 by the xcube development team and contributors. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is furnished to do + * so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { WithLocale } from "@/util/lang"; +import { makeStyles } from "@/util/styles"; +import Popover from "@mui/material/Popover"; +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import i18n from "@/i18n"; +import Slider from "@mui/material/Slider"; +import { PlaceStyle } from "@/model/place"; +import { MouseEvent, useState } from "react"; +import Menu from "@mui/material/Menu"; +import MenuItem from "@mui/material/MenuItem"; +import { userPlaceColorsArray } from "@/config"; +import Tooltip from "@mui/material/Tooltip"; + +const styles = makeStyles({ + container: { + display: "grid", + gridTemplateColumns: "auto 120px", + gridTemplateRows: "auto", + gridTemplateAreas: "'colorLabel colorValue' 'opacityLabel opacityValue'", + rowGap: 1, + columnGap: 2.5, + padding: 1, + }, + colorLabel: { + gridArea: "colorLabel", + alignSelf: "center", + }, + colorValue: { + gridArea: "colorValue", + alignSelf: "center", + width: "100%", + height: "22px", + borderWidth: 1, + borderStyle: "solid", + borderColor: "black", + }, + opacityLabel: { + gridArea: "opacityLabel", + alignSelf: "center", + }, + opacityValue: { + gridArea: "opacityValue", + alignSelf: "center", + width: "100%", + }, + colorMenuItem: { padding: "4px 8px 4px 8px" }, + colorMenuItemBox: { width: "104px", height: "18px" }, +}); + +interface PlaceStyleEditorProps extends WithLocale { + anchorEl: HTMLElement | null; + setAnchorEl: (anchorEl: HTMLElement | null) => void; + isPoint: boolean; + placeStyle: PlaceStyle; + updatePlaceStyle: (placeStyle: PlaceStyle) => void; +} + +const PlaceStyleEditor = ({ + anchorEl, + setAnchorEl, + isPoint, + placeStyle, + updatePlaceStyle, +}: PlaceStyleEditorProps) => { + const [menuAnchorEl, setMenuAnchorEl] = useState(null); + + function handleColorClick(event: MouseEvent) { + setMenuAnchorEl(event.currentTarget); + } + + return ( + <> + setAnchorEl(null)} + anchorOrigin={{ vertical: "bottom", horizontal: "left" }} + transformOrigin={{ vertical: "top", horizontal: "left" }} + > + + {i18n.get("Color")} + + {i18n.get("Opacity")} + + + + updatePlaceStyle({ ...placeStyle, opacity: value as number }) + } + /> + + + setMenuAnchorEl(null)} + > + {userPlaceColorsArray.map(([colorName, _]) => ( + + updatePlaceStyle({ ...placeStyle, color: colorName }) + } + > + + + + + ))} + + + ); +}; + +export default PlaceStyleEditor; diff --git a/src/components/PlaceStyleEditor/index.tsx b/src/components/PlaceStyleEditor/index.tsx new file mode 100644 index 00000000..61b17aaa --- /dev/null +++ b/src/components/PlaceStyleEditor/index.tsx @@ -0,0 +1,27 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2019-2024 by the xcube development team and contributors. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is furnished to do + * so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import PlaceStyleEditor from "./PlaceStyleEditor"; + +export default PlaceStyleEditor; diff --git a/src/components/UserVectorLayer.tsx b/src/components/UserVectorLayer.tsx index e09a52cf..80dc6d33 100644 --- a/src/components/UserVectorLayer.tsx +++ b/src/components/UserVectorLayer.tsx @@ -26,7 +26,7 @@ import * as React from "react"; import { default as OlVectorSource } from "ol/source/Vector"; import { default as OlGeoJSONFormat } from "ol/format/GeoJSON"; -import { Config } from "@/config"; +import { getUserPlaceFillOpacity } from "@/config"; import { PlaceGroup, USER_DRAWN_PLACE_GROUP_ID } from "@/model/place"; import { Vector } from "./ol/layer/Vector"; import { setFeatureStyle } from "./ol/style"; @@ -69,13 +69,14 @@ const UserVectorLayer: React.FC = ({ feature.setId(place.id); } const color = (place.properties || {}).color || "red"; + const opacity = (place.properties || {}).opacity; const pointSymbol = (place.properties || {}).source ? "diamond" : "circle"; setFeatureStyle( feature, color, - Config.instance.branding.polygonFillOpacity, + getUserPlaceFillOpacity(opacity), pointSymbol, ); source.addFeature(feature); @@ -88,7 +89,7 @@ const UserVectorLayer: React.FC = ({ id={placeGroup.id} opacity={placeGroup.id === USER_DRAWN_PLACE_GROUP_ID ? 1 : 0.8} visible={visible} - zIndex={500} + zIndex={501} source={sourceRef.current} /> ); diff --git a/src/components/Viewer.tsx b/src/components/Viewer.tsx index c4e9efd4..5f33a62c 100644 --- a/src/components/Viewer.tsx +++ b/src/components/Viewer.tsx @@ -42,7 +42,11 @@ import { default as OlStyle } from "ol/style/Style"; import { getRenderPixel } from "ol/render"; import i18n from "@/i18n"; -import { Config, getUserPlaceColor, getUserPlaceColorName } from "@/config"; +import { + getUserPlaceColor, + getUserPlaceColorName, + getUserPlaceFillOpacity, +} from "@/config"; import { Place, PlaceGroup, @@ -78,19 +82,30 @@ const COLOR_LEGEND_STYLE: React.CSSProperties = { top: 10, }; +const SELECTION_COLOR = [255, 220, 0, 0.8]; + const SELECTION_LAYER_STROKE = new OlStrokeStyle({ - color: [255, 200, 0, 1.0], - width: 3, + color: SELECTION_COLOR, + width: 10, + lineCap: "square", + lineDash: [10, 15], }); + const SELECTION_LAYER_FILL = new OlFillStyle({ - color: [255, 200, 0, 0.05], + color: [0, 0, 0, 0], }); + const SELECTION_LAYER_STYLE = new OlStyle({ stroke: SELECTION_LAYER_STROKE, fill: SELECTION_LAYER_FILL, image: new OlCircleStyle({ - radius: 10, - stroke: SELECTION_LAYER_STROKE, + radius: 15, + stroke: new OlStrokeStyle({ + color: SELECTION_COLOR, + width: 6, + lineCap: "square", + lineDash: [6, 6], + }), fill: SELECTION_LAYER_FILL, }), }); @@ -320,11 +335,7 @@ export default function Viewer({ const label = findNextLabel(userPlaceGroups, mapInteraction); const color = getUserPlaceColorName(colorIndex); const shadedColor = getUserPlaceColor(color, theme.palette.mode); - setFeatureStyle( - feature, - shadedColor, - Config.instance.branding.polygonFillOpacity, - ); + setFeatureStyle(feature, shadedColor, getUserPlaceFillOpacity()); addDrawnUserPlace( userDrawnPlaceGroupName, @@ -386,6 +397,13 @@ export default function Viewer({ {variableLayer} {overlayLayer} {datasetBoundaryLayer} + { <> {userPlaceGroups.map((placeGroup) => ( @@ -400,13 +418,6 @@ export default function Viewer({ ))} } - {placeGroupLayers} {/*