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/src | |
| 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/src')
| -rw-r--r-- | gui/src/renderer/components/SvgMap.tsx | 501 |
1 files changed, 228 insertions, 273 deletions
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); |
