Bands, composites and downloads
Browse files- README.md +8 -1
- app.js +111 -14
- bands.js +128 -0
- download.js +56 -0
- index.html +13 -2
- parquet.js +20 -3
- 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 |
-
| `
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
| 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}
|
| 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 |
-
|
|
|
|
| 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
|
| 356 |
function showOnMap() {
|
| 357 |
-
const t = pick.
|
| 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.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 378 |
const img = $('lightbox-img')
|
| 379 |
-
img.src = pick.
|
| 380 |
-
$('lightbox-
|
| 381 |
-
|
|
|
|
| 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 |
-
|
| 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 (
|
| 5 |
// -> an object URL for <img>
|
| 6 |
|
| 7 |
-
import { compressors,
|
| 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
|
| 17 |
-
|
| 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'] : [])]
|