diff --git a/content/study/posts/kenya.yml b/content/study/posts/kenya.yml index e8a6560..bd67534 100644 --- a/content/study/posts/kenya.yml +++ b/content/study/posts/kenya.yml @@ -14,7 +14,7 @@ platform: layers: - id: 11kv name: Existing grid 11kv - category: contextual + category: input mbLayer: 11kv info: This dataset contains electricity transmission lines with different voltage levels as well as unidentified voltage in Kenya. The dataset was provided by Kenya Power and Lighting Company (KPLC). source: @@ -22,7 +22,7 @@ layers: url: https://energydata.info/dataset/kenya-kenya-electricity-network - id: 33kv name: Existing grid 33kv - category: contextual + category: input mbLayer: 33kv info: This dataset contains electricity transmission lines with different voltage levels as well as unidentified voltage in Kenya. The dataset was provided by Kenya Power and Lighting Company (KPLC). source: @@ -30,7 +30,7 @@ layers: url: https://energydata.info/dataset/kenya-kenya-electricity-network - id: 66kv name: Existing grid 66kv - category: contextual + category: input mbLayer: 66kv info: This dataset contains electricity transmission lines with different voltage levels as well as unidentified voltage in Kenya. The dataset was provided by Kenya Power and Lighting Company (KPLC). source: @@ -38,7 +38,7 @@ layers: url: https://energydata.info/dataset/kenya-kenya-electricity-network - id: 132kv name: Existing grid 132kv - category: contextual + category: input mbLayer: 132kv info: This dataset contains electricity transmission lines with different voltage levels as well as unidentified voltage in Kenya. The dataset was provided by Kenya Power and Lighting Company (KPLC). source: @@ -46,7 +46,7 @@ layers: url: https://energydata.info/dataset/kenya-kenya-electricity-network - id: 220kv name: Existing grid 220kv - category: contextual + category: input mbLayer: 220kv info: This dataset contains electricity transmission lines with different voltage levels as well as unidentified voltage in Kenya. The dataset was provided by Kenya Power and Lighting Company (KPLC). source: @@ -54,7 +54,7 @@ layers: url: https://energydata.info/dataset/kenya-kenya-electricity-network - id: transformers name: Distribution Transformers - category: contextual + category: input mbLayer: transformers info: The dataset contains Distribution Transformers in Kenya.The dataset was provided by Kenya Power and Lighting Company (KPLC). source: @@ -62,7 +62,7 @@ layers: url: https://energydata.info/dataset/kenya-distribution-transformers - id: substations name: Primary Substations - category: contextual + category: input mbLayer: substation info: The dataset contains primary substations in Kenya. The dataset was provided by Kenya Power and Lighting Company (KPLC). source: @@ -70,7 +70,7 @@ layers: url: https://energydata.info/dataset/kenya-primary-substations - id: transmission name: Transmission Stations - category: contextual + category: input mbLayer: transmission-stations info: The data contains transmission station locations in Kenya. The dataset was provided by Kenya Power and Lighting Company (KPLC). source: @@ -78,7 +78,7 @@ layers: url: https://energydata.info/dataset/kenya-transmission-stations - id: power name: Power Stations - category: contextual + category: input disabled: true mbLayer: 11kv info: The dataset contains location of Power Stations in Kenya. It was provided by Kenya Power and Lighting Company (KPLC). @@ -87,7 +87,7 @@ layers: url: https://energydata.info/dataset/kenya-power-stations - id: population name: Population and Household Dataset (2009 & 2016) - category: contextual + category: input disabled: true mbLayer: 11kv info: Population and Household statistics for the years 2009 and 2016 as well as the enumeration areas.The dataset was provided by Kenya National Bureau of Statistics (KNBS). @@ -96,7 +96,7 @@ layers: url: https://energydata.info/dataset/kenya-population-and-household-dataset - id: roads name: Roads - category: contextual + category: input mbLayer: roads info: Road network in Kenya. The dataset was provided by Kenya Roads Board (KRB). source: @@ -104,7 +104,7 @@ layers: url: https://energydata.info/dataset/kenya-roads-1 - id: health name: Healthcare Facilities - category: contextual + category: input disabled: true mbLayer: 11kv info: Data on healthcare facility locations in Kenya. The dataset was provided by the Government of Kenya. @@ -113,7 +113,7 @@ layers: url: https://energydata.info/dataset/kenya-healthcare-facilities - id: education name: Schools - category: contextual + category: input mbLayer: education info: School locations in Kenya. It comprises Primary and Secondary Schools. The dataset was provided by Kenya Ministry of Education. source: @@ -121,7 +121,7 @@ layers: url: https://energydata.info/dataset/kenya-schools - id: minigrid name: Overview of Off-Grid Electricity Service Areas - category: result + category: outcome mbLayer: minigrid visible: true info: This dataset represents the locations of existing mini-grids, mini-grids under development, proposed KOSAP mini-grids, and potential SHS markets in Kenya. This is the output of preliminary GIS analysis funded by the World Bank and undertaken in 2017. @@ -130,7 +130,7 @@ layers: url: https://energydata.info/dataset/kenya-overview-of-off-grid-electricity-service-areas - id: grid-expansion name: Grid Expansion Projects - category: result + category: outcome disabled: true mbLayer: 11kv info: This dataset represents potential grid expansion projects identified through a least-cost geospatial analysis undertaken over the period 2017-2018. @@ -139,7 +139,7 @@ layers: url: https://energydata.info/dataset/kenya-grid-expansion - id: minigrid-expansion name: Existing Mini-Grid Expansion Projects - category: result + category: outcome mbLayer: minigrid-existing visible: true info: Potential expansion projects for existing mini-grids; the expansion projects were identified through a least-cost geospatial analysis undertaken over the period 2017-2018. @@ -148,7 +148,7 @@ layers: url: https://energydata.info/dataset/kenya-potential-expansion-of-existing-mini-grids - id: minigrid-new name: New Mini-Grid Projects - category: result + category: outcome mbLayer: minigrid-proposed visible: true info: Potential mini-grid projects; these projects were identified through a least-cost geospatial analysis undertaken over the period 2017-2018. @@ -157,9 +157,22 @@ layers: url: https://energydata.info/dataset/kenya-potential-new-mini-grid-sites - id: wind name: Mean wind speed - category: contextual + category: input mbLayer: wind info: The mean wind speed is a measure of the wind resource. Higher mean wind speeds normally indicate better wind resources, but mean wind power density gives a more accurate indication of the available wind resource. source: name: Global Wind Atlas - url: https://globalwindatlas.info/ \ No newline at end of file + url: https://globalwindatlas.info/ + legendData: + type: gradient + min: '< 2.5' + max: '> 9.75 m/s' + stops: + - '#BEE6FA' + - '#488FC6' + - '#7BC34C' + - '#F9E65B' + - '#F56E2B' + - '#C82333' + - '#A3305C' + \ No newline at end of file diff --git a/docs/README.md b/docs/README.md index 84ce341..b0d9491 100644 --- a/docs/README.md +++ b/docs/README.md @@ -50,6 +50,14 @@ The main information and metadata of each study is managed through a `yml` file, | layers[].source | `object` | Link to the data source | | layers[].source.name | `string` | Name of the source link | | layers[].source.url | `string` | URL of the data source | +| layers[].legendData | `object` | Custom legend | +| layers[].legendData.type | `enum` one of [`gradient`, `line`, `circle`, `symbol`] | Type of legend | +| layers[].legendData.color | `string` | The color of the feature. Applies to `circle` and `line` | +| layers[].legendData.dashed | `boolean` | Use a dashed line. Applies to `line` | +| layers[].legendData.icon | `string` | The basename of the icon, without file extension. Applies to `symbol` | +| layers[].legendData.min | `string` | Minimum value printed on the x-axis. Applies to `gradient` | +| layers[].legendData.max | `string` | Maximum value printed on the x-axis. Applies to `gradient` +| layers[].legendData.stops | `array` | An array with RGB colors that indicate the stops. Applies to `gradient` ## Map configuration The map of each study is configured using a `json` file that follows the Mapbox Style specification. For a full example, please see [`kenya-mb.json`](/content/study/posts/kenya-mb.json). @@ -166,6 +174,55 @@ New icons can be added to [`/content/icons`](/content/icons). They should be in [To top](#managing-studies) + +# Legends +Broadly speaking, AEP supports two types of legends: symbology for features on the map like circles and lines and gradient legends for raster data like the Global Wind Atlas. + +## Customizing vector legends +The platform will automatically determine the legend for vector layers. It's possible to override these defaults by specifying a `legendData` object on the layer configuration. For example: + +### Line +![](media/line-legend.png) + +``` yml +legendData: + type: line + color: '#00FF00' + dashed: true +``` + +### Symbol +![](media/symbol-legend.png) + +``` yml +legendData: + type: symbol + color: 'electricity' +``` + +## Defining raster legends +The legend for external data layers can't be automatically determined by AEP and always have to be defined through configuration. The platform currently supports linear gradients, below is an example with 7 color stops. + +![](media/wind-legend.png) + + +``` yml +legendData: + type: gradient + min: '< 2.5' + max: '> 9.75 m/s' + stops: + - '#BEE6FA' + - '#488FC6' + - '#7BC34C' + - '#F9E65B' + - '#F56E2B' + - '#C82333' + - '#A3305C' +``` + +[To top](#managing-studies) + # Troubleshooting ## Map shows an unexpected layer If the map loads with a layer that can't be managed through the layer switcher, it's likely that you added a layer in the Mapbox Style that isn't referenced in the layer configuration of the `yml`. This is by design. It allows you to overlay a contextual layer on the map that the user don't have control over. A use case could be a layer that adds a disputed border. diff --git a/docs/media/line-legend.png b/docs/media/line-legend.png new file mode 100644 index 0000000..581d9d2 Binary files /dev/null and b/docs/media/line-legend.png differ diff --git a/docs/media/symbol-legend.png b/docs/media/symbol-legend.png new file mode 100644 index 0000000..89dc601 Binary files /dev/null and b/docs/media/symbol-legend.png differ diff --git a/docs/media/wind-legend.png b/docs/media/wind-legend.png new file mode 100644 index 0000000..eee576c Binary files /dev/null and b/docs/media/wind-legend.png differ diff --git a/gatsby-node.js b/gatsby-node.js index 03ce810..1ef52da 100644 --- a/gatsby-node.js +++ b/gatsby-node.js @@ -6,6 +6,16 @@ exports.createSchemaCustomization = ({ actions, schema }) => { const typeDefs = [ ` + type LayerLegend { + type: String + min: String + max: String + stops: [String] + color: String + icon: String + dashed: Boolean + } + type PanelLayerSource { name: String url: String @@ -19,6 +29,7 @@ exports.createSchemaCustomization = ({ actions, schema }) => { mbLayer: String info: String source: PanelLayerSource + legendData: LayerLegend } type Platform { diff --git a/schema/validate.js b/schema/validate.js index 5bda386..9e9b0e4 100644 --- a/schema/validate.js +++ b/schema/validate.js @@ -7,9 +7,8 @@ const mbValidator = require('@mapbox/mapbox-gl-style-spec'); const Schema = require('validate'); const yml = require('js-yaml'); -const fileExists = (val) => { - return fs.existsSync(path.join(__dirname, '../content/study/posts/', val)); -}; +const studyFileExists = (val) => + fs.existsSync(path.join(__dirname, '../content/study/posts/', val)); const studySchema = new Schema({ title: { type: String, required: true }, @@ -24,7 +23,7 @@ const studySchema = new Schema({ ] ], zoomExtent: [{ type: Number }, { type: Number }], - mapConfig: { type: String, use: { fileExists }, required: true }, + mapConfig: { type: String, use: { studyFileExists }, required: true }, study: { consultant: { type: String, required: true }, period: { required: true }, @@ -41,7 +40,7 @@ const studySchema = new Schema({ name: { type: String, required: true }, category: { type: String, - enum: ['contextual', 'result'], + enum: ['input', 'outcome'], required: true }, visible: { type: Boolean }, @@ -51,6 +50,18 @@ const studySchema = new Schema({ source: { name: { type: String, required: true }, url: { type: String, required: true } + }, + legendData: { + type: { + type: String, + enum: ['gradient', 'line', 'circle', 'symbol'] + }, + color: { type: String, match: /^#[0-9a-fA-F]{6}$/ }, + dashed: { type: Boolean }, + icon: { type: String }, + min: { type: String }, + max: { type: String }, + stops: [{ type: String, match: /^#[0-9a-fA-F]{6}$/ }] } } ] diff --git a/src/components/burger-options.js b/src/components/burger-options.js new file mode 100644 index 0000000..30fc07e --- /dev/null +++ b/src/components/burger-options.js @@ -0,0 +1,78 @@ +import React from 'react'; +import T from 'prop-types'; +import styled from 'styled-components'; +import { Button } from '@devseed-ui/button'; +import Dropdown, { + DropTitle, + DropMenu, + DropMenuItem +} from '@devseed-ui/dropdown'; +import { themeVal, glsp } from '@devseed-ui/theme-provider'; +import collecticon from '@devseed-ui/collecticons'; + +import { Link } from '../styles/clean/link'; + +// TODO: Remove once ui library is updated. +const DropMenuItemLink = styled(DropMenuItem)` + &.active { + color: ${themeVal('color.primary')}; + + &::after { + ${collecticon('tick--small')} + position: absolute; + z-index: 1; + top: ${glsp(1 / 4)}; + right: ${glsp(1 / 2)}; + font-size: 1rem; + line-height: 1.5rem; + width: 1.5rem; + text-align: center; + } + } +`; + +function BurgerOptions(props) { + const { items } = props; + + return ( + ( + + )} + > + Browse + + {items.map((l) => ( +
  • + + {l.label} + +
  • + ))} +
    +
    + ); +} + +BurgerOptions.propTypes = { + items: T.array +}; + +export default BurgerOptions; diff --git a/src/components/layout.js b/src/components/layout.js index ddb8466..f866468 100644 --- a/src/components/layout.js +++ b/src/components/layout.js @@ -3,7 +3,6 @@ import styled from 'styled-components'; import T from 'prop-types'; import { DevseedUiThemeProvider } from '@devseed-ui/theme-provider'; import { CollecticonsGlobalStyle } from '@devseed-ui/collecticons'; -import { reveal } from '@devseed-ui/animation'; import theme from '../styles/theme'; @@ -23,7 +22,6 @@ const Page = styled.div` const PageBody = styled.main` padding: 0; margin: 0; - animation: ${reveal} 0.32s ease 0s 1; `; const Layout = ({ children, title, metaImage, description }) => { diff --git a/src/components/page-header.js b/src/components/page-header.js index ff73891..40f880f 100644 --- a/src/components/page-header.js +++ b/src/components/page-header.js @@ -2,16 +2,18 @@ import React from 'react'; import styled from 'styled-components'; import { glsp, media, themeVal } from '@devseed-ui/theme-provider'; import { reveal } from '@devseed-ui/animation'; -import { VerticalDivider } from '@devseed-ui/toolbar'; import { Heading } from '@devseed-ui/typography'; import { Button } from '../styles/button'; -import { filterComponentProps } from '../styles/utils/general'; - +import { Link } from '../styles/clean/link'; +import BurgerOptions from './burger-options'; import ShareOptions from './share-options'; -import { graphql, Link, useStaticQuery } from 'gatsby'; + +import useBreakpoints from '../utils/use-breakpoints'; const PageHeaderSelf = styled.header` + position: relative; + z-index: 10; display: grid; grid-template-columns: max-content 1fr; grid-gap: ${glsp(themeVal('layout.gap.xsmall'))}; @@ -20,6 +22,7 @@ const PageHeaderSelf = styled.header` color: #fff; animation: ${reveal} 0.32s ease 0s 1; padding: ${glsp(0.5, themeVal('layout.gap.xsmall'))}; + box-shadow: ${themeVal('boxShadow.elevationD')}; ${media.mediumUp` grid-gap: ${glsp(themeVal('layout.gap.medium'))}; @@ -64,115 +67,84 @@ const PageNav = styled.nav` const GlobalMenu = styled.ul` display: inline-grid; - grid-gap: ${glsp(0.5)}; + grid-gap: ${glsp(0.25)}; + margin-right: -0.5rem; + + ${media.mediumUp` + grid-gap: ${glsp(0.5)}; + `} > * { grid-row: 1; } `; -// See documentation of filterComponentProp as to why this is -const propsToFilter = [ - 'variation', - 'size', - 'hideText', - 'useIcon', - 'active', - 'visuallyDisabled' +const pageMainNavLinks = [ + { + url: '/', + title: 'Visit the home page', + label: 'Welcome' + }, + { + url: '/studies', + partiallyActive: true, + title: 'View Studies page', + label: 'Studies' + }, + { + url: '/support', + title: 'View Project Support page', + label: 'Support' + }, + { + url: '/toolkit', + title: 'View Agricultural Toolkit page', + label: 'Toolkit' + }, + { + url: '/about', + title: 'View About page', + label: 'About' + } ]; -const StyledNavLink = filterComponentProps(Link, propsToFilter); function PageHeader() { - const data = useStaticQuery(graphql` - query { - site { - siteMetadata { - title - } - } - } - `); - - const { title } = data.site.siteMetadata; + const { mediumUp } = useBreakpoints(); return ( - + AEP -
  • - -
  • -
  • - -
  • -
  • - -
  • -
  • - -
  • -
  • - -
  • -
    - - + {mediumUp && + pageMainNavLinks.map((l) => ( +
  • + +
  • + ))}
  • + {!mediumUp && ( +
  • + +
  • + )}
    diff --git a/src/components/share-options.js b/src/components/share-options.js index a980af8..8286e71 100644 --- a/src/components/share-options.js +++ b/src/components/share-options.js @@ -55,26 +55,24 @@ function ShareOptions() { Share
  • - +
  • - +
  • diff --git a/src/components/study-map/map-layers-defaults.js b/src/components/study-map/map-layers-defaults.js new file mode 100644 index 0000000..49cd49b --- /dev/null +++ b/src/components/study-map/map-layers-defaults.js @@ -0,0 +1,39 @@ +import merge from 'deepmerge'; + +const styleDefaults = { + circle: { + paint: { + 'circle-color': '#5860FF', + 'circle-stroke-color': '#FFFFFF', + 'circle-stroke-opacity': 0.64, + 'circle-stroke-width': 2, + 'circle-radius': ['interpolate', ['linear'], ['zoom'], 6, 5, 12, 15] + } + }, + line: { + paint: { + 'line-color': '#747BFC', + 'line-opacity': ['interpolate', ['linear'], ['zoom'], 6, 0.96, 12, 0.66], + 'line-width': ['interpolate', ['linear'], ['zoom'], 6, 1, 12, 2] + } + }, + symbol: { + layout: { + 'icon-size': ['interpolate', ['linear'], ['zoom'], 6, 0.2, 12, 0.5], + 'icon-allow-overlap': true + } + } +}; + +export const applyMapLayersDefaults = (layers) => { + if (!layers) return null; + return layers.map((layer) => { + if (!styleDefaults[layer.type]) return layer; + + // Merge custom layer properties from MB Style with the default ones. + // Arrays are not concatenated, instead overwritten by custom props. + return merge(styleDefaults[layer.type], layer, { + arrayMerge: (destination, source) => source + }); + }); +}; diff --git a/src/components/study-map/mb-map.js b/src/components/study-map/mb-map.js index d88f85a..3367f04 100644 --- a/src/components/study-map/mb-map.js +++ b/src/components/study-map/mb-map.js @@ -2,10 +2,10 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import T from 'prop-types'; import styled from 'styled-components'; import mapboxgl from 'mapbox-gl'; -import merge from 'deepmerge'; import { diffArrayById } from '../../utils/array'; import { graphql, useStaticQuery } from 'gatsby'; +import { applyMapLayersDefaults } from './map-layers-defaults'; const MapContainer = styled.div` position: relative; @@ -14,31 +14,6 @@ const MapContainer = styled.div` height: 100%; `; -const styleDefaults = { - circle: { - paint: { - 'circle-color': '#5860FF', - 'circle-stroke-color': '#FFFFFF', - 'circle-stroke-opacity': 0.64, - 'circle-stroke-width': 2, - 'circle-radius': ['interpolate', ['linear'], ['zoom'], 6, 5, 12, 15] - } - }, - line: { - paint: { - 'line-color': '#747BFC', - 'line-opacity': ['interpolate', ['linear'], ['zoom'], 6, 0.96, 12, 0.66], - 'line-width': ['interpolate', ['linear'], ['zoom'], 6, 1, 12, 2] - } - }, - symbol: { - layout: { - 'icon-size': ['interpolate', ['linear'], ['zoom'], 6, 0.2, 12, 0.5], - 'icon-allow-overlap': true - } - } -}; - export default function MbMap(props) { const { token, @@ -75,18 +50,7 @@ export default function MbMap(props) { }, [mapConfig]); const mapLayers = useMemo(() => { - if (mapConfig && mapConfig.layers) { - return mapConfig.layers.map((layer) => { - if (!styleDefaults[layer.type]) return layer; - - // Merge custom layer properties from MB Style with the default ones. - // Arrays are not concatenated, instead overwritten by custom props. - return merge(styleDefaults[layer.type], layer, { - arrayMerge: (destination, source) => source - }); - }); - } - return null; + return applyMapLayersDefaults(mapConfig?.layers); }, [mapConfig]); const mapContainer = useRef(null); diff --git a/src/styles/clean/README.md b/src/styles/clean/README.md new file mode 100644 index 0000000..e56bad1 --- /dev/null +++ b/src/styles/clean/README.md @@ -0,0 +1,20 @@ +# Elements clean of props + +This folder contains element which were filtered for unwanted props. + +This is used to circumvent a bug with styled-component where unwanted props are passed to the dom causing react to display an error: + +``` + `Warning: React does not recognize the hideText prop on a DOM element. + If you intentionally want it to appear in the DOM as a custom attribute, + spell it as lowercase hideText instead. If you accidentally passed it from + a parent component, remove it from the DOM element.` +``` + +This commonly happens when an element is impersonating another with the `as` or `forwardedAs` prop: +``` + +``` + +Because of a bug, all the props passed to `Button` are passed to `Link` without being filtered before rendering, causing the aforementioned error. +Issue tracking the bug: https://github.com/styled-components/styled-components/issues/2131 \ No newline at end of file diff --git a/src/styles/clean/link.js b/src/styles/clean/link.js new file mode 100644 index 0000000..95a4d9a --- /dev/null +++ b/src/styles/clean/link.js @@ -0,0 +1,15 @@ +import { Link as Link$ } from 'gatsby'; + +import { filterComponentProps } from '../utils/general'; + +// See documentation of filterComponentProp as to why this is +const propsToFilter = [ + 'variation', + 'size', + 'hideText', + 'useIcon', + 'active', + 'visuallyDisabled' +]; + +export const Link = filterComponentProps(Link$, propsToFilter); diff --git a/src/styles/inpage.js b/src/styles/inpage.js index e0cf5b0..c1c8f3a 100644 --- a/src/styles/inpage.js +++ b/src/styles/inpage.js @@ -15,17 +15,20 @@ export const InpageHeader = styled.header` position: relative; z-index: 20; display: grid; - grid-template-columns: max-content 1fr; + grid-template-columns: auto 1fr; grid-gap: ${glsp(0, themeVal('layout.gap.xsmall'))}; align-items: center; background-color: ${themeVal('color.primary')}; color: #fff; + box-shadow: ${themeVal('boxShadow.elevationD')}; + clip-path: polygon(0 0, 100% 0, 100% 200%, 0% 200%); padding: ${glsp( 0, themeVal('layout.gap.xsmall'), 0.75, themeVal('layout.gap.xsmall') )}; + animation: ${reveal} 0.32s ease 0s 1; ${media.mediumUp` grid-gap: ${glsp(0, themeVal('layout.gap.medium'))}; diff --git a/src/styles/panel.js b/src/styles/panel.js index bcef719..2bd5b81 100644 --- a/src/styles/panel.js +++ b/src/styles/panel.js @@ -6,7 +6,7 @@ import { headingAlt } from '@devseed-ui/typography'; export const Panel = styled.section` position: relative; - z-index: 20; + z-index: 30; display: flex; flex-flow: column nowrap; width: 18rem; @@ -44,20 +44,10 @@ export const PanelSection = styled.section` `; export const PanelSectionHeader = styled.header` - padding: ${glsp( - 0.5, - themeVal('layout.gap.xsmall'), - 0, - themeVal('layout.gap.xsmall') - )}; + padding: ${glsp(0.5, themeVal('layout.gap.xsmall'))}; ${media.mediumUp` - padding: ${glsp( - 1, - themeVal('layout.gap.medium'), - 0, - themeVal('layout.gap.medium') - )}; + padding: ${glsp(1, themeVal('layout.gap.medium'))}; `} `; @@ -65,7 +55,7 @@ export const PanelSectionHeadline = styled.div``; export const PanelSectionTitle = styled(Heading)` font-size: 1rem; - line-height: 1.5rem; + line-height: 1.25rem; margin: 0; `; @@ -76,18 +66,42 @@ export const PanelSectionBody = styled.div` `; export const PanelGroup = styled.section` + position: relative; display: flex; flex-flow: column nowrap; flex: 1; - box-shadow: 0 1px 0 0 ${themeVal('color.baseAlphaC')}; - padding: ${glsp(0.5, 0, 0, 0)}; + + &::before { + position: absolute; + top: 0; + left: ${glsp(themeVal('layout.gap.xsmall'))}; + right: 0; + content: ''; + pointer-events: none; + height: 1px; + background: ${themeVal('color.baseAlphaC')}; + + ${media.mediumUp` + left: ${glsp(themeVal('layout.gap.medium'))}; + `} + } `; export const PanelGroupHeader = styled.header` - padding: ${glsp(0.25, themeVal('layout.gap.xsmall'))}; + padding: ${glsp( + 0.75, + themeVal('layout.gap.xsmall'), + 0.25, + themeVal('layout.gap.xsmall') + )}; ${media.mediumUp` - padding: ${glsp(0.5, themeVal('layout.gap.medium'))}; + padding: ${glsp( + 1, + themeVal('layout.gap.medium'), + 0.5, + themeVal('layout.gap.medium') + )}; `} `; diff --git a/src/styles/theme.js b/src/styles/theme.js index 3f1fba0..0790587 100644 --- a/src/styles/theme.js +++ b/src/styles/theme.js @@ -40,9 +40,9 @@ export default function theme(uiTheme) { gap: { xsmall: 1, small: 1, - medium: 2, - large: 2, - xlarge: 2 + medium: 1.5, + large: 1.5, + xlarge: 1.5 } } }, diff --git a/src/templates/study-single/carto.js b/src/templates/study-single/carto.js index 1bd5ec4..aa47a32 100644 --- a/src/templates/study-single/carto.js +++ b/src/templates/study-single/carto.js @@ -17,6 +17,7 @@ import { } from '../../styles/panel'; import MbMap from '../../components/study-map/mb-map'; import PanelLayersGroup from './panel-layers-group'; +import { applyMapLayersDefaults } from '../../components/study-map/map-layers-defaults'; const Carto = styled.div` display: grid; @@ -32,6 +33,50 @@ const CartoPanelHeader = styled(PanelHeader)` ${visuallyHidden()} `; +const castArray = (l) => (Array.isArray(l) ? l : [l]); + +const inferLegend = (panelLayer, mbLayer) => { + const t = mbLayer.type; + + // Try to infer the legend from the layers. This only works for simple + // types. Anything more complicated will have to be defined by the user. + if (t === 'line') { + const color = mbLayer.paint?.['line-color']; + if (typeof color === 'string') { + return { + label: panelLayer.name, + type: 'line', + color, + dashed: !!mbLayer.paint?.['line-dasharray'] + }; + } + } + + if (t === 'circle') { + const color = mbLayer.paint?.['circle-color']; + if (typeof color === 'string') { + return { + label: panelLayer.name, + type: 'circle', + color + }; + } + } + + if (t === 'symbol') { + const symbolId = mbLayer.layout?.['icon-image']; + if (typeof symbolId === 'string') { + return { + label: panelLayer.name, + type: 'symbol', + icon: symbolId + }; + } + } + + return null; +}; + function StudySingleCarto(props) { const { mbToken, @@ -44,21 +89,44 @@ function StudySingleCarto(props) { onAction } = props; - // Group panel layers by their category. + // Group panel layers by their category and get the config for each map layer + // being controlled. This is needed to construct the legend. const { - result: panelResultLayers, - contextual: panelContextualLayers - } = useMemo( - () => - panelLayers.reduce((acc, layer) => { - const c = layer.category || 'n/a'; - return { - ...acc, - [c]: [...(acc[c] || []), layer] - }; - }, {}), - [panelLayers] - ); + outcome: panelOutcomeLayers, + input: panelInputLayers + } = useMemo(() => { + const mapLayers = applyMapLayersDefaults(mapConfig?.layers); + + return panelLayers.reduce((acc, layer) => { + const c = layer.category || 'n/a'; + const mbLayers = castArray(layer.mbLayer); + + // Prep legend for each mb layer. + const legends = layer.legendData + ? castArray(layer.legendData) + : mbLayers.map((id) => { + const mbLayer = mapLayers.find((l) => l.id === id); + if (!mbLayer) { + /* eslint-disable-next-line no-console */ + console.error( + `Map layer with id \`${id}\` not found in map config` + ); + return null; + } + return inferLegend(layer, mbLayer); + }); + + const l = { + ...layer, + legendData: legends.filter(Boolean) + }; + + return { + ...acc, + [c]: [...(acc[c] || []), l] + }; + }, {}); + }, [mapConfig, panelLayers]); return ( @@ -75,13 +143,13 @@ function StudySingleCarto(props) { diff --git a/src/templates/study-single/index.js b/src/templates/study-single/index.js index 2ff7c27..7ba7e66 100644 --- a/src/templates/study-single/index.js +++ b/src/templates/study-single/index.js @@ -91,7 +91,6 @@ const ViewMenuLink = styled(StyledLink)` active && css` opacity: 1; - box-shadow: ${themeVal('boxShadow.elevationD')}; &::after { width: 100%; @@ -268,6 +267,15 @@ export const pageQuery = graphql` name url } + legendData { + type + min + max + stops + color + icon + dashed + } } } site { diff --git a/src/templates/study-single/layer-legend.js b/src/templates/study-single/layer-legend.js new file mode 100644 index 0000000..e325aba --- /dev/null +++ b/src/templates/study-single/layer-legend.js @@ -0,0 +1,235 @@ +import React from 'react'; +import T from 'prop-types'; +import styled, { css } from 'styled-components'; +import { tint } from 'polished'; +import { + glsp, + visuallyHidden, + stylizeFunction, + themeVal, + truncated +} from '@devseed-ui/theme-provider'; +import { headingAlt } from '@devseed-ui/typography'; + +import { formatThousands } from '../../utils/format'; +import { graphql, useStaticQuery } from 'gatsby'; + +const _tint = stylizeFunction(tint); + +const makeGradient = (stops) => { + const d = 100 / stops.length - 1; + const steps = stops.map((s, i) => `${s} ${i * d}%`); + return `linear-gradient(to right, ${steps.join(', ')})`; +}; + +const printLegendVal = (val) => + typeof val === 'number' ? formatThousands(val, { shorten: true }) : val; + +const LayerLegendSelf = styled.div` + grid-row: 2; + grid-column: 1 / span 2; +`; + +const LegendList = styled.dl` + display: grid; + grid-gap: 0 ${glsp(1 / 8)}; + grid-auto-columns: minmax(1rem, 1fr); + grid-auto-flow: column; + + dt { + grid-row: 1; + } + + dd { + ${headingAlt()} + font-size: 0.75rem; + line-height: 1rem; + grid-row: 2; + display: flex; + + /* stylelint-disable-next-line no-descending-specificity */ + > * { + width: 8rem; + + /* stylelint-disable-next-line no-descending-specificity */ + > * { + ${truncated()} + display: block; + } + + &:last-child:not(:first-child) { + text-align: right; + } + } + + &:last-of-type:not(:first-of-type) { + justify-content: flex-end; + text-align: right; + } + + &:not(:first-of-type):not(:last-of-type) { + ${visuallyHidden()} + } + + i { + margin: 0 auto; + opacity: 0; + } + } +`; + +const LegendSwatch = styled.span` + display: block; + font-size: 0; + height: 0.5rem; + border-radius: ${themeVal('shape.rounded')}; + background: ${({ stops }) => + typeof stops === 'string' ? stops : makeGradient(stops)}; + margin: 0 0 ${glsp(1 / 8)} 0; + box-shadow: inset 0 0 0 1px ${themeVal('color.baseAlphaB')}; + cursor: ${(props) => (props['data-tip'] ? 'help' : 'auto')}; +`; + +const LayerLegendTitle = styled.h2` + ${visuallyHidden()} +`; + +const renderIconography = ({ type, dashed, color }) => { + switch (type) { + case 'line': { + const borderStyle = css` + border: 1px ${dashed ? 'dashed' : 'solid'} ${color}; + `; + + return css` + &::before { + ${borderStyle} + display: block; + content: ''; + height: 100%; + width: 1px; + transform: rotate(45deg); + border-radius: ${themeVal('shape.ellipsoid')}; + } + `; + } + case 'circle': + return css` + &::before { + display: block; + height: 0.75rem; + width: 0.75rem; + content: ''; + background: ${color}; + border-radius: ${themeVal('shape.ellipsoid')}; + } + `; + case 'gradient': + return css` + &::before { + display: block; + height: 100%; + width: 100%; + content: ''; + background: blue; + background: linear-gradient( + to right, + ${_tint(0.8, themeVal('color.base'))}, + ${_tint(0.32, themeVal('color.base'))} + ); + border-radius: ${themeVal('shape.rounded')}; + } + `; + } +}; + +const LayerSubtitle = styled.p` + grid-column: 1; + + span { + position: relative; + display: flex; + height: 1rem; + width: 1rem; + justify-content: center; + align-items: center; + font-size: 0; + + ${renderIconography} + + img { + max-width: 100%; + height: auto; + } + } +`; + +export function LegendGradient(props) { + const { min, max, stops } = props; + + return ( + + Legend + +
    + + {stops[0]} to {stops[stops.length - 1]} + +
    +
    + {printLegendVal(min)} + + {printLegendVal(max)} +
    +
    +
    + ); +} + +LegendGradient.propTypes = { + min: T.oneOfType([T.string, T.number]), + max: T.oneOfType([T.string, T.number]), + stops: T.array +}; + +export function LegendIcon(props) { + const { type, color, icon, dashed } = props; + + const icons = useStaticQuery(graphql` + query { + allFile(filter: { sourceInstanceName: { eq: "icons" } }) { + nodes { + name + publicURL + } + } + } + `); + + if (type === 'symbol') { + const iconData = icons.allFile.nodes.find((n) => n.name === icon); + return ( + iconData && ( + + + Layer icon + {type} type layer + + + ) + ); + } + + return ( + + {type} type layer + + ); +} + +LegendIcon.propTypes = { + type: T.string, + color: T.string, + icon: T.string, + dashed: T.bool +}; diff --git a/src/templates/study-single/panel-layer.js b/src/templates/study-single/panel-layer.js index b558e20..8207318 100644 --- a/src/templates/study-single/panel-layer.js +++ b/src/templates/study-single/panel-layer.js @@ -1,15 +1,13 @@ import React from 'react'; import T from 'prop-types'; import styled from 'styled-components'; -// import ReactTooltip from 'react-tooltip'; import { glsp, media, themeVal, truncated } from '@devseed-ui/theme-provider'; import { AccordionFold } from '@devseed-ui/accordion'; -import { Heading } from '@devseed-ui/typography'; import { Button } from '@devseed-ui/button'; -import { headingAlt } from '@devseed-ui/typography'; +import { Heading, headingAlt } from '@devseed-ui/typography'; import Prose from '../../styles/typography/prose'; -// import LayerLegend from './layer-legend'; +import { LegendGradient, LegendIcon } from './layer-legend'; const LayerSelf = styled(AccordionFold)` position: relative; @@ -19,7 +17,7 @@ const LayerSelf = styled(AccordionFold)` const LayerHeader = styled.header` display: grid; grid-auto-columns: 1fr min-content; - grid-gap: ${glsp(0.5)}; + grid-gap: ${glsp(0.5, 1)}; padding: ${glsp(0.5, themeVal('layout.gap.xsmall'))}; align-items: center; @@ -31,6 +29,14 @@ const LayerHeader = styled.header` const LayerHeadline = styled.div` grid-row: 1; min-width: 0px; + display: grid; + justify-content: start; + align-items: center; + grid-gap: 0.5rem; + + > * { + grid-row: 1; + } `; const LayerTitle = styled(Heading)` @@ -60,17 +66,44 @@ const LayerBodyInner = styled(Prose)` z-index: 8; display: grid; grid-template-columns: 1fr; - grid-gap: ${glsp()}; + grid-gap: ${glsp(0.75)}; box-shadow: inset 0 1px 0 0 ${themeVal('color.baseAlphaB')}, inset 0 -1px 0 0 ${themeVal('color.baseAlphaB')}; background: ${themeVal('color.baseAlphaA')}; font-size: 0.875rem; line-height: 1.25rem; - backdrop-filter: saturate(48%); - padding: ${glsp(1, themeVal('layout.gap.xsmall'))}; + padding: ${glsp(0.75, themeVal('layout.gap.xsmall'))}; + mask-image: linear-gradient( + to right, + transparent 0, + black ${glsp(themeVal('layout.gap.xsmall'))} + ); ${media.mediumUp` - padding: ${glsp(1, themeVal('layout.gap.medium'))}; + padding: ${glsp(0.75, themeVal('layout.gap.medium'))}; + mask-image: linear-gradient( + to right, + transparent 0, + black ${glsp(themeVal('layout.gap.medium'))} + ); + `} + + ${media.largeUp` + padding: ${glsp(0.75, themeVal('layout.gap.large'))}; + mask-image: linear-gradient( + to right, + transparent 0, + black ${glsp(themeVal('layout.gap.large'))} + ); + `} + + ${media.xlargeUp` + padding: ${glsp(0.75, themeVal('layout.gap.xlarge'))}; + mask-image: linear-gradient( + to right, + transparent 0, + black ${glsp(themeVal('layout.gap.xlarge'))} + ); `} /* stylelint-disable-next-line no-descending-specificity */ @@ -82,7 +115,7 @@ const LayerBodyInner = styled(Prose)` const LayerDetailsList = styled.dl` display: grid; grid-template-columns: minmax(min-content, max-content) 1fr; - grid-gap: ${glsp(0.25, 1)}; + grid-gap: ${glsp(0.125, 0.5)}; dt { ${headingAlt()} @@ -102,6 +135,7 @@ function PanelLayer(props) { disabled = false, info, source, + legendData, onToggleClick, isExpanded, setExpanded @@ -116,16 +150,23 @@ function PanelLayer(props) { {label} + {legendData && ( + + )} - {/* */} + {legendData?.type === 'gradient' && ( + + )} )} renderBody={() => ( @@ -156,6 +197,8 @@ function PanelLayer(props) { {info} {source && ( +
    Title
    +
    {label}
    Source
    @@ -176,6 +219,7 @@ PanelLayer.propTypes = { disabled: T.bool, info: T.node, source: T.object, + legendData: T.object, onToggleClick: T.func, isExpanded: T.bool, setExpanded: T.func diff --git a/src/templates/study-single/panel-layers-group.js b/src/templates/study-single/panel-layers-group.js index 67e49dd..d6186c4 100644 --- a/src/templates/study-single/panel-layers-group.js +++ b/src/templates/study-single/panel-layers-group.js @@ -36,7 +36,9 @@ function PanelLayersGroup(props) { active={l.visible} info={l.info} source={l.source} - // legend={l.legend} + // For the time being we only care about the first entry on + // the array list + legendData={l.legendData?.[0]} isExpanded={checkExpanded(idx)} setExpanded={(v) => setExpanded(idx, v)} onToggleClick={() => onAction('layer.toggle', l)} diff --git a/src/utils/format.js b/src/utils/format.js new file mode 100644 index 0000000..1a763e7 --- /dev/null +++ b/src/utils/format.js @@ -0,0 +1,106 @@ +/** + * Rounds a number to a specified amount of decimals. + * + * @param {number} value The value to round + * @param {number} decimals The number of decimals to keep. Default to 2 + */ +export function round(value, decimals = 2) { + return Math.round(value * Math.pow(10, decimals)) / Math.pow(10, decimals); +} + +export function shortenLargeNumber(value, decimals = 2) { + if (value / 1e9 >= 1) { + return { + num: round(value / 1e9, decimals), + unit: 'B' + }; + } else if (value / 1e6 >= 1) { + return { + num: round(value / 1e6, decimals), + unit: 'M' + }; + } else if (value / 1e3 >= 1) { + return { + num: round(value / 1e3, decimals), + unit: 'K' + }; + } + return { + num: value, + unit: '' + }; +} + +/** + * Adds a separator every 3 digits and rounds the number. + * + * @param {number} num The number to format. + * @param {object} options Options for the formatting. + * @param {number} options.decimals Amount of decimals to keep. (Default 2) + * @param {string} options.separator Separator to use. (Default ,) + * @param {boolean} options.forceDecimals Force the existence of decimal. (Default false) + * Eg: Using 2 decimals and force `true` would result: + * formatThousands(1 /2, { forceDecimals: true }) => 0.50 + * @param {boolean} options.shorten Shorten large numbers. (Default false) + * Shortening is done for millions and billions. + * formatThousands(10000000, { shorten: true }) => 10M + * + * @example + * formatThousands(1) 1 + * formatThousands(1000) 1,000 + * formatThousands(10000000) 10,000,000 + * formatThousands(1/3) 0.33 + * formatThousands(100000/3) 33,333.33 + * formatThousands() -- + * formatThousands('asdasdas') -- + * formatThousands(1/2, { decimals: 0 }) 1 + * formatThousands(1/2, { decimals: 0, forceDecimals: true}) 1 + * formatThousands(1/2, { decimals: 5 }) 0.5 + * formatThousands(1/2, { decimals: 5, forceDecimals: true}) 0.50000 + * + */ +export function formatThousands(num, options) { + const opts = { + decimals: 2, + separator: ',', + forceDecimals: false, + shorten: false, + ...options + }; + + // isNaN(null) === true + if (isNaN(num) || (!num && num !== 0)) { + return '--'; + } + + const repeat = (char, length) => { + let str = ''; + for (let i = 0; i < length; i++) str += char + ''; + return str; + }; + + let [int, dec] = Number(round(num, opts.decimals)).toString().split('.'); + + let largeNumUnit = ''; + if (opts.shorten) { + const { num, unit } = shortenLargeNumber(int, 0); + int = num.toString(); + largeNumUnit = unit; + } + + // Space the integer part of the number. + int = int.replace(/\B(?=(\d{3})+(?!\d))/g, opts.separator); + // Round the decimals. + dec = (dec || '').substr(0, opts.decimals); + // Add decimals if forced. + dec = opts.forceDecimals + ? `${dec}${repeat(0, opts.decimals - dec.length)}` + : dec; + + return dec !== '' + ? `${int}.${dec} ${largeNumUnit}` + : `${int} ${largeNumUnit}`; +} + +// Add zero leading decimals +export const zeroPad = (v) => (v < 10 ? `0${v}` : v); diff --git a/src/utils/use-breakpoints.js b/src/utils/use-breakpoints.js new file mode 100644 index 0000000..facaad4 --- /dev/null +++ b/src/utils/use-breakpoints.js @@ -0,0 +1,49 @@ +import { useContext, useEffect, useState } from 'react'; +import { ThemeContext } from 'styled-components'; + +const calculateBreakpoints = (ranges) => { + const w = typeof window === 'undefined' ? 0 : window.innerWidth; + const mediaKeys = Object.keys(ranges); + return mediaKeys.reduce((acc, k) => { + const [lower, upper] = ranges[k]; + + if (lower) { + acc[`${k}Up`] = w >= lower; + } + if (upper) { + acc[`${k}Down`] = w <= upper; + } + if (lower && upper) { + acc[`${k}Only`] = w >= lower && w <= upper; + } + + return acc; + }, {}); +}; + +export default function useBreakpoints() { + const theme = useContext(ThemeContext); + const [breakpoints, setBreakpoints] = useState( + calculateBreakpoints(theme.mediaRanges) + ); + + useEffect(() => { + const listener = () => { + const newBreak = calculateBreakpoints(theme.mediaRanges); + // Quick compare check to see if properties changed. + for (const r in newBreak) { + if (newBreak[r] !== breakpoints[r]) { + setBreakpoints(newBreak); + return; + } + } + }; + + window.addEventListener('resize', listener); + return () => { + window.removeEventListener('resize', listener); + }; + }, [breakpoints, theme.mediaRanges]); + + return breakpoints; +}