diff options
| author | Andrej Mihajlov <and@mullvad.net> | 2018-01-25 14:19:36 +0100 |
|---|---|---|
| committer | Andrej Mihajlov <and@mullvad.net> | 2018-01-26 14:04:14 +0100 |
| commit | fcaa777ebb41a80991be3779bf9f13ba323b8f05 (patch) | |
| tree | 4422e90190c566654fc0e8e06dabcc1106aa6046 /app/components/Map.js | |
| parent | 2dc0e01780720d1ca6bad129b456865ee4321c25 (diff) | |
| download | mullvadvpn-fcaa777ebb41a80991be3779bf9f13ba323b8f05.tar.xz mullvadvpn-fcaa777ebb41a80991be3779bf9f13ba323b8f05.zip | |
Add Map implementation
Diffstat (limited to 'app/components/Map.js')
| -rw-r--r-- | app/components/Map.js | 338 |
1 files changed, 309 insertions, 29 deletions
diff --git a/app/components/Map.js b/app/components/Map.js index 337d5c812b..256cdbf621 100644 --- a/app/components/Map.js +++ b/app/components/Map.js @@ -1,51 +1,331 @@ // @flow import React, { Component } from 'react'; -import ReactMapboxGl, { Marker } from 'react-mapbox-gl'; -import { mapbox as mapboxConfig } from '../config'; -import cheapRuler from 'cheap-ruler'; +import { ComposableMap, ZoomableGroup, Geographies, Geography, Markers, Marker } from 'react-simple-maps'; + +import { geoTimes } from 'd3-geo-projection'; +import rbush from 'rbush'; + +import geographyData from '../assets/geo/geometry.json'; +import statesProvincesLinesData from '../assets/geo/states-provinces-lines.json'; + +import countryTreeData from '../assets/geo/countries.rbush.json'; +import cityTreeData from '../assets/geo/cities.rbush.json'; +import geometryTreeData from '../assets/geo/geometry.rbush.json'; +import statesProvincesLinesTreeData from '../assets/geo/states-provinces-lines.rbush.json'; + +const countryTree = rbush().fromJSON(countryTreeData); +const cityTree = rbush().fromJSON(cityTreeData); +const geometryTree = rbush().fromJSON(geometryTreeData); +const provincesStatesLinesTree = rbush().fromJSON(statesProvincesLinesTreeData); import type { Coordinate2d } from '../types'; -const ReactMap = ReactMapboxGl({ - accessToken: mapboxConfig.accessToken, - attributionControl: false, - interactive: false, -}); +type BBox = [number, number, number, number]; + +export type MapProps = { + width: number, + height: number, + center: Coordinate2d, // longitude, latitude + offset: [number, number], // [x, y] in points + zoomLevel: number, + showMarker: boolean, + markerImagePath: string, +}; + +type MapState = { + zoomCenter: [number, number], + zoomLevel: number, + visibleCities: Array<Object>, + visibleCountries: Array<Object>, + visibleGeometry: Array<Object>, + visibleStatesProvincesLines: Array<Object>, + viewportBbox: BBox, +}; + +const MOVE_SPEED = 2000; export default class Map extends Component { - props: { - animate: boolean, - location: Coordinate2d, - altitude: number, - markerImagePath: string, + props: MapProps; + state: MapState = { + zoomCenter: [0, 0], + zoomLevel: 1, + visibleCities: [], + visibleCountries: [], + visibleGeometry: [], + visibleStatesProvincesLines: [], + viewportBbox: [0, 0, 0, 0], + }; + + _projectionConfig = { + scale: 160 + }; + + constructor(props: MapProps) { + super(props); + + this.state = this._getNextState(null, props); + } + + componentWillReceiveProps(nextProps: MapProps) { + if(this._shouldInvalidateState(nextProps)) { + this.setState(prevState => this._getNextState(prevState, nextProps)); + } + } + + shouldComponentUpdate(nextProps: MapProps, nextState: MapState) { + return ( + this.props.width !== nextProps.width || + this.props.height !== nextProps.height || + + this.props.center[0] !== nextProps.center[0] || + this.props.center[1] !== nextProps.center[1] || + + this.props.offset[0] !== nextProps.offset[0] || + this.props.offset[1] !== nextProps.offset[1] || + + this.props.zoomLevel !== nextProps.zoomLevel || + + this.props.showMarker !== nextProps.showMarker || + this.props.markerImagePath !== nextProps.markerImagePath || + + this.state.zoomCenter !== nextState.zoomCenter || + this.state.zoomLevel !== nextState.zoomLevel + ); } render() { + const mapStyle = { + width: '100%', + height: '100%', + backgroundColor: '#192e45', + }; + + const zoomableGroupStyle = { + transition: `transform ${MOVE_SPEED}ms ease-in-out` + }; + + const geographyStyle = this._mergeRsmStyle({ + default: { + fill: '#294d73', + stroke: '#192e45', + strokeWidth: `${1 / this.state.zoomLevel}`, + } + }); + + const stateProvinceLineStyle = this._mergeRsmStyle({ + default: { + fill: 'transparent', + stroke: '#192e45', + strokeWidth: `${1 / this.state.zoomLevel}`, + } + }); - const mapBounds = this.calculateMapBounds(this.props.location, this.props.altitude); + const markerStyle = this._mergeRsmStyle({ + default: { + transition: `transform ${MOVE_SPEED}ms ease-in-out`, + }, + }); - const mapBoundsOptions = { offset: [0, -113], animate: this.props.animate }; + // disable CSS transition when moving between locations + // by using the different "key" + const userMarker = this.props.showMarker && ( + <Marker key={ `user-location-${ this.props.center.join('-') }` } + marker={{ coordinates: this.props.center }} + style={ markerStyle }> + <image x="-30" y="-30" href={ this.props.markerImagePath } /> + </Marker> + ); - return <ReactMap style={ mapboxConfig.styleURL } - containerStyle={{ height: '100%' }} - fitBounds={ mapBounds } - fitBoundsOptions={ mapBoundsOptions }> + const countryMarkers = this.state.visibleCountries.map(item => ( + <Marker key={ `country-${item.id}` } + marker={{ coordinates: item.geometry.coordinates }} + style={ markerStyle }> + <text fill="rgba(255,255,255,.6)" fontSize="22" textAnchor="middle"> + { item.properties.name } + </text> + </Marker> + )); - <Marker coordinates={ this.convertToMapCoordinate(this.props.location) } offset={ [0, -10] }> - <img src={ this.props.markerImagePath } /> + const cityMarkers = this.state.visibleCities.map(item => ( + <Marker key={ `city-${item.id}` } + marker={{ coordinates: item.geometry.coordinates }} + style={ markerStyle }> + <circle r="2" fill="rgba(255,255,255,.6)" /> + <text x="0" y="-10" fill="rgba(255,255,255,.6)" fontSize="16" textAnchor="middle"> + { item.properties.name } + </text> </Marker> - </ReactMap>; + )); + + return ( + <ComposableMap + width={ this.props.width } + height={ this.props.height } + style={ mapStyle } + projection={ this._getProjection } + projectionConfig={ this._projectionConfig }> + <ZoomableGroup + center={ this.state.zoomCenter } + zoom={ this.state.zoomLevel } + disablePanning={ false } + style={ zoomableGroupStyle }> + <Geographies geography={ geographyData } disableOptimization={ true }> + {(geographies, projection) => { + // see https://github.com/zcreativelabs/react-simple-maps/issues/51 + if(geographies.length === 0) { + return []; + } + return this.state.visibleGeometry.map(({ id }) => ( + <Geography + key={ id } + geography={ geographies[id] } + projection={ projection } + style={ geographyStyle } /> + )); + }} + </Geographies> + <Geographies geography={ statesProvincesLinesData } disableOptimization={ true }> + {(geographies, projection) => { + // see https://github.com/zcreativelabs/react-simple-maps/issues/51 + if(geographies.length === 0) { + return []; + } + return this.state.visibleStatesProvincesLines.map(({ id }) => ( + <Geography + key={ id } + geography={ geographies[id] } + projection={ projection } + style={ stateProvinceLineStyle } /> + )); + }} + </Geographies> + <Markers> + { [...countryMarkers, ...cityMarkers, userMarker] } + </Markers> + </ZoomableGroup> + </ComposableMap> + ); + } + + _mergeRsmStyle(style: Object) { + const defaultStyle = style.default || {}; + return { + default: defaultStyle, + hover: style.hover || defaultStyle, + pressed: style.pressed || defaultStyle + }; + } + + _getProjection(width: number, height: number, config: { + scale?: number, + xOffset?: number, + yOffset?: number, + rotation?: [number, number, number], + precision?: number, + }) { + const scale = config.scale || 160; + const xOffset = config.xOffset || 0; + const yOffset = config.yOffset || 0; + const rotation = config.rotation || [0, 0, 0]; + const precision = config.precision || 0.1; + + return geoTimes() + .scale(scale) + .translate([ xOffset + width / 2, yOffset + height / 2 ]) + .rotate(rotation) + .precision(precision); + } + + _getZoomCenter( + center: [number, number], + offset: [number, number], + projection: Function, + zoom: number + ) { + const pos = projection(center); + return projection.invert([ + pos[0] + offset[0] / zoom, + pos[1] + offset[1] / zoom + ]); + } + + _getViewportGeoBoundingBox( + centerCoordinate: [number, number], + width: number, height: number, + projection: Function, + zoom: number + ) { + const center = projection(centerCoordinate); + const halfWidth = width * 0.5 / zoom; + const halfHeight = height * 0.5 / zoom; + + const northWest = projection.invert([center[0] - halfWidth, center[1] - halfHeight]); + const southEast = projection.invert([center[0] + halfWidth, center[1] + halfHeight]); + + // normalize to [minX, minY, maxX, maxY] + return [ + Math.min(northWest[0], southEast[0]), + Math.min(northWest[1], southEast[1]), + Math.max(northWest[0], southEast[0]), + Math.max(northWest[1], southEast[1]), + ]; } - calculateMapBounds(center: Coordinate2d, altitude: number): [Coordinate2d, Coordinate2d] { - const bounds = cheapRuler(center[0], 'meters').bufferPoint(center, altitude); - // convert [lat,lng] bounds to [lng,lat] - return [ [bounds[1], bounds[0]], [bounds[3], bounds[2]] ]; + _shouldInvalidateState(nextProps: MapProps) { + const oldProps = this.props; + return ( + oldProps.width !== nextProps.width || + oldProps.height !== nextProps.height || + + oldProps.center[0] !== nextProps.center[0] || + oldProps.center[1] !== nextProps.center[1] || + + oldProps.offset[0] !== nextProps.offset[0] || + oldProps.offset[1] !== nextProps.offset[1] || + + oldProps.zoomLevel !== nextProps.zoomLevel + ); } - convertToMapCoordinate(pos: Coordinate2d): Coordinate2d { - // convert [lat,lng] bounds to [lng,lat] - return [pos[1], pos[0]]; + _getNextState(prevState: ?MapState, nextProps: MapProps): MapState { + const { width, height, center, offset, zoomLevel } = nextProps; + + const projection = this._getProjection(width, height, this._projectionConfig); + const zoomCenter = this._getZoomCenter(center, offset, projection, zoomLevel); + const viewportBbox = this._getViewportGeoBoundingBox(zoomCenter, width, height, projection, zoomLevel); + + const viewportBboxMatch = { + minX: viewportBbox[0], minY: viewportBbox[1], + maxX: viewportBbox[2], maxY: viewportBbox[3], + }; + + // combine previous and current viewports to get the rough area of transition + const combinedViewportBboxMatch = prevState ? { + minX: Math.min(viewportBbox[0], prevState.viewportBbox[0]), + minY: Math.min(viewportBbox[1], prevState.viewportBbox[1]), + maxX: Math.max(viewportBbox[2], prevState.viewportBbox[2]), + maxY: Math.max(viewportBbox[3], prevState.viewportBbox[3]), + } : { + minX: viewportBbox[0], + minY: viewportBbox[1], + maxX: viewportBbox[2], + maxY: viewportBbox[3], + }; + + const visibleCountries = zoomLevel < 5 || zoomLevel > 20 ? [] : countryTree.search(viewportBboxMatch); + const visibleCities = zoomLevel >= 40 ? cityTree.search(viewportBboxMatch) : []; + const visibleGeometry = geometryTree.search(combinedViewportBboxMatch); + const visibleStatesProvincesLines = provincesStatesLinesTree.search(combinedViewportBboxMatch); + + return { + zoomCenter, + zoomLevel, + visibleCities, + visibleCountries, + visibleGeometry, + visibleStatesProvincesLines, + viewportBbox, + }; } } |
