From 956dd1a7be9f916272b5c6352909592a15ea13f8 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Mon, 27 Oct 2025 12:33:59 -0400 Subject: [PATCH 01/25] Vendor upstream implementation --- src/cog-tileset/cog-tileset-2d.ts | 75 ++++++ src/cog-tileset/index.ts | 0 src/cog-tileset/tile-2d-traversal.ts | 280 +++++++++++++++++++++ src/cog-tileset/types.ts | 35 +++ src/cog-tileset/utils.ts | 361 +++++++++++++++++++++++++++ 5 files changed, 751 insertions(+) create mode 100644 src/cog-tileset/cog-tileset-2d.ts create mode 100644 src/cog-tileset/index.ts create mode 100644 src/cog-tileset/tile-2d-traversal.ts create mode 100644 src/cog-tileset/types.ts create mode 100644 src/cog-tileset/utils.ts diff --git a/src/cog-tileset/cog-tileset-2d.ts b/src/cog-tileset/cog-tileset-2d.ts new file mode 100644 index 00000000..a08a232d --- /dev/null +++ b/src/cog-tileset/cog-tileset-2d.ts @@ -0,0 +1,75 @@ +import { Viewport } from "@deck.gl/core"; +import { _Tileset2D as Tileset2D } from "@deck.gl/geo-layers"; +import { TileIndex, ZRange } from "@deck.gl/geo-layers/dist/tileset-2d/types"; +import { Matrix4 } from "@math.gl/core"; + +import { getOSMTileIndices } from "./tile-2d-traversal"; + +type Bounds = [minX: number, minY: number, maxX: number, maxY: number]; + +export class COGTileset2D extends Tileset2D { + getTileIndices({ + viewport, + maxZoom, + minZoom, + zRange, + tileSize, + modelMatrix, + modelMatrixInverse, + zoomOffset, + }: { + viewport: Viewport; + maxZoom?: number; + minZoom?: number; + zRange: ZRange | null; + tileSize?: number; + modelMatrix?: Matrix4; + modelMatrixInverse?: Matrix4; + zoomOffset?: number; + }): TileIndex[] { + const { extent } = this.opts; + } +} + +const TILE_SIZE = 512; + +/** + * Returns all tile indices in the current viewport. If the current zoom level is smaller + * than minZoom, return an empty array. If the current zoom level is greater than maxZoom, + * return tiles that are on maxZoom. + */ + +export function getTileIndices({ + viewport, + maxZoom, + minZoom, + zRange, + extent, + tileSize = TILE_SIZE, + modelMatrix, + modelMatrixInverse, + zoomOffset = 0, +}: { + viewport: Viewport; + maxZoom?: number; + minZoom?: number; + zRange: ZRange | null; + extent?: Bounds; + tileSize?: number; + modelMatrix?: Matrix4; + modelMatrixInverse?: Matrix4; + zoomOffset?: number; +}) { + let z = + Math.round(viewport.zoom + Math.log2(TILE_SIZE / tileSize)) + zoomOffset; + if (typeof minZoom === "number" && Number.isFinite(minZoom) && z < minZoom) { + if (!extent) { + return []; + } + z = minZoom; + } + if (typeof maxZoom === "number" && Number.isFinite(maxZoom) && z > maxZoom) { + z = maxZoom; + } + return getOSMTileIndices(viewport, z, zRange, extent); +} diff --git a/src/cog-tileset/index.ts b/src/cog-tileset/index.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/cog-tileset/tile-2d-traversal.ts b/src/cog-tileset/tile-2d-traversal.ts new file mode 100644 index 00000000..57c95619 --- /dev/null +++ b/src/cog-tileset/tile-2d-traversal.ts @@ -0,0 +1,280 @@ +// deck.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import { Viewport, WebMercatorViewport, _GlobeViewport } from "@deck.gl/core"; +import { + CullingVolume, + Plane, + AxisAlignedBoundingBox, + makeOrientedBoundingBoxFromPoints, +} from "@math.gl/culling"; +import { lngLatToWorld } from "@math.gl/web-mercator"; + +import { Bounds, TileIndex, ZRange } from "./types.js"; +import { osmTile2lngLat } from "./utils.js"; + +const TILE_SIZE = 512; +// number of world copies to check +const MAX_MAPS = 3; +// for calculating bounding volume of a tile in a non-web-mercator viewport +const REF_POINTS_5 = [ + [0.5, 0.5], + [0, 0], + [0, 1], + [1, 0], + [1, 1], +]; // 4 corners and center +const REF_POINTS_9 = REF_POINTS_5.concat([ + [0, 0.5], + [0.5, 0], + [1, 0.5], + [0.5, 1], +]); // 4 corners, center and 4 mid points +const REF_POINTS_11 = REF_POINTS_9.concat([ + [0.25, 0.5], + [0.75, 0.5], +]); // 2 additional points on equator for top tile + +class OSMNode { + x: number; + y: number; + z: number; + + private childVisible?: boolean; + private selected?: boolean; + + private _children?: OSMNode[]; + + constructor(x: number, y: number, z: number) { + this.x = x; + this.y = y; + this.z = z; + } + + get children() { + if (!this._children) { + const x = this.x * 2; + const y = this.y * 2; + const z = this.z + 1; + this._children = [ + new OSMNode(x, y, z), + new OSMNode(x, y + 1, z), + new OSMNode(x + 1, y, z), + new OSMNode(x + 1, y + 1, z), + ]; + } + return this._children; + } + + update(params: { + viewport: Viewport; + project: ((xyz: number[]) => number[]) | null; + cullingVolume: CullingVolume; + elevationBounds: ZRange; + minZ: number; + maxZ: number; + bounds?: Bounds; + offset: number; + }) { + const { + viewport, + cullingVolume, + elevationBounds, + minZ, + maxZ, + bounds, + offset, + project, + } = params; + const boundingVolume = this.getBoundingVolume( + elevationBounds, + offset, + project, + ); + + // First, check if this tile is visible + if (bounds && !this.insideBounds(bounds)) { + return false; + } + + const isInside = cullingVolume.computeVisibility(boundingVolume); + if (isInside < 0) { + return false; + } + + // Avoid loading overlapping tiles - if a descendant is requested, do not request the ancester + if (!this.childVisible) { + let { z } = this; + if (z < maxZ && z >= minZ) { + // Adjust LOD + // If the tile is far enough from the camera, accept a lower zoom level + const distance = + (boundingVolume.distanceTo(viewport.cameraPosition) * + viewport.scale) / + viewport.height; + z += Math.floor(Math.log2(distance)); + } + if (z >= maxZ) { + // LOD is acceptable + this.selected = true; + return true; + } + } + + // LOD is not enough, recursively test child tiles + this.selected = false; + this.childVisible = true; + for (const child of this.children) { + child.update(params); + } + return true; + } + + getSelected(result: OSMNode[] = []): OSMNode[] { + if (this.selected) { + result.push(this); + } + if (this._children) { + for (const node of this._children) { + node.getSelected(result); + } + } + return result; + } + + insideBounds([minX, minY, maxX, maxY]: Bounds): boolean { + const scale = Math.pow(2, this.z); + const extent = TILE_SIZE / scale; + + return ( + this.x * extent < maxX && + this.y * extent < maxY && + (this.x + 1) * extent > minX && + (this.y + 1) * extent > minY + ); + } + + getBoundingVolume( + zRange: ZRange, + worldOffset: number, + project: ((xyz: number[]) => number[]) | null, + ) { + if (project) { + // Custom projection + // Estimate bounding box from sample points + // At low zoom level we need more samples to calculate the bounding volume correctly + const refPoints = + this.z < 1 ? REF_POINTS_11 : this.z < 2 ? REF_POINTS_9 : REF_POINTS_5; + + // Convert from tile-relative coordinates to common space + const refPointPositions: number[][] = []; + for (const p of refPoints) { + const lngLat: number[] = osmTile2lngLat( + this.x + p[0], + this.y + p[1], + this.z, + ); + lngLat[2] = zRange[0]; + refPointPositions.push(project(lngLat)); + + if (zRange[0] !== zRange[1]) { + // Account for the elevation volume + lngLat[2] = zRange[1]; + refPointPositions.push(project(lngLat)); + } + } + + return makeOrientedBoundingBoxFromPoints(refPointPositions); + } + + // Use WebMercator projection + const scale = Math.pow(2, this.z); + const extent = TILE_SIZE / scale; + const originX = this.x * extent + worldOffset * TILE_SIZE; + // deck's common space is y-flipped + const originY = TILE_SIZE - (this.y + 1) * extent; + + return new AxisAlignedBoundingBox( + [originX, originY, zRange[0]], + [originX + extent, originY + extent, zRange[1]], + ); + } +} + +export function getOSMTileIndices( + viewport: Viewport, + maxZ: number, + zRange: ZRange | null, + bounds?: Bounds, +): TileIndex[] { + const project: ((xyz: number[]) => number[]) | null = + viewport instanceof _GlobeViewport && viewport.resolution + ? viewport.projectPosition + : null; + + // Get the culling volume of the current camera + const planes: Plane[] = Object.values(viewport.getFrustumPlanes()).map( + ({ normal, distance }) => new Plane(normal.clone().negate(), distance), + ); + const cullingVolume = new CullingVolume(planes); + + // Project zRange from meters to common space + const unitsPerMeter = viewport.distanceScales.unitsPerMeter[2]; + const elevationMin = (zRange && zRange[0] * unitsPerMeter) || 0; + const elevationMax = (zRange && zRange[1] * unitsPerMeter) || 0; + + // Always load at the current zoom level if pitch is small + const minZ = + viewport instanceof WebMercatorViewport && viewport.pitch <= 60 ? maxZ : 0; + + // Map extent to OSM position + if (bounds) { + const [minLng, minLat, maxLng, maxLat] = bounds; + const topLeft = lngLatToWorld([minLng, maxLat]); + const bottomRight = lngLatToWorld([maxLng, minLat]); + bounds = [ + topLeft[0], + TILE_SIZE - topLeft[1], + bottomRight[0], + TILE_SIZE - bottomRight[1], + ]; + } + + const root = new OSMNode(0, 0, 0); + const traversalParams = { + viewport, + project, + cullingVolume, + elevationBounds: [elevationMin, elevationMax] as ZRange, + minZ, + maxZ, + bounds, + // num. of worlds from the center. For repeated maps + offset: 0, + }; + + root.update(traversalParams); + + if ( + viewport instanceof WebMercatorViewport && + viewport.subViewports && + viewport.subViewports.length > 1 + ) { + // Check worlds in repeated maps + traversalParams.offset = -1; + while (root.update(traversalParams)) { + if (--traversalParams.offset < -MAX_MAPS) { + break; + } + } + traversalParams.offset = 1; + while (root.update(traversalParams)) { + if (++traversalParams.offset > MAX_MAPS) { + break; + } + } + } + + return root.getSelected(); +} diff --git a/src/cog-tileset/types.ts b/src/cog-tileset/types.ts new file mode 100644 index 00000000..c742cffd --- /dev/null +++ b/src/cog-tileset/types.ts @@ -0,0 +1,35 @@ +// deck.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +export type ZRange = [minZ: number, maxZ: number]; + +export type Bounds = [minX: number, minY: number, maxX: number, maxY: number]; + +export type GeoBoundingBox = { + west: number; + north: number; + east: number; + south: number; +}; +export type NonGeoBoundingBox = { + left: number; + top: number; + right: number; + bottom: number; +}; + +export type TileBoundingBox = NonGeoBoundingBox | GeoBoundingBox; + +export type TileIndex = { x: number; y: number; z: number }; + +export type TileLoadProps = { + index: TileIndex; + id: string; + bbox: TileBoundingBox; + url?: string | null; + signal?: AbortSignal; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + userData?: Record; + zoom?: number; +}; diff --git a/src/cog-tileset/utils.ts b/src/cog-tileset/utils.ts new file mode 100644 index 00000000..b6a0f64c --- /dev/null +++ b/src/cog-tileset/utils.ts @@ -0,0 +1,361 @@ +// deck.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import { Viewport } from "@deck.gl/core"; +import { Matrix4 } from "@math.gl/core"; + +import { getOSMTileIndices } from "./tile-2d-traversal"; +import { + Bounds, + GeoBoundingBox, + TileBoundingBox, + TileIndex, + ZRange, +} from "./types.js"; + +const TILE_SIZE = 512; +const DEFAULT_EXTENT: Bounds = [-Infinity, -Infinity, Infinity, Infinity]; + +export type URLTemplate = string | string[] | null; + +export const urlType = { + type: "object" as const, + value: null as URLTemplate, + validate: (value, propType) => + (propType.optional && value === null) || + typeof value === "string" || + (Array.isArray(value) && value.every((url) => typeof url === "string")), + equal: (value1, value2) => { + if (value1 === value2) { + return true; + } + if (!Array.isArray(value1) || !Array.isArray(value2)) { + return false; + } + const len = value1.length; + if (len !== value2.length) { + return false; + } + for (let i = 0; i < len; i++) { + if (value1[i] !== value2[i]) { + return false; + } + } + return true; + }, +}; + +export function transformBox(bbox: Bounds, modelMatrix: Matrix4): Bounds { + const transformedCoords = [ + // top-left + modelMatrix.transformAsPoint([bbox[0], bbox[1]]), + // top-right + modelMatrix.transformAsPoint([bbox[2], bbox[1]]), + // bottom-left + modelMatrix.transformAsPoint([bbox[0], bbox[3]]), + // bottom-right + modelMatrix.transformAsPoint([bbox[2], bbox[3]]), + ]; + const transformedBox: Bounds = [ + // Minimum x coord + Math.min(...transformedCoords.map((i) => i[0])), + // Minimum y coord + Math.min(...transformedCoords.map((i) => i[1])), + // Max x coord + Math.max(...transformedCoords.map((i) => i[0])), + // Max y coord + Math.max(...transformedCoords.map((i) => i[1])), + ]; + return transformedBox; +} + +function stringHash(s: string): number { + return Math.abs( + s.split("").reduce((a, b) => ((a << 5) - a + b.charCodeAt(0)) | 0, 0), + ); +} + +export function getURLFromTemplate( + template: URLTemplate, + tile: { + index: TileIndex; + id: string; + }, +): string | null { + if (!template || !template.length) { + return null; + } + const { index, id } = tile; + + if (Array.isArray(template)) { + const i = stringHash(id) % template.length; + template = template[i]; + } + + let url = template; + for (const key of Object.keys(index)) { + const regex = new RegExp(`{${key}}`, "g"); + url = url.replace(regex, String(index[key])); + } + + // Back-compatible support for {-y} + if (Number.isInteger(index.y) && Number.isInteger(index.z)) { + url = url.replace(/\{-y\}/g, String(Math.pow(2, index.z) - index.y - 1)); + } + return url; +} + +/** + * gets the bounding box of a viewport + */ +function getBoundingBox( + viewport: Viewport, + zRange: number[] | null, + extent: Bounds, +): Bounds { + let bounds; + if (zRange && zRange.length === 2) { + const [minZ, maxZ] = zRange; + const bounds0 = viewport.getBounds({ z: minZ }); + const bounds1 = viewport.getBounds({ z: maxZ }); + bounds = [ + Math.min(bounds0[0], bounds1[0]), + Math.min(bounds0[1], bounds1[1]), + Math.max(bounds0[2], bounds1[2]), + Math.max(bounds0[3], bounds1[3]), + ]; + } else { + bounds = viewport.getBounds(); + } + if (!viewport.isGeospatial) { + return [ + // Top corner should not be more then bottom corner in either direction + Math.max(Math.min(bounds[0], extent[2]), extent[0]), + Math.max(Math.min(bounds[1], extent[3]), extent[1]), + // Bottom corner should not be less then top corner in either direction + Math.min(Math.max(bounds[2], extent[0]), extent[2]), + Math.min(Math.max(bounds[3], extent[1]), extent[3]), + ]; + } + return [ + Math.max(bounds[0], extent[0]), + Math.max(bounds[1], extent[1]), + Math.min(bounds[2], extent[2]), + Math.min(bounds[3], extent[3]), + ]; +} + +/** Get culling bounds in world space */ +export function getCullBounds({ + viewport, + z, + cullRect, +}: { + /** Current viewport */ + viewport: Viewport; + /** Current z range */ + z: ZRange | number | null; + /** Culling rectangle in screen space */ + cullRect: { x: number; y: number; width: number; height: number }; +}): [number, number, number, number][] { + const subViewports = viewport.subViewports || [viewport]; + return subViewports.map((v) => getCullBoundsInViewport(v, z || 0, cullRect)); +} + +function getCullBoundsInViewport( + /** Current viewport */ + viewport: Viewport, + /** At altitude */ + z: ZRange | number, + /** Culling rectangle in screen space */ + cullRect: { x: number; y: number; width: number; height: number }, +): [number, number, number, number] { + if (!Array.isArray(z)) { + const x = cullRect.x - viewport.x; + const y = cullRect.y - viewport.y; + const { width, height } = cullRect; + + const unprojectOption = { targetZ: z }; + + const topLeft = viewport.unproject([x, y], unprojectOption); + const topRight = viewport.unproject([x + width, y], unprojectOption); + const bottomLeft = viewport.unproject([x, y + height], unprojectOption); + const bottomRight = viewport.unproject( + [x + width, y + height], + unprojectOption, + ); + + return [ + Math.min(topLeft[0], topRight[0], bottomLeft[0], bottomRight[0]), + Math.min(topLeft[1], topRight[1], bottomLeft[1], bottomRight[1]), + Math.max(topLeft[0], topRight[0], bottomLeft[0], bottomRight[0]), + Math.max(topLeft[1], topRight[1], bottomLeft[1], bottomRight[1]), + ]; + } + + const bounds0 = getCullBoundsInViewport(viewport, z[0], cullRect); + const bounds1 = getCullBoundsInViewport(viewport, z[1], cullRect); + + return [ + Math.min(bounds0[0], bounds1[0]), + Math.min(bounds0[1], bounds1[1]), + Math.max(bounds0[2], bounds1[2]), + Math.max(bounds0[3], bounds1[3]), + ]; +} + +function getIndexingCoords( + bbox: Bounds, + scale: number, + modelMatrixInverse?: Matrix4, +): Bounds { + if (modelMatrixInverse) { + const transformedTileIndex = transformBox(bbox, modelMatrixInverse).map( + (i) => (i * scale) / TILE_SIZE, + ); + return transformedTileIndex as Bounds; + } + return bbox.map((i) => (i * scale) / TILE_SIZE) as Bounds; +} + +function getScale(z: number, tileSize: number): number { + return (Math.pow(2, z) * TILE_SIZE) / tileSize; +} + +// https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames#Lon..2Flat._to_tile_numbers_2 +export function osmTile2lngLat( + x: number, + y: number, + z: number, +): [number, number] { + const scale = getScale(z, TILE_SIZE); + const lng = (x / scale) * 360 - 180; + const n = Math.PI - (2 * Math.PI * y) / scale; + const lat = (180 / Math.PI) * Math.atan(0.5 * (Math.exp(n) - Math.exp(-n))); + return [lng, lat]; +} + +function tile2XY( + x: number, + y: number, + z: number, + tileSize: number, +): [number, number] { + const scale = getScale(z, tileSize); + return [(x / scale) * TILE_SIZE, (y / scale) * TILE_SIZE]; +} +export function tileToBoundingBox( + viewport: Viewport, + x: number, + y: number, + z: number, + tileSize: number = TILE_SIZE, +): TileBoundingBox { + if (viewport.isGeospatial) { + const [west, north] = osmTile2lngLat(x, y, z); + const [east, south] = osmTile2lngLat(x + 1, y + 1, z); + return { west, north, east, south }; + } + const [left, top] = tile2XY(x, y, z, tileSize); + const [right, bottom] = tile2XY(x + 1, y + 1, z, tileSize); + return { left, top, right, bottom }; +} + +function getIdentityTileIndices( + viewport: Viewport, + z: number, + tileSize: number, + extent: Bounds, + modelMatrixInverse?: Matrix4, +) { + const bbox = getBoundingBox(viewport, null, extent); + const scale = getScale(z, tileSize); + const [minX, minY, maxX, maxY] = getIndexingCoords( + bbox, + scale, + modelMatrixInverse, + ); + const indices: TileIndex[] = []; + + /* + | TILE | TILE | TILE | + |(minX) |(maxX) + */ + for (let x = Math.floor(minX); x < maxX; x++) { + for (let y = Math.floor(minY); y < maxY; y++) { + indices.push({ x, y, z }); + } + } + return indices; +} + +/** + * Returns all tile indices in the current viewport. If the current zoom level is smaller + * than minZoom, return an empty array. If the current zoom level is greater than maxZoom, + * return tiles that are on maxZoom. + */ + +export function getTileIndices({ + viewport, + maxZoom, + minZoom, + zRange, + extent, + tileSize = TILE_SIZE, + modelMatrix, + modelMatrixInverse, + zoomOffset = 0, +}: { + viewport: Viewport; + maxZoom?: number; + minZoom?: number; + zRange: ZRange | null; + extent?: Bounds; + tileSize?: number; + modelMatrix?: Matrix4; + modelMatrixInverse?: Matrix4; + zoomOffset?: number; +}) { + let z = viewport.isGeospatial + ? Math.round(viewport.zoom + Math.log2(TILE_SIZE / tileSize)) + zoomOffset + : Math.ceil(viewport.zoom) + zoomOffset; + if (typeof minZoom === "number" && Number.isFinite(minZoom) && z < minZoom) { + if (!extent) { + return []; + } + z = minZoom; + } + if (typeof maxZoom === "number" && Number.isFinite(maxZoom) && z > maxZoom) { + z = maxZoom; + } + let transformedExtent = extent; + if (modelMatrix && modelMatrixInverse && extent && !viewport.isGeospatial) { + transformedExtent = transformBox(extent, modelMatrix); + } + return viewport.isGeospatial + ? getOSMTileIndices(viewport, z, zRange, extent) + : getIdentityTileIndices( + viewport, + z, + tileSize, + transformedExtent || DEFAULT_EXTENT, + modelMatrixInverse, + ); +} + +/** + * Returns true if s is a valid URL template + */ +export function isURLTemplate(s: string): boolean { + return /(?=.*{z})(?=.*{x})(?=.*({y}|{-y}))/.test(s); +} + +export function isGeoBoundingBox(v: any): v is GeoBoundingBox { + return ( + Number.isFinite(v.west) && + Number.isFinite(v.north) && + Number.isFinite(v.east) && + Number.isFinite(v.south) + ); +} From 01448a80f016367dc7fa057fcd2f04f7a7c9583a Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Mon, 27 Oct 2025 12:41:42 -0400 Subject: [PATCH 02/25] cleanup --- src/cog-tileset/cog-tileset-2d.ts | 25 +++++++++++++------------ src/cog-tileset/tile-2d-traversal.ts | 4 ---- src/cog-tileset/types.ts | 12 ++++++++---- src/cog-tileset/utils.ts | 4 ---- 4 files changed, 21 insertions(+), 24 deletions(-) diff --git a/src/cog-tileset/cog-tileset-2d.ts b/src/cog-tileset/cog-tileset-2d.ts index a08a232d..438d4e34 100644 --- a/src/cog-tileset/cog-tileset-2d.ts +++ b/src/cog-tileset/cog-tileset-2d.ts @@ -1,9 +1,8 @@ import { Viewport } from "@deck.gl/core"; import { _Tileset2D as Tileset2D } from "@deck.gl/geo-layers"; -import { TileIndex, ZRange } from "@deck.gl/geo-layers/dist/tileset-2d/types"; -import { Matrix4 } from "@math.gl/core"; import { getOSMTileIndices } from "./tile-2d-traversal"; +import type { COGTileIndex, ZRange } from "./types"; type Bounds = [minX: number, minY: number, maxX: number, maxY: number]; @@ -14,8 +13,6 @@ export class COGTileset2D extends Tileset2D { minZoom, zRange, tileSize, - modelMatrix, - modelMatrixInverse, zoomOffset, }: { viewport: Viewport; @@ -23,11 +20,18 @@ export class COGTileset2D extends Tileset2D { minZoom?: number; zRange: ZRange | null; tileSize?: number; - modelMatrix?: Matrix4; - modelMatrixInverse?: Matrix4; zoomOffset?: number; - }): TileIndex[] { + }): COGTileIndex[] { const { extent } = this.opts; + return getTileIndices({ + viewport, + maxZoom, + minZoom, + zRange, + extent: extent as Bounds, + tileSize, + zoomOffset, + }); } } @@ -46,8 +50,6 @@ export function getTileIndices({ zRange, extent, tileSize = TILE_SIZE, - modelMatrix, - modelMatrixInverse, zoomOffset = 0, }: { viewport: Viewport; @@ -56,10 +58,9 @@ export function getTileIndices({ zRange: ZRange | null; extent?: Bounds; tileSize?: number; - modelMatrix?: Matrix4; - modelMatrixInverse?: Matrix4; zoomOffset?: number; -}) { +}): COGTileIndex[] { + // Note: for now this only supports geospatial viewports let z = Math.round(viewport.zoom + Math.log2(TILE_SIZE / tileSize)) + zoomOffset; if (typeof minZoom === "number" && Number.isFinite(minZoom) && z < minZoom) { diff --git a/src/cog-tileset/tile-2d-traversal.ts b/src/cog-tileset/tile-2d-traversal.ts index 57c95619..082dd446 100644 --- a/src/cog-tileset/tile-2d-traversal.ts +++ b/src/cog-tileset/tile-2d-traversal.ts @@ -1,7 +1,3 @@ -// deck.gl -// SPDX-License-Identifier: MIT -// Copyright (c) vis.gl contributors - import { Viewport, WebMercatorViewport, _GlobeViewport } from "@deck.gl/core"; import { CullingVolume, diff --git a/src/cog-tileset/types.ts b/src/cog-tileset/types.ts index c742cffd..1f550eb4 100644 --- a/src/cog-tileset/types.ts +++ b/src/cog-tileset/types.ts @@ -1,7 +1,3 @@ -// deck.gl -// SPDX-License-Identifier: MIT -// Copyright (c) vis.gl contributors - export type ZRange = [minZ: number, maxZ: number]; export type Bounds = [minX: number, minY: number, maxX: number, maxY: number]; @@ -33,3 +29,11 @@ export type TileLoadProps = { userData?: Record; zoom?: number; }; + +/** + * Custom tile index for COG tiles + */ +export type COGTileIndex = TileIndex & { + // Optional: include bounds for debugging/rendering + bounds?: [number, number, number, number]; +}; diff --git a/src/cog-tileset/utils.ts b/src/cog-tileset/utils.ts index b6a0f64c..4d31a595 100644 --- a/src/cog-tileset/utils.ts +++ b/src/cog-tileset/utils.ts @@ -1,7 +1,3 @@ -// deck.gl -// SPDX-License-Identifier: MIT -// Copyright (c) vis.gl contributors - import { Viewport } from "@deck.gl/core"; import { Matrix4 } from "@math.gl/core"; From 4512ec6b20bf6806265ed8f9595d10b71ce1b4b6 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Mon, 27 Oct 2025 16:54:43 -0400 Subject: [PATCH 03/25] Add tile matrix set schema --- src/cog-tileset/tile-matrix-set-schema.d.ts | 912 ++++++++++++++++++++ 1 file changed, 912 insertions(+) create mode 100644 src/cog-tileset/tile-matrix-set-schema.d.ts diff --git a/src/cog-tileset/tile-matrix-set-schema.d.ts b/src/cog-tileset/tile-matrix-set-schema.d.ts new file mode 100644 index 00000000..56828ad8 --- /dev/null +++ b/src/cog-tileset/tile-matrix-set-schema.d.ts @@ -0,0 +1,912 @@ +/** + * This file was automatically generated by json-schema-to-typescript. + * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, + * and run json-schema-to-typescript to regenerate this file. + * + * This was created via + * ``` + * git clone https://github.com/opengeospatial/2D-Tile-Matrix-Set + * git checkout a1179bb + * cd 2D-Tile-Matrix-Set/schemas/tms/2.0/json + * npx json-schema-to-typescript tileMatrixSet.json > tile-matrix-set-schema.d.ts + * ``` + */ + +export type CRS = + | string + | ( + | { + /** + * Reference to one coordinate reference system (CRS) + */ + uri: string; + [k: string]: unknown; + } + | { + wkt: { + [k: string]: unknown; + } & HttpsProjOrgSchemasV02ProjjsonSchemaJson; + [k: string]: unknown; + } + | { + /** + * A reference system data structure as defined in the MD_ReferenceSystem of the ISO 19115 + */ + referenceSystem: { + [k: string]: unknown; + }; + [k: string]: unknown; + } + ); +/** + * Schema for PROJJSON (v0.2.1) + */ +export type HttpsProjOrgSchemasV02ProjjsonSchemaJson = + | ( + | BoundCrs + | CompoundCrs + | DerivedEngineeringCrs + | DerivedGeodeticCrs + | DerivedParametricCrs + | DerivedProjectedCrs + | DerivedTemporalCrs + | DerivedVerticalCrs + | EngineeringCrs + | GeodeticCrs + | ParametricCrs + | ProjectedCrs + | TemporalCrs + | VerticalCrs + ) + | ( + | GeodeticReferenceFrame + | VerticalReferenceFrame + | DynamicGeodeticReferenceFrame + | DynamicVerticalReferenceFrame + | TemporalDatum + | ParametricDatum + | EngineeringDatum + ) + | DatumEnsemble + | Ellipsoid + | PrimeMeridian + | (Conversion | Transformation) + | ConcatenatedOperation; +export type AbridgedTransformation = { + [k: string]: unknown; +} & { + $schema?: string; + type?: "AbridgedTransformation"; + name: string; + method: Method; + parameters: ParameterValue[]; + id?: Id; + ids?: Ids; +}; +export type Method = { + [k: string]: unknown; +} & { + $schema?: string; + type?: "OperationMethod"; + name: string; + id?: Id; + ids?: Ids; +}; +export type Ids = Id[]; +export type ParameterValue = { + [k: string]: unknown; +} & { + $schema?: string; + type?: "ParameterValue"; + name: string; + value: string | number; + unit?: + | ("metre" | "degree" | "unity") + | { + [k: string]: unknown; + }; + id?: Id; + ids?: Ids; +}; +export type CompoundCrs = { + [k: string]: unknown; +} & { + type?: "CompoundCRS"; + name: string; + components: ( + | BoundCrs + | CompoundCrs + | DerivedEngineeringCrs + | DerivedGeodeticCrs + | DerivedParametricCrs + | DerivedProjectedCrs + | DerivedTemporalCrs + | DerivedVerticalCrs + | EngineeringCrs + | GeodeticCrs + | ParametricCrs + | ProjectedCrs + | TemporalCrs + | VerticalCrs + )[]; + $schema?: unknown; + scope?: unknown; + area?: unknown; + bbox?: unknown; + usages?: unknown; + remarks?: unknown; + id?: unknown; + ids?: unknown; +}; +export type DerivedEngineeringCrs = { + [k: string]: unknown; +} & { + type?: "DerivedEngineeringCRS"; + name: string; + base_crs: EngineeringCrs; + conversion: Conversion; + coordinate_system: CoordinateSystem; + $schema?: unknown; + scope?: unknown; + area?: unknown; + bbox?: unknown; + usages?: unknown; + remarks?: unknown; + id?: unknown; + ids?: unknown; +}; +export type EngineeringCrs = { + [k: string]: unknown; +} & { + type?: "EngineeringCRS"; + name: string; + datum: EngineeringDatum; + coordinate_system?: CoordinateSystem; + $schema?: unknown; + scope?: unknown; + area?: unknown; + bbox?: unknown; + usages?: unknown; + remarks?: unknown; + id?: unknown; + ids?: unknown; +}; +export type EngineeringDatum = { + [k: string]: unknown; +} & { + type?: "EngineeringDatum"; + name: string; + anchor?: string; + $schema?: unknown; + scope?: unknown; + area?: unknown; + bbox?: unknown; + usages?: unknown; + remarks?: unknown; + id?: unknown; + ids?: unknown; +}; +export type CoordinateSystem = { + [k: string]: unknown; +} & { + $schema?: string; + type?: "CoordinateSystem"; + name?: string; + subtype: + | "Cartesian" + | "spherical" + | "ellipsoidal" + | "vertical" + | "ordinal" + | "parametric" + | "TemporalDateTime" + | "TemporalCount" + | "TemporalMeasure"; + axis: Axis[]; + id?: Id; + ids?: Ids; +}; +export type Axis = { + [k: string]: unknown; +} & { + $schema?: string; + type?: "Axis"; + name: string; + abbreviation: string; + direction: + | "north" + | "northNorthEast" + | "northEast" + | "eastNorthEast" + | "east" + | "eastSouthEast" + | "southEast" + | "southSouthEast" + | "south" + | "southSouthWest" + | "southWest" + | "westSouthWest" + | "west" + | "westNorthWest" + | "northWest" + | "northNorthWest" + | "up" + | "down" + | "geocentricX" + | "geocentricY" + | "geocentricZ" + | "columnPositive" + | "columnNegative" + | "rowPositive" + | "rowNegative" + | "displayRight" + | "displayLeft" + | "displayUp" + | "displayDown" + | "forward" + | "aft" + | "port" + | "starboard" + | "clockwise" + | "counterClockwise" + | "towards" + | "awayFrom" + | "future" + | "past" + | "unspecified"; + unit?: + | ("metre" | "degree" | "unity") + | { + [k: string]: unknown; + }; + id?: Id; + ids?: Ids; +}; +export type Conversion = { + [k: string]: unknown; +} & { + $schema?: string; + type?: "Conversion"; + name: string; + method: Method; + parameters?: ParameterValue[]; + id?: Id; + ids?: Ids; +}; +export type DerivedGeodeticCrs = { + [k: string]: unknown; +} & { + type?: "DerivedGeodeticCRS" | "DerivedGeographicCRS"; + name: string; + base_crs: GeodeticCrs; + conversion: Conversion; + coordinate_system: CoordinateSystem; + $schema?: unknown; + scope?: unknown; + area?: unknown; + bbox?: unknown; + usages?: unknown; + remarks?: unknown; + id?: unknown; + ids?: unknown; +}; +/** + * One and only one of datum and datum_ensemble must be provided + */ +export type GeodeticCrs = { + [k: string]: unknown; +} & { + type?: "GeodeticCRS" | "GeographicCRS"; + name: string; + datum?: GeodeticReferenceFrame | DynamicGeodeticReferenceFrame; + datum_ensemble?: DatumEnsemble; + coordinate_system?: CoordinateSystem; + $schema?: unknown; + scope?: unknown; + area?: unknown; + bbox?: unknown; + usages?: unknown; + remarks?: unknown; + id?: unknown; + ids?: unknown; +}; +export type GeodeticReferenceFrame = { + [k: string]: unknown; +} & { + type?: "GeodeticReferenceFrame"; + name: string; + anchor?: string; + ellipsoid: Ellipsoid; + prime_meridian?: PrimeMeridian; + $schema?: unknown; + scope?: unknown; + area?: unknown; + bbox?: unknown; + usages?: unknown; + remarks?: unknown; + id?: unknown; + ids?: unknown; +}; +export type Ellipsoid = { + [k: string]: unknown; +} & ( + | { + $schema?: string; + type?: "Ellipsoid"; + name: string; + semi_major_axis: number | ValueAndUnit; + semi_minor_axis: number | ValueAndUnit; + id?: Id; + ids?: Ids; + } + | { + $schema?: string; + type?: "Ellipsoid"; + name: string; + semi_major_axis: number | ValueAndUnit; + inverse_flattening: number; + id?: Id; + ids?: Ids; + } + | { + $schema?: string; + type?: "Ellipsoid"; + name: string; + radius: number | ValueAndUnit; + id?: Id; + ids?: Ids; + } +); +export type PrimeMeridian = { + [k: string]: unknown; +} & { + $schema?: string; + type?: "PrimeMeridian"; + name: string; + longitude?: number | ValueAndUnit; + id?: Id; + ids?: Ids; +}; +export type DynamicGeodeticReferenceFrame = GeodeticReferenceFrame & { + type?: "DynamicGeodeticReferenceFrame"; + name: unknown; + anchor?: unknown; + ellipsoid: unknown; + prime_meridian?: unknown; + frame_reference_epoch: number; + deformation_model?: string; + $schema?: unknown; + scope?: unknown; + area?: unknown; + bbox?: unknown; + usages?: unknown; + remarks?: unknown; + id?: unknown; + ids?: unknown; +}; +export type DatumEnsemble = { + [k: string]: unknown; +} & { + $schema?: string; + type?: "DatumEnsemble"; + name: string; + members: { + [k: string]: unknown; + }[]; + ellipsoid?: Ellipsoid; + accuracy: string; + id?: Id; + ids?: Ids; +}; +export type DerivedParametricCrs = { + [k: string]: unknown; +} & { + type?: "DerivedParametricCRS"; + name: string; + base_crs: ParametricCrs; + conversion: Conversion; + coordinate_system: CoordinateSystem; + $schema?: unknown; + scope?: unknown; + area?: unknown; + bbox?: unknown; + usages?: unknown; + remarks?: unknown; + id?: unknown; + ids?: unknown; +}; +export type ParametricCrs = { + [k: string]: unknown; +} & { + type?: "ParametricCRS"; + name: string; + datum: ParametricDatum; + coordinate_system?: CoordinateSystem; + $schema?: unknown; + scope?: unknown; + area?: unknown; + bbox?: unknown; + usages?: unknown; + remarks?: unknown; + id?: unknown; + ids?: unknown; +}; +export type ParametricDatum = { + [k: string]: unknown; +} & { + type?: "ParametricDatum"; + name: string; + anchor?: string; + $schema?: unknown; + scope?: unknown; + area?: unknown; + bbox?: unknown; + usages?: unknown; + remarks?: unknown; + id?: unknown; + ids?: unknown; +}; +export type DerivedProjectedCrs = { + [k: string]: unknown; +} & { + type?: "DerivedProjectedCRS"; + name: string; + base_crs: ProjectedCrs; + conversion: Conversion; + coordinate_system: CoordinateSystem; + $schema?: unknown; + scope?: unknown; + area?: unknown; + bbox?: unknown; + usages?: unknown; + remarks?: unknown; + id?: unknown; + ids?: unknown; +}; +export type ProjectedCrs = { + [k: string]: unknown; +} & { + type?: "ProjectedCRS"; + name: string; + base_crs: GeodeticCrs; + conversion: Conversion; + coordinate_system: CoordinateSystem; + $schema?: unknown; + scope?: unknown; + area?: unknown; + bbox?: unknown; + usages?: unknown; + remarks?: unknown; + id?: unknown; + ids?: unknown; +}; +export type DerivedTemporalCrs = { + [k: string]: unknown; +} & { + type?: "DerivedTemporalCRS"; + name: string; + base_crs: TemporalCrs; + conversion: Conversion; + coordinate_system: CoordinateSystem; + $schema?: unknown; + scope?: unknown; + area?: unknown; + bbox?: unknown; + usages?: unknown; + remarks?: unknown; + id?: unknown; + ids?: unknown; +}; +export type TemporalCrs = { + [k: string]: unknown; +} & { + type?: "TemporalCRS"; + name: string; + datum: TemporalDatum; + coordinate_system?: CoordinateSystem; + $schema?: unknown; + scope?: unknown; + area?: unknown; + bbox?: unknown; + usages?: unknown; + remarks?: unknown; + id?: unknown; + ids?: unknown; +}; +export type TemporalDatum = { + [k: string]: unknown; +} & { + type?: "TemporalDatum"; + name: string; + calendar: string; + time_origin?: string; + $schema?: unknown; + scope?: unknown; + area?: unknown; + bbox?: unknown; + usages?: unknown; + remarks?: unknown; + id?: unknown; + ids?: unknown; +}; +export type DerivedVerticalCrs = { + [k: string]: unknown; +} & { + type?: "DerivedVerticalCRS"; + name: string; + base_crs: VerticalCrs; + conversion: Conversion; + coordinate_system: CoordinateSystem; + $schema?: unknown; + scope?: unknown; + area?: unknown; + bbox?: unknown; + usages?: unknown; + remarks?: unknown; + id?: unknown; + ids?: unknown; +}; +/** + * One and only one of datum and datum_ensemble must be provided + */ +export type VerticalCrs = { + [k: string]: unknown; +} & { + type?: "VerticalCRS"; + name: string; + datum?: VerticalReferenceFrame | DynamicVerticalReferenceFrame; + datum_ensemble?: DatumEnsemble; + coordinate_system?: CoordinateSystem; + geoid_model?: { + name: string; + interpolation_crs?: + | BoundCrs + | CompoundCrs + | DerivedEngineeringCrs + | DerivedGeodeticCrs + | DerivedParametricCrs + | DerivedProjectedCrs + | DerivedTemporalCrs + | DerivedVerticalCrs + | EngineeringCrs + | GeodeticCrs + | ParametricCrs + | ProjectedCrs + | TemporalCrs + | VerticalCrs; + id?: Id; + }; + $schema?: unknown; + scope?: unknown; + area?: unknown; + bbox?: unknown; + usages?: unknown; + remarks?: unknown; + id?: unknown; + ids?: unknown; +}; +export type VerticalReferenceFrame = { + [k: string]: unknown; +} & { + type?: "VerticalReferenceFrame"; + name: string; + anchor?: string; + $schema?: unknown; + scope?: unknown; + area?: unknown; + bbox?: unknown; + usages?: unknown; + remarks?: unknown; + id?: unknown; + ids?: unknown; +}; +export type DynamicVerticalReferenceFrame = VerticalReferenceFrame & { + type?: "DynamicVerticalReferenceFrame"; + name: unknown; + anchor?: unknown; + frame_reference_epoch: number; + deformation_model?: string; + $schema?: unknown; + scope?: unknown; + area?: unknown; + bbox?: unknown; + usages?: unknown; + remarks?: unknown; + id?: unknown; + ids?: unknown; +}; +export type Transformation = { + [k: string]: unknown; +} & { + type?: "Transformation"; + name: string; + source_crs: + | BoundCrs + | CompoundCrs + | DerivedEngineeringCrs + | DerivedGeodeticCrs + | DerivedParametricCrs + | DerivedProjectedCrs + | DerivedTemporalCrs + | DerivedVerticalCrs + | EngineeringCrs + | GeodeticCrs + | ParametricCrs + | ProjectedCrs + | TemporalCrs + | VerticalCrs; + target_crs: + | BoundCrs + | CompoundCrs + | DerivedEngineeringCrs + | DerivedGeodeticCrs + | DerivedParametricCrs + | DerivedProjectedCrs + | DerivedTemporalCrs + | DerivedVerticalCrs + | EngineeringCrs + | GeodeticCrs + | ParametricCrs + | ProjectedCrs + | TemporalCrs + | VerticalCrs; + interpolation_crs?: + | BoundCrs + | CompoundCrs + | DerivedEngineeringCrs + | DerivedGeodeticCrs + | DerivedParametricCrs + | DerivedProjectedCrs + | DerivedTemporalCrs + | DerivedVerticalCrs + | EngineeringCrs + | GeodeticCrs + | ParametricCrs + | ProjectedCrs + | TemporalCrs + | VerticalCrs; + method: Method; + parameters: ParameterValue[]; + accuracy?: string; + $schema?: unknown; + scope?: unknown; + area?: unknown; + bbox?: unknown; + usages?: unknown; + remarks?: unknown; + id?: unknown; + ids?: unknown; +}; +export type ConcatenatedOperation = { + [k: string]: unknown; +} & { + type?: "ConcatenatedOperation"; + name: string; + source_crs: + | BoundCrs + | CompoundCrs + | DerivedEngineeringCrs + | DerivedGeodeticCrs + | DerivedParametricCrs + | DerivedProjectedCrs + | DerivedTemporalCrs + | DerivedVerticalCrs + | EngineeringCrs + | GeodeticCrs + | ParametricCrs + | ProjectedCrs + | TemporalCrs + | VerticalCrs; + target_crs: + | BoundCrs + | CompoundCrs + | DerivedEngineeringCrs + | DerivedGeodeticCrs + | DerivedParametricCrs + | DerivedProjectedCrs + | DerivedTemporalCrs + | DerivedVerticalCrs + | EngineeringCrs + | GeodeticCrs + | ParametricCrs + | ProjectedCrs + | TemporalCrs + | VerticalCrs; + steps: (Conversion | Transformation)[]; + $schema?: unknown; + scope?: unknown; + area?: unknown; + bbox?: unknown; + usages?: unknown; + remarks?: unknown; + id?: unknown; + ids?: unknown; +}; +/** + * A 2D Point in the CRS indicated elsewhere + * + * @minItems 2 + * @maxItems 2 + */ +export type DPoint = [number, number]; + +/** + * A definition of a tile matrix set following the Tile Matrix Set standard. For tileset metadata, such a description (in `tileMatrixSet` property) is only required for offline use, as an alternative to a link with a `http://www.opengis.net/def/rel/ogc/1.0/tiling-scheme` relation type. + */ +export interface TileMatrixSetDefinition { + /** + * Title of this tile matrix set, normally used for display to a human + */ + title?: string; + /** + * Brief narrative description of this tile matrix set, normally available for display to a human + */ + description?: string; + /** + * Unordered list of one or more commonly used or formalized word(s) or phrase(s) used to describe this tile matrix set + */ + keywords?: string[]; + /** + * Tile matrix set identifier. Implementation of 'identifier' + */ + id?: string; + /** + * Reference to an official source for this tileMatrixSet + */ + uri?: string; + /** + * @minItems 1 + */ + orderedAxes?: [string, ...string[]]; + crs: { + [k: string]: unknown; + } & CRS; + /** + * Reference to a well-known scale set + */ + wellKnownScaleSet?: string; + boundingBox?: { + [k: string]: unknown; + } & DBoundingBox; + /** + * Describes scale levels and its tile matrices + */ + tileMatrices: TileMatrix[]; + [k: string]: unknown; +} +export interface BoundCrs { + $schema?: string; + type?: "BoundCRS"; + source_crs: + | BoundCrs + | CompoundCrs + | DerivedEngineeringCrs + | DerivedGeodeticCrs + | DerivedParametricCrs + | DerivedProjectedCrs + | DerivedTemporalCrs + | DerivedVerticalCrs + | EngineeringCrs + | GeodeticCrs + | ParametricCrs + | ProjectedCrs + | TemporalCrs + | VerticalCrs; + target_crs: + | BoundCrs + | CompoundCrs + | DerivedEngineeringCrs + | DerivedGeodeticCrs + | DerivedParametricCrs + | DerivedProjectedCrs + | DerivedTemporalCrs + | DerivedVerticalCrs + | EngineeringCrs + | GeodeticCrs + | ParametricCrs + | ProjectedCrs + | TemporalCrs + | VerticalCrs; + transformation: AbridgedTransformation; +} +export interface Id { + authority: string; + code: string | number; +} +export interface ValueAndUnit { + value: number; + unit: + | ("metre" | "degree" | "unity") + | { + [k: string]: unknown; + }; +} +/** + * Minimum bounding rectangle surrounding a 2D resource in the CRS indicated elsewhere + */ +export interface DBoundingBox { + lowerLeft: DPoint; + upperRight: DPoint; + crs?: CRS; + /** + * @minItems 2 + * @maxItems 2 + */ + orderedAxes?: [string, string]; + [k: string]: unknown; +} +/** + * A tile matrix, usually corresponding to a particular zoom level of a TileMatrixSet. + */ +export interface TileMatrix { + /** + * Title of this tile matrix, normally used for display to a human + */ + title?: string; + /** + * Brief narrative description of this tile matrix set, normally available for display to a human + */ + description?: string; + /** + * Unordered list of one or more commonly used or formalized word(s) or phrase(s) used to describe this dataset + */ + keywords?: string[]; + /** + * Identifier selecting one of the scales defined in the TileMatrixSet and representing the scaleDenominator the tile. Implementation of 'identifier' + */ + id: string; + /** + * Scale denominator of this tile matrix + */ + scaleDenominator: number; + /** + * Cell size of this tile matrix + */ + cellSize: number; + /** + * The corner of the tile matrix (_topLeft_ or _bottomLeft_) used as the origin for numbering tile rows and columns. This corner is also a corner of the (0, 0) tile. + */ + cornerOfOrigin?: "topLeft" | "bottomLeft"; + pointOfOrigin: { + [k: string]: unknown; + } & DPoint; + /** + * Width of each tile of this tile matrix in pixels + */ + tileWidth: number; + /** + * Height of each tile of this tile matrix in pixels + */ + tileHeight: number; + /** + * Width of the matrix (number of tiles in width) + */ + matrixHeight: number; + /** + * Height of the matrix (number of tiles in height) + */ + matrixWidth: number; + /** + * Describes the rows that has variable matrix width + */ + variableMatrixWidths?: VariableMatrixWidth[]; + [k: string]: unknown; +} +/** + * Variable Matrix Width data structure + */ +export interface VariableMatrixWidth { + /** + * Number of tiles in width that coalesce in a single tile for these rows + */ + coalesce: number; + /** + * First tile row where the coalescence factor applies for this tilematrix + */ + minTileRow: number; + /** + * Last tile row where the coalescence factor applies for this tilematrix + */ + maxTileRow: number; + [k: string]: unknown; +} From 35c493ecaace3b7707a66e3423f640493c60a22c Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Mon, 27 Oct 2025 18:21:28 -0400 Subject: [PATCH 04/25] wip: cog node traversal --- src/cog-tileset/tile-2d-traversal.ts | 77 ++++++++++++++++++++-------- 1 file changed, 55 insertions(+), 22 deletions(-) diff --git a/src/cog-tileset/tile-2d-traversal.ts b/src/cog-tileset/tile-2d-traversal.ts index 082dd446..40e6ec02 100644 --- a/src/cog-tileset/tile-2d-traversal.ts +++ b/src/cog-tileset/tile-2d-traversal.ts @@ -7,7 +7,7 @@ import { } from "@math.gl/culling"; import { lngLatToWorld } from "@math.gl/web-mercator"; -import { Bounds, TileIndex, ZRange } from "./types.js"; +import { Bounds, COGTileIndex, TileIndex, ZRange } from "./types.js"; import { osmTile2lngLat } from "./utils.js"; const TILE_SIZE = 512; @@ -32,15 +32,43 @@ const REF_POINTS_11 = REF_POINTS_9.concat([ [0.75, 0.5], ]); // 2 additional points on equator for top tile -class OSMNode { +/** + * COG Metadata extracted from GeoTIFF + */ +interface COGMetadata { + width: number; + height: number; + tileWidth: number; + tileHeight: number; + tilesX: number; + tilesY: number; + bbox: [number, number, number, number]; // [minX, minY, maxX, maxY] in COG's CRS + projection: string; + overviews: Array<{ + level: number; + width: number; + height: number; + tilesX: number; + tilesY: number; + scaleFactor: number; + }>; + image: any; // GeoTIFF reference +} + +/** + * COG Tile Node - similar to upstream's OSMNode but for COG's tile structure + */ +class COGNode { x: number; y: number; z: number; + private cogMetadata: COGMetadata; + private childVisible?: boolean; private selected?: boolean; - private _children?: OSMNode[]; + private _children?: COGNode[]; constructor(x: number, y: number, z: number) { this.x = x; @@ -49,20 +77,23 @@ class OSMNode { } get children() { + // NOTE: for complex COGs you may need to actually compute the child indexes + // https://github.com/developmentseed/morecantile/blob/12698fbd4e52dc1a0fcac4b5658dcc4f3c9e5343/morecantile/models.py#L1668-L1713 if (!this._children) { const x = this.x * 2; const y = this.y * 2; const z = this.z + 1; this._children = [ - new OSMNode(x, y, z), - new OSMNode(x, y + 1, z), - new OSMNode(x + 1, y, z), - new OSMNode(x + 1, y + 1, z), + new COGNode(x, y, z), + new COGNode(x, y + 1, z), + new COGNode(x + 1, y, z), + new COGNode(x + 1, y + 1, z), ]; } return this._children; } + /** Update whether this node is visible or not. */ update(params: { viewport: Viewport; project: ((xyz: number[]) => number[]) | null; @@ -89,10 +120,11 @@ class OSMNode { project, ); - // First, check if this tile is visible - if (bounds && !this.insideBounds(bounds)) { - return false; - } + // TODO: restore + // // First, check if this tile is visible + // if (bounds && !this.insideBounds(bounds)) { + // return false; + // } const isInside = cullingVolume.computeVisibility(boundingVolume); if (isInside < 0) { @@ -127,7 +159,7 @@ class OSMNode { return true; } - getSelected(result: OSMNode[] = []): OSMNode[] { + getSelected(result: COGNode[] = []): COGNode[] { if (this.selected) { result.push(this); } @@ -139,17 +171,18 @@ class OSMNode { return result; } - insideBounds([minX, minY, maxX, maxY]: Bounds): boolean { - const scale = Math.pow(2, this.z); - const extent = TILE_SIZE / scale; + // TODO: update implementation + // insideBounds([minX, minY, maxX, maxY]: Bounds): boolean { + // const scale = Math.pow(2, this.z); + // const extent = TILE_SIZE / scale; - return ( - this.x * extent < maxX && - this.y * extent < maxY && - (this.x + 1) * extent > minX && - (this.y + 1) * extent > minY - ); - } + // return ( + // this.x * extent < maxX && + // this.y * extent < maxY && + // (this.x + 1) * extent > minX && + // (this.y + 1) * extent > minY + // ); + // } getBoundingVolume( zRange: ZRange, From 0b52302dd6738a9951e193bd6c06fc67904414c2 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Tue, 28 Oct 2025 22:24:40 -0400 Subject: [PATCH 05/25] Add simplified types for TileMatrixSet definition --- src/cog-tileset/types.ts | 127 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) diff --git a/src/cog-tileset/types.ts b/src/cog-tileset/types.ts index 1f550eb4..3918f559 100644 --- a/src/cog-tileset/types.ts +++ b/src/cog-tileset/types.ts @@ -37,3 +37,130 @@ export type COGTileIndex = TileIndex & { // Optional: include bounds for debugging/rendering bounds?: [number, number, number, number]; }; + +// type CRS = string | { [k: string]: unknown }; +export type TMSCrs = unknown; + +/** + * A 2D Point in the CRS indicated elsewhere + * + * @minItems 2 + * @maxItems 2 + */ +export type TMSPoint = [number, number]; + +/** + * Minimum bounding rectangle surrounding a 2D resource in the CRS indicated elsewhere + */ +export interface TMSBoundingBox { + lowerLeft: TMSPoint; + upperRight: TMSPoint; + crs?: TMSCrs; + /** + * @minItems 2 + * @maxItems 2 + */ + orderedAxes?: [string, string]; + [k: string]: unknown; +} + +/** + * A definition of a tile matrix set following the Tile Matrix Set standard. For tileset metadata, such a description (in `tileMatrixSet` property) is only required for offline use, as an alternative to a link with a `http://www.opengis.net/def/rel/ogc/1.0/tiling-scheme` relation type. + */ +export type TileMatrixSetDefinition = { + /** + * Title of this tile matrix set, normally used for display to a human + */ + title?: string; + /** + * Brief narrative description of this tile matrix set, normally available for display to a human + */ + description?: string; + /** + * Unordered list of one or more commonly used or formalized word(s) or phrase(s) used to describe this tile matrix set + */ + keywords?: string[]; + /** + * Tile matrix set identifier. Implementation of 'identifier' + */ + id?: string; + /** + * Reference to an official source for this tileMatrixSet + */ + uri?: string; + /** + * @minItems 1 + */ + orderedAxes?: [string, ...string[]]; + crs: TMSCrs; + /** + * Reference to a well-known scale set + */ + wellKnownScaleSet?: string; + boundingBox?: { + [k: string]: unknown; + } & TMSBoundingBox; + /** + * Describes scale levels and its tile matrices + */ + tileMatrices: TMSTileMatrix[]; + [k: string]: unknown; +}; + +/** + * A tile matrix, usually corresponding to a particular zoom level of a TileMatrixSet. + */ +export interface TMSTileMatrix { + /** + * Title of this tile matrix, normally used for display to a human + */ + title?: string; + /** + * Brief narrative description of this tile matrix set, normally available for display to a human + */ + description?: string; + /** + * Unordered list of one or more commonly used or formalized word(s) or phrase(s) used to describe this dataset + */ + keywords?: string[]; + /** + * Identifier selecting one of the scales defined in the TileMatrixSet and representing the scaleDenominator the tile. Implementation of 'identifier' + */ + id: string; + /** + * Scale denominator of this tile matrix + */ + scaleDenominator: number; + /** + * Cell size of this tile matrix + */ + cellSize: number; + /** + * The corner of the tile matrix (_topLeft_ or _bottomLeft_) used as the origin for numbering tile rows and columns. This corner is also a corner of the (0, 0) tile. + */ + cornerOfOrigin?: "topLeft" | "bottomLeft"; + pointOfOrigin: { + [k: string]: unknown; + } & TMSPoint; + /** + * Width of each tile of this tile matrix in pixels + */ + tileWidth: number; + /** + * Height of each tile of this tile matrix in pixels + */ + tileHeight: number; + /** + * Width of the matrix (number of tiles in width) + */ + matrixHeight: number; + /** + * Height of the matrix (number of tiles in height) + */ + matrixWidth: number; + /** + * Describes the rows that has variable matrix width + */ + variableMatrixWidths?: object[]; + [k: string]: unknown; +} From ea69bb4045357ccf50de9958cc7018be7f707fe8 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Tue, 28 Oct 2025 22:26:57 -0400 Subject: [PATCH 06/25] Add geotiff and proj4js deps --- package-lock.json | 75 +++++++++++++++++++++++++++++++++++++++++++++++ package.json | 2 ++ 2 files changed, 77 insertions(+) diff --git a/package-lock.json b/package-lock.json index 67480474..dfaf992e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,11 +20,13 @@ "apache-arrow": "^21.1.0", "esbuild-sass-plugin": "^3.3.1", "framer-motion": "^12.23.19", + "geotiff": "^2.1.4-beta.0", "lodash.debounce": "^4.0.8", "lodash.throttle": "^4.1.1", "maplibre-gl": "^5.9.0", "memoize-one": "^6.0.0", "parquet-wasm": "0.7.1", + "proj4": "^2.19.10", "react": "^19.2.0", "react-dom": "^19.2.0", "react-map-gl": "^8.1.0", @@ -5077,6 +5079,12 @@ "node": ">=0.10" } }, + "node_modules/@petamoriken/float16": { + "version": "3.9.3", + "resolved": "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.3.tgz", + "integrity": "sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==", + "license": "MIT" + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -10764,6 +10772,31 @@ "integrity": "sha512-AV9ROqlNqoZEIJGfm1ncNjEXfkz2hdFlZf0qkVfmkwdKa8vj7H16YUOT81rJw1rdFhyEDlN2Tds91p/glzbl5A==", "license": "ISC" }, + "node_modules/geotiff": { + "version": "2.1.4-beta.0", + "resolved": "https://registry.npmjs.org/geotiff/-/geotiff-2.1.4-beta.0.tgz", + "integrity": "sha512-jb6SYvHMyiCqwqgGGLDAxtig9h1g6O+n1wEyNEE4QgVEXOItYaWrEgPg9SAnwdoZm2yx6DpFtilbGG65hvZgpQ==", + "license": "MIT", + "dependencies": { + "@petamoriken/float16": "^3.4.7", + "lerc": "^3.0.0", + "pako": "^2.0.4", + "parse-headers": "^2.0.2", + "quick-lru": "^6.1.1", + "web-worker": "^1.5.0", + "xml-utils": "^1.10.2", + "zstddec": "^0.2.0-alpha.3" + }, + "engines": { + "node": ">=10.19" + } + }, + "node_modules/geotiff/node_modules/pako": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", + "license": "(MIT AND Zlib)" + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -11926,6 +11959,12 @@ "integrity": "sha512-FeA3g56ksdFNwjXJJsc1CCc7co+AJYDp6ipIp878zZ2bU8kWROatLYf39TQEd4/XRSUvBXovQ8gaVKWPXsCLEQ==", "license": "MIT" }, + "node_modules/lerc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lerc/-/lerc-3.0.0.tgz", + "integrity": "sha512-Rm4J/WaHhRa93nCN2mwWDZFoRVF18G1f47C+kvQWyHGEZxFpTUi73p7lMVSAndyxGt6lJ2/CFbOcf9ra5p8aww==", + "license": "Apache-2.0" + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -12934,6 +12973,12 @@ "integrity": "sha512-fjEGpMApzt3mpI2pUxdRgQGu5G+s4nr0vm5xn43JO7jxdYzzu2fHrVrTHtfeEhtB6vfvTzJBz0WydDYzLWvszQ==", "license": "MIT OR Apache-2.0" }, + "node_modules/parse-headers": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/parse-headers/-/parse-headers-2.0.6.tgz", + "integrity": "sha512-Tz11t3uKztEW5FEVZnj1ox8GKblWn+PvHY9TmJV5Mll2uHEwRdR/5Li1OlXoECjLYkApdhWy44ocONwXLiKO5A==", + "license": "MIT" + }, "node_modules/partysocket": { "version": "0.0.25", "resolved": "https://registry.npmjs.org/partysocket/-/partysocket-0.0.25.tgz", @@ -14203,6 +14248,18 @@ ], "license": "MIT" }, + "node_modules/quick-lru": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-6.1.2.tgz", + "integrity": "sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/quickselect": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", @@ -16671,6 +16728,12 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/web-worker": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz", + "integrity": "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==", + "license": "Apache-2.0" + }, "node_modules/wgsl_reflect": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/wgsl_reflect/-/wgsl_reflect-1.2.3.tgz", @@ -16941,6 +17004,12 @@ } } }, + "node_modules/xml-utils": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/xml-utils/-/xml-utils-1.10.2.tgz", + "integrity": "sha512-RqM+2o1RYs6T8+3DzDSoTRAUfrvaejbVHcp3+thnAtDKo8LskR+HomLajEy5UjTz24rpka7AxVBRR3g2wTUkJA==", + "license": "CC0-1.0" + }, "node_modules/xstate": { "version": "5.23.0", "resolved": "https://registry.npmjs.org/xstate/-/xstate-5.23.0.tgz", @@ -17011,6 +17080,12 @@ "integrity": "sha512-v3fyjpK8S/dpY/X5WxqTK3IoCnp/ZOLxn144GZVlNUjtwAchzrVo03h+oMATFhCIiJ5KTr4V3vDQQYz4RU684g==", "license": "MIT", "optional": true + }, + "node_modules/zstddec": { + "version": "0.2.0-alpha.3", + "resolved": "https://registry.npmjs.org/zstddec/-/zstddec-0.2.0-alpha.3.tgz", + "integrity": "sha512-uHyE3TN8jRFOaMVwdhERfrcaabyoUUawIRDKXE6x0nCU7mzyIZO0LndJ3AtVUiKLF0lC+8F5bMSySWEF586PSA==", + "license": "MIT AND BSD-3-Clause" } } } diff --git a/package.json b/package.json index 45c30006..735e7eda 100644 --- a/package.json +++ b/package.json @@ -15,11 +15,13 @@ "apache-arrow": "^21.1.0", "esbuild-sass-plugin": "^3.3.1", "framer-motion": "^12.23.19", + "geotiff": "^2.1.4-beta.0", "lodash.debounce": "^4.0.8", "lodash.throttle": "^4.1.1", "maplibre-gl": "^5.9.0", "memoize-one": "^6.0.0", "parquet-wasm": "0.7.1", + "proj4": "^2.19.10", "react": "^19.2.0", "react-dom": "^19.2.0", "react-map-gl": "^8.1.0", From 43d42c5e1e03b01408e902d6bc1a86a201cb0c1d Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Tue, 28 Oct 2025 23:26:11 -0400 Subject: [PATCH 07/25] Update types --- src/cog-tileset/types.ts | 160 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 156 insertions(+), 4 deletions(-) diff --git a/src/cog-tileset/types.ts b/src/cog-tileset/types.ts index 3918f559..d89d54c1 100644 --- a/src/cog-tileset/types.ts +++ b/src/cog-tileset/types.ts @@ -1,3 +1,5 @@ +import type { GeoTIFF } from "geotiff"; + export type ZRange = [minZ: number, maxZ: number]; export type Bounds = [minX: number, minY: number, maxX: number, maxY: number]; @@ -30,14 +32,164 @@ export type TileLoadProps = { zoom?: number; }; +//////////////// +// Claude-generated metadata +//////////////// + /** - * Custom tile index for COG tiles + * Represents a single resolution level in a Cloud Optimized GeoTIFF. + * + * COGs contain multiple resolution levels (overviews) for efficient + * visualization at different zoom levels. + * + * IMPORTANT: Overviews are ordered according to TileMatrixSet specification: + * - Index 0: Coarsest resolution (most zoomed out) + * - Index N: Finest resolution (most zoomed in) + * + * This matches the natural ordering where z increases with detail. */ -export type COGTileIndex = TileIndex & { - // Optional: include bounds for debugging/rendering - bounds?: [number, number, number, number]; +export type COGOverview = { + /** + * Overview index in the TileMatrixSet ordering. + * - Index 0: Coarsest resolution (most zoomed out) + * - Higher indices: Progressively finer resolution + * + * This is the index in the COGMetadata.overviews array and represents + * the natural ordering from coarse to fine. + * + * Note: This is different from GeoTIFF's internal level numbering where + * level 0 is the full resolution image. + * + * @example + * // For a COG with 4 resolutions: + * index: 0 // Coarsest: 1250x1000 pixels (8x downsampled) + * index: 1 // Medium: 2500x2000 pixels (4x downsampled) + * index: 2 // Fine: 5000x4000 pixels (2x downsampled) + * index: 3 // Finest: 10000x8000 pixels (full resolution) + */ + level: number; + + /** + * Zoom index (OSM convention). + * Defined as: maxLevel - currentLevel + * + * This makes the code compatible with OSM tile indexing where: + * - Higher z = finer detail (opposite of COG level) + * - Lower z = coarser detail + * + * In TileMatrixSet ordering: z === level (both increase with detail) + */ + z: number; + + /** + * Width of the entire image at this overview level, in pixels. + */ + width: number; + /** + * Height of the entire image at this overview level, in pixels. + */ + height: number; + + /** + * Number of tiles in the X (horizontal) direction at this overview level. + * + * Calculated as: Math.ceil(width / tileWidth) + * + * @example + * // If tileWidth = 512: + * tilesX: 3 // z=0: ceil(1250 / 512) + * tilesX: 5 // z=1: ceil(2500 / 512) + * tilesX: 10 // z=2: ceil(5000 / 512) + * tilesX: 20 // z=3: ceil(10000 / 512) + */ + tilesX: number; + + /** + * Number of tiles in the Y (vertical) direction at this overview level. + * + * Calculated as: Math.ceil(height / tileHeight) + * + * @example + * // If tileHeight = 512: + * tilesY: 2 // z=0: ceil(1000 / 512) + * tilesY: 4 // z=1: ceil(2000 / 512) + * tilesY: 8 // z=2: ceil(4000 / 512) + * tilesY: 16 // z=3: ceil(8000 / 512) + */ + tilesY: number; + + /** + * Downsampling scale factor relative to full resolution (finest level). + * + * Indicates how much this overview is downsampled compared to the finest resolution. + * - Scale factor of 1: Full resolution (finest level) + * - Scale factor of 2: Half resolution + * - Scale factor of 4: Quarter resolution + * - Scale factor of 8: Eighth resolution (coarsest in this example) + * + * Common pattern: Each overview is 2x downsampled from the next finer level, + * so scale factors are powers of 2: 8, 4, 2, 1 (from coarsest to finest) + * + * @example + * scaleFactor: 8 // z=0: 1250x1000 (8x downsampled from finest) + * scaleFactor: 4 // z=1: 2500x2000 (4x downsampled) + * scaleFactor: 2 // z=2: 5000x4000 (2x downsampled) + * scaleFactor: 1 // z=3: 10000x8000 (full resolution) + */ + scaleFactor: number; + + /** + * Index in the original GeoTIFF file. + * + * GeoTIFF stores: image 0 = full resolution, image 1+ = overviews (progressively coarser) + * This index is needed to read the correct image from the GeoTIFF file. + * + * Note: This may differ from `level` since we reorder overviews to TileMatrixSet order. + * + * @example + * // TileMatrixSet order (our array): + * level: 0, geoTiffIndex: 3 // Coarsest (GeoTIFF overview 3) + * level: 1, geoTiffIndex: 2 // Medium (GeoTIFF overview 2) + * level: 2, geoTiffIndex: 1 // Fine (GeoTIFF overview 1) + * level: 3, geoTiffIndex: 0 // Finest (GeoTIFF main image) + */ + geoTiffIndex: number; }; +/** + * COG Metadata extracted from GeoTIFF + */ +export type COGMetadata = { + width: number; + height: number; + tileWidth: number; + tileHeight: number; + tilesX: number; + tilesY: number; + bbox: Bounds; // COG's CRS + projection: string; + overviews: COGOverview[]; + image: GeoTIFF; // GeoTIFF reference +}; + +/** + * COG Tile Index + * + * In TileMatrixSet ordering: level === z (both 0 = coarsest, higher = finer) + */ +export type COGTileIndex = { + x: number; + y: number; + z: number; // TileMatrixSet/OSM zoom (0 = coarsest, higher = finer) + level: number; // Same as z in TileMatrixSet ordering + geoTiffIndex: number; // Index in GeoTIFF file (for reading tiles) + bounds?: Bounds; +}; + +//////////////// +// TileMatrixSet +//////////////// + // type CRS = string | { [k: string]: unknown }; export type TMSCrs = unknown; From 5372ac5b9724d7fc97eefebfb9bf2afbfcc3ce48 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Tue, 28 Oct 2025 23:39:30 -0400 Subject: [PATCH 08/25] Implement metadata extraction --- src/cog-tileset/claude-tileset-2d-improved.ts | 95 +++++++++++++++++++ src/cog-tileset/types.ts | 2 +- 2 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 src/cog-tileset/claude-tileset-2d-improved.ts diff --git a/src/cog-tileset/claude-tileset-2d-improved.ts b/src/cog-tileset/claude-tileset-2d-improved.ts new file mode 100644 index 00000000..559d81ef --- /dev/null +++ b/src/cog-tileset/claude-tileset-2d-improved.ts @@ -0,0 +1,95 @@ +/** + * COGTileset2D - Improved Implementation with Frustum Culling + * + * This version properly implements frustum culling and bounding volume calculations + * following the pattern from deck.gl's OSM tile indexing. + */ + +import { Viewport, WebMercatorViewport, _GlobeViewport } from "@deck.gl/core"; +import { _Tileset2D as Tileset2D } from "@deck.gl/geo-layers"; +import type { Bounds, ZRange } from "@deck.gl/geo-layers/dist/tileset-2d/types"; +import { + CullingVolume, + Plane, + AxisAlignedBoundingBox, + makeOrientedBoundingBoxFromPoints, +} from "@math.gl/culling"; +import { GeoTIFF } from "geotiff"; + +import type { COGMetadata, COGTileIndex, COGOverview } from "./types"; + +/** + * Extract COG metadata + */ +async function extractCOGMetadata(tiff: GeoTIFF): Promise { + const image = await tiff.getImage(); + + const width = image.getWidth(); + const height = image.getHeight(); + const tileWidth = image.getTileWidth(); + const tileHeight = image.getTileHeight(); + + const tilesX = Math.ceil(width / tileWidth); + const tilesY = Math.ceil(height / tileHeight); + + const bbox = image.getBoundingBox(); + const geoKeys = image.getGeoKeys(); + const projection = + geoKeys.ProjectedCSTypeGeoKey || geoKeys.GeographicTypeGeoKey + ? `EPSG:${geoKeys.ProjectedCSTypeGeoKey || geoKeys.GeographicTypeGeoKey}` + : null; + + // Overviews **in COG order**, from finest to coarsest (we'll reverse the + // array later) + const overviews: COGOverview[] = []; + const imageCount = await tiff.getImageCount(); + + // Full resolution image (GeoTIFF index 0) + overviews.push({ + geoTiffIndex: 0, + width, + height, + tilesX, + tilesY, + scaleFactor: 1, + // TODO: combine these two properties into one + level: imageCount - 1, // Coarsest level number + z: imageCount - 1, + }); + + for (let i = 1; i < imageCount; i++) { + const overview = await tiff.getImage(i); + const overviewWidth = overview.getWidth(); + const overviewHeight = overview.getHeight(); + const overviewTileWidth = overview.getTileWidth(); + const overviewTileHeight = overview.getTileHeight(); + + overviews.push({ + geoTiffIndex: i, + width: overviewWidth, + height: overviewHeight, + tilesX: Math.ceil(overviewWidth / overviewTileWidth), + tilesY: Math.ceil(overviewHeight / overviewTileHeight), + scaleFactor: Math.round(width / overviewWidth), + // TODO: combine these two properties into one + level: imageCount - 1 - i, + z: imageCount - 1 - i, + }); + } + + // Reverse to TileMatrixSet order: coarsest (0) → finest (n) + overviews.reverse(); + + return { + width, + height, + tileWidth, + tileHeight, + tilesX, + tilesY, + bbox: [bbox[0], bbox[1], bbox[2], bbox[3]], + projection, + overviews, + image: tiff, + }; +} diff --git a/src/cog-tileset/types.ts b/src/cog-tileset/types.ts index d89d54c1..174e2d53 100644 --- a/src/cog-tileset/types.ts +++ b/src/cog-tileset/types.ts @@ -167,7 +167,7 @@ export type COGMetadata = { tilesX: number; tilesY: number; bbox: Bounds; // COG's CRS - projection: string; + projection: string | null; overviews: COGOverview[]; image: GeoTIFF; // GeoTIFF reference }; From 03c7826802d314fc685dc6a64ba8d0797a5e899f Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Tue, 28 Oct 2025 23:55:47 -0400 Subject: [PATCH 09/25] wip cog traversal --- src/cog-tileset/cog-tile-2d-traversal.ts | 301 +++++++++++++++++++++++ 1 file changed, 301 insertions(+) create mode 100644 src/cog-tileset/cog-tile-2d-traversal.ts diff --git a/src/cog-tileset/cog-tile-2d-traversal.ts b/src/cog-tileset/cog-tile-2d-traversal.ts new file mode 100644 index 00000000..4af71920 --- /dev/null +++ b/src/cog-tileset/cog-tile-2d-traversal.ts @@ -0,0 +1,301 @@ +import { Viewport } from "@deck.gl/core"; +import { + CullingVolume, + AxisAlignedBoundingBox, + makeOrientedBoundingBoxFromPoints, +} from "@math.gl/culling"; + +import { Bounds, ZRange } from "./types"; +import type { COGMetadata, COGOverview } from "./types"; + +// for calculating bounding volume of a tile in a non-web-mercator viewport +const REF_POINTS_5 = [ + [0.5, 0.5], + [0, 0], + [0, 1], + [1, 0], + [1, 1], +]; // 4 corners and center + +/** + * COG Tile Node - similar to OSMNode but for COG's tile structure + * + * Uses TileMatrixSet ordering where: index 0 = coarsest, higher = finer. + * In this ordering, z === level (both increase with detail). + */ +export class COGTileNode { + /** Index across a row */ + x: number; + /** Index down a column */ + y: number; + /** TileMatrixSet-style zoom index (higher = finer detail) */ + z: number; + + private cogMetadata: COGMetadata; + + private childVisible?: boolean; + private selected?: boolean; + /** A cache of the children of this node. */ + private _children?: COGTileNode[]; + + constructor(x: number, y: number, z: number, cogMetadata: COGMetadata) { + this.x = x; + this.y = y; + this.z = z; + this.cogMetadata = cogMetadata; + } + + /** Get overview info for this tile's z level */ + get overview(): COGOverview { + return this.cogMetadata.overviews[this.z]; + } + + /** Get the children of this node. */ + get children(): COGTileNode[] { + if (!this._children) { + const maxZ = this.cogMetadata.overviews.length - 1; + if (this.z >= maxZ) { + // Already at finest resolution, no children + return []; + } + + // In TileMatrixSet ordering: refine to z + 1 (finer detail) + const childZ = this.z + 1; + const parentOverview = this.overview; + const childOverview = this.cogMetadata.overviews[childZ]; + + // Calculate scale factor between levels + const scaleFactor = + parentOverview.scaleFactor / childOverview.scaleFactor; + + // Generate child tiles + this._children = []; + for (let dy = 0; dy < scaleFactor; dy++) { + for (let dx = 0; dx < scaleFactor; dx++) { + const childX = this.x * scaleFactor + dx; + const childY = this.y * scaleFactor + dy; + + // Only create child if it's within bounds + // Some tiles on the edges might not need to be created at higher + // resolutions (higher map zoom level) + if (childX < childOverview.tilesX && childY < childOverview.tilesY) { + this._children.push( + new COGTileNode(childX, childY, childZ, this.cogMetadata), + ); + } + } + } + } + return this._children; + } + + /** + * Update tile visibility using frustum culling + * This follows the pattern from OSMNode + */ + update(params: { + viewport: Viewport; + project: ((xyz: number[]) => number[]) | null; + cullingVolume: CullingVolume; + elevationBounds: ZRange; + minZ: number; // Minimum z (coarsest acceptable) + maxZ: number; // Maximum z (finest acceptable) + bounds?: Bounds; // In COG's coordinate space + }): boolean { + const { + viewport, + cullingVolume, + elevationBounds, + minZ, + maxZ, + bounds, + project, + } = params; + + // Get bounding volume for this tile + const boundingVolume = this.getBoundingVolume(elevationBounds, project); + + // Note: this is a part of the upstream code because they have _generic_ + // tiling systems, where the client doesn't know whether a given xyz tile + // actually exists. So the idea of `bounds` is to avoid even trying to fetch + // tiles that the user doesn't care about (think oceans) + // + // But in our case, we have known bounds from the COG metadata. So the tiles + // are explicitly constructed to match only tiles that exist. + + // Check if tile is within user-specified bounds + // if (bounds && !this.insideBounds(bounds)) { + // return false; + // } + + // Check if tile is visible in frustum + const isInside = cullingVolume.computeVisibility(boundingVolume); + if (isInside < 0) { + return false; + } + + // Avoid loading overlapping tiles + if (!this.childVisible) { + let { z } = this; + + if (z < maxZ && z >= minZ) { + // Adjust LOD based on distance from camera + // If tile is far from camera, accept coarser resolution (lower z) + const distance = + (boundingVolume.distanceTo(viewport.cameraPosition) * + viewport.scale) / + viewport.height; + z += Math.floor(Math.log2(distance)); + } + + if (z >= maxZ) { + // LOD is acceptable + this.selected = true; + return true; + } + } + + // LOD is not enough, recursively test child tiles + this.selected = false; + this.childVisible = true; + + const children = this.children; + // NOTE: this deviates from upstream; we could move to the upstream code if + // we pass in maxZ correctly I think + if (children.length === 0) { + // No children available (at finest resolution), select this tile + this.selected = true; + return true; + } + + for (const child of children) { + child.update(params); + } + return true; + } + + /** + * Collect all selected tiles + */ + getSelected(result: COGTileNode[] = []): COGTileNode[] { + if (this.selected) { + result.push(this); + } + if (this._children) { + for (const node of this._children) { + node.getSelected(result); + } + } + return result; + } + + // Can probably be removed. + // See note in `update`. + + // /** + // * Check if tile intersects user-specified bounds (in COG coordinate space) + // */ + // insideBounds([minX, minY, maxX, maxY]: Bounds): boolean { + // const overview = this.overview; + // const { bbox, tileWidth, tileHeight } = this.cogMetadata; + + // const cogWidth = bbox[2] - bbox[0]; + // const cogHeight = bbox[3] - bbox[1]; + + // const tileGeoWidth = cogWidth / overview.tilesX; + // const tileGeoHeight = cogHeight / overview.tilesY; + + // const tileMinX = bbox[0] + this.x * tileGeoWidth; + // const tileMinY = bbox[1] + this.y * tileGeoHeight; + // const tileMaxX = tileMinX + tileGeoWidth; + // const tileMaxY = tileMinY + tileGeoHeight; + + // return ( + // tileMinX < maxX && tileMinY < maxY && tileMaxX > minX && tileMaxY > minY + // ); + // } + + /** + * Calculate bounding volume for frustum culling + */ + getBoundingVolume( + zRange: ZRange, + project: ((xyz: number[]) => number[]) | null, + ) { + const overview = this.overview; + const { bbox, tileWidth, tileHeight } = this.cogMetadata; + + const cogWidth = bbox[2] - bbox[0]; + const cogHeight = bbox[3] - bbox[1]; + + const tileGeoWidth = cogWidth / overview.tilesX; + const tileGeoHeight = cogHeight / overview.tilesY; + + const tileMinX = bbox[0] + this.x * tileGeoWidth; + const tileMinY = bbox[1] + this.y * tileGeoHeight; + const tileMaxX = tileMinX + tileGeoWidth; + const tileMaxY = tileMinY + tileGeoHeight; + + if (project) { + // Custom projection (e.g., GlobeView) + // Sample points on tile to create bounding volume + const refPoints = [ + [0.5, 0.5], // center + [0, 0], + [0, 1], + [1, 0], + [1, 1], // corners + ]; + + const refPointPositions: number[][] = []; + for (const [fx, fy] of refPoints) { + const geoX = tileMinX + fx * tileGeoWidth; + const geoY = tileMinY + fy * tileGeoHeight; + + // Convert from COG coordinates to lng/lat + // This assumes COG is in Web Mercator - adjust for other projections + const lngLat = this.cogCoordsToLngLat([geoX, geoY]); + lngLat[2] = zRange[0]; + refPointPositions.push(project(lngLat)); + + if (zRange[0] !== zRange[1]) { + lngLat[2] = zRange[1]; + refPointPositions.push(project(lngLat)); + } + } + + return makeOrientedBoundingBoxFromPoints(refPointPositions); + } + + // Web Mercator projection + // Assuming COG is already in Web Mercator (EPSG:3857) + // Convert from meters to deck.gl's common space (world units) + const WORLD_SIZE = 512; // deck.gl's world size + const METERS_PER_WORLD = 40075017; // Earth circumference at equator + + const worldMinX = (tileMinX / METERS_PER_WORLD) * WORLD_SIZE; + const worldMaxX = (tileMaxX / METERS_PER_WORLD) * WORLD_SIZE; + + // Y is flipped in deck.gl's common space + const worldMinY = WORLD_SIZE - (tileMaxY / METERS_PER_WORLD) * WORLD_SIZE; + const worldMaxY = WORLD_SIZE - (tileMinY / METERS_PER_WORLD) * WORLD_SIZE; + + return new AxisAlignedBoundingBox( + [worldMinX, worldMinY, zRange[0]], + [worldMaxX, worldMaxY, zRange[1]], + ); + } + + /** + * Convert COG coordinates to lng/lat + * This is a placeholder - needs proper projection library (proj4js) + */ + private cogCoordsToLngLat([x, y]: [number, number]): number[] { + // For Web Mercator (EPSG:3857) + const R = 6378137; // Earth radius + const lng = (x / R) * (180 / Math.PI); + const lat = + (Math.PI / 2 - 2 * Math.atan(Math.exp(-y / R))) * (180 / Math.PI); + return [lng, lat, 0]; + } +} From 32640d1f2265ed170404102bfa941729657755ae Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Tue, 28 Oct 2025 23:56:42 -0400 Subject: [PATCH 10/25] Remove insideBounds implementation --- src/cog-tileset/cog-tile-2d-traversal.ts | 26 ------------------------ 1 file changed, 26 deletions(-) diff --git a/src/cog-tileset/cog-tile-2d-traversal.ts b/src/cog-tileset/cog-tile-2d-traversal.ts index 4af71920..685c2fe3 100644 --- a/src/cog-tileset/cog-tile-2d-traversal.ts +++ b/src/cog-tileset/cog-tile-2d-traversal.ts @@ -189,32 +189,6 @@ export class COGTileNode { return result; } - // Can probably be removed. - // See note in `update`. - - // /** - // * Check if tile intersects user-specified bounds (in COG coordinate space) - // */ - // insideBounds([minX, minY, maxX, maxY]: Bounds): boolean { - // const overview = this.overview; - // const { bbox, tileWidth, tileHeight } = this.cogMetadata; - - // const cogWidth = bbox[2] - bbox[0]; - // const cogHeight = bbox[3] - bbox[1]; - - // const tileGeoWidth = cogWidth / overview.tilesX; - // const tileGeoHeight = cogHeight / overview.tilesY; - - // const tileMinX = bbox[0] + this.x * tileGeoWidth; - // const tileMinY = bbox[1] + this.y * tileGeoHeight; - // const tileMaxX = tileMinX + tileGeoWidth; - // const tileMaxY = tileMinY + tileGeoHeight; - - // return ( - // tileMinX < maxX && tileMinY < maxY && tileMaxX > minX && tileMaxY > minY - // ); - // } - /** * Calculate bounding volume for frustum culling */ From 952073c6c92849da60a5e8eea118455f3e858d91 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Tue, 28 Oct 2025 23:57:21 -0400 Subject: [PATCH 11/25] don't need to pass in bounds --- src/cog-tileset/cog-tile-2d-traversal.ts | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/src/cog-tileset/cog-tile-2d-traversal.ts b/src/cog-tileset/cog-tile-2d-traversal.ts index 685c2fe3..5866047a 100644 --- a/src/cog-tileset/cog-tile-2d-traversal.ts +++ b/src/cog-tileset/cog-tile-2d-traversal.ts @@ -100,17 +100,9 @@ export class COGTileNode { elevationBounds: ZRange; minZ: number; // Minimum z (coarsest acceptable) maxZ: number; // Maximum z (finest acceptable) - bounds?: Bounds; // In COG's coordinate space }): boolean { - const { - viewport, - cullingVolume, - elevationBounds, - minZ, - maxZ, - bounds, - project, - } = params; + const { viewport, cullingVolume, elevationBounds, minZ, maxZ, project } = + params; // Get bounding volume for this tile const boundingVolume = this.getBoundingVolume(elevationBounds, project); From ae146d23924aa2570f16c707d9180bae515a8e9f Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Wed, 29 Oct 2025 00:02:49 -0400 Subject: [PATCH 12/25] progress on tile traversal --- src/cog-tileset/cog-tile-2d-traversal.ts | 80 +++++++++++------------- 1 file changed, 37 insertions(+), 43 deletions(-) diff --git a/src/cog-tileset/cog-tile-2d-traversal.ts b/src/cog-tileset/cog-tile-2d-traversal.ts index 5866047a..74c8a188 100644 --- a/src/cog-tileset/cog-tile-2d-traversal.ts +++ b/src/cog-tileset/cog-tile-2d-traversal.ts @@ -1,21 +1,16 @@ -import { Viewport } from "@deck.gl/core"; -import { - CullingVolume, - AxisAlignedBoundingBox, - makeOrientedBoundingBoxFromPoints, -} from "@math.gl/culling"; +import { assert, Viewport } from "@deck.gl/core"; +import { CullingVolume, AxisAlignedBoundingBox } from "@math.gl/culling"; -import { Bounds, ZRange } from "./types"; -import type { COGMetadata, COGOverview } from "./types"; +import type { COGMetadata, COGOverview, ZRange } from "./types"; // for calculating bounding volume of a tile in a non-web-mercator viewport -const REF_POINTS_5 = [ - [0.5, 0.5], - [0, 0], - [0, 1], - [1, 0], - [1, 1], -]; // 4 corners and center +// const REF_POINTS_5 = [ +// [0.5, 0.5], +// [0, 0], +// [0, 1], +// [1, 0], +// [1, 1], +// ]; // 4 corners and center /** * COG Tile Node - similar to OSMNode but for COG's tile structure @@ -189,7 +184,7 @@ export class COGTileNode { project: ((xyz: number[]) => number[]) | null, ) { const overview = this.overview; - const { bbox, tileWidth, tileHeight } = this.cogMetadata; + const { bbox } = this.cogMetadata; const cogWidth = bbox[2] - bbox[0]; const cogHeight = bbox[3] - bbox[1]; @@ -203,36 +198,35 @@ export class COGTileNode { const tileMaxY = tileMinY + tileGeoHeight; if (project) { - // Custom projection (e.g., GlobeView) - // Sample points on tile to create bounding volume - const refPoints = [ - [0.5, 0.5], // center - [0, 0], - [0, 1], - [1, 0], - [1, 1], // corners - ]; - - const refPointPositions: number[][] = []; - for (const [fx, fy] of refPoints) { - const geoX = tileMinX + fx * tileGeoWidth; - const geoY = tileMinY + fy * tileGeoHeight; - - // Convert from COG coordinates to lng/lat - // This assumes COG is in Web Mercator - adjust for other projections - const lngLat = this.cogCoordsToLngLat([geoX, geoY]); - lngLat[2] = zRange[0]; - refPointPositions.push(project(lngLat)); - - if (zRange[0] !== zRange[1]) { - lngLat[2] = zRange[1]; - refPointPositions.push(project(lngLat)); - } - } + assert(false, "TODO: check bounding volume implementation in Globe view"); - return makeOrientedBoundingBoxFromPoints(refPointPositions); + // Custom projection (e.g., GlobeView) + // Estimate bounding box from sample points + // TODO: switch to higher ref points at lowest zoom levels, like upstream + // const refPoints = REF_POINTS_5; + + // const refPointPositions: number[][] = []; + // for (const [fx, fy] of refPoints) { + // const geoX = tileMinX + fx * tileGeoWidth; + // const geoY = tileMinY + fy * tileGeoHeight; + + // // Convert from COG coordinates to lng/lat + // // This assumes COG is in Web Mercator - adjust for other projections + // const lngLat = this.cogCoordsToLngLat([geoX, geoY]); + // lngLat[2] = zRange[0]; + // refPointPositions.push(project(lngLat)); + + // if (zRange[0] !== zRange[1]) { + // lngLat[2] = zRange[1]; + // refPointPositions.push(project(lngLat)); + // } + // } + + // return makeOrientedBoundingBoxFromPoints(refPointPositions); } + assert(false, "bounding volume of web mercator is probably wrong"); + // Web Mercator projection // Assuming COG is already in Web Mercator (EPSG:3857) // Convert from meters to deck.gl's common space (world units) From 27383a86683ae52eaa7c393ec7fd4d966aa35f2e Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Wed, 29 Oct 2025 00:32:20 -0400 Subject: [PATCH 13/25] clean up cog traversal --- src/cog-tileset/cog-tile-2d-traversal.ts | 128 +++++++++++++++++++++-- 1 file changed, 122 insertions(+), 6 deletions(-) diff --git a/src/cog-tileset/cog-tile-2d-traversal.ts b/src/cog-tileset/cog-tile-2d-traversal.ts index 74c8a188..07511622 100644 --- a/src/cog-tileset/cog-tile-2d-traversal.ts +++ b/src/cog-tileset/cog-tile-2d-traversal.ts @@ -1,7 +1,13 @@ -import { assert, Viewport } from "@deck.gl/core"; -import { CullingVolume, AxisAlignedBoundingBox } from "@math.gl/culling"; +import { _GlobeViewport, assert, Viewport } from "@deck.gl/core"; +import { AxisAlignedBoundingBox, CullingVolume, Plane } from "@math.gl/culling"; -import type { COGMetadata, COGOverview, ZRange } from "./types"; +import type { + Bounds, + COGMetadata, + COGOverview, + COGTileIndex, + ZRange, +} from "./types"; // for calculating bounding volume of a tile in a non-web-mercator viewport // const REF_POINTS_5 = [ @@ -18,7 +24,7 @@ import type { COGMetadata, COGOverview, ZRange } from "./types"; * Uses TileMatrixSet ordering where: index 0 = coarsest, higher = finer. * In this ordering, z === level (both increase with detail). */ -export class COGTileNode { +class COGTileNode { /** Index across a row */ x: number; /** Index down a column */ @@ -93,8 +99,10 @@ export class COGTileNode { project: ((xyz: number[]) => number[]) | null; cullingVolume: CullingVolume; elevationBounds: ZRange; - minZ: number; // Minimum z (coarsest acceptable) - maxZ: number; // Maximum z (finest acceptable) + /** Minimum (coarsest) COG overview level */ + minZ: number; + /** Maximum (finest) COG overview level */ + maxZ: number; }): boolean { const { viewport, cullingVolume, elevationBounds, minZ, maxZ, project } = params; @@ -186,6 +194,7 @@ export class COGTileNode { const overview = this.overview; const { bbox } = this.cogMetadata; + // TODO: use tileWidth/tileHeight from cogMetadata instead? const cogWidth = bbox[2] - bbox[0]; const cogHeight = bbox[3] - bbox[1]; @@ -259,3 +268,110 @@ export class COGTileNode { return [lng, lat, 0]; } } + +/** + * Get tile indices visible in viewport + * Uses frustum culling similar to OSM implementation + * + * Overviews follow TileMatrixSet ordering: index 0 = coarsest, higher = finer + */ +export function getTileIndices(opts: { + cogMetadata: COGMetadata; + viewport: Viewport; + maxZ: number; + // minZ?: number; + zRange: ZRange | null; +}): COGTileIndex[] { + const { cogMetadata, viewport, maxZ, zRange } = opts; + + const project: ((xyz: number[]) => number[]) | null = + viewport instanceof _GlobeViewport && viewport.resolution + ? viewport.projectPosition + : null; + + // Get the culling volume of the current camera + const planes: Plane[] = Object.values(viewport.getFrustumPlanes()).map( + ({ normal, distance }) => new Plane(normal.clone().negate(), distance), + ); + const cullingVolume = new CullingVolume(planes); + + // Project zRange from meters to common space + const unitsPerMeter = viewport.distanceScales.unitsPerMeter[2]; + const elevationMin = (zRange && zRange[0] * unitsPerMeter) || 0; + const elevationMax = (zRange && zRange[1] * unitsPerMeter) || 0; + + // // Always load at the current zoom level if pitch is small + // const minZ = + // viewport instanceof WebMercatorViewport && viewport.pitch <= 60 ? maxZ : 0; + + // // Map maxZoom/minZoom to COG overview levels + // // In COG: level 0 = full resolution (finest), higher levels = coarser + // // In deck.gl zoom: higher = finer + // // So we need to invert: maxZoom (finest) → minLevel (level 0) + // const minLevel = 0; // Always allow full resolution + // const maxLevel = Math.min( + // cogMetadata.overviews.length - 1, + // Math.max(0, cogMetadata.overviews.length - 1 - (maxZ || 0)), + // ); + + // Start from coarsest overview + const coarsestOverview = cogMetadata.overviews[0]; + + // Create root tiles at coarsest level + // In contrary to OSM tiling, we usually have more than one tile at the + // coarsest level (z=0) + const roots: COGTileNode[] = []; + for (let y = 0; y < coarsestOverview.tilesY; y++) { + for (let x = 0; x < coarsestOverview.tilesX; x++) { + roots.push(new COGTileNode(x, y, 0, cogMetadata)); + } + } + + // Traverse and update visibility + const traversalParams = { + viewport, + project, + cullingVolume, + elevationBounds: [elevationMin, elevationMax] as ZRange, + minZ: 0, + maxZ, + }; + + for (const root of roots) { + root.update(traversalParams); + } + + // Collect selected tiles + const selectedNodes: COGTileNode[] = []; + for (const root of roots) { + root.getSelected(selectedNodes); + } + + // Convert to tile indices with bounds + return selectedNodes.map((node) => { + const overview = cogMetadata.overviews[node.z]; + const { bbox } = cogMetadata; + + const cogWidth = bbox[2] - bbox[0]; + const cogHeight = bbox[3] - bbox[1]; + + // TODO: use tileWidth/tileHeight from cogMetadata instead? + const tileGeoWidth = cogWidth / overview.tilesX; + const tileGeoHeight = cogHeight / overview.tilesY; + + const bounds: Bounds = [ + bbox[0] + node.x * tileGeoWidth, + bbox[1] + node.y * tileGeoHeight, + bbox[0] + (node.x + 1) * tileGeoWidth, + bbox[1] + (node.y + 1) * tileGeoHeight, + ]; + + return { + x: node.x, + y: node.y, + z: node.z, + level: node.z, + bounds, + }; + }); +} From 1a61c1e683597851cf9debd5196d149660978306 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Wed, 29 Oct 2025 00:41:42 -0400 Subject: [PATCH 14/25] cleanup --- src/cog-tileset/claude-tileset-2d-improved.ts | 82 +++++++++++++++++-- src/cog-tileset/cog-tile-2d-traversal.ts | 32 +++++--- src/cog-tileset/types.ts | 2 +- 3 files changed, 95 insertions(+), 21 deletions(-) diff --git a/src/cog-tileset/claude-tileset-2d-improved.ts b/src/cog-tileset/claude-tileset-2d-improved.ts index 559d81ef..3832c2fa 100644 --- a/src/cog-tileset/claude-tileset-2d-improved.ts +++ b/src/cog-tileset/claude-tileset-2d-improved.ts @@ -5,23 +5,20 @@ * following the pattern from deck.gl's OSM tile indexing. */ -import { Viewport, WebMercatorViewport, _GlobeViewport } from "@deck.gl/core"; +import { Viewport, _GlobeViewport } from "@deck.gl/core"; import { _Tileset2D as Tileset2D } from "@deck.gl/geo-layers"; -import type { Bounds, ZRange } from "@deck.gl/geo-layers/dist/tileset-2d/types"; -import { - CullingVolume, - Plane, - AxisAlignedBoundingBox, - makeOrientedBoundingBoxFromPoints, -} from "@math.gl/culling"; +import type { Tileset2DProps } from "@deck.gl/geo-layers/dist/tileset-2d"; +import type { ZRange } from "@deck.gl/geo-layers/dist/tileset-2d/types"; +import { Matrix4 } from "@math.gl/core"; import { GeoTIFF } from "geotiff"; +import { getTileIndices } from "./cog-tile-2d-traversal"; import type { COGMetadata, COGTileIndex, COGOverview } from "./types"; /** * Extract COG metadata */ -async function extractCOGMetadata(tiff: GeoTIFF): Promise { +export async function extractCOGMetadata(tiff: GeoTIFF): Promise { const image = await tiff.getImage(); const width = image.getWidth(); @@ -93,3 +90,70 @@ async function extractCOGMetadata(tiff: GeoTIFF): Promise { image: tiff, }; } + +/** + * COGTileset2D with proper frustum culling + */ +export class COGTileset2D extends Tileset2D { + private cogMetadata: COGMetadata; + + constructor(cogMetadata: COGMetadata, opts: Tileset2DProps) { + super(opts); + this.cogMetadata = cogMetadata; + } + + /** + * Get tile indices visible in viewport + * Uses frustum culling similar to OSM implementation + * + * Overviews follow TileMatrixSet ordering: index 0 = coarsest, higher = finer + */ + getTileIndices(opts: { + viewport: Viewport; + maxZoom?: number; + minZoom?: number; + zRange: ZRange | null; + modelMatrix?: Matrix4; + modelMatrixInverse?: Matrix4; + }): COGTileIndex[] { + return getTileIndices(this.cogMetadata, opts); + } + + getTileId(index: COGTileIndex): string { + return `${index.x}-${index.y}-${index.z}`; + } + + getParentIndex(index: COGTileIndex): COGTileIndex { + if (index.z === 0) { + // Already at coarsest level + return index; + } + + const currentOverview = this.cogMetadata.overviews[index.z]; + const parentOverview = this.cogMetadata.overviews[index.z - 1]; + + const scaleFactor = + currentOverview.scaleFactor / parentOverview.scaleFactor; + + return { + x: Math.floor(index.x / scaleFactor), + y: Math.floor(index.y / scaleFactor), + z: index.z - 1, + }; + } + + getTileZoom(index: COGTileIndex): number { + return index.z; + } + + getTileMetadata(index: COGTileIndex): Record { + const overview = this.cogMetadata.overviews[index.z]; + return { + bounds: index.bounds, + level: index.level, + tileWidth: this.cogMetadata.tileWidth, + tileHeight: this.cogMetadata.tileHeight, + overview, + }; + } +} diff --git a/src/cog-tileset/cog-tile-2d-traversal.ts b/src/cog-tileset/cog-tile-2d-traversal.ts index 07511622..91150250 100644 --- a/src/cog-tileset/cog-tile-2d-traversal.ts +++ b/src/cog-tileset/cog-tile-2d-traversal.ts @@ -102,10 +102,16 @@ class COGTileNode { /** Minimum (coarsest) COG overview level */ minZ: number; /** Maximum (finest) COG overview level */ - maxZ: number; + maxZ?: number; }): boolean { - const { viewport, cullingVolume, elevationBounds, minZ, maxZ, project } = - params; + const { + viewport, + cullingVolume, + elevationBounds, + minZ, + maxZ = this.cogMetadata.overviews.length - 1, + project, + } = params; // Get bounding volume for this tile const boundingVolume = this.getBoundingVolume(elevationBounds, project); @@ -275,14 +281,16 @@ class COGTileNode { * * Overviews follow TileMatrixSet ordering: index 0 = coarsest, higher = finer */ -export function getTileIndices(opts: { - cogMetadata: COGMetadata; - viewport: Viewport; - maxZ: number; - // minZ?: number; - zRange: ZRange | null; -}): COGTileIndex[] { - const { cogMetadata, viewport, maxZ, zRange } = opts; +export function getTileIndices( + cogMetadata: COGMetadata, + opts: { + viewport: Viewport; + maxZ?: number; + // minZ?: number; + zRange: ZRange | null; + }, +): COGTileIndex[] { + const { viewport, maxZ, zRange } = opts; const project: ((xyz: number[]) => number[]) | null = viewport instanceof _GlobeViewport && viewport.resolution @@ -347,6 +355,8 @@ export function getTileIndices(opts: { root.getSelected(selectedNodes); } + // TODO: remove this from here + // Instead, move it to `getTileMetadata` in the tileset class // Convert to tile indices with bounds return selectedNodes.map((node) => { const overview = cogMetadata.overviews[node.z]; diff --git a/src/cog-tileset/types.ts b/src/cog-tileset/types.ts index 174e2d53..647aaeea 100644 --- a/src/cog-tileset/types.ts +++ b/src/cog-tileset/types.ts @@ -182,7 +182,7 @@ export type COGTileIndex = { y: number; z: number; // TileMatrixSet/OSM zoom (0 = coarsest, higher = finer) level: number; // Same as z in TileMatrixSet ordering - geoTiffIndex: number; // Index in GeoTIFF file (for reading tiles) + geoTiffIndex?: number; // Index in GeoTIFF file (for reading tiles) bounds?: Bounds; }; From a72665b815d23e673434c1224c0e51b95f19939a Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Wed, 5 Nov 2025 17:28:39 -0500 Subject: [PATCH 15/25] lock update --- package-lock.json | 185 +++++++++++++++++----------------------------- 1 file changed, 66 insertions(+), 119 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1eb17077..be9a4e7c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -106,8 +106,7 @@ "version": "2.10.0", "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.10.0.tgz", "integrity": "sha512-fdRs9PSrBF7QUntpZpq6BTw58fhgGJojgg39m9oFOJGZT+nip9b0so5cYY1oWl5pvemDLr0cPPsH46vwThEbpQ==", - "license": "(Apache-2.0 AND BSD-3-Clause)", - "peer": true + "license": "(Apache-2.0 AND BSD-3-Clause)" }, "node_modules/@csstools/cascade-layer-name-parser": { "version": "2.0.5", @@ -221,6 +220,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -244,6 +244,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -1358,6 +1359,7 @@ "resolved": "https://registry.npmjs.org/@deck.gl/core/-/core-9.2.2.tgz", "integrity": "sha512-ZvCV8kcC730t62Q+iiCn8SOPgDCEyvV6i/GvIXf59Rnyl8pRvo7wcbl5zorbIEFng+a+EcYv/tEAZcAkwe1oEA==", "license": "MIT", + "peer": true, "dependencies": { "@loaders.gl/core": "^4.2.0", "@loaders.gl/images": "^4.2.0", @@ -1383,6 +1385,7 @@ "resolved": "https://registry.npmjs.org/@deck.gl/extensions/-/extensions-9.2.2.tgz", "integrity": "sha512-DwbUai9Gm3wdKUj7rflMeOTsBHXlvm5Zah10qNHehv4TNgt8iZ1FFvbZoTi5ocj8s5wo0JdyLQuT98r5LGplrw==", "license": "MIT", + "peer": true, "dependencies": { "@luma.gl/constants": "^9.2.2", "@luma.gl/shadertools": "^9.2.2", @@ -1434,6 +1437,7 @@ "resolved": "https://registry.npmjs.org/@deck.gl/layers/-/layers-9.2.2.tgz", "integrity": "sha512-O1tmE6I7KQs3Wv2W+KkLo3mOW6CqWn92jwkCq7i5b7OZwxNfzdEQFK3bDpQTfDEp1BTwR6bMP1TkhGBVksQJ2Q==", "license": "MIT", + "peer": true, "dependencies": { "@loaders.gl/images": "^4.2.0", "@loaders.gl/schema": "^4.2.0", @@ -1472,6 +1476,7 @@ "resolved": "https://registry.npmjs.org/@deck.gl/mesh-layers/-/mesh-layers-9.2.2.tgz", "integrity": "sha512-OhH11zy2yyETXY6rwinJnaKk3eTzAk4BHH31ZltK+DI4XHBUVPpXHIyI9soPsI06TObfdx54LgLxdA/PIDdscg==", "license": "MIT", + "peer": true, "dependencies": { "@loaders.gl/gltf": "^4.2.0", "@loaders.gl/schema": "^4.2.0", @@ -2462,7 +2467,6 @@ "resolved": "https://registry.npmjs.org/@loaders.gl/3d-tiles/-/3d-tiles-4.3.4.tgz", "integrity": "sha512-JQ3y3p/KlZP7lfobwON5t7H9WinXEYTvuo3SRQM8TBKhM+koEYZhvI2GwzoXx54MbBbY+s3fm1dq5UAAmaTsZw==", "license": "MIT", - "peer": true, "dependencies": { "@loaders.gl/compression": "4.3.4", "@loaders.gl/crypto": "4.3.4", @@ -2487,15 +2491,13 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/@loaders.gl/compression": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/@loaders.gl/compression/-/compression-4.3.4.tgz", "integrity": "sha512-+o+5JqL9Sx8UCwdc2MTtjQiUHYQGJALHbYY/3CT+b9g/Emzwzez2Ggk9U9waRfdHiBCzEgRBivpWZEOAtkimXQ==", "license": "MIT", - "peer": true, "dependencies": { "@loaders.gl/loader-utils": "4.3.4", "@loaders.gl/worker-utils": "4.3.4", @@ -2520,6 +2522,7 @@ "resolved": "https://registry.npmjs.org/@loaders.gl/core/-/core-4.3.4.tgz", "integrity": "sha512-cG0C5fMZ1jyW6WCsf4LoHGvaIAJCEVA/ioqKoYRwoSfXkOf+17KupK1OUQyUCw5XoRn+oWA1FulJQOYlXnb9Gw==", "license": "MIT", + "peer": true, "dependencies": { "@loaders.gl/loader-utils": "4.3.4", "@loaders.gl/schema": "4.3.4", @@ -2532,7 +2535,6 @@ "resolved": "https://registry.npmjs.org/@loaders.gl/crypto/-/crypto-4.3.4.tgz", "integrity": "sha512-3VS5FgB44nLOlAB9Q82VOQnT1IltwfRa1miE0mpHCe1prYu1M/dMnEyynusbrsp+eDs3EKbxpguIS9HUsFu5dQ==", "license": "MIT", - "peer": true, "dependencies": { "@loaders.gl/loader-utils": "4.3.4", "@loaders.gl/worker-utils": "4.3.4", @@ -2562,7 +2564,6 @@ "resolved": "https://registry.npmjs.org/@loaders.gl/gis/-/gis-4.3.4.tgz", "integrity": "sha512-8xub38lSWW7+ZXWuUcggk7agRHJUy6RdipLNKZ90eE0ZzLNGDstGD1qiBwkvqH0AkG+uz4B7Kkiptyl7w2Oa6g==", "license": "MIT", - "peer": true, "dependencies": { "@loaders.gl/loader-utils": "4.3.4", "@loaders.gl/schema": "4.3.4", @@ -2623,7 +2624,6 @@ "resolved": "https://registry.npmjs.org/@loaders.gl/math/-/math-4.3.4.tgz", "integrity": "sha512-UJrlHys1fp9EUO4UMnqTCqvKvUjJVCbYZ2qAKD7tdGzHJYT8w/nsP7f/ZOYFc//JlfC3nq+5ogvmdpq2pyu3TA==", "license": "MIT", - "peer": true, "dependencies": { "@loaders.gl/images": "4.3.4", "@loaders.gl/loader-utils": "4.3.4", @@ -2638,7 +2638,6 @@ "resolved": "https://registry.npmjs.org/@loaders.gl/mvt/-/mvt-4.3.4.tgz", "integrity": "sha512-9DrJX8RQf14htNtxsPIYvTso5dUce9WaJCWCIY/79KYE80Be6dhcEYMknxBS4w3+PAuImaAe66S5xo9B7Erm5A==", "license": "MIT", - "peer": true, "dependencies": { "@loaders.gl/gis": "4.3.4", "@loaders.gl/images": "4.3.4", @@ -2669,7 +2668,6 @@ "resolved": "https://registry.npmjs.org/@loaders.gl/terrain/-/terrain-4.3.4.tgz", "integrity": "sha512-JszbRJGnxL5Fh82uA2U8HgjlsIpzYoCNNjy3cFsgCaxi4/dvjz3BkLlBilR7JlbX8Ka+zlb4GAbDDChiXLMJ/g==", "license": "MIT", - "peer": true, "dependencies": { "@loaders.gl/images": "4.3.4", "@loaders.gl/loader-utils": "4.3.4", @@ -2703,7 +2701,6 @@ "resolved": "https://registry.npmjs.org/@loaders.gl/tiles/-/tiles-4.3.4.tgz", "integrity": "sha512-oC0zJfyvGox6Ag9ABF8fxOkx9yEFVyzTa9ryHXl2BqLiQoR1v3p+0tIJcEbh5cnzHfoTZzUis1TEAZluPRsHBQ==", "license": "MIT", - "peer": true, "dependencies": { "@loaders.gl/loader-utils": "4.3.4", "@loaders.gl/math": "4.3.4", @@ -2722,7 +2719,6 @@ "resolved": "https://registry.npmjs.org/@loaders.gl/wms/-/wms-4.3.4.tgz", "integrity": "sha512-yXF0wuYzJUdzAJQrhLIua6DnjOiBJusaY1j8gpvuH1VYs3mzvWlIRuZKeUd9mduQZKK88H2IzHZbj2RGOauq4w==", "license": "MIT", - "peer": true, "dependencies": { "@loaders.gl/images": "4.3.4", "@loaders.gl/loader-utils": "4.3.4", @@ -2749,7 +2745,6 @@ "resolved": "https://registry.npmjs.org/@loaders.gl/xml/-/xml-4.3.4.tgz", "integrity": "sha512-p+y/KskajsvyM3a01BwUgjons/j/dUhniqd5y1p6keLOuwoHlY/TfTKd+XluqfyP14vFrdAHCZTnFCWLblN10w==", "license": "MIT", - "peer": true, "dependencies": { "@loaders.gl/loader-utils": "4.3.4", "@loaders.gl/schema": "4.3.4", @@ -2764,7 +2759,6 @@ "resolved": "https://registry.npmjs.org/@loaders.gl/zip/-/zip-4.3.4.tgz", "integrity": "sha512-bHY4XdKYJm3vl9087GMoxnUqSURwTxPPh6DlAGOmz6X9Mp3JyWuA2gk3tQ1UIuInfjXKph3WAUfGe6XRIs1sfw==", "license": "MIT", - "peer": true, "dependencies": { "@loaders.gl/compression": "4.3.4", "@loaders.gl/crypto": "4.3.4", @@ -2780,13 +2774,15 @@ "version": "9.2.2", "resolved": "https://registry.npmjs.org/@luma.gl/constants/-/constants-9.2.2.tgz", "integrity": "sha512-XURMF0gSh0ImZltYa/PCe9KgmopQJiOA6y1m1PxDxJY8OCLma7ZJyvomLn7TQBvPtWTYZsibTW7blu7RwThsaQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@luma.gl/core": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/@luma.gl/core/-/core-9.2.2.tgz", "integrity": "sha512-X63BnXXDlC9AmoG4sUVsfxLn+DoNovbX/z5ZXxnhpxx47536Ss/SLzwnLvm/ZoDhK9/s5qdI95mSZKuqzKCkjw==", "license": "MIT", + "peer": true, "dependencies": { "@math.gl/types": "^4.1.0", "@probe.gl/env": "^4.0.8", @@ -2800,6 +2796,7 @@ "resolved": "https://registry.npmjs.org/@luma.gl/engine/-/engine-9.2.2.tgz", "integrity": "sha512-Upq/jVPvgi/rjwgSGYyW+jobJBotKR/aNTDwyHAubx6wXWluZqnR0ZBwctiO9i7w2RIzZGboMYs4dIgVw0ULaQ==", "license": "MIT", + "peer": true, "dependencies": { "@math.gl/core": "^4.1.0", "@math.gl/types": "^4.1.0", @@ -2834,6 +2831,7 @@ "resolved": "https://registry.npmjs.org/@luma.gl/shadertools/-/shadertools-9.2.2.tgz", "integrity": "sha512-ChskCXE8Q+5/rC8zPR7pHBSfERGRui5qvw6bZnOZMVwTvGbW5tI5od5Wu9ytGi45kWus66M+M/o5LpP3hfc4Hg==", "license": "MIT", + "peer": true, "dependencies": { "@math.gl/core": "^4.1.0", "@math.gl/types": "^4.1.0", @@ -3031,15 +3029,13 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/@mapbox/martini/-/martini-0.2.0.tgz", "integrity": "sha512-7hFhtkb0KTLEls+TRw/rWayq5EeHtTaErgm/NskVoXmtgAQu/9D299aeyj6mzAR/6XUnYRp2lU+4IcrYRFjVsQ==", - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/@mapbox/point-geometry": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-0.1.0.tgz", "integrity": "sha512-6j56HdLTwWGO0fJPlrZtdU/B13q8Uwmo18Ck2GnGgN9PCFyKTZ3UbXeEdRFh18i9XQ92eH2VdtpJHpBD3aripQ==", - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/@mapbox/tiny-sdf": { "version": "2.0.7", @@ -3058,7 +3054,6 @@ "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-1.3.1.tgz", "integrity": "sha512-MCEddb8u44/xfQ3oD+Srl/tNcQoqTw3goGk2oLsrFxOTc3dUp+kAnby3PvAeeBYSMSjSPD1nd1AJA6W49WnoUw==", "license": "BSD-3-Clause", - "peer": true, "dependencies": { "@mapbox/point-geometry": "~0.1.0" } @@ -3150,7 +3145,6 @@ "resolved": "https://registry.npmjs.org/@math.gl/culling/-/culling-4.1.0.tgz", "integrity": "sha512-jFmjFEACnP9kVl8qhZxFNhCyd47qPfSVmSvvjR0/dIL6R9oD5zhR1ub2gN16eKDO/UM7JF9OHKU3EBIfeR7gtg==", "license": "MIT", - "peer": true, "dependencies": { "@math.gl/core": "4.1.0", "@math.gl/types": "4.1.0" @@ -3161,7 +3155,6 @@ "resolved": "https://registry.npmjs.org/@math.gl/geospatial/-/geospatial-4.1.0.tgz", "integrity": "sha512-BzsUhpVvnmleyYF6qdqJIip6FtIzJmnWuPTGhlBuPzh7VBHLonCFSPtQpbkRuoyAlbSyaGXcVt6p6lm9eK2vtg==", "license": "MIT", - "peer": true, "dependencies": { "@math.gl/core": "4.1.0", "@math.gl/types": "4.1.0" @@ -3172,6 +3165,7 @@ "resolved": "https://registry.npmjs.org/@math.gl/polygon/-/polygon-4.1.0.tgz", "integrity": "sha512-YA/9PzaCRHbIP5/0E9uTYrqe+jsYTQoqoDWhf6/b0Ixz8bPZBaGDEafLg3z7ffBomZLacUty9U3TlPjqMtzPjA==", "license": "MIT", + "peer": true, "dependencies": { "@math.gl/core": "4.1.0" } @@ -4329,6 +4323,7 @@ "resolved": "https://registry.npmjs.org/@nextui-org/system/-/system-2.4.6.tgz", "integrity": "sha512-6ujAriBZMfQ16n6M6Ad9g32KJUa1CzqIVaHN/tymadr/3m8hrr7xDw6z50pVjpCRq2PaaA1hT8Hx7EFU3f2z3Q==", "license": "MIT", + "peer": true, "dependencies": { "@internationalized/date": "3.6.0", "@nextui-org/react-utils": "2.1.3", @@ -4423,6 +4418,7 @@ "resolved": "https://registry.npmjs.org/@nextui-org/theme/-/theme-2.4.5.tgz", "integrity": "sha512-c7Y17n+hBGiFedxMKfg7Qyv93iY5MteamLXV4Po4c1VF1qZJI6I+IKULFh3FxPWzAoz96r6NdYT7OLFjrAJdWg==", "license": "MIT", + "peer": true, "dependencies": { "@nextui-org/shared-utils": "2.1.2", "clsx": "^1.2.1", @@ -7394,7 +7390,6 @@ "resolved": "https://registry.npmjs.org/@turf/boolean-clockwise/-/boolean-clockwise-5.1.5.tgz", "integrity": "sha512-FqbmEEOJ4rU4/2t7FKx0HUWmjFEVqR+NJrFP7ymGSjja2SQ7Q91nnBihGuT+yuHHl6ElMjQ3ttsB/eTmyCycxA==", "license": "MIT", - "peer": true, "dependencies": { "@turf/helpers": "^5.1.5", "@turf/invariant": "^5.1.5" @@ -7405,7 +7400,6 @@ "resolved": "https://registry.npmjs.org/@turf/clone/-/clone-5.1.5.tgz", "integrity": "sha512-//pITsQ8xUdcQ9pVb4JqXiSqG4dos5Q9N4sYFoWghX21tfOV2dhc5TGqYOhnHrQS7RiKQL1vQ48kIK34gQ5oRg==", "license": "MIT", - "peer": true, "dependencies": { "@turf/helpers": "^5.1.5" } @@ -7414,15 +7408,13 @@ "version": "5.1.5", "resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-5.1.5.tgz", "integrity": "sha512-/lF+JR+qNDHZ8bF9d+Cp58nxtZWJ3sqFe6n3u3Vpj+/0cqkjk4nXKYBSY0azm+GIYB5mWKxUXvuP/m0ZnKj1bw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@turf/invariant": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@turf/invariant/-/invariant-5.2.0.tgz", "integrity": "sha512-28RCBGvCYsajVkw2EydpzLdcYyhSA77LovuOvgCJplJWaNVyJYH6BOR3HR9w50MEkPqb/Vc/jdo6I6ermlRtQA==", "license": "MIT", - "peer": true, "dependencies": { "@turf/helpers": "^5.1.5" } @@ -7432,7 +7424,6 @@ "resolved": "https://registry.npmjs.org/@turf/meta/-/meta-5.2.0.tgz", "integrity": "sha512-ZjQ3Ii62X9FjnK4hhdsbT+64AYRpaI8XMBMcyftEOGSmPMUVnkbvuv3C9geuElAXfQU7Zk1oWGOcrGOD9zr78Q==", "license": "MIT", - "peer": true, "dependencies": { "@turf/helpers": "^5.1.5" } @@ -7442,7 +7433,6 @@ "resolved": "https://registry.npmjs.org/@turf/rewind/-/rewind-5.1.5.tgz", "integrity": "sha512-Gdem7JXNu+G4hMllQHXRFRihJl3+pNl7qY+l4qhQFxq+hiU1cQoVFnyoleIqWKIrdK/i2YubaSwc3SCM7N5mMw==", "license": "MIT", - "peer": true, "dependencies": { "@turf/boolean-clockwise": "^5.1.5", "@turf/clone": "^5.1.5", @@ -7467,7 +7457,6 @@ "resolved": "https://registry.npmjs.org/@types/brotli/-/brotli-1.3.4.tgz", "integrity": "sha512-cKYjgaS2DMdCKF7R0F5cgx1nfBYObN2ihIuPGQ4/dlIY6RpV7OWNwe9L8V4tTVKL2eZqOkNM9FM/rgTvLf4oXw==", "license": "MIT", - "peer": true, "dependencies": { "@types/node": "*" } @@ -7488,8 +7477,7 @@ "version": "4.2.2", "resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.2.2.tgz", "integrity": "sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/estree": { "version": "1.0.8", @@ -7581,14 +7569,14 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/@types/pako/-/pako-1.0.7.tgz", "integrity": "sha512-YBtzT2ztNF6R/9+UXj2wTGFnC9NklAnASt3sC0h2m1bbH7G6FyBIkt4AN8ThZpNfxUo1b2iMVO0UawiJymEt8A==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/react": { "version": "19.2.2", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -7672,6 +7660,7 @@ "integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.2", "@typescript-eslint/types": "8.46.2", @@ -7981,7 +7970,6 @@ "resolved": "https://registry.npmjs.org/a5-js/-/a5-js-0.5.0.tgz", "integrity": "sha512-VAw19sWdYadhdovb0ViOIi1SdKx6H6LwcGMRFKwMfgL5gcmL/1fKJHfgsNgNaJ7xC/eEyjs6VK+VVd4N0a+peg==", "license": "Apache-2.0", - "peer": true, "dependencies": { "gl-matrix": "^3.4.3" } @@ -7992,6 +7980,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -8077,6 +8066,7 @@ "resolved": "https://registry.npmjs.org/apache-arrow/-/apache-arrow-21.1.0.tgz", "integrity": "sha512-kQrYLxhC+NTVVZ4CCzGF6L/uPVOzJmD1T3XgbiUnP7oTeVFOFgEUu6IKNwCDkpFoBVqDKQivlX4RUFqqnWFlEA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@swc/helpers": "^0.5.11", "@types/command-line-args": "^5.2.3", @@ -8391,8 +8381,7 @@ } ], "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/baseline-browser-mapping": { "version": "2.8.20", @@ -8445,7 +8434,6 @@ "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "base64-js": "^1.1.2" } @@ -8470,6 +8458,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", @@ -8489,7 +8478,6 @@ "resolved": "https://registry.npmjs.org/buf-compare/-/buf-compare-1.0.1.tgz", "integrity": "sha512-Bvx4xH00qweepGc43xFvMs5BKASXTbHaHm6+kDYIK9p/4iFwjATQkmPKHQSgJZzKbAymhztRbXUf1Nqhzl73/Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -8498,8 +8486,7 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/buffer-builder/-/buffer-builder-0.2.0.tgz", "integrity": "sha512-7VPMEPuYznPSoR21NE1zvd2Xna6c/CloiZCfcMXR1Jny6PjX0N4Nsa38zcBFo/FMK+BlA+FLKbJCQ0i2yxp+Xg==", - "license": "MIT/X11", - "peer": true + "license": "MIT/X11" }, "node_modules/bytewise": { "version": "1.1.0", @@ -8645,7 +8632,6 @@ "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==", "license": "BSD-3-Clause", - "peer": true, "engines": { "node": "*" } @@ -8746,8 +8732,7 @@ "version": "0.5.2", "resolved": "https://registry.npmjs.org/colorjs.io/-/colorjs.io-0.5.2.tgz", "integrity": "sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/command-line-args": { "version": "6.0.1", @@ -8853,7 +8838,6 @@ "resolved": "https://registry.npmjs.org/core-assert/-/core-assert-0.2.1.tgz", "integrity": "sha512-IG97qShIP+nrJCXMCgkNZgH7jZQ4n8RpPyPeXX++T6avR/KhLhgLiHKoEn5Rc1KjfycSfA9DMa6m+4C4eguHhw==", "license": "MIT", - "peer": true, "dependencies": { "buf-compare": "^1.0.0", "is-error": "^2.2.0" @@ -8866,8 +8850,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/cross-spawn": { "version": "7.0.6", @@ -8888,7 +8871,6 @@ "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==", "license": "BSD-3-Clause", - "peer": true, "engines": { "node": "*" } @@ -9009,8 +8991,7 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/d3-hexbin/-/d3-hexbin-0.2.2.tgz", "integrity": "sha512-KS3fUT2ReD4RlGCjvCEm1RgMtp2NFZumdMu4DBzQK8AZv3fXRM6Xm8I4fSU07UXvH4xxg03NwWKWdvxfS/yc4w==", - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/data-view-buffer": { "version": "1.0.2", @@ -9101,7 +9082,6 @@ "resolved": "https://registry.npmjs.org/deep-strict-equal/-/deep-strict-equal-0.2.0.tgz", "integrity": "sha512-3daSWyvZ/zwJvuMGlzG1O+Ow0YSadGfb3jsh9xoCutv2tWyB9dA4YvR9L9/fSdDZa2dByYQe+TqapSGUrjnkoA==", "license": "MIT", - "peer": true, "dependencies": { "core-assert": "^0.2.0" }, @@ -9445,6 +9425,7 @@ "integrity": "sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q==", "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -9524,6 +9505,7 @@ "integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -9976,7 +9958,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "strnum": "^1.1.1" }, @@ -9997,8 +9978,7 @@ "version": "0.7.4", "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.7.4.tgz", "integrity": "sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/file-entry-cache": { "version": "8.0.0", @@ -10146,6 +10126,7 @@ "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.24.tgz", "integrity": "sha512-HMi5HRoRCTou+3fb3h9oTLyJGBxHfW+HnNE25tAXOvVx/IvwMHK0cx7IR4a2ZU6sh3IX1Z+4ts32PcYBOqka8w==", "license": "MIT", + "peer": true, "dependencies": { "motion-dom": "^12.23.23", "motion-utils": "^12.23.6", @@ -10475,7 +10456,6 @@ "resolved": "https://registry.npmjs.org/h3-js/-/h3-js-4.3.0.tgz", "integrity": "sha512-zgvyHZz5bEKeuyYGh0bF9/kYSxJ2SqroopkXHqKnD3lfjaZawcxulcI9nWbNC54gakl/2eObRLHWueTf1iLSaA==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=4", "npm": ">=3", @@ -10605,8 +10585,7 @@ "url": "https://feross.org/support" } ], - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/ignore": { "version": "5.3.2", @@ -10641,8 +10620,7 @@ "version": "3.0.6", "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/immutable": { "version": "5.1.4", @@ -10681,8 +10659,7 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/input-otp": { "version": "1.4.1", @@ -10814,8 +10791,7 @@ "version": "1.1.6", "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/is-callable": { "version": "1.2.7", @@ -10884,8 +10860,7 @@ "version": "2.2.2", "resolved": "https://registry.npmjs.org/is-error/-/is-error-2.2.2.tgz", "integrity": "sha512-IOQqts/aHWbiisY5DuPJQ0gcbvaLFCa7fBa9xoLfxBZvQ+ZI/Zh9xoI7Gk+G64N0FdK4AbibytHht2tWgpJWLg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/is-extendable": { "version": "0.1.1", @@ -11200,8 +11175,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/isexe": { "version": "2.0.0", @@ -11413,7 +11387,6 @@ "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", "license": "(MIT OR GPL-3.0-or-later)", - "peer": true, "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", @@ -11490,7 +11463,6 @@ "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", "license": "MIT", - "peer": true, "dependencies": { "immediate": "~3.0.5" } @@ -11844,7 +11816,6 @@ "resolved": "https://registry.npmjs.org/long/-/long-3.2.0.tgz", "integrity": "sha512-ZYvPPOMqUwPoDsbJaR10iQJYnMuZhRTvHYl62ErLIEX7RgFlziSBUUvrt3OVfc47QlHHpzPZYP17g3Fv7oeJkg==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=0.6" } @@ -11873,15 +11844,13 @@ "resolved": "https://registry.npmjs.org/lz4js/-/lz4js-0.2.0.tgz", "integrity": "sha512-gY2Ia9Lm7Ep8qMiuGRhvUq0Q7qUereeldZPP1PMEJxPtEWHJLqw9pgX68oHajBH0nzJK4MaZEA/YNV3jT8u8Bg==", "license": "ISC", - "optional": true, - "peer": true + "optional": true }, "node_modules/lzo-wasm": { "version": "0.0.4", "resolved": "https://registry.npmjs.org/lzo-wasm/-/lzo-wasm-0.0.4.tgz", "integrity": "sha512-VKlnoJRFrB8SdJhlVKvW5vI1gGwcZ+mvChEXcSX6r2xDNc/Q2FD9esfBmGCuPZdrJ1feO+YcVFd2PTk0c137Gw==", - "license": "BSD-2-Clause", - "peer": true + "license": "BSD-2-Clause" }, "node_modules/magic-string": { "version": "0.30.21", @@ -11980,7 +11949,6 @@ "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==", "license": "BSD-3-Clause", - "peer": true, "dependencies": { "charenc": "0.0.2", "crypt": "0.0.2", @@ -12434,8 +12402,7 @@ "version": "1.0.11", "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", - "license": "(MIT AND Zlib)", - "peer": true + "license": "(MIT AND Zlib)" }, "node_modules/parent-module": { "version": "1.0.1", @@ -12525,7 +12492,6 @@ "resolved": "https://registry.npmjs.org/pbf/-/pbf-3.3.0.tgz", "integrity": "sha512-XDF38WCH3z5OV/OVa8GKUNtLAyneuzbCisx7QUCF8Q6Nutx0WnJrQe5O+kOtBlLfRNUws98Y58Lblp+NJG5T4Q==", "license": "BSD-3-Clause", - "peer": true, "dependencies": { "ieee754": "^1.1.12", "resolve-protobuf-schema": "^2.1.0" @@ -12599,6 +12565,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -13522,6 +13489,7 @@ "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -13547,7 +13515,6 @@ "resolved": "https://registry.npmjs.org/preact/-/preact-10.27.2.tgz", "integrity": "sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -13583,8 +13550,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/proj4": { "version": "2.19.10", @@ -13691,6 +13657,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -13700,6 +13667,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -13769,7 +13737,6 @@ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", "license": "MIT", - "peer": true, "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -13936,7 +13903,6 @@ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -13972,8 +13938,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/safe-identifier": { "version": "0.4.2", @@ -14058,7 +14023,6 @@ "resolved": "https://registry.npmjs.org/sass-embedded/-/sass-embedded-1.93.2.tgz", "integrity": "sha512-FvQdkn2dZ8DGiLgi0Uf4zsj7r/BsiLImNa5QJ10eZalY6NfZyjrmWGFcuCN5jNwlDlXFJnftauv+UtvBKLvepQ==", "license": "MIT", - "peer": true, "dependencies": { "@bufbuild/protobuf": "^2.5.0", "buffer-builder": "^0.2.0", @@ -14108,7 +14072,6 @@ ], "license": "MIT", "optional": true, - "peer": true, "dependencies": { "sass": "1.93.2" } @@ -14125,7 +14088,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -14142,7 +14104,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -14159,7 +14120,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -14176,7 +14136,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -14193,7 +14152,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -14210,7 +14168,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -14227,7 +14184,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -14244,7 +14200,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -14261,7 +14216,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -14278,7 +14232,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -14295,7 +14248,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -14312,7 +14264,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -14329,7 +14280,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -14346,7 +14296,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -14363,7 +14312,6 @@ "!linux", "!win32" ], - "peer": true, "dependencies": { "sass": "1.93.2" } @@ -14380,7 +14328,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -14397,7 +14344,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -14407,7 +14353,6 @@ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "license": "MIT", - "peer": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -14539,8 +14484,7 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/shebang-command": { "version": "2.0.0", @@ -14690,8 +14634,7 @@ "version": "0.6.1", "resolved": "https://registry.npmjs.org/snappyjs/-/snappyjs-0.6.1.tgz", "integrity": "sha512-YIK6I2lsH072UE0aOFxxY1dPDCS43I5ktqHpeAsuLNYWkE5pGxRGWfDM4/vSUfNzXjC1Ivzt3qx31PCLmc9yqg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/sort-asc": { "version": "0.2.0", @@ -14799,7 +14742,6 @@ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "license": "MIT", - "peer": true, "dependencies": { "safe-buffer": "~5.1.0" } @@ -15038,8 +14980,7 @@ "url": "https://github.com/sponsors/NaturalIntelligence" } ], - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/sucrase": { "version": "3.35.0", @@ -15114,7 +15055,6 @@ "resolved": "https://registry.npmjs.org/sync-child-process/-/sync-child-process-1.0.2.tgz", "integrity": "sha512-8lD+t2KrrScJ/7KXCSyfhT3/hRq78rC0wBFqNJXv3mZyn6hW2ypM05JmlSvtqRbeq6jqA94oHbxAr2vYsJ8vDA==", "license": "MIT", - "peer": true, "dependencies": { "sync-message-port": "^1.0.0" }, @@ -15127,7 +15067,6 @@ "resolved": "https://registry.npmjs.org/sync-message-port/-/sync-message-port-1.1.3.tgz", "integrity": "sha512-GTt8rSKje5FilG+wEdfCkOcLL7LWqpMlr2c3LRuKt/YXxcJ52aGSbGBAdI4L3aaqfrBt6y711El53ItyH1NWzg==", "license": "MIT", - "peer": true, "engines": { "node": ">=16.0.0" } @@ -15501,6 +15440,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -15778,8 +15718,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/varint/-/varint-6.0.0.tgz", "integrity": "sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/web-worker": { "version": "1.5.0", @@ -16023,6 +15962,7 @@ "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10.0.0" }, @@ -16050,6 +15990,7 @@ "resolved": "https://registry.npmjs.org/xstate/-/xstate-5.23.0.tgz", "integrity": "sha512-jo126xWXkU6ySQ91n51+H2xcgnMuZcCQpQoD3FQ79d32a6RQvryRh8rrDHnH4WDdN/yg5xNjlIRol9ispMvzeg==", "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/xstate" @@ -16082,6 +16023,7 @@ "integrity": "sha512-OIDwaflOaq4wC6YlPBy2L6ceKeKuF7DeTxx+jPzv1FHn9tCZ0ZwSRnUBxD05E3yed46fv/FWJbvR+Ud7x0L7zw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "lib0": "^0.2.99" }, @@ -16112,8 +16054,13 @@ "resolved": "https://registry.npmjs.org/zstd-codec/-/zstd-codec-0.1.5.tgz", "integrity": "sha512-v3fyjpK8S/dpY/X5WxqTK3IoCnp/ZOLxn144GZVlNUjtwAchzrVo03h+oMATFhCIiJ5KTr4V3vDQQYz4RU684g==", "license": "MIT", - "optional": true, - "peer": true + "optional": true + }, + "node_modules/zstddec": { + "version": "0.2.0-alpha.3", + "resolved": "https://registry.npmjs.org/zstddec/-/zstddec-0.2.0-alpha.3.tgz", + "integrity": "sha512-uHyE3TN8jRFOaMVwdhERfrcaabyoUUawIRDKXE6x0nCU7mzyIZO0LndJ3AtVUiKLF0lC+8F5bMSySWEF586PSA==", + "license": "MIT AND BSD-3-Clause" } } } From 6f1fa4834fd7a1c7c6b867b1078f01d395ba2889 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Wed, 5 Nov 2025 17:33:49 -0500 Subject: [PATCH 16/25] remove old files --- src/cog-tileset/cog-tileset-2d.ts | 76 ------- src/cog-tileset/tile-2d-traversal.ts | 309 --------------------------- 2 files changed, 385 deletions(-) delete mode 100644 src/cog-tileset/cog-tileset-2d.ts delete mode 100644 src/cog-tileset/tile-2d-traversal.ts diff --git a/src/cog-tileset/cog-tileset-2d.ts b/src/cog-tileset/cog-tileset-2d.ts deleted file mode 100644 index 438d4e34..00000000 --- a/src/cog-tileset/cog-tileset-2d.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { Viewport } from "@deck.gl/core"; -import { _Tileset2D as Tileset2D } from "@deck.gl/geo-layers"; - -import { getOSMTileIndices } from "./tile-2d-traversal"; -import type { COGTileIndex, ZRange } from "./types"; - -type Bounds = [minX: number, minY: number, maxX: number, maxY: number]; - -export class COGTileset2D extends Tileset2D { - getTileIndices({ - viewport, - maxZoom, - minZoom, - zRange, - tileSize, - zoomOffset, - }: { - viewport: Viewport; - maxZoom?: number; - minZoom?: number; - zRange: ZRange | null; - tileSize?: number; - zoomOffset?: number; - }): COGTileIndex[] { - const { extent } = this.opts; - return getTileIndices({ - viewport, - maxZoom, - minZoom, - zRange, - extent: extent as Bounds, - tileSize, - zoomOffset, - }); - } -} - -const TILE_SIZE = 512; - -/** - * Returns all tile indices in the current viewport. If the current zoom level is smaller - * than minZoom, return an empty array. If the current zoom level is greater than maxZoom, - * return tiles that are on maxZoom. - */ - -export function getTileIndices({ - viewport, - maxZoom, - minZoom, - zRange, - extent, - tileSize = TILE_SIZE, - zoomOffset = 0, -}: { - viewport: Viewport; - maxZoom?: number; - minZoom?: number; - zRange: ZRange | null; - extent?: Bounds; - tileSize?: number; - zoomOffset?: number; -}): COGTileIndex[] { - // Note: for now this only supports geospatial viewports - let z = - Math.round(viewport.zoom + Math.log2(TILE_SIZE / tileSize)) + zoomOffset; - if (typeof minZoom === "number" && Number.isFinite(minZoom) && z < minZoom) { - if (!extent) { - return []; - } - z = minZoom; - } - if (typeof maxZoom === "number" && Number.isFinite(maxZoom) && z > maxZoom) { - z = maxZoom; - } - return getOSMTileIndices(viewport, z, zRange, extent); -} diff --git a/src/cog-tileset/tile-2d-traversal.ts b/src/cog-tileset/tile-2d-traversal.ts deleted file mode 100644 index 40e6ec02..00000000 --- a/src/cog-tileset/tile-2d-traversal.ts +++ /dev/null @@ -1,309 +0,0 @@ -import { Viewport, WebMercatorViewport, _GlobeViewport } from "@deck.gl/core"; -import { - CullingVolume, - Plane, - AxisAlignedBoundingBox, - makeOrientedBoundingBoxFromPoints, -} from "@math.gl/culling"; -import { lngLatToWorld } from "@math.gl/web-mercator"; - -import { Bounds, COGTileIndex, TileIndex, ZRange } from "./types.js"; -import { osmTile2lngLat } from "./utils.js"; - -const TILE_SIZE = 512; -// number of world copies to check -const MAX_MAPS = 3; -// for calculating bounding volume of a tile in a non-web-mercator viewport -const REF_POINTS_5 = [ - [0.5, 0.5], - [0, 0], - [0, 1], - [1, 0], - [1, 1], -]; // 4 corners and center -const REF_POINTS_9 = REF_POINTS_5.concat([ - [0, 0.5], - [0.5, 0], - [1, 0.5], - [0.5, 1], -]); // 4 corners, center and 4 mid points -const REF_POINTS_11 = REF_POINTS_9.concat([ - [0.25, 0.5], - [0.75, 0.5], -]); // 2 additional points on equator for top tile - -/** - * COG Metadata extracted from GeoTIFF - */ -interface COGMetadata { - width: number; - height: number; - tileWidth: number; - tileHeight: number; - tilesX: number; - tilesY: number; - bbox: [number, number, number, number]; // [minX, minY, maxX, maxY] in COG's CRS - projection: string; - overviews: Array<{ - level: number; - width: number; - height: number; - tilesX: number; - tilesY: number; - scaleFactor: number; - }>; - image: any; // GeoTIFF reference -} - -/** - * COG Tile Node - similar to upstream's OSMNode but for COG's tile structure - */ -class COGNode { - x: number; - y: number; - z: number; - - private cogMetadata: COGMetadata; - - private childVisible?: boolean; - private selected?: boolean; - - private _children?: COGNode[]; - - constructor(x: number, y: number, z: number) { - this.x = x; - this.y = y; - this.z = z; - } - - get children() { - // NOTE: for complex COGs you may need to actually compute the child indexes - // https://github.com/developmentseed/morecantile/blob/12698fbd4e52dc1a0fcac4b5658dcc4f3c9e5343/morecantile/models.py#L1668-L1713 - if (!this._children) { - const x = this.x * 2; - const y = this.y * 2; - const z = this.z + 1; - this._children = [ - new COGNode(x, y, z), - new COGNode(x, y + 1, z), - new COGNode(x + 1, y, z), - new COGNode(x + 1, y + 1, z), - ]; - } - return this._children; - } - - /** Update whether this node is visible or not. */ - update(params: { - viewport: Viewport; - project: ((xyz: number[]) => number[]) | null; - cullingVolume: CullingVolume; - elevationBounds: ZRange; - minZ: number; - maxZ: number; - bounds?: Bounds; - offset: number; - }) { - const { - viewport, - cullingVolume, - elevationBounds, - minZ, - maxZ, - bounds, - offset, - project, - } = params; - const boundingVolume = this.getBoundingVolume( - elevationBounds, - offset, - project, - ); - - // TODO: restore - // // First, check if this tile is visible - // if (bounds && !this.insideBounds(bounds)) { - // return false; - // } - - const isInside = cullingVolume.computeVisibility(boundingVolume); - if (isInside < 0) { - return false; - } - - // Avoid loading overlapping tiles - if a descendant is requested, do not request the ancester - if (!this.childVisible) { - let { z } = this; - if (z < maxZ && z >= minZ) { - // Adjust LOD - // If the tile is far enough from the camera, accept a lower zoom level - const distance = - (boundingVolume.distanceTo(viewport.cameraPosition) * - viewport.scale) / - viewport.height; - z += Math.floor(Math.log2(distance)); - } - if (z >= maxZ) { - // LOD is acceptable - this.selected = true; - return true; - } - } - - // LOD is not enough, recursively test child tiles - this.selected = false; - this.childVisible = true; - for (const child of this.children) { - child.update(params); - } - return true; - } - - getSelected(result: COGNode[] = []): COGNode[] { - if (this.selected) { - result.push(this); - } - if (this._children) { - for (const node of this._children) { - node.getSelected(result); - } - } - return result; - } - - // TODO: update implementation - // insideBounds([minX, minY, maxX, maxY]: Bounds): boolean { - // const scale = Math.pow(2, this.z); - // const extent = TILE_SIZE / scale; - - // return ( - // this.x * extent < maxX && - // this.y * extent < maxY && - // (this.x + 1) * extent > minX && - // (this.y + 1) * extent > minY - // ); - // } - - getBoundingVolume( - zRange: ZRange, - worldOffset: number, - project: ((xyz: number[]) => number[]) | null, - ) { - if (project) { - // Custom projection - // Estimate bounding box from sample points - // At low zoom level we need more samples to calculate the bounding volume correctly - const refPoints = - this.z < 1 ? REF_POINTS_11 : this.z < 2 ? REF_POINTS_9 : REF_POINTS_5; - - // Convert from tile-relative coordinates to common space - const refPointPositions: number[][] = []; - for (const p of refPoints) { - const lngLat: number[] = osmTile2lngLat( - this.x + p[0], - this.y + p[1], - this.z, - ); - lngLat[2] = zRange[0]; - refPointPositions.push(project(lngLat)); - - if (zRange[0] !== zRange[1]) { - // Account for the elevation volume - lngLat[2] = zRange[1]; - refPointPositions.push(project(lngLat)); - } - } - - return makeOrientedBoundingBoxFromPoints(refPointPositions); - } - - // Use WebMercator projection - const scale = Math.pow(2, this.z); - const extent = TILE_SIZE / scale; - const originX = this.x * extent + worldOffset * TILE_SIZE; - // deck's common space is y-flipped - const originY = TILE_SIZE - (this.y + 1) * extent; - - return new AxisAlignedBoundingBox( - [originX, originY, zRange[0]], - [originX + extent, originY + extent, zRange[1]], - ); - } -} - -export function getOSMTileIndices( - viewport: Viewport, - maxZ: number, - zRange: ZRange | null, - bounds?: Bounds, -): TileIndex[] { - const project: ((xyz: number[]) => number[]) | null = - viewport instanceof _GlobeViewport && viewport.resolution - ? viewport.projectPosition - : null; - - // Get the culling volume of the current camera - const planes: Plane[] = Object.values(viewport.getFrustumPlanes()).map( - ({ normal, distance }) => new Plane(normal.clone().negate(), distance), - ); - const cullingVolume = new CullingVolume(planes); - - // Project zRange from meters to common space - const unitsPerMeter = viewport.distanceScales.unitsPerMeter[2]; - const elevationMin = (zRange && zRange[0] * unitsPerMeter) || 0; - const elevationMax = (zRange && zRange[1] * unitsPerMeter) || 0; - - // Always load at the current zoom level if pitch is small - const minZ = - viewport instanceof WebMercatorViewport && viewport.pitch <= 60 ? maxZ : 0; - - // Map extent to OSM position - if (bounds) { - const [minLng, minLat, maxLng, maxLat] = bounds; - const topLeft = lngLatToWorld([minLng, maxLat]); - const bottomRight = lngLatToWorld([maxLng, minLat]); - bounds = [ - topLeft[0], - TILE_SIZE - topLeft[1], - bottomRight[0], - TILE_SIZE - bottomRight[1], - ]; - } - - const root = new OSMNode(0, 0, 0); - const traversalParams = { - viewport, - project, - cullingVolume, - elevationBounds: [elevationMin, elevationMax] as ZRange, - minZ, - maxZ, - bounds, - // num. of worlds from the center. For repeated maps - offset: 0, - }; - - root.update(traversalParams); - - if ( - viewport instanceof WebMercatorViewport && - viewport.subViewports && - viewport.subViewports.length > 1 - ) { - // Check worlds in repeated maps - traversalParams.offset = -1; - while (root.update(traversalParams)) { - if (--traversalParams.offset < -MAX_MAPS) { - break; - } - } - traversalParams.offset = 1; - while (root.update(traversalParams)) { - if (++traversalParams.offset > MAX_MAPS) { - break; - } - } - } - - return root.getSelected(); -} From d1d879dcaa119e7d5898907287b6a7179c124507 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Wed, 5 Nov 2025 17:58:51 -0500 Subject: [PATCH 17/25] add cogtilelayer definitions --- lonboard/experimental/_surface.py | 8 ++ src/cog-tileset/claude-tileset-2d-improved.ts | 2 +- src/model/layer/index.ts | 8 +- src/model/layer/surface.ts | 88 +++++++++++++++++++ 4 files changed, 103 insertions(+), 3 deletions(-) diff --git a/lonboard/experimental/_surface.py b/lonboard/experimental/_surface.py index c744926b..978eb2cc 100644 --- a/lonboard/experimental/_surface.py +++ b/lonboard/experimental/_surface.py @@ -228,3 +228,11 @@ def from_rasterio( - Type: `bool`, optional - Default: `False` """ + + +class COGTileLayer(BaseLayer): + """COGTileLayer.""" + + _layer_type = t.Unicode("cog-tile").tag(sync=True) + + data = t.Unicode().tag(sync=True) diff --git a/src/cog-tileset/claude-tileset-2d-improved.ts b/src/cog-tileset/claude-tileset-2d-improved.ts index 3832c2fa..8d4447c3 100644 --- a/src/cog-tileset/claude-tileset-2d-improved.ts +++ b/src/cog-tileset/claude-tileset-2d-improved.ts @@ -5,7 +5,7 @@ * following the pattern from deck.gl's OSM tile indexing. */ -import { Viewport, _GlobeViewport } from "@deck.gl/core"; +import { Viewport } from "@deck.gl/core"; import { _Tileset2D as Tileset2D } from "@deck.gl/geo-layers"; import type { Tileset2DProps } from "@deck.gl/geo-layers/dist/tileset-2d"; import type { ZRange } from "@deck.gl/geo-layers/dist/tileset-2d/types"; diff --git a/src/model/layer/index.ts b/src/model/layer/index.ts index 0c5c62c9..d2d7adc5 100644 --- a/src/model/layer/index.ts +++ b/src/model/layer/index.ts @@ -16,7 +16,7 @@ import { SolidPolygonModel, } from "./polygon.js"; import { ScatterplotModel } from "./scatterplot.js"; -import { SurfaceModel } from "./surface.js"; +import { COGTileModel, SurfaceModel } from "./surface.js"; import { TextModel } from "./text.js"; import { TripsModel } from "./trips.js"; @@ -33,7 +33,7 @@ export { SolidPolygonModel, } from "./polygon.js"; export { ScatterplotModel } from "./scatterplot.js"; -export { SurfaceModel } from "./surface.js"; +export { COGTileModel, SurfaceModel } from "./surface.js"; export { TextModel } from "./text.js"; export { TripsModel } from "./trips.js"; @@ -60,6 +60,10 @@ export async function initializeLayer( layerModel = new BitmapTileModel(model, updateStateCallback); break; + case COGTileModel.layerType: + layerModel = new COGTileModel(model, updateStateCallback); + break; + case ColumnModel.layerType: layerModel = new ColumnModel(model, updateStateCallback); break; diff --git a/src/model/layer/surface.ts b/src/model/layer/surface.ts index d95ea135..e5f3a856 100644 --- a/src/model/layer/surface.ts +++ b/src/model/layer/surface.ts @@ -1,8 +1,15 @@ +import { TileLayer, TileLayerProps } from "@deck.gl/geo-layers"; import { SimpleMeshLayer, SimpleMeshLayerProps } from "@deck.gl/mesh-layers"; import type { WidgetModel } from "@jupyter-widgets/base"; import * as arrow from "apache-arrow"; +import GeoTIFF, { fromUrl } from "geotiff"; import { BaseLayerModel } from "./base.js"; +import { + COGTileset2D, + extractCOGMetadata, +} from "../../cog-tileset/claude-tileset-2d-improved.js"; +import { COGMetadata } from "../../cog-tileset/types.js"; import { isDefined } from "../../util.js"; export class SurfaceModel extends BaseLayerModel { @@ -105,3 +112,84 @@ export class SurfaceModel extends BaseLayerModel { }); } } + +export class COGTileModel extends BaseLayerModel { + static layerType = "cog-tile"; + + protected data!: string; + protected tileSize: TileLayerProps["tileSize"]; + protected zoomOffset: TileLayerProps["zoomOffset"]; + protected maxZoom: TileLayerProps["maxZoom"]; + protected minZoom: TileLayerProps["minZoom"]; + protected extent: TileLayerProps["extent"]; + protected maxCacheSize: TileLayerProps["maxCacheSize"]; + protected maxCacheByteSize: TileLayerProps["maxCacheByteSize"]; + protected refinementStrategy: TileLayerProps["refinementStrategy"]; + protected maxRequests: TileLayerProps["maxRequests"]; + + protected tiff?: GeoTIFF; + protected cogMetadata?: COGMetadata; + + constructor(model: WidgetModel, updateStateCallback: () => void) { + super(model, updateStateCallback); + + this.initRegularAttribute("data", "data"); + + this.initRegularAttribute("tile_size", "tileSize"); + this.initRegularAttribute("zoom_offset", "zoomOffset"); + this.initRegularAttribute("max_zoom", "maxZoom"); + this.initRegularAttribute("min_zoom", "minZoom"); + this.initRegularAttribute("extent", "extent"); + this.initRegularAttribute("max_cache_size", "maxCacheSize"); + this.initRegularAttribute("max_cache_byte_size", "maxCacheByteSize"); + this.initRegularAttribute("refinement_strategy", "refinementStrategy"); + this.initRegularAttribute("max_requests", "maxRequests"); + } + + async asyncInit() { + const tiff = await fromUrl(this.data); + const metadata = await extractCOGMetadata(tiff); + + this.tiff = tiff; + this.cogMetadata = metadata; + } + + async loadSubModels() { + await this.asyncInit(); + } + + layerProps(): TileLayerProps { + return { + id: this.model.model_id, + data: this.data, + ...(isDefined(this.tileSize) && { tileSize: this.tileSize }), + ...(isDefined(this.zoomOffset) && { zoomOffset: this.zoomOffset }), + ...(isDefined(this.maxZoom) && { maxZoom: this.maxZoom }), + ...(isDefined(this.minZoom) && { minZoom: this.minZoom }), + ...(isDefined(this.extent) && { extent: this.extent }), + ...(isDefined(this.maxCacheSize) && { maxCacheSize: this.maxCacheSize }), + ...(isDefined(this.maxCacheByteSize) && { + maxCacheByteSize: this.maxCacheByteSize, + }), + ...(isDefined(this.refinementStrategy) && { + refinementStrategy: this.refinementStrategy, + }), + ...(isDefined(this.maxRequests) && { maxRequests: this.maxRequests }), + }; + } + + render(): TileLayer[] { + const layer = new TileLayer({ + ...this.baseLayerProps(), + ...this.layerProps(), + + renderSubLayers: (props) => { + // const [min, max] = props.tile.boundingBox; + console.log(props); + + return []; + }, + }); + return [layer]; + } +} From 7b8d261f400d4a26dd11e93f5dc56c1fb3e376d8 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Wed, 5 Nov 2025 18:09:16 -0500 Subject: [PATCH 18/25] closure --- src/model/layer/surface.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/model/layer/surface.ts b/src/model/layer/surface.ts index e5f3a856..3fd9cf74 100644 --- a/src/model/layer/surface.ts +++ b/src/model/layer/surface.ts @@ -1,4 +1,5 @@ import { TileLayer, TileLayerProps } from "@deck.gl/geo-layers"; +import type { Tileset2DProps } from "@deck.gl/geo-layers/dist/tileset-2d"; import { SimpleMeshLayer, SimpleMeshLayerProps } from "@deck.gl/mesh-layers"; import type { WidgetModel } from "@jupyter-widgets/base"; import * as arrow from "apache-arrow"; @@ -159,9 +160,24 @@ export class COGTileModel extends BaseLayerModel { } layerProps(): TileLayerProps { + // Create a factory class that wraps COGTileset2D with the metadata + if (!this.cogMetadata) { + throw new Error("COG metadata not loaded. Call asyncInit first."); + } + + // Capture cogMetadata in closure with proper type + const cogMetadata: COGMetadata = this.cogMetadata; + + class COGTilesetWrapper extends COGTileset2D { + constructor(opts: Tileset2DProps) { + super(cogMetadata, opts); + } + } + return { id: this.model.model_id, data: this.data, + TilesetClass: COGTilesetWrapper, ...(isDefined(this.tileSize) && { tileSize: this.tileSize }), ...(isDefined(this.zoomOffset) && { zoomOffset: this.zoomOffset }), ...(isDefined(this.maxZoom) && { maxZoom: this.maxZoom }), From 9caf5041c8916d135ce1498d3e66dee6c802da30 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Wed, 5 Nov 2025 18:38:01 -0500 Subject: [PATCH 19/25] try to implement conversion to world bounds --- src/cog-tileset/claude-tileset-2d-improved.ts | 103 +++++++++++++++++- src/cog-tileset/cog-tile-2d-traversal.ts | 41 +++++-- src/cog-tileset/types.ts | 3 + 3 files changed, 132 insertions(+), 15 deletions(-) diff --git a/src/cog-tileset/claude-tileset-2d-improved.ts b/src/cog-tileset/claude-tileset-2d-improved.ts index 8d4447c3..1f78225c 100644 --- a/src/cog-tileset/claude-tileset-2d-improved.ts +++ b/src/cog-tileset/claude-tileset-2d-improved.ts @@ -11,10 +11,83 @@ import type { Tileset2DProps } from "@deck.gl/geo-layers/dist/tileset-2d"; import type { ZRange } from "@deck.gl/geo-layers/dist/tileset-2d/types"; import { Matrix4 } from "@math.gl/core"; import { GeoTIFF } from "geotiff"; +import proj4 from "proj4"; import { getTileIndices } from "./cog-tile-2d-traversal"; import type { COGMetadata, COGTileIndex, COGOverview } from "./types"; +const OGC_84 = { + $schema: "https://proj.org/schemas/v0.7/projjson.schema.json", + type: "GeographicCRS", + name: "WGS 84 (CRS84)", + datum_ensemble: { + name: "World Geodetic System 1984 ensemble", + members: [ + { + name: "World Geodetic System 1984 (Transit)", + id: { authority: "EPSG", code: 1166 }, + }, + { + name: "World Geodetic System 1984 (G730)", + id: { authority: "EPSG", code: 1152 }, + }, + { + name: "World Geodetic System 1984 (G873)", + id: { authority: "EPSG", code: 1153 }, + }, + { + name: "World Geodetic System 1984 (G1150)", + id: { authority: "EPSG", code: 1154 }, + }, + { + name: "World Geodetic System 1984 (G1674)", + id: { authority: "EPSG", code: 1155 }, + }, + { + name: "World Geodetic System 1984 (G1762)", + id: { authority: "EPSG", code: 1156 }, + }, + { + name: "World Geodetic System 1984 (G2139)", + id: { authority: "EPSG", code: 1309 }, + }, + ], + ellipsoid: { + name: "WGS 84", + semi_major_axis: 6378137, + inverse_flattening: 298.257223563, + }, + accuracy: "2.0", + id: { authority: "EPSG", code: 6326 }, + }, + coordinate_system: { + subtype: "ellipsoidal", + axis: [ + { + name: "Geodetic longitude", + abbreviation: "Lon", + direction: "east", + unit: "degree", + }, + { + name: "Geodetic latitude", + abbreviation: "Lat", + direction: "north", + unit: "degree", + }, + ], + }, + scope: "Not known.", + area: "World.", + bbox: { + south_latitude: -90, + west_longitude: -180, + north_latitude: 90, + east_longitude: 180, + }, + id: { authority: "OGC", code: "CRS84" }, +}; + /** * Extract COG metadata */ @@ -31,10 +104,9 @@ export async function extractCOGMetadata(tiff: GeoTIFF): Promise { const bbox = image.getBoundingBox(); const geoKeys = image.getGeoKeys(); - const projection = - geoKeys.ProjectedCSTypeGeoKey || geoKeys.GeographicTypeGeoKey - ? `EPSG:${geoKeys.ProjectedCSTypeGeoKey || geoKeys.GeographicTypeGeoKey}` - : null; + const projectionCode: number | null = + geoKeys.ProjectedCSTypeGeoKey || geoKeys.GeographicTypeGeoKey || null; + const projection = projectionCode ? `EPSG:${projectionCode}` : null; // Overviews **in COG order**, from finest to coarsest (we'll reverse the // array later) @@ -77,6 +149,10 @@ export async function extractCOGMetadata(tiff: GeoTIFF): Promise { // Reverse to TileMatrixSet order: coarsest (0) → finest (n) overviews.reverse(); + const sourceProjection = await getProjjson(projectionCode); + const projectToWgs84 = proj4(sourceProjection, OGC_84); + const projectTo3857 = proj4(sourceProjection, "EPSG:3857"); + return { width, height, @@ -86,11 +162,23 @@ export async function extractCOGMetadata(tiff: GeoTIFF): Promise { tilesY, bbox: [bbox[0], bbox[1], bbox[2], bbox[3]], projection, + projectToWgs84, + projectTo3857, overviews, image: tiff, }; } +async function getProjjson(projectionCode: number | null) { + const url = `https://epsg.io/${projectionCode}.json`; + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to fetch projection data from ${url}`); + } + const data = await response.json(); + return data; +} + /** * COGTileset2D with proper frustum culling */ @@ -116,7 +204,12 @@ export class COGTileset2D extends Tileset2D { modelMatrix?: Matrix4; modelMatrixInverse?: Matrix4; }): COGTileIndex[] { - return getTileIndices(this.cogMetadata, opts); + console.log("Getting tile indices with COGTileset2D"); + console.log(opts); + const tileIndices = getTileIndices(this.cogMetadata, opts); + console.log("Visible tile indices:"); + console.log(tileIndices); + return tileIndices; } getTileId(index: COGTileIndex): string { diff --git a/src/cog-tileset/cog-tile-2d-traversal.ts b/src/cog-tileset/cog-tile-2d-traversal.ts index 91150250..e7e5c1a1 100644 --- a/src/cog-tileset/cog-tile-2d-traversal.ts +++ b/src/cog-tileset/cog-tile-2d-traversal.ts @@ -212,6 +212,9 @@ class COGTileNode { const tileMaxX = tileMinX + tileGeoWidth; const tileMaxY = tileMinY + tileGeoHeight; + console.log("Tile bounding box:"); + console.log(tileMinX, tileMinY, tileMaxX, tileMaxY); + if (project) { assert(false, "TODO: check bounding volume implementation in Globe view"); @@ -240,7 +243,24 @@ class COGTileNode { // return makeOrientedBoundingBoxFromPoints(refPointPositions); } - assert(false, "bounding volume of web mercator is probably wrong"); + // Reproject tile bounds to Web Mercator for bounding volume + const ll = this.cogMetadata.projectTo3857.forward([tileMinX, tileMinY]); + const lr = this.cogMetadata.projectTo3857.forward([tileMaxX, tileMinY]); + const ur = this.cogMetadata.projectTo3857.forward([tileMaxX, tileMaxY]); + const ul = this.cogMetadata.projectTo3857.forward([tileMinX, tileMaxY]); + + const webMercatorMinX = Math.min(ll[0], lr[0], ur[0], ul[0]); + const webMercatorMaxX = Math.max(ll[0], lr[0], ur[0], ul[0]); + const webMercatorMinY = Math.min(ll[1], lr[1], ur[1], ul[1]); + const webMercatorMaxY = Math.max(ll[1], lr[1], ur[1], ul[1]); + + console.log("Tile Web Mercator bounds:"); + console.log( + webMercatorMinX, + webMercatorMinY, + webMercatorMaxX, + webMercatorMaxY, + ); // Web Mercator projection // Assuming COG is already in Web Mercator (EPSG:3857) @@ -248,12 +268,17 @@ class COGTileNode { const WORLD_SIZE = 512; // deck.gl's world size const METERS_PER_WORLD = 40075017; // Earth circumference at equator - const worldMinX = (tileMinX / METERS_PER_WORLD) * WORLD_SIZE; - const worldMaxX = (tileMaxX / METERS_PER_WORLD) * WORLD_SIZE; + const worldMinX = (webMercatorMinX / METERS_PER_WORLD) * WORLD_SIZE; + const worldMaxX = (webMercatorMaxX / METERS_PER_WORLD) * WORLD_SIZE; // Y is flipped in deck.gl's common space - const worldMinY = WORLD_SIZE - (tileMaxY / METERS_PER_WORLD) * WORLD_SIZE; - const worldMaxY = WORLD_SIZE - (tileMinY / METERS_PER_WORLD) * WORLD_SIZE; + const worldMinY = + WORLD_SIZE - (webMercatorMaxY / METERS_PER_WORLD) * WORLD_SIZE; + const worldMaxY = + WORLD_SIZE - (webMercatorMinY / METERS_PER_WORLD) * WORLD_SIZE; + + console.log("Tile world bounds:"); + console.log(worldMinX, worldMinY, worldMaxX, worldMaxY); return new AxisAlignedBoundingBox( [worldMinX, worldMinY, zRange[0]], @@ -266,11 +291,7 @@ class COGTileNode { * This is a placeholder - needs proper projection library (proj4js) */ private cogCoordsToLngLat([x, y]: [number, number]): number[] { - // For Web Mercator (EPSG:3857) - const R = 6378137; // Earth radius - const lng = (x / R) * (180 / Math.PI); - const lat = - (Math.PI / 2 - 2 * Math.atan(Math.exp(-y / R))) * (180 / Math.PI); + const [lng, lat] = this.cogMetadata.projectToWgs84.forward([x, y]); return [lng, lat, 0]; } } diff --git a/src/cog-tileset/types.ts b/src/cog-tileset/types.ts index 647aaeea..a1bca667 100644 --- a/src/cog-tileset/types.ts +++ b/src/cog-tileset/types.ts @@ -1,4 +1,5 @@ import type { GeoTIFF } from "geotiff"; +import type { Converter } from "proj4"; export type ZRange = [minZ: number, maxZ: number]; @@ -170,6 +171,8 @@ export type COGMetadata = { projection: string | null; overviews: COGOverview[]; image: GeoTIFF; // GeoTIFF reference + projectToWgs84: Converter; + projectTo3857: Converter; }; /** From c01d20dc2481d35a355ca6b55c619ed8329941e9 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Wed, 5 Nov 2025 19:20:35 -0500 Subject: [PATCH 20/25] Move metadata to getTileMetadata --- src/cog-tileset/claude-tileset-2d-improved.ts | 25 ++++++++++++---- src/cog-tileset/cog-tile-2d-traversal.ts | 30 +------------------ src/cog-tileset/types.ts | 3 -- 3 files changed, 21 insertions(+), 37 deletions(-) diff --git a/src/cog-tileset/claude-tileset-2d-improved.ts b/src/cog-tileset/claude-tileset-2d-improved.ts index 1f78225c..abb5730b 100644 --- a/src/cog-tileset/claude-tileset-2d-improved.ts +++ b/src/cog-tileset/claude-tileset-2d-improved.ts @@ -240,12 +240,27 @@ export class COGTileset2D extends Tileset2D { } getTileMetadata(index: COGTileIndex): Record { - const overview = this.cogMetadata.overviews[index.z]; + const { x, y, z } = index; + const { bbox, overviews, tileWidth, tileHeight } = this.cogMetadata; + const overview = overviews[z]; + + const cogWidth = bbox[2] - bbox[0]; + const cogHeight = bbox[3] - bbox[1]; + + const tileGeoWidth = cogWidth / overview.tilesX; + const tileGeoHeight = cogHeight / overview.tilesY; + + const bounds: Bounds = [ + bbox[0] + x * tileGeoWidth, + bbox[1] + y * tileGeoHeight, + bbox[0] + (x + 1) * tileGeoWidth, + bbox[1] + (y + 1) * tileGeoHeight, + ]; + return { - bounds: index.bounds, - level: index.level, - tileWidth: this.cogMetadata.tileWidth, - tileHeight: this.cogMetadata.tileHeight, + bounds, + tileWidth, + tileHeight, overview, }; } diff --git a/src/cog-tileset/cog-tile-2d-traversal.ts b/src/cog-tileset/cog-tile-2d-traversal.ts index e7e5c1a1..0e16e8c6 100644 --- a/src/cog-tileset/cog-tile-2d-traversal.ts +++ b/src/cog-tileset/cog-tile-2d-traversal.ts @@ -376,33 +376,5 @@ export function getTileIndices( root.getSelected(selectedNodes); } - // TODO: remove this from here - // Instead, move it to `getTileMetadata` in the tileset class - // Convert to tile indices with bounds - return selectedNodes.map((node) => { - const overview = cogMetadata.overviews[node.z]; - const { bbox } = cogMetadata; - - const cogWidth = bbox[2] - bbox[0]; - const cogHeight = bbox[3] - bbox[1]; - - // TODO: use tileWidth/tileHeight from cogMetadata instead? - const tileGeoWidth = cogWidth / overview.tilesX; - const tileGeoHeight = cogHeight / overview.tilesY; - - const bounds: Bounds = [ - bbox[0] + node.x * tileGeoWidth, - bbox[1] + node.y * tileGeoHeight, - bbox[0] + (node.x + 1) * tileGeoWidth, - bbox[1] + (node.y + 1) * tileGeoHeight, - ]; - - return { - x: node.x, - y: node.y, - z: node.z, - level: node.z, - bounds, - }; - }); + return selectedNodes; } diff --git a/src/cog-tileset/types.ts b/src/cog-tileset/types.ts index a1bca667..aefc84a2 100644 --- a/src/cog-tileset/types.ts +++ b/src/cog-tileset/types.ts @@ -184,9 +184,6 @@ export type COGTileIndex = { x: number; y: number; z: number; // TileMatrixSet/OSM zoom (0 = coarsest, higher = finer) - level: number; // Same as z in TileMatrixSet ordering - geoTiffIndex?: number; // Index in GeoTIFF file (for reading tiles) - bounds?: Bounds; }; //////////////// From 89cc6beebd0a80b0d5e9872e391e38a04b988e0e Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Thu, 6 Nov 2025 13:41:21 -0500 Subject: [PATCH 21/25] add comments --- src/cog-tileset/cog-tile-2d-traversal.ts | 148 +++++++++++++++++++---- 1 file changed, 127 insertions(+), 21 deletions(-) diff --git a/src/cog-tileset/cog-tile-2d-traversal.ts b/src/cog-tileset/cog-tile-2d-traversal.ts index 0e16e8c6..f7c09f7a 100644 --- a/src/cog-tileset/cog-tile-2d-traversal.ts +++ b/src/cog-tileset/cog-tile-2d-traversal.ts @@ -1,3 +1,19 @@ +/** + * This file implements tile traversal for generic 2D tilesets defined by COG + * tile layouts. + * + * The main algorithm works as follows: + * 1. Start at the root tile(s) (z=0, covers the entire image, but not + * necessarily the whole world) + * 2. Test if each tile is visible using viewport frustum culling + * 3. For visible tiles, compute distance-based LOD (Level of Detail) + * 4. If LOD is insufficient, recursively subdivide into 4 child tiles + * 5. Select tiles at appropriate zoom levels based on distance from camera + * + * The result is a set of tiles at varying zoom levels that efficiently + * cover the visible area with appropriate detail. + */ + import { _GlobeViewport, assert, Viewport } from "@deck.gl/core"; import { AxisAlignedBoundingBox, CullingVolume, Plane } from "@math.gl/culling"; @@ -9,22 +25,62 @@ import type { ZRange, } from "./types"; -// for calculating bounding volume of a tile in a non-web-mercator viewport -// const REF_POINTS_5 = [ -// [0.5, 0.5], -// [0, 0], -// [0, 1], -// [1, 0], -// [1, 1], -// ]; // 4 corners and center +/** + * The size of the entire world in deck.gl's common coordinate space. + * + * The world always spans [0, 512] in both X and Y in Web Mercator common space. + * + * The origin (0,0) is at the top-left corner, and (512,512) is at the + * bottom-right. + */ +const WORLD_SIZE = 512; + +// Reference points used to sample tile boundaries for bounding volume +// calculation. +// +// In upstream deck.gl code, such reference points are only used in non-Web +// Mercator projections because the OSM tiling scheme is designed for Web +// Mercator and the OSM tile extents are already in Web Mercator projection. So +// using Axis-Aligned bounding boxes based on tile extents is sufficient for +// frustum culling in Web Mercator viewports. +// +// In upstream code these reference points are used for Globe View where the OSM +// tile indices _projected into longitude-latitude bounds in Globe View space_ +// are no longer axis-aligned, and oriented bounding boxes must be used instead. +// +// In the context of generic tiling grids which are often not in Web Mercator +// projection, we must use the reference points approach because the grid tiles +// will never be exact axis aligned boxes in Web Mercator space. + +// For most tiles: sample 4 corners and center (5 points total) +const REF_POINTS_5 = [ + [0.5, 0.5], // center + [0, 0], // top-left + [0, 1], // bottom-left + [1, 0], // top-right + [1, 1], // bottom-right +]; + +// For higher detail: add 4 edge midpoints (9 points total) +const REF_POINTS_9 = REF_POINTS_5.concat([ + [0, 0.5], // left edge + [0.5, 0], // top edge + [1, 0.5], // right edge + [0.5, 1], // bottom edge +]); /** - * COG Tile Node - similar to OSMNode but for COG's tile structure + * COG Tile Node - similar to OSMNode but for COG's tile structure. * - * Uses TileMatrixSet ordering where: index 0 = coarsest, higher = finer. - * In this ordering, z === level (both increase with detail). + * Represents a single tile in the COG internal tiling pyramid. + * + * COG tile nodes use the following coordinate system: + * + * - x: tile column (0 to COGOverview.tilesX, left to right) + * - y: tile row (0 to COGOverview.tilesY, top to bottom) + * - z: overview level. This uses TileMatrixSet ordering where: 0 = coarsest, higher = finer */ -class COGTileNode { +export class COGTileNode { /** Index across a row */ x: number; /** Index down a column */ @@ -34,8 +90,21 @@ class COGTileNode { private cogMetadata: COGMetadata; + /** + * Flag indicating whether any descendant of this tile is visible. + * + * Used to prevent loading parent tiles when children are visible (avoids + * overdraw). + */ private childVisible?: boolean; + + /** + * Flag indicating this tile should be rendered + * + * Set to `true` when this is the appropriate LOD for its distance from camera. + */ private selected?: boolean; + /** A cache of the children of this node. */ private _children?: COGTileNode[]; @@ -131,6 +200,9 @@ class COGTileNode { // Check if tile is visible in frustum const isInside = cullingVolume.computeVisibility(boundingVolume); + console.log( + `Tile ${this.x},${this.y},${this.z} frustum check: ${isInside} (${isInside < 0 ? "CULLED" : "VISIBLE"})`, + ); if (isInside < 0) { return false; } @@ -176,7 +248,11 @@ class COGTileNode { } /** - * Collect all selected tiles + * Collect all tiles marked as selected in the tree. + * Recursively traverses the entire tree and gathers tiles where selected=true. + * + * @param result - Accumulator array for selected tiles + * @returns Array of selected OSMNode tiles */ getSelected(result: COGTileNode[] = []): COGTileNode[] { if (this.selected) { @@ -191,7 +267,9 @@ class COGTileNode { } /** - * Calculate bounding volume for frustum culling + * Calculate the 3D bounding volume for this tile in deck.gl's common + * coordinate space for frustum culling. + * */ getBoundingVolume( zRange: ZRange, @@ -200,6 +278,15 @@ class COGTileNode { const overview = this.overview; const { bbox } = this.cogMetadata; + const refPoints = REF_POINTS_9; + + // Sample points across the tile surface and project to common space + const refPointPositions: number[][] = []; + for (const [pX, pY] of refPoints) { + // Convert tile-relative coordinates [0-1] to geographic coordinates in + // the COG's CRS + } + // TODO: use tileWidth/tileHeight from cogMetadata instead? const cogWidth = bbox[2] - bbox[0]; const cogHeight = bbox[3] - bbox[1]; @@ -263,20 +350,31 @@ class COGTileNode { ); // Web Mercator projection - // Assuming COG is already in Web Mercator (EPSG:3857) - // Convert from meters to deck.gl's common space (world units) + // Convert from Web Mercator meters to deck.gl's common space (world units) + // Web Mercator range: [-20037508.34, 20037508.34] meters + // deck.gl world space: [0, 512] const WORLD_SIZE = 512; // deck.gl's world size - const METERS_PER_WORLD = 40075017; // Earth circumference at equator + const WEB_MERCATOR_MAX = 20037508.342789244; // Half Earth circumference - const worldMinX = (webMercatorMinX / METERS_PER_WORLD) * WORLD_SIZE; - const worldMaxX = (webMercatorMaxX / METERS_PER_WORLD) * WORLD_SIZE; + // Offset from [-20M, 20M] to [0, 40M], then normalize to [0, 512] + const worldMinX = + ((webMercatorMinX + WEB_MERCATOR_MAX) / (2 * WEB_MERCATOR_MAX)) * + WORLD_SIZE; + const worldMaxX = + ((webMercatorMaxX + WEB_MERCATOR_MAX) / (2 * WEB_MERCATOR_MAX)) * + WORLD_SIZE; // Y is flipped in deck.gl's common space const worldMinY = - WORLD_SIZE - (webMercatorMaxY / METERS_PER_WORLD) * WORLD_SIZE; + WORLD_SIZE - + ((webMercatorMaxY + WEB_MERCATOR_MAX) / (2 * WEB_MERCATOR_MAX)) * + WORLD_SIZE; const worldMaxY = - WORLD_SIZE - (webMercatorMinY / METERS_PER_WORLD) * WORLD_SIZE; + WORLD_SIZE - + ((webMercatorMinY + WEB_MERCATOR_MAX) / (2 * WEB_MERCATOR_MAX)) * + WORLD_SIZE; + console.log("Tile world X bounds:"); console.log("Tile world bounds:"); console.log(worldMinX, worldMinY, worldMaxX, worldMaxY); @@ -313,6 +411,12 @@ export function getTileIndices( ): COGTileIndex[] { const { viewport, maxZ, zRange } = opts; + // console.log("=== getTileIndices called ==="); + // console.log("Viewport:", viewport); + // console.log("maxZ:", maxZ); + // console.log("COG metadata overviews count:", cogMetadata.overviews.length); + // console.log("COG bbox:", cogMetadata.bbox); + const project: ((xyz: number[]) => number[]) | null = viewport instanceof _GlobeViewport && viewport.resolution ? viewport.projectPosition @@ -365,10 +469,12 @@ export function getTileIndices( minZ: 0, maxZ, }; + console.log("Traversal params:", traversalParams); for (const root of roots) { root.update(traversalParams); } + console.log("roots", roots); // Collect selected tiles const selectedNodes: COGTileNode[] = []; From d8d197f202c663648907278f7d185b61737e02bf Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Thu, 6 Nov 2025 14:16:12 -0500 Subject: [PATCH 22/25] handle geotransforms --- src/cog-tileset/claude-tileset-2d-improved.ts | 79 ++++++++++++++++++- src/cog-tileset/cog-tile-2d-traversal.ts | 44 +++++++---- src/cog-tileset/types.ts | 25 ++++++ 3 files changed, 130 insertions(+), 18 deletions(-) diff --git a/src/cog-tileset/claude-tileset-2d-improved.ts b/src/cog-tileset/claude-tileset-2d-improved.ts index abb5730b..395492d6 100644 --- a/src/cog-tileset/claude-tileset-2d-improved.ts +++ b/src/cog-tileset/claude-tileset-2d-improved.ts @@ -10,11 +10,11 @@ import { _Tileset2D as Tileset2D } from "@deck.gl/geo-layers"; import type { Tileset2DProps } from "@deck.gl/geo-layers/dist/tileset-2d"; import type { ZRange } from "@deck.gl/geo-layers/dist/tileset-2d/types"; import { Matrix4 } from "@math.gl/core"; -import { GeoTIFF } from "geotiff"; +import { GeoTIFF, GeoTIFFImage } from "geotiff"; import proj4 from "proj4"; import { getTileIndices } from "./cog-tile-2d-traversal"; -import type { COGMetadata, COGTileIndex, COGOverview } from "./types"; +import type { COGMetadata, COGTileIndex, COGOverview, Bounds } from "./types"; const OGC_84 = { $schema: "https://proj.org/schemas/v0.7/projjson.schema.json", @@ -88,6 +88,53 @@ const OGC_84 = { id: { authority: "OGC", code: "CRS84" }, }; +/** + * Extract affine geotransform from a GeoTIFF image. + * + * Returns a 6-element array in Python `affine` package ordering: + * [a, b, c, d, e, f] where: + * - x_geo = a * col + b * row + c + * - y_geo = d * col + e * row + f + * + * This is NOT GDAL ordering, which is [c, a, b, f, d, e]. + */ +function extractGeotransform( + image: GeoTIFFImage, +): [number, number, number, number, number, number] { + const origin = image.getOrigin(); + const resolution = image.getResolution(); + + // origin: [x, y, z] + // resolution: [x_res, y_res, z_res] + + // Check for rotation/skew in the file directory + const fileDirectory = image.getFileDirectory(); + const modelTransformation = fileDirectory.ModelTransformation; + + let b = 0; // row rotation + let d = 0; // column rotation + + if (modelTransformation && modelTransformation.length >= 16) { + // ModelTransformation is a 4x4 matrix in row-major order + // [0 1 2 3 ] [a b 0 c] + // [4 5 6 7 ] = [d e 0 f] + // [8 9 10 11] [0 0 1 0] + // [12 13 14 15] [0 0 0 1] + b = modelTransformation[1]; + d = modelTransformation[4]; + } + + // Return in affine package ordering: [a, b, c, d, e, f] + return [ + resolution[0], // a: pixel width + b, // b: row rotation + origin[0], // c: x origin + d, // d: column rotation + resolution[1], // e: pixel height (often negative) + origin[1], // f: y origin + ]; +} + /** * Extract COG metadata */ @@ -108,6 +155,10 @@ export async function extractCOGMetadata(tiff: GeoTIFF): Promise { geoKeys.ProjectedCSTypeGeoKey || geoKeys.GeographicTypeGeoKey || null; const projection = projectionCode ? `EPSG:${projectionCode}` : null; + // Extract geotransform from full-resolution image + // Only the top-level IFD has geo keys, so we'll derive overviews from this + const baseGeotransform = extractGeotransform(image); + // Overviews **in COG order**, from finest to coarsest (we'll reverse the // array later) const overviews: COGOverview[] = []; @@ -121,6 +172,7 @@ export async function extractCOGMetadata(tiff: GeoTIFF): Promise { tilesX, tilesY, scaleFactor: 1, + geotransform: baseGeotransform, // TODO: combine these two properties into one level: imageCount - 1, // Coarsest level number z: imageCount - 1, @@ -133,13 +185,34 @@ export async function extractCOGMetadata(tiff: GeoTIFF): Promise { const overviewTileWidth = overview.getTileWidth(); const overviewTileHeight = overview.getTileHeight(); + const scaleFactor = Math.round(width / overviewWidth); + + // Derive geotransform for this overview by scaling pixel size + // [a, b, c, d, e, f] where a and e are pixel dimensions + const overviewGeotransform: [ + number, + number, + number, + number, + number, + number, + ] = [ + baseGeotransform[0] * scaleFactor, // a: scaled pixel width + baseGeotransform[1] * scaleFactor, // b: scaled row rotation + baseGeotransform[2], // c: same x origin + baseGeotransform[3] * scaleFactor, // d: scaled column rotation + baseGeotransform[4] * scaleFactor, // e: scaled pixel height (typically negative) + baseGeotransform[5], // f: same y origin + ]; + overviews.push({ geoTiffIndex: i, width: overviewWidth, height: overviewHeight, tilesX: Math.ceil(overviewWidth / overviewTileWidth), tilesY: Math.ceil(overviewHeight / overviewTileHeight), - scaleFactor: Math.round(width / overviewWidth), + scaleFactor, + geotransform: overviewGeotransform, // TODO: combine these two properties into one level: imageCount - 1 - i, z: imageCount - 1 - i, diff --git a/src/cog-tileset/cog-tile-2d-traversal.ts b/src/cog-tileset/cog-tile-2d-traversal.ts index f7c09f7a..7619ec02 100644 --- a/src/cog-tileset/cog-tile-2d-traversal.ts +++ b/src/cog-tileset/cog-tile-2d-traversal.ts @@ -276,28 +276,42 @@ export class COGTileNode { project: ((xyz: number[]) => number[]) | null, ) { const overview = this.overview; - const { bbox } = this.cogMetadata; + const { tileWidth, tileHeight } = this.cogMetadata; - const refPoints = REF_POINTS_9; + // Use geotransform to calculate tile bounds + // geotransform: [a, b, c, d, e, f] where: + // x_geo = a * col + b * row + c + // y_geo = d * col + e * row + f + const [a, b, c, d, e, f] = overview.geotransform; + + // Calculate pixel coordinates for this tile's extent + const pixelMinCol = this.x * tileWidth; + const pixelMinRow = this.y * tileHeight; + const pixelMaxCol = (this.x + 1) * tileWidth; + const pixelMaxRow = (this.y + 1) * tileHeight; - // Sample points across the tile surface and project to common space + // Sample reference points across the tile surface + const refPoints = REF_POINTS_9; const refPointPositions: number[][] = []; + for (const [pX, pY] of refPoints) { - // Convert tile-relative coordinates [0-1] to geographic coordinates in - // the COG's CRS - } + // pX, pY are in [0, 1] range + // Interpolate pixel coordinates within the tile + const col = pixelMinCol + pX * (pixelMaxCol - pixelMinCol); + const row = pixelMinRow + pY * (pixelMaxRow - pixelMinRow); - // TODO: use tileWidth/tileHeight from cogMetadata instead? - const cogWidth = bbox[2] - bbox[0]; - const cogHeight = bbox[3] - bbox[1]; + // Convert pixel coordinates to geographic coordinates using geotransform + const geoX = a * col + b * row + c; + const geoY = d * col + e * row + f; - const tileGeoWidth = cogWidth / overview.tilesX; - const tileGeoHeight = cogHeight / overview.tilesY; + refPointPositions.push([geoX, geoY]); + } - const tileMinX = bbox[0] + this.x * tileGeoWidth; - const tileMinY = bbox[1] + this.y * tileGeoHeight; - const tileMaxX = tileMinX + tileGeoWidth; - const tileMaxY = tileMinY + tileGeoHeight; + // Calculate bounding box for debugging + const tileMinX = Math.min(...refPointPositions.map((p) => p[0])); + const tileMaxX = Math.max(...refPointPositions.map((p) => p[0])); + const tileMinY = Math.min(...refPointPositions.map((p) => p[1])); + const tileMaxY = Math.max(...refPointPositions.map((p) => p[1])); console.log("Tile bounding box:"); console.log(tileMinX, tileMinY, tileMaxX, tileMaxY); diff --git a/src/cog-tileset/types.ts b/src/cog-tileset/types.ts index aefc84a2..9c752a4c 100644 --- a/src/cog-tileset/types.ts +++ b/src/cog-tileset/types.ts @@ -155,6 +155,30 @@ export type COGOverview = { * level: 3, geoTiffIndex: 0 // Finest (GeoTIFF main image) */ geoTiffIndex: number; + + /** + * Affine geotransform for this overview level. + * + * Uses Python `affine` package ordering (NOT GDAL ordering): + * [a, b, c, d, e, f] where: + * - x_geo = a * col + b * row + c + * - y_geo = d * col + e * row + f + * + * Parameters: + * - a: pixel width (x resolution) + * - b: row rotation (typically 0) + * - c: x-coordinate of upper-left corner of the upper-left pixel + * - d: column rotation (typically 0) + * - e: pixel height (y resolution, typically negative) + * - f: y-coordinate of upper-left corner of the upper-left pixel + * + * @example + * // For a UTM image with 30m pixels: + * [30, 0, 440720, 0, -30, 3751320] + * // x_geo = 30 * col + 440720 + * // y_geo = -30 * row + 3751320 + */ + geotransform: [number, number, number, number, number, number]; }; /** @@ -163,6 +187,7 @@ export type COGOverview = { export type COGMetadata = { width: number; height: number; + /** Number of pixels wide for each tile */ tileWidth: number; tileHeight: number; tilesX: number; From dab326f5103f1a39f451563b225a55a75a0a86f6 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Thu, 6 Nov 2025 14:23:25 -0500 Subject: [PATCH 23/25] cleanup --- src/cog-tileset/cog-tile-2d-traversal.ts | 77 ++++++++---------------- 1 file changed, 26 insertions(+), 51 deletions(-) diff --git a/src/cog-tileset/cog-tile-2d-traversal.ts b/src/cog-tileset/cog-tile-2d-traversal.ts index 7619ec02..44ea0a8f 100644 --- a/src/cog-tileset/cog-tile-2d-traversal.ts +++ b/src/cog-tileset/cog-tile-2d-traversal.ts @@ -292,7 +292,9 @@ export class COGTileNode { // Sample reference points across the tile surface const refPoints = REF_POINTS_9; - const refPointPositions: number[][] = []; + + /** Reference points positions in image CRS */ + const refPointPositionsImage: number[][] = []; for (const [pX, pY] of refPoints) { // pX, pY are in [0, 1] range @@ -304,64 +306,37 @@ export class COGTileNode { const geoX = a * col + b * row + c; const geoY = d * col + e * row + f; - refPointPositions.push([geoX, geoY]); + refPointPositionsImage.push([geoX, geoY]); } - // Calculate bounding box for debugging - const tileMinX = Math.min(...refPointPositions.map((p) => p[0])); - const tileMaxX = Math.max(...refPointPositions.map((p) => p[0])); - const tileMinY = Math.min(...refPointPositions.map((p) => p[1])); - const tileMaxY = Math.max(...refPointPositions.map((p) => p[1])); - - console.log("Tile bounding box:"); - console.log(tileMinX, tileMinY, tileMaxX, tileMaxY); - if (project) { - assert(false, "TODO: check bounding volume implementation in Globe view"); - - // Custom projection (e.g., GlobeView) - // Estimate bounding box from sample points - // TODO: switch to higher ref points at lowest zoom levels, like upstream - // const refPoints = REF_POINTS_5; - - // const refPointPositions: number[][] = []; - // for (const [fx, fy] of refPoints) { - // const geoX = tileMinX + fx * tileGeoWidth; - // const geoY = tileMinY + fy * tileGeoHeight; - - // // Convert from COG coordinates to lng/lat - // // This assumes COG is in Web Mercator - adjust for other projections - // const lngLat = this.cogCoordsToLngLat([geoX, geoY]); - // lngLat[2] = zRange[0]; - // refPointPositions.push(project(lngLat)); - - // if (zRange[0] !== zRange[1]) { - // lngLat[2] = zRange[1]; - // refPointPositions.push(project(lngLat)); - // } - // } - + assert( + false, + "TODO: implement bounding volume implementation in Globe view", + ); + // Reproject positions to wgs84 instead, then pass them into `project` // return makeOrientedBoundingBoxFromPoints(refPointPositions); } - // Reproject tile bounds to Web Mercator for bounding volume - const ll = this.cogMetadata.projectTo3857.forward([tileMinX, tileMinY]); - const lr = this.cogMetadata.projectTo3857.forward([tileMaxX, tileMinY]); - const ur = this.cogMetadata.projectTo3857.forward([tileMaxX, tileMaxY]); - const ul = this.cogMetadata.projectTo3857.forward([tileMinX, tileMaxY]); + /** Reference points positions in EPSG 3857 */ + const refPointPositionsProjected: number[][] = []; - const webMercatorMinX = Math.min(ll[0], lr[0], ur[0], ul[0]); - const webMercatorMaxX = Math.max(ll[0], lr[0], ur[0], ul[0]); - const webMercatorMinY = Math.min(ll[1], lr[1], ur[1], ul[1]); - const webMercatorMaxY = Math.max(ll[1], lr[1], ur[1], ul[1]); + for (const [pX, pY] of refPointPositionsImage) { + // Reproject to Web Mercator (EPSG 3857) + const projected = this.cogMetadata.projectTo3857.forward([pX, pY]); + refPointPositionsProjected.push(projected); + } - console.log("Tile Web Mercator bounds:"); - console.log( - webMercatorMinX, - webMercatorMinY, - webMercatorMaxX, - webMercatorMaxY, - ); + // Calculate bounding box for debugging + const tileMinX = Math.min(...refPointPositionsImage.map((p) => p[0])); + const tileMaxX = Math.max(...refPointPositionsImage.map((p) => p[0])); + const tileMinY = Math.min(...refPointPositionsImage.map((p) => p[1])); + const tileMaxY = Math.max(...refPointPositionsImage.map((p) => p[1])); + + console.log("Tile bounding box:"); + console.log(tileMinX, tileMinY, tileMaxX, tileMaxY); + + // return makeOrientedBoundingBoxFromPoints(refPointPositions); // Web Mercator projection // Convert from Web Mercator meters to deck.gl's common space (world units) From 7a47940372566f52872cd14561286a7a549a713b Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Thu, 6 Nov 2025 14:48:35 -0500 Subject: [PATCH 24/25] rendering one top-level box --- src/cog-tileset/claude-tileset-2d-improved.ts | 55 ++++++++++-- src/cog-tileset/cog-tile-2d-traversal.ts | 86 +++++++++---------- src/model/layer/surface.ts | 48 ++++++++++- 3 files changed, 131 insertions(+), 58 deletions(-) diff --git a/src/cog-tileset/claude-tileset-2d-improved.ts b/src/cog-tileset/claude-tileset-2d-improved.ts index 395492d6..7d439683 100644 --- a/src/cog-tileset/claude-tileset-2d-improved.ts +++ b/src/cog-tileset/claude-tileset-2d-improved.ts @@ -282,6 +282,8 @@ export class COGTileset2D extends Tileset2D { const tileIndices = getTileIndices(this.cogMetadata, opts); console.log("Visible tile indices:"); console.log(tileIndices); + + return [{ x: 0, y: 0, z: 0 }]; // Temporary override for testing return tileIndices; } @@ -314,24 +316,59 @@ export class COGTileset2D extends Tileset2D { getTileMetadata(index: COGTileIndex): Record { const { x, y, z } = index; - const { bbox, overviews, tileWidth, tileHeight } = this.cogMetadata; + const { overviews, tileWidth, tileHeight } = this.cogMetadata; const overview = overviews[z]; - const cogWidth = bbox[2] - bbox[0]; - const cogHeight = bbox[3] - bbox[1]; + // Use geotransform to calculate tile bounds + // geotransform: [a, b, c, d, e, f] where: + // x_geo = a * col + b * row + c + // y_geo = d * col + e * row + f + const [a, b, c, d, e, f] = overview.geotransform; + + // Calculate pixel coordinates for this tile's extent + const pixelMinCol = x * tileWidth; + const pixelMinRow = y * tileHeight; + const pixelMaxCol = (x + 1) * tileWidth; + const pixelMaxRow = (y + 1) * tileHeight; + + // Calculate the four corners of the tile in geographic coordinates + const topLeft = [ + a * pixelMinCol + b * pixelMinRow + c, + d * pixelMinCol + e * pixelMinRow + f, + ]; + const topRight = [ + a * pixelMaxCol + b * pixelMinRow + c, + d * pixelMaxCol + e * pixelMinRow + f, + ]; + const bottomLeft = [ + a * pixelMinCol + b * pixelMaxRow + c, + d * pixelMinCol + e * pixelMaxRow + f, + ]; + const bottomRight = [ + a * pixelMaxCol + b * pixelMaxRow + c, + d * pixelMaxCol + e * pixelMaxRow + f, + ]; - const tileGeoWidth = cogWidth / overview.tilesX; - const tileGeoHeight = cogHeight / overview.tilesY; + // Return the projected bounds as four corners + // This preserves rotation/skew information + const projectedBounds = { + topLeft, + topRight, + bottomLeft, + bottomRight, + }; + // Also compute axis-aligned bounding box for compatibility const bounds: Bounds = [ - bbox[0] + x * tileGeoWidth, - bbox[1] + y * tileGeoHeight, - bbox[0] + (x + 1) * tileGeoWidth, - bbox[1] + (y + 1) * tileGeoHeight, + Math.min(topLeft[0], topRight[0], bottomLeft[0], bottomRight[0]), + Math.min(topLeft[1], topRight[1], bottomLeft[1], bottomRight[1]), + Math.max(topLeft[0], topRight[0], bottomLeft[0], bottomRight[0]), + Math.max(topLeft[1], topRight[1], bottomLeft[1], bottomRight[1]), ]; return { bounds, + projectedBounds, tileWidth, tileHeight, overview, diff --git a/src/cog-tileset/cog-tile-2d-traversal.ts b/src/cog-tileset/cog-tile-2d-traversal.ts index 44ea0a8f..ccb32984 100644 --- a/src/cog-tileset/cog-tile-2d-traversal.ts +++ b/src/cog-tileset/cog-tile-2d-traversal.ts @@ -15,15 +15,13 @@ */ import { _GlobeViewport, assert, Viewport } from "@deck.gl/core"; -import { AxisAlignedBoundingBox, CullingVolume, Plane } from "@math.gl/culling"; +import { + CullingVolume, + Plane, + makeOrientedBoundingBoxFromPoints, +} from "@math.gl/culling"; -import type { - Bounds, - COGMetadata, - COGOverview, - COGTileIndex, - ZRange, -} from "./types"; +import type { COGMetadata, COGOverview, COGTileIndex, ZRange } from "./types"; /** * The size of the entire world in deck.gl's common coordinate space. @@ -327,50 +325,44 @@ export class COGTileNode { refPointPositionsProjected.push(projected); } - // Calculate bounding box for debugging - const tileMinX = Math.min(...refPointPositionsImage.map((p) => p[0])); - const tileMaxX = Math.max(...refPointPositionsImage.map((p) => p[0])); - const tileMinY = Math.min(...refPointPositionsImage.map((p) => p[1])); - const tileMaxY = Math.max(...refPointPositionsImage.map((p) => p[1])); - - console.log("Tile bounding box:"); - console.log(tileMinX, tileMinY, tileMaxX, tileMaxY); - - // return makeOrientedBoundingBoxFromPoints(refPointPositions); - - // Web Mercator projection // Convert from Web Mercator meters to deck.gl's common space (world units) // Web Mercator range: [-20037508.34, 20037508.34] meters // deck.gl world space: [0, 512] - const WORLD_SIZE = 512; // deck.gl's world size const WEB_MERCATOR_MAX = 20037508.342789244; // Half Earth circumference - // Offset from [-20M, 20M] to [0, 40M], then normalize to [0, 512] - const worldMinX = - ((webMercatorMinX + WEB_MERCATOR_MAX) / (2 * WEB_MERCATOR_MAX)) * - WORLD_SIZE; - const worldMaxX = - ((webMercatorMaxX + WEB_MERCATOR_MAX) / (2 * WEB_MERCATOR_MAX)) * - WORLD_SIZE; - - // Y is flipped in deck.gl's common space - const worldMinY = - WORLD_SIZE - - ((webMercatorMaxY + WEB_MERCATOR_MAX) / (2 * WEB_MERCATOR_MAX)) * - WORLD_SIZE; - const worldMaxY = - WORLD_SIZE - - ((webMercatorMinY + WEB_MERCATOR_MAX) / (2 * WEB_MERCATOR_MAX)) * - WORLD_SIZE; - - console.log("Tile world X bounds:"); - console.log("Tile world bounds:"); - console.log(worldMinX, worldMinY, worldMaxX, worldMaxY); - - return new AxisAlignedBoundingBox( - [worldMinX, worldMinY, zRange[0]], - [worldMaxX, worldMaxY, zRange[1]], - ); + /** Reference points positions in deck.gl world space */ + const refPointPositionsWorld: number[][] = []; + + for (const [mercX, mercY] of refPointPositionsProjected) { + // X: offset from [-20M, 20M] to [0, 40M], then normalize to [0, 512] + const worldX = + ((mercX + WEB_MERCATOR_MAX) / (2 * WEB_MERCATOR_MAX)) * WORLD_SIZE; + + // Y: same transformation, but flipped (deck.gl Y increases downward) + const worldY = + WORLD_SIZE - + ((mercY + WEB_MERCATOR_MAX) / (2 * WEB_MERCATOR_MAX)) * WORLD_SIZE; + + // Add z-range minimum + refPointPositionsWorld.push([worldX, worldY, zRange[0]]); + } + + // Add top z-range if elevation varies + if (zRange[0] !== zRange[1]) { + for (const [mercX, mercY] of refPointPositionsProjected) { + const worldX = + ((mercX + WEB_MERCATOR_MAX) / (2 * WEB_MERCATOR_MAX)) * WORLD_SIZE; + const worldY = + WORLD_SIZE - + ((mercY + WEB_MERCATOR_MAX) / (2 * WEB_MERCATOR_MAX)) * WORLD_SIZE; + + refPointPositionsWorld.push([worldX, worldY, zRange[1]]); + } + } + + console.log("Tile world bounds (first point):", refPointPositionsWorld[0]); + + return makeOrientedBoundingBoxFromPoints(refPointPositionsWorld); } /** diff --git a/src/model/layer/surface.ts b/src/model/layer/surface.ts index 3fd9cf74..3922c87a 100644 --- a/src/model/layer/surface.ts +++ b/src/model/layer/surface.ts @@ -1,5 +1,6 @@ import { TileLayer, TileLayerProps } from "@deck.gl/geo-layers"; import type { Tileset2DProps } from "@deck.gl/geo-layers/dist/tileset-2d"; +import { PathLayer } from "@deck.gl/layers"; import { SimpleMeshLayer, SimpleMeshLayerProps } from "@deck.gl/mesh-layers"; import type { WidgetModel } from "@jupyter-widgets/base"; import * as arrow from "apache-arrow"; @@ -148,6 +149,7 @@ export class COGTileModel extends BaseLayerModel { } async asyncInit() { + console.log("Loading COG from URL:", this.data); const tiff = await fromUrl(this.data); const metadata = await extractCOGMetadata(tiff); @@ -195,15 +197,57 @@ export class COGTileModel extends BaseLayerModel { } render(): TileLayer[] { + // Capture cogMetadata in closure + const metadata = this.cogMetadata; + const layer = new TileLayer({ ...this.baseLayerProps(), ...this.layerProps(), renderSubLayers: (props) => { - // const [min, max] = props.tile.boundingBox; + const { tile } = props; + console.log("Rendering COG tile with props:"); console.log(props); - return []; + // Get projected bounds from tile data + // getTileMetadata returns data that includes projectedBounds + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const projectedBounds = (tile as any)?.projectedBounds; + + if (!projectedBounds || !metadata) { + return []; + } + + // Project bounds from image CRS to WGS84 + const { topLeft, topRight, bottomLeft, bottomRight } = projectedBounds; + + const topLeftWgs84 = metadata.projectToWgs84.forward(topLeft); + const topRightWgs84 = metadata.projectToWgs84.forward(topRight); + const bottomRightWgs84 = metadata.projectToWgs84.forward(bottomRight); + const bottomLeftWgs84 = metadata.projectToWgs84.forward(bottomLeft); + + // Create a closed path around the tile bounds + const path = [ + topLeftWgs84, + topRightWgs84, + bottomRightWgs84, + bottomLeftWgs84, + topLeftWgs84, // Close the path + ]; + + console.log("Tile bounds path (WGS84):", path); + + return [ + new PathLayer({ + id: `${tile.id}-bounds`, + data: [{ path }], + getPath: (d) => d.path, + getColor: [255, 0, 0, 255], // Red + getWidth: 2, + widthUnits: "pixels", + pickable: false, + }), + ]; }, }); return [layer]; From 35a1f3d691604ad9e083bf10a4bfde4158171486 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Thu, 6 Nov 2025 16:05:53 -0500 Subject: [PATCH 25/25] something renders --- cog-tile-testing.ipynb | 166 ++++++++++++++++++ src/cog-tileset/claude-tileset-2d-improved.ts | 28 ++- src/cog-tileset/cog-tile-2d-traversal.ts | 126 +++++++++---- 3 files changed, 276 insertions(+), 44 deletions(-) create mode 100644 cog-tile-testing.ipynb diff --git a/cog-tile-testing.ipynb b/cog-tile-testing.ipynb new file mode 100644 index 00000000..8fd31fc1 --- /dev/null +++ b/cog-tile-testing.ipynb @@ -0,0 +1,166 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 8, + "id": "66285659-d958-4be6-ae62-b3b6efdad127", + "metadata": {}, + "outputs": [], + "source": [ + "from lonboard import Map\n", + "from lonboard.experimental._surface import COGTileLayer" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "49ea9e5f-ee08-4924-b968-2e18c058c876", + "metadata": {}, + "outputs": [], + "source": [ + "url = \"https://ds-wheels.s3.us-east-1.amazonaws.com/m_4007307_sw_18_060_20220803.tif\"\n", + "layer = COGTileLayer(data=url)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b890fe4a-6291-4dd6-b62a-20581e22404d", + "metadata": {}, + "outputs": [], + "source": [ + "view_state = {'longitude': -73.20972420038129,\n", + " 'latitude': 40.90307721701271,\n", + " 'zoom': 10.98785761041711}" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "21aa3c2f-e8d3-4a92-acf1-2c1af8451de7", + "metadata": {}, + "outputs": [], + "source": [ + "m = Map(layer, view_state=view_state)" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "cf976d1f-4264-44b5-bed6-fbd1ae8c9811", + "metadata": {}, + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "087f29c18787446591cd6ec85ef49a59", + "version_major": 2, + "version_minor": 1 + }, + "text/plain": [ + "Map(basemap=MaplibreBasemap(), controls=(FullscreenControl(), NavigationControl(), ScaleControl()), custom_att…" + ] + }, + "execution_count": 11, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "m" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "5f559dda-d832-40d5-a746-340944ef64e3", + "metadata": {}, + "outputs": [], + "source": [ + "from dataclasses import asdict" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9787bfa0-0f7f-4ee4-a6ff-c674acdb9451", + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "7eb7fc24-ef48-43de-a4dd-7686dfeab320", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'longitude': -73.20972420038129,\n", + " 'latitude': 40.90307721701271,\n", + " 'zoom': 10.98785761041711,\n", + " 'pitch': 0,\n", + " 'bearing': 0,\n", + " 'max_zoom': 20,\n", + " 'min_zoom': 0,\n", + " 'max_pitch': 60,\n", + " 'min_pitch': 0}" + ] + }, + "execution_count": 15, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "asdict(m.view_state)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4f07a3d8-e045-42ba-840e-58ae844d643f", + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5349bab1-6542-41ce-88fc-413e559253f6", + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9f7961e7-8bdd-429c-9bb9-9de33e03405a", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "lonboard", + "language": "python", + "name": "lonboard" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.7" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/src/cog-tileset/claude-tileset-2d-improved.ts b/src/cog-tileset/claude-tileset-2d-improved.ts index 7d439683..fdbcd2e7 100644 --- a/src/cog-tileset/claude-tileset-2d-improved.ts +++ b/src/cog-tileset/claude-tileset-2d-improved.ts @@ -5,7 +5,7 @@ * following the pattern from deck.gl's OSM tile indexing. */ -import { Viewport } from "@deck.gl/core"; +import { Viewport, WebMercatorViewport } from "@deck.gl/core"; import { _Tileset2D as Tileset2D } from "@deck.gl/geo-layers"; import type { Tileset2DProps } from "@deck.gl/geo-layers/dist/tileset-2d"; import type { ZRange } from "@deck.gl/geo-layers/dist/tileset-2d/types"; @@ -252,6 +252,14 @@ async function getProjjson(projectionCode: number | null) { return data; } +const viewport = new WebMercatorViewport({ + height: 500, + width: 845, + latitude: 40.88775942857086, + longitude: -73.20197979318772, + zoom: 11.294596276534985, +}); + /** * COGTileset2D with proper frustum culling */ @@ -277,13 +285,19 @@ export class COGTileset2D extends Tileset2D { modelMatrix?: Matrix4; modelMatrixInverse?: Matrix4; }): COGTileIndex[] { - console.log("Getting tile indices with COGTileset2D"); - console.log(opts); + console.log("Called getTileIndices", opts); const tileIndices = getTileIndices(this.cogMetadata, opts); - console.log("Visible tile indices:"); - console.log(tileIndices); - - return [{ x: 0, y: 0, z: 0 }]; // Temporary override for testing + console.log("Visible tile indices:", tileIndices); + + // return [ + // { x: 0, y: 0, z: 0 }, + // { x: 0, y: 0, z: 1 }, + // { x: 1, y: 1, z: 2 }, + // { x: 1, y: 2, z: 3 }, + // { x: 2, y: 1, z: 3 }, + // { x: 2, y: 2, z: 3 }, + // { x: 3, y: 1, z: 3 }, + // ]; // Temporary override for testing return tileIndices; } diff --git a/src/cog-tileset/cog-tile-2d-traversal.ts b/src/cog-tileset/cog-tile-2d-traversal.ts index ccb32984..7194622a 100644 --- a/src/cog-tileset/cog-tile-2d-traversal.ts +++ b/src/cog-tileset/cog-tile-2d-traversal.ts @@ -14,7 +14,12 @@ * cover the visible area with appropriate detail. */ -import { _GlobeViewport, assert, Viewport } from "@deck.gl/core"; +import { + _GlobeViewport, + assert, + Viewport, + WebMercatorViewport, +} from "@deck.gl/core"; import { CullingVolume, Plane, @@ -196,7 +201,38 @@ export class COGTileNode { // return false; // } - // Check if tile is visible in frustum + console.log("=== FRUSTUM CULLING DEBUG ==="); + console.log(`Tile: ${this.x}, ${this.y}, ${this.z}`); + console.log("Bounding volume center:", boundingVolume.center); + console.log("Bounding volume halfSize:", boundingVolume.halfSize); + console.log("Viewport cameraPosition:", viewport.cameraPosition); + console.log( + "Viewport pitch:", + viewport instanceof WebMercatorViewport ? viewport.pitch : "N/A", + ); + + for (let i = 0; i < cullingVolume.planes.length; i++) { + const plane = cullingVolume.planes[i]; + const result = boundingVolume.intersectPlane(plane); + const planeNames = ["left", "right", "bottom", "top", "near", "far"]; + + // Calculate signed distance from OBB center to plane + const centerDist = + plane.normal.x * boundingVolume.center.x + + plane.normal.y * boundingVolume.center.y + + plane.normal.z * boundingVolume.center.z + + plane.distance; + + console.log( + `Plane ${i} (${planeNames[i]}): normal=[${plane.normal.x.toFixed(3)}, ${plane.normal.y.toFixed(3)}, ${plane.normal.z.toFixed(3)}], ` + + `distance=${plane.distance.toFixed(3)}, centerDist=${centerDist.toFixed(3)}, result=${result} (${result === 1 ? "INSIDE" : result === 0 ? "INTERSECT" : "OUTSIDE"})`, + ); + } + console.log("=== END FRUSTUM DEBUG ==="); + + // Frustum culling + // Test if tile's bounding volume intersects the camera frustum + // Returns: <0 if outside, 0 if intersecting, >0 if fully inside const isInside = cullingVolume.computeVisibility(boundingVolume); console.log( `Tile ${this.x},${this.y},${this.z} frustum check: ${isInside} (${isInside < 0 ? "CULLED" : "VISIBLE"})`, @@ -205,22 +241,26 @@ export class COGTileNode { return false; } - // Avoid loading overlapping tiles + // LOD (Level of Detail) selection + // Only select this tile if no child is visible (prevents overlapping tiles) if (!this.childVisible) { let { z } = this; if (z < maxZ && z >= minZ) { - // Adjust LOD based on distance from camera - // If tile is far from camera, accept coarser resolution (lower z) + // Compute distance-based LOD adjustment + // Tiles farther from camera can use lower zoom levels (larger tiles) + // Distance is normalized by viewport height to be resolution-independent const distance = (boundingVolume.distanceTo(viewport.cameraPosition) * viewport.scale) / viewport.height; + // Increase effective zoom level based on log2(distance) + // e.g., if distance=4, accept tiles 2 levels lower than maxZ z += Math.floor(Math.log2(distance)); } if (z >= maxZ) { - // LOD is acceptable + // This tile's LOD is sufficient for its distance - select it for rendering this.selected = true; return true; } @@ -230,18 +270,21 @@ export class COGTileNode { this.selected = false; this.childVisible = true; - const children = this.children; - // NOTE: this deviates from upstream; we could move to the upstream code if - // we pass in maxZ correctly I think - if (children.length === 0) { - // No children available (at finest resolution), select this tile - this.selected = true; - return true; - } - - for (const child of children) { + for (const child of this.children) { child.update(params); } + + // // NOTE: this deviates from upstream; we could move to the upstream code if + // // we pass in maxZ correctly I think + // if (children.length === 0) { + // // No children available (at finest resolution), select this tile + // this.selected = true; + // return true; + // } + + // for (const child of children) { + // child.update(params); + // } return true; } @@ -291,6 +334,8 @@ export class COGTileNode { // Sample reference points across the tile surface const refPoints = REF_POINTS_9; + console.log("refPoints", refPoints); + /** Reference points positions in image CRS */ const refPointPositionsImage: number[][] = []; @@ -307,6 +352,9 @@ export class COGTileNode { refPointPositionsImage.push([geoX, geoY]); } + console.log("refPointPositionsImage (image CRS):", refPointPositionsImage); + console.log("Geotransform [a,b,c,d,e,f]:", [a, b, c, d, e, f]); + if (project) { assert( false, @@ -323,8 +371,14 @@ export class COGTileNode { // Reproject to Web Mercator (EPSG 3857) const projected = this.cogMetadata.projectTo3857.forward([pX, pY]); refPointPositionsProjected.push(projected); + + // Also log WGS84 for comparison + const wgs84 = this.cogMetadata.projectToWgs84.forward([pX, pY]); + console.log(`Image [${pX.toFixed(2)}, ${pY.toFixed(2)}] -> WGS84 [${wgs84[0].toFixed(6)}, ${wgs84[1].toFixed(6)}] -> WebMerc [${projected[0].toFixed(2)}, ${projected[1].toFixed(2)}]`); } + console.log("refPointPositionsProjected (EPSG:3857):", refPointPositionsProjected); + // Convert from Web Mercator meters to deck.gl's common space (world units) // Web Mercator range: [-20037508.34, 20037508.34] meters // deck.gl world space: [0, 512] @@ -338,11 +392,13 @@ export class COGTileNode { const worldX = ((mercX + WEB_MERCATOR_MAX) / (2 * WEB_MERCATOR_MAX)) * WORLD_SIZE; - // Y: same transformation, but flipped (deck.gl Y increases downward) + // Y: same transformation WITHOUT flip + // Testing hypothesis: Y-flip might be incorrect since geotransform already handles orientation const worldY = - WORLD_SIZE - ((mercY + WEB_MERCATOR_MAX) / (2 * WEB_MERCATOR_MAX)) * WORLD_SIZE; + console.log(`WebMerc [${mercX.toFixed(2)}, ${mercY.toFixed(2)}] -> World [${worldX.toFixed(4)}, ${worldY.toFixed(4)}]`); + // Add z-range minimum refPointPositionsWorld.push([worldX, worldY, zRange[0]]); } @@ -360,9 +416,14 @@ export class COGTileNode { } } - console.log("Tile world bounds (first point):", refPointPositionsWorld[0]); + console.log("refPointPositionsWorld", refPointPositionsWorld); + console.log("zRange used:", zRange); + + const obb = makeOrientedBoundingBoxFromPoints(refPointPositionsWorld); + console.log("Created OBB center:", obb.center); + console.log("Created OBB halfAxes:", obb.halfAxes); - return makeOrientedBoundingBoxFromPoints(refPointPositionsWorld); + return obb; } /** @@ -385,8 +446,7 @@ export function getTileIndices( cogMetadata: COGMetadata, opts: { viewport: Viewport; - maxZ?: number; - // minZ?: number; + maxZ: number; zRange: ZRange | null; }, ): COGTileIndex[] { @@ -414,25 +474,17 @@ export function getTileIndices( const elevationMin = (zRange && zRange[0] * unitsPerMeter) || 0; const elevationMax = (zRange && zRange[1] * unitsPerMeter) || 0; - // // Always load at the current zoom level if pitch is small - // const minZ = - // viewport instanceof WebMercatorViewport && viewport.pitch <= 60 ? maxZ : 0; - - // // Map maxZoom/minZoom to COG overview levels - // // In COG: level 0 = full resolution (finest), higher levels = coarser - // // In deck.gl zoom: higher = finer - // // So we need to invert: maxZoom (finest) → minLevel (level 0) - // const minLevel = 0; // Always allow full resolution - // const maxLevel = Math.min( - // cogMetadata.overviews.length - 1, - // Math.max(0, cogMetadata.overviews.length - 1 - (maxZ || 0)), - // ); + // Optimization: For low-pitch views, only consider tiles at maxZ level + // At low pitch (top-down view), all tiles are roughly the same distance, + // so we don't need the LOD pyramid - just use the finest level + const minZ = + viewport instanceof WebMercatorViewport && viewport.pitch <= 60 ? maxZ : 0; // Start from coarsest overview const coarsestOverview = cogMetadata.overviews[0]; // Create root tiles at coarsest level - // In contrary to OSM tiling, we usually have more than one tile at the + // In contrary to OSM tiling, we might have more than one tile at the // coarsest level (z=0) const roots: COGTileNode[] = []; for (let y = 0; y < coarsestOverview.tilesY; y++) { @@ -447,7 +499,7 @@ export function getTileIndices( project, cullingVolume, elevationBounds: [elevationMin, elevationMax] as ZRange, - minZ: 0, + minZ, maxZ, }; console.log("Traversal params:", traversalParams);