refactor: simplifies how zooming & scrolling is applied

This commit is contained in:
Mark Mankarious
2023-10-12 13:29:55 +01:00
parent ba44af5c87
commit bfce0b48e5
21 changed files with 189 additions and 185 deletions

View File

@@ -2,7 +2,8 @@ import React, { useMemo } from 'react';
import { Box } from '@mui/material';
import gridTileSvg from 'src/assets/grid-tile-bg.svg';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { getProjectedTileSize } from 'src/utils';
import { PROJECTED_TILE_SIZE } from 'src/config';
import { SizeUtils } from 'src/utils/SizeUtils';
export const Grid = () => {
const scroll = useUiStateStore((state) => {
@@ -12,7 +13,7 @@ export const Grid = () => {
return state.zoom;
});
const projectedTileSize = useMemo(() => {
return getProjectedTileSize({ zoom });
return SizeUtils.multiply(PROJECTED_TILE_SIZE, zoom);
}, [zoom]);
return (

View File

@@ -52,6 +52,7 @@ export const Renderer = () => {
left: 0,
width: '100%',
height: '100%',
zIndex: 0,
bgcolor: (theme) => {
return theme.customVars.customPalette.diagramBg;
}
@@ -60,9 +61,17 @@ export const Renderer = () => {
<SceneLayer>
<Rectangles />
</SceneLayer>
<SceneLayer sx={{ width: '100%', height: '100%', top: 0, left: 0 }}>
<Box
sx={{
position: 'absolute',
width: '100%',
height: '100%',
top: 0,
left: 0
}}
>
<Grid />
</SceneLayer>
</Box>
{mode.showCursor && (
<SceneLayer>
<Cursor />
@@ -86,9 +95,10 @@ export const Renderer = () => {
<TransformControlsManager />
</SceneLayer>
{/* Interaction layer: this is where events are detected */}
<SceneLayer
<Box
ref={containerRef}
sx={{
position: 'absolute',
left: 0,
top: 0,
width: '100%',

View File

@@ -1,5 +1,6 @@
import React, { forwardRef } from 'react';
import React from 'react';
import { Box, SxProps } from '@mui/material';
import { useUiStateStore } from 'src/stores/uiStateStore';
interface Props {
children?: React.ReactNode;
@@ -7,24 +8,31 @@ interface Props {
sx?: SxProps;
}
export const SceneLayer = forwardRef(
({ children, order = 0, sx }: Props, ref) => {
return (
<Box
ref={ref}
sx={{
position: 'absolute',
zIndex: order,
top: '50%',
left: '50%',
width: 0,
height: 0,
userSelect: 'none',
...sx
}}
>
{children}
</Box>
);
}
);
export const SceneLayer = ({ children, order = 0, sx }: Props) => {
const scroll = useUiStateStore((state) => {
return state.scroll;
});
const zoom = useUiStateStore((state) => {
return state.zoom;
});
return (
<Box
sx={{
position: 'absolute',
zIndex: order,
top: '50%',
left: '50%',
width: 0,
height: 0,
userSelect: 'none',
...sx
}}
style={{
transform: `translate(${scroll.position.x}px, ${scroll.position.y}px) scale(${zoom})`
}}
>
{children}
</Box>
);
};

View File

@@ -3,8 +3,8 @@ import { Box, Typography } from '@mui/material';
import { Connector } from 'src/types';
import { useGetTilePosition } from 'src/hooks/useGetTilePosition';
import { connectorPathTileToGlobal } from 'src/utils';
import { PROJECTED_TILE_SIZE } from 'src/config';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { useTileSize } from 'src/hooks/useTileSize';
interface Props {
connector: Connector;
@@ -15,7 +15,6 @@ export const ConnectorLabel = ({ connector }: Props) => {
const zoom = useUiStateStore((state) => {
return state.zoom;
});
const { projectedTileSize } = useTileSize();
const labelPosition = useMemo(() => {
const tileIndex = Math.floor(connector.path.tiles.length / 2);
@@ -40,7 +39,7 @@ export const ConnectorLabel = ({ connector }: Props) => {
}}
style={{
transform: `translate(-50%, -50%) scale(${zoom})`,
maxWidth: projectedTileSize.width * (1 / zoom),
maxWidth: PROJECTED_TILE_SIZE.width,
left: labelPosition.x,
top: labelPosition.y
}}

View File

@@ -9,7 +9,6 @@ import {
getAllAnchors
} from 'src/utils';
import { Circle } from 'src/components/Circle/Circle';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { useSceneStore } from 'src/stores/sceneStore';
import { Svg } from 'src/components/Svg/Svg';
import { useIsoProjection } from 'src/hooks/useIsoProjection';
@@ -23,12 +22,6 @@ export const Connector = ({ connector }: Props) => {
const { css, pxSize } = useIsoProjection({
...connector.path.rectangle
});
const zoom = useUiStateStore((state) => {
return state.zoom;
});
const unprojectedTileSize = useMemo(() => {
return UNPROJECTED_TILE_SIZE * zoom;
}, [zoom]);
const nodes = useSceneStore((state) => {
return state.nodes;
});
@@ -38,18 +31,18 @@ export const Connector = ({ connector }: Props) => {
const drawOffset = useMemo(() => {
return {
x: unprojectedTileSize / 2,
y: unprojectedTileSize / 2
x: UNPROJECTED_TILE_SIZE / 2,
y: UNPROJECTED_TILE_SIZE / 2
};
}, [unprojectedTileSize]);
}, []);
const pathString = useMemo(() => {
return connector.path.tiles.reduce((acc, tile) => {
return `${acc} ${tile.x * unprojectedTileSize + drawOffset.x},${
tile.y * unprojectedTileSize + drawOffset.y
return `${acc} ${tile.x * UNPROJECTED_TILE_SIZE + drawOffset.x},${
tile.y * UNPROJECTED_TILE_SIZE + drawOffset.y
}`;
}, '');
}, [unprojectedTileSize, connector.path.tiles, drawOffset]);
}, [connector.path.tiles, drawOffset]);
const anchorPositions = useMemo(() => {
return connector.anchors.map((anchor) => {
@@ -57,21 +50,18 @@ export const Connector = ({ connector }: Props) => {
return {
id: anchor.id,
x: (connector.path.rectangle.from.x - position.x) * unprojectedTileSize,
y: (connector.path.rectangle.from.y - position.y) * unprojectedTileSize
x:
(connector.path.rectangle.from.x - position.x) *
UNPROJECTED_TILE_SIZE,
y:
(connector.path.rectangle.from.y - position.y) * UNPROJECTED_TILE_SIZE
};
});
}, [
connector.path.rectangle,
connector.anchors,
nodes,
connectors,
unprojectedTileSize
]);
}, [connector.path.rectangle, connector.anchors, nodes, connectors]);
const connectorWidthPx = useMemo(() => {
return (unprojectedTileSize / 100) * connector.width;
}, [connector.width, unprojectedTileSize]);
return (UNPROJECTED_TILE_SIZE / 100) * connector.width;
}, [connector.width]);
const strokeDashArray = useMemo(() => {
switch (connector.style) {
@@ -121,16 +111,16 @@ export const Connector = ({ connector }: Props) => {
<g key={anchor.id}>
<Circle
tile={CoordsUtils.add(anchor, drawOffset)}
radius={18 * zoom}
radius={18}
fill={theme.palette.common.white}
fillOpacity={0.7}
/>
<Circle
tile={CoordsUtils.add(anchor, drawOffset)}
radius={12 * zoom}
radius={12}
stroke={theme.palette.common.black}
fill={theme.palette.common.white}
strokeWidth={6 * zoom}
strokeWidth={6}
/>
</g>
);

View File

@@ -1,6 +1,6 @@
import React, { useRef, useEffect } from 'react';
import { Box } from '@mui/material';
import { useTileSize } from 'src/hooks/useTileSize';
import { PROJECTED_TILE_SIZE } from 'src/config';
import { useResizeObserver } from 'src/hooks/useResizeObserver';
interface Props {
@@ -10,7 +10,6 @@ interface Props {
export const IsometricIcon = ({ url, onImageLoaded }: Props) => {
const ref = useRef();
const { projectedTileSize } = useTileSize();
const { size, observe, disconnect } = useResizeObserver();
useEffect(() => {
@@ -29,7 +28,7 @@ export const IsometricIcon = ({ url, onImageLoaded }: Props) => {
src={url}
sx={{
position: 'absolute',
width: projectedTileSize.width * 0.8,
width: PROJECTED_TILE_SIZE.width * 0.8,
top: -size.height,
left: -size.width / 2,
pointerEvents: 'none'

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { Box } from '@mui/material';
import { Icon } from 'src/types';
import { useTileSize } from 'src/hooks/useTileSize';
import { PROJECTED_TILE_SIZE } from 'src/config';
import { getIsoProjectionCss } from 'src/utils';
interface Props {
@@ -9,25 +9,22 @@ interface Props {
}
export const NonIsometricIcon = ({ icon }: Props) => {
const { projectedTileSize } = useTileSize();
return (
<Box sx={{ pointerEvents: 'none' }}>
<Box
sx={{
position: 'absolute',
left: -projectedTileSize.width / 2,
top: -projectedTileSize.height / 2,
left: -PROJECTED_TILE_SIZE.width / 2,
top: -PROJECTED_TILE_SIZE.height / 2,
transformOrigin: 'top left',
transform: getIsoProjectionCss(),
userSelect: 'none'
transform: getIsoProjectionCss()
}}
>
<Box
component="img"
src={icon.url}
alt={`icon-${icon.id}`}
sx={{ width: projectedTileSize.width * 0.7 }}
sx={{ width: PROJECTED_TILE_SIZE.width * 0.7 }}
/>
</Box>
</Box>

View File

@@ -2,8 +2,7 @@ import React, { useEffect, useRef, useMemo } from 'react';
import { Box, Button } from '@mui/material';
import { MoreHoriz as ReadMoreIcon } from '@mui/icons-material';
import { useResizeObserver } from 'src/hooks/useResizeObserver';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { useTileSize } from 'src/hooks/useTileSize';
import { PROJECTED_TILE_SIZE } from 'src/config';
import { Gradient } from 'src/components/Gradient/Gradient';
const MAX_LABEL_HEIGHT = 80;
@@ -21,13 +20,9 @@ export const LabelContainer = ({
}: Props) => {
const contentRef = useRef<HTMLDivElement>();
const { observe, size: contentSize } = useResizeObserver();
const zoom = useUiStateStore((state) => {
return state.zoom;
});
const { projectedTileSize } = useTileSize();
const yOffset = useMemo(() => {
return projectedTileSize.height / 2;
}, [projectedTileSize]);
return PROJECTED_TILE_SIZE.height / 2;
}, []);
useEffect(() => {
if (!contentRef.current) return;
@@ -39,8 +34,7 @@ export const LabelContainer = ({
<Box
sx={{
position: 'absolute',
transformOrigin: 'top center',
transform: `scale(${zoom})`
transformOrigin: 'top center'
}}
>
<Box

View File

@@ -1,7 +1,7 @@
import React, { useMemo } from 'react';
import { Box, Typography, useTheme } from '@mui/material';
import { Node as NodeI, TileOriginEnum } from 'src/types';
import { useTileSize } from 'src/hooks/useTileSize';
import { PROJECTED_TILE_SIZE } from 'src/config';
import { useGetTilePosition } from 'src/hooks/useGetTilePosition';
import { useIcon } from 'src/hooks/useIcon';
import { MarkdownEditor } from 'src/components/MarkdownEditor/MarkdownEditor';
@@ -14,7 +14,6 @@ interface Props {
export const Node = ({ node, order }: Props) => {
const theme = useTheme();
const { projectedTileSize } = useTileSize();
const { getTilePosition } = useGetTilePosition();
const { iconComponent } = useIcon(node.icon);
@@ -51,7 +50,7 @@ export const Node = ({ node, order }: Props) => {
<Box
style={{
position: 'absolute',
top: -projectedTileSize.height
top: -PROJECTED_TILE_SIZE.height
}}
/>
<LabelContainer labelHeight={node.labelHeight} connectorDotSize={3}>

View File

@@ -4,9 +4,10 @@ import { Card, SxProps } from '@mui/material';
interface Props {
children: React.ReactNode;
sx?: SxProps;
style?: React.CSSProperties;
}
export const UiElement = ({ children, sx }: Props) => {
export const UiElement = ({ children, sx, style }: Props) => {
return (
<Card
sx={{
@@ -15,6 +16,7 @@ export const UiElement = ({ children, sx }: Props) => {
borderColor: 'grey.400',
...sx
}}
style={style}
>
{children}
</Card>

View File

@@ -2,7 +2,6 @@ import React, { useCallback, useMemo } from 'react';
import { Box, useTheme, Typography } from '@mui/material';
import { EditorModeEnum } from 'src/types';
import { UiElement } from 'components/UiElement/UiElement';
import { toPx } from 'src/utils';
import { SceneLayer } from 'src/components/SceneLayer/SceneLayer';
import { DragAndDrop } from 'src/components/DragAndDrop/DragAndDrop';
import { ItemControlsManager } from 'src/components/ItemControls/ItemControlsManager';
@@ -71,9 +70,21 @@ export const UiOverlay = () => {
const availableTools = useMemo(() => {
return getEditorModeMapping(editorMode);
}, [editorMode]);
const rendererSize = useUiStateStore((state) => {
return state.rendererSize;
});
return (
<>
<Box
sx={{
position: 'absolute',
width: 0,
height: 0,
top: 0,
left: 0,
zIndex: 1
}}
>
{availableTools.includes('ITEM_CONTROLS') && itemControls && (
<UiElement
sx={{
@@ -81,12 +92,14 @@ export const UiOverlay = () => {
top: appPadding.y * 2 + spacing(2),
left: appPadding.x,
width: '360px',
maxHeight: `calc(100% - ${toPx(appPadding.y * 6)})`,
overflowY: 'scroll',
'&::-webkit-scrollbar': {
display: 'none'
}
}}
style={{
maxHeight: rendererSize.height - appPadding.y * 6
}}
>
<ItemControlsManager />
</UiElement>
@@ -95,9 +108,10 @@ export const UiOverlay = () => {
{availableTools.includes('TOOL_MENU') && (
<>
<Box
sx={{
style={{
position: 'absolute',
right: appPadding.x,
transform: 'translateX(-100%)',
left: rendererSize.width - appPadding.x,
top: appPadding.y
}}
>
@@ -113,10 +127,11 @@ export const UiOverlay = () => {
{availableTools.includes('ZOOM_CONTROLS') && (
<Box
sx={{
style={{
position: 'absolute',
left: appPadding.x,
bottom: appPadding.y
transformOrigin: 'bottom left',
top: rendererSize.height - appPadding.y * 2,
left: appPadding.x
}}
>
<ZoomControls />
@@ -135,21 +150,32 @@ export const UiOverlay = () => {
</Box>
)}
<UiElement
<Box
sx={{
position: 'absolute',
bottom: appPadding.y,
left: '50%',
display: 'flex',
justifyContent: 'center',
left: rendererSize.width / 2,
top: rendererSize.height - appPadding.y * 2,
width: rendererSize.width - 500,
height: appPadding.y,
transform: 'translateX(-50%)',
px: 2,
py: 1,
pointerEvents: 'none'
}}
>
<Typography fontWeight={600} color="text.secondary">
{sceneTitle}
</Typography>
</UiElement>
<UiElement
sx={{
display: 'inline-flex',
px: 2,
alignItems: 'center',
height: '100%'
}}
>
<Typography fontWeight={600} color="text.secondary">
{sceneTitle}
</Typography>
</UiElement>
</Box>
{debugMode && (
<UiElement
@@ -165,6 +191,6 @@ export const UiOverlay = () => {
<DebugUtils />
</UiElement>
)}
</>
</Box>
);
};

View File

@@ -1,4 +1,4 @@
import { Size, Coords, SceneInput, Connector, Mode } from 'src/types';
import { Size, Coords, SceneInput, Connector } from 'src/types';
import { customVars } from './styles/theme';
// TODO: This file could do with better organisation and convention for easier reading.
@@ -7,6 +7,10 @@ export const TILE_PROJECTION_MULTIPLIERS: Size = {
width: 1.415,
height: 0.819
};
export const PROJECTED_TILE_SIZE = {
width: UNPROJECTED_TILE_SIZE * TILE_PROJECTION_MULTIPLIERS.width,
height: UNPROJECTED_TILE_SIZE * TILE_PROJECTION_MULTIPLIERS.height
};
export const DEFAULT_COLOR = customVars.customPalette.blue;
export const DEFAULT_FONT_FAMILY = 'Roboto, Arial, sans-serif';
export const NODE_DEFAULTS = {

View File

@@ -1,28 +1,16 @@
import { useCallback } from 'react';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { Coords, TileOriginEnum } from 'src/types';
import { getTilePosition as getTilePositionUtil } from 'src/utils';
export const useGetTilePosition = () => {
const { scroll, zoom, rendererSize } = useUiStateStore((state) => {
return {
scroll: state.scroll,
zoom: state.zoom,
rendererSize: state.rendererSize
};
});
const getTilePosition = useCallback(
({ tile, origin }: { tile: Coords; origin?: TileOriginEnum }) => {
return getTilePositionUtil({
tile,
scroll,
zoom,
origin,
rendererSize
origin
});
},
[scroll, zoom, rendererSize]
[]
);
return { getTilePosition };

View File

@@ -7,7 +7,6 @@ import {
} from 'src/types';
import { getBoundingBox, getIsoProjectionCss } from 'src/utils';
import { UNPROJECTED_TILE_SIZE } from 'src/config';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { useGetTilePosition } from './useGetTilePosition';
interface Props {
@@ -28,9 +27,6 @@ export const useIsoProjection = ({
gridSize: Size;
pxSize: Size;
} => {
const zoom = useUiStateStore((state) => {
return state.zoom;
});
const { getTilePosition } = useGetTilePosition();
const gridSize = useMemo(() => {
@@ -59,10 +55,10 @@ export const useIsoProjection = ({
const pxSize = useMemo(() => {
return {
width: gridSize.width * UNPROJECTED_TILE_SIZE * zoom,
height: gridSize.height * UNPROJECTED_TILE_SIZE * zoom
width: gridSize.width * UNPROJECTED_TILE_SIZE,
height: gridSize.height * UNPROJECTED_TILE_SIZE
};
}, [zoom, gridSize]);
}, [gridSize]);
return {
css: {

View File

@@ -1,22 +1,23 @@
import { useMemo } from 'react';
import { TextBox } from 'src/types';
import { useTileSize } from 'src/hooks/useTileSize';
import { DEFAULT_FONT_FAMILY, TEXTBOX_DEFAULTS } from 'src/config';
import {
UNPROJECTED_TILE_SIZE,
DEFAULT_FONT_FAMILY,
TEXTBOX_DEFAULTS
} from 'src/config';
export const useTextBoxProps = (textBox: TextBox) => {
const { unprojectedTileSize } = useTileSize();
const fontProps = useMemo(() => {
return {
fontSize: unprojectedTileSize * textBox.fontSize,
fontSize: UNPROJECTED_TILE_SIZE * textBox.fontSize,
fontFamily: DEFAULT_FONT_FAMILY,
fontWeight: TEXTBOX_DEFAULTS.fontWeight
};
}, [unprojectedTileSize, textBox.fontSize]);
}, [textBox.fontSize]);
const paddingX = useMemo(() => {
return unprojectedTileSize * TEXTBOX_DEFAULTS.paddingX;
}, [unprojectedTileSize]);
return UNPROJECTED_TILE_SIZE * TEXTBOX_DEFAULTS.paddingX;
}, []);
return { paddingX, fontProps };
};

View File

@@ -1,20 +0,0 @@
import { useMemo } from 'react';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { getProjectedTileSize } from 'src/utils';
import { UNPROJECTED_TILE_SIZE } from 'src/config';
export const useTileSize = () => {
const zoom = useUiStateStore((state) => {
return state.zoom;
});
const projectedTileSize = useMemo(() => {
return getProjectedTileSize({ zoom });
}, [zoom]);
const unprojectedTileSize = useMemo(() => {
return UNPROJECTED_TILE_SIZE * zoom;
}, [zoom]);
return { projectedTileSize, unprojectedTileSize };
};

View File

@@ -76,9 +76,6 @@ export const TransformRectangle: ModeActions = {
const anchorPositions = rectangleBounds.map((corner, i) => {
return getTilePosition({
tile: corner,
scroll: uiState.scroll,
zoom: uiState.zoom,
rendererSize: uiState.rendererSize,
origin: outermostCornerPositions[i]
});
});

33
src/utils/SizeUtils.ts Normal file
View File

@@ -0,0 +1,33 @@
import { Size } from 'src/types';
export class SizeUtils {
static isEqual(base: Size, operand: Size) {
return base.width === operand.width && base.height === operand.height;
}
static subtract(base: Size, operand: Size): Size {
return {
width: base.width - operand.width,
height: base.height - operand.height
};
}
static add(base: Size, operand: Size): Size {
return {
width: base.width + operand.width,
height: base.height + operand.height
};
}
static multiply(base: Size, operand: number): Size {
return { width: base.width * operand, height: base.height * operand };
}
static toString(size: Size) {
return `width: ${size.width}, height: ${size.height}`;
}
static zero() {
return { width: 0, y: 0 };
}
}

View File

@@ -1,4 +1,5 @@
export * from './CoordsUtils';
export * from './SizeUtils';
export * from './common';
export * from './inputs';
export * from './pathfinder';

View File

@@ -1,7 +1,7 @@
import { produce } from 'immer';
import {
TILE_PROJECTION_MULTIPLIERS,
UNPROJECTED_TILE_SIZE,
PROJECTED_TILE_SIZE,
ZOOM_INCREMENT,
MAX_ZOOM,
MIN_ZOOM,
@@ -28,24 +28,13 @@ import {
} from 'src/types';
import {
CoordsUtils,
SizeUtils,
clamp,
roundToOneDecimalPlace,
findPath,
toPx
} from 'src/utils';
interface GetProjectedTileSize {
zoom: number;
}
// Gets the size of a tile at a given zoom level
export const getProjectedTileSize = ({ zoom }: GetProjectedTileSize): Size => {
return {
width: UNPROJECTED_TILE_SIZE * TILE_PROJECTION_MULTIPLIERS.width * zoom,
height: UNPROJECTED_TILE_SIZE * TILE_PROJECTION_MULTIPLIERS.height * zoom
};
};
interface ScreenToIso {
mouse: Coords;
zoom: number;
@@ -60,7 +49,7 @@ export const screenToIso = ({
scroll,
rendererSize
}: ScreenToIso) => {
const projectedTileSize = getProjectedTileSize({ zoom });
const projectedTileSize = SizeUtils.multiply(PROJECTED_TILE_SIZE, zoom);
const halfW = projectedTileSize.width / 2;
const halfH = projectedTileSize.height / 2;
@@ -85,25 +74,19 @@ export const screenToIso = ({
interface GetTilePosition {
tile: Coords;
scroll: Scroll;
zoom: number;
origin?: TileOriginEnum;
rendererSize: Size;
}
export const getTilePosition = ({
tile,
scroll,
zoom,
origin = TileOriginEnum.CENTER
}: GetTilePosition) => {
const projectedTileSize = getProjectedTileSize({ zoom });
const halfW = projectedTileSize.width / 2;
const halfH = projectedTileSize.height / 2;
const halfW = PROJECTED_TILE_SIZE.width / 2;
const halfH = PROJECTED_TILE_SIZE.height / 2;
const position: Coords = {
x: halfW * tile.x - halfW * tile.y + scroll.position.x,
y: -(halfH * tile.x + halfH * tile.y) + scroll.position.y
x: halfW * tile.x - halfW * tile.y,
y: -(halfH * tile.x + halfH * tile.y)
};
switch (origin) {

View File

@@ -1,14 +1,10 @@
import { Coords, Size, Scroll } from 'src/types';
import { CoordsUtils } from 'src/utils';
import {
getGridSubset,
isWithinBounds,
screenToIso,
getProjectedTileSize
} from '../renderer';
import { CoordsUtils, SizeUtils } from 'src/utils';
import { PROJECTED_TILE_SIZE } from 'src/config';
import { getGridSubset, isWithinBounds, screenToIso } from '../renderer';
const getRendererSize = (tileSize: Size, zoom: number = 1): Size => {
const projectedTileSize = getProjectedTileSize({ zoom });
const projectedTileSize = SizeUtils.multiply(PROJECTED_TILE_SIZE, zoom);
return {
width: projectedTileSize.width * tileSize.width,