summaryrefslogtreecommitdiffhomepage
path: root/gui
diff options
context:
space:
mode:
authorOskar Nyberg <oskar@mullvad.net>2020-09-10 10:07:28 +0200
committerOskar Nyberg <oskar@mullvad.net>2020-09-10 10:07:28 +0200
commit2d78e61d0766735f36640753e540eb300f437188 (patch)
treea64eb4a843706df384397752ce0174eb6203f18b /gui
parent735f1c40d7b07c471bbbc56013b2f2452f05b4e8 (diff)
parent86a2277f494b34fc82435a4c239cea3f7707f509 (diff)
downloadmullvadvpn-2d78e61d0766735f36640753e540eb300f437188.tar.xz
mullvadvpn-2d78e61d0766735f36640753e540eb300f437188.zip
Merge branch 'update-react-simple-maps2' into master
Diffstat (limited to 'gui')
-rw-r--r--gui/package-lock.json124
-rw-r--r--gui/package.json6
-rw-r--r--gui/src/renderer/components/SvgMap.tsx501
-rw-r--r--gui/types/d3-geo-projection/index.d.ts5
4 files changed, 318 insertions, 318 deletions
diff --git a/gui/package-lock.json b/gui/package-lock.json
index 147bf22d71..ff5cb53d94 100644
--- a/gui/package-lock.json
+++ b/gui/package-lock.json
@@ -699,12 +699,13 @@
}
},
"@types/react-simple-maps": {
- "version": "0.12.2",
- "resolved": "https://registry.npmjs.org/@types/react-simple-maps/-/react-simple-maps-0.12.2.tgz",
- "integrity": "sha512-dmALUc5CDy3+76sn0ZF2mcdWN0VH9d/F4P5fpEB0vdrJIw1KJjHK8kumeHsyXiktz4joBUWcxAZOTfifmj82fQ==",
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@types/react-simple-maps/-/react-simple-maps-1.0.3.tgz",
+ "integrity": "sha512-cCDAsa8FRlTwvMUOUMK3+5ANnF1TDXlLoqpmIOYYIAYXT4nKwpsgSMOWjPoJyCRx5U+pN8rIK+A/HAcyAh6UWA==",
"dev": true,
"requires": {
"@types/d3-geo": "*",
+ "@types/geojson": "*",
"@types/react": "*"
}
},
@@ -3181,23 +3182,79 @@
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-1.2.4.tgz",
"integrity": "sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw=="
},
+ "d3-color": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-1.4.1.tgz",
+ "integrity": "sha512-p2sTHSLCJI2QKunbGb7ocOh7DgTAn8IrLx21QRc/BSnodXM4sv6aLQlnfpvehFMLZEfBc6g9pH9SWQccFYfJ9Q=="
+ },
+ "d3-dispatch": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-1.0.6.tgz",
+ "integrity": "sha512-fVjoElzjhCEy+Hbn8KygnmMS7Or0a9sI2UzGwoB7cCtvI1XpVN9GpoYlnb3xt2YV66oXYb1fLJ8GMvP4hdU1RA=="
+ },
+ "d3-drag": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-1.2.5.tgz",
+ "integrity": "sha512-rD1ohlkKQwMZYkQlYVCrSFxsWPzI97+W+PaEIBNTMxRuxz9RF0Hi5nJWHGVJ3Om9d2fRTe1yOBINJyy/ahV95w==",
+ "requires": {
+ "d3-dispatch": "1",
+ "d3-selection": "1"
+ }
+ },
+ "d3-ease": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-1.0.7.tgz",
+ "integrity": "sha512-lx14ZPYkhNx0s/2HX5sLFUI3mbasHjSSpwO/KaaNACweVwxUruKyWVcb293wMv1RqTPZyZ8kSZ2NogUZNcLOFQ=="
+ },
"d3-geo": {
- "version": "1.11.6",
- "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-1.11.6.tgz",
- "integrity": "sha512-z0J8InXR9e9wcgNtmVnPTj0TU8nhYT6lD/ak9may2PdKqXIeHUr8UbFLoCtrPYNsjv6YaLvSDQVl578k6nm7GA==",
+ "version": "1.12.1",
+ "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-1.12.1.tgz",
+ "integrity": "sha512-XG4d1c/UJSEX9NfU02KwBL6BYPj8YKHxgBEw5om2ZnTRSbIcego6dhHwcxuSR3clxh0EpE38os1DVPOmnYtTPg==",
"requires": {
"d3-array": "1"
}
},
- "d3-geo-projection": {
- "version": "2.7.0",
- "resolved": "https://registry.npmjs.org/d3-geo-projection/-/d3-geo-projection-2.7.0.tgz",
- "integrity": "sha512-G8C/8gvUQVwuLloW88d/NGbyh5CLONowQzU6gB7cczfGbSjMrQHFbaCqipWUqUWaBdqpyfTlLE3GPGy0RMpKYw==",
+ "d3-interpolate": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-1.4.0.tgz",
+ "integrity": "sha512-V9znK0zc3jOPV4VD2zZn0sDhZU3WAE2bmlxdIwwQPPzPjvyLkd8B3JUVdS1IDUFDkWZ72c9qnv1GK2ZagTZ8EA==",
+ "requires": {
+ "d3-color": "1"
+ }
+ },
+ "d3-selection": {
+ "version": "1.4.2",
+ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-1.4.2.tgz",
+ "integrity": "sha512-SJ0BqYihzOjDnnlfyeHT0e30k0K1+5sR3d5fNueCNeuhZTnGw4M4o8mqJchSwgKMXCNFo+e2VTChiSJ0vYtXkg=="
+ },
+ "d3-timer": {
+ "version": "1.0.10",
+ "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-1.0.10.tgz",
+ "integrity": "sha512-B1JDm0XDaQC+uvo4DT79H0XmBskgS3l6Ve+1SBCfxgmtIb1AVrPIoqd+nPSv+loMX8szQ0sVUhGngL7D5QPiXw=="
+ },
+ "d3-transition": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-1.3.2.tgz",
+ "integrity": "sha512-sc0gRU4PFqZ47lPVHloMn9tlPcv8jxgOQg+0zjhfZXMQuvppjG6YuwdMBE0TuqCZjeJkLecku/l9R0JPcRhaDA==",
+ "requires": {
+ "d3-color": "1",
+ "d3-dispatch": "1",
+ "d3-ease": "1",
+ "d3-interpolate": "1",
+ "d3-selection": "^1.1.0",
+ "d3-timer": "1"
+ }
+ },
+ "d3-zoom": {
+ "version": "1.8.3",
+ "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-1.8.3.tgz",
+ "integrity": "sha512-VoLXTK4wvy1a0JpH2Il+F2CiOhVu7VRXWF5M/LroMIh3/zBAC3WAt7QoIvPibOavVo20hN6/37vwAsdBejLyKQ==",
"requires": {
- "commander": "2",
- "d3-array": "1",
- "d3-geo": "^1.10.0",
- "resolve": "^1.1.10"
+ "d3-dispatch": "1",
+ "d3-drag": "1",
+ "d3-interpolate": "1",
+ "d3-selection": "1",
+ "d3-transition": "1"
}
},
"date.js": {
@@ -9960,7 +10017,8 @@
"path-parse": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz",
- "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw=="
+ "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==",
+ "dev": true
},
"path-root": {
"version": "0.1.1",
@@ -10458,32 +10516,23 @@
}
},
"react-simple-maps": {
- "version": "0.12.1",
- "resolved": "https://registry.npmjs.org/react-simple-maps/-/react-simple-maps-0.12.1.tgz",
- "integrity": "sha512-htW2qQCnppGAUvWttf8ugZsVD896AfAjJNz3sgQT5zpdLNC+n2xegiNtn5inkD0aLuQWZ43i5cTlhPe0n6hvNQ==",
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/react-simple-maps/-/react-simple-maps-2.1.2.tgz",
+ "integrity": "sha512-CuwuOomMmf/3zMtMqG9w8IKxpPUDhXHuF1p/8/8G6EMiRdYUJDysmDFGUIxD30CfkR4+9ItE0NV1pI/fZC/1cw==",
"requires": {
- "d3-geo": "1.6.3",
- "d3-geo-projection": "1.2.2",
- "topojson-client": "2.1.0"
+ "d3-geo": "^1.11.9",
+ "d3-selection": "^1.4.1",
+ "d3-zoom": "^1.8.3",
+ "topojson-client": "^3.0.0"
},
"dependencies": {
"d3-geo": {
- "version": "1.6.3",
- "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-1.6.3.tgz",
- "integrity": "sha1-IWg6Q6Bh6rohp/JUtR1ZN+tkB1Y=",
+ "version": "1.12.1",
+ "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-1.12.1.tgz",
+ "integrity": "sha512-XG4d1c/UJSEX9NfU02KwBL6BYPj8YKHxgBEw5om2ZnTRSbIcego6dhHwcxuSR3clxh0EpE38os1DVPOmnYtTPg==",
"requires": {
"d3-array": "1"
}
- },
- "d3-geo-projection": {
- "version": "1.2.2",
- "resolved": "https://registry.npmjs.org/d3-geo-projection/-/d3-geo-projection-1.2.2.tgz",
- "integrity": "sha1-7w5s3PoN8jbQ4j8sp3UEYsozH3I=",
- "requires": {
- "commander": "2",
- "d3-array": "1",
- "d3-geo": "^1.1.0"
- }
}
}
},
@@ -10835,6 +10884,7 @@
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.10.0.tgz",
"integrity": "sha512-3sUr9aq5OfSg2S9pNtPA9hL1FVEAjvfOC4leW0SNf/mpnaakz2a9femSd6LqAww2RaFctwyf1lCqnTHuF1rxDg==",
+ "dev": true,
"requires": {
"path-parse": "^1.0.6"
}
@@ -12550,9 +12600,9 @@
"dev": true
},
"topojson-client": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-2.1.0.tgz",
- "integrity": "sha1-/59784mRGF4LQoTCsGroNPDqxsg=",
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz",
+ "integrity": "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==",
"requires": {
"commander": "2"
}
diff --git a/gui/package.json b/gui/package.json
index e0bcc15fe8..d1c80bc418 100644
--- a/gui/package.json
+++ b/gui/package.json
@@ -15,7 +15,7 @@
"@grpc/grpc-js": "^1.1.2",
"argv-split": "^2.0.1",
"connected-react-router": "^6.8.0",
- "d3-geo-projection": "^2.7.0",
+ "d3-geo": "^1.12.1",
"electron-log": "^4.1.1",
"gettext-parser": "^4.0.3",
"google-protobuf": "^4.0.0-rc.2",
@@ -29,7 +29,7 @@
"react-dom": "^16.13.1",
"react-redux": "^7.2.0",
"react-router": "^5.1.2",
- "react-simple-maps": "^0.12.1",
+ "react-simple-maps": "^2.1.2",
"redux": "^4.0.5",
"sprintf-js": "^1.1.2",
"styled-components": "^5.1.0",
@@ -55,7 +55,7 @@
"@types/react-dom": "^16.9.6",
"@types/react-redux": "^7.1.7",
"@types/react-router": "^5.1.5",
- "@types/react-simple-maps": "^0.12.1",
+ "@types/react-simple-maps": "^1.0.3",
"@types/resize-observer-browser": "^0.1.3",
"@types/sinon": "^7.0.5",
"@types/sprintf-js": "^1.1.2",
diff --git a/gui/src/renderer/components/SvgMap.tsx b/gui/src/renderer/components/SvgMap.tsx
index 2cc23d3399..0c1b862cb5 100644
--- a/gui/src/renderer/components/SvgMap.tsx
+++ b/gui/src/renderer/components/SvgMap.tsx
@@ -1,24 +1,14 @@
-import { geoTimes } from 'd3-geo-projection';
+import { geoMercator, GeoProjection } from 'd3-geo';
import rbush from 'rbush';
-import * as React from 'react';
-import {
- ComposableMap,
- Geographies,
- Geography,
- Marker,
- Markers,
- ZoomableGroup,
-} from 'react-simple-maps';
-import { Scheduler } from '../../shared/scheduler';
+import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { ComposableMap, Geographies, Geography, Marker, ZoomableGroup } from 'react-simple-maps';
import geographyData from '../../../assets/geo/geometry.json';
import statesProvincesLinesData from '../../../assets/geo/states-provinces-lines.json';
import geometryTreeData from '../../../assets/geo/geometry.rbush.json';
import statesProvincesLinesTreeData from '../../../assets/geo/states-provinces-lines.rbush.json';
-
-// Infer the GeoProjection type from the `geoTimes()` return value
-type GeoProjection = ReturnType<typeof geoTimes>;
+import { useScheduler } from '../../shared/scheduler';
interface IGeometryLeaf extends rbush.BBox {
id: string;
@@ -35,288 +25,253 @@ const provincesStatesLinesTree = rbush<IProvinceAndStateLineLeaf>().fromJSON(
type BBox = [number, number, number, number];
-export interface IProps {
- width: number;
- height: number;
- center: [number, number]; // longitude, latitude
- offset: [number, number]; // [x, y] in points
- zoomLevel: number;
- showMarker: boolean;
- markerImagePath: string;
-}
-
-interface IState {
- zoomCenter: [number, number];
- zoomLevel: number;
- visibleGeometry: IGeometryLeaf[];
- visibleStatesProvincesLines: IProvinceAndStateLineLeaf[];
- // combine previous and current viewports to get the rough area of transition.
- viewportBboxes: BBox[];
-}
-
const MOVE_SPEED = 2000;
-// @TODO: Calculate zoom level based on (center + span) (aka MKCoordinateSpan)
-export default class SvgMap extends React.Component<IProps, IState> {
- public state: IState = {
- zoomCenter: [0, 0],
- zoomLevel: 1,
- visibleGeometry: [],
- visibleStatesProvincesLines: [],
- viewportBboxes: [],
- };
-
- private projectionConfig = {
- scale: 160,
- };
-
- private transitionEndScheduler = new Scheduler();
-
- constructor(props: IProps) {
- super(props);
-
- this.state = this.getNextState(null, props);
- }
-
- public UNSAFE_componentWillReceiveProps(nextProps: IProps) {
- if (this.shouldInvalidateState(nextProps)) {
- this.setState((prevState) => this.getNextState(prevState, nextProps));
- }
- }
-
- public shouldComponentUpdate(nextProps: IProps, nextState: IState) {
- 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
- );
- }
+const mapStyle = {
+ width: '100%',
+ height: '100%',
+ backgroundColor: '#192e45',
+};
+const zoomableGroupStyle = {
+ transition: `transform ${MOVE_SPEED}ms ease-out`,
+};
- public componentDidUpdate(_prevProps: IProps, _prevState: IState) {
- if (this.state.viewportBboxes.length > 1) {
- this.transitionEndScheduler.schedule(() => {
- this.setState((state) => this.removeOldViewportBboxes(state));
- }, MOVE_SPEED);
- }
- }
+const markerStyle = mergeRsmStyle({
+ default: {
+ transition: `transform ${MOVE_SPEED}ms ease-out`,
+ },
+});
- public componentWillUnmount() {
- this.transitionEndScheduler.cancel();
- }
+const geographyStyle = mergeRsmStyle({
+ default: {
+ fill: '#294d73',
+ stroke: '#192e45',
+ strokeWidth: 0.2,
+ },
+});
- public render() {
- const mapStyle = {
- width: '100%',
- height: '100%',
- backgroundColor: '#192e45',
- };
+const stateProvinceLineStyle = mergeRsmStyle({
+ default: {
+ fill: 'transparent',
+ stroke: '#192e45',
+ strokeWidth: 0.2,
+ },
+});
- const zoomableGroupStyle = {
- transition: `transform ${MOVE_SPEED}ms ease-in-out`,
- };
+const projectionConfig = {
+ scale: 160,
+};
- 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}`,
- },
- });
+function mergeRsmStyle(style: {
+ default?: React.CSSProperties;
+ hover?: React.CSSProperties;
+ pressed?: React.CSSProperties;
+}) {
+ const defaultStyle = style.default || {};
+ return {
+ default: defaultStyle,
+ hover: style.hover || defaultStyle,
+ pressed: style.pressed || defaultStyle,
+ };
+}
- const markerStyle = this.mergeRsmStyle({
- default: {
- transition: `transform ${MOVE_SPEED}ms ease-in-out`,
- },
- });
+function getProjection(width: number, height: number, offset: [number, number], scale: number) {
+ return geoMercator()
+ .scale(scale)
+ .translate([offset[0] + width / 2, offset[1] + height / 2])
+ .precision(0.1);
+}
- // 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" xlinkHref={this.props.markerImagePath} />
- </Marker>
- );
+function getZoomCenter(
+ center: [number, number],
+ offset: [number, number],
+ projection: GeoProjection,
+ zoom: number,
+): [number, number] {
+ const pos = projection(center)!;
+ return projection.invert!([pos[0] + offset[0] / zoom, pos[1] + offset[1] / zoom])!;
+}
- 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) => {
- return this.state.visibleGeometry.map(({ id }) => (
- <Geography
- key={id}
- geography={geographies[parseInt(id, 10)]}
- projection={projection}
- style={geographyStyle}
- />
- ));
- }}
- </Geographies>
- <Geographies geography={statesProvincesLinesData} disableOptimization={true}>
- {(geographies, projection) => {
- return this.state.visibleStatesProvincesLines.map(({ id }) => (
- <Geography
- key={id}
- geography={geographies[parseInt(id, 10)]}
- projection={projection}
- style={stateProvinceLineStyle}
- />
- ));
- }}
- </Geographies>
- <Markers>{[userMarker]}</Markers>
- </ZoomableGroup>
- </ComposableMap>
- );
- }
+function getViewportGeoBoundingBox(
+ centerCoordinate: [number, number],
+ width: number,
+ height: number,
+ projection: GeoProjection,
+ zoom: number,
+): BBox {
+ const center = projection(centerCoordinate)!;
+ const halfWidth = (width * 0.5) / zoom;
+ const halfHeight = (height * 0.5) / zoom;
- private mergeRsmStyle(style: {
- default?: React.CSSProperties;
- hover?: React.CSSProperties;
- pressed?: React.CSSProperties;
- }) {
- const defaultStyle = style.default || {};
- return {
- default: defaultStyle,
- hover: style.hover || defaultStyle,
- pressed: style.pressed || defaultStyle,
- };
- }
+ const northWest = projection.invert!([center[0] - halfWidth, center[1] - halfHeight])!;
+ const southEast = projection.invert!([center[0] + halfWidth, center[1] + halfHeight])!;
- private 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;
+ // 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]),
+ ];
+}
- return geoTimes()
- .scale(scale)
- .translate([xOffset + width / 2, yOffset + height / 2])
- .rotate(rotation)
- .precision(precision);
- }
+function getCombindedViewportBboxMatch(viewportBboxes: BBox[]) {
+ return {
+ minX: Math.min(...viewportBboxes.map((viewportBbox) => viewportBbox[0])),
+ minY: Math.min(...viewportBboxes.map((viewportBbox) => viewportBbox[1])),
+ maxX: Math.max(...viewportBboxes.map((viewportBbox) => viewportBbox[2])),
+ maxY: Math.max(...viewportBboxes.map((viewportBbox) => viewportBbox[3])),
+ };
+}
- private getZoomCenter(
- center: [number, number],
- offset: [number, number],
- projection: GeoProjection,
- zoom: number,
- ): [number, number] {
- const pos = projection(center)!;
- return projection.invert!([pos[0] + offset[0] / zoom, pos[1] + offset[1] / zoom])!;
- }
+function sameProps(prevProps: IProps, nextProps: IProps) {
+ return (
+ prevProps.width === nextProps.width &&
+ prevProps.height === nextProps.height &&
+ prevProps.center[0] === nextProps.center[0] &&
+ prevProps.center[1] === nextProps.center[1] &&
+ prevProps.offset[0] === nextProps.offset[0] &&
+ prevProps.offset[1] === nextProps.offset[1] &&
+ prevProps.zoomLevel === nextProps.zoomLevel &&
+ prevProps.showMarker === nextProps.showMarker &&
+ prevProps.markerImagePath === nextProps.markerImagePath
+ );
+}
- private getViewportGeoBoundingBox(
- centerCoordinate: [number, number],
- width: number,
- height: number,
- projection: GeoProjection,
- zoom: number,
- ): BBox {
- const center = projection(centerCoordinate)!;
- const halfWidth = (width * 0.5) / zoom;
- const halfHeight = (height * 0.5) / zoom;
+function useViewportBboxes(
+ center: [number, number],
+ width: number,
+ height: number,
+ projection: GeoProjection,
+ zoom: number,
+): [BBox[], () => void] {
+ const viewportBbox = useMemo(
+ () => getViewportGeoBoundingBox(center, width, height, projection, zoom),
+ [center, width, height, projection, zoom],
+ );
- const northWest = projection.invert!([center[0] - halfWidth, center[1] - halfHeight])!;
- const southEast = projection.invert!([center[0] + halfWidth, center[1] + halfHeight])!;
+ const prev = useRef<BBox[]>([]);
+ const viewportBboxes = useMemo(() => [...prev.current, viewportBbox], [viewportBbox]);
- // 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]),
- ];
- }
+ const keepLast = useCallback(() => {
+ prev.current = prev.current.slice(-1);
+ }, []);
- private shouldInvalidateState(nextProps: IProps) {
- 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
- );
- }
+ useEffect(() => {
+ prev.current = [...viewportBboxes];
+ }, [viewportBboxes]);
- private getNextState(prevState: IState | null, nextProps: IProps): IState {
- const { width, height, center, offset, zoomLevel } = nextProps;
- const viewportBboxes = prevState === null ? [] : prevState.viewportBboxes;
+ return [viewportBboxes, keepLast];
+}
- const projection = this.getProjection(width, height, this.projectionConfig);
- const zoomCenter = this.getZoomCenter(center, offset, projection, zoomLevel);
+function useVisibleGeometry(viewportBboxes: BBox[]) {
+ const combinedViewportBboxMatch = useMemo(() => getCombindedViewportBboxMatch(viewportBboxes), [
+ viewportBboxes,
+ ]);
+ const visibleGeometry = useMemo(() => geometryTree.search(combinedViewportBboxMatch), [
+ combinedViewportBboxMatch,
+ ]);
+ const visibleStatesProvincesLines = useMemo(
+ () => provincesStatesLinesTree.search(combinedViewportBboxMatch),
+ [combinedViewportBboxMatch],
+ );
- const viewportBbox = this.getViewportGeoBoundingBox(
- zoomCenter,
- width,
- height,
- projection,
- zoomLevel,
- );
- viewportBboxes.push(viewportBbox);
+ return [visibleGeometry, visibleStatesProvincesLines];
+}
- const combinedViewportBboxMatch = {
- minX: Math.min(...viewportBboxes.map((viewportBbox) => viewportBbox[0])),
- minY: Math.min(...viewportBboxes.map((viewportBbox) => viewportBbox[1])),
- maxX: Math.max(...viewportBboxes.map((viewportBbox) => viewportBbox[2])),
- maxY: Math.max(...viewportBboxes.map((viewportBbox) => viewportBbox[3])),
- };
+export interface IProps {
+ width: number;
+ height: number;
+ center: [number, number]; // longitude, latitude
+ offset: [number, number]; // [x, y] in points
+ zoomLevel: number;
+ showMarker: boolean;
+ markerImagePath: string;
+}
- const visibleGeometry = geometryTree.search(combinedViewportBboxMatch);
- const visibleStatesProvincesLines = provincesStatesLinesTree.search(combinedViewportBboxMatch);
+// @TODO: Calculate zoom level based on (center + span) (aka MKCoordinateSpan)
+function SvgMap(props: IProps) {
+ const { width, height, zoomLevel } = props;
+ const center = useMemo(() => props.center, [...props.center]);
+ const projection = useMemo(
+ () => getProjection(width, height, props.offset, projectionConfig.scale),
+ [width, height, ...props.offset, projectionConfig.scale],
+ );
+ const zoomCenter = useMemo(() => getZoomCenter(center, props.offset, projection, zoomLevel), [
+ ...center,
+ ...props.offset,
+ projection,
+ zoomLevel,
+ ]);
+ const [viewportBboxes, removeOldViewportBboxes] = useViewportBboxes(
+ zoomCenter,
+ width,
+ height,
+ projection,
+ zoomLevel,
+ );
+ const [visibleGeometry, visibleStatesProvincesLines] = useVisibleGeometry(viewportBboxes);
- return {
- zoomCenter,
- zoomLevel,
- visibleGeometry,
- visibleStatesProvincesLines,
- viewportBboxes,
- };
- }
+ // react-simple-maps renders the map with zoom=1 the first render resulting in a transition from
+ // 1 to zoomLevel when it immediately renders a second time. This makes sure that transitions are
+ // disabled until after the second render.
+ const [enableTransition, setEnableTransition] = useState(false);
+ const enableTransitionScheduler = useScheduler();
+ useEffect(() => enableTransitionScheduler.schedule(() => setEnableTransition(true)), []);
- private removeOldViewportBboxes(state: IState) {
- return { viewportBboxes: state.viewportBboxes.slice(-1) };
- }
+ return (
+ <ComposableMap
+ width={width}
+ height={height}
+ style={mapStyle}
+ projection={
+ // Workaround for incorrect type definition in @types/react-simple-maps.
+ /* @ts-ignore */
+ projection as () => GeoProjection
+ }
+ projectionConfig={projectionConfig}>
+ <ZoomableGroup
+ center={zoomCenter}
+ zoom={zoomLevel}
+ onTransitionEnd={removeOldViewportBboxes}
+ style={enableTransition ? zoomableGroupStyle : undefined}>
+ <Geographies geography={geographyData}>
+ {({ geographies }) => {
+ return visibleGeometry.map(({ id }) => (
+ <Geography
+ key={id}
+ geography={geographies[parseInt(id, 10)]}
+ style={geographyStyle}
+ />
+ ));
+ }}
+ </Geographies>
+ <Geographies geography={statesProvincesLinesData}>
+ {({ geographies }) => {
+ return visibleStatesProvincesLines.map(({ id }) => (
+ <Geography
+ key={id}
+ geography={geographies[parseInt(id, 10)]}
+ style={stateProvinceLineStyle}
+ />
+ ));
+ }}
+ </Geographies>
+ {
+ // disable CSS transition when moving between locations
+ // by using the different "key"
+ props.showMarker && (
+ <Marker
+ key={`user-location-${center.join('-')}`}
+ coordinates={center}
+ style={markerStyle}>
+ <image x="-6" y="-6" width="12" xlinkHref={props.markerImagePath} />
+ </Marker>
+ )
+ }
+ </ZoomableGroup>
+ </ComposableMap>
+ );
}
+
+export default React.memo(SvgMap, sameProps);
diff --git a/gui/types/d3-geo-projection/index.d.ts b/gui/types/d3-geo-projection/index.d.ts
deleted file mode 100644
index 0f6de59ba2..0000000000
--- a/gui/types/d3-geo-projection/index.d.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-declare module 'd3-geo-projection' {
- import { GeoProjection } from 'd3-geo';
-
- export function geoTimes(): GeoProjection;
-}