Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 42 additions & 7 deletions src/geo/bboxUtils.ts
Original file line number Diff line number Diff line change
@@ -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 => {
Expand All @@ -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;
};

Expand All @@ -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,
};
};
3 changes: 2 additions & 1 deletion src/geo/geoConvertor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ export const degreesToTile = (point: IPoint, zoomLevel: number, origin: TileOrig
}

const xTile = point.longitude / resolution + (1 << zoomLevel);
const yTile = lat / resolution + (1 << (zoomLevel - 1));
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
const yTile = zoomLevel != 0 ? lat / resolution + (1 << (zoomLevel - 1)) : lat / resolution + 0.5;

return {
x: Math.floor(xTile),
Expand Down
2 changes: 1 addition & 1 deletion src/geo/geoHash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions src/geo/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export * from './bboxUtils';
export * from './geoConvertor';
export * from './geoHash';
export * from './tiles';
export * from './tileRanger';
192 changes: 192 additions & 0 deletions src/geo/tileRanger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
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 TileIntersectionFunction<T> = (tile: ITile, intersectionTarget: T) => TileIntersectionState;

enum TileIntersectionState {
FULL = 'full',
PARTIAL = 'partial',
NONE = 'none',
}

interface IFootprintIntersectionParams {
footprint: Polygon | Feature<Polygon | MultiPolygon>;
maxZoom: number;
}

/**
* class for generating and decoding tile hashes
*/
export class TileRanger {
/**
* converts tile to tile range of specified zoom level
* @param tile
* @param zoom target tile range zoom
* @returns
*/
public tileToRange(tile: ITile, zoom: number): ITileRange {
let minX: number, minY: number, maxX: number, maxY: number;
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 (tile.zoom > zoom) {
const dz = tile.zoom - zoom;
minX = minX >> dz;
minY = minY >> dz;
maxX = minX + 1;
maxY = minY + 1;
}
return {
minX,
minY,
maxX,
maxY,
zoom,
};
}

/**
* generate tile hashes
* @param footprint footprint to cover with generated tile hashes
* @param zoom max hash zoom
* @returns
*/
public *encodeFootprint(footprint: Polygon | Feature<Polygon | MultiPolygon>, zoom: number): Generator<ITileRange> {
const bbox = polygonToBbox(footprint) as BBox2d;
if (this.isBbox(footprint)) {
yield bboxToTileRange(bbox, zoom);
} else {
const intersectionParams: IFootprintIntersectionParams = {
footprint,
maxZoom: zoom,
};
yield* this.generateRanges(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<ITile>;
/**
* generate tile
* @param footprint footprint to cover with generated tiles
* @param zoom target tiles zoom level
*/
public generateTiles(footprint: Polygon | Feature<Polygon | MultiPolygon>, zoom: number): Generator<ITile>;
public generateTiles(area: BBox2d | Polygon | Feature<Polygon | MultiPolygon>, zoom: number): Generator<ITile> {
let gen: Iterable<ITileRange>;
if (Array.isArray(area)) {
gen = [bboxToTileRange(area, zoom)];
} else {
gen = this.encodeFootprint(area, zoom);
}
return this.tilesGenerator(gen, zoom);
}

private *tilesGenerator(rangeGen: Iterable<ITileRange>, targetZoom: number): Generator<ITile> {
for (const range of rangeGen) {
for (let x = range.minX; x < range.maxX; x++) {
for (let y = range.minY; y < range.maxY; y++) {
yield {
x,
y,
zoom: targetZoom,
};
}
}
}
}

private *generateRanges<T>(
bbox: BBox2d,
zoom: number,
intersectionTarget: T,
intersectionFunction: TileIntersectionFunction<T>
): Generator<ITileRange> {
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.tileToRange(tile, zoom);
} else if (intersection === TileIntersectionState.PARTIAL) {
//optimize partial base hashes
yield* this.optimizeHash(tile, zoom, intersectionTarget, intersectionFunction);
}
}
}
}

private *optimizeHash<T>(
tile: ITile,
targetZoom: number,
intersectionTarget: T,
intersectionFunction: TileIntersectionFunction<T>
): Generator<ITileRange> {
const tiles = this.generateSubTiles(tile);
for (const subTile of tiles) {
const intersection = intersectionFunction(subTile, intersectionTarget);
if (intersection === TileIntersectionState.FULL) {
yield this.tileToRange(subTile, targetZoom);
} else if (intersection === TileIntersectionState.PARTIAL) {
yield* this.optimizeHash(subTile, targetZoom, intersectionTarget, intersectionFunction);
}
}
}

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 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<Polygon | MultiPolygon>): boolean {
const bbox = polygonToBbox(footprint);
const bboxPoly = bboxPolygon(bbox);
return booleanEqual(footprint, bboxPoly);
}
}
13 changes: 13 additions & 0 deletions src/geo/tiles.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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];
}
8 changes: 8 additions & 0 deletions src/models/interfaces/geo/iTile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,11 @@ export interface ITile {
y: number;
zoom: number;
}

export interface ITileRange {
minX: number;
minY: number;
maxX: number;
maxY: number;
zoom: number;
}
65 changes: 64 additions & 1 deletion tests/unit/geo/bboxUtils.spec.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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);
});
});
});
Loading