diff --git a/.circleci/config.yml b/.circleci/config.yml index 0b85e0c7..e507c108 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -42,8 +42,7 @@ jobs: - checkout - restore_cache: <<: *restore_cache_def - # - run: yarn lint - - run: echo Skip linting + - run: yarn lint test: <<: *base_def steps: diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..81c36d42 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,28 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Firefox", + "type": "firefox", + "request": "launch", + "reAttach": true, + "url": "http://localhost:9000", + "webRoot": "${workspaceFolder}", + "pathMappings": [ + { + "url": "http://localhost:9000/assets/scripts/app", + "path": "${workspaceFolder}/app" + } + ] + }, + { + "name": "Chrome", + "type": "chrome", + "request": "launch", + "url": "http://localhost:9000", + "sourceMapPathOverrides": { + "*": "${workspaceFolder}/*" + } + } + ] +} \ No newline at end of file diff --git a/app/assets/graphics/layout/app-logo-sprites.png b/app/assets/graphics/layout/app-logo-sprites.png new file mode 100644 index 00000000..f3767e87 Binary files /dev/null and b/app/assets/graphics/layout/app-logo-sprites.png differ diff --git a/app/assets/scripts/components/about/index.js b/app/assets/scripts/components/about/index.js index aa8c4cc9..c5bf9379 100644 --- a/app/assets/scripts/components/about/index.js +++ b/app/assets/scripts/components/about/index.js @@ -8,88 +8,23 @@ import { InpageHeaderInner, InpageHeadline, InpageTitle, - InpageBody, - InpageBodyInner + InpageBody } from '../../styles/inpage'; -import Prose from '../../styles/type/prose'; -import Dl from '../../styles/type/definition-list'; - -import media from '../../styles/utils/media-queries'; -import { glsp } from '../../styles/utils/theme-values'; -import { visuallyHidden } from '../../styles/helpers'; -import { themeVal } from '../../styles/utils/general'; - -const AboutProse = styled(Prose)` - max-width: 40rem; -`; - -const LogoList = styled(Dl)` - display: grid; - grid-template-columns: repeat(12, 1fr); - grid-gap: 0 ${glsp()}; - list-style: none; - padding: 0; - margin: 0; - dt { - grid-column: 1 / span 12; - - &:not(:first-child) { - margin-top: ${glsp()}; - } - } - - dd { - grid-column: auto / span 12; - - ${media.smallUp` - grid-column: auto / span 4; - `} - } -`; +import { + Fold, + FoldTitle +} from '../../styles/fold'; -const LogoLink = styled.a` - display: flex; - flex-direction: column; - flex: 1 1 100%; - align-items: center; - justify-content: center; - padding: 1rem; - height: 6rem; - position: relative; - z-index: 1; - border-radius: ${themeVal('shape.rounded')}; - box-shadow: inset 0 0 0 1px ${themeVal('color.baseAlphaB')}; - transition: all .16s ease 0s; +import Constrainer from '../../styles/constrainer'; - span { - ${visuallyHidden()} - } +import Prose from '../../styles/type/prose'; - img { - display: inline-flex; - width: auto; - max-width: 100%; - max-height: 3rem; - } +const AboutProse = styled(Prose)` + grid-row: 1; + grid-column: span 8; `; -// Compensate for mb logo proportions. -// const LogoLinkMb = styled(LogoLink)` -// img { -// max-height: 2rem; -// } -// `; - -// Compensate for AWS logo proportions. -// const LogoLinkAWS = styled(LogoLink)` -// padding: ${glsp(0.5, 1)}; - -// img { -// max-height: 4.5rem; -// } -// `; - export default class About extends React.Component { render () { return ( @@ -103,30 +38,18 @@ export default class About extends React.Component { - - -

The tool

-

The COVID EO dashbord provides streaming data about the pandemic to inform decisionmakers in government, community leaders, health responders, and the business community.

- - {/* -
Developed by
-
- - Development Seed logo - Development Seed - -
-
*/} -
-
+ + + + The tool +

+ The COVID EO dashbord provides streaming data about the + pandemic to inform decisionmakers in government, community + leaders, health responders, and the business community. +

+
+
+
diff --git a/app/assets/scripts/components/common/app.js b/app/assets/scripts/components/common/app.js index 12b4dc00..c08c60b0 100644 --- a/app/assets/scripts/components/common/app.js +++ b/app/assets/scripts/components/common/app.js @@ -15,7 +15,7 @@ const { appTitle, appDescription } = config; const Page = styled(SizeAwareElement)` display: grid; - grid-template-rows: 3rem auto 0; + grid-template-rows: minmax(3rem, min-content) auto 0; min-height: 100vh; `; diff --git a/app/assets/scripts/components/common/dropdown.js b/app/assets/scripts/components/common/dropdown.js index e4baa681..eadda7eb 100644 --- a/app/assets/scripts/components/common/dropdown.js +++ b/app/assets/scripts/components/common/dropdown.js @@ -558,18 +558,18 @@ export const DropInset = styled.div` background: ${_tint(0.96, themeVal('color.base'))}; color: ${_tint(0.32, themeVal('type.base.color'))}; box-shadow: - inset 0 ${themeVal('layout.border')} 0 0 ${themeVal('color.shadow')}, - inset 0 -${themeVal('layout.border')} 0 0 ${themeVal('color.shadow')}; + inset 0 ${themeVal('layout.border')} 0 0 ${themeVal('color.baseAlphaB')}, + inset 0 -${themeVal('layout.border')} 0 0 ${themeVal('color.baseAlphaB')}; margin: -${glbS} -${glbS} ${glbS} -${glbS}; padding: ${glbS}; &:first-child { - box-shadow: inset 0 -${themeVal('layout.border')} 0 0 ${themeVal('color.shadow')}; + box-shadow: inset 0 -${themeVal('layout.border')} 0 0 ${themeVal('color.baseAlphaB')}; } &:last-child { margin-bottom: -${glbS}; - box-shadow: inset 0 ${themeVal('layout.border')} 0 0 ${themeVal('color.shadow')}; + box-shadow: inset 0 ${themeVal('layout.border')} 0 0 ${themeVal('color.baseAlphaB')}; border-radius: 0 0 ${themeVal('shape.rounded')} ${themeVal('shape.rounded')}; } diff --git a/app/assets/scripts/components/common/gradient-legend-chart/chart.js b/app/assets/scripts/components/common/gradient-legend-chart/chart.js new file mode 100644 index 00000000..c983b8b6 --- /dev/null +++ b/app/assets/scripts/components/common/gradient-legend-chart/chart.js @@ -0,0 +1,128 @@ +import React from 'react'; +import styled from 'styled-components'; +import * as d3 from 'd3'; + +import SizeAwareElement from '../../common/size-aware-element'; + +import trackLayer from './track.layer'; +import knobLayer from './knob.layer'; + +const ChartWrapper = styled.div` + flex-grow: 1; + + svg { + display: block; + width: 100%; + height: 1.25rem; + } + + ${trackLayer.styles} + ${knobLayer.styles} +`; + +class DataBrowserChart extends React.Component { + constructor (props) { + super(props); + this.margin = { top: 0, right: 8, bottom: 0, left: 8 }; + // Control whether the chart was rendered. + // The size aware element fires a onChange event once it is rendered + // But at that time the chart is not ready yet so we can't update the size. + this.initialized = false; + + this.container = null; + this.svg = null; + this.dataCanvas = null; + + this.resizeListener = this.resizeListener.bind(this); + + this.trackSize = 8; + } + + componentDidMount () { + this.initChart(); + this.updateChart(); + this.initialized = true; + } + + componentDidUpdate (prevProps, prevState) { + this.updateChart(); + } + + resizeListener () { + if (!this.initialized) return; + this.updateChart(); + } + + getSize () { + if (!this.container) return { width: 0, height: 0 }; + const { top, bottom, right, left } = this.margin; + const { width, height } = this.container.getSize(); + return { + width: parseInt(width, 10) - left - right, + height: parseInt(height, 10) - top - bottom + }; + } + + initChart () { + const { top, left } = this.margin; + const containerEl = this.container.elRef.current; + + this.svg = d3 + .select(containerEl) + .append('svg') + .attr('class', 'chart'); + + // SVG definitions. + this.svg.append('defs'); + + this.dataCanvas = this.svg + .append('g') + .attr('class', 'data-canvas') + .attr('transform', `translate(${left},${top})`); + + trackLayer.init(this); + knobLayer.init(this); + } + + updateChart () { + const { top, bottom, right, left } = this.margin; + const { width, height } = this.getSize(); + const { svg, dataCanvas } = this; + + // --------------------------------------------------- + // Functions + this.xScale = d3 + .scaleLinear() + .domain([0, 100]) + .range([0, width]); + + // --------------------------------------------------- + // Size updates + svg + .attr('width', width + left + right) + .attr('height', height + top + bottom); + + dataCanvas.attr('width', width).attr('height', height); + + trackLayer.update(this); + knobLayer.update(this); + } + + render () { + return ( + { + this.container = el; + }} + onChange={this.resizeListener} + /> + ); + } +} + +DataBrowserChart.propTypes = { +}; + +export default DataBrowserChart; diff --git a/app/assets/scripts/components/common/gradient-legend-chart/knob.layer.js b/app/assets/scripts/components/common/gradient-legend-chart/knob.layer.js new file mode 100644 index 00000000..d23cd23f --- /dev/null +++ b/app/assets/scripts/components/common/gradient-legend-chart/knob.layer.js @@ -0,0 +1,65 @@ +import * as d3 from 'd3'; +import { css } from 'styled-components'; +import { tint } from 'polished'; + +import { themeVal, stylizeFunction } from '../../../styles/utils/general'; + +const _tint = stylizeFunction(tint); + +const styles = props => css` + .knob { + fill: #fff; + stroke: ${_tint(0.48, themeVal('color.base'))}; + cursor: grab; + + &:active { + cursor: grabbing; + } + } +`; + +const knobSize = 10; +const knobTip = knobSize * 1.5; +const knobPoints = [ + [0, 0], + [knobSize, 0], + [knobSize, knobSize], + [knobSize / 2, knobTip], + [0, knobSize], + [0, 0] +]; + +export default { + styles, + init: ctx => { + const controls = ctx.dataCanvas.append('g') + .attr('class', 'controls'); + + const knob = d3.line(); + + controls.append('path') + .attr('class', 'knob') + .attr('d', knob(knobPoints)); + }, + + update: ctx => { + const { dataCanvas, props, xScale, trackSize } = ctx; + const { width, height } = ctx.getSize(); + + const dragEvent = (end) => { + const newPos = Math.max(0, Math.min(d3.event.x, width)); + ctx.props.onAction('knob.set', { + value: Math.round(xScale.invert(newPos)), + end + }); + }; + + const dragger = d3.drag() + .on('drag', () => dragEvent()) + .on('end', () => dragEvent(true)); + + dataCanvas.select('.knob') + .attr('transform', `translate(${xScale(props.knobPos) - knobSize / 2}, ${height - trackSize / 2 - knobTip})`) + .call(dragger); + } +}; diff --git a/app/assets/scripts/components/common/gradient-legend-chart/track.layer.js b/app/assets/scripts/components/common/gradient-legend-chart/track.layer.js new file mode 100644 index 00000000..198ec9d4 --- /dev/null +++ b/app/assets/scripts/components/common/gradient-legend-chart/track.layer.js @@ -0,0 +1,105 @@ +import * as d3 from 'd3'; +import { css } from 'styled-components'; + +const styles = props => css` + .track { + stroke-width: ${({ trackSize }) => trackSize}px; + stroke: url(#trackGradient); + stroke-linecap: round; + } +`; + +export default { + styles, + init: ctx => { + ctx.dataCanvas + .append('g').attr('class', 'track-layer') + .append('line') + .attr('class', 'track'); + + ctx.svg.select('defs') + .append('linearGradient') + .attr('id', 'trackGradient') + .attr('gradientUnits', 'userSpaceOnUse'); + }, + + update: ctx => { + const { dataCanvas, props, trackSize } = ctx; + const { width, height } = ctx.getSize(); + + // The change of the midpoint (from base) changes all the stops. + const midPointDiff = (props.knobPos - 50) / 100; + // Create a scale for the stops. The input value will be a position from + // 0 - 1, which correspond to the original position of the color stop. + // As the midpoint changes the position of the color stops will change + // according to their original place and the midpoint delta. + // Example + // 1st stop: 25% + // 2nd stop: 50% + // 3rd stop: 75% + // If the midpoint moves from 50 to 75, its position increased 1.5 times, + // but the stops cannot increase 1.5 times, otherwise the 75% would go over. + // The scale takes care of keeping the values bound to the domain. + const stopScale = d3 + .scaleLinear() + .domain([0, 1]) + .range([Math.max(0, midPointDiff), Math.min(1, 1 + midPointDiff)]); + + const trackGradient = ctx.svg.select('#trackGradient'); + + // Set the gradient size so it matched the element is being used on. + // Used in conjunction with gradientUnits='userSpaceOnUse' + // https://stackoverflow.com/questions/21638169/svg-line-with-gradient-stroke-wont-display-straight + trackGradient + .attr('x1', 0) + .attr('y1', 0) + .attr('x2', width) + .attr('y2', height); + + // We always need at lest 3 stops, since the first and last are fixed it's + // the middle one that moved and makes the gradient shift. + // When we only have 2, we need to infer the middle one. + let stops = props.stops; + if (stops.length === 2) { + const stopsScale = d3.scaleLinear() + .domain([0, 100]) + .range(stops); + + stops = [ + stops[0], + stopsScale(50), + stops[1] + ]; + } + + const gradientStops = trackGradient.selectAll('.stop').data(stops); + // Remove old. + gradientStops.exit().remove(); + // Handle new. + gradientStops + .enter() + .append('stop') + .attr('class', 'stop') + .attr('stop-opacity', 1) + .attr('stop-color', d => d) + .merge(gradientStops) + // Update current. + .attr('offset', (d, i, all) => { + if (i === 0) return 0; + if (i === all.length - 1) return 1; + const count = all.length - 1; + const pointOriginalPosition = 1 / count * i; + return stopScale(pointOriginalPosition); + }); + + const gradientTrack = dataCanvas.select('.track'); + + gradientTrack + .attr('x1', 0) + // Half the size of the line. + .attr('y1', height - trackSize / 2) + .attr('x2', width) + // Half the size of the line. + .attr('y2', height - trackSize / 2); + } +}; diff --git a/app/assets/scripts/components/common/layers/layer-no2.js b/app/assets/scripts/components/common/layers/layer-no2.js index 33e50301..a0995d6a 100644 --- a/app/assets/scripts/components/common/layers/layer-no2.js +++ b/app/assets/scripts/components/common/layers/layer-no2.js @@ -1,3 +1,7 @@ +import { format, sub } from 'date-fns'; + +import config from '../../../config'; + export default { id: 'no2', name: 'Nitrogen dioxide', @@ -10,15 +14,21 @@ export default { source: { type: 'raster', tiles: [ - 'https://h4ymwpefng.execute-api.us-east-1.amazonaws.com/v1/{z}/{x}/{y}@1x?url=s3://covid-eo-data/OMNO2d_HRM/OMI_trno2_0.10x0.10_{date}_Col3_V4.nc.tif&resampling_method=bilinear&bidx=1&rescale=0%2C1e16&color_map=magma' + `${config.api}/{z}/{x}/{y}@1x?url=s3://covid-eo-data/OMNO2d_HRM/OMI_trno2_0.10x0.10_{date}_Col3_V4.nc.tif&resampling_method=bilinear&bidx=1&rescale=0%2C1e16&color_map=magma&color_formula=gamma r {gamma}` ] }, + exclusiveWith: ['gibs-population'], + compare: { + enabled: true, + help: 'Compare with baseline (5 years ago)', + mapLabel: date => `${format(date, "MMM yy''")} — ${format(sub(date, { years: 5 }), "MMM yy''")}` + }, swatch: { color: '#411073', name: 'Purple' }, legend: { - type: 'gradient', + type: 'gradient-adjustable', min: 'less', max: 'more', stops: [ diff --git a/app/assets/scripts/components/common/layers/layer-population.js b/app/assets/scripts/components/common/layers/layer-population.js index 3b43ece7..6d4e84b6 100644 --- a/app/assets/scripts/components/common/layers/layer-population.js +++ b/app/assets/scripts/components/common/layers/layer-population.js @@ -8,6 +8,7 @@ export default { 'https://gibs.earthdata.nasa.gov/wmts/epsg3857/best/GPW_Population_Density_2020/default/2020-05-14T00:00:00Z/GoogleMapsCompatible_Level7/{z}/{y}/{x}.png' ] }, + exclusiveWith: ['no2'], swatch: { color: '#F55E2C', name: 'Orange' diff --git a/app/assets/scripts/components/common/layers/types.js b/app/assets/scripts/components/common/layers/types.js index 0f20a532..adee06c3 100644 --- a/app/assets/scripts/components/common/layers/types.js +++ b/app/assets/scripts/components/common/layers/types.js @@ -5,6 +5,25 @@ const prepDateSource = (source, date) => ({ tiles: source.tiles.map((t) => t.replace('{date}', format(date, 'yyyyMM'))) }); +const prepGammaSource = (source, knobPos) => { + // Gamma is calculated with the following scale: + // domain: 0-100 range: 2-0.1 + // The higher the Knob, the lower the gamma. + // This is a linear scale of type y = -mx + b + // y = -0.02x + 2; + + return { + ...source, + tiles: source.tiles.map((t) => t.replace('{gamma}', -0.019 * knobPos + 2)) + }; +}; + +const prepSource = (source, date, knobPos) => { + source = prepDateSource(source, date); + source = prepGammaSource(source, knobPos); + return source; +}; + const replaceRasterTiles = (theMap, sourceId, tiles) => { // https://github.com/mapbox/mapbox-gl-js/issues/2941 // Set the tile url to a cache-busting url (to circumvent browser caching behaviour): @@ -20,38 +39,53 @@ const replaceRasterTiles = (theMap, sourceId, tiles) => { export const layerTypes = { 'raster-timeseries': { update: (ctx, layerInfo, prevProps) => { - const { mbMap, mbMapComparing, props } = ctx; + const { mbMap, mbMapComparing, mbMapComparingLoaded, props } = ctx; const { id, source } = layerInfo; - const { date, compare } = props; + const prevLayerInfo = prevProps.layers.find(l => l.id === layerInfo.id); + const { date, comparing } = props; + + const knobPos = layerInfo.knobCurrPos; + const knobPosPrev = prevLayerInfo.knobCurrPos; + // Do not update if: if ( - prevProps.date && + // There's no date defined. + prevProps.date && date && + // Dates are the same date.getTime() === prevProps.date.getTime() && - compare === prevProps.compare - ) { return; } + // Knob position for gamma correction is the same. + knobPos === knobPosPrev && + // Compare didn't change. + comparing === prevProps.comparing + ) return; - if (mbMap.getSource(id)) { - const tiles = prepDateSource(source, date).tiles; - replaceRasterTiles(mbMap, id, tiles); - - if (compare) { - const source5years = prepDateSource(source, sub(date, { years: 5 })); - if (mbMapComparing.getSource(id)) { - replaceRasterTiles(mbMapComparing, id, source5years.tiles); - } else { - // TODO: Waiting for a map to load should be decoupled from the layer types. - mbMapComparing.once('load', () => { - mbMapComparing.addSource(id, source5years); - mbMapComparing.addLayer( - { - id: id, - type: 'raster', - source: id - }, - 'admin-1-boundary-bg' - ); - }); - } + // The source we're updating is not present. + if (!mbMap.getSource(id)) return; + + // If we're comparing, and the compare map is not loaded. + if (comparing && !mbMapComparingLoaded) return; + + // END update checks. + + // Update layer tiles. + const tiles = prepSource(source, date, knobPos).tiles; + replaceRasterTiles(mbMap, id, tiles); + + // Update/init compare layer tiles. + if (comparing) { + const source5years = prepSource(source, sub(date, { years: 5 }), knobPos); + if (mbMapComparing.getSource(id)) { + replaceRasterTiles(mbMapComparing, id, source5years.tiles); + } else { + mbMapComparing.addSource(id, source5years); + mbMapComparing.addLayer( + { + id: id, + type: 'raster', + source: id + }, + 'admin-1-boundary-bg' + ); } } }, @@ -72,7 +106,7 @@ export const layerTypes = { if (mbMap.getSource(id)) { mbMap.setLayoutProperty(id, 'visibility', 'visible'); } else { - mbMap.addSource(id, prepDateSource(source, date)); + mbMap.addSource(id, prepSource(source, date, layerInfo.knobCurrPos)); mbMap.addLayer( { id: id, diff --git a/app/assets/scripts/components/common/page-header.js b/app/assets/scripts/components/common/page-header.js index 101dd66d..3a4d3d3b 100644 --- a/app/assets/scripts/components/common/page-header.js +++ b/app/assets/scripts/components/common/page-header.js @@ -1,29 +1,29 @@ import React from 'react'; import T from 'prop-types'; import styled from 'styled-components'; +import { connect } from 'react-redux'; import config from '../../config'; import { Link, NavLink } from 'react-router-dom'; +import { visuallyHidden } from '../../styles/helpers'; import { themeVal } from '../../styles/utils/general'; import { reveal } from '../../styles/animation'; -import { panelSkin } from '../../styles/skins'; import { filterComponentProps } from '../../utils/utils'; import { glsp } from '../../styles/utils/theme-values'; +import { wrapApiResult } from '../../redux/reduxeed'; import Button from '../../styles/button/button'; import Dropdown, { DropTitle, DropMenu, DropMenuItem } from './dropdown'; -import superSitesList from '../super-sites'; +import datasetsList from '../datasets'; -const { appTitle, appShortTitle } = config; +const { appTitle, appShortTitle, appVersion } = config; const PageHead = styled.header` - ${panelSkin()} - position: sticky; + position: relative; z-index: 20; - top: 0; - left: 0; - bottom: 0; + background: ${themeVal('color.link')}; + color: ${themeVal('color.baseLight')}; /* Animation */ animation: ${reveal} 0.32s ease 0s 1; @@ -31,7 +31,7 @@ const PageHead = styled.header` const PageHeadInner = styled.div` display: flex; - padding: 0 ${glsp(0.5)}; + padding: ${glsp(0.75)}; align-items: center; margin: 0 auto; height: 100%; @@ -44,27 +44,75 @@ const PageHeadline = styled.div` `; const PageTitle = styled.h1` - display: flex; - text-align: center; - align-items: center; margin: 0; - font-weight: ${themeVal('type.base.semibold')}; - text-transform: uppercase; + line-height: 1; a { - display: flex; - align-items: center; - transition: all 0.24s ease 0s; + display: grid; + grid-gap: 0 ${glsp(0.5)}; + grid-template-columns: min-content 1fr min-content; &, &:visited { color: inherit; } + + &::before { + grid-row: 1 / span 2; + content: ''; + height: 48px; + width: 56px; + background: url('/assets/graphics/layout/app-logo-sprites.png'); + background-size: auto 100%; + background-repeat: none; + background-position: top right; + } + + &:hover { + opacity: 1; + + &::before { + background-position: top left; + } + } + } + + sup { + grid-row: 1; + font-size: 0.875rem; + font-weight: ${themeVal('type.base.extrabold')}; + line-height: 1rem; + text-transform: uppercase; + align-self: end; + top: inherit; + vertical-align: inherit; + + span { + ${visuallyHidden()}; + } + } + + strong { + grid-row: 2; + font-size: 1.25rem; + line-height: 1.5rem; + font-weight: ${themeVal('type.base.light')}; + align-self: center; + letter-spacing: -0.025em; } - span { - font-size: 1rem; - line-height: 1; + sub { + grid-row: 2; + font-size: 0.875rem; + line-height: 1.25rem; + text-transform: uppercase; + color: ${themeVal('color.link')}; + background: ${themeVal('color.surface')}; + padding: 0 ${glsp(0.5)}; + border-radius: ${themeVal('shape.rounded')}; + bottom: inherit; + vertical-align: inherit; + align-self: center; } `; @@ -96,7 +144,9 @@ const NavLinkFilter = filterComponentProps(NavLink, propsToFilter); class PageHeader extends React.Component { render () { - const { useShortTitle } = this.props; + const { useShortTitle, spotlightList } = this.props; + + const spotlightAreas = spotlightList.isReady() && spotlightList.getData(); return ( @@ -104,9 +154,9 @@ class PageHeader extends React.Component { - - {useShortTitle ? appShortTitle || 'COVID-19' : appTitle} - + NASA - Earthdata + {useShortTitle ? appShortTitle || 'COVID-19' : appTitle} + {appVersion} @@ -118,7 +168,7 @@ class PageHeader extends React.Component { to='/' isActive={(match, location) => match && location.pathname.match(/^\/(areas\/|$)/)} - variation='base-plain' + variation='achromic-plain' title='Explore the map' > Map @@ -128,23 +178,24 @@ class PageHeader extends React.Component { - Super sites + Spotlight } > - Super sites + Spotlight areas - {superSitesList.map(ss => ( + {spotlightAreas && spotlightAreas.map(ss => (
  • {ss.label} @@ -154,11 +205,41 @@ class PageHeader extends React.Component {
  • +
  • + + Datasets + + } + > + Datasets + + {datasetsList.filter(d => !!d.LongForm).map(d => ( +
  • + + {d.name} + +
  • + ))} +
    +
    +
  • + + +
    Last year
    +
    100
    +
    Last month
    +
    2000
    +
    + + + + + + How was this created? + + + + + + + +

    + Lorem ipsum dolor sit amet consectetur adipisicing elit. + Numquam rerum minus nesciunt necessitatibus ea id veritatis, + et ut porro possimus accusamus assumenda, pariatur fugit + praesentium magnam enim vero non, a repellat quae distinctio + neque voluptatem. Dignissimos saepe animi praesentium + sapiente. +

    +

    + Neque reprehenderit ullam numquam nulla tempore ea natus + voluptates. Sunt dolor reiciendis dolore impedit asperiores + fugiat, quas saepe quibusdam itaque deleniti ratione + corporis incidunt dignissimos quia nostrum aliquam possimus + unde, voluptatem temporibus repellendus aliquid officia. + Molestiae sit animi sequi velit consectetur est facere + excepturi possimus incidunt! Pariatur blanditiis illo eaque + asperiores harum. +

    +
    +
    + +
    +
    -
    -

    The Answer to the Great Question... Of Life, the Universe and Everything... Is... Forty-two, said Deep Thought, with infinite majesty and calm.

    -
    - Douglas Adams, The Hitchhiker's Guide to the Galaxy -
    -
    + + + + + +

    + Blanditiis ea commodi vero ipsa hic nam corporis! At + similique aspernatur ab, praesentium veniam placeat autem + iste veritatis voluptates amet nesciunt suscipit optio qui + voluptatum tempore. Dolores quaerat consequuntur nulla vero + expedita. +

    +
    +
    + +
    +
    - Image + + +

    + Some important lead, like the conclusion of the previous topic. + Distinctio ullam quaerat esse id consectetur vitae praesentium + facilis facere voluptate. +

    +
    +
    +
    - - + + + + Some content in the form of columns + + + + Ducimus +

    + Exercitationem voluptatum expedita vel rerum nemo quidem quas + error. Assumenda harum dolores quia error illo odio et numquam + magnam qui? Dolores dolorem quisquam aperiam id a ipsum rerum + nesciunt magnam pariatur? Ipsa nemo sequi ad recusandae commodi + non, voluptatum nobis iste a temporibus quidem natus labore + ullam distinctio illum nesciunt molestiae! Ducimus autem ea + aperiam in maxime quae cupiditate magni aut? Quis, blanditiis. + Ipsum esse sapiente ex reprehenderit veniam aliquam maiores + labore. +

    +
    + + Aspernatur +

    + Ab quibusdam, consequatur magni magnam nisi molestiae doloremque + architecto hic reiciendis atque error accusamus vero sapiente, + in enim! Nam, iste quas cum doloremque molestiae autem odit + animi eius error aspernatur voluptas nobis. +

    +
    + + Flora +

    + Temporibus non aliquid numquam eius. Doloremque dolore fugit, + quam qui nihil possimus inventore. Provident nisi dignissimos + aliquid assumenda dolor nemo adipisci voluptas. +

    +
    + + Nobis +

    + Officiis nulla aliquam deserunt facilis consequuntur laborum + voluptas nobis eaque, in aperiam officia excepturi eius, + provident porro? Nobis praesentium quis eos excepturi. +

    +
    +
    +
    + + + + + +

    + Distinctio sunt sequi est, ex velit, eligendi id ducimus nemo + vero tempora, maiores laudantium dignissimos saepe beatae facere + facilis in corrupti? Facere, modi. Voluptatum eum adipisci ad + illum explicabo eveniet facere. Unde enim tempore dolore + doloribus inventore obcaecati aspernatur cum possimus voluptatum + fugit omnis quis molestiae id eos consectetur tempora delectus + quos maiores voluptatem quas doloremque, consequatur ex impedit. + Iure unde est quae doloribus necessitatibus libero aliquid + deleniti odit quas. Iste vel assumenda quae optio quo quam + labore, asperiores provident. A ducimus ipsa amet ullam iste + consequuntur ex, delectus quaerat qui perferendis. +

    +
    + + [See Author et al. 2017 Obcaecati, unde sit commodi molestiae + placeat ipsa]. + +
    +
    + ); } } +NO2LongForm.propTypes = {}; + export default { - id: 'no2', - name: 'Nitrogen Dioxide', + ...metadata, LongForm: NO2LongForm }; diff --git a/app/assets/scripts/components/datasets/index.js b/app/assets/scripts/components/datasets/index.js index 8504d378..657ace8c 100644 --- a/app/assets/scripts/components/datasets/index.js +++ b/app/assets/scripts/components/datasets/index.js @@ -1,7 +1,46 @@ +/** + * How to add a new Dataset page: + * 1) Create a file for the page inside scripts/components/datasets/. + * The name of the file should be `dataset-[name].js + * The file will have to export an object with the following properties: + * - id: string + * - color: string + * - name: string + * - LongForm: ReactComponent + * + * The react component will dictate how the dataset page is rendered. If set + * to null, no page will be created for the dataset. + * + * The file should look something like: + * const metadata = { + * id: 'no2', + * name: 'Nitrogen Dioxide', + * color: '#2276AC' + * }; + * + * class NO2LongForm extends React.Component { + * render () { + * return

    This is the content for the no2 dataset

    + * } + * } + * + * export default { + * ...metadata, + * LongForm: NO2LongForm + * }; + * + * 2) Import the page below. + * + * 3) Add the page to the datasets array + * + */ import no2 from './dataset-no2'; import population from './dataset-population'; -const datasets = [no2, population]; +const datasets = [ + no2, + population +]; export default datasets; diff --git a/app/assets/scripts/components/datasets/single/index.js b/app/assets/scripts/components/datasets/single/index.js index ef70cbb2..f1b11c5a 100644 --- a/app/assets/scripts/components/datasets/single/index.js +++ b/app/assets/scripts/components/datasets/single/index.js @@ -1,6 +1,5 @@ import React from 'react'; import T from 'prop-types'; -import styled from 'styled-components'; import { connect } from 'react-redux'; import App from '../../common/app'; @@ -10,22 +9,13 @@ import { InpageHeaderInner, InpageHeadline, InpageTitle, + InpageSubtitle, InpageBody } from '../../../styles/inpage'; import UhOh from '../../uhoh'; -import { glsp } from '../../../styles/utils/theme-values'; import { getDataset } from '../'; -const InpageTrendsBody = styled(InpageBody)` - position: relative; - z-index: 9; - display: grid; - grid-template-columns: repeat(12, 1fr); - height: 100%; - padding: ${glsp(1, 3)}; -`; - class DatasetsSingle extends React.Component { render () { const { dataset } = this.props; @@ -39,12 +29,13 @@ class DatasetsSingle extends React.Component { {dataset.name} + Dataset - + - + ); diff --git a/app/assets/scripts/components/home/data-layers-block.js b/app/assets/scripts/components/home/data-layers-block.js index 680528bc..bd15b5bd 100644 --- a/app/assets/scripts/components/home/data-layers-block.js +++ b/app/assets/scripts/components/home/data-layers-block.js @@ -1,5 +1,6 @@ import React from 'react'; import T from 'prop-types'; +import styled from 'styled-components'; import get from 'lodash.get'; import { @@ -12,20 +13,21 @@ import { import Layer from './layer'; import { Accordion } from '../common/accordion'; +const PanelBlockLayer = styled(PanelBlock)` + flex: 2; +`; + class DataLayersBlock extends React.Component { render () { const { onAction, layers, mapLoaded } = this.props; return ( - + Data - + {({ checkExpanded, setExpanded }) => (
      @@ -39,12 +41,18 @@ class DataLayersBlock extends React.Component { active={l.visible} swatchColor={get(l, 'swatch.color')} swatchName={get(l, 'swatch.name')} - mapStyle={l.mapStyle} + dataOrder={l.dataOrder} info={l.info} legend={l.legend} isExpanded={checkExpanded(idx)} setExpanded={v => setExpanded(idx, v)} onToggleClick={() => onAction('layer.toggle', l)} + onLegendKnobChange={(payload) => onAction('layer.legend-knob', { id: l.id, ...payload })} + knobPos={l.knobPos} + compareEnabled={get(l, 'compare.enabled')} + compareActive={l.comparing} + compareHelp={get(l, 'compare.help')} + onCompareClick={() => onAction('layer.compare', l)} /> ))} @@ -53,7 +61,7 @@ class DataLayersBlock extends React.Component { - + ); } } diff --git a/app/assets/scripts/components/home/filter-aoi.js b/app/assets/scripts/components/home/filter-aoi.js index 7635a707..43fa96be 100644 --- a/app/assets/scripts/components/home/filter-aoi.js +++ b/app/assets/scripts/components/home/filter-aoi.js @@ -29,6 +29,7 @@ export const Filter = styled.section` display: flex; align-items: flex-start; padding: ${glsp()}; + box-shadow: inset 0 -1px 0 0 ${themeVal('color.baseAlphaB')}; `; export const FilterHeadline = styled.div` @@ -38,7 +39,6 @@ export const FilterHeadline = styled.div` export const FilterTitle = styled.h1` ${headingAlt()} - color: ${themeVal('color.base')}; font-size: 0.75rem; line-height: 1rem; margin: 0; diff --git a/app/assets/scripts/components/home/index.js b/app/assets/scripts/components/home/index.js index 51d6f98d..e6da0db1 100644 --- a/app/assets/scripts/components/home/index.js +++ b/app/assets/scripts/components/home/index.js @@ -2,8 +2,9 @@ import React from 'react'; import T from 'prop-types'; import styled from 'styled-components'; import { connect } from 'react-redux'; -// import { Redirect } from 'react-router'; -import { format, isBefore, sub } from 'date-fns'; +import { sub } from 'date-fns'; +import get from 'lodash.get'; +import find from 'lodash.find'; import App from '../common/app'; import ExpMapPrimePanel from './prime-panel'; @@ -18,24 +19,17 @@ import { } from '../../styles/inpage'; import MbMap from './mb-map'; import Timeline from './timeline'; -import DrawMessage from './map-draw-message'; +import MapMessage from './map-message'; import { showGlobalLoading, hideGlobalLoading } from '../common/global-loading'; import { themeVal } from '../../styles/utils/general'; -import { - fetchTimeSeriesDaily as fetchTimeSeriesDailyAction, - fetchTimeSeriesOverview as fetchTimeSeriesOverviewAction -} from '../../redux/time-series'; -import { fetchLayerData as fetchLayerDataAction } from '../../redux/layer-data'; -import { wrapApiResult, getFromState } from '../../redux/reduxeed'; +import { wrapApiResult } from '../../redux/reduxeed'; import { fetchCogTimeData as fetchCogTimeDataAction, invalidateCogTimeData as invalidateCogTimeDataAction } from '../../redux/cog-time-data'; -import history from '../../utils/history'; import { utcDate } from '../../utils/utils'; import mapLayers from '../common/layers'; -import { unionOverviewDateDomain } from '../../utils/date'; /** * Returns a feature with a polygon geometry made of the provided bounds. @@ -106,9 +100,18 @@ class Home extends React.Component { this.state = { activeLayers: [], + // Additional data that needs to be tracked for the map layers, like the + // knob position on a adjustable gradient legend. + // Values will be objects keyed by the layer id. + layersState: { + // id: { + // comparing: bool + // knosPos: number + // knobCurrPos: number + // } + }, timelineDate: null, mapLoaded: false, - compare: false, aoi: { feature: null, @@ -123,19 +126,72 @@ class Home extends React.Component { this.props.invalidateCogTimeData(); } + /** + * Sets the state of a give layer in the component state. + * Works exactly like setState, but for a specific layer data. + * + * @param {string} id If of the layer for which to update data + * @param {mixed} data Object with data to merge or function. If a function is + * provided, it will be called with the current data. It should + * return an object which will be merged with the existent data + * @param {funct} cb Callback to execute after setting state. + */ + setLayerState (id, data, cb) { + this.setState(state => { + const currentState = state.layersState[id] || {}; + return { + layersState: { + ...state.layersState, + [id]: { + ...currentState, + ...(typeof data === 'function' ? data(currentState) : data) + } + } + }; + }, () => cb && cb()); + } + + /** + * Returns the layer state for a given layer id, or a specific state path + * if second parameter is provided. + * + * @param {string} id Layer for which to get the state. + * @param {mixed} prop Path to a specific prop (optional). Used lodash.get + */ + getLayerState (id, prop) { + const path = prop + ? typeof prop === 'string' ? [id, prop] : [id, ...prop] + : id; + return get(this.state.layersState, path); + } + + /** + * Returns the layer list, merging the visibility state and any other data + * stored for each layer in the layer state. + */ getLayersWithState () { - const { activeLayers } = this.state; - return mapLayers.map((l) => ({ - ...l, - visible: activeLayers.includes(l.id) - })); + const { activeLayers, layersState } = this.state; + return mapLayers.map((l) => { + // Get additional propertied from the layerData array. + const extra = layersState[l.id] || {}; + return { + ...l, + visible: activeLayers.includes(l.id), + ...extra + }; + }); } resizeMap () { - if (this.mbMapRef.current) { + const component = this.mbMapRef.current; + if (component) { // Delay execution to give the panel animation time to finish. setTimeout(() => { - this.mbMapRef.current.mbMap.resize(); + component.mbMap.resize(); + // Also resize the compare map if it exists. + if (component.mbMapComparing) { + component.mbMapComparing.resize(); + } }, 200); } } @@ -144,7 +200,9 @@ class Home extends React.Component { const { aoi: { feature } } = this.state; - if (!feature) return; + const activeLayers = this.getActiveTimeseriesLayers(); + + if (!feature || !activeLayers.length) return; showGlobalLoading(); // TODO: Change from hardcoded cog type and date @@ -165,15 +223,24 @@ class Home extends React.Component { case 'layer.toggle': this.toggleLayer(payload); break; - case 'compare.set': - this.setState({ compare: payload.compare }); + case 'layer.compare': + this.toggleLayerCompare(payload); + break; + case 'layer.legend-knob': + this.setLayerState(payload.id, layerState => ({ + knobPos: payload.value, + // If the event was the end of a drag, set the current value for + // the map to pick up. + knobCurrPos: payload.end + ? payload.value + : layerState.knobCurrPos + })); break; case 'date.set': this.setState({ timelineDate: payload.date }); // this.getActiveTimeseriesLayers().forEach(l => { - // this.props.fetchTimeSeriesDaily( // l.id, // format(payload.date, 'yyyy-MM-dd') // ); @@ -229,9 +296,6 @@ class Home extends React.Component { async onMapAction (action, payload) { switch (action) { - case 'admin-area.click': - history.push(`/areas/${payload.id}`); - break; case 'map.loaded': { this.setState({ mapLoaded: true }); @@ -284,61 +348,38 @@ class Home extends React.Component { } async toggleLayer (layer) { - const { layerData, fetchLayerData, fetchTimeSeriesOverview } = this.props; const layerId = layer.id; const { activeLayers } = this.state; const isEnabled = activeLayers.includes(layerId); - if (layer.type === 'feature-data') { - // Check if there layer data for this layer. - // If not, load the data and only enable the layer if successful. - const data = layerData[layerId]; - if (!data || data.hasError()) { - showGlobalLoading(); - const res = await fetchLayerData(layerId); - hideGlobalLoading(); - if (res.error) return; - } else if (!data.isReady()) { - return; - } - } - - if (layer.type === 'timeseries') { - // Check if there layer data for this layer. - // If not, load the data and only enable the layer if successful. - if (!isEnabled) { - showGlobalLoading(); - const res = await fetchTimeSeriesOverview(layerId); - // Before setting a new date see if it is available for all active - // timeseries layers. - const activeTSLayers = this.getActiveTimeseriesLayers() - // Add the one we're about to enable in the format needed - // by getTimeseriesOverviewData - .concat({ id: layerId }); - const activeTSOverview = this.getTimeseriesOverviewData(activeTSLayers); - // Compute date intersection between all the overviews. - const dateDomain = unionOverviewDateDomain(activeTSOverview); - - // Use the max available date if current date is after it. - const currDate = this.state.timelineDate; - const nextDdate = - currDate && isBefore(currDate, dateDomain[1]) - ? currDate - : dateDomain[1]; - - this.setState({ timelineDate: nextDdate }); - this.props.fetchTimeSeriesDaily( - layer.id, - format(nextDdate, 'yyyy-MM-dd') - ); - hideGlobalLoading(); - if (res.error) return; - } + if (layer.type === 'raster-timeseries') { + this.setState(state => { + // Check if there's a knob value set. If not, means that this is the + // first time it is enabled and we need to set a default. + const knobCurrPos = get(state, ['layersState', layerId, 'knobCurrPos'], null); + const knobData = knobCurrPos === null + ? { + knobPos: 50, + knobCurrPos: 50 + } + : {}; + return { + timelineDate: utcDate(layer.domain[1]), + layersState: { + ...state.layersState, + [layerId]: { + ...state.layersState[layerId], + ...knobData + } + } + }; + }); } - if (layer.type === 'raster-timeseries') { - this.setState({ timelineDate: utcDate(layer.domain[1]) }); + // If we disable a layer we're comparing, disable the comparison as well. + if (this.getLayerState(layerId, 'comparing')) { + this.toggleLayerCompare(layer); } // Hide any layers that are not compatible with the current one. @@ -360,9 +401,43 @@ class Home extends React.Component { return { activeLayers: [...diff, layerId] }; + }, () => { + this.requestCogData(); }); } + toggleLayerCompare (layer) { + const layerId = layer.id; + const isComparing = this.getLayerState(layerId, 'comparing'); + + if (isComparing) { + this.setLayerState(layerId, { + comparing: false + }); + } else { + this.setState(state => { + // Disable compare on all other layers. + // Having a object with settings makes it very fast to access, but it is + // harder to apply changes across all objects. + const layersState = Object.keys(state.layersState).reduce((acc, id) => ({ + ...acc, + [id]: { + ...acc[id], + comparing: false + } + }), state.layersState); + + // Set current as active + layersState[layerId] = { + ...layersState[layerId], + comparing: true + }; + + return { layersState }; + }); + } + } + getActiveTimeseriesLayers () { return mapLayers.filter( (l) => @@ -370,19 +445,19 @@ class Home extends React.Component { ); } - getTimeseriesOverviewData (layers) { - const { timeSeriesOverview } = this.props; - return layers.map((l) => timeSeriesOverview[l.id]); - } - render () { - const { layerData } = this.props; - const adminAreaFeatId = this.props.match.params.id; - const layers = this.getLayersWithState(); - const activeTimeseriesLayers = this.getActiveTimeseriesLayers(); + // Check if there's any layer that's comparing. + const comparingLayer = find(layers, 'comparing'); + const isComparing = !!comparingLayer; + + const mapLabel = get(comparingLayer, 'compare.mapLabel'); + const compareMessage = isComparing && mapLabel + ? typeof mapLabel === 'function' ? mapLabel(this.state.timelineDate) : mapLabel + : ''; + return ( @@ -403,40 +478,34 @@ class Home extends React.Component { onPanelChange={this.resizeMap} /> - + +

      Draw an AOI on the map

      +
      + +

      {compareMessage}

      +
      @@ -447,34 +516,18 @@ class Home extends React.Component { } Home.propTypes = { - // fetchConfig: T.func, - // fetchAdminAreas: T.func, - // fetchSingleAdminArea: T.func, - fetchLayerData: T.func, - fetchTimeSeriesDaily: T.func, - fetchTimeSeriesOverview: T.func, fetchCogTimeData: T.func, invalidateCogTimeData: T.func, - match: T.object, - layerData: T.object, - // timeSeriesDaily: T.object, - timeSeriesOverview: T.object, - no2CogTimeData: T.object + cogTimeData: T.object }; function mapStateToProps (state, props) { return { - layerData: wrapApiResult(state.layerData, true), - timeSeriesDaily: wrapApiResult(state.timeSeries.daily, true), - timeSeriesOverview: wrapApiResult(state.timeSeries.overview, true), - no2CogTimeData: wrapApiResult(getFromState(state, ['cogTimeData', 'no2'])) + cogTimeData: wrapApiResult(state.cogTimeData, true) }; } const mapDispatchToProps = { - fetchLayerData: fetchLayerDataAction, - fetchTimeSeriesDaily: fetchTimeSeriesDailyAction, - fetchTimeSeriesOverview: fetchTimeSeriesOverviewAction, fetchCogTimeData: fetchCogTimeDataAction, invalidateCogTimeData: invalidateCogTimeDataAction }; diff --git a/app/assets/scripts/components/home/layer.js b/app/assets/scripts/components/home/layer.js index 5ae02da2..a5c15e5c 100644 --- a/app/assets/scripts/components/home/layer.js +++ b/app/assets/scripts/components/home/layer.js @@ -1,16 +1,19 @@ import React from 'react'; import T from 'prop-types'; import styled from 'styled-components'; +import ReactTooltip from 'react-tooltip'; import { themeVal } from '../../styles/utils/general'; import { visuallyHidden, truncated } from '../../styles/helpers'; import { glsp } from '../../styles/utils/theme-values'; -import Prose from '../../styles/type/prose'; import { headingAlt } from '../../styles/type/heading'; -import Button from '../../styles/button/button'; +import { formatThousands } from '../../utils/format'; + +import Prose from '../../styles/type/prose'; import { FormSwitch } from '../../styles/form/switch'; +import Button from '../../styles/button/button'; import { AccordionFold } from '../common/accordion'; -import { formatThousands } from '../../utils/format'; +import GradientChart from '../common/gradient-legend-chart/chart'; const makeGradient = (stops) => { const d = 100 / stops.length - 1; @@ -47,10 +50,6 @@ const LayerTitle = styled.h1` const LayerSubtitle = styled.p` ${visuallyHidden()} `; - // ${headingAlt()} - // ${truncated()} - // font-size: 0.75rem; - // line-height: 1rem; const LayerSwatch = styled.span` position: absolute; @@ -85,16 +84,6 @@ const LayerLegend = styled.div` grid-row: 2; grid-column: 1 / span 2; - dt { - display: block; - font-size: 0; - height: 0.5rem; - border-radius: ${themeVal('shape.rounded')}; - background: ${({ stops }) => makeGradient(stops)}; - margin: 0 0 ${glsp(1 / 8)} 0; - box-shadow: inset 0 0 0 1px ${themeVal('color.baseAlphaB')}; - } - dd { ${headingAlt()} font-size: 0.75rem; @@ -108,6 +97,16 @@ const LayerLegend = styled.div` } `; +const LayerLegendGradientTrack = styled.dt` + display: block; + font-size: 0; + height: 0.5rem; + border-radius: ${themeVal('shape.rounded')}; + background: ${({ stops }) => makeGradient(stops)}; + margin: 0 0 ${glsp(1 / 8)} 0; + box-shadow: inset 0 0 0 1px ${themeVal('color.baseAlphaB')}; +`; + const LayerLegendTitle = styled.h2` ${visuallyHidden()} `; @@ -134,29 +133,26 @@ const typesSubtitles = { }; class Layer extends React.Component { - render () { + componentDidMount () { + ReactTooltip.rebuild(); + } + + renderLegend () { const { - id, - label, - type, - disabled, - active, - swatchColor, - swatchName, - mapStyle, - info, + dataOrder, legend, - onToggleClick, - isExpanded, - setExpanded + knobPos, + onLegendKnobChange } = this.props; + if (!legend) return null; + let defaultStops; // Two default styles: - // - pos-neg - where a high value needs to be highlighted (high % of 65+) - // - neg-pos - where low values are highlighted (m2 living area per person) - switch (mapStyle) { - case ('neg-pos'): + // - highlight-high - where a high value needs to be highlighted (high % of 65+) + // - highlight-low - where low values are highlighted (m2 living area per person) + switch (dataOrder) { + case ('highlight-low'): defaultStops = [ 'hsla(105, 91%, 67%, 0.69)', 'hsla(173, 75%, 66%, 0.69)', @@ -175,10 +171,68 @@ class Layer extends React.Component { ]; } - const stops = legend && legend.stops && legend.stops !== 'default' + const stops = legend.stops && legend.stops !== 'default' ? legend.stops : defaultStops; + if (legend.type === 'gradient-adjustable') { + return ( + + Legend +
      +
      + { + onLegendKnobChange(p); + }} + stops={stops} + knobPos={knobPos !== undefined ? knobPos : 50} + /> +
      +
      + {printLegendVal(legend.min)} + + {printLegendVal(legend.max)} +
      +
      +
      + ); + } + + return ( + + Legend +
      + Gradient +
      + {printLegendVal(legend.min)} + + {printLegendVal(legend.max)} +
      +
      +
      + ); + } + + render () { + const { + id, + label, + type, + disabled, + active, + swatchColor, + swatchName, + info, + compareEnabled, + compareActive, + compareHelp, + onCompareClick, + onToggleClick, + isExpanded, + setExpanded + } = this.props; + return ( Info + - {legend && ( - - Legend -
      -
      Gradient
      -
      - {printLegendVal(legend.min)} - - {printLegendVal(legend.max)} -
      -
      -
      - )} + {this.renderLegend()} )} renderBody={() => ( @@ -249,12 +304,18 @@ Layer.propTypes = { active: T.bool, swatchColor: T.string, swatchName: T.string, - mapStyle: T.string, + dataOrder: T.string, info: T.node, legend: T.object, onToggleClick: T.func, isExpanded: T.bool, - setExpanded: T.func + setExpanded: T.func, + onLegendKnobChange: T.func, + knobPos: T.number, + compareEnabled: T.bool, + compareActive: T.bool, + compareHelp: T.string, + onCompareClick: T.func }; export default Layer; diff --git a/app/assets/scripts/components/home/map-draw-message.js b/app/assets/scripts/components/home/map-message.js similarity index 79% rename from app/assets/scripts/components/home/map-draw-message.js rename to app/assets/scripts/components/home/map-message.js index f603b01e..34f47ed2 100644 --- a/app/assets/scripts/components/home/map-draw-message.js +++ b/app/assets/scripts/components/home/map-message.js @@ -13,7 +13,7 @@ const Message = styled.div` left: 50%; z-index: 10; transform: translate(-50%, 0); - padding: ${glsp(1 / 2, 1)}; + padding: ${glsp(1 / 4, 1)}; background: #fff; box-shadow: 0 0 4px 4px ${themeVal('color.baseAlphaA')}; border-radius: ${themeVal('shape.rounded')}; @@ -21,25 +21,25 @@ const Message = styled.div` transition: all ${fadeDuration}ms ease-in-out; ${({ show }) => show ? css` visibility: visible; - top: 2rem; + top: 0.5rem; opacity: 1; ` : css` visibility: hidden; - top: 0; + top: -2rem; opacity: 0; `} `; -class DrawMessage extends Component { +class MapMessage extends Component { render () { return ( {state => ( -

      Draw an AOI on the map

      + {this.props.children}
      )}
      @@ -47,8 +47,9 @@ class DrawMessage extends Component { } } -DrawMessage.propTypes = { - drawing: T.bool +MapMessage.propTypes = { + children: T.node, + active: T.bool }; -export default DrawMessage; +export default MapMessage; diff --git a/app/assets/scripts/components/home/mb-map.js b/app/assets/scripts/components/home/mb-map.js index e291e80c..47a4fd4e 100644 --- a/app/assets/scripts/components/home/mb-map.js +++ b/app/assets/scripts/components/home/mb-map.js @@ -4,8 +4,6 @@ import styled, { withTheme } from 'styled-components'; import mapboxgl from 'mapbox-gl'; import CompareMbGL from 'mapbox-gl-compare'; -import MapboxControl from '../common/mapbox-react-control'; - import config from '../../config'; import { layerTypes } from '../common/layers/types'; import { glsp } from '../../styles/utils/theme-values'; @@ -16,8 +14,7 @@ const { zoom, minZoom, maxZoom, - styleUrl, - logos + styleUrl } = config.map; // Set mapbox token. @@ -71,9 +68,6 @@ class MbMap extends React.Component { this.mapContainer = null; this.mbMap = null; this.mbDraw = null; - - this.adminAreaIdActive = null; - this.adminAreaIdHover = null; } componentDidMount () { @@ -82,26 +76,39 @@ class MbMap extends React.Component { } componentDidUpdate (prevProps, prevState) { - const { activeLayers, compare } = this.props; + const { activeLayers, comparing } = this.props; // Compare Maps - if (compare !== prevProps.compare) { - if (compare) { + if (comparing !== prevProps.comparing) { + if (comparing) { this.mbMap.resize(); this.mbMapComparing = new mapboxgl.Map({ attributionControl: false, - container: this.mapContainer2, - center: center, - zoom: zoom || 5, + container: this.mapContainerComparing, + center: this.mbMap.getCenter(), + zoom: this.mbMap.getZoom(), minZoom: minZoom || 4, maxZoom: maxZoom || 9, style: styleUrl, pitchWithRotate: false, - // renderWorldCopies: false, dragRotate: false, logoPosition: 'bottom-left' }); + // Add zoom controls. + this.mbMapComparing.addControl(new mapboxgl.NavigationControl(), 'top-left'); + + // Style attribution. + this.mbMapComparing.addControl(new mapboxgl.AttributionControl({ compact: true })); + + // Remove compass. + document.querySelector('.mapboxgl-ctrl .mapboxgl-ctrl-compass').remove(); + + this.mbMapComparing.once('load', () => { + this.mbMapComparingLoaded = true; + this.updateActiveLayers(prevProps); + }); + this.compareControl = new CompareMbGL(this.mbMap, this.mbMapComparing, '#container'); } else { if (this.compareControl) { @@ -109,12 +116,13 @@ class MbMap extends React.Component { this.compareControl = null; this.mbMapComparing.remove(); this.mbMapComparing = null; + this.mbMapComparingLoaded = false; } } } // TODO: Improve how compare is handled, by the layers that have it. - if (prevProps.activeLayers !== activeLayers || compare !== prevProps.compare) { + if (prevProps.activeLayers !== activeLayers || comparing !== prevProps.comparing) { const toRemove = prevProps.activeLayers.filter( (l) => !activeLayers.includes(l) ); @@ -147,19 +155,23 @@ class MbMap extends React.Component { }); } - // Update all active layers - activeLayers.forEach((layerId) => { + // Update all active layers. + this.updateActiveLayers(prevProps); + + // Handle aoi state props update. + if (this.mbDraw) { + this.mbDraw.update(prevProps.aoiState, this.props.aoiState); + } + } + + updateActiveLayers (prevProps) { + this.props.activeLayers.forEach((layerId) => { const layerInfo = this.props.layers.find((l) => l.id === layerId); const fns = layerTypes[layerInfo.type]; if (fns && fns.update) { return fns.update(this, layerInfo, prevProps); } }); - - // Handle aoi state props update. - if (this.mbDraw) { - this.mbDraw.update(prevProps.aoiState, this.props.aoiState); - } } initMap () { @@ -172,33 +184,10 @@ class MbMap extends React.Component { maxZoom: maxZoom || 9, style: styleUrl, pitchWithRotate: false, - // renderWorldCopies: false, dragRotate: false, logoPosition: 'bottom-left' }); - if (logos) { - const mapLogosControl = new MapboxControl((props, state) => ( -
      - {logos.map((l) => ( - - {`${l.label} - - ))} -
      - )); - - this.mbMap.addControl(mapLogosControl, 'bottom-left'); - - mapLogosControl.render(this.props, this.state); - } - // Disable map rotation using right click + drag. this.mbMap.dragRotate.disable(); @@ -231,7 +220,7 @@ class MbMap extends React.Component { { - this.mapContainer2 = el; + this.mapContainerComparing = el; }} /> - Explore the map - - } + renderHeader={() => ( + +

      Explore the map

      +
      + )} bodyContent={ <> - + + + + Tools + + + + + } /> diff --git a/app/assets/scripts/components/home/sec-panel.js b/app/assets/scripts/components/home/sec-panel.js index 11d2b69b..ee613b8e 100644 --- a/app/assets/scripts/components/home/sec-panel.js +++ b/app/assets/scripts/components/home/sec-panel.js @@ -3,22 +3,15 @@ import styled from 'styled-components'; import T from 'prop-types'; import * as d3 from 'd3'; -import { glsp } from '../../styles/utils/theme-values'; -import { themeVal } from '../../styles/utils/general'; -import Prose from '../../styles/type/prose'; -import Heading, { headingAlt, Subheading } from '../../styles/type/heading'; +import Heading from '../../styles/type/heading'; import SimpleLineChart from '../common/simple-line-chart/chart'; - -import { formatThousands } from '../../utils/format'; - import Panel, { PanelHeadline, PanelTitle } from '../common/panel'; - import ShadowScrollbar from '../common/shadow-scrollbar'; -import FilterAoi from './filter-aoi'; -import { format } from 'date-fns'; + +import { glsp } from '../../styles/utils/theme-values'; import { utcDate } from '../../utils/utils'; const BodyScroll = styled(ShadowScrollbar)` @@ -33,40 +26,26 @@ const InsightsBlock = styled.div` flex: 1; `; -const InsightsDetails = styled.dl` - dt { - ${headingAlt} - margin: 0 0 ${glsp(1 / 4)} 0; - font-size: 0.75rem; - line-height: 1rem; - } - - dd { - font-size: 1.25rem; - font-weight: ${themeVal('type.base.bold')}; - line-height: 1; - margin: 0 0 ${glsp()} 0; - } -`; - class ExpMapSecPanel extends React.Component { renderContent () { - const { tempNo2Data } = this.props; - // const { adminArea, indicatorsConfig } = this.props; - - // if (!indicatorsConfig) { - // return null; - // } + const { cogTimeData, aoiFeature, layers } = this.props; - if (!tempNo2Data || !tempNo2Data.isReady()) { + if (!aoiFeature) { return

      There is no area of interest defined.

      ; } - // const adminAreaData = adminArea.getData(); - // const { tsData } = adminAreaData; - // const mobilityData = tsData[0]; + if (!layers.length) { + return

      There are no layers with time data enabled.

      ; + } + + // TODO: Do not use hardcoded values. + const no2cogTimeData = cogTimeData.no2; + + if (!no2cogTimeData || !no2cogTimeData.isReady()) { + return null; + } - const data = tempNo2Data.getData(); + const data = no2cogTimeData.getData(); const xDomain = [ utcDate(data[0].date), utcDate(data[data.length - 1].date) @@ -75,18 +54,6 @@ class ExpMapSecPanel extends React.Component { return (
      - {/* {adminAreaData.staticData - ? ( - - {indicatorsConfig.map((ind) => ( - -
      {ind.description}
      -
      {formatThousands(adminAreaData.staticData[ind.attribute], { decimals: 1 }) || 'n/a'}
      -
      - ))} -
      - ) - :

      No indicators available.

      } */} NO2 Concentration molecules/cm2 } bodyContent={ - + {this.renderContent()} @@ -126,9 +90,9 @@ class ExpMapSecPanel extends React.Component { ExpMapSecPanel.propTypes = { onPanelChange: T.func, - selectedAdminArea: T.string, - indicatorsConfig: T.array, - adminArea: T.object + layers: T.array, + aoiFeature: T.object, + cogTimeData: T.object }; export default ExpMapSecPanel; diff --git a/app/assets/scripts/components/home/timeline.js b/app/assets/scripts/components/home/timeline.js index 5ce6829e..b19689d0 100644 --- a/app/assets/scripts/components/home/timeline.js +++ b/app/assets/scripts/components/home/timeline.js @@ -124,12 +124,6 @@ const CurrentDate = styled.p` font-weight: ${themeVal('type.base.bold')}; `; -const CompareButton = styled(Button)` - && { - margin-right: 2rem; - } -`; - class Timeline extends React.Component { constructor (props) { super(props); @@ -139,17 +133,20 @@ class Timeline extends React.Component { }; } + componentDidUpdate (prevProps, prevState) { + if ( + prevProps.isActive !== this.props.isActive || + prevState.isExpanded !== this.state.isExpanded + ) { + this.props.onSizeChange && this.props.onSizeChange(); + } + } + render () { - const { date, onAction, isActive, layers, compare } = this.props; + const { date, onAction, isActive, layers } = this.props; - // if (!isActive || !overview.length) return null; if (!isActive) return null; - // Wait until all the overviews are ready. - // if (overview.some((o) => !o.isReady())) return null; - - // Compute date intersection between all the overviews. - // const dateDomain = unionOverviewDateDomain(overview); const dateDomain = layers[0].domain.map(utcDate); const swatch = layers[0].swatch.color; @@ -181,16 +178,9 @@ class Timeline extends React.Component { onChange={(selectedDate) => onAction('date.set', { date: selectedDate })} /> */} - - onAction('compare.set', { compare: !compare })} - > - {compare ? 'Stop compare (5y ago)' : 'Start compare (5y ago)'} - - {date ? format(date, "MMM yy''") : 'Select date'} + + {date ? format(date, "MMM yy''") : 'Select date'} +