├── 2023-07-lerc-dem-color-ramp.webp ├── package.json ├── index.deck.html ├── README.md ├── index.html └── src ├── quadkey.ts ├── ramp.ts ├── index.ts ├── cogs.ts ├── index.deck.gl.ts └── delatin.ts /2023-07-lerc-dem-color-ramp.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/blacha/lerc-cog-maplibre/HEAD/2023-07-lerc-dem-color-ramp.webp -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dem-map", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "build": "npx esbuild --bundle --outfile=./dist/index.js src/index.ts --platform=browser", 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "keywords": [], 11 | "author": "", 12 | "license": "ISC", 13 | "dependencies": { 14 | "@basemaps/geo": "^6.40.0", 15 | "@basemaps/tiler": "^6.40.0", 16 | "@chunkd/middleware": "^11.0.0", 17 | "@chunkd/source-http": "^11.0.0", 18 | "@cogeotiff/core": "^8.0.0", 19 | "@deck.gl/core": "^8.9.21", 20 | "@deck.gl/geo-layers": "^8.9.21", 21 | "esbuild": "^0.18.11", 22 | "lerc": "^4.0.1", 23 | "maplibre-gl": "^3.2.0" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /index.deck.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | DeckGL example 6 | 7 | 24 | 25 | 26 |
27 | 28 |
29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LERC COGs as Maplibre DEM source 2 | 3 | ![Example render](./2023-07-lerc-dem-color-ramp.webp) 4 | 5 | Grab some elevation data eg https://data.linz.govt.nz/layer/107436-taranaki-lidar-1m-dem-2021/ 6 | 7 | Make VRT 8 | 9 | ```bash 10 | gdalbuildvrt ../BJ29.vrt *.tif 11 | ``` 12 | 13 | Create a 3857 LERC COG 14 | 15 | ```bash 16 | gdal_translate -of COG \ 17 | -co TILING_SCHEME=GoogleMapsCompatible \ 18 | -co NUM_THREADS=ALL_CPUS \ 19 | -co BIGTIFF=NO \ 20 | -co ADD_ALPHA=YES \ 21 | -co BLOCKSIZE=256 \ 22 | -co SPARSE_OK=YES \ 23 | -co compress=lerc -co max_z_error=0.01 \ # 1cm of error 24 | BJ29.vrt BJ29.lerc.cog.tiff 25 | ``` 26 | 27 | bundle everything 28 | ```bash 29 | npm i 30 | npm run bundle 31 | ``` 32 | 33 | Start a local webserver 34 | ```bash 35 | serve . 36 | open http://localhost:3000 37 | ``` 38 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | LERC Terrain 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 |
17 | 18 | 19 |
20 | 21 | 22 | -------------------------------------------------------------------------------- /src/quadkey.ts: -------------------------------------------------------------------------------- 1 | 2 | const CHAR_0 = '0'.charCodeAt(0); 3 | const CHAR_1 = '1'.charCodeAt(0); 4 | const CHAR_2 = '2'.charCodeAt(0); 5 | const CHAR_3 = '3'.charCodeAt(0); 6 | 7 | export const QuadKey = { 8 | /** 9 | * Convert a tile location to a quadkey 10 | * @param tile tile to covert 11 | */ 12 | fromTile(z: number, x: number, y: number): string { 13 | let quadKey = ''; 14 | for (let zI = z; zI > 0; zI--) { 15 | let b = CHAR_0; 16 | const mask = 1 << (zI - 1); 17 | if ((x & mask) !== 0) b++; 18 | if ((y & mask) !== 0) b += 2; 19 | quadKey += String.fromCharCode(b); 20 | } 21 | return quadKey; 22 | }, 23 | 24 | toZxy(qk: string): string { 25 | const tile = this.toTile(qk); 26 | return `${tile.z}-${tile.x}-${tile.y}` 27 | }, 28 | 29 | /** 30 | * Convert a quadkey to a XYZ Tile location 31 | * @param quadKey quadkey to convert 32 | */ 33 | toTile(quadKey: string): { z: number, x: number, y: number } { 34 | let x = 0; 35 | let y = 0; 36 | const z = quadKey.length; 37 | 38 | for (let i = z; i > 0; i--) { 39 | const mask = 1 << (i - 1); 40 | const q = quadKey.charCodeAt(z - i); 41 | if (q === CHAR_1) x |= mask; 42 | if (q === CHAR_2) y |= mask; 43 | if (q === CHAR_3) { 44 | x |= mask; 45 | y |= mask; 46 | } 47 | } 48 | return { x, y, z }; 49 | }, 50 | }; -------------------------------------------------------------------------------- /src/ramp.ts: -------------------------------------------------------------------------------- 1 | 2 | export class ColorRamp { 3 | noData: { v: number, color: [number, number, number, number] }; 4 | ramps: { v: number, color: [number, number, number, number] }[] = [] 5 | constructor(ramp: string, noDataValue: number) { 6 | const ramps = ramp.split('\n') 7 | 8 | for (const ramp of ramps) { 9 | const parts = ramp.trim().split(' ') 10 | if (parts[0] == 'nv') { 11 | this.noData = { v: noDataValue, color: parts.slice(1).map(Number) as [number, number, number, number] } 12 | continue; 13 | } 14 | const numbers = parts.map(Number) 15 | this.ramps.push({ v: numbers[0], color: numbers.slice(1) as [number, number, number, number] }); 16 | } 17 | } 18 | 19 | get(num:number): [number, number, number, number] { 20 | if (num === this.noData.v) return this.noData.color; 21 | 22 | const first = this.ramps[0]; 23 | if (num < first[0]) return first[0].color 24 | 25 | for (let i = 0; i < this.ramps.length - 1; i++) { 26 | const ramp = this.ramps[i]; 27 | const rampNext = this.ramps[i + 1]; 28 | if (num >= rampNext.v) continue; 29 | if (num < ramp.v) continue 30 | if (ramp.v == num) return ramp.color; 31 | 32 | const range = rampNext.v - ramp.v 33 | const offset = num - ramp.v; 34 | const scale = offset / range; 35 | 36 | const r = Math.round((rampNext.color[0] - ramp.color[0]) * scale + ramp.color[0]) 37 | const g = Math.round((rampNext.color[1] - ramp.color[1]) * scale + ramp.color[1]) 38 | const b = Math.round((rampNext.color[2] - ramp.color[2]) * scale + ramp.color[2]) 39 | const a = Math.round((rampNext.color[3] - ramp.color[3]) * scale + ramp.color[3]) 40 | 41 | return [r, g, b, a] 42 | } 43 | return this.ramps[this.ramps.length - 1].color 44 | } 45 | } 46 | 47 | // Stolen from https://github.com/andrewharvey/srtm-stylesheets/blob/master/stylesheets/color-ramps/srtm-Australia-color-ramp.gdaldem.txt 48 | export const ramp = new ColorRamp(`nv 0 0 0 0 49 | -8764 0 0 0 255 50 | -4000 3 45 85 255 51 | -100 0 101 199 255 52 | 0 192 224 255 255 53 | 1 108 220 108 255 54 | 55 50 180 50 255 55 | 390 240 250 150 255 56 | 835 190 185 135 255 57 | 1114 180 128 107 255 58 | 1392 235 220 175 255 59 | 2000 215 200 244 255 60 | 4000 255 0 255 255`, -9999) -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import m from 'maplibre-gl'; 2 | import { lercToImage } from './cogs.js'; 3 | 4 | const cancel = { cancel() { } }; 5 | 6 | m.addProtocol('cog+lerc', (req, cb) => { 7 | if (req.type !== 'image') throw new Error('Invalid request type: ' + req.type) 8 | 9 | lercToImage(req.url).then(buf => { 10 | if (buf) return cb(null, buf); 11 | return cb(new Error('Failed'), null); 12 | }) 13 | return cancel 14 | }) 15 | 16 | document.addEventListener('DOMContentLoaded', async () => { 17 | const main = document.querySelector('#main'); 18 | if (main == null) throw new Error('Failed to find #main') 19 | 20 | const style = { 21 | version: 8, 22 | sources: { 23 | linz: { 24 | type: 'raster', 25 | tiles: ['https://basemaps.linz.govt.nz/v1/tiles/aerial/WebMercatorQuad/{z}/{x}/{y}.webp?api=c01h3e17kjsw5evq8ndjxbda80e'], 26 | tileSize: 256, 27 | }, 28 | raster: { 29 | type: 'raster', 30 | tiles: ['cog+lerc://Taranaki2021#ramp@{z}/{x}/{y}'], 31 | tileSize: 256, 32 | minzoom: 10, 33 | maxzoom: 18, 34 | }, 35 | // TODO why do we need both a hillshade and terrain source 36 | // ref : https://maplibre.org/maplibre-gl-js/docs/examples/3d-terrain/ 37 | hillshadeSource: { 38 | type: 'raster-dem', 39 | tiles: ['cog+lerc://Taranaki2021#mapbox@{z}/{x}/{y}'], 40 | tileSize: 256, 41 | encoding: 'mapbox' 42 | }, 43 | terrainSource: { 44 | type: 'raster-dem', 45 | tiles: ['cog+lerc://Taranaki2021#mapbox@{z}/{x}/{y}'], 46 | tileSize: 256, 47 | encoding: 'mapbox' 48 | }, 49 | }, 50 | layers: [ 51 | { id: 'linz', type: 'raster', source: 'linz' }, 52 | ], 53 | terrain: { 54 | source: 'terrainSource', exaggeration: 1 55 | } 56 | } as any 57 | 58 | 59 | const map = new m.Map({ 60 | container: 'main', 61 | zoom: 10, 62 | center: [174.0416, -39.333], 63 | hash: true, 64 | style, 65 | }); 66 | 67 | map.addControl( 68 | new m.NavigationControl({ 69 | visualizePitch: true, 70 | showZoom: true, 71 | showCompass: true 72 | }) 73 | ); 74 | 75 | map.addControl( 76 | new m.TerrainControl({ 77 | source: 'terrainSource', 78 | exaggeration: 1 79 | }) 80 | ); 81 | 82 | 83 | document.querySelector('#color-ramp')?.addEventListener('click', () => { 84 | const ramp = map.getLayer('ramp') 85 | if (ramp) { 86 | map.removeLayer('ramp') 87 | } else { 88 | map.addLayer({ id: 'ramp', type: 'raster', source: 'raster' }, map.getLayer('hillshade') ? 'hillshade' : undefined) 89 | } 90 | }) 91 | 92 | document.querySelector('#hillshade')?.addEventListener('click', () => { 93 | const shade = map.getLayer('hillshade') 94 | if (shade) { 95 | map.removeLayer('hillshade') 96 | } else { 97 | map.addLayer({ 98 | id: 'hillshade', 99 | type: 'hillshade', 100 | source: 'hillshadeSource', 101 | layout: { visibility: 'visible' }, 102 | paint: { 103 | 'hillshade-shadow-color': '#473B24' 104 | } 105 | }) 106 | } 107 | }) 108 | // map.showTileBoundaries = true 109 | window['map'] = map; 110 | }) 111 | -------------------------------------------------------------------------------- /src/cogs.ts: -------------------------------------------------------------------------------- 1 | import { GoogleTms } from "@basemaps/geo"; 2 | import { CompositionTiff, Tiler } from "@basemaps/tiler"; 3 | import { SourceCache, SourceChunk } from "@chunkd/middleware"; 4 | import { SourceView } from '@chunkd/source'; 5 | import { SourceHttp } from "@chunkd/source-http"; 6 | import { CogTiff } from "@cogeotiff/core"; 7 | import { QuadKey } from "./quadkey.js"; 8 | import { ramp } from "./ramp.js"; 9 | 10 | export const Cogs = { 11 | 'Taranaki2021': [ 12 | [11, 2012, 1267], 13 | [11, 2013, 1266], 14 | [11, 2013, 1267], 15 | [11, 2013, 1268], 16 | [11, 2014, 1265], 17 | [11, 2014, 1266], 18 | [11, 2014, 1267], 19 | [11, 2014, 1268], 20 | [11, 2014, 1269], 21 | [11, 2015, 1265], 22 | [11, 2015, 1266], 23 | [11, 2015, 1267], 24 | [11, 2015, 1268], 25 | [11, 2015, 1269], 26 | [11, 2016, 1264], 27 | [11, 2016, 1265], 28 | [11, 2016, 1266], 29 | [11, 2016, 1267], 30 | [11, 2016, 1268], 31 | [11, 2016, 1269], 32 | [11, 2016, 1270], 33 | [11, 2017, 1263], 34 | [11, 2017, 1264], 35 | [11, 2017, 1265], 36 | [11, 2017, 1266], 37 | [11, 2017, 1267], 38 | [11, 2017, 1268], 39 | [11, 2017, 1269], 40 | [11, 2017, 1270], 41 | [11, 2017, 1271], 42 | [11, 2018, 1263], 43 | [11, 2018, 1264], 44 | [11, 2018, 1265], 45 | [11, 2018, 1266], 46 | [11, 2018, 1267], 47 | [11, 2018, 1268], 48 | [11, 2018, 1269], 49 | [11, 2018, 1270], 50 | [11, 2018, 1271], 51 | [11, 2019, 1265], 52 | [11, 2019, 1266], 53 | [11, 2019, 1269], 54 | [12, 4025, 2533], 55 | [12, 4025, 2536], 56 | [12, 4027, 2538], 57 | [12, 4038, 2537], 58 | [12, 4038, 2540], 59 | [13, 8051, 5065], 60 | [13, 8051, 5074], 61 | [13, 8053, 5076], 62 | [13, 8054, 5063], 63 | [13, 8055, 5062], 64 | [13, 8055, 5063], 65 | [13, 8062, 5080], 66 | [13, 8063, 5080], 67 | [13, 8063, 5081], 68 | [13, 8066, 5084], 69 | [13, 8067, 5084], 70 | [13, 8067, 5085], 71 | [13, 8076, 5068], 72 | [13, 8076, 5082], 73 | [13, 8076, 5083], 74 | [13, 8078, 5080], 75 | ].map(f => QuadKey.fromTile(f[0], f[1], f[2])) 76 | } 77 | const cache = new SourceCache({ size: 64 * 1024 * 1024}) 78 | window['sourceCache'] = cache; 79 | 80 | const chunk = new SourceChunk( {size: 64 * 1024 }) 81 | 82 | function createTiff(path) { 83 | const source = new SourceView(new SourceHttp(path), [chunk, cache]); 84 | (source as any).uri = source.url.href; 85 | return CogTiff.create(source) 86 | } 87 | 88 | const tiffs = new Map>(); 89 | declare const Lerc: any; // FIXME typing 90 | 91 | const emptyBuffer = (type) => { 92 | const raw = new Uint8ClampedArray(256 * 256 * 4) 93 | if (type === 'mapbox') { 94 | for (let i = 0; i < 256 * 256; i++) { 95 | const offset = i * 4; 96 | raw[offset + 3] = 255 97 | 98 | /** mapbox */ 99 | const base = -10_000; 100 | const interval = 0.1; 101 | 102 | const v = (0 - base) / interval 103 | raw[offset + 0] = Math.floor(v / 256 / 256) % 256 104 | raw[offset + 1] = Math.floor(v / 256) % 256 105 | raw[offset + 2] = v % 256 106 | } 107 | return createImageBitmap(new ImageData(raw, 256, 256));; 108 | } 109 | 110 | return createImageBitmap(new ImageData(raw, 256, 256)); 111 | } 112 | 113 | const googleTiler = new Tiler(GoogleTms); 114 | 115 | export async function lercToBuffer(url: string): Promise<{ buffer: Float32Array, width: number, height: number } | null> { 116 | const urlParts = url.split('@'); 117 | const cogParts = urlParts[0].split('#'); 118 | const cogName = cogParts[0].slice('cog+lerc:'.length + 2) 119 | let method = 'mapbox' 120 | if (cogParts[1]) method = cogParts[1] 121 | 122 | const path = urlParts[urlParts.length - 1].split('/') 123 | const z = Number(path[0]); 124 | const x = Number(path[1]); 125 | const y = Number(path[2]); 126 | 127 | const cogs = Cogs[cogName] 128 | if (cogs == null) return null; 129 | const targetTile = QuadKey.fromTile(z, x, y); 130 | 131 | for (const cogQk of cogs) { 132 | if (!targetTile.startsWith(cogQk)) continue; 133 | 134 | const cog = tiffs.get(cogQk) ?? createTiff(`${cogName}/${QuadKey.toZxy(cogQk)}.tiff`) 135 | tiffs.set(cogQk, cog); 136 | 137 | return cog.then(async (tiff) => { 138 | await Lerc.load() 139 | 140 | const tileId = `${z}-${x}-${y}.lerc` 141 | const result = await googleTiler.tile([tiff], x, y, z) 142 | 143 | if (result.length !== 1) { 144 | console.log('non1 result', tileId); 145 | return null 146 | } 147 | const comp = result[0] as CompositionTiff; 148 | 149 | const tile = await tiff.images[comp.source.imageId].getTile(comp.source.x, comp.source.y); 150 | if (tile == null) { 151 | console.log('empty tile', tileId) 152 | return null 153 | } 154 | 155 | 156 | const decoded = Lerc.decode(tile.bytes) 157 | 158 | return { buffer: decoded.pixels[0], width: decoded.width, height: decoded.height } 159 | }) 160 | } 161 | return null; 162 | } 163 | 164 | export async function lercToImage(url: string): Promise { 165 | const urlParts = url.split('@'); 166 | const cogParts = urlParts[0].split('#'); 167 | const cogName = cogParts[0].slice('cog+lerc:'.length + 2) 168 | let method = 'mapbox' 169 | if (cogParts[1]) method = cogParts[1] 170 | 171 | const path = urlParts[urlParts.length - 1].split('/') 172 | const z = Number(path[0]); 173 | const x = Number(path[1]); 174 | const y = Number(path[2]); 175 | 176 | const cogs = Cogs[cogName] 177 | if (cogs == null) return null; 178 | 179 | const tileId = `${z}-${x}-${y}.lerc` 180 | 181 | const ret = await lercToBuffer(url); 182 | if (ret == null) return emptyBuffer(method); 183 | console.time('create:elevation:' + method + ':' + tileId) 184 | 185 | // Convert the DEM into a RGBA picture 186 | const raw = new Uint8ClampedArray(ret.width * ret.height * 4); 187 | const buf = ret.buffer; 188 | 189 | for (let i = 0; i < buf.length; i++) { 190 | let px = buf[i] 191 | 192 | if (method === 'ramp') { 193 | const offset = i * 4; 194 | 195 | const color = ramp.get(px); 196 | raw[offset + 0] = color[0] 197 | raw[offset + 1] = color[1] 198 | raw[offset + 2] = color[2] 199 | raw[offset + 3] = color[3] 200 | continue; 201 | } 202 | 203 | // COG's NoData is -9999, TODO extract this from the LERC metadata 204 | if (px === -9999 || px == 0) px = 0; // NO_DATA ignore 205 | const offset = i * 4; 206 | // Set alpha to full! 207 | raw[offset + 3] = 255 208 | 209 | /** mapbox */ 210 | const base = -10_000; 211 | const interval = 0.1; 212 | 213 | const v = (px - base) / interval 214 | raw[offset + 0] = Math.floor(v / 256 / 256) % 256 215 | raw[offset + 1] = Math.floor(v / 256) % 256 216 | raw[offset + 2] = v % 256 217 | 218 | /** terrarium */ 219 | // const v = px + 32768; 220 | // raw[offset] = (Math.floor(v / 256)); 221 | // raw[offset + 1] = (Math.floor(v % 256)); 222 | // raw[offset + 2] = (Math.floor((v - Math.floor(v)) * 256)); 223 | } 224 | 225 | console.timeEnd('create:elevation:' + method + ':' + tileId) 226 | return await createImageBitmap(new ImageData(raw, ret.width, ret.height)); 227 | } -------------------------------------------------------------------------------- /src/index.deck.gl.ts: -------------------------------------------------------------------------------- 1 | import { Deck } from '@deck.gl/core/typed'; 2 | import { MapView } from '@deck.gl/core'; 3 | import { TerrainLayer, TileLayer } from '@deck.gl/geo-layers/typed'; 4 | import { BitmapLayer, PathLayer } from '@deck.gl/layers'; 5 | import { GeoJsonLayer, ArcLayer } from '@deck.gl/layers'; 6 | import * as terrain from '@loaders.gl/terrain' 7 | import { GoogleTms } from '@basemaps/geo'; 8 | import { Tiler } from '@basemaps/tiler'; 9 | import { lercToBuffer, lercToImage } from './cogs.js'; 10 | import Martini from '@mapbox/martini' 11 | import Delatin from './delatin.js' 12 | export type TypedArray = 13 | | Int8Array 14 | | Uint8Array 15 | | Int16Array 16 | | Uint16Array 17 | | Int32Array 18 | | Uint32Array 19 | | Uint8ClampedArray 20 | | Float32Array 21 | | Float64Array; 22 | const martini = new Martini(257) 23 | type BoundingBox = [[number, number, number], [number, number, number]]; 24 | export type MeshAttribute = { 25 | value: TypedArray; 26 | size: number; 27 | byteOffset?: number; 28 | byteStride?: number; 29 | normalized?: boolean; 30 | } 31 | export type MeshAttributes = Record; 32 | 33 | ; 34 | /** 35 | * Get the (axis aligned) bounding box of a mesh 36 | * @param attributes 37 | * @returns array of two vectors representing the axis aligned bounding box 38 | */ 39 | // eslint-disable-next-line complexity 40 | export function getMeshBoundingBox(attributes: MeshAttributes): BoundingBox { 41 | let minX = Infinity; 42 | let minY = Infinity; 43 | let minZ = Infinity; 44 | let maxX = -Infinity; 45 | let maxY = -Infinity; 46 | let maxZ = -Infinity; 47 | 48 | const positions = attributes.POSITION ? attributes.POSITION.value : []; 49 | const len = positions && positions.length; 50 | 51 | for (let i = 0; i < len; i += 3) { 52 | const x = positions[i]; 53 | const y = positions[i + 1]; 54 | const z = positions[i + 2]; 55 | 56 | minX = x < minX ? x : minX; 57 | minY = y < minY ? y : minY; 58 | minZ = z < minZ ? z : minZ; 59 | 60 | maxX = x > maxX ? x : maxX; 61 | maxY = y > maxY ? y : maxY; 62 | maxZ = z > maxZ ? z : maxZ; 63 | } 64 | return [ 65 | [minX, minY, minZ], 66 | [maxX, maxY, maxZ] 67 | ]; 68 | } 69 | 70 | const COUNTRIES = 71 | 'https://d2ad6b4ur7yvpq.cloudfront.net/naturalearth-3.3.0/ne_50m_admin_0_scale_rank.geojson'; //eslint-disable-line 72 | 73 | const googleTiler = new Tiler(GoogleTms); 74 | 75 | function getMeshAttributes( 76 | vertices, 77 | terrain: Float32Array , 78 | width: number, 79 | height: number, 80 | bounds?: number[] 81 | ) { 82 | const gridSize = width ; 83 | const numOfVerticies = vertices.length / 2; 84 | // vec3. x, y in pixels, z in meters 85 | const positions = new Float32Array(numOfVerticies * 3); 86 | // vec2. 1 to 1 relationship with position. represents the uv on the texture image. 0,0 to 1,1. 87 | const texCoords = new Float32Array(numOfVerticies * 2); 88 | 89 | const [minX, minY, maxX, maxY] = bounds || [0, 0, width, height]; 90 | const xScale = (maxX - minX) / width; 91 | const yScale = (maxY - minY) / height; 92 | 93 | for (let i = 0; i < numOfVerticies; i++) { 94 | const x = vertices[i * 2]; 95 | const y = vertices[i * 2 + 1]; 96 | const pixelIdx = y * gridSize + x; 97 | 98 | positions[3 * i + 0] = x * xScale + minX; 99 | positions[3 * i + 1] = -y * yScale + maxY; 100 | positions[3 * i + 2] = terrain[pixelIdx]; 101 | 102 | texCoords[2 * i + 0] = x / width; 103 | texCoords[2 * i + 1] = y / height; 104 | } 105 | 106 | return { 107 | POSITION: {value: positions, size: 3}, 108 | TEXCOORD_0: {value: texCoords, size: 2}, 109 | // NORMAL: {},// - optional, but creates the high poly look with lighting 110 | }; 111 | } 112 | 113 | 114 | const tileLayer = new TileLayer({ 115 | // https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames#Tile_servers 116 | data: 'https://Taranaki2021#ramp@{z}/{x}/{y}', 117 | 118 | minZoom: 0, 119 | maxZoom: 22, 120 | tileSize: 256, 121 | 122 | async getTileData(props) { 123 | // console.log(props) 124 | 125 | return await lercToImage(props.url?.replace('https', 'cog+lerc')) 126 | // const ret = await fetch(props.url!) 127 | // if (ret.ok) return ret.arrayBuffer(); 128 | // return null; 129 | }, 130 | 131 | renderSubLayers: props => { 132 | // console.log(props) 133 | const { 134 | bbox: {west, south, east, north} 135 | } = props.tile; 136 | 137 | return new BitmapLayer(props, { 138 | data: null, 139 | image: props.data, 140 | bounds: [west, south, east, north] 141 | }); 142 | } 143 | }); 144 | 145 | export function renderToDom() { 146 | const app = document.querySelector('#app'); 147 | const layer = new TerrainLayer({ 148 | id: 'terrain', 149 | 150 | // getTileData(props) { 151 | // console.log('getTile', props) 152 | // }, 153 | 154 | async fetch(url, context) { 155 | if (url.startsWith('https')) return fetch(url); 156 | 157 | const buf = await lercToBuffer(url); 158 | if (buf == null) { 159 | console.log('Nothing'); 160 | throw Error('Missing') 161 | } 162 | const width = buf.width; 163 | const height = buf.height; 164 | 165 | // const tin = new Delatin(buf.buffer, width + 1, height+ 1); 166 | // tin.run(10); 167 | // // @ts-expect-error 168 | // const {coords, triangles} = tin; 169 | // const vertices = coords; 170 | const terrain = new Float32Array((buf.width + 1) * (buf.height + 1)); 171 | for (let i = 0, y = 0; y < height; y++) { 172 | for (let x = 0; x < width; x++, i++) { 173 | // const val = buf.buffer[i]; 174 | terrain[i + y] = buf.buffer[i]; 175 | 176 | } 177 | } 178 | for (let i = (buf.width + 1) * buf.width, x = 0; x < buf.width; x++, i++) { 179 | terrain[i] = terrain[i - buf.width - 1]; 180 | } 181 | // backfill right border 182 | for (let i = buf.height, y = 0; y < buf.height + 1; y++, i += buf.height + 1) { 183 | terrain[i] = terrain[i - 1]; 184 | } 185 | 186 | const tile = martini.createTile(terrain); 187 | const {vertices, triangles} = tile.getMesh(); 188 | 189 | let attributes = getMeshAttributes(vertices, buf.buffer, width, height); 190 | const boundingBox = getMeshBoundingBox(attributes); 191 | console.log(url, width, height) 192 | 193 | // console.log(url, buf.buffer, attributes) 194 | return { 195 | // Data return by this loader implementation 196 | loaderData: { 197 | header: {} 198 | }, 199 | header: { 200 | vertexCount: triangles.length, 201 | boundingBox 202 | }, 203 | mode: 4, // TRIANGLES 204 | indices: {value: Uint32Array.from(triangles), size: 1}, 205 | attributes 206 | }; 207 | }, 208 | minZoom: 0, 209 | maxZoom: 23, 210 | strategy: 'no-overlap', 211 | elevationDecoder: { 212 | rScaler: 6553.6, 213 | gScaler: 25.6, 214 | bScaler: 0.1, 215 | offset: -10000 216 | }, 217 | elevationData: 'cog+lerc://Taranaki2021#@{z}/{x}/{y}', 218 | texture: 'https://basemaps.linz.govt.nz/v1/tiles/aerial/WebMercatorQuad/{z}/{x}/{y}.webp?api=c01h3e17kjsw5evq8ndjxbda80e', 219 | wireframe : false, 220 | color: [255, 255, 255] 221 | }); 222 | 223 | const deck = new Deck({ 224 | canvas: 'deck-canvas', 225 | initialViewState: { 226 | latitude: -39.333, 227 | longitude: 174.0416, 228 | zoom: 11 229 | }, 230 | controller: true, 231 | layers: [ 232 | layer, 233 | // tileLayer, 234 | new GeoJsonLayer({ 235 | id: 'base-map', 236 | data: COUNTRIES, 237 | // Styles 238 | stroked: true, 239 | filled: true, 240 | lineWidthMinPixels: 2, 241 | opacity: 0.4, 242 | getLineColor: [60, 60, 60], 243 | getFillColor: [200, 200, 200] 244 | }), 245 | ] 246 | }) 247 | console.log(deck) 248 | } 249 | 250 | document.addEventListener('DOMContentLoaded', renderToDom); -------------------------------------------------------------------------------- /src/delatin.ts: -------------------------------------------------------------------------------- 1 | // ISC License 2 | 3 | // Copyright(c) 2019, Michael Fogleman, Vladimir Agafonkin 4 | 5 | // Permission to use, copy, modify, and / or distribute this software for any purpose 6 | // with or without fee is hereby granted, provided that the above copyright notice 7 | // and this permission notice appear in all copies. 8 | 9 | // THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 10 | // REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND 11 | // FITNESS.IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 12 | // INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS 13 | // OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER 14 | // TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF 15 | // THIS SOFTWARE. 16 | 17 | // @ts-nocheck 18 | 19 | /* eslint-disable complexity, max-params, max-statements, max-depth, no-constant-condition */ 20 | export default class Delatin { 21 | constructor(data, width, height = width) { 22 | this.data = data; // height data 23 | this.width = width; 24 | this.height = height; 25 | 26 | this.coords = []; // vertex coordinates (x, y) 27 | this.triangles = []; // mesh triangle indices 28 | 29 | // additional triangle data 30 | this._halfedges = []; 31 | this._candidates = []; 32 | this._queueIndices = []; 33 | 34 | this._queue = []; // queue of added triangles 35 | this._errors = []; 36 | this._rms = []; 37 | this._pending = []; // triangles pending addition to queue 38 | this._pendingLen = 0; 39 | 40 | this._rmsSum = 0; 41 | 42 | const x1 = width - 1; 43 | const y1 = height - 1; 44 | const p0 = this._addPoint(0, 0); 45 | const p1 = this._addPoint(x1, 0); 46 | const p2 = this._addPoint(0, y1); 47 | const p3 = this._addPoint(x1, y1); 48 | 49 | // add initial two triangles 50 | const t0 = this._addTriangle(p3, p0, p2, -1, -1, -1); 51 | this._addTriangle(p0, p3, p1, t0, -1, -1); 52 | this._flush(); 53 | } 54 | 55 | // refine the mesh until its maximum error gets below the given one 56 | run(maxError = 1) { 57 | while (this.getMaxError() > maxError) { 58 | this.refine(); 59 | } 60 | } 61 | 62 | // refine the mesh with a single point 63 | refine() { 64 | this._step(); 65 | this._flush(); 66 | } 67 | 68 | // max error of the current mesh 69 | getMaxError() { 70 | return this._errors[0]; 71 | } 72 | 73 | // root-mean-square deviation of the current mesh 74 | getRMSD() { 75 | return this._rmsSum > 0 ? Math.sqrt(this._rmsSum / (this.width * this.height)) : 0; 76 | } 77 | 78 | // height value at a given position 79 | heightAt(x, y) { 80 | return this.data[this.width * y + x]; 81 | } 82 | 83 | // rasterize and queue all triangles that got added or updated in _step 84 | _flush() { 85 | const coords = this.coords; 86 | for (let i = 0; i < this._pendingLen; i++) { 87 | const t = this._pending[i]; 88 | // rasterize triangle to find maximum pixel error 89 | const a = 2 * this.triangles[t * 3 + 0]; 90 | const b = 2 * this.triangles[t * 3 + 1]; 91 | const c = 2 * this.triangles[t * 3 + 2]; 92 | this._findCandidate( 93 | coords[a], 94 | coords[a + 1], 95 | coords[b], 96 | coords[b + 1], 97 | coords[c], 98 | coords[c + 1], 99 | t 100 | ); 101 | } 102 | this._pendingLen = 0; 103 | } 104 | 105 | // rasterize a triangle, find its max error, and queue it for processing 106 | _findCandidate(p0x, p0y, p1x, p1y, p2x, p2y, t) { 107 | // triangle bounding box 108 | const minX = Math.min(p0x, p1x, p2x); 109 | const minY = Math.min(p0y, p1y, p2y); 110 | const maxX = Math.max(p0x, p1x, p2x); 111 | const maxY = Math.max(p0y, p1y, p2y); 112 | 113 | // forward differencing variables 114 | let w00 = orient(p1x, p1y, p2x, p2y, minX, minY); 115 | let w01 = orient(p2x, p2y, p0x, p0y, minX, minY); 116 | let w02 = orient(p0x, p0y, p1x, p1y, minX, minY); 117 | const a01 = p1y - p0y; 118 | const b01 = p0x - p1x; 119 | const a12 = p2y - p1y; 120 | const b12 = p1x - p2x; 121 | const a20 = p0y - p2y; 122 | const b20 = p2x - p0x; 123 | 124 | // pre-multiplied z values at vertices 125 | const a = orient(p0x, p0y, p1x, p1y, p2x, p2y); 126 | const z0 = this.heightAt(p0x, p0y) / a; 127 | const z1 = this.heightAt(p1x, p1y) / a; 128 | const z2 = this.heightAt(p2x, p2y) / a; 129 | 130 | // iterate over pixels in bounding box 131 | let maxError = 0; 132 | let mx = 0; 133 | let my = 0; 134 | let rms = 0; 135 | for (let y = minY; y <= maxY; y++) { 136 | // compute starting offset 137 | let dx = 0; 138 | if (w00 < 0 && a12 !== 0) { 139 | dx = Math.max(dx, Math.floor(-w00 / a12)); 140 | } 141 | if (w01 < 0 && a20 !== 0) { 142 | dx = Math.max(dx, Math.floor(-w01 / a20)); 143 | } 144 | if (w02 < 0 && a01 !== 0) { 145 | dx = Math.max(dx, Math.floor(-w02 / a01)); 146 | } 147 | 148 | let w0 = w00 + a12 * dx; 149 | let w1 = w01 + a20 * dx; 150 | let w2 = w02 + a01 * dx; 151 | 152 | let wasInside = false; 153 | 154 | for (let x = minX + dx; x <= maxX; x++) { 155 | // check if inside triangle 156 | if (w0 >= 0 && w1 >= 0 && w2 >= 0) { 157 | wasInside = true; 158 | 159 | // compute z using barycentric coordinates 160 | const z = z0 * w0 + z1 * w1 + z2 * w2; 161 | const dz = Math.abs(z - this.heightAt(x, y)); 162 | rms += dz * dz; 163 | if (dz > maxError) { 164 | maxError = dz; 165 | mx = x; 166 | my = y; 167 | } 168 | } else if (wasInside) { 169 | break; 170 | } 171 | 172 | w0 += a12; 173 | w1 += a20; 174 | w2 += a01; 175 | } 176 | 177 | w00 += b12; 178 | w01 += b20; 179 | w02 += b01; 180 | } 181 | 182 | if ((mx === p0x && my === p0y) || (mx === p1x && my === p1y) || (mx === p2x && my === p2y)) { 183 | maxError = 0; 184 | } 185 | 186 | // update triangle metadata 187 | this._candidates[2 * t] = mx; 188 | this._candidates[2 * t + 1] = my; 189 | this._rms[t] = rms; 190 | 191 | // add triangle to priority queue 192 | this._queuePush(t, maxError, rms); 193 | } 194 | 195 | // process the next triangle in the queue, splitting it with a new point 196 | _step() { 197 | // pop triangle with highest error from priority queue 198 | const t = this._queuePop(); 199 | 200 | const e0 = t * 3 + 0; 201 | const e1 = t * 3 + 1; 202 | const e2 = t * 3 + 2; 203 | 204 | const p0 = this.triangles[e0]; 205 | const p1 = this.triangles[e1]; 206 | const p2 = this.triangles[e2]; 207 | 208 | const ax = this.coords[2 * p0]; 209 | const ay = this.coords[2 * p0 + 1]; 210 | const bx = this.coords[2 * p1]; 211 | const by = this.coords[2 * p1 + 1]; 212 | const cx = this.coords[2 * p2]; 213 | const cy = this.coords[2 * p2 + 1]; 214 | const px = this._candidates[2 * t]; 215 | const py = this._candidates[2 * t + 1]; 216 | 217 | const pn = this._addPoint(px, py); 218 | 219 | if (orient(ax, ay, bx, by, px, py) === 0) { 220 | this._handleCollinear(pn, e0); 221 | } else if (orient(bx, by, cx, cy, px, py) === 0) { 222 | this._handleCollinear(pn, e1); 223 | } else if (orient(cx, cy, ax, ay, px, py) === 0) { 224 | this._handleCollinear(pn, e2); 225 | } else { 226 | const h0 = this._halfedges[e0]; 227 | const h1 = this._halfedges[e1]; 228 | const h2 = this._halfedges[e2]; 229 | 230 | const t0 = this._addTriangle(p0, p1, pn, h0, -1, -1, e0); 231 | const t1 = this._addTriangle(p1, p2, pn, h1, -1, t0 + 1); 232 | const t2 = this._addTriangle(p2, p0, pn, h2, t0 + 2, t1 + 1); 233 | 234 | this._legalize(t0); 235 | this._legalize(t1); 236 | this._legalize(t2); 237 | } 238 | } 239 | 240 | // add coordinates for a new vertex 241 | _addPoint(x, y) { 242 | const i = this.coords.length >> 1; 243 | this.coords.push(x, y); 244 | return i; 245 | } 246 | 247 | // add or update a triangle in the mesh 248 | _addTriangle(a, b, c, ab, bc, ca, e = this.triangles.length) { 249 | const t = e / 3; // new triangle index 250 | 251 | // add triangle vertices 252 | this.triangles[e + 0] = a; 253 | this.triangles[e + 1] = b; 254 | this.triangles[e + 2] = c; 255 | 256 | // add triangle halfedges 257 | this._halfedges[e + 0] = ab; 258 | this._halfedges[e + 1] = bc; 259 | this._halfedges[e + 2] = ca; 260 | 261 | // link neighboring halfedges 262 | if (ab >= 0) { 263 | this._halfedges[ab] = e + 0; 264 | } 265 | if (bc >= 0) { 266 | this._halfedges[bc] = e + 1; 267 | } 268 | if (ca >= 0) { 269 | this._halfedges[ca] = e + 2; 270 | } 271 | 272 | // init triangle metadata 273 | this._candidates[2 * t + 0] = 0; 274 | this._candidates[2 * t + 1] = 0; 275 | this._queueIndices[t] = -1; 276 | this._rms[t] = 0; 277 | 278 | // add triangle to pending queue for later rasterization 279 | this._pending[this._pendingLen++] = t; 280 | 281 | // return first halfedge index 282 | return e; 283 | } 284 | 285 | _legalize(a) { 286 | // if the pair of triangles doesn't satisfy the Delaunay condition 287 | // (p1 is inside the circumcircle of [p0, pl, pr]), flip them, 288 | // then do the same check/flip recursively for the new pair of triangles 289 | // 290 | // pl pl 291 | // /||\ / \ 292 | // al/ || \bl al/ \a 293 | // / || \ / \ 294 | // / a||b \ flip /___ar___\ 295 | // p0\ || /p1 => p0\---bl---/p1 296 | // \ || / \ / 297 | // ar\ || /br b\ /br 298 | // \||/ \ / 299 | // pr pr 300 | 301 | const b = this._halfedges[a]; 302 | 303 | if (b < 0) { 304 | return; 305 | } 306 | 307 | const a0 = a - (a % 3); 308 | const b0 = b - (b % 3); 309 | const al = a0 + ((a + 1) % 3); 310 | const ar = a0 + ((a + 2) % 3); 311 | const bl = b0 + ((b + 2) % 3); 312 | const br = b0 + ((b + 1) % 3); 313 | const p0 = this.triangles[ar]; 314 | const pr = this.triangles[a]; 315 | const pl = this.triangles[al]; 316 | const p1 = this.triangles[bl]; 317 | const coords = this.coords; 318 | 319 | if ( 320 | !inCircle( 321 | coords[2 * p0], 322 | coords[2 * p0 + 1], 323 | coords[2 * pr], 324 | coords[2 * pr + 1], 325 | coords[2 * pl], 326 | coords[2 * pl + 1], 327 | coords[2 * p1], 328 | coords[2 * p1 + 1] 329 | ) 330 | ) { 331 | return; 332 | } 333 | 334 | const hal = this._halfedges[al]; 335 | const har = this._halfedges[ar]; 336 | const hbl = this._halfedges[bl]; 337 | const hbr = this._halfedges[br]; 338 | 339 | this._queueRemove(a0 / 3); 340 | this._queueRemove(b0 / 3); 341 | 342 | const t0 = this._addTriangle(p0, p1, pl, -1, hbl, hal, a0); 343 | const t1 = this._addTriangle(p1, p0, pr, t0, har, hbr, b0); 344 | 345 | this._legalize(t0 + 1); 346 | this._legalize(t1 + 2); 347 | } 348 | 349 | // handle a case where new vertex is on the edge of a triangle 350 | _handleCollinear(pn, a) { 351 | const a0 = a - (a % 3); 352 | const al = a0 + ((a + 1) % 3); 353 | const ar = a0 + ((a + 2) % 3); 354 | const p0 = this.triangles[ar]; 355 | const pr = this.triangles[a]; 356 | const pl = this.triangles[al]; 357 | const hal = this._halfedges[al]; 358 | const har = this._halfedges[ar]; 359 | 360 | const b = this._halfedges[a]; 361 | 362 | if (b < 0) { 363 | const t0 = this._addTriangle(pn, p0, pr, -1, har, -1, a0); 364 | const t1 = this._addTriangle(p0, pn, pl, t0, -1, hal); 365 | this._legalize(t0 + 1); 366 | this._legalize(t1 + 2); 367 | return; 368 | } 369 | 370 | const b0 = b - (b % 3); 371 | const bl = b0 + ((b + 2) % 3); 372 | const br = b0 + ((b + 1) % 3); 373 | const p1 = this.triangles[bl]; 374 | const hbl = this._halfedges[bl]; 375 | const hbr = this._halfedges[br]; 376 | 377 | this._queueRemove(b0 / 3); 378 | 379 | const t0 = this._addTriangle(p0, pr, pn, har, -1, -1, a0); 380 | const t1 = this._addTriangle(pr, p1, pn, hbr, -1, t0 + 1, b0); 381 | const t2 = this._addTriangle(p1, pl, pn, hbl, -1, t1 + 1); 382 | const t3 = this._addTriangle(pl, p0, pn, hal, t0 + 2, t2 + 1); 383 | 384 | this._legalize(t0); 385 | this._legalize(t1); 386 | this._legalize(t2); 387 | this._legalize(t3); 388 | } 389 | 390 | // priority queue methods 391 | 392 | _queuePush(t, error, rms) { 393 | const i = this._queue.length; 394 | this._queueIndices[t] = i; 395 | this._queue.push(t); 396 | this._errors.push(error); 397 | this._rmsSum += rms; 398 | this._queueUp(i); 399 | } 400 | 401 | _queuePop() { 402 | const n = this._queue.length - 1; 403 | this._queueSwap(0, n); 404 | this._queueDown(0, n); 405 | return this._queuePopBack(); 406 | } 407 | 408 | _queuePopBack() { 409 | const t = this._queue.pop(); 410 | this._errors.pop(); 411 | this._rmsSum -= this._rms[t]; 412 | this._queueIndices[t] = -1; 413 | return t; 414 | } 415 | 416 | _queueRemove(t) { 417 | const i = this._queueIndices[t]; 418 | if (i < 0) { 419 | const it = this._pending.indexOf(t); 420 | if (it !== -1) { 421 | this._pending[it] = this._pending[--this._pendingLen]; 422 | } else { 423 | throw new Error('Broken triangulation (something went wrong).'); 424 | } 425 | return; 426 | } 427 | const n = this._queue.length - 1; 428 | if (n !== i) { 429 | this._queueSwap(i, n); 430 | if (!this._queueDown(i, n)) { 431 | this._queueUp(i); 432 | } 433 | } 434 | this._queuePopBack(); 435 | } 436 | 437 | _queueLess(i, j) { 438 | return this._errors[i] > this._errors[j]; 439 | } 440 | 441 | _queueSwap(i, j) { 442 | const pi = this._queue[i]; 443 | const pj = this._queue[j]; 444 | this._queue[i] = pj; 445 | this._queue[j] = pi; 446 | this._queueIndices[pi] = j; 447 | this._queueIndices[pj] = i; 448 | const e = this._errors[i]; 449 | this._errors[i] = this._errors[j]; 450 | this._errors[j] = e; 451 | } 452 | 453 | _queueUp(j0) { 454 | let j = j0; 455 | while (true) { 456 | const i = (j - 1) >> 1; 457 | if (i === j || !this._queueLess(j, i)) { 458 | break; 459 | } 460 | this._queueSwap(i, j); 461 | j = i; 462 | } 463 | } 464 | 465 | _queueDown(i0, n) { 466 | let i = i0; 467 | while (true) { 468 | const j1 = 2 * i + 1; 469 | if (j1 >= n || j1 < 0) { 470 | break; 471 | } 472 | const j2 = j1 + 1; 473 | let j = j1; 474 | if (j2 < n && this._queueLess(j2, j1)) { 475 | j = j2; 476 | } 477 | if (!this._queueLess(j, i)) { 478 | break; 479 | } 480 | this._queueSwap(i, j); 481 | i = j; 482 | } 483 | return i > i0; 484 | } 485 | } 486 | 487 | function orient(ax, ay, bx, by, cx, cy) { 488 | return (bx - cx) * (ay - cy) - (by - cy) * (ax - cx); 489 | } 490 | 491 | function inCircle(ax, ay, bx, by, cx, cy, px, py) { 492 | const dx = ax - px; 493 | const dy = ay - py; 494 | const ex = bx - px; 495 | const ey = by - py; 496 | const fx = cx - px; 497 | const fy = cy - py; 498 | 499 | const ap = dx * dx + dy * dy; 500 | const bp = ex * ex + ey * ey; 501 | const cp = fx * fx + fy * fy; 502 | 503 | return dx * (ey * cp - bp * fy) - dy * (ex * cp - bp * fx) + ap * (ex * fy - ey * fx) < 0; 504 | } 505 | --------------------------------------------------------------------------------