diff options
| author | Andrej Mihajlov <and@mullvad.net> | 2018-01-29 14:47:00 +0100 |
|---|---|---|
| committer | Andrej Mihajlov <and@mullvad.net> | 2018-01-29 14:47:00 +0100 |
| commit | b818240e9fead04e8f5981a44a4237a6a5cd0619 (patch) | |
| tree | 57c5b67adc3831546871a9c05563cb3b420ab953 | |
| parent | b90a42c33cd3f7c732d0f5d9233facc90bebcac0 (diff) | |
| download | mullvadvpn-b818240e9fead04e8f5981a44a4237a6a5cd0619.tar.xz mullvadvpn-b818240e9fead04e8f5981a44a4237a6a5cd0619.zip | |
Decouple Map and SvgMap
| -rw-r--r-- | app/components/Map.js | 358 | ||||
| -rw-r--r-- | app/components/SvgMap.js | 330 |
2 files changed, 393 insertions, 295 deletions
diff --git a/app/components/Map.js b/app/components/Map.js index 256cdbf621..d416b77961 100644 --- a/app/components/Map.js +++ b/app/components/Map.js @@ -1,331 +1,99 @@ // @flow -import React, { Component } from 'react'; -import { ComposableMap, ZoomableGroup, Geographies, Geography, Markers, Marker } from 'react-simple-maps'; +import React from 'react'; +import { Component, View } from 'reactxp'; -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'; - -type BBox = [number, number, number, number]; +import SvgMap from './SvgMap'; export type MapProps = { - width: number, - height: number, - center: Coordinate2d, // longitude, latitude - offset: [number, number], // [x, y] in points - zoomLevel: number, + center: [number, number], // longitude, latitude + offset: [number, number], // offset [x, y] from the center of the map + zoomLevel: 'high' | 'medium' | 'low', showMarker: boolean, - markerImagePath: string, + markerStyle: 'secure' | 'unsecure', + style: Object, }; type MapState = { - zoomCenter: [number, number], - zoomLevel: number, - visibleCities: Array<Object>, - visibleCountries: Array<Object>, - visibleGeometry: Array<Object>, - visibleStatesProvincesLines: Array<Object>, - viewportBbox: BBox, + bounds: { + width: number, + height: number + } }; -const MOVE_SPEED = 2000; - export default class Map extends Component { + props: MapProps; - state: MapState = { - zoomCenter: [0, 0], - zoomLevel: 1, - visibleCities: [], - visibleCountries: [], - visibleGeometry: [], - visibleStatesProvincesLines: [], - viewportBbox: [0, 0, 0, 0], - }; - _projectionConfig = { - scale: 160 + state: MapState = { + bounds: { + width: 0, + height: 0, + }, }; - 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 markerStyle = this._mergeRsmStyle({ - default: { - transition: `transform ${MOVE_SPEED}ms ease-in-out`, - }, - }); - - // 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> - ); - - 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> - )); - - 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> - )); - + const { width, height } = this.state.bounds; + const readyToRenderTheMap = (width > 0 && height > 0); 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> + <View style={ this.props.style } onLayout={ this._onLayout }> + { readyToRenderTheMap && ( + <SvgMap width={ width } + height={ height } + center={ this.props.center } + offset={ this.props.offset } + zoomLevel={ this._zoomLevel(this.props.zoomLevel) } + showMarker={ this.props.showMarker } + markerImagePath={ this._markerImage(this.props.markerStyle) } /> + ) } + </View> ); } - _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]), - ]; - } - - _shouldInvalidateState(nextProps: MapProps) { + shouldComponentUpdate(nextProps: MapProps, nextState: MapState) { const oldProps = this.props; + const oldState = this.state; 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 + oldProps.zoomLevel !== nextProps.zoomLevel || + oldProps.showMarker !== nextProps.showMarker || + oldProps.markerStyle !== nextProps.markerStyle || + + oldState.bounds.width !== nextState.bounds.width || + oldState.bounds.height !== nextState.bounds.height ); } - _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], - }; + _onLayout = (layoutInfo) => { + this.setState({ + bounds: { + width: layoutInfo.width, + height: layoutInfo.height, + } + }); + } - 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); + // TODO: Remove zoom level in favor of center + coordinate span + _zoomLevel(variant: $PropertyType<MapProps, 'zoomLevel'>) { + switch(variant) { + case 'high': return 1; + case 'medium': return 20; + case 'low': return 40; + } + } - return { - zoomCenter, - zoomLevel, - visibleCities, - visibleCountries, - visibleGeometry, - visibleStatesProvincesLines, - viewportBbox, - }; + _markerImage(style: $PropertyType<MapProps, 'markerStyle'>) { + switch(style) { + case 'secure': + return './assets/images/location-marker-secure.svg'; + case 'unsecure': + return './assets/images/location-marker-unsecure.svg'; + } } + } diff --git a/app/components/SvgMap.js b/app/components/SvgMap.js new file mode 100644 index 0000000000..96bf6a40ba --- /dev/null +++ b/app/components/SvgMap.js @@ -0,0 +1,330 @@ +// @flow + +import React, { Component } from 'react'; +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); + +type BBox = [number, number, number, number]; + +export type SvgMapProps = { + width: number, + height: number, + center: [number, number], // longitude, latitude + offset: [number, number], // [x, y] in points + zoomLevel: number, + showMarker: boolean, + markerImagePath: string, +}; + +type SvgMapState = { + zoomCenter: [number, number], + zoomLevel: number, + visibleCities: Array<Object>, + visibleCountries: Array<Object>, + visibleGeometry: Array<Object>, + visibleStatesProvincesLines: Array<Object>, + viewportBbox: BBox, +}; + +const MOVE_SPEED = 2000; + +// @TODO: Calculate zoom level based on (center + span) (aka MKCoordinateSpan) +export default class SvgMap extends Component { + props: SvgMapProps; + state: SvgMapState = { + zoomCenter: [0, 0], + zoomLevel: 1, + visibleCities: [], + visibleCountries: [], + visibleGeometry: [], + visibleStatesProvincesLines: [], + viewportBbox: [0, 0, 0, 0], + }; + + _projectionConfig = { + scale: 160 + }; + + constructor(props: SvgMapProps) { + super(props); + + this.state = this._getNextState(null, props); + } + + componentWillReceiveProps(nextProps: SvgMapProps) { + if(this._shouldInvalidateState(nextProps)) { + this.setState(prevState => this._getNextState(prevState, nextProps)); + } + } + + shouldComponentUpdate(nextProps: SvgMapProps, nextState: SvgMapState) { + 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 markerStyle = this._mergeRsmStyle({ + default: { + transition: `transform ${MOVE_SPEED}ms ease-in-out`, + }, + }); + + // 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> + ); + + 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> + )); + + 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> + )); + + 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]), + ]; + } + + _shouldInvalidateState(nextProps: SvgMapProps) { + 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 + ); + } + + _getNextState(prevState: ?SvgMapState, nextProps: SvgMapProps): SvgMapState { + 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, + }; + } +} |
