diff --git a/src/components/map/LayerStack.test.tsx b/src/components/map/LayerStack.test.tsx index 2885d90..8d2e1c7 100644 --- a/src/components/map/LayerStack.test.tsx +++ b/src/components/map/LayerStack.test.tsx @@ -9,7 +9,7 @@ describe('The LayerStack component', () => { render( ); const svg = screen.getByTestId('layer-stack'); @@ -273,7 +273,7 @@ describe('The LayerStack component', () => { render( ); const svg = screen.getByTestId('layer-stack'); @@ -529,7 +529,7 @@ describe('The LayerStack component', () => { render( ); const { result } = renderHook(() => [ diff --git a/src/components/map/LayerStack.tsx b/src/components/map/LayerStack.tsx index be8cbbb..952903d 100644 --- a/src/components/map/LayerStack.tsx +++ b/src/components/map/LayerStack.tsx @@ -1,11 +1,10 @@ -import react, { useCallback, useEffect, useRef, useState } from 'react'; -import { atom, useAtom } from 'jotai'; +import react from 'react'; +import { useAtom } from 'jotai'; -import { TileFactory, TileKeyObject } from './types'; +import { TileKeyObject } from './types'; import { coordinateSystemAtom } from './Map'; -import TiledLayer from './TiledLayer'; -import Tile from './Tile'; +import TileSet from './TileSet'; import _, { floor, range } from 'lodash'; import tileUri from './uris'; @@ -49,24 +48,34 @@ export const LayerStack: react.FC = ( const viewPort = { topLeft: { - x: Math.floor(-coordinateSystem.shift.x / coordinateSystem.zoom / 256), - y: Math.floor(-coordinateSystem.shift.y / coordinateSystem.zoom / 256), + x: + props.keyObject.x + + Math.floor(-coordinateSystem.shift.x / coordinateSystem.zoom / 256), + y: + props.keyObject.y + + Math.floor(-coordinateSystem.shift.y / coordinateSystem.zoom / 256), }, bottomRight: { - x: Math.ceil( - (-coordinateSystem.shift.x + window.innerWidth) / - coordinateSystem.zoom / - 256 - ), - y: Math.ceil( - (-coordinateSystem.shift.y + window.innerHeight) / - coordinateSystem.zoom / - 256 - ), + x: + props.keyObject.x + + Math.ceil( + (-coordinateSystem.shift.x + window.innerWidth) / + coordinateSystem.zoom / + 256 + ) - + 1, + y: + props.keyObject.y + + Math.ceil( + (-coordinateSystem.shift.y + window.innerHeight) / + coordinateSystem.zoom / + 256 + ) - + 1, }, }; - const getTiledLayer = (i: number) => { + const getTileSet = (i: number) => { const relativeZoomLevel = i - Math.floor(numberOfTiledLayers / 2); const zoom = 2 ** relativeZoomLevel; const origin = { @@ -90,11 +99,13 @@ export const LayerStack: react.FC = ( return ( - = ( > { // Tiled layers with less detail - range(0, activeTiledLayer).map(getTiledLayer) + range(0, activeTiledLayer).map(getTileSet) } { // Tiled layers with more details - range(numberOfTiledLayers - 1, activeTiledLayer, -1).map( - getTiledLayer - ) + range(numberOfTiledLayers - 1, activeTiledLayer, -1).map(getTileSet) } { // And the active one - getTiledLayer(activeTiledLayer) + getTileSet(activeTiledLayer) } diff --git a/src/components/map/Tile.test.tsx b/src/components/map/Tile.test.tsx index d19b68d..49db1ec 100644 --- a/src/components/map/Tile.test.tsx +++ b/src/components/map/Tile.test.tsx @@ -1,11 +1,18 @@ import { render, screen } from '@testing-library/react'; import Tile from './Tile'; +import { TileKeyObject } from './types'; describe('The Tile component ', () => { + const keyObject: TileKeyObject = { + provider: 'fake', + zoomLevel: 10, + x: 1, + y: 2, + }; test('Is initially empty', () => { const { baseElement } = render( - + ); // screen.debug(); @@ -13,7 +20,9 @@ describe('The Tile component ', () => {
- +
@@ -22,7 +31,7 @@ describe('The Tile component ', () => { test('Gets its image immediately with a fake URL', () => { const { baseElement } = render( - + ); // screen.debug(); @@ -30,10 +39,12 @@ describe('The Tile component ', () => {
- + diff --git a/src/components/map/Tile.tsx b/src/components/map/Tile.tsx index fc9e691..396c5b4 100644 --- a/src/components/map/Tile.tsx +++ b/src/components/map/Tile.tsx @@ -1,9 +1,11 @@ import react, { memo, useEffect, useRef } from 'react'; import { isEqual } from 'lodash'; +import { TileKeyObject } from './types'; +import { getTileUrl } from './tile-providers'; export interface TileProperties { /** The image's source URL */ - href: string; + keyObject: TileKeyObject; /** A delay to add (for test/debug purposes) */ delay?: number; } @@ -27,14 +29,15 @@ export const Tile: react.FC = memo((props: TileProperties) => { const timeout = (ms: number) => { return new Promise((resolve) => setTimeout(resolve, ms)); }; - // console.log(`Rendering tile: ${props.href}`); + // console.log(`Rendering tile: ${JSON.stringify(props)}`); useEffect(() => { const loadImage = async () => { // console.log(`Pre loading: ${props.href}`); + const href = getTileUrl(props.keyObject); const image = new Image(1, 1); image.loading = 'eager'; // @ts-ignore - image.setAttribute('href', props.href); + image.setAttribute('href', href); if (!image.complete) { await image.decode(); } @@ -48,13 +51,18 @@ export const Tile: react.FC = memo((props: TileProperties) => { svgImage.setAttribute('width', '1'); svgImage.setAttribute('height', '1'); // @ts-ignore - svgImage.setAttribute('href', props.href); + svgImage.setAttribute('href', href); g.current?.replaceChildren(svgImage); }; loadImage(); - }, [props.href]); + }, [props.keyObject]); - return ; + return ( + + ); }, isEqual); export default Tile; diff --git a/src/components/map/TileSet.test.tsx b/src/components/map/TileSet.test.tsx new file mode 100644 index 0000000..fba1240 --- /dev/null +++ b/src/components/map/TileSet.test.tsx @@ -0,0 +1,309 @@ +import { render } from '@testing-library/react'; +import TileSet from './TileSet'; + +describe('The TiledLayer component ', () => { + beforeEach(() => { + globalThis.cacheForTileSet = new Map(); + }); + + test('exposes the tiles needed per its viewport', () => { + const { baseElement } = render( + + + + ); + // screen.debug(); + expect(baseElement).toMatchInlineSnapshot(` + +
+ + + + + + + + + + + +
+ +`); + }); + + test('adds more tiles when its viewport is updated without removing the previous ones', () => { + const { baseElement, rerender } = render( + + + + ); + rerender( + + + + ); + // screen.debug(); + expect(baseElement).toMatchInlineSnapshot(` + +
+ + + + + + + + + + + + + + + + + + + + +
+ +`); + }); + test('is not reinitialized if its key isObject updated', () => { + const { baseElement, rerender } = render( + + + + ); + rerender( + + + + ); + // screen.debug(); + expect(baseElement).toMatchInlineSnapshot(` + +
+ + + + + + + + + + + + + + + + + + + + +
+ +`); + }); + test('Also works with negative coordinates', () => { + const { baseElement } = render( + + + + ); + // screen.debug(); + expect(baseElement).toMatchInlineSnapshot(` + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +`); + }); +}); diff --git a/src/components/map/TiledLayer.tsx b/src/components/map/TileSet.tsx similarity index 70% rename from src/components/map/TiledLayer.tsx rename to src/components/map/TileSet.tsx index 907ddd0..e51eb0b 100644 --- a/src/components/map/TiledLayer.tsx +++ b/src/components/map/TileSet.tsx @@ -1,9 +1,9 @@ -import react, { memo, useRef } from 'react'; -import { range, isEqual } from 'lodash'; +import react, { } from 'react'; +import { range } from 'lodash'; -import { Rectangle, TileFactory, TileKeyObject } from './types'; +import { Rectangle, TileKeyObject } from './types'; import tileUri from './uris'; -import tileFactory from './tile-factory'; +import Tile from './Tile'; /** * @hidden @@ -15,22 +15,22 @@ export const thisIsAModule = true; */ declare global { - var cacheForTiledLayer: any; + var cacheForTileSet: any; } //export {}; -globalThis.cacheForTiledLayer = new Map(); +globalThis.cacheForTileSet = new Map(); -export interface TiledLayerProperties { - /** The key of the first (ie top/left) tile */ +export interface TileSetProperties { + /** A partial Tile key object specifying the provider and zoom level */ keyObject: TileKeyObject; /** The current viewport expressed in tiles coordinates */ viewPort?: Rectangle; } /** - * A lazily loaded layer of tiles. + * A lazily loaded set of tiles. * * This component is rather dumb and is mainly a sparse array of tiles. * @@ -49,13 +49,10 @@ export interface TiledLayerProperties { * * TODO: cache housekeeping * - * TODO: test tiles'X and Y boundaries. - * - * TODO: remove tileFactory to facilitate memoisation. * */ -export const TiledLayer: react.FC = ( - props: TiledLayerProperties +export const TileSet: react.FC = ( + props: TileSetProperties ) => { // console.log(`Rendering TiledLayer: ${JSON.stringify(props)}`); @@ -64,7 +61,7 @@ export const TiledLayer: react.FC = ( zoomLevel: props.keyObject.zoomLevel, }); - const tiles: any = globalThis.cacheForTiledLayer.get(key) ?? new Map(); + const tiles: any = globalThis.cacheForTileSet.get(key) ?? new Map(); if (props.viewPort !== undefined) { range(props.viewPort.topLeft.y, props.viewPort.bottomRight.y + 1).forEach( (row) => { @@ -82,18 +79,24 @@ export const TiledLayer: react.FC = ( if (!tiles.has(key)) { tiles.set( key, - - {tileFactory(keyObject)} - + ); } }); } ); - globalThis.cacheForTiledLayer.set(key, tiles); + globalThis.cacheForTileSet.set(key, tiles); } return <>{Array.from(tiles.values())}; }; -export default TiledLayer; +export default TileSet; diff --git a/src/components/map/TiledLayer.test.tsx b/src/components/map/TiledLayer.test.tsx deleted file mode 100644 index 4c44f74..0000000 --- a/src/components/map/TiledLayer.test.tsx +++ /dev/null @@ -1,356 +0,0 @@ -import { render } from '@testing-library/react'; -import TiledLayer from './TiledLayer'; -import { TileFactory } from './types'; - -describe('The TiledLayer component ', () => { - beforeEach(() => { - globalThis.cacheForTiledLayer = new Map(); - }); - - test('exposes the tiles needed per its viewport', () => { - const { baseElement } = render( - - - - ); - // screen.debug(); - expect(baseElement).toMatchInlineSnapshot(` - -
- - - - - - - - - - - - - - - - - -
- -`); - }); - - test('adds more tiles when its viewport is updated without removing the previous ones', () => { - const { baseElement, rerender } = render( - - - - ); - rerender( - - - - ); - // screen.debug(); - expect(baseElement).toMatchInlineSnapshot(` - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- -`); - }); - test('is not reinitialized if its key isObject updated', () => { - const { baseElement, rerender } = render( - - - - ); - rerender( - - - - ); - // screen.debug(); - expect(baseElement).toMatchInlineSnapshot(` - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- -`); - }); - test('Also works with negative coordinates', () => { - const { baseElement } = render( - - - - ); - // screen.debug(); - expect(baseElement).toMatchInlineSnapshot(` - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- -`); - }); -}); diff --git a/src/components/map/tile-factory.tsx b/src/components/map/tile-factory.tsx deleted file mode 100644 index 400c299..0000000 --- a/src/components/map/tile-factory.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import Tile from './Tile'; -import { TileFactory, TileKeyObject } from './types'; - -/** - * - * @param keyObject The tile identifier - * @returns The `` component - */ -export const tileFactory: TileFactory = (keyObject:TileKeyObject) => ( - -); - -export default tileFactory; diff --git a/src/components/map/tile-providers.tsx b/src/components/map/tile-providers.tsx new file mode 100644 index 0000000..93add60 --- /dev/null +++ b/src/components/map/tile-providers.tsx @@ -0,0 +1,146 @@ +import Tile from './Tile'; +import { TileFactory, TileKeyObject } from './types'; + +export interface TileProvider { + name: string; + minZoom: number; + maxZoom: number; + getTileUrl: { (zoom: number, x: number, y: number): string }; +} + +const getRandomItem = (items: any[]) => { + const idx = Math.floor(Math.random() * items.length); + return items[idx]; +}; + +const abc = ['a', 'b', 'c']; + +const tileProviders: any = { + osm: { + name: 'Open Street Map', + minZoom: 0, + maxZoom: 19, + getTileUrl: (zoom: number, x: number, y: number) => + 'https://tile.openstreetmap.org/' + zoom + '/' + x + '/' + y + '.png', + }, + osmfr: { + name: 'Open Street Map France', + minZoom: 0, + maxZoom: 20, + getTileUrl: (zoom: number, x: number, y: number) => + 'https://' + + getRandomItem(abc) + + '.tile.openstreetmap.fr/osmfr/' + + zoom + + '/' + + x + + '/' + + y + + '.png', + }, + otm: { + name: 'Open Topo Map', + minZoom: 2, + maxZoom: 17, + getTileUrl: (zoom: number, x: number, y: number) => + 'https://' + + getRandomItem(abc) + + '.tile.opentopomap.org/' + + zoom + + '/' + + x + + '/' + + y + + '.png', + }, + cyclosm: { + name: 'CyclOSM', + minZoom: 0, + maxZoom: 19, + getTileUrl: (zoom: number, x: number, y: number) => + 'https://' + + getRandomItem(abc) + + '.tile-cyclosm.openstreetmap.fr/cyclosm/' + + zoom + + '/' + + x + + '/' + + y + + '.png', + }, + //https://b.tile.openstreetmap.fr/openriverboatmap/20/535762/382966.png + openriverboatmap: { + name: 'Open River Boat Map', + minZoom: 0, + maxZoom: 20, + getTileUrl: (zoom: number, x: number, y: number) => + 'https://' + + getRandomItem(abc) + + '.tile.openstreetmap.fr/openriverboatmap/' + + zoom + + '/' + + x + + '/' + + y + + '.png', + }, + + // cyclosmlite: { + // name: 'CyclOSM lite', + // minZoom: 0, + // maxZoom: 19, + // getTileUrl: (zoom: number, x: number, y: number) => + // 'https://' + + // getRandomItem(abc) + + // '.tile-cyclosm.openstreetmap.fr/cyclosm-lite/' + + // zoom + + // '/' + + // x + + // '/' + + // y + + // '.png', + // }, + // esrisat: { + // name: 'ESRI Satellite', + // minZoom: 0, + // maxZoom: 19, + // getTileUrl: (zoom: number, x: number, y: number) => + // 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/' + + // zoom + + // '/' + + // x + + // '/' + + // y + + // '.jpg', + // }, + + fake: { + name: 'Fake provider', + minZoom: 0, + maxZoom: 20, + getTileUrl: (zoom: number, x: number, y: number) => + 'https://fakeurl/' + zoom + '/' + x + '/' + y + '.png', + }, +}; + +const mod = (n: number, m: number) => { + const jsMod = n % m; + return jsMod >= 0 ? jsMod : jsMod + m; +}; + +/** + * + * @param keyObject The tile identifier + * @returns The ``'s URL 'as a string) + */ +export const getTileUrl = (keyObject: TileKeyObject) => { + const nbTiles = 2 ** keyObject.zoomLevel; + const x = mod(keyObject.x, nbTiles); + const y = mod(keyObject.y, nbTiles); + return tileProviders[keyObject.provider].getTileUrl( + keyObject.zoomLevel, + x, + y + ); +}; +export default getTileUrl;