diff --git a/CHANGELOG.md b/CHANGELOG.md index c0b0232..c62d0f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## [2.0.0-beta.1] - 2019-02-07 + +### Changed + +- [BREAKING] If using a map style hosted by Mapbox you must pass a [valid Mapbox + Access Token](https://docs.mapbox.com/help/how-mapbox-works/access-tokens/) + with the prop `mapboxToken`. +- [BREAKING] The `mapPosition` and `onChangeMapPosition` props have been + removed, and replaced by `mapViewState` and `onChangeMapViewState`. + ## [1.1.0] - 2018-06-21 ### Added diff --git a/example/index.html b/example/index.html index 7070939..86e73bb 100644 --- a/example/index.html +++ b/example/index.html @@ -27,6 +27,11 @@ padding: 0; overflow: hidden; } + #root { + width: 100vw; + height: 100vh; + position: absolute; + } diff --git a/example/index.js b/example/index.js index db857ff..aba203c 100644 --- a/example/index.js +++ b/example/index.js @@ -1,12 +1,14 @@ const React = require('react') const ReactDOM = require('react-dom') -const MapFilter = require('../src/index.js').default +const MapView = require('../src/components/MapView').default const createHistory = require('history').createBrowserHistory const { MuiThemeProvider, createMuiTheme } = require('@material-ui/core/styles') const blue = require('@material-ui/core/colors/blue').default const pink = require('@material-ui/core/colors/pink').default -const mapboxgl = require('mapbox-gl') const MenuItem = require('@material-ui/core/MenuItem').default +const { randomPoint } = require('@turf/random') +const {LinearInterpolator, FlyToInterpolator} = require('react-map-gl') +const d3 = require('d3-ease') const theme = createMuiTheme({ palette: { @@ -15,6 +17,8 @@ const theme = createMuiTheme({ } }) +const MAPBOX_TOKEN = require('../config.json').mapboxToken + const features = require('./sample.json').features const history = createHistory() @@ -60,10 +64,25 @@ class Example extends React.Component { this.unlisten = history.listen(this.handleHistoryChange) this.state = { ui: uiFromPath(history.location.pathname), - features: features, - mapPosition: {center: [-59.43943162023362, 2.6563784112334616], zoom: 10} + features: features } } + componentDidMount () { + this.intervalId = setInterval(() => { + const coords = randomPoint(1, {bbox: [-76.65, -16.96, -51.92, 6.64]}).features[0].geometry.coordinates + console.log('fly to', coords) + console.log('from', this.state.mapViewState) + this.setState(state => ({ + mapViewState: Object.assign({}, this.state.mapViewState, { + longitude: coords[0], + latitude: coords[1], + zoom: 7 + (Math.random() * 5), + transitionInterpolator: new FlyToInterpolator(), + transitionDuration: 2000 + }) + })) + }, 5000) + } handleHistoryChange = (location, action) => { if (action === 'POP') { this.setState({ui: uiFromPath(location.pathname)}) @@ -77,22 +96,17 @@ class Example extends React.Component { handleChangeFeatures = (_) => { this.setState({features: _}) } - handleChangeMapPosition = (pos) => { - this.setState({mapPosition: pos}) + handleChangeMapViewState = (pos) => { + this.setState({mapViewState: pos}) } render () { return - + } } diff --git a/example/prev-index.js b/example/prev-index.js new file mode 100644 index 0000000..43957e8 --- /dev/null +++ b/example/prev-index.js @@ -0,0 +1,122 @@ +const React = require('react') +const ReactDOM = require('react-dom') +const MapFilter = require('../src/index.js').default +const createHistory = require('history').createBrowserHistory +const { MuiThemeProvider, createMuiTheme } = require('@material-ui/core/styles') +const blue = require('@material-ui/core/colors/blue').default +const pink = require('@material-ui/core/colors/pink').default +const MenuItem = require('@material-ui/core/MenuItem').default +const { randomPoint } = require('@turf/random') +const {LinearInterpolator, FlyToInterpolator} = require('react-map-gl') +const d3 = require('d3-ease') + +const theme = createMuiTheme({ + palette: { + primary: blue, + secondary: pink + } +}) + +const fieldOrder = {caption: 1, public: 0} + +const MAPBOX_TOKEN = require('../config.json').mapboxToken + +const features = require('./sample.json').features + +const history = createHistory() + +const pathRegExp = /^(?:\/((?:[^/]+?)))?(?:\/((?:[^/]+?)))?(?:\/((?:[^/]+?)))?(?:\/(?=$))?$/ + +function uiFromPath (path) { + const match = pathRegExp.exec(path) + if (!match) return {} + return { + activeView: match[1], + activeModal: match[2] && match[2].replace('features', 'feature'), + settingsTab: match[2] === 'settings' && match[3], + featureId: match[2] === 'features' && match[3] + } +} + +function pathFromUi (ui) { + let path = '/' + if (ui.activeView) path += ui.activeView + '/' + if (ui.activeModal === 'settings') path += 'settings/' + ui.settingsTab + '/' + if (ui.activeModal === 'feature') path += 'features/' + ui.featureId + '/' + return path +} + +function resizer (src, size) { + return 'https://resizer.digital-democracy.org/{width}/{height}/{url}' + .replace('{width}', size) + .replace('{height}', size) + .replace('{url}', src) +} + +const MyMenuItem = () => ( + console.log('click myMenu')}> + Custom Menu Item + +) + +class Example extends React.Component { + constructor (props) { + super(props) + this.history = createHistory() + this.unlisten = history.listen(this.handleHistoryChange) + this.state = { + ui: uiFromPath(history.location.pathname), + features: features + } + } + componentDidMount () { + this.intervalId = setTimeout(() => { + const coords = randomPoint(1, {bbox: [-76.65, -16.96, -51.92, 6.64]}).features[0].geometry.coordinates + console.log('fly to', coords) + console.log('from', this.state.mapViewState) + this.setState(state => ({ + mapViewState: Object.assign({}, this.state.mapViewState, { + longitude: coords[0], + latitude: coords[1], + zoom: 10, + transitionInterpolator: new LinearInterpolator(), + transitionDuration: 1000, + transitionEasing: d3.easeCubic + }) + })) + }, 10000) + } + handleHistoryChange = (location, action) => { + if (action === 'POP') { + this.setState({ui: uiFromPath(location.pathname)}) + } + } + handleChangeUi = (ui) => { + const path = pathFromUi(ui) + ui.amberirect ? history.replace(path) : history.push(path) + this.setState({ui}) + } + handleChangeFeatures = (_) => { + this.setState({features: _}) + } + handleChangeMapViewState = (pos) => { + this.setState({mapViewState: pos}) + } + render () { + return + + + } +} + +ReactDOM.render(, document.getElementById('root')) diff --git a/package-lock.json b/package-lock.json index df2afc4..6a82349 100644 --- a/package-lock.json +++ b/package-lock.json @@ -112,9 +112,9 @@ } }, "@babel/runtime": { - "version": "7.0.0-rc.1", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.0.0-rc.1.tgz", - "integrity": "sha512-Nifv2kwP/nwR39cAOasNxzjYfpeuf/ZbZNtQz5eYxWTC9yHARU9wItFnAwz1GTZ62MU+AtSjzZPMbLK5Q9hmbg==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.2.0.tgz", + "integrity": "sha512-oouEibCbHMVdZSDlJBO6bZmID/zA/G/Qx3H1d3rSNPTD+L8UNKvCat7aKWSJ74zYbm5zWGh0GQN0hKj8zYFTCg==", "requires": { "regenerator-runtime": "^0.12.0" }, @@ -205,6 +205,11 @@ "wgs84": "0.0.0" } }, + "@mapbox/geojson-types": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@mapbox/geojson-types/-/geojson-types-1.0.2.tgz", + "integrity": "sha512-e9EBqHHv3EORHrSfbR9DqecPNn+AmuAoQxV6aL8Xu30bJMJR1o8PZLZzpk1Wq7/NfCbuhmakHTPYRhoqLsXRnw==" + }, "@mapbox/jsonlint-lines-primitives": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.1.tgz", @@ -293,6 +298,14 @@ "warning": "^4.0.1" }, "dependencies": { + "@babel/runtime": { + "version": "7.0.0-rc.1", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.0.0-rc.1.tgz", + "integrity": "sha512-Nifv2kwP/nwR39cAOasNxzjYfpeuf/ZbZNtQz5eYxWTC9yHARU9wItFnAwz1GTZ62MU+AtSjzZPMbLK5Q9hmbg==", + "requires": { + "regenerator-runtime": "^0.12.0" + } + }, "recompose": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/recompose/-/recompose-0.28.2.tgz", @@ -654,6 +667,21 @@ "react-treebeard": "^2.1.0" } }, + "@turf/helpers": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-6.1.4.tgz", + "integrity": "sha512-vJvrdOZy1ngC7r3MDA7zIGSoIgyrkWcGnNIEaqn/APmw+bVLF2gAW7HIsdTxd12s5wQMqEpqIQrmrbRRZ0xC7g==", + "dev": true + }, + "@turf/random": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@turf/random/-/random-6.0.2.tgz", + "integrity": "sha512-kAJhZ2QZ4kM2ArI0lrFLtq1LCH4ZSjleUlj1izi/405XenjddjrbgtewpZte8IxCSeV4yK46lsDRS1911cF5lg==", + "dev": true, + "requires": { + "@turf/helpers": "6.x" + } + }, "@types/jss": { "version": "9.5.7", "resolved": "https://registry.npmjs.org/@types/jss/-/jss-9.5.7.tgz", @@ -669,9 +697,9 @@ "integrity": "sha512-3AQoUxQcQtLHsK25wtTWIoIpgYjH3vSDroZOUr7PpCHw/jLY1RB9z9E8dBT/OSmwStVgkRNvdh+ZHNiomRieaw==" }, "@types/react": { - "version": "16.7.21", - "resolved": "https://registry.npmjs.org/@types/react/-/react-16.7.21.tgz", - "integrity": "sha512-8BPxwygC83LgaIjOVVLrzB4mpP2u1ih01fbfy76L3h9OgKN+fNyMVPXj/0mGpWnxImjiM/2lqb3YOeT2Ca+NYQ==", + "version": "16.8.2", + "resolved": "https://registry.npmjs.org/@types/react/-/react-16.8.2.tgz", + "integrity": "sha512-6mcKsqlqkN9xADrwiUz2gm9Wg4iGnlVGciwBRYFQSMWG6MQjhOZ/AVnxn+6v8nslFgfYTV8fNdE6XwKu6va5PA==", "requires": { "@types/prop-types": "*", "csstype": "^2.2.0" @@ -4717,6 +4745,12 @@ "rw": "1" } }, + "d3-ease": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-1.0.5.tgz", + "integrity": "sha512-Ct1O//ly5y5lFM9YTdu+ygq7LleSgSE4oj7vUt9tPLHUi8VCV7QoizGpdWRWAwCO9LdYzIrQDg97+hGVdsSGPQ==", + "dev": true + }, "dashdash": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", @@ -5987,6 +6021,11 @@ "integrity": "sha512-qzm/XxIbxm/FHyH341ZrbnMUpe+5Bocte9xkmFMzPMjRaZMcXww+MpBptFvtU+79L362nqiLhekCxCxDPaUMBQ==", "dev": true }, + "esm": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/esm/-/esm-3.1.1.tgz", + "integrity": "sha512-Md2pR4IbR37UqubbgbA4+wiBorOEFB05Oo+g4WJW7W2ajiOhUfjZt77NzzCoQdrCb40GdKcflitm+XHDF053OQ==" + }, "espree": { "version": "3.5.4", "resolved": "https://registry.npmjs.org/espree/-/espree-3.5.4.tgz", @@ -7837,6 +7876,11 @@ "duplexer": "^0.1.1" } }, + "hammerjs": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/hammerjs/-/hammerjs-2.0.8.tgz", + "integrity": "sha1-BO93hiz/K7edMPdpIJWTAiK/YPE=" + }, "har-schema": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", @@ -9955,6 +9999,15 @@ } } }, + "mjolnir.js": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mjolnir.js/-/mjolnir.js-2.0.3.tgz", + "integrity": "sha512-3AvoMwJCR3m9QQYzsE+D+LWZ9N2uWbl7prixSJGRZNOpaagRgiXJeVvDEHTiXAGmNhdn/VAtgWrx3lpdrj2sIQ==", + "requires": { + "@babel/runtime": "^7.0.0", + "hammerjs": "^2.0.8" + } + }, "mkdirp": { "version": "0.5.1", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", @@ -10070,6 +10123,11 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" }, + "murmurhash-js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz", + "integrity": "sha1-sGJ44h/Gw3+lMTcysEEry2rhX1E=" + }, "mute-stream": { "version": "0.0.7", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.7.tgz", @@ -10964,9 +11022,9 @@ "dev": true }, "popper.js": { - "version": "1.14.6", - "resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.14.6.tgz", - "integrity": "sha512-AGwHGQBKumlk/MDfrSOf0JHhJCImdDMcGNoqKmKkU+68GFazv3CQ6q9r7Ja1sKDZmYWTckY/uLyEznheTDycnA==" + "version": "1.14.7", + "resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.14.7.tgz", + "integrity": "sha512-4q1hNvoUre/8srWsH7hnoSJ5xVmIL4qgz+s4qf2TnJIMyZFUFMGH+9vE7mXynAlHSZ/NdTmmow86muD0myUkVQ==" }, "posix-character-classes": { "version": "0.1.1", @@ -12287,6 +12345,11 @@ } } }, + "potpack": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.1.tgz", + "integrity": "sha512-15vItUAbViaYrmaB/Pbw7z6qX2xENbFSTA7Ii4tgbPtasxm5v6ryKhKtL91tpWovDJzTiZqdwzhcFBCwiMVdVw==" + }, "precinct": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/precinct/-/precinct-4.2.0.tgz", @@ -12770,28 +12833,15 @@ "dev": true }, "react-event-listener": { - "version": "0.6.5", - "resolved": "https://registry.npmjs.org/react-event-listener/-/react-event-listener-0.6.5.tgz", - "integrity": "sha512-//lCxOM3DQ0+xmTa/u9mI9mm55zCPdIKp89d8MGjlNsOOnXQ5sFDD1eed+sMBzQXKiRBLBMtSg/2T9RJFtfovw==", + "version": "0.6.6", + "resolved": "https://registry.npmjs.org/react-event-listener/-/react-event-listener-0.6.6.tgz", + "integrity": "sha512-+hCNqfy7o9wvO6UgjqFmBzARJS7qrNoda0VqzvOuioEpoEXKutiKuv92dSz6kP7rYLmyHPyYNLesi5t/aH1gfw==", "requires": { - "@babel/runtime": "7.2.0", + "@babel/runtime": "^7.2.0", "prop-types": "^15.6.0", "warning": "^4.0.1" }, "dependencies": { - "@babel/runtime": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.2.0.tgz", - "integrity": "sha512-oouEibCbHMVdZSDlJBO6bZmID/zA/G/Qx3H1d3rSNPTD+L8UNKvCat7aKWSJ74zYbm5zWGh0GQN0hKj8zYFTCg==", - "requires": { - "regenerator-runtime": "^0.12.0" - } - }, - "regenerator-runtime": { - "version": "0.12.1", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.12.1.tgz", - "integrity": "sha512-odxIc1/vDlo4iZcfXqRYFj0vpXFNoGdKMAUieAlFYO6m/nl5e9KR/beGf41z4a1FI+aQgtjhuaSlDxQ0hmkrHg==" - }, "warning": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.2.tgz", @@ -12894,6 +12944,85 @@ "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==" }, + "react-map-gl": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/react-map-gl/-/react-map-gl-4.0.9.tgz", + "integrity": "sha512-ob57Ph9v7bvShcTUTJRsGrXE+sQrYJJVbUPMWOatOYYFGbyhyPkPrIu7X8/BSJ2zPMy0MXbkl6ihMPwzlsaggg==", + "requires": { + "@babel/runtime": "^7.0.0", + "mapbox-gl": "~0.52.0", + "mjolnir.js": "^2.0.3", + "prop-types": "^15.5.7", + "react-virtualized-auto-sizer": "^1.0.2", + "viewport-mercator-project": "^6.1.0" + }, + "dependencies": { + "@mapbox/jsonlint-lines-primitives": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz", + "integrity": "sha1-zlblOfg1UrWNENZy6k1vya3HsjQ=" + }, + "@mapbox/whoots-js": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz", + "integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==" + }, + "geojson-vt": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-3.2.1.tgz", + "integrity": "sha512-EvGQQi/zPrDA6zr6BnJD/YhwAkBP8nnJ9emh3EnHQKVMfg/MRVtPbMYdgVy/IaEmn4UfagD2a6fafPDL5hbtwg==" + }, + "kdbush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-3.0.0.tgz", + "integrity": "sha512-hRkd6/XW4HTsA9vjVpY9tuXJYLSlelnkTmVFu4M9/7MIYQtFcHpbugAU7UbOfjOiVSVYl2fqgBuJ32JUmRo5Ew==" + }, + "mapbox-gl": { + "version": "0.52.0", + "resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-0.52.0.tgz", + "integrity": "sha512-jiZMGI7LjBNiSwYpFA3drzbZXrgEGERGJRpNS95t5BLZoc8Z+ggOOI1Fz2X+zLlh1j32iNDtf4j6En+caWwYiQ==", + "requires": { + "@mapbox/geojson-types": "^1.0.2", + "@mapbox/jsonlint-lines-primitives": "^2.0.2", + "@mapbox/mapbox-gl-supported": "^1.4.0", + "@mapbox/point-geometry": "^0.1.0", + "@mapbox/tiny-sdf": "^1.1.0", + "@mapbox/unitbezier": "^0.0.0", + "@mapbox/vector-tile": "^1.3.1", + "@mapbox/whoots-js": "^3.1.0", + "csscolorparser": "~1.0.2", + "earcut": "^2.1.3", + "esm": "^3.0.84", + "geojson-rewind": "^0.3.0", + "geojson-vt": "^3.2.1", + "gl-matrix": "^2.6.1", + "grid-index": "^1.0.0", + "minimist": "0.0.8", + "murmurhash-js": "^1.0.0", + "pbf": "^3.0.5", + "potpack": "^1.0.1", + "quickselect": "^1.0.0", + "rw": "^1.3.3", + "supercluster": "^5.0.0", + "tinyqueue": "^1.1.0", + "vt-pbf": "^3.0.1" + } + }, + "minimist": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", + "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=" + }, + "supercluster": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-5.0.0.tgz", + "integrity": "sha512-9eeD5Q3908+tqdz+wYHHzi5mLKgnqtpO5mrjUfqr67UmGuOwBtVoQ9pJJrfcVHwMwC0wEBvfNRF9PgFOZgsOpw==", + "requires": { + "kdbush": "^3.0.0" + } + } + } + }, "react-modal": { "version": "3.4.5", "resolved": "https://registry.npmjs.org/react-modal/-/react-modal-3.4.5.tgz", @@ -12991,6 +13120,11 @@ "react-lifecycles-compat": "^3.0.4" } }, + "react-virtualized-auto-sizer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.2.tgz", + "integrity": "sha512-MYXhTY1BZpdJFjUovvYHVBmkq79szK/k7V3MO+36gJkWGkrXKtyr4vCPtpphaTLRAdDNoYEYFZWE8LjN+PIHNg==" + }, "read-only-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/read-only-stream/-/read-only-stream-2.0.0.tgz", @@ -15819,6 +15953,22 @@ "extsprintf": "^1.2.0" } }, + "viewport-mercator-project": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/viewport-mercator-project/-/viewport-mercator-project-6.1.0.tgz", + "integrity": "sha512-fy28QiLZ5t4SRMS7orqpRiE3tJ+ruvbjw6S6wguy15eRTc+RDwqCBDBt1YOd7WMHMBmBAjVNgYThQTNTDK/nIw==", + "requires": { + "@babel/runtime": "^7.0.0", + "gl-matrix": "^3.0.0-0" + }, + "dependencies": { + "gl-matrix": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.0.0.tgz", + "integrity": "sha512-PD4mVH/C/Zs64kOozeFnKY8ybhgwxXXQYGWdB4h68krAHknWJgk9uKOn6z8YElh5//vs++90pb6csrTIDWnexA==" + } + } + }, "vlq": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/vlq/-/vlq-0.2.3.tgz", diff --git a/package.json b/package.json index 02b91d7..fb1cfe6 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@mapbox/sexagesimal": "^1.0.0", + "@material-ui/core": "^1.5.1", "@material-ui/icons": "^1.1.1", "@segment/isodate": "^1.0.2", "autosuggest-highlight": "^3.1.0", @@ -41,6 +42,7 @@ "react-intl": "^2.3.0", "react-intl-redux": "^0.6.0", "react-isolated-scroll": "^0.1.1", + "react-map-gl": "^4.0.9", "react-redux": "^5.0.5", "react-virtualized": "^9.8.0", "recompose": "^0.27.1", @@ -52,11 +54,13 @@ "shallow-equal": "^1.0.0", "turf-extent": "^1.0.4", "url-regex": "^4.1.1", - "utm": "^1.1.1" + "utm": "^1.1.1", + "viewport-mercator-project": "^6.1.0" }, "devDependencies": { "@material-ui/codemod": "^1.1.0", "@storybook/react": "^3.2.3", + "@turf/random": "^6.0.2", "babel-cli": "^6.18.0", "babel-eslint": "^8.0.1", "babel-plugin-react-intl": "^2.3.1", @@ -71,6 +75,7 @@ "budo": "^10.0.3", "bundle-collapser": "^1.2.1", "copyfiles": "^2.0.0", + "d3-ease": "^1.0.5", "dependency-check": "^3.1.0", "dotenv": "^6.0.0", "envify": "^4.1.0", diff --git a/src/components/MapView/MapStyleLoader.js b/src/components/MapView/MapStyleLoader.js new file mode 100644 index 0000000..07a80cf --- /dev/null +++ b/src/components/MapView/MapStyleLoader.js @@ -0,0 +1,84 @@ +/* global fetch, AbortController */ + +import { Component } from 'react' +import PropTypes from 'prop-types' +import { normalizeStyleURL } from '../../util/mapbox' + +// Load a map style object from a URL and pass it to children as object +// Will interpret +export default class MapStyleLoader extends Component { + static propTypes = { + /** + * children must be a function, called with params (error, mapStyle) + * error will be null if no error + * mapStyle will be undefined during loading + * mapStyle will be a style object once loaded + */ + children: PropTypes.func.isRequired, + /** + * If the style is hosted on Mapbox you must pass a valid access token + */ + mapboxToken: PropTypes.string, + /** + * Map style. This must be an an object conforming to the schema described + * in the [style reference](https://mapbox.com/mapbox-gl-style-spec/), or a + * URL to a JSON style. To load a style from the Mapbox API, you can use a + * URL of the form `mapbox://styles/:owner/:style`, where `:owner` is your + * Mapbox account name and `:style` is the style ID. Or you can use one of + * the predefined Mapbox styles. + */ + mapStyle: PropTypes.oneOfType([PropTypes.string, PropTypes.object]) + } + + state = { + error: null + } + + static getDerivedStateFromProps (props, state) { + // If the mapStyle prop is an object, we don't need to load anything, just + // pass it directly to the state + if (typeof props.mapStyle === 'object' && props.mapStyle !== state.mapStyle) { + return { mapStyle: props.mapStyle } + } + return null + } + + componentDidMount () { + const {mapStyle} = this.props + if (typeof mapStyle === 'string') this.loadStyle(mapStyle) + } + + componentDidUpdate (prevProps) { + const {mapStyle} = this.props + if (typeof mapStyle === 'string' && mapStyle !== prevProps.mapStyle) { + this.loadStyle(mapStyle) + } + } + + componentWillUnmount () { + if (this.styleRequest) this.styleRequest.abort() + } + + render () { + const { error, mapStyle } = this.state + return this.props.children(error, mapStyle) + } + + loadStyle (styleUrl) { + const url = normalizeStyleURL(styleUrl, this.props.mapboxToken) + if (this.styleRequest) this.styleRequest.abort() + const signal = this.styleRequest = (new AbortController()).signal + fetch(url, { signal }) + .then(res => res.json()) + .then(data => { + this.setState({ mapStyle: data, error: null }) + }) + .catch(error => { + if (error.name === 'AbortError') return + this.setState({ error }) + }) + .finally(() => { + this.styleRequest = null + }) + } +} diff --git a/src/components/MapView/MapView.js b/src/components/MapView/MapView.js index 679c74c..d60e20b 100644 --- a/src/components/MapView/MapView.js +++ b/src/components/MapView/MapView.js @@ -1,54 +1,60 @@ + +import React from 'react' import debug from 'debug' import PropTypes from 'prop-types' -import React from 'react' -import mapboxgl from 'mapbox-gl' -import deepEqual from 'deep-equal' +import memoize from 'memoize-one' import assign from 'object-assign' import featureFilter from 'feature-filter-geojson' +import ReactMapGL, {Popup, NavigationControl} from 'react-map-gl' +import WebMercatorViewport from 'viewport-mercator-project' import { withStyles } from '@material-ui/core/styles' +import { connect } from 'react-redux' import * as MFPropTypes from '../../util/prop_types' import { getBoundsOrWorld } from '../../util/map_helpers' -import config from '../../../config.json' -import Popup from './Popup' +import PopupContent from './PopupContent' +import MapStyleLoader from './MapStyleLoader' require('mapbox-gl/dist/mapbox-gl.css') -/* Mapbox [API access token](https://www.mapbox.com/help/create-api-access-token/) */ -mapboxgl.accessToken = config.mapboxToken - const log = debug('mf:mapview') +const LABEL_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' + const styles = { - root: { - width: '100%', - height: '100%', - position: 'absolute' - }, - map: { - width: '100%', - height: '100%' + popup: { + '& .mapboxgl-popup-content': { + padding: 0 + }, + // The rules below override the style for anchoring the popup in the middle + // of a side, instead forcing the anchor to a corner + '&.mapboxgl-popup-anchor-left': { + transform: 'translate(0, 0) !important' + }, + '&.mapboxgl-popup-anchor-bottom': { + transform: 'translate(-100%, -100%) !important' + }, + '&.mapboxgl-popup-anchor-right': { + transform: 'translate(-100%, 0) !important' + }, + '&.mapboxgl-popup-anchor-top': { + transform: 'translate(-100%, 0) !important' + } } } -const emptyGeoJson = { - type: 'FeatureCollection', - features: [] -} - const labelStyleLayer = { - id: 'labels', + id: 'mapfilter_labels', type: 'symbol', source: 'features', layout: { - 'text-field': '', + 'text-field': '{__mf_label}', 'text-allow-overlap': true, 'text-ignore-placement': true, 'text-size': 9, 'text-font': [ - 'DIN Offc Pro Bold', - 'Arial Unicode MS Bold' + 'DIN Offc Pro Bold' ] }, paint: { @@ -59,11 +65,11 @@ const labelStyleLayer = { } const pointStyleLayer = { - id: 'points', + id: 'mapfilter_points', type: 'circle', source: 'features', paint: { - // make circles larger as the user zooms from z12 to z22 + // make circles larger as the user zooms from z7 to z18 'circle-radius': { 'base': 1.5, 'stops': [[7, 5], [18, 25]] @@ -72,49 +78,57 @@ const pointStyleLayer = { 'property': '__mf_color', 'type': 'identity' }, - 'circle-opacity': 0.75, - 'circle-stroke-width': 1.5, + 'circle-opacity': ['case', + ['boolean', ['feature-state', 'hover'], false], + 1, + 0.75 + ], + 'circle-stroke-width': ['case', + ['boolean', ['feature-state', 'hover'], false], + 2.5, + 1.5 + ], 'circle-stroke-color': '#ffffff', - 'circle-stroke-opacity': 0.9 + 'circle-stroke-opacity': ['case', + ['boolean', ['feature-state', 'hover'], false], + 1, + 0.9 + ] } } -const pointHoverStyleLayer = { - id: 'points-hover', - type: 'circle', - source: 'hover', - paint: assign({}, pointStyleLayer.paint, { - 'circle-opacity': 1, - 'circle-stroke-width': 2.5, - 'circle-stroke-color': '#ffffff', - 'circle-stroke-opacity': 1 - }) -} - const noop = (x) => x class MapView extends React.Component { static defaultProps = { - center: [0, 0], - zoom: 0, features: [], showFeatureDetail: noop, moveMap: noop, interactive: true, - labelPoints: false, - mapControls: [] + labelPoints: false } static propTypes = { /* map center point [lon, lat] */ - center: PropTypes.array, + viewState: PropTypes.shape({ + longitude: PropTypes.number, + latitude: PropTypes.number, + zoom: PropTypes.number, + transitionInterpolator: PropTypes.object, + transitionDuration: PropTypes.number + }), /* Geojson FeatureCollection of features to show on map */ features: PropTypes.arrayOf(MFPropTypes.mapViewFeature).isRequired, /* Current filter (See https://www.mapbox.com/mapbox-gl-style-spec/#types-filter) */ filter: MFPropTypes.mapboxFilter, + mapboxToken: PropTypes.string, /** - * - NOT yet dynamic e.g. if you change it the map won't change - * Map style. This must be an an object conforming to the schema described in the [style reference](https://mapbox.com/mapbox-gl-style-spec/), or a URL to a JSON style. To load a style from the Mapbox API, you can use a URL of the form `mapbox://styles/:owner/:style`, where `:owner` is your Mapbox account name and `:style` is the style ID. Or you can use one of the predefined Mapbox styles. + * Map style. This must be an an object conforming to the schema described + * in the [style reference](https://mapbox.com/mapbox-gl-style-spec/), or a + * URL to a JSON style. To load a style from the Mapbox API, you can use a + * URL of the form `mapbox://styles/:owner/:style`, where `:owner` is your + * Mapbox account name and `:style` is the style ID. Or you can use one of + * the predefined Mapbox styles. */ mapStyle: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), labelPoints: PropTypes.bool, @@ -127,264 +141,166 @@ class MapView extends React.Component { moveMap: PropTypes.func.isRequired, fieldMapping: MFPropTypes.fieldMapping, /* map zoom */ - zoom: PropTypes.number, - interactive: PropTypes.bool, - mapControls: PropTypes.arrayOf(PropTypes.shape({ - onAdd: PropTypes.func.isRequired, - onRemove: PropTypes.func.isRequired - })) + interactive: PropTypes.bool } - state = {} - - handleMapMoveOrZoom = (e) => { - if (e.internal) return - this.props.moveMap({ - center: this.map.getCenter().toArray(), - zoom: this.map.getZoom(), - bearing: this.map.getBearing() - }) + state = { + hoveredId: null } - handleMapClick = (e) => { - // if (!this.map.loaded()) return - var features = this.map.queryRenderedFeatures( - e.point, - {layers: ['points-hover']} - ) - if (!features.length) return - this.setState({lngLat: null}) - this.props.showFeatureDetail(features[0].properties.__mf_id) + constructor (props) { + super(props) + log('Creating new map instance with props:', props) + this.mapRef = window.mmap = React.createRef() } - handleMouseMove = (e) => { - if (!this.map.loaded()) return - var features = this.map.queryRenderedFeatures( - e.point, - {layers: ['points', 'points-hover']} - ) - this.map.getCanvas().style.cursor = (features.length) ? 'pointer' : '' - if (!features.length) { - this.setState({lngLat: null}) - this.map.getSource('hover').setData(emptyGeoJson) - return - } - this.map.getSource('hover').setData(features[0]) - this.setState({lngLat: features[0].geometry.coordinates}) - this.setState({id: features[0].properties.__mf_id}) - } - - ready (fn) { - if (this.map.loaded() && !this._styleDirty) { - fn() - } else { - this.map.once('load', () => fn.call(this)) - } - } - - render () { - const {classes} = this.props - return ( -
-
(this.mapContainer = el)} - className={classes.map} - /> - {this.state.lngLat && } -
- ) - } - - centerMap (geojson) { - this.map.fitBounds(getBoundsOrWorld(geojson), {padding: 15, duration: 0}) - if (this.map.getZoom() > 13) { - this.map.setZoom(13) - } - } - - // The first time our component mounts, render a new map into `mapDiv` - // with settings from props. - componentDidMount () { - const { center, interactive, mapStyle, zoom, mapControls } = this.props - - const mapDiv = document.createElement('div') - mapDiv.style.height = '100%' - mapDiv.style.width = '100%' - this.mapContainer.appendChild(mapDiv) - - const map = window.map = this.map = new mapboxgl.Map({ - style: mapStyle, - container: mapDiv, - center: center || [0, 0], - zoom: zoom || 0 - }) - map._prevStyle = mapStyle - - if (!interactive) { - map.scrollZoom.disable() - } - - // Add zoom and rotation controls to the map. - map.addControl(new mapboxgl.NavigationControl()) - map.dragRotate.disable() - map.touchZoomRotate.disableRotation() - - this.geojson = this.getGeoJson(this.props) - - map.once('load', () => { - if (interactive) { - map.on('click', this.handleMapClick) - map.on('mousemove', this.handleMouseMove) - } - map.on('moveend', this.handleMapMoveOrZoom) - this.setupLayers(this.props) - }) - map.once('style.load', () => { - mapControls.forEach(function (control) { - map.addControl.bind(map)(control) - }) + addLayers = memoize((style, showLabels) => { + const layers = showLabels ? [pointStyleLayer, labelStyleLayer] : [pointStyleLayer] + log('Adding layers to base style:', layers.map(l => l.id)) + return assign({}, style, { + layers: style.layers.concat(layers) }) + }) - // If no map center or zoom passed, set map extent to extent of marker layer - if (!center || !zoom) { - this.centerMap(this.geojson) - } - } - - componentWillReceiveProps (nextProps) { - this.updateIfNeeded(nextProps, this.props) - } - - componentWillUnmount () { - this.map.off('moveend', this.handleMapMoveOrZoom) - this.map.off('click', this.handleMapClick) - this.map.off('mousemove', this.handleMouseMove) - this.map.remove() - } - - setupLayers (props) { - const {filter, labelPoints} = props - this.map.addSource('features', {type: 'geojson', data: this.geojson}) - // TODO: Should choose style based on whether features are point, line or polygon - this.map.addSource('hover', {type: 'geojson', data: emptyGeoJson}) - this.map.addLayer(pointStyleLayer) - this.map.addLayer(pointHoverStyleLayer) - this.map.addLayer(labelStyleLayer) - this.map.setFilter('points', filter) - this.map.setFilter('labels', filter) - if (labelPoints) { - this.map.setLayoutProperty('labels', 'text-field', '{__mf_label}') - this.map.setPaintProperty('points', 'circle-radius', 7) - } - } - - updateIfNeeded (nextProps, props = {}) { - const {disableScrollToZoom} = props - - if (this.map._prevStyle !== nextProps.mapStyle) { - log('updating style') - this._styleDirty = true - this.map.setStyle(nextProps.mapStyle) - this.map._prevStyle = nextProps.mapStyle - this.map.once('style.load', () => { - this.setupLayers(nextProps) - this._styleDirty = false - if (!this.map._loaded) return - this.map.fire('load') + addSource = memoize((style, data) => { + log('Adding GeoJSON source to base style') + return assign({}, style, { + sources: assign({}, style.sources, { + features: { + type: 'geojson', + data: data + } }) - } - - var shouldMapZoom = this.map.getZoom() !== nextProps.zoom - var shouldMapMove = !deepEqual(this.map.getCenter().toArray(), nextProps.center) - - if (shouldMapZoom || shouldMapMove) { - this.map.flyTo({center: nextProps.center, zoom: nextProps.zoom}, {internal: true}) - } - - let shouldDataUpdate = nextProps.features !== props.features || - nextProps.fieldMapping !== props.fieldMapping || - nextProps.colorIndex !== props.colorIndex || - (nextProps.filter !== props.filter && props.labelPoints) - - this.ready(() => { - if (shouldDataUpdate) { - this.geojson = this.getGeoJson(nextProps) - - log('updating source', this.geojson) - this.map.getSource('features').setData(this.geojson) - - this.centerMap(this.geojson) - } - - this.updateFilterIfNeeded(nextProps.filter) - - if (disableScrollToZoom !== nextProps.disableScrollToZoom) { - nextProps.disableScrollToZoom ? this.map.scrollZoom.disable() : this.map.scrollZoom.enable() - } - - const textField = nextProps.labelPoints ? '{__mf_label}' : '' - - if (this.map.getLayoutProperty('labels', 'text-field') !== textField) { - log('updating labels "' + textField + '"') - this.map.setLayoutProperty('labels', 'text-field', textField) - } }) - } - - /** - * Moves the map to a new position if it is different from the current position - * @param {array} center new coordinates for center of map - * @param {number} zoom new zoom level for map - * @return {boolean} true if map has moved, otherwise false - */ - moveIfNeeded (center, zoom) { - const currentPosition = { - center: this.map.getCenter().toArray(), - zoom: this.map.getZoom() - } - const newMapPosition = { - center, - zoom - } - const shouldMapMove = center && zoom && - !deepEqual(currentPosition, newMapPosition) - if (shouldMapMove) { - log('Moving map') - this.map.jumpTo(newMapPosition) - return true - } - return false - } - - updateFilterIfNeeded (filter) { - if (filter !== this.props.filter && filter) { - log('updating filter') - this.map.setFilter('points', filter) - this.map.setFilter('labels', filter) - } - } + }) - // Construct GeoJSON for map - getGeoJson ({features = [], fieldMapping = {}, colorIndex = {}, filter = []}) { - let i = 0 + getGeoJson = memoize(( + features = [], + fieldMapping = {}, + colorIndex = {}, + filter = [] + ) => { + log('Updating GeoJSON with current filter') const ff = featureFilter(filter) return { type: 'FeatureCollection', features: features - .filter(f => f.geometry) - .map(f => { + .filter(f => f.geometry && ff(f)) + .map((f, i) => { + const colorValue = f.properties[fieldMapping.color] || + f.properties[fieldMapping.color + '.0'] const newFeature = { + // Mapbox-gl still (2019-01-22) does not support string ids, + // so we need to generate an id here and save the real is to props + id: i, type: 'feature', geometry: f.geometry, properties: assign({}, f.properties, { __mf_id: f.id, - __mf_color: colorIndex[f.properties[fieldMapping.color] || f.properties[fieldMapping.color + '.0']] + __mf_color: colorIndex[colorValue], + __mf_label: LABEL_CHARS.charAt(i) }) } - if (ff(f)) newFeature.properties.__mf_label = config.labelChars.charAt(i++) return newFeature }) } + }) + + onViewStateChange = ({viewState, interactionState, oldViewState}) => { + console.log('nextvp', viewState, interactionState, oldViewState) + const { viewport, moveMap } = this.props + const propsSpecifyMapLocation = viewport && typeof viewport.longitude === 'number' + if (propsSpecifyMapLocation) return moveMap(viewState) + // If the props for viewport are not set, zoom to bounds of data + const fittedViewport = new WebMercatorViewport(viewState) + const {longitude, latitude, zoom} = fittedViewport.fitBounds( + getBoundsOrWorld(this.props.features), + {padding: 15} + ) + log('viewport not set on props, zooming to bounding box:', longitude, latitude, zoom) + moveMap(assign({}, viewState, { + longitude, + latitude, + zoom + })) + } + + onHover = event => { + if (!this.props.interactive) return + // The more declarative way of doing this would be to set a new style on + // every render of the map if hoverId changes, but we use map feature state + // for performance reasons here + const map = this.mapRef.current.getMap() + const hoveredId = event.features.length ? event.features[0].id : null + if (hoveredId !== this.state.hoveredId) { + if (hoveredId) log('Hover of feature id:', event.features[0].properties.__mf_id) + map.setFeatureState({source: 'features', id: hoveredId}, { hover: true }) + map.setFeatureState({source: 'features', id: this.state.hoveredId}, { hover: false }) + this.setState({hoveredId}) + } + } + + onClick = event => { + if (!this.props.interactive) return + if (!event.features.length) return log('Click detected but no feature') + log('Clicked feature id:', event.features[0].properties.__mf_id) + this.props.showFeatureDetail(event.features[0].properties.__mf_id) + } + + getCursor = ({isHovering, isDragging}) => { + if (isDragging) return 'grabbing' + if (isHovering && this.props.interactive) return 'pointer' + return 'grab' + } + + render () { + const { features, fieldMapping, colorIndex, filter, + labelPoints, mapStyle, mapboxToken, classes, viewport } = this.props + const { hoveredId } = this.state + return + {(error, mapStyle) => { + if (error) return
{error.text}
+ if (!mapStyle) return null // TODO: show loading indicator + const geojson = this.getGeoJson(features, fieldMapping, colorIndex, filter) + const styleWithLayers = this.addLayers(mapStyle, labelPoints) + const styleWithData = this.addSource(styleWithLayers, geojson) + const hoveredFeature = hoveredId !== null && geojson.features[hoveredId] + console.log(viewport) + return +
+ +
+ {hoveredFeature && + + } +
+ }} +
} } diff --git a/src/components/MapView/Popup.js b/src/components/MapView/Popup.js deleted file mode 100644 index e196990..0000000 --- a/src/components/MapView/Popup.js +++ /dev/null @@ -1,156 +0,0 @@ -import React from 'react' -import {compose} from 'redux' -import {connect} from 'react-redux' -import Typography from '@material-ui/core/Typography' -import { withStyles } from '@material-ui/core/styles' -import classNames from 'classnames' - -import Image from '../Image' -import FormattedValue from '../Shared/FormattedValue' -import getFeaturesById from '../../selectors/features_by_id' -import getFieldMapping from '../../selectors/field_mapping' -import getColorIndex from '../../selectors/color_index' -import getFieldAnalysis from '../../selectors/field_analysis' - -const styles = { - wrapper: { - width: 200, - padding: 0, - backgroundColor: 'black', - cursor: 'pointer', - position: 'absolute', - willChange: 'transform', - top: 0, - left: 0, - pointerEvents: 'none' - }, - wrapperImage: { - height: 200 - }, - image: { - width: 200, - height: 200, - objectFit: 'cover', - display: 'block', - background: '#000000' - }, - titleBox: { - position: 'absolute', - bottom: 0, - width: '100%', - backgroundColor: 'rgba(0,0,0,0.5)', - color: 'white', - padding: '0.25em 0.5em', - boxSizing: 'border-box' - }, - title: { - color: 'white' - }, - subheading: { - color: 'white' - } -} - -class Popup extends React.Component { - static defaultProps = { - offset: { - x: 0, - y: 0 - } - } - constructor (props) { - super(props) - this.map = props.map - } - - state = {} - - update = (lngLat) => { - if (!Array.isArray(lngLat) || lngLat.length !== 2) lngLat = this.props.lngLat - const w = this._el.offsetWidth - const h = this._el.offsetHeight - this.setState({ - transform: getPopupTransform(this.map, lngLat, w, h, this.props.offset) - }) - } - - componentDidMount () { - const {lngLat} = this.props - this.map.on('move', this.update) - this.update(lngLat) - } - - componentWillReceiveProps ({lngLat}) { - this.update(lngLat) - } - - componentWillUnmount () { - this.map.off('move', this.update) - } - - render () { - const {media, title, subtitle, classes, titleType, subtitleType} = this.props - const {transform} = this.state - - return
(this._el = el)}> - {media && } -
- {title && - - } - {subtitle && - - } -
-
- } -} - -function getPopupTransform (map, lngLat, width, height, offset = {x: 0, y: 0}) { - const pos = map.project(lngLat).round() - let anchor - - if (pos.y < height) { - anchor = 'top' - } else { - anchor = 'bottom' - } - - if (pos.x > map.transform.width - width) { - anchor += '-right' - } else { - anchor += '-left' - } - - const anchorTranslate = { - 'top-left': 'translate(0,0)', - 'top-right': 'translate(-100%,0)', - 'bottom-left': 'translate(0,-100%)', - 'bottom-right': 'translate(-100%,-100%)' - } - - return `${anchorTranslate[anchor]} translate(${pos.x + offset.x}px,${pos.y + offset.y}px)` -} - -const mapStateToProps = (state, ownProps) => { - const featuresById = getFeaturesById(state) - const colorIndex = getColorIndex(state) - const fieldMapping = getFieldMapping(state) - const feature = featuresById[ownProps.id] - if (!feature) return {} - const geojsonProps = feature.properties - const fieldAnalysisProps = getFieldAnalysis(state).properties - return { - media: geojsonProps[fieldMapping.media], - title: geojsonProps[fieldMapping.title], - subtitle: geojsonProps[fieldMapping.subtitle], - color: colorIndex[geojsonProps[fieldMapping.color]], - titleType: fieldAnalysisProps[fieldMapping.title] && fieldAnalysisProps[fieldMapping.title].type, - subtitleType: fieldAnalysisProps[fieldMapping.subtitle] && fieldAnalysisProps[fieldMapping.subtitle].type - } -} - -export default compose( - connect(mapStateToProps), - withStyles(styles) -)(Popup) diff --git a/src/components/MapView/PopupContent.js b/src/components/MapView/PopupContent.js new file mode 100644 index 0000000..9bba332 --- /dev/null +++ b/src/components/MapView/PopupContent.js @@ -0,0 +1,84 @@ +import React from 'react' +import {compose} from 'redux' +import {connect} from 'react-redux' +import Typography from '@material-ui/core/Typography' +import { withStyles } from '@material-ui/core/styles' +import classNames from 'classnames' + +import Image from '../Image' +import FormattedValue from '../Shared/FormattedValue' +import getFeaturesById from '../../selectors/features_by_id' +import getFieldMapping from '../../selectors/field_mapping' +import getFieldAnalysis from '../../selectors/field_analysis' + +const styles = { + wrapper: { + width: 200, + padding: 0, + backgroundColor: 'black', + cursor: 'pointer', + position: 'relative', + willChange: 'transform', + pointerEvents: 'none' + }, + wrapperImage: { + height: 200 + }, + image: { + width: 200, + height: 200, + objectFit: 'cover', + display: 'block', + background: '#000000' + }, + titleBox: { + position: 'absolute', + bottom: 0, + width: '100%', + backgroundColor: 'rgba(0,0,0,0.5)', + color: 'white', + padding: '0.25em 0.5em', + boxSizing: 'border-box' + }, + title: { + color: 'white' + }, + subheading: { + color: 'white' + } +} + +const PopupContent = ({media, title, subtitle, classes, titleType, subtitleType}) => ( +
+ {media && } +
+ {title && + + } + {subtitle && + + } +
+
+) + +const mapStateToProps = (state, ownProps) => { + const featuresById = getFeaturesById(state) + const fieldMapping = getFieldMapping(state) + const feature = featuresById[ownProps.id] + if (!feature) return {} + const geojsonProps = feature.properties + const fieldAnalysisProps = getFieldAnalysis(state).properties + return { + media: geojsonProps[fieldMapping.media], + title: geojsonProps[fieldMapping.title], + subtitle: geojsonProps[fieldMapping.subtitle], + titleType: fieldAnalysisProps[fieldMapping.title] && fieldAnalysisProps[fieldMapping.title].type, + subtitleType: fieldAnalysisProps[fieldMapping.subtitle] && fieldAnalysisProps[fieldMapping.subtitle].type + } +} + +export default compose( + connect(mapStateToProps), + withStyles(styles) +)(PopupContent) diff --git a/src/components/ReportView/ReportView.js b/src/components/ReportView/ReportView.js index a626397..acc10a6 100644 --- a/src/components/ReportView/ReportView.js +++ b/src/components/ReportView/ReportView.js @@ -183,11 +183,12 @@ class ReportView extends React.Component { }) } - memoizedSlice = memoize((arr, begin, end) => arr.slice(begin, end)) + memoizedSlice = memoize((arr, start, end) => arr.slice(start, end)) render () { const { filteredFeatures, featuresById, showFeatureDetail, fieldAnalysis, classes, requestPrint, paperSize, viewState } = this.props const featuresSlice = this.memoizedSlice(filteredFeatures, 0, MAX_REPORT_LEN) + console.log('report render') return (
- +
{actionButton &&
{createElement(actionButton)}
}
diff --git a/src/containers/MapFilter.js b/src/containers/MapFilter.js index 77d2f21..7c3717d 100644 --- a/src/containers/MapFilter.js +++ b/src/containers/MapFilter.js @@ -70,7 +70,7 @@ const controllableProps = [ 'filters', 'filterFields', 'features', - 'mapPosition', + 'mapViewState', 'mapStyle', 'fieldTypes', 'fieldOrder', @@ -202,15 +202,6 @@ class MapFilter extends React.Component { viewToolbarButtons: PropTypes.arrayOf(PropTypes.shape({ MfViewId: PropTypes.string.isRequired, button: PropTypes.oneOfType([PropTypes.element, PropTypes.func]) - })), - /** - * An array of controls to add to the map. Each control should implement - * the IControl interface of mapbox-gl-js https://www.mapbox.com/mapbox-gl-js/api/#icontrol - * To set the position of the control, add a `getDefaultPosition()` method. - */ - mapControls: PropTypes.arrayOf(PropTypes.shape({ - onAdd: PropTypes.func.isRequired, - onRemove: PropTypes.func.isRequired })) } @@ -242,6 +233,9 @@ class MapFilter extends React.Component { persistStore(this.store, assign({}, reduxPersistOptions, { keyPrefix: 'reduxPersist:' + props.datasetName + ':' })) + if (props.mapStyle.startsWith('mapbox:') && !props.mapboxToken) { + console.warn('To use a Mapbox style you must pass a mapboxToken prop') + } } componentWillReceiveProps (nextProps) { @@ -251,11 +245,26 @@ class MapFilter extends React.Component { } render () { - const {actionButton, detailViewButtons, views, appBarButtons, appBarMenuItems, appBarTitle, locale, mapControls} = this.props + const { + actionButton, + detailViewButtons, + mapboxToken, + views, + appBarButtons, + appBarMenuItems, + appBarTitle, + locale + } = this.props return - + } diff --git a/src/containers/ViewContainer.js b/src/containers/ViewContainer.js index cc7f75b..eda6980 100644 --- a/src/containers/ViewContainer.js +++ b/src/containers/ViewContainer.js @@ -28,8 +28,6 @@ function mapStateToProps (state, ownProps) { viewState: state.viewStates[component.MfViewId], willPrint: state.print.willPrint, paperSize: state.print.paperSize, - center: state.mapPosition.center, - zoom: state.mapPosition.zoom, mapStyle: state.mapStyle, settings: state.settings, features: getFilterableFeatures(state), diff --git a/src/reducers/index.js b/src/reducers/index.js index 4c10b12..49be409 100644 --- a/src/reducers/index.js +++ b/src/reducers/index.js @@ -4,7 +4,7 @@ import {intlReducer} from 'react-intl-redux' import features from './features' import filters from './filters' import filterFields from './filter_fields' -import mapPosition from './map_position' +import mapViewState from './map_view_state' import mapStyle from './map_style' import ui from './ui' import fieldMapping from './field_mapping' @@ -19,7 +19,7 @@ export default combineReducers({ features, filters, filterFields, - mapPosition, + mapViewState, mapStyle, ui, print, diff --git a/src/reducers/map_position.js b/src/reducers/map_view_state.js similarity index 69% rename from src/reducers/map_position.js rename to src/reducers/map_view_state.js index 48c5d3e..d19c187 100644 --- a/src/reducers/map_position.js +++ b/src/reducers/map_view_state.js @@ -1,6 +1,6 @@ import assign from 'object-assign' -const mapPosition = (state = {}, action) => { +const mapViewState = (state = {}, action) => { switch (action.type) { case 'MOVE_MAP': return assign({}, state, action.payload) @@ -9,4 +9,4 @@ const mapPosition = (state = {}, action) => { } } -export default mapPosition +export default mapViewState diff --git a/src/util/map_helpers.js b/src/util/map_helpers.js index b1fead1..2fbe031 100644 --- a/src/util/map_helpers.js +++ b/src/util/map_helpers.js @@ -2,22 +2,23 @@ import extent from 'turf-extent' /** * @private - * For a given geojson FeatureCollection, return the geographic bounds. + * For a given an array of geojson Features, return the geographic bounds. * For a missing or invalid FeatureCollection, return the bounds for * the whole world. - * @param {object} fc Geojson FeatureCollection - * @return {array} Bounds in format `[minLng, minLat, maxLng, maxLat]`` + * @param {array} features Array of GeoJson Features + * @return {array} Bounds in format `[[minLng, minLat], [maxLng, maxLat]]`` */ -export function getBoundsOrWorld (fc) { +export function getBoundsOrWorld (features) { // If we don't have data, default to the extent of the whole world // NB. Web mercator goes to infinity at lat 90! Use lat 85. - if (!fc || !fc.features || !fc.features.length) { - return [-180, -85, 180, 85] + if (!features || !features.length) { + return [[-180, -85], [180, 85]] } - return extent({ + const [minLng, minLat, maxLng, maxLat] = extent({ type: 'FeatureCollection', - features: fc.features.filter(f => f.geometry) + features: features.filter(f => f.geometry) }) + return [[minLng, minLat], [maxLng, maxLat]] } /** diff --git a/src/util/mapbox.js b/src/util/mapbox.js new file mode 100644 index 0000000..6697813 --- /dev/null +++ b/src/util/mapbox.js @@ -0,0 +1,53 @@ +// Code from https://github.com/mapbox/mapbox-gl-js/blob/master/src/util/mapbox.js + +const urlRe = /^(\w+):\/\/([^/?]*)(\/[^?]+)?\??(.+)?/ +const MAPBOX_API_URL = 'https://api.mapbox.com' + +export const normalizeStyleURL = function (url, accessToken) { + if (!isMapboxURL(url)) return url + const urlObject = parseUrl(url) + urlObject.path = `/styles/v1${urlObject.path}` + return makeAPIURL(urlObject, accessToken) +} + +function isMapboxURL (url) { + return url.indexOf('mapbox:') === 0 +} + +function makeAPIURL (urlObject, accessToken) { + const apiUrlObject = parseUrl(MAPBOX_API_URL) + urlObject.protocol = apiUrlObject.protocol + urlObject.authority = apiUrlObject.authority + + if (apiUrlObject.path !== '/') { + urlObject.path = `${apiUrlObject.path}${urlObject.path}` + } + + if (!accessToken) { + throw new Error(`An API access token is required to use Mapbox GL.`) + } + if (accessToken[0] === 's') { + throw new Error(`Use a public access token (pk.*) with Mapbox GL, not a secret access token (sk.*). ${help}`) + } + + urlObject.params.push(`access_token=${accessToken}`) + return formatUrl(urlObject) +} + +function parseUrl (url) { + const parts = url.match(urlRe) + if (!parts) { + throw new Error('Unable to parse URL object') + } + return { + protocol: parts[1], + authority: parts[2], + path: parts[3] || '/', + params: parts[4] ? parts[4].split('&') : [] + } +} + +function formatUrl (obj) { + const params = obj.params.length ? `?${obj.params.join('&')}` : '' + return `${obj.protocol}://${obj.authority}${obj.path}${params}` +}