summaryrefslogtreecommitdiffhomepage
path: root/app/components/Map.js
diff options
context:
space:
mode:
authorAndrej Mihajlov <and@mullvad.net>2018-01-29 14:47:00 +0100
committerAndrej Mihajlov <and@mullvad.net>2018-01-29 14:47:00 +0100
commitb818240e9fead04e8f5981a44a4237a6a5cd0619 (patch)
tree57c5b67adc3831546871a9c05563cb3b420ab953 /app/components/Map.js
parentb90a42c33cd3f7c732d0f5d9233facc90bebcac0 (diff)
downloadmullvadvpn-b818240e9fead04e8f5981a44a4237a6a5cd0619.tar.xz
mullvadvpn-b818240e9fead04e8f5981a44a4237a6a5cd0619.zip
Decouple Map and SvgMap
Diffstat (limited to 'app/components/Map.js')
-rw-r--r--app/components/Map.js358
1 files changed, 63 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';
+ }
}
+
}