diff --git a/package.json b/package.json index 48c511c3a..26162b02a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "biotablero", - "version": "1.7.0", + "version": "1.8.0", "scripts": { "start": "react-scripts start", "build": "react-scripts build", @@ -13,9 +13,11 @@ "@material-ui/icons": "^4.9.1", "@material-ui/lab": "^4.0.0-alpha.57", "@nivo/bar": "^0.69.0", + "@nivo/bullet": "^0.69.1", "@nivo/core": "^0.69.0", "@nivo/line": "^0.69.0", "@nivo/pie": "^0.69.0", + "@nivo/tooltip": "^0.69.0", "@vx/axis": "0.0.170", "@vx/glyph": "0.0.170", "@vx/grid": "0.0.170", @@ -35,6 +37,7 @@ "react-masonry-component": "6.2.1", "react-router-dom": "5.2.0", "react-scripts": "4.0.2", + "react-spring": "^9.1.2", "styled-components": "^5.2.3" }, "devDependencies": { diff --git a/src/app/layout/Footer.jsx b/src/app/layout/Footer.jsx index d767ce842..7c57ad949 100644 --- a/src/app/layout/Footer.jsx +++ b/src/app/layout/Footer.jsx @@ -6,6 +6,7 @@ import nasa from 'images/nasa.png'; import temple from 'images/temple.png'; import geobon from 'images/geobonlogo.png'; import usaid from 'images/usaidlogo.png'; +import umed from 'images/umed.png'; const logosData = { nasa: { img: nasa, url: 'https://www.nasa.gov/' }, @@ -13,11 +14,12 @@ const logosData = { siac: { img: logoSiac, url: 'http://www.siac.gov.co/siac.html' }, geobon: { img: geobon, url: 'https://geobon.org/' }, usaid: { img: usaid, url: 'https://www.usaid.gov/' }, + umed: { img: umed, url: 'https://udemedellin.edu.co/' }, }; const logoSet = { default: ['nasa', 'temple', 'siac'], - monitoreo: ['usaid', 'geobon', 'temple'], + monitoreo: ['usaid', 'geobon', 'umed', 'temple'], }; const Footer = ( diff --git a/src/components/CssIcons.jsx b/src/components/CssIcons.jsx new file mode 100644 index 000000000..268e1283f --- /dev/null +++ b/src/components/CssIcons.jsx @@ -0,0 +1,16 @@ +import styled from 'styled-components'; + +const Icon = styled.div` + background: ${({ image }) => `url(${image}) no-repeat center center`}; + width: 25px; + height: 27px; + display: inline-block; + + &:hover { + background: ${({ hoverImage }) => `url(${hoverImage}) no-repeat center center`}; + width: 25px; + height: 27px; + } +`; + +export default Icon; diff --git a/src/components/CssLegends.jsx b/src/components/CssLegends.jsx index e48c1a316..5a0f64622 100644 --- a/src/components/CssLegends.jsx +++ b/src/components/CssLegends.jsx @@ -9,33 +9,93 @@ const Legend = styled.p` color: #424242; line-height: 1; margin-right: 10px; +`; +const PointLegend = styled(Legend)` &:before { display: inline-block; content: ""; width: 12px; height: 12px; - margin-right: 5px; + margin-right: ${(props) => (props.marginRight ? props.marginRight : '5px')}; border-radius: 6px; vertical-align: middle; + margin-left: ${(props) => (props.marginLeft ? props.marginLeft : '0')}; } `; -const LegendColor = styled(Legend)` +const LegendColor = styled(PointLegend)` &:before { background-color: ${({ color }) => color}; } `; -const BorderLegendColor = styled(Legend)` +const BorderLegendColor = styled(PointLegend)` &:before { color: #ffffff; border: 2px solid ${({ color }) => color}; width: 7px; height: 7px; border-radius: 0; - vertical-align: bottom; } `; -export { LegendColor, BorderLegendColor }; +const LineLegend = styled(Legend)` + &:before { + display: inline-block; + content: ""; + width: 15px; + height: 3px; + margin-right: 5px; + margin-bottom: 4px; + border-bottom: 3px solid ${({ color }) => color}; + vertical-align: middle; + } +`; + +const ThickLineLegend = styled(LineLegend)` + &:before { + border-bottom: 8px solid ${({ color }) => color}; + height: 0px; + } +`; + +const TextLegend = styled(Legend)` + margin-right: 1px; + padding-bottom: 3px; + color: ${({ color }) => color}; + + &:before { + display: inline-block; + content: ""; + background: ${({ image }) => (image ? `url(${image}) no-repeat center` : '')}; + background-size: 15px; + width: 15px; + height: 26px; + margin-right: 5px; + vertical-align: middle; + } + + &:hover { + cursor: pointer; + } + + &:hover:before { + background: ${({ hoverImage }) => (hoverImage ? `url(${hoverImage}) no-repeat center` : '')}; + background-size: 15px; + width: 15px; + height: 26px; + } + + &.filtered { + border-bottom: 2px solid tomato; + } +`; + +export { + LegendColor, + BorderLegendColor, + LineLegend, + ThickLineLegend, + TextLegend, +}; diff --git a/src/components/GradientLegend.jsx b/src/components/GradientLegend.jsx new file mode 100644 index 000000000..3b10012cd --- /dev/null +++ b/src/components/GradientLegend.jsx @@ -0,0 +1,38 @@ +import PropTypes from 'prop-types'; +import React from 'react'; + +import styled from 'styled-components'; + +const Gradient = styled.div` + height: 12px; + width: 95%; + margin: 0 auto; + background: linear-gradient(0.25turn, ${({ fromColor }) => fromColor}, ${({ toColor }) => toColor}); +`; + +const GradientLegend = (props) => { + const { + from, + to, + fromColor, + toColor, + } = props; + return ( +
+ +
+ {from} + {to} +
+
+ ); +}; + +GradientLegend.propTypes = { + from: PropTypes.string.isRequired, + to: PropTypes.string.isRequired, + fromColor: PropTypes.string.isRequired, + toColor: PropTypes.string.isRequired, +}; + +export default GradientLegend; diff --git a/src/components/MapViewer.jsx b/src/components/MapViewer.jsx index cd4ee36bf..061cf5be7 100644 --- a/src/components/MapViewer.jsx +++ b/src/components/MapViewer.jsx @@ -1,4 +1,9 @@ -import { Map, TileLayer, WMSTileLayer } from 'react-leaflet'; +import { + ImageOverlay, + Map, + TileLayer, + WMSTileLayer, +} from 'react-leaflet'; import { Modal } from '@material-ui/core'; import CloseIcon from '@material-ui/icons/Close'; import PropTypes from 'prop-types'; @@ -43,7 +48,7 @@ class MapViewer extends React.Component { componentDidUpdate() { const { layers, activeLayers, update } = this.state; - const { loadingLayer } = this.props; + const { loadingLayer, rasterBounds } = this.props; if (update) { Object.keys(layers).forEach((layerName) => { if (activeLayers.includes(layerName)) this.showLayer(layers[layerName], true); @@ -51,7 +56,9 @@ class MapViewer extends React.Component { }); } const countActiveLayers = Object.values(activeLayers).filter(Boolean).length; - if (countActiveLayers === 0 && !loadingLayer) { + if (rasterBounds) { + this.mapRef.current.leafletElement.fitBounds(rasterBounds); + } else if (countActiveLayers === 0 && !loadingLayer) { this.mapRef.current.leafletElement.setView(config.params.center, 5); } } @@ -99,10 +106,14 @@ class MapViewer extends React.Component { userLogged, loadingLayer, layerError, + rasterLayer, + rasterBounds, + mapTitle, } = this.props; const { openErrorModal } = this.state; return ( + {mapTitle} + {rasterLayer && rasterBounds && ( + + )} {/* TODO: Catch warning from OpenStreetMap when cannot load the tiles */} {/** TODO: Mostrar bajo este formato raster this.CapaBiomasSogamoso de cada estrategia de Compensaciones */} @@ -192,12 +209,18 @@ MapViewer.propTypes = { layers: PropTypes.object.isRequired, // eslint-disable-next-line react/no-unused-prop-types layerError: PropTypes.bool, + rasterLayer: PropTypes.string, + rasterBounds: PropTypes.object, + mapTitle: PropTypes.object, }; MapViewer.defaultProps = { userLogged: null, loadingLayer: false, layerError: false, + rasterLayer: '', + rasterBounds: null, + mapTitle: null, }; export default MapViewer; diff --git a/src/components/TabContainer.jsx b/src/components/TabContainer.jsx index 71554559a..531e3d9c6 100644 --- a/src/components/TabContainer.jsx +++ b/src/components/TabContainer.jsx @@ -20,7 +20,7 @@ class TabContainer extends React.Component { changeTab = (event, value) => { const { handlerSwitchLayer } = this.props; this.setState({ value }); - if (value === 0 || value === 2) { + if (value === 0) { handlerSwitchLayer('geofence'); } }; diff --git a/src/components/charts/GraphLoader.jsx b/src/components/charts/GraphLoader.jsx index 7d4f1eb5d..948150316 100644 --- a/src/components/charts/GraphLoader.jsx +++ b/src/components/charts/GraphLoader.jsx @@ -2,6 +2,7 @@ import DownloadIcon from '@material-ui/icons/Save'; import PropTypes from 'prop-types'; import React from 'react'; +import SingleBulletGraph from 'components/charts/SingleBulletGraph'; import DotInfo from 'components/charts/DotInfo'; import DotsGraph from 'components/charts/DotsGraph'; import LargeBarStackGraph from 'components/charts/LargeBarStackGraph'; @@ -27,21 +28,22 @@ const GraphLoader = (props) => { padding, onClickGraphHandler, markers, - loading, + message, selectedIndexValue, yMax, + reverse, + labelXRight, + labelXLeft, } = props; - // While data is being retrieved from server let errorMessage = null; - // (data === null) while waiting for API response - if (data === null || loading) errorMessage = 'Cargando información...'; - // (!data) if API doesn't respond - else if (!data) errorMessage = 'Información no disponible'; - // (data.length <= 0) if API response in not object - else if (data.length <= 0) errorMessage = 'Información no disponible'; + // TODO: don't relay on data being null for a loading state + if (data === null || message === 'loading') { + errorMessage = 'Cargando información...'; + } else if (!data || data.length <= 0 || Object.keys(data).length === 0 || message === 'no-data') { + errorMessage = 'Información no disponible'; + } if (errorMessage) { - // TODO: ask Cesar to make this message nicer return (
{errorMessage} @@ -105,6 +107,18 @@ const GraphLoader = (props) => { onClickHandler={onClickGraphHandler} /> ); + case 'singleBullet': + return ( + + ); case 'Dots': return (
@@ -198,8 +212,11 @@ GraphLoader.propTypes = { type: PropTypes.string, legendPosition: PropTypes.string, })), - loading: PropTypes.bool, + loading: PropTypes.string, selectedIndexValue: PropTypes.string, + reverse: PropTypes.bool, + labelXRight: PropTypes.string, + labelXLeft: PropTypes.string, }; GraphLoader.defaultProps = { @@ -215,8 +232,11 @@ GraphLoader.defaultProps = { padding: 0.25, onClickGraphHandler: () => {}, markers: [], - loading: false, + message: null, selectedIndexValue: '', + reverse: false, + labelXRight: null, + labelXLeft: null, }; export default GraphLoader; diff --git a/src/components/charts/SingleBulletGraph.jsx b/src/components/charts/SingleBulletGraph.jsx new file mode 100644 index 000000000..7d4e79e43 --- /dev/null +++ b/src/components/charts/SingleBulletGraph.jsx @@ -0,0 +1,280 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { ResponsiveBullet } from '@nivo/bullet'; +import { BasicTooltip, useTooltip } from '@nivo/tooltip'; +import { animated, to } from 'react-spring'; + +/** + * Get the key for a value inside an object + * + * @param {Object} originalObject Object with key + * @param {any} value value to find the key for + * + * @returns {String} desired key + */ +const findKey = (originalObject, value) => ( + Object.keys(originalObject).find( + (key) => originalObject[key] === value, + ) +); + +/** + * Define display element for a tooltip + * + * @param {any} value value to show in tooltip + * @param {String} color color for he tooltip chip + * + * @returns Tooltip elemtent + */ +const tooltip = (value, color) => { + const { showTooltipFromEvent } = useTooltip(); + return (event) => { + showTooltipFromEvent( + {value}} + enableChip + color={color} + />, + event, + ); + }; +}; + +/** + * Wrapper to allow custom measure component to access original key data and custom colors + * + * @param {Object} origMeasures keys for measure values + * @param {Function} colors function to calculate color based on the key + * @param {Boolean} reverse to specify if the chart is reversed + * + * @returns Functional component for a measure in form of a line + */ +const LineMeasureWrap = (origMeasures, colors, reverse = false) => { + /** + * Custom component to display bullet measures as lines (like markers) + * + * @param {Object} props see: https://github.com/plouc/nivo/blob/master/packages/bullet/src/types.ts#L99 + * + * @returns React component + */ + const LineMeasure = (props) => { + const { + animatedProps: { + x, + width, + }, + data, + onMouseLeave, + } = props; + const measureKey = findKey(origMeasures, data.v1); + const xVal = to([x, width], (vx, vw) => { + if (reverse) return vx; + return vx + vw; + }); + return ( + + ); + }; + + LineMeasure.propTypes = { + animatedProps: PropTypes.shape({ + x: PropTypes.object.isRequired, + width: PropTypes.object.isRequired, + }).isRequired, + data: PropTypes.shape({ + v1: PropTypes.number.isRequired, + }).isRequired, + onMouseLeave: PropTypes.func.isRequired, + }; + + return LineMeasure; +}; + +/** + * Wrapper to allow custom marker component to access original key data and custom colors + * + * @param {Object} origMarkers keys for marker values + * @param {Function} colors function to calculate color based on the key + * + * @returns Functional component for a marker in form of a circle + */ +const CircleMarkerWrap = (origMarkers, colors) => { + /** + * Custom component to display bullet markers as circles + * + * @param {Object} props see: https://github.com/plouc/nivo/blob/master/packages/bullet/src/types.ts#L112 + * + * @returns React component + */ + const CircleMarker = (props) => { + const { + x, + y, + data, + value, + onMouseLeave, + } = props; + const markerKey = findKey(origMarkers, value); + + return ( + onMouseLeave(data, event)} + > + + + ); + }; + + CircleMarker.propTypes = { + x: PropTypes.number.isRequired, + y: PropTypes.number.isRequired, + data: PropTypes.object.isRequired, + value: PropTypes.number.isRequired, + onMouseLeave: PropTypes.func.isRequired, + }; + + return CircleMarker; +}; + +/** + * Wrapper to allow custom Range component to access original key data and custom colors + * + * @param {Object} origRanges keys for ranges values + * @param {Function} colors function to calculate color based on the key + * + * @returns Functional component for a range without tooltip + */ + const NoTooltipRangeWrap = (origRanges, colors) => { + /** + * Custom component to display bullet range without tooltip + * + * @param {Object} props see: https://github.com/plouc/nivo/blob/master/packages/bullet/src/types.ts#L99 + * + * @returns React component + */ + const NoTooltipRange = (props) => { + const { + animatedProps: { + x, + y, + width, + height, + }, + data, + onClick, + } = props; + const rangeKey = findKey(origRanges, data.v1); + + return ( + Math.max(value, 0))} + height={to(height, (value) => Math.max(value, 0))} + fill={colors(rangeKey)} + onClick={onClick} + /> + ); + }; + + NoTooltipRange.propTypes = { + animatedProps: PropTypes.shape({ + x: PropTypes.object.isRequired, + y: PropTypes.object.isRequired, + width: PropTypes.object.isRequired, + height: PropTypes.object.isRequired, + }).isRequired, + data: PropTypes.shape({ + v1: PropTypes.number.isRequired, + }).isRequired, + onClick: PropTypes.func.isRequired, + }; + + return NoTooltipRange; +}; + +/** + * Important: measures and markers are inverted with respect to nivo documentation + */ +const SingleBulletGraph = (props) => { + const { + height, + data, + colors, + onClickHandler, + reverse, + labelXRight, + labelXLeft, + } = props; + return ( +
+ + {(labelXRight || labelXLeft) && ( +
+

{labelXLeft}

+

{labelXRight}

+
+ )} +
+ ); +}; + +SingleBulletGraph.propTypes = { + data: PropTypes.object.isRequired, + height: PropTypes.number, + colors: PropTypes.func.isRequired, + onClickHandler: PropTypes.func, + reverse: PropTypes.bool, + labelXRight: PropTypes.string, + labelXLeft: PropTypes.string, +}; + +SingleBulletGraph.defaultProps = { + height: 100, + onClickHandler: () => {}, + reverse: false, + labelXRight: null, + labelXLeft: null, +}; + +export default SingleBulletGraph; diff --git a/src/images/biomodelos.png b/src/images/biomodelos.png new file mode 100644 index 000000000..9a89147da Binary files /dev/null and b/src/images/biomodelos.png differ diff --git a/src/images/biomodelos2.png b/src/images/biomodelos2.png new file mode 100644 index 000000000..b5d41ad74 Binary files /dev/null and b/src/images/biomodelos2.png differ diff --git a/src/images/biomodeloslink.png b/src/images/biomodeloslink.png new file mode 100644 index 000000000..0f21dec21 Binary files /dev/null and b/src/images/biomodeloslink.png differ diff --git a/src/images/biomodeloslink2.png b/src/images/biomodeloslink2.png new file mode 100644 index 000000000..07aee8043 Binary files /dev/null and b/src/images/biomodeloslink2.png differ diff --git a/src/images/mappoint.png b/src/images/mappoint.png new file mode 100644 index 000000000..dd4f298e5 Binary files /dev/null and b/src/images/mappoint.png differ diff --git a/src/images/mappoint2.png b/src/images/mappoint2.png new file mode 100644 index 000000000..8f7da6170 Binary files /dev/null and b/src/images/mappoint2.png differ diff --git a/src/images/mappointlink.png b/src/images/mappointlink.png new file mode 100644 index 000000000..7ed3b14bc Binary files /dev/null and b/src/images/mappointlink.png differ diff --git a/src/images/mappointlink2.png b/src/images/mappointlink2.png new file mode 100644 index 000000000..26ffd4278 Binary files /dev/null and b/src/images/mappointlink2.png differ diff --git a/src/images/umed.png b/src/images/umed.png new file mode 100644 index 000000000..3fa4c58d8 Binary files /dev/null and b/src/images/umed.png differ diff --git a/src/main.css b/src/main.css index d21186375..288f2e0fd 100644 --- a/src/main.css +++ b/src/main.css @@ -1149,17 +1149,35 @@ a#reset-filters { } .mapsTitle { - position: absolute; - top:100px; - left:50px; - background-color: #fff; - color: #e84a5f; - border-bottom: 1px solid #e84a5f; + position: relative; + left: 45px; font-size: 15px; - z-index:1; + z-index: 401; display: inline-block; - padding: 5px 12px; } + +.mapsTitle > .title { + background-color: #fff; + color: #e84a5f; + border-bottom: 1px solid #e84a5f; + padding: 5px 12px; +} + +.gradientLegend { + background-color: #fff; + font-size: 9px; + font-weight: 600; + color: #2a363b; + margin-top: 1px; + padding: 5px 8px 3px; +} + +.gradientLegend > .text { + display: flex; + justify-content: space-between; + margin-top: 0 auto; +} + /*Map container*/ .viewfinder { margin: 2px; @@ -1346,6 +1364,16 @@ overflow: auto; padding: 6px 8px !important; } +.extraLegend { + font-size: 12px; + display: flex; + justify-content: space-between; + font-weight: 600; + padding: 0 30px 0 15px; + margin-top: -8px; + color: #424242; +} + .accordionSelected { background-color: #fff !important; } @@ -1584,6 +1612,14 @@ div[class*="eaRHYv"] { display: table; } +.disclaimer { + font-size: 12px; + padding: 3px 6px; + color: #ffffff; + background-color: #5db1d4; + margin: 0 30px 10px 15px; +} + .singleeco { font-size: 16px; margin-left: 5px; @@ -1804,6 +1840,52 @@ div[class*="eaRHYv"] { border: 1px solid; } +.numberSP { + display: flex; + justify-content:space-between; + align-items: center; +} + +.nos-title { + margin-left: 30px; + line-height: 1.2; + color: #6f6f6f; +} + +.nos-title b { + color: tomato; +} + +.nos-title.legend { + margin-left: 15px; + margin-bottom: 15px; +} + +.nos-title.selected { + color: #2a363b; + font-weight: 600; + margin-left: 15px; + border-left: 2px solid tomato; + padding-left: 14px; +} + +.numberSP a { + margin-right: 10px; +} + +.numberSP a:hover { + color: tomato; +} + +.richnessLegend { + margin: 10px 0 5px 30px; +} + +.richnessLegend p { + margin: 0; + margin-bottom: 2px; +} + .dpcLegend { margin-left: 25px; } @@ -2199,9 +2281,6 @@ tr.row2table:nth-child(odd){ border-top-right-radius: 5px; } -.titeco2 { /* César */ -} - .listSS { margin-top: 10px; background: #fff; diff --git a/src/pages/Search.jsx b/src/pages/Search.jsx index 98300a586..db6d36f03 100644 --- a/src/pages/Search.jsx +++ b/src/pages/Search.jsx @@ -1,6 +1,6 @@ import { withRouter } from 'react-router-dom'; import CloseIcon from '@material-ui/icons/Close'; -import L from 'leaflet'; +import L, { LatLngBounds } from 'leaflet'; import Modal from '@material-ui/core/Modal'; import PropTypes from 'prop-types'; import React, { Component } from 'react'; @@ -13,6 +13,7 @@ import formatNumber from 'utils/format'; import GeoServerAPI from 'utils/geoServerAPI'; import matchColor from 'utils/matchColor'; import RestAPI from 'utils/restAPI'; +import GradientLegend from 'components/GradientLegend'; import MapViewer from 'components/MapViewer'; import Selector from 'components/Selector'; @@ -38,6 +39,10 @@ const tooltipLabel = { no_bosque: 'No bosque', scialta: 'Alto', scibaja_moderada: 'Bajo Moderado', + total: 'Total', + endemic: 'Endémicas', + invasive: 'Invasoras', + threatened: 'Amenazadas', }; class Search extends Component { @@ -54,6 +59,8 @@ class Search extends Component { selectedAreaType: null, selectedArea: null, requestSource: null, + mapBounds: null, + rasterUrl: '', }; } @@ -67,8 +74,8 @@ class Search extends Component { componentDidUpdate() { const { history } = this.props; - history.listen((loc, action) => { - if (loc.search === '' && action === 'POP') { + history.listen((location, action) => { + if (location.search === '' && (action === 'PUSH' || action === 'POP')) { this.handlerBackButton(); } }); @@ -418,6 +425,15 @@ class Search extends Component { this.shutOffLayer('paramoPAConn'); this.switchLayer('dryForestPAConn'); break; + case 'numberOfSpecies': { + const { activeLayer: { id: activeLayer } } = this.state; + if (chartSection !== 'inferred') { + this.switchLayer('geofence'); + } else if (activeLayer !== selectedKey) { + this.switchLayer(`numberOfSpecies-${selectedKey}`); + } + } + break; default: { const { layers, activeLayer: { id: activeLayer } } = this.state; @@ -454,6 +470,8 @@ class Search extends Component { newState.layers[layerKey].active = false; }); newState.activeLayer = {}; + newState.mapBounds = null; + newState.rasterUrl = ''; return newState; }); } else if (layerInState) { @@ -472,8 +490,9 @@ class Search extends Component { * Switch layer based on graph showed * * @param {String} layerType layer type + * @param {function} callback operations to execute sequentially */ - switchLayer = (layerType, callback = () => {}) => { + switchLayer = async (layerType, callback = () => {}) => { const { selectedAreaId, selectedAreaTypeId, @@ -497,6 +516,7 @@ class Search extends Component { let fitBounds = true; let newActiveLayer = null; let layerKey = layerType; + let isRaster = false; switch (layerType) { case 'fc': @@ -583,44 +603,7 @@ class Search extends Component { }); break; case 'currentPAConn': - this.switchLayer('geofence', () => { - this.setState({ - loadingLayer: true, - layerError: false, - requestSource: null, - }); - request = () => RestAPI.requestDPCLayer( - selectedAreaTypeId, - selectedAreaId, - ); - shutOtherLayers = false; - layerStyle = this.featureStyle({ type: layerType, fKey: 'dpc_cat' }); - newActiveLayer = { - id: layerType, - name: 'Conectividad de áreas protegidas', - }; - }); - break; case 'timelinePAConn': - this.switchLayer('geofence', () => { - this.setState({ - loadingLayer: true, - layerError: false, - requestSource: null, - }); - request = () => RestAPI.requestDPCLayer( - selectedAreaTypeId, - selectedAreaId, - ); - shutOtherLayers = false; - layerStyle = this.featureStyle({ type: 'currentPAConn', fKey: 'dpc_cat' }); - layerKey = 'currentPAConn'; - newActiveLayer = { - id: 'currentPAConn', - name: 'Conectividad de áreas protegidas', - }; - }); - break; case 'currentSEPAConn': this.switchLayer('geofence', () => { this.setState({ @@ -637,7 +620,7 @@ class Search extends Component { layerKey = 'currentPAConn'; newActiveLayer = { id: 'currentPAConn', - name: 'Conectividad de áreas protegidas y Ecosistemas estratégicos (EE)', + name: `Conectividad de áreas protegidas${(layerType === 'currentSEPAConn') ? ' y Ecosistemas estratégicos (EE)' : ''}`, }; }); break; @@ -696,12 +679,65 @@ class Search extends Component { id: layerType, name: `Pérdida y persistencia de bosque (${yearIni}-${yearEnd})`, }; + } else if (/numberOfSpecies*/.test(layerType)) { + let group = 'total'; + const selected = layerType.match(/numberOfSpecies-(\w+)/); + if (selected) [, group] = selected; + + isRaster = true; + request = () => RestAPI.requestNOSLayer( + selectedAreaTypeId, + selectedAreaId, + group, + ); + try { + const { min, max } = await RestAPI.requestNOSLayerThresholds( + selectedAreaTypeId, + selectedAreaId, + group, + ); + newActiveLayer = { + id: group, + name: `Número de especies - ${tooltipLabel[group]}`, + legend: { + from: min.toString(), + to: max.toString(), + fromColor: matchColor('richnessNos')('legend-from'), + toColor: matchColor('richnessNos')('legend-to'), + }, + }; + } catch { + this.reportDataError(); + return; + } } break; } - if (request) { - if (shutOtherLayers) this.shutOffLayer(); + if (shutOtherLayers) this.shutOffLayer(); + + if (isRaster) { + const geofenceLayer = layers.geofence; + let mapBounds = null; + if (geofenceLayer) { + mapBounds = geofenceLayer.layer.getBounds(); + } else { + mapBounds = LatLngBounds( + [-78.9909352282, -4.29818694419], [-66.8763258531, 12.4373031682], + ); + } + const { request: apiRequest, source: apiSource } = request(); + this.setState({ requestSource: apiSource }); + apiRequest.then((res) => { + const rasterUrl = `data:${res.headers['content-type']};base64, ${Buffer.from(res.data, 'binary').toString('base64')}`; + this.setState({ + mapBounds, + rasterUrl, + activeLayer: newActiveLayer, + loadingLayer: false, + }); + }).catch(() => this.reportDataError()); + } else if (request) { if (layers[layerKey]) { this.setState((prevState) => { const newState = prevState; @@ -899,6 +935,8 @@ class Search extends Component { newState.activeLayer = {}; newState.loadingLayer = false; newState.layerError = false; + newState.mapBounds = null; + newState.rasterUrl = ''; return newState; }, () => { const { history, setHeaderNames } = this.props; @@ -923,7 +961,9 @@ class Search extends Component { connError, layerError, geofencesArray, - activeLayer: { name: activeLayer }, + activeLayer: { name: activeLayer, legend }, + mapBounds, + rasterUrl, } = this.state; const { @@ -931,6 +971,22 @@ class Search extends Component { selectedAreaId, } = this.props; + const mapTitle = !activeLayer ? null : ( + <> +
+
{activeLayer}
+ {legend && ( + + )} +
+ + ); + return ( <> - {activeLayer && ( -
- {activeLayer} -
- )}
{ (!selectedAreaTypeId || !selectedAreaId) && ( -
+
{biome}
diff --git a/src/pages/search/Drawer.jsx b/src/pages/search/Drawer.jsx index def65261a..4216a141b 100644 --- a/src/pages/search/Drawer.jsx +++ b/src/pages/search/Drawer.jsx @@ -8,6 +8,7 @@ import Paisaje from '@material-ui/icons/FilterHdr'; import SearchContext from 'pages/search/SearchContext'; import Landscape from 'pages/search/drawer/Landscape'; +import Species from 'pages/search/drawer/Species'; import StrategicEcosystems from 'pages/search/drawer/StrategicEcosystems'; import formatNumber from 'utils/format'; import RestAPI from 'utils/restAPI'; @@ -92,13 +93,10 @@ class Drawer extends React.Component { handlerSwitchLayer={handlerSwitchLayer} />
-
-

- Gráficas en construcción -

-

- Pronto más información -

+
+
diff --git a/src/pages/search/drawer/landscape/LandscapeAccordion.jsx b/src/pages/search/drawer/Accordion.jsx similarity index 75% rename from src/pages/search/drawer/landscape/LandscapeAccordion.jsx rename to src/pages/search/drawer/Accordion.jsx index e5b15dfaf..458036f96 100644 --- a/src/pages/search/drawer/landscape/LandscapeAccordion.jsx +++ b/src/pages/search/drawer/Accordion.jsx @@ -1,11 +1,11 @@ import React from 'react'; -import Accordion from '@material-ui/core/Accordion'; +import AccordionUI from '@material-ui/core/Accordion'; import AccordionSummary from '@material-ui/core/AccordionSummary'; import AccordionDetails from '@material-ui/core/AccordionDetails'; import PropTypes from 'prop-types'; import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; -class LandscapeAccordion extends React.Component { +class Accordion extends React.Component { constructor(props) { super(props); this.state = { @@ -16,9 +16,14 @@ class LandscapeAccordion extends React.Component { componentDidMount() { const { componentsArray } = this.props; if (componentsArray.length > 0) { - const defaultTab = componentsArray.find( + let defaultTab = componentsArray.find( (item) => !item.label.collapsed, - ).label.id; + ); + if (defaultTab) { + defaultTab = defaultTab.label.id; + } else { + defaultTab = null; + } this.setState({ expanded: defaultTab }); } } @@ -34,8 +39,18 @@ class LandscapeAccordion extends React.Component { const { expanded } = this.state; return (
+ {componentsArray.length <= 0 && ( +
+

+ Gráficas en construcción +

+

+ Pronto más información +

+
+ )} {componentsArray.map((item) => ( - - + ))}
); } } -LandscapeAccordion.propTypes = { +Accordion.propTypes = { componentsArray: PropTypes.arrayOf(PropTypes.shape({ label: PropTypes.shape({ id: PropTypes.string, @@ -74,17 +89,17 @@ LandscapeAccordion.propTypes = { component: PropTypes.func, componentProps: PropTypes.object, })).isRequired, - classNameDefault: PropTypes.string, // defined in CSS file to default item for this accordion - classNameSelected: PropTypes.string, // defined in CSS file to selected item this accordion + classNameDefault: PropTypes.string, + classNameSelected: PropTypes.string, handlerAccordionGeometry: PropTypes.func, level: PropTypes.string, }; -LandscapeAccordion.defaultProps = { +Accordion.defaultProps = { classNameDefault: 'm0b', classNameSelected: 'm0b selector-expanded', handlerAccordionGeometry: () => {}, level: '1', }; -export default LandscapeAccordion; +export default Accordion; diff --git a/src/pages/search/drawer/Landscape.jsx b/src/pages/search/drawer/Landscape.jsx index 664557e8d..b8a5806f7 100644 --- a/src/pages/search/drawer/Landscape.jsx +++ b/src/pages/search/drawer/Landscape.jsx @@ -5,7 +5,7 @@ import CompensationFactor from 'pages/search/drawer/landscape/CompensationFactor import Forest from 'pages/search/drawer/landscape/Forest'; import HumanFootprint from 'pages/search/drawer/landscape/HumanFootprint'; import PAConnectivity from 'pages/search/drawer/landscape/PAConnectivity'; -import LandscapeAccordion from 'pages/search/drawer/landscape/LandscapeAccordion'; +import Accordion from 'pages/search/drawer/Accordion'; import SearchContext from 'pages/search/SearchContext'; class Landscape extends React.Component { @@ -123,7 +123,7 @@ class Landscape extends React.Component { const componentsArray = initialArray.filter((f) => selected.includes(f.label.id)); return ( - { + const { handlerSwitchLayer } = this.props; + const { visible, childMap } = this.state; + if (tabLayerId === null) handlerSwitchLayer(null); + + switch (level) { + case '1': + this.setState({ visible: tabLayerId }); + handlerSwitchLayer(childMap[tabLayerId]); + break; + case '2': + this.setState((prev) => ({ + childMap: { + ...prev.childMap, + [visible]: tabLayerId, + }, + })); + handlerSwitchLayer(tabLayerId); + break; + default: + break; + } + } + + render() { + const { childMap, availableComponents } = this.state; + const initialArray = [ + { + label: { + id: 'richness', + name: 'Riqueza', + }, + component: Richness, + componentProps: { + handlerAccordionGeometry: this.handlerAccordionGeometry, + openTab: childMap.richness, + }, + }, + { + label: { + id: 'functionalDiversity', + name: 'Diversidad Funcional', + disabled: true, + }, + component: FunctionalDiversity, + componentProps: { + handlerAccordionGeometry: this.handlerAccordionGeometry, + openTab: childMap.functionalDiversity, + }, + }, + ]; + + const componentsArray = initialArray.filter((f) => availableComponents.includes(f.label.id)); + + return ( + + ); + } +} + +Species.propTypes = { + handlerSwitchLayer: PropTypes.func, +}; + +Species.defaultProps = { + handlerSwitchLayer: () => {}, +}; + +export default Species; + +Species.contextType = SearchContext; diff --git a/src/pages/search/drawer/landscape/Forest.jsx b/src/pages/search/drawer/landscape/Forest.jsx index bbd27d4b4..a7f33bced 100644 --- a/src/pages/search/drawer/landscape/Forest.jsx +++ b/src/pages/search/drawer/landscape/Forest.jsx @@ -3,7 +3,7 @@ import React from 'react'; import ForestIntegrity from 'pages/search/drawer/landscape/forest/ForestIntegrity'; import ForestLossPersistence from 'pages/search/drawer/landscape/forest/ForestLossPersistence'; -import LandscapeAccordion from 'pages/search/drawer/landscape/LandscapeAccordion'; +import Accordion from 'pages/search/drawer/Accordion'; const Forest = (props) => { const { @@ -32,7 +32,7 @@ const Forest = (props) => { ]; return (
- { const { @@ -50,7 +50,7 @@ const HumanFootprint = (props) => { ]; return (
- { const { @@ -40,7 +40,7 @@ const PAConnectivity = (props) => { ]; return (
- { if (this.mounted) { if (res.length <= 0) { - this.setState({ SciHfCats: {}, ProtectedAreas: {}, loading: false }); + this.setState({ SciHfCats: {}, ProtectedAreas: {}, loading: 'no-data' }); } else { this.setState((prevState) => { const { SciHfCats: cats, ProtectedAreas: PAs } = prevState; @@ -93,7 +93,7 @@ class ForestIntegrity extends React.Component { percentage: areas.area / cats[sciHfCat].value, })); })); - return { SciHfCats: cats, ProtectedAreas: PAs, loading: false }; + return { SciHfCats: cats, ProtectedAreas: PAs, loading: null }; }); } } @@ -158,7 +158,7 @@ class ForestIntegrity extends React.Component {
{ + const { + handlerAccordionGeometry, + } = props; + + const componentsArray = [ + { + label: { + id: 'tropicalDryForest', + name: 'Plantas del bosque seco', + }, + component: TropicalDryForest, + }, + ]; + return ( +
+ +
+ ); +}; + +FunctionalDiversity.propTypes = { + handlerAccordionGeometry: PropTypes.func, +}; + +FunctionalDiversity.defaultProps = { + handlerAccordionGeometry: () => {}, +}; + +export default FunctionalDiversity; diff --git a/src/pages/search/drawer/species/Richness.jsx b/src/pages/search/drawer/species/Richness.jsx new file mode 100644 index 000000000..045f99e64 --- /dev/null +++ b/src/pages/search/drawer/species/Richness.jsx @@ -0,0 +1,56 @@ +import PropTypes from 'prop-types'; +import React from 'react'; + +import NumberOfSpecies from 'pages/search/drawer/species/richness/NumberOfSpecies'; +import SpeciesRecordsGaps from 'pages/search/drawer/species/richness/SpeciesRecordsGaps'; +import Accordion from 'pages/search/drawer/Accordion'; + +const Richness = (props) => { + const { + handlerAccordionGeometry, + openTab, + } = props; + + const componentsArray = [ + { + label: { + id: 'numberOfSpecies', + name: 'Número de especies', + collapsed: openTab !== 'numberOfSpecies', + }, + component: NumberOfSpecies, + }, + { + label: { + id: 'speciesRecordsGaps', + name: 'Vacíos en registros de especies', + collapsed: openTab !== 'speciesRecordsGaps', + disabled: true, + }, + component: SpeciesRecordsGaps, + }, + ]; + return ( +
+ +
+ ); +}; + +Richness.propTypes = { + handlerAccordionGeometry: PropTypes.func, + openTab: PropTypes.string, +}; + +Richness.defaultProps = { + handlerAccordionGeometry: () => {}, + openTab: '', +}; + +export default Richness; diff --git a/src/pages/search/drawer/species/functionalDiversity/TropicalDryForest.jsx b/src/pages/search/drawer/species/functionalDiversity/TropicalDryForest.jsx new file mode 100644 index 000000000..3dcdf7bcd --- /dev/null +++ b/src/pages/search/drawer/species/functionalDiversity/TropicalDryForest.jsx @@ -0,0 +1,39 @@ +import React from 'react'; + +class TropicalDryForest extends React.Component { + mounted = false; + + constructor(props) { + super(props); + this.state = { + showInfoGraph: false, + }; + } + + componentDidMount() { + this.mounted = true; + } + + componentWillUnmount() { + this.mounted = false; + } + + /** + * Show or hide the detailed information on each graph + */ + toggleInfoGraph = () => { + this.setState((prevState) => ({ + showInfoGraph: !prevState.showInfoGraph, + })); + }; + + render() { + return ( +
+
+
+ ); + } +} + +export default TropicalDryForest; diff --git a/src/pages/search/drawer/species/richness/InfoTexts.jsx b/src/pages/search/drawer/species/richness/InfoTexts.jsx new file mode 100644 index 000000000..3273475f4 --- /dev/null +++ b/src/pages/search/drawer/species/richness/InfoTexts.jsx @@ -0,0 +1,63 @@ +export const NumberOfSpeciesText = `

La riqueza de especies mide el número de especies que se encuentran en un área de consulta, identificando zonas con alta o baja concentración. La riqueza puede ser utilizada como un indicador del estado de la biodiversidad del área consultada siempre y cuando se acompañe de información sobre la identidad y estado de las especies presentes. Un valor alto de riqueza, no necesariamente indica un buen estado de conservación. Se recomienda leer este indicador en conjunto con los demás indicadores de las secciones Ecosistemas y Paisajes en BioTablero, para identificar un panorama más amplio sobre el estado de la biodiversidad del área de consulta. +
+
+Los valores de riqueza de especies se presentan de manera relativa para que el usuario pueda tener puntos de comparación para el área consultada. Cada una de las barras que representan la riqueza de los tipos de especies (total, endémicas, amenazadas e invasoras) se encuentra dividida en dos secciones, representando el rango de riqueza de especies en las unidades de consulta (p.ej. departamentos · color amarillo) respecto a su región natural correspondiente (p.ej. región Andes, Caribe, Pacífico, Orinoquia o Amazonas · color naranja). Adicionalmente, se muestra con un punto sobre la barra el valor de riqueza de la unidad de consulta (p.ej. Antioquia), y el valor mínimo y máximo de las demás unidades de consulta del mismo tipo (p.ej. departamentos). +
+
+Dado que las especies presentes en Colombia se encuentra heterogéneamente representadas en las bases de datos, y la cuantificación real del número de especies tiene retos importantes de investigación relacionados con el diseño y esfuerzo de muestreo, y con la identificación taxonómica, presentamos dos aproximaciones a la cuantificación de la riqueza: riqueza inferida calculada a partir de BioModelos, y riqueza observada calculada a partir de registros georreferenciados de GBIF. +

+

+

+Riqueza inferida de especies

+Los mapas y valores de riqueza inferida representan el número de especies que potencialmente se encuentran en un área de consulta y es calculada a partir de 5808 modelos de distribución de especies, en donde se identifican las condiciones climáticas idóneas donde las especies podrían estar presentes. Estos modelos se encuentran disponibles en BioModelos (Velásquez-Tibatá et al. 2019) +

+Los mapas de riqueza de especies presentan valores desde 1 hasta n, dependiendo de la concentración de especies que se puedan encontrar en celdas de 1km2. Las rutinas empleadas para la construcción de los modelos se encuentran disponibles en el repositorio de código abierto del Laboratorio de Biogeografía Aplicada del Instituto Humboldt. +

+Para el caso de la riqueza de especies amenazadas se incluyen aquellas especies categorizadas como en peligro crítico de extinción (CR), en peligro de extinción (EN) y vulnerable (VU) según la UICN. Para las especies invasoras se construyeron 23 BioModelos de especies de plantas (Salgado-Negret et al. Sometido) usando registros de presencias y variables bioclimáticas para obtener mapas de su distribución potencial (Phillips & Dudik 2008). Las especies invasoras modeladas fueron priorizadas por su alto potencial de invasión en el país (Cárdenas-López et al. 2010). Las especies endémicas se identificaron considerando los listados nacionales de especies publicados a través del SiB Colombia, y se espacializaron con los mapas disponibles en el portal de la UICN (González et al. 2018). +

+Al momento de interpretar los valores de riqueza inferida se debe tener en cuenta que: +

    +
  • + Al ser un indicador calculado a partir de modelos de distribución de especies, es importante considerar que el número de especies reportado corresponde a un número potencial que se relaciona con la presencia probable de las especies en respuesta a las condiciones climáticas idóneas, más no de hábitat o de otro tipo de interacciones bióticas que determinan la presencia real de las especies. Mayor información puede ser consultada en BioModelos o comunicarse directamente por correo electrónico a biomodelos@humboldt.org.co. +
  • +
  • + La riqueza inferida de especies se calculó con base en 5808 especies modeladas, los cuales representan sólo una muestra de las especies existentes en el país, por lo que los valores presentados no reflejan el número real de especies sobre el territorio; estos valores pueden estar sobre o subestimados. Se recomienda ver este indicador en conjunto con la riqueza observada de especies y el mapa de vacíos de información para tener un panorama más amplio de la riqueza del área de consulta.
  • +
+

+

+Riqueza observada de especies

+Los valores de riqueza observada representan el número de especies registradas para cada unidad de consulta, y es calculada a partir de los registros georeferenciados de GBIF. Los mapas de riqueza de especies presentan valores desde 1 hasta n, dependiendo del número de especies encontradas en los registros georeferenciados de cada unidad de consulta. El proceso de obtención de los registros y la cuantificación de la riqueza de especies está a cargo de la Infraestructura Institucional de Datos (I2D) del Instituto Humboldt. Las rutinas para estos cálculos pueden consultarse en su repositorio de código abierto. +

+Los valores de riqueza total fueron estimados a partir de 11.730.023 registros. La información asociada a esta descarga puede ser consultada en este enlace. Para el caso de la riqueza de especies amenazadas se incluyen aquellas especies categorizadas como en peligro crítico de extinción (CR), en peligro de extinción (EN) y vulnerable (VU) según la resolución 1912 de 2017 del Ministerio de Ambiente y Desarrollo Sostenible. Para definir las especies exóticas se utilizaron distintas fuentes de información, entre las que se encuentran Invasive Species Compendium (ISC), Global Invasive Species Database (GISD), Inter-American Biodiversity Information Network (IABIN), además de diversos artículos científicos. En el caso de las especies endémicas se utilizaron múltiples fuentes para validar la categoría de endemismo a nivel de país: AmphibiaWeb, Bernal et al., 2015, Maldonado-Ocampo et al, 2008, Solari et al., 2013, Species lists of birds for South American countries and territories, The Reptile Database, Lista del Catálogo de Plantas y Líquenes de Colombia, Lista de referencias de Mamíferos de Colombia, Lista de especies de agua dulce de Colombia, Lista de referencia de especies de aves de Colombia. +

+Al momento de interpretar los valores de riqueza observada se debe tener en cuenta que: +

    +
  • + Los registros georeferenciados no cuentan con una depuración exhaustiva y pueden contener errores de identificación taxonómica de las especies y de georeferenciación, por lo que los valores presentados no reflejan el número real de especies sobre el territorio, estos valores pueden estar sobre o subestimados. +
  • +
  • + Los registros georeferenciados tienen un sesgo geográfico que está dado principalmente por la accesibilidad a los sitios de muestreo. Por esta razón, existen áreas que no han sido muestreadas en el país, y en este sentido los valores de riqueza pueden estar subestimados. Se recomienda leer este indicador en conjunto con el Análisis de Vacíos en Biodiversidad Continental para Colombia (AVBCC) disponible en BioTablero, con el fin de evaluar la representatividad de los registros en el área de consulta. +
  • +
+

+`; + +export const SpeciesRecordsGapsText = `

El Análisis de Vacíos en Biodiversidad Continental para Colombia (AVBCC) permite identificar las áreas del país que cuentan con datos primarios de biodiversidad disponibles en bases de datos como el SiB Colombia y GBIF, por lo que identifica sitios con vacíos de información, y por ende las áreas en las cuales se requieren esfuerzos de muestreos adicionales para mejorar el conocimiento de la biodiversidad. Esta aproximación se realiza calculando tres componentes: i) Concentración de los datos en el espacio geográfico a partir de un cálculo de densidad de registros. ii) Representatividad ambiental, siguiendo la metodología propuesta por Aguiar et al. (2020), la cual modela los registros de especies sobre variables bioclimáticas para identificar las regiones no estudiadas que son ambientalmente diferentes. iii) Complementariedad de la riqueza de especies, la cual calcula la presencia de especies según la densidad de registros en celdas de 1km2, y con base estimaciones no paramétricas de Jackknife de primer orden, se estima la riqueza esperada en cada celda, siendo el valor de complementariedad, la diferencia entre el valor estimado y el esperado. La ruta metodológica hace parte de la propuesta de García Márquez et al. (2012) para la estimación de un índice espacial de los registros de especies, y cuenta con algunos ajustes para el país. +

+Cada uno de los componentes del AVBCC resulta en un mapa con valores entre 0 y 100 %, siendo 100 sobre lugares donde ocurren mayores vacíos de información en registros, información ambiental o complementariedad en la riqueza de especies, y 0 sobre áreas bien representadas de acuerdo a cada componente. El índice integrado AVBCC se obtiene de promediar los tres componentes. En las gráficas, se representa el AVBCC como un punto sobre la barra, y además se presentan otros seis valores útiles para su comparación: 1) el valor mínimo y 2) máximo del AVBCC en el área de consulta (p.ej. región Antioquia), 3) el valor mínimo y 4) máximo del AVBCC en las demás áreas de consulta del mismo tipo (p.ej. departamentos), 5) el valor mínimo y 6) máximo del AVBCC en la región biótica correspondiente al área de consulta (p.ej. región Andes) (Caribe, Andes, Pacífico, Orinoquia, Amazonas) (color naranja). +

+El mapa de vacíos se encuentra disponible en el repositorio de datos geográficos del Instituto Humboldt. Todos los análisis se hicieron en el paquete estadístico R, y las rutinas que se emplearon para el cálculo del AVBCC se encuentran disponibles en el repositorio de código abierto del Programa de Evaluación y Monitoreo del Instituto Humboldt. +

+Al momento de interpretar los valores del AVBCC se debe tener en cuenta que: +

    +
  • + Los registros obtenidos para el presente análisis fueron descargados en enero del 2021, de forma tal que contará con los insumos disponibles hasta diciembre 31 del 2020. A dicho conjunto de datos se les aplicaron rutinas de verificación geográfica y taxonómica que pueden ser consultadas en los siguientes enlaces: Verificación Geográfica, Verificación Taxonómica. +
  • +
  • + La información obtenida en el presente análisis no cuantifica el nivel de sesgo de la información obtenida, dado que el número de registros presente en un sector puede estar influenciado por su cercanía a vías, ríos u otras unidades político-administrativas. +
  • +
+

+`; + +export const NumberOfSpeciesTextHelper = 'Para escoger entre riqueza observada o inferida haga clic en el texto correspondiente. Para ver la riqueza observada e inferida al mismo tiempo desactívelas. En la sección de riqueza inferida puede hacer clic en cada barra para visualizar el mapa de riqueza correspondiente'; diff --git a/src/pages/search/drawer/species/richness/NumberOfSpecies.jsx b/src/pages/search/drawer/species/richness/NumberOfSpecies.jsx new file mode 100644 index 000000000..bdf75c657 --- /dev/null +++ b/src/pages/search/drawer/species/richness/NumberOfSpecies.jsx @@ -0,0 +1,399 @@ +import React from 'react'; +import InfoIcon from '@material-ui/icons/Info'; + +import { IconTooltip } from 'components/Tooltips'; +import GraphLoader from 'components/charts/GraphLoader'; +import { + LegendColor, + LineLegend, + TextLegend, + ThickLineLegend, +} from 'components/CssLegends'; +import Icon from 'components/CssIcons'; +import matchColor from 'utils/matchColor'; +import RestAPI from 'utils/restAPI'; +import SearchContext from 'pages/search/SearchContext'; +import ShortInfo from 'components/ShortInfo'; +import { NumberOfSpeciesText, NumberOfSpeciesTextHelper } from 'pages/search/drawer/species/richness/InfoTexts'; + +import biomodelos from 'images/biomodelos.png'; +import mappoint from 'images/mappoint.png'; +import biomodelos2 from 'images/biomodelos2.png'; +import mappoint2 from 'images/mappoint2.png'; +import biomodeloslink from 'images/biomodeloslink.png'; +import biomodeloslink2 from 'images/biomodeloslink2.png'; + +const getLabel = (key, area, region) => { + let areaLbl = 'cerca'; + switch (area) { + case 'states': + areaLbl = 'departamentos'; + break; + case 'pa': + areaLbl = 'áreas de manejo especial'; + break; + case 'ea': + areaLbl = 'jurisdicciones ambientales'; + break; + case 'basinSubzones': + areaLbl = 'subzonas hidrográficas'; + break; + default: + break; + } + + return { + total: 'TOTAL', + endemic: 'ENDÉMICAS', + invasive: 'INVASORAS', + threatened: 'AMENAZADAS', + inferred: 'Inferido (BioModelos)', + observed: 'Observado (visor I2D)', + min_inferred: `Min. inferido ${areaLbl} de la región ${region}`, + min_observed: `Min. observado ${areaLbl} de la región ${region}`, + max_inferred: `Max. inferido ${areaLbl} de la región ${region}`, + max_observed: `Max. observado ${areaLbl} de la región ${region}`, + region_observed: `Observado región ${region}`, + region_inferred: `Inferido región ${region}`, + area: `${areaLbl.replace(/^\w/, (l) => l.toUpperCase())} de la región ${region}`, + region: `Región ${region}`, + inferred2: 'Inferido en el área de consulta', + observed2: 'Observado en el área de consulta', + national_inferred: `Max. inferido en ${areaLbl} a nivel nacional: `, + national_observed: `Max. observado en ${areaLbl} a nivel nacional: `, + }[key]; +}; + +class NumberOfSpecies extends React.Component { + mounted = false; + + constructor(props) { + super(props); + this.state = { + showInfoGraph: false, + data: [], + allData: [], + filter: 'all', + message: 'loading', + selected: 'total', + bioticRegion: 'Región Biótica', + maximumValues: [], + showErrorMessage: false, + }; + } + + componentDidMount() { + this.mounted = true; + const { + areaId, + geofenceId, + } = this.context; + + Promise.all([ + RestAPI.requestNumberOfSpecies(areaId, geofenceId, 'all'), + RestAPI.requestNSThresholds(areaId, geofenceId, 'all'), + RestAPI.requestNSNationalMax(areaId, 'all'), + ]) + .then(([values, thresholds, nationalMax]) => { + const data = []; + let region = null; + let showErrorMessage = false; + values.forEach((groupVal) => { + if (!region) region = groupVal.region_name; + const { id, ...limits } = thresholds.find((e) => e.id === groupVal.id); + showErrorMessage = groupVal.inferred > groupVal.region_inferred; + data.push({ + id: groupVal.id, + ranges: { + area: { + max: Math.max(limits.max_inferred, limits.max_observed), + max_inferred: limits.max_inferred, + max_observed: limits.max_observed, + }, + region: { + max: Math.max(groupVal.region_observed, groupVal.region_inferred), + region_observed: groupVal.region_observed, + region_inferred: groupVal.region_inferred, + }, + }, + markers: { + inferred: groupVal.inferred, + observed: groupVal.observed, + }, + measures: { + ...limits, + region_inferred: groupVal.region_inferred, + region_observed: groupVal.region_observed, + }, + title: '', + }); + }); + this.setState({ + allData: data, + maximumValues: nationalMax, + message: null, + bioticRegion: region, + showErrorMessage, + }, () => { + this.filter('inferred')(); + }); + }) + .catch(() => { + this.setState({ message: 'no-data' }); + }); + } + + componentWillUnmount() { + this.mounted = false; + } + + /** + * Show or hide the detailed information on each graph + */ + toggleInfoGraph = () => { + this.setState((prevState) => ({ + showInfoGraph: !prevState.showInfoGraph, + })); + }; + + /** + * Filter data by the given category + * + * @param {String} category category to filter by + * @returns void + */ + filter = (category) => () => { + const { allData, filter, selected } = this.state; + const { handlerClickOnGraph } = this.context; + if (category === filter) { + this.setState({ + data: allData.map((group) => ({ + ...group, + ranges: { + area: group.ranges.area.max, + region: group.ranges.region.max, + }, + })), + filter: 'all', + }); + handlerClickOnGraph({ + chartType: 'numberOfSpecies', + chartSection: 'all', + selectedKey: selected, + }); + } else { + const newData = allData.map((group) => { + const regex = new RegExp(`${category}$`); + const measureKeys = Object.keys(group.measures).filter((key) => regex.test(key)); + const areaKey = Object.keys(group.ranges.area).filter((key) => regex.test(key)); + const regionKey = Object.keys(group.ranges.region).filter((key) => regex.test(key)); + return { + id: group.id, + markers: { + [category]: group.markers[category], + }, + measures: measureKeys.reduce( + (result, key) => ({ ...result, [key]: group.measures[key] }), + {}, + ), + ranges: { + area: group.ranges.area[areaKey], + region: group.ranges.region[regionKey], + }, + }; + }); + this.setState({ data: newData, filter: category }); + handlerClickOnGraph({ + chartType: 'numberOfSpecies', + chartSection: category, + selectedKey: selected, + }); + } + } + + render() { + const { + areaId, + handlerClickOnGraph, + } = this.context; + const { + showInfoGraph, + message, + data, + selected, + maximumValues, + filter, + bioticRegion, + showErrorMessage, + } = this.state; + + let legends = ['inferred', 'min_inferred', 'max_inferred', 'region_inferred', + 'observed', 'min_observed', 'max_observed', 'region_observed']; + + if (filter !== 'all') { + legends = legends.filter((leg) => { + const regex = new RegExp(`${filter}$`); + return regex.test(leg); + }); + } + + return ( +
+

+ + this.toggleInfoGraph()} + /> + +

+ {( + showInfoGraph && ( + + ) + )} +

+ {NumberOfSpeciesTextHelper} +

+ {showErrorMessage && ( +
+ La riqueza inferida del área de consulta supera la de la región biótica en algunos + casos pues sus límites intersectan dos o más regiones bióticas. +
+ )} +
+ + {getLabel('inferred', areaId)} + + + {getLabel('observed', areaId)} + +
+
+ {message === 'no-data' && ( + + )} + {data.map((bar) => ( +
+
+
+ {getLabel(bar.id)} +
+
+
+ {(filter === 'all' || filter === 'inferred') && ( + <> + {getLabel('national_inferred', areaId)} + + {maximumValues.find((e) => e.id === bar.id).max_inferred} + + + )} + {filter === 'all' && ( +
+ )} + {(filter === 'all' || filter === 'observed') && ( + <> + {getLabel('national_observed', areaId)} + + {maximumValues.find((e) => e.id === bar.id).max_observed} + + + )} +
+
+ + + + {/* TODO: + Add I2D link when it's ready (import mappointlink and mappointlink2 images) + */} +
+
+
+
+ { + this.setState({ selected: bar.id }); + handlerClickOnGraph({ + chartType: 'numberOfSpecies', + chartSection: filter, + selectedKey: bar.id, + }); + }} + /> +
+
+ ))} +
+
+ {data[0] && Object.keys(data[0].ranges).map((key) => ( + + {getLabel(key, areaId, bioticRegion)} + + ))} + {data[0] && legends.map((key) => { + if (key === 'inferred' || key === 'observed') { + return ( + + {getLabel(`${key}2`, areaId, bioticRegion)} + + ); + } + return ( + + {getLabel(key, areaId, bioticRegion)} + + ); + })} +
+
+ ); + } +} + +export default NumberOfSpecies; + +NumberOfSpecies.contextType = SearchContext; diff --git a/src/pages/search/drawer/species/richness/SpeciesRecordsGaps.jsx b/src/pages/search/drawer/species/richness/SpeciesRecordsGaps.jsx new file mode 100644 index 000000000..503a1229f --- /dev/null +++ b/src/pages/search/drawer/species/richness/SpeciesRecordsGaps.jsx @@ -0,0 +1,228 @@ +import React from 'react'; +import InfoIcon from '@material-ui/icons/Info'; + +import { IconTooltip } from 'components/Tooltips'; +import GraphLoader from 'components/charts/GraphLoader'; +import { LineLegend } from 'components/CssLegends'; +import matchColor from 'utils/matchColor'; +import ShortInfo from 'components/ShortInfo'; +import SearchContext from 'pages/search/SearchContext'; +import RestAPI from 'utils/restAPI'; +import { SpeciesRecordsGapsText } from 'pages/search/drawer/species/richness/InfoTexts'; + +const areaTypeName = (areaType) => { + switch (areaType) { + case 'states': + return 'departamentos'; + case 'pa': + return 'áreas de manejo especial'; + case 'ea': + return 'jurisdicciones ambientales'; + case 'basinSubzones': + return 'subzonas hidrográficas'; + default: + return 'cerca'; + } +}; + +const getLabelGaps = (key, areaType) => ({ + value: 'Promedio de vacios en el área de consulta', + min: 'Mínimo del área de consulta', + max: 'Máximo del área de consulta', + min_threshold: `Mínimo por ${areaTypeName(areaType)}`, + max_threshold: `Máximo por ${areaTypeName(areaType)}`, +}[key] +); + +const getLabelConcentration = (key) => ({ + min: 'Mínimo del área de consulta', + max: 'Máximo del área de consulta', + min_threshold: 'Mínimo nacional', + max_threshold: 'Máximo nacional', + value: 'Promedio de representación en el área de consulta', +}[key] +); + +class SpeciesRecordsGaps extends React.Component { + mounted = false; + + constructor(props) { + super(props); + this.state = { + showInfoGraph: false, + gaps: {}, + concentration: {}, + message: 'loading', + selected: 'gaps', + }; + } + + componentDidMount() { + this.mounted = true; + const { + areaId, + geofenceId, + } = this.context; + + RestAPI.requestGaps(areaId, geofenceId) + .then((res) => { + if (this.mounted) { + this.setState({ + gaps: this.transformData(res), + message: null, + }); + } + }) + .catch(() => {}); + + RestAPI.requestConcentration(areaId, geofenceId) + .then((res) => { + if (this.mounted) { + this.setState({ + concentration: this.transformData(res), + message: null, + }); + } + }) + .catch(() => {}); + } + + componentWillUnmount() { + this.mounted = false; + } + + /** + * Transform data structure to be passed to graph component as a prop + * + * @param {Object} rawData raw data from RestAPI + */ + transformData = (rawData) => { + const { id, avg, ...limits } = rawData; + return { + id, + ranges: { + area: Math.max(limits.max, limits.max_threshold), + }, + markers: { + value: avg, + }, + measures: limits, + title: '', + }; + }; + + /** + * Show or hide the detailed information on each graph + */ + toggleInfoGraph = () => { + this.setState((prevState) => ({ + showInfoGraph: !prevState.showInfoGraph, + })); + }; + + render() { + const { areaId } = this.context; + const { + showInfoGraph, + message, + gaps, + concentration, + selected, + } = this.state; + return ( +
+

+ + this.toggleInfoGraph()} + /> + +

+ {( + showInfoGraph && ( + + ) + )} +
+ Vacios de datos +
+
+ { this.setState({ selected: 'gaps' }); }} + reverse + labelXLeft="Pocos datos" + labelXRight="Muchos datos" + /> +
+
+ {gaps.measures && Object.keys(gaps.measures).map((key) => ( + + {getLabelGaps(key, areaId)} + + + ))} + + {getLabelGaps('value', areaId)} + +
+
+
+ Concentración de registros +
+ 5 km x 5 km +
+
+ { this.setState({ selected: 'concentration' }); }} + labelXLeft="Poco representado" + labelXRight="Bien representado" + /> +
+
+ {concentration.measures && Object.keys(concentration.measures).map((key) => ( + + {getLabelConcentration(key, areaId)} + + + ))} + + {getLabelConcentration('value', areaId)} + +
+
+ ); + } +} + +export default SpeciesRecordsGaps; + +SpeciesRecordsGaps.contextType = SearchContext; diff --git a/src/utils/colorPalettes.js b/src/utils/colorPalettes.js index ec48253de..2bc2abecb 100644 --- a/src/utils/colorPalettes.js +++ b/src/utils/colorPalettes.js @@ -119,6 +119,28 @@ export default { '#92ab85', '#768a6b', ], + richnessNos: [ + '#fc6467', + '#9ba211', + '#E07979', + '#A4CF32', + '#F0314E', + '#88B22A', + '#B31012', + '#366B27', + '#fcc76f', + '#fba76d', + '#ffb56c', + '#007d8f', + ], + richnessGaps: [ + '#1348a6', + '#1997f2', + '#0f3669', + '#abc074', + '#717e4f', + '#fcbd64', + ], border: ['#fc6467'], default: ['#b2bdc2'], }; diff --git a/src/utils/matchColor.js b/src/utils/matchColor.js index 2d89ca820..499d5fba0 100644 --- a/src/utils/matchColor.js +++ b/src/utils/matchColor.js @@ -101,6 +101,36 @@ const match = { palette: 'timelinePAConn', sort: ['prot', 'protSel', 'prot_conn', 'prot_connSel'], }, + richnessNos: { + palette: 'richnessNos', + // first values, then limits, then backgrounds, then legend limits + sort: [ + 'inferred', + 'observed', + 'min_inferred', + 'min_observed', + 'max_inferred', + 'max_observed', + 'region_inferred', + 'region_observed', + 'area', + 'region', + 'legend-from', + 'legend-to', + ], + }, + richnessGaps: { + palette: 'richnessGaps', + // first values, then limits, then backgrounds + sort: [ + 'value', + 'min', + 'max', + 'min_threshold', + 'max_threshold', + 'area', + ], + }, border: { palette: 'border', }, @@ -184,6 +214,8 @@ const matchColor = (type, resetCache = false) => { case 'paramo': case 'dryForest': case 'wetland': + case 'richnessNos': + case 'richnessGaps': return (value) => { const idx = sort.indexOf(value); if (idx === -1) return null; diff --git a/src/utils/restAPI.js b/src/utils/restAPI.js index 7a4259fba..e05d48e38 100644 --- a/src/utils/restAPI.js +++ b/src/utils/restAPI.js @@ -304,7 +304,7 @@ class RestAPI { * * @return {Promise} Array of objects with data of current PA connectivity */ - static requestCurrentPAConnectivity(areaType, areaId) { + static requestCurrentPAConnectivity(areaType, areaId) { return RestAPI.makeGetRequest(`connectivity/current?areaType=${areaType}&areaId=${areaId}`); } @@ -316,7 +316,7 @@ class RestAPI { * * @return {Promise} Array of objects with data of the protected areas */ - static requestDPC(areaType, areaId, paNumber) { + static requestDPC(areaType, areaId, paNumber) { return RestAPI.makeGetRequest(`connectivity/dpc?areaType=${areaType}&areaId=${areaId}&paNumber=${paNumber}`); } @@ -329,7 +329,7 @@ class RestAPI { * * @return {Promise} Array of objects with data of timeline PA connectivity */ - static requestTimelinePAConnectivity(areaType, areaId, category) { + static requestTimelinePAConnectivity(areaType, areaId, category) { return RestAPI.makeGetRequest(`connectivity/timeline?areaType=${areaType}&areaId=${areaId}&category=${category}`); } @@ -343,10 +343,80 @@ class RestAPI { * * @return {Promise} Array of objects with data of current PA connectivity by SE */ - static requestCurrentPAConnectivityBySE(areaType, areaId, seType) { + static requestCurrentPAConnectivityBySE(areaType, areaId, seType) { return RestAPI.makeGetRequest(`connectivity/current/se?areaType=${areaType}&areaId=${areaId}&seType=${seType}`); } + /** + * Get the number of species for the specified area + * + * @param {String} areaType area type id, f.e. "ea", "states" + * @param {Number | String} areaId area id to request, f.e. "CRQ", 24 + * @param {String} group group to filter results + * + * @return {Promise} Array of objects with observed, inferred and region number of species + */ + static requestNumberOfSpecies(areaType, areaId, group) { + return RestAPI.makeGetRequest( + `richness/number-species?areaType=${areaType}&areaId=${areaId}${group ? `&group=${group}` : ''}`, + ); + } + + /** + * Get the thresholds for the number of species in the same biotic unit as the specified area id + * + * @param {String} areaType area type id, f.e. "ea", "states" + * @param {Number | String} areaId area id to request, f.e. "CRQ", 24 + * @param {String} group group to filter results + * + * @return {Promise} Array of objects with minimum and maximun number of observed and + * inferred species + */ + static requestNSThresholds(areaType, areaId, group) { + return RestAPI.makeGetRequest( + `richness/number-species/thresholds?areaType=${areaType}&areaId=${areaId}${group ? `&group=${group}` : ''}`, + ); + } + + /** + * Get the national max values specified area type + * + * @param {String} areaType area type id, f.e. "ea", "states" + * @param {String} group group to filter results + * + * @return {Promise} Array of objects with minimum and maximun number of observed and + * inferred species + */ + static requestNSNationalMax(areaType, group) { + return RestAPI.makeGetRequest( + `richness/number-species/nationalMax?areaType=${areaType}${group ? `&group=${group}` : ''}`, + ); + } + + /** + * Get values for richness species gaps in the given area + * + * @param {String} areaType area type id, f.e. "ea", "states" + * @param {Number | String} areaId area id to request, f.e. "CRQ", 24 + * + * @return {Promise} Object with values of richness species gaps + */ + static requestGaps(areaType, areaId) { + return RestAPI.makeGetRequest(`richness/gaps?areaType=${areaType}&areaId=${areaId}`); + } + + /** + * Get values for richness species concentration in the given area + * + * @param {String} areaType area type id, f.e. "ea", "states" + * @param {Number | String} areaId area id to request, f.e. "CRQ", 24 + * + * @return {Promise} Object with values of richness species concentration + */ + static requestConcentration(areaType, areaId) { + return RestAPI.makeGetRequest(`richness/concentration?areaType=${areaType}&areaId=${areaId}`); + } + /** ******************** */ /** MAPS - SEARCH MODULE */ /** ******************** */ @@ -581,6 +651,44 @@ class RestAPI { } } + /** + * Get the layer of number of species for the specified group + * + * @param {String} areaType area type id, f.e. "ea", "states" + * @param {Number | String} areaId area id to request, f.e. "CRQ", 24 + * @param {String} group group to get the layer for, options are: total | endemic | invasive | + * threatened + * + * @return {Promise} layer object to be loaded in the map + */ + static requestNOSLayer(areaType, areaId, group) { + const source = CancelToken.source(); + return { + request: RestAPI.makeGetRequest( + `richness/number-species/layer?areaType=${areaType}&areaId=${areaId}&group=${group}`, + { cancelToken: source.token, responseType: 'arraybuffer' }, + true, + ), + source, + }; + } + + /** + * Get the threshold values for the layer of number of species for the specified group + * + * @param {String} areaType area type id, f.e. "ea", "states" + * @param {Number | String} areaId area id to request, f.e. "CRQ", 24 + * @param {String} group group to get the layer for, options are: total | endemic | invasive | + * threatened + * + * @return {Promise} object with min an max values + */ + static requestNOSLayerThresholds(areaType, areaId, group) { + return RestAPI.makeGetRequest( + `richness/number-species/layer/thresholds?areaType=${areaType}&areaId=${areaId}&group=${group}`, + ); + } + /** ******************* */ /** COMPENSATION MODULE */ /** ******************* */ @@ -739,7 +847,7 @@ class RestAPI { * * @param {String} endpoint endpoint to attach to url */ - static makeGetRequest(endpoint, options) { + static makeGetRequest(endpoint, options = {}, completeRes = false) { const config = { ...options, headers: { @@ -747,7 +855,12 @@ class RestAPI { }, }; return axios.get(`${process.env.REACT_APP_BACKEND_URL}/${endpoint}`, config) - .then((res) => res.data) + .then((res) => { + if (completeRes) { + return res; + } + return res.data; + }) .catch((error) => { if (axios.isCancel(error)) { return Promise.resolve('request canceled'); diff --git a/yarn.lock b/yarn.lock index 4245856b4..7c32f8878 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2620,6 +2620,23 @@ __metadata: languageName: node linkType: hard +"@nivo/bullet@npm:^0.69.1": + version: 0.69.1 + resolution: "@nivo/bullet@npm:0.69.1" + dependencies: + "@nivo/axes": 0.69.1 + "@nivo/colors": 0.69.0 + "@nivo/legends": 0.69.0 + "@nivo/tooltip": 0.69.0 + d3-scale: ^3.2.3 + react-spring: 9.1.2 + peerDependencies: + "@nivo/core": 0.69.0 + react: ">= 16.8.4 < 18.0.0" + checksum: 755e9419a0cf0b9174e0eebe17e4b7f842b666e1420db608289dc0fef953a5923018c1ac5379edc56dd0e34847cf66f85d735d8b29a03dc2d55b58ba7721f30b + languageName: node + linkType: hard + "@nivo/colors@npm:0.69.0": version: 0.69.0 resolution: "@nivo/colors@npm:0.69.0" @@ -2736,7 +2753,7 @@ __metadata: languageName: node linkType: hard -"@nivo/tooltip@npm:0.69.0": +"@nivo/tooltip@npm:0.69.0, @nivo/tooltip@npm:^0.69.0": version: 0.69.0 resolution: "@nivo/tooltip@npm:0.69.0" dependencies: @@ -5333,9 +5350,11 @@ __metadata: "@material-ui/icons": ^4.9.1 "@material-ui/lab": ^4.0.0-alpha.57 "@nivo/bar": ^0.69.0 + "@nivo/bullet": ^0.69.1 "@nivo/core": ^0.69.0 "@nivo/line": ^0.69.0 "@nivo/pie": ^0.69.0 + "@nivo/tooltip": ^0.69.0 "@vx/axis": 0.0.170 "@vx/glyph": 0.0.170 "@vx/grid": 0.0.170 @@ -5363,6 +5382,7 @@ __metadata: react-masonry-component: 6.2.1 react-router-dom: 5.2.0 react-scripts: 4.0.2 + react-spring: ^9.1.2 styled-components: ^5.2.3 languageName: unknown linkType: soft @@ -17513,7 +17533,7 @@ fsevents@^1.2.7: languageName: node linkType: hard -"react-spring@npm:9.1.2": +"react-spring@npm:9.1.2, react-spring@npm:^9.1.2": version: 9.1.2 resolution: "react-spring@npm:9.1.2" dependencies: