mikonvergence commited on
Commit
8240fca
·
verified ·
1 Parent(s): 71dc245

Bands, composites and downloads

Browse files
Files changed (7) hide show
  1. README.md +8 -1
  2. app.js +111 -14
  3. bands.js +128 -0
  4. download.js +56 -0
  5. index.html +13 -2
  6. parquet.js +20 -3
  7. samples.js +4 -7
README.md CHANGED
@@ -32,6 +32,11 @@ click a cell -> its footprint rows (page index) -> the image file on Hugging Fac
32
  -> samples.js: that row group's thumbnail column -> panel, with acquisition time, cloud cover or orbit, product
33
  the shown image: Open (full window, zoom and pan) or Show on map (over its footprint, opacity slider): every image
34
  is 10,680 m square in its UTM zone, centred on the sample's centre_lat, centre_lon, so corners need no band read
 
 
 
 
 
35
  in the background: the other datasets' footprints once the first is drawn; after a click, the same cell's samples
36
  and first thumbnail in the other datasets (kept, so switching dataset shows them at once)
37
  ```
@@ -44,7 +49,9 @@ in the background: the other datasets' footprints once the first is drawn; after
44
  | `app.js` | globe, dataset list, loading flag, cursor, selected cell |
45
  | `footprints.js` | a dataset's footprints: coverage mask, a cell's samples |
46
  | `samples.js` | one sample's thumbnail from its image file |
47
- | `parquet.js` | range requests for hyparquet, with bytes and time counted |
 
 
48
  | `tiles.js`, `labels.js`, `places.json` | copied from the Climate Globe: cells and tiles, place names |
49
  | `build_index.py` | Core `metadata.parquet` -> `footprints/<name>.parquet` and `footprints/datasets.json` |
50
  | `footprints/` | built files, not in git: uploaded to the Space once (S2L2A and S2L1C 32.9 MB each, S1RTC 20.6 MB, DEM 14.5 MB) |
 
32
  -> samples.js: that row group's thumbnail column -> panel, with acquisition time, cloud cover or orbit, product
33
  the shown image: Open (full window, zoom and pan) or Show on map (over its footprint, opacity slider): every image
34
  is 10,680 m square in its UTM zone, centred on the sample's centre_lat, centre_lon, so corners need no band read
35
+ View menu: the stored thumbnail (default), composites (S2: true colour, false colour, SWIR, one stretch for the three
36
+ bands; S1: VV, VH, VV/VH in dB; DEM: elevation in colour) and every band (grey, 2-98% stretch, value under the
37
+ cursor in the full window) -> bands.js: the band columns (LZW GeoTIFFs) of the sample's row group, geotiff.js
38
+ Downloads (download.js): the shown band as stored (.tif); every band, the thumbnail and metadata.json (.zip); a
39
+ Python snippet that reads the same row group from Hugging Face
40
  in the background: the other datasets' footprints once the first is drawn; after a click, the same cell's samples
41
  and first thumbnail in the other datasets (kept, so switching dataset shows them at once)
42
  ```
 
49
  | `app.js` | globe, dataset list, loading flag, cursor, selected cell |
50
  | `footprints.js` | a dataset's footprints: coverage mask, a cell's samples |
51
  | `samples.js` | one sample's thumbnail from its image file |
52
+ | `bands.js` | a sample's band columns: read, decode (geotiff.js), stretch, composites |
53
+ | `download.js` | band GeoTIFFs as stored, all bands as a zip (fflate), the Python snippet |
54
+ | `parquet.js` | range requests for hyparquet, with bytes and time counted; each file's footer read once |
55
  | `tiles.js`, `labels.js`, `places.json` | copied from the Climate Globe: cells and tiles, place names |
56
  | `build_index.py` | Core `metadata.parquet` -> `footprints/<name>.parquet` and `footprints/datasets.json` |
57
  | `footprints/` | built files, not in git: uploaded to the Space once (S2L2A and S2L1C 32.9 MB each, S1RTC 20.6 MB, DEM 14.5 MB) |
app.js CHANGED
@@ -5,7 +5,9 @@
5
  // click -> cell -> its samples (footprint rows, page index) -> image file on Hugging Face, one row group,
6
  // thumbnail column (samples.js) -> panel
7
  // cursor -> cell under it -> outline + name, position, whether it has a sample
8
- // the shown image -> full window (zoom, pan) or on the map over its footprint (corners from its UTM centre)
 
 
9
 
10
  import * as maplibregl from 'https://cdn.jsdelivr.net/npm/maplibre-gl@6.14.0/dist/maplibre-gl.mjs'
11
  import { feature } from 'https://cdn.jsdelivr.net/npm/topojson-client@3.1.0/+esm'
@@ -13,6 +15,8 @@ import proj4 from 'https://cdn.jsdelivr.net/npm/proj4@2.12.1/+esm'
13
  import { DATASETS, loadCoverage, samplesAt } from './footprints.js'
14
  import { placeLabels } from './labels.js'
15
  import { imageUrl, loadThumbnail } from './samples.js'
 
 
16
  import { cellAt, cellIndex, renderTile } from './tiles.js'
17
 
18
  const $ = id => document.getElementById(id)
@@ -182,6 +186,7 @@ function stopLoader() {
182
 
183
  const kb = bytes => (bytes >= 1e6 ? `${(bytes / 1e6).toFixed(1)} MB` : `${Math.round(bytes / 1e3)} KB`)
184
  const secs = ms => `${(ms / 1000).toFixed(1)} s`
 
185
 
186
  function buildDatasetList(datasets) {
187
  $('datasets').replaceChildren(...Object.entries(datasets).map(([name, d]) => {
@@ -218,7 +223,7 @@ async function setDataset(name, datasets) {
218
  if (pick.cell) pickCell()
219
  map.once('idle', () => {
220
  if (ticket !== state.loading) return
221
- $('stat-footprints').textContent = `${kb(coverage.stats.bytes)} in ${coverage.stats.requests} requests · ${secs(coverage.stats.read)}`
222
  $('stat-samples').textContent = coverage.samples.toLocaleString('en')
223
  $('stat-draw').textContent = secs(performance.now() - t0)
224
  document.body.dataset.loaded = name // for automated checks
@@ -265,7 +270,7 @@ async function prefetchCell(cell) {
265
  }
266
  }
267
 
268
- const pick = { cell: null, samples: [], chosen: 0, thumbnail: null, ticket: 0 }
269
 
270
  map.on('click', e => {
271
  if (!onGlobe(e.point, e.lngLat)) return
@@ -286,7 +291,7 @@ $('pick-clear').addEventListener('click', () => {
286
  /** The selected cell in the shown dataset: its samples, then the first one's thumbnail. */
287
  async function pickCell() {
288
  const ticket = ++pick.ticket, { row, col } = pick.cell, name = state.name
289
- pick.samples = []; pick.thumbnail = null
290
  showThumbnail(null, 'Looking up the cell…'); showOnMap()
291
  $('choices').replaceChildren(); $('pick-meta').replaceChildren(); $('pick-stats').textContent = ''
292
  let samples
@@ -311,8 +316,8 @@ async function pickCell() {
311
  }
312
 
313
  async function choose(i) {
314
- const ticket = ++pick.ticket, sample = pick.samples[i], info = state.info
315
- pick.chosen = i
316
  for (const [k, b] of [...$('choices').children].entries()) b.setAttribute('aria-pressed', String(k === i))
317
  meta(sample, null, info)
318
  showThumbnail(null, 'Loading the thumbnail…')
@@ -320,10 +325,12 @@ async function choose(i) {
320
  const [loading, fresh] = thumbnailOf(state.name, sample), t = await loading
321
  if (ticket !== pick.ticket) return
322
  pick.thumbnail = { ...t, sample }
 
323
  showThumbnail(t.src)
324
  showOnMap()
325
  meta(sample, t.productId, info)
326
- $('pick-stats').textContent = `Thumbnail: ${kb(t.stats.bytes)} in ${t.stats.requests} requests · ${secs(t.stats.read)}` +
 
327
  (fresh ? '' : ' (loaded before)')
328
  prefetchCell(pick.cell)
329
  } catch (error) {
@@ -334,6 +341,8 @@ async function choose(i) {
334
  function showThumbnail(src, note = '') {
335
  $('thumb').hidden = !src
336
  $('image-actions').hidden = !src
 
 
337
  if (src) $('thumb').src = src
338
  $('thumb-note').textContent = src ? '' : note
339
  }
@@ -352,9 +361,9 @@ function corners(s) {
352
  return [[x - HALF, y + HALF], [x + HALF, y + HALF], [x + HALF, y - HALF], [x - HALF, y - HALF]].map(p => utm.inverse(p))
353
  }
354
 
355
- /** The shown thumbnail over its footprint when "Show on map" is ticked; otherwise none. */
356
  function showOnMap() {
357
- const t = pick.thumbnail, on = $('on-map').checked && t && pick.cell
358
  if (map.getLayer('sample')) map.removeLayer('sample')
359
  if (map.getSource('sample')) map.removeSource('sample')
360
  if (!on) return
@@ -366,7 +375,86 @@ $('on-map').addEventListener('change', showOnMap)
366
  $('opacity').addEventListener('input', () => {
367
  if (map.getLayer('sample')) map.setPaintProperty('sample', 'raster-opacity', Number($('opacity').value) / 100)
368
  })
369
- window.sampleCorners = () => (pick.thumbnail ? corners(pick.thumbnail.sample) : null) // for automated checks
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
370
 
371
  // ---- the shown image in the full window -----------------------------------------------------------------------
372
  // opens fitted to the window; scroll zooms about the cursor, drag pans, Esc or Close closes
@@ -374,11 +462,12 @@ window.sampleCorners = () => (pick.thumbnail ? corners(pick.thumbnail.sample) :
374
  const view = { scale: 1, x: 0, y: 0, drag: null }
375
 
376
  function openImage() {
377
- if (!pick.thumbnail) return
378
  const img = $('lightbox-img')
379
- img.src = pick.thumbnail.src
380
- $('lightbox-title').textContent = `${pick.cell.name} · ${state.info.label}` +
381
- (pick.thumbnail.sample.timestamp ? ` · ${when(pick.thumbnail.sample.timestamp)}` : '')
 
382
  $('lightbox').hidden = false
383
  img.decode().then(() => {
384
  const w = innerWidth, h = innerHeight - 44, s = Math.min(w / img.naturalWidth, h / img.naturalHeight)
@@ -406,6 +495,14 @@ $('lightbox').addEventListener('pointerdown', e => {
406
  $('lightbox').classList.add('dragging'); $('lightbox').setPointerCapture(e.pointerId)
407
  })
408
  $('lightbox').addEventListener('pointermove', e => {
 
 
 
 
 
 
 
 
409
  if (!view.drag) return
410
  view.x = e.clientX - view.drag[0]; view.y = e.clientY - view.drag[1]; place()
411
  })
 
5
  // click -> cell -> its samples (footprint rows, page index) -> image file on Hugging Face, one row group,
6
  // thumbnail column (samples.js) -> panel
7
  // cursor -> cell under it -> outline + name, position, whether it has a sample
8
+ // the shown image (the stored thumbnail, or a band or composite decoded from the image file, bands.js)
9
+ // -> full window (zoom, pan, value under the cursor) or on the map over its footprint (corners from its UTM centre)
10
+ // -> downloads of the bands as stored (download.js)
11
 
12
  import * as maplibregl from 'https://cdn.jsdelivr.net/npm/maplibre-gl@6.14.0/dist/maplibre-gl.mjs'
13
  import { feature } from 'https://cdn.jsdelivr.net/npm/topojson-client@3.1.0/+esm'
 
15
  import { DATASETS, loadCoverage, samplesAt } from './footprints.js'
16
  import { placeLabels } from './labels.js'
17
  import { imageUrl, loadThumbnail } from './samples.js'
18
+ import { bandNames, render, views } from './bands.js'
19
+ import { downloadAll, downloadBand, pythonSnippet } from './download.js'
20
  import { cellAt, cellIndex, renderTile } from './tiles.js'
21
 
22
  const $ = id => document.getElementById(id)
 
186
 
187
  const kb = bytes => (bytes >= 1e6 ? `${(bytes / 1e6).toFixed(1)} MB` : `${Math.round(bytes / 1e3)} KB`)
188
  const secs = ms => `${(ms / 1000).toFixed(1)} s`
189
+ const reqs = n => `${n} request${n === 1 ? '' : 's'}`
190
 
191
  function buildDatasetList(datasets) {
192
  $('datasets').replaceChildren(...Object.entries(datasets).map(([name, d]) => {
 
223
  if (pick.cell) pickCell()
224
  map.once('idle', () => {
225
  if (ticket !== state.loading) return
226
+ $('stat-footprints').textContent = `${kb(coverage.stats.bytes)} in ${reqs(coverage.stats.requests)} · ${secs(coverage.stats.read)}`
227
  $('stat-samples').textContent = coverage.samples.toLocaleString('en')
228
  $('stat-draw').textContent = secs(performance.now() - t0)
229
  document.body.dataset.loaded = name // for automated checks
 
270
  }
271
  }
272
 
273
+ const pick = { cell: null, samples: [], chosen: 0, thumbnail: null, shown: null, views: [], bands: [], ticket: 0, viewTicket: 0 }
274
 
275
  map.on('click', e => {
276
  if (!onGlobe(e.point, e.lngLat)) return
 
291
  /** The selected cell in the shown dataset: its samples, then the first one's thumbnail. */
292
  async function pickCell() {
293
  const ticket = ++pick.ticket, { row, col } = pick.cell, name = state.name
294
+ pick.samples = []; pick.thumbnail = null; pick.shown = null; pick.viewTicket++
295
  showThumbnail(null, 'Looking up the cell…'); showOnMap()
296
  $('choices').replaceChildren(); $('pick-meta').replaceChildren(); $('pick-stats').textContent = ''
297
  let samples
 
316
  }
317
 
318
  async function choose(i) {
319
+ const ticket = ++pick.ticket, sample = pick.samples[i], info = state.info, name = state.name
320
+ pick.chosen = i; pick.shown = null; pick.bands = []; pick.views = []; pick.viewTicket++
321
  for (const [k, b] of [...$('choices').children].entries()) b.setAttribute('aria-pressed', String(k === i))
322
  meta(sample, null, info)
323
  showThumbnail(null, 'Loading the thumbnail…')
 
325
  const [loading, fresh] = thumbnailOf(state.name, sample), t = await loading
326
  if (ticket !== pick.ticket) return
327
  pick.thumbnail = { ...t, sample }
328
+ pick.shown = { src: t.src, values: null, sample, label: 'Thumbnail' }
329
  showThumbnail(t.src)
330
  showOnMap()
331
  meta(sample, t.productId, info)
332
+ buildViews(name, info, sample, ticket)
333
+ $('pick-stats').textContent = `Thumbnail: ${kb(t.stats.bytes)} in ${reqs(t.stats.requests)} · ${secs(t.stats.read)}` +
334
  (fresh ? '' : ' (loaded before)')
335
  prefetchCell(pick.cell)
336
  } catch (error) {
 
341
  function showThumbnail(src, note = '') {
342
  $('thumb').hidden = !src
343
  $('image-actions').hidden = !src
344
+ $('download-actions').hidden = !src
345
+ if (!src) $('view-row').hidden = true
346
  if (src) $('thumb').src = src
347
  $('thumb-note').textContent = src ? '' : note
348
  }
 
361
  return [[x - HALF, y + HALF], [x + HALF, y + HALF], [x + HALF, y - HALF], [x - HALF, y - HALF]].map(p => utm.inverse(p))
362
  }
363
 
364
+ /** The shown image over its footprint when "Show on map" is ticked; otherwise none. */
365
  function showOnMap() {
366
+ const t = pick.shown, on = $('on-map').checked && t && pick.cell
367
  if (map.getLayer('sample')) map.removeLayer('sample')
368
  if (map.getSource('sample')) map.removeSource('sample')
369
  if (!on) return
 
375
  $('opacity').addEventListener('input', () => {
376
  if (map.getLayer('sample')) map.setPaintProperty('sample', 'raster-opacity', Number($('opacity').value) / 100)
377
  })
378
+ window.sampleCorners = () => (pick.shown ? corners(pick.shown.sample) : null) // for automated checks
379
+
380
+ // ---- views: the stored thumbnail, composites and single bands ------------------------------------------------
381
+ // the menu lists the image file's band columns; a chosen view is decoded once and kept (renders)
382
+
383
+ const renders = new Map() // "<dataset>/<part>/<row>/<view>" -> rendered view
384
+
385
+ async function buildViews(name, info, sample, ticket) {
386
+ try {
387
+ const bands = await bandNames(info, sample)
388
+ if (ticket !== pick.ticket) return
389
+ pick.bands = bands; pick.views = views(name, bands)
390
+ $('view').replaceChildren(new Option('Thumbnail (stored)', 'thumbnail'), ...pick.views.map(v => new Option(v.label, v.id)))
391
+ $('view').value = 'thumbnail'; $('view-row').hidden = false
392
+ updateDownloads()
393
+ } catch { /* no menu: the thumbnail stays */ }
394
+ }
395
+
396
+ $('view').addEventListener('change', () => showView($('view').value))
397
+
398
+ async function showView(id) {
399
+ const t = pick.thumbnail, ticket = ++pick.viewTicket
400
+ if (!t) return
401
+ if (id === 'thumbnail') { pick.shown = { src: t.src, values: null, sample: t.sample, label: 'Thumbnail' }; refreshShown(); return }
402
+ const view = pick.views.find(v => v.id === id), key = `${state.name}/${t.sample.part}/${t.sample.parquet_row}/${id}`
403
+ $('pick-stats').textContent = `Loading ${view.label}…`
404
+ const fresh = !renders.has(key)
405
+ if (fresh) renders.set(key, render(state.info, t.sample, view).catch(e => { renders.delete(key); throw e }))
406
+ try {
407
+ const r = await renders.get(key)
408
+ if (ticket !== pick.viewTicket) return
409
+ pick.shown = { ...r, sample: t.sample, label: view.label }
410
+ refreshShown()
411
+ $('pick-stats').textContent = `${view.label}: ${kb(r.stats.bytes)} in ${reqs(r.stats.requests)} · ${secs(r.stats.read)}` +
412
+ (fresh ? '' : ' (loaded before)')
413
+ } catch (error) {
414
+ if (ticket === pick.viewTicket) $('pick-stats').textContent = `Could not load ${view.label}: ${error.message}`
415
+ }
416
+ }
417
+
418
+ function refreshShown() {
419
+ showThumbnail(pick.shown.src)
420
+ showOnMap()
421
+ if (!$('lightbox').hidden) openImage()
422
+ updateDownloads()
423
+ }
424
+
425
+ // ---- downloads ---------------------------------------------------------------------------------------------
426
+
427
+ const shownBand = () => (pick.shown && pick.bands.includes(pick.shown.label) ? pick.shown.label : null)
428
+
429
+ function updateDownloads() {
430
+ const band = shownBand()
431
+ $('dl-band').disabled = !band
432
+ $('dl-band').textContent = band ? `Download ${band} (.tif)` : 'Download band (.tif)'
433
+ $('dl-band').title = band ? `${band} as stored: a GeoTIFF` : 'Choose a single band under View'
434
+ $('dl-all').disabled = !pick.bands.length
435
+ }
436
+
437
+ async function downloading(button, job) {
438
+ const label = button.textContent
439
+ button.disabled = true; button.textContent = 'Preparing…'
440
+ try {
441
+ const stats = await job()
442
+ $('pick-stats').textContent = `Downloaded: ${kb(stats.bytes)} read in ${reqs(stats.requests)} · ${secs(stats.read)}`
443
+ } catch (error) {
444
+ $('pick-stats').textContent = `Could not download: ${error.message}`
445
+ } finally { button.textContent = label; updateDownloads() }
446
+ }
447
+
448
+ $('dl-band').addEventListener('click', () => downloading($('dl-band'),
449
+ () => downloadBand(state.info, state.name, pick.cell.name, pick.thumbnail.sample, shownBand())))
450
+ $('dl-all').addEventListener('click', () => downloading($('dl-all'),
451
+ () => downloadAll(state.info, state.name, pick.cell.name, pick.thumbnail.sample, pick.thumbnail.src, pick.thumbnail.productId)))
452
+ $('dl-python').addEventListener('click', async () => {
453
+ const code = pythonSnippet(state.info, state.name, pick.cell.name, pick.thumbnail.sample, pick.bands.length ? pick.bands : ['thumbnail'])
454
+ window.lastSnippet = code // for automated checks
455
+ try { await navigator.clipboard.writeText(code); $('pick-stats').textContent = 'Python copied to the clipboard' }
456
+ catch { window.prompt('Python that reads this sample:', code) }
457
+ })
458
 
459
  // ---- the shown image in the full window -----------------------------------------------------------------------
460
  // opens fitted to the window; scroll zooms about the cursor, drag pans, Esc or Close closes
 
462
  const view = { scale: 1, x: 0, y: 0, drag: null }
463
 
464
  function openImage() {
465
+ if (!pick.shown) return
466
  const img = $('lightbox-img')
467
+ img.src = pick.shown.src
468
+ $('lightbox-value').textContent = ''
469
+ $('lightbox-title').textContent = `${pick.cell.name} · ${state.info.label} · ${pick.shown.label}` +
470
+ (pick.shown.sample.timestamp ? ` · ${when(pick.shown.sample.timestamp)}` : '')
471
  $('lightbox').hidden = false
472
  img.decode().then(() => {
473
  const w = innerWidth, h = innerHeight - 44, s = Math.min(w / img.naturalWidth, h / img.naturalHeight)
 
495
  $('lightbox').classList.add('dragging'); $('lightbox').setPointerCapture(e.pointerId)
496
  })
497
  $('lightbox').addEventListener('pointermove', e => {
498
+ const v = pick.shown?.values
499
+ if (v) { // value under the cursor (single bands, elevation)
500
+ const k = v.width / pick.shown.size, x = Math.floor(((e.clientX - view.x) / view.scale) * k)
501
+ const y = Math.floor(((e.clientY - view.y) / view.scale) * k)
502
+ const inside = x >= 0 && y >= 0 && x < v.width && y < v.values.length / v.width
503
+ const value = inside ? v.values[y * v.width + x] : NaN
504
+ $('lightbox-value').textContent = inside && Number.isFinite(value) ? `· pixel ${x}, ${y}: ${+value.toFixed(3)}${v.unit ? ` ${v.unit}` : ''}` : ''
505
+ }
506
  if (!view.drag) return
507
  view.x = e.clientX - view.drag[0]; view.y = e.clientY - view.drag[1]; place()
508
  })
bands.js ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // A sample's bands: read from its image file on Hugging Face, decoded and drawn in the browser.
2
+ //
3
+ // image file row group (one per sample) -> band column = the original GeoTIFF bytes (LZW; kept for download)
4
+ // -> geotiff.js -> values (S2: uint16 at 10, 20 or 60 m; S1: float32 linear backscatter at 7.5 m; DEM: float32 m)
5
+ // -> a view: one band in grey (2-98% stretch), or a composite of three (S2: one stretch for the three) -> PNG
6
+ // Every band of a sample covers the same 10,680 m square, so bands of different resolutions are combined by
7
+ // sampling each at the composite's pixel positions (nearest).
8
+
9
+ import { fromArrayBuffer } from 'https://cdn.jsdelivr.net/npm/geotiff@2.1.3/+esm'
10
+ import { compressors, newStats, openFile, parquetRead } from './parquet.js'
11
+ import { imageUrl } from './samples.js'
12
+
13
+ const NOT_BANDS = new Set(['product_id', 'grid_cell', 'product_datetime', 'thumbnail', 'compressed'])
14
+
15
+ /** The views offered for a dataset: composites first, then every band of its image files. */
16
+ export function views(name, bands) {
17
+ const composites = {
18
+ S2L2A: [['True colour (B04, B03, B02)', ['B04', 'B03', 'B02']], ['False colour (B08, B04, B03)', ['B08', 'B04', 'B03']],
19
+ ['SWIR (B12, B8A, B04)', ['B12', 'B8A', 'B04']]],
20
+ S1RTC: [['VV, VH, VV/VH (dB)', ['vv', 'vh', 'ratio']]],
21
+ DEM: [['Elevation (colour)', ['DEM']]],
22
+ }
23
+ composites.S2L1C = composites.S2L2A
24
+ return [...(composites[name] ?? []).map(([label, use]) => ({ id: `rgb:${use.join(',')}`, label, use })),
25
+ ...bands.map(b => ({ id: b, label: b, use: [b] }))]
26
+ }
27
+
28
+ const raw = new Map() // "<url>#<row group>/<band>" -> GeoTIFF bytes
29
+
30
+ /** The band columns of a sample's image file (every column holding a raster). */
31
+ export async function bandNames(info, sample) {
32
+ const { metadata } = await openFile(imageUrl(info, sample.part), newStats())
33
+ return metadata.schema.slice(1).map(c => c.name).filter(n => !NOT_BANDS.has(n))
34
+ }
35
+
36
+ /** The GeoTIFF bytes of `bands` of one sample (read once, then kept): {name: Uint8Array}, and the read's stats. */
37
+ export async function readBands(info, sample, bands) {
38
+ const stats = newStats(), url = imageUrl(info, sample.part), key = b => `${url}#${sample.parquet_row}/${b}`
39
+ const missing = bands.filter(b => !raw.has(key(b)))
40
+ if (missing.length) {
41
+ const { file, metadata } = await openFile(url, stats)
42
+ let rowStart = 0
43
+ for (let g = 0; g < sample.parquet_row; g++) rowStart += Number(metadata.row_groups[g].num_rows)
44
+ await parquetRead({ file, metadata, compressors, columns: missing, rowStart, rowEnd: rowStart + 1, rowFormat: 'object',
45
+ utf8: false, onComplete(rows) { for (const b of missing) raw.set(key(b), rows[0][b]) } })
46
+ }
47
+ stats.read = performance.now() - stats.t0
48
+ return [Object.fromEntries(bands.map(b => [b, raw.get(key(b))])), stats]
49
+ }
50
+
51
+ /** Values of one GeoTIFF: {width, height, values (typed array), noData}. */
52
+ async function decode(bytes) {
53
+ const tiff = await fromArrayBuffer(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength))
54
+ const image = await tiff.getImage(), [values] = await image.readRasters()
55
+ return { width: image.getWidth(), height: image.getHeight(), values, noData: image.getGDALNoData() }
56
+ }
57
+
58
+ /** 2nd and 98th percentile of the finite values (and not noData), from a sample of 100,000 pixels. */
59
+ function stretch(values, noData) {
60
+ const step = Math.max(1, Math.floor(values.length / 100000)), picked = []
61
+ for (let i = 0; i < values.length; i += step) {
62
+ const v = values[i]
63
+ if (Number.isFinite(v) && v !== noData) picked.push(v)
64
+ }
65
+ if (!picked.length) return [0, 1]
66
+ picked.sort((a, b) => a - b)
67
+ const lo = picked[Math.floor(picked.length * 0.02)], hi = picked[Math.floor(picked.length * 0.98)]
68
+ return hi > lo ? [lo, hi] : [lo, lo + 1]
69
+ }
70
+
71
+ const dB = v => (v > 0 ? 10 * Math.log10(v) : NaN)
72
+ const TERRAIN = [[0, [26, 102, 51]], [0.3, [127, 176, 82]], [0.55, [222, 207, 140]], [0.8, [153, 102, 51]], [1, [250, 250, 250]]]
73
+
74
+ function terrain(t) {
75
+ for (let k = 1; k < TERRAIN.length; k++) {
76
+ const [t1, c1] = TERRAIN[k], [t0, c0] = TERRAIN[k - 1]
77
+ if (t <= t1) { const f = (t - t0) / (t1 - t0); return c0.map((c, i) => c + f * (c1[i] - c)) }
78
+ }
79
+ return TERRAIN.at(-1)[1]
80
+ }
81
+
82
+ /** One view of a sample as a PNG object URL: {src, size, values (single band: for the cursor), stats}. */
83
+ export async function render(info, sample, view) {
84
+ const needed = [...new Set(view.use.flatMap(b => (b === 'ratio' ? ['vv', 'vh'] : [b])))]
85
+ const [bytes, stats] = await readBands(info, sample, needed)
86
+ const bands = Object.fromEntries(await Promise.all(needed.map(async b => [b, await decode(bytes[b])])))
87
+ const size = Math.max(...needed.map(b => bands[b].width))
88
+ const s1 = 'vv' in bands, at = (band, x, y) => { // value at composite pixel (x, y), nearest
89
+ const k = band.width / size
90
+ return band.values[Math.floor(y * k) * band.width + Math.floor(x * k)]
91
+ }
92
+ const channel = name => {
93
+ if (name === 'ratio') {
94
+ const vv = bands.vv, vh = bands.vh, out = new Float32Array(size * size)
95
+ for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) out[y * size + x] = dB(at(vv, x, y)) - dB(at(vh, x, y))
96
+ return { width: size, values: out, noData: null }
97
+ }
98
+ const b = bands[name]
99
+ if (!s1) return b
100
+ const out = new Float32Array(b.values.length) // S1: linear backscatter -> dB
101
+ for (let i = 0; i < out.length; i++) out[i] = dB(b.values[i])
102
+ return { width: b.width, values: out, noData: null }
103
+ }
104
+ const chans = view.use.map(channel), own = chans.map(c => stretch(c.values, c.noData))
105
+ // S2 composites: one stretch for the three bands (reflectances on one scale), so colours keep their balance;
106
+ // S1: each channel its own (VV, VH and their ratio are different quantities)
107
+ const joint = !s1 && chans.length === 3 ? [Math.min(...own.map(r => r[0])), Math.max(...own.map(r => r[1]))] : null
108
+ const ranges = joint ? own.map(() => joint) : own
109
+ const canvas = new OffscreenCanvas(size, size), ctx = canvas.getContext('2d'), img = ctx.createImageData(size, size)
110
+ const elevation = view.id === 'rgb:DEM'
111
+ for (let y = 0; y < size; y++) {
112
+ for (let x = 0; x < size; x++) {
113
+ const o = (y * size + x) * 4
114
+ const t = chans.map((c, k) => {
115
+ const v = at(c, x, y), [lo, hi] = ranges[k]
116
+ return Number.isFinite(v) && v !== c.noData ? Math.min(Math.max((v - lo) / (hi - lo), 0), 1) : NaN
117
+ })
118
+ if (t.some(Number.isNaN)) { img.data[o + 3] = 0; continue }
119
+ const rgb = elevation ? terrain(t[0]) : chans.length === 1 ? [t[0] * 255, t[0] * 255, t[0] * 255] : t.map(v => v * 255)
120
+ img.data[o] = rgb[0]; img.data[o + 1] = rgb[1]; img.data[o + 2] = rgb[2]; img.data[o + 3] = 255
121
+ }
122
+ }
123
+ ctx.putImageData(img, 0, 0)
124
+ const src = URL.createObjectURL(await canvas.convertToBlob({ type: 'image/png' }))
125
+ const single = view.use.length === 1 && !elevation ? chans[0] : elevation ? bands.DEM : null
126
+ stats.read = performance.now() - stats.t0
127
+ return { src, size, values: single && { width: single.width, values: single.values, unit: s1 ? 'dB' : elevation ? 'm' : '' }, stats }
128
+ }
download.js ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Downloads of the selected sample: the bands exactly as stored (GeoTIFF bytes from the image file, not re-encoded).
2
+ //
3
+ // one band -> <cell>_<dataset>_<time>_<band>.tif
4
+ // all bands -> <cell>_<dataset>_<time>.zip: every band's .tif, the thumbnail, metadata.json (where it came from)
5
+ // Python -> a snippet that reads the same row group from Hugging Face (copied to the clipboard)
6
+
7
+ import { zipSync } from 'https://cdn.jsdelivr.net/npm/fflate@0.8.2/+esm'
8
+ import { bandNames, readBands } from './bands.js'
9
+ import { imageUrl } from './samples.js'
10
+
11
+ const stem = (cell, name, sample) => [cell, name, sample.timestamp].filter(Boolean).join('_')
12
+
13
+ function save(blob, filename) {
14
+ const a = document.createElement('a')
15
+ a.href = URL.createObjectURL(blob); a.download = filename
16
+ document.body.append(a); a.click(); a.remove()
17
+ setTimeout(() => URL.revokeObjectURL(a.href), 10_000)
18
+ }
19
+
20
+ /** One band's GeoTIFF, as stored. */
21
+ export async function downloadBand(info, name, cell, sample, band) {
22
+ const [bytes, stats] = await readBands(info, sample, [band])
23
+ save(new Blob([bytes[band]], { type: 'image/tiff' }), `${stem(cell, name, sample)}_${band}.tif`)
24
+ return stats
25
+ }
26
+
27
+ /** Every band, the thumbnail and metadata.json, in one zip (stored, not recompressed: the GeoTIFFs are LZW). */
28
+ export async function downloadAll(info, name, cell, sample, thumbnailSrc, productId) {
29
+ const bands = await bandNames(info, sample)
30
+ const [bytes, stats] = await readBands(info, sample, bands)
31
+ const base = stem(cell, name, sample), entries = {}
32
+ for (const b of bands) entries[`${base}_${b}.tif`] = bytes[b]
33
+ const thumb = new Uint8Array(await (await fetch(thumbnailSrc)).arrayBuffer())
34
+ const png = thumb[0] === 0x89 && thumb[1] === 0x50
35
+ entries[`${base}_thumbnail.${png ? 'png' : 'jpg'}`] = thumb
36
+ const meta = { grid_cell: cell, dataset: `Major-TOM/Core-${name}`, product_id: productId, timestamp: sample.timestamp ?? null,
37
+ cloud_cover: sample.cloud_cover ?? null, orbit_state: sample.orbit_state ?? null, epsg: sample.epsg,
38
+ centre_lat: sample.centre_lat, centre_lon: sample.centre_lon, source_file: imageUrl(info, sample.part),
39
+ row_group: sample.parquet_row, bands }
40
+ entries['metadata.json'] = new TextEncoder().encode(JSON.stringify(meta, null, 1) + '\n')
41
+ save(new Blob([zipSync(entries, { level: 0 })], { type: 'application/zip' }), `${base}.zip`)
42
+ return stats
43
+ }
44
+
45
+ /** Python that reads the same sample from Hugging Face (one row group of the image file). */
46
+ export function pythonSnippet(info, name, cell, sample, bands) {
47
+ return `import fsspec
48
+ import pyarrow.parquet as pq
49
+
50
+ url = "${imageUrl(info, sample.part)}"
51
+ with fsspec.open(url, "rb") as f: # reads only the parts of the file needed
52
+ row = pq.ParquetFile(f).read_row_group(${sample.parquet_row}, columns=${JSON.stringify(bands)})
53
+ # ${cell}, Major-TOM/Core-${name}: each band is a GeoTIFF, e.g.
54
+ # from rasterio.io import MemoryFile; MemoryFile(row["${bands[0]}"][0].as_py()).open().read(1)
55
+ `
56
+ }
index.html CHANGED
@@ -87,10 +87,15 @@
87
  .image-actions button { font: inherit; padding: 4px 10px; border: 1px solid var(--line); border-radius: 8px;
88
  background: var(--surface); color: var(--ink); cursor: pointer; }
89
  .image-actions button:hover { border-color: var(--mt-blue); color: var(--mt-blue); }
 
90
  .image-actions label { display: flex; align-items: center; gap: 6px; color: var(--ink-2); cursor: pointer; }
91
  .image-actions input[type=checkbox], .image-actions input[type=range] { accent-color: var(--mt-blue); }
92
  .image-actions input[type=range] { width: 90px; }
93
  .image-actions[hidden] { display: none; }
 
 
 
 
94
  #lightbox { position: fixed; inset: 0; z-index: 10; background: rgba(17,24,39,.88); overflow: hidden; cursor: grab;
95
  touch-action: none; }
96
  #lightbox[hidden] { display: none; }
@@ -174,12 +179,18 @@
174
  <div class="pick-head"><h2>Selected cell</h2><button id="pick-clear" class="link">Clear</button></div>
175
  <div class="pick-name"><b id="pick-cell"></b><span id="pick-pos"></span></div>
176
  <div id="choices"></div>
 
177
  <div id="thumb-wrap"><img id="thumb" alt="Thumbnail of the selected sample" hidden><span id="thumb-note"></span></div>
178
  <div class="image-actions" id="image-actions" hidden>
179
  <button id="open-image">Open</button>
180
  <label><input type="checkbox" id="on-map"> Show on map</label>
181
  <label>Opacity <input type="range" id="opacity" min="0" max="100" value="100" aria-label="Opacity on the map"></label>
182
  </div>
 
 
 
 
 
183
  <dl id="pick-meta"></dl>
184
  <div class="tiny" id="pick-stats"></div>
185
  </section>
@@ -214,7 +225,7 @@
214
 
215
  <div id="lightbox" hidden role="dialog" aria-label="Selected image">
216
  <img id="lightbox-img" alt="">
217
- <div id="lightbox-bar"><span id="lightbox-title"></span><span>scroll to zoom · drag to pan · Esc to close
218
  <button id="lightbox-close">Close</button></span></div>
219
  </div>
220
 
@@ -223,7 +234,7 @@
223
  // carry the signature are let through there: modules imported by other modules would not. So every file of the app
224
  // is asked for with it (an import map, then app.js). With no signature (public Space, local) nothing changes.
225
  {
226
- const sign = location.search, files = ['app.js', 'footprints.js', 'samples.js', 'parquet.js', 'tiles.js', 'labels.js']
227
  const url = file => new URL(file, location.href).href.split('?')[0]
228
  const map = document.createElement('script')
229
  map.type = 'importmap'
 
87
  .image-actions button { font: inherit; padding: 4px 10px; border: 1px solid var(--line); border-radius: 8px;
88
  background: var(--surface); color: var(--ink); cursor: pointer; }
89
  .image-actions button:hover { border-color: var(--mt-blue); color: var(--mt-blue); }
90
+ .image-actions button:disabled { opacity: .45; cursor: not-allowed; border-color: var(--line); color: var(--ink); }
91
  .image-actions label { display: flex; align-items: center; gap: 6px; color: var(--ink-2); cursor: pointer; }
92
  .image-actions input[type=checkbox], .image-actions input[type=range] { accent-color: var(--mt-blue); }
93
  .image-actions input[type=range] { width: 90px; }
94
  .image-actions[hidden] { display: none; }
95
+ .view-row { display: flex; align-items: center; gap: 8px; margin: 0 0 8px; font-size: 13px; color: var(--ink-2); }
96
+ .view-row select { flex: 1; padding: 5px 8px; border: 1px solid var(--line); border-radius: 8px; background: var(--surface);
97
+ color: var(--ink); font: 13px var(--sans); }
98
+ .view-row[hidden] { display: none; }
99
  #lightbox { position: fixed; inset: 0; z-index: 10; background: rgba(17,24,39,.88); overflow: hidden; cursor: grab;
100
  touch-action: none; }
101
  #lightbox[hidden] { display: none; }
 
179
  <div class="pick-head"><h2>Selected cell</h2><button id="pick-clear" class="link">Clear</button></div>
180
  <div class="pick-name"><b id="pick-cell"></b><span id="pick-pos"></span></div>
181
  <div id="choices"></div>
182
+ <label class="view-row" id="view-row" hidden>View <select id="view" aria-label="Band or composite"></select></label>
183
  <div id="thumb-wrap"><img id="thumb" alt="Thumbnail of the selected sample" hidden><span id="thumb-note"></span></div>
184
  <div class="image-actions" id="image-actions" hidden>
185
  <button id="open-image">Open</button>
186
  <label><input type="checkbox" id="on-map"> Show on map</label>
187
  <label>Opacity <input type="range" id="opacity" min="0" max="100" value="100" aria-label="Opacity on the map"></label>
188
  </div>
189
+ <div class="image-actions" id="download-actions" hidden>
190
+ <button id="dl-band" title="The shown band as stored: a GeoTIFF">Download band (.tif)</button>
191
+ <button id="dl-all" title="Every band as stored, the thumbnail and metadata.json">All bands (.zip)</button>
192
+ <button id="dl-python" title="Python that reads this sample from Hugging Face">Copy Python</button>
193
+ </div>
194
  <dl id="pick-meta"></dl>
195
  <div class="tiny" id="pick-stats"></div>
196
  </section>
 
225
 
226
  <div id="lightbox" hidden role="dialog" aria-label="Selected image">
227
  <img id="lightbox-img" alt="">
228
+ <div id="lightbox-bar"><span><span id="lightbox-title"></span> <span id="lightbox-value"></span></span><span>scroll to zoom · drag to pan · Esc to close
229
  <button id="lightbox-close">Close</button></span></div>
230
  </div>
231
 
 
234
  // carry the signature are let through there: modules imported by other modules would not. So every file of the app
235
  // is asked for with it (an import map, then app.js). With no signature (public Space, local) nothing changes.
236
  {
237
+ const sign = location.search, files = ['app.js', 'footprints.js', 'samples.js', 'bands.js', 'download.js', 'parquet.js', 'tiles.js', 'labels.js']
238
  const url = file => new URL(file, location.href).href.split('?')[0]
239
  const map = document.createElement('script')
240
  map.type = 'importmap'
parquet.js CHANGED
@@ -1,6 +1,7 @@
1
  // Remote Parquet files over HTTP range requests, with the bytes, requests and network time each read costs
2
  // (as climate-globe/data.js).
3
 
 
4
  export { parquetMetadataAsync, parquetRead } from 'https://cdn.jsdelivr.net/npm/hyparquet@1.31.4/+esm'
5
  export { compressors } from 'https://cdn.jsdelivr.net/npm/hyparquet-compressors@1.1.2/+esm'
6
 
@@ -29,11 +30,27 @@ export async function countedFile(url, stats, tailSize = TAIL) {
29
  const range = r.headers.get('content-range') // "bytes a-b/size"; absent if whole
30
  const byteLength = range ? Number(range.split('/')[1]) : tail.byteLength
31
  const tailStart = byteLength - tail.byteLength
32
- return {
33
- byteLength,
34
  async slice(start, end = byteLength) {
35
  if (start >= tailStart) return tail.slice(start - tailStart, end - tailStart)
36
- return (await rangeGet(url, `${start}-${end - 1}`, stats))[1]
37
  },
38
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  }
 
1
  // Remote Parquet files over HTTP range requests, with the bytes, requests and network time each read costs
2
  // (as climate-globe/data.js).
3
 
4
+ import { parquetMetadataAsync } from 'https://cdn.jsdelivr.net/npm/hyparquet@1.31.4/+esm'
5
  export { parquetMetadataAsync, parquetRead } from 'https://cdn.jsdelivr.net/npm/hyparquet@1.31.4/+esm'
6
  export { compressors } from 'https://cdn.jsdelivr.net/npm/hyparquet-compressors@1.1.2/+esm'
7
 
 
30
  const range = r.headers.get('content-range') // "bytes a-b/size"; absent if whole
31
  const byteLength = range ? Number(range.split('/')[1]) : tail.byteLength
32
  const tailStart = byteLength - tail.byteLength
33
+ const file = {
34
+ byteLength, stats, // stats: the read being counted, set again by openFile
35
  async slice(start, end = byteLength) {
36
  if (start >= tailStart) return tail.slice(start - tailStart, end - tailStart)
37
+ return (await rangeGet(url, `${start}-${end - 1}`, file.stats))[1]
38
  },
39
  }
40
+ return file
41
+ }
42
+
43
+ const opened = new Map() // URL -> {file, metadata}: a file's footer is read once per page
44
+
45
+ /** A remote file and its metadata, opened once; later reads are counted in `stats`. */
46
+ export async function openFile(url, stats) {
47
+ if (!opened.has(url)) {
48
+ opened.set(url, (async () => {
49
+ const file = await countedFile(url, stats)
50
+ return { file, metadata: await parquetMetadataAsync(file, { initialFetchSize: TAIL }) }
51
+ })().catch(e => { opened.delete(url); throw e }))
52
+ }
53
+ const found = await opened.get(url)
54
+ found.file.stats = stats
55
+ return found
56
  }
samples.js CHANGED
@@ -1,21 +1,18 @@
1
  // One Core sample from its image file on Hugging Face: the row group that holds it, thumbnail column only.
2
  //
3
  // footprint row (part, parquet_row) -> <images>/part_NNNNN.parquet (one row group per sample)
4
- // -> footer (kept per file: 0.2-1.5 MB) -> that row group's thumbnail (and product_id where the file has it)
5
  // -> an object URL for <img>
6
 
7
- import { compressors, countedFile, newStats, parquetMetadataAsync, parquetRead, TAIL } from './parquet.js'
8
-
9
- const footers = new Map() // image file URL -> its metadata, after the first read
10
 
11
  export const imageUrl = (info, part) => `${info.images}part_${String(part).padStart(5, '0')}.parquet`
12
 
13
  /** The thumbnail of one sample: {src (object URL), productId, url, stats}. */
14
  export async function loadThumbnail(info, sample) {
15
  const stats = newStats(), url = imageUrl(info, sample.part)
16
- const file = await countedFile(url, stats, footers.has(url) ? 8 : TAIL) // 8 bytes: the size only
17
- if (!footers.has(url)) footers.set(url, await parquetMetadataAsync(file, { initialFetchSize: TAIL }))
18
- const metadata = footers.get(url), names = metadata.schema.slice(1).map(c => c.name)
19
  let rowStart = 0
20
  for (let g = 0; g < sample.parquet_row; g++) rowStart += Number(metadata.row_groups[g].num_rows)
21
  const columns = ['thumbnail', ...(names.includes('product_id') ? ['product_id'] : [])]
 
1
  // One Core sample from its image file on Hugging Face: the row group that holds it, thumbnail column only.
2
  //
3
  // footprint row (part, parquet_row) -> <images>/part_NNNNN.parquet (one row group per sample)
4
+ // -> footer (read once per file, parquet.js openFile: 0.2-1.5 MB) -> that row group's thumbnail (and product_id where the file has it)
5
  // -> an object URL for <img>
6
 
7
+ import { compressors, newStats, openFile, parquetRead } from './parquet.js'
 
 
8
 
9
  export const imageUrl = (info, part) => `${info.images}part_${String(part).padStart(5, '0')}.parquet`
10
 
11
  /** The thumbnail of one sample: {src (object URL), productId, url, stats}. */
12
  export async function loadThumbnail(info, sample) {
13
  const stats = newStats(), url = imageUrl(info, sample.part)
14
+ const { file, metadata } = await openFile(url, stats) // footer read once per file
15
+ const names = metadata.schema.slice(1).map(c => c.name)
 
16
  let rowStart = 0
17
  for (let g = 0; g < sample.parquet_row; g++) rowStart += Number(metadata.row_groups[g].num_rows)
18
  const columns = ['thumbnail', ...(names.includes('product_id') ? ['product_id'] : [])]