├── 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 | 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 = 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 = 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 | 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 |
8 |
9 |
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 | ? 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 |
    19 |
  • Map Legend

  • 20 |
  • 21 |

    {layer && layer.description} Read more.

    22 |
  • 23 |
  • 26 | likely complete OSM data 27 |
  • 28 |
  • 31 | probable gap in OSM data 32 |
  • 33 |
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 | ? 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 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 | 51 |
    52 |

    Try it out now!

    53 | 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 |
    69 | 70 |
    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 | [![Join the chat at https://gitter.im/hotosm/osm-analytics](https://badges.gitter.im/hotosm/osm-analytics.svg)](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 | ![Comparison map](https://github.com/GFDRR/osm-analytics/blob/master/documentation/embed-example-1.png?raw=true "Comparison map") 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 |
      28 |
    • 29 |

      {this.props.map.times[0]}

      30 |
    • 31 | {this.props.map.filters.filter(filter => this.state.featureCounts[filter]).map(filter => { 32 | return (
    • 33 | { 34 | numberWithCommas( 35 | (filter === 'highways' || filter === 'waterways' ? unitSystems[this.props.stats.unitSystem].distance.convert : x=>x)( 36 | (this.state.featureCounts[filter].find(counts => counts && counts.id === this.props.map.times[0]) || {}).value 37 | )) 38 | }
      39 | {filter === 'highways' || filter === 'waterways' 40 | ? f.name === filter).title} 44 | setUnitSystem={this.props.statsActions.setUnitSystem} 45 | /> 46 | : {this.props.layers.find(f => f.name === filter).title} 47 | } 48 |
    • ) 49 | })} 50 |
    51 |
      52 | {this.props.map.filters.filter(filter => this.state.featureCounts[filter]).map(filter => { 53 | return (
    • f.name === filter).description}> 54 | { 55 | numberWithCommas( 56 | (filter === 'highways' || filter === 'waterways' ? unitSystems[this.props.stats.unitSystem].distance.convert : x=>x)( 57 | (this.state.featureCounts[filter].find(counts => counts && counts.id === this.props.map.times[1]) || {}).value 58 | )) 59 | }
      60 | {filter === 'highways' || filter === 'waterways' 61 | ? f.name === filter).title} 65 | setUnitSystem={this.props.statsActions.setUnitSystem} 66 | /> 67 | : {this.props.layers.find(f => f.name === filter).title} 68 | } 69 |
    • ) 70 | })} 71 |
    • 72 |

      {this.props.map.times[1]}

      73 |
    • 74 |
    75 | 76 |
    77 | 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 = 80 | 81 | return ( 82 |
    83 |
    84 |
    85 | {false ? : ""} 86 | 87 | {embed === false &&
    88 | 89 | or 90 | 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 | 201 | 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 | 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 | --------------------------------------------------------------------------------