Refactoring to split logic between `<Map>` and a brand new `<LiveMap>` component.

This commit is contained in:
Eric van der Vlist 2022-10-31 15:02:46 +01:00
parent 425ad06e33
commit 7b5abaccd3
10 changed files with 165 additions and 34 deletions

View File

@ -33,7 +33,14 @@ const App: React.FC = () => (
<IonReactRouter> <IonReactRouter>
<IonRouterOutlet> <IonRouterOutlet>
<Route exact path='/home'> <Route exact path='/home'>
<Map /> <Map
scope={{
center: { lat: -37.8403508, lon: 77.5539501 },
zoom: 15,
tileProvider: 'osm',
}}
numberOfTiledLayers={1}
/>
</Route> </Route>
<Route exact path='/'> <Route exact path='/'>
<Redirect to='/home' /> <Redirect to='/home' />

View File

@ -1,15 +1,20 @@
import { renderHook, act, render, screen } from '@testing-library/react'; import { renderHook, act, render, screen } from '@testing-library/react';
import { useAtom } from 'jotai'; import { useAtom } from 'jotai';
import LayerStack from './LayerStack'; import LayerStack from './LayerStack';
import { coordinateSystemAtom, relativeCoordinateSystemAtom } from './Map'; import { CoordinateSystem } from './Map';
describe('The LayerStack component', () => { describe('The LayerStack component', () => {
const coordinateSystem:CoordinateSystem= {
shift:{x:0,y:0},
zoom:1,
}
test('generates four empty layers and a populated one', () => { test('generates four empty layers and a populated one', () => {
// const { result } = renderHook(() => useAtom(tiledLayersAtom)); // const { result } = renderHook(() => useAtom(tiledLayersAtom));
render( render(
<LayerStack <LayerStack
numberOfTiledLayers={5} numberOfTiledLayers={5}
keyObject={{ provider: 'fake', zoomLevel: 9, x: 777, y: 333 }} keyObject={{ provider: 'fake', zoomLevel: 9, x: 777, y: 333 }}
coordinateSystem= {coordinateSystem}
/> />
); );
const svg = screen.getByTestId('layer-stack'); const svg = screen.getByTestId('layer-stack');
@ -157,6 +162,7 @@ describe('The LayerStack component', () => {
<LayerStack <LayerStack
numberOfTiledLayers={3} numberOfTiledLayers={3}
keyObject={{ provider: 'fake', zoomLevel: 9, x: 777, y: 333 }} keyObject={{ provider: 'fake', zoomLevel: 9, x: 777, y: 333 }}
coordinateSystem= {coordinateSystem}
/> />
); );
const svg = screen.getByTestId('layer-stack'); const svg = screen.getByTestId('layer-stack');
@ -292,12 +298,13 @@ describe('The LayerStack component', () => {
`); `);
}); });
test('populates a new layer when zoomed in', () => { /* test('populates a new layer when zoomed in', () => {
// const { result } = renderHook(() => useAtom(tiledLayersAtom)); // const { result } = renderHook(() => useAtom(tiledLayersAtom));
render( render(
<LayerStack <LayerStack
numberOfTiledLayers={3} numberOfTiledLayers={3}
keyObject={{ provider: 'fake', zoomLevel: 9, x: 777, y: 333 }} keyObject={{ provider: 'fake', zoomLevel: 9, x: 777, y: 333 }}
coordinateSystem= {coordinateSystem}
/> />
); );
const { result } = renderHook(() => [ const { result } = renderHook(() => [
@ -449,5 +456,5 @@ describe('The LayerStack component', () => {
</g> </g>
</svg> </svg>
`); `);
}); }); */
}); });

View File

@ -1,9 +1,8 @@
import react from 'react'; import react from 'react';
import { useAtom } from 'jotai';
import { TileKeyObject } from './types'; import { TileKeyObject } from './types';
import { coordinateSystemAtom } from './Map'; import { CoordinateSystem } from './Map';
import { range } from 'lodash'; import { range } from 'lodash';
import tileUri from './uris'; import tileUri from './uris';
import TiledLayer from './TiledLayer'; import TiledLayer from './TiledLayer';
@ -17,6 +16,10 @@ export interface LayerStackProperties {
* Number of {@link components/map/TiledLayer!TiledLayer}. * Number of {@link components/map/TiledLayer!TiledLayer}.
*/ */
numberOfTiledLayers?: number; numberOfTiledLayers?: number;
/**
* The current coordinates system
*/
coordinateSystem: CoordinateSystem;
} }
/** /**
@ -32,8 +35,6 @@ export interface LayerStackProperties {
export const LayerStack: react.FC<LayerStackProperties> = ( export const LayerStack: react.FC<LayerStackProperties> = (
props: LayerStackProperties props: LayerStackProperties
) => { ) => {
const [coordinateSystem] = useAtom(coordinateSystemAtom);
const numberOfTiledLayers = const numberOfTiledLayers =
props.numberOfTiledLayers === undefined ? 1 : props.numberOfTiledLayers; props.numberOfTiledLayers === undefined ? 1 : props.numberOfTiledLayers;
@ -67,6 +68,7 @@ export const LayerStack: react.FC<LayerStackProperties> = (
keyObject={keyObject} keyObject={keyObject}
shift={shift} shift={shift}
zoom={zoom * 256} zoom={zoom * 256}
coordinateSystem={props.coordinateSystem}
/> />
); );
}; };
@ -80,7 +82,7 @@ export const LayerStack: react.FC<LayerStackProperties> = (
data-testid='layer-stack' data-testid='layer-stack'
> >
<g <g
transform={`translate(${coordinateSystem.shift.x}, ${coordinateSystem.shift.y}) scale(${coordinateSystem.zoom})`} transform={`translate(${props.coordinateSystem.shift.x}, ${props.coordinateSystem.shift.y}) scale(${props.coordinateSystem.zoom})`}
> >
{ {
// Tiled layers with less detail // Tiled layers with less detail

View File

@ -0,0 +1,9 @@
import react from 'react';
export interface LiveMapProperties {}
export const LiveMap: react.FC<LiveMapProperties> = (props: LiveMapProperties) => {
return <></>;
};
export default LiveMap;

View File

@ -1,11 +1,15 @@
import react from 'react'; import react from 'react';
import { atom, useAtom } from 'jotai'; import { atom } from 'jotai';
import Handlers from './Handlers'; import { Point, MapScope } from './types';
import { Point } from './types';
import LayerStack from './LayerStack'; import LayerStack from './LayerStack';
import { tileProviders } from './tile-providers';
import { lon2tile, lat2tile } from '../../lib/geo';
export interface MapProperties {} export interface MapProperties {
scope: MapScope;
numberOfTiledLayers: number;
}
/** /**
* Definition of a coordinate system * Definition of a coordinate system
@ -73,20 +77,50 @@ export const relativeCoordinateSystemAtom = atom(
/** /**
* *
* @returns A Map component * @returns A `<Map>` component
* *
* TODO: Is this component really useful ?
* TODO: does the coordinate system belong to this component or to `<LayerStack>` ?
*/ */
export const Map: react.FC<MapProperties> = (props: MapProperties) => { export const Map: react.FC<MapProperties> = (props: MapProperties) => {
const [coordinateSystem, setCoordinateSystem] = useAtom(coordinateSystemAtom); const centerPX = {
x: window.innerWidth / 2,
y: window.innerHeight / 2,
};
const tileProvider = tileProviders[props.scope.tileProvider];
const tilesZoom = Math.min(
Math.max(Math.round(props.scope.zoom), tileProvider.minZoom),
tileProvider.maxZoom
);
const tilesCenter: Point = {
x: lon2tile(props.scope.center.lon, tilesZoom),
y: lat2tile(props.scope.center.lat, tilesZoom),
};
const softZoom = props.scope.zoom - tilesZoom;
const relativeScale = 2 ** softZoom;
const visibleTileSize = tileProvider.tileSize * relativeScale;
const nbTilesLeft = window.innerWidth / 2 / visibleTileSize;
const nbTilesTop = window.innerHeight / 2 / visibleTileSize;
const firstTileLeft = Math.floor(tilesCenter.x - nbTilesLeft);
const firstTileTop = Math.floor(tilesCenter.y - nbTilesTop);
return ( return (
<> <>
<Handlers />
<LayerStack <LayerStack
numberOfTiledLayers={3} numberOfTiledLayers={props.numberOfTiledLayers}
keyObject={{ provider: 'osm', zoomLevel: 16, x: 33485, y: 23936 }} keyObject={{
provider: props.scope.tileProvider,
zoomLevel: tilesZoom,
x: firstTileLeft,
y: firstTileTop,
}}
coordinateSystem={{
shift: {
x: -((tilesCenter.x - nbTilesLeft) % 1) * visibleTileSize,
y: -((tilesCenter.y - nbTilesTop) % 1) * visibleTileSize,
},
zoom: relativeScale,
}}
/> />
</> </>
); );

View File

@ -1,10 +1,13 @@
import { renderHook, act, render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import { useAtom } from 'jotai'; import { CoordinateSystem } from './Map';
import LayerStack from './LayerStack';
import { coordinateSystemAtom, relativeCoordinateSystemAtom } from './Map';
import TiledLayer from './TiledLayer'; import TiledLayer from './TiledLayer';
describe('The TiledLayer component', () => { describe('The TiledLayer component', () => {
const coordinateSystem: CoordinateSystem = {
shift: { x: 0, y: 0 },
zoom: 1,
};
beforeEach(() => { beforeEach(() => {
globalThis.cacheForTileSet = new Map(); globalThis.cacheForTileSet = new Map();
}); });
@ -17,6 +20,7 @@ describe('The TiledLayer component', () => {
keyObject={{ provider: 'fake', zoomLevel: 5, x: 2, y: 3 }} keyObject={{ provider: 'fake', zoomLevel: 5, x: 2, y: 3 }}
shift={{ x: 0.5, y: 0.25 }} shift={{ x: 0.5, y: 0.25 }}
zoom={4} zoom={4}
coordinateSystem={coordinateSystem}
/> />
</svg> </svg>
); );
@ -39,6 +43,7 @@ describe('The TiledLayer component', () => {
keyObject={{ provider: 'fake', zoomLevel: 5, x: 2, y: 3 }} keyObject={{ provider: 'fake', zoomLevel: 5, x: 2, y: 3 }}
shift={{ x: 0, y: 0 }} shift={{ x: 0, y: 0 }}
zoom={1} zoom={1}
coordinateSystem={coordinateSystem}
/> />
</svg> </svg>
); );

View File

@ -2,7 +2,7 @@ import { useAtom } from 'jotai';
import react from 'react'; import react from 'react';
import TileSet from './TileSet'; import TileSet from './TileSet';
import { Point, TileKeyObject } from './types'; import { Point, TileKeyObject } from './types';
import { coordinateSystemAtom } from './Map'; import { CoordinateSystem, coordinateSystemAtom } from './Map';
export interface TiledLayerProperties { export interface TiledLayerProperties {
/** /**
@ -17,6 +17,10 @@ export interface TiledLayerProperties {
* The zoom to apply. If equal to 256 (the tile size), the layer is considered active and should add tiles which are in its viewport * The zoom to apply. If equal to 256 (the tile size), the layer is considered active and should add tiles which are in its viewport
*/ */
zoom: number; zoom: number;
/**
* The coordinate system
*/
coordinateSystem: CoordinateSystem;
} }
/** /**
@ -29,7 +33,6 @@ export interface TiledLayerProperties {
export const TiledLayer: react.FC<TiledLayerProperties> = ( export const TiledLayer: react.FC<TiledLayerProperties> = (
props: TiledLayerProperties props: TiledLayerProperties
) => { ) => {
const [coordinateSystem] = useAtom(coordinateSystemAtom);
const viewPort = const viewPort =
props.zoom === 256 props.zoom === 256
? { ? {
@ -37,28 +40,32 @@ export const TiledLayer: react.FC<TiledLayerProperties> = (
x: x:
props.keyObject.x + props.keyObject.x +
Math.floor( Math.floor(
-coordinateSystem.shift.x / coordinateSystem.zoom / 256 -props.coordinateSystem.shift.x /
props.coordinateSystem.zoom /
256
), ),
y: y:
props.keyObject.y + props.keyObject.y +
Math.floor( Math.floor(
-coordinateSystem.shift.y / coordinateSystem.zoom / 256 -props.coordinateSystem.shift.y /
props.coordinateSystem.zoom /
256
), ),
}, },
bottomRight: { bottomRight: {
x: x:
props.keyObject.x + props.keyObject.x +
Math.ceil( Math.ceil(
(-coordinateSystem.shift.x + window.innerWidth) / (-props.coordinateSystem.shift.x + window.innerWidth) /
coordinateSystem.zoom / props.coordinateSystem.zoom /
256 256
) - ) -
1, 1,
y: y:
props.keyObject.y + props.keyObject.y +
Math.ceil( Math.ceil(
(-coordinateSystem.shift.y + window.innerHeight) / (-props.coordinateSystem.shift.y + window.innerHeight) /
coordinateSystem.zoom / props.coordinateSystem.zoom /
256 256
) - ) -
1, 1,

View File

@ -5,6 +5,7 @@ export interface TileProvider {
name: string; name: string;
minZoom: number; minZoom: number;
maxZoom: number; maxZoom: number;
tileSize: number;
getTileUrl: { (zoom: number, x: number, y: number): string }; getTileUrl: { (zoom: number, x: number, y: number): string };
} }
@ -15,17 +16,23 @@ const getRandomItem = (items: any[]) => {
const abc = ['a', 'b', 'c']; const abc = ['a', 'b', 'c'];
const tileProviders: any = { export type TileProviders = {
[key: string]: TileProvider;
};
export const tileProviders: TileProviders = {
osm: { osm: {
name: 'Open Street Map', name: 'Open Street Map',
minZoom: 0, minZoom: 0,
maxZoom: 19, maxZoom: 19,
tileSize: 256,
getTileUrl: (zoom: number, x: number, y: number) => getTileUrl: (zoom: number, x: number, y: number) =>
'https://tile.openstreetmap.org/' + zoom + '/' + x + '/' + y + '.png', 'https://tile.openstreetmap.org/' + zoom + '/' + x + '/' + y + '.png',
}, },
osmfr: { osmfr: {
name: 'Open Street Map France', name: 'Open Street Map France',
minZoom: 0, minZoom: 0,
tileSize: 256,
maxZoom: 20, maxZoom: 20,
getTileUrl: (zoom: number, x: number, y: number) => getTileUrl: (zoom: number, x: number, y: number) =>
'https://' + 'https://' +
@ -42,6 +49,7 @@ const tileProviders: any = {
name: 'Open Topo Map', name: 'Open Topo Map',
minZoom: 2, minZoom: 2,
maxZoom: 17, maxZoom: 17,
tileSize: 256,
getTileUrl: (zoom: number, x: number, y: number) => getTileUrl: (zoom: number, x: number, y: number) =>
'https://' + 'https://' +
getRandomItem(abc) + getRandomItem(abc) +
@ -57,6 +65,7 @@ const tileProviders: any = {
name: 'CyclOSM', name: 'CyclOSM',
minZoom: 0, minZoom: 0,
maxZoom: 19, maxZoom: 19,
tileSize: 256,
getTileUrl: (zoom: number, x: number, y: number) => getTileUrl: (zoom: number, x: number, y: number) =>
'https://' + 'https://' +
getRandomItem(abc) + getRandomItem(abc) +
@ -73,6 +82,7 @@ const tileProviders: any = {
name: 'Open River Boat Map', name: 'Open River Boat Map',
minZoom: 0, minZoom: 0,
maxZoom: 20, maxZoom: 20,
tileSize: 256,
getTileUrl: (zoom: number, x: number, y: number) => getTileUrl: (zoom: number, x: number, y: number) =>
'https://' + 'https://' +
getRandomItem(abc) + getRandomItem(abc) +
@ -118,6 +128,7 @@ const tileProviders: any = {
name: 'Fake provider', name: 'Fake provider',
minZoom: 0, minZoom: 0,
maxZoom: 20, maxZoom: 20,
tileSize: 256,
getTileUrl: (zoom: number, x: number, y: number) => getTileUrl: (zoom: number, x: number, y: number) =>
'https://fakeurl/' + zoom + '/' + x + '/' + y + '.png', 'https://fakeurl/' + zoom + '/' + x + '/' + y + '.png',
}, },

View File

@ -1,9 +1,11 @@
import tileProviders, { TileProviders } from './tile-providers';
/** /**
* An identifier for tiles (can also be used for tile layers) * An identifier for tiles (can also be used for tile layers)
*/ */
export interface TileKeyObject { export interface TileKeyObject {
/**A tile provider id ('osm', 'otm', ...) */ /**A tile provider id ('osm', 'otm', ...) */
provider: string; provider: keyof TileProviders;
/**The zoom level (integer) */ /**The zoom level (integer) */
zoomLevel: number; zoomLevel: number;
/**The X coordinate (integer)*/ /**The X coordinate (integer)*/
@ -12,6 +14,26 @@ export interface TileKeyObject {
y: number; y: number;
} }
/**
* A point identified by its longitude and latitude
*/
export interface geoPoint {
lon: number;
lat: number;
}
/**
* A map scope.
*
* This object contains what's needed to identify the state of the map
*
*/
export interface MapScope {
center: geoPoint;
zoom: number;
tileProvider: keyof TileProviders;
}
/** /**
* A point * A point
*/ */

27
src/lib/geo.ts Normal file
View File

@ -0,0 +1,27 @@
// cf https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames#ECMAScript_(JavaScript/ActionScript,_etc.)
export const lon2tile = (lon: number, zoom: number) => {
return ((Number(lon) + 180) / 360) * Math.pow(2, zoom);
};
export const lat2tile = (lat: number, zoom: number) => {
return (
((1 -
Math.log(
Math.tan((Number(lat) * Math.PI) / 180) +
1 / Math.cos((Number(lat) * Math.PI) / 180)
) /
Math.PI) /
2) *
Math.pow(2, zoom)
);
};
export function tile2long(x: number, z: number) {
return (x / Math.pow(2, z)) * 360 - 180;
}
export function tile2lat(y: number, z: number) {
var n = Math.PI - (2 * Math.PI * y) / Math.pow(2, z);
return (180 / Math.PI) * Math.atan(0.5 * (Math.exp(n) - Math.exp(-n)));
}