diff options
| author | Oskar Nyberg <oskar@mullvad.net> | 2020-09-10 10:07:28 +0200 |
|---|---|---|
| committer | Oskar Nyberg <oskar@mullvad.net> | 2020-09-10 10:07:28 +0200 |
| commit | 2d78e61d0766735f36640753e540eb300f437188 (patch) | |
| tree | a64eb4a843706df384397752ce0174eb6203f18b /gui | |
| parent | 735f1c40d7b07c471bbbc56013b2f2452f05b4e8 (diff) | |
| parent | 86a2277f494b34fc82435a4c239cea3f7707f509 (diff) | |
| download | mullvadvpn-2d78e61d0766735f36640753e540eb300f437188.tar.xz mullvadvpn-2d78e61d0766735f36640753e540eb300f437188.zip | |
Merge branch 'update-react-simple-maps2' into master
Diffstat (limited to 'gui')
| -rw-r--r-- | gui/package-lock.json | 124 | ||||
| -rw-r--r-- | gui/package.json | 6 | ||||
| -rw-r--r-- | gui/src/renderer/components/SvgMap.tsx | 501 | ||||
| -rw-r--r-- | gui/types/d3-geo-projection/index.d.ts | 5 |
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; -} |
