├── Procfile
├── app
├── CNAME
├── middleware
│ ├── index.js
│ └── logger.js
├── assets
│ ├── logos
│ │ ├── ds.png
│ │ ├── aws.png
│ │ ├── gfdrr.png
│ │ ├── hot.png
│ │ ├── osm.png
│ │ ├── heigit.png
│ │ ├── knight.png
│ │ ├── mapbox.png
│ │ ├── zoondka.png
│ │ └── redcross.png
│ ├── about-stats.png
│ ├── about-background.jpg
│ ├── about-gridcells.png
│ ├── about-timegraph.png
│ ├── about-beforeafter.png
│ ├── checkbox-off.svg
│ ├── overlays.svg
│ ├── search-clear.svg
│ ├── checkbox-on.svg
│ └── search.svg
├── settings
│ ├── defaults.js
│ ├── unitSystems.js
│ ├── settings.js
│ ├── themes
│ │ ├── index.js
│ │ ├── default.js
│ │ ├── templates
│ │ │ └── builtup.js
│ │ └── opendri.js
│ └── options.js
├── actions
│ ├── stats.js
│ └── map.js
├── reducers
│ ├── index.js
│ ├── stats.js
│ └── map.js
├── components
│ ├── Stats
│ │ ├── searchHotProjects.js
│ │ ├── subTagsModal.js
│ │ ├── searchBuiltupAreas.js
│ │ ├── contributorsModal.js
│ │ ├── hotProjectsModal.js
│ │ ├── style.css
│ │ ├── searchFeatures.js
│ │ ├── gaps.js
│ │ └── index.js
│ ├── ThresholdSelector
│ │ ├── style.css
│ │ └── index.js
│ ├── Header
│ │ ├── embedHeader.css
│ │ ├── index.js
│ │ ├── button.js
│ │ ├── style.css
│ │ └── embedHeader.js
│ ├── OverlayButton
│ │ └── index.js
│ ├── FilterButton
│ │ ├── index.js
│ │ └── gaps.js
│ ├── UnitSelector
│ │ └── index.js
│ ├── CompareBar
│ │ ├── style.css
│ │ ├── index.js
│ │ └── chart.js
│ ├── Map
│ │ ├── swiper.js
│ │ ├── loadVectorTile.js
│ │ ├── regionToCoords.js
│ │ ├── gapsLayer.js
│ │ ├── style.css
│ │ ├── glstyles.js
│ │ ├── gaps.js
│ │ └── index.js
│ ├── Legend
│ │ ├── style.css
│ │ ├── gaps.js
│ │ └── index.js
│ ├── DropdownButton
│ │ ├── index.js
│ │ └── style.css
│ ├── SearchBox
│ │ ├── style.css
│ │ └── index.js
│ └── About
│ │ ├── style.css
│ │ └── index.js
├── containers
│ ├── AboutPage
│ │ └── index.js
│ ├── Gaps
│ │ └── index.js
│ └── App
│ │ ├── style.css
│ │ └── index.js
├── data
│ └── hotprojects.js
├── store
│ └── index.js
├── index.js
├── index.html
├── polyfill.js
└── libs
│ └── leaflet-mapbox-gl.js
├── .gitignore
├── .babelrc
├── documentation
└── embed-example-1.png
├── deploy.sh
├── .eslintrc.js
├── LICENSE.md
├── Jenkinsfile
├── webpack.config.js
├── package.json
└── README.md
/Procfile:
--------------------------------------------------------------------------------
1 | web: npm start
2 |
--------------------------------------------------------------------------------
/app/CNAME:
--------------------------------------------------------------------------------
1 | osm-analytics.org
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | static
2 | .module-cache
3 | *.log*
4 | node_modules
--------------------------------------------------------------------------------
/app/middleware/index.js:
--------------------------------------------------------------------------------
1 | import logger from './logger'
2 |
3 | export {
4 | logger
5 | }
6 |
--------------------------------------------------------------------------------
/app/assets/logos/ds.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hotosm/osm-analytics/HEAD/app/assets/logos/ds.png
--------------------------------------------------------------------------------
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": ["es2015", "stage-0", "react"],
3 | "plugins": ["transform-runtime"]
4 | }
5 |
--------------------------------------------------------------------------------
/app/assets/about-stats.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hotosm/osm-analytics/HEAD/app/assets/about-stats.png
--------------------------------------------------------------------------------
/app/assets/logos/aws.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hotosm/osm-analytics/HEAD/app/assets/logos/aws.png
--------------------------------------------------------------------------------
/app/assets/logos/gfdrr.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hotosm/osm-analytics/HEAD/app/assets/logos/gfdrr.png
--------------------------------------------------------------------------------
/app/assets/logos/hot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hotosm/osm-analytics/HEAD/app/assets/logos/hot.png
--------------------------------------------------------------------------------
/app/assets/logos/osm.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hotosm/osm-analytics/HEAD/app/assets/logos/osm.png
--------------------------------------------------------------------------------
/app/assets/logos/heigit.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hotosm/osm-analytics/HEAD/app/assets/logos/heigit.png
--------------------------------------------------------------------------------
/app/assets/logos/knight.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hotosm/osm-analytics/HEAD/app/assets/logos/knight.png
--------------------------------------------------------------------------------
/app/assets/logos/mapbox.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hotosm/osm-analytics/HEAD/app/assets/logos/mapbox.png
--------------------------------------------------------------------------------
/app/assets/logos/zoondka.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hotosm/osm-analytics/HEAD/app/assets/logos/zoondka.png
--------------------------------------------------------------------------------
/app/assets/about-background.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hotosm/osm-analytics/HEAD/app/assets/about-background.jpg
--------------------------------------------------------------------------------
/app/assets/about-gridcells.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hotosm/osm-analytics/HEAD/app/assets/about-gridcells.png
--------------------------------------------------------------------------------
/app/assets/about-timegraph.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hotosm/osm-analytics/HEAD/app/assets/about-timegraph.png
--------------------------------------------------------------------------------
/app/assets/logos/redcross.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hotosm/osm-analytics/HEAD/app/assets/logos/redcross.png
--------------------------------------------------------------------------------
/app/assets/about-beforeafter.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hotosm/osm-analytics/HEAD/app/assets/about-beforeafter.png
--------------------------------------------------------------------------------
/documentation/embed-example-1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hotosm/osm-analytics/HEAD/documentation/embed-example-1.png
--------------------------------------------------------------------------------
/app/middleware/logger.js:
--------------------------------------------------------------------------------
1 | export default store => next => action => {
2 | console.log(action)
3 | return next(action)
4 | }
5 |
--------------------------------------------------------------------------------
/app/settings/defaults.js:
--------------------------------------------------------------------------------
1 | const defaults = {
2 | filters: [
3 | 'buildings'
4 | ],
5 | gapsFilters: [
6 | 'buildings-vs-ghs'
7 | ],
8 | overlay: 'recency'
9 | }
10 |
11 | export default defaults
12 |
--------------------------------------------------------------------------------
/app/actions/stats.js:
--------------------------------------------------------------------------------
1 | import { createAction } from 'redux-actions'
2 |
3 | export const setUnitSystem = createAction('set unit system')
4 | export const setTimeFilter = createAction('set time filter')
5 | export const setExperienceFilter = createAction('set experience filter')
6 |
--------------------------------------------------------------------------------
/app/reducers/index.js:
--------------------------------------------------------------------------------
1 | import { routerReducer as routing } from 'react-router-redux'
2 | import { combineReducers } from 'redux'
3 | import map from './map'
4 | import stats from './stats'
5 |
6 | export default combineReducers({
7 | routing,
8 | map,
9 | stats
10 | })
11 |
--------------------------------------------------------------------------------
/app/components/Stats/searchHotProjects.js:
--------------------------------------------------------------------------------
1 | import { inside } from 'turf'
2 | import hotProjects from '../../data/hotprojects.js'
3 |
4 | export default function searchHotProjectsInRegion(region) {
5 | return hotProjects().features
6 | .filter(project => inside(project, region))
7 | }
8 |
--------------------------------------------------------------------------------
/deploy.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | set -e # halt script on error
4 | set -x
5 |
6 | npm run build
7 | cp app/CNAME static/CNAME
8 |
9 | cd static/
10 | git init
11 | git add .
12 | git commit -S -m "deploy"
13 | git push git@github.com:hotosm/osm-analytics.git master:gh-pages --force
14 | rm -rf .git
15 | cd ..
16 |
--------------------------------------------------------------------------------
/app/containers/AboutPage/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import About from '../../components/About'
3 |
4 | class AboutPage extends Component {
5 | render() {
6 | const { actions, children } = this.props
7 | return (
8 |
9 | )
10 | }
11 | }
12 |
13 | export default AboutPage
14 |
--------------------------------------------------------------------------------
/app/settings/unitSystems.js:
--------------------------------------------------------------------------------
1 | const unitSystems = {
2 | 'SI': {
3 | distance: {
4 | descriptor: 'km',
5 | convert: l => l
6 | }
7 | },
8 | 'Imperial': {
9 | distance: {
10 | descriptor: 'miles',
11 | convert: l => l*0.621371
12 | }
13 | },
14 | 'Descriptive': {
15 | distance: {
16 | descriptor: 'trips to the moon',
17 | convert: l => l/385000
18 | }
19 | },
20 | 'Atoms': {
21 | distance: {
22 | descriptor: 'Å',
23 | convert: l => l/1E-13
24 | }
25 | },
26 | }
27 |
28 | export default unitSystems
29 |
--------------------------------------------------------------------------------
/app/components/ThresholdSelector/style.css:
--------------------------------------------------------------------------------
1 | .slider-box {
2 | display: block;
3 | position: absolute;
4 | left: 275px;
5 | bottom: 20px;
6 | margin: 0;
7 | padding: 18px 24px;
8 | list-style-type: none;
9 | background-color: rgba(#fff, 0.6);
10 | border-radius: 4px;
11 | pointer-events: none;
12 |
13 | input {
14 | pointer-events: all;
15 | margin: 2px 10px;
16 | }
17 | span {
18 | font-size: 12px;
19 | vertical-align: top;
20 | padding-bottom: 4px;
21 | display: inline-block;
22 | }
23 | h3 {
24 | display: block;
25 | margin: 0;
26 | padding-bottom: 0.5em;
27 | font-size: 14px;
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/app/components/Header/embedHeader.css:
--------------------------------------------------------------------------------
1 | .embedHeader {
2 | background: #fff;
3 | display: flex;
4 | justify-content: space-between;
5 | padding: 10px;
6 | height: 36px;
7 | font-size: 16px;
8 |
9 | > div > *:not(:last-child) {
10 | margin-right: 10px;
11 |
12 | &.title {
13 | margin-right: 20px;
14 | font-weight: bold;
15 | }
16 | }
17 |
18 |
19 | .left > button {
20 | padding: 0;
21 | background: none;
22 | color: #4B5A6A;
23 | font-weight: bold;
24 |
25 | &:hover {
26 | background: none;
27 | }
28 |
29 | &:active {
30 | background: none;
31 | box-shadow: none;
32 | }
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/app/data/hotprojects.js:
--------------------------------------------------------------------------------
1 | import * as request from 'superagent'
2 | import settings from '../settings/settings'
3 |
4 | var hotprojects = null
5 |
6 | export default function(callback) {
7 | if (hotprojects !== null) {
8 | return hotprojects
9 | } else {
10 | throw new Error('HotProjects data hasn\'t been loaded yet')
11 | }
12 | }
13 |
14 | export function load(callback) {
15 | if (hotprojects !== null) {
16 | return callback(null)
17 | }
18 |
19 | request
20 | .get(settings['tm-api'] + '/projects/')
21 | .end(function(err, res) {
22 | if (err) return callback(err)
23 | hotprojects = res.body.mapResults
24 | callback(null)
25 | })
26 | }
27 |
--------------------------------------------------------------------------------
/app/components/Header/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import { Link } from 'react-router'
3 | import style from './style.css'
4 |
5 | class Header extends Component {
6 | render() {
7 | return (
8 |
9 | OpenStreetMap Analytics beta
10 |
11 | Analysis Map
12 | Gap Detection
13 | About
14 |
15 |
16 | )
17 | }
18 | }
19 |
20 | export default Header
21 |
--------------------------------------------------------------------------------
/app/reducers/stats.js:
--------------------------------------------------------------------------------
1 | import { handleActions } from 'redux-actions'
2 |
3 | const initialState = {
4 | unitSystem: 'SI',
5 | timeFilter: null,
6 | experienceFilter: null
7 | }
8 |
9 | export default handleActions({
10 | 'set unit system' (state, action) {
11 | return Object.assign({}, state, {
12 | unitSystem: action.payload
13 | })
14 | },
15 | 'set time filter' (state, action) {
16 | return Object.assign({}, state, {
17 | timeFilter: action.payload,
18 | experienceFilter: null
19 | })
20 | },
21 | 'set experience filter' (state, action) {
22 | return Object.assign({}, state, {
23 | timeFilter: null,
24 | experienceFilter: action.payload
25 | })
26 | }
27 | }, initialState)
28 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | "env": {
3 | "browser": true,
4 | "es6": true
5 | },
6 | "parser": "babel-eslint",
7 | "extends": ["eslint:recommended", "plugin:react/recommended"],
8 | "parserOptions": {
9 | "sourceType": "module"
10 | },
11 | "plugins": [
12 | "react"
13 | ],
14 | "rules": {
15 | "indent": [
16 | "error",
17 | 2
18 | ],
19 | "linebreak-style": [
20 | "error",
21 | "unix"
22 | ],
23 | "quotes": [
24 | "error",
25 | "single"
26 | ],
27 | "semi": [
28 | "error",
29 | "never"
30 | ],
31 | "react/prop-types": 0
32 | }
33 | };
34 |
--------------------------------------------------------------------------------
/app/settings/settings.js:
--------------------------------------------------------------------------------
1 | const mapbox_token = 'pk.eyJ1IjoiaG90IiwiYSI6ImNpbmx4bWN6ajAwYTd3OW0ycjh3bTZvc3QifQ.KtikS4sFO95Jm8nyiOR4gQ'
2 |
3 | export default {
4 | 'vt-source': 'https://tiles.osm-analytics.heigit.org', // source of current vector tiles
5 | 'vt-gaps-source': 'https://tiles.osm-analytics.heigit.org/gaps',
6 | 'vt-hist-source': 'https://tiles.osm-analytics.heigit.org', // source of historic vector tiles for compare feature
7 | 'map-background-tile-layer': 'https://api.mapbox.com/styles/v1/mapbox/light-v10/tiles/{z}/{x}/{y}?access_token=' + mapbox_token,
8 | 'map-attribution': '© Mapbox © OpenStreetMap contributors ',
9 | 'tm-api': 'https://tasking-manager-tm4-production-api.hotosm.org/api/v2', // hot tasking manager api
10 | }
11 |
--------------------------------------------------------------------------------
/app/actions/map.js:
--------------------------------------------------------------------------------
1 | import { createAction } from 'redux-actions'
2 |
3 | export const enableFilter = createAction('enable filter')
4 | export const disableFilter = createAction('disable filter')
5 | export const setFiltersFromUrl = createAction('set filters from url')
6 | export const setRegion = createAction('set region')
7 | export const setRegionFromUrl = createAction('set region from url')
8 | export const setOverlay = createAction('set overlay')
9 | export const setOverlayFromUrl = createAction('set overlay from url')
10 | export const setView = createAction('set view')
11 | export const setViewFromUrl = createAction('set view from url')
12 | export const setTimes = createAction('set times')
13 | export const setTimesFromUrl = createAction('set times from url')
14 | export const setEmbedFromUrl = createAction('set embed from url')
15 | export const setThemeFromUrl = createAction('set theme from url')
16 |
--------------------------------------------------------------------------------
/app/store/index.js:
--------------------------------------------------------------------------------
1 | import { compose, createStore, applyMiddleware } from 'redux'
2 | //import persistState from 'redux-localstorage'
3 |
4 | import { logger } from '../middleware'
5 | import rootReducer from '../reducers'
6 |
7 | export default function configure(initialState) {
8 | const createDevStore = window.devToolsExtension
9 | ? window.devToolsExtension()(createStore)
10 | : createStore
11 |
12 | const createStoreWithMiddleware = applyMiddleware(
13 | logger
14 | )(createDevStore)
15 |
16 | //const createPersistentStore = compose(
17 | // persistState(['filters'] /*paths, config*/)
18 | //)(createStoreWithMiddleware)
19 |
20 | const store = createStoreWithMiddleware(rootReducer, initialState)
21 |
22 | if (module.hot) {
23 | module.hot.accept('../reducers', () => {
24 | const nextReducer = require('../reducers')
25 | store.replaceReducer(nextReducer)
26 | })
27 | }
28 |
29 | return store
30 | }
31 |
--------------------------------------------------------------------------------
/app/components/ThresholdSelector/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import themes from '../../settings/themes'
3 | import style from './style.css'
4 |
5 | class ThresholdSelector extends Component {
6 | state = {
7 | threshold: undefined
8 | }
9 |
10 | render() {
11 | const { theme } = this.props
12 | return (
13 |
14 |
Choose sensitivity
15 | high low
23 |
24 | )
25 | }
26 |
27 | handleChange(event) {
28 | const newThreshold = event.target.value
29 | this.setState({threshold: newThreshold})
30 | this.props.thresholdChanged(newThreshold)
31 | }
32 |
33 | }
34 |
35 | export default ThresholdSelector
36 |
--------------------------------------------------------------------------------
/app/components/OverlayButton/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import DropdownButton from '../DropdownButton'
3 |
4 | import { overlays } from '../../settings/options'
5 |
6 |
7 | class OverlayButton extends Component {
8 | render() {
9 | var btn = {overlays.find(overlay => overlay.id === this.props.enabledOverlay).description} ▾
10 | return (
11 |
20 | )
21 | }
22 |
23 | handleDropdownChanges(selectedKeys) {
24 | const selectedOverlay = selectedKeys[0]
25 | this.props.setOverlay(selectedOverlay)
26 | if (selectedOverlay !== this.props.enabledOverlay) {
27 | this.props.setExperienceFilter(null)
28 | this.props.setTimeFilter(null)
29 | }
30 | }
31 | }
32 |
33 | export default OverlayButton
34 |
--------------------------------------------------------------------------------
/app/components/FilterButton/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import DropdownButton from '../DropdownButton'
3 |
4 |
5 | class FilterButton extends Component {
6 | render() {
7 | var btn = {this.props.layers.find(layer => layer.name === this.props.enabledFilters[0]).title} ▾
8 | return (
9 | ({ id: layer.name, description: layer.title }))}
11 | btnElement={btn}
12 | multiple={false}
13 | selectedKeys={this.props.enabledFilters}
14 | onSelectionChange={::this.handleDropdownChanges}
15 | />
16 | )
17 | }
18 |
19 | handleDropdownChanges(selectedFilters) {
20 | var enabledFilters = this.props.enabledFilters
21 | selectedFilters.filter(filter => enabledFilters.indexOf(filter) === -1).map(this.props.enableFilter)
22 | enabledFilters.filter(filter => selectedFilters.indexOf(filter) === -1).map(this.props.disableFilter)
23 | }
24 | }
25 |
26 |
27 | export default FilterButton
28 |
--------------------------------------------------------------------------------
/app/components/FilterButton/gaps.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import DropdownButton from '../DropdownButton'
3 | import { gapsFilters } from '../../settings/options'
4 |
5 |
6 | class GapsFilterButton extends Component {
7 | render() {
8 | var btn = {gapsFilters.find(filter => filter.name === this.props.enabledFilters[0]).title} ▾
9 | return (
10 | ({ id: layer.name, description: layer.title }))}
12 | btnElement={btn}
13 | multiple={false}
14 | selectedKeys={this.props.enabledFilters}
15 | onSelectionChange={::this.handleDropdownChanges}
16 | />
17 | )
18 | }
19 |
20 | handleDropdownChanges(selectedFilters) {
21 | var enabledFilters = this.props.enabledFilters
22 | selectedFilters.filter(filter => enabledFilters.indexOf(filter) === -1).map(this.props.enableFilter)
23 | enabledFilters.filter(filter => selectedFilters.indexOf(filter) === -1).map(this.props.disableFilter)
24 | }
25 | }
26 |
27 |
28 | export default GapsFilterButton
29 |
--------------------------------------------------------------------------------
/app/components/UnitSelector/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import DropdownButton from '../DropdownButton'
3 |
4 | import unitSystems from '../../settings/unitSystems'
5 |
6 |
7 | class UnitSelector extends Component {
8 | render() {
9 | var options = Object.keys(unitSystems).map(unitSystem => ({
10 | id: unitSystem,
11 | description: unitSystems[unitSystem][this.props.unit].descriptor
12 | })).filter(u => u.id !== 'Atoms')
13 | var unit = unitSystems[this.props.unitSystem][this.props.unit]
14 | var btn = this.handleDropdownChanges(['Atoms'])}>{unit.descriptor+this.props.suffix} ▾
15 | return (
16 |
24 | )
25 | }
26 |
27 | handleDropdownChanges(selectedKeys) {
28 | this.props.setUnitSystem(selectedKeys[0])
29 | }
30 | }
31 |
32 | export default UnitSelector
33 |
--------------------------------------------------------------------------------
/app/components/Header/button.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import themes from '../../settings/themes'
3 | import cx from 'classnames'
4 |
5 | class Button extends Component {
6 | constructor (props) {
7 | super(props)
8 | this.setHover = this.setHover.bind(this)
9 | this.state = {
10 | hover: false,
11 | active: false
12 | }
13 | }
14 |
15 | setHover (hover) {
16 | this.setState({ hover })
17 | }
18 |
19 | render () {
20 | const { children, disabled, active, theme, className, ...rest } = this.props
21 | const { hover } = this.state
22 | const styles = themes[theme].buttons
23 |
24 | const style = disabled
25 | ? styles.button
26 | : hover
27 | ? styles.hover
28 | : active ? styles.active : styles.button
29 |
30 | return (
31 | this.setHover(true)}
35 | onMouseOut={() => this.setHover(false)}
36 | {...rest}
37 | >
38 | {children}
39 |
40 | )
41 | }
42 | }
43 |
44 | export default Button
45 |
--------------------------------------------------------------------------------
/app/components/CompareBar/style.css:
--------------------------------------------------------------------------------
1 | div#compare {
2 | display: block;
3 | position: absolute;
4 | bottom: 0;
5 | left: 0;
6 | right: 0;
7 | height: 212px;
8 |
9 | background: rgba(#344762, 0.8);
10 | background: linear-gradient(to bottom, rgba(#344762,0.8) 0%,rgba(#262323,0.8) 100%);
11 |
12 | ul.metrics {
13 | list-style-type: none;
14 | display: inline-block;
15 | margin: 0;
16 | margin-top: 20px;
17 | margin-left: 50px;
18 | padding: 0;
19 | li {
20 | display: inline-block;
21 | margin-right: 12px;
22 | line-height: 16px;
23 | }
24 | &.after {
25 | position: absolute;
26 | right: 250px;
27 | }
28 | &.before li:first-child {
29 | margin-right: 24px;
30 | border-right: 1px solid #979797;
31 | padding-right: 18px;
32 | height: 36px;
33 | vertical-align: top;
34 | }
35 | &.after li:last-child {
36 | margin-left: 12px;
37 | border-left: 1px solid #979797;
38 | padding-left: 18px;
39 | height: 36px;
40 | vertical-align: top;
41 | }
42 | p {
43 | margin: 0;
44 | color: white;
45 | font-weight: bold;
46 | }
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/app/settings/themes/index.js:
--------------------------------------------------------------------------------
1 | import defaultTheme from './default'
2 | import odriTheme from './opendri'
3 |
4 | const themePrototype = {
5 | getStyle: function(layer) {
6 | if (this.layerStyles[layer.name] !== undefined)
7 | return this.layerStyles[layer.name]
8 | else
9 | return layer.render.defaultStyle || { "raw": {}, "raw-highlight": {}, "aggregated": {}, "aggregated-highlight": {} }
10 | },
11 | getGlLayerStyle: function(layer, glLayer) {
12 | const style = this.getStyle(layer)
13 | var isHighlightLayer = glLayer.id.indexOf('-highlight') !== -1
14 | var isRawLayer = glLayer.id.indexOf('-raw') !== -1
15 | switch (true) {
16 | case !isHighlightLayer && isRawLayer:
17 | return style["raw"]
18 | case isHighlightLayer && isRawLayer:
19 | return style["raw-highlight"]
20 | case !isHighlightLayer && !isRawLayer:
21 | return style["aggregated"]
22 | case isHighlightLayer && !isRawLayer:
23 | return style["aggregated-highlight"]
24 | }
25 | }
26 | }
27 |
28 | Object.setPrototypeOf(defaultTheme, themePrototype)
29 | Object.setPrototypeOf(odriTheme, themePrototype)
30 |
31 | export default {
32 | default: defaultTheme,
33 | opendri: odriTheme
34 | }
35 |
--------------------------------------------------------------------------------
/app/settings/themes/default.js:
--------------------------------------------------------------------------------
1 | export default {
2 | externalLink: {
3 | position: 'absolute',
4 | bottom: '5px',
5 | right: '1rem',
6 | fontSize: '0.8rem'
7 | },
8 | legend: {
9 | bottom: '50px'
10 | },
11 | thresholdSelector: {
12 | bottom: '50px'
13 | },
14 | embedHeader: {
15 | boxShadow: '0px 0px 20px rgba(0, 0, 0, 0.5)'
16 | },
17 | dropDown: {},
18 | dropDownList: {},
19 | dateFrom: {
20 | after: {
21 | },
22 | afterContent: ' ▾'
23 | },
24 | dateTo: {
25 | after: {
26 | },
27 | afterContent: ' ▾'
28 | },
29 | buttons: {
30 | button: {
31 | backgroundColor: '#4B5A6A'
32 | },
33 | hover: {
34 | backgroundColor: '#193047',
35 | },
36 | active: {
37 | boxShadow: 'inset 0 5px 30px #36414D',
38 | }
39 | },
40 | swiper: {
41 | backgroundColor: 'rgba(75,90,106, 0.8)',
42 | borderColor: '#FFFFFF',
43 | poly: {
44 | shape: 'polygon',
45 | color: 'grey',
46 | weight: 1
47 | }
48 | },
49 |
50 | layerStyles: {
51 | builtup: {
52 | "aggregated": {
53 | "line-color": '#666'
54 | },
55 | "aggregated-highlight": {
56 | "line-color": '#666'
57 | }
58 | }
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/app/components/Header/style.css:
--------------------------------------------------------------------------------
1 | header {
2 | position: absolute;
3 | z-index: 1;
4 | top: 0;
5 | left: 0;
6 | right: 0;
7 | height: 52px;
8 | background-color: rgba(#fff, 0.78);
9 | /*rgba(#fff, 0.78);*/
10 | h1 {
11 | display: inline-block;
12 | margin: 0;
13 | padding-left: 40px;
14 | font-size: 20px;
15 | line-height: 52px;
16 | vertical-align: text-bottom;
17 | font-weight: normal;
18 | color: #414141;
19 | .beta {
20 | vertical-align: bottom;
21 | position: relative;
22 | top: -5px;
23 | font-style: italic;
24 | }
25 | }
26 | ul {
27 | display: inline-block;
28 | vertical-align: text-bottom;
29 | margin-bottom: 14px;
30 | padding-left: 40px;
31 | li {
32 | display: inline-block;
33 | list-style-type: none;
34 | & + li {
35 | padding-left: 40px;
36 | }
37 | a.link {
38 | border-bottom: none;
39 | color: #555555;
40 | }
41 | a.link:hover {
42 | display: inline-block;
43 | margin-bottom: -14px;
44 | padding-bottom: 12px;
45 | border-bottom: 2px solid #AAAAAA;
46 | }
47 | a.active, a.active:hover {
48 | display: inline-block;
49 | margin-bottom: -14px;
50 | padding-bottom: 12px;
51 | border-bottom: 2px solid #4B5A6A;
52 | }
53 | }
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/app/index.js:
--------------------------------------------------------------------------------
1 | import polyfill from './polyfill'
2 |
3 | import { Router, Route, IndexRoute, useRouterHistory } from 'react-router'
4 | import { createHashHistory } from 'history'
5 | import { syncHistoryWithStore } from 'react-router-redux'
6 | import { Provider } from 'react-redux'
7 | import ReactDOM from 'react-dom'
8 | import React from 'react'
9 |
10 | import App from './containers/App'
11 | import Gaps from './containers/Gaps'
12 | import AboutPage from './containers/AboutPage'
13 | import configure from './store'
14 |
15 | const store = configure()
16 | const history = syncHistoryWithStore(useRouterHistory(createHashHistory)({ queryKey: false }), store)
17 |
18 | var routes = (
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 | )
32 |
33 | ReactDOM.render(
34 |
35 |
36 | ,
37 | document.getElementById('root')
38 | )
39 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | Copyright (c) 2016, Humanitarian OpenStreetMap Team
2 | All rights reserved.
3 |
4 | Redistribution and use in source and binary forms, with or without
5 | modification, are permitted provided that the following conditions are met:
6 |
7 | * Redistributions of source code must retain the above copyright notice, this
8 | list of conditions and the following disclaimer.
9 |
10 | * Redistributions in binary form must reproduce the above copyright notice,
11 | this list of conditions and the following disclaimer in the documentation
12 | and/or other materials provided with the distribution.
13 |
14 | * Neither the name of copyright holder nor the names of its
15 | contributors may be used to endorse or promote products derived from
16 | this software without specific prior written permission.
17 |
18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
19 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
20 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
22 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
26 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
28 |
--------------------------------------------------------------------------------
/app/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | OSM Analytics Tool
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
--------------------------------------------------------------------------------
/app/components/Map/swiper.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 |
3 | class Swiper extends Component {
4 | render() {
5 | const { swiper } = this.props.theme
6 | return (
7 |
10 | )
11 | }
12 |
13 | componentDidMount() {
14 | document.addEventListener('mousemove', ::this._onMove)
15 | document.addEventListener('touchmove', ::this._onMove)
16 | document.addEventListener('mouseup', ::this._onUp)
17 | document.addEventListener('touchend', ::this._onUp)
18 | this._setPosition(window.innerWidth/2)
19 | }
20 |
21 | state = {
22 | style: {},
23 | active: false
24 | }
25 |
26 | _onDown(e) {
27 | this.setState({ active: true })
28 | }
29 | _onUp() {
30 | this.setState({ active: false })
31 | }
32 | _onMove(e) {
33 | if (!this.state.active) return
34 | e.preventDefault()
35 | this._setPosition(this._getX(e))
36 | }
37 | _setPosition(x) {
38 | var pos = 'translate(' + x + 'px, 0)'
39 | this.setState({
40 | style: {
41 | transform: pos,
42 | WebkitTransform: pos,
43 | MsTransform: pos
44 | }
45 | })
46 | this.props.onMoved(x)
47 | }
48 | _getX(e) {
49 | e = e.touches ? e.touches[0] : e
50 | return e.clientX
51 | //var x = e.clientX - this._bounds.left;
52 | //if (x < 0) x = 0;
53 | //if (x > this._bounds.width) x = this._bounds.width;
54 | //return x;
55 | }
56 | }
57 |
58 | export default Swiper
59 |
--------------------------------------------------------------------------------
/app/polyfill.js:
--------------------------------------------------------------------------------
1 | import PromisePolyfill from 'promise-polyfill'
2 |
3 | if (!window.Promise) {
4 | window.Promise = PromisePolyfill;
5 | }
6 |
7 | // https://tc39.github.io/ecma262/#sec-array.prototype.find
8 | if (!Array.prototype.find) {
9 | Object.defineProperty(Array.prototype, 'find', {
10 | value: function(predicate) {
11 | // 1. Let O be ? ToObject(this value).
12 | if (this == null) {
13 | throw new TypeError('"this" is null or not defined');
14 | }
15 |
16 | var o = Object(this);
17 |
18 | // 2. Let len be ? ToLength(? Get(O, "length")).
19 | var len = o.length >>> 0;
20 |
21 | // 3. If IsCallable(predicate) is false, throw a TypeError exception.
22 | if (typeof predicate !== 'function') {
23 | throw new TypeError('predicate must be a function');
24 | }
25 |
26 | // 4. If thisArg was supplied, let T be thisArg; else let T be undefined.
27 | var thisArg = arguments[1];
28 |
29 | // 5. Let k be 0.
30 | var k = 0;
31 |
32 | // 6. Repeat, while k < len
33 | while (k < len) {
34 | // a. Let Pk be ! ToString(k).
35 | // b. Let kValue be ? Get(O, Pk).
36 | // c. Let testResult be ToBoolean(? Call(predicate, T, « kValue, k, O »)).
37 | // d. If testResult is true, return kValue.
38 | var kValue = o[k];
39 | if (predicate.call(thisArg, kValue, k, o)) {
40 | return kValue;
41 | }
42 | // e. Increase k by 1.
43 | k++;
44 | }
45 |
46 | // 7. Return undefined.
47 | return undefined;
48 | },
49 | configurable: true,
50 | writable: true
51 | });
52 | }
53 |
--------------------------------------------------------------------------------
/Jenkinsfile:
--------------------------------------------------------------------------------
1 | #!groovy
2 |
3 | node {
4 |
5 | currentBuild.result = "SUCCESS"
6 | // checkout sources
7 | checkout scm
8 |
9 | try {
10 |
11 | stage('Deploy') {
12 |
13 | if (env.BRANCH_NAME == "master") {
14 | sshagent (credentials: ['osma_staging']) {
15 | sh 'ssh ubuntu@${OSMA_STAGING} "cd /home/ubuntu/projects/osm-analytics && ./start.sh"'
16 | }
17 | } else {
18 |
19 | }
20 | notifySuccessful()
21 | }
22 |
23 | } catch (err) {
24 |
25 | currentBuild.result = "FAILURE"
26 | notifyFailed()
27 | throw err
28 | }
29 | }
30 |
31 | def notifySuccessful() {
32 | slackSend (color: '#00FF00', channel: '#osma', message: "SUCCESSFUL: Job '${env.JOB_NAME} [${env.BUILD_NUMBER}]' (${env.BUILD_URL})")
33 | emailext (
34 | subject: "SUCCESSFUL: Job '${env.JOB_NAME} [${env.BUILD_NUMBER}]'",
35 | body: """SUCCESSFUL: Job '${env.JOB_NAME} [${env.BUILD_NUMBER}]':
36 | Check console output at "${env.JOB_NAME} [${env.BUILD_NUMBER}] "
""",
37 | recipientProviders: [[$class: 'DevelopersRecipientProvider']]
38 | )
39 | }
40 |
41 | def notifyFailed() {
42 | slackSend (color: '#FF0000', channel: '#osma', message: "FAILED: Job '${env.JOB_NAME} [${env.BUILD_NUMBER}]' (${env.BUILD_URL})")
43 |
44 | emailext (
45 | subject: "FAILED: Job '${env.JOB_NAME} [${env.BUILD_NUMBER}]'",
46 | body: """FAILED: Job '${env.JOB_NAME} [${env.BUILD_NUMBER}]':
47 | Check console output at "${env.JOB_NAME} [${env.BUILD_NUMBER}] "
""",
48 | recipientProviders: [[$class: 'DevelopersRecipientProvider']]
49 | )
50 | }
51 |
--------------------------------------------------------------------------------
/app/components/Legend/style.css:
--------------------------------------------------------------------------------
1 | #legend {
2 | display: block;
3 | position: absolute;
4 | left: 25px;
5 | bottom: 20px;
6 | margin: 0;
7 | padding: 18px 24px;
8 | list-style-type: none;
9 | background-color: rgba(#fff, 0.6);
10 | border-radius: 4px;
11 | pointer-events: none;
12 |
13 | li {
14 | display: block;
15 | line-height: 18px;
16 | font-size: 12px;
17 | h3 {
18 | display: inline-block;
19 | margin: 0;
20 | font-size: 14px;
21 | line-height: 22px;
22 | margin-bottom: 6px;
23 | }
24 | h3, span, a, p {
25 | pointer-events: all;
26 | }
27 | p {
28 | max-width: 180px;
29 | }
30 | &:last-child {
31 | margin-top: 6px;
32 | }
33 |
34 | span.legend-icon {
35 | display: inline-block;
36 | box-sizing: border-box;
37 | width: 8px;
38 | height: 8px;
39 | margin-right: 4px;
40 |
41 | &.high {
42 | opacity: 1.0;
43 | }
44 | &.mid {
45 | opacity: 0.55;
46 | }
47 | &.low {
48 | opacity: 0.1;
49 | }
50 |
51 | &.fill.feature {
52 | border-style: solid;
53 | border-width: 1px;
54 | transform: translateX(-2px) rotate(24deg);
55 | }
56 |
57 | &.line.feature {
58 | background-color: transparent;
59 | border-style: solid;
60 | border-width: 2px;
61 | border-top-width: 0;
62 | border-left-width: 0;
63 | transform: translateX(-2px) rotate(-20deg);
64 | }
65 |
66 | &.circle.feature {
67 | border-radius: 4px;
68 | }
69 |
70 | &.builtup.feature {
71 | border-style: solid;
72 | border-width: 1px;
73 | }
74 | }
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/app/components/Stats/subTagsModal.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import Modal from 'react-modal'
3 | import * as request from 'superagent'
4 | import { queue } from 'd3-queue'
5 | import { parse, DOM } from 'xml-parse'
6 |
7 | const initialHowMany = 10
8 |
9 | class SubTagsModal extends Component {
10 | state = {
11 | howMany: initialHowMany,
12 | loading: false
13 | }
14 |
15 | userNames = {}
16 |
17 | render() {
18 | const total = this.props.subTags.reduce((prev, subTag) => prev + subTag.count, 0)
19 | return (
20 |
25 | Top {Math.min(this.state.howMany, this.props.subTags.length)} Tags
26 | x
27 |
28 | {this.props.subTags.slice(0,this.state.howMany).map(subTag =>
29 |
30 | {this.props.tagKey}={subTag.subTag}
31 | {Math.round(subTag.count/total*100) || '<1'}%
32 |
33 | )}
34 | {this.props.subTags.length > this.state.howMany
35 | ? show more
36 | : ''}
37 |
38 |
39 |
40 | )
41 | }
42 |
43 | componentWillReceiveProps(nextProps) {
44 | if (!nextProps.isOpen) return
45 | this.setState({ howMany: initialHowMany })
46 | }
47 |
48 | expand() {
49 | this.setState({
50 | howMany: this.state.howMany + initialHowMany
51 | })
52 | }
53 | }
54 |
55 | export default SubTagsModal
56 |
--------------------------------------------------------------------------------
/app/components/DropdownButton/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import style from './style.css'
3 | import themes from '../../settings/themes'
4 | import Dropdown from 'rc-dropdown'
5 | import Menu, { Item as MenuItem, Divider } from 'rc-menu'
6 |
7 | class DropdownButton extends Component {
8 | state = {
9 | visible: false
10 | }
11 |
12 | render() {
13 | const { theme } = this.props
14 | const menu = (
15 |
24 | {this.props.options.map(option => (
25 | option.type === 'divider'
26 | ?
27 | : {option.description}
28 | ))}
29 |
30 | )
31 | return (
32 |
39 | {this.props.btnElement}
40 |
41 | )
42 | }
43 | }
44 |
45 | function onVisibleChange(visible) {
46 | this.setState({ visible })
47 | }
48 |
49 | function onClick({ selectedKeys }) {
50 | if (!this.props.multiple) {
51 | this.setState({
52 | visible: false
53 | })
54 | }
55 | }
56 |
57 | function onSelect({ selectedKeys }) {
58 | this.state
59 | this.props.onSelectionChange(selectedKeys)
60 | }
61 |
62 | export default DropdownButton
63 |
--------------------------------------------------------------------------------
/app/components/Legend/gaps.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import { Link } from 'react-router'
3 | import * as request from 'superagent'
4 | import moment from 'moment'
5 | import style from './style.css'
6 | import settings from '../../settings/settings'
7 | import themes from '../../settings/themes'
8 | import { gapsFilters } from '../../settings/options'
9 | import { colorScheme } from '../Map/gapsLayer'
10 |
11 | class Legend extends Component {
12 | state = {}
13 |
14 | render() {
15 | const { featureType, theme } = this.props
16 | const layer = gapsFilters.find(filter => filter.name === featureType)
17 | return (
18 |
34 | )
35 | }
36 |
37 | componentDidMount() {
38 | this.updateLastModified(this.props.featureType)
39 | }
40 | componentWillReceiveProps(nextProps) {
41 | if (nextProps.featureType !== this.props.featureType) {
42 | this.updateLastModified(nextProps.featureType)
43 | }
44 | }
45 |
46 | updateLastModified(featureType) {
47 | const layer = gapsFilters.find(filter => filter.name === featureType)
48 | request.head(settings['vt-source']+'/'+layer.layers.osm+'/0/0/0.pbf').end((err, res) => {
49 | if (!err) this.setState({
50 | lastModified: res.headers['last-modified']
51 | })
52 | })
53 | }
54 |
55 | }
56 |
57 | export default Legend
58 |
--------------------------------------------------------------------------------
/app/components/SearchBox/style.css:
--------------------------------------------------------------------------------
1 | div.search {
2 | & > .react-autosuggest {
3 | input.searchbox {
4 | width: 300px;
5 | }
6 |
7 | ul {
8 | box-sizing: border-box;
9 | display: block;
10 | position: absolute;
11 | top: 20px;
12 | background-color: white;
13 | border-radius: 4px;
14 | max-height: 300px;
15 | width: 300px;
16 | overflow-y: scroll;
17 | padding: 0;
18 | margin-top: 20px;
19 | border: 1px solid #BECAD6;
20 | padding-top: 10px;
21 |
22 | &:before {
23 | content: 'Press Enter to search for this region on OSM, or select a HOT project below:';
24 | display: block;
25 | padding: 8px 20px;
26 | color: #544F4F;
27 | font-style: italic;
28 | }
29 |
30 | li {
31 | display: block;
32 | list-style: none;
33 | padding: 10px 20px;
34 | cursor: pointer;
35 | &:hover {
36 | background-color: #F3F5F5;
37 | }
38 | }
39 | }
40 | }
41 |
42 | span.search-icon {
43 | display: block;
44 | position: absolute;
45 | top: 0;
46 | right: 0;
47 | height: 35px;
48 | width: 35px;
49 | background-image: url("../../assets/search.svg");
50 | background-repeat: no-repeat;
51 | background-position: right 12px center;
52 | transform: rotate(0deg);
53 | transform-origin: 15.5px 17.5px;
54 | &.loading {
55 | animation: spin 0.8s infinite linear;
56 | }
57 | &.errored {
58 | animation: shake 0.82s cubic-bezier(.36,.07,.19,.97) both;
59 | }
60 | }
61 | }
62 |
63 |
64 | @keyframes spin {
65 | 0% {transform: rotate(0deg)}
66 | 100% {transform: rotate(360deg)}
67 | }
68 |
69 | @keyframes shake {
70 | 10%, 90% {
71 | transform: translate(-1px, 0);
72 | }
73 | 20%, 80% {
74 | transform: translate(1px, 0);
75 | }
76 | 30%, 50%, 70% {
77 | transform: translate(-2px, 0);
78 | }
79 | 40%, 60% {
80 | transform: translate(2px, 0);
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/app/components/Map/loadVectorTile.js:
--------------------------------------------------------------------------------
1 | import * as request from 'superagent'
2 | import vt from 'vector-tile'
3 | import Protobuf from 'pbf'
4 | import { featureCollection as featurecollection } from 'turf'
5 |
6 | // based on https://github.com/mapbox/mapbox-gl-js/blob/master/js/source/worker.js
7 |
8 | export default loadTile
9 |
10 | function loadTile(url, layerName, tile, callback) {
11 | url = url
12 | .replace('{z}', tile.z)
13 | .replace('{x}', tile.x)
14 | .replace('{y}', tile.y)
15 |
16 | getArrayBuffer(url, function done(err, data) {
17 | if (err) return callback(err)
18 | if (data === null) return callback(null, featurecollection([]))
19 | data = new vt.VectorTile(new Protobuf(new Uint8Array(data)))
20 | parseTile(data, layerName, tile, callback)
21 | })
22 | }
23 |
24 | function parseTile(data, layerName, tile, callback) {
25 | const layer = data.layers[layerName]
26 | var features = []
27 | if (layer) {
28 | for (let i=0; i obj
39 | request.parse['application/octet-stream'] = obj => obj
40 |
41 | /* eslint-disable indent */
42 | request.get(url)
43 | .on('request', function () {
44 | // todo: needed?
45 | // todo: check browser compat?? xhr2??? see https://github.com/visionmedia/superagent/pull/393 + https://github.com/visionmedia/superagent/pull/566
46 | this.xhr.responseType = 'arraybuffer' // or blob
47 | })
48 | .end(function(err,res) {
49 | // now res.body is an arraybuffer or a blob
50 | if (!err && res.status >= 200 && res.status < 300) {
51 | callback(null, res.body)
52 | } else if (res && res.status === 404) {
53 | callback(null, null)
54 | } else {
55 | callback(err || new Error(res.status))
56 | }
57 | });
58 | /* eslint-enable indent */
59 | }
60 |
--------------------------------------------------------------------------------
/app/assets/checkbox-off.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
18 |
20 |
38 |
40 |
41 |
43 | image/svg+xml
44 |
46 |
47 |
48 |
49 |
50 |
55 |
61 |
62 |
63 |
--------------------------------------------------------------------------------
/app/settings/themes/templates/builtup.js:
--------------------------------------------------------------------------------
1 | export default ({ aggregatedFill, highlightFill, outline }) => [
2 | {
3 | id: 'builtup-raw',
4 | paint: {
5 | 'fill-color': aggregatedFill,
6 | 'fill-opacity': 1,
7 | 'fill-outline-color': outline
8 | }
9 | },
10 | {
11 | id: 'builtup-aggregated-0',
12 | paint: {
13 | 'fill-color': aggregatedFill,
14 | 'fill-antialias': false,
15 | 'fill-opacity': {
16 | base: 1,
17 | stops: [[10, 0.1], [13, 1.0]]
18 | }
19 | }
20 | },
21 | {
22 | id: 'builtup-aggregated-1',
23 | paint: {
24 | 'fill-color': aggregatedFill,
25 | 'fill-antialias': false,
26 | 'fill-opacity': {
27 | base: 1,
28 | stops: [[8, 0.1], [11, 1.0], [13, 1.0]]
29 | }
30 | }
31 | },
32 | {
33 | id: 'builtup-aggregated-2',
34 | paint: {
35 | 'fill-color': aggregatedFill,
36 | 'fill-antialias': false,
37 | 'fill-opacity': {
38 | base: 1,
39 | stops: [[6, 0.1], [9, 1.0], [13, 1.0]]
40 | }
41 | }
42 | },
43 | {
44 | id: 'builtup-aggregated-3',
45 | paint: {
46 | 'fill-color': aggregatedFill,
47 | 'fill-antialias': false,
48 | 'fill-opacity': {
49 | base: 1,
50 | stops: [[4, 0.1], [7, 1.0], [13, 1.0]]
51 | }
52 | }
53 | },
54 | {
55 | id: 'builtup-aggregated-4',
56 | paint: {
57 | 'fill-color': aggregatedFill,
58 | 'fill-antialias': false,
59 | 'fill-opacity': {
60 | base: 1,
61 | stops: [[2, 0.1], [5, 1.0], [13, 1.0]]
62 | }
63 | }
64 | },
65 | {
66 | id: 'builtup-aggregated-5',
67 | paint: {
68 | 'fill-color': aggregatedFill,
69 | 'fill-antialias': false,
70 | 'fill-opacity': {
71 | base: 1,
72 | stops: [[0, 0.1], [3, 1.0], [13, 1.0]]
73 | }
74 | }
75 | },
76 | {
77 | id: 'builtup-aggregated-6',
78 | paint: {
79 | 'fill-color': aggregatedFill,
80 | 'fill-antialias': false,
81 | 'fill-opacity': {
82 | base: 1,
83 | stops: [[0, 1.0], [13, 1.0]]
84 | }
85 | }
86 | }
87 | ]
88 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | var precss = require('precss')
2 | var rucksack = require('rucksack-css')
3 | var webpack = require('webpack')
4 | var path = require('path')
5 | var ExtractTextPlugin = require("extract-text-webpack-plugin");
6 |
7 | module.exports = {
8 | context: path.join(__dirname, './app'),
9 | entry: {
10 | jsx: './index.js',
11 | html: './index.html',
12 | vendor: ['react']
13 | },
14 | output: {
15 | path: path.join(__dirname, './static'),
16 | filename: 'bundle.js',
17 | },
18 | module: {
19 | loaders: [
20 | {
21 | test: /\.html$/,
22 | loader: 'file?name=[name].[ext]'
23 | },
24 | {
25 | test: /\.css$/,
26 | include: /app/,
27 | //loader: ExtractTextPlugin.extract('style-loader', 'css-loader!postcss-loader')
28 | loaders: [
29 | 'style-loader',
30 | //'css-loader?sourceMap',
31 | 'css-loader',
32 | 'postcss-loader'
33 | ]
34 | },
35 | {
36 | test: /\.(js|jsx)$/,
37 | exclude: /node_modules|libs/,
38 | loaders: [
39 | 'react-hot',
40 | 'babel-loader'
41 | ]
42 | },
43 | {
44 | test: /\.(png|jpg|svg)$/,
45 | loader: 'file-loader?name=assets/[name].[ext]'
46 | },
47 | {
48 | test: /\.(json)$/,
49 | loader: 'json-loader'
50 | },
51 | ],
52 | },
53 | resolve: {
54 | extensions: ['', '.js', '.jsx']
55 | },
56 | postcss: [
57 | precss(),
58 | rucksack({
59 | autoprefixer: true
60 | })
61 | ],
62 | plugins: [
63 | new webpack.optimize.CommonsChunkPlugin('vendor', 'vendor.bundle.js'),
64 | new webpack.DefinePlugin({
65 | 'process.env': { NODE_ENV: JSON.stringify(process.env.NODE_ENV || 'development') }
66 | }),
67 | new webpack.DefinePlugin({
68 | "global.GENTLY": false // https://github.com/visionmedia/superagent/wiki/Superagent-for-Webpack
69 | }),
70 | //new ExtractTextPlugin("style.css")
71 | ],
72 | devServer: {
73 | contentBase: './client',
74 | hot: true
75 | },
76 | node: {
77 | __dirname: true,
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/app/components/Stats/searchBuiltupAreas.js:
--------------------------------------------------------------------------------
1 | import { area, bbox as extent, intersect, bboxPolygon, featureCollection as featurecollection, centroid, lineDistance, within } from 'turf'
2 | import Sphericalmercator from 'sphericalmercator'
3 | import { queue } from 'd3-queue'
4 | import loadTile from '../Map/loadVectorTile.js'
5 | import { getRegionZoom, getRegionTiles } from './searchFeatures.js'
6 | import settings from '../../settings/settings'
7 |
8 | var merc = new Sphericalmercator({size: 512})
9 |
10 | var cache = {} // todo: cache invalidation
11 |
12 | function fetch(region, callback) {
13 | const zoom = Math.min(12, getRegionZoom(region))
14 | const tiles = getRegionTiles(region, zoom)
15 | const cachePage = 'builtup'
16 | if (!cache[cachePage]) cache[cachePage] = {}
17 | const toLoad = tiles.filter(tile => cache[cachePage][tile.hash] === undefined)
18 | var q = queue(4) // max 4 concurrently loading tiles in queue
19 | toLoad.forEach(tile => q.defer(getAndCacheTile, tile))
20 | q.awaitAll(function(err) {
21 | if (err) return callback(err)
22 | // return matching features
23 | var output = []
24 | const regionFc = featurecollection([region])
25 | tiles.forEach(tile => {
26 | output = output.concat(
27 | within(cache[cachePage][tile.hash], regionFc).features
28 | )
29 | })
30 | // todo: handle tile boundaries / split features (merge features with same osm id)
31 | callback(null, featurecollection(output))
32 | })
33 | }
34 |
35 | function getAndCacheTile(tile, callback) {
36 | const cachePage = 'builtup'
37 | loadTile(settings['vt-gaps-source']+'/{z}/{x}/{y}.pbf', 'buildup', tile, function(err, data) {
38 | if (err) return callback(err)
39 | // convert features to centroids, store tile data in cache
40 | data.features = data.features.map(feature => {
41 | var centr = centroid(feature)
42 | centr.properties = feature.properties
43 | centr.properties.tile = tile
44 | centr.properties.area = centr.properties.area || area(feature.geometry)
45 | return centr
46 | })
47 | cache[cachePage][tile.hash] = data
48 | callback(null) // don't return any actual data as it is available via the cache already
49 | })
50 | }
51 |
52 | export default fetch
53 |
--------------------------------------------------------------------------------
/app/assets/overlays.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
18 |
20 |
38 |
40 |
41 |
43 | image/svg+xml
44 |
46 |
47 |
48 |
49 |
50 |
55 |
58 |
64 |
65 |
66 |
67 |
--------------------------------------------------------------------------------
/app/assets/search-clear.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
18 |
20 |
38 |
40 |
41 |
43 | image/svg+xml
44 |
46 |
47 |
48 |
49 |
50 |
55 |
59 |
63 |
64 |
65 |
66 |
--------------------------------------------------------------------------------
/app/assets/checkbox-on.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
18 |
20 |
38 |
40 |
41 |
43 | image/svg+xml
44 |
46 |
47 |
48 |
49 |
50 |
55 |
61 |
64 |
66 |
72 |
73 |
74 |
75 |
76 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "osm-analytics",
3 | "version": "0.1.0",
4 | "description": "OSM data analysis tool, user interface",
5 | "main": "index.js",
6 | "scripts": {
7 | "test": "npm run lint",
8 | "start": "webpack-dev-server -d --history-api-fallback --hot --inline --progress --colors --port 3000",
9 | "build": "NODE_ENV=production webpack --progress --colors",
10 | "lint": "eslint -c .eslintrc.js --ext .jsx,.js app"
11 | },
12 | "repository": {
13 | "type": "git",
14 | "url": "git+https://github.com/GFDRR/osm-analytics.git"
15 | },
16 | "license": "BSD-3-Clause",
17 | "dependencies": {
18 | "babel-core": "^6.10.4",
19 | "babel-eslint": "^7.2.3",
20 | "babel-loader": "^6.2.3",
21 | "babel-plugin-transform-runtime": "^6.5.2",
22 | "babel-preset-es2015": "^6.5.0",
23 | "babel-preset-react": "^6.5.0",
24 | "babel-preset-stage-0": "^6.5.0",
25 | "babel-runtime": "^6.5.0",
26 | "classnames": "^2.2.3",
27 | "colorbrewer": "^1.3.0",
28 | "css-loader": "^0.23.1",
29 | "d3-queue": "^2.0.3",
30 | "eslint": "^4.18.2",
31 | "eslint-plugin-react": "^7.1.0",
32 | "extract-text-webpack-plugin": "^1.0.1",
33 | "file-loader": "^0.8.5",
34 | "history": "^2.1.2",
35 | "javascript.util": "^0.12.12",
36 | "json-loader": "^0.5.4",
37 | "lineclip": "^1.1.5",
38 | "lodash": ">=4.17.21",
39 | "moment": "^2.12.0",
40 | "ms": "^2.1.1",
41 | "pbf": "^1.3.5",
42 | "polyline": "^0.2.0",
43 | "postcss-loader": "^0.8.1",
44 | "postcss-nested": "^1.0.0",
45 | "precss": "^1.4.0",
46 | "promise-polyfill": "8.1.0",
47 | "rc-dropdown": "^1.4.7",
48 | "rc-menu": "^4.10.8",
49 | "react": "^0.14.8",
50 | "react-autocomplete": "^0.2.1",
51 | "react-autosuggest": "2.1.0",
52 | "react-dom": "^0.14.7",
53 | "react-hot-loader": "^1.3.0",
54 | "react-modal": "^0.6.1",
55 | "react-redux": "^4.4.0",
56 | "react-router": "^2.0.0",
57 | "react-router-redux": "^4.0.0",
58 | "redux": "^3.3.1",
59 | "redux-actions": "^0.9.1",
60 | "rucksack-css": "^0.8.5",
61 | "sphericalmercator": "^1.0.4",
62 | "style-loader": "^0.13.0",
63 | "superagent": "^3.7.0",
64 | "superagent-promise-plugin": "^3.1.0",
65 | "turf": "^3.0.14",
66 | "vector-tile": "^1.2.0",
67 | "vega": "^2.5.2",
68 | "webpack": "^1.12.14",
69 | "webpack-hot-middleware": "^2.7.1",
70 | "xml-parse": "^0.3.0"
71 | },
72 | "devDependencies": {
73 | "webpack-dev-server": "~1.14.1"
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/app/assets/search.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
18 |
20 |
38 |
40 |
41 |
43 | image/svg+xml
44 |
46 |
47 |
48 |
49 |
50 |
55 |
58 |
60 |
66 |
67 |
68 |
69 |
70 |
--------------------------------------------------------------------------------
/app/settings/options.js:
--------------------------------------------------------------------------------
1 | import settings from './settings'
2 | import * as request from 'superagent'
3 |
4 | export function loadLayers(callback) {
5 | request
6 | //.get(settings['vt-source'] + '/analytics.json')
7 | .get('https://raw.githubusercontent.com/hotosm/osm-analytics-config/master/analytics.json')
8 | .end(function(err, res) {
9 | if (err) return callback(err)
10 | //callback(null, res.body.layers)
11 | callback(null, JSON.parse(res.text).layers)
12 | })
13 | }
14 |
15 | export const gapsFilters = [
16 | {
17 | name: 'buildings-vs-ghs',
18 | title: 'Buildings vs. Built-up',
19 | description: 'OpenStreetMap buildings compared to data of built-up areas from "Global Human Settlement Layer".',
20 | layers: {
21 | osm: 'buildings',
22 | reference: 'ghs-pop'
23 | }
24 | }
25 | ]
26 |
27 | export const overlays = [
28 | {
29 | id: 'recency',
30 | description: 'Recency of Edits'
31 | },
32 | {
33 | id: 'experience',
34 | description: 'Editor Level of Experience'
35 | },
36 | ]
37 |
38 | export const compareTimes = [
39 | { id: '2007', timestamp: new Date('2007-01-01'), layers: ['buildings', 'highways', 'waterways', 'amenities'] },
40 | { id: '2008', timestamp: new Date('2008-01-01'), layers: ['buildings', 'highways', 'waterways', 'amenities'] },
41 | { id: '2009', timestamp: new Date('2009-01-01'), layers: ['buildings', 'highways', 'waterways', 'amenities'] },
42 | { id: '2010', timestamp: new Date('2010-01-01'), layers: ['buildings', 'highways', 'waterways', 'amenities'] },
43 | { id: '2011', timestamp: new Date('2011-01-01'), layers: ['buildings', 'highways', 'waterways', 'amenities'] },
44 | { id: '2012', timestamp: new Date('2012-01-01'), layers: ['buildings', 'highways', 'waterways', 'amenities'] },
45 | { id: '2013', timestamp: new Date('2013-01-01'), layers: ['buildings', 'highways', 'waterways', 'amenities'] },
46 | { id: '2014', timestamp: new Date('2014-01-01'), layers: ['buildings', 'highways', 'waterways', 'amenities'] },
47 | { id: '2015', timestamp: new Date('2015-01-01'), layers: ['buildings', 'highways', 'waterways', 'amenities'] },
48 | { id: '2016', timestamp: new Date('2016-01-01'), layers: ['buildings', 'highways', 'waterways', 'amenities'] },
49 | { id: '2017', timestamp: new Date('2017-01-01'), layers: ['buildings', 'highways', 'waterways', 'amenities'] },
50 | { id: '2018', timestamp: new Date('2018-01-01'), layers: ['buildings', 'highways', 'waterways', 'amenities'] },
51 | { id: '2019', timestamp: new Date('2018-12-13'), layers: ['buildings', 'highways', 'waterways', 'amenities'] },
52 | { id: 'now', timestamp: new Date() }
53 | ]
54 |
--------------------------------------------------------------------------------
/app/components/DropdownButton/style.css:
--------------------------------------------------------------------------------
1 | .rc-dropdown {
2 | position: absolute;
3 | left: -9999px;
4 | top: -9999px;
5 | z-index: 10;
6 | display: block;
7 | font-size: 13px;
8 | line-height: 20px;
9 | }
10 | .rc-dropdown-hidden {
11 | display: none;
12 | }
13 | .rc-dropdown-menu {
14 | outline: none;
15 | position: relative;
16 | list-style-type: none;
17 | padding: 6px 18px;
18 | margin: 8px 0 0 0;
19 | text-align: left;
20 | border-radius: 4px;
21 | background-color: white;
22 | background-clip: padding-box;
23 | box-shadow: 0 0 0 1px #BEC9D5;
24 |
25 | & > li {
26 | margin: 0;
27 | padding: 0;
28 | }
29 |
30 | &:before {
31 | content: "";
32 | position: absolute;
33 | display: block;
34 | top: -4px;
35 | left: 6px;
36 | right: 21px;
37 | width: 8px;
38 | height: 8px;
39 | background-color: white;
40 | transform: rotate(45deg);
41 | box-shadow: -1px -1px 0 0 #BEC9D5;
42 | }
43 |
44 | & > .rc-dropdown-menu-item {
45 | position: relative;
46 | display: block;
47 | padding: 8px 0;
48 | clear: both;
49 | white-space: nowrap;
50 | cursor: pointer;
51 |
52 | input {
53 | margin-left: 0;
54 | margin-bottom: 0;
55 | margin-top: 0;
56 | vertical-align: text-bottom;
57 | }
58 | }
59 |
60 | & > .rc-dropdown-menu-item:hover,
61 | & > .rc-dropdown-menu-item-active,
62 | & > .rc-dropdown-menu-item-selected {
63 | color: #4B5A6A;
64 | }
65 |
66 | & > .rc-dropdown-menu-item-disabled {
67 | color: #ccc;
68 | cursor: not-allowed;
69 | pointer-events: none;
70 |
71 | &:hover {
72 | color: #ccc;
73 | cursor: not-allowed;
74 | }
75 | }
76 |
77 | & > .rc-dropdown-menu-item {
78 | &:first-child {
79 | border-top-left-radius: 3px;
80 | border-top-right-radius: 3px;
81 | }
82 | &:last-child {
83 | border-bottom-left-radius: 3px;
84 | border-bottom-right-radius: 3px;
85 | }
86 | }
87 |
88 | & > .rc-dropdown-menu-item-divider {
89 | height: 1px;
90 | margin: 1px 17px;
91 | overflow: hidden;
92 | background-color: #e5e5e5;
93 | line-height: 0;
94 | }
95 | &.checkboxes {
96 | & > .rc-dropdown-menu-item:before {
97 | content: "";
98 | background-image: url(../../assets/checkbox-off.svg);
99 | height: 14px;
100 | width: 14px;
101 | display: inline-block;
102 | vertical-align: baseline;
103 | margin-right: 8px;
104 | margin-bottom: -2px;
105 | }
106 | & > .rc-dropdown-menu-item-selected:before {
107 | width: 15px;
108 | margin-right: 7px;
109 | background-image: url(../../assets/checkbox-on.svg);
110 | }
111 | }
112 | }
113 |
--------------------------------------------------------------------------------
/app/containers/Gaps/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import { connect } from 'react-redux'
3 | import { bindActionCreators } from 'redux'
4 | import * as MapActions from '../../actions/map'
5 | import Header from '../../components/Header'
6 | import GapsMap from '../../components/Map/gaps.js'
7 | import GapsStats from '../../components/Stats/gaps.js'
8 | import { loadLayers } from '../../settings/options'
9 | import themes from '../../settings/themes'
10 | import style from '../App/style.css'
11 |
12 | class Gaps extends Component {
13 | state = {
14 | layersLoaded: false
15 | }
16 |
17 | render() {
18 | const { actions, routeParams, route, embed } = this.props
19 | const theme = routeParams.theme || 'default'
20 | const header = (embed) ? "" :
21 |
22 | if (!this.state.layersLoaded) {
23 | return (
24 |
25 | {header}
26 |
Loading…
27 |
28 | )
29 | }
30 |
31 | // update from route params
32 | if (route.view) {
33 | this.props.actions.setViewFromUrl(route.view)
34 | }
35 | if (routeParams.region) {
36 | this.props.actions.setRegionFromUrl(routeParams.region)
37 | }
38 | if (routeParams.filters) {
39 | this.props.actions.setFiltersFromUrl(routeParams.filters)
40 | }
41 |
42 | return (
43 |
44 | {header}
45 |
53 | {route.view === 'gaps-region' && embed === false ?
: ''}
54 | { embed ?
View on osm-analytics.org : '' }
55 |
56 | )
57 | }
58 |
59 | componentDidMount() {
60 | this.props.actions.setEmbedFromUrl(this.props.routeParams.embed === 'embed')
61 | this.props.actions.setThemeFromUrl(this.props.routeParams.theme)
62 | loadLayers((err, layers) => {
63 | if (err) {
64 | return console.error('unable to load available osm-analytics layers: ', err)
65 | }
66 | this.setState({
67 | layersLoaded: true,
68 | layers
69 | })
70 | })
71 | }
72 | }
73 |
74 | function mapStateToProps(state) {
75 | return {
76 | embed: state.map.embed
77 | }
78 | }
79 |
80 | function mapDispatchToProps(dispatch) {
81 | return {
82 | actions: bindActionCreators(MapActions, dispatch)
83 | }
84 | }
85 |
86 | export default connect(
87 | mapStateToProps,
88 | mapDispatchToProps
89 | )(Gaps)
90 |
--------------------------------------------------------------------------------
/app/settings/themes/opendri.js:
--------------------------------------------------------------------------------
1 | const blue = '#8DCCFD'
2 | const orange = '#FFBA8A'
3 | const UIBlue = '#1477c9'
4 |
5 | const baseButton = {
6 | backgroundColor: 'transparent',
7 | color: UIBlue,
8 | boxShadow: 'none'
9 | }
10 |
11 | export default {
12 | externalLink: {
13 | position: 'absolute',
14 | bottom: '5px',
15 | right: '0',
16 | color: UIBlue,
17 | fontSize: '0.8rem'
18 | },
19 | legend: {
20 | bottom: '50px'
21 | },
22 | thresholdSelector: {
23 | display: 'none'
24 | },
25 | embedHeader: {
26 | padding: '10px 0',
27 | boxShadow: 'none'
28 | },
29 | dateFrom: {
30 | after: {
31 | marginLeft: '6px'
32 | },
33 | afterContent: ' - '
34 | },
35 | dateTo: {
36 | after: {
37 | },
38 | afterContent: ''
39 | },
40 | dropDown: {
41 | color: UIBlue,
42 | textDecoration: 'underline'
43 | },
44 | dropDownList: {
45 | boxShadow: 'initial',
46 | borderRadius: 'initial',
47 | top: '-10px',
48 | border: '1px solid #BEC9D5'
49 | },
50 | buttons: {
51 | button: baseButton,
52 | hover: {
53 | ...baseButton,
54 | textDecoration: 'underline',
55 | },
56 | active: {
57 | ...baseButton,
58 | textDecoration: 'underline'
59 | }
60 | },
61 | swiper: {
62 | backgroundColor: UIBlue,
63 | borderColor: UIBlue,
64 | poly: {
65 | shape: 'polyline',
66 | color: UIBlue,
67 | weight: 2
68 | }
69 | },
70 |
71 | layerStyles: {
72 | buildings: {
73 | "raw": {
74 | "fill-color": blue,
75 | "fill-outline-color": blue
76 | },
77 | "raw-highlight": {
78 | "fill-color": orange,
79 | "fill-outline-color": orange
80 | },
81 | "aggregated": {
82 | "fill-color": blue
83 | },
84 | "aggregated-highlight": {
85 | "fill-color": orange
86 | }
87 | },
88 | highways: {
89 | "raw": {
90 | "line-color": blue
91 | },
92 | "raw-highlight": {
93 | "line-color": blue
94 | },
95 | "aggregated": {
96 | "fill-color": blue
97 | },
98 | "aggregated-highlight": {
99 | "fill-color": orange
100 | }
101 | },
102 | waterways: {
103 | "raw": {
104 | "line-color": blue
105 | },
106 | "raw-highlight": {
107 | "line-color": blue
108 | },
109 | "aggregated": {
110 | "fill-color": blue
111 | },
112 | "aggregated-highlight": {
113 | "fill-color": orange
114 | }
115 | },
116 | builtup: {
117 | "aggregated": {
118 | "fill-color": '#666'
119 | },
120 | "aggregated-highlight": {
121 | "fill-color": '#666'
122 | }
123 | }
124 | }
125 | }
126 |
--------------------------------------------------------------------------------
/app/components/Map/regionToCoords.js:
--------------------------------------------------------------------------------
1 | import { bboxPolygon, polygon, flip, simplify } from 'turf'
2 | import * as request from 'superagent'
3 | import superagentPromisePlugin from 'superagent-promise-plugin'
4 | import 'promise'
5 | import settings from '../../settings/settings'
6 |
7 | export default function regionToCoords(region, latLngOrder) {
8 | var coords
9 | switch (region.type) {
10 | case 'gist':
11 | let gistId = region.id
12 | coords = request
13 | .get('https://api.github.com/gists/'+gistId)
14 | .use(superagentPromisePlugin)
15 | .then(function(res) {
16 | let file = res.body.files['polygon.geojson'] || res.body.files['map.geojson']
17 | if (file === undefined) {
18 | throw new Error('uncompatible gist', gistId)
19 | }
20 | return request
21 | .get(file.raw_url)
22 | .use(superagentPromisePlugin)
23 | .then(function(res) {
24 | let geometry = JSON.parse(res.text)
25 | if (geometry.type === 'FeatureCollection') {
26 | geometry = geometry.features[0];
27 | }
28 | if (geometry.type === 'Feature') {
29 | geometry = geometry.geometry;
30 | }
31 | if (geometry.type === 'MultiPolygon' && geometry.coordinates.length === 1) {
32 | return polygon(geometry.coordinates[0])
33 | } else {
34 | return {
35 | type: 'Feature',
36 | properties: {},
37 | geometry: geometry
38 | }
39 | }
40 | })
41 | }).catch(function(err) {
42 | if (err.status == 404) {
43 | throw new Error('unknown gist', gistId)
44 | } else {
45 | throw err
46 | }
47 | });
48 | break;
49 | case 'hot':
50 | let projectId = region.id
51 | coords = request
52 | .get(settings['tm-api'] + '/projects/'+projectId+'/queries/aoi/')
53 | .use(superagentPromisePlugin)
54 | .then(function(res) {
55 | let geometry = res.body
56 | if (geometry.type === 'MultiPolygon' && geometry.coordinates.length === 1) {
57 | return polygon(geometry.coordinates[0])
58 | } else {
59 | return {
60 | type: 'Feature',
61 | properties: {},
62 | geometry: geometry
63 | }
64 | }
65 | }).catch(function(err) {
66 | if (err.status == 404) {
67 | throw new Error('unknown hot project', projectId)
68 | } else {
69 | throw err
70 | }
71 | });
72 | break;
73 | case 'bbox':
74 | coords = bboxPolygon(region.coords)
75 | break;
76 | case 'polygon':
77 | coords = polygon([region.coords.concat([region.coords[0]])])
78 | break;
79 | default:
80 | throw new Error('unknown region', region)
81 | }
82 | return Promise.resolve(coords).then(function(coords) {
83 | if (latLngOrder) {
84 | return flip(coords)
85 | } else {
86 | return coords
87 | }
88 | })
89 | }
90 |
--------------------------------------------------------------------------------
/app/components/Stats/contributorsModal.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import Modal from 'react-modal'
3 | import * as request from 'superagent'
4 | import { queue } from 'd3-queue'
5 | import { parse, DOM } from 'xml-parse'
6 |
7 | const initialHowMany = 10
8 |
9 | class ContributorsModal extends Component {
10 | state = {
11 | howMany: initialHowMany,
12 | loading: false
13 | }
14 |
15 | userNames = {}
16 |
17 | render() {
18 | const total = this.props.contributors.reduce((prev, contributor) => prev + contributor.contributions, 0)
19 | return (
20 |
25 | Top {Math.min(this.state.howMany, this.props.contributors.length)} Contributors
26 | x
27 |
28 | {this.props.contributors.slice(0,this.state.howMany).map(contributor =>
29 | {this.userNames[contributor.uid]
30 | ? ({this.userNames[contributor.uid]} )
31 | : '#'+contributor.uid}
32 | {Math.round(contributor.contributions/total*100) || '<1'}%
33 |
34 | )}
35 | {this.props.contributors.length > this.state.howMany
36 | ? show more
37 | : ''}
38 |
39 |
40 |
41 | )
42 | }
43 |
44 | componentWillReceiveProps(nextProps) {
45 | if (!nextProps.isOpen) return
46 | this.loadUserNamesFor(nextProps.contributors.slice(0,initialHowMany).map(contributor => contributor.uid))
47 | this.setState({ howMany: initialHowMany })
48 | }
49 |
50 | expand() {
51 | this.loadUserNamesFor(this.props.contributors.slice(this.state.howMany,this.state.howMany+initialHowMany).map(contributor => contributor.uid))
52 | this.setState({
53 | howMany: this.state.howMany + initialHowMany
54 | })
55 | }
56 |
57 | loadUserNamesFor(uids) {
58 | this.setState({ loading: true })
59 | var q = queue()
60 | var uidsToRequest = uids.filter(uid => !this.userNames[uid])
61 |
62 | uidsToRequest.forEach(uid => {
63 | let req = request.get('https://api.openstreetmap.org/api/0.6/user/'+uid)
64 | q.defer(req.end.bind(req))
65 | })
66 | q.awaitAll(function(err, data) {
67 | if (err) {
68 | console.error(err)
69 | } else {
70 | uidsToRequest.forEach((uid, idx) => {
71 | const xmlDOM = new DOM(parse(data[idx].text));
72 | const user = xmlDOM.document.getElementsByTagName('user')[0];
73 | this.userNames[uid] = user.attributes.display_name;
74 | })
75 | }
76 | this.setState({ loading: false })
77 | }.bind(this))
78 | }
79 | }
80 |
81 | export default ContributorsModal
82 |
--------------------------------------------------------------------------------
/app/components/Stats/hotProjectsModal.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import Modal from 'react-modal'
3 | import * as request from 'superagent'
4 | import { queue } from 'd3-queue'
5 | import settings from '../../settings/settings'
6 |
7 | const initialHowMany = 10
8 |
9 | class HotProjectsModal extends Component {
10 | state = {
11 | howMany: initialHowMany,
12 | loading: false
13 | }
14 |
15 | projectProperties = {}
16 |
17 | render() {
18 | const propertiesLoaded = p => this.projectProperties[p.properties.projectId] !== undefined
19 | return (
20 |
25 | HOT Projects{this.state.howMany < this.props.hotProjects.length ? ' ('+this.state.howMany+'/'+this.props.hotProjects.length+')': ''}
26 | x
27 |
42 |
43 | )
44 | }
45 |
46 | componentWillReceiveProps(nextProps) {
47 | if (!nextProps.isOpen) return
48 | this.loadprojectPropertiesFor(nextProps.hotProjects.slice(0,initialHowMany).map(project => project.properties.projectId))
49 | }
50 |
51 | expand() {
52 | this.loadprojectPropertiesFor(this.props.hotProjects.slice(this.state.howMany,this.state.howMany+initialHowMany).map(project => project.properties.projectId))
53 | this.setState({
54 | howMany: this.state.howMany + initialHowMany
55 | })
56 | }
57 |
58 | loadprojectPropertiesFor(projectIds) {
59 | this.setState({ loading: true })
60 | var q = queue()
61 | var projectIdsToRequest = projectIds.filter(projectId => !this.projectProperties[projectId])
62 |
63 | projectIdsToRequest.forEach(projectId => {
64 | let req = request.get(settings['tm-api'] + '/projects/'+projectId+'/queries/summary/')
65 | q.defer(req.end.bind(req))
66 | })
67 | q.awaitAll(function(err, data) {
68 | if (err) {
69 | console.error(err)
70 | } else {
71 | projectIdsToRequest.forEach((projectId, idx) => {
72 | this.projectProperties[projectId] = data[idx].body
73 | })
74 | }
75 | this.setState({ loading: false })
76 | }.bind(this))
77 | }
78 | }
79 |
80 | export default HotProjectsModal
81 |
--------------------------------------------------------------------------------
/app/components/Stats/style.css:
--------------------------------------------------------------------------------
1 | div#stats, div#gaps-stats {
2 | display: block;
3 | position: absolute;
4 | bottom: 0;
5 | left: 0;
6 | right: 0;
7 | stats {
8 | height: 212px;
9 | }
10 | gaps-stats {
11 | height: 74px;
12 | }
13 |
14 | background: rgba(#344762, 0.8);
15 | background: linear-gradient(to bottom, rgba(#344762,0.8) 0%,rgba(#262323,0.8) 100%);
16 |
17 | ul.metrics {
18 | list-style-type: none;
19 | display: inline-block;
20 | margin: 0;
21 | margin-top: 20px;
22 | margin-left: 50px;
23 | padding: 0;
24 | li {
25 | display: inline-block;
26 | margin-right: 12px;
27 | line-height: 16px;
28 | vertical-align: top;
29 | }
30 | p {
31 | margin: 0;
32 | color: white;
33 | font-weight: bold;
34 | cursor: pointer;
35 | }
36 | a {
37 | cursor: pointer;
38 | }
39 | a:hover {
40 | color: #DDDDDD;
41 | }
42 | }
43 |
44 | stats ul.metrics li:first-child {
45 | margin-right: 24px;
46 | border-right: 1px solid #979797;
47 | padding-right: 18px;
48 | height: 36px;
49 | vertical-align: top;
50 | }
51 |
52 | gaps-stats ul.metrics li:nth-child(2) {
53 | margin-right: 24px;
54 | border-right: 1px solid #979797;
55 | padding-right: 18px;
56 | height: 36px;
57 | vertical-align: top;
58 | }
59 | gaps-stats ul.metrics li:nth-child(3) {
60 | text-align: right;
61 | }
62 |
63 | div.chart {
64 | cursor: crosshair;
65 | &.shift {
66 | cursor: grab;
67 | }
68 | &.shift-drag {
69 | cursor: grab;
70 | cursor: grabbing;
71 | }
72 | g.start_marker,
73 | g.end_marker {
74 | cursor: ew-resize;
75 | }
76 | }
77 | }
78 |
79 | div.buttons {
80 | position: absolute;
81 | top: 16px;
82 | right: 25px;
83 | button {
84 | margin-left: 1em;
85 | &.compare-toggle {
86 | background-color: none;
87 | border: 2px solid white;
88 | &:not(.disabled):hover {
89 | background-color: #F6F6F6;
90 | color: #4B5A6A;
91 | }
92 | &:active {
93 | box-shadow: inset 0 5px 30px #DADADA;
94 | }
95 | }
96 | }
97 | }
98 |
99 | ul.hot-projects {
100 | list-style-type: none;
101 | padding: 0;
102 | margin: 0;
103 | max-height: 290px;
104 | overflow-y: auto;
105 | li {
106 | line-height: 30px;
107 | margin-bottom: 10px;
108 | }
109 | }
110 |
111 | ul.contributors, ul.subTags {
112 | list-style-type: none;
113 | padding: 0;
114 | margin: 0;
115 | max-height: 290px;
116 | overflow-y: auto;
117 | position: relative;
118 | li {
119 | span.percentage {
120 | position: absolute;
121 | right: 0;
122 | }
123 | button {
124 | margin-top: 20px;
125 | }
126 | }
127 | }
128 |
129 | .overlays-dropdown > .rc-dropdown-menu:before {
130 | left: 21px;
131 | right: auto;
132 | }
133 |
134 | div.ReactModalPortal {
135 | a.close-link {
136 | cursor: pointer;
137 | position: absolute;
138 | top: 20px;
139 | right: 30px;
140 | font-size: 30px;
141 | color: #BEC9D5;
142 | }
143 |
144 | .updating:after {
145 | border-color: #888888;
146 | border-top-color: transparent;
147 | }
148 | }
149 |
150 | @keyframes circle {
151 | 0% { transform: rotate(0); }
152 | 100% { transform: rotate(360deg); }
153 | }
154 |
--------------------------------------------------------------------------------
/app/components/Legend/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import * as request from 'superagent'
3 | import moment from 'moment'
4 | import style from './style.css'
5 | import settings from '../../settings/settings'
6 | import themes from '../../settings/themes'
7 |
8 | class Legend extends Component {
9 | state = {}
10 |
11 | render() {
12 | const { showHighlighted, layer, theme } = this.props
13 | const styles = themes[theme].getStyle(layer)
14 |
15 | function transformStyle(glStyle) {
16 | var cssStyle = {}
17 | if (glStyle["fill-color"])
18 | cssStyle.backgroundColor = glStyle["fill-color"]
19 | if (glStyle["fill-outline-color"])
20 | cssStyle.borderColor = glStyle["fill-outline-color"]
21 | if (glStyle["line-color"])
22 | cssStyle.borderColor = glStyle["line-color"]
23 | return cssStyle
24 | }
25 |
26 |
27 | const aggregatedStyle = transformStyle(styles["aggregated"])
28 | const rawStyle = transformStyle(styles["raw"])
29 | const highligthStyle = transformStyle(styles["aggregated-highlight"])
30 | const rawHighligthStyle = transformStyle(styles["raw-highlight"])
31 |
32 | var legendEntries = []
33 | if (this.props.zoom > 13) {
34 | legendEntries.push(
35 |
38 | {layer.title}
39 | )
40 | if (showHighlighted === true) {
41 | legendEntries.push(
42 |
45 | Highlighted {layer.title.toLowerCase()}
46 | )
47 | }
48 | } else {
49 | legendEntries.push(
50 |
51 |
52 | High density of {layer.title.toLowerCase()} ,
53 |
54 |
55 | Medium density of {layer.title.toLowerCase()} ,
56 |
57 |
58 | Low density of {layer.title.toLowerCase()}
59 | )
60 | if (showHighlighted === true) {
61 | legendEntries.push(
62 |
63 | Area with mostly highlighted {layer.title.toLowerCase()} )
64 | }
65 | }
66 | return (
67 |
68 | Map Legend
69 | {legendEntries}
70 | Last Data Update: {this.state.lastModified
71 | ? {moment(this.state.lastModified).fromNow()}
72 | : ''
73 | }
74 |
75 | )
76 | }
77 |
78 | componentDidMount() {
79 | this.updateLastModified(this.props.layer.name)
80 | }
81 | componentWillReceiveProps(nextProps) {
82 | if (nextProps.layer.name !== this.props.layer.name) {
83 | this.updateLastModified(nextProps.layer.name)
84 | }
85 | }
86 |
87 | updateLastModified(layerName) {
88 | request.head(settings['vt-source']+'/'+layerName+'/0/0/0.pbf').end((err, res) => {
89 | if (!err) this.setState({
90 | lastModified: res.headers['last-modified']
91 | })
92 | })
93 | }
94 |
95 | }
96 |
97 | export default Legend
98 |
--------------------------------------------------------------------------------
/app/components/About/style.css:
--------------------------------------------------------------------------------
1 | .about {
2 | article {
3 | box-sizing: border-box;
4 | width: 100%;
5 | max-width: 800px;
6 | margin: 0 auto;
7 | padding: 15px 30px;
8 | padding-top: 67px;
9 | }
10 |
11 | article:first-child {
12 | max-width: 100%;
13 | min-height: 500px;
14 | background-color: #C77D70;
15 | background-image: url("../../assets/about-background.jpg");
16 | background-size: cover;
17 | background-position: center;
18 | h1, p {
19 | text-align: center;
20 | }
21 | h1 {
22 | max-width: 450px;
23 | margin-left: auto;
24 | margin-right: auto;
25 | margin-top: 127px;
26 | }
27 |
28 | header {
29 | background-color: transparent;
30 | h1, a.link {
31 | margin-top: 0;
32 | }
33 | }
34 | }
35 |
36 | article:nth-child(2n+0) {
37 | background-color: white;
38 | min-height: 450px;
39 | h2 {
40 | margin-top: 40px;
41 | }
42 | h2, p {
43 | width: 400px;
44 | padding-left: 400px;
45 | }
46 | }
47 |
48 | article:nth-child(2n+3) {
49 | max-width: 100%;
50 | min-height: 450px;
51 | background-color: #F2F5F5;
52 |
53 | h2 {
54 | margin-top: 40px;
55 | }
56 | h2, p {
57 | width: 400px;
58 | padding-right: 400px;
59 | margin-right: auto;
60 | margin-left: auto;
61 | }
62 | img {
63 | position: absolute;
64 | top: 40px;
65 | left: 440px;
66 | }
67 | }
68 |
69 | article:last-child {
70 | max-width: 100%;
71 | background-color: #36414D;
72 | min-height: 250px;
73 | text-align: center;
74 | h2, p {
75 | color: white;
76 | width: 100%;
77 | padding-left:0;
78 | margin-left: auto;
79 | margin-right: auto;
80 | }
81 | }
82 |
83 | article:nth-child(2) {
84 | background-image: url("../../assets/about-timegraph.png");
85 | background-position: 0 167px;
86 | background-repeat: no-repeat;
87 | }
88 | article:nth-child(3) {
89 | position: relative;
90 | &:after {
91 | content: " ";
92 | display: block;
93 | position: absolute;
94 | top: 107px;
95 | left: 50%;
96 | height: 300px;
97 | width: 400px;
98 | background-image: url("../../assets/about-beforeafter.png");
99 | background-position: 40px 0;
100 | background-repeat: no-repeat;
101 | }
102 | }
103 | article:nth-child(4) {
104 | h2, p {
105 | width: 800px;
106 | padding-left: 0;
107 | }
108 | }
109 | article:nth-child(5) {
110 | position: relative;
111 | &:after {
112 | content: " ";
113 | display: block;
114 | position: absolute;
115 | top: 107px;
116 | left: 50%;
117 | height: 300px;
118 | width: 400px;
119 | background-image: url("../../assets/about-stats.png");
120 | background-position: 40px 60px;
121 | background-repeat: no-repeat;
122 | }
123 | }
124 | article:nth-child(6) {
125 | background-image: url("../../assets/about-gridcells.png");
126 | background-position: 0 107px;
127 | background-repeat: no-repeat;
128 | }
129 | }
130 |
131 | footer.about {
132 | box-sizing: border-box;
133 | width: 100%;
134 | min-height: 300px;
135 | margin: 0 auto;
136 | padding: 15px 30px;
137 | padding-top: 67px;
138 | text-align: center;
139 |
140 | background-color: #F2F5F5;
141 |
142 | img {
143 | vertical-align: middle;
144 | margin-right: 10px;
145 | margin-left: 10px;
146 | margin-bottom: 30px;
147 | }
148 | }
149 |
--------------------------------------------------------------------------------
/app/containers/App/style.css:
--------------------------------------------------------------------------------
1 | html,
2 | body {
3 | margin: 0;
4 | padding: 0;
5 | overflow-x: hidden;
6 | }
7 |
8 | body {
9 | font: 16px 'Lato', 'Helvetica Neue', Helvetica, Arial, sans-serif;
10 | line-height: 1.4em;
11 | background: #ffffff;
12 | color: #838383;
13 | -webkit-font-smoothing: antialiased;
14 | -moz-font-smoothing: antialiased;
15 | -ms-font-smoothing: antialiased;
16 | font-smoothing: antialiased;
17 | font-weight: 300;
18 | }
19 |
20 | p {
21 | }
22 |
23 | .number {
24 | font-weight: bold;
25 | color: #DDDDDD;
26 | }
27 | .descriptor {
28 | color: #DDDDDD;
29 | font-size: 12px;
30 | }
31 |
32 | a, a:hover, a:active, a:focus {
33 | color: inherit;
34 | text-decoration: none;
35 | }
36 | a:hover {
37 | color: #4B5A6A
38 | }
39 | a.link {
40 | border-bottom: 1px solid #838383;
41 | &:hover {
42 | border-bottom-color: #4B5A6A;
43 | }
44 | }
45 |
46 | h1, h2, h3 {
47 | font-weight: bold;
48 | color: #505254;
49 | line-height: 1.25em;
50 | }
51 | h1 { font-size: 30px; }
52 | h2 { font-size: 26px; }
53 | h3 { font-size: 18px; }
54 |
55 |
56 | input.searchbox {
57 | box-sizing: border-box;
58 | margin: 0;
59 | padding: 8px 16px;
60 | padding-right: 35px;
61 | vertical-align: baseline;
62 | font-size: 14px;
63 | line-height: 19px;
64 | height: 35px;
65 | color: #838383;
66 | background-color: white;
67 | border-radius: 4px;
68 | border: 1px solid #8EA0B3;
69 | outline: none;
70 | text-overflow: ellipsis;
71 | /* auto-clear-search buttons */
72 | &::-webkit-search-cancel-button{
73 | position:relative;
74 | right:-5px;
75 | -webkit-appearance: none;
76 | height: 20px;
77 | width: 16px;
78 | background-image: url("../../assets/search-clear.svg");
79 | background-position: left center;
80 | background-repeat: no-repeat;
81 | background-color: white;
82 | border-right: 1px solid #BEC9D5;
83 | }
84 | &::-ms-clear{
85 | margin-right:-5px
86 | }
87 | /* placeholder text */
88 | ::-webkit-input-placeholder {
89 | color: #AFAEAE;
90 | }
91 | :-moz-placeholder {
92 | color: #AFAEAE;
93 | opacity: 1;
94 | }
95 | ::-moz-placeholder {
96 | color: #AFAEAE;
97 | opacity: 1;
98 | }
99 | :-ms-input-placeholder {
100 | color: #AFAEAE;
101 | }
102 |
103 | }
104 |
105 | button {
106 | margin: 0;
107 | padding: 8px 16px;
108 | border: 0;
109 | font-size: 100%;
110 | vertical-align: baseline;
111 | font-size: 14px;
112 | line-height: 19px;
113 | height: 35px;
114 | color: white;
115 | background-color: #4B5A6A;
116 | border-radius: 4px;
117 | cursor: pointer;
118 |
119 | &.disabled {
120 | opacity: 0.4;
121 | }
122 | &:not(.disabled):hover {
123 | background-color: #36414D;
124 | }
125 | &:active {
126 | box-shadow: inset 0 5px 30px #193047;
127 | }
128 | }
129 |
130 | button,
131 | input[type="checkbox"] {
132 | outline: none;
133 | }
134 |
135 | .updating {
136 | & > * {
137 | opacity: 0.5;
138 | }
139 | &:after {
140 | content: " ";
141 | display: block;
142 | width: 25px;
143 | height: 25px;
144 | position: absolute;
145 | top: 50%;
146 | left: 50%;
147 | margin-top: -12px;
148 | margin-left: -12px;
149 | border: 2px solid #fff;
150 | border-top-color: transparent;
151 | border-radius: 100%;
152 | animation: circle infinite .75s linear;
153 | }
154 | }
155 |
156 | .leaflet-container a.link {
157 | color: inherit; /* override blue from leaflet stylesheet */
158 | font-size: inherit;
159 | }
160 | .leaflet-popup-content-wrapper {
161 | border-radius: 4px;
162 | }
163 | .leaflet-container a.leaflet-popup-close-button {
164 | text-align: right;
165 | }
166 | .external-link {
167 | &:hover {
168 | text-decoration: underline;
169 | }
170 | }
171 | .main header {
172 | box-shadow: 0px 0px 20px rgba(0,0,0, 0.5);
173 | }
174 |
--------------------------------------------------------------------------------
/app/components/Stats/searchFeatures.js:
--------------------------------------------------------------------------------
1 | import { area, bbox as extent, intersect, bboxPolygon, featureCollection as featurecollection, centroid, lineDistance, within } from 'turf'
2 | import Sphericalmercator from 'sphericalmercator'
3 | import { queue } from 'd3-queue'
4 | import loadTile from '../Map/loadVectorTile.js'
5 | import settings from '../../settings/settings'
6 |
7 | var merc = new Sphericalmercator({size: 512})
8 |
9 | var cache = {} // todo: cache invalidation
10 |
11 | function fetch(region, filter, time /*optional*/, callback) {
12 | if (callback === undefined) {
13 | callback = time
14 | time = undefined
15 | }
16 | const zoom = getRegionZoom(region)
17 | const tiles = getRegionTiles(region, zoom)
18 | const cachePage = (!time || time === 'now') ? filter : time + '/' + filter
19 | if (!cache[cachePage]) cache[cachePage] = {}
20 | const toLoad = tiles.filter(tile => cache[cachePage][tile.hash] === undefined)
21 | var q = queue(4) // max 4 concurrently loading tiles in queue
22 | toLoad.forEach(tile => q.defer(getAndCacheTile, tile, filter, time))
23 | q.awaitAll(function(err) {
24 | if (err) return callback(err)
25 | // return matching features
26 | var output = []
27 | const regionFc = featurecollection([region])
28 | tiles.forEach(tile => {
29 | output = output.concat(
30 | within(cache[cachePage][tile.hash], regionFc).features
31 | )
32 | })
33 | // todo: handle tile boundaries / split features (merge features with same osm id)
34 | callback(null, featurecollection(output))
35 | })
36 | }
37 |
38 | function getRegionZoom(region) {
39 | const maxZoom = 13 // todo: setting "maxZoom"
40 | const tileLimit = 12 // todo: setting "tileLimit"
41 | const regionBounds = extent(region)
42 | for (let z=maxZoom; z>0; z--) {
43 | let tileBounds = merc.xyz(regionBounds, z)
44 | let tilesNum = (1 + tileBounds.maxX - tileBounds.minX) * (1 + tileBounds.maxY - tileBounds.minY)
45 | if (tilesNum <= tileLimit) {
46 | return z
47 | }
48 | }
49 | return 0
50 | }
51 |
52 | function getRegionTiles(region, zoom) {
53 | const regionBounds = extent(region)
54 | var tiles = []
55 | // get all tiles for the regions bbox
56 | var tileBounds = merc.xyz(regionBounds, zoom)
57 | for (let x=tileBounds.minX; x<=tileBounds.maxX; x++) {
58 | for (let y=tileBounds.minY; y<=tileBounds.maxY; y++) {
59 | tiles.push({
60 | x,
61 | y,
62 | z: zoom,
63 | hash: x+'/'+y+'/'+zoom
64 | })
65 | }
66 | }
67 | // drop tiles that are actually outside the region
68 | tiles = tiles.filter(tile => {
69 | const bboxPoly = bboxPolygon(merc.bbox(tile.x, tile.y, tile.z))
70 | try {
71 | return intersect(
72 | bboxPoly,
73 | region
74 | )
75 | } catch(e) {
76 | console.warn(e)
77 | return true
78 | }
79 | })
80 | return tiles
81 | }
82 |
83 | function getAndCacheTile(tile, filter, time, callback) {
84 | const cachePage = (!time || time === 'now') ? filter : time + '/' + filter
85 | var url
86 | if (!time || time === 'now') {
87 | url = settings['vt-source']+'/'+filter+'/{z}/{x}/{y}.pbf'
88 | } else {
89 | url = settings['vt-hist-source']+'/'+time+'/'+filter+'/{z}/{x}/{y}.pbf'
90 | }
91 | loadTile(url, 'osm', tile, function(err, data) {
92 | if (err) return callback(err)
93 | // convert features to centroids, store tile data in cache
94 | data.features = data.features.map(feature => {
95 | var centr = centroid(feature)
96 | centr.properties = feature.properties
97 | centr.properties.tile = tile
98 | centr.properties._length = centr.properties._length ||
99 | centr.properties._lineDistance ||
100 | (feature.geometry.type === "LineString" ? lineDistance(feature, 'kilometers') : 0.0)
101 | return centr
102 | })
103 | cache[cachePage][tile.hash] = data
104 | callback(null) // don't return any actual data as it is available via the cache already
105 | })
106 | }
107 |
108 | export default fetch
109 | export { getRegionZoom, getRegionTiles }
110 |
--------------------------------------------------------------------------------
/app/components/Header/embedHeader.js:
--------------------------------------------------------------------------------
1 |
2 | import React, { Component } from 'react'
3 | import classnames from 'classnames'
4 | import _ from 'lodash'
5 | import { connect } from 'react-redux'
6 | import { bindActionCreators } from 'redux'
7 | import * as MapActions from '../../actions/map'
8 | import { compareTimes as timeOptions } from '../../settings/options'
9 | import DropdownButton from '../DropdownButton'
10 | import Button from './button'
11 | import themes from '../../settings/themes'
12 | import './embedHeader.css'
13 |
14 | class EmbedHeader extends Component {
15 | onFeatureTypeClick(selectedFilters) {
16 | var enabledFilters = this.props.enabledFilters
17 | selectedFilters.filter(filter => enabledFilters.indexOf(filter) === -1).map(this.props.actions.enableFilter)
18 | enabledFilters.filter(filter => selectedFilters.indexOf(filter) === -1).map(this.props.actions.disableFilter)
19 | }
20 |
21 | onYearChangeClick(isEnd, selectedYears) {
22 | const newTimes = []
23 | newTimes[0] = (isEnd === true) ? this.props.times[0] : selectedYears[0]
24 | newTimes[1] = (isEnd === true) ? selectedYears[0] : this.props.times[1]
25 | this.props.actions.setTimes(newTimes)
26 | }
27 |
28 | render() {
29 | const { theme } = this.props
30 | const years = timeOptions.map(timeOption => ({
31 | id: timeOption.id,
32 | description: timeOption.id
33 | }))
34 | const yearsList = timeOptions.map(timeOption => timeOption.id)
35 |
36 | const yearStart = this.props.times[0]
37 | const yearEnd = this.props.times[1]
38 |
39 | // don't allow year start after year end and vice versa
40 | const yearsEndIndex = yearsList.indexOf(yearEnd)
41 | const yearsStart = years.slice(0, yearsEndIndex)
42 | const yearsStartIndex = yearsList.indexOf(yearStart)
43 | const yearsEnd = years.slice(yearsStartIndex + 1)
44 | const styles = themes[theme]
45 |
46 | return (
47 |
48 |
49 | Before and after.
50 |
51 |
57 | {yearStart} {styles.dateFrom.afterContent}
58 |
59 | }
60 | multiple={false}
61 | selectedKeys={[yearStart]}
62 | onSelectionChange={_.partial(this.onYearChangeClick, false).bind(this)}
63 | />
64 |
65 |
70 | {yearEnd} {styles.dateTo.afterContent}
71 |
72 | }
73 | multiple={false}
74 | selectedKeys={[yearEnd]}
75 | onSelectionChange={_.partial(this.onYearChangeClick, true).bind(this)}
76 | />
77 |
78 |
79 |
80 | {this.props.layers.filter(filter => filter.hidden !== true).map(filter => {
81 | const active = this.props.enabledFilters !== undefined && filter.name === this.props.enabledFilters[0]
82 | return
89 | {filter.title}
90 |
91 | })}
92 |
93 |
94 | )
95 | }
96 | }
97 |
98 |
99 | function mapStateToProps(state) {
100 | return {
101 | enabledFilters: state.map.filters,
102 | times: state.map.times
103 | }
104 | }
105 |
106 | function mapDispatchToProps(dispatch) {
107 | return {
108 | actions: bindActionCreators(MapActions, dispatch)
109 | }
110 | }
111 |
112 | export default connect(
113 | mapStateToProps,
114 | mapDispatchToProps
115 | )(EmbedHeader)
116 |
--------------------------------------------------------------------------------
/app/components/Map/gapsLayer.js:
--------------------------------------------------------------------------------
1 | import { queue } from 'd3-queue'
2 | import Sphericalmercator from 'sphericalmercator'
3 | import { bboxPolygon, intersect, inside, centroid } from 'turf'
4 | import loadTile from './loadVectorTile.js'
5 | import settings from '../../settings/settings'
6 | import colorbrewer from 'colorbrewer'
7 |
8 | const merc = new Sphericalmercator({size: 512})
9 | const gapsCoverageExtent = bboxPolygon([-17,-20,66,29])
10 |
11 | const colorScheme = colorbrewer.PRGn[7]
12 |
13 | export { colorScheme }
14 | export default L.GridLayer.extend({
15 | threshold: 1000, // ~1000m² per building
16 |
17 | createTile: function(coords, done) {
18 | var tile = document.createElement('canvas')
19 | var tileSize = this.getTileSize()
20 | var cellSize = tileSize.x / 64
21 | tile.setAttribute('width', tileSize.x)
22 | tile.setAttribute('height', tileSize.y)
23 |
24 | const bboxPoly = bboxPolygon(merc.bbox(coords.x, coords.y, coords.z-1))
25 | if (!intersect(gapsCoverageExtent, bboxPoly)) {
26 | setTimeout(() => done(null, tile), 1)
27 | return tile
28 | }
29 |
30 | var ctx = tile.getContext('2d')
31 |
32 | var q = queue()
33 | var tileCoords = { x: coords.x, y: coords.y, z: coords.z-1 }
34 | q.defer(loadTile, settings['vt-gaps-source']+'/{z}/{x}/{y}.pbf', 'buildup', tileCoords)
35 | q.defer(loadTile, settings['vt-source']+'/buildings/{z}/{x}/{y}.pbf', 'osm', tileCoords)
36 | q.awaitAll((err, data) => {
37 | if (err) return done(err)
38 | var areas = {}
39 | data[0].features = data[0].features.filter(feature =>
40 | inside(centroid(feature), gapsCoverageExtent)
41 | ).forEach(feature => {
42 | var binX = feature.properties.binX
43 | var binY = feature.properties.binY
44 | var area = feature.properties.area
45 | areas[binX+'/'+binY] = area
46 | })
47 | var counts = {}
48 | data[1].features = data[1].features.filter(feature =>
49 | inside(centroid(feature), gapsCoverageExtent)
50 | ).forEach(feature => {
51 | var binX = feature.properties.binX
52 | var binY = feature.properties.binY
53 | var count = feature.properties._count
54 | counts[binX+'/'+binY] = count
55 | })
56 | ;(new Set(Object.keys(areas).concat(Object.keys(counts)))).forEach(bin => {
57 | var binX = +bin.split('/')[0]
58 | var binY = +bin.split('/')[1]
59 | var opacity = 0.4 + Math.min(0.4, Math.max((areas[bin] || 0), (counts[bin] * 1000 /*~1000m² per building*/ || 0)) / (5000 * Math.pow(2.9, 12-coords.z)))
60 | var ratio = (areas[bin] || 0) / (counts[bin] * this.threshold || 0)
61 | var color
62 | switch (true) {
63 | case ratio > 15:
64 | ctx.fillStyle = hexToRgbA(colorScheme[0], opacity)
65 | break
66 | case ratio > 5:
67 | ctx.fillStyle = hexToRgbA(colorScheme[1], opacity)
68 | break
69 | case ratio > 2:
70 | ctx.fillStyle = hexToRgbA(colorScheme[2], opacity)
71 | break
72 | case ratio > 1.25:
73 | ctx.fillStyle = hexToRgbA(colorScheme[3], opacity)
74 | break
75 | case ratio > 1:
76 | ctx.fillStyle = hexToRgbA(colorScheme[4], opacity)
77 | break
78 | case ratio > 0.8:
79 | ctx.fillStyle = hexToRgbA(colorScheme[5], opacity)
80 | break
81 | case ratio >= 0:
82 | ctx.fillStyle = hexToRgbA(colorScheme[6], opacity)
83 | break
84 | default:
85 | ctx.fillStyle = "#000000"
86 | }
87 | //ctx.fillStyle = "rgba(255,0,0,"+Math.log(feature.properties.area)/Math.log(1000000)+")"
88 | ctx.fillRect(binX*cellSize, tileSize.y-(binY+1)*cellSize, cellSize, cellSize)
89 | })
90 | done(null, tile)
91 | })
92 | return tile
93 | }
94 | })
95 |
96 | // from https://stackoverflow.com/a/21648508/1627467
97 | function hexToRgbA (hex, opacity) {
98 | var c
99 | if(/^#([A-Fa-f0-9]{3}){1,2}$/.test(hex)) {
100 | c = hex.substring(1).split('')
101 | if (c.length == 3) {
102 | c = [c[0], c[0], c[1], c[1], c[2], c[2]]
103 | }
104 | c = '0x' + c.join('')
105 | return 'rgba(' + [(c>>16)&255, (c>>8)&255, c&255].join(',') + ',' + (opacity || 1) + ')';
106 | }
107 | throw new Error('Bad Hex');
108 | }
109 |
--------------------------------------------------------------------------------
/app/components/Map/style.css:
--------------------------------------------------------------------------------
1 | div#map {
2 | position:absolute;
3 | top:0;
4 | bottom:0;
5 | width:100%;
6 | background-color: #def;
7 | z-index: -1;
8 | }
9 |
10 | div.search {
11 | position: absolute;
12 | top: 72px;
13 | left: 25px;
14 | }
15 |
16 | span.search-alternative {
17 | position: absolute;
18 | display: block;
19 | color: #505254;
20 | top: 72px;
21 | left: 340px;
22 | width: 45px;
23 | line-height: 35px;
24 | text-align: center;
25 | pointer-events: none;
26 | }
27 |
28 | button.outline {
29 | position: absolute;
30 | top: 72px;
31 | left: 390px;
32 | }
33 | button.filter {
34 | position: absolute;
35 | top: 72px;
36 | right: 25px;
37 | }
38 |
39 | /* leaflet stuff */
40 | .leaflet-vertex-icon {
41 | background-color: white;
42 | border-radius: 6px;
43 | border: 2px solid gray;
44 | }
45 |
46 | .leaflet-middle-icon {
47 | background-color: gray;
48 | border-radius: 4px;
49 | border: 0;
50 | }
51 |
52 | .leaflet-bottom.leaflet-right .leaflet-control-zoom {
53 | margin-right: 25px;
54 | margin-bottom: 20px;
55 | }
56 | .leaflet-control-zoom {
57 | a {
58 | background-color: #364B52;
59 | opacity: 0.4;
60 | color: white;
61 | &:hover {
62 | opacity: 0.67;
63 | }
64 | }
65 | }
66 |
67 | .countryView,
68 | .compareView {
69 | .leaflet-bottom,
70 | .slider-box,
71 | #legend {
72 | margin-bottom: 212px;
73 | }
74 | }
75 | .gaps-regionView {
76 | .leaflet-bottom,
77 | .slider-box,
78 | #legend {
79 | margin-bottom: 74px;
80 | }
81 | }
82 |
83 | /* compare swiper */
84 | .compare {
85 | background-color:#fff;
86 | position:absolute;
87 | width:2px;
88 | height:100%;
89 | }
90 | .compare .swiper {
91 | box-shadow:inset 0 0 0 2px #fff;
92 | display:inline-block;
93 | border-radius:50%;
94 | position:absolute;
95 | width:60px;
96 | height:60px;
97 | top:50%;
98 | left:-30px;
99 | margin:-80px 1px 0;
100 | cursor:ew-resize;
101 | background-image:url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+PHN2ZyAgIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgICB4bWxuczpjYz0iaHR0cDovL2NyZWF0aXZlY29tbW9ucy5vcmcvbnMjIiAgIHhtbG5zOnJkZj0iaHR0cDovL3d3dy53My5vcmcvMTk5OS8wMi8yMi1yZGYtc3ludGF4LW5zIyIgICB4bWxuczpzdmc9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiAgIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgICB4bWxuczpzb2RpcG9kaT0iaHR0cDovL3NvZGlwb2RpLnNvdXJjZWZvcmdlLm5ldC9EVEQvc29kaXBvZGktMC5kdGQiICAgeG1sbnM6aW5rc2NhcGU9Imh0dHA6Ly93d3cuaW5rc2NhcGUub3JnL25hbWVzcGFjZXMvaW5rc2NhcGUiICAgd2lkdGg9IjYwIiAgIGhlaWdodD0iNjAiICAgdmVyc2lvbj0iMS4xIiAgIHZpZXdCb3g9IjAgMCA2MCA2MCIgICBpZD0ic3ZnNTQzNCIgICBpbmtzY2FwZTp2ZXJzaW9uPSIwLjkxK2RldmVsK29zeG1lbnUgcjEyOTExIiAgIHNvZGlwb2RpOmRvY25hbWU9Imwtci5zdmciPiAgPG1ldGFkYXRhICAgICBpZD0ibWV0YWRhdGE1NDQ0Ij4gICAgPHJkZjpSREY+ICAgICAgPGNjOldvcmsgICAgICAgICByZGY6YWJvdXQ9IiI+ICAgICAgICA8ZGM6Zm9ybWF0PmltYWdlL3N2Zyt4bWw8L2RjOmZvcm1hdD4gICAgICAgIDxkYzp0eXBlICAgICAgICAgICByZGY6cmVzb3VyY2U9Imh0dHA6Ly9wdXJsLm9yZy9kYy9kY21pdHlwZS9TdGlsbEltYWdlIiAvPiAgICAgICAgPGRjOnRpdGxlPjwvZGM6dGl0bGU+ICAgICAgPC9jYzpXb3JrPiAgICA8L3JkZjpSREY+ICA8L21ldGFkYXRhPiAgPGRlZnMgICAgIGlkPSJkZWZzNTQ0MiIgLz4gIDxzb2RpcG9kaTpuYW1lZHZpZXcgICAgIHBhZ2Vjb2xvcj0iI2ZmZmZmZiIgICAgIGJvcmRlcmNvbG9yPSIjNjY2NjY2IiAgICAgYm9yZGVyb3BhY2l0eT0iMSIgICAgIG9iamVjdHRvbGVyYW5jZT0iMTAiICAgICBncmlkdG9sZXJhbmNlPSIxMCIgICAgIGd1aWRldG9sZXJhbmNlPSIxMCIgICAgIGlua3NjYXBlOnBhZ2VvcGFjaXR5PSIwIiAgICAgaW5rc2NhcGU6cGFnZXNoYWRvdz0iMiIgICAgIGlua3NjYXBlOndpbmRvdy13aWR0aD0iMTI4NiIgICAgIGlua3NjYXBlOndpbmRvdy1oZWlnaHQ9Ijc1MSIgICAgIGlkPSJuYW1lZHZpZXc1NDQwIiAgICAgc2hvd2dyaWQ9InRydWUiICAgICBpbmtzY2FwZTp6b29tPSI0IiAgICAgaW5rc2NhcGU6Y3g9IjI1Ljg4OTgzMSIgICAgIGlua3NjYXBlOmN5PSIzNC4zODE4MzMiICAgICBpbmtzY2FwZTp3aW5kb3cteD0iMCIgICAgIGlua3NjYXBlOndpbmRvdy15PSIyMyIgICAgIGlua3NjYXBlOndpbmRvdy1tYXhpbWl6ZWQ9IjAiICAgICBpbmtzY2FwZTpjdXJyZW50LWxheWVyPSJzdmc1NDM0IiAgICAgaW5rc2NhcGU6b2JqZWN0LW5vZGVzPSJ0cnVlIiAgICAgaW5rc2NhcGU6c25hcC1zbW9vdGgtbm9kZXM9InRydWUiPiAgICA8aW5rc2NhcGU6Z3JpZCAgICAgICB0eXBlPSJ4eWdyaWQiICAgICAgIGlkPSJncmlkNTk4OSIgLz4gIDwvc29kaXBvZGk6bmFtZWR2aWV3PiAgPHBhdGggICAgIHN0eWxlPSJmaWxsOiNmZmZmZmY7ZmlsbC1ydWxlOmV2ZW5vZGQ7c3Ryb2tlOm5vbmU7c3Ryb2tlLXdpZHRoOjFweDtzdHJva2UtbGluZWNhcDpidXR0O3N0cm9rZS1saW5lam9pbjptaXRlcjtzdHJva2Utb3BhY2l0eToxIiAgICAgZD0iTSAyNSAyNCBMIDE2IDMwIEwgMjUgMzYgTCAyNSAyNCB6IE0gMzUgMjQgTCAzNSAzNiBMIDQ0IDMwIEwgMzUgMjQgeiAiICAgICBpZD0icGF0aDU5OTUiIC8+PC9zdmc+);
102 | }
103 |
104 | /* mapboxgl stuff */
105 | .mapboxgl-canvas-container canvas {
106 | transform: none !important;
107 | }
108 | .mapboxgl-map {
109 | position: absolute !important;
110 | }
111 |
--------------------------------------------------------------------------------
/app/components/About/index.js:
--------------------------------------------------------------------------------
1 | /* eslint indent: "off" */
2 |
3 | import React, { Component } from 'react'
4 | import { Link } from 'react-router'
5 | import Header from '../Header'
6 | import style from './style.css'
7 | import logo_hot from '../../assets/logos/hot.png'
8 | import logo_osm from '../../assets/logos/osm.png'
9 | import logo_mapbox from '../../assets/logos/mapbox.png'
10 | import logo_redcross from '../../assets/logos/redcross.png'
11 | import logo_gfdrr from '../../assets/logos/gfdrr.png'
12 | import logo_aws from '../../assets/logos/aws.png'
13 | import logo_zoondka from '../../assets/logos/zoondka.png'
14 | import logo_ds from '../../assets/logos/ds.png'
15 | import logo_heigit from '../../assets/logos/heigit.png'
16 | import logo_knight from '../../assets/logos/knight.png'
17 |
18 | class About extends Component {
19 | render() {
20 | return (
21 |
22 |
23 |
24 |
25 | Explore How the World is Mapped by OpenStreetMap Contributors
26 |
27 |
28 | See OSM Data Over Time
29 | This tool lets you analyse interactively how specific OpenStreetMap features are mapped in a specific region.
30 | Say, you'd like to know when most of the buildings in a country like Nepal were added. This tool lets you select the geographical region of interest and shows a graph of the mapping activity in the region. You can even select a specific time interval to get the number of touched features in that period, and the map will highlight the matching buildings as well!
31 |
32 |
33 | Compare OSM Data at Different Points in Time
34 | Want to dig even deeper into the data? The Compare Time Periods feature gives you a side by side comparison down to the individual objects level. Use the swiper to switch between the selected dates.
35 |
36 |
37 | Find Gaps in OSM Data
38 | By comparing OpenStreetMap feature densities with certain external data sets potential gaps in OSM can be identified: The Gap Detection tab of osm-analytics shows building counts that are matched with the amount of built-up area (derived using remote sensing methods) to help find areas with potentially missing building data in OSM.
39 |
40 |
41 |
42 | What's more
43 | Explore data by mapper experience: Alternatively to the mapping activity over time (recency of edits) view, one can also investigate the data from a editor level of experience point of view: The graph then displays how large the proportion of the objects is that have been contributed by beginners, intermediate level mappers or experienced users.
44 | Hot Projects: Know which projects of the Humanitarian OpenStreetMap Team influenced the development of the mapping of a region.
45 |
46 |
47 | Technical Details
48 | Head over to the project's github repository for its source code, general information and technical details.
49 | The data analyzed in this application is © OpenStreetMap contributors and available under the ODbL license .
50 |
51 |
52 | Try it out now!
53 | Get Started
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
)
70 | }
71 | }
72 |
73 | export default About
74 |
--------------------------------------------------------------------------------
/app/components/SearchBox/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import * as request from 'superagent'
3 | import superagentPromisePlugin from 'superagent-promise-plugin'
4 | import { simplify, polygon } from 'turf'
5 | import Autosuggest from 'react-autosuggest'
6 | import style from './style.css'
7 | import settings from '../../settings/settings'
8 |
9 | class SearchBox extends Component {
10 | state = {
11 | active: true,
12 | currentValue: ''
13 | }
14 |
15 | onClick() {
16 | this.setState({active: true})
17 | }
18 | onKeyPress(event) {
19 | if (this.state.errored) this.setState({ errored: false })
20 | // enter key or search icon clicked
21 | var regionName = this.state.currentValue
22 | if (regionName && (event.type === 'click' || event.which === 13)) {
23 | if (regionName.match(/^\d+$/)) {
24 | this.getSuggestions(regionName, (function(err, results) {
25 | let best = results[0]
26 | if (best && best.id == regionName) {
27 | this.go(best)
28 | }
29 | }).bind(this))
30 | } else {
31 | this.goOSM(regionName)
32 | }
33 | }
34 | }
35 | getSuggestions(input, callback) {
36 | request
37 | .get(settings['tm-api'] + '/projects/')
38 | .query({
39 | textSearch: input
40 | })
41 | .use(superagentPromisePlugin)
42 | .then(function(res) {
43 | var suggestions = res.body.results.map(function(result) {
44 | return {
45 | id: result.projectId,
46 | name: result.name
47 | }
48 | })
49 | callback(null, suggestions)
50 | })
51 | .catch(function(err) {
52 | if (err.status === 404)
53 | callback(null, [])
54 | else
55 | callback(err)
56 | });
57 | }
58 |
59 | go(where) {
60 | this.props.setRegion({
61 | type: 'hot',
62 | id: where.id
63 | })
64 | }
65 | goOSM(where) {
66 | var setState = ::this.setState
67 | setState({ loading: true })
68 | var setRegion = this.props.setRegion
69 | request
70 | .get('https://nominatim.openstreetmap.org/search')
71 | .query({
72 | format: 'json',
73 | polygon_geojson: 1,
74 | q: where
75 | })
76 | .use(superagentPromisePlugin)
77 | .then(function(res) {
78 | var hits = res.body.filter(r => r.osm_type !== 'node')
79 | if (hits.length === 0) throw new Error('nothing found for place name '+where)
80 | return hits[0].geojson
81 | })
82 | .then(function(geojson) {
83 | if (!(geojson.type === 'Polygon' || geojson.type === 'MultiPolygon')) throw new Error('invalid geometry')
84 | var coords = geojson.coordinates
85 | if (geojson.type === 'MultiPolygon') {
86 | coords = coords.sort((p1,p2) => p2[0].length - p1[0].length)[0] // choose polygon with the longest outer ring
87 | }
88 | coords = coords[0]
89 | const maxNodeCount = 40 // todo: setting
90 | if (coords.length > maxNodeCount) {
91 | for (let simpl = 0.00001; simpl<100; simpl*=1.4) {
92 | let simplifiedFeature = simplify(polygon([coords]), simpl)
93 | if (simplifiedFeature.geometry.coordinates[0].length <= maxNodeCount) {
94 | coords = simplifiedFeature.geometry.coordinates[0]
95 | break;
96 | }
97 | }
98 | }
99 | setState({ loading: false })
100 | setRegion({
101 | type: 'polygon',
102 | coords: coords.slice(0,-1)
103 | })
104 | })
105 | .catch(function(err) {
106 | console.error('error during osm region search:', err)
107 | setState({ loading: false, errored: true })
108 | })
109 | }
110 |
111 | render() {
112 | return (
113 |
114 |
('#'+s.id+' '+s.name)}
117 | suggestionValue={s => s.id}
118 | onSuggestionSelected={s => this.go(s)}
119 | value={this.state.currentValue}
120 | scrollBar
121 | inputAttributes={{
122 | className: 'searchbox',
123 | placeholder: 'Search by region or HOT Project ID',
124 | type: 'search',
125 | onKeyPress: ::this.onKeyPress,
126 | onChange: value => ::this.setState({ currentValue: value })
127 | }}
128 | />
129 |
132 |
133 |
134 | )
135 | }
136 |
137 | componentDidMount() {
138 | if (this.props.selectedRegion) {
139 | if (this.props.selectedRegion.type === 'hot') {
140 | this.setState({
141 | currentValue: ""+this.props.selectedRegion.id
142 | })
143 | }
144 | }
145 | }
146 | componentWillReceiveProps(nextProps) {
147 | if (nextProps.selectedRegion) {
148 | if (nextProps.selectedRegion.type === 'hot') {
149 | this.setState({
150 | currentValue: ""+nextProps.selectedRegion.id
151 | })
152 | }
153 | }
154 | }
155 | }
156 |
157 | export default SearchBox
158 |
--------------------------------------------------------------------------------
/app/components/Stats/gaps.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import { bindActionCreators } from 'redux'
3 | import { connect } from 'react-redux'
4 | import Modal from 'react-modal'
5 | import { polygon } from 'turf'
6 | import { queue } from 'd3-queue'
7 | import moment from 'moment'
8 | import * as MapActions from '../../actions/map'
9 | import * as StatsActions from '../../actions/stats'
10 | import OverlayButton from '../OverlayButton'
11 | import UnitSelector from '../UnitSelector'
12 | import Histogram from './chart'
13 | import ContributorsModal from './contributorsModal'
14 | import HotProjectsModal from './hotProjectsModal'
15 | import regionToCoords from '../Map/regionToCoords'
16 | import searchHotProjectsInRegion from './searchHotProjects'
17 | import searchFeatures from './searchFeatures'
18 | import searchBuiltupAreas from './searchBuiltupAreas'
19 | import unitSystems from '../../settings/unitSystems'
20 | import { gapsFilters } from '../../settings/options'
21 | import style from './style.css'
22 |
23 | class GapsStats extends Component {
24 | state = {
25 | features: [],
26 | updating: false
27 | }
28 |
29 | render() {
30 | var features = this.state.features
31 |
32 | // todo: loading animation if region is not yet fully loaded
33 | return (
34 |
35 |
36 | OSM
37 | {features.map(filter => {
38 | var osmLayerName = gapsFilters.find(f => f.name === filter.filter).layers.osm
39 | var osmLayer = this.props.layers.find(f => f.name === osmLayerName)
40 | return (
41 | {
42 | numberWithCommas(Number((osmLayerName === 'highways' || osmLayerName === 'waterways'
43 | ? unitSystems[this.props.stats.unitSystem].distance.convert(
44 | filter.features.reduce((prev, feature) => prev+(feature.properties._length || 0.0), 0.0)
45 | )
46 | : filter.features.reduce((prev, feature) => prev+(feature.properties._count || 1), 0))
47 | ).toFixed(0))
48 | }
49 | {osmLayerName === 'highways' || osmLayerName === 'waterways'
50 | ?
56 | : {osmLayer.title}
57 | }
58 | )
59 | })}
60 |
61 | {numberWithCommas(Math.round(this.state.builtupArea/10000))} hectare built-up area
62 |
63 |
64 | GHS
65 |
66 |
67 |
68 |
71 |
72 |
73 | )
74 | }
75 |
76 | componentDidMount() {
77 | if (this.props.map.region) {
78 | ::this.update(this.props.map.region, this.props.map.filters)
79 | }
80 | }
81 |
82 | componentWillReceiveProps(nextProps) {
83 | // check for changed map parameters
84 | if (nextProps.map.region !== this.props.map.region
85 | || nextProps.map.filters !== this.props.map.filters) {
86 | ::this.update(nextProps.map.region, nextProps.map.filters)
87 | }
88 | }
89 |
90 | update(region, filters) {
91 | regionToCoords(region)
92 | .then((function(region) {
93 | this.setState({ updating: true, features: [] })
94 | var q = queue()
95 | filters.forEach(filter => {
96 | const osmLayerName = gapsFilters.find(f => f.name === filter).layers.osm
97 | q.defer(searchFeatures, region, osmLayerName)
98 | })
99 | q.defer(searchBuiltupAreas, region)
100 | q.awaitAll(function(err, data) {
101 | if (err) throw err
102 | this.setState({
103 | features: data.slice(0, -1).map((d,index) => ({
104 | filter: filters[index],
105 | features: d.features
106 | })),
107 | //builtupCells: data.slice(-1)[0],
108 | builtupArea: data.slice(-1)[0].features.reduce((acc, feature) => acc + feature.properties.area, 0),
109 | updating: false
110 | })
111 | }.bind(this))
112 | }).bind(this));
113 | }
114 | }
115 |
116 |
117 | function numberWithCommas(x) {
118 | return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
119 | }
120 |
121 |
122 | function mapStateToProps(state) {
123 | return {
124 | map: state.map,
125 | stats: state.stats
126 | }
127 | }
128 |
129 | function mapDispatchToProps(dispatch) {
130 | return {
131 | actions: bindActionCreators(MapActions, dispatch),
132 | statsActions: bindActionCreators(StatsActions, dispatch)
133 | }
134 | }
135 |
136 | export default connect(
137 | mapStateToProps,
138 | mapDispatchToProps
139 | )(GapsStats)
140 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | osm-analytics: data analysis tool frontend
2 | =========================================
3 |
4 | [](https://gitter.im/hotosm/osm-analytics?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge)
5 |
6 | OSM-Analytics lets you analyse interactively how specific OpenStreetMap features are mapped in a specific region.
7 |
8 | Say, you'd like to know when most of a specific feature type (e.g. buildings) in a specific country or city were added. This tool lets you select the geographical region of interest and shows a graph of the mapping activity in the region. You can even select a specific time interval to get the number of touched features in that period, and the map will highlight the matching features. Alternatively, one can view the distribution of features by their mapper's user experience. The tool also gives a side by side comparison of the map state at different points in time and lets you view which [HOT](https://hotosm.org/) projects may have included the mapping of a region.
9 |
10 | Features
11 | --------
12 |
13 | * supported feature types: *buildings* (any closed osm way with a building tag), *roads* (any osm way with a highway tag), *rivers* (any osm way with a waterway tag)
14 | * graphs of feature *recency* or *mapper experience*
15 | * highlighting of features by custom date range or user experience interval
16 | * calculated statistics: total number/length of features in selected region and date/experience range, number of contributors
17 | * shows which hot projects influenced the mapping of the selected region
18 | * compare map at different points in time
19 | * data updated daily
20 |
21 | Technical Overview & Limitations
22 | --------------------------------
23 |
24 | See [documentation/architecture.md](documentation/architecture.md) for background information.
25 |
26 | Installation and Usage
27 | ----------------------
28 |
29 | The frontend is implemented in React/Redux and based on [tj/Frontend Boilerplate](https://github.com/tj/frontend-boilerplate).
30 |
31 | Install dependencies:
32 |
33 | ```
34 | $ npm install
35 | ```
36 |
37 | Run in development mode:
38 |
39 | ```
40 | $ npm start
41 | ```
42 |
43 | Generate static build:
44 |
45 | ```
46 | $ npm run build
47 | ```
48 |
49 | The [`deploy.sh`](https://github.com/hotosm/osm-analytics/blob/master/deploy.sh) script can be useful to publish updates on github-pages.
50 |
51 | Embedding
52 | ---------
53 |
54 | This user interface supports a custom UI for embedding on 3rd party websites, using an HTML `iframe`. It allows generating a
55 | time comparison between two points in time for the same region.
56 |
57 | 
58 |
59 |
60 | The above visualization can be generated using a specific URL structure:
61 |
62 | `https://osm-analytics.org/#/compare//...//embed/`
63 |
64 | - __iframe_base_url__ (`http://osm-analytics.org`)
65 | - __region__ the area of interest the embedded map is shown for. Can be a bounding box (`bbox:110.28050,-7.02687,110.48513,-6.94219`), an [encoded polyline](https://www.npmjs.com/package/@mapbox/polyline) of a polygon (e.g. `polygon:ifv%7BDndwkBx%60%40aYwQev%40sHkPuf%40ss%40%7BfA_%40uq%40xdCn%7D%40%5E`)), or a hot project id (e.g. `hot:4053`) or a link to a github gist that contains a `polygon.geojson` file (e.g. `gist:36ea172ef996a44d36a554383d5fb4fa`).
66 | - __start_year__ (`2016`) represents the start year of an OpenDRI project
67 | - __end_year__ (`now`) represents the end year of an OpenDRI project. `now` can also be provided to compare with latest OSM data
68 | - __feature_layer__ (`buildings`) compare `buildings`, `highways` or `waterways`
69 | - __theme_name__ (`default`) use the `default` OSM Analytics visual style, or the `opendri` theme
70 |
71 | The *gap detection* view can also be used as an embedded map in a very similar way:
72 |
73 | `https://osm-analytics.org/#/gaps//buildings-vs-ghs/embed/`
74 |
75 | The *edit recency* and *user experience* views can also be embedded like this:
76 |
77 | `https://osm-analytics.org/#/show///embed//recency` or `https://osm-analytics.org/#/show///embed//experience`
78 |
79 | Here, one can optionally supply a time or user experience selection, which triggers highlights respective features or regions on the map that fall into the given time period or user experience range. Just append the respective query parameter to the embed URL: `/?timeSelection=,` (timestamps are seconds since epoch) or `/?experienceSelection=,` (experience values as defined in the respective layer's [experience](https://github.com/hotosm/osm-analytics-config/blob/master/analytics-json.md#experience-field) field).
80 |
81 | See Also
82 | --------
83 |
84 | * [lukasmartinelli/osm-activity](https://github.com/lukasmartinelli/osm-activity) – similar to OSM-Analytics, but with a simpler, more basic user interface
85 | * [OSMatrix](http://wiki.openstreetmap.org/wiki/OSMatrix) by GIScience Heidelberg – "precursor" of OSM-Analytics
86 | * [joto/taginfo](https://github.com/joto/taginfo) – aggregate statistics for all OSM tags
87 | * [tyrasd/taghistory](https://github.com/tyrasd/taghistory) – tagging history for all OSM tags
88 | * [Crowdlens](http://stateofthemap.us/2016/learning-about-the-crowd/) by Sterling Quinn – prototype of interactive mapping history visualization
89 | * [mapbox/osm-analysis-collab](http://mapbox.github.io/osm-analysis-collab/osm-quality.html) – misc OSM contributor analyses based on osm-qa-tiles data
90 |
--------------------------------------------------------------------------------
/app/containers/App/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import Modal from 'react-modal'
3 | import { connect } from 'react-redux'
4 | import { bindActionCreators } from 'redux'
5 | import * as MapActions from '../../actions/map'
6 | import * as StatsActions from '../../actions/stats'
7 | import Header from '../../components/Header'
8 | import EmbedHeader from '../../components/Header/embedHeader.js'
9 | import Map from '../../components/Map'
10 | import Stats from '../../components/Stats'
11 | import CompareBar from '../../components/CompareBar'
12 | import { load as loadHotProjects } from '../../data/hotprojects.js'
13 | import themes from '../../settings/themes'
14 | import { loadLayers } from '../../settings/options'
15 | import style from './style.css'
16 |
17 | class App extends Component {
18 | state = {
19 | hotProjectsLoaded: false,
20 | layersLoaded: false,
21 | isModalOpen: true
22 | }
23 |
24 | openModal = () => {
25 | this.setState({ isModalOpen: true })
26 | }
27 |
28 | closeModal = () => {
29 | this.setState({ isModalOpen: false })
30 | }
31 |
32 | render() {
33 | const { actions, routeParams, route, location, embed } = this.props
34 | const theme = routeParams.theme || 'default'
35 | var header = ""
36 | if (embed && route.view === "compare")
37 | header =
38 | else if (!embed)
39 | header =
40 |
41 | if (!this.state.hotProjectsLoaded || !this.state.layersLoaded) {
42 | return (
43 |
44 | {header}
45 |
Loading…
46 |
47 | )
48 | }
49 |
50 | // update from route params
51 | if (route.view) {
52 | this.props.actions.setViewFromUrl(route.view)
53 | }
54 | if (routeParams.region) {
55 | this.props.actions.setRegionFromUrl(routeParams.region)
56 | }
57 | if (routeParams.filters) {
58 | this.props.actions.setFiltersFromUrl(routeParams.filters)
59 | }
60 | if (routeParams.overlay) {
61 | this.props.actions.setOverlayFromUrl(routeParams.overlay)
62 | }
63 | if (routeParams.times) {
64 | this.props.actions.setTimesFromUrl(routeParams.times)
65 | }
66 |
67 | if (location.query.experienceSelection) {
68 | this.props.statsActions.setExperienceFilter(
69 | location.query.experienceSelection.split(",").map(Number)
70 | )
71 | }
72 | if (location.query.timeSelection) {
73 | this.props.statsActions.setTimeFilter(
74 | location.query.timeSelection.split(",").map(Number)
75 | )
76 | }
77 |
78 | const modalStyles = {
79 | overlay: {
80 | backgroundColor: 'rgba(60,60,60, 0.5)'
81 | },
82 | content: {
83 | top: '50%',
84 | left: '50%',
85 | right: 'auto',
86 | bottom: 'auto',
87 | marginRight: '-50%',
88 | transform: 'translate(-50%, -50%)',
89 | maxHeight: '350px',
90 | maxWidth: '512px',
91 | minWidth: '300px',
92 | borderRadius: '4px',
93 | paddingTop: '25px',
94 | paddingBottom: '35px',
95 | paddingLeft: '35px',
96 | paddingRight: '35px'
97 | }
98 | }
99 |
100 | return (
101 |
102 | {header}
103 |
113 | {route.view === 'country' && embed === false ?
: ''}
114 | {route.view === 'compare' && embed === false ? : ''}
115 | { embed ? View on osm-analytics.org : '' }
116 |
117 |
121 | Shutdown Notice
122 |
123 | x
124 |
125 | OSM Analytics has ended its service. For alternatives to analyzing OSM data history, quality, and completeness, use the ohsome dashboard , developed and maintained by our partners at HeiGIT.
126 |
127 |
128 | )
129 | }
130 |
131 | componentDidMount() {
132 | this.props.actions.setEmbedFromUrl(this.props.routeParams.embed === 'embed')
133 | this.props.actions.setThemeFromUrl(this.props.routeParams.theme)
134 | loadHotProjects((err) => {
135 | if (err) {
136 | console.error('unable to load hot projects data: ', err)
137 | }
138 | this.setState({ hotProjectsLoaded: true })
139 | })
140 | loadLayers((err, layers) => {
141 | if (err) {
142 | return console.error('unable to load available osm-analytics layers: ', err)
143 | }
144 | this.setState({
145 | layersLoaded: true,
146 | layers
147 | })
148 | })
149 | }
150 | }
151 |
152 | function mapStateToProps(state) {
153 | return {
154 | embed: state.map.embed
155 | }
156 | }
157 |
158 | function mapDispatchToProps(dispatch) {
159 | return {
160 | actions: bindActionCreators(MapActions, dispatch),
161 | statsActions: bindActionCreators(StatsActions, dispatch)
162 | }
163 | }
164 |
165 | export default connect(
166 | mapStateToProps,
167 | mapDispatchToProps
168 | )(App)
169 |
--------------------------------------------------------------------------------
/app/components/Map/glstyles.js:
--------------------------------------------------------------------------------
1 | /* eslint quotes: "off" */
2 |
3 | import settings from '../../settings/settings'
4 | import themes from '../../settings/themes'
5 |
6 | const applyTheme = (themeName, layer, glLayers) => {
7 | //if (!themes[themeName] || !themes[themeName][layerName]) return glLayers
8 | return glLayers.map(glLayer =>
9 | Object.assign(glLayer, {
10 | paint: Object.assign(glLayer.paint,
11 | themes[themeName].getGlLayerStyle(layer, glLayer)
12 | )
13 | })
14 | )
15 | }
16 |
17 | export default function getStyle(availableLayers, activeLayer, options) {
18 | if (!options) options = {}
19 | const currentTheme = options.theme || 'default'
20 | const timeFilter = options.timeFilter
21 | const experienceFilter = options.experienceFilter
22 | const server = options.source || settings['vt-source']
23 |
24 | var glSources = {}
25 | availableLayers.forEach(layer => {
26 | glSources[layer.name + '-raw'] = {
27 | "type": "vector",
28 | "tiles": [
29 | server+"/"+layer.name+"/{z}/{x}/{y}.pbf"
30 | ],
31 | "minzoom": 13,
32 | "maxzoom": 13
33 | }
34 | glSources[layer.name + '-aggregated'] = {
35 | "type": "vector",
36 | "tiles": [
37 | server+"/"+layer.name+"/{z}/{x}/{y}.pbf"
38 | ],
39 | "minzoom": 0,
40 | "maxzoom": 12
41 | }
42 | })
43 |
44 | var glLayers = []
45 | if (activeLayer !== undefined) {
46 | let paint = {}
47 | paint[activeLayer.render.type+"-opacity"] = 1.0
48 | if (activeLayer.render.type === "line") paint["line-width"] = 1
49 | // raw layers
50 | glLayers.push({
51 | "id": activeLayer.name + "-raw",
52 | "source": activeLayer.name + "-raw",
53 | "source-layer": "osm",
54 | "type": activeLayer.render.type,
55 | "paint": Object.assign({}, paint)
56 | })
57 | if (activeLayer.render.type === "line") paint["line-width"] = 2
58 | glLayers.push({
59 | "id": activeLayer.name + "-raw-highlight",
60 | "source": activeLayer.name + "-raw",
61 | "source-layer": "osm",
62 | "filter": ["==", "_timestamp", -1],
63 | "type": activeLayer.render.type,
64 | "paint": Object.assign({}, paint)
65 | })
66 |
67 | // aggregated layers
68 | var zoomBreaks = [1E99]
69 | // contains list of breaks for different zoom level layers
70 | // e.g. 0, 50, 200, 800, 3200, 12800, 51200
71 | var scaleFactor = activeLayer.render.scaleFactor
72 | for (var i=0; i<6; i++) {
73 | zoomBreaks.unshift(scaleFactor)
74 | scaleFactor /= Math.pow(activeLayer.render.scaleBasis, 2)
75 | }
76 | zoomBreaks.unshift(0)
77 |
78 | const opacityBreaks = [
79 | [[10, 0.1], [13, 1.0]],
80 | [[8, 0.1], [11, 1.0], [12, 1.0]],
81 | [[6, 0.1], [9, 1.0], [12, 1.0]],
82 | [[4, 0.1], [7, 1.0], [12, 1.0]],
83 | [[2, 0.1], [5, 1.0], [12, 1.0]],
84 | [[0, 0.1], [3, 1.0], [12, 1.0]],
85 | [[0, 1.0], [12, 1.0]]
86 | ]
87 | const filterField = activeLayer.filter.geometry === "LineString"
88 | ? "_lineDistance"
89 | : "_count"
90 | zoomBreaks.slice(0, -1).forEach((_, i) => {
91 | glLayers.push({
92 | "id": activeLayer.name + "-aggregated-"+i,
93 | "source": activeLayer.name + "-aggregated",
94 | "source-layer": "osm",
95 | "maxzoom": 12.01,
96 | "filter": ["all",
97 | [">=", filterField, zoomBreaks[i]],
98 | ["<", filterField, zoomBreaks[i+1]]
99 | ],
100 | "type": "fill",
101 | "paint": {
102 | "fill-antialias": false,
103 | "fill-opacity": {
104 | base: 1,
105 | stops: opacityBreaks[i]
106 | }
107 | }
108 | })
109 | glLayers.push({
110 | "id": activeLayer.name + "-aggregated-highlight-"+i,
111 | "source": activeLayer.name + "-aggregated",
112 | "source-layer": "osm",
113 | "maxzoom": 12.01,
114 | "filter": ["==", "_timestamp", -1],
115 | "densityFilter": ["all",
116 | [">=", filterField, zoomBreaks[i]],
117 | ["<", filterField, zoomBreaks[i+1]]
118 | ],
119 | "type": "fill",
120 | "paint": {
121 | "fill-antialias": false,
122 | "fill-opacity": {
123 | base: 1,
124 | stops: opacityBreaks[i]
125 | }
126 | }
127 | })
128 | })
129 | }
130 |
131 | return {
132 | "version": 8,
133 | "sources": glSources,
134 | "layers": applyTheme(currentTheme, activeLayer, glLayers).map(layer => {
135 | if (!layer.id.match(/highlight/)) return layer
136 | if (!timeFilter && !experienceFilter) {
137 | layer.filter = ["==", "_timestamp", -1]
138 | }
139 | if (timeFilter) {
140 | layer.filter = ["all",
141 | [">=", "_timestamp", timeFilter[0]],
142 | ["<=", "_timestamp", timeFilter[1]]
143 | ]
144 | }
145 | if (experienceFilter) {
146 | layer.filter = ["all",
147 | [">=", "_userExperience", experienceFilter[0]],
148 | ["<=", "_userExperience", experienceFilter[1]]
149 | ]
150 | }
151 |
152 | return layer
153 | })
154 | .reduce((prev, filterSources) => prev.concat(filterSources), [])
155 | .sort((a,b) => {
156 | if (a.id.match(/highlight/) && b.id.match(/highlight/)) return 0
157 | if (a.id.match(/highlight/)) return +1
158 | if (b.id.match(/highlight/)) return -1
159 | return 0
160 | })
161 | }
162 | }
163 |
164 | export function getCompareStyles(availableLayers, activeLayer, compareTimes, theme) {
165 | const beforeSource = (compareTimes[0] === 'now') ? settings['vt-source'] : settings['vt-hist-source']+'/'+compareTimes[0]
166 | const afterSource = (compareTimes[1] === 'now') ? settings['vt-source'] : settings['vt-hist-source']+'/'+compareTimes[1]
167 | var glCompareLayerStyles = {
168 | before: JSON.parse(JSON.stringify(getStyle(availableLayers, activeLayer, { source: beforeSource, theme }))),
169 | after: JSON.parse(JSON.stringify(getStyle(availableLayers, activeLayer, { source: afterSource, theme })))
170 | }
171 | // don't need highlight layers
172 | glCompareLayerStyles.before.layers = glCompareLayerStyles.before.layers.filter(layer => !layer.source.match(/highlight/))
173 | glCompareLayerStyles.after.layers = glCompareLayerStyles.before.layers.filter(layer => !layer.source.match(/highlight/))
174 | return glCompareLayerStyles
175 | }
176 |
--------------------------------------------------------------------------------
/app/components/CompareBar/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import { bindActionCreators } from 'redux'
3 | import { connect } from 'react-redux'
4 | import { queue } from 'd3-queue'
5 | import { polygon } from 'turf'
6 | import * as MapActions from '../../actions/map'
7 | import * as StatsActions from '../../actions/stats'
8 | import { compareTimes as timeOptions } from '../../settings/options'
9 | import unitSystems from '../../settings/unitSystems'
10 | import regionToCoords from '../Map/regionToCoords'
11 | import searchFeatures from '../Stats/searchFeatures'
12 | import UnitSelector from '../UnitSelector'
13 | import Chart from './chart'
14 | import style from './style.css'
15 |
16 | class CompareBar extends Component {
17 | state = {
18 | featureCounts: {},
19 | updating: false
20 | }
21 |
22 | render() {
23 | const activeLayer = this.props.layers.find(layer => layer.name === this.props.map.filters[0])
24 |
25 | return (
26 |
27 |
51 |
75 |
76 |
77 | Close Comparison View
78 |
79 |
80 |
81 |
82 | )
83 | }
84 |
85 | componentDidMount() {
86 | if (this.props.map.region) {
87 | ::this.update(this.props.map.region, this.props.map.filters)
88 | }
89 | }
90 |
91 | componentWillReceiveProps(nextProps) {
92 | // check for changed map parameters
93 | if (nextProps.map.region !== this.props.map.region
94 | || nextProps.map.filters !== this.props.map.filters) {
95 | ::this.update(nextProps.map.region, nextProps.map.filters)
96 | }
97 | }
98 |
99 | update(region, filters) {
100 | const filter = filters[0]
101 | regionToCoords(region)
102 | .then((function(region) {
103 | this.setState({ updating: true, featureCounts: {} })
104 | var q = queue()
105 | var featureCounts = {}
106 | filters.forEach(filter => {
107 | featureCounts[filter] = []
108 | timeOptions.forEach((timeOption, timeIdx) => {
109 | if (timeOption.layers && timeOption.layers.indexOf(filter) == -1) return
110 | q.defer(function(region, filter, time, callback) {
111 | searchFeatures(region, filter, time, function(err, data) {
112 | if (err) callback(err)
113 | else {
114 | featureCounts[filter][timeIdx] = {
115 | id: timeOption.id,
116 | day: +timeOption.timestamp,
117 | value: filter === 'highways' || filter === 'waterways'
118 | ? data.features.reduce((prev, feature) => prev + (feature.properties._length || 0.0), 0.0)
119 | : data.features.reduce((prev, feature) => prev + (feature.properties._count || 1), 0)
120 | }
121 | callback(null)
122 | }
123 | })
124 | }, region, filter, timeOption.id)
125 | })
126 | })
127 | q.awaitAll(function(err) {
128 | if (err) throw err
129 | this.setState({
130 | featureCounts,
131 | updating: false
132 | })
133 | }.bind(this))
134 | }).bind(this));
135 | }
136 |
137 | disableCompareView() {
138 | this.props.actions.setView('country')
139 | }
140 | }
141 |
142 | function numberWithCommas(x) { // todo: de-duplicate code!
143 | if (isNaN(Number(x))) return '?'
144 | return Number(x).toFixed(0).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
145 | }
146 |
147 | function mapStateToProps(state) {
148 | return {
149 | map: state.map,
150 | stats: state.stats
151 | }
152 | }
153 |
154 | function mapDispatchToProps(dispatch) {
155 | return {
156 | actions: bindActionCreators(MapActions, dispatch),
157 | statsActions: bindActionCreators(StatsActions, dispatch)
158 | }
159 | }
160 |
161 | export default connect(
162 | mapStateToProps,
163 | mapDispatchToProps
164 | )(CompareBar)
165 |
--------------------------------------------------------------------------------
/app/reducers/map.js:
--------------------------------------------------------------------------------
1 | /* eslint indent: "off" */
2 | import { handleActions } from 'redux-actions'
3 | import { createHashHistory } from 'history'
4 | import polyline from 'polyline'
5 |
6 | import {
7 | overlays as overlayOptions,
8 | compareTimes as timeOptions
9 | } from '../settings/options'
10 | import defaults from '../settings/defaults'
11 |
12 | var history = createHashHistory({ queryKey: false })
13 |
14 | const initialState = {
15 | view: 'default',
16 | times: ['2011', 'now'],
17 | region: null,
18 | filters: defaults.filters,
19 | overlay: defaults.overlay,
20 | theme: 'default'
21 | }
22 |
23 | export default handleActions({
24 | 'set region' (state, action) {
25 | var view = state.view
26 | if (view === 'default') view = 'show'
27 | var newState = Object.assign({}, state, {
28 | view,
29 | region: action.payload
30 | })
31 | updateURL(newState)
32 | return newState
33 | },
34 | 'set region from url' (state, action) {
35 | var view = state.view
36 | if (view === 'default') view = 'show'
37 | return Object.assign({}, state, {
38 | view,
39 | region: parseRegionFromUrl(action.payload)
40 | })
41 | },
42 |
43 | 'enable filter' (state, action) {
44 | var newState
45 | if (state.filters.indexOf(action.payload) === -1) {
46 | newState = Object.assign({}, state, {
47 | filters: state.filters.concat(action.payload)
48 | })
49 | } else {
50 | newState = state
51 | }
52 | updateURL(newState)
53 | return newState
54 | },
55 | 'disable filter' (state, action) {
56 | var newState = Object.assign({}, state, {
57 | filters: state.filters.filter(filter => filter !== action.payload)
58 | })
59 | updateURL(newState)
60 | return newState
61 | },
62 | 'set filters from url' (state, action) {
63 | if (action.payload === undefined) return state
64 | return Object.assign({}, state, {
65 | filters: action.payload !== 'none'
66 | ? action.payload.split(',')
67 | : []
68 | })
69 | },
70 | 'set overlay' (state, action) {
71 | var newState = Object.assign({}, state, {
72 | overlay: action.payload
73 | })
74 | updateURL(newState)
75 | return newState
76 | },
77 | 'set overlay from url' (state, action) {
78 | if (action.payload === undefined) return state
79 | if (!overlayOptions.some(overlayOption => overlayOption.id === action.payload)) return state
80 | return Object.assign({}, state, {
81 | overlay: action.payload
82 | })
83 | },
84 |
85 | 'set view' (state, action) {
86 | var newState = Object.assign({}, state, {
87 | view: action.payload,
88 | filters: defaultFilters(action.payload) != defaultFilters(state.view)
89 | ? defaultFilters(action.payload)
90 | : state.filters
91 | })
92 | updateURL(newState)
93 | return newState
94 | },
95 | 'set view from url' (state, action) {
96 | return Object.assign({}, state, {
97 | view: action.payload,
98 | filters: defaultFilters(action.payload) != defaultFilters(state.view)
99 | ? defaultFilters(action.payload)
100 | : state.filters
101 | })
102 | },
103 |
104 | 'set times' (state, action) {
105 | var newState = Object.assign({}, state, {
106 | times: action.payload
107 | })
108 | updateURL(newState)
109 | return newState
110 | },
111 | 'set times from url' (state, action) {
112 | if (action.payload === undefined) return state
113 | const timesArray = action.payload.split('...')
114 | if (!timesArray.every(time =>
115 | timeOptions.some(timeOption =>
116 | timeOption.id === time
117 | )
118 | )) {
119 | return state
120 | }
121 | return Object.assign({}, state, {
122 | times: timesArray
123 | })
124 | },
125 |
126 | 'set embed from url' (state, action) {
127 | return Object.assign({}, state, {
128 | embed: action.payload
129 | })
130 | },
131 | 'set theme from url' (state, action) {
132 | return Object.assign({}, state, {
133 | theme: action.payload
134 | })
135 | }
136 | }, initialState)
137 |
138 | function updateURL(state) {
139 | var view = state.view
140 | switch (view) {
141 | case 'gaps-region':
142 | view = 'gaps'
143 | break
144 | case 'country':
145 | view = 'show'
146 | break
147 | }
148 | const region = state.region
149 | const filtersPart = state.filters.length > 0
150 | ? state.filters.sort().join(',')
151 | : 'none'
152 | const overlayPart = state.overlay
153 | const timesPart = state.times.join('...')
154 | var options
155 | switch (view) {
156 | case 'compare':
157 | options = timesPart + '/' + filtersPart
158 | break
159 | case 'gaps-region':
160 | case 'gaps':
161 | options = filtersPart
162 | break
163 | default:
164 | options = filtersPart + '/' + overlayPart
165 | }
166 |
167 | const embed = state.embed ? '/embed' : ''
168 | const theme = state.theme ? '/' + state.theme : ''
169 |
170 | if (region !== null) {
171 | switch (region.type) {
172 | case 'bbox':
173 | history.replace('/'+view
174 | +'/bbox:'
175 | +region.coords.map(x => x.toFixed(5)).join(',')
176 | +'/'+options + embed + theme
177 | )
178 | break
179 | case 'polygon':
180 | history.replace('/'+view
181 | +'/polygon:'
182 | +encodeURIComponent(
183 | polyline.encode(
184 | region.coords
185 | )
186 | )
187 | +'/'+options + embed + theme
188 | )
189 | break
190 | case 'hot':
191 | history.replace('/'+view
192 | +'/hot:'
193 | +region.id
194 | +'/'+options + embed + theme
195 | )
196 | break
197 | case 'gist':
198 | history.replace('/'+view
199 | +'/gist:'
200 | +region.id
201 | +'/'+options + embed + theme
202 | )
203 | break
204 | default:
205 | throw new Error('unknown region type', region)
206 | }
207 | }
208 | }
209 |
210 | function parseRegionFromUrl(regionString) {
211 | if (!regionString) {
212 | return null
213 | }
214 | const [ regionType, regionContent ] = regionString.split(':')
215 | switch (regionType) {
216 | case 'bbox':
217 | return {
218 | type: 'bbox',
219 | coords: regionContent.split(',').map(Number)
220 | }
221 | break
222 | case 'polygon':
223 | return {
224 | type: 'polygon',
225 | coords: polyline.decode(decodeURIComponent(regionContent))
226 | }
227 | break
228 | case 'hot':
229 | return {
230 | type: 'hot',
231 | id: +regionContent
232 | }
233 | break
234 | case 'gist':
235 | return {
236 | type: 'gist',
237 | id: regionContent
238 | }
239 | break
240 | default:
241 | throw new Error('unknown region type when parsing from URL', regionString)
242 | }
243 | }
244 |
245 | function defaultFilters(view) {
246 | switch (view) {
247 | case 'default':
248 | case 'country':
249 | case 'compare':
250 | default:
251 | return defaults.filters
252 | case 'gaps':
253 | case 'gaps-region':
254 | return defaults.gapsFilters
255 | }
256 | }
257 |
--------------------------------------------------------------------------------
/app/libs/leaflet-mapbox-gl.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | L.MapboxGL = L.Layer.extend({
3 | options: {
4 | updateInterval: 32
5 | },
6 |
7 | initialize: function (options) {
8 | L.setOptions(this, options);
9 |
10 | if (options.accessToken) {
11 | mapboxgl.accessToken = options.accessToken;
12 | } else {
13 | //throw new Error('You should provide a Mapbox GL access token as a token option.');
14 | }
15 |
16 | /**
17 | * Create a version of `fn` that only fires once every `time` millseconds.
18 | *
19 | * @param {Function} fn the function to be throttled
20 | * @param {number} time millseconds required between function calls
21 | * @param {*} context the value of `this` with which the function is called
22 | * @returns {Function} debounced function
23 | * @private
24 | */
25 | var throttle = function (fn, time, context) {
26 | var lock, args, wrapperFn, later;
27 |
28 | later = function () {
29 | // reset lock and call if queued
30 | lock = false;
31 | if (args) {
32 | wrapperFn.apply(context, args);
33 | args = false;
34 | }
35 | };
36 |
37 | wrapperFn = function () {
38 | if (lock) {
39 | // called too soon, queue to call later
40 | args = arguments;
41 |
42 | } else {
43 | // call and lock until later
44 | fn.apply(context, arguments);
45 | setTimeout(later, time);
46 | lock = true;
47 | }
48 | };
49 |
50 | return wrapperFn;
51 | };
52 |
53 | // setup throttling the update event when panning
54 | this._throttledUpdate = throttle(L.Util.bind(this._update, this), this.options.updateInterval);
55 | },
56 |
57 | onAdd: function (map) {
58 | this._map = map;
59 |
60 | if (!this._glContainer) {
61 | this._initContainer();
62 | }
63 |
64 | map._panes.tilePane.appendChild(this._glContainer);
65 |
66 | this._initGL();
67 | },
68 |
69 | onRemove: function (map) {
70 | map.getPanes().tilePane.removeChild(this._glContainer);
71 | this._glMap.remove();
72 | this._glMap = null;
73 | },
74 |
75 | getEvents: function () {
76 | return {
77 | move: this._throttledUpdate, // sensibly throttle updating while panning
78 | zoomanim: this._animateZoom, // applys the zoom animation to the
79 | zoom: this._pinchZoom, // animate every zoom event for smoother pinch-zooming
80 | zoomstart: this._zoomStart, // flag starting a zoom to disable panning
81 | zoomend: this._zoomEnd // reset the gl map view at the end of a zoom
82 | }
83 | },
84 |
85 | addTo: function (map) {
86 | map.addLayer(this);
87 | this._update();
88 | return this;
89 | },
90 |
91 | setStyle: function(style) {
92 | this.options.style = style;
93 | if (this._glMap) {
94 | this._glMap.setStyle(style);
95 | }
96 | },
97 |
98 | _initContainer: function () {
99 | var container = this._glContainer = L.DomUtil.create('div', 'leaflet-gl-layer');
100 |
101 | var size = this._map.getSize();
102 | container.style.width = size.x + 'px';
103 | container.style.height = size.y + 'px';
104 | },
105 |
106 | _initGL: function () {
107 | var center = this._map.getCenter();
108 |
109 | var options = L.extend({}, this.options, {
110 | container: this._glContainer,
111 | interactive: false,
112 | center: [center.lng, center.lat],
113 | zoom: this._map.getZoom() - 1,
114 | attributionControl: false
115 | });
116 |
117 | this._glMap = new mapboxgl.Map(options);
118 |
119 | // allow GL base map to pan beyond min/max latitudes
120 | this._glMap.transform.latRange = null;
121 |
122 | // treat child element like L.ImageOverlay
123 | L.DomUtil.addClass(this._glMap._canvas.canvas, 'leaflet-image-layer');
124 | L.DomUtil.addClass(this._glMap._canvas.canvas, 'leaflet-zoom-animated');
125 | },
126 |
127 | _update: function (e) {
128 | // update the offset so we can correct for it later when we zoom
129 | this._offset = this._map.containerPointToLayerPoint([0, 0]);
130 |
131 | if (this._zooming) {
132 | return;
133 | }
134 |
135 | var size = this._map.getSize(),
136 | container = this._glContainer,
137 | gl = this._glMap,
138 | topLeft = this._map.containerPointToLayerPoint([0, 0]);
139 |
140 | L.DomUtil.setPosition(container, topLeft);
141 |
142 | var center = this._map.getCenter();
143 |
144 | // gl.setView([center.lat, center.lng], this._map.getZoom() - 1, 0);
145 | // calling setView directly causes sync issues because it uses requestAnimFrame
146 |
147 | var tr = gl.transform;
148 | tr.center = mapboxgl.LngLat.convert([center.lng, center.lat]);
149 | tr.zoom = this._map.getZoom() - 1;
150 |
151 | if (gl.transform.width !== size.x || gl.transform.height !== size.y) {
152 | container.style.width = size.x + 'px';
153 | container.style.height = size.y + 'px';
154 | gl.resize();
155 | } else {
156 | gl._update();
157 | }
158 | },
159 |
160 | // update the map constantly during a pinch zoom
161 | _pinchZoom: function (e) {
162 | this._glMap.jumpTo({
163 | zoom: this._map.getZoom() - 1,
164 | center: this._map.getCenter()
165 | });
166 | },
167 |
168 | // borrowed from L.ImageOverlay https://github.com/Leaflet/Leaflet/blob/master/src/layer/ImageOverlay.js#L139-L144
169 | _animateZoom: function (e) {
170 | var scale = this._map.getZoomScale(e.zoom),
171 | offset = this._map._latLngToNewLayerPoint(this._map.getBounds().getNorthWest(), e.zoom, e.center);
172 |
173 | L.DomUtil.setTransform(this._glMap._canvas.canvas, offset.subtract(this._offset), scale);
174 | },
175 |
176 | _zoomStart: function () {
177 | this._zooming = true;
178 | },
179 |
180 | _zoomEnd: function () {
181 | var zoom = this._map.getZoom(),
182 | center = this._map.getCenter(),
183 | offset = this._map.latLngToContainerPoint(this._map.getBounds().getNorthWest());
184 |
185 | // update the map on the next available frame to avoid stuttering
186 | L.Util.requestAnimFrame(function () {
187 | // reset the scale and offset
188 | if (!this._glMap) {
189 | return;
190 | }
191 | L.DomUtil.setTransform(this._glMap._canvas.canvas, offset, 1);
192 |
193 | // enable panning once the gl map is ready again
194 | this._glMap.once('moveend', L.Util.bind(function () {
195 | this._zooming = false;
196 | }, this));
197 |
198 | // update the map position
199 | this._glMap.jumpTo({
200 | center: center,
201 | zoom: zoom - 1
202 | });
203 | }, this);
204 | }
205 | });
206 |
207 | L.mapboxGL = function (options) {
208 | return new L.MapboxGL(options);
209 | };
210 |
--------------------------------------------------------------------------------
/app/components/CompareBar/chart.js:
--------------------------------------------------------------------------------
1 | import React, { Component, PropTypes } from 'react'
2 | import { bindActionCreators } from 'redux'
3 | import { connect } from 'react-redux'
4 | import vg from 'vega'
5 | import { debounce } from 'lodash'
6 | import * as MapActions from '../../actions/map'
7 | import { compareTimes as timeOptions } from '../../settings/options'
8 |
9 | class Timegraph extends Component {
10 | state = {
11 | vis: null
12 | }
13 |
14 | componentDidMount() {
15 | const { before, after } = this.props
16 |
17 | this.initGraph(before, after)
18 | }
19 |
20 | initGraph(before, after) {
21 | const spec = this._spec(before, after)
22 |
23 | vg.parse.spec(spec, chart => {
24 | const vis = chart({
25 | el: this.refs.chartContainer,
26 | renderer: 'svg'
27 | })
28 |
29 | // initialize graph data
30 | this.props.layers.forEach(filter => {
31 | vis.data(filter.name+'_data').insert([])
32 | })
33 | vis.update()
34 |
35 | vis.onSignal('before_drag', debounce(::this.setBeforeAfter, 200));
36 | vis.onSignal('after_drag', debounce(::this.setBeforeAfter, 200));
37 |
38 | const _prevWindowOnresize = window.onresize
39 | window.onresize = function(...args) {
40 | _prevWindowOnresize && _prevWindowOnresize.apply(this, args)
41 | vis.width(window.innerWidth-90).update()
42 | }
43 | vis.width(window.innerWidth-90).update()
44 |
45 | this.setState({ vis })
46 | })
47 | }
48 |
49 | setBeforeAfter(signal, value) {
50 | // search nearest snapshot to dragged timestamp
51 | var nearestDist = Infinity
52 | var nearest
53 | timeOptions.forEach(timeOption => {
54 | let dist = Math.abs(timeOption.timestamp - value)
55 | if (dist < nearestDist) {
56 | nearestDist = dist
57 | nearest = timeOption.id
58 | }
59 | })
60 | var newTimes = this.props.map.times.slice()
61 | if (signal === 'before_drag') {
62 | newTimes[0] = nearest
63 | }
64 | if (signal === 'after_drag') {
65 | newTimes[1] = nearest
66 | }
67 | this.props.actions.setTimes(newTimes)
68 | }
69 |
70 |
71 | componentDidUpdate() {
72 | const { vis } = this.state
73 | const { data, before, after } = this.props
74 |
75 | if (vis) {
76 | // update data in case it changed
77 | this.props.layers.forEach(filter => {
78 | vis.data(filter.name+'_data').remove(() => true).insert(data[filter.name] && data[filter.name].filter(x => x) || [])
79 | })
80 | // update before/after drag markers
81 | let beforeTimestamp = +timeOptions.find(timeOption => timeOption.id === before).timestamp
82 | if (vis.signal('before_drag') !== beforeTimestamp) {
83 | vis.signal('before_drag', beforeTimestamp)
84 | }
85 | let afterTimestamp = +timeOptions.find(timeOption => timeOption.id === after).timestamp
86 | if (vis.signal('after_drag') !== afterTimestamp) {
87 | vis.signal('after_drag', afterTimestamp)
88 | }
89 | vis.update()
90 | }
91 | }
92 |
93 | render() {
94 | return (
95 |
96 | )
97 | }
98 |
99 |
100 | _spec(before, after) {
101 | const filters = this.props.layers.map(filter => filter.name)
102 | var styleSpec = {
103 | "width": 1e6,
104 | "height": 90,
105 | "padding": {"top": 20, "left": 40, "bottom": 30, "right": 5},
106 |
107 | "signals": [{
108 | "name": "before_drag",
109 | "init": +timeOptions.find(timeOption => timeOption.id === before).timestamp,
110 | "streams": [{
111 | "type": "@before:mousedown, [@before:mousedown, window:mouseup] > window:mousemove",
112 | "expr": "iscale('x', clamp(eventX(), 0, width))"
113 | }]
114 | }, {
115 | "name": "after_drag",
116 | "init": +timeOptions.find(timeOption => timeOption.id === after).timestamp,
117 | "streams": [{
118 | "type": "@after:mousedown, [@after:mousedown, window:mouseup] > window:mousemove",
119 | "expr": "iscale('x', clamp(eventX(), 0, width))"
120 | }]
121 | }],
122 |
123 | "data": [],
124 | "scales": [
125 | {
126 | "name": "x",
127 | "type": "time",
128 | "range": "width",
129 | "domainMin": +(timeOptions[0].timestamp),
130 | "domainMax": +(timeOptions[timeOptions.length-1].timestamp)
131 | }
132 | ],
133 | "axes": [
134 | {
135 | "type": "x",
136 | "scale": "x",
137 | "tickSizeEnd": 0,
138 | "properties": {
139 | "axis": {
140 | "stroke": {"value": "#C2C2C2"},
141 | "strokeWidth": {"value": 1}
142 | },
143 | "ticks": {
144 | "stroke": {"value": "#C2C2C2"}
145 | },
146 | "majorTicks": {
147 | "strokeWidth": {"value": 2}
148 | },
149 | "labels": {
150 | "fontSize": {"value": 14},
151 | "fill": {"value": "#BCBCBC"}
152 | }
153 | }
154 | }
155 | ],
156 | "marks": [{
157 | "name": "before",
158 | "type": "rect",
159 | "properties": {
160 | "enter": {
161 | "fill": {"value": "#BCE3E9"},
162 | "fillOpacity": {"value": 1},
163 | "stroke": {"value": "#000"},
164 | "strokeWidth": {"value": 15},
165 | "strokeOpacity": {"value": 0.0},
166 | "cursor": {"value": "ew-resize"}
167 | },
168 | "update": {
169 | "x": {"scale": "x", "signal": "before_drag"},
170 | "width": {"value": 3},
171 | "y": {"value": 30-8},
172 | "height": {"value": 70+2*8}
173 | }
174 | }
175 | }, {
176 | "name": "after",
177 | "type": "rect",
178 | "properties": {
179 | "enter": {
180 | "fill": {"value": "#BCE3E9"},
181 | "fillOpacity": {"value": 1},
182 | "stroke": {"value": "#000"},
183 | "strokeWidth": {"value": 15},
184 | "strokeOpacity": {"value": 0.0},
185 | "cursor": {"value": "ew-resize"}
186 | },
187 | "update": {
188 | "x": {"scale": "x", "signal": "after_drag"},
189 | "width": {"value": 3},
190 | "y": {"value": 30-8},
191 | "height": {"value": 70+2*8}
192 | }
193 | }
194 | }]
195 | }
196 |
197 | styleSpec.data = filters.map(filter => ({
198 | "name": filter+"_data",
199 | "format": {"type": "json", "parse": {"day": "date"}}
200 | }))
201 | styleSpec.scales = styleSpec.scales.concat(
202 | filters.map(filter => ({
203 | "name": filter+"_y",
204 | "type": "linear",
205 | "range": "height",
206 | "domain": {"data": filter+"_data", "field": "value"}
207 | }))
208 | )
209 | styleSpec.marks = styleSpec.marks.concat(filters.map(filter => ({
210 | "type": "line",
211 | "from": {"data": filter+"_data"},
212 | "properties": {
213 | "enter": {
214 | "interpolate": {"value": "cardinal"},
215 | "tension": {"value": 0.8},
216 | "stroke": {"value": "#FDB863"},
217 | "strokeWidth": {"value": 2}
218 | },
219 | "update": {
220 | "x": {"scale": "x", "field": "day"},
221 | "y": {"scale": filter+"_y", "field": "value"}
222 | }
223 | }
224 | })),
225 | filters.map(filter => ({
226 | "name": filter,
227 | "type": "symbol",
228 | "from": {"data": filter+"_data"},
229 | "properties": {
230 | "enter": {
231 | "stroke": {"value": "#FDB863"},
232 | "fill": {"value": "#4B5A6A"},
233 | "size": {"value": 30}
234 | },
235 | "update": {
236 | "x": {"scale": "x", "field": "day"},
237 | "y": {"scale": filter+"_y", "field": "value"}
238 | }
239 | }
240 | })),
241 | filters.map(filter => ({
242 | "type": "text",
243 | "properties": {
244 | "enter": {
245 | "align": {"value": "right"},
246 | "fill": {"value": "#BCBCBC"}
247 | },
248 | "update": {
249 | "x": {"scale": "x", "signal": filter+"_tooltip.day", "offset": -8},
250 | "y": {"scale": filter+"_y", "signal": filter+"_tooltip.value", "offset": -5},
251 | "text": {"signal": filter+"_tooltipText"},
252 | "fillOpacity": [
253 | { "test": "!"+filter+"_tooltip._id",
254 | "value": 0
255 | },
256 | {"value": 1}
257 | ]
258 | }
259 | }
260 | })))
261 |
262 | styleSpec.signals = styleSpec.signals.concat(filters.map(filter => ({
263 | "name": filter+"_tooltip",
264 | "init": {},
265 | "streams": [
266 | {"type": "@"+filter+":mouseover", "expr": "datum"},
267 | {"type": "@"+filter+":mouseout", "expr": "{}"}
268 | ]
269 | })))
270 | styleSpec.signals = styleSpec.signals.concat(filters.map(filter => ({
271 | "name": filter+"_tooltipText",
272 | "init": {},
273 | "streams": [
274 | {"type": filter+"_tooltip", "expr": "round("+filter+"_tooltip.value)"}
275 | ]
276 | })))
277 |
278 | return styleSpec
279 | }
280 |
281 | }
282 |
283 |
284 | function mapStateToProps(state) {
285 | return {
286 | map: state.map,
287 | }
288 | }
289 |
290 | function mapDispatchToProps(dispatch) {
291 | return {
292 | actions: bindActionCreators(MapActions, dispatch)
293 | }
294 | }
295 |
296 | export default connect(
297 | mapStateToProps,
298 | mapDispatchToProps
299 | )(Timegraph)
300 |
--------------------------------------------------------------------------------
/app/components/Map/gaps.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import style from './style.css'
3 | import glStyles from './glstyles'
4 | import Swiper from './swiper'
5 | import GapsFilterButton from '../FilterButton/gaps.js'
6 | import SearchBox from '../SearchBox'
7 | import GapsLegend from '../Legend/gaps.js'
8 | import ThresholdSelector from '../ThresholdSelector'
9 | import DropdownButton from '../DropdownButton'
10 | import { bindActionCreators } from 'redux'
11 | import { connect } from 'react-redux'
12 | import * as MapActions from '../../actions/map'
13 | import { bboxPolygon, area, difference as erase } from 'turf'
14 | import { debounce } from 'lodash'
15 | import regionToCoords from './regionToCoords'
16 | import themes from '../../settings/themes'
17 | import settings from '../../settings/settings'
18 | import { gapsFilters } from '../../settings/options'
19 | import GapsLayer from './gapsLayer.js'
20 |
21 | // leaflet plugins
22 | import * as _leafletmapboxgljs from '../../libs/leaflet-mapbox-gl.js'
23 | import * as _leafleteditable from '../../libs/Leaflet.Editable.js'
24 |
25 | var map // Leaflet map object
26 | var backgroundLayers
27 | var gapsLayer
28 | var glLayer // mapbox-gl layer
29 | var boundsLayer = null // selected region layer
30 | var moveDirectly = false
31 |
32 | var backgrounds = [
33 | {
34 | id: 'default',
35 | description: 'plain base map',
36 | attribution: settings['map-attribution'],
37 | url: settings['map-background-tile-layer']
38 | },
39 | {
40 | id: 'mapbox-satellite',
41 | description: 'satellite imagery (mapbox)',
42 | attribution: '© Mapbox ',
43 | url: 'https://api.mapbox.com/v4/mapbox.satellite/{z}/{x}/{y}.png?access_token=pk.eyJ1IjoiaG90IiwiYSI6ImNpbmx4bWN6ajAwYTd3OW0ycjh3bTZvc3QifQ.KtikS4sFO95Jm8nyiOR4gQ'
44 | },
45 | {
46 | id: 'esri-satellite',
47 | description: 'satellite imagery (esri)',
48 | attribution: '© ESRI ',
49 | url: 'https://server.arcgisonline.com/arcgis/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'
50 | },
51 | {
52 | id: 'osm',
53 | description: 'openstreetmap.org',
54 | attribution: '© OpenStreetMap contributors ',
55 | url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'
56 | },
57 | {
58 | id: 'worldpop',
59 | description: 'worldpop.org.uk',
60 | attribution: '© worldpop.org.uk ',
61 | url: 'http://maps.worldpop.org.uk/tilesets/wp-global-100m-ppp-2010-adj/{z}/{x}/{y}.png'
62 | }
63 | ]
64 |
65 |
66 |
67 | class GapsMap extends Component {
68 | state = {}
69 |
70 | changeBackground(newLayer) {
71 | backgroundLayers.forEach(layer => layer.removeFrom(map))
72 | backgroundLayers.find(layer => layer.options.id === newLayer[0]).addTo(map)
73 | }
74 |
75 | render() {
76 | const { view, actions, embed, theme } = this.props
77 | const containerClassName = (embed === false) ? `${view}View` : '';
78 |
79 | var btn = background map ▾
80 |
81 | return (
82 |
83 |
84 |
85 | {false ?
: ""}
86 |
87 | {embed === false &&
88 |
89 | or
90 | Outline Custom Area
91 |
92 |
99 |
}
100 |
101 |
filter.id === this.props.map.filters[0])}
105 | theme={theme}
106 | />
107 |
114 |
115 | )
116 | }
117 |
118 | componentDidMount() {
119 | const { theme, embed } = this.props
120 |
121 | map = L.map(
122 | 'map', {
123 | editable: true,
124 | minZoom: 2,
125 | scrollWheelZoom: !embed
126 | })
127 | .setView([2, 26], 4)
128 | map.on('editable:editing', debounce(::this.setCustomRegion, 200))
129 | map.on('zoomend', (e) => { this.setState({ mapZoomLevel:map.getZoom() }) })
130 |
131 | L.control.scale({ position: 'bottomright' }).addTo(map)
132 | map.zoomControl.setPosition('bottomright')
133 |
134 | gapsLayer = (new GapsLayer({
135 | tileSize: 512,
136 | maxNativeZoom: 13,
137 | attribution: '© OpenStreetMap contributors '
138 | })).addTo(map);
139 |
140 | backgroundLayers = backgrounds.map(background => L.tileLayer(background.url, {
141 | id: background.id,
142 | attribution: 'background ' + background.attribution,
143 | zIndex: -1
144 | }))
145 | backgroundLayers[0].addTo(map)
146 |
147 | // init from route params
148 | if (this.props.region) {
149 | moveDirectly = true
150 | this.mapSetRegion(this.props.map.region, this.props.embed === false, this.props.embed === false)
151 | }
152 | }
153 |
154 | componentWillReceiveProps(nextProps) {
155 | const { theme } = this.props
156 |
157 | // ceck for changed url parameters
158 | if (nextProps.region !== this.props.region) {
159 | this.props.actions.setRegionFromUrl(nextProps.region)
160 | }
161 | if (nextProps.filters !== this.props.filters) {
162 | this.props.actions.setFiltersFromUrl(nextProps.filters)
163 | }
164 | if (nextProps.overlay !== this.props.overlay) {
165 | this.props.actions.setOverlayFromUrl(nextProps.overlay)
166 | }
167 | if (nextProps.overlay !== this.props.overlay) {
168 | this.props.actions.setOverlayFromUrl(nextProps.overlay)
169 | }
170 | if (nextProps.view !== this.props.view) {
171 | this.props.actions.setViewFromUrl(nextProps.view)
172 | }
173 | if (nextProps.times !== this.props.times) {
174 | this.props.actions.setTimesFromUrl(nextProps.times)
175 | }
176 | // check for changed map parameters
177 | if (nextProps.map.region !== this.props.map.region) {
178 | this.mapSetRegion(nextProps.map.region, nextProps.embed === false, nextProps.embed === false)
179 | }
180 | if (nextProps.map.filters.join() !== this.props.map.filters.join()) { // todo: handle this in reducer?
181 | // todo: rerender
182 | }
183 | }
184 |
185 | setThreshold(newThreshold) {
186 | this.setState({threshold: newThreshold})
187 | this.refreshGapsLayer(newThreshold)
188 | }
189 |
190 | refreshGapsLayer = debounce((newThreshold) => {
191 | gapsLayer.threshold = newThreshold
192 | gapsLayer.redraw()
193 | }, 300)
194 |
195 |
196 | setViewportRegion() {
197 | var pixelBounds = map.getPixelBounds()
198 | var paddedLatLngBounds = L.latLngBounds(
199 | map.unproject(
200 | pixelBounds.getBottomLeft().add([30,-(20+212)])
201 | ),
202 | map.unproject(
203 | pixelBounds.getTopRight().subtract([30,-(70+52)])
204 | )
205 | ).pad(-0.15)
206 | this.props.actions.setRegion({
207 | type: 'bbox',
208 | coords: paddedLatLngBounds
209 | .toBBoxString()
210 | .split(',')
211 | .map(Number)
212 | })
213 | }
214 |
215 | setCustomRegion() {
216 | if (!boundsLayer) return
217 | this.props.actions.setRegion({
218 | type: 'polygon',
219 | coords: L.polygon(boundsLayer.getLatLngs()[1]).toGeoJSON().geometry.coordinates[0].slice(0,-1)
220 | })
221 | }
222 |
223 | mapSetRegion(region, isEditable, fitBoundsWithBottomPadding) {
224 | const { swiper: { poly } } = themes[this.props.theme]
225 |
226 | if (boundsLayer !== null && region === null) {
227 | map.removeLayer(boundsLayer)
228 | return
229 | }
230 | regionToCoords(region, 'leaflet')
231 | .then(function(region) {
232 | let coords = region.geometry.coordinates
233 |
234 | if (boundsLayer !== null) {
235 | map.removeLayer(boundsLayer)
236 | }
237 | boundsLayer = L[poly.shape](
238 | [[[-85.0511287798,-1E5],[85.0511287798,-1E5],[85.0511287798,2E5],[-85.0511287798,2E5],[-85.0511287798,-1E5]]]
239 | .concat(coords), {
240 | weight: poly.weight,
241 | color: poly.color,
242 | interactive: false
243 | }).addTo(map)
244 |
245 | if (isEditable) {
246 | boundsLayer.enableEdit()
247 | }
248 |
249 | // set map view to region
250 | try { // geometry calculcation are a bit hairy for invalid geometries (which may happen during polygon editing)
251 | let viewPort = bboxPolygon(map.getBounds().toBBoxString().split(',').map(Number))
252 | let xorAreaViewPort = erase(viewPort, L.polygon(boundsLayer.getLatLngs()[1]).toGeoJSON())
253 | let fitboundsFunc
254 | if (moveDirectly) {
255 | fitboundsFunc = ::map.fitBounds
256 | moveDirectly = false
257 | } else if (
258 | !xorAreaViewPort // new region fully includes viewport
259 | || area(xorAreaViewPort) > area(viewPort)*(1-0.01) // region is small compared to current viewport (<10% of the area covered) or feature is outside current viewport
260 | ) {
261 | fitboundsFunc = ::map.flyToBounds
262 | } else {
263 | fitboundsFunc = () => {}
264 | }
265 | fitboundsFunc(
266 | // zoom to inner ring!
267 | boundsLayer.getLatLngs().slice(1)
268 | .map(coords => L.polygon(coords).getBounds())
269 | .reduce((bounds1, bounds2) => bounds1.extend(bounds2)),
270 | {
271 | paddingTopLeft: [20, 10+52],
272 | paddingBottomRight: [20, 10+ ((fitBoundsWithBottomPadding) ? 212 : 52)]
273 | })
274 | } catch(e) {}
275 | });
276 | }
277 |
278 | }
279 |
280 |
281 |
282 | function mapStateToProps(state) {
283 | return {
284 | map: state.map,
285 | stats: state.stats
286 | }
287 | }
288 |
289 | function mapDispatchToProps(dispatch) {
290 | return {
291 | actions: bindActionCreators(MapActions, dispatch)
292 | }
293 | }
294 |
295 | export default connect(
296 | mapStateToProps,
297 | mapDispatchToProps
298 | )(GapsMap)
299 |
--------------------------------------------------------------------------------
/app/components/Stats/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import { bindActionCreators } from 'redux'
3 | import { connect } from 'react-redux'
4 | import Modal from 'react-modal'
5 | import { polygon } from 'turf'
6 | import { queue } from 'd3-queue'
7 | import moment from 'moment'
8 | import * as MapActions from '../../actions/map'
9 | import * as StatsActions from '../../actions/stats'
10 | import OverlayButton from '../OverlayButton'
11 | import UnitSelector from '../UnitSelector'
12 | import Histogram from './chart'
13 | import ContributorsModal from './contributorsModal'
14 | import SubTagsModal from './subTagsModal'
15 | import HotProjectsModal from './hotProjectsModal'
16 | import regionToCoords from '../Map/regionToCoords'
17 | import searchHotProjectsInRegion from './searchHotProjects'
18 | import searchFeatures from './searchFeatures'
19 | import unitSystems from '../../settings/unitSystems'
20 | import style from './style.css'
21 |
22 |
23 | const modalStyles = {
24 | overlay: {
25 | backgroundColor: 'rgba(60,60,60, 0.5)'
26 | },
27 | content: {
28 | top: '50%',
29 | left: '50%',
30 | right: 'auto',
31 | bottom: 'auto',
32 | marginRight: '-50%',
33 | transform: 'translate(-50%, -50%)',
34 | maxHeight: '350px',
35 | maxWidth: '512px',
36 | minWidth: '300px',
37 | borderRadius: '4px',
38 | paddingTop: '25px',
39 | paddingBottom: '35px',
40 | paddingLeft: '35px',
41 | paddingRight: '35px'
42 | }
43 | }
44 |
45 | class Stats extends Component {
46 | state = {
47 | features: [],
48 | hotProjects: [],
49 | hotProjectsModalOpen: false,
50 | updating: false
51 | }
52 |
53 | applySelection(timestamp, userExperience, selection) {
54 | if (!Array.isArray(timestamp)) {
55 | timestamp = [timestamp, timestamp]
56 | }
57 | if (!Array.isArray(userExperience)) {
58 | userExperience = [userExperience, userExperience]
59 | }
60 | return (
61 | (selection.timeFilter === null || (
62 | timestamp[1] >= selection.timeFilter[0] && timestamp[0] <= selection.timeFilter[1]
63 | )) &&
64 | (selection.experienceFilter === null || (
65 | userExperience[1] >= selection.experienceFilter[0] && userExperience[0] <= selection.experienceFilter[1]
66 | ))
67 | )
68 | }
69 |
70 | render() {
71 | var features = this.state.features
72 | const activeLayer = this.props.layers.find(layer => layer.name === this.props.map.filters[0])
73 |
74 | // apply time and experience filters
75 | features.forEach(filter => {
76 | // do not override!
77 | filter.highlightedFeatures = filter.features.filter(feature => {
78 | if (feature.properties._timestamp) {
79 | return this.applySelection(
80 | feature.properties._timestamp,
81 | feature.properties._userExperience,
82 | this.props.stats
83 | )
84 | } else {
85 | return this.applySelection(
86 | [feature.properties._timestampMin, feature.properties._timestampMax],
87 | [feature.properties._userExperienceMin, feature.properties._userExperienceMax],
88 | this.props.stats
89 | )
90 | }
91 | })
92 | })
93 |
94 | // calculate number of contributors
95 | var contributors = {}
96 | var subTags = {}
97 | var sampledContributorCounts = false
98 | var featureCount = 0
99 | features.forEach(filter => {
100 | if (filter.features.length > 0 && filter.features[0].properties.tile.z < 13) {
101 | // on the low zoom levels we don't have complete data, but only samples.
102 | // estimating the total contributor count number from a sample is tricky.
103 | // for now just display a lower limit (e.g. "432+" contributors)
104 | // todo: maybe a Good-Turing estimation could be used here? see https://en.wikipedia.org/wiki/Good%E2%80%93Turing_frequency_estimation
105 | sampledContributorCounts = true
106 | filter.features.forEach(f => {
107 | var timestamps = f.properties._timestamps.split(";").map(Number)
108 | var userExperiences = f.properties._userExperiences.split(";").map(Number)
109 | var uids = f.properties._uids.split(";").map(Number)
110 | var tagValues = f.properties._tagValues.split(";")
111 | var matchingSamples = 0
112 | for (var i=0; i {
126 | contributors[f.properties._uid] = (contributors[f.properties._uid] || 0) + 1
127 | subTags[f.properties._tagValue] = (subTags[f.properties._tagValue] || 0) + 1
128 | featureCount++
129 | })
130 | }
131 | })
132 | contributors = Object.keys(contributors).map(uid => ({
133 | uid: uid,
134 | contributions: contributors[uid]
135 | })).sort((a,b) => b.contributions - a.contributions)
136 | var numContributors = contributors.length
137 | subTags = Object.keys(subTags).map(subTag => ({
138 | subTag: subTag,
139 | count: subTags[subTag]
140 | })).sort((a,b) => b.count - a.count)
141 | var numSubTags = subTags.length
142 | featureCount = Math.round(featureCount)
143 |
144 | var timeFilter = ''
145 | if (this.props.stats.timeFilter) {
146 | timeFilter = (
147 | {moment.unix(this.props.stats.timeFilter[0]).format('YYYY MMMM D')} – {moment.unix(this.props.stats.timeFilter[1]).format('YYYY MMMM D')}
148 | )
149 | }
150 |
151 | // todo: loading animation if region is not yet fully loaded
152 | return (
153 |
154 |
155 |
156 |
157 | {timeFilter}
158 |
159 | {features.map(filter => {
160 | const isLinearFeatureLayer = this.props.layers.find(layer => layer.name === filter.filter).filter.geometry === "LineString"
161 | return (
162 | {
163 | numberWithCommas(Number((isLinearFeatureLayer
164 | ? unitSystems[this.props.stats.unitSystem].distance.convert(
165 | filter.highlightedFeatures.reduce((prev, feature) => prev+(feature.properties._length || 0.0), 0.0)
166 | )
167 | : featureCount //filter.highlightedFeatures.reduce((prev, feature) => prev+(feature.properties._count || 1), 0))
168 | )).toFixed(0))
169 | }
170 | {isLinearFeatureLayer
171 | ? f.name === filter.filter).title}
175 | setUnitSystem={this.props.statsActions.setUnitSystem}
176 | />
177 | : {this.props.layers.find(f => f.name === filter.filter).title}
178 | }
179 | )
180 | })}
181 |
182 | {this.state.hotProjects.length > 0
183 | ? {this.state.hotProjects.length}
184 | : this.state.hotProjects.length
185 | } HOT Projects
186 |
187 |
188 |
189 | {numberWithCommas(numContributors) + (sampledContributorCounts ? "+" : "")}
190 | Contributors
191 |
192 |
193 |
194 | {numberWithCommas(numSubTags) + (sampledContributorCounts ? "+" : "")}
195 | Distinct Tags
196 |
197 |
198 |
199 |
200 |
Compare Time Periods
201 |
Close
202 |
203 |
204 |
210 |
216 |
0 && this.props.layers.find(layer => layer.name === features[0].filter).filter.tagKey}
221 | subTags={subTags}
222 | />
223 |
224 | prev.concat(filter.features), [])
226 | }/>
227 |
228 | )
229 | }
230 |
231 | componentDidMount() {
232 | if (this.props.map.region) {
233 | ::this.update(this.props.map.region, this.props.map.filters)
234 | }
235 | }
236 |
237 | componentWillReceiveProps(nextProps) {
238 | // check for changed map parameters
239 | if (nextProps.map.region !== this.props.map.region
240 | || nextProps.map.filters !== this.props.map.filters) {
241 | ::this.update(nextProps.map.region, nextProps.map.filters)
242 | }
243 | }
244 |
245 | update(region, filters) {
246 | regionToCoords(region)
247 | .then((function(region) {
248 | this.setState({ updating: true, features: [] })
249 | var q = queue()
250 | filters.forEach(filter =>
251 | q.defer(searchFeatures, region, filter)
252 | )
253 | q.awaitAll(function(err, data) {
254 | if (err) throw err
255 | this.setState({
256 | features: data.map((d,index) => ({
257 | filter: filters[index],
258 | features: d.features
259 | })),
260 | updating: false
261 | })
262 | }.bind(this))
263 | const hotProjects = searchHotProjectsInRegion(region)
264 | this.setState({ hotProjects })
265 | }).bind(this));
266 | }
267 |
268 |
269 | openHotModal() {
270 | this.setState({ hotProjectsModalOpen: true })
271 | }
272 | closeHotModal() {
273 | this.setState({ hotProjectsModalOpen: false })
274 | }
275 | openContributorsModal() {
276 | this.setState({ contributorsModalOpen: true })
277 | }
278 | closeContributorsModal() {
279 | this.setState({ contributorsModalOpen: false })
280 | }
281 | openSubTagsModal() {
282 | this.setState({ subTagsModalOpen: true })
283 | }
284 | closeSubTagsModal() {
285 | this.setState({ subTagsModalOpen: false })
286 | }
287 |
288 | enableCompareView() {
289 | this.props.actions.setView('compare')
290 | }
291 | }
292 |
293 |
294 | function numberWithCommas(x) {
295 | return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
296 | }
297 |
298 |
299 | function mapStateToProps(state) {
300 | return {
301 | map: state.map,
302 | stats: state.stats
303 | }
304 | }
305 |
306 | function mapDispatchToProps(dispatch) {
307 | return {
308 | actions: bindActionCreators(MapActions, dispatch),
309 | statsActions: bindActionCreators(StatsActions, dispatch)
310 | }
311 | }
312 |
313 | export default connect(
314 | mapStateToProps,
315 | mapDispatchToProps
316 | )(Stats)
317 |
--------------------------------------------------------------------------------
/app/components/Map/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import style from './style.css'
3 | import glStyles, { getCompareStyles } from './glstyles'
4 | import Swiper from './swiper'
5 | import FilterButton from '../FilterButton'
6 | import SearchBox from '../SearchBox'
7 | import Legend from '../Legend'
8 | import { bindActionCreators } from 'redux'
9 | import { connect } from 'react-redux'
10 | import * as MapActions from '../../actions/map'
11 | import { bboxPolygon, area, difference as erase } from 'turf'
12 | import { debounce } from 'lodash'
13 | import regionToCoords from './regionToCoords'
14 | import themes from '../../settings/themes'
15 | import settings from '../../settings/settings'
16 |
17 | // leaflet plugins
18 | import * as _leafletmapboxgljs from '../../libs/leaflet-mapbox-gl.js'
19 | import * as _leafleteditable from '../../libs/Leaflet.Editable.js'
20 |
21 | var map // Leaflet map object
22 | var glLayer // mapbox-gl layer
23 | var glCompareLayers // mapbox-gl layers for before/after view
24 | var boundsLayer = null // selected region layer
25 | var moveDirectly = false
26 |
27 | class Map extends Component {
28 | state = {}
29 |
30 | render() {
31 | const { view, actions, embed, theme } = this.props
32 | const containerClassName = (embed === false) ? `${view}View` : ''
33 | const activeLayer = this.props.layers.find(layer => layer.name === this.props.map.filters[0])
34 | return (
35 |
36 |
37 |
38 | {this.props.map.view === 'compare'
39 | ?
40 | : ''
41 | }
42 |
43 | {embed === false &&
44 |
45 | or
46 | Outline Custom Area
47 |
52 |
}
53 |
54 |
60 |
61 | )
62 | }
63 |
64 | componentDidMount() {
65 | const { theme, embed, layers } = this.props
66 |
67 | const activeLayer = layers.find(layer => layer.name === this.props.map.filters[0])
68 |
69 | map = L.map(
70 | 'map', {
71 | editable: true,
72 | minZoom: 2,
73 | scrollWheelZoom: !embed
74 | })
75 | .setView([0, 35], 2)
76 | map.on('editable:editing', debounce(::this.setCustomRegion, 200))
77 | map.on('zoomend', (e) => { this.setState({ mapZoomLevel:map.getZoom() }) })
78 |
79 | L.control.scale({ position: 'bottomright' }).addTo(map)
80 | map.zoomControl.setPosition('bottomright')
81 |
82 | L.tileLayer(settings['map-background-tile-layer'], {
83 | attribution: settings['map-attribution'],
84 | zIndex: -1
85 | }).addTo(map)
86 |
87 | if (!mapboxgl.supported()) {
88 | alert('This browser does not support WebGL which is required to run this application. Please check that you are using a supported browser and that WebGL is enabled.')
89 | }
90 | glLayer = L.mapboxGL({
91 | updateInterval: 0,
92 | style: glStyles(layers, activeLayer, { theme }),
93 | hash: false
94 | })
95 |
96 | const glCompareLayerStyles = getCompareStyles(layers, activeLayer, this.props.map.times, theme)
97 | glCompareLayers = {
98 | before: L.mapboxGL({
99 | updateInterval: 0,
100 | style: glCompareLayerStyles.before,
101 | hash: false
102 | }),
103 | after: L.mapboxGL({
104 | updateInterval: 0,
105 | style: glCompareLayerStyles.after,
106 | hash: false
107 | })
108 | }
109 |
110 | // add glLayers if map state is already initialized
111 | if (this.props.map.view === 'country' || this.props.map.view === 'default') {
112 | glLayer.addTo(map)
113 | } else if (this.props.map.view === 'compare') {
114 | glCompareLayers.before.addTo(map)
115 | glCompareLayers.after.addTo(map)
116 | this.swiperMoved(window.innerWidth/2)
117 | }
118 |
119 | // init from route params
120 | if (this.props.region) {
121 | moveDirectly = true
122 | this.mapSetRegion(this.props.map.region, this.props.embed === false, this.props.embed === false)
123 | }
124 |
125 | if (this.props.stats.timeFilter) {
126 | glLayer._glMap.on('load', () =>
127 | this.setTimeFilter(this.props.stats.timeFilter)
128 | )
129 | }
130 | }
131 |
132 | componentWillReceiveProps(nextProps) {
133 | const { theme, layers } = this.props
134 |
135 | // ceck for changed url parameters
136 | if (nextProps.region !== this.props.region) {
137 | this.props.actions.setRegionFromUrl(nextProps.region)
138 | }
139 | if (nextProps.filters !== this.props.filters) {
140 | this.props.actions.setFiltersFromUrl(nextProps.filters)
141 | }
142 | if (nextProps.overlay !== this.props.overlay) {
143 | this.props.actions.setOverlayFromUrl(nextProps.overlay)
144 | }
145 | if (nextProps.overlay !== this.props.overlay) {
146 | this.props.actions.setOverlayFromUrl(nextProps.overlay)
147 | }
148 | if (nextProps.view !== this.props.view) {
149 | this.props.actions.setViewFromUrl(nextProps.view)
150 | }
151 | if (nextProps.times !== this.props.times) {
152 | this.props.actions.setTimesFromUrl(nextProps.times)
153 | }
154 | // check for changed map parameters
155 | if (nextProps.map.region !== this.props.map.region) {
156 | this.mapSetRegion(nextProps.map.region, nextProps.embed === false, nextProps.embed === false)
157 | }
158 | const nextActiveLayer = layers.find(layer => layer.name === nextProps.map.filters[0])
159 | if (nextProps.map.filters.join() !== this.props.map.filters.join()) { // todo: handle this in reducer?
160 | glLayer.setStyle(glStyles(layers, nextActiveLayer, {
161 | timeFilter: nextProps.stats.timeFilter,
162 | experienceFilter: nextProps.stats.experienceFilter,
163 | theme
164 | }))
165 | let glCompareLayerStyles = getCompareStyles(layers, nextActiveLayer, nextProps.map.times, theme)
166 | glCompareLayers.before.setStyle(glCompareLayerStyles.before)
167 | glCompareLayers.after.setStyle(glCompareLayerStyles.after)
168 | }
169 | if (nextProps.map.times !== this.props.map.times) {
170 | let glCompareLayerStyles = getCompareStyles(layers, nextActiveLayer, nextProps.map.times, theme)
171 | if (nextProps.map.times[0] !== this.props.map.times[0]) {
172 | glCompareLayers.before.setStyle(glCompareLayerStyles.before)
173 | }
174 | if (nextProps.map.times[1] !== this.props.map.times[1]) {
175 | glCompareLayers.after.setStyle(glCompareLayerStyles.after)
176 | }
177 | }
178 | // check for changed time/experience filter
179 | if (nextProps.stats.timeFilter !== this.props.stats.timeFilter) {
180 | this.setTimeFilter(nextProps.stats.timeFilter)
181 | }
182 | if (nextProps.stats.experienceFilter !== this.props.stats.experienceFilter) {
183 | this.setExperienceFilter(nextProps.stats.experienceFilter)
184 | }
185 | // check for switched map views (country/compare)
186 | if (nextProps.map.view !== this.props.map.view) {
187 | if (!(this.props.map.view === 'country' || this.props.map.view === 'default')
188 | && (nextProps.map.view === 'country' || nextProps.map.view === 'default')) {
189 | glCompareLayers.before.removeFrom(map)
190 | glCompareLayers.after.removeFrom(map)
191 | glLayer.addTo(map)
192 | }
193 | if (nextProps.map.view === 'compare') {
194 | glLayer.removeFrom(map)
195 | glCompareLayers.before.addTo(map)
196 | glCompareLayers.after.addTo(map)
197 | this.swiperMoved(window.innerWidth/2)
198 | }
199 | }
200 | }
201 |
202 | setViewportRegion() {
203 | var pixelBounds = map.getPixelBounds()
204 | var paddedLatLngBounds = L.latLngBounds(
205 | map.unproject(
206 | pixelBounds.getBottomLeft().add([30,-(20+212)])
207 | ),
208 | map.unproject(
209 | pixelBounds.getTopRight().subtract([30,-(70+52)])
210 | )
211 | ).pad(-0.15)
212 | this.props.actions.setRegion({
213 | type: 'bbox',
214 | coords: paddedLatLngBounds
215 | .toBBoxString()
216 | .split(',')
217 | .map(Number)
218 | })
219 | }
220 |
221 | setCustomRegion() {
222 | if (!boundsLayer) return
223 | this.props.actions.setRegion({
224 | type: 'polygon',
225 | coords: L.polygon(boundsLayer.getLatLngs()[1]).toGeoJSON().geometry.coordinates[0].slice(0,-1)
226 | })
227 | }
228 |
229 | mapSetRegion(region, isEditable, fitBoundsWithBottomPadding) {
230 | const { swiper: { poly } } = themes[this.props.theme]
231 |
232 | if (boundsLayer !== null && region === null) {
233 | map.removeLayer(boundsLayer)
234 | return
235 | }
236 | regionToCoords(region, 'leaflet')
237 | .then(function(region) {
238 | let coords = region.geometry.coordinates
239 |
240 | if (boundsLayer !== null) {
241 | map.removeLayer(boundsLayer)
242 | }
243 | boundsLayer = L[poly.shape](
244 | [[[-85.0511287798,-1E5],[85.0511287798,-1E5],[85.0511287798,2E5],[-85.0511287798,2E5],[-85.0511287798,-1E5]]]
245 | .concat(coords), {
246 | weight: poly.weight,
247 | color: poly.color,
248 | interactive: false
249 | }).addTo(map)
250 |
251 | if (isEditable) {
252 | boundsLayer.enableEdit()
253 | }
254 |
255 | // set map view to region
256 | try { // geometry calculcation are a bit hairy for invalid geometries (which may happen during polygon editing)
257 | let viewPort = bboxPolygon(map.getBounds().toBBoxString().split(',').map(Number))
258 | let xorAreaViewPort = erase(viewPort, L.polygon(boundsLayer.getLatLngs()[1]).toGeoJSON())
259 | let fitboundsFunc
260 | if (moveDirectly) {
261 | fitboundsFunc = ::map.fitBounds
262 | moveDirectly = false
263 | } else if (
264 | !xorAreaViewPort // new region fully includes viewport
265 | || area(xorAreaViewPort) > area(viewPort)*(1-0.01) // region is small compared to current viewport (<10% of the area covered) or feature is outside current viewport
266 | ) {
267 | fitboundsFunc = ::map.flyToBounds
268 | } else {
269 | fitboundsFunc = () => {}
270 | }
271 | fitboundsFunc(
272 | // zoom to inner ring!
273 | boundsLayer.getLatLngs().slice(1)
274 | .map(coords => L.polygon(coords).getBounds())
275 | .reduce((bounds1, bounds2) => bounds1.extend(bounds2)),
276 | {
277 | paddingTopLeft: [20, 10+52],
278 | paddingBottomRight: [20, 10+ ((fitBoundsWithBottomPadding) ? 212 : 52)]
279 | })
280 | } catch(e) {}
281 | });
282 | }
283 |
284 | setTimeFilter(timeFilter) {
285 | const { theme, layers } = this.props
286 |
287 | const activeLayer = layers.find(layer => layer.name === this.props.map.filters[0])
288 | const highlightLayers = glStyles(layers, activeLayer, { theme }).layers.filter(l => l.id.match(/highlight/))
289 | if (timeFilter === null) {
290 | // reset time filter
291 | highlightLayers.forEach(highlightLayer => {
292 | glLayer._glMap.setFilter(highlightLayer.id, ["==", "_timestamp", -1])
293 | })
294 | } else {
295 | highlightLayers.forEach(highlightLayer => {
296 | let layerFilter = ["any",
297 | ["all",
298 | [">=", "_timestamp", timeFilter[0]],
299 | ["<=", "_timestamp", timeFilter[1]]
300 | ],
301 | ["all",
302 | [">=", "_timestampMin", timeFilter[0]],
303 | ["<=", "_timestampMax", timeFilter[1]]
304 | ]
305 | ]
306 | if (highlightLayer.densityFilter) {
307 | layerFilter = ["all",
308 | highlightLayer.densityFilter,
309 | layerFilter
310 | ]
311 | }
312 | glLayer._glMap.setFilter(highlightLayer.id, layerFilter)
313 | })
314 | }
315 | }
316 |
317 | setExperienceFilter(experienceFilter) {
318 | const { theme, layers } = this.props
319 |
320 | const activeLayer = layers.find(layer => layer.name === this.props.map.filters[0])
321 | const highlightLayers = glStyles(layers, activeLayer, { theme }).layers.map(l => l.id).filter(id => id.match(/highlight/))
322 | if (experienceFilter === null) {
323 | // reset time filter
324 | highlightLayers.forEach(highlightLayer => {
325 | glLayer._glMap.setFilter(highlightLayer, ["==", "_timestamp", -1])
326 | })
327 | } else {
328 | highlightLayers.forEach(highlightLayer => {
329 | glLayer._glMap.setFilter(highlightLayer, ["all",
330 | [">=", "_userExperience", experienceFilter[0]],
331 | ["<=", "_userExperience", experienceFilter[1]]
332 | ])
333 | })
334 | }
335 | }
336 |
337 | swiperMoved(x) {
338 | if (!map) return
339 | const mapPanePos = map._getMapPanePos()
340 | const nw = map.containerPointToLayerPoint([0, 0])
341 | const se = map.containerPointToLayerPoint(map.getSize())
342 | const clipX = nw.x + (se.x - nw.x) * x / window.innerWidth
343 | glCompareLayers.before._glContainer.style.clip = 'rect(' + [nw.y+mapPanePos.y, clipX+mapPanePos.x, se.y+mapPanePos.y, nw.x+mapPanePos.x].join('px,') + 'px)'
344 | glCompareLayers.after._glContainer.style.clip = 'rect(' + [nw.y+mapPanePos.y, se.x+mapPanePos.x, se.y+mapPanePos.y, clipX+mapPanePos.x].join('px,') + 'px)'
345 | }
346 |
347 | }
348 |
349 |
350 |
351 | function mapStateToProps(state) {
352 | return {
353 | map: state.map,
354 | stats: state.stats
355 | }
356 | }
357 |
358 | function mapDispatchToProps(dispatch) {
359 | return {
360 | actions: bindActionCreators(MapActions, dispatch)
361 | }
362 | }
363 |
364 | export default connect(
365 | mapStateToProps,
366 | mapDispatchToProps
367 | )(Map)
368 |
--------------------------------------------------------------------------------