@aguacerowx/mapsgl 0.0.57 → 0.0.58

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.
@@ -1,1000 +1,1000 @@
1
- import { DEFAULT_BASIS_BASE_URL } from './defaultBasisBaseUrl.js';
2
- import mapboxgl from 'mapbox-gl';
3
- import proj4 from 'proj4';
4
- import { createProgram } from './satelliteShader.js';
5
- import {
6
- DEFAULT_SATELLITE_LONGWAVE_IR_COLORMAP,
7
- DEFAULT_SATELLITE_WATER_VAPOR_COLORMAP,
8
- } from './satelliteDefaultColormaps.js';
9
-
10
- const wgs84 = 'EPSG:4326';
11
-
12
- /** Default GOES-East CONUS mesh bounds in geos coordinates (meters). */
13
- const GOES_CONUS_EXTENT_M = [-3627020.7, 1583424.2, 1382521.8, 4588949.1];
14
-
15
- /** Kelvin ranges for IR/WV colormap generation (matches aguacero-frontend utilityFunctions RANGES). */
16
- const RANGES = {
17
- IR: { minK: 180, maxK: 330, label: 'IR' },
18
- WV: { minK: 183, maxK: 288, label: 'WV' },
19
- VIS: { minK: 0, maxK: 1, label: 'VIS' },
20
- };
21
-
22
- export class SatelliteShaderManager {
23
- constructor(workerPool, findBestFormatFn, options = {}) {
24
- this.id = 'satellite-layer';
25
- this.type = 'custom';
26
- this.renderingMode = '2d';
27
-
28
- this.workerPool = workerPool;
29
- this.findBestFormat = findBestFormatFn;
30
- this.basisBaseUrl = options.basisBaseUrl ?? DEFAULT_BASIS_BASE_URL;
31
-
32
- this.frames = new globalThis.Map();
33
- this.activeFrameKey = null;
34
- this.targetFrameKey = null;
35
-
36
- this.currentOpacity = 1.0;
37
- this.isVisible = true;
38
-
39
- this.repaintTimeout = null;
40
-
41
- // Track the attrib locations used in the last render so onRemove can disable them,
42
- // preventing GL state leakage that would break other layers (e.g. radar) after removal.
43
- this._lastPosAttrib = -1;
44
- this._lastTexCoordAttrib = -1;
45
-
46
- /** Merged layer settings (opacity, colormap overrides, etc.). */
47
- this.currentSettings = {};
48
- }
49
-
50
- onAdd(map, gl) {
51
- this.map = map;
52
- this.gl = gl;
53
-
54
- this.initializeWorkers();
55
-
56
- const vertexSource = `
57
- attribute vec2 a_pos;
58
- attribute vec2 a_texCoord;
59
- uniform mat4 u_matrix;
60
- uniform vec4 u_uv_transform;
61
- varying vec2 v_texCoord;
62
- void main() {
63
- gl_Position = u_matrix * vec4(a_pos, 0.0, 1.0);
64
- v_texCoord = a_texCoord * u_uv_transform.xy + u_uv_transform.zw;
65
- }`;
66
-
67
- const fragmentSource = `
68
- precision mediump float;
69
- varying vec2 v_texCoord;
70
- uniform sampler2D u_texture;
71
- uniform sampler2D u_colormap;
72
- uniform bool u_is_true_color;
73
- uniform bool u_use_colormap;
74
- uniform float u_opacity;
75
-
76
- void main() {
77
- vec4 texColor = texture2D(u_texture, v_texCoord);
78
- vec3 rawColor = texColor.rgb;
79
-
80
- if (u_is_true_color) {
81
- gl_FragColor = vec4(rawColor, texColor.a * u_opacity);
82
- }
83
- else {
84
- float luminance = rawColor.r;
85
-
86
- if (u_use_colormap) {
87
- float intensity = clamp(luminance, 0.0, 1.0);
88
- vec3 colorMapped = texture2D(u_colormap, vec2(intensity, 0.5)).rgb;
89
- gl_FragColor = vec4(colorMapped, u_opacity);
90
- }
91
- else {
92
- float val = luminance * 1.7;
93
-
94
- // if (val < 0.001) {
95
- // discard;
96
- // }
97
-
98
- val = clamp(val, 0.0, 1.0);
99
- gl_FragColor = vec4(val, val, val, u_opacity);
100
- }
101
- }
102
- }`;
103
-
104
- this.program = createProgram(gl, vertexSource, fragmentSource);
105
-
106
- this.isTrueColorLocation = gl.getUniformLocation(this.program, "u_is_true_color");
107
- this.useColormapLocation = gl.getUniformLocation(this.program, "u_use_colormap");
108
- this.colormapSamplerLocation = gl.getUniformLocation(this.program, "u_colormap");
109
- this.uvTransformLocation = gl.getUniformLocation(this.program, "u_uv_transform");
110
- this.interpolateColormapLocation = gl.getUniformLocation(this.program, "u_interpolate_colormap");
111
- this.opacityLocation = gl.getUniformLocation(this.program, "u_opacity");
112
-
113
- this.irColormapTexture = this._generateTextureFromColormapData(
114
- gl,
115
- DEFAULT_SATELLITE_LONGWAVE_IR_COLORMAP,
116
- 'IR',
117
- true
118
- );
119
- this.wvColormapTexture = this._generateTextureFromColormapData(
120
- gl,
121
- DEFAULT_SATELLITE_WATER_VAPOR_COLORMAP,
122
- 'WV',
123
- true
124
- );
125
- }
126
-
127
- onRemove() {
128
- if (this.repaintTimeout) {
129
- cancelAnimationFrame(this.repaintTimeout);
130
- this.repaintTimeout = null;
131
- }
132
-
133
- // Disable any vertex attribute arrays we may have left enabled during the last render.
134
- // This is critical: leaving enabled attrib arrays that reference deleted buffers causes
135
- // INVALID_OPERATION on subsequent drawArrays/drawElements calls from other layers (e.g. radar).
136
- if (this.gl) {
137
- if (this._lastPosAttrib >= 0) {
138
- this.gl.disableVertexAttribArray(this._lastPosAttrib);
139
- this._lastPosAttrib = -1;
140
- }
141
- if (this._lastTexCoordAttrib >= 0) {
142
- this.gl.disableVertexAttribArray(this._lastTexCoordAttrib);
143
- this._lastTexCoordAttrib = -1;
144
- }
145
- // Unbind buffers so nothing else accidentally reads stale bindings.
146
- this.gl.bindBuffer(this.gl.ARRAY_BUFFER, null);
147
- this.gl.bindBuffer(this.gl.ELEMENT_ARRAY_BUFFER, null);
148
- }
149
-
150
- for (const frame of this.frames.values()) {
151
- if (frame.tile) {
152
- this._destroyTile(frame.tile);
153
- }
154
- }
155
- this.frames.clear();
156
-
157
- if (this.gl && this.program) {
158
- this.gl.deleteProgram(this.program);
159
- }
160
- this.program = null;
161
- if (this.gl) {
162
- if (this.irColormapTexture) this.gl.deleteTexture(this.irColormapTexture);
163
- if (this.wvColormapTexture) this.gl.deleteTexture(this.wvColormapTexture);
164
- if (this.userColormapTexture) this.gl.deleteTexture(this.userColormapTexture);
165
- }
166
- this.irColormapTexture = null;
167
- this.wvColormapTexture = null;
168
- this.userColormapTexture = null;
169
- this._destroyReadoutPipeline();
170
- }
171
-
172
- _parseProjectionFromFileName(fileName) {
173
- const match = fileName.match(/_proj_([-\d\.eE]+)_([-\d\.eE]+)_([-\d\.eE]+)/);
174
- if (match) {
175
- const scale = parseFloat(match[3]);
176
- return {
177
- h: 35786023.0,
178
- lon0: -75.0,
179
- xOff: parseFloat(match[1]),
180
- yOff: parseFloat(match[2]),
181
- xScale: scale,
182
- yScale: -scale
183
- };
184
- }
185
- return null;
186
- }
187
-
188
- addFrame(supercompressedBuffer, frameKey, fileName) {
189
- if (this.frames.has(frameKey)) {
190
- return;
191
- }
192
-
193
- let meshSubdivisions = 50;
194
- let frameProjString = "+proj=geos +h=35786023.0 +lon_0=-75.0 +sweep=x +no_defs";
195
- let frameExtent = [...GOES_CONUS_EXTENT_M];
196
-
197
- let isMesoscale = false;
198
-
199
- if (fileName.startsWith('F_') || fileName.includes('MCMIPF') || fileName.includes('CMIPF')) {
200
- frameProjString = "+proj=geos +h=35786023.0 +lon_0=-75.0 +sweep=x +no_defs";
201
- frameExtent =[-5434894.885056, -5434894.885056, 5434894.885056, 5434894.885056];
202
- meshSubdivisions = 100;
203
- } else if (fileName.startsWith('M1_') || fileName.startsWith('M2_') || fileName.includes('MCMIPM') || fileName.includes('CMIPM')) {
204
- isMesoscale = true;
205
- meshSubdivisions = 30;
206
- }
207
-
208
- const customProj = this._parseProjectionFromFileName(fileName);
209
-
210
- if (isMesoscale && !customProj) {
211
- console.warn(
212
- '[SatelliteLayer] Mesoscale file name is missing _proj_xOff_yOff_scale (see single-band M1/M2 keys). ' +
213
- 'RGB pipeline should use the same segment as C## products for correct placement:',
214
- fileName
215
- );
216
- }
217
-
218
- const isTrueColor = fileName.includes('_truecolor_') || fileName.includes('_geocolor_') || fileName.includes('_firetemperature_') || fileName.includes('_dust_') || fileName.includes('_simplewatervapor_') || fileName.includes('_ntmicro_') || fileName.includes('_daycloudphase_') || fileName.includes('_daylandcloudfire_') || fileName.includes('_airmass_') || fileName.includes('_sandwich_');
219
-
220
- let colormapType = 'none';
221
- if (fileName.includes('C13') || fileName.includes('C14') || fileName.includes('C15') || fileName.includes('C16')) {
222
- colormapType = 'ir';
223
- } else if (fileName.includes('C08') || fileName.includes('C09') || fileName.includes('C10')) {
224
- colormapType = 'wv';
225
- }
226
-
227
- const brightnessMultiplier = 1.0;
228
-
229
- const frame = {
230
- supercompressedBuffer,
231
- isTrueColor,
232
- colormapType,
233
- brightnessMultiplier,
234
- ktx2Metadata: null,
235
- fullTranscodedData: null,
236
- tile: null,
237
- ktx2MetadataPromise: null,
238
- customProj,
239
- isMesoscale,
240
- meshSubdivisions,
241
- projString: frameProjString,
242
- extent: frameExtent,
243
- sourceFileName: fileName,
244
- };
245
-
246
- let resolvePromise;
247
- frame.ktx2MetadataPromise = new Promise(resolve => { resolvePromise = resolve; });
248
- frame.ktx2MetadataPromise.resolve = resolvePromise;
249
-
250
- this.frames.set(frameKey, frame);
251
-
252
- if (this.targetFrameKey === frameKey) {
253
- this.setActiveFrame(frameKey);
254
- } else if (this.targetFrameKey === null && this.activeFrameKey === null) {
255
- this.setActiveFrame(frameKey);
256
- }
257
-
258
- this.workerPool.execute({
259
- type: 'INITIAL_LOAD',
260
- ktx2Buffer: supercompressedBuffer,
261
- downsampleRate: 1,
262
- frameKey,
263
- basisBaseUrl: this.basisBaseUrl,
264
- })
265
- .then(result => {
266
- if (!this.frames.has(frameKey)) {
267
- return;
268
- }
269
-
270
- frame.supercompressedBuffer = null;
271
-
272
- if (result.error || !result.ktx2Metadata) {
273
- console.warn(`[ShaderManager] Worker failed for frame ${frameKey}:`, result.error);
274
- frame.ktx2Metadata = null;
275
- frame.ktx2MetadataPromise.resolve(null);
276
- return;
277
- }
278
-
279
- const { ktx2Metadata, fullTranscodedData } = result;
280
- const { fullWidth, fullHeight } = ktx2Metadata;
281
-
282
- if (frame.customProj) {
283
- const { h, lon0, xOff, yOff, xScale, yScale } = frame.customProj;
284
- frame.projString = `+proj=geos +h=${h} +lon_0=${lon0} +sweep=x +no_defs`;
285
-
286
- const xMinRad = xOff;
287
- const xMaxRad = xOff + (fullWidth * xScale);
288
- const yMinRad = yOff + (fullHeight * yScale);
289
- const yMaxRad = yOff;
290
-
291
- frame.extent =[
292
- Math.min(xMinRad, xMaxRad) * h,
293
- Math.min(yMinRad, yMaxRad) * h,
294
- Math.max(xMinRad, xMaxRad) * h,
295
- Math.max(yMinRad, yMaxRad) * h
296
- ];
297
- }
298
-
299
- const s3tc = this.gl.getExtension('WEBGL_compressed_texture_s3tc');
300
- const etc1 = this.gl.getExtension('WEBGL_compressed_texture_etc1');
301
- const astc = this.gl.getExtension('WEBGL_compressed_texture_astc');
302
- const bptc = this.gl.getExtension('EXT_texture_compression_bptc');
303
-
304
- const formatInfo = ktx2Metadata.formatInfo;
305
- switch (formatInfo.webglFormatName) {
306
- case 'COMPRESSED_RGBA_ASTC_4x4_KHR':
307
- if (astc) formatInfo.webglFormatEnum = astc.COMPRESSED_RGBA_ASTC_4x4_KHR;
308
- break;
309
- case 'COMPRESSED_RGBA_BPTC_UNORM_EXT':
310
- if (bptc) formatInfo.webglFormatEnum = bptc.COMPRESSED_RGBA_BPTC_UNORM_EXT;
311
- break;
312
- case 'COMPRESSED_RGBA_S3TC_DXT5_EXT':
313
- if (s3tc) formatInfo.webglFormatEnum = s3tc.COMPRESSED_RGBA_S3TC_DXT5_EXT;
314
- break;
315
- case 'COMPRESSED_RGB_S3TC_DXT1_EXT':
316
- if (s3tc) formatInfo.webglFormatEnum = s3tc.COMPRESSED_RGB_S3TC_DXT1_EXT;
317
- break;
318
- case 'COMPRESSED_RGB_ETC1_WEBGL':
319
- if (etc1) formatInfo.webglFormatEnum = etc1.COMPRESSED_RGB_ETC1_WEBGL;
320
- break;
321
- case 'RGBA':
322
- formatInfo.webglFormatEnum = this.gl.RGBA;
323
- formatInfo.webglTypeEnum = this.gl.UNSIGNED_BYTE;
324
- break;
325
- default:
326
- console.error("Could not resolve WebGL format from worker string:", formatInfo.webglFormatName);
327
- frame.ktx2Metadata = null;
328
- frame.ktx2MetadataPromise.resolve(null);
329
- return;
330
- }
331
-
332
- if (!formatInfo.webglFormatEnum) {
333
- console.error(`Format ${formatInfo.webglFormatName} is not supported by this GPU.`);
334
- frame.ktx2Metadata = null;
335
- frame.ktx2MetadataPromise.resolve(null);
336
- return;
337
- }
338
-
339
- frame.ktx2Metadata = ktx2Metadata;
340
- frame.fullTranscodedData = fullTranscodedData;
341
- frame.ktx2MetadataPromise.resolve(ktx2Metadata);
342
-
343
- const texture = this._createTexture(fullTranscodedData, ktx2Metadata);
344
- if (texture) {
345
- const { vertices, indices } = this._createMeshGeometry(
346
- frame.projString,
347
- frame.extent,
348
- frame.meshSubdivisions
349
- );
350
-
351
- const vertexBuffer = this.gl.createBuffer();
352
- this.gl.bindBuffer(this.gl.ARRAY_BUFFER, vertexBuffer);
353
- this.gl.bufferData(this.gl.ARRAY_BUFFER, vertices, this.gl.STATIC_DRAW);
354
- const indexBuffer = this.gl.createBuffer();
355
- this.gl.bindBuffer(this.gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
356
- this.gl.bufferData(this.gl.ELEMENT_ARRAY_BUFFER, indices, this.gl.STATIC_DRAW);
357
-
358
- frame.tile = {
359
- vertexBuffer,
360
- indexBuffer,
361
- texture: texture,
362
- indexCount: indices.length,
363
- uvTransform:[1.0, 1.0, 0.0, 0.0]
364
- };
365
- }
366
-
367
- this.scheduleRepaint();
368
- })
369
- .catch((error) => {
370
- if (!this.frames.has(frameKey)) {
371
- return;
372
- }
373
- console.error(`[ShaderManager] Worker promise REJECTED for INITIAL_LOAD of frame ${frameKey}:`, error);
374
- });
375
- }
376
-
377
- _ensureReadoutPipeline() {
378
- const gl = this.gl;
379
- if (!gl || this._readoutProgram) return;
380
-
381
- const vs = `attribute vec2 a_pos; void main(){ gl_Position = vec4(a_pos,0.0,1.0); }`;
382
- const fs = `precision mediump float;
383
- uniform sampler2D u_tex;
384
- uniform vec2 u_uv;
385
- void main(){
386
- float L = texture2D(u_tex, u_uv).r;
387
- gl_FragColor = vec4(L, L, L, 1.0);
388
- }`;
389
- this._readoutProgram = createProgram(gl, vs, fs);
390
- this._readoutUuv = gl.getUniformLocation(this._readoutProgram, 'u_uv');
391
- this._readoutUtex = gl.getUniformLocation(this._readoutProgram, 'u_tex');
392
-
393
- this._readoutTriBuffer = gl.createBuffer();
394
- gl.bindBuffer(gl.ARRAY_BUFFER, this._readoutTriBuffer);
395
- gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
396
- gl.bindBuffer(gl.ARRAY_BUFFER, null);
397
-
398
- this._readoutTex = gl.createTexture();
399
- gl.bindTexture(gl.TEXTURE_2D, this._readoutTex);
400
- gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
401
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
402
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
403
-
404
- this._readoutFbo = gl.createFramebuffer();
405
- gl.bindFramebuffer(gl.FRAMEBUFFER, this._readoutFbo);
406
- gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, this._readoutTex, 0);
407
- gl.bindFramebuffer(gl.FRAMEBUFFER, null);
408
- gl.bindTexture(gl.TEXTURE_2D, null);
409
- }
410
-
411
- _destroyReadoutPipeline() {
412
- const gl = this.gl;
413
- if (!gl) return;
414
- if (this._readoutProgram) {
415
- gl.deleteProgram(this._readoutProgram);
416
- this._readoutProgram = null;
417
- }
418
- if (this._readoutTriBuffer) {
419
- gl.deleteBuffer(this._readoutTriBuffer);
420
- this._readoutTriBuffer = null;
421
- }
422
- if (this._readoutTex) {
423
- gl.deleteTexture(this._readoutTex);
424
- this._readoutTex = null;
425
- }
426
- if (this._readoutFbo) {
427
- gl.deleteFramebuffer(this._readoutFbo);
428
- this._readoutFbo = null;
429
- }
430
- }
431
-
432
- /** Sample active frame texture at UV (0–1). Uses a 1×1 FBO + readPixels so compressed GPU textures work. */
433
- _sampleLuminanceAtUv(tu, tv) {
434
- const gl = this.gl;
435
- if (!gl || !this._readoutProgram) return null;
436
- const frame = this.frames.get(this.activeFrameKey);
437
- if (!frame?.tile?.texture) return null;
438
-
439
- const prevFb = gl.getParameter(gl.FRAMEBUFFER_BINDING);
440
- const prevVp = gl.getParameter(gl.VIEWPORT);
441
- const prevProg = gl.getParameter(gl.CURRENT_PROGRAM);
442
- const prevActiveUnit = gl.getParameter(gl.ACTIVE_TEXTURE);
443
- gl.activeTexture(gl.TEXTURE0);
444
- const prevTex0 = gl.getParameter(gl.TEXTURE_BINDING_2D);
445
-
446
- try {
447
- gl.bindFramebuffer(gl.FRAMEBUFFER, this._readoutFbo);
448
- gl.viewport(0, 0, 1, 1);
449
- gl.useProgram(this._readoutProgram);
450
- gl.disable(gl.DEPTH_TEST);
451
- gl.disable(gl.BLEND);
452
- gl.colorMask(true, true, true, true);
453
- gl.clearColor(0, 0, 0, 0);
454
- gl.clear(gl.COLOR_BUFFER_BIT);
455
-
456
- gl.activeTexture(gl.TEXTURE0);
457
- gl.bindTexture(gl.TEXTURE_2D, frame.tile.texture);
458
- gl.uniform1i(this._readoutUtex, 0);
459
- gl.uniform2f(this._readoutUuv, tu, tv);
460
-
461
- const posLoc = gl.getAttribLocation(this._readoutProgram, 'a_pos');
462
- gl.bindBuffer(gl.ARRAY_BUFFER, this._readoutTriBuffer);
463
- gl.enableVertexAttribArray(posLoc);
464
- gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 0, 0);
465
- gl.drawArrays(gl.TRIANGLES, 0, 3);
466
- gl.disableVertexAttribArray(posLoc);
467
- gl.bindBuffer(gl.ARRAY_BUFFER, null);
468
-
469
- const pix = new Uint8Array(4);
470
- gl.readPixels(0, 0, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, pix);
471
- return pix[0] / 255;
472
- } finally {
473
- gl.bindFramebuffer(gl.FRAMEBUFFER, prevFb);
474
- gl.viewport(prevVp[0], prevVp[1], prevVp[2], prevVp[3]);
475
- gl.useProgram(prevProg);
476
- gl.activeTexture(gl.TEXTURE0);
477
- gl.bindTexture(gl.TEXTURE_2D, prevTex0);
478
- gl.activeTexture(prevActiveUnit);
479
- }
480
- }
481
-
482
- getValueAtLatLon(lat, lon) {
483
- if (!this.gl || !this.activeFrameKey || !this.frames.has(this.activeFrameKey)) return null;
484
- if (!this.isVisible || (this.currentOpacity ?? 0) <= 0) return null;
485
-
486
- const frame = this.frames.get(this.activeFrameKey);
487
- if (!frame.tile?.texture || !frame.ktx2Metadata) return null;
488
-
489
- let projX;
490
- let projY;
491
- try {
492
- [projX, projY] = proj4(wgs84, frame.projString, [lon, lat]);
493
- } catch {
494
- return null;
495
- }
496
-
497
- const [xmin, ymin, xmax, ymax] = frame.extent;
498
- if (!Number.isFinite(projX) || !Number.isFinite(projY)) return null;
499
-
500
- const pad = 1e-3 * Math.max(xmax - xmin, ymax - ymin, 1);
501
- if (projX < xmin - pad || projX > xmax + pad || projY < ymin - pad || projY > ymax + pad) return null;
502
-
503
- let u = (projX - xmin) / (xmax - xmin);
504
- let v = (ymax - projY) / (ymax - ymin);
505
- u = Math.min(1, Math.max(0, u));
506
- v = Math.min(1, Math.max(0, v));
507
-
508
- const t = frame.tile.uvTransform || [1, 1, 0, 0];
509
- const tu = u * t[0] + t[2];
510
- const tv = v * t[1] + t[3];
511
- if (tu < -1e-3 || tu > 1 + 1e-3 || tv < -1e-3 || tv > 1 + 1e-3) return null;
512
-
513
- this._ensureReadoutPipeline();
514
- const lum = this._sampleLuminanceAtUv(tu, tv);
515
- if (lum == null || !Number.isFinite(lum)) return null;
516
-
517
- return {
518
- isTrueColor: frame.isTrueColor,
519
- colormapType: frame.colormapType,
520
- luminance: lum,
521
- sourceFileName: frame.sourceFileName,
522
- };
523
- }
524
-
525
- removeFrame(frameKey) {
526
- if (!this.frames.has(frameKey)) {
527
- return;
528
- }
529
-
530
- const frame = this.frames.get(frameKey);
531
-
532
- if (frame.tile) {
533
- this._destroyTile(frame.tile);
534
- }
535
-
536
- this.workerPool.broadcast({
537
- type: 'CLEANUP_FRAME',
538
- frameKey: frameKey
539
- });
540
-
541
- this.frames.delete(frameKey);
542
-
543
- if (this.activeFrameKey === frameKey) {
544
- this.activeFrameKey = null;
545
- }
546
-
547
- this.map.triggerRepaint();
548
- }
549
-
550
- _updateTextureFiltering() {
551
- const gl = this.gl;
552
- const filterMode = this.currentSmoothing === 1 ? gl.LINEAR : gl.NEAREST;
553
-
554
- for (const frame of this.frames.values()) {
555
- if (frame.tile && frame.tile.texture) {
556
- gl.bindTexture(gl.TEXTURE_2D, frame.tile.texture);
557
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, filterMode);
558
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filterMode);
559
- }
560
- }
561
-
562
- gl.bindTexture(gl.TEXTURE_2D, null);
563
- }
564
-
565
- updateLayerSettings(settings) {
566
- this.currentSettings = { ...this.currentSettings, ...settings };
567
-
568
- const s = this.currentSettings;
569
- const oldSmoothing = this.currentSmoothing;
570
-
571
- if (s.fillSmoothing !== undefined) {
572
- this.currentSmoothing = s.fillSmoothing;
573
- }
574
-
575
- if (s.satelliteOpacity !== undefined) {
576
- this.currentOpacity = s.satelliteOpacity;
577
- }
578
-
579
- if (s.satelliteVisibility !== undefined) {
580
- this.isVisible = s.satelliteVisibility;
581
- }
582
-
583
- if (s.interpolateColormap !== undefined) {
584
- this.interpolateColormap = s.interpolateColormap;
585
- }
586
-
587
- if (oldSmoothing !== this.currentSmoothing) {
588
- this._updateTextureFiltering();
589
- }
590
-
591
- this._applySatelliteColormapFromSettings();
592
-
593
- this.map.triggerRepaint();
594
- }
595
-
596
- initializeWorkers() {
597
- const gl = this.gl;
598
- const getExtension = (name) => gl.getExtension(name) || gl.getExtension(`WEBKIT_${name}`) || gl.getExtension(`MOZ_${name}`);
599
-
600
- const supportedFormats = {
601
- s3tc: !!getExtension('WEBGL_compressed_texture_s3tc'),
602
- etc1: !!getExtension('WEBGL_compressed_texture_etc1'),
603
- astc: !!getExtension('WEBGL_compressed_texture_astc'),
604
- bc7: !!getExtension('EXT_texture_compression_bptc'),
605
- rgtc: !!getExtension('EXT_texture_compression_rgtc'),
606
- };
607
-
608
- const msg = {
609
- type: 'INIT_WORKER',
610
- supportedFormats,
611
- basisBaseUrl: this.basisBaseUrl,
612
- };
613
-
614
- // Every Worker instance must receive INIT (including replacements after errors). broadcast() alone
615
- // missed workers created in WorkerPool._replaceWorker → Basis saw undefined transcoder enums.
616
- this.workerPool.setWorkerBootstrap((worker) => {
617
- worker.postMessage(msg);
618
- });
619
- }
620
-
621
- scheduleRepaint() {
622
- if (this.repaintTimeout) return;
623
- this.repaintTimeout = requestAnimationFrame(() => {
624
- this.map.triggerRepaint();
625
- this.repaintTimeout = null;
626
- });
627
- }
628
-
629
- setActiveFrame(frameKey) {
630
- this.targetFrameKey = frameKey;
631
-
632
- if (this.activeFrameKey === frameKey) {
633
- return;
634
- }
635
-
636
- if (!this.frames.has(frameKey)) {
637
- // Avoid showing a stale frame while the new timestep / product is still loading or decoding.
638
- this.activeFrameKey = null;
639
- this.scheduleRepaint();
640
- return;
641
- }
642
- this.activeFrameKey = frameKey;
643
-
644
- this._applySatelliteColormapFromSettings();
645
-
646
- this.scheduleRepaint();
647
- }
648
-
649
- /**
650
- * Builds `userColormapTexture` from `currentSettings` and the active frame’s IR vs WV kind.
651
- * Prefer `satelliteColormapIR` / `satelliteColormapWV` when set; otherwise `satelliteColormap` (legacy one array).
652
- */
653
- _applySatelliteColormapFromSettings() {
654
- const gl = this.gl;
655
- if (!gl) return;
656
-
657
- const settings = this.currentSettings || {};
658
- const activeFrame = this.activeFrameKey ? this.frames.get(this.activeFrameKey) : null;
659
- const ct = activeFrame?.colormapType;
660
-
661
- let stops = null;
662
- let channelType = 'IR';
663
-
664
- if (ct === 'wv' && settings.satelliteColormapWV?.length > 0) {
665
- stops = settings.satelliteColormapWV;
666
- channelType = 'WV';
667
- } else if (ct === 'ir' && settings.satelliteColormapIR?.length > 0) {
668
- stops = settings.satelliteColormapIR;
669
- channelType = 'IR';
670
- } else if (settings.satelliteColormap?.length > 0) {
671
- stops = settings.satelliteColormap;
672
- channelType = ct === 'wv' ? 'WV' : 'IR';
673
- }
674
-
675
- const shouldInterpolate = settings.interpolateColormap !== false;
676
- const cacheKey = JSON.stringify({ stops, channelType, iv: shouldInterpolate, ct });
677
-
678
- if (cacheKey === this.lastColormapCacheKey) {
679
- return;
680
- }
681
- this.lastColormapCacheKey = cacheKey;
682
-
683
- if (this.userColormapTexture) {
684
- gl.deleteTexture(this.userColormapTexture);
685
- this.userColormapTexture = null;
686
- }
687
-
688
- if (stops?.length) {
689
- this.userColormapTexture = this._generateTextureFromColormapData(
690
- gl,
691
- stops,
692
- channelType,
693
- shouldInterpolate
694
- );
695
- }
696
- }
697
-
698
- _createTexture(data, ktx2Metadata) {
699
- const gl = this.gl;
700
- const { fullWidth, fullHeight, formatInfo } = ktx2Metadata;
701
-
702
- const texture = gl.createTexture();
703
- gl.bindTexture(gl.TEXTURE_2D, texture);
704
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
705
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
706
- const filterMode = this.currentSmoothing === 1 ? gl.LINEAR : gl.NEAREST;
707
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, filterMode);
708
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filterMode);
709
-
710
- if (formatInfo.isCompressed) {
711
- gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
712
- gl.compressedTexImage2D(
713
- gl.TEXTURE_2D,
714
- 0,
715
- formatInfo.webglFormatEnum,
716
- fullWidth,
717
- fullHeight,
718
- 0,
719
- data
720
- );
721
- } else {
722
- gl.pixelStorei(gl.UNPACK_ALIGNMENT, 4);
723
- gl.texImage2D(
724
- gl.TEXTURE_2D,
725
- 0,
726
- formatInfo.webglFormatEnum,
727
- fullWidth,
728
- fullHeight,
729
- 0,
730
- formatInfo.webglFormatEnum,
731
- formatInfo.webglTypeEnum,
732
- data
733
- );
734
- }
735
-
736
- gl.bindTexture(gl.TEXTURE_2D, null);
737
- return texture;
738
- }
739
-
740
- _destroyTile(tile) {
741
- if (!tile) return;
742
-
743
- if (tile.texture) {
744
- this.gl.deleteTexture(tile.texture);
745
- }
746
- if (tile.vertexBuffer) {
747
- this.gl.deleteBuffer(tile.vertexBuffer);
748
- }
749
- if (tile.indexBuffer) {
750
- this.gl.deleteBuffer(tile.indexBuffer);
751
- }
752
- }
753
-
754
- _createMeshGeometry(projectionString, extent, subdivisions = 50) {
755
- const[x_min, y_min, x_max, y_max] = extent;
756
- const vertices = [];
757
- const indices =[];
758
-
759
- for (let row = 0; row <= subdivisions; row++) {
760
- for (let col = 0; col <= subdivisions; col++) {
761
- const u = col / subdivisions;
762
- const v = row / subdivisions;
763
-
764
- const proj_x = x_min + u * (x_max - x_min);
765
- const proj_y = y_max - v * (y_max - y_min);
766
-
767
- try {
768
- const [lon, lat] = proj4(projectionString, wgs84,[proj_x, proj_y]);
769
- if (Math.abs(lon) > 180 || Math.abs(lat) > 90) throw new Error("Out of bounds");
770
- const mercator = mapboxgl.MercatorCoordinate.fromLngLat({ lon, lat }, 0);
771
- vertices.push(mercator.x, mercator.y, u, v);
772
- } catch (e) {
773
- vertices.push(NaN, NaN, NaN, NaN);
774
- }
775
- }
776
- }
777
-
778
- for (let row = 0; row < subdivisions; row++) {
779
- for (let col = 0; col < subdivisions; col++) {
780
- const i00 = row * (subdivisions + 1) + col;
781
- const i10 = i00 + 1;
782
- const i01 = (row + 1) * (subdivisions + 1) + col;
783
- const i11 = i01 + 1;
784
-
785
- if (!isNaN(vertices[i00 * 4]) && !isNaN(vertices[i10 * 4]) && !isNaN(vertices[i01 * 4]) && !isNaN(vertices[i11 * 4])) {
786
- indices.push(i00, i01, i10);
787
- indices.push(i10, i01, i11);
788
- }
789
- }
790
- }
791
-
792
- return {
793
- vertices: new Float32Array(vertices),
794
- indices: new Uint32Array(indices)
795
- };
796
- }
797
-
798
- render(gl, matrix) {
799
- if (!this.activeFrameKey || !this.frames.has(this.activeFrameKey)) {
800
- return;
801
- }
802
-
803
- const activeFrame = this.frames.get(this.activeFrameKey);
804
-
805
- if (!activeFrame.tile) {
806
- return;
807
- }
808
-
809
- const effectiveOpacity = this.isVisible ? (this.currentOpacity ?? 1.0) : 0.0;
810
-
811
- if (effectiveOpacity === 0) {
812
- return;
813
- }
814
-
815
- gl.useProgram(this.program);
816
- gl.uniformMatrix4fv(gl.getUniformLocation(this.program, "u_matrix"), false, matrix);
817
-
818
- gl.uniform1i(this.isTrueColorLocation, activeFrame.isTrueColor ? 1 : 0);
819
-
820
- let useColormap = activeFrame.colormapType !== 'none';
821
-
822
- if (this.userColormapTexture) {
823
- gl.activeTexture(gl.TEXTURE1);
824
- gl.bindTexture(gl.TEXTURE_2D, this.userColormapTexture);
825
- gl.uniform1i(this.colormapSamplerLocation, 1);
826
- useColormap = true;
827
- } else if (activeFrame.colormapType !== 'none') {
828
- gl.activeTexture(gl.TEXTURE1);
829
- if (activeFrame.colormapType === 'wv') {
830
- gl.bindTexture(gl.TEXTURE_2D, this.wvColormapTexture);
831
- } else {
832
- gl.bindTexture(gl.TEXTURE_2D, this.irColormapTexture);
833
- }
834
- gl.uniform1i(this.colormapSamplerLocation, 1);
835
- }
836
-
837
- gl.uniform1i(this.useColormapLocation, useColormap ? 1 : 0);
838
- gl.uniform1i(this.isTrueColorLocation, activeFrame.isTrueColor ? 1 : 0);
839
-
840
- const shouldInterpolate = this.currentSettings?.interpolateColormap !== false;
841
- gl.uniform1i(this.interpolateColormapLocation, shouldInterpolate ? 1 : 0);
842
-
843
- const posAttrib = gl.getAttribLocation(this.program, "a_pos");
844
- const texCoordAttrib = gl.getAttribLocation(this.program, "a_texCoord");
845
-
846
- // Remember these for onRemove cleanup in case the layer is torn down before next render.
847
- this._lastPosAttrib = posAttrib;
848
- this._lastTexCoordAttrib = texCoordAttrib;
849
-
850
- gl.enable(gl.BLEND);
851
- gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
852
- gl.disable(gl.DEPTH_TEST);
853
-
854
- this.drawTile(activeFrame.tile, posAttrib, texCoordAttrib, effectiveOpacity);
855
-
856
- // Restore vertex attribute array state. Mapbox GL does NOT automatically reset these
857
- // between custom layer renders, so leaving them enabled causes INVALID_OPERATION when
858
- // the layer is removed (buffers deleted) and another layer (e.g. radar) calls drawArrays.
859
- gl.disableVertexAttribArray(posAttrib);
860
- gl.disableVertexAttribArray(texCoordAttrib);
861
-
862
- // Unbind buffers and textures to leave GL in a clean state for subsequent layers.
863
- gl.bindBuffer(gl.ARRAY_BUFFER, null);
864
- gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);
865
- gl.activeTexture(gl.TEXTURE1);
866
- gl.bindTexture(gl.TEXTURE_2D, null);
867
- gl.activeTexture(gl.TEXTURE0);
868
- gl.bindTexture(gl.TEXTURE_2D, null);
869
- }
870
-
871
- drawTile(tile, posAttrib, texCoordAttrib, opacity = 1.0) {
872
- const gl = this.gl;
873
- gl.bindBuffer(gl.ARRAY_BUFFER, tile.vertexBuffer);
874
- gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, tile.indexBuffer);
875
-
876
- gl.enableVertexAttribArray(posAttrib);
877
- gl.vertexAttribPointer(posAttrib, 2, gl.FLOAT, false, 16, 0);
878
-
879
- gl.enableVertexAttribArray(texCoordAttrib);
880
- gl.vertexAttribPointer(texCoordAttrib, 2, gl.FLOAT, false, 16, 8);
881
-
882
- gl.activeTexture(gl.TEXTURE0);
883
- gl.bindTexture(gl.TEXTURE_2D, tile.texture);
884
- gl.uniform1i(gl.getUniformLocation(this.program, "u_texture"), 0);
885
-
886
- const transform = tile.uvTransform ||[1, 1, 0, 0];
887
- gl.uniform4fv(this.uvTransformLocation, transform);
888
-
889
- const opacityLocation = gl.getUniformLocation(this.program, "u_opacity");
890
- gl.uniform1f(opacityLocation, opacity);
891
-
892
- gl.drawElements(gl.TRIANGLES, tile.indexCount, gl.UNSIGNED_INT, 0);
893
- }
894
-
895
- _generateTextureFromColormapData(gl, colormapData, channelType = 'IR', interpolate = true) {
896
- if (!colormapData || colormapData.length < 2) {
897
- console.log('Invalid colormap data, returning null');
898
- return null;
899
- }
900
-
901
- const width = 256;
902
- const height = 1;
903
- const data = new Uint8Array(width * height * 4);
904
-
905
- const range = RANGES[channelType] || RANGES.IR;
906
-
907
- const parsedStops =[];
908
- for (let i = 0; i < colormapData.length; i += 2) {
909
- const normVal = colormapData[i];
910
- const hex = colormapData[i + 1];
911
-
912
- const r = parseInt(hex.slice(1, 3), 16);
913
- const g = parseInt(hex.slice(3, 5), 16);
914
- const b = parseInt(hex.slice(5, 7), 16);
915
-
916
- let texCoord;
917
- let kelvin;
918
- let celsius;
919
-
920
- if (channelType === 'VIS') {
921
- texCoord = normVal / 100;
922
- kelvin = null;
923
- celsius = null;
924
- } else {
925
- const fraction = normVal / 100;
926
- kelvin = range.maxK - (fraction * (range.maxK - range.minK));
927
- celsius = kelvin - 273.15;
928
- texCoord = (range.maxK - kelvin) / (range.maxK - range.minK);
929
- }
930
- parsedStops.push({ texCoord, color: [r, g, b], normVal, kelvin, celsius });
931
- }
932
-
933
- parsedStops.sort((a, b) => a.texCoord - b.texCoord);
934
-
935
- for (let i = 0; i < width; i++) {
936
- const t = i / (width - 1);
937
-
938
- let r, g, b;
939
-
940
- if (interpolate) {
941
- let c1 = parsedStops[0];
942
- let c2 = parsedStops[parsedStops.length - 1];
943
-
944
- for (let j = 0; j < parsedStops.length - 1; j++) {
945
- if (t >= parsedStops[j].texCoord && t <= parsedStops[j + 1].texCoord) {
946
- c1 = parsedStops[j];
947
- c2 = parsedStops[j + 1];
948
- break;
949
- }
950
- }
951
-
952
- const segmentRange = c2.texCoord - c1.texCoord;
953
-
954
- if (segmentRange < 0.00001) {
955
- r = c1.color[0];
956
- g = c1.color[1];
957
- b = c1.color[2];
958
- } else {
959
- const f = (t - c1.texCoord) / segmentRange;
960
- r = Math.round(c1.color[0] + f * (c2.color[0] - c1.color[0]));
961
- g = Math.round(c1.color[1] + f * (c2.color[1] - c1.color[1]));
962
- b = Math.round(c1.color[2] + f * (c2.color[2] - c1.color[2]));
963
- }
964
- } else {
965
- let chosenStop = parsedStops[0];
966
-
967
- for (let j = 0; j < parsedStops.length; j++) {
968
- if (t >= parsedStops[j].texCoord) {
969
- chosenStop = parsedStops[j];
970
- } else {
971
- break;
972
- }
973
- }
974
-
975
- r = chosenStop.color[0];
976
- g = chosenStop.color[1];
977
- b = chosenStop.color[2];
978
- }
979
-
980
- const offset = i * 4;
981
- data[offset] = r;
982
- data[offset + 1] = g;
983
- data[offset + 2] = b;
984
- data[offset + 3] = 255;
985
- }
986
-
987
- const texture = gl.createTexture();
988
- gl.bindTexture(gl.TEXTURE_2D, texture);
989
- gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, data);
990
-
991
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
992
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
993
-
994
- const filterMode = interpolate ? gl.LINEAR : gl.NEAREST;
995
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, filterMode);
996
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filterMode);
997
-
998
- return texture;
999
- }
1000
- }
1
+ import { DEFAULT_BASIS_BASE_URL } from './defaultBasisBaseUrl.js';
2
+ import mapboxgl from 'mapbox-gl';
3
+ import proj4 from 'proj4';
4
+ import { createProgram } from './satelliteShader.js';
5
+ import {
6
+ DEFAULT_SATELLITE_LONGWAVE_IR_COLORMAP,
7
+ DEFAULT_SATELLITE_WATER_VAPOR_COLORMAP,
8
+ } from './satelliteDefaultColormaps.js';
9
+
10
+ const wgs84 = 'EPSG:4326';
11
+
12
+ /** Default GOES-East CONUS mesh bounds in geos coordinates (meters). */
13
+ const GOES_CONUS_EXTENT_M = [-3627020.7, 1583424.2, 1382521.8, 4588949.1];
14
+
15
+ /** Kelvin ranges for IR/WV colormap generation (matches aguacero-frontend utilityFunctions RANGES). */
16
+ const RANGES = {
17
+ IR: { minK: 180, maxK: 330, label: 'IR' },
18
+ WV: { minK: 183, maxK: 288, label: 'WV' },
19
+ VIS: { minK: 0, maxK: 1, label: 'VIS' },
20
+ };
21
+
22
+ export class SatelliteShaderManager {
23
+ constructor(workerPool, findBestFormatFn, options = {}) {
24
+ this.id = 'satellite-layer';
25
+ this.type = 'custom';
26
+ this.renderingMode = '2d';
27
+
28
+ this.workerPool = workerPool;
29
+ this.findBestFormat = findBestFormatFn;
30
+ this.basisBaseUrl = options.basisBaseUrl ?? DEFAULT_BASIS_BASE_URL;
31
+
32
+ this.frames = new globalThis.Map();
33
+ this.activeFrameKey = null;
34
+ this.targetFrameKey = null;
35
+
36
+ this.currentOpacity = 1.0;
37
+ this.isVisible = true;
38
+
39
+ this.repaintTimeout = null;
40
+
41
+ // Track the attrib locations used in the last render so onRemove can disable them,
42
+ // preventing GL state leakage that would break other layers (e.g. radar) after removal.
43
+ this._lastPosAttrib = -1;
44
+ this._lastTexCoordAttrib = -1;
45
+
46
+ /** Merged layer settings (opacity, colormap overrides, etc.). */
47
+ this.currentSettings = {};
48
+ }
49
+
50
+ onAdd(map, gl) {
51
+ this.map = map;
52
+ this.gl = gl;
53
+
54
+ this.initializeWorkers();
55
+
56
+ const vertexSource = `
57
+ attribute vec2 a_pos;
58
+ attribute vec2 a_texCoord;
59
+ uniform mat4 u_matrix;
60
+ uniform vec4 u_uv_transform;
61
+ varying vec2 v_texCoord;
62
+ void main() {
63
+ gl_Position = u_matrix * vec4(a_pos, 0.0, 1.0);
64
+ v_texCoord = a_texCoord * u_uv_transform.xy + u_uv_transform.zw;
65
+ }`;
66
+
67
+ const fragmentSource = `
68
+ precision mediump float;
69
+ varying vec2 v_texCoord;
70
+ uniform sampler2D u_texture;
71
+ uniform sampler2D u_colormap;
72
+ uniform bool u_is_true_color;
73
+ uniform bool u_use_colormap;
74
+ uniform float u_opacity;
75
+
76
+ void main() {
77
+ vec4 texColor = texture2D(u_texture, v_texCoord);
78
+ vec3 rawColor = texColor.rgb;
79
+
80
+ if (u_is_true_color) {
81
+ gl_FragColor = vec4(rawColor, texColor.a * u_opacity);
82
+ }
83
+ else {
84
+ float luminance = rawColor.r;
85
+
86
+ if (u_use_colormap) {
87
+ float intensity = clamp(luminance, 0.0, 1.0);
88
+ vec3 colorMapped = texture2D(u_colormap, vec2(intensity, 0.5)).rgb;
89
+ gl_FragColor = vec4(colorMapped, u_opacity);
90
+ }
91
+ else {
92
+ float val = luminance * 1.7;
93
+
94
+ // if (val < 0.001) {
95
+ // discard;
96
+ // }
97
+
98
+ val = clamp(val, 0.0, 1.0);
99
+ gl_FragColor = vec4(val, val, val, u_opacity);
100
+ }
101
+ }
102
+ }`;
103
+
104
+ this.program = createProgram(gl, vertexSource, fragmentSource);
105
+
106
+ this.isTrueColorLocation = gl.getUniformLocation(this.program, "u_is_true_color");
107
+ this.useColormapLocation = gl.getUniformLocation(this.program, "u_use_colormap");
108
+ this.colormapSamplerLocation = gl.getUniformLocation(this.program, "u_colormap");
109
+ this.uvTransformLocation = gl.getUniformLocation(this.program, "u_uv_transform");
110
+ this.interpolateColormapLocation = gl.getUniformLocation(this.program, "u_interpolate_colormap");
111
+ this.opacityLocation = gl.getUniformLocation(this.program, "u_opacity");
112
+
113
+ this.irColormapTexture = this._generateTextureFromColormapData(
114
+ gl,
115
+ DEFAULT_SATELLITE_LONGWAVE_IR_COLORMAP,
116
+ 'IR',
117
+ true
118
+ );
119
+ this.wvColormapTexture = this._generateTextureFromColormapData(
120
+ gl,
121
+ DEFAULT_SATELLITE_WATER_VAPOR_COLORMAP,
122
+ 'WV',
123
+ true
124
+ );
125
+ }
126
+
127
+ onRemove() {
128
+ if (this.repaintTimeout) {
129
+ cancelAnimationFrame(this.repaintTimeout);
130
+ this.repaintTimeout = null;
131
+ }
132
+
133
+ // Disable any vertex attribute arrays we may have left enabled during the last render.
134
+ // This is critical: leaving enabled attrib arrays that reference deleted buffers causes
135
+ // INVALID_OPERATION on subsequent drawArrays/drawElements calls from other layers (e.g. radar).
136
+ if (this.gl) {
137
+ if (this._lastPosAttrib >= 0) {
138
+ this.gl.disableVertexAttribArray(this._lastPosAttrib);
139
+ this._lastPosAttrib = -1;
140
+ }
141
+ if (this._lastTexCoordAttrib >= 0) {
142
+ this.gl.disableVertexAttribArray(this._lastTexCoordAttrib);
143
+ this._lastTexCoordAttrib = -1;
144
+ }
145
+ // Unbind buffers so nothing else accidentally reads stale bindings.
146
+ this.gl.bindBuffer(this.gl.ARRAY_BUFFER, null);
147
+ this.gl.bindBuffer(this.gl.ELEMENT_ARRAY_BUFFER, null);
148
+ }
149
+
150
+ for (const frame of this.frames.values()) {
151
+ if (frame.tile) {
152
+ this._destroyTile(frame.tile);
153
+ }
154
+ }
155
+ this.frames.clear();
156
+
157
+ if (this.gl && this.program) {
158
+ this.gl.deleteProgram(this.program);
159
+ }
160
+ this.program = null;
161
+ if (this.gl) {
162
+ if (this.irColormapTexture) this.gl.deleteTexture(this.irColormapTexture);
163
+ if (this.wvColormapTexture) this.gl.deleteTexture(this.wvColormapTexture);
164
+ if (this.userColormapTexture) this.gl.deleteTexture(this.userColormapTexture);
165
+ }
166
+ this.irColormapTexture = null;
167
+ this.wvColormapTexture = null;
168
+ this.userColormapTexture = null;
169
+ this._destroyReadoutPipeline();
170
+ }
171
+
172
+ _parseProjectionFromFileName(fileName) {
173
+ const match = fileName.match(/_proj_([-\d\.eE]+)_([-\d\.eE]+)_([-\d\.eE]+)/);
174
+ if (match) {
175
+ const scale = parseFloat(match[3]);
176
+ return {
177
+ h: 35786023.0,
178
+ lon0: -75.0,
179
+ xOff: parseFloat(match[1]),
180
+ yOff: parseFloat(match[2]),
181
+ xScale: scale,
182
+ yScale: -scale
183
+ };
184
+ }
185
+ return null;
186
+ }
187
+
188
+ addFrame(supercompressedBuffer, frameKey, fileName) {
189
+ if (this.frames.has(frameKey)) {
190
+ return;
191
+ }
192
+
193
+ let meshSubdivisions = 50;
194
+ let frameProjString = "+proj=geos +h=35786023.0 +lon_0=-75.0 +sweep=x +no_defs";
195
+ let frameExtent = [...GOES_CONUS_EXTENT_M];
196
+
197
+ let isMesoscale = false;
198
+
199
+ if (fileName.startsWith('F_') || fileName.includes('MCMIPF') || fileName.includes('CMIPF')) {
200
+ frameProjString = "+proj=geos +h=35786023.0 +lon_0=-75.0 +sweep=x +no_defs";
201
+ frameExtent =[-5434894.885056, -5434894.885056, 5434894.885056, 5434894.885056];
202
+ meshSubdivisions = 100;
203
+ } else if (fileName.startsWith('M1_') || fileName.startsWith('M2_') || fileName.includes('MCMIPM') || fileName.includes('CMIPM')) {
204
+ isMesoscale = true;
205
+ meshSubdivisions = 30;
206
+ }
207
+
208
+ const customProj = this._parseProjectionFromFileName(fileName);
209
+
210
+ if (isMesoscale && !customProj) {
211
+ console.warn(
212
+ '[SatelliteLayer] Mesoscale file name is missing _proj_xOff_yOff_scale (see single-band M1/M2 keys). ' +
213
+ 'RGB pipeline should use the same segment as C## products for correct placement:',
214
+ fileName
215
+ );
216
+ }
217
+
218
+ const isTrueColor = fileName.includes('_truecolor_') || fileName.includes('_geocolor_') || fileName.includes('_firetemperature_') || fileName.includes('_dust_') || fileName.includes('_simplewatervapor_') || fileName.includes('_ntmicro_') || fileName.includes('_daycloudphase_') || fileName.includes('_daylandcloudfire_') || fileName.includes('_airmass_') || fileName.includes('_sandwich_');
219
+
220
+ let colormapType = 'none';
221
+ if (fileName.includes('C13') || fileName.includes('C14') || fileName.includes('C15') || fileName.includes('C16')) {
222
+ colormapType = 'ir';
223
+ } else if (fileName.includes('C08') || fileName.includes('C09') || fileName.includes('C10')) {
224
+ colormapType = 'wv';
225
+ }
226
+
227
+ const brightnessMultiplier = 1.0;
228
+
229
+ const frame = {
230
+ supercompressedBuffer,
231
+ isTrueColor,
232
+ colormapType,
233
+ brightnessMultiplier,
234
+ ktx2Metadata: null,
235
+ fullTranscodedData: null,
236
+ tile: null,
237
+ ktx2MetadataPromise: null,
238
+ customProj,
239
+ isMesoscale,
240
+ meshSubdivisions,
241
+ projString: frameProjString,
242
+ extent: frameExtent,
243
+ sourceFileName: fileName,
244
+ };
245
+
246
+ let resolvePromise;
247
+ frame.ktx2MetadataPromise = new Promise(resolve => { resolvePromise = resolve; });
248
+ frame.ktx2MetadataPromise.resolve = resolvePromise;
249
+
250
+ this.frames.set(frameKey, frame);
251
+
252
+ if (this.targetFrameKey === frameKey) {
253
+ this.setActiveFrame(frameKey);
254
+ } else if (this.targetFrameKey === null && this.activeFrameKey === null) {
255
+ this.setActiveFrame(frameKey);
256
+ }
257
+
258
+ this.workerPool.execute({
259
+ type: 'INITIAL_LOAD',
260
+ ktx2Buffer: supercompressedBuffer,
261
+ downsampleRate: 1,
262
+ frameKey,
263
+ basisBaseUrl: this.basisBaseUrl,
264
+ })
265
+ .then(result => {
266
+ if (!this.frames.has(frameKey)) {
267
+ return;
268
+ }
269
+
270
+ frame.supercompressedBuffer = null;
271
+
272
+ if (result.error || !result.ktx2Metadata) {
273
+ console.warn(`[ShaderManager] Worker failed for frame ${frameKey}:`, result.error);
274
+ frame.ktx2Metadata = null;
275
+ frame.ktx2MetadataPromise.resolve(null);
276
+ return;
277
+ }
278
+
279
+ const { ktx2Metadata, fullTranscodedData } = result;
280
+ const { fullWidth, fullHeight } = ktx2Metadata;
281
+
282
+ if (frame.customProj) {
283
+ const { h, lon0, xOff, yOff, xScale, yScale } = frame.customProj;
284
+ frame.projString = `+proj=geos +h=${h} +lon_0=${lon0} +sweep=x +no_defs`;
285
+
286
+ const xMinRad = xOff;
287
+ const xMaxRad = xOff + (fullWidth * xScale);
288
+ const yMinRad = yOff + (fullHeight * yScale);
289
+ const yMaxRad = yOff;
290
+
291
+ frame.extent =[
292
+ Math.min(xMinRad, xMaxRad) * h,
293
+ Math.min(yMinRad, yMaxRad) * h,
294
+ Math.max(xMinRad, xMaxRad) * h,
295
+ Math.max(yMinRad, yMaxRad) * h
296
+ ];
297
+ }
298
+
299
+ const s3tc = this.gl.getExtension('WEBGL_compressed_texture_s3tc');
300
+ const etc1 = this.gl.getExtension('WEBGL_compressed_texture_etc1');
301
+ const astc = this.gl.getExtension('WEBGL_compressed_texture_astc');
302
+ const bptc = this.gl.getExtension('EXT_texture_compression_bptc');
303
+
304
+ const formatInfo = ktx2Metadata.formatInfo;
305
+ switch (formatInfo.webglFormatName) {
306
+ case 'COMPRESSED_RGBA_ASTC_4x4_KHR':
307
+ if (astc) formatInfo.webglFormatEnum = astc.COMPRESSED_RGBA_ASTC_4x4_KHR;
308
+ break;
309
+ case 'COMPRESSED_RGBA_BPTC_UNORM_EXT':
310
+ if (bptc) formatInfo.webglFormatEnum = bptc.COMPRESSED_RGBA_BPTC_UNORM_EXT;
311
+ break;
312
+ case 'COMPRESSED_RGBA_S3TC_DXT5_EXT':
313
+ if (s3tc) formatInfo.webglFormatEnum = s3tc.COMPRESSED_RGBA_S3TC_DXT5_EXT;
314
+ break;
315
+ case 'COMPRESSED_RGB_S3TC_DXT1_EXT':
316
+ if (s3tc) formatInfo.webglFormatEnum = s3tc.COMPRESSED_RGB_S3TC_DXT1_EXT;
317
+ break;
318
+ case 'COMPRESSED_RGB_ETC1_WEBGL':
319
+ if (etc1) formatInfo.webglFormatEnum = etc1.COMPRESSED_RGB_ETC1_WEBGL;
320
+ break;
321
+ case 'RGBA':
322
+ formatInfo.webglFormatEnum = this.gl.RGBA;
323
+ formatInfo.webglTypeEnum = this.gl.UNSIGNED_BYTE;
324
+ break;
325
+ default:
326
+ console.error("Could not resolve WebGL format from worker string:", formatInfo.webglFormatName);
327
+ frame.ktx2Metadata = null;
328
+ frame.ktx2MetadataPromise.resolve(null);
329
+ return;
330
+ }
331
+
332
+ if (!formatInfo.webglFormatEnum) {
333
+ console.error(`Format ${formatInfo.webglFormatName} is not supported by this GPU.`);
334
+ frame.ktx2Metadata = null;
335
+ frame.ktx2MetadataPromise.resolve(null);
336
+ return;
337
+ }
338
+
339
+ frame.ktx2Metadata = ktx2Metadata;
340
+ frame.fullTranscodedData = fullTranscodedData;
341
+ frame.ktx2MetadataPromise.resolve(ktx2Metadata);
342
+
343
+ const texture = this._createTexture(fullTranscodedData, ktx2Metadata);
344
+ if (texture) {
345
+ const { vertices, indices } = this._createMeshGeometry(
346
+ frame.projString,
347
+ frame.extent,
348
+ frame.meshSubdivisions
349
+ );
350
+
351
+ const vertexBuffer = this.gl.createBuffer();
352
+ this.gl.bindBuffer(this.gl.ARRAY_BUFFER, vertexBuffer);
353
+ this.gl.bufferData(this.gl.ARRAY_BUFFER, vertices, this.gl.STATIC_DRAW);
354
+ const indexBuffer = this.gl.createBuffer();
355
+ this.gl.bindBuffer(this.gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
356
+ this.gl.bufferData(this.gl.ELEMENT_ARRAY_BUFFER, indices, this.gl.STATIC_DRAW);
357
+
358
+ frame.tile = {
359
+ vertexBuffer,
360
+ indexBuffer,
361
+ texture: texture,
362
+ indexCount: indices.length,
363
+ uvTransform:[1.0, 1.0, 0.0, 0.0]
364
+ };
365
+ }
366
+
367
+ this.scheduleRepaint();
368
+ })
369
+ .catch((error) => {
370
+ if (!this.frames.has(frameKey)) {
371
+ return;
372
+ }
373
+ console.error(`[ShaderManager] Worker promise REJECTED for INITIAL_LOAD of frame ${frameKey}:`, error);
374
+ });
375
+ }
376
+
377
+ _ensureReadoutPipeline() {
378
+ const gl = this.gl;
379
+ if (!gl || this._readoutProgram) return;
380
+
381
+ const vs = `attribute vec2 a_pos; void main(){ gl_Position = vec4(a_pos,0.0,1.0); }`;
382
+ const fs = `precision mediump float;
383
+ uniform sampler2D u_tex;
384
+ uniform vec2 u_uv;
385
+ void main(){
386
+ float L = texture2D(u_tex, u_uv).r;
387
+ gl_FragColor = vec4(L, L, L, 1.0);
388
+ }`;
389
+ this._readoutProgram = createProgram(gl, vs, fs);
390
+ this._readoutUuv = gl.getUniformLocation(this._readoutProgram, 'u_uv');
391
+ this._readoutUtex = gl.getUniformLocation(this._readoutProgram, 'u_tex');
392
+
393
+ this._readoutTriBuffer = gl.createBuffer();
394
+ gl.bindBuffer(gl.ARRAY_BUFFER, this._readoutTriBuffer);
395
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
396
+ gl.bindBuffer(gl.ARRAY_BUFFER, null);
397
+
398
+ this._readoutTex = gl.createTexture();
399
+ gl.bindTexture(gl.TEXTURE_2D, this._readoutTex);
400
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
401
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
402
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
403
+
404
+ this._readoutFbo = gl.createFramebuffer();
405
+ gl.bindFramebuffer(gl.FRAMEBUFFER, this._readoutFbo);
406
+ gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, this._readoutTex, 0);
407
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
408
+ gl.bindTexture(gl.TEXTURE_2D, null);
409
+ }
410
+
411
+ _destroyReadoutPipeline() {
412
+ const gl = this.gl;
413
+ if (!gl) return;
414
+ if (this._readoutProgram) {
415
+ gl.deleteProgram(this._readoutProgram);
416
+ this._readoutProgram = null;
417
+ }
418
+ if (this._readoutTriBuffer) {
419
+ gl.deleteBuffer(this._readoutTriBuffer);
420
+ this._readoutTriBuffer = null;
421
+ }
422
+ if (this._readoutTex) {
423
+ gl.deleteTexture(this._readoutTex);
424
+ this._readoutTex = null;
425
+ }
426
+ if (this._readoutFbo) {
427
+ gl.deleteFramebuffer(this._readoutFbo);
428
+ this._readoutFbo = null;
429
+ }
430
+ }
431
+
432
+ /** Sample active frame texture at UV (0–1). Uses a 1×1 FBO + readPixels so compressed GPU textures work. */
433
+ _sampleLuminanceAtUv(tu, tv) {
434
+ const gl = this.gl;
435
+ if (!gl || !this._readoutProgram) return null;
436
+ const frame = this.frames.get(this.activeFrameKey);
437
+ if (!frame?.tile?.texture) return null;
438
+
439
+ const prevFb = gl.getParameter(gl.FRAMEBUFFER_BINDING);
440
+ const prevVp = gl.getParameter(gl.VIEWPORT);
441
+ const prevProg = gl.getParameter(gl.CURRENT_PROGRAM);
442
+ const prevActiveUnit = gl.getParameter(gl.ACTIVE_TEXTURE);
443
+ gl.activeTexture(gl.TEXTURE0);
444
+ const prevTex0 = gl.getParameter(gl.TEXTURE_BINDING_2D);
445
+
446
+ try {
447
+ gl.bindFramebuffer(gl.FRAMEBUFFER, this._readoutFbo);
448
+ gl.viewport(0, 0, 1, 1);
449
+ gl.useProgram(this._readoutProgram);
450
+ gl.disable(gl.DEPTH_TEST);
451
+ gl.disable(gl.BLEND);
452
+ gl.colorMask(true, true, true, true);
453
+ gl.clearColor(0, 0, 0, 0);
454
+ gl.clear(gl.COLOR_BUFFER_BIT);
455
+
456
+ gl.activeTexture(gl.TEXTURE0);
457
+ gl.bindTexture(gl.TEXTURE_2D, frame.tile.texture);
458
+ gl.uniform1i(this._readoutUtex, 0);
459
+ gl.uniform2f(this._readoutUuv, tu, tv);
460
+
461
+ const posLoc = gl.getAttribLocation(this._readoutProgram, 'a_pos');
462
+ gl.bindBuffer(gl.ARRAY_BUFFER, this._readoutTriBuffer);
463
+ gl.enableVertexAttribArray(posLoc);
464
+ gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 0, 0);
465
+ gl.drawArrays(gl.TRIANGLES, 0, 3);
466
+ gl.disableVertexAttribArray(posLoc);
467
+ gl.bindBuffer(gl.ARRAY_BUFFER, null);
468
+
469
+ const pix = new Uint8Array(4);
470
+ gl.readPixels(0, 0, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, pix);
471
+ return pix[0] / 255;
472
+ } finally {
473
+ gl.bindFramebuffer(gl.FRAMEBUFFER, prevFb);
474
+ gl.viewport(prevVp[0], prevVp[1], prevVp[2], prevVp[3]);
475
+ gl.useProgram(prevProg);
476
+ gl.activeTexture(gl.TEXTURE0);
477
+ gl.bindTexture(gl.TEXTURE_2D, prevTex0);
478
+ gl.activeTexture(prevActiveUnit);
479
+ }
480
+ }
481
+
482
+ getValueAtLatLon(lat, lon) {
483
+ if (!this.gl || !this.activeFrameKey || !this.frames.has(this.activeFrameKey)) return null;
484
+ if (!this.isVisible || (this.currentOpacity ?? 0) <= 0) return null;
485
+
486
+ const frame = this.frames.get(this.activeFrameKey);
487
+ if (!frame.tile?.texture || !frame.ktx2Metadata) return null;
488
+
489
+ let projX;
490
+ let projY;
491
+ try {
492
+ [projX, projY] = proj4(wgs84, frame.projString, [lon, lat]);
493
+ } catch {
494
+ return null;
495
+ }
496
+
497
+ const [xmin, ymin, xmax, ymax] = frame.extent;
498
+ if (!Number.isFinite(projX) || !Number.isFinite(projY)) return null;
499
+
500
+ const pad = 1e-3 * Math.max(xmax - xmin, ymax - ymin, 1);
501
+ if (projX < xmin - pad || projX > xmax + pad || projY < ymin - pad || projY > ymax + pad) return null;
502
+
503
+ let u = (projX - xmin) / (xmax - xmin);
504
+ let v = (ymax - projY) / (ymax - ymin);
505
+ u = Math.min(1, Math.max(0, u));
506
+ v = Math.min(1, Math.max(0, v));
507
+
508
+ const t = frame.tile.uvTransform || [1, 1, 0, 0];
509
+ const tu = u * t[0] + t[2];
510
+ const tv = v * t[1] + t[3];
511
+ if (tu < -1e-3 || tu > 1 + 1e-3 || tv < -1e-3 || tv > 1 + 1e-3) return null;
512
+
513
+ this._ensureReadoutPipeline();
514
+ const lum = this._sampleLuminanceAtUv(tu, tv);
515
+ if (lum == null || !Number.isFinite(lum)) return null;
516
+
517
+ return {
518
+ isTrueColor: frame.isTrueColor,
519
+ colormapType: frame.colormapType,
520
+ luminance: lum,
521
+ sourceFileName: frame.sourceFileName,
522
+ };
523
+ }
524
+
525
+ removeFrame(frameKey) {
526
+ if (!this.frames.has(frameKey)) {
527
+ return;
528
+ }
529
+
530
+ const frame = this.frames.get(frameKey);
531
+
532
+ if (frame.tile) {
533
+ this._destroyTile(frame.tile);
534
+ }
535
+
536
+ this.workerPool.broadcast({
537
+ type: 'CLEANUP_FRAME',
538
+ frameKey: frameKey
539
+ });
540
+
541
+ this.frames.delete(frameKey);
542
+
543
+ if (this.activeFrameKey === frameKey) {
544
+ this.activeFrameKey = null;
545
+ }
546
+
547
+ this.map.triggerRepaint();
548
+ }
549
+
550
+ _updateTextureFiltering() {
551
+ const gl = this.gl;
552
+ const filterMode = this.currentSmoothing === 1 ? gl.LINEAR : gl.NEAREST;
553
+
554
+ for (const frame of this.frames.values()) {
555
+ if (frame.tile && frame.tile.texture) {
556
+ gl.bindTexture(gl.TEXTURE_2D, frame.tile.texture);
557
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, filterMode);
558
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filterMode);
559
+ }
560
+ }
561
+
562
+ gl.bindTexture(gl.TEXTURE_2D, null);
563
+ }
564
+
565
+ updateLayerSettings(settings) {
566
+ this.currentSettings = { ...this.currentSettings, ...settings };
567
+
568
+ const s = this.currentSettings;
569
+ const oldSmoothing = this.currentSmoothing;
570
+
571
+ if (s.fillSmoothing !== undefined) {
572
+ this.currentSmoothing = s.fillSmoothing;
573
+ }
574
+
575
+ if (s.satelliteOpacity !== undefined) {
576
+ this.currentOpacity = s.satelliteOpacity;
577
+ }
578
+
579
+ if (s.satelliteVisibility !== undefined) {
580
+ this.isVisible = s.satelliteVisibility;
581
+ }
582
+
583
+ if (s.interpolateColormap !== undefined) {
584
+ this.interpolateColormap = s.interpolateColormap;
585
+ }
586
+
587
+ if (oldSmoothing !== this.currentSmoothing) {
588
+ this._updateTextureFiltering();
589
+ }
590
+
591
+ this._applySatelliteColormapFromSettings();
592
+
593
+ this.map.triggerRepaint();
594
+ }
595
+
596
+ initializeWorkers() {
597
+ const gl = this.gl;
598
+ const getExtension = (name) => gl.getExtension(name) || gl.getExtension(`WEBKIT_${name}`) || gl.getExtension(`MOZ_${name}`);
599
+
600
+ const supportedFormats = {
601
+ s3tc: !!getExtension('WEBGL_compressed_texture_s3tc'),
602
+ etc1: !!getExtension('WEBGL_compressed_texture_etc1'),
603
+ astc: !!getExtension('WEBGL_compressed_texture_astc'),
604
+ bc7: !!getExtension('EXT_texture_compression_bptc'),
605
+ rgtc: !!getExtension('EXT_texture_compression_rgtc'),
606
+ };
607
+
608
+ const msg = {
609
+ type: 'INIT_WORKER',
610
+ supportedFormats,
611
+ basisBaseUrl: this.basisBaseUrl,
612
+ };
613
+
614
+ // Every Worker instance must receive INIT (including replacements after errors). broadcast() alone
615
+ // missed workers created in WorkerPool._replaceWorker → Basis saw undefined transcoder enums.
616
+ this.workerPool.setWorkerBootstrap((worker) => {
617
+ worker.postMessage(msg);
618
+ });
619
+ }
620
+
621
+ scheduleRepaint() {
622
+ if (this.repaintTimeout) return;
623
+ this.repaintTimeout = requestAnimationFrame(() => {
624
+ this.map.triggerRepaint();
625
+ this.repaintTimeout = null;
626
+ });
627
+ }
628
+
629
+ setActiveFrame(frameKey) {
630
+ this.targetFrameKey = frameKey;
631
+
632
+ if (this.activeFrameKey === frameKey) {
633
+ return;
634
+ }
635
+
636
+ if (!this.frames.has(frameKey)) {
637
+ // Avoid showing a stale frame while the new timestep / product is still loading or decoding.
638
+ this.activeFrameKey = null;
639
+ this.scheduleRepaint();
640
+ return;
641
+ }
642
+ this.activeFrameKey = frameKey;
643
+
644
+ this._applySatelliteColormapFromSettings();
645
+
646
+ this.scheduleRepaint();
647
+ }
648
+
649
+ /**
650
+ * Builds `userColormapTexture` from `currentSettings` and the active frame’s IR vs WV kind.
651
+ * Prefer `satelliteColormapIR` / `satelliteColormapWV` when set; otherwise `satelliteColormap` (legacy one array).
652
+ */
653
+ _applySatelliteColormapFromSettings() {
654
+ const gl = this.gl;
655
+ if (!gl) return;
656
+
657
+ const settings = this.currentSettings || {};
658
+ const activeFrame = this.activeFrameKey ? this.frames.get(this.activeFrameKey) : null;
659
+ const ct = activeFrame?.colormapType;
660
+
661
+ let stops = null;
662
+ let channelType = 'IR';
663
+
664
+ if (ct === 'wv' && settings.satelliteColormapWV?.length > 0) {
665
+ stops = settings.satelliteColormapWV;
666
+ channelType = 'WV';
667
+ } else if (ct === 'ir' && settings.satelliteColormapIR?.length > 0) {
668
+ stops = settings.satelliteColormapIR;
669
+ channelType = 'IR';
670
+ } else if (settings.satelliteColormap?.length > 0) {
671
+ stops = settings.satelliteColormap;
672
+ channelType = ct === 'wv' ? 'WV' : 'IR';
673
+ }
674
+
675
+ const shouldInterpolate = settings.interpolateColormap !== false;
676
+ const cacheKey = JSON.stringify({ stops, channelType, iv: shouldInterpolate, ct });
677
+
678
+ if (cacheKey === this.lastColormapCacheKey) {
679
+ return;
680
+ }
681
+ this.lastColormapCacheKey = cacheKey;
682
+
683
+ if (this.userColormapTexture) {
684
+ gl.deleteTexture(this.userColormapTexture);
685
+ this.userColormapTexture = null;
686
+ }
687
+
688
+ if (stops?.length) {
689
+ this.userColormapTexture = this._generateTextureFromColormapData(
690
+ gl,
691
+ stops,
692
+ channelType,
693
+ shouldInterpolate
694
+ );
695
+ }
696
+ }
697
+
698
+ _createTexture(data, ktx2Metadata) {
699
+ const gl = this.gl;
700
+ const { fullWidth, fullHeight, formatInfo } = ktx2Metadata;
701
+
702
+ const texture = gl.createTexture();
703
+ gl.bindTexture(gl.TEXTURE_2D, texture);
704
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
705
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
706
+ const filterMode = this.currentSmoothing === 1 ? gl.LINEAR : gl.NEAREST;
707
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, filterMode);
708
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filterMode);
709
+
710
+ if (formatInfo.isCompressed) {
711
+ gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
712
+ gl.compressedTexImage2D(
713
+ gl.TEXTURE_2D,
714
+ 0,
715
+ formatInfo.webglFormatEnum,
716
+ fullWidth,
717
+ fullHeight,
718
+ 0,
719
+ data
720
+ );
721
+ } else {
722
+ gl.pixelStorei(gl.UNPACK_ALIGNMENT, 4);
723
+ gl.texImage2D(
724
+ gl.TEXTURE_2D,
725
+ 0,
726
+ formatInfo.webglFormatEnum,
727
+ fullWidth,
728
+ fullHeight,
729
+ 0,
730
+ formatInfo.webglFormatEnum,
731
+ formatInfo.webglTypeEnum,
732
+ data
733
+ );
734
+ }
735
+
736
+ gl.bindTexture(gl.TEXTURE_2D, null);
737
+ return texture;
738
+ }
739
+
740
+ _destroyTile(tile) {
741
+ if (!tile) return;
742
+
743
+ if (tile.texture) {
744
+ this.gl.deleteTexture(tile.texture);
745
+ }
746
+ if (tile.vertexBuffer) {
747
+ this.gl.deleteBuffer(tile.vertexBuffer);
748
+ }
749
+ if (tile.indexBuffer) {
750
+ this.gl.deleteBuffer(tile.indexBuffer);
751
+ }
752
+ }
753
+
754
+ _createMeshGeometry(projectionString, extent, subdivisions = 50) {
755
+ const[x_min, y_min, x_max, y_max] = extent;
756
+ const vertices = [];
757
+ const indices =[];
758
+
759
+ for (let row = 0; row <= subdivisions; row++) {
760
+ for (let col = 0; col <= subdivisions; col++) {
761
+ const u = col / subdivisions;
762
+ const v = row / subdivisions;
763
+
764
+ const proj_x = x_min + u * (x_max - x_min);
765
+ const proj_y = y_max - v * (y_max - y_min);
766
+
767
+ try {
768
+ const [lon, lat] = proj4(projectionString, wgs84,[proj_x, proj_y]);
769
+ if (Math.abs(lon) > 180 || Math.abs(lat) > 90) throw new Error("Out of bounds");
770
+ const mercator = mapboxgl.MercatorCoordinate.fromLngLat({ lon, lat }, 0);
771
+ vertices.push(mercator.x, mercator.y, u, v);
772
+ } catch (e) {
773
+ vertices.push(NaN, NaN, NaN, NaN);
774
+ }
775
+ }
776
+ }
777
+
778
+ for (let row = 0; row < subdivisions; row++) {
779
+ for (let col = 0; col < subdivisions; col++) {
780
+ const i00 = row * (subdivisions + 1) + col;
781
+ const i10 = i00 + 1;
782
+ const i01 = (row + 1) * (subdivisions + 1) + col;
783
+ const i11 = i01 + 1;
784
+
785
+ if (!isNaN(vertices[i00 * 4]) && !isNaN(vertices[i10 * 4]) && !isNaN(vertices[i01 * 4]) && !isNaN(vertices[i11 * 4])) {
786
+ indices.push(i00, i01, i10);
787
+ indices.push(i10, i01, i11);
788
+ }
789
+ }
790
+ }
791
+
792
+ return {
793
+ vertices: new Float32Array(vertices),
794
+ indices: new Uint32Array(indices)
795
+ };
796
+ }
797
+
798
+ render(gl, matrix) {
799
+ if (!this.activeFrameKey || !this.frames.has(this.activeFrameKey)) {
800
+ return;
801
+ }
802
+
803
+ const activeFrame = this.frames.get(this.activeFrameKey);
804
+
805
+ if (!activeFrame.tile) {
806
+ return;
807
+ }
808
+
809
+ const effectiveOpacity = this.isVisible ? (this.currentOpacity ?? 1.0) : 0.0;
810
+
811
+ if (effectiveOpacity === 0) {
812
+ return;
813
+ }
814
+
815
+ gl.useProgram(this.program);
816
+ gl.uniformMatrix4fv(gl.getUniformLocation(this.program, "u_matrix"), false, matrix);
817
+
818
+ gl.uniform1i(this.isTrueColorLocation, activeFrame.isTrueColor ? 1 : 0);
819
+
820
+ let useColormap = activeFrame.colormapType !== 'none';
821
+
822
+ if (this.userColormapTexture) {
823
+ gl.activeTexture(gl.TEXTURE1);
824
+ gl.bindTexture(gl.TEXTURE_2D, this.userColormapTexture);
825
+ gl.uniform1i(this.colormapSamplerLocation, 1);
826
+ useColormap = true;
827
+ } else if (activeFrame.colormapType !== 'none') {
828
+ gl.activeTexture(gl.TEXTURE1);
829
+ if (activeFrame.colormapType === 'wv') {
830
+ gl.bindTexture(gl.TEXTURE_2D, this.wvColormapTexture);
831
+ } else {
832
+ gl.bindTexture(gl.TEXTURE_2D, this.irColormapTexture);
833
+ }
834
+ gl.uniform1i(this.colormapSamplerLocation, 1);
835
+ }
836
+
837
+ gl.uniform1i(this.useColormapLocation, useColormap ? 1 : 0);
838
+ gl.uniform1i(this.isTrueColorLocation, activeFrame.isTrueColor ? 1 : 0);
839
+
840
+ const shouldInterpolate = this.currentSettings?.interpolateColormap !== false;
841
+ gl.uniform1i(this.interpolateColormapLocation, shouldInterpolate ? 1 : 0);
842
+
843
+ const posAttrib = gl.getAttribLocation(this.program, "a_pos");
844
+ const texCoordAttrib = gl.getAttribLocation(this.program, "a_texCoord");
845
+
846
+ // Remember these for onRemove cleanup in case the layer is torn down before next render.
847
+ this._lastPosAttrib = posAttrib;
848
+ this._lastTexCoordAttrib = texCoordAttrib;
849
+
850
+ gl.enable(gl.BLEND);
851
+ gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
852
+ gl.disable(gl.DEPTH_TEST);
853
+
854
+ this.drawTile(activeFrame.tile, posAttrib, texCoordAttrib, effectiveOpacity);
855
+
856
+ // Restore vertex attribute array state. Mapbox GL does NOT automatically reset these
857
+ // between custom layer renders, so leaving them enabled causes INVALID_OPERATION when
858
+ // the layer is removed (buffers deleted) and another layer (e.g. radar) calls drawArrays.
859
+ gl.disableVertexAttribArray(posAttrib);
860
+ gl.disableVertexAttribArray(texCoordAttrib);
861
+
862
+ // Unbind buffers and textures to leave GL in a clean state for subsequent layers.
863
+ gl.bindBuffer(gl.ARRAY_BUFFER, null);
864
+ gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);
865
+ gl.activeTexture(gl.TEXTURE1);
866
+ gl.bindTexture(gl.TEXTURE_2D, null);
867
+ gl.activeTexture(gl.TEXTURE0);
868
+ gl.bindTexture(gl.TEXTURE_2D, null);
869
+ }
870
+
871
+ drawTile(tile, posAttrib, texCoordAttrib, opacity = 1.0) {
872
+ const gl = this.gl;
873
+ gl.bindBuffer(gl.ARRAY_BUFFER, tile.vertexBuffer);
874
+ gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, tile.indexBuffer);
875
+
876
+ gl.enableVertexAttribArray(posAttrib);
877
+ gl.vertexAttribPointer(posAttrib, 2, gl.FLOAT, false, 16, 0);
878
+
879
+ gl.enableVertexAttribArray(texCoordAttrib);
880
+ gl.vertexAttribPointer(texCoordAttrib, 2, gl.FLOAT, false, 16, 8);
881
+
882
+ gl.activeTexture(gl.TEXTURE0);
883
+ gl.bindTexture(gl.TEXTURE_2D, tile.texture);
884
+ gl.uniform1i(gl.getUniformLocation(this.program, "u_texture"), 0);
885
+
886
+ const transform = tile.uvTransform ||[1, 1, 0, 0];
887
+ gl.uniform4fv(this.uvTransformLocation, transform);
888
+
889
+ const opacityLocation = gl.getUniformLocation(this.program, "u_opacity");
890
+ gl.uniform1f(opacityLocation, opacity);
891
+
892
+ gl.drawElements(gl.TRIANGLES, tile.indexCount, gl.UNSIGNED_INT, 0);
893
+ }
894
+
895
+ _generateTextureFromColormapData(gl, colormapData, channelType = 'IR', interpolate = true) {
896
+ if (!colormapData || colormapData.length < 2) {
897
+ console.log('Invalid colormap data, returning null');
898
+ return null;
899
+ }
900
+
901
+ const width = 256;
902
+ const height = 1;
903
+ const data = new Uint8Array(width * height * 4);
904
+
905
+ const range = RANGES[channelType] || RANGES.IR;
906
+
907
+ const parsedStops =[];
908
+ for (let i = 0; i < colormapData.length; i += 2) {
909
+ const normVal = colormapData[i];
910
+ const hex = colormapData[i + 1];
911
+
912
+ const r = parseInt(hex.slice(1, 3), 16);
913
+ const g = parseInt(hex.slice(3, 5), 16);
914
+ const b = parseInt(hex.slice(5, 7), 16);
915
+
916
+ let texCoord;
917
+ let kelvin;
918
+ let celsius;
919
+
920
+ if (channelType === 'VIS') {
921
+ texCoord = normVal / 100;
922
+ kelvin = null;
923
+ celsius = null;
924
+ } else {
925
+ const fraction = normVal / 100;
926
+ kelvin = range.maxK - (fraction * (range.maxK - range.minK));
927
+ celsius = kelvin - 273.15;
928
+ texCoord = (range.maxK - kelvin) / (range.maxK - range.minK);
929
+ }
930
+ parsedStops.push({ texCoord, color: [r, g, b], normVal, kelvin, celsius });
931
+ }
932
+
933
+ parsedStops.sort((a, b) => a.texCoord - b.texCoord);
934
+
935
+ for (let i = 0; i < width; i++) {
936
+ const t = i / (width - 1);
937
+
938
+ let r, g, b;
939
+
940
+ if (interpolate) {
941
+ let c1 = parsedStops[0];
942
+ let c2 = parsedStops[parsedStops.length - 1];
943
+
944
+ for (let j = 0; j < parsedStops.length - 1; j++) {
945
+ if (t >= parsedStops[j].texCoord && t <= parsedStops[j + 1].texCoord) {
946
+ c1 = parsedStops[j];
947
+ c2 = parsedStops[j + 1];
948
+ break;
949
+ }
950
+ }
951
+
952
+ const segmentRange = c2.texCoord - c1.texCoord;
953
+
954
+ if (segmentRange < 0.00001) {
955
+ r = c1.color[0];
956
+ g = c1.color[1];
957
+ b = c1.color[2];
958
+ } else {
959
+ const f = (t - c1.texCoord) / segmentRange;
960
+ r = Math.round(c1.color[0] + f * (c2.color[0] - c1.color[0]));
961
+ g = Math.round(c1.color[1] + f * (c2.color[1] - c1.color[1]));
962
+ b = Math.round(c1.color[2] + f * (c2.color[2] - c1.color[2]));
963
+ }
964
+ } else {
965
+ let chosenStop = parsedStops[0];
966
+
967
+ for (let j = 0; j < parsedStops.length; j++) {
968
+ if (t >= parsedStops[j].texCoord) {
969
+ chosenStop = parsedStops[j];
970
+ } else {
971
+ break;
972
+ }
973
+ }
974
+
975
+ r = chosenStop.color[0];
976
+ g = chosenStop.color[1];
977
+ b = chosenStop.color[2];
978
+ }
979
+
980
+ const offset = i * 4;
981
+ data[offset] = r;
982
+ data[offset + 1] = g;
983
+ data[offset + 2] = b;
984
+ data[offset + 3] = 255;
985
+ }
986
+
987
+ const texture = gl.createTexture();
988
+ gl.bindTexture(gl.TEXTURE_2D, texture);
989
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, data);
990
+
991
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
992
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
993
+
994
+ const filterMode = interpolate ? gl.LINEAR : gl.NEAREST;
995
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, filterMode);
996
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filterMode);
997
+
998
+ return texture;
999
+ }
1000
+ }