diff options
| author | Oskar Nyberg <oskar@mullvad.net> | 2023-04-03 11:51:15 +0200 |
|---|---|---|
| committer | Oskar Nyberg <oskar@mullvad.net> | 2024-01-30 10:10:36 +0100 |
| commit | 8efc4a76105779a09cc27b59cbc7f6a59c582e35 (patch) | |
| tree | a59b92c5537b3fab92e04b2f17e4c635ee185a12 /gui/src | |
| parent | 4e97ae3a7a566061675854fea1890518d02fd5c6 (diff) | |
| download | mullvadvpn-8efc4a76105779a09cc27b59cbc7f6a59c582e35.tar.xz mullvadvpn-8efc4a76105779a09cc27b59cbc7f6a59c582e35.zip | |
Integrate webgl maps into app
Diffstat (limited to 'gui/src')
| -rw-r--r-- | gui/src/main/index.ts | 14 | ||||
| -rw-r--r-- | gui/src/renderer/app.tsx | 1 | ||||
| -rw-r--r-- | gui/src/renderer/components/Connect.tsx | 86 | ||||
| -rw-r--r-- | gui/src/renderer/components/Map.tsx | 277 | ||||
| -rw-r--r-- | gui/src/renderer/components/SvgMap.tsx | 296 | ||||
| -rw-r--r-- | gui/src/renderer/lib/3dmap.ts | 822 | ||||
| -rw-r--r-- | gui/src/shared/ipc-schema.ts | 4 |
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>(), |
