summaryrefslogtreecommitdiffhomepage
path: root/gui/src
diff options
context:
space:
mode:
Diffstat (limited to 'gui/src')
-rw-r--r--gui/src/renderer/components/SvgMap.tsx501
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);