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.
- package/README.md +4 -2
- package/package.json +3 -1
- package/src/AxisRegistry.js +2 -2
- package/src/ColorbarLayer.js +1 -1
- package/src/ColorscaleRegistry.js +5 -2
- package/src/Data.js +67 -0
- package/src/EpsgUtils.js +123 -0
- package/src/FilterbarLayer.js +1 -1
- package/src/Layer.js +2 -1
- package/src/LayerType.js +63 -23
- package/src/Plot.js +95 -3
- package/src/ScatterLayer.js +45 -19
- package/src/TileLayer.js +708 -0
- package/src/index.js +3 -0
package/src/TileLayer.js
ADDED
|
@@ -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 }
|