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 Handlers from './Handlers';
import Tile from './Tile';
import TiledLayer from './TiledLayer';
import { Point, TileFactory } from './types';
import { Point } from './types';
import LayerStack from './LayerStack';
export interface MapProperties {}
@ -75,54 +74,20 @@ export const relativeCoordinateSystemAtom = atom(
/**
*
* @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) => {
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 (
<>
<Handlers />
<svg width={window.innerWidth} height={window.innerHeight}>
<g
transform={`translate(${coordinateSystem.shift.x}, ${coordinateSystem.shift.y}) scale(${coordinateSystem.zoom})`}
>
<g transform='scale(256)'>
<TiledLayer
keyObject={{ provider: 'osm', zoomLevel: 16, x: 33485, y: 23936 }}
viewPort={viewPort}
tileFactory={simpleTileFactory}
/>
</g>
</g>
</svg>
<LayerStack
numberOfTiledLayers={3}
keyObject={{ provider: 'osm', zoomLevel: 16, x: 33485, y: 23936 }}
/>
</>
);
};

View File

@ -8,7 +8,7 @@ export interface TiledLayerProperties {
/** The key of the first (ie top/left) tile */
keyObject: TileKeyObject;
/** The current viewport expressed in tiles coordinates */
viewPort: Rectangle;
viewPort?: Rectangle;
/** The factory to create tiles */
tileFactory: TileFactory;
}
@ -19,12 +19,14 @@ export interface TiledLayerProperties {
* 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
* 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
* 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: housekeeping
* TODO: remove tileFactory to facilitate memoisation.
*
*/
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)}`);
const tiles = useRef<any>(new Map());
range(props.viewPort.topLeft.y, props.viewPort.bottomRight.y + 1).map(
(row) => {
range(props.viewPort.topLeft.x, props.viewPort.bottomRight.x + 1).map(
(col) => {
if (props.viewPort !== undefined) {
range(props.viewPort.topLeft.y, props.viewPort.bottomRight.y + 1).forEach(
(row) => {
range(
props.viewPort!.topLeft.x,
props.viewPort!.bottomRight.x + 1
).forEach((col) => {
const keyObject = {
provider: props.keyObject.provider,
zoomLevel: props.keyObject.zoomLevel,
@ -51,14 +56,18 @@ export const TiledLayer: react.FC<TiledLayerProperties> = memo(
</g>
);
}
}
);
}
);
});
}
);
}
return <>{Array.from(tiles.current.values())}</>;
},
isEqual
(prevProps, nextProps) =>
isEqual(
[prevProps.keyObject, prevProps.viewPort],
[nextProps.keyObject, nextProps.viewPort]
)
);
export default TiledLayer;

View File

@ -13,4 +13,13 @@ describe('Test that', () => {
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}
*
*
* TODO: update docuri (or write a wrapper) to support datatyping (and formats).
*/
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') {
return {
provider: r.provider,
zoomLevel: parseInt(r.zoomLevel),
x: parseInt(r.x),
y: parseInt(r.y),
};
return r.x === undefined
? {
provider: r.provider,
zoomLevel: parseInt(r.zoomLevel),
}
: {
provider: r.provider,
zoomLevel: parseInt(r.zoomLevel),
x: parseInt(r.x),
y: parseInt(r.y),
};
}
return r;
};