File size: 35,457 Bytes
695f088
 
 
 
8a28d42
 
695f088
 
 
8240fca
8a28d42
 
8240fca
695f088
 
 
71dc245
695f088
 
 
8240fca
5ad4e5a
8a28d42
 
 
 
 
695f088
4cb7bec
695f088
 
8a28d42
695f088
6e2aeb5
695f088
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8a28d42
695f088
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6e2aeb5
695f088
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
266a3a9
695f088
266a3a9
695f088
 
 
 
266a3a9
695f088
266a3a9
695f088
 
 
 
 
 
 
 
 
 
266a3a9
 
695f088
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8240fca
695f088
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8a28d42
 
 
695f088
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8240fca
695f088
 
 
 
6e2aeb5
695f088
 
 
6e2aeb5
 
 
 
 
 
 
695f088
 
6e2aeb5
 
 
 
 
 
 
 
8a28d42
6e2aeb5
 
8a28d42
 
 
 
 
 
 
 
 
 
 
 
 
 
6e2aeb5
8a28d42
 
 
 
 
 
 
 
6e2aeb5
 
 
 
 
 
8a28d42
6e2aeb5
 
 
 
 
 
695f088
8240fca
695f088
 
 
 
 
 
 
 
 
 
 
 
 
 
71dc245
695f088
 
 
 
 
8240fca
71dc245
8a28d42
695f088
 
6e2aeb5
695f088
 
 
 
 
 
6e2aeb5
695f088
 
 
8a28d42
695f088
 
 
 
8a28d42
695f088
 
 
8240fca
 
695f088
 
8a28d42
 
695f088
8a28d42
 
 
 
 
 
 
 
 
 
 
 
6e2aeb5
8a28d42
 
 
 
695f088
71dc245
695f088
8a28d42
 
 
 
6e2aeb5
 
695f088
 
 
 
 
8a28d42
 
 
 
 
 
 
 
 
 
 
 
 
695f088
8a28d42
695f088
71dc245
8240fca
 
695f088
 
 
 
71dc245
 
 
 
 
 
 
 
8a28d42
71dc245
 
 
 
 
 
8a28d42
71dc245
8a28d42
71dc245
 
 
 
 
8a28d42
71dc245
8240fca
 
8a28d42
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8240fca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8a28d42
 
 
 
 
 
 
 
8240fca
 
 
 
 
8a28d42
 
 
 
 
8240fca
5ad4e5a
8a28d42
 
 
 
5ad4e5a
8240fca
5ad4e5a
8240fca
 
 
 
 
 
 
 
 
 
5ad4e5a
 
 
 
 
 
 
 
 
 
 
8240fca
 
 
 
 
 
 
 
 
 
 
 
8a28d42
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8240fca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8a28d42
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5ad4e5a
 
8a28d42
 
 
 
 
 
 
 
5ad4e5a
 
 
 
 
8240fca
8a28d42
 
 
8240fca
 
 
 
71dc245
 
 
 
 
 
 
8240fca
71dc245
8240fca
 
 
 
71dc245
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8240fca
 
 
 
 
 
 
 
71dc245
 
 
 
 
695f088
 
 
 
 
 
 
 
 
8a28d42
 
 
 
 
 
 
 
 
 
695f088
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
// The page: a MapLibre globe of where a Core dataset has samples, and the image of the sample in a clicked cell.
//
//   dataset (panel) -> footprints/<name>.parquet, two position columns (footprints.js) -> mask on the cell array
//     -> coverage tiles drawn in the browser (tiles.js) -> globe
//   datasets with a kind have no thumbnail column: VIIRS tiles are read and coloured (viirs.js); AlphaEarth windows
//     are read from the yearly COG and shown as a PCA of the 64 bands (aef.js), one choice button per year
//   click -> cell -> its samples (footprint rows, page index) -> image file on Hugging Face, one row group,
//     thumbnail column (samples.js) -> panel
//   cursor -> cell under it -> outline + name, position, whether it has a sample
//   the shown image (the stored thumbnail, or a band or composite decoded from the image file, bands.js)
//     -> full window (zoom, pan, value under the cursor) and on the map over its footprint (corners from its UTM centre)
//   the sample's acquisition -> Core-Weather on source.coop, ±24 h and ±30 d (weather.js) -> charts in the panel
//     -> downloads of the bands as stored (download.js)

import * as maplibregl from 'https://cdn.jsdelivr.net/npm/maplibre-gl@6.14.0/dist/maplibre-gl.mjs'
import { feature } from 'https://cdn.jsdelivr.net/npm/topojson-client@3.1.0/+esm'
import proj4 from 'https://cdn.jsdelivr.net/npm/proj4@2.12.1/+esm'
import { DATASETS, loadCoverage, samplesAt } from './footprints.js'
import { placeLabels } from './labels.js'
import { imageUrl, loadThumbnail } from './samples.js'
import { bandNames, render, views } from './bands.js'
import { newStats } from './parquet.js'
import { loadWeather, weatherRows } from './weather.js'
import { loadViirs } from './viirs.js'
import { aefCell, aefViews, aefWindow, renderAef } from './aef.js'
import { aefSnippet, downloadAefTiff, downloadAll, downloadBand, downloadImage, downloadViirs, pythonSnippet, viirsSnippet }
  from './download.js'
import { cellAt, cellIndex, renderTile } from './tiles.js'
import './zstd.js'          // also here: on the private Space a module is served only if its referer carries the token, as app.js's does

const $ = id => document.getElementById(id)
const COLOURS = { S2L2A: [57, 181, 74], S2L1C: [0, 113, 188], S1RTC: [241, 90, 36], DEM: [180, 140, 90], VIIRS: [126, 87, 194], AEF: [32, 150, 150] }

const state = { name: null, info: null, datasets: null, coverage: null, version: 0, loading: 0 }

// ---- map ---------------------------------------------------------------------------------------------------

/** Coverage of the shown dataset: its colour where a cell has a sample, transparent elsewhere. */
const field = {
  pixel(lon, lat, out, o) {
    const mask = state.coverage?.mask
    if (!mask || !mask[cellIndex(lon, lat)]) { out[o + 3] = 0; return }
    const [r, g, b] = COLOURS[state.name] ?? [57, 181, 74]
    out[o] = r; out[o + 1] = g; out[o + 2] = b; out[o + 3] = 130     // coastlines show through
  },
}
maplibregl.addProtocol('mt', async params => {
  const [z, x, y] = params.url.split('/').slice(-3).map(Number)
  return { data: await renderTile(field, z, x, y) }
})

const map = new maplibregl.Map({
  container: 'map', center: [12, 22], zoom: innerWidth < 820 ? 1.3 : 2.25, attributionControl: false, maxZoom: 13,
  style: {
    version: 8, projection: { type: 'globe' },
    sky: { 'atmosphere-blend': ['interpolate', ['linear'], ['zoom'], 0, 0.6, 4, 0.3, 7, 0] },
    sources: {
      field: { type: 'raster', tiles: ['mt://0/{z}/{x}/{y}'], tileSize: 256, maxzoom: 9 },
      land: { type: 'geojson', data: { type: 'FeatureCollection', features: [] } },
      coast: { type: 'geojson', data: { type: 'FeatureCollection', features: [] } },
      cell: { type: 'geojson', data: { type: 'FeatureCollection', features: [] } },
      selected: { type: 'geojson', data: { type: 'FeatureCollection', features: [] } },
    },
    layers: [
      { id: 'ocean', type: 'background', paint: { 'background-color': '#2a2d33' } },
      { id: 'land', type: 'fill', source: 'land', paint: { 'fill-color': '#3b3f47', 'fill-antialias': false } },
      { id: 'field', type: 'raster', source: 'field', paint: { 'raster-resampling': 'nearest', 'raster-fade-duration': 0 } },
      { id: 'coast', type: 'line', source: 'coast', paint: { 'line-color': 'rgba(20,20,24,0.55)', 'line-width': 0.6 } },
      { id: 'cell', type: 'line', source: 'cell', paint: { 'line-color': '#f15a24', 'line-width': 1.25, 'line-opacity': 0.8 } },
      { id: 'selected', type: 'line', source: 'selected', paint: { 'line-color': '#f15a24', 'line-width': 2.5 } },
    ],
  },
})
map.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'bottom-right')
const places = placeLabels(map, $('places'))
$('show-places').addEventListener('change', e => places.show(e.target.checked))
map.addControl(new maplibregl.AttributionControl({ compact: true,
  customAttribution: 'Coastlines and places: Natural Earth · Data: <a href="https://huggingface.co/Major-TOM">Major TOM Core</a>' }))

map.on('load', async () => {
  const topo = await (await fetch('https://cdn.jsdelivr.net/npm/world-atlas@2.0.2/land-50m.json')).json()
  map.getSource('coast').setData(coastLines(feature(topo, topo.objects.land)))
  map.getSource('land').setData(continuousRings(feature(topo, topo.objects.land)))
  const datasets = await DATASETS
  state.datasets = datasets
  buildDatasetList(datasets)
  setDataset(Object.keys(datasets)[0], datasets)
})

/** Land polygons whose rings step across the date line made continuous (as climate-globe/app.js). */
function continuousRings(land) {
  const unwrap = ring => {
    if (ring.some(([, y]) => y <= -89.99)) return ring
    const out = [ring[0]]
    for (let i = 1, shift = 0; i < ring.length; i++) {
      const dx = ring[i][0] + shift - out[i - 1][0]
      if (dx > 180) shift -= 360; else if (dx < -180) shift += 360
      out.push([ring[i][0] + shift, ring[i][1]])
    }
    return out
  }
  for (const f of land.features) {
    const g = f.geometry
    g.coordinates = g.type === 'Polygon' ? g.coordinates.map(unwrap) : g.coordinates.map(p => p.map(unwrap))
  }
  return land
}

/** Coastlines from the land polygons, without the edges that are not coast (as climate-globe/app.js). */
function coastLines(land) {
  const artificial = ([x1, y1], [x2, y2]) => (Math.abs(x1) >= 179.99 && Math.abs(x2) >= 179.99) ||
    (y1 <= -89.99 && y2 <= -89.99) || Math.abs(x2 - x1) > 180
  const lines = []
  for (const f of land.features) {
    const polygons = f.geometry.type === 'Polygon' ? [f.geometry.coordinates] : f.geometry.coordinates
    for (const ring of polygons.flat()) {
      let line = [ring[0]]
      for (let i = 1; i < ring.length; i++) {
        if (artificial(ring[i - 1], ring[i])) { if (line.length > 1) lines.push(line); line = [ring[i]] }
        else line.push(ring[i])
      }
      if (line.length > 1) lines.push(line)
    }
  }
  return { type: 'Feature', geometry: { type: 'MultiLineString', coordinates: lines } }
}

// ---- cursor ------------------------------------------------------------------------------------------------

let cursor = null

/** Whether a screen point lies on the globe (not the space around it). */
function onGlobe(point, lngLat) {
  if (typeof map.transform?.isPointOnMapSurface === 'function') return map.transform.isPointOnMapSurface(point)
  const back = map.project(lngLat)
  return Math.hypot(back.x - point.x, back.y - point.y) < 2
}
map.on('mousemove', e => {
  if (onGlobe(e.point, e.lngLat)) { cursor = e.lngLat; describe() } else clearCursor()
})
window.globe = map                                         // for automated checks
window.pickState = () => pick

const outline = ([w, s, e, n]) => ({ type: 'Feature', geometry: { type: 'Polygon', coordinates: [[[w, s], [e, s], [e, n], [w, n], [w, s]]] } })

function describe() {
  if (!cursor) return
  const cell = cellAt(cursor.lng, cursor.lat)
  map.getSource('cell').setData(outline(cell.bounds))
  $('cursor-cell').textContent = cell.name
  $('cursor-pos').textContent = `${cursor.lat.toFixed(2)}°, ${cursor.lng.toFixed(2)}°`
  $('cursor-value').textContent = !state.coverage ? '–' : state.coverage.mask[cell.index] ? 'yes, click to load' : 'none'
}
function clearCursor() {
  cursor = null
  map.getSource('cell').setData({ type: 'FeatureCollection', features: [] })
  for (const id of ['cursor-cell', 'cursor-pos', 'cursor-value']) $(id).textContent = '–'
}
map.getCanvas().addEventListener('mouseleave', clearCursor)

// ---- loading indicator (as climate-globe/app.js, with the Major TOM logo) -------------------------------------------------------------

const bars = [...document.querySelectorAll('#loader polygon')]                  // the logo's 4 panels, left to right
const still = matchMedia('(prefers-reduced-motion: reduce)').matches
let loaderTimer = null, animation = null, tick = 0

function animateFlag() {
  const n = bars.length, k = tick++ % (2 * n)                  // first n ticks light the panels, next n clear them
  if (k < n) bars[k].classList.add('on')
  else bars[k - n].classList.remove('on')
}
function startLoader(text) {
  for (const b of bars) b.classList.remove('on')
  tick = 0
  $('loader-phase').textContent = 'Loading'
  $('loader-text').textContent = text
  $('loader-bytes').textContent = ''
  clearTimeout(loaderTimer); clearInterval(animation)
  loaderTimer = setTimeout(() => {                                               // fast and cached loads: no flash
    $('loader').hidden = false
    if (still) bars.slice(0, 2).forEach(b => b.classList.add('on'))
    else animation = setInterval(animateFlag, 180)
  }, 150)
}
function loaderProgress(bytes, expected) {
  $('loader-bytes').textContent = expected ? `${(bytes / 1e6).toFixed(1)} of ${(Math.max(expected, bytes) / 1e6).toFixed(1)} MB`
    : `${(bytes / 1e6).toFixed(1)} MB`
}
function stopLoader() {
  clearTimeout(loaderTimer); clearInterval(animation)
  $('loader').hidden = true
}

// ---- dataset -----------------------------------------------------------------------------------------------

const kb = bytes => (bytes >= 1e6 ? `${(bytes / 1e6).toFixed(1)} MB` : `${Math.round(bytes / 1e3)} KB`)
const secs = ms => `${(ms / 1000).toFixed(1)} s`
const reqs = n => `${n} request${n === 1 ? '' : 's'}`

function buildDatasetList(datasets) {
  $('datasets').replaceChildren(...Object.entries(datasets).map(([name, d]) => {
    const label = document.createElement('label')
    label.className = 'option'
    label.innerHTML = `<input type="radio" name="dataset" value="${name}"><span><b>${d.label}</b>` +
      `<small>Core-${name} · ${d.samples.toLocaleString('en')} samples</small></span>`
    label.querySelector('input').addEventListener('change', () => setDataset(name, datasets))
    return label
  }))
}

async function setDataset(name, datasets) {
  const ticket = ++state.loading, info = datasets[name]
  state.name = name; state.info = info; state.coverage = null
  document.querySelector(`input[name=dataset][value="${name}"]`).checked = true
  const link = info.link ?? `https://huggingface.co/datasets/Major-TOM/Core-${name}`
  $('dataset-link').href = link
  $('dataset-link').textContent = link.replace('https://', '')
  $('status').textContent = ''
  startLoader(`${info.label} · footprints of ${info.samples.toLocaleString('en')} samples`)
  let coverage
  try {
    coverage = await loadCoverage(name, (bytes, expected) => { if (ticket === state.loading) loaderProgress(bytes, expected) })
  } catch (error) {
    if (ticket === state.loading) { stopLoader(); $('status').textContent = `Could not load: ${error.message}` }
    return
  }
  if (ticket !== state.loading) return
  state.coverage = coverage
  $('loader-phase').textContent = 'Drawing'
  const t0 = performance.now()
  map.getSource('field').setTiles([`mt://${++state.version}/{z}/{x}/{y}`])
  describe()
  if (pick.cell) pickCell()
  map.once('idle', () => {
    if (ticket !== state.loading) return
    $('stat-footprints').textContent = `${kb(coverage.stats.bytes)} in ${reqs(coverage.stats.requests)} · ${secs(coverage.stats.read)}`
    $('stat-samples').textContent = coverage.samples.toLocaleString('en')
    $('stat-draw').textContent = secs(performance.now() - t0)
    document.body.dataset.loaded = name                     // for automated checks
    stopLoader()
    prefetchFootprints()
  })
}

/** The other datasets' footprints, one after another, once the shown one is drawn: switching is then instant. */
async function prefetchFootprints() {
  for (const name of Object.keys(state.datasets)) {
    try { await loadCoverage(name) } catch { /* loaded again, with its error shown, when picked */ }
  }
}

// ---- selected cell -----------------------------------------------------------------------------------------
//   click -> cell -> its samples (footprint rows) -> buttons when there are several -> thumbnail of the chosen one
//   then, in the background, the same cell in the other datasets: samples and first thumbnail, one dataset at a time
//   lookups and thumbnails are kept (promises, so a load under way is joined, not repeated)

const lookups = new Map()                                 // "<dataset>/<row>/<col>" -> samples
const thumbs = new Map()                                  // "<dataset>/<part>/<parquet_row>" -> thumbnail

function samplesOf(name, row, col) {
  const key = `${name}/${row}/${col}`
  if (!lookups.has(key)) lookups.set(key, samplesAt(name, row, col).then(rows => byYear(name, rows)).catch(e => { lookups.delete(key); throw e }))
  return lookups.get(key)
}

/** AlphaEarth: the cell's one footprint row as one sample per year (cellYear: its Sentinel-2 year, the default). */
function byYear(name, rows) {
  const info = state.datasets[name]
  if (info.kind !== 'aef' || !rows.length) return rows
  const [first, last] = info.years
  return Array.from({ length: last - first + 1 }, (_, k) => ({ ...rows[0], year: first + k, cellYear: rows[0].year }))
}

/** A sample's key, unique within its dataset. */
const sampleKey = s => (s.part !== undefined ? `${s.part}/${s.parquet_row}` : s.shard ? `${s.shard}/${s.offset}`
  : `${s.grid_row_u}/${s.grid_col_r}/${s.year}`)

/** The first image of a sample, read once: [promise, fresh, stats]. Thumbnail (Core), tile (VIIRS) or PCA (AlphaEarth). */
function thumbnailOf(name, sample) {
  const info = state.datasets[name], key = `${name}/${sampleKey(sample)}`, fresh = !thumbs.has(key)
  if (fresh) {
    const stats = newStats()
    const load = info.kind === 'viirs' ? loadViirs(info, sample, stats) : info.kind === 'aef' ? renderAef(info, sample, 'pca', stats, view => stats.onPreview?.(view))
      : loadThumbnail(info, sample)
    thumbs.set(key, { promise: load.catch(e => { thumbs.delete(key); throw e }), stats })
  }
  return [thumbs.get(key).promise, fresh, thumbs.get(key).stats]
}

/** The selected cell in every other dataset, in the panel's order, after the shown one. */
async function prefetchCell(cell) {
  for (const name of Object.keys(state.datasets)) {
    if (pick.cell !== cell) return                         // another cell was picked
    if (name === state.name || state.datasets[name].kind === 'aef') continue   // AlphaEarth: tens of MB, only on request
    try {
      const samples = await samplesOf(name, cell.row, cell.col)
      if (samples.length) await thumbnailOf(name, samples[0])[0]
    } catch { /* loaded again, with its error shown, when that dataset is shown */ }
  }
}

const pick = { cell: null, samples: [], chosen: 0, thumbnail: null, shown: null, views: [], bands: [], ticket: 0, viewTicket: 0 }

map.on('click', e => {
  if (!onGlobe(e.point, e.lngLat)) return
  pick.cell = { ...cellAt(e.lngLat.lng, e.lngLat.lat), lng: e.lngLat.lng, lat: e.lngLat.lat }
  map.getSource('selected').setData(outline(pick.cell.bounds))
  $('pick').hidden = false
  $('pick-cell').textContent = pick.cell.name
  $('pick-pos').textContent = `${pick.cell.lat.toFixed(2)}°, ${pick.cell.lng.toFixed(2)}°`
  $('pick').scrollIntoView({ block: 'nearest', behavior: 'smooth' })
  pickCell()
})
$('pick-clear').addEventListener('click', () => {
  pick.cell = null; pick.ticket++; $('pick').hidden = true
  map.getSource('selected').setData({ type: 'FeatureCollection', features: [] })
  showOnMap()
})

/** The selected cell in the shown dataset: its samples, then the first one's thumbnail. */
async function pickCell() {
  const ticket = ++pick.ticket, { row, col } = pick.cell, name = state.name
  pick.samples = []; pick.thumbnail = null; pick.shown = null; pick.viewTicket++
  showThumbnail(null, 'Looking up the cell…'); showOnMap()
  $('choices').replaceChildren(); $('pick-meta').replaceChildren(); $('pick-stats').textContent = ''; $('weather').hidden = true
  let samples
  try {
    samples = await samplesOf(name, row, col)
  } catch (error) {
    if (ticket === pick.ticket) showThumbnail(null, `Could not load: ${error.message}`)
    return
  }
  if (ticket !== pick.ticket) return
  pick.samples = samples
  if (!samples.length) { showThumbnail(null, `No ${state.info.label} sample in this cell`); prefetchCell(pick.cell); return }
  if (samples.length > 1) {
    $('choices').replaceChildren(...samples.map((s, i) => {
      const b = document.createElement('button')
      b.textContent = s.timestamp ? when(s.timestamp) : s.year ? String(s.year) : `sample ${i + 1}`
      b.addEventListener('click', () => choose(i))
      return b
    }))
  }
  choose(Math.max(0, samples.findIndex(s => s.cellYear !== undefined && s.year === s.cellYear)))
}

async function choose(i) {
  const ticket = ++pick.ticket, sample = pick.samples[i], info = state.info, name = state.name
  pick.chosen = i; pick.shown = null; pick.bands = []; pick.views = []; pick.viewTicket++
  for (const [k, b] of [...$('choices').children].entries()) b.setAttribute('aria-pressed', String(k === i))
  meta(sample, null, info)
  showWeather(sample, ticket)
  showThumbnail(null, info.kind === 'aef' ? 'Reading the 64 bands…' : 'Loading the thumbnail…')
  try {
    const [loading, fresh, stats] = thumbnailOf(state.name, sample)
    const stop = fresh && info.kind === 'aef' ? followLoad(`AlphaEarth ${sample.year} · ${pick.cell.name} · 64 bands`, stats) : () => {}
    let unbadge = () => {}
    stats.onPreview = view => {                                        // AlphaEarth: the 160 m PCA while 10 m loads
      stop()
      if (ticket !== pick.ticket) return
      pick.shown = { ...view, sample: { ...sample, corners: view.corners } }
      showThumbnail(view.src); showOnMap()
      unbadge = loadingBadge('Loading high-res (10 m)', stats)
      $('pick-stats').textContent = `${view.label} · loading high-res (10 m)…`
    }
    const t = await loading.finally(() => { stop(); unbadge() })
    if (ticket !== pick.ticket) return
    const label = { viirs: 'Radiance', aef: aefViews()[0].label }[info.kind] ?? 'Thumbnail'
    const placed = t.corners ? { ...sample, corners: t.corners } : sample
    pick.thumbnail = { ...t, sample: placed }
    pick.shown = { src: t.src, values: t.values ?? null, size: t.size, sample: placed, label }
    showThumbnail(t.src)
    showOnMap()
    meta(sample, t.productId, info)
    if (info.kind === 'aef') aefMenu(label)
    else if (info.kind) updateDownloads()                               // no band menu: the tile is one band
    else buildViews(name, info, sample, ticket)
    $('pick-stats').textContent = `${label}: ${kb(t.stats.bytes)} in ${reqs(t.stats.requests)} · ${secs(t.stats.read)}` +
      (fresh ? '' : ' (loaded before)')
    prefetchCell(pick.cell)
  } catch (error) {
    if (ticket === pick.ticket) showThumbnail(null, `Could not load: ${error.message}`)
  }
}

/** The badge over a preview while its high-res version loads: the share of its bytes so far. Returns the function
 *  that ends it. */
function loadingBadge(text, stats) {
  const base = stats.bytes, badge = $('thumb-badge')
  const update = () => {
    const share = stats.expected > base ? Math.min(99, Math.round(100 * (stats.bytes - base) / (stats.expected - base))) : 0
    $('thumb-badge-text').textContent = `${text} · ${share}%`
  }
  update(); badge.hidden = false
  const timer = setInterval(update, 150)
  return () => { clearInterval(timer); badge.hidden = true }
}

function showThumbnail(src, note = '') {
  $('thumb-badge').hidden = true                                        // a new image: no high-res load under way for it
  $('thumb').hidden = !src
  $('image-actions').hidden = !src
  $('download-actions').hidden = !src
  if (!src) $('view-row').hidden = true
  if (src) $('thumb').src = src
  $('thumb-note').textContent = src ? '' : note
}

// ---- the shown image on the map -------------------------------------------------------------------------------
// Every Core image is 10,680 m square in its UTM zone, centred on the sample's centre_lat, centre_lon (checked
// against the GeoTIFF bounds), north up: its corners are the centre +- 5,340 m, back in longitude and latitude.

const HALF = 5340

/** Corners of a sample's image, [lon, lat], from top left clockwise (as MapLibre's image source wants them). */
function corners(s) {
  if (s.corners) return s.corners                                      // VIIRS: from the GeoTIFF's own bounds
  const zone = s.epsg % 100, south = s.epsg > 32700
  const utm = proj4(`+proj=utm +zone=${zone}${south ? ' +south' : ''} +datum=WGS84 +units=m +no_defs`)
  const [x, y] = utm.forward([s.centre_lon, s.centre_lat])
  return [[x - HALF, y + HALF], [x + HALF, y + HALF], [x + HALF, y - HALF], [x - HALF, y - HALF]].map(p => utm.inverse(p))
}

/** The shown image over its footprint, opaque, while a cell is selected. */
function showOnMap() {
  const t = pick.shown, on = t && pick.cell
  if (map.getLayer('sample')) map.removeLayer('sample')
  if (map.getSource('sample')) map.removeSource('sample')
  if (!on) return
  map.addSource('sample', { type: 'image', url: t.src, coordinates: corners(t.sample) })
  map.addLayer({ id: 'sample', type: 'raster', source: 'sample',
    paint: { 'raster-opacity': 1, 'raster-fade-duration': 0 } }, 'coast')
}
window.sampleCorners = () => (pick.shown ? corners(pick.shown.sample) : null)             // for automated checks

// ---- Core-Weather at the acquisition --------------------------------------------------------------------------

const WEATHER_DATASETS = ['S2L2A', 'S2L1C', 'S1RTC']                   // Core-Weather has rows for these three

/** The sample's hourly and daily weather in the panel. Datasets without an acquisition time of their own (DEM, VIIRS
 *  annual medians) show the weather at the cell's Sentinel-2 L2A acquisition, and say so. */
async function showWeather(sample, ticket) {
  let at = sample, from = ''
  if (!(sample.timestamp && WEATHER_DATASETS.includes(state.name))) {
    const s2 = await samplesOf('S2L2A', pick.cell.row, pick.cell.col).catch(() => [])
    if (ticket !== pick.ticket) return
    at = s2[0]; from = at?.timestamp ? `At the cell's Sentinel-2 L2A acquisition, ${when(at.timestamp)}` : ''
  }
  $('weather').hidden = !at?.timestamp
  if ($('weather').hidden) return
  $('wx-from').textContent = from; $('wx-from').hidden = !from
  $('wx-hourly').replaceChildren(); $('wx-daily').replaceChildren(); $('wx-stats').textContent = 'Loading Core-Weather…'
  try {
    const w = await loadWeather(pick.cell.name, at.timestamp)
    if (ticket !== pick.ticket) return
    $('wx-hourly').replaceChildren(...weatherRows(w.hourly, 'hourly'))
    $('wx-daily').replaceChildren(...weatherRows(w.daily, 'daily'))
    $('wx-stats').textContent = (w.hourly || w.daily ? '' : 'No Core-Weather row for this acquisition · ') +
      `Core-Weather: ${kb(w.stats.bytes)} in ${reqs(w.stats.requests)} · ${secs(w.stats.read)}`
  } catch (error) {
    if (ticket === pick.ticket) $('wx-stats').textContent = `Could not load Core-Weather: ${error.message}`
  }
}

// ---- views: the stored thumbnail, composites and single bands ------------------------------------------------
//   the menu lists the image file's band columns; a chosen view is decoded once and kept (renders)

const renders = new Map()                                 // "<dataset>/<part>/<row>/<view>" -> rendered view

async function buildViews(name, info, sample, ticket) {
  try {
    const bands = await bandNames(info, sample)
    if (ticket !== pick.ticket) return
    pick.bands = bands; pick.views = views(name, bands)
    $('view').replaceChildren(new Option('Thumbnail (stored)', 'thumbnail'), ...pick.views.map(v => new Option(v.label, v.id)))
    $('view').value = 'thumbnail'; $('view-row').hidden = false
    updateDownloads()
  } catch { /* no menu: the thumbnail stays */ }
}

/** AlphaEarth's View menu: the PCA shown, then each band at 10 m. */
function aefMenu(label) {
  pick.views = aefViews().slice(1)
  $('view').replaceChildren(new Option(label, 'thumbnail'), ...pick.views.map(v => new Option(v.label, v.id)))
  $('view').value = 'thumbnail'; $('view-row').hidden = false
  updateDownloads()
}

$('view').addEventListener('change', () => showView($('view').value))

async function showView(id) {
  const t = pick.thumbnail, ticket = ++pick.viewTicket
  if (!t) return
  if (id === 'thumbnail') {
    pick.shown = { src: t.src, values: t.values ?? null, size: t.size, sample: t.sample, label: $('view').options[0].text }
    refreshShown(); return
  }
  const view = pick.views.find(v => v.id === id), key = `${state.name}/${sampleKey(t.sample)}/${id}`
  $('pick-stats').textContent = `Loading ${view.label}…`
  const fresh = !renders.has(key), stats = newStats()
  if (fresh) {
    const job = state.info.kind === 'aef' ? renderAef(state.info, t.sample, id, stats) : render(state.info, t.sample, view, stats)
    renders.set(key, job.catch(e => { renders.delete(key); throw e }))
  }
  const stop = fresh ? followLoad(`${view.label} · ${pick.cell.name}`, stats) : () => {}
  try {
    const r = await renders.get(key).finally(stop)
    if (ticket !== pick.viewTicket) return
    pick.shown = { ...r, sample: t.sample, label: view.label }
    refreshShown()
    $('pick-stats').textContent = `${view.label}: ${kb(r.stats.bytes)} in ${reqs(r.stats.requests)} · ${secs(r.stats.read)}` +
      (fresh ? '' : ' (loaded before)')
  } catch (error) {
    if (ticket === pick.viewTicket) $('pick-stats').textContent = `Could not load ${view.label}: ${error.message}`
  }
}

/** The loading flag over the map while a band read runs: bytes of the expected total, then its phase (decoding).
 *  Returns the function that ends it. */
function followLoad(text, stats) {
  startLoader(text)
  const timer = setInterval(() => {
    loaderProgress(stats.bytes, stats.expected)
    if (stats.phase) $('loader-phase').textContent = stats.phase
  }, 120)
  return () => { clearInterval(timer); stopLoader() }
}

function refreshShown() {
  showThumbnail(pick.shown.src)
  showOnMap()
  if (!$('lightbox').hidden) openImage()
  updateDownloads()
}

// ---- downloads ---------------------------------------------------------------------------------------------

const shownBand = () => (pick.shown && pick.bands.includes(pick.shown.label) ? pick.shown.label : null)

function updateDownloads() {
  const viirs = state.info?.kind === 'viirs'
  $('dl-all').hidden = viirs
  $('dl-all').textContent = 'All bands (.zip)'
  if (state.info?.kind === 'aef') {                       // the shown image, or the shown band; all 64 bands at 10 m
    const band = pick.shown?.window
    $('dl-band').disabled = !pick.shown
    $('dl-band').textContent = band ? 'Download band (.tif)' : 'Download image (.png)'
    $('dl-band').title = band ? 'The band at 10 m: a GeoTIFF, int8, north up' : 'The image as shown'
    $('dl-all').disabled = false; $('dl-all').textContent = 'All 64 bands (.tif)'
    $('dl-all').title = 'Every band at 10 m: a GeoTIFF of about 73 MB; reads about 120 MB'
    return
  }
  if (viirs) {
    $('dl-band').disabled = false; $('dl-band').textContent = 'Download tile (.tif)'
    $('dl-band').title = 'The tile as stored: a GeoTIFF, radiance in nW/cm²/sr'
    return
  }
  const band = shownBand()
  $('dl-band').disabled = !band
  $('dl-band').textContent = band ? `Download ${band} (.tif)` : 'Download band (.tif)'
  $('dl-band').title = band ? `${band} as stored: a GeoTIFF` : 'Choose a single band under View'
  $('dl-all').disabled = !pick.bands.length
}

async function downloading(button, job) {
  const label = button.textContent
  button.disabled = true; button.textContent = 'Preparing…'
  try {
    const stats = await job()
    $('pick-stats').textContent = `Downloaded: ${kb(stats.bytes)} read in ${reqs(stats.requests)} · ${secs(stats.read)}`
  } catch (error) {
    $('pick-stats').textContent = `Could not download: ${error.message}`
  } finally { button.textContent = label; updateDownloads() }
}

const aefName = (sample, what) => `${pick.cell.name}_AEF_${sample.year}_${what}`

$('dl-band').addEventListener('click', async () => {
  if (state.info.kind === 'aef') {
    const s = pick.thumbnail.sample, w = pick.shown.window
    if (w) downloadAefTiff(w, `${aefName(s, pick.shown.label.split(',')[0].replace('Band ', ''))}.tif`)
    else await downloadImage(pick.shown.src, `${aefName(s, 'pca')}.png`)
    $('pick-stats').textContent = 'Downloaded: from what is shown, nothing read'
    return
  }
  if (state.info.kind === 'viirs') {
    downloadViirs(pick.cell.name, pick.thumbnail.sample, pick.thumbnail.bytes)
    $('pick-stats').textContent = `Downloaded: ${kb(pick.thumbnail.bytes.length)}, read before`
    return
  }
  downloading($('dl-band'), () => downloadBand(state.info, state.name, pick.cell.name, pick.thumbnail.sample, shownBand()))
})
$('dl-all').addEventListener('click', () => downloading($('dl-all'), async () => {
  const stats = newStats(), stop = followLoad(`All bands · ${pick.cell.name} · for download`, stats)
  if (state.info.kind === 'aef') {
    try {
      const s = pick.thumbnail.sample, w = await aefWindow(state.info, s, 0, Array.from({ length: 64 }, (_, b) => b), stats)
      downloadAefTiff(w, `${aefName(s, 'A00-A63')}.tif`)
      stats.read = performance.now() - stats.t0
      return stats
    } finally { stop() }
  }
  try {
    return await downloadAll(state.info, state.name, pick.cell.name, pick.thumbnail.sample, pick.thumbnail.src,
      pick.thumbnail.productId, stats)
  } finally { stop() }
}))
$('dl-python').addEventListener('click', async () => {
  const code = state.info.kind === 'aef' ? aefSnippet(pick.cell.name, pick.thumbnail.sample, await aefCell(state.info, pick.thumbnail.sample))
    : state.info.kind === 'viirs' ? viirsSnippet(state.info, pick.cell.name, pick.thumbnail.sample)
    : pythonSnippet(state.info, state.name, pick.cell.name, pick.thumbnail.sample, pick.bands.length ? pick.bands : ['thumbnail'])
  window.lastSnippet = code                                                         // for automated checks
  try { await navigator.clipboard.writeText(code); $('pick-stats').textContent = 'Python copied to the clipboard' }
  catch { window.prompt('Python that reads this sample:', code) }
})

// ---- the shown image in the full window -----------------------------------------------------------------------
//   opens fitted to the window; scroll zooms about the cursor, drag pans, Esc or Close closes

const view = { scale: 1, x: 0, y: 0, drag: null }

function openImage() {
  if (!pick.shown) return
  const img = $('lightbox-img')
  img.src = pick.shown.src
  $('lightbox-value').textContent = ''
  $('lightbox-title').textContent = `${pick.cell.name} · ${state.info.label} · ${pick.shown.label}` +
    (pick.shown.sample.timestamp ? ` · ${when(pick.shown.sample.timestamp)}` : '')
  $('lightbox').hidden = false
  img.decode().then(() => {
    const w = innerWidth, h = innerHeight - 44, s = Math.min(w / img.naturalWidth, h / img.naturalHeight)
    Object.assign(view, { scale: s, x: (w - img.naturalWidth * s) / 2, y: 44 + (h - img.naturalHeight * s) / 2 })
    place()
  })
}
const place = () => { $('lightbox-img').style.transform = `translate(${view.x}px, ${view.y}px) scale(${view.scale})` }
const closeImage = () => { $('lightbox').hidden = true }

$('thumb').addEventListener('click', openImage)
$('open-image').addEventListener('click', openImage)
$('lightbox-close').addEventListener('click', closeImage)
document.addEventListener('keydown', e => { if (e.key === 'Escape' && !$('lightbox').hidden) closeImage() })
$('lightbox').addEventListener('wheel', e => {
  e.preventDefault()
  const k = Math.exp(-e.deltaY * 0.0015), s = Math.min(Math.max(view.scale * k, 0.05), 40)
  view.x = e.clientX - (e.clientX - view.x) * (s / view.scale)
  view.y = e.clientY - (e.clientY - view.y) * (s / view.scale)
  view.scale = s; place()
}, { passive: false })
$('lightbox').addEventListener('pointerdown', e => {
  if (e.target.closest('button')) return
  view.drag = [e.clientX - view.x, e.clientY - view.y]
  $('lightbox').classList.add('dragging'); $('lightbox').setPointerCapture(e.pointerId)
})
$('lightbox').addEventListener('pointermove', e => {
  const v = pick.shown?.values
  if (v) {                                                   // value under the cursor (single bands, elevation)
    const k = v.width / pick.shown.size, x = Math.floor(((e.clientX - view.x) / view.scale) * k)
    const y = Math.floor(((e.clientY - view.y) / view.scale) * k)
    const inside = x >= 0 && y >= 0 && x < v.width && y < v.values.length / v.width
    const value = inside ? v.values[y * v.width + x] : NaN
    $('lightbox-value').textContent = inside && Number.isFinite(value) ? `· pixel ${x}, ${y}: ${+value.toFixed(3)}${v.unit ? ` ${v.unit}` : ''}` : ''
  }
  if (!view.drag) return
  view.x = e.clientX - view.drag[0]; view.y = e.clientY - view.drag[1]; place()
})
$('lightbox').addEventListener('pointerup', () => { view.drag = null; $('lightbox').classList.remove('dragging') })

/** "20200213T023821" -> "2020-02-13 02:38 UTC". */
const when = t => `${t.slice(0, 4)}-${t.slice(4, 6)}-${t.slice(6, 8)} ${t.slice(9, 11)}:${t.slice(11, 13)} UTC`

function meta(sample, productId, info) {
  const rows = []
  if (sample.timestamp) rows.push(['Acquired', when(sample.timestamp)])
  if (sample.cloud_cover !== undefined && sample.cloud_cover !== null) rows.push(['Cloud cover', `${sample.cloud_cover.toFixed(1)}%`])
  if (sample.orbit_state) rows.push(['Orbit', sample.orbit_state])
  if (productId) rows.push(['Product', productId])
  if (info.kind === 'aef') {
    rows.push(['Year', `${sample.year} (annual embedding)${sample.year === sample.cellYear ? ', the Sentinel-2 year' : ''}`])
    rows.push(['Source', `<a href="${info.link}" target="_blank" rel="noopener">tge-labs/aef</a>, CC-BY 4.0`])
  } else if (info.kind === 'viirs') {
    rows.push(['Year', `${sample.year} (annual median)`])
    rows.push(['File', `<a href="${info.files.replace('/resolve/', '/blob/')}${sample.shard}" target="_blank" rel="noopener">${sample.shard.split('/').pop()}</a>, bytes ${sample.offset.toLocaleString('en')} + ${sample.size.toLocaleString('en')}`])
  } else {
    const url = imageUrl(info, sample.part)
    rows.push(['File', `<a href="${url.replace('/resolve/', '/blob/')}" target="_blank" rel="noopener">part_${String(sample.part).padStart(5, '0')}</a>, row ${sample.parquet_row}`])
  }
  $('pick-meta').innerHTML = rows.map(([k, v]) => `<dt>${k}</dt><dd>${v}</dd>`).join('')
}