From 722cf857cec4d12616b5ef3cbfef02367dba6416 Mon Sep 17 00:00:00 2001 From: Shahar s Date: Thu, 12 Aug 2021 08:34:45 +0300 Subject: [PATCH 1/4] feat: added tile hasher --- src/geo/bboxUtils.ts | 49 ++++- src/geo/geoConvertor.ts | 2 +- src/geo/geoHash.ts | 2 +- src/geo/index.ts | 1 + src/geo/tileHasher.ts | 321 +++++++++++++++++++++++++++++ src/geo/tiles.ts | 13 ++ src/models/interfaces/geo/iTile.ts | 8 + tests/unit/geo/bboxUtils.spec.ts | 65 +++++- tests/unit/geo/tileHasher.spec.ts | 244 ++++++++++++++++++++++ 9 files changed, 695 insertions(+), 10 deletions(-) create mode 100644 src/geo/tileHasher.ts create mode 100644 tests/unit/geo/tileHasher.spec.ts diff --git a/src/geo/bboxUtils.ts b/src/geo/bboxUtils.ts index b34036f..4d3ee54 100644 --- a/src/geo/bboxUtils.ts +++ b/src/geo/bboxUtils.ts @@ -1,6 +1,6 @@ import { BBox2d } from '@turf/helpers/dist/js/lib/geojson'; -import { ITile } from '../models/interfaces/geo/iTile'; -import { tileToDegrees } from './geoConvertor'; +import { ITile, ITileRange } from '../models/interfaces/geo/iTile'; +import { degreesToTile, tileToDegrees } from './geoConvertor'; import { degreesPerTile } from './tiles'; const snapMinCordToTileGrid = (cord: number, tileRes: number): number => { @@ -22,16 +22,20 @@ export const snapBBoxToTileGrid = (bbox: BBox2d, zoomLevel: number): BBox2d => { const tileRes = degreesPerTile(zoomLevel); bbox[0] = snapMinCordToTileGrid(minLon, tileRes); - bbox[1] = snapMinCordToTileGrid(minLat, tileRes); bbox[2] = snapMinCordToTileGrid(maxLon, tileRes); if (bbox[2] != maxLon) { bbox[2] += tileRes; } - bbox[3] = snapMinCordToTileGrid(maxLat, tileRes); - if (bbox[3] != maxLat) { - bbox[3] += tileRes; + if (zoomLevel === 0) { + bbox[1] = -90; + bbox[3] = 90; + } else { + bbox[1] = snapMinCordToTileGrid(minLat, tileRes); + bbox[3] = snapMinCordToTileGrid(maxLat, tileRes); + if (bbox[3] != maxLat) { + bbox[3] += tileRes; + } } - return bbox; }; @@ -55,3 +59,34 @@ export const bboxFromTiles = (minTile: ITile, maxTile: ITile): BBox2d => { return [minPoint.longitude, minPoint.latitude, maxPoint.longitude, maxPoint.latitude]; }; + +/** + * coverts bbox to covering tile range of specified zoom level + * @param bbox + * @param zoom target zoom level + * @returns covering tile range + */ +export const bboxToTileRange = (bbox: BBox2d, zoom: number): ITileRange => { + bbox = snapBBoxToTileGrid(bbox, zoom); + const minTile = degreesToTile( + { + longitude: bbox[0], + latitude: bbox[1], + }, + zoom + ); + const maxTile = degreesToTile( + { + longitude: bbox[2], + latitude: bbox[3], + }, + zoom + ); + return { + minX: minTile.x, + minY: minTile.y, + maxX: maxTile.x, + maxY: maxTile.y, + zoom, + }; +}; diff --git a/src/geo/geoConvertor.ts b/src/geo/geoConvertor.ts index fa5e5f1..5860a28 100644 --- a/src/geo/geoConvertor.ts +++ b/src/geo/geoConvertor.ts @@ -18,7 +18,7 @@ export const degreesToTile = (point: IPoint, zoomLevel: number, origin: TileOrig } const xTile = point.longitude / resolution + (1 << zoomLevel); - const yTile = lat / resolution + (1 << (zoomLevel - 1)); + const yTile = zoomLevel != 0 ? lat / resolution + (1 << (zoomLevel - 1)) : lat / resolution + 0.5; return { x: Math.floor(xTile), diff --git a/src/geo/geoHash.ts b/src/geo/geoHash.ts index 53f17e3..d199d75 100644 --- a/src/geo/geoHash.ts +++ b/src/geo/geoHash.ts @@ -38,7 +38,7 @@ function* geoHash( continue; } const intArea = area(intersection); - const hashArea = area(bboxPolygon(decodeGeoHash(hash))); + const hashArea = area(hashPoly); if (intArea == hashArea || precision == maxPrecision) { yield hash; } else { diff --git a/src/geo/index.ts b/src/geo/index.ts index cd3311f..fc1144c 100644 --- a/src/geo/index.ts +++ b/src/geo/index.ts @@ -2,3 +2,4 @@ export * from './bboxUtils'; export * from './geoConvertor'; export * from './geoHash'; export * from './tiles'; +export * from './tileHasher'; diff --git a/src/geo/tileHasher.ts b/src/geo/tileHasher.ts new file mode 100644 index 0000000..5417d95 --- /dev/null +++ b/src/geo/tileHasher.ts @@ -0,0 +1,321 @@ +import { BBox2d } from '@turf/helpers/dist/js/lib/geojson'; +import { area, bbox as polygonToBbox, bboxPolygon, booleanEqual, Feature, intersect, MultiPolygon, Polygon } from '@turf/turf'; +import { ITile, ITileRange } from '../models/interfaces/geo/iTile'; +import { bboxToTileRange } from './bboxUtils'; +import { tileToBbox } from './tiles'; + +type HashDigits = '0' | '1' | '2' | '3'; +type TileIntersectionFunction = (tile: ITile, intersectionTarget: T) => TileIntersectionState; + +enum TileIntersectionState { + FULL = 'full', + PARTIAL = 'partial', + NONE = 'none', +} + +interface IHashedTile extends ITile { + hash: string; +} + +interface IFootprintIntersectionParams { + footprint: Polygon | Feature; + maxZoom: number; +} + +/** + * class for generating and decoding tile hashes + */ +export class TileHasher { + /* + tile hash structure: + zoom 0: + --------- + | 0 | 1 | + --------- + zoom 1: + --------------------- + | 02 | 03 | 12 | 13 | + --------------------- + | 00 | 01 | 10 | 11 | + --------------------- + .... + */ + + private readonly hashDigits = { + '0': 0, + '1': 1, + '2': 2, + '3': 3, + }; + + /** + * encodes tile to its hash + * @param tile + * @returns + */ + public encodeTile(tile: ITile): string { + let x = tile.x; + let y = tile.y; + let hash = ''; + for (let z = tile.zoom; z >= 0; z--) { + const xDigit = x & 1; + const yDigit = (y & 1) << 1; + const digit = xDigit + yDigit; + hash = `${digit}${hash}`; + x = x >> 1; + y = y >> 1; + } + return hash; + } + + /** + * decodes tile hash to tile + * @param hash + * @returns + */ + public decodeTile(hash: string): ITile { + let x = 0; + let y = 0; + + for (const char of hash) { + const digit = this.hashDigits[char as HashDigits]; + x = (x << 1) + (digit & 1); + y = (y << 1) + (digit >> 1); + } + + return { + x, + y, + zoom: hash.length - 1, + }; + } + + /** + * decodes hash to tile range of specified zoom level + * @param hash + * @param zoom target tile range zoom + * @returns + */ + public decodeTileRange(hash: string, zoom: number): ITileRange { + const baseTile = this.decodeTile(hash); + let minX: number, minY: number, maxX: number, maxY: number; + minX = baseTile.x; + maxX = baseTile.x + 1; + minY = baseTile.y; + maxY = baseTile.y + 1; + if (baseTile.zoom < zoom) { + const dz = zoom - baseTile.zoom; + minX = minX << dz; + maxX = maxX << dz; + minY = minY << dz; + maxY = maxY << dz; + } else if (baseTile.zoom > zoom) { + const dz = baseTile.zoom - zoom; + minX = minX >> dz; + minY = minY >> dz; + maxX = minX + 1; + maxY = minY + 1; + } + return { + minX, + minY, + maxX, + maxY, + zoom, + }; + } + + /** + * returns hash bbox + * @param hash + * @returns + */ + public decodeBbox(hash: string): BBox2d { + const tile = this.decodeTile(hash); + return tileToBbox(tile); + } + + /** + * generate tile hashes + * @param bbox bbox to cover with generated tile hashes + * @param zoom max hash zoom + * @returns + */ + public encodeBBox(bbox: BBox2d, zoom: number): Generator { + const boundingRange = bboxToTileRange(bbox, zoom); + return this.generateHashes(bbox, zoom, boundingRange, this.tileRangeIntersection); + } + + /** + * generate tile hashes + * @param footprint footprint to cover with generated tile hashes + * @param zoom max hash zoom + * @returns + */ + public encodeFootprint(footprint: Polygon | Feature, zoom: number): Generator { + const bbox = polygonToBbox(footprint) as BBox2d; + if (this.isBbox(footprint)) { + return this.encodeBBox(bbox, zoom); + } else { + const intersectionParams: IFootprintIntersectionParams = { + footprint, + maxZoom: zoom, + }; + return this.generateHashes(bbox, zoom, intersectionParams, this.tileFootprintIntersection); + } + } + + /** + * generate tile + * @param bbox bbox to cover with generated tiles + * @param zoom target tiles zoom level + */ + public generateTiles(bbox: BBox2d, zoom: number): Generator; + /** + * generate tile + * @param footprint footprint to cover with generated tiles + * @param zoom target tiles zoom level + */ + public generateTiles(footprint: Polygon | Feature, zoom: number): Generator; + public generateTiles(area: BBox2d | Polygon | Feature, zoom: number): Generator { + let gen: Generator; + if (Array.isArray(area)) { + gen = this.encodeBBox(area, zoom); + } else { + gen = this.encodeFootprint(area, zoom); + } + return this.tilesGenerator(gen, zoom); + } + + private *tilesGenerator(hashGen: Generator, targetZoom: number): Generator { + for (const hash of hashGen) { + const range = this.decodeTileRange(hash, targetZoom); + for (let x = range.minX; x < range.maxX; x++) { + for (let y = range.minY; y < range.maxY; y++) { + yield { + x, + y, + zoom: targetZoom, + }; + } + } + } + } + + private *generateHashes( + bbox: BBox2d, + zoom: number, + intersectionTarget: T, + intersectionFunction: TileIntersectionFunction + ): Generator { + const boundingRange = bboxToTileRange(bbox, zoom); + //find minimal zoom where the the area can be converted by area the size of single tile to skip levels that cant have full hashes + const dx = boundingRange.maxX - boundingRange.minX; + const dy = boundingRange.maxY - boundingRange.minY; + const minXZoom = Math.max(Math.floor(Math.log2(1 << (zoom + 1)) / dx) - 1, 0); + const minYZoom = Math.max(Math.floor(Math.log2(1 << zoom) / dy), 0); + const minZoom = Math.min(minXZoom, minYZoom); + + //find base hashes + const minimalRange = bboxToTileRange(bbox, minZoom); + for (let x = minimalRange.minX; x < minimalRange.maxX; x++) { + for (let y = minimalRange.minY; y < minimalRange.maxY; y++) { + const tile = { x, y, zoom: minimalRange.zoom }; + const intersection = intersectionFunction(tile, intersectionTarget); + if (intersection === TileIntersectionState.FULL) { + yield this.encodeTile(tile); + } else if (intersection === TileIntersectionState.PARTIAL) { + //optimize partial base hashes + yield* this.optimizeHash({ ...tile, hash: this.encodeTile(tile) }, intersectionTarget, intersectionFunction); + } + } + } + } + + private *optimizeHash(tile: IHashedTile, intersectionTarget: T, intersectionFunction: TileIntersectionFunction): Generator { + const tiles = this.generateSubTiles(tile); + for (const subTile of tiles) { + const intersection = intersectionFunction(subTile, intersectionTarget); + if (intersection === TileIntersectionState.FULL) { + yield subTile.hash; + } else if (intersection === TileIntersectionState.PARTIAL) { + yield* this.optimizeHash(subTile, intersectionTarget, intersectionFunction); + } + } + } + + private generateSubTiles(tile: IHashedTile): IHashedTile[] { + const tile0 = { x: tile.x << 1, y: tile.y << 1, zoom: tile.zoom + 1, hash: tile.hash + '0' }; + const tile1 = { x: tile0.x + 1, y: tile0.y, zoom: tile0.zoom, hash: tile.hash + '1' }; + const tile2 = { x: tile0.x, y: tile0.y + 1, zoom: tile0.zoom, hash: tile.hash + '2' }; + const tile3 = { x: tile0.x + 1, y: tile0.y + 1, zoom: tile0.zoom, hash: tile.hash + '3' }; + const tiles = [tile0, tile1, tile2, tile3]; + return tiles; + } + + private readonly tileRangeIntersection = (tile: ITile, range: ITileRange): TileIntersectionState => { + if (tile.zoom < range.zoom) { + const dz = range.zoom - tile.zoom; + const tileSize = (1 << dz) - 1; + const startTile = { + x: tile.x << dz, + y: tile.y << dz, + zoom: range.zoom, + }; + const endTile = { + x: startTile.x + tileSize, + y: startTile.y + tileSize, + zoom: startTile.zoom, + }; + const startIncluded = this.sameZoomTileRangeIntersection(startTile, range); + const endIncluded = this.sameZoomTileRangeIntersection(endTile, range); + if (startIncluded && endIncluded) { + return TileIntersectionState.FULL; + } else if (startIncluded || endIncluded) { + return TileIntersectionState.PARTIAL; + } else if (endTile.x <= range.minX || startTile.x >= range.maxX || endTile.y <= range.minY || startTile.y >= range.maxY) { + return TileIntersectionState.NONE; + } else { + return TileIntersectionState.PARTIAL; + } + } else if (tile.zoom > range.zoom) { + const dz = tile.zoom - range.zoom; + range = { + minX: range.minX << dz, + maxX: range.maxX << dz, + minY: range.minY << dz, + maxY: range.maxX << dz, + zoom: tile.zoom, + }; + } + return this.sameZoomTileRangeIntersection(tile, range) ? TileIntersectionState.FULL : TileIntersectionState.NONE; + }; + + private sameZoomTileRangeIntersection(tile: ITile, range: ITileRange): boolean { + return tile.x >= range.minX && tile.x < range.maxX && tile.y >= range.minY && tile.y < range.maxY; + } + + private readonly tileFootprintIntersection = (tile: ITile, intersectionParams: IFootprintIntersectionParams): TileIntersectionState => { + const tileBbox = tileToBbox(tile); + const tilePoly = bboxPolygon(tileBbox); + const intersection = intersect(intersectionParams.footprint, tilePoly); + if (intersection === null) { + return TileIntersectionState.NONE; + } + if (tile.zoom === intersectionParams.maxZoom) { + return TileIntersectionState.FULL; + } + const intArea = area(intersection); + const hashArea = area(tilePoly); + if (intArea == hashArea) { + return TileIntersectionState.FULL; + } + return TileIntersectionState.PARTIAL; + }; + + private isBbox(footprint: Polygon | Feature): boolean { + const bbox = polygonToBbox(footprint); + const bboxPoly = bboxPolygon(bbox); + return booleanEqual(footprint, bboxPoly); + } +} diff --git a/src/geo/tiles.ts b/src/geo/tiles.ts index a095b51..3df201e 100644 --- a/src/geo/tiles.ts +++ b/src/geo/tiles.ts @@ -1,4 +1,6 @@ +import { BBox2d } from '@turf/helpers/dist/js/lib/geojson'; import { ITile } from '../models/interfaces/geo/iTile'; +import { tileToDegrees } from './geoConvertor'; /** * calculates tile size (resolution) in degrees @@ -49,3 +51,14 @@ export function degreesPerPixelToZoomLevel(resolution: number): number { } return zoomLevel; } + +/** + * returns bbox of given tile + * @param tile + * @returns + */ +export function tileToBbox(tile: ITile): BBox2d { + const minPoint = tileToDegrees(tile); + const tileSize = degreesPerTile(tile.zoom); + return [minPoint.longitude, minPoint.latitude, minPoint.longitude + tileSize, minPoint.latitude + tileSize]; +} diff --git a/src/models/interfaces/geo/iTile.ts b/src/models/interfaces/geo/iTile.ts index 4c41651..b8a8c5d 100644 --- a/src/models/interfaces/geo/iTile.ts +++ b/src/models/interfaces/geo/iTile.ts @@ -6,3 +6,11 @@ export interface ITile { y: number; zoom: number; } + +export interface ITileRange { + minX: number; + minY: number; + maxX: number; + maxY: number; + zoom: number; +} diff --git a/tests/unit/geo/bboxUtils.spec.ts b/tests/unit/geo/bboxUtils.spec.ts index fe0e08b..f7965f2 100644 --- a/tests/unit/geo/bboxUtils.spec.ts +++ b/tests/unit/geo/bboxUtils.spec.ts @@ -1,5 +1,6 @@ +import { BBox2d } from '@turf/helpers/dist/js/lib/geojson'; import { ITile } from '../../../src'; -import { bboxFromTiles, snapBBoxToTileGrid } from '../../../src/geo/bboxUtils'; +import { bboxFromTiles, bboxToTileRange, snapBBoxToTileGrid } from '../../../src/geo/bboxUtils'; describe('bboxUtils', () => { describe('snapBBoxToTileGrid', () => { @@ -31,4 +32,66 @@ describe('bboxUtils', () => { expect(bbox).toEqual([-180, -90, 0, 90]); }); }); + + describe('bboxToTileRange', () => { + it('coverts bbox to expected tile range (no rounding, single tile)', () => { + const bbox = [0, 0, 45, 45] as BBox2d; + + const range = bboxToTileRange(bbox, 2); + + const expectedRange = { + minX: 4, + minY: 2, + maxX: 5, + maxY: 3, + zoom: 2, + }; + expect(range).toEqual(expectedRange); + }); + + it('coverts bbox to expected tile range (no rounding)', () => { + const bbox = [0, 0, 90, 45] as BBox2d; + + const range = bboxToTileRange(bbox, 2); + + const expectedRange = { + minX: 4, + minY: 2, + maxX: 6, + maxY: 3, + zoom: 2, + }; + expect(range).toEqual(expectedRange); + }); + + it('coverts bbox to expected tile range (rounding down)', () => { + const bbox = [0, 0, 45, 45] as BBox2d; + + const range = bboxToTileRange(bbox, 1); + + const expectedRange = { + minX: 2, + minY: 1, + maxX: 3, + maxY: 2, + zoom: 1, + }; + expect(range).toEqual(expectedRange); + }); + + it('coverts bbox to expected tile range (rounding up)', () => { + const bbox = [0, 0, 45, 45.1] as BBox2d; + + const range = bboxToTileRange(bbox, 3); + + const expectedRange = { + minX: 8, + minY: 4, + maxX: 10, + maxY: 7, + zoom: 3, + }; + expect(range).toEqual(expectedRange); + }); + }); }); diff --git a/tests/unit/geo/tileHasher.spec.ts b/tests/unit/geo/tileHasher.spec.ts new file mode 100644 index 0000000..8c9f18c --- /dev/null +++ b/tests/unit/geo/tileHasher.spec.ts @@ -0,0 +1,244 @@ +import { BBox2d } from '@turf/helpers/dist/js/lib/geojson'; +import { bboxPolygon, featureCollection, polygon } from '@turf/turf'; +import { getHashes } from 'crypto'; +import { fstat, writeFileSync } from 'fs'; +import { snapBBoxToTileGrid } from '../../../src'; +import { TileHasher } from '../../../src/geo/tileHasher'; + +describe('TileHasher', () => { + let hasher: TileHasher; + + beforeEach(() => { + hasher = new TileHasher(); + }); + + describe('encodeTile', () => { + it('returns expected hash', () => { + const tile = { + x: 3, + y: 1, + zoom: 2, + }; + + const hash = hasher.encodeTile(tile); + + expect(hash).toEqual('013'); + }); + }); + + describe('decodeTile', () => { + it('returns expected tile', () => { + const tile = hasher.decodeTile('013'); + + const expectedTile = { + x: 3, + y: 1, + zoom: 2, + }; + expect(tile).toEqual(expectedTile); + }); + }); + + describe('decodeTileRange', () => { + it('return expcted tile range', () => { + const range = hasher.decodeTileRange('013', 3); + + const expectedTileRange = { + minX: 6, + minY: 2, + maxX: 8, + maxY: 4, + zoom: 3, + }; + expect(range).toEqual(expectedTileRange); + }); + }); + + describe('decodeBbox', () => { + it('retuns bbox of encoded tile', () => { + const bbox = hasher.decodeBbox('013'); + + const expectedBbox = [-45, -45, 0, 0]; + expect(bbox).toEqual(expectedBbox); + }); + }); + + describe('encodeBbox', () => { + it('generates optimized tile hash cover for bbox', () => { + const bbox = [0, 0, 45, 50.626] as BBox2d; + const tileHashes = []; + + const hashGen = hasher.encodeBBox(bbox, 6); + for (const hash of hashGen) { + tileHashes.push(hash); + } + const expectedHashes = [ + '120', + '122000', + '122001', + '1220020', + '1220021', + '1220030', + '1220031', + '122010', + '122011', + '1220120', + '1220121', + '1220130', + '1220131', + '122100', + '122101', + '1221020', + '1221021', + '1221030', + '1221031', + '122110', + '122111', + '1221120', + '1221121', + '1221130', + '1221131', + ]; + expect(tileHashes).toEqual(expectedHashes); + }); + }); + + describe('encodeFootprint', () => { + it('encode rectangle polygon calls encode bbox', () => { + const encodeBboxMock = jest.fn(); + hasher.encodeBBox = encodeBboxMock; + const bbox = [0, 0, 1, 1] as BBox2d; + const bboxPoly = bboxPolygon(bbox); + + hasher.encodeFootprint(bboxPoly, 8); + + expect(encodeBboxMock).toHaveBeenCalledTimes(1); + expect(encodeBboxMock).toHaveBeenCalledWith(bbox, 8); + }); + + it('encodes none bbox polygon properly', () => { + const encodeBboxMock = jest.fn(); + hasher.encodeBBox = encodeBboxMock; + const poly = polygon([ + [ + [-45, 0], + [0, 45], + [45, 0], + [-45, 0], + ], + ]); + + const tileHashes = []; + const gen = hasher.encodeFootprint(poly, 5); + for (const hash of gen) { + tileHashes.push(hash); + } + const expectedHashes = [ + '031000', + '031001', + '031003', + '03101', + '031030', + '031031', + '031033', + '0311', + '031300', + '031301', + '031303', + '03131', + '031330', + '031331', + '031333', + '1200', + '12010', + '120110', + '120111', + '120112', + '120120', + '120121', + '120122', + '12020', + '120210', + '120211', + '120212', + '120220', + '120221', + '120222', + ]; + expect(tileHashes).toEqual(expectedHashes); + expect(encodeBboxMock).toHaveBeenCalledTimes(0); + }); + }); + + describe('generateTiles', () => { + it('generates expected tiles from bbox', () => { + const bbox = [-45, -45, 0, 0] as BBox2d; + + const tiles = []; + const gen = hasher.generateTiles(bbox, 2); + for (const tile of gen) { + tiles.push(tile); + } + + const expectedTiles = [ + { + x: 3, + y: 1, + zoom: 2, + }, + ]; + expect(tiles).toEqual(expectedTiles); + }); + + it('generates expected tiles from bbox polygon', () => { + const bbox = [-45, -45, 0, 0] as BBox2d; + const poly = bboxPolygon(bbox); + + const tiles = []; + const gen = hasher.generateTiles(poly, 2); + for (const tile of gen) { + tiles.push(tile); + } + + const expectedTiles = [ + { + x: 3, + y: 1, + zoom: 2, + }, + ]; + expect(tiles).toEqual(expectedTiles); + }); + + it('generates expected tiles from none bbox polygon', () => { + const poly = polygon([ + [ + [-45, 0], + [0, 45], + [45, 0], + [-45, 0], + ], + ]); + + const tiles = []; + const gen = hasher.generateTiles(poly, 2); + for (const tile of gen) { + tiles.push(tile); + } + + const expectedTiles = [ + { + x: 3, + y: 2, + zoom: 2, + }, + { + x: 4, + y: 2, + zoom: 2, + }, + ]; + expect(tiles).toEqual(expectedTiles); + }); + }); +}); From b5de4aa447eb87f76bd62cd5e3581a7bc02979a6 Mon Sep 17 00:00:00 2001 From: Shahar s Date: Thu, 12 Aug 2021 09:52:28 +0300 Subject: [PATCH 2/4] chore: removed goehash resmblense and optimization --- src/geo/index.ts | 2 +- src/geo/{tileHasher.ts => tileRanger.ts} | 199 ++++-------------- tests/unit/geo/tileHasher.spec.ts | 244 ----------------------- tests/unit/geo/tileRanger.spec.ts | 217 ++++++++++++++++++++ 4 files changed, 253 insertions(+), 409 deletions(-) rename src/geo/{tileHasher.ts => tileRanger.ts} (50%) delete mode 100644 tests/unit/geo/tileHasher.spec.ts create mode 100644 tests/unit/geo/tileRanger.spec.ts diff --git a/src/geo/index.ts b/src/geo/index.ts index fc1144c..5308a6c 100644 --- a/src/geo/index.ts +++ b/src/geo/index.ts @@ -2,4 +2,4 @@ export * from './bboxUtils'; export * from './geoConvertor'; export * from './geoHash'; export * from './tiles'; -export * from './tileHasher'; +export * from './tileRanger'; diff --git a/src/geo/tileHasher.ts b/src/geo/tileRanger.ts similarity index 50% rename from src/geo/tileHasher.ts rename to src/geo/tileRanger.ts index 5417d95..29f35ae 100644 --- a/src/geo/tileHasher.ts +++ b/src/geo/tileRanger.ts @@ -4,7 +4,6 @@ import { ITile, ITileRange } from '../models/interfaces/geo/iTile'; import { bboxToTileRange } from './bboxUtils'; import { tileToBbox } from './tiles'; -type HashDigits = '0' | '1' | '2' | '3'; type TileIntersectionFunction = (tile: ITile, intersectionTarget: T) => TileIntersectionState; enum TileIntersectionState { @@ -13,10 +12,6 @@ enum TileIntersectionState { NONE = 'none', } -interface IHashedTile extends ITile { - hash: string; -} - interface IFootprintIntersectionParams { footprint: Polygon | Feature; maxZoom: number; @@ -25,92 +20,27 @@ interface IFootprintIntersectionParams { /** * class for generating and decoding tile hashes */ -export class TileHasher { - /* - tile hash structure: - zoom 0: - --------- - | 0 | 1 | - --------- - zoom 1: - --------------------- - | 02 | 03 | 12 | 13 | - --------------------- - | 00 | 01 | 10 | 11 | - --------------------- - .... - */ - - private readonly hashDigits = { - '0': 0, - '1': 1, - '2': 2, - '3': 3, - }; - +export class TileRanger { /** - * encodes tile to its hash + * converts tile to tile range of specified zoom level * @param tile - * @returns - */ - public encodeTile(tile: ITile): string { - let x = tile.x; - let y = tile.y; - let hash = ''; - for (let z = tile.zoom; z >= 0; z--) { - const xDigit = x & 1; - const yDigit = (y & 1) << 1; - const digit = xDigit + yDigit; - hash = `${digit}${hash}`; - x = x >> 1; - y = y >> 1; - } - return hash; - } - - /** - * decodes tile hash to tile - * @param hash - * @returns - */ - public decodeTile(hash: string): ITile { - let x = 0; - let y = 0; - - for (const char of hash) { - const digit = this.hashDigits[char as HashDigits]; - x = (x << 1) + (digit & 1); - y = (y << 1) + (digit >> 1); - } - - return { - x, - y, - zoom: hash.length - 1, - }; - } - - /** - * decodes hash to tile range of specified zoom level - * @param hash * @param zoom target tile range zoom * @returns */ - public decodeTileRange(hash: string, zoom: number): ITileRange { - const baseTile = this.decodeTile(hash); + public tileToRange(tile: ITile, zoom: number): ITileRange { let minX: number, minY: number, maxX: number, maxY: number; - minX = baseTile.x; - maxX = baseTile.x + 1; - minY = baseTile.y; - maxY = baseTile.y + 1; - if (baseTile.zoom < zoom) { - const dz = zoom - baseTile.zoom; + minX = tile.x; + maxX = tile.x + 1; + minY = tile.y; + maxY = tile.y + 1; + if (tile.zoom < zoom) { + const dz = zoom - tile.zoom; minX = minX << dz; maxX = maxX << dz; minY = minY << dz; maxY = maxY << dz; - } else if (baseTile.zoom > zoom) { - const dz = baseTile.zoom - zoom; + } else if (tile.zoom > zoom) { + const dz = tile.zoom - zoom; minX = minX >> dz; minY = minY >> dz; maxX = minX + 1; @@ -125,43 +55,22 @@ export class TileHasher { }; } - /** - * returns hash bbox - * @param hash - * @returns - */ - public decodeBbox(hash: string): BBox2d { - const tile = this.decodeTile(hash); - return tileToBbox(tile); - } - - /** - * generate tile hashes - * @param bbox bbox to cover with generated tile hashes - * @param zoom max hash zoom - * @returns - */ - public encodeBBox(bbox: BBox2d, zoom: number): Generator { - const boundingRange = bboxToTileRange(bbox, zoom); - return this.generateHashes(bbox, zoom, boundingRange, this.tileRangeIntersection); - } - /** * generate tile hashes * @param footprint footprint to cover with generated tile hashes * @param zoom max hash zoom * @returns */ - public encodeFootprint(footprint: Polygon | Feature, zoom: number): Generator { + public *encodeFootprint(footprint: Polygon | Feature, zoom: number): Generator { const bbox = polygonToBbox(footprint) as BBox2d; if (this.isBbox(footprint)) { - return this.encodeBBox(bbox, zoom); + yield bboxToTileRange(bbox, zoom); } else { const intersectionParams: IFootprintIntersectionParams = { footprint, maxZoom: zoom, }; - return this.generateHashes(bbox, zoom, intersectionParams, this.tileFootprintIntersection); + yield* this.generateRanges(bbox, zoom, intersectionParams, this.tileFootprintIntersection); } } @@ -178,18 +87,17 @@ export class TileHasher { */ public generateTiles(footprint: Polygon | Feature, zoom: number): Generator; public generateTiles(area: BBox2d | Polygon | Feature, zoom: number): Generator { - let gen: Generator; + let gen: Iterable; if (Array.isArray(area)) { - gen = this.encodeBBox(area, zoom); + gen = [bboxToTileRange(area, zoom)]; } else { gen = this.encodeFootprint(area, zoom); } return this.tilesGenerator(gen, zoom); } - private *tilesGenerator(hashGen: Generator, targetZoom: number): Generator { - for (const hash of hashGen) { - const range = this.decodeTileRange(hash, targetZoom); + private *tilesGenerator(rangeGen: Iterable, targetZoom: number): Generator { + for (const range of rangeGen) { for (let x = range.minX; x < range.maxX; x++) { for (let y = range.minY; y < range.maxY; y++) { yield { @@ -202,12 +110,12 @@ export class TileHasher { } } - private *generateHashes( + private *generateRanges( bbox: BBox2d, zoom: number, intersectionTarget: T, intersectionFunction: TileIntersectionFunction - ): Generator { + ): Generator { const boundingRange = bboxToTileRange(bbox, zoom); //find minimal zoom where the the area can be converted by area the size of single tile to skip levels that cant have full hashes const dx = boundingRange.maxX - boundingRange.minX; @@ -223,78 +131,41 @@ export class TileHasher { const tile = { x, y, zoom: minimalRange.zoom }; const intersection = intersectionFunction(tile, intersectionTarget); if (intersection === TileIntersectionState.FULL) { - yield this.encodeTile(tile); + yield this.tileToRange(tile, zoom); } else if (intersection === TileIntersectionState.PARTIAL) { //optimize partial base hashes - yield* this.optimizeHash({ ...tile, hash: this.encodeTile(tile) }, intersectionTarget, intersectionFunction); + yield* this.optimizeHash(tile, zoom, intersectionTarget, intersectionFunction); } } } } - private *optimizeHash(tile: IHashedTile, intersectionTarget: T, intersectionFunction: TileIntersectionFunction): Generator { + private *optimizeHash( + tile: ITile, + targetZoom: number, + intersectionTarget: T, + intersectionFunction: TileIntersectionFunction + ): Generator { const tiles = this.generateSubTiles(tile); for (const subTile of tiles) { const intersection = intersectionFunction(subTile, intersectionTarget); if (intersection === TileIntersectionState.FULL) { - yield subTile.hash; + yield this.tileToRange(subTile, targetZoom); } else if (intersection === TileIntersectionState.PARTIAL) { - yield* this.optimizeHash(subTile, intersectionTarget, intersectionFunction); + yield* this.optimizeHash(subTile, targetZoom, intersectionTarget, intersectionFunction); } } } - private generateSubTiles(tile: IHashedTile): IHashedTile[] { - const tile0 = { x: tile.x << 1, y: tile.y << 1, zoom: tile.zoom + 1, hash: tile.hash + '0' }; - const tile1 = { x: tile0.x + 1, y: tile0.y, zoom: tile0.zoom, hash: tile.hash + '1' }; - const tile2 = { x: tile0.x, y: tile0.y + 1, zoom: tile0.zoom, hash: tile.hash + '2' }; - const tile3 = { x: tile0.x + 1, y: tile0.y + 1, zoom: tile0.zoom, hash: tile.hash + '3' }; + private generateSubTiles(tile: ITile): ITile[] { + const tile0 = { x: tile.x << 1, y: tile.y << 1, zoom: tile.zoom + 1 }; + const tile1 = { x: tile0.x + 1, y: tile0.y, zoom: tile0.zoom }; + const tile2 = { x: tile0.x, y: tile0.y + 1, zoom: tile0.zoom }; + const tile3 = { x: tile0.x + 1, y: tile0.y + 1, zoom: tile0.zoom }; const tiles = [tile0, tile1, tile2, tile3]; return tiles; } - private readonly tileRangeIntersection = (tile: ITile, range: ITileRange): TileIntersectionState => { - if (tile.zoom < range.zoom) { - const dz = range.zoom - tile.zoom; - const tileSize = (1 << dz) - 1; - const startTile = { - x: tile.x << dz, - y: tile.y << dz, - zoom: range.zoom, - }; - const endTile = { - x: startTile.x + tileSize, - y: startTile.y + tileSize, - zoom: startTile.zoom, - }; - const startIncluded = this.sameZoomTileRangeIntersection(startTile, range); - const endIncluded = this.sameZoomTileRangeIntersection(endTile, range); - if (startIncluded && endIncluded) { - return TileIntersectionState.FULL; - } else if (startIncluded || endIncluded) { - return TileIntersectionState.PARTIAL; - } else if (endTile.x <= range.minX || startTile.x >= range.maxX || endTile.y <= range.minY || startTile.y >= range.maxY) { - return TileIntersectionState.NONE; - } else { - return TileIntersectionState.PARTIAL; - } - } else if (tile.zoom > range.zoom) { - const dz = tile.zoom - range.zoom; - range = { - minX: range.minX << dz, - maxX: range.maxX << dz, - minY: range.minY << dz, - maxY: range.maxX << dz, - zoom: tile.zoom, - }; - } - return this.sameZoomTileRangeIntersection(tile, range) ? TileIntersectionState.FULL : TileIntersectionState.NONE; - }; - - private sameZoomTileRangeIntersection(tile: ITile, range: ITileRange): boolean { - return tile.x >= range.minX && tile.x < range.maxX && tile.y >= range.minY && tile.y < range.maxY; - } - private readonly tileFootprintIntersection = (tile: ITile, intersectionParams: IFootprintIntersectionParams): TileIntersectionState => { const tileBbox = tileToBbox(tile); const tilePoly = bboxPolygon(tileBbox); diff --git a/tests/unit/geo/tileHasher.spec.ts b/tests/unit/geo/tileHasher.spec.ts deleted file mode 100644 index 8c9f18c..0000000 --- a/tests/unit/geo/tileHasher.spec.ts +++ /dev/null @@ -1,244 +0,0 @@ -import { BBox2d } from '@turf/helpers/dist/js/lib/geojson'; -import { bboxPolygon, featureCollection, polygon } from '@turf/turf'; -import { getHashes } from 'crypto'; -import { fstat, writeFileSync } from 'fs'; -import { snapBBoxToTileGrid } from '../../../src'; -import { TileHasher } from '../../../src/geo/tileHasher'; - -describe('TileHasher', () => { - let hasher: TileHasher; - - beforeEach(() => { - hasher = new TileHasher(); - }); - - describe('encodeTile', () => { - it('returns expected hash', () => { - const tile = { - x: 3, - y: 1, - zoom: 2, - }; - - const hash = hasher.encodeTile(tile); - - expect(hash).toEqual('013'); - }); - }); - - describe('decodeTile', () => { - it('returns expected tile', () => { - const tile = hasher.decodeTile('013'); - - const expectedTile = { - x: 3, - y: 1, - zoom: 2, - }; - expect(tile).toEqual(expectedTile); - }); - }); - - describe('decodeTileRange', () => { - it('return expcted tile range', () => { - const range = hasher.decodeTileRange('013', 3); - - const expectedTileRange = { - minX: 6, - minY: 2, - maxX: 8, - maxY: 4, - zoom: 3, - }; - expect(range).toEqual(expectedTileRange); - }); - }); - - describe('decodeBbox', () => { - it('retuns bbox of encoded tile', () => { - const bbox = hasher.decodeBbox('013'); - - const expectedBbox = [-45, -45, 0, 0]; - expect(bbox).toEqual(expectedBbox); - }); - }); - - describe('encodeBbox', () => { - it('generates optimized tile hash cover for bbox', () => { - const bbox = [0, 0, 45, 50.626] as BBox2d; - const tileHashes = []; - - const hashGen = hasher.encodeBBox(bbox, 6); - for (const hash of hashGen) { - tileHashes.push(hash); - } - const expectedHashes = [ - '120', - '122000', - '122001', - '1220020', - '1220021', - '1220030', - '1220031', - '122010', - '122011', - '1220120', - '1220121', - '1220130', - '1220131', - '122100', - '122101', - '1221020', - '1221021', - '1221030', - '1221031', - '122110', - '122111', - '1221120', - '1221121', - '1221130', - '1221131', - ]; - expect(tileHashes).toEqual(expectedHashes); - }); - }); - - describe('encodeFootprint', () => { - it('encode rectangle polygon calls encode bbox', () => { - const encodeBboxMock = jest.fn(); - hasher.encodeBBox = encodeBboxMock; - const bbox = [0, 0, 1, 1] as BBox2d; - const bboxPoly = bboxPolygon(bbox); - - hasher.encodeFootprint(bboxPoly, 8); - - expect(encodeBboxMock).toHaveBeenCalledTimes(1); - expect(encodeBboxMock).toHaveBeenCalledWith(bbox, 8); - }); - - it('encodes none bbox polygon properly', () => { - const encodeBboxMock = jest.fn(); - hasher.encodeBBox = encodeBboxMock; - const poly = polygon([ - [ - [-45, 0], - [0, 45], - [45, 0], - [-45, 0], - ], - ]); - - const tileHashes = []; - const gen = hasher.encodeFootprint(poly, 5); - for (const hash of gen) { - tileHashes.push(hash); - } - const expectedHashes = [ - '031000', - '031001', - '031003', - '03101', - '031030', - '031031', - '031033', - '0311', - '031300', - '031301', - '031303', - '03131', - '031330', - '031331', - '031333', - '1200', - '12010', - '120110', - '120111', - '120112', - '120120', - '120121', - '120122', - '12020', - '120210', - '120211', - '120212', - '120220', - '120221', - '120222', - ]; - expect(tileHashes).toEqual(expectedHashes); - expect(encodeBboxMock).toHaveBeenCalledTimes(0); - }); - }); - - describe('generateTiles', () => { - it('generates expected tiles from bbox', () => { - const bbox = [-45, -45, 0, 0] as BBox2d; - - const tiles = []; - const gen = hasher.generateTiles(bbox, 2); - for (const tile of gen) { - tiles.push(tile); - } - - const expectedTiles = [ - { - x: 3, - y: 1, - zoom: 2, - }, - ]; - expect(tiles).toEqual(expectedTiles); - }); - - it('generates expected tiles from bbox polygon', () => { - const bbox = [-45, -45, 0, 0] as BBox2d; - const poly = bboxPolygon(bbox); - - const tiles = []; - const gen = hasher.generateTiles(poly, 2); - for (const tile of gen) { - tiles.push(tile); - } - - const expectedTiles = [ - { - x: 3, - y: 1, - zoom: 2, - }, - ]; - expect(tiles).toEqual(expectedTiles); - }); - - it('generates expected tiles from none bbox polygon', () => { - const poly = polygon([ - [ - [-45, 0], - [0, 45], - [45, 0], - [-45, 0], - ], - ]); - - const tiles = []; - const gen = hasher.generateTiles(poly, 2); - for (const tile of gen) { - tiles.push(tile); - } - - const expectedTiles = [ - { - x: 3, - y: 2, - zoom: 2, - }, - { - x: 4, - y: 2, - zoom: 2, - }, - ]; - expect(tiles).toEqual(expectedTiles); - }); - }); -}); diff --git a/tests/unit/geo/tileRanger.spec.ts b/tests/unit/geo/tileRanger.spec.ts new file mode 100644 index 0000000..369662b --- /dev/null +++ b/tests/unit/geo/tileRanger.spec.ts @@ -0,0 +1,217 @@ +import { BBox2d } from '@turf/helpers/dist/js/lib/geojson'; +import { bboxPolygon, polygon } from '@turf/turf'; +import { TileRanger } from '../../../src/geo/tileRanger'; + +describe('TileRanger', () => { + let ranger: TileRanger; + + beforeEach(() => { + ranger = new TileRanger(); + }); + + describe('tileToRange', () => { + it('create rage with same zoom', () => { + const tile = { + x: 1, + y: 1, + zoom: 2, + }; + + const range = ranger.tileToRange(tile, 2); + + const expectedRange = { + minX: 1, + minY: 1, + maxX: 2, + maxY: 2, + zoom: 2, + }; + expect(range).toEqual(expectedRange); + }); + + it('create rage with higher zoom', () => { + const tile = { + x: 1, + y: 1, + zoom: 2, + }; + + const range = ranger.tileToRange(tile, 3); + + const expectedRange = { + minX: 2, + minY: 2, + maxX: 4, + maxY: 4, + zoom: 3, + }; + expect(range).toEqual(expectedRange); + }); + + it('create rage with lower zoom', () => { + const tile = { + x: 1, + y: 1, + zoom: 2, + }; + + const range = ranger.tileToRange(tile, 1); + + const expectedRange = { + minX: 0, + minY: 0, + maxX: 1, + maxY: 1, + zoom: 1, + }; + expect(range).toEqual(expectedRange); + }); + }); + + describe('encodeFootprint', () => { + it('encode rectangle polygon calls encode bbox', () => { + const bbox = [-45, 0, 45, 45] as BBox2d; + const bboxPoly = bboxPolygon(bbox); + + const ranges = []; + const gen = ranger.encodeFootprint(bboxPoly, 2); + for (const range of gen) { + ranges.push(range); + } + + const expectedRanges = [ + { + minX: 3, + maxX: 5, + minY: 2, + maxY: 3, + zoom: 2, + }, + ]; + expect(ranges).toEqual(expectedRanges); + }); + + it('encodes none bbox polygon properly', () => { + const poly = polygon([ + [ + [-45, 0], + [0, 45], + [45, 0], + [-45, 0], + ], + ]); + + const tileRanges = []; + const gen = ranger.encodeFootprint(poly, 5); + for (const range of gen) { + tileRanges.push(range); + } + + const expectedRanges = [ + { minX: 24, minY: 16, maxX: 25, maxY: 17, zoom: 5 }, + { minX: 25, minY: 16, maxX: 26, maxY: 17, zoom: 5 }, + { minX: 25, minY: 17, maxX: 26, maxY: 18, zoom: 5 }, + { minX: 26, minY: 16, maxX: 28, maxY: 18, zoom: 5 }, + { minX: 26, minY: 18, maxX: 27, maxY: 19, zoom: 5 }, + { minX: 27, minY: 18, maxX: 28, maxY: 19, zoom: 5 }, + { minX: 27, minY: 19, maxX: 28, maxY: 20, zoom: 5 }, + { minX: 28, minY: 16, maxX: 32, maxY: 20, zoom: 5 }, + { minX: 28, minY: 20, maxX: 29, maxY: 21, zoom: 5 }, + { minX: 29, minY: 20, maxX: 30, maxY: 21, zoom: 5 }, + { minX: 29, minY: 21, maxX: 30, maxY: 22, zoom: 5 }, + { minX: 30, minY: 20, maxX: 32, maxY: 22, zoom: 5 }, + { minX: 30, minY: 22, maxX: 31, maxY: 23, zoom: 5 }, + { minX: 31, minY: 22, maxX: 32, maxY: 23, zoom: 5 }, + { minX: 31, minY: 23, maxX: 32, maxY: 24, zoom: 5 }, + { minX: 32, minY: 16, maxX: 36, maxY: 20, zoom: 5 }, + { minX: 36, minY: 16, maxX: 38, maxY: 18, zoom: 5 }, + { minX: 38, minY: 16, maxX: 39, maxY: 17, zoom: 5 }, + { minX: 39, minY: 16, maxX: 40, maxY: 17, zoom: 5 }, + { minX: 38, minY: 17, maxX: 39, maxY: 18, zoom: 5 }, + { minX: 36, minY: 18, maxX: 37, maxY: 19, zoom: 5 }, + { minX: 37, minY: 18, maxX: 38, maxY: 19, zoom: 5 }, + { minX: 36, minY: 19, maxX: 37, maxY: 20, zoom: 5 }, + { minX: 32, minY: 20, maxX: 34, maxY: 22, zoom: 5 }, + { minX: 34, minY: 20, maxX: 35, maxY: 21, zoom: 5 }, + { minX: 35, minY: 20, maxX: 36, maxY: 21, zoom: 5 }, + { minX: 34, minY: 21, maxX: 35, maxY: 22, zoom: 5 }, + { minX: 32, minY: 22, maxX: 33, maxY: 23, zoom: 5 }, + { minX: 33, minY: 22, maxX: 34, maxY: 23, zoom: 5 }, + { minX: 32, minY: 23, maxX: 33, maxY: 24, zoom: 5 }, + ]; + expect(tileRanges).toEqual(expectedRanges); + }); + }); + + describe('generateTiles', () => { + it('generates expected tiles from bbox', () => { + const bbox = [-45, -45, 0, 0] as BBox2d; + + const tiles = []; + const gen = ranger.generateTiles(bbox, 2); + for (const tile of gen) { + tiles.push(tile); + } + + const expectedTiles = [ + { + x: 3, + y: 1, + zoom: 2, + }, + ]; + expect(tiles).toEqual(expectedTiles); + }); + + it('generates expected tiles from bbox polygon', () => { + const bbox = [-45, -45, 0, 0] as BBox2d; + const poly = bboxPolygon(bbox); + + const tiles = []; + const gen = ranger.generateTiles(poly, 2); + for (const tile of gen) { + tiles.push(tile); + } + + const expectedTiles = [ + { + x: 3, + y: 1, + zoom: 2, + }, + ]; + expect(tiles).toEqual(expectedTiles); + }); + + it('generates expected tiles from none bbox polygon', () => { + const poly = polygon([ + [ + [-45, 0], + [0, 45], + [45, 0], + [-45, 0], + ], + ]); + + const tiles = []; + const gen = ranger.generateTiles(poly, 2); + for (const tile of gen) { + tiles.push(tile); + } + + const expectedTiles = [ + { + x: 3, + y: 2, + zoom: 2, + }, + { + x: 4, + y: 2, + zoom: 2, + }, + ]; + expect(tiles).toEqual(expectedTiles); + }); + }); +}); From 11bc6e8e4c99f950daacb24171e3024db32b216a Mon Sep 17 00:00:00 2001 From: Shahar s Date: Thu, 12 Aug 2021 09:56:04 +0300 Subject: [PATCH 3/4] chore: lint --- src/geo/geoConvertor.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/geo/geoConvertor.ts b/src/geo/geoConvertor.ts index 5860a28..7ef1c05 100644 --- a/src/geo/geoConvertor.ts +++ b/src/geo/geoConvertor.ts @@ -18,6 +18,7 @@ export const degreesToTile = (point: IPoint, zoomLevel: number, origin: TileOrig } const xTile = point.longitude / resolution + (1 << zoomLevel); + // eslint-disable-next-line @typescript-eslint/no-magic-numbers const yTile = zoomLevel != 0 ? lat / resolution + (1 << (zoomLevel - 1)) : lat / resolution + 0.5; return { From 2fc90999cee6cc7f5cd2b77855a3b980a06f3b28 Mon Sep 17 00:00:00 2001 From: Shahar s Date: Thu, 12 Aug 2021 10:50:44 +0300 Subject: [PATCH 4/4] chore: fix typos --- tests/unit/geo/tileRanger.spec.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/unit/geo/tileRanger.spec.ts b/tests/unit/geo/tileRanger.spec.ts index 369662b..655fc8b 100644 --- a/tests/unit/geo/tileRanger.spec.ts +++ b/tests/unit/geo/tileRanger.spec.ts @@ -10,7 +10,7 @@ describe('TileRanger', () => { }); describe('tileToRange', () => { - it('create rage with same zoom', () => { + it('create range with same zoom', () => { const tile = { x: 1, y: 1, @@ -29,7 +29,7 @@ describe('TileRanger', () => { expect(range).toEqual(expectedRange); }); - it('create rage with higher zoom', () => { + it('create range with higher zoom', () => { const tile = { x: 1, y: 1, @@ -48,7 +48,7 @@ describe('TileRanger', () => { expect(range).toEqual(expectedRange); }); - it('create rage with lower zoom', () => { + it('create range with lower zoom', () => { const tile = { x: 1, y: 1,