├── 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 | 
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 |
--------------------------------------------------------------------------------