├── src
├── styles
│ └── scss
│ │ ├── App.scss
│ │ ├── global
│ │ ├── _forms.scss
│ │ ├── _charts.scss
│ │ ├── _nav.scss
│ │ └── _buttons.scss
│ │ ├── core
│ │ ├── _nav.scss
│ │ ├── _animation.scss
│ │ ├── _utilities.scss
│ │ ├── _base.scss
│ │ └── _typography.scss
│ │ ├── index.scss
│ │ ├── settings
│ │ ├── _functions.scss
│ │ ├── _variables.scss
│ │ └── _mixins.scss
│ │ ├── about
│ │ └── _global.scss
│ │ ├── home
│ │ └── _global.scss
│ │ └── map
│ │ └── _global.scss
├── App.test.js
├── state
│ ├── index.js
│ ├── ReportState.js
│ └── AppState.js
├── graphics
│ └── icons
│ │ ├── chevron-left.svg
│ │ ├── chevron-right.svg
│ │ └── circle-information.svg
├── index.js
├── components
│ ├── PanelContainer.js
│ ├── AoiOption.js
│ ├── Header.js
│ ├── CompletenessStatus.js
│ ├── ReportEditsChart.js
│ ├── HomeMap.js
│ └── ReportMap.js
├── App.js
├── api.js
├── views
│ ├── Home.js
│ ├── About.js
│ └── Report.js
└── registerServiceWorker.js
├── public
├── favicon.ico
├── images
│ ├── logo.png
│ ├── sat.jpg
│ ├── chobe.png
│ ├── attribute.png
│ ├── streets.png
│ └── completeness.png
├── manifest.json
└── index.html
├── .gitignore
├── .circleci
└── config.yml
├── package.json
└── README.md
/src/styles/scss/App.scss:
--------------------------------------------------------------------------------
1 | @import "jeet/jeet";
2 |
3 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hotosm/osma-health/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/public/images/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hotosm/osma-health/HEAD/public/images/logo.png
--------------------------------------------------------------------------------
/public/images/sat.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hotosm/osma-health/HEAD/public/images/sat.jpg
--------------------------------------------------------------------------------
/public/images/chobe.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hotosm/osma-health/HEAD/public/images/chobe.png
--------------------------------------------------------------------------------
/public/images/attribute.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hotosm/osma-health/HEAD/public/images/attribute.png
--------------------------------------------------------------------------------
/public/images/streets.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hotosm/osma-health/HEAD/public/images/streets.png
--------------------------------------------------------------------------------
/src/styles/scss/global/_forms.scss:
--------------------------------------------------------------------------------
1 | .form__label {
2 | font-size: 0.875rem;
3 | font-style: italic;
4 | }
5 |
--------------------------------------------------------------------------------
/public/images/completeness.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hotosm/osma-health/HEAD/public/images/completeness.png
--------------------------------------------------------------------------------
/src/App.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import App from './App';
4 |
5 | it('renders without crashing', () => {
6 | const div = document.createElement('div');
7 | ReactDOM.render( , div);
8 | ReactDOM.unmountComponentAtNode(div);
9 | });
10 |
--------------------------------------------------------------------------------
/src/styles/scss/core/_nav.scss:
--------------------------------------------------------------------------------
1 | .page__header {
2 | @include column(12/12);
3 | padding: $global-spacing 0;
4 | border-bottom: solid 1px rgba($base-color, 0.8);
5 | }
6 |
7 | .page__headline {
8 | @include column(6/12);
9 | font-size: $base-font-size;
10 | }
11 |
12 | .page__nav {
13 | ul {
14 | list-style: none;
15 | float: right;
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React App",
3 | "name": "Create React App Sample",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | }
10 | ],
11 | "start_url": "./index.html",
12 | "display": "standalone",
13 | "theme_color": "#000000",
14 | "background_color": "#ffffff"
15 | }
16 |
--------------------------------------------------------------------------------
/src/state/index.js:
--------------------------------------------------------------------------------
1 | import AppState, {appSaga} from './AppState';
2 | import ReportState, {reportSaga} from './ReportState';
3 | import { all } from 'redux-saga/effects';
4 | import { combineReducers } from 'redux';
5 |
6 | export const rootState = combineReducers({
7 | AppState,
8 | ReportState
9 | });
10 |
11 | export function * rootSaga() {
12 | yield all([appSaga(), reportSaga()]);
13 | }
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/ignore-files/ for more about ignoring files.
2 |
3 | # styles
4 | *.css
5 | /src/styles/scss/jeet*
6 |
7 | # dependencies
8 | /node_modules
9 |
10 | # testing
11 | /coverage
12 |
13 | # production
14 | /build
15 |
16 | # misc
17 | .DS_Store
18 | .env.local
19 | .env.development.local
20 | .env.test.local
21 | .env.production.local
22 |
23 | npm-debug.log*
24 | yarn-debug.log*
25 | yarn-error.log*
26 |
--------------------------------------------------------------------------------
/src/graphics/icons/chevron-left.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/src/graphics/icons/chevron-right.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/src/styles/scss/global/_charts.scss:
--------------------------------------------------------------------------------
1 | .recharts-wrapper {
2 | width: 100% !important;
3 | }
4 |
5 | .recharts-cartesian-axis-tick text {
6 | font-size: 0.75rem;
7 | fill: #FFF;
8 | }
9 |
10 | .recharts-cartesian-grid {
11 | opacity: 0;
12 | }
13 |
14 | .recharts-tooltip-wrapper {
15 | visibility: visible !important;
16 | }
17 |
18 | .custom-tooltip-item {
19 | span {
20 | font-size: 0.875rem;
21 | }
22 | }
23 |
24 | .chart-title {
25 | float: left;
26 | margin-top: 1rem;
27 | font-size: 0.875rem;
28 | }
29 |
30 | .recharts-responsive-container {
31 | margin-bottom: 4rem;
32 | }
33 |
34 | .chart-container {
35 | float: left;
36 | width: 100%;
37 | }
--------------------------------------------------------------------------------
/src/styles/scss/index.scss:
--------------------------------------------------------------------------------
1 | @import "App";
2 | @import "jeet/jeet";
3 | @import "settings/variables";
4 | @import "settings/mixins";
5 | @import "settings/functions";
6 | @import "core/animation";
7 | @import "core/base";
8 | @import "core/typography";
9 | @import "core/utilities";
10 | @import "global/nav";
11 | @import "global/buttons";
12 | @import "global/forms";
13 | @import "global/charts";
14 | @import "home/global";
15 | @import "map/global";
16 | @import "about/global";
17 | @import url('https://fonts.googleapis.com/css?family=Archivo|Barlow+Condensed');
18 |
19 |
20 | body {
21 | margin: 0;
22 | padding: 0;
23 | font-family: sans-serif;
24 | }
25 |
--------------------------------------------------------------------------------
/src/styles/scss/settings/_functions.scss:
--------------------------------------------------------------------------------
1 | /* ==========================================================================
2 | Functions
3 | ========================================================================== */
4 |
5 | /* Range function
6 | ========================================================================== */
7 |
8 | /**
9 | * Define ranges for various things, like media queries.
10 | */
11 |
12 | @function lower-bound($range){
13 | @if length($range) <= 0 {
14 | @return 0;
15 | }
16 | @return nth($range,1);
17 | }
18 |
19 | @function upper-bound($range) {
20 | @if length($range) < 2 {
21 | @return 999999999999;
22 | }
23 | @return nth($range, 2);
24 | }
--------------------------------------------------------------------------------
/src/styles/scss/core/_animation.scss:
--------------------------------------------------------------------------------
1 | /* ==========================================================================
2 | Animation
3 | ========================================================================== */
4 |
5 |
6 | /* Spin clockwise
7 | ========================================================================== */
8 |
9 | @keyframes spin-cw {
10 | from {
11 | transform: rotate(0deg);
12 | }
13 | to { transform: rotate(360deg);
14 | }
15 | }
16 |
17 |
18 | /* Fade-in
19 | ========================================================================== */
20 |
21 | @keyframes fade-in {
22 | 0% {
23 | opacity: 0;
24 | }
25 |
26 | 100% {
27 | opacity: 1;
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | /* react redux preamble */
2 | import React from 'react';
3 | import ReactDOM from 'react-dom';
4 | import { createStore, applyMiddleware } from 'redux';
5 | import { Provider } from 'react-redux';
6 | import createSagaMiddleware from 'redux-saga';
7 |
8 | /** App components */
9 | import './styles/css/index.css';
10 | import App from './App';
11 | import registerServiceWorker from './registerServiceWorker';
12 |
13 | import {rootState, rootSaga} from './state';
14 |
15 | /* store and middleware */
16 | const sagaMiddleware = createSagaMiddleware();
17 |
18 | const store = createStore(rootState, applyMiddleware(sagaMiddleware));
19 | sagaMiddleware.run(rootSaga);
20 |
21 | /* let's kick it off */
22 | ReactDOM.render( , document.getElementById('root'));
23 | registerServiceWorker();
--------------------------------------------------------------------------------
/src/components/PanelContainer.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 |
3 | export default class PanelContainer extends Component {
4 | constructor(props) {
5 | super(props);
6 | this.state = {
7 | panelOpen: true
8 | }
9 |
10 | this.togglePanel = this.togglePanel.bind(this);
11 | }
12 |
13 | togglePanel() {
14 | this.setState({
15 | panelOpen: !this.state.panelOpen
16 | });
17 | }
18 |
19 |
20 | render() {
21 | return (
22 |
23 | {this.props.children}
24 |
25 |
26 |
27 |
28 | )
29 | }
30 | }
--------------------------------------------------------------------------------
/src/components/AoiOption.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import upperFirst from 'lodash.upperfirst';
3 | import CompletenessStatus from '../components/CompletenessStatus';
4 |
5 |
6 | export class AoiOption extends React.PureComponent {
7 | render() {
8 | const {country, name, id} = this.props.aoi.properties;
9 | const completeness = this.props.stats ? this.props.stats[`${country}_${id}`] : undefined;
10 | return(
11 | this.props.history.push(`/${country}/${id}`)}>
12 |
13 |
{upperFirst(country)}: {name}
14 | {
15 | completeness !== undefined &&
16 |
17 | }
18 |
19 |
20 |
21 |
22 |
23 |
24 | );
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/src/graphics/icons/circle-information.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
11 |
--------------------------------------------------------------------------------
/src/App.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { connect } from 'react-redux';
3 | import { requestCountries } from './state/AppState';
4 | import { HashRouter as Router, Route } from 'react-router-dom';
5 |
6 | import { Header } from './components/Header';
7 | /* Views */
8 | import Report from './views/Report';
9 | import Home from './views/Home';
10 | import About from './views/About';
11 |
12 | class App extends Component {
13 |
14 | componentWillMount () {
15 | // Dispatch fetch countries
16 | this.props.requestCountries();
17 | }
18 |
19 | render() {
20 | return (
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 | );
30 | }
31 | }
32 |
33 | const mapDispatchToProps = dispatch => {
34 | return {
35 | requestCountries: () => dispatch(requestCountries())
36 | }
37 | }
38 |
39 | export default connect(null, mapDispatchToProps)(App);
40 |
--------------------------------------------------------------------------------
/src/components/Header.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { withRouter } from 'react-router'
3 |
4 |
5 | class Header extends React.Component {
6 | render() {
7 | return(
8 |
27 | );
28 | }
29 | }
30 |
31 | Header = withRouter(Header);
32 | export {Header};
33 |
--------------------------------------------------------------------------------
/.circleci/config.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | jobs:
3 | build_prod:
4 | docker:
5 | - image: quay.io/hotosm/circleci:node10.16-python2.7
6 | steps:
7 | - checkout
8 | - restore_cache:
9 | keys:
10 | - v1-dependencies-{{ checksum "package.json" }}
11 | - v1-dependencies-
12 | - run:
13 | name: Install dependencies
14 | command: yarn install
15 | - save_cache:
16 | paths:
17 | - node_modules
18 | key: v1-dependencies-{{ checksum "package.json" }}
19 | - run:
20 | name: Build
21 | command: yarn run build
22 | - run:
23 | name: Deploy
24 | command: aws --region us-east-1 s3 sync build s3://osma-health/master --delete
25 | - run:
26 | name: Show site URL
27 | command : echo http://osma-health.s3-website-us-east-1.amazonaws.com/master
28 | workflows:
29 | version: 2
30 | prod:
31 | jobs:
32 | - build_prod:
33 | filters:
34 | branches:
35 | only:
36 | - master
37 |
--------------------------------------------------------------------------------
/src/components/CompletenessStatus.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | const labels = {
4 | 'good': {
5 | statusText: 'Good',
6 | infoText: 'OSM building coverage is good relative to population density.',
7 | color: 'good'
8 | },
9 | 'fair': {
10 | statusText: 'Fair',
11 | infoText: 'OSM building coverage is fair relative to population density.',
12 | color: 'fair'
13 | },
14 | 'poor': {
15 | statusText: 'Poor',
16 | infoText: 'OSM building coverage is poor relative to population density.',
17 | color: 'poor'
18 | }
19 | }
20 |
21 | export default ({ completenessPercentage, page }) => {
22 | let status = 'good';
23 | if (completenessPercentage < 0.5) {
24 | status = 'fair';
25 | }
26 | if (completenessPercentage < -0.5) {
27 | status = 'poor';
28 | }
29 | const {statusText, color} = labels[status];
30 |
31 | if (page === "home") {
32 | return (
33 |
34 |
{statusText} relative completeness
35 |
36 | );
37 | } else {
38 | return (
39 |
42 | );
43 | }
44 |
45 | }
46 |
--------------------------------------------------------------------------------
/src/styles/scss/about/_global.scss:
--------------------------------------------------------------------------------
1 | .about-page {
2 | @include column(12/12);
3 | background-color: rgba($primary-color, 0.05);
4 | }
5 |
6 | .section__body {
7 | @include column(12/12);
8 | img {
9 | width: 50%;
10 | padding: 2rem;
11 | &:first-child {
12 | width: 70%;
13 | };
14 | }
15 | p {
16 | width: 80%;
17 | }
18 | h3, h2, h4{
19 | padding: ($global-spacing * 2) 0 ($global-spacing * 1) 0;
20 | font-weight: $base-font-bold;
21 | }
22 | a {
23 | text-decoration: underline;
24 | }
25 | ul {
26 | margin: $global-spacing;
27 | }
28 | }
29 |
30 | .section__header {
31 | @include column(12/12);
32 | margin-top: $global-spacing * 2;
33 | h2 {
34 | position: relative;
35 | font-weight: $base-font-bold;
36 | padding: ($global-spacing * 2) 0 ($global-spacing * 1) 0;
37 | &:after {
38 | content: '';
39 | position: absolute;
40 | bottom: 0;
41 | left: 0;
42 | height: 1px;
43 | width: 80%;
44 | background-color: rgba($primary-color, 0.3);
45 | };
46 | }
47 | }
48 |
49 | .footer {
50 | background-color: rgba($primary-color, 0.1);
51 | @include column(12/12);
52 | padding: ($global-spacing * 2) 0;
53 | margin-top: $global-spacing * 4;
54 | img {
55 | width: 10%;
56 | float: left;
57 | }
58 | p {
59 | float: left;
60 | width: 55%;
61 | font-size: 0.75rem;
62 | margin: $global-spacing 0 0 ($global-spacing) ;
63 | }
64 | }
65 |
66 |
--------------------------------------------------------------------------------
/src/api.js:
--------------------------------------------------------------------------------
1 | const COUNTRIES_URL = 'https://raw.githubusercontent.com/hotosm/osma-health-workers/master/countries.json';
2 | const STATS_BASE_URL = 'https://s3.amazonaws.com/osma-health/data';
3 |
4 | const URLize = str => str.toLowerCase().replace(/\s/g, '+');
5 | async function fetchCountries() {
6 | const response = await fetch(COUNTRIES_URL);
7 | if (response.ok) {
8 | return response.json();
9 | } else {
10 | throw new Error('Could not retrieve countries');
11 | }
12 | }
13 |
14 | async function fetchDomain (country) {
15 | const url = `${STATS_BASE_URL}/${URLize(country)}/domain.json`;
16 | const response = await fetch(url);
17 |
18 | if (response.ok) {
19 | return response.json();
20 | } else {
21 | throw new Error('Could not retrieve domain stats');
22 | }
23 | }
24 |
25 | async function fetchStats (country, boundary) {
26 | const url = `${STATS_BASE_URL}/${URLize(country)}/${URLize(boundary)}/stats.json`;
27 | const response = await fetch(url);
28 |
29 | if (response.ok) {
30 | return response.json();
31 | } else {
32 | throw new Error('Could not retrieve boundary stats');
33 | }
34 | }
35 |
36 | async function fetchGeneralCompletenessStats (country, boundary) {
37 | const url = `${STATS_BASE_URL}/${URLize(country)}/stats.json`;
38 | const response = await fetch(url);
39 |
40 | if (response.ok) {
41 | return response.json();
42 | } else {
43 | throw new Error('Could not retrieve boundary stats');
44 | }
45 | }
46 |
47 | export default {
48 | fetchCountries,
49 | fetchStats,
50 | fetchGeneralCompletenessStats,
51 | fetchDomain
52 | }
53 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "osma-health",
3 | "version": "0.2.1",
4 | "private": true,
5 | "dependencies": {
6 | "@turf/bbox": "^6.0.1",
7 | "@turf/center-of-mass": "^6.0.1",
8 | "@turf/helpers": "^6.1.4",
9 | "d3-scale": "^2.2.2",
10 | "d3-scale-chromatic": "^1.5.0",
11 | "date-fns": "^1.30.1",
12 | "lodash.upperfirst": "^4.3.1",
13 | "mapbox-gl": "^0.54.1",
14 | "numeral": "^2.0.6",
15 | "react": "^16.5.2",
16 | "react-dom": "^16.8.6",
17 | "react-redux": "^5.1.1",
18 | "react-router-dom": "^5.0.1",
19 | "react-scripts": "3.0.1",
20 | "recharts": "^1.3.1",
21 | "redux": "^4.0.4",
22 | "redux-saga": "^0.16.2"
23 | },
24 | "homepage": ".",
25 | "scripts": {
26 | "postinstall": "npm run preprocess-css",
27 | "preprocess-css": "shx cp -R node_modules/jeet src/styles/scss/",
28 | "build-css": "node-sass-chokidar src/styles/scss/ -o src/styles/css/",
29 | "watch-css": "npm run build-css && node-sass-chokidar src/styles/scss/ -o src/styles/css/ --watch --recursive",
30 | "start-js": "react-scripts start",
31 | "start": "npm-run-all -p watch-css start-js",
32 | "build-js": "react-scripts build",
33 | "build": "npm-run-all build-css build-js",
34 | "test": "react-scripts test --env=jsdom",
35 | "eject": "react-scripts eject"
36 | },
37 | "devDependencies": {
38 | "jeet": "^7.2.0",
39 | "node-sass-chokidar": "^1.3.5",
40 | "npm-run-all": "^4.1.5",
41 | "shx": "^0.3.2"
42 | },
43 | "browserslist": [
44 | ">0.2%",
45 | "not dead",
46 | "not ie <= 11",
47 | "not op_mini all"
48 | ]
49 | }
50 |
--------------------------------------------------------------------------------
/src/styles/scss/global/_nav.scss:
--------------------------------------------------------------------------------
1 | .page__header {
2 | @include column(12/12);
3 | padding: 0.75rem 0 $global-spacing 0;
4 | height: 62px;
5 | border-bottom: solid 1px rgba($primary-color, 0.2);
6 | color: $nav-font-color;
7 | a {
8 | text-decoration: none;
9 | }
10 | }
11 |
12 | .page__headline {
13 | @include column(6/12);
14 | }
15 |
16 | .page__title {
17 | font-size: $base-font-size;
18 | }
19 |
20 | .page__nav {
21 | font-size: 0.875rem;
22 | ul {
23 | list-style: none;
24 | float: right;
25 | }
26 | }
27 |
28 | .page__header-inner {
29 | padding: 0 12px;
30 | }
31 |
32 | .page__header__links a {
33 | display: inline-block;
34 | font-family: "Barlow Condensed",sans-serif;
35 | font-size: 0.95rem;
36 | font-weight: 600;
37 | text-transform: uppercase;
38 | letter-spacing: .035em;
39 | padding-top: 5px;
40 | margin-right: 22px;
41 | color: $nav-font-color;
42 | }
43 | .page__header__links a.active {
44 | opacity: 1;
45 | border-bottom:2px solid $nav-font-color;
46 | }
47 |
48 | .page__header__links {
49 | line-height: 1.9rem;
50 | }
51 |
52 | @media (min-width: 798px) {
53 | .page__header__links a {
54 | border-bottom:2px solid transparent;
55 | }
56 | .page__header__links a:hover {
57 | opacity: 1;
58 | border-bottom:2px solid #eb9f9f;
59 | }
60 | }
61 |
62 | .page__header-hot-logo img {
63 | height: 40px;
64 | width: auto;
65 | padding-top: 5px;
66 | margin-left: 8px;
67 | }
68 |
69 | .page__header-hot-logo span {
70 | align-self: center;
71 | font-size: 0.75rem;
72 | font-family: "Archivo",sans-serif;
73 | }
74 |
75 | .page__header-hot-logo {
76 | float: right;
77 | display: flex;
78 | position: absolute;
79 | right: 10px;
80 | top: 10px;
81 | }
82 |
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
11 |
12 |
13 |
14 |
15 |
24 | OpenStreetMap Analytics for Health
25 |
26 |
27 |
28 | You need to enable JavaScript to run this app.
29 |
30 |
31 |
41 |
42 |
43 |
--------------------------------------------------------------------------------
/src/styles/scss/home/_global.scss:
--------------------------------------------------------------------------------
1 | .panel__header {
2 | padding-top: $global-spacing * 2;
3 | }
4 |
5 | .panel__description {
6 | margin: $global-spacing 0;
7 | font-size: 0.875rem;
8 | width: 90%;
9 | opacity: 0.8;
10 | color: #929db3;
11 | }
12 |
13 | .panel__section {
14 | margin-top: $global-spacing * 3;
15 | }
16 |
17 | .panel__form {
18 | width: 100%;
19 | }
20 |
21 | .panel-container {
22 | position: absolute;
23 | @include column(6/12);
24 | top: 0;
25 | bottom: 0;
26 | left: 0;
27 | }
28 |
29 | .panel-button {
30 | position: relative;
31 | right: 0;
32 | height: 3.5rem;
33 | background: $primary-color;
34 | width: 5%;
35 | float: right;
36 | }
37 |
38 | .panel {
39 | color: #FFF;
40 | position: relative;
41 | height: calc(100vh - 76px);
42 | width: 95%;
43 | float: left;
44 | background: $background-color;
45 | overflow: auto;
46 | }
47 |
48 | header {
49 | height: 8vh;
50 | float: left;
51 | width: 100%;
52 | background-color: rgba(255,255,255, 0.78);
53 | padding: 0.875rem 0 0 0;
54 | box-shadow: 0px 0px 20px rgba(0,0,0, 0.5);
55 | z-index: 5;
56 | position: relative;
57 | }
58 |
59 | header h1 {
60 | font-size: 0.95rem;
61 | line-height: 0.95rem;
62 | float: left;
63 | border: 2px solid $nav-font-color;
64 | color: $nav-font-color;
65 | font-family: "Archivo",sans-serif;
66 | padding: 9px 10px;
67 | flex: 0 1 auto;
68 | font-weight: bold;
69 | margin-right: 22px;
70 | }
71 |
72 | // header h1:hover {
73 | // background-color: #eaeaeb;
74 | // }
75 |
76 | .aoi-list-item, .report__summary-item {
77 | background-color: #383d47;
78 | margin: 4px 0;
79 | padding: 10px;
80 | }
81 |
82 | .aoi-list-item h1 {
83 | font-size: 1.05rem;
84 | font-weight: bold;
85 | color: #fff;
86 | }
87 |
88 | .grid-wrapper {
89 | display: grid;
90 | grid-template-columns: repeat(15, 1fr);
91 | grid-gap: 10px;
92 | grid-auto-rows: minmax(20px, auto);
93 | cursor: pointer;
94 | }
95 |
96 | .aoi-list-item-detail {
97 | grid-column: 1/14;
98 | grid-row: 1;
99 | color: #929db3;
100 | }
101 |
102 | .aoi-list-arrow {
103 | grid-column: 15;
104 | grid-row: 1;
105 | vertical-align: middle;
106 | }
107 |
--------------------------------------------------------------------------------
/src/state/ReportState.js:
--------------------------------------------------------------------------------
1 | import { call, put, take, all, fork } from 'redux-saga/effects';
2 | import Api from '../api';
3 |
4 | /* Actions */
5 | const BOUNDARY_FETCH_FAILED = 'BOUNDARY_FETCH_FAILED';
6 | const BOUNDARY_FETCH_SUCCEEDED = 'BOUNDARY_FETCH_SUCCEEDED';
7 | const BOUNDARY_REQUESTED = 'BOUNDARY_REQUESTED';
8 |
9 | const DOMAIN_FETCH_FAILED = 'DOMAIN_FETCH_FAILED';
10 | const DOMAIN_FETCH_SUCCEEDED = 'DOMAIN_FETCH_SUCCEEDED';
11 |
12 | export function boundaryFetchFailed (message) {
13 | console.error(BOUNDARY_FETCH_FAILED, message);
14 | return { type: BOUNDARY_FETCH_FAILED, message };
15 | }
16 |
17 | export function boundaryFetchSucceeded (data) {
18 | return { type: BOUNDARY_FETCH_SUCCEEDED, data };
19 | }
20 |
21 | export function domainFetchFailed (message) {
22 | console.error(DOMAIN_FETCH_FAILED, message);
23 | return { type: DOMAIN_FETCH_FAILED, message };
24 | }
25 |
26 | export function domainFetchSucceeded (data) {
27 | return { type: DOMAIN_FETCH_SUCCEEDED, data };
28 | }
29 |
30 | export function requestBoundary (country, boundary) {
31 | return { type: BOUNDARY_REQUESTED, country, boundary };
32 | }
33 |
34 | /* Saga */
35 | export function* fetchBoundary (country, boundary) {
36 | try {
37 | const data = yield call(Api.fetchStats, country, boundary);
38 | yield put(boundaryFetchSucceeded(data));
39 | } catch (e) {
40 | yield put(boundaryFetchFailed(e.message));
41 | }
42 | }
43 |
44 | export function* fetchDomain (country) {
45 | try {
46 | const data = yield call(Api.fetchDomain, country);
47 | yield put(domainFetchSucceeded(data));
48 | } catch (e) {
49 | yield put(domainFetchFailed(e.message));
50 | }
51 | }
52 |
53 | export function* reportSaga () {
54 | while (true) {
55 | const {country, boundary} = yield take(BOUNDARY_REQUESTED);
56 | yield all([
57 | fork(fetchBoundary, country, boundary),
58 | fork(fetchDomain, country)
59 | ]);
60 | }
61 | }
62 |
63 | const initialState = {
64 | stats: null,
65 | domain: null,
66 | zoom: null
67 | }
68 |
69 | /* Reducer */
70 | export default function AppState (state = initialState, action) {
71 | switch (action.type) {
72 | case BOUNDARY_FETCH_SUCCEEDED:
73 | return Object.assign({}, state, {
74 | stats: action.data,
75 | });
76 | case DOMAIN_FETCH_SUCCEEDED:
77 | return Object.assign({}, state, {
78 | domain: action.data,
79 | });
80 |
81 | default:
82 | break;
83 | }
84 |
85 | return state;
86 | }
87 |
--------------------------------------------------------------------------------
/src/components/ReportEditsChart.js:
--------------------------------------------------------------------------------
1 | import React, {Component} from 'react';
2 | import {ResponsiveContainer, BarChart, Bar, CartesianGrid, XAxis, Tooltip} from 'recharts';
3 | import {subMonths, format} from 'date-fns';
4 | import numeral from 'numeral';
5 |
6 | /* Custom tooltip if we need it */
7 |
8 | const CustomTooltip = ({active, payload}) => {
9 | const background = {
10 | margin: 0,
11 | padding: 10,
12 | backgroundColor: '#fff',
13 | border: '1px solid #ccc',
14 | whiteSpace: 'nowrap',
15 | };
16 | const listStyle = {
17 | padding: 0,
18 | margin: 0
19 | }
20 |
21 | const itemStyle = {
22 | display: 'block',
23 | paddingTop: 4,
24 | paddingBottom: 4,
25 | color: '#000',
26 | };
27 |
28 | if (active) {
29 | const {value, payload: {formattedDate}} = payload[0];
30 | return (
31 |
32 |
33 |
34 | {formattedDate}
35 | :
36 | {numeral(value).format('0,0')} edits
37 |
38 |
39 |
40 | );
41 | }
42 | return null;
43 | }
44 |
45 | export default class ReportsEditsChart extends Component {
46 | render() {
47 | const {timeBins} = this.props;
48 |
49 | // Get only the bins in the past 24 months
50 | const today = new Date();
51 | let data = {};
52 | for (let i = 0; i < 24; i++) {
53 | const date = subMonths(today, i);
54 | const key = format(date, 'YYYYMM');
55 | const dateReadable = format(date, 'MMM. YYYY');
56 | if (timeBins[key]) {
57 | data[key] = {'edits': timeBins[key], 'formattedDate': dateReadable};
58 | } else {
59 | data[key] = {'edits': 0, 'formattedDate': dateReadable};
60 | }
61 | }
62 |
63 | // Format for the bar chart
64 | data = Object.keys(data).map(key => {
65 | return { 'date': key, ...data[key]};
66 | });
67 |
68 | return (
69 |
70 |
71 | }/>
72 |
73 |
74 |
75 |
76 |
77 | )
78 | }
79 | }
--------------------------------------------------------------------------------
/src/styles/scss/settings/_variables.scss:
--------------------------------------------------------------------------------
1 | /* ==========================================================================
2 | Variables
3 | ========================================================================== */
4 |
5 | /* Colors
6 | ========================================================================== */
7 |
8 | $base-color: #FFF; // Dark green
9 | $primary-color: #36414D; // dark blue
10 | $secondary-color: #8EA0B3; // light blue
11 | $secondary-color-light: #C9D2E3;
12 | $tertiary-color: #68707f;
13 | $highlight-color: #76B5FF; // Blue light
14 | $background-color: #2c3038;
15 |
16 | /* State colors */
17 |
18 | $danger-color: $primary-color; // Red
19 | $success-color: #009896; // Turquoise
20 | $warning-color: #f39c12; // Yellow
21 | $info-color: $secondary-color; // Blue
22 |
23 | /* Helper colors */
24 |
25 | $link-color: $primary-color;
26 | $base-alpha-color: rgba($base-color, 0.10);
27 |
28 | $select-text-color: #36414D;
29 |
30 |
31 | /* Typography
32 | ========================================================================== */
33 |
34 | $root-font-size: 16px;
35 |
36 | $base-font-color: #505254;
37 | $base-font-family: "Lato", "Helvetica Neue", Helvetica, Arial, sans-serif;
38 | $base-font-style: normal;
39 | $base-font-light: 300;
40 | $base-font-regular: 400;
41 | $base-font-bold: 700;
42 | $base-font-weight: $base-font-light;
43 | $base-font-size: 1rem;
44 | $base-line-height: 1.5;
45 |
46 | $code-font-family: Menlo, Monaco, Consolas, "Courier New", monospace;
47 |
48 | $heading-font-family: "Lato", "Helvetica Neue", Helvetica, Arial, sans-serif;
49 | $heading-font-light: 300;
50 | $heading-font-regular: 500;
51 | $heading-font-bold: 700;
52 | $heading-font-weight: $heading-font-regular;
53 |
54 | $nav-font-color: #D73F3F;
55 |
56 |
57 | /* Decoration
58 | ========================================================================== */
59 |
60 | /* Border radius */
61 |
62 | $base-border-radius: 0.25rem;
63 | $full-border-radius: 100vh;
64 |
65 | $base-border-width: 1px;
66 |
67 |
68 | /* Sizing, spacing and media queries
69 | ========================================================================== */
70 |
71 | /* Sizing */
72 |
73 | $page-header-y: 0 !default;
74 |
75 | /* Spacing */
76 |
77 | $global-spacing: 1rem;
78 |
79 | /* Rows */
80 |
81 | $row-min-width: 320px;
82 | $row-max-width: 1280px;
83 | $jeet-max-width: $row-max-width; // reset jeet.gs max width
84 |
85 | /* Media queries */
86 |
87 | $xsmall-range: (0, 543px);
88 | $small-range: (544px, 767px);
89 | $medium-range: (768px, 991px);
90 | $large-range: (992px, 1300px);
91 | $xlarge-range: (1301px, 1600px);
92 | $xxlarge-range: (1601px, 3000px);
93 |
94 | $screen: "only screen";
95 |
--------------------------------------------------------------------------------
/src/state/AppState.js:
--------------------------------------------------------------------------------
1 | import { call, put, takeLatest } from 'redux-saga/effects';
2 | import Api from '../api';
3 |
4 | /* Actions */
5 | const COUNTRY_FETCH_FAILED = 'COUNTRY_FETCH_FAILED';
6 | const COUNTRY_FETCH_SUCCEEDED = 'COUNTRY_FETCH_SUCCEEDED';
7 | const BOUNDARY_SELECTED = 'BOUNDARY_SELECTED';
8 | const COUNTRIES_REQUESTED = 'COUNTRIES_REQUESTED';
9 |
10 | const STATS_FETCH_FAILED = 'STATS_FETCH_FAILED';
11 | const STATS_FETCH_SUCCEEDED = 'STATS_FETCH_SUCCEEDED';
12 | const STATS_REQUESTED = 'STATS_REQUESTED';
13 |
14 | export function countryFetchFailed (message) {
15 | console.error(COUNTRY_FETCH_FAILED, message);
16 | return { type: COUNTRY_FETCH_FAILED, message };
17 | }
18 |
19 | export function countryFetchSucceeded (countries) {
20 | return { type: COUNTRY_FETCH_SUCCEEDED, countries };
21 | }
22 |
23 | export function selectBoundary (boundary) {
24 | return { type: BOUNDARY_SELECTED, boundary }
25 | }
26 |
27 | export function requestCountries () {
28 | return { type: COUNTRIES_REQUESTED };
29 | }
30 |
31 | export function statsFetchFailed (message) {
32 | console.error(STATS_FETCH_FAILED, message);
33 | return { type: STATS_FETCH_FAILED, message };
34 | }
35 |
36 | export function statsFetchSucceeded (data) {
37 | return { type: STATS_FETCH_SUCCEEDED, data };
38 | }
39 |
40 | export function requestStats (countries) {
41 | return { type: STATS_REQUESTED, countries };
42 | }
43 |
44 |
45 | /* Saga */
46 | export function* fetchGeneralStats (data) {
47 | try {
48 | if (data.countries.length > 0) {
49 | var countries = data.countries.values();
50 | var entry;
51 | while (!(entry = countries.next()).done) {
52 | let data = yield call(Api.fetchGeneralCompletenessStats, entry.value);
53 | yield put(statsFetchSucceeded(data));
54 | }
55 | }
56 | } catch (e) {
57 | yield put(statsFetchFailed(e.message));
58 | }
59 | }
60 |
61 | export function* fetchCountries () {
62 | try {
63 | const countries = yield call(Api.fetchCountries);
64 | yield put(countryFetchSucceeded(countries));
65 | } catch (e) {
66 | yield put(countryFetchFailed(e.message));
67 | }
68 | }
69 |
70 | export function* appSaga () {
71 | yield takeLatest (COUNTRIES_REQUESTED, fetchCountries);
72 | yield takeLatest (STATS_REQUESTED, fetchGeneralStats);
73 | }
74 |
75 | /* initialState */
76 | const initialState = {
77 | countries: {},
78 | boundaries: [],
79 | generalStats: {}
80 | }
81 |
82 | /* Reducer */
83 | export default function AppState (state = initialState, action) {
84 | switch (action.type) {
85 | case COUNTRY_FETCH_SUCCEEDED:
86 | const countries = action.countries
87 | const features = Object.keys(countries).map(country => {
88 | const features = countries[country].features;
89 | /**
90 | * Add the country back into the properties of each boundary
91 | */
92 | features.forEach(feature => {
93 | feature.properties['country'] = country;
94 | return feature;
95 | });
96 | return features;
97 | });
98 | const boundaries = [].concat.apply([], features); // Flatten array of arrays
99 | return Object.assign({}, state, {
100 | countries,
101 | boundaries
102 | });
103 |
104 | case STATS_FETCH_SUCCEEDED:
105 | return Object.assign({}, state, {
106 | generalStats: Object.assign({}, state.generalStats, action.data),
107 | });
108 |
109 | default:
110 | break;
111 | }
112 |
113 | return state;
114 | }
115 |
--------------------------------------------------------------------------------
/src/components/HomeMap.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import mapboxgl from 'mapbox-gl';
3 | import { featureCollection } from '@turf/helpers';
4 | import { withRouter } from 'react-router';
5 | import bbox from '@turf/bbox';
6 |
7 | mapboxgl.accessToken = 'pk.eyJ1IjoiZGV2c2VlZCIsImEiOiJnUi1mbkVvIn0.018aLhX0Mb0tdtaT2QNe2Q';
8 |
9 | class HomeMap extends Component {
10 | constructor(props) {
11 | super(props);
12 |
13 | this.state = {
14 | layer: 'Default',
15 | switchImg: 'images/sat.jpg'
16 | }
17 | this.map = null;
18 | this.renderCountries = this.renderCountries.bind(this);
19 | }
20 |
21 | componentDidMount() {
22 | this.map = new mapboxgl.Map({
23 | container: this.mapContainer,
24 | style: `mapbox://styles/devseed/cjfvggcjha5ml2rmyy25i1vde`,
25 | zoom: 3
26 | });
27 |
28 | this.map.addControl(
29 | new mapboxgl.NavigationControl({showCompass: false}),
30 | 'bottom-right'
31 | );
32 | this.map.on('load', () => {
33 | this.renderCountries();
34 | });
35 | }
36 |
37 | componentWillUnmount() {
38 | this.map.remove();
39 | }
40 |
41 | switchBaseLayer() {
42 | if (this.state.layer === 'Default') {
43 | this.setState({layer: 'Satellite'});
44 | this.setState({switchImg: 'images/streets.png'});
45 | this.map.setStyle('mapbox://styles/mapbox/satellite-v9');
46 | } else {
47 | this.setState({layer: 'Default'});
48 | this.setState({switchImg: 'images/sat.jpg'});
49 | this.map.setStyle('mapbox://styles/devseed/cjfvggcjha5ml2rmyy25i1vde');
50 | }
51 | this.map.on('styledata', () => {
52 | this.renderCountries();
53 | });
54 | }
55 |
56 | renderCountries() {
57 | const { boundaries, history } = this.props;
58 |
59 | if (boundaries && boundaries.length > 0 && this.map) {
60 | const aois = featureCollection(boundaries);
61 |
62 | /**
63 | * This initialization is only called once
64 | * this is due to the fact that once we load the source
65 | * for boundaries, we check that it is already loaded and
66 | * skip it.
67 | * If we need to modify the boundaries dynamically this will
68 | * have to be re-written
69 | */
70 | if (!this.map.getSource('aois')) {
71 | this.map.addSource('aois', {
72 | 'type': 'geojson',
73 | 'data': aois
74 | });
75 |
76 | this.map.addLayer({
77 | 'id': 'aoi-fill',
78 | 'type': 'fill',
79 | 'source': 'aois',
80 | 'paint': {
81 | 'fill-color': '#FCC074',
82 | 'fill-opacity': 0.4
83 | }
84 | });
85 |
86 | this.map.on('click', 'aoi-fill', (e) => {
87 | const { country, id } = e.features[0].properties;
88 | history.push(`/${country}/${id}`);
89 | });
90 |
91 | this.map.on('mouseenter', 'aoi-fill', () => {
92 | this.map.getCanvas().style.cursor = 'pointer';
93 | });
94 |
95 | this.map.addLayer({
96 | 'id': 'aoi-line',
97 | 'type': 'line',
98 | 'source': 'aois',
99 | 'paint': {
100 | 'line-color': '#36414D',
101 | 'line-opacity': 1,
102 | 'line-width': 1,
103 | }
104 | });
105 | this.map.fitBounds(bbox(aois), { maxZoom: 6 });
106 | }
107 | }
108 | }
109 |
110 | render() {
111 | const style = {
112 | textAlign: 'left',
113 | height: '100%'
114 | };
115 |
116 | return this.mapContainer = el}>
117 |
this.switchBaseLayer()}>
118 |
119 |
120 |
121 |
122 |
View
123 |
{this.state.layer}
124 |
125 |
126 |
;
127 | }
128 | }
129 |
130 | export default withRouter(HomeMap);
131 |
--------------------------------------------------------------------------------
/src/styles/scss/core/_utilities.scss:
--------------------------------------------------------------------------------
1 | /* ==========================================================================
2 | Utilities
3 | ========================================================================== */
4 |
5 | /* Font smoothing
6 | ========================================================================== */
7 |
8 | /**
9 | * Antialiased font smoothing works best for light text on a dark background.
10 | * Apply to single elements instead of globally to body.
11 | * Note this only applies to webkit-based desktop browsers and Firefox 25 (and later) on the Mac.
12 | */
13 |
14 | .antialiased {
15 | -webkit-font-smoothing: antialiased;
16 | -moz-osx-font-smoothing: grayscale;
17 | }
18 |
19 |
20 | .inner {
21 | @extend .row, .row--centered;
22 | }
23 |
24 | /* Truncated text
25 | ========================================================================== */
26 |
27 | .truncated {
28 | white-space: nowrap;
29 | overflow: hidden;
30 | text-overflow: ellipsis;
31 | }
32 |
33 |
34 | /* Hidden content
35 | ========================================================================== */
36 |
37 | /* Hide from both screenreaders and browsers */
38 |
39 | .hidden {
40 | display: none !important;
41 | visibility: hidden;
42 | }
43 |
44 | /* Hide only visually, but have it available for screenreaders */
45 |
46 | .visually-hidden {
47 | border: 0 none;
48 | clip: rect(0px, 0px, 0px, 0px);
49 | height: 1px;
50 | margin: -1px;
51 | overflow: hidden;
52 | padding: 0;
53 | position: absolute;
54 | width: 1px;
55 | }
56 |
57 | /**
58 | * Extends the .visually-hidden class to allow the element
59 | * to be focusable when navigated to via the keyboard
60 | */
61 |
62 | .visually-hidden.focusable:active,
63 | .visually-hidden.focusable:focus {
64 | clip: auto;
65 | height: auto;
66 | margin: 0;
67 | overflow: visible;
68 | position: static;
69 | width: auto;
70 | }
71 |
72 | /* Undo visually-hidden */
73 |
74 | .visually-hidden-undo {
75 | position: inherit;
76 | overflow: visible;
77 | height: auto;
78 | width: auto;
79 | margin: auto;
80 | }
81 |
82 | /* Hide visually and from screenreaders, but maintain layout */
83 |
84 | .invisible {
85 | visibility: hidden;
86 | }
87 |
88 |
89 | /* Disabled
90 | ========================================================================== */
91 |
92 | .disabled {
93 | opacity: 0.48;
94 | pointer-events: none;
95 | cursor: not-allowed;
96 | }
97 |
98 | .visually-disabled {
99 | &,
100 | &:hover,
101 | &:active,
102 | &:focus {
103 | opacity: 0.48 !important;
104 | cursor: not-allowed;
105 | }
106 | }
107 |
108 |
109 | /* Scrollable
110 | ========================================================================== */
111 |
112 | .scrollable-x {
113 | position: relative;
114 | min-height: .01%;
115 | overflow-x: auto;
116 | overflow-y: hidden;
117 | width: 100%;
118 |
119 | > *:last-child {
120 | margin-bottom: 0;
121 | }
122 | }
123 |
124 |
125 | /* Unscrollable
126 | ========================================================================== */
127 |
128 | .unscrollable-y {
129 | overflow-y: hidden;
130 | }
131 |
132 | .unscrollable-x {
133 | overflow-x: hidden;
134 | }
135 |
136 |
137 | /* Round shapes
138 | ========================================================================== */
139 |
140 | .circle,
141 | .capsule {
142 | border-radius: 100vh;
143 | }
144 |
145 | .oval {
146 | border-radius: 100% / 50%;
147 | }
148 |
149 | .rounded {
150 | border-radius: $base-border-radius;
151 | }
152 |
153 |
154 | /* Aligning
155 | ========================================================================== */
156 |
157 | .float-left {
158 | float: left;
159 | }
160 |
161 | .float-right {
162 | float: right;
163 | }
164 |
165 | .text-left {
166 | align: left;
167 | }
168 |
169 | .text-right {
170 | align: right;
171 | }
172 |
173 | .text-center {
174 | align: center;
175 | }
176 |
177 | .block-center {
178 | display: block;
179 | margin-left: auto;
180 | margin-right: auto;
181 | }
182 |
183 |
184 | /* Clearfix
185 | ========================================================================== */
186 |
187 | .clearfix {
188 | &::before,
189 | &::after {
190 | content: " ";
191 | display: table;
192 | }
193 |
194 | &::after {
195 | clear: both;
196 | }
197 | }
198 |
199 | /* Colours
200 | ========================================================================== */
201 |
202 | .color-white {
203 | color: #fff !important;
204 | }
205 |
--------------------------------------------------------------------------------
/src/views/Home.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { connect } from 'react-redux';
3 | import HomeMap from '../components/HomeMap';
4 | import { AoiOption } from '../components/AoiOption';
5 | import PanelContainer from '../components/PanelContainer';
6 | import { requestStats } from '../state/AppState';
7 |
8 |
9 | class Home extends Component {
10 | constructor(props) {
11 | super(props);
12 | this.state = {
13 | panelOpen: true
14 | }
15 | this.props.getStats(this.props.countries);
16 | this.handleChange = this.handleChange.bind(this);
17 | }
18 |
19 | componentDidUpdate(prevProps) {
20 | if (this.props.countries.length !== prevProps.countries.length) {
21 | this.props.getStats(this.props.countries);
22 | }
23 | }
24 |
25 | handleChange(option) {
26 | if (option) {
27 | const urlFragment = option.value
28 | this.props.history.push(urlFragment);
29 | }
30 | }
31 |
32 | render() {
33 | const { boundaries, history, generalStats } = this.props;
34 |
35 | return (
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
HOT Analytics for Health
44 |
HOT Analytics for Health is focussed on helping measure progress and quality of map data of Malaria campaigns.
45 |
46 | The report focuses on attribute completeness, edit recency, map completeness relative to population, and data errors like duplicate buildings and invalid geometries.
47 |
48 |
49 |
50 |
51 |
52 |
53 |
Reports
54 |
55 |
56 | {boundaries.map((item, n) =>
57 |
58 | )}
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
Completeness
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
84 |
85 | {
86 | (this.state.mapZoom > 12) ?
87 |
88 |
OSM Edit Recency
89 |
90 |
91 | :
92 |
93 | }
94 |
95 |
96 |
97 | );
98 | }
99 | }
100 |
101 | const mapStateToProps = state => {
102 | return {
103 | boundaries: state.AppState.boundaries,
104 | countries: Object.keys(state.AppState.countries),
105 | generalStats: state.AppState.generalStats
106 | }
107 | }
108 |
109 | const mapDispatchToProps = dispatch => {
110 | return {
111 | getStats: (...args) => dispatch(requestStats(...args))
112 | }
113 | }
114 |
115 | export default connect(mapStateToProps, mapDispatchToProps)(Home);
116 |
--------------------------------------------------------------------------------
/src/styles/scss/core/_base.scss:
--------------------------------------------------------------------------------
1 | /* ==========================================================================
2 | Base
3 | ========================================================================== */
4 |
5 | /* Reset the box-sizing */
6 |
7 | html {
8 | box-sizing: border-box;
9 | }
10 |
11 | *,
12 | *::before,
13 | *::after {
14 | box-sizing: inherit;
15 | }
16 |
17 | /* Make viewport responsive on every browser */
18 |
19 | @at-root {
20 | @-moz-viewport { width: device-width; }
21 | @-ms-viewport { width: device-width; }
22 | @-o-viewport { width: device-width; }
23 | @-webkit-viewport { width: device-width; }
24 | @viewport { width: device-width; }
25 | }
26 |
27 | /* Reset HTML, body, etc */
28 |
29 | html {
30 | font-size: $root-font-size;
31 | /* Changes the default tap highlight to be completely transparent in iOS. */
32 | -webkit-tap-highlight-color: rgba(0, 0, 0, 0);
33 | }
34 |
35 | body {
36 | background: tint($base-color, 98%);
37 | color: $base-font-color;
38 | font-size: $base-font-size;
39 | line-height: $base-line-height;
40 | font-family: $base-font-family;
41 | font-weight: $base-font-weight;
42 | font-style: $base-font-style;
43 | min-width: $row-min-width;
44 | }
45 |
46 |
47 | /* Links
48 | ========================================================================== */
49 |
50 | a {
51 | cursor: pointer;
52 | color: $link-color;
53 | text-decoration: underline;
54 | transition: opacity 0.24s ease 0s;
55 | }
56 |
57 | a:visited {
58 | color: $link-color;
59 | }
60 |
61 | a:hover {
62 | opacity: 0.64;
63 | }
64 |
65 | a:active {
66 | outline: none;
67 | transform: translate(0, 1px);
68 | }
69 |
70 | a:focus {
71 | outline: none; // This causes usability problems. Needs fixing.
72 | }
73 |
74 | .link-wrapper {
75 | display: inline-block;
76 | vertical-align: top;
77 | }
78 |
79 | a.link--primary {
80 | background-image: linear-gradient(180deg,transparent 65%, $primary-color 0);
81 | color: $base-color;
82 | font-weight: $base-font-regular;
83 | padding-bottom: $global-spacing/4;
84 | background-size: 100% 140%;
85 | background-repeat: no-repeat;
86 | text-decoration: none;
87 | transition: background-size .4s ease;
88 | &:hover {
89 | opacity: 1;
90 | background-size: 100% 85%;
91 | };
92 | }
93 |
94 | a.link--secondary {
95 | color: $base-color;
96 | font-weight: 400;
97 | background-image: linear-gradient(180deg, transparent 65%, rgba($base-color, 0.1) 0);
98 | background-size: 100% 100%;
99 | background-repeat: no-repeat;
100 | text-decoration: none;
101 | transition: background-size .4s ease;
102 | &:hover {
103 | opacity: 1;
104 | background-size: 100% 140%;
105 | };
106 | }
107 |
108 | /* Rows
109 | ========================================================================== */
110 |
111 | .row {
112 | @extend .clearfix;
113 | padding-left: $global-spacing * 2;
114 | padding-right: $global-spacing * 2;
115 |
116 | &--centered {
117 | margin-left: auto;
118 | margin-right: auto;
119 | }
120 | }
121 |
122 | .row-fold {
123 | @extend .clearfix;
124 | padding-left: $global-spacing * 1.5;
125 | padding-right: $global-spacing * 1.5;
126 |
127 | @include media(xlarge-up) {
128 | padding-left: $global-spacing * 2.5;
129 | padding-right: $global-spacing * 2.5;
130 | }
131 |
132 | &--centered {
133 | max-width: $row-max-width;
134 | margin-left: auto;
135 | margin-right: auto;
136 | }
137 | }
138 |
139 |
140 | /* Buttons
141 | ========================================================================== */
142 |
143 | /**
144 | * iOS "clickable elements" fix for role="button":
145 | * https://developer.mozilla.org/en-US/docs/Web/Events/click#Safari_Mobile
146 | */
147 |
148 | [role="button"] {
149 | cursor: pointer;
150 | }
151 |
152 |
153 | /* Forms
154 | ========================================================================== */
155 |
156 | input, select, textarea {
157 | font: inherit;
158 | height: auto;
159 | width: auto;
160 | margin: 0;
161 | }
162 |
163 |
164 | /* Tables
165 | ========================================================================== */
166 |
167 | /**
168 | * Remove most spacing between table cells.
169 | */
170 |
171 | table {
172 | border-collapse: collapse;
173 | border-spacing: 0;
174 | }
175 |
176 |
177 | /* Misc
178 | ========================================================================== */
179 |
180 | /**
181 | * Make all browsers render the element correctly.
182 | */
183 |
184 | main {
185 | display: block;
186 | }
187 |
188 | /**
189 | * Style selection appearance (via ::selection pseudo-element).
190 | */
191 |
192 | ::selection {
193 | background-color: rgba($base-color, 0.64);
194 | color: #fff;
195 | }
196 |
--------------------------------------------------------------------------------
/src/registerServiceWorker.js:
--------------------------------------------------------------------------------
1 | // In production, we register a service worker to serve assets from local cache.
2 |
3 | // This lets the app load faster on subsequent visits in production, and gives
4 | // it offline capabilities. However, it also means that developers (and users)
5 | // will only see deployed updates on the "N+1" visit to a page, since previously
6 | // cached resources are updated in the background.
7 |
8 | // To learn more about the benefits of this model, read https://goo.gl/KwvDNy.
9 | // This link also includes instructions on opting out of this behavior.
10 |
11 | const isLocalhost = Boolean(
12 | window.location.hostname === 'localhost' ||
13 | // [::1] is the IPv6 localhost address.
14 | window.location.hostname === '[::1]' ||
15 | // 127.0.0.1/8 is considered localhost for IPv4.
16 | window.location.hostname.match(
17 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
18 | )
19 | );
20 |
21 | export default function register() {
22 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
23 | // The URL constructor is available in all browsers that support SW.
24 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location);
25 | if (publicUrl.origin !== window.location.origin) {
26 | // Our service worker won't work if PUBLIC_URL is on a different origin
27 | // from what our page is served on. This might happen if a CDN is used to
28 | // serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374
29 | return;
30 | }
31 |
32 | window.addEventListener('load', () => {
33 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
34 |
35 | if (isLocalhost) {
36 | // This is running on localhost. Lets check if a service worker still exists or not.
37 | checkValidServiceWorker(swUrl);
38 |
39 | // Add some additional logging to localhost, pointing developers to the
40 | // service worker/PWA documentation.
41 | navigator.serviceWorker.ready.then(() => {
42 | console.log(
43 | 'This web app is being served cache-first by a service ' +
44 | 'worker. To learn more, visit https://goo.gl/SC7cgQ'
45 | );
46 | });
47 | } else {
48 | // Is not local host. Just register service worker
49 | registerValidSW(swUrl);
50 | }
51 | });
52 | }
53 | }
54 |
55 | function registerValidSW(swUrl) {
56 | navigator.serviceWorker
57 | .register(swUrl)
58 | .then(registration => {
59 | registration.onupdatefound = () => {
60 | const installingWorker = registration.installing;
61 | installingWorker.onstatechange = () => {
62 | if (installingWorker.state === 'installed') {
63 | if (navigator.serviceWorker.controller) {
64 | // At this point, the old content will have been purged and
65 | // the fresh content will have been added to the cache.
66 | // It's the perfect time to display a "New content is
67 | // available; please refresh." message in your web app.
68 | console.log('New content is available; please refresh.');
69 | } else {
70 | // At this point, everything has been precached.
71 | // It's the perfect time to display a
72 | // "Content is cached for offline use." message.
73 | console.log('Content is cached for offline use.');
74 | }
75 | }
76 | };
77 | };
78 | })
79 | .catch(error => {
80 | console.error('Error during service worker registration:', error);
81 | });
82 | }
83 |
84 | function checkValidServiceWorker(swUrl) {
85 | // Check if the service worker can be found. If it can't reload the page.
86 | fetch(swUrl)
87 | .then(response => {
88 | // Ensure service worker exists, and that we really are getting a JS file.
89 | if (
90 | response.status === 404 ||
91 | response.headers.get('content-type').indexOf('javascript') === -1
92 | ) {
93 | // No service worker found. Probably a different app. Reload the page.
94 | navigator.serviceWorker.ready.then(registration => {
95 | registration.unregister().then(() => {
96 | window.location.reload();
97 | });
98 | });
99 | } else {
100 | // Service worker found. Proceed as normal.
101 | registerValidSW(swUrl);
102 | }
103 | })
104 | .catch(() => {
105 | console.log(
106 | 'No internet connection found. App is running in offline mode.'
107 | );
108 | });
109 | }
110 |
111 | export function unregister() {
112 | if ('serviceWorker' in navigator) {
113 | navigator.serviceWorker.ready.then(registration => {
114 | registration.unregister();
115 | });
116 | }
117 | }
118 |
--------------------------------------------------------------------------------
/src/views/About.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 |
3 | class About extends Component {
4 | render() {
5 | return (
6 |
7 |
8 |
9 |
HOT Analytics for Health
10 |
11 |
12 |
13 |
14 |
15 |
HOT Analytics for Health is one of the OSM Analytics projects focussed on data quality and mapping progress for Malaria campaigns. HOT Analytics for Health uses OSM QA Tiles and Worldpop for analysis in the following areas:
16 |
17 |
Malaria Mapping
18 |
19 |
With support of the Bill and Melinda Gates Foundation and the Clinton Health Access Initiative, we have designed an analysis tool to evaluate the accuracy and precision of OpenStreetMap field data. This credential scoring of OpenStreetMap feature completeness provides a more complete understanding of OpenStreetMap data in a specific area.
20 |
This work features OpenStreetMap analysis for improved malaria prevention but can be applied to any use case to understand OSM data. Check out our github documentation to learn more. Key features are highlighted below.
21 |
22 |
23 |
1. Attribute Completeness
24 |
25 |
26 |
For Malaria campaigns, it's important to identify residential structures on the map with use of the building, roof and wall type. The analysis uses OSMLint to find `buildings=residential` that does not have `roof=*` or `wall=*` attributes.
27 |
These numbers directly inform how completely we can identify a building on OSM. If roof or wall attributes are missing, they can be inferred from satellite imagery or a field mission.
28 |
29 |
30 |
2. Relative Completeness
31 |
32 |
33 |
The most effective Malaria campaign requires all buildings to be mapped in an area. We use Worldpop to predict area of buildings based on a distribution of population per pixel. The machine learning model is trained using rural and urban areas that are identified with good quality residential buildings in OSM. For more information on the model see the repository . The model helps to arrive at the following conclusions:
34 |
35 | OSM coverage is poor, however population density is present
36 | OSM coverage is fair, about what population density would imply
37 | OSM coverage is good, better than population density would imply
38 |
39 |
While WorldPop is last updated in 2015 from census, and potentially outdated in many parts of the world it still presents a relatively good way of understanding populated areas on the map. The model makes assumptions on quality of distribution of population, and the lack of granularity in Worldpop implies that we can only generate meaningful analyses over large aggregate areas, and not at the pixel level.
40 |
41 |
42 |
3. Data Errors
43 |
44 |
Duplicate buildings are also highlighted as part of the report. A higher number of duplicate buildings will lead to wrong estimation of resources.
45 |
46 |
47 |
48 |
56 |
57 | )
58 | }
59 | }
60 |
61 | export default About;
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # OSM Analytics Health Module
2 |
3 | [](https://circleci.com/gh/hotosm/osma-health/tree/master)
4 |
5 | osma-health is an independent extension of OSM Analytics for health campaigns run by HOT. The current phase is to understand the progress and completeness of Malaria campaigns.
6 |
7 | This repository will be the main ticket tracker, as well as the front-end code. Other repos are:
8 |
9 | * [osma-health-infra](https://github.com/hotosm/osma-health-infra) - private infrastructure code.
10 | * [osma-health-workers](https://github.com/hotosm/osma-health-workers) - workers to prepare data for analysis.
11 | * [hotosm-population](https://github.com/azavea/hot-osm-population) - WorldPop based OSM data completeness estimates.
12 | * [osmlint](https://github.com/hotosm/osmlint) - fork of OSMLint with validators specific for this project.
13 |
14 | osma-health is being built by [Development Seed](https://developmentseed.org/).
15 |
16 | ## Setup
17 | The front-end is hosted on S3 bucket called `osma-health`. To deploy, merge a PR to master and CircleCI will automatically push a dist to S3.
18 |
19 | ## Background
20 |
21 | OSM Analytics for Health aims to help field-based, academic and governmental organizations to improve their prevention strategies by tracking where the map is incomplete. `hotosm/osma-health` is a web application developed by HOT and Development Seed to assess the quality and accuracy of OpenStreetMap data.
22 |
23 | By combining Worldpop and completely mapped areas in OSM, we can train a model to estimate gaps in building density. We overlay this with other metrics to provide a report of coverage area.
24 |
25 | The purpose of this document is to outline the metrics required by the application and the underlying infrastructure that produces them. At a high level our approach involves a periodic generation of vector spatial datasets from primary sources.
26 |
27 | ## Infrastructure Requirements
28 | - Periodic updating of metrics
29 | - Use of AWS technologies
30 | - Static frontend with minimal API infrastructure
31 | ## Data requirements
32 |
33 | **Data sources**
34 | Sources of data that will be used to generate the metrics and the map layers
35 |
36 |
37 | - [**WorldPop**](http://www.worldpop.org.uk/): a raster spatial layer of population counts per 100x100m pixel
38 | - [**OpenStreetMap QA Tiles**](http://osmlab.github.io/osm-qa-tiles): a vector tile dataset containing all OSM data
39 | - **Areas of Interest**: A list of vector geometries for each area where the report is generated
40 |
41 | **Derived metrics**
42 | Metrics displayed alongside an area of interest
43 |
44 |
45 | - **Overall quality indicator**: A qualitative measure of the completeness of the area of interest.
46 | - **Last time of update:** When was the report last generated?
47 | - **Estimated population**: Population in the area of interest
48 | - **Relative completeness**: A quantitative measure of completeness based on Worldpop and building density
49 | - **Attribute completeness**: A measure of what percentage of missing tags such as ‘residential building’ in OSM building data
50 | - **Recency of edits**: A histogram of how fresh the data in the area of interest is
51 | - **Number of duplicate buildings**: The number of buildings that were mapped multiple times
52 | - **Logical consistency errors**: The number of features that are misaligned or overlapping illogically with other features
53 |
54 | **Map layers**
55 |
56 | - **Area of interest geometry**: A bounding perimeter around the report area
57 | - **Recency layer**: A spatial gradient layer that displays recency of data
58 | - **Completeness layer**: A spatial category layer that displays relative completeness
59 | ## Implementation Overview
60 |
61 | Our approach will be two-fold, using a one-time job to generate the “relative completeness” metric and associated tile layer, alongside periodic jobs to generate the other metrics. The output of these jobs is spatial vector data stored in AWS S3, either in GeoJSON or Mapbox Vector Tile format.
62 |
63 |
64 | 
65 |
66 |
67 | **One-time ML job**
68 | Azavea is leading the task of building a relative completess metric for a given area of interest. Given WorldPop and the OSM QA tiles, a machine learning training process will generate a model that can fit population counts to OSM building coverage. It will then output geojson for each tile at zoom 12. These tiles will contain:
69 |
70 |
71 | - Estimated population
72 | - Actual OSM building coverage
73 | - Expected building coverage
74 | - A ratio of projected population to worldpop estimate
75 |
76 | The last ratio is the measure of relative completeness. In perfectly mapped areas, it tends to 1, and in poor coverage areas it is less than 1. This 0 to 1 scale can be used for a heatmap layer.
77 |
78 | **Periodic batch jobs**
79 | For the other metrics, Development Seed is building an AWS Batch pipeline that takes in WorldPop and the OSM QA tiles and generates vector data. The AWS Batch pipeline is triggered weekly using a scheduled AWS Lambda function. At that time, a job will be scheduled for each country that covers the areas of interest. The underlying cluster for the jobs are spot instances that scale up to meet the demands of the batch then terminate at the end of all jobs in that batch.
80 |
81 | The batch jobs will each trigger a series of [OSM Lint](https://github.com/osmlab/osmlint) and aggregation tasks. The HOT organization has forked the osmlint repository to add additional tasks that suit `osma-health`'s purpose.
82 |
--------------------------------------------------------------------------------
/src/components/ReportMap.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import mapboxgl from 'mapbox-gl';
3 | import centerOfMass from '@turf/center-of-mass';
4 | import * as d3Scale from 'd3-scale';
5 | import * as d3Chromatic from 'd3-scale-chromatic';
6 |
7 | mapboxgl.accessToken = 'pk.eyJ1IjoiZGV2c2VlZCIsImEiOiJnUi1mbkVvIn0.018aLhX0Mb0tdtaT2QNe2Q';
8 |
9 | export default class ReportMap extends Component {
10 | constructor(props) {
11 | super(props);
12 | this.state = {
13 | layer: 'Default',
14 | switchImg: 'images/sat.jpg'
15 | }
16 | this.map = null;
17 | }
18 | componentDidMount() {
19 | const center = centerOfMass(this.props.aoi).geometry.coordinates;
20 |
21 | this.map = new mapboxgl.Map({
22 | container: this.mapContainer,
23 | style: 'mapbox://styles/devseed/cjfvggcjha5ml2rmyy25i1vde',
24 | zoom: 9,
25 | center
26 | });
27 | this.map.addControl(
28 | new mapboxgl.NavigationControl({showCompass: false}),
29 | 'bottom-right'
30 | );
31 |
32 | this.map.on('load', () => {
33 | this.addDataToMap();
34 | this.map.on('zoomend', () => {
35 | const z = this.map.getZoom();
36 | this.props.onZoom(z);
37 | });
38 | });
39 | }
40 |
41 | componentWillUnmount(){
42 | this.map.remove();
43 | }
44 |
45 | addDataToMap() {
46 | let scale = d3Scale.scaleQuantile()
47 | .domain(this.props.domain)
48 | .range(d3Chromatic.schemeRdYlGn[9])
49 |
50 | const stops = scale.quantiles().map( value => {
51 | return [value, scale(value)]
52 | });
53 | const aoi = this.props.aoi;
54 | if (aoi && this.props.domain && this.map) {
55 | // prepare a quantile scale with the domain of predictions.
56 | if (!this.map.getSource('aoi')){
57 | this.map.addSource('aoi', {
58 | 'type': 'geojson',
59 | 'data': aoi
60 | });
61 | }
62 |
63 | if (!this.map.getSource('buildings-osm')){
64 | this.map.addSource('buildings-osm', {
65 | type: 'vector',
66 | url: 'mapbox://devseed.9lcaji8y'
67 | });
68 | }
69 |
70 | if (!this.map.getSource('completeness')){
71 | this.map.addSource('completeness', {
72 | type: 'vector',
73 | url: `mapbox://hot.${this.props.country}-completeness`
74 | })
75 | }
76 |
77 | if (!this.map.getLayer('completeness')) {
78 | this.map.addLayer({
79 | 'id': 'completeness',
80 | type: 'fill',
81 | source: 'completeness',
82 | 'source-layer': 'completeness',
83 | paint: {
84 | 'fill-color': {
85 | 'property': 'index',
86 | 'stops': stops
87 | },
88 | 'fill-opacity': {
89 | "stops": [
90 | [1, 0.5],
91 | [12, 0.3],
92 | [14, 0.05]
93 | ]
94 | }
95 | },
96 | "filter": [">", "index", -1]
97 | })
98 | }
99 |
100 | if (!this.map.getLayer('aoi-line')) {
101 | this.map.addLayer({
102 | 'id': 'aoi-line',
103 | 'type': 'line',
104 | 'source': 'aoi',
105 | 'paint': {
106 | 'line-color': '#36414D',
107 | 'line-opacity': 1,
108 | 'line-width': 2,
109 | }
110 | });
111 | }
112 |
113 | if (!this.map.getLayer('buildings-osm')) {
114 | this.map.addLayer({
115 | 'id': 'buildings-osm',
116 | type: 'fill',
117 | source: 'buildings-osm',
118 | 'source-layer': 'osm',
119 | paint: {
120 | 'fill-color': [
121 | 'interpolate',
122 | ['linear'],
123 | ['number', ['get', '@timestamp']],
124 | Math.floor(new Date('2016-1-01')/1000), 'rgba(54, 66, 77, 0.1)',
125 | Math.floor(new Date()/1000), 'rgba(54, 66, 77, 1)',
126 | ],
127 | 'fill-outline-color': 'rgba(255, 255, 255, 0.1)'
128 | },
129 | });
130 | }
131 | }
132 | }
133 |
134 | switchBaseLayer() {
135 | if (this.state.layer === 'Default') {
136 | this.setState({layer: 'Satellite'});
137 | this.setState({switchImg: 'images/streets.png'});
138 | this.map.setStyle('mapbox://styles/mapbox/satellite-v9');
139 | } else {
140 | this.setState({layer: 'Default'});
141 | this.setState({switchImg: 'images/sat.jpg'});
142 | this.map.setStyle('mapbox://styles/devseed/cjfvggcjha5ml2rmyy25i1vde');
143 | }
144 | this.map.on('styledata', () => {
145 | this.addDataToMap();
146 | });
147 | }
148 |
149 | render () {
150 | const style = {
151 | textAlign: 'left',
152 | height: '100%'
153 | };
154 |
155 | return this.mapContainer = el}>
156 |
this.switchBaseLayer()}>
157 |
158 |
159 |
160 |
161 |
View
162 |
{this.state.layer}
163 |
164 |
165 |
;
166 | }
167 | }
168 |
--------------------------------------------------------------------------------
/src/styles/scss/settings/_mixins.scss:
--------------------------------------------------------------------------------
1 | /* ==========================================================================
2 | Media queries
3 | ========================================================================== */
4 |
5 | @mixin media($arg) {
6 | @if $arg == screen {
7 | @media #{$screen} { @content; }
8 | }
9 | @if $arg == landscape {
10 | @media #{$screen} and (orientation: landscape) { @content; }
11 | }
12 | @if $arg == portrait {
13 | @media #{$screen} and (orientation: portrait) { @content; }
14 | }
15 | @if $arg == xsmall-up {
16 | @media #{$screen} and (min-width: lower-bound($xsmall-range)) { @content; }
17 | }
18 | @if $arg == xsmall-only {
19 | @media #{$screen} and (max-width: upper-bound($xsmall-range)) { @content; }
20 | }
21 | @if $arg == small-up {
22 | @media #{$screen} and (min-width: lower-bound($small-range)) { @content; }
23 | }
24 | @if $arg == small-only {
25 | @media #{$screen} and (min-width: lower-bound($small-range)) and (max-width: upper-bound($small-range)) { @content; }
26 | }
27 | @if $arg == small-down {
28 | @media #{$screen} and (max-width: upper-bound($small-range)) { @content; }
29 | }
30 | @if $arg == medium-up {
31 | @media #{$screen} and (min-width: lower-bound($medium-range)) { @content; }
32 | }
33 | @if $arg == medium-down {
34 | @media #{$screen} and (max-width: upper-bound($medium-range)) { @content; }
35 | }
36 | @if $arg == medium-only {
37 | @media #{$screen} and (min-width: lower-bound($medium-range)) and (max-width: upper-bound($medium-range)) { @content; }
38 | }
39 | @if $arg == large-up {
40 | @media #{$screen} and (min-width: lower-bound($large-range)) { @content; }
41 | }
42 | @if $arg == large-only {
43 | @media #{$screen} and (min-width: lower-bound($large-range)) and (max-width: upper-bound($large-range)) { @content; }
44 | }
45 | @if $arg == xlarge-up {
46 | @media #{$screen} and (min-width: lower-bound($xlarge-range)) { @content; }
47 | }
48 | @if $arg == xlarge-only {
49 | @media #{$screen} and (min-width: lower-bound($xlarge-range)) and (max-width: upper-bound($xlarge-range)) { @content; }
50 | }
51 | @if $arg == xxlarge-up {
52 | @media #{$screen} and (min-width: lower-bound($xxlarge-range)) { @content; }
53 | }
54 | }
55 |
56 |
57 | /* ==========================================================================
58 | Typography
59 | ========================================================================== */
60 |
61 | @mixin heading($font-size, $max-media: small-up) {
62 | font-size: $font-size;
63 | line-height: $font-size + 0.5;
64 |
65 | @if $max-media == medium-up or $max-media == large-up or $max-media == xlarge-up {
66 | @include media(medium-up) {
67 | $font-size: $font-size + 0.25;
68 | font-size: $font-size;
69 | line-height: $font-size + 0.5;
70 | }
71 | }
72 |
73 | @if $max-media == large-up or $max-media == xlarge-up {
74 | @include media(large-up) {
75 | $font-size: $font-size + 0.25;
76 | font-size: $font-size;
77 | line-height: $font-size + 0.5;
78 | }
79 | }
80 |
81 | @if $max-media == xlarge-up {
82 | @include media(xlarge-up) {
83 | $font-size: $font-size + 0.25;
84 | font-size: $font-size;
85 | line-height: $font-size + 0.5;
86 | }
87 | }
88 | }
89 |
90 |
91 | /* ==========================================================================
92 | Buttons
93 | ========================================================================== */
94 |
95 | @mixin button-variation($color, $style, $brightness) {
96 | $text-color: null;
97 | $bg-color: null;
98 | $bg-color-hover: null;
99 | $bg-color-active: null;
100 |
101 | @if $style == "filled" {
102 |
103 | @if $brightness == "light" {
104 | $text-color: $color;
105 | $bg-color: tint($color, 90%);
106 | $bg-color-hover: tint($color, 100%);
107 | $bg-color-active: tint($color, 92%);
108 | }
109 |
110 | @else if $brightness == "dark" {
111 | $text-color: #FFF;
112 | $bg-color: $color;
113 | $bg-color-hover: shade($color, 8%);
114 | $bg-color-active: shade($color, 12%);
115 | }
116 |
117 | @else {
118 | @error "Invalid brightness property for button raised.";
119 | }
120 | }
121 |
122 | @else if $style == "glass" {
123 | $text-color: $color;
124 | $bg-color: rgba($base-color, 0.08);
125 | $bg-color-hover: rgba($color, 0.16);
126 | $bg-color-active: rgba($color, 0.24);
127 |
128 | /**
129 | * $shadow-color isn't used here to color projected shadows.
130 | * For a border-like effect, $color is applied directly in the box-shadow property.
131 | */
132 | }
133 |
134 | @else if $style == "bounded" {
135 | $text-color: $color;
136 | $bg-color: rgba($color, 0);
137 | $bg-color-hover: rgba($color, 0.16);
138 | $bg-color-active: rgba($color, 0.24);
139 | box-shadow: inset 0 0 0 ($base-border-width * 2) $color;
140 | }
141 |
142 | @else if $style == "plain" {
143 | $text-color: $color;
144 | $bg-color: rgba($color, 0);
145 | $bg-color-hover: rgba($color, 0.16);
146 | $bg-color-active: rgba($color, 0.24);
147 | }
148 |
149 | @else {
150 | @error "Invalid style property for button.";
151 | }
152 |
153 | background-color: $bg-color;
154 |
155 | &, &:visited {
156 | color: $text-color;
157 | }
158 |
159 | &.button--hover,
160 | &:hover {
161 | background-color: $bg-color-hover;
162 | }
163 |
164 | .drop--open > &,
165 | &.button--active,
166 | &.button--active:hover,
167 | &:active {
168 | background-color: $bg-color-active;
169 | }
170 | }
--------------------------------------------------------------------------------
/src/styles/scss/global/_buttons.scss:
--------------------------------------------------------------------------------
1 | /* ==========================================================================
2 | Buttons
3 | ========================================================================== */
4 |
5 | .button {
6 | @extend .antialiased;
7 | user-select: none;
8 | display: inline-block;
9 | text-align: center;
10 | white-space: nowrap;
11 | vertical-align: middle;
12 | line-height: 1.5rem;
13 | font-size: 1rem;
14 | padding: 0.25rem 0.75rem;
15 | min-width: 2rem;
16 | background: none;
17 | text-shadow: none;
18 | border: 0;
19 | border-radius: $base-border-radius;
20 | font-weight: $base-font-regular;
21 | cursor: pointer;
22 |
23 | /* States */
24 |
25 | &:hover {
26 | opacity: initial;
27 | }
28 |
29 | .drop--open > &,
30 | &.button--active,
31 | &:active {
32 | z-index: 2;
33 | transform: none;
34 | }
35 |
36 | &, &:focus {
37 | outline: none; // This causes usability problems. Needs fixing.
38 | }
39 |
40 | /* Icon handling */
41 |
42 | /* &::before,
43 | &::after,
44 | [class^="collecticon-"],
45 | [class*=" collecticon-"] {
46 | font-size: 1rem;
47 | }
48 | */
49 | &::before { margin-right: 0.375rem; }
50 | &::after { margin-left: 0.375rem; }
51 |
52 | &::before,
53 | &::after,
54 | > * {
55 | vertical-align: top;
56 | display: inline-block;
57 | line-height: inherit !important;
58 | }
59 |
60 | /* Checkbox/radio handling */
61 |
62 | > input[type=checkbox],
63 | > input[type=radio] {
64 | @extend .visually-hidden;
65 | }
66 |
67 | /* Animation */
68 |
69 | transition: background-color 0.24s ease 0s;
70 | }
71 |
72 |
73 | /* Button color modifiers
74 | ========================================================================== */
75 |
76 | .button--primary-filled {
77 | @include button-variation($primary-color, "filled", "dark");
78 | &:hover {
79 | background-color: lighten($primary-color, 10%);
80 | };
81 | }
82 |
83 | .button--tertiary-filled {
84 | @include button-variation($tertiary-color, "filled", "dark");
85 | &:hover {
86 | background-color: lighten($tertiary-color, 10%);
87 | };
88 | }
89 |
90 | .button--primary-bounded {
91 | @include button-variation($primary-color, "bounded", null);
92 | }
93 |
94 | .button--base-bounded {
95 | @include button-variation($base-color, "bounded", null);
96 | }
97 |
98 | .button--secondary-filled {
99 | @include button-variation($secondary-color, "filled", "dark");
100 | }
101 |
102 | .button--secondary-bounded {
103 | @include button-variation($secondary-color, "bounded", null);
104 | }
105 |
106 | .button--secondary-light {
107 | @include button-variation($secondary-color, "filled", "light");
108 | }
109 | /* Primary Plain */
110 |
111 | .button--base-plain {
112 | @include button-variation($base-color, "plain", null);
113 | }
114 |
115 | .button--primary-plain {
116 | @include button-variation($primary-color, "plain", null);
117 | }
118 |
119 | .button--secondary-plain {
120 | @include button-variation($secondary-color, "plain", null);
121 | }
122 |
123 |
124 | /* Achromic Glass (white ghost-like) */
125 |
126 | .button--base-glass {
127 | @include button-variation($base-color, "glass", null);
128 | }
129 |
130 | .button--primary-glass {
131 | @include button-variation($primary-color, "glass", null);
132 | }
133 |
134 | .button--secondary-glass {
135 | @include button-variation($secondary-color, "glass", null);
136 | }
137 |
138 | /* Button size modifiers
139 | ========================================================================== */
140 |
141 | /* Small (24px) */
142 |
143 | .button--small,
144 | .button-group--small .button {
145 | line-height: 1.25rem;
146 | font-size: 0.875rem;
147 | padding: 0.75rem 0.75rem;
148 | min-width: 2rem;
149 | }
150 |
151 | /* Medium (32px)
152 | Default
153 | */
154 |
155 | .button--medium,
156 | .button-group--medium .button {
157 | line-height: 1.5rem;
158 | font-size: 1rem;
159 | padding: 0.5rem 1.5rem;
160 | min-width: 2.5rem;
161 | }
162 |
163 | /* Large (40px) */
164 |
165 | .button--large,
166 | .button-group--large .button {
167 | line-height: 1.5rem;
168 | font-size: 1rem;
169 | padding: 0.5rem 2rem;
170 | min-width: 3rem;
171 | }
172 |
173 | /* XLarge (48px) */
174 |
175 | .button--xlarge,
176 | .button-group--xlarge .button {
177 | line-height: 2rem;
178 | font-size: 1rem;
179 | padding: 0.5rem 2rem;
180 | min-width: 3rem;
181 | }
182 |
183 |
184 | /* Button contaning icons & icons modifiers
185 | ========================================================================== */
186 |
187 | /*.button--text-hidden {
188 | &::before,
189 | &::after {
190 | margin: 0;
191 | }
192 | > *:not([class^="collecticon-"]):not([class*=" collecticon-"]) {
193 | @extend .visually-hidden;
194 | }
195 | /* :not(.button-group) & {
196 | padding-left: 0;
197 | padding-right: 0;
198 | }*/
199 |
200 |
201 | /* Button misc modifiers
202 | ========================================================================== */
203 |
204 | .button--block {
205 | display: block;
206 | width: 100%;
207 | }
208 |
209 | .button--semi-fluid {
210 | min-width: 16rem;
211 | }
212 |
213 | .button--capsule {
214 | border-radius: $full-border-radius;
215 | }
216 |
217 | .button--close {
218 | &:before {
219 | font-size: 1rem;
220 | line-height: inherit;
221 | vertical-align: top;
222 | margin-left: 0.25rem;
223 | }
224 | }
225 |
226 | .button--slide-open {
227 | position: relative;
228 | padding: 2rem 0;
229 | &:before {
230 | content: url(../../graphics/icons/chevron-right.svg);
231 | position: absolute;
232 | height: 1rem;
233 | width: 1rem;
234 | margin-left: 0.25rem;
235 | margin-top: 0.25rem;
236 | height: 1rem;
237 | width: 1rem;
238 | left: 0;
239 | top: 1rem;
240 | }
241 | }
242 |
243 | .button--slide-close {
244 | position: relative;
245 | padding: 2rem 0;
246 | &:before {
247 | content: url(../../graphics/icons/chevron-left.svg);
248 | position: absolute;
249 | height: 1rem;
250 | width: 1rem;
251 | margin-left: 0.25rem;
252 | margin-top: 0.25rem;
253 | height: 1rem;
254 | width: 1rem;
255 | left: 0;
256 | top: 1rem;
257 | }
258 | }
259 |
260 | .button--info {
261 | padding-bottom: 0;
262 | float: right;
263 | margin-top: 0.35rem;
264 | height: 1rem;
265 | width: 1rem;
266 | .info-text {
267 | visibility: hidden;
268 | background-color: #FFF;
269 | color: $primary-color;
270 | font-size: 0.75rem;
271 | font-weight: $base-font-light;
272 | font-style: italic;
273 | position: absolute;
274 | border-radius: $global-spacing/4;
275 | z-index: 5;
276 | padding: ($global-spacing/4) ($global-spacing/2);
277 | margin-left: $global-spacing/4;
278 | span {
279 | width: 50%;
280 | word-break: break-all;
281 | }
282 | }
283 | }
284 |
285 | .button--info:hover .info-text {
286 | visibility: visible;
287 | }
288 |
289 | /* ==========================================================================
290 | Button groups
291 | ========================================================================== */
292 |
293 | .button-group {
294 | position: relative;
295 | display: inline-block;
296 | vertical-align: middle;
297 |
298 | > .button {
299 | display: block;
300 | position: relative;
301 | margin: 0;
302 | z-index: 2;
303 | }
304 | }
305 |
306 | /* Horizontal */
307 |
308 | .button-group--horizontal {
309 | > .button {
310 | float: left;
311 | }
312 |
313 | > .button:first-child:not(:last-child) {
314 | border-top-right-radius: 0;
315 | border-bottom-right-radius: 0;
316 | clip-path: inset(-100% 0 -100% -100%);
317 | }
318 |
319 | > .button:last-child:not(:first-child) {
320 | border-top-left-radius: 0;
321 | border-bottom-left-radius: 0;
322 | clip-path: inset(-100% -100% -100% 0);
323 | }
324 |
325 | > .button:not(:first-child):not(:last-child) {
326 | border-radius: 0;
327 | clip-path: inset(-100% 0);
328 | }
329 |
330 | > .button + .button {
331 | margin-left: -$base-border-width;
332 | }
333 | }
334 |
335 | /* Vertical */
336 |
337 | .button-group--vertical {
338 | > .button {
339 | clear: both;
340 | width: 100%;
341 | border-radius: $base-border-radius;
342 | }
343 |
344 | > .button:first-child:not(:last-child) {
345 | border-bottom-right-radius: 0;
346 | border-bottom-left-radius: 0;
347 | clip-path: inset(-100% -100% 0 -100%);
348 | }
349 |
350 | > .button:last-child:not(:first-child) {
351 | border-top-left-radius: 0;
352 | border-top-right-radius: 0;
353 | clip-path: inset(0 -100% -100% -100%);
354 | }
355 |
356 | > .button:not(:first-child):not(:last-child) {
357 | border-radius: 0;
358 | clip-path: inset(0 -100%);
359 | }
360 |
361 | > .button + .button {
362 | margin-top: -$base-border-width;
363 | }
364 | }
365 |
366 | .link-back {
367 | text-decoration: none;
368 | &:before {
369 | content: url(../../graphics/icons/chevron-left.svg);
370 | height: 1rem;
371 | width: 1rem;
372 | margin-top: 0.1rem;
373 | height: 1rem;
374 | width: 1rem;
375 | left: 0;
376 | top: 1rem;
377 | }
378 | }
379 |
380 | .report-link {
381 | font-size: 0.875rem;
382 | padding-top: 0.75rem;
383 | }
384 |
--------------------------------------------------------------------------------
/src/styles/scss/map/_global.scss:
--------------------------------------------------------------------------------
1 | .page__body {
2 | @include column(12/12);
3 | }
4 |
5 | .map {
6 | position: relative;
7 | @include column(12/12);
8 | background-color: rgba($base-color, 0.2);
9 | height: calc(100vh - 62px);
10 | }
11 |
12 | .map__actions {
13 | position: absolute;
14 | right: 1rem;
15 | margin-top: $global-spacing;
16 | li {
17 | display: inline-block;
18 | margin-right: $global-spacing/2;
19 | a {
20 | text-decoration: none;
21 | }
22 | &:last-child {
23 | margin-left: 0;
24 | };
25 | }
26 | }
27 |
28 | .map__layer-switch {
29 | background-color: $background-color;
30 | color: $primary-color;
31 | border-radius: 3px;
32 | position: absolute;
33 | bottom: $global-spacing * 1.75;
34 | right: $global-spacing * 3.3;
35 | padding: 0.7rem 0.6rem 0.6rem 0.65rem;
36 | font-size: 0.75rem;
37 | z-index: 4;
38 | cursor: pointer;
39 | }
40 |
41 | .map__layer-switch-img {
42 | float: left;
43 | display: flex;
44 | }
45 |
46 | .map__layer-switch-info {
47 | float: right;
48 | align-items: center;
49 | margin-left: 0.5rem;
50 | min-width: 3.8rem;
51 | }
52 |
53 | .map__layer-switch-info p {
54 | padding-top: 1.1rem;
55 | display: block;
56 | color: $secondary-color-light;
57 | line-height: 0.5rem;
58 | }
59 |
60 | .map__layer-switch-info h4 {
61 | display: block;
62 | text-align: left;
63 | position: relative;
64 | color: $base-color;
65 | font-weight: 600;
66 | }
67 |
68 | .map__legend {
69 | background-color: $background-color;
70 | color: $primary-color;
71 | border-radius: 3px;
72 | position: absolute;
73 | bottom: $global-spacing * 1.75;
74 | right: $global-spacing * 13.3;
75 | padding: 0.6rem 0.75rem;
76 | font-size: 0.75rem;
77 | width: 13rem;
78 | }
79 |
80 | .legend-label {
81 | color: $base-color;
82 | position: relative;
83 | margin-bottom: 0.15rem;
84 | font-weight: $base-font-bold;
85 | }
86 |
87 | .legend-sub {
88 | position: relative;
89 | font-weight: $base-font-regular;
90 | font-style: italic;
91 | font-size: 0.75rem;
92 | margin-bottom: 0.15rem;
93 | }
94 |
95 | .legend-bar {
96 | height: 0.75rem;
97 | position: relative;
98 | width: 100%;
99 | }
100 |
101 | .legend-bar-osm {
102 | background: linear-gradient(to right, rgba(54, 65, 77, 0.3), rgba(54, 65, 77, 1));
103 | &:before {
104 | content:'2012';
105 | position: absolute;
106 | left: 0;
107 | bottom: -1.25rem;
108 | font-size: 0.675rem;
109 | font-weight: $base-font-regular;
110 | };
111 | &:after {
112 | content:'2018';
113 | position: absolute;
114 | right: 0;
115 | bottom: -1.25rem;
116 | font-size: 0.675rem;
117 | font-weight: $base-font-regular;
118 | };
119 | }
120 |
121 | .report__panel-container {
122 | position: absolute;
123 | @include column(6/12);
124 | top: 14px;
125 | transition: left 0.5s ease-in-out;
126 | }
127 |
128 | .report__panel-container--closed {
129 | left: -46%;
130 | }
131 |
132 | .report__panel-container--open {
133 | left: 10px;
134 | }
135 |
136 | .report__panel-button {
137 | position: relative;
138 | right: 0;
139 | height: 3.5rem;
140 | background: $primary-color;
141 | width: 5%;
142 | float: right;
143 | }
144 |
145 | .report__panel {
146 | color: #FFF;
147 | height: calc(100vh - 76px);
148 | width: 95%;
149 | float: left;
150 | background: $background-color;
151 | overflow: auto;
152 | .inner::-webkit-scrollbar {
153 | display: none;
154 | }
155 | }
156 |
157 | .report__panel::-webkit-scrollbar {
158 | display: none;
159 | }
160 |
161 | .report__actions {
162 | @include column(12/12);
163 | padding: $global-spacing*2 0;
164 | p {
165 | float: left;
166 | padding-top: 0.5rem;
167 | }
168 | button {
169 | float: right;
170 | display: none;
171 | }
172 | }
173 |
174 | .report__header {
175 | @include column(12/12);
176 | margin:($global-spacing*2) 0 ($global-spacing * 3) 0;
177 | padding-bottom: $global-spacing * 4;
178 | border-bottom: solid 1px rgba($base-color, 0.2);
179 | }
180 |
181 | .report__label {
182 | color: $highlight-color;
183 | text-transform: uppercase;
184 | font-size: 1.15rem;
185 | }
186 |
187 | .report__body {
188 | @include column(12/12);
189 | }
190 |
191 | .stat-list, .stat-list-single {
192 | margin-left: 0;
193 | width: 100%;
194 | float: left;
195 | }
196 |
197 | .report__section {
198 | @extend .clearfix;
199 | margin: ($global-spacing * 2) 0;
200 | padding-bottom: $global-spacing * 2;
201 | border-bottom: solid 1px rgba($base-color, 0.1);
202 | &:first-child {
203 | margin-top: 0;
204 | }
205 | &:last-child {
206 | border-bottom: 0;
207 | };
208 | }
209 |
210 |
211 | .report__section-header {
212 | @extend .clearfix;
213 | margin-bottom: $global-spacing * 2;
214 | h2 {
215 | font-size: 1.15rem;
216 | font-weight: $base-font-bold;
217 | float: left;
218 | }
219 | p {
220 | margin-top: $global-spacing/2;
221 | }
222 | }
223 |
224 | .section__number {
225 | text-transform: uppercase;
226 | font-size: 0.875rem;
227 | line-height: 1rem;
228 | color: $highlight-color;
229 | }
230 |
231 | .report__section-description {
232 | font-weight: $base-font-light;
233 | opacity: 0.8;
234 | font-size: 0.875rem;
235 | width: 85%;
236 | float: left;
237 | }
238 |
239 | .report__section-update-date {
240 | font-weight: $base-font-light;
241 | color: $tertiary-color;
242 | font-size: 0.875rem;
243 | padding-bottom: 5px;
244 | }
245 |
246 | .report__title {
247 | font-weight: $base-font-regular;
248 | color: $base-color;
249 | font-size: 2.3rem;
250 | padding-top: 5px;
251 | padding-bottom: 7px;
252 | }
253 |
254 | .report__meta {
255 | margin-left: 0;
256 | margin-bottom: $global-spacing;
257 | padding-top: 5px;
258 | li {
259 | opacity: 1;
260 | font-size: 1rem;
261 | font-weight: $base-font-regular;
262 | display: inline-block;
263 | margin-left: $global-spacing*0.7;
264 | padding-left: $global-spacing*0.7;
265 | border-left: solid 1px rgba($base-color, 0.2);
266 | &:first-child {
267 | border-left: none;
268 | padding-left: 0;
269 | margin-left: 0;
270 | }
271 | }
272 | }
273 |
274 | .report__section-body {
275 | margin-top: $global-spacing;
276 | position: relative;
277 | .stat-list {
278 | li {
279 | @include column(4/12);
280 | }
281 | li.section-button {
282 | margin-left: $global-spacing * 2;
283 | font-size: 0.875rem;
284 | }
285 | }
286 | .stat-list-single {
287 | li {
288 | @include column(12/12);
289 | }
290 | }
291 | p {
292 | font-size: 1.15rem;
293 | margin-bottom: $global-spacing;
294 | small {
295 | opacity: 0.7;
296 | font-weight: 300;
297 | margin-left: $global-spacing/2;
298 | }
299 | }
300 | }
301 |
302 | .report__summary-status, .report__summary-general {
303 | padding-left: 1.9rem;
304 | padding-top: 0.5rem;
305 | }
306 |
307 | .report__summary-general {
308 | color: #fff;
309 | font-size: 0.875rem;
310 | font-weight: $base-font-light;
311 | }
312 |
313 | .report__status {
314 | p {
315 | position: relative;
316 | padding-left: $global-spacing;
317 | font-weight: $base-font-bold;
318 | font-size: 0.85rem;
319 | float: left;
320 | }
321 | button {
322 | float: right;
323 | }
324 | }
325 |
326 | .white-bg-text {
327 | background-color: #fff;
328 | color: $background-color;
329 | padding: 0 2px;
330 | font-weight: bold;
331 | }
332 |
333 | .report__text--poor {
334 | color: #EB6856;
335 | }
336 |
337 | .report__status--poor {
338 | p {
339 | &:before {
340 | content: '';
341 | position: absolute;
342 | left: 0;
343 | top: 0.19rem;
344 | height: 12px;
345 | width: 12px;
346 | border-radius: 99999px;
347 | background-color: #EB6856;
348 | };
349 | }
350 | }
351 |
352 | .report__text--fair {
353 | color: #FFF11A;
354 | }
355 |
356 | .report__status--fair {
357 | p {
358 | &:before {
359 | content: '';
360 | position: absolute;
361 | left: 0;
362 | top: 0.19rem;
363 | height: 12px;
364 | width: 12px;
365 | border-radius: 99999px;
366 | background-color: #FFF11A;
367 | };
368 | }
369 | }
370 |
371 | .report__text--good {
372 | color: #39CC31;
373 | }
374 |
375 | .report__status--good {
376 | p {
377 | &:before {
378 | content: '';
379 | position: absolute;
380 | left: 0;
381 | top: 0.19rem;
382 | height: 12px;
383 | width: 12px;
384 | border-radius: 99999px;
385 | background-color: #39CC31;
386 | };
387 | }
388 | }
389 |
390 | .report__summary-item {
391 | font-size: 0.8rem;
392 | font-weight: $base-font-bold;
393 | color: $tertiary-color;
394 | height: 4rem;
395 | }
396 |
397 | .report__summary-item-title {
398 | padding-left: 1rem;
399 | }
400 |
401 | .color-scale__container {
402 | float: left;
403 | width: 100%;
404 | }
405 |
406 | .recency-scale__container {
407 | padding-bottom: $global-spacing;
408 | }
409 |
410 | .color-scale {
411 | list-style: none;
412 | padding-right: 0;
413 | margin: 0;
414 | position: relative;
415 | }
416 |
417 | .color-scale__item {
418 | float: left;
419 | height: 0.75rem;
420 | width: 12.5%;
421 | &:first-child {
422 | background-color: #D73027;
423 | }
424 | &:nth-child(2) {
425 | background-color: #F46D43;
426 | }
427 | &:nth-child(3) {
428 | background-color: #FDAE61;
429 | }
430 | &:nth-child(4) {
431 | background-color: #FEE08B;
432 | }
433 | &:nth-child(5) {
434 | background-color: #D9EF8B;
435 | }
436 | &:nth-child(6) {
437 | background-color: #A6D96A;
438 | }
439 | &:nth-child(7) {
440 | background-color: #66BD63;
441 | }
442 | &:nth-child(8) {
443 | background-color: #1A9850;
444 | }
445 | }
446 |
447 | .scale-number-bad {
448 | color: #D73027;
449 | }
450 |
451 | .scale-number-good {
452 | color: #1A9850;
453 | }
454 |
455 | .scale-labels {
456 | width: 100%;
457 | p {
458 | text-align: center;
459 | font-size: 0.675rem;
460 | font-weight: $base-font-bold;
461 | }
462 | }
463 |
464 | .less {
465 | float: left;
466 | }
467 |
468 | .more {
469 | float: right;
470 | }
471 |
472 | .report-link {
473 | font-size: 0.875rem;
474 | padding-top: 0.75rem;
475 | }
476 |
477 | .mapboxgl-ctrl-group {
478 | background-color: $background-color;
479 | border-radius: 1px;
480 | box-shadow: 0 0 0 2px $background-color !important;
481 | moz-box-shadow: $background-color;
482 | }
483 |
484 | .mapboxgl-ctrl-icon.mapboxgl-ctrl-zoom-in {
485 | background-image: url("data:image/svg+xml;charset=utf8,%3Csvg%20viewBox%3D%270%200%2020%2020%27%20xmlns%3D%27http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%27%3E%0A%20%20%3Cpath%20style%3D%27fill%3A%23FFFFFF%3B%27%20d%3D%27M%2010%206%20C%209.446%206%209%206.4459904%209%207%20L%209%209%20L%207%209%20C%206.446%209%206%209.446%206%2010%20C%206%2010.554%206.446%2011%207%2011%20L%209%2011%20L%209%2013%20C%209%2013.55401%209.446%2014%2010%2014%20C%2010.554%2014%2011%2013.55401%2011%2013%20L%2011%2011%20L%2013%2011%20C%2013.554%2011%2014%2010.554%2014%2010%20C%2014%209.446%2013.554%209%2013%209%20L%2011%209%20L%2011%207%20C%2011%206.4459904%2010.554%206%2010%206%20z%27%20%2F%3E%0A%3C%2Fsvg%3E%0A");
486 | }
487 |
488 | .mapboxgl-ctrl-icon.mapboxgl-ctrl-zoom-out {
489 | background-image: url("data:image/svg+xml;charset=utf8,%3Csvg%20viewBox%3D%270%200%2020%2020%27%20xmlns%3D%27http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%27%3E%0A%20%20%3Cpath%20style%3D%27fill%3A%23FFFFFF%3B%27%20d%3D%27m%207%2C9%20c%20-0.554%2C0%20-1%2C0.446%20-1%2C1%200%2C0.554%200.446%2C1%201%2C1%20l%206%2C0%20c%200.554%2C0%201%2C-0.446%201%2C-1%200%2C-0.554%20-0.446%2C-1%20-1%2C-1%20z%27%20%2F%3E%0A%3C%2Fsvg%3E%0A");
490 | }
491 |
--------------------------------------------------------------------------------
/src/views/Report.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { connect } from 'react-redux';
3 | import { Link } from 'react-router-dom';
4 | import ReportMap from '../components/ReportMap';
5 | import ReportEditsChart from '../components/ReportEditsChart';
6 | import PanelContainer from '../components/PanelContainer';
7 | import CompletenessStatus from '../components/CompletenessStatus';
8 | import { requestBoundary } from '../state/ReportState';
9 | import numeral from 'numeral';
10 | import { subMonths, format } from 'date-fns';
11 | import upperFirst from 'lodash.upperfirst';
12 |
13 | class Report extends Component {
14 | constructor(props) {
15 | super(props);
16 | const { country, aoi } = this.props.match.params;
17 | this.state = {
18 | mapZoom: 9
19 | }
20 | this.props.getStats(country, aoi);
21 | this.onMapZoom = this.onMapZoom.bind(this);
22 | }
23 |
24 | onMapZoom (z) {
25 | this.setState({
26 | mapZoom: z
27 | });
28 | }
29 |
30 | render() {
31 | const { country, aoi } = this.props.match.params;
32 | const { boundaries, stats, domain } = this.props;
33 |
34 | let layer = null;
35 | if (boundaries.length > 0) {
36 | layer = boundaries.filter(bnd => {
37 | return (
38 | bnd.properties.country === country &&
39 | bnd.properties.id === aoi
40 | );
41 | })[0];
42 | }
43 |
44 | if (!stats || !domain || !layer) return
; //FIXME Should return loading indicator
45 |
46 | const {
47 | buildingResidential,
48 | buildingResidentialIncomplete,
49 | duplicateCount,
50 | totalBuildings,
51 | untaggedWays,
52 | population,
53 | averageCompleteness
54 | } = stats['building-stats'];
55 |
56 | const timestamp = stats.timestamp;
57 | const timeBins = stats['time-bins'];
58 |
59 | // Stats calculation
60 | const numberUntaggedWays = numeral(untaggedWays);
61 | const numberBuildings = numeral(totalBuildings);
62 | const numberResidential = numeral(buildingResidential);
63 | const numberBuildingResidentialIncomplete = numeral(buildingResidentialIncomplete)
64 | const percentResidentialBuildings = numeral(numberResidential.value() / numberBuildings.value());
65 | const percentCompleteBuildings = numeral((numberResidential.value() - numberBuildingResidentialIncomplete.value()) / numberBuildings.value());
66 | const numberDuplicates = numeral(duplicateCount);
67 | const estimatePopulation = numeral(population)
68 |
69 | // Calculate recent buildings
70 | // Get only the bins in the past 6 months
71 | const today = new Date();
72 | let recentEditsFromTimeBins = 0;
73 | let totalEditsFromTimeBins = 0;
74 | for (let i = 0; i < 1; i++) {
75 | const date = subMonths(today, i);
76 | const key = format(date, 'YYYYMM');
77 | if (timeBins[key]) {
78 | recentEditsFromTimeBins += timeBins[key]
79 | }
80 | }
81 | Object.values(timeBins).forEach(timebin => {
82 | totalEditsFromTimeBins += timebin;
83 | });
84 | const percentRecentBuildings = numeral(recentEditsFromTimeBins / totalEditsFromTimeBins);
85 |
86 | return (
87 |
88 |
89 | {
}
90 |
91 |
92 |
93 |
94 |
108 |
109 |
110 |
Updated {format(timestamp, 'MMM. D, YYYY')}
111 |
112 |
113 |
{upperFirst(aoi)} District
114 |
115 | {upperFirst(country)}
116 | Est. Population {estimatePopulation.format('0,0')}
117 |
118 |
119 |
120 |
121 |
1.Relative Completeness
122 |
123 |
124 |
125 |
126 |
127 |
2.Attribute Completeness
128 |
129 | {numberUntaggedWays.format('0,0')} untagged closeways
130 | / {percentResidentialBuildings.format('0.00%')} residential buildings
131 |
132 |
133 |
134 |
3.Temporal Accuracy
135 |
136 | {percentRecentBuildings.format('0.00%')} buildings edited last month
137 |
138 |
139 |
140 |
4.Data Errors
141 |
142 | {numberDuplicates.format('0,0')} data errors
143 |
144 |
145 |
146 |
147 |
148 |
149 |
150 |
Section 1
151 |
Relative Completeness
152 |
Distribution of buildings in OpenStreetMap compared population estimates from WorldPop.
153 |
154 |
155 |
156 |
157 |
158 |
159 |
160 |
161 |
Section 2
162 |
Attribute Completeness
163 |
Metadata about use of building, roof and wall type. Using `buildings=residential`, `roof=*` and `wall=*` attributes.
164 |
165 |
166 |
{numberBuildings.format('0,0')}OSM buildings in this AOI
167 |
168 | {numberUntaggedWays.format('0,0')}untagged closeways
169 | {percentResidentialBuildings.format('0.00%')}residential buildings
170 | {percentCompleteBuildings.format('0.00%')}residential buildings with roof and wall tags
171 |
172 |
173 |
174 |
175 |
176 |
177 |
Section 3
178 |
Temporal Accuracy
179 |
Recency of building data, and the distribution over the last few years.
180 |
181 |
182 |
183 | {percentRecentBuildings.format('0.00%')}buildings edited in the last 6 months
184 | Zoom into map to see OSM buildings and edit recency.
185 |
186 |
187 |
Buildings Edited by Month
188 |
189 |
190 |
191 |
192 |
193 |
194 |
195 |
Section 4
196 |
Data Errors
197 |
Buildings that are potentially overlapping causing overestimation.
198 |
199 |
200 |
201 | {numberDuplicates.format('0,0')}duplicate buildings
202 |
203 |
204 |
205 |
206 |
207 |
208 |
209 |
210 |
211 |
212 |
Completeness
213 |
214 |
215 |
216 |
217 |
218 |
219 |
220 |
221 |
222 |
223 |
224 |
Bad
225 |
Good
226 |
227 |
228 | {
229 | (this.state.mapZoom > 12) ?
230 |
231 |
OSM Edit Recency
232 |
233 |
234 | :
235 |
236 | }
237 |
238 |
239 | );
240 | }
241 | }
242 |
243 | const mapStateToProps = (state) => {
244 | return {
245 | boundaries: state.AppState.boundaries,
246 | stats: state.ReportState.stats,
247 | domain: state.ReportState.domain
248 | }
249 | }
250 |
251 | const mapDispatchToProps = dispatch => {
252 | return {
253 | getStats: (...args) => dispatch(requestBoundary(...args))
254 | }
255 | }
256 |
257 | export default connect(mapStateToProps, mapDispatchToProps)(Report);
258 |
--------------------------------------------------------------------------------
/src/styles/scss/core/_typography.scss:
--------------------------------------------------------------------------------
1 | .prose {
2 | font-size: $base-font-size; // 16px
3 | line-height: $base-line-height; // 24px
4 |
5 | > * {
6 | margin-bottom: $base-font-size * $base-line-height; // same as line-height
7 | }
8 |
9 | > *:last-child {
10 | margin-bottom: 0;
11 | }
12 |
13 | .align-center {
14 | display: block;
15 | margin-left: auto;
16 | margin-right: auto;
17 | }
18 |
19 | .align-left {
20 | float: left;
21 | margin-right: $global-spacing * 1.5;
22 | }
23 |
24 |
25 | .align-right {
26 | float: right;
27 | margin-left: $global-spacing * 1.5;
28 | }
29 | }
30 |
31 | .prose--responsive {
32 | $prose-resp-font-size: 1rem; // 20px
33 | $prose-resp-line-height: 1.5; // 32px
34 |
35 | @include media(medium-up) {
36 | font-size: $prose-resp-font-size;
37 | line-height: $prose-resp-line-height;
38 |
39 | > * {
40 | margin-bottom: $prose-resp-font-size * $prose-resp-line-height; // same as line-height
41 | }
42 |
43 | .align-left {
44 | margin-right: $prose-resp-font-size * $prose-resp-line-height;
45 | }
46 |
47 |
48 | .align-right {
49 | margin-left: $prose-resp-font-size * $prose-resp-line-height;
50 | }
51 | }
52 | }
53 |
54 |
55 | /* Common elements
56 | ========================================================================== */
57 |
58 | p, ul, ol, dl, pre, blockquote {
59 | margin: 0;
60 | }
61 |
62 |
63 | /* Lead
64 | ========================================================================== */
65 |
66 | .lead {
67 | font-size: 1.25rem;
68 | line-height: 1.6;
69 | opacity: 0.64;
70 | }
71 |
72 | /* Prose specific */
73 |
74 | .prose--responsive {
75 | > .lead {
76 | @include media(medium-up) {
77 | font-size: 1.5rem;
78 | line-height: 1.6666667;
79 | }
80 | }
81 | }
82 |
83 |
84 | /* Lists
85 | ========================================================================== */
86 |
87 | ol ol, ol ul, ul ol, ul ul {
88 | margin-bottom: 0;
89 | }
90 |
91 | ul, ol, dl {
92 | padding: 0;
93 | }
94 |
95 | ul {
96 | list-style-type: disc;
97 | }
98 |
99 | ol {
100 | list-style-type: decimal;
101 | }
102 |
103 | ul,
104 | ol {
105 | list-style-position: outside;
106 | margin-left: $global-spacing;
107 | }
108 |
109 | dt {
110 | font-weight: $base-font-regular;
111 | font-size: 1rem;
112 | line-height: 1.25rem;
113 | }
114 |
115 | dd {
116 | margin: 0 0 ($global-spacing / 2) 0;
117 | font-size: 1rem;
118 | font-weight: $base-font-light;
119 |
120 | &:last-child {
121 | margin-bottom: 0;
122 | }
123 | }
124 |
125 | .ul--tick,
126 | .ol--tick,
127 | .ul--go,
128 | .ol--go {
129 | list-style: none;
130 | margin-left: 0;
131 |
132 | li {
133 | display: block;
134 | position: relative;
135 | padding-left:$global-spacing * 1.5;
136 |
137 | &::before {
138 | position: absolute;
139 | top: 0;
140 | left: 0;
141 | z-index: 1;
142 | font-size: 1rem;
143 | line-height: 1.5rem;
144 | color: inherit;
145 | width: 1rem;
146 | text-align: center;
147 | }
148 | }
149 | }
150 |
151 | /*.ul--tick li::before,
152 | .ol--tick li::before {
153 | @extend %collecticon-tick;
154 | }
155 |
156 | .ul--go li::before,
157 | .ol--go li::before {
158 | @extend %collecticon-arrow-right;
159 | }
160 | */
161 | .dl--table {
162 | @extend .clearfix;
163 | padding: 0;
164 |
165 | &:not(:last-child) {
166 | box-shadow: inset 0 (-$base-border-width) 0 0 $base-alpha-color;
167 | padding-bottom: 0.5rem;
168 | }
169 |
170 | dd:not(:last-child) {
171 | box-shadow: 0 $base-border-width 0 0 $base-alpha-color;
172 | padding-bottom: 0.5rem;
173 | }
174 | }
175 |
176 | .ul--footnotes,
177 | .ol--footnotes {
178 | font-feature-settings: "pnum" 0; // Use proportional numbers
179 | position: relative;
180 | color: rgba($base-font-color, 0.64);
181 | font-size: 0.875rem;
182 | line-height: 1.25rem;
183 | padding-top: $global-spacing;
184 | font-weight: $base-font-regular;
185 | margin-left: 1rem;
186 |
187 | &::before {
188 | position: absolute;
189 | top: 0;
190 | left: -$global-spacing;
191 | width: 4rem;
192 | height: $base-border-width;
193 | content: "";
194 | background: $base-alpha-color;
195 | }
196 | }
197 |
198 | .dl--horizontal {
199 | @extend .clearfix;
200 |
201 | dt, dd {
202 | float: left;
203 | }
204 |
205 | dt {
206 | width: 32%;
207 | clear: left;
208 | padding-top: $global-spacing / 8;
209 | padding-bottom: $global-spacing / 8;
210 | padding-right: $global-spacing / 2;
211 | }
212 |
213 | dd {
214 | width: 68%;
215 | padding-left: $global-spacing / 2;
216 | }
217 |
218 | dd + dd {
219 | margin-left: 32%;
220 | }
221 | }
222 |
223 |
224 | /* Prose specific */
225 |
226 | .prose--responsive {
227 | > ul,
228 | > ol {
229 | @include media(medium-up) {
230 | margin-left: $global-spacing * 1.5;
231 | }
232 | }
233 |
234 | > dl dt {
235 | @include media(medium-up) {
236 | padding-top: $global-spacing / 4;
237 | padding-bottom: $global-spacing / 4;
238 | font-size: 1rem;
239 | line-height: 1.5;
240 | }
241 | }
242 |
243 | > .dl--table {
244 | @include media(small-up) {
245 | padding-bottom: 0;
246 |
247 | dt, dd {
248 | float: left;
249 | width: 50%;
250 | margin: 0;
251 | padding: ($global-spacing / 2) 0;
252 | box-shadow: inset 0 $base-border-width 0 0 $base-alpha-color;
253 |
254 | &:first-of-type {
255 | box-shadow: none;
256 | }
257 | }
258 |
259 | dt {
260 | clear: left;
261 | padding-right: $global-spacing / 2;
262 | }
263 |
264 | dd {
265 | text-align: right;
266 | padding-left: $global-spacing / 2;
267 | }
268 |
269 | dd + dd {
270 | margin-left: 50%;
271 | box-shadow: none;
272 | }
273 | }
274 |
275 | @include media(medium-up) {
276 | dt, dd {
277 | padding: ($global-spacing / 2) 0;
278 | }
279 | }
280 | }
281 |
282 | > .ul--tick,
283 | > .ol--tick,
284 | > .ul--go,
285 | > .ol--go {
286 | @include media(medium-up) {
287 | margin-left: 0;
288 |
289 | li {
290 | &::before {
291 | line-height: 2rem;
292 | }
293 | }
294 | }
295 | }
296 |
297 | > .ul--footnotes,
298 | > .ol--footnotes, {
299 | @include media(medium-up) {
300 | font-size: 1rem;
301 | line-height: 1.5rem;
302 | }
303 | }
304 | }
305 |
306 |
307 | /* Blockquote
308 | ========================================================================== */
309 |
310 | blockquote,
311 | .blockquote {
312 | box-shadow: inset $base-border-width 0 0 0 $base-alpha-color;
313 | padding: ($global-spacing / 2) $global-spacing;
314 |
315 | *:last-child {
316 | margin-bottom: 0;
317 | }
318 |
319 | footer {
320 | font-weight: $base-font-regular;
321 | color: rgba($base-font-color, 0.64);
322 | margin-top: -$global-spacing / 2;
323 | font-size: 0.875rem;
324 | line-height: 1.25rem;
325 |
326 | &:before {
327 | content: '— ';
328 | }
329 | }
330 | }
331 |
332 | .blockquote--quote-left {
333 | position: relative;
334 | box-shadow: none;
335 | padding: ($global-spacing / 2) $global-spacing ($global-spacing / 2) ($global-spacing * 2);
336 |
337 | > * {
338 | position: relative;
339 | z-index: 2;
340 | }
341 |
342 | &::before {
343 | /* @extend %collecticon-quote-left;*/
344 | position: absolute;
345 | top: 0;
346 | left: 0;
347 | z-index: 1;
348 | font-size: 4em;
349 | color: $base-alpha-color;
350 | }
351 | }
352 |
353 | /* Prose specific */
354 |
355 | .prose--responsive {
356 | > blockquote {
357 | @include media(medium-up) {
358 | padding: $global-spacing ($global-spacing * 2);
359 | }
360 | }
361 |
362 | > .blockquote--quote-left {
363 | @include media(medium-up) {
364 | padding: $global-spacing ($global-spacing * 2) $global-spacing ($global-spacing * 4);
365 | }
366 | }
367 |
368 | > blockquote footer {
369 | @include media(medium-up) {
370 | font-size: 1rem;
371 | line-height: 1.5rem;
372 | margin-top: 0;
373 | }
374 | }
375 | }
376 |
377 |
378 |
379 | /* Dividers
380 | ========================================================================== */
381 |
382 | hr,
383 | .hr {
384 | float: left;
385 | border: 0;
386 | height: 1px;
387 | background: none;
388 | width: 100%;
389 | margin: $global-spacing 0;
390 | background: transparent linear-gradient(transparent, $base-alpha-color, transparent) 50% / auto $base-border-width repeat-x;
391 | }
392 |
393 | /* Prose specific */
394 |
395 | .prose {
396 | > hr,
397 | >.hr {
398 | margin: $global-spacing auto;
399 | }
400 | }
401 |
402 | .prose--responsive {
403 | > hr,
404 | > .hr {
405 | @include media(medium-up) {
406 | margin: ($global-spacing * 2) auto;
407 | }
408 | }
409 | }
410 |
411 |
412 | /* Emphasis
413 | ========================================================================== */
414 |
415 | b, strong {
416 | font-weight: $base-font-bold;
417 | }
418 |
419 | small, .small {
420 | font-size: 75%;
421 | font-weight: normal;
422 | }
423 |
424 | mark, .mark {
425 | padding: 0 0.25rem;
426 | background: rgba($primary-color, 0.16);
427 | border-radius: $base-border-radius;
428 | }
429 |
430 | .note {
431 | font-style: italic;
432 | opacity: 0.7;
433 | font-weight: $base-font-light;
434 | }
435 |
436 | .stat-list, .stat-list-single {
437 | list-style: none;
438 | li {
439 | display: inline-block;
440 | font-size: 1.5rem;
441 | line-height: 1.25rem;
442 | small {
443 | display: block;
444 | font-size: 0.75rem;
445 | line-height: 1rem;
446 | font-weight: $base-font-light;
447 | opacity: 0.7;
448 | }
449 | }
450 | }
451 |
452 | /* Abbreviation
453 | ========================================================================== */
454 |
455 | abbr[title] {
456 | cursor: help;
457 | border-bottom: $base-border-width dashed rgba($primary-color, 0.32);
458 | text-decoration: none;
459 | text-transform: initial;
460 | }
461 |
462 |
463 | /* Headings
464 | ========================================================================== */
465 |
466 | .heading, h1, h2, h3, h4, h5, h6 {
467 | font-family: $heading-font-family;
468 | font-weight: $heading-font-weight;
469 | margin-top: 0;
470 | margin-bottom: 0;
471 | }
472 |
473 | .heading--xlarge {
474 | @include heading(1.75rem, xlarge-up); // 28, 32, 36, 40
475 | }
476 |
477 | .heading--large {
478 | @include heading(1.5rem, xlarge-up); // 24, 28, 32, 36
479 | }
480 |
481 | .heading--medium {
482 | @include heading(1.25rem, xlarge-up); // 20, 24, 28, 32
483 | }
484 |
485 | .heading--small,
486 | .heading--xsmall,
487 | .heading--xxsmall {
488 | @include heading(1rem, xlarge-up); // 16, 20, 24, 28
489 | }
490 |
491 | .heading-alt {
492 | font-feature-settings: "pnum" 0; // Use proportional numbers
493 | font-family: $heading-font-family;
494 | font-weight: $heading-font-regular;
495 | text-transform: uppercase;
496 | }
497 |
498 | .heading-deco {
499 | &::after {
500 | content: '';
501 | width: 2rem;
502 | height: $base-border-width;
503 | background: $base-font-color;
504 | display: block;
505 | margin: calc(0.5em - #{$base-border-width}) 0 0 0;
506 | }
507 | }
508 |
509 | h1 {
510 | @include heading(1.75rem); // 28
511 | }
512 |
513 | h2 {
514 | @include heading(1.5rem); // 24
515 | }
516 |
517 | h3 {
518 | @include heading(1.25rem); // 20
519 | }
520 |
521 | h4, h5, h6 {
522 | @include heading(1rem); // 16
523 | }
524 |
525 | /* Prose specific */
526 |
527 | .prose {
528 | > h1:not(:first-child),
529 | > h2:not(:first-child),
530 | > h3:not(:first-child),
531 | > h4:not(:first-child),
532 | > h5:not(:first-child),
533 | > h6:not(:first-child) {
534 | margin-top: $global-spacing * 2.5;
535 | }
536 |
537 | > h1 + h2:not(:first-child),
538 | > h2 + h3:not(:first-child),
539 | > h3 + h4:not(:first-child),
540 | > h4 + h5:not(:first-child),
541 | > h5 + h6:not(:first-child) {
542 | margin-top: 0;
543 | }
544 | }
545 |
546 |
547 | /* Decoration
548 | ========================================================================== */
549 |
550 | .dropcap:first-letter {
551 | font-size: 3.4em;
552 | padding: 0 0.1em 0 0;
553 | line-height: 0.7;
554 | float: left;
555 | margin: 0.1em 0.1em 0 0;
556 | }
557 |
558 |
559 | .prose--responsive {
560 | > h1 { @include heading(1.75rem, xlarge-up); } // 28, 32, 36, 40
561 | > h2 { @include heading(1.5rem, xlarge-up); } // 24, 28, 32, 36
562 | > h3 { @include heading(1.25rem, xlarge-up); } // 20, 24, 28, 32
563 | > h4,
564 | > h5,
565 | > h6 { @include heading(1rem, xlarge-up); } // 16, 20, 24, 28
566 |
567 | @include media(medium-up) {
568 | > h1:not(:first-child),
569 | > h2:not(:first-child),
570 | > h3:not(:first-child),
571 | > h4:not(:first-child),
572 | > h5:not(:first-child),
573 | > h6:not(:first-child) {
574 | margin-top: $global-spacing * 4;
575 | }
576 | }
577 | }
--------------------------------------------------------------------------------