Using a global variable to cache <Tile>s for <TiledLayer>s.

This commit is contained in:
Eric van der Vlist 2022-10-20 16:21:27 +02:00
parent 3dee9e90d7
commit d970b288e4
5 changed files with 728 additions and 61 deletions

View File

@ -1,9 +1,10 @@
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';
describe('The LayerStack component', () => { describe('The LayerStack component', () => {
test('generates something', () => { test('generates four empty layers and a populated one', () => {
// const { result } = renderHook(() => useAtom(tiledLayersAtom)); // const { result } = renderHook(() => useAtom(tiledLayersAtom));
render( render(
<LayerStack <LayerStack
@ -22,18 +23,23 @@ describe('The LayerStack component', () => {
transform="translate(0, 0) scale(1)" transform="translate(0, 0) scale(1)"
> >
<g <g
transform="scale(64) translate(0.25, 0.25)" data-testid="tile/xxx/7//"
transform="scale(1024) translate(0.25, 0.25)"
/> />
<g <g
transform="scale(128) translate(0.5, 0.5)" data-testid="tile/xxx/8//"
transform="scale(512) translate(0.5, 0.5)"
/> />
<g <g
transform="scale(2048) translate(0, 0)" data-testid="tile/xxx/11//"
transform="scale(64) translate(0, 0)"
/> />
<g <g
transform="scale(1024) translate(0, 0)" data-testid="tile/xxx/10//"
transform="scale(128) translate(0, 0)"
/> />
<g <g
data-testid="tile/xxx/9//"
transform="scale(256) translate(0, 0)" transform="scale(256) translate(0, 0)"
> >
<g <g
@ -259,6 +265,636 @@ describe('The LayerStack component', () => {
</g> </g>
</g> </g>
</svg> </svg>
`);
});
test('generates two empty layers and a populated one', () => {
// const { result } = renderHook(() => useAtom(tiledLayersAtom));
render(
<LayerStack
numberOfTiledLayers={3}
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
data-testid="tile/xxx/8//"
transform="scale(512) translate(0.5, 0.5)"
/>
<g
data-testid="tile/xxx/10//"
transform="scale(128) translate(0, 0)"
/>
<g
data-testid="tile/xxx/9//"
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>
`);
});
test('populates a new layer when zoomed in', () => {
// const { result } = renderHook(() => useAtom(tiledLayersAtom));
render(
<LayerStack
numberOfTiledLayers={3}
keyObject={{ provider: 'xxx', zoomLevel: 9, x: 777, y: 333 }}
/>
);
const { result } = renderHook(() => [
useAtom(coordinateSystemAtom),
useAtom(relativeCoordinateSystemAtom),
]);
act(() => {
result.current[0][1]({
shift: {
x: 0,
y: 0,
},
zoom: 1,
} as any);
result.current[1][1]({
deltaShift: null,
zoomCenter: { x: 0, y: 0 },
deltaZoom: 2,
} as any);
});
const svg = screen.getByTestId('layer-stack');
expect(svg).toMatchInlineSnapshot(`
<svg
data-testid="layer-stack"
height="768"
width="1024"
>
<g
transform="translate(0, 0) scale(2)"
>
<g
data-testid="tile/xxx/8//"
transform="scale(512) translate(0.5, 0.5)"
/>
<g
data-testid="tile/xxx/9//"
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
data-testid="tile/xxx/10//"
transform="scale(128) translate(0, 0)"
>
<g
transform="translate(0, 0)"
>
<g>
<image
height="1"
href="https://tile.openstreetmap.org/10/1554/666.png"
width="1"
/>
</g>
</g>
<g
transform="translate(1, 0)"
>
<g>
<image
height="1"
href="https://tile.openstreetmap.org/10/1555/666.png"
width="1"
/>
</g>
</g>
<g
transform="translate(2, 0)"
>
<g>
<image
height="1"
href="https://tile.openstreetmap.org/10/1556/666.png"
width="1"
/>
</g>
</g>
<g
transform="translate(0, 1)"
>
<g>
<image
height="1"
href="https://tile.openstreetmap.org/10/1554/667.png"
width="1"
/>
</g>
</g>
<g
transform="translate(1, 1)"
>
<g>
<image
height="1"
href="https://tile.openstreetmap.org/10/1555/667.png"
width="1"
/>
</g>
</g>
<g
transform="translate(2, 1)"
>
<g>
<image
height="1"
href="https://tile.openstreetmap.org/10/1556/667.png"
width="1"
/>
</g>
</g>
<g
transform="translate(0, 2)"
>
<g>
<image
height="1"
href="https://tile.openstreetmap.org/10/1554/668.png"
width="1"
/>
</g>
</g>
<g
transform="translate(1, 2)"
>
<g>
<image
height="1"
href="https://tile.openstreetmap.org/10/1555/668.png"
width="1"
/>
</g>
</g>
<g
transform="translate(2, 2)"
>
<g>
<image
height="1"
href="https://tile.openstreetmap.org/10/1556/668.png"
width="1"
/>
</g>
</g>
</g>
</g>
</svg>
`); `);
}); });
}); });

View File

@ -47,8 +47,13 @@ export const LayerStack: react.FC<LayerStackProperties> = (
const numberOfTiledLayers = const numberOfTiledLayers =
props.numberOfTiledLayers === undefined ? 1 : props.numberOfTiledLayers; props.numberOfTiledLayers === undefined ? 1 : props.numberOfTiledLayers;
const [activeTiledLayer, setActiveTiledLayer] = useState( const activeTiledLayer = Math.min(
Math.floor(numberOfTiledLayers / 2) Math.max(
Math.round(Math.log2(coordinateSystem.zoom)) +
Math.floor(numberOfTiledLayers / 2),
0
),
numberOfTiledLayers - 1
); );
const viewPort = { const viewPort = {
@ -71,7 +76,7 @@ export const LayerStack: react.FC<LayerStackProperties> = (
}; };
const getTiledLayer = (i: number) => { const getTiledLayer = (i: number) => {
const relativeZoomLevel = i - activeTiledLayer; const relativeZoomLevel = i - Math.floor(numberOfTiledLayers / 2);
const zoom = 2 ** relativeZoomLevel; const zoom = 2 ** relativeZoomLevel;
const origin = { const origin = {
x: props.keyObject.x * zoom, x: props.keyObject.x * zoom,
@ -87,15 +92,19 @@ export const LayerStack: react.FC<LayerStackProperties> = (
x: origin.x - floor(origin.x), x: origin.x - floor(origin.x),
y: origin.y - floor(origin.y), y: origin.y - floor(origin.y),
}; };
const key = tileUri({
provider: keyObject.provider,
zoomLevel: keyObject.zoomLevel,
});
return ( return (
<g <g
transform={`scale(${256 * zoom}) translate(${shift.x}, ${shift.y})`} transform={`scale(${256 / zoom}) translate(${shift.x}, ${shift.y})`}
key={tileUri({ key={key}
provider: keyObject.provider, data-testid={key}
zoomLevel: keyObject.zoomLevel,
})}
> >
<TiledLayer <TiledLayer
key={key}
keyObject={keyObject} keyObject={keyObject}
viewPort={i === activeTiledLayer ? viewPort : undefined} viewPort={i === activeTiledLayer ? viewPort : undefined}
tileFactory={simpleTileFactory} tileFactory={simpleTileFactory}
@ -104,8 +113,6 @@ export const LayerStack: react.FC<LayerStackProperties> = (
); );
}; };
const tileLayers = range(0, numberOfTiledLayers).map((i) => {});
// console.log(`tiledLayers: ${JSON.stringify(tiledLayers)}`); // console.log(`tiledLayers: ${JSON.stringify(tiledLayers)}`);
return ( return (
@ -123,7 +130,7 @@ export const LayerStack: react.FC<LayerStackProperties> = (
} }
{ {
// Tiled layers with more details // Tiled layers with more details
range(numberOfTiledLayers, activeTiledLayer + 1, -1).map( range(numberOfTiledLayers - 1, activeTiledLayer, -1).map(
getTiledLayer getTiledLayer
) )
} }

View File

@ -27,10 +27,10 @@ export const Tile: react.FC<TileProperties> = memo((props: TileProperties) => {
const timeout = (ms: number) => { const timeout = (ms: number) => {
return new Promise((resolve) => setTimeout(resolve, ms)); return new Promise((resolve) => setTimeout(resolve, ms));
}; };
console.log(`Rendering tile: ${props.href}`); // console.log(`Rendering tile: ${props.href}`);
useEffect(() => { useEffect(() => {
const loadImage = async () => { const loadImage = async () => {
console.log(`Pre loading: ${props.href}`); // console.log(`Pre loading: ${props.href}`);
const image = new Image(1, 1); const image = new Image(1, 1);
image.loading = 'eager'; image.loading = 'eager';
// @ts-ignore // @ts-ignore

View File

@ -7,6 +7,10 @@ const fakeTileFactory: TileFactory = (keyObject) => (
); );
describe('The TiledLayer component ', () => { describe('The TiledLayer component ', () => {
beforeEach(() => {
globalThis.cacheForTiledLayer = new Map();
});
test('exposes the tiles needed per its viewport', () => { test('exposes the tiles needed per its viewport', () => {
const { baseElement } = render( const { baseElement } = render(
<svg> <svg>
@ -133,7 +137,7 @@ describe('The TiledLayer component ', () => {
rerender( rerender(
<svg> <svg>
<TiledLayer <TiledLayer
keyObject={{ provider: 'osm', zoomLevel: 11, x: 5, y: 8 }} keyObject={{ provider: 'osm', zoomLevel: 10, x: 4, y: 10 }}
viewPort={{ topLeft: { x: 5, y: 0 }, bottomRight: { x: 5, y: 2 } }} viewPort={{ topLeft: { x: 5, y: 0 }, bottomRight: { x: 5, y: 2 } }}
tileFactory={fakeTileFactory} tileFactory={fakeTileFactory}
/> />
@ -169,21 +173,21 @@ describe('The TiledLayer component ', () => {
transform="translate(5, 0)" transform="translate(5, 0)"
> >
<text> <text>
{"provider":"osm","zoomLevel":11,"x":10,"y":8} {"provider":"osm","zoomLevel":10,"x":9,"y":10}
</text> </text>
</g> </g>
<g <g
transform="translate(5, 1)" transform="translate(5, 1)"
> >
<text> <text>
{"provider":"osm","zoomLevel":11,"x":10,"y":9} {"provider":"osm","zoomLevel":10,"x":9,"y":11}
</text> </text>
</g> </g>
<g <g
transform="translate(5, 2)" transform="translate(5, 2)"
> >
<text> <text>
{"provider":"osm","zoomLevel":11,"x":10,"y":10} {"provider":"osm","zoomLevel":10,"x":9,"y":12}
</text> </text>
</g> </g>
</svg> </svg>
@ -267,5 +271,4 @@ describe('The TiledLayer component ', () => {
</body> </body>
`); `);
}); });
}); });

View File

@ -4,6 +4,26 @@ import { range, isEqual } from 'lodash';
import { Rectangle, TileFactory, TileKeyObject } from './types'; import { Rectangle, TileFactory, TileKeyObject } from './types';
import tileUri from './uris'; import tileUri from './uris';
export const thisIsAModule = true;
/**
* A cache to store tiles without being subject to re-initialization when components are unmounted/remounted.
*
* This cache is a map of map, the first key identifying the `<TiledLayer` and the second one for `<Tile>`s.
*
* Idea stolen [on the web](https://dev.to/tiagof/react-re-mounting-vs-re-rendering-lnh)
*
* TODO: housekeeping
*
*/
declare global {
var cacheForTiledLayer: any;
}
export {};
globalThis.cacheForTiledLayer = new Map();
export interface TiledLayerProperties { 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;
@ -29,45 +49,46 @@ export interface TiledLayerProperties {
* TODO: remove tileFactory to facilitate memoisation. * TODO: remove tileFactory to facilitate memoisation.
* *
*/ */
export const TiledLayer: react.FC<TiledLayerProperties> = memo( export const TiledLayer: react.FC<TiledLayerProperties> = (
(props: TiledLayerProperties) => { props: TiledLayerProperties
console.log(`Rendering TiledLayer: ${JSON.stringify(props)}`); ) => {
const tiles = useRef<any>(new Map()); // console.log(`Rendering TiledLayer: ${JSON.stringify(props)}`);
if (props.viewPort !== undefined) { const key = tileUri({
range(props.viewPort.topLeft.y, props.viewPort.bottomRight.y + 1).forEach( provider: props.keyObject.provider,
(row) => { zoomLevel: props.keyObject.zoomLevel,
range( });
props.viewPort!.topLeft.x,
props.viewPort!.bottomRight.x + 1
).forEach((col) => {
const keyObject = {
provider: props.keyObject.provider,
zoomLevel: props.keyObject.zoomLevel,
x: props.keyObject.x + col,
y: props.keyObject.y + row,
};
const key = tileUri(keyObject);
if (!tiles.current.has(key)) {
tiles.current.set(
key,
<g key={key} transform={`translate(${col}, ${row})`}>
{props.tileFactory(keyObject)}
</g>
);
}
});
}
);
}
return <>{Array.from(tiles.current.values())}</>; const tiles: any = globalThis.cacheForTiledLayer.get(key) ?? new Map();
}, if (props.viewPort !== undefined) {
(prevProps, nextProps) => range(props.viewPort.topLeft.y, props.viewPort.bottomRight.y + 1).forEach(
isEqual( (row) => {
[prevProps.keyObject, prevProps.viewPort], range(
[nextProps.keyObject, nextProps.viewPort] props.viewPort!.topLeft.x,
) props.viewPort!.bottomRight.x + 1
); ).forEach((col) => {
const keyObject = {
provider: props.keyObject.provider,
zoomLevel: props.keyObject.zoomLevel,
x: props.keyObject.x + col,
y: props.keyObject.y + row,
};
const key = tileUri(keyObject);
if (!tiles.has(key)) {
tiles.set(
key,
<g key={key} transform={`translate(${col}, ${row})`}>
{props.tileFactory(keyObject)}
</g>
);
}
});
}
);
globalThis.cacheForTiledLayer.set(key, tiles);
}
return <>{Array.from(tiles.values())}</>;
};
export default TiledLayer; export default TiledLayer;