From 45f902a5431739439ed2c90f4fdf355a9cda6641 Mon Sep 17 00:00:00 2001 From: Tomasz Subik Date: Fri, 27 Oct 2023 13:18:08 +0200 Subject: [PATCH] add observation tooltip and improve observations map interactions --- components/map/popup/component.js | 9 +- .../popup/templates/observation/component.js | 128 ++++++++++++++++++ .../map/popup/templates/observation/index.js | 3 + components/operators-detail/fmus.js | 4 +- css/components/map/_popup.scss | 39 +++++- modules/observations.js | 1 - pages/observations.js | 125 ++++++++++++----- pages/operators/index.js | 4 +- .../observations/parsed-map-observations.js | 22 +-- utils/observations.js | 72 +++++----- 10 files changed, 313 insertions(+), 94 deletions(-) create mode 100644 components/map/popup/templates/observation/component.js create mode 100644 components/map/popup/templates/observation/index.js diff --git a/components/map/popup/component.js b/components/map/popup/component.js index 4fd7d644..33bf27b9 100644 --- a/components/map/popup/component.js +++ b/components/map/popup/component.js @@ -10,16 +10,19 @@ import Icon from 'components/ui/icon'; // import LayerTemplate from './templates/layer'; import FmuTemplate from './templates/fmu'; import FmuTemplateAAC from './templates/fmu-aac'; +import ObservationTemplate from './templates/observation'; const TEMPLATES = { fmus: FmuTemplate, - 'fmus-detail': FmuTemplateAAC + 'fmus-detail': FmuTemplateAAC, + observation: ObservationTemplate }; class PopupComponent extends PureComponent { static propTypes = { popup: PropTypes.shape({}).isRequired, template: PropTypes.string.isRequired, + templateProps: PropTypes.shape({}), onClose: PropTypes.func }; @@ -52,7 +55,7 @@ class PopupComponent extends PureComponent { } render() { - const { popup, template, onClose } = this.props; + const { popup, template, templateProps, onClose } = this.props; if (isEmpty(popup)) return null; @@ -72,7 +75,7 @@ class PopupComponent extends PureComponent { {!!TEMPLATES[template] && React.createElement(TEMPLATES[template], { - ...this.props + ...templateProps }) } diff --git a/components/map/popup/templates/observation/component.js b/components/map/popup/templates/observation/component.js new file mode 100644 index 00000000..3e945465 --- /dev/null +++ b/components/map/popup/templates/observation/component.js @@ -0,0 +1,128 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +// Intl +import { useIntl } from 'react-intl'; + +import Icon from 'components/ui/icon'; + +const SEVERITY_LEVELS = ['unknown', 'low', 'medium', 'high']; + +function parseValue(value) { + try { + return JSON.parse(value); + } catch (e) { + return value; + } +} + +export default function ObservationPopup({ data }) { + const intl = useIntl(); + const evidence = parseValue(data.evidence); + const fields = [ + { + label: 'Category', + value: data.category + }, + data.subcategory && { + label: 'Subcategory', + value: data.subcategory + }, + data.operator && { + label: 'Operator', + value: data.operator, + }, + { + label: 'Country', + value: data.country + }, + data.fmu && { + label: 'FMU', + value: data.fmu + }, + { + label: 'Severity', + value: ( +
+
+
+ {intl.formatMessage({ id: SEVERITY_LEVELS[data.level] })} +
+
+ ) + }, + data['litigation-status'] && { + label: 'Litigation Status', + value: data['litigation-status'] + }, + { + label: 'Monitors', + value: data['observer-organizations'] + }, + evidence && { + label: 'Evidence', + value: ( +
+ {Array.isArray(evidence) && + evidence.map((v) => ( + + + + ))} + + {!Array.isArray(evidence) && ( + {evidence} + )} +
+ ) + }, + data.report && { + label: 'Report', + value: ( + + + + ) + } + ].filter(x => x); + + return ( +
+

+ Observation Details +

+ +

+ {data.observation} +

+ + + + {fields.map(o => ( + + + + + ))} + +
{o.label}:{o.value}
+
+ ); +} + +ObservationPopup.propTypes = { + data: PropTypes.object +}; diff --git a/components/map/popup/templates/observation/index.js b/components/map/popup/templates/observation/index.js new file mode 100644 index 00000000..0a4b703c --- /dev/null +++ b/components/map/popup/templates/observation/index.js @@ -0,0 +1,3 @@ +import ObservationPopupComponent from './component'; + +export default ObservationPopupComponent; diff --git a/components/operators-detail/fmus.js b/components/operators-detail/fmus.js index b11a4894..11d458bd 100644 --- a/components/operators-detail/fmus.js +++ b/components/operators-detail/fmus.js @@ -300,7 +300,9 @@ class OperatorsDetailFMUs extends React.Component { )} diff --git a/css/components/map/_popup.scss b/css/components/map/_popup.scss index 958ec7a7..af04449d 100644 --- a/css/components/map/_popup.scss +++ b/css/components/map/_popup.scss @@ -45,6 +45,10 @@ max-width: 300px; } + @include breakpoint(large) { + max-width: 400px; + } + .layer-popup--title { position: relative; margin: 0 0 $space-1 * 2; @@ -67,7 +71,8 @@ padding: 0 0 $space-1 * 1.5; font-weight: $font-weight-bold; vertical-align: baseline; - // text-align: right; + width: 1%; + white-space: nowrap; } .layer-popup--list-dd { @@ -81,4 +86,36 @@ margin: 0; padding-left: $space-1 * 2; } + + .severity { + display: flex; + align-items: center; + gap: 5px; + margin-top: -5px; + + &__icon { + width: 12px; + height: 12px; + border-radius: 2px; + + &.-severity-0 { background: $color-gray-3; } + &.-severity-1 { background: $color-primary; } + &.-severity-2 { background: $color-gray-1; } + &.-severity-3 { background: $color-secondary; } + } + + &__name { + text-transform: uppercase; + font-family: $font-family-proximanova; + font-weight: $font-weight-bold; + font-size: $font-size-extrasmall; + margin-top: 5px; + letter-spacing: 1px; + } + } + + .evidences { + display: flex; + gap: 2px; + } } diff --git a/modules/observations.js b/modules/observations.js index 03b44db2..7cc874c0 100644 --- a/modules/observations.js +++ b/modules/observations.js @@ -101,7 +101,6 @@ export default function reducer(state = initialState, action) { case SET_OBSERVATIONS_MAP_CLUSTER: { return Object.assign({}, state, { cluster: action.payload }); } - default: return state; } diff --git a/pages/observations.js b/pages/observations.js index fc77f27a..4002ce95 100644 --- a/pages/observations.js +++ b/pages/observations.js @@ -32,6 +32,7 @@ import StaticTabs from 'components/ui/static-tabs'; import Map from 'components/map'; import LayerManager from 'components/map/layer-manager'; import Legend from 'components/map/legend'; +import Popup from 'components/map/popup'; import MapControls from 'components/map/map-controls'; import ZoomControl from 'components/map/controls/zoom-control'; import FAAttributions from 'components/map/fa-attributions'; @@ -43,7 +44,7 @@ import { getObservationsUrl, setActiveColumns, setObservationsMapLocation, - setObservationsMapCluster, + setObservationsMapCluster } from 'modules/observations'; import { FILTERS_REFS } from 'constants/observations'; @@ -72,6 +73,7 @@ class ObservationsPage extends React.Component { this.state = { tab: this.props.url.query.subtab || 'observations-list', + popup: null, page: 1, }; @@ -127,51 +129,86 @@ class ObservationsPage extends React.Component { }, 500); onViewportChange = (mapLocation) => { - this.props.setObservationsMapCluster({}); + // if zoom level changes (rounding as sometimes is like 4.999999...) then hide open cluster + if (Math.round(this.props.observations.map.zoom) != Math.round(mapLocation.zoom)) { + this.props.setObservationsMapCluster({}); + } this.setMapLocation(mapLocation); }; onClick = (e) => { - const { cluster: clusterProp } = this.props.observations; + const { cluster: clusterProp, map } = this.props.observations; + + if (e.features && e.features.length && !e.target.classList.contains('mapbox-prevent-click')) { // No better way to do this + const { features, lngLat } = e; + const feature = features[0]; - const { features } = e; - if (features && features.length) { const { source, geometry, properties } = features[0]; const { cluster, cluster_id: clusterId, point_count } = properties; - if (cluster && +clusterId !== +clusterProp.id) { - const layers = this.map - .getStyle() - .layers.filter((l) => l.source === source); - - this.map - .getSource(source) - .getClusterLeaves(clusterId, point_count, 0, (error, fts) => { - if (error) { - this.props.setObservationsMapCluster({}); - return true; - } - - this.props.setObservationsMapCluster({ - id: clusterId, - coordinates: geometry.coordinates, - features: orderBy(fts, 'properties.level'), - layers, + if (cluster) { + if (+clusterId !== +clusterProp.id) { + if (map.zoom < 12 && point_count > 16) { + this.props.setObservationsMapLocation({ + ...map, + longitude: lngLat[0], + latitude: lngLat[1], + zoom: map.zoom + 2 + }); + return; + } + + const layers = this.map + .getStyle() + .layers.filter((l) => l.source === source); + + this.map + .getSource(source) + .getClusterLeaves(clusterId, point_count, 0, (error, fts) => { + if (error) { + this.props.setObservationsMapCluster({}); + return true; + } + + this.props.setObservationsMapCluster({ + id: clusterId, + coordinates: geometry.coordinates, + features: orderBy(fts, 'properties.level'), + layers, + }); + + return fts; }); + } else { + this.props.setObservationsMapCluster({}); + } - return fts; - }); + if (this.state.popup) { + this.setState({ popup: null }); + } } else { - this.props.setObservationsMapCluster({}); + this.setState({ + popup: { + props: { + longitude: lngLat[0], + latitude: lngLat[1], + }, + template: 'observation', + templateProps: { + data: feature.properties + } + } + }); + if (feature.layer.id !== 'observations-leaves') { + this.props.setObservationsMapCluster({}); + } } - } else { - this.props.setObservationsMapCluster({}); } - }; + } jumpToStaticHeader() { const element = document.querySelector('header.c-static-tabs'); - element.scrollIntoView({ behavior: 'smooth', block: 'start'}); + element.scrollIntoView({ behavior: 'smooth', block: 'start' }); } onShowObservations = () => { @@ -224,6 +261,7 @@ class ObservationsPage extends React.Component { parsedTableObservations, parsedChartObservations, } = this.props; + const { popup } = this.state; const changeOfLabelLookup = { level: 'severity', @@ -240,6 +278,15 @@ class ObservationsPage extends React.Component { value: column, })); + const interactiveLayerIds = [ + 'observations-circle-0', + 'observations-symbol-1', + 'observations-circle-2' + ] + if (this.props.observations.cluster.id) { + interactiveLayerIds.push('observations-leaves'); + } + return ( Forest Atlas', + 'Forest Atlas', }} > {(map) => ( {/* LAYER MANAGER */} + {popup && ( + this.setState({ popup: null })} + /> + )} )} @@ -398,7 +449,7 @@ class ObservationsPage extends React.Component { layerGroups={getObservationsLegend} sortable={false} toolbar={<>} - setLayerSettings={() => {}} + setLayerSettings={() => { }} /> {/* MapControls */} diff --git a/pages/operators/index.js b/pages/operators/index.js index 48bdf4d5..3fce5889 100644 --- a/pages/operators/index.js +++ b/pages/operators/index.js @@ -171,7 +171,9 @@ class OperatorsPage extends React.Component { this.props.setOperatorsMapInteractions({})} /> {/* LAYER MANAGER */} diff --git a/selectors/observations/parsed-map-observations.js b/selectors/observations/parsed-map-observations.js index 54d698cb..dbcd9647 100644 --- a/selectors/observations/parsed-map-observations.js +++ b/selectors/observations/parsed-map-observations.js @@ -6,6 +6,8 @@ import sortBy from 'lodash/sortBy'; import { createSelector } from 'reselect'; import { spiderifyCluster } from 'components/map/layer-manager/utils'; +import { parseObservation } from 'utils/observations'; + const intl = (state, props) => props && props.intl; // Get the datasets and filters from state @@ -64,6 +66,7 @@ const getObservationsLayers = createSelector( metadata: { position: 'top' }, + id: 'observations-leaves', type: 'circle', paint: { 'circle-radius': 6, @@ -91,7 +94,7 @@ const getObservationsLayers = createSelector( layers: [ { metadata: { - position: 'top' + // position: 'top' }, type: 'line', paint: { @@ -128,21 +131,10 @@ const getObservationsLayers = createSelector( features: features.map(obs => ({ type: 'Feature', properties: { - id: obs.id, - date: new Date(obs['observation-report'] && obs['observation-report']['publication-date']).getFullYear(), - country: obs.country.iso, - operator: !!obs.operator && obs.operator.name, - category: obs?.subcategory?.category?.name, - observation: obs.details, - level: obs.severity && obs.severity.level, + ...parseObservation(obs), + 'observer-organizations': obs.observers.map(o => o.name).join(', '), fmu: !!obs.fmu && obs.fmu.name, - report: obs['observation-report'] ? obs['observation-report'].attachment.url : null, - 'operator-type': obs.operator && obs.operator.type, - subcategory: obs?.subcategory?.name, - evidence: obs.evidence, - 'litigation-status': obs['litigation-status'], - 'observer-types': obs.observers.map(observer => observer['observer-type']), - 'observer-organizations': obs.observers.map(observer => observer.organization) + country: obs.country.name }, geometry: { type: 'Point', diff --git a/utils/observations.js b/utils/observations.js index 7ddf5858..7d3d1942 100644 --- a/utils/observations.js +++ b/utils/observations.js @@ -116,41 +116,43 @@ function getLocation(obs = {}) { } function parseObservations(data) { - return data.map((obs) => { - const evidence = - obs['evidence-type'] !== 'Evidence presented in the report' - ? obs['observation-documents'] - : obs['evidence-on-report']; + return data.map(parseObservation); +} - return { - category: obs?.subcategory?.category?.name || '', - country: obs.country?.iso || '', - rawdate: new Date(obs['observation-report'] && obs['observation-report']['publication-date']), - date: new Date(obs['observation-report'] && obs['observation-report']['publication-date']).getFullYear(), - details: obs.details, - evidence, - fmu: obs.fmu, - id: obs.id, - level: obs.severity && obs.severity.level, - operator: !!obs.operator && obs.operator.name, - observation: obs.details, - location: getLocation(obs), - 'location-accuracy': obs['location-accuracy'], - 'operator-type': obs.operator && obs.operator['operator-type'], - report: obs['observation-report'] - ? obs['observation-report'].attachment.url - : null, - subcategory: obs?.subcategory?.name || '', - status: obs['validation-status-id'], - 'litigation-status': obs['litigation-status'], - 'observer-types': (obs.observers || []).map( - (observer) => observer['observer-type'] - ), - 'observer-organizations': obs.observers, - 'relevant-operators': (obs['relevant-operators'] || []).map((o) => o.name), - hidden: obs.hidden - }; - }); +function parseObservation(obs) { + const evidence = + obs['evidence-type'] !== 'Evidence presented in the report' + ? obs['observation-documents'] + : obs['evidence-on-report']; + + return { + category: obs?.subcategory?.category?.name || '', + country: obs.country?.iso || '', + rawdate: new Date(obs['observation-report'] && obs['observation-report']['publication-date']), + date: new Date(obs['observation-report'] && obs['observation-report']['publication-date']).getFullYear(), + details: obs.details, + evidence, + fmu: obs.fmu, + id: obs.id, + level: obs.severity && obs.severity.level, + operator: !!obs.operator && obs.operator.name, + observation: obs.details, + location: getLocation(obs), + 'location-accuracy': obs['location-accuracy'], + 'operator-type': obs.operator && obs.operator['operator-type'], + report: obs['observation-report'] + ? obs['observation-report'].attachment.url + : null, + subcategory: obs?.subcategory?.name || '', + status: obs['validation-status-id'], + 'litigation-status': obs['litigation-status'], + 'observer-types': (obs.observers || []).map( + (observer) => observer['observer-type'] + ), + 'observer-organizations': obs.observers, + 'relevant-operators': (obs['relevant-operators'] || []).map((o) => o.name), + hidden: obs.hidden + }; } -export { HELPERS_OBS, parseObservations }; +export { HELPERS_OBS, parseObservations, parseObservation };