summaryrefslogtreecommitdiffhomepage
path: root/gui/src
diff options
context:
space:
mode:
authorOskar Nyberg <oskar@mullvad.net>2023-04-03 11:51:15 +0200
committerOskar Nyberg <oskar@mullvad.net>2024-01-30 10:10:36 +0100
commit8efc4a76105779a09cc27b59cbc7f6a59c582e35 (patch)
treea59b92c5537b3fab92e04b2f17e4c635ee185a12 /gui/src
parent4e97ae3a7a566061675854fea1890518d02fd5c6 (diff)
downloadmullvadvpn-8efc4a76105779a09cc27b59cbc7f6a59c582e35.tar.xz
mullvadvpn-8efc4a76105779a09cc27b59cbc7f6a59c582e35.zip
Integrate webgl maps into app
Diffstat (limited to 'gui/src')
-rw-r--r--gui/src/main/index.ts14
-rw-r--r--gui/src/renderer/app.tsx1
-rw-r--r--gui/src/renderer/components/Connect.tsx86
-rw-r--r--gui/src/renderer/components/Map.tsx277
-rw-r--r--gui/src/renderer/components/SvgMap.tsx296
-rw-r--r--gui/src/renderer/lib/3dmap.ts822
-rw-r--r--gui/src/shared/ipc-schema.ts4
7 files changed, 1015 insertions, 485 deletions
diff --git a/gui/src/main/index.ts b/gui/src/main/index.ts
index 3aa2cc3568..37f6eaa590 100644
--- a/gui/src/main/index.ts
+++ b/gui/src/main/index.ts
@@ -74,6 +74,8 @@ const ALLOWED_PERMISSIONS = ['clipboard-sanitized-write'];
const SANDBOX_DISABLED = app.commandLine.hasSwitch('no-sandbox');
const UPDATE_NOTIFICATION_DISABLED = process.env.MULLVAD_DISABLE_UPDATE_NOTIFICATION === '1';
+const GEO_DIR = path.resolve(__dirname, '../../assets/geo');
+
class ApplicationMain
implements
NotificationSender,
@@ -749,6 +751,18 @@ class ApplicationMain
currentApiAccessMethod: this.currentApiAccessMethod,
}));
+ IpcMainEventChannel.map.handleGetData(async () => ({
+ landContourIndices: await fs.promises.readFile(
+ path.join(GEO_DIR, 'land_contour_indices.bin'),
+ ),
+ landPositions: await fs.promises.readFile(path.join(GEO_DIR, 'land_positions.bin')),
+ landTriangleIndices: await fs.promises.readFile(
+ path.join(GEO_DIR, 'land_triangle_indices.bin'),
+ ),
+ oceanIndices: await fs.promises.readFile(path.join(GEO_DIR, 'ocean_indices.bin')),
+ oceanPositions: await fs.promises.readFile(path.join(GEO_DIR, 'ocean_positions.bin')),
+ }));
+
IpcMainEventChannel.tunnel.handleConnect(this.connectTunnel);
IpcMainEventChannel.tunnel.handleReconnect(this.reconnectTunnel);
IpcMainEventChannel.tunnel.handleDisconnect(this.disconnectTunnel);
diff --git a/gui/src/renderer/app.tsx b/gui/src/renderer/app.tsx
index 01d76474d4..325e237b23 100644
--- a/gui/src/renderer/app.tsx
+++ b/gui/src/renderer/app.tsx
@@ -363,6 +363,7 @@ export default class AppRenderer {
IpcRendererEventChannel.settings.testApiAccessMethodById(id);
public testCustomApiAccessMethod = (method: CustomProxy) =>
IpcRendererEventChannel.settings.testCustomApiAccessMethod(method);
+ public getMapData = () => IpcRendererEventChannel.map.getData();
public login = async (accountToken: AccountToken) => {
const actions = this.reduxActions;
diff --git a/gui/src/renderer/components/Connect.tsx b/gui/src/renderer/components/Connect.tsx
index 58e7b4e7ba..1a557ffa3d 100644
--- a/gui/src/renderer/components/Connect.tsx
+++ b/gui/src/renderer/components/Connect.tsx
@@ -13,25 +13,14 @@ import { useSelector } from '../redux/store';
import { calculateHeaderBarStyle, DefaultHeaderBar } from './HeaderBar';
import ImageView from './ImageView';
import { Container, Layout } from './Layout';
-import Map, { MarkerStyle, ZoomLevel } from './Map';
+import Map from './Map';
import NotificationArea from './NotificationArea';
import TunnelControl from './TunnelControl';
-type MarkerOrSpinner = 'marker' | 'spinner' | 'none';
-
const StyledContainer = styled(Container)({
position: 'relative',
});
-const StyledMap = styled(Map)({
- position: 'absolute',
- top: 0,
- left: 0,
- right: 0,
- bottom: 0,
- zIndex: 0,
-});
-
const Content = styled.div({
display: 'flex',
flex: 1,
@@ -69,70 +58,8 @@ export default function Connect() {
const relayLocations = useSelector((state) => state.settings.relayLocations);
const customLists = useSelector((state) => state.settings.customLists);
- const mapCenter = useMemo<[number, number] | undefined>(() => {
- const { longitude, latitude } = connection;
- return typeof longitude === 'number' && typeof latitude === 'number'
- ? [longitude, latitude]
- : undefined;
- }, [connection]);
-
- const showMarkerOrSpinner = useMemo<MarkerOrSpinner>(() => {
- if (!mapCenter) {
- return 'none';
- }
-
- switch (connection.status.state) {
- case 'error':
- return 'none';
- case 'connecting':
- case 'disconnecting':
- return 'spinner';
- case 'connected':
- case 'disconnected':
- return 'marker';
- }
- }, [mapCenter, connection.status.state]);
-
- const markerStyle = useMemo<MarkerStyle>(() => {
- switch (connection.status.state) {
- case 'connecting':
- case 'connected':
- return MarkerStyle.secure;
- case 'error':
- return !connection.status.details.blockingError ? MarkerStyle.secure : MarkerStyle.unsecure;
- case 'disconnected':
- return MarkerStyle.unsecure;
- case 'disconnecting':
- switch (connection.status.details) {
- case 'block':
- case 'reconnect':
- return MarkerStyle.secure;
- case 'nothing':
- return MarkerStyle.unsecure;
- }
- }
- }, [connection.status]);
-
- const zoomLevel = useMemo<ZoomLevel>(() => {
- const { longitude, latitude } = connection;
-
- if (typeof longitude === 'number' && typeof latitude === 'number') {
- return connection.status.state === 'connected' ? ZoomLevel.high : ZoomLevel.medium;
- } else {
- return ZoomLevel.low;
- }
- }, [connection.latitude, connection.longitude, connection.status.state]);
-
- const mapProps = useMemo<Map['props']>(() => {
- return {
- center: mapCenter ?? [0, 0],
- showMarker: showMarkerOrSpinner === 'marker',
- markerStyle,
- zoomLevel,
- // a magic offset to align marker with spinner
- offset: [0, mapCenter ? 123 : 0],
- };
- }, [mapCenter, showMarkerOrSpinner, markerStyle, zoomLevel]);
+ const showSpinner =
+ connection.status.state === 'connecting' || connection.status.state === 'disconnecting';
const onSelectLocation = useCallback(() => {
history.push(RoutePath.selectLocation, { transition: transitions.show });
@@ -174,15 +101,12 @@ export default function Connect() {
<Layout>
<DefaultHeaderBar barStyle={calculateHeaderBarStyle(connection.status)} />
<StyledContainer>
- <StyledMap {...mapProps} />
+ <Map />
<Content>
<StyledNotificationArea />
<StyledMain>
- {/* show spinner when connecting */}
- {showMarkerOrSpinner === 'spinner' ? (
- <StatusIcon source="icon-spinner" height={60} width={60} />
- ) : null}
+ {showSpinner ? <StatusIcon source="icon-spinner" height={60} width={60} /> : null}
<TunnelControl
tunnelState={connection.status}
diff --git a/gui/src/renderer/components/Map.tsx b/gui/src/renderer/components/Map.tsx
index acfe0f3000..3ccc794008 100644
--- a/gui/src/renderer/components/Map.tsx
+++ b/gui/src/renderer/components/Map.tsx
@@ -1,129 +1,190 @@
-import * as React from 'react';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import styled from 'styled-components';
-import SvgMap from './SvgMap';
+import { TunnelState } from '../../shared/daemon-rpc-types';
+import log from '../../shared/logging';
+import { useAppContext } from '../context';
+import GlMap, { ConnectionState, Coordinate } from '../lib/3dmap';
+import { useCombinedRefs } from '../lib/utilityHooks';
+import { useSelector } from '../redux/store';
-// Higher zoom level is more zoomed in
-export enum ZoomLevel {
- high,
- medium,
- low,
+// Default to Gothenburg when we don't know the actual location.
+const defaultLocation: Coordinate = { latitude: 57.70887, longitude: 11.97456 };
+
+const StyledCanvas = styled.canvas({
+ position: 'absolute',
+ width: '100%',
+ height: '100%',
+});
+
+interface MapParams {
+ location: Coordinate;
+ connectionState: ConnectionState;
+}
+
+type AnimationFrameCallback = (now: number, newParams?: MapParams) => void;
+
+export default function Map() {
+ const connection = useSelector((state) => state.connection);
+
+ const hasLocationValue = hasLocation(connection);
+ const location = useMemo<Coordinate | undefined>(() => {
+ return hasLocationValue ? connection : defaultLocation;
+ }, [hasLocationValue, connection.latitude, connection.longitude]);
+
+ const connectionState = getConnectionState(hasLocationValue, connection.status.state);
+
+ const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+ const animate = !reduceMotion;
+
+ return (
+ <MapInner
+ location={location ?? defaultLocation}
+ connectionState={connectionState}
+ animate={animate}
+ />
+ );
}
-export enum MarkerStyle {
- secure,
- unsecure,
+function hasLocation(location: Partial<Coordinate>): location is Coordinate {
+ return typeof location.latitude === 'number' && typeof location.longitude === 'number';
}
-interface IProps {
- center: [number, number]; // longitude, latitude
- offset: [number, number]; // offset [x, y] from the center of the map
- zoomLevel: ZoomLevel;
- showMarker: boolean;
- markerStyle: MarkerStyle;
- className?: string;
+function getConnectionState(hasLocation: boolean, connectionState: TunnelState['state']) {
+ if (!hasLocation) {
+ return ConnectionState.noMarker;
+ }
+
+ switch (connectionState) {
+ case 'connected':
+ return ConnectionState.connected;
+ case 'disconnected':
+ return ConnectionState.disconnected;
+ default:
+ return ConnectionState.noMarker;
+ }
}
-interface IState {
- bounds: {
- width: number;
- height: number;
- };
+interface MapInnerProps extends MapParams {
+ animate: boolean;
}
-export default class Map extends React.Component<IProps, IState> {
- public state: IState = {
- bounds: {
- width: 0,
- height: 0,
- },
- };
+function MapInner(props: MapInnerProps) {
+ const { getMapData } = useAppContext();
- private containerRef = React.createRef<HTMLDivElement>();
+ // Callback that should be passed to requestAnimationFrame. This is initialized after the canvas
+ // has been rendered.
+ const animationFrameCallback = useRef<AnimationFrameCallback>();
+ // When location or connection state changes it's stored here until passed to 3dmap
+ const newParams = useRef<MapParams>();
- public render() {
- const { width, height } = this.state.bounds;
- const readyToRenderTheMap = width > 0 && height > 0;
- return (
- <div className={this.props.className} ref={this.containerRef}>
- {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)}
- />
- )}
- </div>
- );
- }
+ // This is set to true when rendering should be paused
+ const pause = useRef<boolean>(false);
- public componentDidMount() {
- this.updateBounds();
- }
+ const canvasRef = useRef<HTMLCanvasElement>();
+ const [canvasWidth, setCanvasWidth] = useState(window.innerWidth);
+ // This constant is used for the height the first frame that is rendered only.
+ const [canvasHeight, setCanvasHeight] = useState(493);
- public componentDidUpdate() {
- this.updateBounds();
- }
+ const updateCanvasSize = useCallback((canvas: HTMLCanvasElement) => {
+ const canvasRect = canvas.getBoundingClientRect();
- public shouldComponentUpdate(nextProps: IProps, nextState: IState) {
- const oldProps = this.props;
- const oldState = this.state;
- return (
- 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.showMarker !== nextProps.showMarker ||
- oldProps.markerStyle !== nextProps.markerStyle ||
- oldState.bounds.width !== nextState.bounds.width ||
- oldState.bounds.height !== nextState.bounds.height
- );
- }
+ canvas.width = applyScaleFactor(canvasRect.width);
+ canvas.height = applyScaleFactor(canvasRect.height);
- private updateBounds() {
- const containerRect = this.containerRef.current?.getBoundingClientRect();
- if (containerRect) {
- this.setState((state) => {
- if (
- containerRect.width === state.bounds.width &&
- containerRect.height === state.bounds.height
- ) {
- return null;
- } else {
- return {
- bounds: {
- width: containerRect.width,
- height: containerRect.height,
- },
- };
- }
- });
- }
- }
+ setCanvasWidth(canvasRect.width);
+ setCanvasHeight(canvasRect.height);
+ }, []);
- // TODO: Remove zoom level in favor of center + coordinate span
- // TODO: Zoomlevels below 2.22 makes australia invisible
- private zoomLevel(variant: ZoomLevel) {
- switch (variant) {
- case ZoomLevel.low:
- return 1;
- case ZoomLevel.medium:
- return 2.22;
- case ZoomLevel.high:
- return 5;
+ // This is called when the canvas has been rendered the first time and initializes the gl context
+ // and the map.
+ const canvasCallback = useCallback(async (canvas: HTMLCanvasElement | null) => {
+ if (!canvas) {
+ return;
}
- }
- private markerImage(style: MarkerStyle): string {
- switch (style) {
- case MarkerStyle.secure:
- return '../../assets/images/location-marker-secure.svg';
- case MarkerStyle.unsecure:
- return '../../assets/images/location-marker-unsecure.svg';
+ updateCanvasSize(canvas);
+
+ const gl = canvas.getContext('webgl2', { antialias: true })!;
+
+ const map = new GlMap(
+ gl,
+ await getMapData(),
+ props.location,
+ props.connectionState,
+ () => (pause.current = true),
+ );
+
+ // Function to be used when calling requestAnimationFrame
+ animationFrameCallback.current = (now: number) => {
+ now *= 0.001; // convert to seconds
+
+ // Propagate location change to the map
+ if (newParams.current) {
+ map.setLocation(
+ newParams.current.location,
+ newParams.current.connectionState,
+ now,
+ props.animate,
+ );
+ newParams.current = undefined;
+ }
+
+ map.draw(now);
+
+ // Stops rendering if pause is true. This happens when there is no ongoing movements
+ if (!pause.current) {
+ requestAnimationFrame(animationFrameCallback.current!);
+ }
+ };
+
+ requestAnimationFrame(animationFrameCallback.current);
+ }, []);
+
+ // Set new params when the location or connection state has changed, and unpause if paused
+ useEffect(() => {
+ newParams.current = {
+ location: props.location,
+ connectionState: props.connectionState,
+ };
+
+ if (pause.current) {
+ pause.current = false;
+ if (animationFrameCallback.current) {
+ requestAnimationFrame(animationFrameCallback.current);
+ }
}
- }
+ }, [props.location, props.connectionState]);
+
+ // Resize canvas if window size changes
+ useEffect(() => {
+ const resizeCallback = () => {
+ if (canvasRef.current) {
+ updateCanvasSize(canvasRef.current);
+ }
+ };
+
+ addEventListener('resize', resizeCallback);
+ return () => removeEventListener('resize', resizeCallback);
+ }, [updateCanvasSize]);
+
+ // Log new scale factor if it changes
+ useEffect(() => log.verbose('Map canvas scale factor:', window.devicePixelRatio), [
+ window.devicePixelRatio,
+ ]);
+
+ const combinedCanvasRef = useCombinedRefs(canvasRef, canvasCallback);
+
+ return (
+ <StyledCanvas
+ ref={combinedCanvasRef}
+ width={applyScaleFactor(canvasWidth)}
+ height={applyScaleFactor(canvasHeight)}
+ />
+ );
+}
+
+function applyScaleFactor(dimension: number): number {
+ const scaleFactor = window.devicePixelRatio;
+ return Math.floor(dimension * scaleFactor);
}
diff --git a/gui/src/renderer/components/SvgMap.tsx b/gui/src/renderer/components/SvgMap.tsx
deleted file mode 100644
index bfc87f23fe..0000000000
--- a/gui/src/renderer/components/SvgMap.tsx
+++ /dev/null
@@ -1,296 +0,0 @@
-import { geoMercator, GeoProjection } from 'd3-geo';
-import RBush, { BBox as RBushBBox } from 'rbush';
-import React, { useCallback, useEffect, useMemo, useRef } from 'react';
-import { ComposableMap, Geographies, Geography, Marker } from 'react-simple-maps';
-
-import geographyData from '../../../assets/geo/geometry.json';
-import geometryTreeData from '../../../assets/geo/geometry.rbush.json';
-import statesProvincesLinesData from '../../../assets/geo/states-provinces-lines.json';
-import statesProvincesLinesTreeData from '../../../assets/geo/states-provinces-lines.rbush.json';
-
-interface IGeometryLeaf extends RBushBBox {
- id: string;
-}
-
-interface IProvinceAndStateLineLeaf extends RBushBBox {
- id: string;
-}
-
-const geometryTree = new RBush<IGeometryLeaf>().fromJSON(geometryTreeData);
-const provincesStatesLinesTree = new RBush<IProvinceAndStateLineLeaf>().fromJSON(
- statesProvincesLinesTreeData,
-);
-
-type BBox = [number, number, number, number];
-
-const MOVE_SPEED = 2000;
-
-const mapStyle = {
- width: '100%',
- height: '100%',
- backgroundColor: '#192e45',
-};
-const zoomableGroupStyle: React.CSSProperties = {
- transition: `transform ${MOVE_SPEED}ms ease-out`,
- // Workaround to prevent map blurryness in Electron 13+
- zoom: '100.01%',
-};
-
-function getMarkerImageStyle(zoom: number) {
- return {
- width: '60px',
- transform: `translate3d(${-30 / zoom}px, ${-30 / zoom}px, 0) scale(${1 / zoom})`,
- transition: `transform ${MOVE_SPEED}ms ease-out`,
- };
-}
-
-const geographyStyle = mergeRsmStyle({
- default: {
- fill: '#294d73',
- stroke: '#192e45',
- strokeWidth: 0.2,
- },
-});
-
-const stateProvinceLineStyle = mergeRsmStyle({
- default: {
- fill: 'transparent',
- stroke: '#192e45',
- strokeWidth: 0.2,
- },
-});
-
-const projectionConfig = {
- scale: 160,
-};
-
-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,
- };
-}
-
-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);
-}
-
-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])!;
-}
-
-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;
-
- 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]),
- ];
-}
-
-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])),
- };
-}
-
-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
- );
-}
-
-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 prev = useRef<BBox[]>([]);
- const viewportBboxes = useMemo(() => [...prev.current, viewportBbox], [viewportBbox]);
-
- const keepLast = useCallback(() => {
- prev.current = prev.current.slice(-1);
- }, []);
-
- useEffect(() => {
- prev.current = [...viewportBboxes];
- }, [viewportBboxes]);
-
- return [viewportBboxes, keepLast];
-}
-
-function useVisibleGeometry(viewportBboxes: BBox[]) {
- const combinedViewportBboxMatch = useMemo(() => getCombindedViewportBboxMatch(viewportBboxes), [
- viewportBboxes,
- ]);
- const visibleGeometry = useMemo(() => geometryTree.search(combinedViewportBboxMatch), [
- combinedViewportBboxMatch,
- ]);
- const visibleStatesProvincesLines = useMemo(
- () => provincesStatesLinesTree.search(combinedViewportBboxMatch),
- [combinedViewportBboxMatch],
- );
-
- return [visibleGeometry, visibleStatesProvincesLines];
-}
-
-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;
-}
-
-// @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);
-
- const markerStyle = useMemo(
- () => mergeRsmStyle({ default: { display: props.showMarker ? undefined : 'none' } }),
- [props.showMarker],
- );
- const markerImageStyle = useMemo(() => getMarkerImageStyle(zoomLevel), [zoomLevel]);
-
- return (
- <ComposableMap
- width={width}
- height={height}
- style={mapStyle}
- projection={
- // Workaround for incorrect type definition in @types/react-simple-maps.
- (projection as unknown) as () => GeoProjection
- }
- projectionConfig={projectionConfig}>
- <ZoomableGroup
- center={zoomCenter}
- className="map-zoomable-group"
- zoom={zoomLevel}
- onTransitionEnd={removeOldViewportBboxes}
- style={zoomableGroupStyle}
- width={width}
- height={height}
- projection={projection}>
- <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>
- <Marker coordinates={center} style={markerStyle}>
- <image style={markerImageStyle} xlinkHref={props.markerImagePath} />
- </Marker>
- </ZoomableGroup>
- </ComposableMap>
- );
-}
-
-export default React.memo(SvgMap, sameProps);
-
-// Workaround for issue where react-simple-maps does an animated zoom/pan when first loading the
-// map. When this issue is resolved it can be removed:
-// https://github.com/zcreativelabs/react-simple-maps/issues/228
-interface IZoomableGroupProps extends React.SVGAttributes<SVGGElement> {
- center: [number, number];
- zoom: number;
- width: number;
- height: number;
- projection: GeoProjection;
-}
-
-function ZoomableGroup(props: IZoomableGroupProps) {
- const { height, width, center, zoom, projection, ...otherProps } = props;
-
- const transform = useMemo(() => {
- const [x, y] = projection(center) ?? [0, 0];
- const translateX = width / 2 - x * zoom;
- const translateY = height / 2 - y * zoom;
- return `translate(${translateX} ${translateY}) scale(${zoom})`;
- }, [projection, center, width, height, zoom]);
-
- return <g transform={transform} {...otherProps} />;
-}
diff --git a/gui/src/renderer/lib/3dmap.ts b/gui/src/renderer/lib/3dmap.ts
new file mode 100644
index 0000000000..a39b9e5612
--- /dev/null
+++ b/gui/src/renderer/lib/3dmap.ts
@@ -0,0 +1,822 @@
+import { mat4 } from 'gl-matrix';
+
+type ColorRgba = [number, number, number, number];
+type ColorRgb = [number, number, number];
+
+export interface MapData {
+ landContourIndices: ArrayBuffer;
+ landPositions: ArrayBuffer;
+ landTriangleIndices: ArrayBuffer;
+ oceanIndices: ArrayBuffer;
+ oceanPositions: ArrayBuffer;
+}
+
+interface IndexBuffer {
+ indexBuffer: WebGLBuffer;
+ length: number;
+}
+
+interface ProgramInfo {
+ program: WebGLProgram;
+ attribLocations: {
+ vertexPosition: GLint;
+ vertexColor?: GLint;
+ };
+ uniformLocations: {
+ color?: WebGLUniformLocation;
+ projectionMatrix: WebGLUniformLocation;
+ modelViewMatrix: WebGLUniformLocation;
+ };
+}
+
+interface ZoomAnimation {
+ endTime: number;
+ compute(now: number): [number, number];
+}
+
+export enum ConnectionState {
+ disconnected,
+ connected,
+ noMarker,
+}
+
+// Color of "space" as seen in the corners when zooming out
+const spaceColor: ColorRgba = [10 / 255, 25 / 255, 35 / 255, 1];
+// Color values for various components of the map.
+const landColor: ColorRgba = [0.16, 0.302, 0.45, 1.0];
+const oceanColor: ColorRgba = [0.098, 0.18, 0.271, 1.0];
+// The color of borders between geographical entities
+const contourColor: ColorRgba = oceanColor;
+
+// The green color of the location marker when in the secured state
+const locationMarkerSecureColor: ColorRgb = [0.267, 0.678, 0.302];
+// The red color of the location marken when in the unsecured state
+const locationMarkerUnsecureColor: ColorRgb = [0.89, 0.251, 0.224];
+
+// The angle in degrees that the camera sees in
+const angleOfView = 70;
+
+// Zoom is distance from earths center. 1.0 is at the surface.
+// These constants define the zoom levels for the connected and disconnected states.
+const disconnectedZoom = 1.35;
+const connectedZoom = 1.25;
+
+// Animations longer than this time will use the out-in zoom animation.
+// Shorter animations will use the direct animation.
+const zoomAnimationStyleTimeBreakpoint = 1.7;
+// When animating with the out-in zoom animation, set the middle
+// zoom point to this times the max start or end zoom levels.
+const animationZoomoutFactor = 1.5;
+// Never zoom out further than this.
+const maxZoomout = Math.max(disconnectedZoom, connectedZoom) * animationZoomoutFactor;
+
+// The min and max time an animation to a new location can take.
+const animationMinTime = 1.3;
+const animationMaxTime = 2.5;
+
+// A geographical latitude, longitude coordinate in *degrees*.
+// This class is also being abused as a 2D vector in some parts of the code.
+export interface Coordinate {
+ latitude: number;
+ longitude: number;
+}
+
+class Vector {
+ public constructor(public x: number, public y: number) {}
+
+ public static fromCoordinate(coordinate: Coordinate): Vector {
+ return new Vector(coordinate.latitude, coordinate.longitude);
+ }
+
+ public toCoordinate() {
+ return { latitude: this.x, longitude: this.y };
+ }
+
+ public length() {
+ return Math.sqrt(this.x * this.x + this.y * this.y);
+ }
+
+ public scale(r: number) {
+ return new Vector(this.x * r, this.y * r);
+ }
+
+ public add(other: Vector) {
+ return new Vector(this.x + other.x, this.y + other.y);
+ }
+}
+
+// Class for drawing earth.
+class Globe {
+ private static vsSource = `
+ attribute vec3 aVertexPosition;
+
+ uniform vec4 uColor;
+ uniform mat4 uModelViewMatrix;
+ uniform mat4 uProjectionMatrix;
+
+ varying lowp vec4 vColor;
+
+ void main(void) {
+ gl_Position = uProjectionMatrix * uModelViewMatrix * vec4(aVertexPosition, 1.0);
+ vColor = uColor;
+ }
+ `;
+
+ private static fsSource = `
+ varying lowp vec4 vColor;
+
+ void main(void) {
+ gl_FragColor = vColor;
+ }
+ `;
+
+ private landVertexBuffer: WebGLBuffer;
+ private landContourIndexBuffer: IndexBuffer;
+ private landTriangleIndexBuffer: IndexBuffer;
+ private oceanVertexBuffer: WebGLBuffer;
+ private oceanIndexBuffer: IndexBuffer;
+
+ private programInfo: ProgramInfo;
+
+ public constructor(private gl: WebGL2RenderingContext, data: MapData) {
+ this.landVertexBuffer = initArrayBuffer(gl, data.landPositions);
+ this.oceanVertexBuffer = initArrayBuffer(gl, data.oceanPositions);
+
+ this.landContourIndexBuffer = initIndexBuffer(gl, data.landContourIndices);
+ this.landTriangleIndexBuffer = initIndexBuffer(gl, data.landTriangleIndices);
+ this.oceanIndexBuffer = initIndexBuffer(gl, data.oceanIndices);
+
+ const shaderProgram = initShaderProgram(gl, Globe.vsSource, Globe.fsSource);
+ this.programInfo = {
+ program: shaderProgram,
+ attribLocations: {
+ vertexPosition: gl.getAttribLocation(shaderProgram, 'aVertexPosition'),
+ },
+ uniformLocations: {
+ color: gl.getUniformLocation(shaderProgram, 'uColor')!,
+ projectionMatrix: gl.getUniformLocation(shaderProgram, 'uProjectionMatrix')!,
+ modelViewMatrix: gl.getUniformLocation(shaderProgram, 'uModelViewMatrix')!,
+ },
+ };
+ }
+
+ public draw(projectionMatrix: mat4, viewMatrix: mat4) {
+ const globeViewMatrix = mat4.clone(viewMatrix);
+
+ this.gl.useProgram(this.programInfo.program);
+
+ // Draw country contour lines
+ drawBufferElements(
+ this.gl,
+ this.programInfo,
+ projectionMatrix,
+ globeViewMatrix,
+ this.landVertexBuffer,
+ this.landContourIndexBuffer,
+ contourColor,
+ this.gl.LINES,
+ );
+
+ // We scale down to render the land triangles behind/under the country contour lines.
+ mat4.scale(
+ globeViewMatrix, // destination matrix
+ globeViewMatrix, // matrix to scale
+ [0.9999, 0.9999, 0.9999], // amount to scale
+ );
+
+ // Draw land triangles.
+ drawBufferElements(
+ this.gl,
+ this.programInfo,
+ projectionMatrix,
+ globeViewMatrix,
+ this.landVertexBuffer,
+ this.landTriangleIndexBuffer,
+ landColor,
+ this.gl.TRIANGLES,
+ );
+
+ // Draw the ocean as a sphere just beneath the land.
+ drawBufferElements(
+ this.gl,
+ this.programInfo,
+ projectionMatrix,
+ globeViewMatrix,
+ this.oceanVertexBuffer,
+ this.oceanIndexBuffer,
+ oceanColor,
+ this.gl.TRIANGLES,
+ );
+ }
+}
+
+// Class for rendering a location marker on a given coordinate on the globe.
+class LocationMarker {
+ private static vsSource = `
+ attribute vec3 aVertexPosition;
+ attribute vec4 aVertexColor;
+
+ uniform mat4 uModelViewMatrix;
+ uniform mat4 uProjectionMatrix;
+
+ varying lowp vec4 vColor;
+
+ void main(void) {
+ gl_Position = uProjectionMatrix * uModelViewMatrix * vec4(aVertexPosition, 1.0);
+ vColor = aVertexColor;
+ }
+ `;
+
+ private static fsSource = `
+ varying lowp vec4 vColor;
+
+ void main(void) {
+ gl_FragColor = vColor;
+ }
+ `;
+
+ private programInfo: ProgramInfo;
+ private ringPositionCount: Array<number>;
+ private positionBuffer: WebGLBuffer;
+ private colorBuffer: WebGLBuffer;
+
+ public constructor(private gl: WebGL2RenderingContext, color: ColorRgb) {
+ const white: ColorRgb = [1.0, 1.0, 1.0];
+ const black: ColorRgb = [0.0, 0.0, 0.0];
+ const rings = [
+ circleFanVertices(32, 0.5, [0.0, 0.0, 0.0], [...color, 0.4], [...color, 0.4]), // Semi-transparent outer
+ circleFanVertices(16, 0.28, [0.0, -0.05, 0.00001], [...black, 0.55], [...black, 0.0]), // shadow
+ circleFanVertices(32, 0.185, [0.0, 0.0, 0.00002], [...white, 1.0], [...white, 1.0]), // white ring
+ circleFanVertices(32, 0.15, [0.0, 0.0, 0.00003], [...color, 1.0], [...color, 1.0]), // Center colored circle
+ ];
+
+ const positionArrayBuffer = new Float32Array(rings.map((r) => r.positions).flat());
+ const colorArrayBuffer = new Float32Array(rings.map((r) => r.colors).flat());
+ this.ringPositionCount = rings.map((r) => r.positions.length);
+ this.positionBuffer = initArrayBuffer(gl, positionArrayBuffer);
+ this.colorBuffer = initArrayBuffer(gl, colorArrayBuffer);
+
+ const shaderProgram = initShaderProgram(gl, LocationMarker.vsSource, LocationMarker.fsSource);
+ this.programInfo = {
+ program: shaderProgram,
+ attribLocations: {
+ vertexPosition: gl.getAttribLocation(shaderProgram, 'aVertexPosition'),
+ vertexColor: gl.getAttribLocation(shaderProgram, 'aVertexColor'),
+ },
+ uniformLocations: {
+ projectionMatrix: gl.getUniformLocation(shaderProgram, 'uProjectionMatrix')!,
+ modelViewMatrix: gl.getUniformLocation(shaderProgram, 'uModelViewMatrix')!,
+ },
+ };
+ }
+
+ public draw(projectionMatrix: mat4, viewMatrix: mat4, coordinate: Coordinate, size: number) {
+ const modelViewMatrix = mat4.clone(viewMatrix);
+
+ this.gl.useProgram(this.programInfo.program);
+
+ const [theta, phi] = coordinates2thetaphi(coordinate);
+ mat4.rotateY(modelViewMatrix, modelViewMatrix, theta);
+ mat4.rotateX(modelViewMatrix, modelViewMatrix, -phi);
+
+ mat4.scale(modelViewMatrix, modelViewMatrix, [size, size, 1.0]);
+ mat4.translate(modelViewMatrix, modelViewMatrix, [0.0, 0.0, 1.0001]);
+
+ {
+ this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.positionBuffer);
+ this.gl.vertexAttribPointer(
+ this.programInfo.attribLocations.vertexPosition,
+ 3, // num components
+ this.gl.FLOAT, // type
+ false, // normalize
+ 0, // stride
+ 0, // offset
+ );
+ this.gl.enableVertexAttribArray(this.programInfo.attribLocations.vertexPosition);
+ }
+ {
+ this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.colorBuffer);
+ this.gl.vertexAttribPointer(
+ this.programInfo.attribLocations.vertexColor!,
+ 4, // num components
+ this.gl.FLOAT, // type
+ false, // normalize
+ 0, // stride
+ 0, // offset
+ );
+ this.gl.enableVertexAttribArray(this.programInfo.attribLocations.vertexColor!);
+ }
+
+ // Set the shader uniforms
+ this.gl.uniformMatrix4fv(
+ this.programInfo.uniformLocations.projectionMatrix,
+ false,
+ projectionMatrix,
+ );
+ this.gl.uniformMatrix4fv(
+ this.programInfo.uniformLocations.modelViewMatrix,
+ false,
+ modelViewMatrix,
+ );
+
+ let offset = 0;
+ for (let i = 0; i < this.ringPositionCount.length; i++) {
+ const numVertices = this.ringPositionCount[i] / 3;
+ this.gl.drawArrays(this.gl.TRIANGLE_FAN, offset, numVertices);
+ offset += numVertices;
+ }
+ }
+}
+
+// Class for computing a smooth linear interpolation from `start` along `path`.
+// Starting at time `startTime` (usually now() at the time of creating an instance),
+// and animating for `duration` seconds
+class SmoothLerp {
+ public constructor(
+ private start: Vector,
+ private path: Vector,
+ private startTime: number,
+ private duration: number,
+ ) {}
+
+ // Computes and returns the position as well as the smoothened transition
+ // ratio of this lerp operation.
+ public compute(now: number): [Vector, number] {
+ const animationRatio = Math.min(Math.max((now - this.startTime) / this.duration, 0.0), 1.0);
+ const smoothAnimationRatio = smoothTransition(animationRatio);
+ const position = this.start.add(this.path.scale(smoothAnimationRatio));
+ return [position, smoothAnimationRatio];
+ }
+}
+
+// Zooms from startZoom to endZoom via a midpoint that is `animationZoomoutFactor` times higer up
+// than max(startZoom, endZoom).
+class SmoothZoomOutIn implements ZoomAnimation {
+ private middleZoom: number;
+
+ public constructor(
+ private startZoom: number,
+ private endZoom: number,
+ private startTime: number,
+ private duration: number,
+ ) {
+ this.middleZoom = Math.min(Math.max(startZoom, endZoom) * animationZoomoutFactor, maxZoomout);
+ }
+
+ get endTime(): number {
+ return this.startTime + this.duration;
+ }
+
+ public compute(now: number): [number, number] {
+ const animationRatio = Math.min(Math.max((now - this.startTime) / this.duration, 0.0), 1.0);
+ // Linear animation ratio 0-1. 0.0-0.5 means zooming out and 0.5-1.0 means zooming in
+ if (animationRatio <= 0.5) {
+ const smoothAnimationRatio = smoothTransition(animationRatio * 2);
+ return [
+ this.startZoom + smoothAnimationRatio * (this.middleZoom - this.startZoom),
+ animationRatio,
+ ];
+ } else {
+ const smoothAnimationRatio = smoothTransition((animationRatio - 0.5) * 2);
+ return [
+ this.middleZoom - smoothAnimationRatio * (this.middleZoom - this.endZoom),
+ animationRatio,
+ ];
+ }
+ }
+}
+
+// Zooms from startZoom to endZoom directly in a smooth manner.
+class SmoothZoomDirect implements ZoomAnimation {
+ public constructor(
+ private startZoom: number,
+ private endZoom: number,
+ private startTime: number,
+ private duration: number,
+ ) {}
+
+ get endTime(): number {
+ return this.startTime + this.duration;
+ }
+
+ public compute(now: number): [number, number] {
+ const animationRatio = Math.min(Math.max((now - this.startTime) / this.duration, 0.0), 1.0);
+ const smoothAnimationRatio = smoothTransition(animationRatio);
+ return [
+ this.startZoom + smoothAnimationRatio * (this.endZoom - this.startZoom),
+ animationRatio,
+ ];
+ }
+}
+
+export default class GlMap {
+ private projectionMatrix: mat4;
+ private globe: Globe;
+ private locationMarkerSecure: LocationMarker;
+ private locationMarkerUnsecure: LocationMarker;
+
+ // Current state of the map positioning
+ private coordinate: Coordinate;
+ private zoom: number;
+ private connectionState: ConnectionState;
+
+ // `targetCoordinate` is the same as `coordinate` when no animation is in progress.
+ // This is where the location marker is drawn.
+ private targetCoordinate: Coordinate;
+
+ // Current ongoing animations. Empty arrays when no animation in progress.
+ private animations: Array<SmoothLerp>;
+ private zoomAnimations: Array<ZoomAnimation>;
+
+ public constructor(
+ private gl: WebGL2RenderingContext,
+ data: MapData,
+ startCoordinate: Coordinate,
+ connectionState: ConnectionState,
+ private animationEndListener?: () => void,
+ ) {
+ initGlOptions(gl);
+ this.projectionMatrix = getProjectionMatrix(gl);
+ this.globe = new Globe(gl, data);
+ this.locationMarkerSecure = new LocationMarker(gl, locationMarkerSecureColor);
+ this.locationMarkerUnsecure = new LocationMarker(gl, locationMarkerUnsecureColor);
+
+ this.coordinate = startCoordinate;
+ this.zoom = connectionState === ConnectionState.connected ? connectedZoom : disconnectedZoom;
+ this.connectionState = connectionState;
+
+ this.targetCoordinate = startCoordinate;
+
+ this.animations = [];
+ this.zoomAnimations = [];
+ }
+
+ // Move the location marker to `newCoordinate` (with state `connectionState`).
+ // Queues an animation to `newCoordinate` if `animate` is true. Otherwise it moves
+ // directly to that location.
+ public setLocation(
+ newCoordinate: Coordinate,
+ connectionState: ConnectionState,
+ now: number,
+ animate: boolean,
+ ) {
+ const endZoom = connectionState == ConnectionState.connected ? connectedZoom : disconnectedZoom;
+
+ // Only perform a coordinate animation if the new coordinate is
+ // different from the current position/latest ongoing animation.
+ // If the new coordinate is the same as the current target, we just
+ // queue a zoom animation.
+ if (animate) {
+ if (newCoordinate !== this.targetCoordinate) {
+ const path = shortestPath(
+ Vector.fromCoordinate(this.coordinate),
+ Vector.fromCoordinate(newCoordinate),
+ );
+
+ // Compute animation time as a function of movement distance. Clamp the
+ // duration range between animationMinTime and animationMaxTime
+ const duration = Math.min(Math.max(path.length() / 20, animationMinTime), animationMaxTime);
+
+ this.animations.push(
+ new SmoothLerp(Vector.fromCoordinate(this.coordinate), path, now, duration),
+ );
+ if (duration > zoomAnimationStyleTimeBreakpoint) {
+ this.zoomAnimations.push(new SmoothZoomOutIn(this.zoom, endZoom, now, duration));
+ } else {
+ this.zoomAnimations.push(new SmoothZoomDirect(this.zoom, endZoom, now, duration));
+ }
+ } else {
+ let duration = animationMinTime;
+ // If an animation is in progress, make sure our zoom animation ends at the same time.
+ // Just makes a smooth transition from one zoom end state to the other.
+ if (this.zoomAnimations.length > 0) {
+ const lastZoomAnimation = this.zoomAnimations[this.zoomAnimations.length - 1];
+ duration = Math.max(lastZoomAnimation.endTime - now, animationMinTime);
+ }
+ this.zoomAnimations.push(new SmoothZoomDirect(this.zoom, endZoom, now, duration));
+ }
+ } else {
+ this.animations = [];
+ this.zoomAnimations = [];
+ this.coordinate = newCoordinate;
+ this.zoom = endZoom;
+ }
+
+ this.connectionState = connectionState;
+ this.targetCoordinate = newCoordinate;
+ }
+
+ // Render the map for the time `now`.
+ public draw(now: number) {
+ this.clearCanvas();
+ this.updatePosition(now);
+ this.updateZoom(now);
+
+ if (this.animations.length === 0 && this.zoomAnimations.length === 0) {
+ this.animationEndListener?.();
+ }
+
+ const viewMatrix = mat4.create();
+
+ // Offset Y for placing the marker at the same area as the spinner. The zoom calculation is
+ // required for the unsecured and secured markers to be placed in the same spot.
+ // The constants look arbitrary. They are found by just trying stuff until it looks good.
+ const offsetY = 0.088 + (this.zoom - connectedZoom) * 0.3;
+
+ // Move the camera back `this.zoom` away from the center of the globe.
+ mat4.translate(
+ viewMatrix, // destination matrix
+ viewMatrix, // matrix to translate
+ [0.0, offsetY, -this.zoom],
+ );
+
+ // Rotate the globe so the camera ends up looking down on `this.coordinate`.
+ const [theta, phi] = coordinates2thetaphi(this.coordinate);
+ mat4.rotateX(viewMatrix, viewMatrix, phi);
+ mat4.rotateY(viewMatrix, viewMatrix, -theta);
+
+ this.globe.draw(this.projectionMatrix, viewMatrix);
+
+ // Draw the appropriate location marker depending on our connection state.
+ switch (this.connectionState) {
+ case ConnectionState.disconnected:
+ this.locationMarkerUnsecure.draw(
+ this.projectionMatrix,
+ viewMatrix,
+ this.targetCoordinate,
+ 0.03 * this.zoom,
+ );
+ break;
+ case ConnectionState.connected:
+ this.locationMarkerSecure.draw(
+ this.projectionMatrix,
+ viewMatrix,
+ this.targetCoordinate,
+ 0.03 * this.zoom,
+ );
+ break;
+ }
+ }
+
+ private clearCanvas() {
+ this.gl.clearColor(...spaceColor); // Set the clear color to space color
+ this.gl.clearDepth(1.0);
+ this.gl.enable(this.gl.DEPTH_TEST); // Enable depth testing
+ this.gl.depthFunc(this.gl.LEQUAL); // Near things obscure far things
+
+ // Clear the canvas before we start drawing on it.
+ this.gl.clear(this.gl.COLOR_BUFFER_BIT | this.gl.DEPTH_BUFFER_BIT);
+ }
+
+ // Private function that just updates internal animation state to match with time `now`.
+ private updatePosition(now: number) {
+ if (this.animations.length === 0) {
+ return;
+ }
+
+ // Compute lerp position and ratio of the newest animation
+ const lastAnimation = this.animations[this.animations.length - 1];
+ let [coordinate, ratio] = lastAnimation.compute(now);
+ if (ratio >= 1.0) {
+ // Animation is done. We can empty the animations array
+ this.animations = [];
+ }
+
+ // Loop through all previous animations (that are still in progress) backwards and
+ // lerp between them to compute our actual location.
+ for (let i = this.animations.length - 2; i >= 0; i--) {
+ const [previousPoint, animationRatio] = this.animations[i].compute(now);
+ coordinate = lerpVector(previousPoint, coordinate, ratio);
+ // If this animation is finished, none of the animations [0, i) will have any effect,
+ // so they can be pruned
+ if (animationRatio >= 1.0 && i > 0) {
+ this.animations = this.animations.slice(i, this.animations.length);
+
+ break;
+ }
+ ratio = animationRatio;
+ }
+
+ // Set our coordinate and zoom to the values interpolated from all ongoing animations.
+ this.coordinate = coordinate.toCoordinate();
+ }
+
+ // Private function that updates the current zoom level according to ongoing animations.
+ private updateZoom(now: number) {
+ if (this.zoomAnimations.length === 0) {
+ return;
+ }
+
+ const lastZoomAnimation = this.zoomAnimations[this.zoomAnimations.length - 1];
+ let [zoom, ratio] = lastZoomAnimation.compute(now);
+
+ if (ratio >= 1.0) {
+ // Animation is done. We can empty the animations array
+ this.zoomAnimations = [];
+ }
+
+ // Loop through all previous animations (that are still in progress) backwards and
+ // lerp between them to compute our actual location.
+ for (let i = this.zoomAnimations.length - 2; i >= 0; i--) {
+ const [previousZoom, animationRatio] = this.zoomAnimations[i].compute(now);
+ zoom = lerp(previousZoom, zoom, ratio);
+ // If this animation is finished, none of the animations [0, i) will have any effect,
+ // so they can be pruned
+ if (animationRatio >= 1.0 && i > 0) {
+ this.zoomAnimations = this.zoomAnimations.slice(i, this.zoomAnimations.length);
+ break;
+ }
+ ratio = animationRatio;
+ }
+
+ // Set our coordinate and zoom to the values interpolated from all ongoing animations.
+ this.zoom = zoom;
+ }
+}
+
+function initGlOptions(gl: WebGL2RenderingContext) {
+ // Hide triangles not facing the camera
+ gl.enable(gl.CULL_FACE);
+ gl.cullFace(gl.BACK);
+
+ // Enable transparency (alpha < 1.0)
+ gl.enable(gl.BLEND);
+ gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
+}
+
+function getProjectionMatrix(gl: WebGL2RenderingContext): mat4 {
+ // Enables using gl.UNSIGNED_INT for indexes. Allows 32 bit integer
+ // indexes. Needed to have more than 2^16 vertices in one buffer.
+ // Not needed on WebGL2 canvases where it's enabled by default
+ // const ext = gl.getExtension('OES_element_index_uint');
+
+ // Create a perspective matrix, a special matrix that is
+ // used to simulate the distortion of perspective in a camera.
+ const fieldOfView = (angleOfView / 180) * Math.PI; // in radians
+ // @ts-ignore
+ const aspect = gl.canvas.clientWidth / gl.canvas.clientHeight;
+ const zNear = 0.1;
+ const zFar = 10;
+ const projectionMatrix = mat4.create();
+ mat4.perspective(projectionMatrix, fieldOfView, aspect, zNear, zFar);
+
+ return projectionMatrix;
+}
+
+// Draws primitives of type `mode` (TRIANGLES, LINES etc) using vertex positions from
+// `positionBuffer` at indices in `indices` with the color `color` and using the shaders in
+// `programInfo`.
+function drawBufferElements(
+ gl: WebGL2RenderingContext,
+ programInfo: ProgramInfo,
+ projectionMatrix: mat4,
+ modelViewMatrix: mat4,
+ positionBuffer: WebGLBuffer,
+ indices: IndexBuffer,
+ color: ColorRgba,
+ mode: GLenum,
+) {
+ {
+ gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
+ gl.vertexAttribPointer(
+ programInfo.attribLocations.vertexPosition,
+ 3, // num components
+ gl.FLOAT, // type
+ false, // normalize
+ 0, // stride
+ 0, // offset
+ );
+ gl.enableVertexAttribArray(programInfo.attribLocations.vertexPosition);
+ }
+
+ // Tell WebGL which indices to use to index the vertices
+ gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indices.indexBuffer);
+
+ // Set the shader uniforms
+ gl.uniform4fv(programInfo.uniformLocations.color!, color);
+ gl.uniformMatrix4fv(programInfo.uniformLocations.projectionMatrix, false, projectionMatrix);
+ gl.uniformMatrix4fv(programInfo.uniformLocations.modelViewMatrix, false, modelViewMatrix);
+
+ gl.drawElements(mode, indices.length, gl.UNSIGNED_INT, 0);
+}
+
+// Allocates and returns an ELEMENT_ARRAY_BUFFER filled with the Uint32 indices in `indices`.
+// On a WebGL1 canvas the `OES_element_index_uint` extension must be loaded.
+function initIndexBuffer(gl: WebGL2RenderingContext, indices: ArrayBuffer): IndexBuffer {
+ const indexBuffer = gl.createBuffer()!;
+ gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
+ gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.STATIC_DRAW);
+ return {
+ indexBuffer: indexBuffer,
+ // Values are 32 bit, i.e. 4 bytes per value
+ length: indices.byteLength / 4,
+ };
+}
+
+// Allocates and returns an ARRAY_BUFFER filled with the Float32 data in `data`.
+// This type of buffer is used for vertex coordinate data and color values.
+function initArrayBuffer(gl: WebGL2RenderingContext, data: ArrayBuffer) {
+ const arrayBuffer = gl.createBuffer()!;
+ gl.bindBuffer(gl.ARRAY_BUFFER, arrayBuffer);
+ gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW);
+ return arrayBuffer;
+}
+
+// Initialize a shader program, so WebGL knows how to draw our data
+function initShaderProgram(gl: WebGL2RenderingContext, vsSource: string, fsSource: string) {
+ const vertexShader = loadShader(gl, gl.VERTEX_SHADER, vsSource)!;
+ const fragmentShader = loadShader(gl, gl.FRAGMENT_SHADER, fsSource)!;
+
+ const shaderProgram = gl.createProgram()!;
+ gl.attachShader(shaderProgram, vertexShader);
+ gl.attachShader(shaderProgram, fragmentShader);
+ gl.linkProgram(shaderProgram);
+
+ // See if creating the shader program was successful
+ if (!gl.getProgramParameter(shaderProgram, gl.LINK_STATUS)) {
+ throw new Error('Failed to create shader program');
+ }
+
+ return shaderProgram;
+}
+
+// creates a shader of the given type, uploads the source and compiles it.
+function loadShader(gl: WebGL2RenderingContext, type: GLenum, source: string) {
+ const shader = gl.createShader(type)!;
+ gl.shaderSource(shader, source);
+ gl.compileShader(shader);
+
+ // See if the shader compiled successfully
+ if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
+ alert('An error occurred compiling the shaders: ' + gl.getShaderInfoLog(shader));
+ gl.deleteShader(shader);
+ return null;
+ }
+
+ return shader;
+}
+
+// Takes coordinates in degrees and outputs [theta, phi]
+function coordinates2thetaphi(coordinate: Coordinate) {
+ const phi = coordinate.latitude * (Math.PI / 180);
+ const theta = coordinate.longitude * (Math.PI / 180);
+ return [theta, phi];
+}
+
+// Returns a `Vector` between c1 and c2.
+// ratio=0.0 returns c1. ratio=1.0 returns c2.
+function lerpVector(c1: Vector, c2: Vector, ratio: number) {
+ const x = lerp(c1.x, c2.x, ratio);
+ const y = lerp(c1.y, c2.y, ratio);
+ return new Vector(x, y);
+}
+
+// Performs linear interpolation between two floats, `x` and `y`.
+function lerp(x: number, y: number, ratio: number) {
+ return x + (y - x) * ratio;
+}
+
+// The shortest coordinate change from c1 to c2.
+// Returns a vector representing the movement needed to go from c1 to c2 (as a `Vector`)
+// The input vectors are expected to be lat/long coordinates *in degrees*
+function shortestPath(c1: Vector, c2: Vector) {
+ let longDiff = c2.y - c1.y;
+ if (longDiff > 180) {
+ longDiff -= 360;
+ } else if (longDiff < -180) {
+ longDiff += 360;
+ }
+ return new Vector(c2.x - c1.x, longDiff);
+}
+
+// smooths out a linear 0-1 transition into an accelerating and decelerating transition
+function smoothTransition(x: number) {
+ return 0.5 - 0.5 * Math.cos(x * Math.PI);
+}
+
+// Returns vertex positions and color values for a circle.
+// `offset` is a vector of x, y and z values determining how much to offset the circle
+// position from origo
+function circleFanVertices(
+ numEdges: number,
+ radius: number,
+ offset: [number, number, number],
+ centerColor: ColorRgba,
+ ringColor: ColorRgba,
+) {
+ const positions = [...offset];
+ const colors = [...centerColor];
+ for (let i = 0; i <= numEdges; i++) {
+ const angle = (i / numEdges) * 2 * Math.PI;
+ const x = offset[0] + radius * Math.cos(angle);
+ const y = offset[1] + radius * Math.sin(angle);
+ const z = offset[2];
+ positions.push(x, y, z);
+ colors.push(...ringColor);
+ }
+ return { positions: positions, colors: colors };
+}
+
+// Good resources:
+// https://www.youtube.com/watch?v=aVwxzDHniEw - The Beauty of Bézier Curves
+// https://splines.readthedocs.io/en/latest/rotation/slerp.html - slerp - spherical lerp
diff --git a/gui/src/shared/ipc-schema.ts b/gui/src/shared/ipc-schema.ts
index 3f348b1d86..6c7004d787 100644
--- a/gui/src/shared/ipc-schema.ts
+++ b/gui/src/shared/ipc-schema.ts
@@ -32,6 +32,7 @@ interface ILogEntry {
level: LogLevel;
message: string;
}
+import { MapData } from '../renderer/lib/3dmap';
import { invoke, invokeSync, notifyRenderer, send } from './ipc-helpers';
import {
IChangelog,
@@ -120,6 +121,9 @@ export const ipcSchema = {
state: {
get: invokeSync<void, IAppStateSnapshot>(),
},
+ map: {
+ getData: invoke<void, MapData>(),
+ },
window: {
shape: notifyRenderer<IWindowShapeParameters>(),
focus: notifyRenderer<boolean>(),