Starting to implement a LayerStack component

This commit is contained in:
Eric van der Vlist 2022-10-19 18:35:20 +02:00
parent 4238ce0938
commit 3dee9e90d7
6 changed files with 455 additions and 64 deletions

View File

@ -0,0 +1,264 @@
import { renderHook, act, render, screen } from '@testing-library/react';
import { useAtom } from 'jotai';
import LayerStack from './LayerStack';
describe('The LayerStack component', () => {
test('generates something', () => {
// const { result } = renderHook(() => useAtom(tiledLayersAtom));
render(
<LayerStack
numberOfTiledLayers={5}
keyObject={{ provider: 'xxx', zoomLevel: 9, x: 777, y: 333 }}
/>
);
const svg = screen.getByTestId('layer-stack');
expect(svg).toMatchInlineSnapshot(`
<svg
data-testid="layer-stack"
height="768"
width="1024"
>
<g
transform="translate(0, 0) scale(1)"
>
<g
transform="scale(64) translate(0.25, 0.25)"
/>
<g
transform="scale(128) translate(0.5, 0.5)"
/>
<g
transform="scale(2048) translate(0, 0)"
/>
<g
transform="scale(1024) translate(0, 0)"
/>
<g
transform="scale(256) translate(0, 0)"
>
<g
transform="translate(0, 0)"
>
<g>
<image
height="1"
href="https://tile.openstreetmap.org/9/777/333.png"
width="1"
/>
</g>
</g>
<g
transform="translate(1, 0)"
>
<g>
<image
height="1"
href="https://tile.openstreetmap.org/9/778/333.png"
width="1"
/>
</g>
</g>
<g
transform="translate(2, 0)"
>
<g>
<image
height="1"
href="https://tile.openstreetmap.org/9/779/333.png"
width="1"
/>
</g>
</g>
<g
transform="translate(3, 0)"
>
<g>
<image
height="1"
href="https://tile.openstreetmap.org/9/780/333.png"
width="1"
/>
</g>
</g>
<g
transform="translate(4, 0)"
>
<g>
<image
height="1"
href="https://tile.openstreetmap.org/9/781/333.png"
width="1"
/>
</g>
</g>
<g
transform="translate(0, 1)"
>
<g>
<image
height="1"
href="https://tile.openstreetmap.org/9/777/334.png"
width="1"
/>
</g>
</g>
<g
transform="translate(1, 1)"
>
<g>
<image
height="1"
href="https://tile.openstreetmap.org/9/778/334.png"
width="1"
/>
</g>
</g>
<g
transform="translate(2, 1)"
>
<g>
<image
height="1"
href="https://tile.openstreetmap.org/9/779/334.png"
width="1"
/>
</g>
</g>
<g
transform="translate(3, 1)"
>
<g>
<image
height="1"
href="https://tile.openstreetmap.org/9/780/334.png"
width="1"
/>
</g>
</g>
<g
transform="translate(4, 1)"
>
<g>
<image
height="1"
href="https://tile.openstreetmap.org/9/781/334.png"
width="1"
/>
</g>
</g>
<g
transform="translate(0, 2)"
>
<g>
<image
height="1"
href="https://tile.openstreetmap.org/9/777/335.png"
width="1"
/>
</g>
</g>
<g
transform="translate(1, 2)"
>
<g>
<image
height="1"
href="https://tile.openstreetmap.org/9/778/335.png"
width="1"
/>
</g>
</g>
<g
transform="translate(2, 2)"
>
<g>
<image
height="1"
href="https://tile.openstreetmap.org/9/779/335.png"
width="1"
/>
</g>
</g>
<g
transform="translate(3, 2)"
>
<g>
<image
height="1"
href="https://tile.openstreetmap.org/9/780/335.png"
width="1"
/>
</g>
</g>
<g
transform="translate(4, 2)"
>
<g>
<image
height="1"
href="https://tile.openstreetmap.org/9/781/335.png"
width="1"
/>
</g>
</g>
<g
transform="translate(0, 3)"
>
<g>
<image
height="1"
href="https://tile.openstreetmap.org/9/777/336.png"
width="1"
/>
</g>
</g>
<g
transform="translate(1, 3)"
>
<g>
<image
height="1"
href="https://tile.openstreetmap.org/9/778/336.png"
width="1"
/>
</g>
</g>
<g
transform="translate(2, 3)"
>
<g>
<image
height="1"
href="https://tile.openstreetmap.org/9/779/336.png"
width="1"
/>
</g>
</g>
<g
transform="translate(3, 3)"
>
<g>
<image
height="1"
href="https://tile.openstreetmap.org/9/780/336.png"
width="1"
/>
</g>
</g>
<g
transform="translate(4, 3)"
>
<g>
<image
height="1"
href="https://tile.openstreetmap.org/9/781/336.png"
width="1"
/>
</g>
</g>
</g>
</g>
</svg>
`);
});
});

View File

@ -0,0 +1,139 @@
import react, { useCallback, useEffect, useRef, useState } from 'react';
import { atom, useAtom } from 'jotai';
import { TileFactory, TileKeyObject } from './types';
import { coordinateSystemAtom } from './Map';
import TiledLayer from './TiledLayer';
import Tile from './Tile';
import _, { floor, range } from 'lodash';
import tileUri from './uris';
export interface LayerStackProperties {
/**
* A key identifying the initial top left tile
*/
keyObject: TileKeyObject;
/**
* Number of {@link components/map/TiledLayer!TiledLayer}.
*/
numberOfTiledLayers?: number;
}
/**
*
* @param props
* @returns A stack of layers embedded in an SVG element
*
* This component does the conversion between the {@link components/map/Map!CoordinateSystem} stored
* in the {@link components/map/Map!coordinateSystemAtom} atom and the {@link components/map/TiledLayer!TiledLayer}
* components which units are in tiles.
*
*/
export const LayerStack: react.FC<LayerStackProperties> = (
props: LayerStackProperties
) => {
const [coordinateSystem] = useAtom(coordinateSystemAtom);
const simpleTileFactory: TileFactory = useCallback(
(keyObject) => (
<Tile
href={`https://tile.openstreetmap.org/${keyObject.zoomLevel}/${keyObject.x}/${keyObject.y}.png`}
/>
),
[]
);
const numberOfTiledLayers =
props.numberOfTiledLayers === undefined ? 1 : props.numberOfTiledLayers;
const [activeTiledLayer, setActiveTiledLayer] = useState(
Math.floor(numberOfTiledLayers / 2)
);
const viewPort = {
topLeft: {
x: Math.floor(-coordinateSystem.shift.x / coordinateSystem.zoom / 256),
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
),
},
};
const getTiledLayer = (i: number) => {
const relativeZoomLevel = i - activeTiledLayer;
const zoom = 2 ** relativeZoomLevel;
const origin = {
x: props.keyObject.x * zoom,
y: props.keyObject.y * zoom,
};
const keyObject = {
provider: props.keyObject.provider,
zoomLevel: props.keyObject.zoomLevel + relativeZoomLevel,
x: Math.floor(origin.x),
y: Math.floor(origin.y),
};
const shift = {
x: origin.x - floor(origin.x),
y: origin.y - floor(origin.y),
};
return (
<g
transform={`scale(${256 * zoom}) translate(${shift.x}, ${shift.y})`}
key={tileUri({
provider: keyObject.provider,
zoomLevel: keyObject.zoomLevel,
})}
>
<TiledLayer
keyObject={keyObject}
viewPort={i === activeTiledLayer ? viewPort : undefined}
tileFactory={simpleTileFactory}
/>
</g>
);
};
const tileLayers = range(0, numberOfTiledLayers).map((i) => {});
// console.log(`tiledLayers: ${JSON.stringify(tiledLayers)}`);
return (
<svg
width={window.innerWidth}
height={window.innerHeight}
data-testid='layer-stack'
>
<g
transform={`translate(${coordinateSystem.shift.x}, ${coordinateSystem.shift.y}) scale(${coordinateSystem.zoom})`}
>
{
// Tiled layers with less detail
range(0, activeTiledLayer).map(getTiledLayer)
}
{
// Tiled layers with more details
range(numberOfTiledLayers, activeTiledLayer + 1, -1).map(
getTiledLayer
)
}
{
// And the active one
getTiledLayer(activeTiledLayer)
}
</g>
</svg>
);
};
export default LayerStack;

View File

@ -1,10 +1,9 @@
import react, { useCallback } from 'react'; import react from 'react';
import { atom, useAtom } from 'jotai'; import { atom, useAtom } from 'jotai';
import Handlers from './Handlers'; import Handlers from './Handlers';
import Tile from './Tile'; import { Point } from './types';
import TiledLayer from './TiledLayer'; import LayerStack from './LayerStack';
import { Point, TileFactory } from './types';
export interface MapProperties {} export interface MapProperties {}
@ -75,54 +74,20 @@ 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 [coordinateSystem, setCoordinateSystem] = useAtom(coordinateSystemAtom);
const simpleTileFactory: TileFactory = useCallback(
(keyObject) => (
<Tile
href={`https://tile.openstreetmap.org/${keyObject.zoomLevel}/${keyObject.x}/${keyObject.y}.png`}
/>
),
[]
);
const viewPort = {
topLeft: {
x: Math.floor(-coordinateSystem.shift.x / coordinateSystem.zoom / 256),
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
),
},
};
return ( return (
<> <>
<Handlers /> <Handlers />
<svg width={window.innerWidth} height={window.innerHeight}> <LayerStack
<g numberOfTiledLayers={3}
transform={`translate(${coordinateSystem.shift.x}, ${coordinateSystem.shift.y}) scale(${coordinateSystem.zoom})`} keyObject={{ provider: 'osm', zoomLevel: 16, x: 33485, y: 23936 }}
> />
<g transform='scale(256)'>
<TiledLayer
keyObject={{ provider: 'osm', zoomLevel: 16, x: 33485, y: 23936 }}
viewPort={viewPort}
tileFactory={simpleTileFactory}
/>
</g>
</g>
</svg>
</> </>
); );
}; };

View File

@ -8,7 +8,7 @@ export interface TiledLayerProperties {
/** The key of the first (ie top/left) tile */ /** The key of the first (ie top/left) tile */
keyObject: TileKeyObject; keyObject: TileKeyObject;
/** The current viewport expressed in tiles coordinates */ /** The current viewport expressed in tiles coordinates */
viewPort: Rectangle; viewPort?: Rectangle;
/** The factory to create tiles */ /** The factory to create tiles */
tileFactory: TileFactory; tileFactory: TileFactory;
} }
@ -19,12 +19,14 @@ export interface TiledLayerProperties {
* This component is rather dumb and is mainly a sparse array of tiles. * This component is rather dumb and is mainly a sparse array of tiles.
* *
* New tiles are added to the array when the viewport is updated and they stay in the array until * New tiles are added to the array when the viewport is updated and they stay in the array until
* the component is destroyed or its number of tiles is updated. * the component is destroyed.
* *
* This component has no need to know the number nor the size of its tiles: tiles can be added when needed and * This component has no need to know the number nor the size of its tiles: tiles can be added when needed and
* its unit is the tile size (the parent component needs to transform its enclosing SVG group to adapt its units) * its unit is the tile size (the parent component needs to transform its enclosing SVG group to adapt its units)
* *
* TODO: test tiles'X and Y boundaries. * TODO: test tiles'X and Y boundaries.
* TODO: housekeeping
* TODO: remove tileFactory to facilitate memoisation.
* *
*/ */
export const TiledLayer: react.FC<TiledLayerProperties> = memo( export const TiledLayer: react.FC<TiledLayerProperties> = memo(
@ -32,10 +34,13 @@ export const TiledLayer: react.FC<TiledLayerProperties> = memo(
console.log(`Rendering TiledLayer: ${JSON.stringify(props)}`); console.log(`Rendering TiledLayer: ${JSON.stringify(props)}`);
const tiles = useRef<any>(new Map()); const tiles = useRef<any>(new Map());
range(props.viewPort.topLeft.y, props.viewPort.bottomRight.y + 1).map( if (props.viewPort !== undefined) {
(row) => { range(props.viewPort.topLeft.y, props.viewPort.bottomRight.y + 1).forEach(
range(props.viewPort.topLeft.x, props.viewPort.bottomRight.x + 1).map( (row) => {
(col) => { range(
props.viewPort!.topLeft.x,
props.viewPort!.bottomRight.x + 1
).forEach((col) => {
const keyObject = { const keyObject = {
provider: props.keyObject.provider, provider: props.keyObject.provider,
zoomLevel: props.keyObject.zoomLevel, zoomLevel: props.keyObject.zoomLevel,
@ -51,14 +56,18 @@ export const TiledLayer: react.FC<TiledLayerProperties> = memo(
</g> </g>
); );
} }
} });
); }
} );
); }
return <>{Array.from(tiles.current.values())}</>; return <>{Array.from(tiles.current.values())}</>;
}, },
isEqual (prevProps, nextProps) =>
isEqual(
[prevProps.keyObject, prevProps.viewPort],
[nextProps.keyObject, nextProps.viewPort]
)
); );
export default TiledLayer; export default TiledLayer;

View File

@ -13,4 +13,13 @@ describe('Test that', () => {
y: 3, y: 3,
}); });
}); });
test('x and y are optional', () => {
expect(tileUri({ provider: 'osm', zoomLevel: 16 })).toEqual('tile/osm/16//');
});
test('uri parsing works', () => {
expect(tileUri('tile/otm/5')).toEqual({
provider: 'otm',
zoomLevel: 5,
});
});
}); });

View File

@ -2,18 +2,23 @@ import { route } from 'docuri';
/** /**
* A [docuri](https://github.com/jo/docuri) route for {@link components/map/types!TileKeyObject} * A [docuri](https://github.com/jo/docuri) route for {@link components/map/types!TileKeyObject}
* *
* TODO: update docuri (or write a wrapper) to support datatyping (and formats). * TODO: update docuri (or write a wrapper) to support datatyping (and formats).
*/ */
export const tileUri = (rte: any) => { export const tileUri = (rte: any) => {
const r = route('tile/:provider/:zoomLevel/:x/:y')(rte); const r = route('tile/:provider/:zoomLevel(/:x/:y)')(rte);
if (typeof r === 'object') { if (typeof r === 'object') {
return { return r.x === undefined
provider: r.provider, ? {
zoomLevel: parseInt(r.zoomLevel), provider: r.provider,
x: parseInt(r.x), zoomLevel: parseInt(r.zoomLevel),
y: parseInt(r.y), }
}; : {
provider: r.provider,
zoomLevel: parseInt(r.zoomLevel),
x: parseInt(r.x),
y: parseInt(r.y),
};
} }
return r; return r;
}; };