gladly-plot 0.0.1 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,708 @@
1
+ import proj4 from 'proj4'
2
+ import { LayerType } from './LayerType.js'
3
+ import { AXES } from './AxisRegistry.js'
4
+ import { registerLayerType } from './LayerTypeRegistry.js'
5
+ import { parseCrsCode, crsToQkX, crsToQkY, ensureCrsDefined } from './EpsgUtils.js'
6
+
7
+ // ─── Tile math (standard Web Mercator / "slippy map" grid) ────────────────────
8
+
9
+ const EARTH_RADIUS = 6378137 // metres (WGS84 semi-major axis)
10
+ const MERC_MAX = Math.PI * EARTH_RADIUS // ~20037508.34 m
11
+
12
+ function mercXToNorm(x) { return (x + MERC_MAX) / (2 * MERC_MAX) }
13
+ function mercYToNorm(y) { return (1 - y / MERC_MAX) / 2 }
14
+ function normToMercX(nx) { return nx * 2 * MERC_MAX - MERC_MAX }
15
+ function normToMercY(ny) { return (1 - 2 * ny) * MERC_MAX }
16
+
17
+ function mercToTileXY(x, y, z) {
18
+ const scale = Math.pow(2, z)
19
+ return [
20
+ Math.floor(mercXToNorm(x) * scale),
21
+ Math.floor(mercYToNorm(y) * scale),
22
+ ]
23
+ }
24
+
25
+ function tileToMercBbox(tx, ty, z) {
26
+ const scale = Math.pow(2, z)
27
+ return {
28
+ minX: normToMercX(tx / scale),
29
+ maxX: normToMercX((tx + 1) / scale),
30
+ minY: normToMercY((ty + 1) / scale), // tile y is top-down, merc y is bottom-up
31
+ maxY: normToMercY(ty / scale),
32
+ }
33
+ }
34
+
35
+ function optimalZoom(bboxInTileCrs, pixelWidth, pixelHeight, minZoom, maxZoom) {
36
+ // Assume tile grid is Web Mercator: one tile = 256 px at zoom 0.
37
+ // Use the minimum of the x- and y-derived zoom levels so that neither
38
+ // dimension produces an unmanageable number of tiles. A large y-extent
39
+ // with a small x-extent used to drive zoom to maxZoom via x alone, which
40
+ // could generate hundreds-of-thousands of tiles in the y direction.
41
+ const xExtent = Math.abs(bboxInTileCrs.maxX - bboxInTileCrs.minX)
42
+ const yExtent = Math.abs(bboxInTileCrs.maxY - bboxInTileCrs.minY)
43
+ const worldSize = 2 * MERC_MAX
44
+ const zx = xExtent > 0 ? Math.log2((pixelWidth / 256) * (worldSize / xExtent)) : Infinity
45
+ const zy = yExtent > 0 ? Math.log2((pixelHeight / 256) * (worldSize / yExtent)) : Infinity
46
+ const z = Math.min(zx, zy)
47
+ return Math.min(Math.max(Math.floor(isFinite(z) ? z : maxZoom), minZoom), maxZoom)
48
+ }
49
+
50
+ // ─── Source resolution ────────────────────────────────────────────────────────
51
+
52
+ // source is stored as { xyz: {...} } | { wms: {...} } | { wmts: {...} }
53
+ // Normalize to { type: 'xyz'|'wms'|'wmts', ...params } for the rest of the pipeline.
54
+ function resolveSource(source) {
55
+ const type = Object.keys(source).find(k => k === 'xyz' || k === 'wms' || k === 'wmts')
56
+ if (!type) throw new Error(`source must have exactly one key of: xyz, wms, wmts`)
57
+ return { type, ...source[type] }
58
+ }
59
+
60
+ // ─── URL builders ──────────────────────────────────────────────────────────────
61
+
62
+ function buildXyzUrl(source, z, x, y) {
63
+ const subdomains = source.subdomains ?? ['a', 'b', 'c']
64
+ const s = subdomains[Math.abs(x + y) % subdomains.length]
65
+ return source.url
66
+ .replace('{z}', z)
67
+ .replace('{x}', x)
68
+ .replace('{y}', y)
69
+ .replace('{s}', s)
70
+ }
71
+
72
+ function buildWmtsUrl(source, z, x, y) {
73
+ // Support both RESTful template ({TileMatrix}, {TileRow}, {TileCol}) and KVP
74
+ const url = source.url
75
+ if (url.includes('{TileMatrix}') || url.includes('{z}')) {
76
+ return url
77
+ .replace('{TileMatrix}', z).replace('{z}', z)
78
+ .replace('{TileRow}', y).replace('{y}', y)
79
+ .replace('{TileCol}', x).replace('{x}', x)
80
+ }
81
+ // KVP style
82
+ const params = new URLSearchParams({
83
+ SERVICE: 'WMTS',
84
+ REQUEST: 'GetTile',
85
+ VERSION: '1.0.0',
86
+ LAYER: source.layer,
87
+ STYLE: source.style ?? 'default',
88
+ FORMAT: source.format ?? 'image/png',
89
+ TILEMATRIXSET: source.tileMatrixSet ?? 'WebMercatorQuad',
90
+ TILEMATRIX: z,
91
+ TILEROW: y,
92
+ TILECOL: x,
93
+ })
94
+ return `${source.url}?${params}`
95
+ }
96
+
97
+ function buildWmsUrl(source, bbox, tileCrs, pixelWidth, pixelHeight) {
98
+ const version = source.version ?? '1.3.0'
99
+ const crsParam = `EPSG:${parseCrsCode(tileCrs)}`
100
+
101
+ // WMS 1.3.0 with geographic CRS (EPSG:4326) swaps axis order: BBOX is minLat,minLon,maxLat,maxLon
102
+ const is13 = version === '1.3.0'
103
+ const epsgCode = parseCrsCode(crsParam)
104
+ // EPSG:4326 and other geographic CRS have swapped axes in WMS 1.3.0
105
+ const swapAxes = is13 && (epsgCode === 4326)
106
+ const bboxStr = swapAxes
107
+ ? `${bbox.minY},${bbox.minX},${bbox.maxY},${bbox.maxX}`
108
+ : `${bbox.minX},${bbox.minY},${bbox.maxX},${bbox.maxY}`
109
+
110
+ const crsKey = is13 ? 'CRS' : 'SRS'
111
+ const params = new URLSearchParams({
112
+ SERVICE: 'WMS',
113
+ VERSION: version,
114
+ REQUEST: 'GetMap',
115
+ LAYERS: source.layers,
116
+ [crsKey]: crsParam,
117
+ BBOX: bboxStr,
118
+ WIDTH: Math.round(pixelWidth),
119
+ HEIGHT: Math.round(pixelHeight),
120
+ FORMAT: source.format ?? 'image/png',
121
+ TRANSPARENT: source.transparent !== false ? 'TRUE' : 'FALSE',
122
+ ...(source.styles ? { STYLES: source.styles } : {}),
123
+ })
124
+ return `${source.url}?${params}`
125
+ }
126
+
127
+ // ─── Tessellated mesh builder ──────────────────────────────────────────────────
128
+
129
+ /**
130
+ * Build a tessellated mesh for one tile.
131
+ *
132
+ * @param {{ minX, maxX, minY, maxY }} tileBbox - bbox in tileCrs
133
+ * @param {string} tileCrs - e.g. "EPSG:3857"
134
+ * @param {string} plotCrs - e.g. "EPSG:26911" (may equal tileCrs)
135
+ * @param {number} N - tessellation grid size (N×N quads)
136
+ * @returns {{ positions: Float32Array, uvs: Float32Array, indices: Uint16Array }}
137
+ */
138
+ function buildTileMesh(tileBbox, tileCrs, plotCrs, N) {
139
+ const sameProj = `EPSG:${parseCrsCode(tileCrs)}` === `EPSG:${parseCrsCode(plotCrs)}`
140
+ const project = sameProj ? null : proj4(`EPSG:${parseCrsCode(tileCrs)}`, `EPSG:${parseCrsCode(plotCrs)}`).forward
141
+
142
+ const numVerts = (N + 1) * (N + 1)
143
+ const positions = new Float32Array(numVerts * 2)
144
+ const uvs = new Float32Array(numVerts * 2)
145
+
146
+ for (let j = 0; j <= N; j++) {
147
+ for (let i = 0; i <= N; i++) {
148
+ const u = i / N
149
+ const v = j / N
150
+ const tileX = tileBbox.minX + u * (tileBbox.maxX - tileBbox.minX)
151
+ const tileY = tileBbox.minY + v * (tileBbox.maxY - tileBbox.minY)
152
+
153
+ let px, py
154
+ if (project) {
155
+ ;[px, py] = project([tileX, tileY])
156
+ } else {
157
+ px = tileX
158
+ py = tileY
159
+ }
160
+
161
+ const vi = j * (N + 1) + i
162
+ positions[vi * 2] = px
163
+ positions[vi * 2 + 1] = py
164
+ uvs[vi * 2] = u
165
+ uvs[vi * 2 + 1] = 1 - v // flip v: tile y=0 is top, GL texture v=0 is bottom
166
+ }
167
+ }
168
+
169
+ // Two CCW triangles per cell: (BL, BR, TR) and (BL, TR, TL)
170
+ const numIndices = N * N * 6
171
+ const indices = new Uint16Array(numIndices)
172
+ let idx = 0
173
+ for (let j = 0; j < N; j++) {
174
+ for (let i = 0; i < N; i++) {
175
+ const bl = j * (N + 1) + i
176
+ const br = bl + 1
177
+ const tl = bl + (N + 1)
178
+ const tr = tl + 1
179
+ indices[idx++] = bl; indices[idx++] = br; indices[idx++] = tr
180
+ indices[idx++] = bl; indices[idx++] = tr; indices[idx++] = tl
181
+ }
182
+ }
183
+
184
+ return { positions, uvs, indices }
185
+ }
186
+
187
+ // ─── TileManager ──────────────────────────────────────────────────────────────
188
+
189
+ const MAX_TILE_CACHE = 50
190
+ const DOMAIN_CHANGE_THRESHOLD = 0.02 // 2% change triggers tile sync
191
+
192
+ class TileManager {
193
+ constructor({ regl, source, tileCrs, plotCrs, tessellation, onLoad }) {
194
+ this.regl = regl
195
+ this.source = source
196
+ this.tileCrs = tileCrs // CRS of the tile service (e.g. "EPSG:3857")
197
+ this.plotCrs = plotCrs // CRS of the plot axes
198
+ this.tessellation = tessellation
199
+ this.onLoad = onLoad
200
+
201
+ this.tiles = new Map() // tileKey → tile entry
202
+ this.accessOrder = [] // LRU tracking
203
+ this._neededKeys = new Set() // keys required by the most recent syncTiles call
204
+
205
+ this._lastXDomain = null
206
+ this._lastYDomain = null
207
+ this._lastViewport = null
208
+
209
+ // Pre-compute the proj4 converter from plotCrs to tileCrs for bbox conversion
210
+ const fromCode = parseCrsCode(plotCrs)
211
+ const toCode = parseCrsCode(tileCrs)
212
+ this._plotToTile = fromCode === toCode
213
+ ? (pt) => pt
214
+ : proj4(`EPSG:${fromCode}`, `EPSG:${toCode}`).forward
215
+ }
216
+
217
+ _domainChanged(xDomain, yDomain, viewport) {
218
+ if (!this._lastXDomain || !this._lastYDomain) return true
219
+ const viewChanged = !!(viewport && this._lastViewport && (
220
+ viewport.width !== this._lastViewport.width ||
221
+ viewport.height !== this._lastViewport.height
222
+ ))
223
+ const dx = Math.abs(xDomain[1] - xDomain[0])
224
+ const dy = Math.abs(yDomain[1] - yDomain[0])
225
+ // When a domain has zero width, relative change is undefined; fall back to
226
+ // exact equality so we don't treat every frame as "changed" (div-by-zero
227
+ // would give Infinity > threshold = true on every call).
228
+ if (dx === 0 || dy === 0) {
229
+ return xDomain[0] !== this._lastXDomain[0] ||
230
+ xDomain[1] !== this._lastXDomain[1] ||
231
+ yDomain[0] !== this._lastYDomain[0] ||
232
+ yDomain[1] !== this._lastYDomain[1] ||
233
+ viewChanged
234
+ }
235
+ const xChange = Math.max(
236
+ Math.abs(xDomain[0] - this._lastXDomain[0]) / dx,
237
+ Math.abs(xDomain[1] - this._lastXDomain[1]) / dx
238
+ )
239
+ const yChange = Math.max(
240
+ Math.abs(yDomain[0] - this._lastYDomain[0]) / dy,
241
+ Math.abs(yDomain[1] - this._lastYDomain[1]) / dy
242
+ )
243
+ return xChange > DOMAIN_CHANGE_THRESHOLD || yChange > DOMAIN_CHANGE_THRESHOLD || viewChanged
244
+ }
245
+
246
+ _plotBboxToTileBbox(xDomain, yDomain) {
247
+ // Reproject all four corners and take the axis-aligned bounding box
248
+ const corners = [
249
+ [xDomain[0], yDomain[0]],
250
+ [xDomain[1], yDomain[0]],
251
+ [xDomain[0], yDomain[1]],
252
+ [xDomain[1], yDomain[1]],
253
+ ].map(pt => this._plotToTile(pt))
254
+
255
+ return {
256
+ minX: Math.min(...corners.map(c => c[0])),
257
+ maxX: Math.max(...corners.map(c => c[0])),
258
+ minY: Math.min(...corners.map(c => c[1])),
259
+ maxY: Math.max(...corners.map(c => c[1])),
260
+ }
261
+ }
262
+
263
+ _computeNeededTiles(xDomain, yDomain, viewport) {
264
+ const tileBbox = this._plotBboxToTileBbox(xDomain, yDomain)
265
+ const source = this.source
266
+
267
+ if (source.type === 'wms') {
268
+ // WMS: one image covering the full viewport
269
+ const wmsUrl = buildWmsUrl(source, tileBbox, this.tileCrs, viewport.width, viewport.height)
270
+ return [{ key: wmsUrl, bbox: tileBbox, url: wmsUrl, type: 'wms' }]
271
+ }
272
+
273
+ // XYZ / WMTS: standard tile grid
274
+ const minZoom = source.minZoom ?? 0
275
+ const maxZoom = source.maxZoom ?? 19
276
+ const z = optimalZoom(tileBbox, viewport.width, viewport.height, minZoom, maxZoom)
277
+
278
+ const [xMin, yMax] = mercToTileXY(tileBbox.minX, tileBbox.minY, z) // note: minY → top tile
279
+ const [xMax, yMin] = mercToTileXY(tileBbox.maxX, tileBbox.maxY, z) // maxY → bottom tile
280
+ // Clamp to valid tile range
281
+ const tileCount = Math.pow(2, z)
282
+ const txMin = Math.max(0, xMin)
283
+ const txMax = Math.min(tileCount - 1, xMax)
284
+ const tyMin = Math.max(0, yMin)
285
+ const tyMax = Math.min(tileCount - 1, yMax)
286
+
287
+ // Safety cap: if the computed tile range is still unreasonably large (e.g.
288
+ // due to a degenerate bbox or an unsupported CRS), bail out rather than
289
+ // allocating millions of objects and crashing the tab.
290
+ const MAX_TILES = 512
291
+ if ((txMax - txMin + 1) * (tyMax - tyMin + 1) > MAX_TILES) {
292
+ console.warn(`[TileLayer] tile range too large (${txMax - txMin + 1}×${tyMax - tyMin + 1} at z=${z}), skipping`)
293
+ return []
294
+ }
295
+
296
+ const tiles = []
297
+ for (let ty = tyMin; ty <= tyMax; ty++) {
298
+ for (let tx = txMin; tx <= txMax; tx++) {
299
+ const url = source.type === 'wmts'
300
+ ? buildWmtsUrl(source, z, tx, ty)
301
+ : buildXyzUrl(source, z, tx, ty)
302
+ const bbox = tileToMercBbox(tx, ty, z)
303
+ tiles.push({ key: `${z}/${tx}/${ty}`, bbox, url, type: source.type })
304
+ }
305
+ }
306
+ return tiles
307
+ }
308
+
309
+ syncTiles(xDomain, yDomain, viewport) {
310
+ if (!this._domainChanged(xDomain, yDomain, viewport)) return
311
+
312
+ this._lastXDomain = xDomain.slice()
313
+ this._lastYDomain = yDomain.slice()
314
+ this._lastViewport = viewport ? { width: viewport.width, height: viewport.height } : null
315
+
316
+ const needed = this._computeNeededTiles(xDomain, yDomain, viewport)
317
+ this._neededKeys = new Set(needed.map(t => t.key))
318
+
319
+ // Start loading tiles not yet in cache
320
+ for (const tileSpec of needed) {
321
+ if (!this.tiles.has(tileSpec.key)) {
322
+ this._loadTile(tileSpec)
323
+ }
324
+ }
325
+ // Eviction is deferred to _loadTile() so old tiles stay visible while new ones load.
326
+ }
327
+
328
+ _evictTile(key) {
329
+ const tile = this.tiles.get(key)
330
+ if (!tile) return
331
+ if (tile.texture) tile.texture.destroy()
332
+ if (tile.posBuffer) tile.posBuffer.destroy()
333
+ if (tile.uvBuffer) tile.uvBuffer.destroy()
334
+ if (tile.elements) tile.elements.destroy()
335
+ this.tiles.delete(key)
336
+ const i = this.accessOrder.indexOf(key)
337
+ if (i >= 0) this.accessOrder.splice(i, 1)
338
+ }
339
+
340
+ async _loadTile(tileSpec) {
341
+ this.tiles.set(tileSpec.key, { status: 'loading' })
342
+ this.accessOrder.push(tileSpec.key)
343
+
344
+ try {
345
+ const img = new Image()
346
+ img.crossOrigin = 'anonymous'
347
+ await new Promise((resolve, reject) => {
348
+ img.onload = resolve
349
+ img.onerror = () => reject(new Error(`Failed to load tile: ${tileSpec.url}`))
350
+ img.src = tileSpec.url
351
+ })
352
+
353
+ // Check we haven't been evicted while loading
354
+ if (!this.tiles.has(tileSpec.key)) return
355
+
356
+ const mesh = buildTileMesh(tileSpec.bbox, this.tileCrs, this.plotCrs, this.tessellation)
357
+ const texture = this.regl.texture({ data: img, flipY: false, min: 'linear', mag: 'linear' })
358
+ const posBuffer = this.regl.buffer(mesh.positions)
359
+ const uvBuffer = this.regl.buffer(mesh.uvs)
360
+ const elements = this.regl.elements({ data: mesh.indices, type: 'uint16' })
361
+
362
+ this.tiles.set(tileSpec.key, {
363
+ status: 'loaded',
364
+ texture,
365
+ posBuffer,
366
+ uvBuffer,
367
+ elements,
368
+ indexCount: mesh.indices.length,
369
+ })
370
+
371
+ // Now that this tile is ready, evict old tiles that are no longer needed.
372
+ // Eviction is done here (not in syncTiles) so superseded tiles stay visible
373
+ // as a fallback while their replacements are still loading.
374
+ const neededKeys = this._neededKeys
375
+ if (this.source.type === 'wms') {
376
+ // WMS covers the whole viewport with one image; always evict non-needed tiles.
377
+ for (const key of [...this.tiles.keys()]) {
378
+ if (key !== tileSpec.key && !neededKeys.has(key)) this._evictTile(key)
379
+ }
380
+ } else if (this.tiles.size > MAX_TILE_CACHE) {
381
+ // XYZ/WMTS: LRU eviction only when the cache grows too large.
382
+ for (const key of this.accessOrder) {
383
+ if (key !== tileSpec.key && !neededKeys.has(key) && this.tiles.has(key)) {
384
+ this._evictTile(key)
385
+ if (this.tiles.size <= MAX_TILE_CACHE) break
386
+ }
387
+ }
388
+ }
389
+
390
+ this.onLoad()
391
+ } catch (err) {
392
+ // Mark as failed so we don't retry endlessly (remove from cache to allow future retry on pan)
393
+ this.tiles.delete(tileSpec.key)
394
+ const i = this.accessOrder.indexOf(tileSpec.key)
395
+ if (i >= 0) this.accessOrder.splice(i, 1)
396
+ console.warn(`[TileLayer] ${err.message}`)
397
+ }
398
+ }
399
+
400
+ get loadedTiles() {
401
+ return [...this.tiles.values()].filter(t => t.status === 'loaded')
402
+ }
403
+
404
+ destroy() {
405
+ for (const key of [...this.tiles.keys()]) {
406
+ this._evictTile(key)
407
+ }
408
+ }
409
+ }
410
+
411
+ // ─── GLSL ─────────────────────────────────────────────────────────────────────
412
+
413
+ const TILE_VERT = `
414
+ precision mediump float;
415
+ attribute vec2 position;
416
+ attribute vec2 uv;
417
+ uniform vec2 xDomain;
418
+ uniform vec2 yDomain;
419
+ uniform float xScaleType;
420
+ uniform float yScaleType;
421
+ varying vec2 vUv;
422
+
423
+ float normalize_axis(float v, vec2 domain, float scaleType) {
424
+ float vt = scaleType > 0.5 ? log(v) : v;
425
+ float d0 = scaleType > 0.5 ? log(domain.x) : domain.x;
426
+ float d1 = scaleType > 0.5 ? log(domain.y) : domain.y;
427
+ return (vt - d0) / (d1 - d0);
428
+ }
429
+
430
+ void main() {
431
+ float nx = normalize_axis(position.x, xDomain, xScaleType);
432
+ float ny = normalize_axis(position.y, yDomain, yScaleType);
433
+ gl_Position = vec4(nx * 2.0 - 1.0, ny * 2.0 - 1.0, 0.0, 1.0);
434
+ vUv = uv;
435
+ }
436
+ `
437
+
438
+ const TILE_FRAG = `
439
+ precision mediump float;
440
+ uniform sampler2D tileTexture;
441
+ uniform float opacity;
442
+ varying vec2 vUv;
443
+
444
+ void main() {
445
+ vec4 color = texture2D(tileTexture, vUv);
446
+ gl_FragColor = vec4(color.rgb, color.a * opacity);
447
+ }
448
+ `
449
+
450
+ // ─── TileLayerType ────────────────────────────────────────────────────────────
451
+
452
+ class TileLayerType extends LayerType {
453
+ constructor() {
454
+ super({ name: 'tile' })
455
+ }
456
+
457
+ schema(_data) {
458
+ return {
459
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
460
+ type: 'object',
461
+ properties: {
462
+ source: {
463
+ type: 'object',
464
+ description: 'Tile source configuration. Exactly one key (xyz, wms, or wmts) must be present.',
465
+ anyOf: [
466
+ {
467
+ title: 'XYZ',
468
+ properties: {
469
+ xyz: {
470
+ type: 'object',
471
+ properties: {
472
+ url: { type: 'string', default: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', description: 'URL template with {z}, {x}, {y}, optional {s}' },
473
+ subdomains: { type: 'array', items: { type: 'string' }, default: ['a', 'b', 'c'], description: 'Subdomain letters for {s}' },
474
+ minZoom: { type: 'integer', default: 0 },
475
+ maxZoom: { type: 'integer', default: 19 },
476
+ },
477
+ required: ['url'],
478
+ default: {
479
+ url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
480
+ subdomains: ['a', 'b', 'c'],
481
+ },
482
+ },
483
+ },
484
+ required: ['xyz'],
485
+ additionalProperties: false,
486
+ },
487
+ {
488
+ title: 'WMS',
489
+ properties: {
490
+ wms: {
491
+ type: 'object',
492
+ properties: {
493
+ url: { type: 'string', default: 'https://gibs.earthdata.nasa.gov/wms/epsg3857/best/wms.cgi', description: 'WMS service base URL' },
494
+ layers: { type: 'string', default: 'BlueMarble_NextGeneration', description: 'Comma-separated layer names' },
495
+ styles: { type: 'string', default: '', description: 'Comma-separated style names (optional)' },
496
+ format: { type: 'string', default: 'image/jpeg' },
497
+ version: { type: 'string', enum: ['1.1.1', '1.3.0'], default: '1.1.1' },
498
+ transparent: { type: 'boolean', default: false },
499
+ },
500
+ required: ['url', 'layers'],
501
+ default: {
502
+ url: 'https://gibs.earthdata.nasa.gov/wms/epsg3857/best/wms.cgi',
503
+ layers: 'BlueMarble_NextGeneration',
504
+ format: 'image/jpeg',
505
+ transparent: false,
506
+ version: '1.1.1',
507
+ },
508
+ },
509
+ },
510
+ required: ['wms'],
511
+ additionalProperties: false,
512
+ },
513
+ {
514
+ title: 'WMTS',
515
+ properties: {
516
+ wmts: {
517
+ type: 'object',
518
+ properties: {
519
+ url: { type: 'string', default: 'https://basemap.nationalmap.gov/arcgis/rest/services/USGSTopo/MapServer/WMTS', description: 'WMTS base URL (RESTful template or KVP endpoint)' },
520
+ layer: { type: 'string', default: 'USGSTopo' },
521
+ style: { type: 'string', default: 'default' },
522
+ format: { type: 'string', default: 'image/jpeg' },
523
+ tileMatrixSet: { type: 'string', default: 'GoogleMapsCompatible' },
524
+ minZoom: { type: 'integer', default: 0 },
525
+ maxZoom: { type: 'integer', default: 19 },
526
+ },
527
+ required: ['url', 'layer'],
528
+ default: {
529
+ url: 'https://basemap.nationalmap.gov/arcgis/rest/services/USGSTopo/MapServer/WMTS',
530
+ layer: 'USGSTopo',
531
+ tileMatrixSet: 'GoogleMapsCompatible',
532
+ format: 'image/jpeg',
533
+ },
534
+ },
535
+ },
536
+ required: ['wmts'],
537
+ additionalProperties: false,
538
+ },
539
+ ],
540
+ default: {
541
+ xyz: {
542
+ url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
543
+ subdomains: ['a', 'b', 'c'],
544
+ },
545
+ },
546
+ },
547
+ tileCrs: {
548
+ type: 'string',
549
+ default: 'EPSG:3857',
550
+ description: 'CRS of the tile service. For XYZ/WMTS this is the tile grid CRS; for WMS this becomes the CRS/SRS parameter in GetMap requests. Defaults to EPSG:3857 (Web Mercator).',
551
+ examples: ['EPSG:3857', 'EPSG:4326'],
552
+ },
553
+ plotCrs: {
554
+ type: 'string',
555
+ description: 'CRS of the plot axes (e.g. "EPSG:26911"). Defaults to tileCrs (no reprojection).',
556
+ },
557
+ tessellation: {
558
+ type: 'integer',
559
+ default: 8,
560
+ minimum: 1,
561
+ description: 'Grid resolution (N×N quads per tile) for reprojection accuracy.',
562
+ },
563
+ opacity: {
564
+ type: 'number',
565
+ default: 1.0,
566
+ minimum: 0,
567
+ maximum: 1,
568
+ },
569
+ xAxis: { type: 'string', enum: AXES.filter(a => a.includes('x')), default: 'xaxis_bottom' },
570
+ yAxis: { type: 'string', enum: AXES.filter(a => a.includes('y')), default: 'yaxis_left' },
571
+ },
572
+ required: ['source'],
573
+ }
574
+ }
575
+
576
+ resolveAxisConfig(parameters, _data) {
577
+ const {
578
+ xAxis = 'xaxis_bottom',
579
+ yAxis = 'yaxis_left',
580
+ plotCrs,
581
+ tileCrs,
582
+ } = parameters
583
+ const effectiveTileCrs = tileCrs ?? 'EPSG:3857'
584
+ const effectivePlotCrs = plotCrs ?? effectiveTileCrs
585
+ return {
586
+ xAxis,
587
+ xAxisQuantityKind: crsToQkX(effectivePlotCrs),
588
+ yAxis,
589
+ yAxisQuantityKind: crsToQkY(effectivePlotCrs),
590
+ colorAxisQuantityKinds: [],
591
+ filterAxisQuantityKinds: [],
592
+ }
593
+ }
594
+
595
+ createLayer(parameters, _data) {
596
+ const {
597
+ xAxis = 'xaxis_bottom',
598
+ yAxis = 'yaxis_left',
599
+ plotCrs,
600
+ tileCrs,
601
+ } = parameters
602
+ const effectiveTileCrs = tileCrs ?? 'EPSG:3857'
603
+ const effectivePlotCrs = plotCrs ?? effectiveTileCrs
604
+
605
+ // Return a plain object: compatible with the render loop but no Float32Array required.
606
+ return [{
607
+ type: this,
608
+ xAxis,
609
+ yAxis,
610
+ xAxisQuantityKind: crsToQkX(effectivePlotCrs),
611
+ yAxisQuantityKind: crsToQkY(effectivePlotCrs),
612
+ colorAxes: [],
613
+ filterAxes: [],
614
+ vertexCount: 0,
615
+ instanceCount: null,
616
+ attributes: {},
617
+ domains: {},
618
+ uniforms: {},
619
+ parameters,
620
+ }]
621
+ }
622
+
623
+ createDrawCommand(regl, layer, plot) {
624
+ const {
625
+ source: sourceSpec,
626
+ tileCrs,
627
+ plotCrs,
628
+ tessellation = 8,
629
+ opacity = 1.0,
630
+ } = layer.parameters
631
+ const source = resolveSource(sourceSpec)
632
+ const effectiveTileCrs = tileCrs ?? 'EPSG:3857'
633
+ const effectivePlotCrs = plotCrs ?? effectiveTileCrs
634
+
635
+ // Create the regl draw command immediately — it doesn't need CRS info
636
+ const drawTile = regl({
637
+ vert: TILE_VERT,
638
+ frag: TILE_FRAG,
639
+ attributes: {
640
+ position: regl.prop('posBuffer'),
641
+ uv: regl.prop('uvBuffer'),
642
+ },
643
+ elements: regl.prop('elements'),
644
+ uniforms: {
645
+ xDomain: regl.prop('xDomain'),
646
+ yDomain: regl.prop('yDomain'),
647
+ xScaleType: regl.prop('xScaleType'),
648
+ yScaleType: regl.prop('yScaleType'),
649
+ tileTexture: regl.prop('texture'),
650
+ opacity: opacity,
651
+ },
652
+ viewport: regl.prop('viewport'),
653
+ blend: {
654
+ enable: true,
655
+ func: { src: 'src alpha', dst: 'one minus src alpha' },
656
+ },
657
+ depth: { enable: false },
658
+ })
659
+
660
+ // TileManager is created once both CRS definitions are ready.
661
+ // Renders nothing until then; scheduleRender() triggers a repaint once ready.
662
+ let tileManager = null
663
+
664
+ Promise.all([
665
+ ensureCrsDefined(effectiveTileCrs),
666
+ ensureCrsDefined(effectivePlotCrs),
667
+ ]).then(() => {
668
+ try {
669
+ tileManager = new TileManager({
670
+ regl,
671
+ source,
672
+ tileCrs: effectiveTileCrs,
673
+ plotCrs: effectivePlotCrs,
674
+ tessellation,
675
+ onLoad: () => plot.scheduleRender(),
676
+ })
677
+ plot.scheduleRender()
678
+ } catch (_) {
679
+ // regl may have been destroyed if the plot was updated before CRS resolved
680
+ }
681
+ }).catch(err => {
682
+ console.error('[TileLayer] CRS initialization failed:', err)
683
+ })
684
+
685
+ return (props) => {
686
+ if (!tileManager) return
687
+ tileManager.syncTiles(props.xDomain, props.yDomain, props.viewport)
688
+ for (const tile of tileManager.loadedTiles) {
689
+ drawTile({
690
+ xDomain: props.xDomain,
691
+ yDomain: props.yDomain,
692
+ xScaleType: props.xScaleType,
693
+ yScaleType: props.yScaleType,
694
+ viewport: props.viewport,
695
+ posBuffer: tile.posBuffer,
696
+ uvBuffer: tile.uvBuffer,
697
+ elements: tile.elements,
698
+ texture: tile.texture,
699
+ })
700
+ }
701
+ }
702
+ }
703
+ }
704
+
705
+ export const tileLayerType = new TileLayerType()
706
+ registerLayerType('tile', tileLayerType)
707
+
708
+ export { TileLayerType }