diff --git a/src/sources/batch-tiler.ts b/src/sources/batch-tiler.ts new file mode 100644 index 00000000..795a8127 --- /dev/null +++ b/src/sources/batch-tiler.ts @@ -0,0 +1,115 @@ +// Client helpers for the batched-tiles fast path (server: maps-api sc-556575). +// +// deck.gl requests a viewport's tiles in one burst; per-tile serving turns that +// into N warehouse queries — ruinous on job-based engines. When the source is +// batch-capable the tilejson carries a `tiles_batch` URL; the client coalesces +// the burst into ONE request to it (`&tiles=z/x/y,z/x/y,…`, sorted so a repeated +// viewport is a repeated URL ⇒ CDN-cacheable) and splits the CDTB container the +// server returns, handing each payload to the format's normal tile decoder. +// +// These are the pure pieces (detect / build request / split response); the +// request coalescing itself lives in the deck.gl layer. + +import type {TilejsonResult} from './types.js'; + +export interface BatchTileIndex { + z: number; + x: number; + y: number; +} + +/** + * A source is batch-capable when the server advertised a `tiles_batch` endpoint + * in the tilejson (PostgreSQL/BigQuery, binary/geojson). Absent ⇒ the consumer + * must use the per-tile `tiles` template. + */ +export function isBatchTilesTilejson(tilejson: TilejsonResult): boolean { + return Boolean(tilejson.tiles_batch); +} + +/** + * Build the URL for a batch of tiles: the advertised `tiles_batch` endpoint with + * `&tiles=z/x/y,…` appended. Tiles are sorted (z, then x, then y) so the same + * viewport always yields the same URL — batches stay as CDN-cacheable as single + * tiles. The caller is responsible for keeping the batch within `tiles_batch_max`. + */ +export function buildBatchTilesRequest( + tilejson: TilejsonResult, + tiles: BatchTileIndex[] +): string { + if (!tilejson.tiles_batch) { + throw new Error('tilejson has no batch endpoint (tiles_batch)'); + } + if (tiles.length === 0) { + throw new Error('Cannot build a batch request with zero tiles'); + } + const list = [...tiles] + .sort((a, b) => a.z - b.z || a.x - b.x || a.y - b.y) + .map((t) => `${t.z}/${t.x}/${t.y}`) + .join(','); + const separator = tilejson.tiles_batch.includes('?') ? '&' : '?'; + return `${tilejson.tiles_batch}${separator}tiles=${list}`; +} + +const CDTB_MAGIC = [0x43, 0x44, 0x54, 0x42]; // "CDTB" + +/** Key a tile by its `z/x/y` (matches the strings in a batch request). */ +export function batchTileKey(tile: BatchTileIndex): string { + return `${tile.z}/${tile.x}/${tile.y}`; +} + +/** + * Split the CDTB container the batch endpoint returns into per-tile payloads, + * keyed by `z/x/y`. Each payload is the same bytes a single-tile request would + * have returned, ready for the format's normal decoder. + * + * Container layout (little-endian): + * "CDTB" | u32 tileCount | per tile: u8 z | u32 x | u32 y | u32 len | bytes + */ +export function splitBatchTilesResponse( + data: ArrayBuffer +): Map { + const bytes = new Uint8Array(data); + if ( + bytes.length < 8 || + bytes[0] !== CDTB_MAGIC[0] || + bytes[1] !== CDTB_MAGIC[1] || + bytes[2] !== CDTB_MAGIC[2] || + bytes[3] !== CDTB_MAGIC[3] + ) { + throw new Error('Invalid CDTB batch container'); + } + const view = new DataView(data); + let offset = 4; + const tileCount = view.getUint32(offset, true); + offset += 4; + const tiles = new Map(); + // Each tile header is 13 bytes (u8 z + u32 x + u32 y + u32 len). Bounds-check + // before every read and before slicing the payload so a truncated or + // malformed container throws a clear error instead of a generic DataView + // RangeError or a silently truncated payload. + const TILE_HEADER_BYTES = 13; + for (let i = 0; i < tileCount; i++) { + if (offset + TILE_HEADER_BYTES > data.byteLength) { + throw new Error( + 'Malformed CDTB batch container: unexpected end of metadata' + ); + } + const z = view.getUint8(offset); + offset += 1; + const x = view.getUint32(offset, true); + offset += 4; + const y = view.getUint32(offset, true); + offset += 4; + const length = view.getUint32(offset, true); + offset += 4; + if (offset + length > data.byteLength) { + throw new Error( + 'Malformed CDTB batch container: unexpected end of payload' + ); + } + tiles.set(`${z}/${x}/${y}`, data.slice(offset, offset + length)); + offset += length; + } + return tiles; +} diff --git a/src/sources/index.ts b/src/sources/index.ts index b0a33c67..b6c05a9c 100644 --- a/src/sources/index.ts +++ b/src/sources/index.ts @@ -20,6 +20,13 @@ import {vectorTilesetSource} from './vector-tileset-source.js'; export {SOURCE_DEFAULTS} from './base-source.js'; export {RasterBandColorinterp} from './constants.js'; +export { + isBatchTilesTilejson, + buildBatchTilesRequest, + splitBatchTilesResponse, + batchTileKey, +} from './batch-tiler.js'; +export type {BatchTileIndex} from './batch-tiler.js'; export type { SourceOptions, SourceRequiredOptions, diff --git a/src/sources/types.ts b/src/sources/types.ts index 143bf781..637ae15a 100644 --- a/src/sources/types.ts +++ b/src/sources/types.ts @@ -252,6 +252,18 @@ export interface Tilejson { tilestats: Tilestats; tileResolution?: TileResolution; + /** + * Batched-tiles fast path. When present, a consumer may coalesce a viewport's + * tiles into one request to this URL by appending `&tiles=z/x/y,z/x/y,…` + * (sorted), up to `tiles_batch_max` tiles. Absent ⇒ use the per-tile `tiles` + * template. Served for PostgreSQL/BigQuery sources in binary/geojson. + * + * @internal + */ + tiles_batch?: string; + /** Max tiles accepted in a single `tiles_batch` request. @internal */ + tiles_batch_max?: number; + /** * Resolution of data in spatial-index dataset (e.g. H3, Quadbin). * diff --git a/test/sources/batch-tiler.test.ts b/test/sources/batch-tiler.test.ts new file mode 100644 index 00000000..a9c4f497 --- /dev/null +++ b/test/sources/batch-tiler.test.ts @@ -0,0 +1,141 @@ +import {describe, test, expect} from 'vitest'; +import { + isBatchTilesTilejson, + buildBatchTilesRequest, + splitBatchTilesResponse, + batchTileKey, +} from '@carto/api-client'; +import type {TilejsonResult} from '@carto/api-client'; + +// Build a CDTB container exactly as the maps-api endpoint does, so the splitter +// is tested against the real wire layout (little-endian): +// "CDTB" | u32 count | per tile: u8 z | u32 x | u32 y | u32 len | bytes +function makeCDTB( + tiles: {z: number; x: number; y: number; payload: number[]}[] +): ArrayBuffer { + let size = 8; + for (const t of tiles) size += 13 + t.payload.length; + const buf = new ArrayBuffer(size); + const view = new DataView(buf); + const bytes = new Uint8Array(buf); + bytes.set([0x43, 0x44, 0x54, 0x42], 0); // "CDTB" + let off = 4; + view.setUint32(off, tiles.length, true); + off += 4; + for (const t of tiles) { + view.setUint8(off, t.z); + off += 1; + view.setUint32(off, t.x, true); + off += 4; + view.setUint32(off, t.y, true); + off += 4; + view.setUint32(off, t.payload.length, true); + off += 4; + bytes.set(t.payload, off); + off += t.payload.length; + } + return buf; +} + +const batchTilejson = { + tiles: ['https://x/{z}/{x}/{y}?name=t&formatTiles=binary'], + tiles_batch: + 'https://x/table/tiles?name=t&geomType=points&formatTiles=binary', + tiles_batch_max: 32, +} as unknown as TilejsonResult; + +describe('batch-tiler', () => { + describe('isBatchTilesTilejson', () => { + test('true when tiles_batch is advertised', () => { + expect(isBatchTilesTilejson(batchTilejson)).toBe(true); + }); + test('false when absent', () => { + expect( + isBatchTilesTilejson({ + tiles: ['https://x/{z}/{x}/{y}'], + } as unknown as TilejsonResult) + ).toBe(false); + }); + }); + + describe('buildBatchTilesRequest', () => { + test('sorts tiles (z,x,y) and appends &tiles=', () => { + const url = buildBatchTilesRequest(batchTilejson, [ + {z: 10, x: 5, y: 9}, + {z: 8, x: 1, y: 1}, + {z: 10, x: 5, y: 1}, + {z: 10, x: 2, y: 0}, + ]); + expect(url).toBe( + 'https://x/table/tiles?name=t&geomType=points&formatTiles=binary&tiles=8/1/1,10/2/0,10/5/1,10/5/9' + ); + }); + test('a repeated viewport (any order) yields the same URL', () => { + const a = buildBatchTilesRequest(batchTilejson, [ + {z: 1, x: 0, y: 0}, + {z: 1, x: 1, y: 0}, + ]); + const b = buildBatchTilesRequest(batchTilejson, [ + {z: 1, x: 1, y: 0}, + {z: 1, x: 0, y: 0}, + ]); + expect(a).toEqual(b); + }); + test('throws when the source is not batch-capable', () => { + expect(() => + buildBatchTilesRequest({tiles: []} as unknown as TilejsonResult, [ + {z: 0, x: 0, y: 0}, + ]) + ).toThrow(/tiles_batch/); + }); + test('throws when given zero tiles', () => { + expect(() => buildBatchTilesRequest(batchTilejson, [])).toThrow( + /zero tiles/ + ); + }); + }); + + describe('splitBatchTilesResponse', () => { + test('splits the CDTB container into per-tile payloads keyed by z/x/y', () => { + const buffer = makeCDTB([ + {z: 0, x: 0, y: 0, payload: [1, 2, 3]}, + {z: 10, x: 523, y: 350, payload: [9, 8, 7, 6]}, + ]); + const tiles = splitBatchTilesResponse(buffer); + expect([...tiles.keys()]).toEqual(['0/0/0', '10/523/350']); + expect([...new Uint8Array(tiles.get('0/0/0'))]).toEqual([1, 2, 3]); + expect([ + ...new Uint8Array(tiles.get(batchTileKey({z: 10, x: 523, y: 350}))), + ]).toEqual([9, 8, 7, 6]); + }); + test('handles an empty (zero-payload) tile', () => { + const tiles = splitBatchTilesResponse( + makeCDTB([{z: 5, x: 1, y: 1, payload: []}]) + ); + expect(tiles.get('5/1/1').byteLength).toEqual(0); + }); + test('throws on a non-CDTB buffer', () => { + const bad = new Uint8Array([0x00, 0x01, 0x02, 0x03, 0, 0, 0, 0]).buffer; + expect(() => splitBatchTilesResponse(bad)).toThrow(/CDTB/); + }); + test('throws on a truncated tile header', () => { + // Valid container, then cut mid-header (count says 1 tile, header is 13B + // starting at offset 8 — slice to 15 leaves only 7 of the 13). + const truncated = makeCDTB([ + {z: 0, x: 0, y: 0, payload: [1, 2, 3]}, + ]).slice(0, 15); + expect(() => splitBatchTilesResponse(truncated)).toThrow( + /unexpected end of metadata/ + ); + }); + test('throws on a truncated payload', () => { + // Full header (through offset 21) but the 3-byte payload is cut off. + const truncated = makeCDTB([ + {z: 0, x: 0, y: 0, payload: [1, 2, 3]}, + ]).slice(0, 22); + expect(() => splitBatchTilesResponse(truncated)).toThrow( + /unexpected end of payload/ + ); + }); + }); +});